Copy elision and RVO

A function that returns a value by value does not have to copy it. The compiler is allowed to build the result directly in the caller's storage, and since C++17 it must do so for a prvalue. The copy constructor that the source seems to call never runs.

The counters in this lesson print every constructor and destructor, so the elision is visible as the absence of a line. Lesson 032 showed the move that makes a return cheap; this lesson shows the case where even the move does not happen.

The compiler may elide a copy even when the constructor has side effects, which is the one place where the as-if rule is suspended. A program whose output depends on a constructor's print is a program that depends on whether the optimisation happened.

A returned local

make creates a local and returns it. The compiler may construct that local directly in the storage of the caller's variable, which is the named return value optimisation, and the source's copy or move disappears.

The program prints ctor when the object is built and dtor when the block ends, then the marker. The build with -fno-elide-constructors prints ctor, move, dtor and dtor: the local is moved into the result and then destroyed.

NRVO is an optimisation the standard permits but does not require, so a program must not depend on its side effects. The copy or move constructor still has to exist and be accessible, which is the rule the next section's move-only type depends on.

#include <iostream> struct tracked { tracked() { std::cout << "ctor "; } tracked(const tracked&) { std::cout << "copy "; } tracked(tracked&&) { std::cout << "move "; } ~tracked() { std::cout << "dtor "; } }; tracked make() { tracked local; return local; } int main() { { tracked value = make(); (void)value; } std::cout << "done\n"; }

g++ -std=c++23 -Wall -Wextra nrvo.cpp -o nrvo
./nrvo
g++ -std=c++23 -Wall -Wextra -fno-elide-constructors nrvo.cpp -o nrvo_copy
./nrvo_copy

ctor dtor done
ctor move dtor dtor done

The elision C++17 guarantees

A prvalue is a temporary with no name, and C++17 says that initialising an object from one constructs it directly in its final place. That is not an optimisation and no flag turns it off: tracked value = tracked{}; runs one constructor.

The program builds two objects from prvalues, one by a direct initialisation and one returned from a function, and the output is ctor, ctor, dtor, dtor, done. The build with -fno-elide-constructors prints exactly the same thing, which is the proof.

The guarantee is what makes std::make_unique and a factory function safe without a copy constructor. The object is constructed once, where it will live, and the language promises it.

#include <iostream> struct tracked { tracked() { std::cout << "ctor "; } tracked(const tracked&) { std::cout << "copy "; } ~tracked() { std::cout << "dtor "; } }; tracked make() { return tracked{}; } int main() { { tracked value = tracked{}; tracked second = make(); (void)value; (void)second; } std::cout << "done\n"; }

g++ -std=c++23 -Wall -Wextra guaranteed.cpp -o guaranteed
./guaranteed
g++ -std=c++23 -Wall -Wextra -fno-elide-constructors guaranteed.cpp -o guaranteed_copy
./guaranteed_copy

ctor ctor dtor dtor done
ctor ctor dtor dtor done

A move-only result

A type with a unique_ptr member cannot be copied, and the factory below still returns it by value. The prvalue guarantee builds the result in the caller's storage, so no copy is attempted and the deleted copy constructor is never needed.

The program makes a holder, reads the int through the pointer and prints 42. The struct has no user-written constructor, destructor or move either, which is the rule of zero of lesson 033 doing its work.

Without the guarantee the compiler would look for a move constructor, which the implicit one provides for the unique_ptr member. A named local returned by value uses that move when the compiler does not elide, which is why a move-only type is returnable either way.

#include <iostream> #include <memory> struct holder { std::unique_ptr<int> value; }; holder make() { holder local; local.value = std::make_unique<int>(42); return local; } int main() { holder result = make(); std::cout << *result.value << '\n'; }

g++ -std=c++23 -Wall -Wextra move_only.cpp -o move_only
./move_only

42

The move that blocks the elision

std::move on a local that is about to be returned marks it as an rvalue, and the return then has to move it instead of building it in place. The move is cheap, and it is still work that the compiler would have skipped.

