Skip to content

Pointers

Variable Addresses and Pointers

In a program, all data have their storage addresses. During each actual execution of the program, the physical memory locations of variables may vary. However, we can still obtain the memory addresses of data through certain statements during programming.

Addresses are also data. The variable type used to store addresses has a special name, called a "pointer variable," sometimes abbreviated as "pointer."

Size of Pointer Variables

The size of pointer variables differs across environments. On 32-bit machines, addresses are represented by 32-bit binary integers, so a pointer is 4 bytes in size. On 64-bit machines, addresses are represented by 64-bit binary integers, so a pointer becomes 8 bytes in size.

An address is merely a scale-like data. To accommodate different types of data, "pointer variables" also have different types. For example, there can be int-type pointer variables, where the stored address (i.e., the value stored in the pointer variable) corresponds to the starting address of a 32-bit space; there are char-type pointer variables, where the stored address corresponds to the starting address of an 8-bit space.

In fact, users can also declare pointer variables that point to other pointer variables.

If a user defines a structure:

1
2
3
4
5
struct ThreeInt {
  int a;
  int b;
  int c;
};

Then a pointer variable of type ThreeInt corresponds to a space of 3 × 32 = 96 bits.

Declaration and Usage of Pointers

In C/C++, the type of a pointer variable is the type name followed by an asterisk *. For example, the type name of an int-type pointer variable is int*.

We can use the & symbol to obtain the address of a variable.

To access the space corresponding to the address stored in a pointer variable (also called the space pointed to by the pointer), we need to dereference the pointer variable using the * symbol.

1
2
3
4
5
int main() {
  int a = 123;  // a: 123
  int* pa = &a;
  *pa = 321;  // a: 321
}

It is similar for structure variables. To access a member of the structure pointed to by a pointer, we need to dereference the pointer first, then use the . member access operator. However, using the "arrow" operator -> is a more convenient notation.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
struct ThreeInt {
  int a;
  int b;
  int c;
};

int main() {
  ThreeInt x{1, 2, 3}, y{6, 7, 8};
  ThreeInt* px = &x;
  (*px) = y;    // x: {6,7,8}
  (*px).a = 4;  // x: {4,7,8}
  px->b = 5;    // x: {4,5,8}
}

Pointer Arithmetic

Pointer variables can also perform addition and subtraction operations with integers. For an int-type pointer, each increment by 1 shifts the pointed address by 32 bits (i.e., 4 bytes); if incremented by 2, the pointed address shifts by 2 × 32 = 64 bits. Similarly, for a char-type pointer, each increment shifts the pointed address by 8 bits (i.e., 1 byte).

Accessing Arrays Using Pointer Arithmetic

As mentioned earlier, an array is a contiguous block of storage space. In C/C++, directly using the array name yields the starting address of the array.

1
2
3
4
5
6
7
8
9
int main() {
  int a[3] = {1, 2, 3};
  int* p = a;  // p points to a[0]
  *p = 4;      // a: [4, 2, 3]
  p = p + 1;   // p points to a[1]
  *p = 5;      // a: [4, 5, 3]
  p++;         // p points to a[2]
  *p = 6;      // a: [4, 5, 6]
}

When accessing elements in an array via pointers, "pointer arithmetic" is often used—that is, accessing via a base address (the starting address of the array) plus an offset.

We commonly use the [] operator to access an element at a specified offset in an array, such as a[3] or p[4]. This notation is equivalent to performing arithmetic on the pointer and then dereferencing it, i.e., p[4] and *(p + 4) are two equivalent ways of writing.

Null Pointer

Before C++11, C++ used the NULL macro to represent a null pointer constant, similar to C. In C++, NULL was typically implemented as follows:

1
2
// Before C++11
#define NULL 0

Definition of NULL in C

Before C23, C had two definitions of NULL, differing only in type: one was an integer constant expression, and the other was a constant expression cast to void *, but both had the value 0. Compilers could choose either implementation.

The mixing of null pointers and the integer 0 in C++ led to many issues, such as:

1
2
int f(int x);
int f(int* p);

When calling f(NULL), the actual function called is of type int(int) rather than int(int *).

Problems Caused by NULL in C

Compared to C++, NULL caused more severe problems in C due to its two definitions: If a function expecting a pointer receives an integer-defined NULL in a variadic function, it leads to undefined behavior because type conversion is performed when using the passed variadic arguments inside the function, and conversion from integer to pointer type is undefined behavior.1

To address these issues, C++11 introduced the nullptr keyword as a null pointer constant.

C++ specifies that nullptr can be implicitly converted to any pointer type, and the result of such conversion is a null pointer value of that type.

