第23章:C++11(上)思维导图


第二十三章:C++11(上)

一、C++语言标准发展历史

1. 核心版本里程碑

C++的发展核心分为两个关键分水岭,C++98C++11是迄今为止最重要的两个标准,也是企业开发中必须掌握的核心版本。

标准版本 发布时间 版本定位 核心特点
C++98 1998年 核心奠基版本 定档了C++最核心的语法,包括面向对象、模板、STL等核心能力,是C++语法体系的根基
C++03 2003年 修订版本 并非新标准,仅对C++98的问题进行bug修订,无新增核心特性
C++11 2011年 重大革新版本 时隔13年的大版本更新,引入大量实用新特性,是C++现代化的核心分界点
C++14 2014年 小修小补版本 仅对C++11的特性做少量补充和修订,无重量级新特性
C++17 2017年 中等更新版本 补充了部分实用特性,但整体属于非必需的扩展内容
C++20 2020年 次重大更新版本 再次引入一批重量级特性,是C++11后最大的一次更新
C++23 2023年 新版本 已发布,但编译器支持度不足,企业中极少使用
C++26 规划中 待发布版本 尚未正式发布,处于规划阶段
2. 五年计划到三年计划的转变
  • C++98发布后,标准委员会制定了五年计划,预期每5年更新一次大版本,原定第二个版本在2007-2008年发布。
  • 2006年C++新标准开发烂尾,委员会无法按期完成版本交付,将临时版本命名为C++0xx代表不确定的发布年份),直到2011年才正式发布,因此最终命名为C++11。
  • 因版本烂尾和长期拖延,委员会受到大量开发者的批评,自此放弃五年计划,改为三年一更的固定迭代节奏,后续的14、17、20、23均严格遵循三年更新的规则。
3. 企业使用现状与学习建议
  • 企业开发中,C++98和C++11是绝对主流,几乎所有公司都会使用这两个标准,是学习的核心重点。
  • 后续的C++14/17/20,企业会选择性使用,部分技术激进的公司会用到C++20的部分特性,保守型公司仍仅使用C++98+11。
  • C++23因编译器支持度不足,目前企业中几乎没有落地使用。

【核心学习建议】
优先100%掌握C++98和C++11,这两个标准足以覆盖99%的开发场景;后续的14/17/20仅作为扩展补充学习,时间紧张可延后学习,不影响求职、考研和日常开发。

4. 标准与编译器的关系
  • C++标准仅负责制定语法规则和库的规范,标准的落地实现由编译器完成,标准发布不等于所有编译器都能立刻支持。
  • 主流C++编译器包括三大类:MSVC(Visual Studio系列)、GCC(g++)、Clang。
  • 编译器支持差异:
    1. GCC和Clang对新标准的支持速度更快,C++23的特性在最新版GCC中已有较好支持。
    2. MSVC(Visual Studio)对新标准的支持相对滞后,比如C++11的部分特性直到VS2015才完整支持,C++23目前仍有大量特性未支持。
    3. 部分标准委员会制定的特性,因实现难度过高或无实用价值,至今无任何编译器支持(如垃圾回收相关特性)。

【新特性使用警告】
一个新标准发布后,建议等待2-3年,待编译器支持成熟、行业验证稳定后再大规模使用;仅当项目中有强需求时,再针对性使用个别新特性。

5. 其他补充细节
  • C++标准长期被开发者诟病的点:官方网络库迟迟未落地,这也是C++在网络开发场景中的一大短板。
  • 对于使用频率低的小众特性,无需强行记忆,遇到时可通过官方文档、AI工具查询学习即可。
  • C++11是C++语法风格的分水岭,C++98仍遵循C语言的传统设计风格,而C++11开始借鉴了其他语言的优秀设计(如范围for借鉴Pascal,列表初始化借鉴C语言),学习时需要用学习新语法的视角对待。

二、C++11列表初始化(统一初始化)

1. 传统初始化的痛点

在C++98中,仅数组、聚合类型的结构体支持{}花括号初始化,内置类型、自定义类类型无法使用,初始化方式不统一,不同类型的初始化规则割裂。

// C++98 仅支持的花括号初始化场景
struct Point
{
    int x;
    int y;
};
int a1[] = { 1, 2, 3, 4, 5 };
Point p = { 1, 2 }; // 结构体聚合类型
2. C++11列表初始化的核心目标

C++11对花括号列表初始化做了全面扩展,核心目标是实现大一统的初始化方式——一切类型皆可使用花括号列表初始化,无论是内置类型、自定义类类型,还是STL容器,都可以用同一套初始化规则。

3. 基础语法与使用规则
(1)内置类型的支持

C++11中,内置类型可以直接使用花括号初始化,且支持省略赋值号。

// C++11 内置类型列表初始化
int x0 = 1;        // 传统初始化
int x1 = { 2 };    // 花括号+赋值号
int x2{ 2 };       // 省略赋值号,直接花括号初始化
(2)自定义类型的支持

自定义类类型可以通过花括号直接调用构造函数完成初始化,底层本质是隐式类型转换,语法逻辑为「构造+拷贝构造」,编译器会优化为直接构造,无额外性能开销。

class Date
{
public:
	Date(int year = 1, int month = 1, int day = 1)
		:_year(year), _month(month), _day(day)
	{}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
    // C++98 传统构造
	Date d0(2025, 5, 19);
    // C++11 列表初始化
	Date d1 = { 2025, 5, 19 }; // 带赋值号
	Date d3 { 2025, 5, 19 };    // 省略赋值号
	Date d2 { 2025 };            // 单参数,调用全缺省构造
	return 0;
}
(3)核心使用场景
  1. 容器传参场景:极大简化了STL容器的元素插入写法,无需手动创建匿名对象。

    vector<Date> v;
    // C++98 需要手动创建匿名对象
    v.push_back(Date(2025, 5, 19));
    // C++11 列表初始化直接传参
    v.push_back({ 2025, 5, 19 });
    v.push_back(2025); // 单参数隐式转换+列表初始化
    
  2. 常量引用绑定临时对象:const左值引用可以直接绑定花括号初始化的临时对象。

    // d4引用的是{2024,7,25}构造的临时对象
    const Date& d4 = { 2024, 7, 25 };
    

【易错警告】

  1. 花括号初始化的临时对象具有常性,必须用const左值引用绑定,否则会编译报错。
  2. 列表初始化支持多参数的隐式类型转换,这是C++98不具备的能力(C++98仅支持单参数隐式转换)。

三、initializer_list(初始化列表)

1. 解决的核心问题

普通的列表初始化依赖类的构造函数,参数个数与构造函数的形参个数强绑定,无法支持任意多个同类型参数的初始化。而initializer_list的出现,完美解决了这个问题,让STL容器等类型可以支持任意长度的花括号列表初始化。

// 普通自定义类型:参数个数与构造函数绑定,仅支持1-3个参数
Date d1 { 2025, 5, 19 };
// vector容器:支持任意多个参数初始化,底层依赖initializer_list
vector<int> v1 {1, 2, 3, 4, 5, 6 };
vector<int> v2 { 1, 2, 3, 4, 5, 6,7,8,9 };
2. initializer_list的底层实现

initializer_list是C++11新增的类模板,专门用于接收花括号包裹的同类型数据列表,其底层实现极度轻量:

  1. 内部仅维护两个指针:first(指向列表数组的首元素)和last(指向列表数组的尾后位置)。
  2. 编译器遇到{a,b,c,d}这类列表时,会在栈上开辟一个数组存储这些常量,再创建一个initializer_list对象,用两个指针指向这个数组的首尾。
  3. 支持迭代器遍历,其迭代器本质就是原生指针,因此天然支持范围for循环。
  4. 编译器实现差异:VS下会将列表数据拷贝到栈上的数组中;部分编译器可能直接指向常量区的数组。
// initializer_list 基础使用
int main()
{
    // auto自动推导为 initializer_list<int>
	auto il1 = { 10, 20, 30 };
    // 显式定义initializer_list
	initializer_list<int> il2 = { 10, 20, 30, 5, 5, 5 };
    // 打印类型名,验证类型
	cout << typeid(il1).name() << endl;

    // 支持迭代器遍历
    for (auto it = il1.begin(); it != il1.end(); ++it)
    {
        cout << *it << " ";
    }
    // 支持范围for
    for (auto e : il2)
    {
        cout << e << " ";
    }
	return 0;
}
3. 核心应用场景
  1. STL容器的构造与赋值:C++11后,所有STL容器都新增了initializer_list版本的构造函数和赋值运算符重载,支持直接用花括号列表初始化和赋值。

    int main()
    {
        // 容器构造
        vector<Date> v3 = { {2025,5,19}, {2025,5,29}, {2025,6,19} };
        map<string, string> dict = { {"sort", "排序"}, {"string", "字符串"} };
        // 容器赋值
        vector<int> v1;
        v1 = { 10,20,30,40,50 }; // initializer_list版本的赋值
        // const引用绑定
        const vector<int>& v4 = { 1,2,3,4,5 };
        return 0;
    }
    
  2. 自定义类型支持任意长度初始化:为自定义类实现initializer_list版本的构造函数,即可让自定义类型支持任意长度的列表初始化。

【补充说明】
嵌套列表初始化的本质:外层容器的initializer_list中,每个元素又通过列表初始化完成自身的构造,比如mappair对象、vector<Date>中的Date对象,都是两层列表初始化的结合使用。


四、左值与右值核心概念

1. 历史误区纠正

很多初学者会误以为:左值就是赋值号左边的值,右值就是赋值号右边的值,这个理解是完全错误的。

  • 左值既可以出现在赋值号左边,也可以出现在右边;
  • 右值只能出现在赋值号右边,绝对不能出现在赋值号左边。
2. 左值与右值的唯一判断标准

这是区分左值和右值的核心准则,必须牢记:

【核心结论】

  • 左值:可以通过&取地址符获取其内存地址的表达式,拥有持久化的存储状态。
  • 右值:无法通过&取地址符获取其内存地址的表达式,生命周期短暂,仅在当前表达式行有效。
3. 常见左值与右值举例
(1)常见左值
  • 普通变量、const修饰的常量变量(const变量也能取地址,因此也是左值);
  • 指针变量、指针解引用的结果*p
  • 数组的下标访问结果s[0]
  • 左值引用返回的函数调用结果。
int main()
{
    // 以下都是左值,均可取地址
	int* p = new int(0);
	int b = 1;
	const int c = b; // const变量也是左值
	*p = 10;
	string s("111111");
	s[0] = 'x';

    // 均可成功取地址
	cout << &b << endl;
	cout << &c << endl;
	cout << &s[0] << endl;
	return 0;
}
(2)常见右值
  • 字面量常量(如103.14"hello");
  • 算术表达式的运算结果(如x + yx * y);
  • 传值返回的函数调用结果(返回值为非引用类型);
  • 匿名对象、临时对象。
int main()
{
	double x = 1.1, y = 2.2;
    // 以下都是右值,均无法取地址,编译报错
	10;
	x + y;
	fmin(x, y); // 传值返回的函数结果
	string("11111"); // 匿名临时对象

    // 以下代码均会编译报错
	// cout << &10 << endl;
	// cout << &(x+y) << endl;
	// cout << &string("11111") << endl;
	return 0;
}
4. 底层存储补充说明
  • 左值:通常存储在内存的栈区或堆区,拥有稳定、持久的内存地址,因此可以取地址。
  • 右值:没有稳定的内存地址,可能直接存储在CPU寄存器中,也可能是代码段中的字面常量,生命周期仅在当前表达式,表达式结束后立即销毁,因此编译器禁止对其取地址。
5. 术语补充
  • 传统C++中,左值缩写为lvalue(left value),右值缩写为rvalue(right value);
  • 现代C++中,对其有了更精准的定义:
    • lvalue = locator value,代表有明确内存存储位置、可定位的值;
    • rvalue = read value,代表无明确可寻址位置、仅能读取的临时值。
6. 右值生命周期延长

const左值引用 和 右值引用 可以延长右值的生命周期,将临时对象/匿名对象的生命周期延长到和引用变量一致,直到引用变量出作用域才析构。
设计目的:避免引用即将销毁的临时对象,产生野引用问题。

// 右值生命周期延长示例
const bit::string& r1 = bit::string("111111"); // const左值引用延长生命周期
bit::string&& r2 = bit::string("222222");      // 右值引用延长生命周期
// 此时r1、r2引用的临时对象,不会在当前语句结束后析构,直到出作用域
7. 右值细分:纯右值与将亡值

C++11标准将右值细分为两类,日常开发只需了解,核心还是区分左值和右值:

  • 纯右值:C++98中的传统右值,如字面量、传值返回的临时对象、后置++表达式结果;

  • 右值(rvalue) = 纯右值 (prvalue) + 将亡值 (xvalue)

  • 将亡值:C++11新增,和移动语义强相关,如std::move的返回值、返回右值引用的函数表达式。

  • 泛左值:generalized value,简称glvalue,泛左值包含将亡值左值
    在这里插入图片描述
    lvalue 左值 (l-value)
    rvalue 右值 (r-value)
    prvalue 纯右值 (pure r-value)
    xvalue 将亡值 (expiring value)
    glvalue 泛左值 (generalized l-value)


五、左值引用与右值引用

1. 引用的本质

无论是左值引用还是右值引用,本质都是给对象/值取别名,上层语义为别名,底层通过指针实现,本身不会开辟新的内存空间存储数据。

2. 左值引用(C++98原生支持)

左值引用就是给左值取别名,C++11后为了和右值引用区分,将传统的引用明确命名为左值引用

  • 语法:Type& 别名 = 左值;
  • 核心限制:普通左值引用无法直接引用右值
  • 特殊规则:const左值引用可以引用右值,这是C++98就支持的特性,也是函数传参推荐用const T&的核心原因——可以同时接收左值和右值,避免拷贝。
int main()
{
    int b = 1;
    // 左值引用:给左值b取别名
    int& r1 = b;
    // 普通左值引用无法引用右值,编译报错
    // int& r2 = 10;
    // const左值引用可以引用右值,编译通过
    const int& r3 = 10;
    const double& r4 = 1.1 + 2.2;
    return 0;
}
3. 右值引用

右值引用就是给右值取别名,是C++11实现移动语义的基础。

  • 语法:Type&& 别名 = 右值;
  • 核心限制:普通右值引用无法直接引用左值
  • 特殊规则:通过std::move()函数可将左值强制转换为右值,从而被右值引用绑定。
int main()
{
    double x = 1.1, y = 2.2;
    // 右值引用:给右值取别名
	int&& rr1 = 10;
	double&& rr2 = x + y;
	double&& rr3 = fmin(x, y);
	string&& rr4 = string("11111");

    // 普通右值引用无法引用左值,编译报错
    // int&& rr5 = b;
    return 0;
}
4. std::move()函数详解

std::move()是C++11提供的库函数,很多初学者会误以为它会执行“数据移动”的操作,这是完全错误的。

【核心结论】
std::move()的底层本质是强制类型转换,将一个左值无条件转换为右值引用类型,本身不会做任何数据移动、内存拷贝的操作,真正的资源移动发生在移动构造/移动赋值函数中。

int main()
{
    int b = 1;
    string s("111111");
    // move将左值转为右值,可被右值引用绑定
    int&& rr5 = move(b);
    string&& rr6 = move(s);
    // 底层等价于直接强制类型转换
    string&& rr7 = (string&&)s;
    return 0;
}

【易错警告】
std::move()是危险操作!被move后的左值,其资源可能会被后续的移动构造/移动赋值转移走,变成空对象,后续再使用该左值会导致未定义行为,使用前必须确认其资源状态。

5. 函数重载的参数匹配规则

当函数同时存在左值引用const左值引用右值引用三个重载版本时,编译器会遵循精准匹配优先的原则,选择最匹配的重载版本,无精准匹配时才会使用兜底的const左值引用。

// 三个重载版本
void f(int& x)
{
	std::cout << "左值引用重载 f(" << x << ")\n";
}

void f(const int& x)
{
	std::cout << "const左值引用重载 f(" << x << ")\n";
}

void f(int&& x)
{
	std::cout << "右值引用重载 f(" << x << ")\n";
}

int main()
{
	int i = 1;
	const int ci = 2;
	f(i); // 精准匹配:左值引用重载
	f(ci); // 精准匹配:const左值引用重载
	f(3); // 精准匹配:右值引用重载
	f(std::move(i)); // 精准匹配:右值引用重载
	return 0;
}

【匹配优先级总结】

  1. 普通左值 → 普通左值引用 > const左值引用
  2. const左值 → const左值引用
  3. 右值 → 右值引用 > const左值引用
  4. const左值引用是万能兜底,可接收左值、const左值、右值所有类型。
6. 引用类型汇总表(补充新增)
引用类型 语法 可接收的参数 核心作用
左值引用 T& 只能接收左值 给左值取别名,减少传参拷贝
const左值引用 const T& 左值、右值均可接收 C++98中唯一能接收右值的引用类型,通用传参方案
右值引用 T&& 只能接收右值(或std::move转换后的左值) 区分右值,实现移动语义,转移资源
7. 核心关键易错点:右值引用变量本身是左值

右值本身无法被修改(具有常性),但移动构造的核心是转移右值的资源,必须修改右值对象。为了解决这个悖论,C++标准规定:
右值引用类型的变量,本身是左值
原因:它是有名字的变量,可以取地址,语法上允许修改,这样才能在移动构造中完成资源转移。

void func(bit::string&& s)//s是右值引用类型的变量,本身是左值!
{
    // s是右值引用类型的变量,但它本身是左值!
    // 可以取地址、可以修改
    s += "test";
    bit::string new_s = s; // 这里会调用拷贝构造,因为s是左值
    bit::string move_s = std::move(s); // 必须用move转回右值,才会调用移动构造
}

六、移动语义:移动构造与移动赋值

1. 传统拷贝语义的核心痛点(C++98的性能瓶颈)

左值引用解决了大部分场景的拷贝开销问题(函数传参、左值引用返回),但在函数传值返回的场景中,左值引用完全失效,造成巨大的性能开销。

(1)左值引用的局限性

函数内的局部对象在函数执行结束后,栈帧会被销毁,局部对象会被析构,因此绝对不能返回局部对象的左值引用/右值引用,否则会造成野引用,访问已销毁的内存,引发未定义行为。

// 错误示例:返回局部对象的引用,野引用问题
string& addStrings(string num1, string num2)
{
    string str;
    // ... 字符串处理逻辑
    return str; // 函数结束,str被析构,返回的引用指向已销毁的对象
}
(2)深拷贝的巨大性能开销

对于带堆资源的类(如string、vector、map等),传值返回会触发多次深拷贝+多次析构,尤其是大对象、嵌套容器,性能损耗极其严重。

// C++98 传值返回,无优化场景下触发2次深拷贝
vector<vector<int>> generate(int numRows) {
	vector<vector<int>> vv(numRows);
	// ... 杨辉三角赋值逻辑
	return vv; // 局部对象vv拷贝构造临时对象,vv析构
}

int main()
{
    // 临时对象拷贝构造ret,临时对象析构
    vector<vector<int>> ret = generate(10000);
    // 无优化场景下:2次深拷贝,2次内存释放,性能极差
    return 0;
}
(3)C++98的解决方案

只能通过输出型参数规避拷贝,将容器以引用的形式传入函数,在函数内直接修改,避免传值返回。但这种写法不符合代码直觉,书写繁琐,可读性差。

// C++98 输出型参数方案,规避拷贝
void generate(int numRows, vector<vector<int>>& vv) {
	vv.resize(numRows);
	// ... 杨辉三角赋值逻辑
}

int main()
{
    vector<vector<int>> ret;
    generate(10000, ret); // 无拷贝,但写法不直观
    return 0;
}
2. 移动语义的核心设计思想

C++11通过右值引用,实现了左值和右值的区分,从而实现了移动语义:

【核心思想】

  • 对于左值(持久化对象):只能执行深拷贝,保证原对象的资源不受影响;
  • 对于右值(即将销毁的临时对象):无需深拷贝,直接将其堆资源的所有权转移给新对象,原对象置空,避免重新申请内存、拷贝数据、释放原内存的巨大开销。

移动语义的本质是资源所有权的转移,而非数据的拷贝,时间复杂度从O(n)的深拷贝,降为O(1)的指针交换,性能提升极其显著。

补充核心说明
只有深拷贝类型才有必要实现移动构造和移动赋值;浅拷贝类型(如只有内置类型成员的日期类)没有堆上资源需要转移,实现移动构造没有任何性能收益。

形象类比:
左值是健康的人,不能随便取走他的资源;右值是即将销毁的对象,相当于自愿捐献资源。移动构造就是把右值的堆资源直接转移,而非重新拷贝一份数据。

3. 移动构造函数

移动构造函数是C++11新增的默认成员函数,用于在右值初始化对象时,执行资源转移而非深拷贝。

(1)函数定义规则
  • 第一个参数必须是本类型的右值引用Type&&);
  • 后续的其他参数必须带有默认值;
  • 函数内不执行深拷贝,仅执行资源的转移/交换。
(2)与拷贝构造的核心区别
函数 参数类型 核心行为 原对象状态 性能开销
拷贝构造 const Type&(const左值引用) 深拷贝,申请新内存,复制所有数据 原对象资源保持不变 O(n),大对象开销极高
移动构造 Type&&(右值引用) 资源转移,交换指针,无内存申请与数据拷贝 原对象资源被置空,无有效资源 O(1),仅指针交换,开销可忽略
(3)代码实现与推演

我们以自定义string类为例,从基础版本逐步实现移动构造:

第一步:实现基础的string类(构造、析构、拷贝构造)

#include <iostream>
#include <cstring>
#include <algorithm>
#include <assert.h>
using namespace std;

namespace bit
{
	class string
	{
	public:
		typedef char* iterator;
		iterator begin() { return _str; }
		iterator end() { return _str + _size; }

		// 构造函数
		string(const char* str = "")
			:_size(strlen(str))
			, _capacity(_size)
		{
			cout << "string(char* str)-构造" << endl;
			_str = new char[_capacity + 1];
			strcpy(_str, str);
		}

		// 资源交换函数
		void swap(string& s)
		{
			::swap(_str, s._str);
			::swap(_size, s._size);
			::swap(_capacity, s._capacity);
		}

		// 拷贝构造函数(深拷贝)
		string(const string& s)
			:_str(nullptr)
		{
			cout << "string(const string& s) -- 拷贝构造" << endl;
			reserve(s._capacity);
			for (auto ch : s)
			{
				push_back(ch);
			}
		}

		// 拷贝赋值运算符重载(深拷贝)
		string& operator=(const string& s)
		{
			cout << "string& operator=(const string& s) -- 拷贝赋值" << endl;
			if (this != &s)
			{
				_str[0] = '\0';
				_size = 0;
				reserve(s._capacity);
				for (auto ch : s)
				{
					push_back(ch);
				}
			}
			return *this;
		}

		// 析构函数
		~string()
		{
			cout << "~string() -- 析构" << endl;
			delete[] _str;
			_str = nullptr;
		}

		// 下标访问
		char& operator[](size_t pos)
		{
			assert(pos < _size);
			return _str[pos];
		}

		// 扩容
		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 newcapacity = _capacity == 0 ? 4 : _capacity * 2;
				reserve(newcapacity);
			}
			_str[_size] = ch;
			++_size;
			_str[_size] = '\0';
		}

		string& operator+=(char ch)
		{
			push_back(ch);
			return *this;
		}

		const char* c_str() const { return _str; }
		size_t size() const { return _size; }

	private:
		char* _str = nullptr;
		size_t _size = 0;
		size_t _capacity = 0;
	};

    // 字符串相加函数,传值返回
	string addStrings(string num1, string num2)
	{
		string str;
		int end1 = num1.size() - 1, end2 = num2.size() - 1;
		int next = 0;
		while (end1 >= 0 || end2 >= 0)
		{
			int val1 = end1 >= 0 ? num1[end1--] - '0' : 0;
			int val2 = end2 >= 0 ? num2[end2--] - '0' : 0;
			int ret = val1 + val2 + next;
			next = ret / 10;
			ret = ret % 10;
			str += ('0' + ret);
		}
		if (next == 1)
			str += '1';
		reverse(str.begin(), str.end());
		cout << "******************************" << endl;
		return str;
	}
}

第二步:新增移动构造函数
在上述string类中,新增移动构造函数,核心逻辑是通过swap交换当前对象与源右值对象的资源,源对象最终会析构空资源,无任何深拷贝开销。

// 移动构造函数(新增)
// 参数:本类型的右值引用,不能加const,因为要修改源对象的资源
string(string&& s)
{
    cout << "string(string&& s) -- 移动构造" << endl;
    // 交换当前空对象与源对象的资源
    this->swap(s);
}

第三步:测试移动构造的效果

int main()
{
    // 传值返回场景,右值初始化ret,触发移动构造而非拷贝构造
    bit::string ret = bit::addStrings("11111", "222222222");
    cout << "******************************" << endl;

    // 左值初始化,触发拷贝构造
    bit::string s1("xxxxx");
    bit::string s2 = s1;
    cout << "******************************" << endl;

    // move将左值转为右值,触发移动构造
    bit::string s4 = move(s1);
    cout << "******************************" << endl;
    return 0;
}
4. 移动赋值运算符重载

移动赋值是针对右值的赋值运算符重载,当用右值给已存在的对象赋值时,触发移动赋值,执行资源转移而非深拷贝。

(1)函数定义规则
  • 参数:本类型的右值引用(Type&&);
  • 返回值:本类型的左值引用(Type&),支持连续赋值;
  • 核心逻辑:释放当前对象的原有资源,交换源右值对象的资源,完成所有权转移。
(2)代码实现

在上述string类中,新增移动赋值运算符重载:

// 移动赋值运算符重载(新增)
string& operator=(string&& s)
{
    cout << "string& operator=(string&& s) -- 移动赋值" << endl;
    if (this != &s)
    {
        // 交换当前对象与源对象的资源
        this->swap(s);
    }
    // 源对象s会在函数结束后析构,带走原当前对象的无效资源
    return *this;
}
(3)测试效果
int main()
{
    bit::string s1("xxxxx");
    // 右值赋值给已存在的对象,触发移动赋值
    s1 = bit::string("yyyyy");
    cout << "******************************" << endl;
    return 0;
}
5. 移动语句的效率提升及系统优化

在这组例子中我们重点分析关注return str;来分析右值对象(函数返回值)在不同的构造的情形下,以及编译器不同程度的优化下,赋值和构造的情况下的底层运行情况。
linux下可以将下面代码拷贝到test.cpp文件,编译时用 g++ test.cpp -fno-elide-constructors 的方式关闭构造优化


string addStrings(string num1, string num2)
{
    string str;
    int end1 = num1.size() - 1, end2 = num2.size() - 1;
    int next = 0;
    while (end1 >= 0 || end2 >= 0)
    {
        int val1 = end1 >= 0? num1[end1--] - '0' : 0;
        int val2 = end2 >= 0? num2[end2--] - '0' : 0;
        int ret = val1 + val2 + next;
        next = ret / 10;
        ret = ret % 10;
        str += ('0' + ret);
    }
    if (next == 1)
        str += '1';
    reverse(str.begin(), str.end());
    return str;
}

int main()
{
    bit::string ret = bit::addStrings("11111", "2222");
    cout << ret.c_str() << endl;
    return 0;
}
5.1 右值对象构造,只有拷贝构造,没有移动构造的场景和普通优化

下图展示了vs2019 debug环境下编译器对拷贝的优化,左边为不优化的情况下,两次拷贝构造,右边为编译器优化的场景下连续步骤中的拷贝合二为一变为一次拷贝构造。
请添加图片描述

5.2 右值对象构造,有拷贝构造,也有移动构造的场景和普通优化

有移动构造肯定会优先使用移动构造而不是拷贝构造
请添加图片描述

5.3 右值对象构造,有移动构造的场景和极限优化

需要注意的是在vs2019的release和vs2022的debug和release,下面代码优化为非常恐怖,会直接将str对象的构造,str拷贝构造临时对象,临时对象拷贝构造ret对象,合三为一,变为直接构造。变为直接构造。要理解这个优化要结合局部对象生命周期和栈帧的角度理解。
在这里插入图片描述

5.4 右值对象赋值,只有拷贝构造和拷贝赋值,没有移动构造和移动赋值的场景

下图左边展示了vs2019 debug和 g++ test.cpp -fno-elide-constructors 关闭优化环境下编译器的处理,⼀次拷贝构造,⼀次拷贝赋值。

需要注意的是在vs2019的release和vs2022的debug和release,下面代码会进行优化,直接构造要返回的临时对象,str本质是临时对象的引用,底层角度用指针实现。运行结果的角度,我们可以看到str的析构是在赋值以后,说明str就是临时对象的别名。

请添加图片描述

5.5 右值对象赋值,只有拷贝构造和拷贝赋值,没有移动构造和移动赋值的场景

下图左边展示了vs2019 debug和g++ test.cpp -fno-elide-constructors 关闭优化环境
下编译器的处理,一次移动构造,一次移动赋值。
需要注意的是在vs2019的release和vs2022的debug和release,下面代码会进一步优化,直接构造
要返回的临时对象,str本质是临时对象的引用,底层角度用指针实现。运行结果的角度,我们可以
看到str的析构是在赋值以后,说明str就是临时对象的别名。
请添加图片描述

6. 关键规则与易错警告
  1. 移动语义的适用场景:仅对带有堆资源的类有意义;对于内置类型、无堆资源的自定义类型,移动和拷贝无任何区别,编译器会直接执行拷贝。
  2. 编译器默认生成规则
    • 若用户未手动定义拷贝构造函数、拷贝赋值运算符、析构函数中的任何一个,编译器会自动生成默认的移动构造和移动赋值,执行成员级别的资源移动。
    • 若用户手动定义了拷贝构造、拷贝赋值或析构函数,编译器不会自动生成移动构造和移动赋值,需用户手动实现。
  3. 右值引用的生命周期延长:右值引用绑定一个临时对象后,该临时对象的生命周期会延长至与右值引用一致,不会在表达式结束后立即销毁。
  4. 右值引用本身是左值:一个有名的右值引用变量,本身是左值,可以取地址,因此不能直接用右值引用给另一个右值引用赋值,需要再次用move转换。

七、移动语义核心场景2:STL容器插入接口的右值版本

C++11之后,所有STL容器的push系列、insert系列接口,都新增了右值引用的重载版本,配合移动构造实现插入场景的性能提升。

7.1 容器插入接口的核心逻辑
  • 插入左值:容器调用拷贝构造,将对象深拷贝到容器内存,源对象不变;
  • 插入右值:容器调用移动构造,直接转移资源到容器,源对象资源置空,无深拷贝开销。
7.2 自定义list实现右值版本插入接口
步骤1:最初的左值版本插入接口
// 左值版本insert
void insert(iterator pos, const T& x)
{
    Node* cur = pos._node;
    Node* prev = cur->_prev;
    Node* newnode = new Node(x); // 调用T的拷贝构造
    // 链接节点
    prev->_next = newnode;
    newnode->_prev = prev;
    newnode->_next = cur;
    cur->_prev = newnode;
    ++_size;
}

// 左值版本push_back
void push_back(const T& x)
{
    insert(end(), x);
}

问题:插入右值仍走拷贝构造,无法利用移动语义。

步骤2:新增右值版本插入接口
// 右值版本insert
void insert(iterator pos, T&& x)
{
    Node* cur = pos._node;
    Node* prev = cur->_prev;
    // x是右值引用变量、本身是左值,必须move转回右值
    Node* newnode = new Node(move(x)); // 调用T的移动构造
    // 链接节点
    prev->_next = newnode;
    newnode->_prev = prev;
    newnode->_next = cur;
    cur->_prev = newnode;
    ++_size;
}

// 右值版本push_back
void push_back(T&& x)
{
    insert(end(), move(x));
}

【易错警告】必须用move(x)将形参转回右值,否则会走拷贝构造,移动语义失效。

步骤3:配套修改节点的构造函数
template<class T>
struct list_node
{
    list_node* _next = nullptr;
    list_node* _prev = nullptr;
    T _data;

    // 左值构造
    list_node(const T& x)
        :_next(nullptr), _prev(nullptr), _data(x)
    {}

    // 右值构造
    list_node(T&& x)
        :_next(nullptr), _prev(nullptr), _data(move(x))
    {}
};
7.3 效果验证
int main()
{
    bit::list<bit::string> lt;
    bit::string s1("1111111111");
    
    lt.push_back(s1);          // 左值,调用拷贝构造
    lt.push_back(move(s1));     // 右值,调用移动构造
    lt.push_back("2222222222"); // 临时对象(右值),调用移动构造
    return 0;
}

八、引用折叠与万能引用

8.1 引用折叠的核心规则

C++语法不允许直接定义引用的引用(如int& & a直接写编译报错),但模板参数推导、typedef/using别名中会间接产生引用的引用,编译器会执行引用折叠

折叠铁律:

只要有左值引用参与折叠,最终结果一定是左值引用;只有右值引用和右值引用折叠,最终结果才是右值引用。

