From f3cb010fef0b0d38fb53b5bcb8911181e22f64c7 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sun, 12 Jul 2026 11:18:46 +0100 Subject: [PATCH 1/5] Ask of a `sum` alternative the same question its initialization answers A sum brace-initializes the alternative it stores, but its constraints and noexcept specifications asked `std::is_[nothrow_]constructible`, which is a question about parentheses. The two disagree - braced initialization considers initializer-list constructors first - and where they disagree the parenthesized answer is a lie: a type can promise a nothrow move and still throw from `T{std::move(t)}`. Believing it cost more than a wrong specification. `sum`'s move constructor was declared `noexcept` on that promise and called std::terminate when the promise broke; and `_reinit`, whose temporary arm rests on the move being nothrow, destroyed the alternative it held and then failed to replace it - leaving the sum with no alternative at all, which is not a lost guarantee but a dead object. So every trait that constrains or specifies the construction of an alternative now asks the brace question, through `_initializable` / `_nothrow_initializable` - the traits that already exist for it. The choice of braces is a design direction rather than an implementation detail - it is what gives `sum` aggregate forwarding through brace elision, which `std::variant` cannot express and parenthesized initialization cannot either - so a test now pins it, and says what it buys and what it costs. Other tests would break if it were switched; that one breaks on purpose. The temporary arm's completion, as opposed to its throwing, had no coverage at all: only the throw was ever exercised. Closes #291 Assisted-by: Claude:claude-opus-4-8[1m] --- include/fn/choice.hpp | 14 ++--- include/fn/sum.hpp | 40 ++++++++------ tests/fn/sum.cpp | 124 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 150 insertions(+), 28 deletions(-) diff --git a/include/fn/choice.hpp b/include/fn/choice.hpp index 39889d86..abbbe04b 100644 --- a/include/fn/choice.hpp +++ b/include/fn/choice.hpp @@ -87,7 +87,7 @@ struct choice : sum { template constexpr choice(T &&v) // NOSONAR cpp:S1709,S6458 implicit arm of the explicit pair; has_type excludes self noexcept(detail::_nothrow_initializable<::std::remove_cvref_t, decltype(v)>) - requires has_type<::std::remove_cvref_t> && (::std::is_constructible_v<::std::remove_cvref_t, decltype(v)>) + requires has_type<::std::remove_cvref_t> && (detail::_initializable<::std::remove_cvref_t, decltype(v)>) && (::std::is_convertible_v>) : _impl(::std::in_place_type<::std::remove_cvref_t>, FWD(v)) { @@ -102,7 +102,7 @@ struct choice : sum { template constexpr explicit choice(T &&v) // NOSONAR cpp:S6458 has_type excludes self noexcept(detail::_nothrow_initializable<::std::remove_cvref_t, decltype(v)>) - requires has_type<::std::remove_cvref_t> && (::std::is_constructible_v<::std::remove_cvref_t, decltype(v)>) + requires has_type<::std::remove_cvref_t> && (detail::_initializable<::std::remove_cvref_t, decltype(v)>) && (not ::std::is_convertible_v>) : _impl(::std::in_place_type<::std::remove_cvref_t>, FWD(v)) { @@ -131,8 +131,8 @@ struct choice : sum { */ template constexpr choice(sum const &v) // NOSONAR cpp:S1709 implicit widening by design - noexcept((... && ::std::is_nothrow_copy_constructible_v)) - requires detail::is_superset_of> && (... && ::std::is_copy_constructible_v) + noexcept((... && detail::_nothrow_initializable)) + requires detail::is_superset_of> && (... && detail::_initializable) : _impl(::std::in_place_type>, FWD(v)) { } @@ -145,8 +145,8 @@ struct choice : sum { */ template constexpr choice(sum &&v) // NOSONAR cpp:S1709 implicit widening by design - noexcept((... && ::std::is_nothrow_move_constructible_v)) - requires detail::is_superset_of> && (... && ::std::is_move_constructible_v) + noexcept((... && detail::_nothrow_initializable)) + requires detail::is_superset_of> && (... && detail::_initializable) : _impl(::std::in_place_type>, FWD(v)) { } @@ -159,7 +159,7 @@ struct choice : sum { */ template constexpr choice(::std::in_place_type_t>, some_sum auto &&v) // - noexcept((... && ::std::is_nothrow_constructible_v>)) + noexcept((... && detail::_nothrow_initializable>)) requires ::std::is_same_v<::std::remove_cvref_t, sum> && detail::is_superset_of> : _impl(::std::in_place_type>, FWD(v)) diff --git a/include/fn/sum.hpp b/include/fn/sum.hpp index 0c4991cc..7e661393 100644 --- a/include/fn/sum.hpp +++ b/include/fn/sum.hpp @@ -236,7 +236,7 @@ struct sum { template constexpr sum(T &&v) // NOSONAR cpp:S1709,S6458 implicit arm of the explicit pair; has_type excludes self noexcept(detail::_nothrow_initializable<::std::remove_cvref_t, decltype(v)>) - requires has_type<::std::remove_cvref_t> && (::std::is_constructible_v<::std::remove_cvref_t, decltype(v)>) + requires has_type<::std::remove_cvref_t> && (detail::_initializable<::std::remove_cvref_t, decltype(v)>) && (::std::is_convertible_v>) : data(detail::make_variadic_union<::std::remove_cvref_t, data_t>(FWD(v))), index(detail::type_index<::std::remove_cvref_t, Ts...>) @@ -252,7 +252,7 @@ struct sum { template constexpr explicit sum(T &&v) // NOSONAR cpp:S6458 has_type excludes self noexcept(detail::_nothrow_initializable<::std::remove_cvref_t, decltype(v)>) - requires has_type<::std::remove_cvref_t> && (::std::is_constructible_v<::std::remove_cvref_t, decltype(v)>) + requires has_type<::std::remove_cvref_t> && (detail::_initializable<::std::remove_cvref_t, decltype(v)>) && (not ::std::is_convertible_v>) : data(detail::make_variadic_union<::std::remove_cvref_t, data_t>(FWD(v))), index(detail::type_index<::std::remove_cvref_t, Ts...>) @@ -281,9 +281,9 @@ struct sum { */ template constexpr sum(sum const &arg) // NOSONAR cpp:S1709 implicit widening by design - noexcept((... && ::std::is_nothrow_copy_constructible_v)) + noexcept((... && detail::_nothrow_initializable)) requires detail::is_superset_of> && (not ::std::is_same_v>) - && (... && ::std::is_copy_constructible_v) && (sizeof...(Tx) > 0) + && (... && detail::_initializable) && (sizeof...(Tx) > 0) : data(FWD(arg).template _invoke([](::std::in_place_type_t, auto &&v) { return detail::make_variadic_union(FWD(v)); })), @@ -301,9 +301,9 @@ struct sum { */ template constexpr sum(sum &&arg) // NOSONAR cpp:S1709 implicit widening by design - noexcept((... && ::std::is_nothrow_move_constructible_v)) + noexcept((... && detail::_nothrow_initializable)) requires detail::is_superset_of> && (not ::std::is_same_v>) - && (... && ::std::is_move_constructible_v) && (sizeof...(Tx) > 0) + && (... && detail::_initializable) && (sizeof...(Tx) > 0) : data(FWD(arg).template _invoke([](::std::in_place_type_t, auto &&v) { return detail::make_variadic_union(FWD(v)); })), @@ -321,7 +321,7 @@ struct sum { */ template constexpr sum(::std::in_place_type_t>, some_sum auto &&arg) // - noexcept((... && ::std::is_nothrow_constructible_v>)) + noexcept((... && detail::_nothrow_initializable>)) requires ::std::is_same_v<::std::remove_cvref_t, sum> && detail::is_superset_of> && (sizeof...(Tx) > 0) : data(FWD(arg).template _invoke([](::std::in_place_type_t, auto &&v) { @@ -338,8 +338,8 @@ struct sum { * * @param other TODO */ - constexpr sum(sum const &other) noexcept((... && ::std::is_nothrow_copy_constructible_v)) - requires(... && ::std::is_copy_constructible_v) + constexpr sum(sum const &other) noexcept((... && detail::_nothrow_initializable)) + requires(... && detail::_initializable) : data(detail::invoke_type_variadic_union( // other.data, other.index, // [](::std::in_place_type_t, auto const &v) { // @@ -354,8 +354,8 @@ struct sum { * * @param other TODO */ - constexpr sum(sum &&other) noexcept((... && ::std::is_nothrow_move_constructible_v)) - requires(... && ::std::is_move_constructible_v) + constexpr sum(sum &&other) noexcept((... && detail::_nothrow_initializable)) + requires(... && detail::_initializable) : data(detail::invoke_type_variadic_union( // ::std::move(other).data, other.index, // [](::std::in_place_type_t, auto &&v) { // @@ -386,10 +386,16 @@ struct sum { // a possible OLD alternative (one is assigned over itself whenever the sum already holds it), and // snapshotting that same type would throw in turn. So there is no safe path, and such an // alternative is constrained away rather than half-served. + // + // Every question below is asked of BRACE initialization (`_initializable`), because that is what + // the storage performs - `std::is_nothrow_move_constructible_v` asks about `T(T&&)` instead, and + // the two can disagree: braced initialization considers initializer-list constructors first, so a + // type can promise a nothrow move and still throw from `T{std::move(t)}`. Believing the parens + // answer here would destroy the old alternative and then fail to replace it. template constexpr void _reinit(Args &&...args) noexcept(detail::_nothrow_initializable) requires has_type && detail::_initializable - && (detail::_nothrow_initializable || ::std::is_nothrow_move_constructible_v) + && (detail::_nothrow_initializable || detail::_nothrow_initializable) { if constexpr (detail::_nothrow_initializable) { ::std::destroy_at(this); @@ -397,7 +403,7 @@ struct sum { } else { T tmp{FWD(args)...}; // may throw, and the storage is untouched until it cannot ::std::destroy_at(this); - ::std::construct_at(this, ::std::in_place_type, ::std::move(tmp)); + ::std::construct_at(this, ::std::in_place_type, ::std::move(tmp)); // cannot throw: see above } } @@ -410,10 +416,10 @@ struct sum { // The alternative that changes is CONSTRUCTED, never assigned to: assignment asks nothing of `Ts` // beyond what construction already asks, so a type with no assignment operator at all is still // assignable through the sum. - constexpr sum &operator=(sum const &other) noexcept((... && ::std::is_nothrow_copy_constructible_v)) + constexpr sum &operator=(sum const &other) noexcept((... && detail::_nothrow_initializable)) requires(... - && (::std::is_copy_constructible_v - && (::std::is_nothrow_copy_constructible_v || ::std::is_nothrow_move_constructible_v))) + && (detail::_initializable + && (detail::_nothrow_initializable || detail::_nothrow_initializable))) { if (this != &other) { detail::invoke_type_variadic_union( // @@ -432,7 +438,7 @@ struct sum { // Moving is offered only where it cannot throw, for the reason given on _reinit - and where it can, // a nothrow-copyable sum is still assignable from an rvalue, by copy. constexpr sum &operator=(sum &&other) noexcept - requires(... && ::std::is_nothrow_move_constructible_v) + requires(... && detail::_nothrow_initializable) { if (this != &other) { detail::invoke_type_variadic_union( // diff --git a/tests/fn/sum.cpp b/tests/fn/sum.cpp index f2988a75..70e8fd35 100644 --- a/tests/fn/sum.cpp +++ b/tests/fn/sum.cpp @@ -9,6 +9,7 @@ #include +#include #include #include #include @@ -16,6 +17,7 @@ #include #include #include +#include namespace { struct TestType final { @@ -73,6 +75,106 @@ template concept can_as_sum_value = requires(T v) { fn::as_sum(FWD(v)); }; } // anonymous namespace +// A sum brace-initializes the alternative it stores. That is a DESIGN DIRECTION, not an +// implementation detail, and this TEST_CASE exists to fail if it is ever switched to the +// parenthesized initialization `std::variant` uses. Many other tests would fail with it, but +// incidentally - this one is here to say what was chosen, and why. +// +// What braces buy: +// * Aggregate forwarding, through brace elision: `sum{in_place_type>, 1, 2, 3}`. +// `std::variant` cannot express this, and neither can parenthesized initialization - P0960 +// admits aggregates but forbids brace elision. +// * Coherence with `sum` being a structural type. A structural type's alternatives must themselves +// be structural - public-member types, written with braces, exactly as one writes them as a +// template argument. Storing them any other way would put the two spellings at odds. +// * Narrowing is rejected outright rather than silently truncating. +// +// What braces cost: +// * An initializer-list constructor wins where `std::variant` would call the (count, value) one: +// `sum>{in_place_type>, 3, 0}` holds `{3, 0}`, where a variant would hold +// `{0, 0, 0}`. This divergence is the cost of giving `sum` a capability which `std::variant` lacks. +// +// And the consequence that actually bites, which is why the last section is here: every trait that +// constrains or specifies the construction of an alternative must ask the BRACE question +// (`fn::detail::_initializable` / `_nothrow_initializable`), never `std::is_[nothrow_]constructible`, +// which asks about parentheses. Where the two disagree, the parenthesized answer is a lie. +TEST_CASE("design: braces, not parentheses", "[sum][design]") +{ + using fn::sum; + + WHEN("aggregate forwarding") + { + using A = std::array; + static_assert(can_in_place, A, int, int, int>); // braces elide; parentheses cannot + static_assert(not std::is_constructible_v); // ... which is why the std trait misleads + static_assert(fn::detail::_initializable); // ... and this is the trait that does not + + sum a{std::in_place_type, 1, 2, 3}; + CHECK(a.invoke([](A const &v) { return v[0] * 100 + v[1] * 10 + v[2]; }) == 123); + static_assert(sum{std::in_place_type, 1, 2, 3}.invoke([](A const &v) { return v[2]; }) == 3); + } + + WHEN("narrowing") + { + static_assert(not can_in_place, int, double>); // braces reject it ... + static_assert(std::is_constructible_v); // ... where parentheses would truncate in silence + SUCCEED(); + } + + WHEN("initializer-list constructors win") + { + // the price of the above, and the same mechanism as the trap below: `std::variant` would hold + // three zeroes here, and a sum holds the two elements it was written with + using V = std::vector; + sum v{std::in_place_type, 3, 0}; + CHECK(v.invoke([](V const &x) { return x.size(); }) == 2); + CHECK(v.invoke([](V const &x) { return x[0]; }) == 3); + } + + WHEN("the traits must ask the brace question") + { + // Braced initialization considers initializer-list constructors FIRST, so the two questions can + // answer differently - and the parenthesized answer is not the one the storage acts on. + struct Listy final { + Listy(int) noexcept {} // parentheses select this ... + Listy(std::initializer_list) noexcept(false) {} // ... braces select this + }; + static_assert(std::is_nothrow_constructible_v); // the parenthesized question: nothrow + static_assert(not fn::detail::_nothrow_initializable); // the braced question: it can throw + static_assert(not noexcept(sum{std::in_place_type, 1})); // the sum promises what it does + } + + WHEN("... including where a compiler answers it differently") + { + // The divergence also reaches the copy and move constructors, and the assignment built on them. + // `Evil` converts to double only as an rvalue, so `Evil{lvalue}` copies while `Evil{rvalue}` + // selects the throwing initializer-list constructor - which is what [over.match.list]/1 requires, + // [dcl.init.list]/3.2's same-type carve-out being for AGGREGATES only, and Evil is not one. + // gcc and clang>=20 have it so; clang<=19, AppleClang and MSVC pick the move constructor instead. + // + // So these pin the INVARIANT rather than the answer: whatever a compiler makes of the braces is + // what the sum must promise. Reading the parenthesized answer instead - which is always "nothrow + // move" here - declared a `noexcept` that std::terminate had to keep, and offered an assignment + // that destroyed the alternative it held and then failed to replace it. + struct Tag final {}; + struct Evil final { + int v; + Evil(Tag, int i) noexcept : v(i) {} // NOLINT: constructs without touching either trap + operator double() && noexcept { return v; } + Evil(std::initializer_list) { throw std::runtime_error("init-list"); } + Evil(Evil &&o) noexcept : v(o.v) {} + Evil(Evil const &o) noexcept(false) : v(o.v) {} + }; + static_assert(std::is_nothrow_move_constructible_v); // the parenthesized question, everywhere + + static_assert(std::is_nothrow_move_constructible_v> == fn::detail::_nothrow_initializable); + static_assert(std::is_nothrow_copy_constructible_v> + == fn::detail::_nothrow_initializable); + static_assert(std::is_move_assignable_v> == fn::detail::_nothrow_initializable); + SUCCEED(); + } +} + TEST_CASE("sum basic functionality tests", "[sum]") { // NOTE This test looks very similar to test in choice.cpp - for good reason. @@ -1280,21 +1382,35 @@ TEST_CASE("sum assignment", "[sum][assignment]") // throw, it is made into a temporary first, and only the (nothrow) move touches the storage. struct ThrowingCopy final { int v; - ThrowingCopy(int i) noexcept : v(i) {} // NOLINT: implicit on purpose - ThrowingCopy(ThrowingCopy const &o) : v(o.v) + constexpr ThrowingCopy(int i) noexcept : v(i) {} // NOLINT: implicit on purpose + constexpr ThrowingCopy(ThrowingCopy const &o) : v(o.v) { if (v == 0) throw std::runtime_error("copy"); } - ThrowingCopy(ThrowingCopy &&o) noexcept : v(o.v) {} + constexpr ThrowingCopy(ThrowingCopy &&o) noexcept : v(o.v) {} }; static_assert(std::is_copy_assignable_v>); static_assert(not std::is_nothrow_copy_assignable_v>); + constexpr auto value = [](ThrowingCopy const &t) { return t.v; }; sum a{ThrowingCopy{7}}; sum const bad{ThrowingCopy{0}}; CHECK_THROWS_AS(a = bad, std::runtime_error); - CHECK(a.invoke([](ThrowingCopy const &t) { return t.v; }) == 7); // untouched + CHECK(a.invoke(value) == 7); // untouched + + // the same arm, completing: the copy into the temporary succeeds, and only then is the old + // alternative destroyed and the temporary moved into the storage + sum const good{ThrowingCopy{5}}; + a = good; + CHECK(a.invoke(value) == 5); + + static_assert([] { + sum a{ThrowingCopy{7}}; + sum const good{ThrowingCopy{5}}; + a = good; // the temporary arm, in a constant expression + return a.invoke([](ThrowingCopy const &t) { return t.v; }) == 5; + }()); WHEN("the two arms in one sum") { From c33d0bedde183dff1eaf483a56ac7bccd469316d Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sun, 12 Jul 2026 13:25:35 +0100 Subject: [PATCH 2/5] Derive what storing an alternative costs from the code that stores it The constraints and specifications of `sum` restated what its storage does, and a restatement can drift from the deed - which is how `std::is_nothrow_move_constructible_v` came to answer for `T{std::move(t)}`, a question it does not ask. `make_variadic_union` is the only thing that ever builds an alternative, so it now says what it can build and what that costs: constrained on the initialization it performs, and specified by it. Everything above asks that, through the two concepts beside it, rather than describing it again. `sum` names the operations once - copyable, movable, and their nothrow twins - and every constructor, assignment and `_reinit` is written in those terms. `choice` and the `as_sum` lifts ask the sum they construct, which asks its storage in turn. No declaration restates the initialization, and none can disagree with it. The arm that assignment takes is still chosen per alternative, so `_copy_assignable` keeps that choice inside the fold: one arm serving them all would reject a sum whose alternatives simply need different ones. `make_variadic_union` also declares its return type now. Deducing it meant instantiating the body to deduce it, and the body odr-uses the alternative's constructor - so merely ASKING whether an alternative could be stored demanded a definition of the constructor that would store it. Closes #291 Assisted-by: Claude:claude-opus-4-8[1m] --- include/fn/choice.hpp | 26 +++++---- include/fn/detail/variadic_union.hpp | 31 +++++++---- include/fn/sum.hpp | 79 +++++++++++++++++----------- 3 files changed, 83 insertions(+), 53 deletions(-) diff --git a/include/fn/choice.hpp b/include/fn/choice.hpp index abbbe04b..6c0ebddd 100644 --- a/include/fn/choice.hpp +++ b/include/fn/choice.hpp @@ -86,8 +86,9 @@ struct choice : sum { */ template constexpr choice(T &&v) // NOSONAR cpp:S1709,S6458 implicit arm of the explicit pair; has_type excludes self - noexcept(detail::_nothrow_initializable<::std::remove_cvref_t, decltype(v)>) - requires has_type<::std::remove_cvref_t> && (detail::_initializable<::std::remove_cvref_t, decltype(v)>) + noexcept(::std::is_nothrow_constructible_v<_impl, ::std::in_place_type_t<::std::remove_cvref_t>, decltype(v)>) + requires has_type<::std::remove_cvref_t> + && (::std::is_constructible_v<_impl, ::std::in_place_type_t<::std::remove_cvref_t>, decltype(v)>) && (::std::is_convertible_v>) : _impl(::std::in_place_type<::std::remove_cvref_t>, FWD(v)) { @@ -101,8 +102,9 @@ struct choice : sum { */ template constexpr explicit choice(T &&v) // NOSONAR cpp:S6458 has_type excludes self - noexcept(detail::_nothrow_initializable<::std::remove_cvref_t, decltype(v)>) - requires has_type<::std::remove_cvref_t> && (detail::_initializable<::std::remove_cvref_t, decltype(v)>) + noexcept(::std::is_nothrow_constructible_v<_impl, ::std::in_place_type_t<::std::remove_cvref_t>, decltype(v)>) + requires has_type<::std::remove_cvref_t> + && (::std::is_constructible_v<_impl, ::std::in_place_type_t<::std::remove_cvref_t>, decltype(v)>) && (not ::std::is_convertible_v>) : _impl(::std::in_place_type<::std::remove_cvref_t>, FWD(v)) { @@ -117,8 +119,8 @@ struct choice : sum { */ template constexpr explicit choice(::std::in_place_type_t d, auto &&...args) // - noexcept(detail::_nothrow_initializable) - requires has_type && detail::_initializable + noexcept(::std::is_nothrow_constructible_v<_impl, ::std::in_place_type_t, decltype(args)...>) + requires has_type && ::std::is_constructible_v<_impl, ::std::in_place_type_t, decltype(args)...> : _impl(d, FWD(args)...) { } @@ -131,8 +133,9 @@ struct choice : sum { */ template constexpr choice(sum const &v) // NOSONAR cpp:S1709 implicit widening by design - noexcept((... && detail::_nothrow_initializable)) - requires detail::is_superset_of> && (... && detail::_initializable) + noexcept(::std::is_nothrow_constructible_v<_impl, ::std::in_place_type_t>, sum const &>) + requires detail::is_superset_of> + && (::std::is_constructible_v<_impl, ::std::in_place_type_t>, sum const &>) : _impl(::std::in_place_type>, FWD(v)) { } @@ -145,8 +148,9 @@ struct choice : sum { */ template constexpr choice(sum &&v) // NOSONAR cpp:S1709 implicit widening by design - noexcept((... && detail::_nothrow_initializable)) - requires detail::is_superset_of> && (... && detail::_initializable) + noexcept(::std::is_nothrow_constructible_v<_impl, ::std::in_place_type_t>, sum>) + requires detail::is_superset_of> + && (::std::is_constructible_v<_impl, ::std::in_place_type_t>, sum>) : _impl(::std::in_place_type>, FWD(v)) { } @@ -159,7 +163,7 @@ struct choice : sum { */ template constexpr choice(::std::in_place_type_t>, some_sum auto &&v) // - noexcept((... && detail::_nothrow_initializable>)) + noexcept(::std::is_nothrow_constructible_v<_impl, ::std::in_place_type_t>, decltype(v)>) requires ::std::is_same_v<::std::remove_cvref_t, sum> && detail::is_superset_of> : _impl(::std::in_place_type>, FWD(v)) diff --git a/include/fn/detail/variadic_union.hpp b/include/fn/detail/variadic_union.hpp index a577e605..c8abda8d 100644 --- a/include/fn/detail/variadic_union.hpp +++ b/include/fn/detail/variadic_union.hpp @@ -306,40 +306,51 @@ template } template -[[nodiscard]] constexpr auto make_variadic_union(auto &&...args) - requires(U::template has_type) && ::std::is_same_v +[[nodiscard]] constexpr U make_variadic_union(auto &&...args) noexcept(noexcept(T{FWD(args)...})) + requires(U::template has_type) && ::std::is_same_v && requires { T{FWD(args)...}; } { return U{.v0 = T{FWD(args)...}}; } template -[[nodiscard]] constexpr auto make_variadic_union(auto &&...args) - requires(U::template has_type) && ::std::is_same_v +[[nodiscard]] constexpr U make_variadic_union(auto &&...args) noexcept(noexcept(T{FWD(args)...})) + requires(U::template has_type) && ::std::is_same_v && requires { T{FWD(args)...}; } { return U{.v1 = T{FWD(args)...}}; } template -[[nodiscard]] constexpr auto make_variadic_union(auto &&...args) - requires(U::template has_type) && ::std::is_same_v +[[nodiscard]] constexpr U make_variadic_union(auto &&...args) noexcept(noexcept(T{FWD(args)...})) + requires(U::template has_type) && ::std::is_same_v && requires { T{FWD(args)...}; } { return U{.v2 = T{FWD(args)...}}; } template -[[nodiscard]] constexpr auto make_variadic_union(auto &&...args) - requires(U::template has_type) && ::std::is_same_v +[[nodiscard]] constexpr U make_variadic_union(auto &&...args) noexcept(noexcept(T{FWD(args)...})) + requires(U::template has_type) && ::std::is_same_v && requires { T{FWD(args)...}; } { return U{.v3 = T{FWD(args)...}}; } template -[[nodiscard]] constexpr U make_variadic_union(auto &&...args) - requires(U::template has_type) && (U::more_t::template has_type) +[[nodiscard]] constexpr U make_variadic_union(auto &&...args) noexcept(noexcept(T{FWD(args)...})) + requires(U::template has_type) && (U::more_t::template has_type) && requires { T{FWD(args)...}; } { return U{.more = make_variadic_union(FWD(args)...)}; } +// The two questions anyone above may ask about storing an alternative, asked OF the function that +// stores it rather than restated in terms of a trait. Restating is how the answer drifts from the +// deed: `make_variadic_union` brace-initializes, and `std::is_[nothrow_]constructible` is a question +// about parentheses - the two can disagree, and where they do the trait is wrong. Nothing here needs +// to know that, and nothing above needs to remember it. +template +concept _makeable = requires { make_variadic_union(::std::declval()...); }; + +template +concept _nothrow_makeable = requires { requires noexcept(make_variadic_union(::std::declval()...)); }; + template [[nodiscard]] constexpr auto invoke_variadic_union(some_variadic_union auto &&v, ::std::size_t index, Fn &&fn, Args &&...args) diff --git a/include/fn/sum.hpp b/include/fn/sum.hpp index 7e661393..85c73c41 100644 --- a/include/fn/sum.hpp +++ b/include/fn/sum.hpp @@ -176,6 +176,22 @@ struct sum { static constexpr ::std::size_t size = sizeof...(Ts); + // What copying and moving a sum cost, asked of the storage that performs them - see the concepts + // in detail/variadic_union.hpp. Everything below is specified and constrained in these terms, so + // that no declaration has to restate what the storage does, and none can drift from it. + static constexpr bool _copyable = (... && detail::_makeable); + static constexpr bool _movable = (... && detail::_makeable); + static constexpr bool _nothrow_copyable = (... && detail::_nothrow_makeable); + static constexpr bool _nothrow_movable = (... && detail::_nothrow_makeable); + + // Copy assignment reinitializes ONE alternative, and `_reinit` chooses its arm per alternative - so + // the choice belongs INSIDE the fold. Hoisting it out would demand that one arm serve them all, and + // would reject a sum whose alternatives simply need different ones. + static constexpr bool _copy_assignable + = (... + && (detail::_makeable + && (detail::_nothrow_makeable || detail::_nothrow_makeable))); + /** * @brief TODO * @@ -235,8 +251,8 @@ struct sum { */ template constexpr sum(T &&v) // NOSONAR cpp:S1709,S6458 implicit arm of the explicit pair; has_type excludes self - noexcept(detail::_nothrow_initializable<::std::remove_cvref_t, decltype(v)>) - requires has_type<::std::remove_cvref_t> && (detail::_initializable<::std::remove_cvref_t, decltype(v)>) + noexcept(detail::_nothrow_makeable, decltype(v)>) + requires has_type<::std::remove_cvref_t> && (detail::_makeable, decltype(v)>) && (::std::is_convertible_v>) : data(detail::make_variadic_union<::std::remove_cvref_t, data_t>(FWD(v))), index(detail::type_index<::std::remove_cvref_t, Ts...>) @@ -251,8 +267,8 @@ struct sum { */ template constexpr explicit sum(T &&v) // NOSONAR cpp:S6458 has_type excludes self - noexcept(detail::_nothrow_initializable<::std::remove_cvref_t, decltype(v)>) - requires has_type<::std::remove_cvref_t> && (detail::_initializable<::std::remove_cvref_t, decltype(v)>) + noexcept(detail::_nothrow_makeable, decltype(v)>) + requires has_type<::std::remove_cvref_t> && (detail::_makeable, decltype(v)>) && (not ::std::is_convertible_v>) : data(detail::make_variadic_union<::std::remove_cvref_t, data_t>(FWD(v))), index(detail::type_index<::std::remove_cvref_t, Ts...>) @@ -267,8 +283,8 @@ struct sum { */ template constexpr explicit sum(::std::in_place_type_t, - auto &&...args) noexcept(detail::_nothrow_initializable) - requires has_type && detail::_initializable + auto &&...args) noexcept(detail::_nothrow_makeable) + requires has_type && detail::_makeable : data(detail::make_variadic_union(FWD(args)...)), index(detail::type_index) { } @@ -281,9 +297,9 @@ struct sum { */ template constexpr sum(sum const &arg) // NOSONAR cpp:S1709 implicit widening by design - noexcept((... && detail::_nothrow_initializable)) + noexcept((... && detail::_nothrow_makeable)) requires detail::is_superset_of> && (not ::std::is_same_v>) - && (... && detail::_initializable) && (sizeof...(Tx) > 0) + && (... && detail::_makeable) && (sizeof...(Tx) > 0) : data(FWD(arg).template _invoke([](::std::in_place_type_t, auto &&v) { return detail::make_variadic_union(FWD(v)); })), @@ -301,9 +317,9 @@ struct sum { */ template constexpr sum(sum &&arg) // NOSONAR cpp:S1709 implicit widening by design - noexcept((... && detail::_nothrow_initializable)) + noexcept((... && detail::_nothrow_makeable)) requires detail::is_superset_of> && (not ::std::is_same_v>) - && (... && detail::_initializable) && (sizeof...(Tx) > 0) + && (... && detail::_makeable) && (sizeof...(Tx) > 0) : data(FWD(arg).template _invoke([](::std::in_place_type_t, auto &&v) { return detail::make_variadic_union(FWD(v)); })), @@ -321,7 +337,7 @@ struct sum { */ template constexpr sum(::std::in_place_type_t>, some_sum auto &&arg) // - noexcept((... && detail::_nothrow_initializable>)) + noexcept((... && detail::_nothrow_makeable>)) requires ::std::is_same_v<::std::remove_cvref_t, sum> && detail::is_superset_of> && (sizeof...(Tx) > 0) : data(FWD(arg).template _invoke([](::std::in_place_type_t, auto &&v) { @@ -338,8 +354,8 @@ struct sum { * * @param other TODO */ - constexpr sum(sum const &other) noexcept((... && detail::_nothrow_initializable)) - requires(... && detail::_initializable) + constexpr sum(sum const &other) noexcept(_nothrow_copyable) + requires _copyable : data(detail::invoke_type_variadic_union( // other.data, other.index, // [](::std::in_place_type_t, auto const &v) { // @@ -354,8 +370,8 @@ struct sum { * * @param other TODO */ - constexpr sum(sum &&other) noexcept((... && detail::_nothrow_initializable)) - requires(... && detail::_initializable) + constexpr sum(sum &&other) noexcept(_nothrow_movable) + requires _movable : data(detail::invoke_type_variadic_union( // ::std::move(other).data, other.index, // [](::std::in_place_type_t, auto &&v) { // @@ -387,17 +403,17 @@ struct sum { // snapshotting that same type would throw in turn. So there is no safe path, and such an // alternative is constrained away rather than half-served. // - // Every question below is asked of BRACE initialization (`_initializable`), because that is what - // the storage performs - `std::is_nothrow_move_constructible_v` asks about `T(T&&)` instead, and - // the two can disagree: braced initialization considers initializer-list constructors first, so a - // type can promise a nothrow move and still throw from `T{std::move(t)}`. Believing the parens - // answer here would destroy the old alternative and then fail to replace it. + // The arm is chosen by asking the storage what it can do, never by a trait that restates it: + // `std::is_nothrow_move_constructible_v` asks about `T(T&&)`, the storage performs `T{...}`, and + // the two can disagree - braced initialization considers initializer-list constructors first, so a + // type can promise a nothrow move and still throw from `T{std::move(t)}`. Believing that promise + // here would destroy the old alternative and then fail to replace it. template - constexpr void _reinit(Args &&...args) noexcept(detail::_nothrow_initializable) - requires has_type && detail::_initializable - && (detail::_nothrow_initializable || detail::_nothrow_initializable) + constexpr void _reinit(Args &&...args) noexcept(detail::_nothrow_makeable) + requires has_type && detail::_makeable + && (detail::_nothrow_makeable || detail::_nothrow_makeable) { - if constexpr (detail::_nothrow_initializable) { + if constexpr (detail::_nothrow_makeable) { ::std::destroy_at(this); ::std::construct_at(this, ::std::in_place_type, FWD(args)...); } else { @@ -416,10 +432,8 @@ struct sum { // The alternative that changes is CONSTRUCTED, never assigned to: assignment asks nothing of `Ts` // beyond what construction already asks, so a type with no assignment operator at all is still // assignable through the sum. - constexpr sum &operator=(sum const &other) noexcept((... && detail::_nothrow_initializable)) - requires(... - && (detail::_initializable - && (detail::_nothrow_initializable || detail::_nothrow_initializable))) + constexpr sum &operator=(sum const &other) noexcept(_nothrow_copyable) + requires _copy_assignable { if (this != &other) { detail::invoke_type_variadic_union( // @@ -438,7 +452,7 @@ struct sum { // Moving is offered only where it cannot throw, for the reason given on _reinit - and where it can, // a nothrow-copyable sum is still assignable from an rvalue, by copy. constexpr sum &operator=(sum &&other) noexcept - requires(... && detail::_nothrow_initializable) + requires _nothrow_movable { if (this != &other) { detail::invoke_type_variadic_union( // @@ -747,7 +761,8 @@ template explicit sum(T) -> sum<::std::remove_cvref_t>; * @return TODO */ [[nodiscard]] constexpr auto as_sum(auto &&src) // - noexcept(detail::_nothrow_initializable<::std::remove_cvref_t, decltype(src)>) -> decltype(auto) + noexcept(::std::is_nothrow_constructible_v>, decltype(src)>) + -> decltype(auto) requires(not some_in_place_type) { return sum<::std::remove_cvref_t>(FWD(src)); @@ -761,8 +776,8 @@ template explicit sum(T) -> sum<::std::remove_cvref_t>; */ template [[nodiscard]] constexpr auto as_sum(::std::in_place_type_t, auto &&...args) // - noexcept(detail::_nothrow_initializable) -> decltype(auto) - requires detail::_initializable + noexcept(::std::is_nothrow_constructible_v, ::std::in_place_type_t, decltype(args)...>) -> decltype(auto) + requires ::std::is_constructible_v, ::std::in_place_type_t, decltype(args)...> { return sum(::std::in_place_type, FWD(args)...); } From d71e863e6a903109b6ae1a120067b96ef88cc190 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sun, 12 Jul 2026 13:25:58 +0100 Subject: [PATCH 3/5] Pin what `choice`'s defaulted special members follow `choice` adds no state to the `sum` it derives from, and defaults all five of its special members - so each must behave exactly as the base's, down to its noexcept and its constraints. Nothing said so, and nothing would have noticed if it stopped being true: dropping a `= default`, promising a `noexcept` the base does not, or narrowing a requires-clause would all have passed in silence. They are compared against `sum`'s across the property axes that make the answers differ - a throwing copy, a move-only alternative, an immovable one - and each comparison is backed by what the answer actually is, so that an equality cannot be satisfied by both sides being wrong at once. Closes #291 Assisted-by: Claude:claude-opus-4-8[1m] --- tests/fn/choice.cpp | 62 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/fn/choice.cpp b/tests/fn/choice.cpp index 47a642dc..58e8ac83 100644 --- a/tests/fn/choice.cpp +++ b/tests/fn/choice.cpp @@ -35,6 +35,25 @@ struct NonCopyable final { template concept can_in_place = requires(Args... args) { S{std::in_place_type, args...}; }; +// Every special member of choice is defaulted, and choice adds no state to sum - so each must behave +// exactly as sum's, down to its noexcept and its constraints. +template consteval bool special_members_follow_sum() +{ + using C = fn::choice; + using S = fn::sum; + return std::is_copy_constructible_v == std::is_copy_constructible_v // + && std::is_nothrow_copy_constructible_v == std::is_nothrow_copy_constructible_v // + && std::is_move_constructible_v == std::is_move_constructible_v // + && std::is_nothrow_move_constructible_v == std::is_nothrow_move_constructible_v // + && std::is_copy_assignable_v == std::is_copy_assignable_v // + && std::is_nothrow_copy_assignable_v == std::is_nothrow_copy_assignable_v // + && std::is_move_assignable_v == std::is_move_assignable_v // + && std::is_nothrow_move_assignable_v == std::is_nothrow_move_assignable_v // + && std::is_destructible_v == std::is_destructible_v // + && std::is_nothrow_destructible_v == std::is_nothrow_destructible_v // + && std::is_trivially_destructible_v == std::is_trivially_destructible_v; +} + } // anonymous namespace TEST_CASE("choice non-monadic functionality", "[choice]") @@ -821,3 +840,46 @@ TEST_CASE("choice assignment", "[choice][assignment]") SUCCEED(); } } + +TEST_CASE("choice special members", "[choice]") +{ + using fn::choice; + + // choice declares all five, and defaults all five: the copy constructor because a user-declared + // move constructor would otherwise delete it, and the two assignments because they would otherwise + // be deleted and suppressed in turn. Removing a `= default`, adding a `noexcept` the base does not + // promise, or narrowing a requires-clause would all break the equalities below. + static_assert(special_members_follow_sum()); + static_assert(special_members_follow_sum()); + static_assert(special_members_follow_sum()); + static_assert(special_members_follow_sum()); + static_assert(special_members_follow_sum()); + + // ... and what they follow it TO, so that the equalities cannot be satisfied by both being wrong + static_assert(std::is_nothrow_copy_constructible_v>); + static_assert(std::is_nothrow_move_constructible_v>); + static_assert(std::is_nothrow_copy_assignable_v>); + static_assert(std::is_nothrow_move_assignable_v>); + static_assert(std::is_nothrow_destructible_v>); + static_assert(not std::is_trivially_destructible_v>); // the base destroys its alternative + + // a throwing copy is reported as one, rather than promised away + static_assert(std::is_copy_constructible_v>); + static_assert(not std::is_nothrow_copy_constructible_v>); + static_assert(std::is_nothrow_move_constructible_v>); + static_assert(not std::is_nothrow_copy_assignable_v>); + static_assert(std::is_nothrow_move_assignable_v>); + + // an alternative that cannot be copied takes copy construction and copy assignment with it + static_assert(not std::is_copy_constructible_v>); + static_assert(not std::is_copy_assignable_v>); + static_assert(std::is_nothrow_move_constructible_v>); + static_assert(std::is_nothrow_move_assignable_v>); + + // ... and one that can be neither copied nor moved leaves none of the four + static_assert(not std::is_copy_constructible_v>); + static_assert(not std::is_move_constructible_v>); + static_assert(not std::is_copy_assignable_v>); + static_assert(not std::is_move_assignable_v>); + SUCCEED(); +} From 45a5387c7a9d0e78d5896671046682112dbdec4e Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sun, 12 Jul 2026 16:26:28 +0100 Subject: [PATCH 4/5] Derive what pack's storage performs from the code that performs it The pack half of #291. Relocation asked `is_nothrow_constructible_v` - direct-initialization - where the aggregate initialization in `_append` copy-initializes each element from its source ([dcl.init.aggr]/4.3), which cannot reach an explicit constructor: an element with an explicit nothrow move and a throwing copy made `append` promise a `noexcept` the deed cannot keep. Nothing asked whether relocation was possible at all, so appending to a pack whose element cannot make the move hard-errored inside the body where a clean rejection was owed. `as_pack` asked the same question of `pack` itself, where parenthesized initialization performs no brace elision - false for every argument list, a permanent under-promise. `invoke_r` promised direct-initialization of `Ret` and performed a return statement. `_make_element` now declares its own contract, `_relocatable_element` asks the element holder for the copy-initialization it performs, `_append` is constrained on and specified by both questions, and every `append` overload asks the `_append` it calls. `as_pack` is constrained on and specified by the braces it performs, and declares its return type. `invoke_r` delegates to the new `pack_impl::_invoke_r`, asking the same `INVOKE` question `sum::invoke_r` asks - which also aligns `invoke_r`, accepted by `std::invoke_r` and `sum` and until now rejected by `pack`. Closes #291 Assisted-by: Claude:claude-fable-5 --- include/fn/detail/pack_impl.hpp | 45 ++++++++++++++++-- include/fn/pack.hpp | 62 +++++++++---------------- include/fn/sum.hpp | 14 +++++- tests/fn/pack.cpp | 81 ++++++++++++++++++++++++++++++++- 4 files changed, 155 insertions(+), 47 deletions(-) diff --git a/include/fn/detail/pack_impl.hpp b/include/fn/detail/pack_impl.hpp index bc7adeaa..d122c032 100644 --- a/include/fn/detail/pack_impl.hpp +++ b/include/fn/detail/pack_impl.hpp @@ -27,7 +27,9 @@ template <::std::size_t I, typename T> struct _element { // type is reference-related to it, i.e. binds), but gcc reads the braced form as materializing a // temporary and rejects the bind - while accepting the equivalent declaration. The cast keeps both // compilers on the binding path. -template [[nodiscard]] constexpr auto _make_element(auto &&...args) -> T +template +[[nodiscard]] constexpr auto _make_element(auto &&...args) noexcept(_nothrow_initializable) -> T + requires _initializable { if constexpr (::std::is_reference_v) return T(FWD(args)...); // a single argument, so this is a cast expression: it binds @@ -35,6 +37,25 @@ template [[nodiscard]] constexpr auto _make_element(auto &&...args) return T{FWD(args)...}; } +// The two questions anyone above may ask about constructing an element, asked OF the function that +// constructs it rather than restated in terms of a trait - the same discipline as `_makeable` beside +// `make_variadic_union`, and for the same reason: a restatement can drift from the deed. +template +concept _makeable_element = requires { _make_element(::std::declval()...); }; + +template +concept _nothrow_makeable_element = requires { requires noexcept(_make_element(::std::declval()...)); }; + +// One element's relocation into a new pack: the copy-initialization its holder performs +// ([dcl.init.aggr]/4.3, reached through brace elision), asked of the holder one element at a time - +// `_element{src}` elides into the same member copy-initialization. This excludes explicit +// constructors, where `is_[nothrow_]constructible_v` would admit them. +template +concept _relocatable_element = requires { E{::std::declval()}; }; + +template +concept _nothrow_relocatable_element = requires { requires noexcept(E{::std::declval()}); }; + template struct pack_impl; template struct _pack_append; @@ -89,6 +110,16 @@ struct pack_impl<::std::index_sequence, Ts...> : _element... { FWD(fn), static_cast>(FWD(self)._element::v)..., FWD(args)...); } + template + requires(not(... || (_some_pack || _some_sum))) + static constexpr auto _invoke_r(Self &&self, Fn &&fn, Args &&...args) // + noexcept(_is_nothrow_invocable_r..., Args &&...>::value) -> Ret + requires(_is_invocable_r..., Args && ...>::value) + { + return ::fn::detail::_invoke_r( + FWD(fn), static_cast>(FWD(self)._element::v)..., FWD(args)...); + } + template <::std::size_t I, typename Self> static constexpr decltype(auto) _get(Self &&self) noexcept requires(I < size) @@ -101,15 +132,19 @@ struct pack_impl<::std::index_sequence, Ts...> : _element... { // Every existing element is relocated into the new pack, so appending weighs that as well as the // construction of the element being appended. + template + static constexpr bool _relocatable + = (... && _relocatable_element<_element, apply_const_lvalue_t>); + template static constexpr bool _nothrow_relocatable - = (... && ::std::is_nothrow_constructible_v>); + = (... && _nothrow_relocatable_element<_element, apply_const_lvalue_t>); template static constexpr auto _append(Self &&self, auto &&...args) // - noexcept(_nothrow_relocatable && _nothrow_initializable) + noexcept(_nothrow_relocatable && _nothrow_makeable_element) -> pack_impl<::std::index_sequence, Ts..., T> - requires(not _some_sum) && (not _some_pack) && _initializable + requires(not _some_sum) && (not _some_pack) && _relocatable && _makeable_element { return {static_cast>(FWD(self)._element::v)..., _make_element(FWD(args)...)}; @@ -121,6 +156,8 @@ struct pack_impl<::std::index_sequence, Ts...> : _element... { && ::std::remove_cvref_t::_impl::template _nothrow_relocatable) -> // typename _pack_append<::std::remove_cvref_t, Ts...>::impl requires _some_pack && (::std::is_same_v<::std::remove_cvref_t, ::std::remove_cvref_t>) + && _relocatable && ::std::remove_cvref_t::_impl::template + _relocatable { using type = _pack_append<::std::remove_cvref_t, Ts...>::impl; return FWD(other)._invoke(FWD(other), [&self](auto &&...args) { diff --git a/include/fn/pack.hpp b/include/fn/pack.hpp index 4e10930c..5797cd2e 100644 --- a/include/fn/pack.hpp +++ b/include/fn/pack.hpp @@ -46,10 +46,8 @@ template struct pack : detail::pack_impl<::std::index_sequence_ */ template [[nodiscard]] constexpr auto append(::std::in_place_type_t, auto &&...args) & // - noexcept(_impl::template _nothrow_relocatable && detail::_nothrow_initializable) - -> append_type - requires detail::_initializable - && requires { append_type{_impl::template _append(*this, FWD(args)...)}; } + noexcept(noexcept(_impl::template _append(::std::declval(), FWD(args)...))) -> append_type + requires requires { append_type{_impl::template _append(*this, FWD(args)...)}; } { return {_impl::template _append(*this, FWD(args)...)}; } @@ -63,10 +61,8 @@ template struct pack : detail::pack_impl<::std::index_sequence_ */ template [[nodiscard]] constexpr auto append(::std::in_place_type_t, auto &&...args) const & // - noexcept(_impl::template _nothrow_relocatable - && detail::_nothrow_initializable) -> append_type - requires detail::_initializable - && requires { append_type{_impl::template _append(*this, FWD(args)...)}; } + noexcept(noexcept(_impl::template _append(::std::declval(), FWD(args)...))) -> append_type + requires requires { append_type{_impl::template _append(*this, FWD(args)...)}; } { return {_impl::template _append(*this, FWD(args)...)}; } @@ -80,10 +76,8 @@ template struct pack : detail::pack_impl<::std::index_sequence_ */ template [[nodiscard]] constexpr auto append(::std::in_place_type_t, auto &&...args) && // - noexcept(_impl::template _nothrow_relocatable && detail::_nothrow_initializable) - -> append_type - requires detail::_initializable - && requires { append_type{_impl::template _append(::std::move(*this), FWD(args)...)}; } + noexcept(noexcept(_impl::template _append(::std::declval(), FWD(args)...))) -> append_type + requires requires { append_type{_impl::template _append(::std::move(*this), FWD(args)...)}; } { return {_impl::template _append(::std::move(*this), FWD(args)...)}; } @@ -97,10 +91,8 @@ template struct pack : detail::pack_impl<::std::index_sequence_ */ template [[nodiscard]] constexpr auto append(::std::in_place_type_t, auto &&...args) const && // - noexcept(_impl::template _nothrow_relocatable - && detail::_nothrow_initializable) -> append_type - requires detail::_initializable - && requires { append_type{_impl::template _append(::std::move(*this), FWD(args)...)}; } + noexcept(noexcept(_impl::template _append(::std::declval(), FWD(args)...))) -> append_type + requires requires { append_type{_impl::template _append(::std::move(*this), FWD(args)...)}; } { return {_impl::template _append(::std::move(*this), FWD(args)...)}; } @@ -248,12 +240,10 @@ template struct pack : detail::pack_impl<::std::index_sequence_ */ template [[nodiscard]] constexpr auto invoke_r(Fn &&fn, auto &&...args) & // - noexcept(noexcept(_impl::_invoke(::std::declval(), FWD(fn), FWD(args)...)) - && ::std::is_nothrow_constructible_v) -> Ret - requires requires { _impl::_invoke(*this, FWD(fn), FWD(args)...); } - && ::std::is_convertible_v + noexcept(noexcept(_impl::template _invoke_r(::std::declval(), FWD(fn), FWD(args)...))) -> Ret + requires requires { _impl::template _invoke_r(*this, FWD(fn), FWD(args)...); } { - return _impl::_invoke(*this, FWD(fn), FWD(args)...); + return _impl::template _invoke_r(*this, FWD(fn), FWD(args)...); } /** @@ -267,12 +257,10 @@ template struct pack : detail::pack_impl<::std::index_sequence_ */ template [[nodiscard]] constexpr auto invoke_r(Fn &&fn, auto &&...args) const & // - noexcept(noexcept(_impl::_invoke(::std::declval(), FWD(fn), FWD(args)...)) - && ::std::is_nothrow_constructible_v) -> Ret - requires requires { _impl::_invoke(*this, FWD(fn), FWD(args)...); } - && ::std::is_convertible_v + noexcept(noexcept(_impl::template _invoke_r(::std::declval(), FWD(fn), FWD(args)...))) -> Ret + requires requires { _impl::template _invoke_r(*this, FWD(fn), FWD(args)...); } { - return _impl::_invoke(*this, FWD(fn), FWD(args)...); + return _impl::template _invoke_r(*this, FWD(fn), FWD(args)...); } /** @@ -286,13 +274,10 @@ template struct pack : detail::pack_impl<::std::index_sequence_ */ template [[nodiscard]] constexpr auto invoke_r(Fn &&fn, auto &&...args) && // - noexcept(noexcept(_impl::_invoke(::std::declval(), FWD(fn), FWD(args)...)) - && ::std::is_nothrow_constructible_v) -> Ret - requires requires { _impl::_invoke(::std::move(*this), FWD(fn), FWD(args)...); } - && ::std::is_convertible_v + noexcept(noexcept(_impl::template _invoke_r(::std::declval(), FWD(fn), FWD(args)...))) -> Ret + requires requires { _impl::template _invoke_r(::std::move(*this), FWD(fn), FWD(args)...); } { - return _impl::_invoke(::std::move(*this), FWD(fn), FWD(args)...); + return _impl::template _invoke_r(::std::move(*this), FWD(fn), FWD(args)...); } /** @@ -306,13 +291,10 @@ template struct pack : detail::pack_impl<::std::index_sequence_ */ template [[nodiscard]] constexpr auto invoke_r(Fn &&fn, auto &&...args) const && // - noexcept(noexcept(_impl::_invoke(::std::declval(), FWD(fn), FWD(args)...)) - && ::std::is_nothrow_constructible_v) -> Ret - requires requires { _impl::_invoke(::std::move(*this), FWD(fn), FWD(args)...); } - && ::std::is_convertible_v + noexcept(noexcept(_impl::template _invoke_r(::std::declval(), FWD(fn), FWD(args)...))) -> Ret + requires requires { _impl::template _invoke_r(::std::move(*this), FWD(fn), FWD(args)...); } { - return _impl::_invoke(::std::move(*this), FWD(fn), FWD(args)...); + return _impl::template _invoke_r(::std::move(*this), FWD(fn), FWD(args)...); } }; @@ -348,9 +330,9 @@ template <::std::size_t I, some_pack P> */ [[nodiscard]] constexpr auto as_pack() noexcept -> pack<> { return {}; } template - requires(not some_in_place_type) + requires(not some_in_place_type) && detail::_initializable, T, Args...> [[nodiscard]] constexpr auto as_pack(T &&src, Args &&...args) // - noexcept(::std::is_nothrow_constructible_v, T, Args...>) -> decltype(auto) + noexcept(detail::_nothrow_initializable, T, Args...>) -> pack { return pack{FWD(src), FWD(args)...}; } diff --git a/include/fn/sum.hpp b/include/fn/sum.hpp index 85c73c41..a9211cf7 100644 --- a/include/fn/sum.hpp +++ b/include/fn/sum.hpp @@ -753,6 +753,17 @@ struct sum { template explicit sum(::std::in_place_type_t, auto &&...) -> sum; template explicit sum(T) -> sum<::std::remove_cvref_t>; +namespace detail { +// The value lift builds `sum>` - a class whose body refuses an in_place tag as +// an alternative. MSVC (C++20 mode) compiles a candidate's noexcept-specifier once deduction +// succeeds, BEFORE the constraint rejects the tag, so the specifier must not name that sum unless +// the guard holds: a guarded specialization, as `_nothrow_eq_with` is, and for the same reason. +template constexpr inline bool _nothrow_sum_lift = false; +template + requires(not some_in_place_type) +constexpr inline bool _nothrow_sum_lift = ::std::is_nothrow_constructible_v>, Src>; +} // namespace detail + // Lifts /** * @brief TODO @@ -761,8 +772,7 @@ template explicit sum(T) -> sum<::std::remove_cvref_t>; * @return TODO */ [[nodiscard]] constexpr auto as_sum(auto &&src) // - noexcept(::std::is_nothrow_constructible_v>, decltype(src)>) - -> decltype(auto) + noexcept(detail::_nothrow_sum_lift) -> decltype(auto) requires(not some_in_place_type) { return sum<::std::remove_cvref_t>(FWD(src)); diff --git a/tests/fn/pack.cpp b/tests/fn/pack.cpp index c6267aa9..15b88ad2 100644 --- a/tests/fn/pack.cpp +++ b/tests/fn/pack.cpp @@ -13,6 +13,8 @@ #include #include +#include +#include #include #include @@ -34,6 +36,9 @@ concept can_append_in_place = requires(V v, Args... args) { FWD(v).append(std::i template concept can_append = requires(V v, Arg arg) { FWD(v).append(FWD(arg)); }; +template +concept can_as_pack = requires(Args &&...args) { fn::as_pack(FWD(args)...); }; + } // namespace TEST_CASE("pack", "[pack]") @@ -209,6 +214,19 @@ TEST_CASE("append value categories", "[pack][append]") CHECK(std::move(std::as_const(s)).append(std::in_place_type).invoke_r(check) == 1); CHECK(std::move(s).append(std::in_place_type).invoke_r(check) == 1); } + + WHEN("aggregate forwarding") + { + // braces elide through the aggregate: three ints construct the array element in place + auto q = s.append(std::in_place_type>, 5, 6, 7); + static_assert(std::same_as>>); + CHECK(q.invoke([](int, std::string_view, A, std::array const &a) // + { return a[0] * 100 + a[1] * 10 + a[2]; }) + == 567); + static_assert(fn::pack<>{}.append(std::in_place_type>, 5, 6, 7).invoke([](auto const &a) { + return a[0] * 100 + a[1] * 10 + a[2]; + }) == 567); + } } WHEN("deduced type") @@ -304,6 +322,22 @@ TEST_CASE("append value categories", "[pack][append]") static_assert(not can_append_in_place, fn::sum>); static_assert(not can_append_in_place, fn::sum_for>); + // relocation is part of the question: the elements already held move into the new pack in the + // pack's own value category, and an element which cannot make that move rejects the append + // instead of hard-erroring inside it + static_assert(can_append_in_place> &&, int, int>); + static_assert(not can_append_in_place> &, int, int>); + static_assert(not can_append_in_place> const &, int, int>); + static_assert(not can_append> const &, int>); + // the same guard covers both operands when appending a whole pack + static_assert(can_append &, pack>>); + static_assert(not can_append &, pack> const &>); + static_assert(not can_append> const &, pack>); + // const propagates through a reference element, so a const pack cannot rebind it into the new + // pack's non-const reference element - the same pack, non-const, can + static_assert(can_append_in_place &, int, int>); + static_assert(not can_append_in_place const &, int, int>); + SUCCEED(); } } @@ -322,6 +356,11 @@ TEST_CASE("pack noexcept", "[pack][noexcept]") Quiet(Quiet const &) noexcept {} Quiet(Quiet &&) noexcept {} }; + struct Evil { + Evil() = default; + explicit Evil(Evil &&) noexcept {} + Evil(Evil const &) { throw std::runtime_error{"copied"}; } + }; WHEN("append") { @@ -334,9 +373,35 @@ TEST_CASE("pack noexcept", "[pack][noexcept]") // ... and so can relocating an element the pack already holds, even where the new one cannot static_assert(not noexcept(std::declval &>().append(std::in_place_type, 1))); static_assert(not noexcept(std::declval &>().append(1))); // deduced form + + // relocation copy-initializes ([dcl.init.aggr]/4.3), and copy-initialization cannot reach an + // explicit constructor - a promise computed from is_nothrow_constructible_v would see the + // explicit nothrow move and promise what the deed cannot keep: this throw must propagate + static_assert(std::is_nothrow_constructible_v); // the questions disagree here ... + static_assert(not noexcept(std::declval &&>().append(std::in_place_type, 1))); // ... deed answers + pack p{Evil{}}; // elided, nothing copied yet + CHECK_THROWS_AS(std::move(p).append(std::in_place_type, 1), std::runtime_error); SUCCEED(); } + WHEN("as_pack") + { + // asked of the braces as_pack performs - a question about pack's (nonexistent) constructors + // would answer false for every argument list + static_assert(noexcept(fn::as_pack(true, 12))); + static_assert(std::same_as>); + static_assert(not noexcept(fn::as_pack(Throwy{}))); // moving the argument in can throw ... + Throwy t{}; + static_assert(noexcept(fn::as_pack(t))); // ... but an lvalue binds, and binding cannot + struct NoMove { + NoMove() = default; + NoMove(NoMove &&) = delete; + }; + static_assert(not can_as_pack); // constrained on the same initialization ... + static_assert(can_as_pack); // ... which a binding reference element satisfies + CHECK(fn::as_pack(t, 12).invoke([&t](Throwy const &x, int i) { return &x == &t && i == 12; })); + } + WHEN("invoke") { constexpr auto nothrow_fn = [](auto &&...) noexcept { return 0; }; @@ -345,7 +410,21 @@ TEST_CASE("pack noexcept", "[pack][noexcept]") static_assert(not noexcept(std::declval &>().invoke(throwing_fn))); static_assert(noexcept(std::declval &>().template invoke_r(nothrow_fn))); static_assert(not noexcept(std::declval &>().template invoke_r(throwing_fn))); - SUCCEED(); + + // Ret is entered by INVOKE's implicit conversion; the promise asks that call rather than + // is_nothrow_constructible_v, whose direct-initialization would reach the explicit move + static_assert(std::is_nothrow_constructible_v); + pack p{1}; + Evil ev{}; + auto give_evil = [&ev](int) noexcept -> Evil && { return std::move(ev); }; + static_assert(not noexcept(std::declval &>().template invoke_r(give_evil))); + CHECK_THROWS_AS(p.invoke_r(give_evil), std::runtime_error); + // exactly std::invoke_r's own promise - conservative for a prvalue result (the deed elides, + // and indeed nothing throws below), but never a lie + auto make_evil = [](int) noexcept -> Evil { return {}; }; + static_assert(noexcept(std::declval &>().template invoke_r(make_evil)) + == std::is_nothrow_invocable_r_v); + CHECK_NOTHROW(p.invoke_r(make_evil)); } } From c36d9d794ea8e5593880793913cb1005f437d546 Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sun, 12 Jul 2026 16:31:17 +0100 Subject: [PATCH 5/5] Pin what pack is: an aggregate, at every layer `pack` declares no constructor and no special member - `_element`, `pack_impl` and `pack` itself are aggregates, and everything above rests on that: brace initialization with elision, the structural type, and special members composed from the elements' own. A constructor added anywhere in the stack would change all of it in silence; these assertions fail instead. Assisted-by: Claude:claude-fable-5 --- tests/fn/pack.cpp | 79 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/fn/pack.cpp b/tests/fn/pack.cpp index 15b88ad2..bfa60554 100644 --- a/tests/fn/pack.cpp +++ b/tests/fn/pack.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include @@ -39,8 +40,86 @@ concept can_append = requires(V v, Arg arg) { FWD(v).append(FWD(arg)); }; template concept can_as_pack = requires(Args &&...args) { fn::as_pack(FWD(args)...); }; +// pack declares no special member, so each one is implicit - composed from the _element bases which +// hold the data. Ask the elements, not the element types: a holder of a reference or of a const +// member answers assignment differently than the bare type would. +template using elem = fn::detail::_element<0, T>; +template consteval bool special_members_follow_elements() +{ + using P = fn::pack; + bool ok = std::is_copy_constructible_v

== (... && std::is_copy_constructible_v>); + ok = ok && std::is_nothrow_copy_constructible_v

== (... && std::is_nothrow_copy_constructible_v>); + ok = ok && std::is_move_constructible_v

== (... && std::is_move_constructible_v>); + ok = ok && std::is_nothrow_move_constructible_v

== (... && std::is_nothrow_move_constructible_v>); + ok = ok && std::is_copy_assignable_v

== (... && std::is_copy_assignable_v>); + ok = ok && std::is_nothrow_copy_assignable_v

== (... && std::is_nothrow_copy_assignable_v>); + ok = ok && std::is_move_assignable_v

== (... && std::is_move_assignable_v>); + ok = ok && std::is_nothrow_move_assignable_v

== (... && std::is_nothrow_move_assignable_v>); + ok = ok && std::is_destructible_v

== (... && std::is_destructible_v>); + ok = ok && std::is_nothrow_destructible_v

== (... && std::is_nothrow_destructible_v>); + ok = ok && std::is_trivially_destructible_v

== (... && std::is_trivially_destructible_v>); + return ok; +} + } // namespace +// pack is an aggregate at every layer - the element holder, the implementation, and pack itself. +// Everything above rests on that: brace initialization with elision, the structural type, and +// special members composed from the elements' own. A constructor added anywhere in the stack would +// change all of it in silence; these assertions fail instead. +TEST_CASE("design: an aggregate, at every layer", "[pack][design]") +{ + using fn::pack; + + WHEN("aggregates") + { + static_assert(std::is_aggregate_v>); + static_assert(std::is_aggregate_v>); + static_assert(std::is_aggregate_v, int, int &>>); + static_assert(std::is_aggregate_v>); + static_assert(std::is_aggregate_v>); + SUCCEED(); + } + + WHEN("brace elision") + { + // flat initializers reach the elements through two layers of subaggregate without written braces + constexpr pack p{3, 0.5}; + static_assert(fn::get<0>(p) == 3); + CHECK(fn::get<1>(p) == 0.5); + } + + WHEN("special members follow the elements") + { + static_assert(special_members_follow_elements<>()); + static_assert(special_members_follow_elements()); + static_assert(special_members_follow_elements()); + static_assert(special_members_follow_elements()); + static_assert(special_members_follow_elements>()); + static_assert(special_members_follow_elements()); + static_assert(special_members_follow_elements()); + static_assert(special_members_follow_elements, int &>()); + + struct Immovable { + Immovable(Immovable &&) = delete; + }; + static_assert(special_members_follow_elements()); + + // anchors, so the equalities above cannot be satisfied by both sides being wrong at once + static_assert(std::is_nothrow_move_constructible_v>); + static_assert(not std::is_nothrow_copy_constructible_v>); + static_assert(not std::is_copy_constructible_v>>); + static_assert(std::is_nothrow_move_constructible_v>>); + static_assert(not std::is_move_constructible_v>); + static_assert(std::is_copy_constructible_v>); + static_assert(not std::is_copy_assignable_v>); + static_assert(not std::is_copy_assignable_v>); + static_assert(std::is_trivially_destructible_v>); + static_assert(not std::is_trivially_destructible_v>); + SUCCEED(); + } +} + TEST_CASE("pack", "[pack]") { using fn::pack;