The C++ Textbook

Part 7 · Performance

Copies, moves, and elision

Where copies actually happen, and how to stop paying for them.

By the end of this chapter you can

  • Explain guaranteed copy elision and NRVO
  • Find an unnecessary copy in a code review
  • Explain why returning std::move(x) can be worse than returning x

“Returning by value is expensive” was true in 1998, has been false since 2017, and is still repeated. Meanwhile the copies that actually cost you something are in range-for loops and parameter lists, where nobody looks.

The only way to settle any of this is to count. Every sample in this chapter uses a type that announces what happens to it — and prints the same thing at -O0 and -O2, because elision is a rule of the language rather than an optimisation.

What returning by value actually costs
#include <cstdio>
#include <string>
#include <utility>

struct Loud {
    std::string name;
    explicit Loud(const char* n) : name(n) { std::printf("  construct\n"); }
    Loud(const Loud& other) : name(other.name) { std::printf("  COPY\n"); }
    Loud(Loud&& other) noexcept : name(std::move(other.name)) { std::printf("  move\n"); }
};

Loud make_temporary() {
    return Loud{"temporary"};          // a prvalue
}

Loud make_named() {
    Loud result{"named"};
    return result;                     // a named local
}

int main() {
    std::printf("returning a temporary:\n");
    Loud a = make_temporary();

    std::printf("returning a named local:\n");
    Loud b = make_named();

    std::printf("\n%s and %s\n", a.name.c_str(), b.name.c_str());
}

One construction each. No copy, no move, no temporary. The two cases get there by different rules, and the difference matters.

Guaranteed elision: the temporary never existed

Since C++17, return Loud{"temporary"}; does not create an object and then copy it. A prvalue is not an object at all — it is a recipe for initialising one, and the recipe is not run until there is somewhere to put the result. That somewhere is a, in main’s stack frame. Loud{"temporary"} constructs directly into it.

This is not an optimisation the compiler is allowed to make. It is what the language says the code means, so it happens at every optimisation level, and it works even for a type whose copy and move constructors are deleted:

Elision for a type that cannot be copied or moved
#include <cstdio>
#include <string>

struct Immovable {
    std::string label;
    explicit Immovable(const char* l) : label(l) {}
    Immovable(const Immovable&) = delete;
    Immovable(Immovable&&) = delete;
};

Immovable make() {
    return Immovable{"cannot be copied, cannot be moved"};
}

int main() {
    Immovable value = make();          // still fine
    std::printf("%s\n", value.label.c_str());
}

Before C++17 this did not compile: the return required an accessible copy or move constructor even when the compiler intended to elide the call. Now there is no call to elide.

NRVO: allowed, not guaranteed

make_named is a different rule. result is a real object with a name and an address, so eliding it means the compiler decides to construct result directly in the caller’s storage rather than locally. That is the named return value optimisation, and unlike the prvalue case it is permitted, not required.

GCC and Clang do it, including at -O0. MSVC does it in optimised builds. But the standard does not promise it, so:

  • Never write code whose correctness depends on NRVO happening. A constructor or destructor side effect you are counting on may or may not run.
  • Do rely on it for performance. It fires in the overwhelming majority of real functions: one local, returned on every path.

Two things stop it, and both are worth recognising.

The two shapes NRVO cannot handle
#include <cstdio>
#include <string>
#include <utility>

struct Loud {
    std::string name;
    explicit Loud(const char* n) : name(n) { std::printf("  construct %s\n", n); }
    Loud(const Loud& other) : name(other.name) { std::printf("  COPY\n"); }
    Loud(Loud&& other) noexcept : name(std::move(other.name)) { std::printf("  move\n"); }
};

Loud two_candidates(bool first) {
    Loud a{"a"};
    Loud b{"b"};
    return first ? a : b;              // not a name — an expression
}

Loud two_returns(bool first) {
    Loud a{"a"};
    Loud b{"b"};
    if (first) return a;               // each return names one local
    return b;
}

int main() {
    std::printf("return first ? a : b;\n");
    Loud x = two_candidates(true);

    std::printf("\nif (first) return a; return b;\n");
    Loud y = two_returns(true);

    std::printf("\n%s %s\n", x.name.c_str(), y.name.c_str());
}

The first prints COPY. The second prints move. The difference is one sentence of the standard: an implicit move — treating the returned object as an rvalue — applies when the return statement’s operand is the name of a local object. In return first ? a : b; the operand is a conditional expression, not a name, so neither NRVO nor the implicit move applies and you get a copy.

two_returns cannot use NRVO either — the compiler would have to pick one local to construct in the caller’s storage, and there are two — but the operand is a name, so the implicit move kicks in. A move instead of nothing, rather than a copy instead of nothing.

return std::move(x) is a pessimisation

Which brings us to the habit this chapter exists to break.

The move that costs you the elision
#include <cstdio>
#include <string>
#include <utility>

struct Loud {
    std::string name;
    explicit Loud(const char* n) : name(n) { std::printf("  construct\n"); }
    Loud(const Loud&) { std::printf("  COPY\n"); }
    Loud(Loud&&) noexcept { std::printf("  move\n"); }
};

Loud plain() {
    Loud result{"plain"};
    return result;                     // NRVO: constructed in the caller
}

Loud helpful() {
    Loud result{"helpful"};
    return std::move(result);          // "helping" the compiler
}

int main() {
    std::printf("return result;\n");
    Loud a = plain();
    std::printf("return std::move(result);\n");
    Loud b = helpful();
    (void)a; (void)b;
}

plain constructs once. helpful constructs and then moves. The std::move made the code slower by one move, and the reason is mechanical: NRVO requires the returned expression to name a local object, and std::move(result) is a function call returning Loud&&, not a name. Naming the object was the qualification; the cast disqualified it.

GCC and Clang both diagnose this without being asked:

warning: moving a local object in a return statement prevents copy elision
  [-Wpessimizing-move]
note: remove 'std::move' call

There is a milder sibling. std::string f(std::string s) { return std::move(s); } does not lose an elision — a parameter can never be elided, because the caller built it — so the std::move there is merely redundant, since the implicit move already applies. GCC calls that one -Wredundant-move. Both warnings are in -Wall; both mean “delete these nine characters”.

Finding the copies that are real

None of the above is where your program actually loses time. These are.

Four copies nobody meant to write
#include <cstdio>
#include <map>
#include <string>
#include <vector>

int copies = 0;

struct Payload {
    std::string data;
    Payload() = default;
    explicit Payload(std::string d) : data(std::move(d)) {}
    Payload(const Payload& o) : data(o.data) { ++copies; }
    Payload(Payload&&) noexcept = default;
    Payload& operator=(const Payload&) = default;
    Payload& operator=(Payload&&) noexcept = default;
};

std::size_t total_size_bad(std::vector<Payload> items) {      // 1: by value
    std::size_t n = 0;
    for (Payload item : items) n += item.data.size();         // 2: by value
    return n;
}

std::size_t total_size_good(const std::vector<Payload>& items) {
    std::size_t n = 0;
    for (const Payload& item : items) n += item.data.size();
    return n;
}

int main() {
    std::vector<Payload> items;
    for (int i = 0; i < 5; ++i) items.emplace_back(std::string(64, 'x'));

    // Note the two statements: reading `copies` in the same printf call that
    // makes the call would read it before the call ran. Chapter 5.6.
    copies = 0;
    std::size_t bad_total = total_size_bad(items);
    std::printf("by value:      %zu bytes, %d copies\n", bad_total, copies);

    copies = 0;
    std::size_t good_total = total_size_good(items);
    std::printf("by reference:  %zu bytes, %d copies\n", good_total, copies);

    // 3: operator[] on a map default-constructs; at() does not.
    std::map<std::string, Payload> index;
    index.emplace("key", Payload{std::string(64, 'y')});

    copies = 0;
    Payload fetched = index["key"];                            // copies out
    int by_value = copies;

    copies = 0;
    const Payload& viewed = index.at("key");                   // no copy
    int by_reference = copies;

    std::printf("map lookup by value:     %d copies\n", by_value);
    std::printf("map lookup by reference: %d copies\n", by_reference);
    (void)fetched; (void)viewed;
}

Ten copies against zero, for one function called once with five elements. The by-value parameter copies the whole vector, and the by-value range-for copies every element again.

The review checklist, in rough order of how often it finds something:

Look for Ask
for (T x : container) should it be const T&?
a parameter taken as T is it stored, or only read? Only read → const T&
a parameter taken as const std::string& is a std::string_view enough?
auto x = expr; did expr return a reference you just copied? const auto&
map[key] used to read at or findoperator[] inserts, and cannot be const
push_back(T{...}) emplace_back(...) builds in place
a lambda’s [=] which captures did you actually mean?
a const T return type it blocks moves at every call site, for no benefit

The last one deserves a word, because it was once recommended. const Widget make() was advice for preventing make() = x;, back when returning by value meant a copy. Today the const prevents the move instead: a const Widget prvalue cannot initialise a Widget by moving, so every caller copies. Return by plain value.

Check yourself

Practice