diff --git a/CHANGELOG.md b/CHANGELOG.md index 442a95df..844b4338 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ Design history of libfn, newest first. The living documents — [README.md](README.md), [CONTRIBUTING.md](CONTRIBUTING.md), [docs/](docs/) — describe only the present state of the design; when a decision makes an earlier idea obsolete, this file is where the transition is recorded and explained. +## The conjunction sums two ungraded errors — 25 July 2026 + +- **`operator&` no longer asks its operands to arrive graded** ([#384](https://github.com/libfn/functional/issues/384)): conjoining `expected` with `expected` was ill-formed unless one of the two error types was already a `copack`, though the same pair disjoined without complaint. The conjunction's error channel *is* the sum, so producing the grade is the operator's work, not a precondition on its operands — and nowhere else does a sum ask for one: `|` forms its value sum out of two ungraded value types, in every carrier. Of the four channels — `&`'s value product and error sum, `|`'s value sum and error product — this was the only one gated. The four different-error arms now serve any distinct pair, widening to `copack_for` exactly as they did for a graded operand, and `conjoin` follows as their fold. Purely additive: every expression newly accepted was ill-formed before, and a matching pair of error types still collapses to the plain error it always had. +- **The bind is deliberately left out of this.** `and_then` and `or_else` are the foundational operations, and a callback whose error type merely differs from self's still refuses: switching a pipeline to the graded monad is the caller's decision to make, not something a bind does behind their back, and the library offers spellings enough to opt in. + ## The verbs reach an uninhabited value side, and `pack` compares — 25 July 2026 - **Every value-side verb now serves a carrier whose value side is uninhabited** ([#380](https://github.com/libfn/functional/issues/380)): `optional>`, never engaged, and `expected, E>`, always holding its error. Their members have been the identity there since the empty-sum doctrine (17 July below) — the mapping returns `*this` and the callback is neither invoked nor instantiated — but the verbs answered false, so a pipeline could not reach what the member could. `transform` and `and_then` delegate to those vacuous members; `inspect` observes nothing, `filter` rejects nothing and `fail` never fires, each passing the operand through; `discard` always accepted them. The operand class is named: `some_empty_value` for the value side and `some_empty_error` for its mirror, the identity `expected` and only it, which `some_identity` now spells as its third alternative. Both answer for any type at all — the carrier test short-circuits before the value or error type is named — so their negations stay usable in constraints. diff --git a/include/fn/expected.hpp b/include/fn/expected.hpp index 1ccd7db3..03b76ec2 100644 --- a/include/fn/expected.hpp +++ b/include/fn/expected.hpp @@ -1971,8 +1971,6 @@ template requires some_expected_void && (not some_expected_void) && (not ::std::is_same_v::error_type, typename ::std::remove_cvref_t::error_type>) - && (some_copack::error_type> - || some_copack::error_type>) [[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) // noexcept(detail::_nothrow_join_widened< expected::value_type, @@ -2024,8 +2022,6 @@ template requires(not some_expected_void) && some_expected_void && (not ::std::is_same_v::error_type, typename ::std::remove_cvref_t::error_type>) - && (some_copack::error_type> - || some_copack::error_type>) [[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) // noexcept(detail::_nothrow_join_widened< expected::value_type, @@ -2074,8 +2070,6 @@ template requires some_expected_void && some_expected_void && (not ::std::is_same_v::error_type, typename ::std::remove_cvref_t::error_type>) - && (some_copack::error_type> - || some_copack::error_type>) [[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) // noexcept(detail::_nothrow_join_widened< expected::error_type, @@ -2121,8 +2115,6 @@ template requires(not some_expected_void) && (not some_expected_void) && (not ::std::is_same_v::error_type, typename ::std::remove_cvref_t::error_type>) - && (some_copack::error_type> - || some_copack::error_type>) [[nodiscard]] constexpr auto operator&(Lh &&lh, Rh &&rh) // noexcept(noexcept( ::fn::detail::_join< diff --git a/tests/fn/expected.cpp b/tests/fn/expected.cpp index 562582af..a7c9bfee 100644 --- a/tests/fn/expected.cpp +++ b/tests/fn/expected.cpp @@ -1085,12 +1085,13 @@ TEST_CASE("expected conjunction", "[expected][operator_and][graded][copack]") static_assert( not noexcept(std::declval &>() & std::declval &>())); - // constraints: both operands are expected, and their error types must match or be graded + // constraints: both operands are expected; their error types may differ, graded or not constexpr auto can_amp = [](auto &&rh) { return requires { std::declval &>() & rh; }; }; static_assert(can_amp(fn::expected{})); static_assert(not can_amp(42)); enum class other_error {}; - static_assert(not can_amp(fn::expected{1})); // mismatched non-graded error + static_assert(can_amp(fn::expected{1})); // mismatched non-graded error + static_assert(not can_amp(fn::optional{1})); // ... but the kinds still have to match SECTION("same error type") { @@ -1470,6 +1471,61 @@ TEST_CASE("expected conjunction", "[expected][operator_and][graded][copack]") } } + SECTION("different plain error types") + { + // The conjunction produces the error sum rather than requiring it: two unrelated, ungraded + // error types conjoin into their copack, exactly as an already graded operand does. + enum OtherError : int { Oops }; + using EA = fn::expected; + using EB = fn::expected; + using Sum = fn::copack_for; + + static_assert( + std::same_as() & std::declval()), fn::expected, Sum>>); + static_assert(std::same_as>() & std::declval()), + fn::expected>); + static_assert(std::same_as() & std::declval>()), + fn::expected>); + static_assert(std::same_as>() + & std::declval>()), + fn::expected>); + + constexpr auto is_1_true = [](int i, bool b) { return i == 1 && b; }; + // which alternative the sum holds: the failing operand's error injects by type, leftmost first + constexpr auto which = fn::overload{[](Error e) { return e == FileNotFound ? 1 : 0; }, + [](OtherError e) { return e == Oops ? 2 : 0; }}; + + static_assert((EA{1} & EB{true}).value().apply(is_1_true)); + static_assert((EA{::fn::unexpect, FileNotFound} & EB{true}).error().apply(which) == 1); + static_assert((EA{1} & EB{::fn::unexpect, Oops}).error().apply(which) == 2); + static_assert((EA{::fn::unexpect, FileNotFound} & EB{::fn::unexpect, Oops}).error().apply(which) == 1); + static_assert((fn::expected{} & EB{true}).value()); + static_assert((EA{1} & fn::expected{}).value() == 1); + static_assert( + (fn::expected{} & fn::expected{::fn::unexpect, Oops}).error().apply(which) == 2); + + CHECK((EA{1} & EB{true}).value().apply(is_1_true)); + CHECK((EA{::fn::unexpect, FileNotFound} & EB{true}).error().apply(which) == 1); + CHECK((EA{1} & EB{::fn::unexpect, Oops}).error().apply(which) == 2); + CHECK((EA{::fn::unexpect, FileNotFound} & EB{::fn::unexpect, Oops}).error().apply(which) == 1); + CHECK((fn::expected{} & EB{true}).value()); + CHECK((EA{1} & fn::expected{}).value() == 1); + CHECK((fn::expected{} & fn::expected{::fn::unexpect, Oops}).error().apply(which) + == 2); + + SECTION("noexcept") + { + // both operands' errors are lifted into the sum, so both relocations weigh + using Th = fn::expected; + static_assert(noexcept(std::declval() & std::declval())); + static_assert(not noexcept(std::declval() & std::declval())); // copies the error + static_assert(noexcept(std::declval() & std::declval())); // moves it + static_assert(not noexcept(std::declval() & std::declval())); + static_assert(noexcept(std::declval() & std::declval())); + SUCCEED(); + } + } + SECTION("graded monad as left operand") { static_assert(std::same_as>>() diff --git a/tests/fn/pack.cpp b/tests/fn/pack.cpp index 17ddbc28..a72e3eb3 100644 --- a/tests/fn/pack.cpp +++ b/tests/fn/pack.cpp @@ -1022,6 +1022,16 @@ TEST_CASE("operator &", "[pack][copack][operator_and]") static_assert(fn::conjoin(EA{1}, EB{2.5}).value().apply(is_1_2_5)); static_assert(fn::conjoin(EA{fn::unexpect, true}, EB{2.5}).error() == true); + // ... and the operands' errors need not agree: the conjunction sums them, ungraded or not + using EC = fn::expected; + static_assert(std::same_as(), std::declval())), + fn::expected, fn::copack_for>>); + static_assert(fn::conjoin(EA{1}, EC{2.5}).value().apply(is_1_2_5)); + static_assert(fn::conjoin(EA{fn::unexpect, true}, EC{2.5}).error() == fn::copack_for{true}); + static_assert(fn::conjoin(EA{1}, EC{fn::unexpect, 7}).error() == fn::copack_for{7}); + CHECK(fn::conjoin(EA{1}, EC{2.5}).value().apply(is_1_2_5)); + CHECK(bool(fn::conjoin(EA{1}, EC{fn::unexpect, 7}).error() == fn::copack_for{7})); + // n-ary, and the product splices rather than nesting constexpr auto is_1_true_2 = [](int a, bool b, int c) { return a == 1 && b && c == 2; }; static_assert(fn::conjoin(fn::just{1}, fn::just{true}, fn::just{2}).value().apply(is_1_true_2));