生命周期省略规则下 fn 返回引用的三种消除路径

写 Rust 的时候,只要函数签名里出现 & 而返回值又是引用,编译器就很可能甩出一串 error[E0106]: missing lifetime specifier。新手常见的反应是随手加 'a,加完发现 borrow checker 又在调用点报错,于是继续加,最后把一个本来十行的函数写成生命周期标注的涂鸦墙。 真正需要理解的是:省略规则(lifeti

写 Rust 的时候,只要函数签名里出现 & 而返回值又是引用,编译器就很可能甩出一串 error[E0106]: missing lifetime specifier。新手常见的反应是随手加 'a,加完发现 borrow checker 又在调用点报错,于是继续加,最后把一个本来十行的函数写成生命周期标注的涂鸦墙。

真正需要理解的是:省略规则(lifetime elision)只对「输入位置」和「输出位置」做机械匹配,它并不能替你表达「返回值到底借了谁」。当规则无法唯一确定输出生命周期时,你必须把这层语义显式写出来——而写出它的方式不止「加 'a」一种。本文围绕三种真正能消除报错的路径展开:绑定到唯一输入、让返回类型不再持有借用、以及用 where 子句把约束从签名前缀挪走。每条路径都给出可编译的真实代码、失败样本与编译器原始输出。

先把规则本身钉死:输出位置只认三种情况

Rust Reference 对 elision 的规定很短,落到函数签名上就是三步:

  1. 每个被省略的输入生命周期,各自获得一个独立的匿名生命周期参数('1、'2…)。
  2. 如果输入里只有一个生命周期(无论是否省略),那么这个生命周期被赋给所有被省略的输出生命周期。
  3. 如果输入里有多个生命周期,但其中一个是 &self 或 &mut self,那么 self 的生命周期被赋给所有被省略的输出生命周期。

除此之外,输出位置的生命周期不能靠猜。下面这段代码就是规则的边界:

fn longest(a: &str, b: &str) -> &str {
    if a.len() >= b.len() { a } else { b }
}

编译(rustc 1.78.0,edition 2021)实际输出:

error[E0106]: missing lifetime specifier
 --> src/main.rs:1:40
  |
1 | fn longest(a: &str, b: &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 `a` or `b`
help: consider introducing a named lifetime parameter
  |
1 | fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
  |          ++++     ++          ++          ++

注意 help 的措辞:does not say whether it is borrowed from a or b。这就是问题本质——不是缺标注,而是缺语义决定。三种消除路径,本质上就是对这个决定给出三种不同答案。

路径一:把输出绑定到唯一输入,让规则第 2 条生效

如果业务上确实只可能返回其中一个参数,最省事的做法是让签名只有一个输入生命周期。规则第 2 条会立刻接管输出,你连 -> &'a str 都可以不写。

做法 A:直接收窄成单参数

fn first_word(s: &str) -> &str {
    match s.find(' ') {
        Some(i) => &s[..i],
        None => s,
    }
}

fn main() {
    let text = String::from("hello world");
    let w = first_word(&text);
    println!("{w}");
}

这里输入只有一个引用,输出省略后自动等于它,编译通过。很多「需要两个引用参数」的函数,其实只要把第二个参数改成语义上不参与返回的类型,问题就消失了。

做法 B:多参数但只有一个持有借用

// 第二个参数是 usize,不携带生命周期,输入里仍只有一个生命周期
fn nth_word(s: &str, n: usize) -> &str {
    s.split_whitespace().nth(n).unwrap_or("")
}

fn main() {
    let text = String::from("alpha beta gamma");
    assert_eq!(nth_word(&text, 1), "beta");
}

做法 C:确实要两个 &str,但返回值只可能来自第一个

这时不能靠省略,但可以用「只给第一个参数命名、其余给匿名」的写法,让被命名的那个成为唯一候选——不行,规则要求输入里只有一个生命周期。正确写法是显式标注,且只标注必要的那一个:

fn prefix_of<'a>(s: &'a str, pat: &str) -> &'a str {
    match s.find(pat) {
        Some(i) => &s[..i],
        None => s,
    }
}

fn main() {
    let hay = String::from("key=value");
    let pat = String::from("=");
    let p = prefix_of(&hay, &pat);
    println!("{p}"); // key
}

关键细节:pat 的生命周期完全独立,p 的存活期不受 pat 影响。如果你图省事写成 fn prefix_of<'a>(s: &'a str, pat: &'a str) -> &'a str,那么 pat 会被要求活得和 hay 一样久,调用点就会出现下面这种典型报错:

error[E0597]: `pat` does not live long enough
  |
  |     let p = prefix_of(&hay, &pat);
  |                              ^^^^ borrowed value does not live long enough
  |     println!("{p}");
  | }
  | - `pat` dropped here while still borrowed

这就是「乱加 'a」最常制造的连锁反应:签名能过,调用点崩。路径一的核心不是「加标注」,而是把生命周期压缩到真正必要的那个输入上。

路径一的边界:多个输入都可能被返回

一旦返回值真的可能来自任一参数,路径一就失效了,因为任何单点绑定都会误伤调用方。此时看路径二。

路径二:让返回类型不再持有借用

很多时候 E0106 只是一个信号:这个函数的抽象选错了。引用适合「零拷贝视图」,一旦要把结果带出参数的作用域、或需要被多个所有者共享,就该换类型。

做法 A:返回拥有所有权的 String

fn longest_owned(a: &str, b: &str) -> String {
    if a.len() >= b.len() { a.to_string() } else { b.to_string() }
}

fn main() {
    let s = {
        let a = String::from("abcd");
        let b = String::from("xy");
        longest_owned(&a, &b) // a、b 在此释放,返回值仍有效
    };
    assert_eq!(s, "abcd");
}

优点显而易见:调用方拿到的是独立值,不会把父作用域的借用关系传染下去。代价是一次堆分配。是否值得,取决于调用频率与字符串长度——在解析器这类热路径里,反复 to_string() 可能成为瓶颈,这时优先考虑下面的 Cow 或索引方案。

做法 B:Cow<'a, str>,能借则借

use std::borrow::Cow;

fn normalize(input: &str) -> Cow<'_, str> {
    if input.contains(' ') {
        Cow::Owned(input.split_whitespace().collect::<Vec<_>>().join("_"))
    } else {
        Cow::Borrowed(input)
    }
}

fn main() {
    assert_eq!(normalize("a b"), Cow::Owned("a_b".to_string()));
    assert_eq!(normalize("abc"), Cow::Borrowed("abc"));
}

注意返回类型写的是 Cow<'_, str>。这里的 '_ 是匿名但显式的生命周期,配合只有一个输入引用,elision 依然能确定它,所以不产生 E0106。同时 Cow 让「不需要改写」的路径保持零拷贝。

做法 C:返回索引区间 Range<usize>

fn longest_range(a: &str, b: &str) -> std::ops::Range<usize> {
    if a.len() >= b.len() { 0..a.len() } else { 0..b.len() }
}

fn main() {
    let a = "abcd";
    let b = "xy";
    let r = longest_range(a, b);
    let picked = if r.end == a.len() { &a[r.clone()] } else { &b[r] };
    assert_eq!(picked, "abcd");
}

返回 Range 完全没有生命周期,天然可跨作用域传递,调用方自己决定去切哪个切片。解析器、文本编辑器、编译器前端里这种「返回位置而非返回切片」的模式非常常见,因为它同时解决了借用传播和自引用结构两个问题。

做法 D:写回 out: &mut String

fn longest_into(a: &str, b: &str, out: &mut String) {
    out.clear();
    if a.len() >= b.len() { out.push_str(a) } else { out.push_str(b) }
}

fn main() {
    let mut buf = String::new();
    longest_into("abcd", "xy", &mut buf);
    assert_eq!(buf, "abcd");
}

返回值是 (),没有输出生命周期问题。调用方可以复用一个缓冲区,避免热循环里反复分配。这是路径二里最容易被忽略、但性能收益最实在的一条。

判断该走路径二的信号:你的函数被调用的地方总是要先 let s = ... 再迅速用掉返回值;或者调用点被迫把两个参数都声明在很外层的作用域。这两者都说明返回借用的抽象与实际使用方式错位。

路径三:把生命周期约束搬进 where 子句与显式签名

当确实必须返回借用、且输入不止一个时,剩下的选择是写全。但「写全」不等于「在每个 & 后面都塞 'a」。更稳妥的写法是把生命周期参数集中声明,用 where 子句表达关系,让约束一目了然。

基础形:显式命名 + where

fn pick<'a, 'b>(a: &'a str, b: &'b str, use_a: bool) -> &'a str
where
    'b: 'a,
{
    if use_a { a } else { b }
}

fn main() {
    let a = String::from("first");
    let b = String::from("second");
    let r = pick(&a, &b, false);
    println!("{r}"); // second
}

'b: 'a 读作「'b 至少活得和 'a 一样久」。有了这条约束,b 的引用可以被当作 &'a str 返回,返回类型只带一个生命周期。main 里 r 的存活期由 a、b 中较短者决定,编译器会自动取交集。

如果去掉 where 'b: 'a,编译会报:

error[E0623]: lifetime mismatch
 --> src/main.rs:6:17
  |
1 | fn pick<'a, 'b>(a: &'a str, b: &'b str, use_a: bool) -> &'a str {
  |                  -      -                               ------ these two types are declared with different lifetimes...
6 |     if use_a { a } else { b }
  |                 ^ ...but data from `b` flows here

这个报错的信息量比 E0106 大得多:它直接指出了数据流向。所以实践上更推荐先写全、再让编译器告诉你缺哪条约束,而不是一开始就靠省略硬猜。

场景:方法返回持有 &self 之外的数据

规则第 3 条让 &self 方法几乎不需要标注,但当方法返回的是另一个参数的借用时,规则会给出错误答案:

struct Parser {
    src: String,
}

impl Parser {
    // 危险:elision 会把返回值的生命周期绑定到 &self
    fn find(&self, needle: &str) -> &str {
        match self.src.find(needle) {
            Some(i) => &self.src[i..i + needle.len()],
            None => "",
        }
    }
}

fn main() {
    let p = Parser { src: String::from("a,b,c") };
    let out;
    {
        let sep = String::from(",");
        out = p.find(&sep);
    }
    println!("{out}");
}

这段其实能编译,因为返回值确实来自 self。但如果你把实现改成返回 needle 本身,签名就会说谎:

impl Parser {
    fn echo(&self, needle: &str) -> &str {
        needle // 实际借用来自 needle,但 elision 说来自 &self
    }
}

编译失败:

error[E0515]: cannot return value referencing function parameter `needle`
  |
  |         needle
  |         ^^^^^^ returns a value referencing data owned by the current function

error[E0308]: mismatched types
  |
  | fn echo(&self, needle: &str) -> &str
  |                            -      ^ expected `&str`, found `&str`
  |                            |
  |                            this function's return type contains a borrowed value,
  |                            but the signature does not say whether it is borrowed from
  |                            `self` or `needle`

修法是显式声明两个生命周期,并用 where 说明关系:

impl Parser {
    fn echo<'s, 'n>(&'s self, needle: &'n str) -> &'n str
    where
        's: 'n,
    {
        needle
    }
}

fn main() {
    let p = Parser { src: String::from("x") };
    let n = String::from("hi");
    let r = p.echo(&n);
    assert_eq!(r, "hi");
    // 注意:r 的生命周期受 'n 约束,与 &p 无关
}

这里 &'s self 只是为了满⾜「方法借用期间 self 有效」,真正返回的借用来自 'n。's: 'n 表明在这段调用中 self 活得比 needle 长——这是必需的,因为方法调用本身要求 self 可用。把这条约束写出来,E0515 就消失了。

泛型参数上的高阶写法:for<'a> 与 trait 方法

如果函数接收闭包或 trait 对象,生命周期约束常常要写在泛型边界里:

fn apply<F>(f: F, s: &str) -> String
where
    F: for<'a> Fn(&'a str) -> &'a str,
{
    f(s).to_string()
}

fn main() {
    let out = apply(|x| x.trim(), "  padded  ");
    assert_eq!(out, "padded");
}

for<'a> Fn(&'a str) -> &'a str(高阶生命周期约束,HRTB)表达的是「对任意生命周期,输入和输出的借用关系保持同一」。少了 for<'a>,编译器会抱怨闭包类型不满足约束,报错形如 error[E0631]: type mismatch in closure arguments。这类签名在迭代器适配器、序列化回调中很常见,写全之后几乎不再需要回头调整。

三种路径怎么选:一张对照表

判断条件 推荐路径 典型签名 代价
返回值只可能来自一个参数 路径一:收窄输入 fn f(s: &str, n: usize) -> &str 无
返回值可能来自任一参数,但调用方马上用掉 路径三:显式 'a + where fn f<'a>(x: &'a T, y: &'a T) -> &'a T 签名变长,借用关系外溢到调用点
返回值需要跨作用域存活 / 被多处共享 路径二:String / Cow / Range fn f(&str, &str) -> Cow<'_, str> 可能一次分配;Cow 无分配但类型变复杂
热路径、需复用缓冲区 路径二 D:out: &mut String fn f(a: &str, b: &str, out: &mut String) 调用方需持有可写缓冲
方法返回外部参数而非 self 的数据 路径三:双生命周期 + 's: 'n fn m<'s,'n>(&'s self, x: &'n str) -> &'n str where 's: 'n 签名复杂,但语义精确

一个实用的判断顺序:先问「返回值能不能不借用」(路径二),再问「输入能不能收窄到一个」(路径一),最后才写显式生命周期(路径三)。顺序反过来做,通常会把简单问题复杂化。

小结

E0106 不是「忘了写 'a」,而是「签名没有表达清楚返回值借了谁」。省略规则只覆盖三种可机械判定的情形:唯一输入、&self 方法、无输出借用。超出这个范围,就必须做语义决定,而决定有三条出口:把输入收窄到唯一一个引用;让返回类型换成 String、Cow、Range 或 &mut String 这类不依赖输出生命周期的形式;或者显式命名生命周期并用 where 子句把「谁活得比谁久」写清楚。路径三中最常见的坑是把所有参数都标成同一个 'a,那会强制调用方延长参数寿命并触发 E0597;正确做法是分别命名,再用最小必要的 where 约束连接。

FAQ

Q1:为什么 fn f(x: &str) -> &str 能过,fn f(x: &str, y: &str) -> &str 不能过?

前者输入只有一个生命周期,规则第 2 条直接赋给输出;后者有两个独立生命周期,规则无法判定,编译器拒绝猜测,于是报 E0106。

Q2:'static 能消除这个错吗?

技术上可以,但那是把返回值强行声明成「活得和整个程序一样久」,绝大多数情况下是错的抽象,会把调用点限制死。只有返回字面量、常量或真正全局数据时才该用 &'static str。

Q3:&'a T 和 T: 'a 有什么区别?

&'a T 是「一个存活 'a 的引用」,T: 'a 是「类型 T 里的所有借用都至少活 'a」。前者出现在参数/返回位置,后者出现在泛型边界里。写泛型结构体时两者经常同时出现。

Q4:返回值写成 &'_ str 算不算消除?

只有输入唯一或方法是 &self 时才算。'_ 是「让编译器推断」的占位符,它不提供新信息;输入有歧义时照样报 E0106,只是报错位置换成 '_。

Q5:报 E0515: cannot return value referencing function parameter 和 E0106 是一回事吗?

不是。E0106 是签名层面缺信息;E0515 是签名已确定、但函数体实际返回了签名不允许的借用(典型是返回了局部变量的引用,或返回了未被签名绑定的参数)。修 E0515 要么改实现,要么按路径三补生命周期约束。

Q6:闭包参数里报 E0631 怎么办?

通常是缺少高阶生命周期约束。把 F: Fn(&str) -> &str 改成 F: for<'a> Fn(&'a str) -> &'a str,让「任意生命周期下借用关系一致」被显式表达出来。