Rust · lesson 22 of 30
Beginner
15- 1 Introduction to Rust
- 2 Setting Up Rust
- 3 Variables in Rust
- 4 Data Types in Rust
- 5 Operators in Rust
- 6 Control Flow in Rust
- 7 Functions in Rust
- 8 Ownership in Rust
- 9 Borrowing and References
- 11 Structs in Rust
- 12 Enums in Rust
- 13 Pattern Matching in Rust
- 17 Collections in Rust
- 25 Testing in Rust
- 26 File I/O in Rust
Intermediate
9Advanced
6Memory Management in Rust
Understand Rust memory: Box, Rc, RefCell, Arc and Cell, measured type sizes and the null-pointer niche, plus the five things unsafe Rust permits.
By SkillByExample · Updated
Box<T> — Heap Allocation
Box<T> allocates a value on the heap and gives you a single-owner pointer to it.
fn main() {
let b = Box::new(5);
println!("b = {}", b); // dereferences automatically
// Explicit dereference
println!("*b = {}", *b);
// Useful for large values (avoids stack overflow)
let big_array = Box::new([0u8; 1_000_000]);
println!("allocated {} bytes on heap", big_array.len());
} // big_array is freed here
$ rustc memory_box_t_heap_allocation.rs && ./memory_box_t_heap_allocation
b = 5
*b = 5
allocated 1000000 bytes on heap
Recursive Data Structures
Without Box, the size of a recursive type would be infinite:
// ERROR: recursive type has infinite size
// enum List { Cons(i32, List), Nil }
// FIX: Box breaks the recursion — pointer has known size
#[derive(Debug)]
enum List {
Cons(i32, Box<List>),
Nil,
}
impl List {
fn new() -> Self { List::Nil }
fn prepend(self, val: i32) -> Self {
List::Cons(val, Box::new(self))
}
fn sum(&self) -> i32 {
match self {
List::Cons(val, next) => val + next.sum(),
List::Nil => 0,
}
}
}
fn main() {
let list = List::new().prepend(3).prepend(2).prepend(1);
println!("sum: {}", list.sum());
}
$ rustc memory_recursive_data_structures.rs && ./memory_recursive_data_structures
sum: 6
Rc<T> — Reference Counting (Single-Threaded)
Rc<T> allows multiple owners of the same heap value. The value is dropped when the reference count reaches zero.
use std::rc::Rc;
fn main() {
let a = Rc::new(String::from("shared"));
let b = Rc::clone(&a); // increment reference count
let c = Rc::clone(&a);
println!("count: {}", Rc::strong_count(&a));
println!("{} {} {}", a, b, c);
drop(b);
println!("count after drop: {}", Rc::strong_count(&a));
} // a and c dropped here, count -> 0, string freed
$ rustc memory_rc_t_reference_counting_single_threaded.rs && ./memory_rc_t_reference_counting_single_threaded
count: 3
shared shared shared
count after drop: 2
Rc<T> is not thread-safe (!Send). Use Arc<T> for multi-threaded scenarios.
RefCell<T> — Interior Mutability
RefCell<T> allows mutation through a shared reference by deferring borrow checks to runtime:
use std::cell::RefCell;
fn main() {
let data = RefCell::new(vec![1, 2, 3]);
// Multiple shared borrows
{
let r1 = data.borrow();
let r2 = data.borrow();
println!("{:?} {:?}", *r1, *r2);
} // r1, r2 released
// Mutable borrow
data.borrow_mut().push(4);
println!("{:?}", data.borrow());
}
$ rustc memory_refcell_t_interior_mutability.rs && ./memory_refcell_t_interior_mutability
[1, 2, 3] [1, 2, 3]
[1, 2, 3, 4]
Rc<RefCell<T>> — Shared Mutable Data
use std::rc::Rc;
use std::cell::RefCell;
#[derive(Debug)]
struct Node {
value: i32,
children: Vec<Rc<RefCell<Node>>>,
}
impl Node {
fn new(value: i32) -> Rc<RefCell<Self>> {
Rc::new(RefCell::new(Node { value, children: Vec::new() }))
}
fn add_child(parent: &Rc<RefCell<Node>>, child: Rc<RefCell<Node>>) {
parent.borrow_mut().children.push(child);
}
}
fn main() {
let root = Node::new(1);
let child1 = Node::new(2);
let child2 = Node::new(3);
Node::add_child(&root, Rc::clone(&child1));
Node::add_child(&root, child2);
child1.borrow_mut().value = 20; // mutate through one handle...
let r = root.borrow();
let values: Vec<i32> = r.children.iter().map(|c| c.borrow().value).collect();
println!("root {} has children {:?}", r.value, values); // ...observe through another
println!("child1 strong count: {}", Rc::strong_count(&child1));
}
$ rustc memory_rc_refcell_t_shared_mutable_data.rs && ./memory_rc_refcell_t_shared_mutable_data
root 1 has children [20, 3]
child1 strong count: 2
child1 has two owners — the local handle and the entry in root.children — while child2 was moved in and has one. Changing value through child1 is visible when reading through root, because both handles reach the same RefCell<Node>.
Arc<T> — Atomic Reference Counting (Multi-Threaded)
Arc<T> is Rc<T> with atomic operations — safe to share across threads:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let shared = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..5 {
let s = Arc::clone(&shared);
handles.push(thread::spawn(move || {
*s.lock().unwrap() += 1;
}));
}
for h in handles { h.join().unwrap(); }
println!("{}", *shared.lock().unwrap());
}
$ rustc memory_arc_t_atomic_reference_counting_multi_th.rs && ./memory_arc_t_atomic_reference_counting_multi_th
5
Cell<T> — Copy-Type Interior Mutability
Cell<T> is a lightweight alternative to RefCell<T> for Copy types:
use std::cell::Cell;
struct Config {
debug: Cell<bool>,
level: Cell<u32>,
}
impl Config {
fn new() -> Self {
Config { debug: Cell::new(false), level: Cell::new(1) }
}
fn enable_debug(&self) { self.debug.set(true); }
fn set_level(&self, l: u32) { self.level.set(l); }
}
fn main() {
let cfg = Config::new();
cfg.enable_debug(); // mutate through &self!
cfg.set_level(3);
println!("debug={}, level={}", cfg.debug.get(), cfg.level.get());
}
$ rustc memory_cell_t_copy_type_interior_mutability.rs && ./memory_cell_t_copy_type_interior_mutability
debug=true, level=3
Memory Layout
Understanding how Rust lays out data in memory:
fn main() {
use std::mem::{size_of, align_of};
println!("bool: size={} align={}", size_of::<bool>(), align_of::<bool>());
println!("u8: size={} align={}", size_of::<u8>(), align_of::<u8>());
println!("i32: size={} align={}", size_of::<i32>(), align_of::<i32>());
println!("f64: size={} align={}", size_of::<f64>(), align_of::<f64>());
println!("&str: size={}", size_of::<&str>()); // ptr + len
println!("String: size={}", size_of::<String>()); // ptr + len + cap
println!("Vec<u8>: size={}", size_of::<Vec<u8>>());
println!("Box<i32>: size={}", size_of::<Box<i32>>()); // one pointer
println!("Option<&i32>: size={}", size_of::<Option<&i32>>());
println!("Option<i32>: size={}", size_of::<Option<i32>>());
}
$ rustc memory_memory_layout.rs && ./memory_memory_layout
bool: size=1 align=1
u8: size=1 align=1
i32: size=4 align=4
f64: size=8 align=8
&str: size=16
String: size=24
Vec<u8>: size=24
Box<i32>: size=8
Option<&i32>: size=8
Option<i32>: size=8
The last two lines are the interesting pair. Option<&i32> is the same size as &i32 because a reference can never be null, so the compiler uses the null bit pattern to mean None — the “null pointer optimization”. i32 has no spare bit pattern, so Option<i32> needs a separate tag; with 4-byte alignment that tag costs a full 4 bytes, doubling the size.
Unsafe Rust
unsafe unlocks five additional capabilities: dereferencing raw pointers, calling unsafe functions, implementing unsafe traits, accessing mutable statics, and reading union fields. It does not switch off the borrow checker or any other check — it only permits those five operations. Use it sparingly and document the invariants it relies on:
fn main() {
// 1. Dereference raw pointers
let x = 42;
let r = &x as *const i32; // create raw pointer (safe)
unsafe {
println!("{}", *r); // dereference (unsafe)
}
// Mutable raw pointer
let mut y = 10;
let p = &mut y as *mut i32;
unsafe {
*p += 1;
}
println!("{}", y);
}
$ rustc memory_unsafe_rust.rs && ./memory_unsafe_rust
42
11
Creating a raw pointer is safe; only dereferencing it needs unsafe, because only then could it point at freed or misaligned memory. Here both pointers come from live references, so the invariant holds.
// 2. Call unsafe functions
/// # Safety
/// `ptr` must point to `len` initialized bytes that stay alive for `'a`.
unsafe fn dangerous<'a>(ptr: *const u8, len: usize) -> &'a str {
let slice = unsafe { std::slice::from_raw_parts(ptr, len) };
std::str::from_utf8(slice).unwrap()
}
// Safe wrapper: a &[u8] guarantees exactly what the Safety comment requires
fn safe_from_bytes(bytes: &[u8]) -> &str {
unsafe { dangerous(bytes.as_ptr(), bytes.len()) }
}
fn main() {
let bytes = b"hello";
println!("{}", safe_from_bytes(bytes));
}
$ rustc memory_unsafe_rust_2.rs && ./memory_unsafe_rust_2
hello
The pattern that matters is the wrapper: safe_from_bytes can only be called with a real slice, so the raw-pointer contract is upheld by construction and callers never write unsafe themselves. The lifetime parameter 'a matters too: declaring the return type as &'static str would let callers keep the string after the borrowed bytes are freed — the signature must not promise more than the raw pointer guarantees.
// 3. Implementing unsafe traits
/// # Safety
/// Implement only for types where all-zero bytes is a valid value.
unsafe trait Zeroable {}
unsafe impl Zeroable for u8 {}
unsafe impl Zeroable for i32 {}
fn zero_out<T: Zeroable>(val: &mut T) {
unsafe {
let size = std::mem::size_of::<T>();
let ptr = val as *mut T as *mut u8;
std::ptr::write_bytes(ptr, 0, size);
}
}
fn main() {
let mut n: i32 = -12345;
zero_out(&mut n);
println!("{}", n);
}
$ rustc memory_unsafe_rust_3.rs && ./memory_unsafe_rust_3
0
unsafe trait moves the promise to the implementer: writing unsafe impl Zeroable for i32 asserts that zero bytes form a valid i32, which is what lets zero_out itself be a safe function. Implementing it for &i32 would be a lie — an all-zero reference is a null pointer — and the compiler would not stop you.
Smart Pointer Summary
| Type | Ownership | Thread safe | Interior mut | Use case |
|---|---|---|---|---|
Box<T> | Unique | Yes (T: Send) | No | Heap alloc, recursive types, dyn Trait |
Rc<T> | Shared | No | No | Single-threaded shared ownership |
Arc<T> | Shared | Yes | No | Multi-threaded shared ownership |
Cell<T> | Unique | No | Yes (Copy) | Cheap interior mutation |
RefCell<T> | Unique | No | Yes (any) | Runtime borrow checking |
Mutex<T> | Shared | Yes | Yes | Thread-safe mutation |
RwLock<T> | Shared | Yes | Yes | Many readers OR one writer |
Practice
-
Predict
size_offorOption<Box<i32>>,Option<u8>,(u8, u32), andOption<Option<bool>>, then print them.Solution
Option<Box<i32>>: 8 Option<u8>: 2 (u8, u32): 8 Option<Option<bool>>: 1Boxis never null, so it gets the same niche as&T.Option<u8>needs a tag byte.(u8, u32)is padded to theu32’s 4-byte alignment. Andboolonly uses the values 0 and 1, leaving 254 spare bit patterns — enough to encode bothNones inside a single byte. -
In the
Rc<RefCell<Node>>example, holdlet r = root.borrow();and then callNode::add_child(&root, Node::new(4));whileris still alive. Does it compile? Does it run?Solution
It compiles —
RefCellmoves the check to runtime — and then panics insideadd_child’sborrow_mut()withRefCell already borrowed. Dropr(or end its scope) before mutating. -
Call
zero_outon au8holding255and on ani32holdingi32::MIN, printing both before and after.Solution
Both become
0:255 -> 0and-2147483648 -> 0.write_byteszeroessize_of::<T>()bytes — 1 foru8, 4 fori32— which theZeroablebound guarantees is a valid value.