modulito.svg
modulitos

homeblogprojectscontact

Comparing OOP vs data-oriented design in Rust

Photo by Blake Weyland

One of the toughest hurdles in my journey of learning Rust was managing architecture that is very different from the Object-Oriented Programming (OOP) I am used to. At first, it felt like a curse - yet another obstacle to steepen the learning curve. But I soon learned a new way of architecting my programs, and this new perspective has been quite enjoyable. Amidst the loss of relying on my familiar OOP patterns, I discovered that Rust is well-suited for the lesser-known, but perhaps equally valuable, Entity Component System (ECS).

If you are new to Rust, and/or ECS, then you are not alone. I just learned ECS over the past week, and I'm still new to Rust, so my goal for this post is to serve as a helpful introduction for anyone in the same boat.

In this post, we're going to explore two programs that solve the same problem - but one is written in OOP, and the other in ECS. Then we will analyze their tradeoffs, and see what conclusions we can find!

A Concrete Example

We are going to focus on a concrete example - implementing a parking lot. The parking lot consists of 12 parking spots, where each spot has one of 3 sizes - motorcycle, compact, and large.

We will also have 3 kinds of vehicles - motorcycles, cars, and buses. The bus can only fit in a large spot, a car can fit in a compact spot or a large spot, and a motorcycle can fit in all 3 spots.

So let's get started by implementing the vehicles that will occupy our parking spots, using a familiar OOP pattern:

class VehicleSize(Enum):
    motorcycle = 1
    compact = 2
    large = 3


class Spot:
    def __init__(self, id: int, size: VehicleSize):
        self.id = id
        self.is_available = True
        self.size = size

class ParkableVehicle:
    def can_fit_in_spot(self, spot: Spot) -> bool:
        return self.size <= spot.size


class Motorcycle(ParkableVehicle):
    def __init__(self):
        super().__init__()
        self.size = VehicleSize.motorcycle


class Car(ParkableVehicle):
    def __init__(self):
        super().__init__()
        self.size = VehicleSize.compact


class Bus(ParkableVehicle):
    def __init__(self):
        super().__init__()
        self.size = VehicleSize.large


class ParkingLot:
    def __init__(self):
        self.spots = []
        for i in range(12):
            if i < 4:
                size = VehicleSize.motorcycle
            elif i < 8:
                size = VehicleSize.compact
            else:
                size = VehicleSize.large
            self.spots.append(Spot(i, size))

    def available_spots(self) -> int:
        return sum(1 for spot in filter(lambda spot: spot.is_available, self.spots))

    def park_vehicle(self, vehicle) -> bool:
        spot = next(
            (
                spot
                for spot in self.spots
                if spot.is_available and vehicle.can_fit_in_spot(spot)
            ),
            None,
        )
        if spot is None:
            return False
        else:
            spot.is_available = False
            return True

if __name__ == "__main__":
    lot = ParkingLot()
    lot.available_spots()  # 12
    lot.park_vehicle(Motorcycle())  # True
    lot.available_spots()  # 11
    lot.park_vehicle(Bus())
    lot.available_spots()  # 10
    lot.park_vehicle(Bus())
    lot.park_vehicle(Bus())
    lot.park_vehicle(Bus())
    lot.available_spots()  # 7
    lot.park_vehicle(Bus())  # we are out of large Spots now
    lot.available_spots()  # 7

In this version, often the components and necessary bits of data are paired together: each component by itself can still be a class with encapsulated data, and each class is a clean abstraction that neatly models the objects in the real world.

But Rust on the other hand, doesn't have any classes, nor inheritance. So an equivalent implementation in Rust would use traits and structs, like this:

struct SpotId(usize);

struct Spot {
    is_available: bool,
    id: SpotId,
}

trait Parkable {
    fn can_fit_in_spot(&self, spot: &Spot) -> bool;
}

struct Motorcycle {}
impl Parkable for Motorcycle {
    fn can_fit_in_spot(&self, spot: &Spot) -> bool {
        spot.size >= VehicleSize::Motorcycle
    }
}

