From 47290d122e6682b952e46ec027d0f23f0c5fb73b Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 17 Oct 2026 18:56:21 +0100 Subject: [PATCH 1/5] Reject cv/ref fallthrough in pfn optional monadic operations Deduce owning optional transform and and_then return types in their bodies, as specified by the standard. Trailing-return substitution could remove the selected overload and bypass a callback's deleted cv/ref overload, allowing a borrow from a temporary through const&. Keep conditional noexcept and valid reference results. Add constexpr and runtime controls for callable categories, lvalue-only callbacks, external references, and temporary reference wrappers; document the hard errors. GCC and Clang full builds and 48/48 tests pass on each. Separate compiler probes reject all 32 previously accepted invalid calls, matching the standard-library controls; mandate errors cannot run in the unit suite. The shared std optional tests compile in C++23 and C++26 on both compilers. Assisted-by: Codex:gpt-6 --- CHANGELOG.md | 6 ++ TYPE_ALGEBRA.md | 2 + include/pfn/optional.hpp | 9 +-- tests/pfn/optional.cpp | 129 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 138 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1c2370c..645e1a39 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. +## `pfn::optional` preserves the selected monadic overload — 17 October 2026 + +Owning `pfn::optional::transform` and `and_then` reject callbacks that cannot accept the selected payload cv/ref category, as the standard requires. Previously, substitution failure could remove the selected member and allow a different cv/ref overload to run. A callback that explicitly deleted its rvalue overload could consequently borrow from a temporary optional through its lvalue overload and leave a dangling reference. + +The members use unconstrained `auto` return types, so invalid invocations produce hard errors rather than falling back. Valid calls retain their conditional `noexcept`, and reference optionals still pass their referents as lvalues. + ## Conan selects the C++26 mode with an option — 13 October 2026 The Conan recipe replaces its `fn_cxx26` component with a `cxx26` option, following the review of the Conan Center recipe. CMakeDeps links every component into the `libfn::libfn` aggregate, so linking that target selected `LIBFN_CXX26` and C++26 without the consumer asking. The option makes mode selection explicit; consumers that exchange `fn` types must agree on it because the modes use different ABI namespaces. The profile's `compiler.cppstd` setting now selects C++26 or newer, replacing the CMake build module that supplied that requirement. When `cxx26=True`, `libfn::fn_cxx26` is an alias of `libfn::fn`. diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index 7310fbda..9c63d393 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -1072,6 +1072,8 @@ 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 `|`. +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. + 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. ## Functional terminology diff --git a/include/pfn/optional.hpp b/include/pfn/optional.hpp index 14c160c7..9a5312ac 100644 --- a/include/pfn/optional.hpp +++ b/include/pfn/optional.hpp @@ -1238,59 +1238,52 @@ template class optional : private detail::_optional_base constexpr auto and_then(F &&f) & // noexcept(noexcept(_base::_and_then(*this, FWD(f)))) // extension - -> decltype(_base::_and_then(*this, FWD(f))) { return _base::_and_then(*this, FWD(f)); } template constexpr auto and_then(F &&f) && // noexcept(noexcept(_base::_and_then(::std::move(*this), FWD(f)))) // extension - -> decltype(_base::_and_then(::std::move(*this), FWD(f))) { return _base::_and_then(::std::move(*this), FWD(f)); } template constexpr auto and_then(F &&f) const & // noexcept(noexcept(_base::_and_then(*this, FWD(f)))) // extension - -> decltype(_base::_and_then(*this, FWD(f))) { return _base::_and_then(*this, FWD(f)); } template constexpr auto and_then(F &&f) const && // noexcept(noexcept(_base::_and_then(::std::move(*this), FWD(f)))) // extension - -> decltype(_base::_and_then(::std::move(*this), FWD(f))) { return _base::_and_then(::std::move(*this), FWD(f)); } template constexpr auto transform(F &&f) & // noexcept(noexcept(_base::_transform(*this, FWD(f)))) // extension - -> decltype(_base::_transform(*this, FWD(f))) { return _base::_transform(*this, FWD(f)); } template constexpr auto transform(F &&f) && // noexcept(noexcept(_base::_transform(::std::move(*this), FWD(f)))) // extension - -> decltype(_base::_transform(::std::move(*this), FWD(f))) { return _base::_transform(::std::move(*this), FWD(f)); } template constexpr auto transform(F &&f) const & // noexcept(noexcept(_base::_transform(*this, FWD(f)))) // extension - -> decltype(_base::_transform(*this, FWD(f))) { return _base::_transform(*this, FWD(f)); } template constexpr auto transform(F &&f) const && // noexcept(noexcept(_base::_transform(::std::move(*this), FWD(f)))) // extension - -> decltype(_base::_transform(::std::move(*this), FWD(f))) { return _base::_transform(::std::move(*this), FWD(f)); } diff --git a/tests/pfn/optional.cpp b/tests/pfn/optional.cpp index be8468a2..4ae91dfa 100644 --- a/tests/pfn/optional.cpp +++ b/tests/pfn/optional.cpp @@ -89,6 +89,16 @@ concept has_spaceship = requires(L const &l, R const &r) { l <=> r; }; template concept has_or_else = requires(O &&o, F &&f) { std::forward(o).or_else(std::forward(f)); }; +template struct lvalue_callback { + constexpr R operator()(int const &value) const noexcept { return value; } + R operator()(int const &&) const = delete; +}; + +template struct qualified_callback { + constexpr R operator()(int value) & noexcept { return value; } + constexpr R operator()(int value) && { return value + 1; } +}; + } // namespace TEST_CASE("optional", "[optional][polyfill]") @@ -1543,6 +1553,43 @@ TEST_CASE("optional", "[optional][polyfill]") { SECTION("and_then") { + SECTION("lvalue-only callback") + { + using F = lvalue_callback>; + static_assert(std::is_invocable_v); + static_assert(std::is_invocable_v); + static_assert(not std::is_invocable_v); + static_assert(not std::is_invocable_v); + // pfn/std owning rvalue calls with F are hard errors, requiring separate compile-failure probes. + constexpr auto check = [] { + optional a(7); + return a.and_then(F{}) == a && std::as_const(a).and_then(F{}) == a; + }; + static_assert(check()); + CHECK(check()); + } + + SECTION("callable qualifiers") + { + using F = qualified_callback>; + constexpr auto check = []() { + static_assert(std::is_same_v().and_then(std::declval())), optional>); + static_assert(not extension || noexcept(std::declval().and_then(std::declval()))); + static_assert(not noexcept(std::declval().and_then(F{}))); + optional a(7); + F f; + return static_cast(a).and_then(f) == *a && static_cast(a).and_then(F{}) == *a + 1; + }; + static_assert(check.template operator() &>()); + static_assert(check.template operator() const &>()); + static_assert(check.template operator() &&>()); + static_assert(check.template operator() const &&>()); + CHECK(check.template operator() &>()); + CHECK(check.template operator() const &>()); + CHECK(check.template operator() &&>()); + CHECK(check.template operator() const &&>()); + } + SECTION("value") { using T = optional; @@ -1634,6 +1681,22 @@ TEST_CASE("optional", "[optional][polyfill]") auto res = std::move(o).and_then([](std::unique_ptr p) -> optional { return *p + 1; }); CHECK(res.value() == 43); } + +#ifndef PFN_TEST_VALIDATION + SECTION("to reference") + { + constexpr auto check = [] { + optional owner(7); + auto bound = owner.and_then(lvalue_callback>{}); + int external = 9; + auto from_temporary + = optional(1).and_then([&external](int &&) noexcept -> optional { return external; }); + return bound && &*bound == &*owner && from_temporary && &*from_temporary == &external; + }; + static_assert(check()); + CHECK(check()); + } +#endif } SECTION("or_else") @@ -1705,6 +1768,43 @@ TEST_CASE("optional", "[optional][polyfill]") SECTION("transform") { + SECTION("lvalue-only callback") + { + using F = lvalue_callback; + static_assert(std::is_invocable_v); + static_assert(std::is_invocable_v); + static_assert(not std::is_invocable_v); + static_assert(not std::is_invocable_v); + // pfn/std owning rvalue calls with F are hard errors, requiring separate compile-failure probes. + constexpr auto check = [] { + optional a(7); + return a.transform(F{}) == a && std::as_const(a).transform(F{}) == a; + }; + static_assert(check()); + CHECK(check()); + } + + SECTION("callable qualifiers") + { + using F = qualified_callback; + constexpr auto check = []() { + static_assert(std::is_same_v().transform(std::declval())), optional>); + static_assert(not extension || noexcept(std::declval().transform(std::declval()))); + static_assert(not noexcept(std::declval().transform(F{}))); + optional a(7); + F f; + return static_cast(a).transform(f) == *a && static_cast(a).transform(F{}) == *a + 1; + }; + static_assert(check.template operator() &>()); + static_assert(check.template operator() const &>()); + static_assert(check.template operator() &&>()); + static_assert(check.template operator() const &&>()); + CHECK(check.template operator() &>()); + CHECK(check.template operator() const &>()); + CHECK(check.template operator() &&>()); + CHECK(check.template operator() const &&>()); + } + SECTION("value") { using T = optional; @@ -1765,6 +1865,16 @@ TEST_CASE("optional", "[optional][polyfill]") auto r = a.transform([&x](int) -> int & { return x; }); static_assert(std::is_same_v>); CHECK(&*r == &x); + + constexpr auto check = [] { + optional owner(7); + auto projected = owner.transform(lvalue_callback{}); + int external = 9; + auto from_temporary = optional(1).transform([&external](int &&) noexcept -> int & { return external; }); + return projected && &*projected == &*owner && from_temporary && &*from_temporary == &external; + }; + static_assert(check()); + CHECK(check()); } #endif @@ -2765,6 +2875,17 @@ TEST_CASE("optional reference", "[optional_ref][polyfill]") auto same = [&x](int &v) -> optional { return &v == &x; }; CHECK(a.and_then(same).value()); + SECTION("temporary wrapper") + { + constexpr auto check = [] { + int value = 7; + auto r = T(value).and_then(lvalue_callback>{}); + return r && &*r == &value; + }; + static_assert(check()); + CHECK(check()); + } + SECTION("constexpr") { static_assert([] { @@ -2851,6 +2972,14 @@ TEST_CASE("optional reference", "[optional_ref][polyfill]") static_assert(std::is_same_v>); CHECK(&*r == &x); CHECK(not e.transform(fn).has_value()); + + constexpr auto check = [] { + int value = 7; + auto r = T(value).transform(lvalue_callback{}); + return r && &*r == &value; + }; + static_assert(check()); + CHECK(check()); } SECTION("constexpr") From c4322b84566304cb9e84edafe0cd27fe34da5c36 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 17 Oct 2026 19:40:54 +0100 Subject: [PATCH 2/5] Match reference optional monadic declarations to the draft Keep reference and_then's return deduction in its body so invalid invocations are hard errors. Spell reference transform's result and both or_else result types as specified, preserving their substitution rules, constraints and conditional noexcept. Add paired compile-failure controls for reference and_then and owning monadic cv/ref fallback. Guard extension-only noexcept assertions in the standard-library validation tests. Assisted-by: Codex:gpt-6 --- CHANGELOG.md | 6 ++ CONTRIBUTING.md | 2 +- TYPE_ALGEBRA.md | 4 +- include/pfn/optional.hpp | 12 +-- tests/CMakeLists.txt | 25 +++++++ tests/compile_fail/optional_monadic.cpp | 97 +++++++++++++++++++++++++ tests/pfn/optional.cpp | 31 ++++++-- 7 files changed, 159 insertions(+), 18 deletions(-) create mode 100644 tests/compile_fail/optional_monadic.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 645e1a39..2ab65f7e 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. +## Reference optional monadic declarations match the draft — 17 October 2026 + +`pfn::optional::and_then` uses body-deduced `auto`, so a non-invocable callback causes a hard error even in a dependent `requires`-expression. Previously its helper-based trailing return type treated that error as substitution failure. Reference `transform` retains substitution failure through the draft's explicit `optional>>` return type. Both specializations spell `or_else` with its fixed `optional` return type and keep its specified constraints. Valid calls retain conditional `noexcept`. + +Paired compile-failure tests cover reference `and_then` and owning `and_then`/`transform` cv/ref fallback. Each rejection has a normal-build positive control using the same callback fixture. + ## `pfn::optional` preserves the selected monadic overload — 17 October 2026 Owning `pfn::optional::transform` and `and_then` reject callbacks that cannot accept the selected payload cv/ref category, as the standard requires. Previously, substitution failure could remove the selected member and allow a different cv/ref overload to run. A callback that explicitly deleted its rvalue overload could consequently borrow from a temporary optional through its lvalue overload and leave a dangling reference. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7fedd001..1f64a1ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -158,7 +158,7 @@ Match the assertion to the tested property: * **Conditional Specifications**: Pair negative assertions with their positive converses (e.g., pair `static_assert(not noexcept(expr))` with a witness for which it is `noexcept`). This proves that the specification is conditional rather than unconditionally false. Apply the same positive-control discipline to negative constraint or viability probes. * **Substitution Contexts**: Ensure expressions in negative viability probes are dependent on a template parameter. A `requires`-expression over concrete types is not a substitution context, meaning an invalid requirement triggers a hard error rather than a SFINAE failure. Enclose the dependent probe in a generic lambda or a type-keyed concept to yield `false` on invalid substitution. * **Exception Fixtures**: Select exception-proving fixtures carefully. The library's `helper_t` implements a separate constructor for each value category, and its non-const-lvalue copy constructor is always `noexcept`. A `helper_t` configured to throw is therefore not throwing for every value category, which can hide incorrect `noexcept` specifications. Use a simple, local type if the test requires every relevant constructor to be potentially throwing. -* **Expected Failures**: The suite cannot test runtime crashes or compilation failures. Record these untested cases beside their positive controls. +* **Expected Failures**: Compile-must-fail tests build excluded object targets through CTest with `WILL_FAIL`. Pair each rejection with a normal-build positive control using the same fixture and compiler configuration. The suite cannot test runtime crashes; record these untested cases beside their positive controls. ## Client code diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index 9c63d393..3c01807f 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -1072,10 +1072,10 @@ 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 `|`. -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. - 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. +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. + ## Functional terminology For readers with a background in functional languages (like Haskell or OCaml), this table translates standard terminology to `libfn`'s C++ vocabulary: diff --git a/include/pfn/optional.hpp b/include/pfn/optional.hpp index 9a5312ac..a9e01b24 100644 --- a/include/pfn/optional.hpp +++ b/include/pfn/optional.hpp @@ -1288,17 +1288,15 @@ template class optional : private detail::_optional_base - constexpr auto or_else(F &&f) && // + constexpr optional or_else(F &&f) && // noexcept(noexcept(_base::_or_else(::std::move(*this), FWD(f)))) // extension - -> decltype(_base::_or_else(::std::move(*this), FWD(f))) requires(::std::invocable && ::std::move_constructible) { return _base::_or_else(::std::move(*this), FWD(f)); } template - constexpr auto or_else(F &&f) const & // + constexpr optional or_else(F &&f) const & // noexcept(noexcept(_base::_or_else(*this, FWD(f)))) // extension - -> decltype(_base::_or_else(*this, FWD(f))) requires(::std::invocable && ::std::copy_constructible) { return _base::_or_else(*this, FWD(f)); @@ -1450,21 +1448,19 @@ template class optional : private detail::_optional_base constexpr auto and_then(F &&f) const // noexcept(noexcept(_base::_and_then(*this, FWD(f)))) // extension - -> decltype(_base::_and_then(*this, FWD(f))) { return _base::_and_then(*this, FWD(f)); } template constexpr auto transform(F &&f) const // noexcept(noexcept(_base::_transform(*this, FWD(f)))) // extension - -> decltype(_base::_transform(*this, FWD(f))) + -> optional<::std::remove_cv_t<::std::invoke_result_t>> { return _base::_transform(*this, FWD(f)); } template - constexpr auto or_else(F &&f) const // + constexpr optional or_else(F &&f) const // noexcept(noexcept(_base::_or_else(*this, FWD(f)))) // extension - -> decltype(_base::_or_else(*this, FWD(f))) requires ::std::invocable { return _base::_or_else(*this, FWD(f)); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6d8c270a..9b3a1d63 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -75,6 +75,31 @@ foreach(mode 20 23 26) continue() endif() + foreach(scenario RANGE 1 9) + set(rejection_target "tests_optional_monadic_reject_${scenario}_cxx${mode}") + set(control_target "tests_optional_monadic_control_${scenario}_cxx${mode}") + add_library("${rejection_target}" OBJECT EXCLUDE_FROM_ALL compile_fail/optional_monadic.cpp) + add_library("${control_target}" OBJECT compile_fail/optional_monadic.cpp) + foreach(target IN ITEMS "${rejection_target}" "${control_target}") + target_link_libraries("${target}" PRIVATE include_pfn) + append_compilation_options("${target}" WARNINGS) + set_property(TARGET "${target}" PROPERTY CXX_STANDARD "${mode}") + target_compile_definitions("${target}" PRIVATE OPTIONAL_MONADIC_CASE=${scenario}) + endforeach() + target_compile_definitions("${rejection_target}" PRIVATE OPTIONAL_MONADIC_POSITIVE=0) + target_compile_definitions("${control_target}" PRIVATE OPTIONAL_MONADIC_POSITIVE=1) + add_dependencies("tests" "${control_target}") + add_dependencies("cxx${mode}" "${control_target}") + add_test(NAME "${rejection_target}" + COMMAND "${CMAKE_COMMAND}" --build "${CMAKE_BINARY_DIR}" + --config "$" --target "${rejection_target}") + set_tests_properties("${rejection_target}" PROPERTIES + WILL_FAIL TRUE RUN_SERIAL TRUE + LABELS "tests_pfn;cxx${mode};pfn_optional;compile_fail") + unset(rejection_target) + unset(control_target) + endforeach() + foreach(source IN ITEMS ${TESTS_PFN_SOURCES}) string(REGEX REPLACE "^(pfn)/(detail|)/?([^\.]+)\.cpp$" "\\1_\\2\\3" root_name ${source}) set(target "tests_${root_name}_cxx${mode}") diff --git a/tests/compile_fail/optional_monadic.cpp b/tests/compile_fail/optional_monadic.cpp new file mode 100644 index 00000000..cf9dfc8b --- /dev/null +++ b/tests/compile_fail/optional_monadic.cpp @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Bronek Kozicki +// +// Distributed under the ISC License. See accompanying file LICENSE.md +// or copy at https://opensource.org/licenses/ISC + +#include + +#include +#include + +#if OPTIONAL_MONADIC_CASE == 9 + +struct callback { +#if OPTIONAL_MONADIC_POSITIVE + pfn::optional operator()(int &) const { return {}; } +#endif +}; + +static_assert(std::is_invocable_v == bool(OPTIONAL_MONADIC_POSITIVE)); + +template +concept can_bind = requires(pfn::optional value, F f) { value.and_then(f); }; + +#if OPTIONAL_MONADIC_POSITIVE +static_assert(can_bind); +#else +// Invalid invocation is a hard error even inside a dependent requires-expression. +static_assert(!can_bind); +#endif + +#else + +#if OPTIONAL_MONADIC_CASE <= 4 +using result_type = int; +#define OPTIONAL_MONADIC_CALL transform +#else +using result_type = pfn::optional; +#define OPTIONAL_MONADIC_CALL and_then +#endif + +#define OPTIONAL_MONADIC_DIRECTION ((OPTIONAL_MONADIC_CASE - 1) % 4 + 1) + +struct callback { +#if OPTIONAL_MONADIC_DIRECTION == 1 && !OPTIONAL_MONADIC_POSITIVE + result_type operator()(int &) const = delete; +#else + result_type operator()(int &) const { return {}; } +#endif + result_type operator()(int const &) const { return {}; } +#if (OPTIONAL_MONADIC_DIRECTION == 2 || OPTIONAL_MONADIC_DIRECTION == 3) && !OPTIONAL_MONADIC_POSITIVE + result_type operator()(int &&) const = delete; +#else + result_type operator()(int &&) const { return {}; } +#endif +#if (OPTIONAL_MONADIC_DIRECTION == 3 || OPTIONAL_MONADIC_DIRECTION == 4) && !OPTIONAL_MONADIC_POSITIVE + result_type operator()(int const &&) const = delete; +#else + result_type operator()(int const &&) const { return {}; } +#endif +}; + +#if OPTIONAL_MONADIC_DIRECTION == 1 +using selected_arg = int &; +using fallback_arg = int const &; +#elif OPTIONAL_MONADIC_DIRECTION == 2 +using selected_arg = int &&; +using fallback_arg = int const &&; +#elif OPTIONAL_MONADIC_DIRECTION == 3 +using selected_arg = int &&; +using fallback_arg = int const &; +#else +using selected_arg = int const &&; +using fallback_arg = int const &; +#endif + +static_assert(std::is_invocable_v); +static_assert(std::is_invocable_v == bool(OPTIONAL_MONADIC_POSITIVE)); +#if OPTIONAL_MONADIC_DIRECTION == 3 +static_assert(std::is_invocable_v == bool(OPTIONAL_MONADIC_POSITIVE)); +#endif + +// The selected overload must reject the call instead of falling back to another cv/ref category. +void probe_optional_monadic() +{ +#if OPTIONAL_MONADIC_DIRECTION == 1 + pfn::optional value; + (void)value.OPTIONAL_MONADIC_CALL(callback{}); +#elif OPTIONAL_MONADIC_DIRECTION == 4 + pfn::optional const value; + (void)std::move(value).OPTIONAL_MONADIC_CALL(callback{}); +#else + pfn::optional value; + (void)std::move(value).OPTIONAL_MONADIC_CALL(callback{}); +#endif +} + +#endif diff --git a/tests/pfn/optional.cpp b/tests/pfn/optional.cpp index 4ae91dfa..112db4eb 100644 --- a/tests/pfn/optional.cpp +++ b/tests/pfn/optional.cpp @@ -89,12 +89,15 @@ concept has_spaceship = requires(L const &l, R const &r) { l <=> r; }; template concept has_or_else = requires(O &&o, F &&f) { std::forward(o).or_else(std::forward(f)); }; +template +concept has_transform = requires(O &&o, F &&f) { std::forward(o).transform(std::forward(f)); }; + template struct lvalue_callback { constexpr R operator()(int const &value) const noexcept { return value; } R operator()(int const &&) const = delete; }; -template struct qualified_callback { +template struct ref_qualified_callback { constexpr R operator()(int value) & noexcept { return value; } constexpr R operator()(int value) && { return value + 1; } }; @@ -1560,7 +1563,7 @@ TEST_CASE("optional", "[optional][polyfill]") static_assert(std::is_invocable_v); static_assert(not std::is_invocable_v); static_assert(not std::is_invocable_v); - // pfn/std owning rvalue calls with F are hard errors, requiring separate compile-failure probes. + // pfn/std owning rvalue calls with F are hard errors, covered in compile_fail/optional_monadic.cpp. constexpr auto check = [] { optional a(7); return a.and_then(F{}) == a && std::as_const(a).and_then(F{}) == a; @@ -1571,11 +1574,11 @@ TEST_CASE("optional", "[optional][polyfill]") SECTION("callable qualifiers") { - using F = qualified_callback>; + using F = ref_qualified_callback>; constexpr auto check = []() { static_assert(std::is_same_v().and_then(std::declval())), optional>); static_assert(not extension || noexcept(std::declval().and_then(std::declval()))); - static_assert(not noexcept(std::declval().and_then(F{}))); + static_assert(not extension || not noexcept(std::declval().and_then(F{}))); optional a(7); F f; return static_cast(a).and_then(f) == *a && static_cast(a).and_then(F{}) == *a + 1; @@ -1775,7 +1778,7 @@ TEST_CASE("optional", "[optional][polyfill]") static_assert(std::is_invocable_v); static_assert(not std::is_invocable_v); static_assert(not std::is_invocable_v); - // pfn/std owning rvalue calls with F are hard errors, requiring separate compile-failure probes. + // pfn/std owning rvalue calls with F are hard errors, covered in compile_fail/optional_monadic.cpp. constexpr auto check = [] { optional a(7); return a.transform(F{}) == a && std::as_const(a).transform(F{}) == a; @@ -1786,11 +1789,11 @@ TEST_CASE("optional", "[optional][polyfill]") SECTION("callable qualifiers") { - using F = qualified_callback; + using F = ref_qualified_callback; constexpr auto check = []() { static_assert(std::is_same_v().transform(std::declval())), optional>); static_assert(not extension || noexcept(std::declval().transform(std::declval()))); - static_assert(not noexcept(std::declval().transform(F{}))); + static_assert(not extension || not noexcept(std::declval().transform(F{}))); optional a(7); F f; return static_cast(a).transform(f) == *a && static_cast(a).transform(F{}) == *a + 1; @@ -2942,6 +2945,20 @@ TEST_CASE("optional reference", "[optional_ref][polyfill]") T const a(std::in_place, x); T const e(std::nullopt); + SECTION("substitution") + { + constexpr auto check = [] { + static_assert(has_transform int & { return v; })>); + static_assert(not has_transform); + static_assert(not has_transform); + }; + check.template operator()(); + check.template operator()(); + check.template operator()(); + check.template operator()(); + SUCCEED(); + } + SECTION("to value") { constexpr auto fn = [](int &v) { return v * 3; }; From 6ed7961c0954833f6ccd67a768c918eb4b72ad0e Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 17 Oct 2026 19:50:10 +0100 Subject: [PATCH 3/5] Validate rejection fixtures in successful builds Compile each negative callback fixture without its rejected call so an incorrect invocability assertion fails a normal build instead of passing a compile-must-fail test. Assisted-by: Codex:gpt-6 --- CONTRIBUTING.md | 2 +- tests/CMakeLists.txt | 11 ++++++++--- tests/compile_fail/optional_monadic.cpp | 4 ++++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1f64a1ab..dddbc4fe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -158,7 +158,7 @@ Match the assertion to the tested property: * **Conditional Specifications**: Pair negative assertions with their positive converses (e.g., pair `static_assert(not noexcept(expr))` with a witness for which it is `noexcept`). This proves that the specification is conditional rather than unconditionally false. Apply the same positive-control discipline to negative constraint or viability probes. * **Substitution Contexts**: Ensure expressions in negative viability probes are dependent on a template parameter. A `requires`-expression over concrete types is not a substitution context, meaning an invalid requirement triggers a hard error rather than a SFINAE failure. Enclose the dependent probe in a generic lambda or a type-keyed concept to yield `false` on invalid substitution. * **Exception Fixtures**: Select exception-proving fixtures carefully. The library's `helper_t` implements a separate constructor for each value category, and its non-const-lvalue copy constructor is always `noexcept`. A `helper_t` configured to throw is therefore not throwing for every value category, which can hide incorrect `noexcept` specifications. Use a simple, local type if the test requires every relevant constructor to be potentially throwing. -* **Expected Failures**: Compile-must-fail tests build excluded object targets through CTest with `WILL_FAIL`. Pair each rejection with a normal-build positive control using the same fixture and compiler configuration. The suite cannot test runtime crashes; record these untested cases beside their positive controls. +* **Expected Failures**: Compile-must-fail tests build excluded object targets through CTest with `WILL_FAIL`. Pair each rejection with a normal-build positive control using the same fixture and compiler configuration. Check rejection-fixture assumptions in a build that must succeed, with the rejected call omitted. The suite cannot test runtime crashes; record these untested cases beside their positive controls. ## Client code diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9b3a1d63..a61ef118 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -78,9 +78,11 @@ foreach(mode 20 23 26) foreach(scenario RANGE 1 9) set(rejection_target "tests_optional_monadic_reject_${scenario}_cxx${mode}") set(control_target "tests_optional_monadic_control_${scenario}_cxx${mode}") + set(fixture_target "tests_optional_monadic_fixture_${scenario}_cxx${mode}") add_library("${rejection_target}" OBJECT EXCLUDE_FROM_ALL compile_fail/optional_monadic.cpp) add_library("${control_target}" OBJECT compile_fail/optional_monadic.cpp) - foreach(target IN ITEMS "${rejection_target}" "${control_target}") + add_library("${fixture_target}" OBJECT compile_fail/optional_monadic.cpp) + foreach(target IN ITEMS "${rejection_target}" "${control_target}" "${fixture_target}") target_link_libraries("${target}" PRIVATE include_pfn) append_compilation_options("${target}" WARNINGS) set_property(TARGET "${target}" PROPERTY CXX_STANDARD "${mode}") @@ -88,8 +90,10 @@ foreach(mode 20 23 26) endforeach() target_compile_definitions("${rejection_target}" PRIVATE OPTIONAL_MONADIC_POSITIVE=0) target_compile_definitions("${control_target}" PRIVATE OPTIONAL_MONADIC_POSITIVE=1) - add_dependencies("tests" "${control_target}") - add_dependencies("cxx${mode}" "${control_target}") + target_compile_definitions("${fixture_target}" PRIVATE + OPTIONAL_MONADIC_POSITIVE=0 OPTIONAL_MONADIC_FIXTURE_ONLY=1) + add_dependencies("tests" "${control_target}" "${fixture_target}") + add_dependencies("cxx${mode}" "${control_target}" "${fixture_target}") add_test(NAME "${rejection_target}" COMMAND "${CMAKE_COMMAND}" --build "${CMAKE_BINARY_DIR}" --config "$" --target "${rejection_target}") @@ -98,6 +102,7 @@ foreach(mode 20 23 26) LABELS "tests_pfn;cxx${mode};pfn_optional;compile_fail") unset(rejection_target) unset(control_target) + unset(fixture_target) endforeach() foreach(source IN ITEMS ${TESTS_PFN_SOURCES}) diff --git a/tests/compile_fail/optional_monadic.cpp b/tests/compile_fail/optional_monadic.cpp index cf9dfc8b..9e29b73d 100644 --- a/tests/compile_fail/optional_monadic.cpp +++ b/tests/compile_fail/optional_monadic.cpp @@ -18,6 +18,7 @@ struct callback { static_assert(std::is_invocable_v == bool(OPTIONAL_MONADIC_POSITIVE)); +#if !OPTIONAL_MONADIC_FIXTURE_ONLY template concept can_bind = requires(pfn::optional value, F f) { value.and_then(f); }; @@ -27,6 +28,7 @@ static_assert(can_bind); // Invalid invocation is a hard error even inside a dependent requires-expression. static_assert(!can_bind); #endif +#endif #else @@ -79,6 +81,7 @@ static_assert(std::is_invocable_v == bool(OPTIONAL_MONAD static_assert(std::is_invocable_v == bool(OPTIONAL_MONADIC_POSITIVE)); #endif +#if !OPTIONAL_MONADIC_FIXTURE_ONLY // The selected overload must reject the call instead of falling back to another cv/ref category. void probe_optional_monadic() { @@ -93,5 +96,6 @@ void probe_optional_monadic() (void)std::move(value).OPTIONAL_MONADIC_CALL(callback{}); #endif } +#endif #endif From 51b7c34f5e1180746e4f14e2e38295e466dbcdc2 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 17 Oct 2026 20:01:32 +0100 Subject: [PATCH 4/5] Match object expected monadic constraints to the draft Expose only the unchanged-side constructibility constraints on the four monadic families. Deduce returns in the body so an invalid callback cannot remove the selected overload or become false in a requires-expression. Preserve valid copy fallback and cover rejection with paired compile-fail controls and independently checked callback fixtures. Leave conditional noexcept unchanged. Assisted-by: Codex:gpt-6 --- CHANGELOG.md | 6 + TYPE_ALGEBRA.md | 2 + include/pfn/expected.hpp | 33 ++--- tests/CMakeLists.txt | 32 +++++ tests/compile_fail/expected_monadic.cpp | 119 +++++++++++++++++ tests/pfn/expected.cpp | 164 ++++++++++++++++++++++++ 6 files changed, 340 insertions(+), 16 deletions(-) create mode 100644 tests/compile_fail/expected_monadic.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ab65f7e..89e6df4d 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 overloads follow the draft — 17 October 2026 + +The non-void `pfn::expected` monadic members use body-deduced `auto` returns and expose the draft's constructibility constraints. Previously helper-based trailing returns also removed overloads for invalid callbacks, allowing a different cv/ref callback to run or a dependent `requires`-expression to return false. Invalid callbacks now cause hard errors after overload selection. Overloads whose unchanged side cannot be constructed still drop out, preserving valid copy fallback. + +Compile-failure tests cover all four operations and cv/ref fallback directions, with positive controls and independently checked rejection fixtures. Runtime and constant-evaluation tests preserve constructibility-based fallback. Conditional `noexcept` is unchanged. + ## Reference optional monadic declarations match the draft — 17 October 2026 `pfn::optional::and_then` uses body-deduced `auto`, so a non-invocable callback causes a hard error even in a dependent `requires`-expression. Previously its helper-based trailing return type treated that error as substitution failure. Reference `transform` retains substitution failure through the draft's explicit `optional>>` return type. Both specializations spell `or_else` with its fixed `optional` return type and keep its specified constraints. Valid calls retain conditional `noexcept`. diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index 3c01807f..90b45cce 100644 --- a/TYPE_ALGEBRA.md +++ b/TYPE_ALGEBRA.md @@ -1076,6 +1076,8 @@ 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 non-void `pfn::expected`, the monadic members constrain construction of the unchanged side: `E` for `and_then` and `transform`, `T` for `or_else` and `transform_error`. These constraints can remove an overload and permit copying through another cv/ref overload. Once an overload is selected, an invalid callback is a hard error, including in a dependent `requires`-expression. + ## Functional terminology For readers with a background in functional languages (like Haskell or OCaml), this table translates standard terminology to `libfn`'s C++ vocabulary: diff --git a/include/pfn/expected.hpp b/include/pfn/expected.hpp index 78d3af32..3e4c6930 100644 --- a/include/pfn/expected.hpp +++ b/include/pfn/expected.hpp @@ -1415,31 +1415,32 @@ template class expected : private detail::_expected_base constexpr auto and_then(F &&f) & // noexcept(noexcept(_base::_and_then(*this, FWD(f)))) // extension - -> decltype(_base::_and_then(*this, FWD(f))) + requires ::std::is_constructible_v { return _base::_and_then(*this, FWD(f)); } template constexpr auto and_then(F &&f) && // noexcept(noexcept(_base::_and_then(::std::move(*this), FWD(f)))) // extension - -> decltype(_base::_and_then(::std::move(*this), FWD(f))) + requires ::std::is_constructible_v { return _base::_and_then(::std::move(*this), FWD(f)); } template constexpr auto and_then(F &&f) const & // noexcept(noexcept(_base::_and_then(*this, FWD(f)))) // extension - -> decltype(_base::_and_then(*this, FWD(f))) + requires ::std::is_constructible_v { return _base::_and_then(*this, FWD(f)); } template constexpr auto and_then(F &&f) const && // noexcept(noexcept(_base::_and_then(::std::move(*this), FWD(f)))) // extension - -> decltype(_base::_and_then(::std::move(*this), FWD(f))) + requires ::std::is_constructible_v { return _base::_and_then(::std::move(*this), FWD(f)); } @@ -1447,28 +1448,28 @@ template class expected : private detail::_expected_base constexpr auto or_else(F &&f) & // noexcept(noexcept(_base::_or_else(*this, FWD(f)))) // extension - -> decltype(_base::_or_else(*this, FWD(f))) + requires ::std::is_constructible_v { return _base::_or_else(*this, FWD(f)); } template constexpr auto or_else(F &&f) && // noexcept(noexcept(_base::_or_else(::std::move(*this), FWD(f)))) // extension - -> decltype(_base::_or_else(::std::move(*this), FWD(f))) + requires ::std::is_constructible_v { return _base::_or_else(::std::move(*this), FWD(f)); } template constexpr auto or_else(F &&f) const & // noexcept(noexcept(_base::_or_else(*this, FWD(f)))) // extension - -> decltype(_base::_or_else(*this, FWD(f))) + requires ::std::is_constructible_v { return _base::_or_else(*this, FWD(f)); } template constexpr auto or_else(F &&f) const && // noexcept(noexcept(_base::_or_else(::std::move(*this), FWD(f)))) // extension - -> decltype(_base::_or_else(::std::move(*this), FWD(f))) + requires ::std::is_constructible_v { return _base::_or_else(::std::move(*this), FWD(f)); } @@ -1476,28 +1477,28 @@ template class expected : private detail::_expected_base constexpr auto transform(F &&f) & // noexcept(noexcept(_base::_transform(*this, FWD(f)))) // extension - -> decltype(_base::_transform(*this, FWD(f))) + requires ::std::is_constructible_v { return _base::_transform(*this, FWD(f)); } template constexpr auto transform(F &&f) && // noexcept(noexcept(_base::_transform(::std::move(*this), FWD(f)))) // extension - -> decltype(_base::_transform(::std::move(*this), FWD(f))) + requires ::std::is_constructible_v { return _base::_transform(::std::move(*this), FWD(f)); } template constexpr auto transform(F &&f) const & // noexcept(noexcept(_base::_transform(*this, FWD(f)))) // extension - -> decltype(_base::_transform(*this, FWD(f))) + requires ::std::is_constructible_v { return _base::_transform(*this, FWD(f)); } template constexpr auto transform(F &&f) const && // noexcept(noexcept(_base::_transform(::std::move(*this), FWD(f)))) // extension - -> decltype(_base::_transform(::std::move(*this), FWD(f))) + requires ::std::is_constructible_v { return _base::_transform(::std::move(*this), FWD(f)); } @@ -1505,28 +1506,28 @@ template class expected : private detail::_expected_base constexpr auto transform_error(F &&f) & // noexcept(noexcept(_base::_transform_error(*this, FWD(f)))) // extension - -> decltype(_base::_transform_error(*this, FWD(f))) + requires ::std::is_constructible_v { return _base::_transform_error(*this, FWD(f)); } template constexpr auto transform_error(F &&f) && // noexcept(noexcept(_base::_transform_error(::std::move(*this), FWD(f)))) // extension - -> decltype(_base::_transform_error(::std::move(*this), FWD(f))) + requires ::std::is_constructible_v { return _base::_transform_error(::std::move(*this), FWD(f)); } template constexpr auto transform_error(F &&f) const & // noexcept(noexcept(_base::_transform_error(*this, FWD(f)))) // extension - -> decltype(_base::_transform_error(*this, FWD(f))) + requires ::std::is_constructible_v { return _base::_transform_error(*this, FWD(f)); } template constexpr auto transform_error(F &&f) const && // noexcept(noexcept(_base::_transform_error(::std::move(*this), FWD(f)))) // extension - -> decltype(_base::_transform_error(::std::move(*this), FWD(f))) + requires ::std::is_constructible_v { return _base::_transform_error(::std::move(*this), FWD(f)); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a61ef118..b3673782 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -105,6 +105,38 @@ foreach(mode 20 23 26) unset(fixture_target) endforeach() + foreach(operation RANGE 0 3) + foreach(direction RANGE 0 4) + set(test_stem "tests_expected_monadic_object_${operation}_${direction}_cxx${mode}") + foreach(variant IN ITEMS reject control fixture) + set(target "${test_stem}_${variant}") + if(variant STREQUAL "reject") + add_library("${target}" OBJECT EXCLUDE_FROM_ALL compile_fail/expected_monadic.cpp) + else() + add_library("${target}" OBJECT compile_fail/expected_monadic.cpp) + add_dependencies("tests" "${target}") + add_dependencies("cxx${mode}" "${target}") + endif() + target_link_libraries("${target}" PRIVATE include_pfn) + append_compilation_options("${target}" WARNINGS) + set_property(TARGET "${target}" PROPERTY CXX_STANDARD "${mode}") + target_compile_definitions("${target}" PRIVATE + EXPECTED_MONADIC_VOID=0 EXPECTED_MONADIC_OP=${operation} + EXPECTED_MONADIC_DIRECTION=${direction} + EXPECTED_MONADIC_POSITIVE=$ + EXPECTED_MONADIC_FIXTURE_ONLY=$) + endforeach() + add_test(NAME "${test_stem}_reject" + COMMAND "${CMAKE_COMMAND}" --build "${CMAKE_BINARY_DIR}" + --config "$" --target "${test_stem}_reject") + set_tests_properties("${test_stem}_reject" PROPERTIES + WILL_FAIL TRUE RUN_SERIAL TRUE + LABELS "tests_pfn;cxx${mode};pfn_expected;compile_fail") + unset(target) + unset(test_stem) + endforeach() + endforeach() + foreach(source IN ITEMS ${TESTS_PFN_SOURCES}) string(REGEX REPLACE "^(pfn)/(detail|)/?([^\.]+)\.cpp$" "\\1_\\2\\3" root_name ${source}) set(target "tests_${root_name}_cxx${mode}") diff --git a/tests/compile_fail/expected_monadic.cpp b/tests/compile_fail/expected_monadic.cpp new file mode 100644 index 00000000..1c8e383b --- /dev/null +++ b/tests/compile_fail/expected_monadic.cpp @@ -0,0 +1,119 @@ +// Copyright (c) 2026 Bronek Kozicki +// +// Distributed under the ISC License. See accompanying file LICENSE.md +// or copy at https://opensource.org/licenses/ISC + +#include + +#include +#include + +#if EXPECTED_MONADIC_VOID +using value_type = void; +#else +using value_type = int; +#endif +using subject_type = pfn::expected; + +#if EXPECTED_MONADIC_OP == 0 +using result_type = pfn::expected; +#define EXPECTED_MONADIC_CALL and_then +#elif EXPECTED_MONADIC_OP == 1 +using result_type = int; +#define EXPECTED_MONADIC_CALL transform +#elif EXPECTED_MONADIC_OP == 2 +using result_type = pfn::expected; +#define EXPECTED_MONADIC_CALL or_else +#else +using result_type = int; +#define EXPECTED_MONADIC_CALL transform_error +#endif + +#if EXPECTED_MONADIC_DIRECTION == 0 + +struct callback { +#if EXPECTED_MONADIC_POSITIVE +#if EXPECTED_MONADIC_VOID && EXPECTED_MONADIC_OP <= 1 + result_type operator()() const { return {}; } +#else + result_type operator()(int &) const { return {}; } +#endif +#endif +}; + +#if EXPECTED_MONADIC_VOID && EXPECTED_MONADIC_OP <= 1 +static_assert(std::is_invocable_v == bool(EXPECTED_MONADIC_POSITIVE)); +#else +static_assert(std::is_invocable_v == bool(EXPECTED_MONADIC_POSITIVE)); +static_assert(!std::is_invocable_v); +static_assert(!std::is_invocable_v); +static_assert(!std::is_invocable_v); +#endif + +#if !EXPECTED_MONADIC_FIXTURE_ONLY +template +concept can_call = requires(subject_type value, F f) { value.EXPECTED_MONADIC_CALL(f); }; + +// Invalid invocation is a hard error even inside a dependent requires-expression. +static_assert(can_call == bool(EXPECTED_MONADIC_POSITIVE)); +#endif + +#else + +struct callback { +#if EXPECTED_MONADIC_DIRECTION == 1 && !EXPECTED_MONADIC_POSITIVE + result_type operator()(int &) const = delete; +#else + result_type operator()(int &) const { return {}; } +#endif + result_type operator()(int const &) const { return {}; } +#if (EXPECTED_MONADIC_DIRECTION == 2 || EXPECTED_MONADIC_DIRECTION == 3) && !EXPECTED_MONADIC_POSITIVE + result_type operator()(int &&) const = delete; +#else + result_type operator()(int &&) const { return {}; } +#endif +#if (EXPECTED_MONADIC_DIRECTION == 3 || EXPECTED_MONADIC_DIRECTION == 4) && !EXPECTED_MONADIC_POSITIVE + result_type operator()(int const &&) const = delete; +#else + result_type operator()(int const &&) const { return {}; } +#endif +}; + +#if EXPECTED_MONADIC_DIRECTION == 1 +using selected_arg = int &; +using fallback_arg = int const &; +#elif EXPECTED_MONADIC_DIRECTION == 2 +using selected_arg = int &&; +using fallback_arg = int const &&; +#elif EXPECTED_MONADIC_DIRECTION == 3 +using selected_arg = int &&; +using fallback_arg = int const &; +#else +using selected_arg = int const &&; +using fallback_arg = int const &; +#endif + +static_assert(std::is_invocable_v); +static_assert(std::is_invocable_v == bool(EXPECTED_MONADIC_POSITIVE)); +#if EXPECTED_MONADIC_DIRECTION == 3 +static_assert(std::is_invocable_v == bool(EXPECTED_MONADIC_POSITIVE)); +#endif + +#if !EXPECTED_MONADIC_FIXTURE_ONLY +// The selected overload must reject the call instead of falling back to another cv/ref category. +void probe_expected_monadic() +{ +#if EXPECTED_MONADIC_DIRECTION == 1 + subject_type value; + (void)value.EXPECTED_MONADIC_CALL(callback{}); +#elif EXPECTED_MONADIC_DIRECTION == 4 + subject_type const value; + (void)std::move(value).EXPECTED_MONADIC_CALL(callback{}); +#else + subject_type value; + (void)std::move(value).EXPECTED_MONADIC_CALL(callback{}); +#endif +} +#endif + +#endif diff --git a/tests/pfn/expected.cpp b/tests/pfn/expected.cpp index 702625b5..ab1f620a 100644 --- a/tests/pfn/expected.cpp +++ b/tests/pfn/expected.cpp @@ -40,6 +40,38 @@ struct greedy_t { template constexpr greedy_t(U &&) noexcept {} }; +namespace { + +struct monadic_copy_only { + int value; + constexpr explicit monadic_copy_only(int v = 0) noexcept : value(v) {} + constexpr monadic_copy_only(monadic_copy_only const &) noexcept = default; + monadic_copy_only(monadic_copy_only &&) = delete; + monadic_copy_only(monadic_copy_only const &&) = delete; +}; + +struct monadic_move_only { + monadic_move_only() = default; + monadic_move_only(monadic_move_only const &) = delete; + monadic_move_only(monadic_move_only &&) = default; +}; + +template struct monadic_lvalue_callback { + constexpr R operator()(int const &) const noexcept { return R{}; } + R operator()(int const &&) const = delete; +}; + +template +concept has_and_then = requires(O &&o, F &&f) { std::forward(o).and_then(std::forward(f)); }; +template +concept has_or_else = requires(O &&o, F &&f) { std::forward(o).or_else(std::forward(f)); }; +template +concept has_transform = requires(O &&o, F &&f) { std::forward(o).transform(std::forward(f)); }; +template +concept has_transform_error = requires(O &&o, F &&f) { std::forward(o).transform_error(std::forward(f)); }; + +} // namespace + TEST_CASE("bad_expected_access", "[expected][polyfill][bad_expected_access]") { #ifndef PFN_TEST_VALIDATION @@ -3377,6 +3409,39 @@ TEST_CASE("expected non void", "[expected][polyfill]") { SECTION("and_then") { + SECTION("constructibility constraints") + { + using O = expected; + using F = decltype([](int) -> expected { return {}; }); + static_assert(not has_and_then); + static_assert(not has_and_then); + static_assert(not has_and_then); + static_assert(has_and_then); + static_assert(not has_and_then); + SUCCEED(); + } + + SECTION("copy fallback") + { + using O = expected; + using F = monadic_lvalue_callback>; + static_assert(std::is_constructible_v); + static_assert(not std::is_constructible_v); + static_assert(not std::is_constructible_v); + static_assert(std::is_invocable_v); + static_assert(not std::is_invocable_v); + static_assert(not std::is_invocable_v); + constexpr auto check = [] { + O a(7); + O e(unexpect, 9); + auto value = std::move(a).and_then(F{}); + auto error = std::move(e).and_then(F{}); + return value.has_value() && not error.has_value() && *value == 0 && error.error().value == 9; + }; + static_assert(check()); + CHECK(check()); + } + SECTION("value") { using T = expected; @@ -3475,6 +3540,39 @@ TEST_CASE("expected non void", "[expected][polyfill]") SECTION("or_else") { + SECTION("constructibility constraints") + { + using O = expected; + using F = decltype([](int) -> expected { return {}; }); + static_assert(not has_or_else); + static_assert(not has_or_else); + static_assert(not has_or_else); + static_assert(has_or_else); + static_assert(not has_or_else); + SUCCEED(); + } + + SECTION("copy fallback") + { + using O = expected; + using F = monadic_lvalue_callback>; + static_assert(std::is_constructible_v); + static_assert(not std::is_constructible_v); + static_assert(not std::is_constructible_v); + static_assert(std::is_invocable_v); + static_assert(not std::is_invocable_v); + static_assert(not std::is_invocable_v); + constexpr auto check = [] { + O a(std::in_place, 7); + O e(unexpect, 9); + auto value = std::move(a).or_else(F{}); + auto error = std::move(e).or_else(F{}); + return value.has_value() && error.has_value() && value->value == 7 && error->value == 0; + }; + static_assert(check()); + CHECK(check()); + } + SECTION("error") { using T = expected; @@ -3581,6 +3679,39 @@ TEST_CASE("expected non void", "[expected][polyfill]") SECTION("transform") { + SECTION("constructibility constraints") + { + using O = expected; + using F = decltype([](int) -> int { return {}; }); + static_assert(not has_transform); + static_assert(not has_transform); + static_assert(not has_transform); + static_assert(has_transform); + static_assert(not has_transform); + SUCCEED(); + } + + SECTION("copy fallback") + { + using O = expected; + using F = monadic_lvalue_callback; + static_assert(std::is_constructible_v); + static_assert(not std::is_constructible_v); + static_assert(not std::is_constructible_v); + static_assert(std::is_invocable_v); + static_assert(not std::is_invocable_v); + static_assert(not std::is_invocable_v); + constexpr auto check = [] { + O a(7); + O e(unexpect, 9); + auto value = std::move(a).transform(F{}); + auto error = std::move(e).transform(F{}); + return value.has_value() && not error.has_value() && *value == 0 && error.error().value == 9; + }; + static_assert(check()); + CHECK(check()); + } + SECTION("value") { using T = expected; @@ -3698,6 +3829,39 @@ TEST_CASE("expected non void", "[expected][polyfill]") SECTION("transform_error") { + SECTION("constructibility constraints") + { + using O = expected; + using F = decltype([](int) -> int { return {}; }); + static_assert(not has_transform_error); + static_assert(not has_transform_error); + static_assert(not has_transform_error); + static_assert(has_transform_error); + static_assert(not has_transform_error); + SUCCEED(); + } + + SECTION("copy fallback") + { + using O = expected; + using F = monadic_lvalue_callback; + static_assert(std::is_constructible_v); + static_assert(not std::is_constructible_v); + static_assert(not std::is_constructible_v); + static_assert(std::is_invocable_v); + static_assert(not std::is_invocable_v); + static_assert(not std::is_invocable_v); + constexpr auto check = [] { + O a(std::in_place, 7); + O e(unexpect, 9); + auto value = std::move(a).transform_error(F{}); + auto error = std::move(e).transform_error(F{}); + return value.has_value() && not error.has_value() && value->value == 7 && error.error() == 0; + }; + static_assert(check()); + CHECK(check()); + } + SECTION("error") { using T = expected; From efe155cdd60c128598f19f489d84f032e705e00d Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 17 Oct 2026 20:08:26 +0100 Subject: [PATCH 5/5] Match void expected monadic constraints to the draft Keep error constructibility constraints on and_then and transform while leaving or_else and transform_error unconstrained. Body-deduced returns make invalid callbacks hard errors without removing valid copy fallback. Cover nullary callback rejection, error callback cv/ref fallback, and operations on immovable errors. Leave conditional noexcept unchanged. Assisted-by: Codex:gpt-6 --- CHANGELOG.md | 6 +++ TYPE_ALGEBRA.md | 2 +- include/pfn/expected.hpp | 25 ++++----- tests/CMakeLists.txt | 59 +++++++++++---------- tests/pfn/expected.cpp | 111 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 159 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89e6df4d..c856aa58 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 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. + +Compile-failure tests cover nullary callback detection and error callback cv/ref fallback. Preservation tests cover valid copying and error transformations with an immovable original error, including all four source cv/ref categories. Conditional `noexcept` is unchanged. + ## Object expected monadic overloads follow the draft — 17 October 2026 The non-void `pfn::expected` monadic members use body-deduced `auto` returns and expose the draft's constructibility constraints. Previously helper-based trailing returns also removed overloads for invalid callbacks, allowing a different cv/ref callback to run or a dependent `requires`-expression to return false. Invalid callbacks now cause hard errors after overload selection. Overloads whose unchanged side cannot be constructed still drop out, preserving valid copy fallback. diff --git a/TYPE_ALGEBRA.md b/TYPE_ALGEBRA.md index 90b45cce..b6b4ec83 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 non-void `pfn::expected`, the monadic members constrain construction of the unchanged side: `E` for `and_then` and `transform`, `T` for `or_else` and `transform_error`. These constraints can remove an overload and permit copying through another cv/ref overload. Once an overload is selected, an invalid callback is a hard error, including in a dependent `requires`-expression. +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 3e4c6930..bcca894b 100644 --- a/include/pfn/expected.hpp +++ b/include/pfn/expected.hpp @@ -1719,31 +1719,32 @@ template class expected : private detail::_expected_base constexpr auto and_then(F &&f) & // noexcept(noexcept(_base::_and_then(*this, FWD(f)))) // extension - -> decltype(_base::_and_then(*this, FWD(f))) + requires ::std::is_constructible_v { return _base::_and_then(*this, FWD(f)); } template constexpr auto and_then(F &&f) && // noexcept(noexcept(_base::_and_then(::std::move(*this), FWD(f)))) // extension - -> decltype(_base::_and_then(::std::move(*this), FWD(f))) + requires ::std::is_constructible_v { return _base::_and_then(::std::move(*this), FWD(f)); } template constexpr auto and_then(F &&f) const & // noexcept(noexcept(_base::_and_then(*this, FWD(f)))) // extension - -> decltype(_base::_and_then(*this, FWD(f))) + requires ::std::is_constructible_v { return _base::_and_then(*this, FWD(f)); } template constexpr auto and_then(F &&f) const && // noexcept(noexcept(_base::_and_then(::std::move(*this), FWD(f)))) // extension - -> decltype(_base::_and_then(::std::move(*this), FWD(f))) + requires ::std::is_constructible_v { return _base::_and_then(::std::move(*this), FWD(f)); } @@ -1751,28 +1752,24 @@ template class expected : private detail::_expected_base constexpr auto or_else(F &&f) & // noexcept(noexcept(_base::_or_else(*this, FWD(f)))) // extension - -> decltype(_base::_or_else(*this, FWD(f))) { return _base::_or_else(*this, FWD(f)); } template constexpr auto or_else(F &&f) && // noexcept(noexcept(_base::_or_else(::std::move(*this), FWD(f)))) // extension - -> decltype(_base::_or_else(::std::move(*this), FWD(f))) { return _base::_or_else(::std::move(*this), FWD(f)); } template constexpr auto or_else(F &&f) const & // noexcept(noexcept(_base::_or_else(*this, FWD(f)))) // extension - -> decltype(_base::_or_else(*this, FWD(f))) { return _base::_or_else(*this, FWD(f)); } template constexpr auto or_else(F &&f) const && // noexcept(noexcept(_base::_or_else(::std::move(*this), FWD(f)))) // extension - -> decltype(_base::_or_else(::std::move(*this), FWD(f))) { return _base::_or_else(::std::move(*this), FWD(f)); } @@ -1780,28 +1777,28 @@ template class expected : private detail::_expected_base constexpr auto transform(F &&f) & // noexcept(noexcept(_base::_transform(*this, FWD(f)))) // extension - -> decltype(_base::_transform(*this, FWD(f))) + requires ::std::is_constructible_v { return _base::_transform(*this, FWD(f)); } template constexpr auto transform(F &&f) && // noexcept(noexcept(_base::_transform(::std::move(*this), FWD(f)))) // extension - -> decltype(_base::_transform(::std::move(*this), FWD(f))) + requires ::std::is_constructible_v { return _base::_transform(::std::move(*this), FWD(f)); } template constexpr auto transform(F &&f) const & // noexcept(noexcept(_base::_transform(*this, FWD(f)))) // extension - -> decltype(_base::_transform(*this, FWD(f))) + requires ::std::is_constructible_v { return _base::_transform(*this, FWD(f)); } template constexpr auto transform(F &&f) const && // noexcept(noexcept(_base::_transform(::std::move(*this), FWD(f)))) // extension - -> decltype(_base::_transform(::std::move(*this), FWD(f))) + requires ::std::is_constructible_v { return _base::_transform(::std::move(*this), FWD(f)); } @@ -1809,28 +1806,24 @@ template class expected : private detail::_expected_base constexpr auto transform_error(F &&f) & // noexcept(noexcept(_base::_transform_error(*this, FWD(f)))) // extension - -> decltype(_base::_transform_error(*this, FWD(f))) { return _base::_transform_error(*this, FWD(f)); } template constexpr auto transform_error(F &&f) && // noexcept(noexcept(_base::_transform_error(::std::move(*this), FWD(f)))) // extension - -> decltype(_base::_transform_error(::std::move(*this), FWD(f))) { return _base::_transform_error(::std::move(*this), FWD(f)); } template constexpr auto transform_error(F &&f) const & // noexcept(noexcept(_base::_transform_error(*this, FWD(f)))) // extension - -> decltype(_base::_transform_error(*this, FWD(f))) { return _base::_transform_error(*this, FWD(f)); } template constexpr auto transform_error(F &&f) const && // noexcept(noexcept(_base::_transform_error(::std::move(*this), FWD(f)))) // extension - -> decltype(_base::_transform_error(::std::move(*this), FWD(f))) { return _base::_transform_error(::std::move(*this), FWD(f)); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b3673782..e1f6c31a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -105,35 +105,40 @@ foreach(mode 20 23 26) unset(fixture_target) endforeach() - foreach(operation RANGE 0 3) - foreach(direction RANGE 0 4) - set(test_stem "tests_expected_monadic_object_${operation}_${direction}_cxx${mode}") - foreach(variant IN ITEMS reject control fixture) - set(target "${test_stem}_${variant}") - if(variant STREQUAL "reject") - add_library("${target}" OBJECT EXCLUDE_FROM_ALL compile_fail/expected_monadic.cpp) - else() - add_library("${target}" OBJECT compile_fail/expected_monadic.cpp) - add_dependencies("tests" "${target}") - add_dependencies("cxx${mode}" "${target}") + foreach(kind IN ITEMS object void) + foreach(operation RANGE 0 3) + foreach(direction RANGE 0 4) + if(kind STREQUAL "void" AND operation LESS 2 AND direction GREATER 0) + continue() endif() - target_link_libraries("${target}" PRIVATE include_pfn) - append_compilation_options("${target}" WARNINGS) - set_property(TARGET "${target}" PROPERTY CXX_STANDARD "${mode}") - target_compile_definitions("${target}" PRIVATE - EXPECTED_MONADIC_VOID=0 EXPECTED_MONADIC_OP=${operation} - EXPECTED_MONADIC_DIRECTION=${direction} - EXPECTED_MONADIC_POSITIVE=$ - EXPECTED_MONADIC_FIXTURE_ONLY=$) + set(test_stem "tests_expected_monadic_${kind}_${operation}_${direction}_cxx${mode}") + foreach(variant IN ITEMS reject control fixture) + set(target "${test_stem}_${variant}") + if(variant STREQUAL "reject") + add_library("${target}" OBJECT EXCLUDE_FROM_ALL compile_fail/expected_monadic.cpp) + else() + add_library("${target}" OBJECT compile_fail/expected_monadic.cpp) + add_dependencies("tests" "${target}") + add_dependencies("cxx${mode}" "${target}") + endif() + target_link_libraries("${target}" PRIVATE include_pfn) + append_compilation_options("${target}" WARNINGS) + set_property(TARGET "${target}" PROPERTY CXX_STANDARD "${mode}") + target_compile_definitions("${target}" PRIVATE + EXPECTED_MONADIC_VOID=$ EXPECTED_MONADIC_OP=${operation} + EXPECTED_MONADIC_DIRECTION=${direction} + EXPECTED_MONADIC_POSITIVE=$ + EXPECTED_MONADIC_FIXTURE_ONLY=$) + endforeach() + add_test(NAME "${test_stem}_reject" + COMMAND "${CMAKE_COMMAND}" --build "${CMAKE_BINARY_DIR}" + --config "$" --target "${test_stem}_reject") + set_tests_properties("${test_stem}_reject" PROPERTIES + WILL_FAIL TRUE RUN_SERIAL TRUE + LABELS "tests_pfn;cxx${mode};pfn_expected;compile_fail") + unset(target) + unset(test_stem) endforeach() - add_test(NAME "${test_stem}_reject" - COMMAND "${CMAKE_COMMAND}" --build "${CMAKE_BINARY_DIR}" - --config "$" --target "${test_stem}_reject") - set_tests_properties("${test_stem}_reject" PROPERTIES - WILL_FAIL TRUE RUN_SERIAL TRUE - LABELS "tests_pfn;cxx${mode};pfn_expected;compile_fail") - unset(target) - unset(test_stem) endforeach() endforeach() diff --git a/tests/pfn/expected.cpp b/tests/pfn/expected.cpp index ab1f620a..0085337c 100644 --- a/tests/pfn/expected.cpp +++ b/tests/pfn/expected.cpp @@ -5932,6 +5932,37 @@ TEST_CASE("expected void", "[expected_void][polyfill]") { SECTION("and_then") { + SECTION("constructibility constraints") + { + using O = expected; + using F = decltype([]() -> expected { return {}; }); + static_assert(not has_and_then); + static_assert(not has_and_then); + static_assert(not has_and_then); + static_assert(has_and_then); + static_assert(not has_and_then); + SUCCEED(); + } + + SECTION("copy fallback") + { + using O = expected; + static_assert(std::is_constructible_v); + static_assert(not std::is_constructible_v); + static_assert(not std::is_constructible_v); + constexpr auto check = [] { + O a; + O e(unexpect, 9); + constexpr auto fn + = []() noexcept -> expected { return expected{7}; }; + auto value = std::move(a).and_then(fn); + auto error = std::move(e).and_then(fn); + return value.has_value() && *value == 7 && not error.has_value() && error.error().value == 9; + }; + static_assert(check()); + CHECK(check()); + } + SECTION("value") { using T = expected; @@ -6015,6 +6046,32 @@ TEST_CASE("expected void", "[expected_void][polyfill]") SECTION("or_else") { + SECTION("immovable error") + { + using O = expected; + static_assert(not std::is_copy_constructible_v); + static_assert(not std::is_move_constructible_v); + constexpr auto check = [] { + O a; + O e(unexpect, helper_list_t{}, 5); + constexpr auto fn = [](helper_immovable const &) noexcept -> expected { + return expected{unexpect, 7}; + }; + static_assert(not extension || noexcept(std::declval().or_else(fn))); + auto value = static_cast(a).or_else(fn); + auto error = static_cast(e).or_else(fn); + return value.has_value() && not error.has_value() && error.error() == 7 && e.error().v == 5; + }; + 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()()); + } + SECTION("error") { using T = expected; @@ -6111,6 +6168,36 @@ TEST_CASE("expected void", "[expected_void][polyfill]") SECTION("transform") { + SECTION("constructibility constraints") + { + using O = expected; + using F = decltype([]() -> int { return {}; }); + static_assert(not has_transform); + static_assert(not has_transform); + static_assert(not has_transform); + static_assert(has_transform); + static_assert(not has_transform); + SUCCEED(); + } + + SECTION("copy fallback") + { + using O = expected; + static_assert(std::is_constructible_v); + static_assert(not std::is_constructible_v); + static_assert(not std::is_constructible_v); + constexpr auto check = [] { + O a; + O e(unexpect, 9); + constexpr auto fn = []() noexcept -> int { return int{7}; }; + auto value = std::move(a).transform(fn); + auto error = std::move(e).transform(fn); + return value.has_value() && *value == 7 && not error.has_value() && error.error().value == 9; + }; + static_assert(check()); + CHECK(check()); + } + SECTION("value") { using T = expected; @@ -6211,6 +6298,30 @@ TEST_CASE("expected void", "[expected_void][polyfill]") SECTION("transform_error") { + SECTION("immovable error") + { + using O = expected; + static_assert(not std::is_copy_constructible_v); + static_assert(not std::is_move_constructible_v); + constexpr auto check = [] { + O a; + O e(unexpect, helper_list_t{}, 5); + constexpr auto fn = [](helper_immovable const &) noexcept -> int { return 7; }; + static_assert(not extension || noexcept(std::declval().transform_error(fn))); + auto value = static_cast(a).transform_error(fn); + auto error = static_cast(e).transform_error(fn); + return value.has_value() && not error.has_value() && error.error() == 7 && e.error().v == 5; + }; + 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()()); + } + SECTION("error") { using T = expected;