Стандарт C++20: обзор новых возможностей C++. Часть 2 «Операция ''Космический Корабль''»

bsuxzhf28lzu5gp_ju_iyetuin8.png

25 февраля автор курса «Разработчик C++» в Яндекс.Практикуме Георгий Осипов рассказал о новом этапе языка C++ — Стандарте C++20. В лекции сделан обзор всех основных нововведений Стандарта, рассказывается, как их применять уже сейчас и чем они могут быть полезны.

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

  1. Модули и краткая история C++.
  2. Операция «космический корабль».
  3. Концепты.
  4. Ranges.
  5. Корутины.
  6. Другие фичи ядра и стандартной библиотеки. Заключение.

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

Это вторая часть, рассказывающая об операции «космический корабль» в современном C++.

Операция «космический корабль»


В C++ теперь свой космос!

exu7svbyg0udnj8cvye1p-8tazg.png

Мотивация


В C++ шесть операций сравнения:
  1. меньше,
  2. больше,
  3. меньше или равно,
  4. больше или равно,
  5. равно,
  6. не равно.

Они все выражаются через одно любое неравенство. Но написать эти операции всё равно придётся. И это проблема, которую решает «космический корабль».

Предположим, вы определили структуру, содержащую одно число:

struct X {
    int a;
};

Мы хотим сделать так, чтобы значения этой структуры можно было сравнивать друг с другом. Для этого придётся написать шесть операций:
bool operator== (X l, X r) { return l.a == r.a; }
bool operator!= (X l, X r) { return l.a != r.a; }
bool operator>= (X l, X r) { return l.a >= r.a; }
bool operator<= (X l, X r) { return l.a <= r.a; }
bool operator< (X l, X r) { return l.a < r.a; }
bool operator> (X l, X r) { return l.a > r.a; }

А теперь представьте, что мы хотим сравнивать элементы этой структуры не только между собой, но также с числами int. Количество операций возрастает с шести до 18:
…

bool operator== (X l, int r) { return l.a == r; }
bool operator!= (X l, int r) { return l.a != r; }
bool operator>= (X l, int r) { return l.a >= r; }
bool operator<= (X l, int r) { return l.a <= r; }
bool operator< (X l, int r) { return l.a < r; }
bool operator> (X l, int r) { return l.a > r; }

bool operator== (int l, X r) { return l == r.a; }
bool operator!= (int l, X r) { return l != r.a; }
bool operator>= (int l, X r) { return l >= r.a; }
bool operator<= (int l, X r) { return l <= r.a; }
bool operator< (int l, X r) { return l < r.a; }
bool operator> (int l, X r) { return l > r.a; }

Что делать? Можно позвать штурмовиков. Их много, и они быстро напишут 18 операций.

ug2eh4xjkthpyuuoxr1wpcxgc-a.jpeg

Или воспользоваться «космическим кораблём». Эту новую операцию в C++ называют «космический корабль», потому что она на него похожа: <=>. Более формальное название «трёхстороннее сравнение» фигурирует в документах Стандарта.

Пример


В структуру X я добавил всего одну строчку, определяющую операцию <=>. Заметьте, что я даже не написал, что именно она делает:
#include 

struct X {
    auto operator<=>(const X&) const = default; // <-- !
    int a;
};

И C++ всё сделал за меня. Это сработает и в более сложных случаях, например, когда у X несколько полей и базовых классов. При этом всё, что есть в X, должно поддерживать сравнение. После того, как я написал эту магическую строчку, я могу сравнивать объекты X любым способом:
int main() {
    X x1{1}, x42{42};

    std::cout << (x1 < x42 ? "x1 < x42" : "not x1 < x42") << std::endl;
    std::cout << (x1 > x42 ? "x1 > x42" : "not x1 > x42") << std::endl;
    std::cout << (x1 <= x42 ? "x1 <= x42" : "not x1 <= x42") << std::endl;
    std::cout << (x1 >= x42 ? "x1 >= x42" : "not x1 >= x42") << std::endl;
    std::cout << (x1 == x42 ? "x1 == x42" : "not x1 == x42") << std::endl;
    std::cout << (x1 != x42 ? "x1 != x42" : "not x1 != x42") << std::endl;
}

Получилась корректная программа. Её можно собрать и запустить. Текстовый вывод выглядит так:
x1 < x42
not x1 > x42
x1 <= x42
not x1 >= x42
not x1 == x42
x1 != x42

Операция космического корабля сработает и для сравнения элемента структуры X с числом. Но придётся написать реализацию. На этот раз C++ не сможет придумать её за вас. В реализации воспользуемся встроенной операцией <=> для чисел:
#include 

struct X {
    auto operator<=>(const X&) const = default; 
    auto operator<=>(int r) const {   // <-- !
        return this->a <=> r;
    }
    int a;
};

Правда, возникает проблема. C++ создаст не все операции. Если вы определили эту операцию не через default, а написали сами, проверка на равенство и неравенство не будет добавлена. Кто знает причины — пишите в комменты.
int main() {
    X x1{1}, x42{42};

    std::cout << (x1 < 42 ? "x1 < 42" : "not x1 < 42") << std::endl;
    std::cout << (x1 > 42 ? "x1 > 42" : "not x1 > 42") << std::endl;
    std::cout << (x1 <= 42 ? "x1 <= 42" : "not x1 <= 42") << std::endl;
    std::cout << (x1 >= 42 ? "x1 >= 42" : "not x1 >= 42") << std::endl;

    std::cout << (x1 == 42 ? "x1 == 42" : "not x1 == 42") << std::endl; // <--- ошибка
    std::cout << (x1 != 42 ? "x1 != 42" : "not x1 != 42") << std::endl; // <--- ошибка
}

Впрочем, никто не запрещает определить эту операцию самостоятельно. Ещё одно нововведение C++20: можно добавить проверку только на равенство, а неравенство добавится автоматически:
#include 

struct X {
    auto operator<=>(const X&) const = default;
    bool operator==(const X&) const = default;
    auto operator<=>(int r) const {
        return this->a <=> r;
    }
    bool operator==(int r) const { // <-- !
        return operator<=>(r) == 0;
    }
    int a;
};

int main() {
    X x1{1}, x42{42};

    std::cout << (x1 < 42 ? "x1 < 42" : "not x1 < 42") << std::endl;
    std::cout << (x1 > 42 ? "x1 > 42" : "not x1 > 42") << std::endl;
    std::cout << (x1 <= 42 ? "x1 <= 42" : "not x1 <= 42") << std::endl;
    std::cout << (x1 >= 42 ? "x1 >= 42" : "not x1 >= 42") << std::endl;
    std::cout << (x1 == 42 ? "x1 == 42" : "not x1 == 42") << std::endl;
    std::cout << (x1 != 42 ? "x1 != 42" : "not x1 != 42") << std::endl;
}

Хоть 2 операции и пришлось определить, но это гораздо лучше, чем 18.

Мы добавили код для тех ситуаций, когда левый операнд — это X, а правый — int. Оказывается, сравнение в другую сторону писать не нужно, оно добавится автоматически:

#include 

struct X {
    auto operator<=>(const X&) const = default;
    bool operator==(const X&) const = default;
    auto operator<=>(int r) const {
        return this->a <=> r;
    }
    bool operator==(int r) const { // <-- !
        return operator<=>(r) == 0;
    }
    int a;
};

int main() {
    X x1{1}, x42{42};

    std::cout << (1 < x42 ? "1 < x42" : "not 1 < x42") << std::endl;
    std::cout << (1 > x42 ? "1 > x42" : "not 1 > x42") << std::endl;
    std::cout << (1 <= x42 ? "1 <= x42" : "not 1 <= x42") << std::endl;
    std::cout << (1 >= x42 ? "1 >= x42" : "not 1 >= x42") << std::endl;
    std::cout << (1 == x42 ? "1 == x42" : "not 1 == x42") << std::endl;
    std::cout << (1 != x42 ? "1 != x42" : "not 1 != x42") << std::endl;
}

Теория


На этом рассказ про космический корабль можно было бы завершить, но, оказывается, не всё так просто. Есть ещё несколько нюансов.

Первый: всё, что я сказал — это неправда. Никаких операций сравнения на самом деле не добавилось. Если вы попробуете явно вызвать операцию «меньше», компилятор скажет: «Ошибка. Такой операции нет». Несмотря на то, что сравнение работает, получить адрес операции «меньше» не получится:

