C++ Template-4 可变参数模板

通过大量例子介绍了非类型模板参数。你可以学习到如何使用非类型模板参数,了解非类型模板参数使用的限制。

不是本系列第一篇文章,推荐阅读完前面的文章再看本文,以下是本系列文章目录。

1. 函数模板

2. 类模板

3. 非类型模板参数

4. 可变参数模板(本文)

5. 基础技术

6. 移动语意与 enable_if<>

4. 可变参数模板

从 C++ 11 开始,模板可以接受数量可变的参数。这个特性允许你在参数类型和参数数量都不确定的情况下使用模板。一个典型的应用是通过 class 或者 framework 传递一组数量和类型都不确定的参数。另一个应用是提供泛型代码处理数量任意且类型也任意的参数。

4.1 可变参数模板

可以将模板参数定义为可以接受多个模板参数。这种模板被称为可变参数模板(variadic templates)

4.1.1 可变参数模板实例

例如,你可以通过以下代码调用 print() 函数来打印多个类型不确定的模板参数

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>

void print ()
{
}

template<typename T, typename... Types>
void print(T firstArg, Types... args)
{
std::cout << firstArg << '\n'; // print first argument
print(args...); // call print() for remaining arguments
}

如果传入的参数是一个或者多个,就会调用这个函数模板,这里通过把第一个模板参数单独声明,就可以先打印第一个参数,然后再递归地调用 print() 来打印剩余的参数。这些被称为 args 的剩余参数,是一个函数参数包(function parameter pack)

1
void print(T firstArg, Types... args)

使用了模板参数包(template parameter pack)定义了 Type

1
template <typename T, typename... Types>

为了结束递归,重载了没有参数的非模板函数 print(),它会在参数包为空的时候被调用。

比如这样一个调用:

1
2
std::string s("world");
print(7.5, "hello", s);

会输出以下结果:

1
2
3
7.5
hello
world

因为这个调用会首先扩展成:

1
print<double, char const*, std::string>(7.5, "hello", s);

其中:

  • firstArg 的值是 7.5,其类型 Tdouble
  • args 是一个可变模板参数,它包含类型是 char const*"hello" 和类型是 std::string"world"

在打印了 firstArg 对应的值 7.5 之后,继续调用 print() 打印剩余的参数,这时 print() 被扩展为:

1
print<char const*, std::string>("hello", s);

其中:

  • firstArg 的值是 "hello",其类型 Tchar const*

  • args 是一个可变模板参数,它包含的参数类型是 std::string

在打印了 firstArg 对应的 "hello"之后,继续调用 print() 打印剩余的参数,这时 print() 被扩展为:

1
print<std::string>(s);

其中:

  • firstArg 的值是 "world",其类型 Tstd::string
  • args 是一个空的可变模板参数,它没有任何值。

这样在打印了firstArg 对应的 "world" 之后,就会调用被重载的不接受参数的非模板函数 print(),从而结束了递归。

4.1.2 重载可变参数和非可变参数模板

上面的例子也可以这样实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>

template<typename T>
void print (T arg)
{
std::cout << arg << '\n'; // print passed argument
}

template<typename T, typename... Types>
void print (T firstArg, Types... args)
{
print(firstArg); // call print() for the first argument
print(args...); // call print() for remaining arguments
}

也就是说,如果两个函数模板的区别只有尾部的参数包的话,没有参数包的函数模板会被优先选择。

4.1.3 运算符 sizeof…

C++11为可变参数模板引入了一个新的 sizeof 运算符:sizeof...。它会扩展成参数包所包含的参数数目。因此,

1
2
3
4
5
6
7
template <typename T, typename... Types>
void print(T firstArg, Types... args)
{
std::cout << sizeof...(Types) << '\n'; // print number of remaining types
std::cout << sizeof...(args) << '\n'; // print number of remaining args
...
}

在第一个模板参数传入 print() 之后,会将剩余的参数数目打印两次。如你所见,运算符 sizeof... 既可以调用于模板参数包也可以调用于函数参数包。

可能让我们认为我们不调用空参数列表的 print() 函数来结束递归:

