Eta-expansion, or what does Rust think it is protecting me from?

In this program:

struct Obj {
    o: i32
}

fn make() -> Obj {
    Obj { o: 42 }
}

impl Obj {
fn touch(self: &mut Obj, amt: i32) -> i32 {
    self.o = self.o + amt;
    return self.o;
}
}

fn main() {
    let mut t = make();
    let x1 = t.touch(t.touch(6));
    println!("{:?}", x1);
}

The compiler errors with the message:

24 |     let x1 = t.touch(t.touch(6));                     
   |              - ----- ^ second mutable borrow occurs here                                                         
   |              | |                                      
   |              | first borrow later used by call        
   |              first mutable borrow occurs here 

However, when I eta-expand it, like:

fn main() {
    let mut t = make();
    let x0 = t.touch(6);
    let x1 = t.touch(x0);
    println!("{:?}", x1);
}

It works.

I expect that programming languages should be invariant to such transformations, because the presence or non-presence of variables that appear only once in the program shouldn't matter.

However, I'm very skeptical that this is a problem with Rust and instead assume that there's some situation that Rust is trying to protect me from that I can't see.

Can some please help me understand?

Thank you!

Jay

1 Like

There is no real reason per se, I'd say, it's rather an unhappy byproduct of how everything else in Rust works.

In this case, there are two things going on / to be aware of:

  • Expression / statement temporaries, and when they are dropped.

    That is, the "eta-expanded" / "outlined (2nd) arg" version of:

    let x1 = t.touch(t.touch(6));
    

    is actually:

    //            if this expression created temporaries ...
    //             vvvvvvvvvv                |
    let x1 = match t.touch(6) { x0 => { //   |
        t.touch(x0) // -- borrow of `t` ---->|
    }}; // <-- ... they'd be dropped here ---+
    

    That is, the temporaries created by t.touch(6) (if any) are only dropped after the outer call, so if one of these temporaries had drop glue, and borrowed from t, then the compiler would be right in forbidding the t.touch( t.touch(6) ) call.

    • Granted, it is not the case in your example, but a sneakier one could feature it.

    This "problem" would not be present if rewriting the code as:

    let x1 = {
        let x0 = t.touch(6); // ---------+
        // <-- temporaries dropped here -+
        t.touch(x0)
    };
    

    This is to say that no matter what I'll say in the following section, there is a semantic difference between your two versions; if you want to "eta expand" / "outline a temporary as an expression", then the match is the actual way of writing it (featuring let ... in / scoped let semantics).

  • Order of evaluation of function args.

    This has to do with the fact that, if you wrote:

    fn touch_into (amt: i32, at_t: &mut Obj)
      -> i32
    {
        at_t.touch(amt)
    }
    

    then:

    let x0 = touch_into(t.touch(6), &mut t);
    

    compiles fine.

    How so? Well, because the expression t.touch(6) is evaluated first, the borrow has the time to end, and only then is &mut t evaluated, avoiding the conflicting borrow.

    but in your example, if we were to write in in fully desugared form, we'd have:

    t.touch(t.touch(6))
    

    becomes

    Obj::touch(&mut t, t.touch(6))
    

    which becomes:

    match &mut t { arg0 => {
        //    vvvvvvvvvv - Error, t is already being borrowed.
        match t.touch(6) { arg1 => {
            Obj::touch(arg0, arg1)
        }
    }
    

    As you can see, you hit a limitation whereby order of evaluation leads to the borrow on the "left t" to start before the borrow on the "right t".

7 Likes

If you expand the self argument as well, you get the following expansion, which is roughly what the compiler uses:

let self0 = &mut t;
let x0 = {
    let self1  = &mut t;
    let x1 = 6;
    Obj::touch(self1, x1)
};
let x1 = Obj::touch(self0, x0);

This breaks because the self0 and self1 references break the aliasing rules. Note that self0 is evaluated before x0 because arguments are evaluated from left to right.

As you showed, there is a way to reorder this code that is fully equivalent and does not violate aliasing. The compiler even has a special rule called two-phase borrowing that effectively does this re-ordering implicitly in some cases. However, it only covers nested method calls where the “outer” call takes &mut self and the inner call takes &self.

There was some discussion of generalizing two-phase borrows to include cases like this one. It's possible that the compiler will be smart enough to allow this code in the future.

12 Likes

Thank you very much! @Yandros!

1 Like

Thank you very much! @mbrubeck!

1 Like

Thank you both, I've made an issue about this.