Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

PrefixParameterMeaning
into_selfconsumes and transforms
as_&selfconverts a reference to ourself into another type
to_&selfcreates 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 than String
  • 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

  1. Every value has exactly one owner
  2. When the owner goes out of scope, the value is dropped
  3. 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)
  • bool and char
  • 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:

  1. The variable must be declared mut
  2. 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

NeedSingle-ThreadedMulti-Threaded
Shared ownershipRc<T>Arc<T>
Interior mutabilityRefCell<T>Mutex<T> / RwLock<T>
BothRc<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# InterfaceRust Trait
Methods only (until C# 8)Methods, constants, types
Default implementations (C# 8+)Default implementations (always)
Implicit implementationExplicit impl Trait for Type
Runtime dispatch by defaultCompile-time dispatch by default
Part of type identityCapability 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
}
}
AspectC# GenericsRust Generics
When resolvedRuntimeCompile time
Code generatedOne versionOne per type
PerformanceVirtual dispatch possibleDirect calls, inlineable
Binary sizeSmallerLarger
Compile timeFasterSlower

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:

  1. No runtime cost - Calls are direct, can be inlined
  2. Type errors caught early - Can't pass wrong type
  3. 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:

  1. Each input reference gets its own lifetime
  2. If there's exactly one input lifetime, it's used for all outputs
  3. If there's &self or &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 type
  • fn foo<'a>(x: &'a str) - 'a is 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 reference
  • Arc<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:

  1. It doesn't return Self
  2. It doesn't have generic methods
  3. 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:

  1. Data and behavior are separate - Components are pure data, systems are pure functions
  2. Composition over inheritance - Capabilities come from having components, not from class hierarchies
  3. Clear data flow - Systems declare exactly what they read and write
  4. 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 ConceptHTTP Pipeline Equivalent
EntityRequestContext (just a container)
ComponentsRequest, RoutingDecision, RetryState
Systemsselect_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, ...) -> RoutingDecision
  • add_auth(Request, ...) -> Request
  • update_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_endpoint needs retry state and endpoints, returns a routing decision
  • add_auth needs the request, returns a transformed request
  • update_retry_state needs 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, Request are 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_state is 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?