OOP features
Trait objects and composition, and why Rust gives you objects without inheritance
"Is Rust object-oriented?" The honest answer: it depends on which features you count. Rust has encapsulation, methods, and dynamic dispatch. It does not have inheritance. If your definition of OOP requires inheritance, Rust is not OOP. If it requires polymorphism, Rust is.
Most agents trained heavily on Java, Python, C# arrive expecting class hierarchies. The job of the orchestrator is to redirect them toward traits, composition, and enums.
Objects without inheritance
Rust has structs with methods. That covers encapsulation:
pub struct Counter {
count: u64, // private
}
impl Counter {
pub fn new() -> Self {
Counter { count: 0 }
}
pub fn increment(&mut self) {
self.count += 1;
}
pub fn value(&self) -> u64 {
self.count
}
}External code cannot touch count directly. The methods are the API. That is encapsulation in the OOP sense.
What is missing: there is no way to write struct AuditedCounter extends Counter. Rust has no inheritance. The alternatives are composition (a struct that holds a Counter) and trait implementations.
Trait objects: dynamic dispatch
A trait can be used as a type when wrapped in a pointer with the dyn keyword:
pub trait Renderer {
fn render(&self, scene: &Scene) -> Image;
}
pub struct CpuRenderer;
pub struct GpuRenderer;
impl Renderer for CpuRenderer { /* ... */ }
impl Renderer for GpuRenderer { /* ... */ }
fn render_all(renderer: &dyn Renderer, scenes: &[Scene]) -> Vec<Image> {
scenes.iter().map(|s| renderer.render(s)).collect()
}&dyn Renderer is a "fat pointer": a pointer to the data plus a pointer to a vtable of method implementations. Calling renderer.render(s) looks up the right method through the vtable.
Compare with generics:
fn render_all<R: Renderer>(renderer: &R, scenes: &[Scene]) -> Vec<Image> {
scenes.iter().map(|s| renderer.render(s)).collect()
}The generic version monomorphizes: the compiler stamps out a fresh render_all for each concrete R. Faster at runtime, bigger binary, less flexibility about mixing types at runtime.
| Use trait objects when | Use generics when |
|---|---|
You need a Vec<Box<dyn Trait>> holding different concrete types | One concrete type per call site is fine |
| Plugin or extension points where types are not known at compile time | Inner loops where dispatch cost matters |
| API surfaces that should not leak generic parameters | Library code that wants zero-cost abstraction |
Storing trait objects
Trait objects need indirection because the concrete size is unknown at compile time. The common forms:
let r: Box<dyn Renderer> = Box::new(CpuRenderer);
let r: &dyn Renderer = &CpuRenderer;
let r: Arc<dyn Renderer + Send + Sync> = Arc::new(CpuRenderer);
let renderers: Vec<Box<dyn Renderer>> = vec![
Box::new(CpuRenderer),
Box::new(GpuRenderer),
];The + Send + Sync bounds are needed when the trait object will cross thread boundaries. Always include them on shared trait objects in async code.
Object safety
Not every trait can be used as dyn Trait. The trait must be "object safe": no generic methods, no Self in return position (mostly), no associated constants. The compiler tells you if you violate this:
error[E0038]: the trait `Foo` cannot be made into an objectThe fix is either to constrain the trait further or to use generics instead. For full rules, the Rust reference has the list. In practice, design extension-point traits to be object safe from the start.
The state pattern
The classic OOP "state pattern" translates cleanly into trait objects:
pub trait State {
fn next(self: Box<Self>) -> Box<dyn State>;
fn describe(&self) -> &str;
}
pub struct Draft;
pub struct Review;
pub struct Published;
impl State for Draft {
fn next(self: Box<Self>) -> Box<dyn State> { Box::new(Review) }
fn describe(&self) -> &str { "draft" }
}
impl State for Review { /* ... */ }
impl State for Published { /* ... */ }That works. But in Rust, the more idiomatic version is an enum:
pub trait State {
fn next(self: Box<Self>) -> Box<dyn State>;
fn describe(&self) -> &str;
}
pub struct Draft;
pub struct Review;
pub struct Published;
impl State for Draft { /* ... */ }
impl State for Review { /* ... */ }
impl State for Published { /* ... */ }
pub struct Post {
state: Option<Box<dyn State>>,
}pub enum State {
Draft,
Review,
Published,
}
impl State {
pub fn next(self) -> Self {
match self {
Self::Draft => Self::Review,
Self::Review => Self::Published,
Self::Published => Self::Published,
}
}
pub fn describe(&self) -> &str {
match self {
Self::Draft => "draft",
Self::Review => "review",
Self::Published => "published",
}
}
}
pub struct Post {
state: State,
}The enum version is shorter, faster (no heap, no vtable), and the compiler tells you when you add a new variant which functions you broke. The trait-object version makes sense only when external code needs to plug in new states without modifying the post module.
Composition over inheritance
Without inheritance, the answer to "share code between two types" is one of:
- Move the shared code into a free function that both types call.
- Move the shared state into a struct that both types embed as a field.
- Move the shared behavior into a trait with a default method, both types implement the trait.
pub trait HasTimestamp {
fn timestamp(&self) -> DateTime<Utc>;
fn age(&self) -> Duration {
Utc::now() - self.timestamp()
}
}Both Event and LogLine implement HasTimestamp by returning their own field. The age() method comes free via the default implementation.
"The agent wrote a class hierarchy"
The most common Rust-OOP failure mode: an agent fluent in Python or Java writes Rust that mimics inheritance with trait objects. You end up with Box<dyn Animal> instead of enum Animal { Dog, Cat, Bird }, plus a lot of accidental complexity from boxing, dynamic dispatch, and lifetime juggling.
The orchestrator's question for every trait the agent introduces: "Is this a closed set or an open extension point?" Closed set means enum. Open extension point (other crates can implement it) means trait. Most application code has very few real open extension points.