探究Rust中有趣的设计

这篇文章主要是看一下Rust有哪些比较有意思的设计,相比其他语言之下为什么要这么设计。

变量的可变性

Rust 的变量在默认情况下是不可变的,但是可以通过 mut 关键字让变量变为可变的,让设计更灵活。也就是说,如果我们这么写,编译会报错:

fn main() {
    let x = 5;
    println!("The value of x is: {}", x);
    x = 6;
    println!("The value of x is: {}", x);
}

报错:

error[E0384]: cannot assign twice to immutable variable `x`
 --> src/main.rs:4:5
  |
2 |     let x = 5;
  |         - first assignment to `x`
3 |     println!("The value of x is: {}", x);
4 |     x = 6;
  |     ^^^^^ cannot assign twice to immutable variable 

这其实和 C++ 和 Go(以及 Java、Python 等绝大多数主流语言)的设计哲学完全相反。C++ 和 Go 默认就是可变的,除非加上 const 表示是个常量。

Rust 这样做主要是为了让代码变得清晰点,降低心智负担 。一个变量往往被多处代码所使用,其中一部分代码假定该变量的值永远不会改变,而另外一部分代码却无情的改变了这个值,在实际开发过程中,这个错误是很难被发现的,特别是在多线程编程中。再来就是编译器优化,如果编译器知道一个变量绝不会变,它可以更激进地进行常量折叠、寄存器分配等优化。

在 Rust 中,可变性很简单,只要在变量名前加一个 mut 即可:

fn main() {
    let mut x = 5;
    println!("The value of x is: {}", x);
    x = 6;
    println!("The value of x is: {}", x);
}

但是 Rust 提供了 Shadowing 的功能:

fn main() {
    let x = 5;
    // x = x + 1; // 报错,不能修改

    let x = x + 1; // 合法!这是一个全新的 x,它遮蔽了旧的 x

    let x = "Hello"; // 甚至可以改变类型!
    println!("{}", x);
}

这点很有意思,在很多语言是不可以这么重复声明变量的。我觉得还是和不可变性有关,既然都不可变了,重复声明也是安全的,并且复用同一个变量名,而不需要想出 x_str, x_int, x_final 这种名字,相对来说代码会简洁一些。

所有权 (Ownership)

现在内存管理一般分为两类:

  1. 手动管理派 (C / C++),申请 (malloc/new),需要手动负责释放 (free/delete),但是这是很痛苦的,有时候忘记释放就会内存泄露,或者释放两次就会导致崩溃或为定义的行为;
  2. 垃圾回收派 (Java / Go / Python),有一个 Runtime 里的 GC (Garbage Collector) 盯着内存,不再用的就自动回收。自动回收也有缺点,需要STW,例如在游戏后端或高频交易中,几毫秒的 GC 卡顿可能就是灾难;

而 Rust 使用所有权 (Ownership)来控制。编译器在编译阶段通过一套严格的规则,自动在合适的地方插入 free 代码。没有运行时 GC,也不依赖手动管理。这个编译器定义的所有权规则有以下几条:

  1. Rust 中每一个值都被一个变量所拥有,该变量被称为值的所有者
  2. 一个值同时只能被一个变量所拥有,或者说一个值只能拥有一个所有者
  3. 当所有者(变量)离开作用域范围时,这个值将被丢弃(drop)

比如这个例子:

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

    // println!("{}, world!", s1); // ??? 这里能打印 s1 吗?
}

在 C++ 中,如果 s1 申请的是一个堆上的对象,如果是浅拷贝 (Shallow Copy),s1s2 指向堆上的同一块内存。如果函数结束,析构函数执行两次,导致 Double Free 错误。

在 Rust 中,由于所有权的存在,这一行 let s2 = s1;代码执行后,s1 会当场死亡!发生 所有权转移 (Move)。Rust 认为:堆上的那块 "hello" 内存,现在归 s2 管了。所以如果你后面再用 s1编译直接报错

报错:

error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:28
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |
5 |     println!("{}, world!", s1);
  |                            ^^ value borrowed here after move

如果你确实需要两个独立的字符串数据(深拷贝),你需要显式调用 .clone()

let s1 = String::from("hello");
let s2 = s1.clone(); // 在堆上重新开辟内存,复制数据

println!("s1 = {}, s2 = {}", s1, s2); // s1 依然活着

