[Перевод] Новый оператор spaceship (космический корабль) в C++20
C++20 добавляет новый оператор, названный «космическим кораблем»: <=>
. Не так давно Simon Brand опубликовал пост, в котором содержалась подробная концептуальная информация о том, чем является этот оператор и для каких целей используется. Главной задачей этого поста является изучение конкретных применений «странного» нового оператора и его аналога operator==
, а также формирование некоторых рекомендаций по его использованию в повседневном кодинге.
Сравнение
Нет ничего необычного в том, чтобы увидеть код, подобный следующему:
struct IntWrapper {
int value;
constexpr IntWrapper(int value): value{value} { }
bool operator==(const IntWrapper& rhs) const { return value == rhs.value; }
bool operator!=(const IntWrapper& rhs) const { return !(*this == rhs); }
bool operator<(const IntWrapper& rhs) const { return value < rhs.value; }
bool operator<=(const IntWrapper& rhs) const { return !(rhs < *this); }
bool operator>(const IntWrapper& rhs) const { return rhs < *this; }
bool operator>=(const IntWrapper& rhs) const { return !(*this < rhs); }
};
Примечание: внимательные читатели заметят, что это на самом деле даже менее многословно, чем должно быть в коде до версии C++20. Подробнее об этом позже.
Нужно написать много стандартного кода, чтобы убедиться, что наш тип сопоставим с чем-то такого же типа. Хорошо, мы разберемся с этим за какое-то время. Затем приходит кто-то, кто пишет так:
constexpr bool is_lt(const IntWrapper& a, const IntWrapper& b) {
return a < b;
}
int main() {
static_assert(is_lt(0, 1));
}
Первое, что вы заметите, это то, что программа не будет компилироваться.
error C3615: constexpr function 'is_lt' cannot result in a constant expression
Проблема в том, что был забыт constexpr
в функции сравнения. Затем некоторые добавят constexpr
во все операторы сравнения. Несколько дней спустя кто-то добавит помощник is_gt
, но заметит, что все операторы сравнения не имеют спецификации исключений, и придется проходить один и тот же утомительный процесс добавления noexcept
к каждой из 5 перегрузок.
Именно здесь в помощь нам приходит новый оператор C++20 spaceship. Давайте посмотрим, как можно написать исходный IntWrapper
в мире C++20:
#include
struct IntWrapper {
int value;
constexpr IntWrapper(int value): value{value} { }
auto operator<=>(const IntWrapper&) const = default;
};
Первое отличие, которое вы можете заметить — это новое включение
. Заголовок
отвечает за заполнение компилятора всеми типами категорий сравнения, необходимыми для оператора spaceship, чтобы он возвращал тип, подходящий для нашей дефолтной функции. В приведенном выше фрагменте тип возвращаемого значения auto
будет std::strong_ordering
.
Мы не только удалили 5 лишних строк, но нам даже не нужно ничего определять, компилятор сделает это за нас. is_lt
остается неизменным и просто работает, оставаясь при этом constexpr
, хотя мы не указали это явно в нашем дефолтном operator<=>
. Это хорошо, но некоторые люди могут ломать голову над тем, почему is_lt
разрешено компилировать, даже если он вообще не использует оператор spaceship. Давайте найдем ответ на этот вопрос.
Переписывание выражений
В C++20 компилятор вводится в новую концепцию, имеющую отношение к «переписанным» выражениям. Оператор spaceship, наряду с operator==
, является одним из первых двух кандидатов, которые могут быть переписаны. Для более конкретного примера переписывания выражений давайте разберем пример, приведенный в is_lt
.
Во время разрешения перегрузки компилятор будет выбирать из набора наиболее подходящих кандидатов, каждый из которых соответствует оператору, который нам нужен. Процесс отбора кандидатов изменяется очень незначительно для случая операций сравнения и операций эквивалентности, когда компилятор также должен собирать специальных переписанных и синтезированных кандидатов ([over.match.oper]/3.4).
Для нашего выражения a < b
стандарт утверждает, что мы можем искать тип a
для operator<=>
или функции operator<=>
, которые принимают этот тип. Так делает компилятор и обнаруживает, что на самом деле тип a
содержит IntWrapper::operator<=>
. Затем компилятору разрешается использовать этот оператор и переписать выражение a < b
как (a <=> b) < 0
. Это переписанное выражение затем используется в качестве кандидата для нормального разрешения перегрузки.
Вы можете спросить, почему это переписанное выражение является корректным. Правильность выражения фактически вытекает из семантики, которую обеспечивает оператор spaceship. <=>
— это трехстороннее сравнение, которое подразумевает, что вы получаете не просто бинарный результат, но и порядок (в большинстве случаев). Если у вас есть порядок, вы можете выразить этот порядок в терминах любых операций сравнения. Быстрый пример, выражение 4 <=> 5 в C++20 вернет вам результат std::strong_ordering::less
. Результат std::strong_ordering::less
подразумевает, что 4
не только отличается от 5
, но и строго меньше этого значения, что делает применение операции (4 <=> 5) < 0
правильным и точным для описания нашего результата.
Используя приведенную выше информацию, компилятор может взять любой обобщенный оператор сравнения (т.е. <
, >
, и т.д.) и переписать его в терминах оператора spaceship. В стандарте переписанное выражение часто упоминается как (a <=> b) @ 0
где @
представляет любую операцию сравнения.
Синтезирующие выражения
Читатели, возможно, заметили тонкое упоминание «синтезированных» выражений выше, и они также играют роль в этом процессе переписывания операторов. Рассмотрим следующую функцию:
constexpr bool is_gt_42(const IntWrapper& a) {
return 42 < a;
}
Если мы используем наше первоначальное определение для IntWrapper
, этот код не будет компилироваться.
error C2677: binary '<': no global operator found which takes type 'const IntWrapper' (or there is no acceptable conversion)
Это имеет смысл до версии C++20, и способ решения этой проблемы заключается в добавлении некоторых дополнительных функций friend
в IntWrapper
, которые занимают левую сторону от int
. Если вы попробуете построить этот пример с помощью компилятора и определения IntWrapper
C++20, вы можете заметить, что он, опять же, просто работает. Давайте рассмотрим, почему приведенный выше код все еще компилируется в C++20.
Во время разрешения перегрузки компилятор также будет собирать то, что стандарт называет «синтезированными» кандидатами, или переписанным выражением с обратным порядком параметров. В приведенном выше примере компилятор попытается использовать переписанное выражение (42 <=> a) < 0
, но обнаружит, что нет преобразования из IntWrapper
в int
, чтобы удовлетворить левую часть, так что переписанное выражение отбрасывается. Компилятор также вызывает «синтезированное» выражение 0 < (a <=> 42)
и обнаруживает, что происходит преобразование из int
в IntWrapper
через его конструктор преобразования, поэтому этот кандидат используется.
Цель синтезированных выражений состоит в том, чтобы избежать путаницы в необходимости написания шаблонов функций friend
, чтобы заполнить пробелы, в которых ваш объект может быть преобразован из других типов. Синтезированные выражения обобщаются до 0 @ (b <=> a)
.
Более сложные типы
Сгенерированный компилятором оператор spaceship не останавливается на отдельных членах классов, он генерирует правильный набор сравнений для всех подобъектов в ваших типах:
struct Basics {
int i;
char c;
float f;
double d;
auto operator<=>(const Basics&) const = default;
};
struct Arrays {
int ai[1];
char ac[2];
float af[3];
double ad[2][2];
auto operator<=>(const Arrays&) const = default;
};
struct Bases : Basics, Arrays {
auto operator<=>(const Bases&) const = default;
};
int main() {
constexpr Bases a = { { 0, 'c', 1.f, 1. },
{ { 1 }, { 'a', 'b' }, { 1.f, 2.f, 3.f }, { { 1., 2. }, { 3., 4. } } } };
constexpr Bases b = { { 0, 'c', 1.f, 1. },
{ { 1 }, { 'a', 'b' }, { 1.f, 2.f, 3.f }, { { 1., 2. }, { 3., 4. } } } };
static_assert(a == b);
static_assert(!(a != b));
static_assert(!(a < b));
static_assert(a <= b);
static_assert(!(a > b));
static_assert(a >= b);
}
Компилятор знает, как развернуть члены классов, которые являются массивами, в их списки подобъектов и сравнить их рекурсивно. Конечно, если вы хотите написать тела этих функций самостоятельно, вы все равно получите пользу от переписывания выражений компилятором.
Выглядит как утка, плавает как утка, и крякает как operator==
Некоторые очень умные люди в комитете по стандартизации заметили, что оператор spaceship всегда будет выполнять лексикографическое сравнение элементов, несмотря ни на что. Безусловное выполнение лексикографических сравнений может привести к неэффективному коду, в частности, с оператором равенства.
Канонический пример со сравнением двух строк. Если у вас есть строка "foobar"
и вы сравниваете ее со строкой "foo"
, используя ==, можно ожидать, что эта операция будет почти постоянной. Эффективный алгоритм сравнения строк следующий:
- Сначала сравните размер двух строк. Если размеры отличаются, то верните
false
- В противном случае пошагово просматривайте каждый элемент двух строк и сравнивайте их до тех пор, пока не найдется отличие или не закончатся все элементы. Верните результат.
В соответствии с правилами оператора spaceship мы должны начать с сравнения каждого элемента, пока не найдем тот, который отличается. В нашем примере "foobar"
и "foo"
только при сравнении 'b'
и '\0'
вы наконец возвращаете false
.
Для борьбы с этим была статья P1185R2, в которой подробно описывается, как компилятор переписывает и генерирует operator==
независимо от оператора spaceship. Наш IntWrapper
может быть написан следующим образом:
#include
struct IntWrapper {
int value;
constexpr IntWrapper(int value): value{value} { }
auto operator<=>(const IntWrapper&) const = default;
bool operator==(const IntWrapper&) const = default;
};
Еще один шаг… однако, есть хорошие новости; вам на самом деле не нужно писать код выше, потому что простого написания auto operator<=>(const IntWrapper&) const = default
достаточно, чтобы компилятор неявно сгенерировал отдельный и более эффективный operator==
для вас!
Компилятор применяет слегка измененное правило «перезаписи», специфичное для ==
и !=
, где в этих операторах они переписываются в терминах operator==
, а не operator<=>
. Это означает, что !=
также выигрывает от оптимизации.
Старый код не сломается
В этот момент вы можете подумать: хорошо, если компилятору разрешено выполнять эту операцию переписывания операторов, что произойдет, если я попытаюсь перехитрить компилятор:
struct IntWrapper {
int value;
constexpr IntWrapper(int value): value{value} { }
auto operator<=>(const IntWrapper&) const = default;
bool operator<(const IntWrapper& rhs) const { return value < rhs.value; }
};
constexpr bool is_lt(const IntWrapper& a, const IntWrapper& b) {
return a < b;
}
Ответ — вы не сможете. Модель разрешения перегрузки в C++ имеет арену, в которой сражаются все кандидаты. В этом конкретном сражении у нас есть 3 кандидата:
IntWrapper::operator<(const IntWrapper& a, const IntWrapper& b)
IntWrapper::operator<=>(const IntWrapper& a, const IntWrapper& b)
(переписанный)
IntWrapper::operator<=>(const IntWrapper& b, const IntWrapper& a)
(синтезированный)
Если бы мы приняли правила разрешения перегрузки в C++17, результат этого вызова был бы неоднозначным, но правила разрешения перегрузки C++20 были изменены, чтобы компилятор мог разрешить эту ситуацию до наиболее логичной перегрузки.
Существует фаза разрешения перегрузки, когда компилятор должен выполнить серию тай-брейков. В C ++20 появился новый механизм тай-брейкинга, в рамках которого предпочтение отдается перегрузкам, которые не переписываются и не синтезируются, что делает нашу перегрузку IntWrapper::operator<
лучшим кандидатом и разрешает неоднозначность. Этот же механизм предотвращает полное замещение регулярных переписанных выражений синтезированными кандидатами.
Заключительные мысли
Оператор spaceship является желанным дополнением к C++, поскольку сможет помочь упростить ваш код и писать его меньше, а иногда меньше — лучше. Так что пристегивайтесь и управляйте космическим кораблем C++20!
Мы призываем вас выйти и опробовать оператор spaceship, он доступен прямо сейчас в Visual Studio 2019 под /std:c++latest
! Как примечание, изменения, внесенные в P1185R2, будут доступны в Visual Studio 2019 версии 16.2. Пожалуйста, имейте в виду, что оператор spaceship является частью C++20 и подвержен некоторым изменениям вплоть до того момента, когда C++20 будет финализирован.
Как всегда, мы ждем ваших отзывов. Не стесняйтесь присылать любые комментарии по электронной почте по адресу visualcpp@microsoft.com, через Twitter @visualc, или Facebook Microsoft Visual Cpp.
Если вы столкнулись с другими проблемами с MSVC в VS 2019, сообщите нам об этом через опцию «Сообщить о проблеме», либо из установщика, либо из самой Visual Studio IDE. Для предложений или сообщией об ошибках, пишите нам через DevComm.