Como está escrito, "cheira", mas esses podem ser apenas os exemplos que você deu. Armazenar dados em contêineres de objetos genéricos e depois convertê-los para obter acesso aos dados não é automaticamente um cheiro de código. Você o verá usado em muitas situações. No entanto, quando você o usa, deve estar ciente do que está fazendo, como está fazendo e por quê. Quando olho para o exemplo, o uso de comparações baseadas em seqüências de caracteres para me dizer qual objeto é o que aciona meu medidor de cheiro pessoal. Isso sugere que você não tem muita certeza do que está fazendo aqui (o que é bom, pois você teve a sabedoria de vir aqui para os programadores. SE e diga "ei, eu não acho que gosto do que estou fazendo, ajude estou fora!").
A questão fundamental com o padrão de transmissão de dados de contêineres genéricos como esse é que o produtor dos dados e o consumidor dos dados devem trabalhar juntos, mas pode não ser óbvio que eles o façam à primeira vista. Em todos os exemplos desse padrão, fedorento ou não fedido, esta é a questão fundamental. É muito possível que o próximo desenvolvedor não saiba que você está executando esse padrão e quebre-o acidentalmente; portanto, se você usar esse padrão, tenha cuidado para ajudar o próximo desenvolvedor. Você precisa facilitar a quebra do código acidentalmente devido a alguns detalhes que ele talvez não saiba que existem.
Por exemplo, e se eu quisesse copiar um jogador? Se eu apenas olhar o conteúdo do objeto player, parece bem fácil. Eu só tenho que copiar os attack
, defense
e tools
variáveis. Fácil como torta! Bem, descobrirei rapidamente que o uso de ponteiros torna um pouco mais difícil (em algum momento, vale a pena olhar para ponteiros inteligentes, mas esse é outro tópico). Isso é facilmente resolvido. Vou apenas criar novas cópias de cada ferramenta e colocá-las na minha nova tools
lista. Afinal, Tool
é uma aula realmente simples, com apenas um membro. Então, eu crio um monte de cópias, incluindo uma cópia do Sword
, mas eu não sabia que era uma espada, então apenas copiei o name
. Mais tarde, a attack()
função olha para o nome, vê que é uma "espada", lança-a e coisas ruins acontecem!
Podemos comparar esse caso com outro caso na programação de soquetes, que usa o mesmo padrão. Eu posso configurar uma função de soquete UNIX assim:
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));
Por que esse é o mesmo padrão? Como bind
não aceita um sockaddr_in*
, ele aceita um mais genérico sockaddr*
. Se você observar as definições dessas classes, veremos que sockaddr
há apenas um membro da família à qual designamos sin_family
*. A família diz em qual subtipo você deve usar o sockaddr
. AF_INET
informa que a estrutura de endereço é realmente a sockaddr_in
. Se fosse AF_INET6
, o endereço seria a sockaddr_in6
, que possui campos maiores para suportar os endereços IPv6 maiores.
Isso é idêntico ao seu Tool
exemplo, exceto que ele usa um número inteiro para especificar qual família, em vez de a std::string
. No entanto, vou afirmar que não tem cheiro, e tentarei fazê-lo por outras razões além de "é uma maneira padrão de fazer soquetes, para que não cheire". Obviamente é o mesmo padrão, que é por que afirmo que armazenar dados em objetos genéricos e convertê-los não é automaticamente um cheiro de código, mas há algumas diferenças em como eles fazem isso, o que o torna mais seguro.
Ao usar esse padrão, as informações mais importantes são capturar o transporte de informações sobre a subclasse do produtor para o consumidor. É isso que você está fazendo com o name
campo e os soquetes UNIX fazem com o sin_family
campo deles . Esse campo é a informação que o consumidor precisa para entender o que o produtor realmente criou. Em todos os casos desse padrão, deve ser uma enumeração (ou pelo menos um número inteiro que atua como uma enumeração). Por quê? Pense no que seu consumidor fará com as informações. Eles precisam ter escrito uma grande if
declaração ou umswitch
, como você determinou, onde eles determinam o subtipo correto, o convertem e usam os dados. Por definição, pode haver apenas um pequeno número desses tipos. Você pode armazená-lo em uma string, como você fez, mas isso tem inúmeras desvantagens:
- Lento -
std::string
normalmente precisa fazer alguma memória dinâmica para manter a string. Você também precisa fazer uma comparação de texto completo para corresponder ao nome toda vez que quiser descobrir qual subclasse você possui.
- Muito versátil - há algo a ser dito para colocar restrições em si mesmo quando você está fazendo algo extremamente perigoso. Eu tive sistemas como este que procuravam uma substring para dizer a que tipo de objeto ele estava olhando. Isso funcionou muito bem até que o nome de um objeto conteve acidentalmente essa substring e criou um erro terrivelmente enigmático. Como, como declaramos acima, precisamos apenas de um pequeno número de casos, não há razão para usar uma ferramenta massivamente sobrecarregada, como strings. Isto leva a...
- Propenso a erros - digamos apenas que você desejará entrar em um tumulto assassino tentando depurar por que as coisas não estão funcionando quando um consumidor acidentalmente define o nome de um pano mágico
MagicC1oth
. Sério, erros como esse podem levar dias para coçar a cabeça antes que você percebesse o que aconteceu.
Uma enumeração funciona muito melhor. É rápido, barato e muito menos propenso a erros:
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!");
}
}
};
Este exemplo também mostra uma switch
declaração envolvendo as enumerações, com a parte mais importante desse padrão: um default
caso que é lançado. Você nunca deve entrar nessa situação se fizer as coisas perfeitamente. No entanto, se alguém adicionar um novo tipo de ferramenta e você esquecer de atualizar seu código para suportá-lo, precisará de algo para detectar o erro. Na verdade, eu recomendo tanto que você os adicione, mesmo que não precise deles.
A outra grande vantagem enum
disso é que ele fornece ao próximo desenvolvedor uma lista completa de tipos de ferramentas válidos, desde o início. Não há necessidade de vasculhar o código para encontrar a classe especializada de flauta de Bob que ele usa em sua batalha épica com chefes.
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
}
}
Sim, coloquei uma declaração padrão "vazia", apenas para explicitar ao próximo desenvolvedor o que eu espero que aconteça se algum novo tipo inesperado aparecer no meu caminho.
Se você fizer isso, o padrão cheira menos. No entanto, para não sentir cheiro, a última coisa que você precisa fazer é considerar as outras opções. Esses lançamentos são algumas das ferramentas mais poderosas e perigosas que você tem no repertório C ++. Você não deve usá-los, a menos que tenha um bom motivo.
Uma alternativa muito popular é o que chamo de "estrutura de união" ou "classe de união". Para o seu exemplo, isso seria realmente um ajuste muito bom. Para criar um desses, você cria uma Tool
classe, com uma enumeração como antes, mas, em vez de subclassificar Tool
, colocamos todos os campos de todos os subtipos nela.
class Tool {
public:
enum TypeE {
kSword,
kShield,
kMagicCloth
};
TypeE type;
int attack;
int defense;
};
Agora você não precisa de subclasses. Você só precisa olhar para o type
campo para ver quais outros campos são realmente válidos. Isso é muito mais seguro e fácil de entender. No entanto, tem desvantagens. Há momentos em que você não deseja usar isso:
- Quando os objetos são muito diferentes - Você pode acabar com uma lista de campos completa e não fica claro quais se aplicam a cada tipo de objeto.
- Ao operar em uma situação crítica de memória - Se você precisar criar 10 ferramentas, poderá ficar preguiçoso com a memória. Quando você precisar criar 500 milhões de ferramentas, começará a se preocupar com bits e bytes. As estruturas de união são sempre maiores do que precisam.
Essa solução não é usada pelos soquetes UNIX devido ao problema de dissimulação composto pelo limite aberto da API. A intenção com soquetes UNIX era criar algo com o qual todos os sabores do UNIX pudessem trabalhar. Cada sabor poderia definir a lista de famílias que eles sustentam AF_INET
, e haveria uma pequena lista para cada um. No entanto, se um novo protocolo aparecer, como AF_INET6
aconteceu, talvez você precise adicionar novos campos. Se você fizesse isso com uma estrutura de união, acabaria criando efetivamente uma nova versão da estrutura com o mesmo nome, criando infinitos problemas de incompatibilidade. É por isso que os soquetes do UNIX optaram por usar o padrão de conversão em vez de uma estrutura de união. Tenho certeza de que eles consideraram e o fato de pensarem sobre isso faz parte do motivo pelo qual não cheira quando o usam.
Você também pode usar uma união de verdade. Os sindicatos economizam memória, sendo tão maiores quanto o maior membro, mas eles vêm com seu próprio conjunto de problemas. Provavelmente, essa não é uma opção para o seu código, mas sempre é uma opção que você deve considerar.
Outra solução interessante é boost::variant
. O Boost é uma ótima biblioteca cheia de soluções reutilizáveis entre plataformas. Provavelmente é um dos melhores códigos C ++ já escritos. Boost.Variant é basicamente a versão C ++ dos sindicatos. É um contêiner que pode conter muitos tipos diferentes, mas apenas um de cada vez. Você poderia fazer o seu Sword
, Shield
e por um pouco!), Mas este padrão pode ser extremamente útil. A variante é frequentemente usada, por exemplo, em árvores de análise, que pegam uma sequência de texto e a dividem usando uma gramática para regras.MagicCloth
as classes, em seguida, fazer ferramenta ser um boost::variant<Sword, Shield, MagicCloth>
, o que significa que contém um desses três tipos. Isso ainda sofre com o mesmo problema com compatibilidade futura que impede os soquetes UNIX de usá-lo (para não mencionar os soquetes UNIX são C, anterioresboost
A solução final que eu recomendaria analisar antes de mergulhar e usar a abordagem genérica de conversão de objetos é o padrão de design do Visitor . Visitor é um poderoso padrão de design que tira proveito da observação de que chamar uma função virtual efetivamente faz a conversão necessária e faz isso para você. Como o compilador faz isso, nunca pode estar errado. Assim, em vez de armazenar uma enumeração, o Visitor usa uma classe base abstrata, que possui uma tabela que sabe que tipo de objeto é. Em seguida, criamos uma chamada simples e indireta, que faz o trabalho:
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;
};
Então, qual é esse padrão horrível de Deus? Bem, Tool
tem uma função virtual accept
,. Se você passar um visitante, espera-se que ele retorne e chame a visit
função correta nesse visitante para o tipo. Isso é o que visitor.visit(*this);
faz em cada subtipo. Complicado, mas podemos mostrar isso com o seu exemplo acima:
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
}
Então o que acontece aqui? Criamos um visitante que fará algum trabalho para nós, uma vez que ele sabe que tipo de objeto está visitando. Em seguida, iteramos sobre a lista de ferramentas. Por uma questão de argumento, digamos que o primeiro objeto seja a Shield
, mas nosso código ainda não sabe disso. Chama t->accept(v)
, uma função virtual. Como o primeiro objeto é um escudo, ele acaba chamando void Shield::accept(ToolVisitor& visitor)
, que chama visitor.visit(*this);
. Agora, quando procuramos por quem visit
chamar, já sabemos que temos um Shield (porque essa função foi chamada), por isso, acabamos chamando void ToolVisitor::visit(Shield* shield)
nosso AttackVisitor
. Agora, o código correto é executado para atualizar nossa defesa.
O visitante é volumoso. É tão desajeitado que quase acho que tem um cheiro próprio. É muito fácil escrever padrões de visitantes ruins. No entanto, ele tem uma enorme vantagem que nenhum dos outros possui. Se adicionarmos um novo tipo de ferramenta, precisamos adicionar uma nova ToolVisitor::visit
função para ele. No instante em que fazemos isso, todos ToolVisitor
os programas se recusam a compilar porque está faltando uma função virtual. Isso facilita muito a captura de todos os casos em que perdemos alguma coisa. É muito mais difícil garantir isso se você usar if
ou switch
instruções para fazer o trabalho. Essas vantagens são boas o suficiente para que o Visitor tenha encontrado um pequeno nicho nos geradores de cenas gráficas em 3D. Por acaso, eles precisam exatamente do tipo de comportamento que o Visitor oferece, por isso funciona muito bem!
Ao todo, lembre-se de que esses padrões tornam difícil o próximo desenvolvedor. Gaste tempo facilitando para eles, e o código não cheira!
* Tecnicamente, se você olhar as especificações, sockaddr tem um membro chamado sa_family
. Há algumas coisas complicadas sendo feitas aqui no nível C que não importam para nós. Você pode analisar a implementação real , mas para esta resposta eu vou usar sa_family
sin_family
e outras de forma totalmente intercambiável, usando a que for mais intuitiva para a prosa, confiando que essa manobra em C cuide dos detalhes sem importância.