В сегодняшней публикации мы поговорим о новом новшестве в мире 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)


  1. starik-2005
    12.10.2023 14:52
    +2

    Прочитал статью, но так и не понял, чему равно "5<=>2". ЧЯДНТ?


    1. myswordishatred
      12.10.2023 14:52

      Возвращает ">", как я понял. Корректный знак неравенства.


  1. tri_tuza_v_karmane Автор
    12.10.2023 14:52

    5<=>2 = 'greater'


  1. tenzink
    12.10.2023 14:52
    +3

    Приводить ассемблерный вывод и не указывать ключи компиляции и компилятор - моветон. Ещё лучше было бы дать ссылку на godbolt.


  1. vk6677
    12.10.2023 14:52
    +5

    Я не использовал такой оператор. Но, как я понял, одна его реализация позволяет заменить перегрузку всех операторов сравнения.


  1. NeoCode
    12.10.2023 14:52
    +2

    Зачем так сложно сделали? Аж 3 типа с результатами сравнения... Достаточно было одного, имеющего 4 значения: "больше", "меньше", "равно" и "несравнимо". Это красиво ложится на 2 бита, которые можно представить как специальное знаковое целое число, имеющее соответственно коды +1, -1, 0 и -2 (которое "несравнимо", но в нашем случае это было бы не отрицательное число, а специальный код - аналог NaN для целых чисел, который не сравнивался бы с целыми числами).

    А то что Nan не равен числу и одновременно несравним - это ИМХО нормально. Число может быть неравно другому числу и одновременно больше другого числа. А другое может быть неравно и одновременно меньше. Это же не вызывает вопросов? Собственно, неравенство может получаться если число больше, меньше или несравнимо с другим.


    1. Kelbon
      12.10.2023 14:52
      +10

      Математику учить надо, чтобы понимать зачем существуют категории сравнений. Ничего сложного там нет, действительно только больше-меньше-равно-несравнимо, только для strong_ordering несравнимо не бывает и *читайте доку*

      А статья буквально ничего не говорит полезного. Ни про типы сравнений, ни про то зачем оно нужно, ни про перегрузки операторов и = default. Ни че го


      1. NeoCode
        12.10.2023 14:52
        +4

        На практике они зачем нужны, в реальном программировании? Если у вас есть два числа int, то они никогда не будут несравнимы. А если у вас есть два double, то могут оказаться несравнимы, даже если вы примените strong_ordering. Что там в этом случае будет? Исключение кинется чтоли?

        Всю математику в код не затащить по определению. Поэтому я склонен к поиску баланса между математической строгостью и практической целесообразностью.

        А статья - это просто повод пообсуждать интересную тему:)


        1. Kelbon
          12.10.2023 14:52

          А если у вас есть два double, то могут оказаться несравнимы, даже если вы примените strong_ordering

          как же это вы примените strong_ordering тогда? Для этого и нужно, такая ошибка обнаружится на компиляции.
          А специальное сравнение std::three_way_compare вроде бы сделает специальную обработку даблов чтобы там был реальный strong_ordering


          1. NeoCode
            12.10.2023 14:52
            +2

            Примерно понятно. А что тогда такое weak_ordering?


    1. vadimr
      12.10.2023 14:52

      Вообще в вычислительных приложениях принято отличать signaling_nan от quiet_nan.


  1. dprotopopov
    12.10.2023 14:52
    +1

    Лучшее враг хорошего

    Что Страуструп сам то по этому поводу думает?


  1. vadimr
    12.10.2023 14:52

    Я не понял, std::strong_ordering::less и < – это одно и то же или нет?

    Если да, то зачем так по-разному? Если нет, то к чему это всё?


    1. domix32
      12.10.2023 14:52
      +1

      Чтобы писать одну единственную перегрузку вместо 3+ в обычном С++. Всё. То бишь синтаксический сахар. Все сравнения этим оператором напрямую в коде смысла не имеют и автор делает это только для того чтобы посмотреть что же там вернётся.


  1. boldape
    12.10.2023 14:52
    +14

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

    struct X{
    private: int data = {};
    public: constexpr auto operator <=>(const &X, const &X) = default;)
    }

    Все, объекты класса Х теперь можно сравнивать друг с другом - не надо писать километры бойлер плэйт кода. Когда это не сработает? Тогда когда есть члены класса у которых нет дефолтного оператора <=> тогда придется немного повозиться и определить аж 2 оператора: == и вот этот новый спэйсшип, остальные будут сгенерированы сами, но это все равно сильно меньше чем дедовским способом и самое главное у вас не будет места для тупой опечатки, семантика всех операторов будет согласованной и корректной.

    Есть ещё одно место зачем он нужен, например вызов дженерик сортировки для массива флотов/даблов это вообще-то УБ, внезапно да? Вот у этих самых сторонников дедовских способов щас уверен подгорело, сто лет так пишем и никаких УБ не видели. Есть даже целый ток от Шона (забыл фамилию), который из адоба, который детально объясняет почему так. Так вот что бы писать алгоритмы правильно и выставлять наружу требования к типам собственно и нужны эти новые типы одеринга и именно поэтому они разные, а не как тут товарищ выше предлагал все в один энум запихать. Алгоритм может быть перегружен для правильной сортировки даблов как раз за счёт разных типов одеринга, да и чего угодно с партиал одерингом, называется топологическая сортировка и работает совсем не так как ваши эти квик сорты, которые вообще говоря требуют вик одеринга.

    Зачем на практике нужно различать Вик и Стронг я пока не нашел, а в чем разница спросите вы? Ну при Стронг одеринге и == ГАРАНТИРОВАНО можете использовать хоть правый хоть левый операнд в ЛЮБОЙ функции и получать один и тот же результат. Ну это только если человек который реализовывал спэйсшип возвращающий Стронг ордеринг понимал в чем разница и не допустил ошибок.

    Я хз где это ограничение вообще можно применить, ну вот Инты у них Стронг ордеринг, а у структуры где есть много полей, и сравнение ведётся только по части из них по определению Вик ордеринг, ну и чё?

    Этот момент конечно очень слабо освящён и допустить ошибку очень просто если сами реализуете, а не компилятор дефолт генерит можно по ошибке/не знанию/не пониманию объявить тип возвращаемого значения оператора спэйсшип как Стронг ордеринг, но это почти навярнека ошибка иначе вы бы использовали дефолтную реализацию.


    1. KanuTaH
      12.10.2023 14:52

      Я хз где это ограничение вообще можно применить, ну вот Инты у них Стронг ордеринг, а у структуры где есть много полей, и сравнение ведётся только по части из них по определению Вик ордеринг, ну и чё?

      Ну я могу себе представить, где это можно применить. Например, если есть некий контейнер для хранения объектов, то то, что два каких-то объекта равны в соответствии со strong_ordering гарантирует, что вместо хранения этих двух (или больше) объектов можно хранить только один из них плюс их количество (счетчик дубликатов). А в случае равенства по weak_ordering - нет, нужно хранить все по отдельности.


      1. boldape
        12.10.2023 14:52

        В целом я с вами согласен, но на практике такой тип скорее всего будет и так маленький по размеру, поэтому такая оптимизация будет скорее всего бессмысленной, ибо вам придется хранить такие объекты в куче + виртуальность либо в варианте либо иметь 2 контейнера. В общем на практике найти применение этому сложно.


    1. vadimr
      12.10.2023 14:52

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


      1. boldape
        12.10.2023 14:52

        Вы забыли про инфы и не сигнальные наны. Их как будете сортировать? Они ж как нулы в эскуэле, не сравнимы сами с собой, а наны ещё и ни с чем другим. Вот когда их исключите из массива вот тогда у вас вик ордеринг получится. Хотел написать сначало, что стронг, но кажется два нуля не позволяют это сказать, но я не уверен. Правильная сортировка даблов это сложно.


        1. 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, а с последними невозможны вообще никакие действия.


          1. boldape
            12.10.2023 14:52
            +1

            Я из мира с++, а тут у нас можно почти все что угодно, https://en.cppreference.com/w/cpp/types/numeric_limits/signaling_NaN


            1. vadimr
              12.10.2023 14:52
              +1

              When 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 можно будет поймать программно. И тем более точно определить точку его возникновения, если одновременно обрабатывается длинный конвейер. И тем более вернуться в неё для возобновления вычислений.


              1. boldape
                12.10.2023 14:52

                Ну может и так только код все равно продолжит работать, а не

                а с последними невозможны вообще никакие действия.

                Т.е. действия вполне возможны и результат тоже ясный - нан, но вот флаг эксепции может стоять, а может и нет - ну ладно.


    1. tenzink
      12.10.2023 14:52

      | Есть даже целый ток от Шона (забыл фамилию), который из адоба, который детально объясняет почему так.

      Вероятно речь про Sean Parent, правда не знаю о каком конкретно выступлении идёт речь


      1. boldape
        12.10.2023 14:52
        +1

        Да, спасибо что напомнили его имя, вот тот самый ток https://m.youtube.com/watch?v=2FAi2mNYjFA


  1. Playa
    12.10.2023 14:52
    +2

    В C++20 вместе с этим оператором завезли ещё такой breaking change: при упорядочивании двух std::pair с помощью operator< будет использоваться operator<=> на компонентах этой пары. Это может выстрелить в классах, у которых есть операторы неявного приведения, потому что operator<=> предпочтёт другой operator<=>, даже если в классе уже есть написаный руками operator<, а для сравнения нужно приведение типов.


  1. funny_falcon
    12.10.2023 14:52
    +2

    Про дефолтные ключи - прям смешно. Всем известно, что у gcc дефолт -O0, т.е. вообще без оптимизаций. Использовать можно или если сильно торопитесь бинарь получить, или как последняя надежда в gdb понять, что происходит.

    Укажите хотя бы -Og, тогда и поговорим про ассемблер.


  1. buldo
    12.10.2023 14:52
    +2

    Я думал, что это чисто чтобы нормально сравнивать объекты, а не примитивные типы. Типа для нормальной работы коллекций, сортировки и тп. То есть я его понимаю как аналог интерфейса для сравнения объектов из шарпов


    1. atd
      12.10.2023 14:52

      Да, это и есть прямой аналог IComaparable, только вместо трёх вариантов (<, =, >) он может вернуть ещё что-то. Тут, мне кажется, комитет перестарался впихнуть невпихуемое и вообще нарушил YAGNI, но в плюсах всё так и происходит...