Dovrei evitare di usare l'ereditarietà degli oggetti il ​​più possibile per sviluppare un gioco?


36

Preferisco le funzionalità OOP durante lo sviluppo di giochi con Unity. Di solito creo una classe base (per lo più astratta) e utilizzo l'ereditarietà degli oggetti per condividere la stessa funzionalità con i vari altri oggetti.

Tuttavia, recentemente ho sentito da qualcuno che l'uso dell'ereditarietà deve essere evitato e dovremmo usare invece le interfacce. Quindi ho chiesto perché, e ha detto che "L'ereditarietà degli oggetti è importante ovviamente, ma se ci sono molti oggetti estesi, le relazioni tra le classi sono profondamente accoppiate.

Sto utilizzando una classe base astratta, una cosa del genere WeaponBasee la creazione di classi di armi specifiche come Shotgun, AssaultRifle, Machinegunqualcosa di simile. Ci sono molti vantaggi e uno schema che mi piace molto è il polimorfismo. Posso trattare l'oggetto creato da sottoclassi come se fossero la classe base, in modo che la logica possa essere drasticamente ridotta e resa più riutilizzabile. Se devo accedere ai campi o ai metodi della sottoclasse, ricolloco la sottoclasse.

Io non voglio definire stessi campi, comunemente utilizzati in diverse classi, come AudioSource/ Rigidbody/ Animatore un sacco di campi membro ho definito come fireRate, damage, range, spreade così via. Anche in alcuni casi, alcuni dei metodi potrebbero essere sovrascritti. Quindi sto usando metodi virtuali in quel caso, in modo che sostanzialmente posso invocare quel metodo usando il metodo dalla classe genitore, ma se la logica dovrebbe essere diversa in figlio, li ho sovrascritti manualmente.

Quindi, tutte queste cose dovrebbero essere abbandonate come cattive pratiche? Dovrei usare invece le interfacce?


30
"Ci sono così tanti vantaggi e uno schema che mi piace molto è il polimorfismo." Il polimorfismo non è un modello. È letteralmente l'intero punto di OOP. Altre cose come campi comuni, ecc. Possono essere facilmente raggiunti senza duplicazione o ereditarietà del codice.
Cubico

3
La persona che ti ha suggerito che le interfacce erano migliori dell'eredità ti ha dato qualche argomento? Sono curioso.
Hawker65,

1
@Cubic Non ho mai detto che il polimorfismo è uno schema. Ho detto "... uno schema che mi piace molto è il polimorfismo". Significa che lo schema che mi piace davvero è usare il "polimorfismo", non il polimorfismo è uno schema.
modernatore

4
Le persone usano le interfacce oltre l'ereditarietà in qualsiasi forma di sviluppo. Tuttavia, tende ad aggiungere un sacco di sovraccarico, dissonanza cognitiva, porta all'esplosione di classe, spesso aggiunge valore discutibile e di solito non si sinergizza bene in un sistema a meno che TUTTI nel progetto non seguano quel focus sulle interfacce. Quindi non abbandonare l'eredità ancora. Tuttavia, i giochi hanno alcune caratteristiche uniche e l'architettura di Unity si aspetta che tu usi un paradigma di progettazione CES, motivo per cui dovresti modificare il tuo approccio. Leggi la serie Wizards and Warriors di Eric Lippert che descrive i problemi di tipo di gioco.
Dunk,

2
Direi che un'interfaccia è un caso particolare di ereditarietà in cui la classe base non contiene campi di dati. Penso che il suggerimento sia di provare a non costruire un albero enorme in cui ogni oggetto debba essere classificato, ma piuttosto molti piccoli alberi basati su interfacce in cui è possibile sfruttare l'ereditarietà multipla. Il polimorfismo non si perde con le interfacce.
Emanuele Paolini,

Risposte:


65

Favorisci la composizione rispetto all'eredità nella tua entità e nei sistemi di inventario / articolo. Questo consiglio tende ad applicarsi alle strutture logiche di gioco, quando il modo in cui è possibile assemblare cose complesse (in fase di esecuzione) può portare a molte combinazioni diverse; è allora che preferiamo la composizione.

Favorisci l'ereditarietà rispetto alla composizione nella logica a livello di applicazione, tutto, dai costrutti dell'interfaccia utente ai servizi. Per esempio,

