Guide contents

Lesson 60 of 94

OOP Concepts in Rust

Rust has no classes, no inheritance, and no this. Yet everything you use object-oriented programming for is here: encapsulation, polymorphism, code reuse, and the design patterns built on them. They're assembled from parts you already know: structs and enums hold data, impl blocks add methods, modules control visibility, traits describe behavior, and generics or trait objects provide polymorphism.

This section puts those parts together. This lesson translates the OOP concepts one by one. The next three rebuild the Gang of Four design patterns: creational, structural, and behavioral.

OOP concept Rust
class (data + methods) struct or enum + impl block
constructor associated function, by convention new
this self, an explicit parameter
private fields private fields (per module)
static fields and methods associated constants and associated functions
equality, hashing, display, ordering PartialEq, Hash, Display, Ord, usually derived
interface trait
abstract class with shared code trait with default methods, plus composition for shared state
extends (implementation inheritance) composition and delegation
interface inheritance supertraits (trait Manager: Employee)
method overloading different names, generics, or Option parameters
protected, package-private no protected; pub(crate) and pub(super)
polymorphic reference Employee e &dyn Employee, Box<dyn Employee>, or a generic T: Employee
type test + cast match on an enum, or Any::downcast_ref
sealed hierarchy enum + match

Part 1: Classes and objects

1. A class becomes a struct plus an impl block

In Rust, data and behavior are declared separately. The struct lists the fields, an impl block adds the methods, and each trait implementation (here Display) gets its own impl block.

Rust
use std::fmt;

struct Counter {
    name: String,
    count: u32,
}

impl Counter {
    fn new(name: &str) -> Counter {
        Counter { name: name.to_string(), count: 0 }
    }

    fn increment(&mut self) {
        self.count += 1;
    }

    fn count(&self) -> u32 {
        self.count
    }
}

impl fmt::Display for Counter {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{}={}", self.name, self.count)
    }
}

fn main() {
    let mut clicks = Counter::new("clicks");
    clicks.increment();
    clicks.increment();
    println!("{clicks} (count() = {})", clicks.count());
}
Output
clicks=2 (count() = 2)

The receiver is explicit and says what the method does to the object: &self reads it, &mut self changes it, and self consumes it. Because increment takes &mut self, calling it requires let mut clicks. Mutability belongs to the variable, not to the class: a Counter held in a non-mut variable can't change at all.

2. Encapsulation

Fields are private to their module unless marked pub, and methods enforce invariants. The privacy boundary is the module, not the class (Modules & Visibility).

Rust
mod bank {
    pub struct Account {
        owner: String,
        balance_cents: i64, // private: only changed through methods
    }

    impl Account {
        pub fn open(owner: &str) -> Account {
            Account { owner: owner.to_string(), balance_cents: 0 }
        }

        pub fn deposit(&mut self, cents: i64) -> Result<(), String> {
            if cents <= 0 {
                return Err(format!("deposit must be positive, got {cents}"));
            }
            self.balance_cents += cents;
            Ok(())
        }

        pub fn withdraw(&mut self, cents: i64) -> Result<(), String> {
            if cents > self.balance_cents {
                return Err(format!("{} has only {} cents", self.owner, self.balance_cents));
            }
            self.balance_cents -= cents;
            Ok(())
        }

        pub fn balance(&self) -> i64 {
            self.balance_cents
        }
    }
}

fn main() {
    let mut acc = bank::Account::open("ana");
    println!("{:?}", acc.deposit(5_000));
    println!("{:?}", acc.withdraw(7_500));
    println!("{:?}", acc.deposit(-1));
    println!("balance: {}", acc.balance());
    // acc.balance_cents = 1_000_000; // error: field is private
}
Output
Ok(())
Err("ana has only 5000 cents")
Err("deposit must be positive, got -1")
balance: 5000

3. Constructors that guarantee valid objects

A constructor that validates its input returns a Result, because in Rust the constructor is an ordinary function. Because the field is private, parse is the only way to get an Email from outside the module, which means every Email in the program is valid and nobody has to check it again.

Rust
mod user {
    #[derive(Debug)]
    pub struct Email(String); // private field: can't be built from outside

    impl Email {
        pub fn parse(raw: &str) -> Result<Email, String> {
            let raw = raw.trim();
            match raw.split_once('@') {
                Some((local, domain)) if !local.is_empty() && domain.contains('.') => Ok(Email(raw.to_lowercase())),
                _ => Err(format!("{raw:?} is not an email address")),
            }
        }

        pub fn as_str(&self) -> &str {
            &self.0
        }
    }
}

use user::Email;

fn send_welcome(to: &Email) -> String {
    format!("welcome mail queued for {}", to.as_str()) // no need to re-validate
}

fn main() {
    for raw in ["Ana@Example.com ", "ben@localhost", "@x.org"] {
        match Email::parse(raw) {
            Ok(email) => println!("{}", send_welcome(&email)),
            Err(e) => println!("rejected: {e}"),
        }
    }
}
Output
welcome mail queued for ana@example.com
rejected: "ben@localhost" is not an email address
rejected: "@x.org" is not an email address

Trying to skip the check doesn't compile:

Rust✗ does not compile · E0603
mod user {
    pub struct Email(String);
}

fn main() {
    let email = user::Email(String::from("not-checked"));
}
Output
error[E0603]: tuple struct constructor `Email` is private
 --> src/main.rs:6:23
  |
2 |     pub struct Email(String);
  |                      ------ a constructor is private if any of the fields is private
...
6 |     let email = user::Email(String::from("not-checked"));
  |                       ^^^^^ private tuple struct constructor
  |
note: the tuple struct constructor `Email` is defined here
 --> src/main.rs:2:5
  |
2 |     pub struct Email(String);
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^
help: consider making the field publicly accessible
  |
2 |     pub struct Email(pub String);
  |                      +++

4. Getters and setters

Rust getters are named after the field (name(), not getName()) and return a reference for anything that isn't a cheap Copy value. Setters exist only when there is a rule to enforce. When a field has no invariant to protect, the idiomatic choice is to make it pub.

Rust
mod shop {
    pub struct Product {
        name: String,
        price_cents: u32,
        pub tags: Vec<String>, // no rule to protect: just make it public
    }

    impl Product {
        pub fn new(name: &str, price_cents: u32) -> Product {
            Product { name: name.to_string(), price_cents, tags: Vec::new() }
        }

        // Getter: named after the field, returns a borrow for non-Copy types.
        pub fn name(&self) -> &str {
            &self.name
        }

        pub fn price_cents(&self) -> u32 {
            self.price_cents
        }

        // Setter: only because there's a rule to enforce.
        pub fn set_price_cents(&mut self, cents: u32) -> Result<(), String> {
            if cents == 0 {
                return Err(String::from("price must be positive"));
            }
            self.price_cents = cents;
            Ok(())
        }
    }
}

fn main() {
    let mut p = shop::Product::new("lamp", 4_500);
    p.tags.push(String::from("home"));
    println!("{:?}", p.set_price_cents(0));
    println!("{:?}", p.set_price_cents(3_900));
    println!("{} costs {} {:?}", p.name(), p.price_cents(), p.tags);
}
Output
Err("price must be positive")
Ok(())
lamp costs 3900 ["home"]

Returning &str gives the caller read access without copying. A shared reference is already read-only, so no defensive copy is ever needed.

5. Static members: associated functions and constants

Functions in an impl block without a self parameter are called as Type::name(...). Associated constants belong to the type rather than to an instance. A group of utility functions that need no instance is simply a module of free functions.

Rust
struct Temperature {
    celsius: f64,
}

impl Temperature {
    const ABSOLUTE_ZERO: f64 = -273.15;

    fn from_fahrenheit(f: f64) -> Temperature {
        Temperature { celsius: (f - 32.0) * 5.0 / 9.0 }
    }

    fn is_physical(&self) -> bool {
        self.celsius >= Self::ABSOLUTE_ZERO
    }
}

const MAX_READINGS: usize = 3; // a module-level constant, no class needed

fn main() {
    let readings = [Temperature::from_fahrenheit(98.6), Temperature { celsius: -300.0 }, Temperature { celsius: 21.0 }];
    for t in readings.iter().take(MAX_READINGS) {
        println!("{:>7.2} C physical: {}", t.celsius, t.is_physical());
    }
    println!("absolute zero: {}", Temperature::ABSOLUTE_ZERO);
}
Output
  37.00 C physical: true
-300.00 C physical: false
  21.00 C physical: true
absolute zero: -273.15

Mutable static state, such as an instance counter, is possible but has to be thread-safe; it's done with atomics (Atomics, Send & Sync) or with a lazily created global, shown as the singleton in the next lesson.

6. Equality, hashing, display, and ordering

Equality, hashing, display, and ordering are opt-in traits, and derive generates correct field-by-field implementations that always agree with each other (Deriving Standard Traits).

Rust
use std::collections::HashSet;
use std::fmt;

#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
struct Version {
    major: u32,
    minor: u32,
    patch: u32,
}

// toString for users: Display is always written by hand.
impl fmt::Display for Version {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "v{}.{}.{}", self.major, self.minor, self.patch)
    }
}

fn v(major: u32, minor: u32, patch: u32) -> Version {
    Version { major, minor, patch }
}

fn main() {
    let mut releases = vec![v(1, 10, 0), v(1, 2, 3), v(2, 0, 0), v(1, 2, 3)];
    println!("equal: {}", releases[1] == releases[3]); // equals

    releases.sort(); // compareTo: field by field, in declaration order
    let names: Vec<String> = releases.iter().map(|r| r.to_string()).collect();
    println!("sorted: {}", names.join(", "));

    let unique: HashSet<Version> = releases.iter().cloned().collect(); // hashCode + equals
    println!("{} unique, newest {}", unique.len(), releases.last().unwrap());
    println!("debug: {:?}", releases[0]); // toString for developers
}
Output
equal: true
sorted: v1.2.3, v1.2.3, v1.10.0, v2.0.0
3 unique, newest v2.0.0
debug: Version { major: 1, minor: 2, patch: 3 }

Note that v1.2.3 sorts before v1.10.0: the derived ordering compares the numbers, not the text.

Part 2: Interfaces, inheritance, and polymorphism

7. Interfaces and abstract classes become traits

A trait is an interface; Box<dyn Trait> is a polymorphic reference. Default methods provide shared behavior that implementations can override.

Rust
trait Employee {
    fn name(&self) -> &str;
    fn base_salary(&self) -> u32;

    fn bonus(&self) -> u32 {
        0 // default: no bonus
    }

    fn total_pay(&self) -> u32 {
        self.base_salary() + self.bonus() // uses the overridable parts
    }
}

struct Engineer {
    name: String,
    salary: u32,
}

struct Manager {
    name: String,
    salary: u32,
    reports: u32,
}

impl Employee for Engineer {
    fn name(&self) -> &str {
        &self.name
    }
    fn base_salary(&self) -> u32 {
        self.salary
    }
}

impl Employee for Manager {
    fn name(&self) -> &str {
        &self.name
    }
    fn base_salary(&self) -> u32 {
        self.salary
    }
    fn bonus(&self) -> u32 {
        self.reports * 2_000 // override
    }
}

fn main() {
    let staff: Vec<Box<dyn Employee>> = vec![
        Box::new(Engineer { name: String::from("Ana"), salary: 95_000 }),
        Box::new(Manager { name: String::from("Ben"), salary: 110_000, reports: 6 }),
    ];
    for e in &staff {
        println!("{:<4} {:>7}", e.name(), e.total_pay());
    }
    println!("payroll: {}", staff.iter().map(|e| e.total_pay()).sum::<u32>());
}
Output
Ana    95000
Ben   122000
payroll: 217000

What a trait can't have is fields. Shared state goes in a struct, as example 12 shows.

8. Calling the "super" implementation

An override often calls super.method() and adds to it. A Rust implementation that overrides a default method can't call the default it replaced. The idiom is to move the default body into a function of its own, which both the default and the overrides call.

Rust
trait Notifier {
    fn format(&self, msg: &str) -> String {
        default_format(msg)
    }

    fn send(&self, msg: &str) {
        println!("{}", self.format(msg));
    }
}

// The "super" implementation, callable from overrides.
fn default_format(msg: &str) -> String {
    format!("[notice] {msg}")
}

struct Plain;
struct Urgent;

impl Notifier for Plain {} // keeps every default

impl Notifier for Urgent {
    fn format(&self, msg: &str) -> String {
        format!("{}!!!", default_format(msg).to_uppercase())
    }
}

