0%

C++ 和 Rust 的移动操作

各种编程语言都支持对象(object)的构造、复制、读取、修改和销毁等操作。其中的复制(copy)操作,就是在计算机内存中的某个新地址,构造一个和原对象一模一样的新对象。

少数语言,例如 C++ 和 Rust,还支持一种叫『移动』(move)的操作。对象的移动,顾名思义,就是把原对象从计算机内存中的某个地址搬运至新的地址。听上去,移动操作做的事情似乎和复制差不多。那么为什么 C++ 和 Rust 要引入移动操作呢?为什么其他大多数语言都不支持移动操作呢?


如何使用移动操作

在讨论移动操作的用途之前,我们需要先了解一下如何在程序中使用移动操作。

在 C++ 中,当需要在内存中的某个新地址,构造一个和原对象一模一样的新对象的时候,编译器会根据原对象是『左值』(lvalue)还是『右值』(rvalue),来决定是执行复制操作,还是执行移动操作。一个对象是左值还是右值,有一种较为直观(但不十分准确)的判定方法:能被赋值,或者说,能出现在赋值符号 = 左侧的对象是左值,否则就是右值[1]。我们通过几个直观的例子来看一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// i 是左值。
int i = 0;

// 即便这回 i 出现在右侧,
// 它还是左值。
int j = i;

// 常量 10 不能出现在左侧,所以 10 是右值。
10 = 5; // 编译错误

int GetMagicNumber() {
return 10;
}

// 函数运行的结果不能出现在左侧,
// 所以是右值。
GetMagicNumber() = 5; // 编译错误

当 C++ 编译器发现原对象是一个右值对象的引用(rvalue reference),且该对象类型支持移动构造 / 移动赋值时,会执行移动操作,否则会执行复制操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
std::vector<int> vec = {1, 2, 3};

// 把 vec 复制到 vec2,
// 因为此处的 vec 是一个左值对象的引用。
std::vector<int> vec2 = vec;

std::vector<int> VecGenerator() {
return {1, 2, 3};
}

// 移动操作,
// 因为此处调用 VecGenerator 函数,
// 会得到一个右值对象的引用。
std::vector<int> vec3 = VecGenerator();

程序员可以使用 std::move 函数将某一个对象的引用,强制转换为一个右值对象的引用,来触发编译器执行移动操作。

1
2
3
4
5
6
7
8
9
std::vector<int> vec4 = {1, 2, 3};

// 使用 std::move 函数将 vec4 强制转换成
// 右值对象的引用。所以此处会执行移动操作。
std::vector<int> vec5 = std::move(vec4);

// vec4 被移动到 vec5 之后,会变成空值,
// 因此下一行会打印 0
std::cout << vec4.size();

移动发生后,被移动的对象(如上例中的 vec4)通常会变成空值。一般来说,程序不应该继续使用被移动的对象,因为从理论上来说它的生命周期已经结束了(转移到了新的对象身上)。不过 C++ 编译器不会禁止这种行为。此外,如果对象的类型不支持移动构造 / 移动赋值,C++ 总是会执行复制操作。复制操作是移动操作无法进行时的备用方案。


和 C++ 区分左值和右值不同,在 Rust 中使用某个对象构造一个一模一样的新对象时,除了少数基本类型外[2],总是会执行移动操作。如果程序员想要执行复制操作而不是移动操作,需要调用 clone() 方法。

1
2
3
4
5
6
7
8
9
10
11
12
let vec = vec![1, 2, 3];

// 将 vec 的值移动到 vec2。
let vec2 = vec;

// 在移动发生后,vec 的生命周期结束,
// Rust 会禁止继续使用被移动的对象。
println!("{}", vec.len()); // 编译错误

// 使用 clone() 方法
// 复制 vec2 到 vec3 上。
let vec3 = vec2.clone();

移动操作的用途

C++ 和 Rust 支持移动操作,主要有三方面的用途。

第一,对于生命周期很短的临时对象,使用移动可以减少复制和销毁操作的次数。

在上文中,我们已经提到了,下面这段代码会执行一个移动操作,将 VecGenerator 函数的返回值移动到变量 vec3 身上。

1
2
3
4
5
std::vector<int> VecGenerator() {
return {1, 2, 3};
}

std::vector<int> vec3 = VecGenerator();

假如 C++ 不支持移动操作,这段代码在实际运行时会发生什么呢?首先,需要构造一个临时对象,用于存储函数 VecGenerator 的返回值。其次,将这个临时对象的值复制到 vec3 身上。最后一步,销毁那个临时对象。实际执行的步骤看上去会像这个样子:

1
2
3
std::vector<int> temp = VecGenerator();
std::vector<int> vec3 = temp;
// then delete temp

支持移动操作之后,原本需要一个构造、一个复制和一个销毁才能搞定的事情,现在一次移动就完成了,提升了程序的运行效率。

第二,对于较大的对象,和复制相比,移动的开销要小很多。

这一点,尤其适用于一些指针或者元数据(metadata)存储在栈(stack)上,而值存储在堆(heap)上的数据类型,比如 C++ 中的 std::vectorstd::string

在 C 和 C++ 中,我们可以使用 sizeof 运算符得到一个对象存储消耗的栈空间的大小。无论是空的 std::vector<int> 对象,还是一个存储了 10000 个整数的 std::vector<int> 对象,在 64 位计算机上 sizeof 运算符返回的结果都是 24:他们在栈上消耗的空间大小是相同的[3]。这两个对象的不同之处,是前者不消耗堆空间,而后者会消耗至少 40000 字节的堆空间。

如果要复制一个空的 vector 对象,我们只需要复制栈上的 24 字节;而如果要复制一个存储了 10000 个整数的 vector 对象,我们不仅要复制栈上的 24 字节,还要复制堆上的 40000 字节。复制一个容器的开销,和这个容器存储的数据大小是成正比的。

但是移动操作不一样。当一个对象被移动时,我们完全可以只移动它在栈上的部分,而不需要移动它在堆上的部分。因此移动一个 vector 对象,无论这个对象有多大,都只涉及栈上的 24 字节。这个开销和复制相比,通常要小得多。

第三,对于一些不能复制,或者需要独占所有权的对象,移动操作提供了转移对象所有权的方法。

某一些类型的对象,例如 C++ 的 std::unique_ptr,是不允许被复制的。之所以不允许复制 unique_ptr,是因为它的设计目标,就是让持有 unique_ptr 的函数能独占该对象的所有权。因为 unique_ptr 不能被复制,所以移动操作就成为了转移该对象所有权的唯一方法。


为什么多数编程语言不支持移动操作

为什么大多数编程语言,例如 Java / Go / Python,都不支持移动操作呢?从上面的例子我们已经看到,编程语言支持移动操作,为了和复制区分,肯定会在语法、语义和实现上变得更加复杂。只有移动操作带来了足够的好处,一门语言才有支持它的动力。对于多数编程语言来说,因为复制的开销不大,而且缺乏独占所有权的概念,没有足够的动机去支持移动操作。

我们在上一节中提到了,复制一个存储了 10000 个整数的 std::vector<int> 是一件开销很大的事情,因为 C++ 会把堆上存储的数据也复制一份,也就是深度复制(deep copy)。

然而,大多数编程语言在复制时,都不会进行深度复制,而是只进行浅层复制(shallow copy)。比如下面这段 Java 代码:

1
2
List<Integer> list = Arrays.asList(new Integer[10000]);
List<Integer> list2 = list;

list 复制给 list2 的时候,那 10000 个整数并没有被复制,被复制的只是 list 这个 8 字节大的指针而已。复制一个指针的开销是很低的,根本不需要使用移动操作来优化。

此外,C++ 和 Rust 语言不支持自动垃圾回收。这两种语言垃圾回收的普遍做法是,由一个函数独占对象的所有权,在函数执行结束时,自动销毁其独占的对象[4]。对于 Java / Go / Python 这些支持自动垃圾回收的语言来说,无须发展出一套完善的所有权的机制来进行垃圾回收。

总而言之,对于大多数编程语言,移动操作既不能改善运行效率,而且因为根本没有所有权的概念,也不需要通过它来实现所有权的转移。不支持对象的移动操作,也就是理所当然的事情了。


[1] 关于左值和右值的详细定义,参见 C++ 标准

[2] 对于如下基本类型,Rust 会进行拷贝而不是移动操作:布尔类型(bool)、字符类型(char)、整数和浮点数类型、以及由这几种类型组成的元组(tuple)。

[3] 一个 std::vector 对象在 64 位计算机上总是消耗 24 字节的栈空间。其中有 8 字节用于记录 vector 容器中存储的数据个数(size),另外 8 字节记录容器的容量(capacity),还有 8 字节的指针记录实际数据在堆中存放的位置(ptr)。

[4] 这种资源管理 / 垃圾回收方式有个专门的说法,叫 RAII -- Resource acquisition is initialization