Repository navigation
exec::function: member functions, stateless factories, and a noexcept contract - #2321
Merged
Merged
Conversation
ispeters
force-pushed
the
support_member_functions
branch
8 times, most recently
from
October 10, 2026 04:20
40efbc1 to
9a85c0a
Compare
Collaborator
|
/ok to test 9a85c0a |
Collaborator
|
/ok to test fc3dfe6 |
`__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
force-pushed
the
support_member_functions
branch
from
October 17, 2026 22:41
fc3dfe6 to
a77f2a2
Compare
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
force-pushed
the
support_member_functions
branch
from
October 17, 2026 22:46
a77f2a2 to
b118d66
Compare
Collaborator
|
/ok to test b118d66 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR extends
exec::functionwith support for asynchronous memberfunctions and gives
noexceptfunction 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 apointer 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) &>andfunction<int(bar) const &>(optionally
noexcept, and in thesender_tagform) declare asynchronousmember functions. The constructor takes the object as an lvalue, and the
functionborrows it. Because factories are stateless, the ref-qualifiertells you whether a
functionborrows an object and whether it may modify it.Forms that would borrow an expiring object (
&&,const &&, andconstwithout a ref-qualifier) are rejected, as are
volatileforms. Virtual memberfunctions that return
functionbecome 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::movewould change nothing. Everything else still has to bemoved or explicitly copied.
noexcept. Anoexceptfunction type now promises that the type-erasedpath (invoking the factory and connecting the sender it returns) doesn't
throw, independently of the completion signatures, so the
sender_tagformmay be declared
noexcepttoo.connectis conditionallynoexcept: it'snoexceptwhen the functiontype is
noexceptand the frame allocator chosen from the receiver'senvironment allocates without throwing (
connect() const &also requiresnothrow-copyable curried arguments).
nothrow-invocable, and, unless a domain transforms the factory's sender,
that sender's
connectmust not throw. Domain transformations aretrusted, since the function type's author chose the domain.
connectoverloads are now constrained onreceiver.Fixes and cleanups.
__msortaccepts an empty type list. This one touches stdexec core andis independent of the rest; I'm happy to split it out if you'd rather.
get_completion_domain_t<>inattrs<>now means theset_valuedomain,as it does elsewhere.
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.factory-exception behaviour.
Notes for reviewers
MSVC-ABI targets; I'm relying on CI for that.
how construction through the shared
__anymachinery differs fromallocating raw storage and using
construct_at, and what a fix wouldlook like.