1
2
3
4
5
6
7
8
9
template <typename T, typename... Types>
void print(T firstArg, Types... args)
{
std::cout << firstArg << '\n';
if (sizeof...(args) > 0) // error if sizeof...(args) == 0
{
print(args...);// and no print() for no arguments declared
}
}

但是这一方法是错误的,因为通常 if 语句中的两个分支都会被实例化。是否调用实例化出的代码实在运行期间决定的,是否实例化是在编译期间决定的。由于以上原因,如果你对最后一个参数调用 print() 函数模板,虽然 args... 为空,单数如果没有定义不接受参数的 print() 函数,会出现错误。

不过从 C++17 开始,可以使用编译期的 if 语句,这样通过一些稍微不同的语法,就可以实现前面的功能。8.5 节(未写)会具体讨论

4.2 折叠表达式

从 C++17 开始,提供了一整计算参数包(可以有初始值)所有参数的二元运算符。

例如,下面一个函数返回所有传递参数的和:

1
2
3
4
5
template <typename... T>
auto foldSum (T... s)
{
return (... + s); // ((s1 + s2) + s3) + ...
}

如果参数包是空的,这个表达式通常是 ill-formed(对于运算符 && ,结果是 true;对于运算符 || ,结果是 false;对于运算符 , ,结果是 void())。

表4.1 列举了可用的折叠表达式:

几乎所有的二元运算符都可以用于折叠表达式(参见12.4.6 节(未写))。例如,你可以使用折叠表达式和二元运算符 ->* 去遍历一个二叉树的路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// define binary tree structure and traverse helpers:
struct Node
{
int value;
Node* left;
Node* right;
Node(int i=0) : value(i), left(nullptr), right(nullptr) {}
//...
};
auto left = &Node::left;
auto right = &Node::right;

// traverse tree, using fold expression:
template <typename T, typename... TP>
Node* traverse(T np, TP... paths)
{
return (np ->* ... ->* paths); // np ->* paths1 ->* paths2 ...
}

int main()
{
// init binary tree structure:
Node* root = new Node{0};
root->left = new Node{1};
root->left->right = new Node{2};
//...
// traverse binary tree:
Node* node = traverse(root, left, right);
//...
}

这里 (np ->* ... ->* paths) 使用了折叠表达式从 np 开始遍历了 paths 中所有可变元素。

通过这样一个使用了初始化其的折叠表达式,我们可能会想到这样简化地打印可变参数木模板的所有参数:

1
2
3
4
5
template <typename..Types>
void print(Types const&... args)
{
(std::cout << ... << args) << '\n';
}

不过这样打印的参数包中的所有参数并不会有空白符分开它们。为了完成这个,你需要额外定义一个类模板,它可以在所有要打印的参数后面追加一个空格:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template <typename T>
class AddSpace
{
private:
T const& ref; // refer to argument passed in constructor
public:
AddSpace(T const& r): ref(r) {}
friend std::ostream& operator<< (std::ostream& os, AddSpace<T> s)
{
return os << s.ref << ' '; // output passed argument and a space
}
};

template <typename... Args>
void print(Args... args)
{
(std::cout << ... << AddSpace(args) ) << '\n';
}

注意在表达式 AddSpace(args) 中使用了类模板参数推导(参见2.9 节),相当于使用了 AddSpace<Args>(args),它会给每一个参数创建一个引用了该参数的 AddSpace 的对象,当这个对象用于输出的时候,会在后面追加一个空格。

更多细节参见 12.4.6 节(未写)

4.3 可变参数模板的应用

可变参数模板在泛型库的开发中起到了很大的作用,比如 C++ 标准库。

一个典型的应用就是转发任意类型和数量的参数。下面的例子就运用了这个特性:

  • 向一个由只能指针管理的,在堆中创建的对象的构造函数传递参数:

    1
    2
    // creat shared pointer to complex<float> initialized by 4.2 and 7.7
    auto sp = std::make_shared<std::complex<float>>(4.2, 7.7);
  • 向由库启动的 thread 传递参数:

    1
    std::thread t(foo, 42, "hello"); // call foo(42, "hello") in a separate thread
  • 向一个被 push_backvector 的对象的构造函数传递参数:

    1
    2
    3
    4
    std::vector<Customer> v;
    ...
    v.emplace("Tim", "Jovi", 1962);
    // insert a Customer initialized by three argument

