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&&),它给右值取别名。引用就是取别名,语法上都是不另开空间,底层实现都是指针,你不用去纠结汇编层面——语法表达的语义,和底层汇编的实现,不需要强行对等,混在一起理解反而容易走进死胡同。

记住三条铁律:

  1. 左值引用不能直接引用右值;但 const 左值引用可以引用右值。
  2. 右值引用不能直接引用左值;但右值引用可以引用 std::move(左值)。
  3. 变量的表达式,都是左值属性——哪怕它本身是右值引用类型。也就是说,一个右值被右值引用 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 最惊人的一个细节:把右值引用和完美转发玩到极致——智能指针与资源管理的完整实现。