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 scope
let ref2 = &mut wisdom; // Now this is valid
ref2.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 lifetimes
fn get_str(s: &str) -> &str { s }
fn get_first(s: &str) -> &str { &s[0..1] }
// These would need explicit lifetimes
fn 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 parameter
struct Philosopher<'a> {
name: &'a str,
quote: &'a str,
}
// Or different lifetimes if needed
struct PhilosopherFlex<'n, 'q> {
name: &'n str,
quote: &'q str,
}
Common patterns:
// Own the data instead
struct PhilosopherOwned {
name: String,
quote: String,
}
// Mixed ownership
struct 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"
));
}
The returned reference must remain within a lifetime valid for both inputs. It cannot outlive either borrowed value. Explicit annotations describe that relationship; they do not extend lifetimes.
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() > b.len() { a } else { b }
}
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);
}
The question panics on the second borrow_mut() because the first mutable borrow is still alive. RefCell enforces this borrowing rule at runtime. In the corrected example below, drop(second) releases the mutable borrow before the two shared borrows.
use std::cell::RefCell;
fn main() {
let data = RefCell::new(42);
let mut second = data.borrow_mut();
*second += 1;
drop(second);
let read1 = data.borrow();
let read2 = data.borrow();
println!("{} {}", *read1, *read2);
}
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 types
struct Counter {
count: Cell<i32>,
}
impl Counter {
fn increment(&self) {
self.count.set(self.count.get() + 1);
}
}
// RefCell for non-Copy types
struct 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 structures
struct Node {
next: Option<Rc<Node>>,
value: i32,
}
// Combining with interior mutability
struct SharedState {
data: Rc<RefCell<Vec<String>>>,
}
// Multiple owners of same data
let 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 1
let data = RefCell::new(vec![1, 2, 3]);
let borrowed = data.borrow_mut();
// Example 2
let shared = RwLock::new(vec![1, 2, 3]);
let locked = shared.write().unwrap();
RefCell<T> checks borrowing at runtime and is not Sync: do not share a reference to it between threads. It can be moved to another thread when T: Send. RwLock<T> provides synchronized access, subject to its type bounds.
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);
}
Locking the same mutex twice in one thread without releasing the first guard never returns normally. The API permits a deadlock or panic; release the guard before locking again.
use std::sync::Mutex;
fn main() {
let lock = Mutex::new(42);
{ let mut guard = lock.lock().unwrap(); *guard += 1; }
let guard = lock.lock().unwrap();
println!("{}", *guard);
}
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 cycles
struct 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. In this example, FileWrapper does not need a custom Drop implementation for the file handle to close: its File field is dropped automatically when the wrapper goes out of scope.
You only implement Drop when the wrapper itself has extra cleanup behavior beyond dropping its fields:
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
- Fields are dropped automatically when the owner goes out of scope
- Custom Drop adds extra cleanup when needed
- Use
?for error propagation
Best practices:
- Rely on standard library Drop implementations when they already model the resource
- 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);
}
Copy performs implicit bitwise copying. Clone is explicit and may allocate; here both String fields receive independent copies. Whether a value is on the stack or heap does not determine whether its type implements Copy.
On a typical current 64-bit Rust target, 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:
// Typical current 64-bit Rust layout: 32 bytes
struct Metadata {
id: u32, // 4 bytes
name: String, // 24 bytes on 64-bit systems
active: bool // 1 byte + padding/alignment
}
// Reordering fields may reduce padding for repr(C) structs,
// but default Rust layout is not a stable ABI guarantee.
struct OptimizedMetadata {
name: String, // 24 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 and 24-byte size on common 64-bit targets
- bool: 1-byte alignment
-
Field ordering strategies:
- Group similar-sized fields
- Put larger alignments first
- Consider cache line optimization
Best practices:
- For FFI or stable layout assumptions, use an appropriate
repr(...) - Use appropriate integer sizes
- Consider using Option for optional fields
- Measure size-critical structs with
std::mem::size_of - Use #[repr(packed)] carefully - it can affect performance
With optimization enabled, what can Rust’s zero-cost iterator abstractions achieve in these two implementations?
// Implementation A: Iterator
fn sum_iterator(v: &[i32]) -> i32 {
v.iter().fold(0, |acc, &x| acc + x)
}
// Implementation B: Raw loop
fn sum_loop(v: &[i32]) -> i32 {
let mut sum = 0;
for i in 0..v.len() {
sum += v[i];
}
sum
}
Zero-cost abstractions allow efficient optimized code; they do not guarantee identical timings for every compiler or optimization level. Both functions calculate the same sum. Benchmark the actual workload when performance matters.
use std::ops::Range;
trait ZeroCost {
fn process(&self) -> u32;
}
impl ZeroCost for Range<u32> {
fn process(&self) -> u32 {
self.clone().fold(0, |acc, x| acc + x)
}
}
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: