Содержание статьи
Метапрограммирование становится столь же неотъемлемой частью написания кода на C++, как и использование стандартной библиотеки, часть которой создана именно для использования на этапе компиляции. Сегодня мы произведем на свет библиотеку безопасного приведения скалярных типов C++, метапрограммируя шаблонами!
Разрыв шаблона
На самом деле все метапрограммирование сводится не столько к шаблонному поведению, независимо от типа, сколько к нарушению этого самого шаблона поведения. Допустим, у нас есть шаблонный класс или шаблонная функция:
template <class T>class Some;template <class T>T func(T const& value);
Как правило, такие классы и функции описывают сразу с телом, общим для любого типа. Но никто не мешает нам задать явную специализацию шаблона по одному из типов, создав для этого типа уникальное поведение функции или особый вид класса:
template <>class Some<int>{public: explicit Some(int value) : m_twice(value * 2) { } int get_value() const { return m_twice / 2; }private: int m_twice;};template <>double func(double const& value){return std::sqrt(value);}
При этом общее поведение может описываться сильно отлично от указанного для специализаций шаблона:
template <class T>class Some{public: explicit Some(T const& value) : m_value(value) { } T const& get_value() const { return m_value; }private: T m_value;};template <class T>T func(T const& value){return value * value;}
В этом случае при использовании шаблона будет наблюдаться особое поведение для специализаций Some<
и func<
: оно будет сильно расходиться с общим поведением шаблона, хотя внешне API отличаться будет незначительно. Зато при создании экземпляры Some<
будут хранить удвоенное значение и выдавать исходное значение, деля пополам свойство m_twice
по запросу get_value(
. Общий шаблон Some<
, где T — любой тип, кроме int, будет просто сохранять переданное значение, выдавая константную ссылку на поле m_value
при каждом запросе get_value(
.
Функция func<
и вовсе вычисляет корень значения аргумента, в то время как любая другая специализация шаблона func<
будет вычислять квадрат переданного значения.
Зачем это нужно? Как правило, для того, чтобы сделать логическую развилку внутри шаблонного алгоритма, например такого:
template <class T>T create(){ Some<T> some(T());return func(some.get_value());}
Поведение алгоритма внутри create
Несмотря на нелогичность кода специализаций шаблона, мы получили простой и понятный пример управления поведения шаблонами.
Разрыв несуществующего шаблона
Давай сделаем наш пример чуть более веселым — уберем общий шаблон поведения для Some и func, оставив лишь уже написанные специализации Some
Что в этом случае произойдет с шаблоном create<
? Он просто перестанет компилироваться для любого типа. Ведь для create<
не существует реализации функции func<
, а для create<
нет нужного Some<
. Первая же попытка вставить в код вызов create для какого‑либо типа приведет к ошибке компиляции.
Чтобы оставить возможность работать функции create<
, нужно специализировать Some<
и func<
хотя бы от одного типа одновременно. Можно реализовать Some<
или func<
, например так:
template <>int func(int const& value){ return value;}template <>class Some<double>{public: explicit Some(double value) : m_value(value*value) { } double get_value() const { return m_square; }private: double m_square;};
Добавив две специализации, мы не только оживили компиляцию специализаций create от типов int и double, получилось еще и так, что возвращать для этих типов алгоритм будет одни и те же значения. Но поведение при этом будет разным!
info
В C++ типы ведут себя по‑разному и не всегда шаблонный алгоритм ведет себя эффективно для всех типов. Зачастую, добавив специализацию шаблона, мы получаем не только прирост производительности, но и более понятное поведение программы в целом.
Да поможет нам std::
С каждым годом в стандартную библиотеку добавляется все больше инструментов для метапрограммирования. Как правило, все новое — это хорошо опробованное старое, позаимствованное из библиотеки Boost.MPL и узаконенное. Нам все чаще требуется #include <
, и все больше кода идет с применением развилок вида std::
, все больше нам требуется знать на этапе компиляции, не является ли аргумент шаблона целочисленным типом std::
, или, например, сравнить два типа внутри шаблона с помощью std::
, чтобы управлять поведением специализаций шаблона.
Вспомогательные структуры шаблона выстроены так, что компилируется только та специализация, что дает истинность выражения, а специализации для ложного поведения отсутствуют.
Чтобы стало более понятно, рассмотрим подробнее std::
. Этот шаблон зависит от истинности первого своего аргумента (второй опционален), и выражение вида std::
будет скомпилировано лишь для истинных выражений, делается это довольно просто — специализацией от значения true:
template <bool predicate_value, class result_type = void>struct enable_if;template<class result_type>struct enable_if<true, result_type>{ typedef result_type type;};
Для значения false типа std::
компилятор просто не сможет создать, и это можно использовать, например ограничив поведение ряда типов частичной специализации шаблонной структуры или класса.
Здесь в помощь в качестве аргументов std::
могут быть использованы самые разнообразные структуры‑предикаты из того же <
: std::
истинно, если тип T поддерживает тип знак + или - (что очень удобно для отсечения поведения беззнаковых целых), std::
истинно для вещественных типов float и double, std::
истинно, если типы T1 и T2 совпадают.
Структур предикатов, помогающих нам, множество, а если чего не хватает в std:
или boost:
, можно запросто сделать свою структуру.
Что ж, вводная часть завершена, переходим к практике.
Как устроены предикаты?
Предикат — это обычная частичная специализация шаблонной структуры. Например, для std::
в общем случае все выглядит примерно так:
template <class T1, class T2>struct is_same;template <class T>struct is_same<T,T>{ static const bool value = true;};template <class T1, class T2>struct is_same{ static const bool value = false;};
Для совпадающих типов аргументов std::
компилятор C++ выберет подходящую специализацию, в данном случае частичную с value = true, а для несовпадающих попадет в общую реализацию шаблона с value = false. Компилятор всегда пытается отыскать строго подходящую специализацию по типам аргументов и, лишь не найдя нужную, идет в общую реализацию шаблона.
Вход по шаблону строго воспрещен
Чтобы начать программировать программный код и заняться всяческим метапрограммированием, попробуем‑ка создать страшную функцию, возвращающую разный результат для одинаковых и разных типов аргументов шаблона. В этом нам поможет механизм частичной специализации для вспомогательной структуры.
Поскольку частичной специализации для функций не существует, внутри функции мы будем просто обращаться к простой соответствующей специализации структуры, у которой мы и зададим частичную специализацию:
template <class result_type, class value_type>struct type_cast;template <class result_type, class value_type>bool try_safe_cast(result_type& result, value_type const& value){ return type_cast<result_type, value_type>::try_cast(result, value);}template <class same_type>struct type_cast<same_type, same_type>{ static bool try_cast(result_type& result, value_type const& value) { result = value; return true; }}
Очевидно, что мы создали заготовку для функции безопасного приведения типов. Функция основывается на типах переданных в нее аргументов и идет выполнять статический метод try_cast
у соответствующей специализации структуры type_cast
. В настоящий момент мы реализовали только тривиальный случай, когда тип значения совпадает с типом результата и преобразование, по сути, не нужно. Переменной результата просто присваивается входящее значение, и всегда возвращается true — признак успешного приведения типа значения к типу результата.
Для несовпадающих типов сейчас будет выдана ошибка компиляции с длинным непонятным текстом. Чтобы немного поправить это дело, необходимо ввести общую реализацию шаблона со static_assert(
в теле метода try_cast
— это сделает сообщение об ошибке более понятным:
template <class result_type, class value_type>struct type_cast{ static bool try_cast(result_type&, value_type const&) { static_assert(false, "Здесь нужно понятное сообщение об ошибке"); }}
Таким образом, каждый раз, когда будет произведена попытка приведения типа функцией try_safe_cast
типов, для которых нет соответствующей специализации структуры type_cast
, будет выдаваться сообщение об ошибке компиляции из общего шаблона.
Заготовка готова, пора приступать к метапрограммированию!
Пометапрограммируй мне тут!
Для начала нужно поправить объявление вспомогательной структуры type_cast
. Нам потребуется дополнительный тип meta_type
для логической развилки без ущерба для передаваемых параметров и неявного определения их типов. Теперь описание шаблона структуры будет выглядеть чуть сложнее:
template <class result_type, class value_type, class meta_type = void>struct type_cast;
Как видно, новый тип в объявлении шаблона опционален и никак не мешает уже существующим объявлениям специализации и общего поведения шаблона. Однако этот маленький нюанс позволяет нам управлять успешностью компиляции, передавая третьим параметром результат std::
. Специализации с некомпилируемым параметром шаблона будут отброшены, что нам и нужно, чтобы управлять логикой приведения типов различных групп.
Ведь очевидно, что целые числа приводятся друг к другу по‑разному, в зависимости от того, есть ли у обоих типов знак, какой тип большей разрядности и не выходит ли переданное значение value за пределы допустимых значений для result_type
.
Так, если оба типа — знаковые целые и тип результата большей разрядности, нежели тип входящего значения, то можно без проблем присвоить результату входящее значение, это же верно и для беззнаковых типов. Давай опишем это поведение специальной частичной специализацией шаблона type_cast
:
template <class result_type, class value_type>struct type_cast<result_type, value_type, typename std::enable_if<...>::value>{ static bool try_cast(result_type& result, value_type const& value) { result = value; return true; }};
Теперь нужно разобраться, что за условие нам нужно вставить вместо многоточия параметром std::
.
Поехали описывать условие времени компиляции:
typename std::enable_if<
Во‑первых, специализация не должна пересекаться с уже существующей, где тип результата и входящего значения совпадают:
!std::is_same<result_type, value_type>::value &&
Во‑вторых, мы рассматриваем случай, когда оба аргумента шаблона — целочисленные типы:
std::is_integral<result_type>::value &&std::is_integral<value_type>::value &&
В‑третьих, мы подразумеваем, что оба типа либо знаковые, либо беззнаковые (скобки обязательны — условия параметров шаблона вычисляются иначе, нежели на этапе выполнения!):
(std::is_signed<result_type>::value == std::is_signed<value_type>::value) &&
В‑четвертых, разрядность целочисленного типа результата больше, чем разрядность типа переданного значения (снова обязательны скобки!):
(sizeof(result_type) > sizeof(value_type))
И наконец, закрываем объявление std::enable_if:
::type
В результате type для std::
будет сгенерирован только при выполнении указанных четырех условий. В остальных случаях для прочих комбинаций типов данная частичная специализация даже не будет создана.
Получается зубодробительное выражение внутри std::
, которое отсекает исключительно указанный нами случай. Данный шаблон спасает от тиражирования кода приведения различных целочисленных типов друг в друга.
Чтобы закрепить материал, можно описать чуть более сложный случай — приведение беззнакового целого к типу меньшей разрядности беззнакового целого. Тут нам поможет знание бинарного представления целого числа и стандартный класс std::
:
template <typename result_type, typename value_type>struct type_cast<result_type, value_type, typename std::enable_if<...>::type>{ static bool try_cast(result_type& result, value_type const& value) { if (value != (value & std::numeric_limits<result_type>::max())) { return false; } result = result_type(value); return true; }};
В условии if все достаточно просто: максимальное значение типа result_type
неявно приводится к типу больше разрядности value_type
и выступает в качестве маски для значения value
. В случае если для значения value
задействованы биты вне result_type
, мы получим выполненное неравенство и попадем на return false.
Теперь пройдем по условию времени компиляции:
typename std::enable_if<
Первые два условия остаются теми же — оба типа целочисленные, но различные между собой:
!std::is_same<result_type, value_type>::value && std::is_integral<result_type>::value && std::is_integral<value_type>::value &&
Оба типа являются беззнаковыми целыми:
std::is_unsigned<result_type>::value &&std::is_unsigned<value_type>::value &&
Тип результата меньшей разрядности, нежели тип входящего значения (скобки обязательны!):
(sizeof(result_type) < sizeof(value_type))
Все условия перечислены, закрываем условие специализации:
::type
Для знаковых целых, где результат меньшей разрядности, условие будет похожим, но с двумя std::
внутри std::
, однако условие выхода за пределы значений будет несколько другим:
static bool try_cast(result_type& result, value_type const& value){ if (value != (value & (std::numeric_limits<result_type>::max() | std::numeric_limits<value_type>::min()))) { return false; } result = result_type(value); return true;}
Снова вспоминаем бинарное представление целых чисел со знаком: здесь маской будет бит знака входящего значения и биты значения типа результата, исключая бит знака. Соответственно, минимальное число типа value_type
, где заполнен только бит знака, объединенное побитово с максимальным числом типа result_type
, где заполнены все биты, кроме знакового, и будет давать нам искомую маску допустимых значений.
В качестве домашнего задания рассмотри следующие случаи:
- Приведение знакового к беззнаковому с использованием уже написанных специализаций и модификатора
std::
.make_unsigned - Приведение беззнакового к знаковому большей разрядности с использованием уже написанных специализаций и модификатора
std::
.make_signed - Чуть посложнее: приведение беззнакового к знаковому меньшей или равной разрядности с использованием условия невыхода за пределы значений и модификатора
std::
.make_signed
Также не составит труда написать аналогичные специализации для преобразования из std::
типов, а также преобразование из типа bool
. Для полного удовлетворения можно дописать приведение из и в строковые типы и оформить это столь нужной всем библиотекой безопасного приведения типов C++.
Нешаблонное мышление
Для каждого случая использования шаблона может существовать исключение. Теперь ты будешь готов встретиться с ним и грамотно его обработать. Не всегда нужен специальный метатип в шаблоне вспомогательной структуры, но если пришла пора обрабатывать предикаты на этапе компиляции — что ж, в этом нет ничего страшного. Все, что нужно, — это засучить рукава и аккуратно создать шаблонную конструкцию с предикатом времени компиляции.
Но будь аккуратен, злоупотребление шаблонами до добра не доводит! Относись к шаблонам исключительно как к обобщению кода для разных типов со схожим поведением, шаблоны должны появляться обоснованно, когда есть риск тиражирования одинакового кода для разных типов.
Помни также о том, что для того, чтобы разобраться в логике шаблонного предиката без автора кода, нужно быть как минимум смелым оптимистом, поэтому береги психику коллег, оформляй шаблонные предикаты аккуратно, красиво и читабельно и не стесняйся комментировать чуть ли не каждое условие в предикате.
Шаблонизируй код аккуратно и лишь по необходимости, и коллеги скажут тебе спасибо. И не бойся ломать шаблон в случае исключения из правил. Правила без исключений — это, скорее, исключения из правил.