Skip to content

Make operator& promise only what joining actually promises#302

Merged
Bronek merged 3 commits into
mainfrom
bronek/P8/operator_amp_noexcept
Jul 13, 2026
Merged

Make operator& promise only what joining actually promises#302
Bronek merged 3 commits into
mainfrom
bronek/P8/operator_amp_noexcept

Conversation

@Bronek

@Bronek Bronek commented Jul 13, 2026

Copy link
Copy Markdown
Member

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 promises

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.

Compute a real noexcept for fn's own monadic arms

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 #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 to main once 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 of main, and the aggregate branch ran the full CI matrix green (#289).

@augmentcode

augmentcode Bot commented Jul 13, 2026

Copy link
Copy Markdown
🤖 Augment PR Summary

Summary: This PR fixes the library’s operator& join and monadic operations so their noexcept contracts accurately reflect the moves/copies they actually perform.

Changes:

  • Adds fn::detail::_fold_detail::_nothrow_fold and related named callable helpers so the fold/join machinery can compute a correct noexcept across pack/sum dispatch paths.
  • Reworks expected’s monadic helpers (and_then/or_else/transform/transform_error) to use fn’s own dispatch-aware nothrow traits rather than std::is_nothrow_invocable_v (which can be pessimistic for pack/sum callbacks).
  • Updates optional’s monadic helpers similarly, including a trait-based noexcept for the value-widening or_else arm.
  • Makes join operator& for optional/expected compute noexcept based on whether relocating values/errors can throw, instead of being unconditionally noexcept.
  • Special-cases unreachable error-lift paths for sum<> error types so “unit error grade” joins don’t become spuriously throwing.
  • Adds extensive tests in tests/fn/expected.cpp and tests/fn/optional.cpp validating the new noexcept behavior for lvalues vs rvalues and for pack/sum dispatch.

Why: Previously, joins and some monadic arms promised noexcept (or conservatively noexcept(false)) in ways that didn’t match actual relocation behavior, risking std::terminate on throwing moves/copies or unnecessarily pessimistic contracts for dispatching callables.

🤖 Was this summary useful? React with 👍 or 👎

@augmentcode augmentcode Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review completed. 1 suggestion posted.

Fix All in Augment

Comment augment review to trigger a new review at any time.

Comment thread include/fn/pack.hpp Outdated
@Bronek Bronek force-pushed the bronek/P7/verbs_noexcept branch from 9c1294d to f82bf4e Compare July 13, 2026 15:32
@Bronek Bronek changed the base branch from bronek/P7/verbs_noexcept to main July 13, 2026 16:24
Bronek added 3 commits July 13, 2026 17:38
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
@Bronek Bronek force-pushed the bronek/P8/operator_amp_noexcept branch from f1df6ea to 34cda15 Compare July 13, 2026 16:38
@codecov

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@sonarqubecloud

Copy link
Copy Markdown

@Bronek

Bronek commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

augment review

1 similar comment
@Bronek

Bronek commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

augment review

@augmentcode augmentcode Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review completed. No suggestions at this time.

Comment augment review to trigger a new review at any time.

@Bronek Bronek merged commit 8b8819b into main Jul 13, 2026
64 checks passed

@augmentcode augmentcode Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review completed. No suggestions at this time.

Comment augment review to trigger a new review at any time.

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]
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.

operator& is unconditionally noexcept though joining copies operand values Compute real conditional noexcept for fn's monadic extensions

1 participant