Откровения метапрограммиста. Программируем программный код на этапе компиляции, используем шаблоны C++ для нешаблонных решений

от автора

Шаблоны можно назвать самым главным отличием и основным преимуществом языка 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`, где заполнены все биты, кроме знакового, и будет давать нам искомую маску допустимых значений.

В качестве домашнего задания рассмотри следующие случаи:

  1. Приведение знакового к беззнаковому с использованием уже написанных специализаций и модификатора `std::make_unsigned`.
  2. Приведение беззнакового к знаковому большей разрядности с использованием уже написанных специализаций и модификатора `std::make_signed`.
  3. Чуть посложнее: приведение беззнакового к знаковому меньшей или равной разрядности с использованием условия невыхода за пределы значений и модификатора `std::make_signed`.

Также не составит труда написать аналогичные специализации для преобразования из `std::is_floating_point` типов, а также преобразование из типа `bool`. Для полного удовлетворения можно дописать приведение из и в строковые типы и оформить это столь нужной всем библиотекой безопасного приведения типов C++.

Нешаблонное мышление

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

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

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

Шаблонизируй код аккуратно и лишь по необходимости, и коллеги скажут тебе спасибо. И не бойся ломать шаблон в случае исключения из правил. Правила без исключений — это, скорее, исключения из правил.

image

Впервые опубликовано в журнале Хакер #193.
Автор: Владимир Qualab Керимов, ведущий С++ разработчик компании Parallels

Подпишись на «Хакер»

ссылка на оригинал статьи http://habrahabr.ru/post/257899/