Skip to content

exec::function: member functions, stateless factories, and a noexcept contract - #2321

Merged
ericniebler merged 19 commits into
NVIDIA:mainfrom
ispeters:support_member_functions
Oct 18, 2026
Merged

ericniebler merged 19 commits into
NVIDIA:mainfrom
ispeters:support_member_functions

Conversation

@ispeters

@ispeters ispeters commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

This PR extends exec::function with support for asynchronous member
functions and gives noexcept function types a precise, enforced meaning.
These changes implement the design I'm proposing in the next revision of
P4223 (std::execution::function).

The commits are meant to be reviewed one at a time; each one builds and
passes the tests on its own.

What changes

Stateless sender factories. A function's sender factory must now be a
pointer to function, a pointer to member, or an empty, trivially-copyable
callable. Any state the factory needs is passed as a curried argument, so it
shows up in the function's type instead of hiding in a lambda capture.

Member functions. function<int(bar) &> and function<int(bar) const &>
(optionally noexcept, and in the sender_tag form) declare asynchronous
member functions. The constructor takes the object as an lvalue, and the
function borrows it. Because factories are stateless, the ref-qualifier
tells you whether a function borrows an object and whether it may modify it.
Forms that would borrow an expiring object (&&, const &&, and const
without a ref-qualifier) are rejected, as are volatile forms. Virtual member
functions that return function become natural to write.

Curried arguments. An lvalue curried argument is accepted (and copied)
when its type is trivially copy- and move-constructible, i.e. exactly when
writing std::move would change nothing. Everything else still has to be
moved or explicitly copied.

noexcept. A noexcept function type now promises that the type-erased
path (invoking the factory and connecting the sender it returns) doesn't
throw, independently of the completion signatures, so the sender_tag form
may be declared noexcept too.

  • connect is conditionally noexcept: it's noexcept when the function
    type is noexcept and the frame allocator chosen from the receiver's
    environment allocates without throwing (connect() const & also requires
    nothrow-copyable curried arguments).
  • The constructors enforce the promise with constraints: the factory must be
    nothrow-invocable, and, unless a domain transforms the factory's sender,
    that sender's connect must not throw. Domain transformations are
    trusted, since the function type's author chose the domain.
  • Both connect overloads are now constrained on receiver.

Fixes and cleanups.

  • __msort accepts an empty type list. This one touches stdexec core and
    is independent of the rest; I'm happy to split it out if you'd rather.
  • get_completion_domain_t<> in attrs<> now means the set_value domain,
    as it does elsewhere.
  • The factory storage is sized and aligned for every accepted factory kind,
    including the Microsoft ABI's larger pointer-to-member representations.
  • function's type lists are canonicalized with __munique/__msort.
  • function's operation state is explicitly immovable.
  • There are new tests of the existing frame-allocator and
    factory-exception behaviour.

Notes for reviewers

  • The factory-storage sizing for the Microsoft ABI can only be exercised on
    MSVC-ABI targets; I'm relying on CI for that.
  • There's a comment at the operation state's construction site describing
    how construction through the shared __any machinery differs from
    allocating raw storage and using construct_at, and what a fix would
    look like.

@copy-pr-bot

copy-pr-bot Bot commented Oct 9, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@ispeters
ispeters force-pushed the support_member_functions branch 8 times, most recently from 40efbc1 to 9a85c0a Compare October 10, 2026 04:20
@ericniebler

Copy link
Copy Markdown
Collaborator

/ok to test 9a85c0a

@ericniebler

Copy link
Copy Markdown
Collaborator

/ok to test fc3dfe6

