Understanding Reference and Pointer Types

Learn the fundamentals of references, pointers, and the const keyword in C++ programming.
This lesson is part of the course:

Game Dev with SDL2

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

Free, Unlimited Access
Abstract art representing computer programming
Ryan McCombe
Ryan McCombe
Updated

This lesson is a quick introductory tour of references and pointers within C++. It is not intended for those who are entirely new to programming. Rather, the people who may find it useful include:

  • those who have completed our introductory course, but want a quick review
  • those who are already familiar with programming in another language, but are new to C++
  • those who have used C++ in the past, but would benefit from a refresher

It summarises several lessons from our introductory course. Anyone looking for more thorough explanations or additional context should consider completing Chapter 5 of that course.

Previous Course

Intro to Programming with C++

Starting from the fundamentals, become a C++ software engineer, step by step.

Screenshot from Cyberpunk 2077
Screenshot from The Witcher 3: Wild Hunt

By default, variables in C++ are passed by value. That is, when we pass objects into functions, the function receives a copy of that object.

We see an example of this below:

#include <iostream>

void Increment(int Number){
  ++Number;
}

int main(){
  int MyNumber{1};
  Increment(MyNumber);
  std::cout << "Value: "
    << MyNumber;
}
Value: 1

This function logs 1 as the Increment function received a copy of MyNumber. The original value within the main function was unchanged by the function call.

References

We can pass by reference instead. A reference, and the variable it was created from, both point to the same memory location. A reference type has the & character appended to the underlying type. For example, a reference to an int has the type int&:

#include <iostream>

void Increment(int& Number){
  ++Number;
}

int main(){
  int MyNumber{1};
  Increment(MyNumber);
  std::cout << "Value: "
    << MyNumber;
}
Value: 2

Having our functions receive references rather than values is generally preferred where possible. This is because passing by value requires the original variable to be copied, which has a performance impact when dealing with more complex objects.

We’re not restricted to just creating references as part of a function call. We can freely create them as needed. Below, we create a reference to MyNumber, and we then increment MyNumber through that reference:

#include <iostream>

int main(){
  int MyNumber{1};
  int& Ref{MyNumber};
  ++Ref;
  std::cout << "Value: "
    << MyNumber;
}
Value: 2

Constants using const

Variables and parameters can be marked as constants in C++ using the const keyword. This will cause the compiler to throw an error if an attempt is made to modify such a variable:

int main(){
  const int MyInteger{1};
  MyInteger++; 
}

This is most useful when working with references. When we create a function that receives a reference, the developers who call that function are often going to want to know if their variable is going to be modified.

If our function does not modify a parameter, we should mark it as const in our parameter list:

void LogNumber(const int& Number){
  std::cout << Number;
}

If we try to modify a variable we marked as const, the compiler will alert us to the error.

This is also the case if we do something that may indirectly cause the variable to be modified. This can include passing it off by reference to another function that hasn’t marked the parameter as const, or calling a method on the object that isn’t marked as const

To mark methods or operators as const, we include that keyword in our function signatures:

struct Vector {
  float x;
  float y;
  float z;

  // A const method cannot modify the object
  float GetLength() const{
    ++x; 
  }
};

Marking things as const is not required, but is highly desirable if the variable is not intended to be modified. Code that does this correctly is said to be const correct.

Pointers

References implement two restrictions:

  • References must be initialized with a value
  • References cannot be updated to point to something else

These two restrictions simplify our lives, by preventing most of the common pitfalls that occur when dealing with memory addresses. However, sometimes our requirements are more complex, and we need to remove those restrictions. For this, we have pointers.

Pointers have the * character appended to their data type. For example, a pointer to an int has the type of int*:

int* PointerToAnInteger;

Whilst references and the values they point at can be implicitly converted to each other and used in the same way, that’s not the case for pointers. To make a pointer point at an object, we need to get where that object is stored in memory.

We can do that using the unary address-of operator, &:

int main(){
  int MyNumber{1};
  int* Pointer{&MyNumber};
  std::cout << MyNumber;
}

This code will log out a member address, which will look something like 0x7ffe88a53e88

To access the value of a memory address stored in a pointer, we need to dereference it, using the unary * operator:

int main(){
  int MyNumber{1};
  int* Pointer{&MyNumber};
  std::cout << "Value: " << *Pointer; 
}
Value: 1

The * operator has fairly low precedence, so we often need to introduce brackets when we’re combining * with other operators:

int main(){
  int MyNumber{1};
  int* Pointer{&MyNumber};

  std::cout << "Value: " << (*Pointer) + 1;
}
Value: 2

The Confusing & and * Syntax

A large source of confusion when it comes to references and pointers is the overuse of the & and * syntax.

The & syntax is used in both cases and, even though the & symbol is the same, it has a different meaning depending on where it is used in our code:

When & appears after a type: it is part of the type. For example, int& is a type - specifically a reference to an int.

When & appears before a variable: it’s an operator that will get the memory address of that variable. For example, &MyVariable returns the memory address of MyVariable. Memory addresses are pointers, so what it returns is a pointer.

