OOP est√° muerto, larga vida OOP

imagen

Fuentes de inspiración


Esta publicaci√≥n surgi√≥ gracias a una publicaci√≥n reciente de Aras Prantskevichus sobre un informe destinado a programadores junior. Habla sobre c√≥mo adaptarse a las nuevas arquitecturas ECS. Aras sigue el patr√≥n habitual ( explicaci√≥n a continuaci√≥n ): muestra ejemplos del terrible c√≥digo OOP y luego demuestra que el modelo relacional ( pero lo llama "ECS" en lugar de relacional ) es una gran alternativa. De ninguna manera critico a Aras, ¬°soy un gran admirador de su trabajo y lo elogio por su excelente presentaci√≥n! Eleg√≠ su presentaci√≥n en lugar de cientos de otras publicaciones de ECS de Internet porque hizo un esfuerzo adicional y public√≥ un repositorio git para estudiar en paralelo con la presentaci√≥n. Contiene un peque√Īo "juego" simple, usado como un ejemplo de la selecci√≥n de diferentes soluciones arquitect√≥nicas. Este peque√Īo proyecto me permiti√≥ demostrar mis comentarios sobre un material espec√≠fico, ¬°as√≠ que gracias, Aras!

Las diapositivas de Aras están disponibles aquí: http://aras-p.info/texts/files/2018Academy - ECS-DoD.pdf , y el código está en github: https://github.com/aras-p/dod-playground .

No analizar√© (¬Ņtodav√≠a?) Analizar la arquitectura ECS resultante de este informe, pero me centrar√© en el c√≥digo de "POO malo" (similar al truco relleno) desde el principio. Mostrar√© c√≥mo se ver√≠a realmente si todas las violaciones de los principios de OOD (dise√Īo orientado a objetos, dise√Īo orientado a objetos) se corrigieran correctamente.

Spoiler: la eliminación de todas las violaciones de OOD conduce a mejoras de rendimiento similares a las conversiones de Aras a ECS, ¡también usa menos RAM y requiere menos líneas de código que la versión ECS!

TL; DR: Antes de concluir que OOP es una mierda y unidades ECS, haga una pausa y examine OOD (para saber cómo usar OOP correctamente), y también comprenda el modelo relacional (para saber cómo aplicar correctamente ECS).

He participado en muchas discusiones sobre ECS en el foro durante mucho tiempo, en parte porque no creo que este modelo merezca existir como un término separado ( spoiler: esta es solo una versión ad-hoc del modelo relacional ), pero también porque Casi todas las publicaciones, presentaciones o artículos que promueven un patrón ECS siguen la siguiente estructura:

  1. Muestre un ejemplo de código OOP terrible, cuya implementación tiene fallas terribles debido al uso excesivo de la herencia (lo que significa que esta implementación viola muchos principios de OOD).
  2. Para mostrar que la composición es una mejor solución que la herencia (y sin mencionar que OOD realmente nos da la misma lección).
  3. Demuestre que el modelo relacional es ideal para juegos (pero ll√°melo "ECS").

Tal estructura me enfurece porque: (A) este es un truco "relleno" ... que compara lo suave con lo c√°lido (c√≥digo incorrecto y buen c√≥digo) ... y esto es deshonesto, incluso si se hace de manera no intencional y no se requiere que demuestre que la nueva arquitectura es buena; y, lo que es m√°s importante: (B) tiene un efecto secundario: este enfoque suprime el conocimiento y desanima involuntariamente a los lectores del conocimiento de los estudios realizados durante medio siglo. Comenzaron a escribir sobre el modelo relacional en la d√©cada de 1960. A lo largo de los a√Īos 70 y 80, este modelo ha mejorado significativamente. Los principiantes a menudo tienen preguntas como " ¬Ņen qu√© clase quieres poner estos datos? ", Y en respuesta a menudo se les dice algo vago, como " solo necesitas ganar experiencia y luego aprender a comprender internamente " ... pero en los a√Īos 70 esta pregunta fue activa estudi√≥ y en el caso general se dedujo una respuesta formal; Esto se llama normalizaci√≥n de la base de datos . Descartando la investigaci√≥n existente y llamando a ECS una soluci√≥n completamente nueva y moderna, ocultas este conocimiento a los principiantes.

¡Los fundamentos de la programación orientada a objetos se establecieron hace mucho tiempo, si no antes ( este estilo comenzó a explorarse en el trabajo de la década de 1950 )! Sin embargo, fue en la década de 1990 que la orientación a objetos se puso de moda, viral y muy rápidamente se convirtió en el paradigma de programación dominante. Se ha producido la explosión de popularidad de muchos nuevos lenguajes OO, incluidos Java y la C ++ ( versión estandarizada ). Sin embargo, dado que esto se debió a la exageración, todos necesitaban conocer este concepto de alto perfil para escribir en su currículum, pero solo unos pocos realmente entraron en él. Estos nuevos lenguajes crearon las palabras clave - clase , virtual , amplia , implementa - a partir de muchas características de OO, y creo que es por eso que en ese momento OO se dividió en dos entidades separadas que viven sus propias vidas.

Me referir√© al uso de estas caracter√≠sticas del lenguaje inspiradas en OO como " OOP " y el uso de t√©cnicas de dise√Īo / arquitectura inspiradas en OO " OOD ". Todo muy r√°pidamente recogi√≥ la POO. Las instituciones educativas tienen cursos de OO que hornean a nuevos programadores de OOP ... sin embargo, el conocimiento de OOD va a la zaga.

Creo que el c√≥digo que usa las caracter√≠sticas del lenguaje de OOP, pero no sigue los principios del dise√Īo OOD, no es un c√≥digo OO . La mayor√≠a de las cr√≠ticas contra OOP usan c√≥digo destripado como ejemplo, que no es realmente un c√≥digo OO.

El código OOP tiene muy mala reputación, y en particular porque la mayoría del código OOP no sigue los principios de OOD y, por lo tanto, no es un código OO "verdadero".

Antecedentes


Como se indicó anteriormente, la década de 1990 se convirtió en el pico de la "moda OO", y fue en ese momento que la "mala OOP" fue probablemente la peor. Si estudiaste POO en ese momento, lo más probable es que hayas aprendido sobre los "cuatro pilares de la POO":

  • Abstracci√≥n
  • Encapsulaci√≥n
  • Polimorfismo
  • Herencia

Prefiero llamarlos no cuatro pilares, sino "cuatro herramientas OOP". Estas son herramientas que puede usar para resolver problemas. Sin embargo, no basta con descubrir c√≥mo funciona la herramienta, debe saber cu√°ndo usarla ... Por parte de los maestros, es irresponsable ense√Īar a las personas una nueva herramienta, no decirles cu√°ndo vale la pena usar cada una de ellas. A principios de la d√©cada de 2000, hubo resistencia al mal uso activo de estas herramientas, una especie de "segunda ola" de pensamiento OOD. El resultado fue la aparici√≥n de mnem√≥nicos S√ďLIDOS , que proporcionaron una forma r√°pida de evaluar las fortalezas arquitect√≥nicas. Cabe se√Īalar que esta sabidur√≠a en realidad se extendi√≥ en los a√Īos 90, pero a√ļn no ha recibido un acr√≥nimo genial, lo que permiti√≥ que se fijaran como cinco principios b√°sicos ...

  • El principio de responsabilidad exclusiva ( principio de responsabilidad individual). Cada clase debe tener solo una raz√≥n para el cambio. Si la clase "A" tiene dos responsabilidades, entonces debe crear la clase "B" y "C" para procesar cada una de ellas individualmente, y luego crear "A" a partir de "B" y "C".
  • El principio de apertura / cierre ( O pen / principio cerrado). El software cambia con el tiempo ( es decir, su soporte es importante ). Intente poner las partes que tienen m√°s probabilidades de cambiar en las implementaciones ( es decir, en clases espec√≠ficas ) y cree interfaces basadas en esas partes que no es probable que cambien ( por ejemplo, clases base abstractas ).
  • El principio de sustituci√≥n de Barbara Liskov (principio de sustituci√≥n de L iskov). Cada implementaci√≥n de una interfaz debe cumplir al 100% los requisitos de esta interfaz, es decir cualquier algoritmo que funcione con una interfaz deber√≠a funcionar con cualquier implementaci√≥n.
  • El principio de separaci√≥n de la interfaz ( principio de segregaci√≥n de interfaz ). Haga que las interfaces sean lo m√°s peque√Īas posible para que cada parte del c√≥digo "sepa" sobre la cantidad m√°s peque√Īa de c√≥digo base, por ejemplo, evite dependencias innecesarias. Este consejo tambi√©n es bueno para C ++, donde los tiempos de compilaci√≥n se vuelven enormes si no lo sigue.
  • El principio de inversi√≥n de dependencia ( principio de inversi√≥n de dependencia ). En lugar de dos implementaciones espec√≠ficas que se comunican directamente (y dependen unas de otras), generalmente pueden separarse formalizando su interfaz de comunicaci√≥n como una tercera clase, utilizada como interfaz entre ellas. Puede ser una clase base abstracta que define las llamadas de los m√©todos utilizados entre ellos, o incluso simplemente una estructura POD que define los datos transferidos entre ellos.
  • Otro principio no est√° incluido en el acr√≥nimo SOLID, pero estoy seguro de que es muy importante: "Prefiera la composici√≥n sobre la herencia" (Principio de reutilizaci√≥n de compuestos). La composici√≥n es la opci√≥n correcta por defecto . La herencia se debe dejar para los casos en que sea absolutamente necesario.

Entonces obtenemos SOLID-C (++) :)

A continuación me referiré a estos principios, llamándolos acrónimos: SRP, OCP, LSP, ISP, DIP, CRP ...

Algunas notas m√°s:

  • En OOD, los conceptos de interfaces e implementaciones no pueden vincularse a ninguna palabra clave espec√≠fica de OOP. En C ++, a menudo creamos interfaces con clases base abstractas y funciones virtuales , y luego las implementaciones heredan de estas clases base ... pero esta es solo una forma espec√≠fica de implementar el principio de la interfaz. En C ++, tambi√©n podemos usar PIMPL , punteros opacos , tipeo de pato , typedef, etc. ¬°Puede crear una estructura OOD y luego implementarla en C, en la que no hay palabras clave del lenguaje OOP! Entonces, cuando hablo de interfaces , no me refiero necesariamente a las funciones virtuales , estoy hablando del principio de ocultar la implementaci√≥n . Las interfaces pueden ser polim√≥rficas , ¬°pero la mayor√≠a de las veces lo son! El polimorfismo rara vez se usa correctamente, pero las interfaces son un concepto fundamental para todo el software.
    • Como dej√© claro anteriormente, si crea una estructura POD que simplemente almacena algunos datos para la transmisi√≥n de una clase a otra, entonces esta estructura se usa como una interfaz ; esta es una descripci√≥n formal de los datos .
    • Incluso si solo crea una clase separada con las partes p√ļblica y privada , entonces todo lo que est√° en la parte com√ļn es una interfaz , y todo en la parte privada es una implementaci√≥n .
  • La herencia en realidad tiene (al menos) dos tipos: herencia de interfaz y herencia de implementaci√≥n.
    • En C ++, la herencia de interfaz incluye clases base abstractas con funciones virtuales puras, PIMPL, typedef condicional. En Java, la herencia de la interfaz se expresa a trav√©s de la palabra clave implements .
    • En C ++, la herencia de implementaciones ocurre cada vez que las clases base contienen algo m√°s que funciones virtuales puras. En Java, la herencia de implementaci√≥n se expresa utilizando la palabra clave extend .
    • ¬°OOD tiene muchas reglas para heredar interfaces, pero vale la pena considerar la herencia de implementaciones como "c√≥digo con un mordisco" !

Y finalmente, debo mostrar algunos ejemplos de la terrible capacitación de OOP y cómo conduce a un mal código en la vida real (y la mala reputación de OOP).

  1. Cuando le ense√Īaron jerarqu√≠as / herencia, es posible que le hayan asignado una tarea similar: suponga que tiene una aplicaci√≥n universitaria que contiene un directorio de estudiantes y personal. Puede crear la clase base Persona, y luego la clase Estudiante y la clase Personal, heredadas de Persona.

    No no no Aquí te detendré. La implicación tácita del principio LSP es que las jerarquías de clase y los algoritmos que las procesan son simbióticos. Estas son las dos mitades de todo el programa. OOP es una extensión de la programación de procedimientos, y todavía se asocia principalmente con estos procedimientos. Si no sabemos qué tipos de algoritmos funcionarán con los estudiantes y el personal ( y qué algoritmos se simplificarán debido al polimorfismo ), entonces será completamente irresponsable comenzar a crear la estructura de las jerarquías de clases. Primero necesita conocer los algoritmos y los datos.
  2. Cuando le ense√Īaron jerarqu√≠as / herencia, probablemente le dieron una tarea similar: suponga que tiene una clase de formas. Tambi√©n tenemos cuadrados y rect√°ngulos como subclases. ¬ŅDeber√≠a un cuadrado ser un rect√°ngulo o un rect√°ngulo un cuadrado?

    Este es realmente un buen ejemplo para demostrar la diferencia entre la herencia de implementaciones y la herencia de interfaces.
    • Si utiliza el enfoque de herencia de implementaci√≥n, entonces ignora por completo el LSP y, desde un punto de vista pr√°ctico, piensa en la posibilidad de reutilizar el c√≥digo, utilizando la herencia como herramienta.

      Desde este punto de vista, lo siguiente es perfectamente lógico:

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

      El cuadrado tiene solo el ancho, y el rect√°ngulo tiene el ancho + alto, es decir, al expandir el cuadrado con el componente de altura, ¬°obtenemos un rect√°ngulo!
      • Como habr√°s adivinado, OOD dice que hacer esto es ( probablemente ) incorrecto. Dije "probablemente" porque aqu√≠ puedes discutir sobre las caracter√≠sticas impl√≠citas de la interfaz ... oh, bueno.

        Un cuadrado siempre tiene la misma altura y ancho, por lo que desde la interfaz del cuadrado es perfectamente cierto suponer que el √°rea es "ancho * ancho".

        Al heredar de un cuadrado, la clase de rect√°ngulos (seg√ļn LSP) debe obedecer las reglas de la interfaz cuadrada. Cualquier algoritmo que funcione correctamente para un cuadrado tambi√©n deber√≠a funcionar correctamente para un rect√°ngulo.
      • Toma otro algoritmo:

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

        Funcionar√° correctamente para cuadrados (calculando la suma de sus √°reas), pero no funcionar√° para rect√°ngulos.

        Por lo tanto, el rect√°ngulo viola el principio LSP.
    • Si utiliza el enfoque de herencia de interfaz, ni Square ni Rectangle heredar√°n entre s√≠. Las interfaces para el cuadrado y el rect√°ngulo son realmente diferentes, y una no es un superconjunto de la otra.
    • Por lo tanto, OOD desalienta el uso de la herencia de implementaci√≥n. Como se indic√≥ anteriormente, si desea reutilizar el c√≥digo, ¬°OOD dice que la composici√≥n es la elecci√≥n correcta!
      • Entonces, la versi√≥n correcta del c√≥digo (incorrecto) anterior para la jerarqu√≠a de herencia de las implementaciones de C ++ se ve as√≠:

         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; }; 

        • "P√ļblico virtual" en Java significa "implementos". Se usa al implementar la interfaz.
        • "Privado" le permite extender la clase base sin heredar su interfaz; en este caso, el rect√°ngulo no es un cuadrado, aunque hereda de √©l.
      • No recomiendo escribir dicho c√≥digo, pero si desea usar la herencia de implementaciones, ¬°debe hacer eso!

TL; DR: su clase de OOP le dijo cómo era la herencia. ¡Tu clase de OOD faltante debería haberte dicho que no la uses el 99% del tiempo!

Conceptos de entidad / componente


Habiendo tratado con los requisitos previos, pasemos a donde comenzó Aras, al llamado punto de partida de una "OOP típica".

Pero para empezar, una adición más: Aras llama a este código "OOP tradicional", y quiero objetarlo. Este código puede ser típico para OOP en el mundo real, pero, al igual que los ejemplos anteriores, viola todo tipo de principios básicos de OO, por lo que no debe considerarse como tradicional.

Comenzar√© con el primer commit antes de que √©l comenzara a rehacer la estructura hacia ECS: ‚ÄúHaz que funcione nuevamente en Windows‚ÄĚ 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; } 

S√≠, es dif√≠cil descifrar cien l√≠neas de c√≥digo de inmediato, as√≠ que comencemos gradualmente ... Necesitamos otro aspecto de los requisitos previos: era popular usar la herencia en los juegos de los a√Īos 90 para resolver todos los problemas de reutilizaci√≥n de c√≥digo. Ten√≠as Entity, Character extensible, Player y Monster extensibles, y as√≠ sucesivamente ... Esta es una herencia de implementaciones, como describimos anteriormente ( "c√≥digo con estrangulador" ), y parece que es correcto comenzar con eso, pero como resultado conduce a base de c√≥digo inflexible. Porque OOD tiene el principio de "composici√≥n sobre herencia" descrito anteriormente. Entonces, en la d√©cada de 2000, el principio de "composici√≥n sobre herencia" se hizo popular, y los desarrolladores de juegos comenzaron a escribir c√≥digo similar.

¬ŅQu√© hace este c√≥digo? Bueno no bueno : D

En resumen, este código vuelve a implementar una característica existente del lenguaje: la composición como una biblioteca de tiempo de ejecución y no como una característica del lenguaje. Puede imaginar esto como si el código estuviera realmente creando un nuevo metalenguaje sobre C ++ y una máquina virtual (VM) para ejecutar este metalenguaje. En el juego de demostración de Aras, este código no es obligatorio ( ¡lo eliminaremos por completo pronto! ) Y solo sirve para reducir el rendimiento del juego unas 10 veces.

