JavaRush /Курсы /C++ SELF /operator<=> — идея трёхстороннего сравнения в C++23...

operator<=> — идея трёхстороннего сравнения в C++23

C++ SELF
50 уровень , 0 лекция
Открыта

1. Типам нужен порядок

Когда мы впервые слышим «сравнение объектов», обычно думаем про ==: равны или нет. Но в реальных программах (и особенно в STL) очень быстро появляется вопрос порядка: какой объект «меньше», какой «больше», как отсортировать список. Без порядка std::sort разводит руками, а разработчик начинает писать самодельные operator<, operator<=, operator> и operator>= — и где-то между третьим и четвёртым оператором обычно рождается баг, который будет жить дольше некоторых домашних растений.

Представим, что мы делаем маленькое консольное приложение TaskBox (простой список задач). Оно у нас уже умеет хранить задачи в std::vector и печатать их через operator<<. Теперь мы хотим выводить задачи в отсортированном виде: например, по приоритету, а при равном приоритете — по id.

Пока без <=>, многие начинают так:

  • написать operator< (вроде бы ок);
  • потом «на всякий случай» написать operator> как return other < *this;
  • затем добавить <= и >=;
  • и дальше ловить странности, если один из операторов оказался не согласован с другим.

Трёхстороннее сравнение operator<=> как раз пытается сделать эту историю более аккуратной: один оператор — один источник истины для порядка.

2. operator<=>: базовая идея и что он заменяет

Один оператор вместо четырёх

Если очень грубо, operator<=> — это оператор, который отвечает не на вопрос «меньше ли?», а на вопрос «как именно сравнились?». То есть у результата есть три «настроения»: меньше, равно, больше. Из-за формы значка его часто называют spaceship operator («оператор-космолёт»).

Важно не перепутать: operator< возвращает bool, а operator<=> возвращает результат сравнения, который можно проверять, сравнивая с нулём (в духе «равно/не равно нулю»). Детали типов результата бывают разными (есть разные категории сравнения), но сегодня нам достаточно практической модели: результат можно сравнить с 0, и это работает.

Чаще всего operator<=> объявляют так:

#include <compare> // часто нужно для <=>  

struct X {
    auto operator<=>(const X& other) const = default;
};

Обратите внимание на заголовок <compare>: он связан с механизмом трёхстороннего сравнения, и в учебных примерах его лучше подключать явно, чтобы не гадать «почему у меня не компилируется на этом компиляторе/в этой IDE».

Что меняется в повседневном коде

Когда вы добавляете своему типу operator<=>, вы как будто говорите компилятору: «Вот мой официальный способ сравнивать два объекта по порядку. Если тебе нужны <, <=, >, >= — используй его». В результате тип становится более «STL-дружелюбным»: сортировки, бинарные поиски (в отсортированных данных), упорядоченные структуры и просто читаемый код начинают получаться проще.

Полезно держать в голове маленькую табличку:

Что хотим в коде Как это обычно выглядит без <=> Что меняется с <=>
Сортировка std::sort нужен < < часто «получается» из <=>
Сравнить «не меньше ли» пишем <= или выражаем через < <= тоже может быть выведен
Один «центр сравнения» легко рассинхронизировать 3–4 оператора один оператор — меньше шансов на рассинхрон
«Равны ли?» нужен == в некоторых случаях == можно = default, а порядок — через <=>

При этом остаётся важное правило дизайна: операторы должны быть предсказуемыми. Если вы задаёте «естественный порядок» для типа — он должен быть таким, чтобы читатель кода не впал в философские размышления о смысле жизни и сортировки.

3. TaskBox: сортируем задачи через <=>

Модель Task

Сейчас мы добавим к нашей модели задачи понятный порядок, чтобы можно было сортировать задачи в std::vector. Пусть задача имеет id, title и priority. Для простоты приоритет сделаем как int: 0 — низкий, 1 — обычный, 2 — высокий. Да, это не идеальная модель мира, но зато не требует новых тем.

Начнём с самой структуры:

#include <string>

struct Task {
    int id{};
    std::string title;
    int priority{}; // 0..2
};

Чтобы красиво печатать задачи, добавим operator<<:

#include <iostream>
#include <string>

std::ostream& operator<<(std::ostream& os, const Task& t) {
    return os << "Task{id=" << t.id
              << ", pr=" << t.priority
              << ", title=\"" << t.title << "\"}";
}

Теперь хочется сортировать. И вот тут у нас два сценария: либо нас устраивает «порядок по полям как записано», либо нужен более хитрый порядок (например, сначала приоритет, потом id). С operator<=> оба сценария выражаются довольно компактно.

= default и порядок по полям

Иногда нам действительно подходит «структурный» порядок: сравниваем первое поле, если равны — второе, если равны — третье. Это называется лексикографическим сравнением (по сути «как в словаре, но по полям»). В таком случае можно попросить компилятор сделать всё за нас.

