Космическое сравнение

Многие критикуют современный C++ и вектор его развития за «раздутость» и сложность. Мол, зачем вы внедряете столько нового функционала, от него голова идёт кругом, и изучать без того сложный язык становится практически невозможно. Я не согласен с подобными заявлениями, так как считаю, что практически всё, что было внедрено, начиная с C++11, пошло языку только на пользу и сделало использование языка массовым программистом проще. В этой статье я бы хотел поговорить об изменениях в части сравнения объектов, которые были внесены в C++20. Эти нововведения очень хорошо характеризуют современный C++: они могут казаться сложными и перегруженными, но когда дело доходит до обычного кода среднестатистического продукта, оказывается, что с ними код становится компактнее и проще.

Прибытие корабля

C++20 является крупной вехой в развитии языка, т. к. он положил начало целой плеяде крупных нововведений, с частью которых до сих пор не могут справиться компиляторы. Но были нововведения и поменьше, которые не являются настолько важными, как, к примеру, модули или сопрограммы (англ. coroutine). Одним из таких нововведений является оператор трёхстороннего сравнения operator<=>, также известный как «spaceship operator» (оператор «космический корабль»). В английском языке его так назвали, потому что выговаривать проще, но в русском, на мой взгляд, это преимущество отсутствует, потому вряд ли приживётся.

Этот оператор позаимствован C++ из других языков, и его назначение — быть единым оператором сравнения, который покрывает все «сравнительные» нужды класса. Используется он так же, как любой другой оператор сравнения: a <=> b. Возвращаемым значением operator<=> будет некий объект, тип которого не определён, но он должен поддерживать сравнение с нулём. Интерпретируется результат следующим образом:

C++-код

Интерпретация

a <=> b < 0

a < b

a <=> b <= 0

a ≤ b

a <=> b == 0

a = b

a <=> b != 0

a ≠ b

a <=> b > 0

a > b

a <=> b >= 0

a ≥ b

Здесь никаких сюрпризов. Вооружившись этим знанием, напишем простенький пример:

#include <print>

class SillyInt
{
public:
    SillyInt(int value):
        m_Value(value)
    {}

    int operator<=>(const SillyInt& rhs) const
    {
        if(m_Value < rhs.m_Value)
            return -1;
        if(m_Value > rhs.m_Value)
            return 1;
        return 0;
    }
private:
    int m_Value;
};

int main()
{
    SillyInt a{1}, b{2};
    std::println("a < b: {}", a <=> b < 0);
    std::println("a = b: {}", a <=> b == 0);
}

Я использовал int в качестве типа возвращаемого значения, т. к. стандарт это позволяет, а для примера ничего проще int не найти. Но почему стандарт не регламентирует тип возвращаемого значения, ведь банальный int подходит, что ещё нужно? Дел�� в том, что комитет по стандартизации, в рамках добавления нового оператора, решил, что было бы неплохо различать, к какой категории относится выполняемое сравнение, а не только его результат. Таким образом появилось три поддерживаемых категории (определены в <compare>):

  • std::strong_ordering

  • std::weak_ordering

  • std::partial_ordering

Давайте разберёмся с каждой из них.

strong_ordering

К этой категории относится такое сравнение, при котором множество всех сравниваемых значений является линейно упорядоченным (англ. total order). Т. е. любое значение множества можно сравнить с любым другим и получить непротиворечивый результат (либо a ≤ b, либо a ≥ b). Более того, если a < b, тогда f(a) < f(b) для любых a и b, при условии, что f используют только те данные, которые являются релевантными (англ. comparison-salient) сравнению a < b. Чтобы стало понятнее, давайте рассмотрим пример:

#include <print>
#include <vector>
int main()
{
    std::vector<int> a{1, 2, 3, 4};
    std::vector<int> b(1500, 1);
    b.assign_range(a);
    std::println("a.capacity={}, b.capacity={}", a.capacity(), b.capacity());
    std::println("a < b: {}; a.capacity() < b.capacity(): {} ", a < b, a.capacity() < b.capacity());
    static_assert(std::is_same_v<decltype(a <=> b), std::strong_ordering>);
}

Его вывод:

a.capacity=4, b.capacity=1500
a < b: false; a.capacity() < b.capacity(): true

