Devo evitar usar a herança de objetos possível para desenvolver um jogo?

36

Prefiro recursos de OOP ao desenvolver jogos com o Unity. Normalmente, crio uma classe base (principalmente abstraída) e uso a herança de objetos para compartilhar a mesma funcionalidade com os vários outros objetos.

No entanto, ouvi recentemente de alguém que o uso de herança deve ser evitado e, em vez disso, devemos usar interfaces. Então, perguntei o porquê e ele disse que "a herança de objetos é importante, é claro, mas se houver muitos objetos estendidos, o relacionamento entre as classes será profundamente associado.

Eu estou usando uma classe base abstrata, algo como WeaponBasee criando classes de armas específicas como Shotgun, AssaultRifle, Machinegunalgo assim. Há tantas vantagens, e um padrão que eu realmente gosto é o polimorfismo. Posso tratar o objeto criado pelas subclasses como se elas fossem a classe base, para que a lógica possa ser drasticamente reduzida e tornada mais reutilizável. Se eu precisar acessar os campos ou métodos da subclasse, lancei de volta para a subclasse.

Eu não quero definir mesmos campos, comumente utilizados em diferentes classes, como AudioSource/ Rigidbody/ Animatore um monte de campos de membros I definidos como fireRate, damage, range, spreade assim por diante. Também em alguns casos, alguns dos métodos podem ser substituídos. Então, eu estou usando métodos virtuais nesse caso, para que basicamente eu possa invocar esse método usando o método da classe pai, mas se a lógica deve ser diferente no filho, eu os substitui manualmente.

Então, tudo isso deve ser abandonado como más práticas? Em vez disso, devo usar interfaces?

modernador
fonte
30
"Há tantas vantagens, e um padrão que eu realmente gosto é o polimorfismo". Polimorfismo não é um padrão. É literalmente o ponto principal da OOP. Outras coisas, como campos comuns, etc, podem ser facilmente alcançadas sem duplicação de código ou herança.
Cubic
3
A pessoa que sugeriu suas interfaces era melhor que a herança deu-lhe algum argumento? Estou curioso.
Hawker65
1
@ Cubic Eu nunca disse que o polimorfismo é um padrão. Eu disse "... um padrão que eu realmente gosto é o polimorfismo". Isso significa que o padrão que eu realmente gosto é usar "polimorfismo", não polimorfismo é um padrão.
Modernator
4
As pessoas defendem o uso de interfaces sobre herança em qualquer forma de desenvolvimento. No entanto, ele tende a acrescentar muita sobrecarga, dissonância cognitiva, leva à explosão de classes, frequentemente agrega valor questionável e geralmente não é sinérgico em um sistema, a menos que TODOS no projeto sigam o foco nas interfaces. Portanto, não abandone a herança ainda. No entanto, os jogos têm algumas características únicas e a arquitetura do Unity espera que você use um paradigma de design do CES, e é por isso que você deve ajustar sua abordagem. Leia a série Wizards and Warriors de Eric Lippert, descrevendo problemas do tipo jogo.
Dunk
2
Eu diria que uma interface é um caso particular de herança em que a classe base não contém campos de dados. Eu acho que a sugestão é tentar não construir uma árvore enorme onde cada objeto deve ser classificado, mas muitas árvores pequenas baseadas em interfaces nas quais você pode tirar proveito da herança múltipla. Polimorfismo não se perde com interfaces.
Emanuele Paolini

Respostas:

65

Favorecer a composição sobre a herança em seus sistemas de entidade e estoque / item. Esse conselho tende a se aplicar às estruturas lógicas do jogo, quando a maneira pela qual você pode montar coisas complexas (em tempo de execução) pode levar a muitas combinações diferentes; é quando preferimos composição.

Favorecer a herança sobre a composição em sua lógica no nível do aplicativo, desde construções de interface do usuário até serviços. Por exemplo,

Widget->Button->SpinnerButton ou

