Walking through "The Java Tutorials" with Rust
Reading 'What Is Inheritance?' with Rust in mind
A hot evening in May 2021The next lesson in The Java Tutorials is "What Is Inheritance?".
The lesson introduces the model of inheritance which enables sharing state representation and behavior between a superclass and its subclasses.
Trying it with Rust
Looks like we've reached a hard part - rust doesn't support inheritance in the convenient way java supports it. Let's try few approaches to model the same Bicycle --> MountainBicycle relationship and afterward describe the relevant design choices in java and rust.
Bikes as Structs that implement a Bicycle
Trait
Rust provides Traits
that define behavior, and support defining default implementation based on trait methods
// we can define a Bicycle trait with default implementation for `print_states
trait Bicycle {
fn get_model() -> String;
fn get_cadence(&self) -> i32;
fn get_speed(&self) -> i32;
fn get_gear(&self) -> i32;
fn print_states(&self){
println!("model: {} cadence: {} speed: {} gear: {}", Self::get_model(), &self.get_cadence(), &self.get_speed(), &self.get_gear());
}
}
// we need to implement Bicycle for every struct separately
impl Bicycle for BasicBicycle {
fn get_cadence(&self) -> i32 {
self.cadence
}
fn get_speed(&self) -> i32 {
self.speed
}
fn get_gear(&self) -> i32 {
self.gear
}
fn get_model() -> String {
"basic".to_string()
}
// print_states have a default implementation
}
// this feels a bit verbose because we know MountainBicycle has all the members BasicBicycle have
// but I haven't found a way to formalize this relationship in Rust.
impl Bicycle for MountainBicycle {
fn get_cadence(&self) -> i32 {
self.cadence
}
fn get_speed(&self) -> i32 {
self.speed
}
fn get_gear(&self) -> i32 {
self.gear
}
fn get_model() -> String {
"mountain".to_string()
}
// print_states have a default implementation
}
Looks like traits default implementations allow behavior sharing, but not state model sharing (Trait defines only behavior and not state, one can have methods that mutate state but the trait would only contain fn change_something(&mut self)
without defining what changes inside self).
This approach also doesn't allow polymorphism on it's own because the complier would require to know which of the two structs you refer to:
#[test]
fn random_bike() {
let coin : bool = rand::random();
let mut bike;
if coin {
bike = BasicBicycle { cadence: 0, speed: 0, gear: 0};
} else {
// the next line won't compile
bike = MountainBicycle { cadence: 0, speed: 0, gear: 0, tires: "mountain tires".to_string()};
// mismatched types
// expected struct `BasicBicycle`, found struct `MountainBicycle`
}
bike.speed_up(123);
bike.print_states();
}
Rust does provide a way to model polymorphic objects (like java) with "trait objects" and the dyn
keyword. It feels non-trivial to me so I'll get to it later.
Bikes as enum
variants
Another possible approach is to define both bikes as enum variants, and then each method should match the enum variants:
enum Bicycle {
Basic {cadence : i32, speed: i32, gear: i32},
Mountain {cadence : i32, speed: i32, gear: i32, tires: String}
}
impl Bicycle {
fn print_states(&self) {
match self {
// There's even a shorthand for matching multiple variants with the same implementation
Bicycle::Basic { cadence, speed, gear } | Bicycle::Mountain { cadence, speed, gear, .. } => {
println!("cadence: {} speed: {} gear: {}", cadence, speed, gear)
}
}
}
fn speed_up(&mut self, increment : i32) {
match self {
Bicycle::Basic { ref mut speed, .. } | Bicycle::Mountain { ref mut speed, ..} => {
*speed += increment
}
}
}
}
This even allows something that feels like polymorphism! the test below flips a coin and creates a different Bicycle variant at runtime.
#[test]
fn random_bike() {
let coin : bool = rand::random();
let mut bike;
if coin {
bike = Bicycle::Basic { cadence: 0, speed: 0, gear: 0};
} else {
bike = Bicycle::Mountain { cadence: 0, speed: 0, gear: 0, tires: "mountain tires".to_string()};
}
bike.speed_up(123);
bike.print_states();
}
Running this test prints a different bike on every run:
It's not polymorphism in the java-like way: all enum variants of Bicycle
and the size of bike
are fixed and known in compile time, and there is a single speed_up
method that is called when bike.speed_up(123)
that deals with all the variants.
In the next page I'll try to learn what could be achieved with Rust's dyn
keyword that enables defining "trait objects" - objects with unknown size that all the compiler knows about them is the traits (i.e behavior/methods) they implement.