TIL: nullopt_t is not equality-comparable, but monostate is

On Slack, Kilian Henneberger asked for some STL types that are copyable but not equality-comparable. One example is std::function<int()>; see “On function_ref and string_view (2019-05-10). The simplest example is struct S {}; — for compatibility with C, C++98 provided every class type with a copy constructor, but none of them with comparison operators.

So, I said, a good STL example would be std::monostate from the <variant> header, which is basically just a trivial tag type, right? Wrong!

constexpr std::monostate m;
static_assert(m == m);

It turns out that monostate needs to be totally ordered, because if it weren’t, then variant<int, monostate> wouldn’t be totally ordered either.

But you know what’s like a variant<int, monostate>? optional<int>! Everyone knows that optional<int> is totally ordered, right? So:

constexpr std::nullopt_t n;
bool b = (n == n);  // Error: does not compile!

It turns out that std::nullopt_t is not equality-comparable.

This makes sense in hindsight, actually. My new mental model is:

  • std::nullopt_t is a tag type, like std::in_place_t or even std::nullptr_t. Its only purpose is to be used in syntactic constructs like myOptional = std::nullopt. It’s not a “value-semantic” type; its only job is to participate in overload sets and then get out of the way as fast as possible. An optional never really “holds” a nullopt_t value. In a value-semantic, Tony-van-Eerd kind of sense, nullopt_t doesn’t have any “values.” You physically can make a vector<nullopt_t>, just like you can make a vector<nullptr_t> or a vector<in_place_t>, but you shouldn’t. Notably, you physically cannot make an optional<nullopt_t>.

  • std::monostate is a value-semantic type, like bool — it just has one fewer value in its domain. It can be stored in variants, or containers, or sets (it’s ordered!), or unordered_sets (it’s hashable!), or anywhere else you might use a value-semantic type like bool. It’s totally fine to make an optional<monostate>.

Given time, Ranges will erase this mental model

Problem: The C++20 Ranges library has Opinions about comparability. Ranges deals only with value-semantic (or reference-semantic) types; it has very little respect for syntactic fillips like nullopt or nullptr. For example, Ranges assumes that “Type X is comparable with type Y” must necessarily mean “Values of type X are comparable with values of type Y,” i.e., X and Y cover the same domain, i.e., common_reference_t<X, Y> must exist and be equality-comparable with itself.

template <class T, class U>
concept equality_comparable_with =
    equality_comparable<T> &&
    equality_comparable<U> &&
    common_reference_with<
        const remove_reference_t<T>&,
        const remove_reference_t<U>&> && ~~~;

This breaks badly for syntactic tags like nullptr_t and nullopt_t. And because Ranges concepts are used to constrain all the Ranges algorithms, we end up with ridicule-worthy situations like (Godbolt):

std::unique_ptr<int> a[10];
std::optional<int> b[10];

auto it = std::find(a, a+10, nullptr);      // OK
auto jt = std::find(b, b+10, std::nullopt); // OK

std::ranges::find(a, a+10, nullptr);      // Error: no viable overload
std::ranges::find(a, a+10, std::nullopt); // Error: no viable overload

(By the way, if you’re not used to Concepts error messages, take a look at the error messages in that Godbolt!) The problem is that, because nullopt_t is not equality_comparable with itself (or in the other case because const unique_ptr<int>& is not convertible to common_reference_with<unique_ptr<int>, nullptr_t>), Ranges thinks that the two types are not comparable at all.

This is the subject of Justin Bassett’s recent paper P2405 “nullopt_t and nullptr_t should both have operator<=> and operator== (July 2021), currently slated for “not C++23” but maybe C++26.

In other words, the gravitational pull of Ranges will probably end up eroding the distinction I just described between “value-semantic” types like monostate and “syntactic tag” types like in_place_t and nullptr_t, simply because Ranges cannot (currently) deal with types that aren’t value-semantic in the most heavyweight sense possible. Maybe this is a good thing — maybe “syntactic tag” types are an abomination and we should be glad if they all turn into proper values. But I doubt it.

Posted 2022-03-07