fn main() {
    Plain.send("backup finished");
    Urgent.send("disk almost full");
}
Output
[notice] backup finished
[NOTICE] DISK ALMOST FULL!!!

impl Notifier for Plain {} is an empty implementation: every method has a default, so there's nothing to write.

9. Static polymorphism with generics

When the concrete type is known at compile time, generics give the same polymorphism with no vtable, and each call can be inlined. Rust makes you pick between the two (Trait Objects & Dynamic Dispatch compares the two in detail).

Rust
trait Shape {
    fn area(&self) -> f64;
    fn name(&self) -> String;
}

struct Circle(f64);
struct Rect(f64, f64);

impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.0 * self.0
    }
    fn name(&self) -> String {
        format!("circle r={}", self.0)
    }
}

impl Shape for Rect {
    fn area(&self) -> f64 {
        self.0 * self.1
    }
    fn name(&self) -> String {
        format!("rect {}x{}", self.0, self.1)
    }
}

fn describe(s: &impl Shape) -> String {
    format!("{} has area {:.2}", s.name(), s.area()) // compiled once per concrete type
}

fn largest<'a>(shapes: &'a [&'a dyn Shape]) -> Option<&'a dyn Shape> {
    shapes.iter().copied().max_by(|a, b| a.area().total_cmp(&b.area())) // dynamic dispatch
}

fn main() {
    println!("{}", describe(&Circle(1.0)));
    println!("{}", describe(&Rect(2.0, 3.0)));
    let all: [&dyn Shape; 2] = [&Circle(1.5), &Rect(2.0, 3.0)];
    println!("largest: {}", largest(&all).unwrap().name());
}
Output
circle r=1 has area 3.14
rect 2x3 has area 6.00
largest: circle r=1.5

10. Composition instead of inheritance

Instead of class Car extends Vehicle, a Car has the parts it needs and delegates to them. It's more typing than extends, but every dependency is explicit and nothing is inherited by accident (the "fragile base class" problem can't happen).

Rust
struct Engine {
    horsepower: u32,
    running: bool,
}

impl Engine {
    fn start(&mut self) -> &'static str {
        self.running = true;
        "engine started"
    }
}

struct Gps {
    location: (f64, f64),
}

impl Gps {
    fn position(&self) -> String {
        format!("{:.4}, {:.4}", self.location.0, self.location.1)
    }
}

struct Car {
    model: String,
    engine: Engine, // has-a
    gps: Gps,       // has-a
}

impl Car {
    fn start(&mut self) -> String {
        format!("{}: {}", self.model, self.engine.start()) // explicit delegation
    }

    fn where_am_i(&self) -> String {
        self.gps.position()
    }
}

fn main() {
    let mut car = Car { model: String::from("Roadster"), engine: Engine { horsepower: 300, running: false }, gps: Gps { location: (52.5200, 13.4050) } };
    println!("{}", car.start());
    println!("{} hp, running: {}", car.engine.horsepower, car.engine.running);
    println!("at {}", car.where_am_i());
}
Output
Roadster: engine started
300 hp, running: true
at 52.5200, 13.4050

11. "Extending" a type: wrap it and forward

Where a subclass would inherit every method and override a few, in Rust Puppy contains a Dog and implements the same trait, forwarding the methods it doesn't change.

Rust
trait Animal {
    fn name(&self) -> String;
    fn sound(&self) -> String;
    fn legs(&self) -> u32;

    fn describe(&self) -> String {
        format!("{} says {} on {} legs", self.name(), self.sound(), self.legs())
    }
}

struct Dog {
    name: String,
}

impl Animal for Dog {
    fn name(&self) -> String {
        self.name.clone()
    }
    fn sound(&self) -> String {
        String::from("woof")
    }
    fn legs(&self) -> u32 {
        4
    }
}

struct Puppy {
    dog: Dog,
    age_weeks: u32,
}

impl Animal for Puppy {
    fn name(&self) -> String {
        format!("{} ({} weeks)", self.dog.name(), self.age_weeks) // extend
    }
    fn sound(&self) -> String {
        String::from("yip") // override
    }
    fn legs(&self) -> u32 {
        self.dog.legs() // forward unchanged
    }
}

fn main() {
    let pets: Vec<Box<dyn Animal>> = vec![
        Box::new(Dog { name: String::from("Rex") }),
        Box::new(Puppy { dog: Dog { name: String::from("Bit") }, age_weeks: 9 }),
    ];
    for p in &pets {
        println!("{}", p.describe());
    }
}
Output
Rex says woof on 4 legs
Bit (9 weeks) says yip on 4 legs

Every forwarded method is written out. That's the price of having no inheritance; the payoff is that Puppy's behavior is fully visible in one place, and a new method added to Dog can't silently change it. It's tempting to implement Deref<Target = Dog> for Puppy to "inherit" Dog's methods; Don't use Deref for inheritance explains why that backfires.

12. Shared state plus varying behavior

An abstract class often combines shared fields with abstract methods. In Rust, split the two: a struct holds the shared state and the common logic, and a trait (or a closure) supplies the varying part.

Rust
trait DiscountPolicy {
    fn discount(&self, subtotal: u32) -> u32;
}

struct NoDiscount;
struct Percentage(u32);
struct BulkOver { threshold: u32, off: u32 }

impl DiscountPolicy for NoDiscount {
    fn discount(&self, _: u32) -> u32 {
        0
    }
}
impl DiscountPolicy for Percentage {
    fn discount(&self, subtotal: u32) -> u32 {
        subtotal * self.0 / 100
    }
}
impl DiscountPolicy for BulkOver {
    fn discount(&self, subtotal: u32) -> u32 {
        if subtotal > self.threshold { self.off } else { 0 }
    }
}

// The "abstract base class": shared fields and logic, with the variable part plugged in.
struct Checkout {
    items: Vec<(String, u32)>,
    policy: Box<dyn DiscountPolicy>,
}

impl Checkout {
    fn total(&self) -> u32 {
        let subtotal: u32 = self.items.iter().map(|(_, p)| p).sum();
        subtotal - self.policy.discount(subtotal).min(subtotal)
    }
}

fn main() {
    let items = || vec![(String::from("book"), 3_000), (String::from("lamp"), 4_500)];
    let policies: Vec<(&str, Box<dyn DiscountPolicy>)> =
        vec![("none", Box::new(NoDiscount)), ("10%", Box::new(Percentage(10))), ("bulk", Box::new(BulkOver { threshold: 5_000, off: 1_000 }))];
    for (name, policy) in policies {
        println!("{name:<5} {}", Checkout { items: items(), policy }.total());
    }
}
Output
none  7500
10%   6750
bulk  6500

13. Supertraits: interface inheritance

trait Manager: Employee means every Manager must also be an Employee, and Manager's methods can call Employee's. Only interfaces inherit, never implementations.

Rust
use std::fmt::Display;

trait Entity: Display {
    fn id(&self) -> u32;
}

trait Auditable: Entity {
    fn changes(&self) -> Vec<String>;

    fn audit_log(&self) -> String {
        // Can use methods from both supertraits: Display and Entity.
        format!("#{} {}: {}", self.id(), self, self.changes().join("; "))
    }
}

struct Invoice {
    id: u32,
    total: u32,
    edits: Vec<String>,
}

impl Display for Invoice {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(f, "Invoice(total={})", self.total)
    }
}

impl Entity for Invoice {
    fn id(&self) -> u32 {
        self.id
    }
}

impl Auditable for Invoice {
    fn changes(&self) -> Vec<String> {
        self.edits.clone()
    }
}

fn main() {
    let inv = Invoice { id: 17, total: 4_200, edits: vec![String::from("created"), String::from("discount applied")] };
    println!("{}", inv.audit_log());
}
Output
#17 Invoice(total=4200): created; discount applied

14. Overloading: different names, generics, or Option

Rust has no method overloading: a type can't have two methods with the same name. Overloads usually fall into three groups, and each has a Rust replacement: several input types become one generic parameter (often impl Into<T>), different meanings get different names, and optional arguments become Option parameters (or a builder, in the next lesson).

Rust
#[derive(Debug, Clone, Copy)]
struct Point {
    x: i32,
    y: i32,
}

impl From<(i32, i32)> for Point {
    fn from((x, y): (i32, i32)) -> Point {
        Point { x, y }
    }
}