除此之外,要注意栈上的数据 ,对于基本类型,基本类型(存储在栈上),Rust 会自动拷贝,其他的非基本类型会存储在堆上,不能自动拷贝。

let x = 5;
let y = x;

代码首先将 5 绑定到变量 x,接着拷贝 x 的值赋给 y,最终 xy 都等于 5,因为整数是 Rust 基本数据类型,是固定大小的简单值,因此这两个值都是通过自动拷贝的方式来赋值的,都被存在栈中,完全无需在堆上分配内存。

借用(Borrowing)

借用(Borrowing),就是允许你在不获取所有权 (Ownership) 的情况下访问数据。简单来说,借用就是创建数据的引用 (Reference)

借用有两种方式,不可变借用 : &T,可变借用:&mut T在任意给定的作用域中,你只能满足以下两个条件之一:

  1. 拥有 任意数量 的不可变引用 (&T)。
  2. 拥有 唯一一个 可变引用 (&mut T)。

即:要么多读,要么独写,绝不能同时存在,这个规则非常像 读写锁(Read-Write Lock)

比如下面就是合法的借用 (多读):

fn main() {
    let s = String::from("hello"); // s 拥有所有权

    let r1 = &s; // 不可变借用 1
    let r2 = &s; // 不可变借用 2

    // 可以同时存在多个读者
    println!("{}, {}", r1, r2); 
} // 借用结束

合法的借用 (独写):

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

    let r1 = &mut s; // 可变借用
    r1.push_str(", world"); // 修改数据

    println!("{}", r1); 
}

非法的借用 (读写冲突):

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

    let r1 = &s;      // r1 借用以此只读
    let r2 = &mut s;  // 错误!不能在有不可变引用的同时创建可变引用
                      // 因为 r2 可能会改变 s,导致 r1 看到的数据失效或不一致

    println!("{}, {}", r1, r2);
}

需要注意的是,现在的 Rust 编译器非常聪明,它的“作用域”不再仅仅是花括号 {},而是看引用的最后一次使用位置,这叫做 Non-Lexical Lifetimes (NLL)

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

    let r1 = &s; 
    let r2 = &s;
    println!("{} and {}", r1, r2); 
    // --- r1 和 r2 的作用在这里就结束了!因为后面没再用过它们 ---

    let r3 = &mut s; // 现在可以了!
    // 因为上面的不可变引用已经不再使用了,Rust 判定冲突解除。
    println!("{}", r3);
}

Rust 这么做其实也是为了安全,我们看看 C++ 中常见的坑:

// C++ 伪代码
vector<int> v = {1, 2, 3};
int& element = v[0]; // 获取第一个元素的引用

v.push_back(4); 
// 危险!如果 push_back 导致 vector 扩容(重新分配内存),
// 原来的内存被释放,element 现在指向的是垃圾内存。
// 再次访问 element 会导致 Crash。

在 Rust 中:

  • element 是一个不可变借用 (&T)。
  • v.push_back 需要获取 v 的可变借用 (&mut T)。
  • 规则冲突:已经有 & 了,不能再借出 &mut
  • 结果:编译直接报错,阻止隐患。

字符串

&strString

在其他很多语言用 "hello" 这种方式创建的一般就叫字符串,但在 rust 里面不一样,它实际上是申明了一个只读的字符串字面量 &str,这意味着它是不可变的,数据直接硬编码在编译后的可执行文件 (Binary) 中(静态存储区),有点像 const。

let name = "Rust"; // 类型是 &str
println!("Hello, {}", name);
// name.push_str(" World"); // 报错!&str 不能修改

在 rust 里面只有使用 String::from(...) 声明的字符串才是我们常规意义上理解的字符串,比如可以对它进行修改拼接传递

  1. 修改字符串 (必须加 mut)

    fn main() {
       // 注意:如果要修改,必须加 mut 关键字
       let mut s = String::from("Hello");
    
       // 1. 追加字符串切片 push_str()
       s.push_str(", world"); 
    
       // 2. 追加单个字符 push()
       s.push('!'); 
    
       println!("{}", s); // 输出: Hello, world!
    }
  2. 字符串拼接 (连接两个字符串),有两种主要方式:使用 + 运算符或 format! 宏。

    fn main() {
       let s1 = String::from("Tick");
       let s2 = String::from("Tock");
    
       // 注意细节:
       // s1 必须交出所有权 (被移动了),后面不能再用了
       // s2 必须传引用 (&s2)
       let s3 = s1 + " " + &s2; 
       // 类似 C 语言的 sprintf,生成一个新的 String
       let s4 = format!("{} - {}", s1, s2);
    }
  3. String 可以自动假装成 &str

    fn main() {
       let s = String::from("Hello World");
    
       // 场景 1: 函数需要 String (拿走所有权)
       take_ownership(s); 
       // println!("{}", s); // 报错,s 已经被拿走了
    
       // --- 重新创建一个 s ---
       let s = String::from("Hello Again");
    
       // 场景 2: 函数需要 &str (只读借用) -> 【这是最常用的】
       // 虽然 s 是 String,但 &s 可以被当做 &str 用
       borrow_it(&s); 
       println!("s 还在: {}", s); // s 还在
    }
    
    fn take_ownership(input: String) {
       println!("我拿到了所有权: {}", input);
    } // input 在这里被释放
    
    fn borrow_it(input: &str) {
       println!("我只是借看一下: {}", input);
    }
  4. 转换回切片 (Slicing)

    let s = String::from("Hello World");
    
    let hello = &s[0..5]; // 提取前5个字节
    let world = &s[6..];  // 从第6个字节取到最后

需要注意的是,Rust 的字符串在底层是 UTF-8 编码的字节数组,不支持直接通过数组下标索引(Index)访问字符。比如这样是会报错的:

let s1 = String::from("hello");
let h = s1[0];

Rust 的 String 本质上是一个 Vec<u8>(字节向量)。对于纯英文: "hello",每个字母占 1 个字节。s[0] 确实是 'h'对于中文/特殊符号: "你好"。在 UTF-8 中,’你’ 占用 3 个字节。

为了强迫开发者意识到 “字符 ≠ 字节” 这一事实,Rust 干脆在编译阶段就禁止了 String[index] 这种写法。

所以,为了获取第 N 个字符 (最常用,安全),需要使用 .chars() 迭代器:

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

    // .chars() 把字符串解析为 Unicode 字符
    // .nth(0) 取出第 0 个元素
    // 结果是 Option<char>,因为字符串可能是空的
    match s1.chars().nth(0) {
        Some(c) => println!("第一个字符是: {}", c),
        None => println!("字符串是空的"),
    }
}

为什么要有这两个?

  • String 是为了当你需要在运行时动态生成修改或者持有字符串数据时使用的(比如从网络读取数据,拼接 SQL)。
  • &str 是为了高性能传递。当你只需要“看”一下字符串,而不需要拥有它时,用 &str。因为它只是传两个整数(指针+长度),不需要拷贝堆上的数据,速度极快。

枚举

Rust 的枚举和其他语言最大的不同应该就是能挂载数据。每一个枚举成员,都可以关联不同类型、不同数量的数据。

enum Message {
    Quit,                       // 没有关联数据
    Move { x: i32, y: i32 },    // 像 Struct 一样包含命名字段
    Chat(String),               // 包含一个 String
    ChangeColor(i32, i32, i32), // 包含三个 i32
}

fn main() {
    // 创建不同类型的消息
    let msg1 = Message::Move { x: 10, y: 20 };
    let msg2 = Message::Chat(String::from("你好"));
}

比如上面的 Message 这个枚举,Quit 成员没有挂载数据,其他三个都挂载了各不相同的数据类型。

然后我们就可以根据枚举挂载的不同数据,使用 match 来进行匹配,这有点像 C++ 里面的 switch-case:

fn process_message(msg: Message) {
    match msg {
        Message::Quit => {
            println!("玩家退出了");
        }
        Message::Move { x, y } => {
            println!("玩家移动到了: x={}, y={}", x, y);
        }
        Message::Chat(text) => {
            println!("玩家发送消息: {}", text);
        }
        Message::ChangeColor(r, g, b) => {
            println!("更改颜色为: R{} G{} B{}", r, g, b);
        }
    }
}

但是需要注意的是 match 会强制你处理所有可能的情况,如果你漏写了 Message::Quit,代码根本编译不过。这保证了你不会遗漏任何一种业务逻辑。如果不想在匹配时列出所有值的时候,可以使用特殊的模式 _ 替代:

let some_u8_value = 0u8;
match some_u8_value {
    1 => println!("one"),
    3 => println!("three"),
    5 => println!("five"),
    7 => println!("seven"),
    _ => (),
}

顺带提一下,match 其实也可以用来返回赋值,这点就和很多其他语言不同:

enum IpAddr {
   Ipv4,
   Ipv6
}

fn main() {
    let ip1 = IpAddr::Ipv6;
    let ip_str = match ip1 {
        IpAddr::Ipv4 => "127.0.0.1",
        _ => "::1",
    };

    println!("{}", ip_str);
}

这里匹配到 _ 分支,所以将 "::1" 赋值给了 ip_str

上面我们也说了枚举可以挂载数据,所以相应的 match 也能把数据解包出来:

enum Action {
    Say(String),
    MoveTo(i32, i32),
    ChangeColorRGB(u16, u16, u16),
}

fn main() {
    let actions = [
        Action::Say("Hello Rust".to_string()),
        Action::MoveTo(1,2),
        Action::ChangeColorRGB(255,255,0),
    ];
    for action in actions {
        match action {
            Action::Say(s) => {
                println!("{}", s);
            },
            Action::MoveTo(x, y) => {
                println!("point from (0, 0) move to ({}, {})", x, y);
            },
            Action::ChangeColorRGB(r, g, _) => {
                println!("change color into '(r:{}, g:{}, b:0)', 'b' has been ignored",
                    r, g,
                );
            }
        }
    }
}

上面当代码执行到 match action 时 Rust 发现 actionAction::Say 这一类,就会解构这个类型里面的 String,Action::Say(s)的意思是:“如果是 Say,把它肚子里的数据拿出来,赋值给变量 s。”,其他的同理。

Rust 的枚举类型还有一个特点就是结构比较紧凑,枚举 = 标签(tag)+ 数据(payload),比如这个枚举:

enum Message {
    Quit,                    // 0 字节 payload
    Move { x: i32, y: i32 }, // 8 字节
    Write(String),           // 24 字节(64位平台上,大概是 3 个指针)
}

tag 就是当前是 Quit/Move/Write 中的哪一个,数据(payload)就是容纳“所有变体里最大的那个数据”的空间,这里就是24字节的 String 类型,当然还有根据对齐要求,可能在 tag 和 payload 之间加 padding,那么一个枚举的结构就是:

size_of::<Message>() ≈ size_of::<payload最大> + size_of::<tag> + 对齐填充

所以可以看到枚举的结构比使用结构体更加紧凑。

最后,枚举甚至还能可以有自己的方法:

#![allow(unused)]
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

impl Message {
    fn call(&self) {
        // 在这里定义方法体
    }
}

fn main() {
    let m = Message::Write(String::from("hello"));
    m.call();
}

null

Tony Hoare, null 的发明者,曾经说过一段非常有名的话:

我称之为我十亿美元的错误。当时,我在使用一个面向对象语言设计第一个综合性的面向引用的类型系统。我的目标是通过编译器的自动检查来保证所有引用的使用都应该是绝对安全的。不过在设计过程中,我未能抵抗住诱惑,引入了空引用的概念,因为它非常容易实现。就是因为这个决策,引发了无数错误、漏洞和系统崩溃,在之后的四十多年中造成了数十亿美元的苦痛和伤害。

所以 Rust 只有 Option 枚举,没有 null / nil / nullptr。Rust 标准库是这样定义的:

enum Option<T> {
    None,    // 相当于 null
    Some(T), // 包含一个值
}

Option 可以配合 match 来使用:

let some_number = Some(5);
let absent_number: Option<i32> = None;

// let sum = some_number + 1; // 报错!不能把 Option<i32> 和 i32 相加

// 必须处理为空的情况
match some_number {
    Some(i) => println!("数字是: {}", i),
    None => println!("没有数字"),
}

使用 Option 还有个好处就是编译器会做一种优化叫做 “空指针优化 (Null Pointer Optimization)”

对于 Option<&T>(引用类型的 Option)或 Option<Box<T>>(堆指针的 Option)Rust 编译器在底层依然把它看作一个指针,Some(ptr) 对应非零地址,None 对应 0 (null) 地址。所以在汇编层面,Rust 的 Option<&T> 和 C++ 的 T\* 是一模一样的,内存占用也是一样的(64位机器上都是8字节)。

所以把指针包在 Option 枚举里不会增加内存开销,也不会导致运行变慢。

泛型(Generics)

Rust 泛型其实是有点像C++的模版的,而不是类似 Java 或 C# 的泛型。 Java 泛型是在编译期进行检查,但在运行时被“擦除”。例如,List<String>List<Integer> 在 Java 虚拟机(JVM)看来,本质上都是 List<Object>。这样做优点是节省了代码空间,缺点是牺牲了很多性能,因为在运行的时候虚拟机必须进行大量的类型转换(Casting)。

Rust 泛型则不一样,它和C++的模版是一样的,会根据你传入的具体类型(比如 int/i32float/f64),生成多份不同的机器码。这样做的好处就是执行效率极高,没有 Java/Go 那种装箱(Boxing)或运行时类型断言的开销。

但是相对的,Rust 又比 C++ 模版又要使用上要舒服很多,Rust 泛型因为 Trait Bounds 的原因,所以编译器在定义阶段就能发现错误,而不是等到调用阶段。假设我们要写一个打印函数:

C++:

template <typename T>
void print_it(T value) {
    // 编译器在这里不检查 value 到底有没有 .print() 方法
    // 直到你在 main 函数里调用 print_it(int) 时,它才发现 int 没有 .print()
    value.print(); 
}

Rust:

// 必须显式约束:T 必须实现了 Debug trait
fn print_it<T: std::fmt::Debug>(value: T) {
    println!("{:?}", value);
}

// 如果你写成下面这样,编译器直接报错,甚至不需要你调用它:
// fn print_it<T>(value: T) {
//     println!("{:?}", value); // 错误!编译器说:T 没有实现 Debug,不能打印
// }

特征对象(Trait Objects)

Rust 的特征对象 (dyn Trait) ≈ C++ 的虚基类指针 (Base) ≈ Go 的 interface

在 Rust 中,如果你想编写一个函数,或者定义一个容器(如 Vec),让它可以接受多种不同类型的数据,只要这些数据实现了同一个 Trait,你有两种选择:

  1. 泛型(Generics)fn foo<T: Draw>(x: T)
    • 这是编译期决定的。
    • 编译器会为每个不同的类型生成不同的代码(单态化)。
    • 缺点:你不能在一个 Vec 里同时存 u8f64,因为 Vec 只能存一种类型。
  2. 特征对象(Trait Objects)fn foo(x: &dyn Draw)
    • 这是运行期决定的。
    • “类型擦除”(Type Erasure):编译器此时不再关心具体的类型是 u8 还是 f64,它只关心“这东西能 Draw”。
    • 优点:你可以在一个 Vec<Box<dyn Draw>> 里混存 u8f64

特征对象(Trait Objects)实现的方式其实和 C++ 的虚表实现很像,比如当你把一个具体类型(如 &u8)转换成特征对象(&dyn Draw)时,Rust 会生成一个胖指针(Fat Pointer)

这个胖指针包含两部分(占用 16 字节):

  1. data 指针:指向具体的数据(如堆上的 u8 值)。
  2. vtable 指针:指向该具体类型针对该 Trait 的虚函数表(Virtual Method Table)

当你调用 x.draw() 时,如果 x 是特征对象,机器码执行的逻辑如下:

  1. 读取 vtable:从胖指针的第二个字段找到 vtable 的地址。

  2. 查找方法:在 vtable 中找到 draw 方法对应的函数指针(比如偏移量为 0 的位置)。

  3. 跳转执行:调用该函数指针,并将胖指针的第一个字段(data 指针)作为 self 传进去。

举个例子,如果我们使用泛型来实现下面的 Screen 类,里面的 components 想要放多个元素:

pub struct Screen<T: Draw> {
    pub components: Vec<T>,
}

如果你这样写,Screen 实例被创建时,T 必须被确定为某一种具体的类型。这意味着 components 里的所有元素都必须是同一个类型。如果想要混装是不行的,如下面:

let screen = Screen {
    components: vec![
        10u8,     // 编译器推断 T 是 u8
        3.14f64,  // 报错!期望是 u8,但你给了 f64
    ],
};
// error[E0308]: mismatched types

只能全部都是同一类型:

let screen = Screen {
    components: vec![10u8, 20u8, 30u8], // 全是 u8,没问题
};

如果使用特征对象(Trait Objects),就可以实现混装,在列表中存储多种不同类型的实例。

pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

这样是 ok 的:

let v: Vec<Box<dyn Draw>> = vec![
    Box::new(10u8),
    Box::new(3.14f64),
];

生命周期

生命周期就是控制有效作用域,防止 悬垂引用(Dangling Reference)的 。

fn main() {
    let r;                // ---------+-- r 的生命周期开始
    {                     //          |
        let x = 5;        // -+-- x 的生命周期开始
        r = &x;           //  | 试图让 r 指向 x
    }                     // -+-- x 在这里死了(被释放)!
                          //          |
    println!("r: {}", r); //          | r 依然活着,但它指向的 x 已经没了!
                          //          | -> 报错:借用了活得不够久的值
}                         // ---------+

比如这段代码,Rust 编译器会拒绝编译,因为它发现 r 活得比 x 久,为了安全考虑,然后就拒绝编译。

用过 C++ 的同学知道, 在线上经常会因为这种问题而导致程序的 panic,因为C++ 编译器通常会“相信”你,比如下面的例子编译器根本不会管你:

#include <iostream>
#include <string>

// C++ 代码
const std::string& get_dangling() {
    std::string s = "Hello";
    return s; // 危险!s 在这里会被销毁
}

int main() {
    // 这里拿到了一个引用,指向了一块已经被释放的栈内存
    const std::string& ref = get_dangling(); 

    // 运行时表现:
    // 1. 可能崩溃 (Segmentation Fault)
    // 2. 可能打印出乱码
    // 3. 可能打印出 "Hello" (如果在内存被覆盖前运气好) -> 这是最可怕的“未定义行为”
    std::cout << ref << std::endl; 
}

所以相对而言,Rust 编译器的严格管控,实际上是”为了你好“。

Rust 为了实现这种严格管控,就出现了生命周期标注这种东西。生命周期不是“运行时的计时器”,也不是你手动管理内存的东西。它是编译器做静态分析时用的标记/约束,表示:

  • 这个引用至少要活到哪里
  • 或者:两个引用之间谁不能比谁短

你写的 'a 这种符号,就是一种生命周期参数。大多数时候,Rust 编译器能自动推断生命周期,但在一些模糊的情况下,就需要手动标注。手动标注是以 ' 开头,名称往往是一个单独的小写字母,大多数人都用 'a 来作为生命周期的名称。

关于什么时候需要使用标注其实就一句话:输出的引用到底是从哪个输入里借来的有歧义的时候。具体来说,主要有以下 4 种场景

场景 1:结构体中包含引用

只要你的 struct 定义里包含字段是引用(而不是像 Stringi32 这样的拥有所有权的类型),你就必须给整个结构体加上生命周期参数。

// 报错:missing lifetime specifier
struct Book {
    author: &str, // 这是一个引用,编译器慌了:这个引用指向谁?能活多久?
}

// 正确
// 读作:Book 实例活多久,'a 就得活多久;author 引用的数据至少也要活 'a 这么久。
struct Book<'a> {
    author: &'a str,
}

场景 2:函数有多个引用参数,且返回值也是引用

如果你有两个输入引用,且返回一个引用。编译器就蒙了:“返回的这个引用,是借用了参数 A,还是参数 B?”

// 报错
// 编译器困惑:返回的 &str 到底是谁的?
// 如果 user 活 10s,data 活 5s,我该让返回值活多久?
fn choose_one(user: &str, data: &str) -> &str {
    if user.len() > 5 { user } else { data }
}

// 正确
// 显式告诉编译器:返回值的生命周期取 user 和 data 中较短的那个('a)
fn choose_one<'a>(user: &'a str, data: &'a str) -> &'a str {
    if user.len() > 5 { user } else { data }
}

如果返回值只跟其中一个参数有关,只标那个参数就行:

// 这里的 'a 表示返回值只和 x 有关,和 y 无关
fn verify<'a>(x: &'a str, y: &str) -> &'a str {
    x
}

当然,如果只有一个输入引用那就没有歧义:

fn capitalize(s: &str) -> &str { ... } 
// 编译器自动脑补为:
// fn capitalize<'a>(s: &'a str) -> &'a str { ... }

场景 3:在 impl 块中实现方法时

这是语法要求,防止你忘记这个结构体是有“保质期”的。

struct Book<'a> {
    author: &'a str,
}

// 必须写 impl<'a>,把 'a 声明出来
impl<'a> Book<'a> {
    // 这里的 &self 其实隐含了生命周期
    fn get_author(&self) -> &str {
        self.author
    }
}

注意:在方法内部,通常不需要给参数标生命周期,因为 Rust 有一条强大的规则:“如果是方法(有 &self),那么返回值的生命周期默认和 self 一样。”

Reference

https://course.rs/