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:
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.
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.
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
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 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
&
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 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.
->
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;
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
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 interpretations. 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 rarely necessary. 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 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.
In this lesson, we explored references and pointers in C++, learning how to work with variables in new ways. Key takeaways:
const
keyword can be used with references and pointers to prevent modification->
is a convenient way to access members through a pointerLearn the fundamentals of references, pointers, and the const
keyword in C++ programming.
Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games