第5章 | 对值的引用,使用引用,引用安全

罗宾和娜美

图书馆(库)1无法弥补个人(程序员)能力的不足。

------Mark Miller

1"库"与"图书馆"在英文中是同一个词,这里一语双关。------译者注

迄今为止,我们看到的所有指针类型(无论是简单的 Box<T> 堆指针,还是 String 值和 Vec 值内部的指针)都是拥有型指针,这意味着当拥有者被丢弃时,它的引用目标也会随之消失。Rust 还有一种名为引用(reference)的非拥有型指针,这种指针对引用目标的生命周期毫无影响。

事实上,影响是反过来的:引用的生命周期绝不能超出其引用目标。你的代码必须遵循这样的规则,即任何引用的生命周期都不可能超出它指向的值。为了强调这一点,Rust 把创建对某个值的引用的操作称为借用(borrow)那个值:凡是借用,终须归还。

笔记

这里想到了《权利的游戏》 中的兰尼斯特家族的俗语:"兰尼斯特有债必还" A Lannister always pay his debt

Rust:凡是借用,终须归还

如果在读到"你的代码必须遵循这样的规则"这句话的时候不知道如何是好,那么你并非特例。引用本身确实没什么特别之处------说到底,它们只是地址而已。但用以让引用保持安全的规则,对 Rust 来说是一种创新,除了一些研究性语言,你不可能在其他编程语言中见到类似的规则。尽管这些规则是 Rust 中掌握起来最费心力的部分,但它们在防止经典的、常见的错误方面的覆盖度令人叹为观止,它们对多线程编程的影响也是革命性的。这又是 Rust 的"激进赌注"。

本章将介绍引用在 Rust 中的工作方式,我们会展开讲解引用、函数和自定义类型是如何通过包含生命周期信息来确保它们被安全使用的,并阐明这些努力为何能在编译期就避免一些常见类别的缺陷,而不必在运行期付出性能方面的代价。

5.1 对值的引用

假设我们要创建一张表格,列出文艺复兴时期某一特定类型的艺术家和他们的作品。Rust 的标准库包含一个哈希表类型,所以可以像下面这样定义我们的类型:

rust 复制代码
use std::collections::HashMap;

type Table = HashMap<String, Vec<String>>;

换句话说,这是一个将 String 值映射到 Vec<String> 值的哈希表,用于将艺术家的名字对应到他们作品名称的列表中。由于可以使用 for 循环遍历 HashMap 的条目,因此需要编写一个函数来打印 Table 的内容:

rust 复制代码
fn show(table: Table) {
    for (artist, works) in table {
        println!("works by {}:", artist);
        for work in works {
            println!("  {}", work);
        }
    }
}

构建和打印这个表格的代码也一目了然:

less 复制代码
fn main() {
    let mut table = Table::new();
    table.insert("Gesualdo".to_string(),
                 vec!["many madrigals".to_string(),
                      "Tenebrae Responsoria".to_string()]);
    table.insert("Caravaggio".to_string(),
                 vec!["The Musicians".to_string(),
                      "The Calling of St. Matthew".to_string()]);
    table.insert("Cellini".to_string(),
                 vec!["Perseus with the head of Medusa".to_string(),
                      "a salt cellar".to_string()]);

    show(table);
}

一切正常:

csharp 复制代码
$ cargo run
     Running `/home/jimb/rust/book/fragments/target/debug/fragments`
works by Gesualdo:
  many madrigals
  Tenebrae Responsoria
works by Cellini:
  Perseus with the head of Medusa
  a salt cellar
works by Caravaggio:
  The Musicians
  The Calling of St. Matthew
$

但是,如果你已经阅读过第 4 章关于"移动"的部分,就会对 show 这个函数的定义产生一些疑问。特别是,HashMap 不是 Copy 类型------也不可能是,因为它拥有能动态分配大小的表格。所以当程序调用 show(table) 时,整个结构就移动到了函数中,而变量 table 变成了未初始化状态。(而且它还会以乱序迭代其内容,所以如果你看到的顺序与这里不同,请不要担心,这是正常现象。)现在如果调用方代码试图使用 table,则会遇到麻烦:

css 复制代码
...
show(table);
assert_eq!(table["Gesualdo"][0], "many madrigals");

Rust 会报错说 table 不再可用:

rust 复制代码
error: borrow of moved value: `table`
   |
20 |     let mut table = Table::new();
   |         --------- move occurs because `table` has type
   |                   `HashMap<String, Vec<String>>`,
   |                   which does not implement the `Copy` trait
...
31 |     show(table);
   |          ----- value moved here
32 |     assert_eq!(table["Gesualdo"][0], "many madrigals");
   |                ^^^^^ value borrowed here after move

事实上,如果查看 show 的定义,你会发现外层的 for 循环获取了哈希表的所有权并完全消耗掉了,并且内部的 for 循环对每个向量执行了相同的操作。(之前我们在 4.2.3 节的示例中看到过这种行为。)由于移动的语义特点,我们只是想把它打印出来,却完全破坏了整个结构。Rust,你可"真行"!

