web-dev-qa-db-fra.com

Est-ce une odeur de code pour stocker des objets génériques dans un conteneur, puis obtenir un objet et abattre les objets du conteneur?

Par exemple, j'ai un jeu, qui a quelques outils pour augmenter la capacité du joueur:

Tool.h

class Tool{
public:
    std::string name;
};

Et quelques outils:

Sword.h

class Sword : public Tool{
public:
    Sword(){
        this->name="Sword";
    }
    int attack;
};

Shield.h

class Shield : public Tool{
public:
    Shield(){
        this->name="Shield";
    }
    int defense;
};

MagicCloth.h

class MagicCloth : public Tool{
public:
    MagicCloth(){
        this->name="MagicCloth";
    }
    int attack;
    int defense;
};

Et puis un joueur peut détenir certains outils pour attaquer:

class Player{
public:
    int attack;
    int defense;
    vector<Tool*> tools;
    void attack(){
        //original attack and defense
        int currentAttack=this->attack;
        int currentDefense=this->defense;
        //calculate attack and defense affected by tools
        for(Tool* tool : tools){
            if(tool->name=="Sword"){
                Sword* sword=(Sword*)tool;
                currentAttack+=sword->attack;
            }else if(tool->name=="Shield"){
                Shield* shield=(Shield*)tool;
                currentDefense+=shield->defense;
            }else if(tool->name=="MagicCloth"){
                MagicCloth* magicCloth=(MagicCloth*)tool;
                currentAttack+=magicCloth->attack;
                currentDefense+=magicCloth->shield;
            }
        }
        //some other functions to start attack
    }
};

Je pense qu'il est difficile de remplacer if-else avec des méthodes virtuelles dans les outils, car chaque outil a des propriétés différentes, et chaque outil affecte l'attaque et la défense du joueur, pour lesquelles la mise à jour de l'attaque et de la défense du joueur doit être effectuée à l'intérieur de l'objet Player.

Mais je n'étais pas satisfait de cette conception, car elle contient des abaissements, avec un long if-else déclaration. Cette conception doit-elle être "corrigée"? Si oui, que puis-je faire pour le corriger?

35
ggrr

Oui, c'est une odeur de code (dans de nombreux cas).

Je pense qu'il est difficile de remplacer if-else par des méthodes virtuelles dans les outils

Dans votre exemple, il est assez simple de remplacer le if/else par des méthodes virtuelles:

class Tool{
 public:
   virtual int GetAttack() const=0;
   virtual int GetDefense() const=0;
};

class Sword : public Tool{
    // ...
 public:
   virtual int GetAttack() const {return attack;}
   virtual int GetDefense() const{return 0;}
};

Maintenant, plus besoin de votre bloc if, l'appelant peut simplement l'utiliser comme

       currentAttack+=tool->GetAttack();
       currentDefense+=tool->GetDefense();

Bien sûr, pour des situations plus compliquées, une telle solution n'est pas toujours aussi évidente (mais néanmoins presque toujours possible). Mais si vous arrivez à une situation où vous ne savez pas comment résoudre le cas avec des méthodes virtuelles, vous pouvez à nouveau poser une nouvelle question ici sur "Programmeurs" (ou, si cela devient spécifique au langage ou à l'implémentation, sur Stackoverflow).

64
Doc Brown

Le problème majeur avec votre code est que chaque fois que vous introduisez un nouvel élément, vous devez non seulement écrire et mettre à jour le code de l'élément, vous devez également modifier votre lecteur (ou partout où l'élément est utilisé), ce qui rend le tout un beaucoup plus compliqué.

En règle générale, je pense que c'est toujours un peu louche, quand vous ne pouvez pas compter sur un sous-classement/héritage normal et que vous devez faire l'upcasting vous-même.

Je pourrais penser à deux approches possibles rendant le tout plus flexible:

  • Comme d'autres l'ont mentionné, déplacez les membres attack et defense vers la classe de base et initialisez-les simplement sur 0. Cela pourrait également servir de vérification pour savoir si vous pouvez réellement faire pivoter l'élément pour une attaque ou l'utiliser pour bloquer les attaques.

  • Créez une sorte de système de rappel/événement. Il existe différentes approches possibles pour cela.

    Que diriez-vous de rester simple?

    • Vous pouvez créer des membres de classe de base comme virtual void onEquip(Owner*) {} et virtual void onUnequip(Owner*) {}.
    • Leurs surcharges seraient appelées et modifieraient les statistiques lors de (dé) équiper l'article, par ex. virtual void onEquip(Owner *o) { o->modifyStat("attack", attackValue); } et virtual void onUnequip(Owner *o) { o->modifyStat("attack", -attackValue); }.
    • Les statistiques sont accessibles de manière dynamique, par ex. en utilisant une chaîne courte ou une constante comme clé, vous pouvez même introduire de nouvelles valeurs ou bonus spécifiques à l'équipement, que vous n'avez pas nécessairement à gérer spécifiquement dans votre joueur ou votre "propriétaire".
    • Comparé à la simple demande des valeurs d'attaque/défense juste à temps, cela rend non seulement le tout plus dynamique, mais vous évite également les appels inutiles et vous permet même de créer des objets qui affecteront votre personnage de manière permanente.

      Par exemple, imaginez un anneau maudit qui établira simplement une statistique cachée une fois équipé, marquant votre personnage comme maudit de façon permanente.

23
Mario

Bien que @DocBrown ait donné une bonne réponse, cela ne va pas assez loin, à mon humble avis. Avant de commencer à évaluer les réponses, vous devez évaluer vos besoins. De quoi avez-vous vraiment besoin?

Ci-dessous, je vais montrer deux solutions possibles, qui offrent différents avantages pour différents besoins.

Le premier est très simpliste et adapté spécifiquement à ce que vous avez montré:

class Tool {
    public:
        std::string name;
        int attack;
        int defense;
}

public void attack() {
    int attack = this->attack;
    int defense = this->defense;
    for (Tool* tool : tools){
        attack += tool->attack;
        defense += tool->defense;
    }
}

Cela permet très une sérialisation/désérialisation facile des outils (par exemple pour la sauvegarde ou la mise en réseau), et ne nécessite pas du tout d'envoi virtuel. Si votre code est tout ce que vous avez montré et que vous ne vous attendez pas à ce qu'il évolue beaucoup plus que d'avoir des outils plus différents avec des noms et des statistiques différents, uniquement en quantités différentes, alors c'est la voie à suivre.

@DocBrown a proposé une solution qui repose toujours sur la répartition virtuelle, et qui peut être un avantage si vous spécialisez en quelque sorte les outils pour des parties de votre code qui n'ont pas été affichées. Cependant, si vous avez vraiment besoin ou souhaitez également changer d'autres comportements, je suggère la solution suivante:

Composition sur l'héritage

Et si vous voulez plus tard un outil qui modifie agilité? Ou vitesse d'exécution? Pour moi, il semble que vous fassiez un RPG. Une chose qui est importante pour les RPG est d'être ouvert pour l'extension. Les solutions présentées jusqu'à présent n'offrent pas cela. Vous devez modifier la classe Tool et y ajouter de nouvelles méthodes virtuelles chaque fois que vous avez besoin d'un nouvel attribut.

La deuxième solution que je montre est celle à laquelle j'ai fait allusion plus tôt dans un commentaire - elle utilise la composition au lieu de l'héritage et suit le principe "fermé pour modification, ouvert pour extension *. Si vous connaissez le fonctionnement des systèmes d'entités, certaines choses semblera familier (j'aime penser à la composition comme le petit frère d'ES).

Notez que ce que je montre ci-dessous est beaucoup plus élégant dans les langages qui ont des informations de type runtime, comme Java ou C #. Par conséquent, le code C++ que je montre doit inclure une "comptabilité" qui est tout simplement nécessaire pour faire fonctionner la composition ici. Peut-être que quelqu'un avec plus d'expérience en C++ peut suggérer une approche encore meilleure.

Tout d'abord, nous regardons à nouveau sur le côté de l'appelant. Dans votre exemple, en tant qu'appelant dans la méthode attack, vous ne vous souciez pas du tout des outils. Ce qui vous importe, ce sont deux propriétés: les points d'attaque et de défense. Vous ne vous souciez pas vraiment de leur origine et vous ne vous souciez pas des autres propriétés (par exemple la vitesse d'exécution, l'agilité).

Alors d'abord, nous introduisons une nouvelle classe

class Component {
    public:
        // we need this, in Java we'd simply use getClass()
        virtual std::string type() const = 0;
};

Et puis, nous créons nos deux premiers composants

class Attack : public Component {
    public:
        std::string type() const override { return std::string("mygame::components::Attack"); }
        int attackValue = 0;
};

class Defense : public Component {
    public:
      std::string type() const override { return std::string("mygame::components::Defense"); }
      int defenseValue = 0;
};

Ensuite, nous faisons en sorte qu'un outil contienne un ensemble de propriétés, et rend les propriétés interrogeables par d'autres.

class Tool {
private:
    std::map<std::string, Component*> components;

public:
    /** Adds a component to the tool */
    void addComponent(Component* component) { 
        components[component->type()] = component;
    };
    /** Removes a component from the tool */
    void removeComponent(Component* component) { components.erase(component->type()); };
    /** Return the component with the given type */
    Component* getComponentByType(std::string type) { 
        std::map<std::string, Component*>::iterator it = components.find(type);
        if (it != components.end()) { return it->second; }
        return nullptr;
    };
    /** Check wether a tol has a given component */
    bool hasComponent(std::string type) {
        std::map<std::string, Component*>::iterator it = components.find(type);
        return it != components.end();
    }
};

Notez que dans cet exemple, je ne supporte qu'un composant de chaque type - cela facilite les choses. En théorie, vous pourriez également autoriser plusieurs composants du même type, mais cela devient très très laid. Un aspect important: Tool est maintenant fermé pour modification - nous ne toucherons plus jamais la source de Tool - mais ouvert pour extension - nous pouvons étendre le comportement d'un outil en modifiant d'autres choses, et simplement en y passant d'autres composants.

Nous avons maintenant besoin d'un moyen de récupérer les outils par type de composant. Vous pouvez toujours utiliser un vecteur pour les outils, comme dans votre exemple de code:

class Player {
    private:
        int attack = 0; 
        int defense = 0;
        int walkSpeed;
    public:
        std::vector<Tool*> tools;
        std::vector<Tool*> getToolsByComponentType(std::string type) {
            std::vector<Tool*> retVal;
            for (Tool* tool : tools) {
                if (tool->hasComponent(type)) { 
                    retVal.Push_back(tool); 
                }
            }
            return retVal;
        }

        void doAttack() {
            int attackValue = this->attack;
            int defenseValue = this->defense;

            for (Tool* tool : this->getToolsByComponentType(std::string("mygame::components::Attack"))) {
                Attack* component = (Attack*) tool->getComponentByType(std::string("mygame::components::Attack"));
                attackValue += component->attackValue;
            }
            for (Tool* tool : this->getToolsByComponentType(std::string("mygame::components::Defense"))) {
                Defense* component = (Defense*)tool->getComponentByType(std::string("mygame::components::Defense"));
                defenseValue += component->defenseValue;
            }
            std::cout << "Attack with strength " << attackValue << "! Defend with strenght " << defenseValue << "!";
        }
};

Vous pouvez également refactoriser cela dans votre propre classe Inventory et stocker des tables de recherche qui simplifient considérablement la récupération des outils par type de composant et évitent d'itérer sur la collection entière encore et encore.

Quels avantages a cette approche? Dans attack, vous processus Des outils qui ont deux composants - vous ne vous souciez de rien d'autre.

Imaginons que vous ayez une méthode walkTo, et maintenant vous décidez que c'est une bonne idée si un outil gagnerait la capacité de modifier votre vitesse de marche. Aucun problème!

Créez d'abord le nouveau Component:

class WalkSpeed : public Component {
public:
    std::string type() const override { return std::string("mygame::components::WalkSpeed"); }
    int speedBonus;
};

Ensuite, vous ajoutez simplement une instance de ce composant à l'outil dont vous souhaitez augmenter la vitesse de réveil et modifiez la méthode WalkTo pour traiter le composant que vous venez de créer:

void walkTo() {
    int walkSpeed = this->walkSpeed;

    for (Tool* tool : this->getToolsByComponentType(std::string("mygame::components:WalkSpeed"))) {
        WalkSpeed* component = (WalkSpeed*)tool->getComponentByType(std::string("mygame::components::Defense"));
        walkSpeed += component->speedBonus;
        std::cout << "Walk with " << walkSpeed << std::endl;
    }
}

Notez que nous avons ajouté un comportement à nos outils sans modifier la classe Tools du tout.

Vous pouvez (et devez) déplacer les chaînes vers une macro ou une variable const statique, de sorte que vous n'avez pas à la taper encore et encore.

Si vous poursuivez cette approche - par exemple créer des composants qui peuvent être ajoutés au lecteur et créer un composant Combat qui signale au joueur qu'il peut participer au combat, vous pouvez également vous débarrasser de la méthode attack, et que cela soit géré par le Composant ou traité ailleurs.

L'avantage de faire en sorte que le joueur puisse également obtenir des composants serait que vous n'auriez même pas besoin de changer le joueur pour lui donner un comportement différent. Dans mon exemple, vous pouvez créer un composant Movable, de cette façon, vous n'avez pas besoin d'implémenter la méthode walkTo sur le lecteur pour le faire bouger. Il vous suffit de créer le composant, de le joindre au lecteur et de laisser quelqu'un d'autre le traiter.

Vous pouvez trouver un exemple complet dans ce Gist: https://Gist.github.com/NetzwergX/3a29e1b106c6bb9c7308e89dd715ee2

Cette solution est évidemment un peu plus complexe que les autres qui ont été postées. Mais en fonction de la flexibilité que vous souhaitez être, de la mesure dans laquelle vous voulez aller, cela peut être une approche très puissante.

Modifier

Certaines autres réponses proposent un héritage direct (rendre l'outil d'extension des épées, rendre l'outil d'extension du bouclier). Je ne pense pas que ce soit un scénario où l'héritage fonctionne très bien. Et si vous décidez que le blocage avec un bouclier d'une certaine manière peut également endommager l'attaquant? Avec ma solution, vous pouvez simplement ajouter un composant Attack à un bouclier et réaliser cela sans aucune modification de votre code. Avec l'héritage, vous auriez un problème. Les éléments/outils dans les RPG sont des candidats privilégiés pour la composition ou même directement en utilisant des systèmes d'entités dès le départ.

7
Polygnome

De manière générale, si vous avez déjà besoin d'utiliser if (en combinaison avec la nécessité du type d'instance) dans n'importe quelle langue OOP, c'est un signe que quelque chose de malodorant est Au moins, vous devriez regarder de plus près vos modèles.

Je modéliserais votre domaine différemment.

Pour votre utilisation, un Tool a un AttackBonus et un DefenseBonus - qui pourraient tous deux être 0 au cas où ce serait inutile pour se battre comme des plumes ou quelque chose comme ça.

Pour une attaque, vous avez votre baserate + bonus de l'arme utilisée. Il en va de même pour la défense baserate + bonus.

En conséquence, votre Tool doit avoir une méthode virtual pour calculer les boni d'attaque/défense.

tl; dr

Avec une meilleure conception, vous pourriez éviter les hacky ifs.

1
Thomas Junk

Tel qu'il est écrit, cela "sent", mais ce ne sont peut-être que les exemples que vous avez donnés. Le stockage des données dans des conteneurs d'objets génériques, puis leur conversion pour accéder aux données est pas code automatiquement l'odeur. Vous le verrez utilisé dans de nombreuses situations. Cependant, lorsque vous l'utilisez, vous devez savoir ce que vous faites, comment vous le faites et pourquoi. Quand je regarde l'exemple, l'utilisation de comparaisons basées sur des chaînes pour me dire quel objet est quelle est la chose qui déclenche mon odoromètre personnel. Cela suggère que vous n'êtes pas entièrement sûr de ce que vous faites ici (ce qui est bien, puisque vous avez eu la sagesse de venir ici aux programmeurs.SE et de dire "hé, je ne pense pas que j'aime ce que je fais, aide moi! ").

Le problème fondamental avec le modèle de transtypage de données à partir de conteneurs génériques comme celui-ci est que le producteur des données et le consommateur des données doivent travailler ensemble, mais il peut ne pas être évident qu'ils le font à première vue. Dans chaque exemple de ce schéma, malodorant ou non malodorant, c'est la question fondamentale. Il est très possible que le prochain développeur ignore complètement que vous faites ce modèle et le casse par accident, donc si vous utilisez ce modèle, vous devez prendre soin d'aider le prochain développeur. Vous devez lui permettre de ne pas casser le code involontairement en raison de certains détails dont il ignore peut-être l'existence.

Par exemple, que faire si je voulais copier un lecteur? Si je regarde simplement le contenu de l'objet joueur, cela semble assez facile. Je dois juste copier les variables attack, defense et tools. C'est de la tarte! Eh bien, je découvrirai rapidement que votre utilisation de pointeurs rend les choses un peu plus difficiles (à un moment donné, cela vaut la peine de regarder des pointeurs intelligents, mais c'est un autre sujet). C'est facile à résoudre. Je vais simplement créer de nouvelles copies de chaque outil et les mettre dans ma nouvelle liste tools. Après tout, Tool est une classe vraiment simple avec un seul membre. J'ai donc créé un tas de copies, y compris une copie du Sword, mais je ne savais pas que c'était une épée, donc j'ai seulement copié le name. Plus tard, la fonction attack() regarde le nom, voit que c'est une "épée", le lance et de mauvaises choses se produisent!

On peut comparer ce cas à un autre cas en programmation socket, qui utilise le même schéma. Je peux configurer une fonction socket UNIX comme celle-ci:

int sockfd = socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in serv_addr;
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(portno);
serv_addr.sin_addr.s_addr = INADDR_ANY;
bind(sockfd, (struct sockaddr *) &serv_addr, sizeof(serv_addr));

Pourquoi est-ce le même schéma? Parce que bind n'accepte pas un sockaddr_in*, Il accepte un sockaddr* Plus générique. Si vous regardez les définitions de ces classes, nous voyons que sockaddr n'a qu'un seul membre de la famille que nous avons assigné à sin_family *. La famille indique dans quel sous-type vous devez convertir le sockaddr. AF_INET Vous indique que la structure d'adresse est en fait un sockaddr_in. Si c'était AF_INET6, L'adresse serait un sockaddr_in6, Qui a des champs plus grands pour prendre en charge les adresses IPv6 plus grandes.

Ceci est identique à votre exemple Tool, sauf qu'il utilise un entier pour spécifier quelle famille plutôt qu'un std::string. Cependant, je vais affirmer qu'il ne sent pas, et j'essaie de le faire pour des raisons autres que "c'est une façon standard de faire des sockets, donc il ne devrait pas" sentir "." Évidemment, c'est le même modèle, qui est pourquoi je prétends que le stockage de données dans des objets génériques et leur diffusion ne sont pas automatiquement une odeur de code, mais il y a quelques différences dans la façon dont elles le font qui le rendent plus sûr.

Lorsque vous utilisez ce modèle, les informations les plus importantes capturent la transmission d'informations sur la sous-classe du producteur au consommateur. C'est ce que vous faites avec le champ name et les sockets UNIX avec leur champ sin_family. Ce champ est l'information dont le consommateur a besoin pour comprendre ce que le producteur a réellement créé. Dans tous cas de ce modèle, ce devrait être une énumération (ou tout au moins, un entier agissant comme une énumération). Pourquoi? Pensez à ce que votre consommateur va faire des informations. Ils devront avoir écrit une grande instruction if ou une instruction switch, comme vous l'avez fait, où ils déterminent le sous-type correct, le transtypent et utilisent les données. Par définition, il ne peut y avoir qu'un petit nombre de ces types. Vous pouvez le stocker dans une chaîne, comme vous l'avez fait, mais cela présente de nombreux inconvénients:

  • Lent - std::string Doit généralement faire de la mémoire dynamique pour conserver la chaîne. Vous devez également effectuer une comparaison en texte intégral pour faire correspondre le nom à chaque fois que vous souhaitez déterminer la sous-classe que vous avez.
  • Trop polyvalent - Il y a quelque chose à dire pour vous imposer des contraintes lorsque vous faites quelque chose d'extrêmement dangereux. J'ai eu des systèmes comme celui-ci qui cherchaient une sous-chaîne pour lui dire quel type d'objet il regardait. Cela a très bien fonctionné jusqu'à ce que le nom d'un objet accidentellement contienne cette sous-chaîne et crée une erreur terriblement cryptique. Étant donné que, comme nous l'avons indiqué ci-dessus, nous n'avons besoin que d'un petit nombre de cas, il n'y a aucune raison d'utiliser un outil extrêmement puissant comme les cordes. Cela mène à...
  • Sujet aux erreurs - Disons simplement que vous voudrez faire un déchaînement meurtrier en essayant de déboguer pourquoi les choses ne fonctionnent pas lorsqu'un consommateur définit accidentellement le nom d'un tissu magique sur MagicC1oth. Sérieusement, des bugs comme celui-ci peuvent prendre jours de se gratter la tête avant de réaliser ce qui s'est passé.

Une énumération fonctionne beaucoup mieux. C'est rapide, bon marché et beaucoup moins sujet aux erreurs:

class Tool {
public:
    enum TypeE {
        kSword,
        kShield,
        kMagicCloth
    };
    TypeE type;

    std::string typeName() const {
        switch(type) {
            case kSword:      return "Sword";
            case kSheild:     return "Sheild";
            case kMagicCloth: return "Magic Cloth";

            default:
                throw std::runtime_error("Invalid enum!");
        }
   }
};

Cet exemple montre également une instruction switch impliquant les énumérations, avec la partie la plus importante de ce modèle: un cas default qui se déclenche. Vous devriez jamais vous mettre dans cette situation si vous faites les choses parfaitement. Cependant, si quelqu'un ajoute un nouveau type d'outil et que vous oubliez de mettre à jour votre code pour le prendre en charge, vous voudrez que quelque chose rattrape l'erreur. En fait, je les recommande tellement que vous devriez les ajouter même si vous n'en avez pas besoin.

L'autre énorme avantage du enum est qu'il donne au prochain développeur une liste complète des types d'outils valides, dès le départ. Il n'est pas nécessaire de parcourir le code pour trouver la classe de flûte spécialisée de Bob qu'il utilise dans sa bataille de boss épique.

void damageWargear(Tool* tool)
{
    switch(tool->type)
    {
        case Tool::kSword:
            static_cast<Sword*>(tool)->damageSword();
            break;
        case Tool::kShield:
            static_cast<Sword*>(tool)->damageShield();
            break;
        default:
            break; // Ignore all other objects
    }
}

Oui, je mets une déclaration par défaut "vide", juste pour rendre explicite au prochain développeur ce que je m'attends à se produire si un nouveau type inattendu se présente à moi.

Si vous faites cela, le motif sentira moins. Cependant, pour être sans odeur, la dernière chose que vous devez faire est de considérer les autres options. Ces conversions sont quelques-uns des outils les plus puissants et les plus dangereux que vous ayez dans le répertoire C++. Vous ne devez pas les utiliser sauf si vous avez une bonne raison.

Une alternative très populaire est ce que j'appelle une "structure syndicale" ou "classe syndicale". Pour votre exemple, ce serait en fait un très bon ajustement. Pour en créer une, vous créez une classe Tool, avec une énumération comme avant, mais au lieu de sous-classer Tool, nous mettons simplement tous les champs de chaque sous-type dessus.

class Tool {
    public:
        enum TypeE {
            kSword,
            kShield,
            kMagicCloth
        };
    TypeE type;

    int   attack;
    int   defense;
};

Maintenant, vous n'avez plus besoin de sous-classes. Il suffit de regarder le champ type pour voir quels autres champs sont réellement valides. C'est beaucoup plus sûr et plus facile à comprendre. Cependant, il présente des inconvénients. Il y a des moments où vous ne voulez pas utiliser ceci:

  • Lorsque les objets sont trop différents - Vous pouvez vous retrouver avec une liste de champs de blanchisserie, et il peut être difficile de savoir lesquels s'appliquent à chaque type d'objet.
  • Lorsque vous travaillez dans une situation critique en mémoire - Si vous devez créer 10 outils, vous pouvez être paresseux avec la mémoire. Lorsque vous devez créer 500 millions d'outils, vous allez commencer à vous soucier des bits et des octets. Les structures d'union sont toujours plus grandes qu'elles ne devraient l'être.

Cette solution n'est pas utilisée par les sockets UNIX en raison du problème de dissimilarité aggravé par le caractère ouvert de l'API. L'intention avec les sockets UNIX était de créer quelque chose avec lequel toutes les saveurs d'UNIX pourraient fonctionner. Chaque saveur pourrait définir la liste des familles qu'elles prennent en charge, comme AF_INET, Et il y aurait une courte liste pour chacune. Cependant, si un nouveau protocole arrive, comme AF_INET6, Vous devrez peut-être ajouter de nouveaux champs. Si vous faisiez cela avec une structure d'union, vous finiriez par créer une nouvelle version de la structure avec le même nom, créant ainsi des problèmes d'incompatibilité sans fin. C'est pourquoi les sockets UNIX ont choisi d'utiliser le modèle de transtypage plutôt qu'une structure d'union. Je suis sûr qu'ils l'ont considéré, et le fait qu'ils y aient pensé fait partie des raisons pour lesquelles cela ne sent pas quand ils l'utilisent.

Vous pouvez également utiliser une union pour de vrai. Les syndicats économisent de la mémoire, en étant aussi gros que le plus grand membre, mais ils viennent avec leur propre ensemble de problèmes. Ce n'est probablement pas une option pour votre code, mais c'est toujours une option que vous devriez considérer.

Une autre solution intéressante est boost::variant. Boost est une grande bibliothèque pleine de solutions multi-plateformes réutilisables. C'est probablement l'un des meilleurs codes C++ jamais écrits. Boost.Variant est fondamentalement la version C++ des unions. Il s'agit d'un conteneur qui peut contenir de nombreux types différents, mais un seul à la fois. Vous pouvez créer vos classes Sword, Shield et MagicCloth, puis faire de l'outil un boost::variant<Sword, Shield, MagicCloth>, Ce qui signifie qu'il contient l'un de ces trois types. Cela souffre toujours du même problème avec la compatibilité future qui empêche les sockets UNIX de l'utiliser (sans parler des sockets UNIX sont C, précédant un peu boost!), Mais ce modèle peut être incroyablement utile. La variante est souvent utilisée, par exemple, dans les arbres d'analyse, qui prennent une chaîne de texte et la décomposent en utilisant une grammaire pour les règles.

La solution finale que je recommanderais d'examiner avant de plonger et d'utiliser l'approche générique de moulage d'objets est le modèle de conception Visitor . Visitor est un modèle de conception puissant qui tire parti de l'observation selon laquelle appeler une fonction virtuelle fait efficacement le casting dont vous avez besoin, et il le fait pour vous. Parce que le compilateur le fait, il ne peut jamais se tromper. Ainsi, au lieu de stocker une énumération, Visitor utilise une classe de base abstraite, qui possède une table virtuelle qui sait de quel type est l'objet. Nous créons ensuite un petit appel à double indirection soigné qui fait le travail:

class Tool;
class Sword;
class Shield;
class MagicCloth;

class ToolVisitor {
public:
    virtual void visit(Sword* sword) = 0;
    virtual void visit(Shield* shield) = 0;
    virtual void visit(MagicCloth* cloth) = 0;
};

class Tool {
public:
    virtual void accept(ToolVisitor& visitor) = 0;
};

lass Sword : public Tool{
public:
    virtual void accept(ToolVisitor& visitor) { visitor.visit(*this); }
    int attack;
};
class Shield : public Tool{
public:
    virtual void accept(ToolVisitor& visitor) { visitor.visit(*this); }
    int defense;
};
class MagicCloth : public Tool{
public:
    virtual void accept(ToolVisitor& visitor) { visitor.visit(*this); }
    int attack;
    int defense;
};

Alors, quel est ce modèle horrible de Dieu? Eh bien, Tool a une fonction virtuelle, accept. Si vous lui passez un visiteur, il devrait se retourner et appeler la fonction visit correcte sur ce visiteur pour le type. C'est ce que fait la visitor.visit(*this); sur chaque sous-type. Compliqué, mais nous pouvons le montrer avec votre exemple ci-dessus:

class AttackVisitor : public ToolVisitor
{
public:
    int& currentAttack;
    int& currentDefense;

    AttackVisitor(int& currentAttack_, int& currentDefense_)
    : currentAttack(currentAttack_)
    , currentDefense(currentDefense_)
    { }

    virtual void visit(Sword* sword)
    {
        currentAttack += sword->attack;
    }

    virtual void visit(Shield* shield)
    {
        currentDefense += shield->defense;
    }

    virtual void visit(MagicCloth* cloth)
    {
        currentAttack += cloth->attack;
        currentDefense += cloth->defense;
    }
};

void Player::attack()
{
    int currentAttack = this->attack;
    int currentDefense = this->defense;
    AttackVisitor v(currentAttack, currentDefense);
    for (Tool* t: tools) {
        t->accept(v);
    }
    //some other functions to start attack
}

Alors que se passe-t-il ici? Nous créons un visiteur qui fera du travail pour nous, une fois qu'il saura quel type d'objet il visite. Nous parcourons ensuite la liste des outils. Par souci d'argument, disons que le premier objet est un Shield, mais notre code ne le sait pas encore. Il appelle t->accept(v), une fonction virtuelle. Parce que le premier objet est un bouclier, il finit par appeler void Shield::accept(ToolVisitor& visitor), qui appelle visitor.visit(*this);. Maintenant, quand nous cherchons quel visit appeler, nous savons déjà que nous avons un bouclier (parce que cette fonction a été appelée), donc nous finirons par appeler void ToolVisitor::visit(Shield* shield) sur notre AttackVisitor. Cela exécute maintenant le code correct pour mettre à jour notre défense.

Le visiteur est encombrant. C'est tellement maladroit que je pense presque qu'il a sa propre odeur. Il est très facile d'écrire de mauvais schémas de visiteurs. Cependant, il a un énorme avantage qu'aucun des autres n'a. Si nous ajoutons un nouveau type d'outil, nous devons lui ajouter une nouvelle fonction ToolVisitor::visit. À l'instant où nous faisons cela, chaqueToolVisitor dans le programme refusera de compiler car il manque une fonction virtuelle. Cela permet de détecter très facilement tous les cas où nous avons manqué quelque chose. Il est beaucoup plus difficile de garantir que si vous utilisez les instructions if ou switch pour effectuer le travail. Ces avantages sont suffisamment bons pour que Visitor ait trouvé une jolie petite niche dans les générateurs de scènes graphiques 3D. Il se trouve qu'ils ont besoin exactement du type de comportement que les visiteurs offrent, donc cela fonctionne très bien!

Dans l'ensemble, rappelez-vous que ces modèles compliquent la tâche du prochain développeur. Passez du temps à leur faciliter la tâche et le code ne sentira pas!

* Techniquement, si vous regardez la spécification, sockaddr a un membre nommé sa_family. Il y a des choses difficiles à faire ici au niveau C qui ne nous importent pas. Vous êtes invités à regarder l'implémentation réelle, mais pour cette réponse, je vais utiliser sa_familysin_family Et d'autres de manière complètement interchangeable, en utilisant celle qui est le plus intuitif pour la prose, confiant que cette supercherie C s'occupe des détails sans importance.

1
Cort Ammon

En général, j'évite d'implémenter plusieurs classes/héritant si c'est juste pour communiquer des données. Vous pouvez vous en tenir à une seule classe et tout implémenter à partir de là. Pour votre exemple, cela suffit

class Tool{
    public:
    //constructor, name etc.
    int GetAttack() { return attack }; //Endpoints for your Player
    int GetDefense() { return defense };
    protected:
         int attack;
         int defense;
};

Probablement, vous vous attendez à ce que votre jeu implémente plusieurs types d'épées, etc., mais vous aurez d'autres moyens de l'implémenter. L'explosion de classe est rarement la meilleure architecture. Rester simple.

0
Arthur Havlicek

Comme indiqué précédemment, il s'agit d'une sérieuse odeur de code. Cependant, on pourrait considérer que la source de votre problème utilise l'héritage plutôt que la composition dans votre conception.

Par exemple, compte tenu de ce que vous nous avez montré, vous avez clairement 3 concepts:

  • Article
  • Objet qui peut avoir une attaque.
  • Objet qui peut avoir une défense.

Notez que votre quatrième classe n'est qu'une combinaison des deux derniers concepts. Je suggère donc d'utiliser la composition pour cela.

Vous avez besoin d'une structure de données pour représenter les informations nécessaires à l'attaque. Et vous avez besoin d'une structure de données représentant les informations dont vous avez besoin pour la défense. Enfin, vous avez besoin d'une structure de données pour représenter des choses qui peuvent ou non avoir l'une ou l'autre des deux propriétés:

class Attack
{
private:
  int attack_;

public:
  int AttackValue() const;
};

class Defense
{
private:
  int defense_

public:
  int DefenseValue() const;
};

class Tool
{
private:
  std::optional<Attack> atk_;
  std::optional<Defense> def_;

public:
  const std::optional<Attack> &GetAttack() const {return atk_;}
  const std::optional<Defense> &GetDefense() const {return def_;}
};
0
Nicol Bolas

Pourquoi ne pas créer des méthodes abstraites modifyAttack et modifyDefense dans la classe Tool? Ensuite, chaque enfant aurait sa propre implémentation, et vous appelez cette manière élégante:

for(Tool* tool : tools){
    currentAttack = tool->recalculateAttack(currentAttack);
    currentDefense = tool->recalculateDefense(currentDefense);
}
// proceed with new values for currentAttack and currentDefense

La transmission de valeurs comme référence économisera des ressources si vous pouvez:

for(Tool* tool : tools){
    tool->recalculateAttack(&currentAttack);
    tool->recalculateDefense(&currentDefense);
}
// proceed with new values for currentAttack and currentDefense
0
Paulo Amaral

Si l'on utilise le polymorphisme, il est toujours préférable que tout le code qui se soucie de la classe utilisée soit à l'intérieur de la classe elle-même. Voici comment je le coderais:

class Tool{
 public:
   virtual void equipTo(Player* player) =0;
   virtual void unequipFrom(Player* player) =0;
};

class Sword : public Tool{
  public:
    int attack;
    virtual void equipTo(Player* player) {
      player->attackBonus+=this->attack;
    };
    //unequipFrom = reverse equip
};
class Shield : public Tool{
  public:
    int defense;
    virtual void equipTo(Player* player) {
      player->defenseBonus+=this->defense;
    };
    //unequipFrom = reverse equip
};
//other tools
class Player{
  public:
    int baseAttack;
    int baseDefense;
    int attackBonus;
    int defenseBonus;

    virtual void equip(Tool* tool) {
      tool->equipTo(this);
      this->tools.Push_back(tool)
    };

    //unequip = reverse equip

    void attack(){
      //modified attack and defense
      int modifiedAttack = baseAttack + this->attackBonus;
      int modifiedDefense = baseDefense+ this->defenseBonus;
      //some other functions to start attack
    }
  private:
    vector<Tool*> tools;
};

Cela présente les avantages suivants:

  • plus facile d'ajouter de nouvelles classes: vous n'avez qu'à implémenter toutes les méthodes abstraites et le reste du code fonctionne
  • plus facile à supprimer des classes
  • plus facile d'ajouter de nouvelles statistiques (les classes qui ne se soucient pas de la statistique l'ignorent simplement)
0
Siphor

Je pense qu'une façon de reconnaître les défauts de cette approche est de développer votre idée jusqu'à sa conclusion logique.

Cela ressemble à un jeu, donc à un certain stade, vous commencerez probablement à vous soucier des performances et à échanger ces comparaisons de chaînes pour un int ou enum. À mesure que la liste des éléments s'allonge, ce if-else Commence à devenir assez lourd, vous pouvez donc envisager de le refactoriser dans un switch-case. Vous avez également un mur de texte à ce stade, vous pouvez donc vider l'action dans chaque case dans une fonction distincte.

Une fois que vous atteignez ce point, la structure de votre code commence à vous sembler familière - elle commence à ressembler à une table virtuelle homebrew, roulée à la main * - la structure de base sur laquelle les méthodes virtuelles sont généralement implémentées. Sauf qu'il s'agit d'une table que vous devez mettre à jour et entretenir manuellement vous-même, chaque fois que vous ajoutez ou modifiez un type d'élément.

En vous en tenant aux "vraies" fonctions virtuelles, vous pouvez conserver l'implémentation du comportement de chaque élément dans l'élément lui-même. Vous pouvez ajouter des éléments supplémentaires de manière plus autonome et cohérente. Et comme vous faites tout cela, c'est le compilateur qui se chargera de l'implémentation de votre dispatch dynamique, plutôt que vous.

Pour résoudre votre problème spécifique: vous avez du mal à écrire une simple paire de fonctions virtuelles pour mettre à jour l'attaque et la défense car certains éléments n'affectent que l'attaque et certains éléments n'affectent que la défense. L'astuce dans un cas simple comme celui-ci pour implémenter les deux comportements de toute façon, mais sans effet dans certains cas. GetDefenseBonus() peut retourner 0 ou ApplyDefenseBonus(int& defence) peut simplement laisser defence inchangé. La façon dont vous allez procéder dépendra de la façon dont vous souhaitez gérer les autres actions qui ont un effet. Dans les cas plus complexes, où les comportements sont plus variés, vous pouvez simplement combiner l'activité en une seule méthode.

* (Quoique, transposé par rapport à l'implémentation typique)

0

Avoir un bloc de code qui connaît tous les "outils" possibles n'est pas une excellente conception (d'autant plus que vous vous retrouverez avec beaucoup de tels blocs dans votre code); mais ni l'un ni l'autre n'a un Tool de base avec des stubs pour toutes les propriétés d'outil possibles: maintenant la classe Tool doit connaître toutes les utilisations possibles.

Ce que chaque outil sait, c'est ce qu'il peut apporter au personnage qui l'utilise. Fournissez donc une méthode pour tous les outils, giveto(*Character owner). Il ajustera les statistiques du joueur comme il convient sans savoir ce que les autres outils peuvent faire, et ce qui est le mieux, il n'a pas besoin non plus de connaître les propriétés non pertinentes du personnage. Par exemple, un bouclier n'a même pas besoin de connaître les attributs attack, invisibility, health etc. Tout ce qui est nécessaire pour appliquer un outil est que le personnage prenne en charge les attributs dont l'objet a besoin. Si vous essayez de donner une épée à un âne, et que l'âne n'a pas de statistiques attack, vous obtiendrez une erreur.

Les outils doivent également avoir une méthode remove(), qui inverse leur effet sur le propriétaire. C'est un peu délicat (il est possible de se retrouver avec des outils qui laissent un effet non nul lorsqu'ils sont donnés puis retirés), mais au moins il est localisé sur chaque outil.

0
alexis