Lifetimes are too obscure

Consider the following example:

fn main() {
    a(&[1, 2, 3]);
}

fn a(b: &[u8]) {
    //
}

What holds an ownership of [1, 2, 3]? Is it dropped before execution of a or after?

Aforementioned example compiles. However, it is unclear what is actually happening. I would like to have an option in Cargo to visualize lifetimes just like it does when it encounters an error. Or, alternatively, some kind of strict mode which will make Rust more explicit.

1 Like

[1, 2, 3] is dropped after the execution of a, within main. It's a temporary, so it will be freed just after the call to a ends. The thing being passed to a is a reference &_, and you can think of it as being passed to a and then dropped at the end of a (to little effect). You can actually make your own special type to trace drops:

use std::fmt::Debug;

#[derive(Debug)]
struct NoisyDrop<T: Debug>(T);

impl<T: Debug> Drop for NoisyDrop<T> {
    fn drop(&mut self) {
        println!("Dropping {:?}", self.0);
    }
}

fn main() {
    println!("starting main");
    a(NoisyDrop(&NoisyDrop([1, 2, 3])));
    println!("ending main");
}

fn a(_: NoisyDrop<&NoisyDrop<[u8; 3]>>) {
    println!("a");
}

which gives us

starting main
a
Dropping NoisyDrop([1, 2, 3])
Dropping [1, 2, 3]
ending main

This question is a better fit for the Rust users forum, internals is for discussion about compiler/language internals.

2 Likes

Values such as the array [1, 2, 3] that are results of parts of larger expressions because they are used in a by-reference manner are being put in a so-called “temporary variable”, or more commonly shorter a “temporary”. The Rust Reference contains some explanations as to how temporaries work, but the rule of thumb would be that they all get dropped (in reverse order of creation) at the end of the enclosing statement. -> Paragraph about temporaries in the Rust Reference.

A statement in Rust is often a single line, or a single multi-line thing terminated with semicolon, though not all statements need a semi-colon, since there’s exceptions for things like if or for expressions used as statements; a block (the thing enclosed with braces {} is a sequence of statements) of function body is a sequence of statements. For more details on such syntactical terminology also see the Rust Reference. “The enclosing statement” relevant for dropping temporaries, in case there’s multiple levels of statements, e.g. when you use a block, or some control-flow expression, is always the innermost one.

To be more precise, for the scope of a temporary (i.e. for determining where it’s dropped) the actual rules identify a few more syntactical constructs besides just statements that are also used as places/boundaries where temporaries are dropped. The full list can be found here.

4 Likes

It's not a question. It's a language design issue. It becomes even more confusing in async context.

async fn a() {
    let b = Box::new([1, 2, 3]);

    let future = async {
        let b = &b;
    };

    future.await;
}

This code shouldn't compile, because the user could await on future after b is dropped. However, it does, because compiler is smart enough to look at the code and see how future is used. However, I am also pretty smart, that's why I spent my time to figure out what's going on.

async fn a() {
    let b = Box::new([1, 2, 3]);

    let future = async {
        let b = &b;
    };

    drop(b);

    future.await;
}

On the other hand, this code, in fact, does not compile, because b is dropped before future.await.

async fn a() {
    let b = [1, 2, 3];

    let future = async {
        let b = &b;
    };

    drop(b);

    future.await;
}

However, what happens if b is no longer boxed? The code compiles. Why? Probably because b implements Copy. This also adds to obscurity.

These are just simple cases, but what if I'm working with unsafe code? Will compiler be able to figure out what is still alive and what is dropped? I'm not sure about that. That's why I have to search for esoteric ways to initialize variables to make sure everything is valid and nothing is implicitly copied.

I don’t understand this statement. It isn’t made easier by the fact that you put two different variables called b into this example. Assuming you mean the first/outer b… I see future.await; and then the scope of the variable b ends, so in fact future is awaited before b is dropped.

Indeed, running drop(b) on a value b: SomeType where SomeType: Copy will not invalidate b, so b can still be used after that. That’s the very point (i.e. the only point) of Copy, so I wouldn’t consider this as obscure.

2 Likes

On second read, perhaps your statement was something along the lines of:


Look at this example

fn a() {
    let b = Box::new(42);
    let r = &b;
    println!("{r}");
}

this should not compile because r could be used after b is dropped. However it does, because the compiler is smart enough to see that we don’t use r after b is dropped. In fact, if I write

fn a() {
    let b = Box::new(42);
    let r = &b;
    drop(b);
    println!("{r}");
}

then it doesn’t compile, but if I write

fn a() {
    let b = 42;
    let r = &b;
    drop(b);
    println!("{r}");
}

it does compile again. How obscure!


Honestly though, this kind of compiler behavior is the whole point of Rust and what makes it so successful. Of course the first code example has to compile, otherwise you essentially couldn’t use references at all, but then again, you must not do disallowed things like use-after-free so the analysis needs to be somewhat smart. Also, as demonstrated above, the behavior of the your example has little to do with async.

I agree that there’s a bit of a disparity in that the compiler will give explanations if code doesn’t compile, but won’t give explanations if it does compile. On the other hand, there’s a lot, lot, lot of analysis going on during compilation, so I suppose it doesn’t seem easy to determine what exactly the output of a feature where you can ask the compiler “why does my code compile!?” should be.

3 Likes

this should not compile because r could be used after b is dropped. However it does, because the compiler is smart enough to see that we don’t use r after b is dropped. In fact, if I write

Yes, you got that right. I apologize for not being clear enough.

it doesn’t seem easy to determine what exactly the output of a feature where you can ask the compiler “why does my code compile!?” should be.

I believe it should be possible to implement a Cargo command that would output comments and visualize lifetimes just like cargo check does, even if code compiles.

Some kind of strict mode that makes compiler less smart (e.g. #![disable(inspection_name])) and disables implicit operations (like Copy) would be useful as well (not sure how the compiler works, so this might not make sense).

1 Like

I’d certainly like to see an analysis tool that can highlight lifetime regions and even offer some explanation on why the lifetime is what it is. This would be very useful with elided lifetimes as well as unnameable lifetimes that you can’t write explicitly even if you want to.

5 Likes

I believe it should be possible to implement a Cargo command that would output comments and visualize lifetimes just like cargo check does, even if code compiles.

Do you think this could be helpful? GitHub - rustviz/rustviz: Interactively Visualizing Ownership and Borrowing for Rust

1 Like