Design orientado a dados (ou por que, usando OOP, você provavelmente se atira no pé)

imagem

Imagine esta imagem: o fim do ciclo de desenvolvimento está chegando, seu jogo mal está se aproximando, mas no criador de perfil você não consegue encontrar áreas problemáticas óbvias. Quem é o culpado? Padrões de memória de acesso aleatório e falhas persistentes de cache. Tentando melhorar o desempenho, você tenta paralelizar partes do código, mas vale a pena os esforços heróicos e, no final, devido a toda a sincronização que precisou ser adicionada, a aceleração é quase imperceptível. Além disso, o código é tão complicado que a correção de bugs causa ainda mais problemas, e o pensamento de adicionar novos recursos é imediatamente descartado. Parece familiar?

Esse desenvolvimento de eventos descreve com bastante precisão quase todos os jogos em que participei nos últimos dez anos. As razões não estão nas linguagens de programação ou nas ferramentas de desenvolvimento, ou mesmo na falta de disciplina. Na minha experiência, em grande parte, a programação orientada a objetos (OOP) e sua cultura circundante devem ser responsabilizadas. OOP pode não ajudar, mas interfere nos seus projetos!

É tudo sobre dados


OOP penetrou tanto na cultura existente de desenvolvimento de videogames que, quando você pensa em um jogo, é difícil imaginar outra coisa além de objetos. Há muitos anos, criamos aulas para carros, jogadores e máquinas estaduais. Quais são as alternativas? Programação processual? Linguagens funcionais? Linguagens de programação exóticas?

O design orientado a dados é outra maneira de projetar software projetado para resolver todos esses problemas. O principal elemento da programação procedural são as chamadas de procedimentos, e o POO lida principalmente com objetos. Observe que, nos dois casos, o código é colocado no centro: em um caso, esses são procedimentos (ou funções) comuns; no outro, código agrupado associado a um determinado estado interno. O design orientado a dados muda o foco de atenção dos objetos para os próprios dados: o tipo de dado, sua localização na memória, os métodos para lê-lo e processá-lo no jogo.

Programar por definição é uma maneira de converter dados: o ato de criar uma sequência de instruções da máquina que descrevem o processo de processamento de dados de entrada e criação de dados de saída. Um jogo nada mais é do que um programa interativo; portanto, não seria mais lógico concentrar-se principalmente nos dados, e não no código que os processa?

Para não confundir você, explicarei imediatamente: o design orientado a dados não significa que o programa é orientado por dados. Um jogo orientado a dados geralmente é um jogo cuja funcionalidade está amplamente fora do código; permite que os dados determinem o comportamento do jogo. Esse conceito é independente do design orientado a dados e pode ser usado em qualquer método de programação.

Dados perfeitos


Sequência de chamadas com uma abordagem orientada a objetos

Figura 1a. Sequência de chamadas com uma abordagem orientada a objetos

Se olharmos para o programa em termos de dados, como serão os dados ideais? Depende dos dados em si e de como usá-los. Em geral, os dados ideais estão em um formato que pode ser usado com o mínimo esforço. Na melhor das hipóteses, o formato coincide completamente com o resultado esperado, ou seja, o processamento consiste apenas em copiar os dados. Muitas vezes, um esquema de dados ideal se parece com grandes blocos de dados homogêneos adjacentes que podem ser processados ​​sequencialmente. Seja como for, o objetivo é minimizar o número de transformações; se possível, “cozinhe” os dados nesse formato ideal com antecedência, na fase de criação de recursos do jogo.

Como o design orientado a dados coloca os dados em primeiro lugar, podemos criar a arquitetura de um programa inteiro em torno de um formato de dados ideal. Nem sempre conseguiremos torná-lo completamente perfeito (assim como o código raramente se assemelha ao OOP de um livro didático), mas esse é o nosso principal objetivo, do qual sempre lembramos. Quando alcançamos isso, a maioria dos problemas mencionados no começo do artigo simplesmente se dissolve (mais nesta próxima seção).

Quando pensamos em objetos, lembramos imediatamente as árvores - árvores de herança, árvores de nidificação ou árvores de mensagens, e nossos dados são naturalmente ordenados dessa maneira. Portanto, quando realizamos uma operação em um objeto, isso geralmente leva ao fato de que o objeto, por sua vez, acessa outros objetos na árvore. Ao iterar sobre vários objetos, executar a mesma operação gera operações a jusante, completamente diferentes para cada objeto (veja a Figura 1a).

Sequência de chamadas com uma abordagem orientada a dados

Figura 1b. Sequência de chamadas em uma técnica orientada a dados

Para obter o melhor esquema de armazenamento de dados, pode ser útil dividir cada objeto em diferentes componentes e agrupar componentes do mesmo tipo na memória, independentemente do objeto do qual os tiramos. Essa ordenação leva à criação de grandes blocos de dados homogêneos, permitindo processar os dados sequencialmente (veja a Figura 1b). A principal razão para o poder do conceito de design orientado a dados é que ele funciona muito bem com grandes grupos de objetos. OOP, por definição, trabalha com um único objeto. Lembre-se do último jogo em que você trabalhou: com que frequência no código havia lugares em que você tinha que trabalhar com apenas um elemento? Um inimigo? Um veículo? Uma maneira de encontrar o nó? Uma bala? Uma peça? Nunca! Onde há um, há vários mais. OOP ignora isso e trabalha com cada objeto individualmente. Portanto, podemos simplificar o trabalho para nós mesmos e para o equipamento, organizando os dados para que fosse necessário processar muitos elementos do mesmo tipo.

Essa abordagem parece estranha para você? Mas você sabe o que? Provavelmente, você já o está usando em algumas partes do código: ou seja, no sistema de partículas! O design orientado a dados transforma toda a base de código em um enorme sistema de partículas. É possível que esse método parecesse mais familiar aos desenvolvedores de jogos, mas teria que ser chamado de programação orientada a partículas.

Benefícios do design orientado a dados


Se pensarmos primeiro nos dados e criarmos a arquitetura do programa com base nisso, isso nos dará muitas vantagens.

Paralelismo


Hoje em dia é impossível nos livrar do fato de que precisamos trabalhar com vários núcleos. Aqueles que tentaram paralelizar o código OOP podem confirmar a complexidade da tarefa, propensa a erros e talvez não particularmente eficiente. Freqüentemente, é necessário adicionar muitas primitivas de sincronização para evitar o acesso simultâneo aos dados de vários threads, e geralmente muitos threads ficam ociosos por um longo tempo, aguardando que outros threads terminem de funcionar. Como resultado, os ganhos de produtividade são bastante medíocres.

Se aplicarmos o design orientado a dados, a paralelização se tornará muito mais simples: temos dados de entrada, uma pequena função que os processa e gera dados. Algo semelhante pode ser facilmente dividido em vários fluxos com sincronização mínima entre eles. Você pode dar mais um passo à frente e executar esse código em processadores com memória local (por exemplo, em SPUs de processadores Cell) sem alterar nenhuma operação.

Uso de cache


Além de usar o multi-core, uma das principais maneiras de obter alto desempenho em equipamentos modernos com dutos avançados de instruções e sistemas de memória lenta com vários níveis de cache é a implementação do acesso a dados que é conveniente para o armazenamento em cache. O design orientado a dados permite o uso muito eficiente do cache de comandos, porque o mesmo código é constantemente executado nele. Além disso, se organizarmos os dados em grandes blocos adjacentes, poderemos processar os dados sequencialmente, obtendo um uso quase perfeito do cache de dados e excelente desempenho.

Opção de otimização


Quando pensamos em objetos ou funções, geralmente nos concentramos em otimizar no nível de uma função ou mesmo em um algoritmo: tentamos alterar a ordem das chamadas de funções, o método de classificação ou até reescrever parte do código C na linguagem assembly.

Essas otimizações são certamente úteis, mas se você pensar nos dados primeiro, podemos recuar e criar otimizações mais ambiciosas e importantes. Não esqueça que o jogo lida apenas com a conversão de determinados dados (recursos, entrada do usuário, status) em outros dados (comandos gráficos, novos estados do jogo). Com esse fluxo de dados em mente, podemos tomar decisões mais informadas e de alto nível com base em como os dados são convertidos e aplicados. Tais otimizações nas técnicas mais tradicionais de POO podem ser extremamente complexas e demoradas.

Modularidade


Todas as vantagens acima do design orientado a dados estavam relacionadas ao desempenho: uso, otimização e paralelização de cache. Não há dúvida de que, para nós programadores de jogos, o desempenho é extremamente importante. Geralmente, há um conflito entre técnicas que aumentam a produtividade e técnicas que promovem a legibilidade do código e a facilidade de desenvolvimento. Por exemplo, se reescrevermos parte do código na linguagem assembly, melhoraremos o desempenho, mas isso geralmente leva a uma diminuição da legibilidade e dificulta o suporte ao código.

Felizmente, o design orientado a dados beneficia a produtividade e a facilidade de desenvolvimento. Se você escreve um código especificamente para a conversão de dados, obtém pequenas funções com um número muito pequeno de dependências com outras partes do código. A base de código permanece muito "plana", com muitas funções "folha" que não possuem grandes dependências. Esse nível de modularidade e a ausência de dependências simplificam muito o entendimento, a substituição e a atualização do código.

Teste


O último grande benefício do design orientado a dados é a facilidade de teste. Muitas pessoas sabem que escrever testes de unidade para testar a interação de objetos é uma tarefa não trivial. Você precisa criar layouts e elementos de teste indiretamente. Honestamente, isso é bastante doloroso. Por outro lado, trabalhando diretamente com dados, escrever testes de unidade é absolutamente fácil: criamos alguns dados recebidos, chamamos a função que os converte e verificamos se a saída corresponde aos dados esperados. E isso é tudo. De fato, essa é uma grande vantagem que simplifica bastante o teste de código, seja no desenvolvimento orientado a testes ou na gravação de testes de unidade após o código.

Desvantagens do design orientado a dados


