È un odore di codice archiviare oggetti generici in un contenitore e quindi ottenere oggetti e downcast gli oggetti dal contenitore?


34

Ad esempio, ho un gioco, che ha alcuni strumenti per aumentare l'abilità del giocatore:

Tool.h

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

E alcuni strumenti:

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;
};

E quindi un giocatore può avere alcuni strumenti per l'attacco:

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
    }
};

Penso che sia difficile sostituirlo if-elsecon metodi virtuali negli strumenti, poiché ogni strumento ha proprietà diverse e ogni strumento influenza l'attacco e la difesa del giocatore, per cui è necessario eseguire l'aggiornamento dell'attacco e della difesa all'interno dell'oggetto Giocatore.

Ma non ero soddisfatto di questo progetto, poiché contiene downcasting, con una lunga if-elsedichiarazione. Questo progetto deve essere "corretto"? In tal caso, cosa posso fare per correggerlo?


4
Una tecnica OOP standard per rimuovere i test per una sottoclasse specifica (e i successivi downcast) è quella di creare un metodo virtuale, o in questo caso forse due, nella classe base da utilizzare al posto della catena if e dei cast. Questo può essere usato per rimuovere completamente gli if e delegare l'operazione alle sottoclassi da implementare. Inoltre, non dovrai modificare le istruzioni if ​​ogni volta che aggiungi una nuova sottoclasse.
Erik Eidt,

2
Considera anche Double Dispatch.
Boris the Spider il

Perché non aggiungere una proprietà alla tua classe Tool che contiene un dizionario di tipi di attributi (ad esempio attacco, difesa) e un valore assegnato ad esso. L'attacco, la difesa potrebbe essere enumerato i valori. Quindi puoi semplicemente chiamare il valore dallo strumento stesso dalla costante enumerata.
user1740075


1
Vedi anche il modello Visitatore.
JDługosz,

Risposte:


63

Sì, è un odore di codice (in molti casi).

Penso che sia difficile sostituire if-else con metodi virtuali negli strumenti

Nel tuo esempio, è abbastanza semplice sostituire if / else con metodi virtuali:

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;}
};

Ora non c'è più bisogno del ifblocco, il chiamante può semplicemente usarlo come

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

Naturalmente, per situazioni più complicate, una soluzione del genere non è sempre così ovvia (ma quasi sempre possibile). Ma se ti trovi in ​​una situazione in cui non sai come risolvere il caso con metodi virtuali, puoi fare di nuovo una nuova domanda qui su "Programmatori" (o, se diventa lingua o implementazione specifica, su Stackoverflow).


4
o, del resto, su gamedev.stackexchange.com
Kromster afferma di supportare Monica il

7
Non avresti nemmeno bisogno del concetto di Swordquesto modo nella tua base di codice. Potresti semplicemente new Tool("sword", swordAttack, swordDefense)ad esempio un file JSON.
AmazingDreams,

7
@AmazingDreams: è corretto (per le parti del codice che vediamo qui), ma immagino che l'OP abbia semplificato il suo vero codice perché la sua domanda si focalizzasse sull'aspetto che voleva discutere.
Doc Brown,

3
Questo non è molto meglio del codice originale (beh, è ​​un po '). Qualsiasi strumento con proprietà aggiuntive non può essere creato senza l'aggiunta di metodi aggiuntivi. Penso che in questo caso si debba favorire la composizione rispetto all'eredità. Sì, al momento esiste solo attacco e difesa, ma non è necessario che rimanga tale.
Polygnome,

1
@DocBrown Sì, è vero, anche se sembra un gioco di ruolo in cui un personaggio ha alcune statistiche che sono modificate da strumenti o oggetti piuttosto equipaggiati. Vorrei fare un generico Toolcon tutti i possibili modificatori, riempire alcuni vector<Tool*>con roba letta da un file di dati, quindi semplicemente passare su di essi e modificare le statistiche come fai ora. Ti metteresti nei guai quando desideri che un oggetto ti dia, ad esempio, un bonus del 10% per l'attacco. Forse a tool->modify(playerStats)è un'altra opzione.
AmazingDreams,

23

Il problema principale con il tuo codice è che ogni volta che introduci un nuovo oggetto, non solo devi scrivere e aggiornare il codice dell'articolo, ma devi anche modificare il tuo giocatore (o ovunque sia usato l'oggetto), il che rende l'intera cosa un molto più complicato.

Come regola generale, penso che sia sempre un po 'sospetto, quando non puoi fare affidamento sulla normale sottoclasse / eredità e devi fare l'upgrade da solo.

Potrei pensare a due possibili approcci che rendono il tutto più flessibile:

  • Come altri hanno già detto, spostare i membri attacke defensenella classe base e semplicemente inizializzarli 0. Questo potrebbe anche raddoppiare come controllo se sei effettivamente in grado di oscillare l'oggetto per un attacco o usarlo per bloccare gli attacchi.

  • Crea un qualche tipo di sistema di callback / evento. Esistono diversi approcci possibili per questo.

    Che ne dici di mantenerlo semplice?

    • È possibile creare membri di una classe base come virtual void onEquip(Owner*) {}e virtual void onUnequip(Owner*) {}.
    • I loro sovraccarichi sarebbero stati chiamati e modificare le statistiche quando (ONU) dotare l'elemento, ad esempio, virtual void onEquip(Owner *o) { o->modifyStat("attack", attackValue); }e virtual void onUnequip(Owner *o) { o->modifyStat("attack", -attackValue); }.
    • È possibile accedere alle statistiche in modo dinamico, ad esempio utilizzando una stringa corta o una costante come chiave, in modo da poter introdurre nuovi valori specifici di equipaggiamento o bonus che non è necessario gestire nel proprio giocatore o "proprietario" in modo specifico.
    • Rispetto alla semplice richiesta dei valori di attacco / difesa appena in tempo, ciò non solo rende il tutto più dinamico, ma consente anche di risparmiare chiamate non necessarie e persino di creare oggetti che influenzeranno il tuo personaggio in modo permanente.

      Ad esempio, immagina un anello maledetto che imposterà alcune statistiche nascoste una volta equipaggiato, contrassegnando il tuo personaggio come maledetto in modo permanente.


7

Mentre @DocBrown ha dato una buona risposta, non va abbastanza lontano, imho. Prima di iniziare a valutare le risposte, è necessario valutare le proprie esigenze. Di cosa hai davvero bisogno ?

Di seguito mostrerò due possibili soluzioni, che offrono diversi vantaggi per esigenze diverse.

Il primo è molto semplicistico e adattato specificamente a ciò che hai mostrato:

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;
    }
}

Questo permette molto una serializzazione / deserializzazione degli strumenti semplice (ad es. Per il salvataggio o il collegamento in rete) e non richiede affatto l'invio virtuale. Se il tuo codice è tutto ciò che hai mostrato e non ti aspetti che evolva molto di più che avere strumenti più diversi con nomi e statistiche diversi, solo in quantità diverse, allora questa è la strada da percorrere.

@DocBrown ha offerto una soluzione che si basa ancora sulla spedizione virtuale e che può essere un vantaggio se in qualche modo specializzi gli strumenti per parti del tuo codice che non sono state mostrate. Tuttavia, se hai davvero bisogno o vuoi cambiare anche altri comportamenti, suggerirei la seguente soluzione:

Composizione sull'eredità

Che cosa succede se in seguito desideri uno strumento che modifica l' agilità ? O correre velocità ? Per me, sembra che tu stia facendo un gioco di ruolo. Una cosa che è importante per i giochi di ruolo è essere aperti per l'estensione . Le soluzioni mostrate fino ad ora non lo offrono. Dovresti modificare ilTool classe e aggiungere nuovi metodi virtuali ogni volta che hai bisogno di un nuovo attributo.

La seconda soluzione che sto mostrando è quella a cui ho accennato in precedenza in un commento: utilizza la composizione anziché l'ereditarietà e segue il principio "chiuso per modifica, aperto per estensione *. Se hai familiarità con il funzionamento dei sistemi di entità, alcune cose sembrerà familiare (mi piace pensare alla composizione come al fratello minore di ES).

Si noti che ciò che sto mostrando di seguito è significativamente più elegante nei linguaggi che hanno informazioni sul tipo di runtime, come Java o C #. Pertanto, il codice C ++ che sto mostrando deve includere un po 'di "contabilità" che è semplicemente necessario per far funzionare la composizione proprio qui. Forse qualcuno con più esperienza in C ++ è in grado di suggerire un approccio ancora migliore.

Innanzitutto, guardiamo di nuovo al lato del chiamante . Nel tuo esempio, tu come chiamante all'interno del attackmetodo non ti preoccupi affatto degli strumenti. Quello che ti interessa sono due proprietà: i punti di attacco e di difesa. Non ti importa davvero da dove vengano, e non ti interessano altre proprietà (ad es. Velocità di corsa, agilità).

Quindi, per prima cosa, introduciamo una nuova classe

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

E quindi, creiamo i nostri primi due componenti

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;
};

Successivamente, facciamo in modo che uno strumento mantenga un insieme di proprietà e rendiamo le proprietà interrogabili da altri.

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();
    }
};

Si noti che in questo esempio, supporto solo un componente di ciascun tipo: questo semplifica le cose. In teoria potresti anche consentire più componenti dello stesso tipo, ma diventa brutto molto velocemente. Un aspetto importante: Toolora è chiuso per modifica - non toccheremo mai più la fonte di Toolnuovo - ma aperto per estensione - possiamo estendere il comportamento di uno strumento modificando altre cose e semplicemente passando altri componenti al suo interno.

Ora abbiamo bisogno di un modo per recuperare gli strumenti per tipo di componente. Puoi ancora usare un vettore per gli strumenti, proprio come nel tuo esempio di codice:

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 << "!";
        }
};

Potresti anche rifattarlo in tuo Inventory classe e archiviare le tabelle di ricerca che semplificano notevolmente il recupero degli strumenti per tipo di componente ed evitano di ripetere ripetutamente l'intera raccolta.

Quali vantaggi ha questo approccio? In attack, elabori strumenti che hanno due componenti: non ti importa di nient'altro.

Immaginiamo di avere un walkTometodo, e ora decidi che è una buona idea se qualche strumento guadagnasse la capacità di modificare la tua velocità di camminata. Nessun problema!

Innanzitutto, crea il nuovo Component:

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

Quindi aggiungi semplicemente un'istanza di questo componente allo strumento che desideri aumentare la velocità di attivazione e modifichi il WalkTometodo per elaborare il componente appena creato:

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;
    }
}

Si noti che abbiamo aggiunto alcuni comportamenti ai nostri strumenti senza modificare affatto la classe Strumenti.

Puoi (e dovresti) spostare le stringhe in una macro o in una variabile const statica, quindi non devi digitarla ancora e ancora.

Se segui ulteriormente questo approccio, ad esempio crea componenti che possono essere aggiunti al giocatore e crea un Combatcomponente che segnala al giocatore la possibilità di partecipare al combattimento, allora puoi anche sbarazzarti del attackmetodo e lasciare che sia gestito dal Componente o essere elaborato altrove.

Il vantaggio di rendere anche il giocatore in grado di ottenere componenti, sarebbe che non avresti nemmeno bisogno di cambiare il giocatore per dargli un comportamento diverso. Nel mio esempio, potresti creare un Movablecomponente, in questo modo non è necessario implementare il walkTometodo sul giocatore per farlo muovere. Dovresti semplicemente creare il componente, collegarlo al lettore e lasciare che qualcun altro lo elabori.

Puoi trovare un esempio completo in questa sintesi: https://gist.github.com/NetzwergX/3a29e1b106c6bb9c7308e89dd715ee20

Questa soluzione è ovviamente un po 'più complessa rispetto agli altri che sono stati pubblicati. Ma a seconda di quanto flessibile vuoi essere, quanto lontano vuoi portarlo, questo può essere un approccio molto potente.

modificare

Alcune altre risposte propongono l'eredità diretta (facendo estendere lo strumento alle spade, facendo estendere lo strumento allo scudo). Non penso che questo sia uno scenario in cui l'ereditarietà funziona molto bene. E se decidessi che il blocco con uno scudo in un certo modo può anche danneggiare l'attaccante? Con la mia soluzione, potresti semplicemente aggiungere un componente Attack a uno scudo e rendertene conto senza alcuna modifica al tuo codice. Con l'eredità, avresti un problema. gli oggetti / strumenti nei giochi di ruolo sono i candidati principali per la composizione o anche direttamente usando i sistemi di entità fin dall'inizio.


1

In generale, se hai mai bisogno di usare if(in combinazione con la richiesta del tipo di istanza) in qualsiasi linguaggio OOP, questo è un segno, che sta succedendo qualcosa di puzzolente. Almeno, dovresti dare un'occhiata più da vicino ai tuoi modelli.

Modellerei il tuo dominio in modo diverso.

Per il tuo caso a Toolha un AttackBonuse un DefenseBonus- che potrebbero essere entrambi 0nel caso in cui sia inutile combattere come piume o qualcosa del genere.

Per un attacco, hai il tuo baserate+ bonusdall'arma usata. Lo stesso vale per la difesa baserate+ bonus.

Di conseguenza devi Toolavere un virtualmetodo per calcolare l'attacco / difesa boni.

tl; dr

Con un design migliore, si potrebbe evitare la confusione if.


A volte è necessario un if, ad esempio quando si confrontano valori scalari. Per la commutazione del tipo di oggetto, non così tanto.
Andy,

Haha, se è un operatore piuttosto essenziale e non si può semplicemente dire che usare è un odore di codice.
Tymtam,

1
@Tymski per un certo rispetto hai ragione. Mi sono reso più chiaro. Non sto difendendo ifmeno programmazione. Principalmente in combinazioni come se instanceofo qualcosa del genere. Ma c'è una posizione, che sostiene che ifsono un codice e ci sono modi per aggirarlo. E hai ragione, questo è un operatore essenziale che ha i suoi diritti.
Thomas Junk,

