ХабрПостыНовостиМегапосты?

Какой тип ordering должен возвращать мой operator<=> в C++?

На Хабре было опубликовано уже достаточно статей, посвященных «spaceship operator» operator<=> ([1], [2], [3], [4]) И этой статьи бы не было, если бы все они были идеальны и описывали его во всей полноте. Но ни одна из них в деталях не рассказывает: а какой тип, собственно, должен возвращать наш operator<=>, если мы реализуем его своими руками: std::strong_ordering, std::weak_ordering или std::partial_ordering? И какая вообще между ними разница?

Ответ для нетерпеливых:

  1. Возвращайте strong_ordering, если для вашего типа a == b подразумевает f(a) == f(b) (где f читает только значимое для сравнения состояние, доступное через публичные константные члены). То есть, если пользователь никак не может отличить значения, эквивалентные с точки зрения operator==.

  2. Возвращайте weak_ordering, если может быть так, что хоть a == b и дало true, пользователь может отличить a от b.

  3. Возвращайте partial_ordering, если некоторые значения вашего типа вообще невозможно как-либо адекватно сравнивать с другими его значениями.

Остались вопросы? Разберем вышенаписанное подробнее!

Полностью упорядоченные типы

Как определить, что ваш тип является полностью упорядоченным? Следуйте нашей инструкции!

Его эквивалентные (a == b) значения неразличимы (подробнее об этом понятии будет рассказано далее)? Он не может представлять несравнимые значения? Их вообще возможно сранивать с помощью «больше», «меньше» и «равно»? Если ваши ответы — да, да, да, то ваш тип полностью упорядоченный и его operator<=> должен возвращать std::strong_ordering.

Таким типом может быть, например, класс Person, который мы можем пожелать сортировать в первую очередь по фамилии, далее — по имени, далее — по ИНН.

class Person {
  string tax_ident;
  string first_name;
  string last_name;
public:
    std::strong_ordering operator<=>(const Person& rhs) const {
      if (auto cmp = last_name <=> rhs.last_name; cmp != 0) return cmp;
      if (auto cmp = first_name <=> rhs.first_name; cmp != 0) return cmp;
      return tax_ident <=> rhs.tax_ident;
    }
};

Слабо упорядоченные типы

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

Так, класс CaseInsensitiveString является слабо упорядоченным: хоть для него "abc" == "aBc" и возвращает true, эти значения различимы, так как пользователь может отличить "abc" от "aBc", сравнив значения, возвращенные публичным методом str. Так что в его operator<=> более уместным и правильным будет возвращать std::weak_ordering.

class CaseInsensitiveString {
  string s;
public:
  std::weak_ordering operator<=>(const CaseInsensitiveString& rhs) const {
    return case_insensitive_compare(s.c_str(), rhs.s.c_str());
  }
  std::string_view str() const { return s; }
};
Подробнее о «различимости»

Под «различимостью» в контексте operator<=> понимается именно то, может ли пользователь отличить a от b при том, что a == b вернуло true.

Так, в примере с строго упорядоченным типом Person, пользователь никак не может отличить a от b, если a == b, потому что хоть сравни он попарно все их члены (tax_ident, first_name и last_name; для которых можно написать геттеры) — он не найдет между ними разницы.

И, наоборот, тип CaseInsensitiveString (далее — CIS) называется слабо упорядоченным, так как пользователь может наблюдать разницу между значениями, одинаковыми с точки зрения operator==: CIS{"abc"} (объект a) == CIS{"aBc"} (объект b), но a.str() != b.str(). Если бы CIS не предоставлял пользователю никакой возможности получить значение исходной строки, то формально он считался бы строго упорядоченным.

Частично упорядоченные типы

А что, если эквивалентные значения нашего типа различимы, и, кроме того, наш тип может представлять несравнимые значения?

Например, если он представляет человека относительно некоего семейного древа: между двумя значениями такого типа можно установить отношение эквивалентности (a == b, если a и b — один и тот же человек), отношение «меньше» (a < b, если a — потомок b) и отношение «больше» (a > b, если a — предок b). Но, кроме этого, для него существуют случаи, когда значения несравнимы: например, когда a и b — разные люди, никак не связанные кровными узами.

class PersonInFamilyTree {
public:
  std::partial_ordering operator<=>(const PersonInFamilyTree& rhs) const {
    if (this->is_the_same_person_as(rhs)) return partial_ordering::equivalent;
    if (this->is_transitive_child_of(rhs)) return partial_ordering::less;
    if (rhs.is_transitive_child_of(*this)) return partial_ordering::greater;
    return partial_ordering::unordered;
  }
};

Более жизненный пример частично упорядоченного типа — double (или же float). Хоть в большинстве случаев мы можем установить между его значениями отношение (на которые обычно полагаться не стоит) эквивалентности, «меньше» и «больше», но, например, NaN не эквивалентен, не больше и не меньше чего-либо, даже другого NaN.


Теперь вы знаете, как правильно передать семантику вашего operator<=> с помощью типов std::strong_ordering, std::weak_ordering и std::partial_ordering, какой из них следует использовать в каком случае. Вы великолепны!

Опубликовано при поддержке C++ Moscow

@eoanermine
03.02.2024 14:00 UTC
Первоисточник

Комментарии

@Kelbon
03.02.2024 09:43 UTC
+11

Чёто очень странное, походит то ли на перевод, то ли на генерацию

вообще ничего не понимаю что тут имелось в виду, кто такой _ordering

