好文档 - 专业文书写作范文服务资料分享网站

深入理解 Rust 中的生命周期 - 图文

天下 分享 时间: 加入收藏 我要投稿 点赞

1. 前言从 C++ 来到 Rust 并需要学习生命周期,非常类似于从 Java 来到 C++ 并需要学习指针。起初,它看起来是一个不必要的概念,是编译器应该处理好的东西。后来,当你意识到它赋予你更多的力量——在 Rust 中,它带来了更多的安全性和更好的优化 -- 你感到兴奋,想掌握它,但却失败了,因为它并不直观,很难找到形式化的规则。可以说,C++ 指针比 Rust 生命周期更容易沉浸其中,因为 C++ 指针在代码中随处可见,而 Rust 生命周期通常隐藏在大量的语法糖背后。所以你最终会在语法糖不适用的时候接触生命周期,这通常是一些复杂的情况。当你面临的只有这些复杂情况时,你很难内化这个概念。2. 引言--对于生命周期,需要记住的第一件事就是,它们全都是关于引用(references)的,与其他东西无关。例如,当我们看到一个带有生命周期(lifetime)类型参数的结构体时,它指的是这个结构体所拥有的引用的生命周期,再无其他。不存在结构体的生命周期或者闭包的生命周期,只有结构体或闭包内部引用的生命周期。因此,我们对生命周期的讨论会不可避免地涉及到 Rust 引用。2.1 生命周期背后的动机要理解生命周期,我们首先需要理解其背后的动机,这就要求我们先理解借用规则背后的动机。借用规则中指出:在代码中,存在对重叠内存的引用,也称为别名(aliasing),它们中至少有一个会变更(mutate)内存中的内容。同时变更是不允许的,因为这样是不安全的,并且它阻碍编译器进行各种优化。2.2 示例假定我们现在想要写一个函数,该函数将一个坐标沿着 x 轴在给定方向上移动两倍的距离。struct Coords { pub x: i64, pub y: i64, } fn shift_x_twice(coords: &mut Coords, delta: &i64) { coords.x += *delta; coords.x += *delta; } fn main() { let mut a = Coords{x: 10, y: 10}; let delta_a = 10; shift_x_twice(&mut a, &delta_a); // All good. let mut b = Coords{x: 10, y: 10}; let delta_b = &b.x; // shift_x_twice(&mut b, delta_b); // Compilation failure. } 最后一条语句会把坐标移动三倍距离而不是两倍,这可能会在生产系统中引发各种 bug。关键问题在于,delta_b和&mut b指向一块重叠的内存,而这在 Rust 中是被生命周期和借用规则所阻止的。尤其是,Rust 编译器会提醒,delta_b要求持有一个b的不可变引用直到main()结束,但是在那个作用域内,我们还试图创建一个b的可变引用,这是被禁止的。为了能够进行借用规则检查,编译器需要知道所有引用的生命周期。在很多情况下,编译器能够自己推导出生命周期,但是有些情况它无法完成,这就需要开发者手动的对生命周期进行标注。此外,编译器还给开发者提供了工具,例如,我们可以要求所有实现了某个特定 trait 的结构体,其所有引用至少在给定的时间段内都是有效的。对比 Rust 的引用和 C++ 中的引用,在 C++ 中,我们也可以有常量(const)和非常量(non-const)引用,类似于 Rust 中的&x和&mut x。但是,C++ 中没有生命周期。常量引用(const reference)能够帮助 C++ 编译器进行优化,但是它们不能给出完整的安全性保证。所以,上面的示例如果用 C++ 来写是可以编译通过的。2.3 脱糖(Desugaring)在我们深入理解生命周期之前,我们需要弄清生命周期是什么,因为各种 Rust 文档用生命周期这个词既指代作用域(scope)也指代类型参数(type-parameter)。在这里,我们用生命周期(lifetime ) 表示一个作用域,用生命周期参数(lifetime-parameter ) 来表示一个参数,编译器会用一个真正的生命周期来替换这个参数,就像它在推导泛型时那样。2.4 示例为了让解释更加清晰,我们将会对一些 Rust 代码进行脱糖(译注:指脱去语法糖)。考虑下面的代码:fn announce(value: &impl Display) { println!(\} fn main() { let num = 42; let num_ref = # announce(num_ref); } 下面是脱糖的版本:fn announce<'a, T>(value: &'a T) where T: Display { println!(\} fn main() { 'x: { let num = 42; 'y: { let num_ref = &'y num; 'z: { announce(num_ref); } } } } 后面脱糖的代码使用生命周期参数'a和生命周期 / 作用域'x,'y进行了显式的标注。我们还使用impl Display来比较生命周期参数和一般的类型参数。注意这里语法糖是如何把生命周期参数'a和类型参数T都隐藏起来的。注意,作用域并不是 Rust 语法的一部分,我们只是用它来标注,所以脱糖后的代码是无法编译的。而且,在这个以及后面的示例中,我们忽略了在 Rust 2024 中加入的非词法生命周期(non-lexical lifetimes)以简化我们的解释。2.5 子类型从技术角度看,生命周期不是一个类型,因为我们无法像u64或者Vec这样的普通的类型一样构建一个生命周期的实例。然而,当我们对函数或结构进行参数化时,生命周期参数就像类型参数一样被使用,请看上面的announce示例。另外,我们后面会看到的变型规则(Variance Rule)也会像使用类型一样使用生命周期,所以我们在本文中也会称之为类型。比较生命周期和普通类型、生命周期参数和普通类型参数是有用的:当编译器为一个普通类型参数推导类型时,如果有多个类型可以满足类型参数,编译器就会报错。而在生命周期的情况下,如果有多个生命周期可以满足给定的生命周期参数,编译器将会使用最小的那个生命周期。简单的 Rust 类型没有子类型,更具体来讲,一个结构体不能是另一个结构体的子类型,除非它们有生命周期参数。但是,生命周期允许有子类型,并且,如果生命周期'longer覆盖了整个'shorter,那么'longer就是'shorter的子类型。生命周期子类型还可以对将生命周期参数化的类型进行有限的子类型化。正如我们在后面所见,它是指&'longer int是&'shorter int的子类型。'static生命周期是所有生命周期的一个子类型,因为它是最长的。'static和 Java 中的Object恰好相反,Object在 Java 中是所有类型的超类型。3. 规则--3.1 强制转换和子类型Rust 有一系列规则,允许一个类型被强制转换为另一个类型。尽管强制转换和子类型很相似,但是能够区分它们也很重要。关键的不同在于,子类型没有改变底层的值,但是强制转换改变了。具体来讲,编译器在强制转换的位置插入额外的代码以执行某些底层转换,而子类型只是一个编译器检查。因为这些额外的代码对开发者是不可见的,并且强制转换和子类型看起来很相似,因为二者看起来都像这样:let b: B; ... let a: A = b; 强制转换和子类型放一起:// 这是强制转换(This is coercion): let values: [u32; 5] = [1, 2, 3, 4, 5]; let slice: &[u32] = &values; // 这是子类型(This is subtyping): let val1 = 42; let val2 = 24; 'x: { let ref1 = &'x val1; 'y: { let mut ref2 = &'y val2; ref2 = ref1; } } 这段代码能够工作,因为'x是'y的子类型,而且也因此,&'x也是&'y的子类型。通过学习一些最常见的强制转换,很容易就能区分二者,剩下的一些不常见的,见 Rustonomicon[1]指针弱化:&mut T到&T解引用:类型&T的&x到&U的类型&*x,如果T: Deref。这使得我们可以像使用普通类型一样使用智能指针[T; n]到[T]如果T: Trait,T到dyn Trait你可能想知道为什么'x是'y的子类型这件事能够推导出&'x也是&'y的子类型?要回答这个问题,我们需要讨论Variance。3.2 变型(Variance)基于前面的内容,我已经可以很容易区分生命周期'longer是否是生命周期'shorter的子类型。你甚至可以直观地理解为什么&'longer T是&'shorter T的子类型。但是,你能够区分&'a mut &'longer T是否是&'amut &'shorter T的子类型嘛?实际上做不到,要知道为什么,我们需要 Variance 规则。正如我们之前所说,生命周期能够对那些生命周期参数化的类型上进行有限的子类型化。变型 是类型构造器(type-constructor)的一个属性, 类型构造器是一个带有参数的类型,比如Vec或者&mut T。更具体的,变型决定了参数的子类型化如何影响结果类型的子类型化。如果类型构造器有多个参数,比如F<'a, T, U>或者&'b mut V,那么变型就针对每个参数单独计算。有三种类型的变型:如果F是F的子类型(subtype), F是T的协变(convarinat) 。如果F是F的超类型(supertype),那么F是T的逆变(contravariant)。如果F既不是F的子类型,也不算F的超类型,它们不兼容,F是T的不变(invariant) 。

当类型构造器有多个参数时,我们这样来讨论单个的变型,例如,F<'a, T>是'a的协变并且是T的不变。而且,还有第四种类型的变型 - 二变体,但它是一个特定的编译器实现细节,这里我们不需要了解。下面是一张针对最常见的类型构造器的变型表格:

协变基本上是一个传递规则。逆变很少见,并且只发生在当我们传递指针到一个使用了更高级别 trait 约束 [2]的函数时才会发生,不变是最重要的,当我们开始组合变型时,我们会看到它的动机。

3.3 变型运算(Variance arithmetic)

现在我们知道&'a mut T和Vec的子类型和超类型是什么了,但是我们知道&'a mut Vec和Vec<&'amut T>的子类型和超类型是什么嘛?要回答这个问题,我们需要知道如何组合类型构造器的 variance。组合变型有两种数学运算:Transform 和最大下确界(greatest lower bound, GLB )。Transform 用于类型组合,而 GLB 用于所有的聚合体:结构体、元组、枚举以及联合体。让我们分别用 0、+、和 - 来表示不变,协变和逆变。然后 Transform(X)和 GLB(^)可以用下面两张表来表示:

深入理解 Rust 中的生命周期 - 图文

1.前言从C++来到Rust并需要学习生命周期,非常类似于从Java来到C++并需要学习指针。起初,它看起来是一个不必要的概念,是编译器应该处理好的东西。后来,当你意识到它赋予你更多的力量——在Rust中,它带来了更多的安全性和更好的优化--你感到兴奋,想掌握它,但却失败了,因为它并不直观,很难找到形式化的规则。可以说,C++指针比Rust生命周期更容易沉浸其
推荐度:
点击下载文档文档为doc格式
2bap710h0k7zlrl1bkfq6d7jn4l91z0135x
领取福利

微信扫码领取福利

微信扫码分享