Composition instead of inheritance
Rust has no inheritance: a struct cannot extend another struct, and a method cannot be overridden. What it has instead is composition, a field that holds another value, and traits, which share behaviour without sharing data. Most class hierarchies translate into one of those two, and often into both.
This lesson builds the smallest car: an engine as a field, a trait for the part every vehicle shares, and a method that forwards to the inner value. Then it shows the enum form for a closed set of variants and the error for reaching for a field that belongs to the inner type.
A struct inside a struct
The car owns an engine, and the engine owns its power. Reading the power takes two dots: car.engine.power. The outer struct decides what it exposes, and the inner value keeps its own type and its own rules.
The program builds a car with an engine and prints the name and the power. Nothing is allocated: both structs are plain values on the stack.
struct Engine {
power: u32,
}
struct Car {
engine: Engine,
name: String,
}
fn main() {
let car = Car {
engine: Engine { power: 110 },
name: String::from("rust"),
};
println!("{} {}", car.name, car.engine.power);
}
rustc --edition 2024 composition.rs
./composition
rust 110
Replacing the engine with another type is one field change, and the rest of the car does not care. A base class would have made the engine a parent instead of a part, which is the design difference the lesson is about.
A trait for the shared part
The behaviour that several types share goes into a trait. An engine and a motor both start, and the trait gives the caller one name for both. The types do not share any data, only the promise.
The program calls start on both and prints the two strings. The trait is the common interface, and each implementation decides its own words.
trait Start {
fn start(&self) -> String;
}
struct Engine;
struct Motor;
impl Start for Engine {
fn start(&self) -> String {
String::from("engine started")
}
}
impl Start for Motor {
fn start(&self) -> String {
String::from("motor started")
}
}
fn main() {
println!("{}", Engine.start());
println!("{}", Motor.start());
}
rustc --edition 2024 trait_shared.rs
./trait_shared
engine started
motor started
The trait may be used as a bound, as a trait object or as a generic, which the lessons on traits and trait objects show. Inheritance would have shared the data as well, which is the part composition avoids.
Forwarding a method
The car exposes the engine power through its own method and calls the inner one. That forwarding is explicit, so the outer type can add a rule later without changing its callers, and the inner type stays replaceable.
The program calls car.power() and prints the value the engine reports. The two methods have the same name, and the compiler picks the outer one because of the receiver.
struct Engine {
power: u32,
}
impl Engine {
fn power(&self) -> u32 {
self.power
}
}
struct Car {
engine: Engine,
}
impl Car {
fn power(&self) -> u32 {
self.engine.power()
}
}
fn main() {
let car = Car { engine: Engine { power: 110 } };
println!("{}", car.power());
}
rustc --edition 2024 delegation.rs
./delegation
110
A wrapper that exposes everything of the inner type can implement Deref instead, which the standard library does for smart pointers. For a domain type, the explicit method is clearer and keeps the API small.
An enum instead of a hierarchy
When the set of kinds is closed, an enum is smaller and safer than a hierarchy. A vehicle is a car or a bike, and a match handles both without a vtable or a base class.
The program describes a car and a bike and prints the two strings. The match in the describe function is exhaustive, so a third kind would be a compile error until it is handled.
enum Vehicle {
Car { seats: u32 },
Bike { gears: u32 },
}
fn describe(vehicle: &Vehicle) -> String {
match vehicle {
Vehicle::Car { seats } => format!("car with {seats} seats"),
Vehicle::Bike { gears } => format!("bike with {gears} gears"),
}
}
fn main() {
println!("{}", describe(&Vehicle::Car { seats: 5 }));
println!("{}", describe(&Vehicle::Bike { gears: 21 }));
}
rustc --edition 2024 enum_alt.rs
./enum_alt
car with 5 seats
bike with 21 gears
The enum form is the one the lesson on enums with data builds in full. A trait object is the choice when the set of kinds is open and other crates may add to it.
A field of the inner type
Composition does not flatten the fields: car.power does not exist, because power belongs to the engine. The compiler reports E0609 and suggests the field path that does work, which is the whole lesson in one message.
The program reaches for the inner field directly. The help line names car.engine, and the fix is the second dot.
struct Engine {
power: u32,
}
struct Car {
engine: Engine,
}
fn main() {
let car = Car { engine: Engine { power: 110 } };
println!("{}", car.power);
}
rustc --edition 2024 field_err.rs
error[E0609]: no field `power` on type `Car`
--> field_err.rs:11:24
|
11 | println!("{}", car.power);
| ^^^^^ unknown field
|
help: one of the expressions' fields has a field of the same name
|
11 | println!("{}", car.engine.power);
| +++++++
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0609`.
The explicit path is a feature: the outer type decides what it exposes, and a change inside the engine does not leak into every caller. An accessor method can hide the path when the field is an implementation detail.
Exercises
- Add a second part to the car, a wheel, and give the car a method that sums the two weights.
- Implement the Start trait for the car by forwarding to its engine's method.
- Add a third variant to the vehicle enum and let the compiler list the matches that need an arm.
Questions
A new set of questions in a new order every time. Choose one answer for each, then check.
-
How does Rust share data between related types?
Answer
By owning a field of the other type
Composition puts the other value in a field, and the outer type decides what to expose.
-
What shares behaviour without sharing data?
Answer
A trait
A trait names methods that each type implements for itself.
-
What does a delegating method do?
Answer
It calls the inner method and returns its result
The outer method forwards to the inner one and may add its own rules around the call.
-
When is an enum better than a trait object?
Answer
When the set of variants is known and closed
A closed set is exhaustive and checked; an open set needs a trait object.
-
What does this program print?
struct Engine { power: u32, } struct Car { engine: Engine, name: String, } fn main() { let car = Car { engine: Engine { power: 110 }, name: String::from("rust"), }; println!("{} {}", car.name, car.engine.power); }Answer
rust 110
The car owns the engine, and the two dots reach the power inside it.
-
What does this program print?
trait Start { fn start(&self) -> String; } struct Engine; impl Start for Engine { fn start(&self) -> String { String::from("engine started") } } fn main() { println!("{}", Engine.start()); }Answer
engine started
The trait method takes a shared reference, and the implementation returns its string.
-
What does this program print?
struct Engine { power: u32, } impl Engine { fn power(&self) -> u32 { self.power } } struct Car { engine: Engine, } impl Car { fn power(&self) -> u32 { self.engine.power() } } fn main() { let car = Car { engine: Engine { power: 110 } }; println!("{}", car.power()); }Answer
110
The car method forwards to the engine method, and the receiver type picks the right one.
-
What does this program print?
enum Vehicle { Car { seats: u32 }, Bike { gears: u32 }, } fn describe(vehicle: &Vehicle) -> String { match vehicle { Vehicle::Car { seats } => format!("car with {seats} seats"), Vehicle::Bike { gears } => format!("bike with {gears} gears"), } } fn main() { println!("{}", describe(&Vehicle::Car { seats: 5 })); }Answer
car with 5 seats
The car variant matches its arm, and the payload is formatted into the string.
-
What is the first line of the compiler output for this program?
struct Engine { power: u32, } struct Car { engine: Engine, } fn main() { let car = Car { engine: Engine { power: 110 } }; println!("{}", car.power); }Answer
error[E0609]: no field
poweron typeCarThe field belongs to the engine, and the error lists the path that does work.
-
What does this program print?
struct Engine { power: u32, } struct Car { engine: Engine, } fn main() { let car = Car { engine: Engine { power: 110 } }; let engine = car.engine; println!("{}", engine.power); }Answer
110
The engine is moved out of the car, because the engine type is not Copy, and then read.
The code on this page was built with rustc 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.
Article author: Arthur Isaev