所有权可以说是Rust中最为独特的一个功能了。正是所有权概念和相关工具的引入,Rust才能够在没有垃圾回收机制的前提下保障内存安全。
因此,正确地了解所有权概念及其在Rust中的实现方式,对于所有Rust开发者来讲都是十分重要的。在本文中,我们会详细地讨论所有权及其相关功能:借用、切片,以及Rust在内存中布局数据的方式。
先预览下本文要讲的主要内容:
1. 栈与堆
由于所有权的某些内容会涉及栈与堆,所以让我们先来简单地了解一下它们。
栈和堆都是代码在运行时可以使用的内存空间,不过它们通常以不同的结构组织而成。栈会以我们放入值时的顺序来存储它们,并以相反的顺序将值取出。
这也就是所谓的 “后进先出” 策略。你可以把栈上的操作想象成堆放盘子:当你需要放置盘子时,你只能将它们放置在栈的顶部,而当你需要取出盘子时,你也只能从顶部取出。
换句话说,你没有办法从中间或底部插入或移除盘子。用术语来讲,添加数据这一操作被称作入栈,移除数据则被称作出栈。
所有存储在栈中的数据都必须拥有一个已知且固定的大小。对于那些在编译期无法确定大小的数据,你就只能将它们存储在堆中。
堆空间的管理是较为松散的:当你希望将数据放入堆中时,你就可以请求特定大小的空间。操作系统会根据你的请求在堆中找到一块足够大的可用空间,将它标记为已使用,并把指向这片空间地址的指针返回给我们。
这一过程就是所谓的堆分配,它也常常被简称为分配。将值压入栈中不叫分配。由于指针的大小是固定的且可以在编译期确定,所以可以将指针存储在栈中。当你想要访问指针所指向的具体数据时,可以通过指针指向的地址来访问。
你可以把这个过程想象为到餐厅聚餐。当你到达餐厅表明自己需要的座位数后,服务员会找到一张足够大的空桌子,并将你们领过去入座。
即便这时有小伙伴来迟了,他们也可以通过询问你们就座的位置来找到你们。向栈上推入数据要比在堆上进行分配更有效率一些,因为操作系统省去了搜索新数据存储位置的工作;这个位置永远处于栈的顶端。
除此之外,操作系统在堆上分配空间时还必须首先找到足够放下对应数据的空间,并进行某些记录工作来协调随后进行的其余分配操作。
由于多了指针跳转的环节,所以访问堆上的数据要慢于访问栈上的数据。一般来说,现代处理器在进行计算的过程中,由于缓存的缘故,指令在内存中跳转的次数越多,性能就越差。
继续使用上面的餐厅来作类比。假设现在同时有许多桌的顾客正在等待服务员的处理。那么最高效的处理方式自然是报完一张桌子所有的订单后再接着服务下一张桌子的顾客。
而一旦服务员每次在单个桌子前只处理单个订单,那么他就不得不浪费较多的时间往返于不同的桌子之间。
出于同样的原因,处理器在操作排布紧密的数据(比如在栈上)时要比操作排布稀疏的数据(比如在堆上)有效率得多。另外,分配命令本身也可能消耗不少时间周期。
许多系统编程语言都需要你记录代码中分配的堆空间,最小化堆上的冗余数据,并及时清理堆上的无用数据以避免耗尽空间。
而所有权概念则解决了这些问题。一旦你熟练地掌握了所有权及其相关工具,就可以将这些问题交给Rust处理,减轻用于思考栈和堆的心智负担。不过,知晓如何使用和管理堆内存可以帮助我们理解所有权存在的意义及其背后的工作原理。
2. 所有权规则
现在,让我们来具体看一看所有权规则。你最好先将这些规则记下来,我们会在随后的内容中通过示例来解释它们:
💫 Rust中,每一个值都有一个对应的变量作为它的所有者。
💫 在同一时间内,值有且仅有一个所有者。
💫 当所有者离开自己的作用域时,它持有的值就会被释放掉。
3. 变量作用域
作为所有权的第一个示例,我们先来了解一下变量的作用域。简单来讲,作用域是一个对象在程序中有效的范围。假设有这样一个变量:
let s = "hello";
这里的变量 s 指向了一个字符串字面量,它的值被硬编码到了当前的程序中。变量从声明的位置开始直到当前作用域结束都是有效的。下面示例中的注释对变量的有效范围给出了具体的说明:
{ // 由于变量 s 还未被声明,所以它在这里是不可用的 let s = "hello"; // 从这里开始,变量 s 变成可用// 执行与 s 相关的操作
} // 作用域到这里结束,变量 s 再次不可用
这里有两个重点:
💫 s 在进入作用域后变得有效。
💫 它会保持自己的有效性直到自己离开作用域为止。
到目前为止,Rust语言中变量的有效性与作用域之间的关系跟其他编程语言中的类似。现在,让我们继续在作用域的基础上学习 String 类型。
4. String类型
为了演示所有权的相关规则,我们需要一个特别的数据类型,它要比之前文章的“数据类型”中涉及的类型都更加复杂。
之前接触的那些类型会将数据存储在栈上,并在离开自己的作用域时将数据弹出栈空间。我们需要一个存储在堆上的数据类型来研究Rust是如何自动回收这些数据的。
我们将以 String 类型为例,并将注意力集中到 String 类型与所有权概念相关的部分。这些部分同样适用于标准库中提供的或你自己创建的其他复杂数据类型。
我们会在后面文章中更加深入地讲解 String 类型。你已经在上面的示例中接触过字符串字面量了,它们是那些被硬编码进程序的字符串值。
字符串字面量的确是很方便,但它们并不能满足所有需要使用文本的场景。原因之一在于字符串字面量是不可变的。而另一个原因则在于并不是所有字符串的值都能够在编写代码时确定:假如我们想要获取用户的输入并保存,应该怎么办呢?
为了应对这种情况,Rust提供了第二种字符串类型 String 。这个类型会在堆上分配到自己需要的存储空间,所以它能够处理在编译时未知大小的文本。你可以调用 from 函数根据字符串字面量来创建一个 String 实例:
let s = String::from("hello");
这里的双冒号(::)运算符允许我们调用置于 String 命名空间下面的特定 from 函数,而不需要使用类似于 string_from 这样的名字。上面定义的字符串对象能够被声明为可变的:
let mut s = String::from("hello");s.push_str(", world!"); // push_str() 函数向String空间的尾部添加了一段字面量println!("{}", s); // 这里会输出完整的hello, world!
你也许会问:为什么 String 是可变的,而字符串字面量不是?这是因为它们采用了不同的内存处理方式。
5. 内存与分配
对于字符串字面量而言,由于我们在编译时就知道其内容,所以这部分硬编码的文本被直接嵌入到了最终的可执行文件中。
这就是访问字符串字面量异常高效的原因,而这些性质完全得益于字符串字面量的不可变性。
不幸的是,我们没有办法将那些未知大小的文本在编译期统统放入二进制文件中,更何况这些文本的大小还可能随着程序的运行而发生改变。
对于 String 类型而言,为了支持一个可变的、可增长的文本类型,我们需要在堆上分配一块在编译时未知大小的内存来存放数据。这同时也意味着:
💫 我们使用的内存是由操作系统在运行时动态分配出来的。
💫 当使用完 String 时,我们需要通过某种方式来将这些内存归还给操作系统。
这里的第一步由我们,也就是程序的编写者,在调用 String::from 时完成,这个函数会请求自己需要的内存空间。
在大部分编程语言中都有类似的设计:由程序员来发起堆内存的分配请求。然而,对于不同的编程语言来说,第二步实现起来就各有区别了。
在某些拥有垃圾回收(Garbage Collector,GC)机制的语言中,GC 会代替程序员来负责记录并清除那些不再使用的内存。
而对于那些没有 GC 的语言来说,识别不再使用的内存并调用代码显式释放的工作就依然需要由程序员去完成,正如我们请求分配时一样。
按照以往的经验来看,正确地完成这些任务往往是十分困难的。假如我们忘记释放内存,那么就会造成内存泄漏;假如我们过早地释放内存,那么就会产生一个非法变量;假如我们重复释放同一块内存,那么就会产生无法预知的后果。
为了程序的稳定运行,我们必须严格地将分配和释放操作一一对应起来。与这些语言不同,Rust提供了另一套解决方案:内存会自动地在拥有它的变量离开作用域后进行释放。下面的代码类似于上面示例中的代码,不过我们将字符串字面量换成了 String 类型:
}let s = String::from("hello"); // 从此处开始,变量 s 开始生效// 执行与 s 相关的操作} // 作用域到此处结束,变量 s 失效
审视上面的代码,有一个很适合用来回收内存给操作系统的地方:变量 s 离开作用域的地方。Rust在变量离开作用域时,会调用一个叫作drop的特殊函数。
String 类型的作者可以在这个函数中编写释放内存的代码。记住,Rust会在作用域结束的地方(即 } 处)自动调用 drop 函数。
注意
在 C++ 中,这种在对象生命周期结束时释放资源的模式有时也被称作 资源获取即初始化(Resource Acquisition Is Initialization, RAII)。假如你使用过类似的模式,那么你应该对Rust中的特殊函数 drop 并不陌生。
这种模式极大地影响了Rust中的许多设计抉择,并最终决定了我们现在编写Rust代码的方式。
在上面的例子中,这套释放机制看起来也许还算简单,然而一旦把它放置在某些更加复杂的环境中,代码呈现出来的行为往往会出乎你的意料,特别是当我们拥有多个指向同一处堆内存的变量时。
让我们接着来看一看其中一些可能的使用场景。
5.1 变量和数据交互的方式:移动
Rust中的多个变量可以采用一种独特的方式与同一数据进行交互。让我们看一看下面示例中的代码,这里使用了一个整型作为数据:
let x = 5;
let y = x;
这个示例是将变量 x 绑定的整数值重新绑定到变量 y 上。
你也许能够猜到这段代码的执行效果:将整数值 5 绑定到变量 x 上;然后创建一个 x 值的拷贝,并将它绑定到 y 上。结果我们有了两个变量 x 和 y,它们的值都是 5。
这正是实际发生的情形,因为整数是已知固定大小的简单值,两个值 5 会同时被推入当前的栈中。现在,让我们看一看这段程序的 String 版本:
let s1 = String::from("hello");
let s2 = s1;
以上两段代码非常相似,你也许会假设它们的运行方式也是一致的。也就是说,第二行代码可能会生成一个 s1 值的拷贝,并将它绑定到 s2 上。不过,事实并非如此。
下图展示了 String 的内存布局,它实际上由 3 部分组成,如图左侧所示:一个指向存放字符串内容的指针(ptr)、一个长度(len)及一个容量(capacity),这部分的数据存储在了栈中。图片右侧显示了字符串存储在堆上的文本内容。
长度字段被用来记录当前 String 中的文本使用了多少字节的内存。而容量字段则被用来记录 String 向操作系统总共获取到的内存字节数量。
长度和容量之间的区别十分重要,但我们先不去讨论这个问题,简单地忽略容量字段即可。当我们将 s1 赋值给 s2 时,便复制了一次 String 的数据,这意味着我们复制了它存储在栈上的指针、长度及容量字段。
但需要注意的是,我们没有复制指针指向的堆数据。换句话说,此时的内存布局应该类似于图2。
由于Rust不会在复制值时深度地复制堆上的数据,所以这里的布局不会像图3中所示的那样。假如Rust依照这样的模式去执行赋值,那么当堆上的数据足够大时,类似于 s2 = s1 这样的指令就会造成相当可观的运行时性能消耗。
前面我们提到过,当一个变量离开当前的作用域时,Rust会自动调用它的 drop 函数,并将变量使用的堆内存释放回收。
不过,图2中展示的内存布局里有两个指针指向了同一个地址,这就导致了一个问题:当 s2 和 s1 离开自己的作用域时,它们会尝试去重复释放相同的内存。
这也就是我们之前提到过的内存错误之一,臭名昭著的二次释放。重复释放内存可能会导致某些正在使用的数据发生损坏,进而产生潜在的安全隐患。
为了确保内存安全,同时也避免复制分配的内存,Rust在这种场景下会简单地将 s1 废弃,不再视其为一个有效的变量。
因此,Rust也不需要在 s1 离开作用域后清理任何东西。试图在 s2 创建完毕后使用 s1(如下所示)会导致编译时错误。
let s1 = String::from("hello");
let s2 = s1;println!("{}, world!", s1);
假如你在其他语言中接触过浅度拷贝(shallow copy)和深度拷贝(deep copy)这两个术语,那么你也许会将这里复制指针、长度及容量字段的行为视作浅度拷贝。
但由于Rust同时使第一个变量无效了,所以我们使用了新的术语移动(move)来描述这一行为,而不再使用浅度拷贝。
在上面的示例中,我们可以说 s1 被移动到了 s2 中。在这个过程中所发生的操作如图4所示。
这一语义完美地解决了我们的问题!既然只有 s2 有效,那么也就只有它会在离开自己的作用域时释放空间,所以再也没有二次释放的可能性了。
另外,这里还隐含了另外一个设计原则:Rust永远不会自动地创建数据的深度拷贝。因此在Rust中,任何自动的赋值操作都可以被视为高效的。
5.2 变量和数据交互的方式:克隆
当你确实需要去深度拷贝 String 堆上的数据,而不仅仅是栈数据时,就可以使用一个名为 clone 的方法。
我们将在后面文章中讨论类型方法的语法,但你应该在其他语言中见过类似的东西。下面是一个实际使用 clone 方法的例子:
let s1 = String::from("hello");
let s2 = s1.clone();println!("s1 = {}, s2 = {}", s1, s2);
这段代码在Rust中完全合法,它显式地生成了图3中的行为:复制了堆上的数据。
当你看到某处调用了 clone 时,你就应该知道某些特定的代码将会被执行,而且这些代码可能会相当消耗资源。你可以很容易地在代码中察觉到一些不寻常的事情正在发生。
5.3 栈上数据的复制
上面的讨论中遗留了一个没有提及的知识点。我们在之前的代码示例中曾经使用整型编写出了如下所示的合法代码:
let x = 5;
let y = x;println!("x = {}, y = {}", x, y);
这与我们刚刚学到的内容似乎有些矛盾:即便代码没有调用 clone,x 在被赋值给 y 后也依然有效,且没有发生移动现象。
这是因为类似于整型的类型可以在编译时确定自己的大小,并且能够将自己的数据完整地存储在栈中,对于这些值的复制操作永远都是非常快速的。
这也同样意味着,在创建变量 y 后,我们没有任何理由去阻止变量 x 继续保持有效。换句话说,对于这些类型而言,深度拷贝与浅度拷贝没有任何区别,
调用 clone 并不会与直接的浅度拷贝有任何行为上的区别。因此,我们完全不需要在类似的场景中考虑上面的问题。
Rust提供了一个名为 Copy 的 trait,它可以用于整数这类完全存储在栈上的数据类型(我们会在后面文章中详细地介绍 trait)。一旦某种类型拥有了 Copy 这种 trait,那么它的变量就可以在赋值给其他变量之后保持可用性。
如果一种类型本身或这种类型的任意成员实现了 Drop 这种 trait,那么Rust就不允许其实现 Copy 这种 trait。尝试给某个需要在离开作用域时执行特殊指令的类型实现 Copy 这种 trait 会导致编译时错误。
那么究竟哪些类型是 Copy 的呢?你可以查看特定类型的文档来确定,不过一般来说,任何简单标量的组合类型都可以是 Copy 的,任何需要分配内存或某种资源的类型都不会是 Copy 的。
下面是一些拥有 Copy 这种trait的类型:
💫 所有的整数类型,诸如 u32。
💫 仅拥有两种值(true 和 false)的布尔类型:bool。
💫 字符类型:char。
💫 所有的浮点类型,诸如 f64。
💫 如果元组包含的所有字段的类型都是 Copy 的,那么这个元组也是 Copy 的。例如,(i32, i32)是 Copy 的,但(i32, String)则不是。
6. 所有权与函数
将值传递给函数在语义上类似于对变量进行赋值。将变量传递给函数将会触发移动或复制,就像是赋值语句一样。下面示例展示了变量在这种情况下作用域的变化过程:
尝试在调用 takes_ownership 后使用变量 s 会导致编译时错误。这类静态检查可以使我们免于犯错。你可以尝试在main函数中使用 s 和 x 变量,来看一看在所有权规则的约束下能够在哪些地方合法地使用它们。
7. 返回值与作用域
函数在返回值的过程中也会发生所有权的转移。示例2像示例1一样详细地标注了这种情况下变量所有权和作用域的变化过程:
变量所有权的转移总是遵循相同的模式:将一个值赋值给另一个变量时就会转移所有权。
当一个持有堆数据的变量离开作用域时,它的数据就会被 drop 清理回收,除非这些数据的所有权移动到了另一个变量上。
在所有的函数中都要获取所有权并返回所有权显得有些烦琐。假如你希望在调用函数时保留参数的所有权,那么就不得不将传入的值作为结果返回。
除了这些需要保留所有权的值,函数还可能会返回它们本身的结果。当然,你可以利用元组来同时返回多个值,如下面代码所示:
fn main() { let s1 = String::from("hello"); let (s2, len) = calculate_length(s1); println!("The length of '{}' is {}.", s2, len);
} fn calculate_length(s: String) -> (String, usize) { let length = s.len(); // len()会返回当前字符串的长度 (s, length)
}
但这种写法未免太过笨拙了,类似的概念在编程工作中相当常见。幸运的是,Rust针对这类场景提供了一个名为引用的功能,这个我们下篇文章再讲。
最后,码字不易,如果大家能给我一个赞,我也会动力满满,万分感谢你们的点赞支持!