Шаблоны можно назвать самым главным отличием и основным преимуществом языка C++. Возможность создать шаблон алгоритма для различных типов без копирования кода и со строгой проверкой типов — это лишь один из аспектов использования шаблонов. Код специализаций шаблона строится на этапе компиляции, а это значит, что поведением создаваемых типов и функций можно управлять. Как тут удержаться от возможности попрограммировать компилируемые классы?
Метапрограммирование становится столь же неотъемлемой частью написания кода на 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 будет отличаться для типов int и double. При этом отличаться будет поведение различных компонент алгоритма. Несмотря на нелогичность кода специализаций шаблона, мы получили простой и понятный пример управления поведения шаблонами.
Разрыв несуществующего шаблона
Давай сделаем наш пример чуть более веселым — уберем общий шаблон поведения для Some и func, оставив лишь уже написанные специализации Some и func и, конечно же, не трогая предварительное объявление.
Что в этом случае произойдет с шаблоном `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 <type_traits>`, и все больше кода идет с применением развилок вида `std::enable_if`, все больше нам требуется знать на этапе компиляции, не является ли аргумент шаблона целочисленным типом `std::is_integral`, или, например, сравнить два типа внутри шаблона с помощью `std::is_same`, чтобы управлять поведением специализаций шаблона.
Вспомогательные структуры шаблона выстроены так, что компилируется только та специализация, что дает истинность выражения, а специализации для ложного поведения отсутствуют.
Чтобы стало более понятно, рассмотрим подробнее `std::enable_if`. Этот шаблон зависит от истинности первого своего аргумента (второй опционален), и выражение вида `std::enable_if::type` будет скомпилировано лишь для истинных выражений, делается это довольно просто — специализацией от значения 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::enable_if<P, T>::type` компилятор просто не сможет создать, и это можно использовать, например ограничив поведение ряда типов частичной специализации шаблонной структуры или класса.
Здесь в помощь в качестве аргументов `std::enable_if` могут быть использованы самые разнообразные структуры-предикаты из того же `<type_traits>`: `std::is_signed::value` истинно, если тип T поддерживает тип знак + или — (что очень удобно для отсечения поведения беззнаковых целых), `std::is_floating_point::value` истинно для вещественных типов float и double, `std::is_same<T1, T2>::value` истинно, если типы T1 и T2 совпадают. Структур предикатов, помогающих нам, множество, а если чего не хватает в `std::` или `boost::`, можно запросто сделать свою структуру.
Что ж, вводная часть завершена, переходим к практике.
Как устроены предикаты?
Предикат — это обычная частичная специализация шаблонной структуры. Например, для `std::is_same` в общем случае все выглядит примерно так:
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::is_same` компилятор 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(false, …)` в теле метода `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::enable_if<предикат>::value`. Специализации с некомпилируемым параметром шаблона будут отброшены, что нам и нужно, чтобы управлять логикой приведения типов различных групп.
Ведь очевидно, что целые числа приводятся друг к другу по-разному, в зависимости от того, есть ли у обоих типов знак, какой тип большей разрядности и не выходит ли переданное значение 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::enable_if`.
Поехали описывать условие времени компиляции:
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::enable_if` будет сгенерирован только при выполнении указанных четырех условий. В остальных случаях для прочих комбинаций типов данная частичная специализация даже не будет создана.
Получается зубодробительное выражение внутри `std::enable_if`, которое отсекает исключительно указанный нами случай. Данный шаблон спасает от тиражирования кода приведения различных целочисленных типов друг в друга.
Чтобы закрепить материал, можно описать чуть более сложный случай — приведение беззнакового целого к типу меньшей разрядности беззнакового целого. Тут нам поможет знание бинарного представления целого числа и стандартный класс `std::numeric_limits`:
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::is_signed` внутри `std::enable_if`, однако условие выхода за пределы значений будет несколько другим:
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::is_floating_point` типов, а также преобразование из типа `bool`. Для полного удовлетворения можно дописать приведение из и в строковые типы и оформить это столь нужной всем библиотекой безопасного приведения типов C++.
Нешаблонное мышление
Для каждого случая использования шаблона может существовать исключение. Теперь ты будешь готов встретиться с ним и грамотно его обработать. Не всегда нужен специальный метатип в шаблоне вспомогательной структуры, но если пришла пора обрабатывать предикаты на этапе компиляции — что ж, в этом нет ничего страшного. Все, что нужно, — это засучить рукава и аккуратно создать шаблонную конструкцию с предикатом времени компиляции.
Но будь аккуратен, злоупотребление шаблонами до добра не доводит! Относись к шаблонам исключительно как к обобщению кода для разных типов со схожим поведением, шаблоны должны появляться обоснованно, когда есть риск тиражирования одинакового кода для разных типов.
Помни также о том, что для того, чтобы разобраться в логике шаблонного предиката без автора кода, нужно быть как минимум смелым оптимистом, поэтому береги психику коллег, оформляй шаблонные предикаты аккуратно, красиво и читабельно и не стесняйся комментировать чуть ли не каждое условие в предикате.
Шаблонизируй код аккуратно и лишь по необходимости, и коллеги скажут тебе спасибо. И не бойся ломать шаблон в случае исключения из правил. Правила без исключений — это, скорее, исключения из правил.
Впервые опубликовано в журнале Хакер #193.
Автор: Владимир Qualab Керимов, ведущий С++ разработчик компании Parallels
Подпишись на «Хакер»