Хабрахабр

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

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

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

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

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

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

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

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

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

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

template <typename T> concept NoThrowDefaultConstructible = noexept(T); template <typename From, typename To>
concept Assignable = std::is_assignable_v<From, To>

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

template<int I> concept Even = I % 2 == 0;

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

template<typename T, size_t MaxSize, size_t MaxAlign>
concept Small = sizeof(T) <= MaxSize && alignof(T) <= MaxAlign;

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

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

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

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

// bool expression evaluated in compile-time
static_assert(Assignable<float, int>); template<typename T>
void test() noexcept(NothrowDefaultConstructible<T>) { T t; ...
}

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

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

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

// Частичное применение
class any { struct SB { static constexpr size_t Size = ...; static constexpr size_t Alignment = ...; aligned_storage_t<Size, Alignment> storage; }; union { SB sb; void* handle; }; template<Small<SB::Size, SB::Alignment> 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<Size, Alignment> storage; }; union { SB sb; void* handle; }; any(Small<SB::Size, SB::Alignment> auto const & t) : sb(...) ...
};

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

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

//Terse syntax (ограничение на auto)
template<typename T>
concept LEPtr = Small<T, sizeof(void *), alignof(void *)>; template<typename T>
auto get_handle(T& object) { if constexpr (LEPtr<T>) return object; else return &object;
}

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

// Terse syntax (ограничение на auto)
template<typename T>
concept LEPtr = Small<T, sizeof(void *), alignof(void *)>; template<typename T>
LEPtr auto get_handle(T &object) { if constexpr (LEPtr<T>) return object; else return &object;
}

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

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

template<typename T, typename... Args>
concept Constructible = requires(Args... args) { T{args...} };

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

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

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

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

// concepts, полный пример в С++20 template<typename Iterator>
concept RandomAccessIterator = BidirectionalIterator<Iterator> && ...; template<typename R>
concept Sortable = RandomAccessIterator<Iterator<R>> && Comparable<ValueType<R>>; template<Sortable Range>
void sort(Range &) {...}

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

//concepts, полный пример в С++20, тесты struct X {};
void test() { vector<int> vi; sort(vi); // OK list <int> li; sort(li); // Fail, list<int>::iterator is not random access vector< X > vx; sort(vx); // Fail, X is not Comparable
}

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

//concepts, полный пример в С++17 #define concept constexpr bool template<typename T>
concept Comparable = is_convertible_v< decltype(declval<T const &>() < declval<T const &>()), bool >; template<typename Iterator>
concept RandomAccessIterator = BidirectionalIterator<Iterator> && ...; template<typename R>
concept Sortable = RandomAccessIterator<Iterator<R>> && Comparable<ValueType<R>>; template<typename Range, typename = enable_if_t<Sortable<Range>>>
void sort(Range &) { ... }

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

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

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

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

// requires-clause template<typename R> concept Sortable = RandomAccessIterator<Iterator<R>> && Comparable<ValueType<R>>; template<Sortable Range>
void sort(Range &) { ... }

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

template<typename R> concept Sortable = RandomAccessIterator<Iterator<R>> && Comparable<ValueType<R>>; template<typename Range> requires Sortable<Range>
void sort(Range &) { ... }

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

//template<typename R> concept Sortable
// = RandomAccessIterator<Iterator<R>> && Comparable<ValueType<R>>; template<typename Range> requires RandomAccessIterator<Iterator<Range>> && Comparable<ValueType<Range>>;
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<typename Point>
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<typename Point>
void f(Point const & p) { float h; if constexpr(requires { Point::z; }) h = p.z; else h = sensor.get_height(p);
}

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

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

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

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

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

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

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

template<typename Container>
concept Sizable = requires(Container const & c) { {c.size()} -> size_t;
};
static_assert(Sizable<vector<int>>); // OK struct X { struct Inner { int size_t; }; Inner* size() const;
};
static_assert(Sizable<X>); // Fail

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

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

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

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

template<typename F, typename S> struct pair { F f; S s; ...
};

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

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

template<typename F, typename S> struct pair { F f; S s; pair() : f() , s() {}
};

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

struct A { A(int);
}; pair<int, A> a2; // must fail
pair<int, A> a1; { 1, 2 }; // must be OK

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

template<typename F, typename S> struct pair { F f; S s; template<typename = enable_if_t<conjunction_v< is_default_constructible<F>, // not dependent is_default_constructible<S> >>> pair() : f(), s() {}
};

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

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

template<typename F, typename S> struct pair { F f; S s; template<typename T = F, typename U = S, typename = enable_if_t<conjunction_v< is_default_constructible<T>, is_default_constructible<U> >>> pair() : f(), s() {}
};

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

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

struct B { explicit B(); }; pair<int, B> p = {};

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

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

template<typename F, typename S> struct pair { F f; S s; template<typename T = F, typename U = S, typename = enable_if_t<conjunction_v< is_default_constructible<T>, is_default_constructible<U>, is_implicity_default_constructible<T>, is_implicity_default_constructible<U> >>> pair() : f(), s() {} template<...> explicit pair() : f(), s() {}
};

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

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

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

template<typrname T> true_type test(T, int);
template<typrname T> false_type test(int, ...); template<typrname T>
using is_implicity_default_constructible = decltype(test<T>({}, 0));

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

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

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

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

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

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

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

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

template<..., typename = enable_if_t<is_default_constructible<T>::value>>, typename = enable_if_t<is_default_constructible<U>::value>>, typename = enable_if_t<is_implicity_default_constructible<T>::value>>, typename = enable_if_t<is_implicity_default_constructible<U>::value>> >
...

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

pair<int, B> p = {}; // candidate template ignored: requirement 'is_implicity_default_constructible<B>::value' was not satisfied
with...

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

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

template<typename F, typename S> struct pair { F f; S s; pair() requires DefaultConstructible<F> && DefaultConstructible<S> && ImplicitlyDefaultConstructible<F> && ImplicitlyDefaultConstructible<S> : f(), s() {} explicit pair() ... };

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

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

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

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

template<typename F, typename S> struct pair { F f; S s; explicit(!ImplicityDefaultConstructible<F> || !ImplicityDefaultConstructible<S>) pair() requires DefaultConstructible<F> && DefaultConstructible<S> : f(), s() {}
};

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

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

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

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

enum Option<T> { None, Some(t)
}

Тогда так:

class Optional<T> { final T value; Optional() {this.value = null; } Optional(T value) {this.value = value; }
}

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

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

template<typename T> class optional { bool initialized; aligned_storage_t<sizeof(T), alignof(T)> storage; ...

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

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

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

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

template<typename T> class optional { bool initialized; aligned_storage_t<sizeof(T), alignof(T)> storage; ... optional(optional const & other) noexcept(NothrowCopyConstructible<T>) : 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<T> && NothrowMoveConstructible<T> && NothrowDestructible<T>) { 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<typename T> class optional { ... optional(optional const &) noexcept(NothrowCopyConstructible<T>); optional(optional &&) noexcept(NothrowMoveConstructible<T>); optional& operator =(optional const &) noexcept(...); optional& operator =(optional &&) noexcept(...);
};

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

template class optional<unique_ptr<int>>; // compilation error

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

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

  • deleted_copy_construct объявляет соответствующую операцию delete, а остальные — default;
  • copy_construct дефолтит три операции, а в copy_construct просто вызывает метод базового класса.

template<class Base> 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<class Base> 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<typename T, class Base>
using select_copy_construct = conditional_t<CopyConstructible<T> copy_construct<Base> deleted_copy_construct<Base>
>;

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

template<typename T> class optional_base { ... void construct(optional_base const & other) noexcept(NothrowCopyConstructible<T>) { if ((initialized = other.initialized)) new (&storage) t(other.get()); }
}; template<typename T> class optional : select_copy_construct<T, optional_base<T>> { ...
};

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

template<typename T, class Base>
using select_move_construct = select_copy_construct<T, conditional_t<MoveConstructible<T>, move_construct<Base> >
>; template<typename T, class Base>
using select_copy_assign = select_move_construct<T, conditional_t<CopyAssignable<T> && CopyConstructible<T>, copy_assign<Base> delete_copy_assign<Base> >
>;

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

template<typename T, class Base>
using select_move_assign = select_copy_assign<T, ...>; template<typename T> class optional_base { ... void construct(optional_base const&) noexcept(NothrowCopyConstructible<T>); void construct(optional_base &&) noexcept(NothrowMoveConstructible<T>); optional_base& assign(optional_base &&) noexcept(...); optional_base& assign(optional_base const &) noexcept(...);
}; template<typename T> class optional : select_move_assign<T, optional_base<T>> { ...
};

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

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

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

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

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

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

template<typename T>
class optional : select_move_assign<T, optional_base<T>> {...}; static_assert(TriviallyCopyable<optional<int>>); static_assert(TriviallyCopyConstructible<optional<int>>);
static_assert(TriviallyMoveConstructible<optional<int>>);
static_assert(TriviallyCopyAssignable <optional<int>>);
static_assert(TriviallyMoveAssignable <optional<int>>);
static_assert(TriviallyDestructible <optional<int>>);

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

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

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

template<typename T, class Base>
using select_copy_construct = conditional_t<CopyConstructible<T>, copy_construct<Base> deleted_copy_construct<Base>
>;

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

template<typename T, class Base>
using select_copy_construct = conditional_t<CopyConstructible<T>, conditional_t<TriviallyCopyConstructible<T>, Base, copy_construct<Base> >, deleted_copy_construct<Base>
>;

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

template<typename T, class Base>
using select_destruct = conditional_t<TriviallyDenstructible<T>, Base, destruct<Base> >
>;

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

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

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

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

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

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

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

  • если вы хотите, чтобы он стал deleted, вы пишете = delete или наследуетесь от нашего класса deleted_copy_construct;
  • если он попадает в промежуточные классы, то вы там пишете copy_construct, реализуете его c соответствующей noexcept спецификацией;
  • наконец, если он тривиальный, вы просто не пишите его, либо дефолтите.

Давайте просто возьмём эту схему и перенесём один в один на код с концептами.

Реализация класса optional в C++20

Как в C++20 должен выглядеть optional и его copy конструктор?
Есть три реализации:

  • если тип T не CopyConstructible, мы объявляем его как deleted;
  • если он TriviallyCopyConstructible, мы его дефолтим;
  • иначе мы пишем его реализацию с соответствующей noexcept спецификацией.

template<typename T> class optional { ... optional(optional const &) requires(!CopyConstructible<T>) = delete; // #1 optional(optional const &) requires(TriviallyCopyConstructible<T>) = default; // #2 optional(optional const &) noexcept(NothrowCopyConstructible<T>) {...} // #3 ... ~optional() requires(TriviallyDestructible<T>) = default; ~optional() noexcept(NothroeDestructible<T>) {...}
};

Во-первых, первые две перегрузки являются взаимоисключающими, то есть после подстановки типа T аргумент requires clause одной из них вычислится в false. Давайте поймём, как это работает. У нас может возникнуть ситуация, что одна из этих перегрузок вычислилась в requires(true), то есть она не отброшена и при этом у нас есть третья перегрузка.
В этом случае будет предпочтена первая или вторая перегрузка, поскольку она более ограничена. Мы получим там requires(false), и это значит, что данная перегрузка исключается из overload resolution.

То есть requires clause работает не как = delete:

  • Функция с = delete участвует в overload resolution, но если она будет выбрана, то вы получите ошибку компиляции, потому что пытаетесь вызвать deleted функцию.
  • Функция с requires(false) будет просто исключена из overload resolution.

Просто из них будет выбираться более предпочтительная. И это нормально, что мы имеем несколько copy конструкторов, несколько перегрузок, даже не отрезанных requires clause.

Дождались! Более того, мы даже можем иметь несколько деструкторов. И вот, наконец, можно. Сорок лет в C++ нельзя было перегружать деструкторы, ну что такое? Но консенсус таков, что фича полезная, поэтому давайте дадим её пользователям, чтобы можно было вот таким образом, допустим, тот же optional реализовывать. По этому поводу было обсуждение и некоторые люди, особенно разработчики компилятора, были не очень рады, что теперь можно перегружать деструкторы.

Но это понятно, поддержать концепты сложно. Правда, если вы сейчас попробуете скомпилировать этот код, то GCC упадёт с internal compiler error, а Clang не будет правильно работать. У них ещё есть время, они подтянутся.

Как видим, существенно меньше мучений, чем в C++17. В принципе, это почти всё, что я хотел сказать про реализацию optional в C++20.

Соответственно, в compile-time такой optional работать не будет, а мы же хотим всю стандартную библиотеку в compile-time. Остаётся один момент: я использовал aligned_storage и для этого мне в геттерах приходилось делать reinterpret_cast, а reinterpret_cast нельзя использовать в constexpr функциях. Хотя, казалось бы, эти классы перекочевали в STL из Boost именно для реализации optional и variant соответственно. Поэтому настоящая реализация STL не использует aligned_storage для реализации optional и не использует aligned_union для реализации variant. Давайте посмотрим на примере variant, что используется вместо этого:

template<bool all_types_are_trivially_destructible, typename...>
class _Variant_storage_; template<typename... _Types> using _Variant_storage = _Variant_storage_< conjunction_v<is_trivially_destructible<_Types>...>, _Types...
>; template<typename _First, typename... _Rest>
class _Variant_storage_<true, _First, _Rest...> { union { remove_const_t<First> _Head; _Variant_storage<_Rest...> _Tail; };
};

Есть вспомогательный класс _Variant_storage_, который параметризуется, во-первых, альтернативами, которые может хранить variant, а во-вторых, дополнительным булевским шаблонным параметром. Это фрагмент из майкрософтовской реализации variant. У нас есть вспомогательный type alias, который подставляет реальное значение этого шаблонного параметра. Правда ли, что все альтернативы trivially_destructible? Вариант, когда он true, более простой. И у нас есть специализация _Variant_storage_ на случай, когда этот параметр true и когда он false. Если у нас все типы trivially_destructible, мы можем просто хранить union из первого элемента и Variant'а от остальных.

Нам потребовалось вводить дополнительный type alias в _Variant_storage. Я думаю, что идея, как это работает, примерно понятна, но получилось довольно громоздко. Обычно такие штуки записываются примерно так:

template<typename... _Types, bool = conjunction_v<is_trivially_destructible<_Types>...> >
class _Variant_storage_;

Однако в данном случае это сделать не получится, потому что variadic template параметр должен идти последним. Мы просто указываем значения по умолчанию шаблонного параметра. Поэтому на C++17 приходится мучиться, как мы мучились. Соответственно, мы не можем писать булевский параметр после него, но мы не можем написать булевский параметр с дефолтным значением перед ним, поскольку тогда мы не будем видеть ещё определения идентификатора _Types.

То есть та же самая идея в C++20 записывается такой вот requires clause: На C++20 нам не нужно вообще вводить дополнительный шаблонный параметр,
для того чтобы специализировать классы, ведь у нас есть для этого
requires clause.

template<typename... _Types>
class _Variant_storage_; template<typename _First, typename... _Rest> requires(TriviallyDestructible<_First> && ... && TriviallyDestuctible<_Rest>)
class _Variant_storage_<_First, _Rest...> { union { remove_const_t<_First> _Head; _Variant_storage_<_Rest...> _Tail };
};

Таким образом, мы с вами уже видели применение requires clause для шаблонной функции, для нешаблонной функции, а теперь и для других шаблонных деклараций. Эта реализация класса _Variant_storage_ подходит, только если все типы TriviallyDestructible.

То есть если вам в C++20 вдруг зачем-то понадобится enable_if, вы сможете реализовать его таким простым и изящным способом: Говорят, что можно будет использовать requires clause даже для template type alias.

template<bool condition, typename T = void> requires condition
using enable_if_t = T;

У меня есть ещё один пример кода: Я надеюсь, что вам было не очень скучно читать про концепты.

// Equivalent, but functionally not equivalent
template<typename T> enable_if_t<(sizeof(T) < 239)> f();
template<typename T> enable_if_t<(sizeof(T) > 239)> f(); // Not equivalent
template<typename T> requires(sizeof(T) < 239) void f();
template<typename T> requires(sizeof(T) > 239) void f();

В чём тут проблема? Я готов заключить пари с любым желающим, что реализованный таким образом enable_if никогда ни один компилятор не поддержит. И мы получаем ещё один пример эквивалентных, но функционально не эквивалентных функций: Давайте напишем две функции f(): одну с enable_if, который зарезает типы меньшие по размеру, чем 239, вторую, соответственно, которые больше, чем 239.

  • с одной стороны, это одна и та же функция, поскольку компилятор обязан раскрывать template type alias'ы, и для него это просто «void f(); void f();
  • с другой стороны, с точки зрения SFINAE, очевидно, это должны быть две разные функции, у них непересекающиеся области определения.

Однако у компилятора нет способа понять, что два эти определения на самом деле об одном и том же. Программист, который написал код через enable_if, на самом деле имел в виду, что первая функция должна быть ограничена для типов с size < 239, а вторая для типов с size > 239. При эквивалентности мы проверяем ещё и requires clause. Для него второй вариант является валидным, поскольку во втором случае функции f() не являются эквивалентными. А первый вариант — это эквивалентные функции, при этом функционально они не эквивалентны.

Совсем скоро в Петербурге пройдет C++ Russia 2019 Piter, где Андрей выступит с темой «Модули: изменения в core language». Если вам понравился этот доклад Андрея Давыдова — обратите внимание, что он уже вовсю готовит новый. А кроме него, тему модулей на C++ Russia будет раскрывать ещё и Дмитрий Кожевников (JetBrains) с докладом «Модули в С++20 — правда или вымысел?» В этом докладе он расскажет, например, следующее: что такое reachable entity и чем это отличается от visible, как модули влияют на ADL, могут ли entities с internal linkage протечь в другой модуль.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть