written on Saturday, March 31, 2018
The last year has been fun because I could build a lot for really nice stuff for Sentry in Rust and for the first time the development experience was without bigger roadblocks. While we have been using Rust before it now feels different because the ecosystem is so much more stable and we ran less against language or tooling issues.
However talking to people 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 and that 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 putting your mind back back on the right track.
So here are things not to do in Rust and what to do instead which I think should be better known.
The biggest difference between Rust and C++ for me is the address-of operator (&). In C++ (like C) that thing just returns the address of whatever its applied to and while the language might put some restrictions on you when doing so is a good idea, there is generally nothing stopping you from taking an address of a value and then using it.
In Rust this is just 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 for this is that objects in Rust generally move around.
Just 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 this 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 one of the most fundamental ones that has huge consequences for Rust programmers. In particular it is one of the reasons you cannot have self referential structs. While there is talk about expressing types that cannot be moved in Rust there is 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. So 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.
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”.
Another quite interesting case that is surprisingly easy to run into also has to do with the borrow checker. The borrow checker doesn't let you do stupid things with data you do not own and sometimes that can feel like running into a wall because you think you know better. In many of those cases the answer is just one Rc<T> away however.
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. This is why in Rust the direct equivalent of that 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. Secondly we can only access the config object within the closure passed to the with function. Any attempt of trying to borrow from this config object and have it outlive the closure will fail (probably with something like “cannot infer an appropriate lifetime”). There is no way around it!
This API is clearly objectively bad. Imagine we want to look up more of those thread local variables. So let's look at both of those 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 the current thread. What happens if the config is passed to another thread but the current thread shuts down? This is a typical example where one can think of logically the config having 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<Config>. 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.
So 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 one 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.
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 programmed 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 just 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? Because of that interior mutability should be used carefully. Ideally an object once created does not change its state in such a way. In general I think such a type of setter should 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 created it but 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.
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`.
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: