Ask of a sum alternative the same question its initialization answers#305
Merged
Conversation
This was referenced Jul 13, 2026
🤖 Augment PR SummarySummary: This PR aligns Changes:
Technical Notes: Adds targeted tests that pin (1) “braces, not parentheses” for 🤖 Was this summary useful? React with 👍 or 👎 |
2f50521 to
68e5e78
Compare
25981c1 to
b729a01
Compare
A sum brace-initializes the alternative it stores, but its constraints and
noexcept specifications asked `std::is_[nothrow_]constructible`, which is a
question about parentheses. The two disagree - braced initialization considers
initializer-list constructors first - and where they disagree the parenthesized
answer is a lie: a type can promise a nothrow move and still throw from
`T{std::move(t)}`.
Believing it cost more than a wrong specification. `sum`'s move constructor was
declared `noexcept` on that promise and called std::terminate when the promise
broke; and `_reinit`, whose temporary arm rests on the move being nothrow,
destroyed the alternative it held and then failed to replace it - leaving the sum
with no alternative at all, which is not a lost guarantee but a dead object.
So every trait that constrains or specifies the construction of an alternative
now asks the brace question, through `_initializable` / `_nothrow_initializable`
- the traits that already exist for it.
The choice of braces is a design direction rather than an implementation detail -
it is what gives `sum` aggregate forwarding through brace elision, which
`std::variant` cannot express and parenthesized initialization cannot either - so
a test now pins it, and says what it buys and what it costs. Other tests would
break if it were switched; that one breaks on purpose.
The temporary arm's completion, as opposed to its throwing, had no coverage at
all: only the throw was ever exercised.
Closes #291
Assisted-by: Claude:claude-opus-4-8[1m]
The constraints and specifications of `sum` restated what its storage does, and a
restatement can drift from the deed - which is how `std::is_nothrow_move_constructible_v`
came to answer for `T{std::move(t)}`, a question it does not ask.
`make_variadic_union` is the only thing that ever builds an alternative, so it now
says what it can build and what that costs: constrained on the initialization it
performs, and specified by it. Everything above asks that, through the two
concepts beside it, rather than describing it again. `sum` names the operations
once - copyable, movable, and their nothrow twins - and every constructor,
assignment and `_reinit` is written in those terms. `choice` and the `as_sum`
lifts ask the sum they construct, which asks its storage in turn. No declaration
restates the initialization, and none can disagree with it.
The arm that assignment takes is still chosen per alternative, so `_copy_assignable`
keeps that choice inside the fold: one arm serving them all would reject a sum
whose alternatives simply need different ones.
`make_variadic_union` also declares its return type now. Deducing it meant
instantiating the body to deduce it, and the body odr-uses the alternative's
constructor - so merely ASKING whether an alternative could be stored demanded a
definition of the constructor that would store it.
Closes #291
Assisted-by: Claude:claude-opus-4-8[1m]
`choice` adds no state to the `sum` it derives from, and defaults all five of its special members - so each must behave exactly as the base's, down to its noexcept and its constraints. Nothing said so, and nothing would have noticed if it stopped being true: dropping a `= default`, promising a `noexcept` the base does not, or narrowing a requires-clause would all have passed in silence. They are compared against `sum`'s across the property axes that make the answers differ - a throwing copy, a move-only alternative, an immovable one - and each comparison is backed by what the answer actually is, so that an equality cannot be satisfied by both sides being wrong at once. Closes #291 Assisted-by: Claude:claude-opus-4-8[1m]
The pack half of #291. Relocation asked `is_nothrow_constructible_v` - direct-initialization - where the aggregate initialization in `_append` copy-initializes each element from its source ([dcl.init.aggr]/4.3), which cannot reach an explicit constructor: an element with an explicit nothrow move and a throwing copy made `append` promise a `noexcept` the deed cannot keep. Nothing asked whether relocation was possible at all, so appending to a pack whose element cannot make the move hard-errored inside the body where a clean rejection was owed. `as_pack` asked the same question of `pack` itself, where parenthesized initialization performs no brace elision - false for every argument list, a permanent under-promise. `invoke_r` promised direct-initialization of `Ret` and performed a return statement. `_make_element` now declares its own contract, `_relocatable_element` asks the element holder for the copy-initialization it performs, `_append` is constrained on and specified by both questions, and every `append` overload asks the `_append` it calls. `as_pack` is constrained on and specified by the braces it performs, and declares its return type. `invoke_r` delegates to the new `pack_impl::_invoke_r`, asking the same `INVOKE<R>` question `sum::invoke_r` asks - which also aligns `invoke_r<void>`, accepted by `std::invoke_r` and `sum` and until now rejected by `pack`. Closes #291 Assisted-by: Claude:claude-fable-5
`pack` declares no constructor and no special member - `_element`, `pack_impl` and `pack` itself are aggregates, and everything above rests on that: brace initialization with elision, the structural type, and special members composed from the elements' own. A constructor added anywhere in the stack would change all of it in silence; these assertions fail instead. Assisted-by: Claude:claude-fable-5
68e5e78 to
c36d9d7
Compare
|
augment review |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
Bronek
added a commit
that referenced
this pull request
Jul 13, 2026
Retire #291's tripwire: as_pack asks the braces it performs, so it no longer under-promises for every argument list, and #305's own section says it better than the pin did - an lvalue binds rather than moves, and the constraint follows the same initialization. Drop our coverage of sum's temporary arm completing, which #305 brings with a constant-evaluation twin: the same test, said twice, would not have compiled. Assisted-by: Claude:claude-opus-4-8[1m]
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.



