Where's My Inheritance?
Two ways to understand how to work without the classical OOP inheritance that you miss from C++...
You've decided to make a move to Rust. Bravo! That's the first and most important step. You're reading the Rust book(s), tackling the syntax, fighting the borrow-checker, everything is in order, but you have an ever-growing shadow above your head: yeah, but how do I do X? In most cases I've had, it's been something to do with inheritance, so let's tackle it.
Inheritance is very much one of your prime architectural tools when working in C++, so the expectation is firmly set that it should be just as prime in any other language. It's hard to imagine hierarchies without inheritance, after all. I'm here to tell you that most problems that you've solved using inheritance weren't really about hierarchies nor inheritance. Sometimes, it's about reducing bloat, sometimes it's making sure the Liskov Substitution Principle holds (that children have interfaces that coincide with their parents, and that they can always be used as a parent), and sometimes it's about defining abstract interfaces at different levels of abstraction. Let's not focus on why we need them, though, but rather what to do when it's not there and your instincts yell that you need it.
For the past couple of weeks, I've been doing (and then thinking about) 7DRL. I had seven days to make a game, so I had to be snappy. It wouldn't be good if I got lost thinking about inheritance on day 4, but that, indeed, did happen. Here's my scenario:
My game has various actions that both characters and enemy mobs can perform. One action can lead to another being queued, and an action can query or effect a whole lot of components, so I can't do it from a unified place (like a function that would
match
on an action and do it) -- they have to be separate.
In C++, I'd make something like an AAction
pure abstract class:
class AAction {
public:
virtual void do_action(World&);
virtual void ~AAction() = 0;
}
After this, I'd make several actions that inherit and implement AAction
:
class WaitAction : public AAction {
public:
WaitAction() {}
void do_action(World{} world) override {}
}
class WalkAction : public AAction {
Entity who;
IVec2 direction;
public:
WalkAction(Entity who, IVec2 dir)
: who{who}
, direction{dir}
{}
void do_action(World& world) override { ... }
...
}
struct HitAction : public AAction {
Entity attacker;
Entity target;
public:
HitAction(Entity atk, Entity tgt)
: attacker{atk}
, target{tgt}
{}
void do_action(World& world) override { ... }
...
}
As simple as that and we're done: now when we need to call these, we can have an action queue and just pull the next action and resolve it. I've simplified the signature of the do_action
function here to not return a vector of actions, although it really should: one action can spawn many others in its wake!
Let's see how to get to this in Rust, in two ways. The first one requires fewer lines to explain, and is the one you should always try first, but has some limitations. Let's start there.
Couple similar subtypes into an enum
Enums in Rust are full-blown sum types and not just integer-equivalent enumerations. This fact means that an enum is structurally as powerful as a struct: if one can have fields, the other can too - they both can carry values. Both also can carry nothing and simply be a named marker of sorts!
If you have an enumerable number of similar datatypes, enums exist for exactly this purpose, mapping them tightly under a single name. In the example above, it could be something like this:
#[derive(Debug, PartialEq)]
enum Action {
Wait,
Walk { who: Entity, direction: IVec2 },
Hit { attacker: Entity, target: Entity },
}
You can think of Actions
as a supertype of all the child-types, which can by themselves be built up by simple construction:
let current_action = Action::Walk { who: me, direction: IVec2::new(0, 1) };
As actual datatypes, enums can also have implemented behaviours. This is done in exactly the same way as with structs, either through implementing traits or writing their own impl
blocks. Let's try with an impl
block here, as we really have no need for a separate trait here.
impl Action {
pub fn do_action(&self, world: &mut World) -> Vec<Action> { ... }
}
Okay, so far so good - what's the catch? Well, we can't typecheck against a variant of Action
! We can't put Action::Walk
as a type in any function's argument to match only those actions, so we need to build an executor of sorts in do_action
that then splits the workload to different action execution functions:
impl Action {
pub fn do_action(&self, world: &mut World) -> Vec<Action> {
match self {
Self::Wait => Self::do_wait(world),
Self::Walk { who, direction } => Self::do_walk(who, direction, world),
Self::Hit { attacker, target } => Self::do_hit(attacker, target, world),
}
}
fn do_wait(world: &mut World) -> Vec<Action> { ... }
fn do_walk(who: Entity, direction: IVec2, world: &mut World) -> Vec<Action> { ... }
fn do_hit(attacker: Entity, target: Entity, world: &mut World) -> Vec<Action> { ... }
}
This might look like a sort of setback from what we have in C++, but when you consider what we've just done here - it's kind of remarkable, no? In C++, to cover a feature like this, we had to go into virtual functions, dynamic dispatch, and heap allocations. Here, we still can do all of that, but we didn't have to. Our code is compact, it's a case of perfect forwarding to simple non-member functions that can do the work for us.
Let's push enums to the point where we need to go to heap... It won't be by increasing the number of variants, nor their number of arguments. It has to do with recursion. So, for example, if we update our enum to have an action in there that would be a reaction to some other kind of action (quite meta, I know!), it won't work:
#[derive(Debug, PartialEq)]
enum Action {
Wait,
Walk { who: Entity, direction: IVec2 },
Hit { attacker: Entity, target: Entity },
React { kind: Action, } // <-- this here is new!
}
Why is this? The thing to remember is that Rust is doing a lot of work to keep us in check. It's very interested in things being and staying safe and reachable. This means that it has to make sure that data put in memory storage is well sized. A completely off the hook recursive structure could be infinite, and we don't have memory for that! Well, we don't have memory for that on the stack... Given that the heap is always 1) bigger and 2) potentially unavailable (*alloc
can certainly fail), we need to tell the compiler what we want to do with this recursive type: how do we want to put it into the heap?
The minimal solution is a Box
, which gives exclusive ownership over the memory, or Rc
, which signifies shared ownership.
#[derive(Debug, PartialEq)]
enum Action {
Wait,
Walk { who: Entity, direction: IVec2 },
Hit { attacker: Entity, target: Entity },
React { kind: Box<Action>, } // <-- this now works!
}
The size can now be statically found out (size of Box
is probably a pointer, given that it's basically just a *const T
for some type T
) and our type can exist once more.
How does one use a Box
, though?
You might find it weird, but having a Box
doesn't change much! It might look scary, but it's as transparent as a smart pointer would be in C++. If your type T
has a field name
in it, and you have a obj: Box<T>
, you can reach in and grab it by doing the same thing you would if it were just obj: T
: a simple obj.name
works in both cases.
If you really need a reference from it, for eample if you have a function that explicitly takes a &T
or &mut T
, you can get these from a Box<T>
by calling .as_ref()
and .as_mut()
respectively. The mutable case is also the one where Box
and Rc
differ: because you have exclusive ownership of a Box
, you can mutate it willy-nilly. Rc
s not so much, but that's a story for another day!
And if we need externally different types?
The only reason you might not use this is if you really need to use externally different types (so, if you need WaitAction
and HitAction
and co. to be their own types). One such scenario might be if you're writing a system and want to statically query specifically all the HitAction
s but not take ownership of the other actions as well. If this is the case, we need to rebuild a bit more of that C++ machinery. In this case, we're using shared behaviour between different types, so it needs to work via traits. We want to write something like this:
pub trait AbstractAction {
fn do_action(&self, world: &mut World) -> Vec<AbstractAction>;
}
However, this won't work. This moment here starts a cascade that scares off many people and underlines that Rust is hard, so take a moment while we go step by step through the compiler's process here. The code above will fail because...
pub trait AbstractAction {
fn do_action(&self, world: &mut World) -> Vec<AbstractAction>;
/* ^^^^^^^^^^^^^^ */
/* trait objects must include the `dyn` keyword */
}
What's a trait object? Well, think of it as an object of some type for which we only know that it satisfies some set of traits. In this case, we don't know what type of object this is, but we know it satisfies AbstractAction
. The logic itself isn't too far from C++ here: over there, we need to work with AAction*
in the end for the inheritance to work, which... we could just call an abstract class object.
Okay, but why dyn
? Well, to make explicit that the type will be dynamically found out. Once we add the dyn
, just like the doctor ordered, we get a different error:
pub trait AbstractAction {
fn do_action(&self, world: &mut World) -> Vec<dyn AbstractAction>;
/* ^^^^^^^^^^^^^^^^^^^^^^^ */
/* doesn't have a size known at compile-time */
}
Okay, so we're back to the problem we already considered and solved before: a trait object by its very nature can't be sized at compile time! We don't know whether this will be a WaitAction
that has no arguments or a MoveAction
that has an Entity
(64 bits) and IVec2
(I think also 64 bits), for example. We fix this the same way we did before: we explicitly promise to keep our things on the heap if they're dynamic.
pub trait AbstractAction {
fn do_action(&self, world: &mut World) -> Vec<Box<dyn AbstractAction>>;
}
While this piece of code compiles and will work correctly, I like to isolate the Box
into a separate type. You may have noticed I called the trait AbstractAction
. I did this so that we could call the boxes of abstract behaviour Action
, so let's rewrite that:
pub type Action = Box<dyn AbstractAction>;
pub trait AbstractAction {
fn do_action(&self, world: &mut World) -> Vec<Action>;
}
Now we just make different structs for the different actions, and implement abstract actions:
pub struct WaitAction;
impl AbstractAction for WaitAction {
fn do_action(&self, world: &mut World) -> Vec<Action> {
vec![]
}
}
pub struct MoveAction { who: Entity, direction: Direction }
impl AbstractAction for MoveAction {
fn do_action(&self, world: &mut World) -> Vec<Action> {
world.get_entity(self.who).position += self.direction;
vec![]
}
}
What I did in my case was also make cute helper functions for constructing the boxed actions, so I'd have these out in the open:
pub fn a_wait() -> Action { Box::new(WaitAction) }
pub fn a_move(who: Entity, direction: IVec2) -> Action {
Box::new(MoveAction { who, direction })
}
Now if I had one action cause another action, I could do it without the hassle of making boxes, for example:
impl AbstractAction for MoveAction {
fn do_action(&self, world: &mut World) -> Vec<Action> {
let mut pos = world.get_entity(self.who).position;
if world.is_solid(&pos) {
vec![ a_log("Can't move there"), a_wait() ]
} else {
*pos += self.direction;
vec![]
}
}
}
So there we go, we have two valid ways to work with shared behaviours without inheritance. The third way to go with this specific problem, and in my context (of using it with Bevy, and a very tight ECS schedule) is completely different and stops even seeming to be related to inheritance, which just tells me that we shouldn't be solving for inheritance, but rather seeing what problems we have without it and then solving those directly. Until next week, enjoy, do dev, and stay rusty!