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.
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());
}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).
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
}Ok(())
Err("ana has only 5000 cents")
Err("deposit must be positive, got -1")
balance: 50003. 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.
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}"),
}
}
}welcome mail queued for ana@example.com
rejected: "ben@localhost" is not an email address
rejected: "@x.org" is not an email addressTrying to skip the check doesn't compile:
mod user {
pub struct Email(String);
}
fn main() {
let email = user::Email(String::from("not-checked"));
}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.
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);
}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.
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);
} 37.00 C physical: true
-300.00 C physical: false
21.00 C physical: true
absolute zero: -273.15Mutable 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).
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
}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.
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>());
}Ana 95000
Ben 122000
payroll: 217000What 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.
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");
}[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).
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());
}circle r=1 has area 3.14
rect 2x3 has area 6.00
largest: circle r=1.510. 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).
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());
}Roadster: engine started
300 hp, running: true
at 52.5200, 13.405011. "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.
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());
}
}Rex says woof on 4 legs
Bit (9 weeks) says yip on 4 legsEvery 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.
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());
}
}none 7500
10% 6750
bulk 650013. 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.
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());
}#17 Invoice(total=4200): created; discount applied14. 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).
#[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"));
}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 24The 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.
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
}INVOICE 1042 total 1299.90
INVOICE 1042 total 1299.90 [DRAFT]
note: customer asked for split payment16. 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.
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);
}
}
}card ending 4242
transfer from DE89...
voucher #77
logger plugin at level 317. 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:
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 }));
}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:
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);
}
}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: 8020Every 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.
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:?}"),
}
}
}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.