The type of nullptr is std::nullptr_t, called the null pointer type, which may be implemented as:

1
2
3
namespace std {
typedef decltype(nullptr) nullptr_t;
}

Additionally, starting from C++11, the implementation of the NULL macro was changed to:

1
2
// Since C++11
#define NULL nullptr

Improvements to Null Pointer Constants in C

For similar reasons, C23 also introduced nullptr as a null pointer constant and nullptr_t as its type1.

Advanced Usage of Pointers

Using pointers allows program writers to manipulate data anywhere during program execution, without being limited by scope.

Usage of Pointer-Type Parameters

In C/C++, parameters passed when calling functions (procedures) are passed by copy into the subprocess (except for references, which will be introduced later). By default, functions can only return results to the caller via return values. However, if a function wishes to modify external data, or if a structure/class is too large to be copied efficiently, it can pass the address of the external data to access or even modify it inside the function.

The following my_swap function receives two int-type pointers and uses a temporary variable to swap the values of two int variables.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
void my_swap(int *a, int *b) {
  int t;
  t = *a;
  *a = *b;
  *b = t;
}

int main() {
  int a = 6, b = 10;
  my_swap(&a, &b);
  // After the call, the value of variable a in the main function becomes 10, and b becomes 6
}

C++ introduced the concept of references, which are easier to use and safer compared to pointers. For details, refer to C++: References and Differences Between C and C++: Pointers and References.

Dynamic Instantiation

Additionally, programming often involves dynamic memory allocation, meaning the program requests or releases memory from the operating system at runtime. When the program requests memory via operating system interfaces, the operating system returns the address of the allocated space. To use this space, we need to store its address in a pointer variable.

In C++, we use the new operator to allocate memory and the delete operator to release the space pointed to by a pointer.

1
2
3
int* p = new int(1234);
/* ... */
delete p;

The above statement uses the new operator to request a space of int size from the operating system, initializes its value to 1234, and declares an int-type pointer p pointing to this space.

Similarly, new can also be used to create new objects:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class A {
  int a;

 public:
  A(int a_) : a(a_) {}
};

int main() {
  A* p = new A(1234);
  /* ... */
  delete p;
}

As shown, the "new expression" attempts to allocate a space of the corresponding size, constructs the object in that space, and returns the address of the space.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct ThreeInt {
  int a;
  int b;
  int c;
};

int main() {
  ThreeInt* p = new ThreeInt{1, 2, 3};
  /* ... */
  delete p;
}

List Initialization

The {} operator can be used to initialize structures without constructors. Additionally, using the {} operator can unify variable initialization forms. For details, see "list initialization (since C++11)."

Note that when memory allocated with new is no longer needed, it must be released using delete. Do not release the same memory block twice or more. However, using delete on a null pointer nullptr is legal.

Dynamic Array Creation

The new[] operator can also be used to create arrays. In this case, the new[] operator returns the starting address of the array, i.e., the address of the first element, which can be stored in a pointer of the corresponding type. When releasing, the delete[] operator must be used.

1
2
3
size_t element_cnt = 5;
int *p = new int[element_cnt];
delete[] p;

Elements in an array are stored contiguously, meaning p + 1 points to the successor element of p.

Two-Dimensional Arrays

When storing matrix-like data, a "two-dimensional array" data type may be used. Semantically, a two-dimensional array is an array of arrays. Computer memory can be viewed as a long one-dimensional array. To store a two-dimensional array in computer memory, there is the concept of "contiguity."

"Contiguous" means that the end of any row of the two-dimensional array is adjacent in physical address to the start of the next row; in other words, the entire two-dimensional array can be viewed as a one-dimensional array. Otherwise, they are not necessarily physically adjacent.

For a "contiguous" two-dimensional array, a single loop with a continuously incrementing pointer can traverse all data in the array. For a non-contiguous two-dimensional array, since each row is not contiguous, we need to obtain the starting address of a row first, then access the elements in that row.

Storage Method of Two-Dimensional Arrays

This method of storing data by "row" is called row-major order; conversely, data can also be stored by column. Due to the characteristics of computer memory access, accessing contiguous data generally yields higher efficiency. Therefore, the storage method (row-major or column-major) should be chosen based on how the data will likely be used.

Dynamic Creation of Two-Dimensional Arrays

In C/C++, we can declare a two-dimensional array with N rows and M columns using a statement like the one below, where the space is physically contiguous.

Describing Array Dimensions

A more general approach is to use the concept of the nth dimension. For row-major storage, the first dimension length is N, and the second dimension length is M.

1
int a[N][M];

This declaration requires N and M to be constant expressions determinable at compile time.

In C/C++, the first element of an array has index 0, so an expression like a[r][c] represents the element at row r+1, column c+1 in the two-dimensional array a, and we say this element has index (r,c).

However, in practice, the size of a (two-dimensional) array may not be fixed, requiring dynamic memory allocation.

A common method is to declare a one-dimensional array of length N × M and access the element at index (r, c) via the index r * M + c.

1
int* a = new int[N * M];

This method ensures the two-dimensional array is contiguous.

Linear Storage of Arrays at the Physical Level

In fact, data in memory can all be viewed as stored linearly. Therefore, under certain rules, by dynamically allocating space for a one-dimensional array, n-dimensional arrays can be stored on it.

Alternatively, memory can be obtained and used based on the concept of an "array of arrays." An array storing several arrays is actually an array storing the starting addresses of several arrays, i.e., an array storing several pointer variables.

We need a variable to store the starting address of this "array of arrays"—that is, the address of a pointer. This variable is a "pointer to a pointer," sometimes called a "double pointer," such as:

1
int** a = new int*[5];

Next, we need to allocate space for each array:

1
2
3
for (int i = 0; i < 5; i++) {
  a[i] = new int[5];
}

At this point, we have completed memory allocation. To release such memory, a reverse operation is required: first release each array, then release the array storing the starting addresses of these arrays:

1
2
3
4
for (int i = 0; i < 5; i++) {
  delete[] a[i];
}
delete[] a;

Note that a two-dimensional array obtained this way does not guarantee contiguous space.

Another method involves using a "pointer to an array."

Difference Between Array Name and Address of First Element

As mentioned earlier, in C/C++, directly using the array name yields the address of the first element of the array. However, the type of the variable represented by the array name is actually the entire array, not a single element.

1
int main() { int a[5] = {1, 2, 3, 4, 5}; }

Conceptually, the type of identifier a in the code is int[5]; practically, the offset of the address pointed to by a + 1 relative to the address pointed to by a is the length of 5 int variables.

1
2
3
4
5
6
int main() {
  int(*a)[5] = new int[5][5];
  int* p = a[2];
  a[2][1] = 1;
  delete[] a;
}

This method also yields contiguous memory, but a[n] can directly obtain the starting address of the n+1th row, so a[r][c] can be used to access the element at index (r, c).

Since a pointer to an array is also a fixed data type, except for the first dimension, the lengths of other dimensions must be constants determinable at compile time. Otherwise, the compiler cannot translate expressions like a[n] (where a is a pointer to an array).

Pointers to Functions

For an introduction to functions, refer to the C++ Functions chapter.

Simply put, to call a function, one needs to know its parameter types, count, and return type, collectively referred to as the interface type.

Functions can be called via function pointers. Sometimes, several functions share the same interface type. Using function pointers allows dynamic selection of which function to call based on program execution. In other words, without modifying a function itself, only by changing the parameter (function pointer) passed to it, the behavior of that function can be altered.

Suppose we have several binary operation functions for int types, where the function parameters are two ints and the return type is also int. Below is an example using function pointers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>

int (*binary_int_op)(int, int);

int foo1(int a, int b) { return a * b + b; }

int foo2(int a, int b) { return (a + b) * b; }

int main() {
  int choice;
  std::cin >> choice;
  if (choice == 1) {
    binary_int_op = foo1;
  } else {
    binary_int_op = foo2;
  }

  int m, n;
  std::cin >> m >> n;
  std::cout << binary_int_op(m, n);
}

&, *, and Function Pointers

In C, statements like void (*p)() = foo;, void (*p)() = &foo;, void (*p)() = *foo;, void (*p)() = ***foo all yield the same result.

Because functions (like foo) can be implicitly converted to pointers to functions, void (*p)() = foo; is valid.

The & operator can obtain the address of an object, which also applies to functions, so void (*p)() = &foo; is still valid.

Using the * operator on a function pointer yields the function pointed to. For an expression like **foo, *foo yields the function foo, which is then implicitly converted back to a pointer to foo. By extension, **foo ultimately yields a function pointer to foo; users can use as many * as they like, and the result remains the same.

Similarly, when calling, statements like (*p)() and p() are equivalent, and the * operator can be omitted.

Reference: Why do function pointer definitions work with any number of ampersands '&' or asterisks '*'? - stackoverflow.com

The typedef keyword can be used to declare a function pointer type.

1
typedef int (*p_bi_int_op)(int, int);

This allows us to later use the type p_bi_int_op, which is a pointer to a function with "two int parameters and an int return type."

Using std::function can make referencing functions more convenient. (To be continued)

Function pointers can be used to implement "callback functions." (To be continued)

References and Notes