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
, defense
e tools
le 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 tools
elenco. 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é bind
non accetta un sockaddr_in*
, accetta un più generico sockaddr*
. Se guardi le definizioni di quelle classi, vediamo che sockaddr
ha un solo membro della famiglia che abbiamo assegnato a sin_family
*. La famiglia dice a quale sottotipo dovresti lanciare sockaddr
. AF_INET
ti 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 Tool
esempio, 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 name
campo e i socket UNIX fanno con il loro sin_family
campo. 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 unswitch
come 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::string
genere 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 switch
un'affermazione che coinvolge gli enum, con la parte più importante di questo modello: un default
caso 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 Tool
classe, 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 type
campo 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_INET6
fatto, 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
, Shield
e le MagicCloth
classi, 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 precedeboost
un 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, Tool
ha una funzione virtuale, accept
. Se gli passi un visitatore, si prevede che si giri e chiami la visit
funzione 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 visit
chiamare, 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::visit
funzione 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 if
o switch
affermazioni 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_family
e altri completamente intercambiabili, usando quello che è più intuitivo per la prosa, confidando che l'inganno C si occupi dei dettagli non importanti.