Returning References and Pointers from Functions
Return references or pointers from functions without copying, while avoiding dangling issues.
What Is Return by Reference and Return by Address?
A function that returns by reference or by address hands the caller a way to reach an object that already exists, rather than handing over a copy of it. The return type is written as a reference (std::string&) or as a pointer (std::string*), and what travels back is a binding or an address, not the object's contents.
That buys speed, and it costs a guarantee. Every reference return carries an obligation that copies never had: the object on the other end has to still be alive when the caller looks at it. Most of this lesson is about telling the cases where that holds from the cases where it does not.
The Copy on the Way Out
You already know why parameters are passed by reference. Passing by value copies the argument into the parameter, which is free for an int and expensive for a std::string, so reference parameters let a function work on the caller's object directly.
Returning has the mirror-image problem. A function that returns by value produces a copy for the caller to keep, and when the return type is a class type that copy is exactly as expensive as the one you avoided on the way in:
std::string buildPlayerName(); // caller ends up owning its own std::string
Declaring the return type as a reference removes that copy:
std::string& getPlayerName(); // hands back a std::string that already exists
const std::string& getPlayerNameConst(); // same again, read-only
Declaring the Return Type as a Reference
Here is the mechanism working end to end. The function owns a static string and hands out a read-only view of it:
#include <iostream>
#include <string>
const std::string& activeProfile() // nothing is copied on the way out
{
static const std::string s_profile{"studio-mix"}; // static storage: outlives every call
return s_profile;
}
int main()
{
std::cout << "Loaded profile: " << activeProfile() << '\n';
return 0;
}
Loaded profile: studio-mix
return s_profile; does not copy the string. It binds the function's return reference to s_profile, and main() streams the characters straight out of the object the function still owns.
The Only Question That Matters: Does the Object Survive the Call?
Whether a reference return is correct comes down to storage duration, and there are only a handful of things a function can plausibly return. This table is the whole decision:
What the return statement names |
Still alive when the caller reads it? | Verdict |
|---|---|---|
A static local |
Yes, until the program ends | Safe |
| A parameter that arrived by reference | Yes, the caller owns it | Safe |
| A temporary the caller passed in by const reference | Yes, until the caller's full expression ends | Safe |
| An ordinary (non-static) local | No, destroyed at the closing brace | Dangling |
A temporary created inside the return statement |
No, destroyed as the function exits | Dangling |
The two "Dangling" rows produce undefined behavior the moment the caller touches the result. They are worth seeing in detail before the safe rows.
Returning a Local Is a Broken Promise
The following program is broken on purpose. Change s_profile from a static to an ordinary local and the mechanism stays identical while the guarantee disappears:
#include <iostream>
#include <string>
const std::string& activeProfile()
{
const std::string profile{"studio-mix"}; // automatic duration: gone at the closing brace
return profile;
}
int main()
{
std::cout << "Loaded profile: " << activeProfile() << '\n'; // undefined behavior
return 0;
}
profile has automatic duration, so it is destroyed when the function returns. The reference the caller receives refers to storage that has already been reclaimed: it is a dangling reference, and reading through it is undefined behavior. Our run of that program crashed outright, but a crash is only one of the things undefined behavior is allowed to do, and a quieter run that prints plausible garbage is just as likely.
This particular mistake is obvious enough that the compiler catches it:
s.cpp: In function 'const std::string& activeProfile()':
s.cpp:8:12: warning: reference to local variable 'profile' returned [-Wreturn-local-addr]
8 | return profile;
| ^~~~~~~
s.cpp:6:23: note: declared here
6 | const std::string profile{"studio-mix"}; // automatic duration: gone at the closing brace
| ^~~~~~~
Do not read that warning as a safety net. It fires on the direct case, where the returned name is visibly a local, and goes quiet as soon as the reference travels through another function or a class member on its way out.
A reference return type is a promise that the object outlasts the call. Locals and temporaries cannot keep that promise, so returning either one leaves the caller holding a reference to memory that has already been reclaimed.
Lifetime Extension Stops at the Function Boundary
The second dangling case is easier to write by accident, because there is no local variable in sight. This program is also deliberately broken:
#include <iostream>
const int& bufferSize()
{
return 512; // a temporary is materialised to hold 512, then bound to the return type
}
int main()
{
const int& size{bufferSize()};
std::cout << size << '\n'; // undefined behavior
return 0;
}
512 is a value, not an object, so the compiler materialises a temporary object to hold it and binds the return reference to that. The temporary belongs to the function call and dies with it, which the compiler again points out:
s.cpp: In function 'const int& bufferSize()':
s.cpp:5:12: warning: returning reference to temporary [-Wreturn-local-addr]
5 | return 512; // a temporary is materialised to hold 512, then bound to the return type
| ^~~
You might expect lifetime extension to rescue this. Binding a temporary to a const reference normally keeps the temporary alive for as long as the reference lives, which is why const int& boundHere{88}; is perfectly safe. The rule only applies to the binding that first captures the temporary, though, and once the value has been through a return statement the caller's reference is a second binding, arriving too late. This third broken program shows both halves side by side:
#include <iostream>
const int& passThrough(const int& value)
{
return value;
}
int main()
{
const int& boundHere{88}; // this reference captures the temporary itself
std::cout << boundHere << '\n'; // fine, the temporary lasts as long as boundHere
const int& boundByCall{passThrough(88)}; // captured inside the call instead
std::cout << boundByCall << '\n'; // undefined behavior
return 0;
}
Here the temporary holding 88 is created for the call, bound to parameter value, returned unchanged, and destroyed at the end of the full expression that created it, which finishes before the next statement runs. boundByCall is left pointing at nothing:
s.cpp: In function 'int main()':
s.cpp:13:16: warning: possibly dangling reference to a temporary [-Wdangling-reference]
13 | const int& boundByCall{passThrough(88)}; // captured inside the call instead
| ^~~~~~~~~~~
s.cpp:13:40: note: 'const int' temporary created here
13 | const int& boundByCall{passThrough(88)}; // captured inside the call instead
| ^~
Binding a temporary to a const reference extends its life only at the point where the binding happens. Route the same temporary out through a `return` statement and the extension is lost, because the caller's reference is a second binding to an object already scheduled for destruction.
Non-const Statics Make Poor Referents
The opening example returned a const static, which is the tame version. Returning a static that changes underneath its callers is legal, lifetime-safe, and still a design mistake:
#include <iostream>
const int& generateTicket()
{
static int s_ticketNumber{1000}; // not const, on purpose
++s_ticketNumber;
return s_ticketNumber;
}
int main()
{
const int& ticket1{generateTicket()}; // ampersand: ticket1 aliases the counter
const int& ticket2{generateTicket()}; // ampersand: ticket2 aliases it too
std::cout << ticket1 << ' ' << ticket2 << '\n';
return 0;
}
1002 1002
Two calls, two tickets, one number printed twice. There is only ever one s_ticketNumber, so ticket1 and ticket2 are two names for the same object, and the second call incremented the object the first name was already watching. A reference records where a value lives, not what it was at the time.
The other awkwardness is that callers have no way to put the counter back. A function-local static is initialised once, and short of adding a reset parameter or restarting the process there is no standard route back to its starting value.
Returning a const reference to a const static is the case that does earn its keep, and only when the object is costly to build and would otherwise be rebuilt on every call. The same reasoning applies to handing out a const reference to a const global, which is a recognised way to expose a global for reading while keeping writes private.
Reserve reference returns for objects the caller can reason about. A non-const function-local `static` fails that test: every caller ends up aliasing the same variable, and none of them can reset it. Return a copy instead, and keep the `const` reference to a `const` static for the rare object that is expensive to construct.
Assigning to a Non-Reference Variable Copies
The fix for the program above is not in the function at all. Drop the ampersands and the callers each get their own value:
#include <iostream>
const int& generateTicket()
{
static int s_ticketNumber{1000};
++s_ticketNumber;
return s_ticketNumber;
}
int main()
{
const int ticket1{generateTicket()}; // no ampersand: ticket1 keeps its own value
const int ticket2{generateTicket()}; // likewise
std::cout << ticket1 << ' ' << ticket2 << '\n';
return 0;
}
1001 1002
Initialising or assigning a non-reference variable from a returned reference reads the value through the reference and copies it, exactly as if the function had returned by value. The reference return is still doing its job, it just is not being kept.
That cuts both ways. Copying from a returned reference does not launder a dangling one: the reference is already dangling at the moment the copy reads through it, so std::string name{activeProfile()}; against the broken version above is undefined behavior before the copy even finishes.
Returning a Reference Parameter
Now the safe rows of the table. If an argument reached the function by reference, the caller must have had an object to pass, and that object is still there when the function returns. Passing a reference back out is therefore always sound:
#include <iostream>
#include <string>
// hands back whichever argument is longer, without copying
const std::string& getLonger(const std::string& first, const std::string& second)
{
return (first.length() > second.length()) ? first : second;
}
int main()
{
std::string heroName{"Arthur"};
std::string villainName{"Mordred the Betrayer"};
std::cout << getLonger(heroName, villainName) << '\n';
return 0;
}
Mordred the Betrayer
Count the copies this avoids. Written with pass by value and return by value, the same call would construct up to three std::string objects: one per parameter and one for the result. Written with references, it constructs none.
Returning an Rvalue Argument by Const Reference
A caller does not have to own a named variable for this to work. When the argument is an rvalue, such as the result of another function, it is still safe to return the corresponding const reference parameter, because an rvalue survives until the end of the full expression that created it:
#include <iostream>
#include <string>
const std::string& identity(const std::string& text)
{
return text;
}
std::string buildGreeting()
{
return "Welcome, adventurer!";
}
int main()
{
const std::string message{identity(buildGreeting())};
std::cout << message << '\n';
return 0;
}
Welcome, adventurer!
The temporary that buildGreeting() produces is bound to text, returned by const reference, and read to initialise message, all inside one expression. Only after message exists does the full expression end and the temporary go away. Compare that with the passThrough() case earlier, where the caller tried to hold the reference past the end of the expression rather than consuming it inside.
A Non-const Reference Return Is an Assignable Expression
Everything so far returned const references. Drop the const and the returned reference behaves like the variable it refers to, which means it can appear on the left of an assignment:
#include <iostream>
// takes modifiable references, hands one back
int& getHigher(int& first, int& second)
{
return (first > second) ? first : second;
}
int main()
{
int playerScore{85};
int enemyScore{72};
getHigher(playerScore, enemyScore) = 100;
std::cout << playerScore << ' ' << enemyScore << '\n';
return 0;
}
100 72
getHigher(playerScore, enemyScore) picks first, which is bound to playerScore, so the whole statement resolves to playerScore = 100. The call expression is not a value here, it is the variable itself, reached through the reference. This is the mechanism behind operators like operator[], which is why container[3] = 7 works.
Returning a Pointer Instead of a Reference
Return by address is the same idea with a pointer in place of the reference, and it inherits the same lifetime rule: point at something that outlives the call, or the caller gets a dangling pointer.
What it adds is a value that means "nothing". A reference must refer to something, so a function returning a reference has no way to report that it found no answer. A pointer can return nullptr:
#include <iostream>
// address of the first reading above the limit, or nullptr if neither is
int* firstOverLimit(int& bowTemp, int& sternTemp, int limit)
{
if (bowTemp > limit)
return &bowTemp;
if (sternTemp > limit)
return &sternTemp;
return nullptr;
}
int main()
{
int bowTemp{68};
int sternTemp{91};
constexpr int limit{85};
int* overheating{firstOverLimit(bowTemp, sternTemp, limit)};
if (overheating) // a pointer forces this check on every caller
*overheating = limit; // clamp the offending sensor back to the limit
std::cout << bowTemp << ' ' << sternTemp << '\n';
return 0;
}
68 85
The parameters still arrive by reference, so the addresses handed back belong to main()'s variables and outlive the call. Searching is the usual reason to want this shape: look through a set of candidates, return the address of the match, return nullptr when there is no match.
The price is the if (overheating) line. Every caller has to write it, and a caller who forgets dereferences a null pointer, which is undefined behavior. A reference return cannot fail that way, because there is no null reference to forget about.
Reach for a pointer return only when the function needs a way to say "there is nothing to give you". Everywhere else a reference return is the better default, because it deletes the null check from every call site.
When the thing you want to report is a value rather than an object, or when "no result" deserves to be part of the return type itself, `std::optional` expresses it more directly than a raw pointer. It is covered later in this chapter.
Key Terminology
- Return by reference: declaring a reference return type so the caller receives a binding to an existing object instead of a copy
- Return by address: declaring a pointer return type, which additionally allows
nullptrto stand for "no object" - Dangling reference: a reference whose referent has already been destroyed; reading it is undefined behavior
- Lifetime extension: the rule that binding a temporary to a const reference keeps the temporary alive as long as that reference, which does not survive a function return
- Storage duration: how long an object lives (automatic for ordinary locals, static for
staticlocals), and therefore the property that decides whether a reference return is safe
Looking Forward
Return by reference becomes routine once classes arrive: member functions that expose a member, and overloaded operators that need to be assignable, both depend on it. When the value being returned is text, there is a separate choice between const std::string& and std::string_view, which the std::string_view lessons cover. The std::optional alternative to a pointer return appears later in this chapter.
Summary
- A reference or pointer return hands back access to an existing object, avoiding the copy that return by value makes
- The object named in the
returnstatement must outlive the call, and storage duration is what decides that staticlocals, reference parameters, and rvalues passed in by const reference are all safe to return by reference- Ordinary locals and temporaries created in the
returnstatement are not: the result is a dangling reference and undefined behavior - Compilers warn about the obvious cases, and miss the ones that travel through another function
- Lifetime extension applies to the binding that first captures a temporary, so it never survives a function return
- Returning a non-const function-local
staticby reference gives every caller the same aliased object with no way to reset it - Initialising a non-reference variable from a returned reference copies the value, which is the usual fix
- A non-const reference return can be assigned to, so the call expression stands in for the variable itself
- Return by address adds
nullptrfor "no object" and, in exchange, requires a null check at every call site
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.
Returning References and Pointers from Functions - Quiz
Test your understanding of the lesson.
Practice Exercises
Return by Reference and Address
Learn to return references and pointers from functions efficiently while avoiding dangling references. Understand when objects must outlive function scope and when it's safe to return references.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!