#include 

struct X {
    auto operator<=>(const X&) const = default; 
    int a;
};

int main() {
    X x1{1}, x42{42};
    std::cout << (x1.operator<(x42) ? "<" : "!<")    // <--- ошибка
              << std::endl; 
}

Удивительно, как же компилятор выполняет операцию, которой нет. Всё благодаря тому, что поменялись правила поведения компилятора при вычислении операций сравнения. Когда вы пишете x1 < x2, компилятор, как и раньше, проверяет наличие операции <. Но теперь, если он её не нашёл, то обязательно посмотрит операцию «космического корабля». В примере она находится, поэтому он её использует. При этом, если типы операндов разные, компилятор посмотрит сравнение в обе стороны: сначала в одну, потом в другую. Поэтому нет необходимости определять третий «космический корабль» для сравнения int и типа X — достаточно определить только вариант, где X слева.

Если вам по какой-то причине вместо x < y нравится писать x.operator<(y), то определите операцию < явно. У меня для вас хорошие новости: реализацию можно не писать. default будет работать для обычных операций сравнения так же, как и для <=>. Напишите его, и C++ определит его за вас. Вообще, C++20 многое делает за вас.

#include 

struct X {
    auto operator<=>(const X&) const = default; 
    bool operator<(const X&) const = default; // <-- !

    int a;
};

int main() {
    X x1{1}, x42{42};
    std::cout << (x1.operator<(x42) ? "<" : "!<")
              << std::endl;
}

Заметьте, что у operator< потребовалось указать явный тип возврата — bool. А в <=> эту работу предоставляли компилятору, указывая auto. Оно означает, что тип я писать не хочу: компилятор умный, он поймёт сам, что нужно поставить вместо auto. Но какой-то тип там есть — функция же должна что-то возвращать.

Оказывается, тут не всё так просто. Это не bool, как для простых операций сравнения. Здесь сразу три варианта. Эти варианты — разные виды упорядочивания:

  • std::strong_ordering. Линейный порядок, равные элементы которого неразличимы. Примеры: int, char, string.
  • std::weak_ordering. Линейный порядок, равные могут быть различимы. Примеры: string, сравниваемый без учёта регистра; порядок на точках плоскости, определяемый удалённостью от центра.
  • std::partial_ordering. Частичный порядок. Примеры: float, double, порядок по включению на объектах set.

Математики хорошо знакомы с этими понятиями, тут даже нет никакого программирования. Для остальных расскажем. Линейный порядок — такой, при котором любые два элемента можно сравнить между собой. Пример линейного порядка — целые числа: какие бы два числа мы ни взяли, их можно сравнить между собой.

При частичном порядке элементы могут быть несравнимы. Числа с плавающей запятой float и double подпадают под понятие частичного порядка, потому что у них есть специальное значение NaN, не сравнимое ни с каким другим числом.

Дальнейшие рассуждения об упорядочивании выходят за рамки вебинара. Я лишь хочу сказать, что не всё так тривиально, как кажется. Рекомендую поэкспериментировать с частичным упорядочиванием в разных алгоритмах и контейнерах типа set.

Статус


rphc3p1vgza55r3slyyir092bca.png

«Космический корабль» уже есть везде, и им можно пользоваться:

  • GCC. Хорошо поддерживается с версии 10, хотя и не до конца. Полную поддержку обещают только в GCC 11.
  • Clang. Полная поддержка в версии 10.
  • Visual Studio. Полная поддержка в VS 2019.

Заключение


Во время трансляции мы опросили аудиторию, нравится ли ей эта функция. Результаты опроса:
  • Суперфича — 47 (87.04%)
  • Так себе фича — 2 (3.70%)
  • Пока неясно — 5 (9.26%)

Мы довольно подробно разобрали операцию «космический корабль», однако всё равно часть её функций осталась непокрытой. Например, интересный вопрос: как компилятор обработает ситуацию, в которой определено несколько операций сравнения с разными типами.

Читателям Хабра, как и слушателям вебинара, дадим возможность оценить нововведения.

© Habrahabr.ru