Rust学习笔记#6:所有权系统

语言: CN / TW / HK

引子:段错误与内存安全

在刚开始接触Rust的时候,我们就提过Rust语言的定位:

Rust is a system's programming language that runs blazingly fast, prevents segfaults, and guarantees thread safety.

其中有一条是prevents segfaults,它的意思是避免段错误,segfaultsegment fault的缩写。

那何为段呢?一个进程在执行的时候,它所占用的内存的虚拟地址空间一般被分割成好几个区域,我们称为“段”(segment),常见的段有代码段、数据段、函数调用栈、堆等。这些段通过硬件映射到真正的物理空间,不同的段具有不同的访问权限,例如代码段只能读不能写,如果违反了这些访问权限,就会产生段错误(segfault)。

在传统的C/C++语言中,指针能力强大,但也同时意味着非常容易制造段错误,例如空指针解引用就会访问到本无权限访问的内存导致段错误,所以C/C++也是非常难精通的语言,你必须要掌握很多的最佳实践来尽可能地避免段错误。其他语言例如Java采取了自动垃圾回收机制来规避段错误,但也付出了性能的代价。Rust的设计目标之一就是在不使用自动垃圾回收机制的前提下避免产生段错误,也就是Rust所宣称的内存安全(Memory Safety)。

但注意,Rust所宣称的内存安全并不是说避免了所有的内存错误,以下这些情况,是Rust想要避免的问题。

  • 空指针解引用
  • 野指针(未初始化的指针)
  • 悬空指针(指针所指向的内存空间被释放之后该指针被继续使用)
  • 使用未初始化内存
  • 非法释放(对同一个指针释放多次)
  • 缓冲区溢出(指针访问越界)
  • 数据竞争(并发场景下无保护地对同一块内存的读写)

以上这些问题都是极度危险的,会造成危害性大且修复难度高的bug。但在Rust语境中,例如内存泄漏和内存耗尽这样的内存错误是不算在内存安全的范畴的,因为它们的危害性较低。

那Rust是如何保证内存安全的呢,这就是下面要介绍的内容。

生命周期

保证内存安全,就是保证一个变量从它创建到销毁的整个过程都是安全的。一个变量从它创建到销毁的整个过程 ,又称为这个变量的生命周期 (lifetime)。当变量所在的作用域结束时,该变量的生命周期就结束了。在Rust中,从let绑定(变量创建)开始到最近的}为止(变量销毁),是let所声明变量的作用域(生命周期)。在C/C++中,变量的生命周期需要手动管理,申请了内存要手动释放,否则就会造成内存泄漏;在Java中,变量的生命周期交由GC处理,但需要付出额外的性能代价。但是:

在Rust中,当变量的生命周期结束时,它和它所拥有的内存将会被自动释放。

上面这句话凝聚了Rust内存管理的精髓,我们将围绕这句话来对Rust内存管理机制进行学习。我们将着重解决以下问题:

  • 如何判断变量的生命周期结束?这部分内容已在生命周期小节讲述。
  • 什么是变量拥有的内存?这部分将在所有权小节讲述。
  • 变量不拥有内存就不能使用内存吗?这部分将在借用小节讲述。

所有权

Rust中采用所有权(ownership)的概念管理内存,以保证内存安全。所有权的含义是Rust中分配的每一块内存都有其所有者,所有者负责该内存的释放和读写权限,并且每个值都只能有唯一的所有者。如果你有一些编程经验,你就会知道,“共享+可变”是万恶之源,它导致了无穷多bug的发生。如果能做到“共享的变量不可变,可变的变量不共享”,那么将能够避免非常多的问题,而Rust的所有权系统就致力于此。

我们在之前提过,所有权代表着以下意义:

  • 每个值在Rust中都有一个变量来管理它,这个变量就是这个值、这块内存的所有者。
  • 每个值在一个时间点上只有一个管理者。
  • 当变量所在的作用域结束的时候,变量以及它代表的值将会被销毁。

关于所有权,大家掌握两个概念即可,那就是复制语义和移动语义。复制语义和移动语义是对内存管理的两种截然不同的思想,复制语义是指变量在赋值的时候采用复制操作,移动语义是指变量在赋值的时候采用移动操作,也就是说,对于a = b,复制语义会把b的值复制一份给a,赋值操作结束后变量ab都拥有值,而移动语义会把b的值转移给a,赋值操作结束后变量b不再拥有值而只有变量a拥有值。

移动语义描述的其实是对值的所有权的转移,赋值语句、函数调用和函数返回等,都可能导致所有权转移。需要注意的是,对于常见的变量绑定(其他编程语言称为赋值操作),Rust默认是移动语义,例如下面的示例。

fn create() -> String {
    let s = String::from("hello");
    return s; // 所有权转移,从函数内部移动到外部
}

fn consume(s: String) { // 所有权转移,从函数外部移动到内部
    println!("{}", s);
}

fn main() {
	let s = create();
    consume(s);
    // let s1 = s; // error[E0382]: use of moved value: `s`
}
  • main函数调用create函数,create函数创建字符串hello,栈上的局部变量s拥有堆上的hello
  • create函数返回的时候,发生所有权转移,堆上的hello的所有权被转移到main函数中的栈上变量screate中旧的s变量被销毁时由于其已经不再拥有hello,所以hello没有被释放。
  • main函数调用consume函数,发生所有权转移,堆上的hello的所有权被转移到consume函数中的栈上变量s,当consume函数结束时,变量s和它拥有的内存hello一起被销毁。
  • main函数的最后一行,如果在调用完consume后再使用变量s,就会产生编译错误。

借用

对于某块内存,如果永远都只有一个变量作为入口进行访问的话,那就太难使用了。如果每次函数传参都要传递所有权,若函数调用完毕后还想继续使用该变量,那就要再从函数返回值里将所有权传递出来,而这显然是不合理的。因此,变量的所有权不仅可以被转移(move),还可以被借用(borrow)。借用的含义是在不拥有变量所有权的情况下获得对变量读写的权利,也就是说,借用指针不负责管理变量的生命周期。借用也被称为引用,在Rust中,它们具有相同的含义。

借用使用&符号或者&mut表示,分别表示只读借用和读写借用。&在C语言中是取地址的意思,在Rust中也一样,借用实际上就是一个指针,其内部数据和C语言中的普通指针一样,都是由一个地址构成,但借用指针的语义不同,它告诉编译器,它对指向的内存区域没有所有权。

关于借用指针,有以下几个规则:

  • 借用指针的生命周期不能长于出借方的生命周期
  • &mut型借用只能指向本身具有mut修饰的变量
  • &mut型借用指针存在的时候,被借用的变量本身会处于“冻结”状态,即该变量只可读,不可写也不可转移所有权
  • &型借用指针可以存在多个,&mut型借用指针只能存在一个

下面给出一个简单的借用的示例。

fn create() -> String {
    let s = String::from("hello");
    return s; 
}

fn consume(s: String) {
    println!("{}", s);
}

fn borrow(s: &String) {
    println!("{}", s);
}

fn main() {
    let s = create();
    let s1 = &s;
    println!("{}", s); // 输出:hello
    consume(s); // error[E0505]: cannot move out of `s` because it is borrowed
    borrow(s1); // 输出:hello
}

对于借用,Rust编译器要确保该借用在使用时原变量仍然是存活的,即借用指针的生命周期不能长于出借方的生命周期,否则会造成悬垂指针。Rust中存在借用检查器(borrow checker)来确保借用的合法性,例如在下面的代码中,r借用了x,但r的生命周期比x要长,因此在x被销毁后r就变成了悬垂指针。

fn main() {
    let r;
    {
        let x = 5;
        r = &x; // error[E0597]: `x` does not live long enough
    }
    println!("r: {}", r);
}

上面的例子只是函数内的借用检查,借用检查器可以获得所有变量的生命周期,因此很容易能推导出r的生命周期比x要长,但对于跨函数的借用检查,借用检查器就无法正确推断所有变量的生命周期了,例如在下面的代码中,borrow函数参数是两个引用,并随机将其中一个返回,在main函数对borrow进行调用的时候,借用检查器并不能够知道borrow返回的是变量a的引用还是变量b的引用,如果是变量a的引用,则r的生命周期合法,若是变量b的引用,则不合法。

fn borrow(a: &i32, b: &i32) -> &i32 {
    let number = rand::thread_rng().gen_range(1..=100);
    if number == 0 {
        a
    } else {
        b
    }
}

fn main() {
    let r;
    let a = 5;
    {
        let b = 5;
        r = borrow(&a, &b);
    }
    println!("r: {}", r);
}

实际上,如果编译上面的代码,编译器会在borrow函数处提示一个错误,error[E0106]: missing lifetime specifier,意思是borrow函数缺少生命周期标记。

生命周期标记,顾名思义,用于对变量的生命周期的长度进行标记,它本身并不改变变量的生命周期,只用于借用检查器来防止悬垂指针。生命周期以单引号'开头,后面跟一个合法的名字,例如'a。生命周期之间可以进行比较,如果生命周期'a比生命周期'b更长或相等,则记为'a : 'b。另外,'static是一个特殊的生命周期,其代表从程序开始到结束的整个阶段,所以,对任何一个生命周期'a,都有'static : 'b。生命周期标记位于引用符号&的后面,并使用空格来分隔生命周期参数和类型,例如&'a mut i32

在函数签名中的生命周期标记写法如下所示,<'a, 'b>是生命周期标记的声明,它们在后面的参数和返回值中被使用。此时,Rust借用检查器能够知道borrow返回值的生命周期和其第一个参数a的生命周期相同,因此,便能够对变量r的生命周期进行检查。

fn borrow<'a, 'b>(a: &'a i32, b: &'b i32) -> &'a i32 {
    a
}

fn main() {
    let r;
    let a = 5;
    {
        let b = 5;
        r = borrow(&a, &b);
    }
    println!("r: {}", r);
}

实际上,对于函数来说,如果其参数里没有任何引用,那么它将不能够返回任何的引用('static标记的引用除外)。因为如果函数参数里没有引用而返回值是引用,则说明该引用必定来自于函数内部的某个变量,这个变量在该函数结束后就会销毁,因此此时再返回其引用就会违反了生命周期规则。

实际上,每一个借用指针都有一个生命周期标记,只不过在不必要的情况下是可以省略的。Rust有一套自动补全生命周期的机制:

  • 每个输入位置上省略的生命周期都将成为一个不同的生命周期标记

  • 如果只有一个带生命周期标记的输入参数,则返回值的生命周期标记和其相同

  • 如果有多个带生命周期标记的输入参数,但其中有&self或者&mut self,则返回值的生命周期标记和self相同

  • 以上都不满足,就不能自动补全返回值的生命周期标记,就会产生编译错误

以上就是所有权系统的基本概念,所有权系统的诞生是为了内存安全服务的,所以其各种设计的理念也源于内存安全,我们以内存安全为线索,便能够将所有权系统深刻理解并彻底掌握。

参考文献

  • 《Rust编程之道》张汉东
  • 《深入浅出Rust》范长春
分享到: