The problem with shared callbacks
You are building a dashboard. A sensor reports a temperature spike. Three widgets need to react: a gauge updates its needle, a log appends a line, and an alarm checks if the threshold is breached. In Python or JavaScript, you would attach callbacks to the sensor and move on. In Rust, the borrow checker stops you. The sensor needs to hold references to the widgets, but the widgets might outlive the sensor, or multiple sensors might share the same widget. You cannot hand out mutable references freely, and you cannot move values into the sensor if the widgets still need to exist elsewhere.
You need a way for one component to announce a change and have many components hear it, without violating Rust's ownership rules. The Observer pattern solves this by decoupling the subject from its observers using shared ownership and dynamic dispatch.
Ownership meets subscription
The Observer pattern is a subscription service. The subject is the publisher. Observers are subscribers. When an event occurs, the publisher notifies every subscriber. The publisher does not care about the internal structure of the subscribers; it only cares that they implement a specific interface.
Rust adds two constraints. First, every value must have a clear owner. If the subject holds the observers, the observers cannot be dropped while the subject exists. If the observers also hold references back to the subject, you create a cycle and memory leaks. Second, Rust forbids shared mutable state by default. The subject needs to mutate its list of observers, but callers might hold shared references to the subject.
The standard solution combines three tools. Rc<T> provides shared ownership so multiple places can hold the same observer. RefCell<T> provides interior mutability so the subject can modify its observer list even behind a shared reference. dyn Trait provides dynamic dispatch so the subject can store observers of different concrete types in a single list.
Minimal implementation
Here is a working observer system. The subject holds a vector of trait objects wrapped in Rc and RefCell. Observers implement a trait. The subject notifies them by iterating the list.
use std::cell::RefCell;
use std::rc::Rc;
/// Observers must implement this trait to receive updates.
/// The trait is object-safe because it only takes &self and has no generic methods.
trait Observer {
fn update(&self, message: &str);
}
/// The subject manages a list of observers.
/// RefCell allows interior mutability so we can modify the list
/// even when the Subject is accessed via a shared reference.
struct Subject {
observers: RefCell<Vec<Rc<dyn Observer>>>,
}
impl Subject {
fn new() -> Self {
Subject {
observers: RefCell::new(Vec::new()),
}
}
/// Attach an observer to the subject.
/// We take an Rc to share ownership. The subject keeps a clone.
fn attach(&self, observer: Rc<dyn Observer>) {
// borrow_mut checks the runtime borrow counter.
// It panics if the list is currently borrowed immutably.
self.observers.borrow_mut().push(observer);
}
/// Notify all observers of an event.
fn notify(&self, message: &str) {
// Borrow the list immutably to iterate.
// This borrow must end before we return, or subsequent
// mutable borrows will panic.
let observers = self.observers.borrow();
for observer in observers.iter() {
observer.update(message);
}
}
}
/// A concrete observer that prints messages.
struct ConcreteObserver {
name: String,
}
impl Observer for ConcreteObserver {
fn update(&self, message: &str) {
println!("{} received: {}", self.name, message);
}
}
fn main() {
let subject = Subject::new();
// Create observers with shared ownership.
let obs1 = Rc::new(ConcreteObserver { name: "Gauge".into() });
let obs2 = Rc::new(ConcreteObserver { name: "Alarm".into() });
// Convention: use Rc::clone explicitly to signal shallow cloning.
// obs1.clone() compiles but looks like a deep copy to readers.
subject.attach(Rc::clone(&obs1));
subject.attach(Rc::clone(&obs2));
subject.notify("Temperature high");
}
How the pieces fit together
Rc::new allocates the observer on the heap and initializes a reference count to one. When you call Rc::clone, the count increments. The subject holds one reference, and the variable in main holds another. When either goes out of scope, the count decrements. The value drops only when the count reaches zero.
RefCell tracks borrows at runtime. borrow_mut increments a mutable borrow counter. If a mutable borrow is active, borrow fails with a panic. This enforces the same rules as the borrow checker, but at runtime instead of compile time. The trade-off is safety: a logic error that causes overlapping borrows will crash the program rather than being caught during compilation.
dyn Observer erases the concrete type. The vector stores pointers to a vtable containing the function pointers for update. When notify calls observer.update, the call goes through the vtable. This allows Gauge and Alarm to coexist in the same list. The cost is a small indirection on every call.
Keep RefCell borrows as short as possible. Holding a borrow across a function call or a loop boundary increases the risk of panics. The compiler cannot help you here; you must manage the scope manually.
Handling removal and real-world complexity
A realistic observer system supports removal. Observers should be able to unsubscribe. Removing an item from a vector while iterating it is dangerous. If you remove by value, you must search the list, which requires a mutable borrow, but you might be iterating with an immutable borrow.
The standard pattern is to use indices and mark slots as empty. This avoids reallocation and borrowing conflicts.
use std::cell::RefCell;
use std::rc::Rc;
trait Observer {
fn update(&self, message: &str);
}
struct Subject {
// Option allows marking slots as empty without removing them.
// This prevents index invalidation and borrowing issues.
observers: RefCell<Vec<Option<Rc<dyn Observer>>>>,
}
impl Subject {
fn new() -> Self {
Subject {
observers: RefCell::new(Vec::new()),
}
}
/// Attach returns an index that can be used to detach later.
fn attach(&self, observer: Rc<dyn Observer>) -> usize {
let mut list = self.observers.borrow_mut();
list.push(Some(observer));
list.len() - 1
}
/// Detach marks the slot as empty.
/// The Rc reference count drops, potentially freeing the observer.
fn detach(&self, index: usize) {
let mut list = self.observers.borrow_mut();
if index < list.len() {
list[index] = None;
}
}
fn notify(&self, message: &str) {
let list = self.observers.borrow();
// flatten skips the None values.
for observer in list.iter().flatten() {
observer.update(message);
}
}
}
fn main() {
let subject = Subject::new();
let obs = Rc::new(ConcreteObserver { name: "Log".into() });
let id = subject.attach(Rc::clone(&obs));
subject.notify("Starting");
subject.detach(id);
subject.notify("Stopping"); // Log does not receive this.
}
struct ConcreteObserver {
name: String,
}
impl Observer for ConcreteObserver {
fn update(&self, message: &str) {
println!("{}: {}", self.name, message);
}
}
The index-based approach is robust. Detaching is O(1). Notifying skips empty slots. The vector may grow over time if you attach and detach frequently. If memory pressure becomes an issue, compact the vector periodically by removing None entries, but only when no borrows are active.
Pitfalls and compiler errors
Observer implementations in Rust hit specific traps. Recognizing them saves debugging time.
RefCell panics. If you call attach while notify is running, borrow_mut panics because the list is already borrowed immutably. The panic message reads "already borrowed: BorrowMutError". This happens when an observer tries to modify the subject during notification. Restructure the code to avoid recursive borrows. Collect the messages first, drop the borrow, then dispatch.
Object safety violations. The Observer trait must be object-safe. If you add a method that returns Self or uses generic parameters, the compiler rejects the trait with an error like "the trait Observer cannot be made into an object". Fix this by removing non-object-safe methods or using a wrapper type.
trait BadObserver {
// This method returns Self, which is unknown for dyn BadObserver.
fn clone_self(&self) -> Self;
}
// Error: the trait BadObserver cannot be made into an object
Memory leaks from cycles. If an observer holds an Rc back to the subject, and the subject holds an Rc to the observer, the reference count never reaches zero. The memory leaks. Use Weak references to break cycles. The subject holds Rc, the observer holds Weak. When the subject drops, the Weak upgrade fails, and the observer knows the subject is gone.
Thread safety. Rc is not Send. If you try to move a Subject containing Rc observers to another thread, the compiler rejects it with E0277 (trait bound not satisfied). Rc uses non-atomic reference counting. Replace Rc with Arc and RefCell with Mutex for multi-threaded scenarios.
Choosing your synchronization strategy
The Observer pattern has many variants in Rust. Pick the one that matches your concurrency model and performance needs.
Use Rc<RefCell<Vec<Rc<dyn T>>>> for single-threaded event systems where you need shared ownership and runtime mutation. This is the standard choice for UI frameworks and game loops running on one thread.
Use Arc<Mutex<Vec<Arc<dyn T>>>> when the subject must be shared across threads and observers might be added or removed concurrently. The mutex serializes access, adding overhead but providing safety.
Use std::sync::mpsc channels when observers are independent workers and you do not need the subject to hold references to them. Channels decouple completely and scale better for high-throughput scenarios.
Use tokio::sync::broadcast for async applications where many tasks listen to a single stream of events. The broadcast channel handles backpressure and async notification automatically.
Use a Vec<Box<dyn Fn(&Event)>> closure list when you do not need named observer types and just want to attach arbitrary callbacks. Closures capture their environment and simplify the API, though they lack the explicit lifecycle management of trait objects.
Treat the RefCell borrow scope as a critical section. If you cannot guarantee non-overlapping borrows, reach for Mutex or channels.