Мы сравнили два вектора, в которых содержатся одинаковые элементы, но их вместимость отличается. Т. е. применили функцию capacity() (f из формулы) к нашим объектам, получили результат, отличный от сравнения непосредственно объектов, но наше сравнение всё равно является std::strong_ordering, потому что capacity() не задействовано в сравнении внутри operator<=>. В результате все формальности соблюдены, и мы имеем строгий порядок.

Другим примером линейно упорядоченного множества может выступить множество натуральных чисел, соответственно, int, а также ранее реализованный SillyInt. Да и практически любой другой тип, который есть в стандартной библиотеке [1] и вне её: std::string, std::map и т. д. Это самая простая категория, которая любому человеку должна быть интуитивно понятна — отличный вариант для использования по умолчанию.

weak_ordering

Если мы ослабим наши требования и разрешим a < b и f(a) ≥ f(b), тогда мы заходим на территорию std::weak_ordering. Эта категория применяется тогда, когда при сравнении мы используем либо лишь часть данных, либо же каким-то образом изменяем данные при сравнении. Каноническим примером для данной категории является сравнение строк без учёта регистра:

#include <algorithm>
#include <cctype>
#include <string>
#include <compare>
#include <print>

class Person
{
public:
    Person(std::string name):
        m_Name(name)
    {}

    const std::string& name() const
    {
        return m_Name;
    }

    std::weak_ordering operator<=>(const Person& rhs) const
    {
        return std::lexicographical_compare_three_way(
            m_Name.begin(), m_Name.end(),
            rhs.m_Name.begin(), rhs.m_Name.end(),
            [](char l, char r) { return std::tolower(l) <=> std::tolower(r); }
        );
    }
private:
    std::string m_Name;
};

int main()
{
    Person john{"John"};
    Person weirdJohn{"JoHN"};
    std::println("john == weirdJohn: {}", john <=> weirdJohn == 0);
    std::println("john.name() == weirdJohn.name(): {}",
        john.name() <=> weirdJohn.name() == 0);
}

Этот код даёт ожидаемый результат:

john == weirdJohn: true
john.name() == weirdJohn.name(): false

Т. е. получается, что мы использовали одни и те же данные в сравнении (объект m_Name), но получили разные результаты на выходе. Можно придумать ещё вот такой пример:

class Commit
{
public:
    Commit(std::string hash):
        m_Hash(hash)
    {}

    const std::string& hash() const
    {
        return m_Hash;
    }

    std::weak_ordering operator<=>(const Commit& rhs) const
    {
        return std::string_view(m_Hash.begin(), m_Hash.begin() + 8) <=>
            std::string_view(rhs.m_Hash.begin(), rhs.m_Hash.begin() + 8);
    }
private:
    std::string m_Hash;
};

Мы используем только 8 байт в сравнении, потому что считаем, что этого достаточно, а 8 байт сравнить можно куда быстрее, чем 40. Можно ещё много примеров придумать, но для меня они все выглядят довольно искусственно: я просто не вижу себя применяющим std::weak_ordering к моим типам. Сам же слабый порядок с нами давно присутствует в std::map, к примеру: этот контейнер состоит из std::pair<key, value>, но сортировка идёт только по key, что как раз подходит под наше определение слабого порядка. Тем не менее std::weak_ordering к внутренней упорядоченности std::map не имеет никакого отношения, этот тип применяется исключительно к operator<=>.

И здесь мы вступаем на интересную территорию. Все мы знаем, что для того, чтобы мы могли поместить объекты нашего типа в std::map, он должен иметь operator<, но ему совершенно не нужен operator==. В результате, когда мы ищем элемент в std::vector, мы находим (или нет) элемент, равный искомому, а когда мы ищем в std::map, тогда мы находим элемент, эквивалентный искомому. В первом случае, если a ≮ b и a ≯ b, тогда a = b, а во втором — нет, такое условие будет означать только эквивалентность, а не равенство. В итоге мы получаем тот же линейный порядок, но построенный не на отношении непосредственно объектов, а на отношении классов эквивалентности, т. е. порядок, выстроенный на основании некого аспекта объекта. Примеры: сортировка людей по возрасту, собак по весу, гаек по диаметру и т. п. Мы также могли бы отсортировать натуральные числа в слабом порядке, используя сортировку по количеству цифр в числе, вместо сравнения чисел.

Заметьте, рассуждая в предыдущем абзаце о слабом порядке, я ни разу не упомянул std::weak_ordering; потому что мы не говорили об operator<=>. Мы ещё вернёмся к этому, когда будем подводить итог.

partial_ordering

Последней категорией, которую поддерживает operator<=>, является std::partial_ordering. Эта категория описывает сравнение на множестве, в котором не каждое сравнение двух объектов может привести к «осмысленному» результату. Т. е. для произвольных a и b возможен следующий результат: a ≰ b и a ≱ b. Кроме того, как и слабый порядок, частичный порядок позволяет a < b и f(a) ≥ f(b).

Самым банальным примером такого множества в программировании являются вещественные типы стандарта IEEE 754 (float, double). В этом стандарте присутствует значение NaN, которое обладает следующим свойством: NaN ≰ x и NaN ≱ x для любых x из double. Из жизни тоже можно привести немало примеров; вот один из них:

struct Track
{
    std::string albumName;
    std::string trackName;
    std::size_t trackNumber;

    std::partial_ordering operator<=>(const Track& rhs) const
    {
        if(albumName == rhs.albumName)
            return trackNumber <=> rhs.trackNumber;
        return std::partial_ordering::unordered;
    }
};

Идея следующая: у нас есть класс, описывающий музыкальный трек, принадлежащий некоторому альбому. Треки можно отсортировать по порядку, но только в рамках одного альбома. Треки разных альбомов никак осмысленно сравнить нельзя, т. к. относительно друг друга они не упорядочены. Как и в случае с std::weak_ordering, этот пример высосан из пальца, потому что std::partial_ordering в своём коде я не вижу, а следовательно, придумать реалистичный пример мне тяжело.

В стандарте С++ этот тип применяется только в одном месте (и это на целую единицу больше, чем std::weak_ordering): он является возвращаемым значением operator<=> для вещественных типов.

Применение порядков

Итак, с идеей, которая лежит за тремя типами порядков, мы разобрались. Теперь предлагаю поговорить о применении их в реальном коде. Начну, пожалуй, с последнего типа — std::partial_ordering. Т. к. это возвращаемый тип operator<=>, то мы можем использовать эту информацию в наших алгоритмах. К примеру, std::sort требует, чтобы оператор сравнения выдавал по меньшей мере слабый порядок. Возьмём такой простой код:

std::vector<float> vec{1.f, .5f, NAN};
static_assert(std::is_same_v<decltype(vec <=> vec), std::partial_ordering>);
std::sort(vec.begin(), vec.end());

std::sort требует слабый порядок словами, но мы нарушаем это требование, имея оператор сравнения дающий частичный порядок. В результате мы имеем неопределённое поведение (НП).

Могли бы мы (стандарт) не словами описать требование, а сделать проверку? Конечно, очень легко сделать так, что этот код просто не будет компилироваться, используя знания о возвращаемом типе operator<=>. Так почему это не сделано? Скорее всего, потому, что это сломает кучу существующего кода. В целом я всегда выступаю за прогресс и большую безопасность, но я не уверен, что внедрение жёсткой проверки в std::sort было бы хорошей идеей.

Более того, я считаю, что это было бы плохой идеей для любой xyz::sort, потому что очень малый процент пользователей столкнётся с НП, а вот тех, кто хочет сортировать вещественные числа, в которых нет NAN, думаю, очень много. Да, решение с НП плохое, но оно является результатом дизайна вещественных чисел, std::sort тут не при чём. И это плавно подводит меня к следующей мысли: если для типа X правильным типом возвращаемого значения operator<=> является std::partial_ordering, тогда X не должен содержать operator<=> вообще!

Возьмём класс Track из предыдущего раздела: ему совершенно не нужно реализовывать operator<=>, более того, из-за того, что у нас выходит std::partial_ordering, это даже вредно. Посудите сами: какой смысл реализовывать общий оператор сравнения, который лишь на часть данных будет выдавать осмысленный результат? Упорядочивание треков по номеру в рамках одного альбома является лишь одним вариантом, как можно упорядочить объекты этого типа. Он ничем не лучше и не хуже, скажем, упорядочивания по имени трека. Поэтому операция сравнения для двух объектов типа Track должна быть внешней, по отношению к классу: пусть тот, кому нужно упорядочить набор объектов Track, пишет функцию сравнения; сам класс Track операции неравенства поддерживать не должен — они имеют слишком ограниченный смысл.

Другой пример возьмём из документации по Qt: Qt предлагает рассмотреть QOperatingSystemVersion в качестве кандидата на operator<=> с std::partial_ordering, потому что он может содержать версии различных ОС и сравнивать их между собой нет никакого смысла. Но результат такого решения оставляет желать лучшего, потому что явное использование operator<=> выглядит так:

if(ver1 <=> ver2 < 0)
    ...

Тогда как правильно было бы писать вот так, потому что мы должны учитывать unordered:

if(ver1 <=> ver2 == std::partial_ordering::less)
    ...

Вы себе представляете, чтобы кто-то так писал на постоянной основе? Вот и я не представляю, особенно учитывая тот факт, что пользовательский код вообще operator<=> явно не будет вызывать, но об этом мы позже поговорим.

В результате, погнавшись за удобным/знакомым синтаксисом, люди создают классы, интерфейс которых очень легко использовать неверно, а это противоречит одному очень важному принципу программирования: «Код должно быть легко использовать по назначению, но тяжело (лучше невозможно) не по назначению». Но пример из Qt интересен тем, что, в отличие от Track, он реально нуждается в функции сравнения. Потому что версия — это достаточно примитивный объект, значение которого используется либо для вывода, либо для сравнения с другой версией, а значит, сравнивать нам придётся.

Но это можно легко сделать через внешнюю функцию, которую можно назвать compare, и которая будет возвращать

enum class VerRel {Precedes, Supersedes, Equal, Unrelated}

В результате проверка выше может выглядеть так:

if(compare(ver1, ver2) == VerRel::Precedes)
    ...

Да, писанины прибавится, зато попробуй теперь сравнить Android с Windows и попасть внутрь проверки! А учитывая, что альтернативы, которая программисту выдаст бессмысленный результат, не будет, он больше не сможет ошибиться в сравнении.

Но мои аргументы «против» на std::partial_ordering не останавливаются; я точно так же считаю, что если для типа X правильным типом возвращаемого значения operator<=> является std::weak_ordering, тогда X не должен его содержать. И причина тут та же: если мы возьмём мои примеры Commit и Person, то можно легко прийти к выводу, что в общем виде мы можем написать для них только сравнение на равенство. Будучи составными объектами, мы не должны иметь общий случай упорядочивания таких объектов: сортировка по короткому хэшу объекта, равно как и сортировка по имени без учёта регистра, — это частные случаи, которые никак нельзя принять за общие. Поэтому operator<=> из обоих классов должен быть удалён, operator== добавлен, а любое упорядочивание должно лечь на плечи того, кому оно будет нужно — ему нечего делать в интерфейсе класса.

Правда, я уже признавался, что мои примеры искусственные, поэтому давайте снова вернёмся к «полям» и Qt. Вот что документация Qt предлагает в качестве примера std::weak_ordering: QDateTime. Более того, в каждом операторе неравенства (пример) приведено вполне логично выглядящее пояснение, почему именно std::weak_ordering является правильным типом возвращаемого значения. Проблема в том, что, используя это объяснение, реализацию, а также тот факт, что понятие релевантных сравнению данных в стандарте крайне размыто, я могу легко доказать, что сравнение двух объектов QDateTime дают на выходе std::strong_ordering.

Объяснение в документации Qt указывает на то, что QDateTime — это {временная точка, часовой пояс}, и если пользователь отдельно сравнит временные точки двух объектов без учёта часовых поясов, то может получить ответ, отличный от сравнения всего объекта. Я не буду утверждать, что их аргументы ничтожны — нет, так действительно можно рассуждать. Но можно и иначе: QDateTime описывает временную точку, которая однозначна и не зависит от часового пояса. При сравнении двух объектов QDateTime всегда сравниваются две точки на единой шкале времени, т. е. формирования классов эквивалентности не происходит; мы не сравниваем аспекты объекта, мы сравниваем его суть.

А вот с помощью различных функций интерфейса мы уже можем получить различные аспекты, как то: часовой пояс, временная точка, смещенная согласно какому-то часовому поясу и т. д. Так что семантически QDateTime::operator<=> должен возвращать std::strong_ordering, а то, что в теории (в реализации Qt 6 этого сделать не удастся, т. к. нет прямого доступа к внутреннему состоянию через интерфейс) с помощью тщательного анализа реализации можно приплести std::weak_ordering — абсолютно не важно, потому что интерфейс превалирует над реализацией.

