likes
comments
collection
share

Dive Into Rust: 所有权、借用、lifetime

作者站长头像
站长
· 阅读数 21

前言

Chrome 代码库中70%的严重漏洞与内存管理和安全有关 —— Google Chrome

从 Google Chrome 的代码库统计结果可知,大部分的严重漏洞都来自于内存安全,微软也有这样的一个统计,结果类似。对于解决一个问题来说,能在写代码的时候就解决自然是最好的。

Rust 语言从设计之初就做了一套精妙的设计,确保了在编译期就能避免常见的内存安全的问题,其中核心的地方就在于所有权、借用和生命周期。所以这三点是为了 保障内存安全 而存在的。

常见的一些内存安全问题:

  • 内存泄漏:值用完了,没有释放,导致内存不断增大
  • 双重释放 Double Free: 值已经释放了, 又释放一遍
  • 悬垂引用 Dangling Reference: 一个指针原本指向了某个值,但值被突然释放了,指向了一个无效的地址

我们先了解 Rust 中的各种特性,然后看看他们是如何解决这些内存安全问题的。最后,我们会把所有的知识揉进一个知识模型中,便于理解和记忆。

所有权

所有权其实就是将一个值与一个变量所绑定,当变量离开作用域,这个变量也就无效了,其值也就跟着一起释放了。

本质是在说,谁拥有这个值,谁就对这个值负责,这个值的存亡,都由拥有者所限定。

三大法则

学习 Rust 所有权的时候都会听闻三个法则:

  1. 每个值都有一个所有者 owner (也就是变量)
  2. 一个值同时只能拥有一个 owner
  3. 当 owner 离开其作用域的时候,值跟着释放

这三个法则,前两个实际就是限定了 值和变量一对一的绑定关系 ,最后一个法则,就是要求 owner 要对其值负责了。owner 你没了,那你的值也就跟着人间蒸发了。

也就是说,这三个法则共同 确定了值的负责人和释放时机

RAII 和 Drop Trait

变量离开作用域就清除其对应的值,这样的操作实际在 C++ 中就存在,Rust 也是借鉴过来的,这种模式在 C++ 中叫做 RAII (资源获取即初始化)。这个 RAII 的翻译难以理解,我们只要记住,当某个变量离开作用域的时候,其对应的资源(无论是网络、文件、还是普通的值)都会跟着释放就行了。

在 Rust 中,为了实现 RAII 的模式,每个值离开作用域的时候,都可以附加一个 drop() 操作,这个操作是自动自行的。drop() 是由 Drop Trait 所声明,某个类型只要实现了 Drop Trait,其实例出来的变量都会在离开作用域的时候,自动执行 drop()。编码的时候,就无需管某个资源有没有释放了,双手插兜,有 drop 会最终兜底。

Move 语义与所有权转移

当变量要赋值给其他变量的时候,或者作为参数传递的时候,它的值有两种转移形式:

  • 一种是复制一份,你也有我也有,我们各自的相互独立。
  • 还有一种方式就是直接转移所有权,我没有了,都给你了,后续就都由你负责了。

这两种方式对应的就是两种语义,你可以理解为两种行为模式。这两种模式是针对具体类型来说的。

Copy

Copy 是一种 按位复制 的手段,也就是是说,赋值的时候,直接做一份副本,人手一份。就像一份文件一样,没必要大家都抢着一份用,用复印机多复印几份就可以了。

这种方式叫做 Copy

let a = 10;
let b = a;

println!("{}, {}", a, b);
// OUTPUT: 10, 10

Move

Move 语义是讲一个 值的所有权被转移了 ,从一个 owner 手中,换到了另一个新的 owner 手中,后面这个值要释放,也是由新的 owner 来负责了,老的 owner 不再拥有访问这个值的权限。如同房屋交易似的,换主人了,一切都是新主人做主。

let s = String::from("Hello");
let s1 = s; // s 的值转移给了 s1,s 不再生效

println!("{}", s);
// OUTPUT: Hello

// println!("{}", s); // s 失效了,这么做会报错

什么类型 Move,什么类型 Copy?

简单来说,看复制成本高低。一些原始类型的值,值所占用的内存大小是固定的,在编译期间就确定了,复制起来会很快,那就直接复制一份得了。一些复制成本太大的,就直接 Move,我用完了,直接给你,不用再开辟一个空间了,省时省力。

但这样说并不准确,还可以 从编译时是否能确定其占用内存大小的角度来看,对于一些动态增长的值,比如 Vec,String 等,编译期间没法获知它会实际占用多大内存,因为在运行的时候其占用的内存变大变小都可能。那就不敢直接都 Copy 了,成本没法确定,就统一 Move。

更仔细的看,从内存的角度来说,一个值可以存储在栈上或者堆上。同时,对于存储在堆上的值,想要使用它都需要在栈上有一个引用。比如 String 的数据结构就是在栈上有 cap,len,ptr 三个字段,然后 ptr 指向了堆内存中的一个数组(放在堆上这样就便于动态扩展内存)。假设我们在 String 类型复制的时候是 Copy 语义,Copy 是只会按位复制栈上的内容的,这会导致在栈上同时存在多个对同一堆内存的引用。

let a = String::from("Hello");
let b = a

Dive Into Rust: 所有权、借用、lifetime

那么这两个变量在释放的时候,其中一个将堆内存释放了,另一个再释放就会导致 Double Free 问题了。因此,String 类型需要是 Move 语义,也即 a 把值给了 b,那 a 就失效了,这样就不会有 Double Free 了。

Dive Into Rust: 所有权、借用、lifetime

小结一下:

一个类型该是 Move 还是 Copy 语义

  • 主要是看是否有堆上的引用,因为在 Copy 语义下会导致内存问题
  • 其次,看编译期间是否能确定内存大小,无确定大小就没法估计成本,那就不能一上来都 Copy,Move 更合适

借用

对于上面的 Move 和 Copy 语义来说,是针对具体类型就存在的,但如果一个类型是 Move 语义,我们可能在使用的时候并不方便。比如一个函数接收一个 String 类型的值。

fn bar(s: String) {
    // do something
}

fn main() {
    let s = String::from("Hello");
    bar(s)
    
    // 这里就无法使用 s 了,因为 s 的值被 Move,s 失效了
}

回顾一下,Move 表示的是所有权的转移,而所有权是为了确保一个值有一个 “管理者”,对其负责。我们很多时候只需要使用这个值,并不想对其负责 (没错,渣男行为),此时就可以 借用 一下。比如你此时需要一个螺丝刀修个东西,你没必要非得去买一个,直接隔壁借一下就可以了。你不需要对这个螺丝刀有所有权,但可以借过来,有使用权。

这其实就类似于 C、C++ 中的指针,不直接存储值,但可以指向某个存储了值的内存地址。

引用可以理解为是一种访问受限的指针,编译器会确保在引用上,不会出现悬垂指针、野指针的情况。引用总是有效的,而指针并不一定。

Immutable Borrow 和 Mutable Borrow

学习 Rust 的时候,借用和引用这两个概念,经常混合着说,可以理解为 Borrow 借用,是一种行为,这种行为得到的结果是引用,而引用是一种数据类型。那么可变借用就得到可变的引用,不可变借用就得到不可变的引用。

let mut s = String::from("Hello");

let s1 = &s;
let s2 = &mut s; // 如果 s 不是 mut 的,那就不能做可变借用

此时,就可以这样写函数了

fn bar(s: &String) {
    // do something
    
    // 函数执行完,s 这个引用会在这里被释放
}

fn main() {
    let s = String::from("Hello");
    bar(&s);
    
    // 可以使用 s,因为 s 仍然持有其值,拥有所有权
    println!("{}", s);
}

对于借用有两条规则需要熟记:

  1. 可以同时存在多个不可变借用或者只存在一个可变借用。(可变和不可变不能同时存在)
  2. 引用总是有效的

对于第一条来说,就是一个数据竞争问题,比如同时存在多个可变引用,分别被多个线程使用,那同时对这一个值变更,就乱套了;或者可变引用和不可变引用同时存在,也是同理。

对于引用总是有效的这一点,是 Rust 编译器要确保的事,避免出现野指针、悬垂指针等情况,让你可以放心的使用引用。

生命周期 lifetime

其实任何一个值都是有其生命周期的,只不过是隐式存在的,又因为所有权机制,值的生命周期又是跟着其变量走的。就好像你手里拿着一个杯子,杯子里面有水,杯子啥时候落地碎了,水也就洒了。而一个变量的生命周期,也就是从变量声明开始,直到其所处的作用域结束的位置。

对于除了引用类型的变量来说,值的生命周期都由 owner 负责,我们一般都不需要去管理,但对于引用类型来说,就会存在一些问题,这些问题需要我们来解决,这也是我们需要了解了解生命周期的原因。

首先,引用类型的变量其本身是有一个生命周期的,其次,它所引用的那个变量也是有其生命周期的。我们在 “借用” 的部分说过,引用总是有效的。那就必须确保一件事, 引用的生命周期不能大于被引用的值的生命周期 ,也即不能被引用的值都被销毁了,这个引用还存在,那就是悬垂引用了。

def rs() -> &str {
    let s = String::from("hello");
   
    &s // error, 这样是不行的,s 在函数结束的时候值就释放了,但却返回了一个有更长声明周期的引用
}

于是,Rust 编译器中有个 Borrow Checker 的东西会专门检测引用和被引用值的生命周期之间的关系,来避免可能存在无效引用的情况。这个东西大部分时间都是可以自行检测的,如果引用活得比被引用的值还久,就会报 outlive 的错误。

但是,有些情况比较特殊,它搞不定,比如:

fn r_ref(s1: &String, s2: &String) } -> &String {
    if (s1.len() > s2.len()) {
        return s1;
    } else {
        return s2;
    }
}

这个函数返回的是一个引用,我们假设这个引用是这个函数内部创建的某个值,那这个函数一定会报错,因为内部的任何值都会在函数结束的时候就销毁,引用就会失效。那么从逻辑上来讲就只能是返回 s1, s2 其中之一了。

但是,这里 s1, s2 的 len 都是运行期间才能知道的,编译的时候可不知道,他们各自有各自的生命周期,也就无法知道具体会返回 s1 还是 s2,无法确保返回的引用是否依然有效。我们把生命周期标注出来是这样的

fn r_ref(s1: &'a String, s2: &'b String) } -> &'c String {
    if (s1.len() > s2.len()) {
        return s1;
    } else {
        return s2;
    }
}

此时,编译器犯了难,咋办,不知道返回的引用是否是有效的。这时候就需要我们人工做一下标记。

生命周期标注

函数中的标注

对于上面的例子,我们可以这样标记,就可以通过编译了。

fn r_ref(s1: &'a String, s2: &'a String) } -> &'a String {
    if (s1.len() > s2.len()) {
        return s1;
    } else {
        return s2;
    }
}

把三个引用都标记为 'a,需要注意的是,这个只是一个标记,并不会改变 s1, s2, 以及返回值的实际的生命周期。标记的目的是告诉编译器,在这整个函数执行期间,s1, s2, 和返回值,都是存活有效的。

结构体中的标注

对于结构体来说,如果某个字段是个引用,也需要标记其生命周期,表示 这个引用的值在结构体实例存活期间,都要是有效的。不然就会出现引用的值无效了,然后还在用这个结构体的实例。

struct Person<'a> {
    age: u32,
    name: &'a String
}

省略标注

对于函数来说,很多时候我们是不需要手动去写生命周期标注的,Rust 编译器会自动进行推断,推断的规则如下:

  1. 每个引用类型的入参都会有一个独立的生命周期
  2. 如果只有一个引用类型的入参,则其生命周期标注会自动应用到引用类型的返回值上
  3. 如果第一个参数是 &self 或 &mut self,则其生命周期参数会追加到引用类型的返回值上

几个省略的例子,可以自行思考一下,为何不需要标注:


// example 1
impl Person {
    fn say(&self, ...) -> &str {
        // ...
    }
}


// example 2
fn one(s: &str) -> &str {
    // ...
}

大部分情况下,编译器报错的时候,你再出手标注就可以了

内存安全

我们一开始说过三种类型的内存问题:

  • 内存泄漏:值用完了,没有释放,导致内存不断增大
  • 双重释放 Double Free: 值已经释放了, 又释放一遍
  • 悬垂引用 Dangling Reference: 一个指针原本指向了某个值,但值被突然释放了,指向了一个无效的地址

其实不用细说,从整个 Ownership、Borrow 和 Lifetime 的介绍中,也都可以看出 Rust 是如何确保不出现这些问题的了。

有了 Ownership 的三条规则,一个值归它的 owner 管,owner 离开作用域会自动释放资源,也就解决了内存泄漏的问题。再加上 Move 和 Copy 语义,要么大家都有一个副本,要么 A 给了 B,A 就不再管怎么释放了,所有权给 B,B 来处理,从而避免了重复释放的问题。最后,引用和生命周期,确保引用不会比被引用的值活更久,避免了悬垂引用的问题。

在这三大范式的压制下,这些在 C 语言中常见的内存问题在编译期就被扼制住了,Write Less Bug,可以更加放心的上线了。

一个模型记住整个知识结构

为了更好的理解和记忆这系列知识,你可以这样想象一个:房屋模型。

  • 所有权:一个房子,同一时间有且只能有一个所有者
  • Copy:你看到别人的房子好看,自己复制建了一个一模一样的
  • Move:把房子卖给别人了。
  • Drop:房主哪天没了,房子直接就推了(虽然真实世界可能不会这么干)
  • 借用:房子租出去了,但不能动家里的装修布局
  • 可变借用:租出去了,还允许他们自己装修一遍
  • 生命周期:房子租了,但出租时限不能超过房主升天的时限(这里要假设房主啥时候升天是固定已知的)。

所有权在生活中还是挺多见的,房屋模型只是其中一种表述形式,方便你理解和记忆。

【完结】

转载自:https://juejin.cn/post/7252198762625417276
评论
请登录