Добавим в Task трёхстороннее сравнение по умолчанию:

#include <compare>
#include <string>

struct Task {
    int id{};
    std::string title;
    int priority{};

    auto operator<=>(const Task&) const = default;
};

Здесь важно осознать одну вещь: порядок полей становится приоритетом критериев. То есть при = default компилятор будет сравнивать id, потом title, потом priority (в порядке объявления). Если вы ожидали «сначала priority», то по умолчанию вы получите не то.

Это не «ошибка компилятора» и не «каприз стандарта». Это честное выполнение вашего контракта: вы сказали «сравнивай по полям как есть» — он и сравнивает как есть.

Проверим, что сортировка теперь возможна:

#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    std::vector<Task> tasks = { {2, "Refactor", 1}, {1, "Fix bug", 2} };
    std::sort(tasks.begin(), tasks.end());

    std::cout << tasks[0] << '\n'; // Task{id=1, pr=2, title="Fix bug"}
}

Да, сортировка теперь работает. Но сортирует она по id (потому что id первое поле). Это может быть ок, а может быть «ну такое», если вы хотели сначала видеть срочные задачи.

Ручной <=>: приоритет, потом id

Когда хочется явно контролировать порядок, operator<=> можно написать вручную. И тут появляется приятный паттерн: сравнили одно поле — если не равно, сразу вернули результат; если равно — сравнили следующее.

Сделаем порядок: сначала priority по убыванию, а потом id по возрастанию. По убыванию — это значит «priority=2» должен идти раньше «priority=1». Чтобы получить убывание, можно сравнивать «наоборот»: other.priority <=> priority.

#include <compare>
#include <string>

struct Task {
    int id{};
    std::string title;
    int priority{};

    auto operator<=>(const Task& other) const {
        if (auto r = other.priority <=> priority; r != 0) return r;
        return id <=> other.id;
    }
};

Обратите внимание на стиль: мы используем проверку результата через != 0. Это читается почти как «если нашли отличие — возвращай». И это ровно то, чего мы хотим: порядок задаётся последовательно и прозрачно.

Теперь сортировка будет выдавать задачи сначала по приоритету (2, потом 1, потом 0), а внутри приоритета — по id.

#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    std::vector<Task> tasks = { {2, "Refactor", 1}, {1, "Fix bug", 2} };
    std::sort(tasks.begin(), tasks.end());

    std::cout << tasks[0] << '\n'; // Task{id=1, pr=2, title="Fix bug"}
    std::cout << tasks[1] << '\n'; // Task{id=2, pr=1, title="Refactor"}
}

И да: теперь «срочная задача» правда наверху.

Почему результат сравнивают с нулём

После operator< у новичка есть естественное ожидание: «оператор сравнения возвращает true/false». Но <=> делает иначе, потому что он отвечает на более богатый вопрос: как именно объекты сравнились.

На уровне повседневного кода вам обычно достаточно знать два факта.

Первый факт: результат можно проверить на «не равно нулю», чтобы понять, что объекты различаются по этому критерию. Именно поэтому паттерн if (auto r = a <=> b; r != 0) return r; так популярен.

Второй факт: если вы пишете std::sort, он по умолчанию использует <. А < в присутствии <=> для вашего типа обычно начинает работать автоматически (компилятор «понимает», как выразить < через <=>). То есть вы всё равно продолжаете писать привычный код уровня алгоритмов, а <=> живёт в типе как «двигатель порядка».

Если очень хочется визуальную картинку, можно представить это так:

flowchart TD
    A["Ваш тип: operator<=>"] --> B["Компилятор выводит <, <=, >, >="]
    B --> C["std::sort использует <"]
    C --> D["Список задач сортируется"]

В этом и смысл: один раз аккуратно определили порядок внутри типа — и дальше пользуемся стандартными инструментами STL без ручной «сборки велосипеда из четырёх операторов».

Согласованность с operator==

Когда в типе есть и равенство, и порядок, у них должен быть мир и дружба. Если a == b, то и трёхстороннее сравнение должно сообщать «равно». Иначе вы получите странности: например, std::sort может вести себя непредсказуемо, а поиск в отсортированном контейнере будет выдавать сюрпризы уровня «вроде элемент есть, но его как бы нет».

В нашем Task мы сравнивали по priority и id. Это автоматически задаёт идею равенства: «равны, если одинаковый приоритет и одинаковый id». Но что насчёт title? Если title не участвует в порядке, то логично, что он не должен участвовать и в равенстве — иначе получится противоречие: два объекта могут быть «равны по порядку» (сравнение даёт равно), но «не равны по ==».

Поэтому, если вы задаёте ручной <=>, полезно задуматься: нужно ли вам явно определить operator== так же по тем же полям. Мы сделаем это честно:

#include <string>

struct Task {
    int id{};
    std::string title;
    int priority{};

    bool operator==(const Task& other) const {
        return id == other.id && priority == other.priority;
    }
};

А operator<=> оставим тем, что уже написали. Теперь контракт более ясный: заголовок задачи — «описание», но идентичность и порядок идут по id и priority.

И да, это решение может быть спорным в реальном проекте (например, id обычно сам по себе уникален, и тогда сравнение только по id тоже имеет смысл). Но для учебного приложения важнее показать принцип: равенство и порядок должны быть согласованы.

4. Ещё пример и границы применения

Version: естественный порядок

Чтобы закрепить идею «по полям» и «по частям», удобно рассмотреть тип Version: major.minor.patch. Здесь порядок обычно очевиден: сначала major, потом minor, потом patch. Это почти идеальный кандидат для = default.

#include <compare>

struct Version {
    int major{};
    int minor{};
    int patch{};

    auto operator<=>(const Version&) const = default;
};

Использование получается очень «человеческим»:

#include <iostream>

int main() {
    Version a{1, 2, 0};
    Version b{1, 10, 0};

    std::cout << (a < b) << '\n'; // 1
}

Тут <=> даёт вам порядок для <, а std::sort (если бы мы сортировали версии) был бы счастлив.

Где <=> полезен, а где лучше компаратор

<=> особенно приятен там, где у типа действительно есть один естественный порядок: версия, дата-время (в упрощённом виде), координата (если вы именно так определили смысл), ключ сортировки, какой-то «ранг».

А вот если у типа много возможных сортировок («по имени», «по цене», «по рейтингу», «по дате создания») — тут нужно быть осторожным: пытаясь «впихнуть» все критерии в один <=>, можно сделать API типа непредсказуемым. Тогда читатель кода будет гадать: «А a < b — это по имени или по возрасту?».

Здесь полезно зафиксировать практическое правило: если вы решились давать типу порядок, operator<=> позволяет сделать это компактно и согласованно. Если же «естественного» порядка нет, то сортировку чаще задают локально (например, в std::sort через компаратор), а тип оставляют без «встроенной философии сравнения».

5. Типичные ошибки при использовании operator<=>

Ошибка №1: ожидать, что <=> возвращает bool.
После опыта с operator< рука тянется написать что-то вроде bool operator<=>(...). Так нельзя: смысл <=> именно в том, что он возвращает «результат сравнения», а не истину/ложь. Практический признак ошибки — компилятор ругается на сигнатуру или на использование результата. Лечится просто: возвращайте auto и используйте паттерн с проверкой через != 0.

Ошибка №2: забыть подключить <compare> и получить странную ошибку «не найдено то, не найдено сё».
В некоторых окружениях часть сравнений подтягивается косвенно, и кажется, что заголовок не нужен. Потом вы переносите код в другую IDE/другой компилятор — и всё падает. В учебном и прикладном коде лучше подключать <compare> явно: это делает зависимость честной и уменьшает «магические» проблемы.

Ошибка №3: использовать = default, не подумав о порядке полей.
operator<=> = default сравнивает поля в порядке объявления. Если в вашем типе поля лежат «как удобно хранить», а не «как удобно сравнивать», вы получите неожиданный порядок. Это не баг компилятора — это ваш контракт. Исправление обычно одно из двух: либо переставить поля (если это не ломает смысл модели), либо написать <=> вручную «по частям».

Ошибка №4: сделать порядок несогласованным с равенством.
Если == учитывает одни поля, а <=> — другие, можно получить ситуацию, где a == b — ложь, но при этом (a <=> b) == 0 (то есть «равны по порядку»). Такие противоречия ломают интуицию, сортировки и поиск. Хорошее правило для новичка: какие поля участвуют в идентичности (==), те же (или совместимые) должны участвовать и в <=>.

Ошибка №5: забыть const у operator<=>.
Сравнение почти всегда не должно менять объект. Если вы забыли const, то внезапно ваш тип нельзя сравнивать, если он лежит в const-контексте (например, в алгоритмах или при сравнении двух const объектов). Это больно и выглядит как «почему я вообще не могу сравнить две задачи?». Правильная форма почти всегда auto operator<=>(const T& other) const.

1
Задача
C++ SELF, 50 уровень, 0 лекция
Недоступна
Версии релиза
Версии релиза
1
Задача
C++ SELF, 50 уровень, 0 лекция
Недоступна
Книжная сортировка
Книжная сортировка
1
Задача
C++ SELF, 50 уровень, 0 лекция
Недоступна
Очередь задач
Очередь задач
1
Задача
C++ SELF, 50 уровень, 0 лекция
Недоступна
Дробный рейтинг
Дробный рейтинг
Комментарии (1)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
kasnil Уровень 66
27 июня 2026
В разделе Согласованность с operator==, если задаём ручной <=>, то логичнее написать:

bool operator==(const Task& other) const {
    return ((*this <=> other) == 0);
}