RefCell<T> — Runtime Borrow Checking
Tutorial Video
Text description (accessibility)
This video demonstrates the "RefCell<T> — Runtime Borrow Checking" functional Rust example. Difficulty level: Advanced. Key concepts covered: Ownership, Borrowing, Interior Mutability. Enable mutation through a shared (`&self`) reference by deferring Rust's borrow rules from compile time to runtime, using `RefCell<T>` to enforce "one writer XOR many readers" dynamically. Key difference from OCaml: 1. **Borrow enforcement:** OCaml enforces nothing; Rust enforces "one writer XOR multiple readers" — just at runtime instead of compile time with `RefCell`.
Tutorial
The Problem
Enable mutation through a shared (&self) reference by deferring Rust's borrow rules from compile time to runtime, using RefCell<T> to enforce "one writer XOR many readers" dynamically.
🎯 Learning Outcomes
borrow() / borrow_mut() to obtain guarded references at runtime&self mutation (no &mut self needed) via RefCelltry_borrow() / try_borrow_mut() for fallible, panic-free access🦀 The Rust Way
RefCell<T> wraps a value and hands out Ref<T> (shared) or RefMut<T> (exclusive) guard objects. The counts are tracked at runtime; any attempt to hold a mutable borrow alongside any other borrow causes an immediate panic. Sequential borrows — where each guard is dropped before the next is acquired — are always safe.
Code Example
use std::cell::RefCell;
pub fn collect_items() -> Vec<String> {
let items: RefCell<Vec<String>> = RefCell::new(Vec::new());
items.borrow_mut().push("first".to_string());
items.borrow_mut().push("second".to_string());
items.borrow_mut().push("third".to_string());
let borrowed = items.borrow();
borrowed.clone()
}Key Differences
RefCell.mutable field methods implicitly allow mutation; Rust requires &mut self unless RefCell provides interior mutability, enabling &self methods.try_borrow() / try_borrow_mut() let library code handle contention gracefully rather than panicking.OCaml Approach
OCaml has no borrow rules — a ref value or mutable record field can be read and written freely at any time. The programmer bears full responsibility for correctness. This makes code concise but removes the compile-time safety net that Rust provides.
Full Source
#![allow(clippy::all)]
// Example 111: RefCell<T> — Runtime Borrow Checking
//
// RefCell<T> enforces Rust's borrowing rules at runtime instead of compile time,
// enabling interior mutability for non-Copy types.
//
// Rule: either multiple Ref<T> OR one RefMut<T> — never both.
// Violation = panic at runtime (same rule as borrow checker, just deferred).
use std::cell::RefCell;
// ---------------------------------------------------------------------------
// Approach 1: Interior mutability — mutate through a shared reference
//
// The `items` binding is immutable, but the Vec inside can be mutated.
// Useful when you need to share a collector across callbacks or closures
// without making the binding itself `mut`.
// ---------------------------------------------------------------------------
pub fn collect_items() -> Vec<String> {
// Immutable binding — the RefCell *is* the mutability
let items: RefCell<Vec<String>> = RefCell::new(Vec::new());
// Each borrow_mut() returns a RefMut guard; it releases when dropped
items.borrow_mut().push("first".to_string());
items.borrow_mut().push("second".to_string());
items.borrow_mut().push("third".to_string());
// borrow() returns a shared Ref guard; bind before returning to avoid lifetime issue
let borrowed = items.borrow();
borrowed.clone()
}
// ---------------------------------------------------------------------------
// Approach 2: Shared mutable stack
//
// Stack<T> stores its data in a RefCell so push/pop can take &self
// instead of &mut self — multiple owners can share one Stack via Rc<Stack<T>>.
// ---------------------------------------------------------------------------
pub struct Stack<T> {
data: RefCell<Vec<T>>,
}
impl<T> Stack<T> {
pub fn new() -> Self {
Stack {
data: RefCell::new(Vec::new()),
}
}
pub fn push(&self, value: T) {
self.data.borrow_mut().push(value);
}
pub fn pop(&self) -> Option<T> {
self.data.borrow_mut().pop()
}
pub fn peek(&self) -> Option<T>
where
T: Clone,
{
self.data.borrow().last().cloned()
}
pub fn len(&self) -> usize {
self.data.borrow().len()
}
pub fn is_empty(&self) -> bool {
self.data.borrow().is_empty()
}
}
impl<T> Default for Stack<T> {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Approach 3: Shared observer — multiple immutable handles, one mutable log
//
// Demonstrates why RefCell is indispensable: an observer that records events
// while appearing immutable to the subjects it watches.
// ---------------------------------------------------------------------------
pub struct EventLog {
events: RefCell<Vec<String>>,
}
impl EventLog {
pub fn new() -> Self {
EventLog {
events: RefCell::new(Vec::new()),
}
}
// Takes &self — caller sees an immutable observer
pub fn record(&self, event: &str) {
self.events.borrow_mut().push(event.to_string());
}
pub fn entries(&self) -> Vec<String> {
self.events.borrow().clone()
}
pub fn count(&self) -> usize {
self.events.borrow().len()
}
}
impl Default for EventLog {
fn default() -> Self {
Self::new()
}
}
// ---------------------------------------------------------------------------
// Helper: demonstrate try_borrow to avoid panics
// ---------------------------------------------------------------------------
pub fn try_borrow_example() -> Result<usize, String> {
let cell: RefCell<Vec<i32>> = RefCell::new(vec![1, 2, 3]);
// Exclusive borrow held across this scope
let _writer = cell.borrow_mut();
// try_borrow returns Err rather than panicking
// Bind to local to avoid borrow-outlive-local issue
let result = match cell.try_borrow() {
Ok(r) => Ok(r.len()),
Err(e) => Err(format!("borrow failed: {e}")),
};
result
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_collect_items_order() {
let items = collect_items();
assert_eq!(items, vec!["first", "second", "third"]);
}
#[test]
fn test_collect_items_length() {
let items = collect_items();
assert_eq!(items.len(), 3);
}
#[test]
fn test_stack_push_pop() {
let s: Stack<i32> = Stack::new();
assert!(s.is_empty());
s.push(1);
s.push(2);
s.push(3);
assert_eq!(s.len(), 3);
assert_eq!(s.pop(), Some(3));
assert_eq!(s.pop(), Some(2));
assert_eq!(s.len(), 1);
}
#[test]
fn test_stack_peek_does_not_remove() {
let s: Stack<&str> = Stack::new();
s.push("hello");
s.push("world");
assert_eq!(s.peek(), Some("world"));
assert_eq!(s.len(), 2);
}
#[test]
fn test_stack_empty_pop() {
let s: Stack<u8> = Stack::new();
assert_eq!(s.pop(), None);
assert_eq!(s.peek(), None);
}
#[test]
fn test_event_log_records_in_order() {
let log = EventLog::new();
log.record("connect");
log.record("query");
log.record("disconnect");
assert_eq!(log.count(), 3);
assert_eq!(log.entries(), vec!["connect", "query", "disconnect"]);
}
#[test]
fn test_event_log_immutable_receiver() {
let log = EventLog::new();
let log_ref: &EventLog = &log;
log_ref.record("event-a");
log_ref.record("event-b");
assert_eq!(log.count(), 2);
}
#[test]
fn test_try_borrow_returns_err_when_mutably_borrowed() {
let result = try_borrow_example();
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(msg.contains("borrow failed"));
}
#[test]
fn test_multiple_shared_borrows_allowed() {
let cell: RefCell<Vec<i32>> = RefCell::new(vec![10, 20, 30]);
let r1 = cell.borrow();
let r2 = cell.borrow();
assert_eq!(r1.len(), r2.len());
assert_eq!(*r1, *r2);
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_collect_items_order() {
let items = collect_items();
assert_eq!(items, vec!["first", "second", "third"]);
}
#[test]
fn test_collect_items_length() {
let items = collect_items();
assert_eq!(items.len(), 3);
}
#[test]
fn test_stack_push_pop() {
let s: Stack<i32> = Stack::new();
assert!(s.is_empty());
s.push(1);
s.push(2);
s.push(3);
assert_eq!(s.len(), 3);
assert_eq!(s.pop(), Some(3));
assert_eq!(s.pop(), Some(2));
assert_eq!(s.len(), 1);
}
#[test]
fn test_stack_peek_does_not_remove() {
let s: Stack<&str> = Stack::new();
s.push("hello");
s.push("world");
assert_eq!(s.peek(), Some("world"));
assert_eq!(s.len(), 2);
}
#[test]
fn test_stack_empty_pop() {
let s: Stack<u8> = Stack::new();
assert_eq!(s.pop(), None);
assert_eq!(s.peek(), None);
}
#[test]
fn test_event_log_records_in_order() {
let log = EventLog::new();
log.record("connect");
log.record("query");
log.record("disconnect");
assert_eq!(log.count(), 3);
assert_eq!(log.entries(), vec!["connect", "query", "disconnect"]);
}
#[test]
fn test_event_log_immutable_receiver() {
let log = EventLog::new();
let log_ref: &EventLog = &log;
log_ref.record("event-a");
log_ref.record("event-b");
assert_eq!(log.count(), 2);
}
#[test]
fn test_try_borrow_returns_err_when_mutably_borrowed() {
let result = try_borrow_example();
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(msg.contains("borrow failed"));
}
#[test]
fn test_multiple_shared_borrows_allowed() {
let cell: RefCell<Vec<i32>> = RefCell::new(vec![10, 20, 30]);
let r1 = cell.borrow();
let r2 = cell.borrow();
assert_eq!(r1.len(), r2.len());
assert_eq!(*r1, *r2);
}
}
Deep Comparison
OCaml vs Rust: RefCell<T> — Runtime Borrow Checking
Side-by-Side Code
OCaml
(* OCaml: mutable reference inside an immutable binding *)
let collect_items () =
let items = ref [] in
items := "first" :: !items;
items := "second" :: !items;
items := "third" :: !items;
List.rev !items
(* Shared mutable stack with mutable record field *)
type 'a stack = { mutable data : 'a list }
let push s x = s.data <- x :: s.data
let pop s = match s.data with
| [] -> None
| x :: rest -> s.data <- rest; Some x
Rust (idiomatic — RefCell interior mutability)
use std::cell::RefCell;
pub fn collect_items() -> Vec<String> {
let items: RefCell<Vec<String>> = RefCell::new(Vec::new());
items.borrow_mut().push("first".to_string());
items.borrow_mut().push("second".to_string());
items.borrow_mut().push("third".to_string());
let borrowed = items.borrow();
borrowed.clone()
}
Rust (functional — interior-mutable Stack via &self)
pub struct Stack<T> {
data: RefCell<Vec<T>>,
}
impl<T> Stack<T> {
pub fn push(&self, value: T) { // &self, not &mut self
self.data.borrow_mut().push(value);
}
pub fn pop(&self) -> Option<T> {
self.data.borrow_mut().pop()
}
}
Type Signatures
| Concept | OCaml | Rust |
|---|---|---|
| Mutable binding | let x = ref value | let x = RefCell::new(value) |
| Read access | !x (deref ref) | x.borrow() → Ref<T> |
| Write access | x := new_val | x.borrow_mut() → RefMut<T> |
| Mutable field | mutable field : 'a | field: RefCell<T> |
| Borrow violation | — (no rule) | Runtime panic |
| Safe fallible borrow | — | try_borrow() → Result<Ref<T>, BorrowError> |
Key Insights
ref value can be read and written freely at any point; mutation safety is the programmer's responsibility alone.borrow() / borrow_mut() is called, not at compile time. Violation panics rather than failing to compile.&self mutation** — Stack::push(&self) can mutate internal state without requiring &mut self, enabling shared ownership via Rc<Stack<T>> without Rc<RefCell<Stack<T>>>.try_borrow avoids panics** — when the borrow outcome is uncertain (e.g., in library code), try_borrow() returns Result instead of panicking, mirroring OCaml's implicit "it just works" but with explicit error handling.Ref<T> and RefMut<T>** — borrows are tracked via guard objects that decrement the borrow count when dropped, so sequential borrow_mut() calls in separate statements never overlap.When to Use Each Style
**Use RefCell::borrow_mut() directly** when mutating through a non-shared local value and you want clarity that each statement releases its borrow before the next starts.
**Use RefCell inside a struct** when you need &self methods that still mutate state — most commonly for mock/spy objects in tests, observers/loggers, and data structures shared via Rc.
Exercises
Rc<RefCell<Vec<Box<dyn Fn(&Event)>>>> that allows subscribers to register closures and the bus to broadcast events to all of them.HashMap<NodeId, RefCell<Node>> where edges can be added at runtime, and write a BFS traversal that borrows each node only when needed.RefCell borrow panic by holding a mutable borrow and attempting a second mutable borrow in the same scope; then refactor the code to avoid the panic using split borrows or restructuring.