⬆️ ⬇️

PLO is dead, long live PLO

image


sources of inspiration



This post came about thanks to a recent publication by Aras Prantskevichius on a report intended for junior programmers. It tells you how to adapt to new ECS architectures. Aras follows the usual pattern ( explained below ): shows examples of a terrible OOP code, and then demonstrates that the relational model is an excellent alternative solution ( but calls it "ECS", not a relational one ). I do not in any way criticize Aras - I am a big fan of his work and praise him for his excellent presentation! I chose his presentation instead of hundreds of other posts about ECS from the Internet because he put additional effort and published a git-repository to study in parallel with the presentation. It contains a small simple “game” used as an example of a choice of different architectural solutions. This small project allowed me to demonstrate my comments on specific material, so thank you, Aras!



Slides of Aras are posted here: http://aras-p.info/texts/files/2018Academy - ECS-DoD.pdf , and the code is on github: https://github.com/aras-p/dod-playground .



I will not (for now?) Analyze the resulting ECS ​​architecture from this report, but focus on the “bad OOP” code (similar to the “stuffed trick” trick) from its beginning. I will show how it would look in reality if they correctly corrected all violations of the principles of OOD (object-oriented design, object-oriented design).

')

Spoiler: eliminating all OOD violations leads to performance improvements, similar to the Aras transformations in ECS, besides it uses less RAM and requires fewer lines of code than the ECS version!



TL; DR: Before you conclude that OOP sucks, and ECS rules, pause and examine OOD (to know how to properly use OOP), and also understand the relational model (to know how to properly apply ECS).



I have been participating in a lot of discussions about ECS on the forum for a long time, partly because I don’t think that this model deserves to exist as a separate term ( spoiler: it's just an ad-hoc version of the relational model ), but also because almost every post, presentation, or article promoting the ECS pattern has the following structure:



  1. Show an example of a terrible OOP code, the implementation of which has terrible flaws due to excessive use of inheritance (which means that this implementation violates many of the principles of OOD).

  2. Show that composition is a better solution than inheritance (and not to mention that OOD actually gives us the same lesson).

  3. Show that the relational model is great for games (but call it “ECS”).


This structure infuriates me because: (A) this is a scarecrow trick ... soft is compared with warm (bad code and good code) ... and that is not fair, even if done unintentionally and not required to demonstrate that the new architecture is good; and, more importantly: (B) it has a side effect - such an approach suppresses knowledge and inadvertently de-motivates readers from exploring the research that has been conducted for half a century. About the relational model first started writing in the 1960s. During the 70s and 80s, this model has improved significantly. Beginners often have questions like “ what class do you need to put this data in? ”, And in response they are often told something vague, like “ you just need to gain experience and then you just learn to understand with your gut ” ... but in the 70s this question was actively it was studied and a formal answer was derived for it in the general case; this is called database normalization . Discarding existing research and calling ECS ​​a completely new and modern solution, you hide this knowledge from newcomers.



The basics of object-oriented programming were laid just as long ago, if not earlier ( this style began to be explored in the work of the 1950s )! However, it was in the 1990s that object-orientation became fashionable, viral, and very quickly turned into the dominant programming paradigm. There has been an explosion in the popularity of many new OO languages, including Java and (the standardized version ) C ++. However, since this was connected with the hype, everyone needed to know this loud concept in order to write it into their resume, but only a few really went into it. These new languages ​​have created many of the features of OO keywords — class , virtual , extends , implements — and I believe that this is why at that moment OO was divided into two separate entities living their own lives.



I will call the use of these OO-inspired language features " OOP " and the use of OO-inspired design / architecture creation techniques " OOD ". All very quickly picked up the PLO. In educational institutions there are OO courses baking new OOP programmers ... however, the knowledge of OOD is lagging behind.



I believe that code that uses OOP language features, but is not following OOD design principles, is not OO code . Most of the criticisms against OOP use gutted code for example, which is not really OO code.



The OOP code has a very bad reputation, and in particular because most of the OOP code does not follow the principles of OOD, and therefore is not a “true” OO code.



Prerequisites



As mentioned above, the 1990s marked the peak of the “fashion for OO”, and it was at that time that the “bad OOP” was probably the worst of all. If you studied PLO at that time, you most likely learned about the "four pillars of PLO":





I prefer to call them not four pillars, but "four tools of the PLO." These are tools that can be used to solve problems. However, it is not enough just to find out how the tool works, you need to know when to use it ... It is irresponsible for teachers to teach people new tools, not to tell them when each of them should be used. In the early 2000s, there was resistance to the active misuse of these tools, a kind of “second wave” of OOD thinking. This resulted in the emergence of the SOLID mnemonic, which provided a quick way to assess the strengths of the architecture. It should be noted that this wisdom was actually widespread in the 90s, but did not receive a steep acronym that allowed them to be consolidated as five basic principles ...





So we get SOLID-C (++) :)



Below I will refer to these principles, calling them by acronyms - SRP, OCP, LSP, ISP, DIP, CRP ...



A few more comments:





And finally, I should show a few examples of the terrible learning of the PLO and how it leads to bad code in real life (and the bad reputation of OOP).



  1. When you were taught hierarchies / inheritance, you might have been given a similar task: Suppose you have a university application that contains a catalog of students and staff. You can create the base class Person, and then the class Student and the class Staff, inherited from Person.



    No no no. Here I will stop you. The tacit subtext of the LSP principle states that class hierarchies and algorithms that process them are symbiotic. These are two halves of the whole program. OOP is an extension of procedural programming, and it is still mainly related to these procedures. If we do not know which types of algorithms will work with Students and Staff ( and which algorithms will be simplified due to polymorphism ), then it will be completely irresponsible to start creating the structure of class hierarchies. First you need to know the algorithms and data.
  2. When you were taught hierarchies / inheritance, you were probably given a similar task: Suppose you have a class of shapes. We also have squares and rectangles as subclasses. Should the square be a rectangle, or a rectangle square?



    In fact, this is a good example to demonstrate the difference between the inheritance of implementations and the inheritance of interfaces.

    • If you use the inheritance approach, you completely ignore the LSP and think from a practical point of view about the reusability of the code, using inheritance as a tool.



      From this point of view, the following is completely logical:



      struct Square { int width; }; struct Rectangle : Square { int height; }; 


      The square has only width, and the rectangle has width + height, that is, expanding the square with the height component, we get a rectangle!

      • As you might have guessed, OOD says that doing so ( probably ) is wrong. I said "probably" , because here you can argue about the implied characteristics of the interface ... but oh well.



        The square always has the same height and width, so from the interface of the square it is quite true to assume that the area is equal to "width * width".



        Inheriting from the square, the class of rectangles (in accordance with the LSP) should obey the rules of the interface of the square. Any algorithm that works correctly for a square should also work correctly for a rectangle.
      • Take another algorithm:



         std::vector<Square*> shapes; int area = 0; for(auto s : shapes) area += s->width * s->width; 


        It will work correctly for squares (calculating the sum of their areas), but will not work for rectangles.



        Consequently, the rectangle violates the principle of LSP.
    • If you use the interface inheritance approach, then neither Square nor Rectangle will inherit from each other. The interfaces for a square and a rectangle are actually different, and one is not a superset of the other.

    • Therefore, OOD prevents the use of inheritance implementations. As stated above, if you want to reuse the code, then OOD says that the right choice is composition!

      • So the correct version of the above (bad) code inheritance hierarchy of implementations in C ++ looks like this:



         struct Shape { virtual int area() const = 0; }; struct Square : public virtual Shape { virtual int area() const { return width * width; }; int width; }; struct Rectangle : private Square, public virtual Shape { virtual int area() const { return width * height; }; int height; }; 


        • "Public virtual" in Java means "implements". Used when implementing an interface.

        • “Private” allows you to extend the base class without inheriting its interface — in this case, the rectangle is not a square, although it is inherited from it.

      • I do not recommend writing such code, but if you want to use inheritance implementations, then you need to do it that way!


TL; DR - your OOP class told you what inheritance was like. Your missing OOD class should have told you not to use it 99% of the time!



Entity / Component Concepts



Having dealt with the prerequisites, let's move on to what Aras began with - the so-called starting point of the “typical OOP”.



But first, one more addition - Aras calls this code “traditional OOP”, and I want to object to this. This code may be typical of OOP in the real world, but, like the examples above, it violates all sorts of basic principles of OO, so it should not be considered as traditional at all.



I will start with the first commit before he began to redo the structure in the direction of ECS: “Make it work on Windows again” 3529f232510c95f53112bbfff87df6bbc6aa1fae



 // ------------------------------------------------------------------------------------------------- // super simple "component system" class GameObject; class Component; typedef std::vector<Component*> ComponentVector; typedef std::vector<GameObject*> GameObjectVector; // Component base class. Knows about the parent game object, and has some virtual methods. class Component { public: Component() : m_GameObject(nullptr) {} virtual ~Component() {} virtual void Start() {} virtual void Update(double time, float deltaTime) {} const GameObject& GetGameObject() const { return *m_GameObject; } GameObject& GetGameObject() { return *m_GameObject; } void SetGameObject(GameObject& go) { m_GameObject = &go; } bool HasGameObject() const { return m_GameObject != nullptr; } private: GameObject* m_GameObject; }; // Game object class. Has an array of components. class GameObject { public: GameObject(const std::string&& name) : m_Name(name) { } ~GameObject() { // game object owns the components; destroy them when deleting the game object for (auto c : m_Components) delete c; } // get a component of type T, or null if it does not exist on this game object template<typename T> T* GetComponent() { for (auto i : m_Components) { T* c = dynamic_cast<T*>(i); if (c != nullptr) return c; } return nullptr; } // add a new component to this game object void AddComponent(Component* c) { assert(!c->HasGameObject()); c->SetGameObject(*this); m_Components.emplace_back(c); } void Start() { for (auto c : m_Components) c->Start(); } void Update(double time, float deltaTime) { for (auto c : m_Components) c->Update(time, deltaTime); } private: std::string m_Name; ComponentVector m_Components; }; // The "scene": array of game objects. static GameObjectVector s_Objects; // Finds all components of given type in the whole scene template<typename T> static ComponentVector FindAllComponentsOfType() { ComponentVector res; for (auto go : s_Objects) { T* c = go->GetComponent<T>(); if (c != nullptr) res.emplace_back(c); } return res; } // Find one component of given type in the scene (returns first found one) template<typename T> static T* FindOfType() { for (auto go : s_Objects) { T* c = go->GetComponent<T>(); if (c != nullptr) return c; } return nullptr; } 


Yes, one hundred lines of code is difficult to understand right away, so let's start gradually ... We need another aspect of the prerequisites - in the games of the 90s it was popular to use inheritance to solve all the problems of code reuse. You had an Entity, an expandable Character, an expandable Player and Monster, and so on ... This is an inheritance of implementations, as we described it earlier ( "tactile code" ), and it seems that it’s right to start with it, but as a result it leads to a very inflexible codebase. Because in OOD there is the “composition over inheritance” principle described above. So, in the 2000s, the “composition over inheritance” principle became popular, and game developers started writing similar code.



What does this code do? Well, nothing good :D



In short, this code re-implements an existing language feature — composition as a runtime library, and not as a language feature. You can think of it as if the code actually creates a new metalanguage over C ++ and a virtual machine (VM) to execute this metalanguage. In the demo game Aras, this code is not required ( soon we will completely remove it! ) And serves only to reduce the performance of the game by about 10 times.



However, what does he actually do? This is the concept of " E ntity / C omponent" ("entity / component") ( sometimes for some unknown reason called the " E ntity / C omponent system" ), but it is completely different from the concept of " E ntity C omponent S ystem "(" entity-component-system ") ( which for obvious reasons is never called" E ntity C omponent S ystem systems ). It formalizes several principles of "EC":





This concept was very popular in the 2000s, and despite its limitations, it was flexible enough to create countless games, then and today.



However, this is not required. In your programming language, there is already support for composition as a feature of the language — to access it, there is no need for a bloated concept ... Why, then, do these concepts exist? Well, to be honest, they allow you to perform dynamic composition at run time . Instead of hard-typing GameObject types in code, you can load them from data files. And this is very convenient because it allows game / level designers to create their own object types ... However, in most game projects there are very few designers and literally an entire army of programmers, so I would argue that this is an important opportunity. Worse, this is not the only way a composition can be realized at runtime! For example, Unity uses C # as its “scripting language”, and many other games use its alternatives, for example, Lua - a handy tool for designers, can generate C # / Lua code for defining new game objects without the need for a bloated concept like this! We will re-add this “function” in the next post, and make it so that it does not cost us a tenfold reduction in performance ...



Let's rate this code according to the OOD:





So, all the code shown above can actually be deleted. All this structure. Delete GameObject (also called Entity in other frameworks), delete Component, remove FindOfType. This is part of a useless VM that violates the principles of OOD and terribly slows down our game.



Composition without frameworks (i.e. use of features of the programming language itself)



If we remove the composition framework, and we don’t have the base Component class, how can our GameObjects use the composition and consist of components? As stated in the title, instead of writing this bloated VM and creating GameObjects on top of it on a strange metalanguage, let's just write them in C ++, because we are game programmers and this is literally our job.



Here is the commit in which the Entity / Component framework is deleted: https://github.com/hodgman/dod-playground/commit/f42290d0217d700dea2ed002f2f3b1dc45e8c27c



Here is the original version of the source code: https://github.com/hodgman/dod-playground/blob/3529f232510c95f53112bbfff87df6bbc6aa1fae/source/game.cpp



Here is the modified version of the source code: https://github.com/hodgman/dod-playground/blob/f42290d0217d700dea2ed002f2f3b1dc45e8c27c/source/game.cpp



Briefly about the changes:





Objects



Therefore, instead of this “virtual machine” code:



  // create regular objects that move for (auto i = 0; i < kObjectCount; ++i) { GameObject* go = new GameObject("object"); // position it within world bounds PositionComponent* pos = new PositionComponent(); pos->x = RandomFloat(bounds->xMin, bounds->xMax); pos->y = RandomFloat(bounds->yMin, bounds->yMax); go->AddComponent(pos); // setup a sprite for it (random sprite index from first 5), and initial white color SpriteComponent* sprite = new SpriteComponent(); sprite->colorR = 1.0f; sprite->colorG = 1.0f; sprite->colorB = 1.0f; sprite->spriteIndex = rand() % 5; sprite->scale = 1.0f; go->AddComponent(sprite); // make it move MoveComponent* move = new MoveComponent(0.5f, 0.7f); go->AddComponent(move); // make it avoid the bubble things AvoidComponent* avoid = new AvoidComponent(); go->AddComponent(avoid); s_Objects.emplace_back(go); } 


We now have the usual C ++ code:



 struct RegularObject { PositionComponent pos; SpriteComponent sprite; MoveComponent move; AvoidComponent avoid; RegularObject(const WorldBoundsComponent& bounds) : move(0.5f, 0.7f) // position it within world bounds , pos(RandomFloat(bounds.xMin, bounds.xMax), RandomFloat(bounds.yMin, bounds.yMax)) // setup a sprite for it (random sprite index from first 5), and initial white color , sprite(1.0f, 1.0f, 1.0f, rand() % 5, 1.0f) { } }; ... // create regular objects that move regularObject.reserve(kObjectCount); for (auto i = 0; i < kObjectCount; ++i) regularObject.emplace_back(bounds); 


Algorithms



Another major change has been made to the algorithms. Remember, at the beginning I said that interfaces and algorithms work in symbiosis, and should influence the structure of each other? So, the anti-pattern " virtual void Update " has become the enemy here too. The initial code contains the main loop algorithm, consisting of only this:



  // go through all objects for (auto go : s_Objects) { // Update all their components go->Update(time, deltaTime); 


You can argue that it is beautiful and simple, but IMHO is very, very bad. This completely obfuscates both the flow of control and the flow of data within the game. If we want to be able to understand our software, if we want to support it, if we want to add new things to it, optimize it, run it efficiently on several processor cores, then we need to understand both the flow of control and the flow of data. Therefore, “virtual void Update” needs to be turned on.



Instead, we have created a more explicit main loop, which greatly simplifies the understanding of the control flow (the data flow in it is still obfuscated, but we will fix this in the following commits ).



  // Update all positions for (auto& go : s_game->regularObject) { UpdatePosition(deltaTime, go, s_game->bounds.wb); } for (auto& go : s_game->avoidThis) { UpdatePosition(deltaTime, go, s_game->bounds.wb); } // Resolve all collisions for (auto& go : s_game->regularObject) { ResolveCollisions(deltaTime, go, s_game->avoidThis); } 


The disadvantage of this style is that for each new type of object added to the game, we will have to add a few lines to the main loop. I will come back to this in a later post in this series.



Performance



There are a lot of huge violations of OOD, some bad decisions were made when choosing a structure and there are still many opportunities for optimization, but I will get to them in the next post of the series. However, at this stage it is clear that the version with the “corrected OOD” almost fully meets or defeats the final “ECS” code from the end of the presentation ... And all we did was just take the bad pseudo-OOP code and make it follow the principles OOP (and also deleted a hundred lines of code)!



img


Next steps



Here I want to consider a much wider range of issues, including solving the remaining OOD problems, immutable objects ( programming in the functional style ) and the advantages they can bring in reasoning about data flows, message passing, applying DOD logic to our OOD code, applying relevant wisdom in the OOD code, removing these classes of “entities” that we end up with, and using only pure components, using different styles of connecting components (comparing pointers and the responsibility of carrying) components of containers from the real world, ECS-revision version for better optimization, as well as further optimization, not mentioned in the report Aras (such as multi-threading / SIMD). The order will not necessarily be such, and perhaps I will consider not all of the above ...



Addition



Links to the article have spread beyond the circles of game developers, so add: " ECS " ( this Wikipedia article is bad, by the way, it combines the concepts of EC and ECS, and this is not the same thing ... ) - this is a fake template that circulates within communities game developers. In essence, it is a version of the relational model, in which “entities” are simply IDs, meaning a shapeless object, “components” are rows in specific tables that refer to IDs, and “systems” are procedural code that can modify components . This “pattern” has always been positioned as a solution to the problem of excessive use of inheritance, but it does not mention that excessive use of inheritance actually violates the recommendations of the PLO. From here my indignation. This is not “the only true way” of writing software. The post is designed so that people actually study existing design principles.

Source: https://habr.com/ru/post/441174/



All Articles