web-dev-qa-db-fra.com

Quand est-il approprié d'introduire une nouvelle couche d'abstraction dans une hiérarchie de classe?

Supposons que je crée un jeu joué sur une grille de coordonnée 2D. Le jeu a 3 types d'ennemis qui se déplacent tous de différentes manières:

  • Drunkard: se déplace en utilisant le mouvement de type 1.
  • Mummy: se déplace à l'aide du mouvement de type 1, sauf lorsqu'il est proche du personnage principal, auquel cas il utilisera le mouvement de type 2.
  • Ninja: se déplace en utilisant le mouvement de type 3.

Voici les idées que j'ai proposées pour organiser la hiérarchie de la classe:

Proposition 1

Une seule classe de base où chaque ennemi est dérivé de:

abstract class Enemy:
    show()   // Called each game tick
    update() // Called each game tick
    abstract move() // Called in update

class Drunkard extends Enemy:
    move() // Type 1 movement

class Mummy extends Enemy:
    move() // Type 1 + type 2 movement

class Ninja extends Enemy:
    move() // Type 3 movement

Problèmes :

  • VIOLATE DRY Etant donné que le code n'est pas partagé entre Drunkard et Mummy.

Proposition 2

Identique à la proposition 1 mais l'ennemi fait plus:

abstract class Enemy:
    show()            // Called each game tick
    update()          // Called each game tick
    move()           // Tries alternateMove, if unsuccessful, perform type 1 movement
    abstract alternateMove() // Returns a boolean

class Drunkard extends Enemy:
    alternateMove(): return False

class Mummy extends Enemy:
    alternateMove() // Type 2 movement if in range, otherwise return false

class Ninja extends Enemy:
    alternateMove() // Type 3 movement and return true

Problèmes :

  • Ninja n'a vraiment que un déménagement, de sorte qu'il n'a pas vraiment de "geste alternatif". Ainsi, Enemy est une représentation sous-titre de tous les ennemis.

Proposition 3

Extension de proposition 2 avec un MovementPlanEnemy.

abstract class Enemy:
    show()   // Called each game tick
    update() // Called each game tick
    abstract move() // Called in update

class MovementPlanEnemy:
    move() // Type 1 movement
    abstract alternateMove()

class Drunkard extends MovementPlanEnemy:
    alternateMove() // Return false

class Mummy extends MovementPlanEnemy:
    alternateMove() // Tries type 2 movement

class Ninja extends Enemy:
    move() // Type 3 movement

Problèmes :

  • Moche et éventuellement excentré.

Question

La proposition 1 est simple mais a un niveau d'abstraction inférieur. La proposition 3 est complexe mais a un niveau d'abstraction plus élevé.

Je comprends tout ce qui concerne la "composition sur l'héritage" et comment cela peut résoudre ce désordre entier. Cependant, je dois mettre en œuvre cela pour un projet scolaire qui nous oblige à utiliser l'héritage. Donc, compte tenu de cette restriction, quelle serait la meilleure façon d'organiser cette hiérarchie de classe? Est-ce juste un exemple de pourquoi l'héritage est intrinsèquement mauvais?

Je suppose que ma restriction est que je dois utiliser l'héritage, je demande vraiment à la question plus large: en général, quand est-il approprié d'introduire une nouvelle couche d'abstraction au coût de la complication de l'architecture de programme?

36
Frank

J'ai construit un 2D Roguelike de très bien rayer et, après de nombreuses expérimentations, j'ai utilisé une approche totalement différente. Essentiellement une architecture de composant entité.

Chaque objet de jeu est un Entity, et un Entity a de nombreux attributs qui contrôlent comment il répond aux stimuli du lecteur et de l'environnement. Un de ces composants de mon jeu est un composant Movable (d'autres exemples sont Burnable, Harmable, etc., mon github a la liste complète) :

class Entity
    movable
    harmable
    burnable
    freezable
    ...

Différents types d'ennemis sont distingués en injectant des composants de base différents au moment de la création d'objets. Alors quelque chose comme:

drunkard = Entity(
    movable=SometimesRandomMovable(),
    harmable=BasicHarmable(),
    burnable=MonsterBurnable(),
    freezable=LoseATurnFreezable()
    ...
)

et

ninja = Entity(
    movable=QuickMovable(),
    harmable=WeakHarmable(),
    burnable=MonsterBurnable(),
    freezable=NotFreezable()
    ...
)

Chaque composant stocke une référence à son propriétaire Entity pour des informations telles que la position.

Les composants savent comment recevoir des messages du monde du jeu, les traiter, puis générer plus de messages pour les résultats. Ces messages atterrissent dans une file d'attente globale et il y a une boucle principale chaque tour qui apparaît les messages de la file d'attente, ce qui les traite, puis poussant et entraînant des messages sur la file d'attente. Ainsi, par exemple, un composant Movable ne modifie pas réellement les attributs de position de la possession entity, il génère un message au moteur de jeu qu'ils devraient être modifiés, ainsi que la position dans laquelle Le propriétaire doit être déplacé vers.

Il n'y a essentiellement aucune hiérarchie de classe pour les entités de jeux de base, et je ne me suis pas retrouvé à me manquer. Le comportement se distingue entièrement par les composants d'une entité. Cela fonctionne pour toutes les entités du monde du jeu, du joueur, de l'ennemi ou de l'objet.

69
Matthew Drury

C'est pourquoi nous aimons souvent les interfaces sur l'héritage: de nombreux problèmes du monde réel ne peuvent pas être modélisés dans une hiérarchie de l'objet.

interface IMove
{
    // returns an intermediate location chosen with 
    // the intention to move toward destination
    Point Move(Point currentLocation, Point destination)
}

Maintenant, nous pouvons injecter une imove, ou nous pouvons écrire un "déplacer cet objet à l'aide de la stratégie Ninja" type de fonction.

Nous pouvons également tester les stratégies de mouvement séparément

47
Martin K

Je suivrais votre première option, mais utilisez ensuite le modèle de stratégie pour les différents styles de déplacement. Cela vous permettra d'échanger des styles de déplacement et d'altérer les styles de déplacement plus facilement avancer.

Donc, vous auriez une interface appelée movestyle, puis plusieurs classes la mettant en œuvre pour chaque type de mouvement.

9
Adam B

Si vous faites Mummy étendre Drunkard (vous pouvez soutenir que c'est un ivrogne légèrement plus intelligent) au lieu de Enemy, votre conditionnel peut appeler la base (c.-à-d. Drunkard) move() ou utiliser le mouvement de type 2. Vous pouvez voir cela comme une variante sur la proposition 3, dans laquelle il n'y a pas alternateMove(), et Drunkard sert le rôle que vous avez donné à la classe MovementPlanEnemyclass. Le nom de cette classe, incidemment, est plus suggestif de l'approche de la catégorie de la stratégie dans la réponse de @ Adamb.

Une autre façon d'empêcher DRY Violation est de faire une méthode de mouvement de type 1 vivant en dehors de toutes les classes des ennemis et avoir Drunkard et Mummy appelez-le au besoin. Selon Comment vous mettez en œuvre cette approche, il peut réduire l'idée de @ ADAMA pour avoir également une classe MoveStyle. Vous ne devez pas avez pour créer une autre classe, mais la méthode de mouvement de type-1 a vivre quelque part.

Comme @gqqnbig a noté, la première de ces suggestions a une grosse inconvénération: la maintenance. Il ne fait que "fonctionne" dans le sens de Yagni que nous n'avons pas actuellement besoin Mummy _ différer de Drunkard, une telle héritage respecte l'esprit de nos besoins actuels. Mummy hériter de Drunkard peut conduire à de nombreuses autres remplaçantes nécessaires à long terme. Comme cela est pour un jeu, le problème le plus évident est que nous allons finalement ajouter des indicateurs audiovisuels du type ennemi. C'est probablement pourquoi ils l'appellent Yagni plutôt que YDNIRN (vous n'en avez pas besoin maintenant).

2
J.G.

Remarque: vous avez oublié de mentionner préféré P.l.

Conceptuellement, chacun Enemy a un seul mouvement, simple ou complexe, permet de commencer par:

abstract class Enemy:
  show( )
  update( )
  move ( )

Ajoutons les sous-classes, pour le moment, pensons simplement, chaque classe move est juste différente.

class Drunkard: extends Enemy
  /* override */ move ( )

class Mummy: extends Enemy
  /* override */ move ( )

class Ninja: extends Enemy
  /* override */ move ( )

Ok, nous savons déjà move peut être mélangé ou simple, et simple ne peut pas être utilisé pour toutes les sous-classes.

Il y a deux façons de faire face à cela. On est d'ajouter les méthodes simples en tant que méthodes protégées comme:

abstract class Enemy:
  show( )
  update( )
  move ( )
  /* protected */ simplemove1 ( )
  /* protected */ simplemove2 ( )
  /* protected */ simplemove3 ( )

Et, chaque sous-classe move méthode appelle les méthodes simples requises. Mais cela ne vous aidera pas si nous voulons ajouter plus d'ennemis et des mouvements.

Une autre solution consiste à utiliser des "traits", similaires à "interfaces", mais elle n'est pas mise en œuvre par plusieurs P L.

Comme vous le savez déjà, la troisième option est de déléguer le fonctionnement move à une autre classe.

abstract class MoveOperation:
  move ( )

abstract class Enemy:
  show( )
  update( )
  move ( )

Et, il ajoute un calque supplémentaire, mais toujours valide.

Pendant un moment, permet de supporter des mouvements simples.

abstract class MoveOperation:
  abstract move ( )

class MoveOperation1: extends MoveOperation
  abstract move ( )

class MoveOperation2: extends MoveOperation
  abstract move ( )

class MoveOperation3: extends MoveOperation
  abstract move ( )

Ensuite, chaque opération de remplacement move( ) Opération créera et appelle la méthode requise.

Ninja.move ( ):
    MoveOperation M = new MoveOperation1( )
    M.move( )

Mais, puisque vous pouvez combiner celles-ci, alors crée une classe simple et une classe composée:

abstract class SimpleMove:
  abstract move ( )

class MoveOperation1: extends SimpleMove
  move ( )

class MoveOperation2: extends SimpleMove
  move ( )

class MoveOperation3: extends SimpleMove
  move ( )

Et puis, l'opération composée:

abstract class ComposedMove:
  abstract move ( )

class DrunkardMove: extends ComposedMove
  move ( )

class MommyMove: extends ComposedMove
  move ( )

class NinjaMove: extends ComposedMove
  move ( )

NinjaMove.move:
  SimpleMove1 M1 = new SimpleMove1( )
  SimpleMove2 M2 = new SimpleMove2( )
  M1()
  M2()

Et la classe Enemy offre:

abstract class Enemy:
  /* protected */ ComposedMove Action

  show( )
  update( )
  abstract move ( )

class Drunkard: extends Enemy
  /* override */ move ( )

class Mummy: extends Enemy
  /* override */ move ( )

class Ninja: extends Enemy
  /* override */ move ( )

Ninja.move ( ):
    this.Action = new NinjaMove( )
    this.Action.move( )

Et la "beauté" de cela est que vous pouvez ajouter plus tard, d'autres "ennemis":

class EvilKittyMove: extends ComposedMove
  move ( )

class EvilKitty: extends Enemy
  /* override */ move ( )

À votre santé.

0
umlcat

D'où mainCharacter vient de?

De votre description, move() Dépend parfois des données du personnage principal, parfois non.

Dans cette situation, mainCharacter doit être un paramètre de move() dans l'interface. C'est un détail de mise en œuvre en classe dérivée si elle est utilisée ou non.

Si mainCharacter est une donnée globale ou contextuelle dans votre moteur de jeu, tout code peut atteindre, alors il s'agit d'un détail de mise en œuvre de la MOMMY'S move() à:

class Mummy extends Enemy:
    move()
        if ( self.IsNear( context.MainCharacter ) )
            moveType2()
        else
            moveType1()

Dans un jeu moteur, vous avez probablement retour un objet composite contenant:

class CalculatedMove
    var Sprint
    var Location

class Mummy extends Enemy:
    move()
        return new CalculatedMove( self , calculateNextPositoin( context.MainCharacter ) )
0
André LFS Bacci

L'utilisateur @vector fait un point important qui est obscurci par sa réponse lenghaty: sèche ne signifie pas ne pas écrire des lignes de code identiques.

Avec cela dit, proposition 1 est clairement préféré, mais c'est potentiellement incomplet.

Votre préoccupation concernant la duplication du code pour Déplacement à l'aide du mouvement de type 1 peut être adressée au moins deux manières différentes:

Option 1. Ne vous inquiétez pas pour cela, allez-y et dupliquez-le. Au fur et à mesure que votre code évolue pour accueillir les exigences futures, quelles sont les chances que le mouvement pour Drunkard et Mummy restera exactement le même pour toujours? Dans ce cas, avoir des lignes de code en double peuvent empêcher l'introduction de bugs futurs dans lesquels une modification de l'autre casse l'autre.

Option 2. Plus approprié si le code associé au mouvement est non trivial: créez une hiérarchie de classe distincte pour le mouvement et faites-en un attribut de l'ennemi. Maintenant, vous pouvez réutiliser le code dans la classe de mouvement:

TL; DR

abstract class Movement:
    abstract move() // Called by Enemy code to move itself

abstract class Enemy:
    show()   // Called each game tick
    update() // Called each game tick
    abstract movement() // Called in update

class Drunkard extends Enemy:
    movement(): return new Type1Movement

class Mummy extends Enemy:
    movement(): if isMainCharacter return new Type1Movement else return new Type2Movement

class Ninja extends Enemy:
    movement(): return new Type3Movement

class Type1Movement extends Movement:
    move(): ... your code here ...

class Type2Movement extends Movement:
    move(): ... your code here ...

class Type3Movement extends Movement:
    move(): ... your code here ...
0
Alex R