impl From<[i32; 2]> for Point {
    fn from([x, y]: [i32; 2]) -> Point {
        Point { x, y }
    }
}

#[derive(Default)]
struct Canvas {
    log: Vec<String>,
}

impl Canvas {
    // One generic method instead of dot(Point), dot(int[]), dot(int, int)...
    fn dot(&mut self, at: impl Into<Point>) {
        let p = at.into();
        self.log.push(format!("dot at ({}, {})", p.x, p.y));
    }

    // A different name where the meaning differs.
    fn line(&mut self, from: impl Into<Point>, to: impl Into<Point>) {
        let (a, b) = (from.into(), to.into());
        self.log.push(format!("line ({}, {}) -> ({}, {})", a.x, a.y, b.x, b.y));
    }

    // Option for an optional argument, instead of an overload without it.
    fn text(&mut self, s: &str, size: Option<u32>) {
        self.log.push(format!("text {s:?} at size {}", size.unwrap_or(12)));
    }
}

fn main() {
    let mut c = Canvas::default();
    c.dot(Point { x: 1, y: 2 });
    c.dot((3, 4));
    c.dot([5, 6]);
    c.line((0, 0), [10, 10]);
    c.text("hi", None);
    c.text("TITLE", Some(24));
    println!("{}", c.log.join("\n"));
}
Output
dot at (1, 2)
dot at (3, 4)
dot at (5, 6)
line (0, 0) -> (10, 10)
text "hi" at size 12
text "TITLE" at size 24

The standard library follows the same conventions: Vec::new and Vec::with_capacity instead of two constructors, String::from accepting several types through the From trait.

15. protected and package-private

There's no protected, because there are no subclasses to protect anything for. Visibility is about modules instead: pub(crate) is visible in the whole crate (close to package-private), pub(super) to the parent module, and a child module can see the private items of its ancestors.

Rust
mod billing {
    pub struct Invoice {
        pub number: u32,
        pub(crate) internal_note: String, // visible anywhere in this crate
        total_cents: u64,                 // private to `billing` and its child modules
    }

    impl Invoice {
        pub fn new(number: u32, total_cents: u64) -> Invoice {
            Invoice { number, internal_note: String::new(), total_cents }
        }
    }

    pub mod pdf {
        // A child module sees its parent's private fields.
        pub fn render(inv: &super::Invoice) -> String {
            format!("INVOICE {} total {}.{:02}", inv.number, inv.total_cents / 100, inv.total_cents % 100)
        }

        pub(super) fn watermark() -> &'static str {
            "DRAFT" // callable from `billing`, but not from outside it
        }
    }

    pub fn render_draft(inv: &Invoice) -> String {
        format!("{} [{}]", pdf::render(inv), pdf::watermark())
    }
}

fn main() {
    let mut inv = billing::Invoice::new(1042, 129_990);
    inv.internal_note.push_str("customer asked for split payment");
    println!("{}", billing::pdf::render(&inv));
    println!("{}", billing::render_draft(&inv));
    println!("note: {}", inv.internal_note);
    // inv.total_cents;           // error: private field
    // billing::pdf::watermark(); // error: private function
}
Output
INVOICE 1042 total 1299.90
INVOICE 1042 total 1299.90 [DRAFT]
note: customer asked for split payment

16. Type tests and downcasting

To branch on the concrete type of a value, use an enum and match if you control the set of types: the compiler then checks that every case is handled. For open-ended trait objects, Any allows downcasting, but needing it is usually a design smell.

Rust
use std::any::Any;

// Closed set: an enum is the idiomatic answer.
enum Payment {
    Card { last4: String },
    BankTransfer { iban: String },
    Voucher(u32),
}

fn describe(p: &Payment) -> String {
    match p {
        Payment::Card { last4 } => format!("card ending {last4}"),
        Payment::BankTransfer { iban } => format!("transfer from {}...", &iban[..4]),
        Payment::Voucher(code) => format!("voucher #{code}"),
    }
}

// Open set: downcast through Any.
trait Plugin: Any {
    fn name(&self) -> &str;
    fn as_any(&self) -> &dyn Any;
}

struct Logger {
    level: u8,
}

impl Plugin for Logger {
    fn name(&self) -> &str {
        "logger"
    }
    fn as_any(&self) -> &dyn Any {
        self
    }
}

fn main() {
    for p in [Payment::Card { last4: String::from("4242") }, Payment::BankTransfer { iban: String::from("DE89370400440532013000") }, Payment::Voucher(77)] {
        println!("{}", describe(&p));
    }

    let plugins: Vec<Box<dyn Plugin>> = vec![Box::new(Logger { level: 3 })];
    for p in &plugins {
        if let Some(logger) = p.as_any().downcast_ref::<Logger>() {
            println!("{} plugin at level {}", p.name(), logger.level);
        }
    }
}
Output
card ending 4242
transfer from DE89...
voucher #77
logger plugin at level 3

17. Sealed hierarchies are enums

A closed hierarchy is a Rust enum with match, which Rust has had since 1.0. Add a variant, and every match that doesn't handle it stops compiling, with the compiler pointing at each one:

Rust✗ does not compile · E0004
enum Shape {
    Circle { r: f64 },
    Rect { w: f64, h: f64 },
    Triangle { base: f64, height: f64 }, // newly added
}

fn area(s: &Shape) -> f64 {
    match s {
        Shape::Circle { r } => std::f64::consts::PI * r * r,
        Shape::Rect { w, h } => w * h,
    }
}

fn main() {
    println!("{}", area(&Shape::Rect { w: 2.0, h: 3.0 }));
}
Output
error[E0004]: non-exhaustive patterns: `&Shape::Triangle { .. }` not covered
  --> src/main.rs:8:11
   |
 8 |     match s {
   |           ^ pattern `&Shape::Triangle { .. }` not covered
   |
note: `Shape` defined here
  --> src/main.rs:1:6
   |
 1 | enum Shape {
   |      ^^^^^
...
 4 |     Triangle { base: f64, height: f64 }, // newly added
   |     -------- not covered
   = note: the matched value is of type `&Shape`
help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown
   |
10 ~         Shape::Rect { w, h } => w * h,
11 ~         &Shape::Triangle { .. } => todo!(),
   |

18. Enums, trait objects, or generics?

The three tools overlap, and choosing well is most of object-oriented design in Rust:

enum + match trait objects (dyn Trait) generics (T: Trait)
Set of types closed, known to you open: anyone can add a type open
Mixed types in one collection yes yes no, one type per instance
Adding a new operation easy: one new function edit the trait and every impl edit the trait and every impl
Adding a new type edit every match (the compiler lists them) easy: one new impl easy: one new impl
Dispatch match, inlined vtable call static, inlined

A good default: start with an enum when you own all the variants, use a trait when others must be able to add types (plugins, libraries), and write generic functions over the trait when callers use one type at a time.

Part 3: Putting it together

19. Realistic example: a class hierarchy as an enum

Consider an account hierarchy: an abstract base class with shared state, and subclasses that override parts of the behavior. The account kinds are a closed set, so the shared state stays in one struct and the kind-specific data in an enum:

Rust
enum Kind {
    Checking { overdraft_cents: i64 },
    Savings { rate_bp: i64 },
}

struct Account {
    holder: String,
    balance_cents: i64,
    kind: Kind,
}

impl Account {
    fn monthly_fee_cents(&self) -> i64 {
        match self.kind {
            Kind::Checking { .. } => 500,
            Kind::Savings { .. } => 0,
        }
    }

    fn can_withdraw(&self, cents: i64) -> bool {
        let limit = match self.kind {
            Kind::Checking { overdraft_cents } => self.balance_cents + overdraft_cents,
            Kind::Savings { .. } => self.balance_cents,
        };
        cents <= limit
    }

    fn withdraw(&mut self, cents: i64) -> Result<(), String> {
        if !self.can_withdraw(cents) {
            return Err(format!("{}: insufficient funds for {cents}", self.holder));
        }
        self.balance_cents -= cents;
        Ok(())
    }

    fn end_of_month(&mut self) {
        self.balance_cents -= self.monthly_fee_cents();
        if let Kind::Savings { rate_bp } = self.kind {
            self.balance_cents += self.balance_cents * rate_bp / 10_000 / 12;
        }
    }
}

fn main() {
    let mut accounts = vec![
        Account { holder: String::from("ana"), balance_cents: 10_000, kind: Kind::Checking { overdraft_cents: 5_000 } },
        Account { holder: String::from("ben"), balance_cents: 120_000, kind: Kind::Savings { rate_bp: 300 } },
        Account { holder: String::from("cy"), balance_cents: 8_000, kind: Kind::Savings { rate_bp: 300 } },
    ];

    for acc in &mut accounts {
        match acc.withdraw(12_000) {
            Ok(()) => println!("{}: withdrew 12000", acc.holder),
            Err(e) => println!("{e}"),
        }
    }
    for acc in &mut accounts {
        acc.end_of_month();
        println!("{:<3} balance after month end: {}", acc.holder, acc.balance_cents);
    }
}
Output
ana: withdrew 12000
ben: withdrew 12000
cy: insufficient funds for 12000
ana balance after month end: -2500
ben balance after month end: 108270
cy  balance after month end: 8020

Every rule for every account kind is in one method, and adding a kind makes the compiler point at each method that needs a new arm. If third parties had to add their own account kinds, you would switch to a trait, as in the next example.

20. Realistic example: an open hierarchy of exporters

Report exporters are a good fit for a trait: new formats get added over time, possibly by other teams. Each exporter implements the trait, and a registry of trait objects picks one at runtime.

Rust
struct Row {
    name: &'static str,
    qty: u32,
}

trait Exporter {
    fn name(&self) -> &str;

    fn extension(&self) -> &str {
        "txt"
    }

    fn export(&self, rows: &[Row]) -> String;
}

struct Csv;
struct Markdown;
struct Summary;

impl Exporter for Csv {
    fn name(&self) -> &str {
        "csv"
    }
    fn extension(&self) -> &str {
        "csv"
    }
    fn export(&self, rows: &[Row]) -> String {
        let mut out = String::from("name,qty");
        for r in rows {
            out.push_str(&format!("\n{},{}", r.name, r.qty));
        }
        out
    }
}

impl Exporter for Markdown {
    fn name(&self) -> &str {
        "markdown"
    }
    fn extension(&self) -> &str {
        "md"
    }
    fn export(&self, rows: &[Row]) -> String {
        let mut out = String::from("| name | qty |\n|---|---|");
        for r in rows {
            out.push_str(&format!("\n| {} | {} |", r.name, r.qty));
        }
        out
    }
}

impl Exporter for Summary {
    fn name(&self) -> &str {
        "summary"
    }
    fn export(&self, rows: &[Row]) -> String {
        let total: u32 = rows.iter().map(|r| r.qty).sum();
        format!("{} rows, {} items", rows.len(), total)
    }
}

fn find<'a>(exporters: &'a [Box<dyn Exporter>], name: &str) -> Option<&'a dyn Exporter> {
    exporters.iter().find(|e| e.name() == name).map(|e| e.as_ref())
}

