In previous chapters, we have covered two different ways to pass an argument to a function: pass by value
and pass by reference
.
Here's a simple program that shows a std::string
object being passed by value and by reference:
#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
}
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
return 0;
}
When we pass argument str
by value, the function parameter val
received 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 original 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 parameter is const
, we are not allowed to change ref
. But if ref
were non-const, any changes were made to ref
would change str
.
In both cases, the caller is providing the actual object (str
) to be passed as an argument to the function call.
Pass by Address
C++ provides a third way to pass values to a function, called pass by address, With pass by address
, instead of providing an object as an argument, the caller provides an object's address (via a pointer). This pointer (holding the address of the object) is copied into a pointer parameter of the called function (which now also holds the address of the object). The function can then dereference that pointer to access the object whose address was passed.
Here's a version of the above program that adds a pass by address variant:
#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;
}
First, because we want our printByAddress()
function to use pass by address, we have made our function parameter a pointer named ptr
. Since printByAddress()
will use ptr
in a read-only-member, ptr
is a pointer to a const value.
void printByAddress(const std::string* ptr)
{
std::cout << *ptr << '\n'; // print the value via the dereferenced pointer
}
Inside the printByAddress()
function, we dereference ptr
parameter to access the value 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 of str
. The easiest way to do is to use the address-of operator (&) to get a pointer holding the address of str
:
void printByAddress(const std::string* ptr)
{
std::cout << *ptr << '\n'; // print the value via the dereferenced pointer
}
When this call is executed, &str
will create a pointer holding the address of str
. This address is then copied into function parameter ptr
as part of the function call. Because ptr
now holds the address of str
, when the function dereferences ptr
, it will get the value of str
, which the function prints to the console.
Although we use the address-of operator in the above example to get the address of str
if we already had a pointer variable holding the address of str
, we could use that instead:
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
std::string* ptr { &str }; // define a pointer variable holding the address of str
printByAddress(ptr); // pass str by address, does not make a copy of str
return 0;
}
Pass by address does not make a copy of the object being pointed to
Consider the following statements:
std::string str{ "Hello, world!" };
printByAddress(&str); // use address-of operator (&) to get pointer holding address of str
Copying a std::string
is expensive, so that's something we want to avoid. When we pass a std::string
by address, we are not copying the actual std::string
object – we are just copying the pointer (holding the address of the object) from the caller to the called function. Since an address is typically only 4 or 8 bytes, so copying a pointer is always fast.
Thus, similar to pass by reference, pass by address is fast.
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;
}
// Output
x = 5
x = 6
As you can see, the argument is modified and this modification persists even after changeValue()
has finished running.
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
}
Null Checking
Now consider this program:
#include <iostream>
void print(int* ptr)
{
std::cout << *ptr << '\n';
}
int main()
{
int x{ 5 };
print(&x);
int* myPtr {};
print(myPtr);
return 0;
}
When you run this program, it will print 5 and then most likely crash.
In the call to print(myPtr)
, myPtr
is a null pointer, so function parameter ptr
will also be a null pointer. When this null pointer is dereferenced in the body of the function, undefined behavior results.
When passing a parameter by address, care should be taken to ensure the pointer is not a null pointer before you dereference the value. One way to do that is to use a conditional statement:
#include <iostream>
void print(int* ptr)
{
if (ptr) // if ptr is not a null pointer
{
std::cout << *ptr << '\n';
}
}
int main()
{
int x{ 5 };
print(&x);
print(nullptr);
return 0;
}
In the above program, we are testing ptr
to ensure it is not null before we dereference it. While this is fine for such a simple function, in more complicated functions this can result in redundant logic (testing if ptr is not null multiple times) or nesting of the primary logic of the function (if contained in a block).
In most cases, it is more effective to do the opposite: test whether the function parameter is null as precondition and handles the negative case immediately:
#include <iostream>
void print(int* ptr)
{
if (ptr) // if ptr is not a null pointer
{
std::cout << *ptr << '\n';
}
}
int main()
{
int x{ 5 };
print(&x);
print(nullptr);
return 0;
}
If a null pointer should never be passed to the function, an assert
can be used instead.
#include <iostream>
#include <cassert>
void print(const int* ptr) // now a pointer to a const int
{
assert(ptr); // fail the program in debug mode if a null pointer is passed (since this should never happen)
// (optionally) handle this as an error case in production mode so we don't crash if it does happen
if (!ptr)
return;
std::cout << *ptr << '\n';
}
int main()
{
int x{ 5 };
print(&x);
print(nullptr);
return 0;
}
Prefer pass by (const) reference
Note that function print()
in the above example 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 hava 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;
}
Second, the syntax for pass by reference is natural, as we can just pass in literals or objects. With pass by address, our code ends up littered with ampersands (&) and asterisks (*).
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.
#include <iostream>
#include <string>
void greet(std::string* name=nullptr)
{
std::cout << "Hello ";
std::cout << (name ? *name : "Ajnabee") << '\n';
}
int main()
{
greet(); // we don't know who the user is yet
std::string naam{ "JAAT" };
greet(&naam); // we know the user is joe
return 0;
}
// Output
Hello ajnabee
Hello JAAT
In this program, the greet()
function has one parameter that is passed by address and defaulted to nullptr
. Inside main()
, we call this function twice. The first call, we don't know who the user is, so we call greet()
without an argument. The name
parameter defaults to nullptr
, and the greet function substitutes in the name “ajnabee”. For the second call, we now have a valid user, so we call greet(&naam)
. The name
parameter receives the address of naam
, and ca use it to print the name “JAAT”.
However, in many cases, function overloading is a better alternative to achieve the same result:
#include <iostream>
#include <string>
#include <string_view>
void greet(std::string_view name)
{
std::cout << "Hello " << name << '\n';
}
void greet()
{
greet("ajnabee");
}
int main()
{
greet(); // we don't know who the user is yet
std::string naam{ "JAAT" };
greet(naame); // we know the user
return 0;
}
This has a number of advantages: we no longer have to worry about null dereferences, and we could pass in a string literal if we wanted.
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). Consider the following program:
#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;
}
// Output
ptr is non-null
prt 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, ptr
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 pt2r
points at, this only affects the copy held by ptr2
.
So what if we want to allow a function to change what a pointer argument points to?
Pass by address…. by reference?
Yup, it's a thing. Just like we can pass a normal variable by reference, we can also pass pointers by reference. Here's the same program as above with ptr2
changed to be a reference to an address:
#include <iostream>
void nullify(int*& refptr) // refptr is now a reference to a pointer
{
refptr = 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;
}
// Output
ptr is non-null
ptr is null
Because refptr
is now a reference to a pointer, when ptr
is passed as an argument refptr
is bound to ptr
. This means any changes to refptr
are made to ptr
.
Why using 0 and NULL is no longer preferred
The literal 0 can be interpreted as either an integer literal, or as a null pointer literal. In certain cases, it can be ambiguous which one we intend – and in some of those cases, the compiler may assume we mean one when we mean the other – with unintended consequences to the behavior of our program.
The definition of preprocessor macro NULL
is not defined by the language standard. It can be defined as 0
, 0L
, ((void*)0)
, or something else entirely.
The functions can be overloaded (multiple functions can have the same name, so long as they can differentiated by the number or type of parameters). The compiler can figure out which overloaded function you desire by the arguments passed in as part of the function call.
When using 0
or NULL
, this can cause problems:
#include <iostream>
#include <cstddef> // for NULL
void print(int x) // this function accepts an integer
{
std::cout << "print(int): " << x << '\n';
}
void print(int* ptr) // this function accepts an integer pointer
{
std::cout << "print(int*): " << (ptr ? "non-null\n" : "null\n");
}
int main()
{
int x{ 5 };
int* ptr{ &x };
print(ptr); // always calls print(int*) because ptr has type int* (good)
print(0); // always calls print(int) because 0 is an integer literal (hopefully this is what we expected)
print(NULL); // this statement could do any of the following:
// call print(int) (Visual Studio does this)
// call print(int*)
// result in an ambiguous function call compilation error (gcc and Clang do this)
print(nullptr); // always calls print(int*)
return 0;
}
print(int*): non-null
print(int): 0
print(int): 0
print(int*): null
When passing integer value 0
as as parameter, the compiler will prefer print(int)
over print(int*)
. This can lead to unexpected results when we intended print(int*)
to be called with a null pointer argument.
In the case where NULL
is defined as value 0
, print(NULL)
will also call print(int)
, not print(int*)
like you might expect for a null pointer literal. In cases where NULL
is not defined as 0
, other behavior might result, like a call to print(int*)
or a compilation error.
Using nullptr
removes this ambiguity (it will always call print(int*)
), since nullptr
will only match a pointer type.