本章覆盖有:
- 为什么决定性的(deterministic)、隐式(implicit)的对象销毁是Rust的一大亮点
- 对象所有者(ownership)的概念
- 为什么自定义销毁可能有用,怎么创建
- 三种赋值语义:共享(share)、拷贝(copy)、移动(move)
- 为什么隐式共享对软件正确性是糟糕的
- 为什么对象的移动(move semantics)比起拷贝(copy semantics)可能有更好的性能
- 为什么某些类型需要拷贝(copy semantics),某些不需要,怎么区分
- 为什么某些类型需要是不可复制的(non-cloneable),怎么区分
¶Deterministic Destruction
前面,我们看到有几种内存分配对象的方式,这些分配都是在stack和heap发生:
- 临时表达式,分配在stack;
- 变量(包括数组),分配在stack;
- 函数和闭包的参数,分配在stack;
Box
对象,引用分配在stack,引用的对象分配在heap;- 动态字符串和集合(包括vector),header分配在stack,数据分配在heap。
对象“真实”的瞬时分配是很难预测的,因为它取决于编译器优化。因此,我们考虑“概念”上的瞬时分配情况。
概念上,当对应表达式第一次出现在代码时发生stack分配,因此:
- 临时表达式,变量,数组在它们第一次出现在代码时被分配;
- 函数和闭包的参数,在函数/闭包被调用时被分配;
Box
对象,动态字符串,集合header,在代码第一出现时被分配。
heap的当需要这些数据时,进行heap分配。因此:
Box
对象的分配,由Box::new
函数触发;- 动态字符串的字符分配,在字符被添加到该字符串时触发;
- 集合内容的分配,出现在有数据被添加到集合时。
上面这些跟大多数编程语言没不同之处。那么数据的销毁在什么时候发生?
概念上,在Rust中,当这些数据不再可被访问时,自动销毁。因此:
- 临时表达式被回收,当它在语句的结束位置(即,在下一个
;
位置或当前scope的结束位置); - 变量(包括数组)被回收,当它在scope的声明结束;
- 函数/闭包的参数的回收,出现在函数/闭包体结束;
Box
对象的回收,在当前scope的声明结束;- 动态字符串的字符被回收,出现在从字符串中删除该字符时,或者整个字符串删除时;
- 集合中的条目被回收,出现在从集合中删除该条目时,或者整个集合被删除时。
这一概念使得Rust和大部分语言区分开来。任何语言都有临时对象或栈分配(stack-allocated)对象,这种对象是自动回收的。但堆分配(heap-allocated)对象的回收,不同语言各不相同。
在某些语言中,诸如Pascal,C,C++,heap上的对象通常仅能显式地调用类似free
或delete
这些函数进行回收。另一些语言,诸如Java,JavaScript,C+,Python,堆上的对象不可访问时,并没有立即进行回收,而是有一个定期的行程,用来查找heap不可达对象,并回收这些对象。这种机制称为“垃圾回收”,因为它类似城市的清理系统:它定期清理城镇,当有垃圾堆积。
因此,在C++和类似语言中,heap回收既是决定性的,deterministic
,也是显式的,explicit
。决定性的,因为它在源代码的定义位置,以及是显式的,因为它要求程序员编写指定的回收语句。决定性的好处在于,可能有更好的性能,程序员可能更好地控制。但显式的却不好,因为不能避免出现错误的回收,丑陋的bug结果。
相反,在Java和类似语言中,heap回收既是非决定性的,non-deterministic
,也是隐式的,implicit
。非决定性的,因为它出现未知的执行瞬时,以及是隐式的,因为它不需要指定回收语句。非决定性是糟糕的,但隐式的美好的。
区别于这两种技术,在Rust中,通常,heap的回收既是决定性的,deterministic
,也是隐式的,implicit
,这是Rust比起其它语言更大的优势。
这种可能性的实现,是因为遵循了基于“所有者,ownership
”的概念,
¶Ownership
首先介绍术语“to own
”。在计算机科学中,对于一个标识符或一个对象A,拥有(to own)对象B,意味着A可以对B进行回收,它有两个意义:
- Only A can deallocate B.
- When A becomes unreachable, A must deallocate B.
在Rust中没有显示的回收机制,因此这种定义可以复述为“A owns B means that B is deallocated when and only when A becomes unreachable”。
1 | let mut a = 3; |
该程序,变量a
拥有一个对象初始化值3,因为当a
离开了它的scope,变成不可访问,该初始化值为3的对象被回收。我们也可以这样说“a
是一个对象的所有者,它由值3初始化”。尽管,我们不能说“a
拥有3”,因为3是一个值,不是对象;仅对象才能被拥有(owned)。在内存中,有很多对象值是3的,a
拥有其中一个。在第二条语句中,该对象的值变为4;但它的拥有者没有变。
在最后一条语句中,b
由一个5个元素的vector初始化。这个vector由一个头(header)和一个数据缓冲区(data buffer);header的实现由一个结构体三个filed表示:一个执行data buffer的指针,两个数(capacity、len);数据缓冲区包含5个条目,另外可能有额外的空间。这里我们可以说“b
拥有vector的header,以及一个指针,该指针包含数据缓冲区的拥有者头(header)”。实际上,当b
离开了它的scope,vector的头被回收;当vector的header被回收后,它包含的指针不可访问;当前的vector表示为一个空,因此缓冲区的条目被回收。
不是每个引用所有者是一个对象,
1 | let a = 3; |
这里的a_ref
变量拥有一个引用,但这个引用什么都没有。实际上,在这个嵌入块的结束位置,a_ref
变量离开了它的scope,该引用被回收,但引用对象,即这个包含值3的对象,没有立即被回收,因为它必须在最后一条语句打印输出。
为了确保每个对象自动回收,Rust中有一个简单规则,在每个执行的瞬时,每个对象有且仅能有一个“owner”。当这个owner被回收,该对象自身被回收。如果一个对象有几个owner,这个对象可能被回收几次,这是不被允许的。如果对象没有owner,该对象从不被回收,这种情况叫做“内存泄露,memory leak”。
¶Destructors
我们看到对象的创建有两步:给对象分配内存,初始化这个内存空间的值。对于复杂对象,初始化是如此复杂,通常需要使用一个函数实现。这个函数叫“构造器”,用来“构造”一个新的对象。
我们刚看到,当一个对象被回收,会发生一些复杂情况。如果在heap中一个对象引用另一个对象,一个级联(cascade)的回收可能会发生。因此,对象的“销毁”可能需要由一个函数处理,称作“destructor,焚烧炉,销毁装置”。
通常销毁器是属于标准库的一部分,但有时你可能需要在对象回收时做一些cleanup code操作,所以你需要写一个destructor。
1 | struct CommunicationChannel { |
该程序将打印:
1 | Operning port usb4:879 |
第二条语句声明新的类型CommunicationChannel用于实现Drop
。这个trait有一个特有的方法drop
,它会在对象被回收时自动被调用,因此它是一个“destructor”。通常,给一个类型创建一个销毁器,为该类型实现这个Drop
trait即可。因为任何没有被定义的trait,不能在程序外部实现。
第三条语句是一个语句块,为结构体定义了两个方法:create
构造器,send
方法。
最后是应用代码。创建了一个CommunicationChannel,这个创建会打印一行内容。接着调用了send方法,打印第二行内容。接着是内嵌语句块,创建了另一个channel,打印第三、四行内容。
嵌套语句块内的变量名跟存在的变量名相同,这会导致变量投影(shadow)。
接着嵌套语句结束。这发生率内部变量被销毁,因此它的drop
方法被调用,于是打印第五行。
现在,嵌套语句块结束后,第一个变量再次可见。send
方法再次调用,打印一行。
最后,变量被销毁,打印最后一行。
在Rust中,内存早已由语言和标准库释放掉了,因此没有必要像C语言那样调用free
函数,或像C++那样调用delete
。但其他资源不会自动释放。因此销毁器(destructor)对于那些副作用的实现非常有用:诸如文件处理,通讯处理,GUI窗口,图形资源等,标准库中早已为资源的处理的任何类型提供了Drop
实现。
销毁器可以更好地理解内存的管理。
1 | struct S ( i32 ); |
结果打印:
1 | INNER |
注意到对象的销毁的顺序跟构造顺序相反,
1 | struct S ( i32 ); |
结果将打印:
1 | Dropped 1 |
因为只有占位符,因此所有对象都是临时的。临时对象在它们语句结束位置就销毁了,即统计到分号(;
)立即销毁。
上面的程序和下面的是等价的,
1 | struct S ( i32 ); |
¶Assignment Semantics
下面程序做了什么?
1 | let v1 = vec![11, 22, 33]; |
概念上,
首先,v1
的标头(header)被分配到了栈。然后,v1
的内容,会在堆为该内容分配一个缓冲区,v1
的元素之被拷贝到这个缓冲区。然后标头(header)被初始化,作为引用指向新分配的堆缓冲。
然后,v2
的标头被分配在栈。接着,用v1
的值初始化v2
。但,这是如何实现的?
通常至少有三种方式实现这种操作:
-
Share semantics
:v1
的标头被拷贝到v2
的标头,其它不发生任何操作。因此,可以用v1
,也可以用v2
,它们都同时指向相同的堆缓冲区;因此,它们指向同样的内容,不是相等的,而是唯一的。这种术语的常见于垃圾回收语言,比如Java。 -
Copy semantics
:分配另外的堆缓冲。它和v1
使用的缓冲区有同样的大小,并将先存的缓冲区内容拷贝到新的缓冲区。然后v2
的标头被初始化指向新分配的缓冲区。因此,两个变量指向两个不同的缓冲区并且初始化的内容相同。这种实现,是C++的默认机制。 -
Move semantics
:v1
的标头被拷贝到v2
的标头,其它不发生任何操作。因此,v2
可以使用,它的标头指向原先v1
分配的堆缓冲区,但v1
不能再被使用。这种实现,是Rust的默认机制。
1 | let v1 = vec![11, 22, 33]; |
该代码产生编译错误:“use of moved value: v1
”。当v1
的值指派给v2
是,变量v1
终止并退出。再次使用是不被编译器允许的。
先看看,为什么Rust不实现share semantics。首先,如果变量是可变的,这种语义(semnatics)会有几分迷惑。在共享术语(share semantics),通过一个变量更改一个条目,这个条目也可以被其它变量更改和访问。这不是直觉,可能是bug的根源。因此,共享术语(share senantics)仅在只读数据(read-only data)能被接收。
但这里有个大问题,对于内存回收。如果使用共享术语,v1
和v2
都将会拥有同一个单一的数据缓冲区,因此当他们被回收时,同样的堆缓冲区会被回收两次。一个缓冲区不能被分配两次,而不导致内存损耗以及引起程序崩溃(program malfunction)。要解决这个问题,语言本身需要在scope结束时不对变量使用的内进行回收,而是凭借GC处理。
相反,拷贝语义(copy semantics)和移动语义(move semantics)都是正确的。实际上,Rust规则上把回收看做是任何对象必须有且仅有一个owner。当使用拷贝语义时,原来的vector缓冲区还是原来的owner,即变量v1
的标头,新创建的缓冲区,有新的owner引用,即v2
的标头。另一方面,当使用移动语义时,原来单一vector缓冲区更改它的owner:分配之前,缓冲区的所有者是v1
的标头reference,分配之后,所有者更改为v2
的标头reference。在分配之前,v2
的标头并不存在,分配之后,v1
的标头不再存在。
那为什么Rust不实现拷贝语义(copy semantics)?
实际上,某些情况下,使用拷贝语义更合适,另一些情况下,使用移动语义更适合。甚至C++,从2011年开始,允许同时拷贝语义和移动语义。
1 |
|
这段C程序会打印:0 3 3。v1
首先被拷贝到v2
,然后移动到v3
。C标准函数move
会清空vector但不会让其undefined。因此,在最后,v2
有三个元素的拷贝,v3
就是原来的v1
,v1
变为空。
Rust中也允许拷贝语义和移动语义。
1 | let v1 = vec![11, 22, 33]; |
将会打印:3 3。
这段程序和C类似,但不能再访问v1
了,因为它被移动了。因为C的默认语义是拷贝语义(copy semantics),所以需要调用move
标准函数来进行对象移动;而Rust的默认语义是移动语义(move semantics),所以需要调用标准函数clone
进行拷贝。
另外,v1
虽已被移动,但仍然可访问,只不过内容为空,Rust中被移动的变量不可再被访问。
¶Copying vs. Moving Performance
Rust偏向于移动语义的选择是从性能方面考量的。对于拥有堆缓冲区的对象,比如vector,移动比拷贝要快,因为移动的仅是header,然而如果是拷贝一个vector,要求分配和初始化一个可能的堆缓冲区,它最终会被回收。
在C++中,被移动的对象意味着不在被使用了,但语言为了对遗留代码做后向兼容(backward-compatible),被移动的对象仍然可以访问,这可能会给开发者再次使用该对象的机会。另外,清空一个被移动的vector有较小的消耗,即当一个vector被销毁,会检测它是否为空,这也有较小消耗。Rust被设计避免手动移动对象,因此不会有不正当的移动vector,因为编译器知道vector被移动了,可以产生更好的代码。
我们可以通过下面代码度量性能的影响,这并不简单,因为编译优化器会移除loop内的工作。
下面代码使用了拷贝语义。
1 | use std::time::Instant; |
下面是C++的等价实现,
1 |
|
下面Rust程序使用了移动术语,
1 | use std::time::Instant; |
C++的等价实现为,
1 |
|
下面是编译优化后的大致的时间损耗,
Rust | C++ | |
---|---|---|
Copy semantics | 157 | 87 |
Move semantics | 67 | 67 |
不管是在C还是Rust中,移动术语都要比拷贝术语要快。在这方面,移动术语两者都差不多,拷贝术语方面C要比Rust好很多。
¶Moving and Destroying Objects
所有这些概念不单是对vector,任何有缓冲区引用的对象都适用,譬如String
或Box
。
1 | let s1 = "abcd".to_string(); |
这和C++类似,
1 |
|
前面说过,被移动的对象不能再访问,因此s1
访问时会导致编译错误;而对于C++,原来的s1
会置为空,所以会输出0 4 4。
对于Box
类型,
1 | let i1 = Box::new(12345i16); |
对应的C++,
1 |
|
Rust程序会输出12345 12345,任何访问i1
都会导致编译错误。C++会输出0 1 1 12345 12345。因为仅i1
是null,它被移动到i3
了。
仅当他们被用于初始化一个变量,给一个有值的变量重新赋值,对象不被移动,
1 | let v1 = vec![false; 3]; |
以及给函数参数传递值时,
1 | fn f(v2: Vec<bool>) {} |
以及指派的对象在当前没有实际堆分配时,
1 | let v1 = vec![false; 0]; |
编译上面任何三条程序,最后一个语句都会导致“use of a moved value”的编译错误,
尤其是,在程序最后,v1
被移动到v2
,即使他们都为空,因此它们没有堆空间被使用。为什么?因为移动规则由编译器提供,因此它在运行期必须是独立的内容。
下面的代码,最后一行也会导致编译错误,
1 | struct S {} |
编译器可以确切知道这个引用不会指向heap,但仍然编译报错。为什么Rust不为该类型使用拷贝语义?
它的基本原理是这样的。用户定义的类型S
现在没有引用内存,但在将来软件维护的时候,指向堆的引用可能会被添加,即S
可能会被作为字段(field)等。因此,如果为S
实现了拷贝语义,当程序源被更改,一个String
、Box
、或集合,直接或间接地添加到S
,会导致很多错误。因此,作为规则,最后保留移动语义。
¶Need for Copy Semantics
我们看到很多类型使用移动语义,包括vector,动态字符串,boxes,结构体… 下面的程序是合法的,
1 | let i1 = 123; |
结果将打印:“123 abc 123”。怎么来的?
首先,对于原生类型,静态字符串,引用,Rust不使用移动语义。对于这些数据类型,Rust使用拷贝语义。
为什么?前面看到,如果一个对象可以“拥有”一个或多个堆对象,它的类型应该实现移动语义;但如果不能“拥有”任何堆内存,它仅可以实现拷贝语义。移动语义对于原生类型来说是个麻烦的东西,而且它也不适合于被改变来“拥有”某些堆对象。因此,对于这些类型来说,拷贝语义是安全的、高效的、并且更方便。
因此,Rust中的某些类型实现了拷贝语义,另一些实现了移动语义。另外,numbers、Booleans、static strings、arrays、tuples、references实现了拷贝语义。相反,dynamic strings、boxes、集合(包括vectors)、enums、structs、tuple-structs均默认实现了移动语义。
¶Cloning Objects
然而,对于对象的拷贝,有另一种重要的区分。所有实现拷贝语义的类型可以通过指派的方式简单地拷贝;但对于移动语义的对象来说也可以进行拷贝,但需要使用标准库的clone
。对于动态字符串、boxed对象、vector可以使用clone
函数。但对于某些类型,不能使用clone
函数,因为没有合适的拷贝类型。例如一个文件处理,一个GUI窗口处理,或一个互斥锁处理。如果你拷贝了它,然后销毁某一份拷贝,源资源会被释放,其它拷贝的处理会前后不一致:
因此,关于可被拷贝的能力,会有三种不同的对象:
- 对象不“拥有”任何东西,拷贝是廉价的、容易的。
- 对象“拥有”某些堆对象,但不“拥有”内部资源,所以可以被拷贝,但运行期有较大损耗。
- 对象“拥有”内部资源,譬如文件处理、GUI窗口处理,所以它不能被拷贝。
第一类对象,称为“可拷贝对象,copyable objects”,因为对于这类对象来说,拷贝更加高效。
第二种对象,称为“克隆而非拷贝对象,cloneable but non-copyable objects”,顾名思义,这类对象可以实现拷贝语义,但也应该实现移动语义,以避免运行期非必要的副本消耗。甚至,需要显式提供一个方法进行复制。
第三种类型,应该实现移动语义,但不应该提供复制方法,因为它拥有的资源不能在Rust代码复制,这种资源仅能有一个“owner”,所以这类称之为“不可拷贝对象,non-cloneable objects”。
当然,任何对象可自动被拷贝也可显式地被拷贝,所以任何可拷贝对象(copyable object)也是一个可克隆对象(cloneable object)。
总结,某些对象不能被克隆(如文件处理),某些可以拷贝(显式地),某些可以隐式拷贝(如数字),某些不能拷贝(如集合)。
为了区分这三者,Rust标准库包含有两个特殊的trait:Copy
和Clone
。任何类型实现了Copy
的是可拷贝的;任何类型实现了Clone
是可克隆的。
因此,这三种可以文字描述为如下:
- 对象实现了
Copy
和Clone
的,包含拷贝语义,也可以显式克隆。例如原生类型。 - 对象实现了
Clone
,但没有实现Copy
的,它们实现了移动语义,可以显式克隆。例如集合类型。 - 既没有实现
Copy
,也没有实现Clone
的,属于不可复制,它们实现了移动语义,例如文件处理等。 - 没有对象是实现了
Copy
,却没有Clone
的。这意味着对象的拷贝是隐式的,却不能显式调用,这是无意义的。
下面例子展示所有的情况,
1 | let a1 = 123; |
结果打印:“123 123 123 [] [] File
”。
此处三处注释的地方是不合法的语句。
首先,a1
是原生类型,所以这种类型可以隐式拷贝,也可以显式克隆(clone)。所以此处三个对象有同样的值,并打印输出。
因为a2
是一个集合类型,这种类型可以克隆,但不能拷贝,所以b2
可以通过a2
显式克隆,对b2
到c2
的赋值是移动语义,b2
不可再访问。
对于a3
而言,它是一个文件handle,这种类型不能被克隆,尝试编译a3.clone()
会出现编译错误,以及对a3
到c3
的赋值是移动语义,对象被移动了,a3
不可再访问。
¶Making Types Cloneable or Copyable
前面说过,枚举,结构体,元组-结构体,默认都没有实现Copy
和Clone
。因此它是不可克隆的。因此,你可能需要仅实现Clone
,又或Copy
和Clone
都需要有。
下面代码是不合法的,
1 | struct S {} |
所以你会为其实现Clone
,
1 | struct S {} |
这些实现写法,在前面面向对象编程介绍过,你需要为其实现clone
方法,
另外,实现的Clone
方法,不能隐式使用拷贝语义,所以下面代码是不合法的,
1 | struct S {} |
所以,你需要实现Copy
,使其合法,
1 | struct S {} |
主要到Copy
的实现可以为空;只要声明了Copy
的实现,拷贝语义就激活了。
下面写法却是不合法的,
1 | struct S {} |
编译器会抱怨:“the trait bound
main::S:std::clone::Clone is not satisfied
”。Copy
的实现,前提条件是Clone
也实现了,
但下面写法也是不合法的,
1 | struct S { x: Vec<i32> } |
编译产生错误信息,“the trait Copy
may not be implemented for this type”。告诉你Vec<i2>
类型没有实现Copy
。
Rust允许实现Copy
,仅能允许其类型是包含可拷贝对象的,因为拷贝对象,意味着会拷贝它的所有成员(members)。这里,Vec
没有实现Copy
,所以S
也不能实现拷贝语义。
相反,下面代码是合法的,
1 | struct S { x: Vec<i32> } |
结果将打印:“13 12”。
这里,S
结构体不是可拷贝的,但却是可克隆的(clonable),因为它实现了Clone
。因此,s1
可以对s2
赋值。s1
被修改,两者输出内容不同。