[Перевод] Новый оператор spaceship (космический корабль) в C++20

C++20 добавляет новый оператор, названный «космическим кораблем»:  <=>. Не так давно Simon Brand опубликовал пост, в котором содержалась подробная концептуальная информация о том, чем является этот оператор и для каких целей используется. Главной задачей этого поста является изучение конкретных применений «странного» нового оператора и его аналога operator==, а также формирование некоторых рекомендаций по его использованию в повседневном кодинге.

nypabpd9lxsd9ifx_f7_zitj7ti.png

Сравнение


Нет ничего необычного в том, чтобы увидеть код, подобный следующему:

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.

© Habrahabr.ru