Request help with a borrow checker error, I'm lost!

I would love some help from other friendly Rustaceans! I have stared at this one long enough!

I don't understand why the store.transaction() call is blamed for "escapes the function body here". The error goes away if I comment out the call to the callback FnOnce, so I'm guessing the error could be misattributed here?

If the error is related to the callback, is it possible to annotate the callback's type with lifetimes such that the borrow checker is appeased?

My use case is dependency injection, so think I do want to hold a repository in a Box<dyn Repository>. I'd rather not make huge swaths of my program generic over the repository type. Beyond that I have a lot of flexibility to change the design here.

The compilation error, code, and playground link:

error[E0521]: borrowed data escapes outside of function
  --> src/lib.rs:19:19
   |
15 | fn with_transaction<F>(store: &mut dyn Repository, callback: F)
   |                        -----  - let's call the lifetime of this reference `'1`
   |                        |
   |                        `store` is a reference that is only valid in the function body
...
19 |     let mut txn = store.transaction();
   |                   ^^^^^^^^^^^^^^^^^^^
   |                   |
   |                   `store` escapes the function body here
   |                   argument requires that `'1` must outlive `'static`

For more information about this error, try `rustc --explain E0521`.
error: could not compile `playground` (lib) due to 1 previous error
trait Transaction {
    fn delete_task(&mut self, id: i32);

    // Commit and consume the transaction.
    //
    // See https://stackoverflow.com/q/46620790 for why this argument
    // is boxed.
    fn commit(self: Box<Self>);
}

trait Repository {
    fn transaction(&mut self) -> Box<dyn Transaction + '_>;
}

fn with_transaction<F>(store: &mut dyn Repository, callback: F)
where
    F: FnOnce(&mut Box<dyn Transaction>),
{
    let mut txn = store.transaction();
    callback(&mut txn);
    txn.commit();
}

struct ConcreteTransaction {}
struct ConcreteRepository {}

impl Transaction for ConcreteTransaction {
    fn delete_task(&mut self, id: i32) {
        _ = id;
        todo!()
    }

    fn commit(self: Box<Self>) {
        todo!()
    }
}

impl Repository for ConcreteRepository {
    fn transaction(&mut self) -> Box<dyn Transaction + '_> {
        todo!()
    }
}

fn example(store: &mut ConcreteRepository) {
    with_transaction(store, |txn| {
        txn.delete_task(42);
    });
}

(Playground)

The compiler tells you what your problem is: it expects a reference that lives for 'static.

That is because you are passing a &mut Box<dyn Trait> in which the Box is implicitly Box<dyn Trait + 'static>.

Change it to a non-boxed reference and it compiles. You shouldn't be passing around mutable references to boxes anyway. You don't want the user to be able to replace the transaction object itself with a new allocation anyway.


If you absolutely 100% do want to keep the box, then break the 'static assumption by asking for an inferred lifetime.

Edit: I see that you already had this issue before, didn't you? The return type of the Repository::transaction() method is Box<dyn Trait + '_>, so supposedly you already solved this once, just in a different place.

6 Likes

In this case the compiler pointed me to the store.transaction() call, not the act of passing it to the callback. Is this an area the compiler could be improved? If it had blamed the callback(txn) call I may have figured this out myself.

Wonderful! So the issue was that the Box took a borrow of 'static lifetime, but this wasn't actually the problem. The problem was passing such a Box in the callback(txn) function call. The bounds checker must assume that function could do anything with the txn (because it was a mut Box<T>), including move it and retain it beyond the life of the call. True?

Which is why I'm thinking the compiler error can do better here, and point at the callback(txn) call directly. The lifetime "escapes" the function there, not at the point the compiler error flags. True?

Thank you. Even this helps me understand the issue better.

Yes, but in this case the compiler gave a specific suggestion to add the '_ lifetime, and this fixed the problem. This is one downside to great suggestions in compiler errors: they hand hold the programmer and get them past problems, but the programmer may not learn much from it.

No, that's not even remotely true. You are confusing multiple different things here:

  1. You are confusing a type satisfying the 'static bound and the lifetime parameter of a reference (or any type) being 'static.

    The former means "must be valid if ever borrowed as &'static", IOW "must not contain borrows shorter than 'static"; hence this includes references to statics and owned values that do not borrow at all.

    The latter means that the borrow actually lasts for the 'static duration.

    These two have very little to do with each other. The only partial correlation is that a T: 'short cannot be behind a borrow &'long T. But the converse is not true. i32: 'static because it doesn't borrow anything, yet you can create a short-lived reference (and only a short-lived reference) to an i32 stored in a local variable.

  2. You are also confused about something related to Boxing. I'm not sure about what exactly it is you are thinking, but you can move any value that you have ownership of. This is not related to boxing at all. If dyn Trait could be manipulated by value, you would observe the exact same behavior and error.

    Box is not magic. The ownership structure of Box<T> is exactly the same as that of T itself. The fact that Box contains indirection and heap-allocates doesn't affect borrow checking at all. To the borrow checker, Box<T> looks exactly the same as T. You can move Box<T: Sized> if and only you can move T. You can borrow Box<T> for a given lifetime if and only if you can borrow its inner value for the same lifetime. No difference.

And, finally, to get to the actual root cause of the issue, it's trivial and not deep at all. The root cause is that Box<dyn Trait> implicitly means Box<dyn Trait + 'static>. This is merely a piece of syntactic sugar; there's nothing more to it. It doesn't have to be that way and it's not specific to Box (it works in the same manner with Rc, etc.); it was simply decided that the compiler should specifically add the 'static annotation if the lifetime annotation is omitted, for ergonomic reasons. So this has nothing to do with the ownership or borrowing structure of Box, it's simply a special case in the language, supposedly for the programmer's convenience.

Now if you insist that your function takes &mut Box<dyn Trait (+ 'static)>, then it obviously wont work with anything that contains a lifetime shorter than 'static. But the latter is what your repository object returns – the lifetime of the transaction is tied to that of the borrowed repository object!

Removing the Box helped not because of some deep magic related to Box or anything. The simple reason is that &'lt dyn Trait is implicitly inferred to be &'lt dyn Trait + 'lt (as opposed to &'lt dyn Trait + 'static) by the compiler – this is, again, merely a syntactic device, which can be in place because, unlike an owned value, a reference carries useful lifetime information that the compiler can use to assign a sensible default to the (immediately-contained) inner lifetime.

Incidentally, this isn't really true, either. When you are passing a T: 'short where a T: 'long was expected, then the error may be either that the actually-passed lifetime is too short and the caller is at fault (not the case here), or that the expected lifetime annotation is too strict (long) and the provider of the API is at fault (this is your case). The compiler clearly has to pick one interpretation, but there is no bulletproof way to do that, only half-wrong heuristics.

Lifetimes are not absolute or concrete; the only concrete lifetime is 'static, but scopes can't be quantified, only compared. So expecting the compiler to correctly know whether it's one lifetime that's too short or another that's too long is unreasonable.

This is like asking: Given that person A is heavier than person B, should person A lose weight or should person B gain weight to become healthy? Without being able to measure the absolute weight of each person, this question cannot be answered sensibly.

No, again.

I would advise you to largely ignore compiler suggestions altogether. Especially those pertaining to lifetimes.

People say that "Rust error messages are very good" and that "the compiler's suggestions are really helpful", but I disagree. A compiler is not intelligent, it doesn't have enough context or domain knowledge to be able to supply helpful suggestions for solving semantic problems.

In my experience, the only kind of rustc suggestions that actually work for me are the lowest-level syntactic and lexical ones. A missing semicolon, an unpaired parenthesis, an unterminated string literal, a typo in the name of a type or a variable. These are obvious mistakes with equally obvious, often almost unequivocal, solutions. (I say almost – we tried making languages with automatic semicolon insertion, and the end result is a complete mess.)

Anything higher-level that requires reasoning about eg. lifetimes or traits is "intelligence and domain knowledge needed" territory. For example, the compiler will often emit an error about a type not implementing a trait. For example, people trying to move out of a reference may encounter the infamous "move happens because the type Foo doesn't implement Copy". And then the person will ask how s/he can implement Copy for a type that clearly shouldn't be Copy, eg. a Vec or a MutexGuard. The compiler drives the user in a completely wrong direction.

I don't blame Rust, as this, in my point of view, a problem that simply can't be solved in the compiler. The solution to this sort of misunderstanding is not to try and make the compiler smarter, it is instead educating programmers working in the language.

Passing the Box in the callback(txn) required than txn be a Box<dyn Transaction + 'static> specifically.[1] Everything from there backwards is mechanical: txn being a Box<dyn Transaction + 'static> requires store.transaction() to return a Box<dyn Transaction + 'static>. That in turn requires store to be a &'static mut (dyn Repository + '_).[2] That in turn requires (trying to) extend the lifetime of store to something longer than was passed in, which is an error (that the compiler tried to explain by saying it "escaped the function body").

The borrow checker isn't reasoning about Box specifically or what the callback can do with it in this example. It's just trying to find a solution to the lifetime constraints and eventually saying "no, I definitely can't prove that, this input lifetime would have to be 'static but nothing says it has to be" (or such).

Box<dyn Transaction> mattered because dyn Trait lifetime elision makes that sugar for Box<dyn Transaction + 'static> outside of function bodies, but that's purely a syntactical sugar concern, not something magical about Box specifically. The borrow checker only cares about the desugared lifetime.

[3]

I agree that it could better highlight the source of the 'static constraint -- perhaps the callback invocation, but alternatively "hey look, you needed a Box<dyn Transaction + 'static> due to this bound". Especially since the lifetime is invisible in the bound.


  1. Due to the F: FnOnce(Box<dyn Transaction>) ↩︎

  2. As per the signature of transaction; I'm assuming you understand the fn elision rules. ↩︎

  3. Box is magical in some ways, but not in ways that matter to the example at hand. ↩︎

1 Like

Thanks @paramagnetic and @quinedot, I have filed This "escapes the function body here" error could use more details · Issue #126739 · rust-lang/rust · GitHub about improving the compiler's diagnostics for this situation.

3 Likes