Pointers

Recall that in C++, an object is a piece of data in memory, and that it is located at some address in memory. The compiler and runtime determine the location of an object when it is created; aside from deciding whether an object is in the global segment, on the stack, or in the heap segment (the segment used for dynamic memory), the programmer generally does not control the exact location where an object is placed [1]. Given the same program and the same inputs to that program, different systems will often end up placing the same objects at different memory locations. In fact, in many implementations, running the same program twice on the same system will result in different addresses for the objects.

Though the programmer does not have control over the address at which an object is located, the programmer does have the ability to query the address on an object once it has been created. In C++, the & (usually pronounced “address-of”) operator can be applied to an object to determine its address:

int main() {
  int x = 3;
  double y = 5.5;
  cout << &x << endl; // sample output: 0x7ffee0659a2c
  cout << &y << endl; // sample output: 0x7ffee0659a20
}

Addresses are usually written in hexadecimal (base-16) notation, with a leading 0x followed by digits in the range 0-9 and a-f, with a representing the value 10, b the value 11, and so on. Most modern machines use 64 bits for an address; since each digit in a hexadecimal number represents four bits (\(2^4 = 16\) values), a 64-bit address requires up to 16 hexadecimal digits. The examples above use only 12 digits, implying that the leading four digits are zeros. Most examples in this text use fewer digits for conciseness.

In addition to being printed, addresses can also be stored in a category of objects called pointers [2]. A pointer variable can be declared by placing a * symbol to the left of the variable name in its declaration:

int x = 3;
int *ptr = &x;
cout << ptr << endlt; // sample output: 0x7ffee0659a2c

A pointer type consists of two elements:

  • the type of the objects whose addresses the pointer can hold as a value. For example, an int * pointer can hold the address of an int, but not that of any other data type.

  • the * symbol, which indicates that the type is a pointer

Each data type in C++ has a corresponding pointer type. For instance, int * is the pointer type corresponding to int, double * is the pointer type corresponding to double, and double ** is the pointer type corresponding to double *.

A pointer object can be dereferenced to obtain the object whose address the pointer is holding by applying the * (usually pronounced “star” or “dereference”) operator:

int x = 3;
int y = 4;
int *ptr = &x;
cout << *ptr << endl; // prints 3
ptr = &y;
cout << *ptr << endl; // prints 4

We often say that a pointer “points to” an object, and that the * operator “follows” the pointer to the object it is pointing at. In keeping with this terminology, a pointer is often pictured as an arrow from the pointer to the object it is pointing at, as shown in Figure 13.

_images/03_pointer_arrow.svg

Figure 13 An arrow indicates the object whose address a pointer holds.

Usually, we are not concerned with actual address values – they are implementation-dependent and can vary between program runs. Instead, we only concern ourselves with which object each pointer is referring to. Thus, we often draw just an arrow to illustrate which object a pointer is pointing to, as in Figure 14, without the actual address value.

_images/03_pointer_no_address.svg

Figure 14 Pointers are often illustrated by just an arrow, without the actual address value.

The following is another example of working with a pointer:

int main() {
  int foo = 1;
  int *bar = &foo;
  foo += 1;
  *bar += 1;

  cout << foo << endl;   // prints 3
  cout << bar << endl;   // prints address of foo (e.g. 0x1000)
  cout << *bar << endl;  // prints 3
}
_images/03_pointers_simple.svg

Figure 15 Example of modifying an object through a pointer.

The code initializes the pointer bar with the address of foo. It proceeds to increment foo directly, then dereferences bar to increment the object it is pointing at. Since it is pointing at the object associated with foo, the result is that foo has value 3. The state of memory at each point is shown in Figure 15.

Pointer Errors

A pointer is an atomic type, since it cannot be subdivided into smaller objects. As with other atomic types, a variable of pointer type that isn’t explicitly initialized is default initialized to an undefined value:

int x = 3;
int *ptr;  // undefined value
ptr = &x;  // well-defined value -- the address of x

Dereferencing a default-initialized pointer results in undefined behavior – the program may crash, or it may not; reading the dereferenced value can result in zero, or some other random value. Undefined behavior is notoriously difficult to debug, as the behavior can be different on different machines or in different runs of the program on the same machine. Tools like Valgrind or an address sanitizer can help detect undefined behavior.

The following is another example of default initializing pointers:

int main() {
  int *x;
  int *y;
  int a = -1;
  x = &a;
  cout << *x << endl;  // prints -1
  *x = 42;
  cout << a << endl;   // prints 42
  *y = 13;             // UNDEFINED BEHAVIOR
}
_images/03_pointers_uninitialized.svg

Figure 16 Dereferencing an uninitialized pointer results in undefined behavior.

Figure 16 illustrates the execution of this code. Both x and y are default initialized to indeterminate values. The code proceeds to assign the address of a into x, so x now has a well-defined value, and it is valid to dereference x. Assigning to the object it is pointing at changes the value of that object, the one named by a. Dereferencing y, on the other hand, produces undefined behavior, since its value is a junk address. The program may crash, or it may not. It may overwrite a memory location that is in use by something else, or some location that is not in use. With undefined behavior, anything is possible.

A null pointer is a pointer that holds an address value of 0x0. No object can be located at that address, making the null value a useful value for a pointer that does not point to a valid object. In C++, the nullptr literal represents the null value, and it can be used with any pointer type:

int *ptr1 = nullptr;
double *ptr2 = nullptr;
cout << (ptr1 == nullptr) << endl;  // prints 1 (i.e. true)

Dereferencing a null pointer also results in undefined behavior. However, in most implementations, doing so will crash the program, which is generally easier to debug than other forms of undefined behavior.

Since a pointer is an object in its own right, it can live past the lifetime of the object it is pointing at. The following is an example:

int * get_address(int x) {
  return &x;
}

void print(int val) {
  cout << val << endl;
}

int main() {
  int a = 3;
  int *ptr = get_address(a);
  print(42);
  cout << *ptr << endl;       // UNDEFINED BEHAVIOR
}
_images/03_pointers_address_of_local.svg

Figure 17 A pointer may refer to a dead object, in which case dereferencing it produces undefined behavior.

In this code, the parameter of the get_address() function is passed by value. So the x parameter is a new object in the activation record for get_address(), and it is initialized as a copy of a. The function returns the address of x, and that value is placed in the ptr object in main(). However, x dies along with the activation record of get_address(), so ptr is now pointing at a dead object. The code then calls print(), and it so happens that its activation record is placed in the same location previously used by get_address(), as shown in Figure 17. At this point, ptr happens to point at the val object, whose value is 42. When the print() function returns, its activation record is also reclaimed. Proceeding to dereference ptr produces undefined behavior. It so happens in this implementation that 42 is printed, but other implementations may have different behavior.

We can fix the code above by passing the parameter to get_address() by reference:

int * get_address(int &x) {
  return &x;
}

void print(int val) {
  cout << val << endl;
}

int main() {
  int a = 3;
  int *ptr = get_address(a);
  print(42);
  cout << *ptr << endl;       // prints 3
}
_images/03_address_of_reference_parameter.svg

Figure 18 Example of taking the address of a reference parameter.

Now x aliases the object a in main(), as shown in Figure 18. Thus, get_address() returns the address of a, which is still alive when *ptr is printed.

Pointers and References

The * and & symbols mean different things when they are used as part of a type and when they are used in an expression:

  • When used in a type, * means that the type is a pointer type, while & means the type is a reference type.

  • When used as a unary prefix operator in an expression, * is used to dereference a pointer, while & is used to obtain the address of an object.

  • When used as a binary infix operator, * is multiplication, while & is a bitwise and operation (which is outside the scope of this course).

The following illustrates some examples:

int x = 3;
int *ptr = &x;
int &ref = *ptr;

In the second line, * is used in a type, so ptr is a pointer to an int. The initialization is an expression, so the & obtains the address of the object corresponding to x. In the third line, & is used as part of the type, so ref is a reference variable. Its initialization is an expression, so the * dereferences ptr to get to the object associated with x. The result is that ref is an alias for the same object as x.

Pointers allow us to work indirectly with objects. The following is an implementation of a swap function that uses pointers to refer to the objects whose values to swap:

void swap_pointed(int *x, int *y) {
  int tmp = *x;
  *x = *y;
  *y = tmp;
}

int main() {
  int a = 3;
  int b = 5;
  swap_pointed(&a, &b);
  cout << a << endl;     // prints 5
  cout << b << endl;     // prints 3
}

Figure 19 demonstrates the execution of this code.

_images/03_pointers_swap.svg

Figure 19 Pointers enable redirection, allowing a function to modify objects in a different scope.

References can also be used to indirectly refer to other objects. However, a pointer actually holds the address of another object, while a reference just acts as another name for an existing object. A reference must be initialized to refer to another object, and the association between the reference and its object cannot be broken. On the other hand, a pointer need not be initialized to point to an object, and the address value it holds can be later changed to be the address of a different object. A pointer can be null or have an undefined value, so it must be used carefully to avoid undefined behavior. On the other hand, while it is possible to construct an invalid reference, it usually does not occur in the normal course of programming in C++.

Both pointers and references allow objects to be used across scopes. They both enable subtype polymorphism, a concept we will see later in the course. Both can also be used to refer to objects in dynamic memory, as we will also see later.

Pointers are strongly connected to arrays. Indexing into an array actually works through pointer arithmetic, as we will see next time.