Моё текущее отношение к std::weak_ordering такое: это абсолютно бесполезный тип, который нигде использовать не нужно. Может, со временем я увижу какие-то доказательства его полезности и изменю своё мнение, но пока так.

Ну и наконец, std::strong_ordering: я считаю, что это единственный тип, который обычный прикладной программист должен рассматривать, при написании operator<=>. Он покрывает все нужды [2]. Поэтому перепишем наш SillyInt в соответствии с этим:

#include <compare>
class SillyInt
{
public:
    SillyInt(int value):
        m_Value(value)
    {}
    std::strong_ordering operator<=>(const SillyInt& rhs) const = default;
private:
    int m_Value;
};

Получился совсем маленький и аккуратный класс, даром что бесполезный. Тут я использовал новую старую конструкцию (= default), о которой ещё не говорил: мы можем попросить компилятор сгенерировать за нас operator<=>, так что нам даже реализацию писать не надо! Реализация по умолчанию описана в class.spaceship, но если сжато пересказать, то выполняется лексикографическое сравнение всех подобъектов класса в порядке их объявления. Т. е. происходит то, что пользователь ожидает:

struct Point
{
    auto operator<=>(const Point& rhs) const
    {
        return std::tie(x, y) <=> std::tie(rhs.x, rhs.y);
    }
    int x;
    int y;
};

Очень удобно и устойчиво к ошибкам, когда в класс добавляется новый член, а сравнение для него добавить забывают — язык всё сделает за нас сам.

Итак, мы получили замечательный operator<=>, который компилятор ещё и напишет за нас, теперь мы можем писать «невероятно читабельный» код if(a <=> b < 0), это ли не предел мечтаний? Разумеется, нет, и составители стандарта работают над упрощением кода, а не его усложнением, поэтому вам, скорее всего, никогда не придётся писать такой код. А причину этого мы рассмотрим в следующем разделе.

Один (два) оператор чтобы сравнить всё

В самом начале предыдущего раздела я приводил таблицу интерпретации р��зультата выполнения operator<=>. Эта таблица хорошо показывает, что любой оператор сравнения может быть переписан с использованием operator<=>. По изначальной задумке авторов предложения, так и должно было произойти: при наличии в классе operator<=> любое сравнение в коде пользователя должно было быть переписано с его помощью автоматически, т. е. следующий код является абсолютно рабочим, хотя используемых в нём операторов в нашем классе нет:

#include <compare>
class SillyInt
{
public:
    SillyInt(int value):
        m_Value(value)
    {}
    auto operator<=>(const SillyInt& rhs) const = default;
private:
    int m_Value;
};

SillyInt a{1}, b{2};
bool res =
    (a < b ||
     a <= b ||
     a > b ||
     a >= b ||
     a == b ||
     a != b
    );

Внимательный читатель, безусловно, обратил внимание на несогласованность времён в моём предыдущем абзаце: «должно было произойти» и «является рабочим». Всё дело в том, что в стандарт попал вариант, который близок по духу изначальному предложению, но не повторяет его полностью; поэтому код выше работает начиная с C++20, но не всё в нём делается через operator<=>.

Прежде чем я перейду к объяснению, я бы хотел, чтобы вы остановились и мысленно поблагодарили комитет по стандартизации C++ за то, что теперь мы можем написать всего одну строчку там, где раньше нужно было писать не менее семи (это при условии, что вы пишете однострочники, у меня это была бы двадцать одна строка минимум). Чтобы почувствовать разницу, давайте попросим бездушную машину сгенерировать нам SillyInt в духе стандарта C++17:

class SillyInt
{
public:
    SillyInt(int value) : m_Value(value) {}

    bool operator<(const SillyInt& rhs) const {
        return m_Value < rhs.m_Value;
    }

    bool operator<=(const SillyInt& rhs) const {
        return m_Value <= rhs.m_Value;
    }

    bool operator>(const SillyInt& rhs) const {
        return m_Value > rhs.m_Value;
    }

    bool operator>=(const SillyInt& rhs) const {
        return m_Value >= rhs.m_Value;
    }

    bool operator==(const SillyInt& rhs) const {
        return m_Value == rhs.m_Value;
    }

    bool operator!=(const SillyInt& rhs) const {
        return m_Value != rhs.m_Value;
    }

private:
    int m_Value;
};

Это то, что Copilot мне выдал по умолчанию: выглядит не очень, но более-менее отражает то, как многие пишут эти операторы. А теперь представьте, что мы хотим добавить ещё сравнение с int — это будет ещё семь (на самом деле четырнадцать, потому что int должен быть учтён и справа, и слева) старых операторов или всего один новый:

#include <compare>
class SillyInt
{
public:
    explicit SillyInt(int value):
        m_Value(value)
    {}
    auto operator<=>(const SillyInt& rhs) const = default;
    auto operator<=>(int rhs) const
    {
        return m_Value <=> rhs;
    }
    private:
        int m_Value;
};

SillyInt a{1};
int b{2};
bool res =
    (a < b ||
     a <= b ||
     a > b ||
     a >= b //||
     //a == b ||
     //a != b
    );

Здесь есть несколько интересных моментов: во-первых, мы запретили неявное создание SillyInt из int, потому что это в целом хорошая практика, да и нам нужно перенаправить любое сравнение с int в соответствующий оператор. Во-вторых, operator<=>(int rhs) за нас компилятор сгенерировать не может, поэтому никаких default: откуда компилятору знать, как он должен сравнивать внутренности объекта с произвольным типом? Ну и наконец, в-третьих: операции сравнения на равенство закомментированы, потому что если их раскомментировать, то код выше выдаст ошибку: нет таких операторов! Но почему всё работает с операторами неравенства?

Дело в том, что компилятор, видя вышеозначенный код сравнения, а также то, что SillyInt содержит operator<=>(int rhs), переписывает его следующим образом:

bool res =
    (a <=> b < 0 ||
     a <=> b <= 0 ||
     a <=> b > 0 ||
     a <=> b >= 0 ||
     a == b ||
     a != b
    );

Заметьте, что все операции сравнения были переписаны, кроме == и != (я их раскомментировал)! Это произошло потому, что через operator<=> выражаются все операторы сравнения, кроме равенства. Поэтому для того, чтобы код работал, нам нужно явно добавить operator==:

#include <compare>
class SillyInt
{
public:
    explicit SillyInt(int value):
        m_Value(value)
    {}
    auto operator<=>(const SillyInt& rhs) const = default;
    auto operator<=>(int rhs) const
    {
        return m_Value <=> rhs;
    }
    bool operator==(int rhs) const
    {
        return m_Value == rhs;
    }
private:
    int m_Value;
};

И всё, теперь наш код собирается и работает. Но постойте, мы же не добавили operator!=, как это он работает? Более того, в самом первом примере нет operator==, а ведь он тоже работает! Начнём с последнего примера: после того как мы добавили SillyInt::operator==(int rhs), у нас больше не будет != в коде, две последние строчки компилятор перепишет на следующее:

a == b ||
!(a == b)

Т. е. если у нас в классе есть operator<=>, то любая операция неравенства (кроме !=), которая подходит под его аргументы, будет переписана через него. Если же есть operator==, то любая != с подходящими аргументами будет переписана через него. Таким образом, у нас есть два основных оператора == и <=>, которые используются при переписывании всех остальных.

Важно понимать, что из этих операторов не генерируются производные: в SillyInt по-прежнему нет operator< или operator!=, даже неявных — их просто не существует. Это один из редких примеров, когда компилятор именно что переписывает один C++-код на другой. Если же вы явно добавите один из этих операторов в класс, то он и будет вызван, а переписывания для него не произойдёт. Хотя описание всего этого действа в стандарте (или в этой статье) и может быть несколько запутанным, работает всё так, как того ожидает программист, к тому же с почти полной обратной совместимостью.

Интересно, что в стандартной библиотеке C++ очень давно существует механизм генерации операторов сравнения из operator== и operator<: std::rel_ops. Думаю, что о нём подозревает лишь малый процент пишущих на C++, а уж в живую, наверное, видели вообще единицы. std::rel_ops, кстати, перевели в разряд устаревших (англ. deprecated) в C++20, по очевидной причине.

Теперь вернёмся к первому примеру и разберём, почему он работает. Я уже упоминал, что изначально operator<=> должен был заменить все операторы, а не только строгие/нестрогие неравенства. Но, столкнувшись с суровой реальностью, было принято решение вывести operator== из-под крыла operator<=>. Детальное обсуждение вы можете почитать в оригинальном предложении, я же ограничусь простым примером. Давайте возьмём реализацию вышеозначенных операторов для std::vector из libc++ (без обычного для таких реализаций «мусора»):

