Pass by Address in C++

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.