Quiz: Essential Rust Memory Management
(Borrow) check yo self before you wreck yo self! 🦀

Ready to test your Rust memory management skills? 🦀
This quiz will challenge your understanding of Rust’s ownership system, borrowing rules, lifetimes, and smart pointers.
Note: The questions are formatted in ~50-column width to ensure readability across all devices. (Suggestions for improvement are welcome!)
Whether you’re a seasoned Rustacean or just getting started with memory management, this quiz will help reinforce your knowledge. Let’s dive in! 🦀
What happens when you run this code? Try to predict the output or error:
fn main() { let philosopher = String::from("Zeno of Citium"); let greeting = philosopher;
println!("Hello, {}!", philosopher);}
This code fails to compile because of Rust’s ownership rules. When we assign philosopher
to greeting
, the ownership of the String is moved to greeting
. After this move, philosopher
is no longer valid to use.
Here are three ways to fix this:
- Clone the string (creates a new copy):
let greeting = philosopher.clone();
- Use a reference (borrows the value):
let greeting = &philosopher;
- Use a string slice (borrows part of the string):
let greeting = &philosopher[..];
Each solution has different use cases and performance implications. Cloning is more expensive but gives you ownership, while references are cheaper but have lifetime constraints.
What happens when you run this code? Think about ownership transfer:
fn take_knowledge(knowledge: String) { println!("Knowledge: {}", knowledge);}
fn main() { let wisdom = String::from("know thyself"); take_knowledge(wisdom); // What happens to our wisdom? println!("Do you {}", wisdom);}
The code fails to compile because wisdom
’s ownership moved to take_knowledge
and therefore can’t be used afterward.
Here are three ways to fix this issue:
- Pass by reference (borrow the value):
fn borrow_it(text: &String) { println!("Inside: {}", text);}borrow_it(&wisdom); // Now wisdom can be used after
- Clone the value (create a new copy):
take_knowledge(wisdom.clone()); // Original wisdom remains valid
- Return ownership from the function:
fn take_and_return(text: String) -> String { println!("Inside: {}", text); text // Return ownership back}let wisdom = take_and_return(wisdom); // Reassign returned ownership
Each approach has different use cases:
- References: Most efficient, but need lifetime management
- Cloning: Simple but potentially expensive
- Returning ownership: Useful for transforming values
Best practice: Use references unless you need ownership transfer.
What happens with multiple mutable references?
fn main() { let mut wisdom = String::from("He who laughs at"); let ref1 = &mut wisdom; // First mutable borrow let ref2 = &mut wisdom; // Second mutable borrow ref1.push_str(" himself never runs"); ref2.push_str(" out of things to laugh at.");}
Think about Rust’s rules for mutable references.
This code violates Rust’s fundamental borrowing rules:
- Only ONE mutable reference to a value at a time
- OR any number of immutable references
- References cannot outlive their referent
Here’s how to fix the code:
- Use sequential scoping:
let mut wisdom = String::from("He who laughs at");{ let ref1 = &mut wisdom; ref1.push_str(" himself never runs");} // ref1 goes out of scopelet ref2 = &mut wisdom; // Now this is validref2.push_str(" out of things to laugh at.");
- Or modify the string in a single borrow:
let mut wisdom = String::from("He who laughs at");let ref1 = &mut wisdom;ref1.push_str(" himself never runs out of things to laugh at.");
These rules prevent data races at compile time, making Rust thread-safe by default.
Common pitfall: Trying to use multiple mutable references to avoid cloning or to modify different parts of the same value simultaneously.
Will this code compile? If so, why? If not, what’s wrong?
fn first_word(s: &str) -> &str { // No explicit lifetimes? match s.find(' ') { Some(pos) => &s[0..pos], None => s, }}
fn main() { let name = String::from("Seneca the Younger"); let first = first_word(&name); println!("Hello, {}", first);}
This code compiles successfully thanks to Rust’s lifetime elision rules. These rules allow the compiler to automatically infer lifetimes in common patterns.
The three lifetime elision rules are:
- Each parameter gets its own lifetime parameter
- If there’s exactly one input lifetime parameter, that lifetime is assigned to all output lifetime parameters
- If there are multiple input lifetime parameters, but one of them is &self or &mut self, the lifetime of self is assigned to all output lifetime parameters
This function is equivalent to:
fn first_word<'a>(s: &'a str) -> &'a str { // ... same implementation}
Common patterns where elision works:
// These don't need explicit lifetimesfn get_str(s: &str) -> &str { s }fn get_first(s: &str) -> &str { &s[0..1] }
// These would need explicit lifetimesfn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y }}
Best practice: Let elision work for you when possible, but understand when explicit lifetimes are needed.
What’s wrong with this recursive type definition?
#[derive(Debug)]enum CatList { Cons(i32, CatList), // Recursive without indirection Nil,}
fn main() { let catlist = CatList::Cons(1, CatList::Cons(2, CatList::Cons(3, CatList::Nil)));}
This code fails because the compiler can’t determine the size of CatList
at compile time. The recursive nature of the type means it could be infinitely large!
Here’s how to fix it using Box<T>
:
#[derive(Debug)]enum CatList { Cons(i32, Box<CatList>), // Box provides a fixed-size pointer Nil,}
fn main() { let catlist = CatList::Cons(1, Box::new(CatList::Cons(2, Box::new(CatList::Cons(3, Box::new(CatList::Nil))))));}
Why Box<T>
works:
- Box provides a fixed-size pointer (usually 8 bytes on 64-bit systems)
- The actual data is stored on the heap
- The compiler now knows exactly how much space to allocate
Common use cases for Box<T>
:
- Recursive data structures (linked lists, trees)
- Large data you want to ensure is heap-allocated
- Trait objects when you need dynamic dispatch
Best practice: Use Box<T>
when you need:
- Recursive types
- To ensure heap allocation
- To move large data without copying
What will this code print? Count carefully!
use std::rc::Rc;
fn main() { let text = Rc::new(String::from("Meditations")); // Count: 1 let marcus = Rc::clone(&text); // What happens here? let aurelius = Rc::clone(&text); // And here? println!( "Reference count: {}", Rc::strong_count(&text) );}
Let’s break down how Rc works:
- Initial creation with
Rc::new()
: count = 1 - First clone for
marcus
: count = 2 - Second clone for
aurelius
: count = 3
Important Rc characteristics:
use std::rc::Rc;
fn demonstrate_rc() { let original = Rc::new(String::from("Shared")); println!("Count after creation: {}", Rc::strong_count(&original)); // 1
{ let copy = Rc::clone(&original); println!("Count inside scope: {}", Rc::strong_count(&original)); // 2 } // copy is dropped here
println!("Count after scope: {}", Rc::strong_count(&original)); // 1}
Key points:
- Rc::clone() is cheap - it only increments a counter
- Rc is for single-threaded scenarios only
- When the last reference is dropped, the data is cleaned up
- Use Weak references to prevent reference cycles
Best practices:
- Use Rc when you need shared ownership
- Consider Arc for thread-safe scenarios
- Avoid creating reference cycles
Will this struct definition compile? Why or why not?
struct Philosopher { name: &str, // Reference without lifetime quote: &str, // Another reference without lifetime}
fn main() { let phil = Philosopher { name: "Seneca", quote: "Luck happens when preparation meets opportunity", };}
The code fails because structs containing references must specify lifetimes. Here’s how to fix it:
// Single lifetime parameterstruct Philosopher<'a> { name: &'a str, quote: &'a str,}
// Or different lifetimes if neededstruct PhilosopherFlex<'n, 'q> { name: &'n str, quote: &'q str,}
Common patterns:
// Own the data insteadstruct PhilosopherOwned { name: String, quote: String,}
// Mixed ownershipstruct PhilosopherMixed<'a> { name: String, // Owned quote: &'a str, // Borrowed}
Best practices:
- Use owned types (String) when you need to store data indefinitely
- Use references when the struct’s lifetime is clearly shorter than the data
- Consider multiple lifetime parameters when references can have different lifetimes
- Document lifetime relationships in complex structures
What happens with this function that returns the longer of two string slices?
fn longest(text1: &str, text2: &str) -> &str { if text1.len() > text2.len() { text1 // Returning a reference, but which lifetime? } else { text2 // Could be this reference instead }}
fn main() { println!("{}", longest( "Seneca the Younger", "Marcus Aurelius" ));}
This code fails because the compiler can’t determine the relationship between the input and output lifetimes. Here’s why and how to fix it:
// Correct version with explicit lifetime annotationfn longest<'a>(text1: &'a str, text2: &'a str) -> &'a str { if text1.len() > text2.len() { text1 } else { text2 }}
// Alternative with different lifetimesfn longest_flex<'a, 'b>(text1: &'a str, text2: &'b str) -> &'a str { if text1.len() > text2.len() { text1 } else { text2.to_string().as_str() // Won't compile! Shows why we need same lifetime }}
Why lifetimes are needed here:
- Multiple input references could have different lifetimes
- The return value must live as long as both inputs
- The compiler needs to verify these relationships
Common patterns:
// Single input reference - elision worksfn first_word(s: &str) -> &str { /* ... */ }
// Multiple references, same lifetime neededfn compare_str<'a>(s1: &'a str, s2: &'a str) -> &'a str { /* ... */ }
// Different lifetimes possiblefn combine<'a, 'b>(s1: &'a str, s2: &'b str) -> String { /* ... */ }
Best practices:
- Let lifetime elision work when possible
- Use explicit lifetimes when relationships need to be clear
- Consider returning owned types to avoid lifetime complexity
- Document complex lifetime relationships
What happens when this code runs?
use std::cell::RefCell;
fn main() { let data = RefCell::new(42); let _borrow1 = data.borrow_mut(); // First mutable borrow let _borrow2 = data.borrow_mut(); // Second mutable borrow println!("Value: {}", _borrow2);}
RefCell provides interior mutability but still enforces Rust’s borrowing rules at runtime:
use std::cell::RefCell;
fn demonstrate_refcell() { let data = RefCell::new(42);
// Correct way to use RefCell { let mut first = data.borrow_mut(); *first += 1; } // first is dropped here
// Now we can borrow again let second = data.borrow_mut();
// Or multiple immutable borrows let read1 = data.borrow(); let read2 = data.borrow(); // This is OK}
Key concepts:
- RefCell moves borrowing checks to runtime
- Can cause panics if rules are violated
- Useful for interior mutability pattern
Common use cases:
- Mock objects in tests
- Implementing self-referential structures
- When you need to mutate data behind a shared reference
Best practices:
- Prefer compile-time borrowing when possible
- Keep RefCell borrows in narrow scopes
- Consider using drop() to explicitly end borrows
- Use RefCell when you need interior mutability
What will this code print?
use std::cell::Cell;
fn main() { let life = Cell::new(42); let meaning = &life; // Shared reference println!("{}", life.get()); // What prints here? meaning.set(43); // Mutation through shared ref println!("{}", life.get()); // And here?}
Cell and RefCell serve different purposes for interior mutability:
use std::cell::{Cell, RefCell};
// Cell for Copy typesstruct Counter { count: Cell<i32>,}
impl Counter { fn increment(&self) { self.count.set(self.count.get() + 1); }}
// RefCell for non-Copy typesstruct Logger { messages: RefCell<Vec<String>>,}
impl Logger { fn log(&self, msg: &str) { self.messages.borrow_mut().push(msg.to_string()); }}
Key differences:
- Cell:
- Works best with Copy types
- No borrowing API
- Always copies or moves values
- RefCell:
- Works with any type
- Has borrowing API
- Runtime borrow checking
Best practices:
- Use Cell for simple Copy types (numbers, bool, etc.)
- Use RefCell when you need to borrow the contents
- Keep mutations through Cell/RefCell minimal
- Document why interior mutability is needed
When should you use Rc (Reference Counting) in Rust?
Consider this example:
use std::rc::Rc;
struct SharedConfig { name: String, value: i32,}
fn main() { let config = Rc::new(SharedConfig { name: "settings".to_string(), value: 42, });
let config2 = Rc::clone(&config); // Both config and config2 share ownership}
Rc (Reference Counting) is designed for single-threaded scenarios where you need shared ownership.
Common use cases:
use std::rc::Rc;use std::cell::RefCell;
// Shared ownership in data structuresstruct Node { next: Option<Rc<Node>>, value: i32,}
// Combining with interior mutabilitystruct SharedState { data: Rc<RefCell<Vec<String>>>,}
// Multiple owners of same datalet original = Rc::new(vec![1, 2, 3]);let clone1 = Rc::clone(&original);let clone2 = Rc::clone(&original);
Key points:
-
Use Rc when:
- Multiple parts of your code need ownership
- You know the sharing is single-threaded
- The lifetime can’t be statically determined
-
Use Arc instead when:
- You need thread-safe sharing
- Multiple threads need ownership
-
Rc limitations:
- Not thread-safe
- Slight runtime overhead
- Can’t break reference cycles automatically
Best practices:
- Prefer unique ownership when possible
- Use Rc for single-threaded shared ownership
- Use Arc for multi-threaded scenarios
- Combine with Weak to prevent reference cycles
What’s the key difference between RefCell and RwLock in Rust?
Consider these examples:
use std::cell::RefCell;use std::sync::RwLock;
// Example 1let data = RefCell::new(vec![1, 2, 3]);let borrowed = data.borrow_mut();
// Example 2let shared = RwLock::new(vec![1, 2, 3]);let locked = shared.write().unwrap();
RefCell and RwLock serve similar purposes but in different contexts:
// Single-threaded scenario with RefCelluse std::cell::RefCell;
struct SingleThreaded { data: RefCell<Vec<i32>>,}
impl SingleThreaded { fn modify(&self) { self.data.borrow_mut().push(42); }}
// Multi-threaded scenario with RwLockuse std::sync::RwLock;
struct ThreadSafe { data: RwLock<Vec<i32>>,}
impl ThreadSafe { fn modify(&self) { self.data.write().unwrap().push(42); }}
Key differences:
- RefCell:
- Single-threaded only
- No synchronization overhead
- Panics on borrowing violations
- RwLock:
- Thread-safe
- Has synchronization overhead
- Can block threads instead of panicking
Best practices:
- Use RefCell for single-threaded interior mutability
- Use RwLock when thread safety is needed
- Consider Mutex for simpler thread-safe mutability
- Document thread safety requirements clearly
What happens when this code runs?
use std::sync::{Arc, Mutex};
fn main() { let lock = Arc::new(Mutex::new(42)); let lock2 = Arc::clone(&lock);
let _guard1 = lock.lock().unwrap(); // First lock let _guard2 = lock2.lock().unwrap(); // Second lock attempt
println!("Value: {}", _guard2);}
This code demonstrates a classic deadlock scenario. Here’s how to fix it:
use std::sync::{Arc, Mutex};
// Correct way - Release lock before acquiring it againfn safe_mutex() { let lock = Arc::new(Mutex::new(42));
{ let mut data = lock.lock().unwrap(); *data += 1; } // Lock is released here
// Now we can acquire it again let data2 = lock.lock().unwrap(); println!("Value: {}", data2);}
// Using multiple mutexes safelyfn multiple_mutexes() { let lock1 = Arc::new(Mutex::new(42)); let lock2 = Arc::new(Mutex::new(43));
// Always acquire locks in the same order let guard1 = lock1.lock().unwrap(); let guard2 = lock2.lock().unwrap();}
Best practices to prevent deadlocks:
- Keep critical sections small
- Release locks promptly using scopes
- Acquire multiple locks in a consistent order
- Use parking_lot::Mutex for better performance
- Consider using RwLock for read-heavy workloads
Common patterns:
// Thread-safe counterstruct Counter { count: Arc<Mutex<i32>>,}
impl Counter { fn increment(&self) { let mut count = self.count.lock().unwrap(); *count += 1; } // Lock automatically released here}
What happens when you run this code with weak references?
use std::rc::{Rc, Weak};
fn main() { let data = Rc::new(String::from("Wisdom")); let weak = Rc::downgrade(&data); // Create weak reference drop(data); // Drop strong reference
println!("Value: {:?}", weak.upgrade());}
Weak references don’t prevent deallocation of their targets. Here’s a detailed example:
use std::rc::{Rc, Weak};use std::cell::RefCell;
// Parent-child tree structure avoiding reference cyclesstruct Node { next: Option<Rc<Node>>, parent: RefCell<Weak<Node>>, // Weak to prevent cycles value: i32,}
impl Node { fn new(value: i32) -> Rc<Node> { Rc::new(Node { next: None, parent: RefCell::new(Weak::new()), value, }) }
fn set_parent(&self, parent: &Rc<Node>) { *self.parent.borrow_mut() = Rc::downgrade(parent); }
fn get_parent(&self) -> Option<Rc<Node>> { self.parent.borrow().upgrade() }}
Common use cases:
- Cache-like structures where entries can be cleared
- Tree structures with parent references
- Observer patterns where subjects can be dropped
- Breaking reference cycles in complex data structures
Best practices:
- Use Weak references for optional relationships
- Check upgrade() results before using
- Document ownership relationships clearly
- Consider alternatives like indices for simpler cases
What happens to the file handle in this RAII example?
use std::fs::File;
struct FileWrapper { file: File,}
fn main() { let file = File::create("test.txt").unwrap(); let wrapper = FileWrapper { file }; // ... use wrapper ... // No Drop implementation}
RAII in Rust ensures resources are properly managed. Here’s how to implement it correctly:
use std::fs::File;use std::io::{self, Write};
struct FileWrapper { file: File, path: String,}
impl FileWrapper { fn new(path: &str) -> io::Result<FileWrapper> { Ok(FileWrapper { file: File::create(path)?, path: path.to_string(), }) }
fn write(&mut self, content: &str) -> io::Result<()> { self.file.write_all(content.as_bytes()) }}
impl Drop for FileWrapper { fn drop(&mut self) { // Ensure file is properly closed // Could also do cleanup like deletion println!("Closing file: {}", self.path); }}
RAII Patterns:
- Constructor acquires resources
- Methods use resources safely
- Drop releases resources
- Use
?
for error propagation
Best practices:
- Implement Drop for custom resource types
- Keep resource management simple and obvious
- Use standard library types when possible
- Document cleanup behavior
- Consider using guard patterns for scoped operations
What happens when we clone this Philosophy struct?
#[derive(Clone)]struct Philosophy { school: String, founder: String,}
fn main() { let stoicism = Philosophy { school: String::from("Stoicism"), founder: String::from("Zeno of Citium") }; let new_school = stoicism.clone(); println!("{} - {}", stoicism.school, new_school.school);}
Let’s understand Copy vs Clone in detail:
// Types that can be Copy#[derive(Copy, Clone)]struct Point { x: i32, y: i32,}
// Types that can only be Clone#[derive(Clone)]struct ComplexData { name: String, // String can't be Copy points: Vec<i32> // Vec can't be Copy}
// Manual implementation example#[derive(Debug)]struct Custom { data: Vec<i32>, identifier: u32,}
impl Clone for Custom { fn clone(&self) -> Self { Custom { data: self.data.clone(), identifier: self.identifier, // Copy type } }}
Key differences:
- Copy:
- Implicit, bitwise copy
- Must be Copy-safe (no heap allocations)
- Typically for small, stack-only types
- Clone:
- Explicit, potentially deep copy
- Can handle heap allocations
- More flexible but potentially expensive
Best practices:
- Implement Copy for small, stack-only types
- Use Clone for types with owned resources
- Document performance implications of Clone
- Consider custom Clone implementations for optimization
- Be cautious with automatic derivation
On a 64-bit system, what’s the size of this struct?
struct Metadata { id: u32, // How many bytes? name: String, // How many bytes? active: bool // How many bytes + padding?}
Let’s break down struct memory layout and optimization:
// Original: 24 bytesstruct Metadata { id: u32, // 4 bytes + 4 padding name: String, // 8 bytes (pointer) active: bool // 1 byte + 7 padding}
// Optimized: 16 bytesstruct OptimizedMetadata { name: String, // 8 bytes id: u32, // 4 bytes active: bool // 1 byte + 3 padding}
// Further optimization with packing#[repr(packed)]struct PackedMetadata { id: u32, active: bool, name: String,}
Memory layout considerations:
-
Alignment requirements:
- u32: 4-byte alignment
- String: 8-byte alignment (64-bit pointer)
- bool: 1-byte alignment
-
Field ordering strategies:
- Group similar-sized fields
- Put larger alignments first
- Consider cache line optimization
Best practices:
- Order fields from largest to smallest
- Use appropriate integer sizes
- Consider using Option for optional fields
- Document size-critical structs
- Use #[repr(packed)] carefully - it can affect performance
How does the performance of these two implementations compare?
// Implementation A: Iteratorfn sum_iterator(v: &[i32]) -> i32 { v.iter().fold(0, |acc, &x| acc + x)}
// Implementation B: Raw loopfn sum_loop(v: &[i32]) -> i32 { let mut sum = 0; for i in 0..v.len() { sum += v[i]; } sum}
Rust’s zero-cost abstractions compile to equivalent efficient code:
use std::ops::Range;
// High-level abstractiontrait ZeroCost { fn process(&self) -> u32;}
impl ZeroCost for Range<u32> { fn process(&self) -> u32 { self.fold(0, |acc, x| acc + x) }}
// Compiles to essentially the same code as:fn manual_process(range: Range<u32>) -> u32 { let mut sum = 0; let mut i = range.start; while i < range.end { sum += i; i += 1; } sum}
// Even more abstractions, still zero-costfn complex_processing<T>(data: &[T]) -> u32where T: AsRef<str> { data.iter() .map(|s| s.as_ref().len()) .filter(|&n| n > 3) .fold(0, |acc, n| acc + n as u32)}
Key principles:
- What you don’t use, you don’t pay for
- What you do use, you couldn’t hand-code better
Best practices:
- Use high-level abstractions freely
- Trust the compiler’s optimizations
- Profile before optimizing
- Focus on readability first
- Use iterators and closures without fear
Thanks for taking the quiz! If you enjoyed testing your Rust knowledge, check out my other programming challenges! 🧠
Want to level up your Rust skills? Here are some recommended resources: