In this lecture we round off our foundational knowledge of Rust itself with structs, traits, and lifetimes. We investigate how they interact with one another to create a memory safe and comprehensive OOP system.
When a variable has the same name as a field, you can use shorthand:
fncreate_user(username: String, email: String) -> User {
User {
username, // Same as `username: username`
email, // Same as `email: email`
active: true,
sign_in_count: 1,
}
}
Structs Own Their Data
This is crucial: structs own their fields. When the struct is dropped, all owned fields are dropped too.
structDocument {
title: String, // Document owns this String
content: String, // Document owns this String
}
fnmain() {
letdoc = Document {
title: String::from("My Doc"),
content: String::from("Hello, world!"),
};
// When `doc` goes out of scope here, both Strings are dropped
}
π‘ Connection to LN3: This is the ownership system at work! The struct is the owner, and when the owner is dropped, all owned values are deallocated.
Struct Update Syntax
Create a new struct instance using values from an existing one:
letuser2 = User {
email: String::from("bob@example.com"),
..user1 // Use remaining fields from user1
};
β οΈ Watch out for moves! The .. syntax moves non-Copy fields. After this, user1.username is invalid (it was moved to user2), but user1.active and user1.sign_in_count are still valid (they implement Copy).
letuser1 = User {
username: String::from("alice"),
email: String::from("alice@example.com"),
active: true,
sign_in_count: 1,
};
letuser2 = User {
email: String::from("bob@example.com"),
..user1
};
// println!("{}", user1.username); // β ERROR: username was movedprintln!("{}", user1.active); // β OK: bool is Copyprintln!("{}", user1.sign_in_count); // β OK: u64 is Copy
Field Type
Behavior with ..
Original Usable?
Copy types (bool, i32, etc.)
Copied
Yes
Non-Copy types (String, Vec)
Moved
No
Tuple Structs
Tuple structs have fields accessed by position, not name:
π‘ Key Insight: The newtype pattern gives you type safety at compile time with zero runtime overhead. Meters(100.0) and 100.0_f64 have identical memory representations.
Why use newtypes?
Benefit
Example
Type safety
Can't accidentally mix Meters and Feet
Implement foreign traits
Can implement Display for Meters even though f64 already has one
Hide implementation
Users see Meters, not f64
Unit Structs (Marker Structs)
Structs with no fieldsβthey're zero-sized types (ZSTs):
structAlwaysEqual;
structAuthenticated;
structGuest;
fnmain() {
let_marker = AlwaysEqual;
// Takes no memory at runtime!println!("Size of AlwaysEqual: {}", std::mem::size_of::<AlwaysEqual>()); // 0
}
Out-of-Band Information
This is a remarkably powerful concept: information that exists purely in the type system, not in memory. Traditional programming stores all information as bits in RAMβbut marker structs let you encode constraints, states, and properties that the compiler tracks without any runtime cost.
Think about it: in most languages, to track whether a user is authenticated, you'd store a boolean somewhere:
// Traditional approach: runtime datastructConnection {
address: String,
is_authenticated: bool, // Takes memory, can be wrong at runtime
}
But with markers, that information moves "out of band"βinto the type system itself:
// Marker approach: compile-time guaranteestructConnection<State> {
address: String,
_state: std::marker::PhantomData<State>,
}
structAuthenticated;
structGuest;
// The STATE is encoded in the TYPE, not in memory!// Connection<Authenticated> and Connection<Guest> are different types
π‘ Key Insight: This is "zero-cost abstraction" taken to its logical extreme. The marker adds zero bytes to your struct, yet provides compile-time guarantees that would otherwise require runtime checks. Bugs that would be caught at runtime (or worse, in production) are now caught at compile time.
Use cases:
Marker traits: Indicate a type has a property without storing data
Type-level state: Encode states in the type system (state machines)
Phantom types: Combined with PhantomData for advanced patterns
Permission systems: Encode access levels in the type system
Example 1: Type-State Pattern (Connection)
Ensure operations only happen in valid states:
// Type-state pattern: can only send if authenticatedstructConnection<State> {
address: String,
_state: std::marker::PhantomData<State>,
}
structDisconnected;
structConnected;
implConnection<Disconnected> {
fnconnect(self) -> Connection<Connected> {
// ... establish connection ...
Connection {
address: self.address,
_state: std::marker::PhantomData,
}
}
}
implConnection<Connected> {
fnsend(&self, data: &[u8]) {
// Can only call send() on Connected connections!
}
}
Example 2: Permission System
Encode access control at compile time:
use std::marker::PhantomData;
// Permission markers - zero-sized!structReadOnly;
structReadWrite;
structAdmin;
structFileHandle<Permission> {
path: String,
_permission: PhantomData<Permission>,
}
impl<P> FileHandle<P> {
// All permission levels can readfnread(&self) ->Vec<u8> {
println!("Reading from {}", self.path);
vec![]
}
}
implFileHandle<ReadWrite> {
// Only ReadWrite can writefnwrite(&self, data: &[u8]) {
println!("Writing to {}", self.path);
}
}
implFileHandle<Admin> {
// Admin can do everythingfnwrite(&self, data: &[u8]) {
println!("Admin writing to {}", self.path);
}
fndelete(self) {
println!("Deleting {}", self.path);
}
}
fnmain() {
letreadonly_file: FileHandle<ReadOnly> = FileHandle {
path: String::from("/etc/passwd"),
_permission: PhantomData,
};
letadmin_file: FileHandle<Admin> = FileHandle {
path: String::from("/tmp/log.txt"),
_permission: PhantomData,
};
readonly_file.read(); // β OK// readonly_file.write(&[]); // β ERROR: no method `write` for FileHandle<ReadOnly>// readonly_file.delete(); // β ERROR: no method `delete` for FileHandle<ReadOnly>
admin_file.read(); // β OK
admin_file.write(&[]); // β OK
admin_file.delete(); // β OK
}
π OS Relevance: This pattern mirrors how operating systems handle file permissionsβbut instead of checking permissions at runtime (and potentially failing), Rust checks them at compile time. Invalid operations simply don't compile.
Struct Memory Layout
Understanding memory layout is essential for systems programming.
Default Layout (repr(Rust))
Rust may reorder fields for optimization under repr(Rust); the exact layout is not a stable guarantee you should rely on:
repr(Rust) - Optimized: repr(C) - Predictable:
ββββββ¬βββββ¬βββββ¬βββββ ββββββ¬βββββ¬βββββ¬βββββ
β b β b β b β b β (u32) β a βpad βpad βpad β
ββββββΌβββββΌβββββΌβββββ€ ββββββΌβββββΌβββββΌβββββ€
β a β c βpad βpad β β b β b β b β b β (u32)
ββββββ΄βββββ΄βββββ΄βββββ ββββββΌβββββΌβββββΌβββββ€
Total: 8 bytes β c βpad βpad βpad β
ββββββ΄βββββ΄βββββ΄βββββ
Total: 12 bytes
π OS Relevance: When writing device drivers or syscall interfaces, #[repr(C)] ensures your struct layout matches what the OS expects.
Lifetimes in Structs
When a struct contains references, you must specify lifetimes:
structExcerpt<'a> {
content: &'astr,
}
fnmain() {
lettext = String::from("Call me Ishmael. Some years ago...");
letexcerpt = Excerpt {
content: &text[..15], // Borrows from `text`
};
println!("Excerpt: {}", excerpt.content);
// `excerpt` cannot outlive `text`!
}
π€ Why lifetimes? The compiler needs to ensure the reference inside the struct doesn't outlive the data it points to. The 'a annotation says "this struct cannot outlive the reference it contains."
// This won't compile:fncreate_excerpt() -> Excerpt<'static> {
lettext = String::from("temporary");
Excerpt { content: &text } // β ERROR: `text` doesn't live long enough
}
The key differentiator from C: variants can hold data:
enumMessage {
Quit, // No data (unit variant)
Move { x: i32, y: i32 }, // Named fields (struct variant)Write(String), // Single value (tuple variant)ChangeColor(u8, u8, u8), // Multiple values (tuple variant)
}
fnprocess_message(msg: Message) {
match msg {
Message::Quit => println!("Quitting"),
Message::Move { x, y } => println!("Moving to ({}, {})", x, y),
Message::Write(text) => println!("Writing: {}", text),
Message::ChangeColor(r, g, b) => println!("Color: rgb({}, {}, {})", r, g, b),
}
}
π‘ Key Insight: This is what makes Rust enums incredibly powerful. Each variant is like a mini-struct inside the enum. You can represent complex state machines, protocol messages, or AST nodes elegantly.
Enum Memory Layout
Enums usually need a discriminant (tag) plus enough space for the largest variant, though niche optimizations can change the exact representation:
enumExample {
Small(u8), // 1 byte of dataMedium(u32), // 4 bytes of dataLarge([u8; 100]), // 100 bytes of data
}
// Size = discriminant + largest variant + padding// All variants occupy the same total size!
ββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββ
β Discriminant β Variant Data β
β (1-8 bytes)β (size of largest variant) β
ββββββββββββββββ΄ββββββββββββββββββββββββββββββββββββββ
π‘ Why only the largest? This is fundamentally different from structs! A struct's fields all exist simultaneouslyβif you have username, email, and active, all three are stored in memory at once. But enum variants are mutually exclusive: a Message is eitherQuitorMoveorWriteβnever more than one at a time. Since only one variant can exist at any moment, we only need enough space for the biggest possibility. The discriminant tells us which variant is currently "active."
Type
Memory Model
Why
Struct
Sum of all fields
All fields coexist
Enum
Size of largest variant
Only one variant exists at a time
Niche Optimization
Rust is smart about memory. For Option<&T>:
// You might expect:// Option<&T> = discriminant (1+ byte) + pointer (8 bytes) = 9+ bytes// But actually:// Option<&T> = 8 bytes!// How? The null pointer (0x0) is the "niche" - it represents None// Some(&value) stores the valid pointer// None is represented as null (which can never be a valid reference)
use std::mem::size_of;
println!("Size of &i32: {}", size_of::<&i32>()); // 8println!("Size of Option<&i32>: {}", size_of::<Option<&i32>>()); // 8 (same!)
π Key Point: This is why Option<Box<T>> and Option<&T> can often have no extra size overhead compared to nullable pointers in CβRust may use the null representation for None.
Pattern Matching with Enums
The match expression is exhaustiveβyou must handle all variants:
The Option<T> enum represents a value that might or might not exist:
enumOption<T> {
Some(T), // A value is presentNone, // No value
}
Why Not Just Use Null?
In languages like C, Java, or Python, any reference can be null/None. This leads to the infamous null pointer exceptionβTony Hoare, who invented null references, called it his "billion-dollar mistake."
// C: Null pointer dereference - undefined behavior (crash, security vulnerability)char* name = get_user_name(id);
printf("%s", name); // What if name is NULL? π₯// Java: NullPointerException at runtime
String name = getUserName(id);
System.out.println(name.length()); // What if name is null? π₯
Rust's approach: make absence explicit in the type system.
// Rust: The type TELLS you it might be absentfnget_user_name(id: u64) ->Option<String> { ... }
letname = get_user_name(id);
// println!("{}", name.len()); // β ERROR: Option<String> has no method `len`// You MUST handle the None casematch name {
Some(n) => println!("{}", n.len()),
None => println!("No user found"),
}
π‘ Key Insight: With Option, you can't accidentally use a value that might not exist. The compiler forces you to handle the None case.
Common Option Patterns
Pattern 1: Match
fndescribe_number(n: Option<i32>) ->String {
match n {
Some(x) if x > 0 => format!("{} is positive", x),
Some(x) if x < 0 => format!("{} is negative", x),
Some(0) => String::from("zero"),
None => String::from("no number provided"),
}
}
Pattern 2: if let (when you only care about Some)
letconfig_max = Some(3u8);
// Instead of match with a throwaway arm:ifletSome(max) = config_max {
println!("Maximum configured as {}", max);
}
Pattern 3: Unwrap methods
letx: Option<i32> = Some(5);
lety: Option<i32> = None;
// unwrap: Get value or panic (use sparingly!)letval = x.unwrap(); // 5// expect: Panic with custom messageletval = x.expect("x should have a value"); // 5// unwrap_or: Provide defaultletval = y.unwrap_or(0); // 0// unwrap_or_else: Compute default lazilyletval = y.unwrap_or_else(|| expensive_computation());
// unwrap_or_default: Use Default traitletval: i32 = y.unwrap_or_default(); // 0
Pattern 5: The ? operator (in functions returning Option)
fnget_first_word_length(text: Option<&str>) ->Option<usize> {
lettext = text?; // Return None early if text is Noneletfirst_word = text.split_whitespace().next()?; // Return None if no wordsSome(first_word.len())
}
// Equivalent to:fnget_first_word_length_verbose(text: Option<&str>) ->Option<usize> {
match text {
None => None,
Some(text) => match text.split_whitespace().next() {
None => None,
Some(first_word) => Some(first_word.len()),
}
}
}
Result: Handling Errors
The Result<T, E> enum represents an operation that might fail:
enumResult<T, E> {
Ok(T), // Success, with value of type TErr(E), // Failure, with error of type E
}
Why Not Just Use Exceptions?
Exceptions (try/catch) have problems:
Invisible control flow: Any function might throw, but you can't tell from the signature
Easy to forget: Uncaught exceptions crash your program
Performance overhead: Exception machinery has runtime cost
Rust's approach: errors are values, returned explicitly.
use std::fs::File;
use std::io::{self, Read};
// The return type TELLS you this can failfnread_file_contents(path: &str) ->Result<String, io::Error> {
letmut file = File::open(path)?; // Returns Err early if file doesn't existletmut contents = String::new();
file.read_to_string(&mut contents)?; // Returns Err early if read failsOk(contents)
}
π‘ Key Insight: You can't ignore a Result. If you try to use it without handling the error case, the compiler warns you. Errors are part of the API contract.
π OS Relevance: System calls can fail for many reasonsβfile not found, permission denied, resource exhausted. Rust's Result type makes these failures explicit, forcing you to handle them rather than silently ignoring return codes (a common source of bugs in C programs).
π OS Relevance: The Drop trait is essential for RAII (Resource Acquisition Is Initialization). File handles, mutexes, network socketsβanything that needs cleanup implements Drop.
Standard Library Types
Smart Pointers
Smart pointers are structs that implement Deref and Drop:
Box<T> β Heap Allocation
fnmain() {
// Allocate an i32 on the heapletboxed = Box::new(5);
println!("Boxed value: {}", *boxed);
// Useful for recursive typesenumList {
Cons(i32, Box<List>),
Nil,
}
}