Те, кто читал книгу Андрея Александреску «Современное программирование
на C++» знают, что существует обширный класс задач (в области
метапрограммирования с использованием шаблонов), когда шаблону при
инстанцировании необходимо указать переменное (заранее неизвестное)
количество аргументов. Типичные примеры таких задач:
— Описание кортежей (tuples)
— Описание типов наподобие вариантов (variants)
— Описание функторов (в этом случае перечень типов аргументов зависит от сигнатуры функции)
— Классификация типов по заранее заданным множествам
— и т. п.
В каждой такой задаче точное количество типов, передаваемых
соответствующему шаблону в качестве аргументов, заранее определить
сложно. И, вообще говоря, зависит от желания и потребностей того, кто
намеревается использовать соответствующий шаблонный класс.
В рамках действующего стандарта С++ сколь-нибудь удобного решения таких
задач не существует. Шаблоны могут принимать строго определённое
количество параметров и никак иначе. А. Александреску (в упомянутой выше
книге) предлагает общее решение, основанное на т. н. «списках типов», в
котором типы представлены в виде односвязного списка, реализованного
посредством рекурсивных шаблонов. Альтернативным решением (используемом,
например, в boost::variant и boost::tuple) является объявление
шаблонного класса с большим количеством параметров, которым (всем, кроме
первого) присвоено некоторое значение по умолчанию. Оба этих решения
являются половинчатыми и не охватывают весь спектр возможных задач. По
этому, для устранения недостатков существующих решений и упрощения кода
новый стандарт предлагает С++-разработчикам новый вариант объявления
шаблонов? «шаблоны с переменным количеством параметров» или, в
оригинале, «variadic templates».
Простые варианты использования
Объявление шаблона с переменным количеством параметров выглядит следующим образом:
template<typename ... Types>
class VariadicTemplate
{
};
подобным же образом объявляются шаблоны с переменным количеством параметров-не типов: template<int ... Ints>
class VariadicTemplate
{
};
Здесь необходимо отметить, что эмуляция подобного в рамках текущего
стандарта — весьма нетривиальная задача (если не сказать, что
невыполнимая).
Помимо шаблонных классов, можно также объявлять шаблонные функции с
переменным количеством параметров. Подобные объявления могут выглядеть
следующим образом: template<typename ... Type>
void printf(const char* format, Type ... args);
Очевидно, что такого рода параметры шаблонов (они называются «пакетами
параметров» или «parameters packs») не могут использоваться везде, где
могут использоваться обычные (одиночные) параметры шаблонов. Допустимо
использование пакетов параметров в следующих контекстах:
В перечислении базовых классов шаблона (base-specifier-list);
В списке инициализации членов данных в конструкторе (mem-initializer-list);
В списках инициализации (initializer-list)
В списках параметров других шаблонов (template-argument-list);
В спецификации исключений (dynamic-exception-specification);
В списке атрибутов (attribute-list);
В списке захвата лямбда-функции (capture-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;
// Специализируем шаблон для указателя на простые функции. При этом указываем, что пакет параметров содержит тип возвращаемого
// значения ® и аргументы (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 ...);
}
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;
// Объявляем два варианта оператора вызова функции - для случая, когда функтор используется как "замыкание", и когда объект,
// для которого вызывается метод, передаётся первым аргументом
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);
Приведённый пример достаточно прост и наглядно демонстрирует основные
возможности шаблонов с переменным количеством параметров. Анализируя его
можно определить следующую общую схему использования шаблонов с
переменным количеством параметров:
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)};
};
Конструкция sizeof … (Nums), приведённая в этом примере, используется
для получения количества параметров в пакете. В ней Nums — это имя
пакета параметров. К сожалению, дизайн шаблонов с переменным количеством
параметров таков, что это — единственное, что можно сделать с пакетом
параметров (помимо его непосредственно раскрытия). Получить параметр из
пакета по его индексу, например, или совершить какие-либо более сложные
манипуляции в рамках проекта нового стандарта невозможно.
При раскрытии пакетов можно применять более сложные паттерны. Например, в приведённом выше коде можно сделать следующую замену: template<int ... Nums>
int NumsPack<Nums ...>::m_nums[] = {Nums * 10 ...};
что приведёт к выводу на экран другой последовательности:
10 20 30 40 50
Вообще, конкретный вид паттерна зависит от контекста, в котором он
раскрывается. Более того, паттерн может содержать упоминание более
одного пакета параметров. В этом случае все упомянутые в паттерне пакеты
будут раскрываться синхронно, а потому количество фактических
параметров в них должно совпадать.
Такая ситуация может возникать в случае, когда требуется определить
кортежи значений. Предположим, необходимо организовать универсальный
функтор-композитор, задача которого — передать в некоторую функцию
результаты выполнения заданных функций для некоего аргумента. Пусть
существует некоторый набор функций: 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<int ... Idxs, typename ... F>
struct ComposerBase<IndexesTuple<Idxs...>, F ...> : public DataHolder<Idxs, F>...
{
ComposerBase(F ... fs) : DataHolder<Idxs, F>(fs)... {;}
};
Также этот класс можно было бы реализовать на базе кортежей из STL
(std::tuple). В этом случае в классе DataHolder не было бы
необходимости. В этом случае реализация композитора будет следующей: template<typename Op, typename ... F>
class TupleComposer
{
Op m_op;
std::tuple<F ...> m_fs;
public:
typedef decltype(m_op((*(F*)NULL)(0)...)) result_t;
Раскрытие пакета параметров в контексте «список инициализации»
предоставляет программисту достаточно большую свободу действий, т. к. в
этом случае паттерном может быть полноценное выражение. Например, сумму
переданных в качестве аргументов чисел можно посчитать так: template<typename ... T>
void ignore(T ...) {;}
template<typename ... T>
int CalcSum(T ... nums)
{
int ret_val = 0;
ignore(ret_val += nums ...);
return ret_val;
}
Проверить, есть ли среди переданных чисел положительные — так: template<typename ... T>
bool HasPositives(T ... nums)
{
bool ret_val = true;
ignore(ret_val = ret_val && nums >= 0 ...);
return ret_val;
}
Но при использовании такого метода нельзя забывать, что
последовательность вычислений аргументов, строго говоря, не определена, и
в каком именно порядке будут выполнены операции — заранее сказать
нельзя.
Подводя итог, можно сказать, что шаблоны с переменным количеством
параметров — очень мощное средство, появляющееся в языке C++. Они лишены
очевидных недостатков существующих сейчас списков типов (или иных
эмуляций подобного поведения), позволяют относительно небольшим объёмом
кода выражать достаточно сложные концепции. Приведённые в этой статье
конструкции можно сравнить с аналогичными, выполненными в рамках
действующего стандарта (для этого можно заглянуть в исходные файлы
boost::bind, boost::function, boost::tuple). Но они не лишены и
некоторых недостатков. Главный из них — ограниченное число контекстов, в
которых пакеты параметров могут раскрываться. В частности, пакеты не
могут раскрываться внутри лямбда-функций (соответствующий запрос
направлен в комитет по стандартизации, но будет ли этот запрос
удовлетворён?), пакеты не могут раскрываться в выражения, чтобы можно
было написать, например, так:
auto result = args + ...;
к элементам пакета нельзя обращаться по индексу.