The #291 sweep: constraints and specifications derived from the initialization the code performs, at the layer performing it -
sum's storage,choice's inheritance,pack's aggregate machinery - each pinned by tests so it cannot drift again.Ask of a
sumalternative the same question its initialization answersA sum brace-initializes the alternative it stores, but its constraints and noexcept specifications asked
std::is_[nothrow_]constructible, which is a question about parentheses. The two disagree - braced initialization considers initializer-list constructors first - and where they disagree the parenthesized answer is a lie: a type can promise a nothrow move and still throw fromT{std::move(t)}.Believing it cost more than a wrong specification.
sum's move constructor was declarednoexcepton that promise and called std::terminate when the promise broke; and_reinit, whose temporary arm rests on the move being nothrow, destroyed the alternative it held and then failed to replace it - leaving the sum with no alternative at all, which is not a lost guarantee but a dead object.So every trait that constrains or specifies the construction of an alternative now asks the brace question, through
_initializable/_nothrow_initializable- the traits that already exist for it.The choice of braces is a design direction rather than an implementation detail - it is what gives
sumaggregate forwarding through brace elision, whichstd::variantcannot express and parenthesized initialization cannot either - so a test now pins it, and says what it buys and what it costs. Other tests would break if it were switched; that one breaks on purpose.The temporary arm's completion, as opposed to its throwing, had no coverage at all: only the throw was ever exercised.
Derive what storing an alternative costs from the code that stores it
The constraints and specifications of
sumrestated what its storage does, and a restatement can drift from the deed - which is howstd::is_nothrow_move_constructible_vcame to answer forT{std::move(t)}, a question it does not ask.make_variadic_unionis the only thing that ever builds an alternative, so it now says what it can build and what that costs: constrained on the initialization it performs, and specified by it. Everything above asks that, through the two concepts beside it, rather than describing it again.sumnames the operations once - copyable, movable, and their nothrow twins - and every constructor, assignment and_reinitis written in those terms.choiceand theas_sumlifts ask the sum they construct, which asks its storage in turn. No declaration restates the initialization, and none can disagree with it.The arm that assignment takes is still chosen per alternative, so
_copy_assignablekeeps that choice inside the fold: one arm serving them all would reject a sum whose alternatives simply need different ones.make_variadic_unionalso declares its return type now. Deducing it meant instantiating the body to deduce it, and the body odr-uses the alternative's constructor - so merely ASKING whether an alternative could be stored demanded a definition of the constructor that would store it.Pin what
choice's defaulted special members followchoiceadds no state to thesumit derives from, and defaults all five of its special members - so each must behave exactly as the base's, down to its noexcept and its constraints. Nothing said so, and nothing would have noticed if it stopped being true: dropping a= default, promising anoexceptthe base does not, or narrowing a requires-clause would all have passed in silence.They are compared against
sum's across the property axes that make the answers differ - a throwing copy, a move-only alternative, an immovable one - and each comparison is backed by what the answer actually is, so that an equality cannot be satisfied by both sides being wrong at once.Derive what pack's storage performs from the code that performs it
The pack half of #291. Relocation asked
is_nothrow_constructible_v- direct-initialization - where the aggregate initialization in_appendcopy-initializes each element from its source ([dcl.init.aggr]/4.3), which cannot reach an explicit constructor: an element with an explicit nothrow move and a throwing copy madeappendpromise anoexceptthe deed cannot keep. Nothing asked whether relocation was possible at all, so appending to a pack whose element cannot make the move hard-errored inside the body where a clean rejection was owed.as_packasked the same question ofpackitself, where parenthesized initialization performs no brace elision - false for every argument list, a permanent under-promise.invoke_rpromised direct-initialization ofRetand performed a return statement._make_elementnow declares its own contract,_relocatable_elementasks the element holder for the copy-initialization it performs,_appendis constrained on and specified by both questions, and everyappendoverload asks the_appendit calls.as_packis constrained on and specified by the braces it performs, and declares its return type.invoke_rdelegates to the newpack_impl::_invoke_r, asking the sameINVOKE<R>questionsum::invoke_rasks - which also alignsinvoke_r<void>, accepted bystd::invoke_randsumand until now rejected bypack.Pin what pack is: an aggregate, at every layer
packdeclares no constructor and no special member -_element,pack_implandpackitself are aggregates, and everything above rests on that: brace initialization with elision, the structural type, and special members composed from the elements' own. A constructor added anywhere in the stack would change all of it in silence; these assertions fail instead.Closes #291
Stack: P11 of 13 — the release-0.1 bugfix queue, merging bottom-up into
main. Based on #304 and to be retargeted tomainonce it merges; the diff shows only this PR's commits. Every stack boundary was validated with a gcc build and the full test suite on top ofmain, and the aggregate branch ran the full CI matrix green (#289).