处理这个问题的正确方式是使用引用。引用能让你在不影响其所有权的情况下访问值。引用分为以下两种。

  • 共享引用 允许你读取但不能修改其引用目标。但是,你可以根据需要同时拥有任意数量的对特定值的共享引用。表达式 &e 会产生对 e 值的共享引用,如果 e 的类型为 T,那么 &e 的类型就是 &T,读作"ref T"。共享引用是 Copy 类型。
  • 可变引用 允许你读取和修改值。但是,一旦一个值拥有了可变引用,就无法再对该值创建其他任何种类的引用了。表达式 &mut e 会产生一个对 e 值的可变引用,可以将其类型写成 &mut T,读作"ref mute T"。可变引用不是 Copy 类型。

可以将共享引用和可变引用之间的区别视为在编译期强制执行"多重读取"或"单一写入"规则的一种手段。事实上,这条规则不仅适用于引用,也适用于所引用值的拥有者。只要存在对一个值的共享引用,即使是它的拥有者也不能修改它,该值会被锁定。当 show 正在使用 table 时,没有人可以修改它。类似地,如果有某个值的可变引用,那么它就会独占对该值的访问权,在可变引用消失之前,即使拥有者也根本无法使用该值。事实证明,让共享和修改保持完全分离对于内存安全至关重要,本章会在稍后内容中讨论原因。

我们示例中的打印函数不需要修改表格,只需读取其内容即可。所以调用者可以向它传递一个对表的共享引用,如下所示:

scss 复制代码
show(&table);

引用是非拥有型指针,因此 table 变量仍然是整个结构的拥有者,show 刚刚只是借用了一会儿。当然,我们需要调整 show 的定义来匹配它,但必须仔细观察才能看出差异:

rust 复制代码
fn show(table: &Table) {
    for (artist, works) in table {
        println!("works by {}:", artist);
        for work in works {
            println!("  {}", work);
        }
    }
}

show 中的 table 参数的类型已从 Table 变成了 &Table:现在不再按值传入 table(那样会将所有权转移到函数中),而是传入了共享引用。这是代码上的唯一变化。但是当我们深入函数体了解其工作原理时,这会有怎样的影响呢?

在以前的版本中,外部 for 循环获取了此 HashMap 的所有权并消耗掉了它,但在新版本中,它收到了对 HashMap 的共享引用。迭代中对 HashMap 的共享引用就是对每个条目的键和值的共享引用:artistString 变成了 &String,而 worksVec<String> 变成了 &Vec<String>

内层循环也有类似的改变。迭代中对向量的共享引用就是对其元素的共享引用,因此 work 现在是 &String。此函数的任何地方都没有发生过所有权转移,它只会传递非拥有型引用。

现在,如果想写一个函数来按字母顺序排列每位艺术家的作品,那么只通过共享引用是不够的,因为共享引用不允许修改。而这个排序函数需要对表进行可变引用:

scss 复制代码
fn sort_works(table: &mut Table) {
    for (_artist, works) in table {
        works.sort();
    }
}

于是我们需要传入一个:

scss 复制代码
sort_works(&mut table);

这种可变借用使 sort_works 能够按照向量的 sort 方法的要求读取和修改此结构。

当通过将值的所有权转移给函数的方式将这个值传给函数时,就可以说按值 传递了它。如果改为将值的引用传给函数,就可以说按引用 传递了它。例如,我们刚刚修复了 show 函数,将其改为按引用而不是按值接受 table。许多语言中也有这种区分,但在 Rust 中这尤为重要,因为它阐明了所有权是如何受到影响的。

笔记

Rust这里的对值的引用和JavaScript中差距还是蛮大的,JavaScript中变量操作感觉有点随意

5.2 使用引用

前面的示例展示了引用的一个非常典型的用途:允许函数在不获取所有权的情况下访问或操纵某个结构。但引用比这要灵活得多,下面我们通过一些示例来更详细地了解引用的用法。

5.2.1 Rust 引用与 C++ 引用

如果熟悉 C++ 中的引用,你就会知道它们确实与 Rust 引用有某些共同点。最重要的是,它们都只是机器级别的地址。但在实践中,Rust 的引用会给人截然不同的感觉。

在 C++ 中,引用是通过类型转换隐式创建的,并且是隐式解引用的:

ini 复制代码
// C++代码!
int x = 10;
int &r = x;            // 初始化时隐式创建引用
assert(r == 10);       // 对r隐式解引用,以查看x的值
r = 20;                // 把20存入x,r本身仍然指向x

在 Rust 中,引用是通过 & 运算符显式创建的,同时要用 * 运算符显式解引用:

ini 复制代码
// 从这里开始回到Rust代码
let x = 10;
let r = &x;            // &x是对x的共享引用
assert!(*r == 10);     // 对r显式解引用

要创建可变引用,可以使用 &mut 运算符:

ini 复制代码
let mut y = 32;
let m = &mut y;        // &muty是对y的可变引用
*m += 32;              // 对m显式解引用,以设置y的值
assert!(*m == 64);     // 来看看y的新值

也许你还记得,当我们修复 show 函数以通过引用而非值来获取艺术家表格时,并未使用过 * 运算符。这是为什么呢?

由于引用在 Rust 中随处可见,因此 . 运算符就会按需对其左操作数隐式解引用:

rust 复制代码
struct Anime { name: &'static str, bechdel_pass: bool }
let aria = Anime { name: "Aria: The Animation", bechdel_pass: true };
let anime_ref = &aria;
assert_eq!(anime_ref.name, "Aria: The Animation");

// 与上一句等效,但把解引用过程显式地写了出来
assert_eq!((*anime_ref).name, "Aria: The Animation");

show 函数中使用的 println! 宏会展开成使用 . 运算符的代码,因此它也能利用这种隐式解引用的方式。

在进行方法调用时,. 运算符也可以根据需要隐式借用对其左操作数的引用。例如,Vecsort 方法就要求参数是对向量的可变引用,因此这两个调用是等效的:

scss 复制代码
let mut v = vec![1973, 1968];
v.sort();           // 隐式借用对v的可变引用
(&mut v).sort();    // 等效,但是更烦琐

简而言之,C++ 会在引用和左值(引用内存中位置的表达式)之间隐式转换,并且这种转换会出现在任何需要转换的地方,而在 Rust 中要使用 & 运算符和 * 运算符来创建引用(借用)和追踪引用(解引用),不过 . 运算符不需要做这种转换,它会隐式借用和解引用。

笔记

. 运算符在日常使用中会非常高频

5.2.2 对引用变量赋值

把引用赋值给某个引用变量会让该变量指向新的地方:

ini 复制代码
let x = 10;
let y = 20;
let mut r = &x;

if b { r = &y; }

assert!(*r == 10 || *r == 20);

引用 r 最初指向 x。但如果 btrue,则代码会把它改为指向 y,如图 5-1 所示。

图 5-1:引用 r 现在指向 y 而不再是 x

乍一看,这种行为可能太显而易见,不值一提:现在 r 当然会指向 y,因为我们在其中存储了 &y。但特意指出这一点是因为 C++ 引用的行为与此截然不同:如前所述,在 C++ 中对引用赋值会将新值存储在其引用目标中而非指向新值。C++ 的引用一旦完成初始化,就无法再指向别处了。

5.2.3 对引用进行引用

Rust 允许对引用进行引用:

ini 复制代码
struct Point { x: i32, y: i32 }
let point = Point { x: 1000, y: 729 };
let r: &Point = &point;
let rr: &&Point = &r;
let rrr: &&&Point = &rr;

(为了让讲解更清晰,我们标出了引用的类型,但你可以省略它们,这里没有什么是 Rust 无法自行推断的。). 运算符会追踪尽可能多层次的引用来找到它的目标:

ini 复制代码
assert_eq!(rrr.y, 729);

在内存中,引用的排列方式如图 5-2 所示。

图 5-2:对引用的引用链

在这里,表达式 rrr.y 根据 rrr 的类型的指引遍历了 3 层引用才取到相应 Pointy 字段。

5.2.4 比较引用

就像 . 运算符一样,Rust 的比较运算符也能"看穿"任意数量的引用:

ini 复制代码
let x = 10;
let y = 10;

let rx = &x;
let ry = &y;

let rrx = &rx;
let rry = &ry;

assert!(rrx <= rry);
assert!(rrx == rry);

虽然 rrxrry 指向的是不同的值(rxry),这里的断言最终仍然会成功,因为 == 运算符会追踪所有引用并对它们的最终目标 xy 进行比较。这几乎总是你期望的行为,尤其是在编写泛型函数时。如果你真想知道两个引用是否指向同一块内存,可以使用 std::ptr::eq,它会将两者作为地址进行比较:

perl 复制代码
assert!(rx == ry);              // 它们引用的目标值相等
assert!(!std::ptr::eq(rx, ry)); // 但所占据的地址(自身的值)不同

但要注意,比较运算符的操作数(包括引用型操作数)必须具有完全相同的类型。

ini 复制代码
assert!(rx == rrx);    // 错误:`&i32`与`&&i32`的类型不匹配
assert!(rx == *rrx);   // 这样没问题

5.2.5 引用永不为空

Rust 的引用永远都不会为空。与 C 的 NULL 或 C++ 的 nullptr 类似的东西是不存在的。引用没有默认初始值(在初始化之前不能使用任何变量,无论其类型如何),并且 Rust 不会将整数转换为引用(在 unsafe 代码外)。因此,不能将 0 转换成引用。

C 代码和 C++ 代码通常会使用空指针来指示值的缺失:当可用内存充足时,malloc 函数会返回指向新内存块的指针,否则会返回 nullptr。在 Rust 中,如果需要用一个值来表示对某个"可能不存在"事物的引用,请使用类型 Option<&T>。在机器码级别,Rust 会将 None 表示为空指针,将 Some(r) 表示为非零地址(其中 r&T 型的值),因此 Option<&T> 与 C 或 C++ 中的可空指针一样高效,但更安全:它的类型要求你在使用之前必须检查它是否为 None

5.2.6 借用任意表达式结果值的引用

C 和 C++ 只允许将 & 运算符应用于某些特定种类的表达式,而 Rust 允许借用任意种类的表达式结果值的引用:

scss 复制代码
fn factorial(n: usize) -> usize {
    (1..n+1).product()
}
let r = &factorial(6);
// 数学运算符可以"看穿"一层引用
assert_eq!(r + &1009, 1729);

在这种情况下,Rust 会创建一个匿名变量来保存此表达式的值,并让该引用指向它。这个匿名变量的生命周期取决于你对引用做了什么。

  • let 语句中,如果立即将引用赋值给某个变量(或者使其成为立即被赋值的某个结构体或数组的一部分),那么 Rust 就会让匿名变量存在于 let 初始化此变量期间。在前面的示例中,Rust 就会对 r 的引用目标这样做。
  • 否则,匿名变量会一直存续到所属封闭语句块的末尾。在我们的示例中,为保存 1009 而创建的匿名变量只会存续到 assert_eq! 语句的末尾。

如果你习惯于使用 C 或 C++,那么这可能听起来很容易出错。但别忘了,Rust 永远不会让你写出可能生成悬空引用的代码。只要引用可能在匿名变量的生命周期之外被使用,Rust 就一定会在编译期间报告问题,然后你就可以通过将引用保存在具有适当生命周期的命名变量中来修复代码。

5.2.7 对切片和特型对象的引用

迄今为止,我们展示的引用全都是简单地址。但是,Rust 还包括两种胖指针,即携带某个值地址的双字值,以及要正确使用该值所需的某些额外信息。

对切片的引用就是一个胖指针,携带着此切片的起始地址及其长度。第 3 章详细讲解过切片。

Rust 的另一种胖指针是特型对象,即对实现了指定特型的值的引用。特型对象会携带一个值的地址和指向适用于该值的特型实现的指针,以便调用特型的方法。11.1.1 节会详细介绍特型对象。

除了会携带这些额外数据,切片和特型对象引用的行为与本章中已展示过的其他引用是一样的:它们并不拥有自己的引用目标、它们的生命周期也不允许超出它们的引用目标、它们可能是可变的或共享的,等等。

5.3 引用安全

正如前面介绍过的那样,引用看起来很像 C 或 C++ 中的普通指针。但普通指针是不安全的,Rust 又如何保持对引用的全面控制呢?或许了解规则的最佳方式就是尝试打破规则。

为了传达基本思想,我们将从最简单的案例开始,展示 Rust 如何确保在单个函数体内正确使用引用。然后我们会看看如何在函数之间传递引用并将它们存储到数据结构中。这需要为函数和数据类型提供生命周期参数(稍后会对其进行解释)。最后我们会介绍 Rust 提供的一些简写形式,以简化常见的使用模式。在整个过程中,我们将展示 Rust 如何找出损坏的代码,并不时提出解决方案。

5.3.1 借用局部变量

这是一个非常浅显的案例。你不能借用对局部变量的引用并将其移出变量的作用域:

ini 复制代码
{
    let r;
    {
        let x = 1;
        r = &x;
    }
    assert_eq!(*r, 1);  // 错误:试图读取`x`所占用的内存
}

Rust 编译器会拒绝此程序,并显示详细的错误消息:

perl 复制代码
error: `x` does not live long enough
   |
7  |         r = &x;
   |             ^^ borrowed value does not live long enough
8  |     }
   |     - `x` dropped here while still borrowed
9  |     assert_eq!(*r, 1);  // 错误:试图读取`x`所占用的内存
10 |     }

Rust 报错说 x 只能存续至内部块的末尾,而引用(r)会一直存续至外部块的末尾,这就让它成了悬空指针,这是被禁止的。

虽然对人类读者来说这个程序很明显是错误的,但还是值得研究一下 Rust 本身如何得出的这个结论。即使是这么简单的例子,也能展示出 Rust 用来检查更复杂代码的一些逻辑工具。

Rust 会尝试为程序中的每个引用类型分配一个生命周期,以表达根据其使用方式应施加的约束。生命周期是程序的一部分,可以确保引用在下列位置都能被安全地使用:语句中、表达式中、某个变量的作用域中等。生命周期完全是 Rust 在编译期虚构的产物。在运行期,引用只是一个地址,它的生命周期只是其类型的一部分,不存在运行期表示。

在这个例子中,我们要分析 3 个生命周期之间的关系。变量 rx 都有各自的生命周期,从它们被初始化的时间点一直延续到足以让编译器断定不再使用它们的时间点。第三个生命周期是引用类型,即借用了 x 并存储在 r 中的引用类型。

这里有一个显而易见的约束:如果有一个变量 x,那么对 x 的引用的生命周期不能超出 x 本身,如图 5-3 所示。

图 5-3:&x 的容许生命周期

x 超出作用域时,其引用将是一个悬空指针。为此,我们说变量的生命周期必须涵盖借用过它的引用的生命周期。

这是另一个约束:如果将引用存储在变量 r 中,则引用类型必须在变量 r 从初始化到最后一次使用的整个生命周期内都可以访问,如图 5-4 所示。

图 5-4:存储在 r 中的引用的容许生命周期

如果引用的生命周期不能至少和变量 r 一样长,那么在某些时候变量 r 就会变成悬空指针。为此,我们说引用的生命周期必须涵盖变量 r 的生命周期。

第一个约束限制了引用的生命周期可以有多大,而第二个约束则限制了它可以有多小。Rust 会尝试找出能让每个引用都满足这两个约束的生命周期。然而我们的示例中并不存在这样的生命周期,如图 5-5 所示。

图 5-5:引用的生命周期,其各项约束存在矛盾

现在来看一个不一样的例子,这次就行得通了。还是同样的约束:引用的生命周期必须包含在 x 中,但也要完全涵盖 r 的生命周期。因为现在 r 的生命周期变小了,所以会有一个生命周期满足这些约束,如图 5-6 所示。

图 5-6:引用的生命周期涵盖了 r 的生命周期且同时位于 x 的作用域内

当你借用大型数据结构中某些部分(比如向量的元素)的引用时,会自然而然地应用这些规则:

ini 复制代码
let v = vec![1, 2, 3];
let r = &v[1];

由于 v 拥有一个向量,此向量又拥有自己的元素,因此 v 的生命周期必须涵盖 &v[1] 引用类型的生命周期。类似地,如果将一个引用存储于某个数据结构中,则此引用的生命周期也必须涵盖那个数据结构的生命周期。如果构建一个由引用组成的向量,则所有这些引用的生命周期都必须涵盖拥有该向量的变量的生命周期。

这是 Rust 用来处理所有代码的过程的本质。引入更多的语言特性(比如数据结构和函数调用),必然会引入一些全新种类的约束,但基本原则保持不变:首先,要了解程序中使用各种引用的方式带来的约束;其次,找出能同时满足这些约束的生命周期。这与 C 和 C++ 程序员不得不人工担负的过程没有多大区别,唯一的区别是 Rust 知道这些规则并会强制执行。

5.3.2 将引用作为函数参数

当我们传递对函数的引用时,Rust 要如何确保函数能安全地使用它呢?假设我们有一个函数 f,它会接受一个引用并将其存储在全局变量中。我们需要对此进行一些修改,下面是第一个版本:

rust 复制代码
// 这段代码有一系列问题,无法编译
static mut STASH: &i32;
fn f(p: &i32) { STASH = p; }

在 Rust 中,全局变量的等价物称为静态变量(static):它是在程序启动时就会被创建并一直存续到程序终止时的值。(与任何其他声明一样,Rust 的模块系统会控制静态变量在何处可见,因此这只表示它们的生命周期是"全局"的,并不表示它们全局可见。)第 8 章会介绍静态变量,现在我们只讲一下刚才展示的代码违反了哪些规则。

  • 每个静态变量都必须初始化。
  • 可变静态变量本质上不是线程安全的(毕竟,任何线程都可以随时访问静态变量),即使在单线程的程序中,它们也可能成为一些另类可重入性问题的牺牲品。由于这些原因,你只能在 unsafe 块中访问可变静态变量。在这个例子中,我们并不关心那几个具体问题,所以只是把可变静态变量扔到了一个 unsafe 块中。

经过这些修改,现在我们有了以下内容:

rust 复制代码
static mut STASH: &i32 = &128;
fn f(p: &i32) { // 仍然不够理想
    unsafe {
        STASH = p;
    }
}

就快完工了。要查看剩下的问题,就要把 Rust 替我们省略的一些代码写出来。此处所写的 f 的签名实际上是以下内容的简写:

rust 复制代码
fn f<'a>(p: &'a i32) { ... }

这里,生命周期 'a(读作"tick A")是 f生命周期参数<'a> 的意思是"对于任意生命周期 'a",因此当我们编写 fn f<'a>(p: &'a i32) 时,就定义了一个函数,该函数能接受对具有任意生命周期 'ai32 型引用。

因为必须允许 'a 是任意生命周期,所以如果它是可能的最小生命周期(一个恰好涵盖对 f 调用的生命周期),那么问题就能轻易解决。接下来赋值语句就成了争论的焦点:

ini 复制代码
STASH = p;

由于 STASH 会存续在程序的整个执行过程中,因此它所持有的引用类型必须具有等长的生命周期,Rust 将此称为 'static 生命周期 。但是指向 p 的引用的生命周期是 'a,它可以是任何能涵盖对 f 调用的生命周期。所以,Rust 拒绝了我们的代码:

go 复制代码
error: explicit lifetime required in the type of `p`
  |
5 |         STASH = p;
  |                 ^ lifetime `'static` required

在这个时间点,很明显我们的函数不能接受任意引用作为参数。但正如 Rust 指出的那样,它应当接受具有 'static 生命周期的引用:在 STASH 中存储这样的引用不会创建悬空指针。事实上,下面的代码就编译得很好:

rust 复制代码
static mut STASH: &i32 = &10;

fn f(p: &'static i32) {
    unsafe {
        STASH = p;
    }
}

这一次,f 的签名指出 p 必须是生命周期为 'static 的引用,因此将其存储在 STASH 中不会再有任何问题。我们只能用对其他静态变量的引用来调用 f,但这是唯一一种肯定不会让 STASH 悬空的方式。所以可以像下面这样写:

ini 复制代码
static WORTH_POINTING_AT: i32 = 1000;
f(&WORTH_POINTING_AT);

由于 WORTH_POINTING_AT 是静态变量,因此 &WORTH_POINTING_AT 的类型是 &'static i32,将该类型传给 f 是安全的。

不过,可以退后一步,来看看在修改成正确方法时,f 的签名发生了哪些变化:原来的 f(p: &i32) 最后变成了 f(p: &'static i32)。换句话说,我们无法编写在全局变量中潜藏一个引用却不在函数签名中明示该意图的函数。在 Rust 中,函数的签名总会揭示出函数体的行为。

相反,如果确实看到一个带有 g(p: &i32) 签名的函数(或者带着生命周期写成 g<'a>(p: &'a i32)),那么就可以肯定它没有 将其参数 p 藏在任何超出此调用点的地方。无须查看 g 的具体定义,签名本身就可以告诉我们 g 用它的参数能做什么,不能做什么。当你尝试为函数调用建立安全保障时,这一认知会非常有价值。

5.3.3 把引用传给函数

我们刚刚揭示了函数签名与其函数体的关系,下面再来看一下函数签名与其调用者的关系。假设你有以下代码:

rust 复制代码
// 这个函数可以简写为fn g(p: &i32),但这里还是把它的生命周期写出来了
fn g<'a>(p: &'a i32) { ... }

let x = 10;
g(&x);

只要看看 g 的签名,Rust 就知道它不会将 p 保存在生命周期可能超出本次调用的任何地方:包含本次调用的任何生命周期都必须符合 'a 的要求。所以 Rust 为 &x 选择了尽可能短的生命周期,即调用 g 时的生命周期。这满足所有约束:它的生命周期不会超出 x,并且会涵盖对 g 的完整调用。所以这段代码通过了审核。

请注意,虽然 g 有一个生命周期参数 'a,但调用 g 时并不需要提及它。只要在定义函数和类型时关心生命周期参数就够了,使用它们时,Rust 会为你推断生命周期。

如果试着将 &x 传给之前要求其参数存储在静态变量中的函数 f 会怎样呢?

ini 复制代码
fn f(p: &'static i32) { ... }

let x = 10;
f(&x);

这无法编译:引用 &x 的生命周期不能超出 x,但通过将它传给 f,又限制了它必须至少和 'static 一样长。没办法做到两全其美,所以 Rust 只好拒绝了这段代码。

5.3.4 返回引用

函数通常会接收某个数据结构的引用,然后返回对该结构的某个部分的引用。例如,下面是一个函数,它会返回对切片中最小元素的引用:

ini 复制代码
// v应该至少有一个元素
fn smallest(v: &[i32]) -> &i32 {
    let mut s = &v[0];
    for r in &v[1..] {
        if *r < *s { s = r; }
    }
    s
}

我们依惯例在该函数的签名中省略了生命周期。当函数以单个引用作为参数并返回单个引用时,Rust 会假定两者具有相同的生命周期。如果把生命周期明确地写出来,则能看得更清楚:

rust 复制代码
fn smallest<'a>(v: &'a [i32]) -> &'a i32 { ... }

假设我们是这样调用 smallest 的:

ini 复制代码
let s;
{
    let parabola = [9, 4, 1, 0, 1, 4, 9];
    s = smallest(&parabola);
}
assert_eq!(*s, 0); // 错误:指向了已被丢弃的数组的元素

smallest 的签名可以看出它的参数和返回值必须具有相同的生命周期 'a。在我们的调用中,参数 &parabola 的生命周期不得超出 parabola 本身,但 smallest 的返回值的生命周期必须至少和 s 一样长。生命周期 'a 不可能同时满足这两个约束,因此 Rust 拒绝执行这段代码:

perl 复制代码
error: `parabola` does not live long enough
   |
11 |         s = smallest(&parabola);
   |                       -------- borrow occurs here
12 |     }
   |     ^ `parabola` dropped here while still borrowed
13 |     assert_eq!(*s, 0); // 错误:指向了已被丢弃的数组的元素
   |                 - borrowed value needs to live until here
14 |     }

移动 s,让其生命周期完全包含在 parabola 内就可以解决问题:

ini 复制代码
{
    let parabola = [9, 4, 1, 0, 1, 4, 9];
    let s = smallest(&parabola);
    assert_eq!(*s, 0); // 很好:parabola仍然"活着"
}

函数签名中的生命周期能让 Rust 评估你传给函数的引用与函数返回的引用之间的关系,并确保安全地使用它们。

5.3.5 包含引用的结构体

Rust 如何处理存储在数据结构中的引用呢?下面仍然是之前那个出错的程序,但这次我们将引用"藏"在了结构体中:

ini 复制代码
// 这无法编译
struct S {
    r: &i32
}

let s;
{
    let x = 10;
    s = S { r: &x };
}
assert_eq!(*s.r, 10); // 错误:从已被丢弃的`x`中读取

Rust 对引用的安全约束不会因为我们将引用"藏"在结构体中而神奇地消失。无论如何,这些约束最终也必须应用在 S 上。的确,Rust 提出了质疑:

yaml 复制代码
error: missing lifetime specifier
  |
7 |         r: &i32
  |            ^ expected lifetime parameter

每当一个引用类型出现在另一个类型的定义中时,必须写出它的生命周期。可以这样写:

rust 复制代码
struct S {
    r: &'static i32
}

这表示 r 只能引用贯穿程序整个生命周期的 i32 值,这种限制太严格了。还有一种方法是给类型指定一个生命周期参数 'a 并将其用在 r 上:

rust 复制代码
struct S<'a> {
    r: &'a i32
}

现在 S 类型有了一个生命周期,就像引用类型一样。你创建的每个 S 类型的值都会获得一个全新的生命周期 'a,它会受到该值的使用方式的限制。你存储在 r 中的任何引用的生命周期最好都涵盖 'a,并且 'a 必须比存储在 S 中的任何内容的生命周期都要长。

回到前面的代码,表达式 S { r: &x } 创建了一个新的 S 值,其生命周期为 'a。当你将 &x 存储在 r 字段中时,就将 'a 完全限制在了 x 的生命周期内部。

赋值语句 s = S { ... } 会将此 S 存储在一个变量中,该变量的生命周期会延续到示例的末尾,这种限制决定了 'as 的生命周期更长。现在 Rust 遇到了与之前一样矛盾的约束:'a 的生命周期不能超出 x,但必须至少和 s 一样长。因为找不到两全其美的生命周期,所以 Rust 拒绝执行该代码。一场灾难提前化解了。

如果将具有生命周期参数的类型放置在其他类型中会怎样呢?

arduino 复制代码
struct D {
    s: S  // 不合格
}

Rust 提出了质疑,就像试图在 S 中放置一个引用而未指定其生命周期一样:

go 复制代码
error: missing lifetime specifier
  |
8 |     s: S  // 不合格
  |        ^ expected named lifetime parameter
  |

不能在这里省略 S 的生命周期参数:Rust 需要知道 D 的生命周期和其引用的 S 的生命周期之间是什么关系,以便对 D 进行与"S 和普通引用"一样的检查。

可以给 s 一个 'static 生命周期。这样没问题:

swift 复制代码
struct D {
    s: S<'static>
}

使用这种定义,s 字段只能借用存续于整个程序执行过程中的值。这会带来一定的限制,但它确实表明 D 不可能借用局部变量,而 D 本身的生命周期并没有特殊限制。

来自 Rust 的错误消息其实建议了另一种方法,这种方法更通用:

rust 复制代码
help: consider introducing a named lifetime parameter
  |
7 | struct D<'a> {
8 |     s: S<'a>
  |

这一次,为 D 提供生命周期参数并将其传给 S

rust 复制代码
struct D<'a> {
    s: S<'a>
}

通过获取生命周期参数 'a 并在 s 的类型中使用它,我们允许 Rust 将 D 值的生命周期和其 S 类型字段持有的引用的生命周期关联起来。

我们之前展示过函数的签名如何明确表达出它对我们传给它的引用做了什么。现在我们在类型方面展示了类似的做法:类型的生命周期参数总会揭示它是否包含某些值得关心其生命周期的引用(也就是非 'static 的)以及这些生命周期可以是什么。

假设我们有一个解析函数,它会接受一个字节切片并返回一个存有解析结果的结构:

rust 复制代码
fn parse_record<'i>(input: &'i [u8]) -> Record<'i> { ... }

不用看 Record 类型的定义就可以知道,如果从 parse_record 接收到 Record,那么它包含的任何引用就必然指向我们传入的输入缓冲区,而不是其他地方('static 静态值除外)。

事实上,Rust 要求包含引用的类型都要接受显式生命周期参数就是为了明示这种内部行为。其实 Rust 原本可以简单地为结构体中的每个引用创建一个不同的生命周期,从而省去把它们写出来的麻烦。实际上,Rust 的早期版本就是这么做的,但开发人员发现这样会令人困惑:了解"某个值是从另一个值中借用出来的"这一点很有帮助,特别是在处理错误时。

不仅像 S 这样的引用和类型有生命周期,Rust 中的每个类型都有生命周期,包括 i32String。它们大多数是 'static 的,这意味着这些类型的值可以一直存续下去,例如,Vec<i32> 是自包含的,在任何特定变量超出作用域之前都不需要丢弃它。但是像 Vec<&'a i32> 这样的类型,其生命周期就必须被 'a 涵盖,也就是说必须在引用目标仍然存续的情况下丢弃它。

5.3.6 不同的生命周期参数

假设你已经定义了一个包含两个引用的结构体,如下所示:

rust 复制代码
struct S<'a> {
    x: &'a i32,
    y: &'a i32
}

这两个引用使用相同的生命周期 'a。如果这样写代码,那么可能会有问题:

ini 复制代码
let x = 10;
let r;
{
    let y = 20;
    {
        let s = S { x: &x, y: &y };
        r = s.x;
    }
}
println!("{}", r);

上述代码不会创建任何悬空指针。对 y 的引用会保留在 s 中,它会在 y 之前超出作用域。对 x 的引用最终会出现在 r 中,它的生命周期不会超出 x

然而,如果你尝试编译这段代码,那么 Rust 会报错说 y 的存活时间不够长,但其实它看起来是足够长的。为什么 Rust 会担心呢?如果仔细阅读代码,就能明白其推理过程。

  • S 的两个字段是具有相同生命周期 'a 的引用,因此 Rust 必须找到一个同时适合 s.xs.y 的生命周期。
  • 赋值 r = s.x,这就要求 'a 涵盖 r 的生命周期。
  • &y 初始化 s.y,这就要求 'a 不能长于 y 的生命周期。

这些约束是不可能满足的:没有哪个生命周期比 y 短但比 r 长。Rust 被迫止步于此。

出现这个问题是因为 S 中的两个引用具有相同的生命周期 'a。只要更改 S 的定义,让每个引用都有各自的生命周期,就可以解决所有问题:

rust 复制代码
struct S<'a, 'b> {
    x: &'a i32,
    y: &'b i32
}

根据这个定义,s.xs.y 具有独立的生命周期。对 s.x 所做的操作不会影响 s.y 中存储的内容,因此现在很容易满足约束条件:'a 可以用 r 的生命周期,而 'b 可以用 s 的生命周期。(虽然 'b 也可以用 y 的生命周期,但 Rust 会尝试选择可行的最小生命周期。)一切都好起来了。

函数签名也有类似的情况。假设有这样一个函数:

rust 复制代码
fn f<'a>(r: &'a i32, s: &'a i32) -> &'a i32 { r } // 可能过于严格

在这里,两个引用参数使用了相同的生命周期 'a,这可能会给调用者施加不必要的限制,就像前面讲过的那样。如果这确实是问题,可以让各个参数的生命周期独立变化:

rust 复制代码
fn f<'a, 'b>(r: &'a i32, s: &'b i32) -> &'a i32 { r } // 宽松多了

这样做的缺点是,添加生命周期会让类型和函数签名更难阅读。我们(本书作者)倾向于先尝试尽可能简单的定义,然后放宽限制,直到代码能编译通过为止。由于 Rust 不允许不安全的代码运行,因此简单地等到报告问题时再修改也是一种完全可以接受的策略。

5.3.7 省略生命周期参数

迄今为止,本书已经展示了很多返回引用或以引用为参数的函数,但通常没必要详细说明每个生命周期。生命周期就在那里,如果它们显而易见,那么 Rust 就允许我们省略。

在最简单的情况下,你可能永远不需要为参数写出生命周期。Rust 会为需要生命周期的每个地方分配不同的生命周期。例如:

rust 复制代码
struct S<'a, 'b> {
    x: &'a i32,
    y: &'b i32
}

fn sum_r_xy(r: &i32, s: S) -> i32 {
    r + s.x + s.y
}

此函数的签名是以下代码的简写形式:

rust 复制代码
fn sum_r_xy<'a, 'b, 'c>(r: &'a i32, s: S<'b, 'c>) -> i32

如果确实要返回引用或其他带有生命周期参数的类型,那么针对无歧义的情况,Rust 会尽量采用简单的设计。如果函数的参数只有一个生命周期,那么 Rust 就会假设返回值具有同样的生命周期:

rust 复制代码
fn first_third(point: &[i32; 3]) -> (&i32, &i32) {
    (&point[0], &point[2])
}

明确写出所有生命周期后的代码如下所示:

rust 复制代码
fn first_third<'a>(point: &'a [i32; 3]) -> (&'a i32, &'a i32)

如果函数的参数有多个生命周期,那么就没有理由选择某一个生命周期作为返回值的生命周期,Rust 会要求你明确指定生命周期。

笔记

JavaScript中有作用域的概念,生命周期和作用域本质上是类似的,都是定义变量的,变量在什么时候可以用,什么时候不可用

但是,如果函数是某个类型的方法,并且具有引用类型的 self 参数,那么 Rust 就会假定返回值的生命周期与 self 参数的生命周期相同。(self 指的是调用方法的对象,类似于 C++、Java 或 JavaScript 中的 this 或者 Python 中的 self。9.6 节会介绍这些方法。)

例如,你可以编写以下内容:

rust 复制代码
struct StringTable {
    elements: Vec<String>,
}

impl StringTable {
    fn find_by_prefix(&self, prefix: &str) -> Option<&String> {
        for i in 0 .. self.elements.len() {
            if self.elements[i].starts_with(prefix) {
                return Some(&self.elements[i]);
            }
        }
        None
    }
}

find_by_prefix 方法的签名是以下内容的简写形式:

rust 复制代码
fn find_by_prefix<'a, 'b>(&'a self, prefix: &'b str) -> Option<&'a String>

Rust 假定无论你借用的是什么,本质上都是从 self 借用的。

再次强调,这些都只是简写形式,旨在提供便利且比较直观。如果它们不是你想要的,那么你随时可以明确地写出生命周期。

欢迎大家讨论交流 Rust,如果喜欢本文章或感觉文章有用,动动你那发财的小手点个赞再走呗 ^_^

微信公众号:草帽Lufei

相关推荐
hlsd#21 分钟前
go mod 依赖管理
开发语言·后端·golang
四喜花露水24 分钟前
Vue 自定义icon组件封装SVG图标
前端·javascript·vue.js
陈大爷(有低保)26 分钟前
三层架构和MVC以及它们的融合
后端·mvc
亦世凡华、26 分钟前
【启程Golang之旅】从零开始构建可扩展的微服务架构
开发语言·经验分享·后端·golang
河西石头27 分钟前
一步一步从asp.net core mvc中访问asp.net core WebApi
后端·asp.net·mvc·.net core访问api·httpclient的使用
前端Hardy33 分钟前
HTML&CSS: 实现可爱的冰墩墩
前端·javascript·css·html·css3
2401_8574396938 分钟前
SpringBoot框架在资产管理中的应用
java·spring boot·后端
怀旧66640 分钟前
spring boot 项目配置https服务
java·spring boot·后端·学习·个人开发·1024程序员节
阿华的代码王国1 小时前
【SpringMVC】——Cookie和Session机制
java·后端·spring·cookie·session·会话
web Rookie1 小时前
JS类型检测大全:从零基础到高级应用
开发语言·前端·javascript