完整折叠规则表:

引用组合 折叠后结果
T& & T&
T& && T&
T&& & T&
T&& && T&&
// 引用折叠示例
typedef int& lref;
typedef int&& rref;

lref& r1 = n;  // int& & → int&,左值引用
lref&& r2 = n; // int& && → int&,左值引用
rref& r3 = n;  // int&& & → int&,左值引用
rref&& r4 = 1; // int&& && → int&&,右值引用
8.2 万能引用(通用引用)

万能引用不是新引用类型,是引用折叠在模板中的特殊应用:一个模板函数同时接收左值、右值,自动匹配对应引用类型

万能引用的定义条件

只有函数模板中,形参为T&&类型,且T需要通过函数实参推导时,才是万能引用

【易错警告】类模板成员函数的T&&不是万能引用!类实例化时T已确定,不是实参推导,只是普通右值引用。

万能引用的推导规则
  1. 传入左值:T推导为T&,折叠后为T&(左值引用);
  2. 传入右值:T推导为T,无折叠,形参为T&&(右值引用);
  3. 传入const左值:T推导为const T&,折叠后const T&
  4. 传入const右值:T推导为const T,形参const T&&
// 万能引用模板函数
template<class T>
void Function(T&& t)
{
    // 传入左值→T&;传入右值→T&&
}

int main()
{
    int a = 10;
    Function(a);        // 左值 → int&
    Function(10);       // 右值 → int&&
    Function(move(a));  // 右值 → int&&
    
    const int b = 20;
    Function(b);        // const左值 → const int&
    Function(move(b));  // const右值 → const int&&
    return 0;
}
万能引用核心价值

无需为左值、右值写两个重载,一个模板函数即可处理所有场景,减少代码冗余。


九、完美转发 std::forward

9.1 完美转发要解决的核心问题

万能引用接收参数后,形参变量本身都是左值,向下传递时会丢失原左值/右值属性,永远匹配左值版本函数。
std::move会无条件转右值,会破坏左值原始属性;
完美转发:保持参数原始左值/右值属性向下传递。

9.2 std::forward的使用与原理

语法:

std::forward<T>(参数);

强制要求:必须显式指定模板参数T,不能让编译器自动推导

工作原理:

  • T为左值引用类型 → forward返回左值引用,保持左值属性;
  • T为非引用类型 → forward返回右值引用,恢复右值属性。
    底层是有条件类型强转,保留原始值类别。
9.3 实战:万能引用+完美转发重构list接口
步骤1:重构push_back
template<class X>
void push_back(X&& x)
{
    insert(end(), forward<X>(x));
}
步骤2:重构insert
template<class X>
void insert(iterator pos, X&& x)
{
    Node* cur = pos._node;
    Node* prev = cur->_prev;
    Node* newnode = new Node(forward<X>(x));
    prev->_next = newnode;
    newnode->_prev = prev;
    newnode->_next = cur;
    cur->_prev = newnode;
    ++_size;
}
步骤3:重构list_node构造函数
template<class T>
struct list_node
{
    list_node* _next = nullptr;
    list_node* _prev = nullptr;
    T _data;

    list_node() = default;//额外添加
    template<class X>
    list_node(X&& x)
        :_next(nullptr)
        , _prev(nullptr)
        , _data(forward<X>(x))
    {}
};

关键提醒:每一层传递都必须用forward,遗漏一层就会丢失值属性。

【注意】为什么必须显式声明 list_node() = default

在实现包含完美转发(万能引用)模板构造函数list_node 结构体/类时,显式声明 list_node() = default; 是强制要求,核心原因分为两层关键逻辑:

1. 基础规则:模板构造会屏蔽编译器自动生成的默认构造

C++ 有明确语法规则:只要类中定义了任意显式构造函数(包括模板构造函数 template<class X> list_node(X&& x)),编译器就不会自动生成无参的默认构造函数
这会直接导致功能缺失:无法创建空节点、链表哨兵头节点,也无法在 std::vector 等STL容器中预分配默认对象。

2. 核心缺陷:模板构造无法用默认参数替代无参构造

有人会尝试给模板构造加默认参数(如 template<class X> list_node(X&& x = X())),试图兼容无参调用,但这是错误方案
无参调用时没有实际参数传入,默认参数不参与模板类型推导,编译器完全无法推断模板类型 X,直接触发编译错误。

完美转发模板构造函数会屏蔽编译器自动生成的无参构造,且自身无法通过默认参数实现无参调用
因此,list_node() = default;唯一且最优雅的解决方案——强制编译器生成合规的无参默认构造函数。

步骤4:效果验证
int main()
{
    bit::list<bit::string> lt;
    bit::string s1("111111");
    
    lt.push_back(s1);          // 左值→拷贝构造
    lt.push_back(move(s1));     // 右值→移动构造
    lt.push_back("222222");     // 右值→移动构造
    return 0;
}

更多推荐