最近正好在学习 Rust,所以新加了个 Rust
的 tag 用来记录 Rust 相关的内容。
本文是这个 tag 的第一篇文章,主要是介绍 Rust 中的所有权和引用等概念。关于标题是什么意思,我们先留个悬念,到最后的时候我们会讲到。
首先我们来简单介绍一下 Rust。
Rust 是一门系统编程语言,对标 C++ 的同时又抛去了 C++ 的历史包袱。
那什么是系统编程(System Programming)呢?说到系统编程那就不得不提与之对应的应用编程(Application Programming)了。系统编程和应用编程最大的不同点在于应用编程开发的软件是直接给用户提供服务的,而系统编程开发的软件则是为其它软件提供服务。像笔者是做 iOS 开发的,这种就属于应用编程。
典型的系统编程例子有操作系统,文件系统,硬件驱动,数据库以及音视频编解码等等。从这些例子可以看出,系统编程也可以说是在资源受限的环境下编程,每一字节内存,每一个 CPU 周期都是不可浪费的。
当说到内存管理的时候,我们希望编程语言可以提供两个特性:
让我们先来看个 C++ 的代码示例:
int main() {
std::string s = "Hello World";
}
代码的第 2 行我们通过 C++ 在栈上初始化了一个字符串变量 s,下面是这个字符串的内存示意图:
当运行到第 3 行的时候,因为变量 s 离开了作用域,上图中对应的内存会自动得到释放。
所以控制指的是我们拥有对内存的控制权,可以控制内存释放的时机。
我们还是以 C++ 为例:
int main() {
std::vector<int> v = {1};
auto& elem = v[0];
v.push_back(2);
cout << elem;
}
代码的第 2 行初始化了一个只含有整数 1 的 vector 变量 v,第 3 行又创建了指向这个 vector 第一个元素的引用 elem。下图是内存示意图:
代码的第 4 行向这个 vector 的末尾中添加了一个新元素—整数 2,因为 buffer 的空间不够,vector 会重新分配一个更大的内存,并把原有的元素拷贝到这个新的内存里,原有的内存区域此时已经是无效的区域了:
如上图所示,buffer 现在指向的时候新的内存区域,而 elem 指向的是内存已不再有效(图中做了淡化处理的部分),elem 成为了悬垂引用(dangling reference)。
所以安全指的是我们可以安全地访问内存。就像这个例子一样,我们应该无法再通过 elem 访问内存,如果继续使用 elem 的话会导致未定义的行为、崩溃甚至是安全问题。
控制和安全这两点听上去像是互斥的,毕竟如果能控制内存释放时机的话,就没办法很好地兼顾到安全问题,反之亦然。所以当今编程语言大致分为了两大阵营:
一个阵营是以安全优先,内存管理的控制权交给了 GC,GC 代替程序员来负责记录并清除那些不再使用的内存。而 GC 需要一个运行时去支撑,并且 GC 也没办法避免 iterator invalidation, data races 等问题。大部份现代语言都在这个阵营里,比如 Swift,Go,Ruby,Python 等等。
另一个阵营以控制优先,内存管理的控制权交给了程序员,由程序员识别不再使用的内存并在合适的时机调用代码显式地释放内存。但是正确完成这个任务是很困难的:
当今主流语言只有 C/C++ 在这个阵营。
在面对这个选择题的时候,Rust 给出了完全不一样的答案:“小孩子才做选择,我全都要”。那 Rust 是如何同时做到这两点的呢,这就是我们接下来要讲的内容。
Rust 使用被称为所有权的机制来管理内存,所有权包含了特定的规则,这些规则允许编译器在编译过程中执行检查工作,而不会产生任何运行时开销。
我们先来看一下所有权的规则:
介绍完规则之后,我们先来看个简单的例子:
fn main() {
let mut padovan = vec![1,1,1];
for i in 3..10 {
let next = padovan[i-3] + padovan[i-2];
padovan.push(next);
}
println!("P(1..10) = {:?}", padovan);
}
代码的第 2 行初始化了一个 vector 并赋值给了变量 padovan,此时变量 padovan 就是这个 vector 值的所有者。在代码的最后一行 padovan 变量离开作用域后,这个 vector 值就会被丢弃。
我们再来看个稍微复杂点的例子:
struct Person {
name: String,
birth: i32
}
fn main() {
let mut composers = Vec::new();
composers.push(Person { name: "Palestrina".to_string(), birth: 1525 });
composers.push(Person { name: "Dowland".to_string(), birth: 1563 });
composers.push(Person { name: "Lully".to_string(),birth: 1632 });
for composer in &composers {
println!("{}, born {}", composer.name, composer.birth);
}
}
就像变量拥有值那样,结构体也拥有它的字段,元组和数组拥有各自的元素,下图是内存示意图:
上述代码中的所有权关系看着很多,但是都非常直观:composers 拥有一个 vector,vector 拥有了若干个 person,每个 person 拥有它自己的字段,其中 name 字段又拥有具体的文本,birth 字段拥有具体的数字。当 composer 离开作用域后所有资源都会被释放。
值得注意的是,虽然每个 value 只能有一个所有者,但是反过来并不需要是一对一的关系,就像这里的 vector 同时拥有多个 person 一样,可以是一对多的关系。
从某种角度看,所有权使得 Rust 不像其他语言那么强大:可以随意地建立对象图,对象以你认为合适的方向去指向其它对象。但是
现在看好像所有权模型没什么用,我们接下来会讲到 Rust 还有其它策略扩展了它的功能,使其更具灵活性。
和别的语言不用,在 Rust 中赋值、函数传参和作为函数返回值返回并不是进行拷贝,而是会转移所有权。所有权转移后原本的所有者就会变成未初始化切不可用的状态,这一点会由编译器强制保证。
你可能会惊讶于 Rust 改变了如此基础的赋值操作的语义,但其实如果你看一下其它语言是如何处理赋值的,就会发现赋值语义其实在不同语言中都不太一样。
s = ['udon', 'ramen', 'soba']
t = s
u = s
代码第 1 行初始化了一个变量 s,其值为一个字符串数组,随后将 s 分别赋值给了变量 t 和 u,下图是最终的内存示意图:
在 Python 中赋值其实是更新引用计数,所以 Python 的赋值是个很轻量的操作,但这是以额外维护引用计数为代价的。
std::vector<string> s = { "udon", "ramen", "soba" };
std::vector<string> t = s;
std::vector<string> u = s;
同样的例子在 C++ 中赋值完成之后,内存的示意图如下:
可以看出在 C++ 赋值会进行 deep copy 操作,根据涉及到的类型不同,所需的内存和耗时也不一样,但是保证了所有权的清晰。(注:这里仅为举例用,在实际编码时会选用比赋值更加高效实用的方案。)
现在我们来看一下 Rust 中的赋值是怎样的,还是同样的例子:
fn main() {
let s = vec!["udon".to_string(), "ramen".to_string(), "soba".to_string()];
let t = s;
let u = s;
}
当初始化完变量 s 之后,大概的内存示意图如下:
紧接着将 s 赋值给了变量 t:
此时 vector 的所有权从 s 转移给了 t,vector 的元素和三个字符串 buffer 都保持原样,vector 依旧只有一个 owner。细心的读者可能已经发现了,此时 s 处于未初始化的状态,那第 4 行把 s 赋值给 u 会发生什么事?
答案是会编译报错:
在没有重新初始化 s 之前编译器会禁止我们继续使用变量 s。
从这个例子可以看出,在 Rust 中赋值是轻量的操作同时所有权依旧是清晰的。
如果我们不想转移所有权的话,需要显式地调用 clone
方法,拷贝原有的字符串:
fn main() {
let s = vec!["udon".to_string(), "ramen".to_string(), "soba".to_string()];
let t = s.clone();
let u = s.clone();
}
正如之前所说,像 String, Vector 等类型如果是使用 copy 会花费更多内存以及时间,move 使得赋值更轻量同时也保持了所有权的清晰。但是像数字之类的基础类型,这种特别的处理是不必要的,比如 i32:
其本质上只是栈上一组比特位,并不拥有堆上的数据,直接进行复制的消耗可以直接忽略不计。
Move 会将原变量变成未初始化的状态,以阻止其继续被使用,但是继续使用数字是没有任何危险的。
move 带来的好处对数字而言既不适用也不方便,所以 Rust 设计了一个 Copy 类型(也就是上文编译错误中提到的给类型实现 Copy 这个 trait),对于这种类型,原本的 move 操作就会被 copy 操作替代。
只有简单地按位拷贝即可满足要求的类型才可以是 Copy 类型,比如所有的基础数字类型(整数,浮点数)、布尔类型、char 类型以及元素都是 Copy 类型的元组或者数组。
结构体或者枚举默认不是 Copy 类型,当然如果里面的字段都是 Copy 的话,可以让编译器自动生成对应的实现代码:
#[derive(Copy, Clone)]
struct Label { number: u32 }
如果 number 字段是 String 类型的话,上述代码就会编译报错,因为 String 不是 Copy 类型:
#[derive(Copy, Clone)]
struct Label { name: String }
可能有读者会想既然是 trait 的话那我自己手写实现,不通过编译器自动生成对应的代码是不是就可以通过编译了。答案是否定的,编译器依旧会报类似的错误。Copy trait 的完整路径是 std::marker::Copy,其本身是一个空 trait,里面没有定义任何函数。Copy 是起一个类型标记的作用,它会直接影响编译器对赋值等操作是使用 move 还是 copy 指令,所以它的实现是要靠编译器完成的。
从上面我们可以看到,尽管类型是符合条件的,但默认不会是 Copy 类型。Rust 这么设计是因为一个类型是否是 Copy,对类型的使用者和实现者而言是大相径庭的:
将变量传递给函数在语义上类似于对变量进行赋值,会触发移动或者复制:
fn main() {
let s = String::from("hello");
takes_ownership(s); // 字符串的所有权转移进了 takes_ownership 函数中
} // s 在这里离开了作用域,但是因为其值已经转移了,所以这里什么也不会发生
fn takes_ownership(some_string: String) {
println!("{}", some_string);
} // some_string 在这里离开作用域,字符串对应的内存被释放
同样地,函数在返回值的过程中也会发生所有权的转移:
fn main() {
let s1 = gives_ownership(); // gives_ownership 将它的返回值转移给了 s1
let s2 = String::from("hello");
let s3 = takes_and_gives_back(s2); // s2 值的所有权转移到了 takes_and_gives_back 函数中,同时返回值的所有权转移给了 s3
} // 在这里 s1 离开了作用域并释放了对应的内存,s2 因为其值的所有权已经转移所以什么事都不会发生,s3 离开了作用域并释放了对应的内存
fn gives_ownership() -> String {
let some_string = String::from("hello");
some_string // some_string 作为返回值所有权转移出了 gives_ownership 函数
}
fn takes_and_gives_back(a_string: String) -> String {
a_string // some_string 作为返回值所有权转移出了 takes_and_gives_back 函数
}
假如我们现在需要写一个计算字符串长度的函数,以我们现有的知识这个函数可能会写成这样:
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();
(s, length)
}
在这个例子中,我们希望在调用函数时保留参数的所有权,那么就不得不将传入的值作为结果返回,并且不光是需要保留值的所有权,函数还有本身的返回值,我们可以像上面代码一样利用元组来同时返回多个值,但是这种写法未免太过笨拙了。幸运的是,Rust 提供了解决方案。
Rust 有一种被称为引用的指针,不会对指向的数据的生命周期产生任何影响。事实上,编译器还会强制保证 reference 不能比 referent 存活的时间更长。
在 Rust 中,我们将“创建一个值的引用”称之为借用(borrow)这个值,借用允许你在不获取所有权的前提下使用值。
reference 本身并不是什么新事物,本质上就是地址,但是 Rust 保证它们安全的规则是很创新的。尽管这部分的内容学习曲线并不平缓,在你写 Rust 的前期可能更多的时间就是在和这部分的编译错误做斗争,但你会看到它们在预防那些经典且广泛的 bug 上是卓有成效的 ,甚至在多线程编程中也是大放异彩。
有了引用之后,我们可以将计算字符串的函数改写成:
fn calculate_length(s: &String) -> usize {
s.len()
}
新的函数使用了 String 的引用作为参数而没有直接转移值的所有权,这里的 &
代表的就是引用语义。
Rust 当中有两种类型的引用,它们分别是
&T
:仅可通过该引用进行读,并且这种引用是 Copy 类型。&mut T
:可以通过该引用进行读写,这种引用不是 Copy 类型。引用的一个规则是在同一时间要么只能拥有一个可变引用,要么只能拥有多个共享引用,简单概括就是**共享不可变,可变不共享。**这点可以和读写锁类比。事实上这个规则也作用于 owner:当有共享引用的时候,连 owner 也只能进行访问而不能进行更改操作;当有可变引用时甚至无法使用 owner。
和 C++ 不同,在 Rust 中引用需要显式地创建,并且需要显式地解引用:
let x = 10;
let r = &x; // &x 是 x 的共享引用
assert!(*r == 10);
let mut y = 32;
let m = &mut y; // &mut y 是 y 的可变引用
*m += 32; // 显式地解引用
assert!(*m == 64);
引用还有一个规则是编译器会保证引用总是有效的,不安全的引用甚至无法通过编译。
我们先从一个简单的例子开始,来看看 Rust 是如何做到这个保证:
{
let r;
{
let x = 1;
r = &x;
}
assert_eq!(*r, 1);
}
这个代码片段在编译的时候会报错:
错误信息指出 x 只存活在内层 block 中,但是它的引用在外层 block 的末尾还在被使用,这就造成了悬垂引用。很明显这是应该被禁止的。
这个例子展示了在 Rust 中无法创建一个本地变量的引用但同时又在变量的作用域之外使用这个引用。这个简单的例子可能程序员很容易能看出,不过我们可以通过这个例子来学习了解一下 Rust 是如何完成这个检查的。
Rust 会尝试给每个引用分配一个满足约束的生命周期,生命周期指示了引用可以被安全使用的范围,它是一个仅存在于编译期的概念,用于编译器确保内存安全,在运行的时候引用仅仅是一个地址。
刚刚说到了生命周期要满足约束,第 1 个显而易见的约束是 x 的引用不能比 x 存活时间更长。也就是说,x 的生命周期必须大于等于 reference 的生命周期: