Crust of Rust: The Drop Check
A comprehensive guide to Rust's drop check, destructor semantics, the #[may_dangle] attribute (dropck_eyepatch), PhantomData usage, and variance interaction in custom smart pointers and unsafe collections.
1. Motivation: Building a Custom Box<T>
When building custom smart pointers, collections, or low-level memory abstractions in Rust (such as standard library collections like Box<T>, Vec<T>, or BTreeMap<K, V>), developers frequently interact with raw pointers and unsafe blocks.
To understand how the compiler tracks resource management and lifetime validities during type destruction, consider a simple generic heap-allocation wrapper: a Norwegian wrapper type for Box, named Box<T> (using Norwegian naming conventions where appropriate, such as nee for new).
In a naive implementation, this wrapper type manages heap allocation and deallocation manually via standard library allocation routines.
use std::ops::{Deref, DerefMut};
use std::ptr::NonNull;
pub struct Box<T> {
ptr: NonNull<T>,
}
impl<T> Box<T> {
pub fn nee(value: T) -> Self {
let boxed = std::boxed::Box::new(value);
let ptr = std::boxed::Box::into_raw(boxed);
Self {
// Safety: Box::into_raw never returns null
ptr: unsafe { NonNull::new_unchecked(ptr) },
}
}
}
2. Baseline Implementation & The Naive Destructor
To ensure the inner heap memory is released and the inner type's destructor executes when Box<T> goes out of scope, a manual implementation of Drop is required.
impl<T> Drop for Box<T> {
fn drop(&mut self) {
// Safety: ptr was constructed from Box::into_raw and has not been freed yet.
unsafe {
let _ = std::boxed::Box::from_raw(self.ptr.as_ptr());
}
}
}
impl<T> Deref for Box<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
// Safety: ptr points to a valid, fully initialized T for the lifetime of self.
unsafe { self.ptr.as_ref() }
}
}
impl<T> DerefMut for Box<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
// Safety: ptr points to a valid T, and &mut self guarantees exclusive access.
unsafe { self.ptr.as_mut() }
}
}
While this smart pointer appears functional for simple primitive types (such as Box<i32>), it introduces severe borrow-checker limitations when storing references.
3. The Drop Check Problem: Over-Conservative Borrow Checking
Consider using Box<T> to store a mutable reference to a stack variable:
fn main() {
let mut y = 42;
let b = Box::nee(&mut y);
// Attempt to access y while b exists in scope
println!("{}", y);
}
Compiling this code triggers a borrow checker failure:
Why the Borrow Checker Rejects This
- Conservative Assumption: Whenever a type generic over
TimplementsDrop, the compiler assumes that itsdrop()execution may read or accessT. - Borrow Overlap: Because
bholds&mut yand implementsDrop, the compiler assumes that at the end of the scope (whenbis dropped),b.drop()might access the data pointed to by&mut y. - Scope Extension: Consequently, the mutable borrow of
ymust remain active until the very end of the enclosing block—invalidating any intervening reads or writes toy.
If Box<T> were the standard library's std::boxed::Box<T>, this snippet would compile seamlessly. The Rust compiler possesses special knowledge about std::boxed::Box<T>, recognizing that dropping a Box does not read from T before deallocating it.
4. Bypassing Direct Access Checks: The #[may_dangle] Attribute
To allow custom collections and smart pointers to exhibit standard-library behavior, Rust provides an unstable feature named dropck_eyepatch.
This feature introduces the #[may_dangle] attribute, which can be applied to generic type parameters in a Drop implementation.
#![feature(dropck_eyepatch)]
// SAFETY: We promise that drop() does NOT access any data inside T,
// allowing T to contain dangling references during destruction.
unsafe impl<#[may_dangle] T> Drop for Box<T> {
fn drop(&mut self) {
unsafe {
let _ = std::boxed::Box::from_raw(self.ptr.as_ptr());
}
}
}
Note: The
unsafekeyword on theimplblock is mandatory here. By adding#[may_dangle], the programmer asserts a safety invariant: theDrop::dropexecution will not read, dereference, or access any data of typeT.
By applying #[may_dangle], the compiler shortens the borrow duration of &mut y to its last actual usage point rather than extending it through b's destruction.
5. The Soundness Hole: Destructors that Access Data (Oison)
Opting out of the default drop check via #[may_dangle] introduces a dangerous soundness hole if handled improperly.
Consider a custom struct, Oison (Norwegian slang for "Oopsie Daisy"), that explicitly accesses its inner data inside its destructor:
use std::fmt::Debug;
struct Oison<T: Debug>(T);
impl<T: Debug> Drop for Oison<T> {
fn drop(&mut self) {
// Accesses inner T during drop execution!
println!("Oison drop printing inner: {:?}", self.0);
}
}
Now consider placing an Oison holding a reference inside our Box<T>:
fn main() {
let mut z = 42;
// Box holds an Oison, which holds &mut z
let b = Box::nee(Oison(&mut z));
// Modify z directly
z = 43;
// End of scope: `b` drops.
// `b.drop()` runs, which drops `Oison(&mut z)`.
// `Oison::drop()` executes and attempts to read `&mut z` (which points to invalid state/aliased z)!
}
The Unsound Execution Flow
Because Box<T> declared #[may_dangle] T, and internally holds only a raw pointer (NonNull<T>), the compiler is blinded:
- It assumes
Box<T>does not accessT. - It assumes
Box<T>does not own or dropT(because raw pointers carry no ownership semantics to the drop checker).
Thus, the compiler permits z = 43, causing Undefined Behavior when Oison::drop() runs.
6. Closing the Hole: PhantomData<T> and Drop Semantics
To prevent this soundness hole while retaining the flexibility of #[may_dangle], the drop check must be informed that Box<T> owns and will drop a T, even if Box<T>'s own drop() method doesn't directly access T's contents.
This ownership and destruction relationship is communicated using std::marker::PhantomData<T>.
use std::marker::PhantomData;
use std::ptr::NonNull;
pub struct Box<T> {
ptr: NonNull<T>,
_marker: PhantomData<T>, // Signals ownership and drop obligation of T
}
impl<T> Box<T> {
pub fn nee(value: T) -> Self {
let boxed = std::boxed::Box::new(value);
let ptr = std::boxed::Box::into_raw(boxed);
Self {
unsafe { NonNull::new_unchecked(ptr) },
_marker: PhantomData,
}
}
}
How PhantomData<T> Interacts with Drop Check
When Box<T> uses both #[may_dangle] and PhantomData<T>, the compiler evaluates two distinct rules during destruction:
#[may_dangle] T: "The destructorBox::dropwill not directly read/access fields of typeT."PhantomData<T>: "The typeBox<T>logically owns aT, and droppingBox<T>will recursively dropT."
If T is Oison<&mut z>, the compiler sees that T has its own destructor (Oison::drop). Because PhantomData<T> asserts that Box will drop T, the compiler enforces that &mut z must remain valid for Oison's destruction. The unsound code is once again correctly rejected at compile time.
7. Fixing Variance: Transitioning to NonNull<T>
In Rust's type system, subtyping applies primarily to lifetimes:
- A longer lifetime
'staticis a subtype of a shorter lifetime'a('static <: 'a). - Covariance: A type
F<T>is covariant overTifF<'static>can be passed whereF<'a>is expected. - Invariance: A type
F<T>is invariant overTifF<'static>cannot be substituted forF<'a>.
The Raw Pointer Pitfall
If Box<T> holds a mutable raw pointer (*mut T), the type parameter T becomes invariant:
// Invariant due to *mut T
pub struct BadBox<T> {
ptr: *mut T,
}
Invariance prevents lifetime shortening upon assignment:
let mut b1: Box<&'a str> = Box::nee("hello");
let b2: Box<&'static str> = Box::nee("world");
// ERROR if Box is invariant: cannot assign Box<&'static str> to Box<&'a str>
b1 = b2;
The NonNull Solution
NonNull<T> is defined as covariant over T. By replacing raw pointers (*mut T) with NonNull<T>, Box<T> inherits covariance over T, matching the subtyping properties of standard library smart pointers.
8. Advanced PhantomData Variance Patterns
PhantomData<U> can express distinct variance and drop-check rules depending on the exact form of U used.
Common PhantomData Idioms
-
PhantomData<T>:- Variance: Covariant over
T. - Drop Check: Assumes the container drops
T. - Use Case: Owned containers (
Box,Vec,Unique<T>).
- Variance: Covariant over
-
PhantomData<fn() -> T>:- Variance: Covariant over
T. - Drop Check: Assumes the container does NOT drop
T. - Use Case: Iterators or deserializers that generate or refer to
Twithout owning stored instances (e.g.,std::iter::Empty<T>).
- Variance: Covariant over
-
PhantomData<*const T>:- Variance: Covariant over
T. - Drop Check: Assumes the container does NOT drop
T. - Send/Sync: Suppresses automatic
SendandSyncimplementations.
- Variance: Covariant over
9. Complete Flow Walkthrough: Anatomy of a Production-Grade Custom Box
Below is the complete, robust implementation of our custom smart pointer incorporating NonNull<T>, #[may_dangle], PhantomData<T>, Deref, and DerefMut.
#![feature(dropck_eyepatch)]
use std::fmt;
use std::marker::PhantomData;
use std::ops::{Deref, DerefMut};
use std::ptr::NonNull;
/// A Norwegian heap-allocated wrapper type (`Box<T>`).
pub struct Box<T> {
ptr: NonNull<T>,
_marker: PhantomData<T>,
}
impl<T> Box<T> {
/// Allocates memory on the heap and places `value` into it.
pub fn nee(value: T) -> Self {
let boxed = std::boxed::Box::new(value);
let raw = std::boxed::Box::into_raw(boxed);
Self {
// Safety: Box::into_raw guarantees a non-null, aligned pointer.
ptr: unsafe { NonNull::new_unchecked(raw) },
_marker: PhantomData,
}
}
}
// SAFETY: We assert via #[may_dangle] that Box::drop does not directly
// read or access fields of T. PhantomData<T> informs the compiler that
// T's own destructor (if any) will be invoked.
unsafe impl<#[may_dangle] T> Drop for Box<T> {
fn drop(&mut self) {
unsafe {
// Reconstruct std::boxed::Box to deallocate heap memory and invoke T's drop.
let _ = std::boxed::Box::from_raw(self.ptr.as_ptr());
}
}
}
impl<T> Deref for Box<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
// Safety: ptr is valid and aligned for the lifetime of self.
unsafe { self.ptr.as_ref() }
}
}
impl<T> DerefMut for Box<T> {
type Target = T;
fn deref_mut(&mut self) -> &mut Self::Target {
// Safety: &mut self guarantees exclusive mutable access to ptr.
unsafe { self.ptr.as_mut() }
}
}
impl<T: fmt::Debug> fmt::Debug for Box<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(self.deref(), f)
}
}
Summary and Key Takeaways
- The Conservative Default: Any generic type implementing
Dropcauses the compiler to assume its destructor accesses generic parametersT, extending borrows of referenced data to the end of the enclosing block. - The
#[may_dangle]Attribute: Opts out of the standard drop-check access assumption, promising the compiler thatDrop::dropwill not read or dereferenceT. - Soundness Requirements: When using
#[may_dangle], you must usePhantomData<T>if the container logically owns and dropsT. This ensures the compiler checksT's destructor for internal accesses. PhantomDataSelection:PhantomData<T>= Covariant + DropsT.PhantomData<fn() -> T>= Covariant + Does NOT dropT.
- Covariance via
NonNull<T>: Standard raw pointers (*mut T) force invariance. UsingNonNull<T>preserves covariance overT, enabling lifetime subtyping. - Unstable Reality:
dropck_eyepatch(#[may_dangle]) remains an unstable nightly feature while the Rust language design team works toward a permanent, safe surface syntax for drop-check assertions.