Skip to content

Polymorphism

Alan Jefferson edited this page Jan 19, 2020 · 3 revisions

Having a look at how we can achieve polymorphic behavior without classes and inheritance.

Table of contents


Object Oriented Polymorphism

Let's see how an example from C# on polymorphism can be applied to a data-oriented design.

class Shape {
    int x, y;
    int height, width;
   
    virtual void draw() {
        printf("Drawing a generic shape.\n");
    }
};

class Circle : public Shape {
    void draw() override {
        printf("Drawing a circle.\n");
    }
}

class Square : public Shape {
    void draw() override {
        printf("Drawing a square.\n");
    }
}

class Triangle : public Shape {
    void draw() override {
        printf("Drawing a triangle.\n");
    }
}

From the example.

Virtual methods enable you to work with groups of related objects in a uniform way. For example, suppose you have a drawing application that enables a user to create various kinds of shapes on a drawing surface. You do not know at compile time which specific types of shapes the user will create.

Good point. Here's what they mean by that.

// User creates
std::vector<Shape*> shapes {
    new Rectangle(),
    new Triangle(),
    new Circle()
};

// Application draws
for (auto* shape : shapes) {
    shape->draw();
}

Notice how that one vector now contains multiple different types. So convenient. Next let's have a look at how this could be done with EnTT.


Data Oriented Polymorphism

Let's start separating between data and function.

struct Shape {
    int x, y;
    int height, width;
};

struct Circle {};
struct Rectangle {};
struct Triangle {};

That's our data, in the form of Components, the C in ECS.

void Draw(entt::registry& registry) {
    auto circle = [](const auto& shape) {
        printf("Drawing a circle.\n");
    };

    auto rectangle = [](const auto& shape) {
        printf("Drawing a rectangle\n");
    };

    auto triangle = [](const auto& shape) {
        printf("Drawing a triangle\n");
    };

    registry.view<Shape, Circle>().less(circle);
    registry.view<Shape, Rectangle>().less(rectangle);
    registry.view<Shape, Triangle>().less(triangle);
}

Great, that's our System, the S in ECS. Notice how we're accessing the same members of Shape, but provide different implementations based on whether the entity has been assigned a Circle, Rectangle or Triangle component.

// User creates
entt::registry registry;
auto a = registry.create();
auto b = registry.create();
auto c = registry.create();

// The "baseclass"
registry.assign<Shape>(a);
registry.assign<Shape>(b);
registry.assign<Shape>(c);

// The "subclass"
registry.assign<Circle>(a);
registry.assign<Rectangle>(b);
registry.assign<Triangle>(c);

Finally, these are our Entities, the E in ECS. I've labeled them with baseclass versus subclass to drive home the point about how these can be identified either as a Shape or as the more specialised Circle, Rectangle or Triangle. Almost as though they were inherited.

We're now ready to draw.

// Application draws
Draw(registry);

That's it.


Data Oriented Polymorphism II

The above was a direct port of both the example, but also mindset of an object oriented approach. Without it, there are a few things I would do differently.

Firstly, the Shape contain all data relevant to subclasses. That's the Liskov Substitution Principle, the L in SOLID, the 5 design principles of object oriented programming.

The problem however is how to tackle shapes that require additional data.

struct Cube {};

In order to support this shape, Shape needs a makeover.

struct Shape {
    int x, y, z;
    int width, height, depth;
};

Now we have the data required to draw a Cube, but to Circle, Rectangle and Triangle the data is meaningless. Superflous.

struct Capsule {};
struct Line {};

And what now? A Capsule needs a length, and a Line needs two positions. And have you noticed how we've been using width and height to represent a Circle? I can't remember seeing a circle where width and height differed.

Let's have a look at how to approach this from a data-oriented perspective.

struct Shape {};

struct Circle {
    int x, y;
    int radius;
};

struct Rectangle {
    int x, y;
    int width, height;
};

struct Cube {
    int x, y, z;
    int width, height, depth;
};

There. With Liskov out of the way, we've inverted the members of our components such that each one contain data specific to its purpose.

void Draw(entt::registry& registry) {
    auto circle = [](const auto& shape) {
        printf("Drawing a circle.\n");
    };

    auto rectangle = [](const auto& shape) {
        printf("Drawing a rectangle\n");
    };

    auto cube = [](const auto& shape) {
        printf("Drawing a cube\n");
    };

    registry.view<Circle>().less(circle);
    registry.view<Rectangle>().less(rectangle);
    registry.view<Cube>().less(cube);
}

Now each implementation provides data relative the component, as opposed to the generic, all-encompassing data provided by a baseclass. Also note that we didn't need to mention Shape this time, but sometimes you may want to.

using Name = std::string;

auto print_shapes = [](const auto& name) {
    printf("The shape is: %s\n", name.c_str());
};

registry.view<Shape, Name>().less(print_shapes);

Here we're listing all shapes by name, where Shape helps separate between anything with a name that isn't a shape, like a UI element, sound or event etc.


Data Oriented Polymorphism III

The previous example sure is interesting, and touches one of the things I like about data-oriented design; separation of concern at the level of data.

There's one more thing this enables us to do, something to give object-orientation a run for its money.

struct Shape {};

struct Position {
    int x, y, z;
};

struct Circle {
    int radius;
};

struct Rectangle {
    int width, height;
};

struct Cube {
    int width, height, depth;
};

Hm, ok. We've moved position out of the shape type, why do such a thing?

entt::registry& registry;
auto a = registry.create();
auto b = registry.create();
auto c = registry.create();

registry.assign<Shape>(a);
registry.assign<Shape>(b);
registry.assign<Shape>(c);

registry.assign<Position>(a);
registry.assign<Position>(b);
registry.assign<Position>(c);

registry.assign<Circle>(a);
registry.assign<Rectangle>(b);
registry.assign<Cube>(c);

With this, we're still able to iterate over all shapes, still able to zone in on a specific type of shape, like the Circle. But here's the kicker.

auto draw_positions = [](auto entity, const auto& position) {
    printf("Drawing every entity as a point in 3d space.\n");
};

registry.view<Position>().less(draw_positions);

Think about what just happened here. We're iterating over everything with a Position component, much like how we iterate over anything that has a Shape component. As though each entitiy "inherited" from both of them, except there is no inheritance. No diamond-shaped dependencies.

And how about this one, on managing selection?

auto print_selected = [](auto entity, const auto& name) {
    printf("%s is selected\n", name);
};

registry.reset<Selected>();
registry.assign<Selected>(b);
registry.view<Selected, Name>().less(print_selected);

And what about hierarchical relationships?

if (registry.has<Parent>(b)) {
    printf("%s has a parent.\n", registry.get<Name>(b));
}

Sean Parent's talk, Inheritance is the base class of evil, says that polymorphism is not a property of the type, but rather a property of how it is used.

In an ECS, the entity is our type and assigned components governs its behavior.


Discussion

Anything I got wrong? Anything you'd like to add? You can find me in the EnTT chat room.