Widget->Button->SpinnerButton o

ConnectionManager->TCPConnectionManagervs ->UDPConnectionManager.

... esiste una gerarchia di derivazione chiaramente definita qui, piuttosto che una moltitudine di potenziali derivazioni, quindi è più semplice usare l'ereditarietà.

In conclusione : usa l'eredità dove puoi, ma usa la composizione dove devi. PS L'altra ragione per cui possiamo favorire la composizione nei sistemi di entità è che di solito ci sono molte entità e l'ereditarietà può comportare un costo in termini di prestazioni per accedere ai membri su ogni oggetto; vedi vtables .


10
L'ereditarietà non implica che tutti i metodi siano virtuali. Quindi non comporta automaticamente un costo in termini di prestazioni. VTable svolge solo un ruolo nel dispacciamento delle chiamate virtuali , non nell'accesso ai membri dei dati.
Ruslan,

1
@Ruslan Ho aggiunto le parole 'may' e 'can' per accogliere il tuo commento. Altrimenti, nell'interesse di mantenere la risposta concisa, non ho approfondito troppo i dettagli. Grazie.
Ingegnere

7
P.S. The other reason we may favour composition in entity systems is ... performance: È davvero vero? Guardando la pagina di Wikipedia collegata dagli spettacoli di @Linaith, è necessario comporre l'oggetto delle interfacce. Quindi hai (ancora o anche più) chiamate di funzione virtuale e più mancate cache, mentre hai introdotto un altro livello di indiretta.
Fiamma

1
@Flamefire Sì, è vero; ci sono modi per organizzare i tuoi dati in modo che l'efficienza della cache sia ottimale, indipendentemente e sicuramente molto più ottimale di quelle ulteriori indicazioni. Questi non sono ereditarietà e lo sono composizione, sebbene più nel senso dell'array of struct / struct-of-array (dati interleaved / non interleaved nella disposizione cache-linear), suddividendoli in array separati e talvolta duplicando i dati per abbinare i set delle operazioni in diverse parti della pipeline. Ma ancora una volta, approfondire qui sarebbe un grande diversivo che è meglio evitare per amor di concisione.
Ingegnere,

2
Ricorda di tenere commenti sulle richieste civili di chiarimenti o critiche e di discutere il contenuto delle idee e non il carattere o l'esperienza della persona che le dichiara.
Josh

18

Hai già ricevuto alcune belle risposte, ma l'enorme elefante nella stanza della tua domanda è questo:

sentito da qualcuno che l'uso dell'ereditarietà deve essere evitato e dovremmo usare invece le interfacce

Come regola empirica, quando qualcuno ti dà una regola empirica, quindi ignoralo. Questo non vale solo per "qualcuno che ti dice qualcosa", ma anche per leggere cose su Internet. A meno che tu non lo sappia perché (e può davvero sostenerlo), tale consiglio è inutile e spesso molto dannoso.

Nella mia esperienza, i concetti più importanti e utili in OOP sono "accoppiamento basso" e "alta coesione" (le classi / gli oggetti conoscono il meno possibile l'uno dell'altro e ogni unità è responsabile del minor numero di cose possibile).

Accoppiamento basso

Ciò significa che qualsiasi "pacchetto di roba" nel codice dovrebbe dipendere il meno possibile da ciò che lo circonda. Questo vale per le classi (design della classe) ma anche per gli oggetti (implementazione effettiva), i "file" in generale (cioè il numero di#include elettronica per singolo .cppfile, il numero di importper .javafile singolo e così via).

Un segno che due entità sono accoppiate è che una di esse si romperà (o dovrà essere cambiata) quando l'altra viene cambiata in qualche modo.

L'ereditarietà aumenta l'accoppiamento, ovviamente; la modifica della classe di base modifica tutte le sottoclassi.

Le interfacce riducono l'accoppiamento: definendo un contratto chiaro e basato sul metodo, è possibile modificare liberamente qualsiasi cosa su entrambi i lati dell'interfaccia, purché non si modifichi il contratto. (Si noti che "interfaccia" è un concetto generale, Javainterface classi astratte o C ++ sono solo dettagli di implementazione).

Alta coesione

Ciò significa che ogni classe, oggetto, file ecc. Deve essere interessato o responsabile il meno possibile. Vale a dire, evitare grandi classi che fanno un sacco di cose. Nel tuo esempio, se le tue armi hanno aspetti completamente separati (munizioni, comportamento al fuoco, rappresentazione grafica, rappresentazione dell'inventario ecc.), Puoi avere classi diverse che rappresentano esattamente una di quelle cose. La principale classe di armi si trasforma quindi in un "detentore" di quei dettagli; un oggetto arma è quindi poco più di qualche puntatore a quei dettagli.

In questo esempio, ti assicureresti che la tua classe che rappresenta il "Comportamento al fuoco" sappia quanto meno umanamente possibile sulla classe principale di armi. In modo ottimale, niente di niente. Ciò significherebbe, ad esempio, che potresti dare "Comportamento al fuoco" a chiunque oggetto nel tuo mondo (torrette, vulcani, PNG ...) con un semplice schiocco di un dito. Se ad un certo punto vuoi cambiare il modo in cui le armi sono rappresentate nell'inventario, allora puoi semplicemente farlo - solo la tua classe di inventario lo sa.

Un segno che un'entità non è coesiva è se diventa sempre più grande, ramificandosi in più direzioni contemporaneamente.

L'ereditarietà mentre la descrivi diminuisce la coesione: le tue classi di armi sono, alla fine della giornata, grossi pezzi che gestiscono tutti i tipi di aspetti diversi e non correlati delle tue armi.

Le interfacce aumentano indirettamente la coesione dividendo chiaramente le responsabilità tra le due parti dell'interfaccia.

cosa fare adesso

Non ci sono ancora regole rigide e veloci, tutto questo è solo una guida. In generale, come menzionato dall'utente TKK nella sua risposta, l'eredità viene insegnata molto a scuola e nei libri; sono le cose fantasiose su OOP. Le interfacce sono entrambe probabilmente più noiose da insegnare, e anche (se si superano esempi banali) un po 'più difficili, aprendo il campo dell'iniezione di dipendenza, che non è così netta come l'eredità.

Alla fine della giornata, il tuo schema basato sull'ereditarietà è ancora meglio che non avere alcun progetto OOP chiaro. Quindi sentiti libero di attenerci. Se lo desideri, puoi ruminare / google un po 'su Low Coupling, High Cohesion e vedere se desideri aggiungere quel tipo di pensiero al tuo arsenale. Puoi sempre rifattorizzare per provarlo, se lo desideri, in seguito; oppure prova approcci basati sull'interfaccia sul tuo prossimo nuovo più grande modulo di codice.


Grazie per una spiegazione dettagliata. È davvero utile capire cosa mi sto perdendo.
modernatore

13

L'idea che l'ereditarietà debba essere evitata è semplicemente sbagliata.

Esiste un principio di codifica chiamato Composizione sull'ereditarietà . Dice che puoi ottenere le stesse cose con la composizione, ed è preferibile, perché puoi riutilizzare parte del codice. Vedi Perché dovrei preferire la composizione rispetto all'eredità?

Devo dire che mi piacciono le tue classi di armi e farebbe allo stesso modo. Ma non ho ancora creato un gioco ...

Come sottolineato da James Trotter, la composizione potrebbe presentare alcuni vantaggi, soprattutto nella flessibilità in fase di esecuzione per modificare il funzionamento dell'arma. Ciò sarebbe possibile con l'eredità, ma è più difficile.


14
Invece di avere classi per le armi, potresti avere una sola classe "arma" e componenti per gestire ciò che fa il fuoco, quali accessori ha e cosa fanno, quale "deposito di munizioni" ha, potresti costruire molte configurazioni , alcuni dei quali possono essere un "fucile da caccia" o un "fucile d'assalto", ma che possono anche essere cambiati in fase di esecuzione, ad esempio per cambiare gli accessori e cambiare le capacità del caricatore, ecc. O possono essere create nuove configurazioni di pistole che non erano ho nemmeno pensato originariamente. Sì, puoi ottenere qualcosa di simile con l'eredità, ma non così facile.
Trotski94,

3
@JamesTrotter A questo punto, penso a Borderlands e alle sue pistole, e mi chiedo quale mostruosità sarebbe solo con l'eredità ...
Baldrickk,

1
@JamesTrotter Vorrei che fosse una risposta e non un commento.
Pharap,

6

Il problema è che l'eredità porta all'accoppiamento: i tuoi oggetti devono sapere di più l'uno dell'altro. Ecco perché la regola è "Favorire sempre la composizione rispetto all'eredità". Questo non significa MAI usare l'ereditarietà, significa usarlo dove è del tutto appropriato, ma se sei mai seduto lì a pensare "Potrei farlo in entrambi i modi ed entrambi hanno un senso", vai direttamente alla composizione.

L'ereditarietà può anche essere una specie di limitazione. Hai un "WeaponBase" che può essere un AssultRifle - fantastico. Cosa succede quando hai un fucile a doppia canna e vuoi permettere ai barili di sparare in modo indipendente - un po 'più difficile ma fattibile, hai solo una classe a canna singola e doppia canna, ma non puoi semplicemente montare 2 barili singoli sulla stessa pistola, vero? Potresti modificarne uno per avere 3 barili o hai bisogno di una nuova classe per quello?

Che ne dici di un AssultRifle con un GrenadeLauncher sotto - hmm, un po 'più duro. Riesci a sostituire GrenadeLauncher con una torcia per la caccia notturna?

Infine, come si consente all'utente di creare le pistole precedenti modificando un file di configurazione? Questo è difficile perché hai relazioni codificate che potrebbero essere meglio composte e configurate con i dati.

L'ereditarietà multipla può correggere alcuni di questi esempi banali, ma aggiunge il proprio set di problemi.

Prima che esistessero detti comuni come "Preferire la composizione rispetto all'eredità", l'ho scoperto scrivendo un albero ereditario troppo complesso (che è stato incredibilmente divertente e ha funzionato perfettamente) e poi ho scoperto che era davvero difficile modificarlo e mantenerlo in seguito. Il modo di dire è solo che hai un modo alternativo e compatto per imparare e ricordare un simile concetto senza dover passare attraverso l'intera esperienza - ma se desideri usare l'eredità pesantemente ti consiglio di mantenere una mente aperta e valutare quanto funziona bene per te - non accadrà nulla di terribile se usi pesantemente l'eredità e potrebbe essere una grande esperienza di apprendimento nella peggiore delle ipotesi (nella migliore delle ipotesi potrebbe funzionare bene per te)


2
"come si consente al proprio utente di fabbricare le pistole precedenti modificando un file di configurazione?" -> Lo schema che generalmente faccio è creare una classe finale per ogni arma. Ad esempio, come Glock17. Per prima cosa crea la classe WeaponBase. Questa classe viene sottratta, definisce valori comuni come la modalità di fuoco, la velocità di fuoco, le dimensioni del caricatore, le munizioni massime, i pellet. Inoltre gestisce l'input dell'utente, come mouse1 / mouse2 / ricaricare e invoca il metodo corrispondente. Sono quasi virtuali o suddivisi in più funzioni in modo che lo sviluppatore possa sostituire la funzionalità di base se necessario. E quindi creare un'altra classe che si estendeva WeaponBase,
modernator

2
qualcosa come SlideStoppableWeapon. La maggior parte delle pistole ha questo, e questo gestisce comportamenti aggiuntivi come lo slide fermato. Infine, crea la classe Glock17 che si estende da SlideStoppableWeapon. Glock17 è la classe finale, se la pistola ha un'abilità unica che ha solo, finalmente scritta qui. Di solito uso questo schema quando la pistola ha un meccanismo di fuoco speciale o un meccanismo di ricarica. Implementa quel comportamento unico alla fine della gerarchia di classe, combina il più possibile logiche comuni dai genitori.
modernatore

2
@modernator Come ho già detto, è solo una linea guida - se non ti fidi, sentiti libero di ignorarlo, tieni gli occhi aperti sui risultati nel tempo e valuta i benefici contro i dolori ogni tanto. Cerco di ricordare i luoghi in cui mi sono messo le dita dei piedi e di aiutare gli altri a evitare lo stesso, ma ciò che funziona per me potrebbe non funzionare per te.
Bill K,

7
@modernator In Minecraft hanno creato Monsteruna sottoclasse di WalkingEntity. Quindi hanno aggiunto delle melme. Quanto pensi che abbia funzionato?
user253751

1
@immibis Hai un link di riferimento o aneddotico a quel problema? Le parole chiave di Google Minecraft mi affogano nei collegamenti video ;-)
Peter - Reinstalla Monica il

3

Contrariamente alle altre risposte, questo non ha nulla a che fare con l'eredità rispetto alla composizione. Ereditarietà vs. composizione è una decisione che prendi in merito a come verrà implementata una classe. Interfacce vs. classi è una decisione che viene prima.

Le interfacce sono i cittadini di prima classe di qualsiasi lingua OOP. Le classi sono dettagli di implementazione secondari. Le menti dei nuovi programmatori sono gravemente deformate da insegnanti e libri che introducono classi ed eredità prima delle interfacce.

Il principio chiave è che, ove possibile, i tipi di tutti i parametri del metodo e i valori di ritorno dovrebbero essere interfacce, non classi. Questo rende le tue API molto più flessibili e potenti. La maggior parte delle volte, i tuoi metodi e i loro chiamanti non dovrebbero avere motivo di conoscere o preoccuparsi delle classi reali degli oggetti con cui hanno a che fare. Se la tua API è agnostica riguardo ai dettagli dell'implementazione, puoi passare liberamente tra composizione ed eredità senza interrompere nulla.


Se le interfacce sono i cittadini di prima classe di qualsiasi linguaggio OOP, mi chiedo se Python, Ruby, ... non siano linguaggi OOP.
BlackJack,

@BlackJack: In Ruby, l'interfaccia è implicita (digitando anatra se vuoi) ed espressa usando la composizione anziché l'eredità (inoltre, ha Mixin che sono da qualche parte nel mezzo, per quanto mi riguarda) ...
AnoE

@BlackJack "Interface" (il concetto) non richiede interface(la parola chiave della lingua).
Caleth,

2
@Caleth Ma chiamarli cittadini di prima classe fa IMHO. Quando una descrizione della lingua afferma che qualcosa è cittadino di prima classe , di solito significa che è una cosa reale in quella lingua che ha un tipo e un valore e può essere passato in giro. Come le classi sono cittadini di prima classe in Python, così come le funzioni, i metodi e i moduli. Se stiamo parlando del concetto, allora anche le interfacce fanno parte di tutti i linguaggi non OOP.
BlackJack,

2

Ogni volta che qualcuno ti dice che un approccio specifico è il migliore per tutti i casi, è lo stesso che dirti che la stessa medicina cura tutte le malattie.

Eredità vs composizione è una domanda is-a vs has-a. Le interfacce sono ancora un altro (terzo) modo, appropriato per alcune situazioni.

Eredità o logica is-a: la usi quando la nuova classe si comporterà e sarà usata completamente come (esternamente) la vecchia classe da cui la stai derivando, se la nuova classe esporrà al pubblico tutte le funzionalità che aveva la vecchia classe ... quindi erediti.

Composizione o logica has-a: se la nuova classe ha solo bisogno di usare internamente la vecchia classe, senza esporre al pubblico le funzionalità della vecchia classe, allora usi la composizione - cioè, hai un'istanza della vecchia classe come proprietà membro o una variabile di quella nuova. (Questa può essere una proprietà privata, o protetta, o qualsiasi altra cosa, a seconda del caso d'uso). Il punto chiave qui è che questo è il caso d'uso in cui non si desidera esporre al pubblico le funzionalità della classe originale e utilizzarle, basta usarle internamente, mentre nel caso dell'eredità le si espone al pubblico, attraverso il nuova classe. A volte hai bisogno di un approccio, a volte dell'altro.

Interfacce: le interfacce sono per l'ennesimo caso d'uso: quando si desidera che la nuova classe venga implementata parzialmente e non completamente ed esporre al pubblico le funzionalità di quella precedente. Ciò ti consente di avere una nuova classe, classe da una gerarchia totalmente diversa da quella precedente, comportarti come la vecchia in alcuni aspetti.

Supponiamo che tu abbia creature assortite rappresentate da classi e che abbiano funzionalità rappresentate da funzioni. Ad esempio, un uccello avrebbe Talk (), Fly () e Poop (). La classe Duck erediterebbe dalla classe Bird, implementando tutte le funzionalità.

Classe Fox, ovviamente, non può volare. Pertanto, se si definisce l'antenato in modo da disporre di tutte le funzionalità, non è possibile derivare correttamente il discendente.

Se, tuttavia, si suddividono le funzionalità in gruppi, che rappresentano ciascun gruppo di chiamate con un'interfaccia, ad esempio IFly, contenente Takeoff (), FlapWings () e Land (), allora per la classe Fox è possibile implementare funzioni da ITalk e IPoop ma non IFly. Definiresti quindi variabili e parametri per accettare oggetti che implementano un'interfaccia specifica, e quindi il codice che lavora con loro saprebbe cosa può chiamare ... e può sempre cercare altre interfacce, se ha bisogno di vedere se anche altre funzionalità sono implementato per l'oggetto corrente.

