Использование возможностей нового стандарта С++. Шаблоны с переменным количеством аргументов

Те, кто читал книгу Андрея Александреску «Современное программирование на C++» [1] знают, что в области метапрограммирования с использованием шаблонов существует обширный класс задач, в которых шаблону при инстанцировании необходимо указать переменное, заранее неизвестное количество аргументов. Типичные примеры таких задач:

  • Описание кортежей (tuples)
  • Описание типов наподобие вариантов (variants)
  • Описание функторов (в этом случае перечень типов аргументов зависит от сигнатуры функции)
  • Классификация типов по заранее заданным множествам и т. п.

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

В рамках существующего стандарта С++ (в редакции от 2003-го года) сколько-нибудь удобного решения этой задачи не существует. Шаблоны могут принимать строго определённое количество параметров. А. Александреску в [1] предлагает общее решение, основанное на т. н. «списках типов». Предлагаемый им «список типов» представляет из себя шаблонный класс следующего вида:

template<typename H, typename T>
struct TypeList
{
   typedef H Head;
   typedef T Tail;
};

Этот класс, как несложно заметить, очень похож на элемент односвязного списка. Используется он следующим образом:

typedef TypeList<char, TypeList<wchar_t, TypeList<short, TypeList<int, long> > > > IntsList;

Выше приведен пример объявления, описывающего список целочисленных типов.

Для упрощения формирования такого рода объявлений Александреску предлагает набор макросов с числом аргументов от 1 до 50-ти, раскрывающихся в объявление соответствующего списка. Это решение далеко от идеала, так как пользоваться такими списками не очень удобно, но с задачами, возложенными на него, оно вполне справлялось.

Альтернативным решением, как, например, в boost::variant и boost::tuple, является объявление шаблонного класса с большим количеством параметров, которым (всем, кроме первого) присвоено некоторое значение по умолчанию, в результате чего появляются две возможности:

  1. инстанцировать шаблон с любым, но не превышающем максимального, количеством аргументов;
  2. определять, сколько именно аргументов было передано при инстанцировании.

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

Для облегчения решения этих задач, устранения недостатков существующих решений и упрощения кода новый стандарт предлагает С++-разработчикам новый вариант объявления шаблонов ? «шаблоны с переменным количеством параметров» или, в оригинале, «variadic templates».

Простые варианты использования

Объявление шаблона с переменным количеством параметров выглядит следующим образом:
Подобным же образом объявляются шаблоны с переменным количеством параметров – не типов:

template<typename ... Types>
class VariadicTemplate
{
};

Здесь необходимо отметить, что эмуляция подобного в рамках текущего стандарта – весьма нетривиальная задача.

Объявление шаблонной функции с переменным количеством параметров выглядит так:

template<int ... Ints>
void printf(const char* format, Type ... args);

Очевидно, что такого рода параметры шаблонов, «пакеты параметров» или «parameters packs», не могут использоваться везде, где могут использоваться обычные одиночные параметры шаблонов. Допустимо использование пакетов параметров в следующих контекстах:

  • в перечислении базовых классов шаблона (base-specifier-list),
  • в списке инициализации членов данных в конструкторе (mem-initializer-list),
  • в списках инициализации (initializer-list),
  • в списках параметров других шаблонов (template-argument-list),
  • в спецификации исключений (exception-specification),
  • в списке атрибутов (attribute-list).

В зависимости от того, где именно используется пакет параметров, соответствующим образом интерпретируются элементы этого пакета. Использование пакета параметров называется «раскрытием пакета» (pack expansion), и записывается в коде следующим образом:
Types ...
Здесь Types – это название пакета параметров.
Например, для такого объявления шаблона:

template<typename ... Types>
class VariadicTemplate
{
};

возможные варианты раскрытия пакета параметров могут выглядеть так:

class VariadicTemplate : public Types ...
// раскрытие в список базовых классов. 'public Types' - паттерн
{
   //...
   // Раскрытие в список параметров другого шаблона. Паттерн - Types
   typedef OtherVariadicTemplate<Types ...> OtherVT;
   // Более сложный вариант. Паттерн - Types *
   typedef OtherVariadicTemplate<Types* ...> SomeOtherVT;
   // Раскрытие в список параметров функции.
   //Паттерном является Types, a args - это новый список параметров:
   void operator () (Types ... arg)
   {
      // Раскрытие в список аргументов при вызове функции
      foo(&args ...);
   }
   // Раскрытие в списке инициализации в конструкторе:
   VariadicTemplate() : Types() ...
};

Под термином «паттерн» здесь понимается фрагмент кода, окружающего имя пакета параметров, который будет повторяться при раскрытии соответствующего пакета. В приведённом примере, если проводить раскрытие параметров вручную, то такое инстанцирование шаблона:

/* ... */ VariadicTemplate<int, char, double> /* ... */

было бы раскрыто следующим образом:

class VariadicTemplate : public int, public char, public double
{
   //...
   typedef OtherVariadicTemplate<int, char, double> OtherVT;
   typedef OtherVariadicTemplate<int*, char*, double*> SomeOtherVT;
   void operator () (int args1, char args2, double args3)
   {
     foo(&args1, &args2, &args3);
   }
   VariadicTemplate() : int(), char(), double()
   // очевидно, этот код получится некомпилируемым для такого списка типов
};

В качестве достаточно простого примера использования шаблонов с переменным числом параметров можно привести реализацию функтора. Выглядит эта реализация следующим образом:

#include <iostream>
// Объявляется общий вариант шаблона, хранящего указатель на функцию.
//При этом все возможные типы, которые могут придти в шаблон
// в процессе инстанцирования, упаковываются в пакет параметров
template<typename ... Args> struct FunctorImpl;
// Шаблон специализируется для указателя на простые функции.
//При этом указывается, что пакет параметров содержит тип возвращаемого
// значения (R) и аргументы (Args). Из этих двух параметров,
//простого и пакетного, затем формируется сигнатура функции
template<typename R, typename ... Args>
struct FunctorImpl<R (Args ...)>
{
   // Описывается тип указателя на функцию с нужной сигнатурой.
   //При этом раскрывается пакет параметров
   typedef R (*FT)(Args ...);
   FunctorImpl(FT fn) : m_fn(fn) {;}
   // Оператор вызова функции объявляется таким образом, что он принимает
   //на вход ровно столько параметров, сколько аргументов
   // у хранимого типа функции.
   R operator () (Args ... args)
   {
      // Вызывается функция, ей передаются все полученные аргументы
      return m_fn(args ...);
   }
   FT m_fn;
};
// Объявляется общий шаблон-диспетчер
template<typename FT>
struct Functor : public FunctorImpl<FT>
{
   Functor() : FunctorImpl<FT>(NULL) {;}
   Functor(FT fn) : FunctorImpl<FT>(fn) {;}
};
int plus_fn(int a, int b) {return a + b;}
int minus_fn(int a, int b) {return a - b;}
int increment(int& a) {return a ++;}
int main()
{
   Functor<int (int, int)> plus(plus_fn);
   Functor<int (int, int)> minus(minus_fn);
   Functor<int (int&)> inc(increment);
   std::cout << plus(10, 20) << " " << minus(10, 20) << std::endl;
   int a = 100;
   std::cout << inc(a) << " ";
   std::cout << a << std::endl;
}

Результат выполнения этого кода вполне ожидаем:
30 -10
100 101
а код – прост и понятен. Для сравнения можно посмотреть файлы с реализацией

boost::function.

Описанные выше шаблоны несложно специализировать для указателей на функции-члены:

// Объявляется специализация контейнера функции для указателя
//на функцию член, конкретезируя всё тот же пакет параметров
template<typename T, typename R, typename ... Args>
struct FunctorImpl<R (T::*)(Args ...)>
{
   typedef R (T::*FT)(Args ...);
   typedef T HostType;
   FunctorImpl(FT fn = NULL, T* obj = NULL) : m_fn(fn), m_obj(obj) {;}
   // Объявляются два варианта оператора вызова функции:
   //для случая использования функтора как "замыкание", и для случая
   //передачи объекта, для которого вызывается метод, первым аргументом
   R operator() (Args... args)
   {
      (m_obj->*m_fn)(args ...);
   }
   R operator() (T* obj, Args... args)
   {
      (obj->*m_fn)(args ...);
   }
   FT m_fn;
   T* m_obj;
};
// Объявляется класс-замыкание, принимающий в конструкторе объект,
//для которого будет вызываться функция-член. Выглядит он очень просто
template<typename FT>
struct Closure : public FunctorImpl<FT>
{
   typedef typename FunctorImpl<FT>::HostType HostType;
   Closure(HostType* obj, FT fn) : FunctorImpl<FT>(fn, obj) {;}
};
// Использование
class A
{
   public:
   A(int base = 0) : m_base(base) {;}
   int foo(int a) {return a + m_base;}
   private:
   int m_base;
};
A b1(10), b2;
Closure<int (A::*)(int)> a_foo(&b1, &A::foo);
// Можно заметить, что общая реализация функтора
//корректно работает и с указателями на функции-члены:
Functor<int (A::*)(int)> b_foo(&A::foo);
std::cout << a_foo(20) << " " << a_foo(&b2, 20) << " " << b_foo(&b1, 20) << std::endl;

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

1. Декларируется наиболее общий шаблон, последний параметр которого описывается в виде пакета параметров. В примере это

template<typename ... Args> struct FunctorImpl;

2. Определяются частичные специализации этого шаблона, конкретизирующие ту или иную часть пакета параметров. В приведённом примере это определение

template<typename R, typename ... Args>
struct FunctorImpl<R (Args ...)>

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

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

Более сложные случаи

Как отмечалось выше, пакеты параметров могут содержать не только типы, но и не-типы. Например:

// Объявляется шаблон, принимающий переменное количество целых чисел
template<int ... Nums>
struct NumsPack
{
   // Объявляется статический массив, размер которого равен количеству фактически переданных аргументов
   static int m_nums[sizeof...(Nums)];
   // А также объявляется перечисление, сохраняющее количество элементов в массиве
   enum {nums_count = sizeof ... (Nums)};
};
// Инициализируется статический массив
template<int ... Nums>
int NumsPack<Nums ...>::m_nums[] = {Nums ...};

Проверочный код:

typedef NumsPack<10, 20, 30, 40, 50> Nums_5;
std::cout << Nums_5::nums_count << std::endl;
for (int n = 0; n < Nums_5::nums_count; ++ n)
   std::cout << Nums_5::m_nums[n] << " ";
std::cout << std::endl;

печатает на консоль ожидаемые числа:
5
10 20 30 40 50
Конструкция sizeof … (Nums), использованная в этом примере, нужна для получения количества параметров в пакете. В ней Nums – это имя пакета параметров. К сожалению, дизайн шаблонов с переменным количеством параметров таков, что это – единственное, что можно сделать с пакетом параметров помимо его непосредственно раскрытия. Получить параметр по индексу, например, или совершить какие-либо более сложные манипуляции в рамках проекта нового стандарта невозможно.
При раскрытии пакетов можно применять более сложные паттерны. Например, в приведённом выше коде можно сделать следующую замену:

template<int ... Nums>
int NumsPack<Nums ...>::m_nums[] = {Nums * 10  ...};

что приведёт к выводу на экран другой последовательности:
100 200 300 400 500

Вообще, конкретный вид паттерна зависит от контекста, в котором он раскрывается. Более того, паттерн может содержать упоминание более одного пакета параметров. В этом случае все упомянутые в паттерне пакеты будут раскрываться синхронно, а количество фактических параметров в них должно совпадать.
Такая ситуация может возникать в случаях, когда требуется определить кортежи значений. Предположим, необходимо организовать универсальный функтор-композитор, задача которого состоит в передаче в некоторую функцию результатов выполнения заданных функций для некого аргумента. Иными словами, пускай существует некоторый набор функций:

double fn1(double a)
{
   return a * 2;
}
int fn2(int a)
{
   return a * 3;
}
int fn3(int a)
{
   return a * 4;
}

И две операции:

int test_opr(int a, int b)
{
  return a + b;
}
int test_opr3(int a, int b, int c)
{
  return a + b * c;
}

Необходимо написать универсальный функтор, применение операции вызова функции к которому приводило бы к выполнению такого кода:

test_opr(f1(x), f2(x));

или

test_opr3(f1(x), f2(x), f3(x));

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

template<typename Op, typename ... F>
class Compositor
{
   public:
   Compositor(Op op, F ... fs);
};

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

template<typename T>
struct DataHolder
{
   T m_data;
};
template<typename Op, typename ... F>
class Composer : public DataHolder<F> ...
{
   // ...
};

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

// Определяется класс собственно кортежа
template<int ... Idxs> struct IndexesTuple
{
};
// Определяется общий вид шаблона, используемого для порождения кортежа
template<int Num, typename Tp = IndexesTuple<>>
struct IndexTupleBuilder;
// Определяется специализация, которая генерирует последовательность
//чисел в виде пакета целочисленных параметров. Для этого в качестве
//второго параметра в объявлении шаблона используется не собственно
//тип кортежа, а ранее сформированный пакет. Для получения итогового
//пакета производится наследование от порождающегося шаблона,
//при этом в пакет добавляется новое число
template<int Num, int ... Idxs>
struct IndexTupleBuilder<Num, IndexesTuple<Idxs ...>> : IndexTupleBuilder<Num - 1, IndexesTuple<Idxs ..., sizeof ... (Idxs)>>
{
};
// Терминирующая рекурсию специализация. Содержит итоговый typedef,
//определяющий кортеж с нужным набором чисел
template<int ... Idxs>
struct IndexTupleBuilder<0, IndexesTuple<Idxs ...>>
{
typedef IndexesTuple<Idxs...> Indexes;
};

Использовать этот шаблон можно следующим образом:

typedef typename IndexTupleBuilder<6> Indexes;

При этом Indexes будет эквивалентно IndexesTuple<0, 1, 2, 3, 4, 5>
Чтобы этот класс был применим в реализации композитора, надо ввести промежуточный базовый класс, который и будет наследоваться от классов с данными. При этом каждый класс с данными будет снабжён своим уникальным индексом:

template<int idx, typename T>
struct DataHolder
{
   DataHolder(T const& data) : m_data(data) {;}
   T m_data;
};
// Сначала объявляется общий шаблон, принимающий на вход кортеж.
//Объявление непосредственно в таком виде не потребуется, но
// оно требуется для последующей специализации.
template<typename IdxsTuple, typename ... F> struct ComposerBase;
// Специализируется общий шаблон, из кортежа извлекается пакет параметров.
// В данном случае шаблон объявляется с двумя пакетами параметров.
//Это разрешено, т. к. пакеты могут быть однозначно разделены.
// При наследовании используется паттерн, в котором упоминается сразу
//два пакета параметров. Это позволяет однозначно сопоставить
// элементы целочисленного кортежа и перечня типов функций.
template<int ... Idxs, typename ... F>
struct ComposerBase<IndexesTuple<Idxs...>, F ...> : public DataHolder<Idxs, F>...
{
   // А здесь паттерн содержит сразу три пакета: пакет с индексами,
   //пакет типов функций и пакет аргументов. Всё это раскрывается в список
   // инициализации конструктора.
   ComposerBase(F ... fs) : DataHolder<Idxs, F>(fs)... {;}
};
// От описанного выше шаблона, содержащего фактические данные, наследуется шаблон композитора
template<typename Op, typename ... F>
struct Composer : public ComposerBase<typename IndexTupleBuilder<sizeof...(F)>::Indexes, F...>
{
   Op m_op;
   public:
   // Объявляется конструктор
   Composer(Op op, F const &... fs) : m_op(op), Base(fs...) {;}
};

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

template<typename Op, typename ... F>
struct Composer : /* ...  */
{
   Op m_op;
   public:
   typedef decltype(m_op((*(F*)NULL)(0)...)) result_t;
   // ...
};

Для определения типа возвращаемого значения используется другая новая для C++ конструкция – decltype. Результатом её применения в данном случае является тип возвращаемого функцией значения. Конструкция выглядит несколько странной. По смыслу она эквивалентна такой:

decltype(op(fs(0) ...))

Но поскольку в области видимости класса пакет fs не определён, оператор применяется к сконвертированному к ссылке на тип функции NULL.
Теперь всё готово для определения оператора вызова функции. Поскольку классы, участвующие в композиции функции, в качестве одного из параметров шаблона принимают целочисленный индекс, этот оператор реализуется через вспомогательную функцию, в которую передаётся всё тот же целочисленный кортеж:

template<typename Op, typename ... F>
struct Composer : /* ...  */
{
   Op m_op;
   public:
   ret_type operator()(int x) const
   {
      return MakeCall(x, Indexes());
   }
   private:
   // Здесь используется тот же самый трюк, что и в определении класса ComposerBase.
   //Тип кортежа используется для того, чтобы "поймать" пакет целочисленных индексов.
   template<int ... Idxs>
   ret_type MakeCall(int x, IndexesTuple<Idxs...> const&) const
   {
      return m_op(DataHolder<Idxs, F>::m_data(x)...);
   }
};

Осталось только определить функцию, облегчающую создание экземпляров этого класса:

template<typename Op, typename ... F>
Composer<Op, F ...> Compose(Op op, F ... fs)
{
   return Composer<Op, F...>(op, fs ...);
}

Композитор готов. Несколько примеров его использования:

auto f = MakeOp(test_opr, fn1, fn2);
auto ff = MakeOp(test_opr3, fn1, fn2, fn3);
auto ff1 = MakeOp(test_opr3, fn1, fn2, [=](int x) {return f(x) * 5;});
// здесь последним параметром в композитор передаётся лямбда-функция.

Полное определение шаблонного класса-композитора выглядит следующим образом:

template
struct ComposerBase, F ...> : public DataHolder...
{
  ComposerBase(F ... fs) : DataHolder(fs)... {;}
};
template
struct Composer : public ComposerBase::Indexes, F...>
{
  Op m_op;
  public:
  typedef ComposerBase::Indexes, F...> Base;
  typedef decltype(m_op((*(F*)NULL)(0)...)) result_t;
  Composer(Op op, F const &... fs) : m_op(op), Base(fs...) {;}
  result_t operator()(int x) const
  {
    return MakeCall(x, typename IndexTupleBuilder::Indexes());
  }
  private:
  template
  result_t MakeCall(int x, IndexesTuple const&) const
  {
    return m_op(DataHolder::m_data(x)...);
  }
};

Также этот класс можно было бы реализовать на базе кортежей из STL (std::tuple). В этом случае в классе DataHolder нет необходимости, а реализация композитора будет следующей:

template
class TupleComposer
{
  Op m_op;
  std::tuple m_fs;
  public:
  typedef decltype(m_op((*(F*)NULL)(0)...)) result_t;
  TupleComposer(Op op, F... fs) : m_op(op), m_fs(fs ...) {;}
  result_t operator()(int x) const
  {
    return MakeCall(x, typename IndexTupleBuilder::Indexes());
  }
  private:
  template
  result_t MakeCall(int x, IndexesTuple const&) const
  {
    return m_op(std::get(m_fs)(x)...);
  }
};

Такой вариант выглядит несколько проще.
Ещё некоторое количество хитростей.
Раскрытие пакета параметров в контексте «список инициализации» предоставляет программисту достаточно большую свободу действий, т. к. в этом случае паттерном может быть полноценное выражение. Например, сумму переданных в качестве аргументов чисел можно посчитать так:

template
void ignore(T ...) {;}
template
int CalcSum(T ... nums)
{
  int ret_val = 0;
  ignore(ret_val += num ...);
}

Проверить, есть ли среди переданных чисел положительные – так:

template
bool HasPositives(T ... nums)
{
  bool ret_val = true;
  ignore(ret_val = ret_val && nums >= 0 ...);
}

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

Подводя итог, можно сказать, что шаблоны с переменным количеством параметров – очень мощное средство, появившееся в языке C++. Они лишены очевидных недостатков существующих сейчас списков типов или иных эмуляций подобного поведения и позволяют относительно небольшим объёмом кода выражать достаточно сложные концепции. Приведённые в этой статье конструкции можно сравнить с аналогичными, выполненными в рамках существующего стандарта. Для этого можно заглянуть в исходные файлы boost::bind, boost::function, boost::tuple. Но они не лишены и некоторых недостатков. Главный из них – ограниченное число контекстов, в которых пакеты параметров могут раскрываться. В частности, пакеты не могут раскрываться внутри лямбда-функций. Соответствующий запрос направлен в комитет по стандартизации, но будет ли этот запрос удовлетворён? Кроме того, пакеты не могут раскрываться в выражения иначе, нежели через промежуточные функции, к элементам пакета нельзя обращаться по индексу или связывать с ним какой-либо уникальный в рамках пакета ключ. Возвращаясь к началу статьи, хотелось бы отметить, что variant-типы с помощью шаблонов с переменным количеством параметров описать будет достаточно сложно. Но для этого в новом стандарте появилось другое нововведение, т. н. «unrestricted unions». Это объединения, не обладающие ограничениями, присущим этим конструкциям в существующем стандарте.

Это, пожалуй, всё, что можно сказать про этот инструмент. Приведённые здесь примеры могут быть откомпилированы версией 4.4 или 4.5 компилятора gcc. Проверялись они с помощью gcc 4.5.

#1

[...] admin пишет: Можно применить множественное наследование от классов, непосредственно хранящих данные заданного типа: template struct DataHolder { T m_data; }; template class Composer : public DataHolder … …. Подводя итог, можно сказать, что шаблоны с переменным количеством параметров – очень мощное средство, появившееся в языке C++. Они лишены очевидных недостатков существующих сейчас списков типов или иных эмуляций подобного поведения и … [...]

Вы должны войти для того, чтобы оставить комментарий.