The Developer’s Cry

Yet another blog by a hobbyist programmer

Rust and SDL2 : Fighting With Lifetimes (part 3)

The previous post tells the tale of an epic battle with the Rust borrow checker, and how we, in the end, made our escape by calling in the help of Rc. But one does not simply drop Rc and walk away. There is still some explaining to do of how this works, and what we were doing wrong in the first place. Rust is a wonderful language, if you know what you’re doing.

Never send a human to do a machine’s job

The trouble started with defining this structure:

struct TextureManager {
    creator: &TextureCreator<WindowContext>,
    textures: Vec<Texture>,
}

Since this doesn’t work, we followed the rust compiler’s advice in adding lifetime annotations, only sinking deeper into the swamps. I find this thinking of “creator” and “lifetimes” to be very confusing, but you know, we have been misled! In order to clarify things, I am going to give a different example that is (hopefully) a lot less confusing. Take this structure:

struct Sprite {
    atlas: &TextureAtlas,
    nr: u32,
    x: i32,
    y: i32,
}

We have a 2D sprite that is a part of a texture atlas. The atlas contains many sprites; many sprites refer to the same atlas. This code does not compile, and Rust advises to add lifetimes. This sends us down the same path as before, leading to another fight with the borrow checker. The root of the problem is entirely in misunderstanding the meaning of “&” and what a reference is.

In C++ a reference acts like a pointer. You can pass objects to a function either by value (which makes a copy on the stack) or by reference. Large objects are practically always passed by reference, because it avoids making the copy, and thus is much cheaper, has better performance. [If you take a debugger and go down to the machine level, you will find that a reference is indeed coded as an address, which is basically the same thing as a pointer.]

But Rust is not C++. Rust works with ownership, and when you pass objects to a function “by reference” then they are borrowed. The “&” ampersand in the struct field is not just a reference, it is a borrowed reference. “Borrowing”, as in “temporarily giving away”. To be more precise, this particular borrow is an immutable borrow. Besides ownership, Rust also tracks the lifetime of the borrow. Often the lifetime is implicit (due to so-called lifetime elision), but at times the compiler will request that you add explicit lifetime annotations.

So in the case of the sprite and the texture atlas, the whole texture atlas object has been given to that sprite. If you now want to mutably use the atlas elsewhere, like in 500 lines down in the code, then the borrow checker will complain, because the sprite still holds the immutable borrow.

Dodge this

I never wanted it to be a borrow in the first place. What I meant to write was that the sprite has a pointer to the texture atlas. Raw pointers are unsafe in Rust, but we can have shared pointers. Shared pointers are reference counted, hence the name Rc. And so, the Sprite structure gets fixed by:

struct Sprite {
    atlas: Rc<TextureAtlas>,
    nr: u32,
    x: i32,
    y: i32,
}

The only thing to be aware of is to clone Rc’s correctly; first create them from the original object with Rc::new(), and then make new “references” to the data with Rc::clone(). [Do not use the .clone() method/function. That one makes a copy of the reference count, which is not what we want. I guess the Rust team ran out of meaningful words when naming these functions].

The nice thing about Rc is that it truly is a pointer, and we can refer to public fields and functions in atlas as we normally would, for example:

let size = self.atlas.cell_size();

That is a cool language feature because it allows you to retool existing data structures to use Rc, without having to change a lot of code.

Some rules can be bent. Others can be broken

Recapping, Rc is a reference counted shared pointer. Use it when you have many objects pointing at one other object, or in other words, when you have many objects sharing an object.

There is a consequence to sharing an object: in principle the share is immutable. For example, suppose we want to apply a color filter over the texture atlas, then we’re going to have to mutate it. Color modulation in SDL2 is a property of the texture; you actively have to change the texture instance in order to apply color modulation.

The solution is simple: put it in a RefCell like so:

struct Sprite {
    atlas: Rc<RefCell<TextureAtlas>>,
    nr: u32,
    x: i32,
    y: i32,
}

RefCell allows mutability in places that were immutable before. Think of the RefCell as a read/write lock with which you are able to mutate the data in a safe way. [It’s not really a read/write lock. Our code is single threaded anyway]. There is a tiny runtime cost involved, but it’s immeasurably small. The annoying thing about RefCell is that you now have to write explicit borrows when accessing, like so:

self.atlas
    .borrow_mut()
    .texture.set_color_mod(
        Color::RGBA(0, 255, 0, 255)
    )

This kind of explicit borrows will often be tucked away in struct methods, so don’t worry about it too much.

Basically whenever you want a shared pointer, it will often be a Rc<RefCell<T>>. The syntax is pretty horrible at first, but you’ll take it up quickly. It’s common idiomatic Rust, and you’ll be seeing this thing more often.

I can only show you the door

What if we do not want to share with many objects? You can actually stubbornly put a borrowed reference with a lifetime as struct field anyway. It will compile and it does work in cases. In my experience it works especially for short-lived objects, thus with short-lived borrows. However if you later decide to use such an object in a different scenario where it lives longer, there’s a fair chance of running into borrowck hell again. Using a smart pointer is a much better choice, and saves you the headaches should you refactor the code again later.

If you know you are not going to share with anyone else, then you might use a Box<T> smart pointer. Box<T> works like unique_ptr in C++ in that there is a single owner of that data. In practice however, just grab Rc<T> and Rc<RefCell<T>> and use that. It is somewhat important to get the data structures right in the early stages of development. Adding Rc<RefCell<T>>’s and the explicit borrows in later can be a chore, but it can be done.

Going back to the original problem of the TextureManager, it’s now obvious that we shouldn’t put a borrowed reference as a struct field. Truthfully, the final version of my code has no “texture manager”. There is nothing to manage, really, since we have shared pointers now; no need to do any lookups via handles.

Free your mind

Lifetimes are actually not an entirely new thing. Scoping of variables creates lifetimes. The fight with the borrow checker was not so much about lifetimes; it was more about misunderstanding references — or really I should say borrowed references.

Software is designed around data structures. The code reflects how these data structures relate and interact.