C++ return Statements: Copy Elision, Dangling Returns and Signaling Failure

Key takeaways

Return by value and let the compiler elide or move; never return references or views into locals; treat -Wreturn-type as an error; and return a struct or std::optional instead of out-parameters and magic values.

What return actually does

A return statement does two things: it initializes the function’s result object from its operand, and then it leaves the function, destroying locals in reverse order of construction. Almost every bug around return comes from forgetting one of those halves. Either the result object refers to something that the second half is about to destroy, or the compiler did not initialize a result object at all.

int add(int a, int b) { return a + b; }

void process(int value) {
    if (value < 0) return;     // early exit from a void function
    // ...
}                              // falling off the end of a void function is fine

void functions may omit return entirely. main is special too: flowing off its end is equivalent to return 0;. Every other non-void function must reach a return on every path, and that rule matters more than it looks.

All examples below were compiled with g++ 10.3; warnings are quoted exactly as g++ prints them with -Wall -Wextra.

Missing returns are undefined behavior, not “zero”

#include <cstdio>

int sign(int v) {
    if (v > 0) return 1;
    if (v < 0) return -1;
}   // warning: control reaches end of non-void function [-Wreturn-type]

int main(int argc, char**) {
    std::printf("sign(0) = %d\n", sign(argc - 1));
}

Run with no arguments, so sign(0) falls off the end:

$ g++ -O0 ub.cpp && ./a.exe
sign(0) = 0
$ g++ -O2 ub.cpp && ./a.exe
sign(0) = -1

At -O0 the answer happened to be whatever was left in the return register; at -O2 the optimizer assumed the missing path cannot happen and folded the branches, so zero now comes out as -1. Neither is “the” behavior; the program has no defined meaning. I have seen exactly this pattern survive for a long time in a codebase because the debug build “returned 0” and all the tests passed in debug. The release build was the one that was wrong. Since then I put -Werror=return-type in every project’s flags on day one. It costs nothing, and GCC’s warning here has essentially no false positives in ordinary code; when it fires on a switch that covers every enum value, add a final return or an explicit “unreachable” call such as std::abort() after the switch.

Returning references and pointers: only to things that outlive the call

Returning T& or T* is fine when the referenced object outlives the function: a member of *this, an element of a container the caller passed in, a static object. It is never fine for a local:

const std::string& name_ref() {
    std::string s = "Alice";
    return s;
}
int* make_ptr() {
    int x = 10;
    return &x;
}
warning: reference to local variable 's' returned [-Wreturn-local-addr]
warning: address of local variable 'x' returned [-Wreturn-local-addr]

g++ catches these direct cases. It does not catch the same mistake one layer removed, which is how it usually shows up in modern code:

std::string_view label(int id) {
    std::string s = "item-" + std::to_string(id);
    return s;    // no warning from g++ 10 -Wall -Wextra; the view dangles
}

std::string_view, std::span, iterators and raw .data() pointers are all non-owning, so returning one that points into a local string or vector compiles silently and reads freed memory later. The rule of thumb: if the function created the data, return an owning type (std::string, std::vector). Return views only into storage the caller already owns. AddressSanitizer (-fsanitize=address) catches many of these as heap-use-after-free when a test actually reads the view, but not all: a short string like "item-7" lives in the small-string buffer inside the local object itself, on the stack, so detection there depends on ASan’s stack-use-after-return mode.

Return by value is usually free: elision and implicit move

Returning a std::vector or std::string by value is the idiomatic choice, because in most forms the object is either constructed directly in the caller’s storage or moved, not copied. The details decide which, and they are worth knowing because a couple of innocent-looking forms really do copy.

struct W {
    W() { std::puts("  ctor"); }
    W(const W&) { std::puts("  copy"); }
    W(W&&) noexcept { std::puts("  move"); }
};

W prvalue()           { return W{}; }                       // guaranteed elision
W named()             { W w; return w; }                    // NRVO
W two_names(bool b)   { W a, c; if (b) return a; return c; } // implicit move
W ternary(bool b)     { W a, c; return b ? a : c; }         // copy
W from_param(W w)     { return w; }                         // implicit move
struct Holder { W w; };
W member_of_local()   { Holder h; return h.w; }             // copy
W moved()             { W w; return std::move(w); }         // move, NRVO lost

Output with g++ -std=c++17 -O2 (constructions of the extra locals included):

FunctionPrintedWhy
prvaluectorC++17 guarantees no temporary exists; the result is built in place.
namedctorNRVO: allowed, not required, but GCC does it for a single named return.
two_namesctor, ctor, moveTwo different named results, so NRVO is impossible; return name; falls back to move.
ternaryctor, ctor, copyb ? a : c is not a plain name, so neither elision nor implicit move applies.
from_paramctor, moveParameters are never elided into the result, but they are implicitly moved.
member_of_localctor, copyA subobject is not “the local”; implicit move does not apply.
movedctor, movestd::move turned an elidable return into a mandatory move.

With -fno-elide-constructors, named also prints a move, which shows that NRVO is an optimization while the prvalue case is a language guarantee.

Two practical consequences:

  • Do not write return std::move(local);. It is never faster and often slower. g++ tells you:

    warning: moving a local object in a return statement prevents copy elision [-Wpessimizing-move]
    warning: redundant move in return statement [-Wredundant-move]

    The first (in -Wall) is for locals where elision was possible; the second (in -Wextra) is for parameters, where it was already going to move.

  • Rewrite ternaries and member returns when the type is expensive. if (b) return a; return c; restores the implicit move. For a member of a local, return std::move(h.w); is the one place where an explicit move on return is correct.

The deeper rules, including why NRVO fails across different branches, are in RVO vs NRVO.

Returning more than one value

Before C++17, “multiple return values” meant out-parameters (bool parse(const char*, int& out)). They still work, but they force the caller to declare an uninitialized variable first and make it unclear which arguments are inputs. Returning an aggregate is clearer:

#include <charconv>
#include <optional>
#include <string_view>

struct ParseResult { int value; std::size_t consumed; };

std::optional<ParseResult> parse_int(std::string_view s) {
    int v = 0;
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), v);
    if (ec != std::errc{}) return std::nullopt;
    return ParseResult{v, static_cast<std::size_t>(ptr - s.data())};
}

int main() {
    if (auto r = parse_int("123abc")) {
        auto [value, consumed] = *r;
        std::cout << value << " (" << consumed << " chars)\n";   // 123 (3 chars)
    }
    std::cout << parse_int("abc").has_value() << '\n';           // 0
}

Why a named struct instead of std::pair<int, std::size_t>? Because .first and .second carry no meaning, and two fields of compatible types are easy to swap by accident. A pair or std::tuple is fine for a local helper whose result is immediately unpacked with structured bindings; for anything in a header, name the fields. std::from_chars itself follows this design: it returns std::from_chars_result with named members ptr and ec.

Signaling failure through the return value

The return type is the most visible part of a function’s contract, so it should say whether failure is possible:

SituationReturn typeCaller sees
Always succeedsTJust the value
May have no result, reason is obviousstd::optional<T>if (r) / value_or
May fail, caller needs to know whystd::expected<T, E> (C++23, GCC 12+) or a struct { T value; std::error_code ec; }Error details without exceptions
Failure is rare and should unwindT plus an exceptiontry/catch further up

Avoid sentinel values such as -1 or an empty string when those are also valid results; the caller has no way to tell them apart. std::optional in practice and std::expected cover the trade-offs of each.

When ignoring the result is almost always a bug, mark the function [[nodiscard]]:

[[nodiscard]] bool save();
int main() { save(); }
warning: ignoring return value of 'bool save()', declared with attribute 'nodiscard' [-Wunused-result]

Error-returning functions, factories that return an owning handle, and pure functions such as empty() are the usual candidates. Callers who really mean to discard can write (void)save(); or std::ignore = save();, which also documents the intent.

auto return types

With a deduced return type, every return must deduce the same type:

auto pick(bool b) {
    if (b) return 1;
    return 2.0;
}
error: inconsistent deduction for auto return type: 'int' and then 'double'

That is a helpful error, not an obstacle: it forces you to decide what the function returns. auto also never deduces a reference (auto strips &), so a getter written as auto get() { return member_; } returns a copy. Use const auto& or decltype(auto) if you meant to return a reference, and then apply the lifetime rules from above.