Dan Levy's Avatar DanLevy.net

Quiz: Essential Rust Memory Management

(Borrow) check yo self before you wreck yo self! 🦀

Quiz: Essential Rust Memory Management

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:

  1. Clone the string (creates a new copy):
let greeting = philosopher.clone();
  1. Use a reference (borrows the value):
let greeting = &philosopher;
  1. 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:

  1. Pass by reference (borrow the value):
fn borrow_it(text: &String) {
println!("Inside: {}", text);
}
borrow_it(&wisdom); // Now wisdom can be used after
  1. Clone the value (create a new copy):
take_knowledge(wisdom.clone()); // Original wisdom remains valid
  1. 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:

  1. 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.");
  1. 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:

  1. Each parameter gets its own lifetime parameter
  2. If there’s exactly one input lifetime parameter, that lifetime is assigned to all output lifetime parameters
  3. 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:

  1. Box provides a fixed-size pointer (usually 8 bytes on 64-bit systems)
  2. The actual data is stored on the heap
  3. 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:

  1. Initial creation with Rc::new(): count = 1
  2. First clone for marcus: count = 2
  3. 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:

  1. Use owned types (String) when you need to store data indefinitely
  2. Use references when the struct’s lifetime is clearly shorter than the data
  3. Consider multiple lifetime parameters when references can have different lifetimes
  4. 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 annotation
fn longest<'a>(text1: &'a str, text2: &'a str) -> &'a str {
if text1.len() > text2.len() {
text1
} else {
text2
}
}
// Alternative with different lifetimes
fn 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:

  1. Multiple input references could have different lifetimes
  2. The return value must live as long as both inputs
  3. The compiler needs to verify these relationships

Common patterns:

// Single input reference - elision works
fn first_word(s: &str) -> &str { /* ... */ }
// Multiple references, same lifetime needed
fn compare_str<'a>(s1: &'a str, s2: &'a str) -> &'a str { /* ... */ }
// Different lifetimes possible
fn combine<'a, 'b>(s1: &'a str, s2: &'b str) -> String { /* ... */ }

Best practices:

  1. Let lifetime elision work when possible
  2. Use explicit lifetimes when relationships need to be clear
  3. Consider returning owned types to avoid lifetime complexity
  4. 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:

  1. RefCell moves borrowing checks to runtime
  2. Can cause panics if rules are violated
  3. 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:

  1. Prefer compile-time borrowing when possible
  2. Keep RefCell borrows in narrow scopes
  3. Consider using drop() to explicitly end borrows
  4. 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 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:

  1. Cell:
    • Works best with Copy types
    • No borrowing API
    • Always copies or moves values
  2. RefCell:
    • Works with any type
    • Has borrowing API
    • Runtime borrow checking

Best practices:

  1. Use Cell for simple Copy types (numbers, bool, etc.)
  2. Use RefCell when you need to borrow the contents
  3. Keep mutations through Cell/RefCell minimal
  4. 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:

  1. Use Rc when:

    • Multiple parts of your code need ownership
    • You know the sharing is single-threaded
    • The lifetime can’t be statically determined
  2. Use Arc instead when:

    • You need thread-safe sharing
    • Multiple threads need ownership
  3. Rc limitations:

    • Not thread-safe
    • Slight runtime overhead
    • Can’t break reference cycles automatically

Best practices:

  1. Prefer unique ownership when possible
  2. Use Rc for single-threaded shared ownership
  3. Use Arc for multi-threaded scenarios
  4. 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 and RwLock serve similar purposes but in different contexts:

// Single-threaded scenario with RefCell
use std::cell::RefCell;
struct SingleThreaded {
data: RefCell<Vec<i32>>,
}
impl SingleThreaded {
fn modify(&self) {
self.data.borrow_mut().push(42);
}
}
// Multi-threaded scenario with RwLock
use std::sync::RwLock;
struct ThreadSafe {
data: RwLock<Vec<i32>>,
}
impl ThreadSafe {
fn modify(&self) {
self.data.write().unwrap().push(42);
}
}

Key differences:

  1. RefCell:
    • Single-threaded only
    • No synchronization overhead
    • Panics on borrowing violations
  2. RwLock:
    • Thread-safe
    • Has synchronization overhead
    • Can block threads instead of panicking

Best practices:

  1. Use RefCell for single-threaded interior mutability
  2. Use RwLock when thread safety is needed
  3. Consider Mutex for simpler thread-safe mutability
  4. 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 again
fn 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 safely
fn 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:

  1. Keep critical sections small
  2. Release locks promptly using scopes
  3. Acquire multiple locks in a consistent order
  4. Use parking_lot::Mutex for better performance
  5. Consider using RwLock for read-heavy workloads

Common patterns:

// Thread-safe counter
struct 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 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:

  1. Cache-like structures where entries can be cleared
  2. Tree structures with parent references
  3. Observer patterns where subjects can be dropped
  4. Breaking reference cycles in complex data structures

Best practices:

  1. Use Weak references for optional relationships
  2. Check upgrade() results before using
  3. Document ownership relationships clearly
  4. 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:

  1. Constructor acquires resources
  2. Methods use resources safely
  3. Drop releases resources
  4. Use ? for error propagation

Best practices:

  1. Implement Drop for custom resource types
  2. Keep resource management simple and obvious
  3. Use standard library types when possible
  4. Document cleanup behavior
  5. 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:

  1. Copy:
    • Implicit, bitwise copy
    • Must be Copy-safe (no heap allocations)
    • Typically for small, stack-only types
  2. Clone:
    • Explicit, potentially deep copy
    • Can handle heap allocations
    • More flexible but potentially expensive

Best practices:

  1. Implement Copy for small, stack-only types
  2. Use Clone for types with owned resources
  3. Document performance implications of Clone
  4. Consider custom Clone implementations for optimization
  5. 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 bytes
struct Metadata {
id: u32, // 4 bytes + 4 padding
name: String, // 8 bytes (pointer)
active: bool // 1 byte + 7 padding
}
// Optimized: 16 bytes
struct 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:

  1. Alignment requirements:

    • u32: 4-byte alignment
    • String: 8-byte alignment (64-bit pointer)
    • bool: 1-byte alignment
  2. Field ordering strategies:

    • Group similar-sized fields
    • Put larger alignments first
    • Consider cache line optimization

Best practices:

  1. Order fields from largest to smallest
  2. Use appropriate integer sizes
  3. Consider using Option for optional fields
  4. Document size-critical structs
  5. Use #[repr(packed)] carefully - it can affect performance

How does the performance of these two implementations compare?

// 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
}

Rust’s zero-cost abstractions compile to equivalent efficient code:

use std::ops::Range;
// High-level abstraction
trait 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-cost
fn complex_processing<T>(data: &[T]) -> u32
where 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:

  1. What you don’t use, you don’t pay for
  2. What you do use, you couldn’t hand-code better

Best practices:

  1. Use high-level abstractions freely
  2. Trust the compiler’s optimizations
  3. Profile before optimizing
  4. Focus on readability first
  5. Use iterators and closures without fear

Quiz Score:

Congrats! Quiz completed.

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:

Edit on GitHubGitHub