Compiler ignores lifetime bounds?

Hi!
I am trying to figure out why does this code compile.
The foo function requests that the first argument must outlive the second, however, in this example b obviously outlives a.

fn foo<'a, 'b>(_aa: &'a str, _bb: &'b str) 
where  'a: 'b,
{}

fn main() {
    let b = "b";
    {
        let a = "a";

        foo(a, b);

        println!("dropping {}", a);
        drop(a);
    }
    println!("dropping {}", b);
    drop(b);
}

What's 'a and 'b?

Let's desugar:

fn foo<'a, 'b>(_aa: &'a str, _bb: &'b str) 
where  'a: 'b,
{}

fn main() {
    let b: &'static str = "b";
    {
        let a: &'static str = "a";

        foo::<'static, 'static>(a, b);

        println!("dropping {}", a);
        drop(a);
    }
    println!("dropping {}", b);
    drop(b);
}

Yes, b outlives a, but foo() doesn't require a to outlive b - it requires the referent of a to outlive the referent of b. And both have the 'static lifetime. 'static: 'static is true.

How about this then?

fn foo<'a, 'b>(_aa: &'a String, _bb: &'b String) 
where  'a: 'b,
{}

fn main() {
    let b: String = "b".into();
    {
        let a: String = "a".into();

        foo(&a, &b);

        println!("dropping {}", a);
        drop(a);
    }
    println!("dropping {}", b);
    drop(b);
}

Shared references have a covariant lifetime, so the compiler is free to convert &'long T into &'short T when it's verifying lifetimes, where 'long: 'short. In this case, the borrows can be unified into a single lifetime 'c which only lasts until the end of the statement foo(&a, &b);.

To prevent this shortening, you'll need to return a value annotated with one or both of the lifetimes. This fails to compile, for example:

fn foo<'a, 'b>(_aa: &'a str, _bb: &'b str)->&'b str 
where  'a: 'b,
{ _bb }

fn main() {
    let bstr: String = "b".into();
    let mut b = bstr.as_str();
    {
        let astr: String = "a".into();
        let a = astr.as_str();

        b = foo(a, b);

        println!("dropping {}", a);
        drop(a);
    }
    println!("dropping {}", b);
    drop(b);
}
error[E0597]: `astr` does not live long enough
  --> src/main.rs:10:17
   |
10 |         let a = astr.as_str();
   |                 ^^^^ borrowed value does not live long enough
...
16 |     }
   |     - `astr` dropped here while still borrowed
17 |     println!("dropping {}", b);
   |                             - borrow later used here
2 Likes

So the statement "a must outlive b" only applies to body of the function? Seems like this bit is always glossed over in all lifetime explanations I've seen. For example, this definitely gives the impression that lifetime bound's scope is global.

Anyhow, is there some way to model "If you call function foo with references to objects a and b, then a must not be destroyed before b" in Rust? (because foo might store a reference to a inside b). This is in the context of creating a Rust wrapper for a C library, so the requirement comes from the API I'm wrapping.

It is true. It's just that the compiler automatically creates new references, with new lifetimes.

IIRC, mut references are invariant.

Bear in mind that "'a must outlive 'b" is not what 'a: 'b means. It means "'b must not outlive 'a", or "'a lives at least as long as 'b" -- a subtle but important difference, because it implies that there could be a 'c that both 'a and 'b outlive which could reasonably be substituted for both of them, as the compiler often likes to do when there's a scope narrower than both 'a and 'b, such as during a function call.

3 Likes

Barring interior mutability, you aren't allowed to modify the referent of &T, which is why the lifetime-shortening transformation is valid. If you're going to be modifying something, it needs to be either &mut T or &UnsafeCell<T>, which are both invariant in T.

So this fails to compile, as expected:

fn foo<'a, 'b>(_aa: &'a str, _bb: &'b mut Vec<&'a str>) 
where  'a: 'b,
{ _bb.push(_aa) }

fn main() {
    let mut b = vec![];
    {
        let astr: String = "a".into();
        let a = astr.as_str();

        foo(a, &mut b);

        println!("dropping {}", a);
        drop(a);
    }
    println!("dropping {:?}", b);
    drop(b);
}

No, what happens is that lifetimes can be shrunk. An &'long String can be converted to a &'short String because if it's guaranteed to be value for a certain lifetime then it's also guaranteed to be valid for a subset of that lifetime.

In this case you want the struct b to be generic over a certain lifetime, then the function can take &'a StructA and &'b StructB<'a>.

2 Likes

I am not sure I appreciate the distinction... @chrefr said the lifetimes are of the referents, not of the references themselves. But the compiler can still decide to "shrink lifetimes" by re-borrowing? Are lifetimes associated with references then? This all seems a bit too subtle to be clearly explainable.

This is what I was hoping to avoid, because struct definitions are auto-generated.

The lifetime is the lifetime of the borrow, which happens from when the reference is created to the time it falls out of scope. The compiler can narrow the scope of a borrow because it has no impact on when the referent is dropped. It's confusing because the term "lifetime" can be used to refer to the lifetime parameter (Rust's unique syntax), the 'lifetime argument' that the compiler chooses to satisfy that parameter, or to the lifetime of the object (which has no syntax, and is present in most languages.) These are all distinct things. The 'lifetime argument' can be shortened if it is permitted by the variance and bounds on the parameter.

In your examples, it's hard to express what you're saying you want to, because your foo function is taking two borrows, so it has no meaningful concern over which gets deallocated first: both will be deallocated after the function returns, so why should it care? The only reason it would matter is if it's going to store one or more of the borrows, in which case it should return something to indicate that, or if it's going to store one in the other, in which case one of them should be &mut.

If there's a more complex dependency, you would do as SkiFire13 says, and express that in the types passed to the function. (Using something like PhantomData if they are entirely simulated relations.)

3 Likes

The discussion and example from the NLL RFC may be helpful. (Remember that NLL is now implemented when considering the examples.)

When it comes to "shrinking lifetimes", one analogy that people use is that the longer lifetime is a subtype of the shorter lifetime. (That article isn't my favourite, but it does have examples relevant to this discussion.) So if the bounds require a shorter lifetime, a longer lifetime will also do -- though I can only use the capabilities of the shorter lifetime within the function.

In general, the bounds are a set of relationships which must be met at the callsite, and not a set of directives for the compiler to enforce beyond the callsite. If the compiler can prove that the bounds are met (e.g. by "shrinking lifetimes"), it will allow it.

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.