В сегодняшней публикации мы поговорим о новом новшестве в мире 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
Если очень легкомысленно подытожить мои личные впечатления от космической технологии, то будет примерно так: работа проделана огромная, результаты ... не очень.
Мне кажется, что фича относительно не очень важная, и реализована она как-то размашисто с одной стороны, и корявенько с другой.
Разве что использовать ее в компайл-тайме, может там у нее есть какие-то важные плюсы... Не знаю, не копал.
А вы как думаете?