第二十四章:C++11(中)

第二十四章:C++11(中)
一、可变参数模板核心基础
1. 前置铺垫:C语言的可变参数
课程从C语言的可变参数引入,最典型的例子就是printf函数:
- 支持传入任意个数的参数,可传1个、2个、n个参数,配合格式化字符串完成打印。
- 底层本质:通过二维数组存储传入的参数,取出后按照格式类型解析打印。
- 局限性:仅支持参数个数可变,无法支持参数类型可变,且类型安全无保障。
C++的模板体系中,需要更强大的能力:不仅要支持参数个数可变,还要支持任意多个类型的参数,由此诞生了可变参数模板。
2. 核心概念:参数包
可变参数模板的核心是参数包,分为两类,二者数量一一对应(一个类型对应一个参数对象):
| 类型 | 定义 | 作用 |
|---|---|---|
| 模板参数包 | template <class ...Args> 或 template <typename ...Args> |
代表0到n个模板参数,即任意多个不同的类型 |
| 函数参数包 | Args&&... args |
代表0到n个函数参数,与模板参数包的类型、数量完全匹配 |
【补充说明】参数包名可以自定义,通常用
Args/args(对应arguments的缩写),也可以用任意合法名称,不是固定语法。
3. 核心语法规则
(1)省略号...的位置规范(老师重点强调+吐槽的易错点)
C++11对...的位置设计不统一,极易写错,必须严格遵守:
- 模板参数包声明:
...在类型关键字class/typename之后,参数包名之前,例如template <class ...Args> - 函数参数包声明:
...在类型之后,参数名之前,例如Args&&... args - 参数包展开传参:
...在参数包名之后,例如func(args...)
(2)万能引用与引用折叠
函数参数包的Args&&... args不是右值引用,而是万能引用(通用引用),这是C++11引用折叠规则的核心应用:
- 当传入左值实参时,模板参数推导为左值引用,最终折叠为左值引用
- 当传入右值实参时,模板参数推导为原生类型,最终为右值引用
- 价值:一个模板函数替代左值引用、右值引用两个版本的重载,极大简化泛型代码
【补充知识】引用折叠规则:只有
右值引用&& + 右值引用&&会折叠为右值引用,其余所有组合(左值& + 任意、右值&& + 左值&)都会折叠为左值引用。万能引用仅在模板参数推导场景生效,普通函数的T&&就是右值引用。
(3)sizeof...运算符
C++11新增的编译期运算符,用于编译期计算参数包中参数的个数,语法为sizeof...(args)或sizeof...(Args)。
- 核心特性:编译期完成计算,结果是编译期常量,不会在运行时执行。
- 示例:
template <class ...Args>
void Print(Args&&... args)
{
cout << sizeof...(args) << endl; // 打印参数包的参数个数
}
int main()
{
Print(); // 输出0,空参数包
Print(1); // 输出1
Print(1, string("hello")); // 输出2
return 0;
}
4. 可变参数模板的底层原理
可变参数模板是模板的进一步泛型化,本质是编译器在编译期的自动代码生成:
- 没有可变参数模板时,要支持0~n个参数的函数,需要手动写0个、1个、2个…n个参数的模板重载,代码冗余且无法覆盖无限个数。
- 有了可变参数模板,只需要写一个模板函数,编译器会根据调用时传入的参数个数、参数类型,自动实例化出对应个数和类型的具体函数。
- 例如调用
Print(1.1, string("hello"), 2),编译器会自动实例化出void Print(double, string, int)的具体函数,和手动写的重载函数底层完全一致。
【核心结论】可变参数模板没有改变C++模板的本质,只是把“手动写N个模板重载”的工作交给了编译器,实现了真正的“任意个数、任意类型”的泛型支持。
5. 绝对禁止的错误用法
很多初学者会想当然地用数组下标访问参数包,例如:
template <class ...Args>
void Print(Args... args)
{
for (size_t i = 0; i < sizeof...(args); i++)
{
cout << args[i] << " "; // 语法错误,编译不通过
}
}
老师明确解释了不支持的原因:
- 参数包中的参数是不同类型的,C++11没有支持存储不同类型的容器,无法像数组一样统一访问。
- 这种写法是运行时逻辑,而可变参数模板的解析必须在编译期完成,二者本质冲突。
- C++17才新增了
std::variant等支持异构类型的容器,也新增了更简洁的参数包解析语法,但C++11完全不支持这种写法。
二、可变参数包的解析与扩展方式
C++11中,要获取参数包中的每个元素并处理,只能通过包扩展的方式实现,课程讲解了两种主流的解析方案。
1. 方案一:编译期递归 + 函数重载
这是C++11解析参数包的标准方案,核心逻辑是递归拆解:每次提取参数包的第一个元素处理,剩下的n-1个元素继续递归,直到参数包为空。
(1)实现的三个核心要素
- 递归函数模板:包含一个普通模板参数(接收第一个参数)+ 一个模板参数包(接收剩余参数),处理当前参数后,把剩余参数包继续递归调用。
- 递归终止函数:无参的同名重载函数,当参数包为空时匹配,作为递归的结束条件。
- 参数包展开:传参时通过
args...把剩余参数包完整传递给下一层递归。
(2)完整实现示例
// 递归终止函数:参数包为空时调用
void ShowList()
{
cout << endl;
}
// 递归模板函数:拆解参数包
template <class T, class ...Args>
void ShowList(T x, Args... args)
{
cout << x << " "; // 处理当前第一个参数
ShowList(args...); // 剩余参数包继续递归
}
// 对外封装的可变参数模板
template <class ...Args>
void Print(Args... args)
{
ShowList(args...);
}
int main()
{
double x = 2.2;
Print(); // 输出换行,匹配无参终止函数
Print(1); // 输出1 换行
Print(1, string("hello")); // 输出1 hello 换行
Print(1.1, string("world"), x); // 输出1.1 world 2.2 换行
return 0;
}
(3)底层编译期推演逻辑
以Print(1, string("hello"), 2.2)为例,编译器在编译期会完成以下实例化:
- 第一步:实例化
ShowList(int, string, double),处理1,递归调用ShowList(string("hello"), 2.2) - 第二步:实例化
ShowList(string, double),处理"hello",递归调用ShowList(2.2) - 第三步:实例化
ShowList(double),处理2.2,递归调用ShowList() - 第四步:匹配无参的
ShowList(),递归终止
【易错警告】绝对不能用运行时
if判断终止递归!
错误写法:template <class T, class ...Args> void ShowList(T x, Args... args) { cout << x << " "; if(sizeof...(args) == 0) return; // 运行时判断,编译不通过 ShowList(args...); }错误原因:模板实例化是编译期行为,
if是运行时逻辑。编译器在实例化ShowList(double)时,会继续实例化ShowList(),但此时没有无参的终止函数,直接报“未找到匹配的函数”错误。必须用无参重载函数作为编译期的递归终止条件。
(4)进阶优化:万能引用+完美转发
上面的基础版本是传值传参,会有拷贝开销,实际开发中必须用万能引用+完美转发优化:
// 递归终止函数
void ShowList()
{
cout << endl;
}
// 万能引用版本,保持参数的值属性
template <class T, class ...Args>
void ShowList(T&& x, Args&&... args)
{
cout << forward<T>(x) << " "; // forward完美转发,保持左值/右值属性
ShowList(forward<Args>(args)...); // 参数包完美转发
}
template <class ...Args>
void Print(Args&&... args)
{
ShowList(forward<Args>(args)...);
}
2. 方案二:逗号表达式+辅助函数(非递归包扩展)
这是一种无需递归的解析方案,核心是利用参数包的展开规则,把每个参数交给辅助函数处理,无需递归拆解。
(1)实现原理
- 定义一个辅助处理函数
GetArg,接收单个参数,完成打印/处理逻辑,返回一个无关紧要的值(比如int)。 - 定义一个空的占位函数
Arguments,接收任意个数的参数,仅用于触发参数包展开。 - 调用
Arguments(GetArg(args)...),编译器会把参数包展开为Arguments(GetArg(arg1), GetArg(arg2), ..., GetArg(argn)),每个参数都会调用一次GetArg,完成处理。
(2)完整实现示例
// 辅助处理函数:处理单个参数
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)
{
// 展开为 Arguments(GetArg(arg1), GetArg(arg2), ...)
Arguments(GetArg(args)...);
cout << endl;
}
int main()
{
Print(1, string("hello"), 2.2); // 输出1 hello 2.2 换行
return 0;
}
【补充说明】这种方案无需递归,代码更简洁,但灵活性不如递归方案,适合对每个参数做相同的简单处理的场景。
三、emplace系列接口的底层原理与使用详解
可变参数模板最核心、最广泛的工业级应用,就是STL容器的emplace系列插入接口,这也是课程的重点内容。
1. 接口概述
C++11中,所有STL容器都实现了emplace系列接口,和传统的插入接口一一对应:
| 传统插入接口 | 对应的emplace接口 | 容器 |
|---|---|---|
| push_back | emplace_back | vector、list、deque等 |
| push_front | emplace_front | list、deque、forward_list等 |
| insert | emplace | 所有序列式容器、关联式容器 |
| push | emplace | stack、queue、priority_queue等 |
2. emplace与传统插入接口的核心区别
emplace系列的核心优势,来自于可变参数模板+直接构造,而非传统的“先构造对象,再拷贝/移动到容器”。
老师通过多个场景的对比,明确了二者的效率差异:
场景1:传入左值/右值的容器元素对象
- 示例:
lt.push_back(s1)、lt.emplace_back(s1)(左值);lt.push_back(move(s1))、lt.emplace_back(move(s1))(右值) - 结论:二者效率、用法完全一致,没有任何区别。
- 原因:push_back有左值引用、右值引用两个版本,emplace_back的万能引用也会实例化出对应的版本,最终都会调用拷贝构造/移动构造,开销完全相同。
场景2:传入元素对象的构造参数(隐式类型转换场景)
这是emplace系列唯一有性能优势的场景,也是设计的核心初衷。
我们以list<bit::string>为例,对比push_back("hello world")和emplace_back("hello world"):

场景3:多参数构造的复合对象场景
以list<pair<bit::string, int>>为例,这是开发中最常用的场景之一:
push_back({"hello", 1}):会先构造pair临时对象,再移动构造到容器中,最后析构临时对象。emplace_back("hello", 1):直接把两个参数传递给pair的构造函数,在容器节点上直接构造pair,无临时对象开销。
【易错警告】emplace多参数构造时,不能用花括号包裹参数!
错误写法:lt.emplace_back({"hello", 1});
错误原因:花括号会被编译器推导为std::initializer_list,而initializer_list只能存储同类型的元素,const char*和int是不同类型,无法推导,直接编译报错。多参数必须直接传入,不能用花括号。
3. 核心结论与使用规范
【终极结论】emplace系列接口只有在直接传入构造容器元素的参数包时,才会有性能优势;直接传入左值/右值的元素对象时,和push/insert系列效率完全一致,没有任何区别。
网上“emplace一定比push_back快”的说法是完全错误的,必须用对方法才能发挥优势。
工业级使用规范:
- 优先使用emplace系列接口替代传统的push/insert接口,养成习惯。
- 尽量直接传入构造元素的参数,而非先构造对象再传入,最大化发挥emplace的性能优势。
- 多参数构造时,直接传入参数,不要用花括号包裹。
四、自定义list容器的emplace系列实现
课程结合自定义的list容器,从0到1迭代实现了emplace系列接口,完整还原了STL的底层设计逻辑。
迭代步骤1:基础版本的push_back与insert(左值+右值版本)
最初的实现,为了支持左值和右值,需要写两个版本的重载,代码冗余,对应List.h的初始版本:
(1)list节点的构造函数
template<class T>
struct list_node
{
list_node* _next = nullptr;
list_node* _prev = nullptr;
T _data;
// 左值构造:接收const左值引用,拷贝构造_data
list_node(const T& x)
:_next(nullptr)
,_prev(nullptr)
,_data(x)
{}
// 右值构造:接收右值引用,移动构造_data
list_node(T&& x = T())
:_next(nullptr)
, _prev(nullptr)
, _data(move(x))
{}
};
(2)list容器的插入接口
template<class T>
class list
{
typedef list_node<T> Node;
public:
// 左值版本push_back
void push_back(const T& x)
{
insert(end(), x);
}
// 右值版本push_back
void push_back(T&& x)
{
insert(end(), move(x));
}
// 左值版本insert
void insert(iterator pos, const T& x)
{
Node* cur = pos._node;
Node* prev = cur->_prev;
Node* newnode = new Node(x); // 调用左值构造
// 链表节点链接逻辑
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = cur;
cur->_prev = newnode;
++_size;
}
// 右值版本insert
void insert(iterator pos, T&& x)
{
Node* cur = pos._node;
Node* prev = cur->_prev;
Node* newnode = new Node(move(x)); // 调用右值构造
// 链表节点链接逻辑
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = cur;
cur->_prev = newnode;
++_size;
}
// 其他接口省略...
};
迭代步骤2:优化为万能引用版本(替代双版本重载)
利用万能引用+完美转发,用一个模板函数替代左值、右值两个版本的重载,减少代码冗余:
template<class T>
class list
{
typedef list_node<T> Node;
public:
// 万能引用版本push_back:一个函数替代左值+右值两个版本
template<class X>
void push_back(X && x)
{
insert(end(), forward<X>(x)); // 完美转发,保持值属性
}
// 万能引用版本insert
template<class X>
void insert(iterator pos, X&& x)
{
Node* cur = pos._node;
Node* prev = cur->_prev;
// 完美转发x给节点构造函数
Node* newnode = new Node(forward<X>(x));
// 链表节点链接逻辑
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = cur;
cur->_prev = newnode;
++_size;
}
// 其他接口省略...
};
// 节点的构造函数也优化为万能引用版本
template<class T>
struct list_node
{
list_node* _next = nullptr;
list_node* _prev = nullptr;
T _data;
// 万能引用构造函数,替代左值+右值两个版本
template<class X>
list_node(X&& x)
:_next(nullptr)
,_prev(nullptr)
,_data(forward<X>(x))
{}
};
迭代步骤3:实现emplace系列接口(可变参数模板版本)
最终版本,实现emplace和emplace_back接口,核心是可变参数模板+完美转发,无需解析参数包,直接匹配元素的构造函数,对应最终的List.h:
(1)新增list节点的可变参数模板构造函数
template<class T>
struct list_node
{
list_node* _next = nullptr;
list_node* _prev = nullptr;
T _data;
// 左值构造(保留兼容)
list_node(const T& x)
:_next(nullptr)
,_prev(nullptr)
,_data(x)
{}
// 右值构造(保留兼容)
list_node(T&& x = T())
:_next(nullptr)
, _prev(nullptr)
, _data(move(x))
{}
// 可变参数模板构造函数:emplace的核心
template<class ...Args>
list_node(Args&& ... args)
: _next(nullptr)
, _prev(nullptr)
// 完美转发参数包,直接匹配T的构造函数,无需解析参数包
, _data(forward<Args>(args)...)
{}
};
(2)list容器的emplace系列接口实现
template<class T>
class list
{
typedef list_node<T> Node;
public:
// emplace_back:尾插,直接传构造参数
template<class ...Args>
void emplace_back(Args&&... args)
{
emplace(end(), forward<Args>(args)...);
}
// emplace:任意位置插入,核心实现
template<class ...Args>
void emplace(iterator pos, Args&&... args)
{
Node* cur = pos._node;
Node* prev = cur->_prev;
// 完美转发参数包给节点的可变参数构造函数
Node* newnode = new Node(forward<Args>(args)...);
// 链表节点链接逻辑
prev->_next = newnode;
newnode->_prev = prev;
newnode->_next = cur;
cur->_prev = newnode;
++_size;
}
// 其他接口省略...
};
(3)实现的核心要点
- 无需解析参数包:emplace系列的核心优势之一,就是不需要把参数包的每个元素拆解出来,直接把完整的参数包完美转发给元素的构造函数,由编译器自动匹配对应的构造函数重载。
- 完美转发的必要性:参数包在多层传递过程中,右值会退化为左值,必须通过
forward<Args>(args)...对参数包的每个元素做完美转发,保持其左值/右值属性,否则会调用拷贝构造而非移动构造。 - 通用性:无论元素是单参数构造、多参数构造、无参构造,都能完美支持,真正实现了“任意个数、任意类型”的构造参数传递。
五、C++11 类的新功能
课程第二部分讲解了C++11对类体系的新增功能,核心围绕移动语义、代码简化、类型安全三个方向。
1. 默认成员函数的更新
C++98中,类有6个默认成员函数,C++11新增了移动构造函数和移动赋值运算符重载,总共8个默认成员函数:
- 构造函数
- 析构函数
- 拷贝构造函数
- 拷贝赋值运算符重载
- 取地址运算符重载
- const取地址运算符重载
- 移动构造函数(C++11新增)
- 移动赋值运算符重载(C++11新增)
2. 移动构造/移动赋值的默认生成规则
编译器自动生成默认移动构造/赋值的条件非常苛刻,必须同时满足:
- 用户没有显式定义移动构造函数、移动赋值运算符重载
- 用户没有显式定义拷贝构造函数、拷贝赋值运算符重载、析构函数中的任意一个
【核心逻辑】C++标准认为:如果用户显式定义了拷贝构造、拷贝赋值、析构中的任意一个,说明类需要管理资源,那么移动构造/赋值的逻辑也应该由用户自己实现,编译器不会自动生成。
默认生成的移动构造/赋值的行为
- 内置类型成员:按字节拷贝(浅拷贝)
- 自定义类型成员:先检查该成员是否实现了移动构造/赋值,实现了就调用移动构造/赋值,没有实现就调用拷贝构造/拷贝赋值。
【易错示例】
class Person { public: // 显式定义了拷贝构造,编译器不会生成默认移动构造 Person(const Person& p) :_name(p._name), _age(p._age) {} private: bit::string _name; int _age; }; int main() { Person p1; Person p2 = move(p1); // 不会调用移动构造,只会调用拷贝构造! return 0; }
3. 委托构造
C++11新增的语法,允许同一个类的一个构造函数,调用该类的其他构造函数来完成初始化,简化代码复用,避免重复逻辑。
语法规则
- 委托构造必须在初始化列表中调用其他构造函数,不能在函数体内调用。
- 委托构造的初始化列表中,不能再初始化其他成员变量,因为被委托的构造函数已经完成了所有成员的初始化。
示例
class Person
{
public:
// 基础构造函数:被委托的目标
Person(const char* name = "张三", int age = 1)
:_name(name)
, _age(age)
{}
// 委托构造:调用上面的基础构造函数完成初始化
Person(int i, const char* name = "张三", int age = 1)
:Person(name, age) // 委托构造,必须在初始化列表
{
// 只能在这里做额外的逻辑,不能在初始化列表初始化其他成员
_i = i;
}
private:
bit::string _name;
int _age;
int _i = 0;
};
4. 类成员变量的缺省值
C++11支持给类的非静态成员变量,在声明时直接设置缺省值:
- 如果初始化列表中显式初始化了该成员,就用初始化列表的值,忽略缺省值。
- 如果初始化列表中没有显式初始化,就用声明时的缺省值初始化。
- 编译器自动生成的默认构造函数,也会使用这些缺省值初始化成员。
示例:
class Person
{
private:
bit::string _name = "张三"; // 声明时给缺省值
int _age = 18; // 声明时给缺省值
int _i;
};
5. default关键字:强制生成默认成员函数
default关键字用于强制编译器生成默认的成员函数,即使该函数的自动生成条件不满足,也能强制生成。
适用场景:
- 显式定义了拷贝构造,想要编译器依然生成默认的移动构造。
- 想要显式声明默认构造函数,同时让编译器生成默认实现。
示例:
class Person
{
public:
// 显式定义拷贝构造,编译器不会自动生成移动构造
Person(const Person& p)
:_name(p._name), _age(p._age)
{}
// 强制编译器生成默认的移动构造和移动赋值
Person(Person&& p) = default;
Person& operator=(Person&& p) = default;
private:
bit::string _name;
int _age;
};
6. delete关键字:禁用默认成员函数
delete关键字用于禁用编译器自动生成的成员函数,比C++98的“只声明不实现”更简洁、更安全,编译期就能报错。
最常用的场景:禁用类的拷贝构造和拷贝赋值,让类无法被拷贝。
示例:
// 禁止拷贝的类,比如IO流、线程、锁等
class NonCopyable
{
public:
NonCopyable() = default;
~NonCopyable() = default;
// 禁用拷贝构造和拷贝赋值
NonCopyable(const NonCopyable&) = delete;
NonCopyable& operator=(const NonCopyable&) = delete;
};
【补充说明】C++98的传统方案是把拷贝构造声明为private且不实现,只能在编译期+链接期双重报错,而
delete方案在编译期就能直接报错,更清晰、更安全。
7. final和override关键字
这两个关键字是为了增强C++继承和多态的类型安全,避免人为的编码错误。
(1)final关键字
- 修饰类:该类无法被继承,任何类尝试继承该类都会编译报错。
- 修饰虚函数:该虚函数无法被派生类重写,任何派生类尝试重写该函数都会编译报错。
示例:
// 该类无法被继承
class Base final
{
public:
// 该虚函数无法被重写
virtual void func() final
{
cout << "Base::func()" << endl;
}
};
(2)override关键字
- 放在派生类重写的虚函数后面,强制编译器检查该函数是否正确重写了基类的虚函数。
- 如果函数签名不匹配、基类没有对应的虚函数,直接编译报错,避免人为的拼写错误、参数不匹配等问题。
示例:
class Base
{
public:
virtual void func(int a)
{
cout << "Base::func(int)" << endl;
}
};
class Derived : public Base
{
public:
// 正确重写,编译通过
void func(int a) override
{
cout << "Derived::func(int)" << endl;
}
// 错误示例:函数签名不匹配,编译直接报错
// void func(double a) override { ... }
};
六、C++11 STL的其他变化
1. 新增容器
C++11新增了3个容器,其中只有unordered_map/unordered_set在工业级开发中广泛使用:
| 容器 | 底层实现 | 特点与使用场景 |
|---|---|---|
unordered_map/unordered_set |
哈希表 | 增删查的平均时间复杂度为O(1),效率远高于红黑树实现的map/set;缺点是元素无序。绝大多数场景优先推荐使用。 |
forward_list |
单链表 | 每个节点只有一个后继指针,比list节省内存;仅支持头插头删,不支持反向迭代,尾插尾删效率极低。极少使用。 |
array |
静态数组 | 封装了原生数组,支持迭代器、范围for等STL通用接口;大小固定,在栈上分配内存,效率略高于vector。仅在固定大小数组的场景使用。 |
2. 容器通用接口更新
- 所有容器都新增了右值版本的插入接口、移动构造和移动赋值,支持右值对象的高效插入和容器整体的移动。
- 所有容器都新增了
initializer_list列表初始化的构造函数,支持用{}快速初始化容器。 - 所有容器都新增了emplace系列插入接口(前文已详细讲解)。
- 新增
shrink_to_fit()缩容接口:把容器的容量缩到和元素个数size一致,以时间换空间,释放多余的内存,实际开发中极少使用。 - 所有容器都支持范围for循环,底层通过迭代器实现。
3. swap函数的优化
- C++98的
std::swap:通过拷贝构造和拷贝赋值实现交换,对于string、vector等深拷贝类型,会产生巨大的性能开销。 - C++11的
std::swap:利用移动构造和移动赋值实现,直接交换两个对象的底层资源,无需拷贝数据,时间复杂度为O(1),效率极高。 - 所有STL容器都实现了自己的
swap成员函数,效率更高,优先使用成员函数版本。
更多推荐



所有评论(0)