Rust 不以规矩,不能成方圆 —— Rust 王国的物权法

james · 2024年06月12日 · 65 次阅读

孟子曰:离娄之明,公输子之巧,不以规矩,不能成方圆。 —— 《孟子》离娄章句上篇语

所有权与借用机制,明确了内存资源的权属,规范了借用内存资源的秩序,巧妙地解决了内存管理中的三大经典问题,是 Rust 王国的物权法。本文将从核心思想、具体规则以及对规则的验证等方面,对所有权与借用机制进行深入浅出的介绍,旨在让读者能快速的理解所有权与借用的内涵。

核心思想

我们已经知道了一些事实的真相。那就是,栈中的内容会自动实现创建与释放,每调用一个函数都会创建一个函数栈,函数内部的创建的变量在这个栈里有效,当函数返回时,这个函数栈会整体的被弹出,这些变量就变得不可见了,也就等同于被释放了。函数栈的空间范围,就是栈内变量的生命周期,变量仅在其生命周期内有效。我们还知道,对堆的使用,其实都需要通过栈作为中转。分配一个堆上的空间,会在栈上有一个指针类型的变量与之关联,之后对堆内容的读写操作,无一不是通过与之关联的栈上指针变量进行的。

很显然,栈与堆存在着依赖关系,它们同生不同死,一个是自生自灭,另一个却贪恋红尘、不死不休。说到这里,一个自然而然的想法就是,我们能不能通过栈中的变量来管理堆中内容,能让他们同生共死呢?这种朴素的想法,即使是山野山夫也能想到的,这也是“道法自然”的体现,数据结构中的“树”、“链表”、“队列”等等都是如此。

Rust 正是基于这种朴素自然的思想,提出了以所有权为基石的一系列方法论,用以回答内存管理中的三个问题的。其核心思想是:把栈与堆关联起来,通过对栈的自动化先天特性进行赋能,以实现对堆资源管理的自动化。

为了实现上述核心思想,Rust 设计了具体的方案。那就是,把所有的内存资源“拟物化”,然后比照我们现实社会,制订了《物权法》来管理这些资源。编译器充当大法官的角色,在编译时,对源代码进行检查,如果存在有不合法行为,就拒绝编译。

该物权法包括两部分内容,一是所有权,二是借用。所有权篇,明确了物的权属问题,即回答了“内存资源的所有者是谁?谁有权处置?”等问题。借用篇,提出了借用的概念(借用者可以通过引用方式借用内存资源),并明确了多个借用者之间、借用者与所有者之间的关系,旨在盘活内存资源、挖掘其资源价值。前者是体,后者为用。

所有权

所有权部分相关的主要法条如下:

  • 1.每一个值,都有一个对应的变量作为它的所有者;
  • 2.在某一时段内,值有且只有一个所有;
  • 3.当所有者离开自己的作用域时,它持有的值就会被释放掉。

现在对该法条中的“所有者”与“作用域”以及其相关的概念,作一下解读。

所有者

对于栈中的值,其所有者就是自身或者说它对应的变量符号;对于堆中的值,其所有者就是保存在栈上的变量。

看一张图:

作用域

作用域是针对变量而言的,通俗地讲就是它的可见范围。如果变量是可见的,其所代表的资源是存活的;如果变量是不可见的,其所代表的资源就应该被释放。一个变量是否可见,就是看它是否在当前的程序栈中,在栈内的变量就是可见,否则就是不可见的。所以,变量作为域的长度,等于变量所在位置到最内层栈的栈顶长度。事实上,作用域是生命周期的空间化概念。

比如如下代码:

{ // 变量 s 还没有声明,所以 s 不可见、不可用
    let s = 5;  // 从这里开始, s 可见、可用
} // 作用域结束, s 不可见、不可用

复制与移动

值是资源,变量是所有者。我们的目的是要让值与变量保持一一对应,达到同生共死。当一个变量赋值给别一个变量的时候 (函数的传参也一样),就要考虑到始终保持这种一一对应关系不被打破。对此,就赋值行为,Rust 引入了“复制”与“移动”机制,以对不同的场景作分别处理。

1. 复制

所谓的复制,是把值按位逐一拷贝一份,然后用一个新的变量(即所有者)与之关联。

是否采取复制行为,关键是看一个类型是否实现了 Copy 特征。对于储备在栈上的值,其内存大小是在编译时可知的,比如基本数据类型都默认实现了该特征,所以会采取复制行为。

这些类型主要有:

类型 描述
基本类型 整数、浮点数、布尔值和字符类型等
复合类型 数组、元组
指针类型 引用、裸指针、函数指针等

比如:

// 下面的每个值都只有一个所有者,赋值时的行为是复制
let num1 = 42;
let num2 = num1; // num2是一个新的所有者,它的值是 num1值的复制品,num1仍然是一个有效的所有者
println!("{}", num1); // 42,可以通过 num1 使用值

let arr1: [i32; 3] = [1, 2, 3];
let arr2 = arr1;

let num = 21;
let p1 = #
let p2 = p1;

2. 移动

移动是 Rust 的默认行为,没有实现了Copy特征的类型变量在赋值(或函数传参)时,都会发生移动行为。所谓的移动,是指会让值的原变量失效,而新的变量作为值的所有者。

现在我们来讨论一下为什么要这么设计? 假设有一个栈中的变量a,是指针类型,它里面所保存的值是某个 堆内存 中的地址,此时,栈与栈的内容如下图:

当把这个变量a赋值给同类型的变量b会发生什么呢?

let a: 指针类型 = 堆内存A的起始地址
let b: 指针类型 = a

如果按照之前的复制行为进行赋值,栈与堆中的内容会如下:

如图所示,此时堆中的值有栈上的两个变量与之关联。这就违背了“一个值只有一个变量作为所有者”的规定,所以这种复制行为对于指针类型的变量在赋值时就不适应。上面讨论的是纯粹的指针类型变量的情况,对于组合类型,如果其中某个分量的类型也是指针这种类型,并且指向堆内存的值,也会出现同样的问题,即一个值将会有多个变量与其关联。出现这种情况的原因是,这种复制其实是浅拷贝,即仅仅对栈上的内存进行byte to byte的拷贝。顺便提一下,Rust 也支持深拷贝,提供了一个叫做Clone的特征,其中有个 clone()方法会实现深拷贝。但通常深拷贝会消耗更多的性能,所以必须显式调用。

如果按移动方式进行的话,上面这个例子中,当完成赋值操作后,原变量a 将失效,不允许再使用。此时,栈与堆中的内容如下:

对于在编译期无法知道其大小的类型(这种类型要么直接关联有堆内存中的值,要么至少有一个分量关联有堆内存中的值),比如字符串类型,在赋值时会采取移动行为。这些类型主要有:

类型 描述
字符串类型 str, 本质上是一个 u8 类型的数据序列,实际中经常使用的形式:&str 和 String
切片类型 [T], 它代表类型为 T 的元素组成的数据序列:实际中经常使用的形式:Vec
trait object trait object 的大小只有在运行时才能确定
... ...

3. 自动释放

通过上面的分析,可以看出 所有者复制与移动 机制,保证了法条中第一和第二条的规定,但还不能保证第三条的要求。变量在作用域 结束后,将变得不可访问(类似于释放),但如果这个变量是指针类型,还应该让其所指向的堆内容也一同释放。为了实现这一要求,Rust 约定,一个类型如果实现了Drop特征(其中有一个drop方法),该类型的变量离开作用域之前,编译器会插入调用drop方法的代码,用以负责处理释放堆内容等相关善后操作。

这样,当栈变量释放的同时,与之关联的堆空间也得到了释放,也保证了法条的第三条的规定。这也是体现所有权的核心思想:把栈与堆关联起来,通过对栈的自动化先天特性进行赋能,以实现对堆资源管理的自动化。

验证所有权规则

所有权相关的内容已经讨论完了,现在我们来验证下。下面我们分别使用编译期可知大小的基本类型i32与编译期未知大小的动态类型String进行验证。字符串类型String内部有一个指针类型的变量,指向堆上实际存放字符串的空间,程序在运行的时候,可能通过操作这块堆内存实现字符串内容的动态增减。

现在,我们开始验证吧。看看,这些类型能不能满足所有权机制的要求。

1. 验证固定尺寸的类型

作用域
fn main() {
   let a = 5;

   { // b 的作用域是内部的花括号内
      let b = 6;
   }

   // 此处,b 已经离开作用域,固不可见、不可访问
   let a = b;
}

以上代码无法通过编译,会报:error[E0425]: cannot find valuebin this scope错误,说明变量仅在作用域内生效。

赋值
fn main() {
   let a = 5;
   let b = a; // 发生复制行为
   println!("{},{}", a, b);
}

以上代码能通过编译,并输出 5,5

函数传参
fn func1(a: i32) {
   println!("{}", a);
}

fn main() {
   let a = 5;
   func1(a); //传参时,发生复制行为

   println!("{}", a); // 传参后,原变量仍然有效
}

以上代码,能通过编译,运行后会打印出变量a的值:5。

2. 验证动态尺寸的类型

作用域
fn main() {
   { // s1 的作用域是内部的花括号内
      let s1 = "abc";
   }
   let s2 = s1; // 此处,s1 已经离开作用域,固不可见、不可访问
}

以上代码无法通过编译,会报:error[E0425]: cannot find values1in this scope 错误,说明变量仅在作用域内生效。

赋值
fn main() {
   let s1 = String::from("hello");

   // 因为,String这种动态类型
   // 没有实现`Copy`特征,所以赋值时发生的是移动行为,所有权转移至新的变量
   let s2 = s1; 

   println!("{}", s1);

}

以上代码无法通过编译,会报:error[E0382]: borrow of moved value:s1`` 错误,说明所有权发生转移,原变量失效。

6  |    let s1 = String::from("hello");
   |        -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
...
10 |    let s2 = s1; 
   |             -- value moved here
11 |
12 |    println!("{}", s1);
   |                   ^^ value borrowed here after move

以上代码之所以会无法通过编译的原因是,String这种类型由于没有实现Copy特征,在赋值的时候进行的移动行为。移动作为会导致值所关联的原变量失效,值的所有得变成新的变量。这个行为如下图所示:

如果不是进行移动,而是复制,会是下图的效果:

如果把let s2 = s1 改成let s2 = s1.clone(),实现的深拷贝,其效果如下:

函数传参
fn func1(a: String) {
   println!("{}", a);
}

fn main() {
   let s1 = String::from("abc");
   func1(s1); // 发生移动行为,所有权转移到函数中,原变量s1不可用

   println!("{}",s1); // s1 在此处不可用

}

函数传参的规则跟赋值类似,函数调用时会传参,传参数的过程等同于赋值。上面代码无法通过编译,会报:error[E0382]: borrow of moved value:s1`错误,说明所有权转移至func1函数体内,原变量s1`失效。

error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:9:18
  |
6 |    let s1 = String::from("abc");
  |        -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
7 |    func1(s1); // 发生移动行为,所有权转移到函数中,原变量s1不可用                    ...
  |          -- value moved here
8 |
9 |    println!("{}",s1); // s1 在此处不可用
  |                  ^^ value borrowed here after move
函数返回

值的所有权,也可以从函数内部返回到调用方。

fn main() {
  let s1 = gives_ownership();         // gives_ownership 将返回值
                                      // 移给 s1

  let s2 = String::from("hello");     // s2 进入作用域

  let s3 = takes_and_gives_back(s2);  // s2 被移动到
                                      // takes_and_gives_back 中,
                                      // 它也将返回值移给 s3
} // 这里, s3 移出作用域并被丢弃。s2 也移出作用域,但已被移走,
  // 所以什么也不会发生。s1 移出作用域并被丢弃

fn gives_ownership() -> String {           // gives_ownership 将返回值移动给
                                           // 调用它的函数

  let some_string = String::from("yours"); // some_string 进入作用域

  some_string                              // 返回 some_string 并移出给调用的函数
}

// takes_and_gives_back 将传入字符串并返回该值
fn takes_and_gives_back(a_string: String) -> String { // a_string 进入作用域

  a_string  // 返回 a_string 并移出给调用的函数
}

3. 验证自动释放

为了验证当变量离开作用域时,会不会调用Drop特征中的drop方法以完成诸如释内存等清尾工作。我们需要自己定义一个类型,并为这个类型实现Drop特征。我们还没有学习如何自定义类型,也不知道特征是怎么事。我在这里简单地介绍一下,特征类似于别的语言中的接口,它定义一组行为,具体就是指一组方法,用于描述这个特征所具备的能力。这些方法,通常都是没有实现的,仅仅只是给出方法的签名,如果某个类型实现了某个特征中的这些方法,就称这个类型具备了某个特征。

首先,我们定义一个类型:

struct Wrapper(i32);

上面代码的意思是,我们定义了结构体类型Wrapper,它包含了一个i32类型的成员。但需要主要的是,Wrapper类型实例虽然在内存中的布局与i32一样,但它的实例是不能与i32实例进行互相赋值。 第二步,就是为Wrapper类型实现Drop特征,因为该特征已经有标准库定义好了,它只有一个方法 drop,所以我们实现这个方法就可以了。

impl Drop for Wrapper {
    fn drop(&mut self) {
        println!("Wrapper类型的实例正在被释放...");
    }
}

至此,我们自定义了一个类型,并且为该类型实现了Drop特征,接下来我们看看,当这个类型实例的变量(所有者)离开作用域时,会发生什么?

struct Wrapper(i32);

impl Drop for Wrapper {
    fn drop(&mut self) {
        println!("Wrapper类型的实例正在被释放...");
    }
}

fn main() {
   let wrapper1 = Wrapper(11);
   let wrapper2 = wrapper1; // 所有权转移到 wrapper2, 原所有者失效
} //  wrapper2先离开作用域,由于它是值的所有者,所以还会调用drop()方法

执行cargo run运行上面的程序,终端上会输出:

Wrapper类型的实例正在被释放...

以上代码中,所有权由wrapper1转移到wrapper2,当wrapper2离开作用域时会调用drop()方法释放资源,由于wrapper1已经失效,所以没有调用drop()方法,保证了没有重复释放问题。

借用

所有权机制,把内存资源限制在其所有者内,要对已有内存资源的使用意味着所有权的转移,这就限制了内存作为资源的使用价值。为了盘活内存资源价值,Rust 引入了借用机制。

借用是目的,引用是手段。其实质是,通过引用的方式,借用原本没有所有权的值。

引用

引用,是 Rust 指针类型家族中的重要成员之一(另一个类型是祼指针,祼指针用于 Unsafe 场景,引用用于 Safe 场景),分为不可变引用(&T)可变引用(&mut T)。引用不持有值的所有权,当引用离开其作用域时,它所指向的值不会被丢失。这也就是为什么把引用也叫作借用的原因。之所以有不变不可变之别,主要是区分引用对被引用本身的使用权限,前者只能读,后者还可以修改。

与引用机制相关的主要法条如下:

  • 在任何一段给定的时间里,你要么只能拥有一个可变引用,要么只能拥有任意数量的不可变引用。
  • 引用总是有效。

不可变引用

不可变引用,用&T表示,其中T表示具体的类型,通过不可变引用无法去修改被引用本体的内容。

比如:

let a = 5;

// 定义了一个引用类型ref_a, 它保存的内容是变量a的内存地址,&表示取地址,在这里表示取变量a在内存中的地址。
let ref_a = &a; 

可以通过ref_a来访问它所引用的本身 a,这个过程叫作解引用,解引用是通过*来完成的。 比如:

// 对引用类型的变量ref_a进行解引用,具体过程是先找到ref_a中的内容(变量a的地址),
// 然后再根据该地址去找到变量a 中的内容。
let b = *ref_a; 

引用跟其他指针类型一样,其内容都是另一个值的内存地址。但一个重要的区别是,引用必须要有被引用的本身,否则就失去了引用的本身意义。也就是说,在对引用进行解引用之前,引用必须要被初始化,即其中的内容必须是一个变量的内存地址,且这个变量是有效的,这就是法条中“引用总是有效”的意思。这条规则适用于所有引用,包括不可变与可变引用。

看一段代码:

fn main() {
    let ref_some: &i32;
    let some_value = *ref_some;
}

以上代码无法通过编译,会报类似如下的错误信息:

error[E0381]: used binding `ref_some` isn't initialized
 --> src/main.rs:4:22
  |
2 |     let ref_some: &i32;
  |         -------- binding declared here but left uninitialized
3 |
4 |     let some_value = *ref_some;
  |                      ^^^^^^^^^ `*ref_some` used here but it isn't initialized

由于引用不占用值的所有权,传递引用不会导致被引用资源的所有权转移,这在诸如“用一个变量调用一个函数后,继续使用原变量”这种需要多种使用资源的场景特别有用。比如:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

上面代码,通过引用&s1调用calculate_length()计算出字符串s1的长度后,由于没有发生所有权的转移,所以可以继续使用s1。这就把借用的内涵体现的淋漓尽致。

如果我们要通过不可变引用来改变被引用本身的内容是不被允许的,比如:

fn main() {
    let s = String::from("hello");

    change(&s);
}

fn change(some_string: &String) {
    some_string.push_str(", world");
}

上面代码将无法通过编译,会报类似如下的错误:

error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
 --> src/main.rs:8:5
  |
8 |     some_string.push_str(", world");
  |     ^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable
  |

其原因是,我们不能通过不可变引用&s调用push_str方法改变字符串的内容。

可变引用

可变引用,用&mut T表示,其中T表示具体的类型,关键字mut强调可变,通过可变引用可以修改被引用本体的内容。

把上面代码稍做如下修改,即可通过编译。修改后的代码:

fn main() {
    let mut s = String::from("hello");

    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

下面代码,是定义可变引用 (&T),并进行解引用与改变内容的示例:

fn main() {
    let  mut  a = 5; // 必须标注为mut,否则无法被可变的引用
    let ref_a = &mut a;

    let b = *ref_a;
    println!("b = {b}");

    *ref_a += 100;
    println!("After changed: a= {a}");
}

另外一点要注意的是,一个变量要允许被可变地引用,其本身必须是能被修改的,这是显然的,一个不允许被修改的变量,当然通过引用也不能被修改,因为毕竟引用只是本体的一个别名罢了。

验证引用的规则

在验证引用的规则之前,我想再重复一次这个规则。规则一是,在任何一段给定的时间里,你要么只能拥有一个可变引用,要么只能拥有任意数量的不可变引用;规则二是,引用总是有效的。规则一,我们在前面已经验证过了,现在我们来验证规则二。

可以拥有任意数量的不可变引用

fn main() {
    let s = String::from("the web3");
    let r1 = &s;
    let r2 = &s;
    println!("{}", *r1);
    let r3 = &s;
    println!("{}", r2);
}

以上代码演示了可以拥有任意数量的不可变引用,可以通过编译。

在任何一段给定的时间里,只能拥有一个可变引用

fn main() {
    let mut  s = String::from("the web3");
    let r1 = &mut s;
    println!("{}", *r1);
    let r2 = &mut s;
    println!("{}", *r2);
    //println!("{}", *r1); // 放开这行代码,无法通过编译
}

以上代码,对字符串s有两个借用r1r2,应该是违背了这个规则。但是却能正常通过编译!!这到底是哪里出问题呢?难道《物权法》的规则失效了?

其实不然,是我们没有读懂“一段给定的时间里”的内涵。因为我们的代码是顺序执行的,它所强调的给定时间主要是强调是引用定义到该引用使用这一段时间,比如上面的引用r1在定义到使用 (通过引用打印)的这段时间内,没有其他可变引用引用本体s,而使用完r1后,后面再没有通过r1就访问或修改本体,虽然r1的作用域持续到函数结束处,但编译器认为它作为引用的使命就已经结束了,而当使用r2的时候,从定义r2到使用r2这个时间段内,也只有r2引用到本体。可见,这是符合规则要求的,所以能正常通过编译。

不可变引用与可变引用不能同时存在

let mut  s = String::from("the web3");
let r1 = & s;
println!("{}", *r1);
let r2 = &s;
println!("{}", *r2);

let r3 = &mut s;
println!("{}", *r3);
//println!("{}", *r2); // 放开这行代码,无法通过编译

以代码也是可以正常通过编译,其原因跟上面的一样,当使用r3可变引用时,前面的r1r2作用引用的使命已经结束,此时此刻,对于本体s而言,它只有一个可变引用r3,没有不可变引用。

如果放开最一行代码,将无法通过编译,因为在r3的时候,对于本体s而言,它有一个可变引用r3,还有一个不可变引用r2

对于同一个本身的引用中,“只能有一个可变引用”以及“可变引用与不可变引用不能共存”的判断,有一个非常实用的标准,那就是:如果定义引用处至最后使用引用处之间,包含有另外的对于同一本体的引用的定义或使用,就违背了规则,否则符合规则。

如果套用数学的术语,我们可以尝试作如下定义:

  1. 设 o 为本体,对 o 的某个不可变引用 x 的定义处记为:Rs_o1_x,最后一个使用处记为:Re_o1_x
  2. 设 o 为本体,对 o 的可变引用 x 的定义处记为:Rs_o3_x,最后一个使用处记为:Re_o3_x
  3. 对于给定的不可变引用 i,在定义处与最后使用处的区间(Rs_o1_i,Re_o1_i)内,如果存在任意Rs_o3_xRe_o3_x (其中 x<>i) ,则违背了规则;
  4. 对于给定的可变引用 i,在定义处与最后使用处的区间(Rs_o3_i,Re_o3_i)内,如果存在任意Rs_o1_x/Rs_o3_xRe_o1_x/Re_o3_x (其中 x<>i),则违背了规则;

  5. 其他情况,符合借用法的规则。

对不起,我承认我已经小题大作了!其实把定义处与最后使用处画一条线连起来,就明白了,线条没有交叉和包含就可以了。

切片

切片是引用类型的一种,它不同于普通引用把被引用本体当成一个整体使用,切片只对被引用本体的部分内容进行引用。这就是切片类型的实质!切片通常是对诸如数组、字符串等这种连续数据中的部分序列进行引用。

由于切片也是一种引用,所以引用类型所具备的所有特性、对引用约束的机制以及引用的使用场景,同样适合于切片类型。

字符串字面量的切片

let s = "hello world";

上面代码中的"hello world"就是字符串字面量,编译后会被直接编译到二进制程序中,程序加载内存后,会被存储在静态区,静态区中的内容是不能修改的。"hello world" 的类型是str,它是一个虚无的类型,它表示在静态区中的数据序列,在编译时无法这个序列有多长,因为里面的具体内容及长度取决于程序中各种字符串字面量定义情况。这有点类似于镜中花、水中月,只可意喻、不可言传,更不能直接拿来使用。 要使用str,就必须通过它的引用&str,但由于静态区保存有很多内容,不能把整个内容当成一个整体引用来使用,于是就需要明确指出引用从何处开始,至何处结束,这也体现了切片是引用部分内容的实际意义。可见,引用也是解决无法使用编译期未知大小类型的万能法宝!

上面代码中的s就是一个&str类型,表示对静态内存区中某一段内容的引用。

切片类型通常是一个保存栈上的结构体,其中一个字段用来标识引用的起始位置,另一个字段用来标识要引用数据的长度。

字符串切片

字符串切片是对字符串String中保存在堆内存中的实际数据感兴趣,引用其中某一段内容。 比如:

let s = String::from("hello world");

let hello = &s[0..5];
let world = &s[6..11];

上面的hello的类型是&str,表示一个对 String 类型的切片,具体意思是,引用字符串s在堆内存中的数据序列中从第一个位置开始至第五个位置结束这一段内容,[0..5]0表示起始位置的索引值,5表示切片终止位置的下一个位置。

上面代码中的变量world在内存的示意图:

数组切片

数组保存着同类型的连续的元素,也可以通过切片来引用部分元素序列。

fn main() {
    let arr = [1,2,3,4,5];
    let slice_for_arr = &arr[1..4];
    println!("{}", slice_for_arr[0]);
}

上面代码中slice_for_arr 就是对数组arr的切片,它引用从第 2 个元素开始至第 4 个元素结束的序列([2,3,4])。

这里有几个概念要作一下解释:

  • [T;n] 表示数组,比如[i32,3]是一个长度为 3、内部的元素的类型为 i32 的数组;
  • [T] 是一个虚无的概念,程序中无法直接使用,表示某个类型T的数据序列,类似于str编译期无法知道其大小
  • &[T] 是对[T]的切片,表示对类型T的数据序列的某个区段范围内的数据的引用,类似于&str

悬垂引用

所有权的一物一所有者机制,解决了内存泄露问题。借用规则的第一条:“在任何一段给定的时间里,要么只能拥有一个可变引用,要么只能拥有任意数量的不可变引用”,解决了数据竞争问题。

对于“引用总是有效的”之第二条规则,我们已经验证过编译器会确保在使用引用之前必须初始化,否则无法通过编译。这也似乎解决了悬垂指针问题。

但事实是,编译器没有那么聪明,有时它无法确定一个引用是不是有效的!

对于一些简单的问题,编译器会明确给出结论,比如:

fn main() {
    let ref_i32: &i32;
    {
        let a = 5;
        ref_i32 = &a;
    }
    println!("{}",*ref_i32);
}

上面代码无法通过编译,会认为是悬垂引用。又比如:

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String {
    let s = String::from("hello");

    &s
}

上面代码也无法通过编译,同样会认为是悬垂引用,并给出了缺少生命周期参数标的错误。具体的错误信息如下:

error[E0106]: missing lifetime specifier
 --> src/main.rs:5:16
  |
5 | fn dangle() -> &String {
  |                ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`

但对于一些复杂的场景,编译器没有那么智能,它没有办法精准地跟踪一个引用与被引用本体的关系以及两者的生命周期的长短,也就没有办法确保被引用本体是不是比引用活得更长。尤其是在存在引用类型字段的结构体中,且结构体内方法又有引用类型的参数和引用类型的返回值时,对编译器来说,这种不确定性就更为明显。

基于此,Rust 提出了生命周期与标注机制,把变量的生命周期进行量化,然后让程序员去对引用进行显式的标注,用以向编译器指明函数中各种传入引用与传出引用之间、引用与被引用本体之间的关系,进而让编译去决定是否符合借用规则。

限于篇幅,不能就生命周期标注的内容作更多的介绍,后续会另开专文解读。

总结

所有权与借用是 Rust 语言的基石,本文以通俗易懂的言语,就所有权与借用相关的内容进行了全面系统地介绍。 但愿,你能懂我。

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请 注册新账号