1

Come scritto, "odora", ma potrebbero essere solo gli esempi che hai dato. Memorizzare i dati in contenitori di oggetti generici, quindi trasmetterli per ottenere l'accesso ai dati non significa automaticamente odore di codice. Lo vedrai usato in molte situazioni. Tuttavia, quando lo usi, dovresti essere consapevole di ciò che stai facendo, come lo stai facendo e perché. Quando guardo l'esempio, l'uso di confronti basati su stringhe per dirmi quale oggetto è qual è la cosa che fa scattare il mio misuratore di odore personale. Suggerisce che non sei del tutto sicuro di cosa stai facendo qui (il che va bene, dal momento che hai avuto la saggezza di venire qui ai programmatori.SE e dire "ehi, non penso che mi piace quello che sto facendo, aiuto me fuori! ").

Il problema fondamentale con il modello di trasmissione dei dati da contenitori generici come questo è che il produttore dei dati e il consumatore dei dati devono lavorare insieme, ma potrebbe non essere ovvio che lo facciano a prima vista. In ogni esempio di questo modello, puzzolente o non puzzolente, questo è il problema fondamentale. È molto probabile che il prossimo sviluppatore sia completamente inconsapevole del fatto che stai eseguendo questo schema e lo rompa per caso, quindi se usi questo schema devi prenderti cura di aiutare lo sviluppatore successivo. Devi renderlo più facile per lui non rompere involontariamente il codice a causa di alcuni dettagli che potrebbe non sapere esistessero.

Ad esempio, se volessi copiare un lettore? Se guardo solo il contenuto dell'oggetto giocatore, sembra abbastanza facile. Non mi resta che copiare i attack, defensee toolsle variabili. Facile come una torta! Bene, scoprirò rapidamente che l'uso dei puntatori lo rende un po 'più difficile (ad un certo punto, vale la pena guardare i puntatori intelligenti, ma questo è un altro argomento). Questo è facilmente risolvibile. Creerò solo nuove copie di ogni strumento e le inserirò nel mio nuovo toolselenco. Dopo tutto, Toolè una classe davvero semplice con un solo membro. Quindi creo un mucchio di copie, inclusa una copia del Sword, ma non sapevo che fosse una spada, quindi ho solo copiato il name. Più tardi, la attack()funzione guarda il nome, vede che è una "spada", la lancia e accadono cose brutte!

Possiamo confrontare questo caso con un altro caso nella programmazione socket, che utilizza lo stesso modello. Posso impostare una funzione socket UNIX come questa:

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));

Perché questo è lo stesso modello? Perché bindnon accetta un sockaddr_in*, accetta un più generico sockaddr*. Se guardi le definizioni di quelle classi, vediamo che sockaddrha un solo membro della famiglia che abbiamo assegnato a sin_family*. La famiglia dice a quale sottotipo dovresti lanciare sockaddr. AF_INETti dice che la struttura dell'indirizzo è in realtà un sockaddr_in. Se lo fosse AF_INET6, l'indirizzo sarebbe a sockaddr_in6, che ha campi più grandi per supportare gli indirizzi IPv6 più grandi.

Questo è identico al tuo Toolesempio, tranne per il fatto che utilizza un numero intero per specificare quale famiglia anziché a std::string. Tuttavia, ho intenzione di affermare che non ha odore, e cercherò di farlo per ragioni diverse da "è un modo standard di fare prese, quindi non dovrebbe 'odorare". "Ovviamente è lo stesso schema, che è perché sostengo che archiviare i dati in oggetti generici e trasmetterli non sia automaticamente un odore di codice, ma ci sono alcune differenze nel modo in cui lo fanno che lo rendono più sicuro.

Quando si utilizza questo modello, l'informazione più importante è catturare la trasmissione di informazioni sulla sottoclasse dal produttore al consumatore. Questo è ciò che stai facendo con il namecampo e i socket UNIX fanno con il loro sin_familycampo. Quel campo è l'informazione di cui il consumatore ha bisogno per capire cosa ha effettivamente creato il produttore. In tutti i casi di questo modello, dovrebbe essere un'enumerazione (o almeno un numero intero che agisce come un'enumerazione). Perché? Pensa a cosa il consumatore farà con le informazioni. Avranno bisogno di aver scritto qualcosa di grossoif dichiarazione o unswitchcome hai fatto, in cui determinano il sottotipo corretto, lo trasmettono e usano i dati. Per definizione, ci può essere solo un piccolo numero di questi tipi. Puoi memorizzarlo in una stringa, come hai fatto, ma questo ha numerosi svantaggi:

  • Lento - in std::stringgenere deve fare un po 'di memoria dinamica per mantenere la stringa. Devi anche fare un confronto full-text per abbinare il nome ogni volta che vuoi capire quale sottoclasse hai.
  • Troppo versatile - C'è qualcosa da dire per mettere dei vincoli su te stesso quando fai qualcosa di estremamente pericoloso. Ho avuto sistemi come questo che cercavano una sottostringa per dirgli che tipo di oggetto stava guardando. Funzionò alla grande fino a quando il nome di un oggetto non conteneva per errore quella sottostringa e creava un errore terribilmente criptico. Dal momento che, come abbiamo detto sopra, abbiamo solo bisogno di un piccolo numero di casi, non c'è motivo di usare uno strumento fortemente sopraffatto come le stringhe. Questo porta a...
  • A rischio di errore - Diciamo solo che vorrai scatenare una furia omicida cercando di eseguire il debug del motivo per cui le cose non funzionano quando un consumatore imposta accidentalmente il nome di un panno magico MagicC1oth. Seriamente, bug del genere possono richiedere giorni di grattarsi la testa prima di rendersi conto di ciò che è successo.

Un'enumerazione funziona molto meglio. È veloce, economico e molto meno soggetto a errori:

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!");
        }
   }
};

Questo esempio mostra anche switchun'affermazione che coinvolge gli enum, con la parte più importante di questo modello: un defaultcaso che genera. Non dovresti mai entrare in quella situazione se fai le cose alla perfezione. Tuttavia, se qualcuno aggiunge un nuovo tipo di strumento e si dimentica di aggiornare il codice per supportarlo, si vorrà qualcosa per rilevare l'errore. In effetti, li consiglio così tanto che dovresti aggiungerli anche se non ne hai bisogno.

L'altro enorme vantaggio di questo enumè che offre allo sviluppatore successivo un elenco completo di tipi di strumenti validi, fin dall'inizio. Non c'è bisogno di passare in rassegna il codice per trovare la classe di flauto specializzata di Bob che usa nella sua epica battaglia con il boss.

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
    }
}

Sì, ho inserito un'istruzione predefinita "vuota", solo per rendere esplicito allo sviluppatore successivo ciò che mi aspetto che accada se un nuovo tipo imprevisto mi viene incontro.

Se lo fai, il motivo avrà un odore inferiore. Tuttavia, per essere senza odore, l'ultima cosa che devi fare è considerare le altre opzioni. Questi cast sono alcuni degli strumenti più potenti e pericolosi che hai nel repertorio C ++. Non dovresti usarli a meno che tu non abbia una buona ragione.

Un'alternativa molto popolare è quella che chiamo una "struttura sindacale" o "classe sindacale". Per il tuo esempio, questo sarebbe davvero un'ottima soluzione. Per crearne uno, crei una Toolclasse, con un elenco come prima, ma invece di sottoclassare Tool, inseriamo semplicemente tutti i campi di ogni sottotipo.

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

    int   attack;
    int   defense;
};

Ora non hai affatto bisogno di sottoclassi. Devi solo guardare il typecampo per vedere quali altri campi sono effettivamente validi. Questo è molto più sicuro e più facile da capire. Tuttavia, ha degli svantaggi. Ci sono momenti in cui non vuoi usare questo:

  • Quando gli oggetti sono troppo diversi - È possibile finire con un elenco di campi di lavanderia e può non essere chiaro quali si applicano a ciascun tipo di oggetto.
  • Quando si opera in una situazione critica della memoria - Se è necessario creare 10 strumenti, si può essere pigri con la memoria. Quando devi creare 500 milioni di strumenti, inizierai a preoccuparti di bit e byte. Le strutture dell'Unione sono sempre più grandi di quanto debbano essere.

Questa soluzione non viene utilizzata dai socket UNIX a causa del problema di dissomiglianza aggravato dall'estremità aperta dell'API. L'intento con i socket UNIX era quello di creare qualcosa con cui ogni sapore di UNIX potesse funzionare. Ogni sapore potrebbe definire l'elenco delle famiglie che supportano, come AF_INET, e ci sarebbe un breve elenco per ciascuno. Tuttavia, se arriva un nuovo protocollo, come è stato AF_INET6fatto, potrebbe essere necessario aggiungere nuovi campi. Se lo facessi con una struttura sindacale, finiresti per creare effettivamente una nuova versione della struttura con lo stesso nome, creando infiniti problemi di incompatibilità. Questo è il motivo per cui i socket UNIX hanno scelto di utilizzare il modello di fusione piuttosto che una struttura di unione. Sono sicuro che lo hanno preso in considerazione e il fatto che ci abbiano pensato è parte del motivo per cui non ha odore quando lo usano.

Potresti anche usare un'unione per davvero. I sindacati risparmiano memoria, essendo solo più grandi del membro più grande, ma hanno i loro problemi. Questa probabilmente non è un'opzione per il tuo codice, ma è sempre un'opzione che dovresti considerare.

Un'altra soluzione interessante è boost::variant. Boost è una grande libreria piena di soluzioni multipiattaforma riutilizzabili. È probabilmente uno dei migliori codici C ++ mai scritti. Boost.Variant è fondamentalmente la versione C ++ dei sindacati. È un contenitore che può contenere molti tipi diversi, ma solo uno alla volta. Potresti fare il tuoSword , Shielde le MagicClothclassi, quindi rendere lo strumento un boost::variant<Sword, Shield, MagicCloth>, nel senso che contiene uno di questi tre tipi. Ciò presenta ancora lo stesso problema con la compatibilità futura che impedisce ai socket UNIX di usarlo (per non parlare dei socket UNIX sono C, che precedeboostun bel po '!), ma questo schema può essere incredibilmente utile. La variante viene spesso utilizzata, ad esempio, negli alberi di analisi, che accettano una stringa di testo e la suddividono utilizzando una grammatica per le regole.

La soluzione finale che consiglierei di guardare prima di fare un tuffo e usare l'approccio di casting di oggetti generico è il modello di progettazione di Visitor . Il visitatore è un potente modello di progettazione che sfrutta l'osservazione che chiamare una funzione virtuale fa effettivamente il casting di cui hai bisogno e lo fa per te. Perché il compilatore lo fa, non può mai essere sbagliato. Pertanto, invece di archiviare un enum, Visitor utilizza una classe base astratta, che ha una vtable che conosce il tipo di oggetto. Quindi creiamo una piccola chiamata ordinata a doppia indiretta che fa il lavoro:

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;
};

Allora, qual è questo modello maestoso di Dio? Beh, Toolha una funzione virtuale, accept. Se gli passi un visitatore, si prevede che si giri e chiami la visitfunzione corretta su quel visitatore per il tipo. Questo è ciò che visitor.visit(*this);fa su ciascun sottotipo. Complicato, ma possiamo mostrarlo con il tuo esempio sopra:

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
}

Quindi cosa succede qui? Creiamo un visitatore che farà un po 'di lavoro per noi, una volta che sa quale tipo di oggetto sta visitando. Passiamo quindi all'elenco degli strumenti. Per l'argomento, supponiamo che il primo oggetto sia a Shield, ma il nostro codice non lo sa ancora. Chiama t->accept(v), una funzione virtuale. Poiché il primo oggetto è uno scudo, finisce per chiamare void Shield::accept(ToolVisitor& visitor), che chiama visitor.visit(*this);. Ora, quando stiamo cercando quale visitchiamare, sappiamo già che abbiamo uno scudo (perché questa funzione è stata chiamata), quindi finiremo per chiamare void ToolVisitor::visit(Shield* shield)il nostro AttackVisitor. Questo ora esegue il codice corretto per aggiornare la nostra difesa.

Il visitatore è ingombrante. È così goffo che quasi penso che abbia un suo odore. È molto facile scrivere modelli di visitatori cattivi. Tuttavia, ha un enorme vantaggio che nessuno degli altri ha. Se aggiungiamo un nuovo tipo di strumento, dobbiamo aggiungere una nuova ToolVisitor::visitfunzione per esso. Nel momento in cui lo facciamo, tutti ToolVisitor i programmi si rifiuteranno di compilare perché manca una funzione virtuale. Questo rende molto facile catturare tutti i casi in cui abbiamo perso qualcosa. È molto più difficile garantire che se usi ifo switchaffermazioni per fare il lavoro. Questi vantaggi sono abbastanza buoni che Visitor ha trovato una piccola nicchia nei generatori di scene grafiche 3D. Capita di aver bisogno esattamente del tipo di comportamento offerto dai visitatori, quindi funziona alla grande!

Nel complesso, ricorda che questi schemi rendono difficile il prossimo sviluppatore. Dedica del tempo a renderli più facili e il codice non avrà alcun odore!

* Tecnicamente, se guardi le specifiche, sockaddr ha un membro chiamato sa_family. C'è qualcosa di complicato fatto qui a livello C che non ha importanza per noi. Sei il benvenuto a guardare l' implementazione effettiva , ma per questa risposta userò sa_family sin_familye altri completamente intercambiabili, usando quello che è più intuitivo per la prosa, confidando che l'inganno C si occupi dei dettagli non importanti.


Attaccare consecutivamente renderà il giocatore infinitamente forte nel tuo esempio. E non puoi estendere il tuo approccio senza modificare la fonte di ToolVisitor. È un'ottima soluzione, comunque.
Poligono

