В сегодняшней публикации мы поговорим о новом новшестве в мире 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
Ключи оптимизации - дефолтные в обоих случаях.
Особенно хотелось бы отметить строчки 30 и 40, где происходит вызов функций.
Вызов функций в операторе сравнения двух чисел!
(Микрофон падает)
Finale
Если очень легкомысленно подытожить мои личные впечатления от космической технологии, то будет примерно так: работа проделана огромная, результаты ... не очень.
Мне кажется, что фича относительно не очень важная, и реализована она как-то размашисто с одной стороны, и корявенько с другой.
Разве что использовать ее в компайл-тайме, может там у нее есть какие-то важные плюсы... Не знаю, не копал.
А вы как думаете?
P.S. Этот текст был создан без использования БЯМ.
