Хабрахабр

[Перевод] Операции сравнения в C++20

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

У нас не просто будет новый оператор — семантика сравнений претерпит существенные изменения на уровне самого языка.
Даже если ничего больше вы из этой статьи не вынесете, запомните эту таблицу:

Теперь у нас будет новый оператор, <=>, но, что ещё важнее, операторы теперь систематизированы. Есть базовые операторы и есть производные операторы — каждая группа обладает своими возможностями.

Об этих возможностях мы поговорим коротко во вступлении и рассмотрим подробнее в следующих разделах.

переписаны с обратным порядком параметров). Базовые операторы могут быть обращены (т.е. Ни обращённые, ни переписанные кандидаты не порождают новых функций, они просто являются заменами на уровне исходного кода и отбираются из расширенного набора кандидатов. Производные операторы могут быть переписаны через соответствующий базовый оператор. Это значит, что можно будет обойтись одним или двумя операторами там, где для достижения того же поведения сейчас требуется вручную написать 2, 4, 6 или даже 12 операторов. Например, выражение a < 9 теперь может вычисляться как a.operator<=>(9) < 0, а выражение 10 != b — как !operator==(b, 10). Краткий обзор правил будет представлен ниже вместе с таблицей всех возможных преобразований.

В случае базовых операторов это означает, что оператор будет применяться к каждому члену в порядке объявления; в случае производных операторов — что будут использоваться переписанные кандидаты. И базовые, и производные операторы можно определять в качестве используемых по умолчанию.

равенства или упорядочения) мог бы выражаться через оператор другого вида. Следует отметить, что не существует такого преобразования, при котором оператор одного вида (т.е. Выражение a == b никогда не будет вычисляться как operator<=>(a, b) == 0 неявно (но, разумеется, ничто не мешает вам определить свой operator== через operator<=>, если захочется). Иными словами, столбцы в нашей таблице никак не зависят друг от друга.

Мы напишем тип строки, не учитывающий регистр, CIString, объекты которого могут сравниваться как друг с другом, так и с char const*. Рассмотрим небольшой пример, в котором покажем, как выглядит код до и после применения нового функционала.

В C++17 для нашей задачи потребуется написать 18 функций сравнения:

class CIString friend bool operator< (const CIString& a, const CIString& b) { return ci_compare(a.s.c_str(), b.s.c_str()) < 0; } friend bool operator!=(const CIString& a, const CIString& b) { return !(a == b); } friend bool operator> (const CIString& a, const CIString& b) { return b < a; } friend bool operator>=(const CIString& a, const CIString& b) { return !(a < b); } friend bool operator<=(const CIString& a, const CIString& b) { return !(b < a); } friend bool operator==(const CIString& a, const char* b) { return ci_compare(a.s.c_str(), b) == 0; } friend bool operator< (const CIString& a, const char* b) { return ci_compare(a.s.c_str(), b) < 0; } friend bool operator!=(const CIString& a, const char* b) { return !(a == b); } friend bool operator> (const CIString& a, const char* b) { return b < a; } friend bool operator>=(const CIString& a, const char* b) { return !(a < b); } friend bool operator<=(const CIString& a, const char* b) { return !(b < a); } friend bool operator==(const char* a, const CIString& b) { return ci_compare(a, b.s.c_str()) == 0; } friend bool operator< (const char* a, const CIString& b) { return ci_compare(a, b.s.c_str()) < 0; } friend bool operator!=(const char* a, const CIString& b) { return !(a == b); } friend bool operator> (const char* a, const CIString& b) { return b < a; } friend bool operator>=(const char* a, const CIString& b) { return !(a < b); } friend bool operator<=(const char* a, const CIString& b) { return !(b < a); }
};

В C++20 можно обойтись всего лишь 4 функциями:

class CIString { string s; public: bool operator==(const CIString& b) const { return s.size() == b.s.size() && ci_compare(s.c_str(), b.s.c_str()) == 0; } std::weak_ordering operator<=>(const CIString& b) const { return ci_compare(s.c_str(), b.s.c_str()) <=> 0; } bool operator==(char const* b) const { return ci_compare(s.c_str(), b) == 0; } std::weak_ordering operator<=>(const char* b) const { return ci_compare(s.c_str(), b) <=> 0; }
};

