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:
It summarises several lessons from our introductory course. Anyone looking for more thorough explanations or additional context should consider Chapter 5 of that course.
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.
#include <iostream>
void Increment(int Number){ ++Number; }
int main(){
int MyNumber{1};
Increment(MyNumber);
std::cout << MyNumber;
}
1
This function logs 1
as the Increment
function received a copy of MyNumber
. The original value was unchanged.
We can store and pass values 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 << MyNumber;
}
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.
#include <iostream>
int main(){
int MyNumber{1};
int& Ref{MyNumber};
++Ref;
std::cout << MyNumber;
}
2
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”.
References implement two restrictions:
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 something, we need to get it’s address 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. This code will log out 1
:
int main(){
int MyNumber{1};
int* Pointer{&MyNumber};
std::cout << *Pointer;
}
The *
operator has fairly low precedence, so we often need to introduce brackets when we’re combining *
with other operators. The following code will log out 2
:
int main(){
int MyNumber{1};
int* Pointer{&MyNumber};
std::cout << (*Pointer) + 1;
}
&
and *
SyntaxA 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 totally different meaning depending on where it is used in our code:
&
appears after a type, it is part of the type. For example, int&
is a type - specifically a reference to an int
.&
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:
*
appears after a type, it is part of the type. For example, int*
is a type - specifically a pointer to an int
.*
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.When a pointer is pointing to an object, we have the arrow operator, ->
which combines both the dereferencing operator (*
) and the member access operator (.
).
The following code shows two ways of accessing a member variable through a pointer:
Vector MyVector{1.f, 2.f, 3.f};
Vector* Pointer{&MyVector};
std::cout << (*Pointer).x;
std::cout << Pointer->x;
An uninitialized pointer points to a random memory location. This will log out a random memory address:
int* Pointer;
std::cout << Pointer << endl;
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 we can check if a pointer is null at run time. The following code will compile and run successfully, but will not log out anything because nullptr
is falsey:
int* Pointer{nullptr};
if (Pointer) {
std::cout << *Pointer;
}
const
PointersGiven 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 implementations. Here, we demo them using an integer, but it applies to any underlying data type:
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;
const
PointerThe 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;
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;
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 somewhat niche and, if we find ourselves doing this, there’s probably a way we can restructure our code to eliminate this complexity.
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 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.
— First Published
Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games