移动语义底层逻辑拆解#
1. 左值(lvalue)与右值(rvalue)的本质#
在 C++ 内存模型中:
- 左值:指的是拥有持久存储地址的对象(Identity)。例如变量
int_array,它在栈上有一个明确的内存起始位置,你可以通过&int_array获取它的地址。 - 右值:通常是临时对象或字面量(Cannot take address)。例如
std::vector<int>{1,2,3}生成的匿名对象,它在表达式结束后就会被销毁。
2. std::move 的真实作用:右值强制转换#
std::move 并不移动数据。它的唯一功能是:将左值强制转换为“右值引用”类型。
std::vector<int> stealing_ints = std::move(int_array);c这行代码告诉编译器:虽然 int_array 是个有名字的左值,但请把它当成一个“右值”来对待。这会触发 std::vector 的**移动赋值运算符(Move Assignment Operator)**而非拷贝赋值。
3. 所有权的转移#
vector 的结构可以简化为三个指针:start (指向数据首地址), finish, end_of_storage。
- 执行
std::move前:int_array拥有指向堆内存(存储{1,2,3,4})的指针。 - 执行
std::move后:stealing_ints直接复制了int_array的三个指针地址。- 关键步骤:
int_array的三个指针被重置为nullptr。 - 结果:此时不能访问到
int_array里的数据。
4. 右值引用 && 的深意#
std::vector<int> &&rvalue_stealing_ints = std::move(stealing_ints);c这里的 && 是一种类型声明。它声明 rvalue_stealing_ints 是对 stealing_ints 数据的引用,且暗示该引用所指向的对象是可以被“夺取”的。
- 注意:这行代码并没有触发“移动构造”,因为它仅仅是绑定了一个引用,没有发生实际的所有权交接。所以
stealing_ints此时仍然持有数据。
5. 函数调用中的所有权陷阱#
代码中两个函数的对比展示了移动语义的破坏性:
场景 A:所有权被夺取#
move_add_three_and_print(std::move(int_array2));c- 在函数内部执行了
std::vector<int> vec1 = std::move(vec);。 - 这导致
int_array2在内存中的指针被置为空。 - 后果:函数结束后,
int_array2变成了有效但内容为空的状态。
场景 B:仅操作引用#
add_three_and_print(std::move(int_array3));c- 虽然传入的是右值引用,但函数内部直接操作了传入的
vec,没有再将其move给其他对象。 - 后果:所有权从未离开过
int_array3,因此它依然持有数据。
附录#
move_semantics.cpp
/**
* @file move_semantics.cpp
* @author Abigale Kim (abigalek)
* @brief 关于移动语义的教程代码。
*/
// C++ 中的移动语义是一个非常有用的概念,它允许在对象之间高效且优化地传输数据的所有权。
// 移动语义的主要目标之一是提高性能,因为移动一个对象比对该对象进行深拷贝(deep copying)
// 要快得多,也更高效。
// 要理解移动语义,必须理解左值(lvalues)和右值(rvalues)的概念。
// 一个简化的定义是:左值是指向内存中特定位置的对象。右值则是任何非左值的东西。
// std::move 是将对象从一个左值移动到另一个左值的最常用方法。
// std::move 将表达式转换为右值。这允许我们将左值当作右值来处理,
// 并允许所有权从一个左值转移到另一个左值。
// 在下面的代码中,我们包含了一些示例,用于识别 C++ 中的表达式是左值还是右值、
// 如何使用 std::move,以及如何将右值引用传递给函数。
// 为了演示目的,引入 std::cout(打印)。
#include <iostream>
// 引入 utility 头文件以使用 std::move。
#include <utility>
// 引入 vector 头文件。我们将在 containers.cpp 中更多地介绍 vector,
// 但目前只需知道 vector 本质上是动态数组,std::vector<int> 是一个整数数组。
// 主要是因为 vector 占用不容忽视的内存量,这里用它来展示使用 std::move 的性能优势。
#include <vector>
// 该函数接收一个右值引用作为参数。
// 它夺取了传入的 vector 的所有权,在其末尾添加 3,并打印 vector 中的值。
void move_add_three_and_print(std::vector<int> &&vec) {
// 使用 std::move 夺取所有权,转移到 vec1
std::vector<int> vec1 = std::move(vec);
vec1.push_back(3);
for (const int &item : vec1) {
std::cout << item << " ";
}
std::cout << "\n";
}
// 该函数接收一个右值引用作为参数。
// 它在传入的 vector 参数末尾添加 3,并打印 vector 中的值。
// 值得注意的是,它并没有夺取 vector 的所有权。
// 因此,传入的参数在调用者上下文中仍然是可用的。
void add_three_and_print(std::vector<int> &&vec) {
vec.push_back(3);
for (const int &item : vec) {
std::cout << item << " ";
}
std::cout << "\n";
}
int main() {
// 以这个表达式为例。注意 'a' 是一个左值,因为它是一个指向特定内存空间的变量(存储 'a' 的地方)。
// 10 是一个右值。
int a = 10;
// 让我们看一个将数据从一个左值移动到另一个左值的基本示例。
// 我们在这里定义一个整数 vector。
std::vector<int> int_array = {1, 2, 3, 4};
// 现在,我们将这个数组的值移动到另一个左值。
std::vector<int> stealing_ints = std::move(int_array);
// 右值引用是指向数据本身(而非左值)的引用。
// 对左值(如 stealing_ints)调用 std::move 将导致表达式被转换为右值引用。
std::vector<int> &&rvalue_stealing_ints = std::move(stealing_ints);
// 但是请注意,在此之后,仍然可以访问 stealing_ints 中的数据,
// 因为它是拥有数据的左值,而不是 rvalue_stealing_ints。
std::cout << "Printing from stealing_ints: " << stealing_ints[1] << std::endl;
// 可以将右值引用传递给函数。但是,一旦右值从调用者上下文中的左值移动到
// 被调用者上下文中的左值,它对于调用者来说实际上就变得不可用了。
// 基本上,在调用 move_add_three_and_print 之后,我们不能再使用 int_array2 中的数据。
// 它不再属于 int_array2 这个左值。
std::vector<int> int_array2 = {1, 2, 3, 4};
std::cout << "Calling move_add_three_and_print...\n";
move_add_three_and_print(std::move(int_array2));
// 在这里尝试对 int_array2 进行任何操作都是极其愚蠢的。取消下面代码的注释可以尝试一下!
// (在我的机器上,这会导致段错误/segfault...)
// 注意:它在你那里可能正常工作,但这并不意味着这样做是明智的!
// std::cout << int_array2[1] << std::endl;
// 如果我们没有将调用者上下文中的左值移动到被调用者上下文中的任何左值,
// 那么函数实际上会将传入的右值引用视为普通引用,
// 此时当前上下文中的左值仍然拥有该 vector 数据。
std::vector<int> int_array3 = {1, 2, 3, 4};
std::cout << "Calling add_three_and_print...\n";
add_three_and_print(std::move(int_array3));
// 如下所示,我们可以从这个数组中打印数据。
std::cout << "Printing from int_array3: " << int_array3[1] << std::endl;
return 0;
}c