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:
0x7ffe88a53e88Test 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
intis anint* - A pointer to a
boolis 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: 42Operator 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 convenient, so it is almost always preferred over using both * and . operators.
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 SwordFinally, let's see a slightly more complex example. Our classes can contain references and pointers 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 BatUninitialized 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 nullptrCompilers can typically detect this problem in the simple cases, and will issue a warning:
main.cpp:6:7: warning: variable 'Ptr' is uninitialized when used hereIf 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 isWhy 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 location that our variable uses will still contain whatever was previously there. Perhaps that memory location was previously being used for a different variable, or perhaps it was used by a different program entirely.
Either way, we have no idea what is stored there, so 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 85There 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
nullptrand 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.