Rust如何实现仅在编译时检查指针安全性?

17
我曾经看到过,在使用指针的语言中,编译器无法在编译时完全确定所有指针是否正确地使用或有效(引用一个存在的对象),这是由于这本质上等同于解决停机问题。直觉上并不奇怪,因为在这种情况下,我们将能够推断出程序的运行行为,类似于相关问题所述。
然而,从我所知道的来看,Rust语言要求指针检查完全在编译时完成(至少对于“安全”指针没有未定义行为,并且没有“无效指针”或“空指针”运行时异常)。
假设Rust编译器不解决停机问题,那么这个谬误在哪里呢?
  • 是指针检查不完全在编译时完成吗?与C中的原始指针相比,Rust的智能指针是否仍然引入了一些运行时开销?
  • 还是可能Rust编译器不能做出完全正确的决策,有时需要信任程序员™,可能使用生命周期注释(具有<'lifetime_ident>语法的注释)之一?在这种情况下,这是否意味着指针/内存安全性保证不是100%,仍然依赖于程序员编写正确的代码?
  • 另一种可能性是Rust指针是非“通用”或受到某种限制,以便编译器可以在编译时完全推断它们的属性,但它们不像C中的原始指针或C++中的智能指针那样有用。
  • 或者可能是其他完全不同的东西,我误解了一个或多个{“指针”、“安全性”、“保证”、“编译时”}。

5
这句话的意思是,在使用指针的编程语言中,由于各种原因编译器无法在编译时完全确定所有指针是否正确使用和/或有效(指向活动对象),因为这实际上等同于解决停机问题。我对这个说法表示质疑。也许对于C风格的指针来说是如此,因为它们缺乏各种好处并具有各种坏处,比如指针算术运算,但就人们实际从指针中需要的东西而言,Rust的引用是该说法无效的完美例证。 - Chris Morgan
@ChrisMorgan 我明白了,谢谢。 - The Paramagnetic Croissant
3个回答

10

免责声明:我有点匆忙,所以这篇文章可能有些冗长。请随意加以简化。

语言设计师最讨厌的“秘密技巧”基本上就是:Rust只能处理'static生命周期(用于全局变量和其他整个程序生命周期的事物)和栈(即局部)变量的生命周期,无法表达或处理分配的生命周期。

这意味着几件事情。首先,所有与堆分配相关的库类型(例如Box<T>Rc<T>Arc<T>)都拥有它们所指向的对象。因此,它们实际上不需要生命周期才能存在。

您需要生命周期的地方是当您访问智能指针的内容时。例如:

let mut x: Box<i32> = box 0;
*x = 42;

在第二行背后发生的事情是这样的:

{
    let box_ref: &mut Box<i32> = &mut x;
    let heap_ref: &mut i32 = box_ref.deref_mut();
    *heap_ref = 42;
}

换句话说,因为 Box 并非神奇,我们必须告诉编译器如何将其转换为常规的借用指针。这就是Deref DerefMut 特性所用之处。这引出了一个问题: heap_ref 的生命周期是什么?

答案在DerefMut 的定义中(从内存中得出,因为我很匆忙):

trait DerefMut {
    type Target;
    fn deref_mut<'a>(&'a mut self) -> &'a mut Target;
}

就像我之前说的,Rust 绝对不能 谈论“堆生存期”。相反,它必须将堆分配的i32的生命周期与其手头上唯一的其他生命周期:即Box的生命周期联系起来。

这意味着“复杂”的事物没有可表达的生命周期,因此必须拥有它们所管理的东西。当您将复杂智能指针/句柄转换为简单的借用指针时,就是您必须引入生命周期的时刻,通常只使用句柄本身的生命周期。

实际上,我应该澄清一下:通过“句柄的生命周期”,我真正的意思是“句柄当前存储的变量的生命周期”:生命周期真正是为了存储而不是为了。这通常是新手入门 Rust 时被绊倒的原因,因为他们无法弄清楚为什么不能做以下操作:

fn thingy<'a>() -> (Box<i32>, &'a i32) {
    let x = box 1701;
    (x, &x)
}

"但是……我知道这个盒子将继续存在,为什么编译器说它不会?!" 因为Rust无法推断堆生命周期,必须&x的生命周期绑定到变量x,而不是它指向的堆分配。


8
“指针检查是否完全在编译时完成,相对于C中的原始指针,Rust的智能指针是否仍然引入了一些运行时开销?” “有一些无法在编译时检查的特殊运行时检查。这些通常在cell crate中找到。但总的来说,Rust会在编译时检查所有内容,并应该生成与C中相同的代码(如果你的C代码没有做未定义的事情)。 ”
“或者Rust编译器不能做出完全正确的决定,有时需要信任程序员™,可能使用生命周期注释之一(带有'life_ident'语法)?在这种情况下,这是否意味着指针/内存安全保证不是100%,仍然依赖于程序员编写正确的代码?” “If the compiler cannot make the correct decision you get a compile time error telling you that the compiler cannot verify what you are doing. This might also restrict you from stuff you know is correct, but the compiler doesn't. You can always go to unsafe code in that case. But as you correctly assumed, then the compiler relies partly on the programmer.”
编译器检查函数的实现,以确定它是否完全符合生命周期所描述的内容。然后,在函数的调用点,它会检查程序员是否正确使用该函数。这类似于类型检查。C++编译器检查您是否返回了正确类型的对象。然后它在调用点检查返回的对象是否存储在正确类型的变量中。在任何时候,函数的程序员都不能违反承诺(除非使用unsafe,但您始终可以让编译器强制执行在项目中不使用unsafe)。
Rust正在不断改进。一旦编译器变得更加智能,Rust中可能会出现更多的合法操作。
另一个可能性是Rust指针是非“通用”的或以某种方式受到限制,因此编译器可以在编译时完全推断它们的属性,但它们并不像C中的原始指针或C++中的智能指针那么有用。
在C中有几件事情可能会出错:
1. 悬空指针 2. 重复释放 3. 空指针 4. 野指针
在安全的Rust中,这些错误不会发生。
  1. 在编译时,通过生命周期证明,您永远无法拥有指向不再位于堆栈或堆上的对象的指针。
  2. Rust 中没有手动内存管理。使用 Box 来分配您的对象(类似但不等同于 C++ 中的 unique_ptr)。
  3. 同样,没有手动内存管理。 Box 会自动释放内存。
  4. 在安全的 Rust 中,您可以创建指向任何位置的指针,但无法对其进行解引用。您创建的任何引用都始终绑定到一个对象。

