C++11 是 C++ 的第二个主要版本,也是从 C++98 起最重要的一次更新。你可能更熟悉它当初的名字 C++0x——因为人们原本期待它在 2010 年之前发布,0x 里的 x 就是"未知数"。最终它由 ISO 在 2011 年 8 月 12 日正式采纳。要知道,C++98 和 C++03 几乎没什么差别,而 C++03 与 C++11 之间整整隔了 8 年,是迄今最长的版本间隔——所以你完全可以想象这 8 年里憋了多少东西。从 C++11 开始,C++ 进入了"每 3 年更新一次"的快节奏年代,后面依次是 14、17、20、23。
你要理解为什么这次更新这么"重"。C++98 时代的标准库长期停滞,业界真正在用的不少好东西其实是靠 boost 这个第三方库在客观上"铺路"的:shared_ptr、function、bind、tuple、可变参数模板的雏形……这些你本篇会学到的名字,很多在 C++11 正式标准化之前,就都以 boost 的形式在真实工程里跑了好几年。C++11 本质上是一锅"把业界已验证的实践 + 标准委员会打磨的新语法"的大乱炖。所以它不像 C++98 那样是"新大陆",更像是一次"全民合流"——这也解释了为什么它一上来就是铺天盖地的一大堆特性。
这一篇我会把这颗"憋了 8 年的大招"完整拆给你看。你会遇到一堆新语法、新工具、新规则,但从本质上去理解,它们都在解决同一个问题:让 C++ 既安全、又高效、还好写。放心,这一篇我们不赶进度,一个一个来。而且我保证,每个知识点我都会从"是什么、为什么存在、底层怎么运作、实际怎么跑、踩过什么坑、生活里怎么类比、又能在哪儿用上"这几个角度全都给你掰开,力求你读完"删无可删、无可再问"。
在正式开讲之前,有几样"前置知识"我要先跟你确认,因为后面会一直用到——它们都是我们之前的教程里讲过的。
第一,类、构造函数、拷贝构造、拷贝赋值、析构函数。我们知道一个类有构造、析构、拷贝构造、拷贝赋值这些"默认成员函数",它们搞不定的时候编译器会自己生成。C++11 在这套体系上动了刀,后面你会看到它把"默认可能被生成的成员"从原有的一套扩充到了"八件套"。
第二,引用。我们的引用是"给对象取别名"的语法,C++11 之后,它被正式称为"左值引用",因为 C++11 又新增了"右值引用"。别慌,后面我会把这两者掰开揉碎——尤其是"为什么一个右值被右值引用绑定之后,这个引用变量本人又算左值"这个让所有人头大的点。
第三,模板与仿函数(函数对象)。模板是 C++ 泛型的基石,仿函数是"重载了 operator() 的类"。C++11 的 lambda 和 function,本质都是在玩这两个老朋友——后面讲 lambda 原理时,你会亲眼看到"编译器把 lambda 翻译成一个仿函数类"的全过程。
好,前置知识备齐,我们正式开始。
统一的列表初始化
先看 C++98。那时候 {} 只能用来初始化内置的数组和简单的结构体,规则很窄:
#include <iostream>
using namespace std;
struct Point
{
int _x;
int _y;
};
int main()
{
int array1[] = { 1, 2, 3, 4, 5 }; // 数组用 {} 初始化
int array2[5] = { 0 }; // 数组全部置 0
Point p = { 1, 2 }; // 结构体用 {} 初始化
return 0;
}C++11 想干一件大事:试图实现"一切对象皆可用 {} 初始化"。这种用 {} 的初始化就叫列表初始化(list initialization)。内置类型支持,自定义类型也支持。自定义类型本质上走的是"类型转换"——用 {2025, 1, 1} 先构造一个临时对象,再初始化目标;编译器优化后,很多时候会"合二为一",直接构造,连临时对象都省了。
我们来验证一下。注意下图里 Date 的打印信息:
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
: _year(year), _month(month), _day(day)
{
cout << "Date(int, int, int)" << endl; // 走构造函数
}
Date(const Date& d)
: _year(d._year), _month(d._month), _day(d._day)
{
cout << "Date(const Date&)" << endl; // 走拷贝构造
}
private:
int _year;
int _month;
int _day;
};
int main()
{
// 内置类型也可以用 {} 初始化
int x1 = { 2 };
// 本质:用 {2025,1,1} 构造临时对象,再拷贝给 d1
// 但编译器把"构造临时 + 拷贝构造"优化为"直接构造",所以只会打印一行 Date(int,int,int)
Date d1 = { 2025, 1, 1 };
// d2 引用的是 {2024,7,25} 构造出的临时对象(临时对象的生命周期被延长了)
const Date& d2 = { 2024, 7, 25 };
// 单参数时,C++98 就支持隐式类型转换,可以不用 {}
Date d3 = { 2025 };
Date d4 = 2025;
// 列表初始化可以省略 "="
Date d6 { 2024, 7, 25 };
int x2 { 2 };
return 0;
}你运行上面的代码会发现,d1 只打印了一行 Date(int,int,int),根本没有 Date(const Date&)。这就证明了编译器把"临时对象构造 → 拷贝构造"两步合并成了"直接构造"这一步——这也是 C++11 里一个著名的优化行为。在不同编译器、不同 Release/Debug 环境下,优化的激进程度不一样,但结论方向是一致的:列表初始化简洁、安全,还常常更高效。
列表初始化的三种骨相:拷贝列表初始化与直接列表初始化
这里有个细节你要注意:只有列表初始化({})才能省略 =。像 Date d8 2025; 这种在 C++98 里就不合法,C++11 也一样不合法。
更进一步,列表初始化本身还分两种形态,学名你要认得:
- 拷贝列表初始化(copy-list-initialization):带
=,如int x = {2};、Date d = {2024,7,25};。它要求从大括号里的值 可以隐式转换 到目标类型。 - 直接列表初始化(direct-list-initialization):不带
=,如int x{2};、Date d{2024,7,25};。它要求能匹配到目标类型的构造函数,要求稍稍宽松一点(连explicit构造函数它也能用)。
一个由此推出来的结论:想用列表初始化去调用 explicit 构造函数,必须写不带 = 的那种。有 = 的版本走隐式转换,explicit 会被挡住;没 = 的直接初始化能进去。举个经典例子:
#include <iostream>
#include <vector>
using namespace std;
struct Vector2
{
explicit Vector2(int n = 0, int m = 0) : _x(n), _y(m) {}
int _x, _y;
};
int main()
{
// Vector2 v1 = { 1, 2 }; // 错误:explicit 构造不能用于拷贝列表初始化
Vector2 v2{ 1, 2 }; // 正确:直接列表初始化可以匹配 explicit 构造函数
cout << v2._x << "," << v2._y << endl; // 1,2
// 标准库里也有这种差别:vector 的 (n, value) 构造本身不是 explicit
vector<int> v{ 5 }; // {5} :只有一个元素 5
vector<int> w(5); // 五 :5 个默认构造的元素(都是 0)
cout << v.size() << " " << w.size() << endl; // 1 5
return 0;
}上面 v{5} 和 w(5) 的天壤之别(1 个元素 vs 5 个元素),正是后面 initializer_list 章节最要命的一个坑,请先记在心里,我们马上详细讲。
列表初始化最著名的安全特性之一:禁止窄化转换
C++11 的列表初始化心甘情愿做"保守派":它禁止隐式窄化转换(narrowing conversion),也就是"往会丢失信息的类型转换"。double 给 int、long 给 int、一个大数给一个装不下的小类型——用 {} 写,编译器大概率直接报错;用普通赋值 = 写,C++ 会静默截断给你。"宁可编译期报错,也不让你在运行时莫名其妙丢精度",这就是列表初始化变安全的原因之一:
#include <iostream>
using namespace std;
int main()
{
// ------------------ 普通赋值:允许窄化,静默截断 ------------------
int a = 3.14; // 合法:3.14 被截断成 3(编译器可能给 warning)
cout << a << endl; // 3
double d = 1e300;
int b = d; // 合法:但 1e300 超出 int 范围,行为是未定义的(undefined behavior)
// ------------------ 列表初始化:禁止窄化,编译期报错 ------------------
// int c{3.14}; // 错误:禁止 double 到 int 的窄化
// int e{d}; // 错误:同上
return 0;
}注意两个边界:
- 这条"禁止"是实现相关的措辞——GCC 和 Clang 会老老实实报
error;而在很久以前的部分 MSVC 版本里,它只给一个warning而非致命错误,代码照样编译过去。所以"列表初始化禁窄化"这句话,在不同编译器下严格程度不同,你要清楚你手上的编译器是哪家。现代 MSVC(VS2015 之后)也同样按标准错误处理了。 - 有些"看起来像窄化"的其实合法,比如
int c{0}、char ch{65}(字面量精确可表示),编译器允许;真正禁的是"有风险的隐式转换",比如int c{3.9}。
生活类比:为什么要把"装箱"做得如此严格
你可以把窄化禁止想成"打包时的安检"。普通赋值 = 像是一个"粗心快递员":你说"这个 3.14 厘米的柱子,我就用 3 厘米的盒子装吧",他二话不说切掉 0.14 就发走了。而列表初始化 {} 像是一个"较真安检员":盒子装不下?他会大声喊"尺寸不合规!你打算切掉的那 0.14 去哪了?凭什么静默丢失?"——逼你在编译期就把"我就是要损失精度"这件事摆在桌面上,你自己决定承担后果。把风险从"运行时悄悄发生"前移到"编译期大声报错",这正是现代 C++ 全线追求的方向。
std::initializer_list:容器能用 {} 初始化的秘密
前面说了,列表初始化很爽,但有个问题:我想用一个 vector 存 5 个值,总不能给 vector 写一堆"接收 1 个参数、2 个参数……"的构造函数吧?C++11 库给出了一个漂亮的回答:std::initializer_list。
你可以先直观地用它:
#include <iostream>
#include <vector>
#include <initializer_list>
using namespace std;
int main()
{
auto il = { 10, 20, 30 }; // il 的类型就是 initializer_list<int>
for (int e : il)
{
cout << e << " ";
}
cout << endl;
// 用 N 个值直接初始化 vector:靠的是 initializer_list 版本的构造函数
vector<int> v1 = { 1, 2, 3, 4, 5 };
vector<int> v2{ 1, 2, 3, 4, 5 };
// 容器的赋值也支持 initializer_list 版本
v1 = { 10, 20, 30, 40, 50 };
cout << v2[4] << endl; // 5
cout << v2.size() << endl; // 5
il.size(); // 3
// 这里每个元素的类型必须一致;{1, "a"} 这种混合类型会导致编译错误
// auto bad = { 1, "a" }; // 错误:元素类型不一致
return 0;
}那 std::initializer_list 到底是什么?它的本质是:底层开一块数组,把数据拷贝过来,initializer_list 对象内部存两个指针,分别指向这块数组的开始和结束。你可以遍历它,但它不是容器,是"给容器/对象做初始化"的中间人。
深挖 initializer_list 的底层
严格按标准来说,std::initializer_list<T> 只需要保证三样东西:begin()、end()、size(),本质就是一个"指向一块 const 元素数组的头尾信息"的轻量句柄。标准并不强制规定"必须是两个指针,还是指针+长度",但几乎所有主流实现(MSVC、GCC、Clang)都把它实现成一个极小的对象(通常就 1~2 个机器字)。有几个关键点你务必清楚:
- 那杯 "const 拷贝数组" 由编译器在你写
{1,2,3}时悄悄创建,元素是 const 的——也就是说你拿到initializer_list后只能读,不能改; initializer_list不是容器,它没有push_back、没有增删改,甚至不拥有(owns)那些数据——所以把它存在变量里再长期使用时要小心,底层数组的生命周期规则有些微妙(常见实现里它通常是静态存储的,但标准只保证了"够了就行");auto x = {1,2,3};这条语句在 C++11/14 里会把x推导成std::initializer_list<int>。注意你不会得到一个vector!想存下一堆值,还是得显式写vector<int> x = {1,2,3};。(补充版本差异:在 C++17 里auto x{1,2,3};这种不带=的多元素列表初始化被收紧了,不再是合法写法,只允许单元素——但auto x = {1,2,3};依然成立。到这篇为止你不用纠结,知道"版本之间有这种细微差别"即可。)
STL 里所有容器(vector、list、map……)都多了一个接收 initializer_list 的构造函数,所以它们都能直接用 {x1, x2, x3 ...} 来初始化。例如:map<string, string> dict = { {"sort", "排序"}, {"string", "字符串"} };——这里既用了 map 的 initializer_list 构造,又用 {} 构造了 pair,两种语法叠在一起,读起来非常自然。
那个绕不过去的坑:{...} 与 (...) 的构造函数重载之争
当一个类同时有"接收 initializer_list 的构造"和"接收普通参数的构造"时,一旦你写 {},编译器会优先选择 initializer_list 版本——哪怕另一个版本看起来"更像"你要的语义。这是现代 C++ 最容易让人翻车的点之一,比"一个元素还是五个元素"严重得多:
#include <iostream>
#include <vector>
#include <string>
using namespace std;
int main()
{
// ---------- pair 的经典坑 ----------
pair<string, string> p1{ "key", "value" }; // 花括号:两个 string 元素
cout << p1.first << " " << p1.second << endl; // key value
// 注意:pair 并没有"接收 3 个参数的"构造函数,下面这句会编译错误:
// pair<string, string> bad{ "kk", "v", "x" }; // 错误:参数个数不匹配
// vector<pair<string,int>> 这种"里面又是分组 {} "的场景,行为最易踩坑:
vector<pair<string, int>> x{ { "a", 1 }, { "b", 2 } }; // 花括号:两个 pair 元素
cout << x.size() << endl; // 2
// 而下面这行想表达"1 组 = 2 个元素"?也会报错,因为花括号优先匹配 initializer_list
// vector<pair<string, int>> y{ 1, {"k","v"} }; // 错误:类型对不上
// 最干净的对比还是 vector<int>:
vector<int> a(10, 2); // 圆括号:10 个 2 -> 10
vector<int> b{10, 2}; // 花括号:两个数 {10,2} -> 2
cout << a.size() << " " << b.size() << endl; // 10 2
// 再比如你自己的类:
// 只要写了 initializer_list<int> 版本构造,{1,2} 一定优先走它
struct V {
V(int n, int m) { cout << "V(int,int)\n"; }
V(initializer_list<int>) { cout << "V(initializer_list)\n"; }
};
V v{ 3, 5 }; // 优先走 initializer_list 版本!
V vv(3, 5); // 圆括号才走 V(int,int)
return 0;
}所以记住一条铁律:想表达"多个值填进去",优先用 {};想表达"构造某种语义",当心 {} 会被 initializer_list 劫持。这也是为什么 STL 里建议 vector<int> v(5, 0); 这种"重复值"习惯用圆括号,而要写出具体元素才用花括号。
如果你自己写一个简单的容器,就能体会"实现 initializer_list 构造"意味着什么:
#include <iostream>
#include <vector>
#include <initializer_list>
using namespace std;
template<class T>
class MyVec
{
public:
typedef T* iterator;
// 增加一个 initializer_list 版本的构造函数
MyVec(initializer_list<T> l)
{
for (auto e : l)
{
push_back(e); // 把列表里每个元素依次尾插进来
}
}
void push_back(const T& val)
{
if (_finish == _endofstorage)
{
size_t oldsize = _finish - _start;
size_t newcap = _endofstorage == _start ? 4 : 2 * (_endofstorage - _start);
T* tmp = new T[newcap];
for (size_t i = 0; i < oldsize; ++i)
tmp[i] = _start[i];
delete[] _start;
_start = tmp;
_finish = _start + oldsize;
_endofstorage = _start + newcap;
}
*_finish = val; // 在末尾位置构造
++_finish;
}
size_t size() const { return _finish - _start; }
private:
iterator _start = nullptr;
iterator _finish = nullptr;
iterator _endofstorage = nullptr;
};
int main()
{
MyVec<int> v{ 1, 2, 3, 4, 5 }; // 靠 initializer_list 构造完成
MyVec<int> w = { 6, 7, 8 }; // 同理
cout << v.size() << " " << w.size() << endl; // 5 3
return 0;
}注意我这里 iterator 是指针,简化实现里指针数组用 new T[n],读写都直接解引用。这块你之前学容器已经见过,此处重点是:一旦某个类型提供了 initializer_list 的构造函数,它就获得了"用任意多个值初始化"的超能力。这就是容器喜欢它、我们也喜欢它的地方。
auto 与 decltype:让编译器帮你推导类型
在我们之前的代码里,你早就用过一个看不见的类型推导了——其实 C++11 把这件事做成了语法糖:auto。
auto 的作用是:让编译器根据初始化表达式自动推导变量的类型。它要求必须有初始化表达式,因为它要根据初值来猜类型。最初的动机就是 lambda(后面会讲),因为 lambda 没有显式类型,你只能用 auto 去接它。比如 auto fn = [](int a, int b) { return a + b; };。
#include <iostream>
#include <vector>
#include <map>
using namespace std;
int main()
{
auto a = 10; // 推导为 int
auto b = 3.14; // 推导为 double
auto c = "hello"; // 推导为 const char*
auto d = { 1, 2, 3 }; // C++11:推导为 initializer_list<int>
// 简化本来很长的类型名
vector<int> v{ 1, 2, 3, 4, 5 };
auto it = v.begin(); // 推导为 vector<int>::iterator
cout << *it << endl;
map<string, string> dict{ {"a", "1"}, {"b", "2"} };
auto mit = dict.find("a"); // 推导为 map<...>::iterator,手写会非常啰嗦
cout << mit->second << endl;
// 结合范围 for(见下一节),写容器的遍历无比清爽
for (const auto& e : v)
{
cout << e << " ";
}
cout << endl;
return 0;
}有个容易忽略的点要提醒你:auto 在推导时会把引用和顶层 const 剥掉。如果我用 auto x = 变量;,得到的 x 往往是个值副本,而不是引用。想保留引用和 const,你得显式写 auto& 或 const auto&。这也是为什么遍历容器时大家习惯写 const auto&——避免无谓的拷贝。
深挖 auto 的"剥"与"不剥"
auto 的推导规则,英文里叫 type deduction,本质跟模板实参推导是同一套。它有一套"剥皮规则":
- 剥掉引用:
auto&不加时,引用会被去掉,换来一个值; - 剥掉顶层 const/volatile:
const int x; auto y = x;得到的y是int(const 被剥了); - 数组退化为指针:
int arr[3]; auto p = arr;得到int*; - 但指针底层的 const 不会被剥:
const int* p; auto q = p;得到const int*(这个 const 是底层 const,修饰的是指向的对象,属于类型本体,剥不掉)。
想"留住"哪些修饰,就显式写哪些:auto& 留引用、const auto& 留引用+const、auto&& 还能成为转发引用(到讲引用折叠那节你会再见到它)。
而 decltype(表达式) 跟 auto 是互补的关系:decltype 精确保留表达式的一切"修饰",包括引用和 const。它不会剥。这在写模板和"追踪返回类型"时特别有用:
#include <iostream>
using namespace std;
int main()
{
int x = 5;
const int& rx = x;
auto a = rx; // a 是 int(auto 剥掉了 const int&)-> 拷贝
decltype(rx) b = x; // b 是 const int&(decltype 保留原样)-> 引用
cout << a << endl;
cout << b << endl;
// decltype 一个关键的括号规则:
decltype(x) c = x; // 不带括号的变量名 x -> int
decltype((x)) d = x; // 带括号的表达式 (x) -> int& (括号让 x 成为左值表达式)
// 也就是说 decltype((变量)) 的 `(变量)` 是一个"左值表达式",推导为 int&
cout << (&d == &x ? "d 是 x 的引用" : "不是引用") << endl;
return 0;
}上面这个 decltype(x) 与 decltype((x)) 的差别对新手特别坑:变量名裸写 vs 用括号包一层,推导结果不同。裸写是按"你声明的类型"来;包括号后它变成了"求一个左值表达式"的语境,对左值表达式 decltype 会给出"引用"。这个坑在写"追踪返回类型"和模板代码时特别容易踩,你只要记住"括号会让 decltype 多出一个 &"就够了。
auto 不能拿来做什么
auto 不是万能的,有几种位置它不能上,列给你记住边界:
auto用于形参在 C++11 里不允许(那要到 C++14 的泛型 lambda、以及 C++20 的缩写函数模板才放开);auto用于函数返回类型做自动推导,C++11 也不允许,得配合"追踪返回类型"(下一小节);auto不能用于成员变量的直接就地推导类型(就地初始化的值你可以给int _count = 0;,但不能auto _count = 0;);这一点在 C++17 也依然不行(除非用模板/结构化绑定等)。
两者的搭配使用有一个经典的场景:函数返回类型依赖参数的精确类型,C++11 无法从函数体内推导,这时用"追踪返回类型":
#include <iostream>
using namespace std;
// 返回类型用 -> 放在参数后面,写成 decltype 表达式来精确推导
template<class T1, class T2>
auto add(T1 a, T2 b) -> decltype(a + b)
{
return a + b;
}
int main()
{
cout << add(1, 2) << endl; // int
cout << add(1.5, 2) << endl; // double
return 0;
}为什么 C++11 偏偏不让你把返回类型写在函数名前面、非要绕到 -> 后面?因为返回类型依赖 a、b 这样的参数——而参数在"函数名之前"的写法里还没被声明出来呢。写成 auto f(A a, B b) -> decltype(a+b) 之后,参数已经在作用域里,decltype(a+b) 就能拿到精确类型了。这就是"追踪返回类型"存在的理由:让你能引用到"此刻还不在前面的参数"来拼返回类型。等你学了 decltype 的括号规则、auto 的剥皮规则,回头看这个例子会非常通透。
(版本补充:这个"返回类型依赖参数"的痛苦,在 C++14 里被彻底治愈了——函数返回类型直接写 auto 就能让编译器从函数体推导,连 -> decltype(...) 都不用写了。C++11 里你还得保守地写追踪返回类型,本篇用 C++11 的写法。)
你可以这样理解这组工具:auto 是"我懒得写类型,编译器你看着办";decltype 是"我要精确到每一个 const 和 &,一点点都不许变"。
基于范围的 for
C++11 给 for 加了一个非常舒服的写法——基于范围的 for(range-based for)。它的效果是:你不用再手写迭代器、不用管 begin/end 的调用,直接就能遍历一个容器或数组。
#include <iostream>
#include <vector>
#include <string>
using namespace std;
int main()
{
int arr[] = { 1, 2, 3, 4, 5 };
// 把 arr 里每个元素拷贝进 e
for (int e : arr)
{
cout << e << " ";
}
cout << endl;
vector<string> names{ "苹果", "香蕉", "橙子" };
// 只读遍历,用 const auto&,不拷贝
for (const auto& n : names)
{
cout << n << " ";
}
cout << endl;
// 想修改容器里的元素,用 auto& 引用遍历
for (auto& e : arr)
{
e *= 2; // 直接把 arr 里的元素乘以 2
}
for (int e : arr)
{
cout << e << " ";
}
cout << endl;
return 0;
}它底层到底是什么?其实没有任何魔法——编译器把它翻译成基于迭代器的最朴素的 for 循环,等价于:
for (auto it = begin(c); it != end(c); ++it)
{
/* 循环体 */
}这里有点"小抄"的意味:范围 for 里的 begin(c)/end(c) 靠的是ADL(参数依赖查找)——编译器先在 c 的类型所在的命名空间里找 begin/end 或成员函数/成员变量,找不到再去 <iterator> 里找通用的那个。所以你自己的容器只要提供了 begin()/end()(成员或全局的 free function 都行),就能享受范围 for。
范围 for 的三层写法对比
遍历一个 vector<string>,你其实有三个层次的选择,性能和可用性各有取舍:
| 写法 | 元素访问方式 | 特点 |
|---|---|---|
for (string s : v) | 值拷贝 | 简洁但每次元素全量拷贝一份,大对象代价高 |
for (const string& s : v) | const 引用 | 只读最推荐,零拷贝 |
for (string& s : v) | 可变引用 | 既能读又能改容器元素本体 |
新手最常见的错误就是一律写 for (auto e : v),遍历大对象时悄悄做了一堆无谓的拷贝。养成"读用 const auto&、改才用 auto&"的习惯会省很多事。
这里的坑:不能在范围 for 里增删容器
这里有个高频的坑,你务必记住:在基于范围的 for 里,不能修改容器本身的结构(比如往 vector 里 push_back、pop_back)。因为它底层用的是"快照"下来的 begin/end 迭代器,一旦元素被添加或删除,原迭代器可能失效,行为是未定义的。你要"一边遍历一边增删",得自己写传统循环或用别的机制。
举一个最典型的翻车现场:遍历时删除满足条件的元素。
#include <iostream>
#include <vector>
using namespace std;
int main()
{
vector<int> v{ 1, 2, 3, 4, 5 };
#if 0 // 这段是错误示范:运行会崩溃/未定义
for (auto& e : v)
{
if (e % 2 == 0)
v.erase(v.begin() + (&e - &v[0])); // 迭代器失效,UB
}
#endif
// 正确做法之一:去掉被删元素(erase-remove idiom)
// 思路:先用 remove 把 "要删的" 挪到末尾,再 erase 统一删掉
// (这里只演示思想,remove 需要 <algorithm>)
// v.erase(remove_if(v.begin(), v.end(),
// [](int x){ return x % 2 == 0; }), v.end());
// 更朴素的正确做法:传统下标反向删(删"后面"的不影响前面下标)
for (int i = (int)v.size() - 1; i >= 0; --i)
{
if (v[i] % 2 == 0)
v.erase(v.begin() + i); // 从后往前删,安全
}
for (int x : v) cout << x << " "; // 1 3 5
cout << endl;
return 0;
}课件里在讲 lambda 原理时提到"范围 for 底层是迭代器",正是这个意思——范围 for 只是语法糖,是给程序员看的,底层根本不认识什么"范围 for"。
左值和右值:移动语义的地基
进入本篇的重头戏了。要理解 C++11 后面所有的移动语义,你首先得搞清楚两个概念:左值和右值。
一句话:能不能取地址(能不能放在内存里有名字的位置),就是左值与右值的核心区别。
- 左值(lvalue):一个表示数据的表达式,它有持久的状态,存储在内存中,我们可以取到它的地址。左值可以出现在赋值符号的左边,也可以出现在右边。这里有个容易弄混的:
const修饰的左值不能给它赋值,但仍然可以取它的地址。 - 右值(rvalue):一个表示数据的表达式,它要么是字面量常量,要么是表达式求值过程中创建的临时对象。右值可以出现在赋值号的右边,但不能出现在左边,也不能取地址。
顺带一提名字的由来。传统上认为 lvalue / rvalue 是 left value / right value 的缩写(站在赋值号左右两边)。但现代 C++ 里它们有了更准确的含义:lvalue 被解释为 locator value(存在于内存、有明确地址、可取地址的对象),rvalue 被解释为 read value(能提供数据值但不可寻址,比如临时变量、字面量、存在寄存器里的值)。所以"能不能取地址",才是判断左右值的金标准。
看个例子,把常见的左值和右值认个脸熟:
#include <iostream>
#include <string>
#include <cmath>
using namespace std;
int main()
{
// ---------- 常见左值:可以取地址 ----------
int* p = new int(0); // p 是左值
int b = 1; // b 是左值
const int c = b; // c 是 const 左值(不能赋值但可以取地址)
*p = 10; // *p 是左值
string s("1111111"); // s 是左值
s[0] = 'x'; // s[0] 是左值
cout << &c << endl; // 可以取地址
cout << (void*)&s[0] << endl; // 可以取地址
// 字符串字面量也是左值(它是 const char 数组,存在静态区,可取地址)
cout << (const void*)"hello" << endl; // 可取地址
// ---------- 常见右值:不能取地址 ----------
double x = 1.1, y = 2.2;
10; // 字面量常量
x + y; // 表达式求值结果
fmin(x, y); // 函数调用返回值(临时对象)
string("11111"); // 匿名临时对象
// 下面这些取地址都会编译报错:
// cout << &10 << endl;
// cout << &(x + y) << endl;
// cout << &fmin(x, y) << endl;
// cout << &string("11111") << endl;
return 0;
}补充:几个"反直觉"的分类细节
当你想用"左右值"当标尺判断各种表达式时,有几个细节最反直觉,提前帮你点破:
- 字符串字面量是左值,不是右值。
"hello"是const char[6]类型的数组,它存在于静态存储区、有地址,完全可以取地址——所以它是左值。(对比:数字字面量10没有地址,是右值。) ++i是左值,i++是纯右值。因为++i返回"自增后的那个 i 本身"(有地址,可取),而i++返回"自增前的旧值的临时副本"(没地址)。*p是左值(解引用得到的是指向的那个对象),&p是纯右值(地址本身只不过是一个值)。- 函数调用返回值要看返回类型:返回值的调用是右值(
fmin返回 double),返回引用的调用是左值(比如v[0]、a.front()返回T&)。这解释了为什么v[0] = 5合法(左值可赋值)、fmin(a,b) = 5非法。
退一步,其实你不需要背这些分类。"我能对这句话取地址吗?"——打勾就基本是左值,打叉就基本是右值,这个实用判据能覆盖绝大多数场景。当某些边界情况模棱两可时,再回到"ccplusplus 的值类别"文档去查。
在 C++98 里,一切都是"左值引用"(也就是我们之前学的引用)和上面这两种值。C++11 在语法层面给"右值"也配了一种引用来"取别名"——那就是右值引用。
左值引用与右值引用
C++11 之后,我们以前学的引用(Type&)被正式叫做左值引用,它给左值取别名。C++11 新增了右值引用(Type&&),它给右值取别名。引用就是取别名,语法上都是不另开空间,底层实现都是指针,你不用去纠结汇编层面——语法表达的语义,和底层汇编的实现,不需要强行对等,混在一起理解反而容易走进死胡同。
记住三条铁律:
- 左值引用不能直接引用右值;但
const左值引用可以引用右值。 - 右值引用不能直接引用左值;但右值引用可以引用
std::move(左值)。 - 变量的表达式,都是左值属性——哪怕它本身是右值引用类型。也就是说,一个右值被右值引用
rr绑定后,rr这个"变量"在表达式里依然算左值。
std::move 是标准库里的一个函数模板,本质就是对实参做一次强制类型转换:
template <class _Ty>
remove_reference_t<_Ty>&& move(_Ty&& _Arg)
{
return static_cast<remove_reference_t<_Ty>&&>(_Arg); // 无条件转成右值引用
}它里面还牵扯到"引用折叠"的知识,我们后面专门讲。你现在只要有个印象:move 干的事就是"把一个值标记为右值",好让下面的重载能选中"移动"那一套。注意 move 里的形参 _Ty&& 是"转发引用"——因为 _Ty 是模板自己推导的,所以它既能接左值又能接右值;而 remove_reference_t<_Ty> 是把 T 里的引用剥掉之后再加 &&,也就是说无论你传给它左值还是右值,它都"无条件地吐出一个右值引用"。这就是"move = 无条件转右值"这个本质的来龙去脉。
直接看代码验证这三条铁律:
#include <iostream>
#include <string>
#include <utility> // std::move
using namespace std;
int main()
{
int b = 1;
int* p = &b;
string s("1111111");
double x = 1.1, y = 2.2;
// ---------- 左值引用:给左值取别名 ----------
int& r1 = b; // OK:b 是左值
string& r4 = s; // OK:s 是左值
char& r5 = s[0]; // OK:s[0] 是左值
// ---------- 右值引用:给右值取别名 ----------
int&& rr1 = 10; // OK:10 是右值
double&& rr2 = x + y; // OK:x+y 的结果是右值
string&& rr4 = string("11111"); // OK:匿名临时对象是右值
// ---------- const 左值引用可以引用右值 ----------
const int& rx = 10; // OK
const string& rx4 = string("11111"); // OK(且延长了临时对象生命周期)
// ---------- 右值引用不能直接引用左值,但可以引用 move(左值) ----------
// int&& rrx1 = b; // 错误:b 是左值
int&& rrx1 = move(b); // OK:move 把 b 转成右值
string&& rrx4 = move(s); // OK
string&& rrx5 = (string&&)s; // 等价于直接强转
// ---------- 验证第三条铁律:变量的表达式都是左值 ----------
int&& rr = 5; // rr 本身是右值引用,让它绑定右值 5
// int&& rrx2 = rr; // 错误!rr 这个"变量表达式"是左值,不能再被右值引用绑定
int&& rrx3 = move(rr); // 除非先 move 一下
cout << rr1 << " " << rr2 << " " << rr4 << endl;
return 0;
}为什么第三条铁律(右值引用变量是左值)是刻意为之?
这可能是本系列最"反直觉"的一条规则,但它是专门设计出来的,不是漏洞。设计者的动机非常清晰:一个右值被右值引用 rr 接管之后,rr 就有了名字、有了地址、能被取地址、能被反复使用——它已经"落袋为安"了。既然它的生命周期已经被你掌握,编译器就再也没有理由把它当成"即将消失的临时对象"来看待。 所以你一旦用 rr 这个名字去访问这块内存,它就是左值。
而"右值身份"这个东西,只在该"还活在一个纯表达式的瞬间"才存在。一旦你用某个名字(哪怕这个名字的类型是 X&&)接住了它,身份就变了。你如果想要"继续把它当右值递给下一层",就必须再次显式 move 一下,告诉编译器"我明确要搬走它"——这正是我们后面讲 std::forward 和完美转发时反复用到的手势。它逼着你"每次搬资源都要盖章确认",防止你一不小心把源对象掏空。
这三条规则,尤其是"[右值引用]变量表达式是左值"这一条,很多人一开始会觉得 "Why so weird"?别急,下一小节我们就能体会到它的设计价值了。
右值引用如何延长生命周期
右值引用有一个特别实用的能力:为临时对象延长生命周期。const 的左值引用也能延长临时对象的生存期,但它绑定的是 const,你没法修改那个对象;右值引用绑定的是非 const,可以修改。直接看:
#include <iostream>
#include <string>
using namespace std;
int main()
{
string s1 = "Test";
// string&& r1 = s1; // 错误:不能把右值引用绑定到左值 s1
const string& r2 = s1 + s1; // OK:const 左值引用延长临时对象的生存期
// r2 += "Test"; // 错误:r2 是 const,不能修改
string&& r3 = s1 + s1; // OK:右值引用同样延长临时对象生存期
r3 += "Test"; // OK:r3 非 const,能修改
cout << r3 << '\n'; // 输出 TestTestTest
return 0;
}看到了吗?s1 + s1 返回的本来是一个马上就会析构的临时 string,但被 r3 这个右值引用"接住"之后,临时对象的生命周期被延长到了 r3 的作用域结束,而且我们还能随意修改它。这就像你用一个肉眼看都行将消失的气泡,把它"装进瓶子"让它多活一阵——右值引用让"将亡之物"可以安全地被我们接管。
深挖生命周期延长的规则与边界
这里把"延长临时对象生命周期"这件事说透,因为它既有威力又有坑:
- 绑定到引用的临时对象,其生命周期被延长到该引用的生命周期结束。这条适用于
const T&和T&&两种。它解决了一大类"C++98 里引用悬空"的经典问题——C++98 里const string& r = s1 + s1;甚至也会延寿,所以这个机制其实古已有之,C++11 只是把同样的能力给了可修改的T&&。 - 延迟只对"直接绑定的那一次"生效。如果中途经过函数传参、返回、存进容器等"绕了一圈",临时对象往往就被销毁了,引用不再保证有效。经验法则:不要让你的引用"过夜"(存起来供未来使用),尤其不要存进一个会销毁临时对象的上下文。
- 初始化列表、以及对数组元素/成员用临时对象初始化时,规则更细。最省心的做法:临时对象要么立刻被"引用接住并尽快使用完",要么靠拷贝/移动落到一个"自己拥有数据"的对象里,不要指望永远存活。
- 引用延寿 ≠ 引用持有对象。引用不是一个拥有所有权的对象,它只是"帮别人多活一会儿"。
这个"接管一个将死对象的资源"的能力,正是移动语义要发力的地方。同样地,"把一个还常用的左值随手 move 掉"则是这条规则的阴暗面——后面你会反复听到这句警告:move 一个你还想用的对象,等于自断后路。
移动构造与移动赋值
现在我们来到本篇最核心的一篇:移动构造和移动赋值。
回想一下,拷贝构造的参数是一个该类型左值引用(通常是 const T&),它的行为是"把对象的资源复制一份"。问题是:如果我们这个对象是个临时对象(右值),它马上就要死了,你复制它岂不是浪费?能不能把它手上那块 heap 资源"偷"过来,反正它也活不长了?
移动构造干的就是这件事。它跟拷贝构造长得几乎一样,唯一的区别是参数类型从"左值引用"改成了"右值引用":
移动构造函数:T(T&& other)移动赋值运算符:T& operator=(T&& other)
因为参数是右值引用,所以只有右值实参才会触发它(配合铁律,左值你想触发就得先 move 一下)。它的本质是**"窃取"引用所指那个右值对象的资源**,然后把源对象置为一个"空壳"(通常是置空指针、清零,保证它析构时安全),从而极大提升效率。这跟拷贝构造"老老实实复制一份"是两个截然不同的路线。
对像 string、vector 这种深拷贝的类(底层自己管理一块 heap 内存),移动构造和移动赋值才真正有意义。我们来手写一个简化版 bit::string,感受一下"窃取资源"和"复制资源"的差别:
#define _CRT_SECURE_NO_WARNINGS 1 // Windows/MSVC 下关闭 strcpy 等不安全告警,便于演示
#include <iostream>
#include <utility> // std::swap / std::move
#include <cstring>
using namespace std;
namespace bit
{
class string
{
public:
typedef char* iterator;
typedef const char* const_iterator;
iterator begin() { return _str; }
iterator end() { return _str + _size; }
const_iterator begin() const { return _str; }
const_iterator end() const { return _str + _size; }
// 普通构造
string(const char* str = "")
: _size((int)strlen(str)), _capacity(_size)
{
cout << "构造" << endl;
_str = new char[_capacity + 1];
strcpy(_str, str);
}
// 拷贝构造:逐个复制字符,深拷贝
string(const string& s)
: _str(nullptr)
{
cout << "拷贝构造" << endl;
reserve(s._capacity);
for (auto ch : s)
push_back(ch);
}
// 移动构造:参数是右值引用,直接 swap 窃取资源,深拷贝为零
string(string&& s)
{
cout << "移动构造" << endl;
swap(s); // 把 s 的资源全部"搬"到自己身上
}
// 拷贝赋值:深拷贝
string& operator=(const string& s)
{
cout << "拷贝赋值" << endl;
if (this != &s)
{
delete[] _str;
_str = nullptr;
_size = _capacity = 0;
reserve(s._capacity);
for (auto ch : s)
push_back(ch);
}
return *this;
}
// 移动赋值:参数是右值引用,swap 窃取资源
string& operator=(string&& s)
{
cout << "移动赋值" << endl;
swap(s);
return *this;
}
void swap(string& s)
{
std::swap(_str, s._str);
std::swap(_size, s._size);
std::swap(_capacity, s._capacity);
}
~string()
{
delete[] _str;
_str = nullptr;
}
void reserve(size_t n)
{
if (n > _capacity)
{
char* tmp = new char[n + 1];
if (_str)
{
strcpy(tmp, _str);
delete[] _str;
}
_str = tmp;
_capacity = n;
}
}
void push_back(char ch)
{
if (_size >= _capacity)
{
size_t newcap = _capacity == 0 ? 4 : _capacity * 2;
reserve(newcap);
}
_str[_size] = ch;
++_size;
_str[_size] = '\0';
}
const char* c_str() const { return _str; }
private:
char* _str = nullptr;
size_t _size = 0;
size_t _capacity = 0;
};
}
int main()
{
bit::string s1("xxxxx");
bit::string s2 = s1; // 左值 -> 拷贝构造
bit::string s3 = bit::string("yy"); // 右值临时对象 -> 移动构造
bit::string s4 = move(s1); // move 强制转右值 -> 移动构造
cout << "s4 = " << s4.c_str() << endl;
return 0;
}运行后你会看到:s2 触发"拷贝构造",而 s3、s4 触发"移动构造"。其中 s3 = bit::string("yy") 这种写法,在优化下可能连移动都被省成直接构造了,但一旦有临时对象真正要被搬移,移动构造就把"复制 N 个字符 + 分配新内存"降级成了 3 次指针交换——这就是移动语义的意义。
为什么"移动后就置空壳"是必须的?
移动构造里 swap(s) 看起来很省事,但背后有一个分寸问题:移动不能直接"把 s 的堆内存指针偷过来然后不告诉 s",因为 s 这个临时对象稍后还要被析构。如果移动后 s 依旧握着那块指针,析构时 delete[] 就会把已经被搬走的内存再释放一次——双重释放(double free)崩溃。所以移动的纪律是:
搬走资源的同时,把源对象手里的资源"清掉/置换干净",保证源对象此后处于"有效但未指明(valid but unspecified)"的状态。
在我们的 bit::string 里,正好用 swap(s) 一并完成:_str、_size、_capacity 全被换走,源对象 s 变成空串 "",它的析构 delete[] _str 释放空串的堆内存,完全安全。真实的 std::string 移动构造也是类似思路:把它内部缓冲区指针交接给新对象,而把源对象置为一个空的可安全析构状态。
这里有两个非常关键的点,我要单独拎出来讲,它们是面试和踩坑的重灾区:
第一,移动 vs 拷贝的本质区别。 拷贝的代价是 O(N) 的分配+复制,移动的代价是 O(1) 的指针搬运。但移动有一个代价:源对象被掏空了。所以对一个"你还想继续用的左值"绝对不要随手 move。只有确定它"用不上了"(比如确实是个临时对象),才应该让它走移动路线。
第二,std::move 本身并不移动任何东西。 它只是把你手里那个左值"贴了个右值的标签"——本质就是 static_cast<T&&>(x)。真正完成资源搬运的,是后面那次的移动构造或移动赋值。换句话说:move 负责"打标签",移动构造/移动赋值负责"干活"。没有移动构造和移动赋值,你 move 一百次也白搭——它只会退化成调用拷贝构造。
深挖:std::move 的本质是一个"标签",不是"搬家公司"
很多初学者以为 std::move(x) 的字面意思是在"搬动 x"。请彻底纠正这个误解。std::move 的整份"工作"就是那一行 static_cast<remove_reference_t<T>&&>(x)——它什么资源都没有移动,甚至没有产生任何对象、没有分配内存。它只是把 x 这个左值的类型临时改挂到"右值引用"名下,好让编译器在重载决议时,优先选中"移动构造/移动赋值"那套接收右值的版本。
用一句话把整个流程说清:
左值 x --std::move--> (被标记成右值) --匹配--> T(T&& other) 移动构造 --真正干活--> 搬资源
【打标签】(O(1), 无副作用) 【编译器选中】 【搬家公司】(O(1)指针交接)
没有移动构造/移动赋值,move 一个对象只会在编译期"意外"地匹配到 const T& 拷贝版本,一切照旧,性能半分没提——这就是为什么"只写了析构清内存、没写移动构造"的老代码 move 过去毫无改善。
移动构造的 noexcept:它为什么攸关 vector 的扩容?
这里补充一个移动语义里非常隐蔽但极其重要的细节:移动构造建议标记 noexcept(保证不抛异常)。这是因为它和 vector 扩容有致命耦合。
当 vector 需要扩容而搬移元素时,标准要求要么用拷贝、要么用移动,选择依据之一就是移动构造是否 noexcept:如果移动构造是 noexcept,编译器放心地全用移动;如果移动构造可能抛异常,标准建议编译实现退回到"逐元素拷贝"——因为拷贝是自包含的(copy 失败可以回滚,能保证强异常安全),而移动一旦抛到一半,源对象状态可能已经是坏的一摊,无法回滚。也就是说:
没有
noexcept的移动构造,在很多实现下根本不给你用,vector扩容白给。 你的性能优化泡了汤,而且你完全察觉不到。
#include <iostream>
#include <vector>
#include <utility>
using namespace std;
// 带打印的类,观察 vector 扩容时到底拷贝还是移动
class Item
{
public:
Item(int n = 0) : _n(n) {}
Item(const Item& src) { _n = src._n; cout << "拷贝构造\n"; }
Item(Item&& src) noexcept { _n = src._n; cout << "移动构造(noexcept)\n"; }
Item& operator=(const Item&) { cout << "拷贝赋值\n"; return *this; }
Item& operator=(Item&&) noexcept { cout << "移动赋值(noexcept)\n"; return *this; }
private:
int _n;
};
int main()
{
// 一步步触发扩容:第 5 个 push_back 时容量翻倍,需要搬移已有 4 个元素
vector<Item> v;
v.reserve(1); // 先留 1 个
v.push_back(Item(1));
v.reserve(8); // 这次从 1 -> 8,搬移 1 个已有元素
cout << "--- 扩容搬运发生在上面的 reserve 里 ---\n";
return 0;
}把上面 Item 的移动构造声明里的 noexcept 去掉,你会发现扩容时输出的是"拷贝构造"而不是"移动构造"——同样是搬 1 个元素,代价一个是指针交接、一个是整份复制。这就是 noexcept 在移动语义里的分量。凡是会参与容器搬移的自定义类型,移动构造/移动赋值请一律加上 noexcept。
移动 vs 拷贝:为什么编译器会选拷贝构造的坑
这里得补一个重要规则,来自课件"新的类功能"部分,非常容易被忽略:
如果你没有自己实现移动构造,且没有实现析构、拷贝构造、拷贝赋值中的任意一个,编译器才会自动生成默认移动构造。
换句话说:只要你写了析构函数、拷贝构造、拷贝赋值里的任意一个,编译器就"默认不给你生成"移动构造了。很多老代码"只写了析构清内存,没写移动构造",结果 move 过去走的是拷贝构造,性能完全没提上去——这就是个很隐蔽的性能坑。反过来,如果你自己提供了移动构造或移动赋值,编译器也不会自动生成拷贝构造和拷贝赋值(它们相当于被删除了)。
C++11 的类里,现在有 8 个默认成员函数了:构造、析构、拷贝构造、拷贝赋值、移动构造、移动赋值,以及两个取地址重载(用处不大)。移动构造和移动赋值是 C++11 新增的一对。
深挖:默认移动构造怎么"逐成员移动"
编译器自动生成的默认移动构造,是按下面这套"逐成员"的流水线来干的:对每个成员——
- 内置类型成员(
int、char*、size_t…):逐成员按字节拷贝(因为对内置类型而言,移动和拷贝没区别,都是把值搬过去); - 自定义类类型成员:先看这个成员自己的类型有没有移动构造——有就调它的移动构造,没有就退回去调它的拷贝构造。
所以你写 Person(Person&& p) = default 时,如果 Person 里有个 std::string _name,编译器会生成"对 _name 调 string 的移动构造、对 _age 直接拷贝字节"的默认移动构造。这一整套和"默认拷贝构造里逐成员拷贝"是对称的,理解了一个另一个就通了。
深挖:Rule of Five(五法则)与 Rule of Zero(零法则)
把上面这些"谁生成谁不生成"的规则串起来,工程界总结出了两条心法,你在写类时务必过一遍:
- 三法则(Rule of Three)升级为五法则(Rule of Five):C++98 有"拷贝构造/拷贝赋值/析构"三件套,一旦你手动写了其中一个,往往另外两个也该一起自定义(否则资源会悬空或泄漏)。C++11 加入移动构造/移动赋值后,升级为"拷贝构造、拷贝赋值、移动构造、移动赋值、析构"五件套。如果你自定义了其中之一,最好五件一起考虑清楚——尤其别只写拷贝和析构、把移动忘掉,那是性能坑;也别只顾移动、把拷贝给"断了"。
- 零法则(Rule of Zero):最省事的实践是——让每个成员都自带正确的资源管理,你的类一行特殊成员函数都不用写。比如
Person里直接放std::string、std::vector这些"会自动拷贝/移动/析构"的 RAII 成员,编译器生成的默认五件套就够用了。现代 C++ 强烈推荐"零法则优先,实在需要再手写五法则"。
深挖:自移动赋值(a = std::move(a))
还有个必须处理的边界:如果用户写 a = std::move(a);,也就是对象移动赋给自己。如果你的移动赋值里含"先释放自己的旧资源、再从对方拿资源"的逻辑,自赋值时"先释放自己"就会把还没被拿走的资源先释放掉,然后从"已释放的自己"身上拿——崩溃。标准要求移动赋值应该对自赋值是安全的(std::swap 风格的实现天然安全,因为我们先交换再处理,不存在"边销毁边读取自己")。所以:
#include <iostream>
#include <utility>
using namespace std;
class Widget
{
public:
// 安全的移动赋值:swap 一声令下,自赋值也安全
Widget& operator=(Widget&& other) noexcept
{
cout << "移动赋值" << endl;
swap(a, other.a); // 交换后 this 拿走 old other 的 a,other 拿走 old this 的 a
return *this;
}
int a = 0;
};
int main()
{
Widget x;
x = std::move(x); // 自移动赋值,swap 方案安全不崩溃
cout << x.a << endl;
return 0;
}真实 std::string、std::vector 的移动赋值都是自赋值安全的,但你自己手写的移动赋值如果没用 swap、是"先释放再搬"的写法,请务必显式判断 if (this != &other) 或用 swap 风格。这部分逻辑参照我们前面拷贝赋值里已经写过的 if (this != &s) 判断即可。
右值引用解决传值返回难题
在之前学习string/vector 时我们遇到过一个无解的场景:函数要返回一个"函数内部创建出来的局部对象",比如字符串相加、生成杨辉三角。这类函数不能用左值引用返回(返回的是局部对象,函数一结束就析构了,返回的引用会成为悬空引用),C++98 只能憋屈地用"输出型参数"。传值返回吗?拷贝代价又太吓人。
C++11 之后,这个难题被移动语义优雅地化解了——虽然你返回的是一个局部对象(它会以右值/临时对象的形式被接收),但接收方可以调用移动构造/移动赋值把它接走,全程零深拷贝:
#include <iostream>
#include <string>
using namespace std;
// 返回"函数内部创建的局部对象"是传值返回;过去拷贝代价大,现在自动走移动
string addStrings(const string& num1, const string& num2)
{
string str; // 函数内部创建局部对象
str = num1; // 这里演示真正的字符串相加逻辑,方便运行验证
str.append(num2);
return str; // 返回局部对象(以右值身份被接收)
}
int main()
{
// 场景1:接收处直接构造,返回值是右值,触发移动构造,零深拷贝
string ret = addStrings("11111", "2222");
cout << ret << endl;
// 场景2:接收处赋值,右值触发移动赋值
string ret2;
ret2 = addStrings("11111", "2222");
cout << ret2 << endl;
return 0;
}注意,这里我用标准库 string 演示,它的移动构造/移动赋值由库代为实现,所以你看不到搬运过程的打印,但流程是确定且正确的:addStrings 返回局部对象时,返回值本身是一个右值(准确说是传值返回的临时对象),C++11 会调用移动构造/移动赋值把它接走——这就是"返回局部对象不再爆炸"的原因。这也是标准库容器(vector、string、list 等)在 C++11 之后能安全、高效地按值返回的根本保证。
深挖:为什么"右值引用做返回值"也不行?
课件里专门点破了一个迷惑:这个场景能不能直接用"右值引用做返回值"? 答案是不能。因为 str 是函数内的局部对象,函数一结束它就析构销毁了——无论你返回 T& 还是 T&&,返回的都只是一个指向"已经销毁的内存"的引用,本质是悬空引用。你既不能返回局部对象的左值引用(指向已死对象),也不能返回局部对象的右值引用(同样指向已死对象)。
真正正确的路线是按值返回 + 移动:函数内部的对象作为一个"临时对象/右值"离开函数,接收方用移动构造把它的资源搬走——这样对象虽然"离开了函数",但它的资源"没死",只是换了个主人的手、换了块存储罢了。
深挖:Copy Elision(拷贝/移动消除)——编译器比你更省
课件里还讲了一处细节:在 vs2019 的 release 和 vs2022 的 debug/release 下,编译器优化非常激进,会把"局部构造 str → 拷贝/移动构造临时对象 → 临时对象构造 ret"这几步合并为一步直接构造;想看清楚中间的拷贝/移动次数,可以在 Linux 上用 g++ test.cpp -fno-elide-constructors 关闭构造优化再观察。理解这个要结合局部对象生命周期和栈帧的角度——但结论你已经知道了,右值引用 + 移动语义 + 编译优化,三管齐下解决传值返回的效率问题。
这里把"编译器替我省掉了拷贝/移动"这件事讲透,因为它非常漂亮:Copy Elision(拷贝消除) 是编译器允许(在很多主流实现里是必须/总是做)的一种优化:当函数返回一个局部对象、而这个返回值刚好要用来初始化/赋给另一个对象时,编译器可以直接在目标位置构造这个对象,省掉"临时对象 → 目标对象"的这一次拷贝或移动。最著名的两种是:
- RVO(Return Value Optimization):返回一个局部具名对象时的消除。
return str;这种,编译器直接在调用方那块存储上就地构造str,一次构造都没浪费。 - NRVO(Named Return Value Optimization):RVO 的具名变量版本,同样就地构造。
所以在你实际编译开优化的情况下,addStrings 的那句 return str; 往往连移动都省了——直接从函数内部的存储上就地构造接收方对象。想看清中间真实发生了多少次拷贝/移动,就得像课件说的用 -fno-elide-constructors 关掉消除再看。这提醒你一个重要事实:移动本身 O(1),而消除是 O(0),是这个场景下"零浪费时间"的最终形态。
在容器的 push / insert 里同理提效
不仅是返回值,传参也一样。C++11 以后,容器的 push_back、insert 系列都增加了右值引用版本的重载——实参是左值就走拷贝构造,实参是右值就走移动构造。看运行结果的判断逻辑:
#include <iostream>
#include <list>
#include <utility>
using namespace std;
// 下面假设 bit::string 已经有拷贝构造和移动构造(与上文一致)
// 为演示,这里用标准库 string 代替 bit::string,效果相同
void demo(list<string>& lt)
{
string s1("11111111111111111111111111");
lt.push_back(s1); // 左值 -> 拷贝构造
lt.push_back(string("2222222222")); // 右值临时对象 -> 移动构造
lt.push_back("333333333333"); // const char* 转出的临时 -> 移动构造
lt.push_back(move(s1)); // move -> 移动构造
cout << "前两个元素:" << lt.front();
lt.pop_front();
cout << " " << lt.front() << endl;
}
int main()
{
list<string> lt;
demo(lt);
return 0;
}这几条 push_back 的判决逻辑,本质就是我们上一章地盘的那套重载匹配规则——左值命中左值引用,右值命中右值引用。而 move(s1) 就能让一个明明有名字的左值也走移动路线。课件已经模拟过一个带右值 push_back/insert 的 bit::list,思路就是把资源层层用 move 传递下去,底层 ListNode 里多写一个接收 T&& 的构造,push_back(T&& x) 里再 insert(end(), move(x)) 把右值身份传到底。这里我不再整段贴出来,你理解"左值→拷贝、右值→移动、move 人为制造右值"就够了。至于更狠的 emplace 系列,我们讲完可变参数模板再来收拾它。
顺带一提:std::swap 也被移动化了
你可能会好奇:我们前面手写的 swap 用的是 std::swap。C++11 之后 std::swap 的实现其实改成了"三个 move":
template<class T>
void swap(T& a, T& b)
{
T tmp = std::move(a); // 搬走 a
a = std::move(b); // b 搬给 a
b = std::move(tmp); // tmp 里的旧 a 搬给 b
}以前 swap 要三次拷贝(每次副本 O(N)),现在对支持移动的类型变成三次 move(每次 O(1) 指针交接)。这也是为什么"移动语义"一上线,连 swap 这种老函数都自动"变快"了——它不要求你改调用代码,弦就绷在"你给类型写了移动构造/移动赋值"这件事上。
参数匹配:左值与右值的分派规则
C++11 之后,你可以针对"左值 / const 左值 / 右值"分别写三个重载的 f,调用时编译器会自动选对版本。这也是移动语义能"按值分流"的基础。规则一句话:
- 实参是左值 → 匹配
f(T&) - 实参是 const 左值 → 匹配
f(const T&) - 实参是右值 → 优先匹配
f(T&&);如果没有右值版本,会回退到f(const T&)
#include <iostream>
#include <utility>
using namespace std;
void f(int& x) { cout << "左值引用 f(" << x << ")\n"; }
void f(const int& x) { cout << "const左值引 f(" << x << ")\n"; }
void f(int&& x) { cout << "右值引用 f(" << x << ")\n"; }
int main()
{
int i = 1;
const int ci = 2;
f(i); // 左值 -> f(int&)
f(ci); // const左值 -> f(const int&)
f(3); // 右值 -> f(int&&);若没有右值重载则回退到 f(const int&)
f(std::move(i)); // move 后成右值 -> f(int&&)
// 第三条铁律再现:右值引用变量表达式是左值
int&& x = 1;
f(x); // x 是左值 -> f(int&)
f(std::move(x)); // 再 move 一次 -> f(int&&)
return 0;
}这就是为什么课件强调"右值引用变量在用于表达式时是左值"这个看似怪异的规则——它逼着你继续用 move 把右值身份层层传递下去。试着这样理解:右值引用变量既然有名字、能取地址,别人自然得按"左值"来对待它;你想继续把它当右值用?那就再 move 一下,明确告诉编译器"我就是要搬走它的资源"。
为什么"const 左值引用会被右值回退选中"
上面规则里有一条容易忽略但极重要:当没有右值重载时,右值实参会"回退"去匹配 const T&。这解释了 C++98 里一个历史事实——以前我们写的 void f(const string& s) 这种"万能接收"式参数,其实左值、右值都能收,因为 const T& 既能绑左值也能绑右值(第一条铁律的红利)。
换句话说:const T& 是"既能接左值也能接右值"的底线。如果你的类只提供了 const T& 的拷贝构造(老代码几乎都这样),那么右值对象来了也会走它——虽然不能移动,但至少拷贝是安全的、语义是对的。这正是不写分支语言里"右值兼容老代码"的保障:右值对象永远能退回到 const 拷贝,不会因为缺少匹配版本而编译失败。所以"移动"是"给性能的加分项",而"const 拷贝"始终是"保底项"。
值类别:纯右值与将亡值
C++11 为了把值体系讲得更严谨,进一步把右值划分成了两类,顺便引入了一个"泛左值"的顶头上司概念。这个知识点偏理论,但有它做底,理解和记忆 move/forward 会顺很多。
- 纯右值(prvalue,pure value):那些字面量常量,或求值结果相当于字面量、或是一个不具名的临时对象。比如
42、true、nullptr、a + b、传值返回的函数调用结果。 - 将亡值(xvalue,expiring value):返回右值引用的函数调用表达式,以及转换为右值引用的转换表达式。比如
std::move(x)、static_cast<X&&>(x)的结果。这个名字很形象:这是个"即将消亡的值"。 - 泛左值(glvalue,generalized value):它是"将亡值"和"左值"的并集,也就是说左值和将亡值都属于泛左值。
给个直观的映射:C++11 的纯右值,基本等价于 C++98 的右值;将亡值是把亡的右值,它常由 move/强转制造出来。你写程序时不必时刻纠结分类,但知道"move(x) 的结果是将亡值"这一层,会帮你理解 move 为什么能把资源"搬"走——因为将亡值本来就要亡了,搬走它名正言顺。
用一张"嵌套图"理解 C++11 的 value categories
把所有值类别叠起来,其实是一个两层的分类树,把它们的关系想成"俄罗斯套娃"就通了:
值(expression)
├── 泛左值(glvalue):还能"找到地方/知道是谁"的值
│ ├── 左值(lvalue):常量时的、有名字的、可取地址的
│ └── 将亡值(xvalue):快要消失的值(move/强转造出来的右值引用)
└── 右值(rvalue):临时的、即将消失的值
├── 纯右值(prvalue):裸字面量、表达式结果、传值返回
└── 将亡值(xvalue):move 出来的那个"右值身份"
注意"将亡值"这个浑圆小可爱同时出现在"泛左值"和"右值"两棵树上——它是"既有地址(还没消失)又即将消失"的中间态。std::move(x) 的返回值就是一个将亡值:它的资源还实实在在待在内存里(所以它类型上仍属于"泛左值"那一支),可它的价值分类却是"右值"(所以它能绑到 T&&、能触发移动)。理解这一点,好多"move 到底移了啥"的迷雾就散了。
引用折叠与万能引用
C++ 里不能直接写"引用的引用",int& && r = i; 会直接报错。但通过模板或 typedef 构成的类型,却可能产生"引用的引用"。遇到这种情况,C++11 定了一条**引用折叠(reference collapsing)**规则,非常简洁:
右值引用的右值引用折叠成右值引用;其它所有组合都折叠成左值引用。
也就是:
T& & ——> T& (左值引用的引用 → 左值引用)
T& && ——> T& (左值 + 右值 → 左值引用)
T&& & ——> T& (右值 + 左值 → 左值引用)
T&& &&——> T&& (右值引用的右值引用 → 右值引用)
有一条非常省事的记法:只要四个组合里出现过一次左值引用 &,结果就是左值引用 &;只有"两个都是右值引用"才是右值引用 &&。这条和"只要有一个 & 就 &"的直觉一致,好记好背。
那这个规则有什么用?答案就在函数模板里。看这段:
#include <iostream>
using namespace std;
// f1:T& 经过折叠,总是实例化成左值引用版本
template<class T>
void f1(T& x) {}
// f2:T&& 经过折叠,可以既是左值引用版本,也可以是右值引用版本
template<class T>
void f2(T&& x) {}
int main()
{
typedef int& lref;
typedef int&& rref;
int n = 0;
lref& r1 = n; // 折叠后类型是 int& -> 左值引用
lref&& r2 = n; // 折叠后类型是 int& -> 左值引用
rref& r3 = n; // 折叠后类型是 int& -> 左值引用
rref&& r4 = 1; // 折叠后类型是 int&& -> 右值引用
// 用显式模板实参来强制观察 f1/f2 各自折叠出的形态
f1<int>(n); // 实例化成 f1(int&)
// f1<int>(0); // 错误:int 不是左值,绑不到 int&
f1<int&>(n); // int& 实例化成 f1(int&):int& 本身折叠
f1<int&&>(n); // int&& 传入,T& 形参加上 int&& 折叠为 int&:也绑最近对的 n
f2<int>(0); // 右值:f2(int&&)(T=int,无折叠)
// f2<int>(n); // 错误:等 T=int 且形参 int&&,右值引用绑不了左值 n
f2<int&>(n); // 左值:int& && 折叠成 int& -> f2(int&)
f2<int&&>(0); // 右值:int&& && 折叠成 int&& -> f2(int&&)
// f2<int&&>(n); // 错误:int&& 形参绑不了左值 n
return 0;
}而template<class T> void f2(T&& x) 这种写法,在 C++11 里有个专门的称呼叫万能引用(universal reference),现在常被称为转发引用(forwarding reference)。它的神奇之处在于参数本身的推导 + 引用折叠:
- 如果实参是右值,
T被推导为普通类型(比如int),T&&不折叠 →f2成为右值引用版本; - 如果实参是左值,
T被推导为int&,int& &&折叠成int&→f2自动成为左值引用版本。
也就是说,一个 T&& 参数能同时接受左值和右值,并且"记住"实参原来是左值还是右值。这个"能记住左右值身份"的能力,正是完美转发的前提。前面我们讲的 std::move(x) 的实现里那个 T&& arg,就是一个万能引用形参。
深挖转发引用的基本形态
转发引用(forwarding reference)有一个标准的判别技巧,它有一个必要条件和一个本质:
- 必要条件:
T必须是模板自己推导的,不能是你写死的具体类型。template<class T> void f(T&& x)是转发引用;而void f(int&& x)只是普通的右值引用,不具备"左值也能绑"的能力。但注意,T&&里的 T 也可能是auto&&(在 lambda、局部变量里),或者类模板的成员……只要它是"推导出来的",就算转发引用。 - 本质:转发引用在"接东西"的时候就办了两件事——第一步按它的推导把左值/右值身份记在 T 里(左值→
T&,右值→T),第二步靠引用折叠把这个参数变成匹配的引用类型。所以它既能接左值又能接右值,而且把身份信息"烙"进了 T 的类型里,随时可以靠std::forward<T>取出来。
这条"身份信息藏在 T 里"的机制,就是下一小节完美转发的燃料。理解不透这一层,看到 forward<T>() 会一头雾水;理解透了你就会发现 T 里藏着的是实参的"口供"。
完美转发:std::forward 为何必需
现在我们手上有了两块拼图:①转发引用 T&& 能记住左右值身份;②但"被绑定的变量表达式是左值"。
把这两块拼起来,就出现一个大麻烦:我写一个转发函数骨架 template<class T> void Wrap(T&& t),我在内部再调用另一层的 Fun(t),可因为 t 在表达式里是左值,无论外面传进来是左值还是右值,Fun(t) 一定匹配左值版本——右值身份在转发过程中丢失了!
这就是**完美转发(perfect forwarding)**要解决的:把参数原原本本、不增不减地传给下一个函数,连"左值/右值"的身份都要保持一致。工具就是 std::forward:
#include <iostream>
#include <utility>
using namespace std;
void Fun(int& x) { cout << "左值引用\n"; }
void Fun(const int& x) { cout << "const左值引用\n"; }
void Fun(int&& x) { cout << "右值引用\n"; }
void Fun(const int&& x) { cout << "const右值引用\n"; }
// 不使用 forward 的"伪转发"(不完美)
template<class T>
void NotPerfect(T&& t)
{
Fun(t); // t 是左值,永远命中左值版本,右值身份丢失!
}
// 使用 forward 的完美转发
template<class T>
void Perfect(T&& t)
{
Fun(forward<T>(t)); // forward<T> 依据 T 恢复 t 原来的左右值身份
}
int main()
{
int a = 1;
const int b = 8;
cout << "---- 不完美转发 ----\n";
NotPerfect(10); // 期望右值,结果命中 左值引用
NotPerfect(a); // 左值 -> 左值引用
cout << "---- 完美转发 ----\n";
Perfect(10); // 右值 -> 右值引用
Perfect(a); // 左值 -> 左值引用
Perfect(move(a)); // 右值 -> 右值引用
Perfect(b); // const左值 -> const左值引用
Perfect(move(b)); // const右值 -> const右值引用
return 0;
}std::forward 的实现,本质就是这么一行靠引用折叠工作的强转:
template <class _Ty>
_Ty&& forward(remove_reference_t<_Ty>& _Arg) noexcept
{
return static_cast<_Ty&&>(_Arg);
}我们拆解一下 forward 的"魔法":假使 Perfect 收到的是右值,则 T 推导为 int,forward<int> 返回 int&&,于是交给 Fun 时是右值;假使收到的是左值,则 T 推导为 int&,int& && 折叠为 int&,forward<int&> 返回 int&,于是交给 Fun 时是左值。一个 forward,把 T 的记忆翻译回了实参的左右值身份。对比之下,std::move 是"无条件转右值",而 std::forward 是"按需还原左右值"——这是它俩最本质的区别。
move vs forward:找到你眼中的那面"正反面"
很多同学会问:move 和 forward 看起来都是在做"转换成右值引用",到底差在哪?这里给你一张终极对照表:
| 工具 | 行为 | 是否看"实参原本是啥" | 用途 |
|---|---|---|---|
std::move(x) | 无条件强转右值 | 不看,不管你是不是左值,一律转右值 | 明确表示"我要把 x 的战场清空" |
std::forward<T>(x) | 按需还原实参左右值身份 | 看 T 的推导,是左值就还原成左值,是右值才还原成右值 | 完美转发中保持身份无损 |
一句话:move 是"我决定了,已无回头路";forward 是"我如实转述,原封不动"。forward 只适合出现在泛型转发代码里(配合推导出的 T),而 move 可以在任何地方、对任何对象用。工程里最常见的错误之一,就是在泛型代码里用 move 代替 forward,导致身份被强制改成右值,破坏了完美转发。
在实际工程里,forward 最常见的搭档就是可变参数模板(下一节就讲)以及容器的 emplace 接口——参数包每往下传一层,都必须 forward,否则右值身份又会在某一层变成左值,移动的优势瞬间归零。这是个非常容易踩的坑,请务必记住。
可变参数模板
C++11 支持可变参数模板(variadic templates),也就是说模板可以接收"任意数量、任意类型"的参数。这个数量不确定的参数就被称为参数包(parameter pack)。有两种包:
- 模板参数包:表示零个或多个类型参数;
- 函数参数包:表示零个或多个函数参数。
语法上用省略号 ... 表示"一个包"。我们可以在各种形态里声明它:
template <class... Args> void Func(Args... args) {} // 按值
template <class... Args> void Func(Args&... args) {} // 左值引用
template <class... Args> void Func(Args&&... args) {} // 转发引用(最常用)它的原理跟普通模板一样,本质还是按"类型 + 个数"实例化出多个具体函数。你可以用 sizeof... 运算符获得包里参数的个数:
#include <iostream>
#include <string>
using namespace std;
template <class... Args>
void Print(Args&&... args)
{
cout << sizeof...(args) << endl; // 打印参数包中有几个参数
}
int main()
{
double x = 2.2;
Print(); // 0 个参数
Print(1); // 1 个
Print(1, string("xxxxx")); // 2 个
Print(1.1, string("xxxxx"), x); // 3 个
return 0;
}编译器会根据实参,把上面的 Print 实例化成若干具体版本(Print(int&&)、Print(int&&, string&&)、Print(double&&, string&&, double&)……)。你可以把它理解成:以前你需要手写 N 个重载函数模板才能支持 N 种参数组合,现在一个可变参数模板全搞定了——这是"类型泛化"基础上叠加了"数量泛化",泛型编程的威力瞬间上一个台阶。
深挖:可变参数模板能泛化"类型"和"个数"两轴
这里再铺垫一下可变参数模板的真正价值。普通模板 template<class T> void f(T) 只泛化了"类型"这一轴——参数个数是固定的。而可变参数模板把类型和个数两轴同时泛化了:不但在类型上任意,数量上也能任意。这立刻解锁了 printf 式、委托、event、make_shared/make_unique、容器的 emplace 等一大批"参数不确定"的老大难。
如果你用"没有可变参数模板"的世界观去实现"打印任意数量参数",你只能手写 Print()、Print(T1)、Print(T1,T2)、Print(T1,T2,T3)……每个都写一遍——数量没上限就没完没了。可变参数模板让"写一个函数模板,覆盖任意数量组合"成为可能。后面你会看到它有"递归展开"和"逗号展开"两种消费参数包的方式,分别对应打印全部、传递给下一个函数等用法。
注意一个容易踩的坑:参数包是编译期的东西,不能当运行期的 args[i] 来下标访问。因为不同参数的类型各不相同(int、string、double…),它们不可能存在于同一块可下标访问的数组/数组式存储里。下面这种想当然的写法是错的(详见课件注释),编译器会直接告诉你"参数包里没有 operator[]":
#include <iostream>
using namespace std;
// 这条路走不通:不能用下标访问参数包里的元素
// template<class... Args>
// void Print(Args... args)
// {
// for (size_t i = 0; i < sizeof...(args); ++i)
// cout << args[i]; // 错误:args 是参数包,不是数组
// }
int main(){ return 0; }想逐个访问参数包?见下一节"包扩展"——C++ 给的正式工具是"编译期递归/展开",不是"运行期下标"。
包扩展:如何"展开"一个大礼包
拿到参数包,除了数个数,我们最常做的事是包扩展(pack expansion)。把包展开成多个独立的元素,对每个元素应用某种"模式(pattern)",再拼成一个新的参数列表。触发扩展的关键,是在模式右边放一个省略号 ...。
最常见的两种用途:一是递归式逐个打印,二是把整个包原样传给下一个函数。
先看最经典的打印全套 —— 它靠"递归推导 + 空包终止"来实现:
#include <iostream>
#include <string>
using namespace std;
// 递归的终止条件:参数包为 0 个参数时匹配到这个无参版本
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...); // 包扩展:把 args 依次展开传给 ShowList
}
int main()
{
Print(1, string("xxxxx"), 2.2);
// 输出:1 xxxxx 2.2
return 0;
}它的原理是编译器把"参数包"不断拆"头"和"尾":Print(1, string, 2.2) → ShowList(1, string, 2.2) → 打印 1,递归 ShowList(string, 2.2) → 打印 string,递归 ShowList(2.2) → 打印 2.2,递归 ShowList() → 命中空包终止函数,结束。
深挖:递归展开在编译期长什么样
这个递归不是运行时的,而是编译期就被编译器"摊开"成一串具体函数的。对 Print(1, string("xxxxx"), 2.2),编译器会推导并实例化出下面这串渐进调用(这也是理解"为什么参数包能均匀吃头"的关键):
ShowList(int) { cout << 1; ShowList(); }
ShowList(string, double) { cout << "xxxxx"; ShowList(double); }
ShowList(int, string,double){ cout << 1; ShowList(string,double); }
Print(int, string, double) { ShowList(1, string, double); }
每一步都"吃掉第一个参数",剩下的包继续缩;直到一个参数都不剩,命中那个无参的 ShowList() 终止。所以写"递归 + 终止函数"是消费参数包的通用套路。
另一种更"高级"的扩展是把包当实参整个递交给另一个函数处理,甚至可以在每个元素前套一个函数调用作为"模式":
#include <iostream>
#include <string>
using namespace std;
// 对单个参数做点事,再转发出去
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)
{
// 包扩展模式:对包中每个元素都调用 GetArg(x),产出 GetArg(x_1), GetArg(x_2), ...
Arguments(GetArg(args)...);
}
int main()
{
Print(1, string("xxxxx"), 2.2);
// 编译器大致把它展开成:Arguments(GetArg(1), GetArg(string("xxxxx")), GetArg(2.2))
return 0;
}你看,GetArg(args)... 这种写法,本质上就是"对包里的每个元素套同一个函数"。刚开始你会觉得这种语法很抽象,别灰心——我们把"包扩展"想清楚之后,emplace 系列就好懂了,因为你反复需要做的一件事就是 std::forward<Args>(args)...——对包里的每个参数做完美转发。
深挖:递归求和处理——包扩展的另一名场面
除了打印、往下传,参数包最经典的消费场景之一就是递归求和/求积。它在"头元素 + 剩余包 + 终止"模型上再来一次:
#include <iostream>
using namespace std;
// 终止:空包的和是 0
long long Sum()
{
return 0;
}
// 递归:取第一个 + 对剩余参数包求和
template<class T, class... Args>
long long Sum(T first, Args... rest)
{
return first + Sum(rest...); // 第一个 加 剩余包的和
}
int main()
{
cout << Sum(1, 2, 3, 4, 5) << endl; // 15
cout << Sum(1, 2.5, 3) << endl; // 6.5
cout << Sum() << endl; // 0
return 0;
}注意 Sum(1, 2.5, 3) 会先把 2.5 这条路展开成 1 + Sum(2.5, 3),再 2.5 + Sum(3),再 3 + Sum() → 0,一路加起来恰好是 6.5——不同类型在这个表达式里通过"模板实例化出各自版本"被分开处理,互不干扰。这就是"编译期把包摊开成若干段独立的算术"的鲜活体现。
(版本补充:C++17 引入的 fold expression(折叠表达式) 让这种求和极度简化:return (args + ...); 一行即可,还能选左折叠/右折叠、+ 换成任意二元运算符。这里不展开,你只要知道"递归展开是 C++11 的经典学法,C++17 有更简的语法"即可。)
emplace 系列接口:直接在容器空间构造
还记得上一则预告吗?「讲完可变参数模板就来收拾 emplace」。现在正是时候——emplace 系列的优雅,正是建立在"可变参数模板 + 完美转发"之上的。
C++11 以后,STL 容器的 push / insert 系列之外,新增了 emplace_back、emplace 等接口。它们都是可变参数模板:
template<class... Args> void emplace_back(Args&&... args);
template<class... Args> iterator emplace(const_iterator position, Args&&... args);功能上 emplace_back 兼容 push_back,但多了一个"杀手锏":可以把"构造 T 对象所需要的参数"直接传进来,容器会根据这些参数,在它管理的空间里直接构造出 T 对象。想想看这两个写法的差别:
// push_back:先构造临时 T,再把临时 T(经过移动/拷贝)放入容器
lt.push_back(bit::string("1111"));
// emplace_back:直接从 "1111" 这个 const char* 构造出容器里的 T,少一次移动
lt.emplace_back("1111");第一行 push_back("1111") 实际上会:用 "1111" 构造一个临时 string → 把这个临时 string 移动/拷贝进容器 → 销毁临时对象。而第二行 emplace_back("1111") 会在容器节点空间直接调用 string("1111") 构造,省掉了中间那一次移动。对常见的 list<pair<string,int>> 这种嵌套类型,优势更明显——它连"构造 pair 的临时对象"那一步都能省。
深挖:emplace 与 push_back 的完整成本对比
我们用 list<pair<string, int>> 这个经典的嵌套例子,把两种写法的成本彻底摆开:
#include <iostream>
#include <list>
#include <string>
#include <utility>
using namespace std;
int main()
{
list<pair<string, int>> lt;
pair<string, int> kv("苹果", 1);
lt.push_back(kv); // 用已存在的 kv:pair 拷贝构造进节点
// 成本:pair 的拷贝构造(里面 string 深拷贝)
lt.push_back(move(kv)); // pair 移动构造进节点
// 成本:pair 的移动构造(里面 string 移动,O(1))
lt.push_back({ "香蕉", 2 }); // {} 先造 pair 临时对象,再移动/拷贝进节点
// 成本:构造临时 pair + 移动/拷贝进节点 + 析构临时 pair
lt.emplace_back("橙子", 3); // 直接把 "橙子"、3 这两个参数塞进节点,在原地构造 pair
// 成本:只有一次原地构造 pair(内部 string 直接构造),零移动零拷贝零临时
cout << "元素个数:" << lt.size() << endl;
return 0;
}注意最后一行 emplace_back("橙子", 3):它把 "橙子" 和 3 一路 forward 到节点的 pair 构造那里,pair<string,int> 就地用这两个参数构造出来——既不产生临时 pair,也不做移动/拷贝,是"纯一次性构造"。这就是 emplace 存在的意义。由于它总体通常更快且写法更简洁,STL 官方和个人实践都推荐在 C++11 之后用 emplace/emplace_back 取代 push/insert。
课件里给出了完整的 bit::list 模拟实现。核心就在节点构造上——把参数包一层层 forward 下去,直到节点构造函数用 std::forward<Args>(args)... 去触发容器元素类型 T 的构造函数。这里必须用 std::forward<Args>(args)...(完美转发参数包):否则包扩展后,右值引用的变量表达式会变成左值,移动的优势就没了。我把它最关键的几行抽出来给你看:
namespace bit
{
// 链表节点:新增一个接收参数包的构造(用于 emplace 直接构造 T)
template<class T>
struct ListNode
{
ListNode<T>* _next;
ListNode<T>* _prev;
T _data;
// 关键:参数包 + 完美转发,直接构造 _data(对应 T 的某个构造函数)
template<class... Args>
ListNode(Args&&... args)
: _next(nullptr)
, _prev(nullptr)
, _data(std::forward<Args>(args)...)
{}
};
// 链表类里(截取最关键的两段)
template<class T>
class list
{
typedef ListNode<T> Node;
public:
// emplace_back:把参数包继续 forward 进 insert
template<class... Args>
void emplace_back(Args&&... args)
{
insert(end(), std::forward<Args>(args)...);
}
// insert 的模板版本:用参数包直接构造节点
template<class... Args>
iterator insert(iterator pos, Args&&... args)
{
Node* cur = pos._node;
Node* prev = cur->_prev;
Node* newnode = new Node(std::forward<Args>(args)...); // 用参数包直接构造节点
prev->_next = newnode; // 链接指针:prev newnode cur
newnode->_prev = prev;
newnode->_next = cur;
cur->_prev = newnode;
return iterator(newnode);
}
private:
Node* _head;
};
}核心线索就是那条链:emplace_back(Args&&... args) → insert(..., forward<Args>(args)...) → Node(forward<Args>(args)...) → _data(forward<Args>(args)...)。每一层都用 std::forward 把左右值身份原封不动地传到底,直到最后触发容器元素的构造。这,就是"直接在容器空间构造"的实现秘密,也是推荐你用 emplace_back 取代 push_back 的原因——总体而言它更高效。
关键字 default 与 delete
前面提到,编译器的默认成员函数(拷贝构造、移动构造等)有一套微妙的"自动生成/不生成"规则。C++11 给了你两把钥匙,想精确控制默认函数:default 和 delete。
= default:显式告诉编译器"请你(一如既往地)给我生成这个默认函数"。典型场景是:你想用移动构造,但你写了拷贝构造(导致编译器不再生成默认移动构造)——那就在移动构造声明后面加= default,把默认移动构造"请回来"。= delete:显式告诉编译器"我绝不使用这个函数的默认版本",被标记的称为删除函数。调用它会在编译期报错。C++98 里我们通常把函数设为 private 并且只声明来禁止拷贝;C++11 一句= delete更干净。
看例子:
#include <iostream>
#include <string>
using namespace std;
class Person
{
public:
Person(const char* name = "", int age = 0) : _name(name), _age(age)
{}
// 一旦写了拷贝构造,编译器默认就不再生成移动构造
Person(const Person& p) : _name(p._name), _age(p._age)
{}
// 用 default 显式让编译器生成默认移动构造(逐成员移动:string 自身会移动)
Person(Person&& p) = default;
// 如果想去掉拷贝赋值,一个 delete 就搞定:
// Person& operator=(const Person& p) = delete;
private:
std::string _name; // 成员里有 string,移动它会真的走 string 的移动构造
int _age;
};
int main()
{
Person s1("Alice", 20);
Person s2 = s1; // 拷贝构造
Person s3 = std::move(s1); // 移动构造(default 生成,逐成员移动,_name 走 string 移动)
return 0;
}这里注意一个细节:Person(Person&&) = default 生成的默认移动构造,对内置类型成员(_age)做逐成员按字节拷贝,对自定义类型成员(_name)则看它有没有移动构造——有就调移动,没有就调拷贝。这个和拷贝构造的逐成员逻辑是对称的。
总而言之——default 是对编译器说"这个默认行为我很需要,请保留";delete 是对编译器说"这个默认行为我不想要,请禁掉"。
深挖:= default 和空函数体的差别
很多同学会问:Person(Person&& p) = default; 和我自己手动写一个"内容空空的" Person(Person&& p) {} 有区别吗?有,而且区别很关键:
= default:由编译器生成,它会对成员真正做"逐成员移动",而且还能在某些地方被编译器识别为"平凡/便宜"而进一步优化;它本质上是"让编译器照默认配方做"。- 手写空函数体
{}:这是你自己的函数,你什么都没干——即既不移动也不拷贝任何成员!如果_name是std::string,空函数体意味着_name会被默认构造(空串),源_name里的内容连搬都没搬。这通常是 bug。
同理对拷贝构造、拷贝赋值、析构也一样:想"保留编译器默认行为"用 = default;想"自己重写行为"才手写函数体。用 = default 最省、最不容易出错。
深挖:= delete 的更多用武之地(不只是禁拷贝)
delete 不止能删拷贝构造/拷贝赋值,凡是被 delete 的函数,一旦被调用就是编译错误。于是它还有两个杀手级应用:
第一,禁掉"错的参数类型",逼调用者显式转换。 比如你只想让函数接受 double,却想堵住 int 会隐式走进来的口子:
#include <iostream>
using namespace std;
void f(double) { cout << "double 版本\n"; }
void f(int) = delete; // int 不准直接进来
int main()
{
f(3.14); // double -> f(double)
// f(3); // 错误:f(int) 被删除了,int 3 想隐式转 double 的那条路也被挡住
f(static_cast<double>(3)); // 显式转换后才放行
return 0;
}第二,禁拷贝智能指针/锁这种"一次性所有权"对象。 unique_ptr 就是最典型例子:它的拷贝构造和拷贝赋值被 = delete,所以不能复制,只能移动。你看 unique_ptr 文档里就是这样声明的:
unique_ptr(const unique_ptr&) = delete;
unique_ptr& operator=(const unique_ptr&) = delete;第三,堵住内存分配/释放,防止对象被 new 到堆上又没被管理好(把 new 运算符 delete 掉)。总之 delete 是一件让你"精确宣布某招不成立"的利器。
final 与 override
final 和 override 这两个关键字我们在继承和多态章节已经详细讲过了,这里只复习要点。
override:显式标注一个成员函数"我要覆盖基类的虚函数"。它的最大价值是让编译器帮你检查——如果你拼错了函数名、弄错了参数(签名对不上基类虚函数),编译会直接报错,而不是"静默地新增一个函数"。final:作用在两个层面。在类上,class X final { };表示这个类不能再被继承;在虚函数上,virtual void f() final;表示这个虚函数不能再被派生类覆盖。
#include <iostream>
using namespace std;
class Base
{
public:
virtual void draw() { cout << "Base::draw\n"; } // 基类虚函数
};
class Derived : public Base
{
public:
void draw() override { cout << "Derived::draw\n"; } // override:编译器核对签名确实覆盖了 draw()
virtual void seal() final { cout << "sealed\n"; } // final 虚函数:派生类不能再覆盖
};
class Leaf final : public Derived // final 类,不能再被继承
{
public:
void draw() override { cout << "Leaf::draw\n"; }
// void seal() override {} // 错误:Base seal 在 Derived 里被标 final,不可再覆盖
};
// class GrandChild : public Leaf {}; // 错误:Leaf 是 final 类,不能再被继承
int main()
{
Base* p = new Derived();
p->draw(); // Derived::draw(多态)
delete p;
return 0;
}override 的最大价值就在于"报错而非静默"。想象你没写 override、又拼错成 draaw:编译器会当你是想"新增一个全新的虚函数 draaw",完全合法,于是你的多态悄悄失效了,派生类 draw 调用根本不会发生——这种 bug 极其隐蔽。写了 override,编译器立刻吼你:"draaw override 不了任何虚函数!"。所以现代 C++ 铁律:只要想覆盖基类虚函数,就必须写 override。
如果你来不及复习也不用慌,记住一句话就够撑起 C++11 的范围:override 是"帮我验证我是不是真的覆盖了",final 是"到此为止,不许再覆盖/再继承"。
成员变量缺省值(就地初始化)
C++11 还允许你在声明成员变量的时候直接给缺省值(就地初始化)。这个缺省值不是"随便给个初值",它是给初始化列表用的:如果你没有在初始化列表里显式初始化某成员,就会去用声明处这个缺省值。
#include <iostream>
#include <string>
using namespace std;
class Foo
{
public:
// _count 和 _name 有"就地"缺省值;若构造时不进初始化列表,就用它们
Foo() {} // _count=0, _name="unknown"
Foo(int c) : _count(c) {} // _count=c, _name="unknown"
explicit Foo(int c, const char* n) // 两个都显式指定
: _count(c), _name(n)
{}
void show() const { cout << _count << " " << _name << endl; }
private:
int _count = 0; // C++11:声明时给缺省值
string _name = "unknown"; // 同上
};
int main()
{
Foo a;
a.show(); // 0 unknown
Foo b(5);
b.show(); // 5 unknown
Foo c(1, "hello");
c.show(); // 1 hello
return 0;
}这个特性让构造函数写起来更简洁:很多"都能给个默认值"的成员,不必在每个构造函数里重复初始化一份。不过要留意,若成员已在初始化列表显式初始化,则以初始化列表为准,就地缺省值只是保底。
深挖:就地初始化与初始化列表的关系、以及与 C++98 的差异
把这个特性再打磨透几点:
- 优先级:初始化列表 > 就地缺省值 > 什么都不写(那才是"随机/默认构造")。就地缺省值只是"每个构造函数的隐式保底",只要你在某构造函数里显式初始化了它,就按你写的来。
- 顺序问题:无论你用哪种方式给成员初值,成员初始化的实际顺序永远由声明顺序决定,而不是初始化列表的书写顺序。所以
int b = _a; int a = 0;这种"成员声明顺序和依赖关系倒挂"的写法,会造成b先初始化、拿到未初始化的_a——这是 C++ 里一个经典的坑。就地初始化同样逃不开声明顺序的约束。 - 与 C++98 的差异:C++98 里成员必须且只能在构造函数的初始化列表里初始化(否则是随机值),不能在声明处给默认值。就地初始化(学名 NSDMI,non-static data member initializer)是 C++11 新增的。它让"大多数成员都有合理默认值"的类写起来极其清爽——尤其对"内部全是 RAII 成员、构造函数基本不用写"的零法则类,简直是绝配。
nullptr 与强类型枚举
C++ 里关于"空指针",C++98 用的是 NULL。问题在于 NULL 在 C++ 里本质上是个整数 0,这会带来重载歧义这类陈年 bug——NULL 到底是 0 还是 (void*)0,各家实现还不一样。C++11 引入了真正的空指针关键字 nullptr。
nullptr的类型是std::nullptr_t,它可以隐式转换成任何指针类型和成员指针类型,但不会隐式转换成整型。- 像
int* p = nullptr;完全合法;而int n = nullptr;是编译错误,这正好堵住了NULL的坑。
#include <iostream>
using namespace std;
void f(int) { cout << "f(int)\n"; }
void f(void*) { cout << "f(void*)\n"; }
int main()
{
int* p = nullptr; // 明确:p 是空指针
// int n = nullptr; // 错误:nullptr 不能隐式转 int
f(nullptr); // 精确命中 f(void*),不再有歧义
// f(NULL); // 若 NULL 是 0,会命中 f(int),与预期不符
return 0;
}深挖:nullptr_t 到底是什么类型
nullptr 的正式类型是 std::nullptr_t(在 <cstddef> 里定义,C++11 起;nullptr 是它的一个值)。它引入的目的,是把"空指针"从"万能整数 0"里单独立法:
std::nullptr_t只能依次转换为指针类型(T*)、指向成员的指针和bool(变成false),不能隐式转为任何整型/浮点类型;- 所以
int n = nullptr;报错,if (p) / if (p == nullptr)都能正常工作,bool b = (p != nullptr);也合法; nullptr_t本身类型已专向空指针设计,你基本不需要自己拿来声明变量(它大多时候只是"空指针的准确类型名",用于重载/模板分辨)。
这直接根除了 NULL 的两个历史顽疾:一来各家 NULL 定义不一致(0 还是 (void*)0)导致跨平台差异;二来 f(NULL) 存在"走到 f(int) 还是 f(void*)"的重载歧义。用 nullptr 干净利落。
再顺便提一个 C++11 相关但独立的补充——强类型枚举(scoped enum) 也叫 enum class。它和传统 enum 相比有两大改善:①强作用域,枚举值不会"污染"外层命名空间;②不隐式转换成整型,用起来更安全。它也是 C++11 里的常见新写法(上面给的标题把它和 nullptr 并列,因为两者都是 C++11 的"类型化"改进,且课件在值类别里用 nullptr 作过纯右值的例子):
#include <iostream>
using namespace std;
enum class Color : char // 指定底层类型为 char(C++11 支持)
{
Red, // 0
Green, // 1
Blue // 2
};
enum OldColor { ORed, OGreen }; // 传统 enum:值会泄漏到外层作用域
int main()
{
Color c = Color::Green; // 必须带作用域 Color::
// int i = c; // 错误:enum class 不隐式转 int
int i = static_cast<int>(c); // 需要显式转换
cout << i << endl;
int x = ORed; // 传统 enum 就能隐式转 int
cout << x << endl;
// switch 里用 enum class 更安全(编译器会警告未处理的情形)
switch (c)
{
case Color::Red: cout << "red\n"; break;
case Color::Green: cout << "green\n"; break;
case Color::Blue: cout << "blue\n"; break;
}
return 0;
}强类型枚举的更多细节
- 指定底层类型:
enum class Color : char显示指定底层类型(C++11 的写入基础设施),这既让枚举占内存变小,也让它能"稳定"地在跨平台/二进制协议里用(同样的底层类型、同样的编码)。注意它同样影响前置声明——enum class Color : char;想前置声明一个枚举,必须给它确定底层类型,编译器才放行(因为没写完作用域大小编译期就算不清)。 - 枚举值必须带类名:
Color::Green,不能裸写Green;这让枚举值互相之间不再打架(两个枚举都叫Red也没事),这是强作用域的好处。 - 不能隐式转整型:
int i = c;直接报错,必须static_cast<int>(c)。这堵住了"拿一个枚举值去当索引、当计数"这种历史隐患。 - 在 C++11 里建议:新代码一律
enum class,传统enum只有当需与 C 代码兼容或用它充当整型别名时才保留。
lambda 表达式:语法与姿态
上面我们已经遇到过 auto fn = [](...) {...} 这种写法了,现在正式介绍 lambda 表达式。你把它理解为"一个定义在函数内部的匿名函数对象"再合适不过——它跟普通函数不同,可以定义在函数内部;用起来没有显式的类型,所以一般用 auto 或模板参数去接收它。
语法是固定的五段式:
[捕捉列表](参数列表) -> 返回值类型 { 函数体 }拆开讲每段:
[capture-list]捕捉列表:永远出现在 lambda 的开头,编译器靠[]判断接下来的代码是个 lambda。它可以捕捉上下文变量供 lambda 使用,细节下一节讲。捕捉列表即使为空,也不能省略[]。(parameters)参数列表:和普通函数一样;不需要参数时可以连同()一起省略。-> return type返回值类型:用追踪返回类型形式声明;没有返回值时省略;返回值类型明确的常见情况也可以省略,让编译器自动推导。{ function body }函数体:使用参数和捕获的变量;函数体不能省略。
先看一个最小 demo,把语法件件落地:
#include <iostream>
using namespace std;
int main()
{
// 一个最简单的 lambda:两个 int 相加
auto add1 = [](int x, int y)->int { return x + y; };
cout << add1(1, 2) << endl; // 3
// 捕捉列表为空也不能省;参数可以省;返回值可省略;函数体不能省
auto func1 = []
{
cout << "hello" << endl; // 没有返回值
return 0; // 返回 0,编译器推导返回 int
};
func1();
int a = 0, b = 1;
// lambda 也可以接收引用参数来修改外部变量(这里没用到捕捉列表)
auto swap1 = [](int& x, int& y)
{
int tmp = x;
x = y;
y = tmp;
};
swap1(a, b);
cout << a << ":" << b << endl; // 1:0
return 0;
}你发现没有,add1、func1、swap1 这三个"函数"类型各不相同、彼此没有关联,但都能被 auto 接住、被调用。这恰恰说明了 lambda 的定位——一种轻量的、就地定义的匿名可调用对象。
补充:无捕获的 lambda 能退化成函数指针
有一个关于 lambda 的小知识点非常实用:如果一个 lambda 的捕捉列表是空的(没有捕获任何变量),它就能隐式转换成普通的函数指针。因为既然不捕获变量,它本质上就"退化成"了一个静态函数,编译器可以给你一个裸的函数指针:
#include <iostream>
using namespace std;
int main()
{
// 无捕获 lambda 可以转成函数指针
void (*fp)(int) = [](int x) { cout << x << " "; };
fp(42); // 42
// C 风格 API 需要函数指针回调时,无捕获 lambda 能直接喂进去
int (*cmp)(int, int) = [](int a, int b) { return a - b; };
cout << cmp(5, 3) << endl; // 2
// 一旦捕获了外部变量,就不能转函数指针了:
// int base = 10;
// void (*fp2)(int) = [=](int x) { cout << x + base; }; // 错误
return 0;
}这个技巧在对接 C 语言库、旧式回调接口时相当有用——你既享受 lambda 的简洁(不用单独写一个全局函数),又能把它交给一个只认函数指针的老 API。
lambda 的捕捉列表与 mutable
lambda 默认只能使用"函数体内的局部变量 + 自己的参数"。如果你想在函数体里读取/修改外层作用域的变量,就必须通过捕捉列表把外层变量"捕获"进来。C++ 提供三种捕捉姿势:
第一种:显式捕捉。 指定某个变量按值、某几个按引用,用 [x, &y] 这种逗号分隔的格式,x 值捕捉,&y 引用捕捉。
第二种:隐式捕捉。 只写一个 = 表示"我用到的所有外层变量都按值捕捉",或写一个 & 表示"都用引用捕捉"。编译器看你函数体里用到了哪些,就自动捕捉哪些。
第三种:混合捕捉。 [=, &x] 表示"除 x 外全按值,x 按引用";[&, x, y] 表示"除 x、y 外全按引用,x、y 按值"。注意:混合捕捉的第一个元素必须是 = 或 &,且跟在后面的捕捉方向要反过来。
再多说几个容易踩的细节:
- lambda 在局部域,能捕捉定义在它之前的外层变量;不能捕捉静态局部变量和全局变量,因为它们不需要捕捉,在 lambda 里直接用就行。这也意味着定义在全局位置的 lambda 捕捉列表必须为空。
- 默认情况下 lambda 的捕捉是按 const 处理的:按值捕捉进来的变量是 const 的,你在函数体里不能修改它;想改,就在参数列表后加
mutable。但注意:加了 mutable 后,改的只是拷贝进来那份形参对象,不影响外层的实参。加了 mutable 之后,参数列表(即使是空的())不能省略。
写一段全覆盖匿测来验证:
#include <iostream>
using namespace std;
int g = 0; // 全局变量:不需要捕捉,lambda 里可直接用
int main()
{
int a = 0, b = 1, c = 2, d = 3;
// 显式捕捉:a 值捕捉,b 引用捕捉
auto func1 = [a, &b]
{
// a++; // 错误:值捕捉的 a 默认是 const,不能改
b++; // 引用捕捉可以修改外部 b
int ret = a + b;
return ret;
};
cout << func1() << endl; // 0 + 2 = 2
// 隐式值捕捉:用到哪些就按值捕捉哪些
auto func2 = [=]
{
int ret = a + b + c; // a、b、c 都被自动值捕捉
return ret;
};
cout << func2() << endl;
// 隐式引用捕捉:用到的外层变量都按引用捕捉,可修改
auto func3 = [&]
{
a++; b++; c++; d++; // 直接改外层
};
func3();
cout << a << " " << b << " " << c << " " << d << endl; // 1 2 3 4
// 混合捕捉:除 a、b 外都按引用,a、b 按值
auto func4 = [&, a, b]
{
// a++; b++; // 错误:a、b 是值捕捉(const)
c++; d++; // OK:其余按引用捕捉
};
func4();
// mutable:让值捕捉的变量可改,但只改拷贝,不影响外边
auto func7 = [=]() mutable
{
a++; // 现在能在函数体里改了(改的是副本)
b++;
return a + b;
};
cout << func7() << endl; // 自增副本后返回
cout << a << " " << b << endl; // 外部 a、b 不受影响
return 0;
}一句话记住要点:按值捕捉 = 拷一份进来(默认还 const);按引用捕捉 = 直接操作外头那份;mutable = 让那份"拷贝"变得可以修改,但改不到外面。 很多 bug 出在"我以为改到了外面,其实是改了拷贝",或相反——先分清"值还引用捕捉",就能避开八成陷阱。
深挖 mutable:它到底在"改"谁
mutable 这个名字极其容易误导人,它的完整语义值得单独敲黑板:
lambda 其实就是一个仿函数类,它的 operator() 默认是 const 的。 所以"值捕捉的变量"其实是这个仿函数类的成员变量(后面原理一节你会看到),而一个 const 成员函数是不能修改自己的成员变量的——这就是"值捕捉的变量默认不能改"的底层真相。mutable 做的事,就是把这个 operator() 从 const 版改成非 const 版,于是你就能改"自己的那份拷贝成员"了。但它改的永远是拷贝进 lambda 类里的副本成员,跟外面那份外层变量毫无关系——所以对外面的值没有任何影响。
这是不是和"传值参数在函数里改了不影响实参"一模一样?是的——值捕捉就是"传了个值进去",mutable 就是"允许你在函数内部改这个值",它是语义自洽的,只是名字叫"mutable"让人误以为能改外面。真正的"改外面",靠的是按引用捕捉(&)。所以:
- 想"改外面" → 按引用捕捉
[&a]; - 只想"改自己拷进来的副本" → 值捕捉 +
mutable; - 两个都不想要 → 值捕捉,别加 mutable。
深挖:[this] / 初始化捕获(C++14)等进阶捕获
上面是 C++11 的三种捕捉姿势和 mutable,属于"正统三段式"。但现代 C++ 里还有两个极常用、值得你提前知道的进阶形态(C++14 起):
[this]捕获当前对象:在成员函数里写 lambda,常用[this]捕获 this 指针,以便在 lambda 里访问当前对象的成员。因为成员(成员变量)不是普通局部变量,必须通过捕获this才能访问。注意 C++11 只有[this](捕获 this 指针);C++17 又加了[*this](捕获对象的拷贝)——[this]捕获指针有"对象销毁后 lambda 还活着就悬空"的风险,[*this]捕获拷贝更安全。- 初始化捕获(generalized capture / init-capture,C++14):
[y = x + 1]允许在捕捉列表里同时定义一个"仅 lambda 内部可见的新变量"。这在 moving 进 lambda、或捕获时做变换时特别有用。
这些是 lambda 的"进阶关卡",等你把 C++11 的三段式 + mutable 吃透,遇到它们就不会发怵。
深挖:按引用捕捉的悬挂风险
最后给你敲一个报警器:按引用捕捉有个致命前提——引用指向的对象必须比 lambda 活得久。 如果你把趁引用捕捉的 lambda 存下来(比如放进 std::function 或容器),又让 lambda 的寿命超过了那些引用对象的寿命,等 lambda 再被调用时,它手里握着的引用就是悬空引用,访问即未定义行为:
#include <iostream>
#include <functional>
using namespace std;
std::function<int()> make()
{
int local = 100;
return [&]{ return local; }; // 危险:local 是栈上局部,函数返回就亡了
}
// make() 返回后,local 已销毁,这个 lambda 再调用会悬空
int main()
{
// 下面这行一调用就会用到悬空引用(行为未定义,可能崩溃/输出乱值)
// auto f = make();
// cout << f() << endl;
// 治本一:按值捕捉,把值拷进 lambda
std::function<int()> ok = [x = 100]{ return x; }; // (初始化捕获,C++14)
std::function<int()> ok2 = [y = 42]{ return y; }; // C++14 语法
cout << ok() << " " << ok2() << endl;
// 治本二:明确对象的生命周期比 lambda 长(如全局、静态、或被长存对象持有)
return 0;
}这句原理的通用化就是:值捕捉适合"自包含、要存起来慢慢用"的 lambda;引用捕捉适合"就地传、立刻用、不收藏"的临时回调。要存进容器或拖到后面才调用,就多考虑按值捕捉,或用初始化捕获把需要的值拍进 lambda。
lambda 的原理:本质是一个仿函数
前面讲范围 for 时我提过一个观点:编译器眼里根本没有"范围 for"这个概念,它底层就是迭代器。lambda 同理——编译器眼里也没有 lambda,它会给每个 lambda 生成一个对应的仿函数类。
你看下面这个对比,左边用手写的仿函数类 Rate,右边用 lambda,二者是等价的:
#include <iostream>
using namespace std;
// 手写的一个"仿函数类":重载 operator(),内部存一个成员 _rate
class Rate
{
public:
Rate(double r) : _rate(r) {}
double operator()(double money, int year) // lambda 的函数体就对应这里
{
return money * _rate * year;
}
private:
double _rate; // lambda 的"值捕捉变量"就对应这里的成员
};
int main()
{
double rate = 0.49;
// 用 lambda 实现同样的事情:rate 是值捕捉
auto r2 = [rate](double money, int year)
{
return money * rate * year;
};
// 手写仿函数
Rate r1(rate);
cout << r1(10000, 2) << endl; // 9800
cout << r2(10000, 2) << endl; // 9800
return 0;
}这个对应关系是理解 lambda 的钥匙:
- lambda 的捕获列表 → 生成类的成员变量(值捕捉就是成员变量的构造/初始化实参,引用捕捉就是成员是引用);
- lambda 的参数/返回类型/函数体 → 生成类的
operator()的参数/返回类型/函数体; - 编译器会按一套规则给每个 lambda 生成一个独一无二的名字,保证彼此不冲突。
从汇编/编译的角度看,r2(...) 的调用本质就是调用了某个 <lambda_1>::operator(),跟你调 r1(...) 的 Rate::operator() 毫无二致。所以你完全可以把 lambda 当成"匿名的、就地定义的仿函数"来理解,而你之前学的仿函数(函数对象)的所有玩法——传给 sort、存进容器、用作回调——lambda 全部照单全收,而且更省事。
课件里还给了汇编级的一个印证:你反汇编会看到 r2(10000, 2) 实际上是 call <lambda_1>::operator()(...),而 r1(...) 是 call Rate::operator()(...)——两者本就是同一个层面的东西。这也解释了"为什么每个 lambda 的类型都不同":编译器为每个 lambda 生成一个彼此不同的仿函数类(类名按自定规则唯一生成),所以 auto a = []{}; auto b = []{}; 的 a 和 b 是两个不同类的对象,互不能赋值、类型不互相兼容。
比如排序:
#include <iostream>
#include <vector>
#include <string>
#include <algorithm> // sort
using namespace std;
struct Goods
{
string _name;
double _price;
int _evaluate;
Goods(const char* n, double p, int e) : _name(n), _price(p), _evaluate(e) {}
};
int main()
{
vector<Goods> v = {
{ "苹果", 2.1, 5 },
{ "香蕉", 3.0, 4 },
{ "橙子", 2.2, 3 },
{ "菠萝", 1.5, 4 }
};
// 以前要为此定义一个仿函数类或函数指针,现在一行 lambda 就够了
sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2)
{
return g1._price < g2._price; // 按价格升序
});
sort(v.begin(), v.end(), [](const Goods& g1, const Goods& g2)
{
return g1._evaluate > g2._evaluate; // 按评价降序
});
for (const auto& g : v)
cout << g._name << " " << g._price << " " << g._evaluate << endl;
return 0;
}std::function:统一可调用对象的类型
上面我们看到,函数指针、仿函数、lambda……它们类型各异、互不相通。这就带来了麻烦:我如果想定义"一个可调用的对象",到底用什么类型去声明?C++11 给的答案是 std::function——它是一个类模板,也是一个可调用对象包装器,把各种可调用对象(函数指针、仿函数、lambda、bind 表达式等)统一起一个类型来。它定义在 <functional> 头文件中。
std::function 的实例对象可以"存放"任意附带相同签名的可调用对象,存放的目标称为它的 target。如果它不含目标(是空的),调用它会抛出 std::bad_function_call 异常。
最典型的声明形式带一个函数签名:
std::function<返回类型(参数类型列表)> 变量 = 某个可调用对象;看 demo,把一堆风格迥异的可调用对象统一装进同一个类型:
#include <iostream>
#include <functional>
using namespace std;
// 普通函数
int f(int a, int b) { return a + b; }
// 仿函数
struct Functor
{
int operator()(int a, int b) { return a + b; }
};
// 带静态和普通成员函数的类
class Plus
{
public:
Plus(int n = 10) : _n(n) {}
static int plusi(int a, int b) { return a + b; } // 静态成员函数
double plusd(double a, double b) { return (a + b) * _n; } // 普通成员函数
private:
int _n;
};
int main()
{
using BinOp = function<int(int, int)>; // 统一的类型别名
BinOp f1 = f; // 存函数指针
BinOp f2 = Functor(); // 存仿函数
BinOp f3 = [](int a, int b) { return a + b; }; // 存 lambda
cout << f1(1, 1) << " " << f2(1, 1) << " " << f3(1, 1) << endl; // 2 2 2
// 包装静态成员函数:需要带类域 + 取地址
BinOp f4 = &Plus::plusi;
cout << f4(1, 1) << endl; // 2
// 包装普通成员函数:成员函数隐含一个 this 参数
// 所以签名里第一个参数要写成 Plus* / Plus&(对象自身)
function<double(Plus, double, double)> f5 = &Plus::plusd;
Plus pd;
cout << f5(pd, 1.1, 1.1) << endl; // (1.1+1.1)*10 = 22
return 0;
}注意普通成员函数那点:成员函数有个隐含的 this 参数,所以包装它时,函数签名里要有一个对象参数占住 this 的位置。这是 function 包装成员函数时最容易搞错的地方。
深挖:function 的"类型擦除"原理
std::function 之所以能用一个固定类型装下"函数指针/仿函数/lambda"等等五花八门的东西,靠的是 C++ 的 类型擦除(type erasure)。它的内部大致长这样:
class function
{
// 内部一个"基类指针",指向一个"抹掉了具体类型的虚基类"
// 而实际装进来的 Functor / lambda,被包进一个模板化的派生类里
// 所有"调用"都通过这个虚基类的 virtual operator() 分发到不同的实现
};换句话说:std::function 内部有一段"统一协议"的虚函数接口,当你把任何可调用对象塞进来时,function 会用模板做一个"适配器",把那个具体可调用对象包起来,转走标准化的虚调用。你一调用 f(x),它内部先经过这个"虚函数门面"转发到真正干活的那个 lambda 或仿函数上。这就是为什么"函数指针、仿函数、lambda"这三个类型迥异的东西能被塞进同一个 function。
有两句后面的工程提醒很重要:
std::function通常有一层间接(虚函数)分发,而且function对象本身一般比较大(几十字节,用于容纳小对象;放不下大型可调用对象时内部还会走堆分配)。所以热路径 / 高频回调别为图省事乱用function——能直接用函数指针或模板就更好;function的价值是"类型统一"和"运行时多态的灵活性",不是超高性能。- 空
function调用会抛std::bad_function_call。你可以用operator bool()、!fn或fn == nullptr来检测它是否为空:
#include <iostream>
#include <functional>
using namespace std;
int add(int a, int b) { return a + b; }
int main()
{
function<int(int, int)> fn; // 默认是空的
cout << boolalpha << "fn 为空? " << (fn == nullptr) << endl; // true
if (fn) // 空的 function 转成 bool 是 false
fn(1, 2);
else
cout << "还没有目标,先别调用\n";
fn = add;
cout << "fn 为空? " << (fn == nullptr) << endl; // false
cout << fn(3, 4) << endl; // 7
// target() 可以取出所存的真实可调用对象(前提是类型完全匹配)
if (int (*p)(int, int) = fn.target<int(*)(int, int)>())
cout << "取回目标并调用: " << p(1, 2) << endl; // 3
return 0;
}std::function 最大的价值是"类型统一"。用它你就能做出一张"字符串 → 某种可调用对象"的映射表,从而用数据驱动替换掉一大坨 switch。经典的例子是逆波兰表达式求值:
#include <iostream>
#include <stack>
#include <vector>
#include <string>
#include <map>
#include <functional>
using namespace std;
// 用 map<string, function<int(int,int)>> 把运算符映射到各自的 lambda
int evalRPN(vector<string>& tokens)
{
stack<int> st;
map<string, function<int(int, int)>> opFuncMap = {
{"+", [](int x, int y) { return x + y; }},
{"-", [](int x, int y) { return x - y; }},
{"*", [](int x, int y) { return x * y; }},
{"/", [](int x, int y) { return x / y; }}
};
for (const auto& str : tokens)
{
if (opFuncMap.count(str)) // 是运算符
{
int right = st.top(); st.pop();
int left = st.top(); st.pop();
st.push(opFuncMap[str](left, right));
}
else
{
st.push(stoi(str)); // 是数字
}
}
return st.top();
}
int main()
{
vector<string> tokens = { "2", "1", "+", "3", "*" }; // (2+1)*3
cout << evalRPN(tokens) << endl; // 9
return 0;
}这种写法的最大优势是方便扩展:以后再加一种运算,往 map 里塞一个 lambda 即可,不用去改那个 switch-case 的骨架。这就是"类型统一"带来的红利。
std::bind:灵活的参数适配器
std::bind 也是一个可调用对象的包装器,你可以把它看作一个函数适配器:它接收一个可调用对象 fn 和一组参数 args,处理后返回一个新的可调用对象。bind 最重要的两个用途是调整实参个数和调整实参顺序。它也定义在 <functional> 中,占位符 _1、_2、_3…… 存放在名字空间 std::placeholders 里。
最常用的调用形式:
auto newCallable = bind(callable, arg_list);newCallable是返回的可调用对象;arg_list是逗号分隔的参数列表,对应callable的参数;- 其中出现
_n表示占位符:_1指newCallable的第一个实参,_2指第二个,依此类推。
而 arg_list 里"不是占位符"的部分,会被"绑死"——也就是说你给它一个固定值,之后调用 newCallable 时不必再传。
#include <iostream>
#include <functional>
using namespace std;
using placeholders::_1;
using placeholders::_2;
using placeholders::_3;
int Sub(int a, int b) { return (a - b) * 10; }
int SubX(int a, int b, int c) { return (a - b - c) * 10; }
class Plus
{
public:
static int plusi(int a, int b) { return a + b; }
double plusd(double a, double b) { return a + b; }
};
int main()
{
// 原样转发两个参数
auto sub1 = bind(Sub, _1, _2);
cout << sub1(10, 5) << endl; // (10-5)*10 = 50
// 调整参数顺序:_2 代表第一个实参、_1 代表第二个实参
auto sub2 = bind(Sub, _2, _1);
cout << sub2(10, 5) << endl; // (5-10)*10 = -50
// 绑死常量参数:只需要一个实参,另一个固定为 100
auto sub3 = bind(Sub, 100, _1);
cout << sub3(5) << endl; // (100-5)*10 = 950
auto sub4 = bind(Sub, _1, 100);
cout << sub4(5) << endl; // (5-100)*10 = -950
// 三个参数的绑定,部分绑死
auto sub5 = bind(SubX, 100, _1, _2);
cout << sub5(5, 1) << endl; // (100-5-1)*10 = 940
// 与 function 结合:把成员函数绑定到一个对象上,之后调用不再需要传对象
function<double(double, double)> f7 = bind(&Plus::plusd, Plus(), _1, _2);
cout << f7(1.1, 1.1) << endl; // 2.2
// 现实场景:绑定复利 lambda 的部分参数,制成只差"本金"一个参数的函数
auto func = [](double rate, double money, int year) -> double
{
double ret = money;
for (int i = 0; i < year; ++i)
ret += ret * rate;
return ret - money;
};
function<double(double)> interest3y = bind(func, 0.015, _1, 3);
function<double(double)> interest5y = bind(func, 0.015, _1, 5);
cout << interest3y(1000000) << endl; // 3 年 1.5% 复利息
cout << interest5y(1000000) << endl; // 5 年 1.5% 复利息
return 0;
}深挖:bind 用 ref/cref 传引用、以及 bind 与 lambda 的关系
bind 有三条进阶细节,值得你记住:
- 默认按值绑定,可能拷贝大对象:
bind里的一个普通参数(如 100、0.015)会被"按值绑定"进返回的可调用对象。如果被绑的是一个你想按引用传递的大对象,直接写bind(f, obj, _1)会把它拷贝进去。想按引用传,得显式包一层std::ref(obj)/std::cref(obj)(这俩在<functional>):
#include <iostream>
#include <functional>
using namespace std;
void bump(int& x) { ++x; } // 想改实参
int main()
{
int n = 10;
auto f = bind(bump, ref(n)); // ref(n):按引用绑定,不是拷贝 n 的副本
f();
cout << n << endl; // 11(被改了)
return 0;
}- 占位符可以"少用"但不能"多要":
_1必须从 1 开始连续,结果里出现的最大的_n,就是你调用newCallable时至少要传的参数个数。传多了?多传的就"吞掉"不报错(可调用对象会丢弃多余的实参)。 - bind vs lambda 的取舍:在 C++11 里
bind是个不错的工具;但现代推荐写 lambda 而不是 bind。因为 lambda 更直观、更安全(类型推导看得住),可读性远好于占位符满天飞的bind。bind主要在 C++11 没得选、或与std::function/回调固定签名配合时仍有用武之地。你自己写新代码时,优先 lambda。
一句话定位 bind:它是"参数适配器"——把原本参数又多又乱的函数,重新组装成"参数更少、顺序更友好"的新可调用对象。很多回调场景(例如注册事件、传给 STL 算法)都能靠 bind 把签名"掰"成目标要求的样子。
智能指针概览
最后简短交代智能指针。课件指出智能指针会在单独的"智能指针"课程里详细讲,这里给你一个概览框架,把它纳入 C++11 新特性的大盘里。
C++11 里最重要的智能指针是 std::shared_ptr 和 std::unique_ptr(还有配套的辅助工具 std::weak_ptr 避免循环引用)。它们把"动态内存必须手动 delete"这个 C++98 的老大难,变成了"对象不再被使用就自动释放"的安全机制。
#include <iostream>
#include <memory>
using namespace std;
int main()
{
// unique_ptr:独占所有权,不能拷贝,只能移动
unique_ptr<int> up = make_unique<int>(42);
// unique_ptr<int> up2 = up; // 错误:独占指针不能拷贝
unique_ptr<int> up2 = move(up); // 可以移动
cout << *up2 << endl;
// shared_ptr:共享所有权,引用计数归零时自动释放
shared_ptr<int> sp = make_shared<int>(7);
{
shared_ptr<int> sp2 = sp; // 拷贝:引用计数 +1
cout << sp.use_count() << endl; // 2
}
cout << sp.use_count() << endl; // 回到 1
return 0;
}深挖:三个智能指针的分工、以及 unique_ptr 还藏着两个大杀器
unique_ptr独占所有权:同一时刻只能有一个 owner,不能拷贝、只能移动。因为它把 move 用的好的话开销基本为零(就是个指向堆对象的指针)。另外它有前后两个引用来做"自定义删除器"和"指向数组"的能力:- 自定义删除器:
unique_ptr<T, Deleter>的第二个模板参数让你在销毁时干自定义的活(比如close(fd)关文件描述符、自定义资源释放逻辑)。这让你能用它管理"不是 new 出来的资源"(文件、socket、内存池等)。 - 数组特化:
unique_ptr<T[]>专为动态数组设计,析构时调用delete[]而不是delete,避免数组只删首元素的经典错误。
- 自定义删除器:
shared_ptr共享所有权:靠一个内部的引用计数(control block)记录有多少个 owner,计数归零到 0 时自动销毁对象并释放自己。make_shared<T>一次性分配"对象+控制块",比分开new+ 构造shared_ptr更省一次堆分配、更安全(避免异常时的泄漏)。weak_ptr是"不增加引用计数的观察者":它不拥有所有权,只是"偷偷看着"那个对象还活着没有,主要用于打破shared_ptr的循环引用(两个对象互相持有着对方,导致计数永远到不了 0、永久泄漏)。用weak_ptr把其中一个方向的引用降级为"不计数"就能解套。
你会在后面的智能指针专题里学到它们的引用计数原理、weak_ptr 解决循环引用等细节。这里只要记住它们的存在与基本分工即可:unique_ptr 独占、shared_ptr 共享、weak_ptr 辅助打破循环——这三者共同把 C++ 的"手动管理内存"在许多场景下升级成了"自动管理"。
STL 里的新变化
最后,我们把 C++11 给 STL 带来的变化整体收个尾,很多我们其实已经讲到了:
- 新容器:新增了
unordered_map、unordered_set(哈希表,平均 O(1) 查找)以及array、forward_list、tuple等。其中最实用的是前两个,它们的原理我们之前讲哈希时已经很详细地研究过了,其他了解即可。 - 新接口:容器普遍增加了右值引用版本的
push/insert系列、emplace系列(移动 + 在容器空间直接构造)、initializer_list版本的构造和赋值,还有配套的移动构造/移动赋值——这些都在这篇文章前面逐条讲透了。 - 统一初始化 + 范围 for:容器都支持
{...}列表初始化和基于范围的 for 遍历,这些也在前面验证过了。 - 其他一些小工具(如
cbegin/cend等)需要时查查文档即可,不必死记。
把新容器之一起个底——比如 std::tuple(元组),它让你能"一次打包多个不同类型的值",和 std::pair 是一脉相承的升级版:
#include <iostream>
#include <string>
#include <tuple>
using namespace std;
int main()
{
tuple<int, string, double> t{ 1, "hello", 3.14 };
cout << get<0>(t) << " " << get<1>(t) << " " << get<2>(t) << endl;
// 结构化绑定(C++17,放到这里一提,帮你建立后续知识的锚点)
auto [i, s, d] = t;
cout << i << " " << s << " " << d << endl;
return 0;
}std::array:定长数组但要它"现代化"
如果你还好奇 array 是干嘛的,这里用一句话说清:std::array<T, N> 是"更安全的定长数组"——它和 C 的 T arr[N] 一样是栈上的固定大小数组(没有任何堆分配、没有 new),但具备了 size()、begin()/end()(可被范围 for 遍历)、at()(越界检查)、以及赋值/拷贝等现代容器的待遇。它跟 vector 的区别就是"长度编译期固定、不能扩容"。当你需要"一块固定大小、又想用现代容器接口"的存储时,array 是首选。同理 forward_list 是"C++11 才加入的、内存极小的单向链表",知道名字即可。
到这里,C++11 这枚"憋了 8 年的重型火箭"的核心载荷我们都拆完了。借这股劲往回看,你会发现所有新特性其实是在织一张网:列表初始化让写法更安全;auto / decltype / 范围 for让代码更清爽;而贯穿全篇主线的右值引用与移动语义,则让"传值返回、按值传参、容器搬运"这些本来让人肉疼的拷贝开销,一个个变成了 O(1) 的资源转手;std::move 负责打标签、移动构造/赋值负责真正搬资源、std::forward 负责身份无损地层层传递;可变参数模板 + emplace 在容器空间直接构造;lambda、function、bind 让"可调用对象"这件事变得既自由又统一;final / override / default / delete 把类的默认行为牢牢抓在程序员手里;nullptr、强类型枚举、智能指针则把一批历史遗留的坑一个个填平。
从 C++98 到 C++11,C++ 不再只是"带类的 C",它带着一整套现代范式正式登台——这也是"现代 C++(Modern C++)"这个名字的由来。后面的 C++14、17、20、23 基本都是在这套地基上继续盖楼。所以这篇文章你值得反复回来翻——尤其是移动语义那几节,它是往后所有高性能代码的命门。让我们下一站见。下一篇文章,我们会一头扎进 C++11 最惊人的一个细节:把右值引用和完美转发玩到极致——智能指针与资源管理的完整实现。
还没有评论 — 第一条由你来留。