scope_association concept to
P3149| Document #: | P3815R0 |
| Date: | 2025-09-01 |
| Project: | Programming Language C++ |
| Audience: |
LEWG Library Evolution Working Group |
| Reply-to: |
Ian Petersen <ispeters@gmail.com> Jessica Wong <jesswong2011@gmail.com> |
[P3149R11] was approved for C++26 by
WG21. The paper introduces two scope types,
simple_counting_scope and
counting_scope, along with
several basis operations, including
associate,
spawn, and
spawn_future. In R11, the
example implementations of these facilities are expressed in terms of
the scope_token concept:
template <class Token>
concept scope_token =
copyable<Token> &&
requires(const Token token) {
{ token.try_associate() } -> same_as<bool>;
{ token.disassociate() } noexcept -> same_as<void>;
{ token.wrap(declval<test-sender>()) } -> sender_in<test-env>;
};During the development of these sample implementations, it was
observed that reintroducing the
scope_association concept from
[P3149R7] would yield several
benefits:
These improvements can be achieved without any impact on the user-facing APIs proposed in R11.
Illustrated below are the R11 implementations of
spawn and
associate in contrast with the
scope_association concept
implementation.
Before
|
After
|
|---|---|
|
|
Before
|
After
|
|---|---|
|
|
template <class Assoc>
concept scope_association =
movable<Assoc> &&
default_initializable<Assoc> &&
requires(Assoc assoc) {
{ static_cast<bool>(assoc) } noexcept;
{ assoc.try_associate() } -> same_as<Assoc>;
};A type that models
scope_association is an RAII
handle that represents a possible association between a sender and an
async scope. If the scope association contextually converts to true then
the object is “engaged” and represents an association; otherwise, the
object is “disengaged” and represents the lack of an association. Scope
associations are movable and not copyable, and expose a
try_associate member function
with semantics identical to the
try_associate member function on
a type that models
scope_token.
The following are the proposed changes to
scope_token,
associate,
spawn,
spawn_future,
simple_counting_scope, and
counting_scope with the adoption
of scope_association.
execution::scope_tokenThe primary change to
scope_token is to
try_associate, which will return
a scope_association rather than
a bool.
template <class Token>
concept scope_token =
copyable<Token> &&
requires(Token token) {
{ token.try_associate() } -> scope_association;
{ token.wrap(declval<test-sender>()) } -> sender_in<test-env>;
};The try_associate member
function on a token attempts to create a new association with the scope;
try_associate returns an engaged
association when the association is successful, and it may either return
a disengaged association or throw an exception to indicate failure.
execution::associateWith the application of the proposed changes, the copy behavior of
the associate-sender returned from
associate becomes the
following:
If the sender, snd, provided
to associate() is copyable then
the resulting associate-sender is also copyable, with the following
rules:
copying an unassociated associate-sender invariably produces a new unassociated associate-sender; and
copying an associated associate-sender requires copying the
associate-data it
contains and the
associate-data
copy-constructor proceeds as follows:
The result of invoking the source’s
association.try_associate()
will be passed to the destination
associate-data.
associate-data; the
destination associate-sender is associatedFurthermore, the
operation-state’s
destructor becomes the following:
An operation-state
with its own association must invoke the association’s destructor as the
last step of the
operation-state’s
destructor.
execution::spawnThe behavior of spawn remains
largely unchanged, with the primary difference being that
op_t now holds an
association rather than
a token. Upon
completion of the
operation-state, the
destructor of the
association is invoked,
replacing the previous mechanism of explicitly calling
token.disassociate() on
the local copy of the
token.
execution::spawn_futureThe changes to spawn_future
reflect the same changes proposed in
spawn.
execution::simple_counting_scopeThe behavior of
simple_counting_scope remains
largely unchanged, with the primary difference being that the
disassociation is handled by the destructor of the association returned
from token.try_associate().
execution::counting_scopeThe changes to counting_scope
reflect the same changes proposed in
simple_counting_scope.
<execution> synopsis
33.4
[execution.syn]To the <execution>
synopsis 33.4
[execution.syn],
make the following change:
// [exec.scope]
// [exec.scope.concepts], scope concepts
template <class Token>
concept scope_association = see below;
template <class Token>
concept scope_token = see below;execution::associateTo the subsection 33.9.12.16 [exec.associate], make the following changes:
2
Let associate-data be
the following exposition-only class template:
namespace std::execution {
template <scope_token Token, sender Sender>
struct associate-data { // exposition only
using wrap-sender = // exposition only
remove_cvref_t<decltype(declval<Token&>().wrap(declval<Sender>()))>;
using assoc-t = // exposition only
decltype(declval<Token&>().try_associate());
using sender-ref = // exposition only
unique_ptr<wrap-sender, decltype([](auto* p) noexcept { destroy_at(p); })>;
explicit associate-data(Token t, Sender&& s)
: sndr(t.wrap(std::forward<Sender>(s))), {
token(t) {
sender-ref guard{addressof(sndr)};
if (!tokenassoc = t.try_associate())
sndr.reset()(void)guard.release();
}
associate-data(const associate-data& other)
noexcept(is_nothrow_copy_constructible_v<wrap-sender> &&
noexcept(other.tokenassoc.try_associate()));
associate-data(associate-data&& other)
noexcept(is_nothrow_move_constructible_v<wrap-sender>);
~associate-data();
optional<pair<Token, wrap-sender>>
pair<assoc-t, sender-ref>
release() && noexcept(is_nothrow_move_constructible_v<wrap-sender>);
private:
optional<wrap-sender> sndr; // exposition only
Token token; // exposition only
associate-data(pair<assoc-t, scope-ref> parts); // exposition only
assoc-t assoc; // exposition only
union {
wrap-sender sndr; // exposition only
};
};
template <scope_token Token, sender Sender>
associate-data(Token, Sender&&) -> associate-data<Token, Sender>;
}3
For an associate-data
object a, a.sndr.has_value()
isa.assoc
contextually converts to
true if and only if an
association was successfully made and is owned by
a.
associate-data(const associate-data& other)
noexcept(is_nothrow_copy_constructible_v<wrap-sender> &&
noexcept(other.tokenassoc.try_associate()));4
Constraints: copy_constructible<wrap-sender>
is true.
5
Effects: Value-initializes
Initializes
sndr and
initializes
token with
other.token.
If
other.sndr.has_value()
is false, no
further effects; otherwise, calls
token.try_associate()
and, if that returns
true, calls
sndr.emplace(*other.sndr)
and, if that exits with an exception, calls
token.disassociate()
before propagating the exception.assoc with
other.assoc.try_associate().
If assoc
contextually converts to
false, no further
effects; otherwise, initializes
sndr with
other.sndr.
associate-data(associate-data&& other)
noexcept(is_nothrow_move_constructible_v<wrap-sender>);6
Effects: Initializes
Equivalent to sndr with
std::move(other.sndr)
and initializes
token with
std::move(other.token)
and then calls
other.sndr.reset().associate-data(std::move(other).release()).
associate-data(pair<assoc-t, sender-ref> parts);
7
Effects: Initializes
assoc with
std::move(parts.first).
If assoc
contextually converts to
false, no further
effects; otherwise, initializes
sndr with
std::move(*parts.second).
~associate-data();
8
Effects: If
If
sndr.has_value()
returns false then
no effect; otherwise, invokes
sndr.reset()
before invoking
token.disassociate().assoc
contextually converts to
false then no
effect; otherwise, destroys
sndr.
optional<pair<Token, wrap-sender>>
pair<assoc-t, scope-ref>
release() && noexcept(is_nothrow_move_constructible_v<wrap-sender>);
9
Effects: If
sndr.has_value()
returns false then
returns an optional
that does not contain a value; otherwise returns an
optional containing
a value of type pair<Token, wrap-sender>
as if by:
return optional(pair(token, std::move(*sndr)));
Constructs an object
u of type
scope-ref
that is initialized with
nullptr if
assoc
contextually converts to
false and with
addressof(sndr)
otherwise, then returns pair{std::move(assoc), std::move(u)}.
9
Postconditions:
sndr does
not contain a value.
10 The
name associate denotes a
pipeable sender adaptor object. For subexpressions
sndr and
token, if
decltype((sndr)) does not
satisfy sender, or remove_cvref_t<decltype((token))>
does not satisfy scope_token,
then associate(sndr, token) is
ill-formed.
…
13 The
member impls-for<associate_t>::get-state
is initialized with a callable object equivalent to the following
lambda:
[]<class Sndr, class Rcvr>(Sndr&& sndr, Rcvr& rcvr) noexcept(see below) {
auto&& [_, data] = std::forward<Sndr>(sndr);
auto dataParts = std::move(data).release();
using scope_token = decltype(dataParts->first);
using wrap_sender = decltype(dataParts->second);
using associate_data_t = remove_cvref_t<decltype(data)>;
using assoc_t = typename associate_data_t::assoc-t;
using sender_ref_t = typename associate_data_t::sender-ref;
using op_t = connect_result_t<wrap_sendertypename sender_ref_t::element_type, Rcvr>;
struct op_state {
boolassoc_t associated = false; // exposition only
union {
Rcvr* rcvr; // exposition only
struct {
scope_token token; // exposition only
op_t op; // exposition only
} assoc; // exposition only
};
explicit op_state(Rcvr& r) noexcept
: rcvr(addressof(r)) {}
explicit op_state(scope_token tkn, wrap_sender&& sndr, Rcvr& r) try
: associated(true),
assoc(tkn, connect(std::move(sndr), std::move(r))) {
}
catch (…) {
tkn.disassociate();
throw;
}
explicit op_state(pair<assoc_t, sender_ref_t> parts, Rcvr& r)
: assoc(std::move(parts.first)) {
if (assoc)
::new (voidify(op)) op_t{
connect(std::move(*parts.second), std::move(r))};
else
rcvr = addressof(r);
}
explicit op_state(associate_data_t&& ad, Rcvr& r)
: op_state(std::move(ad).release(), r) {}
explicit op_state(const associate_data_t& ad, Rcvr& r)
requires copy_constructible<associate_data_t>
: op_state(associate_data_t(ad).release(), r) {}
op_state(op_state&&) = delete;
~op_state() {
if (associated) {
assoc.op.~op_t();
assoc._token_.disassociate();
assoc._token_.~scope_token();
}
}
void run() noexcept { // exposition only
if (associated)
start(assoc.op);
else
set_stopped(std::move(*rcvr));
}
};
if (dataParts)
return op_state{std::move(dataParts->first), std::move(dataParts->second), rcvr};
else
return op_state{std::forward_like(data), rcvr};
}14 The
expression in the noexcept
clause of impls-for<associate_t>::get-state
is
is_nothrow_constructible_v<remove_cvref_t
is_nothrow_move_constructible_v<wrap-sender> &&
(is_rvalue_reference_v<Sndr&&> || is_nothrow_constructible_v<remove_cvref_t
nothrow-callable<connect_t, wrap-sender, Rcvr>
where wrap-sender is
the type remove_cvref_t<data-type<Sndr>>::wrap-sender.
execution::spawn_futureTo the subsection 33.9.12.18 [exec.spawn.future], make the following changes:
7
Let spawn-future-state
be the exposition-only class template:
namespace std::execution {
template<class Alloc, scope_token Token, sender Sender, class Env>
struct spawn-future-state // exposition only
: spawn-future-state-base<completion_signatures_of_t<future-spawned-sender<Sender, Env>>> {
using sigs-t = // exposition only
completion_signatures_of_t<future-spawned-sender<Sender, Env>>;
using receiver-t = // exposition only
spawn-future-receiver<sigs-t>;
using op-t = // exposition only
connect_result_t<future-spawned-sender<Sender, Env>, receiver-t>;
spawn-future-state(Alloc alloc, Sender&& sndr, Token token, Env env) // exposition only
: alloc(std::move(alloc)),
op(connect(
write_env(stop-when(std::forward<Sender>(sndr), ssource.get_token()), std::move(env)),
receiver-t(this))),
token(std::move(token)),
associated(token.try_associate()) {
if (associatedassoc)
start(op);
else
set_stopped(receiver-t(this));
}
void complete() noexcept override; // exposition only
void consume(receiver auto& rcvr) noexcept; // exposition only
void abandon() noexcept; // exposition only
private:
using alloc-t = // exposition only
typename allocator_traits<Alloc>::template rebind_alloc<spawn-future-state>;
using assoc-t = // exposition only
remove_cvref_t<decltype(declval<Token&>().try_associate())>;
alloc-t alloc; // exposition only
ssource-t ssource; // exposition only
op-t op; // exposition only
Tokenassoc-t tokenassoc; // exposition only
bool associated; // exposition only
void destroy() noexcept; // exposition only
};
}…
void destroy() noexcept;
12 Effects: Equivalent to:
auto token = std::move(this->token);
bool associated = this->associated;
auto assoc = std::move(this->assoc);
{
auto alloc = std::move(this->alloc);
allocator_traits<alloc-t>::destroy(alloc, this);
allocator_traits<alloc-t>::deallocate(alloc, this, 1);
}
if (associated)
token.disassociate();execution::spawnTo the subsection 33.9.13.3 [exec.spawn], make the following changes:
5 Let spawn-state be the exposition-only class template:
namespace std::execution {
template<class Alloc, scope_token Token, sender Sender>
struct spawn-state : spawn-state-base { // exposition only
using op-t = connect_result_t<Sender, spawn-receiver>; // exposition only
spawn-state(Alloc alloc, Sender&& sndr, Token token); // exposition only
void complete() noexcept override; // exposition only
void run() noexcept; // exposition only
private:
using alloc-t = // exposition only
typename allocator_traits<Alloc>::template rebind_alloc<spawn-state>;
using assoc-t = // exposition only
remove_cvref_t<decltype(declval<Token&>().try_associate())>;
alloc-t alloc; // exposition only
op-t op; // exposition only
Tokenassoc-t tokenassoc; // exposition only
void destroy() noexcept; // exposition only
};
}spawn-state(Alloc alloc, Sender&& sndr, Token token);
6
Effects: Initializes
Initializes
alloc with
alloc,
token with
token, and
op
with:
connect(std::move(sndr), spawn-receiver(this))alloc with
std::move(alloc),
op with
connect(std::move(sndr), spawn-receiver(this)),
and assoc
with
token.try_associate().
void run() noexcept;
7 Effects: Equivalent to:
if (token.try_associate())
start(op);
else
destroycomplete();void complete() noexcept override;
8 Effects: Equivalent to:
auto token = std::move(this->token);
destroy();
token.disassociate();void destroy() noexcept;
9
Effects: Equivalent to:
auto assoc = std::move(this->assoc);
auto alloc = std::move(this->alloc);
allocator_traits<alloc-t>::destroy(alloc, this);
allocator_traits<alloc-t>::deallocate(alloc, this, 1);At the beginning of subsection 33.14.1 [exec.scope.concepts], make the following changes
1 The
scope_assocation
concept defines the requirements on a type
Assoc that, when
engaged, owns an association with an async scope.
namespace std::execution {
template <class Assoc>
concept scope_association =
movable &&
default_initializable &&
requires(const Assoc assoc) {
{ static_cast(assoc) } noexcept;
{ assoc.try_associate() } -> same_as;
};
}2
The scope_token concept defines
the requirements on a type Token
that can be used to create associations between senders and an async
scope.
3
Let test-sender and
test-env be unspecified
types such that sender_in<test-sender, test-env>
is modeled.
namespace std::execution {
template <class Token>
concept scope_token =
copyable<Token> &&
requires(const Token token) {
{ token.try_associate() } -> same_as;
{ token.disassociate() } noexcept -> same_as;
{ token.try_associate() } -> scope_association;
{ token.wrap(declval<test-sender>()) } -> sender_in<test-env>;
};
}execution::simple_counting_scope
and execution::counting_scopeTo the subsection 33.14.2.2.1 [exec.scope.simple.counting.general], make the following change:
namespace std::execution {
class simple_counting_scope {
public:
// [exec.simple.counting.token], token
struct token;
// [exec.simple.counting.assoc], assoc
struct assoc;
static constexpr size_t max_associations = implementation-defined;
// [exec.simple.counting.ctor], constructor and destructor
simple_counting_scope() noexcept;
simple_counting_scope(simple_counting_scope&&) = delete;
~simple_counting_scope();
// [exec.simple.counting.mem], members
token get_token() noexcept;
void close() noexcept;
sender auto join() noexcept;
private:
size_t count; // exposition only
scope-state-type state; // exposition only
boolassoc try-associate() noexcept; // exposition only
void disassociate() noexcept; // exposition only
template<class State>
bool start-join-sender(State& state) noexcept; // exposition only
};
}To the subsection 33.14.2.2.3 [exec.simple.counting.mem], make the following changes:
boolassoc try-associate() noexcept;
5
Effects: If
count is equal to
max_associations, then no
effects. Otherwise, if
state is
unused, then increments
count and changes
state to
open;open or
open-and-joining, then
increments count;6
Returns: An
object true
if count
was incremented,
false
otherwise.a of type
simple_counting_scope::assoc
such that
a.scope is
this if
count was
incremented,
nullptr
otherwise.
To the subsection 33.14.2.2.4 [exec.simple.counting.token], make the following changes:
namespace std::execution {
struct simple_counting_scope::token {
template<sender Sender>
Sender&& wrap(Sender&& snd) const noexcept;
boolassoc try_associate() const noexcept;
void disassociate() const noexcept;
private:
simple_counting_scope* scope; // exposition only
};
}template <sender Sender>
Sender&& wrap(Sender&& snd) const noexcept;1
Returns:
std::forward<Sender>(snd).
boolassoc try_associate() const noexcept;
2
Effects: Equivalent to: return scope->try-associate();
void disassociate() const noexcept;
3
Effects: Equivalent to
scope->disassociate().
Add the following new section immediately after 33.14.2.2.4 [exec.simple.counting.token]:
Association [exec.simple.counting.assoc]
namespace std::execution {
struct simple_counting_scope::assoc {
explicit operator bool() const noexcept;
assoc try_associate() const noexcept;
private:
using handle = // exposition only
unique_ptr<simple_counting_scope, decltype([](auto* p) noexcept {
p->disassociate();
})>;
handle scope; // exposition only
};
}explicit operator bool() const noexcept;
1
Returns:
scope != nullptr
assoc try_associate() const noexcept;
2
Returns: A default-initialized
assoc if
scope is
nullptr,
scope->try-associate()
otherwise.
To the subsection 33.14.2.3 [exec.scope.counting], make the following changes:
namespace std::execution {
class counting_scope {
public:
struct assoc {
explicit operator bool() const noexcept;
assoc try_associate() const noexcept;
private:
[using _handle_ = // _exposition\ only_]{.add}
unique_ptr<counting_scope, decltype([](auto* p) noexcept {
[p->_disassociate_();]{.add}
})>;
[_handle_ _scope_; // _exposition\ only_]{.add}
};
struct token {
template<sender Sender>
sender auto wrap(Sender&& snd) const noexcept(see below);
boolassoc try_associate() const noexcept;
void disassociate() const noexcept;
private:
counting_scope* scope; // exposition only
};
static constexpr size_t max_associations = implementation-defined;
counting_scope() noexcept;
counting_scope(counting_scope&&) = delete;
~counting_scope();
token get_token() noexcept;
void close() noexcept;
sender auto join() noexcept;
void request_stop() noexcept;
private:
size_t count; // exposition only
scope-state-type state; // exposition only
inplace_stop_source s_source; // exposition only
boolassoc try-associate() noexcept; // exposition only
void disassociate() noexcept; // exposition only
template<class State>
bool start-join-sender(State& state) noexcept; // exposition only
};
}