资源传递 移动和转发
之前说过 RAII,在构造的时候创建资源,在析构的时候释放资源,也说过左右值的事情。
这次稍微深入一点点细节,把左值引用和右值引用,以及std::move和std::forward等稍微说说。
(有一说一,如果C++也引入了所有权检查是不是会变得更清晰💦,Rust 化这块;不过有一个资源所有权的约定还是挺不错的
跨对象转移资源
假设我们需要把一个资源分发给其他对象,那么语义上有两种可能性 ——
- 拷贝,完整的复制一份新的,和旧的无关
- 移动,把可转移的内部资源交给新对象,源对象旧内容不再可依赖
分别对应 ——
拷贝
拷贝构造,就是把资源重新拷贝,构造新的对象,语义上 —— 复制出一份全新的资源,不影响旧资源。
优点是做了一份copy,这样新的资源不会影响旧的资源,两份都可以随意使用,所以对于源对象来说比较安全;
不过缺点也比较明显,就是完整拷贝的开销可能会比较大
移动
移动构造,把资源移动给新的 "owner",源对象旧的资源不再依赖,让新管理者管理资源。
这样优点在于规避了拷贝的开销,不过被移动对象的旧内容不能继续依赖
构造函数和赋值函数
假设手动创建一个类,那么我们需要思考构造和赋值的时候的语义
class Buffer {
public:
Buffer(const Buffer& other); // 拷贝构造
Buffer(Buffer&& other) noexcept; // 移动构造
Buffer& operator=(const Buffer& other); // 拷贝赋值
Buffer& operator=(Buffer&& other) noexcept; // 移动赋值
};一般来说,会选择再移动的时候把旧资源给置空;
而拷贝的时候要考虑深拷贝还是浅拷贝
- 深拷贝:复制资源本体,新旧对象各自管理自己的资源。
- 浅拷贝:只复制指针 / 句柄 / 引用,新旧对象指向同一份资源;如果双方都负责释放,就会重复释放。
触发条件
一般来说,在需要构造/赋值目标对象时,左值通常选择拷贝版本
Buffer a(1145);
Buffer b = a;因为 a 是左值,所以在语义上 —— "a 可能之后还有用到,我们不消耗它,而是复制一份给 b"。
而对应的,右值一般来说会触发移动
Buffer b = std::move(a);此时的语义是把“所有权”交付给 b,系统不再依赖 a,其中std::move就是把 a 从左值转化成一个右值表达式,表示 a 是可移动的
move 和 forward
std::move 之前浅说过是把表达式转化成右值表达式,它是无条件的转化,不论传入的是左值还是右值表达式都会进行转化。
std::forward 是有条件的转发 —— 传入左值引用返回左值引用;传入右值引用返回右值引用~~(听上去很没用)~~,不过实际上是 —— 根据模板参数 T 恢复原始值类别一般用于转发引用(好像又重复了一遍)
不如举例 ——
void Process(int& a); // 接受左值引用
void Process(int&& a); // 接受右值引用
template<typename T>
void Wrapper(T&& value)
{
Process(std::forward<T>(value)); // 不论左右值引用,直接转发
}
int i = 1;
Wrapper(i); // 调用传入左值引用的方法
Wrapper(1); // 调用函数签名是右值引用的因为如果正常的去 call process 方法的话,由于在函数体内的变量名会被视作左值表达式,所以无法正确调用右值引用的方法,就可以使用forward来进行转发,在STL容器设计以及日常泛型写法等都比较常用。
具体使用场景
在实际当中,一方面要考虑所有权的语义(虽然没有rust那样严格的checker,不过此处考虑的就是资源的创建和释放问题(生命周期))
另一方面也要考虑运行效率;
- 使用移动
std::move- 大的对象
vector等容器- 大型的 Buffer
- 大的封装资源
- 明确唯一Owner的语义
std::unique_ptr
- 当对象后续不再需要旧内容,并且该类型的移动有意义时
- 大的对象
- 不使用 move
- 普通类型,
int,float,enum等 const修饰的对象
- 普通类型,
以及考虑异常安全,
写在最后
Effective C++ / Effective Modern C++ 写的不赖,说的很清晰
如果是我们自己管理资源,就必须明确 ——
- 怎么释放?
- 怎么复制?
- 怎么移动?
- 怎么赋值?
否则容易出现 ——
- 内存泄漏
- 重复释放
- 浅拷贝
- 悬空指针
- 释放后继续使用
以及其他问题