Why is Rust's borrow checker so conservative?

fn main() {
    let mut x = String::from("hello");
    let static_str = give_me_the_static_str(&x);
    x.push_str(" world");
    println!("static_str = {static_str}, x = {x}");
}

fn give_me_the_static_str(_s: &str) -> &str {
    "nice better"
}

This program is totally correct and safe, why rust reject this one ? The static_str is from literal which is stored in .bss segement and alive for the whole program. However, Rust's borrow checker misunderstand that static_str is immutable borrowed from x;

   Compiling playground v0.0.1 (/playground)
error[E0502]: cannot borrow `x` as mutable because it is also borrowed as immutable.
 --> src/main.rs:4:5
  |
3 |     let static_str = give_me_the_static_str(&x);
  |                                             -- immutable borrow occurs here
4 |     x.push_str(" world");
  |     ^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
5 |     println!("static_str = {static_str}, x = {x}");
  |                             ---------- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.
error: could not compile `playground` due to previous error

Because:

  1. Rust does not and should not "see thought" a function's implementation detail.

  2. You did not annotate your function signature right. You should have wrote this instead:

fn give_me_the_static_str(_s: &str) -> &'static str {
    "nice better"
}
13 Likes

The borrow checker operates on its own rules, rather than implementation details. This enables two things:

  • libraries can have a stable API, and be certain that changes to function bodes won't cause lifetime errors for library users elsewhere. If the borrow checker was too smart, small changes that affect actual lifetimes could have a domino effect.

  • it's possible to build abstractions on top of lifetimes that aren't just built-in loans that the compiler understands, but also work with custom allocators, complex data structures, and even things that aren't memory management like locks or other resources.

It's like a game of chess. The rules are strict and sometimes inflexible and complex, but at least they're clear. You don't have to consider whether the pieces are glued to the board or your opponent is going to eat them.

17 Likes

Well, Should Rust give another technique to distinguish lifetime and borrow from who ? In this case,
fn give_me_the_static_str(_s: &str) -> &'a #BorrowFrom() str {
#BorrowFrom(static) "nice better"
}

It does. Your code is equivalent to

fn give_me_the_static_str<'a>(_s: &'a str) -> &'a str {
    "nice better"
}

Note that the lifetime are the same. That is why compilation fails — the inferred lifetime is that of x.

8 Likes

Take a look at the elision rules for more.

fn gmtss(_s: &str) -> &str { "" }
// By the elision rules, it's the same as:
fn gmtss<'a>(_s: &'a str) -> &'a str { "" }
// Which means "the returned value is considered a reborrow of the input"
// because the lifetimes are connected

And the API is the contract -- both for the function writer and the caller.

  • You returned something that coerce to &'a str for any lifetime 'a
    • So the function writer has fulfilled the contract by returning what they said they would
  • When called, the caller must behave as if the return is a borrow of _s
    • As the contract says the function writer is allowed to change gmtss to do so
    • Hence the error in the OP; the API contract is being honored

To get the #BorrowFrom(static) in your API (contract), use the signature @zirconium-n provided.

fn gmtss(_s: &str) -> &'static str { "" }
// Means: "The return value is a `&'static str`" and thus, borrow-check wise,
// not related to the input.  By the elision rules, not that it matters here
// really, the same as:
fn gmtss<'a>(_s: &'a str) -> &'static str { "" }
8 Likes

Well, thinking in the real world, you can borrow two books from different libraries,and both two have same lease term.

The contract should only specify that how long the book is borrowed, but not require where the book is borrowed.

fn give_me_the_static_str_not_from_s_but_outlive_s(_s: &str) -> &'a #TellCallerBorrowFromWhere(w) str {
    #TellCallerBorrowFromWhere(not from _s but from static or some other object which outlive than 'a)
    "nice better"
}

Then borrow checker should know that no aliasing rules are break by the contract since _s and returned &str are borrowed from different object.

The contract has to specify what is borrowed because that's what makes the borrow checker work. You can't prevent aliasing if you don't know what aliases.


That said, specifying from where the borrow comes from is supported too:

// I didn't add your "which outlives" because it's irrelevant in this
// case as per the last link in this reply
fn gmtssnfsbos<'a, 'b>(_s: &'a str, elsewhere: &'b str) -> &'b str {
   elsewhere.trim()
}

In the case of a single argument, the only possible "elsewheres" are going to be from something static or leaked (which would also have a 'static lifetime in this case). The only other things the function body can see are locals, which will be dropped at the end of the function (if not leaked), so it would be UB to return references to those, no matter the lifetime.

If you want you can write the "returns a &'static str" version as

fn gmtssnfsbos<'elsewhere>(_s: &str) -> &'elsewhere str {
    "guess where"
}

But it's functionally equivalent and less clear (presuming you understand a &'static _ can coerce to a &'any_lifetime _). You could even limit it to be "at least as long", technically...

fn gmtssnfsbos<'elsewhere: 's, 's>(_s: &'s str) -> &'elsewhere str {
    "guess where"
}

...but this is actually still functionally equivalent to...

fn gmtssnfsbos(_s: &str) -> &'static str {
    "guess where"
}

...because the input parameter's lifetime can similarly be coerced to an arbitrarily short lifetime, and one call also call let _: &'static str = gmtssnfsbos(arg) to get the 'static output version.

Or illustrated more directly. And similarly for multiple arguments.

6 Likes

The contract does require where the borrow comes from. The obvious but often neglected case is the lifetime annotation on the function or aforehand means inputs or output could stay alive outside of the function body. Examples:

If the contract doesn't require that, you can easily let a reference yielded within the function body escape, leading to invalid dangling reference which Rust strongly forbids.


Annotating lifetimes is like picking from contracts.

The <'a>(&'a str) -> &'a str contract, in short form (&str) -> &str, only specifies the output stay alive as long as the input is alive, but doesn't specify the output could strictly outlive the input (in this case after the input is dead, the output is still valid to be alive). The contract to the latter case can be:

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