【C++11篇(三)】可变参数模版详解

能从冰封的土地里
培育出十万朵怒放的蔷薇
C++11篇(三) —— 可变参数模版详解
本篇文章将由浅入深,带你循序渐进地掌握C++11中的可变参数模版,全程干货,坐稳发车~ ദ്ദി˶ー̀֊ー́ )✧
1. 什么是可变参数模板
C++11 引入的可变参数模板,简单说就是:让函数模板和类模板可以接受任意数量、任意类型的参数。以前我们只能为每种参数个数写一个重载,现在一个模板就能搞定。
比如:
template <class... Args>
void Func(Args... args) {}
这里 Args 就是一个模板参数包,可以代表 0 个或多个模板类型参数;而 args 是函数参数包,对应着 0 个或多个函数参数。
和普通模板参数不一样,参数包前面有省略号 ...。class... Args 表示 Args 是一组类型的集合(可以没有,也可以有很多)。Args... args 则是一组值的集合。
2. 基本语法与原理
2.1 声明一个可变参数函数模板
看几个常见的写法:
// 1. 按值传递参数包
template <class ...Args>
void Func(Args... args) {}
// 2. 左值引用
template <class ...Args>
void Func(Args&... args) {}
// 3. 万能引用(也叫转发引用)
template <class ...Args>
void Func(Args&&... args) {}
重点解释一下第三种,Args&&... args。这里的 && 不是单纯的右值引用,因为 Args 是模板参数包,在类型推导时会发生引用折叠:
- 如果传入左值
int x,Args被推导为int&,折叠后int& &&->int&,所以参数是左值引用。- 如果传入右值
string("xxx"),Args被推导为string,那么就是string&&,绑定右值。
这样一来,第三种写法几乎可以接收任何类型的实参,同时保留其左右值属性,非常灵活。
2.2 获取参数包的大小:sizeof…
我们可以用 sizeof...(args) 来获取参数包中元素的个数,它是一个编译期常量。
一个简单的例子:
template<class ...Args>
void Print(Args&&... args)
{
cout << sizeof...(args) << endl; // 计算参数包中参数的个数
}
int main()
{
double x = 1.11;
Print(); // 包里有0个参数
Print(1); // 包里有1个参数
Print(1, string("xxx")); // 包里有2个参数
Print(1, string("xxx"), x); // 包里有3个参数
return 0;
}
2.3 实例化原理——编译器悄悄做了重载
可变参数模板并不是运行时动态处理参数,而是编译时通过模板实例化,生成一个个具体参数个数的重载函数。
以上面 Print 为例,编译器会根据调用,实例化出以下四个函数(应用引用折叠规则后):
void Print(); // 无参数
void Print(int&& arg1); // 一个右值
void Print(int&& arg1, string&& arg2); // 两个右值
void Print(int&& arg1, string&& arg2, double& arg3); // 两个右值+一个左值引用
从本质上说,可变参数模板只是在类型泛化的基础上,又给我们叠加了一层“数量泛化”。没有它,我们得手动写一堆重载:
void Print();
template<class T1>
void Print(T1&& arg1);
template<class T1, class T2>
void Print(T1&& arg1, T2&& arg2);
template<class T1, class T2, class T3>
void Print(T1&& arg1, T2&& arg2, T3&& arg3);
// 还有更多...
有了可变参数模板,一个 template<class... Args> void Print(Args&&... args) 就全搞定了,不仅代码量骤减,而且扩展性大大提高。
2.4 不能用循环或下标访问参数包
很多人会尝试这样访问参数包:
// 错误示例1:范围for
template<class ...Args>
void Print(Args&&... args)
{
for (auto&& e : args...) // 错误!参数包不是容器,不能这样遍历
cout << e << " ";
}
或者:
// 错误示例2:下标访问
template<class ...Args>
void Print(Args&&... args)
{
for (size_t i = 0; i < sizeof...(args); i++)
cout << args[i] << " "; // 错误!args不是数组,没有下标操作
}
这两种想法很自然,但都是错误的。原因在于:
- 参数包是编译时概念,在代码编译后,
args...就变成了具体的多个参数,根本不存在一个运行时的集合让你遍历。- 下标操作需要运行期索引,而每个参数的类型可能不同,编译器无法在运行期才去决定访问哪个类型。
那我们想处理每一个参数该怎么办?答案就是下一节的重头戏——包扩展。
3. 包扩展(参数包展开)
包扩展是我们能对参数包做的唯一“处理动作”。扩展一个包,就是把它拆成一个一个的元素,并对每个元素应用某个模式,最后生成一个逗号分隔的列表。
基本语法就是在模式的右边放一个省略号 ... 来触发扩展。
举个直观的模式:GetArg(args)...,如果 args 是 x, y, z,就会扩展成 GetArg(x), GetArg(y), GetArg(z)。
3.1 递归展开
最常见的包扩展方式是用递归函数,每次从参数包里获得第一个参数,剩下的参数包继续递归,直到包为空时调用一个终止函数。
看例子:
// 递归终止函数:参数包为空时匹配此版本
void ShowList()
{
cout << endl;
}
// 递归函数:每次输出第一个参数,剩余参数包继续递归
template<class T, class... Args>
void ShowList(T&& x, Args&&... args)
{
cout << x << " ";
ShowList(args...); // 包扩展:将 args 原封不动地展开传递给下一次调用
}
// 对外接口:转发参数包
template<class ...Args>
void Print(Args&&... args)
{
ShowList(args...);
}
调用 Print(1, string("xxxx"), 2.2); 时发生了什么?我们来跟踪编译器的推导过程:
Print(1, string("xxxx"), 2.2)调用ShowList(args...),此时args...是1, string("xxxx"), 2.2。- 第一次匹配
ShowList(T&& x, Args&&... args):
T推导为int,x绑定到1;
- 剩余的
Args推导为[string, double],参数包args包含string("xxxx")和2.2。
- 输出
1后调用ShowList(string("xxxx"), 2.2)。
- 第二次匹配:
T推导为string,x绑定"xxxx";
- 剩余
Args为double,args包含2.2。
- 输出
xxxx后调用ShowList(2.2)。
- 第三次匹配:
T推导为double,x绑定2.2;
- 剩余
Args为空包。
- 输出
2.2后调用ShowList()。
ShowList()匹配到无参的终止版本,输出换行。
整个过程中,编译器递归地生成了三份 ShowList 的实例:
// 实例1:三参数版本 int, string, double
void ShowList(int&& x, string&& a, double&& b)
{
cout << x << " ";
ShowList(a, b); // 传给两参数版本
}
// 实例2:两参数版本 string, double
void ShowList(string&& x, double&& b)
{
cout << x << " ";
ShowList(b); // 传给单参数版本
}
// 实例3:单参数版本 double
void ShowList(double&& x)
{
cout << x << " ";
ShowList(); // 传给空参终止函数
}
而顶层的 Print 实质上变成了:
void Print(int x, string y, double z)
{
ShowList(x, y, z);
}
这样,通过编译时递归,我们实现了对每个参数的处理。整个过程没有循环,没有运行期开销,全部在编译期完成。

