diff --git a/CHANGELOG.md b/CHANGELOG.md index c856aa58..869617f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ 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. +## Void expected monadic noexcept includes callback-result construction — 17 October 2026 + +The void specialization of `pfn::expected` includes callback-result construction in the conditional `noexcept` of `and_then` and `or_else`. `and_then` also retains its error-construction check. `or_else` has no unchanged value to construct. Throwing copies and moves of reference-returned results propagate, and immovable prvalues retain guaranteed copy elision. + +Tests exercise all source cv/ref categories, reference-result copies and moves, and prvalues for both operations. No `fn` wrapper is changed. + +## Object expected monadic noexcept includes callback-result construction — 17 October 2026 + +For non-void `pfn::expected`, `and_then` and `or_else` include copying or moving a callback result returned by reference in their conditional `noexcept`. They retain the check for constructing the unchanged error or value. Throwing result construction propagates; immovable prvalue results remain supported through guaranteed copy elision. Constraints and invalid-callback diagnostics are unchanged. + +Tests cover result-reference and source cv/ref categories, throwing copies and moves, non-throwing controls, and immovable prvalues. + +## Optional monadic noexcept includes callback-result construction — 17 October 2026 + +`pfn::optional` accounts for constructing the returned optional in the conditional `noexcept` of `and_then` and `or_else`, for owning and reference specializations. A non-throwing callback can return a reference to an optional whose copy or move throws; that exception propagates instead of terminating. The specified constraints and invalid-callback diagnostics are unchanged. Prvalue results retain guaranteed copy elision, including immovable payloads. + +Tests cover throwing copies and moves, non-throwing reference results, and immovable prvalues. The `fn` monadic wrappers remain separate work. + ## Void expected monadic overloads follow the draft — 17 October 2026 The void specialization of `pfn::expected` uses body-deduced `auto` returns for all four monadic operations. `and_then` and `transform` retain their error-construction constraints; `or_else` and `transform_error` have no constructibility constraint. Invalid callbacks cause hard errors instead of disappearing through the helper's trailing return type. diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index b6b4ec83..f96f5547 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -1072,10 +1072,12 @@ The library is divided into layers: - `pfn` (Polyfill fn) is the standards-facing layer. It provides `std::optional` and `std::expected` in their C++26 shape — monadic member functions, `optional`, range support — plus smaller utilities such as `std::invoke_r` and `std::unreachable`, all available to a C++20 compiler. - `fn` is the strict extension layer. It introduces the `pack`/`copack` algebra, multidispatch, graded errors, `choice`, `just`, the pipeline functors, and the composition operators `&` and `|`. -Every `fn` type with a `pfn` counterpart is a strict superset of it: switching a valid program from `pfn` to `fn` changes neither compilation nor behaviour. +Every `fn` type with a `pfn` counterpart is a strict superset of it: switching a valid program from `pfn` to `fn` changes neither compilation nor behaviour. A known exception is the monadic wrappers’ conditional `noexcept`: `fn` can still terminate when constructing a callback result throws, where the corrected `pfn` operation propagates the exception. The `fn` fixes are separate work ([#436](https://github.com/libfn/functional/issues/436)). For an owning `pfn::optional`, `transform` and `and_then` pass the payload with the source's cv/ref category. If the callback cannot accept that argument, the call is ill-formed; another cv/ref member overload is not a fallback. An `optional` passes its referent as `T&`, including through a temporary wrapper. Reference `and_then` also produces a hard error for an invalid invocation. Reference `transform` instead substitutes its explicit result type, so a non-invocable callback removes it from overload resolution. Both optional specializations constrain `or_else` on callback invocability; the owning specialization additionally requires copy or move construction for its respective overload. These rejection rules describe the `pfn` interface. +For `pfn::optional` and `pfn::expected`, the conditional `noexcept` on `and_then` and `or_else` accounts for invoking the callback and constructing the returned object, including copying or moving a callback result returned by reference. Prvalue results use guaranteed copy elision, so they need no copy or move constructor. For `optional`, `or_else` also accounts for copying or moving an engaged source; for `expected`, both operations account for constructing the unchanged value or error, where one exists. + For `pfn::expected`, `and_then` and `transform` constrain construction of the unchanged error `E`. For non-void `T`, `or_else` and `transform_error` similarly constrain construction of `T`; the void specialization has no such constraint. These constraints can remove an overload and permit copying through another cv/ref overload. For callbacks with an argument, this can pass a const lvalue from an rvalue source. Once an overload is selected, an invalid callback is a hard error, including in a dependent `requires`-expression. ## Functional terminology diff --git a/include/pfn/expected.hpp b/include/pfn/expected.hpp index bcca894b..f85ea8e2 100644 --- a/include/pfn/expected.hpp +++ b/include/pfn/expected.hpp @@ -852,7 +852,9 @@ template struct _expected_base { template static constexpr auto _and_then(Self &&self, Fn &&fn) // - noexcept(::std::is_nothrow_invocable_v + noexcept(::std::is_nothrow_invocable_r_v< + ::std::remove_cvref_t<::std::invoke_result_t>, Fn, + decltype(_expected_base::_value(FWD(self)))> && ::std::is_nothrow_constructible_v) requires(not ::std::is_void_v && ::std::is_invocable_v && ::std::is_constructible_v) @@ -868,7 +870,7 @@ template struct _expected_base { template static constexpr auto _and_then(Self &&self, Fn &&fn) // - noexcept(::std::is_nothrow_invocable_v + noexcept(::std::is_nothrow_invocable_r_v<::std::remove_cvref_t<::std::invoke_result_t>, Fn> && ::std::is_nothrow_constructible_v) requires(::std::is_void_v && ::std::is_invocable_v && ::std::is_constructible_v) @@ -884,7 +886,9 @@ template struct _expected_base { template static constexpr auto _or_else(Self &&self, Fn &&fn) // - noexcept(::std::is_nothrow_invocable_v + noexcept(::std::is_nothrow_invocable_r_v< + ::std::remove_cvref_t<::std::invoke_result_t>, Fn, + decltype(_expected_base::_error(FWD(self)))> && ::std::is_nothrow_constructible_v) requires(not ::std::is_void_v && ::std::is_invocable_v && ::std::is_constructible_v) @@ -900,7 +904,9 @@ template struct _expected_base { template static constexpr auto _or_else(Self &&self, Fn &&fn) // - noexcept(::std::is_nothrow_invocable_v) + noexcept(::std::is_nothrow_invocable_r_v< + ::std::remove_cvref_t<::std::invoke_result_t>, Fn, + decltype(_expected_base::_error(FWD(self)))>) requires(::std::is_void_v && ::std::is_invocable_v) { using result_t = ::std::remove_cvref_t<::std::invoke_result_t>; diff --git a/include/pfn/optional.hpp b/include/pfn/optional.hpp index a9e01b24..730c919e 100644 --- a/include/pfn/optional.hpp +++ b/include/pfn/optional.hpp @@ -694,7 +694,8 @@ template struct _optional_base { // through public API -- same reasoning as the _from_optional_t constructors above. template static constexpr auto _and_then(Self &&self, Fn &&fn) // - noexcept(::std::is_nothrow_invocable_v) + noexcept(::std::is_nothrow_invocable_r_v<::std::remove_cvref_t<::std::invoke_result_t>, + Fn, decltype(*FWD(self))>) requires ::std::is_invocable_v { using result_t = ::std::remove_cvref_t<::std::invoke_result_t>; @@ -724,7 +725,7 @@ template struct _optional_base { template static constexpr auto _or_else(Self &&self, Fn &&fn) // - noexcept(::std::is_nothrow_invocable_v + noexcept(::std::is_nothrow_invocable_r_v, Fn> && ::std::is_nothrow_constructible_v, Self>) requires(::std::is_invocable_v && ::std::is_constructible_v, Self>) { @@ -877,7 +878,8 @@ template struct _optional_base { // T& (const on the optional does not propagate to the referent), so Self only says which // public overload delegated here. template - static constexpr auto _and_then(Self &&self, Fn &&fn) noexcept(::std::is_nothrow_invocable_v) + static constexpr auto _and_then(Self &&self, Fn &&fn) noexcept( + ::std::is_nothrow_invocable_r_v<::std::remove_cvref_t<::std::invoke_result_t>, Fn, T &>) requires ::std::is_invocable_v { using result_t = ::std::remove_cvref_t<::std::invoke_result_t>; @@ -905,7 +907,8 @@ template struct _optional_base { } template - static constexpr auto _or_else(Self &&self, Fn &&fn) noexcept(::std::is_nothrow_invocable_v) + static constexpr auto + _or_else(Self &&self, Fn &&fn) noexcept(::std::is_nothrow_invocable_r_v, Fn>) requires ::std::is_invocable_v { using result_t = typename Policy::template type; diff --git a/tests/pfn/expected.cpp b/tests/pfn/expected.cpp index 0085337c..567dbe99 100644 --- a/tests/pfn/expected.cpp +++ b/tests/pfn/expected.cpp @@ -70,6 +70,141 @@ concept has_transform = requires(O &&o, F &&f) { std::forward(o).transform(st template concept has_transform_error = requires(O &&o, F &&f) { std::forward(o).transform_error(std::forward(f)); }; +// Result-construction noexcept tests exclude the fn wrappers, whose fixes are separate work (#436). +struct monadic_result_error {}; + +template struct monadic_result { + int value; + bool fail; + constexpr explicit monadic_result(int v, bool throws = false) noexcept : value(v), fail(throws) {} + constexpr monadic_result(monadic_result const &other) noexcept(CopyNoexcept) : value(other.value), fail(other.fail) + { + if constexpr (not CopyNoexcept) + if (fail) + throw monadic_result_error{}; + } + constexpr monadic_result(monadic_result &&other) noexcept(MoveNoexcept) : value(other.value), fail(other.fail) + { + if constexpr (not MoveNoexcept) + if (fail) + throw monadic_result_error{}; + } +}; + +template + requires(not Recover) +constexpr auto monadic_operation(S &&source, + F &&fn) noexcept(noexcept(std::forward(source).and_then(std::forward(fn)))) +{ + return std::forward(source).and_then(std::forward(fn)); +} + +template + requires Recover +constexpr auto monadic_operation(S &&source, + F &&fn) noexcept(noexcept(std::forward(source).or_else(std::forward(fn)))) +{ + return std::forward(source).or_else(std::forward(fn)); +} + +template constexpr R make_monadic_result(Args &&...args) +{ + if constexpr (Error) + return R(unexpect, std::forward(args)...); + else + return R(std::in_place, std::forward(args)...); +} + +template void check_monadic_result_references() +{ + auto check_source = [] { + auto check_result = [] { + using R = std::remove_cvref_t; + static_assert(not Extension || std::is_nothrow_constructible_v == Nothrow); + constexpr auto read_result = [](R const &result) { + if constexpr (Recover) + return result.error().value; + else + return result->value; + }; + constexpr auto make_source = [] { + if constexpr (Recover == Active) + return O(unexpect, 5); + else + return O(); + }; + constexpr auto succeeds = [=] { + O source = make_source.template operator()(); + O bypass = make_source.template operator()(); + R returned = make_monadic_result(7); + auto fn = [&returned](auto &&...) noexcept -> Ref { return static_cast(returned); }; + static_assert(not Extension || noexcept(monadic_operation(std::declval(), fn)) == Nothrow); + auto result = monadic_operation(static_cast(source), fn); + auto unchanged = monadic_operation(static_cast(bypass), fn); + return result.has_value() != Recover && read_result(result) == 7 && read_result(returned) == 7 + && source.has_value() != Recover && unchanged.has_value() == Recover; + }; + static_assert(succeeds()); + CHECK(succeeds()); + if constexpr (not Nothrow) { + O source = make_source.template operator()(); + O bypass = make_source.template operator()(); + R returned = make_monadic_result(7, true); + auto fn = [&returned](auto &&...) noexcept -> Ref { return static_cast(returned); }; + CHECK_THROWS_AS(monadic_operation(static_cast(source), fn), monadic_result_error); + CHECK(source.has_value() != Recover); + if constexpr (Recover) + CHECK(source.error() == 5); + else if constexpr (not std::is_void_v) + CHECK(*source == 0); + CHECK(read_result(returned) == 7); + CHECK(monadic_operation(static_cast(bypass), fn).has_value() == Recover); + } + }; + using Copy = std::conditional_t>, + expected, int>>; + using Move = std::conditional_t>, + expected, int>>; + check_result.template operator()(); + check_result.template operator()(); + check_result.template operator()(); + check_result.template operator()(); + check_result.template operator()(); + check_result.template operator()(); + }; + check_source.template operator()(); + check_source.template operator()(); + check_source.template operator()(); + check_source.template operator()(); +} + +template void check_monadic_prvalue() +{ + constexpr auto check = [] { + using R = std::conditional_t, + expected>; + static_assert(not std::is_copy_constructible_v && not std::is_move_constructible_v); + O source = [] { + if constexpr (Recover) + return O(unexpect, 5); + else + return O(); + }(); + auto fn = [](auto &&...) noexcept -> R { return make_monadic_result(helper_list_t{}, 7); }; + auto cfn = [](auto &&...) noexcept -> R const { return make_monadic_result(helper_list_t{}, 9); }; + static_assert(not Extension || noexcept(monadic_operation(source, fn))); + static_assert(not Extension || noexcept(monadic_operation(source, cfn))); + auto result = monadic_operation(source, fn); + auto const_result = monadic_operation(std::move(source), cfn); + if constexpr (Recover) + return not result && result.error().v == 7 && not const_result && const_result.error().v == 9; + else + return result && result->v == 7 && const_result && const_result->v == 9; + }; + static_assert(check()); + CHECK(check()); +} + } // namespace TEST_CASE("bad_expected_access", "[expected][polyfill][bad_expected_access]") @@ -3409,6 +3544,10 @@ TEST_CASE("expected non void", "[expected][polyfill]") { SECTION("and_then") { +#if !defined(PFN_TEST_NESTED) || defined(PFN_TEST_VALIDATION) + SECTION("result references") { check_monadic_result_references, false, extension>(); } + SECTION("immovable prvalue") { check_monadic_prvalue, false, extension>(); } +#endif SECTION("constructibility constraints") { using O = expected; @@ -3540,6 +3679,10 @@ TEST_CASE("expected non void", "[expected][polyfill]") SECTION("or_else") { +#if !defined(PFN_TEST_NESTED) || defined(PFN_TEST_VALIDATION) + SECTION("result references") { check_monadic_result_references, true, extension>(); } + SECTION("immovable prvalue") { check_monadic_prvalue, true, extension>(); } +#endif SECTION("constructibility constraints") { using O = expected; @@ -5932,6 +6075,10 @@ TEST_CASE("expected void", "[expected_void][polyfill]") { SECTION("and_then") { +#if !defined(PFN_TEST_NESTED) || defined(PFN_TEST_VALIDATION) + SECTION("result references") { check_monadic_result_references, false, extension>(); } + SECTION("immovable prvalue") { check_monadic_prvalue, false, extension>(); } +#endif SECTION("constructibility constraints") { using O = expected; @@ -6046,6 +6193,10 @@ TEST_CASE("expected void", "[expected_void][polyfill]") SECTION("or_else") { +#if !defined(PFN_TEST_NESTED) || defined(PFN_TEST_VALIDATION) + SECTION("result references") { check_monadic_result_references, true, extension>(); } + SECTION("immovable prvalue") { check_monadic_prvalue, true, extension>(); } +#endif SECTION("immovable error") { using O = expected; diff --git a/tests/pfn/optional.cpp b/tests/pfn/optional.cpp index 112db4eb..6651722b 100644 --- a/tests/pfn/optional.cpp +++ b/tests/pfn/optional.cpp @@ -102,6 +102,93 @@ template struct ref_qualified_callback { constexpr R operator()(int value) && { return value + 1; } }; +// Result-construction noexcept tests exclude the fn wrappers, whose fixes are separate work (#436). +struct monadic_result_error {}; + +template struct monadic_result { + int value; + bool fail; + constexpr explicit monadic_result(int v, bool throws = false) noexcept : value(v), fail(throws) {} + constexpr monadic_result(monadic_result const &other) noexcept(CopyNoexcept) : value(other.value), fail(other.fail) + { + if constexpr (not CopyNoexcept) + if (fail) + throw monadic_result_error{}; + } + constexpr monadic_result(monadic_result &&other) noexcept(MoveNoexcept) : value(other.value), fail(other.fail) + { + if constexpr (not MoveNoexcept) + if (fail) + throw monadic_result_error{}; + } +}; + +template void check_and_then_result_references() +{ + auto check_source = [] { + auto check_result = [] { + using R = std::remove_cvref_t; + static_assert(not Extension || std::is_nothrow_constructible_v == Nothrow); + constexpr auto succeeds = [] { + int value = 1; + O source(value); + O empty; + R returned(std::in_place, 7); + auto fn = [&returned](auto &&) noexcept -> Ref { return static_cast(returned); }; + static_assert(not Extension || noexcept(std::declval().and_then(fn)) == Nothrow); + auto result = static_cast(source).and_then(fn); + return result && result->value == 7 && source && *source == 1 && returned->value == 7 + && not static_cast(empty).and_then(fn); + }; + static_assert(succeeds()); + CHECK(succeeds()); + if constexpr (not Nothrow) { + int value = 1; + O source(value); + O empty; + R returned(std::in_place, 7, true); + auto fn = [&returned](auto &&) noexcept -> Ref { return static_cast(returned); }; + CHECK_THROWS_AS(static_cast(source).and_then(fn), monadic_result_error); + CHECK(source.has_value()); + CHECK(*source == 1); + CHECK(returned->value == 7); + CHECK(not static_cast(empty).and_then(fn)); + } + }; + using Copy = optional>; + using Move = optional>; + check_result.template operator()(); + check_result.template operator()(); + check_result.template operator()(); + check_result.template operator()(); + check_result.template operator()(); + check_result.template operator()(); + }; + check_source.template operator()(); + check_source.template operator()(); + check_source.template operator()(); + check_source.template operator()(); +} + +template void check_and_then_prvalue() +{ + constexpr auto check = [] { + int value = 1; + O source(value); + using R = optional; + static_assert(not std::is_copy_constructible_v && not std::is_move_constructible_v); + auto fn = [](auto &&) noexcept -> R { return R(std::in_place, helper_list_t{}, 7); }; + auto cfn = [](auto &&) noexcept -> R const { return R(std::in_place, helper_list_t{}, 9); }; + static_assert(not Extension || noexcept(source.and_then(fn))); + static_assert(not Extension || noexcept(source.and_then(cfn))); + auto result = source.and_then(fn); + auto const_result = std::move(source).and_then(cfn); + return result && result->v == 7 && const_result && const_result->v == 9; + }; + static_assert(check()); + CHECK(check()); +} + } // namespace TEST_CASE("optional", "[optional][polyfill]") @@ -1556,6 +1643,11 @@ TEST_CASE("optional", "[optional][polyfill]") { SECTION("and_then") { +#if !defined(PFN_TEST_NESTED) || defined(PFN_TEST_VALIDATION) + SECTION("result references") { check_and_then_result_references, extension>(); } + SECTION("immovable prvalue") { check_and_then_prvalue, extension>(); } + +#endif SECTION("lvalue-only callback") { using F = lvalue_callback>; @@ -1704,6 +1796,61 @@ TEST_CASE("optional", "[optional][polyfill]") SECTION("or_else") { +#if !defined(PFN_TEST_NESTED) || defined(PFN_TEST_VALIDATION) + SECTION("result references") + { + auto check = [] { + using R = optional>; + using S = std::conditional_t; + using Ref = std::conditional_t; + static_assert(not extension || std::is_nothrow_constructible_v); + static_assert(not std::is_nothrow_constructible_v); + constexpr auto succeeds = [] { + R empty; + R engaged(std::in_place, 3); + R returned(std::in_place, 7); + auto fn = [&returned]() noexcept -> Ref { return static_cast(returned); }; + static_assert(not extension || not noexcept(std::declval().or_else(fn))); + auto result = static_cast(empty).or_else(fn); + auto unchanged = static_cast(engaged).or_else(fn); + return result && result->value == 7 && unchanged && unchanged->value == 3 && not empty + && returned->value == 7; + }; + static_assert(succeeds()); + CHECK(succeeds()); + R empty; + R engaged(std::in_place, 3); + R returned(std::in_place, 7, true); + auto fn = [&returned]() noexcept -> Ref { return static_cast(returned); }; + CHECK_THROWS_AS(static_cast(empty).or_else(fn), monadic_result_error); + CHECK(not empty); + CHECK(returned->value == 7); + CHECK(static_cast(engaged).or_else(fn)->value == 3); + }; + check.template operator()(); + check.template operator()(); + } + + SECTION("nothrow result references") + { + constexpr auto check = [] { + optional source; + optional returned(7); + auto fn = [&returned]() noexcept -> Ref { return static_cast(returned); }; + static_assert(not extension || noexcept(std::declval().or_else(fn))); + return static_cast(source).or_else(fn) == 7 && not source && returned == 7; + }; + static_assert(check.template operator() &, optional &>()); + static_assert(check.template operator() const &, optional const &>()); + static_assert(check.template operator() &&, optional &&>()); + static_assert(check.template operator() const &&, optional const &&>()); + CHECK(check.template operator() &, optional &>()); + CHECK(check.template operator() const &, optional const &>()); + CHECK(check.template operator() &&, optional &&>()); + CHECK(check.template operator() const &&, optional const &&>()); + } + +#endif SECTION("engaged") { using T = optional; @@ -2859,6 +3006,11 @@ TEST_CASE("optional reference", "[optional_ref][polyfill]") SECTION("and_then") { +#ifndef PFN_TEST_NESTED + SECTION("result references") { check_and_then_result_references(); } + SECTION("immovable prvalue") { check_and_then_prvalue(); } + +#endif int x = 5; T const a(std::in_place, x); T const e(std::nullopt); @@ -2904,6 +3056,29 @@ TEST_CASE("optional reference", "[optional_ref][polyfill]") SECTION("or_else") { +#ifndef PFN_TEST_NESTED + SECTION("result references") + { + constexpr auto check = [] { + int value = 7; + T source; + T returned(value); + auto fn = [&returned]() noexcept -> Ref { return static_cast(returned); }; + static_assert(noexcept(source.or_else(fn))); + auto result = source.or_else(fn); + return result && &*result == &value && not source && &*returned == &value; + }; + static_assert(check.template operator()()); + static_assert(check.template operator()()); + static_assert(check.template operator()()); + static_assert(check.template operator()()); + CHECK(check.template operator()()); + CHECK(check.template operator()()); + CHECK(check.template operator()()); + CHECK(check.template operator()()); + } + +#endif int x = 5; int y = 9; T const a(std::in_place, x);