A thing that is often quite time-consuming writing Rust code is the fight with the borrow-checker. This post will give a small overview of the problem and how to fix them or better, how to prevent them.
Having some native development background makes it a bit easier to jump into Rust, but there is also a need to change the mindset. Coming from any other language it’s very likely that the way one wrote code will simply not work with Rust, although being a “good developer”. At least I learned that the hard way.
One of Rust’s core concepts is:
- ownership: data has exactly one owner
- exclusive mutable or shared immutable borrows: borrowed data is exclusive mutable or shared immutable
Ownership
Languages without garbage collector like C++ provide concepts and conventions for ownership. The problem is that the language can’t enforce them. Thus static code analysis tools may help you, but at some point even they can’t.
Rust on the other hand has a strict ownership concept. That also means reading any code will clearly show what is happening regarding ownership.
pub fn main() {
let cat = Cat {};
feed(&cat); // borrow
// we are still owner of `cat`
sell(cat); // pass ownership
// we are not allowed to use `cat` anymore
feed(&cat); // err: cat moved
}
struct Cat {}
fn feed(_cat: &Cat) {}
fn sell(_cat: Cat) {}
Obviously, ownership will not be a big problem as it feels kinda natural to have this concept. It’s getting more interesting when borrowing is involved.
Exclusive mutable or shared immutable borrows
While data is exclusively owned by other data or a local variable, it’s possible to borrow data during its lifetime and for example pass it as parameter to a function. There are two different borrow types:
- immutable borrows:
&T
- mutable borrows:
&mut T
Depending on whether you want to mutate data or not, you need to pass the borrow. Technically it’s just a reference, but Rust has strict rules about borrowing:
- either have multiple immutable (
&T
) borrows - OR exclusively one mutable (
&mut T
) borrow
Those rules may sound reasonable at first place. But let’s look at the next example which will not compile.
pub fn main() {
let mut cat = Cat::new();
let checker = FeedChecker { cat: &cat }; // immutable borrow
cat.feed(); // ERR: mutable borrow
if checker.is_fed() {
println!("cat is ok");
} else {
println!("cat is hungry");
}
}
struct Cat {
fed: bool,
}
impl Cat {
fn new() -> Self {
Self { fed: false }
}
fn feed(&mut self) {
self.fed = true;
}
}
struct FeedChecker<'a> {
cat: &'a Cat,
}
impl<'a> FeedChecker<'a> {
fn is_fed(&self) -> bool {
self.cat.fed
}
}
We pass an immutable borrow to the FeedChecker
that it can check the cat later and we then also mutate the cat, because you can read cat.feed()
same like Cat::feed(&mut cat)
, because feed
takes a &mut self
, so it creates a mutable borrow of cat
. Having the immutable borrow living in the FeedChecker will overlap the mutable borrow for feeding the cat, which then break the second borrow rule.
Borrow-checker issues — Code smell or bug?
Now, why on earth should the last example be a problem? You may have heard the term “multiple readers or one writer” in a multi-threaded context. That is not an issue here, because Rust has other concepts for data access with multiple threads, but it is going in the same direction.
Let’s assume is_fed
is a very expensive operation and in Rust we have the second rule, that when I have a borrow (no matter if mutable or immutable) it’s not possible for any other component to have a mutable borrow, so reading is_fed
once could be cached for the whole lifetime of the FeedChecker
.
Think of &mut T
means “exclusive (mutable) access” and &T
means “no one has mutable access”.
What helped me understand the second rule and “mutable borrows” is to better consider them as application wide exclusive access, so the other way round: whatever you may mutate, you can assume to not break any reader’s discovery for that data.
Mutating a vector while someone has an iterator into it is not possible in Rust and that’s a very good reason to have the rule of exclusive mutability.
Still the fact that the simple example with FeedChecker
is not compiling is a bit annoying and there are quick (but somehow also more dirtier) ways to fix that. Some of them (like RefCell
) also come with a runtime cost, which is rarely what we want. The overall problem is actually simpler than that. A good question is: Why should FeedChecker
need a reference to a cat before and after is_fed()
calls?
What about that our FeedChecker
only encapsulates the is_fed
implementation and we borrow the cat
for the check? Yes, we may also go further and ask why at all do we need a struct FeedChecker
when it’s empty? Sometimes all what we need is a simple pure function.
The following example shows, what we at least have to do to make it compile, but we can also omit the whole struct FeedChecker
and simply implement one function.
pub fn main() {
let mut cat = Cat::new();
let checker = FeedChecker {};
cat.feed();
if checker.is_fed(&cat) {
println!("cat is ok");
} else {
println!("cat is hungry");
}
}
struct Cat {
fed: bool,
}
impl Cat {
fn new() -> Self {
Self { fed: false }
}
fn feed(&mut self) {
self.fed = true;
}
}
struct FeedChecker {}
impl FeedChecker {
fn is_fed(&self, cat: &Cat) -> bool {
cat.fed
}
}
How to get rid of (most) borrow-checker issues?
My personal #1 experience to most of the borrow-checker issues I ran into were excessive borrow lifetimes, which then overlap. That means, for example, there is mostly no reason to store borrows in structs (e.g. via constructor), only to have them when any of the struct methods are called. That “pattern” is tempting to reduce boilerplate code, but soon or later may break the second borrow rule.
Reducing borrow lifetimes to the needed minimum may fix borrow-checker issues.
Creating a struct
I always try to have only owned data, no borrows, which on the other hand is also simpler code without lifetimes. I think there is no rule how to write Rust code to always avoid borrow issues. They may happen at a time, telling you either you have some code smell or they even save you from bugs, like mutating a vector while having an iterator, which e.g. leads to nasty undefined behavior in C++.
Conclusion
The borrow-checker doesn’t only guarantee, that data access is legit. The additional positive result is, that we should start to rethink our code design. It’s likely that simpler code designs may fix borrow-checker issues.
- A borrow-checker issue may be code smell or a real bug. Rust doesn’t allow either, respectively doesn’t know the kind. It doesn’t compile.
- It’s mostly possible to fix borrow issues throwing mutability wrapper on them, but you should rather investigate on fixing it with a better design, because it may bite you again or worst case: panic at runtime.
- “Code smell” borrow-checker issues are fixed by reducing borrow lifetimes so that they don’t overlap. Passing borrows to store them (e.g. via constructor) for later use may be one use-case when a borrow lifetime is rather excessive than necessary.
- Try to design structs owning all their data and pass short-living borrows as parameters for their methods. That may increase boilerplate code but elegant design simplicity will shine through. Those “backdoor borrows” may feel like hidden side-channels and confuse the reader.