Skip to content

S03: Rc<RefCell<T>> object graph (Java translated directly)

S03Severity: Medium

A Java object graph copied with shared, mutable, reference-counted cells.

Clippy: no lint finds this. Look for it in review.

Each order holds a shared, changeable pointer to its customer, as a Java Order holds a Customer reference:

pub struct Customer {
pub name: String,
pub points: u32,
}
pub struct Order {
pub id: u64,
pub total_cents: u64,
pub customer: Rc<RefCell<Customer>>, // shared and changeable, like a Java reference
}
pub fn complete(order: &Order) {
let mut customer = order.customer.borrow_mut(); // checked at run time
customer.points += u32::try_from(order.total_cents / 100).unwrap_or(u32::MAX);
}
pub fn complete_all(orders: &[Order]) {
for order in orders {
complete(order);
}
}
  • It is the shape of the Java code. In Java, many objects point to one Customer, and any of them can change it. Rc<RefCell<Customer>> is the only Rust type that allows exactly that, so a direct translation arrives there.
  • It makes the borrow checker stop. When an agent gets ownership or borrow errors in a graph of objects, wrapping each object in Rc<RefCell<T>> makes every error go away. The code compiles, and the agent moves on.
  • Tutorials show it. The Rust book uses Rc<RefCell<T>> to explain interior mutability, so the pattern is common in training data.
  • Borrow errors become panics. RefCell checks the borrow rules at run time. A conflict that the compiler would report is now a crash in production. In this example, a log line that holds a borrow() while complete calls borrow_mut() panics with “already borrowed”:
pub fn complete_and_log(order: &Order) -> String {
let customer = order.customer.borrow(); // a shared borrow, for the log line...
complete(order); // ...and `complete` calls `borrow_mut`: panic "already borrowed"
format!("{} now has {} points", customer.name, customer.points)
}

The test worse_panics_at_run_time proves it. The conflict is hidden inside a function call, so a reviewer must read every function that the code calls while a borrow is alive.

  • Changes are invisible in signatures. complete(order: &Order) takes a shared reference, and it changes the customer. Rust readers expect & to mean “read only”.
  • Cycles leak. If the customer also holds Rcs to its orders, neither is ever freed. See S35: Rc cycles (memory leak).
  • It is slower. Each object is a separate heap allocation with a reference count and a borrow flag, and every access checks the flag.
  1. WorseEach order shares the customer through Rc<RefCell<_>>. A &Order can change the customer.

    pub struct Customer {
    pub name: String,
    pub points: u32,
    }
    pub struct Order {
    pub id: u64,
    pub total_cents: u64,
    pub customer: Rc<RefCell<Customer>>, // shared and changeable, like a Java reference
    }
    pub fn complete(order: &Order) {
    let mut customer = order.customer.borrow_mut(); // checked at run time
    customer.points += u32::try_from(order.total_cents / 100).unwrap_or(u32::MAX);
    }
    pub fn complete_all(orders: &[Order]) {
    for order in orders {
    complete(order);
    }
    }
  2. BetterThe Shop owns the customers in a map. Orders store a CustomerId. complete takes &mut self.

    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
    pub struct CustomerId(pub u64);
    pub struct Customer {
    pub name: String,
    pub points: u32,
    }
    pub struct Order {
    pub id: u64,
    pub total_cents: u64,
    pub customer: CustomerId, // a key, not a pointer
    }
    #[derive(Default)]
    pub struct Shop {
    pub customers: HashMap<CustomerId, Customer>, // the shop owns the customers
    }
    impl Shop {
    pub fn complete(&mut self, order: &Order) {
    if let Some(customer) = self.customers.get_mut(&order.customer) {
    customer.points += u32::try_from(order.total_cents / 100).unwrap_or(u32::MAX);
    }
    }
    pub fn summary(&self, id: CustomerId) -> Option<String> {
    let c = self.customers.get(&id)?;
    Some(format!("{} now has {} points", c.name, c.points))
    }
    }

The Shop is the one owner. complete(&mut self, ...) says in its signature that it changes data, and the compiler checks every borrow. A missing customer is an Option, not a dangling pointer. The gallery entry A linked or tree structure shows the same idea with an arena and index IDs.

  • Shared, mutable state in one thread that has no natural owner: for example, UI widgets that several event handlers change, or a mock in a test that records calls.
  • Small, local use, where the Rc<RefCell<T>> does not leave one function or module, and no borrow is held across a call.
  • Even then, prefer Cell<T> for small Copy values, and keep each borrow_mut() short.
  • No Clippy lint reports this pattern.
  • Search for Rc<RefCell< in struct fields. One or two can be fine. Many, in a domain model, is the smell.
  • Look for borrow_mut() in functions that take &self or &T.
  • Look for a borrow() or borrow_mut() result stored in a variable, followed by a function call.
Remove the Rc<RefCell<...>> fields from the domain types. Give each kind of
data one owner (a struct that holds a HashMap or Vec), and replace the
pointers with ID newtypes such as CustomerId(u64). Functions that change data
take &mut of the owner. Keep Rc<RefCell<...>> only where there is no single
owner, and explain why in a comment. Run the tests after each step.

Related: S35: Rc cycles (memory leak), S37: Primitive types where a newtype gives safety (u64 for IDs), and Smart pointers.