各种编程语言都支持对象(object)的构造、复制、读取、修改和销毁等操作。其中的复制(copy)操作,就是在计算机内存中的某个新地址,构造一个和原对象一模一样的新对象。
少数语言,例如 C++ 和 Rust,还支持一种叫『移动』(move)的操作。对象的移动,顾名思义,就是把原对象从计算机内存中的某个地址搬运至新的地址。听上去,移动操作做的事情似乎和复制差不多。那么为什么 C++ 和 Rust 要引入移动操作呢?为什么其他大多数语言都不支持移动操作呢?
如何使用移动操作
在讨论移动操作的用途之前,我们需要先了解一下如何在程序中使用移动操作。
在 C++ 中,当需要在内存中的某个新地址,构造一个和原对象一模一样的新对象的时候,编译器会根据原对象是『左值』(lvalue)还是『右值』(rvalue),来决定是执行复制操作,还是执行移动操作。一个对象是左值还是右值,有一种较为直观(但不十分准确)的判定方法:能被赋值,或者说,能出现在赋值符号 =
左侧的对象是左值,否则就是右值[1]。我们通过几个直观的例子来看一下。
1 | // i 是左值。 |
当 C++ 编译器发现原对象是一个右值对象的引用(rvalue reference),且该对象类型支持移动构造 / 移动赋值时,会执行移动操作,否则会执行复制操作。
1 | std::vector<int> vec = {1, 2, 3}; |
程序员可以使用 std::move
函数将某一个对象的引用,强制转换为一个右值对象的引用,来触发编译器执行移动操作。
1 | std::vector<int> vec4 = {1, 2, 3}; |
移动发生后,被移动的对象(如上例中的 vec4
)通常会变成空值。一般来说,程序不应该继续使用被移动的对象,因为从理论上来说它的生命周期已经结束了(转移到了新的对象身上)。不过 C++ 编译器不会禁止这种行为。此外,如果对象的类型不支持移动构造 / 移动赋值,C++ 总是会执行复制操作。复制操作是移动操作无法进行时的备用方案。
和 C++ 区分左值和右值不同,在 Rust 中使用某个对象构造一个一模一样的新对象时,除了少数基本类型外[2],总是会执行移动操作。如果程序员想要执行复制操作而不是移动操作,需要调用 clone()
方法。
1 | let vec = vec![1, 2, 3]; |
移动操作的用途
C++ 和 Rust 支持移动操作,主要有三方面的用途。
第一,对于生命周期很短的临时对象,使用移动可以减少复制和销毁操作的次数。
在上文中,我们已经提到了,下面这段代码会执行一个移动操作,将 VecGenerator
函数的返回值移动到变量 vec3 身上。
1 | std::vector<int> VecGenerator() { |
假如 C++ 不支持移动操作,这段代码在实际运行时会发生什么呢?首先,需要构造一个临时对象,用于存储函数 VecGenerator
的返回值。其次,将这个临时对象的值复制到 vec3
身上。最后一步,销毁那个临时对象。实际执行的步骤看上去会像这个样子:
1 | std::vector<int> temp = VecGenerator(); |
支持移动操作之后,原本需要一个构造、一个复制和一个销毁才能搞定的事情,现在一次移动就完成了,提升了程序的运行效率。
第二,对于较大的对象,和复制相比,移动的开销要小很多。
这一点,尤其适用于一些指针或者元数据(metadata)存储在栈(stack)上,而值存储在堆(heap)上的数据类型,比如 C++ 中的 std::vector
和 std::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 | List<Integer> list = Arrays.asList(new Integer[10000]); |
把 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。