bool operator==(const vector<T>& x, const vector<T>& y)
{
    return x.size() == y.size() && std::equal(x.begin(), x.end(), y.begin());
}
auto operator<=>(const vector<T>& x, const vector<T>& y)
{
    return std::lexicographical_compare_three_way(x.begin(), x.end(),
        y.begin(), y.end());
}

Как нетрудно заметить, если у нас есть два вектора разного размера, то их сравнение на равенство может быть выполнено за одну машинную инструкцию, и только если их размеры равны, происходит полное сравнение содержимого. Реализация operator== через operator<=> будет пессимизацией для любого типа, где подобная оптимизация возможна. Что справедливо практически для любого контейнера, а значит, львиной доли стандартной библиотеки C++ (и за её пределами).

Поэтому решено было их разделить, но в некотором виде сохранить дух изначального предложения: определяя auto operator<=>(...) = default, автоматически синтезируется bool operator==(...) = default, который имеет абсолютно идентичную сигнатуру и возвращает bool. Но это работает исключительно для = default и = delete (в этом случае тоже получаем bool operator==(...) = default). Если добавили свою реализацию operator<=>, то operator== добавлен не будет. Сгенерированный operator== тоже работает так, как от него ждёшь: поочерёдно сравнивает каждый член на равенство.

Разобравшись с тем, почему наш код работает, предлагаю рассмотреть ещё вот такой код, который тоже будет работать: a == 6 && 5 != a && 3 < a, где a — это объект нашего SillyInt, в котором есть bool operator==(int rhs) const и auto operator<=>(int rhs) const, но нет версий этих операторов, где int идёт первым аргументом. Как это работает? В C++17 и ранее, чтобы код выше заработал, нам бы пришлось добавить какой-то такой код в SillyInt:

friend bool operator==(int lhs, const SillyInt& rhs)
{
    return lhs == rhs.m_Value;
}
friend bool operator<(int lhs, const SillyInt& rhs)
{
    return lhs < rhs.m_Value;
}

А теперь эту ношу компилятор берёт на себя [3], переписывая a == 6 && 5 != a && 3 < a на a == 6 && !(a == 5) && a <=> 3 > 0. И с этим можно официально отправить на пенсию совет реализовывать операторы сравнения в том виде, в каком он приведён выше. Теперь мы реализуем их в качестве функции-члена, а компилятор всё остальное делает за нас! Неправда ли здорово, что огромные простыни кода с операторами сравнения теперь умещаются в какие-то 5—10 строк, а то и вообще в одну?

Заключение

Хотя в статье немало текста, а также не все понятия и правила могут быть сразу ясны любому программисту на C++, на деле это всё не имеет никакого значения. Потому что для применения нового функционала его понимание совершенно не нужно. Достаточно знать, что для добавления базового сравнения в свой класс достаточно написать:

bool operator==(const YourClass& rhs) const

Я думаю, что в подавляющем числе случаев одного такого оператора будет достаточно, а львиная доля этих случаев ещё и сможет обойтись простым = default. Небольшому количеству программистов нужно будет ещё реализовать

auto operator<=>(const YourClass& rhs) const

Я, к примеру, не помню, когда последний раз реализовывал операторы неравенства в своих классах.

Ну и буквально единицы будут действительно вынуждены разбираться с возвращаемым значением operator<=>, и только этим единицам реально нужно понимать всё, что затронуто в этой статье. Поэтому можно смело записывать в актив C++ очередное упрощение, которое можно назвать «раздуванием» только с точки зрения текста стандарта, но с точки зрения программиста это является «сдуванием» кода. Код всегда важнее количества букв в стандарте.


[1] Это не совсем правда: на самом деле контейнеры, включая std::vector, имеют operator<=>, категория которого зависит от данных, которые они содержат. Т. е. нельзя утверждать, что упорядочивание вектора всегда даёт строгий порядок — всё зависит от его содержимого. Но сам объект вектора выполняет сравнение согласно строгому порядку; поэтому я и причисляю все контейнеры к этой категории.

[2] Разработчики библиотек могут столкнуться с тем, что в каких-то случаях может быть полезным использовать std::partial_ordering, но не думаю, что у std::weak_ordering найдётся реальное применение. Но библиотеки — это отдельный случай.

[3] Правила формирования списка переписанных выражений для разрешения перегрузки могут быть найдены в over.match.oper/p3.4