使用Rust结构体驯服生命周期传染

5
我试图在Rust中定义一个结构体,其中包含一个类型为async_executor::LocalExecutor的成员,该类型本身是针对生命周期'a泛型的:
pub struct LocalExecutor<'a> {
    inner: ......<Executor<'a>>,
    ...
    ...
}

现在,我的结构体显然也必须是泛型的,涵盖了生命周期'a,对于它本身来说意义不大——这个生命周期是async_executor::LocalExecutor的一个细节。

#[cfg(all(test, not(target = "wasm32")))]
struct MockThing<'a> {
    executor: async_executor::LocalExecutor<'a>,
}

我的结构体仅在构建单元测试时存在,我需要一个模拟的、单线程执行器来运行异步代码。问题在于:我的结构体的唯一使用者使用了#[cfg(...)]条件编译,在内部进行以下操作:
  1. 编译单元测试时使用我的模拟(而不是WebAssembly)
  2. 编译WebAssembly时使用一个真实的实现
  3. 否则使用另一个真实的实现。
这是通过条件编译来实现的,以确保消费者本身不会不必要地变得通用,这会污染其公共API,并将传染性泛型问题推给消费它的所有东西——数量众多。条件编译提供了一种编译时的鸭子类型,因为这种条件编译只存在于一个地方,所以其他人不需要了解实现细节——正如应该做的那样。

实现2和3都不需要通用生命周期,但是由于模拟的那个(1)必须在' a上通用,所以现在我必须使我的整个代码库都通用一些生命周期,' a'!(并且使用PhantomData解决编译器抱怨'a'无意义的问题,大多数情况下确实如此。)

有没有办法以不引起这个问题的方式定义我的模拟结构?如果我可以在成员定义中使用'_',那将非常方便,比如...

#[cfg(test)]
struct MockThing {
    executor: async_executor::LocalExecutor<'_>,
}

...表示executor的生命周期应该从MockThing的生命周期中推导出来。(当然,这是不起作用的。)

我想我也可以为我的单元测试使用另一个带有环境执行器的async运行时,并绕过这个问题,但这并不能帮助我理解发生了什么,在这里,以及通常情况下,如何将生命周期封装为实现细节,在具有一些成员泛型的结构中。

然而,有一些我不理解的问题:为什么Executor(在LocalExecutor内部)必须是泛型的,覆盖'a -- 它不包含具有生命周期'a的引用 -- 以及他们为什么使用PhantomData来确保它是泛型的,并且是一个不变的生命周期,'a,甚至在这种情况下,什么是生命周期不变性。我已经在 nomicon和其他地方阅读了相关内容,但需要很多天的学习才能说我理解了生命周期变异,而我想做的只是“将其中之一放入我的struct”。

肯定有一些方法可以驯服生命周期传染并防止它污染整个代码库,只因为一个类型是泛型的生命周期?请求帮助!


我认为在这种情况下,你可以将生命周期简单地设置为'static。正如你所指出的那样,它实际上并不是执行器内任何引用的生命周期。它更像是迭代器的“作用域”,允许向其中提交具有非静态生命周期的任务。既然看起来你不想这样做,那么简单地使用'static应该可以解决你的问题。 - Sven Marnach
这些幽灵生命周期有时用于保证一个对象比另一个对象更长寿,即使它们没有直接引用彼此。或者它可以用来避免在 LocalExecutor 存在时修改 Executor,因为它被借用了。 - rodrigo
1
@rodrigo 在这种情况下,幽灵生命周期被用来允许在执行器上运行不是 'static 但只有 'a 的未来。这确保了未来的寿命至少与执行器一样长。 - Sven Marnach
我在此提名“终身传染”为这种现象的专业术语。(我相信@Xharlie刚刚创造了这个词,因为如果你在谷歌上搜索它,这个问题是唯一的结果。) - BallpointBen
@BallpointBen:我很乐意作为“lifetime contagion”这个术语的创造者来接受荣誉,但我非常确定它只是我从其他项目作者的阅读中逻辑推断出来的东西。我认为这个术语的种子来自于gfx-rs / wgpu-rs领域的讨论。但它在我的Rust笔记、Trello板和代码库注释中都有突出的地位:LIFETIME CONTAGION。也许“generic contagion”更准确。 - Xharlie
1个回答

