OOP / trait objects
Rust keeps objects and encapsulation. It refuses inheritance. Trait objects carry shared behavior at runtime.
<!-- hal:authoritative:yaml -->
Rust keeps objects and encapsulation. It refuses inheritance. Trait objects carry shared behavior at runtime.
§I - Frame
Duha session 21. Topics #21, TRPL Chapter 18: what counts as object-oriented, what Rust will and will not do for that style, and how a trait object stands in for many concrete types. Session 20 taught async / .await. This row leaves the runtime and opens polymorphism through traits.
The book opens with a frank map: by some definitions Rust is object oriented; by others it is not. Three characteristics usually appear in the debate: objects, encapsulation, and inheritance. Done-criteria for this row: state what Rust will and will not do for OOP, and use a trait object. The ch18-03 state-pattern walkthrough can wait; the ledger and the dyn pointer are enough for the Done cell.
Three moves land by the end:
- The Object Ledger: objects and encapsulation yes; classical inheritance no.
- The Dyn Pointer:
Box<dyn Trait>(or another pointer +dyn) is a trait object. - The Open Collection: trait objects let one
Vechold many concrete types that share a trait.
Done-criteria: Can state what Rust will and will not do for OOP, and use a trait object.
§II - The Object Ledger
Gang of Four style: an object packages data and the procedures that operate on that data. In Rust, structs and enums hold data; impl blocks hold methods. Under that definition, Rust qualifies.
Encapsulation is the next column. Private fields stay private unless you mark them pub. Listing 18-1 / 18-2 shape keeps the average honest by forcing every mutation through methods:
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
impl AveragedCollection {
pub fn add(&mut self, value: i32) {
self.list.push(value);
self.update_average();
}
pub fn remove(&mut self) -> Option<i32> {
let result = self.list.pop();
match result {
Some(value) => {
self.update_average();
Some(value)
}
None => None,
}
}
pub fn average(&self) -> f64 {
self.average
}
fn update_average(&mut self) {
let total: i32 = self.list.iter().sum();
self.average = total as f64 / self.list.len() as f64;
}
}
Callers see add, remove, and average. They never touch list or average directly, so the cache cannot drift. That is encapsulation without a class keyword.
Inheritance is the missing column. There is no parent struct whose fields and method bodies a child struct inherits. Default trait methods give limited reuse; they are not a type hierarchy. If a language must ship inheritance to count as OOP, Rust refuses that test on purpose.
Thus the Object Ledger: data+methods and pub boundaries yes; classical inheritance no.
§III - The Dyn Pointer
Chapter 8 used an enum when the set of types was closed at compile time. Chapter 18 needs an open set: a GUI library that draws components the library author has not named yet. Inheritance would invent a Component base class. Rust defines a trait instead.
Listing 18-3 through 18-5 shape:
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
A trait object is a pointer (Box, &, and friends) plus dyn plus the trait. It points at a concrete value and at a table of method addresses for that trait. Screen::run only needs draw; it does not need the concrete type name. The book stresses the limit: you cannot add fields to a trait object. Its job is shared behavior, not a bag of data like objects in some other languages.
Button and a user-defined SelectBox both implement Draw, then enter the vector as Box::new(...). At runtime, each draw call dispatches through the table for that value's type.
Thus the Dyn Pointer: pointer + dyn Trait abstracts over implementors the library did not list.
§IV - The Open Collection
Generics with a trait bound monomorphize to one concrete type per instantiation. Listing 18-6 shape (Screen<T: Draw> with Vec<T>) forces every component in that screen to be the same T. That is faster when the collection is homogeneous.
Trait objects trade that for openness. One Screen can hold Box<Button> and Box<SelectBox> in the same Vec<Box<dyn Draw>>. The cost is dynamic dispatch and the object-safety rules the book names later in the section (methods that need Self: Sized, or that return Self, cannot go on a trait object). For this row, remember the choice: closed homogeneous set → generic; open heterogeneous set → trait object.
Write a tiny proof without a full GUI:
trait Draw {
fn draw(&self);
}
struct Label { text: String }
struct Dot;
impl Draw for Label {
fn draw(&self) {
println!("label: {}", self.text);
}
}
impl Draw for Dot {
fn draw(&self) {
println!(".");
}
}
fn main() {
let screen: Vec<Box<dyn Draw>> = vec![
Box::new(Label { text: String::from("ready") }),
Box::new(Dot),
];
for component in screen.iter() {
component.draw();
}
}
Two concrete types, one trait, one vector. That is the Open Collection.
§V - Proof and close
- Fill the Object Ledger in one sentence each: objects, encapsulation, inheritance (yes / yes / no).
- Point at
AveragedCollectionand name which fields stay private and why. - Write
Box<dyn Draw>(or another pointer +dyn) and say what two things the trait object points at. - Contrast
Screen<T: Draw>withVec<Box<dyn Draw>>: which collection can mix concrete types?
Done-criteria: Can state what Rust will and will not do for OOP, and use a trait object.
Next: Patterns (Topics #22, TRPL Ch.19). Keep the Dyn Pointer; #22 leaves trait objects and opens pattern syntax.