@Polygnome Hai ragione sull'esempio. Ho pensato che il codice sembrava strano, ma scorrendo tutte quelle pagine di testo ho perso l'errore. Risolvendolo ora. Per quanto riguarda il requisito di modifica della fonte di ToolVisitor, si tratta di una caratteristica di progettazione del modello di visitatori. È una benedizione (come ho scritto) e una maledizione (come hai scritto). Gestire il caso in cui si desidera una versione arbitrariamente estendibile di questo è molto più difficile e inizia a scavare sul significato delle variabili, piuttosto che solo sul loro valore, e apre altri schemi, come variabili e dizionari debolmente tipizzati e JSON.
Cort Ammon - Ripristina Monica il

1
Sì, purtroppo non sappiamo abbastanza delle preferenze e degli obiettivi dei PO per prendere una decisione davvero informata. E sì, una soluzione completamente flessibile è più difficile da implementare, ho lavorato sulla mia risposta per quasi 3 ore, dal momento che il mio C ++ è piuttosto arrugginito :(
Polygnome,

0

In generale, evito di implementare diverse classi / ereditarietà solo per comunicare dati. Puoi attenersi a una singola classe e implementare tutto da lì. Per il tuo esempio, questo è abbastanza

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

Probabilmente stai anticipando che il tuo gioco implementerà diversi tipi di spade, ecc. Ma avrai altri modi per implementarlo. L'esplosione di classe è raramente la migliore architettura. Mantienilo semplice.


0

Come precedentemente affermato, questo è un odore di codice serio. Tuttavia, si potrebbe considerare che l'origine del problema sia l'ereditarietà anziché la composizione nel progetto.

Ad esempio, dato quello che ci hai mostrato, hai chiaramente 3 concetti:

  • Articolo
  • Oggetto che può avere un attacco.
  • Oggetto che può avere difesa.

Nota che la tua quarta classe è solo una combinazione degli ultimi due concetti. Quindi suggerirei di usare la composizione per questo.

È necessaria una struttura di dati per rappresentare le informazioni necessarie per l'attacco. E hai bisogno di una struttura di dati che rappresenti le informazioni necessarie per la difesa. Infine, è necessaria una struttura di dati per rappresentare elementi che possono o meno avere una o entrambe queste proprietà:

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_;}
};

Inoltre: non usare un approccio componi-sempre :)! Perché usare la composizione in questo caso? Sono d'accordo che si tratta di una soluzione alternativa, ma creare una classe per "incapsulare" un campo (notare il "") sembra strano in questo caso ...
AilurusFulgens,

@AilurusFulgens: è "un campo" oggi. Che sarà domani? Questo design consente Attacke Defensediventa più complicato senza cambiare l'interfaccia di Tool.
Nicol Bolas,

1
Non puoi ancora estendere molto bene Tool con questo - certo, l'attacco e la difesa potrebbero diventare più complessi, ma è tutto. Se usi la composizione al massimo della potenza, potresti renderla Toolcompletamente chiusa per modifiche pur lasciandola aperta per l'estensione.
Polygnome,

@Polygnome: se vuoi affrontare il problema di creare un intero sistema di componenti arbitrari per un caso banale come questo, dipende da te. Personalmente non vedo alcun motivo per cui vorrei estenderlo Toolsenza modificarlo . E se ho il diritto di modificarlo, non vedo la necessità di componenti arbitrari.
Nicol Bolas,

Finché Tool è sotto il tuo controllo, puoi modificarlo. Ma il principio "chiuso per modifica, aperto per estensione" esiste per una buona ragione (troppo lungo per essere elaborato qui). Non penso che sia così banale, però. Se passi il tempo giusto a pianificare un sistema di componenti flessibile per un gioco di ruolo, otterrai enormi vantaggi a lungo termine. Non vedo il vantaggio aggiunto in questo tipo di composizione rispetto all'uso dei semplici campi. Essere in grado di specializzare ulteriormente l'attacco e la difesa sembra essere uno scenario molto teorico. Ma come ho scritto, dipende dai requisiti esatti del PO.
Polygnome,

0

Perché non creare metodi astratti modifyAttacke modifyDefensein Toolclasse? Quindi ogni bambino avrebbe la propria implementazione e tu chiamerai questo modo elegante:

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

Il passaggio di valori come riferimento consente di risparmiare risorse se è possibile:

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

0

Se si usa il polimorfismo, è sempre meglio se tutto il codice che interessa a quale classe viene utilizzata sia all'interno della classe stessa. Ecco come lo codificherei:

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;
};

Ciò presenta i seguenti vantaggi:

  • più facile aggiungere nuove classi: devi solo implementare tutti i metodi astratti e il resto del codice funziona
  • più facile da rimuovere le classi
  • più facile aggiungere nuove statistiche (le classi che non si preoccupano della statistica semplicemente la ignorano)

