¿Por qué debería preferir la composición sobre la herencia?

109

Siempre leo que la composición es preferible a la herencia. Una publicación de blog sobre tipos diferentes , por ejemplo, aboga por el uso de la composición sobre la herencia, pero no puedo ver cómo se logra el polimorfismo.

Pero tengo la sensación de que cuando las personas dicen que prefieren la composición, en realidad quieren decir que prefieren una combinación de composición e implementación de interfaz. ¿Cómo vas a obtener el polimorfismo sin herencia?

Aquí hay un ejemplo concreto donde uso la herencia. ¿Cómo se cambiaría esto para usar la composición y qué ganaría?

Class Shape
{
    string name;
  public:
    void getName();
    virtual void draw()=0;
}

Class Circle: public Shape
{
    void draw(/*draw circle*/);
}
MustafaM
fuente
57
No, cuando la gente dice preferir composición que realmente quieren decir prefieren composición, no nunca jamás utilizar la herencia . Toda su pregunta se basa en una premisa defectuosa. Use la herencia cuando sea apropiado.
Bill the Lizard
2
También puede echar un vistazo a programmers.stackexchange.com/questions/65179/…
vjones
2
Estoy de acuerdo con el proyecto de ley, he visto que el uso de la herencia es una práctica común en el desarrollo de GUI.
Prashant Cholachagudda
2
Desea usar composición porque un cuadrado es solo la composición de dos triángulos, de hecho, creo que todas las formas que no sean elipse son solo composiciones de triángulos. El polimorfismo se trata de obligaciones contractuales y se elimina al 100% de la herencia. Cuando el triángulo se le agrega un montón de rarezas porque alguien quería hacerlo capaz de generar pirámides, si heredas de Triángulo obtendrás todo eso a pesar de que nunca generarás una pirámide 3D a partir de tu hexágono .
Jimmy Hoffa
2
@BilltheLizard Creo que muchas de las personas que lo dicen realmente nunca quieren usar la herencia, pero se equivocan.
user253751

Respuestas:

51

El polimorfismo no necesariamente implica herencia. A menudo, la herencia se usa como un medio fácil para implementar el comportamiento polimórfico, porque es conveniente clasificar objetos de comportamiento similares como los que tienen una estructura y comportamiento raíz completamente comunes. Piense en todos esos ejemplos de códigos de automóviles y perros que ha visto a lo largo de los años.

Pero, ¿qué pasa con los objetos que no son lo mismo? Modelar un automóvil y un planeta sería muy diferente, y sin embargo, ambos podrían querer implementar el comportamiento Move ().

En realidad, básicamente respondiste tu propia pregunta cuando dijiste "But I have a feeling that when people say prefer composition, they really mean prefer a combination of composition and interface implementation.". El comportamiento común se puede proporcionar a través de interfaces y un compuesto de comportamiento.

En cuanto a cuál es mejor, la respuesta es algo subjetiva y realmente se reduce a cómo desea que funcione su sistema, lo que tiene sentido tanto contextual como arquitectónicamente, y lo fácil que será probar y mantener.

S.Robins
fuente
En la práctica, ¿con qué frecuencia aparece el "Polimorfismo a través de la interfaz", y se considera normal (en lugar de ser una explotación de una expresividad langues). Apostaría a que el polimorfismo a través de la herencia fue por un diseño cuidadoso, no una consecuencia del lenguaje (C ++) descubierto después de su especificación.
Samis
1
¿Quién llamaría move () en un planeta y un automóvil y lo consideraría igual? La pregunta aquí es ¿en qué contexto deberían poder moverse? Si ambos son objetos 2D en un juego 2D simple, podrían heredar el movimiento, si fueran objetos en una simulación de datos masiva, podría no tener mucho sentido dejarlos heredar de la misma base y, como consecuencia, debe usar un interfaz
NikkyD
2
@SamusArin Se muestra por todas partes , y se considera perfectamente normal en lenguajes que soportan interfaces. ¿Qué quieres decir con "una explotación de la expresividad de un idioma"? Para eso están las interfaces .
Andres F.
@AndresF. Acabo de encontrar "Polimorfismo a través de la interfaz" en un ejemplo mientras leía "Análisis orientado a objetos y diseño con aplicaciones" que demostró el patrón de observador. Entonces me di cuenta de mi respuesta.
Samis
@AndresF. Supongo que mi visión estaba un poco cegada con respecto a esto debido a cómo usé el polimorfismo en mi último (primer) proyecto. Tenía 5 tipos de registros que todos derivaban de la misma base. De todos modos, gracias por la iluminación.
samis
79

La composición preferida no se trata solo de polimorfismo. Aunque eso es parte de esto, y tiene razón en que (al menos en lenguajes de escritura nominal) lo que la gente realmente quiere decir es "preferir una combinación de composición e implementación de interfaz". Pero, las razones para preferir la composición (en muchas circunstancias) son profundas.

El polimorfismo se trata de una cosa que se comporta de múltiples maneras. Por lo tanto, los genéricos / plantillas son una característica "polimórfica" en la medida en que permiten que una sola pieza de código varíe su comportamiento con los tipos. De hecho, este tipo de polimorfismo es realmente el mejor comportamiento y generalmente se conoce como polimorfismo paramétrico porque la variación está definida por un parámetro.

Muchos idiomas proporcionan una forma de polimorfismo llamada "sobrecarga" o polimorfismo ad hoc en el que múltiples procedimientos con el mismo nombre se definen de manera ad hoc y donde el idioma elige uno (quizás el más específico). Este es el tipo de polimorfismo menos bien comportado, ya que nada conecta el comportamiento de los dos procedimientos, excepto la convención desarrollada.

Un tercer tipo de polimorfismo es el subtipo de polimorfismo . Aquí un procedimiento definido en un tipo dado, también puede funcionar en una familia completa de "subtipos" de ese tipo. Cuando implementa una interfaz o extiende una clase, generalmente declara su intención de crear un subtipo. Los subtipos verdaderos se rigen por el Principio de sustitución de Liskov, que dice que si puede probar algo sobre todos los objetos en un supertipo, puede probarlo sobre todas las instancias en un subtipo. Sin embargo, la vida se vuelve peligrosa, ya que en lenguajes como C ++ y Java, las personas generalmente tienen suposiciones no aplicadas, y a menudo indocumentadas, sobre clases que pueden ser ciertas o no sobre sus subclases. Es decir, el código se escribe como si se pudiera demostrar más de lo que realmente es, lo que produce una gran cantidad de problemas cuando se subtipe descuidadamente.

La herencia es en realidad independiente del polimorfismo. Dada alguna cosa "T" que tiene una referencia a sí misma, la herencia ocurre cuando crea una nueva cosa "S" a partir de "T" reemplazando la referencia de "T" a sí misma con una referencia a "S". Esa definición es intencionalmente vaga, ya que la herencia puede ocurrir en muchas situaciones, pero la más común es la subclasificación de un objeto que tiene el efecto de reemplazar el thispuntero llamado por funciones virtuales con el thispuntero al subtipo.

La herencia es peligrosa, como todas las cosas muy poderosas, la herencia tiene el poder de causar estragos. Por ejemplo, suponga que anula un método cuando hereda de alguna clase: todo está bien hasta que algún otro método de esa clase asume que el método que hereda se comporta de cierta manera, después de todo, así es como lo diseñó el autor de la clase original. . Puede protegerse parcialmente contra esto declarando que todos los métodos invocados por otro de sus métodos son privados o no virtuales (final), a menos que estén diseñados para ser anulados. Aunque esto no siempre es lo suficientemente bueno. A veces puede ver algo como esto (en pseudo Java, con suerte legible para usuarios de C ++ y C #)

interface UsefulThingsInterface {
    void doThings();
    void doMoreThings();
}

...

class WayOfDoingUsefulThings implements UsefulThingsInterface{
     private foo stuff;
     public final int getStuff();
     void doThings(){
       //modifies stuff, such that ...
       ...
     }
     ...
     void doMoreThings(){
       //ignores stuff
       ...
     }
 }

crees que esto es encantador y tienes tu propia forma de hacer "cosas", pero usas la herencia para adquirir la capacidad de hacer "más cosas",

class MyUsefulThings extends WayOfDoingUsefulThings{
     void doThings {
        //my way
     }
}

Y todo está bien y bien. WayOfDoingUsefulThingsfue diseñado de tal manera que reemplazar un método no cambia la semántica de ningún otro ... excepto esperar, no, no lo fue. Parece que fue, pero doThingscambió el estado mutable que importaba. Entonces, a pesar de que no llamó a ninguna función de anulación,

 void dealWithStuff(WayOfDoingUsefulThings bar){
     bar.doThings()
     use(bar.getStuff());
 }

ahora hace algo diferente de lo esperado cuando lo pasa a MyUsefulThings. Lo que es peor, es posible que ni siquiera sepa que WayOfDoingUsefulThingshizo esas promesas. Tal vez dealWithStuffproviene de la misma biblioteca WayOfDoingUsefulThingsy getStuff()ni siquiera es exportada por la biblioteca (piense en las clases de amigos en C ++). Peor aún, has derrotado las comprobaciones estáticas del lenguaje sin darte cuenta: dealWithStufftomaste una WayOfDoingUsefulThingsdecisión justa para asegurarte de que tuviera una getStuff()función que se comportara de cierta manera.

Usando composición

class MyUsefulThings implements UsefulThingsInterface{
     private way = new WayOfDoingUsefulThings()
     void doThings() {
        //my way
     }
     void doMoreThings() {
        this.way.doMoreThings();
     }
}

trae de vuelta la seguridad de tipo estático. En general, la composición es más fácil de usar y más segura que la herencia cuando se implementa el subtipo. También le permite anular los métodos finales, lo que significa que debe sentirse libre de declarar todo final / no virtual, excepto en las interfaces la gran mayoría de las veces.

En un mundo mejor, los idiomas insertarían automáticamente el repetitivo con una delegationpalabra clave. La mayoría no, así que un inconveniente son las clases más grandes. Sin embargo, puede hacer que su IDE escriba la instancia de delegación por usted.

Ahora, la vida no se trata solo de polimorfismo. No puede necesitar subtipo todo el tiempo. El objetivo del polimorfismo es generalmente la reutilización del código, pero no es la única forma de lograr ese objetivo. A menudo, tiene sentido usar la composición, sin polimorfismo de subtipo, como una forma de administrar la funcionalidad.

Además, la herencia conductual tiene sus usos. Es una de las ideas más poderosas en informática. Es solo que, la mayoría de las veces, las buenas aplicaciones de OOP se pueden escribir utilizando solo el uso de herencia de interfaz y composiciones. Los dos principios

  1. Prohibir herencia o diseño para ello
  2. Composición preferida

son una buena guía por los motivos anteriores y no incurre en costos sustanciales.

Philip JF
fuente
44
Buena respuesta. Resumiría que tratar de lograr la reutilización de código con herencia es una ruta completamente incorrecta. La herencia es una restricción muy fuerte (¡imho agregar "poder" es una analogía incorrecta!) Y crea una fuerte dependencia entre las clases heredadas. Demasiadas dependencias = código incorrecto :) Por lo tanto, la herencia (también conocida como "se comporta como") generalmente brilla para las interfaces unificadas (= ocultar la complejidad de las clases heredadas), para cualquier otra cosa, piense dos veces o use composición ...
MaR
2
Esta es una buena respuesta. +1. Al menos en mis ojos, parece que la complicación adicional puede ser un costo considerable, y esto me ha hecho dudar un poco en hacer una gran cosa al preferir la composición. Especialmente cuando intentas hacer un montón de código amigable para pruebas de unidad a través de interfaces, composición y DI (sé que esto está agregando otras cosas), parece muy fácil hacer que alguien revise varios archivos diferentes para buscar muy Pocos detalles. ¿Por qué esto no se menciona con más frecuencia, incluso cuando solo se trata de la composición sobre el principio de diseño de herencia?
Panzercrisis
27

La razón por la que la gente dice esto es que los programadores principiantes de OOP, recién salidos de sus conferencias de polimorfismo a través de la herencia, tienden a escribir clases grandes con muchos métodos polimórficos, y luego, en algún momento, terminan con un desastre imposible de mantener.

Un ejemplo típico proviene del mundo del desarrollo de juegos. Supongamos que tiene una clase base para todas sus entidades de juego: la nave espacial del jugador, los monstruos, las balas, etc. Cada tipo de entidad tiene su propia subclase. El enfoque herencia usaría algunos métodos polimórficas, por ejemplo update_controls(), update_physics(), draw(), etc., y ponerlas en práctica para cada subclase. Sin embargo, esto significa que está acoplando una funcionalidad no relacionada: es irrelevante el aspecto de un objeto para moverlo, y no necesita saber nada sobre su IA para dibujarlo. El enfoque compositivo en su lugar define varias clases base (o interfaces), por ejemplo EntityBrain(subclases implementan AI o entrada de jugador), EntityPhysics(subclases implementan física de movimiento) y EntityPainter(subclases se encargan del dibujo) y una clase no polimórficaEntityque tiene una instancia de cada uno. De esta manera, puede combinar cualquier apariencia con cualquier modelo de física y cualquier IA, y dado que los mantiene separados, su código también será mucho más limpio. Además, problemas como "Quiero un monstruo que se parezca al monstruo globo en el nivel 1, pero se comporta como el payaso loco en el nivel 15" desaparecen: simplemente toma los componentes adecuados y pégalos.

Tenga en cuenta que el enfoque compositivo todavía utiliza la herencia dentro de cada componente; aunque idealmente usaría solo interfaces y sus implementaciones aquí.

"Separación de preocupaciones" es la frase clave aquí: representar la física, implementar una IA y dibujar una entidad son tres preocupaciones, y combinarlas en una entidad es la cuarta. Con el enfoque compositivo, cada preocupación se modela como una clase.

tdammers
fuente
1
Todo esto es bueno, y se recomienda en el artículo vinculado a la pregunta original. Me encantaría (siempre que tenga tiempo) si pudiera proporcionar un ejemplo simple que involucre a todas estas entidades, al tiempo que mantiene el polimorfismo (en C ++). O señalarme un recurso. Gracias.
MustafaM
Sé que tu respuesta es muy antigua, pero hice exactamente lo mismo que mencionaste en mi juego y ahora busco el enfoque de composición sobre herencia.
Sneh
"Quiero un monstruo que se parezca a ..." ¿cómo tiene sentido esto? Una interfaz no proporciona ninguna implementación, tendría que copiar el código de apariencia y comportamiento de una forma u otra
NikkyD
1
Se toma @NikkyD MonsterVisuals balloonVisualsy MonsterBehaviour crazyClownBehavioure instanciar una Monster(balloonVisuals, crazyClownBehaviour), junto a los Monster(balloonVisuals, balloonBehaviour)y Monster(crazyClownVisuals, crazyClownBehaviour)las instancias que se crean instancias en el nivel 1 y el nivel 15
Caleth
13

El ejemplo que ha dado es uno en el que la herencia es la elección natural. No creo que nadie afirme que la composición es siempre una mejor opción que la herencia: es solo una guía que significa que a menudo es mejor ensamblar varios objetos relativamente simples que crear muchos objetos altamente especializados.

La delegación es un ejemplo de una forma de usar la composición en lugar de la herencia. La delegación le permite modificar el comportamiento de una clase sin subclasificar. Considere una clase que proporciona una conexión de red, NetStream. Puede ser natural que la subclase de NetStream implemente un protocolo de red común, por lo que puede encontrar FTPStream y HTTPStream. Pero en lugar de crear una subclase HTTPStream muy específica para un solo propósito, por ejemplo, UpdateMyWebServiceHTTPStream, a menudo es mejor usar una instancia antigua simple de HTTPStream junto con un delegado que sabe qué hacer con los datos que recibe de ese objeto. Una razón por la que es mejor es que evita la proliferación de clases que deben mantenerse pero que nunca podrá reutilizar.

Caleb
fuente
11

Verá mucho este ciclo en el discurso del desarrollo de software:

  1. Se descubre que alguna característica o patrón (llamémoslo "Patrón X") es útil para algún propósito en particular. Las publicaciones de blog están escritas exaltando las virtudes sobre Pattern X.

  2. La exageración lleva a algunas personas a pensar que debes usar el Patrón X siempre que sea posible .

  3. Otras personas se molestan al ver que se usa el Patrón X en contextos donde no es apropiado, y escriben publicaciones en el blog indicando que no siempre debe usar el Patrón X y que es perjudicial en algunos contextos.

  4. La reacción violenta hace que algunas personas crean que el Patrón X siempre es dañino y nunca debe usarse.

Verá que este ciclo de exageración / reacción violenta ocurre con casi cualquier característica, desde GOTOpatrones hasta SQL y NoSQL y, sí, herencia. El antídoto es considerar siempre el contexto .

Tener Circledescender de Shapees exactamente cómo se supone que se debe usar la herencia en los idiomas OO que admiten la herencia.

La regla general de "preferir la composición sobre la herencia" es realmente engañosa sin contexto. Debería preferir la herencia cuando la herencia sea más apropiada, pero prefiera la composición cuando la composición sea más apropiada. La oración está dirigida a personas en la etapa 2 del ciclo de bombo, que piensan que la herencia debe usarse en todas partes. Pero el ciclo ha avanzado, y hoy parece hacer que algunas personas piensen que la herencia es de alguna manera mala en sí misma.

Piense en ello como un martillo contra un destornillador. ¿Prefieres un destornillador sobre un martillo? La pregunta no tiene sentido. Debe usar la herramienta adecuada para el trabajo, y todo depende de la tarea que necesita realizar.

JacquesB
fuente