Skip to content

← Unsafe and Soundness step 19 of 24

Hard Framework

`PhantomData`, variance and drop-check

pub struct MyIter<'a, T>    { ptr: *const T, end: *const T, _marker: ??? }
pub struct MyIterMut<'a, T> { ptr: *mut T,   end: *mut T,   _marker: ??? }

pub fn iter_script(ops: Vec<String>) -> Vec<i64>

The starter hands you a working MyVec<T>. Write its two iterators — a shared-borrowing MyIter and a mutable-borrowing MyIterMut — with the right PhantomData in each, plus iter()/iter_mut() and the two Iterator impls. Then run a script over a MyVec<i64>:

op emits
push N the new length
sum the sum, via iter()
double doubles every element via iter_mut(), then emits the new sum
max the largest element, or -1 if empty
count the number of elements, counted by consuming iter()
len the length
anything else -2

The starter does not compile: E0392, twice. That error is the first of the three jobs PhantomData does.

Three jobs, constantly conflated

1. Lifetime binding

A struct holding *const T and a lifetime parameter 'a that appears nowhere else is rejected outright:

error[E0392]: lifetime parameter `'a` is never used

and the error is doing you a favour. If rustc did accept it, 'a would be unbounded — the caller could instantiate it with anything, including 'static, and your iterator would happily outlive the vector it borrows from. PhantomData<&'a T> ties 'a to the data.

2. Variance

This is the part that produces unsoundness rather than a compile error, and it is the hardest class of bug in the language to see: the code compiles, it runs, and it produces a dangling reference from entirely safe code.

The table you need:

type variance in T
&'a T covariant in 'a and in T
&'a mut T covariant in 'a, invariant in T
*const T covariant
*mut T invariant
fn(T) contravariant in T
fn() -> T covariant in T
Cell<T>, UnsafeCell<T> invariant

Covariant means F<'long> can be used where F<'short> is wanted — shrinking a lifetime is always safe when all you can do is read. Invariant means no substitution is allowed in either direction.

If your container hands out &mut T, it must be invariant in T.

Otherwise a caller can shrink the lifetime on the way in, write a short-lived reference into a long-lived slot through the &mut, and read it back after the short lifetime has ended. Using PhantomData<&'a T> on a mutable iterator is a real, exploitable unsoundness — not a style nit.

3. Drop-check

Everybody has read that PhantomData<T> is needed so the drop-checker knows your type owns a T. Since RFC 1238 that is no longer true for the common case: a type with a Drop impl is already assumed to own its generic parameters, so PhantomData<T> adds nothing for dropck alone.

The exception is std’s #[may_dangle] opt-out, which lets a container promise its destructor will not look at the Ts it drops — and may_dangle is nightly-only, so the concept can be taught here but the feature cannot be used.

::: question Why is the mandatory assert_variance function the only way this grader can check variance at all? Because variance is a type-level property. It has no runtime representation, so no test can observe it — but a function whose body is just x type-checks if and only if the conversion is legal.

pub fn assert_variance<'long: 'short, 'short>(x: MyIter<'long, i32>)
    -> MyIter<'short, i32> { x }

With PhantomData<&'a T> this compiles: a shared-borrowing iterator may shrink its lifetime. Swap in PhantomData<&'a mut T> or PhantomData<*mut T> and it stops compiling, because those are invariant in 'a… in T, at least — and &'a mut T is still covariant in 'a, which is why the mutable iterator can also shrink its lifetime while remaining invariant in its element type.

Turning a type-level property into a compile-or-not gate is a technique worth stealing. In a real crate you put these assertions in a tests/ file or behind #[cfg(test)], and they cost nothing at runtime while catching a whole class of regression that no unit test can reach.

The mirror image is the assertion that must not compile:

// must NOT compile -- MyIterMut is invariant in T
// fn bad<'a>(x: MyIterMut<'static, &'static str>) -> MyIterMut<'a, &'a str> { x }

Uncomment it locally and confirm rustc rejects it. If it compiles, your mutable iterator has the wrong marker. :::

The practical rule for this track’s types

  • owning container (MyVec<T>) → PhantomData<T>, if you need one at all;
  • shared-borrowing iteratorPhantomData<&'a T>;
  • mutable-borrowing iteratorPhantomData<&'a mut T>.

And one more, worth knowing because it is occasionally exactly what you want: PhantomData<*const T> and PhantomData<*mut T> also strip Send and Sync, because raw pointers are neither. That is how you make a handle thread-local by construction.

::: question MyIterMut::next returns Option<&'a mut T> — a reference tied to the iterator’s lifetime, not to &mut self. How is that not two live &mut to the same element? Because the pointer advances before next can be called again, so no element is ever handed out twice.

This is the fundamental trick behind every mutable iterator in std, and it is genuinely unsafe: the signature promises the caller a reference that outlives the &mut self borrow, which the compiler cannot verify. What makes it sound is a property of the implementation — each element is yielded at most once, and the elements are disjoint — expressed to the compiler as a raw-pointer walk that the borrow checker never sees.

Note the shape here, because it is the shape of the whole track: a safe signature, an internal argument the compiler cannot check, and a safety comment that states the argument. The signature is the promise; the pointer arithmetic is the proof. :::

Lints to expect

clippy::needless_lifetimes if you write fn iter<'a>(&'a self) -> MyIter<'a, T> where MyIter<'_, T> would do. clippy::multiple_bound_locations if you split a bound between the angle brackets and a where clause. clippy::non_send_fields_in_send_ty if you ever add an unsafe impl Send to one of these.

What this grader cannot check

Variance is checked, by assert_variance — that is the point of shipping it. What is not checked is the aliasing argument inside MyIterMut::next: an implementation that forgets to advance the pointer hands out the same &mut twice, which is undefined behaviour, and this harness would simply see the wrong sum. Some bugs of that class show up as wrong answers; some do not.

Remember the grade is compile + tests + clippy -D warnings.

Loading visualization…