Parameter Passing with Pointers
Pass pointers to functions when you need optional parameters or C API compatibility.
What Is Pass by Address?
Pass by address is the third way of getting an argument into a function. The caller does not hand over the object, and does not hand over a name bound to the object. It hands over the object's address, and the function reaches the object by dereferencing the pointer that address arrived in.
You already have two ways to pass an argument, and each one answers the question "what does the function receive?" differently:
- pass by value: the function receives a duplicate of the object
- pass by reference: the function receives a name that refers to the caller's object
- pass by address: the function receives a pointer holding the location of the caller's object
Only the first of those three copies the object. The other two both give the function access to the original, which is why the interesting comparison in this lesson is not value against address, but reference against address.
What Actually Crosses the Call Boundary
Here are all three forms applied to the same std::string, so the difference is in the parameter list rather than in the scenario:
#include <iostream>
#include <string>
void announceCopy(std::string roastName) // parameter is a copy of the caller's string
{
std::cout << "copy: " << roastName << '\n';
}
void announceBinding(const std::string& roastName) // parameter binds to the caller's string
{
std::cout << "binding: " << roastName << '\n';
}
void announceAddress(const std::string* roastName) // parameter holds the caller's address
{
std::cout << "address: " << *roastName << '\n';
}
int main()
{
std::string roastName{"Ethiopia Guji"};
announceCopy(roastName); // hands over a duplicate string
announceBinding(roastName); // hands over the object itself
announceAddress(&roastName); // hands over where the object lives
return 0;
}
This prints:
copy: Ethiopia Guji
binding: Ethiopia Guji
address: Ethiopia Guji
Three identical lines, three different mechanisms. In announceAddress, the parameter is a pointer, so two operators appear that the other versions do not need. At the call site, &roastName produces a pointer holding the address of roastName, and that pointer value is copied into the parameter. Inside the function, *roastName dereferences the parameter to reach the original object.
The parameter is a pointer to const because the function only reads. That is the same reasoning behind the const std::string& in announceBinding: nothing is being modified, so nothing needs write access.
A pointer parameter is cheap for exactly one reason, and it is worth seeing the reason as a number rather than as a claim:
#include <iostream>
#include <string>
int main()
{
std::string roastName{"Ethiopia Guji"};
std::cout << "bytes in the object: " << sizeof(roastName) << '\n';
std::cout << "bytes in its address: " << sizeof(&roastName) << '\n';
return 0;
}
On the platform compiler this prints:
bytes in the object: 32
bytes in its address: 8
The exact numbers depend on the implementation, but the shape of the answer does not. An address is one machine word, typically 4 or 8 bytes, no matter how large the object at that address is. announceCopy duplicates the string, including whatever heap allocation its characters live in. announceAddress copies 8 bytes and touches nothing else. Pass by address is fast for the same reason pass by reference is fast: the object stays exactly where it was.
Three Parameter Forms, Side by Side
| Parameter declaration | What the call copies | Accepts a literal | Can mean "no object" | Written at the call site |
|---|---|---|---|---|
std::string roastName |
the whole object | yes | no | announceCopy(roastName) |
const std::string& roastName |
nothing, it binds | yes | no | announceBinding(roastName) |
const std::string* roastName |
one address | no | yes, as nullptr |
announceAddress(&roastName) |
The last two columns are the entire argument for and against pass by address, and the rest of this lesson works through them. Being able to say "no object" is the one capability a reference genuinely lacks. Everything else in that row is a cost.
The address does not have to be produced at the call site. If a pointer variable is already aimed at the object, pass that instead:
#include <iostream>
#include <string>
void announceAddress(const std::string* roastName)
{
std::cout << "address: " << *roastName << '\n';
}
int main()
{
std::string roastName{"Ethiopia Guji"};
std::string* roastHandle{&roastName}; // a pointer variable already holding the address
announceAddress(&roastName); // take the address at the call site
announceAddress(roastHandle); // or hand over a pointer you already have
return 0;
}
Both calls print the same line:
address: Ethiopia Guji
address: Ethiopia Guji
In both calls above,
roastName is said to be passed by address. In the second call, the pointer roastHandle is itself passed by value: the function gets its own copy of the pointer. That distinction matters later, because a function cannot change where the caller's pointer points unless the pointer is passed by reference.
Writing Through a Pointer Parameter
Because the parameter holds the address of the caller's object rather than a copy of it, a pointer to non-const lets the function write to the original:
#include <iostream>
void grindBatch(int* beanGrams) // pointer to non-const, so the object can be written
{
*beanGrams -= 18;
}
int main()
{
int hopperGrams{480};
std::cout << "Hopper before: " << hopperGrams << " g\n";
grindBatch(&hopperGrams);
std::cout << "Hopper after: " << hopperGrams << " g\n";
return 0;
}
This prints:
Hopper before: 480 g
Hopper after: 462 g
The change survives the return, because grindBatch was never working on a copy.
The const in the parameter is what decides this. Moving it in front of the type turns the parameter into a pointer to const, and the assignment stops compiling. This version will not compile:
#include <iostream>
void grindBatch(const int* beanGrams) // pointer to const, so the object is read-only here
{
*beanGrams -= 18; // will not compile
}
int main()
{
int hopperGrams{480};
grindBatch(&hopperGrams);
std::cout << hopperGrams << '\n';
return 0;
}
The compiler rejects it:
s.cpp: In function 'void grindBatch(const int*)':
s.cpp:5:16: error: assignment of read-only location '* beanGrams'
5 | *beanGrams -= 18; // will not compile
| ~~~~~~~~~~~^~~~~
Note that hopperGrams is not const. The const restricts this one access path, not the object, which is exactly what makes pointer to const useful in a parameter list: the function accepts the address of anything, const or not, and the signature promises not to write to it.
Point a parameter at
const whenever the function only reads through it, so the signature advertises that the caller's object survives untouched. Reserve const in a parameter list for that job, and do not write int* const parameters without a specific reason: a const on the pointer itself changes nothing for the caller, and every spurious const makes the significant ones harder to spot.
A Pointer Parameter Has a Failure Mode References Do Not
A reference parameter always refers to an object. A pointer parameter has one extra state, and nothing in the type system forces the function to consider it. The following program is broken:
#include <iostream>
void reportRoast(const int* grams)
{
std::cout << "Batch: " << *grams << " g\n";
}
int main()
{
int batchGrams{907};
reportRoast(&batchGrams);
const int* missingBatch{}; // value initialized, so it holds nullptr
reportRoast(missingBatch); // undefined behaviour: nothing to dereference
return 0;
}
It compiles without a single warning. The second call passes a null pointer, the parameter becomes null, and *grams dereferences it, which is undefined behaviour. Running it on the platform executor ends the process with a segmentation fault. A crash is the good outcome here, because undefined behaviour is under no obligation to crash at all.
This is not an exotic mistake. Any function taking a pointer parameter can be handed nullptr, from any call site, at any time, so a pointer parameter comes with an obligation attached: decide what null means before you dereference.
Three Guards Against a Null Argument
| Guard | Shape | Use it when |
|---|---|---|
| Conditional | wrap the work in if (grams) { ... } |
the function is short and doing nothing is a sensible response |
| Precondition | if (!grams) return; on the first line |
the function is long, and you want the rest of it written as if the pointer were valid |
| Assertion | assert(grams); |
null is a caller bug rather than a legitimate input |
The conditional is the obvious first move, but it scales badly: in a longer function you either repeat the test or push the real work into a nested block. The precondition avoids both. It also gives you somewhere to put a meaningful response, which is what turns a null pointer from a hazard into information:
#include <iostream>
void reportRoast(const int* grams)
{
if (!grams) // a null pointer here means "this batch was never weighed"
{
std::cout << "Batch: not weighed\n";
return;
}
std::cout << "Batch: " << *grams << " g\n";
}
int main()
{
int batchGrams{907};
reportRoast(&batchGrams);
reportRoast(nullptr);
return 0;
}
This prints:
Batch: 907 g
Batch: not weighed
That version treats null as a value with a meaning: the argument is optional, and its absence is reportable. Use an assertion instead when null carries no meaning and its arrival would indicate a bug upstream:
#include <cassert>
#include <iostream>
void reportRoast(const int* grams)
{
assert(grams && "reportRoast requires a weighed batch");
std::cout << "Batch: " << *grams << " g\n";
}
int main()
{
int batchGrams{907};
reportRoast(&batchGrams);
return 0;
}
This prints:
Batch: 907 g
Passing nullptr to that version aborts the program during development with a message naming the file, the line, and the failed condition, which is a far better outcome than a segmentation fault somewhere downstream.
An assertion documents an expectation, it does not enforce one in a release build, because
assert compiles away when NDEBUG is defined. If a null argument is genuinely possible in production, handle it with a real check as well.
Why the Default Is a Reference
Look back at what the guards cost. Every one of them is code you would not have written had the parameter been a reference, and two of the three end with the function refusing to do its job. If null is not a state you want to support, do not accept a type that can be null.
There is a second, more mechanical reason. An address can only be taken of something that has one, so pass by address accepts lvalues and nothing else. A const reference accepts both. This program will not compile:
#include <iostream>
void announceCopy(int grams)
{
std::cout << "copy: " << grams << '\n';
}
void announceBinding(const int& grams)
{
std::cout << "binding: " << grams << '\n';
}
void announceAddress(const int* grams)
{
std::cout << "address: " << *grams << '\n';
}
int main()
{
announceCopy(907); // fine: the literal is copied into the parameter
announceBinding(907); // fine: a const reference binds to a temporary
announceAddress(&907); // will not compile: a literal has no address
return 0;
}
The failure is on the last call only:
s.cpp: In function 'int main()':
s.cpp:22:22: error: lvalue required as unary '&' operand
22 | announceAddress(&907); // will not compile: a literal has no address
| ^~~
A literal, an arithmetic expression, a function's return value: none of those can be passed by address without first being parked in a named variable. And even where it does work, the syntax leaks into every call site, sprinkling & on arguments and * on uses of the parameter, none of which the reference version needs.
Reach for a pointer parameter only when the argument genuinely needs to be optional. Everywhere else a reference does the same work with fewer failure modes, which is why the shorthand for this rule is "pass by reference when you can, pass by address when you must".
The main thing that counts as "must" is the capability in the table's fourth column: a parameter that can legitimately be absent. The next lesson takes that case up in detail.
Looking Forward
Pass by address part 2 covers the situations that justify a pointer parameter: optional arguments signalled with nullptr, and changing what the caller's pointer points at by passing the pointer itself by reference. Later in this chapter, std::optional offers a type built specifically to express "there may or may not be a value here", which is often a cleaner answer than a nullable pointer for the same problem.
Key Terminology
- Pass by address: passing an object's address so the function receives a pointer to it rather than a copy of it
- Address-of operator (
&): applied to an object, produces a pointer holding that object's address - Dereference operator (
*): applied to a pointer, yields the object at the address it holds - Pointer to const: a pointer parameter through which the pointed-to object cannot be modified
- Precondition: a condition that must hold when a function is entered, commonly checked at the top of the function body
Summary
- Pass by address copies a pointer holding the object's address into the parameter, so the object itself is never duplicated
- The copied address is one machine word, typically 4 or 8 bytes, regardless of the size of the object it refers to
- The function reaches the caller's object by dereferencing the parameter, and can modify it if the parameter is a pointer to non-const
- Declare pointer parameters as pointer to const unless the function needs to write through them
- Only lvalues can be passed by address, because rvalues such as literals have no address to take
- A pointer parameter can be null, and dereferencing it is undefined behaviour, so guard it with a conditional, an early-return precondition, or an assertion
- Pass by reference gives the same access and the same speed without the null state or the extra punctuation, so it is the default: pass by reference when you can, pass by address when you must
How did you find this lesson?
Your rating helps us improve the content.
Create an account to track your progress and access interactive exercises. Already have one? Sign in.
Parameter Passing with Pointers - Quiz
Test your understanding of the lesson.
Practice Exercises
Pass by Address
Learn pass by address as an alternative to pass by reference. Understand when to use pointers versus references, null checking, and why pass by reference is usually preferred.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!