From bc078555474a1783da71eb67c284a7c5bec9699a Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 17 Oct 2026 20:59:08 +0100 Subject: [PATCH 1/4] Account for callback-result construction in optional noexcept Include copying or moving a reference-returned optional in and_then and or_else exception specifications. Preserve guaranteed elision for prvalue results and the draft's constraints and hard-error behavior. Cover result and source cv/ref categories, exception propagation, and immovable prvalues. Keep the new regressions out of the fn wrapper suite, whose exception specifications are separate work. Assisted-by: Codex:gpt-6 --- CHANGELOG.md | 6 ++ TYPE_ALGEBRA.md | 4 +- include/pfn/optional.hpp | 11 ++- tests/pfn/optional.cpp | 175 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 191 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c856aa58..28a9849c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ 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. +## 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..ffd22753 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`, the conditional `noexcept` on `and_then` and `or_else` accounts for invoking the callback and constructing the returned optional, including copying or moving a callback result returned by reference. Prvalue results use guaranteed copy elision, so they need no copy or move constructor. `or_else` also accounts for copying or moving an engaged source. + 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/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/optional.cpp b/tests/pfn/optional.cpp index 112db4eb..ef0f3e9d 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(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(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); From fb5dae61303ec7f650c4df665fcc624f8f3799be Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 17 Oct 2026 21:02:33 +0100 Subject: [PATCH 2/4] Account for callback-result construction in object expected noexcept Include result copying or moving in and_then and or_else while retaining the unchanged-side construction checks. Preserve guaranteed elision for prvalue results and the draft's constraints and hard-error behavior. Test all source cv/ref categories, reference-result copies and moves, exception propagation, and immovable prvalues against pfn and std. Assisted-by: Codex:gpt-6 --- CHANGELOG.md | 6 ++ TYPE_ALGEBRA.md | 2 +- include/pfn/expected.hpp | 8 ++- tests/pfn/expected.cpp | 143 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28a9849c..568bed7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ 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. +## 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. diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index ffd22753..746fa8a2 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -1076,7 +1076,7 @@ Every `fn` type with a `pfn` counterpart is a strict superset of it: switching a 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`, the conditional `noexcept` on `and_then` and `or_else` accounts for invoking the callback and constructing the returned optional, including copying or moving a callback result returned by reference. Prvalue results use guaranteed copy elision, so they need no copy or move constructor. `or_else` also accounts for copying or moving an engaged source. +For `pfn::optional` and non-void `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. 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. diff --git a/include/pfn/expected.hpp b/include/pfn/expected.hpp index bcca894b..6f74c784 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) @@ -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) diff --git a/tests/pfn/expected.cpp b/tests/pfn/expected.cpp index 0085337c..d06a1b9d 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(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() == not Recover && read_result(result) == 7 && read_result(returned) == 7 + && source.has_value() == not 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() == not 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; From 94004ff4232ea56a046add94977b05188c68d511 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 17 Oct 2026 21:08:28 +0100 Subject: [PATCH 3/4] Account for callback-result construction in void expected noexcept Include result copying or moving in and_then and or_else. Keep the error construction check on and_then and preserve guaranteed elision for prvalues without adding constraints. Exercise the same source and result cv/ref matrix as the object specialization, including exception propagation and immovable results. Guard constructor-noexcept extension assertions in std validation. Assisted-by: Codex:gpt-6 --- CHANGELOG.md | 6 ++++++ TYPE_ALGEBRA.md | 2 +- include/pfn/expected.hpp | 6 ++++-- tests/pfn/expected.cpp | 10 +++++++++- tests/pfn/optional.cpp | 4 ++-- 5 files changed, 22 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 568bed7c..869617f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ 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. diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index 746fa8a2..f96f5547 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -1076,7 +1076,7 @@ Every `fn` type with a `pfn` counterpart is a strict superset of it: switching a 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 non-void `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. +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. diff --git a/include/pfn/expected.hpp b/include/pfn/expected.hpp index 6f74c784..f85ea8e2 100644 --- a/include/pfn/expected.hpp +++ b/include/pfn/expected.hpp @@ -870,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) @@ -904,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/tests/pfn/expected.cpp b/tests/pfn/expected.cpp index d06a1b9d..4bab291b 100644 --- a/tests/pfn/expected.cpp +++ b/tests/pfn/expected.cpp @@ -120,7 +120,7 @@ template void check_monadic_result_refer auto check_source = [] { auto check_result = [] { using R = std::remove_cvref_t; - static_assert(std::is_nothrow_constructible_v == Nothrow); + static_assert(not Extension || std::is_nothrow_constructible_v == Nothrow); constexpr auto read_result = [](R const &result) { if constexpr (Recover) return result.error().value; @@ -6075,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; @@ -6189,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 ef0f3e9d..6651722b 100644 --- a/tests/pfn/optional.cpp +++ b/tests/pfn/optional.cpp @@ -128,7 +128,7 @@ template void check_and_then_result_references() auto check_source = [] { auto check_result = [] { using R = std::remove_cvref_t; - static_assert(std::is_nothrow_constructible_v == Nothrow); + static_assert(not Extension || std::is_nothrow_constructible_v == Nothrow); constexpr auto succeeds = [] { int value = 1; O source(value); @@ -1803,7 +1803,7 @@ TEST_CASE("optional", "[optional][polyfill]") using R = optional>; using S = std::conditional_t; using Ref = std::conditional_t; - static_assert(std::is_nothrow_constructible_v); + static_assert(not extension || std::is_nothrow_constructible_v); static_assert(not std::is_nothrow_constructible_v); constexpr auto succeeds = [] { R empty; From 0086e0b277db6d3c43847cd3003bbdc4f178a3ad Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 17 Oct 2026 21:29:11 +0100 Subject: [PATCH 4/4] Avoid negated template booleans in expected tests MSVC 19.44 miscompiles negation of an enclosing boolean template parameter across generic-lambda instantiations. Compare has_value() directly with Recover using !=, preserving the assertions. A standalone reproducer fails without libfn or Catch2. The affected helper reproduces all 32 assertion failures on Godbolt MSVC in /Od and /O2; both configurations pass with this change. Rebuilt GCC and Clang expected suites also pass. Assisted-by: Codex:gpt-6 --- tests/pfn/expected.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/pfn/expected.cpp b/tests/pfn/expected.cpp index 4bab291b..567dbe99 100644 --- a/tests/pfn/expected.cpp +++ b/tests/pfn/expected.cpp @@ -141,8 +141,8 @@ template void check_monadic_result_refer 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() == not Recover && read_result(result) == 7 && read_result(returned) == 7 - && source.has_value() == not Recover && unchanged.has_value() == Recover; + 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()); @@ -152,7 +152,7 @@ template void check_monadic_result_refer 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() == not Recover); + CHECK(source.has_value() != Recover); if constexpr (Recover) CHECK(source.error() == 5); else if constexpr (not std::is_void_v)