3.2 更灵活的模式扩展:直接作为实参列表
包扩展不仅能在递归中传递包本身,还可以对一个函数调用应用模式,比如把所有元素经过某个函数转换后再传给另一个函数。
看这个例子:
template <class T>
const T& GetArg(const T& x)
{
cout << x << " ";
return x;
}
template <class ...Args>
void Arguments(Args... args)
{}
template<class ...Args>
void Print(Args... args)
{
// 对 args 中每一个元素调用 GetArg,然后把所有返回值组成参数包传给 Arguments
Arguments(GetArg(args)...);
}
当调用 Print(1, string("xxxx"), 1.1); 时,包扩展 GetArg(args)... 会展开成:
Arguments(GetArg(1), GetArg(string("xxxx")), GetArg(1.1));
所以 GetArg 会被依次调用,输出每个参数,并返回它的引用,这些返回值再作为实参传给 Arguments。
编译器最终会将该模板实例化为类似这样的函数:
void Print(int x, string y, double z)
{
Arguments(GetArg(x), GetArg(y), GetArg(z));
}
这种模式非常强大,可以用在各种需要对参数包进行逐元素转换的场景。
3.3 包扩展的通用理解
一句话总结:扩展一个包,就是把它拆开,每个元素套上左边的模式,最后拼成一个逗号分隔的列表。
常见形式:
args...:直接展开成arg1, arg2, arg3&args...:展开成&arg1, &arg2, &arg3(取地址)func(args)...:展开成func(arg1), func(arg2), func(arg3)std::forward<Args>(args)...:展开成std::forward<Arg1>(arg1), std::forward<Arg2>(arg2), ...(完美转发)
4. emplace 系列接口 —— 直接构造,高效插入
C++11 给 STL 容器增加了 emplace 系列接口,比如 vector、list 都支持:
template <class... Args>
void emplace_back(Args&&... args);
template <class... Args>
iterator emplace(const_iterator position, Args&&... args);
4.1 功能对比:push_back vs emplace_back
emplace_back 不仅兼容 push_back 的全部功能,还多了一个“绝活”:可以直接传递构造元素所需的参数,在容器内部直接构造对象,省去临时对象的创建和移动/拷贝。
这里我们用之前模拟实现的命名空间为 hj 的string类来测试:
list<hj::string> lt;
hj::string s1("1111");
// 1. 传左值 —— 与 push_back 一样,都是拷贝构造
lt.emplace_back(s1); // 一次拷贝构造
lt.push_back(s1); // 一次拷贝构造
// 2. 传右值 —— 与 push_back 一样,都是移动构造
lt.emplace_back(move(s1)); // 一次移动构造
lt.push_back(move(s1)); // 一次移动构造
// 3. 直接传字符串字面量 "1111" —— push_back 做不到这种!
lt.emplace_back("1111");
// 内部直接用 "1111" 作为参数,在节点构造时调用 hj::string(const char*),一步到位
// 而 push_back("1111") 必须先隐式转换出一个临时 hj::string,
// 再移动/拷贝到容器,多一次临时对象的构造和析构
lt.push_back("1111");

对于 pair 这类复合类型,优势更明显:
list<pair<hj::string, int>> lt1;
pair<hj::string, int> kv("苹果", 1);
// 正常传 pair 对象
lt1.emplace_back(kv); // 拷贝构造
lt1.emplace_back(move(kv)); // 移动构造
// 而 emplace_back 允许直接传 pair 的构造参数!
lt1.emplace_back("苹果", 1);
// 节点内直接调用 pair< hj::string, int >("苹果", 1),原地构造

这种方式不仅少写了 make_pair 或临时对象,效率也更高,减少了不必要的临时对象产生。
4.2 实现原理:完美转发参数包
emplace_back 为什么这么厉害?我们模拟实现一个简化的 list,看看它的内部是如何工作的。
核心在于:
- 可变参数模板接收任意构造参数;
- 通过
std::forward完美转发这些参数到节点的构造函数;- 节点构造函数再转发给存储类型的构造函数,直接原地构造。
代码骨架如下:
namespace hj
{
template<class T>
struct ListNode
{
ListNode<T>* _next;
ListNode<T>* _prev;
T _data;
ListNode(T&& data)
:_next(nullptr)
, _prev(nullptr)
, _data(move(data))
{ }
template <class... Args>
ListNode(Args&&... args)
: _next(nullptr)
, _prev(nullptr)
, _data(std::forward<Args>(args)...)
{ }
};
template<class T, class Ref, class Ptr>
struct ListIterator
{
typedef ListNode<T> Node;
typedef ListIterator<T, Ref, Ptr> Self;
Node* _node;
ListIterator(Node* node)
:_node(node)
{ }
// ++it;
Self& operator++()
{
_node = _node->_next;
return *this;
}
Self& operator--()
{
_node = _node->_prev;
return *this;
}
Ref operator*()
{
return _node->_data;
}
bool operator!=(const Self& it)
{
return _node != it._node;
}
};
template<class T>
class list
{
typedef ListNode<T> Node;
public:
typedef ListIterator<T, T&, T*> iterator;
typedef ListIterator<T, const T&, const T*> const_iterator;
iterator begin()
{
return iterator(_head->_next);
}
iterator end()
{
return iterator(_head);
}
void empty_init()
{
_head = new Node();
_head->_next = _head;
_head->_prev = _head;
}
list()
{
empty_init();
}
void push_back(const T& x)
{
insert(end(), x);
}
void push_back(T&& x)
{
insert(end(), move(x));
}
iterator insert(iterator pos, const T& x)
{
Node* cur = pos._node;
Node* newnode = new Node(x);
Node* prev = cur->_prev;
// prev newnode cur
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = cur;
cur->_prev = newnode;
return iterator(newnode);
}
iterator insert(iterator pos, T&& x)
{
Node * cur = pos._node;
Node* newnode = new Node(move(x));
Node* prev = cur->_prev;
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = cur;
cur->_prev = newnode;
return iterator(newnode);
}
template <class... Args>
void emplace_back(Args&&... args)
{
insert(end(), std::forward<Args>(args)...);
}
template <class... Args>
iterator insert(iterator pos, Args&&... args)
{
Node* cur = pos._node;
Node* newnode = new Node(std::forward<Args>(args)...);
Node* prev = cur->_prev;
// prev newnode cur
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = cur;
cur->_prev = newnode;
return iterator(newnode);
}
private:
Node* _head;
};
}
注意转发时的写法是:
std::forward<Args>(args)...
这行代码会展开成:
std::forward<Arg1>(arg1), std::forward<Arg2>(arg2), ...
为什么必须用 std::forward?
因为 Args&&... args 是万能引用,传入的左值会推导为左值引用,右值会推导为右值引用。但形参 args 本身是个变量,它一定是左值。如果不加 forward,参数包展开后直接传 args...,那么所有参数都会变成左值,丢失了原始的值类别。
完美转发保证了:你传左值我就给你左值,你传右值我就给你右值,原封不动地传递下去。
4.3 与 push_back 的调用路径对比
以 lt.emplace_back("1111") 和 lt.push_back("1111") 为例:
push_back 路径:
"1111"首先隐式转换成临时string对象(调用string(const char*)构造)。push_back(const T&)或push_back(T&&)接收这个临时对象。- 在节点内调用
data的拷贝或移动构造。- 析构临时对象。
emplace_back 路径:
emplace_back接收const char*参数。- 通过完美转发,将
const char*原样传给节点的构造函数。- 节点构造函数在初始化列表里直接调用
string(const char*)构造data。- 完全没有临时对象。
很明显,emplace_back 少了一次构造和一次析构,所以效率更高。尤其在构造对象代价高昂的时候,提升非常可观。
5. 面试常见考点
5.1 面试可能被问到的问题
Q1:可变参数模板如何展开参数包?
A:编译器通过递归模板实例化,每次分离出第一个参数和剩余参数包,直到包为空时调用终止函数。这是编译时递归,不依赖运行期循环。
Q2:sizeof...(args) 和普通 sizeof 有什么区别?
A:sizeof... 是专门用于参数包的运算符,返回参数个数,是编译期常量。普通 sizeof 用于求类型或表达式的字节大小。
Q3:为什么不能用循环遍历参数包?
A:参数包是编译时概念,编译实例化后就是独立的多个参数,不存在运行时可遍历的集合,且各个参数类型可能不同,无法用统一的循环体处理。
Q4:emplace_back 比 push_back 快吗?什么时候快?
A:当你直接传递构造参数而非现成对象时,emplace_back 可以避免创建临时对象,从而减少一次拷贝/移动构造和析构。如果你已经有一个现成的对象,两者性能一样。
Q5:完美转发参数包的写法及必要性?
A:写法是 std::forward<Args>(args)...。必要性:因为形参都是左值,不转发就丢失了右值信息,导致本该移动的变成拷贝。
结语:
今天的内容到这里就结束了,希望你能有所收获~
干货整理到手抖,觉得有用的话,赏个三连回回血?__(:ᗤ」ㄥ)_ _
更多推荐


所有评论(0)