What Not to Do in Rust
Editor's note: A version of this first ran on Armin's own blog last week. If you're in the mood to read this post twice, or just want to see what else Armin is up to, be sure to check it out.
The last year has been fun, and a big reason for that is because we’ve been building a lot of really nice stuff at Sentry in Rust, and, for the first time, the development experience was free of bigger roadblocks. Though we were using Rust previous to this past year, it feels different now because the ecosystem is so much more stable and we ran up against fewer language or tooling issues.
That said, talking to people who are new to Rust — and even brainstorming APIs with coworkers — it's hard to get rid of the feeling that Rust can be a mind bending adventure. That’s why the best way to have a stress free experience is knowing upfront what you cannot (or should not attempt to) do. Knowing that certain things just cannot be done helps get your mind back on the right track.
So based on our experience, here are some things not to do in Rust, and what to do instead, which I think should be better known.
Things move
The biggest difference between Rust and C++ (at least for me) is the address-of operator (&). In C++ (like C) that just returns the address of whatever its applied to and, while the language might put some restrictions on you whenever doing so is a good idea, there is generally nothing stopping you from taking an address of a value and just using it.
But in Rust this is usually not useful. First of all, the moment you take a reference in Rust the borrow checker looms over your code and prevents you from doing anything stupid. More importantly, however, is that even if it's safe to take a reference it's not nearly as useful as you might think. The reason is that objects in Rust generally move around.
Take how objects are typically constructed in Rust:
struct Point {
x: u32,
y: u32,
}
impl Point {
fn new(x: u32, y: u32) -> Point {
Point { x, y }
}
}
Here the new method (not taking self) is a static method on the implementation. It also returns Point
here by value. This is generally how values are constructed. Because of this, taking a reference in the function does not do anything useful as the value is potentially moved to a new location on calling. This is very different to how the whole thing works in C++:
struct Point {
uint32_t x;
uint32_t y;
};
Point::Point(uint32_t x, uint32_t y) {
this->x = x;
this->y = y;
}
A constructor in C++ is already operating on an allocated piece of memory. Before the constructor even runs something already provided the memory where this
points to (typically either somewhere on the stack or through the new operator on the heap). This means that C++ code can generally assume that an instance does not move around. It's not uncommon that C++ code does really stupid things with the this
pointer as a result (like storing it in another object).
This difference might sound very minor, but it's a fundamental one that has huge consequences for Rust programmers. In particular, it’s one of the reasons you cannot have self referential structs. While there has been talk about expressing types that cannot be moved in Rust there’s no reasonable workaround for this at the moment (the future direction is the pinning system from RFC 2349).
So what do we do currently instead? This depends a bit on the situation, but generally the answer is to replace pointers with some form of Handle. Instead of just storing an absolute pointer in a struct one would instead store the offset to some reference value. Later if the pointer is needed it's calculated on demand.
For instance, we use a pattern like this to work with memory mapped data:
use std::{marker, mem::{transmute, size_of}, slice, borrow::Cow};
#[repr(C)]
struct Slice<T> {
offset: u32,
len: u32,
phantom: marker::PhantomData<T>,
}
#[repr(C)]
struct Header {
targets: Slice<u32>,
}
pub struct Data<'a> {
bytes: Cow<'a, [u8]>,
}
impl<'a> Data<'a> {
pub fn new<B: Into<Cow<'a, [u8]>>>(bytes: B) -> Data<'a> {
Data { bytes: bytes.into() }
}
pub fn get_target(&self, idx: usize) -> u32 {
self.load_slice(&self.header().targets)[idx]
}
fn bytes(&self, start: usize, len: usize) -> *const u8 {
self.bytes[start..start + len].as_ptr()
}
fn header(&self) -> &Header {
unsafe { transmute(self.bytes(0, size_of::<Header>())) }
}
fn load_slice<T>(&self, s: &Slice<T>) -> &[T] {
let size = size_of::<T>() * s.len as usize;
let bytes = self.bytes(s.offset as usize, size);
unsafe { slice::from_raw_parts(bytes as *const T, s.len as usize) }
}
}
In this case Data<'a>
only holds a copy-on-write reference to the backing byte storage (an owned Vec<u8>
or a borrowed &[u8]
slice). The byte slice starts with the bytes from Header
and they are resolved on demand when header()
is called. Likewise a single slice is resolved similarly by the call to load_slice()
which takes a stored slice and then looks it up by offsetting on demand.
So, to recap: instead of storing a pointer to an object itself, store some information so that you can calculate the pointer later. This is also commonly called using “handles”.
Refcounts are not dirty
Another quite interesting case — that is surprisingly easy to run into — also has to do with the borrow checker. The borrow checker won’t let you do stupid things with data you do not own, which can sometimes feel like running into a wall because you think you know better. In many of those cases, however, the answer is just one Rc<T>
away.
To make this less mysterious, let's look at the following piece of C++ code:
thread_local struct {
bool debug_mode;
} current_config;
int main() {
current_config.debug_mode = true;
if (current_config.debug_mode) {
// do something
}
}
This seems pretty innocent, but it has a problem: nothing stops you from borrowing a field from current_config
and then passing it somewhere else. That’s why in Rust the direct equivalent of this looks significantly more complicated:
#[derive(Default)]
struct Config {
pub debug_mode: bool,
}
thread_local! {
static CURRENT_CONFIG: Config = Default::default();
}
fn main() {
CURRENT_CONFIG.with(|config| {
// here we can *immutably* work with config
if config.debug_mode {
// do something
}
});
}
This should make it immediately obvious that this API is not fun. First of all, the config is immutable. Second of all, we can only access the config object within the closure passed to the withfunction. Any attempt to borrow from this config object and have it outlive the closure will fail (probably with something like “cannot infer an appropriate lifetime”). There’s no way around it!
This API is objectively bad. Imagine we want to look up more of those thread local variables. So let's look at both issues separately. As hinted above, ref counting is generally a really nice solution to deal with the underlying issue here: it's unclear who the owner is.
Let's imagine for a second this config object just happens to be bound to the current thread, but is not really owned by it. What happens if the config is passed to another thread but the current thread shuts down? This is a typical example the config could have multiple owners. Since we might want to pass from one thread to another we want an atomically reference counted wrapper for our config: an Arc. This lets us increase the refcount in the with block and return it. The refactored version looks like this:
use std::sync::Arc;
#[derive(Default)]
struct Config {
pub debug_mode: bool,
}
impl Config {
pub fn current() -> Arc<Config> {
CURRENT_CONFIG.with(|c| c.clone())
}
}
thread_local! {
static CURRENT_CONFIG: Arc<Config> = Arc::new(Default::default());
}
fn main() {
let config = Config::current();
// here we can *immutably* work with config
if config.debug_mode {
// do something
}
}
The change here is that now the thread local holds a reference counted config. As such we can introduce a function that returns an Arc<Config>
. In the closure from the TLS we increment the refcount with the clone()
method on the Arc<Config>
and return it. Now any caller to Config::current gets that refcounted config and can hold on to it for as long as necessary. For as long as there is code holding the Arc, the config within it is kept alive. Even if the originating thread died.
But how do we make it mutable like in the C++ version? We need something that provides us with interior mutability. There are two options for this. One is to wrap the Config
in something like an RwLock
. The second is to have the Config
use locking internally.
For instance one might want to do this:
use std::sync::{Arc, RwLock};
#[derive(Default)]
struct ConfigInner {
debug_mode: bool,
}
struct Config {
inner: RwLock<ConfigInner>,
}
impl Config {
pub fn new() -> Arc<Config> {
Arc::new(Config { inner: RwLock::new(Default::default()) })
}
pub fn current() -> Arc<Config> {
CURRENT_CONFIG.with(|c| c.clone())
}
pub fn debug_mode(&self) -> bool {
self.inner.read().unwrap().debug_mode
}
pub fn set_debug_mode(&self, value: bool) {
self.inner.write().unwrap().debug_mode = value;
}
}
thread_local! {
static CURRENT_CONFIG: Arc<Config> = Config::new();
}
fn main() {
let config = Config::current();
config.set_debug_mode(true);
if config.debug_mode() {
// do something
}
}
If you do not need this type to work with threads you can also replace Arc
with Rc
and RwLock
with RefCell
.
To recap: when you need to borrow data that outlives the lifetime of something you need refcounting. Don't be afraid of using Arc
but be aware that this locks you to immutable data. Combine with interior mutability (like RwLock
) to make the object mutable.
Kill all setters
But the above pattern of effectively having Arc<RwLock<Config>>
can be a bit problematic and swapping it for RwLock<Arc<Config>>
can be significantly better.
Rust done well is a liberating experience because, if you do it well, it's shockingly easy to parallelize your code after the fact. Rust encourages immutable data and that makes everything so much easier.
However, in the previous example we introduced interior mutability. Imagine we have multiple threads running, all referencing the same config, but one flips a flag. What happens to concurrently running code that now is not expecting the flag to randomly flip? For that reason, interior mutability should be used carefully. Ideally an object once created does not change its state in such a way. I think this type of setter this should generally be an anti-pattern.
So instead of doing this, what about we take a step back to where we were earlier where configs were not mutable? What if we never mutate the config after we create it but instead we add an API to promote another config to current. This means anyone who is currently holding on to a config can safely know that the values won't change.
use std::sync::{Arc, RwLock};
#[derive(Default)]
struct Config {
pub debug_mode: bool,
}
impl Config {
pub fn current() -> Arc<Config> {
CURRENT_CONFIG.with(|c| c.read().unwrap().clone())
}
pub fn make_current(self) {
CURRENT_CONFIG.with(|c| *c.write().unwrap() = Arc::new(self))
}
}
thread_local! {
static CURRENT_CONFIG: RwLock<Arc<Config>> = RwLock::new(Default::default());
}
fn main() {
Config { debug_mode: true }.make_current();
if Config::current().debug_mode {
// do something
}
}
Now configs are still initialized automatically by default, but a new config can be set by constructing a Config
object and calling make_current
. That will move the config into an Arc
and then bind it to the current thread. Callers to current()
will get that Arc
back and can then again do whatever they want.
Likewise, you can again switch Arc
for Rc
and RwLock
for RefCell
if you do not need this to work with threads. If you are just working with thread locals you can also combine RefCell
with Arc
.
To recap: instead of using interior mutability where an object changes its internal state, consider using a pattern where you promote new state to be current and current consumers of the old state will continue to hold on to it by putting an Arc
into an RwLock
.
In conclusion
Honestly I wish I would have learned the above three things earlier than I did. Mostly because even if you know the patterns you might not necessarily know when to use them. So I guess the following mantra is now what I want to print out and hang somewhere:
Handles, not self referential pointers
Reference count your way out of lifetime / borrow checker hell
Consider promoting new state instead of interior mutability