From dbee9ac02be8efd35b999ed6445b3286a7a6a02a Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 11 Jul 2026 21:45:46 +0100 Subject: [PATCH 1/3] 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. Closes #279 Assisted-by: Claude:claude-opus-4-8[1m] --- include/fn/detail/functional.hpp | 76 ++++++++++++++++---- include/fn/expected.hpp | 117 ++++++++++++++++++++++++------- include/fn/optional.hpp | 15 +++- include/fn/pack.hpp | 34 +++++++-- tests/fn/expected.cpp | 51 ++++++++++++++ tests/fn/optional.cpp | 26 +++++++ 6 files changed, 270 insertions(+), 49 deletions(-) diff --git a/include/fn/detail/functional.hpp b/include/fn/detail/functional.hpp index c27511a8..294917bd 100644 --- a/include/fn/detail/functional.hpp +++ b/include/fn/detail/functional.hpp @@ -19,7 +19,22 @@ namespace fn::detail { namespace _fold_detail { -template [[nodiscard]] constexpr auto _fold(auto &&l, auto &&r) +// The branch the fold takes is chosen by `if constexpr`, so a single expression cannot state its +// specification: the two untaken spellings would be ill-formed. Hence one specialization per branch. +template +struct _nothrow_fold : ::std::bool_constant{::std::declval(), ::std::declval()})> {}; +template + requires _some_pack +struct _nothrow_fold + : ::std::bool_constant().append(::std::in_place_type_t{}, ::std::declval()))> {}; +template + requires(not _some_pack) && _some_pack +struct _nothrow_fold : ::std::bool_constant{::std::declval()}.append( + ::std::in_place_type_t{}, ::std::declval()))> {}; + +template +[[nodiscard]] constexpr auto _fold(auto &&l, auto &&r) // + noexcept(_nothrow_fold::value) { if constexpr (_some_pack) { return FWD(l).append(::std::in_place_type_t{}, FWD(r)); @@ -32,35 +47,70 @@ template [[nodiscard]] constexpr auto _fold(auto &&l, a } } +// Named types, not lambdas: `fold` and everything above it specify themselves in terms of what +// dispatching through these promises, and a lambda can be named neither in a noexcept-specifier nor +// (before clang 17) in any unevaluated operand at all. +template struct _fold_rh final { + Rv rv; + + template + [[nodiscard]] constexpr auto operator()(::std::in_place_type_t, auto &&l) const + noexcept(noexcept(_fold(FWD(l), ::std::declval()))) + { + return _fold(FWD(l), static_cast(rv)); + } +}; + +template struct _fold_lh final { + Lv lv; + + template + [[nodiscard]] constexpr auto operator()(::std::in_place_type_t, auto &&r) const + noexcept(noexcept(_fold(::std::declval(), FWD(r)))) + { + return _fold(static_cast(lv), FWD(r)); + } +}; + +template struct _fold_rh_sum final { + Rv rv; + + template + [[nodiscard]] constexpr auto operator()(::std::in_place_type_t, auto &&l) const + noexcept(noexcept(::std::declval()._transform(_fold_lh{FWD(l)}))) + { + return static_cast(rv)._transform(_fold_lh{FWD(l)}); + } +}; + template requires _some_sum && _some_sum -[[nodiscard]] constexpr auto fold(auto &&lv, auto &&rv) +[[nodiscard]] constexpr auto fold(auto &&lv, auto &&rv) // + noexcept(noexcept(FWD(lv)._transform(_fold_rh_sum{FWD(rv)}))) { - return FWD(lv)._transform([&rv](::std::in_place_type_t, auto &&l) { - return FWD(rv)._transform( - [&l](::std::in_place_type_t, auto &&r) { return _fold(FWD(l), FWD(r)); }); - }); + return FWD(lv)._transform(_fold_rh_sum{FWD(rv)}); } template requires _some_sum && (not _some_sum) -[[nodiscard]] constexpr auto fold(auto &&lv, auto &&rv) +[[nodiscard]] constexpr auto fold(auto &&lv, auto &&rv) // + noexcept(noexcept(FWD(lv)._transform(_fold_rh{FWD(rv)}))) { - return FWD(lv)._transform( - [&rv](::std::in_place_type_t, auto &&l) { return _fold(FWD(l), FWD(rv)); }); + return FWD(lv)._transform(_fold_rh{FWD(rv)}); } template requires(not _some_sum) && _some_sum -[[nodiscard]] constexpr auto fold(auto &&lv, auto &&rv) +[[nodiscard]] constexpr auto fold(auto &&lv, auto &&rv) // + noexcept(noexcept(FWD(rv)._transform(_fold_lh{FWD(lv)}))) { - return FWD(rv)._transform( - [&lv](::std::in_place_type_t, auto &&r) { return _fold(FWD(lv), FWD(r)); }); + return FWD(rv)._transform(_fold_lh{FWD(lv)}); } template requires(not _some_sum) && (not _some_sum) -[[nodiscard]] constexpr auto fold(auto &&lv, auto &&rv) +[[nodiscard]] constexpr auto fold(auto &&lv, auto &&rv) // + noexcept(noexcept(_fold(FWD(lv), FWD(rv)))) { return _fold(FWD(lv), FWD(rv)); } diff --git a/include/fn/expected.hpp b/include/fn/expected.hpp index ce5feb41..8597d69c 100644 --- a/include/fn/expected.hpp +++ b/include/fn/expected.hpp @@ -1051,13 +1051,61 @@ template class expected : private detail::_expected_ba return FWD(src).sum_error(); } +namespace detail { +template struct _expected_type { + template using type = ::fn::expected; +}; + +// `error()` throws when the expected holds a value, but every arm below is reached only once +// `has_value()` has answered - so these ask what constructing the result promises, with the accessor +// spelled as a type rather than as a call which would drag its own throw in. +template +constexpr inline bool _nothrow_join_expected + = _nothrow_initializable + && _nothrow_initializable().error())> + && _nothrow_initializable().error())>; + +// Lifting an operand's error into a widened error type. A sum<> operand can never hold an error, so +// its arm is unreachable - the joins below assert as much - and what cannot run cannot throw. +template +constexpr inline bool _nothrow_error_lift = _nothrow_initializable().error())>; +template + requires ::std::is_same_v::error_type, sum<>> +constexpr inline bool _nothrow_error_lift = true; + +template +constexpr inline bool _nothrow_join_widened = _nothrow_initializable + && (_nothrow_error_lift && _nothrow_error_lift) + && _nothrow_initializable; + +// A named type, not a lambda: `operator&` specifies itself in terms of what lifting the error +// promises, and a lambda can be named neither in a noexcept-specifier nor (before clang 17) in any +// unevaluated operand at all. +template struct _expected_efn final { + // Explicit return type: for a sum<> (never-erroring) operand the else branch is the only one + // instantiated, and without this it would deduce void - poisoning _join's return-type deduction. + [[nodiscard]] constexpr auto operator()(auto &&v) const noexcept(_nothrow_error_lift>) + -> ::fn::unexpected + { + if constexpr (not ::std::is_same_v::error_type, sum<>>) { + return ::fn::unexpected(FWD(v).error()); + } else { + ::pfn::unreachable(); // LCOV_EXCL_LINE + } + } +}; +} // namespace detail + // When any of the sides is expected, we do not produce expected, ...> // Instead just elide void and carry non-void (or elide both voids if that's what we get) template requires some_expected_void && (not some_expected_void) && ::std::is_same_v::error_type, typename ::std::remove_cvref_t::error_type> -[[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) noexcept +[[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) // + noexcept(detail::_nothrow_join_expected< + expected::value_type, typename ::std::remove_cvref_t::error_type>, + Lh, Rh, decltype(FWD(rh).value())>) { using error_type = ::std::remove_cvref_t::error_type; using value_type = ::std::remove_cvref_t::value_type; @@ -1076,7 +1124,13 @@ template typename ::std::remove_cvref_t::error_type>) && (some_sum::error_type> || some_sum::error_type>) -[[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) noexcept +[[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) // + noexcept(detail::_nothrow_join_widened< + expected::value_type, + sum_for::error_type, + typename ::std::remove_cvref_t::error_type>>, + sum_for::error_type, typename ::std::remove_cvref_t::error_type>, + Lh, Rh, decltype(FWD(rh).value())>) { using new_error_type = sum_for::error_type, typename ::std::remove_cvref_t::error_type>; @@ -1101,7 +1155,10 @@ template requires(not some_expected_void) && some_expected_void && ::std::is_same_v::error_type, typename ::std::remove_cvref_t::error_type> -[[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) noexcept +[[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) // + noexcept(detail::_nothrow_join_expected< + expected::value_type, typename ::std::remove_cvref_t::error_type>, + Lh, Rh, decltype(FWD(lh).value())>) { using error_type = ::std::remove_cvref_t::error_type; using value_type = ::std::remove_cvref_t::value_type; @@ -1120,7 +1177,13 @@ template typename ::std::remove_cvref_t::error_type>) && (some_sum::error_type> || some_sum::error_type>) -[[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) noexcept +[[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) // + noexcept(detail::_nothrow_join_widened< + expected::value_type, + sum_for::error_type, + typename ::std::remove_cvref_t::error_type>>, + sum_for::error_type, typename ::std::remove_cvref_t::error_type>, + Lh, Rh, decltype(FWD(lh).value())>) { using new_error_type = sum_for::error_type, typename ::std::remove_cvref_t::error_type>; @@ -1145,7 +1208,8 @@ template requires some_expected_void && some_expected_void && ::std::is_same_v::error_type, typename ::std::remove_cvref_t::error_type> -[[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) noexcept +[[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) // + noexcept(detail::_nothrow_join_expected::error_type>, Lh, Rh>) { using error_type = ::std::remove_cvref_t::error_type; using type = expected; @@ -1163,7 +1227,12 @@ template typename ::std::remove_cvref_t::error_type>) && (some_sum::error_type> || some_sum::error_type>) -[[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) noexcept +[[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) // + noexcept(detail::_nothrow_join_widened< + expected::error_type, + typename ::std::remove_cvref_t::error_type>>, + sum_for::error_type, typename ::std::remove_cvref_t::error_type>, + Lh, Rh>) { using new_error_type = sum_for::error_type, typename ::std::remove_cvref_t::error_type>; @@ -1185,21 +1254,18 @@ template // Overloads when both sides are non-void, producing either of // expected, ...> or expected, pack...>, ...> -namespace detail { -template struct _expected_type { - template using type = ::fn::expected; -}; -} // namespace detail - template requires(not some_expected_void) && (not some_expected_void) && ::std::is_same_v::error_type, typename ::std::remove_cvref_t::error_type> -[[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) noexcept +[[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) // + noexcept(noexcept(::fn::detail::_join< + detail::template _expected_type::error_type>::template type>( + FWD(lh), FWD(rh), detail::_expected_efn::error_type>{}))) { using error_type = ::std::remove_cvref_t::error_type; - constexpr auto efn = [](auto &&v) { return ::fn::unexpected(FWD(v).error()); }; - return ::fn::detail::_join::template type>(FWD(lh), FWD(rh), efn); + return ::fn::detail::_join::template type>( + FWD(lh), FWD(rh), detail::_expected_efn{}); } template @@ -1208,20 +1274,19 @@ template typename ::std::remove_cvref_t::error_type>) && (some_sum::error_type> || some_sum::error_type>) -[[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) noexcept +[[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) // + noexcept(noexcept( + ::fn::detail::_join< + detail::template _expected_type::error_type, + typename ::std::remove_cvref_t::error_type>>::template type>( + FWD(lh), FWD(rh), + detail::_expected_efn::error_type, + typename ::std::remove_cvref_t::error_type>>{}))) { using new_error_type = sum_for::error_type, typename ::std::remove_cvref_t::error_type>; - // Explicit return type: for a sum<> (never-erroring) operand the else branch is the only one - // instantiated, and without this it would deduce void — poisoning _join's return-type deduction. - constexpr auto efn = [](auto &&v) -> ::fn::unexpected { - if constexpr (not ::std::is_same_v::error_type, sum<>>) { - return ::fn::unexpected(FWD(v).error()); - } else { - ::pfn::unreachable(); // LCOV_EXCL_LINE - } - }; - return ::fn::detail::_join::template type>(FWD(lh), FWD(rh), efn); + return ::fn::detail::_join::template type>( + FWD(lh), FWD(rh), detail::_expected_efn{}); } } // namespace fn diff --git a/include/fn/optional.hpp b/include/fn/optional.hpp index c9980f91..3801458f 100644 --- a/include/fn/optional.hpp +++ b/include/fn/optional.hpp @@ -891,10 +891,19 @@ constexpr optional make_optional(::std::initializer_list il, Args &&...arg return FWD(src).sum_value(); } -template [[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) noexcept +namespace detail { +// A named type, not a lambda: `operator&`'s specification has to name it, and a lambda cannot be +// spelled in an unevaluated operand before C++20's P0315, which our floor compilers predate. +struct _optional_efn final { + [[nodiscard]] constexpr auto operator()(auto const &) const noexcept -> ::std::nullopt_t { return ::std::nullopt; } +}; +} // namespace detail + +template +[[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) // + noexcept(noexcept(::fn::detail::_join(FWD(lh), FWD(rh), detail::_optional_efn{}))) { - constexpr auto efn = [](auto const &) { return ::std::nullopt; }; - return ::fn::detail::_join(FWD(lh), FWD(rh), efn); + return ::fn::detail::_join(FWD(lh), FWD(rh), detail::_optional_efn{}); } } // namespace fn diff --git a/include/fn/pack.hpp b/include/fn/pack.hpp index 9deb48c8..115ede98 100644 --- a/include/fn/pack.hpp +++ b/include/fn/pack.hpp @@ -357,16 +357,34 @@ template namespace detail { +// `value()` throws when the monad holds no value, but the join only reaches it once `has_value()` +// has answered - so the specification below asks what folding and lifting the value promise, with +// the accessor spelled as a type rather than as a call which would drag its own throw in. +template using _value_of_t = decltype(::std::declval().value()); + +template +using _joined_t = decltype(::fn::detail::_fold_detail::fold::value_type, + typename ::std::remove_cvref_t::value_type>( + ::std::declval<_value_of_t>(), ::std::declval<_value_of_t>())); + +template