Pre-pre-RFC/Working Prototype: Borrow-Aware Automated Context Passing

I think I have part of a solution to the problem of broken semantic versioning. Of course, feel free to explore alternatives; none of this is even close to being set in stone—let alone implemented!

As a reminder of the problem, any analysis rule which requires us to know, ahead of time, which trait members borrow something will introduce semantic versioning hazards. Consider the following snippet:

use autoken::*;

// upstream version 1
pub fn wrap_closure(f: impl Fn() + 'static) -> impl Fn() + 'static {
    move || {}
}

// upstream version 2
pub fn wrap_closure(f: impl Fn() + 'static) -> impl Fn() + 'static {
    move || f()
}

// downstream consumer
fn my_consumer() {
    let foo = wrap_closure(|| {
        let _ = BorrowsOne::<u32>::acquire_mut();  
    });
    
    // The upgrade from version 1 to 2 causes this to break since unsizing
    // operations are only permitted if `foo` doesn't borrow anything.
    let foo = &foo as &dyn Fn();
}

A similar issue occurs if the unsizing is done in the upstream crate instead:

use autoken::*;

// upstream version 1
pub fn wrap_closure(f: impl Fn() + 'static) -> impl Fn() + 'static {
    move || {}
}

// upstream version 2
pub fn wrap_closure(f: impl Fn() + 'static) -> impl Fn() + 'static {
    let f = &f as &dyn Fn();
    move || {}
}

// downstream consumer
fn my_consumer() {
    // The upgrade from version 1 to 2 causes this to break since unsizing
    // operations are only permitted if `foo` doesn't borrow anything.
    wrap_closure(|| {
        let _ = BorrowsOne::<u32>::acquire_mut();  
    });
}

While we could use markers to determine whether a given trait is allowed to be unsized à la ?Sized, doing this would only introduce even more function colors to an already quite colorful language!

pub fn wrap_closure(f: impl Fn() + 'static) -> impl Fn() + 'static {
    move || f()
}

pub fn wrap_closure_sized(f: impl ?unsizable Fn() + 'static) -> impl ?unsizable Fn() + 'static {
    move || f()
}

pub fn does_not_compile(f: impl ?unsizable Fn() + 'static) -> impl Fn() + 'static {
    // ERROR: Calling a potentially unsizable trait member in a closure which is
    // unsizable is not allowed.
    move || f()
}

I'd rather not block this proposal on the already complex keyword generics proposal. Instead, I suggest that we ban trait members which perform borrows without absorbing them. Although this sounds quite restrictive, it really isn't! Traits can always emulate the legacy behavior by taking Borrows and BorrowsOne objects as parameters like so:

use autoken::*;

pub fn wrap_closure<T: TokenSet>(f: impl Fn(&mut Borrows<T>) + 'static)
    -> impl Fn(&mut Borrows<T>) + 'static
{
    // We know this closure implementation borrows nothing and is therefore
    // a safe `Fn` implementation since `f(b)` is calling a trait method
    // which we inductively know to borrow nothing!
    move |b| f(b)
}

fn my_consumer() {
    let borrows_nothing = wrap_closure::<()>(|_| {});
    let borrows_something = wrap_closure::<Mut<u32>>(|b| {
        // We need new syntax for this since the old syntax used closures,
        // which can no longer leak borrows.
        absorb b {
            BorrowsOne::<u32>::acquire_mut();
        }
      
        // Personally, I'd also introduce a form of `absorb` which applies the
        // absorbption operation to the entire block to avoid rightward drift.
        //
        // Something like:
        //
        // ```
        // |b| {
        //     absorb b;
        //     autoken::BorrowsOne::<u32>::acquire_mut();
        // }
        // ```
        //
        // ...or even:
        //
        // ```
        // |absorb b| {
        //     autoken::BorrowsOne::<u32>::acquire_mut();
        // }
        // ```
        //
        // ...would go a long way in making the feature usable!
    }));

    // Oh look, trait implementations are always unsizable!
    let dyn_1 = &borrows_nothing as &dyn Fn(&mut Borrows<()>);
    let dyn_2 = &borrows_something as &dyn Fn(&mut Borrows<autoken::Mut<u32>>);
}

One may perhaps be concerned that this restriction would require users to introduce &mut Borrows<T> parameters everywhere. Luckily, this pattern is required not nearly as much as one would expect since it is usually possible for a user to do something like this:

use autoken::*;

pub fn wrap_closure<'a>(f: impl Fn() + 'a) -> impl Fn() + 'a {
    move || f()
}

fn my_consumer() {
    let mut b = BorrowsOne::<u32>::acquire_mut();
    let borrows_something = wrap_closure(|| {
        absorb b;
        BorrowsOne::<u32>::acquire_mut();
    });
    borrows_something();
}

Ideally, we would make this pattern move convenient by allowing borrow acquisitions to be inferred. This, however, would require some complex compiler trickery to defer borrow set inference until all other types have been inferred so it might be a good idea to defer that feature until a later proposal.

Preventing trait members from leaking token borrows also fixes the other big semantic versioning breaker: trait method ties. Here's a reminder of the hazard:

use autoken::*;

pub trait MyTrait {
   fn run<'a>(self) -> &'a ();
}

// version 1
pub fn my_func(f: impl MyTrait, g: impl MyTrait) {
   let _a = f.run();
   let _b = g.run();
}

// version 2
pub fn my_func(f: impl MyTrait, g: impl MyTrait) {
   let a = f.run();
   let b = g.run();
   let _ = (a, b);
}

fn my_consumer() {
    struct Breaks;

    impl MyTrait for Breaks {
        fn run<'a>(self) -> &'a () {
            tie!('a => mut u32);
            &()
        }
    }

    // The upgrade from version 1 to 2 causes this to break since the
    // two results of `.run()` are forced to live concurrently, making
    // `u32` mutably borrowed in more than one place and causing a
    // compiler error.
    my_func(Breaks, Breaks);
}

Since you can't borrow anything in a trait method anymore, hazardous ties become impossible.

One might wonder how we can express token-tied lifetimes in trait methods now. Not to fear: this is still entirely possible by encoding this possibility as part of the trait method signature.

use autoken::*;

cap! {
    MyCap = Vec<u32>;
}

trait MyTrait {
    type Tokens: TokenSet;

    fn do_something<'a>(&self, b: &'a mut Borrows<Self::Tokens>) -> &'a mut Vec<u32>;
}

struct Demo;

impl MyTrait for Demo {
    type Tokens = Mut<MyCap>;
  
    fn do_something<'a>(&self, b: &'a mut Borrows<Self::Tokens>) -> &'a mut Vec<u32> {
        // Since we can absorb `b` for `'a`, leaking `MyCap` from the scope is perfectly fine.
        absorb b {
            cap!(mut MyCap)
        }
    }
}

With that simple change, we've effectively eliminated all of our semantic-versioning breakers without losing an ounce of the system's expressiveness. Neat!

This leaves just one open question for the analyzer: how do we handle generic borrows. Although they don't break semantic versioning, they do result in some potentially nasty semantic versioning foot-guns:

use autoken::*;

// version 1
pub fn my_func<T, V>() {
    let _a = BorrowsOne::<T>::acquire_mut();
    let _b = BorrowsOne::<V>::acquire_mut();
}

// version 2
pub fn my_func<T, V>() {
    let a = BorrowsOne::<T>::acquire_mut();
    let b = BorrowsOne::<V>::acquire_mut();
    let _ = (a, b);
}

fn my_consumer() {
    // The upgrade from version 1 to 2 causes this to break since the
    // borrows of `T` and `V` now alias mutably, causing a compiler error.
    my_func::<u32, u32>();
}

Although it's tempting to also ban borrows involving generic parameters, I'm much less confident in the impacts of that restriction since, unlike the restriction proposed here, that restriction will actually impact feature expressiveness. Maybe we could just lint this type of usage as potentially hazardous since I only really expect power-users to use it?

P.S. Well, we do technically lose the neat Deref overloading trick but we could get that back by introducing an alternative form of Deref that's token aware. I think something like this could work assuming we automatically acquire and pass tokens on each dereference operation.

trait TokenDeref {
    type Output;
    type Tokens: TokenSet;

    fn deref_tokens<'t>(&self, tokens: &'t Borrows<Self::Tokens>) -> &'t Self::Output;
}

trait TokenDerefMut: TokenDeref {
    fn deref_tokens<'t>(&self, tokens: &'t mut Borrows<Self::Tokens>) -> &'t mut Self::Output;
}
1 Like