Pointers
This lesson provides a thorough introduction to pointers in C++, covering their definition, usage, and the distinction between pointers and references
Previously, we introduced reference variables. These were a way to, indirectly, have two variables pointing to the same location in memory.
Working with memory addresses is often considered quite dangerous; it can introduce some nasty bugs if not managed correctly.
Because of this, references had two restrictions, designed to ward off the most common cause of those bugs:
- References must be initialized with a value
- References cannot be reassigned
However, sometimes our use case requires us to do one or both of these things. For this reason, we have pointers.
The Address-Of Operator: &
We can get the address of where a variable is stored in memory by using the &
operator. This is referred to as the address-of operator.
#include <iostream>
using namespace std;
int main(){
int x{1};
// Log out the location of x in memory
cout << &x;
}
The above program will output a memory address, which may look something like the following:
0x7ffe88a53e88
Test your Knowledge
The Address-Of Operator
How can we find the memory address used by the isAlive
variable?
bool isAlive { true };
Creating Pointers
What gets returned from the &
operator is referred to as a pointer. It points to a location in memory. A pointer is a value like any other - it can be stored in a variable, stored as member of a class, handed off to a function, and more.
A pointer type includes the underlying type (ie, the type of data being pointed at), and a *
suffix.
For example:
- A pointer to an
int
is anint*
- A pointer to a
bool
is abool*
- A pointer to a
Character
(a user-defined type) is aCharacter*
For example, if we wanted a variable called MyPointer
to store a pointer to an int
, we could declare it like this:
int* MyPointer;
If we use the address-of operator, &
on a variable containing an int
, we will get an int*
- a pointer to an int
We can store that like any other variable:
int x { 1 };
int* MyPointer { &x };
And we can pass them to functions:
void HandlePointer(int* Pointer) {
// ...
}
int main() {
int x{42};
HandlePointer(&x);
}
Test your Knowledge
Creating Pointers
How can we create a variable called FloatPointer
to store a pointer to a float
?
Dereferencing Pointers
With references, we could just work with the reference as if it were the base data type. We could use a reference to an int (an int&
) as if it were an int
.
We can't do that with pointers. To access the value that a pointer points to, we first need to visit the memory address the pointer is pointing at, and get the value stored there. This is referred to as dereferencing the pointer and it is done using the *
operator.
The *
operator returns an object of the underlying type. For example, dereferencing an int*
(a pointer to an int
) will return an int
(a simple int
value)
#include <iostream>
using namespace std;
void HandlePointer(int* Pointer){
int Dereferenced{*Pointer};
cout << "Dereferenced: " << Dereferenced;
}
int main(){
int x{42};
HandlePointer(&x);
}
Dereferenced: 42
Operator Precedence
Previously, we covered how operators have different precedence. When we have multiple operators acting in a single expression, precedence controls what happens first.
For example, in an expression like 1 + 2 * 3
, multiplication happens first.
Precedence rules apply to non-arithmetic operators too, such as the dereferencing operator.
For example, the following code will not compile:
int main() {
int x{42};
int* Pointer(&x);
*Pointer++;
}
This is because the incrementing ++
operator has higher precedence than the dereferencing operator *
, so it happens first. It's equivalent to this:
int main() {
int x{42};
int* Pointer(&x);
*(Pointer++);
}
The dereferencing operator has fairly low precedence in general, so when using it, we will often need to introduce brackets to ensure it happens first:
int main() {
int x{42};
int* Pointer(&x);
(*Pointer)++;
}
Pointers vs References
To put all these concepts together, let's look at how we can get our Increment()
function that we originally implemented with references to use pointers instead. Here's the version using a reference:
1void Increment(int& Number){
2 Number++;
3}
4
5int main(){
6 int x{1};
7 Increment(x);
8}
And here it is using a pointer:
1void Increment(int* Number){
2 (*Number)++;
3}
4
5int main(){
6 int x{1};
7 Increment(&x);
8}
The notable changes here are:
On line 1:
Our function no longer accepts an int&
(a reference to an integer). Instead, it now expects an int*
(a pointer to an integer)
On line 2:
Our function body can no longer treat Number
as an integer. It is now a pointer, so we need to dereference it before accessing or modifying the underlying value
On line 7:
We can no longer just pass an int
into our function and have the compiler implicitly convert it to the correct type for us automatically.
When using a pointer, we need to be more explicit. Therefore, we use the address-of operator &
to ensure our function receives the pointer it expects.
Test your Knowledge
Dereferencing Pointers
What should we put in the body of this function to double the value pointed at by the Number
pointer?
void Double(int* Number) {
// ??
}
The Arrow Operator: ->
When we're working with pointers to our custom types, a common requirement we'll have is to dereference the pointer, and then access one of its members.
The member access operator .
has higher precedence than the dereferencing operator *
, so we need to use brackets here:
void Combat(Monster* Enemy){
(*Enemy).TakeDamage(50);
}
C++ provides an alternative syntax for this, typically called the arrow operator. We can think of it as combining the dereferencing operator *
and the member access operator .
:
void Combat(Monster* Enemy){
Enemy->TakeDamage(50);
}
This is much more common, therefore, it will be our preferred approach going forward.
Note, that we only use the ->
operator with pointers to an object. When we have the actual object by value, or a reference to it, the .
is still used to access its members.
// We use . with objects
Character MyCharacter;
MyCharacter.TakeDamage(50);
// We use . with references
Character& Reference { MyCharacter };
Reference.TakeDamage(50);
// We use -> with pointers
Character* Pointer { &MyCharacter };
Pointer->TakeDamage(50);
Test your Knowledge
Member Access
Given the following code, what could we put on line 7 to call the Equip
function of the Weapon
passed in as a parameter?
class Weapon {
public:
void Equip() {}
};
void PrepareForBattle(Weapon SelectedWeapon) {
// ???
}
Given the following code, what could we put on line 7 to call the Equip
function of the Weapon
passed in as a parameter?
class Weapon {
public:
void Equip() {}
};
void PrepareForBattle(Weapon& SelectedWeapon) {
// ??
}
Given the following code, what could we put on line 7 to call the Equip
function of the Weapon
passed in as a parameter?
class Weapon {
public:
void Equip() {}
};
void PrepareForBattle(Weapon* SelectedWeapon) {
// ??
}
Null Pointers and Uninitialized Variables
When working with pointers, we'll often need a way to represent empty values.
For example, we may be making a game where our player can have a weapon:
class Weapon {};
class Character {
public:
Weapon* mWeapon;
};
To make a pointer point to nothing, representing the absence of a value, we use the nullptr
keyword:
class Weapon {};
class Character {
public:
Weapon* mWeapon{nullptr};
};
We should never attempt to dereference a nullptr
using the *
or ->
operator. If we need to dereference a pointer, and we think it may be a nullptr
, we can first check for that condition using an if
statement:
#include <iostream>
using namespace std;
class Weapon {
public:
string mName{"Iron Sword"};
};
class Character {
public:
Weapon* mWeapon{nullptr};
};
int main(){
Character Player;
Weapon Sword;
if (!Player.mWeapon) {
cout << "I am unarmed";
}
Player.mWeapon = &Sword;
if (Player.mWeapon) {
cout << "\nBut not any more! Behold my "
<< Player.mWeapon->mName;
}
}
I am unarmed
But not any more! Behold my Iron Sword
Finally, let's see a slightly more complex example. Our classes can contain references to other objects of the same class.
For example, a Character
can have a member variable that is a reference or a pointer to another Character
object.
This is the case for the mEnemy
variable within our Character
class below:
#include <iostream>
using namespace std;
class Character {
public:
Character(string Name): mName{Name}{}
void SetEnemy(Character* Enemy){
mEnemy = Enemy;
}
void LogEnemy(){
if (mEnemy) {
cout << "\nEnemy: " << mEnemy->mName;
} else {
cout << "\nI don't have an enemy";
}
}
string mName;
Character* mEnemy{nullptr};
};
int main(){
Character Player{"Anna"};
Player.LogEnemy();
Character Enemy{"Goblin Warrior"};
Player.SetEnemy(&Enemy);
Player.LogEnemy();
Character AnotherEnemy{"Vampire Bat"};
Player.SetEnemy(&AnotherEnemy);
Player.LogEnemy();
}
I don't have an enemy
Enemy: Goblin Warrior
Enemy: Vampire Bat
Uninitialized Variables
A somewhat counterintuitive behaviour of C++ is that pointers are not created in the nullptr
state by default. Assuming an uninitialized pointer is a nullptr
is a common source of bugs.
In the following program, we might expect Ptr
to be empty, but that is not the case:
#include <iostream>
using namespace std;
int main() {
int* Ptr;
if (Ptr) {
cout << "Ptr is NOT a nullptr";
}
}
Ptr is NOT a nullptr
Compilers can typically detect this problem in the simple case, and will issue a warning:
main.cpp:6:7: warning: variable 'Ptr' is uninitialized when used here
If we want a pointer to initially be empty, we need to explicitly initialize it as a nullptr
:
#include <iostream>
using namespace std;
int main() {
int* Ptr{nullptr};
if (!Ptr) {
cout << "Now it is";
}
}
Now it is
Why does C++ Work This Way?
C++ has a "zero overhead" design philosophy. This means that it will not perform unnecessary work unless asked to do so, as unnecessary work has a performance cost.
Visiting a memory address and updating it to store some initial value falls into this category, so it isn't done unless we explicitly ask for it.
If we create a variable without an initial value, the memory address associated with that variable continues to use whatever the previous value happened to be. Using an uninitialized variable results in unpredictable and undefined behavior:
#include <iostream>
using namespace std;
int main() {
int x;
if (x) {
cout << "x is: " << x;
}
}
x is 85
There is no reason we would want to do this, so most compilers can detect and issue warnings in simple cases:
main.cpp:8:25: warning: variable 'x' is uninitialized when used here [-Wuninitialized]
Whilst this undefined behavior is true of fundamental, built-in types like int
and pointers, it's not necessarily true of all types. As we've seen, more complex types can have a default constructor defined as part of their class or struct.
For those types, that default constructor controls what happens when objects are created without initial values.
Summary
In this lesson, we explored the fundamental concepts of pointers. The key points include:
- Understanding the address-of operator (
&
) and how it differs from a reference. - Learning about pointers, their declaration, and how they point to memory locations.
- Grasping the concept of dereferencing pointers to access or modify the value at the memory address they point to.
- Recognizing the importance of operator precedence in expressions involving pointers.
- Comparing pointers and references, understanding their uses, and the advantages of references in certain scenarios.
- Utilizing the arrow operator (
>
) for member access in objects pointed to by pointers. - Handling null pointers using
nullptr
and ensuring safety checks before dereferencing.
The this
Pointer
Learn about the this
pointer in C++ programming, focusing on its application in identifying callers, chaining functions, and overloading operators.