Rust 所有权:内存管理新流派

7,298 阅读10分钟

在 Rust 中每个值都只能被一个所有者拥有,当这个值被赋给其他所有者,原所有者无法再使用。正是这种机制保证了 Rust 语言的内存安全,从而无需自动垃圾回收,也无需手动释放。所有权是 Rust 最重要的特性之一。下面来看一个简单的例子。

fn main() {
    let s = String::from("hello");
    let a = s;          // 字符串对象“hello” 的所有权被转移
    println!("{}", s); // error! 无法再使用 s
}
 

上面展示了所有权转移的基本案例, 由于a获得了字符串的所有权,s无法再使用。这可能和现存的编程语言有很大的区别,事实上在 Rust 之前,我所了解的所有语言中这样用是完全正确的,但是现在我明白了这么做的奇妙之处。

为什么要转移所有权?

Rust 定位系统编程语言,要求内存安全以及内存管理无运行时开销。

何为内存管理的运行时开销,这里要拿 Java 做个例子,Java 自称是内存安全的语言,因为 Java 中程序员无需手动管理内存(程序员自己管理是内存不安全的源头), Java 采用垃圾自动回收,所有的 Java 程序都运行在 Jvm 中,Jvm 在 Java 程序运行期间,必须时刻监控、遍历 Java 对象树,以鉴别出堆上哪些变量不再被引用,在一定的时间周期到达时自动释放那些不被引用变量的内存。

也就是说,Jvm 在运行着一个和实际程序完全无关的垃圾回收进程,这被称为运行时开销。当然,由于 Java 的定位,这些开销是可以完全不用考虑的。

C/C++ 作为系统编程语言,由程序员手动管理内存,在mallocfree必须被使用,否则会发生内存泄漏,最终占满进程的所有内存空间,在 C++ 中是newdelete这一对好兄弟。目测这很好处理,只要记得同时使用就好了,但是当问题变得复杂,这将变得困难而又容易出错。来看一段 C 代码:

#include <stdio.h>
#include <malloc.h>
 
int* func()
{
    int *p = malloc(sizeof(int));
    /*
       do something
    */
    return p;
}
 

谁能保证在这个函数的外部,有人记得这个指针是 指向堆而不是栈上的,并且记得调用free()?这很难说, 尤其是情况变得更加复杂的时候… 因此,C/C++ 高手很难炼成,连 Goole 都因它犯难,试图用Go取代部分 C/C++ 的应用场景,Go的特性这里不多提,它无疑是一个优秀的编程语言,出身名门,虽然自我定位是系统编程语言,但是目前主要被用于网络编程,它也采用了垃圾自动回收机制,因此,运行时开销是无法避免的。

上面说到的 Java 和 C/C++两个例子,代表了当前内存管理的两个流派,两种方式都存在一定的痛点,这就是为什么 Rust 决定采用一种完全不同的管理方式,通过转移所有权,Rust 做到了安全的内存管理。那么现在回到主题,来看 Rust 是如何管理内存的。

Rust 的内存管理

Rust 中没有自动垃圾回收(Auto GC), 也不需手动管理,这一工作在编译阶段,由编译器来负责。编译成功后,变量内存何时回收已经被确定,硬编码到二进制程序中了,程序自己运行到该回收的时候就自动回收了。编译器如何做到如此智能?Rust 中的所有权系统功绩首屈一指。下面来分别介绍所有权系统的各种特性。

作用域

每一个变量被限定在一个作用域内有效,和大多数编程语言一样,{}被看作一个作用域的标志,但不同的是,在运行到}时,不仅回收栈上的变量,也回收堆上的内存。

fn func() {
    let n = 2;  // 在栈上分配
    let s = String::new(); // 在堆上分配
}
 

如上,在堆上分配的空间也被回收了,这看似很正常,但是如果 s被作为返回值,它的作用域改变了,它仍然能够最终在某个}(所处作用域结束时)处被释放,作用域保证了变量一定会被回收,也就避免了像上面 C 语言忘记调用free()的情况了。

到底是如何回收的?在 Rust 中,类使用struct定义,或者你可以不叫它“类”,而是别的名字。每个对象都实现了一个trait, 即Drop(如果你熟悉 Java 可以把trait理解为“接口”),Drop 包含一个方法drop(), 在任何对象离开作用域的时候,它的drop()会被自动调用,从而释放自身内存。

转移

正如本文开始提到,一个值只能有一个拥有者,因此当赋值给其他的变量时,所有权被转移,原所有者不能继续使用。在 Rust 中,所有权转移称为 move. 本文开头的赋值是一个所有权转移的基本例子,下面我们再来看一个稍微复杂的。

fn main() {
    let s = String::from("hello");
    func(s);    // 字符串的所有权转移到了func()的内部
    let a = s;  // error  s 已经无法被使用
}
 
fn func(s: String) {
    println!("{}", s);  // s 将在离开作用域时被释放
}
 

但是有时在作为函数参数使用后,仍要使用怎么办,在函数结尾将其 return是一个解决办法,但不是好办法,后面马上会讲到的借用,会很好的解决这个问题。

值得注意的是,move的例子中我都使用的是 String::new()或者String::from()来创建一个字符串对象, 为什么不直接用字符串类型例如let s = "hello"或者其他类型如i32做演示,因为 move 规则对它们并不适用!

fn main() {
    let n = 2;   // i32 类型
    let a = n;
    println!("{}", n);  // success! 并没有问题
}
 

看起来这和之前的理论矛盾了,但实际上所有权规则对所有类型都是适用的,只不过 Rust 为了减少编程的复杂度,在基本类型赋值的时候, 拷贝了一份原内存作为新变量,而不是转移所有权。也就是说本例中 a 是一个独立的变量,拥有独立的内存,而不是从n处获得。n也得以保留了对应值的内存,因而可以继续使用。

以上说的“基本类型”到底是哪些类型,常用的 i32, bool等都是。具体来说是实现了Copy这个trait的类型,基本类型 Rust 已经内置实现了,也就是说,我们完全可以自己为String类型实现Copy ,从而在字符串对象赋值的时候,拷贝而不转移。

注意! let s = "hello"中的 s 并不是基本类型变量,虽然赋值也不会转移所有权,那是因为 s的类型是&str, 是借用在起作用而不是拷贝!

上面的 赋值传参move的隐式调用,有些情况下,必须通过关键字move显式指定,否则无法编译通过,比如闭包就是一个常见的情况。文章篇幅考虑,这里先不介绍闭包,让我们快速进入前面多次提到的借用,这也是本节最后一部分。

借用

使用转移所有权有的时候还是太麻烦了,正如现实中一样,我可以把自己的东西借给被人用,但仍然具有所有权,Rust 中支持借用(borrow),用&表示,有些文章中也称为“引用”,但是我觉得这样并不好,因为这里的&与在 C/C++ 中&有很大的区别, 而且编译器都叫它 “borrow” 而非 “refer” !

来看一下如何用借用解决所有权问题。

fn main() {
    let s = String::from("hello");
    let a = &s;
    println!("{}", s);  // success
    println!("{}", a);  // success, print "hello"
    func(&s);  // success
}
 
fn func(s: &String) {
    println!("{}", s);
}
 

这段代码是借用的基本用法,a通过&借用了s的内存,并没有转移,但现在a能访问s的空间了,Rust 允许有多个借用者,传入到函数func()的也是s的一个借用,但是它在func()结束时被释放了。

但是,在被借用期间,拥有者不允许修改变量,或者转移所有权!这看似是一个礼节问题,但实则是为内存安全考虑,修改值将导致这些借用的值与本身不一致,引发逻辑错误,转移所有权必将导致借用失效,因此,这不被允许!让我们尝试在func(&s)之后转移所有权。

fn main() {
    let s = String::from("hello");
    ...
    func(&s);
    let b = s;  // error  s 已被借用,无法转移
}
 

到这里为止,提到的借用都是指不可变借用,也可以说是“只读借用”,借用者允许有多个,借用者不允许修改值,这不难理解,如果有一个借用者修改了值,必将造成数据的不一致。

但有时我们还真的需要修改值!比如我们熟悉的swap(), 这时 Rust 提供了可变借用(&mut),可变借用者能够对数据进行修改,当然前提是这个值本身是可变的(mut)。简单考虑,这里用字符串连接作为例子。

fn main() {
    let mut s = String::from("hello");
    func(&mut s);
}
 
fn func(s: &mut String) {
    s.push_str(" world");  // s = "hello world"
}
 

通过可变借用,func()函数得以修改了s的值,但是可变借用有一个非常严格的限制,那就是只能有一个可变借用。可变借用期间,不允许有其他的任何借用,包括不可变借用; 可变借用期间,拥有者本身也不能进行任何操作,不能转移,不能修改值。

从某种角度来看,可变借用和转移没什么区别,它相当于一个临时的所有权转移,当接收转移的那个变量离开作用域,所有权自动物归原主 。

到此为止,Rust 的所有权系统基本介绍完毕,正是这些规则撑起了 Rust 内存安全的大旗,完备而相互论证,借用理论来源于生活,符合情理,不得不说 Rust 内存管理设计的非常精妙!

Tips

提炼出本文得出的几个有用的 Tip:

  • 基本变量(实现了Copy)的变量赋值时不转移所有权,而是拷贝
  • 被借用期间,不允许修改值
  • 可变借用只允许一个,借用期间,拥有者不允许任何操作
  • 可变借用相当于临时的所有权转移,借用释放后,物归原主

完。

× 如转载请注明出处,谢谢 ×