Pointers and References in C++

Pointers and References in C++

Overview

This article explains the concepts of C/C++ pointers and C++ references. It is aimed at experienced programmers who are not fully familiar with these concepts.

Value Types Versus Reference Types

In Java all primitives are value types and all objects are reference types (AKA pointer types). An int variable contains an actual integer value while a String variable ("reference") contains the memory location of the String object.

In C by comparison, any variable can be either a value type that contains an actual value or a pointer type that contains the memory location of the variable that has the actual value.

Declaring Pointers

Regular variables are declared like this: int a, b, c, d;

Pointers are declared by putting * in front of the name. To make b and d pointer variables we would declare them like this: int a, *b, c, *d; That would mean b and d do not contain regular int values but instead contain the memory locations of int values.

The rules are a little wonky because the types of a and c (for casting or typedef purposes etc.) are int whereas the types of b and d are int*, so really there are two different types of variables being declared in this single declaration statement.

It's common to declare pointer variables and associate the * with the type instead of the variable name, like this: int* b;. I favor this approach myself but just be warned that if we declared int* x, y; then x would be a pointer but y would be a value.

Dereferencing Pointers

Once we've declared a pointer p and assigned a memory location to it, accessing p will retrieve or change the memory address assigned to p while accessing *p will retrieve or change the value stored at that memory location. Writing *p is called dereferencing p.

Here is an example. Let's start off with these declarations:

int a = 1;
int b = 5;
int c = 10;

For argument's sake let's say these variables exist at the following memory locations. In reality we wouldn't know the literal addresses and we'll see how to deal with that later.

Address Type Name Value
400inta1
404intb5
408intc10

If we knew those addresses we could write the following (technically this syntax  as well as later syntax would need additional typecasts to avoid compiler warnings and errors):

int* p = 400;

Now memory might look like this:

Address Type Name Value
400inta1
404intb5
408intc10
412int*p400

Now let's play around with that:

printf( "%d\n", a );      // 1
printf( "%d\n", p );      // 400
printf( "%d\n", *p );     // 1

*p += 2;
printf( "%d\n", a );      // 3
printf( "%d\n", p );      // 400
printf( "%d\n", *p );     // 3

++p;
printf( "%d\n", p );      // 404
printf( "%d\n", *p );     // 5
printf( "%d\n", p+1 );    // 408
printf( "%d\n", *(p+1) ); // 10
++*(p+1);
printf( "%d\n", c );      // 11

In the last section it may be a little surprising when p is 400 and ++p gives 404. When we perform pointer math, adding 1 to or subtracting 1 from a pointer ends up stepping it by the byte size of the type that's being pointed to.

The last section also demonstrates that we can dereference expressions as well as variables. p+1 gives us a memory location and *(p+1) dereferences that memory location to retrieve the value stored there.

Let me point out some caveats and complexities that would prevent the above program from working in the real world as written:

  • Generally local variables are placed in adjacent memory locations.
  • Locals are stored on the stack which grows downwards in memory, so the addresses of the consecutive locations would actually be decreasing.
  • When building in debug mode, the compiler can put padding in-between local variables and the debugger may inspect the padding during or after the program runs. This is in order to check for memory corruption due to logic mistakes in pointer math or pointer accesses.
  • While we can assign a literal address to a pointer (and on some game consoles and old computers we would have to in order to access functionality available at specific memory locations), we would need to cast it to int* first, like this: int* p = (int*) 400;.
  • int is usually still 32 bits on a 64-bit system - sizeof(int)==4. Meanwhile int* and pointers of any type are 64 bits on a 64-bit system, sizeof(int*)==8.
  • To print out a pointer: printf( "%llX\n", (long long) p );. %lld would work as well but X gives uppercase hexadecimal output. The ll (LL)  stands for "long long". In the old days an int tended to be 16 bits, a long int was 32 bits, and a long long int was 64 bits. Now int and long int are usually both 32 bits.

Getting Memory Addresses with &

The address of any variable can be obtained by writing & in front of the variable name. In the previous section we could assign p to be the address of a by writing int* p = &a;

Here is a little program that declares a few variables and prints their memory locations:

#include <cstdio>
using namespace std;

int main()
{
  int a = 3;
  int b = 4;
  int c = 5;
  double d = 6;
  int e = 7;
  printf( "&a:%llX\n", (long long) &a );
  printf( "&b:%llX\n", (long long) &b );
  printf( "&c:%llX\n", (long long) &c );
  printf( "&d:%llX\n", (long long) &d );
  printf( "&e:%llX\n", (long long) &e );
  return 0;
}

