-
Notifications
You must be signed in to change notification settings - Fork 0
Polymorphism
Having a look at how we can achieve polymorphic behavior without classes and inheritance.
Table of contents
- Object Oriented Polymorphism
- Data Oriented Polymorphism
- Data Oriented Polymorphism II
- Data Oriented Polymorphism III
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.
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.
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 data relevant to any and all subclasses. The problem with that approach 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. This is how God-objects are born.
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.
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.
Anything I got wrong? Anything you'd like to add? You can find me in the EnTT chat room.
EnTT - Fast and Reliable ECS (Entity Component System)
Table of contents
Examples
Blog
- RAII
- Polymorphism
- Shared Components
- Intent System
- Input Handling
- Undo
- Operator Stack
- State
- Resources
- Interpolation
Resources
Extras