Comme écrit, ça "sent", mais ce ne sont peut-être que des exemples que vous avez donnés. Stocker des données dans des conteneurs d'objets génériques, puis les diffuser pour accéder aux données ne constitue pas automatiquement une odeur de code. Vous verrez qu'il est 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 odomètre. Cela suggère que vous n'êtes pas tout à fait sûr de ce que vous faites ici (ce qui est bien, car vous avez eu la sagesse de venir ici pour les programmeurs. SE et dire "hé, je ne pense pas que j'aime ce que je fais, aide moi dehors! ").
Le problème fondamental du modèle de diffusion de données à partir de conteneurs génériques, comme celui-ci, est que le producteur et le consommateur de données doivent travailler ensemble, mais il n'est peut-être pas évident qu'ils le fassent à première vue. Dans chaque exemple de ce modèle, malodorant ou non, c'est l'enjeu fondamental. Il est très possible que le prochain développeur ignore complètement que vous suivez ce modèle et le casse par accident. Par conséquent, si vous utilisez ce modèle, vous devez prendre soin d'aider le prochain développeur. Vous devez lui faciliter la tâche pour qu'il ne perde pas le code par inadvertance en raison de détails dont il ne sait peut-être pas qu'ils existaient.
Par exemple, si je voulais copier un lecteur? Si je ne regarde que le contenu de l'objet player, cela semble assez facile. Je viens de copier les attack
, defense
et les tools
variables. C'est de la tarte! Eh bien, je découvrirai rapidement que votre utilisation des pointeurs rend la tâche un peu plus difficile (à un moment donné, cela vaut la peine de regarder les pointeurs intelligents, mais c’est un autre sujet). C'est facilement résolu. Je vais simplement créer de nouvelles copies de chaque outil et les mettre dans ma nouvelle tools
liste. Après tout, Tool
c'est un cours très simple avec un seul membre. Donc, je crée un tas de copies, y compris une copie de la Sword
, mais je ne savais pas que c’était une épée, je n’ai donc que copié la name
. Plus tard, la attack()
fonction regarde le nom, voit que c'est une "épée", la lance, et de mauvaises choses arrivent!
Nous pouvons comparer ce cas à un autre cas en programmation de socket, qui utilise le même motif. Je peux configurer une fonction de socket UNIX comme ceci:
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 modèle? Parce que bind
n'accepte pas un sockaddr_in*
, il accepte un plus générique sockaddr*
. Si vous regardez les définitions de ces classes, nous voyons qu’un sockaddr
seul membre est la famille à laquelle nous avons attribué sin_family
*. La famille dit à quel sous-type vous devez lancer le sockaddr
. AF_INET
vous dit que l'adresse est une structure sockaddr_in
. Si c'était le cas AF_INET6
, l'adresse serait a sockaddr_in6
, avec des champs plus grands pour prendre en charge les adresses IPv6 plus grandes.
Ceci est identique à votre Tool
exemple, sauf qu’il utilise un entier pour spécifier quelle famille plutôt que un std::string
. Cependant, je vais prétendre que ça ne sent pas, et essayer de le faire pour des raisons autres que "c'est un moyen standard de faire des sockets, alors ça ne devrait pas" sentir ". pourquoi je prétends que le stockage de données dans des objets génériques et leur transposition ne constituent pas automatiquement une odeur de code, mais il existe quelques différences dans la manière dont ils le font qui le rendent plus sûr.
Lorsque vous utilisez ce modèle, les informations les plus importantes sont la capture des informations sur la sous-classe, du producteur au consommateur. C'est ce que vous faites avec le name
champ et les sockets UNIX avec leur sin_family
champ. Ce champ est l’information dont le consommateur a besoin pour comprendre ce que le producteur a réellement créé. Dans tous les cas de ce modèle, il devrait s'agir d'une énumération (ou à tout le moins, d'un entier agissant comme une énumération). Pourquoi? Pensez à ce que votre consommateur va faire avec l'information. Ils vont avoir besoin d'avoir écrit un grosif
déclaration ou unswitch
déclaration, comme vous l'avez fait, où ils déterminent le bon sous-type, 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 - il faut
std::string
généralement faire de la mémoire dynamique pour conserver la chaîne. Vous devez également faire une comparaison de texte intégral pour faire correspondre le nom à chaque fois que vous souhaitez déterminer votre sous-classe.
- 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 fonctionné jusqu'à ce que le nom d'un objet contienne accidentellement cette sous-chaîne et crée une erreur terriblement cryptique. Comme, comme nous l’avons indiqué ci-dessus, nous n’avons besoin que d’un petit nombre de cas, il n’ya aucune raison d’utiliser un outil extrêmement puissant comme les chaînes. Cela mène à...
- Susceptible d'erreurs - Disons simplement que vous voudrez vous lancer dans un déchaînement meurtrier pour essayer de résoudre les problèmes qui surviennent lorsqu’un consommateur donne accidentellement le nom d’un vêtement magique
MagicC1oth
. Sérieusement, des insectes comme ceux-là peuvent prendre des jours avant que vous ne réalisiez ce qui s'est passé.
Une énumération fonctionne beaucoup mieux. C'est rapide, pas cher 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 switch
déclaration impliquant les enums, avec la partie la plus importante de ce modèle: un default
cas qui se déclenche. Vous ne devriez jamais vous retrouver dans cette situation si vous faites les choses parfaitement. Toutefois, si quelqu'un ajoute un nouveau type d'outil et que vous oubliez de mettre à jour votre code pour le prendre en charge, vous souhaiterez que quelque chose corrige l'erreur. En fait, je les recommande tellement que vous devriez les ajouter même si vous n'en avez pas besoin.
L’autre grand avantage de ce logiciel enum
réside dans le fait qu’il offre immédiatement au développeur suivant une liste complète des types d’outils valides. 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 son combat épique contre le 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
}
}
Oui, j'ai inséré une instruction par défaut "vide", simplement pour que le prochain développeur sache clairement ce que j'attend si de nouveaux types inattendus se présentent à moi.
Si vous faites cela, le motif sentira moins. Toutefois, pour éliminer les odeurs, la dernière chose à faire est d’envisager les autres options. Ces moulages font partie des outils les plus puissants et les plus dangereux du répertoire C ++. Vous ne devriez 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 un, vous créez une Tool
classe, avec une énumération comme avant, mais au lieu de sous Tool
- classer , nous mettons simplement tous les champs de chaque sous-type.
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 type
champ 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 exhaustive de champs, et il est 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 syndicales sont toujours plus grandes que nécessaire.
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 des sockets UNIX était de créer quelque chose avec lequel chaque version d'UNIX pourrait fonctionner. Chaque type pourrait définir la liste des familles qu’il soutient, par exemple AF_INET
, et il y aurait une courte liste pour chacun. Toutefois, si un nouveau protocole se présente AF_INET6
, vous devrez peut-être ajouter de nouveaux champs. Si vous faisiez cela avec une structure d'union, vous créeriez effectivement 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 casting plutôt qu'une structure d'union. Je suis sûr qu'ils y ont pensé et le fait d'y avoir pensé explique en partie pourquoi ça ne sent pas quand ils l'utilisent.
Vous pouvez également utiliser un syndicat pour de vrai. Les syndicats économisent de la mémoire en étant aussi gros que le membre le plus important, mais ils ont leurs propres problèmes. Ce n'est probablement pas une option pour votre code, mais c'est toujours une option à considérer.
Une autre solution intéressante est boost::variant
. Boost est une excellente bibliothèque contenant de nombreuses solutions multiplates-formes réutilisables. C'est probablement l'un des meilleurs codes C ++ jamais écrits. Boost.Variant est essentiellement la version C ++ des unions. C'est un conteneur qui peut contenir plusieurs types différents, mais un seul à la fois. Vous pouvez créer votre Sword
, Shield
et vos MagicCloth
classes, puis transformer l'outil en outil boost::variant<Sword, Shield, MagicCloth>
, ce qui signifie qu'il contient l'un de ces trois types. Ceci a toujours le même problème avec la compatibilité future qui empêche les sockets UNIX de l’utiliser (sans parler des sockets UNIX en C,boost
un peu!), mais ce modèle peut être incroyablement utile. La variante est souvent utilisée, par exemple, dans les arborescences d’analyse syntaxique, qui prennent une chaîne de texte et la divisent en utilisant une grammaire pour les règles.
La solution finale que je vous conseillerais de regarder avant de plonger et d'utiliser l'approche générique de casting d'objet est le modèle de conception Visitor . Visiteur est un modèle de conception puissant qui tire parti de l'observation selon laquelle l'appel d'une fonction virtuelle effectue effectivement le casting dont vous avez besoin, et il le fait pour vous. Parce que le compilateur le fait, cela ne peut jamais être faux. Ainsi, au lieu de stocker une énumération, Visitor utilise une classe de base abstraite, qui a une table virtuelle qui sait quel type d'objet est. Nous créons ensuite un joli petit appel double-indirection 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 dieu aweful? Eh bien, Tool
a une fonction virtuelle, accept
. Si vous transmettez un visiteur, il est censé se retourner et appeler la visit
fonction correcte sur ce visiteur pour le type. C'est ce que l'on visitor.visit(*this);
fait 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 qu'est-ce qui se passe ici? Nous créons un visiteur qui travaillera pour nous une fois qu'il aura identifié le type d'objet visité. Nous parcourons ensuite la liste des outils. Par argument, supposons que le premier objet soit un Shield
, mais notre code ne le sait pas encore. Cela appelle t->accept(v)
une fonction virtuelle. Comme le premier objet est un bouclier, il finit par appeler void Shield::accept(ToolVisitor& visitor)
, ce qui appelle visitor.visit(*this);
. Maintenant, quand nous cherchons où 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)
notre AttackVisitor
. Ceci exécute maintenant le code correct pour mettre à jour notre défense.
Le visiteur est volumineux. C'est tellement maladroit que je pense presque qu'il a une odeur qui lui est propre. Il est très facile d'écrire de mauvais modèles de visiteurs. Cependant, il possède un énorme avantage qu'aucun des autres n'a. Si nous ajoutons un nouveau type d’outil, nous devons lui ajouter une nouvelle ToolVisitor::visit
fonction. Dès que nous faisons cela, chaque ToolVisitor
programme refusera de compiler car il manque une fonction virtuelle. Cela rend très facile d'attraper tous les cas où nous avons manqué quelque chose. Il est beaucoup plus difficile de garantir que si vous utilisez if
ou des switch
déclarations pour faire le travail. Ces avantages sont suffisants pour que Visit ait trouvé une belle petite niche dans les générateurs de scènes graphiques 3D. Ils ont besoin du type de comportement que propose Visiteur pour que cela fonctionne bien!
En tout, rappelez-vous que ces schémas rendent la tâche difficile au prochain développeur. Passez du temps à leur faciliter la tâche et le code ne sentira pas!
* Techniquement, si vous regardez les spécifications, sockaddr a un membre nommé sa_family
. Au niveau C, nous faisons quelque chose de délicat qui importe peu pour nous. Vous pouvez regarder l’ implémentation réelle , mais pour cette réponse, je vais sa_family
sin_family
utiliser celle qui est la plus intuitive pour la prose, de manière totalement interchangeable, en sachant que cette supercherie C s’occupe des détails sans importance.