在作用域内引用生命周期不正确(比正确的寿命长)的内容,这样可以吗?

9
拥有一个引用&'a T是否会立即导致未定义行为(UB),如果'a大于所引用的类型T的值,还是只要它没有超出类型T的生命周期就可以拥有这样的引用呢? 作为比较:即使您从未访问返回的值,mem::transmute::<u8, bool>(2)也会立即导致UB。 如果具有值0的引用,则情况相同,因为引用始终必须有效,即使您从未访问它们。 另一方面,具有ptr::null()不是问题,直到您尝试取消引用空指针。 考虑以下代码:
let x = '';
let r_correct: &char = &x;

{
    let r_incorrect: &'static char = unsafe { mem::transmute(r_correct) };
    println!("{}", r_incorrect);
}

这段代码中有两个对x的引用。但是,两者都不超过x的寿命。但r_incorrect 的类型明显是个谎言,因为 x并没有永久存在。

这段代码是否表现出了良好定义的行为?我觉得有三种选择:

  • (a) 这段代码表现出未定义的行为。
  • (b) 这段代码的行为是良好定义的(“安全的”)。
  • (c) Rust尚未定义关于这部分语言的规则。

我不确定这段代码是否可以作为参考,但我非常确定它是不安全的。 - Ömer Erden
@ÖmerErden 嗯,那段代码肯定做了某些被禁止的事情:在仍然存在对它的引用时,它却丢弃了 x。编译器没有捕获到这一点,因为我们欺骗了生命周期。但我的问题是:仅仅是欺骗生命周期,而没有做任何其他“坏事”,就已经是未定义行为了吗? - Lukas Kalbertodt
好的观点,如果“不安全”代码可以被安全代码误用以展示未定义行为,则它是不可靠的。根据书中的说法,我的例子表明它“可以被”安全代码(悬空指针)误用。我猜我们可以说你的代码是安全的(按照你的术语),但不可靠。 - Ömer Erden
1
@ÖmerErden 确实,那是一句好引言,答案当然应该提到这一点。但那并不完全是问题所在。我的问题确切地说就是关于问题中的代码片段。就是那个。我们可以运行代码而且它不会崩溃,太好了。但这并不意味着行为是明确定义的。 - Lukas Kalbertodt
3个回答

6
不,只有在“x”超出作用域后访问“r_incorrect”时才会发生未定义行为,而您在这里没有这样做。
Rust中的生命周期注释由编译器进行“检查”,以确保您不会执行任何可能导致内存不安全的操作。但是——假设借用检查器很高兴——它们对生成的二进制文件或变量实际存在的时间没有影响。
在您的示例中,您向编译器声明“r_incorrect”的生命周期要比实际更长,但没有问题,因为您只在其有效生命周期内访问它。
这种方法的危险在于,“未来的更改”可能会尝试在超出其真实生命周期之后使用“r_incorrect”。编译器无法阻止发生这种情况,因为您已经坚持认为这是可以的。

1
“after r_correct has gone out of scope” -> 你可能是指 x,对吗?另外:你有这方面的任何来源吗?生成的二进制文件不依赖于生命周期这一点?当涉及到 unsafe 的内容时,来源总是很重要的 :P - Lukas Kalbertodt
我猜你真正想问的问题是:“当编译器看到静态生命周期时,它是否会使用该信息进行优化?”我不确定这是否明确写在任何地方,但我肯定答案永远都是“不”。生命周期的目的是证明借用是有效的或者在可能无效时产生错误,并且在编译器优化步骤之前完全被抹去。 - Peter Hall
Rust 可以并且确实使用生命周期信息来执行优化。它使用生命周期来确保各种优化的正确性(例如使用 llvm 的 noalias 属性),还使用生命周期用于 llvm 的 lifetime.startlifetime.end 内部函数。话虽如此,只要在创建 'static 引用后没有对 x 进行可变借用,并且在释放 x 后不存在引用,我相信代码仍然是良好定义的。 - Aiden4
这个答案是错误的:即使你不从中读取任何内容,一个悬空引用的存在也会造成未定义行为。 - Dirbaio

2
据我所知,没有官方资源明确说明具有比正确寿命更长的引用和/或取消引用引用是否会导致未定义的行为。但是,有多个资源谈论了Rust中的未定义行为以及具有无限寿命的引用的取消引用,这些资源提供了关于执行此操作的定义性的提示。
引起Rustonomicon中未定义行为的事物:
引用Rustonomicon,第“什么是不安全”的章节中的引用(本文和所有后续引用中的粗体突出显示[方括号内的斜体文本]由我完成):
与 C 不同,Rust 中未定义行为的范围非常有限。所有核心语言关心的仅仅是防止以下几种情况发生:
- 解引用(使用 * 操作符)悬空或未对齐的指针(见下文) - 违反指针别名规则 - 以错误的调用 ABI 调用函数或从错误的取消 ABI 函数中取消。 - 导致数据竞争 - 执行使用当前执行线程不支持的目标特性编译的代码 - 生成无效值(作为单独的值或复合类型(如enum/struct/array/tuple)的字段): - [很多与引用生命周期无关的子项]
"生成"值是指任何时候都会分配值、传递给函数/原始操作或从函数/原始操作返回值。如果引用/指针为“悬空”,则它为空或者它所指向的字节不全部属于同一分配(因此它们都必须属于某个分配)。它所指向的字节跨度由指针值和指针类型的大小确定。因此,如果跨度为空,则“悬空”与“非空”相同。请注意,切片和字符串指向其整个范围,因此重要的是长度元数据永远不要太大(特别是,分配和因此切片和字符串不能超过isize::MAX字节)。如果由于某种原因这太麻烦了,请考虑使用裸指针。这就是所有嵌入到 Rust 中的未定义行为的原因。当然,不安全的函数和特性可以自由地声明程序必须维护以避免未定义行为的任意其他约束。

只有前两个要点与指针和/或引用相关。

  • The first point is about dereferencing dangling and unaligned pointers.

    • Unaligned pointers: Transmuting the lifetime of a reference cannot change its alignedness, so this is not a problem here.
    • Dangling pointers: During the correct lifetime, a reference cannot be dangling according to the definition above. Therefore, during the correct lifetime, the transmuted reference is also not dangling and can be dereferenced without causing undefined behavior.
  • The second point is about the pointer aliasing rules of Rust. Quote from the Rustonomicon, chapter "References" (which is also linked in the quote above when talking about pointer aliasing rules):

    There are two kinds of reference:

    • Shared reference: &
    • Mutable reference: &mut

    Which obey the following rules:

    • A reference cannot outlive its referent
    • A mutable reference cannot be aliased

    That's it. That's the whole model references follow.

    Of course, we should probably define what aliased means.

    error[E0425]: cannot find value `aliased` in this scope
     --> <rust.rs>:2:20
      |
    2 |     println!("{}", aliased);
      |                    ^^^^^^^ not found in this scope
    
    error: aborting due to previous error
    

    Unfortunately, Rust hasn't actually defined its aliasing model.

    While we wait for the Rust devs to specify the semantics of their language, let's use the next section to discuss what aliasing is in general, and why it matters.

    • That first point does indeed not sound that good for our case – "a reference cannot outlive its referent". However, quote from the Rustonomicon, chapter "Lifetimes", section "The area covered by a lifetime":

      The lifetime (sometimes called a borrow) is alive from the place it is created to its last use. The borrowed thing needs to outlive only borrows that are alive.

      So, as we are not using the reference after the end of the correct lifetime, it is not alive anymore. Therefore, the referent also does not need to be alive after the end of the correct lifetime.

    • The second point is only about mutable references – in your example, a shared reference is used. And either way, as long as the original value is not used while the transmuted reference is alive, there is no pointer aliasing going on by any definition (though, as the Rustonomicon says, there is no aliasing model defined for Rust, so language lawyering about this is hard...)

结论 - Rustonomicon中导致未定义行为的因素

Rustonomicon中列举的触发未定义行为的因素不包括具有比正确生命周期更长的引用并对其进行解引用只要在正确生命周期结束后不再访问此引用

Rust参考文档中导致未定义行为的因素

Rustonomicon并非唯一讨论未定义行为的文档。引用自Rust参考文档,章节“被视为未定义的行为”

如果 Rust 代码表现出以下行为中的任何一种,那么它就是错误的。这包括在 `unsafe` 块和 `unsafe` 函数内部的代码。`unsafe` 只意味着避免未定义的行为取决于程序员;它并不改变 Rust 程序绝不能引起未定义行为的事实。
编写 `unsafe` 代码时,程序员有责任确保与 `unsafe` 代码交互的任何安全代码都无法触发这些行为。对于任何安全客户端都满足此属性的 `unsafe` 代码称为“sound”;如果安全代码可以误用 `unsafe` 代码以表现未定义行为,则其为“unsound”。
⚠️ 警告:以下列表不是详尽无遗的。Rust 的语义模型没有正式的模型来确定 `unsafe` 代码中允许什么,不允许什么,因此可能会有更多被认为是不安全的行为。以下列表只是我们知道的肯定是未定义行为的内容。请在编写 `unsafe` 代码之前阅读 Rustonomicon
数据竞争。
在悬空或未对齐的原始指针上评估解引用表达式(*expr),即使在放置表达式上下文中(例如 addr_of!(&*expr))也是如此。
违反指针别名规则。&mut T 和 &T 遵循 LLVM 的作用域 noalias 模型,但如果 &T 包含 UnsafeCell 则除外。
更改不可变数据。所有 const 项内的数据都是不可变的。此外,通过共享引用访问或由不可变绑定拥有的数据都是不可变的,除非该数据包含在 UnsafeCell 中。
通过编译器内部函数调用来调用未定义行为。
执行使用当前平台不支持的平台特性编译的代码(请参见 target_feature)。
使用错误的调用 ABI 调用函数或从具有错误展开 ABI 的函数展开。
生成无效值,即使在私有字段和局部变量中也是如此。每当将值分配给或从位置读取、传递给函数/原始操作或从函数/原始操作返回时,都会“生成”一个值。以下值是无效的(在其各自的类型上):
[大量与引用生命周期无关的子项]
注意:对于具有受限制的有效值集的任何类型,未初始化的内存也隐式为无效。换句话说,只有在 union 中和“填充”(类型的字段/元素之间的间隙)中才允许读取未初始化的内存。
注意:未定义行为影响整个程序。例如,在 C 中调用表现出 C 的未定义行为的函数意味着您的整个程序包含未定义行为,这也可能影响 Rust 代码。反之,在 Rust 中的未定义行为可能对通过任何 FFI 调用其他语言执行的代码产生不良影响。
悬空指针
如果引用/指针为空或它们指向的所有字节都不属于同一分配(因此它们都必须属于某个分配),则它们是“dangling”。它所指向的字节跨度由指针值和指针类型的大小(使用 size_of_val)确定。因此,如果跨度为空,则“悬挂”与“非空”相同。请注意,切片和字符串指向其整个范围,因此长度元数据永远不能太大。特别地,分配和因此切片和字符串的大小不能超过 isize::MAX 字节。
这个列表,包括两个粗体点,与Rustonomicon中的列表基本相同(虽然不太严格,因为第一个粗体符号只禁止解除悬空的原始指针,而不是解除悬空的引用 - 我想这是疏忽)。有一些有趣的链接到LLVM文档,但最终结果是相同的:根据此列表,具有比正确寿命更长的引用不会导致未定义行为,只要在正确寿命结束后不解引用该引用。但是,在这里还有一个额外的注释:

⚠️ 警告:以下列表不是详尽无遗的。 Rust的语义没有正式模型来确定不安全代码中允许或不允许的内容,因此可能会有更多被认为是不安全的行为。以下列表仅表示我们确定的未定义行为。编写不安全代码之前,请阅读[Rustonomicon]。

结论-根据Rust参考手册导致未定义行为的事情

Rust参考手册中没有详尽列举所有可能引发未定义行为的事项。虽然Rust参考手册没有明确说明具有超过正确生命周期的引用会触发未定义行为,但它也没有明确说明它们不会触发。

关于无界生命周期的Rustonomicon

来自Rustonomicon, 章节“无界生命周期”的引用:

不安全的代码通常会产生无中生有的引用或生命周期。这样的生命周期是 无限制的。最常见的来源是对裸指针进行解引用,这会产生一个具有无限制生命周期的引用。这样的生命周期会随着上下文需求而变得越来越大。实际上,这比简单地成为 'static 更加强大,因为例如 &'static &'a T 将无法进行类型检查,但无限制生命周期将完美地适应 &'a &'a T。然而,对于大多数意图和目的,这样的无限制生命周期可以被视为 'static

几乎没有引用是 'static 的,所以这可能是错误的。 transmutetransmute_copy 是另外两个主要的罪犯。人们应该尽快将无限制生命周期限定在一个范围内,特别是跨函数边界时。

Rustonomicon提到应该“尽快限定无限生命周期,特别是在函数边界上”。它没有表明解引用无限制的引用(假设引用对象仍然存在)会导致未定义的行为。由于解引用是一种常见操作,我无法想象Rustonomicon不指出这样一个显而易见的陷阱。因此,只要引用对象仍然存在,我得出结论:解引用无限制的引用不会导致未定义的行为。
然而,问题不在于生命周期无限制的引用,而在于生命周期大于正确生命周期的引用,例如&'static T。Rustonomicon指出,“对于大多数意图,无限生命周期可以被视为'static”。这并不确定意味着与具有无限生命周期的引用相比,解引用具有更长生命周期的引用也是已定义的行为。但是,我不明白为什么rustc在这方面会以不同方式处理无限生命周期。如果是这样,我希望Rustonomicon包含一条注释说明它的确如此,并且无限生命周期仍然比错误绑定的生命周期更安全。
结论- Rustonomicon关于无限生命周期
"Dereferencing"指对一个生命周期不确定的引用进行解引用,根据Rustonomicon,这可能不是未定义行为。对于生命周期“有界但大于正确值”的引用,这是否适用尚不确定,但我认为是适用的。 transmute()文档中的示例:标准库std::mem::transmute()文档的第二个示例:

Extending a lifetime [...]. This is advanced, very unsafe Rust!

struct R<'a>(&'a i32);
unsafe fn extend_lifetime<'b>(r: R<'b>) -> R<'static> {
    std::mem::transmute::<R<'b>, R<'static>>(r)
}

*[...]*
这是最明确的证据,证明至少拥有比正确寿命更长的引用并不会导致未定义行为 - 否则,任何使用非“静态”引用调用此函数的人都会立即触发未定义行为,而此函数将变得非常无用。此外,对我来说,这意味着您也可以解引用由extend_lifetime返回的引用 - 否则,该函数的好处是什么?
结论 - transmute()文档示例 transmute()文档中的示例似乎暗示着解引用具有比正确寿命更长的引用是被允许的。
最终结论
遗憾的是,Unsafe Rust的详细文档仍然不完整,并且存在许多关于像这样的边缘案例的问题,文档无法给出明确的答案。但是,所有与问题相关的文档似乎都暗示着在实际情况中解引用所讨论的引用是良好定义的行为。是否足够让您像这样做某些事情取决于您 - 对我来说可能足够了。
然而,您真的不应该这样做。
为了澄清:虽然这段代码可能定义良好,但它肯定仍然是一个“踩脚枪”。跨越函数边界传递这样的引用是一个坏主意,特别是如果该函数是公共的,并且可以被其他模块/板条箱调用。即使不跨越函数边界传递,仍然很容易误用此引用,导致您的代码引起未定义的行为。如果您认为可能需要这样做,请重新考虑是否可以重构代码以避免转换寿命。例如,直接使用具有不正确寿命的引用而不是裸指针可能更安全(或者至少更清楚地表明这是完全不安全的)。

谢谢您的回答!很遗憾,没有文档明确回答我的问题,但正如您所说:大多数文档都暗示了我的代码不是未定义行为,所以,对我来说可能已经足够了。关于您的回答只有一件事:考虑从引用中删除更多的无关内容,使整个答案更简短一些。但是,还是非常感谢您的努力! - Lukas Kalbertodt
不客气!你说得对,引号可以更简洁一些。如果我有时间的话,今天稍后我可能会重新修改一下。感谢你的反馈和奖励! - Elias Holzmann

1
只要指向的值没有被释放,即使引用的生命周期不正确,它也是有效的。在释放该值之前,必须确保不存在对该值的引用。即使您从未读取/写入过它,引用存在于已释放的值上是未定义行为。从引用的undefined behavior中可知:即使是私有字段和局部变量,产生无效值也会导致无效行为,包括指向无效值的悬空引用或Box。 Rust引用被认为是“仅寿命检查的指针”是一个常见的误解。实际上,它们比这严格得多:它们必须在其存在的整个时间内有效(非悬空、对齐、指向有效值),即使您不读取/写入它们。与原始指针相比,后者只需要在读取/写入时有效。
只要确保引用不会悬空,就算引用具有“错误”的生命周期,它本身并不属于未定义行为。行为被视作未定义中也没有这样的说明。生命周期只是借用检查器强制执行引用有效性的工具,使用transmute绕过生命周期并不会立即导致未定义行为,这仅意味着现在你需要确保所有引用都是有效的。

感谢您的回答!我将奖励授予了另一个答案,因为我喜欢在一个地方获得所有相关文档的概述。无论如何,谢谢! - Lukas Kalbertodt

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