Rust编程Rust 开发者

如何通过 &mut T 类型的方差防止将 &mut &'long str 安全地赋值给 &mut &'short str,并且如果允许这样做会导致什么样的内存安全问题?

用 Hintsage AI 助手通过面试

问题的答案

问题的历史

类型系统中的方差决定了通用参数之间的子类型关系如何影响整体类型。Rust 的方法受到基于区域的内存管理研究的深刻影响,以及防止使用后释放漏洞的必要性。当 Rust 引入可变引用 (&mut T) 时,设计师必须决定它们应该是协变(像 &T),反变,还是不变。选择 &mut T 相对于 T 的不变性对于维护内存安全而不需要运行时检查至关重要。

问题

如果 &mut T 相对于 T 是协变的,您可以在预期 &mut V 的地方替换 &mut U,只要 UV 的子类型。从生命周期的角度来看,由于 'long'short 的子类型(因为 'long 的生存期长于 'short),这意味着您可以将 &mut &'long str 赋给 &mut &'short str。这看起来无害,但会造成一个健全性漏洞。

解决方案

&mut TT 上是 不变的。这意味着 &mut &'a str&mut &'b str 是无关的类型,除非 'a 恰好等于 'b,无论生命周期之间的子类型关系如何。编译器会拒绝试图在它们之间进行强制转换的代码,防止将短期存在的数据赋给期望更长期引用的位置,通过可变间接。

代码示例:

fn demonstrate_invariance() { let mut long_lived: &'static str = "static string"; // 如果 &mut T 是协变的,这将会编译通过: // let short_ref: &mut &'short str = &mut long_lived; // 但由于 &mut T 是不变的,这将失败: // error: lifetime mismatch // let short_ref: &mut &'_ str = &mut long_lived; let local = String::from("temporary"); // 如果允许上面的代码,我们可以这样做: // *short_ref = &local; // 现在 long_lived 指向已释放的数据(UAF!) } // local 在这里被丢弃

生活中的情况

一个团队正在为高性能网络堆栈构建 配置管理器。核心结构需要持有一个可以在运行时交换的协议配置的可变引用,而不需要获取所有权。

问题: 最初的 API 设计使用 &mut &'a Config,其中 'a 是网络会话的生命周期。开发人员试图用 &mut &'static Config(用于全局默认配置)初始化这一点,然后将其传递给期待 &mut &'session Config 的函数。编译器拒绝了这个,导致困惑,因为不可变引用 (& &'static Config) 工作正常。

考虑的解决方案:

1. 使用 Unsafe Transmute 强制转换 团队考虑使用 std::mem::transmute&mut &'static Config 转换为 &mut &'session Config。这将绕过编译器的方差检查。然而,这会允许将短期存在的配置引用写入可能超出当前作用域的地方,在配置被丢弃后访问将导致立即的未定义行为。在生产代码中引发使用后释放的风险使得这一点不可接受。

2. 改为使用不可变引用 他们考虑将 API 更改为使用 & &'a Config 而不是 &mut &'a Config。由于共享引用是协变的,& &'static Config 可以强制转换为 & &'session Config。然而,这会失去在运行时更新期间原子交换配置的能力,这是热重新加载设置而无需重新启动连接的核心要求。

3. 使用 Cell<&'a Config> 实现内部可变性 这个选项将允许通过共享引用进行变更。然而,Cell<T>T 上也是不变的,出于相同的安全原因,因此没有解决方差问题。此外,Cell 不提供多线程访问的同步,并且使用 RefCell 进行运行时借用检查的开销被认为在热路径中太昂贵。

4. 重新设计使用拥有类型和间接 选择的解决方案完全消除了引用到引用的模式。构造体存储 &'a mut ConfigHolder,其中 ConfigHolder 是一个拥有的包装器,而不是 &mut &'a Config。这将可变性转移到持有者级别,而不是引用级别,从而避免了方差陷阱,同时维护了交换配置的能力。API 变得更加符合人体工程学,因为用户不再需要管理双重引用。

结果: 重新设计产生了一个更安全的 API,该 API 在不使用不安全代码的情况下编译通过。&mut T 的不变性迫使团队认识到潜在的架构缺陷,在这些缺陷中生存期假设可能会被违反。最终系统防止了一类错误,即过期的配置指针可能在过期后仍然存在。

候选人常常遗漏的内容

为什么 Cell<T> 在 T 上是不变的,这与 &mut T 的方差有什么关系?

Cell<T> 提供内部可变性,允许通过共享引用进行变更。如果 Cell<T>T 上是协变的,您可以将 Cell<&'short str> 上升到 Cell<&'static str>。然后,您可以在其中存储一个短期存在的字符串引用,并在之后通过 Cell<&'static str> 类型读取它,将临时数据视为静态。这将是一个使用后释放漏洞。因此,像 &mut T 一样,Cell<T> (和 UnsafeCell<T>)必须在 T 上是不变的,以防止将短期存在的数据写入声称持有长期存在数据的槽中。这种不变性传播到 RefCellMutex 和其他内部可变类型。

PhantomData<T> 如何影响包含无实际 T 的结构的方差,为什么要使用 PhantomData<fn(T)> 来实现反变?

PhantomData<T> 告诉编译器将该结构视为拥有一个 T,用于方差和丢弃检查。默认情况下,PhantomData<T> 赋予结构与 T 相同的方差。然而,函数指针具有特殊的方差:fn(A) -> BA(参数)上是反变的,而在 B(返回)上是协变的。如果您需要一个结构在生命周期上是反变的(意味着 Struct<'long>Struct<'short> 的子类型,而 'long 的生存期长于 'short),则使用 PhantomData<fn(T)>。这对于构建类型安全的回调或比较器至关重要,其中生命周期之间的关系必须被反转。

在不安全代码中,当使用原始指针实现自引用结构时,为什么该结构必须在其生命周期参数上标记为不变?

当一个结构包含指向同一结构内其他数据的原始指针(自引用)时,该结构的生命周期决定了指针的有效性。如果该结构在其生命周期 'a 上是协变的,则可以将 'a 缩小为更短的生命周期 'b,有效地声称该结构仅存在于 'b 期间。然而,内部的原始指针是在结构存在更长时间时创建的,可能指向在更短作用域中不再有效的数据。不变性确保该结构不能被强制转换为较短的生命周期,从而维护自引用在类型系统编码的整个生命周期内都是有效的安全不变。这就是为什么 Pin 常与不安全自引用实现中的显式方差标记结合使用的原因。