From b729a01b7d7f43971faf6cac438782778db6543d Mon Sep 17 00:00:00 2001 From: Bronek Kozicki Date: Sat, 11 Jul 2026 22:42:29 +0100 Subject: [PATCH] Add assignment to `sum`, with the strong exception guarantee Without it `fn::expected>` - the graded monad the sum exists to serve - was not assignable at all. `_reinit` mirrors reinit-expected ([expected.object.assign]): a sum always holds one of its alternatives, and there is no valueless state to fall back on, so the one it holds is destroyed only once its replacement is certain. Which arm applies is decided per ALTERNATIVE, not for the sum as a whole - a nothrow-copyable one is built straight over the old, and one whose copy can throw is copied into a temporary first, where only its (nothrow) move goes near the storage. A sum whose alternatives need different arms is therefore still assignable, and the spec is the conjunction of what each arm promises. The standard's third arm - snapshot the old alternative, roll back on throw - has nothing to offer a sum. It would exist for an alternative that can neither be built nor moved without throwing; but every alternative is also a possible OLD alternative, since one is assigned over itself whenever the sum already holds it, and snapshotting that same type would throw in turn. There is no safe path, so such an alternative is constrained away rather than half-served. The alternative being replaced is CONSTRUCTED over, never assigned to - even when it does not change. Assignment therefore asks nothing of `Ts` beyond what construction already asks, and a type with no assignment operator at all is still assignable through the sum. `choice` adds no state of its own, so it only has to declare the two operators to inherit all of this: its move constructor would otherwise have deleted the implicit copy assignment and suppressed the implicit move assignment - the same reason `sum` cannot rely on implicit ones either. Closes #183 Assisted-by: Claude:claude-opus-4-8[1m] --- include/fn/choice.hpp | 7 ++ include/fn/sum.hpp | 70 ++++++++++++++ tests/fn/choice.cpp | 38 ++++++++ tests/fn/expected.cpp | 22 +++++ tests/fn/sum.cpp | 215 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 352 insertions(+) diff --git a/include/fn/choice.hpp b/include/fn/choice.hpp index 6a85f5db..39889d86 100644 --- a/include/fn/choice.hpp +++ b/include/fn/choice.hpp @@ -170,6 +170,13 @@ struct choice : sum { constexpr choice(choice &&other) = default; constexpr ~choice() = default; + // Declared because the move constructor above would otherwise delete the implicit copy assignment + // and suppress the implicit move assignment. Defaulted, so both inherit the base sum's - its + // constraints, its strong guarantee, and its computed noexcept (which an explicit one here would + // contradict, and thereby delete). + constexpr choice &operator=(choice const &other) = default; + constexpr choice &operator=(choice &&other) = default; + /** * @brief TODO * diff --git a/include/fn/sum.hpp b/include/fn/sum.hpp index 9dbee87c..0c4991cc 100644 --- a/include/fn/sum.hpp +++ b/include/fn/sum.hpp @@ -12,6 +12,7 @@ #include #include +#include #include #include @@ -372,6 +373,75 @@ struct sum { }); } + // Replaces the alternative in hand with a `T` built from `args`, mirroring reinit-expected + // ([expected.object.assign]): a sum always holds one of its alternatives - there is no valueless + // state to fall back on - so the one it holds is destroyed only once its replacement is certain. + // Which arm applies is decided per alternative, not for the sum as a whole: a nothrow-copyable one + // is built straight over the old, and one whose copy can throw is copied into a temporary first, + // where only its (nothrow) move goes near the storage. + // + // The standard's third arm - snapshot the old alternative, roll back on throw - has nothing to + // offer here, which is why the constraints below leave no room for it. It would exist for an + // alternative that can neither be built nor moved without throwing; but every alternative is also + // 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. + template + constexpr void _reinit(Args &&...args) noexcept(detail::_nothrow_initializable) + requires has_type && detail::_initializable + && (detail::_nothrow_initializable || ::std::is_nothrow_move_constructible_v) + { + if constexpr (detail::_nothrow_initializable) { + ::std::destroy_at(this); + ::std::construct_at(this, ::std::in_place_type, FWD(args)...); + } 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)); + } + } + + /** + * @brief Copy assignment, with the strong exception guarantee + * + * @param other TODO + * @return TODO + */ + // 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)) + requires(... + && (::std::is_copy_constructible_v + && (::std::is_nothrow_copy_constructible_v || ::std::is_nothrow_move_constructible_v))) + { + if (this != &other) { + detail::invoke_type_variadic_union( // + other.data, other.index, + [this](::std::in_place_type_t, auto const &v) { this->template _reinit(v); }); + } + return *this; + } + + /** + * @brief Move assignment, with the strong exception guarantee + * + * @param other TODO + * @return TODO + */ + // 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) + { + if (this != &other) { + detail::invoke_type_variadic_union( // + ::std::move(other).data, other.index, + [this](::std::in_place_type_t, auto &&v) { this->template _reinit(FWD(v)); }); + } + return *this; + } + /** * @brief TODO * diff --git a/tests/fn/choice.cpp b/tests/fn/choice.cpp index b346f10f..47a642dc 100644 --- a/tests/fn/choice.cpp +++ b/tests/fn/choice.cpp @@ -11,6 +11,8 @@ #include +#include +#include #include namespace { @@ -783,3 +785,39 @@ TEST_CASE("choice transform", "[choice][transform]") } } } + +TEST_CASE("choice assignment", "[choice][assignment]") +{ + using fn::choice; + + // choice adds no state of its own, so its assignment is the base sum's: same strong guarantee, + // same constraints, same noexcept - it only has to be declared, since choice's move constructor + // would otherwise delete the implicit copy assignment and suppress the implicit move assignment + static_assert(std::is_copy_assignable_v>); + static_assert(std::is_move_assignable_v>); + static_assert(std::is_nothrow_copy_assignable_v>); + static_assert(not std::is_nothrow_copy_assignable_v>); + static_assert(std::is_nothrow_move_assignable_v>); + + WHEN("the alternative changes") + { + choice a{42}; + choice const b{true}; + a = b; + CHECK(a == choice{true}); + CHECK(a.has_value(std::in_place_type)); + + a = choice{12}; + CHECK(a == choice{12}); + } + + WHEN("constexpr") + { + static_assert([] { + choice a{42}; + a = choice{true}; + return a == choice{true}; + }()); + SUCCEED(); + } +} diff --git a/tests/fn/expected.cpp b/tests/fn/expected.cpp index 4091b859..42f2d717 100644 --- a/tests/fn/expected.cpp +++ b/tests/fn/expected.cpp @@ -624,6 +624,28 @@ TEST_CASE("graded monad", "[expected][sum][graded][and_then][or_else][sum_value] } } + WHEN("assignment") + { + // a graded monad is assignable because its summed error is: without sum::operator= the whole + // expected> would not be assignable at all + using T = fn::expected>; // Error is an enum: the order is per-platform + static_assert(std::is_copy_assignable_v); + static_assert(std::is_move_assignable_v); + + T a{12}; + T const b{::fn::unexpect, fn::sum{FileNotFound}}; + a = b; + CHECK(a.error() == fn::sum{FileNotFound}); + a = T{42}; + CHECK(a.value() == 42); + + static_assert([] { + T a{12}; + a = T{::fn::unexpect, fn::sum{true}}; + return a.error() == fn::sum{true}; + }()); + } + WHEN("noexcept") { // the widening arms relocate BOTH sides into the summed result - self's, and the callback's - diff --git a/tests/fn/sum.cpp b/tests/fn/sum.cpp index 5fd160c5..f2988a75 100644 --- a/tests/fn/sum.cpp +++ b/tests/fn/sum.cpp @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -40,6 +41,21 @@ template auto read_nttp() return S.invoke([](auto const &...args) { return (0.0 + ... + static_cast(args)); }); } +// Counts live instances through every path a sum can take one: unlike TestType, whose implicit copy +// constructor does not count (leaving its counter negative once anything copies it), this one is +// balanced, so an assignment can be asked whether it destroyed what it replaced. +struct Counted final { + static int live; + int v; + + Counted(int i) noexcept : v(i) { ++live; } // NOLINT: implicit on purpose + Counted(Counted const &o) noexcept : v(o.v) { ++live; } + Counted(Counted &&o) noexcept : v(o.v) { ++live; } + ~Counted() noexcept { --live; } + bool operator==(Counted const &) const noexcept = default; +}; +int Counted::live = 0; + // Comparison probes are type-keyed rather than the file's usual value-taking lambda: sum<> has no // values to pass one (its default constructor is deleted, by design). template @@ -716,6 +732,22 @@ TEST_CASE("sum noexcept", "[sum][noexcept]") static_assert(not noexcept(std::declval const &>() != std::declval const &>())); SUCCEED(); } + + WHEN("assignment") + { + // assignment weighs the construction it performs: the copy, or - where that copy can throw - the + // move of the temporary it is made into + static_assert(std::is_nothrow_copy_assignable_v>); + static_assert(std::is_nothrow_copy_assignable_v>); + static_assert(std::is_nothrow_move_assignable_v>); + static_assert(not std::is_nothrow_copy_assignable_v>); + static_assert(std::is_nothrow_move_assignable_v>); + + // moving is nothrow wherever it is offered at all: a throwing move is constrained away, having + // nowhere to fail safely (see TEST_CASE "sum assignment") + static_assert(not std::is_move_assignable_v>); + SUCCEED(); + } } TEST_CASE("sum type collapsing", "[sum][transform][normalized]") @@ -1163,3 +1195,186 @@ TEST_CASE("sum", "[sum][has_value][get_ptr]") static_assert(d.get_ptr(std::in_place_type) == nullptr); static_assert(*d.get_ptr(std::in_place_type) == 12); } + +TEST_CASE("sum assignment", "[sum][assignment]") +{ + using fn::sum; + + WHEN("same alternative") + { + sum a{12}; + sum const b{42}; + a = b; + CHECK(a == sum{42}); + CHECK(a.has_value(std::in_place_type)); + + a = sum{7}; + CHECK(a == sum{7}); + } + + WHEN("the alternative changes") + { + sum a{12}; + sum const b{true}; + a = b; + CHECK(a == sum{true}); + CHECK(a.has_value(std::in_place_type)); + CHECK(not a.has_value(std::in_place_type)); + + a = sum{42}; + CHECK(a == sum{42}); + CHECK(a.has_value(std::in_place_type)); + } + + WHEN("self-assignment") + { + sum a{12}; + sum const &self = a; // through an alias: `a = a` is a warning, and rightly so + a = self; + CHECK(a == sum{12}); + CHECK(a.has_value(std::in_place_type)); + } + + WHEN("the replaced alternative is destroyed") + { + using T = fn::sum_for; // sum<...> order is platform-specific; sum_for normalizes + Counted::live = 0; + { + T a{std::in_place_type, 7}; + CHECK(Counted::live == 1); + a = T{12}; // the Counted goes + CHECK(Counted::live == 0); + a = T{std::in_place_type, 9}; + CHECK(Counted::live == 1); + a = T{std::in_place_type, 3}; // same alternative: the old one still goes + CHECK(Counted::live == 1); + } + CHECK(Counted::live == 0); + } + + WHEN("constexpr") + { + static_assert([] { + sum a{42}; + sum const b{true}; + a = b; + return a == sum{true}; + }()); + static_assert([] { + sum a{true}; + a = sum{42}; + return a == sum{42}; + }()); + static_assert([] { + sum a{42}; + sum const &self = a; + a = self; + return a == sum{42}; + }()); + SUCCEED(); + } + + WHEN("strong exception guarantee") + { + // The alternative held is destroyed only once its replacement is certain: where the copy can + // 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) + { + if (v == 0) + throw std::runtime_error("copy"); + } + ThrowingCopy(ThrowingCopy &&o) noexcept : v(o.v) {} + }; + static_assert(std::is_copy_assignable_v>); + static_assert(not std::is_nothrow_copy_assignable_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 + + WHEN("the two arms in one sum") + { + // whichever arm the incoming alternative needs, the one being replaced survives a throw + struct ThrowingMove final { // takes the direct arm: its move is never called + int v; + ThrowingMove(int i) noexcept : v(i) {} // NOLINT: implicit on purpose + ThrowingMove(ThrowingMove const &o) noexcept : v(o.v) {} + ThrowingMove(ThrowingMove &&) { throw std::runtime_error("move"); } + }; + using M = fn::sum_for; + constexpr auto value = fn::overload{[](ThrowingCopy const &t) { return t.v; }, // + [](ThrowingMove const &t) { return t.v; }}; + + M m{std::in_place_type, 7}; // in place: its move would throw + M const bad_copy{ThrowingCopy{0}}; + CHECK_THROWS_AS(m = bad_copy, std::runtime_error); // the copy into the temporary throws + CHECK(m.invoke(value) == 7); // ... and the ThrowingMove is still there + + M const good{std::in_place_type, 3}; + m = good; // the direct arm: copied straight over, its throwing move never used + CHECK(m.invoke(value) == 3); + } + } + + WHEN("constraints") + { + // Assignment CONSTRUCTS the new alternative, so it asks nothing of the alternatives' own + // assignment operators: a type with none at all is still assignable through the sum. + static_assert(not std::is_copy_assignable_v); + static_assert(not std::is_copy_constructible_v); + static_assert(not std::is_copy_assignable_v>); // it cannot be copied at all + static_assert(not std::is_move_assignable_v>); + + struct NoAssign final { + int v; + constexpr NoAssign(int i) noexcept : v(i) {} // NOLINT: implicit on purpose + constexpr NoAssign(NoAssign const &) noexcept = default; + constexpr NoAssign(NoAssign &&) noexcept = default; + NoAssign &operator=(NoAssign const &) = delete; + NoAssign &operator=(NoAssign &&) = delete; + }; + static_assert(not std::is_copy_assignable_v); + static_assert(std::is_copy_assignable_v>); // ... yet the sum of it is + static_assert(std::is_move_assignable_v>); + + // An alternative that can be neither copied nor moved without throwing has no safe arm: the + // temporary would itself have to be moved into the storage, and snapshotting the old one is no + // help when the old one is the same type. It is constrained away rather than half-served. + struct ThrowingBoth final { + ThrowingBoth() = default; + ThrowingBoth(ThrowingBoth const &) noexcept(false) {} + ThrowingBoth(ThrowingBoth &&) noexcept(false) {} + }; + static_assert(not std::is_copy_assignable_v>); + static_assert(not std::is_move_assignable_v>); + + // The arm is chosen per ALTERNATIVE, not for the sum as a whole: one that is nothrow-copyable is + // built straight over the old, and one whose copy can throw is copied into a temporary first. A + // sum whose alternatives need different arms is therefore still assignable. + struct QuietCopy final { // needs the direct arm: its move throws + QuietCopy() = default; + QuietCopy(QuietCopy const &) noexcept = default; + QuietCopy(QuietCopy &&) noexcept(false) {} + }; + struct QuietMove final { // needs the temporary arm: its copy throws + QuietMove() = default; + QuietMove(QuietMove const &) noexcept(false) {} + QuietMove(QuietMove &&) noexcept = default; + }; + static_assert(std::is_nothrow_copy_assignable_v>); // direct + static_assert(std::is_move_assignable_v>); // the copy assignment takes the rvalue + static_assert(std::is_copy_assignable_v>); // through the temporary + static_assert(not std::is_nothrow_copy_assignable_v>); + static_assert(std::is_nothrow_move_assignable_v>); + + using Mixed = fn::sum_for; // one alternative per arm + static_assert(std::is_copy_assignable_v); + static_assert(not std::is_nothrow_copy_assignable_v); // QuietMove's copy can throw + static_assert(std::is_move_assignable_v); // the copy assignment takes the rvalue ... + static_assert(not std::is_nothrow_move_assignable_v); // ... and says that it can throw + } +}