Is Rust destructor deterministic or does the compiler decides when the `Drop` trait's `fn drop(...)` gets called?

Consider this piece of code that uses ManuallyDrop<T> and typenum::consts::{U0, U1}.

/*
[dependencies]
typenum = "1.17.0"
*/
use core::{
    marker::PhantomData,
    mem::{forget, ManuallyDrop},
    ops::{Add, Deref, Drop},
};

pub use typenum;
use typenum::{Sum, Unsigned, U0, U1};

struct Counter<T, N: Unsigned + Add<U1> = U0>(ManuallyDrop<T>, PhantomData<N>);

impl<T, N> Counter<T, N>
where
    N: Unsigned + Add<U1>,
    U1: Add<N>,
    Sum<N, U1>: Unsigned + Add<U1>,
{
    fn new(t: T) -> Self {
        Self(ManuallyDrop::new(t), PhantomData)
    }

    fn count_up(mut self) -> Counter<T, Sum<N, U1>>
    where
        Sum<N, U1>: Add<U1> + Unsigned,
        N: Unsigned + Add<U1>,
    {
        println!(
            "Inside `Counter::count_up`, `N` before increment: {:?}",
            N::to_usize()
        );
        let inner = ManuallyDrop::new(unsafe { ManuallyDrop::take(&mut self.0) });
        forget(self);
        println!(
            "Inside `Counter::count_up`, `N` after increment: {:?}\n",
            Sum::<N, U1>::to_usize()
        );
        Counter(inner, PhantomData::<Sum<N, U1>>)
    }
}

impl<T, N> Drop for Counter<T, N>
where
    N: Add<U1> + Unsigned,
{
    fn drop(&mut self) {
        println!(
            "Inside `Drop` implementation of Counter<T, {:?}>",
            N::to_u64()
        );
        // Need to take out `T` from the `ManuallyDrop<T>` wrapper
        let _ = unsafe { ManuallyDrop::take(&mut self.0) };
    }
}

fn main() {
    use core::sync::atomic::{AtomicUsize, Ordering};

    static NUM_DROPS: AtomicUsize = AtomicUsize::new(0);
    struct DetectDrop;

    impl Drop for DetectDrop {
        fn drop(&mut self) {
            NUM_DROPS.fetch_add(1, Ordering::Relaxed);
        }
    }

    let counter: Counter<DetectDrop> = Counter::new(DetectDrop);

    {
        let counter = counter.count_up(); // `N` is now 1;
                                          // Inside `Counter::count_up`, `N` before increment: 0
                                          // Inside `Counter::count_up`, `N` after increment: 1
        assert_eq!(NUM_DROPS.load(Ordering::Relaxed), 0usize);
        let counter = counter.count_up(); // `N` is now 2;
                                          // Inside `Counter::count_up`, `N` before increment: 1
                                          // Inside `Counter::count_up`, `N` after increment: 2
        let counter = counter.count_up(); // `N` is now 3;
        let counter = counter.count_up(); // `N` is now 4;
        let counter = counter.count_up(); // `N` is now 5;
                                          // Why is this `0`?
        assert_eq!(NUM_DROPS.load(Ordering::Relaxed), 0usize);
    }

    assert_eq!(NUM_DROPS.load(Ordering::Relaxed), 1usize);
}

Rust Explorer

Astonishingly, on the line of assert_eq!(NUM_DROPS.load(Ordering::Relaxed), 0usize); within the braced scope, NUM_DROPS is actually 0usize, signifies that Drop trait's fn drop wasn't called despite reaching the end of the scope.

In my crate sosecrets-rs, for the testing that the drop function is indeed called correctly code, I noted that NUM_DROPS is 1usize at the end of the scope.

My question is why there are differences in when drop is called in the two scenarios? Is it because the compiler does not guarantee when drop is called?

Order of drops in Rust is generally deterministic and well-defined. For example, local variables are dropped in inverse order of declaration at the end of the scope (unless you moved the value from the variable out to somewhere else, of course), fields of structs are dropped in order of declaration (not inverse), etc…

In your case, the second assert_eq!(NUM_DROPS.load(Ordering::Relaxed), 0usize); is still inside of the scope of counter. The variable(s) called counter is/are dropped at the very end of the scope, right after the last statement, thus after the assert_eq!(NUM_DROPS.load(Ordering::Relaxed), 0usize); runs.

The other code you link features the binding let (_, _) = …. The _ pattern infamously does not bind to any local variable. Any value being matched against this pattern is dropped immediately[1].

If you changed the other code to use let (next_secret, _) = … in the line 533, too, or perhaps let (_next_secret, _) = … if you want to suppress the warning about unused variables, then the behavior there should be the same as in the linked rustexplorer example. (Variables with understore like _next_secret are real variables, unlike the catch-all pattern _.)

Similarly, if you changed the rustexplorer example to use let _ = counter.count_up(); instead of the last let counter = counter.count_up();, then that code would change behavior, too, accordingly.

        let _ = counter.count_up(); // `N` is now 5;
                                          // Now this is `1` ;-)
        assert_eq!(NUM_DROPS.load(Ordering::Relaxed), 1usize);

Rust Explorer


  1. whereas place expressions would not be moved from at all, but that distinction is irrelevant in this particular case ↩︎

8 Likes

Hi @steffahn, thank you for resolving my issue and for your excellent answer! :+1::rocket::rocket::rocket: