Make operator& promise only what joining actually promises#302
Merged
Conversation
This was referenced Jul 13, 2026
🤖 Augment PR SummarySummary: This PR fixes the library’s Changes:
Why: Previously, joins and some monadic arms promised 🤖 Was this summary useful? React with 👍 or 👎 |
9c1294d to
f82bf4e
Compare
All nine `operator&` overloads were unconditionally `noexcept`, yet joining relocates both operands' values - and, for expected, their errors - into the result. A throwing copy or move called std::terminate instead of propagating. The specification bottoms out in the fold, which had no specification of its own, so an honest answer had to be computed there first. Its four overloads dispatch through `sum::_transform` with a callable, and neither a noexcept-specifier nor (before clang 17, and our floor is 16) any unevaluated operand can name a lambda - so the fold's lambdas, and the error lifts of optional's and expected's joins, become named types. A sum<> operand can hold no error, so the arm lifting its error is unreachable; what cannot run cannot throw, and the trait says so - otherwise the unit-error grade, which the README leads with, would report noexcept(false) for a join that cannot fail. The accessors are only reached once has_value() has answered, so the arms are specified with `decltype` of `value()` / `error()` rather than `noexcept` of a call to them, which would drag in a throw the body never risks. Closes #279 Assisted-by: Claude:claude-opus-4-8[1m]
fn's monadic extensions - the sum/pack dispatch, and the graded arms that widen an error or a value into a sum - all advertised potentially-throwing, whatever they were given. The specs asked std::is_nothrow_invocable_v, which is false for a callable that is not DIRECTLY invocable: a pack's callback takes one argument per element and a sum's is chosen per alternative, and both are reached through fn's own `_invoke`. The graded arms fared worse: their specs led with a same-shape conjunct, so widening - the whole point of the graded monad - was false by construction. Two of the arms carried no spec at all. The traits from #45 answer the first: `_is_nothrow_invocable` walks the same dispatch the body does, and agrees with the std trait wherever the callable is directly invocable, so the pfn-shaped paths keep pfn's answer exactly. The graded arms are chosen by `if constexpr`, and a noexcept-specifier - an ordinary constant expression - cannot choose: the untaken arm's spelling would have to be well-formed too. So a trait chooses, with one constrained specialization per arm, and an unconstrained primary that leaves a bad callback to the body's static_assert rather than pre-empting it with a hard error. A sum<> error is unconstructible, so an expected carrying one can never hold an error: the arms lifting it are unreachable, and what cannot run cannot throw. The guard is on the error alone - such an expected still has a value, which is still relocated, and still weighs. Closes #254 Assisted-by: Claude:claude-opus-4-8[1m]
`_join` invokes its error-continuation as an lvalue - a named parameter - but its specification asked `is_nothrow_invocable_v<Efn, ...>` where `Efn` is the deduced reference: for a callable passed as a temporary, that is the rvalue call, which the body never performs. A callable whose `&` and `&&` overloads differ could answer with the wrong noexcept - or the wrong result type, through `invoke_result_t` - and one offering only the lvalue call had no answer at all. Every question now asks `Efn &`, which reference-collapses to the body's call whatever category the callable arrived in. Not reachable through the public surface - all three callers pass functors whose call operators are not ref-qualified - so the tests ask at the detail seam, where the divergence is visible. Assisted-by: Claude:claude-fable-5
f1df6ea to
34cda15
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
Member
Author
|
augment review |
1 similar comment
Member
Author
|
augment review |
Bronek
added a commit
that referenced
this pull request
Jul 13, 2026
Flip #254 and #279: the sum and pack dispatch answers for its own noexcept now, so the pins that read conservatively false - a visitor set weighed as if std would call it on the sum itself - become true, and operator& promises only what relocating the operands promises, so the throwing-copy witnesses become false. The pack-level operator&, which had no specifier and so never over-promised, gains one too. Take #302's join coverage in place: the value and error relocations per value category, the void operand, the widening error, and the unit-error grade the README leads with - which cannot fail, and now says so. 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
operator&join and fn's own monadic arms - the same honest-spec derivation, written atop each other.Make
operator&promise only what joining actually promisesAll nine
operator&overloads were unconditionallynoexcept, yet joining relocates both operands' values - and, for expected, their errors - into the result. A throwing copy or move called std::terminate instead of propagating.The specification bottoms out in the fold, which had no specification of its own, so an honest answer had to be computed there first. Its four overloads dispatch through
sum::_transformwith a callable, and neither a noexcept-specifier nor (before clang 17, and our floor is 16) any unevaluated operand can name a lambda - so the fold's lambdas, and the error lifts of optional's and expected's joins, become named types.A sum<> operand can hold no error, so the arm lifting its error is unreachable; what cannot run cannot throw, and the trait says so - otherwise the unit-error grade, which the README leads with, would report noexcept(false) for a join that cannot fail.
The accessors are only reached once has_value() has answered, so the arms are specified with
decltypeofvalue()/error()rather thannoexceptof a call to them, which would drag in a throw the body never risks.Compute a real
noexceptfor fn's own monadic armsfn's monadic extensions - the sum/pack dispatch, and the graded arms that widen an error or a value into a sum - all advertised potentially-throwing, whatever they were given. The specs asked std::is_nothrow_invocable_v, which is false for a callable that is not DIRECTLY invocable: a pack's callback takes one argument per element and a sum's is chosen per alternative, and both are reached through fn's own
_invoke. The graded arms fared worse: their specs led with a same-shape conjunct, so widening - the whole point of the graded monad - was false by construction. Two of the arms carried no spec at all.The traits from #45 answer the first:
_is_nothrow_invocablewalks the same dispatch the body does, and agrees with the std trait wherever the callable is directly invocable, so the pfn-shaped paths keep pfn's answer exactly.The graded arms are chosen by
if constexpr, and a noexcept-specifier - an ordinary constant expression - cannot choose: the untaken arm's spelling would have to be well-formed too. So a trait chooses, with one constrained specialization per arm, and an unconstrained primary that leaves a bad callback to the body's static_assert rather than pre-empting it with a hard error.A sum<> error is unconstructible, so an expected carrying one can never hold an error: the arms lifting it are unreachable, and what cannot run cannot throw. The guard is on the error alone - such an expected still has a value, which is still relocated, and still weighs.
Closes #279
Closes #254
Stack: P8 of 13 — the release-0.1 bugfix queue, merging bottom-up into
main. Based on #301 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).