P2025R1
Guaranteed copy elision for return variables

Draft Proposal,

Issue Tracking:
Inline In Spec
Author:
Audience:
EWG, CWG
Project:
ISO/IEC JTC1/SC22/WG21 14882: Programming Language — C++
Current Source:
https://github.com/Anton3/cpp-proposals/blob/master/published/p2025r1.bs
Current:
https://github.com/Anton3/cpp-proposals/blob/master/published/p2025r1.html

Abstract

This proposal aims to provide guaranteed copy elision for common cases of local variables being returned from a function, a.k.a. "guaranteed NRVO".

1. Revision history

R1 (post-Prague):

2. Introduction

When a function cannot carry its result in registers, implementations add a hidden parameter containing the address of the caller-owned return slot (which is usually an object in the C++ sense). When the function decides to return, the result of the function is stored there. For example, in C++14, 2 copies could be performed for both foo and bar (assuming widget is not trivially copyable):

widget foo() {      // hidden parameter: widget* r  widget x;  return x;         // copy}widget bar() {      // hidden parameter: widget* r  return widget();  // copy}void test() {  auto y = foo();   // copy  auto z = bar();   // copy}

x or widget() was constructed, then copied into the return slot (*r), then the return slot was copied into y or z. The last copy followed from the imperfect pre-C++17 value category design and is irrelevant to our purposes.

The implementation could eliminate some copies under the as-if rule or as allowed by copy elision. The copy in bar could be eliminated by Unnamed Return Value Optimization (URVO) and the copy in foo — by Named Return Value Optimization (NRVO): widget() and w would be constructed directly into the return slot. Collectively, both optimizations are sometimes known as RVO; confusingly, URVO is sometimes also called RVO. Note that all of these terms are applied to the implementation and to the physical machine, and are not defined in the Standard.

The updated value category and temporary materialization rules brought by C++17 (informally known as guaranteed copy elision) mandated, among everything, that no copies or moves can be made for a returned prvalue expression (except for trivially copyable class types). This made URVO mandatory in most cases.

In C++20, the only copy allowed in the example is the one at line 3 (actually, x is implicitly moved from). Copy elision (NRVO) is allowed there and is routinely performed by most compilers, but is still non-guaranteed, and the widget class cannot be non-copyable non-movable. With the proposed wording, x is called a return variable, and no copy or move is performed at line 3 — this copy evasion will also informally be called guaranteed copy elision.

To differentiate between the two kinds of guaranteed copy elision, it is sometimes useful to use informal terms "guaranteed URVO" and "guaranteed NRVO", meaning the respective language features. Unless otherwise specified, "copy elision" refers to the implementation-defined copy elision provided by [class.copy.elision].

3. Motivation

What follows are the examples where the absence of guaranteed copy elision for returned variables enforces rewriting the code in a way that is less readable or efficient.

3.1. Construct-cook-return

Sometimes we want to create an object, set it up and return it.

widget setup_widget(int x) {
  widget w;
  w.set_x(x);
  return w;  // move or copy elision
}

Implementations usually do perform copy elision in such simple cases, but widget must be at least movable. This situation is unacceptable in these cases, among others:

On practice, the workaround can be either:

Both "solutions" are often viewed as anti-patterns. A proper solution should allow for the construct-cook-return pattern, even if a copy or move is not affordable.

3.2. Construct-cleanup-return

With [P1144R5], we may be able to relocate elements out of containers, which should be more efficient:

widget widget_owner::pilfer() {
  widget w = this->relocate_internal();
  this->cleanup_storage();
  return w;  // move or copy elision
}

Unfortunately, such a clean-up work inhibits guaranteed copy elision. This can, however, be worked around using a facility like scope_success from [P0052R9]:

widget widget_owner::pilfer() {
  auto s = scope_success([&]{ this->cleanup_storage(); });
  return this->relocate_internal();  // guaranteed copy elision
}

The code rewritten in such a way is less straightforward and contains the potential overhead of scope_success.

3.3. Operator rewrites

[P1046R2] proposes automatically generating operator++(int) for a type that implements operator+=(int). Its definition would look approximately as follows:

T T::operator++(int) {
  T result = *this;  // intended copy
  *this += 1;
  return result;  // guaranteed copy elision wanted
}

In order to deliver on the promise of guaranteed copy elision there, we would have to use the scope_success trick described above.

4. Proposed solution

If a returned variable is of the same non-trivially-copyable class type as the return type of the function (ignoring cv-qualification), and all non-discarded return statements in its potential scope return the variable, guaranteed copy elision is performed. The type of the variable is allowed to be non-copyable, non-movable.

(For the purposes of brevity, the explanation above is not rigorous; see § 5 Proposed wording for a rigorous explanation.)

To state the gist of the main requirement even easier (and even less rigorous):

Copy elision is guaranteed for return x; if every return "seen" by x is return x;

4.1. Examples

Suppose that widget is a class type, is copy-constructible and move-constructible. Unless stated otherwise, references to copy elision utilize [class.copy.elision]/(1.1).

Legend:

4.1.1. Example 1

widget setup_widget(int x) {  return widget(x);}

4.1.2. Example 2

widget setup_widget(int x) {  auto w = widget();  w.set_x(x);  return w;}

4.1.3. Example 3

Guaranteed copy elision cannot be performed at line 4, because the return statement on line 6, which belongs to the potential scope of w, returns non-w.

widget test() {  widget w;  if () {    return w;  //!  } else {    return widget();  }}

4.1.4. Example 4

The example above can be "fixed" so that guaranteed copy elision is performed. Now all the return statements within the potential scope of w (the one on line 4) return w.

widget test() {  if () {    widget w;    return w;  } else {    return widget();  }}

4.1.5. Example 5

Guaranteed copy elision cannot be performed at line 6, because the return statement on line 4, which belongs to the potential scope of w, returns non-w.

widget test() {  widget w;  if () return {};  return w;  //!}

4.1.6. Example 6

The example above can be "fixed" so that guaranteed copy elision is performed. Now all the return statements within the potential scope of w (the one on line 7) return w.

widget test() {  do {    widget w;    if () break;    return w;  } while (false);  return {};}

4.1.7. Example 7

Here, the return statement at line 2 does not belong to the potential scope of b (which starts at line 3), therefore not inhibiting guaranteed copy elision at line 5.

widget test() {  if () return {};  //!  widget b;  return b;}

4.1.8. Example 8

Here, the return statement at line 5 belongs to the potential scope of one and thus inhibits guaranteed copy elision for one.

widget test() {  widget one;  if (toss_a_coin()) return one;  //!  widget two;  return two;}

4.1.9. Example 9

Constructing, setting up and passing an object as a parameter by value using an immediately invoked lambda expression (p is directly initialized under the name of w).

void consume_widget(widget p);void test(int x) {  int y = process(x);  consume_widget([&] {    auto w = widget(x);    w.set_y(y);    return w;  }());}

4.1.10. Example 10

If w is not returned, it is destroyed, and another widget (perhaps, another object named w) can take its place.

widget test() {  if (false) {    impossible:    if () return widget();  }  while () {    widget w;    if () return w;    if () break;    if () continue;    if () goto impossible;    if () throw;    if () return w;  }  return widget();}

4.1.11. Example 11

Implementation-wise, w1, w2 and w3 will occupy the return slot at different times. Wording-wise, if we reach line 10, then w1 and w2 are not considered to have ever denoted the result object of the function call; they are considered normal local variables, so the result object of the function is only constructed and destroyed once.

widget test() {  {    {      widget w1;      if () return w1;    }    widget w2;    if () return w2;  }  widget w3;  return w3;}

4.1.12. Example 12

Guaranteed copy elision will be unaffected by a nested class, a lambda capture and a discarded return statement.

widget test() {  widget w;  struct s { widget f() { return widget(); } };  auto l = [&w]() { return widget(); }();  if constexpr (false) { return widget(); }  return w;}

4.1.13. Example 13

Guaranteed copy elision will be required in constant evaluation context.

consteval widget test() {  widget x;  if () return x;  widget y;  return y;}constinit widget z = test();

4.1.14. Example 14

void foo();  // throws a widgetwidget test() {  try {    foo();  } catch (widget w) {  //!    watch(w);    return w;  }}

See also: § 6.2.3 Exception-declarations can introduce return variables

4.1.15. Example 15

widget test() {  widget x;  if (toss_a_coin()) return (x);  //!  widget y;  return ((y));}

See also: § 6.2.1 Parentheses are now allowed around the variable name

4.1.16. Example 16

For trivially copyable types, a copy may still be introduced.

struct gadget {  gadget* p;  gadget() : p(this) {}};gadget test() {  gadget x;  return x;  //!}gadget y = test();

See also: § 6.3 What about trivially copyable temporaries?

4.1.17. Example 17

For non-class types, a copy may still be introduced.

using large = std::intmax_t;large test() {  large x = 42;  return x;}large a = test();large b = test() + test();signed char c = test();

See also: § 6.4 What about copy elision for non-class types?

4.1.18. Example 18

template <bool B>widget test() {  widget w;  if constexpr (B) {    if (false) return widget();  }  return w;}

4.1.19. Example 19

const volatile widget foo() {  widget a;  a.mutate();  // OK  return a;}auto b = foo();widget bar() {  if () {    const widget c;    // c.mutate();  // ERROR    // const_cast<widget&>(c).mutate();  // UB    return c;  }  volatile widget d;  return d;  //!}auto e = foo();void baz() {  // b.mutate();  // ERROR  // const_cast<widget&>(b).mutate();  // UB  e.mutate();  // OK}

4.1.20. Example 20

extern widget x;widget test() {  widget y;  if (&x == &y) {  // true    throw 0;  }  return y;}widget x = test();

5. Proposed wording

The wording in this section is relative to WG21 draft [N4861].

5.1. Definitions

Add new sections in [class.copy.elision]:

A return ([stmt.return]) or co_return ([stmt.return.coroutine]) statement R directly observes a variable V if the potential scope ([basic.scope]) of V includes R and V is declared in the body or parameter-declaration-clause of the innermost enclosing function or lambda-expression of R. If, additionally, the operand of R is a (possibly parenthesized) id-expression designating V, then R returns the variable V.
A variable with automatic storage duration is called a potential return variable when all of the following conditions are satisfied:

If additionally, all non-discarded return statements, which directly observe the variable, return it, then the variable is called a return variable. [ Note: A potential return variable shall have an eligible copy or move constructor ([class.copy.ctor]), unless it is a return variable. — end note ]

Note: The definition avoids mentioning the object a return variable names prematurely.
Note: See also § 6.2 Have requirements for copy elision changed?, § 6.4 What about copy elision for non-class types?
Too many new terms might have been introduced: to directly observe a variable, to return a variable, potential return variable, return variable.
Should we say "function or lambda-expression", or is it enough to say "function"?

Modify [class.copy.elision]/3:

[…] In the following copy-initialization contexts, a move operation might be used instead of a copy operation:

5.2. The behavior of return variables

Add new sections in [class.copy.elision]:

A return variable occupies the same storage as the result object of the function call expression.
If the scope of a return variable is exited by executing a statement that returns the variable (and the destruction of local variables is not terminated by an exception), then the variable denotes the result object of the function call expression. [ Note: If the return variable is of a trivially copyable type, then a temporary object can be introduced, with a subsequent copy or move ([class.temporary]). In this case the variable shall denote the temporary. — end note ] The statement that returns the variable performs no copy-initialization ([stmt.return]) and does not cause the destruction of the object ([stmt.jump]). Except for that, until the control is transferred out of the function, the variable shall be treated as a local variable with automatic storage duration, and const and volatile semantics ([dcl.type.cv]) applied to the object corresponding to the type of the return variable. [ Example:
class A {
  int x;
  A() = default;
  A(A&&) = delete;
};

A f() {
  A a;       // "a" is a return variable
  a.x = 5;   // OK, a has non-const semantics
  return a;  // OK, no copy-initialization
}

const A b = f();  // "b" names the same object as "a"

end example ] The destructor for the object is potentially invoked ([class.dtor], [except.ctor]). [ Example:

class A {
  ~A() {}
};
A f() {
  A a;
  return a;  // error: destructor of A is private (even though it is never invoked)
}

end example ]

If the scope of a return variable is exited in a way other than by executing a statement that returns the variable (or the destruction of a local variable is terminated by an exception), the return variable is considered a normal local variable that denotes an object of automatic storage duration. [ Note: In particular, on exit from the scope, the object is destroyed, among other objects of automatic storage duration, in the reverse order of construction ([stmt.jump]). If destruction of a local variable is terminated by an exception, the return variable can be destroyed out of normal order, as per [except.ctor]. — end note ] [ Example:
struct A {
  A() = default;
  A(A&&) { }
};

struct X { ~X() noexcept(false) { throw 0; } };

A f() {
  A a;
  X x;
  A b;
  A c;  // #1
  A d;
  return c;  // #2
}

At #1, the return variable c is constructed in the storage for the result object of the function call expression. The local variable c does not denote the result object, because the control will exit the scope of c by the means of an exception. At #2, the local variable d is destroyed, then the local variable b is destroyed. Next, the local variable x is destroyed, causing stack unwinding, resulting in the destruction of the local variable c, followed by the destruction of the local variable a. The function call is terminated by the exception.

end example ]

Until the control is transferred out of the function, if the value of the object of the return variable or any of its subobjects is accessed through a glvalue that is not obtained, directly or indirectly, from the variable, the value of the object or subobject thus obtained is unspecified.
Note: The object of a return variable is analogous to an object under construction. Some wording was borrowed from [class.ctor] and [class.cdtor]/2.
Note: See also § 6.3 What about trivially copyable temporaries?, § 6.5 What about the invalidation of optimizations?
Following the proposed wording, it cannot be known at the time of initialization of the return variable's object, what object it is exactly ("Schrodinger’s object"). Later on, it is retrospectively established that we either have initialized and worked with a normal local variable, or with the result object of the function call expression.

Modify [class.copy.elision]/1:

[…] This elision of copy/move operations, called copy elision, is permitted in the following circumstances (which may be combined to eliminate multiple copies):

5.3. Cross-references

Modify [stmt.jump]/2:

On exit from a scope (however accomplished), objects with automatic storage duration that have been constructed in that scope are destroyed in the reverse order of their construction. [ Note: For temporaries, see [class.temporary]. For objects participating in copy elision, see [class.copy.elision].end note ] Transfer out of a loop, out of a block, or back past an initialized variable with automatic storage duration involves the destruction of objects with automatic storage duration that are in scope at the point transferred from but not at the point transferred to. (See [stmt.dcl] for transfers into blocks). […]

Modify [stmt.return]/2:

[…] A return statement with any other operand shall be used only in a function whose return type is not cv void; the return statement initializes the glvalue result or prvalue result object of the (explicit or implicit) function call by copy-initialization from the operand (except when copy elision applies ([class.copy.elision])) . […]

Modify [stmt.dcl]/2:

Variables with automatic storage duration are initialized each time their declaration-statement is executed. [ Note: Variables with automatic storage duration declared in the block are destroyed on exit from the block as described in ( [stmt.jump] ) . end note ]
Note: The modified sentence currently duplicates the specification in [stmt.jump]/2. If the sentence is turned into a reference, it will not have to duplicate the exception for return variables.

Modify [class.dtor]/15:

[…] A destructor is potentially invoked if it is invoked or as specified in [expr.new], [stmt.return], [class.copy.elision], [dcl.init.aggr], [class.base.init], and [except.throw]. A program is ill-formed if a destructor that is potentially invoked is deleted or not accessible from the context of the invocation.

5.4. Returning non-class types

Modify [class.temporary]/3:

When an object of class type X is passed to or returned from a function, if X has at least one eligible copy or move constructor ([special]), each such constructor is trivial, and the destructor of X is either trivial or deleted, implementations are permitted to create a temporary object to hold the function parameter or result object. The temporary object is constructed from the function argument or return value, respectively, and the function’s parameter or return object is initialized as if by using the eligible trivial constructor to copy the temporary (even if that constructor is inaccessible or would not be selected by overload resolution to perform a copy or move of the object). Similarly, when an object of a non-class type is passed to or returned from a function, implementations are permitted to create a temporary object. [ Note: This latitude is granted to allow objects of class type to be passed to or returned from functions in registers. — end note ]
Note: CWG2434 (alt. link) proposes essentially the same change. The wording might be more precise over there.

Add a new section after [expr.context]/2:

When a function call prvalue expression of non-class type other than cv void is used to compute the value of an operand of a built-in operator, the prvalue result object is a temporary object.

Modify [basic.lval]/5:

The result object of a prvalue is the object initialized by the prvalue; a non-discarded prvalue that is used to compute the value of an operand of a built-in operator or a prvalue that has type cv void has no result object. [ Note: Except when the prvalue is the operand of a decltype-specifier, a prvalue of class or array type always has a result object. For a discarded prvalue that has type other than cv void, a temporary object is materialized; see [expr.context]. — end note ]
Note: See also § 6.4 What about copy elision for non-class types?.

6. Discussion

6.1. Is "return variable" a good term choice?

"Return variable" might not be the best term for our purposes.

A previous revision of this proposal (R0) used the term "named return object". That term choice was unfortunate, because it refers to a variable, not to an object. And a variable cannot be "unnamed", so that was excessive.

Some alternative choices:

6.2. Have requirements for copy elision changed?

There are multiple issues with current wording for copy elision. While [class.copy.elision]/3 has recently been updated ([P0527R1], [P1155R3], [P1825R0]), [class.copy.elision]/1 has not. Proposed wording cleans up those issues.

6.2.1. Parentheses are now allowed around the variable name

See § 4.1.15 Example 15. x is considered a return variable, and y a potential return variable.

Meanwhile, at the time of writing, copy elision is not allowed for parenthesized variables.

Implementation divergence has been discovered. Clang and MSVC currently does perform copy elision there, but GCC does not. Consequently, this change may be delivered in a Defect Report.

6.2.2. Copy elision is no longer allowed for lambda captures

In the following case, name lookup for w at line 4 finds exactly the outer w variable. The w expression does satisfy the "name of a non-volatile object with automatic storage duration" condition. Therefore, copy elision is currently allowed for the inner return statement. This seems unintentional; none of the major compilers performs copy elision in this case.

widget foo() {  widget w;  return [&w] {    return w;  }();}

This case will no longer be eligible for copy elision under the proposed wording. This change may be delivered in a Defect Report as well.

6.2.3. Exception-declarations can introduce return variables

Note: Guaranteed copy elision will only affect exceptions caught by value ("by copy"). Exceptions caught by reference are not affected.
struct widget {  widget();  widget(const widget&);  widget(widget&&);};void bar();bool toss_a_coin();widget foo() {  try {    bar();  } catch (widget w) {            // (1.4)    use(w);    if (toss_a_coin()) return w;  // (1.1)  }  return widget();}

Not applying [class.copy.elision]/(1.4)

Applying [class.copy.elision]/(1.4) (non-guaranteed)

This proposal can inhibit [class.copy.elision]/(1.4) in some edge cases for a particular compiler. In return, copy elision is guaranteed consistently for the return statement ([class.copy.elision]/(1.1)).

Overall, it seems that this is an edge case that can be removed painlessly. To guarantee the absence of copies for the exception object, one should catch the exception by reference, instead of catching by copy and complaining about the copy. On the other hand, when a copy is intended, this proposal ensures that the caught exception is treated as well as any other variable.

The previous restriction in this case looks like not-a-defect. Should this change belong to a separate proposal?

6.3. What about trivially copyable temporaries?

According to [class.temporary], the implementation is allowed to create a copy when the object of a trivially copyable type is returned. That is also the case when the copied object participates in (existing or proposed) guaranteed copy elision. If the address of such an object is saved to a pointer variable, the pointer will become dangling on return from the function:

class A {
public:
  A* p;
  A() : p(this) {}
};

A existing() {
  return A();
}
A x = existing();  // x.p may be dangling

A* q;
A proposed() {
  A y = A();
  q = &y;
  return y;
}
A z = proposed();  // z.p and q may be dangling

Changing [class.temporary] and prohibiting such temporaries would cause ABI breakage, and is infeasible. ABI issues aside, it is not desirable to prohibit optimizations related to liberal treatment of trivially copyable types.

The amount of copying will still be reduced even for those cases. Currently it is implementation-defined whether a copy is elided ([class.copy.elision]/1) and whether a temporary is created ([class.temporary]/3). Depending on that, either 0, 1 or 2 copies may be performed (counting moves as copies). For example:

std::pair<int, int> foo() {
  auto a = std::pair(1, 2);
  return a;
}
auto b = foo();

Currently, 4 scenarios are possible:

  1. Copy elided, no temporary. a and b denote the same object. 0 copies

  2. Copy elided, temporary. a denotes the temporary, trivially copied into b. 1 copy

  3. Copy not elided, no temporary. a is a normal local variable, trivially copied into b. 1 copy

  4. Copy not elided, temporary. a is trivially copied into the temporary, which is then trivially copied into b. 2 copies

With this proposal accepted, only scenarios 1 and 2 are possible.

See also: § 4.1.16 Example 16

6.4. What about copy elision for non-class types?

Currently, [class.copy.elision]/1 disallows copy elision when a variable of a non-class type is returned. Moreover, it is difficult to talk about copy elision for non-class types, because a function call with result of a non-class object type might not have a result object (see [expr.prop]/5).

Meanwhile, there are definitely situations where results of non-class types are passed on stack. An implementation can perform NRVO, so the result gets written directly into the memory location specified by the caller. For example, on Clang:

// substitute with any non-class object type passed on stack,
// e.g. std::int64_t on a 16-bit system.
using big_t = _ExtInt(256);

big_t* px = nullptr;

big_t foo() {
  big_t x = 0;
  px = &x;
  return x;
}

void test() {
  big_t y = foo();
  printf("%p  %p  %d\n", py, &y, py == &y);
  //=>  <addr.>  <same addr.>  0
}

While x and y represent the same object "in the metal", they name different objects in the current C++ sense of "object", thus they compare unequal.

The proposed wording suggests being more honest in this regard by saying that a function with object return type always has a result object, and then by allowing non-class object types to participate in copy elision on equal rights with trivially-copyable objects.

See also: § 4.1.17 Example 17

Should copy elision for non-class types belong to a separate proposal?

6.5. What about the invalidation of optimizations?

Observe an example:

struct A {  int x;  A(int x) : x(x) {}  A(const A& o) : x(o.x) {}};extern A global;A foo() {  A local(2);  local.x += global.x;  return local;}

Currently, copy elision is not guaranteed here and cannot make invalid code valid. global at line 10 can be assumed to have nothing common with local, e.g. global cannot be defined as A global = foo();. Compilers use this knowledge to optimize foo to the equivalent of:

A foo() {
  return A(2 + global.x);
}

Under this proposal, local and global can be assumed to be distinct too, for a different reason. Inside A global = foo();, global and local are guaranteed to denote the same object, because local is a return variable. Before foo returns, the value of local.x is accessed through glvalue global.x that is not obtained from local, thus the value of global.x is unspecified.

In summary, code which would require invalidation of optimizations for its correctness is kept incorrect.

6.5.1. Complication of escape analysis

Previously, implementations could choose to never perform copy elision ([class.copy.elision]/(1.1)) for some non-trivially-copyable class types. For these classes, the implementation could assume that the address of a local variable never escapes:

struct widget {
  widget() { }
  widget(widget&&) { }
};

widget foo();   // invisible implementation
widget* bar();  // invisible implementation

void test() {
  widget x = foo();
  if (&x == bar()) throw "impossible";
}

Under the proposed wording, test is guaranteed to throw if we define foo and bar as follows:

widget* py;

widget foo() {
  widget y;
  py = &y;
  return y;
}

widget* bar() {
  return py;
}

Accounting for such cases will lead to pessimizations for some implementations.

6.6. Are the proposed changes source or ABI breaking?

Propored changes can break constant expressions that rely on effects of the copy-initialization and destruction that are proposed to be elided. The defect report [CWG2278], requiring that copy elision is not performed in constant expressions, has been presented in March, 2018. However, relying on the effects of copy-initialization and destruction in constant expressions is considered exotic, and real-world code breakage is deemed to be minimal.

The proposal prohibits some corner-case copy elision (§ 6.2.2 Copy elision is no longer allowed for lambda captures), which was most probably a defect. Note that previously, copy elision could not be relied upon by portable programs.

The proposal allows some new cases of copy elision described in previous sub-sections. Programs that relied on copy elision not being performed there (corner-case scenarios) will no longer be valid.

The proposal is not ABI-breaking, because, in all known implementations, whether NRVO is performed for a function does not impact its calling convention.

6.7. What are the costs associated with the proposed changes?

There is no runtime cost associated with the proposed copy elision, because storage for the return object is allocated on stack before the function body starts executing, in all known implementations.

The proposal will make declarations of local variables with automatic storage duration context-dependent: storage of a variable will depend on return statements in its potential scope. However, this analysis is local and purely syntactic. The impact on compilation times is thus deemed to be minimal.

Compilers that already perform NRVO will enable it (or at least the required part of it) in all compilation modes. The proposal might even have a positive impact on compilation time, because such implementations will not have to check whether copy-initialization on the return type can be performed.

7. Implementation experience

7.1. How to deal with exceptions?

If the return rv; statement is executed, but the destruction of a local variable throws, we may need to destroy the return variable. Some popular compilers, such as GCC and Clang, fail to call the destructor for the return variable in such cases (as of the time of writing) when they choose to perform copy elision:

Here are some tests that we have to keep in mind when implementing the proposal:

#include <cstdio>

struct X {
  char s;
  bool throws;

  ~X() noexcept(false) {
    printf("~%c\n", s);
    if (throws) throw 0;
  }

  X(X&& o) = delete;

  explicit X(char s, bool throws = false) 
    : s(s), throws(throws)
  {
    printf("%c\n", s);
  }
};

// correct order of destruction: ma
X test1() {
  X m('m', true);
  return X('a');
}

// correct order of destruction: dbmca
X test2() {
  X a('a');
  X m('m', true);
  X b('b');
  X c('c');
  X d('d');
  return c;
}

// correct order of destruction: mab
X test3() {
  X a('a');
  X b('b');
  try {
    X m('m', true);
    return b;
  } catch (...) { }
  return b;  // b is returned here
}

// correct order of destruction if cond:  mbad
// correct order of destruction if !cond: bmcad
X test4(bool cond) {
  X a('a');
  try {
    X m('m', true);
    {
      X b('b');
      if (cond) {
        return b;
      }
    }
    {
      X c('c');
      return c;
    }
  } catch (...) { }
  return X('d');
}

int main() {
  try { test1(); } catch(...) {} puts("");
  try { test2(); } catch(...) {} puts("");
  test3(); puts("");
  test4(true); puts("");
  test4(false); puts("");
}

7.2. An exception-safe implementation strategy

All possible "exceptional" cases can be grouped into two categories:

To deal with the various possible circumstances, the following implementation strategy is suggested:

It is expected that constant propagation eliminates all branches at least for non-exceptional paths. An implementation that uses Table-Driven Exception Handling can instead fold these flags into exception-handling tables, eliminating all overhead for non-exceptional paths.

7.3. When is copy elision for potential return variables feasible?

Extended copy elision (treating a potential return variable as a return variable) does not seem to be easily achievable. For example:

bool toss_a_coin() {
  return true;
}

widget test() {
  widget a;
  widget b;
  widget c;
  if (toss_a_coin()) {
    return widget();  // d
  }
  return b;
}

If the implementation treats b as a return variable, then the destruction shall occur in the order "dcba". (Because b does not end up being returned, is shall be treated like a normal local variable.) But that means b is destroyed after d is constructed, which is not possible. To perform copy elision in this case, the implementation must prove that the destruction of b can be moved right before the construction of d (and before the destruction of c!) under the "as-if" rule.

This only seems feasible in case no non-trivially-destructible variables are declared between the potential return variable b and the return statement that does not return b, and if the operand of the return statement is "sufficiently pure". These limiting conditions are satisfied in the following common case:

std::vector<int> test() {
  std::vector<int> result;
  fill_vector(result);
  if (something_went_wrong(result)) return {};
  return result;
}

7.4. A proof of concept implementation in Circle

The suggested exception-safe algorithm has been implemented by Sean Baxter in Circle compiler [p2062r0], build 98. For the purposes of this proposal, Circle can be viewed as a Clang fork with several extensions, including, now, guaranteed copy elision for return variables.

The initial testing shows that all the cases are handled correctly. Analysis of the IR and assembly at -O2 shows no sign of the flags - they are eliminated by constant propagation and incorporated into TDEH by the existing passes.

The only limitation of the current implementation is that discarded branches of "if constexpr" can still prevent "guaranteed NRVO" from happening. No non-guaranteed copy elision for potential return variables is provided.

8. Alternative solutions

8.1. Implement similar functionality using existing features

We can implement similar functionality, with cooperation from the returned object type, in some cases.

Suppose the widget class defines the following constructor, among others:

template <typename... Args, std::invocable<widget&> Func>
widget(Args&&... args, Func&& func)
  : widget(std::forward<Args>(args)...)
  { std::invoke(std::forward<Func>(func)), *this); }

We can then use it to observe the result object of a prvalue through a reference before returning it:

widget setup_widget(int x) {
  int y = process(x);

  return widget(x, [&](widget& w) {
    w.set_y(y);
  });
}

However, it requires cooperation from widget and breaks when some of its other constructors accept an invocable parameter. We cannot implement this functionality in general.

8.2. "Guarantee NRVO" in more cases

class builder {
public:
  builder();
  widget build();
  widget rebuild();};

widget foo() {
  builder b;
  widget w = b.build();
  if () return b.rebuild();
  return w;
}

Copy elision will not be guaranteed for w, according to this proposal. Meanwhile, one could say that it could be guaranteed: if the condition is true, then we could arrange it so that w (which is stored as the return object) is destroyed before b.rebuild() is called.

However, what if build saves a pointer to the returned object, which is then used in rebuild? Then the b.rebuild() call will try to reach for w, which will lead to undefined behavior.

While the compiler can in some cases analyze the control flow and usage patterns (usually after inlining is performed), this is impossible in general. (This is why a previous attempt at "guaranteeing NRVO" was shut down, see [CWG2278].) The limitations of the proposed solution describe the cases where correctness can always be guaranteed without overhead and non-local reasoning.

8.3. Require an explicit mark for return variables

As an alternative, return variables could require a specific attribute or a mark of some sort in order to be eligible for guaranteed copy elision:

widget setup_widget(int x) {
  widget w [[nrvo]];
  w.set_x(x);
  return w;
}

The mark would only be applicable to non-trivially-copyable return types.

The benefit of requiring a mark is that the compiler would not have to determine for each local variable whether it could be a return variable. However, the cost of the compile-time checks is deemed to be low, while there would be some language complexity cost associated with the mark.

Another benefit would be that the reader of the code would be able to see at a glance that w is a return variable without reading further.

The arguments against requiring an explicit mark are:

8.4. Alias expressions

Alias expressions would be a new type of expression. An alias expression would accept a prvalue, execute a block, providing that block a "magical" reference to the result object of that prvalue, and the alias expression would itself be a prvalue with the original result object:

widget setup_widget(int x) {
  return using (w = widget()) {
    w.set_x(x);
  };
}

Such a construct would require more wording and special cases on the behavior of the "magical" reference w and the underlying object. It would be prohibited to return from inside the block of the alias expression. More importantly, alias expressions would introduce the precedent of an expression that contains statements, which has issues with a lot of the standard. And as with explicit marks, it introduces more syntax, which the proposed solution avoids.

Alias expressions could also be used to get rid of copies in places other than the return expressions, e.g. when passing a function argument by value:

void consume_widget(widget);

void test(int x) {
  consume_widget(using (w = widget()) {
    w.set_x(x);
  });
}

The proposed solution can be used with an immediately invoked lambda expression to perform the same task:

void consume_widget(widget);

void test(int x) {
  consume_widget([&] {
    widget w;
    w.set_x(x);
    return w;
  }());
}

9. Future work

9.1. Guarantee some other types of copy elision

[class.copy.elision]/1 describes 4 cases where copy elision is allowed. Let us review whether it is feasible to guarantee copy elision in those cases:

9.2. Guarantee currently disallowed types of copy elision

Requiring copy elision in more cases than is currently allowed by the standard is a breaking change and is out of scope of this proposal. If another proposal that guarantees copy elision in more cases is accepted, those cases could also be reviewed for feasibility of guaranteed copy elision. This proposal will not be influenced by that future work.

9.3. Reduce the number of moves performed in other cases

This proposal belongs to a group of proposals that aim to reduce the number of moves performed in C++ programs. Within that group, there are two subgroups:

The problem solved by the current proposal is orthogonal to the problems dealt with by relocation proposals, as well as to the problem dealt with by P0927R2.

The current proposal combines with [P0927R2] nicely. That proposal requires that the lazy parameter is only used once (and forwarded to another lazy parameter or to its final destination), while in some cases it may be desirable to acquire and use it for some time before forwarding. This proposal would allow to achieve it in a clean way, see the immediately invoked lambda expression example.

The changes proposed by this proposal and [P0927R2], combined, would allow to implement alias expressions (see the corresponding section) without any extra help from the language:

template <typename T, invokable<T&> Func>
T also([] -> T value, Func&& func) {
  T computed = value();
  func(computed);
  return computed;
}

void consume_widget(widget);

void test(int x) {
  consume_widget(also(widget(x), [&](auto& w) {
    w.set_x(x);
  }));
}

9.4. Allow temporaries to be created for types other than trivially-copyables

Currently, extra copies of the result object are allowed for trivially copyable types, to allow passing those objects in registers, presumably when it is beneficial for performance. A relocation proposal, such as [P1144R5], could allow trivially relocatable types to be treated the same way. If so, then those types will need to be excluded from guaranteed copy elision.

This important change will be source-breaking and will lead to silent UB and bugs if the relocation proposal (with the exemption for trivially relocatable types) is accepted in a later C++ version compared to this proposal.

9.5. std::pin<T> class

For trivially copyable types, copy elision will still be non-guaranteed: the implementation may do a trivial copy to pass the result in registers. Meanwhile, sometimes it is highly desirable to have the guarantee for the absence of copies, e.g. when a pointer to the variable is stored elsewhere. To help in this situation, we may want a non-copyable, non-movable wrapper pin<T> which is an aggregate with a single value data member. It can be used as follows:

struct A {
  A* p;
  constexpr A(int) : p(this) {}
};

constexpr pin<A> foo() {
  pin<A> x{1};
  return x;
}

void test_foo() {
  constexpr auto y = foo();
  static_assert(y.value.p == &y.value);  // OK
}

pin<std::string> bar() {
  pin<std::string> std::string y;if () return {};return y;  // ERROR: y is not a return variable
}

The pin<T> class can be implemented as follows:

struct __pin_non_movable {
  __pin_non_movable& operator=(__pin_non_movable&&) = delete;
};

template <typename T>
struct __pin_holder {
  T value;
};

template <typename T>
struct pin : __pin_holder<T>, __pin_non_movable { };

9.6. Add an optional mark [[nrvo_verify]]

Sometimes it’s useful to assert that a variable is a return variable (and of a non-trivially-copyable type, otherwise it is useless). It would allow to catch subtle bugs caused by evolution of code.

Before:

widget frobnicate() {
  widget result [[nrvo_verify]];
  watch(&result);
  // …
      if (good()) {
        return result;
      }
  // …
  return result;
}

After:

widget frobnicate() {
  widget result [[nrvo_verify]];  // ERROR, good
  watch(&result);
  // …
      if (good()) {
        return result;
      } else if (bad()) {
        return widget();
      }
  // …
  return result;
}
Note: This idea is different from § 8.3 Require an explicit mark for return variables. Here, the mark is optional.
Note: Note that the same result can be achieved using § 9.5 std::pin<T> class without a specialized language feature.

9.7. Allow copy elision for complex expressions

Can copy elision be allowed in these cases?

widget foo() {
  widget x;
  return x += bar();
  return foo(), bar(), x;
  return toss_a_coin() ? foo() : x;
}

All in all, it seems that the benefits are not worth the additional complexity.

10. Acknowledgements

Thanks to Agustín Bergé, Arthur O’Dwyer, Krystian Stasiowski and everyone else who provided feedback on a draft of this proposal.

Special thanks to Antony Polukhin for championing the proposal.

Index

Terms defined by this specification

References

Normative References

[N4861]
Richard Smith, Thomas Koeppe, Jens Maurer, Dawn Perchik. Working Draft, Standard for Programming Language C++. 8 April 2020. URL: https://wg21.link/n4861

Informative References

[CWG2125]
Vinny Romano. Copy elision and comma operator. 6 May 2015. extension. URL: https://wg21.link/cwg2125
[CWG2278]
Richard Smith. Copy elision in constant expressions reconsidered. 27 June 2016. drafting. URL: https://wg21.link/cwg2278
[N4158]
Pablo Halpern. Destructive Move (Rev 1). 12 October 2014. URL: https://wg21.link/n4158
[P0023R0]
Denis Bider. Relocator: Efficiently moving objects. 8 April 2016. URL: https://wg21.link/p0023r0
[P0052R9]
Peter Sommerlad, Andrew L. Sandoval. Generic Scope Guard and RAII Wrapper for the Standard Library. 3 October 2018. URL: https://wg21.link/p0052r9
[P0527R1]
David Stone. Implicitly move from rvalue references in return statements. 8 November 2017. URL: https://wg21.link/p0527r1
[P0927R2]
James Dennett, Geoff Romer. Towards A (Lazy) Forwarding Mechanism for C++. 5 October 2018. URL: https://wg21.link/p0927r2
[P1046R2]
David Stone. Automatically Generate More Operators. 11 January 2020. URL: https://wg21.link/p1046r2
[P1144R5]
Arthur O'Dwyer. Object relocation in terms of move plus destroy. 2 March 2020. URL: https://wg21.link/p1144r5
[P1155R2]
Arthur O'Dwyer, David Stone. More implicit moves. 19 January 2019. URL: https://wg21.link/p1155r2
[P1155R3]
Arthur O'Dwyer, David Stone. More implicit moves. 17 June 2019. URL: https://wg21.link/p1155r3
[P1825R0]
David Stone. Merged wording for P0527R1 and P1155R3. 19 July 2019. URL: https://wg21.link/p1825r0
[P2062R0]
Daveed Vandevoorde, Wyatt Childers, Andrew Sutton, Faisal Vali. The Circle Meta-model. 13 January 2020. URL: https://wg21.link/p2062r0

Issues Index

Too many new terms might have been introduced: to directly observe a variable, to return a variable, potential return variable, return variable.
Should we say "function or lambda-expression", or is it enough to say "function"?
Following the proposed wording, it cannot be known at the time of initialization of the return variable's object, what object it is exactly ("Schrodinger’s object"). Later on, it is retrospectively established that we either have initialized and worked with a normal local variable, or with the result object of the function call expression.
"Return variable" might not be the best term for our purposes.
The previous restriction in this case looks like not-a-defect. Should this change belong to a separate proposal?
Should copy elision for non-class types belong to a separate proposal?