From 5839ab9003ff6edb661e777bef9214d6df1b0fc8 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 11 Jul 2026 22:29:33 +0100 Subject: [PATCH 1/2] Constrain the relocating verbs on what they actually relocate `value_or`, `recover`, `fail` and `filter` each return a FRESH monad, carrying the untouched side of the operand into it. Their concepts never asked whether that was possible, so an immovable value or error satisfied them and then hard-errored deep inside the body - the failure #250 was raised for, where a clean SFINAE drop was owed. The question is not `is_move_constructible_v`. The body relocates in whatever value category the operand is piped in - an lvalue is copied, an rvalue moved - so a move-only value piped as an lvalue must be REJECTED, and that trait would accept it. `_relocatable_value` / `_relocatable_error` / `_relocatable` ask what the bodies construct, which is also what their noexcept specs weigh: one asks "can it", the other "can it throw". The same lie had a second trigger. An `optional` binds its referent rather than carrying it, and its value type is the referent - so `is_constructible_v< value_type, Args...>` said yes to `optional | value_or(1)`, and the body then could not bind `int&` to the prvalue fallback. The fallback builds the RESULT, so that is what the concept now asks; an lvalue fallback still works, and an immovable referent stays pipeable, because nothing is relocated through a reference. Closes #250 Assisted-by: Claude:claude-opus-4-8[1m] --- include/fn/concepts.hpp | 20 ++++++++++++++++++++ include/fn/fail.hpp | 3 +++ include/fn/filter.hpp | 5 +++++ include/fn/recover.hpp | 11 +++++++++++ include/fn/value_or.hpp | 13 ++++++++++--- tests/fn/fail.cpp | 25 +++++++++++++++++++++++++ tests/fn/filter.cpp | 23 +++++++++++++++++++++++ tests/fn/recover.cpp | 36 ++++++++++++++++++++++++++++++++++++ tests/fn/value_or.cpp | 36 ++++++++++++++++++++++++++++++++++++ 9 files changed, 169 insertions(+), 3 deletions(-) diff --git a/include/fn/concepts.hpp b/include/fn/concepts.hpp index 7d797186..4cbbb941 100644 --- a/include/fn/concepts.hpp +++ b/include/fn/concepts.hpp @@ -17,6 +17,26 @@ namespace fn { +namespace detail { +// A verb that returns a fresh monad BY VALUE relocates what the source carries into it, in the value +// category the source is piped in - an lvalue is copied, an rvalue moved. So the question is not +// whether the carried type is movable: `is_move_constructible_v` would accept a move-only value +// piped as an lvalue, which the body then cannot copy. It is whether the result can be built from +// what the body actually reaches for, which is what these ask - and what the verbs' noexcept specs +// weigh, one asking "can it", the other "can it throw". +template +concept _relocatable_value // `type{::std::in_place, FWD(v).value()}` + = ::std::is_constructible_v<::std::remove_cvref_t, ::std::in_place_t, decltype(::std::declval().value())>; + +template +concept _relocatable_error // `type{::fn::unexpect, FWD(v).error()}` + = ::std::is_constructible_v<::std::remove_cvref_t, ::fn::unexpect_t, decltype(::std::declval().error())>; + +template +concept _relocatable // `return FWD(v);` - the whole monad, hence both sides + = ::std::is_constructible_v<::std::remove_cvref_t, V>; +} // namespace detail + /** * @brief TODO * @note `same_kind` is a fundamental concept in category theory; it allows diff --git a/include/fn/fail.hpp b/include/fn/fail.hpp index acf49d7d..948b6da8 100644 --- a/include/fn/fail.hpp +++ b/include/fn/fail.hpp @@ -6,6 +6,7 @@ #ifndef INCLUDE_FN_FAIL #define INCLUDE_FN_FAIL +#include #include #include @@ -27,8 +28,10 @@ concept invocable_fail // { ::fn::invoke(FWD(fn), FWD(v).value()) } -> ::std::convertible_to::error_type>; + requires detail::_relocatable_error; // the error branch carries the existing error over }) || (some_expected_void && requires(Fn &&fn) { { ::fn::invoke(FWD(fn)) } -> ::std::convertible_to::error_type>; + requires detail::_relocatable_error; }) || (some_optional && requires(Fn &&fn, V &&v) { { ::fn::invoke(FWD(fn), FWD(v).value()) } -> ::std::same_as; }); diff --git a/include/fn/filter.hpp b/include/fn/filter.hpp index ad4785de..bf4c725b 100644 --- a/include/fn/filter.hpp +++ b/include/fn/filter.hpp @@ -21,6 +21,8 @@ namespace fn { * @tparam Err The error handler * @tparam V The monadic type */ +// A rejected operand is returned whole (`return FWD(v)`), and a kept one is rebuilt around its +// existing value - so both sides must survive the trip. template concept invocable_filter // = (some_expected_non_void && requires(Pred &&pred, Err &&on_err, V &&v) { @@ -28,11 +30,14 @@ concept invocable_filter // { ::fn::invoke(FWD(on_err), FWD(v).value()) } -> ::std::convertible_to::error_type>; + requires detail::_relocatable && detail::_relocatable_value; }) || (some_expected_void && requires(Pred &&pred, Err &&on_err, V &&v) { { ::fn::invoke(FWD(pred)) } -> convertible_to_bool; { ::fn::invoke(FWD(on_err)) } -> ::std::convertible_to::error_type>; + requires detail::_relocatable; }) || (some_optional && ::std::same_as && requires(Pred &&pred, V &&v) { { ::fn::invoke(FWD(pred), ::std::as_const(v).value()) } -> convertible_to_bool; + requires detail::_relocatable && detail::_relocatable_value; }); /** diff --git a/include/fn/recover.hpp b/include/fn/recover.hpp index 6511fc20..6fb8c0c9 100644 --- a/include/fn/recover.hpp +++ b/include/fn/recover.hpp @@ -6,6 +6,7 @@ #ifndef INCLUDE_FN_RECOVER #define INCLUDE_FN_RECOVER +#include #include #include @@ -20,16 +21,26 @@ namespace fn { * @tparam Fn TODO * @tparam V TODO */ +// The recovered value builds the RESULT, not merely its value type: for an `optional` those +// differ - the value type is the referent, which a prvalue can construct, but the result binds a +// reference to it, which a prvalue cannot. The success branch carries the existing value over, so +// that must survive the trip too. template concept invocable_recover // = (some_expected_non_void && requires(Fn &&fn, V &&v) { { ::fn::invoke(FWD(fn), FWD(v).error()) } -> ::std::convertible_to::value_type>; + requires ::std::is_constructible_v<::std::remove_cvref_t, ::std::in_place_t, + decltype(::fn::invoke(FWD(fn), FWD(v).error()))>; + requires detail::_relocatable_value; }) || (some_expected_void && requires(Fn &&fn, V &&v) { { ::fn::invoke(FWD(fn), FWD(v).error()) } -> ::std::same_as; }) || (some_optional && requires(Fn &&fn, V &&v) { { ::fn::invoke(FWD(fn)) } -> ::std::convertible_to::value_type>; + requires ::std::is_constructible_v<::std::remove_cvref_t, ::std::in_place_t, + decltype(::fn::invoke(FWD(fn)))>; + requires detail::_relocatable_value; }); /** diff --git a/include/fn/value_or.hpp b/include/fn/value_or.hpp index d7985edf..1d2a9270 100644 --- a/include/fn/value_or.hpp +++ b/include/fn/value_or.hpp @@ -18,10 +18,17 @@ namespace fn { * @tparam V TODO * @tparam Args TODO */ +// The fallback builds the RESULT, not merely its value type: for an `optional` those differ - +// the value type is the referent, which a prvalue can construct, but the result binds a reference to +// it, which a prvalue cannot. And the existing value is carried over when there is one, so it must +// be able to survive that: an immovable value type would otherwise satisfy this and then fail inside +// the body. template -concept invocable_value_or // - = (some_expected_non_void && ::std::is_constructible_v::value_type, Args...>) - || (some_optional && ::std::is_constructible_v::value_type, Args...>); +concept invocable_value_or // + = (some_expected_non_void && ::std::is_constructible_v<::std::remove_cvref_t, ::std::in_place_t, Args...> // + && detail::_relocatable_value) + || (some_optional && ::std::is_constructible_v<::std::remove_cvref_t, ::std::in_place_t, Args...> + && detail::_relocatable_value); /** * @brief TODO diff --git a/tests/fn/fail.cpp b/tests/fn/fail.cpp index aec1ea8f..793d7292 100644 --- a/tests/fn/fail.cpp +++ b/tests/fn/fail.cpp @@ -5,6 +5,8 @@ #include "util/static_check.hpp" +#include + #include #include @@ -422,3 +424,26 @@ TEST_CASE("fail noexcept", "[fail][noexcept]") static_assert(not noexcept(std::declval() | fn::fail([](int) {}))); SUCCEED(); } + +TEST_CASE("fail constraints", "[fail][constraints]") +{ + using namespace fn; + + // The error branch carries the existing error over, so it must be able to + constexpr auto from_value = [](int) -> int { return 1; }; // converts to either helper below + using immovable_t = fn::expected; + static_assert(std::is_constructible_v); + static_assert(monadic_static_check::not_invocable_with_any(from_value)); + static_assert(monadic_static_check>::not_invocable_with_any( + []() -> int { return 1; })); + + // ... and a move-only error only where it can be moved out of + using is = monadic_static_check>; + static_assert(is::invocable(from_value)); // moved + static_assert(is::invocable(from_value)); // const-moved + static_assert(is::not_invocable(from_value)); // would have to copy + + // An optional discards its value and returns nullopt, relocating nothing + static_assert(monadic_static_check>::invocable_with_any([](auto const &) {})); + SUCCEED(); +} diff --git a/tests/fn/filter.cpp b/tests/fn/filter.cpp index 12735fb7..cb0efec2 100644 --- a/tests/fn/filter.cpp +++ b/tests/fn/filter.cpp @@ -5,6 +5,8 @@ #include "util/static_check.hpp" +#include + #include #include @@ -674,3 +676,24 @@ TEST_CASE("filter noexcept", "[filter][noexcept]") static_assert(not noexcept(std::declval() | fn::filter([](int) { return true; }))); SUCCEED(); } + +TEST_CASE("filter constraints", "[filter][constraints]") +{ + using namespace fn; + + // A rejected operand is returned whole and a kept one is rebuilt around its existing value, so + // BOTH sides must survive the trip - an immovable value or error is rejected either way + constexpr auto keep = [](auto const &) -> bool { return true; }; + constexpr auto on_err = [](auto const &) -> int { return 1; }; + static_assert( + monadic_static_check>::not_invocable_with_any(keep, on_err)); + static_assert(monadic_static_check>::not_invocable_with_any( + [](int) -> bool { return true; }, [](int) -> int { return 1; })); + static_assert(monadic_static_check>::not_invocable_with_any(keep)); + + // ... and a move-only one only where it can be moved out of + using is = monadic_static_check>; + static_assert(is::invocable(keep, on_err)); // moved + static_assert(is::not_invocable(keep, on_err)); // would have to copy + SUCCEED(); +} diff --git a/tests/fn/recover.cpp b/tests/fn/recover.cpp index 85385679..286353b9 100644 --- a/tests/fn/recover.cpp +++ b/tests/fn/recover.cpp @@ -5,6 +5,8 @@ #include "util/static_check.hpp" +#include + #include #include @@ -281,3 +283,37 @@ TEST_CASE("recover noexcept", "[recover][noexcept]") static_assert(not noexcept(std::declval() | fn::recover([](int) noexcept { return std::string{}; }))); SUCCEED(); } + +TEST_CASE("recover constraints", "[recover][constraints]") +{ + using namespace fn; + + // The success branch carries the existing value over, so it must be able to: an immovable value + // type must be dropped by the concept, not fail inside the body. + constexpr auto from_error = [](Error) -> int { return 1; }; // converts to either helper below + using immovable_t = fn::expected; + static_assert(std::is_constructible_v); + static_assert(monadic_static_check::not_invocable_with_any(from_error)); + + // A move-only value type is carried only where it can be moved out of - which is why the question + // is `is_constructible_v` and not `is_move_constructible_v` + using is = monadic_static_check>; + static_assert(is::invocable(from_error)); // moved + static_assert(is::invocable(from_error)); // const-moved + static_assert(is::not_invocable(from_error)); // would have to copy + + // A void-valued expected has no value to carry, so nothing constrains it + static_assert(monadic_static_check>::invocable_with_any([](Error) {})); + + // A reference optional binds its referent: the recovered value builds the RESULT, and a reference + // cannot bind to the prvalue a callback returns + using ref_t = fn::optional; + static_assert(std::is_constructible_v); + static_assert(not invocable_recover int { return 1; }) &, ref_t &>); + static_assert(invocable_recover int & { + static int i = 1; + return i; + }) &, + ref_t &>); + SUCCEED(); +} diff --git a/tests/fn/value_or.cpp b/tests/fn/value_or.cpp index 89974103..433c4a6b 100644 --- a/tests/fn/value_or.cpp +++ b/tests/fn/value_or.cpp @@ -3,6 +3,8 @@ // Distributed under the ISC License. See accompanying file LICENSE.md // or copy at https://opensource.org/licenses/ISC +#include "util/static_check.hpp" + #include #include @@ -13,6 +15,8 @@ #include #include +using namespace util; + namespace { struct Error final { std::string what; @@ -168,3 +172,35 @@ TEST_CASE("value_or noexcept", "[value_or][noexcept]") static_assert(not noexcept(std::declval() | fn::value_or("x"))); SUCCEED(); } + +TEST_CASE("value_or constraints", "[value_or][constraints]") +{ + using namespace fn; + + // The result carries the existing value over, so it must be able to. An immovable value type can + // still be built in place - which is not the question - but never carried: the candidate must + // drop, not fail inside the body. + using immovable_t = fn::expected; + static_assert(std::is_constructible_v); + static_assert(monadic_static_check::not_invocable_with_any(1)); + static_assert(monadic_static_check>::not_invocable_with_any(1)); + + // A move-only value type is carried only where it can be moved out of. This is what makes the + // question `is_constructible_v` and not `is_move_constructible_v` - the + // latter would accept every category below, including the two that would have to copy. + using move_only_t = fn::expected; + using is = monadic_static_check; + static_assert(is::invocable(1)); // moved + static_assert(is::invocable(1)); // const-moved + static_assert(is::not_invocable(1)); // would have to copy + + // A reference optional binds its referent rather than carrying it - so an immovable referent is + // fine, but the fallback builds the RESULT, and a reference cannot bind to a prvalue. The value + // type alone cannot answer this: it is the referent, which a prvalue constructs happily. + using ref_t = fn::optional; + static_assert(std::is_constructible_v); + static_assert(not invocable_value_or); // a temporary to bind to: rejected + static_assert(invocable_value_or); + static_assert(invocable_value_or &, helper_immovable &>); + SUCCEED(); +} From 19eaeaf932e52742ebe2bdbc56438b9ca52cdbd7 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Mon, 13 Jul 2026 17:02:16 +0100 Subject: [PATCH 2/2] Weigh the value `value_or` actually relocates The specification restated the `or_else` delegation with the whole-monad question - `is_nothrow_constructible_v` - which weighs the error's copy, though no branch of the body relocates the error: the value branch carries the value into the result and the error branch builds the fallback. So `value_or` reported `noexcept(false)` for a nothrow value beside a throwing-copy error. The conjunct now asks the value relocation the body performs, as `recover`, `fail` and `filter` already do. Assisted-by: Claude:claude-fable-5 --- include/fn/value_or.hpp | 6 ++++-- tests/fn/value_or.cpp | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/include/fn/value_or.hpp b/include/fn/value_or.hpp index 1d2a9270..71b74a1a 100644 --- a/include/fn/value_or.hpp +++ b/include/fn/value_or.hpp @@ -65,8 +65,10 @@ struct value_or_t::apply final { // callable or_else receives is a lambda, which cannot be named in this specification. template [[nodiscard]] constexpr auto operator()(V &&v, Args &&...args) const // - noexcept(::std::is_nothrow_constructible_v<::std::remove_cvref_t, ::std::in_place_t, Args...> - && ::std::is_nothrow_constructible_v<::std::remove_cvref_t, V>) -> ::std::remove_cvref_t + noexcept( + ::std::is_nothrow_constructible_v<::std::remove_cvref_t, ::std::in_place_t, Args...> + && ::std::is_nothrow_constructible_v<::std::remove_cvref_t, ::std::in_place_t, decltype(FWD(v).value())>) + -> ::std::remove_cvref_t requires invocable_value_or { using type = ::std::remove_cvref_t; diff --git a/tests/fn/value_or.cpp b/tests/fn/value_or.cpp index 433c4a6b..98c7afcd 100644 --- a/tests/fn/value_or.cpp +++ b/tests/fn/value_or.cpp @@ -170,6 +170,20 @@ TEST_CASE("value_or noexcept", "[value_or][noexcept]") // building the fallback value can throw using S = fn::optional; static_assert(not noexcept(std::declval() | fn::value_or("x"))); + + // the untouched error is never relocated, so its throwing copy does not weigh + using X = fn::expected; + static_assert(noexcept(std::declval() | fn::value_or(1))); + + // the carried value is relocated in the operand's category, and that weighs + struct MoveNothrow { + MoveNothrow(int) noexcept {} + MoveNothrow(MoveNothrow const &) noexcept(false) {} + MoveNothrow(MoveNothrow &&) noexcept {} + }; + using W = fn::expected; + static_assert(not noexcept(std::declval() | fn::value_or(1))); // copies + static_assert(noexcept(std::declval() | fn::value_or(1))); // moves SUCCEED(); }