Я расскажу, что всё это значит, подробнее, но сначала давайте немного вернёмся в прошлое и вспомним, как работали сравнения до стандарта C++20.

Сравнения в стандартах с C++98 по C++17

Операции сравнения почти не менялись с момента создания языка. У нас было шесть операторов: ==, !=, <, >, <= и >=. Стандарт определяет каждый из них для встроенных типов, но в целом они подчиняются одним и тем же правилам. При вычислении любого выражения a @ b (где @ — один из шести операторов сравнения) компилятор ищет функции-члены, свободные функции и встроенные кандидаты с именем operator@, которые могут быть вызваны с типом A или B в указанном порядке. Из них выбирается самый подходящий кандидат. Вот и всё. По сути, все операторы работали одинаково: операция < не отличалась от <<.

Все операторы абсолютно независимы и эквивалентны. Такой простой набор правил легко усвоить. С точки зрения языка, это одно и то же. Неважно, что мы, люди, знаем о фундаментальном отношении между операциями == и !=. Например, мы определяем оператор != через ==:
Мы же используем идиомы.

bool operator==(A const&, A const&); bool operator!=(A const& lhs, A const& rhs) { return !(lhs == rhs);
}

Аналогично, через оператор < мы определяем все остальные операторы отношения. Мы пользуемся этими идиомами, потому что, несмотря на правила языка, мы на самом деле не считаем все шесть операторов эквивалентными. Мы принимаем, что два из них являются базовыми (== и <), а через них уже выражаются все остальные.

В самом деле, стандартная библиотека шаблонов (Standard Template Library) целиком построена на этих двух операторах, и огромное количество типов в эксплуатируемом коде содержит определения только одного из них или их обоих.

Однако оператор < не очень-то подходит на роль базового по двум причинам.

Да, a > b означает ровно то же, что b < a, но неверно, что a <= b значит ровно то же, что !(b < a). Во-первых, через него нельзя гарантированно выразить другие операторы отношения. При наличии трихотомии выражение a <=b означает, что мы имеем дело либо с первым, либо со вторым случаем… а это эквивалентно утверждению, что мы не имеем дела с третьим случаем. Последние два выражения будут эквивалентны, если имеется свойство трихотомии, при котором для любых двух значений верно только одно из трёх утверждений: a < b, a == b или a > b. Поэтому (a <= b) == !(a > b) == !(b < a).

Это характерно для отношений частичного порядка. Но что если отношение не обладает свойством трихотомии? Поэтому 1.f <= NaN также даёт ложь, но при этом !(NaN < 1.f)правда. Классический пример — числа с плавающей запятой, для которых любая из операций 1.f < NaN, 1.f == NaN и 1.f > NaN даёт ложь.

Единственный способ реализовать оператор <= в общем виде через базовые операторы — это расписать обе операции как (a == b) || (a <b), что является большим шагом назад в том случае, если нам всё же придётся иметь дело с линейным порядком, поскольку тогда будет вызываться не одна функция, а две (например, выражение «abc..xyz9» <= «abc..xyz1» придётся переписать как («abc..xyz9»== «abc..xyz1») || («abc..xyz9» < «abc..xyz1») и дважды сравнивать всю строку целиком).

Программисты часто допускают такую ошибку:
Во-вторых, оператор < не очень подходит на роль базового из-за особенностей его использования в лексикографических сравнениях.

struct A { T t; U u; bool operator==(A const& rhs) const { return t == rhs.t && u == rhs.u; } bool operator< (A const& rhs) const { return t < rhs.t && u < rhs.u; } };

Чтобы определить оператор == для коллекции элементов, достаточно один раз применить == к каждому члену, но с оператором < так не получится. С точки зрения этой реализации, множества A{1, 2} и A{2, 1} будут считаться эквивалентными (так как ни одно из них не меньше другого). Чтобы исправить это, следует применить оператор < дважды к каждому члену, кроме последнего:

bool operator< (A const& rhs) const { if (t < rhs.t) return true; if (rhs.t < t) return false; return u < rhs.u;
}

Наконец, чтобы гарантировать правильную работу сравнений разнотипных объектов — т.е. гарантировать, что выражения a == 10 и 10 == a означают одно и то же, — обычно рекомендуют писать сравнения как свободные функции. На самом деле это вообще единственный способ реализовать такие сравнения. Это неудобно, потому что, во-первых, придётся следить за соблюдением этой рекомендации, а во-вторых, обычно такие функции приходится объявлять скрытыми друзьями для более удобной реализации (т.е. внутри тела класса).

