Skip to content

pfn::expected comparison constraints can depend on themselves #381

Description

@Bronek

pfn::expected's comparison operators declare their expected operand by its concrete type, and constrain themselves on a comparison involving the other operand. Because that parameter is a non-deduced context, deduction can never reject a candidate on the left operand, so the constraint is evaluated for every left operand whatsoever — and when the right operand's ADL reaches the same expected, the constraint asks the question it is answering.

Both compilers reject it outright, so a question that should answer false is a hard error.

Repro (verified on main, gcc 16.1.1 and clang 22.1.6, C++20)

Nothing here involves comparing an expected with anything:

#include <pfn/expected.hpp>

struct P { int i; bool operator==(P const &) const = default; };
struct C { int i; bool operator==(C const &) const = default; };

using E = pfn::expected<P, C>;
using G = E (*)(int);            // a function pointer whose ADL reaches E

template <typename A, typename B>
concept eq = requires(A const &a, B const &b) { a == b; };

static_assert(not eq<P, G>);     // expected: passes. actual: hard error
gcc:   error: satisfaction of atomic constraint 'requires{{*x == v} -> convertible_to<bool>;}' ... depends on itself
clang: error: satisfaction of constraint 'requires { { *x == v } -> convertible_to<bool>; }' depends on itself

The same thing reaches the fn layer today, through any class template instantiated over such a type:

fn::just<G> j{nullptr};          // fine - declaring it is not the problem
bool const b = (j == j);         // hard error
// and asking is no better: `requires(fn::just<G> const &a) { a == a; }` hard-errors too

Mechanism

All four comparison operators in include/pfn/expected.hpp (lines 1052, 1069, 1084, 1095) have this shape:

template <class T2>
  requires(not ::std::is_void_v<T> && not Policy::template is_specialization<T2>)
constexpr friend bool operator==(typename Policy::template type<T, E> const &x, T2 const &v)
  requires requires { { *x == v } -> ::std::convertible_to<bool>; }

typename Policy::template type<T, E> is a dependent qualified name, hence a non-deduced context: nothing about the left argument can make deduction fail. Constraints are checked before implicit conversion sequences are formed, so for any expression a == b whose right operand brings this hidden friend in by ADL, the constraint runs — and if b's type also brings it back in, satisfaction depends on itself.

The trigger therefore needs only:

  • a right operand whose associated entities include the expected — a function pointer or reference returning one, or a class template instantiated over one (fn::just<G>, fn::pack<G>), and
  • a left operand that is not itself that expected.

Why std::expected does not have this

[expected.object.eq] specifies *x == v as a Mandates, not a Constraint, so a letter-conforming implementation never evaluates it during overload resolution. pfn promotes it to a constraint (the source marks the neighbouring noexcept "extension"), which is a real improvement — asking answers instead of hard-erroring in the body — and libstdc++ makes the same choice. But libstdc++ pairs it with a deduced left operand:

template<typename _Up, same_as<_Tp> _Vp>
  requires (!__expected::__is_expected<_Up>)
    && requires (const _Tp& __t, const _Up& __u) { { __t == __u } -> convertible_to<bool>; }
friend constexpr bool operator==(const expected<_Vp, _Er>& __x, const _Up& __v)

so a left operand that is not an expected fails deduction, and the candidate dies before the recursive requirement is ever reached. Measured side by side in the same TU shape:

ask pfn::expected std::expected (libstdc++)
expected == same expected 1 1
expected == other expected 1 1
expected == value 1 1
expected == unexpected 1 1
value == expected (reversed) 1 1
value == fn-pointer returning that expected hard error 0
convertible-to-value == fn-pointer returning that expected hard error 0
value == unrelated struct 0 0

Every intended comparison already works; only the questions that should answer false are poisoned.

Proposed fix

Adopt libstdc++'s pairing: keep the constraint, make the expected operand deducible, so deduction rejects a non-expected left operand before the constraint is evaluated. With the Policy indirection the parameter cannot be spelled as a deducible expected<V, E>, so the deduced parameter has to be the operand itself:

template <class Self, class T2>
  requires ::std::same_as<Self, typename Policy::template type<T, E>>
        && (not ::std::is_void_v<T>) && (not Policy::template is_specialization<T2>)
constexpr friend bool operator==(Self const &x, T2 const &v) //
    noexcept(...)
  requires requires { { *x == v } -> ::std::convertible_to<bool>; }

Associated constraints are a conjunction checked in order, so the same_as test short-circuits before the self-referential requirement. The same treatment belongs on all four operators for consistency, whether or not each is reachable today.

Notes

  • unexpected's operator== is not implicated: the failures above attribute to the operator at line 1084.
  • This blocks the "defaulted operator== and operator<=> on pack" item of Minor issues #380 — a defaulted comparison forces exactly this question at class-instantiation time, for every element type, and fn::pack is what every verb functor stores its callables in.
  • Worth a WG21 look: an implementation that reads the Mandates as an invitation to constrain (as both pfn and libstdc++ do) gets this unless it also deduces the operand.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingrelease-0.1Planned for release 0.1

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions