凛ノブログ

Eat, Sleep & Daydream

告别linter驱动编程(※怎么可能) - Rust生命周期

草,第一次感觉需要给 blog 加一个 Toc。还有不喜欢这个 typography。

学习 Rust 很长一段时间,我虽然理解「生命周期」这个概念,但在实际写代码时,往往不知道什么时候该标注,标注有时也看不懂💦,都是靠 linter 提示,我甚至觉得编译器有能力推断出大部分生命周期。

这次借助喷气大脑学院的课程重新学习了下。

前置知识:所有权和借用

这个在单线程的模型里应该比较好理解,感觉没必要写了。

为什么要有生命周期?

在大多数有 GC(垃圾回收)的语言中,我们不需要关心对象活多久。但在 Rust 中,为了保证内存安全且无需 GC,编译器必须确保:引用的数据在引用被使用期间必须是有效的,换句话说引用的生命周期不能长于它所引用的数据的生命周期,这是为了防止悬垂指针。

fn main() {
    let i = 3; // Lifetime for `i` starts. ────────────────┐
    {                                                      │
        let borrow = &i; // `borrow` lifetime starts.   ──┐│
        println!("borrow: {}", borrow);                   ││
    } // `borrow` ends. ──────────────────────────────────┘│
}   // Lifetime ends. ─────────────────────────────────────┘

虽然此处 borrow 的作用域到花括号结束,但在现代 Rust (NLL) 中,引用的实际有效生命周期是在最后一次使用处结束。

自动省略规则(什么时候可以不写)

Rust 有一组借用省略规则(lifetime elision),所以常见简单情况不需显式标注。

  1. 第一条规则是每一个是引用的参数都有它自己的生命周期参数。

  2. 如果只有一个输入生命周期参数,那么它被赋予所有输出生命周期参数:

    fn foo(x: &str) -> &str { x }

编译器自动把返回值和输入 x 绑定。

  1. 如果输入参数中有 &self 或 &mut self,那么 self 的生命周期会被赋给所有输出生命周期参数。
    impl A {
        fn name(&self) -> &str { &self.field }
    }

编译器假设返回值的生命周期与 self 相同。

生命周期标注

当函数返回的引用可能来自多个输入引用时,这类签名无法套用生命周期省略规则,编译器也无法自动推断,必须显式标注。

典型情况是“两个输入引用,返回其中一个”:

fn pick(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}
   | fn pick(x: &str, y: &str) -> &str {
   |            ----     -----     ^ expected named lifetime parameter
   |
   = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
   |
   | fn pick<'a>(x: &'a str, y: &'a  str) -> &'a str {
   |        ++++     ++          ++           ++

这时编译器建议了让函数的两个输入使用相同的生命周期'a

fn pick<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

这段标注的真实含义是什么? 有人会误以为这里的“相同”意味着输入的生命周期真的一样长。它们当然可能不一样长,标注只是让编译器把它们放进“同一约束里”而已。

  • 对输入的约束:x 和 y 的生命周期必须涵盖'a。换句话说,'axy 生命周期的交集(即两者中较短的那个生命周期)。
  • 对输出的约束:返回值的生命周期也不会超过 'a

生命周期标注不会改变任何引用的实际存活时间,它只是在类型签名中描述引用之间的关系,以便编译器进行安全检查。

回到调用处:

fn main() {
    let ret: &str;
    let a = String::from("Hello");
    {
        let b = String::from("World!!");
        ret = pick(a.as_str(), b.as_str());
    }
    println!("{}", ret);
}

编译器在检查 pick 的调用时,会执行以下逻辑:

  1. 确定 'a 的范围:
    • 输入 x 来自 a(活到 main 结束)。
    • 输入 y 来自 b(活到内部花括号 } 结束)。
    • 根据签名 fn pick<'a>(x: &'a str, y: &'a str)'a 必须能同时被两个输入满足。因此,编译器推断 'a 的长度不能超过 两者中较短的那个(即 b 的生命周期)。
  2. 检查返回值的有效性:
    • 函数签名承诺返回值的生命周期也是 'a
    • 这意味着 ret 的有效性被限制在了内部花括号之内。
  3. 发现冲突
    • 代码试图在内部花括号之外(println! 处)使用 ret
    • 编译器发现 ret 的推断生命周期('a,即内部作用域)无法覆盖实际使用的地方。
    • 报错:b 活得不够久。

在这个例子中,x (“Hello”) 长度 5,y (“World!!”) 长度 7。函数逻辑实际会返回 yy 确实销毁了,报错是应该的。但如果我反过来,让逻辑返回长命的 x 呢?

即使逻辑上返回的是 x,编译器依然会报错。

因为 Rust 的编译器在编译阶段只看函数签名,不看函数体内的运行时逻辑。签名 -> &'a str 告诉编译器:“返回值可能引用了 x,也可能引用了 y”。为了保证内存安全,编译器必须假设最坏的情况——即返回值引用了那个生命周期最短的数据。

Rust的编译器或许有能力从函数体推导出生命周期,但它不会这么做。除了确定性的省略规则,其余情况都是需要标注生命周期。

结构体定义中的生命周期标注

如果一个结构体的字段包含引用(reference),那么该结构体的定义必须包含生命周期参数。

这归根结底还是为了避免悬垂引用,结构体本身(借用方)的存活时间,不能超过它所引用的数据(出借方)的存活时间。

因此,我们需要在定义结构体时显式声明生命周期参数(如 'a),并将其应用到对应的引用字段上。这意味着:“该结构体实例的存活时间,不能超过生命周期 'a”。

// 1. 在结构体名称后声明生命周期参数 <'a>
// 2. 在引用类型的字段中使用该生命周期 &'a str
struct ImportantExcerpt<'a> {
    part: &'a str,
}

方法定义中的生命周期标注

当我们在具有生命周期的结构体上实现方法时,语法可能会让人感到一点点繁琐,因为我们需要在两个地方声明生命周期:impl 关键字之后和方法签名中。

impl 块的标注

如果是给一个带有生命周期的结构体写方法,必须在 impl 之后声明生命周期,以便在结构体名称中使用它。

struct ImportantExcerpt<'a> {
    part: &'a str,
}

// 这里的 <'a> 是为了声明 impl 块中用到的生命周期
// 后面的 ImportantExcerpt<'a> 是在使用这个生命周期
impl<'a> ImportantExcerpt<'a> {
    
    // 根据省略规则,返回值的生命周期默认与 &self 相同(如果返回值是引用)
    fn level(&self) -> i32 {
        3
    }
}

方法签名的独立生命周期

有时候,方法参数的生命周期和结构体本身的生命周期('a)是独立的。这时我们可以在方法名称后声明新的生命周期参数。

impl<'a> ImportantExcerpt<'a> {
    // 这里声明了新的生命周期 'b
    // 根据省略规则,如果不写标注,返回值的生命周期会默认跟 &self 一样是 'a
    // 但如果我们想明确指定返回值跟第二个参数 announcement 绑定,就需要手动标注
    fn announce_and_return_part<'b>(
        &self, 
        announcement: &'b str
    ) -> &'b str {
        println!("Attention please: {}", announcement);
        announcement // 返回值的生命周期是 'b
    }
}

在这个例子中,编译器清楚地知道:ImportantExcerpt 实例活多久('a)与 announcement 活多久('b)没有必然联系,且返回值跟随的是 announcement

Trait 实现中的生命周期

有时我们需要为引用类型实现 Trait,比如为了实现 for x in &collection,我们需要为 &'a TicketStore 实现 IntoIterator

此时,Trait 内部的关联类型(如 ItemIntoIter)也必须带上生命周期,显式告诉编译器:迭代器产生的数据,其存活时间与原始引用 'a 是一致的。

// 1. 声明生命周期 <'a>,并在目标类型中使用 &'a
impl<'a> IntoIterator for &'a TicketStore {
    
    // 2. 产生的元素是引用,必须标注 &'a
    type Item = &'a Ticket;
    
    // 3. 迭代器内部持有引用,也必须标注 <'a>
    type IntoIter = std::slice::Iter<'a, Ticket>;

    fn into_iter(self) -> Self::IntoIter {
        self.tickets.iter()
    }
}

这里的关键在于:impl 声明了 'a&TicketStore, Item 的生命周期都标记为 'a, 这样编译器就能确保:只要迭代器还在用,原始的 TicketStore 就不能被销毁。

变型 强制转换

通常我们说 Rust 没有传统 OOP 那样基于类的继承(Inheritance),因此也没有结构体层面的子类型。但是,在生命周期的范畴内,Rust 确实存在子类型(Subtyping)的概念。

生命周期的子类型

规则非常直观:生命周期越长,它是越具体的子类型。

如果 'long 的生命周期比 'short 更长('long: 'short),那么:

  • 'long'short子类型(subtype)
  • 'short'long超类型(supertype)
  • 'static 是所有生命周期的子类型,'static 表示该引用指向的数据在程序的整个剩余运行期间都有效

换句话说:更长的生命周期是更具体的类型,是子类型

变型 (Variance)

变型描述的是复合类型(如 &'a TVec<T>)相对于其内部类型'aT)的兼容性关系。

简单来说就是:如果 AB 的子类型,那么 Box<A> 还是 Box<B> 的子类型吗?

主要有三种变型:

  1. 协变 (Covariant):如果 AB 的子类型,那么 Wrapper<A> 也是 Wrapper<B> 的子类型。
  2. 逆变 (Contravariant):如果 AB 的子类型,那么 Wrapper<B> 才是 Wrapper<A> 的子类型。
    • 只出现在函数的参数中。
  3. 不变 (Invariant)“不能替”。必须完全匹配,不能互相替代。
    • &mut T,为了防止将短生命周期的数据写入长生命周期的容器中。

常见类型的变型表:

类型’aTU说明
&'a T协变协变只读引用,生命周期和类型都可缩小范围
&'a mut T协变不变重要:可变引用的内容类型不能变,防止写入错误
Box<T> / Vec<T>协变拥有所有权的容器通常是协变的
UnsafeCell<T> / Cell<T>不变内部可变性类型必须不变
fn(T) -> U逆变协变参数逆变,返回值协变
*const T协变
*mut T不变

函数中的强制转换(Coercion)

fn(T) -> U 中 U 上是协变的

这意味着函数实际返回的生命周期,可以比函数签名声明的生命周期更长。

// `<'a: 'b>` 表示 'a 至少和 'b 一样长('a 是 'b 的子类型)
fn choose_first<'a: 'b, 'b>(first: &'a i32, _: &'b i32) -> &'b i32 {
    // 既然 &'a i32 是 &'b i32 的子类型,它就可以替代 &'b i32 被返回。
    first 
}

'static

这也意味着,返回一个 'static 也是合法的

fn pick<'a>(x: &'a str, y: &'a str) -> &'a str {
    "I am static string constant" // 这是一个 &'static str
}

然而,必须注意的是:Rust 编译器在检查调用端的安全性时,只看函数签名,不看函数体实现。

即使 pick 实际上返回了 'static,调用者看到的签名依然是 -> &'a str。编译器必须假设最坏的情况——即返回值可能只是其中一个输入参数的引用。

因此,下面这段代码依然无法通过编译:

fn main() {
    let ret: &str;
    let a = String::from("Hello");
    {
        let b = String::from("World!!");
        ret = pick(a.as_str(), b.as_str());
    }
    println!("{}", ret);
}

参考

Validating References with Lifetimes - The Rust Programming Language

Lifetime Elision - The Rustonomicon

Coercion - Rust By Example

本作品采用知识共享署名-非商业性使用-相同方式共享 (CC BY-NC-SA) 协议进行许可。
由于是静态页面,评论提交后不会立即显示,这里 查看提交的评论。