通常,我们通过移动语意对参数进行完美转发(perfectly forward)参见 6.1 节),可以像下面这样声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
namespace std
{
template <typename T, typename... Args> shared_ptr<T>;
make_shared(Args&&... args);
class thread
{
public:
template<typename F, typename... Args>
explicit thread<F&& f, Args&&... args);
...
};

template <typename T, typename Allocator = allocator<T>>
class vector
{
public:
template<typename... Args> reference emplace_back(Args&&...args);
...
};
}

注意,之前对于常规的模板参数的规则也使用于可变参数模板参数。比如,如果参数是按值传递的,那么这个参数会被拷贝,类型会被 decay(数组变为指针),如果它是按引用传递的,那么参数是实参的引用,并且类型不会 decay

1
2
3
4
// args are copies with decayed types:
template <typename... Args> void foo(Args... args);
// args are nondecayed references to passed objects:
template <typename... Args> void bar(Args const&... args);

4.4 可变参数模板和可变参数表达式

除了上面的这些例子,参数包也可以用于其他地方,比如表达式,类模板,using 声明,推导指南。完整的列表参见 12.4.2 节(未写)

4.4.1 可变参数表达式

除了转发所有参数外,你还可以做别的事情,比如对他们进行计算。

下面例子的函数现将参数包中的所有参数翻倍,然后把他们传递到 print() 函数中:

1
2
3
4
5
template <typename... T>
void printDouble(T const&... args)
{
print(args + args...);
}

如果你这样调用它:

1
printDoubled(7.5, std::string("hello"), std::complex<float>(4,2));

等价于下面的调用(除了构造函数方面不同):

1
2
print(7.5 + 7.5, std::string("hello") + std::string("hello"),
std::complex<float>(4,2) + std::complex<float>(4,2));

如果你只想向所有参数 +1,省略号 ... 不能紧跟在数值后面:

1
2
3
4
5
6
7
template <typename... T>
void addOne(T const&... args)
{
print(args + 1...); // ERROR: 1... is a literal with too many decimal points
print(args + 1 ...); // OK
print((args + 1)...); // OK
}

编译期的表达式也可以像上面那样包含参数包。比如下面这个返回参数包所有参数是否是同一类型的函数模板:

1
2
3
4
5
template <typename T1, typename.. TN>
constexpr bool isHomogeneous(T1, TN...)
{
return (std::is_same<T1,TN>::value && ...); // since C++17
}

这是对折叠表达式(参见 4.2 节)的一种应用:

1
isHomogeneous(43, -1, "hello")

上面的语句被扩展为:

1
std::is_same<int,int>::value && std::is_same<int,char const*>::value

当然返回结果是 false。而对于:

1
isHomogeneous("hello", "", "world", "!")

结果则是 true,因为所有参数的类型都被推导为 char const*(注意参数类型发生了 decay,因为它们是按值传递的)。

4.4.2 可变参数指数

下面的例子中的函数通过使用一组可变下标来访问传递给第一个参数中对应的元素:

1
2
3
4
5
template <typename C, typename... Idx>
void printElems(C const& coll, Idx... idx)
{
print(coll[idx]...);
}

当调用:

1
2
std::vector<std::string> coll = {"good", "times", "say", "bye"};
printElems(coll,2,0,3);

等同于调用:

1
print(coll[2], coll[0], coll[3]);

你也可以将非类型模板参数声明为参数包,例如:

1
2
3
4
5
template <std::size_t... Idx, typename C>
void printIdx(C const& coll)
{
print(coll[Idx]...);
}

可以这样调用:

1
2
std::vector<std::string> coll = {"good", "times", "say", "bye"};
printIdx<2,0,3>(coll);

效果和前面的一个例子相同。

4.4.3 可变参数类模板

可变参数模板可以是类模板。一个重要的例子是,通过任意数量的模板参数特例化类的相关成员类型:

1
2
3
4
template <typename... Elements>
class Tuple;

Tuple<int, std::string, char> t; // t can hold integer, string, ans character

这部分会在 25节(未写)介绍。

另外一个例子是可以用来特例化对象可能包含的类型:

1
2
3
4
template <typename.. Types>
class Variant;

