P4304R0
Zero-copy co_return

Published Proposal,

This version:
https://wg21.link/p4304r0
Author:
Audience:
SG17, EWG
Project:
ISO/IEC 14882 Programming Languages — C++, ISO/IEC JTC1/SC22/WG21

1. Revision History

R0 July 2026: Initial version.

2. Introduction

For a plain function, auto v = foo(); constructs the result directly into v: mandatory copy elision has made the value path of ordinary calls zero-copy since C++17. The equivalent coroutine code

task<T> foo() { co_return make_T(); }

task<void> bar() {
  auto v = co_await foo();
}

cannot achieve this today because the coroutine customization protocol has no channel through which the destination of the value can flow. The value produced by co_return must cross two user-written function-call boundaries, namely return_value() and await_resume(). Neither can be crossed without a move.

The return_value boundary. co_return expr; is specified as p.return_value(expr) ([stmt.return.coroutine]/2). The value must survive until the awaiting caller resumes and asks for it, so return_value must transfer it into storage that outlives the call; in every existing task implementation, a member of the promise. No overload signature avoids a move:

Named-operand co_return obj; fares no better: obj is a glvalue, and initializing anything from a glvalue costs a copy, or here a move, since the operand is implicitly movable ([expr.prim.id.unqual], after P2266). Zero-transfer construction is inherently a prvalue property, and prvalue operands are what this paper targets.

The await_resume() boundary. The value of the co_await expression is the return value of awaiter.await_resume() ([expr.await]). Guaranteed elision already makes await_resume()’s prvalue result object be v itself, but the return std::move(result_); inside await_resume constructs v from promise storage.

So the floor is exactly two moves, plus two extra destructions of moved-from objects, where the plain function performs zero. A secondary consequence is that task-like coroutines cannot yield immovable types at all: task<T> requires T to be move-constructible even though neither endpoint of the value’s journey needs it to be.

3. Proposed Design

This paper proposes a small, opt-in extension to the coroutine customization machinery, a result slot, that lets the operand of co_return be constructed directly into storage designated by the promise, and lets the awaiter designate the result object of the co_await expression as that storage. Together they make auto v = co_await foo() construct the co_return operand directly into v: zero moves, and T need not be movable.

3.1. co_return machinery: the promise result slot

A promise type may declare a member function return_slot() returning T*, a pointer to storage suitable for an object of type T. If it does, it must also declare return_value() taking no arguments, and the semantics of co_return expr; change from calling p.return_value(expr) to, in exposition:

// co_return expr;  becomes:
::new (p.return_slot()) T(expr);   // copy-initialization of a T at the slot
p.return_value();                  // nullary: "the result is ready"
goto final_suspend;

Because the operand now initializes an object rather than a function parameter, guaranteed elision applies through co_return: a prvalue operand is constructed directly at the slot, and a named operand costs the one implicit move it always did. The nullary return_value() call preserves the library’s ability to track state (result present vs. exception vs. never completed), which today it tracks from inside return_value(x); the library remains responsible for destroying the slot object, exactly as it is for its promise member today.

A braced-init-list operand list-initializes the slot object; an operand of a different type undergoes the usual conversion as part of the copy-initialization. If the promise declares return_slot, it takes precedence: co_return does not use the parameterized forms of return_value, but they may remain declared; this coexistence lets one promise type also serve older language versions (see § 3.5 Compatibility with older language versions).

With this extension alone, the promise points return_slot() at its own storage member.

3.2. Awaiter machinery: result binding

An awaiter may declare a member function void await_bind_result(T*). If it does, it shall also declare void await_complete(), and the co_await expression’s handling changes as follows:

The obligation to construct the object is the library’s, not the language’s, and that is deliberate: it gives the awaiter a graceful dynamic fallback. A lazy task’s awaiter plumbs addr into the awaited coroutine’s promise (it holds that coroutine’s handle, so this is one pointer store), the promise’s return_slot() returns it, and the awaited coroutine’s co_return constructs the value directly into the caller’s result object: zero moves. An awaiter that discovers at resume time that the value was produced somewhere else (an eager task that completed before being awaited, a when-all node holding buffered results) simply placement-news the value into addr from inside await_complete: one move, today’s cost at the await site, with no protocol violation.

3.3. End-to-end walkthrough

task<T> foo() { co_return make_T(); }

task<void> bar() {
  auto v = co_await foo();   // zero moves of T, T need not be movable
}
  1. bar evaluates co_await foo(). The awaiter is obtained; the implementation calls awaiter.await_bind_result(&v) (the result object of the await-expression is v by guaranteed elision, as today).

  2. The awaiter stores the pointer into foo’s promise and suspends bar, starting foo (lazy start, as in every task implementation).

  3. foo reaches co_return make_T();. The promise’s return_slot() returns the stored pointer, the address of v. The prvalue make_T() is constructed at that address: its result object is v. p.return_value() records that the result is ready; foo transfers control back (symmetric transfer, unchanged).

  4. bar resumes; awaiter.await_complete() checks for the exceptional case, rethrowing if needed, and returns void. The initialization of v is complete.

Move count: zero. Every step of the pointer plumbing (steps 2 and the lookup in 3) is ordinary library code; the language contributes exactly two things: constructing the co_return operand at a designated address, and designating the await-expression’s result object as an address.

For concreteness, the essence of a lazy task<T> implementation of the new customization points:

struct promise_type {
  T* slot_ = nullptr;                 // written by await_bind_result
  union { T value_; };                // fallback storage; engaged only if never bound
  enum : char { empty, in_slot, in_promise, failed } state_ = empty;
  std::exception_ptr error_;
  // Everything a lazy task's promise has today is unchanged and omitted:
  // get_return_object, suspend points, unhandled_exception (sets state_ = failed),
  // continuation handling, destruction of the engaged value_ member.

  T*   return_slot() { return slot_ ? slot_ : std::addressof(value_); }
  void return_value() { state_ = slot_ ? in_slot : in_promise; }  // value already constructed
};

struct awaiter {
  std::coroutine_handle<promise_type> child_;
  T* addr_;
  // await_ready and await_suspend (symmetric transfer starting child_) are
  // unchanged and omitted. T await_resume() must remain declared (T is
  // deduced from its return type) but only older-standard TUs call it.

  void await_bind_result(T* addr) { addr_ = addr; child_.promise().slot_ = addr; }
  void await_complete() {
    auto& p = child_.promise();
    if (p.state_ == p.failed) std::rethrow_exception(p.error_);
    if (p.state_ == p.in_promise)     // value landed in the promise (completed before
                                      // binding, or body compiled against the old protocol):
      ::new (static_cast<void*>(addr_)) T(std::move(p.value_));  // one move, today's cost
    // else in_slot: co_return constructed the value at addr_, zero moves
  }
};

return_slot degrades by returning promise-local storage when nothing was bound; await_complete degrades by placement-new from that storage when the value did not land at addr_. Both fallbacks are the compatibility paths of § 3.5 Compatibility with older language versions.

3.4. Move-count summary

Configuration co_return make_T() co_return obj (named local)
Plain function call (for reference) 0 0 with NRVO, else 1
Coroutine, status quo (best possible) 2 2
co_return machinery only 1 1
Both extensions, lazy task 0 1
Both extensions, eager completion fallback 1 2

3.5. Compatibility with older language versions

Awaiters and promises that do not declare the new members are untouched. The requirement that shaped the protocol’s surface is the reverse direction: a library must be able to ship one awaiter type and one promise type that work under both this protocol and older language versions, without preprocessor switching: #if-selected class definitions are an ODR violation the moment two translation units of a program are compiled under different standard versions, and mixed-standard programs are the norm during migrations.

This is why the protocol is expressed as additional members rather than changed requirements on existing ones:

With both the legacy and the new members present, the dynamic fallback of § 3.2 Awaiter machinery: result binding already covers every way old and new translation units can mix:

Coroutine body compiled under Awaiting side compiled under Path taken Moves
new new slot bound to v; co_return constructs in place 0
new old nothing bound; return_slot() falls back to promise-local storage; legacy await_resume() moves out 1
old new return_value(expr) stores into the promise; await_complete() places the value at the bound address, the eager-completion fallback above 1
old old status quo 2

One class definition, identical in every translation unit; graceful degradation at every mix point; the zero-move path lights up exactly where both sides are new.

3.6. Design notes and subtleties

4. Proposed Wording

Wording is relative to N5050. All names remain strawmen. Editorial conventions: green underlined text is inserted, red struck-through text is removed.

4.1. [expr.await]

Modify paragraph 3 (the auxiliary types, expressions, and objects). After the bullet defining e, insert two bullets, and modify the bullet defining await-resume:

Modify paragraph 4:

The await-expression has the same type and value category as the await-resume expression , unless it is a result-binding await-expression, in which case it is a prvalue of type C .

Modify paragraph 5 (evaluation), in the introductory sentence and the final bullet:

The await-expression evaluates the (possibly-converted) o expression , then, for a result-binding await-expression, the await-bind expression, and then the await-ready expression, then:

4.2. [stmt.return.coroutine]

Modify paragraph 2:

The expr-or-braced-init-list of a co_return statement is called its operand. Let p be an lvalue naming the coroutine promise object ([dcl.fct.def.coroutine]). A co_return statement is equivalent to:
{ S; goto final-suspend; }
where final-suspend is the exposition-only label defined in [dcl.fct.def.coroutine] and S is defined as follows:

4.3. [dcl.fct.def.coroutine]

Modify the paragraph making return_void/return_value coexistence ill-formed:

If searches a search for the names name return_void and return_value in the scope of the promise type each find finds any declarations, and a search in that scope for either of the names return_value or return_slot also finds any declarations, the program is ill-formed.

4.4. [cpp.predefined]

Add a row to [tab:cpp.predefined.ft]:

__cpp_impl_coroutine_result_slot, value 20XXYYL

Although § 3.5 Compatibility with older language versions shows that a library can serve every language version with a single class definition and no conditional compilation, the macro remains useful for opportunistic adoption, e.g. static_asserting that the zero-move path is active, or choosing at compile time whether task<T> requires T to be movable.