DOP not OOP
You know Object-Oriented Programming (OOP) - it's the paradigm behind C#, Java, and most mainstream languages.
Data-Oriented Programming (DOP) is a different way of thinking. It's not new - game developers and systems programmers have used it for decades - but Rust makes it the default.
The Core Idea
In OOP: Objects encapsulate data and behavior together
public class Player {
private int health;
private string name;
public Player(string name) {
this.name = name;
this.health = 100;
}
public void TakeDamage(int amount) {
health -= amount;
}
}
In DOP: Data describes what things are. Behavior describes what things do, and these are separate by design.
// Data: what a Player IS
struct Player {
health: i32,
name: String,
}
The struct just defines the shape of the data - nothing more. No constructors run, no base classes initialize.
// Behavior: what a Player DOES
impl Player {
fn new(name: String) -> Player {
Player { health: 100, name }
}
fn take_damage(&mut self, amount: i32) {
self.health -= amount;
}
}
The impl block adds behavior separately. Notice how &mut self is very explicit syntax, indicating we accept a mutable reference to self as a parameter.
struct Player { health: i32, name: String, } impl Player { fn new(name: String) -> Player { Player { health: 100, name } } fn take_damage(&mut self, amount: i32) { self.health -= amount; } } fn main() { let mut player = Player::new(String::from("Alice")); println!("Starting health: {}", player.health); player.take_damage(25); println!("After damage: {}", player.health); }
Why This Matters
This separation is intentional. It enables:
- Composability: Behavior can be added to types after the fact. Data can be combined with other data without worrying about how behaviors conflict.
- Testability: Data is transparent and inspectable. Most behaviors transform data with minimal (or even no) side-effects.
- Performance: Data layout can be optimized independently of behavior
Rust's "restrictions" (ownership, borrowing, lifetimes) are natural consequences of thinking about data first.
Data Structures are Just Data
In Rust, structs and enums describe data layout directly - no constructors, no hidden state, no inheritance.
Structs: Named Fields
A struct is just named fields. No constructor runs, no base class is initialized.
struct Player {
name: String,
health: i32,
position: (f32, f32),
}
Creating one is straightforward - just fill in the fields:
struct Player { name: String, health: i32, position: (f32, f32), } fn main() { let player = Player { name: String::from("Alice"), health: 100, position: (0.0, 0.0), }; // Fields are directly accessible (within the same module) println!("{} has {} health at position {:?}", player.name, player.health, player.position); }
No new keyword required. The data is transparent - you can see exactly what's there.
Enums: Algebraic Data Types
Rust enums are more powerful than C# enums. Each variant can hold different data.
This is called an "algebraic data type" - something C# doesn't have natively... yet.
enum GameEvent {
PlayerJoined { name: String },
PlayerMoved { x: f32, y: f32 },
DamageTaken { amount: i32, source: String },
PlayerDied,
}
Each variant is a distinct shape. PlayerJoined carries a name. PlayerMoved carries coordinates. PlayerDied carries nothing.
Pattern Matching
Pattern matching destructures the data - no casting, no type checking at runtime:
enum GameEvent { PlayerJoined { name: String }, PlayerMoved { x: f32, y: f32 }, DamageTaken { amount: i32, source: String }, PlayerDied, } fn main() { let event = GameEvent::DamageTaken { amount: 25, source: String::from("Goblin"), }; match event { GameEvent::PlayerJoined { name } => { println!("{} joined the game", name); } GameEvent::PlayerMoved { x, y } => { println!("Player moved to ({}, {})", x, y); } GameEvent::DamageTaken { amount, source } => { println!("Took {} damage from {}", amount, source); } GameEvent::PlayerDied => { println!("Player died!"); } } }
The compiler ensures you handle all variants. Try commenting out a match arm - the compiler will tell you what you missed.
This is exhaustive matching - a safety net that C# switch statements don't provide.
Visibility and Encapsulation
Rust still has encapsulation, but it works differently than C#.
mod game {
pub struct Player {
health: i32, // private to this module
pub name: String, // visible outside this module
}
impl Player {
pub fn new(name: String) -> Player {
Player { name, health: 100 }
}
pub fn health(&self) -> i32 {
self.health
}
}
}
Rust's visibility modifiers work very differently from C#'s. In C#, visibility is tied to classes and assemblies (private, internal, public). In Rust, visibility is based almost exclusively on modules.
The default in Rust is module-private - items are visible within their module and any child modules. Adding pub makes an item visible outside its module. This is fundamentally different from C#'s class-based privacy model.
Rust also has pub(crate), which makes items visible throughout the current crate but not to external crates. However, this is somewhat of an escape hatch for situations where you can't easily express visibility using just the module hierarchy. In practice, if you have internal APIs that should be visible across your crate but not exported publicly, it's often better to organize them into a private module in your crate and export the necessary APIs as pub from that module, rather than liberally using pub(crate) everywhere.
Think about visibility in terms of module boundaries first, and your public API surface in lib.rs at the top.
Module-Private vs Class-Private
The key difference: Rust's default visibility is module-private, not class-private.
mod game { pub struct Player { health: i32, pub name: String, } impl Player { pub fn new(name: String) -> Player { Player { name, health: 100 } } pub fn health(&self) -> i32 { self.health } } } fn main() { let player = game::Player::new(String::from("Alice")); // This works - name is pub println!("Name: {}", player.name); // This works - we have a public accessor println!("Health: {}", player.health()); // This would fail - health is private to the game module: println!("Direct health: {}", player.health); }
This encourages organizing related code into modules rather than hiding everything inside individual types. It also encourages thinking locally about visibility. Think about whether a given struct/enum/function/module needs to be visible outside its module. Then, think about your public API surface in the lib.rs at the top.
Behavior Lives in Impl Blocks
Behavior is separate from data - it lives in impl blocks. You can have multiple impl blocks for the same type, organized however makes sense.
struct Player {
name: String,
health: i32,
max_health: i32,
}
Associated Functions
Associated functions don't take self - they're like static methods in C#. By convention, new is the constructor name:
impl Player {
fn new(name: String) -> Player {
Player {
name,
health: 100,
max_health: 100,
}
}
}
In C#, static methods are often used because there are no free functions - everything must belong to a class. This leads to "static utility classes" that are really just namespaces for functions:
// C# - static class as a container for functions
public static class MathUtils {
public static double Lerp(double a, double b, double t) { ... }
public static double Clamp(double value, double min, double max) { ... }
}
In Rust, the equivalent of a C# static class is simply a module (mod) with free functions and constants. Types should be containers for data (and highly related behavior), not just containers for behavior:
// Rust - module with free functions
mod math_utils {
pub fn lerp(a: f64, b: f64, t: f64) -> f64 {
a + (b - a) * t
}
pub fn clamp(value: f64, min: f64, max: f64) -> f64 {
if value < min { min } else if value > max { max } else { value }
}
}
Use associated functions on types when the function is conceptually tied to the type itself (like constructors, or type-specific utilities). Use free functions in modules when the behavior is more general-purpose or doesn't conceptually "belong" to a single type.
The Self Parameter
The self parameter is explicit - there's no hidden this. This tells you exactly how data flows:
impl Player {
// &self = borrows immutably (read-only access)
fn is_alive(&self) -> bool {
self.health > 0
}
In C#, methods are an inherent part of a class - this is magical and implicit. In Rust, methods are really just syntax sugar for bare functions that happen to take Self as their first parameter:
impl Player {
// This method...
fn is_alive(&self) -> bool {
self.health > 0
}
}
// ...is essentially equivalent to this free function:
fn is_alive(player: &Player) -> bool {
player.health > 0
}
The only difference is namespacing and call syntax. Both work:
struct Player { name: String, health: i32, max_health: i32, } impl Player { fn new(name: String) -> Player { Player { name, health: 100, max_health: 100 } } } impl Player { fn take_damage(&mut self, amount: i32) { self.health = (self.health - amount).max(0); } } fn main() { let mut player = Player::new(String::from("Alice")); // Method syntax - what you'll usually write player.take_damage(10); // But this is exactly equivalent - calling it as a function! Player::take_damage(&mut player, 20); println!("{} has {} health", player.name, player.health); }
This isn't just an implementation detail - it reinforces that behavior is attached to data, not embedded in it. The impl block is organizational, not fundamental.
Self Variants
The three forms of self control ownership, just like any other parameter:
impl Player {
// &self = borrows immutably (read-only access)
fn is_alive(&self) -> bool {
self.health > 0
}
fn health_percentage(&self) -> f32 {
self.health as f32 / self.max_health as f32 * 100.0
}
// &mut self = borrows mutably (read-write access)
fn heal(&mut self, amount: i32) {
self.health = (self.health + amount).min(self.max_health);
println!("{} heals {} HP! Health: {}", self.name, amount, self.health);
}
// self (no &) = takes ownership, consuming the value
// after this call, self is destroyed and can't be used again
fn into_name(self) -> String {
self.name
}
}
In Rust, there are conventional method name prefixes used to indicate some of these ownership semantics:
| Prefix | Parameter | Meaning |
|---|---|---|
| into_ | self | consumes and transforms |
| as_ | &self | converts a reference to ourself into another type |
| to_ | &self | creates an owned copy of ourself as a different type |
This does not mean that all methods with this kind of self parameter use this prefix, but it's a common pattern used for transformative methods.
This even goes beyond just ownership and standard borrows. You can have self parameters use other smart pointers too, to enforce certain usage patterns:
use std::sync::Arc; pub struct Cache { val: usize } impl Cache { pub fn read(self: Arc<Self>) -> usize { self.val } } pub fn main() { let cache = Arc::new(Cache { val: 42 }); println!("Value: {:?}", cache.read()); }
The main function would fail to compile if we were to call cache.read() without wrapping self in Arc:
use std::sync::Arc; pub struct Cache { val: usize } impl Cache { pub fn read(self: Arc<Self>) -> usize { self.val } } pub fn main() { let cache = Cache { val: 42 }; println!("Value: {:?}", cache.read()); }
Use this with care - You're enforcing a specific ownership model on users of your type. Only do this when it's really necessary for safety or correctness.
Using Methods
struct Player { name: String, health: i32, max_health: i32, } impl Player { fn new(name: String) -> Player { Player { name, health: 100, max_health: 100 } } fn is_alive(&self) -> bool { self.health > 0 } fn health_percentage(&self) -> f32 { self.health as f32 / self.max_health as f32 * 100.0 } fn take_damage(&mut self, amount: i32) { self.health = (self.health - amount).max(0); } fn heal(&mut self, amount: i32) { self.health = (self.health + amount).min(self.max_health); println!("{} heals {} HP! Health: {}", self.name, amount, self.health); } fn into_name(self) -> String { self.name } } fn main() { // Call associated function with :: syntax let mut player = Player::new(String::from("Alice")); // &self methods - can call multiple times, player is just borrowed println!("{} at {}% health", player.name, player.health_percentage()); println!("Alive? {}", player.is_alive()); // &mut self method - needs `mut` binding to call player.heal(10); // You can also call methods as functions - it's the same thing! Player::take_damage(&mut player, 30); // self method - consumes the player let name = player.into_name(); println!("Player's name was: {}", name); // Uncommenting this would fail - player was consumed: // println!("{}", player.health); // ERROR: borrow of moved value: `player` }
Function signatures tell you everything about how data flows.
Newtypes
A newtype (a term that originates from the Haskell functional programming language) is a single-field tuple struct that wraps another type. It's one of Rust's most powerful patterns for creating richer, more expressive types.
struct UserId(u64); struct PostId(u64); struct Timestamp(u64); fn main() { let user = UserId(42); let post = PostId(123); // These are distinct types, even though both wrap u64 // let wrong: UserId = post; // ERROR: mismatched types }
Why Newtypes?
In many systems, you'll have multiple concepts that share the same underlying representation but have different semantic meanings. Without newtypes, it's easy to mix them up:
// Without newtypes - easy to confuse! fn get_user(user_id: u64) -> String { format!("User {}", user_id) } fn get_post(post_id: u64) -> String { format!("Post {}", post_id) } fn main() { let user_id = 42; let post_id = 123; // Oops! Passed the wrong ID - compiler can't help println!("{}", get_user(post_id)); // Compiles but wrong! }
With newtypes, the type system catches this mistake:
struct UserId(u64); struct PostId(u64); fn get_user(user_id: UserId) -> String { format!("User {}", user_id.0) } fn get_post(post_id: PostId) -> String { format!("Post {}", post_id.0) } fn main() { let user_id = UserId(42); let post_id = PostId(123); println!("{}", get_user(user_id)); // Correct // println!("{}", get_user(post_id)); // ERROR: expected UserId, found PostId }
The .0 syntax accesses the first (and only) field of the tuple struct.
Zero-Cost Abstraction
Newtypes are a zero-cost abstraction - they exist only at compile time for type checking. At runtime, they compile down to the inner type with no overhead:
use std::mem::size_of; struct Meters(f64); struct Seconds(f64); fn main() { let distance = Meters(100.0); let time = Seconds(9.58); // At runtime, these are just f64 values // No wrapper, no indirection, no cost println!("Size of f64: {} bytes", size_of::<f64>()); println!("Size of Meters: {} bytes", size_of::<Meters>()); println!("Size of Seconds: {} bytes", size_of::<Seconds>()); // All print: 8 bytes }
This means you get type safety without sacrificing performance.
Adding Behavior to Newtypes
Newtypes are full-fledged types, so you can implement methods and traits on them:
struct Celsius(f64); struct Fahrenheit(f64); impl Celsius { fn new(temp: f64) -> Self { Celsius(temp) } fn into_fahrenheit(self) -> Fahrenheit { Fahrenheit(self.0 * 9.0 / 5.0 + 32.0) } } impl Fahrenheit { fn new(temp: f64) -> Self { Fahrenheit(temp) } fn into_celsius(self) -> Celsius { Celsius((self.0 - 32.0) * 5.0 / 9.0) } } fn main() { let temp_c = Celsius::new(-5.0); let temp_f = temp_c.into_fahrenheit(); println!("-5°C = {}°F", temp_f.0); let temp_f2 = Fahrenheit::new(98.6); let temp_c2 = temp_f2.into_celsius(); println!("98.6°F = {}°C", temp_c2.0); }
This lets you attach domain-specific logic directly to your types, and prevents mixing different units.
Deriving Traits
You can derive standard traits to make newtypes more ergonomic:
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] struct UserId(u64); #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] struct Score(i32); use std::collections::HashMap; fn main() { let user1 = UserId(1); let user2 = UserId(2); // Can use in collections let mut scores = HashMap::new(); scores.insert(user1, Score(100)); scores.insert(user2, Score(250)); // Can compare println!("User 1 score: {:?}", scores.get(&user1)); let s1 = Score(100); let s2 = Score(200); println!("Higher score: {:?}", s1.max(s2)); }
When to Use Newtypes
Good use cases for newtypes:
- Distinct IDs:
UserId,OrderId,SessionId- prevent mixing different kinds of IDs - Units:
Meters,Seconds,Bytes- encode units in the type system - Validation: Wrap types that should only be constructed through validation (by keeping the inner field private)
- Semantic meaning:
Email,PhoneNumber,Url- more expressive thanString - Orphan rule workaround: Implement foreign traits for foreign types (we'll talk about that later)
Transparent Newtypes
Sometimes you want the newtype to have the exact same memory layout as the inner type. The #[repr(transparent)] attribute guarantees this:
#[repr(transparent)] struct Seconds(f64); fn main() { let time = Seconds(3.14); // The memory layout is guaranteed to be identical to f64 }
This attribute ensures that the newtype has the same memory representation as its inner type, enabling safe transmutation between them. This is particularly useful for FFI (Foreign Function Interface) where you need to pass Rust types to C functions but want the type safety of newtypes in your Rust code.
Without #[repr(transparent)], the compiler might add padding or change the layout. With it, you're guaranteed that Seconds and f64 are layout-compatible.
The Pattern
Newtypes embody data-oriented thinking: use the type system to capture domain invariants. Instead of relying on conventions ("this u64 is always a user ID"), encode that directly in the type. The compiler becomes your ally in preventing bugs.
In C#, you might use interfaces, base classes, or lots of defensive checks. In Rust, you use newtypes to create lightweight, zero-cost, compiler-enforced distinctions between semantically different data.
The Ownership Rules
In C#, you rarely think about who "owns" data. Objects live on the heap, the garbage collector cleans them up, and references are cheap to copy. Rust doesn't work that way.
The Three Rules
- Every value has exactly one owner
- When the owner goes out of scope, the value is dropped
- Assignment moves ownership (for most types)
These rules eliminate garbage collection while preventing memory leaks and use-after-free bugs.
Move Semantics: The Surprise
In C#, this code works fine - both variables reference the same object:
var items = new List<int> { 1, 2, 3 };
var other = items; // Both point to the same list
items.Add(4); // Still works!
In Rust, assignment moves the value:
fn main() { let items = vec![1, 2, 3]; let other = items; // Ownership moves to `other` // items is no longer valid here! println!("{:?}", other); // This works }
Using a Moved Value
What happens if we try to use items after the move?
fn main() { let items = vec![1, 2, 3]; let other = items; // Ownership moves to `other` println!("{:?}", items); // ERROR: borrow of moved value: `items` }
The compiler catches this at compile time. No null pointer exceptions, no use-after-free - just a clear error message.
Why Move by Default?
This prevents a whole class of bugs:
- No dangling pointers (data freed while still referenced)
- No double frees (two owners trying to clean up)
- No data races (clear ownership = clear responsibility)
The cost? You have to think about ownership explicitly.
Copy Types: The Exception
Some types are simple enough to copy cheaply. These implement the Copy trait:
fn main() { let x: i32 = 42; let y = x; // Copy, not move - x is still valid! println!("x = {}, y = {}", x, y); // Both work fine }
Copy types include:
- All integer types (
i32,u64, etc.) - Floating point (
f32,f64) boolandchar- Tuples and arrays of Copy types
What Makes Something Copy?
A type can be Copy if it:
- Lives entirely on the stack (no heap allocations)
- Has no resources to clean up (no destructors needed)
String can't be Copy because it owns heap memory. Copying it would mean... what? Two owners of the same heap buffer? That's exactly what Rust prevents.
fn main() { // This is Copy - just 8 bytes on the stack let point: (i32, i32) = (10, 20); let other = point; println!("{:?} and {:?}", point, other); // Both valid }
But String is NOT Copy - it owns heap data:
fn main() { let name = String::from("Alice"); let other_name = name; println!("{}", name); // ERROR: borrow of moved value: `name` }
Functions and Ownership
Passing a value to a function also moves it:
struct Player { name: String, health: i32, } fn print_player(p: Player) { println!("{} has {} health", p.name, p.health); } // p is dropped here fn main() { let player = Player { name: String::from("Alice"), health: 100, }; print_player(player); // Ownership moves into the function }
After the call, player is no longer valid:
struct Player { name: String, health: i32, } fn print_player(p: Player) { println!("{} has {} health", p.name, p.health); } fn main() { let player = Player { name: String::from("Alice"), health: 100, }; print_player(player); print_player(player); // ERROR: use of moved value: `player` }
The Mental Model Shift
C# thinking: "Variables are labels pointing to objects. Multiple labels can point to the same object."
Rust thinking: "Variables own values. Ownership can transfer, but there's always exactly one owner responsible for cleanup."
This isn't a limitation - it's making explicit what was always true. Someone has to be responsible for memory. In C#, it's the GC. In Rust, it's the owner.
Moves Are Free
Coming from C#, you might worry that all this moving is expensive. In C#, passing objects around is cheap (just copying a reference), but creating new objects, copying data, and cloning all trigger allocations and eventually garbage collection.
In Rust, it's the opposite: moves are essentially free.
What Actually Happens in a Move
When you move a value in Rust, the compiler just copies the bytes and forgets about the original:
fn main() { let player_name = String::from("Alice"); // This "move" is just copying 24 bytes (pointer, length, capacity) // The heap data ("Alice") doesn't move at all! let name = player_name; println!("{}", name); }
A String is just three values on the stack: a pointer, a length, and a capacity. Moving it copies those 24 bytes. The actual string data on the heap stays exactly where it is.
Even Better: Move Elision
Here's the secret: most moves don't even copy bytes. The compiler is smart.
fn create_name() -> String { let name = String::from("Alice"); // Created here name // "Moved" to caller } fn main() { let player_name = create_name(); // Where does the String live? println!("{}", player_name); }
In theory, name is created in create_name's stack frame, then moved to main's stack frame. In practice? The compiler uses return value optimization - it reserves the space for player_name in main's stack frame as usual, and create_name is able to actually write it's return value directly into that space. No copying of bytes needed at all!
This works for function arguments too:
struct Player { name: String, health: i32, } fn process_player(p: Player) { println!("Processing {} with {} health", p.name, p.health); } fn main() { process_player(Player { name: String::from("Alice"), health: 100, }); }
Again, the Player struct is created directly in the stack space allocated for p inside process_player. No copying of bytes needed.
The "move" in the source code is a semantic concept - it tells the compiler about ownership. The actual bytes often don't move at all. Move semantics actually make these optimizations trivial and reliable, whereas other languages have to perform aggressive escape analysis to figure out when they can avoid copies.
NOTE: This is also why you'll hear people say "Rust has no ABI". The exact calling conventions and stack layouts are not part of the language specification, because the compiler is free to optimize them as it sees fit. This makes it very difficult to call Rust code that wasn't compiled together into a single binary by the exact same compiler, which makes plugin models very challenging. It's also why most Rust code is shipped as source code. Dynamic loading and reflection usually depend on artificial interfaces like exporting a C ABI from a Rust library.
No Hidden Costs
In C#, passing an object to a method or returning it has hidden costs:
// C# - what's really happening?
Player CreatePlayer(string name) {
return new Player(name); // Allocation!
}
void ProcessPlayer(Player p) {
// Does ProcessPlayer retain a reference to p?
}
In Rust, you see exactly what happens:
struct Player { name: String, health: i32, } // Returns by value - but it's just copying bytes, not allocating fn create_player(name: String) -> Player { Player { name, health: 100 } } // Takes ownership - just copies the Player struct bytes fn process_player(p: Player) { println!("Processing {}", p.name); } // p is dropped here - memory freed fn main() { let name = String::from("Alice"); let player = create_player(name); // name moved into function process_player(player); // player moved into function // No garbage collector, no reference counting // Memory is freed deterministically when values are dropped }
Extracting Fields is a Move
You can move a field out of a struct, leaving the struct partially invalid:
struct Player { name: String, health: i32, } fn main() { let player = Player { name: String::from("Alice"), health: 100, }; // Move the name out of the player let extracted_name = player.name; // player.name is now invalid, but player.health is fine println!("Health: {}", player.health); println!("Name: {}", extracted_name); }
This is extremely cheap - we're just moving ownership of the String, not copying any data.
But you can't use player as a whole anymore:
#[derive(Debug)] struct Player { name: String, health: i32, } fn main() { let player = Player { name: String::from("Alice"), health: 100, }; let extracted_name = player.name; println!("{:?}", player); // ERROR: borrow of partially moved value: `player` }
C# Comparison
In C#, extracting a value from an object is different:
// C# - strings are immutable and reference-counted
class Player {
public string Name { get; set; }
public int Health { get; set; }
}
var player = new Player { Name = "Alice", Health = 100 };
var name = player.Name; // Now two references to the same string
// Both are still valid - but there's hidden bookkeeping
// The string stays alive until BOTH references are gone
Rust's move semantics mean there's always exactly one owner. No reference counting, no garbage collector deciding when to clean up.
Destructuring Moves Multiple Fields
You can destructure a struct to move all its fields at once:
struct Player { name: String, health: i32, } fn main() { let player = Player { name: String::from("Alice"), health: 100, }; // Destructure - moves name, copies health (it's Copy) let Player { name, health } = player; println!("Name: {}, Health: {}", name, health); // player is completely consumed - all fields moved out }
The Mental Model
Think of Rust values as physical objects you hold in your hands:
- Moving is like handing someone a box - now they have it, you don't
- It's instant - no copying the contents, just transferring possession
- The contents (heap data) stay where they are inside the box
In C#, everything is more like sticky notes with addresses. You're not holding the object - you're holding a note that says "the real thing is at location 0x7fff42". Anyone can copy the sticky note. A cleaning service (the garbage collector) periodically checks if any sticky notes still point to each location before cleaning up.
Rust's approach is simpler and faster, but requires you to think about who holds what.
Borrowing: Temporary Access
Moving ownership everywhere would be impractical. You'd have to return values from every function just to keep using them. Borrowing solves this.
References: Borrowing Without Taking
A reference lets you borrow a value without taking ownership:
struct Player { name: String, health: i32, } fn print_player(p: &Player) { // Takes a reference println!("{} has {} health", p.name, p.health); } // p goes out of scope, but it was just borrowing - nothing is dropped fn main() { let player = Player { name: String::from("Alice"), health: 100, }; print_player(&player); // Lend the player print_player(&player); // Can lend again - we still own it! println!("Still ours: {}", player.name); }
The & means "borrow" - you can look, but you don't own it.
Immutable Borrows
By default, borrows are immutable - you can read but not modify:
struct Player { name: String, health: i32, } fn try_to_damage(p: &Player) { p.health -= 10; // ERROR: cannot assign to `p.health`, which is behind a `&` reference } fn main() { let player = Player { name: String::from("Alice"), health: 100, }; try_to_damage(&player); }
Mutable Borrows
To modify borrowed data, you need a mutable borrow:
struct Player { name: String, health: i32, } fn damage(p: &mut Player, amount: i32) { p.health -= amount; println!("{} takes {} damage, now at {} health", p.name, amount, p.health); } fn main() { let mut player = Player { // Note: the binding must also be mut name: String::from("Alice"), health: 100, }; damage(&mut player, 30); damage(&mut player, 25); println!("Final health: {}", player.health); }
Two requirements:
- The variable must be declared
mut - The borrow must be
&mut
The Aliasing XOR Mutability Rule
Here's the key insight: You can have either:
- Any number of immutable borrows (
&T), OR - Exactly one mutable borrow (
&mut T)
But never both at the same time.
fn main() { let mut data = vec![1, 2, 3]; let r1 = &data; // Immutable borrow - OK let r2 = &data; // Another immutable borrow - OK println!("{:?} {:?}", r1, r2); // Using both - OK let r3 = &mut data; // Mutable borrow - OK (r1 and r2 are no longer used) r3.push(4); println!("{:?}", r3); }
Why This Rule?
This rule prevents data races at compile time:
fn main() { let mut data = vec![1, 2, 3]; let r1 = &data; // Immutable borrow let r2 = &mut data; // ERROR: cannot borrow `data` as mutable because // it is also borrowed as immutable println!("{:?}", r1); // r1 is still in use here }
If this compiled, r2 could modify the vector while r1 is reading it. The compiler prevents this entirely.
Borrow Scopes: Non-Lexical Lifetimes
Borrows end when they're last used, not at the end of the block:
fn main() { let mut data = vec![1, 2, 3]; let r1 = &data; println!("{:?}", r1); // Last use of r1 // r1's borrow ends here, so we can now mutate let r2 = &mut data; r2.push(4); println!("{:?}", r2); }
The compiler tracks where borrows are actually used, not just where variables are declared.
Common Borrow Checker Errors
Borrowing while iterating
fn main() { let mut items = vec![1, 2, 3, 4, 5]; for item in &items { if *item == 3 { // items.push(6); // ERROR: cannot borrow `items` as mutable // because it is also borrowed as immutable } } // Fix: collect what to do, then do it let should_add = items.iter().any(|&x| x == 3); if should_add { items.push(6); } println!("{:?}", items); }
Returning references to local data
struct Player { name: String, health: i32, } fn create_player() -> &Player { let p = Player { name: String::from("Alice"), health: 100 }; &p // ERROR: returns a reference to data owned by the current function } fn main() { let player = create_player(); println!("{}", player.name); }
Fix: return the owned value:
struct Player { name: String, health: i32, } fn create_player() -> Player { Player { name: String::from("Alice"), health: 100 } } fn main() { let player = create_player(); println!("{}", player.name); }
The Borrowing Mental Model
Think of it like a library:
- Owning = You bought the book. You can read it, write in it, or throw it away.
- Immutable borrow (
&T) = You borrowed the book from the library. Others can borrow it too. You can read it but not write in it. - Mutable borrow (
&mut T) = You have exclusive checkout. No one else can borrow it while you have it, but you can write in it.
The library (compiler) enforces these rules to prevent chaos.
Shared Ownership
Sometimes you genuinely need multiple owners of the same data. Rust provides escape hatches for this - but they come with trade-offs.
Rc: Reference Counting (Single-Threaded)
Rc<T> (Reference Counted) allows multiple owners of the same data:
use std::rc::Rc; fn main() { let data = Rc::new(vec![1, 2, 3]); let owner1 = Rc::clone(&data); // Increment reference count let owner2 = Rc::clone(&data); // Increment again println!("Reference count: {}", Rc::strong_count(&data)); // 3 println!("All see same data: {:?}", owner2); drop(owner1); println!("After drop: {}", Rc::strong_count(&data)); // 2 } // data is freed when the last Rc is dropped
Rc::clone doesn't clone the data - it just increments the count. Cheap!
The Catch: Rc is Immutable
Rc gives you shared ownership, but the data is immutable:
use std::rc::Rc; fn main() { let data = Rc::new(vec![1, 2, 3]); data.push(4); // ERROR: cannot borrow as mutable println!("{:?}", data); }
Multiple owners means we can't have &mut - that would violate the borrowing rules!
RefCell: Runtime Borrow Checking
RefCell<T> moves borrow checking from compile time to runtime:
use std::cell::RefCell; fn main() { let data = RefCell::new(vec![1, 2, 3]); // Borrow mutably at runtime data.borrow_mut().push(4); data.borrow_mut().push(5); // Borrow immutably to read let borrow1 = data.borrow(); println!("{:?}", borrow1); // [1, 2, 3, 4, 5] let borrow2 = data.borrow_mut(); // PANIC: already borrowed! // This is here to keep the first borrow alive println!("{:?}", borrow1); }
This is a runtime panic, not a compile error. You've traded safety guarantees for flexibility.
Rc + RefCell: Shared Mutable State
Combine them for multiple owners with mutation:
use std::rc::Rc; use std::cell::RefCell; fn main() { let shared = Rc::new(RefCell::new(vec![1, 2, 3])); let owner1 = Rc::clone(&shared); let owner2 = Rc::clone(&shared); // Either owner can mutate owner1.borrow_mut().push(4); owner2.borrow_mut().push(5); println!("{:?}", shared.borrow()); // [1, 2, 3, 4, 5] }
This is Rust's closest equivalent to C#'s reference semantics. Use sparingly - you're opting out of compile-time safety.
Arc: Thread-Safe Reference Counting
Rc is not thread-safe. For multi-threaded code, use Arc (Atomic Reference Counted):
use std::sync::Arc; use std::thread; fn main() { let data = Arc::new(vec![1, 2, 3]); let data_clone = Arc::clone(&data); let handle = thread::spawn(move || { println!("From thread: {:?}", data_clone); }); println!("From main: {:?}", data); handle.join().unwrap(); }
Arc has slightly more overhead than Rc (atomic operations), so use Rc when you don't need thread safety.
Arc is Also Immutable
Just like Rc, Arc only gives you shared immutable access, because we need to protect against data races:
use std::sync::Arc;
fn main() {
let data = Arc::new(vec![1, 2, 3]);
data.push(4); // ERROR: cannot borrow as mutable
}
For thread-safe mutation, you need a lock.
Mutex: Mutual Exclusion
Mutex<T> provides exclusive access across threads:
use std::sync::{Arc, Mutex}; use std::thread; fn main() { let counter = Arc::new(Mutex::new(0)); let mut handles = vec![]; for _ in 0..10 { let counter = Arc::clone(&counter); let handle = thread::spawn(move || { let mut num = counter.lock().unwrap(); *num += 1; }); handles.push(handle); } for handle in handles { handle.join().unwrap(); } println!("Final count: {}", *counter.lock().unwrap()); // 10 }
lock() blocks until you have exclusive access. The lock is released when the guard is dropped.
NOTE: Unlike in C#, locks in Rust wrap the data to be protected by the lock. This ensures that you must hold the lock to access the data and that the lock will automatically be released when you're done.
RwLock: Multiple Readers OR One Writer
RwLock<T> allows many readers or one writer (like the borrow rules!):
use std::sync::{Arc, RwLock}; use std::thread; fn main() { let data = Arc::new(RwLock::new(vec![1, 2, 3])); // Multiple readers can access simultaneously let data1 = Arc::clone(&data); let data2 = Arc::clone(&data); let reader1 = thread::spawn(move || { let read = data1.read().unwrap(); println!("Reader 1: {:?}", *read); }); let reader2 = thread::spawn(move || { let read = data2.read().unwrap(); println!("Reader 2: {:?}", *read); }); // Writer needs exclusive access { let mut write = data.write().unwrap(); write.push(4); println!("Writer added 4"); } reader1.join().unwrap(); reader2.join().unwrap(); }
Use RwLock when reads are much more common than writes. Rust's immutable/mutable borrowing rules help enforce safe access patterns here. The compiler will not allow you to mutate data within an RwLock unless you hold the write lock.
The Hierarchy of Escape Hatches
| Need | Single-Threaded | Multi-Threaded |
|---|---|---|
| Shared ownership | Rc<T> | Arc<T> |
| Interior mutability | RefCell<T> | Mutex<T> / RwLock<T> |
| Both | Rc<RefCell<T>> | Arc<Mutex<T>> / Arc<RwLock<T>> |
When to Use These
Prefer normal ownership and borrowing. These types are escape hatches, not defaults.
Good uses:
- Graph structures with cycles
- Shared configuration across a system
- Caches that multiple components read/write
- Thread-safe shared state
Code smells:
- Using
Rc<RefCell<T>>everywhere - Fighting the borrow checker on every function
- Runtime panics from RefCell violations
If you're reaching for these constantly, consider whether a different design (like arenas with indices) might work better.
Traits as Shared Behavior
We've seen how Rust separates data (structs) from behavior (impl blocks). But what if multiple types need the same behavior? That's where traits come in.
Defining a Trait
A trait defines a set of methods that types can implement:
#![allow(unused)] fn main() { trait Damageable { fn take_damage(&mut self, amount: i32); fn health(&self) -> i32; fn is_alive(&self) -> bool { self.health() > 0 // Default implementation } } }
This says: "Any type that is Damageable must provide take_damage and health, and gets is_alive for free."
Implementing a Trait
Types opt into traits explicitly:
trait Damageable { fn take_damage(&mut self, amount: i32); fn health(&self) -> i32; fn is_alive(&self) -> bool { self.health() > 0 } } struct Player { name: String, health: i32, armor: i32, } impl Damageable for Player { fn take_damage(&mut self, amount: i32) { // Players have armor that reduces damage let actual_damage = (amount - self.armor).max(0); self.health -= actual_damage; } fn health(&self) -> i32 { self.health } } fn main() { let mut player = Player { name: String::from("Alice"), health: 100, armor: 5, }; player.take_damage(20); println!("{} has {} health, alive: {}", player.name, player.health(), player.is_alive()); }
Multiple Types, Same Trait
Different types can implement the same trait differently:
trait Damageable { fn take_damage(&mut self, amount: i32); fn health(&self) -> i32; fn is_alive(&self) -> bool { self.health() > 0 } } struct Player { name: String, health: i32, armor: i32, } impl Damageable for Player { fn take_damage(&mut self, amount: i32) { let actual_damage = (amount - self.armor).max(0); self.health -= actual_damage; } fn health(&self) -> i32 { self.health } } struct Crate { health: i32, } impl Damageable for Crate { fn take_damage(&mut self, amount: i32) { // Crates have no armor, take full damage self.health -= amount; } fn health(&self) -> i32 { self.health } } fn main() { let mut player = Player { name: String::from("Alice"), health: 100, armor: 5 }; let mut crate1 = Crate { health: 50 }; player.take_damage(20); // Takes 15 (armor absorbs 5) crate1.take_damage(20); // Takes full 20 println!("Player: {}, Crate: {}", player.health(), crate1.health()); }
Overriding Default Implementations
Types can override default methods when needed:
trait Damageable { fn take_damage(&mut self, amount: i32); fn health(&self) -> i32; fn is_alive(&self) -> bool { self.health() > 0 } } struct Zombie { health: i32, } impl Damageable for Zombie { fn take_damage(&mut self, amount: i32) { self.health -= amount; } fn health(&self) -> i32 { self.health } // Zombies are "alive" even at 0 health - they need to be destroyed! fn is_alive(&self) -> bool { self.health() > -10 } } fn main() { let mut zombie = Zombie { health: 20 }; zombie.take_damage(25); println!("Health: {}, Alive: {}", zombie.health(), zombie.is_alive()); zombie.take_damage(10); println!("Health: {}, Alive: {}", zombie.health(), zombie.is_alive()); }
Traits vs C# Interfaces
| C# Interface | Rust Trait |
|---|---|
| Methods only (until C# 8) | Methods, constants, types |
| Default implementations (C# 8+) | Default implementations (always) |
| Implicit implementation | Explicit impl Trait for Type |
| Runtime dispatch by default | Compile-time dispatch by default |
| Part of type identity | Capability that can be added |
The key difference: In C#, you implement interfaces when you define the class. In Rust, you can implement traits for any type, anywhere (with some restrictions).
Multiple Traits, One Type
Types can implement as many traits as needed:
trait Damageable { fn take_damage(&mut self, amount: i32); fn health(&self) -> i32; } trait Moveable { fn move_by(&mut self, dx: f32, dy: f32); fn position(&self) -> (f32, f32); } struct Player { health: i32, x: f32, y: f32, } impl Damageable for Player { fn take_damage(&mut self, amount: i32) { self.health -= amount; } fn health(&self) -> i32 { self.health } } impl Moveable for Player { fn move_by(&mut self, dx: f32, dy: f32) { self.x += dx; self.y += dy; } fn position(&self) -> (f32, f32) { (self.x, self.y) } } fn main() { let mut player = Player { health: 100, x: 0.0, y: 0.0 }; player.move_by(5.0, 3.0); player.take_damage(10); println!("Position: {:?}, Health: {}", player.position(), player.health()); }
No inheritance hierarchy needed. Capabilities compose freely.
The Mental Model
C# thinking: "This class IS-A something. It inherits from a base class and implements interfaces that define what it is."
Rust thinking: "This type HAS capabilities. It can do these things because it implements these traits. Capabilities are independent."
Traits describe what a type can DO, not what it IS. This is composition over inheritance, built into the language.
Generics and Trait Bounds
Traits become powerful when combined with generics. Instead of writing code for specific types, you write code for "any type that has this capability."
A Function That Works on Any Damageable
trait Damageable { fn take_damage(&mut self, amount: i32); fn health(&self) -> i32; fn is_alive(&self) -> bool { self.health() > 0 } } struct Player { health: i32 } struct Crate { health: i32 } impl Damageable for Player { fn take_damage(&mut self, amount: i32) { self.health -= amount; } fn health(&self) -> i32 { self.health } } impl Damageable for Crate { fn take_damage(&mut self, amount: i32) { self.health -= amount; } fn health(&self) -> i32 { self.health } } // Generic function: works on ANY type that implements Damageable fn apply_explosion<T: Damageable>(target: &mut T, damage: i32) { println!("Explosion deals {} damage!", damage); target.take_damage(damage); if !target.is_alive() { println!("Target destroyed!"); } } fn main() { let mut player = Player { health: 100 }; let mut crate1 = Crate { health: 30 }; apply_explosion(&mut player, 25); println!("Player health: {}\n", player.health()); apply_explosion(&mut crate1, 50); println!("Crate health: {}", crate1.health()); }
The <T: Damageable> is a trait bound - it says "T can be any type, as long as it implements Damageable."
Monomorphization: Zero-Cost Abstraction
When you call apply_explosion(&mut player, 25), the compiler generates:
#![allow(unused)] fn main() { // Compiler generates this specialized version for Player fn apply_explosion_player(target: &mut Player, damage: i32) { println!("Explosion deals {} damage!", damage); target.take_damage(damage); // Direct call to Player::take_damage if !target.is_alive() { println!("Target destroyed!"); } } }
And for apply_explosion(&mut crate1, 50), it generates a separate version for Crate.
This is monomorphization: the generic function becomes multiple specialized functions at compile time. No runtime overhead!
In more formal terms, monomorphization is the transformation of a polymorphic type or function into several monomorphic variants, each specialized for a single concrete type. The polymorphic generic code you write gets "instantiated" into monomorphic code at compile-time for each type you actually use it with.
Multiple Trait Bounds
Sometimes you need a type with multiple capabilities:
trait Damageable { fn take_damage(&mut self, amount: i32); fn health(&self) -> i32; } trait Named { fn name(&self) -> &str; } struct Player { name: String, health: i32, } impl Damageable for Player { fn take_damage(&mut self, amount: i32) { self.health -= amount; } fn health(&self) -> i32 { self.health } } impl Named for Player { fn name(&self) -> &str { &self.name } } // T must implement BOTH Damageable AND Named fn damage_with_message<T: Damageable + Named>(target: &mut T, amount: i32) { println!("{} takes {} damage!", target.name(), amount); target.take_damage(amount); println!("{} now has {} health", target.name(), target.health()); } fn main() { let mut player = Player { name: String::from("Alice"), health: 100, }; damage_with_message(&mut player, 30); }
The + combines multiple bounds: the type must have ALL the capabilities.
When trait bounds get complicated, you can also use a where clause for clarity.
Rust Generics vs C# Generics
In C#, generics use reification - type information exists at runtime:
void Process<T>(T item) {
// Can check typeof(T) at runtime
// Single version of the code handles all types
}
In Rust, generics use monomorphization - specialized code is generated:
#![allow(unused)] fn main() { fn process<T>(item: T) { // No runtime type info // Compiler generates separate code for each T } }
| Aspect | C# Generics | Rust Generics |
|---|---|---|
| When resolved | Runtime | Compile time |
| Code generated | One version | One per type |
| Performance | Virtual dispatch possible | Direct calls, inlineable |
| Binary size | Smaller | Larger |
| Compile time | Faster | Slower |
impl Trait: Simpler Syntax
For simple cases, impl Trait is a shorthand:
use std::fmt::Display; // These two are equivalent: fn print_thing<T: Display>(thing: T) { println!("{}", thing); } fn print_thing_simpler(thing: impl Display) { println!("{}", thing); } fn main() { print_thing("hello"); print_thing_simpler(42); }
impl Trait in argument position means "some type that implements this trait." It's syntactic sugar for simple bounds.
The Power of Compile-Time Polymorphism
Because generics are resolved at compile time:
- No runtime cost - Calls are direct, can be inlined
- Type errors caught early - Can't pass wrong type
- Optimizations apply - Compiler sees the concrete types
trait Damageable { fn take_damage(&mut self, amount: i32); } struct Player { health: i32 } impl Damageable for Player { fn take_damage(&mut self, amount: i32) { self.health -= amount; } } fn damage_many<T: Damageable>(targets: &mut [T], amount: i32) { for target in targets { target.take_damage(amount); // Direct call, inlined } } fn main() { let mut players = vec![ Player { health: 100 }, Player { health: 100 }, Player { health: 100 }, ]; damage_many(&mut players, 10); for (i, p) in players.iter().enumerate() { println!("Player {}: {} health", i, p.health); } }
The loop compiles to direct Player::take_damage calls - as fast as if you wrote it by hand.
Conditional Implementations
One of Rust's most powerful features: you can implement traits conditionally, based on what capabilities the type parameters have.
Implementing for Generic Types
When you have a generic struct, you can add implementations that only apply when the type parameter meets certain bounds:
use std::fmt::Display; struct Container<T> { value: T, } // This impl exists for ALL Container<T> impl<T> Container<T> { fn new(value: T) -> Self { Container { value } } fn into_inner(self) -> T { self.value } } // This impl ONLY exists when T implements Display impl<T: Display> Container<T> { fn print(&self) { println!("Container holds: {}", self.value); } } fn main() { let int_container = Container::new(42); int_container.print(); // Works: i32 implements Display let string_container = Container::new(String::from("hello")); string_container.print(); // Works: String implements Display // Vec<i32> implements Display, so this works too let vec_container = Container::new(vec![1, 2, 3]); // vec_container.print(); // ERROR: Vec<i32> doesn't implement Display // But we can still use methods that don't require Display let values = vec_container.into_inner(); println!("{:?}", values); }
Blanket Implementations
The standard library uses this pattern extensively. For example:
#![allow(unused)] fn main() { // In the standard library (simplified): // impl<T: Display> ToString for T { // fn to_string(&self) -> String { // format!("{}", self) // } // } }
This means: "For ANY type that implements Display, automatically implement ToString."
use std::fmt::Display; struct Player { name: String, health: i32, } impl Display for Player { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{} ({}hp)", self.name, self.health) } } fn main() { let player = Player { name: String::from("Alice"), health: 100 }; // We get ToString for free because we implemented Display! let s: String = player.to_string(); println!("{}", s); }
Building Capability Through Composition
This pattern lets capabilities compose automatically:
trait Damageable { fn health(&self) -> i32; } trait Named { fn name(&self) -> &str; } // Any type that is both Named and Damageable gets a status message trait HasStatus: Named + Damageable { fn status(&self) -> String { format!("{}: {} health", self.name(), self.health()) } } // Blanket implementation: anything that's Named + Damageable gets HasStatus impl<T: Named + Damageable> HasStatus for T {} struct Player { name: String, health: i32, } impl Named for Player { fn name(&self) -> &str { &self.name } } impl Damageable for Player { fn health(&self) -> i32 { self.health } } // Player automatically implements HasStatus! fn main() { let player = Player { name: String::from("Alice"), health: 100 }; println!("{}", player.status()); }
The Orphan Rule
There's a restriction: you can only implement a trait if either:
- You defined the trait, OR
- You defined the type
This prevents conflicts when two crates try to implement the same trait for the same type.
// This would NOT compile if written in your crate: // impl Display for Vec<i32> { ... } // ERROR: neither Display nor Vec are defined in this crate // But you CAN implement your trait for foreign types: trait Describable { fn describe(&self) -> String; } impl Describable for i32 { fn describe(&self) -> String { format!("The number {}", self) } } // And you CAN implement foreign traits for your types: struct Player { name: String } impl std::fmt::Display for Player { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "Player: {}", self.name) } } fn main() { println!("{}", 42.describe()); println!("{}", Player { name: String::from("Alice") }); }
If you need to implement a foreign trait for a foreign type (which the orphan rule prevents), you can use a newtype to create a "wrapper" type that you own. This acts as an adapter, allowing you to bridge the gap. For more on newtypes, see the Newtypes chapter in the DOP Fundamentals section.
Newtypes as Orphan Rule Workarounds
Here's how you can use a newtype to implement a foreign trait for a foreign type:
use std::fmt; // We can't implement Display for Vec<i32> directly (orphan rule) // But we can wrap it in a newtype! struct Comma<T>(Vec<T>); impl<T: fmt::Display> fmt::Display for Comma<T> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let mut first = true; for item in &self.0 { if !first { write!(f, ", ")?; } write!(f, "{}", item)?; first = false; } Ok(()) } } fn main() { let numbers = Comma(vec![1, 2, 3, 4, 5]); println!("{}", numbers); // Output: 1, 2, 3, 4, 5 }
The newtype acts as an adapter, giving you a local type that you can implement traits on.
Real-World Example: Debug and Clone
The standard library's derive macros use conditional implementation:
// When you write: #[derive(Debug, Clone)] struct Container<T> { value: T, } // The compiler generates something like: // impl<T: Debug> Debug for Container<T> { ... } // impl<T: Clone> Clone for Container<T> { ... } fn main() { let c1 = Container { value: 42 }; println!("{:?}", c1); // Works: i32 is Debug let c2 = c1.clone(); // Works: i32 is Clone println!("{:?}", c2); }
The derived implementations are conditional on T having those same capabilities.
The Mental Model
C# thinking: "I explicitly implement interfaces on my classes. What I implement is fixed at definition time."
Rust thinking: "Implementations can be conditional. Capabilities emerge from the combination of types and their properties. I can add behavior to types based on what they can already do."
This is composition at the type level. Small traits combine to provide rich functionality, and the compiler figures out what's available for each concrete type.
Why Lifetimes Exist
References must always point to valid data. In C#, the garbage collector ensures this. In Rust, the compiler proves it at compile time using lifetimes.
The Problem: Dangling References
What happens if a reference outlives the data it points to?
fn main() { let reference; { let value = 42; reference = &value; } // value is dropped here println!("{}", reference); // ERROR: `value` does not live long enough }
The compiler catches this: reference would point to freed memory. This is a dangling reference, and Rust prevents it.
References in Functions
When functions return references, where does the data live?
fn first_word(s: &str) -> &str { match s.find(' ') { Some(i) => &s[..i], None => s, } } fn main() { let sentence = String::from("hello world"); let word = first_word(&sentence); println!("First word: {}", word); }
This works - but how does the compiler know word is valid? It needs to know that the returned reference lives as long as the input.
The Compiler's Question
When the compiler sees a function that returns a reference, it asks: "How long is this reference valid?"
// This function returns a reference, but to what? // The compiler will emit an error here because it doesn't know where the &str comes from fn get_name() -> &str { // Doesn't matter what we do here ... } fn main() { println!("{}", get_name()); }
The compiler needs to prove the returned reference won't dangle. If it can't, it rejects the code.
Lifetime Annotations: Describing Relationships
Lifetime annotations tell the compiler how references relate to each other:
// 'a is a lifetime parameter // This says: the returned reference lives as long as the input fn first_word<'a>(s: &'a str) -> &'a str { match s.find(' ') { Some(i) => &s[..i], None => s, } } fn main() { let sentence = String::from("hello world"); let word = first_word(&sentence); println!("First word: {}", word); }
The 'a connects the input and output: "the output reference is valid for the same scope as the input reference."
Lifetimes are Descriptive, Not Prescriptive
Important: lifetimes don't change how long data lives. They describe relationships that already exist.
fn longer<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } } fn main() { let string1 = String::from("hello"); let string2 = String::from("world!"); // The compiler knows: result is valid as long as BOTH inputs are valid let result = longer(&string1, &string2); println!("Longer: {}", result); }
You're telling the compiler: "I'm returning one of these two references, so the result is valid only while both are valid."
Lifetime Elision: When You Don't Write Them
You've been using lifetimes all along - the compiler infers them in common cases:
// You write:
fn first_word(s: &str) -> &str { /* ... */ }
// Compiler sees:
// fn first_word<'a>(s: &'a str) -> &'a str { /* ... */ }
The elision rules let you skip explicit lifetimes when the relationship is obvious:
- Each input reference gets its own lifetime
- If there's exactly one input lifetime, it's used for all outputs
- If there's
&selfor&mut self, that lifetime is used for outputs
When Elision Isn't Enough
Sometimes the compiler can't infer the relationship:
// Which input does the output come from? fn pick_one(x: &str, y: &str) -> &str { if x.len() > 0 { x } else { y } } // ERROR: missing lifetime specifier fn main() {}
We must be explicit:
fn pick_one<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > 0 { x } else { y } } fn main() { let a = String::from("hello"); let b = String::from("world"); println!("{}", pick_one(&a, &b)); }
Two inputs, one output - the compiler needs you to specify the relationship.
The Mental Model
C# thinking: "References just work. The GC tracks what's alive."
Rust thinking: "References have lifetimes. The compiler tracks how long each reference is valid and proves they never dangle."
Lifetimes make explicit what C# hides behind garbage collection. The benefit: zero runtime cost, guaranteed safety, and documentation of reference relationships in your API.
Lifetime Annotations in Practice
Let's explore common lifetime patterns and how to read and write them confidently.
Reading Lifetime Annotations
Lifetime syntax looks like generics, but for scopes:
fn example<'a>(x: &'a str) -> &'a str {
x
}
Read this as: "The function example has a lifetime parameter 'a. It takes a reference that's valid for 'a and returns a reference that's valid for 'a."
The 'a is just a name - convention uses 'a, 'b, 'c, etc.
Relating Multiple Inputs
When a function takes multiple references and returns one:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } } fn main() { let string1 = String::from("hello"); { let string2 = String::from("world!"); let result = longest(&string1, &string2); println!("Longest: {}", result); } // result is no longer valid here (string2 was dropped) }
Both inputs share 'a, so the output is valid only while BOTH inputs are valid.
Different Lifetimes for Different Purposes
Sometimes inputs have independent lifetimes:
fn first_of_second<'a, 'b>(first: &'a str, second: &'b str) -> &'a str { // We always return from 'first', so output only needs 'a first } fn main() { let long_lived = String::from("I live long"); let result; { let short_lived = String::from("I'm temporary"); result = first_of_second(&long_lived, &short_lived); // short_lived is dropped here, but that's OK - we don't return it } println!("{}", result); // Still valid! }
The output lifetime 'a is independent of 'b because we only return from the first parameter.
Structs with References
If a struct holds references, it needs lifetime parameters:
struct Excerpt<'a> { text: &'a str, } impl<'a> Excerpt<'a> { fn new(text: &'a str) -> Self { Excerpt { text } } fn text(&self) -> &str { self.text } } fn main() { let novel = String::from("Call me Ishmael. Some years ago..."); let first_sentence = novel.split('.').next().unwrap(); let excerpt = Excerpt::new(first_sentence); println!("Excerpt: {}", excerpt.text()); }
The 'a says: "This Excerpt can't outlive the string it references."
The Struct Lifetime Constraint
The lifetime ensures the struct doesn't outlive its references:
struct Excerpt<'a> { text: &'a str, } fn main() { let excerpt; { let novel = String::from("Call me Ishmael."); excerpt = Excerpt { text: &novel }; // ERROR: `novel` does not live long enough } // If this compiled, excerpt.text would be a dangling reference println!("{}", excerpt.text); }
Here's the correct version where the data lives long enough:
struct Excerpt<'a> { text: &'a str, } fn main() { let novel = String::from("Call me Ishmael."); let excerpt = Excerpt { text: &novel }; println!("{}", excerpt.text); }
The 'static Lifetime
'static means "lives for the entire program." String literals have this lifetime:
fn main() { // String literals are stored in the binary, so they're always valid let s: &'static str = "I live forever"; println!("{}", s); }
You'll also see 'static in trait bounds, meaning "contains no non-static references":
use std::fmt::Display; // T: Display + 'static means T implements Display AND // doesn't contain any references (or only 'static ones) fn print_static<T: Display + 'static>(value: T) { println!("{}", value); } fn main() { print_static(42); // OK: i32 is 'static print_static(String::from("hello")); // OK: String owns its data // let s = String::from("temp"); // let r = &s; // print_static(r); // ERROR: &String is not 'static }
Common Lifetime Errors
Returning references to local data
// Can't return reference to local fn create_string() -> &str { let s = String::from("hello"); &s // ERROR: returns reference to local variable } fn main() { let s = create_string(); println!("{}", s); }
Fix: return owned data instead:
fn create_string() -> String { String::from("hello") } fn main() { let s = create_string(); println!("{}", s); }
Reference outlives data
struct Player<'a> { name: &'a str, } fn main() { let player; { let name = String::from("Alice"); player = Player { name: &name }; // ERROR: `name` doesn't live long enough } println!("Player: {}", player.name); }
Fix: ensure name lives long enough:
struct Player<'a> { name: &'a str, } fn main() { let name = String::from("Alice"); let player = Player { name: &name }; println!("Player: {}", player.name); }
When to Own vs Borrow
If lifetime constraints get complicated, consider owning the data instead:
// With lifetime - struct borrows the string struct ExcerptBorrowed<'a> { text: &'a str, } // Without lifetime - struct owns the string struct ExcerptOwned { text: String, } fn main() { // Borrowed: lighter weight, but constrained by source lifetime let source = String::from("hello world"); let borrowed = ExcerptBorrowed { text: &source }; // Owned: more flexible, but involves allocation/copying let owned = ExcerptOwned { text: String::from("hello world") }; println!("{}, {}", borrowed.text, owned.text); }
Borrowing is efficient when you can; owning is simpler when lifetimes get tricky.
The Mental Model
Lifetimes are like generic type parameters, but for scopes instead of types.
fn foo<T>(x: T)- T is some typefn foo<'a>(x: &'a str)-'ais some scope/duration
You're telling the compiler: "These references are connected. The output is valid for this long because the input lives at least that long."
The compiler uses this information to prove your code is memory-safe at compile time.
Dynamic Dispatch with dyn Trait
So far, all our polymorphism has been resolved at compile time. But sometimes you genuinely don't know the concrete type until runtime. That's where dyn Trait comes in - but use it sparingly.
The Problem: Heterogeneous Collections
With generics, every element in a collection must be the same type:
trait Drawable { fn draw(&self); } struct Circle { radius: f32 } struct Rectangle { width: f32, height: f32 } impl Drawable for Circle { fn draw(&self) { println!("Drawing circle with radius {}", self.radius); } } impl Drawable for Rectangle { fn draw(&self) { println!("Drawing {}x{} rectangle", self.width, self.height); } } fn main() { // This works - all the same type let circles: Vec<Circle> = vec![ Circle { radius: 1.0 }, Circle { radius: 2.0 }, ]; // But what if we want mixed shapes? // let shapes: Vec<???> = vec![Circle { ... }, Rectangle { ... }]; for c in &circles { c.draw(); } }
Generics can't help here - we need different types in the same collection.
dyn Trait: Runtime Polymorphism
dyn Trait creates a trait object - a pointer plus a vtable:
trait Drawable { fn draw(&self); } struct Circle { radius: f32 } struct Rectangle { width: f32, height: f32 } impl Drawable for Circle { fn draw(&self) { println!("Drawing circle with radius {}", self.radius); } } impl Drawable for Rectangle { fn draw(&self) { println!("Drawing {}x{} rectangle", self.width, self.height); } } fn main() { // Box<dyn Drawable> = owned pointer to "something that implements Drawable" let shapes: Vec<Box<dyn Drawable>> = vec![ Box::new(Circle { radius: 1.0 }), Box::new(Rectangle { width: 2.0, height: 3.0 }), Box::new(Circle { radius: 0.5 }), ]; for shape in &shapes { shape.draw(); // Dynamic dispatch - resolved at runtime } }
Now we can mix types! The cost: runtime dispatch instead of compile-time.
Why the Box?
dyn Trait is unsized - the compiler doesn't know how big it is. You need a pointer:
trait Drawable { fn draw(&self); } struct Circle { radius: f32 } impl Drawable for Circle { fn draw(&self) { println!("Circle"); } } fn main() { // These work - pointer types let _boxed: Box<dyn Drawable> = Box::new(Circle { radius: 1.0 }); let circle = Circle { radius: 1.0 }; let _borrowed: &dyn Drawable = &circle; // This doesn't work - unsized! // let direct: dyn Drawable = Circle { radius: 1.0 }; // ERROR: the size of `dyn Drawable` cannot be statically determined }
Common pointer types for trait objects:
Box<dyn Trait>- owned, heap-allocated&dyn Trait- borrowed referenceArc<dyn Trait>- shared ownership (thread-safe)
How It Works: The Vtable
A trait object is two pointers: one to the data, one to a vtable:
Box<dyn Drawable>
┌─────────────────┐
│ data pointer │ ──► Circle { radius: 1.0 }
├─────────────────┤
│ vtable pointer │ ──► ┌─────────────────────┐
└─────────────────┘ │ draw: Circle::draw │
│ drop: Circle::drop │
│ size: 4 bytes │
└─────────────────────┘
When you call shape.draw(), the runtime looks up the function in the vtable. This is just like C# interface dispatch.
The Runtime Cost
Dynamic dispatch has real overhead:
use std::time::Instant; trait Processable { fn process(&self) -> i64; } struct Data { value: i64 } impl Processable for Data { fn process(&self) -> i64 { self.value * 2 } } // Static dispatch - compiler inlines this fn process_static<T: Processable>(items: &[T]) -> i64 { items.iter().map(|x| x.process()).sum() } // Dynamic dispatch - vtable lookup each iteration fn process_dynamic(items: &[Box<dyn Processable>]) -> i64 { items.iter().map(|x| x.process()).sum() } fn main() { // Size difference println!("Size of Data: {} bytes", std::mem::size_of::<Data>()); println!("Size of Box<dyn Processable>: {} bytes (pointer + vtable pointer)", std::mem::size_of::<Box<dyn Processable>>()); println!(); // Create test data let data: Vec<Data> = (0..100_000).map(|i| Data { value: i }).collect(); let boxed: Vec<Box<dyn Processable>> = (0..100_000) .map(|i| Box::new(Data { value: i }) as Box<dyn Processable>) .collect(); // Benchmark static dispatch let start = Instant::now(); let mut result = 0; for _ in 0..100 { result = process_static(&data); } let static_time = start.elapsed(); println!("Static dispatch: {:?} (result: {})", static_time, result); // Benchmark dynamic dispatch let start = Instant::now(); let mut result = 0; for _ in 0..100 { result = process_dynamic(&boxed); } let dynamic_time = start.elapsed(); println!("Dynamic dispatch: {:?} (result: {})", dynamic_time, result); }
Static dispatch: direct call, can be inlined, cache-friendly. Dynamic dispatch: vtable lookup, no inlining, pointer chasing.
Object Safety
Not all traits can be used as dyn Trait. A trait is object-safe if:
- It doesn't return
Self - It doesn't have generic methods
- It doesn't require
Self: Sized
// Object-safe: can be used as dyn Drawable trait Drawable { fn draw(&self); } // NOT object-safe: returns Self trait Clonable { fn clone(&self) -> Self; // How big is the return value? Unknown! } // NOT object-safe: generic method trait Processor { fn process<T>(&self, item: T); // Which version goes in the vtable? } fn main() { // let x: Box<dyn Clonable>; // ERROR: the trait `Clonable` cannot be made into an object println!("Object safety matters!"); }
Essentially, any method that requires concrete knowledge of the type at compile time makes the trait non-object-safe. Returning Self requires that the compiler know how big the return type is. Generic methods require recompiling code for each possible type parameter (impossible when we don't know the type at runtime). Trait objects are inherently unsized, so requiring Self: Sized is a contradiction. Object-safe traits must look a lot like interfaces in C#, which are always object-safe.
The compiler tells you when a trait isn't object-safe if you try to make a dyn pointer to it. If you want to use such a trait as a trait object, you'll need to redesign it.
When to Use dyn Trait
✅ Good use cases:
- Plugin systems where types aren't known at compile time
- Heterogeneous collections (mixed types)
- Reducing binary size (one function instead of many monomorphized versions)
- Recursive types that would otherwise have infinite size
trait Plugin { fn name(&self) -> &str; fn execute(&self); } struct PluginManager { #[allow(dead_code)] plugins: Vec<Box<dyn Plugin>>, // Unknown plugins loaded at runtime } fn main() { println!("Plugin systems are a good use case for dyn Trait"); }
When NOT to Use dyn Trait
❌ Use generics instead when you know types at compile time:
trait Drawable { fn draw(&self); } struct Circle { radius: f32 } impl Drawable for Circle { fn draw(&self) { println!("Circle: {}", self.radius); } } // Bad: unnecessary dynamic dispatch #[allow(dead_code)] fn draw_bad(shape: &dyn Drawable) { shape.draw(); } // Good: static dispatch, can be inlined fn draw_good<T: Drawable>(shape: &T) { shape.draw(); } fn main() { let c = Circle { radius: 1.0 }; draw_good(&c); // Works, faster }
When NOT to Use dyn Trait (continued)
❌ Use an enum instead when you have a fixed set of variants:
// Instead of dyn Trait for a known set of shapes... enum Shape { Circle { radius: f32 }, Rectangle { width: f32, height: f32 }, Triangle { base: f32, height: f32 }, } impl Shape { fn draw(&self) { match self { Shape::Circle { radius } => println!("Circle: {}", radius), Shape::Rectangle { width, height } => println!("Rect: {}x{}", width, height), Shape::Triangle { base, height } => println!("Triangle: {}x{}", base, height), } } fn area(&self) -> f32 { match self { Shape::Circle { radius } => 3.14159 * radius * radius, Shape::Rectangle { width, height } => width * height, Shape::Triangle { base, height } => 0.5 * base * height, } } } fn main() { let shapes = vec![ Shape::Circle { radius: 1.0 }, Shape::Rectangle { width: 2.0, height: 3.0 }, ]; for shape in &shapes { shape.draw(); println!(" Area: {}", shape.area()); } }
Enums are faster (no vtable), exhaustive (compiler checks all cases), and sized.
The Mental Model
C# thinking: "Interfaces are everywhere. I use them for abstraction, testing, dependency injection..."
Rust thinking: "Dynamic dispatch is a specialized tool. I use generics by default. I use enums for closed sets of variants. I use dyn Trait only when I genuinely don't know the type at compile time."
Most Rust code uses zero dyn Trait. It's not wrong to use it - but it shouldn't be your first instinct coming from C#.
Entity-Component-System Pattern
Data-Oriented Programming isn't just "how Rust works" - it's a design philosophy that powers high-performance systems. The Entity-Component-System (ECS) pattern from game development is a pure expression of DOP. Bevy, a popular Rust game engine, uses ECS as its core architecture. While this isn't a game design book, understanding ECS reveals how DOP principles apply in real-world scenarios beyond games.
The OOP Instinct: Inheritance Hierarchies
In C#, you might model game entities with inheritance:
// C# approach: inheritance hierarchy
abstract class Entity {
public Vector2 Position { get; set; }
}
class Player : Entity {
public int Health { get; set; }
}
class Enemy : Entity, IDamageable {
public int Health { get; set; }
public AI Brain { get; set; }
}
This creates problems: What if an Enemy needs to share behavior with Player? What about a destructible Projectile that has Health?
The DOP Alternative: Composition Over Inheritance
In DOP, we separate "what things are" from "what things have". Entities are just IDs, and data lives in separate component storage:
use std::collections::HashMap;
// Components are just data - no behavior, no hierarchy
#[derive(Clone, Copy)]
struct Position { x: f32, y: f32 }
#[derive(Clone, Copy)]
struct Velocity { dx: f32, dy: f32 }
#[derive(Clone, Copy)]
struct Health { current: i32, max: i32 }
// An entity is just an identifier
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
struct Entity(u32);
// Components stored separately, indexed by entity
struct World {
next_entity: u32,
positions: HashMap<Entity, Position>,
velocities: HashMap<Entity, Velocity>,
healths: HashMap<Entity, Health>,
}
impl World {
fn new() -> Self {
World {
next_entity: 0,
positions: HashMap::new(),
velocities: HashMap::new(),
healths: HashMap::new(),
}
}
fn spawn(&mut self) -> Entity {
let entity = Entity(self.next_entity);
self.next_entity += 1;
entity
}
}
Entities gain capabilities by having components added - no inheritance needed.
Games have large quantities of entities, with lots of varied components, which is why they use this "columnar" storage format (storing components in separate collections indexed by entity ID). This allows systems to work by iterating over these large arrays of data efficiently. They don't have to use locking to protect unrelated data, and they can use the CPU cache extremely efficiently because they are usually working with large contiguous arrays of data.
Systems: Functions Over Data
Behavior lives in systems - functions that operate on entities with specific components. In Bevy, you declare what data your system needs via query parameters, and the framework fills them in automatically:
use bevy::prelude::*;
// Components are simple structs marked with #[derive(Component)]
#[derive(Component)]
struct Position { x: f32, y: f32 }
#[derive(Component)]
struct Velocity { dx: f32, dy: f32 }
#[derive(Component)]
struct Health { current: i32, max: i32 }
// Movement system: Query tells Bevy "give me all entities with Position AND Velocity"
// Bevy automatically finds matching entities and calls this function
fn movement_system(mut query: Query<(&mut Position, &Velocity)>, time: Res<Time>) {
for (mut pos, vel) in &mut query {
pos.x += vel.dx * time.delta_secs();
pos.y += vel.dy * time.delta_secs();
}
}
// Health regen: only needs entities with Health component
fn health_regen_system(mut query: Query<&mut Health>) {
for mut health in &mut query {
if health.current < health.max {
health.current += 1;
}
}
}
// Startup system: runs once to spawn initial entities
// Commands lets us create/destroy entities and add/remove components
fn setup(mut commands: Commands) {
// Player: has position, velocity, and health - will move and regenerate
commands.spawn((
Position { x: 0.0, y: 0.0 },
Velocity { dx: 10.0, dy: 5.0 },
Health { current: 80, max: 100 },
));
// Enemy: has position, velocity, and health
commands.spawn((
Position { x: 100.0, y: 50.0 },
Velocity { dx: -5.0, dy: 0.0 },
Health { current: 50, max: 50 },
));
// Tree: only has position - won't move, can't be damaged
commands.spawn(Position { x: 50.0, y: 50.0 });
}
// The function signature IS the query - no manual filtering needed
// Bevy inspects the function parameters and wires everything up
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.add_systems(Update, (movement_system, health_regen_system))
.run();
}
The tree (with only Position) won't be matched by movement_system's query because it requires both Position and Velocity. Also, the health system and the movement system can run in parallel because they touch completely distinct data. The type system defines data access clearly and safely.
Why This Pattern Matters
ECS demonstrates core DOP principles:
- Data and behavior are separate - Components are pure data, systems are pure functions
- Composition over inheritance - Capabilities come from having components, not from class hierarchies
- Clear data flow - Systems declare exactly what they read and write
- Parallelism-ready - Systems touching different components can run in parallel
Game engines like Bevy use ECS, but the pattern applies anywhere you have entities with varying capabilities - and as we'll see, similar thinking applies to request pipelines.
HTTP Pipeline, but with ECS flair
If you've worked on an SDK that makes HTTP requests, you know the complexity: retries, timeouts, endpoint selection, request signing, logging. Let's see how DOP shapes this design.
The OOP Approach: Handler Chains
In C#, you might build a pipeline with delegating handlers:
// C# - HttpClient handler chain
public class RetryHandler : DelegatingHandler {
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken ct)
{
// Handler owns retry state, logging, configuration...
for (int i = 0; i < 3; i++) {
var response = await base.SendAsync(request, ct);
if (response.IsSuccessStatusCode) return response;
}
throw new Exception("Retries exhausted");
}
}
Each handler is an object with its own state. The request flows through objects calling other objects.
DOP Approach: The ECS Pattern in Disguise
Remember ECS? Entities are just IDs, components hold the data, and systems operate on specific components. The same pattern applies here, but with some simplification because we don't have the large-scale data processing needs a game has:
| ECS Concept | HTTP Pipeline Equivalent |
|---|---|
| Entity | RequestContext (just a container) |
| Components | Request, RoutingDecision, RetryState |
| Systems | select_endpoint, add_auth, update_retry_state |
We don't need to store Components in separate arrays for cache efficiency (which incurs a developer experience cost), but we still want to separate data into focused types.
The key insight: systems operate on components, not entities. Just like movement_system takes Query<(&mut Position, &Velocity)> rather than the whole entity, our pipeline functions take specific pieces of the context.
use std::collections::HashMap;
use std::time::Duration;
#[derive(Clone)]
enum Method { Get, Post, Put, Delete }
// COMPONENT: Core request data - the "what" of the request
struct Request {
method: Method,
path: String,
headers: HashMap<String, String>,
body: Option<Vec<u8>>,
}
// COMPONENT: Routing decision - where the request goes
// This is its own type because routing can be re-decided on retry
enum RoutingDecision {
Pending, // Not yet decided
Routed { endpoint: String }, // We know where to send it
Redirect { new_path: String }, // Server told us to go elsewhere
}
// COMPONENT: Retry state - tracking attempt history
// Grouped together because these fields change together
struct RetryState {
attempt: u32,
max_attempts: u32,
last_error: Option<RequestError>,
backoff: Duration,
}
#[derive(Clone, Debug)]
enum RequestError {
Timeout,
ConnectionFailed,
ServiceUnavailable,
Throttled { retry_after: Duration },
}
// ENTITY: Just a container that holds components together
// The RequestContext itself has no behavior - it's just an ID for "this request"
struct RequestContext {
request: Request,
routing: RoutingDecision,
retry: RetryState,
}
Why structure it this way? Systems (functions) operate on components, not the entity. Each function takes only the components it needs:
select_endpoint(&RetryState, ...) -> RoutingDecisionadd_auth(Request, ...) -> Requestupdate_retry_state(RetryState, ...) -> RetryState
In C#, you'd likely have one big mutable object where any method could touch anything. The ECS/DOP approach makes data access explicit.
Pipeline Stages Transform Specific Parts
Each stage is a function that works with just the data it needs:
use std::collections::HashMap; use std::time::Duration; #[derive(Clone)] enum Method { Get, Post, Put, Delete } struct Request { method: Method, path: String, headers: HashMap<String, String>, body: Option<Vec<u8>>, } #[derive(Clone)] enum RoutingDecision { Pending, Routed { endpoint: String }, } #[derive(Clone)] struct RetryState { attempt: u32, max_attempts: u32, last_error: Option<RequestError>, backoff: Duration, } #[derive(Clone, Debug)] enum RequestError { Timeout, ServiceUnavailable, Throttled { retry_after: Duration }, } struct RequestContext { request: Request, routing: RoutingDecision, retry: RetryState, } struct Response { status: u16, body: Vec<u8>, } // Routing stage - ONLY looks at retry state to pick an endpoint // Takes just what it needs, returns just what it changes fn select_endpoint(retry: &RetryState, endpoints: &[String]) -> RoutingDecision { let idx = retry.attempt as usize % endpoints.len(); RoutingDecision::Routed { endpoint: endpoints[idx].clone() } } // Auth stage - transforms the request headers // Takes the request, returns a new request with auth added fn add_auth(mut request: Request, token: &str) -> Request { request.headers.insert( "Authorization".to_string(), format!("Bearer {}", token) ); request } // Retry stage - transforms retry state based on error // Pure function: error in, new retry state out fn update_retry_state(mut retry: RetryState, error: RequestError) -> RetryState { retry.attempt += 1; retry.backoff = match &error { RequestError::Throttled { retry_after } => *retry_after, _ => retry.backoff * 2, // Exponential backoff }; retry.last_error = Some(error); retry } fn send_request(_request: &Request, _routing: &RoutingDecision) -> Result<Response, RequestError> { // Simulated - would actually make HTTP call Err(RequestError::ServiceUnavailable) } fn main() { let endpoints = vec![ "https://cosmos-east.azure.com".to_string(), "https://cosmos-west.azure.com".to_string(), ]; // Start with initial context let mut ctx = RequestContext { request: Request { method: Method::Get, path: "/dbs/mydb/colls/mycoll".to_string(), headers: HashMap::new(), body: None, }, routing: RoutingDecision::Pending, retry: RetryState { attempt: 0, max_attempts: 3, last_error: None, backoff: Duration::from_millis(100), }, }; loop { println!("Attempt {}", ctx.retry.attempt + 1); // Each stage transforms just its piece ctx.routing = select_endpoint(&ctx.retry, &endpoints); ctx.request = add_auth(ctx.request, "secret-token"); match send_request(&ctx.request, &ctx.routing) { Ok(response) => { println!("Success! Status: {}", response.status); break; } Err(e) => { println!("Error: {:?}", e); // Transform just the retry state ctx.retry = update_retry_state(ctx.retry, e); if ctx.retry.attempt >= ctx.retry.max_attempts { println!("Retries exhausted after {} attempts", ctx.retry.attempt); break; } } } } }
Notice how each function takes only what it needs:
select_endpointneeds retry state and endpoints, returns a routing decisionadd_authneeds the request, returns a transformed requestupdate_retry_stateneeds current state and error, returns new state
This is ownership in action - you can pass ctx.retry to a function without giving it access to ctx.request.
The Key DOP Insights
Notice what's different from the C# approach:
1. Context is composed of focused types
RetryState,RoutingDecision,Requestare separate types- Each can be transformed independently
- Related data stays together (retry count + backoff + last error)
2. Ownership enables narrow function signatures
select_endpoint(&RetryState, &[String]) -> RoutingDecision- Functions take only what they need - not the whole context
- The compiler enforces this boundary
3. Transformations replace, not mutate
ctx.retry = update_retry_state(ctx.retry, error)- Old state is consumed, new state takes its place
- No hidden side effects or spooky action at a distance
4. The type system documents data flow
- Reading the function signatures tells you what each stage touches
- In C#, any handler could modify any part of the request
5. Unit testing is trivial
update_retry_stateis a pure function: construct input, assert output- No mocking frameworks, no dependency injection, no test fixtures
- If it compiles and the test passes, it works
Enums for Request Decisions
The PipelineResult enum makes control flow explicit - a pattern you'll use constantly:
use std::time::Duration; // What should happen after each stage? enum PipelineAction { // Continue to the next stage Continue, // Retry with a different endpoint RetryDifferentEndpoint { reason: String }, // Retry after a delay (e.g., throttling) RetryAfterDelay { delay: Duration }, // Request is complete Done, // Unrecoverable failure Abort { error: String }, } fn decide_retry_strategy(status: u16, attempt: u32) -> PipelineAction { match (status, attempt) { (200..=299, _) => PipelineAction::Done, (429, a) if a < 3 => PipelineAction::RetryAfterDelay { delay: Duration::from_secs(1 << a) // Exponential backoff }, (503, a) if a < 3 => PipelineAction::RetryDifferentEndpoint { reason: "Service unavailable".to_string() }, (_, _) => PipelineAction::Abort { error: format!("Failed with status {}", status) }, } } fn main() { // Test the decision function println!("Status 200: {:?}", matches!(decide_retry_strategy(200, 0), PipelineAction::Done)); println!("Status 429 attempt 1: exponential backoff"); println!("Status 503 attempt 1: try different endpoint"); println!("Status 500 attempt 5: abort"); }
Compare this to C# where retry logic is often buried in handler implementations. Here, decide_retry_strategy is a pure function you can unit test trivially.
Data-Oriented Thinking for SDKs
This pattern scales to complex SDK scenarios:
- Connection pooling: Pool state is data, checkout/return are functions
- Request signing: Signature computation is a pure function on request data
- Caching: Cache is data (HashMap), lookup/store are functions
- Diagnostics: Trace context is data that flows through the pipeline
We still use impl blocks to group related functions with the data they operate on. But the focus is on data transformations rather than object behavior.
The DOP mindset: What data do I need? What transformations happen to it?