Beginning Rust From Novice to Professional
Author: Carlo Milanesi
如果需要电子书的小伙伴,可以留下邮箱,看到了会发送的
Chapter 21 Drops, Moves, and Copies
Deterministic Destruction
到目前为止,我们看到了几种分配对象的方法,包括在栈和堆中:
- 临时表达式,在栈中
- 变量(包括数组),在栈中
- 方法和闭包的参数,在栈中
- Box对象的引用,在栈中,Box所引用的对象在堆中(也就是指针变量在栈中,指针指向堆中)
- 动态字符串和集合,在栈中分配header,并在堆中分配数据
当分配这些对象时的实际瞬间很难预测,因为它依赖于编译器的优化。所以,让我们考虑一下这种分配的概念瞬间。
从概念上讲,每个栈分配都在相应的表达式首次在代码中出现时发生
- 临时表达式、变量和数组会申请内存,当他们首次出现在代码中
- 方法和闭包的参数会在执行时申请内存
- Box、动态字符串和集合的header(存放指针的地方)会申请内存,当他们首次出现在代码中
每次堆分配都会在需要此类数据时进行
- Box::new时发生
- 当将一些字符添加到字符串时
- 当将某些数据添加到集合时
所有这些都与大多数编程语言没有什么不同。
那么数据的释放何时发生?
从概念上讲,在Rust中,当这样的数据项不再可以被访问时,它就会自动发生
- 当包含临时表达式的语句结束时(即在下一个分号或当前范围结束时),将释放临时表达式;
- 当包含其声明的范围结束时,将释放变量(包括数组)
- 函数和闭包参数的函数/闭包块结束时被解除分配
- 当包含其声明的作用域结束时,释放Box对象
- 动态字符串中包含的字符在从字符串中移除时,或者当字符串被释放时
- 集合中包含的项在从集合中删除时释放,或者在释放集合时释放。
这是一个区别于大多数编程语言的概念。在任何具有临时对象或栈分配对象的语言中,这类对象都会自动被释放。但是堆分配的对象的内存释放因不同的语言而不同。
在某些语言中,如Pascal、C和C++,堆对象通常只通过调用“free”或“delete”等函数来显式地释放。在其他语言中,如Java、JavaScript、c#和Python,当堆对象不再可访问时不会立即释放,但是有一个例程定期运行,它找到不可到达的堆对象并释放它们。这种机制被称为“垃圾收集”,因为它类似于城市的清洁系统:当一些垃圾堆积起来时,它会定期清理城镇。
因此,在C-++和类似的语言中,堆的释放是确定性的和显式的。它是确定性的,因为它发生在源代码中定义良好的位置;而且它是显式的,因为它要求程序员编写一个特定的释放语句。确定性是很好的,因为它有更好的性能,它允许程序员更好地控制计算机中正在发生的事情。但是显式又是不好的,因为如果释放执行错误,就会导致严重的错误。
相反,在Java和类似的语言中,堆的释放是不确定性的和隐式的。它是不确定性的,因为它发生在未知的执行实例中;而且它是隐式的,因为它不需要特定的交易语句。不确定性是不好的,而隐式则是好的。
与这两种技术不同的是,在Rust中,堆的释放通常是确定性的和隐式的,这是Rust相对于其他语言的一个巨大优势。这是可能的,因为基于“所有权”概念的机制。
Ownership
在计算机科学中,对于一个标识符或一个对象a,“拥有”一个对象B,意味着A负责释放B,这意味着两件事:
- 只有A可以释放B
- 当A无法到达(可达性分析)时,A必须释放B。
在Rust中,没有显式的释放机制,所以这个定义可以重新定义为“A拥有B意味着B是当且只有当A无法到达时才被释放”。
let mut a = 3;
a = 4;
let b = vec![11, 22, 33, 44, 55];
在这个程序中,变量a拥有一个最初包含值3的对象,因为当a超出它的范围,因此它变得不可达,这个最初具有值3的对象被释放。我们也可以说,“a是一个对象的所有者,其初始值为3”。但是,我们不应该说“a拥有3”,因为3是一个值,而不是一个对象;只有对象才能被拥有。在内存中,可以有许多对象的值为3,而a只拥有其中的一个对象。在前一个程序的第二个语句中,该对象的值被更改为4;但是它的所有权并没有改变:a仍然拥有它。
在最后一个语句中,b被初始化为一个包含五个项目的集合。这样的集合有一个header和一个数据缓冲区;header被实现为一个包含三个成员的struct:一个指向数据缓冲区的指针和两个数字;数据缓冲区包含这五个项目,可能还有一些额外的空间。这里我们可以说“b拥有一个集合的header,而包含在集合的header中的指针拥有数据缓冲区”。实际上,当b离开它的范围时,header被释放;当该header被释放时,其包含的指针变得不可达;当该集合表示一个非空集合时,包含集合项的缓冲区也被释放。
不过,并不是每个引用都拥有一个对象。
let a = 3;
{
let a_ref = &a;
}
print!("{}", a);
在这里,a_ref变量拥有一个引用,但该引用并不拥有任何东西。实际上,在嵌套块的末尾,a_ref变量离开它的范围,因此引用被释放,但是引用对象,即值为3的数字,不应该立即释放,因为它必须在最后一个语句中打印。为了确保每个对象在不再被引用时都被自动释放,Rust有一个简单的规则:在执行的每一刻,每个对象都必须有一个“所有者”,不多,不少。当该所有者被释放时,该对象本身将被释放。如果有多个所有者,对象可以多次释放,这是不允许的。如果没有所有者,该对象就永远无法被释放,这是一个名为“内存泄漏”的错误。
Destructors
我们看到对象的创建有两个步骤:分配对象所需的内存空间,以及用一个值初始化这样的空间。对于复杂对象,初始化非常复杂,通常使用函数。这样的函数被命名为“构造函数”,因为它们“构造”了新的对象。
我们只是看到,当一个物体被释放时,可能会发生一些相当复杂的事情。如果该对象引用了堆中的其他对象,则可能会发生级联交易。因此,即使是对对象的“破坏”也可能需要由一个名为“析构函数”的函数来执行。
通常析构函数是语言或标准库的一部分,但有时当对象被释放时,您可能需要执行一些清理代码,因此您需要编写析构函数。
struct CommunicationChannel {
address: String,
port: u16,
}
impl Drop for CommunicationChannel {
fn drop(&mut self) {
println!("Closing port {}:{}", self.address, self.port);
}
}
impl CommunicationChannel {
fn create(address: &str, port: u16) -> CommunicationChannel {
println!("Opening port {}:{}", address, port);
CommunicationChannel {
address: address.to_string(),
port: port,
}
}
fn send(&self, msg: &str) {
println!("Sent to {}:{} the message '{}'", self.address, self.port, msg);
}
}
let channel = CommunicationChannel::create("usb4", 879);
channel.send("Message 1");
{
let channel = CommunicationChannel::create("eth1", 12000);
channel.send("Message 2");
}
channel.send("Message 3");
第二个语句为新声明的类型通信通道实现了trait Drop
。这种由语言定义的trait具有一个特殊的特性,即它唯一的方法,名为drop,在对象被释放时被自动调用,因此它是一个“析构函数”。通常,要为类型创建析构函数,只要为这种类型实现Drop trait就足够了。由于程序中未定义的任何其他特征,您不能为程序之外定义的类型实现它。
Assignment Semantics
let v1 = vec![11, 22, 33];
let v2 = v1;
以上语句发生了什么?从概念上讲,首先,v1的header将在栈中分配。然后,由于集合具有内容,因此在堆中分配这些内容的缓冲区,并将值复制到堆上。然后,将初始化header,使其引用新分配的堆缓冲区。然后在栈中分配v2的header。然后,使用v1初始化v2。但是,这是如何实现的呢?
一般来说,至少有三种方法来实现这种操作:
- Share semantics: v1的header被复制到v2的header上,没有发生其他事情。随后,可以使用v1和v2,它们都引用相同的堆缓冲区;因此,它们引用的是相同的内容,不是两个相等而是不同的内容。这个语义是由垃圾收集语言实现的,比如Java。
- Copy semantics: 已分配了另一个堆缓冲区。它与v1所使用的堆缓冲区一样大,并且预先存在的缓冲区的内容会被复制到新的缓冲区中。然后初始化v2的header,使其引用新分配的缓冲区。因此,这两个变量指的是最初具有相同内容的两个不同的缓冲区。默认情况下,这是由C++实现的。
- Move semantics: v1的header被复制到v2的header上,没有发生其他事情。随后,可以使用v2,它引用了为v1分配的堆缓冲区,但是v1不能再使用了。默认情况下,这是由Rust实现的。
让我们来看看为什么Rust没有实现共享语义。首先,如果变量是可变的,那么这样的语义就会有些混乱。使用共享语义,在通过一个变量更改一个项后,当通过另一个变量访问它时,该项似乎也会发生更改。而且这并不直观,也可能是错误的来源。因此,共享语义只适用只读数据。
但关于释放,还有一个更大的问题。如果使用共享语义,那么v1和v2都将拥有单个数据缓冲区,因此当它们被释放时,相同的堆缓冲区将被释放两次。缓冲区不能释放两次,而导致内存损坏,从而导致程序故障。为了解决这个问题,使用共享语义的语言不会使用这些内存在变量的范围末尾释放内存,而是使用垃圾收集。
相反,复制语义和移动语义都是正确的。事实上,关于Rust的释放规则是,任何对象必须只能有一个所有者。当使用复制语义时,原始的集合缓冲区保留它的单个所有者,即v1引用的header,而新创建的集合缓冲区得到它的单个所有者,即v2引用的header。另一方面,当使用移动语义时,单个集合缓冲区会改变所有者:在赋值之前,它的所有者是v1引用的header,在赋值之后,它的所有者是v2引用的header。在分配之前,v2的header还不存在,而在分配之后,v1的header也不再存在。
实际上,在某些情况下,复制语义更合适,但在其他情况下,移动语义更合适
Copying vs. Moving Performance
Rust更倾向移动语义是因为性能上会更好。对于一个拥有堆内存数据的对象来说,移动是比复制要更快的,因为移动仅仅是复制栈上的header,但是如果是复制语义,那就会把堆中的数据也一起复制了,这涉及了堆内存的分配以及初始化和数据的复制。一般来说,Rust的设计选择是允许任何操作,但要使用更小的语法来进行最安全、更高效的操作。
我们可以使用下面的代码来衡量这种性能影响
use std::time::Instant;
fn elapsed_ms(t1: Instant, t2: Instant) -> f64 {
let t = t2 - t1;
t.as_secs() as f64 * 1000. + t.subsec_nanos() as f64 / 1e6
}
const N_ITER: usize = 100_000_000;
let start_time = Instant::now();
for i in 0..N_ITER {
let v1 = vec![11, 22];
let mut v2 = v1.clone(); // Copy semantics is used
// let mut v2 = v1; // Move semantics is used
v2.push(i);
if v2[1] + v2[2] == v2[0] {
print!("Error");
}
}
let finish_time = Instant::now();
print!("{} ns per iteration\n", elapsed_ms(start_time, finish_time) * 1e6 / N_ITER as f64);
如果不是这种小的集合,而是一个很大的集合,或者是树的对象,那么移动语义和复制语义之间的性能差距会更大
Moving and Destroying Objects
所有这些概念不仅适用于集合,而且也适用于引用堆缓冲区的任何对象,比如String和Box
对象不仅在用于初始化变量时会移动,而且在分配已经具有值的变量时也会移动
let v1 = vec![false; 3];
let mut v2 = vec![false; 2];
v2 = v1;
v1;
当将一个值传递给一个函数参数时也会移动
fn f(v2: Vec<bool>) {}
let v1 = vec![false; 3];
f(v1);
v1;
当当前分配的对象没有引用实际的堆时
let v1 = vec![false; 0];
let mut v2 = vec![false; 0];
v2 = v1;
v1;
特别是,在最后一个程序中,v1被移动到v2,即使它们都是空的,不使用堆。为什么?因为移动规则是由编译器执行的,因此它必须独立于运行时对象的实际内容。
Need for Copy Semantics
嗯,对于原始类型、静态字符串和引用,Rust不使用移动语义。对于这些数据类型,Rust使用了复制语义
为什么?我们之前看到,如果一个对象可以拥有堆对象,它的类型应该实现移动语义;但是如果它不能拥有任何堆内存,它也可以实现复制语义。移动语义对于原始类型来说是一个麻烦的问题,而且它们不可能被更改为拥有一些堆对象。因此,对他们来说,复制语义是安全、高效、更方便的。
因此,一些Rust类型实现了复制语义,而其他类型则实现了移动语义。特别是,numbers, Booleans, static strings, arrays, tuples和任何实现了复制语义的类型的引用。相反,dynamic strings, boxes, any collection(including vectors), enums, structs, and tuple-structs在默认情况下实现了移动语义。
Cloning Objects
然而,关于对象的复制,还有另一个重要的区别需要知道。所有实现复制语义的类型都可以很容易地通过赋值复制;但是,也可以使用clone标准函数复制实现移动语义的对象。但是,对于某些类型,克隆函数不应该适用,因为任何复制都不合适。比如file handle, a GUI window handle, 或者 a mutex handle。如果复制其中一个副本,然后销毁其中一个副本,则底层资源将被释放,而该句柄的其他副本具有不一致的句柄。
- 第一种对象的类型可以实现复制语义,它们应该可以实现,因为它更方便。让我们称它们为“可复制的对象”。
- 第二类对象的类型可以实现复制语义,但它们应该实现移动语义,以避免不需要的重复造成的运行时成本。此外,它们应该提供一种显式地复制它们的方法。让我们称它们为“可克隆但不可复制的对象”。
- 第三类对象的类型也应该实现移动语义,但它们不应该提供显式复制它们的方法,因为它们拥有一个不能被Rust代码复制的资源,并且这样的资源应该只有一个所有者。让我们称它们为“不可克隆的对象”。
为了区分这三个类别,Rust标准库包含了两个特定的trait:Copy 和 Clone。实现Copy的任何类型都是可复制的;实现Clone的任何类型都是可克隆的。
Making Types Cloneable or Copyable
struct S { x: Vec<i32> }
impl Copy for S {}
impl Clone for S {
fn clone(&self) -> Self { *self }
}
上面代码编译会报错,因为S里面包含了没有实现Copy的Vec<i32>
,Rust允许您只对只包含可复制对象的类型实现Copy,因为复制一个对象意味着复制它的所有成员。