Advanced Pointer Parameter Techniques
Handle null pointer arguments and modify pointer parameters themselves.
What Does a Pointer Parameter Buy You?
Part 1 finished with a rule and one narrow exception to it: a reference parameter is the default, and a pointer parameter earns its place only when the argument genuinely needs to be optional. This lesson cashes that exception in. It shows what "optional" looks like in real code, and then adds a second capability that a reference to the object cannot provide at all: letting a function change which object the caller's pointer names.
Both capabilities come from one fact about pointers that is easy to state and easy to forget. A pointer parameter is an ordinary object that happens to hold an address, so there are two separate things a function can modify:
- the object at the address, reached by writing through
*param - the address itself, by assigning to
param
The first is what part 1 was about. The second only reaches the caller if you ask for it explicitly. Here is the whole lesson as a lookup table:
| What you want the function to be able to do | Parameter form |
|---|---|
| Read the caller's object | const int* reading |
| Write to the caller's object | int* reading |
| Accept "there is no object at all" | const int* reading = nullptr |
| Re-aim the caller's own pointer | int*& reading |
An Argument the Caller Can Simply Omit
Give a pointer parameter a default of nullptr and the call site gets a choice: hand over an address, or say nothing and let the function work out what to do with the absence.
#include <iostream>
void printCuppingSheet(const int* cupScore = nullptr)
{
if (cupScore)
std::cout << "Kochere lot scored " << *cupScore << " points.\n";
else
std::cout << "Kochere lot has not been cupped.\n";
}
int main()
{
printCuppingSheet(); // no score exists yet, so the parameter defaults to nullptr
int panelScore{88};
printCuppingSheet(&panelScore); // the tasting panel has scored it now
return 0;
}
This prints:
Kochere lot has not been cupped.
Kochere lot scored 88 points.
Notice how little of that is new. The if (cupScore) test is the same conditional guard part 1 introduced for protecting a dereference. The only addition is = nullptr in the parameter list, and that single default turns the guard from a defensive measure into part of the interface: the function now documents, in its own signature, that a caller with nothing to report is allowed to say so by writing nothing at all.
The Same Interface Without the Pointer
That version has a rival, and the rival wins more often than people expect. If a function behaves one way with a value and another way without one, that is two behaviours, and C++ already has a way to spell two behaviours under one name.
#include <iostream>
void printCuppingSheet()
{
std::cout << "Kochere lot has not been cupped.\n";
}
void printCuppingSheet(int cupScore)
{
std::cout << "Kochere lot scored " << cupScore << " points.\n";
}
int main()
{
printCuppingSheet(); // no score exists yet
int panelScore{88};
printCuppingSheet(panelScore);
printCuppingSheet(91); // a literal works here, which the pointer version could not accept
return 0;
}
Which prints:
Kochere lot has not been cupped.
Kochere lot scored 88 points.
Kochere lot scored 91 points.
Compare the two side by side and the pointer version comes out behind on every count:
| Defaulted pointer parameter | Two overloads | |
|---|---|---|
| Ways to call it wrongly | a dangling pointer compiles and runs | none, the type system covers both cases |
printCuppingSheet(91) |
rejected, a literal has no address | accepted |
| Where the two behaviours live | interleaved in one body behind an if |
one per function, each read on its own |
| What the reader must check | whether every path guards the dereference | nothing |
When a function has one behaviour with a value and a different behaviour without one, write the two behaviours as two overloads instead of one pointer parameter defaulted to
nullptr. Overloads cannot be handed a dangling address, they accept literals and other rvalues, and each body ends up saying exactly one thing.
The defaulted pointer still has a place when the "absent" path is a small variation rather than a separate behaviour, or when the value is genuinely large and copying it for the overload would cost something. Later in this chapter, std::optional gives you a third option that expresses "maybe a value" as a type rather than as a nullable pointer.
Reassigning the Parameter Does Not Reach the Caller
Now for the second capability, which starts with a demonstration of what does not happen. When a call passes an address, the address is copied into the parameter, exactly the way an int argument is copied into an int parameter. Assigning to the parameter therefore overwrites a copy.
#include <iostream>
void clearSelection(int* selected)
{
selected = nullptr;
std::cout << "inside clearSelection: " << (selected ? "still aimed" : "cleared") << '\n';
}
int main()
{
int batchWeight{450};
int* selectedBatch{&batchWeight};
clearSelection(selectedBatch);
std::cout << "back in main: " << (selectedBatch ? "still aimed" : "cleared") << '\n';
return 0;
}
This prints:
inside clearSelection: cleared
back in main: still aimed
Two pointers exist here, and only one of them was touched. selectedBatch in main holds the address of batchWeight. selected in clearSelection holds a second copy of that same address. Setting selected to nullptr discards the copy and leaves selectedBatch aimed exactly where it was.
This is the most common misreading of pass by address. A pointer parameter gives a function write access to the pointed-to object, not to the pointer that was passed in. Writing
*selected = 0 would have changed batchWeight in the caller; writing selected = nullptr changed nothing outside the function.
Binding the Parameter to the Caller's Pointer
If the copy is the problem, then remove the copy. A pointer is an object, and any object can be passed by reference, including one that holds an address. Declaring the parameter int*& makes it a reference to a pointer to int, so it binds to the caller's pointer rather than duplicating it.
#include <iostream>
void clearSelection(int*& selected) // selected refers to the caller's pointer
{
selected = nullptr;
std::cout << "inside clearSelection: " << (selected ? "still aimed" : "cleared") << '\n';
}
int main()
{
int batchWeight{450};
int* selectedBatch{&batchWeight};
clearSelection(selectedBatch);
std::cout << "back in main: " << (selectedBatch ? "still aimed" : "cleared") << '\n';
return 0;
}
One character changed, and now both lines agree:
inside clearSelection: cleared
back in main: cleared
Read the declaration from the identifier outwards, the way you read any other declaration: selected is a reference (&) to a pointer (*) to int. The reference is the outermost part, so it is the thing written closest to the name.
Get the two symbols the wrong way round and the compiler stops you immediately. This will not compile:
#include <iostream>
void clearSelection(int&* selected) // will not compile
{
selected = nullptr;
std::cout << (selected ? "still aimed" : "cleared") << '\n';
}
int main()
{
int batchWeight{450};
int* selectedBatch{&batchWeight};
clearSelection(selectedBatch);
return 0;
}
s.cpp:3:27: error: cannot declare pointer to 'int&'
3 | void clearSelection(int&* selected) // will not compile
| ^~~~~~~~
The error names the reason rather than just the mistake. int&* would be a pointer to a reference, and a pointer has to hold the address of an object. References are not objects and have no address of their own, so that type cannot exist. There is no ambiguous case to memorise here: only one of the two orderings is a type at all.
Choosing a Null Pointer Literal
Three spellings of "null pointer" exist in C++ and they are not interchangeable. Only one of them has a type that says what it means.
| Spelling | What it actually is | What overload resolution does with it |
|---|---|---|
0 |
an integer literal that is allowed to convert to a pointer | prefers an integer parameter |
NULL |
a macro whose expansion the standard does not fix, commonly 0 or 0L |
depends on the expansion, and can be ambiguous |
nullptr |
a literal of its own type, std::nullptr_t |
matches pointer parameters only |
The differences stay invisible until two overloads compete for the same call:
#include <iostream>
void logTemperature(int celsius)
{
std::cout << "logTemperature(int) got " << celsius << '\n';
}
void logTemperature(int* probe)
{
std::cout << "logTemperature(int*) got " << (probe ? "a reading" : "no probe") << '\n';
}
int main()
{
int drumTemp{212};
int* probeReading{&drumTemp};
logTemperature(probeReading); // the argument's type is int*, so the pointer overload wins
logTemperature(0); // 0 is an integer literal first, so the int overload wins
logTemperature(nullptr); // nullptr matches pointer types only
return 0;
}
logTemperature(int*) got a reading
logTemperature(int) got 0
logTemperature(int*) got no probe
The middle line is the trap. A reader who wrote logTemperature(0) meaning "no probe attached" gets the integer overload, silently, with no diagnostic anywhere. Nothing about the call site hints that the wrong function ran.
NULL fails differently, and its failure is not portable. On the platform compiler NULL expands to something that neither overload can claim outright, so the call is simply rejected. Adding this main to the same two overloads will not compile:
#include <cstddef> // for NULL
#include <iostream>
void logTemperature(int celsius)
{
std::cout << "logTemperature(int) got " << celsius << '\n';
}
void logTemperature(int* probe)
{
std::cout << "logTemperature(int*) got " << (probe ? "a reading" : "no probe") << '\n';
}
int main()
{
logTemperature(NULL); // will not compile on this platform
return 0;
}
s.cpp: In function 'int main()':
s.cpp:16:19: error: call of overloaded 'logTemperature(NULL)' is ambiguous
16 | logTemperature(NULL); // will not compile on this platform
| ~~~~~~~~~~~~~~^~~~~~
The full diagnostic goes on to list both overloads as candidates, because neither one is a better match than the other for whatever NULL expanded to here. A different compiler with a different NULL may compile that same line and quietly call the integer overload instead. Code whose meaning changes with the toolchain is not code you want in a header.
Write
nullptr every time you mean a null pointer. It carries a type that only pointers accept, so overload resolution can never mistake it for a number, and its behaviour does not vary between compilers the way a NULL macro can.
The Type That Holds Exactly One Value
If nullptr can be told apart from 0 during overload resolution, it must have a type of its own, and it does: std::nullptr_t, declared in <cstddef>. The type is unusual in that the entire set of values it can hold is { nullptr }.
That makes it a parameter type that accepts the null pointer literal and nothing else:
#include <cstddef> // for std::nullptr_t
#include <iostream>
void attachProbe(std::nullptr_t)
{
std::cout << "attachProbe(std::nullptr_t)\n";
}
void attachProbe(int*)
{
std::cout << "attachProbe(int*)\n";
}
int main()
{
attachProbe(nullptr); // the literal itself has type std::nullptr_t
int drumTemp{212};
int* probeReading{&drumTemp};
attachProbe(probeReading); // probeReading has type int*
probeReading = nullptr;
attachProbe(probeReading); // still type int*, even though its value is now null
return 0;
}
attachProbe(std::nullptr_t)
attachProbe(int*)
attachProbe(int*)
The first call has an exact match available and takes it, since no conversion is needed to reach std::nullptr_t.
The third call is the one worth sitting with. probeReading holds nullptr by then, yet the pointer overload still runs. Overload resolution is a compile-time process and works entirely from the type of each argument expression; the value stored at run time is not something it can see. probeReading is declared int*, so int* is what the compiler matches on, whatever the pointer happens to contain.
A
std::nullptr_t parameter is a specialist tool, not something to reach for in ordinary code. It appears mainly in library code that needs to reject a real pointer while still permitting the literal nullptr.
Every Argument Is Copied
Step back from the three passing mechanisms and one description covers all of them.
Pass by value copies the object into the parameter. Pass by address copies an address into the parameter, and an address is just a value like any other, so that is a copy too. Pass by reference looks like the exception, but compilers that cannot optimise a reference away implement it with a pointer behind the scenes, which puts it in the same category: something small is copied into the function.
So C++ has exactly one calling mechanism, and it copies. What separates the three forms is not how the argument travels but what the function can do once it arrives. A value parameter holds a copy of the object, and writing to it changes only the copy. A pointer or reference parameter holds a copy of a location, and following that location leads straight back to the caller's object. Modifying the argument was never a property of the call. It is a property of what the parameter points at.
Looking Forward
Two lessons ahead, return by reference and return by address ask the same question in the other direction: what a function may hand back, and which of those things outlive the function that produced them. Later in the chapter, std::optional gives "there might be no value here" a type of its own, which removes the last common reason to reach for a nullable pointer parameter.
Key Terminology
- Optional argument: a parameter the caller may leave out, implemented here by defaulting a pointer parameter to
nullptr - Default argument: a value written in the parameter list that is used when the call site supplies no argument
- Reference to a pointer (
int*&): a parameter bound to the caller's pointer, allowing the function to change which object that pointer names std::nullptr_t: the type of the literalnullptr, declared in<cstddef>, whose only value isnullptr- Overload resolution: the compile-time selection of one function from a set sharing a name, decided by the types of the argument expressions
Summary
- Defaulting a pointer parameter to
nullptrmakes the argument optional, and the function distinguishes the two cases with the same null check that protects any dereference - Two overloads usually express that better: no null state, literals accepted, and one behaviour per function body
- A pointer parameter receives a copy of the address, so assigning to the parameter re-aims the copy and leaves the caller's pointer untouched
- Declaring the parameter
int*&binds it to the caller's pointer, which is what lets a function change where that pointer points int&*is not a type, because a pointer to a reference cannot exist, so only one ordering of the two symbols ever compiles0prefers an integer parameter andNULLbehaves differently across compilers, so usenullptr, whose typestd::nullptr_tmatches pointer parameters only- Overload resolution matches on the type of an argument expression, never on the value it holds at run time
- Value, address, and reference all copy something into the parameter; the difference is whether that something is the object or a route back to it
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.
Advanced Pointer Parameter Techniques - Quiz
Test your understanding of the lesson.
Practice Exercises
Pass by Address Part 2
Explore advanced pass by address techniques including optional parameters, changing what pointers point to, and passing pointers by reference. Understand why nullptr is preferred over 0 or NULL.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!