Ognuno di questi approcci ha casi d'uso quando è il migliore, nessuno approccio è la soluzione migliore in assoluto per tutti i casi.


1

Per un gioco, in particolare con Unity, che funziona con un'architettura componente-entità, dovresti privilegiare la composizione rispetto all'eredità per i componenti del gioco. È molto più flessibile ed evita di entrare in una situazione in cui desideri che un'entità di gioco "sia" due cose che si trovano su foglie diverse di un albero ereditario.

Ad esempio, supponiamo che tu abbia TalkingAI su un ramo di un albero ereditario e VolumetricCloud su un altro ramo separato e desideri una nuvola parlante. Questo è difficile con un albero ereditario profondo. Con entità-componente, crei semplicemente un'entità che ha componenti TalkingAI e Cloud e sei pronto per partire.

Ma ciò non significa che, per esempio, nell'implementazione volumetrica del cloud, non dovresti usare l'ereditarietà. Il codice potrebbe essere sostanziale e costituito da diverse classi e puoi utilizzare OOP secondo necessità. Ma tutto equivale a un singolo componente di gioco.

Come nota a margine, prendo qualche problema con questo:

Di solito creo una classe base (per lo più astratta) e utilizzo l'ereditarietà degli oggetti per condividere la stessa funzionalità con i vari altri oggetti.