C++ 中有一些问题:

  1. 所有 C 中可能出现的问题
  2. 智能指针只能帮助您不忘记调用 free。您仍然可以创建悬空引用:auto x = make_unique<int>(42); auto& y = *x; x.reset(); y = 99;

Rust 解决了这些问题:

  1. 见上文
  2. 只要存在y,你就不能修改x。这在编译时进行检查,不能通过更多的间接层或结构来规避。

我曾经在某个地方读到,在一种具有指针特性的语言中,由于各种原因,编译器无法在编译时完全决定所有指针是否被正确使用和/或有效(参考一个活动对象),因为这实际上等同于解决停机问题。

Rust并没有证明所有指针都被正确使用。你仍然可以编写虚假程序。Rust证明您未使用无效指针。Rust证明您从未拥有空指针。Rust证明您从未拥有指向同一对象的两个指针,除非这些指针都是不可变的(const)。 Rust不允许您编写任何程序(因为这将包括违反内存安全性的程序)。现在Rust仍然阻止您编写一些有用的程序,但计划允许在安全的Rust中编写更多(合法的)程序。

这并不奇怪,直觉上来说,因为在这种情况下,我们将能够在编译期间推断程序的运行时行为,类似于this related question中所述的内容。
重新审视您所引用的关于停机问题的相关问题的示例:
void foo() {
    if (bar() == 0) this->a = 1;
}

上述的C++代码在Rust中可能有以下两种方式:
fn foo(&mut self) {
    if self.bar() == 0 {
        self.a = 1;
    }
}

fn foo(&mut self) {
    if bar() == 0 {
        self.a = 1;
    }
}

对于任意的bar,你无法证明这一点,因为它可能会访问全局状态。Rust即将推出const函数,可以用于在编译时计算东西(类似于constexpr)。如果barconst,那么在编译时就变得很容易证明self.a是否设置为1。除了pure函数或其他限制函数内容的情况外,你永远无法证明self.a是否被设置为1
目前,Rust并不关心你的代码是否被调用。它关心的是在赋值期间self.a的内存是否仍然存在。self.bar()永远不会销毁self(除非在unsafe代码中)。因此,在if分支内部self.a将始终可用。

非常好的解释。只有一件事情对我来说不是完全清楚的:你所说的“Rust不能证明所有指针都被正确使用”是什么意思?当然,人们可以编写“虚假”的程序,即那些应该执行某些操作但实际上执行了不同的操作,并给出了错误的结果。通过“正确使用指针”,我指的是“您不能因为无效/空/悬挂指针而导致内存错误”。 - The Paramagnetic Croissant
是的。您仍然可以在刚读取的整数上写入42,并且之后想知道为什么您键入任何内容都无所谓,因为您的整数为42。这是一种逻辑错误,而不是内存错误。 - oli_obk

3
大多数Rust引用的安全性都是通过严格的规则来保证的:
- 如果您拥有一个const引用(&),则可以克隆此引用并传递它,但不能从中创建可变的&mut引用。 - 如果存在对对象的可变(&mut)引用,则不能存在对该对象的其他引用。 - 引用不允许超出其所引用的对象的生命周期,所有操作引用的函数必须使用生命周期注释(如'a')声明它们输入和输出的引用如何链接。
因此,在表达能力方面,我们实际上比仅使用原始指针更受限制(例如,仅使用安全引用无法构建图结构),但这些规则可以在编译时完全检查。
然而,仍然可以使用原始指针,但必须将处理它们的代码放在unsafe { /* ... */ }块中,并告诉编译器“相信我,在这里我知道我在做什么”。这就是一些特殊智能指针内部所做的事情,例如RefCell,它允许您在运行时而不是编译时检查这些规则,以获得表现力。

所有操作引用的函数都必须声明它们输入和输出的引用如何链接 - 是的,这正是我所指的生命周期注释。这是否意味着如果我错误地使用了生命周期注释,我会解引用无效指针并导致崩溃? - The Paramagnetic Croissant
不行,因为编译器实际上会检查你的注释是否正确。有时这会迫使程序员做一些不寻常的事情,以便编译器能够看到注释是正确的。 - Levans
但是另一方面,如果您在“不安全”的块中做错了什么,任何事情都可能发生。 - Levans
当然,在unsafe块中可能发生任何事情,因此我对它们不感兴趣。因此,答案是“如果编译器无法证明它是正确的,则是非法的。”谢谢! - The Paramagnetic Croissant

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