2

简而言之,如果您不需要执行程序的未来引用执行程序周围的本地数据,则应只使用'static

#[cfg(test)]
struct MockThing {
    executor: LocalExecutor<'static>
}

ExecutorLocalExecutor都有生命周期,以便允许它们运行的future从外部环境中借用数据。例如,以下代码可以编译并正常运行:

// local data
let greeting = "foo".to_owned();

// executor
let local_ex = LocalExecutor::new();

// spawn a future that references local data
let handle = local_ex.spawn(async {
    println!("Hello {}", greeting);
});
future::block_on(local_ex.run(handle));

// data still alive
println!("done {}", greeting);

LocalExecutor(类似于其同类Executor)跟踪所借值的生命周期,并静态地证明没有借用将超过该值的寿命。这就是它的结构体上的生命周期'a的含义:它表示提交给执行器的未来所借值的范围的交集。

除了'static生命周期外,您无法在构造LocalExecutor::<'foo>::new()时显式指定并命名'foo生命周期。相反,生命周期会自动推导,在本例中推导为的范围,并且只有在接收它的类型和函数中才会得到命名。这就像闭包的类型,在声明闭包时未命名,但在泛型函数将其作为T: Fn()接受时会得到命名一样。类似地,调用者无法在LocalExecutor上指定生命周期,但LocalExecutor<'a>将其视为'a

现在让我们使用tokio尝试相同的操作

let greeting = "foo".to_owned();

let runtime = tokio::runtime::Runtime::new().unwrap();
let handle = runtime.spawn(async {
    println!("Hello {}", greeting);
});
runtime.block_on(handle);
drop(runtime);  // greeting outlives runtime

println!("done {}", greeting);

上述代码显然是正确的,因为greeting的生命周期长于运行时,但它却无法编译:

error[E0373]: async block may outlive the current function, but it borrows `greeting`, which is owned by the current function
 --> src/main.rs:6:38
  |
6 |       let handle = runtime.spawn(async {
  |  ______________________________________^
7 | |         println!("Hello {}", greeting);
  | |                              -------- `greeting` is borrowed here
8 | |     });
  | |_____^ may outlive borrowed value `greeting`
  |
  = note: async blocks are not executed immediately and must either take a reference or ownership of outside variables they use
help: to force the async block to take ownership of `greeting` (and any other referenced variables), use the `move` keyword
  |
6 |     let handle = runtime.spawn(async move {
  |                                      ++++

tokio不允许在任何future中使用外部引用。

它们必须满足 'static 约束,这意味着一个future不能包含对其外部环境的任何引用,除了 'static 数据。(它们也可以拥有任何数据,这就是为什么编译器建议使用 move 的原因 - 但在这种情况下,最后一个 println!() 会编译失败,因为 greeting 已经不存在了。)

如果您不需要从本地上下文中借用,请只使用 'static 作为生命周期:

#[cfg(test)]
struct MockThing {
    executor: LocalExecutor<'static>
}

...你使用tokio并不会变得更糟。没有生命周期污染,只允许futures拥有值(这对于许多用例来说是可以接受的,正如tokio接受这种限制一样)。

如果我能在成员定义中使用'_'就好了,以此表示执行器的生命周期应该从MockThing的生命周期中推导出来。

这不是生命周期的工作方式。生命周期'a'是一个作用域,在调用方环境中的源代码行集合,这不是父结构体可以提供的内容(但是)。非静态生命周期必须与周围环境中的本地对象相连接。

在上面的第一个代码片段中,LocalExecutor的生命周期自动推导为greeting本地变量的生命周期。如果我们借用了多个变量,生命周期将是最短寿命的那个。如果我们借用的变量作用域不重叠,我们将得到编译错误。


非常棒的回复,谢谢。我会更详细地研究并尝试消化Matsakis关于Polonius的讲话,如果我满意的话,就会接受你的回答作为答案。 - Xharlie
2
@Xharlie 注意,关于Polonius的讨论与此处问题基本无关,但"yet"链接实在太酷了不能错过。这个最近的问题是一个更好的例子,展示了目前借用检查器拒绝但Polonius可以接受的完全有效的代码(无需对代码进行任何修改)。 - user4815162342

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接