Как стать автором
Обновить

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

Время на прочтение11 мин
Количество просмотров29K


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


Подпишись на «Хакер»
Теги:
Хабы:
Всего голосов 17: ↑16 и ↓1+15
Комментарии2

Публикации

Информация

Сайт
xakep.ru
Дата регистрации
Дата основания
Численность
51–100 человек
Местоположение
Россия

Истории