Unable to break 'a: 'b constraint

Hi!

To test my understanding of lifetimes I've been trying to write the simplest code that breaks the where 'a: 'b. I wrote two pieces of code that seems invalid to me, but the code compiles.

Can you explain why this compiles? Can you find a simple snippet that breaks the 'a:'b constraint - but the snippet compiles if you remove the constraint from the struct?

fn main() {
  break_attempt1();
  break_attempt2();
}

fn break_attempt1() {
  let outer = 12;
  {
    let inner = 34;
    let valid = SomeStruct { long_ref: &outer, short_ref: &inner };
    println!("(valid) {}, {}", valid.long_ref, valid.short_ref);
    let invalid = SomeStruct { long_ref: &inner, short_ref: &outer };
    println!("(invalid) {}, {}", invalid.long_ref, invalid.short_ref);
  }
  println!("DONE! {}", outer);
}

fn break_attempt2() {
  let outer = 12;
  let mut foo = SomeStruct { long_ref: &outer, short_ref: &outer };
  println!("orig: {}, {}", foo.long_ref, foo.short_ref);
  {
    let inner = 34;
    foo.short_ref = &outer;
    foo.long_ref = &inner;
    println!("invalid mut: {}, {}", foo.long_ref, foo.short_ref);
    foo.short_ref = &outer;
    foo.long_ref = &outer;
    println!("restored: {}, {}", foo.long_ref, foo.short_ref);
  }
  println!("DONE! {}", outer);
}

struct SomeStruct<'long, 'short> where 'long: 'short {
  long_ref :&'long u32,
  short_ref :&'short u32,
}

References are covariant in their lifetime parameter (and shared references are also covariant in their referent's lifetime parameter, if any), so you'll never be able to do this with just one level of reference or any number of shared references. The compiler will shorten the longer lifetime and equate it with the shorter one.

You have to ensure invariance by introducing a level of mutable references.

1 Like

In broad strokes, the borrow checker

  • figures out where all lifetimes must be alive, due to constraints and the uses of values[1]
  • figures out where and how[2] all places[3] must be borrowed[4]
  • checks every use[5] of every place to see if the use conflicts with a borrow[6]

Looking at your attempts, there are probably some misconceptions around lexical scopes ({ ... }). The main interaction of borrow checking with scopes is that destructors can run when values go out of scope. That can...

  • mean a lifetime/borrow has to be alive at the end of a scope
  • count as the use of a place

But in your playground, all the types have trivial destructors that don't cause the lifetimes/borrows to be alive, and you don't try to keep a variable borrowed while it goes out of scope either. So the nested scopes you've added have no effect on the borrow checking.

Borrow checking doesn't care which lexical scope a variable was declared in. It does care when the variable is used, including going out of scope. Lifetimes are also "forward-facing": they don't care about parts of the code that are before when the borrow actually happens.

Additionally, all your borrows are shared-borrows and most your uses are compatible with being shared-borrowed too, so there's not a lot of chance for conflicts.


OK, that was a lot of abstract talk. Let's look at something more concrete.

Here, the borrows only have to last until the println!, because you don't try to use them after that. Making the lifetime the same and having them end after the println! is a valid solution to the lifetime constraints. Where inner and outer were declared matters not at all.

        let invalid = SomeStruct {
            long_ref: &inner,  // -------- 'long -------------+
            short_ref: &outer, // -------- 'short ------------+
        }; //                                                 |
        println!( //                                          |
            "(invalid) {}, {}", //                            |
            invalid.long_ref,  //                             |
            invalid.short_ref //                              |
        ); //                                                 |
        // ---- no more uses of 'long or 'short --------------+
    }

In the second attempt, it doesn't matter where foo was declared, either. The "borrows can just be the same and end after the last use of foo" part is pretty much the same. Technically the borrowing is more complicated, although it didn't matter to the example.

fn break_attempt2() {
    let outer = 12;
    let mut foo = SomeStruct { // 'long, 'short
        long_ref: &outer, // 'outer_1: 'long
        short_ref: &outer, // 'outer_2: 'short
    };
    println!("orig: {}, {}", foo.long_ref, foo.short_ref);
    {
        let inner = 34;
        foo.short_ref = &inner; // 'inner_1: 'short
        println!("valid mut: {}, {}", foo.long_ref, foo.short_ref);
        // `inner`'s borrow can end here because the borrowing
        // reference gets overwritten.  Same with `outer`,
        // technically, but it's immediately borrowed again
        foo.short_ref = &outer; // 'outer_3: 'short
        foo.long_ref = &inner; // 'inner_2: 'long
        println!("invalid mut: {}, {}", foo.long_ref, foo.short_ref);
        // inner can be not-borrowed again
        foo.long_ref = &outer; // 'outer_4: 'long
        foo.long_ref = &outer; // 'outer_5: 'long
        println!("restored: {}, {}", foo.long_ref, foo.short_ref);
        // No more uses of borrowed values: 'long and 'short
        // can end here
    }
    println!("DONE! {}", outer);
}

On top of all that, the lifetimes in your SomeStruct are covariant, meaning that they can coerced to be shorter.... ah as I write this, I see someone else has already pointed out how this can make a demonstration harder. But it is still possible to have conflicts without invariance or nesting.

Here's one demonstration of "breaking the constraint" without getting rid of the covariance, by coding an unsatisfiable ascription:

fn break_attempt() {
    let mut foo: SomeStruct::<'_, 'static> = SomeStruct {
        long_ref: &0,
        short_ref: &0
    };
    let local = 0;
    foo.long_ref = &local;
}

And here's another example which is probably more illustrative.

fn break_attempt() {
    let mut a = 0;
    let b = 0;
    let foo = SomeStruct {
        long_ref: &a,
        short_ref: &b,
    };
    
    // Overwriting conflicts with shared borrows
    a = 0;
    
    // Use the short borrow, forcing 'short to be alive
    println!("{}", foo.short_ref);
    
    // 'long: 'short, so 'long has to be alive too
    // So it had to be alive when `a` was overwritten too
    // But that's a conflict
}

  1. whose type has a lifetime ↩︎

  2. e.g. shared vs exclusive borrows ↩︎

  3. such as variables ↩︎

  4. which can be shorter than the lifetime of the type that borrowed the place due to things like references getting overwritten ↩︎

  5. there are different types of uses, similar to how there are different types of borrrows ↩︎

  6. for example, a variable being overwritten is in conflict with being shared or exclusive borrowed, whereas a variable being read is in conflict with being exclusive borrowed, but not in conflict with being shared borrowed ↩︎

2 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.