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.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