Потом говорится о типах, которых вообще в стандарте не существует, какие то strong_equality, дальше упоминается, что их не существует (зачем тогда упоминать?)

Итак, правильный ответ:

если ваш тип просто агрегация других типов:

auto operator<=>(const my_type&) const = default;

компилятор сам разберётся

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

Если различимы (например какой то из филдов не участвует в сравнении, но логически является частью типа), то weak_ordering

Если иногда у вас вообще не получается как то логично сравнить 2 значения - partial_ordering



04.02.2024 08:57 UTC
0

Всё верно, этот конкретный фрагмент — перевод, так как я прямо цитировал пропозал Consistent comparison (P0515). Абзацем ранее было написано: «а какой тип, собственно, должен возвращать наш operator<=>, если мы реализуем его своими руками: std::strong_ordering, std::weak_ordering или std::partial_ordering».
Видимо, я был должен уточнить, что под «_ordering» подразумевается «(strong/weak/partial)_ordering». Соглашусь с тем, что информация про удаленные виды экволити оказалась лишней. Моей мотивацией было полностью сохранить оригинальные формулировки, так как я посчитал, что они достаточно интересны и полезны. Но теперь мне действительно кажется, что они лишь вносят неясность. Спасибо, я подкорректирую этот фрагмент статьи.

Ваш ответ несомненно правильный, но очень сжатый. Целью моей статьи был развернутый ответ на вопрос с приведением примеров.

Еще раз спасибо за фидбек!

@NeoCode
03.02.2024 13:48 UTC
+3

Слабо упорядоченные тип

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

может быть все-же "различимы"?

04.02.2024 08:25 UTC
0

Спасибо, вы правы, я опечатался

04.02.2024 08:25 UTC
0
НЛО прилетело и опубликовало эту надпись здесь
@NeoCode
03.02.2024 20:31 UTC
+1

Кстати, чисто порассуждать. Если с упорядоченностью всё понятно - есть упорядоченные и есть неупорядоченные типы, то вот с эквивалентностью/различимостью... Да, можно сказать что "ABC" и "abc" эквивалентны при case-insensitive сравнении, но при этом различимы. А можно сказать что два объекта различимы если они у них разные адреса в памяти (кажется это то что делает оператор "идентичности" === в некоторых языках). Можно придумать огромное количество уровней эквивалентности. Например если целые части двух чисел double равны, то числа эквивалентны. Или если равны с точностью до десятых, с точностью до сотых и т.д. Но оператор сравнения единственный, и поэтому, если мы его переопределяем, мы выбираем какой-то конкретный критерий эквивалентности. А что тогда такое "различимость"? Двоичная эквивалентность? А зачем это нужно, если программист явно определил, какие объекты считать "равными"? Не нарушает ли это принцип инкапсуляции?

04.02.2024 09:38 UTC
0

Оригинальный пропозал Consistent comparison (P0515) подразумевает под различимостью то, что хоть a == b может быть true, но какое-то f(a) == f(b) при этом может быть и не true, где f «считывает только заметное для сравнения состояние, доступное с помощью public const членов».

Стандарт не навязывает, что такое различимость. Программист сам регулирует это под свои намерения.

Мне кажется, в примере с CaseInsensitiveString можно сказать, что в целом все, что от нее требуется — это case insensitive сравнение. Но концептуально она должна давать нам возможность получить исходный string. Он может нам для чего-то понадобиться. Так что у нас тут weak order, ибо мы можем выдумать удовлетворяющую критериям функцию f, что f(a) != f(b) при a == b: bool f(C a, C b) { return a.str(); == b.str(); }

Является ли адрес в памяти «заметным для сравнения состоянием, доступным с помощью public const членов»? Наверное, только в очень специфических случаях. И программист это явно регулирует.

Про всякие даблы. Допустим, у нас есть класс, представляющий какое-нибудь вещественное число (без всяких NaNов). Программист явно определил, какие объекты считать «равными» (допустим, равные с точностью до целого). И программист явно же определил, какие объекты считать различимыми, ограничивая публичный интерфейс. Он сделал возможность получить исходное число частью публичного интерфейса — тогда 1.2 и 1.5 различимы. А если же не сделал — то есть, пользователи, пользуясь публичным интерфейсом, никак не могут различить 1.2 и 1.5 — неразличимы.

Имхо, с инкапсуляцией всё ок.

04.02.2024 18:31 UTC
0

А зачем weak_ordering может потребоваться на практике?

05.02.2024 18:25 UTC
+1

Я уже как-то приводил где-то здесь этот гипотетический пример. Предположим, у нас есть некий контейнер. Этот контейнер в своей специализации для элементов со strong ordering имеет полное право хранить вместо кучи одинаковых элементов всего один элемент со счетчиком его дубликатов (ведь элемент заявляет, что его дубликаты неразличимы), в то же время как в своей специализации для элементов с weak ordering он, очевидно, не имеет на это права - он должен хранить все якобы "равные" элементы.

@eoanermine
04.02.2024 11:01 UTC
+1

UPD 04.02.2024 (13:54 MSK)
Обновлено введение: добавлено пояснение относительно функции f и в общем более человеческое объяснение как strong_ordering, так и weak_ordering. Удалена бесполезная информация касательно так и не попавших в стандарт strong_equality и weak_equality.

Раздел про слабо упорядоченные типы стал подробнее: появилось детальное объяснение понятия «различимости».

Спасибо @Kelbon и @NeoCodeза ранний фидбек.