Whether you wanted to find out about object oriented Rust yourself, or you wanted to see why in the world I’m talking about object oriented rust, you are here. And so let us talk about object oriented Rust.
Object oriented Rust is not so outlandish. Many folks think of Rust as a functional language, and while there are plenty of functional paradigms in Rust, many of those paradigms are also available to other languages in one way or another.
Most folks would not call Java a functional language, and yet many of the features cited that make Rust a functional language are available as libraries for Java. If you want algebraic data types, there’s a library for that. If you want pattern matching, there’s a library for that too1.
The absence or presence of such features does not make a language object oriented or functional, there are plenty of ways to stamp features from one langauge onto others.
With that said, given Rust is intended to be a functional replacement to C++, plenty of object oriented features exist in Rust. You don’t have to leave the standard library to access them either!
Because of many of the restrictions and lack of a GC in Rust, there are a number of nuances with how Rust handles OOP. It can certainly work like Java if you really want it to, but sometimes it can take a little finagling.
The remainder of this post will talk about OOP in the context of Rust itself, and common pitfalls you may hit along the way while writing object oriented Rust or using object oriented patterns.
Anyway enough talk, let’s write some Rust. Rust has a concept of traits, which are the Java equivalent of interfaces2. They define a common set of methods to be used across object types. Let’s throw together some traits.
|
|
These two traits are friends. SoftwareEngineer is a SuperTrait that encapsulates the functionality of Worker. Implementors must also implement worker if they would like to implement SoftwareEngineer. So far this looks like piecemeal composition. Nothing exciting yet.
Let’s get some implementors.
First we are making some structs to hold traits for the implementors.
|
|
And then we implement the above traits on them:
|
|
Still nothing exciting here. So let’s change that a little. Now I want to start using these types and we can really start leveraging our traits.
|
|
|
|
You can see we are generic over Worker and receive pay on both of them. Despite
not needing it, you’ll note I made the pay_worker
method take ownership of the
Worker
argument. It doesn’t even use a reference.
This is the simplest and fastest way to pass generic objects around. It doesn’t use dynamic dispatch. Rust knows ahead of time what your type is and just calls the method.
Anyway let’s keep going with the examples. I want to get a bunch of these workers, put them in a collection, and give them all pay.
|
|
|
|
Well that’s interesting. Everything implements Worker, but it’s just not allowed. Even more interesting, if you replace the two SoftwareEngineer + Astronaut with just Astronaut, it works!
|
|
|
|
That’s because static dispatch doesn’t allow for collections of implementors
unless the implementors are all the same. All impl Trait
s must eventually
compile down to the same type, meaning there is no way to carry a collection of
traits using static dispatch if they compile to different implementations.
This is one of the most frustrating behaviors of static dispatch, as it makes it very difficult to pass ownership of objects around in a generic way. If you need ownership of said objects in a collection, you’ll need to use dynamic dispatch.
Dynamic dispatch you say?
Yes, dynamic dispatch. Smarter folks have described it than me, so here is one of their definitions.
Copied shamelessly from Wikipedia:
In computer science, dynamic dispatch is the process of selecting which implementation of a polymorphic operation (method or function) to call at run time. It is commonly employed in, and considered a prime characteristic of, object-oriented programming (OOP) languages and systems.
In short, the way dynamic dispatch works is:
- Have a pointer to an object
- Carry a vtable with a set of pointers to that object’s implementation of methods.
- Figure out which to call at runtime through this indirection
This indirection has a cost. It’s all figured out at runtime which makes it a little slower. However we get vastly increased flexibility with dynamic dispatch.
So how does it work with Rust?
The TL/DR is we have to throw our data behind a pointer. Heap or stack allocated, method calls must be behind a pointer to the trait, and rustc will figure out what to do from there.
Let’s change up our example a little:
|
|
|
|
Run and no more compile errors! The only “real” change to the code was from
impl
-> dyn
This has performance implications, but also opens up a world of
possibilities. For instance, you can see we now have an engineer and an
astronaut in the same array now.
We’re still using references here, but if we wrapped our astronaut and engineer in a Box, we would own them in the vec. Nice!
Holding traits
No not like a hug, but in a struct. Let’s try it out real quick. Does this work?
|
|
|
|
Well no. But that’s not because it doesn’t work. The error is a little disingenuous.
impl
is syntactic sugar for a more verbose syntax that specifies data is
generic over a type. For some reason the impl
syntactic sugar does not work
here. The proper syntax to make this work looks like so:
|
|
There isn’t much more excitement to talk about for static dispatch here, so let’s move back to dynamic to discuss a few other quirks with it.
This is the proper syntax for holding a traits using dynamic dispatch:
|
|
Owning and cloning
Let’s take a closer look at the boxed implementation. I’d like to pass the worker into a method that looks like this:
|
|
Let’s make an astronaut and do crazy stuff with him twice. This method wants an instance of the astronaut, and so we comply.
|
|
|
|
Oh no, lifetime errors. That’s no good. Luckily the errors look simple. We’re giving the method ownership of our object and then call it again. Of course we don’t own it anymore.
Since we just want to give it an instance of our worker we can clone it, right?
|
|
|
|
Oh no, rustc is not happy with us. Zippy lime has a good article on Sized and Clone and all that jazz. If you’d like to read a longer more detailed explanation about cloning boxes, it’s a good read. With that said, let’s delve in a little bit here too.
We want to clone the data in our box, and put it in another box. Unfortunately that doesn’t work out of the box (eh? get it? ;) please don’t leave)
Box will implement clone but only if the object inside implements clone. Straight from the docs:
|
|
The problem with this and traits, is that to implement clone, the type you’re cloning must implement sized. clone doesn’t know we want to clone what’s inside the box, and put it right away in another box. It thinks we’re going to take unsized data and try to put it on the stack which rust cannot and will not handle.
In order to clone a box (or rather, what we’d really like is: to clone what is inside the box, allocate new memory in the heap for it, and return that new box), we must venture outside the standard library to a crate called dyn-clone3. which does exactly as we’d like.
So long as all implementors of the trait also implement clone, we may implement dyn-clone on our trait and clone the data inside the box to another box.
The actual changes might look like this:
|
|
And finally we can clone the box data:
|
|
Seems like a bit of a hassle, but if you want to easily clone your instances inside boxes, this is more or less the only way.
Depending on the use, we may have been able to get away with using Rc or Arc, but sometimes you need ownership.
Upcasting
Well now we can hold our traits. Can we pass them around? Yes and no.
|
|
|
|
Well that’s interesting. The boxed struct is passed into the function fine, but, the boxed trait is not. The implementations are the same, so why doesn’t it work?
It actually should work. It’s legitimate fine code. There’s an issue about supporting upcasting here.
Frustrating, so how do you get around this? Well it turns out it isn’t too hard, although you have to waste a whole function call on the conversion which is a shame.
|
|
And now the example works if you do:
|
|
Definitely a little grody, but you gotta do what you gotta do.
The kind folks on reddit pointed me to a handy crate that hides all this behind a proc macro if you don’t feel like implementing it yourself. You can check it out here.
Methods without self
Occasionally you may want to add a method to a trait that does not reference self. The most common case I’ve seen is a constructor, but there are plenty of other reasons you may want to add one. Let’s try to add one to our Astronaut.
|
|
|
|
Well that’s no good. With no self
parameter, rustc tells us we can’t
add the method to our trait. Since there is no self
parameter in the method,
rustc won’t know what implementation to call. This makes the Astronaut object
unconstructable.
Luckily this is easily fixable. rustc even gives us the answer in the error:
|
|
By adding a where Self: Sized;
we tell rust this method must only be
available to objects that are sized. In short, it must only be available to
implementations. Once that’s done, we can compile away and it all works!
And that’s that!
If you’ve got through all of this, you’ve now got a pretty solid idea of how to do simple object oriented programming in rust.
If you liked this article, feel free to check out another one! I’ve stuck to my unofficial schedule of one post a month, and all the others talk about something that has to do with rust.
-
These libraries are really good. If you have to write Java for your day job, they erase a lot of boilerplate and really do a great job bringing functional features to Java. We heavily use these at HubSpot. ↩︎
-
I don’t want to hear any complaining or: “Well technically’s” that source is straight from rust-lang.org ↩︎
-
Luckily it is a crate made by dtolnay, so it might as well be standard library quality. ↩︎