Dovresti almeno includere anche un metodo unequip () che rimuove il bonus dal giocatore.
Polygnome,

0

Penso che un modo per riconoscere i difetti di questo approccio sia sviluppare la tua idea fino alla sua logica conclusione.

Sembra un gioco, quindi a un certo punto probabilmente inizierai a preoccuparti delle prestazioni e scamberai i confronti di stringhe con un into enum. Man mano che l'elenco degli articoli si allunga, if-elseinizia a diventare piuttosto ingombrante, quindi potresti considerare di rifattorizzarlo in a switch-case. A questo punto hai anche un bel muro di testo, quindi potresti interrompere l'azione all'interno di ciascuno casein una funzione separata.

Una volta raggiunto questo punto, la struttura del codice inizia a sembrare familiare - inizia a sembrare una vtable homebrew, arrotolata a mano * - la struttura di base su cui vengono generalmente implementati i metodi virtuali. Tranne, questa è una tabella che è necessario aggiornare e mantenere manualmente, ogni volta che si aggiunge o si modifica un tipo di elemento.

Attenendosi a funzioni virtuali "reali", si è in grado di mantenere l'implementazione del comportamento di ciascun oggetto all'interno dell'articolo stesso. È possibile aggiungere ulteriori elementi in modo più autonomo e coerente. E mentre fai tutto questo, è il compilatore che si occuperà dell'implementazione del tuo invio dinamico, piuttosto che di te.

Per risolvere il tuo problema specifico: stai lottando per scrivere una semplice coppia di funzioni virtuali per l'aggiornamento dell'attacco e della difesa perché alcuni oggetti influenzano solo l'attacco e altri influenzano solo la difesa. Il trucco in un caso semplice come questo è implementare comunque entrambi i comportamenti, ma in alcuni casi senza alcun effetto. GetDefenseBonus()potrebbe tornare 0o ApplyDefenseBonus(int& defence)potrebbe rimanere defenceinvariato. Il modo in cui lo fai dipenderà da come vuoi gestire le altre azioni che hanno un effetto. In casi più complessi, in cui esiste un comportamento più vario, è possibile semplicemente combinare l'attività in un singolo metodo.

* (Anche se trasposto rispetto all'implementazione tipica)


0

Avere un blocco di codice che conosce tutti i possibili "strumenti" non è un ottimo progetto (specialmente perché finirai con molti di questi blocchi nel tuo codice); ma nessuno dei due ha uno Toolstub di base per tutte le possibili proprietà degli strumenti: ora la Toolclasse deve conoscere tutti i possibili usi.

Ciò che ogni strumento sa è ciò che può contribuire al personaggio che lo utilizza. Così fornire un metodo per tutti gli strumenti, giveto(*Character owner). Adatterà le statistiche del giocatore come appropriato senza sapere cosa possono fare gli altri strumenti e, cosa c'è di meglio, non ha nemmeno bisogno di conoscere le proprietà irrilevanti del personaggio. Ad esempio, uno scudo non ha nemmeno bisogno di conoscere gli attributi attack, invisibility, healthecc tutto ciò che serve per applicare uno strumento è per il personaggio di sostenere gli attributi che l'oggetto richiede. Se provi a dare una spada a un asino e l'asino non ha attackstatistiche, otterrai un errore.

Gli strumenti dovrebbero anche avere un remove()metodo, che inverte il loro effetto sul proprietario. Questo è un po 'complicato (è possibile finire con strumenti che lasciano un effetto diverso da zero quando vengono dati e poi portati via), ma almeno è localizzato per ogni strumento.


-4

Non c'è risposta che dica che non ha odore, quindi sarò io a sostenere quell'opinione; questo codice va benissimo! La mia opinione si basa sul fatto che a volte è più facile andare avanti e consentire alle tue abilità di aumentare gradualmente mentre crei più cose nuove. Puoi rimanere bloccato per giorni a realizzare un'architettura perfetta, ma probabilmente nessuno lo vedrà mai in azione, perché non hai mai finito il progetto. Saluti!


4
Migliorare le competenze con l'esperienza personale è buono, certo. Ma migliorare le competenze chiedendo alle persone che hanno già quell'esperienza personale, quindi non è necessario cadere da soli, è più intelligente. Sicuramente questo è il motivo per cui le persone fanno domande qui in primo luogo, non è vero?
Graham,

Non sono d'accordo Ma capisco che questo sito è tutto incentrato. A volte ciò significa essere eccessivamente pedanti. Questo è il motivo per cui ho voluto pubblicare questa opinione, perché è ancorato alla realtà e se stai cercando consigli per migliorare e aiutare un principiante, perdi l'intero capitolo su "abbastanza buono" che è abbastanza utile per un principiante.
Ostmeistro,
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.