OOP está morto, viva OOP

imagem

Fontes de inspiração


Este post surgiu graças a uma publicação recente de Aras Prantskevichus sobre um relatório destinado a programadores juniores. Ele fala sobre como se adaptar às novas arquiteturas do ECS. Aras segue o padrão usual ( explicação abaixo ): mostra exemplos do terrível código de POO e depois demonstra que o modelo relacional ( mas o chama de “ECS” em vez de relacional ) é uma ótima alternativa. De maneira alguma eu critico Aras - sou um grande fã de seu trabalho e o elogio por sua excelente apresentação! Escolhi a apresentação dele em vez de centenas de outros posts sobre ECS na Internet, porque ele fez esforços extras e publicou um repositório git para estudo em paralelo com a apresentação. Ele contém um pequeno “jogo” simples, usado como exemplo da seleção de diferentes soluções arquitetônicas. Este pequeno projeto me permitiu demonstrar meus comentários sobre um material específico, então obrigado, Aras!

Os slides Aras estão disponíveis aqui: http://aras-p.info/texts/files/2018Academy - ECS-DoD.pdf , e o código está no github: https://github.com/aras-p/dod-playground .

Não analisarei (ainda?) A arquitetura do ECS resultante deste relatório, mas focarei no código de "POO ruim" (semelhante ao truque) desde o início. Mostrarei como realmente ficaria se todas as violações dos princípios do OOD (design orientado a objetos, design orientado a objetos) fossem corrigidas corretamente.

Spoiler: eliminar todas as violações de OOD leva a melhorias de desempenho semelhantes às conversões de Aras para ECS, também usa menos RAM e requer menos linhas de código que a versão do ECS!

TL; DR: Antes de concluir que o OOP é um saco e as unidades do ECS, faça uma pausa e examine o OOD (para saber como usar o OOP corretamente) e também entenda o modelo relacional (para saber como aplicar o ECS corretamente).

Venho participando de muitas discussões sobre o ECS no fórum há muito tempo, em parte porque acho que esse modelo não existe como um termo separado ( spoiler: essa é apenas uma versão ad-hoc do modelo relacional ), mas também porque quase todas as postagens, apresentações ou artigos que promovem um padrão de ECS seguem a seguinte estrutura:

  1. Mostre um exemplo de código OOP terrível, cuja implementação apresenta falhas terríveis devido ao uso excessivo de herança (o que significa que essa implementação viola muitos princípios do OOD).
  2. Mostrar que a composição é uma solução melhor que a herança (e sem mencionar que o OOD realmente nos dá a mesma lição).
  3. Mostre que o modelo relacional é ótimo para jogos (mas chame de "ECS").

Essa estrutura me enfurece porque: (A) esse é um truque "cheio" ... compara macio a quente (código ruim e bom código) ... e isso é injusto, mesmo se feito sem intenção e não é necessário para demonstrar que a nova arquitetura é boa; e, mais importante: (B) tem um efeito colateral - essa abordagem suprime o conhecimento e desmotiva inadvertidamente os leitores do conhecimento de estudos realizados por meio século. Eles começaram a escrever sobre o modelo relacional na década de 1960. Ao longo dos anos 70 e 80, esse modelo melhorou significativamente. Os iniciantes geralmente têm perguntas como "em que classe você deseja colocar esses dados? ". E em resposta, muitas vezes, são informados de algo vago, como " você só precisa ganhar experiência e aprender a entender interiormente " ... mas nos anos 70 essa pergunta era ativamente estudado e, no caso geral, foi deduzida uma resposta formal; isso é chamado de normalização do banco de dados . Descartando a pesquisa existente e chamando o ECS de uma solução completamente nova e moderna, você esconde esse conhecimento dos iniciantes.

Os fundamentos da programação orientada a objetos foram estabelecidos há muito tempo, se não antes ( esse estilo começou a ser explorado no trabalho da década de 1950 )! No entanto, foi nos anos 90 que a orientação a objetos se tornou moda, viral e rapidamente se transformou no paradigma de programação dominante. Ocorreu a explosão de popularidade de muitas novas linguagens OO, incluindo Java e o C ++ ( versão padronizada ). No entanto, como isso se deveu ao hype, todos precisavam conhecer esse conceito de alto perfil para escrever em seu currículo, mas apenas alguns realmente se interessaram. Essas novas linguagens criaram as palavras-chave - classe , virtual , extende , implementa - dentre muitos recursos do OO, e acredito que é por isso que naquele momento o OO estava dividido em duas entidades separadas que vivem suas próprias vidas.

Vou me referir ao uso desses recursos de linguagem inspirados em OO como " OOP " e ao uso de técnicas de design / arquitetura inspiradas em OO " OOD ". Tudo rapidamente pegou o POO. As instituições educacionais têm cursos de OO que preparam novos programadores de OOP ... no entanto, o conhecimento do OOD fica para trás.

Acredito que o código que usa os recursos de linguagem do OOP, mas não segue os princípios do design do OOD, não é um código OO . A maioria das críticas contra o OOP usa, por exemplo, código estripado, que não é realmente um código OO.

O código OOP tem uma reputação muito ruim e, principalmente, porque a maioria do código OOP não segue os princípios do OOD e, portanto, não é um código OO "verdadeiro".

Antecedentes


Como dito acima, a década de 90 se tornou o pico da moda OO, e foi nessa época que a “OOP ruim” foi provavelmente a pior. Se você estudou POO naquele momento, provavelmente aprendeu sobre os “quatro pilares da POO”:

  • Abstração
  • Encapsulamento
  • Polimorfismo
  • Herança

Prefiro chamá-los não de quatro pilares, mas de "quatro ferramentas de POO". Essas são ferramentas que você pode usar para resolver problemas. No entanto, não basta apenas descobrir como a ferramenta funciona, você precisa saber quando usá-la ... Por parte dos professores, é irresponsável ensinar às pessoas uma nova ferramenta, sem dizer a eles quando vale a pena usá-las. No início dos anos 2000, houve resistência ao uso indevido ativo dessas ferramentas, uma espécie de "segunda onda" do pensamento OOD. O resultado foi o surgimento das mnemônicas do SOLID , que forneceram uma maneira rápida de avaliar os pontos fortes da arquitetura. Deve-se notar que essa sabedoria foi realmente difundida nos anos 90, mas ainda não recebeu um acrônimo legal, que permitiu que eles fossem fixados em cinco princípios básicos ...

  • O princípio da responsabilidade exclusiva ( princípio da responsabilidade única ). Cada classe deve ter apenas um motivo para a mudança. Se a classe "A" tiver duas responsabilidades, será necessário criar a classe "B" e "C" para processar cada uma delas individualmente e, em seguida, criar "A" a partir de "B" e "C".
  • O princípio de abertura / fechamento ( O pen / princípio fechado). O software muda com o tempo ( ou seja, seu suporte é importante ). Tente colocar as partes com maior probabilidade de alteração nas implementações ( ou seja, em classes específicas ) e crie interfaces com base nas partes que provavelmente não serão alteradas ( por exemplo, classes base abstratas ).
  • O princípio da substituição de Barbara Liskov (princípio da substituição de Iskov). Cada implementação de uma interface deve 100% atender aos requisitos dessa interface, ou seja, qualquer algoritmo que trabalha com uma interface deve funcionar com qualquer implementação.
  • O princípio da separação da interface ( princípio da segregação da interface ). Torne as interfaces o mais pequenas possível, para que cada parte do código "conheça" a menor quantidade de base de código, por exemplo, evite dependências desnecessárias. Essa dica também é boa para C ++, onde os tempos de compilação se tornam enormes se você não a seguir.
  • O princípio da inversão de dependência ( princípio de inversão de dependência ). Em vez de duas implementações específicas que se comunicam diretamente (e dependem uma da outra), elas geralmente podem ser separadas formalizando sua interface de comunicação como uma terceira classe, usada como interface entre elas. Pode ser uma classe base abstrata que define as chamadas dos métodos usados ​​entre eles, ou mesmo apenas uma estrutura POD que define os dados transferidos entre eles.
  • Outro princípio não está incluído no acrônimo SOLID, mas tenho certeza de que é muito importante: “Preferir composição sobre herança” (princípio de reutilização de compostos). A composição é a escolha certa por padrão . A herança deve ser deixada para os casos em que é absolutamente necessário.

Então, temos o SOLID-C (++) :)

Abaixo vou me referir a esses princípios, chamando-os de siglas - SRP, OCP, LSP, ISP, DIP, CRP ...

Mais algumas notas:

  • No OOD, os conceitos de interfaces e implementações não podem ser vinculados a nenhuma palavra-chave OOP específica. Em C ++, geralmente criamos interfaces com classes base abstratas e funções virtuais e, em seguida, implementações herdam dessas classes base ... mas essa é apenas uma maneira específica de implementar o princípio da interface. Em C ++, também podemos usar PIMPL , ponteiros opacos , tipagem de pato , typedef, etc. ... Você pode criar uma estrutura OOD e implementá-la em C, na qual não há palavras-chave da linguagem OOP! Portanto, quando falo de interfaces , não quero dizer necessariamente funções virtuais - estou falando do princípio de ocultar a implementação . As interfaces podem ser polimórficas , mas na maioria das vezes elas são! O polimorfismo é raramente usado corretamente, mas as interfaces são um conceito fundamental para todos os softwares.
    • Como deixei claro acima, se você criar uma estrutura POD que simplesmente armazena alguns dados para transmissão de uma classe para outra, essa estrutura será usada como uma interface - essa é uma descrição formal dos dados .
    • Mesmo se você apenas criar uma classe separada com as partes pública e privada , tudo o que está na parte comum é uma interface e tudo na parte privada é uma implementação .
  • Na verdade, a herança possui (pelo menos) dois tipos - herança de interface e herança de implementação.
    • No C ++, a herança de interface inclui classes básicas abstratas com funções virtuais puras, PIMPL, typedef condicional. Em Java, a herança da interface é expressa através da palavra-chave implementements .
    • No C ++, a herança de implementações ocorre toda vez que as classes base contêm algo diferente de funções virtuais puras. Em Java, a herança de implementação é expressa usando a palavra-chave extends .
    • OOD tem muitas regras para herdar interfaces, mas geralmente vale a pena considerar a herança de implementações como "código com uma mordida" !

E, finalmente, devo mostrar alguns exemplos do terrível treinamento em OOP e como ele leva ao código incorreto na vida real (e à má reputação do OOP).

  1. Ao aprender hierarquias / herança, você pode ter recebido uma tarefa semelhante: suponha que você tenha um aplicativo universitário que contenha um diretório de alunos e funcionários. Você pode criar a classe base Person e, em seguida, a classe Student e a classe Staff, herdadas de Person.

    Não não não Aqui eu vou parar você. A implicação tácita do princípio LSP é que as hierarquias de classe e os algoritmos que as processam são simbióticos. Estas são duas metades de todo o programa. OOP é uma extensão da programação processual e ainda está principalmente associada a esses procedimentos. Se não soubermos que tipos de algoritmos funcionarão com alunos e funcionários ( e quais algoritmos serão simplificados devido ao polimorfismo ), será completamente irresponsável começar a criar a estrutura das hierarquias de classe. Primeiro você precisa conhecer os algoritmos e os dados.
  2. Quando você aprendeu hierarquias / herança, provavelmente recebeu uma tarefa semelhante: suponha que você tenha uma classe de formas. Também temos quadrados e retângulos como subclasses. Um quadrado deve ser um retângulo ou um retângulo um quadrado?

    Este é realmente um bom exemplo para demonstrar a diferença entre herança de implementações e herança de interfaces.
    • Se você usar a abordagem de herança de implementação, desconsidere completamente o LSP e, de um ponto de vista prático, pense na possibilidade de reutilizar o código, usando a herança como ferramenta.

      Desse ponto de vista, o seguinte é perfeitamente lógico:

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

      O quadrado tem apenas a largura e o retângulo tem a largura + altura, ou seja, expandindo o quadrado com o componente de altura, obtemos um retângulo!
      • Como você deve ter adivinhado, a OOD diz que fazer isso ( provavelmente ) está errado. Eu disse "provavelmente" porque aqui você pode discutir sobre as características implícitas da interface ... tudo bem.

        Um quadrado sempre tem a mesma altura e largura; portanto, a partir da interface do quadrado, é perfeitamente verdade supor que a área é "largura * largura".

        Herdando de um quadrado, a classe de retângulos (de acordo com o LSP) deve obedecer às regras da interface do quadrado. Qualquer algoritmo que funcione corretamente para um quadrado também deve funcionar corretamente para um retângulo.
      • Pegue outro algoritmo:

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

        Funcionará corretamente para quadrados (calculando a soma de suas áreas), mas não funcionará para retângulos.

        Portanto, o retângulo viola o princípio LSP.
    • Se você usar a abordagem de herança da interface, nem Quadrado nem Retângulo herdarão um do outro. As interfaces para o quadrado e o retângulo são realmente diferentes e uma não é um superconjunto da outra.
    • Portanto, OOD desencoraja o uso da herança de implementação. Como mencionado acima, se você deseja reutilizar o código, o OOD diz que a composição é a escolha certa!
      • Portanto, a versão correta do código (ruim) acima para a hierarquia de herança das implementações do C ++ é semelhante a esta:

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

        • "Virtual público" em Java significa "implementos". Usado ao implementar a interface.
        • "Privado" permite que você estenda a classe base sem herdar sua interface - nesse caso, o retângulo não é um quadrado, embora seja herdado.
      • Não recomendo escrever esse código, mas se você quiser usar a herança de implementações, precisará fazer exatamente isso!

TL; DR - sua classe OOP lhe disse como era a herança. Sua classe OOD que faltava deveria ter lhe dito para não usá-la 99% do tempo!

Conceitos de entidade / componente


Tendo lidado com os pré-requisitos, vamos prosseguir para onde Aras começou - o chamado ponto de partida de uma "OOP típica".

Mas para iniciantes, mais uma adição - Aras chama esse código de "OOP tradicional", e eu quero contestar isso. Esse código pode ser típico para OOP no mundo real, mas, como nos exemplos acima, viola todos os tipos de princípios básicos de OO, portanto, não deve ser considerado tradicional.

Iniciarei com o primeiro commit antes que ele começasse a refazer a estrutura para o ECS: “Faça funcionar novamente no 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; } 

Sim, é difícil descobrir centenas de linhas de código imediatamente, então vamos começar gradualmente ... Precisamos de outro aspecto dos pré-requisitos - era popular o uso de herança nos jogos dos anos 90 para resolver todos os problemas de reutilização de código. Você tinha Entidade, Personagem extensível, Jogador e monstro extensíveis e assim por diante ... Essa é uma herança de implementações, como descrevemos anteriormente ( "código com um estrangulamento" ), e parece que é certo começar com ela, mas, como resultado, leva a base de código inflexível. Porque OOD tem o princípio de "composição sobre herança" descrito acima. Então, nos anos 2000, o princípio de “composição sobre herança” se tornou popular, e os desenvolvedores de jogos começaram a escrever um código semelhante.

O que esse código faz? Bem, não é bom :D

Em resumo, esse código reimplementa um recurso existente do idioma - composição como uma biblioteca de tempo de execução, e não como um recurso do idioma. Você pode imaginar isso como se o código estivesse realmente criando uma nova metalinguagem sobre C ++ e uma máquina virtual (VM) para executar essa metalinguagem. No jogo de demonstração Aras, esse código não é necessário ( iremos removê-lo completamente em breve! ) E serve apenas para reduzir o desempenho do jogo em cerca de 10 vezes.

Mas o que ele realmente faz? Esse é o conceito de "Sistema de propriedade / componente C " ( às vezes por algum motivo chamado "Sistema de unidade / componente C " ), mas é completamente diferente do conceito de "Sistema de unidade C / componente" sistema de componentes "(" sistema de componentes de entidades ") ( que por razões óbvias nunca é chamado de" sistemas de sistemas de componentes de componentes da comunidade ). Formaliza vários princípios do "CE":

  • o jogo será construído a partir de não ter recursos de "Entidades" ("Entidade") ( neste exemplo chamado GameObjects), que consistem em "componentes" ("Componente").
  • Os GameObjects implementam o padrão "localizador de serviço" - seus componentes filhos serão consultados por tipo.
  • Os componentes sabem a que GameObject eles pertencem - eles podem encontrar componentes que estão no mesmo nível consultando o GameObject principal.
  • A composição pode ter apenas um nível de profundidade (os componentes não podem ter seus próprios componentes filho, GameObjects não pode ter GameObjects filho ).
  • O GameObject pode ter apenas um componente de cada tipo ( em algumas estruturas, este é um requisito obrigatório, em outras não ).
  • Cada componente (provavelmente) muda ao longo do tempo de alguma maneira não especificada, portanto a interface contém uma "atualização de vazio virtual".
  • GameObjects pertencem a uma cena que pode executar consultas em todos os GameObjects (e, portanto, em todos os componentes).

Um conceito semelhante era muito popular nos anos 2000 e, apesar de suas limitações, acabou por ser flexível o suficiente para criar inúmeros jogos tanto na época quanto nos dias de hoje.

No entanto, isso não é necessário. Sua linguagem de programação já tem suporte à composição como um recurso da linguagem - não há necessidade de um conceito inchado para acessá-lo ... Por que, então, esses conceitos existem? Bem, para ser sincero, eles permitem que você execute composição dinâmica em tempo de execução . Em vez de definir os tipos de GameObject no código, você pode carregá-los dos arquivos de dados. E isso é muito conveniente, porque permite que designers de jogos / níveis criem seus próprios tipos de objetos ... No entanto, na maioria dos projetos de jogos, existem muito poucos designers e literalmente um exército inteiro de programadores, então eu diria que essa é uma oportunidade importante. Pior ainda, essa não é a única maneira de implementar uma composição em tempo de execução! Por exemplo, o Unity usa C # como sua "linguagem de script" e muitos outros jogos usam suas alternativas, por exemplo, Lua - uma ferramenta conveniente para os designers podem gerar código C # / Lua para definir novos objetos de jogo sem a necessidade de um conceito tão inchado! Adicionaremos novamente esse "recurso" no próximo post e o faremos para que não nos custe uma redução de dez vezes no desempenho ...

Vamos avaliar esse código de acordo com o OOD:

  • GameObject :: GetComponent usa dynamic_cast. A maioria das pessoas dirá que dynamic_cast é um “código com estrangulamento”, uma grande dica de que você tem um bug em algum lugar. Eu diria isso - isso é evidência de que você violou o LSP - você tem algum tipo de algoritmo que funciona com a interface base, mas precisa conhecer detalhes diferentes da implementação. Por esse motivo específico, o código tem um cheiro ruim.
  • GameObject, em princípio, não é ruim, se você imagina que implementa o modelo "localizador de serviço" ... mas se você for além das críticas do ponto de vista do OOD, esse modelo cria conexões implícitas entre partes do projeto, e eu acho ( sem um link para a Wikipedia que possa suportar com conhecimento da ciência da computação ) que os canais implícitos de comunicação são um antipadrão e devem preferir canais explícitos de comunicação. O mesmo argumento se aplica ao inchado "conceito de eventos" que às vezes é usado em jogos ...
  • Quero declarar que um componente é uma violação do SRP porque sua interface ( atualização de cancelamento virtual (hora) ) é muito ampla. O uso da "atualização de vácuo virtual" no desenvolvimento de jogos é onipresente, mas eu também diria que é antipadrão. Um bom software deve permitir que você pense facilmente sobre o fluxo de controle e o fluxo de dados. Colocar cada elemento do código de jogo atrás da chamada "atualização de vácuo virtual" ofusca completamente o fluxo de controle e o fluxo de dados. O IMHO, efeitos colaterais invisíveis, também chamados de efeitos de longo alcance , são algumas das fontes mais comuns de bugs, e a "atualização de vácuo virtual" garante que quase tudo seja um efeito colateral invisível.
  • Embora o objetivo da classe Component seja habilitar a composição, ele o faz por herança, o que é uma violação do CRP .
  • O único lado bom deste exemplo é que o código do jogo é um exagero para cumprir os princípios do SRP e ISP - ele é dividido em muitos componentes simples com muito pouca responsabilidade, o que é ótimo para reutilizar o código.

    No entanto, ele não é tão bom em manter o DIP - muitos componentes têm conhecimento direto um do outro.

Portanto, todo o código mostrado acima pode realmente ser excluído. Toda essa estrutura. Exclua GameObject (também chamado Entity em outras estruturas), remova Component, exclua FindOfType. Isso faz parte de uma VM inútil que viola os princípios de OOD e desacelera enormemente o nosso jogo.

Composição sem estruturas (ou seja, usando recursos da própria linguagem de programação)


Se removermos a estrutura de composição e não tivermos a classe base Component, como nossos GameObjects conseguirão usar a composição e consistir em componentes? Como o título diz, em vez de escrever esta VM inchada e criar GameObjects em uma estranha metalinguagem, vamos escrevê-los em C ++ porque somos programadores de jogos e esse é literalmente o nosso trabalho.

Aqui está o commit que removeu a estrutura de Entidade / Componente: https://github.com/hodgman/dod-playground/commit/f42290d0217d700dea2ed002f2f3b1dc45e8c27c

Aqui está a versão original do código-fonte: https://github.com/hodgman/dod-playground/blob/3529f232510c95f53112bbfff87df6bbc6aa1fae/source/game.cpp

Aqui está a versão modificada do código-fonte: https://github.com/hodgman/dod-playground/blob/f42290d0217d700dea2ed002f2f3b1dc45e8c27c/source/game.cpp

Brevemente sobre as mudanças:

  • Removido ": public Component" de cada tipo de componente.
  • Adicionado um construtor para cada tipo de componente.
    • OOD é basicamente encapsular o estado de uma classe, mas como essas classes são pequenas / simples, não há nada a esconder: uma interface é uma descrição dos dados. No entanto, um dos principais motivos pelos quais o encapsulamento é o pilar principal é que ele nos permite garantir a verdade constante dos invariantes de classe ... ou se o invariante estiver quebrado, basta examinar o código de implementação encapsulado para encontrar o erro. Neste exemplo de código, vale a pena adicionar construtores para implementar uma invariável simples - todos os valores devem ser inicializados.
  • Renomeei os métodos gerais de “Update” para que seus nomes reflitam o que eles realmente fazem - UpdatePosition for MoveComponent e ResolveCollisions for AvoidComponent.
  • Eu removi três blocos de código codificados que se assemelhavam a um modelo / pré-fabricado - o código que cria um GameObject contendo tipos específicos de Componente e o substitui por três classes C ++.
  • Antipattern eliminado "atualização de vazio virtual".
  • Em vez de os componentes se procurarem através do modelo "localizador de serviço", o jogo os liga explicitamente durante a construção.

Os objetos


Portanto, em vez deste 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); } 

Agora temos código C ++ regular:

 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


Outra grande mudança foi feita nos algoritmos. Lembre-se, no começo eu disse que interfaces e algoritmos funcionam em simbiose e deveriam influenciar a estrutura um do outro? Portanto, o antipattern " Virtual Void Update " se tornou o inimigo aqui também. O código inicial contém o algoritmo principal do loop, consistindo apenas nisso:

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

Você pode argumentar que é bonito e simples, mas IMHO é muito, muito ruim. Isso ofusca completamente o fluxo de controle e o fluxo de dados dentro do jogo. Se queremos entender nosso software, se queremos dar suporte a ele, se queremos adicionar coisas novas, otimizá-lo e executá-lo com eficiência em vários núcleos de processador, precisamos entender o fluxo de controle e o fluxo de dados. Portanto, a "atualização de cancelamento virtual" deve ser incendiada.

Em vez disso, criamos um loop principal mais explícito, que simplifica bastante o entendimento do fluxo de controle (o fluxo de dados ainda é ofuscado, mas corrigiremos isso nos seguintes 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); } 

A desvantagem desse estilo é que, para cada novo tipo de objeto adicionado ao jogo, precisamos adicionar várias linhas ao loop principal. Voltarei a isso em um post subsequente desta série.

Desempenho


Existem muitas violações enormes de OOD, algumas decisões ruins são tomadas ao escolher uma estrutura e existem muitas oportunidades de otimização, mas eu as abordarei no próximo post da série. No entanto, já nesta fase, está claro que a versão com "OOD fixo" corresponde quase completamente ou vence o código "ECS" final a partir do final da apresentação ... E tudo o que fizemos foi apenas pegar o código pseudo-OOP ruim e cumprir com os princípios OOP (e também excluiu centenas de linhas de código)!

img

Próximas etapas


Aqui, quero considerar uma variedade muito maior de problemas, incluindo a solução dos problemas restantes de OOD, objetos imutáveis ​​( programação em estilo funcional ) e as vantagens que eles podem trazer em discussões sobre fluxos de dados, passagem de mensagens, aplicação da lógica DOD ao nosso código OOD, aplicando sabedoria relevante no código OOD, removendo essas classes de “entidades” com as quais acabamos e usando apenas componentes puros, usando estilos diferentes para conectar componentes (comparando ponteiros e a responsabilidade de levar) componentes de contentores do mundo real, a versão ECS-revisão para melhor otimização, bem como uma maior otimização, não mencionada no relatório Aras (como multi-threading / SIMD). A ordem não será necessariamente essa, e talvez eu não considere todas as opções acima ...

Adição


Os links para o artigo se espalharam além dos círculos dos desenvolvedores de jogos, então adicionarei: " ECS " ( este artigo da Wikipedia é ruim, a propósito, combina os conceitos de EC e ECS, e isso não é o mesmo ... ) - esse é um modelo falso que circula nas comunidades desenvolvedores de jogos. De fato, é uma versão do modelo relacional em que "entidades" são apenas IDs que designam um objeto sem forma, "componentes" são linhas em tabelas específicas que fazem referência a IDs e "sistemas" são códigos de procedimentos que podem modificar componentes . Esse “modelo” sempre foi posicionado como uma solução para o problema da aplicação excessiva de herança, mas não é mencionado que a aplicação excessiva de herança realmente viola as recomendações da OOP. Daí minha indignação. Esta não é a "única maneira verdadeira" de escrever software. A publicação foi projetada para garantir que as pessoas realmente aprendam sobre os princípios de design existentes.

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


All Articles