ispeters and others added 17 commits October 17, 2026 09:20
`__msort` builds its array of type ids with
`std::array{__mtypeid<_Ts>...}`, but class template argument deduction
can't deduce a `std::array` from an empty initializer list, so
`__msort<...>::__f<>` is ill-formed. This diff names the array type
explicitly and adds tests for `__msort`'s ordering and for empty lists,
both alone and composed with `__munique`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This diff adds two tests of existing behaviour:
 * an erased operation state that's too large for the type-erased
   operation state's inline buffer is allocated exactly once, through
   the frame allocator found in the receiver's environment; and
 * an exception thrown by the sender factory propagates out of
   `connect`.

The existing "forwards get_frame_allocator" test only confirms that the
frame allocator is visible to the erased sender; the first new test
confirms that `function` uses it for its own allocation, which is the
path fixed in NVIDIA#2249.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`function` stores its sender factory as raw bytes and reconstitutes it
in `connect`. Any small, trivially-copyable callable has qualified as a
factory, so a lambda capturing `this` (or a reference to a local) could
hide a borrowed object inside the factory, where neither the
`function`'s type nor the code constructing it shows the dependency.

This diff requires the factory to be a pointer to function, a pointer to
member, or an empty, trivially-copyable callable (like a captureless
lambda or a CPO like `ex::just`). Any state the factory needs must now
be passed as a curried argument, which the `function`'s type records.
The constraints are factored into named concepts
(`__is_callable_pointer`, `__is_empty_callable`,
`__is_factory_of_suitable_sender`, and `__is_suitable_factory`), and
`__not_decays_to` is checked first so that copies and moves don't
instantiate the more expensive factory checks. A stateless factory is at
most a pointer to member, so the size check becomes a `static_assert` in
the constructor rather than a constraint.

The tests that captured state now curry it instead: the frame-allocator
forwarding test curries the memory resource, and the
virtual-member-function tests curry an `iface const *`. The next diff
gives the latter a nicer spelling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This diff teaches `function` to represent asynchronous member functions,
declared as `function<int(bar) &>` or `function<int(bar) const &>`
(optionally `noexcept`, and also in the `sender_tag` form with explicit
completions, queries, and attrs). The constructor takes the object,
which must be an lvalue, as its first argument; the `function` stores a
pointer to it, and `connect` passes it to the factory, with the declared
qualifiers, ahead of the curried arguments. Since factories are
stateless, the ref-qualifier is a guarantee rather than an annotation: a
`function`'s type shows whether it borrows an object, and whether it may
modify it. It also makes virtual member functions that return `function`
natural to write.

A `const &` `function` also accepts a non-const lvalue, as a synchronous
`const &` member function would; the factory still receives a const
reference. Rvalues are never accepted because the `function` would then
borrow an object that's about to expire, so something like
`auto f = make_example().get_int();` would dangle. For the same reason,
`&&` and `const &&` function types are rejected, as is `const` without a
ref-qualifier (which binds rvalues, too), and so are the `volatile`
forms. The `function` alias is constrained on
`__is_supported_function_type` so that unsupported forms produce a
constraint failure naming the concept rather than an incomplete-type
error, and so that `function<Sig>` stays SFINAE-friendly.

Under the hood, `__make_function` maps a ref-qualified function type to
one whose first parameter is a `__self_box` that records the qualifiers.
The matching `__function` specialization stores a (possibly const)
`void*` and wraps the factory in a `__self_adapting_factory`, which
casts the pointer back to `__declared_self_t` before invoking the user's
factory. `__binds_to_self` decides which self arguments are accepted.

Supporting member functions motivated a reorganization of
`__make_function`: its specializations are regrouped (return-type-driven
ones, then completion-signature-driven ones), they express the variants
through inheritance rather than repetition, and `__make_function` is now
a struct template. `__sigs_from_t` is inlined, and some unnecessary
`__func::` qualification is removed. The documentation at the top of the
file and on the `function` alias template now describes the current
interface.

The tests cover member functions end to end, `noexcept` construction
from pointer factories, which self arguments each form accepts, and the
rejection of the unsupported function types. The virtual-interface tests
now return `function<int() const & noexcept>` instead of currying a
pointer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`__canonical_t` sorts and deduplicates completion signatures, queries,
and attrs with its own `consteval` machinery (`__canonicalize`,
`__canonicalize_impl`, and `__canonicalize_splice` over a
`__static_vector` of type ids). `__meta.hpp` provides `__munique` and
`__msort`, and `__execution_fwd.hpp` already composes them the same way,
so this diff replaces the bespoke machinery with
`__munique<__msort<__q<_List>>>`. Both approaches order by `__mtypeid`,
so the canonical types don't change.

The `__static_vector.hpp` and `__typeinfo.hpp` includes are no longer
needed (`__meta.hpp` includes the latter for `__msort`), so they're
removed. New tests confirm that duplicate completion signatures collapse
and that the order of attrs doesn't matter, alongside the existing
order-independence tests for completion signatures and queries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The factory storage, `__make_sender_`, is a plain `std::byte` array of
`2 * sizeof(void *)` bytes, which bakes in two assumptions:
 * that alignment doesn't matter, but `__mk_opstate` reconstitutes the
   factory with `start_lifetime_as`, and pointers to functions and to
   members need pointer alignment; it works only because the array
   happens to follow a pointer member; and
 * that a pointer to member function is two pointers, which holds under
   the Itanium C++ ABI but not under the Microsoft ABI, where the size
   of a pointer to member depends on the class's inheritance model and
   the general representation (used, for example, when the pointer type
   is formed while the class is incomplete) is larger. On such targets,
   a valid pointer-to-member factory satisfies `__is_suitable_factory`
   and then fails the `static_assert` in the constructor.

This diff sizes and aligns the storage for the largest of a pointer to
function, a pointer to member function, and a pointer to member data,
where the pointers to members point into a never-defined class so that
they get the ABI's most general representation. It also asserts that
each stored factory's alignment fits. Computing the storage this way
tracks the target ABI rather than the compiler, so Clang targeting
`*-windows-msvc` and MinGW GCC should each get the right size, and the
storage remains two pointers under Itanium.

The new tests store pointer-to-member factories for classes with
multiple and virtual inheritance and for a class that was incomplete
when the pointer type was formed. They only exercise the larger
representations on Microsoft-ABI targets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`get_completion_domain_t<>` (i.e. `get_completion_domain_t<void>`) asks
for the `set_value` completion domain, and `__get_completion_domain_tag`
already maps it to `set_value_t`, but `__make_domain_impl` only answers
a `get_completion_domain<set_value_t>` query for attributes spelled with
an explicit `set_value_t`. As a result, a `function` declared with
`attrs<D(get_completion_domain_t<>)>` neither reports `D` as its
`set_value` domain nor requires the erased sender to complete there; it
accepts `ex::just`, whose `set_value` domain is the default domain.

This diff normalizes `get_completion_domain_t<>` to
`get_completion_domain_t<set_value_t>` (preserving `noexcept`) before
canonicalizing the `attrs<>` specification, adds a test covering both
the constraint and the reported domain, and documents the equivalence on
the `function` alias template.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`__opstate` passes `this` to the type-erased receiver that it connects
the erased sender to, and the `__opstate_base` specialization that
injects a frame allocator stores a `polymorphic_allocator` that points
at its own `__resource_`, so moving an `__opstate` would leave both
pointing at the old object. `__opstate` is currently immovable only
because its `_any_opstate_base` member is; this diff declares it
immovable directly with `STDEXEC_IMMOVABLE` so that a future change to
the shared `__any` machinery can't silently make it movable.

There's no test because, on GCC, `STDEXEC_IMMOVABLE` declares a move
constructor without deleting it (a workaround for gcc#98995), so
`is_move_constructible` can't check this portably.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`function`'s constructors take every curried argument as `_Args &&`, so
an lvalue `int` has to be passed as `std::move(i)` or `int(i)`. This
diff introduces `__curried_param_t`, which takes a curried argument
declared as `T` as `T const &` when `T` is a non-reference type that's
both trivially copy-constructible and trivially move-constructible, and
as `T &&` otherwise. The upshot is that an lvalue is accepted (and
copied) exactly when writing `std::move` would change nothing; costly
copies still have to be spelled out (e.g. with `auto(s)`).

Both traits are required because, with a `const &` parameter, even
`std::move(x)` copies, so a type whose move is non-trivial must not take
that path. `is_trivially_copyable` would be the wrong test: it also
requires trivial assignment and destruction, which `function` never
uses, and it excludes `std::pair<int, int>`. Reference-typed parameters
are unchanged.

The tests cover `int` and `std::pair<int, int>` lvalues (accepted),
`std::string` lvalues (rejected), a trivially-copyable type with a
deleted copy constructor (which falls back to `T &&`), reference-typed
parameters, and a member function's explicit arguments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This diff gives a `noexcept` function type a meaning of its own: it
promises that the type-erased path (invoking the factory and connecting
the sender it returns) doesn't throw, while the completion signatures
describe only the asynchronous contract. Since that makes `noexcept` a
property of the type that's separate from the completions, `__function`
gains a leading `bool _Nothrow` template parameter, which
`__function_meta` sets from the function type.

Because `noexcept` now means something the completions can't express,
the `sender_tag` form may be declared `noexcept`, too. Its completion
signatures stay exactly as declared; only the convenience forms drop
`set_error(exception_ptr)` when declared `noexcept`, as they did before.

One consequence is that `function<int(int) noexcept>` is no longer the
same type as the `sender_tag` form with the same completions unless the
latter is also declared `noexcept`, so the order-independence test now
declares it that way.

This diff only records the promise; the following diffs make `connect`
conditionally `noexcept` and constrain the constructors to enforce it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This diff makes `connect() &&` `noexcept` exactly when nothing on the
path to the operation state can throw, which is when:
 * the function type is `noexcept` (the author's promise that invoking
   the factory and connecting its sender don't throw); and
 * the frame allocator chosen from the receiver's environment allocates
   without throwing (the caller's choice).

`connect() const &` additionally requires the curried arguments to be
nothrow-copy-constructible, since it copies them.

There's no condition on the receiver because the `receiver` concept
already requires receivers to be nothrow-move-constructible, and
[exec.connect] makes it ill-formed, no diagnostic required, for whether
`connect` is `noexcept` to differ between receivers with the same
environment type.

The allocator condition is evaluated on the `__frame_allocator_t`
adaptation of the allocator that actually allocates. When `function`
injects a type-erased frame allocator, that's the allocator chosen from
the concrete receiver's environment, since the injected
`polymorphic_allocator` and `__memory_resource_adaptor_t` only forward
to it (the adaptor adds only `std::align` and placement new, and
`polymorphic_allocator`'s length check can't fire for `std::byte`). When
the `function` declares a `get_frame_allocator` query, it's the declared
result type, as the type-erased receiver's environment reports it.

A domain transformation of the factory's sender isn't checked (the next
diff explains why), so if the transformation, or the transformed
sender's `connect`, throws under a `noexcept` function type,
`std::terminate` is called.

The tests cover each condition, the declared-query case, the extra copy
in `connect() const &`, and a `noexcept` function allocating its frame
through a nothrow frame allocator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A `noexcept` function type promises that the type-erased path (invoking
the factory and connecting the sender it returns) doesn't throw. This
diff enforces that promise in both constructors with constraints added
after `__is_suitable_factory`, so that `is_constructible` stays
truthful. It checks only what can be checked exactly and what the
function type's author can fix:
 * `__factory_is_nothrow_invocable`: the factory is nothrow-invocable
   with the curried arguments as `connect() &&` passes them; and
 * `__factory_sender_connects_without_throwing`: unless a domain
   transforms the factory's sender (`__is_transformed_for`), connecting
   that sender doesn't throw (`__nothrow_connectable`).

`__meets_noexcept_contract` combines the two, and is trivially satisfied
for function types that aren't `noexcept`.

The `connect` check is made against
`__nothrow_frame_allocation_receiver`, which is the type-erased receiver
with its environment's frame allocator replaced by a stub whose
`allocate` is declared `noexcept`. Frame allocation failure is the
caller's concern, and it's already reflected in `connect`'s conditional
`noexcept`. Checking against the real type-erased receiver would reject
a `noexcept` function whose factory returns another `noexcept` function,
because the inner `connect` allocates through the outer function's
type-erased frame allocator, whose `allocate` can't be `noexcept`;
`noexcept` functions wouldn't compose. Substituting the stub is sound as
long as a sender's `connect` depends on the frame allocator's exception
behaviour only through calls to `allocate`.

Domain transformations and transformed senders' `connect` aren't
checked, because checking them would make some types impossible to
declare. They're trusted instead. The domains that can transform the
factory's sender depend only on that sender and on the function type's
declared queries: the type-erased receiver's environment provides
nothing else but a frame allocator, so the caller's domain can't reach
the sender. Those domains are chosen only implicitly (declaring a
`get_scheduler_t` query, for example, brings that scheduler's domain
into play) and nothing checks the choice, so declaring the type
`noexcept` vouches for whatever they do. If either throws anyway,
`std::terminate` is called.

The tests cover a throwing factory, a factory that copies a curried
argument when invoked, and a sender with a throwing `connect` (each
rejected only for the `noexcept` type), the member-function form, a
`noexcept` function returning a `noexcept` function, and a domain
transformation into a sender with a throwing `connect`. There's also a
test that the constructor rejects a sender that's broken only in the
receiver's environment (`read_env(get_stop_token) | then(f)` with an `f`
that can't take the stop token), which guards the SFINAE-friendliness of
the existing constraints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This diff constrains both `connect` overloads to require an argument
that models `receiver`, as `__basic_sender`'s do. That matches the
working draft's convention: both of the classes it specifies with a
member `connect`, *`basic-sender`* and `task`, declare it as a template
constrained on `receiver`.

The `connect` CPO already mandates a receiver, so this only affects
direct calls to the member and code that detects `.connect` with SFINAE:
passing a non-receiver now makes the call ill-formed instead of
instantiating the operation state.

The tests confirm that a receiver is accepted and that `int` is rejected
by both overloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`__self_adapting_factory::operator()` asserts that the factory is a
callable pointer or an empty callable, but `__self_adapting_factory` is
only instantiated by the member-function constructor, whose constraints
already require `__is_suitable_factory`, which includes the same check.
This diff removes the `static_assert` so that the factory's kind is
checked in one place, as a constraint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ideally, `function` would allocate raw storage for its operation state
from the frame allocator and construct the operation state with
`construct_at`. Instead, it goes through the `__any` machinery that it
shares with `any_sender_of`, which wraps an operation state that doesn't
fit in the inline buffer and constructs and destroys the wrapper with
`allocator_traits<...>::construct` and `destroy`.

This diff adds a comment at the construction site describing the
difference and what's observable about it. Uses-allocator construction
never reaches the operation state, since the wrapper declares no
`allocator_type` and the operation state is initialized inside it from
`connect`'s result. A frame allocator's own `construct` and `destroy`
members are called with the wrapper's type, but only when the `function`
declares the `get_frame_allocator` query, since the injected
`polymorphic_allocator` forwards only `allocate` and `deallocate`. The
comment also sketches the fix: a `function`-specific path through
`__any` that uses raw storage and `construct_at`/`destroy_at`, leaving
`any_sender_of` unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A member-function `function` such as `function<int() const &>` is
implemented in terms of the void-pointer form it's built on,
`function<int(void const *)>`. Deriving publicly from that form made the
member-function form convert to it, so passing one where the other was
expected type-checked. That relationship is an implementation detail,
not part of either type's interface.

This diff holds the void-pointer form as a member instead and forwards
`connect`, `get_env` and `get_completion_signatures` to it. The primary
template befriends its other specializations so that the member-function
form can still use its private constructor.

The tests confirm that the member-function form doesn't convert to the
void-pointer form by value, reference or pointer, and that the
void-pointer form can't be explicitly constructed from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A `function` reports the completion domains its type declares, whatever
environment it's connected in. When its implementation completes inline,
as `just` does, the operation actually completes wherever the caller
started it, so the reported domain can differ from the domain that
`just` itself would report in the same environment.

This diff adds a test that pins that behaviour:
 * in an environment whose `get_domain` names a domain derived from
   `default_domain`, `just` reports that domain while a `function`
   wrapping `just` reports `default_domain`;
 * forwarding `get_domain` through a declared query doesn't change the
   reported domain;
 * a `function` that forwards a domain derived from `default_domain` but
   declares the default is still constructible, since the constraint
   accepts the declared domain when it's the common domain of the
   declared and actual ones, while an unrelated forwarded domain is
   rejected; and
 * declaring the forwarded domain in `attrs<...>` makes the `function`
   report it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ispeters
ispeters force-pushed the support_member_functions branch from fc3dfe6 to a77f2a2 Compare October 17, 2026 22:41
nvc++ (seen with 26.1 through 26.9) stops with an internal error in its
name mangler (`record_substitution_for_type`) when it compiles a
`function` with no curried arguments, such as `function<int()>`. The
constructors take the curried arguments as `__curried_param_t<_Args>...`,
and the mangler fails on a constructor template whose parameter list
begins with an empty expansion of a dependent type and continues with a
deduced one, here the factory. A leading plain pack such as
`_Args &&...`, a leading non-pack parameter, or a factory parameter
whose type isn't deduced each avoid the error.

This diff names the parameter types as a separate pack. `__function`
gains a template parameter, `_Params`, which is always
`__curried_params_t<_Args...>` (that is,
`__mlist<__curried_param_t<_Args>...>`), and both forms of `function`
specialize on `__mlist<_Params...>`, so their constructors take
`_Params...`. The member-function form's specialization adds `_SelfBox`
after the list, which keeps it more specialized than the void-pointer
form's. Nothing outside `__function` changes, and neither does the
layout.

Two smaller changes don't work. Constraining away the general
constructor when `_Args` is empty doesn't help, apparently because nvc++
mangles the constructor template's specialization during overload
resolution, before checking its constraints. Moving the members and
constructors into a base class whose constructors `__function` inherits
avoids the error, but nvc++ then doesn't reuse the base subobject's tail
padding, so `function` would grow inside an enclosing object.

Most of the existing tests use functions with no curried arguments, so
they cover the change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test that `function`'s padding is at its end checks that a following
`char` fits in the tail padding of `function<int(char)>` and
`function<int(int)>`. On 32-bit x86, `function<int(int)>` has no tail
padding at all: its 8-byte factory storage, 4-byte operation-state
factory pointer and 4-byte `int` fill 16 bytes exactly, so the `char`
makes the enclosing object 20 bytes and the assertion fails. The
property under test still holds; the test assumed there was padding to
reuse.

This diff uses `function<int(short)>` instead, which has tail padding
wherever pointers are wider than two bytes: two bytes of it on 32-bit
x86 and six on x86-64.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ispeters
ispeters force-pushed the support_member_functions branch from a77f2a2 to b118d66 Compare October 17, 2026 22:46
@ericniebler

Copy link
Copy Markdown
Collaborator

/ok to test b118d66

@ericniebler
ericniebler merged commit dfc8c95 into NVIDIA:main Oct 18, 2026
40 checks passed
@ispeters
ispeters deleted the support_member_functions branch October 18, 2026 01:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants