From bd881b5aaf83081260d520456a966210d67fcef4 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 25 Jul 2026 16:01:51 +0100 Subject: [PATCH 1/8] Add the expected_unit type alias Assisted-by: Claude:claude-opus-5 --- include/fn/expected.hpp | 9 +++++++++ tests/fn/expected.cpp | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/include/fn/expected.hpp b/include/fn/expected.hpp index de9990ab..d01150f3 100644 --- a/include/fn/expected.hpp +++ b/include/fn/expected.hpp @@ -1865,6 +1865,15 @@ template class expected : private detail::_expected_ba { } }; + +/** + * @brief The unit of the `expected` family: a carrier over `void` whose error side is uninhabited + * + * It always holds its empty value - `copack<>` offers no alternative to fail with - so it belongs to + * the identity cluster, and `operator&` elides it from a product. + */ +using expected_unit = expected>; + // Lifts for copack transformation functions [[nodiscard]] constexpr auto copack_value(some_expected_non_void auto &&src) noexcept(noexcept(FWD(src).copack_value())) -> decltype(auto) diff --git a/tests/fn/expected.cpp b/tests/fn/expected.cpp index b3f65020..562582af 100644 --- a/tests/fn/expected.cpp +++ b/tests/fn/expected.cpp @@ -4,6 +4,7 @@ // or copy at https://opensource.org/licenses/ISC #include +#include #include #include #include @@ -4181,7 +4182,9 @@ TEST_CASE("expected with empty copack side", "[expected][copack]") SECTION("empty copack error, void value") { - using E = fn::expected; + using E = fn::expected_unit; + static_assert(std::is_same_v>); + static_assert(fn::some_identity); E e{}; CHECK(e.transform_error(poison).has_value()); CHECK(e.or_else(poison).has_value()); From ddee80b888546d864610f1f0ead318de57bf6670 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 25 Jul 2026 17:00:26 +0100 Subject: [PATCH 2/8] Admit an uninhabited value side in the value-side verbs The verbs now reach as far as the members do over an optional> or expected, E>: the operation is the identity and the callback is never instantiated. The operand class is named some_empty_value, beside its mirror some_empty_error, which some_identity now spells. Assisted-by: Claude:claude-opus-5 --- include/fn/and_then.hpp | 11 +++++++ include/fn/concepts.hpp | 28 +++++++++++++++-- include/fn/fail.hpp | 12 +++++++ include/fn/filter.hpp | 19 ++++++++++++ include/fn/inspect.hpp | 10 ++++++ include/fn/monadic.hpp | 20 ++++++++---- include/fn/transform.hpp | 11 +++++++ tests/fn/and_then.cpp | 66 +++++++++++++++++++++++++++++++++++++++ tests/fn/concepts.cpp | 39 +++++++++++++++++++++++ tests/fn/fail.cpp | 51 ++++++++++++++++++++++++++++++ tests/fn/filter.cpp | 51 ++++++++++++++++++++++++++++++ tests/fn/inspect.cpp | 45 +++++++++++++++++++++++++++ tests/fn/transform.cpp | 67 ++++++++++++++++++++++++++++++++++++++++ 13 files changed, 422 insertions(+), 8 deletions(-) diff --git a/include/fn/and_then.hpp b/include/fn/and_then.hpp index 2627f2f6..ae3e73d2 100644 --- a/include/fn/and_then.hpp +++ b/include/fn/and_then.hpp @@ -192,6 +192,17 @@ struct and_then_t::apply final { return FWD(v).and_then(FWD(fn)); } + // An uninhabited value side leaves no value to bind - delegate to the member, which is the + // identity there and neither invokes nor instantiates the callback, as in transform. + template + [[nodiscard]] constexpr auto operator()(V &&v, Fn &&fn) const // + noexcept(noexcept(FWD(v).and_then(FWD(fn)))) // + -> same_kind auto + requires some_empty_value + { + return FWD(v).and_then(FWD(fn)); + } + // The cluster arms: an identity input binds across the carrier kinds, and the bind follows the // function. Engine-direct - the members are each carrier's own endo-bind, so the cluster cannot // ride delegation, and the verb layer is the licensed cross-carrier place. Dropping the input's diff --git a/include/fn/concepts.hpp b/include/fn/concepts.hpp index 59332083..64d9b29b 100644 --- a/include/fn/concepts.hpp +++ b/include/fn/concepts.hpp @@ -156,6 +156,31 @@ template concept convertible_to_choice = (not ::std::is_void_v) && requires { static_cast>>(::std::declval()); }; +/** + * @brief Checks if a carrier's error side is uninhabited - it can never hold an error + * + * The identity `expected`, and only it: `choice` and `just` have no error channel to empty, so they + * answer false here while `some_identity` below accepts all three. + * + * @tparam T Type to check, possibly cv-ref qualified + */ +template +concept some_empty_error = some_expected && empty_copack::error_type>; + +/** + * @brief Checks if a carrier's value side is uninhabited - it can never hold a value + * + * An `optional>`, which is never engaged, or an `expected, E>`, which always holds + * its error: every value-side operation over one is the identity, and its callback is neither + * invoked nor instantiated. `optional` belongs here where it cannot belong to the mirror above - its + * empty state is a state that is, not a channel that can never engage. + * + * @tparam T Type to check, possibly cv-ref qualified + */ +template +concept some_empty_value + = (some_expected || some_optional) && empty_copack::value_type>; + /** * @brief Checks if a type is an identity carrier - a monad which never short-circuits * @@ -167,8 +192,7 @@ concept convertible_to_choice * @tparam T Type to check, possibly cv-ref qualified */ template -concept some_identity = some_choice || some_just - || (some_expected && empty_copack::error_type>); +concept some_identity = some_choice || some_just || some_empty_error; /** * @brief TODO diff --git a/include/fn/fail.hpp b/include/fn/fail.hpp index db745b27..7908eb2b 100644 --- a/include/fn/fail.hpp +++ b/include/fn/fail.hpp @@ -126,6 +126,18 @@ struct fail_t::apply final { } return type{::std::nullopt}; } + + // An uninhabited value side never holds a value to fail on, so the failure can never fire: the + // operand passes through, and the callback is neither invoked nor instantiated. Unlike an identity + // carrier, which is refused above, such an operand does have somewhere to fail into - it is + // already there. + template + [[nodiscard]] constexpr auto operator()(V &&v, Fn &&) const + noexcept(::std::is_nothrow_constructible_v<::std::remove_cvref_t, V>) -> ::std::remove_cvref_t + requires some_empty_value && detail::_relocatable + { + return FWD(v); + } }; } // namespace LIBFN_VERSION diff --git a/include/fn/filter.hpp b/include/fn/filter.hpp index d8fbf33e..02ba6337 100644 --- a/include/fn/filter.hpp +++ b/include/fn/filter.hpp @@ -164,6 +164,25 @@ struct filter_t::apply final { } return FWD(v); } + + // An uninhabited value side never holds a value to test, so nothing can be rejected: the operand + // passes through, and neither callback is invoked or instantiated. Unlike an identity carrier, + // which is refused above, such an operand does have somewhere to fail into - it is already there. + template + [[nodiscard]] constexpr auto operator()(V &&v, Pred &&, OnErr &&) const + noexcept(::std::is_nothrow_constructible_v<::std::remove_cvref_t, V>) -> ::std::remove_cvref_t + requires some_empty_value && detail::_relocatable + { + return FWD(v); + } + + template + [[nodiscard]] constexpr auto operator()(V &&v, Pred &&) const + noexcept(::std::is_nothrow_constructible_v<::std::remove_cvref_t, V>) -> ::std::remove_cvref_t + requires some_empty_value && detail::_relocatable + { + return FWD(v); + } }; } // namespace LIBFN_VERSION diff --git a/include/fn/inspect.hpp b/include/fn/inspect.hpp index d0d7bda4..eca9b624 100644 --- a/include/fn/inspect.hpp +++ b/include/fn/inspect.hpp @@ -6,6 +6,7 @@ #ifndef INCLUDE_FN_INSPECT #define INCLUDE_FN_INSPECT +#include #include #include #include @@ -163,6 +164,15 @@ struct inspect_t::apply final { ::fn::apply(FWD(fn)); // side-effects only return FWD(v); } + + // An uninhabited value side never holds a value, so there is nothing to observe: the operand + // passes through, and the callback is neither invoked nor instantiated. + template + [[nodiscard]] constexpr auto operator()(V &&v, Fn &&) const noexcept -> V && + requires some_empty_value + { + return FWD(v); + } }; } // namespace LIBFN_VERSION diff --git a/include/fn/monadic.hpp b/include/fn/monadic.hpp index 5796d867..7d689c46 100644 --- a/include/fn/monadic.hpp +++ b/include/fn/monadic.hpp @@ -13,19 +13,27 @@ namespace fn { inline namespace LIBFN_VERSION { /** - * @brief TODO + * @brief Checks if a type is one of the library's carriers - what a pipeline flows through * - * @tparam T TODO + * The four kinds a verb may be applied to: `expected`, `optional`, `choice` and `just`. A `pack` or + * a `copack` is data a carrier holds, not a carrier, and answers false. + * + * @tparam T Type to check, possibly cv-ref qualified */ template concept some_monadic_type = detail::_some_monadic_type; /** - * @brief TODO + * @brief Checks if a verb applies to a carrier - the constraint `operator|` itself carries + * + * The question asked of the verb's own `apply` object, so every arm it offers counts. This is what + * a pipeline asks, and it differs from the `applicable_` concept of an individual verb, which asks + * whether the callback is used to serve the operand: an operation over an uninhabited side consults + * no callback at all, and so applies while no callback is applicable to it. * - * @tparam Functor TODO - * @tparam V TODO - * @tparam Args TODO + * @tparam Functor The verb, such as `fn::transform_t` + * @tparam V The carrier, possibly cv-ref qualified + * @tparam Args The verb's arguments as its functor holds them, typically the callback */ template concept monadic_invocable = detail::_monadic_invocable; diff --git a/include/fn/transform.hpp b/include/fn/transform.hpp index 0d388399..971042c5 100644 --- a/include/fn/transform.hpp +++ b/include/fn/transform.hpp @@ -131,6 +131,17 @@ struct transform_t::apply final { return FWD(v).transform(FWD(fn)); } + // An uninhabited value side leaves no value to map - delegate to the member, which is the + // identity there and neither invokes nor instantiates the callback. The verb must reach wherever + // the member does, so this arm mirrors transform_error's over an uninhabited error side. + template + [[nodiscard]] constexpr auto operator()(V &&v, Fn &&fn) const noexcept(noexcept(FWD(v).transform(FWD(fn)))) + -> same_kind auto + requires some_empty_value + { + return FWD(v).transform(FWD(fn)); + } + // The promotion arms: a copack result over a just operand becomes the choice over the same // alternatives - verb-level only, along the canonical isomorphism the member's mandate names template diff --git a/tests/fn/and_then.cpp b/tests/fn/and_then.cpp index eae4ec2c..48964574 100644 --- a/tests/fn/and_then.cpp +++ b/tests/fn/and_then.cpp @@ -31,6 +31,12 @@ constexpr char const got_84_and_half[] = "Got 84 and 0.5"; constexpr char const got_84_and_half[] = "Got 84 and 0.500000"; #endif +// Instantiating this callable for any argument is a dependent hard error: a verb that compiles +// while receiving it provably never instantiates its callback. +struct Poison final { + template constexpr void operator()(T &&) const { static_assert(sizeof(T) == 0); } +}; + struct Xint final { int value; @@ -1604,6 +1610,58 @@ TEST_CASE("and_then tuple-like payload", "[and_then][expected][optional][tuple]" } } +TEST_CASE("and_then over an uninhabited value side", "[and_then][expected][optional][copack]") +{ + // The mirror of transform's case: a copack<> value can never be constructed, so there is nothing + // to bind - the callback is neither invoked nor instantiated, and the verb delegates to the + // member, which is the identity there. applicable_and_then keeps answering false. + using E = fn::expected, int>; + using O = fn::optional>; + constexpr Poison poison{}; + + SECTION("expected: always the error, carried through unchanged") + { + E e{fn::unexpect, 7}; + auto r = e | fn::and_then(poison); + static_assert(std::is_same_v); + CHECK(r.error() == 7); + CHECK((std::as_const(e) | fn::and_then(poison)).error() == 7); + CHECK((std::move(e) | fn::and_then(poison)).error() == 7); + } + + SECTION("optional: never engaged") + { + O o{}; + auto r = o | fn::and_then(poison); + static_assert(std::is_same_v); + CHECK(not r.has_value()); + CHECK(not(std::as_const(o) | fn::and_then(poison)).has_value()); + CHECK(not(std::move(o) | fn::and_then(poison)).has_value()); + } + + SECTION("noexcept weighs the pass-through, the only relocation left") + { + struct Throwing final { + Throwing() = default; + Throwing(Throwing const &) {} // and hence no move constructor either + }; + static_assert(noexcept(std::declval() | fn::and_then(poison))); + static_assert(noexcept(std::declval() | fn::and_then(poison))); + static_assert(not noexcept(std::declval, Throwing> &&>() | fn::and_then(poison))); + SUCCEED(); + } + + SECTION("constexpr") + { + // named source: VS 2022 misreads a mid-expression prvalue's empty-class union member + constexpr E ce{fn::unexpect, 7}; + constexpr O co{}; + static_assert((ce | fn::and_then(Poison{})).error() == 7); + static_assert(not(co | fn::and_then(Poison{})).has_value()); + SUCCEED(); + } +} + namespace fn { namespace { struct Error {}; @@ -1659,5 +1717,13 @@ static_assert(not applicable_and_then>) static_assert(not applicable_and_then>), optional>); // mixed choice and optional static_assert(not applicable_and_then>), choice>); // cannot bind temporary to lvalue static_assert(applicable_and_then>), choice &>); + +// An uninhabited value side has nothing to bind, so the concept answers false where the inhabited rows +// above answer true: applicability is a property of the callback, and a vacuous bind consults none. +// The verb still applies - its own arm admits the operand - which is what monadic_invocable answers. +static_assert(not applicable_and_then>), expected, Error>>); +static_assert(not applicable_and_then>), optional>>); +static_assert(monadic_invocable, Error>, decltype(fn_generic>)>); +static_assert(monadic_invocable>, decltype(fn_generic>)>); // clang-format on } // namespace fn diff --git a/tests/fn/concepts.cpp b/tests/fn/concepts.cpp index bd025cdf..aa91ec6f 100644 --- a/tests/fn/concepts.cpp +++ b/tests/fn/concepts.cpp @@ -380,6 +380,45 @@ static_assert(not fn::some_identity>); static_assert(not fn::some_identity>>); static_assert(not fn::some_identity>); static_assert(not fn::some_identity); + +// the two uninhabited sides, named apart: the error one is the identity expected and only it, while +// optional joins expected on the value one - its empty state is a state that is +static_assert(fn::some_empty_error>>); +static_assert(fn::some_empty_error); +static_assert(fn::some_empty_error> const &>); +static_assert(not fn::some_empty_error>>); +static_assert(not fn::some_empty_error>); +static_assert(not fn::some_empty_error>); +static_assert(not fn::some_empty_error>); + +static_assert(fn::some_empty_value, int>>); +static_assert(fn::some_empty_value>>); +static_assert(fn::some_empty_value> &&>); +static_assert(not fn::some_empty_value>>); +static_assert(not fn::some_empty_value>); +static_assert(not fn::some_empty_value>); +static_assert(not fn::some_empty_value>); + +// a carrier may be both, and each answer is independent of the other side +static_assert(fn::some_empty_value, fn::copack<>>>); +static_assert(fn::some_empty_error, fn::copack<>>>); + +// Both are well-formed for anything, answering false: the carrier test short-circuits before the +// value or error type is ever named, so nothing is substituted where it does not exist - and the +// answers survive negation, which an equivalent conjunction spelled inline would not. +struct Incomplete; +template +concept not_empty_value = not fn::some_empty_value; +template +concept not_empty_error = not fn::some_empty_error; +static_assert(not_empty_value && not_empty_error); +static_assert(not_empty_value && not_empty_error); +static_assert(not_empty_value && not_empty_error); +static_assert(not_empty_value::*> && not_empty_error::*>); +static_assert(not_empty_value && not_empty_error); +static_assert(not_empty_value> && not_empty_error>); +static_assert(not not_empty_value>>); // the converse: negation is not vacuous +static_assert(not not_empty_error); static_assert(fn::same_kind, fn::just>); static_assert(not fn::same_kind, fn::choice>); diff --git a/tests/fn/fail.cpp b/tests/fn/fail.cpp index 5974142b..1b02b5da 100644 --- a/tests/fn/fail.cpp +++ b/tests/fn/fail.cpp @@ -33,6 +33,12 @@ int Value::count = 0; struct Derived : Error {}; +// Instantiating this callable for any argument is a dependent hard error: a verb that compiles +// while receiving it provably never instantiates its callback. +struct Poison final { + template constexpr void operator()(T &&) const { static_assert(sizeof(T) == 0); } +}; + // C++26 to_string formats through std::format (P2587): to_string(0.5) is "0.5", not "0.500000" #if defined(__cpp_lib_to_string) && __cpp_lib_to_string >= 202306L constexpr char const got_84_and_half[] = "Got 84 and 0.5"; @@ -420,6 +426,51 @@ TEST_CASE("fail noexcept", "[fail][noexcept]") SUCCEED(); } +TEST_CASE("fail over an uninhabited value side", "[fail][expected][optional][copack]") +{ + // There is no value to fail on, so the failure can never fire: the operand passes through and the + // callback is neither invoked nor instantiated. The identity cluster is refused instead - it has + // no error to fail into - while such an operand is in the error state already. + constexpr Poison poison{}; + using E = fn::expected, int>; + using O = fn::optional>; + static_assert(monadic_static_check::invocable_with_any(poison)); + static_assert(monadic_static_check::invocable_with_any(poison)); + static_assert(monadic_static_check>>::not_invocable_with_any(poison)); + + E e{fn::unexpect, 7}; + auto r = e | fn::fail(poison); + static_assert(std::is_same_v); + CHECK(r.error() == 7); + CHECK((std::move(e) | fn::fail(poison)).error() == 7); + O o{}; + auto r2 = o | fn::fail(poison); + static_assert(std::is_same_v); + CHECK(not r2.has_value()); + + SECTION("noexcept weighs the pass-through, the only relocation left") + { + struct Throwing final { + Throwing() = default; + Throwing(Throwing const &) {} // and hence no move constructor either + }; + static_assert(noexcept(std::declval() | fn::fail(poison))); + static_assert(noexcept(std::declval() | fn::fail(poison))); + static_assert(not noexcept(std::declval, Throwing> &&>() | fn::fail(poison))); + SUCCEED(); + } + + SECTION("constexpr") + { + // named source: VS 2022 misreads a mid-expression prvalue's empty-class union member + constexpr E ce{fn::unexpect, 7}; + constexpr O co{}; + static_assert((ce | fn::fail(Poison{})).error() == 7); + static_assert(not(co | fn::fail(Poison{})).has_value()); + SUCCEED(); + } +} + namespace fn { namespace { struct Error {}; diff --git a/tests/fn/filter.cpp b/tests/fn/filter.cpp index 9c814bf9..3a354d3c 100644 --- a/tests/fn/filter.cpp +++ b/tests/fn/filter.cpp @@ -31,6 +31,12 @@ struct Value final { Error error() const { return {"Got " + std::to_string(v)}; } Error error_() { return {"Got " + std::to_string(v)}; } }; + +// Instantiating this callable for any argument is a dependent hard error: a verb that compiles +// while receiving it provably never instantiates its callback. +struct Poison final { + template constexpr void operator()(T &&) const { static_assert(sizeof(T) == 0); } +}; } // namespace TEST_CASE("filter", "[filter][expected][expected_value]") @@ -712,6 +718,51 @@ TEST_CASE("filter identity expected", "[filter][expected][copack]") SUCCEED(); } +TEST_CASE("filter over an uninhabited value side", "[filter][expected][optional][copack]") +{ + // The mirror of the case above, and it goes the other way: there is no value to test, so nothing + // is rejected and the operand passes through, neither callback invoked nor instantiated. An + // identity carrier is refused because a rejection has nowhere to go; here it has - the operand is + // in the error state already, which the type system knows. + constexpr Poison poison{}; + using E = fn::expected, int>; + using O = fn::optional>; + static_assert(monadic_static_check::invocable_with_any(poison, poison)); + static_assert(monadic_static_check::invocable_with_any(poison)); + + E e{fn::unexpect, 7}; + auto r = e | fn::filter(poison, poison); + static_assert(std::is_same_v); + CHECK(r.error() == 7); + CHECK((std::move(e) | fn::filter(poison, poison)).error() == 7); + O o{}; + auto r2 = o | fn::filter(poison); + static_assert(std::is_same_v); + CHECK(not r2.has_value()); + + SECTION("noexcept weighs the pass-through, the only relocation left") + { + struct Throwing final { + Throwing() = default; + Throwing(Throwing const &) {} // and hence no move constructor either + }; + static_assert(noexcept(std::declval() | fn::filter(poison, poison))); + static_assert(noexcept(std::declval() | fn::filter(poison))); + static_assert(not noexcept(std::declval, Throwing> &&>() | fn::filter(poison, poison))); + SUCCEED(); + } + + SECTION("constexpr") + { + // named source: VS 2022 misreads a mid-expression prvalue's empty-class union member + constexpr E ce{fn::unexpect, 7}; + constexpr O co{}; + static_assert((ce | fn::filter(Poison{}, Poison{})).error() == 7); + static_assert(not(co | fn::filter(Poison{})).has_value()); + SUCCEED(); + } +} + namespace fn { namespace { struct Error {}; diff --git a/tests/fn/inspect.cpp b/tests/fn/inspect.cpp index 1f7a11d3..cf09b9ef 100644 --- a/tests/fn/inspect.cpp +++ b/tests/fn/inspect.cpp @@ -33,6 +33,12 @@ struct Value final { }; int Value::count = 0; + +// Instantiating this callable for any argument is a dependent hard error: a verb that compiles +// while receiving it provably never instantiates its callback. +struct Poison final { + template constexpr void operator()(T &&) const { static_assert(sizeof(T) == 0); } +}; } // namespace TEST_CASE("inspect expected", "[inspect][expected][expected_value][pack]") @@ -554,6 +560,45 @@ TEST_CASE("inspect noexcept", "[inspect][noexcept]") SUCCEED(); } +TEST_CASE("inspect over an uninhabited value side", "[inspect][expected][optional][copack]") +{ + // Nothing can be observed, because nothing can ever be there: the operand passes through and the + // callback is neither invoked nor instantiated, in every value category. + constexpr Poison poison{}; + using E = fn::expected, int>; + using O = fn::optional>; + static_assert(monadic_static_check::invocable_with_any(poison)); + static_assert(monadic_static_check::invocable_with_any(poison)); + + E e{fn::unexpect, 7}; + static_assert(std::is_same_v); + CHECK((e | fn::inspect(poison)).error() == 7); + O o{}; + static_assert(std::is_same_v); + CHECK(not(o | fn::inspect(poison)).has_value()); + + SECTION("the operand is returned by reference, so the specification is unconditional") + { + struct Throwing final { + Throwing() = default; + Throwing(Throwing const &) {} // and hence no move constructor either + }; + static_assert(noexcept(std::declval() | fn::inspect(poison))); + static_assert(noexcept(std::declval, Throwing> &&>() | fn::inspect(poison))); + SUCCEED(); + } + + SECTION("constexpr") + { + // named source: VS 2022 misreads a mid-expression prvalue's empty-class union member + constexpr E ce{fn::unexpect, 7}; + constexpr O co{}; + static_assert((ce | fn::inspect(Poison{})).error() == 7); + static_assert(not(co | fn::inspect(Poison{})).has_value()); + SUCCEED(); + } +} + namespace fn { namespace { struct Error {}; diff --git a/tests/fn/transform.cpp b/tests/fn/transform.cpp index a01041e3..970d52cf 100644 --- a/tests/fn/transform.cpp +++ b/tests/fn/transform.cpp @@ -28,6 +28,12 @@ struct Xint final { auto fn() const & -> int { return value + 1; } }; + +// Instantiating this callable for any argument is a dependent hard error: a verb that compiles +// while receiving it provably never instantiates its callback. +struct Poison final { + template constexpr void operator()(T &&) const { static_assert(sizeof(T) == 0); } +}; } // namespace TEST_CASE("transform", "[transform][expected][expected_value][pack]") @@ -771,6 +777,59 @@ TEST_CASE("transform tuple-like payload", "[transform][expected][optional][tuple } } +TEST_CASE("transform over an uninhabited value side", "[transform][expected][optional][copack]") +{ + // A copack<> value can never be constructed, so the callback can never be presented one: the + // mapping is the identity and the callback is neither invoked nor instantiated. The verb reaches + // as far as the member does - its own arm admits the operand, while applicable_transform keeps + // answering false (the static_asserts at the end of the file). + using E = fn::expected, int>; + using O = fn::optional>; + constexpr Poison poison{}; + + SECTION("expected: always the error, carried through unchanged") + { + E e{fn::unexpect, 7}; + auto r = e | fn::transform(poison); + static_assert(std::is_same_v); + CHECK(r.error() == 7); + CHECK((std::as_const(e) | fn::transform(poison)).error() == 7); + CHECK((std::move(e) | fn::transform(poison)).error() == 7); + } + + SECTION("optional: never engaged") + { + O o{}; + auto r = o | fn::transform(poison); + static_assert(std::is_same_v); + CHECK(not r.has_value()); + CHECK(not(std::as_const(o) | fn::transform(poison)).has_value()); + CHECK(not(std::move(o) | fn::transform(poison)).has_value()); + } + + SECTION("noexcept weighs the pass-through, the only relocation left") + { + struct Throwing final { + Throwing() = default; + Throwing(Throwing const &) {} // and hence no move constructor either + }; + static_assert(noexcept(std::declval() | fn::transform(poison))); + static_assert(noexcept(std::declval() | fn::transform(poison))); + static_assert(not noexcept(std::declval, Throwing> &&>() | fn::transform(poison))); + SUCCEED(); + } + + SECTION("constexpr") + { + // named source: VS 2022 misreads a mid-expression prvalue's empty-class union member + constexpr E ce{fn::unexpect, 7}; + constexpr O co{}; + static_assert((ce | fn::transform(Poison{})).error() == 7); + static_assert(not(co | fn::transform(Poison{})).has_value()); + SUCCEED(); + } +} + namespace fn { namespace { struct Error {}; @@ -811,5 +870,13 @@ static_assert(applicable_transform), optional), optional>>); static_assert(not applicable_transform), expected, Error>>); // a void result has no place in a copack static_assert(not applicable_transform), optional>>); + +// An uninhabited value side has no alternative to dispatch, so the concept answers false where the rows +// above answer true: applicability is a property of the callback, and a vacuous mapping consults none. +// The verb still applies - its own arm admits the operand - which is what monadic_invocable answers. +static_assert(not applicable_transform), expected, Error>>); +static_assert(not applicable_transform), optional>>); +static_assert(monadic_invocable, Error>, decltype(fn_generic)>); +static_assert(monadic_invocable>, decltype(fn_generic)>); // clang-format on } // namespace fn From d4491246dc74421393c32304a34ff0cda9c1e9fe Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 25 Jul 2026 17:13:19 +0100 Subject: [PATCH 3/8] Admit a void value side in value_or Assisted-by: Claude:claude-opus-5 --- include/fn/value_or.hpp | 10 +++++----- tests/fn/value_or.cpp | 44 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/include/fn/value_or.hpp b/include/fn/value_or.hpp index fc92fd43..4a055ed8 100644 --- a/include/fn/value_or.hpp +++ b/include/fn/value_or.hpp @@ -26,11 +26,13 @@ inline namespace LIBFN_VERSION { // 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. +// the body. A void value is the degenerate case of both: `value_or()` substitutes the empty value, +// so the arguments must be none at all, and there is nothing to carry over. template concept applicable_value_or // = (some_expected_non_void && ::std::is_constructible_v<::std::remove_cvref_t, ::std::in_place_t, Args...> // && detail::_relocatable_value) + || (some_expected_void && ::std::is_constructible_v<::std::remove_cvref_t, ::std::in_place_t, Args...>) || (some_optional && ::std::is_constructible_v<::std::remove_cvref_t, ::std::in_place_t, Args...> && detail::_relocatable_value); @@ -71,10 +73,8 @@ struct value_or_t::apply final { // constrained yet dead, the delegated member or_else being vacuous. 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, ::std::in_place_t, decltype(FWD(v).value())>) - -> ::std::remove_cvref_t + noexcept(::std::is_nothrow_constructible_v<::std::remove_cvref_t, ::std::in_place_t, Args...> + && detail::_nothrow_carry_value<::std::remove_cvref_t, V>) -> ::std::remove_cvref_t requires(not some_choice) && (not some_just) && applicable_value_or { using type = ::std::remove_cvref_t; diff --git a/tests/fn/value_or.cpp b/tests/fn/value_or.cpp index 3ce5a7c3..0b70a88d 100644 --- a/tests/fn/value_or.cpp +++ b/tests/fn/value_or.cpp @@ -249,6 +249,46 @@ TEST_CASE("value_or identity expected", "[value_or][expected][copack]") static_assert((operand_t{5} | fn::value_or(9)).value() == 5); } +TEST_CASE("value_or void expected", "[value_or][expected][expected_void]") +{ + // The empty value is substituted by naming no arguments at all - there is nothing to build it + // from - so the failed operand comes back engaged and any argument makes the call non-viable. + using operand_t = fn::expected; + static_assert(monadic_static_check::invocable_with_any()); + static_assert(monadic_static_check::not_invocable_with_any(9)); + + operand_t a{fn::unexpect, 7}; + auto r1 = a | fn::value_or(); + static_assert(std::is_same_v); + CHECK(r1.has_value()); + operand_t b{}; + CHECK((b | fn::value_or()).has_value()); + + // the unit keeps it too, its fallback constrained yet dead: the delegated member or_else is vacuous + static_assert(monadic_static_check::invocable_with_any()); + fn::expected_unit u{}; + CHECK((u | fn::value_or()).has_value()); + + SECTION("noexcept: no value to carry, and the error is discarded rather than relocated") + { + static_assert(noexcept(std::declval() | fn::value_or())); + static_assert(noexcept(std::declval() | fn::value_or())); + static_assert(not std::is_nothrow_copy_constructible_v); + static_assert(noexcept(std::declval &>() | fn::value_or())); + SUCCEED(); + } + + SECTION("constexpr") + { + // named source: VS 2022 misreads a mid-expression prvalue's empty-class union member + constexpr operand_t ce{fn::unexpect, 7}; + constexpr fn::expected_unit cu{}; + static_assert((ce | fn::value_or()).has_value()); + static_assert((cu | fn::value_or()).has_value()); + SUCCEED(); + } +} + namespace fn { namespace { struct Error {}; @@ -263,7 +303,9 @@ static_assert(applicable_value_or, int, int>); static_assert(not applicable_value_or, char const *>); // no conversion found static_assert(not applicable_value_or, int, int>); // too many initialisers static_assert(not applicable_value_or, int>); // wrong type -static_assert(not applicable_value_or, int>); // void has no value to fall back to +static_assert(not applicable_value_or, int>); // void: an argument has nothing to build +static_assert(applicable_value_or>); // ... and the empty value needs none +static_assert(applicable_value_or); // the unit keeps it, its fallback dead static_assert(applicable_value_or, int>); static_assert(not applicable_value_or, char const *>); static_assert(not applicable_value_or, int>); // no choice disjunct From 168c6c9f132c4205cccd7ea1e2a577d5e33da8b8 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 25 Jul 2026 17:19:36 +0100 Subject: [PATCH 4/8] Refuse carriers in the conjoin data fold A carrier among the arguments became a pack element, packing the carriers themselves rather than conjoining what they carry - and reaching their values would need value(), which throws. A single argument is still forwarded unchanged, carrier or not. Assisted-by: Claude:claude-opus-5 --- include/fn/pack.hpp | 13 +++++++++++-- tests/fn/pack.cpp | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/include/fn/pack.hpp b/include/fn/pack.hpp index 690d633b..fe1af8da 100644 --- a/include/fn/pack.hpp +++ b/include/fn/pack.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -459,6 +460,14 @@ template