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:
<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.
functor::operator|(some_monadic_type auto &&v, auto &&self) noexcept — functor.hpp:41.
pack_impl::_swap_invoke(Self &&, Fn &&, Args &&...) noexcept — pack_impl.hpp:55.
<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.
fn::optionalandfn::expectedcompute a precise conditionalnoexceptfor their monadicmembers. 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 anoexceptboundary and callsstd::terminate.The same operation, written the two supported ways, has two different exception behaviours:
operator|is libfn's headline API — it is what the README's examples are written in — so this isthe path users are most likely to take.
Verified (g++ 16.1.1, clang 22.1.6, C++20 — both agree)
The four boundaries
A pipeline call passes through four unconditional
noexceptdeclarations, any one of which is enoughto turn a propagating exception into a terminate:
<verb>_t::operator()(auto &&fn) const noexcept -> functor<...>— e.g.and_then.hpp:66. Copiesthe callable into
pack<as_value_t<Fn>>; that copy can throw.functor::operator|(some_monadic_type auto &&v, auto &&self) noexcept—functor.hpp:41.pack_impl::_swap_invoke(Self &&, Fn &&, Args &&...) noexcept—pack_impl.hpp:55.<verb>_t::apply::operator()(V &&v, Fn &&fn) const noexcept— e.g.and_then.hpp:82, which simplyforwards 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 thenielbloid
operator()and theapply::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.
with
_swap_invokeandoperator|likewisenoexcept(...)-computed from what they call. There is nocycle: each layer's spec depends only on the layer below it.
Related: #279 and #280 (the same over-promise, in
operator&and insum/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
noexceptcoverage totests/fn/and_then.cpp(#85), which assertsthe current behaviour so these flip when the fix lands.