S03: Rc<RefCell<T>> object graph (Java translated directly)
What it looks like
Section titled “What it looks like”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); }}Why AI agents produce it
Section titled “Why AI agents produce it”- 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.
Why it is a problem
Section titled “Why it is a problem”- Borrow errors become panics.
RefCellchecks 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 aborrow()whilecompletecallsborrow_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:Rccycles (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.
The better version
Section titled “The better version”WorseEach order shares the customer through
Rc<RefCell<_>>. A&Ordercan 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 timecustomer.points += u32::try_from(order.total_cents / 100).unwrap_or(u32::MAX);}pub fn complete_all(orders: &[Order]) {for order in orders {complete(order);}}BetterThe
Shopowns the customers in a map. Orders store aCustomerId.completetakes&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.
When it is acceptable
Section titled “When it is acceptable”- 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 smallCopyvalues, and keep eachborrow_mut()short.
How to detect it
Section titled “How to detect it”- 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&selfor&T. - Look for a
borrow()orborrow_mut()result stored in a variable, followed by a function call.
What to tell your agent
Section titled “What to tell your agent”Remove the Rc<RefCell<...>> fields from the domain types. Give each kind ofdata one owner (a struct that holds a HashMap or Vec), and replace thepointers with ID newtypes such as CustomerId(u64). Functions that change datatake &mut of the owner. Keep Rc<RefCell<...>> only where there is no singleowner, 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.