L'ereditarietà non è per il riutilizzo del codice. Serve per stabilire una relazione is-a . Molte volte queste vanno di pari passo, ma devi stare attento. Solo perché due moduli potrebbero dover utilizzare lo stesso codice, ciò non significa che uno sia dello stesso tipo dell'altro.

Puoi usare le interfacce se vuoi, per esempio, avere una List<IWeapon>con diversi tipi di armi. In questo modo, puoi ereditare sia dall'interfaccia IWeapon, sia dalla sottoclasse MonoBehaviour per tutte le armi ed evitare problemi con l'assenza di ereditarietà multipla.


-3

Sembra che tu non capisca cosa sia o cosa faccia un'interfaccia. Mi ci sono voluti più di 10 anni per pensare a OO e porre domande ad alcune persone davvero intelligenti è stato in grado di avere un momento ah-ha con le interfacce. Spero che sia di aiuto.

Il meglio è usarne una combinazione. Nella mia mente OO modellando il mondo e la gente, diciamo, un esempio profondo. L'anima è interfacciata con il corpo attraverso la mente. La mente È l'interfaccia tra l'anima (alcuni considerano l'intelletto) e i comandi che impartisce al corpo. L'interfaccia delle menti con il corpo è la stessa per tutte le persone, dal punto di vista funzionale.

La stessa analogia può essere usata tra la persona (corpo e anima) che si interfaccia con il mondo. Il corpo è l'interfaccia tra la mente e il mondo. Ognuno si interfaccia al mondo allo stesso modo, l'unica unicità è COME interagiamo con il mondo, ecco come usiamo la MENTE per DECIDERE COME interfaccia / interagire nel mondo.

Un'interfaccia è quindi semplicemente un meccanismo per mettere in relazione la propria struttura OO con un'altra diversa struttura OO con ciascuna parte con attributi diversi che devono negoziare / mappare l'un l'altro per produrre un output funzionale in un diverso mezzo / ambiente.

Quindi, affinché la mente chiami la funzione a mano aperta, deve implementare l'interfaccia nervous_command_system con QUELLO che ha la propria API con istruzioni su come implementare tutti i requisiti necessari prima di chiamare a mano aperta.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.