Inconsistent drop check behavior with PhantomData depending on initialization method

Hello everyone,

I'm trying to use PhantomData to correctly inform the drop checker about lifetimes for a struct that holds a raw pointer. I've run into some surprising behavior that seems inconsistent, and I'm not sure if it is desired behavior or a known/unknown compiler issue.

I have a simple struct, NoRefContainer<'a, T>, that holds a *const T and uses PhantomData<&'a T> to signal that it logically borrows a T for the lifetime 'a. My expectation is that the compiler should prevent the borrowed data from being dropped before the container itself.

This works as expected when I use a new() function, but not when I initialize the struct directly.

Case 1: Direct Initialization (Compiles Successfully)
When I build the struct directly within a function, the compiler allows me to drop the owned data before dropping the container that logically borrows it. This seems like it should be an error.

Case 2: new() Function Initialization (Fails to Compile)
When I wrap the exact same initialization logic in a new() function, the compiler correctly catches the invalid drop order and produces a borrow-checking error, as expected.

Just to be clear, the issue still happens even if the pointer is accessed in the destructor.

Minimal example, can be reproduced on rustc 1.90.0 (1159e78c4 2025-09-14) and rustc 1.92.0-nightly (dc2c3564d 2025-09-29)

#![allow(dead_code)]

use std::{fmt::{self, Debug}, marker::PhantomData};

#[derive(Debug)]
struct NonCopyint(i32);

struct NoRefContainer<'a, T: 'a + fmt::Debug> {
    pointer: *const T,
    _marker: PhantomData<&'a T>,
}

impl<'a, T: 'a + fmt::Debug> NoRefContainer<'a, T> {
    fn new(value: &'a T) -> Self {
        Self {
            pointer: value as *const T,
            _marker: PhantomData,
        }
    }
}

impl<'a, T: 'a + fmt::Debug> Drop for NoRefContainer<'a, T> {
    fn drop(&mut self) {
        unsafe {
            println!("Dropping NoRefContainer: {:?}", *self.pointer);
        }
    }
}

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn direct_initialization_compiles() {
        let secret;
        let container;

        secret = NonCopyint(42);
        container = NoRefContainer {
            pointer: &secret,
            _marker: PhantomData,
        };

        // This compiles but it is incorrect
        drop(secret); // `secret` is dropped...
        drop(container); // ...while `container` still logically borrows it.
    }

    #[test]
    fn new_function_fails_to_compile() {
        let secret;
        let container;

        secret = NonCopyint(42);

        container = NoRefContainer::new(&secret);

        // This won't compile! (uncomment to see error)
        // drop(secret);
        // drop(container);

        // Correct order - this compiles
        drop(container);
        drop(secret);
    }
}

When you initialize this without new, how would the compiler assign a lifetime to the object? There's no connection between the lifetimes 'a and 'b in &'b secret. The type of the new function is what ties them together. In the non-new example you end up with a PhantomData<&'static NonCopyint>. This is a common dangling pointer issue when you borrow an object just to convert it to a pointer.

3 Likes

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.