条款 23:理解 std::move std::forward

ZaynPei Lv6

移动语义 (Move Semantics) 和 完美转发 (Perfect Forwarding) 是 C++11 中引入的两个革命性特性,它们共同解决了 C++ 中长期存在的资源管理效率和泛型编程(模板)中的参数传递问题。

移动语义 (Move Semantics)

在 C++11 之前,我们只有“拷贝语义”。当我们处理包含堆内存(如指针指向的数据、文件句柄、网络套接字等)的对象时,拷贝操作(通过拷贝构造函数或拷贝赋值运算符)通常意味着“深拷贝”——即分配一块新内存,并将数据完整拷贝过去。

考虑一个返回 std::vector 的函数:

1
2
3
4
5
6
7
8
9
std::vector<int> create_large_vector() {
std::vector<int> temp_vec(1000000); // 假设分配了大量数据
// ... 填充数据 ...
return temp_vec; // [C++03] 在这里,temp_vec (左值) 将被拷贝到一个“返回值临时对象”(右值)
}

// [C++03] 调用:
std::vector<int> my_vec = create_large_vector();
// [C++03] “返回值临时对象”(右值) 又被拷贝到 my_vec 中。

在 C++03 中,这个过程涉及两次昂贵的深拷贝。但我们知道,函数内的 temp_vec 和那个“返回值临时对象”在语句结束时都将被销毁。我们其实并不需要拷贝它们内部的数据,我们只需要把数据(即指向堆内存的指针)转移给 my_vec 就行了。

这个问题的解决方案就是移动语义. 它允许一个对象将其内部资源(如指针)的“所有权转移 (transfer) 给另一个对象,而不是拷贝它们。这是一种“窃取”或“掠夺”资源的方式,它之所以安全,是因为我们只对那些“即将被销毁”的对象(即右值)执行此操作。

因此, 一个类现在可以定义专门处理右值参数的版本:移动构造函数: MyClass(MyClass&& other)和移动赋值运算符: MyClass& operator=(MyClass&& other)

它们的实现逻辑如下:

  • 窃取资源: 将 other 对象的内部资源(例如,指向数据的指针 other.data_ptr)直接拷贝(浅拷贝)到 this 对象(this->data_ptr = other.data_ptr)。

  • 置空源对象: 将 other 原对象的指针设置为 nullptr (other.data_ptr = nullptr;)。

    • 这是至关重要的安全步骤。因为 other 仍然是一个有效的对象,它在作用域结束时会被析构。如果不将其指针置空,other 的析构函数会释放掉那块内存,导致 this 对象持有一个悬空指针。将其置空后,other 的析构函数调用 delete nullptr;(这是安全的),而资源的所有权已成功转移给 this。

std::move

对于右值, 编译器会自动调用移动构造函数或移动赋值运算符. 而对于左值, 我们不能直接将一个它传递给一个只接受右值引用的函数(如移动构造函数)。解决方法是, 使用 std::move 来显式请求移动

std::move 不做任何移动操作。它仅仅是一个强制类型转换:它将其参数(无论左值还是右值)无条件地转换为一个右值引用

这相当于你对编译器说:“我保证,我不再使用这个左值变量了,请你把它当作一个临时对象(右值)来处理,你可以随意窃取它的资源。”

对于上述示例, 我们可以将 create_large_vector 函数修改为:

1
2
3
4
5
std::vector<int> create_large_vector() {
std::vector<int> temp_vec(1000000); // 假设分配了大量数据
// ... 填充数据 ...
return std::move(temp_vec); // [C++11] 在这里,temp_vec (左值) 将被移动到一个“返回值临时对象”(右值), 因此不会触发深拷贝, 将 O(N) 的资源拷贝操作降维到 O(1) 的指针交换操作。
}

std::move后仅保证右值, 不保证可移动

在 C++ 开发中,我们被教导“尽可能使用 const”,这是一个良好的编程习惯。同时,为了优化性能,现代 C++(遵循《Effective Modern C++》条款 41 等指导原则)建议我们利用移动语义。然而,当这两个原则被错误地结合时,可能会产生与预期不符的行为。

假设我们正在编写一个 Annotation (注解) 类,它在内部存储一个字符串。根据条款 41 的建议(针对既需要拷贝也需要移动赋值的“sink”参数),我们采用按值传递(pass-by-value)参数,然后将其 std::move 到数据成员中,以此来高效处理左值(拷贝)和右值(移动)实参

同时, 因为这个构造函数仅仅是读取 text 的值(用于移动),而不会在函数体内对其进行逻辑上的修改。遵循“尽可能使用 const”的旧习惯,我们将这个按值传递的参数标记为 const:

1
2
3
4
5
6
7
8
9
10
class Annotation {
public:
// 按值传递 text
explicit Annotation(const std::string text)
: value(std::move(text)) // 将 text 的内容“移动”到 value 中
{ }

private:
std::string value;
};

结果是, 这段代码可以顺利地编译、链接并运行。并且,成员变量 value 最终确实包含了传入的 text 的内容。然而,一个关键的优化丢失了:text 并没有被移动,它被拷贝到了 value 中。

我们迫切想知道, 为什么移动(Move)变成了拷贝(Copy)?

这个行为是 C++ 类型系统和重载解析 (Overload Resolution) 共同作用的结果,并且这种设计对于维持“常量正确性”至关重要。

我们一步一步分析, std::move 本身不执行任何“移动”操作。它是一个无条件的类型转换工具(cast)。它的唯一工作就是将其接收的参数(通常是一个左值)强制转换为一个右值引用(rvalue reference)

这里的关键点在于,当 std::move 转换一个 const 变量时,其“常量性”(const-ness)会被保留

在我们的例子中,形参 text 的类型是 const std::string(这是一个左值); std::move(text) 的调用将其转换为右值, 转换的结果类型是const std::string&& (一个指向 const std::string 的右值引用)。

当编译器尝试使用这个结果(一个 const 右值)来初始化成员 value (类型为 std::string) 时,它会检查 std::string 的可用构造函数,主要有两个候选者:

1
2
3
4
5
6
7
8
9
10
class string { 
public:
// 1. 移动构造函数 (Move Constructor)
// 它接受一个“非 const 的”右值引用 (string&&)
string(string&& rhs);

// 2. 拷贝构造函数 (Copy Constructor)
// 它接受一个“const 的”左值引用 (const string&)
string(const string& rhs);
};

对于移动构造函数, 我们的参数类型是 const std::string&& (const 右值)。而移动构造函数需要 string&& (非 const 右值)。而C++ 不允许将 const 对象绑定到非 const 引用上,因为移动构造函数必须能够修改其参数(例如,将其内部指针设为 null), 显然对一个 const 对象执行“窃取”操作是违反常量正确性的。

而对于第二个拷贝构造函数, C++ 语言规则允许一个 const 左值引用(const T&)绑定到一个 const 右值(const T&&), 所以编译器别无选择,只能调用拷贝构造函数,导致了数据拷贝而非移动。

这个案例带来了两个至关重要的经验:

  1. 不要对计划移动的对象声明 const: 如果你希望能够从一个对象中“移出”数据(即允许该对象被修改并置于一个有效的、但未指定的状态),那么该对象本身绝对不能被声明为 const。任何对 const 对象的“移动”尝试都会悄无声息地降级为一次“拷贝”操作

  2. std::move 不保证“可移动性”: std::move 并不执行移动,它只负责类型转换。它告诉编译器:“请把这个对象当作右值来处理”。它不保证转换后的右值一定能匹配到移动构造函数或移动赋值运算符。如本例所示,如果源对象是 const,它最终只会匹配到拷贝操作。

完美转发 (Perfect Forwarding)

完美转发解决的是一个在模板(泛型编程)中遇到的不同问题: 转发时的值类别丢失

假设我们要编写一个“工厂函数”(或任何包装函数),它接受一些参数,然后用这些参数去构造另一个类型的对象(例如 std::make_unique 或 std::vector::emplace_back)。

工厂函数(Factory Function)是一种封装对象创建过程的函数,它的核心目的是:将对象的构造逻辑从使用者那里抽离出来,让你通过调用一个函数来获得所需的对象,而不需要关心它是如何被创建的。

我们希望:

  • 如果工厂函数收到了一个左值,它应该传递一个左值给最终的构造函数(触发拷贝)。
  • 如果工厂函数收到了一个右值,它应该传递一个右值给最终的构造函数(触发移动)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// process 根据参数是左值还是右值进行了重载
void process(const Widget& lvalArg); // 处理左值
void process(Widget&& rvalArg); // 处理右值

// logAndProcess 是一个函数模板,用于记录日志并转发参数
template<typename T>
void logAndProcess(T&& param)
{
// 取得当前时间
auto now = std::chrono::system_clock::now();
makelogEntry("Calling 'process'", now);

// 将 param 完美转发给 process 函数
process(std::forward<T>(param));
}

// 考虑两种调用 logAndProcess 的情形,一种向其传入左值,一种向其传入右值
Widget w;
logAndProcess(w); // 传入左值
logAndProcess(std::move(w)); // 传入右值

在 logAndProcess 内,形参 param 被传递给函数 process。而 process 依据其形参是左值还是右值类型进行了重载。所以我们很自然地会期望:

  • 当调用 logAndProcess 时若传入的是个左值(如 w),则该左值在被传递给 process 函数时仍被当作一个左值。
  • 而当调用 logAndProcess 时若传入的是个右值(如 std::move(w)),则会调用 process 取用右值类型的那个重载版本。

但是,有一个关键规则:所有(具名的)函数形参皆为左值(即使它是右值引用类型),param 亦不例外。

一个值被传递给函数后,在函数内部它就“落地”了,有了一个具体的名字和一块内存。只要有了这两样东西,它就变成了左值,以便在函数体内稳定地使用。 与之不同, 函数返回值的类型情况取决于返回类型如果返回类型是非引用/右值引用, 则返回值是右值; 如果是左值引用则是左值

因此,如果在 logAndProcess 函数体内只是简单地调用 process(param),那么 process 的所有调用都会调用取用左值类型的那个重载版本(因为 param 本身是左值),即使我们传给 logAndProcess 的是一个右值。

为了避免这种结果,就需要一种机制:当且仅当用来初始化 param 的实参(即传递给 logAndProcess 的实参)是一个右值时,才把 param 强制转换成右值类型。这恰恰就是 std::forward 所做的一切。

std::forward:有条件的类型转换

这就是为何说 std::forward 是有条件的强制类型转换:仅当它的实参(param)最初是使用一个右值完成初始化时,它才会执行向右值类型的强制类型转换。(也就是说它会”转发实参的类型保持不变)

你可能会疑惑,std::forward 何以知晓其参数是否通过右值完成初始化?一句话:该信息是被编码到 logAndProcess(也就是上一层的函数) 的模板形参 T 中的, 该模板参数 T 被传递给 std::forward 后,随即由后者将编码的信息恢复出来,从而决定是返回一个左值引用还是右值引用。

回到 std::forward 本身, 它有两个参数: 模板参数(尖括号里的 T)和函数实参(圆括号里的变量,比如 param), 其中模板参数T的作用是告诉 std::forward 调用者传进来的原始实参类型是什么; 而函数实参则是要被转发的对象, 两者结合即可用模板参数还原它的原始值类别。

std::move 与 std::forward:语义的明确区分

综上所述, std::move 和 std::forward 都是强制型别转换,唯一不同就在于 std::move 始终实施强制型别转换,而 std::forward 仅仅有时会实施。

更为重要的是,使用 std::move 和 std::forward 所要传达的语义完全不同:

  • std::move:传达的意思是无条件地向右值类型强制转换。这典型地用于为移动操作做铺垫(“我确定要移动这个对象,请将其视为右值”)。

  • std::forward:传达的意思是有条件的强制类型转换(仅仅对绑定到右值的引用实施转换)。这专门用于传递(转发)一个对象到另一个函数,并在此过程中保持该对象原始的左值性 (lvalueness) 或右值性 (rvalueness)。

这是两个非常不同的行为。这两个行为是如此不同,因而我们最好使用两个不同的函数(以及函数名字)来明确区分这两者。在移动构造函数(或移动赋值运算符)内部,我们确定要将来源对象(rhs)的成员视为右值进行“窃取”,因此我们应该总是使用 std::move 来表达这种无条件的移动意图。