¬ŅPero qu√© hace √©l realmente? Este es el concepto de "sistema de componente E / entidad" (a veces por alguna raz√≥n llamado "sistema de componente E / C " ), pero es completamente diferente del concepto de "sistema C sistema de componente omponente "(" sistema de componente de entidad ") ( que por razones obvias nunca se llama" sistemas de sistema de componente de entidad de la entidad). Formaliza varios principios de la "CE":

  • el juego se construir√° a partir de no tener caracter√≠sticas de "Entidades" ("Entidad") ( en este ejemplo, llamadas GameObjects), que consisten en "componentes" ("Componente").
  • GameObjects implementa el patr√≥n de "localizador de servicios" : sus componentes secundarios se consultar√°n por tipo.
  • Los componentes saben a qu√© GameObject pertenecen: pueden encontrar componentes que est√©n en el mismo nivel con ellos consultando el GameObject padre.
  • La composici√≥n puede tener solo un nivel de profundidad (los componentes no pueden tener sus propios componentes secundarios, GameObjects no puede tener GameObjects secundarios ).
  • GameObject puede tener solo un componente de cada tipo ( en algunos marcos esto es un requisito obligatorio, en otros no ).
  • Cada componente (probablemente) cambia con el tiempo de una manera no especificada, por lo que la interfaz contiene una "Actualizaci√≥n virtual vac√≠a".
  • Los GameObjects pertenecen a una escena que puede ejecutar consultas en todos los GameObjects (y, por lo tanto, en todos los componentes).

Un concepto similar fue muy popular en la década de 2000, y a pesar de sus limitaciones, resultó ser lo suficientemente flexible como para crear innumerables juegos tanto en la actualidad como en la actualidad.

Sin embargo, esto no es obligatorio. Su lenguaje de programaci√≥n ya tiene soporte para la composici√≥n como una caracter√≠stica del lenguaje: no hay necesidad de un concepto hinchado para acceder a √©l ... ¬ŅPor qu√©, entonces, existen estos conceptos? Bueno, para ser sincero, te permiten realizar composiciones din√°micas en tiempo de ejecuci√≥n . En lugar de definir tipos de GameObject en el c√≥digo, puede cargarlos desde archivos de datos. Y esto es muy conveniente, porque permite a los dise√Īadores de juegos / niveles crear sus propios tipos de objetos ... Sin embargo, en la mayor√≠a de los proyectos de juegos hay muy pocos dise√Īadores y literalmente un ej√©rcito completo de programadores, por lo que dir√≠a que esta es una oportunidad importante. Peor a√ļn, ¬°esta no es la √ļnica forma en que puede implementar una composici√≥n en tiempo de ejecuci√≥n! Por ejemplo, Unity usa C # como su "lenguaje de programaci√≥n", y muchos otros juegos usan sus alternativas, por ejemplo, Lua, una herramienta conveniente para los dise√Īadores que puede generar c√≥digo C # / Lua para definir nuevos objetos de juego sin la necesidad de un concepto tan inflado. Volveremos a agregar esta "caracter√≠stica" en la pr√≥xima publicaci√≥n y haremos que no nos cueste una disminuci√≥n de diez veces en el rendimiento ...

Evaluemos este código de acuerdo con OOD:

  • GameObject :: GetComponent usa dynamic_cast. La mayor√≠a de la gente te dir√° que dynamic_cast es un "c√≥digo con un estrangulador", una gran pista de que tienes un error en alguna parte. Dir√≠a esto, esto es evidencia de que viol√≥ el LSP , tiene alg√ļn tipo de algoritmo que funciona con la interfaz base, pero necesita conocer diferentes detalles de implementaci√≥n. Por esta raz√≥n en particular, el c√≥digo huele mal.
  • GameObject, en principio, no es malo, si imagina que implementa la plantilla de "localizador de servicios" ... pero si va m√°s all√° de las cr√≠ticas desde el punto de vista de OOD, esta plantilla crea conexiones impl√≠citas entre partes del proyecto, y creo que ( sin un enlace a Wikipedia que pueda soportar yo con conocimiento de la inform√°tica ) que los canales de comunicaci√≥n impl√≠citos son un antipatr√≥n , y deber√≠an preferir canales de comunicaci√≥n expl√≠citos. El mismo argumento se aplica al hinchado "concepto de eventos" que a veces se usa en los juegos ...
  • Quiero afirmar que un componente es una violaci√≥n de SRP porque su interfaz ( virtual void Update (time) ) es demasiado amplia. El uso de la "Actualizaci√≥n virtual de vac√≠o" en el desarrollo de juegos es omnipresente, pero tambi√©n dir√≠a que es antipattern. Un buen software deber√≠a permitirle pensar f√°cilmente sobre el flujo de control y el flujo de datos. Colocar cada elemento del c√≥digo de juego detr√°s de la llamada "Actualizaci√≥n virtual vac√≠a" ofusca completamente y completamente el flujo de control y el flujo de datos. En mi humilde opini√≥n, los efectos secundarios invisibles, tambi√©n llamados de largo alcance, son algunas de las fuentes m√°s comunes de errores, y la "Actualizaci√≥n de vac√≠o virtual" asegura que casi todo ser√° un efecto secundario invisible.
  • Aunque el objetivo de la clase Component es habilitar la composici√≥n, lo hace a trav√©s de la herencia, lo cual es una violaci√≥n de la CRP .
  • El √ļnico lado bueno de este ejemplo es que el c√≥digo del juego es excesivo para cumplir con los principios de SRP e ISP: est√° dividido en muchos componentes simples con muy poca responsabilidad, lo cual es excelente para reutilizar el c√≥digo.

    Sin embargo, no es tan bueno para mantener DIP: muchos componentes tienen conocimiento directo el uno del otro.

Por lo tanto, todo el c√≥digo que se muestra arriba se puede eliminar. Toda esta estructura. Eliminar GameObject (tambi√©n llamado Entidad en otros marcos), eliminar Componente, eliminar FindOfType. Esto es parte de una m√°quina virtual in√ļtil que viola los principios de OOD y ralentiza terriblemente nuestro juego.

Composición sin marcos (es decir, utilizando características del lenguaje de programación en sí)


Si eliminamos el marco de composici√≥n y no tenemos la clase base Componente, ¬Ņc√≥mo lograr√°n nuestros GameObjects utilizar la composici√≥n y consistir en componentes? Como dice el t√≠tulo, en lugar de escribir esta m√°quina virtual hinchada y crear GameObjects en un extra√Īo metalenguaje, solo escrib√°moslos en C ++ porque somos programadores de juegos y este es literalmente nuestro trabajo.

Aquí está el commit que eliminó el marco Entity / Component: https://github.com/hodgman/dod-playground/commit/f42290d0217d700dea2ed002f2f3b1dc45e8c27c

Aquí está la versión original del código fuente: https://github.com/hodgman/dod-playground/blob/3529f232510c95f53112bbfff87df6bbc6aa1fae/source/game.cpp

Aquí está la versión modificada del código fuente: https://github.com/hodgman/dod-playground/blob/f42290d0217d700dea2ed002f2f3b1dc45e8c27c/source/game.cpp

Brevemente sobre los cambios:

  • Se elimin√≥ ": Componente p√ļblico" de cada tipo de componente.
  • Se agreg√≥ un constructor a cada tipo de componente.
    • OOD se trata principalmente de encapsular el estado de una clase, pero dado que estas clases son tan peque√Īas / simples, no hay nada especial que ocultar: una interfaz es una descripci√≥n de los datos. Sin embargo, una de las razones principales por las que la encapsulaci√≥n es el pilar principal es que nos permite garantizar la verdad constante de los invariantes de clase ... o si el invariante est√° roto, entonces solo necesita examinar el c√≥digo de implementaci√≥n encapsulado para encontrar el error. En este ejemplo de c√≥digo, vale la pena agregar constructores para implementar una invariante simple: todos los valores deben inicializarse.
  • Cambi√© el nombre de los m√©todos "Actualizar" demasiado generales para que sus nombres reflejen lo que realmente hacen: UpdatePosition para MoveComponent y ResolveCollisions for AvoidComponent.
  • Elimin√© tres bloques de c√≥digo codificados que se parec√≠an a una plantilla / prefabricado, el c√≥digo que crea un GameObject que contiene tipos espec√≠ficos de Componente, y lo reemplac√© con tres clases de C ++.
  • Se elimin√≥ la "Actualizaci√≥n de vac√≠o virtual" antipatr√≥n.
  • En lugar de que los componentes se busquen entre s√≠ a trav√©s de la plantilla de "localizador de servicios", el juego los une expl√≠citamente durante la construcci√≥n.

Los objetos


Por lo tanto, en lugar de este código de "máquina virtual":

  // 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); } 

Ahora tenemos código C ++ normal:

 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); 

Algoritmos


Se ha realizado otro cambio importante en los algoritmos. ¬ŅRecuerdas que al principio dije que las interfaces y los algoritmos funcionan en simbiosis y deber√≠an influir en la estructura de cada uno? Entonces, la " Actualizaci√≥n de vac√≠o virtual " antipatr√≥n se ha convertido en el enemigo aqu√≠ tambi√©n. El c√≥digo inicial contiene el algoritmo del bucle principal, que consiste solo en esto:

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

Puedes argumentar que es hermoso y simple, pero en mi humilde opini√≥n es muy, muy malo. Esto ofusca completamente tanto el flujo de control como el flujo de datos dentro del juego. Si queremos poder comprender nuestro software, si queremos admitirlo, si queremos agregarle cosas nuevas, optimizarlo, ejecutarlo de manera eficiente en varios n√ļcleos de procesador, entonces debemos comprender tanto el flujo de control como el flujo de datos. Por lo tanto, la "Actualizaci√≥n de vac√≠o virtual" debe ser incendiada.

En cambio, creamos un bucle principal m√°s expl√≠cito, que simplifica enormemente la comprensi√≥n del flujo de control (el flujo de datos a√ļn est√° ofuscado, pero lo arreglaremos en las siguientes confirmaciones ).

  // 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); } 