ConnectionManager->TCPConnectionManagervs ->UDPConnectionManager.

... há uma hierarquia claramente definida de derivação aqui, em vez de uma infinidade de derivações em potencial, portanto, é mais fácil usar a herança.

Conclusão : use herança onde puder, mas use composição onde for necessário. PS A outra razão pela qual podemos favorecer a composição nos sistemas de entidades é que geralmente existem muitas entidades, e a herança pode incorrer em um custo de desempenho para acessar membros em cada objeto; veja vtables .

Engenheiro
fonte
10
A herança não implica que todos os métodos sejam virtuais. Portanto, não gera automaticamente um custo de desempenho. O VTable apenas desempenha um papel no despacho das chamadas virtuais , não no acesso aos membros de dados.
Ruslan
1
@Ruslan Adicionei as palavras 'may' e 'can' para acomodar seu comentário. Caso contrário, no interesse de manter a resposta concisa, não entrei em muitos detalhes. Obrigado.
Engineer
7
P.S. The other reason we may favour composition in entity systems is ... performance: Isso é realmente verdade? Olhando para a página da WIkipedia vinculada por @Linaith mostra, você precisaria compor seu objeto de interfaces. Portanto, você tem (ainda ou mais) chamadas de função virtual e mais falhas de cache, à medida que introduziu outro nível de indireção.
Flamefire
1
@Flamefire Sim, é verdade; existem maneiras de organizar seus dados de modo que a eficiência do cache seja ideal, independentemente e certamente muito mais ideal do que essas indicações indiretas. Essas não são herança e são composição, embora mais no sentido de matriz de estrutura / estrutura de matrizes (dados intercalados / não intercalados em arranjo linear de cache), dividindo-se em matrizes separadas e, às vezes, duplicando dados para corresponder aos conjuntos de operações em diferentes partes do seu pipeline. Mas, mais uma vez, abordar isso aqui seria um desvio muito importante, que é melhor evitar por causa de concisão.
Engenheiro
2
Lembre-se de manter comentários sobre pedidos civis de esclarecimento ou crítica e debater o conteúdo das idéias e não o caráter ou a experiência da pessoa que as declara.
Josh
18

Você já tem algumas respostas legais, mas o enorme elefante na sala da sua pergunta é este:

ouvimos de alguém que o uso de herança deve ser evitado e, em vez disso, devemos usar interfaces

Como regra geral, quando alguém fornece uma regra geral, ignore-a. Isso não vale apenas para "alguém lhe dizendo algo", mas também para ler coisas na internet. A menos que você saiba o porquê (e possa realmente apoiá-lo), esse conselho é inútil e geralmente muito prejudicial.

Na minha experiência, os conceitos mais importantes e úteis no POO são "baixo acoplamento" e "alta coesão" (classes / objetos sabem o mínimo possível um do outro e cada unidade é responsável pelo mínimo de coisas possível).

Baixo acoplamento

Isso significa que qualquer "pacote de coisas" no seu código deve depender do ambiente ao menor tempo possível. Isso vale para classes (design da classe), mas também para objetos (implementação real), "arquivos" em geral (ou seja, número de #includes por .cpparquivo único , número de importpor .javaarquivo único e assim por diante).

Um sinal de que duas entidades estão acopladas é que uma delas quebrará (ou precisará ser alterada) quando a outra for alterada de alguma forma.

A herança aumenta o acoplamento, obviamente; alterar a classe base altera todas as subclasses.

As interfaces reduzem o acoplamento: definindo um contrato claro e baseado em método, você pode alterar qualquer coisa sobre os dois lados da interface livremente, desde que não altere o contrato. (Observe que "interface" é um conceito geral, as interfaceclasses abstratas Java ou C ++ são apenas detalhes de implementação).

Coesão alta

Isso significa que cada classe, objeto, arquivo etc. se preocupe ou seja responsável pelo mínimo possível. Ou seja, evite classes grandes que fazem muitas coisas. No seu exemplo, se suas armas tiverem aspectos completamente separados (munição, comportamento de tiro, representação gráfica, representação de inventário etc.), você poderá ter diferentes classes que representam exatamente uma dessas coisas. A classe principal de armas se transforma em um "detentor" desses detalhes; um objeto de arma é pouco mais do que alguns ponteiros para esses detalhes.

Neste exemplo, você garantiria que sua classe que representa o "comportamento de tiro" saiba o mínimo humanamente possível sobre a classe principal de armas. Idealmente, nada. Isso significaria, por exemplo, que você poderia atribuir "Comportamento de disparo" a qualquer objeto em seu mundo (torres, vulcões, NPCs ...) com apenas um clique. Se em algum momento você quiser alterar a forma como as armas são representadas no inventário, basta fazê-lo - apenas sua classe de inventário sabe disso.

Um sinal de que uma entidade não é coesa é se cresce cada vez mais, ramificando-se em várias direções ao mesmo tempo.

A herança que você descreve diminui a coesão - suas classes de armas são, no final das contas, grandes pedaços que lidam com todos os tipos de aspectos diferentes e não relacionados a suas armas.

As interfaces indiretamente aumentam a coesão, dividindo claramente as responsabilidades entre os dois lados da interface.

O que fazer agora

Ainda não existem regras rígidas, tudo isso são apenas diretrizes. Em geral, como o usuário TKK mencionou em sua resposta, a herança é ensinada muito na escola e nos livros; é a coisa mais chique sobre OOP. As interfaces são provavelmente mais chatas de ensinar, e também (se você passar por exemplos triviais) um pouco mais difícil, abrindo o campo da injeção de dependência, que não é tão clara quanto a herança.

No final do dia, seu esquema baseado em herança ainda é melhor do que não ter um design claro de OOP. Portanto, fique à vontade para ficar com ela. Se desejar, você pode refletir um pouco no Google sobre baixo acoplamento, alta coesão e ver se deseja adicionar esse tipo de pensamento ao seu arsenal. Você sempre pode refatorar para tentar isso, se desejar, mais tarde; ou experimente abordagens baseadas em interface em seu próximo novo módulo de código.

AnoE
fonte
Obrigado pela explicação detalhada. É realmente útil entender o que estou perdendo.
Modernator
13

A ideia de que a herança deve ser evitada está simplesmente errada.

Existe um princípio de codificação chamado Composição sobre herança . Ele diz que você pode conseguir as mesmas coisas com a composição, e é preferível, porque você pode reutilizar parte do código. Consulte Por que devo preferir composição a herança?

Devo dizer que gosto das suas classes de armas e faria da mesma maneira. Mas eu não fiz um jogo até agora ...

Conforme apontado por James Trotter, a composição pode ter algumas vantagens, principalmente na flexibilidade do tempo de execução para alterar o funcionamento da arma. Isso seria possível com a herança, mas é mais difícil.

Linaith
fonte
14
Porém, em vez de ter classes para as armas, você poderia ter uma única classe de "armas" e componentes para lidar com o que dispara, quais anexos ela possui e o que eles fazem, que "armazenamento de munição" possui, você pode criar muitas configurações , alguns dos quais podem ser uma "espingarda" ou um "rifle de assalto", mas que também podem ser alterados em tempo de execução, por exemplo, para trocar acessórios e alterar as capacidades do carregador etc. Ou podem ser criadas novas configurações de armas que não foram encontradas sequer pensei em originalmente. Sim, você pode conseguir algo semelhante com a herança, mas não tão fácil.
Trotski94
3
@JamesTrotter Neste momento, penso em Borderlands e suas armas, e saber o que uma monstruosidade isso seria apenas com herança ...
Baldrickk
1
@ JamesTrotter Gostaria que isso fosse uma resposta e não um comentário.
Pharap
6

O problema é que a herança leva ao acoplamento - seus objetos precisam saber mais um sobre o outro. É por isso que a regra é "Favorecer sempre a composição sobre a herança". Isso não significa que NUNCA use herança, significa usá-la onde for completamente apropriado, mas se você estiver sentado lá pensando "Eu poderia fazer isso dos dois lados e os dois meio que fazem sentido", vá direto à composição.

A herança também pode ser uma espécie de limitação. Você tem um "WeaponBase" que pode ser um AssultRifle - incrível. O que acontece quando você tem uma espingarda de cano duplo e deseja permitir que os canos atinjam de forma independente - um pouco mais difícil, mas é possível, você só tem uma classe de cano único e cano duplo, mas não pode montar apenas dois canos na mesma arma, você pode? Você poderia modificar um para ter 3 barris ou precisa de uma nova classe para isso?

Que tal um AssultRifle com um GrenadeLauncher embaixo - hmm, um pouco mais difícil. Você pode substituir o GrenadeLauncher por uma lanterna para a caça noturna?

Finalmente, como você permite que o usuário faça as armas anteriores, editando um arquivo de configuração? Isso é difícil porque você possui relacionamentos codificados que podem ser melhor compostos e configurados com dados.

A herança múltipla pode corrigir alguns desses exemplos triviais, mas acrescenta seu próprio conjunto de problemas.

Antes que houvesse ditados comuns como "Preferir composição sobre herança", descobri isso escrevendo uma árvore de herança excessivamente complexa (que foi muito divertida e funcionou perfeitamente) e depois descobri que era realmente difícil modificar e manter mais tarde. O ditado é apenas para que você tenha uma maneira alternativa e compacta de aprender e se lembrar de um conceito desse tipo sem ter que passar por toda a experiência - mas se você deseja usar a herança fortemente, recomendo apenas manter a mente aberta e avaliar como ela funciona para você - nada de terrível acontecerá se você usar muito a herança e pode ser uma ótima experiência de aprendizado na pior das hipóteses (na melhor das hipóteses, pode funcionar bem para você)

Bill K
fonte
2
"como você permite que o usuário faça as armas anteriores editando um arquivo de configuração?" -> O padrão que geralmente faço é criar uma classe final para cada arma. Por exemplo, como Glock17. Primeiro crie a classe WeaponBase. Esta classe é abstraída, define valores comuns como modo de disparo, taxa de disparo, tamanho do compartimento, munição máxima, pellets. Também lida com a entrada do usuário, como mouse1 / mouse2 / reload e chama o método corresponder. Eles são quase virtuais ou divididos em mais funções, para que o desenvolvedor possa substituir a funcionalidade básica, se necessário. E, em seguida, criar outra classe que se estendeu WeaponBase,
modernator
2
algo como SlideStoppableWeapon. A maioria das pistolas tem isso, e isso lida com comportamentos adicionais, como o deslizamento parado. Por fim, crie a classe Glock17 que se estende de SlideStoppableWeapon. Glock17 é a classe final, se a arma tiver uma habilidade única que só tem, finalmente escrita aqui. Eu costumo usar esse padrão quando a arma possui um mecanismo especial de disparo ou de recarga. Implemente esse comportamento exclusivo para a hierarquia de fim de classe, combine lógicas comuns o máximo possível dos pais.
modernator
2
@modernator Como eu disse, é apenas uma diretriz - se você não confiar, fique à vontade para ignorá-la, mantenha os olhos abertos para os resultados ao longo do tempo e avalie os benefícios e as dores de vez em quando. Tento me lembrar de lugares em que bati os dedos dos pés e ajudo outras pessoas a evitar o mesmo, mas o que funciona para mim pode não funcionar para você.
Bill K
7
@modernator No Minecraft, eles fizeram Monsteruma subclasse de WalkingEntity. Então eles adicionaram slimes. Você acha que isso funcionou bem?
user253751
1
@immibis Você tem uma referência ou link anedótico para esse problema? Pesquisando palavras-chave Minecraft afoga me em links de vídeo ;-)
Peter - Reintegrar Monica
3

Ao contrário das outras respostas, isso não tem nada a ver com herança versus composição. Herança versus composição é uma decisão que você toma com relação a como uma classe será implementada. Interfaces vs. classes é uma decisão que vem antes disso.

As interfaces são os cidadãos de primeira classe de qualquer idioma OOP. Classes são detalhes de implementação secundários. As mentes dos novos programadores são severamente distorcidas por professores e livros que introduzem classes e herança antes das interfaces.

O princípio principal é que, sempre que possível, os tipos de todos os parâmetros de método e valores de retorno devem ser interfaces, não classes. Isso torna suas APIs muito mais flexíveis e poderosas. Na grande maioria das vezes, seus métodos e seus interlocutores não devem ter motivos para conhecer ou se preocupar com as classes reais dos objetos com os quais estão lidando. Se sua API é independente de detalhes de implementação, você pode alternar livremente entre composição e herança sem interromper nada.

No U
fonte
Se as interfaces são os cidadãos de primeira classe de qualquer linguagem OOP, estou pensando se Python, Ruby ... não são linguagens OOP.
BlackJack
@BlackJack: Em Ruby, a interface é implícita (se você quiser digitar pato) e expressa usando composição em vez de herança (também possui Mixins que estão em algum lugar no meio, no que me diz respeito) ...
AnoE
@BlackJack "Interface" (o conceito) não requer interface(a palavra-chave do idioma).
Caleth
2
@ Caleth Mas chamá-los de cidadãos de primeira classe IMHO. Quando uma descrição de idioma afirma que algo é um cidadão de primeira classe , geralmente significa que é algo real naquele idioma que tem um tipo e valor e pode ser repassado. As classes semelhantes são cidadãos de primeira classe em Python, assim como funções, métodos e módulos. Se estamos falando sobre o conceito, bem, as interfaces também fazem parte de todas as linguagens que não são OOP.
BlackJack 13/07
2

Sempre que alguém lhe diz que uma abordagem específica é a melhor para todos os casos, é o mesmo que dizer que um único medicamento cura todas as doenças.

Herança versus composição é uma questão do tipo is-a vs has-a. As interfaces são mais uma (terceira) via, apropriada para algumas situações.

Herança, ou a lógica é-a: você a usa quando a nova classe se comporta e é usada completamente como (externamente) a classe antiga da qual você a derivou, se a nova classe for exposta ao público toda a funcionalidade que a classe antiga tinha ... então você herda.

Composição, ou a lógica has-a: se a nova classe precisar usar internamente a classe antiga, sem expor os recursos da classe antiga ao público, use a composição - ou seja, tenha uma instância da classe antiga como propriedade de membro ou uma variável da nova. (Pode ser uma propriedade privada ou protegida, ou qualquer outra coisa, dependendo do caso de uso). O ponto principal aqui é que este é o caso de uso em que você não deseja expor os recursos da classe original e usá-lo ao público, apenas use-o internamente, enquanto no caso de herança você os expõe ao público, por meio do nova classe. Às vezes você precisa de uma abordagem, e outras a outra.

Interfaces: as interfaces são para outro caso de uso - quando você deseja que a nova classe implemente parcialmente e não implemente completamente e exponha ao público a funcionalidade da antiga. Isso permite que você tenha uma nova classe, classe de uma hierarquia totalmente diferente da antiga, se comportando como a antiga em apenas alguns aspectos.

Digamos que você tenha criaturas variadas representadas por classes e que elas tenham funcionalidades representadas por funções. Por exemplo, um pássaro teria Talk (), Fly () e cocô (). A classe Duck herdaria da classe Bird, implementando todos os recursos.

A classe Fox, obviamente, não pode voar. Portanto, se você definir o ancestral para ter todos os recursos, não poderá derivar o descendente adequadamente.

Se, no entanto, você dividir os recursos em grupos, representando cada grupo de chamadas com uma interface, digamos, IFly, contendo Takeoff (), FlapWings () e Land (), você poderá, para a classe Fox, implementar funções do ITalk e IPoop mas não IFly. Você definiria variáveis ​​e parâmetros para aceitar objetos que implementam uma interface específica e, em seguida, o código que trabalha com eles saberia o que ele pode chamar ... e sempre poderá consultar outras interfaces, se precisar ver se outras funcionalidades também estão disponíveis. implementado para o objeto atual.

Cada uma dessas abordagens tem casos de uso quando é a melhor, nenhuma abordagem é a melhor solução absoluta para todos os casos.

Dragan Juric
fonte
1

Para um jogo, especialmente com o Unity, que funciona com uma arquitetura de componente de entidade, você deve favorecer a composição sobre a herança dos componentes do jogo. É muito mais flexível e evita entrar em uma situação em que você deseja que uma entidade do jogo "seja" duas coisas que estão nas folhas diferentes de uma árvore de herança.

Por exemplo, digamos que você tenha um TalkingAI em um ramo de uma árvore de herança e VolumetricCloud em outro ramo separado e deseje uma nuvem falante. Isso é difícil com uma árvore de herança profunda. Com o componente de entidade, você apenas cria uma entidade que possui componentes TalkingAI e Cloud, e está pronto.

Mas isso não significa que, digamos, na sua implementação volumétrica de nuvem, você não deva usar herança. O código para isso pode ser substancial e consistir em várias classes, e você pode usar o OOP conforme necessário. Mas tudo isso equivale a um único componente do jogo.

Como uma observação lateral, tomo um problema com isso:

Normalmente, crio uma classe base (principalmente abstraída) e uso a herança de objetos para compartilhar a mesma funcionalidade com os vários outros objetos.

A herança não é para reutilização de código. É para estabelecer um relacionamento é-um . Na maioria das vezes, eles andam de mãos dadas, mas você precisa ter cuidado. Só porque dois módulos podem precisar usar o mesmo código, isso não significa que um é do mesmo tipo que o outro.

Você pode usar interfaces se quiser, digamos, usar um List<IWeapon>com diferentes tipos de armas. Dessa forma, você pode herdar da interface IWeapon e da subclasse MonoBehaviour para todas as armas e evitar problemas com a ausência de herança múltipla.

Sava B.
fonte
-3

Você parece não entender o que é ou não uma interface. Levei mais de 10 anos para pensar em OO e fazer algumas perguntas realmente inteligentes para as pessoas: eu era capaz de ter um momento ah-ha com interfaces. Espero que isto ajude.

O melhor é usar uma combinação deles. Na minha mente OO, modelando o mundo e as pessoas, digamos, um exemplo profundo. A alma está em interface com o corpo através da mente. A mente é a interface entre a alma (alguns consideram o intelecto) e os comandos que emite para o corpo. A interface da mente com o corpo é a mesma para todas as pessoas, funcionalmente falando.

A mesma analogia pode ser usada entre a pessoa (corpo e alma) que faz interface com o mundo. O corpo é a interface entre a mente e o mundo. Todos fazem interface com o mundo da mesma maneira, a única singularidade é COMO interagimos com o mundo, é assim que usamos nossa MENTE para DECIDIR COMO interagimos / interagimos no mundo.

Uma interface é simplesmente um mecanismo para relacionar sua estrutura OO com outra estrutura OO diferente, com cada lado tendo atributos diferentes que devem ser negociados / mapeados entre si para produzir alguma saída funcional em um meio / ambiente diferente.

Portanto, para que a mente chame a função de mão aberta, ela deve implementar a interface nervoso_command_system, com ISSO com sua própria API, com instruções sobre como implementar todos os requisitos necessários antes de chamar a mão aberta.

Christopher Hoffman
fonte