Understanding Reference and Pointer Types
Learn the fundamentals of references, pointers, and the const
keyword in C++ programming.
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.
Intro to C++ Programming
Become a software engineer with C++. Starting from the basics, we guide you step by step along the way
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
When dealing with more complex types, 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. This isn't a problem with simple types like int
and float
, but with larger types, unnecessary copying has a performance cost.
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 << Pointer;
}
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 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:
struct SomeType {
float x;
float y;
}
int main() {
SomeType SomeObject{1.f, 2.f};
SomeType* Pointer{&SomeObject};
// 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
:
// Initialize a pointer to point at nothing
int* Pointer{nullptr};
// Update an existing pointer to point at nothing
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
Using const
with 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. In the following example, PtrB
points to PtrA
, which points to Number
:
int Number{5};
int* PtrA{&Number};
int** PtrB{&PtrA};
std::cout
<< "PtrB -> " << PtrB << "\n"
<< " -> " << *PtrB << "\n"
<< " -> " << **PtrB;
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.
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
Operator Overloading
Discover operator overloading, allowing us to define custom behavior for operators when used with our custom types