1. Return by value: why it's fine
When you are just starting out, the phrase “return an object by value” sounds like “carry a refrigerator out of an apartment through a door without scratching the frame.” Intuitively it feels scary: it seems expensive, you assume copying must happen, and that it's better to return T* or T& to “avoid hauling heavy things.” In practice, in modern C++ that mindset often leads to more dangerous code, not faster code.
Return by value is a basic, natural contract: the function creates the result, and the caller receives its own independent object that lives outside the function. This matches object lifetimes well: local variables die when the function exits, and the result should live on. So “return by value” is usually the most honest and safe way to say: “Here is the result; it's yours now.”
Let's recall the feeling on a very simple example (without our resource):
#include <string>
std::string make_title() {
return "Task Tracker"; // return by value
}
Yes, std::string is not a tiny type, but you constantly return strings by value and the world does not collapse. The reason is precisely the optimizations we'll discuss today: copy elision and NRVO.
2. What happens on return: copy, move or elision
From the outside return looks the same, but “under the hood” the compiler has several possible behaviors. And here the magic of modern C++ begins: the compiler is not required to literally “create an object, then copy it, then copy it again.” It can be smarter—and most of the time it is.
From the perspective of our previous lectures (where a type has copy/move operations), three scenarios are possible when returning by value. Sometimes a copy will happen (if move is unavailable), sometimes a move (if copy is expensive and move exists), and sometimes the compiler simply creates the result directly where it must live, so there is neither a copy nor a move.
As a reference, keep this table in mind:
| Situation on return | What the compiler does | How it looks “conceptually” |
|---|---|---|
| Full independence is needed, and no move exists | Copying | “Make a second identical thing” |
| move exists and copying is undesirable | Moving | “Transfer the resource, zero out the source” |
| It is possible to construct directly “in-place” | Copy elision (elimination of copies/moves) | “Don't create intermediate objects at all” |
Key idea: modern C++ does not require you to use out-parameters for performance in most common cases. Return by value is a normal practice, and the language/compiler work hard to make it cheap.
Copy elision: “no extra work”
In programming, a “lazy” compiler is a compiler that avoids unnecessary work. Copy elision is exactly the family of optimizations where the compiler eliminates copying/moving because it sees that an intermediate object is not needed as a separate entity.
The most useful mental model for a beginner is this: the result object may be constructed directly in the caller's memory, and the function effectively “builds” that object right there. So it's not “create local → copy out”, but “construct the outer object right away”.
To visualize, here is a simplified diagram:
flowchart LR
A[Function] --> B[Local object]
B --> C[Copy/Move to result]
C --> D[Object in caller]
And with copy elision the compiler strives to do this:
flowchart LR
A[Function] --> D[Object in caller]
A -->|constructs directly| D
Historically the C++ standard has reinforced this behavior; in particular, C++17 introduced changes that guarantee elimination of copies in certain scenarios (often referred to as “guaranteed copy elision”).
Important: copy elision is not an “olympiad trick”. It's a daily practice that makes returning by value convenient.
NRVO: returning a named local variable
If copy elision in general is the idea “don't make intermediate copies”, then NRVO (Named Return Value Optimization) is the most common subtype in real programs: when you create a local variable, fill it, and then return that variable.
NRVO sounds scary (like a diagnosis), but the idea is simple: the compiler can detect that the local variable is the future result and create it directly in the “result memory”. So the variable looks local… but physically it can end up being constructed directly as the return value.
Here is the classic pattern:
Buffer make_report() {
Buffer r{128};
// fill r with data...
return r; // NRVO candidate
}
And yes, NRVO often takes effect. But there is a nuance: unlike some cases of “guaranteed copy elision”, NRVO has historically been considered an optimization the compiler may perform (and almost always does), but you should not write code that logically depends on whether it happens.
Practically the rule is simple: write code as if copy, move, and elision may occur, and the correctness of your program should not change because of that.
Mini experiment: “why aren't my copy/move operations called?”
Sometimes you add logging in copy/move constructors to see how many times “something is copied”, and then… you see almost nothing. And you think: “Is the compiler ignoring my code?”
No, it's not ignoring it. It simply is not obliged to create intermediate objects, and therefore is not obliged to call your copy/move where it was able to eliminate them. This is normal, and one reason you must not design programs so that “side effects of copying” are part of the required logic.
If you want to observe what happens, you can temporarily add a simple log to constructors. But treat it as an educational flashlight, not as a measuring instrument.
Why return std::move(x) is usually unnecessary
This is the champion place for “automatic mistakes born of habit.”
Once you have learned about std::move, your hand may start writing:
return std::move(r);
as if this “speeds up the return”. In reality most of the time you make things worse or, at best, do nothing useful.
Why? Because std::move(r) is an explicit signal: “treat r as a move source.” The compiler already can consider a local object in a return as a potential move source if elision does not kick in. More importantly, adding std::move can interfere with NRVO, because the return expression ceases to be “just the name of a local variable” and becomes “an expression with std::move.”
Almost always write return r; if r is a local variable result, and don't add std::move “to speed things up.”
If you ever need to deliberately write std::move in a return, do it consciously because you understand exactly why NRVO cannot apply there and what you want to achieve. In this course level it's a rare case.
3. Practice with Buffer: return from a function without fear
Next we'll continue our tutorial mini-project in the console: an imaginary Task Tracker that prints a report. To tie the topic to earlier lectures, we'll build the report not via std::string (in the real world that would have been appropriate), but via our tutorial resource-owning type Buffer.
Assume that Buffer is already implemented after lectures on the Rule of Five and move-operations: it stores size and data, properly copies (deep copy) and moves (transfer pointer + nullptr in the source). Here I'll show only the small pieces needed specifically for the topic of return by value.
Let's add a simple method to write characters (simplified, without complex formatting logic):
#include <cstddef>
struct Buffer {
std::size_t size{};
char* data{nullptr};
// ... constructors/destructor/copy/move already implemented earlier
void set(std::size_t i, char ch) {
if (i < size) data[i] = ch;
}
};
Returning a local result (NRVO candidate)
Now let's make a function that returns a buffer by value by creating a local object and returning it. This is one of the most common scenarios for NRVO:
#include <cstddef>
Buffer make_banner() {
Buffer b{6}; // suppose it allocates char[6]
b.set(0, 'H');
b.set(1, 'i');
b.set(2, '!');
return b; // NRVO candidate
}
Even if NRVO does not kick in, we have a move constructor, and the compiler can return the result by moving. And if NRVO does work—there will be neither copy nor move, and that's fine.
Copy elision “in the pure form”: return Buffer{...};
There is an even more “transparent” style: don't create a named variable, return a temporary object directly:
Buffer make_small() {
return Buffer{3};
}
In this form the compiler's choice is maximally simple: “Why create a temporary object just to move it somewhere? I'll construct the result in place.” This is where copy elision usually seems most obvious intuitively.
Historically the standard strengthened and clarified such cases of elision; in drafts and editorial notes you can find explicit mentions of work on “mandatory copy elision”.
Multiple return statements in a function: why NRVO sometimes doesn't apply
Now let's make an example closer to real code: we have branching. For example, if there are few tasks—one report template, if many—another.
Buffer make_report_template(bool many_tasks) {
Buffer a{4};
Buffer b{4};
if (many_tasks) return a;
return b;
}
The nuance here is that the function returns either a or b. The compiler finds it harder (or impossible) to apply NRVO to “one specific variable” because there are multiple candidates. In such situations a move is often used (if available), since it's still cheap compared to a deep copy.
It's important to understand without mysticism: NRVO doesn't “break”—the code pattern is simply different. The compiler is not obliged to guess your intentions. It sees two different variables and the optimization becomes less straightforward.
The practical takeaway is simple: if you want the most NRVO-friendly style, create one result variable and fill it depending on conditions, then return the same variable.
For example (simplified):
Buffer make_report_template2(bool many_tasks) {
Buffer r{4};
if (many_tasks) r.set(0, 'M');
else r.set(0, 'S');
return r; // NRVO candidate again
}
Embedding in a console app: printing the result
Now let's connect everything into a small application snippet. Let Task Tracker (still very primitive) print the banner and report that the report is “assembled”.
For simplicity add a print function: it prints the first few characters of the buffer. This isn't perfect string handling, but what matters here is ownership and return mechanics.
#include <cstddef>
#include <iostream>
void print_prefix(const Buffer& b, std::size_t n) {
for (std::size_t i = 0; i < n && i < b.size; ++i) {
std::cout << b.data[i];
}
std::cout << '\n';
}
Now main:
#include <iostream>
int main() {
Buffer banner = make_banner(); // returned by value
print_prefix(banner, 3); // Hi! (and newline)
Buffer small = make_small(); // return Buffer{3};
std::cout << small.size << '\n'; // 3
return 0;
}
Note one important design detail: we never return the internals of the buffer, we don't return char*, we don't return a reference to a local object. We simply return an owning object by value—and that's exactly the scenario modern C++ is designed for.
4. Performance without panic: keep it simple
At this point many want to “know for sure” whether NRVO will happen at a specific line. In practice it's healthier to adopt a different habit: write code that is correct under any of the variants (copy/move/elision), and then understand the general picture.
If a function returns an owning object by value, two conditions should be met. First, the type itself must be correctly implemented: deep copy where copyability is needed, move where transfer of ownership is possible, and a destructor that releases the resource. Second, your code must not depend on “how many times the move constructor ran,” because the compiler may eliminate extra steps.
The irony is that the cleaner and more straightforward your code, the easier it is for the compiler to apply optimizations. So the best way to help the optimizer is not to obstruct it with excessive “magic” like sprinkling std::move everywhere.
5. Common mistakes
Mistake #1: “I'll return a reference to avoid copying.”
Beginners sometimes replace T make() with const T& make() because “references don't copy.” The problem is that a local variable inside make() is destroyed when the function exits. Returning a reference to it yields a dangling result. Even if “it seems to work for now,” it's fragile. Return by value solves this correctly.
Mistake #2: the habit of writing return std::move(x); “to speed things up.”
It seems logical: “move is fast, let's force a move.” But in modern C++ return x; can already result in a move (if elision doesn't occur), and std::move in a return can sometimes prevent NRVO. So prefer to write simply return x;, especially when x is a local result.
Mistake #3: measuring program logic by the number of copy/move calls.
If you conclude “the program works correctly because I saw two Move ctors in the console,” then with another compiler or different flags you may see zero and panic. Copy elision can eliminate those calls. Program correctness should not depend on side effects of copying/moving.
Mistake #4: writing code that accidentally hinders optimizations and then blaming C++.
When code turns into a set of micro-tricks (“std::move here, std::move there, another temporary for the temporary”), the compiler has a harder time seeing the simple picture. You end up with more complex code that is not necessarily faster. In modern C++ a clear style usually wins: one result object, straightforward filling, return result;.
Mistake #5: trying to “compensate” with pointers and manual lifetime management.
After learning about performance some start returning new T(...) thinking “this guarantees no copies.” In reality you get manual memory management, risk of leaks, unclear ownership contracts, and the need for delete. Return by value plus correct copy/move operations give both safety and good performance—especially together with copy elision.
GO TO FULL VERSION