fn main() {
    let exporters: Vec<Box<dyn Exporter>> = vec![Box::new(Csv), Box::new(Markdown), Box::new(Summary)];
    let rows = [Row { name: "bolts", qty: 120 }, Row { name: "nuts", qty: 80 }];

    for wanted in ["markdown", "summary", "pdf"] {
        match find(&exporters, wanted) {
            Some(e) => println!("report.{}:\n{}\n", e.extension(), e.export(&rows)),
            None => println!("no exporter named {wanted:?}"),
        }
    }
}
Output
report.md:
| name | qty |
|---|---|
| bolts | 120 |
| nuts | 80 |

report.txt:
2 rows, 200 items

no exporter named "pdf"

Summary

OOP concept Rust
class struct or enum + impl block; each trait gets its own impl
constructor an associated function (new, parse, from_...), returning Result when it can fail
encapsulation private fields, per module; getters named after the field
static members associated functions and constants; modules of free functions
equality / hashing / display / ordering derived PartialEq, Eq, Hash, Ord, Debug; hand-written Display
interface / abstract class trait with default methods; shared state in a struct
super.method() put the default body in a function both can call
inheritance composition, with explicit forwarding; supertraits for interfaces
overloading generics (impl Into<T>), different names, Option parameters
protected none; pub(crate) and pub(super)
type test match on an enum; Any::downcast_ref for open sets
sealed hierarchy enum + exhaustive match

Next: Creational Patterns: singletons, factories, builders, prototypes, and dependency injection without a framework.