La desventaja de este estilo es que para cada nuevo tipo de objeto agregado al juego, tenemos que agregar varias líneas al bucle principal. Volveré sobre esto en una publicación posterior de esta serie.

Rendimiento


Hay muchas violaciones enormes de OOD, se toman algunas malas decisiones al elegir una estructura y hay muchas oportunidades para la optimización, pero las abordaré en la próxima publicación de la serie. Sin embargo, ya en esta etapa está claro que la versión con "OOD fijo" coincide casi por completo o gana el código final "ECS" desde el final de la presentación ... Y todo lo que hicimos fue tomar el código pseudo-OOP incorrecto y hacer que cumpliera con los principios ¡OOP (y también borró cien líneas de código)!

img

Próximos pasos


Aquí quiero considerar una gama mucho más amplia de problemas, incluida la resolución de los problemas OOD restantes, los objetos inmutables ( programación en un estilo funcional ) y las ventajas que pueden aportar en las discusiones sobre flujos de datos, paso de mensajes, aplicación de lógica DOD a nuestro código OOD, aplicando sabiduría relevante en el código OOD, eliminando estas clases de "entidades" con las que terminamos, y usando solo componentes puros, usando diferentes estilos para conectar componentes (comparando punteros y la responsabilidad de llevar) componentes de contenedores del mundo real, la versión ECS-revisión para una mejor optimización, así como una mayor optimización, no se menciona en el informe de Aras (tales como multi-threading / SIMD). El orden no será necesariamente este, y quizás no consideraré todo lo anterior ...

Adem√°s


Los enlaces al art√≠culo se han extendido m√°s all√° de los c√≠rculos de desarrolladores de juegos, por lo que agregar√©: " ECS " ( este art√≠culo de Wikipedia es malo, por cierto, combina los conceptos de EC y ECS, y esto no es lo mismo ... ) - esta es una plantilla falsa que circula dentro de las comunidades desarrolladores de juegos. De hecho, es una versi√≥n del modelo relacional en el que las "entidades" son solo ID que designan un objeto sin forma, los "componentes" son filas en tablas espec√≠ficas que hacen referencia a los ID, y los "sistemas" son c√≥digos de procedimiento que pueden modificar componentes. . Esta "plantilla" siempre se ha posicionado como una soluci√≥n al problema del uso excesivo de la herencia, pero no se menciona que el uso excesivo de la herencia realmente viola las recomendaciones de la OOP. De ah√≠ mi indignaci√≥n. Esta no es la "√ļnica forma verdadera" de escribir software. La publicaci√≥n est√° dise√Īada para garantizar que las personas realmente aprendan sobre los principios de dise√Īo existentes.

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


All Articles