如果我已经实现了 Drop,为什么使用 PhantomData 通知编译器结构拥有泛型很有用



在 Rustonomicon 的PhantomData指南中,有一部分是关于如果一个类似Vec的结构有*const T字段但没有PhantomData<T>会发生什么:

下降检查器将慷慨地确定Vec<T>不拥有任何类型T的值。这反过来又会得出结论,它不需要担心在其析构函数中Vec丢弃任何T来确定丢弃检查的健全性。这反过来又允许人们使用Vec的析构函数创造不健全。

什么意思?如果我为结构实现Drop并手动销毁其中的所有T,我为什么要关心编译器是否知道我的结构拥有一些T

Vec<T>中的PhantomData<T>(通过RawVec<T>内的Unique<T>间接保存)向编译器传达向量可能拥有T的实例,因此当向量被删除时,向量可以运行析构函数T


深入探讨:我们这里有一系列因素:

  • 我们有一个Vec<T>,它有一个impl Drop(即析构函数实现)。

  • 根据 RFC 1238 的规则,这通常意味着Vec<T>实例与T内发生的任何生命周期之间的关系,要求T内的所有生命周期都严格超过向量。

  • 但是,Vec<T>的析构函数通过使用特殊的不稳定属性(请参阅 RFC 1238 和 RFC 1327)专门选择退出该析构函数(Vec<T>本身)的此语义。这允许向量保存与向量本身具有相同生存期的引用。这被认为是合理的;毕竟,向量本身不会取消引用此类引用所指向的数据(它所做的只是删除值和解除分配后备数组),只要重要的警告成立。

  • 重要的警告:虽然向量本身在破坏自身时不会取消引用其包含值内的指针,但它删除向量持有的值。如果这些类型为T的值本身具有析构函数,则T运行这些析构函数。如果这些析构函数访问其引用中保存的数据,那么如果我们允许在这些引用中悬挂指针,我们将遇到问题。

  • 因此,更深入地研究:我们确认给定结构的dropck有效性的方式S,我们首先仔细检查S本身是否有impl Drop for S(如果是,我们对其类型参数的S强制执行规则)。但即使在这一步之后,我们也会递归地下降到S本身的结构中,并仔细检查它的每个字段,根据dropck,一切都是犹太洁食的。(请注意,即使S的类型参数标记为#[may_dangle],我们也会这样做。

  • 在这种特定情况下,我们有一个Vec<T>(间接通过RawVec<T>/Unique<T>)拥有T类型的值集合,用原始指针*const T表示。但是,编译器没有将所有权语义附加到*const T;在结构中单独S该字段意味着ST之间没有关系,因此在ST类型中对生存期的关系没有强制约束(至少从dropck的角度来看)。

  • 因此,如果Vec<T>只有一个*const T,递归下降到向量的结构中将无法捕获向量和向量中包含的T实例之间的所有权关系。这与T上的#[may_dangle]属性相结合,将导致编译器接受不合理的代码(即T的析构函数最终尝试访问已解除分配的数据的情况)。

  • 但是:Vec<T>不仅包含*const T。还有一个PhantomData<T>向编译器传达"嘿,即使你可以假设(由于#[may_dangle] T)Vec的析构函数在删除向量时不会访问T的数据,但仍然有可能T本身的某些析构函数会在删除向量时访问T的数据。

最终效果:给定Vec<T>,如果T没有析构函数,那么编译器为您提供了更大的灵活性(即,它允许向量保存数据,并引用与向量本身存在相同时间的数据,即使这些数据可能在向量之前被拆除)。但是,如果T确实有一个析构函数(并且该析构函数不会以其他方式与编译器通信,它不会访问任何引用的数据),则编译器会更加严格,要求任何引用的数据严格超过向量(从而确保当T析构函数运行时,所有引用的数据仍然有效)。


如果想尝试通过具体的探索来理解这一点,您可以尝试比较编译器在处理小容器类型方面的不同之处,这些容器类型在使用#[may_dangle]PhantomData方面有所不同。

以下是我为说明这一点而编写的一些示例代码:

// Illustration of a case where PhantomData is providing necessary ownership
// info to rustc.
//
// MyBox2<T> uses just a `*const T` to hold the `T` it owns.
// MyBox3<T> has both a `*const T` AND a PhantomData<T>; the latter communicates
// its ownership relationship with `T`.
//
// Skim down to `fn f2()` to see the relevant case, 
// and compare it to `fn f3()`. When you run the program,
// the output will include:
//
// drop PrintOnDrop(mb2b, PrintOnDrop("v2b", 13, INVALID), Valid)
//
// (However, in the absence of #[may_dangle], the compiler will constrain
// things in a manner that may indeed imply that PhantomData is unnecessary;
// pnkfelix is not 100% sure of this claim yet, though.)
#![feature(alloc, dropck_eyepatch, generic_param_attrs, heap_api)]
extern crate alloc;
use alloc::heap;
use std::fmt;
use std::marker::PhantomData;
use std::mem;
use std::ptr;
#[derive(Copy, Clone, Debug)]
enum State { INVALID, Valid }
#[derive(Debug)]
struct PrintOnDrop<T: fmt::Debug>(&'static str, T, State);
impl<T: fmt::Debug> PrintOnDrop<T> {
fn new(name: &'static str, t: T) -> Self {
PrintOnDrop(name, t, State::Valid)
}
}
impl<T: fmt::Debug> Drop for PrintOnDrop<T> {
fn drop(&mut self) {
println!("drop PrintOnDrop({}, {:?}, {:?})",
self.0,
self.1,
self.2);
self.2 = State::INVALID;
}
}
struct MyBox1<T> {
v: Box<T>,
}
impl<T> MyBox1<T> {
fn new(t: T) -> Self {
MyBox1 { v: Box::new(t) }
}
}
struct MyBox2<T> {
v: *const T,
}
impl<T> MyBox2<T> {
fn new(t: T) -> Self {
unsafe {
let p = heap::allocate(mem::size_of::<T>(), mem::align_of::<T>());
let p = p as *mut T;
ptr::write(p, t);
MyBox2 { v: p }
}
}
}
unsafe impl<#[may_dangle] T> Drop for MyBox2<T> {
fn drop(&mut self) {
unsafe {
// We want this to be *legal*. This destructor is not 
// allowed to call methods on `T` (since it may be in
// an invalid state), but it should be allowed to drop
// instances of `T` as it deconstructs itself.
//
// (Note however that the compiler has no knowledge
//  that `MyBox2<T>` owns an instance of `T`.)
ptr::read(self.v);
heap::deallocate(self.v as *mut u8,
mem::size_of::<T>(),
mem::align_of::<T>());
}
}
}
struct MyBox3<T> {
v: *const T,
_pd: PhantomData<T>,
}
impl<T> MyBox3<T> {
fn new(t: T) -> Self {
unsafe {
let p = heap::allocate(mem::size_of::<T>(), mem::align_of::<T>());
let p = p as *mut T;
ptr::write(p, t);
MyBox3 { v: p, _pd: Default::default() }
}
}
}
unsafe impl<#[may_dangle] T> Drop for MyBox3<T> {
fn drop(&mut self) {
unsafe {
ptr::read(self.v);
heap::deallocate(self.v as *mut u8,
mem::size_of::<T>(),
mem::align_of::<T>());
}
}
}
fn f1() {
// `let (v, _mb1);` and `let (_mb1, v)` won't compile due to dropck
let v1; let _mb1;
v1 = PrintOnDrop::new("v1", 13);
_mb1 = MyBox1::new(PrintOnDrop::new("mb1", &v1));
}
fn f2() {
{
let (v2a, _mb2a); // Sound, but not distinguished from below by rustc!
v2a = PrintOnDrop::new("v2a", 13);
_mb2a = MyBox2::new(PrintOnDrop::new("mb2a", &v2a));
}
{
let (_mb2b, v2b); // Unsound!
v2b = PrintOnDrop::new("v2b", 13);
_mb2b = MyBox2::new(PrintOnDrop::new("mb2b", &v2b));
// namely, v2b dropped before _mb2b, but latter contains
// value that attempts to access v2b when being dropped.
}
}
fn f3() {
let v3; let _mb3; // `let (v, mb3);` won't compile due to dropck
v3 = PrintOnDrop::new("v3", 13);
_mb3 = MyBox3::new(PrintOnDrop::new("mb3", &v3));
}
fn main() {
f1(); f2(); f3();
}

