Skip to content

Verbs and operator| are unconditionally noexcept — a throwing callback terminates where the member call propagates #285

Description

@Bronek

fn::optional and fn::expected compute a precise conditional noexcept for their monadic
members. The verb layer then throws that answer away: every step of the pipeline is declared
unconditionally noexcept, so an exception that the member would propagate instead crosses a
noexcept boundary and calls std::terminate.

The same operation, written the two supported ways, has two different exception behaviours:

fn::optional<int> o{42};
auto f = [](int i) noexcept(false) -> fn::optional<int> { throw std::runtime_error{"boom"}; };

o.and_then(f);        // noexcept(false) - the exception propagates.  Correct.
o | fn::and_then(f);  // noexcept(true)  - the exception hits a noexcept boundary -> std::terminate.

operator| is libfn's headline API — it is what the README's examples are written in — so this is
the path users are most likely to take.

Verified (g++ 16.1.1, clang 22.1.6, C++20 — both agree)

using O = fn::optional<int>;
constexpr auto throwing = [](int i) noexcept(false) -> O { return {i}; };
constexpr auto nothrow_ = [](int i) noexcept -> O { return {i}; };

// the member gets it right
static_assert(not noexcept(std::declval<O &>().and_then(throwing)));
static_assert(noexcept(std::declval<O &>().and_then(nothrow_)));

// the verb does not
static_assert(noexcept(std::declval<O &>() | fn::and_then(throwing)));   // <-- claims noexcept
static_assert(noexcept(std::declval<O &&>() | fn::and_then(throwing)));

// transform, and expected, behave the same way
static_assert(not noexcept(std::declval<O &>().transform(throwing_t)));
static_assert(noexcept(std::declval<O &>() | fn::transform(throwing_t)));

// and constructing the functor copies the callable into a pack - that copy can throw too
struct ThrowingCopyFn final {
  ThrowingCopyFn() = default;
  ThrowingCopyFn(ThrowingCopyFn const &) noexcept(false) {}
  ThrowingCopyFn(ThrowingCopyFn &&) noexcept(false) {}
  auto operator()(int i) const noexcept -> O { return {i}; }
};
static_assert(noexcept(fn::and_then(std::declval<ThrowingCopyFn const &>())));  // <-- claims noexcept

The four boundaries

A pipeline call passes through four unconditional noexcept declarations, any one of which is enough
to turn a propagating exception into a terminate:

  1. <verb>_t::operator()(auto &&fn) const noexcept -> functor<...> — e.g. and_then.hpp:66. Copies
    the callable into pack<as_value_t<Fn>>; that copy can throw.
  2. functor::operator|(some_monadic_type auto &&v, auto &&self) noexceptfunctor.hpp:41.
  3. pack_impl::_swap_invoke(Self &&, Fn &&, Args &&...) noexceptpack_impl.hpp:55.
  4. <verb>_t::apply::operator()(V &&v, Fn &&fn) const noexcept — e.g. and_then.hpp:82, which simply
    forwards to FWD(v).and_then(FWD(fn)), discarding its conditional spec.

Sites: the pattern is uniform across every verb — and_then, or_else, transform,
transform_error, inspect, inspect_error, recover, fail, discard, filter — in both the
nielbloid operator() and the apply::operator().

Fix

Unlike #280, this needs no new traits and is not blocked on #45: the underlying members already
compute the answer, so the chain can simply propagate it bottom-up.

// apply: key off the member's own spec
template <some_monadic_type V, typename Fn>
[[nodiscard]] constexpr auto operator()(V &&v, Fn &&fn) const
    noexcept(noexcept(FWD(v).and_then(FWD(fn)))) -> same_kind<V &&> auto
  requires invocable_and_then<Fn &&, V &&>

// the nielbloid: key off constructing the functor's pack
[[nodiscard]] constexpr auto operator()(auto &&fn) const
    noexcept(::std::is_nothrow_constructible_v<functor<and_then_t, decltype(fn)>, decltype(fn)>)
    -> functor<and_then_t, decltype(fn)>

with _swap_invoke and operator| likewise noexcept(...)-computed from what they call. There is no
cycle: each layer's spec depends only on the layer below it.

Related: #279 and #280 (the same over-promise, in operator& and in sum/pack). Distinct from
#254, which is about the members under-promising on their sum/pack arms — the opposite direction, a
layer down. Found while adding noexcept coverage to tests/fn/and_then.cpp (#85), which asserts
the current behaviour so these flip when the fix lands.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingrelease-0.1Planned for release 0.1

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions