C++ 模板相关内容往期回顾:
初始模板:C++泛型编程与模板核心知识全解析
模板进阶:C++ 模板进阶:非类型参数、特化与分离编译全解析
目录
一、模板里的黑魔法——可变参数模板
1.1 先从概念聊起——什么是可变参数与参数包
1.1.1 从C库的printf引入
1.1.2 模板的可变参数
1.2 可变参数模板到底怎么用
1.2.1 递归函数方式展开参数包(包扩展)
1.2.2 包扩展的终止函数的设计为何重要
1.2.3 强化对参数包的理解——另外一种调用场景分析
1.2.4 总结——可变参数模板就是模板中的模板
1.3 标准库里的可变参数模板——emplace 系列接口
1.3.1 C++11 新增的 emplace 系列
1.3.1.1 说好的“更高效”,到底高在哪儿?
1.3.1.2 push_back与emplace_back调用上的异同
1.4 在模拟list中看emplace_back的运行过程
二、新的类功能
2.1 默认的移动构造和移动赋值
2.2 成员变量声明时给缺省值
2.3 default和delete
2.4 final与override
三、STL中的一些变化
一、模板里的黑魔法——可变参数模板
1.1 先从概念聊起——什么是可变参数与参数包
1.1.1 从C库的printf引入
梦开始的地方,还要数C语言里的printf。这应该是不少人接触到的第一个可变参数函数,支持传入任意数量的参数,完成格式化输出。

但C语言这套可变参数的实现比较朴素,底层是靠宏和栈空间遍历参数,局限性不小,也没有类型安全保障。我们接下来要讲的模板可变参数,是完全不同的实现逻辑,能力和灵活性都要高出一个量级。
1.1.2 模板的可变参数
C++11正式把可变参数能力带进了模板体系,支持定义参数数量可变的函数模板和类模板。这一串数目不固定的参数,统一叫做参数包,具体分成两类:
- 模板参数包:代表零个或多个模板类型参数;
- 函数参数包:代表零个或多个函数形参。
写法核心就是省略号…,规则很好记:
template <class …Args> void Func(Args… args) {}
template <class …Args> void Func(Args&… args) {}
template <class …Args> void Func(Args&&… args) {}
模板参数列表里的class… /typename…,表示后面跟着零到多个类型;函数形参列表里,类型名后加 …,就代表零到多个参数对象。和普通模板一致,函数参数包也支持左值引用、右值引用写法,实例化时同样遵循引用折叠规则。
说穿了可变参数模板没那么玄乎,底层逻辑和普通模板一脉相承:编译器会根据传入的参数类型和数量,实例化出对应版本的函数,所有工作都在编译期完成。
这里补充一个配套的实用操作符:sizeof…
这是C++11专门为参数包新增的编译期操作符,功能很直接:返回参数包里包含的参数个数。
#include<iostream>
#include<string>
using namespace std;
template<class …Args>
void print(Args&& … args){
cout << sizeof…(args) << endl;
}
int main(){
int i = 0;int j = 1;double k = 2;
char l = 3;string m = "123456";
print(i);
print(i, j);
print(i, j, k, l, m, 1, 9, "546");
return 0;
}
上面这个用万能引用实现的print函数,可以接收任意类型、任意数量、不分左右值的参数。三次调用分别传入 1个、2个、8个参数,sizeof…(args) 就会对应输出1、2、8,整个计算过程在编译阶段就全部完成了。
1.2 可变参数模板到底怎么用
光靠sizeof…拿到参数个数肯定不够用。实际开发里,我们真正需要的是把参数包里的每一个元素都取出来逐个处理。 但参数包不是普通数组,没法用args[i]按下标直接访问。在C++11的标准下,最经典、最主流的展开方式,就是编译期递归法。
1.2.1 递归函数方式展开参数包(包扩展)
这是最符合模板思维的展开方式,核心思路很朴素:把参数包拆成第一个参数Head+剩余参数包 Tail两部分,每次处理第一个参数,剩下的继续递归调用,一层层把整个参数包剥开。
#include <iostream>
#include <string>
using namespace std;
// 1. 递归终止函数:参数包为空时匹配,作为递归出口
void ShowList(){ cout << endl;}
// 2. 递归展开函数:每次拆出第一个参数处理,剩余参数包继续递归
template <class T, class …Args>
void ShowList(T x, Args… args){
cout << x << " ";
ShowList(args…);
}
// 3. 可变参数模板入口
template <class …Args>
void Print(Args… args){
ShowList(args…);
}
int main(){
Print(1, string("xxxxx"), 2.2);
return 0;
}
我们顺着调用链路走一遍,就能彻底看懂展开逻辑:
1.2.2 包扩展的终止函数的设计为何重要
有人看见上面那段代码,八成要问了:“递归调用嘛,在函数里头塞个终止条件不就完了,犯得着这么折腾?”
不对,这跟咱们平时写的递归压根不是一回事儿。模板里的这套“递归”,本质上是一个编译期递归实例化的过程,就像下面这张图展示的那样:
编译的时候,它会不断递归,生出参数个数各不相同的函数模板,再依照参数类型,把这些模板一一实例化成对应的函数。
到了运行的时候,则沿着这条已经铺好的实例化链,挨个儿调用那些早就生成妥当的函数。
所以,那个不起眼的终止函数void ShowList(){ cout << endl; }其实必不可少。没有它充当递归的出口,编译器在一层层把参数包“剥”干净、面对空荡荡的参数包时,根本找不到匹配的函数原型,干脆直接撂挑子报错。

1.2.3 强化对参数包的理解——另外一种调用场景分析
来看下面这行代码:
Arguments(GetArg(args)…);
它的意思,是把一个参数包传给Arguments函数。有意思的地方在于,这个参数包里的每一个参数,都是GetArg函数调用的返回值。而GetArg一回只能接一个参数,所以实际上它会被调用 sizeof…(args)次,每次老老实实地从args参数包里按顺序领走一个参数。
这个过程,编译器在底层其实又悄悄帮你生成了好多函数调用,展开之后大概就长这样: Arguments(GetArg(1), GetArg(2), GetArg(3)…);
template <class T>
int GetArg(const T& x){
cout << x << " ";
return 0;
}
template <class …Args>
void Arguments(Args… args){}
template <class …Args>
void Print(Args… args)
{
// 注意 GetArg 必须返回一个实实在在的对象(哪怕只是个 int),
// 才能拼出一个参数包喂给 Arguments。
Arguments(GetArg(args)…);
}
1.2.4 总结——可变参数模板就是模板中的模板
说来说去,可变参数模板骨子里就是一个更灵活的模板。你想啊,如果不支持可变模板参数,想支持不同个数的参数,我就得吭哧吭哧手写五个、六个重载函数。也就是说,这玩意儿,是“模板的模板”,帮你自动生成模板。
结合前面的包展开,可以这样理解:编译器在实例化可变参数模板的时候,其实有两大步(有时编译器偷个懒,一步就给你捏完了)。另外,像之前那样一层层剥参数包产生出来的函数,严格来说不能叫“递归调用”,而应该看作函数重载。更准确的说法是:编译期递归式地生成,运行期本质靠的是重载。

我还想多啰嗦两句: 可能有小伙伴嘀咕,C++这设计也太挫了吧,参数包展开干吗不搞成“底层用一个容器装着所有参数,再遍历展开”?其实,不是不想,是真做不到,C++17以前,根本不允许往一个容器里塞不同类型的参数。
1.3 标准库里的可变参数模板——emplace 系列接口
1.3.1 C++11 新增的 emplace 系列
template <class… Args> void emplace_back (Args&&… args);
template <class… Args> iterator emplace (const_iterator position, Args&&… args);
C++11 之后,STL容器新增了emplace系列接口,一个个全都是可变参数模板。它们在功能上能完全覆盖原来的push和insert系列,但emplace还多出一手新玩法:假设容器是container<T>,emplace可以直接接收那些用来构造T对象的参数,在很多场景下,就能直接在容器的空间上把对象“就地”造出来,省掉不必要的折腾。
一句话,emplace_back普遍更高效,所以推荐今后尽量用emplace系列取代insert和push系列。
1.3.1.1 说好的“更高效”,到底高在哪儿?
好多小伙伴看完上面那句“emplace_back比push_back快”,心里还是云里雾里。咱们直接掰开揉碎,看核心逻辑,说白了,就是少了一次搬运。

push_back:图中左上角那块:push_back的参数类型是写死的value_type(这儿假设咱们把它显式实例化成string)。 你扔一个字符串字面量"1111111111"进去,编译器一瞧,类型不匹配,一个是const char*,一个是 string。于是编译器就暗地里先拿这个字面量造出一个string临时对象。然后,push_back再把这个临时对象拷贝(或移动)进list的新节点里。代价?平白无故多出一个临时对象,再加一次构造调用。
emplace_back:再瞅左下角,emplace_back可就灵光多了。它本身是个可变参数模板,参数类型一直等到调用那一刻才拍板。 你照旧传"1111111111",它直接把这const char*原封不动地一路透到底,交给list node的构造函数。最终,在容器已经申请好的那块内存上,直接原地调用string的构造函数。没有中间商赚差价,对象就这么在目的地“合体”了。
所以说,emplace_back又快又直接,能上emplace的地方,就别再惦记push和insert了。
1.3.1.2 push_back与emplace_back调用上的异同
1. 和push_back一样——左值走拷贝,右值走移动 先看代码和输出,然后再细说。
int main()
{
list<pair<bit::string, int>> lt1;
// 跟 push_back 一样,左值 → 拷贝构造
pair<bit::string, int> kv("苹果", 1);
lt1.emplace_back(kv);
cout << "**********************" << endl;
// 跟 push_back 一样,右值 → 移动构造
lt1.emplace_back(move(kv));
cout << "**********************" << endl;
return 0;
}
测试输出:
string(char* str) – 构造
string(const string& s) — 拷贝构造
**************************
string(string&& s) — 移动构造
**************************
行为跟push_back完全一致:传一个左值进去,它老老实实调用拷贝构造把pair “抄”进节点里;一旦转成右值(move),它就机灵地走移动构造,省去一次深拷贝。
2. 和push_back不同——带不带{},规矩差很多
这里头有个很容易踩的坑:push_back的参数个数、类型是写死的,所以直接传多个构造参数它不认,必须用大括号{}包成一个整体。 emplace_back刚好反过来,它能直接收一堆构造参数,原地搭出对象,可你要是随手给它 {…}这种初始化列表,它瞬间懵圈。因为{"苹果", 1}这种大括号包裹的东西没有确定的类型,模板参数Args根本推导不出来。结论就是:emplace_back不支持列表构造。
来看对比代码:
int main()
{
list<pair<bit::string, int>> lt1;
// emplace_back 直接传构造参数,这种玩法 push_back 想都别想
lt1.emplace_back("苹果", 1);
// push_back 必须用 {} 把参数拢起来,这才能构造出 pair
lt1.push_back({"苹果", 1});
// 但是 emplace_back 反过来就不行
// lt1.emplace_back({ "苹果", 1 }); // ← 直接编译报错
lt1.emplace_back("苹果", 1);
lt1.push_back({ "苹果", 1 }); // ← 正确:借助初始化列表构造 pair
return 0;
}
push_bac吃大括号,emplace_back吃裸参数,别喂反了。
1.4 在模拟list中看emplace_back的运行过程
咱们自己搓了一个list,把emplace_back接口用可变参数模板实现了一遍。整个思路不复杂:参数包沿着调用链一路往下传,最终溜进节点(ListNode)的构造函数里,直接去匹配容器元素类型T的构造参数。这就坐实了前面说的,emplace支持直塞构造参数,在某些场景下不用先捏一个临时对象再搬来搬去,而是在容器的内存上就地构造,自然更高效。
有个容易踩坑的细节:如果形参是Args&&… args这样的万能引用参数包,往下传递时记得顺手配上完美转发,写法是std::forward<Args>(args)…。如果偷懒不加这一步,包展开之后那些右值引用变量表达式就退化成左值,本打算“移动”的悄悄变成了“拷贝”,性能就白瞎了。

下面把整段模拟代码贴出来,重点盯着emplace_back模板、insert模板以及节点构造函数里对完美转发的用法。
// Test.cpp
#include "List.h"
#include<iostream>
using namespace std;
int main(){
bit::list<pair<string, int>> lt1;
lt1.emplace_back("苹果", 1);
return 0;
}
//List.h
#pragma once
namespace bit
{
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
{
public:
typedef ListNode<T> Node;
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 newnode cur
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;
};
}
二、新的类功能
2.1 默认的移动构造和移动赋值
在原来的C++类里,有6个“你不写编译器也会偷偷生成”的默认成员函数:构造函数、析构函数、拷贝构造函数、拷贝赋值运算符重载、取地址运算符重载、const 取地址运算符重载。 C++11在此基础上又塞了两个新成员:移动构造函数 和 移动赋值运算符重载。
不过,编译器可不是每次都这么好心。它只在你没自己实现移动构造,同时也没碰过析构函数、拷贝构造、拷贝赋值重载这三个中的任何一个时,才会自动生成一个默认移动构造。 (标准委员会觉得这仨是绑在一根绳上的蚂蚱,你只要自己动了其中一个,大概率另外两个你也得自己搞定。)
默认移动构造的行为大致是这样的:
-
对于内置类型成员,直接按字节逐成员拷贝,也就是浅拷贝。别指望有什么“夺取资源”的移动语义,那是不存在的。
-
对于自定义类型成员,就看这个成员自己有没有实现移动构造。实现了,就调它的移动构造;没实现,乖乖退回去调拷贝构造。
默认移动赋值重载的生成条件和行为,完全是一个模子刻出来的:同样要求你没手写移动赋值,也没碰那三个“绑定”函数;内置类型逐字节拷贝,自定义类型能移动就移动,不能就拷贝。
另外有个重要的“连带后果”:一旦你提供了移动构造或者移动赋值,编译器就不会再自动帮你生成拷贝构造和拷贝赋值了。
2.2 成员变量声明时给缺省值
成员变量声明时给的缺省值,本质上是给初始化列表准备的备胎:如果你没有在初始化列表里显式初始化某个成员,它就会拿这个缺省值顶上。 这个知识点在类和对象部分已经聊过了,更详细的拆解可以精准投送到那篇文章的第3.4小节,讲得非常细:零基础也能学会的C++类与对象————保姆级教程(下篇)
2.3 default和delete
C++11给你更好的手段来控制那些“默认函数”的生杀大权。
有时候,你明明想用某个默认函数,可编译器因为某些原因愣是不生成。比如,你手写了拷贝构造,移动构造就自动不给了。这时候,只要用显式告诉编译器:“喂,这个你还是得给我生成一下。”
class MyClass {
public:
// 强制编译器生成默认构造函数
MyClass() = default;
// 因为存在这个带参构造,编译器原本不会自动生成 MyClass()
MyClass(int x) : _val(x) {}
private:
int _val;
};
MyClass obj; // 多亏了 default,这里才没报错
反过来,如果你想禁止某些默认函数,C++98时代得把它们设成private,并且只声明不定义,让人一调就链不上。到了C++11,简单粗暴多了:直接在声明后面加个=delete ,这门函数就算“删除了”,编译器根本不生成对应版本。
class NoCopy {
public:
NoCopy() = default;
// 禁止拷贝构造和拷贝赋值
NoCopy(const NoCopy&) = delete;
NoCopy& operator=(const NoCopy&) = delete;
};
NoCopy a;
// NoCopy b = a; // 这行直接编译挂掉
标准库里就有现成的例子:IO 流对象就是不允许拷贝的,背后的实现用的就是=delete这套机制。

2.4 final与override
关于final和override,继承和多态那章我们已经掰开揉碎了聊过,这里不再从头啰嗦,直接精准投送到那篇文章的第五点,需要补课的可以空降过去:深入理解C++多态(构成条件、重写机制与底层实现)
不过我发现有些小伙伴对final有那么一丢丢小误会,趁这儿多嘴澄清几句:
- final并不是只能往类上怼,它还能用来修饰虚函数,而且只能修饰虚函数(或是已经加了 override 的虚函数)。普通非虚函数想加final?编译器第一个不答应。
- 修饰类的时候,final的意思很干脆,“这个类到此为止,别想再继承我”。
- 修饰虚函数的时候,final的意思就稍微绕一点:它不是说这个函数不能被继承,子类照样会把函数乖乖继承下来,只是不能重写(override)它了。相当于在虚函数的门口挂了块“到此为止,禁止改造”的牌子。
这么一区分,就不会把“不能重写”和“不能继承”搞混了。
三、STL中的一些变化
C++11给STL塞了几个新容器,下图里圈出来的那些就是。不过说句实话,真正在日常开发里频繁露脸的,还得是unordered_map和unordered_set。这俩我们前面已经花大力气拆解过了,剩下的几个混个脸熟就行,知道有这么个东西,用到的时候再去翻文档也不迟。

容器这边新增的接口也不少,但真正称得上“重量级”的,基本都和右值引用、移动语义脱不开关系,push/insert/emplace系列接口、移动构造、移动赋值,还有 initializer_list 版本的构造等等,这些前头都细细聊过了。至于像cbegin/cend这种只读迭代器之类的小修小补,属于“无关痛痒但偶尔救急”的范畴,真要用了现查文档绰绰有余。还有容器的范围for遍历,也在容器那部分讲透了,这里就不再赘述。
说到底,C++11给STL带来的这些变化,核心其实就两条线:
一条是“能省就省”,右值引用、移动语义,还有emplace系列,都是在帮我们少做不必要的拷贝,把性能抠到骨头里;
另一条是“想用就有”,新容器、初始化列表构造、default/delete、范围for这些,纯粹让代码写起来更顺手,表达意图更直白。
这一整套组合拳打下来,STL没换骨架,但骨血里的效率和安全又往上拔了一截。后面的内容,咱们就继续顺着这条路走下去。