Заметим, что не всегда при сравнениях разнотипных объектов требуется писать именно operator==(X, int); они могут также подразумевать случаи, когда int может неявно приводиться к X.

Подведём итоги по правилам до стандарта C++20:

  • Все операторы обрабатываются одинаково.
  • Мы используем идиомы для облегчения реализации. Операторы == и < мы принимаем за базовые идиомы и выражаем остальные операторы отношения через них.
  • Вот только оператор < не очень подходит на роль базового.
  • Важно (и рекомендовано) писать сравнения разнотипных объектов как свободные функции.

Новый базовый оператор упорядочения: <=>

Самое значительное и заметное изменение в работе сравнений в C++20 — это добавление нового оператора — operator<=>, оператора трёхстороннего сравнения.

Все они возвращают значение типа int, которое представлено произвольным положительным числом, если первый аргумент больше второго, 0 — если они равны, и произвольным отрицательным числом в противном случае. С трёхсторонними сравнениями мы уже знакомы по функциям memcmp/strcmp в C и basic_string::compare() в C++.

Существует три основных категории: Оператор «космический корабль» возвращает не значение типа int, а объект, принадлежащий к одной из категорий сравнения, чьё значение отражает вид отношения между сравниваемыми объектами.

  • strong_ordering: отношение линейного порядка, при котором равенство подразумевает взаимозаменяемость элементов (т.е. (a <=> b) == strong_ordering::equal подразумевает, что для всех подходящих функций f имеет место f(a) == f(b). Термину «подходящая функция» намеренно не даётся чёткого определения, но к таковым не относятся функции, которые возвращают адреса своих аргументов или capacity() вектора и т.п. Нас интересуют только «существенные» свойства, что тоже очень расплывчато, но можно условно считать, что речь идёт о значении типа. Значение вектора — это содержащиеся в нём элементы, но не его адрес и т.п.). Эта категория включает в себя следующие значения: strong_ordering::greater, strong_ordering::equal и strong_ordering::less.
  • weak_ordering: отношение линейного порядка, при котором равенство определяет лишь некоторый класс эквивалентности. Классический пример — нечувствительное к регистру сравнение строк, когда два объекта могут быть weak_ordering::equivalent, но не равны в строгом смысле (этим объясняется замена слова equal на equivalent в имени значения).
  • partial_ordering: отношение частичного порядка. В этой категории к значениям greater, equivalent и less (как в weak_ordering) добавляется ещё одно — unordered («неупорядоченно»). С его помощью можно выражать отношения частичного порядка в системе типов: 1.f <=> NaN даёт значение partial_ordering::unordered.

В основном вы будете работать с категорией strong_ordering; это также оптимальная категория для использования по умолчанию. Например, 2 <=> 4 возвращает strong_ordering::less, а 3 <=> -1strong_ordering::greater.

