Rust 知识点梳理 - 变量,所有权和引用
个人学习和使用 Rust 语言已经有一段时间了,在此期间深感 Rust 语言的入门和深入掌握之不易。因此希望在这里将自己的一些个人经验以尽可能简洁清晰的方式呈现出来,作为大家在学习与回顾时的参考。
本文将会是一个系列的一部分,随缘更新。如果喜欢,欢迎点赞和留下宝贵意见,这将对我起到很大的激励作用。
变量与所有权
与其它语言有所不同,在 Rust 中,「变量」指的是一种 绑定关系(binding):比如
let x = 42;
代表将「42」这个对象 绑定 给变量x
。此时x
并不是真的占有 42 这个对象的存储空间,而只是指向这个对象的一个符号。由于变量只是一种「绑定」,而不是真的占有了对象的存储空间,因此这种「绑定关系」是可以 随意更换 的。比如
let x = 42; let x = "Hello";
这两个语句代表着:首先将x
绑定到 42,然后立刻解绑,改成绑定"Hello"
这个字符串。从「绑定」的角度来理解的话,我们并不是将变量x
重新定义了一遍,而仅仅是将它的绑定关系更换了。这与其它语言中的「定义变量」是不同的概念。在同一时间,一个对象只能绑定到一个变量上。此时,我们称该变量是该对象的「所有者」(owner)。在任意时刻,一个对象的所有者都是唯一的。
对象的所有权是可以发生 转移 的,这种情况只发生在不支持
Copy
的类型上。比如let a = String::from("Hello"); let b = a;
会把字符串"Hello"
的所有权从a
转移到b
。当所有权被转移之后,原变量会自动失效,无法再被使用,除非重新赋值(绑定)。对于支持
Copy
的类型,在发生传值操作时,原对象会被 复制给新变量,而不是发生所有权转移。比如在let a = 42; let b = a;
之后,a
和b
会指向两个(拥有相同值的)不同的对象,并且b
指向的对象是由a
指向的对象复制而来的。关于Copy
的详细信息将会在之后的章节介绍。
引用和借用
所有权系统已经能够保证内存安全,但为了更高效地编程,只有所有权的概念还是不够的。比如,在给函数传递参数时,有些函数可能并不需要参数对象的所有权,如果我们把所有权传递给这些函数就太麻烦了。这时,我们可以只把对象的「使用权」「租借」(lend)给这些函数,这就是 引用(references)和 借用(borrowing)的概念。
Rust 中的引用分为 共享引用(shared references)和 可变引用(mutable references)两种。(也有其它命名方式,但以上两个是最官方的命名。)其中,共享引用的特点是:只读,允许有多个租借者,并且所有其它租借者也只能持有共享引用;而可变引用的特点是:可读可写,只允许有一个租借者(即 独占)。根据以上的定义,共享引用和可变引用是 不能同时存在的,在同一时间,只能有其中一种存在。
另外,Rust 通过 生命周期约束 来保证 所有的引用一定是有效的(即,永远不会指向无效的位置)。这就彻底避免了 C 语言的「悬吊指针」问题。(生命周期问题在之后的章节会解释。)
高阶知识点:一个引用
&'a T
实际上有 两个类型参数,一个是被引用的类型T
,一个是该引用的生命周期'a
。'a
决定了该引用的 有效范围。超出这个有效范围之后,该引用就会被回收,无法再被使用。(有关生命周期为什么是类型参数的问题,可以参考之后章节的 协变与逆变 的内容,这属于 unsafe Rust 的范畴。)为了保证内存安全,在有对象引用存活时,被引用的对象本身将被 冻结(freeze),其使用会受到限制。具体来说,当共享引用存在时,原对象将变成 只读的;而 当可变引用存在时,原对象将被 彻底屏蔽,无法使用,只能通过可变引用来对该对象进行操作。冻结操作是暂时的,当所有的引用都被释放之后,原对象会被自动解冻。(注:「冻结」不是 Rust 官方的概念,是他人为了方便理解而创造的。)
如果想要以可视化的方式理解借用和生命周期,可以参考这篇文章:Graphical depiction of ownership and borrowing in Rust - Rufflewind's Scratchpad。另外,RustViz 这个程序能够可视化任何一个能够编译的 Rust 程序。
一些个人观点:初学者通常会很希望绕过 Rust 的所有权、借用和生命周期机制,大量使用
unsafe
进行编程,以获得像 C++ 一样不受限制的编程体验。这其实是不正确的,因为这就相当于放弃了 Rust 最重要的一个特性(没有之一):内存安全。如果实在认为借用机制难以对付,可以给自己出一些简单的编程题目(如:从零开始编写一个 TCP 服务器),然后多在 Google,GitHub 等网站上寻找与自己的需求类似的程序,观察他人是如何实现类似的需求的。在入门时,向他人学习也不失为一种良好的成长方式。
移动,复制和克隆
在 Rust 中,在不同变量之间传递值的方式共有 3 种,分别是 移动(move),复制(copy)和克隆(clone)。其中,移动和复制是在发生传值操作时,由编译器自动触发的;克隆是需要通过
clone
方法手动触发的。移动和复制发生在需要传值的情景中。比如
let b = a;
表示将变量a
的值传递给变量b
;调用一个函数fn foo(s: String)
时,相当于将一个String
值传递给该函数的参数。当 Rust 编译器发现一个操作需要传值时,只要被传递的类型允许复制(即实现了
Copy
trait),编译器就会优先进行复制;否则,编译器会进行移动。(简记:能 Copy 则 Copy,不能 Copy 则 Move。)移动操作指的是把对应的值从旧变量「抢走」。移动操作发生后,对象的所有权被转移给新变量,旧变量无法再被使用,否则会发生编译错误。(移动操作的好处在于尽可能避免发生数据复制操作,比如在需要传递
String
时,可以避免复制字符串的内容。)只有实现了
Copy
trait 的类型才支持复制。Copy
trait 的含义是:对应的类型能够通过 直接复制对象底层数据 的方式进行复制。这保证了Copy
是一个低成本的操作。(因此诸如String
和Rc
等类型都不是Copy
,因为String
类型作为容器,其内部数据是无法直接复制的;而Rc
在复制时需要将引用计数加 1,这些都属于「附加操作」,是不被Copy
所允许的。)与
Copy
不同,Clone
除了复制对象数据之外,还能够执行一些额外的步骤,比如 复制容器内部的数据,或者进行一些必要的修改,以保证程序正确性等。(比如String
的clone
方法会将内部的字符串数据也复制一份;而Rc
的clone
方法会修改引用计数。)因此,Clone
操作通常不是零成本的,并且在部分类型上是一个比较耗时的操作。为了实现 Rust 的「零成本抽象」承诺,Clone
操作只能通过clone
方法触发。在程序中,除了确实必要的情况之外,应该尽量少使用clone
。
作者:Kazurin
链接:https://juejin.cn/post/7024857265010278408