---
title: "Zero-copy co_return"
document: P4304R0
date: 2026-07-09
audience: SG17, EWG
reply-to:
  - "Yuxuan Chen <i@yuxuan.ch>"
---

## Revision History

R0 July 2026: Initial version.

## 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:

- `return_value(T&& x)`: a prvalue operand is materialized as a temporary bound to `x`; the body then move-constructs promise storage from `x`.
- `return_value(T x)`: a prvalue operand constructs the parameter directly (guaranteed elision reaches *into* the call), but the parameter dies when `return_value` returns, so the body must still move it onward into the promise.

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.

## 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.

### `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.

### 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 await-expression is a prvalue of type `T`, where `T` is the return type of `await_resume()`, the same source of truth as today. The awaiter therefore still declares `await_resume()`, but it is never called under this protocol (a declaration suffices; for a class-template awaiter over an immovable `T`, a `T`-returning definition is simply never instantiated). The parameter of `await_bind_result` shall be exactly `T*`; a mismatch is ill-formed. The parameter is a checked annotation, not a second source of deduction.
- Immediately after the awaiter is obtained, before `await_ready` is called and hence before any possibility of suspension, the implementation calls `a.await_bind_result(addr)`, where `addr` is the address of the result object of the await-expression ([basic.lval]). That result object is `v` in `auto v = co_await t;`, a temporary in `use(co_await t)`, and so on; every prvalue has one.
- Upon resumption, `a.await_complete()` is called in place of `await_resume()` for its side effects and exceptions. Upon its normal return, an object of type `T` shall have been constructed at `addr` (by whatever code the library arranged); that object *is* the result object, and its initialization is complete at that point. If `await_complete` exits via an exception, no object shall exist at `addr`, and the result object’s lifetime never begins: the ordinary consequence of an initializer throwing.

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.

### 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.

### Move-count summary

<!-- tomd:lossy-table -->
| 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 |

### 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:

- On the awaiter, resumption under the new protocol goes through the separately named `await_complete()`; the legacy `await_resume()` returning `T` remains declared (it is what `T` 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.)
- On the promise, `return_slot` takes precedence over the parameterized `return_value` overloads, which may remain declared for older-standard consumers.

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:

<!-- tomd:lossy-table -->
| 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.

### Design notes and subtleties

- **Exception path.** If the awaited coroutine’s body throws, `unhandled_exception()` runs, `return_slot()` is never consulted, and `await_complete()` rethrows at the await site: the result object is never constructed and `v` 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 (from `return_value()`, from a throwing destructor in the awaited coroutine, or from `await_complete` itself). The object at `addr` is then already alive: its lifetime started when the `co_return` operand finished constructing it, before the await-expression completed. The library must therefore destroy it before the exception escapes to the await site, so that `addr` holds no object on exceptional exit and `v` never begins its lifetime.
- **Discarded and converted results.** `co_await foo();` binds the slot to the materialized temporary of the discarded-value expression; `T2 x = co_await foo();` binds it to a temporary of type `T`, then converts. Both fall out of "every prvalue has a result object" with no special cases.
- **Caller destroyed while suspended.** If `bar`’s coroutine state is destroyed at the suspension point, `v`’s storage (in `bar`’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 of `await_bind_result`, not the language design.

## Proposed Wording

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

### [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*.
> - <ins>If a search for the name `await_bind_result` 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 *e*`.await_resume()` shall be a prvalue of complete object type; `C` denotes its *cv*-unqualified type. [*Note*: For a result-binding await-expression, `await_resume` is not invoked; its declaration only determines `C`. — *end note*]</ins>
> - <ins>For a result-binding await-expression, *await-bind* is the expression *e*`.await_bind_result(ADDR)`, where *ADDR* is a prvalue of type `C*` 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 type `void`, and the parameter of the function selected by overload resolution for the call shall be of type `C*`.</ins>
> - [...]
> - *await-resume* is the expression <ins>*e*`.await_complete()` if the *await-expression* is result-binding, and</ins> *e*`.await_resume()` <ins>otherwise. For a result-binding await-expression, *await-resume* shall be a prvalue of type `void`</ins> .

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*. <ins>For a result-binding await-expression: if the evaluation of *await-resume* completes without exiting via an exception, the lifetime of an object of type `C` shall 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 a `co_return` statement in an awaited coroutine whose promise’s `return_slot()` returned the value of *ADDR* ([stmt.return.coroutine]), or by library code within `await_complete`. — *end note*]</ins>

### [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:
> 
> - If the operand is a *braced-init-list* or an expression of non-`void` type <ins>, then:</ins>
>   - <ins> If a search for the name `return_slot` in the scope of the promise type ([class.member.lookup]) finds any declarations, then the expression *p*`.return_slot()` shall be a prvalue of type `U*` for some *cv*-unqualified complete object type `U`, and *S* is the *compound-statement* { *slot-init* ; *p*.return_value() ; } where *slot-init* denotes the copy-initialization ([dcl.init]) of an object of type `U` from the operand, in the storage whose address is the value of *p*`.return_slot()`; the evaluation of *p*`.return_slot()` is sequenced before the initialization. The expression *p*`.return_value()` shall be a prvalue of type `void`. [*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 type `U`, the behavior is undefined ([intro.object], [basic.life]). — *end note*] </ins>
>   - <ins>Otherwise,</ins> *S* is *p*`.return_value(`*expr-or-braced-init-list*`)`. The expression *S* shall be a prvalue of type `void`.
> - Otherwise, *S* is the *compound-statement* `{`*expression<sub>opt</sub>*`;`*p*`.return_void() ; }`. The expression *p*`.return_void()` shall be a prvalue of type `void`.

### [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.

### [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_assert`ing that the zero-move path is active, or choosing at compile time whether `task<T>` requires `T` to be movable.
