Mutating observers in Rust
In this article we’ll explore some variations of the observer pattern in Rust.
We’ll see an example of the language steering us away from a potentially risky design
while at the same time giving us the flexibility to go ahead with it as long as we’re explicit about what we’re doing.
Although we’ll be exploring shared mutability, none of this is unsafe
code.
The Observer Pattern
The observer pattern is basically when we ask someone else to tell us when something interesting happens. Usually, that means “invoke one of my member functions”. This is easily expressible in a lot of languages, but there are ways to get it wrong and the Rust compiler catches some of them.
Timmy Jose wrote an anticle on The Observer Pattern in Rust, which was a helpful starting point for me. I’d recommend reading that first.
We’ll be using the following Observer
trait,
which is similar to the one Timmy defined.
Notice that it takes an immutable reference to self
.
pub trait Observer<T> {
fn notify(&self, value: &T);
}
Instead of using an Observable
trait, I’m going to simplify things by making it a struct,
since we’ll only be using one concrete implementation:
pub struct Counter {
value: usize,
observers: Vec<Box<Observer<usize>>>,
}
impl Counter {
pub fn new() -> Counter {
Counter { value: 0, observers: vec![] }
}
pub fn register(&mut self, observer: Box<Observer<usize>>) {
self.observers.push(observer);
}
pub fn run(&mut self) {
loop {
self.value += 1;
for observer in self.observers.iter() {
observer.notify(&self.value);
}
}
}
}
We’re using Box
here because the Counter
doesn’t know the concrete types of its observers.
They could be anything, as long as they implement the Observer
trait.
Mutability
Suppose we want our observers to be mutable.
This is easy!
We just change our trait’s notify
function to take &mut self
:
pub trait Observer<T> {
fn notify(&mut self, value: &T);
}
We also modify our counter’s run
function to use iter_mut
instead of iter
:
impl Counter {
// ...
pub fn run(&mut self) {
loop {
self.value += 1;
for observer in self.observers.iter_mut() {
observer.notify(&self.value); // Changed this line
}
}
}
}
This didn’t require us to change our Counter
struct.
Since it owns its observers, it’s free to mutate them as it pleases.
Other than being explicit about which iterator we use, there’s no difference.
Non-Ownership
In the example above, the Counter
owns its observers.
This is probably the simplest situation when it comes to memory safety.
The observers will live as long as the Counter
, and no longer.
Suppose we don’t want our observers to be owned by the Counter
.
Rather than using Box
, we’ll have to use plain references.
We’ll leave mutability aside for now, and use the immutable Observer
from our first example.
pub struct Counter<'a> {
value: usize,
observers: Vec<&'a Observer<usize>>,
}
impl<'a> Counter<'a> {
// ...
pub fn register(&mut self, observer: &'a Observer<usize>) {
self.observers.push(observer);
}
}
The lifetime specifier is necessary to tell the compiler that the elements of our Vec
will live at least as long as 'a
.
This is something we must be careful about in other languages.
If our observers didn’t live as long as our counter, then we’d end up with dangling pointers.
This can be a problem even in garbage collected languages.1
In Rust, our program will only compile if we initialize the observer before the counter,
so that it gets destroyed after the counter:
struct Foo;
impl Observer<usize> for Foo {
fn notify(&self, value: &usize) {}
}
fn main() {
let observer = Foo;
let mut counter = Counter::new();
counter.register(&observer);
counter.run();
}
If we declared the observer and counter in the opposite order,
then the the compiler will stop us because the counter is still holding a reference to the observer when the observer goes out of scope.
At the risk of going too far on a tangent, this is a place where one could use reference counted pointers like Rc
.
Since this example uses non-mutable references, we can safely register our observer with as many counters as we want:
fn main() {
let observer = Foo;
let mut counter_1 = Counter::new();
let mut counter_2 = Counter::new();
let mut counter_3 = Counter::new();
counter_1.register(&observer);
counter_2.register(&observer);
counter_3.register(&observer);
counter_1.run();
counter_2.run();
counter_3.run();
}
Mutability and Non-Ownership
So far, we’ve taken our initial example in two different directions. In one case, we allowed our observers to mutate their state when notified. In the other case, we moved the owneship of the observers outside of the counter, so that they could be shared by multiple counters. Can we do both?
Take a moment to consider what we’re asking for: multiple mutable references to the same data.
Isn’t that exactly what Rust forbids us from having?
Well, yes, but there’s always an escape hatch.
In this case, it’s called RefCell
.
RefCell
moves the borrow checking from compile-time to runtime.
It provides interior mutability - while the compiler treats the RefCell
itself as immutable,
it can be used to obtain both immutable and mutable references using functions
such as borrow
and borrow_mut
.
In this way, it’s similar to a read-write lock, but single threaded.
Be careful!
Rather than getting a compiler error when we make a mistake, those functions will panic instead.
There are related functions, try_borrow
and try_borrow_mut
, which return Result
s instead of panicking.
In any case, we’ll know as soon as something goes wrong and we’ll get a clear error message,
rather than some subtle undefined behaviour due to memory corruption.
Note, however, that this comes with the runtime cost of a reference count.
Modifying our Counter
to use RefCell
is fairly straightforward:
pub struct Counter<'a> {
value: usize,
observers: Vec<&'a RefCell<Observer<usize>>>,
}
impl<'a> Counter<'a> {
// ...
pub fn run(&mut self) {
loop {
self.value += 1;
for observer in self.observers.iter() {
observer.borrow_mut().notify(&self.value); // Changed this line
}
}
}
}
Now we can register an observer with multiple sources,
and still mutate it when its notify
callback is invoked.
Do we really want this?
RefCell
should not be the first tool we reach for.
Although we haven’t sacrificed memory safety
(trying to concurrently access the same memory will cause a panic, not undefined behaviour),
it moves those safety guarantees from compile time to runtime.
If we’re thinking of using RefCell
, we should probably consider alternative designs as well.
Consider some form of message passing;
perhaps the standard library’s thread safe queue would be useful.
That said, there will be times when it is the tool we need. That’s why it’s in the standard library in the first place. In my case, I have a C library that I’m interfacing with. The library allows us to register callbacks for various events. While I know that the callbacks will be invoked in a thread safe way, the Rust compiler can’t prove that. This is a case where it’s okay to have multiple mutable references to the same memory. We just have to be sure that they won’t be used incorrectly.
-
Dangling pointers in a garbage collected language aren’t a memory safety problem, but they can certainly be a correctness problem. For example, consider a game in which you hold a reference to a
GameObject
. If the object is removed from the game world then your reference will still point to valid memory. However, you might try to do some collision detection and find that the object’sMeshComponent
is no longer valid. ↩