1. Revision History
R0 July 2026: Initial version.
2. Introduction
For a plain function, constructs the result directly into
: 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 must cross two user-written function-call
boundaries, namely and . Neither can be
crossed without a move.
The boundary. is specified as
([stmt.return.coroutine]/2). The value must survive
until the awaiting caller resumes and asks for it, so 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:
-
: a prvalue operand is materialized as a temporary bound toreturn_value ( T && x ) ; the body then move-constructs promise storage fromx .x -
: a prvalue operand constructs the parameter directly (guaranteed elision reaches into the call), but the parameter dies whenreturn_value ( T x ) returns, so the body must still move it onward into the promise.return_value
Named-operand fares no better: 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 boundary. The value of the expression is the
return value of ([expr.await]). Guaranteed elision
already makes ’s prvalue result object be itself, but
the inside constructs
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: requires 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 be
constructed directly into storage designated by the promise, and lets the
awaiter designate the result object of the expression as that
storage. Together they make construct the
operand directly into : zero moves, and need not be
movable.
3.1. co_return machinery: the promise result slot
A promise type may declare a member function returning
, a pointer to storage suitable for an object of type . If it does,
it must also declare taking no arguments, and the semantics
of change from calling 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 : a prvalue operand
is constructed directly at the slot, and a named operand costs the one
implicit move it always did. The nullary call preserves the
library’s ability to track state (result present vs. exception vs. never
completed), which today it tracks from inside ; 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 , it takes
precedence: does not use the parameterized forms of
, 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 at its own
storage member.
3.2. Awaiter machinery: result binding
An awaiter may declare a member function . If it
does, it shall also declare , and the
expression’s handling changes as follows:
-
The await-expression is a prvalue of type
, whereT is the return type ofT , the same source of truth as today. The awaiter therefore still declaresawait_resume () , but it is never called under this protocol (a declaration suffices; for a class-template awaiter over an immovableawait_resume () , aT -returning definition is simply never instantiated). The parameter ofT shall be exactlyawait_bind_result ; a mismatch is ill-formed. The parameter is a checked annotation, not a second source of deduction.T * -
Immediately after the awaiter is obtained, before
is called and hence before any possibility of suspension, the implementation callsawait_ready , wherea . await_bind_result ( addr ) is the address of the result object of the await-expression ([basic.lval]). That result object isaddr inv , a temporary inauto v = co_await t ; , and so on; every prvalue has one.use ( co_await t ) -
Upon resumption,
is called in place ofa . await_complete () for its side effects and exceptions. Upon its normal return, an object of typeawait_resume () shall have been constructed atT (by whatever code the library arranged); that object is the result object, and its initialization is complete at that point. Ifaddr exits via an exception, no object shall exist atawait_complete , and the result object’s lifetime never begins: the ordinary consequence of an initializer throwing.addr
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 into the awaited coroutine’s
promise (it holds that coroutine’s handle, so this is one pointer store),
the promise’s returns it, and the awaited coroutine’s
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 from inside : 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 }
-
evaluatesbar . The awaiter is obtained; the implementation callsco_await foo () (the result object of the await-expression isawaiter . await_bind_result ( & v ) by guaranteed elision, as today).v -
The awaiter stores the pointer into
’s promise and suspendsfoo , startingbar (lazy start, as in every task implementation).foo -
reachesfoo . The promise’sco_return make_T (); returns the stored pointer, the address ofreturn_slot () . The prvaluev is constructed at that address: its result object ismake_T () .v records that the result is ready;p . return_value () transfers control back (symmetric transfer, unchanged).foo -
resumes;bar checks for the exceptional case, rethrowing if needed, and returnsawaiter . await_complete () . The initialization ofvoid is complete.v
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 operand at a designated address, and
designating the await-expression’s result object as an address.
For concreteness, the essence of a lazy 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 } };
degrades by returning promise-local storage when nothing was
bound; degrades by placement-new from that storage when
the value did not land at . Both fallbacks are the compatibility
paths of § 3.5 Compatibility with older language versions.
3.4. Move-count summary
| Configuration |
| (named local)
|
|---|---|---|
| Plain function call (for reference) | 0 | 0 with NRVO, else 1 |
| Coroutine, status quo (best possible) | 2 | 2 |
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: -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:
-
On the awaiter, resumption under the new protocol goes through the separately named
; the legacyawait_complete () returningawait_resume () remains declared (it is whatT is deduced from) and is called only by older-standard consumers. (A separate name is forced regardless: both hooks are nullary, so they cannot overload.)T -
On the promise,
takes precedence over the parameterizedreturn_slot overloads, which may remain declared for older-standard consumers.return_value
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 ; constructs in place
| 0 |
| new | old | nothing bound; falls back to promise-local storage; legacy moves out
| 1 |
| old | new | stores into the promise; 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
-
Exception path. If the awaited coroutine’s body throws,
runs,unhandled_exception () is never consulted, andreturn_slot () rethrows at the await site: the result object is never constructed andawait_complete () never begins its lifetime. This is ordinary initializer-throws semantics; nothing new is needed. An exception can also occur after the slot object is constructed (fromv , from a throwing destructor in the awaited coroutine, or fromreturn_value () itself). The object atawait_complete is then already alive: its lifetime started when theaddr operand finished constructing it, before the await-expression completed. The library must therefore destroy it before the exception escapes to the await site, so thatco_return holds no object on exceptional exit andaddr never begins its lifetime.v -
Discarded and converted results.
binds the slot to the materialized temporary of the discarded-value expression;co_await foo (); binds it to a temporary of typeT2 x = co_await foo (); , then converts. Both fall out of "every prvalue has a result object" with no special cases.T -
Caller destroyed while suspended. If
’s coroutine state is destroyed at the suspension point,bar ’s storage (inv ’s frame) goes away with it. The library must ensure the awaited coroutine no longer writes through the bound pointer, but awaiting already creates exactly this obligation (structured task libraries destroy or detach the child first), so this constrains implementations ofbar , not the language design.await_bind_result
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:
- e is an lvalue referring to the result of evaluating the (possibly-converted) o.
- If a search for the name
in the scope of the type of e ([class.member.lookup]) finds at least one declaration, the await-expression is a result-binding await-expression. In that case, the expression eawait_bind_result shall be a prvalue of complete object type;. await_resume () denotes its cv-unqualified type. [Note: For a result-binding await-expression,C is not invoked; its declaration only determinesawait_resume . — end note]C - For a result-binding await-expression, await-bind is the expression e
, where ADDR is a prvalue of type. await_bind_result ( ADDR ) whose value is the address of the storage associated with the result object of the await-expression ([basic.lval], [class.temporary]). The expression await-bind shall be a prvalue of typeC * , and the parameter of the function selected by overload resolution for the call shall be of typevoid .C * - [...]
- await-resume is the expression e
if the await-expression is result-binding, and e. await_complete () otherwise. For a result-binding await-expression, await-resume shall be a prvalue of type. await_resume () .void
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:
- [...]
- If the result of await-ready is
true, or when the coroutine is resumed other than by rethrowing an exception from await-suspend, the await-resume expression is evaluated, and its result is the result of the await-expression. For a result-binding await-expression: if the evaluation of await-resume completes without exiting via an exception, the lifetime of an object of typeshall have begun within the storage whose address is the value of ADDR and shall not have ended; otherwise, the behavior is undefined. That object is the result object of the await-expression, and its initialization is complete at the point at which the evaluation of await-resume completes. If the evaluation of await-resume exits via an exception, that storage shall not contain an object, and the result object is not initialized. [Note: The object is typically created either by aC statement in an awaited coroutine whose promise’sco_return returned the value of ADDR ([stmt.return.coroutine]), or by library code withinreturn_slot () . — end note]await_complete
4.2. [stmt.return.coroutine]
Modify paragraph 2:
The expr-or-braced-init-list of astatement is called its operand. Let p be an lvalue naming the coroutine promise object ([dcl.fct.def.coroutine]). Aco_return statement is equivalent to:co_return where final-suspend is the exposition-only label defined in [dcl.fct.def.coroutine] and S is defined as follows:{ S ; goto final - suspend ; }
- If the operand is a braced-init-list or an expression of non-
type , then:void
- If a search for the name
in the scope of the promise type ([class.member.lookup]) finds any declarations, then the expression preturn_slot shall be a prvalue of type. return_slot () for some cv-unqualified complete object typeU * , and S is the compound-statementU where slot-init denotes the copy-initialization ([dcl.init]) of an object of type{ slot - init ; p . return_value () ; } from the operand, in the storage whose address is the value of pU ; the evaluation of p. return_slot () is sequenced before the initialization. The expression p. return_slot () shall be a prvalue of type. return_value () . [Note: Because the operand initializes an object, a prvalue operand is constructed directly in that storage; no temporary is materialized ([dcl.init.general]). — end note] [Note: The coroutine does not destroy the object; destroying it is the responsibility of the program, in practice of the coroutine library that provided the storage. If the storage is unsuitable for an object of typevoid , the behavior is undefined ([intro.object], [basic.life]). — end note]U - Otherwise, S is p
expr-or-braced-init-list. return_value ( . The expression S shall be a prvalue of type) .void - Otherwise, S is the compound-statement
expressionopt{ p; . The expression p. return_void () ; } shall be a prvalue of type. return_void () .void
4.3. [dcl.fct.def.coroutine]
Modify the paragraph making / coexistence
ill-formed:
Ifsearchesa search for thenamesnamereturn_void andin the scope of the promise typereturn_value each findfinds any declarations, and a search in that scope for either of the namesorreturn_value also finds any declarations, the program is ill-formed.return_slot
4.4. [cpp.predefined]
Add a row to [tab:cpp.predefined.ft]:
, value__cpp_impl_coroutine_result_slot 20 XXYYL
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. ing that
the zero-move path is active, or choosing at compile time whether
requires to be movable.