Caveat emptor— 我对真正回答你问题的极其深刻的理论并不那么强大。我只是一个使用过 Rust 并阅读过相关 RFC 的外行。 始终参考这些原始来源以获得不太稀释的真相版本。


RFC 769 引入了实际的丢弃检查规则:

v是某个值(临时的或命名的),'a是一些 寿命(范围);如果v类型拥有类型D的数据,其中 (1.)D具有生存期或类型参数化Drop实现,以及 (2.)D的结构可以达到&'a _型的参考,以及(3.) 也:

  • (A.)DDrop impl'a处实例化D直接,即D<'a>,或者,

  • (B.)DDrop impl具有某个类型参数,其中包含 特征绑定T其中T是至少具有 一种方法,

那么'a必须严格超过v的范围.

然后,它进一步定义了其中一些术语,包括一种类型拥有另一种类型的含义。这进一步特别提到了PhantomData

因此,作为上述标准的另一个特殊情况,当类型E拥有类型D的数据时,我们包括:

如果EPhantomData<T>,则递归于T


同时定义两个变量时会出现一个关键问题:

struct Noisy<'a>(&'a str);
impl<'a> Drop for Noisy<'a> {
fn drop(&mut self) { println!("Dropping {}", self.0 )}
}
fn main() -> () {
let (mut v, s) = (Vec::new(), "hi".to_string());
let noisy = Noisy(&s);
v.push(noisy);
}

据我了解,如果没有删除检查规则并指示Vec拥有Noisy,这样的代码可能会编译。删除Vec时,drop实现可能会访问无效的引用;引入不安全。

回到你的观点:

如果我为结构实现Drop并手动销毁其中的所有T,我为什么要关心编译器是否知道我的结构拥有一些T

编译器必须知道您拥有该值,因为您可以/将调用drop。由于drop的实现是任意的,如果要调用它,编译器必须禁止您接受会导致丢弃期间不安全行为的值。

永远记住,任何任意T都可以是值、引用、包含引用的值等。当试图弄清楚这些类型的事情时,重要的是要尝试在任何思想实验中使用复杂的变体。


所有这些都应该提供足够的部分来连接点;为了充分理解,阅读几次RFC可能比依赖我有缺陷的解释更好。

然后它变得更加复杂。RFC 1238 进一步修改了丢弃检查规则,删除了此特定推理。它确实说:

参数性是证明 dropck 做出的推论的必要条件,但不是充分条件。

继续使用PhantomData似乎是最安全的做法,但可能不是必需的。一位匿名的Twitter捐助者指出了这段代码:

use std::marker::PhantomData;
#[derive(Debug)] struct MyGeneric<T> { x: Option<T> }
#[derive(Debug)] struct MyDropper<T> { x: Option<T> }
#[derive(Debug)] struct MyHiddenDropper<T> { x: *const T }
#[derive(Debug)] struct MyHonestHiddenDropper<T> { x: *const T, boo: PhantomData<T> }
impl<T> Drop for MyDropper<T> { fn drop(&mut self) { } }
impl<T> Drop for MyHiddenDropper<T> { fn drop(&mut self) { } }
impl<T> Drop for MyHonestHiddenDropper<T> { fn drop(&mut self) { } }
fn main() {
// Does Compile! (magic annotation on destructor)
{
let (a, mut b) = (0, vec![]);
b.push(&a);
}
// Does Compile! (no destructor)
{
let (a, mut b) = (0, MyGeneric { x: None });
b.x = Some(&a);
}
// Doesn't Compile! (has destructor, no attribute)
{
let (a, mut b) = (0, MyDropper { x: None });
b.x = Some(&a);
}
{
let (a, mut b) = (0, MyHiddenDropper { x: 0 as *const _ });
b.x = &&a;
}
{
let (a, mut b) = (0, MyHonestHiddenDropper { x: 0 as *const _, boo: PhantomData });
b.x = &&a;
}
}

这表明 RFC 1238 中的更改使编译器更加保守,因此仅具有生存期或类型参数就足以阻止其编译。

您还可以注意到,Vec没有此问题,因为它使用 RFC 中描述的unsafe_destructor_blind_to_params属性。