Contents

Polymorphism in Rust

Overview

Rust doesn’t support the traditional concept of inheritance, and given that many popular modern languages do, that can be a little tough for some folks to come to terms with. Some even argue that a language cannot be considered "object oriented" if it doesn't support inheritance. But Rust does support polymorphism, and you can still write object oriented Rust in a way that is pretty similar to other languages.

But there are some key concepts that, if you're not aware of, can really trip you up. We are going to demystify those concepts in this blog post.

Of course by polymorphism I mean the ability to potentially treat the same piece of data as different types depending on what you need to do with it.

An easy example of polymorphism is if we consider different kinds of vehicles. There are different types of vehicles that are capable of driving on land, different types of vehicles that are capable of floating on water, and some special vehicles that can do both.

Imagine that we want to write some business logic where the specific type of vehicle is irrelevant, but the type of surface the vehcile can traverse is relevant.

If you're familiar with implementing Polymorphism in other languages, this example may feel really familiar in the beginning. But don't worry, there's a curve ball in the middle that you might not see coming that'll make things a little more interesting.

The Code

Of course in Rust, we don’t have classes, we have structs. Structs can have methods which take self as a parameter.

struct Sedan;
impl Sedan {
	fn drive(&self) {
		println!("Car is driving");
	}
}

But what if we wanted to write a function road_trip that takes one parameter, which can be any vehicle capable of traversing land. All it’s going to do is call the drive method.

fn road_trip(vehicle: &Sedan) {
	vehicle.drive();
}

Before we move forward let's make a main function to test this out:

fn main() {
	let car = Sedan;
	road_trip(&car);
}

If I were to make another vehicle, say SUV:

struct SUV;
impl SUV {
    fn drive(&self) {
        println!("SUV is driving");
    }
}

I'd have to make another road_trip function just to take SUV as a parameter:

fn road_trip(vehicle: &SUV) {
	vehicle.drive();
}

As you probably know if you’re familiar with the concept of interfaces in languages like Java or TypeScript, there’s a way to implement this function in a way that can accommodate any struct that implements the .drive() method.

Rust has something similar called traits that are sort of similar to interfaces, but not exactly the same. They provide the type of polymorphism we need in this scenario.

trait LandCapable {
	fn drive(&self);
}

Then we can have our road_trip function accept parameter that implements the LandCapable trait.

If you’ve used any object oriented language before, this might seem pretty familiar so far. But things are about to get a little strange

You might think you can just do this:

fn road_trip(vehicle: &LandCapable) {
	vehicle.drive();
}

But that won’t compile. The error we get says “add dyn keyword before this trait”.

Dyanmic vs Static Dispatch

Let's take that at face value and add the dyn keyword:

fn road_trip(vehicle: &dyn LandCapable) {
	vehicle.drive();
}

And it compiles and works as expected. What does the dyn keyword do and why is it required?

This is a good example of running face-first into one of Rust’s core tenets - which is “zero cost abstractions”. If you’re doing something that is going to add runtime cost, Rust will force you to be explicit about it.

When you prefix a trait with dyn, it is creating something called a “trait object”.

In this case, the dyn keyword refers to something called “Dynamic Dispatch”. Because LandCapable is a trait that could be implemented by anything, Rust needs to use something called a “fat pointer” to refer to the value passed in to the function. A “fat pointer” is comprised of two pointers - one that points to the implementor’s data, and another that points to something called a vtable which contains pointers to all of the implementor’s functions.

That’s a long winded explanation - the main thing need to know is that there is a runtime cost incurred when using dyn.

There’s also an alternative to dyn - and that is using what’s called static dispatch, which involves duplicating the relevant function for each implementing type at compile time. This actually results in no extra runtime cost, but can lead to a lot of duplicated code if the function is called with many different implementors of the trait.

To use static dispatch, one way is to prefix the trait name with impl:

fn road_trip_impl(vehicle: &impl LandCapable) {
	vehicle.drive();
}

With this approach, the compiler will create a separate implementation of road_trip for each of the LandCapable implementations that it is called with. That might be preferable in a lot of cases.

The technical term for this is “monomorphization”. Say that 10 times fast.

If you’re familiar with Rust’s generics and trait bounds, using the impl keyword is actually just shorthand for making the function generic and putting a trait bound on the type:

fn road_trip_generic<T: LandCapable>(vehicle: &T) {
	vehicle.drive();
}

Default Implementations

You can create default implementations in traits, similar to default implementations in Java interfaces. When a trait has a function with no default implementations, structs that implement the trait must provide an implementation for that method.

Default implementations of trait methods make it optional for the trait’s implementors to implement the method.

We currently don’t have a default implementation for drive in LandCapable. If we comment out the implementation of drive for Car, the compiler is going to give us grief.

Default implementations for trait methods go right in the trait code block - without a default implementation the functions are postfixed with a semicolon. To write a default implementation, just put it right in the trait code block:

trait LandCapable {
    fn drive(&self) {
        println!("Land default drive")
    }
}

If we leave the implementation of drive in Car, that will take precedence over the this default implementation. But now we have the option to remove that function from the LandCapable implementation for Car:

    impl LandCapable for Car {}

Supertraits

Rust doesn’t have traditional object oriented inheritance, but it does have a concept called Supertraits, which is sort of like inheritance for traits.

Lets make another trait to describe vehicles that can traverse water, such as a boat

trait WaterCapable {
	fn float(&self) {
		println!("Default float");
	}
}

Now say I have another trait called Amphibious to represent vehicles that can traverse land and water.

trait Amphibious {}

I want to be able to force implementors of the Amphibious to implement both LandCapable and WaterCapable. To do that, I can just add Those as supertraits of Amphibious by adding a colon after the trait name, and a + delimited list of traits that I want to add as supertraits

trait Amphibious : WaterCapable + LandCapable {}

Now any implementors of Amphibious will be forced to implement WaterCapable and LandCapable as well.

So I can have a function (my understanding is that amphibious vehicles are the go-to for traversing frozen lakes)

fn traverse_frozen_lake(vehicle: &impl Amphibious) {
	vehicle.float();
	vehicle.drive();
}

and then we can make a hovercraft that we pass in to this new function

fn main() {
	let hc = Hovercraft;
	traverse_frozen_lake(&hc);
}

Conclusion

That’s a quick run-through of using traits to facilitate object oriented programming in Rust. Now you’re officially an implementor of the OOPCapable trait!