Lab: Pointers

Today’s lab will help you get started wrapping your head around one of C’s trickiest concepts: pointers. Just as we could make values of type int or char before, with pointers we can now create int* (pointer to int) or char* (pointer to char) variables. The values we store in pointer variables are different from the variables we’ve seen before, though. A pointer value is a memory address, and we use it to keep track of where something is stored.

As you complete this lab you will practice reasoning about code that uses pointer variables, using printf to look at the values assigned to pointer variables, and finally, practice using pointers as function To get started, download the starter code for this lab: pointers.tar.gz and run the following terminal commands:

$ cd csc161/labs
$ tar xvzf ~/Downloads/pointers.tar.gz
$ cd pointers
$ code .

A. Pointer Variables

Before we get into complex examples, we’ll start by reading and predicting the behavior of code that uses pointers. The exercises below reference this code fragment:

// Code for exercise 1:
int a = 1;
int b = 2;
int* c = &a;
int* d = &b;
b++;
printf("a: %d, b: %d, *c: %d, *d: %d\n", a, b, *c, *d);

// Code for exercise 2:
*c = *c + 1;
*d = *c + b;
d = c;
printf("a: %d, b: %d, *c: %d, *d: %d\n", a, b, *c, *d);

// Code for exercise 3:
int* e = &a;
int* f = e;
int g = 99;
(*e)++;
e = &g;
(*f)++;
printf("a: %d, b: %d, *c: %d, *d: %d, *e: %d, *f: %d, g: %d\n",
       a, b, *c, *d, *e, *f, g);

Exercises

  1. Read through the first block of code above. Draw a box and arrow diagram (like the ones from the reading) to show how the values change up to the first printf. Then, write down predictions for what the printf will print. Check your predictions by running the first block of code in a new source file (you’ll have to add #include and a main function). If you predicted any incorrectly, update your diagram and explain what you missed.

  2. Follow the same process for the second block of code. Make sure you can explain why the program prints what it does before moving on, especially if your prediction was incorrect.

  3. Repeat the process again, this time for the third block of code.

  4. Optional Challenge: See if you can predict what the block of code below will do. This code uses a double pointer, which is a pointer to a pointer value. Box and arrow diagrams will still work for this example, but double pointers can be tricky.

    int a = 0;
    int b = 1;
    int* p = &a;
    int* q = &b;
    int** r = &p;
    **r = 10;
    *r = q;
    *p = 11;
    printf("a: %d, b: %d, *p: %d, *q: %d, **r: %d\n", a, b, *p, *q, **r);
    

    Compile and run this code to check your predictions.

B. Functions with Pointer Parameters

Most modern languages have a notion of values and references, especially for function parameters. Passing a parameter to a function by value makes the value available during the function’s execution, but the function cannot change the value stored in the caller’s scope. Passing a parameter by reference allows the function to use the value of that parameter, but also to modify its state in a way that will remain after the function returns.

In C, all parameters are passed by value. Pointer parameters are the mechanism we use to pass references to a function; the pointer itself is a value, but the function can use the pointer to reference the location the pointer points to.

The following exercises explore some of the uses for pointer parameters in C, but first let’s look at some of the applications of pointer parameters.

Allowing a Function to Change a Value

The simplest case where we would pass a pointer parameter is when we want a function to be able to change a value for us. Here’s an example function:

void increment(int* n) {
  *n = *n + 1;
}

We would call the function like this:

int i = 5;
increment(&i);
// i is now 6

increment(&i);
// i is now 7
...

Returning an Error Code and a Value

As with any programming language, things can fail in a C program and we have to deal with failures appropriately. A common way to do this in C is to make a function return an int to indicate whether it succeeded (0) or failed (-1 or some other non-zero value). This may seem backwards, but it allows for some easy error checking we’ll see in a moment. First, here’s an example function that uses this pattern:

int divide(int dividend, int divisor, int* quotient) {
  // Check for division by zero
  if (divisor == 0) {
    return -1;
  }
  *quotient = dividend / divisor;
  return 0;
}

We would call the function like this (assume variables x and y are ints):

int answer;
divide = divide(x, y, &answer);
printf("x/y is %d\n", answer);

But what if y is zero? Our code doesn’t check for errors, but it should. We can do that with this updated example:

int answer;
if (divide(x, y, &answer)) {
  printf("Error: attempted to divide by zero.\n");
} else {
  printf("x/y is %d\n", answer);
}

It may seem odd that the function’s main output goes to a parameter instead of its return value, but this is common practice in C (including many standard functions you will use). It may also seem odd that we are allowed to pass a pointer to answer without giving answer a value, but you’ll notice the program doesn’t access the value of answer until after the divide function returns (and only if it succeeds).

The answer parameter, often called an output parameter, allows us to get around C’s limitation that functions can only return one value. You’ll explore this further in the exercises.

Exercises

  1. Create a source file named max.c where you will implement the following function:
    /**
     * Find the larger of two numbers and write the answer to result
     * \param m A number
     * \param n A number
     * \param Output location for the larger of m and n
     */
    void set_max(int m, int n, int* result);
    

    Write a main function to test your implementation on a few different numbers.

  2. Integer division doesn’t just produce a quotient; it gives us a remainder as well. Write a new version of the divide function above that computes both a quotient and a remainder. Do not remove the error check from divide though: your implementation should still return 0 on success and -1 on error.

    Create a new source file, divide.c, to test your implementation. Write a main function that calls divide at least three times: once with parameters that will return an error, once where the answer will have zero remainder, and a third time with a non-zero remainder.

  3. Create a source file name time.c where you will implement the following function:
    /**
     * Take in a total number of seconds and split it up into parts
     * 
     * \param total_seconds The input number of seconds
     * \param days    Output: the number of whole days in total_seconds
     * \param hours   Output: the number of whole hours in total_seconds
     * \param minutes Output: the number of whole minutes in total_seconds
     * \param seconds Output: the remaining seconds
     */
    void split_time(unsigned long total_seconds, unsigned int* days, 
                 unsigned int* hours, unsigned int* minutes,
                 unsigned int* seconds);
    

    This function takes one input parameter and has four output parameters. Seconds and minutes should never be larger than 59 and hours should never be greater than 23.

    Once you’ve completed your implementation, write a main function to test your function on a few values.

  4. Create a source file named exchange.c and implement the exchange function below. The p and q parameters should point to locations that have values assigned. After calling the function, the two variables should have their values swapped. In this case, p and q are both input and output parameters. For example, if we have two variables a set to 543.21 and b set to 123.45 and call exchange(&a, &b) the result should be that a is now 123.45 and b is now 543.21.
    /**
     * Exchange the values in two locations
     * \param p A pointer to one value
     * \param q A pointer to another value
     * \post The values at p and q have been exchanged
     */
    void exchange(double* p, double* q);
    

C. Printing Pointer Values

Just as we did with regular variables before, we can use printf to show the value of a pointer. This will not be the same thing as printing the value a pointer points to (i.e. printing the value we get when we dereference a pointer like this: *p). This can help you check your box and arrow diagrams.

Review the code in perim-area-1.c which computes the perimeter and area of a rectangle, given the lengths of the two sides. You will need to make some modifications to the code before you start the exercises.

Modifications to calculate_perimeter

Insert into calculate_perimeter just before the return statement:

printf("parameter side1:  location: %p, value: %lf\n", &side1, side1);
printf("parameter side2:  location: %p, value: %lf\n", &side2, side2);
printf("local lengthPlusWidth:  location: %p, lengthPlusWidth: %lf\n",
       &lengthPlusWidth, lengthPlusWidth);

Modifications to calculate_area

Insert into calculate_area just before the return statement:

printf("parameter side1:  location: %p, value: %lf\n", &side1, side1);
printf("parameter side2:  location: %p, value: %lf\n", &side2, side2);
printf("local area:       location: %p, value: %lf\n", &area, area);

Modifications to main

Insert into main just before the return statement:

printf("variable length:  location: %p, value: %lf\n", &length, length);
printf("variable width:   location: %p, value: %lf\n", &width, width);
printf("variable perim:   location: %p, value: %lf\n", &perimeter, perimeter);
printf("variable area:    location: %p, value: %lf\n", &area, area);

The %p format string tells printf to expect a pointer type as its parameter. The value of a pointer is a memory address. Memory addresses—like just about everything else in computing—are represented as numbers. But we should not assume a pointer will fit in an int, nor should we be casting things to and from pointer types (at least not until you take CSC 211 or CSC 213).

Rather than displaying the address as a decimal number, the value is printed in hexadecimal, where each position in the number is a power of 16 (instead of the usual power of 10). Hex values are typically preceded by the 0x prefix; printf adds this prefix automatically when you use the %p format flag.

Note: Modern operating systems will randomize the layout of memory for each program you run, so the pointer values you see when you run the program will not match the example outputs, and they may change from run to run.

Exercises

Don’t run the program with your modifications until you are asked to do so. We’re going to start by making some predictions about the output.

  1. First, predict which of the memory addresses will be the same. Write down any pairs that you think will be exactly equal. For example, if you believe the side1 parameter in calculate_perimeter will be stored in the same location as length in main, write that pair down. Don’t run the program yet; we have more predictions to make!

  2. Next, write down pairs of variables/parameters you believe will be next to each other. For example, if you believe length and width in main will be adjacent, write that pair down.

  3. Recompile and run the program. To interpret the output, suppose that the first printf statement in the calculate_perimeter function produced the output:

    parameter side1:  location: 0x7ff7b8b0f6a8, value: 5.000000
    

    This indicates that the parameter side1 corresponds to memory location 0x7ff7b8b0f6a8, and the value 5.0 is stored there.

    Go back and check your predictions from the two previous exercises. Did you miss any parameters or variables that would share a location? Keep in mind that a double is eight bytes, so two doubles are adjacent if their addresses differ by exactly eight (e.g. 0x7ff7b8b0f6a8 and 0x7ff7b8b0f6b0).

D. Examining Pointer Parameters

Review the code in perim-area-2.c. This program is similar to perim-area-1.c, but has been modified to use output parameters, just as we saw in part B above. Now a single function, compute, will calculate both the perimeter and area of a rectangle.

Exercises

  1. Add printf statements like the ones in part C to show the addresses of local variables and parameters. Make sure you print the memory addresses and values of:
    • length, width, perimeter, and area in main
    • side1, side2, lengthPlusWidth, perimeter, and area in compute

    Some of the values you are printing in compute are already pointers. For these, you should print three things:

    • The address of the parameter (e.g. &perimeter, which is a pointer)
    • The value of the parameter (e.g. perimeter, which is also a pointer)
    • The value the parameter points to (e.g. *perimeter, which is a double)
  2. Make predictions about which values will have the same memory addresses, and which will be adjacent just as you did in part C of the lab. You may find it helpful to draw box and arrow diagrams as you work through this exercise.

  3. Compile and run your program to check your predictions.