Rust In Practice - Dealing With Borrowck
Rust is a great language to code in, but as many would agree, it has a learning curve. Even if you don’t bother with traits, lifetimes, refcounted pointers, or any of that, you may still encounter difficulties with the borrow-checker from time to time. Rust demands that you structure the code in a certain way, and it may be hard putting a finger on what exactly is the right structure to keep our beloved borrow-checker happy.
Apples and oranges
Whenever I run into borrowck issues, my first intuitive reaction is to refactor the code. Sometimes it works, sometimes it doesn’t. What’s up with that? Let’s get to the bottom of this.
People online say that writing Rust code is not the same as writing in Java or C++. While I get their point, I’m not so convinced that it’s totally different.
As a concrete example, let’s have a look at a simple windowing system, that
could be used for a GUI, or maybe a text-mode UI. The user interface has
Window
s that are drawn to the Screen
. And thus we have:
struct Window {
x: u16,
y: u16,
w: u16,
h: u16,
color: Color
}
struct Screen {
w: u16,
h: u16,
stdout: Stdout
}
impl Screen {
fn draw(&mut self, window: &Window) {
...
}
}
The Screen
does drawing operations, so it makes sense that Screen
has a method that draws a Window
, right?
From an object-oriented point of view, we might also say that the
Window
is the central object, and a Window
knows how to draw
itself onto the Screen
.
[In fact, in C++ I would simply use a global variable screen
and let
Window::draw()
use that. Since there is only one screen (AFAIC) anyway,
I don’t have a problem with using globals like that. I do realize that
technically, that means the C++ version relies on a side-effect. Sue me.]
In Rust mutable globals are a no-no, and therefore we write:
impl Window {
fn draw(&self, screen: &mut Screen) {
...
}
}
It’s apples and oranges whether it’s the screen doing the drawing, or the window drawing itself onto the screen. It doesn’t matter much. But let me get back to this later.
Brain stew
The window and screen have to be managed somewhere, and we can pass
them down from main()
, or we can have a nice WindowManager
.
struct WindowManager {
windows: Vec<Window>,
screen: Screen
}
impl WindowManager {
fn open(&mut self, window: Window) {
self.windows.push(window);
let window_ref = self.windows.last().unwrap();
------------ immutable borrow occurs here
self.draw(window_ref);
| |
| immutable borrow later used by call
mutable borrow occurs here
}
fn draw(&mut self, window: &Window) {
self.screen.draw(window);
}
}
Ack! Borrowck kicks in, we can not code it like this. There is no harm done in Java nor C++, but Rust does not allow it.
Let’s try refactoring, and passing the screen separately:
fn open(&mut self, window: Window) {
self.windows.push(window);
let window_ref = self.windows.last().unwrap();
self.draw(window_ref, &mut self.screen);
---- ---- ^^^^^^^^^^^^^^^^ mutable borrow occurs here
| |
| immutable borrow later used by call
immutable borrow occurs here
}
fn draw(&self, window: &Window, screen: &mut Screen) {
screen.draw(window);
}
Well, that was a futile attempt.
It can be frustrating when borrowck stops you. When you spell out all the bytes, there is nothing wrong with this code. The same thing works perfectly when written in Java or C++. There seriously is no bug here whatsoever—but this pattern doesn’t play by the borrowck rules. It’s a precaution mechanism for subtle, hard to find bugs that appear in Java, C++, and other unsafe languages.
Whenever I run into borrowck, I have to remind myself that a reference is not a pointer, it is a borrow that is handed out.
But if you can’t code it like this, then how do I …? This confusion causes many programmers to turn away from Rust. Imagine having 20, 30, 40 years of programming experience, and then this language just fails to compile? In order to make it work, we have to work with the borrow-checker, not against it.
Keep em separated
Let’s analyze it one more time, and see what we can do. We have our data structure:
struct WindowManager {
windows: Vec<Window>,
screen: Screen
}
We want to open a window, add it to the .windows
list, and then draw
that window to the screen.
What we are doing is calling a mutable method (ie. passing &mut self
)
while holding an immutable reference into self.windows
.
See the added annotations:
fn open(&mut self, window: Window) {
^ all of self is mutable
// append window to our list of windows
self.windows.push(window);
let window_ref = self.windows.last().unwrap();
^ immutable reference into self.windows
self.draw(window_ref);
^ call mutable method
}
fn draw(&mut self, window: &Window) {
^ ^ immutable reference into self.windows
^ all of self is mutable
self.screen.draw(window);
}
You can’t do this cross-reference of mutable and immutable data when
calling the draw
method. The compiler was pointing out the offending
line all along:
self.draw(window_ref);
There is no way for the Rust compiler to guarantee that self.draw()
is not going to modify self.windows
(that would be a data race!)
and therefore it won’t compile.
We have a couple of options:
- change data layout; take
Screen
out of the struct - change the code; refactor so that
self.windows
andself.screen
are always used separately - make a copy of the object so that it doesn’t interfere with mutations
All of these solutions work;
1. change data layout
The Rust compiler doesn’t do code philosophy (it just looks at
data ownerships and mutability), but we can easily argue that
a WindowManager
should not own a Screen
.
Therefore the screen does not belong in the struct.
So let’s remove it, and pass it as a separate parameter:
struct WindowManager {
windows: Vec<Window>,
}
impl WindowManager {
fn open(&mut self, window: Window, screen: &mut Screen) {
self.window.push(window);
let window_ref = self.window.last().unwrap();
screen.draw(window_ref);
}
}
This shows that data design is more important in Rust than it is in other languages.
2. refactor code
Let’s keep the struct as it is.
We are having trouble calling a mutable method. So let’s try avoidance. One way of doing that is inlining:
impl WindowManager {
fn open(&mut self, window: Window) {
self.window.push(window);
let window_ref = self.window.last().unwrap();
self.screen.draw(window_ref);
}
}
It may seem bananas that such trickery is necessary.
However, we can now clearly see that self.window
and self.screen
are being used separately; there are no data races possible.
Another way of doing that is “outsourcing”:
impl WindowManager {
fn open(&mut self, window: Window) {
self.window.push(window);
let window_ref = self.window.last().unwrap();
Self::draw_window(window_ref, &mut self.screen);
}
fn draw_window(window: &Window, screen: &mut Screen) {
screen.draw(window);
}
}
Here draw_window()
is a static method. It may also be a free function
in the crate.
This code reveals that we can reverse the situation: instead of having
the screen draw the window, let the window draw itself onto the screen.
It’s apples and oranges, but sometimes the result is more logical.
Notice how Window::draw()
is an immutable method:
impl WindowManager {
fn open(&mut self, window: Window) {
self.window.push(window);
let window_ref = self.window.last().unwrap();
window_ref.draw(&mut self.screen);
}
}
impl Window {
fn draw(&self, screen: &mut Screen) {
screen.draw(self);
}
}
3. make a copy
We can prevent holding a reference into self.window
by making a copy of
the referenced element. A deep copy of an object is made with .clone()
.
impl WindowManager {
fn open(&mut self, window: Window) {
self.window.push(window);
let window_clone = self.window.last().unwrap().clone();
self.draw(&window_clone);
}
}
Making a copy is considered bad practice because it’s a lazy solution. It takes memory and (a little) performance, while better options exist.
OOPsie daisy
Compositing structs is a common approach for data encapsulation and adding higher layers of abstraction. People on the internet are quick to blame OOP, but it’s not OOP that is the problem. It is easy to make connections that are criss-cross, inside-out, upside-down. Rust borrowck does not allow such design, because it wouldn’t be able to guarantee memory safety.
The key is to decouple things that should not be coupled in the first place. Use separation of concerns like your life depends on it. As a general rule, refrain from linking structures together, or excessively combining them into larger structures. When the data layout is sound, refactoring is usually possible.
One tip is spend more time in the design phase. I am not a fan of meticulously planning out everything up-front, however. I prefer the bottom-up approach, starting out small, and letting it come to fruition from there. Stick to the main strategy—a separation of concerns—and there should be few issues with borrowck.