Skip to content

and_then overlooks the copy when a callback returns a carrier by reference #436

Description

@Bronek

When an and_then callback returns a carrier by lvalue reference, and_then copies it into its return value. Its constraints and noexcept specification omit that copy. If the copy throws, an incorrectly declared noexcept call terminates. If the carrier cannot be copied, just::and_then passes its constraints and then fails to compile inside the function body; optional::and_then cannot even be probed, because its deduced return type instantiates the body.

This affects fn::just<T>, fn::just<void>, fn::just<T&>, fn::optional<T>, and fn::optional<T&>. Additionally, pfn::optional<T> and pfn::optional<T&> are also affected, where noexcept is an extension.

The following reproducer compiles cleanly on g++ 16.2.1 and clang++ 22.1.8 with -std=c++20 -Wall -Wextra -Wpedantic -Werror. The assertions demonstrate the incorrect behaviour:

#include <fn/and_then.hpp>
#include <fn/just.hpp>
#include <fn/optional.hpp>

#include <memory>

// A carrier whose copy may throw, returned from a noexcept callback by lvalue reference
struct ThrowingCopy {
  ThrowingCopy() = default;
  ThrowingCopy(ThrowingCopy const &) noexcept(false) {}
};

inline fn::just<ThrowingCopy> gj{};
inline fn::optional<ThrowingCopy> go{};
inline fn::just<std::unique_ptr<int>> gu{};
inline int gi = 1;

constexpr auto retJ = [](auto &&...) noexcept -> fn::just<ThrowingCopy> & { return gj; };
constexpr auto retO = [](auto &&...) noexcept -> fn::optional<ThrowingCopy> & { return go; };
constexpr auto retU = [](auto &&...) noexcept -> fn::just<std::unique_ptr<int>> & { return gu; };

// Each member returns the callback's carrier by value, copying it, yet promises noexcept
static_assert(noexcept(fn::just<int>{1}.and_then(retJ)));
static_assert(noexcept(fn::just<void>{}.and_then(retJ)));
static_assert(noexcept(fn::optional<int>{1}.and_then(retO)));
static_assert(noexcept(fn::optional<int &>{gi}.and_then(retO)));

// The copy is not part of the constraint either: the member is viable for a non-copyable result,
// and calling it is a hard error
template <typename S, typename F>
concept member_and_then = requires(S s, F f) { s.and_then(f); };
static_assert(member_and_then<fn::just<int>, decltype(retU)>);

int main() {}

The first four assertions show that and_then promises noexcept even though copying the returned carrier may throw. The final assertion shows that its constraints accept a non-copyable carrier returned by reference. Adding fn::just<int>{1}.and_then(retU); then fails inside just::and_then with a deleted-copy-constructor error on both compilers.

The fix should account for this copy in both the constraints and noexcept, including through the pipeline and_then. Prvalue results should remain unaffected: guaranteed copy elision means they need no copy or move.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingrelease-0.2Planned for release 0.2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions