Lifetime bug with PhantomData?

Is this a compiler bug?

use std::marker::PhantomData;

pub struct Anchor {}

impl Anchor {
    fn new() -> Self {
        Self {}
    }
    pub fn simple_writer<'a>(&'a self) -> std::io::Result<Accessor> {
        Ok(Accessor {
            _parent: PhantomData::<&'a Self>,
        })
    }
    pub fn commit(self) -> std::io::Result<()> {
        Ok(())
    }
}

impl Drop for Anchor {
    fn drop(&mut self) {}
}

pub struct Accessor<'a> {
    _parent: PhantomData<&'a Anchor>,
}

impl<'a> Drop for Accessor<'a> {
    fn drop(&mut self) {}
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    println!("");

    let p = Anchor::new();

    let _w = p.simple_writer()?;

    // The compiler is free to drop _w here.  Why does it not do that?

    p.commit()?;
    /*
    error[E0505]: cannot move out of `p` because it is borrowed
    --> src\main.rs:36:5
    |
    34 |     let p = Anchor::new();
    |         - binding `p` declared here
    35 |     let _w = p.simple_writer()?;
    |              - borrow of `p` occurs here
    36 |     p.commit()?;
    |     ^ move out of `p` occurs here
    ...
    39 | }
    | - borrow might be used here, when `_w` is dropped and runs the `Drop` code for type `Accessor`

    For more information about this error, try `rustc --explain E0505`.
    */
    Ok(())
}

The very short Cargo.toml...

[package]
name = "compiler-bug-001"
version = "0.1.0"
edition = "2021"
[dependencies]

The same error occurs without PhantomData, with:

_parent: &'a Anchor

So it's not PhantomData.

1 Like

Drops are done at a specific time: at the end of the scope where the variable was declared, in reverse order of declaration. This is so that, whatever the Drop does, it does it in a highly predictable way.

If you want to drop the value earlier, you can call drop(_w) or introduce a block to narrow the scope of the variable.

3 Likes

Thank you for checking!

1 Like

I bring this up because the original version does not include Drop for Accessor. Without that there is no error.

The original Accessor holds a file open while it's alive. Anchor::commit requires that file to not be open when called. Because Accessor::Drop is called late, a run-time error is triggered (file in use). Essentially, Accessor is outliving the borrow it has on Anchor. That seems bad / dangerous. In my case it's really annoying.

I see, so the confusion is that only explicit borrows are active until they're no longer used, in simple cases where this can be detected by the compiler. For example the error goes away if we replace:

            let _w = p.simple_writer()?;

with

            let _w = &p;

But this is not true not of borrowing by a function result, or perhaps just whenever the situation is not quite so simple?

The rule is:

  • Types which implement Drop — or more precisely, has a destructor that calls at least one Drop implementation — are dropped (if not already moved) at the end of scope, for consistency.

  • Other types, which have trivial destructors that don't actually execute any code, work in the NLL fashion and let you pretend they were dropped wherever is convenient for borrow checking.

The intention of this part of the design of Rust is that you can predict exactly where the file is closed — at the end of the scope — and you cannot accidentally change the behavior of the program by adding/removing a borrow that pushes its drop earlier/later.

Rust could have been designed so that values are dropped immediately after their last use, which would produce the result you want, but that probably would have been considered too surprising. It would also mean that if you want to do something merely “in the scope of” some Drop-guard but without actually mentioning the guard again, you'd have to add a dummy mention of it.

Essentially, Accessor is outliving the borrow it has on Anchor. That seems bad / dangerous.

The program won't compile, as you have observed, if the borrow doesn't live long enough.

Think of drop timing as “stronger” than lifetimes: nontrivial drops have rules for where they are executed, and the results of the borrow check must work with them, not the other way around.

5 Likes

FYI it's a bit more complicated than that for a couple of reasons:

  • some types may not implement Drop but still contain fields that do implement Drop. In that case it depends on the generic arguments of those fields, see for example Rust Playground

    • as a more general rule, you may consider if any Drop implementation can "see" a given lifetime. If not, then the lifetime gets the NLL niceties, otherwise it is considered used by that Drop implementation
  • some types (e.g. Vec and Box) get special treatment thanks to an unstable feature which lets them declare that their Drop implementation will only drop instances of their generic parameters, so the NLL behaviour can be delegated to whatever is the behaviour on their generic parameters.

If you want to read more you can check Drop Check - The Rustonomicon but note that this is not really a beginner topic. In practice you'll likely never encounter it unless you writing your own Box/Vec and you notice that some patterns don't work while they do with the stdlib's ones, or if you're writing extremely cursed and unsafe stuff that rely on Drop causing a compile error in some cases.

6 Likes

@kpreid and @SkiFire13 , thank you for explaining! Again, I should say, since I've read this in other threads and for some reason it hasn't been drilled into my head enough times.

When I look at the compiler error:

borrow might be used here, when _w is dropped and runs the Drop code for type Accessor

I wonder if it could be improved for those of use who have gotten used to NLL behavior and forget (or never knew) that it only applies when there is no drop code to run. I think the message is already trying to tell us that NLL doesn't apply, but perhaps it could be slightly more explicit?

borrow might be used here, when _w is dropped and runs the Drop code for type Accessor (drop code is always run at the end of the scope)

Adding this note about "end of the scope" shouldn't be necessary since the message has an arrow pointing to the end of the block. But perhaps this would make it click in some of our brains.

If I correctly understand the perspective of the language design (including some of the nuances mentioned by @SkiFire13 ), it really isn't “values may be dropped early, if they have no drop code”; rather, it's “values may continue to exist while containing dangling references, if they are not used, including not being used by drop code”. The only thing that can be done with values in this state is letting them drop at end of scope.

The “NLL behavior” always applies, and determines where the first borrow of p is obligated to end (specifically p.commit()). The question is not what this lifetime is determined to be, but whether or not there is a conflicting usage of _w at the end of the scope (that happens to be implicit).

1 Like

I understand, thank you, your explanation is very clear. I guess the real source of the confusion is that NLL "works silently", so it is natural to intuit that they are dropped early. As you said:

It seems like it would be possible to redefine drop to conceptually occur early, when a binding is no longer used, including not causing drop code to run. But I suppose that would cause other terminology problems.

From the borrow checker perspective, even values with trivial destructors (alternatively, no destructor) go out of scope. The primary difference is that non-trivial dropping acts similarly to taking a &mut (ala the definition of Drop::drop), whereas trivially going out of scope acts similarly to being overwritten (only invalidating references to the value and its fields).

Here's a recent discussion on the "eager drop" concept. It's a breaking change to do implicitly, and probably requires the borrow checker to be able to change the semantics of programs (which also implies drop locations of a given program could continue to change in the future, as the borrow checker gets smarter).

1 Like

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.