在C++98时代,我们经常会遇到这样的性能瓶颈:当需要返回或传递大型对象(如字符串、向量或自定义资源管理类)时,不得不进行昂贵的深拷贝操作。即使我们知道某些对象即将被销毁,也无法避免这种拷贝开销。
C++11引入的右值引用和移动语义彻底改变了这一局面,让C++程序员能够写出更高效、更现代的代码。
基础概念 - 左值与右值
左值(lvalue) 是指具有持久状态的表达式,简单说就是有名字、有内存地址的对象。
1 | int a = 10; // a是左值 |
右值(rvalue) 是指临时性的表达式,通常是短暂存在的中间结果。
1 | int result = 3 + 4; // 3+4是右值(计算结果) |
问题根源 - 不必要的拷贝
1 | class MyVector { |
在 createLargeVector()返回时,vec是一个即将被销毁的局部对象(本质上是右值)。它的内部资源(那个包含一百万个整数的数组)在 vec析构时会被释放掉。然而,在 main函数中构造 v时,我们却不得不进行代价高昂的深拷贝,将这一百万个整数完整地复制一份。这造成了双重浪费:
- 深拷贝本身消耗大量 CPU 时间和内存带宽。
- vec内部的资源最终会被销毁,我们本可以“直接拿来”给 v用,却选择了复制一份然后销毁原版。
注意:由于现代编译器的返回值优化(RVO)功能,上述示例无法按照期望运行,可以尝试禁用编译器优化。
返回值优化(Return Value Optimization, RVO) 是 C++ 编译器的一项优化技术,它允许编译器在返回一个局部对象时,避免执行不必要的拷贝(或移动)构造函数,从而直接将对象构造在函数外部的目标内存位置。简单来说,它消除了函数返回时产生的临时对象。
解决方案 - 右值引用和移动语义
C++11引入了右值引用,语法为T&&,它专门用于绑定到右值(临时对象)。
1 | int&& rref = 10 + 20; // 右值引用绑定到临时结果 |
有了右值引用,我们就可以对上述MyVector类进行改造,使其支持移动语义。
1 | MyVector(MyVector&& other) noexcept |
对于没有定义移动构造函数或移动赋值运算符的类使用std::move,会自动回退到使用拷贝构造函数或拷贝赋值运算符,而如果拷贝构造函数或拷贝赋值运算符也没定义,则会编译错误。
如果类成员是内置类型 (int, double, 指针等)、支持移动的类型(如std::string、std::vector等),无需自定义移动构造函数或移动赋值运算符,编译器会自动支持移动操作。(见下面的Rule of Zero)
基本原则
Rule of Zero
零法则,在现代C++中,鼓励通过使用标准库容器 (std::vector, std::string) 、智能指针 (std::unique_ptr, std::shared_ptr)等标准库提供的工具来管理资源,从而无需手动编写析构函数、拷贝构造函数和拷贝赋值运算符(C++11的“三个大”)。在这种情况下,编译器提供的默认版本已经足够且正确。这种方法不仅简化了代码,还能有效降低出错的可能性。
Rule of Five
如果一个类需要自定义实现以下五个特殊成员函数中的任何一个,那么它也需要为这五个函数都提供自定义实现,以确保资源管理的正确性和行为的一致性。
- 析构函数
- 拷贝构造函数
- 拷贝赋值运算符
- 移动构造函数
- 移动赋值运算符
C++ 编译器有一套明确的规则来决定何时自动生成特殊成员函数(拷贝构造、拷贝赋值、移动构造、移动赋值、析构):
| 类中声明的成员函数 | 编译器自动生成的函数 |
|---|---|
| 无特殊成员函数 | 所有:拷贝构造、拷贝赋值、移动构造、移动赋值、析构 |
| 有析构函数 | 拷贝构造、拷贝赋值(不生成移动操作) |
| 有拷贝构造或拷贝赋值 | 析构(不生成移动操作) |
| 有移动构造或移动赋值 | 不生成拷贝操作(拷贝构造和拷贝赋值被删除) |
std::move
std::move实际上只是一个静态类型转换:
1 | template <typename T> |
它的唯一作用是将表达式转换为右值引用类型,告诉编译器:”这个对象可以被视为临时对象,允许移动其资源”。
如果一个类没有移动构造函数或移动赋值运算符,使用 std::move 仍然有效,但行为会有所不同:
- 如果类有拷贝操作:编译器会退回到拷贝构造函数,因为右值引用可以绑定到const T&参数。
- 如果类没有拷贝操作:如果满足自动生成移动操作,则使用默认的移动操作,否则代码无法编译。