Lab 5: Ownership, Pointers, and Memory - Solutions
This lab explores Rust’s ownership system, memory management, and how these features enable type-safe state machines.
Ownership and State Machines
Rust’s ownership system provides a powerful way to enforce state machine transitions and ensure resources are properly managed. This allows us to encode state machine transitions as type changes.
Typestate Pattern
The typestate pattern uses Rust’s type system to represent different states of an object and ensure that only valid operations are performed in each state. Key implementation approaches:
- Method Consumption Pattern: Methods take
selfinstead of&self, consuming the object in the current state and returning a new object in the resulting state:
pub fn close(self) -> Result<(), Error> {
// Close implementation
// Doesn't return self, so object is consumed
}This pattern is powerful because:
- It makes state transitions explicit
- The compiler ensures objects aren’t used after state transitions
- It prevents access to methods that aren’t valid in the current state
- Phantom Types: Using zero-sized marker types to represent states:
struct Open;
struct Closed;
struct File<State> {
// File data
_state: PhantomData<State>,
}
impl File<Open> {
fn close(self) -> File<Closed> {
// Implementation
}
}Phantom types take no space in memory but allow the compiler to track state at compile time.
Memory Safety in Rust
Example 1: Vector Ownership
#[derive(Debug)]
struct Person<'a> {
name: &'a str,
role: &'a str
}
fn print_employees(employees: &Vec<Person>) {
for e in employees {
println!("{:?}", e);
}
}
fn main() {
let mut v = Vec::new();
v.push(Person{name: "Alice", role: "Manager"});
v.push(Person{name: "Bob", role: "Sales"});
v.push(Person{name: "Carol", role: "Programmer"});
print_employees(&v);
println!("v.len() = {}", v.len());
}Problem: The original code passes ownership of v to print_employees, then tries to access v after it’s been moved.
Solution: Make print_employees take a reference to the vector instead of ownership.
Lifetime Specifier 'a: The 'a in Person<'a> indicates that the references name and role must live at least as long as any instance of Person that contains them.
Example 2: Dangling References
fn smallest(v: &[i32]) -> &i32 {
let mut smallest_index = 0;
for i in 1..v.len() {
if v[i] < v[smallest_index] {
smallest_index = i;
}
}
&v[smallest_index]
}
fn main() {
let n = [12, 42, 6, 8, 15, 24];
let s = smallest(&n);
println!("{}", s);
}Problem: The original code tried to create a local variable s and return a reference to it, but the variable would be destroyed when the function returns, leaving a dangling reference.
Solution: Find the smallest element’s index in the array and return a reference to that position in the original array.
Safety Benefit: The compiler prevents returning references to data that won’t live long enough.
Example 3: Box and Heap Allocation
struct Point {
x: f32,
y: f32
}
impl Drop for Point {
fn drop(&mut self) {
println!("Dropping Point({}, {})", self.x, self.y);
}
}
struct Rectangle<'a> {
ul: &'a Point,
br: &'a Point
}
impl<'a> Rectangle<'a> {
fn area(&self) -> f32 {
let w = self.br.x - self.ul.x;
let h = self.ul.y - self.br.y;
w * h
}
}
fn main() {
let ul = Box::new(Point{x: 3.0, y: 8.0});
let br = Box::new(Point{x: 5.0, y: 4.0});
let rect = Rectangle{ul: &ul, br: &br};
let a = rect.area();
println!("area = {}", a);
}Deallocation: The memory for the boxed points will be deallocated automatically when ul and br go out of scope at the end of main(). Adding the Drop implementation confirms this by printing a message.
The Box<T> type implements Deref, allowing &Box<Point> to be coerced to &Point, which is why the example works despite Rectangle expecting references to Point, not Box<Point>.
Example 4: Move Semantics
fn main() {
let v = vec![4, 8, 19, 27, 34, 10];
let r = &v;
let aside = v; // This moves v
println!("{}", r[0]); // Error: v was moved but r still references it
println!("{}", aside[0]);
}Problem: When v is moved to aside, any references to v (like r) become invalid. This demonstrates Rust’s prevention of use-after-move errors.
Solution: Either remove the aside = v line, or create aside as a reference to v as well: let aside = &v;.
Example 5: References and Borrows
struct Point {
x: f32,
y: f32
}
fn main() {
let p = Point{x: 3.0, y: 5.0};
let x = &p.x;
let y = &p.y;
println!("x={}", x);
println!("y={}", y);
}This example works because we’re taking immutable references to fields of p, and these references don’t outlive p. Multiple immutable references are allowed simultaneously.
Unsafe Memory Usage in C
The C program contains numerous memory safety issues that Rust would prevent:
-
Stack-allocated return value:
vec_new()returns a pointer to a stack-allocated struct that becomes invalid when the function returns. -
Uninitialized memory: When capacity is 0 and growing the vector,
new_capacity = 0 * 2 = 0, resulting in allocating 0 bytes. -
Memory leak: When growing the capacity, the old
dataarray isn’t freed. -
Double free: In
vec_free(), it frees both the vector and its data, then inmain(),vec->datais freed again. -
Use after free: After freeing
vec->data,vec_free()tries to access it again. -
Iterator invalidation:
npoints into the vector’s data, but aftervec_push()is called again, the vector might reallocate, makingnpoint to memory that’s no longer valid. -
Wrong order of freeing:
vec_free()frees the vector before freeing its data, but the vector needs to be valid to access its data.
Rust’s ownership system would prevent all of these issues through compile-time checks:
- Values returned from functions are moved, not referenced
- Memory is always initialized before use
- Resources are automatically freed when they go out of scope
- Double-free is impossible because ownership is moved when freeing
- Use-after-free is caught by the borrow checker
- Iterator invalidation is prevented by borrowing rules
- Resources are dropped in the correct order automatically