Lorsque plusieurs classes doivent accéder aux mêmes données, où doivent-elles être déclarées?

39

J'ai un jeu de base de tour de défense 2D en C ++.

Chaque carte est une classe distincte qui hérite de GameState. La carte délègue la logique et le code de dessin à chaque objet du jeu et définit des données telles que le chemin de la carte. Dans le pseudo-code, la section logique pourrait ressembler à ceci:

update():
  for each creep in creeps:
    creep.update()
  for each tower in towers:
    tower.update()
  for each missile in missiles:
    missile.update()

Les objets (creeps, tours et missiles) sont stockés dans des vecteurs de pointeurs. Les tours doivent avoir accès au vecteur de fluage et au vecteur de missiles pour créer de nouveaux missiles et identifier les cibles.

La question est: où puis-je déclarer les vecteurs? Devraient-ils être membres de la classe Map et passés en tant qu'arguments à la fonction tower.update ()? Ou déclaré globalement? Ou y a-t-il d'autres solutions qui me manquent entièrement?

Lorsque plusieurs classes doivent accéder aux mêmes données, où doivent-elles être déclarées?

Juteux
la source
1
Les membres globaux sont considérés comme "laids" mais sont rapides et facilitent le développement. Si c'est un petit jeu, ce n'est pas un problème (IMHO). Vous pouvez également créer une classe externe qui gère la logique ( pourquoi les tours ont besoin de ces vecteurs) et a accès à tous les vecteurs.
Jonathan Connell
-1 si cela est lié à la programmation de jeux, alors manger de la pizza l'est aussi. Prenez quelques bons livres sur la conception de logiciels
Maik Semder
9
@Maik: Comment la conception de logiciels n'est-elle pas liée à la programmation de jeux? Le fait que cela s'applique également à d'autres domaines de la programmation ne le rend pas hors sujet.
BlueRaja - Danny Pflughoeft Le
Les listes de modèles de conception logicielle de @BlueRaja sont mieux adaptées à SO, c'est pour cela qu'elles sont là après tout. GD.SE est destiné à la programmation de jeux, pas à la conception de logiciels
Maik Semder Le

Réponses:

53