// Sample output (the addresses can be different every time):
// &a:7FFEEA2783E8
// &b:7FFEEA2783E4
// &c:7FFEEA2783E0
// &d:7FFEEA2783D8
// &e:7FFEEA2783D4

Pointers and Arrays

Earlier when we modified p to move from pointing to a to b to c, we were using a, b, and c like an array. That's a very unsafe thing to do, but we can apply the same concept to use pointers to access arrays in a safer way.

The following sample shows three ways to print an array that have identical results:

int data[5] = {10,11,12,13,14};

for (int i=0; i<5; ++i) printf( "%d\n", data[i] );

int* ptr = &data[0];
for (int i=0; i<5; ++i) printf( "%d\n", ptr[i] );

ptr = data;
for (int i=0; i<5; ++i) printf( "%d\n", *(ptr++) );

The name of an array is really a pointer, so the type of data is int*.

We don't need to say int* ptr = &data[0];, we can just say int* ptr = data;. If we wanted to set ptr to point to element 2 of the array (value 12), we could write ptr = &data[2]; or even simpler, ptr = data+2. If we did that then ptr[0] would be 12 and ptr[2] would be 14.

Pointers to Pointers

We can have pointers to pointers - in fact we can have "at least 12" levels of pointers to pointers to pointers etc. depending on the compiler. They are most useful for dealing with 2D arrays - and arrays of C strings are a common example of 2D arrays.

Let's revisit the toy example with its fake but convenient addresses to investigate pointer-pointers.

double a = 4.5;
double b = 6.7;
double c = 8.9;
double* p1 = &a;
double* p2 = &c;    

Say the memory looked like this:

Address Type Name Value
400doublea4.5
408doubleb6.7
416doublec8.9
424double*p1400
432double*p2416

Now what if we want a pointer to p1? The type of a pointer to p1 is the type of p1 with another * on the end, so:

double** pp = &p1;
Address Type Name Value
400doublea4.5
408doubleb6.7
416doublec8.9
424double*p1400
432double*p2416
440double**pp424

To play with that:

printf( "%lld\n", (long long) pp );     // 424
printf( "%lld\n", (long long) *pp );    // 400
printf( "%lf\n",  **pp );               // 4.5
printf( "%lld\n", (long long)(*pp+1) ); // 408
printf( "%lf\n",  *(*pp+1) );           // 6.7
printf( "%lld\n", (pp+1) );             // 432
printf( "%lld\n", *(pp+1) );            // 416
printf( "%lf\n",  **(pp+1) );           // 8.9

Arrays of Strings

For a more practical example of using pointers to pointers, let's set up an array of strings:

const char* a = "abc";
const char* b = "xyz";
const char* array[2];
array[0] = a;
array[1] = b;
const char** strings = array;
// Remember an int[] array has type int*, so a char*[] array has 
// type char**.
// There's no need to assign 'array' to 'strings'; we could just keep
// using 'array' after this. Just showing that it can be done.

Here's how that might look in memory, again using a simplified toy memory model:

Address Type Name Value
200char(bytes in memory)97 98 99 0
204char(bytes in memory)120 121 122 0
400char*a200
408char*b204
-char**array416
416char*array[0]200
424char*array[1]204
432char**strings416

Using Pointers with Functions

Attempting to send, return, and receive pointers to and from function calls can be a little confusing at first, especially in terms of when we need stars versus ampersands. One age-old workaround is to use a brute force programming search and "sprinkle stars until it compiles". But let's take some time to understand it here.

Here are some commands that convert Cartesian (x,y) coordinates into Polar (r,theta) coordinates.

double x = 3;
double y = 4;
double r = sqrt( x*x + y*y );
double theta = atan2( y, x );

Say we want to make a function to perform the conversion. We'll send in x and y as regular parameters, and then because functions can only return a single value, we'll send the addresses of where we want r and theta to be stored as two more arguments to be stored in two more pointer parameters.

Before we write that, let's write just convert the code above to use pointers without using a function yet:

double x = 3;
double y = 4;
double r, theta;
double* r_ptr = &r;
double* theta_ptr = &theta;
*r_ptr = sqrt( x*x + y*y );
*theta_ptr = atan2( y, x );
// (r,theta) now contain the converted coordinate

Now in function form:

double x = 3;
double y = 4;
double r, theta;
xy_to_polar( x, y, &r, &theta );
// (r,theta) now contain the converted coordinate

void xy_to_polar( double x, double y, double* r, double* theta )
{
  *r = sqrt( x*x + y*y );
  *theta = atan2( y, x );
}

Dangling Pointers

A dangling pointer is a pointer to a memory location that was originally valid but is now invalid. When we try and dereference that pointer we either get a garbage value, a segmentation fault, or some similar error related to memory corruption.

This usually happens due to either of the following situations:

  1. Having a pointer continue to point to an object or other allocation after the object has been freed, instead of setting the pointer to NULL (AKA zero).
  2. Similarly, having the return value of a function be the address of a local variable within the function. The address of the local is returned but then the stack frame is discarded and that address is overwritten with data from successive function calls.

A nice thing about C and C++ is being able to declare fixed-size objects and arrays as local variables. Local data is placed on the stack, which is a very quick operation that requires no heap allocation, reference counting, or garbage collection. The caveat is that it's easy to forget that a pointer is stack-local and return it. For example:

// Bad Function
char* error_message( int line, const char* message )
{
  char st[256];
  snprintf( st, 256, "Error on line %d: %s", line, message );
  return st;
  // 'st' is a pointer to a local buffer so it will be a dangling,
  // invalid pointer once the function returns.
}

// Good Function
char* error_message( int line, const char* message )
{
  size_t len = strlen(message) + 30;
  char* st = new char[ len ];
  snprintf( st, len, "Error on line %d: %s", line, message );
  return st;
  // No dangling pointer but note that the caller is responsible
  // for eventually delete'ing the returned C string.
}

Function Pointers

C/C++ allows us to have pointers to functions as well as to primitives, arrays, and objects.

Function pointers have an unusual syntax. If an existing function has the signature int fn(int,int) then a pointer called fnptr that can point to any function with the same parameter and return-type signature can be declared with int (*fnptr)(int,int);. So essentially we write the function signature and instead of a function name we write (*ptr_name) and that declares a function pointer variable called ptr_name.

Function pointers are set to point to existing functions with a simple assignment: fnptr = existing_function;. No ampersand is necessary. After that, use regular function-calling syntax to invoke the function pointed to by fnptr: fnptr(a,b,...);.

An example:

int op_add( int a, int b )
{
  return a + b;
}

int op_multiply( int a, int b )
{
  return a * b;
}

void test( int (*op)(int,int), char symbol )
{
  printf( "3 %c 4 = %d\n", symbol, op(3,4) );
}

int main()
{
  int (*add)(int,int) = op_add;

                             // OUTPUT
  test( add, '+' );          // 3 + 4 = 7
  test( op_multiply, '*' );  // 3 * 4 = 12
  return 0;
}

Writing out function signatures every time we need a function pointer is tedious and prone to errors. To simplify things we can create a function pointer typedef using a similar syntax. Here is the above example using a typedef:

typedef int(*Operator)(int,int);

int op_add( int a, int b )
{
  return a + b;
}

int op_multiply( int a, int b )
{
  return a * b;
}

void test( Operator op, char symbol )
{
  printf( "3 %c 4 = %d\n", symbol, op(3,4) );
}

int main()
{
  Operator add = op_add;
                             // OUTPUT
  test( add, '+' );          // 3 + 4 = 7
  test( op_multiply, '*' );  // 3 * 4 = 12
  return 0;
}

C++ References

The pointer-related syntax mentioned up to this point exists in both C and C++.

Our final topic concerns reference variables or just references. This is a syntax that only exists in C++.

References are more or less just pointers that hide most of the implementation details. They are less flexible than pointers but arguably more convenient.

Where int x = 5; declares an integer and int *p = &x; declares a pointer to x, int &r = x; declares a reference to x (also called an alias of x).  Any operation you perform on r is just like performing the operation on x - and that behavior extends into function calls as well.

Here's the Cartesian-to-Polar coordinate conversion rewritten to use references:

double x = 3;
double y = 4;
double r, theta;
xy_to_polar( x, y, r, theta );
// (r,theta) now contain the converted coordinate

void xy_to_polar( double x, double y, double& r, double& theta )
{
  r = sqrt( x*x + y*y );
  theta = atan2( y, x );
}

Here are some other notes on references:

  • Pointers are declared with e.g. int *x = &a; and references are declared with e.g. int &x = a;.
  • It is common to write e.g. int& x = a; instead of int &x = a; because the type of x is int&. But as with pointers, if we wrote int& x=a, y=b; then only x would be a reference and y would be a regular integer.
  • You must assign a reference when you declare it unless it is a function parameter.
  • You cannot have null references like you can have null pointers.
  • You cannot reassign references after their initial assignment.
  • You can take the address of a reference ( &x) and it will return the address of the original variable that the reference is aliased to. So after int& x = a;, &x and &a yield the same memory address.
  • You can return a reference type from a function call ( int& fn(...)) but be careful of the "dangling reference" problem. The return value must be a single variable - it cannot be an expression - and the returned variable cannot be local to the function or else it can become garbage at any time.

Credits

Cover photo by Pixabay on pexels.com