O design orientado a dados não é uma "bala de prata" que resolve todos os problemas no desenvolvimento de jogos. Realmente ajuda a escrever código de alto desempenho e a criar programas mais legíveis e fáceis de manter, mas, por si só, têm algumas desvantagens.

O principal problema do design orientado a dados: ele difere do que a maioria dos programadores aprendeu e costumava fazer. Exige transformar nosso modelo mental do programa em noventa graus e mudar o ponto de vista dele. Para que essa abordagem se torne uma segunda natureza, é necessária prática.

Além disso, devido à diferença de abordagens, isso pode causar dificuldades em interagir com o código existente, escrito em estilo processual ou OOP. É difícil escrever uma função separadamente, mas assim que você pode aplicar o design orientado a dados para um subsistema inteiro, você pode obter muitas vantagens.

Usando Design Orientado a Dados


Teoria e críticas suficientes. Como começar a implementar o método de design orientado a dados? Para começar, selecione uma área específica do seu código: navegação, animações, colisões ou outra coisa. Posteriormente, quando a parte principal do mecanismo do jogo estiver focada nos dados, você poderá ajustar o fluxo de dados ao longo de todo o caminho, desde o início do quadro até o fim.

Em seguida, é necessário identificar claramente os dados de entrada exigidos pelo sistema e o tipo de dados que ele deve gerar. Você pode estar pensando na terminologia OOP por enquanto, apenas para identificar os dados. Por exemplo, para um sistema de animação, parte dos dados de entrada serão esqueletos, poses básicas, dados de animação e o estado atual. O resultado não é "código de animação animada", mas dados gerados pelas animações atualmente sendo reproduzidas. Nesse caso, a saída será um novo conjunto de poses e um estado atualizado.

É importante dar um passo atrás e classificar os dados recebidos com base em como eles são usados. Eles são somente leitura, leitura / gravação ou somente gravação? Essa classificação ajudará a tomar decisões sobre onde armazenar dados e quando processá-los devido a dependências de outras partes do programa.

Nesse estágio, você precisa parar de pensar nos dados necessários para uma operação e começar a pensar em aplicá-los a dezenas ou centenas de elementos. Não temos mais um esqueleto, uma pose básica e um estado atual: temos um bloco de cada um desses tipos com muitas instâncias em cada um dos blocos.

Considere cuidadosamente como os dados serão usados ​​no processo de transformação de entrada em saída. Você pode perceber que, para transmitir dados, é necessário varrer um campo específico da estrutura e, em seguida, usar os resultados para executar outra passagem. Nesse caso, pode ser mais lógico dividir esse campo de origem em um bloco de memória separado, que pode ser processado separadamente, o que fará melhor uso do cache e preparará o código para uma possível paralelização. Ou você pode precisar vetorizar parte do código se precisar receber dados de locais diferentes para colocá-los em um registro de vetor. Nesse caso, os dados serão armazenados adjacentes para que as operações vetoriais possam ser aplicadas diretamente, sem conversões desnecessárias.

Agora você deve ter um entendimento muito bom dos seus dados. Escrever código para convertê-los se tornará muito mais fácil. Será como criar código preenchendo espaços. Você ficará surpreso ao saber que o código acabou sendo muito mais simples e mais compacto do que você pensava originalmente, em comparação com o mesmo código OOP.

A maioria das postagens no meu blog preparou você para esse tipo de design. Agora, precisamos ter cuidado com a organização dos dados, assar os dados no formato de entrada, para que possam ser usados ​​com eficiência e usar links sem ponteiros entre os blocos de dados, para que possam ser movidos facilmente.

Ainda há espaço para usar OOP?


Isso significa que o POO é inútil e nunca deve ser usado ao criar programas? Eu não posso dizer isso. Pensar no contexto de objetos não é prejudicial se estivermos falando apenas de uma instância de cada objeto (por exemplo, um dispositivo gráfico, gerenciador de logs etc.), embora, neste caso, o código possa ser implementado com base em funções simples e estáticas no estilo C dados no nível do arquivo. E mesmo nessa situação, ainda é importante que os objetos sejam projetados com ênfase na transformação de dados.

Outra situação em que eu ainda uso OOP são os sistemas GUI. Talvez seja porque aqui estamos trabalhando com um sistema que já foi projetado de maneira orientada a objetos, ou talvez porque desempenho e complexidade não sejam fatores críticos para o código da GUI. Seja como for, prefiro APIs da GUI que fazem pouco uso da herança e maximizam o aninhamento (bons exemplos aqui são Cocoa e CocoaTouch). É provável que, para jogos, você possa escrever sistemas GUI de boa aparência com uma orientação de dados, mas até agora não o vi.

No final, nada impede que você crie uma imagem mental baseada em objetos, se você preferir pensar no jogo dessa maneira. Só que a essência do inimigo não ocupará um lugar físico na memória, mas será dividida em subcomponentes menores, cada um dos quais faz parte de uma grande tabela de dados de componentes semelhantes.

O design orientado a dados está um pouco longe dos métodos de programação tradicionais, mas se você sempre pensar nos dados e nas formas necessárias para transformá-los, ele oferecerá grandes vantagens em termos de produtividade e facilidade de desenvolvimento.

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


All Articles