Lorsque vous avez besoin d’une seule instance d’une classe dans l’ensemble de votre programme, nous appelons cette classe un service . Il existe plusieurs méthodes standard d'implémentation de services dans les programmes:

  • Variables globales . Ce sont les plus faciles à mettre en œuvre, mais les moins bien conçus. Si vous utilisez trop de variables globales, vous vous retrouverez rapidement en train d'écrire des modules trop dépendants les uns des autres ( couplage fort ), ce qui rend le flux de logique très difficile à suivre. Les variables globales ne sont pas compatibles avec le multithreading. Les variables globales rendent plus difficile le suivi de la durée de vie des objets et encombrent l'espace de noms. Cependant, ils constituent l’option la plus performante. Ils peuvent donc et doivent être utilisés dans certains cas, mais ils doivent être utilisés avec parcimonie.
  • Singletons . Il y a environ 10-15 ans, les singletons étaient le grand motif de conception à connaître. Cependant, de nos jours, ils sont méprisés. Ils sont beaucoup plus faciles à multi-thread, mais vous devez limiter leur utilisation à un thread à la fois, ce qui n'est pas toujours ce que vous voulez. Suivre les durées de vie est tout aussi difficile qu'avec des variables globales.
    Une classe singleton typique ressemblera à quelque chose comme ceci:

    class MyClass
    {
    private:
        static MyClass* _instance;
        MyClass() {} //private constructor
    
    public:
        static MyClass* getInstance();
        void method();
    };
    
    ...
    
    MyClass* MyClass::_instance = NULL;
    MyClass* MyClass::getInstance()
    {
        if(_instance == NULL)
            _instance = new MyClass(); //Not thread-safe version
        return _instance;
    
        //Note that _instance is *never* deleted - 
        //it exists for the entire lifetime of the program!
    }
  • Injection de dépendance (DI) . Cela signifie simplement que le service doit être passé en tant que paramètre constructeur. Un service doit déjà exister pour pouvoir être passé dans une classe. Il est donc impossible pour deux services de s'appuyer l'un sur l'autre. dans 98% des cas, c'est ce que vous voulez (et pour les 2% restants, vous pouvez toujours créer une setWhatever()méthode et transmettre le service plus tard) . De ce fait, DI n’a pas les mêmes problèmes de couplage que les autres options. Il peut être utilisé avec le multithreading, car chaque thread peut simplement avoir sa propre instance de chaque service (et ne partager que ceux dont il a absolument besoin). Cela rend également le code testable par unité, si cela vous intéresse.

    Le problème avec l'injection de dépendance est que cela prend plus de mémoire; maintenant chaque instance d'une classe a besoin de références à chaque service qu'elle utilisera. En outre, il devient ennuyeux de l'utiliser lorsque vous avez trop de services. il existe des frameworks qui atténuent ce problème dans d'autres langages, mais en raison du manque de réflexion de C ++, les frameworks DI en C ++ ont tendance à être encore plus laborieux que de le faire manuellement.

    //Example of dependency injection
    class Tower
    {
    private:
        MissileCreationService* _missileCreator;
        CreepLocatorService* _creepLocator;
    public:
        Tower(MissileCreationService*, CreepLocatorService*);
    }
    
    //In order to create a tower, the creating-class must also have instances of
    // MissileCreationService and CreepLocatorService; thus, if we want to 
    // add a new service to the Tower constructor, we must add it to the
    // constructor of every class which creates a Tower as well!
    //This is not a problem in languages like C# and Java, where you can use
    // a framework to create an instance and inject automatically.

    Voir cette page (de la documentation de Ninject, un framework C # DI) pour un autre exemple.

    L’injection de dépendance est la solution habituelle à ce problème, et c’est la réponse que vous verrez être la plus votée vers des questions comme celle-ci sur StackOverflow.com. DI est un type d' inversion de contrôle (IoC).

  • Localisateur de service . Fondamentalement, juste une classe qui contient une instance de chaque service. Vous pouvez le faire en utilisant la réflexion ou vous pouvez simplement y ajouter une nouvelle instance chaque fois que vous souhaitez créer un nouveau service. Vous avez toujours le même problème qu'auparavant - Comment les classes accèdent-elles à ce localisateur? - ce qui peut être résolu de l’une quelconque des manières ci-dessus, mais maintenant, vous ne devez le faire que pour votre ServiceLocatorclasse, plutôt que pour des dizaines de services. Cette méthode est également testable à l'unité, si vous vous souciez de ce genre de chose.

    Les localisateurs de services sont une autre forme d'inversion de contrôle (IoC). Généralement, les infrastructures qui effectuent l'injection automatique de dépendances ont également un localisateur de services.

    XNA (infrastructure de programmation de jeux C # de Microsoft) comprend un localisateur de services; pour en savoir plus, voir cette réponse .


À propos, les tours à mon humble avis ne devraient pas être au courant de la chair de poule. À moins que vous ne prévoyiez simplement de parcourir la liste des creeps de chaque tour, vous souhaiterez probablement implémenter un partitionnement d'espace non trivial ; et cette sorte de logique n'appartient pas à la classe des tours.

BlueRaja - Danny Pflughoeft
la source
Les commentaires ne sont pas pour une discussion prolongée; cette conversation a été déplacée pour discuter .
Josh
Une des meilleures réponses les plus claires que j'ai jamais lues. Bien joué. Je pensais qu'un service était toujours supposé être partagé.
Nikos
5

Personnellement, je voudrais utiliser le polymorphisme ici. Pourquoi avoir un missilevecteur, un towervecteur et un vecteur ... creepquand ils appellent tous la même fonction; update? Pourquoi ne pas avoir un vecteur de pointeurs sur une classe de base Entityou GameObject?

Je trouve qu'un bon moyen de concevoir est de penser «est-ce que cela a du sens en termes de propriété»? Il est évident qu'une tour possède un moyen de se mettre à jour, mais une carte possède-t-elle tous les objets qu'elle contient? Si vous optez pour le global, dites-vous que rien ne possède les tours et la chair de poule? Global est généralement une mauvaise solution - elle favorise les mauvais modèles de conception, mais il est beaucoup plus facile de travailler avec. Envisagez de peser "est-ce que je veux finir ceci?" et 'est-ce que je veux quelque chose que je puisse réutiliser'?

Une façon de contourner ce problème consiste à utiliser une forme de système de messagerie. Le towerpeut envoyer un message au map(auquel il a accès, peut-être une référence à son propriétaire?) Qu'il a touché un creep, mappuis dire au creepqu'il a été touché. Ceci est très propre et sépare les données.

Une autre méthode consiste simplement à rechercher sur la carte elle-même ce qu'elle veut. Cependant, il peut y avoir des problèmes avec l'ordre de mise à jour ici.

Le canard communiste
la source
1
Votre suggestion sur le polymorphisme n'est pas vraiment pertinente. Je les ai stockés dans des vecteurs distincts afin que je puisse parcourir chaque type individuellement, comme dans le code de dessin (où je veux que certains objets soient dessinés en premier) ou dans le code de collision.
Juicy
Pour mes besoins, la carte possède les entités, puisque la carte ici est analogue à «niveau». Je vais considérer votre idée sur les messages, merci.
Juicy
1
Dans un jeu, la performance compte. Ainsi, les vecteurs du même temps d'objet ont une meilleure localité de référence. En outre, les objets polymorphes dotés de pointeurs virtuels ont des performances médiocres, car ils ne peuvent pas être insérés dans la boucle de mise à jour.
Zan Lynx
0

C'est un cas dans lequel la programmation orientée objet stricte (OOP) tombe en panne.

Selon les principes de la programmation orientée objet, vous devez regrouper les données avec un comportement associé à l'aide de classes. Mais vous avez un comportement (ciblage) qui nécessite des données qui ne sont pas liées les unes aux autres (tours et rampants). Dans cette situation, de nombreux programmeurs essaieront d'associer le comportement à une partie des données dont ils ont besoin (par exemple, les tours gèrent le ciblage, mais ne connaissent pas les creeps), mais il existe une autre option: ne groupez pas le comportement avec les données.

Au lieu de faire du comportement de ciblage une méthode de la classe tower, faites-en une fonction libre qui accepte les tours et rampe comme arguments. Cela pourrait nécessiter de rendre plus public les membres qui restent dans la tour et les classes de fluage, et ce n'est pas grave. Cacher des données est utile, mais c’est un moyen, pas une fin en soi, et vous ne devriez pas en être esclave. En outre, les membres privés ne sont pas le seul moyen de contrôler l'accès aux données: si les données ne sont pas transmises à une fonction et si elles ne sont pas globales, elles sont en réalité masquées à cette fonction. Si vous utilisez cette technique pour éviter les données globales, vous pourriez réellement améliorer l' encapsulation.

Un exemple extrême de cette approche est l' architecture de système d'entité .

Steve S
la source