В сегодняшней публикации мы поговорим о новом новшестве в мире C++ - операторе "спейсшип" (spaceship aka three-way comparison), он же тройное сравнение.
Устраивайтесь поудобнее, взлетаем.
Итак, оператор <=>
появился в C++20.
Что же он делает?
Обычный оператор сравнения вроде <
берет на вход два значения, тестирует на них корректность заданного бинарного отношения и возвращает булево значение, обозначающее результат проверки.
Например, для выражения 5<2
очевидно, что отношение <
здесь не осуществляется, поэтому результат будет false.
Просто и понятно.
Выражение 5<=>2
действует по другому. Оно вычисляет само отношение (в данном случае >
) и возвращает его. Т.е, 5<=>2 выдает результат 'greater', потому что 5>2.
Прямо скажем, функция не самая важная и в других языках давно реализованная.
Давайте теперь взглянем на нюансы использования этой операции.
Нюанс #1
Возьмем простенький код
int main(){
auto n=5;
auto m=2;
bool a = n<m;
return 0;
}
Он скомпилируется?
Конечно.
А такой код, тоже простенький?
int main(){
auto n=5;
auto m=2;
auto a = n<=>m;
return 0;
}
Такой код уже не скомпилируется.
Почему?
Потому что надо добавить хедер.
#include <compare>
int main(){
auto n=5;
auto m=2;
auto a = n<=>m;
return 0;
}
Обратите внимание: если вы хотите использовать спейсшип и не использовать стандартную библиотеку вообще - придется выбрать что-то одно.
Совместить не получится.
Почему же так?
О. Тут хитро. <
и <=>
оба встроенные операторы языка. Но одному не требуется никакой внешний хедер, а другому таки да. Дело в том, что <=>
возвращает значения, определенные в стандартной библиотеке. И вместо того, чтобы вынести сам оператор в стандартную библиотеку, разработчики стандарта затолкали его в язык, а необходимые ему для работы значения вынесли наружу. Почему они так сделали, я лично не знаю, но предчувствую плохое.
Нюанс #2
Опять простой код:
#include <compare>
int main(){
auto n=5;
auto m=2;
auto a = n<=>m;
auto c=2.0;
auto d=1.0;
auto e=c<=>d;
bool eq=(e==a); // returns true
return 0;
}
Что мы здесь имеем?
Мы производим 2 сравнения с разными типами. И получаем 2 разных класса ответов -std::strong_ordering
и std::partial_ordering
.
На самом деле есть даже три разных класса ответов - добавьте std::weak_ordering
.
Причем, что небезынтересно, в 2 классах есть по 4 стандартных значения, а в одном 3.
Третий класс мы тут трогать не будем, потрогаем только первые два.
Так вот, оказывается, что в одном (std::strong_ordering
) 4 значения, из которых 2 на самом деле эквивалентны (по-моему, даже побитово).
Но в целом значения одного класса не совсем эквивалентны другому - 4 значения одного класса (std::strong_ordering
) отображаются в 3 значения другого (std::partial_ordering
)!
Здорово, правда?
Но это еще не все.
Нюанс #3
Простой код:
auto c=2.0;
auto d=1.0;
auto e=c<=>d;
if(e==std::partial_ordering::equivalent){
std::cout<<"e==eqvuivalent "<<std::endl;
}
else if (e==std::partial_ordering::less){
std::cout<<"e==less "<<std::endl;
}
else if (e==std::partial_ordering::greater){
std::cout<<"e==great "<<std::endl;
}
else if (e==std::partial_ordering::unordered){
std::cout<<"e==unordered "<<std::endl;
}
Скомпилируется? Ну конечно!
А так?
auto c=2.0;
auto d=1.0;
auto e=c<=>d;
if(e==0){
std::cout<<"e==equivalent "<<std::endl;
}
else if (e==std::partial_ordering::less){
std::cout<<"e==less "<<std::endl;
}
else if (e==std::partial_ordering::greater){
std::cout<<"e==great "<<std::endl;
}
else if (e==std::partial_ordering::unordered){
std::cout<<"e==unordered "<<std::endl;
}
Да, да, да!
А так?
auto c=2.0;
auto d=1.0;
auto e=c<=>d;
if(e==0){
std::cout<<"e==equivalent "<<std::endl;
}
else if (e==1){
std::cout<<"e==less "<<std::endl;
}
else if (e==std::partial_ordering::greater){
std::cout<<"e==great "<<std::endl;
}
else if (e==std::partial_ordering::unordered){
std::cout<<"e==unordered "<<std::endl;
}
А?
А так уже нет.
Оказывается, с нулем сравнивать можно, а с остальными числами низя.
Почему? Потому.
Зато с нулем - сравнивай не хочу:
auto c=2.0;
auto d=1.0;
auto e=c<=>d;
if(e==0){
std::cout<<"e==eqvuivalent "<<std::endl;
}
else if (e<0){
std::cout<<"e==less "<<std::endl;
}
else if (e>0){
std::cout<<"e==great "<<std::endl;
}
else if (e==std::partial_ordering::unordered){
std::cout<<"e==unordered "<<std::endl;
}
Нюанс #4
auto dnan = std::nan("1");
auto dnancomp = d<=>dnan;
bool t = dnan==0.1;
Какое значение будет иметь t? A dnancomp?
t у нас будет false, а dnancomp будет иметь значение unordered
Что означает, цитирую: a valid value of the type std::partial_ordering
indicating relationship with an incomparable value
Это немножко вызывает недоумение.
Как же так, я ж в другой строке выяснил, что nan не равен числу, а тут вдруг оказывается, что значения несравнимые?
Как говорила Ф.Раневская, мы значения сравниваем, а они, оказывается, несравнимые!
Почему нельзя было добавить в std::partial_ordering
еще и значение "non_equivalent"? Гулять так гулять, где 4 значения, там и пятое можно всунуть.
Нюанс #5
Как нетрудно сообразить, эту космическую приблуду нетрудно сэмулировать.
Например, такой код:
auto n=5;
auto m=2;
auto a = n<=>m;
Легко можно заменить таким:
auto n=5;
auto m=2;
if(n>m){
//greater
}
else if(n<m){
//less
}
else{
//equal
}
Возможны вариации.
Но с космическим оператором жизнь становится краше: теперь надо проверить к какому классу относится результат (просто, чтобы знать, какими значениями можно пользоваться), а потом проверить какое значение выпало.
Ну как надо - необязательно, но может возникнуть такая ситуация.
В чем выигрыш по сравнению с тремя стариковскими строчками (см. выше) - даже не знаю.
Нюанс #6
Нам столько говорили про то, как благотворно влияют новые стандарты на перформанс, что захотелось проверить.
Никаких бенчмарков проводить не буду, ограничусь толко сравнением ассемблеров.
Вот традиционный, он же стариковско-пещерный код:
int main(){
int x=0;
auto n=2;
auto m=1;
if(n>m){
x=1;
}
else if(m>n){
x=2;
}
else{
x=3;
}
return 0;
}
Вот его ассемблер:
main:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], 0
mov DWORD PTR [rbp-8], 2
mov DWORD PTR [rbp-12], 1
mov eax, DWORD PTR [rbp-8]
cmp eax, DWORD PTR [rbp-12]
jle .L2
mov DWORD PTR [rbp-4], 1
jmp .L3
.L2:
mov eax, DWORD PTR [rbp-12]
cmp eax, DWORD PTR [rbp-8]
jle .L4
mov DWORD PTR [rbp-4], 2
jmp .L3
.L4:
mov DWORD PTR [rbp-4], 3
.L3:
mov eax, 0
pop rbp
ret
А это молодежно-космический код:
#include <compare>
int main(){
int x=0;
auto n=2;
auto m=1;
auto a = n<=>m;
if(a==std::strong_ordering::greater){
x=1;
}
else if(a==std::strong_ordering::less){
x=2;
}
else{
x=3;
}
return 0;
}
Здесь я обработал только три значения - если бы речь шла о классе std::partial_ordering, пришлось бы обработать 4.
И его ассемблер:
std::strong_ordering::less:
.byte -1
std::strong_ordering::greater:
.byte 1
main:
push rbp
mov rbp, rsp
sub rsp, 16
mov DWORD PTR [rbp-4], 0
mov DWORD PTR [rbp-8], 2
mov DWORD PTR [rbp-12], 1
mov edx, DWORD PTR [rbp-8]
mov eax, DWORD PTR [rbp-12]
cmp edx, eax
je .L5
cmp edx, eax
jge .L6
mov BYTE PTR [rbp-13], -1
jmp .L7
.L6:
mov BYTE PTR [rbp-13], 1
jmp .L7
.L5:
mov BYTE PTR [rbp-13], 0
.L7:
mov edx, 1
movzx eax, BYTE PTR [rbp-13]
mov esi, edx
mov edi, eax
call std::operator==(std::strong_ordering, std::strong_ordering)
test al, al
je .L8
mov DWORD PTR [rbp-4], 1
jmp .L9
.L8:
mov edx, -1
movzx eax, BYTE PTR [rbp-13]
mov esi, edx
mov edi, eax
call std::operator==(std::strong_ordering, std::strong_ordering)
test al, al
je .L10
mov DWORD PTR [rbp-4], 2
jmp .L9
.L10:
mov DWORD PTR [rbp-4], 3
.L9:
mov eax, 0
leave
ret
Компилятор один и тот же - gcc 13.2 x86-64
Ключи оптимизации - дефолтные в обоих случаях.
(Микрофон падает)
Finale
Если очень легкомысленно подытожить мои личные впечатления от космической технологии, то будет примерно так: работа проделана огромная, результаты ... не очень.
Мне кажется, что фича относительно не очень важная, и реализована она как-то размашисто с одной стороны, и корявенько с другой.
Разве что использовать ее в компайл-тайме, может там у нее есть какие-то важные плюсы... Не знаю, не копал.
А вы как думаете?
Комментарии (29)
tenzink
12.10.2023 14:52+3Приводить ассемблерный вывод и не указывать ключи компиляции и компилятор - моветон. Ещё лучше было бы дать ссылку на godbolt.
vk6677
12.10.2023 14:52+5Я не использовал такой оператор. Но, как я понял, одна его реализация позволяет заменить перегрузку всех операторов сравнения.
NeoCode
12.10.2023 14:52+2Зачем так сложно сделали? Аж 3 типа с результатами сравнения... Достаточно было одного, имеющего 4 значения: "больше", "меньше", "равно" и "несравнимо". Это красиво ложится на 2 бита, которые можно представить как специальное знаковое целое число, имеющее соответственно коды +1, -1, 0 и -2 (которое "несравнимо", но в нашем случае это было бы не отрицательное число, а специальный код - аналог NaN для целых чисел, который не сравнивался бы с целыми числами).
А то что Nan не равен числу и одновременно несравним - это ИМХО нормально. Число может быть неравно другому числу и одновременно больше другого числа. А другое может быть неравно и одновременно меньше. Это же не вызывает вопросов? Собственно, неравенство может получаться если число больше, меньше или несравнимо с другим.
Kelbon
12.10.2023 14:52+10Математику учить надо, чтобы понимать зачем существуют категории сравнений. Ничего сложного там нет, действительно только больше-меньше-равно-несравнимо, только для strong_ordering несравнимо не бывает и *читайте доку*
А статья буквально ничего не говорит полезного. Ни про типы сравнений, ни про то зачем оно нужно, ни про перегрузки операторов и = default. Ни че го
NeoCode
12.10.2023 14:52+4На практике они зачем нужны, в реальном программировании? Если у вас есть два числа int, то они никогда не будут несравнимы. А если у вас есть два double, то могут оказаться несравнимы, даже если вы примените strong_ordering. Что там в этом случае будет? Исключение кинется чтоли?
Всю математику в код не затащить по определению. Поэтому я склонен к поиску баланса между математической строгостью и практической целесообразностью.
А статья - это просто повод пообсуждать интересную тему:)
Kelbon
12.10.2023 14:52А если у вас есть два double, то могут оказаться несравнимы, даже если вы примените strong_ordering
как же это вы примените strong_ordering тогда? Для этого и нужно, такая ошибка обнаружится на компиляции.
А специальное сравнение std::three_way_compare вроде бы сделает специальную обработку даблов чтобы там был реальный strong_ordering
vadimr
12.10.2023 14:52Вообще в вычислительных приложениях принято отличать signaling_nan от quiet_nan.
vadimr
12.10.2023 14:52Я не понял,
std::strong_ordering::less
и<
– это одно и то же или нет?Если да, то зачем так по-разному? Если нет, то к чему это всё?
domix32
12.10.2023 14:52+1Чтобы писать одну единственную перегрузку вместо 3+ в обычном С++. Всё. То бишь синтаксический сахар. Все сравнения этим оператором напрямую в коде смысла не имеют и автор делает это только для того чтобы посмотреть что же там вернётся.
boldape
12.10.2023 14:52+14Зачем нужен этот новый оператор? Самый простой и обоснованный ответ это автоматическая генерация всех остальных операторов. Самое важное в нем то, что компилятор может предоставить дефолтную реализацию. Что это значит на практике?
struct X{ private: int data = {}; public: constexpr auto operator <=>(const &X, const &X) = default;) }
Все, объекты класса Х теперь можно сравнивать друг с другом - не надо писать километры бойлер плэйт кода. Когда это не сработает? Тогда когда есть члены класса у которых нет дефолтного оператора <=> тогда придется немного повозиться и определить аж 2 оператора: == и вот этот новый спэйсшип, остальные будут сгенерированы сами, но это все равно сильно меньше чем дедовским способом и самое главное у вас не будет места для тупой опечатки, семантика всех операторов будет согласованной и корректной.
Есть ещё одно место зачем он нужен, например вызов дженерик сортировки для массива флотов/даблов это вообще-то УБ, внезапно да? Вот у этих самых сторонников дедовских способов щас уверен подгорело, сто лет так пишем и никаких УБ не видели. Есть даже целый ток от Шона (забыл фамилию), который из адоба, который детально объясняет почему так. Так вот что бы писать алгоритмы правильно и выставлять наружу требования к типам собственно и нужны эти новые типы одеринга и именно поэтому они разные, а не как тут товарищ выше предлагал все в один энум запихать. Алгоритм может быть перегружен для правильной сортировки даблов как раз за счёт разных типов одеринга, да и чего угодно с партиал одерингом, называется топологическая сортировка и работает совсем не так как ваши эти квик сорты, которые вообще говоря требуют вик одеринга.
Зачем на практике нужно различать Вик и Стронг я пока не нашел, а в чем разница спросите вы? Ну при Стронг одеринге и == ГАРАНТИРОВАНО можете использовать хоть правый хоть левый операнд в ЛЮБОЙ функции и получать один и тот же результат. Ну это только если человек который реализовывал спэйсшип возвращающий Стронг ордеринг понимал в чем разница и не допустил ошибок.
Я хз где это ограничение вообще можно применить, ну вот Инты у них Стронг ордеринг, а у структуры где есть много полей, и сравнение ведётся только по части из них по определению Вик ордеринг, ну и чё?
Этот момент конечно очень слабо освящён и допустить ошибку очень просто если сами реализуете, а не компилятор дефолт генерит можно по ошибке/не знанию/не пониманию объявить тип возвращаемого значения оператора спэйсшип как Стронг ордеринг, но это почти навярнека ошибка иначе вы бы использовали дефолтную реализацию.
KanuTaH
12.10.2023 14:52Я хз где это ограничение вообще можно применить, ну вот Инты у них Стронг ордеринг, а у структуры где есть много полей, и сравнение ведётся только по части из них по определению Вик ордеринг, ну и чё?
Ну я могу себе представить, где это можно применить. Например, если есть некий контейнер для хранения объектов, то то, что два каких-то объекта равны в соответствии со
strong_ordering
гарантирует, что вместо хранения этих двух (или больше) объектов можно хранить только один из них плюс их количество (счетчик дубликатов). А в случае равенства поweak_ordering
- нет, нужно хранить все по отдельности.boldape
12.10.2023 14:52В целом я с вами согласен, но на практике такой тип скорее всего будет и так маленький по размеру, поэтому такая оптимизация будет скорее всего бессмысленной, ибо вам придется хранить такие объекты в куче + виртуальность либо в варианте либо иметь 2 контейнера. В общем на практике найти применение этому сложно.
vadimr
12.10.2023 14:52При тех условиях, при которых сравнение даблов не имеет достаточной для сортировки упорядоченности (т.е. при учёте специальных значений) их сортировка вообще невозможна, так как доступ к значению signaling_nan может приводить к остановке работы программы.
boldape
12.10.2023 14:52Вы забыли про инфы и не сигнальные наны. Их как будете сортировать? Они ж как нулы в эскуэле, не сравнимы сами с собой, а наны ещё и ни с чем другим. Вот когда их исключите из массива вот тогда у вас вик ордеринг получится. Хотел написать сначало, что стронг, но кажется два нуля не позволяют это сказать, но я не уверен. Правильная сортировка даблов это сложно.
vadimr
12.10.2023 14:52Да, я тоже сначала хотел написать про стронг, а потом подумал про два нуля.
Они ж как нулы в эскуэле, не сравнимы сами с собой, а наны ещё и ни с чем другим.
Это вы как раз с SQL путаете.
Hidden text
program nans use IEEE_Arithmetic real :: nanp, infp, infm, zerop, zerom logical, allocatable, dimension (:) :: testgt, testeq, testlt, testsf nanp = IEEE_Value (nanp, IEEE_QUIET_NAN) infp = IEEE_Value (infp, IEEE_POSITIVE_INF) infm = IEEE_Value (infm, IEEE_NEGATIVE_INF) zerop = IEEE_Value (zerop, IEEE_POSITIVE_ZERO) zerom = IEEE_Value (zerom, IEEE_NEGATIVE_ZERO) testgt = [nanp>0,infp>0,infm>0,zerop>0,zerom>0] testeq = [nanp==0,infp==0,infm==0,zerop==0,zerom==0] testlt = [nanp<0,infp<0,infm<0,zerop<0,zerom<0] testsf = [nanp==nanp, infp==infp, infm==infm, zerop==zerop, zerom==zerom] print *, testgt print *, testeq print *, testlt print *, testsf end program nans
F T F F F F F F T T F F T F F F T T T T
Но я как раз про это и написал. Если мы учитываем молчаливые nan, тогда должны учитывать и сигнальные nan, а с последними невозможны вообще никакие действия.
boldape
12.10.2023 14:52+1Я из мира с++, а тут у нас можно почти все что угодно, https://en.cppreference.com/w/cpp/types/numeric_limits/signaling_NaN
vadimr
12.10.2023 14:52+1When a signaling NaN is used as an argument to an arithmetic expression, the appropriate floating-point exception may be raised
Ни IEEE-754, ни C++ не гарантируют никакого конкретного поведения с сигнальным nan. В частности, не гарантируют, что прерывание от сигнального nan можно будет поймать программно. И тем более точно определить точку его возникновения, если одновременно обрабатывается длинный конвейер. И тем более вернуться в неё для возобновления вычислений.
boldape
12.10.2023 14:52Ну может и так только код все равно продолжит работать, а не
а с последними невозможны вообще никакие действия.
Т.е. действия вполне возможны и результат тоже ясный - нан, но вот флаг эксепции может стоять, а может и нет - ну ладно.
tenzink
12.10.2023 14:52| Есть даже целый ток от Шона (забыл фамилию), который из адоба, который детально объясняет почему так.
Вероятно речь про Sean Parent, правда не знаю о каком конкретно выступлении идёт речь
boldape
12.10.2023 14:52+1Да, спасибо что напомнили его имя, вот тот самый ток https://m.youtube.com/watch?v=2FAi2mNYjFA
Playa
12.10.2023 14:52+2В C++20 вместе с этим оператором завезли ещё такой breaking change: при упорядочивании двух
std::pair
с помощьюoperator<
будет использоватьсяoperator<=>
на компонентах этой пары. Это может выстрелить в классах, у которых есть операторы неявного приведения, потому чтоoperator<=>
предпочтёт другойoperator<=>
, даже если в классе уже есть написаный рукамиoperator<
, а для сравнения нужно приведение типов.
funny_falcon
12.10.2023 14:52+2Про дефолтные ключи - прям смешно. Всем известно, что у gcc дефолт -O0, т.е. вообще без оптимизаций. Использовать можно или если сильно торопитесь бинарь получить, или как последняя надежда в gdb понять, что происходит.
Укажите хотя бы -Og, тогда и поговорим про ассемблер.
buldo
12.10.2023 14:52+2Я думал, что это чисто чтобы нормально сравнивать объекты, а не примитивные типы. Типа для нормальной работы коллекций, сортировки и тп. То есть я его понимаю как аналог интерфейса для сравнения объектов из шарпов
atd
12.10.2023 14:52Да, это и есть прямой аналог IComaparable, только вместо трёх вариантов (<, =, >) он может вернуть ещё что-то. Тут, мне кажется, комитет перестарался впихнуть невпихуемое и вообще нарушил YAGNI, но в плюсах всё так и происходит...
starik-2005
Прочитал статью, но так и не понял, чему равно "5<=>2". ЧЯДНТ?
myswordishatred
Возвращает ">", как я понял. Корректный знак неравенства.