Концепты: упрощаем реализацию классов STD Utility

etc4ir2odcs1wv10o5jcu0yneng.jpeg

Появляющиеся в C++20 концепты — давно и широко обсуждаемая тема. Несмотря на избыток материала, накопившегося за годы (в т.ч. выступления экспертов мирового уровня), среди прикладных программистов (не засыпающих ежедневно в обнимку со стандартом) все еще остается неразбериха, что же такое С++20-концепты и так ли они нам нужны, если есть проверенный годами enable_if. Частично виной тому то, как концепты эволюционировали за ~15 лет (Concepts Full + Concept Map → Concepts Lite), а частично то, что концепты получились непохожими на аналогичные средства в других языках (Java/С# generic bounds, Rust traits, …).

Под катом — видео и расшифровка доклада Андрея Давыдова из команды ReSharper C++ с конференции C++ Russia 2019. Андрей сделал краткий обзор concept-related нововведений C++20, после чего рассмотрел реализацию некоторых классов и функций STL, сравнивая C++17 и С++20-решения. Далее повествование — от его лица.


Поговорим о концептах. Это довольно сложная и обширная тема, так что, готовясь к докладу, я был в некотором затруднении. Я решил обратиться к опыту одного из лучших спикеров C++ комьюнити Андрея Александреску.

В ноябре 2018 года, выступая на открытии Meeting C++, Андрей спросил аудиторию о том, что будет следующей большой фичей C++:


  • концепты,
  • метаклассы,
  • или интроспекция?

Давайте и мы начнём с этого вопроса. Считаете ли вы, что следующей большой фичей в C++ будут концепты?

По мнению Александреску, концепты — это скучно. Вот этой скучной вещью я и предлагаю вам заняться. Тем более, что я всё равно не смогу так же интересно и зажигательно рассказать про метаклассы, как Герб Саттер, или про интроспекцию, как Александреску.

Что мы имеем в виду, когда говорим о концептах в C++20? Эта фича обсуждалась как минимум с 2003 года, и она за это время успела сильно эволюционировать. Давайте разберёмся, какие новые concept related-фичи появились в C++20.

Новая сущность под названием «концепты» определяется ключевым словом concept. Это предикат на шаблонных параметрах. Выглядит это примерно так:

template  concept NoThrowDefaultConstructible = noexept(T{});

template 
concept Assignable = std::is_assignable_v

Я не просто так использовал фразу «на шаблонных параметрах», а не «на типах», потому что концепты можно определять и на нетиповых шаблонных параметрах. Если вам совсем делать нечего, можно определить концепт для числа:

template concept Even = I % 2 == 0;

Но гораздо больше смысла в том чтобы смешивать типовые и нетиповые шаблонные параметры. Назовём тип маленьким, если его size и alignment не превышает заданных ограничений:

template
concept Small = sizeof(T) <= MaxSize && alignof(T) <= MaxAlign;

Наверное, пока неочевидно, почему нам нужно городить в языке новую сущность, и почему концепт — это не просто constexpr bool переменная.

// почему `concept` нельзя определить таким образом?
#define concept constexpr bool

Чтобы разобраться, давайте посмотрим, как используются концепты.

Во-первых, так же, как и constexpr bool переменные, их можно использовать везде, где вам в compile-time нужно булевское выражение. Например, внутри static_assert или внутри noexcept
спецификаций:

// bool expression evaluated in compile-time
static_assert(Assignable);

template
void test() noexcept(NothrowDefaultConstructible) {
  T t;
  ...
}

Во-вторых, концепты можно использовать вместо ключевых слов typename или class при определении шаблонных параметров. Определим простой класс optional, который будет просто хранить пару из булевского флажка initializedи значения. Естественно, такой optional применим только для тривиальных типов. Поэтому мы тут пишем Trivial и при попытке проинстанцировать от чего-то нетривиального, например, от std::string, у нас будет ошибка компиляции:

// вместо type-parameter-key (class, typename)
template
class simple_optional {
  T value;
  bool initialized = false;
  ...
};

Концепты можно применять частично. К примеру, реализуем свой класс any со small buffer оптимизацией. Определим структуру SB (small buffer) с фиксированным Size и Alignment, будем хранить union из SB и указателя в куче. И теперь, если в конструктор приходит маленький тип, то мы можем просто разместить его в SB. Чтобы определить, что тип маленький, мы пишем, что он удовлетворяет концепту Small. Концепт Small принимал 3 шаблонных параметра: два мы определили, и у нас как бы получилась функция от одного шаблонного параметра:

// Частичное применение
class any {
  struct SB {
    static constexpr size_t Size = ...;
    static constexpr size_t Alignment = ...;

    aligned_storage_t storage;
  };
  union {
    SB sb;
    void* handle;
  };
  template T>
  any(T const & t) : sb(...) ...
};

Есть и более краткая запись. Мы пишем имя шаблонного параметра, возможно, с какими-то аргументами, перед auto. Предыдущий пример переписывается таким образом:

// Terse syntax (ограничение на auto)
class any {
  struct SB {
    static constexpr size_t Size = ...;
    static constexpr size_t Alignment = ...;

    aligned_storage_t storage;
  };
  union {
    SB sb;
    void* handle;
  };
  any(Small auto const & t) : sb(...) ...
};

Наверное, в любом месте, где мы пишем auto, теперь можно писать перед ним имя концепта.

Определим функцию get_handle, которая возвращает для объекта некоторый handle.
Будем считать, что маленькие объекты сами для себя являются handle, а для больших — указатель на них является handle. Поскольку у нас две ветки if constexpr обозначают выражения разных типов, то нам удобно не указывать тип этой функции явно, а попросить компилятор его вывести. Но, написав там просто auto, мы потеряем информацию о том, что обозначаемое значение маленькое, оно не превышает указатель:

//Terse syntax (ограничение на auto)
template
concept LEPtr = Small;

template
auto get_handle(T& object) {
  if constexpr (LEPtr)
    return object;
  else
    return &object;
}

В C++20 можно будет перед ним написать, что это не просто auto, это ограниченное auto:

// Terse syntax (ограничение на auto)
template
concept LEPtr = Small;

template
LEPtr auto get_handle(T &object) {
    if constexpr (LEPtr)
        return object;
    else
        return &object;
}

Requires expression — это целое семейство expression’ов, все они имеют тип bool и вычисляются в compile-time. Их используют для проверки утверждений о выражениях и типах. Requires expression очень удобно применять для определения концептов.

Пример с Constructible. Те, кто были на моём предыдущем докладе, уже его видели:

template
concept Constructible = requires(Args... args) { T{args...} };

И пример с Comparable. Скажем, что тип T является Comparable, если два объекта типа T можно сравнить с помощью оператора «меньше» и результат конвертируется в bool. Эта стрелочка и тип после неё означают, что тип expression конвертируется в bool, а не то, что он равен bool:

template
concept Comparable = requires(T const & a, T const & b) {
  {a < b} -> bool;
};

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

У нас есть уже концепт Comparable, давайте определим концепты для итераторов. Скажем, RandomAccessIterator — это BidirectionalIterator и ещё какие-то свойства. Имея это, определим концепт Sortable. Range называется Sortable, если его итератор RandomAccessи его элементы можно сравнивать. И теперь мы можем написать функцию sort, которая принимает не абы что, а Sortable Range:

// concepts, полный пример в С++20

template
concept RandomAccessIterator = BidirectionalIterator && ...;

template
concept Sortable = RandomAccessIterator> && Comparable>;

template
void sort(Range &) {...}

Теперь, если мы попробуем вызвать эту функцию от чего-то, не удовлетворяющего концепту Sortable, мы получим от компилятора хорошую, SFINAE-friendly ошибку с понятным сообщением. Попробуем проинстанцировать std::list'ом или вектором элементов, которые не умеют сравниваться:

//concepts, полный пример в С++20, тесты

struct X {};
void test() {
  vector vi; sort(vi); // OK
  list  li; sort(li); // Fail, list::iterator is not random access
  vector< X > vx; sort(vx); // Fail, X is not Comparable
}

Вы уже видели подобный пример использования концептов или что-то очень похожее? Я такое видел несколько раз. Честно скажу, меня это совсем не убеждало. Нужно ли нам городить в языке столько новых сущностей, если можно получить это в C++17?

//concepts, полный пример в  С++17

#define concept constexpr bool

template
concept Comparable = is_convertible_v<
  decltype(declval() < declval()), bool
  >;

template
concept RandomAccessIterator = BidirectionalIterator && ...;

template
concept Sortable = RandomAccessIterator> && Comparable>;

template>>
void sort(Range &) { ... }

Ключевое слово concept я ввёл макросом, а Comparable переписывается таким образом. Он стал немножко уродливее, и это намекает нам, что requires expression действительно полезная и удобная вещь. Вот мы определили концепт Sortable и с помощью enable_if указали, что функция sort принимает Sortable Range.

Можно подумать, что такой способ сильно проигрывает по сообщениям об ошибках компиляции, но, на самом деле, это вопрос качества реализации компилятора. Скажем, в Clang на эту тему подсуетились и специально захачили, что если при подстановке enable_if у вас первый аргумент
вычисляется false, то они презентуют эту ошибку так, что такой вот requirement не был удовлетворён.

Пример выше как будто бы написан через концепты. У меня есть гипотеза: этот пример неубедительный, потому что он не использует главную фичу концептов — requires clause.

Requires clause — это такая штука, которая вешается на почти любую шаблонную декларацию или на нешаблонную функцию. Синтаксически это выглядит как ключевое слово requires, а дальше некоторое булевское выражение. Это нужно для того, чтобы отфильтровывать template specialization или overloading candidate, то есть работает так же как SFINAE, только сделанный правильно, а не хаками:

// requires-clause

template concept Sortable
  = RandomAccessIterator> && Comparable>;

template
void sort(Range &) { ... }

Где в нашем примере с сортировкой, мы можем использовать requires clause? Вместо краткого синтаксиса применения концептов напишем так:

template concept Sortable
  = RandomAccessIterator> && Comparable>;

template requires Sortable
void sort(Range &) { ... }

Кажется, что код стал только хуже, и его стало больше. Но теперь мы можем избавиться от концепта Sortable. C моей точки зрения, это улучшение, поскольку сам по себе концепт Sortable тавтологичный: мы называем Sortable всё, что можно передать в функцию sort. Физического смысла это не имеет. Перепишем код таким образом:

//template concept Sortable
//    = RandomAccessIterator> && Comparable>;

template requires
  RandomAccessIterator> && Comparable>;
void sort(Range &) { ... }

Список concept-related нововведений в C++20 выглядит так. Пункты в этом списке отсортированы по возрастанию полезности фичи с моей субъективной точки зрения:


  • Новая сущность concept. Без сущности concept, мне кажется, можно было бы обойтись, наделив constexpr bool переменные дополнительной семантикой.
  • Специальный синтаксис для применения концептов. Конечно, он приятен, но это всего лишь синтаксис. Если бы программисты на C++ боялись плохого синтаксиса, они бы давно уже вымерли от страха.
  • Requires expression — это действительно клёвая штука, и она полезна не только для определения концептов.
  • Requires clause — это самая большая ценность концептов, она позволяет забыть о SFINAE и прочих легендарных ужасах шаблонов C++.

Прежде чем мы перейдём к обсуждению requires clause, пара слов о requires expression.

Во-первых, их можно применять не только для определения концептов. С незапамятных времен на майкрософтовском компиляторе есть расширение __if_exists-__if_not_exists. Оно позволяет в compile-time проверять существование имени и в зависимости от этого включать или выключать компиляцию блока кода. А в кодовой базе, с которой я работал несколько лет назад, было примерно такое. Есть функция f(), она принимает точку шаблонного типа и берёт от этой точки высоту. Она может инстанцироваться трёхмерной или двухмерной точкой. Для трёхмерной мы считаем высотой координату z, для двухмерной мы обращаемся к специальному сенсору поверхности. Это выглядит вот таким образом:

struct Point2 { float x, y; };
struct Point3 { float x, y, z; };

template
void f(Point const & p) {
  float h;
  __if_exists(Point::z) {
    h = p.z;
  }
  __if_not_exists(Point::z) {
    h = sensor.get_height(p);
  }
}

В C++20 мы можем это переписать без использования расширений компилятора, стандартным кодом. Как мне кажется, стало не хуже:

struct Point2 { float x, y; };
struct Point3 { float x, y, z; };

template
void f(Point const & p) {
  float h;
  if constexpr(requires { Point::z; })
    h = p.z;
  else
    h = sensor.get_height(p);
}

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

Давайте определим концепт Sizable, который проверяет, что у контейнера есть константный метод size, возвращающий size_t. Мы, естественно, ожидаем, что vector является Sizable, однако этот static_assert заваливается. Понимаете, из-за чего у нас ошибка? Почему этот код не компилируется?

template
concept Sizable = requires(Container const & c) {
  c.size() -> size_t;
};
static_assert(Sizable>); // Fail

Давайте я покажу код, который компилируется. Такой класс X удовлетворяет концепту Sizable. Теперь понимаете, в чём у нас проблема?

struct X {
  struct Inner {
    int size_t;
  };
  Inner* size() const;
};
static_assert(Sizable); // OK

Давайте я исправлю подсветку кода. Слева код раскрашен так, как мне бы хотелось. А на самом деле, он должен быть раскрашен так, как справа:

muhtl2zbr7nbutf7an8or2qjhse.jpeg

Видите, поменялся цвет size_t, стоящего после стрелочки? Я хотел, чтобы это был тип, но это просто поле, к которому мы обращаемся. Всё, что у нас в requires expression — это одно большое выражение, и мы проверяем его корректность. Для типа X — да, это корректное выражение, для vector — нет. Чтобы достичь того, что мы хотели, нужно взять выражение в фигурные скобки:

template
concept Sizable = requires(Container const & c) {
  {c.size()} -> size_t;
};
static_assert(Sizable>); // OK

struct X {
  struct Inner {
    int size_t;
  };
  Inner* size() const;
};
static_assert(Sizable); // Fail

Но это просто забавный пример. В общем-то, просто нужно проявлять аккуратность.


Реализация класса pair

Дальше я буду демонстрировать какие-то фрагменты STL, которые можно реализовать в C++17, но довольно громоздко.
А затем мы посмотрим, как в C++20 мы можем улучшить имплементацию.

Давайте начнём с класса pair.
Это очень старый класс, он есть ещё в C++98.
Он не содержит какой-то сложной логики, так что
хотелось бы, чтобы его определение выглядело примерно таким образом.
Оно, с моей точки зрения, должно примерно на этом и закончиться:

template struct pair {
  F f; S s;
  ...
};

Но, согласно cppreference, у pair одних только конструкторов 8 штук.
А если посмотреть в настоящую реализацию, допустим, в майкрософтовскую STL, то будет целых 15 конструкторов класса pair. Мы не будем смотреть на всю эту мощь и ограничимся конструктором по умолчанию.

Казалось бы, в нём-то что сложного? Для начала поймём, зачем он нужен. Мы хотим, чтобы если один из аргументов класса pair был тривиального типа, допустим, int, то после конструирования класса pair он был инициализирован нулём, а не оставался неинициализированным. Для этого мы хотим написать такой конструктор, который вызовет value-инициализацию для полей f (first) и s (second).

template struct pair {
  F f; S s;

  pair()
    : f()
    , s()
  {}
};

К сожалению, если мы попробуем проинстанцировать pair от чего-то, что не имеет конструктора по умолчанию, допустим, от такого класса А, мы сразу же получим ошибку компиляции. Желаемое поведение — это чтобы при попытке сконструировать pair по умолчанию была бы ошибка компиляции, но если мы явно передаём значения f и s, то всё бы работало:

struct A {
  A(int);
};

pair a2;     // must fail
pair a1; { 1, 2 };    // must be OK

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

template struct pair {
  F f; S s;

  template, // not dependent
      is_default_constructible
    >>>
  pair() : f(), s() {}
};

Это работать не будет, потому что аргументы enable_if_t зависят только от шаблонных параметров класса. То есть после подстановки класса они становятся независимыми, их можно немедленно вычислить. Но если мы получим false, соответственно, мы снова получим hard compiler error.

Чтобы это преодолеть, давайте добавим ещё шаблонных параметров в этот конструктор и сделаем так, чтобы условие enable_if_t было зависимо от этих шаблонных параметров:

template struct pair {
  F f; S s;

  template,
      is_default_constructible
    >>>
  pair() : f(), s() {}
};

Cитуация довольно забавная. Дело в том, что шаблонные параметры T и U не могут быть заданы пользователем явно. В C++ нет синтаксиса для того, чтобы явно задать шаблонные параметры конструктора, они не могут быть выведены компилятором, потому что ему их неоткуда выводить. Они могут прийти только из значения по умолчанию. То есть, эффективно этот код ничем не отличается от кода в предыдущем примере. Однако с точки зрения компилятора, он валидный, а в предыдущем примере — нет.

Мы решили нашу первую проблему, но сталкиваемся со второй, чуть более тонкой. Предположим, у нас есть класс B с explicit конструктором по умолчанию, и мы хотим неявно сконструировать pair:

struct B { explicit B(); };

pair p = {};

У нас это получится, но, по стандарту, не должно получиться. По стандарту, пара должна неявно дефолт конструироваться, только если оба её элемента неявно дефолт конструируются.

Вопрос: нужно ли нам писать конструктор пары explicit или нет? В C++17 у нас есть соломоново решение: давайте напишем и такой, и такой.

template struct pair {
  F f; S s;

  template,
      is_default_constructible,
      is_implicity_default_constructible,
      is_implicity_default_constructible
    >>>
  pair() : f(), s() {}

  template<...> explicit pair() : f(), s() {}
};

Теперь у нас два конструктора по умолчанию:


  • один из них мы отрежем по SFINAE для случая, когда элементы — implicitly default constructible;
  • и второй для противоположного случая.

К слову, для реализации type trait is_implicitly_default_constructible в C++17, я знаю такое решение, но решения без SFINAE я не знаю:

template true_type test(T, int);
template false_type test(int, ...);

template
using is_implicity_default_constructible = decltype(test({}, 0));

Если мы теперь попробуем всё-таки неявно сконструировать pair , то получим ошибку компиляции, как и хотели:

template<..., typename = enable_if_t,
    is_default_constructible,
    is_implicity_default_constructible,
    is_implicity_default_constructible
  >>>
...
pair p = {};
...
candidate template ignored: requirement 'conjunction_v<
  is_default_constructible,
  is_default_constructible,
  is_implicity_default_constructible,
  is_implicity_default_constructible
>' was not satisfied [with T=int, U=B]

В разных компиляторах эта ошибка будет разной степени понятности. К примеру, майкрософтовский компилятор в данном случае говорит: «Не получилось сконструировать пару от пустых фигурных скобок». GCC и Clang к этому ещё добавят: «Мы попробовали такой и такой конструктор, ни один из них не подошёл», — и про каждый скажут причину.

Какие у нас тут есть конструкторы? Есть сгенерированные компилятором copy и move конструкторы, есть написанные нами. С copy и move всё просто: они ожидают один параметр, мы передаём ноль. Для нашего конструктора причина в том, что подстановка зафейлилась.

GCC говорит: «Substitution failed, попытался найти внутри enable_if тип type — не нашёл, извините».

Clang считает эту ситуацию special case. Поэтому он очень здорово показывает эту ошибку. Если у нас при вычислении enable_if первого аргумента получился false, он пишет, что конкретный requirement не удовлетворён.

При этом мы сами себе испортили жизнь тем, что сделали громоздкое условие enable_if. Мы видим, что оно получилось false, но пока не видим, почему.

Это можно преодолеть, если мы разобьём enable_if на четыре таким образом:

template<...,
    typename = enable_if_t::value>>,
    typename = enable_if_t::value>>,
    typename = enable_if_t::value>>,
    typename = enable_if_t::value>>
  >
...

Теперь при попытке неявно сконструировать пару мы получим отличное сообщение, что такой-то кандидат не подходит, потому что type trait is_implicitly_default_constructable не удовлетворён:

pair p = {};

// candidate template ignored: requirement 'is_implicity_default_constructible::value' was not satisfied
with...

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

Чем нам поможет C++20? Сначала избавимся от шаблонов, переписав это с помощью requires clause. То, что мы раньше писали внутри enable_if, теперь пишем внутри аргумента requires clause:

template struct pair {
    F f; S s;

    pair() requires DefaultConstructible
            && DefaultConstructible
            && ImplicitlyDefaultConstructible
            && ImplicitlyDefaultConstructible
        : f(), s() {}

    explicit pair() ...      
};

Концепт ImplicitlyDefaultConstructible можно реализовать с помощью такого симпатичного requires expression, внутри которого используются почти только скобки разной формы:

template concept ImplicitlyDefaultConstructible =
    requires { [] (T) {} ({}); };

Здесь тип T является ImplicitlyDefaultConstructible, если лямбду, ожидающую один параметр типа T можно вызвать от пустых фигурных скобок. В принципе, та же идея, что и в реализации через SFINAE.

Ещё одна фича C++20: появляется условный (conditional) explicit (так же как условный noexcept). Теперь мы можем в explicit писать условия. Поэтому теперь не нужно писать два шаблона и два конструктора, можно ограничиться одним соответствующим explicit.

template struct pair {
  F f; S s;

  explicit(!ImplicityDefaultConstructible ||
           !ImplicityDefaultConstructible)
  pair() requires DefaultConstructible
               && DefaultConstructible
    : f(), s() {}
};

Такое решение мне уже кажется хорошим, оно понятно читается. Конструктор определён тогда и только тогда, когда оба элемента DefaultConstructible, и он explicit, если хотя бы один из них explicit.


Реализация класса Optional в C++17

Теперь мы посмотрим на реализацию класса Optional. Тут мы не ограничимся конструктором по умолчанию, это было бы слишком просто.

Давайте попробуем написать минимальную рабочую реализацию. Как она должна выглядеть? Хотелось бы так, но у нас в C++ нет алгебраических типов данных:

enum Option {
  None,
  Some(t)
}

Тогда так:

class Optional {
  final T value;

  Optional()  {this.value = null; }
  Optional(T value)  {this.value = value; }
}

Это решение тоже не подходит для C++: какие там null, когда у нас value-семантика?

Давайте писать настоящее C++ решение. Возьмём булевский флажок initialized и storage, в котором мы будем хранить объект, если он есть. Мы не можем хранить значение как объект типа T, потому что для пустого optional никакого объекта T существовать не должно, по C++ memory model.

template class optional {
  bool initialized;
  aligned_storage_t storage;

    ...

Давайте сразу напишем геттеры, они нам пригодятся. Ещё конструкторы: один создаёт пустой optional, другой создаёт optional со значением. И ещё нам нужен деструктор:

  ...
  T       & get()       & { return reinterpret_cast(storage); }
  T const & get() const & { return reinterpret_cast(storage); }
  T      && get()      && { return move(get()); }

  optional() noexcept : initialized(false) {}
  optional(T const & value) noexcept(NothrowCopyConstructible)
    : initialized(true) {
    new (&storage) T(value);
  }
  ~optional() : noexcept(NothrowDestructible) { if (initialized) get().~T(); }
};

Таким optional'ом уже можно пользоваться. Можно конструировать пустой optional, можно конструировать optional со значением, он корректно разрушится, но такой optional пока ещё нельзя копировать и перемещать. Если мы явно пишем деструктор, то компилятор у нас уже не генерирует copy и move операции.

Давайте напишем их. Их всего четыре: два конструктора и два assignment оператора. Я ограничусь двумя, поскольку они симметричны. Выберем из каждого класса по представителю. Напишем copy constructor. Он довольно простой:

template class optional {
  bool initialized;
  aligned_storage_t storage;
  ...

  optional(optional const & other) noexcept(NothrowCopyConstructible)
    : initialized(other.initialized) {
    if (initialized) new (&storage) T(other.get());
  }

  optional& operator =(optional && other) noexcept(...) {...}
};

Напишем move assignment. Он чуть более громоздкий, поскольку нужно разбирать случаи:


  • Если оба optional'а пустые, не надо ничего делать.
  • Если они оба содержат значение, мы присваиваем значение.
  • Если один содержит значение, а другой — нет, мы перемещаем значение, разрушаем старое.

Здесь мы использовали для типа T три операции: move constructor, move assignment и деструктор:

optional& operator =(optional && other) noexcept(...) {
  if (initialized) {
    if (other.initialized) {
      get() = move(other.get());
    } else {
      initialized = false; other.initilized = true;
      new(&other.storage) T(move(get()));
      get().~T();
    }
  } else if (other.initialized) {
    initialized = true; other.initialized = false;
    new(&storage) T(move(get()));
    other.get().~T();
  }
  return *this;
}

На эти три операции нам нужно написать спецификацию noexcept:

optional& operator =(optional && other)
    noexcept(NothrowAssignable &&
             NothrowMoveConstructible &&
             NothrowDestructible) {
  if (initialized) {
    if (other.initialized) {
      get() = move(other.get());
    } else {
      initialized = false; other.initialized = true;
      new (&other.storage) T(move(get()));
      get().~T();
    }
  }
  ...
}

Класс optional будет выглядеть примерно таким образом:

template class optional {
  ...
  optional(optional const &)  noexcept(NothrowCopyConstructible);
  optional(optional &&)       noexcept(NothrowMoveConstructible);
  optional& operator =(optional const &)  noexcept(...);
  optional& operator =(optional &&)       noexcept(...);
};

Но тут мы сталкиваемся с той же проблемой, что и с конструктором по умолчанию класса pair:
когда мы пытаемся проинстанцировать этот Optional от чего-то, у чего некоторые специальные операции не существуют (например, deleted), мы получаем compilation error.

template class optional>; // compilation error

Желаемое поведение было бы, чтобы optional от unique_ptr можно было бы инстанцировать,
просто copy constructor и copy assignment были бы deleted. В случае с конструктором по умолчанию пары мы решали это тем, что делали его шаблонным, а потом ограничивали по SFINAE.
Это решение не подходит для copy и move конструкторов и assignment операторов, поскольку у них жёстко определена сигнатура — они не могут быть шаблонными. Можно написать что-то шаблонное, что после подстановки напоминает copy конструктор, но в действительности им не является.

Возможное решение — использовать трюк. В каждой из специальных операций начнём с copy конструктора и определим две вспомогательные структуры: deleted operation и, собственно, operation:


  • deleted_copy_construct объявляет соответствующую операцию delete, а остальные — default;
  • copy_construct дефолтит три операции, а в copy_construct просто вызывает метод базового класса.
template struct deleted_copy_construct : Base {
  deleted_copy_construct(deleted_copy_construct const &)                = delete;
  deleted_copy_construct(deleted_copy_construct &&)                     = default;
  deleted_copy_construct& operator =(deleted_copy_construct const &)    = default;
  deleted_copy_construct& operator =(deleted_copy_construct &&)         = default;
};

template struct copy_construct : Base {
  copy_construct(copy_construct const & other)
      noexcept(noexcept(Base::construct(other))) {
    Base::construct(other);
  }
  copy_construct(copy_construct &&)                     = default;
  copy_construct& operator =(copy_construct const &)    = default;
  copy_construct& operator =(copy_construct &&)         = default;
};

Заведём метафункцию select_copy_construct, которая в зависимости от того, является тип CopyConstrictuble или нет, либо вернёт наш copy_construct, либо deleted_copy_construct:

template
using select_copy_construct = conditional_t
  copy_construct
  deleted_copy_construct
>;

То, что раньше называлась optional, переименуем в optional_base, copy конструктор переименуем в метод construct с соответствующей логикой, а класс optional унаследуем от
select_copy_construct>. Так мы обеспечим правильную семантику для copy конструктора:

template class optional_base {
  ...
  void construct(optional_base const & other) noexcept(NothrowCopyConstructible) {
    if ((initialized = other.initialized)) new (&storage) t(other.get());
  }
};

template class optional : select_copy_construct> {
  ...
};

Аналогично мы поступим с остальными операциями. Единственное, что, если у нас copy_construct к базе делегировал работу, то move_construct у нас будет делегировать работу к copy_construct, copy_assign, соответственно, к move_construct, реализует свою операцию и передаёт по цепочке другой, мол, ты реализуй свою операцию:

template
using select_move_construct = select_copy_construct,
    move_construct
  >
>;

template
using select_copy_assign = select_move_construct && CopyConstructible,
    copy_assign
    delete_copy_assign
  >
>;

Соответственно, move_assign к copy_assign, optional_base выглядит таким образом, вместо конструкторов и assignment операторов методы construct и assign, и optional наследуется от select_move_assign>.

template
using select_move_assign = select_copy_assign;

template class optional_base {
  ...
  void construct(optional_base const&)  noexcept(NothrowCopyConstructible);
  void construct(optional_base &&)      noexcept(NothrowMoveConstructible);
  optional_base& assign(optional_base &&)       noexcept(...);
  optional_base& assign(optional_base const &)  noexcept(...);
};

template class optional : select_move_assign> {
  ...
};

Соответственно, мы получаем такую симпатичную иерархию наследования:
optional наследуется от deleted_copy_construct, тот от
move_construct и так далее. Зато работает!

optional>
  : deleted_copy_construct<...>
    : move_construct<...>
      : deleted_copy_assign<...>
        : move_assign<...>
          : optional_base>

Но по дороге мы потеряли полезные свойства: наш optional даже от TriviallyCopyable типов перестал быть TriviallyCopyable.

Что такое TriviallyCopyable? Грубо говоря, тип T является TriviallyCopyable, если его можно
копировать с помощью memcpy. Это довольно полезное свойство, которое позволяет компилятору во многих случаях генерировать более оптимальный код.

К примеру, именно такие типы можно передавать как аргументы функции через регистры и возвращать, соответственно, через регистры. Если мы делаем resize для vector TriviallyCopyable типов, то нам для перемещения элементов из старого буфера в новый можно выполнить один memcpy, а не в цикле копировать старые элементы, а потом разрушать. В общем, свойство полезное, терять его не хочется.

Для того чтобы тип был TriviallyCopyable, нужно, чтобы выполнялись следующие пять static_assert'ов, его специальные операции copy-move и деструктор должны быть тривиальными:

template
class optional : select_move_assign> {...};

static_assert(TriviallyCopyable>);

static_assert(TriviallyCopyConstructible>);
static_assert(TriviallyMoveConstructible>);
static_assert(TriviallyCopyAssignable   >);
static_assert(TriviallyMoveAssignable   >);
static_assert(TriviallyDestructible     >);

У нас все эти пять static_assert'ов заваливаются. Что обидно, нет фундаментальных причин, почему оно должно себя так вести. Ведь поля optional — это aligned_storage, грубо говоря, массив байт, и булевский флажок, оба TriviallyCopyable.

Вся беда в том, что мы явно написали логику конструктора копирования и так далее. Если бы мы просто ничего не писали, мы бы сохранили свойство TriviallyCopyable.

К счастью, у нас есть способ сделать лучше. Давайте вспомним нашу метафункцию select_copy_construct:

template
using select_copy_construct = conditional_t,
  copy_construct
  deleted_copy_construct
>;

Для CopyContructible типов мы выдавали copy_construct, но можно написать ещё один if в compile-time: если тип CopyContructible и TriviallyCopyContructible, тогда мы просто выдаём Base.

template
using select_copy_construct = conditional_t,
  conditional_t,
    Base,
    copy_construct
  >,
  deleted_copy_construct
>;

Соответственно, у нас нет нашего copy конструктора. Так же мы сделаем для всех остальных операций, плюс ещё добавим select_destruct для деструктора. Поскольку деструктор для int ничего не делает, но из-за того что мы написали какой-то код, он перестал быть тривиальным.

template
using select_destruct = conditional_t,
    Base,
    destruct
  >
>;

Таким образом, наша иерархия увеличивается ещё немножко, мы добавили туда деструктор. Опять же, безобразно, зато работает:

optional>
  : deleted_copy_construct<...>
    : move_construct<...>
      : deleted_copy_assign<...>
        : move_assign<...>
          : destruct>>
            : optional_base>

Таким образом, в C++17 для реализации optional нам потребовалась иерархия наследования глубиной 7; завести вспомогательные классы для каждой операции: по два класса operation, deleted_operation и метафункцию select_operation; вынести реализацию construct и assign во вспомогательные функции. В общем, довольно много рутинной работы.

Все наши проблемы были из-за реализации специальных мемберов. Давайте посмотрим на них немного отвлечённо. Операции в первом приближении делятся на два класса: нормальные и deleted.

Если присмотреться, то из нормальных операций выделяется подкласс noexcept операций.
Если присмотреться, в свою очередь, к этому подклассу, то выделяется подкласс trivial операций, noexcept операции могут быть тривиальными или мы их вручную написали. В некотором смысле, эти классы упорядочены, то есть из trivial следует noexcept, а из noexcept следует, что операция не deleted. Поэтому я позволил себе их разместить тут вдоль некой воображаемой оси. Четыре класса на этой оси образуют четыре промежутка, четыре промежутка разделены тремя перегородками, на слайде они жёлтые.

Для каждой из этих перегородок у нас есть специальный type trait, который говорит, в какую сторону от перегородки попадает наша операция. Тут, например, copy конструктор: он deleted или нормальный, он nothrow или нет, и тривиальный ли он?

С другой стороны, если вы реализуете свой класс и вы хотите обеспечить в нём какой-то special member, то, в зависимости о того, чего вы пытаетесь достичь, у&

© Habrahabr.ru