struct Car {}
impl Parkable for Car {
    fn can_fit_in_spot(&self, spot: &Spot) -> bool {
        spot.size >= VehicleSize::Compact
    }
}

struct Bus {}
impl Parkable for Bus {
    fn can_fit_in_spot(&self, spot: &Spot) -> bool {
        spot.size >= VehicleSize::Large
    }
}


struct ParkingLot {
    spots: Vec<Spot>,
}
impl ParkingLot {
    fn new(floor: usize) -> Self {
        ParkingLot {
            spots: (0..12)
                .map(|i| {
                    let size = if i < 4 {
                        VehicleSize::Motorcycle
                    } else if i < 8 {
                        VehicleSize::Compact
                    } else {
                        VehicleSize::Large
                    };
                    Spot {
                        size,
                        is_available: true,
                        id: SpotId(usize::from(i)),
                    }
                })
                .collect(),
        }
    }
    fn available_spots(&self) -> usize {
        self.spots.iter().filter(|spot| spot.is_available).count()
    }

    fn park_vehicle(&mut self, vehicle: &mut impl Parkable) -> Result<(), String> {
        if let Some(spot) = self
            .spots
            .iter_mut()
            .find(|spot| spot.is_available && vehicle.can_fit_in_spot(spot))
        {
            spot.is_available = false;
            Ok(())
        } else {
            Err(String::from("No parking available for this vehicle!"))
        }
    }
}
fn main() {
    let mut lot = ParkingLot::new(3);
    lot.park_vehicle(&mut Bus::new()).ok();
    lot.park_vehicle(&mut Bus::new()).ok();
    lot.park_vehicle(&mut Bus::new()).ok();
    lot.park_vehicle(&mut Bus::new()).ok();
    lot.park_vehicle(&mut Bus::new()).err(); // we are out of large spots now
}

To come up with this design, I felt like I had to turn my mental model of the program ninety degrees and change the way I think about it.

Relative to the OOP implementation, we are changing the relationship between our ParkableVehicle and its subtypes from an "is-a" to a "has-a" relationship. Instead of our vehicles "being" a ParkableVehicle, they now "have" a Parkable trait.

I think this is a great example of composition over inheritance, and really highlights the benefits of using traits in Rust.

But our program is missing something - we have no way to unpark the vehicles! Do accomplish this, we want each vehicle to track which spot it's parked in.

This is where ECS comes in...

Entity Component System in Rust

As a point of comparison, let's start by tackling this problem using our familiar OOP pattern. So if we want each vehicle to know it's parking spot, we would add state to our ParkableVehicle base class:

class ParkableVehicle:
    def __init__(self):        self.spot = None
    def can_fit_in_spot(self, spot: Spot) -> bool:
        return self.size <= spot.size

    def park(self, spot: Spot):        self.spot = spot    def leave(self):        self.spot = None

And then we'll update the park_vehicle method of our ParkingLot class to pass the parking spot to our vehicle:

class ParkingLot:

    def __init__(self):
        # same as before...

    def available_spots(self) -> int:
        # same as before...

    def park_vehicle(self, vehicle) -> bool:
        spot = next(
            (
                spot
                for spot in self.spots
                if spot.is_available and vehicle.can_fit_in_spot(spot)
            ),
            None,
        )
        if spot is None:
            return False
        else:
            spot.is_available = False
            vehicle.park(spot)            return True


    def clear_vehicle(self, vehicle):        if vehicle.spot is None:            # vehicle isn't parked            return        vehicle.spot.is_available = True        vehicle.leave()

Seems like a simple and familiar implementation.

But in Rust, we have a concept of ownership, and the Spots are owned by our ParkingLot. Since there can only be one owner, this prevents us from simply adding a Spot to our vehicle, as we are doing in the example above.

A standard option is to use a reference counted pointer, but ECS perscribes another pattern:

Instead of trying to store spot itself within our vehicle, we will store an identifier of the spot on our vehicle.

So let's add a SpotId to a ParkedVehicle struct, which we can then compose within our vehicles:

struct ParkedVehicle {    spot: SpotId,}
struct Motorcycle {
    parked: Option<ParkedVehicle>,}
impl Motorcycle {    fn new() -> Self {        Motorcycle { parked: None }    }}
// Apply the same pattern for our Bus and Car structs...

Storing a SpotID instead of a Spot instance inside our ParkedVehicle is a seemingly subtle distinction that marks a core conecept for data-oriented design patterns like ECS.

Notice how we also created a separate ParkedVehicle struct, which helps keep our state composable. In the next section, we will discuss the benefits of this in more detail.

Next we'll need to update our Parkable trait so that we can operate on our new state:

trait Parkable {
    fn slots_needed(&self) -> usize;
    fn can_fit_in_spot(&self, spot: &Spot) -> bool;
    fn park(&mut self, spot: SpotId);    fn leave(&mut self) -> Result<ParkedVehicle, String>;}

impl Parkable for Motorcycle {
    fn can_fit_in_spot(&self, spot: &Spot) -> bool {
        spot.size >= VehicleSize::Motorcycle
    }
    fn park(&mut self, spot: SpotId) {        self.parked = Some(ParkedVehicle { spot });    }    fn leave(&mut self) -> Result<ParkedVehicle, String> {        if let Some(parked) = self.parked.take() {            self.parked = None;            Ok(parked)        } else {            Err(String::from("Wasn't parked!"))        }    }}

Notice how we are returning the ParkedVehicle struct from our Parkable.leave method - it allows us to communicate our parking-related information from our vehicle to the ParkingLot. Thus, ParkedVehicle serves as a message to our ParkingLot, without having to provide ParkingLot with access to our entire vehicle.

We can see it in use when we add a new clear_vehicle method to our ParkingLot implementation:

impl ParkingLot {
    fn new(floor: usize) -> Self {
        // same as before...
    }
    fn available_spots(&self) -> usize {
        // same as before...
    }

    fn park_vehicle(&mut self, vehicle: &mut impl Parkable) -> Result<(), String> {
        if let Some(spot) = self
            .spots
            .iter_mut()
            .find(|spot| spot.is_available && vehicle.can_fit_in_spot(spot))
        {
            spot.is_available = false;
            vehicle.park(&spot.id);            Ok(())
        } else {
            Err(String::from("No parking available for this vehicle!"))
        }
    }
    fn clear_vehicle(&mut self, vehicle: &mut impl Parkable) -> Result<(), String> {        if let Ok(parked_vehicle) = vehicle.leave() {            let spot = self                .spots                .iter_mut()                .find(|spot| parked_vehicle.spot == spot.id)                .unwrap();            spot.is_available = true;            Ok(())        } else {            Err(String::from("Vehicle isn't parked!"))        }    }}

This example highlights the three concepts in our ECS pattern:

  • Entity - an identifier, such as our SpotId. Basically just a "primary key".
  • Component - our data. In this case, it's our vehicle and our parking lot structs.
  • System - our logic. This consists of the trait implementations for our vehicle and parking lot.

The full program, which includes a few more enhancements, is available here.

Conclusions

For this extremely trivial example, I think implementing ECS with Rust is quite heavy-handed. For example, instead of using ECS, we could have nested our Spot instance within our vehicle by using a reference counted pointer, while still leveraging the composability patterns of Rust's traits.

But when it comes to managing complexity, I think there is a lot to be learned from the ECS pattern. Let's imagine that we needed our vehicle to model many more traits, such as:

  • AlarmSystem
  • Engine
  • Owner

and so on. By storing references to these entities, then their data can be shuffled around in memory as needed, or even destroyed, without having to worry about dangling pointers. And since our components are simple data buckets, they won't have any dependencies to worry about. ECS also promotes better performance due to data locality, but can become a major bottleneck as shared pointers propagate accross numerous objects.

And aside from ECS, I hope this example demonstrates how having a flat representation of traits can be advantageous over nested hierarchies and mixins, expecially as we add more features to our vehicles.

I suspect that a major problem with ECS is that it’s different from what most programmers are used to or learned in school. So I am happy to have learned something new, because writing this program was a great exercise in developing from a new perspective.

Related reading:

create commons icon
modulitos, 2019