Skip to content

← Smart Pointers and Interior Mutability step 15 of 26

Hard End-to-End

`Rc<RefCell<T>>`: the workhorse, and when it is a smell

Build the same event bus twice — once with shared ownership, once with an arena — and get identical answers from both.

pub fn event_bus(subscribers: Vec<String>, events: Vec<String>) -> Vec<(String, Vec<String>)>
pub fn event_bus_arena(subscribers: Vec<String>, events: Vec<String>) -> Vec<(String, Vec<String>)>

Each subscriber name gets one inbox. A name listed twice registers a second route to that same inbox — so a matching event lands in it twice. That duplication is deliberate: it is what proves the two registrations share one inbox rather than owning separate ones.

An event is "<topic> <text>", split at the first space; an event with no space has an empty text. A topic of * matches everyone. A topic matching no subscriber is dropped.

Return, sorted by subscriber name, one (name, messages) pair per distinct name, messages in delivery order.

Version one: Rc<RefCell<Vec<String>>>

This is the pattern you will meet in real Rust more than any other in this track, and it composes two things you already know:

  • Rc<T> — several owners of one value, but only &T out of it;
  • RefCell<T> — mutation through &T, checked at runtime.

Together: shared ownership of one mutable cell. The dispatcher and the subscriber both own the inbox; either can write to it; it lives exactly as long as the last of them.

The nesting order matters and is not arbitrary. Rc<RefCell<T>> is “several owners of one mutable thing”. RefCell<Rc<T>> is “one mutable slot that currently holds a shared handle” — occasionally what you want (a re-pointable link), almost never what you meant.

Two neighbours in the same family, for completeness: Arc<RefCell<T>> is what people write when they first need threads, and it is wrong — RefCell is Send but never Sync, so the Arc buys nothing. Clippy’s default-on arc_with_non_send_sync catches it at the construction site, which is exactly where the starter fails:

error: usage of an `Arc` that is not `Send` and `Sync`
  = note: `Arc<RefCell<Vec<String>>>` is not `Send` and `Sync`
          as `RefCell<Vec<String>>` is not `Sync`
  = help: if the `Arc` will not be used across threads replace it with an `Rc`
  = help: otherwise … consider a wrapper type such as `Mutex`

And Rc<Mutex<T>> is the mirror-image nonsense: an atomic lock guarding data that a non-atomic refcount already proves is single-threaded. Clippy has rc_mutex for it, allow-by-default.

Version two: the arena

Now write it again with Rc and RefCell banned. Keep the inboxes in one Vec<Vec<String>>, keep the names in a parallel Vec<String>, and make a route a plain usize. Two registrations of the same name push the same index twice.

Compare the two when you are done. The arena version is shorter, has no runtime borrow checks, cannot panic, cannot leak a cycle, is Send, and its inboxes are contiguous in memory. The Rc<RefCell<_>> version has one thing the arena does not: the inbox handle can be carried away by a subscriber that has no idea the bus exists.

That is the whole trade, and it is worth being able to state it in one sentence.

When Rc<RefCell<T>> is right, and when it is a crutch

This is a live disagreement in the Rust community, not settled doctrine, so here is the honest version of both sides.

Legitimate: observer and callback graphs where handlers outlive the dispatch loop; GUI widget trees where a child needs a handle to something it does not own; interpreter environments with genuine multi-owner scoping; caches with real shared ownership. In all of these the sharing is not a workaround — it is the design.

A smell: using it to dodge the borrow checker; building linked lists and general graphs where Vec<Node> plus usize handles is simpler, faster, cycle-safe and Send; reaching for it because a function signature was awkward.

The decision criterion that actually works:

RefCell panics are a runtime failure mode you are choosing to accept. Accept it when the borrow pattern is genuinely dynamic. Refuse it when it is standing in for thinking about ownership.

And remember that every .borrow_mut() composes badly: two independent modules, each correct in isolation, can borrow the same cell in a combination neither author tested, and the failure appears only on that path.

Two hazards worth naming

Type complexity. Rc<RefCell<Vec<Rc<RefCell<Node>>>>> is a type people genuinely write, and clippy’s default-on type_complexity will tell you so. A type alias is the minimum fix. A redesign is usually the real one — this problem uses type Inbox = Rc<RefCell<Vec<String>>>; for exactly that reason.

Never #[derive(Debug)] on a cyclic Rc<RefCell<Node>>. The derived Debug follows every Rc it can reach, and a cycle makes it recurse forever — unbounded output, no error, no stack trace, just a process that never comes back. When you want to inspect shared structure, print Rc::strong_count, not {:?}.

Loading visualization…