Similarly, the * syntax has multiple meanings depending on how it is used:

When * appears after a type: it is part of the type. For example, int* is a type - specifically a pointer to an int.

When * appears before a variable: it's an operator. Typically, it’s an operator that is used with pointer types, where it’s an instruction to visit the memory location that the pointer is pointing at and return the value that is stored there.

For example, let’s imagine MyVariable is an int* - a pointer to an int. Then, *MyVariable will visit the memory address, and return the int stored there.

The Arrow Operator ->

As an alternative to the dereferencing operator * we can often use the arrow operator, -> instead. We can think of this as being like a combination of the dereferencing operator (*) and the member access operator (.).

The following code shows two ways of accessing a member variable through a pointer. The second option, using the arrow operator, saves a few keystrokes and makes our expressions more readable:

Vector MyVector{1.f, 2.f, 3.f};
Vector* Pointer{&MyVector};

// Option 1:
(*Pointer).x;

// Option 2:
Pointer->x;

Null Pointers

An uninitialized pointer points to a random memory location.

int* Pointer;

This is quite dangerous as if we dereference it, we don’t know what we’re accessing.

Additionally, in a complex program, we cannot easily determine if a pointer is uninitialized.

If we intentionally want a pointer to point to nothing, we should set its value to nullptr:

int* Pointer{nullptr};

Dereferencing a null pointer will result in undefined behavior, but unlike uninitialized pointers, we can check if a pointer is null.

A nullptr is falsey, so if we want to check if a pointer is pointing at something before dereferencing it, we can use a simple conditional statement:

int* Pointer{nullptr};

if (Pointer) {
  std::cout << *Pointer;
} else {
  std::cout << "That was a nullptr";
}
That was a nullptr

const Pointers

Given that references cannot be updated to point at something else, the use of const in that context was perhaps clear - a const reference means the value cannot be modified.

However, with pointers, there’s some additional complexity, because pointers can be updated to point to something else.

So, the use of const in a pointer data type can have three different interpretations. Here, we demo them using an integer, but it applies to any underlying data type:

1. Pointer to a const

The value being pointed at is const, meaning it can’t be modified. But, the pointer can be updated to point at something else:

int x{1};
int y{2};
const int* Pointer{&x};

(*Pointer)++;
Pointer = &y;

2. const Pointer

The pointer can be const, meaning it cannot be updated to point to something else. But, the value it is pointing at can still be modified:

int x{1};
int y{2};

int* const Pointer{&x};

(*Pointer)++;
Pointer = &y;

3. const Pointer to a const

Finally, both the pointer and the value it is pointing to can be marked as const, meaning neither can be modified:

int x{1};
int y{2};

const int* const Pointer{&x};

(*Pointer)++;
Pointer = &y;

Pointers to Pointers

Like any data type, pointers themselves are also stored in memory. As such, we can have pointers to pointers:

int Number{5};
int* PtrA{&Number};
int** PtrB{&PtrA};

std::cout << "   " << PtrB << "\n"
          << "-> " << *PtrB << "\n"
          << "-> " << **PtrB;
0x7ffcacb0e8e0
-> 0x7ffcacb0e8ec
-> 5

This is rarely necessary. If we find ourselves doing this, there’s probably a way we can restructure our code to eliminate this complexity.

Memory Ownership and Smart Pointers

The concept of memory ownership may be familiar to those coming to C++ from other languages. The pointers we covered here are sometimes referred to as raw pointers. They do not implement any form of ownership, and when working with them, memory management is left to us as developers.

The C++ standard library includes smart pointers, which implement a memory ownership model and automated management. We cover smart pointers, and memory management in general, later in this course.

Summary

In this lesson, we explored references and pointers in C++, learning how to work with variables in new ways. Key takeaways:

  • References provide an alternative name for an existing variable, while pointers store memory addresses
  • The const keyword can be used with references and pointers to prevent modification
  • The arrow operator -> is a convenient way to access members through a pointer
  • Null pointers should be used when a pointer intentionally points to nothing
  • Be cautious when working with memory addresses, as they can add bugs and complexity to your code

Was this lesson useful?

Next Lesson

Operator Overloading

Discover operator overloading, allowing us to define custom behavior for operators when used with our custom types
Abstract art representing computer programming
Ryan McCombe
Ryan McCombe
Updated
sdl2-promo.jpg
This lesson is part of the course:

Game Dev with SDL2

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

Free, Unlimited Access
  • 53.GPUs and Rasterization
  • 54.SDL Renderers
sdl2-promo.jpg
This lesson is part of the course:

Game Dev with SDL2

Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games

Free, unlimited access

This course includes:

  • 55 Lessons
  • 100+ Code Samples
  • 91% Positive Reviews
  • Regularly Updated
  • Help and FAQ
Next Lesson

Operator Overloading

Discover operator overloading, allowing us to define custom behavior for operators when used with our custom types
Abstract art representing computer programming
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved