sencillos programacion polimorfismo parametrico orientada objetos herencia encapsulamiento ejemplos c++ oop tdd polymorphism

c++ - parametrico - polimorfismo programacion



¿El polimorfismo o los condicionales promueven un mejor diseño? (12)

Recientemente me encontré con esta entrada en el blog de prueba de google sobre las pautas para escribir un código más comprobable. Estuve de acuerdo con el autor hasta este punto:

Favorece el polimorfismo sobre los condicionales: si ve una declaración de cambio, debería pensar en polimorfismos. Si ve la misma condición si se repite en muchos lugares de su clase, debería volver a pensar en polimorfismo. El polimorfismo dividirá su clase compleja en varias clases simples más pequeñas que definen claramente qué partes del código están relacionadas y se ejecutan juntas. Esto ayuda a las pruebas ya que la clase más simple / más pequeña es más fácil de probar.

Simplemente no puedo entender eso. Puedo entender el uso de polimorfismo en lugar de RTTI (o DIY-RTTI, según sea el caso), pero parece una afirmación tan amplia que no puedo imaginar que realmente se use efectivamente en el código de producción. Me parece, más bien, que sería más fácil agregar casos de prueba adicionales para los métodos que tienen declaraciones de cambio, en lugar de desglosar el código en docenas de clases separadas.

Además, tenía la impresión de que el polimorfismo puede conducir a todo tipo de otros errores sutiles y problemas de diseño, por lo que tengo curiosidad por saber si la compensación aquí valdría la pena. ¿Puede alguien explicarme exactamente qué significa esta guía de prueba?


No temas...

Supongo que tu problema radica en la familiaridad, no en la tecnología. Familiarícese con C ++ OOP.

C ++ es un lenguaje OOP

Entre sus múltiples paradigmas, tiene características de OOP y es más que capaz de admitir la comparación con el lenguaje OO más puro.

No permita que la "parte C dentro de C ++" le haga creer que C ++ no puede lidiar con otros paradigmas. C ++ puede manejar una gran cantidad de paradigmas de programación bastante gentilmente. Y entre ellos, OOP C ++ es el más maduro de los paradigmas de C ++ después del paradigma de procedimiento (es decir, la mencionada "parte C").

El polimorfismo está bien para la producción

No hay cosa de "errores sutiles" o "no aptos para el código de producción". Hay desarrolladores que se mantienen en sus formas, y desarrolladores que aprenderán a usar herramientas y usar las mejores herramientas para cada tarea.

interruptor y polimorfismo son [casi] similares ...

... Pero el polimorfismo eliminó la mayoría de los errores.

La diferencia es que debes manejar los interruptores manualmente, mientras que el polimorfismo es más natural, una vez que te acostumbras a la anulación del método de herencia.

Con los switches, tendrá que comparar una variable de tipo con diferentes tipos y manejar las diferencias. Con polimorfismo, la variable en sí misma sabe cómo comportarse. Solo debe organizar las variables de forma lógica y anular los métodos correctos.

Pero al final, si olvida manejar un caso en el interruptor, el compilador no le dirá, mientras que le dirán si deriva de una clase sin anular sus métodos virtuales puros. Por lo tanto, se evitan la mayoría de los errores de conmutación.

En general, las dos características son hacer elecciones. Pero el polimorfismo le permite hacer elecciones más complejas y al mismo tiempo más naturales y, por lo tanto, más fáciles.

Evite usar RTTI para encontrar el tipo de un objeto

RTTI es un concepto interesante y puede ser útil. Pero la mayoría de las veces (es decir, el 95% del tiempo), la anulación de método y la herencia serán más que suficientes, y la mayoría de su código ni siquiera debería conocer el tipo exacto de objeto manejado, pero confíe en que haga lo correcto.

Si usas RTTI como un interruptor glorificado, te estás perdiendo el punto.

(Descargo de responsabilidad: soy un gran admirador del concepto de RTTI y de los Dynamic_casts. Pero uno debe usar la herramienta adecuada para la tarea en cuestión, y la mayoría de las veces RTTI se usa como un interruptor glorificado, lo cual es incorrecto)

Compare el polimorfismo dinámico vs. el estático

Si su código no conoce el tipo exacto de un objeto en tiempo de compilación, utilice polimorfismo dinámico (es decir, herencia clásica, métodos virtuales anulados, etc.)

Si su código conoce el tipo en tiempo de compilación, entonces quizás podría usar polimorfismo estático, es decir, el patrón CRTP http://en.wikipedia.org/wiki/Curiously_Recurring_Template_Pattern

El CRTP le permitirá tener un código que huele a polimorfismo dinámico, pero cuya llamada a cada método se resolverá estáticamente, lo cual es ideal para algunos códigos muy críticos.

Ejemplo de código de producción

Un código similar a este (de memoria) se usa en producción.

La solución más fácil giraba en torno al procedimiento llamado por ciclo de mensaje (un WinProc en Win32, pero escribí una versión simplista, por simplicidad). Resumir, fue algo así como:

void MyProcedure(int p_iCommand, void *p_vParam) { // A LOT OF CODE ??? // each case has a lot of code, with both similarities // and differences, and of course, casting p_vParam // into something, depending on hoping no one // did a mistake, associating the wrong command with // the wrong data type in p_vParam switch(p_iCommand) { case COMMAND_AAA: { /* A LOT OF CODE (see above) */ } break ; case COMMAND_BBB: { /* A LOT OF CODE (see above) */ } break ; // etc. case COMMAND_XXX: { /* A LOT OF CODE (see above) */ } break ; case COMMAND_ZZZ: { /* A LOT OF CODE (see above) */ } break ; default: { /* call default procedure */} break ; } }

Cada adición de comando agregó un caso.

El problema es que algunos comandos son similares, y se comparten en parte su implementación.

Entonces, mezclar las cajas era un riesgo para la evolución.

Resolví el problema usando el patrón de Comando, es decir, creando un objeto de Comando base, con un método de proceso ().

Así que volví a escribir el procedimiento del mensaje, minimizando el código peligroso (es decir, jugando con void *, etc.) al mínimo, y lo escribí para asegurarme de que nunca más necesitaría volver a tocarlo:

void MyProcedure(int p_iCommand, void *p_vParam) { switch(p_iCommand) { // Only one case. Isn''t it cool? case COMMAND: { Command * c = static_cast<Command *>(p_vParam) ; c->process() ; } break ; default: { /* call default procedure */} break ; } }

Y luego, para cada comando posible, en lugar de agregar código en el procedimiento, y mezclar (o peor, copiar / pegar) el código de comandos similares, creé un nuevo comando, y lo derivé desde el objeto Comando, o uno de sus objetos derivados:

Esto condujo a la jerarquía (representada como un árbol):

[+] Command | +--[+] CommandServer | | | +--[+] CommandServerInitialize | | | +--[+] CommandServerInsert | | | +--[+] CommandServerUpdate | | | +--[+] CommandServerDelete | +--[+] CommandAction | | | +--[+] CommandActionStart | | | +--[+] CommandActionPause | | | +--[+] CommandActionEnd | +--[+] CommandMessage

Ahora, todo lo que necesitaba hacer era anular el proceso para cada objeto.

Simple y fácil de extender.

Por ejemplo, supongamos que CommandAction debía hacer su proceso en tres fases: "antes", "mientras" y "después". Su código sería algo así como:

class CommandAction : public Command { // etc. virtual void process() // overriding Command::process pure virtual method { this->processBefore() ; this->processWhile() ; this->processAfter() ; } virtual void processBefore() = 0 ; // To be overriden virtual void processWhile() { // Do something common for all CommandAction objects } virtual void processAfter() = 0 ; // To be overriden } ;

Y, por ejemplo, CommandActionStart podría codificarse como:

class CommandActionStart : public CommandAction { // etc. virtual void processBefore() { // Do something common for all CommandActionStart objects } virtual void processAfter() { // Do something common for all CommandActionStart objects } } ;

Como dije: Fácil de entender (si se comenta adecuadamente), y muy fácil de ampliar.

El interruptor se reduce a su mínimo nivel (es decir, si, porque todavía necesitamos delegar los comandos de Windows en el procedimiento predeterminado de Windows), y no es necesario RTTI (o peor, RTTI interno).

El mismo código dentro de un interruptor sería bastante entretenido, supongo (si solo a juzgar por la cantidad de código "histórico" que vi en nuestra aplicación en el trabajo).


Debo reiterar que encontrar todas las declaraciones de cambio puede ser un proceso no trivial en una base de código madura. Si pierde alguno, es probable que la aplicación falle debido a una declaración de caso sin precedentes a menos que tenga el conjunto predeterminado.

También echa un vistazo al libro de "Martin Fowlers" sobre "Refactorización"
Usar un interruptor en lugar de polimorfismo es un olor codificado.


El polimorfismo es una de las piedras angulares de OO y ciertamente es muy útil. Al dividir las preocupaciones sobre las clases múltiples, crea unidades aisladas y comprobables. Entonces, en lugar de hacer un cambio ... en caso de que invoque métodos en varios tipos o implementaciones diferentes, crea una interfaz unificada y tiene múltiples implementaciones. Cuando necesite agregar una implementación, no necesita modificar los clientes, como es el caso de cambiar ... caso. Muy importante ya que esto ayuda a evitar la regresión.

También puede simplificar el algoritmo de su cliente al tratar con un solo tipo: la interfaz.

Para mí, es muy importante que el polimorfismo se utilice mejor con un patrón de implementación / interfaz puro (como el venerable Shape <- Circle, etc.). También puede tener polimorfismo en clases concretas con métodos de plantilla (aka hooks), pero su efectividad disminuye a medida que aumenta la complejidad.

El polimorfismo es la base sobre la que se basa la base de código de nuestra empresa, por lo que lo considero muy práctico.


En realidad, esto hace que las pruebas y el código sean más fáciles de escribir.

Si tiene una declaración de conmutación basada en un campo interno, es probable que tenga el mismo interruptor en varios lugares haciendo cosas ligeramente diferentes. Esto causa problemas cuando agrega un nuevo caso ya que tiene que actualizar todas las instrucciones de cambio (si puede encontrarlas).

Mediante el uso de polimorfismo, puede usar funciones virtuales para obtener la misma funcionalidad y, dado que un caso nuevo es una nueva clase, no es necesario buscar en el código las cosas que deben verificarse, todo está aislado para cada clase.

class Animal { public: Noise warningNoise(); Noise pleasureNoise(); private: AnimalType type; }; Noise Animal::warningNoise() { switch(type) { case Cat: return Hiss; case Dog: return Bark; } } Noise Animal::pleasureNoise() { switch(type) { case Cat: return Purr; case Dog: return Bark; } }

En este caso simple cada nueva causa de animal requiere que se actualicen ambas declaraciones de cambio.
Olvidas uno? ¿Cuál es el valor predeterminado? ¡¡EXPLOSIÓN!!

Usando polimorfismo

class Animal { public: virtual Noise warningNoise() = 0; virtual Noise pleasureNoise() = 0; }; class Cat: public Animal { // Compiler forces you to define both method. // Otherwise you can''t have a Cat object // All code local to the cat belongs to the cat. };

Al usar polimorfismo puedes probar la clase Animal.
Luego prueba cada una de las clases derivadas por separado.

También esto le permite enviar la clase Animal ( Cerrado por alteración ) como parte de su biblioteca binaria. Pero las personas aún pueden agregar nuevos Animales ( Abrir para extensión ) derivando nuevas clases derivadas del encabezado Animal. Si toda esta funcionalidad se hubiera capturado dentro de la clase Animal, entonces todos los animales deben definirse antes del envío (Cerrado / Cerrado).


Esto tiene que ver principalmente con la encapsulación del conocimiento. Comencemos con un ejemplo realmente obvio: toString (). Esto es Java, pero se transfiere fácilmente a C ++. Supongamos que desea imprimir una versión amigable para los humanos de un objeto con fines de depuración. Podrías hacerlo:

switch(obj.type): { case 1: cout << "Type 1" << obj.foo <<...; break; case 2: cout << "Type 2" << ...

Sin embargo, esto sería claramente tonto. ¿Por qué un método en algún lugar debería saber cómo imprimir todo? A menudo será mejor para el objeto saber cómo imprimir, por ejemplo:

cout << object.toString();

De esa forma, toString () puede acceder a los campos de miembros sin necesidad de conversiones. Se pueden probar de forma independiente. Se pueden cambiar fácilmente.

Sin embargo, podría argumentar que la forma en que un objeto imprime no debe asociarse con un objeto, sino que debe estar asociado con el método de impresión. En este caso, otro patrón de diseño resulta útil, que es el patrón Visitor, utilizado para falsificar Double Dispatch. Describirlo completamente es demasiado largo para esta respuesta, pero puedes leer una buena descripción aquí .


Funciona muy bien si lo entiendes .

También hay 2 sabores de polimorfismo. El primero es muy fácil de entender en java-esque:

interface A{ int foo(); } final class B implements A{ int foo(){ print("B"); } } final class C implements A{ int foo(){ print("C"); } }

B y C comparten una interfaz común. B y C en este caso no se pueden extender, por lo que siempre estás seguro de a quién estás llamando. Lo mismo ocurre con C ++, simplemente haz A :: foo pure virtual.

En segundo lugar, y más truculento es el polimorfismo en tiempo de ejecución. No se ve muy mal en el pseudo-código.

class A{ int foo(){print("A");} } class B extends A{ int foo(){print("B");} } class C extends B{ int foo(){print("C");} } ... class Z extends Y{ int foo(){print("Z"); } main(){ F* f = new Z(); A* a = f; a->foo(); f->foo(); }

Pero es mucho más complicado. Especialmente si trabajas en C ++ donde algunas de las declaraciones foo pueden ser virtuales, y parte de la herencia puede ser virtual. También la respuesta a esto:

A* a = new Z; A a2 = *a; a->foo(); a2.foo();

podría no ser lo que esperas

Solo mantente al tanto de lo que haces y no sabes si estás usando un polimorfismo en tiempo de ejecución. No confíe demasiado y, si no está seguro de qué va a hacer algo en tiempo de ejecución, pruébelo.


La prueba unitaria de un programa OO significa probar cada clase como una unidad. Un principio que desea aprender es "Abierto a extensión, cerrado a modificación". Lo obtuve de Head First Design Patterns. Pero básicamente dice que desea tener la capacidad de extender fácilmente su código sin modificar el código probado existente.

El polimorfismo lo hace posible al eliminar esas declaraciones condicionales. Considera este ejemplo:

Supongamos que tiene un objeto Character que porta un Arma. Puedes escribir un método de ataque como este:

If (weapon is a rifle) then //Code to attack with rifle else If (weapon is a plasma gun) //Then code to attack with plasma gun

etc.

Con el polimorfismo, el personaje no tiene que "conocer" el tipo de arma, simplemente

weapon.attack()

trabajaría. ¿Qué pasa si se inventó una nueva arma? Sin polimorfismo, tendrá que modificar su declaración condicional. Con el polimorfismo, tendrás que agregar una nueva clase y dejar solo la clase de caracteres probada.


Los interruptores y el polimorfismo hacen lo mismo.

En polimorfismo (y en la programación basada en clases en general) agrupa las funciones por su tipo. Cuando usa interruptores, agrupa los tipos por función. Decide qué vista es buena para ti.

Entonces, si su interfaz es fija y solo agrega nuevos tipos, el polimorfismo es su amigo. Pero si agrega nuevas funciones a su interfaz, deberá actualizar todas las implementaciones.

En ciertos casos, puede tener una cantidad fija de tipos, y pueden venir nuevas funciones, luego los interruptores son mejores. Pero agregar nuevos tipos te hace actualizar cada interruptor.

Con los switches está duplicando listas de subtipos. Con el polimorfismo estás duplicando las listas de operaciones. Cambiaste un problema para obtener uno diferente. Este es el llamado problema de expresión , que no está resuelto por ningún paradigma de programación que conozca. La raíz del problema es la naturaleza unidimensional del texto utilizado para representar el código.

Dado que los puntos pro-polimorfismo se discuten aquí, permítanme proporcionar un punto pro-switch.

OOP tiene patrones de diseño para evitar trampas comunes. La programación procesal también tiene patrones de diseño (pero nadie lo ha anotado todavía AFAIK, necesitamos otro nuevo Gang of N para hacer un best-seller de esos ...). Un patrón de diseño podría incluir siempre un caso predeterminado .

Los interruptores se pueden hacer bien:

switch (type) { case T_FOO: doFoo(); break; case T_BAR: doBar(); break; default: fprintf(stderr, "You, who are reading this, add a new case for %d to the FooBar function ASAP!/n", type); assert(0); }

Este código señalará su depurador favorito a la ubicación donde olvidó manejar un caso. Un compilador puede obligarte a implementar tu interfaz, pero esto te obliga a probar tu código a fondo (al menos para ver si se nota el nuevo caso).

Por supuesto, si un interruptor en particular se utiliza en más de un lugar, se corta en una función ( no se repita ).

Si desea extender estos interruptores solo haga un grep ''case[ ]*T_BAR'' rn . (en Linux) y escupirá las ubicaciones que vale la pena mirar. Como necesita ver el código, verá un contexto que le ayuda a agregar correctamente la nueva caja. Cuando usa polimorfismo, los sitios de llamada están ocultos dentro del sistema y depende de la corrección de la documentación, si es que existe.

La extensión de los interruptores tampoco rompe el OCP, ya que no altera los casos existentes, simplemente agrega un nuevo caso.

Los conmutadores también ayudan al siguiente tipo que intenta acostumbrarse y entender el código:

  • Los posibles casos están ante tus ojos. Eso es bueno al leer el código (menos saltos).
  • Pero las llamadas a métodos virtuales son como las llamadas a métodos normales. Uno nunca puede saber si una llamada es virtual o normal (sin buscar la clase). Eso es malo.
  • Pero si la llamada es virtual, los casos posibles no son obvios (sin encontrar todas las clases derivadas). Eso también es malo

Cuando proporciona una interfaz a una tercera parte, para que puedan agregar comportamiento y datos de usuario a un sistema, entonces esa es una cuestión diferente. (Pueden establecer callbacks y punteros a datos de usuario, y les das identificadores)

Se puede encontrar más debate aquí: http://c2.com/cgi/wiki?SwitchStatementsSmell

Me temo que mi "síndrome de C-hacker" y el anti-OOPism acabarán por quemar toda mi reputación aquí. Pero cada vez que necesitaba o tenía que hackear o atornillar algo en un sistema de C procedimental, lo encontraba bastante fácil, la falta de restricciones, el encapsulamiento forzado y las capas de abstracción menos me hacen "simplemente hacerlo". Pero en un sistema C ++ / C # / Java donde decenas de capas de abstracción se superponen una a la otra en la vida útil del software, necesito pasar muchas horas, algunas veces días, para descubrir cómo solucionar todas las restricciones y limitaciones que otros programadores incorporado en su sistema para evitar que otros "se metan con su clase".


No es un experto en las implicaciones para casos de prueba, pero desde una perspectiva de desarrollo de software:

  • Principio abierto-cerrado : las clases deben estar cerradas a la alteración, pero abiertas a extensión. Si administra operaciones condicionales a través de una construcción condicional, entonces si se agrega una nueva condición, su clase necesita cambiar. Si usa polimorfismo, la clase base no necesita cambiar.

  • No se repita : una parte importante de la guía es la " misma condición si". Eso indica que su clase tiene algunos modos de operación distintos que pueden tenerse en cuenta en una clase. Entonces, esa condición aparece en un lugar en su código cuando crea una instancia del objeto para ese modo. Y nuevamente, si aparece uno nuevo, solo necesita cambiar un código.


Realmente depende de tu estilo de programación. Si bien esto puede ser correcto en Java o C #, no estoy de acuerdo con que la decisión automática de usar polimorfismo sea correcta. Puede dividir el código en muchas funciones pequeñas y realizar una búsqueda en matriz con punteros a función (inicializados en tiempo de compilación), por ejemplo. En C ++, el polimorfismo y las clases a menudo se usan en exceso, probablemente el error de diseño más grande cometido por personas que provienen de fuertes lenguajes de programación orientada a objetos en C ++ es que todo entra en una clase; esto no es cierto. Una clase solo debe contener el conjunto mínimo de cosas que lo hacen funcionar como un todo. Si una subclase o amigo es necesario, que así sea, pero no deberían ser la norma. Cualquier otra operación en la clase debe ser funciones libres en el mismo espacio de nombres; ADL permitirá que estas funciones se utilicen sin buscar.

C ++ no es un lenguaje OOP, no lo convierta en uno. Es tan malo como programar C en C ++.


Si está utilizando instrucciones de cambio en cualquier lugar, se encuentra con la posibilidad de que al actualizar pierda un lugar que necesita una actualización.


Soy un poco escéptico: creo que la herencia a menudo agrega más complejidad de la que elimina.

Creo que estás haciendo una buena pregunta, y una cosa que considero es la siguiente:

¿Se está dividiendo en múltiples clases porque tiene que lidiar con cosas diferentes? ¿O es realmente lo mismo, actuando de forma diferente?

Si realmente es un tipo nuevo, siga adelante y cree una nueva clase. Pero si solo es una opción, generalmente la mantengo en la misma clase.

Creo que la solución predeterminada es la de una sola clase, y la responsabilidad recae en el programador que propone la herencia para probar su caso.