Что обозначает сравнение в с
Перейти к содержимому

Что обозначает сравнение в с

  • автор:

Что обозначает сравнение в с

К строкам в языке С++ можно применять операции сравнения.

Эти операции сравнивают два объекта string, либо объект string со строковым литералом. Во всех операцияъ операнды сравниваются посимвольно до тех пор, пока не будет найдена пара соответствующих символов, которые содержат разные символы, или пока не будет достигнут конец одного или обоих операндов. Когда пара символов различается, с помощью сравнение числовых кодов символов определяется, какая строка условно «меньше» или «больше». Если разные пары символов не найдены, но строки имеют разную длину, более короткая строка «меньше» более длинной строки. Две строки равны, если они содержат одинаковое количество символы и все соответствующие коды символов равны. Подобный способ сравнения еще называется лексикографическим сравнением или сравнением в лексикографическом порядке. При этом стоит отметить, что поскольку сравниваются числовые коды символов, результат сравнения также зависит от регистра символов.

Например, оператор == возвращает true, если все символы обеих строк равны.

Поскольку строки «hello» и «Hello» отличаются по регистру первой буквы, соответственно отличается и числовой код символа, поэтому эти строки не равны.

Другой пример — операция > (больше):

std::string s1 <"helm">; std::string s2 <"hello">; bool result ; // true

В данном случае условие s1 > s2 верно, то есть s1 больше чем s2, так как при равенстве первых трех символов («hel») третий символ первой строки («m») стоит в алфавите после четвертого символа второй строки («l»), то есть «m» больше чем «l» (несмотря на то, что по количеству символов «hello» больше чем «helm»).

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

Здесь для сортировки массива строк применяется не самая быстрая, но наглядная сортировка пузырьком, которая сравнивает предыдущую строку с последующей. Если предыдущая «больше» последующей, то через промежуточную переменную temp обмениваем значения. Чтобы оптимизировать сортировку, добавлена переменная sorted . Каждый раз, когда производится обмен значениями, эта переменная сбрасывается в false. А это значит, что нам надо заново запустить внешний цикл do-while .

Функция compare()

Для сравнения строк у строки также можно вызвать функцию compare() . В нее передается другая строка, с которой сравнивается текущая. Функция compare возвращает 0, если две строки равны. Если текущая строка больше, то возвращается число больше 0. Если текущая строка меньше, то возвращается число менише 0. Например:

Здесь две строки равны, поэтому возвращается число 0.

Здесь строка «Sam» больше строки «Bob», поэтому результатом первого сравнения будет число 1. А во втором сравнении первая строка «Sam» будет меньше второй строки «Tom», соответственно функция возвратить -1.

Функция compare() имеет ряд версий. Отметим одну из них, которая принимает три параметра:

Первый параметр представляет индекс первого символа в текущей строке, начиная с которого начинается подстрока. Второй параметр — количество символов подстроки. Третий параметр — строка, которая сравнивается подстрока. То есть сравниваем строку из третьего параметра с подстрокой, которая начинается с индекса в первом параметре и имеет количество символов, указанных во втором параметре.

Где мы это можем применить? Например, нам надо узнать индекс, с которого встречается одна строка в другой:

В данном случае мы пытаемся найти индекс строки word («world») в строке text («Hello world!»). Для этого в цикле проходим по символам из строки text, пока не дойдем до символа с индексом text.length() — word.length() + 1 (так как сравниваем word.length() символов, поэтому вычитаем word.length(). И так как строки могут быть равны, добавляем 1)

В цикле выполняем сравнение

то есть в строке text сравниваем подстроку, которая начинается с индекса i и имеет word.length() символов с строкой word. Если для какого то числа i мы смогли найти подобное равенство, то выводим число i на консоль. И в данном случае консоль отобразит следующее

Еще одна форма функции compare() позволяет сравнивать две подстроки:

Здесь в принципе тот же алгоритм, что и с предыдущем примере. Только теперь сравниваем из строки word подстроку размером size, которая начинаяется с индекса 1 (то есть подстрока «or»), с подстрокой из строки text. И в данном случае консольный вывод будет следующим

Операторы сравнения и логические операторы

Каждый из этих операторов вычисляется в логическое значение true (1) или false (0).

Логические операторы

Логические операторы позволяют проверить сразу несколько условий за раз.

Оператор Символ Пример Операция
Логическое НЕ ! !x true, если x — false и false, если x — true
Логическое И && x && y true, если x и y — true, в противном случае — false
Логическое ИЛИ || x || y true, если x или y — true, в противном случае — false

! (Логическое НЕ)

Таблица истинности
логического оператора НЕ

Операнд Результат
true false
false true

Если операнд равен true , то с применением логического НЕ он станет false . Если операнд до применения оператора равен false , то с его применением станет true . Другими словами, логический оператор НЕ меняет результат с true на false и наоборот.

Оператор НЕ часто используется в условных выражениях:

Следует помнить, что логический оператор НЕ имеет очень высокий уровень приоритета. Например, при вычислении выражения ! х == у сначала вычисляется ! х , а затем производится сравнение на равенсство

Правило: Если логическое НЕ должно работать с результатами работы других операторов, то другие операторы и их операнды должны находится в круглых скобках. Используйте круглые скобки. Тогда не нужно будет помнить правила приоритета.

|| (логическое ИЛИ)

Если хотя бы один из двух операндов истинно, то логический оператор ИЛИ возвращает true, в противном случае результат равен false.

Таблица истинности
логического оператора ИЛИ (оператор ||)

Левый операнд Правый операнд Результат
false false false
false true true
true false true
true true true

Не путайте логическое ИЛИ (||) с побитовым ИЛИ (|) . Хоть у них и одинаковые названия, функции они выполняют разные.

&& (логическое И)

Результат выполнения логического оператор И будет true только при условии, что оба операнда будут истинными, в противном случае – false .

Таблица истинности
логического оператора И (оператор &&)

Левый операнд Правый операнд Результат
false false false
false true false
true false false
true true true

Например, мы хотим знать, находится ли значение переменной х в диапазоне чисел от 10 до 20. В этом случае необходимо проверить два условия: является ли х больше 10 и является ли х меньше 20.

Если два условия истинны, то выполняться будет часть if . Если хоть одно или оба сразу ложные, то выполняться будет часть else .

Можете комбинировать вместе несколько условий И:

Короткий цикл вычислений

Для того, чтобы логическое И возвращало true , оба операнда должны быть истинными. Если первый операнд вычисляется как false , то оператор И должен сразу возвращать false независимо от результата второго операнда, даже без его обработки. Это называется короткий цикл вычислений (short circuit evaluation) и выполняется он, в первую очередь, в целях оптимизации.

Аналогично, если первый операнд логического ИЛИ — true , то и всё условие будет true , надобности вычислять второй операнд нет.

Использование И/ИЛИ

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

Рассмотрим выражение: value1 || value2 && value3 . Поскольку приоритет логического И выше, то обрабатываться оно будет так: value1 || (value2 && value3) , а не так: (value1 || value2) && value3 .

Хорошей практикой является использование круглых скобок для заключения в них операций. Это предотвратит ошибки приоритета, увеличит читабельность кода и четко даст понять компилятору, как нужно обрабатывать выражения. Например, вместо того, чтобы писать value1 && value2 || value3 && value4 , лучше записать (value1 && value2) || (value3 && value4) .

Comparison operators

Выражения операторов двустороннего сравнения имеют форму.

lhs < rhs (1)
lhs > rhs (2)
lhs <= rhs (3)
lhs >= rhs (4)
lhs == rhs (5)
lhs != rhs (6)

Во всех случаях,для встроенных операторов,lhs и rhs должны иметь либо то,либо другое.

  • арифметика или тип перечисления (см.арифметические операторы сравнения ниже)
  • тип указателя (см.ниже операторы сравнения указателей)

после применения стандартных преобразований lvalue-to-rvalue , массива в указатель и функции в указатель . Сравнение устарело, если оба операнда имеют тип массива до применения этих преобразований (начиная с C++20).

В любом случае, результатом является предварительное значение bool .

операторы арифметического сравнения

Если операнды имеют арифметический тип или тип перечисления (scoped или unscoped), обычные арифметические преобразования выполняются для обоих операндов в соответствии с правилами для арифметических операторов . Значения сравниваются после преобразования:

Example
Операторы сравнения указателей

Операторы сравнения можно использовать для сравнения двух указателей.

Только операторы равенства ( operator== и operator!= ) можно использовать для сравнения следующих пар указателей:

  • two pointers-to-members
  • константа нулевого указателя с указателем или указателем на член
  • значение std::nullptr_t с константой нулевого указателя (которая также может быть значением std::nullptr_t )

Во- первых, к обоим операндам применяются преобразования указателей (преобразования указателя в член, если аргументы являются указателями на члены), преобразования указателя функции (начиная с C++17) и преобразования квалификации применяются к обоим операндам для получения комбинированный указатель , следующим образом.

1) Если оба операнда являются константами нулевого указателя, тип составного указателя является std::nullptr_t (since C++11)
  • указатель на cv1 void , and
  • указатель на cv2 T , где T — тип объекта или void ,
  • P1 , указатель на (возможно, cv-квалифицированный) T1 , и
  • P2 , указатель на (возможно, cv-квалифицированный) T2 ,
  • MP1 , указатель на элемент T1 типа (возможно, cv-квалифицированный) U1 , и
  • MP2 , указатель на элемент T2 типа (возможно, cv-квалифицированный) U2 ,

В приведенном выше определении, cv-combined Тип двух указателей P1 и P2 — это тип P3 , который имеет то же количество уровней и тип на каждом уровне, что и P1 , за исключением того, что cv-квалификации на каждом уровне устанавливаются следующим образом:

Например, типом составного указателя void* и const int* является const void* . Тип составного указателя int** и const int** — const int* const* . Обратите внимание, что до решения CWG1512 нельзя было сравнивать int int** и const int** .

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

Обратите внимание, что это означает, что любой указатель можно сравнить с void* .

Результат сравнения двух указателей на объекты (после преобразования)определяется следующим образом:

Результат сравнения равенства двух указателей (после преобразования)определяется следующим образом:

Результат сравнения двух указателей с членами (после преобразования)определяется следующим образом:

Если указатель p compare equal чтобы указатель q , p<=q и p>=q как выход true и p<q и p>q и выход false .

Если указатель p compares greater чем указатель q , то p>=q , p>q , q<=p и q<p все дают true , а p<=q , p<q , q>=p и q>p все дают false .

Если два указателя не указаны для сравнения больше или равно,результат сравнения не определен.Неопределенный результат может быть недетерминированным и не обязательно должен быть последовательным даже для нескольких оценок одного и того же выражения с одними и теми же операндами при одном и том же выполнении программы:

При разрешении перегрузки для определенных пользователем операторов для каждой пары повышенных арифметических типов L и R , включая типы перечисления, следующие сигнатуры функций участвуют в разрешении перегрузки:

Для каждого типа P , который является либо указателем на объект, либо указателем на функцию, следующие сигнатуры функций участвуют в разрешении перегрузки:

Для каждого типа MP , который является указателем на объект-член или указателем на функцию-член или std::nullptr_t , следующие сигнатуры функций участвуют в разрешении перегрузки:

Example
Notes

Поскольку эти операторы группируются слева направо, выражение a<b<c анализируется (a<b)<c , а не a<(b<c) или (a<b)&&(b<c) .

Общим требованием для пользовательского operator< является строгое слабое упорядочение . В частности, этого требуют стандартные алгоритмы и контейнеры, работающие с типами Compare : std::sort , std::max_element , std::map и т. д.

Хотя результаты сравнения указателей случайного происхождения (например, не все указывают на элементы одного и того же массива) не определены, многие реализации обеспечивают строгое полное упорядочение указателей, например, если они реализованы как адреса в непрерывном виртуальном адресном пространстве. Те реализации, которые не (например, где не все биты указателя являются частью адреса памяти и должны игнорироваться для сравнения, или требуется дополнительное вычисление, или иначе указатель и целое число не являются отношением 1 к 1), обеспечивают специализация std::less для указателей с такой гарантией. Это позволяет использовать все указатели случайного происхождения в качестве ключей в стандартных ассоциативных контейнерах, таких как std::set или std::map .

Для типов, которые являются EqualityComparable и LessThanComparable , стандартная библиотека C ++ делает различие между equality , которое является значением выражения a == b и equivalence , которое является значением выражения !(a < b) && !(b < a) .

Сравнение между указателями и константами пустых указателей было удалено решением проблемы CWG 583 , включенной в N3624 .

Операции сравнения в C++20

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

У нас не просто будет новый оператор — семантика сравнений претерпит существенные изменения на уровне самого языка.

Даже если ничего больше вы из этой статьи не вынесете, запомните эту таблицу:

Равенство Упорядочение
Базовые == <=>
Производные != <, >, <=, >=

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

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

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

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

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

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

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

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

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

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

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

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

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

В самом деле, стандартная библиотека шаблонов (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, 1.f == NaN и 1.f > NaN даёт ложь. Поэтому 1.f <= NaN также даёт ложь, но при этом !(NaN < 1.f)правда.

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

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

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

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

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

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

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

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

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

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

Оператор «космический корабль» возвращает не значение типа 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 приводимо к weak_ordering). При этом текущий вид отношения сохраняется (т.е. strong_ordering::equal превращается в weak_ordering::equivalent).

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

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

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

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

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

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

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

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

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

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

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

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

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

Равенство Упорядочение
Базовые == <=>
Производные != <, >, <=, >=

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

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

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

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

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

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

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

Опять же, выражение 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). Просто два выражения переписаны через обращённые кандидаты. Повторю: никаких новых функций не создаётся.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

В 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, можем ли мы считать такую операцию операцией равенства?)

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

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

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

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

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

Исходная операция Вариант 1 Вариант 2
a == b b == a
a != b !(a == b) !(b == a)
a <=> b 0 <=> (b <=> a)
a < b (a <=> b) < 0 (b <=> a) > 0
a <= b (a <=> b) <= 0 (b <=> a) >= 0
a > b (a <=> b) > 0 (b <=> a) < 0
a >= b (a <=> b) >= 0 (b <=> a) <= 0

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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