Variant<int, std::string, char> v; // v can hold integer, string, or character

这部分会在 26节(未写)介绍。

你也可以将类定义成代表一组表的类型:

1
2
3
4
// type for arbitray number of indices:
template <std::size_t...>
struct Indices{
};

这可以被用来定义一个 print() 函数来打印 std::arraystd::tuple 中的元素,可以使用编译期的 get<>() 方法获取特定下标的元素:

1
2
3
4
5
template<typename T, std::size_t... Idx>
void printByIdx(T t, Indices<Idx...>)
{
print(std::get<Idx>(t)...);
}

可以像下面这样使用这个模板:

1
2
std::array<std::string, 5> arr = {"Hello", "my", "new", "!", "World"};
printByIdx(arr, Indices<0, 4, 3>());

或者像下面这样:

1
2
auto t = std::make_tuple(12, "monkeys", 2.0);
printByIdx(t, Indices<0, 1, 2>());

这是迈向元编程(meta-programming)的第一步,在 8.1 节(未写)23 节(未写)会有介绍。

4.4.4 可变参数推导指南

甚至推导指引(详见 2.9 节)可以是可变参数的。比如在 C++ 标准库中,为 std::array 定义了如下的推导指引:

1
2
3
4
5
6
namespace std
{
template<typename T, typename... U> array(T, U...)
-> array<enable_if_t<(is_same_v<T, U> && ...), T>,
(1 + sizeof...(U));
}

比如下面的初始化:

1
std::array a{42, 45, 77};

将指引中的 T 推导成元素的类型,U... 也会被推导为剩余元素对应的类型。因此,元素的总数量是 1 + sizeof...(U)

1
std::array<int, 3> a{42, 45, 77};

array 的第一个元素的 std::enable_if<> 操作是一个折叠表达式(和 4.4.1 节 中的 isHomogeneous()相似),可以展开如下:

1
is_same_v<T, U1> && is_same_v<T, U2> && is_same_v<T, U3> ...

如果结果不是 true (i.e. 所有元素类型不是全部相同的),推导指引会被丢弃而且总的类型推导会失败。这样,标准库确保了所有的类型必须是同一类型时推导指引才会推导成功。

4.4.5 可变参数基类和 using

最后,思考下面的这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <string>
#include <unordered_set>

class Customer
{
private:
std::string name;
public:
Customer(std::string const& n) : name(n) { }
std::string getName() const { return name; }
};

struct CustomerEq
{
bool operator() (Customer const& c1, Customer const& c2) const
{
return c1.getName() == c2.getName();
}
};

struct CustomerHash
{
std::size_t operator() (Customer const& c) const
{
return std::hash<std::string>()(c.getName());
}
};

// define class that combines operator() for variadic base classes:
template <typename... Bases>
struct Overloader : Bases...
{
using Bases::operator()...; // OK since C++17
};

int main()
{
// combine hasher and equality for customers in one type:
using CustomerOP = Overloader<CustomerHash,CustomerEq>;

std::unordered_set<Customer,CustomerHash,CustomerEq> coll1;
std::unordered_set<Customer,CustomerOP,CustomerOP> coll2;
//...
}

这里,我们首先定义了一个 Customer 类和一个独立的函数对象用来计算 Customer 对象的 hash 值并且比较。通过:

1
2
3
4
5
template<typename... Bases>
struct Overloader : Bases...
{
using Bases::operator()...; // OK since C++17
};

我们可以定义一个从个数不定的基类派生出一个类,为每个基类引入了 operator() 的声明。通过:

1
using CustomerOP = Overloader<CustomerHash, CustomerEq>;

我们运用了这个特性从 CustomerHashCustomerEq 中派生出了 CustomerOP 并且派生类中包含两个基类 operator() 的实现。

26.4 节(未写)介绍了另外一个运用此技术的应用。

4.5 小结

  • 通过使用参数包,模板可以有任意多个任意类型的参数。
  • 为了处理参数,你需要一个递归函数和一个非可变参数函数来终止递归。
  • 运算符 sizeof... 用来计算参数包中参数的个数。
  • 可变参数模板一个经典的应用时传递任意多个任意类型的参数。
  • 通过使用折叠表达式,你可以将运算应用到参数包中的所有参数。