References and Pointers
pointers are objects that hold the address of another object. This address can be deferenced using the dereference operator (*) to get the object at that address.
#include <iostream>
int main()
{
int x{5};
std:: cout << x << '\n'; // print the value of variable x
int* ptr{ &x }; // ptr holds the address of x
std::cout << *ptr << '\n'; // use deference operator to print the value of the object at the address that ptr is holding (which is x's address)
return 0;
}
The output is:
5
5
1. Null pointers
Besides a memory address, there is one additional value that a pointer can hold: a null value. A null value (often shortened to null) is a special value that means something has no value. When a pointer is holding a null value, it means the pointer is not pointing at anything. Such a pointer is called a null pointer.
The easiest way to create a null pointer is to use value initialization:
int main()
{
int* ptr {}; // ptr is now a null pointer, and is not holding an address
return 0;
}
Because we can use assignment to change what a pointer is pointing at, a pointer that is initially set to null can later be changed to point at a valid object:
#include <iostream>
int main()
{
int* ptr {}; // ptr is a null pointer, and is not holding an address
int x { 5 };
ptr = &x; // ptr now pointing at object x (no longer a null pointer)
std::cout << *ptr << '\n'; // print value of x through dereferenced ptr
return 0;
}
The nullptr keyword
Much like the keywords true
and false
represent Boolean literal values, the nullptr keyword represents a null pointer literal. We can use nullptr
to explicitly initialize or assign a pointer a null value.
int main()
{
int* ptr { nullptr }; // can use nullptr to initialize a pointer to be a null pointer
int value { 5 };
int* ptr2 { &value }; // ptr2 is a valid pointer
ptr2 = nullptr; // Can assign nullptr to make the pointer a null pointer
someFunction(nullptr); // we can also pass nullptr to a function that has a pointer parameter
return 0;
}
In the above example, we use assignment to set the value of ptr2
to nullptr
, making ptr2
a null pointer.
Dereferencing a null pointer results in undefined behavior
Much like dereferencing a dangling (or wild) pointer leads to undefined behavior, dereferencing a null pointer also leads to undefined behavior. In most cases, it will crash your application.
The following program illustrates this, and will probably crash or terminate your application abnormally when you run it
#include <iostream>
int main()
{
int* ptr {}; // Create a null pointer
std::cout << *ptr << '\n'; // Dereference the null pointer
return 0;
}
Conceptually, this makes sense. Dereferencing a pointer means “go to the address the pointer is pointing at and access the value there”. A null pointer holds a null value, which semantically means the pointer is not pointing at anything. So what value would it access?
Accidentally dereferencing null and dangling pointers is one of the most common mistakes C++ programmers make, and is probably the most common reason that C++ programs crash in practice.
Whenever you are using pointers, you’ll need to be extra careful that your code isn’t dereferencing null or dangling pointers, as this will cause undefined behavior (probably an application crash).
Check for null pointers
#include <iostream>
int main()
{
int x { 5 };
int* ptr { &x };
if (ptr == nullptr) // explicit test for equivalence
std::cout << "ptr is null\n";
else
std::cout << "ptr is non-null\n";
int* nullPtr {};
std::cout << "nullPtr is " << (nullPtr==nullptr ? "null\n" : "non-null\n"); // explicit test for equivalence
return 0;
}
output:
ptr is non-null
nullPtr is null
Use nullptr to avoid dangling pointers
Above, we mentioned that dereferencing a pointer that is either null or dangling will result in undefined behavior. Therefore, we need to ensure our code does not do either of these things.
We can easily avoid dereferencing a null pointer by using a conditional to ensure a pointer is non-null before trying to dereference it:
// Assume ptr is some pointer that may or may not be a null pointer
if (ptr) // if ptr is not a null pointer
std::cout << *ptr << '\n'; // okay to dereference
else
// do something else that doesn't involve dereferencing ptr (print an error message, do nothing at all, etc...)
But what about dangling pointers? Because there is no way to detect whether a pointer is dangling, we need to avoid having any dangling pointers in our program in the first place. We do that by ensuring that any pointer that is not pointing at a valid object is set to nullptr.
That way, before dereferencing a pointer, we only need to test whether it is null -- if it is non-null, we assume the pointer is not dangling.
Favor references over pointers whenever possible
Pointers and references both give us the ability to access some other object indirectly.
Pointers have the additional abilities of being able to change what they are pointing at, and to be pointed at null. However, these pointer abilities are also inherently dangerous: A null pointer runs the risk of being dereferenced, and the ability to change what a pointer is pointing at can make creating dangling pointers easier:
int main()
{
int* ptr { };
{
int x{ 5 };
ptr = &x; // assign the pointer to an object that will be destroyed (not possible with a reference)
} // ptr is now dangling and pointing to invalid object
if (ptr) // condition evaluates to true because ptr is not nullptr
std::cout << *ptr; // undefined behavior
return 0;
}
2. Pointers and const
A pointer to a const value (sometimes called a pointer to const
for short) is a (non-const) pointer that points to a constant value.
To declare a pointer to a const value, use the const
keyword before the pointer’s data type:
int main()
{
const int x{ 5 };
const int* ptr { &x }; // okay: ptr is pointing to a "const int"
*ptr = 6; // not allowed: we can't change a const value
return 0;
}
In the above example, ptr
points to a const int
. Because the data type being pointed to is const, the value being pointed to can’t be changed.
However, because a pointer to const is not const itself (it just points to a const value), we can change what the pointer is pointing at by assigning the pointer a new address:
int main()
{
const int x{ 5 };
const int* ptr { &x }; // ptr points to const int x
const int y{ 6 };
ptr = &y; // okay: ptr now points at const int y
return 0;
}
Just like a reference to const, a pointer to const can point to non-const variables too. A pointer to const treats the value being pointed to as constant, regardless of whether the object at that address was initially defined as const or not:
int main()
{
int x{ 5 }; // non-const
const int* ptr { &x }; // ptr points to a "const int"
*ptr = 6; // not allowed: ptr points to a "const int" so we can't change the value through ptr
x = 6; // allowed: the value is still non-const when accessed through non-const identifier x
return 0;
}
Const pointers
We can also make a pointer itself constant. A const pointer is a pointer whose address can not be changed after initialization.
To declare a const pointer, use the const
keyword after the asterisk in the pointer declaration:
int main()
{
int x{ 5 };
int* const ptr { &x }; // const after the asterisk means this is a const pointer
return 0;
}
Just like a normal const variable, a const pointer must be initialized upon definition, and this value can’t be changed via assignment:
int main()
{
int x{ 5 };
int y{ 6 };
int* const ptr { &x }; // okay: the const pointer is initialized to the address of x
ptr = &y; // error: once initialized, a const pointer can not be changed.
return 0;
}
Const pointer to a const value
Finally, it is possible to declare a const pointer to a const value by using the const
keyword both before the type and after the asterisk:
int main()
{
int value { 5 };
const int* const ptr { &value }; // a const pointer to a const value
return 0;
}
- A
const
before the asterisk is associated with the type being pointed to. Therefore, this is a pointer to a const value, and the value cannot be modified through the pointer. - A
const
after the asterisk is associated with the pointer itself. Therefore, this pointer cannot be assigned a new address.
3. Pass by address
#include <iostream>
#include <string>
void printByValue(std::string val) // The function parameter is a copy of str
{
std::cout << val << '\n'; // print the value via the copy
}
void printByReference(const std::string& ref) // The function parameter is a reference that binds to str
{
std::cout << ref << '\n'; // print the value via the reference
}
void printByAddress(const std::string* ptr) // The function parameter is a pointer that holds the address of str
{
std::cout << *ptr << '\n'; // print the value via the dereferenced pointer
}
int main()
{
std::string str{ "Hello, world!" };
printByValue(str); // pass str by value, makes a copy of str
printByReference(str); // pass str by reference, does not make a copy of str
printByAddress(&str); // pass str by address, does not make a copy of str
return 0;
}
When we pass argument str
by value, the function parameter val
receives a copy of the argument. Because the parameter is a copy of the argument, any changes to the val
are made to the copy, not the orginal argument.
When we pass argument str
by reference, the reference parameter ref
is bound to the actual argument. This avoids making a copy of the argument. Because our reference parameters is const, we are not allowed to change ref
. But if ref
were non-const, any changes we made to ref
would change to str
.
In printByAddress()
function to use pass by address, we've made our function parameter a pointer named ptr
. Since printByAddress()
will use ptr
in a read-only manner, ptr
is a pointer to a const value.
- Inside the
printByAddress()
function, we deferenceptr
parameter to access thevalue of the object being pointed to. - Second, when the function is called, we can't just pass in the
str
object-- we need to pass in the address ofstr
. The easiest way to do that is to use the address-of operator (&) to get the pointer holding the address ofstr
.
Pass by address does not make a copy of the object being pointed to
Pass by address allows the function to modify the argument’s value
When we pass an object by address, the function receives the address of the passed object, which it can access via dereferencing. Because this is the address of the actual argument object being passed (not a copy of the object), if the function parameter is a pointer to non-const, the function can modify the argument via the pointer parameter:
#include <iostream>
void changeValue(int* ptr) // note: ptr is a pointer to non-const in this example
{
*ptr = 6; // change the value to 6
}
int main()
{
int x{ 5 };
std::cout << "x = " << x << '\n';
changeValue(&x); // we're passing the address of x to the function
std::cout << "x = " << x << '\n';
return 0;
}
This prints
x = 5
x = 6
If a function is not supposed to modify the object being passed in, the function parameter can be made a pointer to const:
void changeValue(const int* ptr) // note: ptr is now a pointer to a const
{
*ptr = 6; // error: can not change const value
}
Prefer pass by (const) reference
Note that function print()
in the example above doesn’t handle null values very well -- it effectively just aborts the function. Given this, why allow a user to pass in a null value at all? Pass by reference has the same benefits as pass by address without the risk of inadvertently dereferencing a null pointer.
Pass by const reference has a few other advantages over pass by address.
First, because an object being passed by address must have an address, only lvalues can be passed by address (as rvalues don’t have addresses). Pass by const reference is more flexible, as it can accept lvalues and rvalues:
#include <iostream>
void printByValue(int val) // The function parameter is a copy of the argument
{
std::cout << val << '\n'; // print the value via the copy
}
void printByReference(const int& ref) // The function parameter is a reference that binds to the argument
{
std::cout << ref << '\n'; // print the value via the reference
}
void printByAddress(const int* ptr) // The function parameter is a pointer that holds the address of the argument
{
std::cout << *ptr << '\n'; // print the value via the dereferenced pointer
}
int main()
{
printByValue(5); // valid (but makes a copy)
printByReference(5); // valid (because the parameter is a const reference)
printByAddress(&5); // error: can't take address of r-value
return 0;
}
Best practice
Prefer pass by reference to pass by address unless you have a specific reason to use pass by address.
Pass by address for “optional” arguments
One of the more common uses for pass by address is to allow a function to accept an “optional” argument. This is easier to illustrate by example than to describe:
#include <iostream>
void printIDNumber(const int *id=nullptr)
{
if (id)
std::cout << "Your ID number is " << *id << ".\n";
else
std::cout << "Your ID number is not known.\n";
}
int main()
{
printIDNumber(); // we don't know the user's ID yet
int userid { 34 };
printIDNumber(&userid); // we know the user's ID now
return 0;
}
This example prints:
Your ID number is not known.
Your ID number is 34.
However, in many cases, function overloading is a better alternative to achieve the same result:
#include <iostream>
void printIDNumber()
{
std::cout << "Your ID is not known\n";
}
void printIDNumber(int id)
{
std::cout << "Your ID is " << id << "\n";
}
int main()
{
printIDNumber(); // we don't know the user's ID yet
int userid { 34 };
printIDNumber(userid); // we know the user is 34
printIDNumber(62); // now also works with rvalue arguments
return 0;
}
Changing what a pointer parameter points at
When we pass an address to a function, that address is copied from the argument into the pointer parameter (which is fine, because copying an address is fast).
#include <iostream>
// [[maybe_unused]] gets rid of compiler warnings about ptr2 being set but not used
void nullify([[maybe_unused]] int* ptr2)
{
ptr2 = nullptr; // Make the function parameter a null pointer
}
int main()
{
int x{ 5 };
int* ptr{ &x }; // ptr points to x
std::cout << "ptr is " << (ptr ? "non-null\n" : "null\n");
nullify(ptr);
std::cout << "ptr is " << (ptr ? "non-null\n" : "null\n");
return 0;
}
This program prints:
ptr is non-null
ptr is non-null
As you can see, changing the address held by the pointer parameter had no impact on the address held by the argument (ptr
still points at x
). When function nullify()
is called, ptr2
receives a copy of the address passed in (in this case, the address held by ptr
, which is the address of x
). When the function changes what ptr2
points at, this only affects the copy held by ptr2
.
4. Return by reference and return by address
We encounter a similar situation when returning by value: a copy of the return value is passed back to the caller. If the return type of the function is a class type, this can be expensive.
std::string returnByValue(); // returns a copy of a std::string (expensive)
In cases where we’re passing a class type back to the caller, we may (or may not) want to return by reference instead. Return by reference returns a reference that is bound to the object being returned, which avoids making a copy of the return value. To return by reference, we simply define the return value of the function to be a reference type:
std::string& returnByReference(); // returns a reference to an existing std::string (cheap)
const std::string& returnByReferenceToConst(); // returns a const reference to an existing std::string (cheap)