strong_ordering приводимо к weak_ordering). Категории более сильного порядка могут неявно приводиться к категориям более слабого порядка (т.е. strong_ordering::equal превращается в weak_ordering::equivalent). При этом текущий вид отношения сохраняется (т.е.

Значения категорий сравнения можно сравнивать с литералом 0 (не с любым int и не с int, равным 0, а просто с литералом 0) с помощью одного из шести операторов сравнения:

strong_ordering::less < 0 // true
strong_ordering::less == 0 // false
strong_ordering::less != 0 // true
strong_ordering::greater >= 0 // true partial_ordering::less < 0 // true
partial_ordering::greater > 0 // true // unordered - особое значение, которое невозможно
// сравнить ни с каким другим значением
partial_ordering::unordered < 0 // false
partial_ordering::unordered == 0 // false
partial_ordering::unordered > 0 // false

Именно благодаря сравнению с литералом 0 мы можем реализовывать операторы отношения: a @ b эквивалентно (a <=> b) @ 0 для каждого из таких операторов.

Например, 2 < 4 можно вычислить как (2 <=> 4) < 0, что превращается в strong_ordering::less < 0 и даёт значение true.

На роль базового элемента оператор <=> подходит намного лучше, чем оператор <, поскольку он избавлен от обеих проблем последнего.

Для двух неупорядоченных значений a <=> b даст значение partial_ordered::unordered, а partial_ordered::unordered <= 0 даст false, что нам и требуется. Во-первых, выражение a <= b гарантированно эквивалентно (a <=> b) <= 0 даже при частичном порядке. Значение типа bool может быть только true или false, поэтому раньше мы не могли различать сравнения упорядоченных и неупорядоченных значений. Это возможно потому, что <=> может вернуть больше разновидностей значений: так, категория partial_ordering содержит четыре возможных значения.

Допустим, мы хотим добавить типу int состояние NaN, где NaN — это просто значение, которое не образует упорядоченной пары ни с одним задействованным значением. Для большей ясности рассмотрим пример отношения частичного порядка, не связанный с числами с плавающей запятой. Сделать это можно, используя для его хранения std::optional:

struct IntNan { std::optional<int> val = std::nullopt; bool operator==(IntNan const& rhs) const { if (!val || !rhs.val) { return false; } return *val == *rhs.val; } partial_ordering operator<=>(IntNan const& rhs) const { if (!val || !rhs.val) { // состояние unordered можно выразить // как значение первого класса return partial_ordering::unordered; } // <=> возвращает значение strong_ordering для int, // но оно может быть неявно приведено к partial_ordering return *val <=> *rhs.val; }
}; IntNan{2} <=> IntNan{4}; // partial_ordering::less
IntNan{2} <=> IntNan{}; // partial_ordering::unordered // принцип работы этих операций см. в следующем разделе
IntNan{2} < IntNan{4}; // true
IntNan{2} < IntNan{}; // false
IntNan{2} == IntNan{}; // false
IntNan{2} <= IntNan{}; // false

Оператор <= возвращает правильное значение потому, что теперь мы можем выразить больше информации на уровне самого языка.

Во-вторых, чтобы получить всю необходимую информацию, достаточно один раз применить <=>, что облегчает реализацию лексикографического сравнения:

struct A { T t; U u; bool operator==(A const& rhs) const { return t == rhs.t && u == rhs.u; } strong_ordering operator<=>(A const& rhs) const { // выполняем трёхстороннее сравнение // элементов t. Если результат != 0 (т.е. элементы t // различаются), это будет результат // всего сравнения if (auto c = t <=> rhs.t; c != 0) return c; // в противном случае сравниваем // следующую пару элементов return u <=> rhs.u;
};

Более подробный разбор см. в P0515 — исходном предложении по добавлению operator<=>.

Новые возможности операторов

Мы не просто получаем в своё распоряжение новый оператор. В конце концов, если бы показанный выше пример с объявлением структуры A говорил лишь о том, что вместо x < y теперь придётся всякий раз писать (x <=> y) < 0, это никому бы не понравилось.

Если раньше это была идиома (запись через == и <), которой пользовались мы, но о которой не знал компилятор, то теперь и он будет понимать это различие. Механизм разрешения сравнений в C++20 заметно отличается от старого подхода, но это изменение напрямую связано с новой концепцией двух базовых операторов сравнения: == и <=>.

Ещё раз приведу таблицу, которую вы уже видели в начале статьи:

Каждый из базовых и производных операторов получил новую способность, о чём я скажу пару слов далее.

Обращение базовых операторов

В качестве примера возьмём тип, который может сравниваться только с int:

struct A { int i; explicit A(int i) : i(i) { } bool operator==(int j) const { return i == j; }
};

С точки зрения старых правил, нет ничего удивительного в том, что выражение a == 10 работает и вычисляется как a.operator==(10).

В C++17 это выражение считалось бы явной синтаксической ошибкой. Но как насчёт 10 == a? Чтобы такой код заработал, пришлось бы писать симметричный operator==, который бы сначала брал значение int, а затем A… а реализовывать это пришлось бы в виде свободной функции. Не существует такого оператора.

Для 10 == a компилятор найдёт кандидат operator==(A, int) (на самом деле это функция-член, но для наглядности я пишу её здесь как свободную функцию), а затем дополнительно — вариант с обратным порядком параметров, т.е. В C++20 базовые операторы могут быть обращены. Этот второй кандидат совпадает с нашим выражением (причём идеально), так что его мы и выберем. operator==(int, A). Компилятор понимает, что равенство симметрично. Выражение 10 == a в C++20 вычисляется как a.operator==(10).

Теперь расширим наш тип так, чтобы его можно было сравнивать с int не только через оператор равенства, но и через оператор упорядочения:

struct A { int i; explicit A(int i) : i(i) { } bool operator==(int j) const { return i == j; } strong_ordering operator<=>(int j) const { return i <=> j; }
};

Опять же, выражение a <=> 42 работает прекрасно и вычисляется по старым правилам как a.operator<=>(42), но вот 42<=> a было бы неправильно с точки зрения C++17, даже если бы оператор <=> уже существовал в языке. Но в C++20 operator<=>, как и operator==, симметричен: он распознаёт обращённые кандидаты. Для 42 <=> a будет найдена функция-член operator<=>(A, int) (опять же, я пишу её здесь как свободную функцию просто для большей наглядности), а также синтетический кандидат operator<=>(int, A). Этот обращённый вариант точно соответствует нашему выражению — его и выбираем.

Так было бы неправильно. Однако 42 <=> a вычисляется НЕ как a.operator<=>(42). Попробуйте сами догадаться, почему эта запись — правильная. Это выражение вычисляется как 0 <=> a.operator<=>(42).

При вычислении 10 == a не появился новый оператор operator==(int, A), а при вычислении 42 <=> a не появился operator<=>(int, A). Важно отметить, что никаких новых функций компилятор не создаёт. Повторю: никаких новых функций не создаётся. Просто два выражения переписаны через обращённые кандидаты.

То есть:
Также обратите внимание, что запись с обратным порядком параметров доступна только для базовых операторов, а для производных — нет.

struct B { bool operator!=(int) const;
}; b != 42; // ok и в C++17, и в C++20
42 != b; // ошибка и в C++17, и в C++20

Переписывание производных операторов

Вернёмся к нашему примеру со структурой A:

struct A { int i; explicit A(int i) : i(i) { } bool operator==(int j) const { return i == j; } strong_ordering operator<=>(int j) const { return i <=> j; }
};

Возьмём выражение a != 17. В C++17 это синтаксическая ошибка, потому что не существует оператора operator!=. Однако в C++20 для выражений, содержащих производные операторы сравнения, компилятор будет также искать соответствующие им базовые операторы и выражать через них производные сравнения.

Теперь это известно и компилятору. Мы знаем, что в математике операция != по сути означает НЕ ==. Для данного примера мы нашли оператор равенства, который нам почти подходит, — нужно только переписать его в соответствии с желаемой семантикой: a != 17 будет вычисляться как !(a == 17). Для выражения a!= 17 он будет искать не только операторы operator!=, но и operator== (а также, как в предыдущих примерах, обращённые operator==).

Аналогично, 17 != a вычисляется как !a.operator==(17), что является одновременно и переписанным, и обращённым вариантом.

Если бы мы написали a < 9, то попытались бы (безуспешно) найти operator<, а также рассмотрели бы базовые кандидаты: operator<=>. Похожие преобразования проводятся и для операторов упорядочения. В нашем случае — a.operator<=>(9) < 0. Соответствующая замена для операторов отношения выглядит так: a @ b (где @ — один из операторов отношения) вычисляется как (a <=> b) @ 0. Аналогично, 9 <= a вычисляется как 0 <= a.operator<=>(9).

Они просто по-другому вычисляются, а все трансформации проводятся только на уровне исходного кода. Заметим, что, как и в случае с обращением, компилятор не создаёт никаких новых функций для переписанных кандидатов.

Вышесказанное приводит меня к следующему совету:

ТОЛЬКО БАЗОВЫЕ ОПЕРАТОРЫ: В своём типе определяйте только базовые операторы (== и <=>).

Это значит, что вам понадобится только 2 оператора для сравнения однотипных объектов (вместо 6, как сейчас) и только 2 оператора для сравнения разнотипных объектов (вместо 12). Поскольку базовые операторы дают весь набор сравнений, то и определять достаточно только их. Класс std::sub_match представляет собой крайний случай: в C++17 в нём используется 42 оператора сравнения, а в C++20 — только 8, при этом функциональность никак не страдает. Если вам нужна только операция равенства, то достаточно написать 1 функцию для сравнения однотипных объектов (вместо 2) и 1 функцию для сравнения разнотипных объектов (вместо 4).

Больше не придётся писать свободные функции только ради сравнения разнотипных объектов. Так как компилятор рассматривает также обращённые кандидаты, все эти операторы можно будет реализовывать как функции-члены.

Особые правила поиска кандидатов

Как я уже упоминал, поиск кандидатов для a @ b в C++17 происходил по следующему принципу: находим все операторы operator@ и выбираем из них наиболее подходящий.

Теперь мы будем искать все operator@. В C++20 используется расширенный набор кандидатов. Мы также находим все operator@@ и для каждого из них добавляем его обращённую версию. Пусть @@ — это базовый оператор для @ (это может быть один и тот же оператор). Из всех этих найденных кандидатов выбираем наиболее подходящий.

Мы не пытаемся подставлять разные кандидаты. Заметьте, что перегрузка оператора разрешается за один-единственный проход. Если такого не существует, поиск, как и раньше, заканчивается неудачей. Сначала мы собираем их все, а затем выбираем из них наилучший.

Рассмотрим следующий пример:
Теперь у нас гораздо больше потенциальных кандидатов, а значит и больше неопределённости.

struct C { bool operator==(C const&) const; bool operator!=(C const&) const;
}; bool check(C x, C y) { return x != y;
}

В C++17 у нас был только один кандидат для x != y, а теперь их три: x.operator!=(y), !x.operator==(y) и y.operator==(x). Что же выбрать? Они все равнозначны! (Примечание: кандидата y.operator!=(x) не существует, так как обращать можно только базовые операторы.)

Необращённые кандидаты предпочтительнее обращённых; непереписанные кандидаты предпочтительнее переписанных. Для снятия этой неопределённости введены два дополнительных правила. Этот принцип согласуется со стандартными правилами, по которым «побеждает» наиболее точный вариант. Тогда получается, что x.operator!=(y) «главнее» !x.operator==(y), а тот «главнее» !y.operator==(x).

Мы просто находим их. Ещё одно замечание: на этапе поиска нас не интересует тип возвращаемого значения кандидатов operator@@. Нас интересует только, являются ли они наилучшим выбором или нет.

Если наилучший кандидат — переписанный или обращённый (например, мы написали x < y, а наилучший кандидат — это (x <=> y) < 0), но корректно переписать или обратить сравнение невозможно (например, x <=> y возвращает void или какой-то иной тип, потому что мы вообще пишем на DSL), то программа считается некорректной. Неудачный исход при поиске теперь тоже выглядит по-другому. В случае с операцией равенства мы принимаем, что никакой тип возвращаемого значения кроме bool не совместим с переписанными кандидатами (логика здесь такая: если operator== не возвращает bool, можем ли мы считать такую операцию операцией равенства?) Возвращаться и искать другой подходящий вариант мы уже не будем.

Например:

struct Base { friend bool operator<(const Base&, const Base&); // #1 friend bool operator==(const Base&, const Base&); }; struct Derived : Base { friend void operator<=>(const Derived&, const Derived&); // #2
}; bool f(Derived d1, Derived d2) { return d1 < d2;
}

Для выражения d1 < d2 будут найдены два кандидата: #1 и #2. Наилучший вариант — #2, так как он является точным совпадением, значит, его и выбираем. Поскольку это переписанный кандидат, то d1 < d2 вычисляется как (d1 <=> d2) < 0. Но это некорректное выражение, ведь нельзя сравнивать void с 0 — значит, и всё сравнение некорректно. Заметьте, что после этой неудачи мы уже не будем совершать какие-либо действия, чтобы выбрать кандидат #1.

Краткий обзор правил

Очевидно, что эти правила сложнее тех, что были в C++17, но я привожу их полностью в этом небольшом разделе. Здесь не будет сносок, посвящённых каким-то особым случаям или исключениям. Просто запомните самые главные принципы:

  • Обращение доступно только для базовых операторов
  • Переписываться могут только производные операторы (через соответствующие базовые)
  • При поиске кандидатов за один проход ищутся все операторы с данным именем, а также все их обращённые и переписанные версии
  • Если наилучший кандидат является переписанной или обращённой версией и при этом такая замена является недопустимой, программа считается некорректной.

Если вы будете следовать этому совету и определять ТОЛЬКО БАЗОВЫЕ ОПЕРАТОРЫ, вам и не придётся беспокоиться обо всём этом. Все ваши сравнения будут работать.

В каждом случае выражение в первом столбце имеет больший приоритет, чем выражение во втором, а то, в свою очередь, имеет больший приоритет, чем выражение в третьем столбце (при прочих равных условиях). Для ясности я привожу таблицу со всеми возможными преобразованиями на уровне исходного кода. Обратите внимание, что второй и третий столбцы содержат только базовые операторы:

Варианты с «космическим кораблём» в правом столбце обычно пишутся с тем же оператором, что и в исходной версии, т.е. a < b пишется как 0 < (b <=> a), но я написал их с противоположными знаками, чтобы нагляднее показать, как меняется знак в переписанной версии.

Определение сравнений для использования по умолчанию

Среди прочего в C++17 раздражает необходимость подробно расписывать поэлементные лексикографические сравнения. Это занятие утомительно и чревато ошибками. Напишем полный набор операторов для линейно упорядоченного типа с тремя членами:

struct A { T t; U u; V v; bool operator==(A const& rhs) const { return t == rhs.t && u == rhs.u && v == rhs.v; } bool operator!=(A const& rhs) const { return !(*this == rhs); } bool operator< (A const& rhs) const { // я предпочитаю этот стиль, потому что так сложнее ошибиться, // чем если использовать вложенные ?: или &&/|| if (t < rhs.t) return true; if (rhs.t < t) return false; if (u < rhs.u) return true; if (rhs.u < u) return false; return v < rhs.v; } bool operator> (A const& rhs) const { return rhs < *this; } bool operator<=(A const& rhs) const { return !(rhs < *this); } bool operator>=(A const& rhs) const { return !(*this < rhs); }
};

Ещё лучше было бы использовать какой-нибудь std::tie(), но это всё равно утомительно.

Теперь давайте попробуем написать ту же структуру, следуя моему совету: определять только базовые операторы:

struct A { T t; U u; V v; bool operator==(A const& rhs) const { return t == rhs.t && u == rhs.u && v == rhs.v; } strong_ordering operator<=>(A const& rhs) const { // сравниваем элементы T if (auto c = t <=> rhs.t; c != 0) return c; // ... теперь U if (auto c = u <=> rhs.u; c != 0) return c; // ... теперь V return v <=> rhs.v; }
};

Тут не просто меньше кода. Сама реализация <=> гораздо проще для понимания по сравнению с реализацией <. Она очевидней, поскольку полное сравнение можно выполнить за один проход. Проверки c != 0 не дадут нам продолжить, если мы обнаружим пару неравных значений, и каким бы отношением ни было выражено это неравенство (меньше или больше), это будет окончательный результат сравнения.

А в C++20 достаточно просто сказать компилятору, что мы хотим:
В итоге получается обычное поэлементное лексикографическое сравнение по умолчанию.

struct A { T t; U u; V v; bool operator==(A const& rhs) const = default; strong_ordering operator<=>(A const& rhs) const = default;
};

Нужно явно указать, какие операторы сравнения должен сгенерировать компилятор по умолчанию. Наш код можно ещё упростить, если категорию сравнения определять автоматически:

struct A { T t; U u; V v; bool operator==(A const& rhs) const = default; auto operator<=>(A const& rhs) const = default;
};

Можно пойти ещё дальше. В типичном сценарии, когда требуется обычное поэлементное сравнение на равенство и отношение, достаточно определить только один оператор:

struct A { T t; U u; V v; auto operator<=>(A const& rhs) const = default;
};

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

Темы будущих статей

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

Примечание переводчика

Команда PVS-Studio с интересом познакомилась с этой статьей, так как нам в ближайшее время предстоит реализовать поддержку нового оператора в анализаторе. А поскольку статья очень полезная и хорошо всё объясняет, мы решили сделать её перевод для хабра-сообщества. На наш взгляд, это очень нужное нововведение языка, так как по нашему опыту операторы сравнения очень часто содержат ошибки (см. статью "Зло живёт в функциях сравнения"). Теперь С++ программистам жить станет проще и ошибок данного типа будет меньше.

Заодно возникла идея создать в PVS-Studio новую диагностику для поиска некорректно написанных операторов <, которые были описаны в статье:

bool operator< (A const& rhs) const { return t < rhs.t && u < rhs.u;
}

Подобный код может присутствовать в старых больших проектах. Возможно, и ещё какие-то диагностики сделаем. Надо подумать.

Первоисточник: Comparisons in C++20.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть