2.9.6 &Struct 与协变

译文 · 基于 Learning Rust

&Struct 与协变

原文链接: https://quinedot.github.io/rust-learning/pf-shared-nested.html

这里有一个看起来类似于永远借用某物的情况,但实际上有些不同。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
struct Person<'a> {
    given: &'a str,
    sur: &'a str,
}

impl<'a> Person<'a> {
    //       vvvvvvvv `&'a Person<'a>`
    fn given(&'a self) -> &'a str {
        self.given
    }
}

fn example(person: Person<'_>) {
    // 与永远借用某物不同,这可以编译
    let _one = person.given();
    let _two = person.given();
}

区别在于 &U 在 U 上是协变的,所以 生命周期可以在引用背后"缩短" (与 &mut U 不同,它在 U 上是不变的)。Person<'a> 在 'a 上也是协变的,因为我们在定义中对 'a 的所有使用都处于协变位置。

这一切意味着 &'long Person<'long> 可以强制转换为 &'short Person<'short>。因此,调用 Person::given 不必永远借用 person——它只需要在返回值被使用期间借用 person。

注意协变性是必需的!内部生命周期不变时的共享嵌套借用,几乎和"永远借用"的 &mut 情况一样糟糕。本页大部分讨论协变情况;我们将在末尾考虑不变情况。

这仍然是一个黄色警告

尽管不像 &mut 情况那么成问题,那个签名仍然有些不理想:它迫使对 person 的借用比必要的更长。例如,这会失败:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# struct Person<'a> { given: &'a str }
# impl<'a> Person<'a> { fn given(&'a self) -> &'a str { self.given } }
# struct Stork(String);
# impl Stork { fn deliver(&self, _: usize) -> Person<'_> { Person { given: &self.0 } } }
fn example(stork: Stork) {
    let mut last = "";
    for i in 0..10 {
        let person = stork.deliver(i);
        last = person.given();
        // ...
    }
    println!("Last: {last}");
}

person 必须在返回值存在期间保持借用,因为我们说 &self 和返回的 &str 必须有相同的生命周期。

如果我们改为允许生命周期不同:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# struct Person<'a> { given: &'a str }
# struct Stork(String);
# impl Stork { fn deliver(&self, _: usize) -> Person<'_> { Person { given: &self.0 } } }
impl<'a> Person<'a> {
    //       vvvvv 我们从 `&self` 中移除了 `'a`
    fn given(&self) -> &'a str {
        self.given
    }
}

fn example(stork: Stork) {
    let mut last = "";
    for i in 0..10 {
        let person = stork.deliver(i);
        last = person.given();
        // ...
    }
    println!("Last: {last}");
}

那么对 person 的借用可以在调用后立即结束,即使返回值仍然可用。这是可能的,因为我们只是复制引用出来。或者如果你更喜欢另一种思考方式,我们给出的是对已有借用的重借用,而不是借用我们自己拥有的东西。

所以现在 stork 仍然必须在 last 被使用期间存在,但 person 可以在循环结束时消失。

当你有一个以某种方式管理借用资源的结构体时,允许生命周期不同通常是你想要的——当你分发借用资源的片段时,你希望它们与原始借用的生命周期绑定,而不是与方法调用上的 &self 或 &mut self 的生命周期绑定。借用迭代器就是这样工作的, 例如。

主题的一个变体

考虑这个版本的方法:

1
2
3
4
5
6
# struct Person<'a> { given: &'a str }
impl<'a> Person<'a> {
    fn given(&self) -> &str {
        self.given
   }
}

它与 given(&'a self) -> &'a str 有同样的缺点:返回值与 self 绑定,而不是与 'a 绑定。在开发借用结构体时很容易犯这个错误,因为生命周期省略规则会引导你朝这个方向走。它也更难发现,因为没有 &'a self 来提示你。

但有时完全没问题

另一方面,由于我们在本页开头讨论的协变性,这两个方法之间没有实际区别:

1
2
3
4
5
# struct Person<'a> { given: &'a str }
impl<'a> Person<'a> {
    fn foo(&self) {}
    fn bar(&'a self) {}
}

没有返回值来强制生命周期更长,所以这些方法的行为是一样的。&'a self 上的 'a 没有理由,但也没有害处。

同样,在结构体内部,将嵌套生命周期分开很少有好处,所以你可能不如使用:

1
2
3
4
# struct Person<'a> { given: &'a str }
struct Cradle<'a> {
    person: &'a Person<'a>
}

而不是使用两个生命周期的写法。

(话虽如此,更好的方法是根本不要有复杂的嵌套借用数据结构。)

不变情况

最后,让我们看一个通常不太行的情况:内部借用不变的共享嵌套借用。

也许最可能出现的原因是共享可变性:能够改变共享引用(&)背后的东西的能力。标准库中的一些例子包括 Cell<T>、 RefCell<T> 和 Mutex<T>。这些共享可变性类型在其泛型参数 T 上必须是不变的,就像 &mut T 在 T 上是不变的一样。

让我们看一个例子,类似于我们之前见过的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# use std::cell::Cell;
#[derive(Debug)]
struct ShareableSnek<'a> {
   owned: String,
   borrowed: Cell<&'a str>,
}

impl<'a> ShareableSnek<'a> {
    fn bite(&'a self) {
        self.borrowed.set(&self.owned);
    }
}

let snek = ShareableSnek {
    owned: "🐍".to_string(),
    borrowed: Cell::new(""),
};

snek.bite();

// 与 `&mut` 情况不同,我们仍然可以使用 `snek`!它被永远借用了,
// 但只是*共享*-借用永远。
println!("{snek:?}");

这似乎不太糟糕,对吧?嗯,它不像 &mut 情况那么糟糕,但通常仍然太受限而无法实用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# use std::cell::Cell;
# #[derive(Debug)]
# struct ShareableSnek<'a> {
#    owned: String,
#    borrowed: Cell<&'a str>,
# }
#
# impl<'a> ShareableSnek<'a> {
#     fn bite(&'a self) {
#         self.borrowed.set(&self.owned);
#     }
# }
#
# let snek = ShareableSnek {
#     owned: "🐍".to_string(),
#     borrowed: Cell::new(""),
# };
#
snek.bite();
let _mutable_stuff = &mut snek;
let _move = snek;

// 有非平凡的析构函数也会导致失败

一旦被永远借用,snek 只能以"共享"方式使用。它只能通过共享可变性被修改,不能被移动——它被永远固定在了原地。

最后修改 August 23, 2026: 更新 (499855b16)