Tal como está escrito, "huele", pero eso podría ser solo los ejemplos que dio. Almacenar datos en contenedores de objetos genéricos, luego enviarlos para obtener acceso a los datos no es automáticamente código de olor. Lo verás usado en muchas situaciones. Sin embargo, cuando lo use, debe saber qué está haciendo, cómo lo está haciendo y por qué. Cuando miro el ejemplo, el uso de comparaciones basadas en cadenas para decirme qué objeto es lo que dispara mi medidor de olor personal. Sugiere que no estás completamente seguro de lo que estás haciendo aquí (lo cual está bien, ya que tuviste la sabiduría de venir aquí a los programadores. SE y decir "oye, no creo que me guste lo que estoy haciendo, ayuda ¡Sacarme!").
El problema fundamental con el patrón de enviar datos desde contenedores genéricos como este es que el productor de los datos y el consumidor de los datos deben trabajar juntos, pero puede no ser obvio que lo hagan a primera vista. En cada ejemplo de este patrón, maloliente o no, este es el problema fundamental. Es muy posible que el próximo desarrollador ignore por completo que está haciendo este patrón y lo rompa por accidente, por lo que si usa este patrón debe tener cuidado de ayudar al próximo desarrollador. Debe facilitarle que no rompa el código involuntariamente debido a algunos detalles que tal vez no sepa que existen.
Por ejemplo, ¿qué pasa si quisiera copiar un reproductor? Si solo miro el contenido del objeto reproductor, parece bastante fácil. Sólo tengo que copiar los attack
, defense
y tools
las variables. ¡Muy fácil! Bueno, descubriré rápidamente que su uso de punteros lo hace un poco más difícil (en algún momento, vale la pena mirar los punteros inteligentes, pero ese es otro tema). Eso se resuelve fácilmente. Simplemente crearé nuevas copias de cada herramienta y las pondré en mi nueva tools
lista. Después de todo, Tool
es una clase realmente simple con un solo miembro. Así que creo un montón de copias, incluida una copia de la Sword
, pero no sabía que era una espada, así que solo copié la name
. Más tarde, la attack()
función mira el nombre, ve que es una "espada", lo lanza, ¡y suceden cosas malas!
Podemos comparar este caso con otro caso en la programación de sockets, que usa el mismo patrón. Puedo configurar una función de socket UNIX como esta:
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 qué es este el mismo patrón? Porque bind
no acepta un sockaddr_in*
, acepta un más genérico sockaddr*
. Si observa las definiciones para esas clases, vemos que sockaddr
solo tiene un miembro de la familia que le asignamos sin_family
*. La familia dice a qué subtipo se debe enviar el sockaddr
. AF_INET
le dice que la estructura de la dirección es en realidad un sockaddr_in
. Si lo fuera AF_INET6
, la dirección sería a sockaddr_in6
, que tiene campos más grandes para admitir las direcciones IPv6 más grandes.
Esto es idéntico a su Tool
ejemplo, excepto que usa un número entero para especificar qué familia en lugar de a std::string
. Sin embargo, voy a afirmar que no huele, e intentaré hacerlo por otras razones que no sean "es una forma estándar de hacer enchufes, por lo que no debe" oler ". Obviamente es el mismo patrón, que es Por eso afirmo que almacenar datos en objetos genéricos y emitirlos no es automáticamente olor a código, pero hay algunas diferencias en cómo lo hacen que lo hacen más seguro.
Cuando se usa este patrón, la información más importante es capturar la transmisión de información sobre la subclase de productor a consumidor. Esto es lo que está haciendo con el name
campo y los sockets UNIX hacen con su sin_family
campo. Ese campo es la información que el consumidor necesita para comprender lo que el productor realmente había creado. En todos los casos de este patrón, debe ser una enumeración (o al menos, un número entero que actúa como una enumeración). ¿Por qué? Piense en lo que su consumidor hará con la información. Necesitarán haber escrito algo grandeif
declaración o unswitch
declaración, como lo hizo, donde determinan el subtipo correcto, lo convierten y usan los datos. Por definición, solo puede haber un pequeño número de estos tipos. Puede almacenarlo en una cadena, como lo hizo, pero eso tiene numerosas desventajas:
- Lento:
std::string
normalmente tiene que hacer algo de memoria dinámica para mantener la cadena. También debe hacer una comparación de texto completo para que coincida con el nombre cada vez que desee averiguar qué subclase tiene.
- Demasiado versátil: hay algo que decir para imponer restricciones cuando haces algo extremadamente peligroso. He tenido sistemas como este que buscaban una subcadena para decirle qué tipo de objeto estaba mirando. Esto funcionó muy bien hasta que el nombre de un objeto accidentalmente contuvo esa subcadena y creó un error terriblemente críptico. Como, como dijimos anteriormente, solo necesitamos un pequeño número de casos, no hay razón para usar una herramienta masivamente poderosa como cadenas. Esto lleva a...
- Propenso a errores: digamos que querrá ir a un alboroto asesino tratando de depurar por qué las cosas no funcionan cuando un consumidor accidentalmente establece el nombre de una tela mágica
MagicC1oth
. En serio, errores como ese pueden tomar días de rascarse la cabeza antes de que te des cuenta de lo que sucedió.
Una enumeración funciona mucho mejor. Es rápido, barato y mucho menos propenso a errores:
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 ejemplo también muestra una switch
declaración que involucra las enumeraciones, con la parte más importante de este patrón: un default
caso que arroja. Usted debe no estar en esa situación si haces las cosas a la perfección. Sin embargo, si alguien agrega un nuevo tipo de herramienta y olvida actualizar su código para admitirlo, querrá que algo detecte el error. De hecho, los recomiendo tanto que debería agregarlos incluso si no los necesita.
La otra gran ventaja de esto enum
es que le da al siguiente desarrollador una lista completa de tipos de herramientas válidas, desde el principio. No hay necesidad de leer el código para encontrar la clase de flauta especializada de Bob que usa en su épica batalla de jefes.
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í, puse una declaración predeterminada "vacía", solo para hacer explícito al próximo desarrollador lo que espero que suceda si algún nuevo tipo inesperado me llega.
Si haces esto, el patrón olerá menos. Sin embargo, para estar libre de olores, lo último que debe hacer es considerar las otras opciones. Estos lanzamientos son algunas de las herramientas más poderosas y peligrosas que tiene en el repertorio de C ++. No debe usarlos a menos que tenga una buena razón.
Una alternativa muy popular es lo que yo llamo una "estructura sindical" o "clase sindical". Para su ejemplo, esto realmente encajaría muy bien. Para crear uno de estos, crea una Tool
clase, con una enumeración como antes, pero en lugar de subclasificar Tool
, simplemente ponemos todos los campos de cada subtipo.
class Tool {
public:
enum TypeE {
kSword,
kShield,
kMagicCloth
};
TypeE type;
int attack;
int defense;
};
Ahora no necesitas subclases en absoluto. Solo tiene que mirar el type
campo para ver qué otros campos son realmente válidos. Esto es mucho más seguro y más fácil de entender. Sin embargo, tiene inconvenientes. Hay veces que no quieres usar esto:
- Cuando los objetos son muy diferentes: puede terminar con una lista exhaustiva de campos, y no está claro cuáles se aplican a cada tipo de objeto.
- Cuando se opera en una situación crítica de memoria: si necesita hacer 10 herramientas, puede ser flojo con la memoria. Cuando necesite hacer 500 millones de herramientas, comenzará a preocuparse por los bits y bytes. Las estructuras sindicales son siempre más grandes de lo que deben ser.
Los sockets de UNIX no utilizan esta solución debido al problema de disimilitud agravado por el carácter abierto de la API. La intención con los sockets de UNIX era crear algo con lo que todos los sabores de UNIX pudieran funcionar. Cada sabor podría definir la lista de familias que apoyan AF_INET
, y habría una lista corta para cada uno. Sin embargo, si aparece un nuevo protocolo, como lo AF_INET6
hizo, es posible que deba agregar nuevos campos. Si hiciera esto con una estructura de unión, terminaría creando efectivamente una nueva versión de la estructura con el mismo nombre, creando infinitos problemas de incompatibilidad. Esta es la razón por la cual los sockets UNIX eligieron usar el patrón de conversión en lugar de una estructura de unión. Estoy seguro de que lo consideraron, y el hecho de que lo pensaron es parte de por qué no huele cuando lo usan.
También podrías usar una unión de verdad. Los sindicatos ahorran memoria, ya que solo son tan grandes como el miembro más grande, pero vienen con su propio conjunto de problemas. Probablemente esta no sea una opción para su código, pero siempre es una opción que debe considerar.
Otra solución interesante es boost::variant
. Boost es una gran biblioteca llena de soluciones reutilizables multiplataforma. Probablemente sea uno de los mejores códigos C ++ jamás escritos. Boost.Variant es básicamente la versión C ++ de las uniones. Es un contenedor que puede contener muchos tipos diferentes, pero solo uno a la vez. ¡Podrías hacer tu Sword
, Shield
y MagicCloth
clases, y luego hacer que la herramienta sea un poco!), Pero este patrón puede ser increíblemente útil. La variante se usa a menudo, por ejemplo, en árboles de análisis, que toman una cadena de texto y la dividen usando una gramática para las reglas.boost::variant<Sword, Shield, MagicCloth>
, lo que significa que contiene uno de estos tres tipos. Esto todavía sufre el mismo problema con la compatibilidad futura que impide que los sockets UNIX lo usen (sin mencionar que los sockets UNIX son C, anteriores aboost
La solución final que recomendaría mirar antes de zambullirse y usar el enfoque genérico de fundición de objetos es el patrón de diseño Visitante . Visitor es un poderoso patrón de diseño que aprovecha la observación de que llamar a una función virtual efectivamente hace el casting que necesita, y lo hace por usted. Debido a que el compilador lo hace, nunca puede estar equivocado. Por lo tanto, en lugar de almacenar una enumeración, Visitor utiliza una clase base abstracta, que tiene una vtable que sabe de qué tipo es el objeto. Luego creamos una pequeña llamada ordenada de doble indirección que hace el trabajo:
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;
};
Entonces, ¿cuál es este patrón horrible de Dios? Bueno, Tool
tiene una función virtual, accept
. Si le pasa un visitante, se espera que se dé la vuelta y llame a la visit
función correcta en ese visitante para el tipo. Esto es lo que visitor.visit(*this);
hace en cada subtipo. Complicado, pero podemos mostrar esto con su ejemplo anterior:
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
}
Entonces, ¿qué pasa aquí? Creamos un visitante que hará un trabajo por nosotros, una vez que sepa qué tipo de objeto está visitando. Luego iteramos sobre la lista de herramientas. Por el bien del argumento, digamos que el primer objeto es un Shield
, pero nuestro código aún no lo sabe. Llama t->accept(v)
, una función virtual. Debido a que el primer objeto es un escudo, termina llamando void Shield::accept(ToolVisitor& visitor)
, que llama visitor.visit(*this);
. Ahora, cuando estamos buscando qué visit
llamar, ya sabemos que tenemos un Escudo (porque se llamó a esta función), así que terminaremos llamando void ToolVisitor::visit(Shield* shield)
a nuestro AttackVisitor
. Esto ahora ejecuta el código correcto para actualizar nuestra defensa.
El visitante es voluminoso. Es tan torpe que casi creo que tiene un olor propio. Es muy fácil escribir malos patrones de visitante. Sin embargo, tiene una gran ventaja que ninguno de los otros tiene. Si agregamos un nuevo tipo de herramienta, tenemos que agregarle una nueva ToolVisitor::visit
función. En el instante en que hacemos esto, todos ToolVisitor
en el programa se negarán a compilar porque le falta una función virtual. Esto hace que sea muy fácil detectar todos los casos en los que nos perdimos algo. Es mucho más difícil garantizar que si usa if
o switch
declaraciones para hacer el trabajo. Estas ventajas son tan buenas que Visitor ha encontrado un pequeño nicho en los generadores de escenas de gráficos en 3D. Por casualidad necesitan exactamente el tipo de comportamiento que ofrece Visitor, ¡así que funciona muy bien!
En general, recuerde que estos patrones dificultan el próximo desarrollador. ¡Dedique tiempo para que sea más fácil para ellos, y el código no huele!
* Técnicamente, si nos fijamos en las especificaciones, sockaddr tiene un miembro llamado sa_family
. Aquí se está haciendo algo complicado en el nivel C que no nos importa. Le invitamos a ver la implementación real , pero para esta respuesta voy a usar sa_family
sin_family
y otras completamente intercambiables, usando la que sea más intuitiva para la prosa, confiando en que ese truco C se ocupa de los detalles sin importancia.