Walking through "The Java Tutorials" with Rust

Reading 'What Is Inheritance?' with Rust in mind

A hot evening in May 2021

The 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:

A gif showing that on every run we get a different bike

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.