In the Rustonomicon's guide to PhantomData
, there is a part about what happens if a Vec
-like struct has *const T
field, but no PhantomData<T>
:
The drop checker will generously determine that
Vec<T>
does not own any values of typeT
. This will in turn make it conclude that it doesn't need to worry aboutVec
dropping anyT
's in its destructor for determining drop check soundness. This will in turn allow people to create unsoundness usingVec
's destructor.
What does it mean? If I implement Drop
for a struct and manually destroy all T
s in it, why should I care if compiler knows that my struct owns some T
s?
PhantomData consumes no space, but simulates a field of the given type for the purpose of static analysis. This was deemed to be less error-prone than explicitly telling the type-system the kind of variance that you want, while also providing other useful things such as the information needed by drop check.
pub struct PhantomData<T> Sized; Zero-sized type used to mark things that “act like” they own a T . Adding a PhantomData<T> field to your type tells the compiler that your type acts as though it stores a value of type T , even though it doesn't really. This information is used when computing certain safety properties.
The PhantomData<T>
within Vec<T>
(held indirectly via a Unique<T>
within RawVec<T>
) communicates to the compiler that the vector may own instances of T
, and therefore the vector may run destructors for T
when the vector is dropped.
Deep dive: We have a combination of factors here:
We have a Vec<T>
which has an impl Drop
(i.e. a destructor implementation).
Under the rules of RFC 1238, this would usually imply a relationship between instances of Vec<T>
and any lifetimes that occur within T
, by requiring that all lifetimes within T
strictly outlive the vector.
However, the destructor for Vec<T>
specifically opts out of this semantics for just that destructor (of Vec<T>
itself) via the use of special unstable attributes (see RFC 1238 and RFC 1327). This allows for a vector to hold references that have the same lifetime of the vector itself. This is considered sound; after all, the vector itself will not dereference data pointed to by such references (all its doing is dropping values and deallocating the backing array), as long as an important caveat holds.
The important caveat: While the vector itself will not dereference pointers within its contained values while destructing itself, it will drop the values held by the vector. If those values of type T
themselves have destructors, those destructors for T
get run. And if those destructors access the data held within their references, then we would have a problem if we allowed dangling pointers within those references.
So, diving in even more deeply: the way that we confirm dropck validity for a given structure S
, we first double check if S
itself has an impl Drop for S
(and if so, we enforce rules on S
with respect to its type parameters). But even after that step, we then recursively descend into the structure of S
itself, and double check for each of its fields that everything is kosher according to dropck. (Note that we do this even if a type parameter of S
is tagged with #[may_dangle]
.)
In this specific case, we have a Vec<T>
which (indirectly via RawVec<T>
/Unique<T>
) owns a collection of values of type T
, represented in a raw pointer *const T
. However, the compiler attaches no ownership semantics to *const T
; that field alone in a structure S
implies no relationship between S
and T
, and thus enforces no constraint in terms of the relationship of lifetimes within the types S
and T
(at least from the viewpoint of dropck).
Therefore, if the Vec<T>
had solely a *const T
, the recursive descent into the structure of the vector would fail to capture the ownership relation between the vector and the instances of T
contained within the vector. That, combined with the #[may_dangle]
attribute on T
, would cause the compiler to accept unsound code (namely cases where destructors for T
end up trying to access data that has already been deallocated).
BUT: Vec<T>
does not solely contain a *const T
. There is also a PhantomData<T>
, and that conveys to the compiler "hey, even though you can assume (due to the #[may_dangle] T
) that the destructor for Vec
won't access data of T
when the vector is dropped, it is still possible that some destructor of T
itself will access data of T
as the vector is dropped."
The end effect: Given Vec<T>
, if T
doesn't have a destructor, then the compiler provides you with more flexibility (namely, it allows a vector to hold data with references to data that lives for the same amount of time as the vector itself, even though such data may be torn down before the vector is). But if T
does have a destructor (and that destructor is not otherwise communicating to the compiler that it won't access any referenced data), then the compiler is more strict, requiring any referenced data to strictly outlive the vector (thus ensuring that when the destructor for T
runs, all the referenced data will still be valid).
If one wants to try to understand this via concrete exploration, you can try comparing how the compiler differs in its treatment of little container types that vary in their use of #[may_dangle]
and PhantomData
.
Here is some sample code I have whipped up to illustrate this:
// 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 — I'm not that strong in the extremely deep theory that truly answers your question. I'm just a layperson who has used Rust a bit and has read the related RFCs. Always refer back to those original sources for a less-diluted version of the truth.
RFC 769 introduced the actual The Drop-Check Rule:
Let
v
be some value (either temporary or named) and'a
be some lifetime (scope); if the type ofv
owns data of typeD
, where (1.)D
has a lifetime- or type-parametricDrop
implementation, and (2.) the structure ofD
can reach a reference of type&'a _
, and (3.) either:
(A.) the
Drop impl
forD
instantiatesD
at'a
directly, i.e.D<'a>
, or,(B.) the
Drop impl
forD
has some type parameter with a trait boundT
whereT
is a trait that has at least one method,then
'a
must strictly outlive the scope ofv
.
It then goes further to define some of those terms, including what it means for one type to own another. This goes further to mention PhantomData
specifically:
Therefore, as an additional special case to the criteria above for when the type
E
owns data of typeD
, we include:If
E
isPhantomData<T>
, then recurse onT
.
A key problem occurs when two variables are defined at the same time:
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);
}
As I understand it, without The Drop-Check Rule and indicating that Vec
owns Noisy
, code like this might compile. When the Vec
is dropped, the drop
implementation could access an invalid reference; introducing unsafety.
Returning to your points:
If I implement
Drop
for a struct and manually destroy allT
s in it, why should I care if compiler knows that my struct owns someT
s?
The compiler must know that you own the value because you can/will call drop
. Since the implementation of drop
is arbitrary, if you are going to call it, the compiler must forbid you from accepting values that would cause unsafe behavior during drop.
Always remember that any arbitrary T
can be a value, a reference, a value containing a reference, etc. When trying to puzzle out these types of things, it's important to try to use the most complicated variant for any thought experiments.
All of that should provide enough pieces to connect-the-dots; for full understanding, reading the RFC a few times is probably better than relying on my flawed interpretation.
Then it gets more complicated. RFC 1238 further modifies The Drop-Check Rule, removing this specific reasoning. It does say:
parametricity is a necessary but not sufficient condition to justify the inferences that dropck makes
Continuing to use PhantomData
seems the safest thing to do, but it may not be required. An anonymous Twitter benefactor pointed out this code:
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;
}
}
This suggests that the changes in RFC 1238 made the compiler more conservative, such that simply having a lifetime or type parameter is enough to prevent it from compiling.
You can also note that Vec
doesn't have this problem because it uses the unsafe_destructor_blind_to_params
attribute described in the the RFC.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With