Dr. David J. Pearce

Fooling the Borrow Checker


An interesting question is how the Rust borrow checker decides when a borrow could still be live. This illustrates a simple example:

let mut x = 1234;
let z = f(&x);
...

The question here is whether or not the borrow &x is still live after the method call. This matters as it impacts how x can be used in following statements. In fact, we don’t have enough information above to answer this definitively. There are two cases:

Answering the question comes down to the return type of f(). For example, if f() returns i32 then it certainly cannot return the borrow. On the otherhand, if f() has the following type then it can return the borrow:

fn f<'a>(p : &'a i32) -> &'a i32 { 
   ... 
}

In this case, it must either return the borrow or return a 'static as there is nothing else it could return to satisfy the return type (well, assuming its not doing something unsafe).

Warm Up

The above is pretty straightforward, but we can make it a bit more interesting as follows:

fn f<'a>(p : &'a i32, q : &'a i32) -> &'a i32 { ... }

   ...
   let mut x = 1234;
   let mut y = 678;
   let z = f(&x,&y);
   ...

Now, there are two borrows going in and only one coming out. To be safe, Rust must assume that either borrow could be returned. Hence, neither x nor y can be mutated after the call (at least while z is still live).

The above is interesting because Rust makes assumptions about what our code is doing, and those assumptions might not hold true. For example, maybe we always return p above but only ever read q (for whatever reason). If we know this, its annoying that Rust doesn’t. In fact, we can resolve this using a simple pattern by rewriting f as follows:

fn f<'a,'b>(p : &'a i32, q : &'b i32) -> &'a i32 { 
   p 
}

This might seem cumbersome, but it does the job as Rust now knows q could never be returned (i.e. since there is no relationship between the lifetimes a and b).

Stretching Out

In the above examples, its fairly obvious from the method signature that it could return the borrow. We can obfuscate this a little by trying to hide it in something else. For example:

struct Wrap<'a> { 
   field: &'a i32 
}

fn f<'a>(p : &'a i32) -> Wrap<'a> { 
   ... 
}

This is still not enough to fool the borrow checker though. The presence of lifetime a in the return type is a giveaway that our borrow could be hiding in there. The same applies for arrays, such as:

fn f<'a>(p : &'a i32) -> Box<[Wrap<'a>]> { 
   ... 
}

(NOTE: to make this compile add #[derive(Copy,Clone)] before struct Wrap)

Again, the presence of a is enough to trigger the borrow checker that our borrow might be returned. Still, in this case, we don’t actually have to return the borrow — we could just return an empty array.

The Puzzle

An interesting (though largely pointless) question arising from all this, is the following puzzle:

Can a method signature fool the borrow checker into thinking a borrow can be returned when, in fact, it cannot?

That is, where the return type is something we know could never hold the borrow, but where Rust must still assume it could. In fact, it’s not so hard. But, my first attempt failed:

struct Empty<'a> { }

fn f<'a>(v : &'a i32) -> Empty<'a> {
    Empty{}
}

The intuition here is that the mere presence of lifetime a in the return type triggers the borrow checker to think &x might be live afterwards. However, this doesn’t compile as Rust is not happy with lifetimes that aren’t used. So, we just need to use it without using it:

struct Empty<'a> { 
   phantom: PhantomData<&'a i32> 
}

fn f<'a>(v : &'a i32) -> Empty<'a> {
   Empty{phantom:PhantomData}
}

And now Rust will always think the borrow could be live after the call, even though this is no valid implementation of f() where this is true (again, assuming it doesn’t do something unsafe).

Another Puzzle

Now, there are other ways to fool the borrow checker, but these somehow don’t seem as interesting to me. For example, we can exploit knowledge of control-flow like so:

fn f(n:i32) {
   let mut x = 123;
   let mut y = 234;
   let mut z = 456;
   let mut p = &mut x;
   //
   if n >= 0 { p = &mut y; } 
   if n <= 0 { p = &mut z; } 
   //
   println!("x={},p={}",x,p);
}

Here, we know the borrow &mut x has expired by the time we reach println!(), but the borrow checker is not this smart (it is just a fancy data-flow analysis after all). We can also fix this program by just using an else block:

fn f(n:i32) {
   let mut x = 123;
   let mut y = 234;
   let mut z = 456;
   let mut p = &mut x;
   //
   if n >= 0 { p = &mut y; } 
   else { p = &mut z; } 
   //
   println!("x={},p={}",x,p);
}

This now compiles because the borrow checker can easily determine that the borrow has expired on all paths through the function.

Conclusion

Well, hopefully that was an interesting take on a few subtle points of Rust! There is no real conclusion, but if you like studying the borrow checker you might find my recent paper on the subject provides interesting reading.


Follow the discussion on Twitter or Reddit