The program returns std::move(local) and prints ctor, move, dtor, dtor, done: one more line than the first section, and the same output as the -fno-elide-constructors build. The explicit move turned an elision into a move.

A return statement already treats a local as an rvalue, so the explicit move adds nothing for a movable type. It is a pessimisation, and for a type that is copyable only it turns a valid return into a compile error.

#include <iostream> #include <utility> struct tracked { tracked() { std::cout << "ctor "; } tracked(const tracked&) { std::cout << "copy "; } tracked(tracked&&) { std::cout << "move "; } ~tracked() { std::cout << "dtor "; } }; tracked make() { tracked local; return std::move(local); } int main() { { tracked value = make(); (void)value; } std::cout << "done\n"; }

g++ -std=c++23 -Wall -Wextra move_return.cpp -o move_return
./move_return

move_return.cpp: In function 'tracked make()':
move_return.cpp:16:21: warning: moving a local object in a return statement prevents copy elision [-Wpessimizing-move]
16 | return std::move(local);
| ~~~~~~~~~^~~~~~~
move_return.cpp:16:21: note: remove 'std::move' call
ctor move dtor dtor done

Exercises

  1. Add a print to the move constructor and run the NRVO program with and without the flag.
  2. Return a std::vector from a function and check that the caller's vector has the data without a copy.
  3. Remove the std::move from the last program and check that the move line disappears.

Questions

A new set of questions in a new order every time. Choose one answer for each, then check.

  1. What is the return value optimisation?

    Answer

    Building the returned object directly in the caller's storage

    The construction happens once, in the place the result will live, and the copy or move is skipped.

  2. Is the named return value optimisation guaranteed by the standard?

    Answer

    No, it is permitted but not required

    Only the prvalue case is guaranteed; a named local may still be moved on some builds.

  3. What does -fno-elide-constructors show?

    Answer

    The copy or move that the optimisation removes

    Turning the optional elision off leaves the move that the source would otherwise perform.

  4. What does the NRVO program print?

    Answer

    ctor dtor done

    The local is built in place and destroyed at the end of the block, and then the marker is printed.

  5. This program is built with g++ -std=c++23 -fno-elide-constructors q.cpp and then run. What does it print? #include <iostream> struct tracked { tracked() { std::cout << "ctor "; } tracked(const tracked&) { std::cout << "copy "; } tracked(tracked&&) { std::cout << "move "; } ~tracked() { std::cout << "dtor "; } }; tracked make() { tracked local; return local; } int main() { { tracked value = make(); (void)value; } std::cout << "done"; }

    Answer

    ctor move dtor dtor done

    Without the elision the local is moved into the result, and both objects run their destructors.

  6. What does this program print? #include <iostream> struct tracked { tracked() { std::cout << "ctor "; } tracked(const tracked&) { std::cout << "copy "; } ~tracked() { std::cout << "dtor "; } }; tracked make() { return tracked{}; } int main() { { tracked value = tracked{}; tracked second = make(); (void)value; (void)second; } std::cout << "done"; }

    Answer

    ctor ctor dtor dtor done

    Each prvalue is constructed once in its final place, so the copy constructor never runs.

  7. Why is a move-only type returnable by value?

    Answer

    The prvalue is built in place, and a named local is moved, never copied

    A copy is never required, which is what lets a unique_ptr or a holder of one cross a return.

  8. What does return std::move(local); do to the return?

    Answer

    It blocks the elision and forces a move of the local

    The explicit move is a pessimisation: the return already treats the local as an rvalue.

  9. Does elision change observable behaviour?

    Answer

    Yes, the side effects of the skipped constructor and destructor do not happen

    A constructor with a print proves it: the line is simply absent when the object is built in place.

  10. What is a prvalue?

    Answer

    A temporary with no name, such as tracked{}

    The prvalue is the category the guarantee is about, and it has no name until it initialises an object.

The code on this page was built with g++ 15.2.0 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev