¿Existe alguna razón "real" por la que se odie la herencia múltiple?

120

Siempre me ha gustado la idea de tener una herencia múltiple admitida en un idioma. La mayoría de las veces, aunque intencionalmente se ha perdido, y la supuesta "sustitución" son las interfaces. Las interfaces simplemente no cubren todo el mismo terreno que la herencia múltiple, y esta restricción puede llevar ocasionalmente a más código repetitivo.

La única razón básica que he escuchado para esto es el problema del diamante con clases básicas. Simplemente no puedo aceptar eso. Para mí, se desprende muchísimo como: "Bueno, es posible arruinarlo, por lo que es automáticamente una mala idea". Sin embargo, puedes arruinar cualquier cosa en un lenguaje de programación, y quiero decir cualquier cosa. Simplemente no puedo tomar esto en serio, al menos no sin una explicación más detallada.

Ser consciente de este problema es el 90% de la batalla. Además, creo que hace años escuché algo sobre una solución de propósito general que involucraba un algoritmo de "envolvente" o algo así (¿esto suena, alguien?).

Con respecto al problema de los diamantes, el único problema potencialmente genuino que se me ocurre es si está intentando usar una biblioteca de terceros y no puede ver que dos clases aparentemente no relacionadas en esa biblioteca tienen una clase base común, pero además de la documentación, una característica de lenguaje simple podría, digamos, requerir que declare específicamente su intención de crear un diamante antes de que compile uno para usted. Con tal característica, cualquier creación de un diamante es intencional, imprudente o porque uno no es consciente de este escollo.

Para que todo se diga ... ¿Existe alguna razón real por la cual la mayoría de las personas odian la herencia múltiple o es solo un montón de histeria que causa más daño que bien? ¿Hay algo que no estoy viendo aquí? Gracias.

Ejemplo

Coche extiende WheeledVehicle, KIASpectra extiende Coche y Electrónica, KIASpectra contiene Radio. ¿Por qué KIASpectra no contiene electrónica?

  1. Porque es un electrónico. La herencia frente a la composición siempre debe ser una relación is-a vs una relación has-a.

  2. Porque es un electrónico. Hay cables, tableros de circuitos, interruptores, etc., todos arriba y abajo de esa cosa.

  3. Porque es un electrónico. Si su batería se agota en el invierno, tendrá tantos problemas como si todas sus ruedas desaparecieran repentinamente.

¿Por qué no usar interfaces? Tome # 3, por ejemplo. No quiero escribir esto una y otra vez, y realmente no quiero crear una clase de proxy proxy extraña para hacer esto tampoco:

private void runOrDont()
{
    if (this.battery)
    {
        if (this.battery.working && this.switchedOn)
        {
            this.run();
            return;
        }
    }
    this.dontRun();
}

(No nos estamos preguntando si esa implementación es buena o mala). Puedes imaginarte cómo puede haber varias de estas funciones asociadas con la electrónica que no están relacionadas con nada en WheeledVehicle, y viceversa.

No estaba seguro de establecer o no ese ejemplo, ya que allí hay espacio para la interpretación. También puedes pensar en términos de Vehículo de extensión de avión y Objeto de vuelo y Objeto de ave y Pájaro y Objeto de vuelo volador, o en términos de un ejemplo mucho más puro.

    
pregunta Panzercrisis 14.11.2013 - 16:59

9 respuestas

68

En muchos casos, las personas usan la herencia para proporcionar un rasgo a una clase. Por ejemplo, piensa en un pegaso. Con la herencia múltiple, podrías sentir la tentación de decir que Pegasus extiende Horse and Bird porque has clasificado al Bird como un animal con alas.

Sin embargo, los pájaros tienen otros rasgos que Pegasi no tiene. Por ejemplo, los pájaros ponen huevos, los pegasos tienen un nacimiento vivo. Si la herencia es su único medio de transmitir rasgos de intercambio, entonces no hay forma de excluir el rasgo de puesta de huevos del Pegaso.

Algunos idiomas han optado por convertir los rasgos en una construcción explícita dentro del idioma. Otros te guían suavemente en esa dirección al eliminar MI del idioma. De cualquier manera, no puedo pensar en un solo caso donde pensé "Hombre, realmente necesito que MI haga esto correctamente".

También vamos a discutir qué herencia es REALMENTE. Cuando hereda de una clase, toma una dependencia de esa clase, pero también tiene que respaldar los contratos que la clase admite, tanto implícitos como explícitos.

Tomemos el ejemplo clásico de un cuadrado heredado de un rectángulo. El rectángulo expone una propiedad de longitud y anchura y también un método getPerimeter y getArea. El cuadrado anularía el largo y el ancho, de modo que cuando se configura uno, el otro se ajusta para que coincida con el Perímetro y getArea funcionaría igual (2 * largo + 2 * ancho para el perímetro y largo * ancho para el área).

Hay un solo caso de prueba que se rompe si sustituyes esta implementación de un cuadrado por un rectángulo.

var rectangle = new Square();
rectangle.length= 5;
rectangle.width= 6;
Assert.AreEqual(30, rectangle.GetArea()); 
//Square returns 36 because setting the width clobbers the length

Es lo suficientemente difícil para hacer las cosas bien con una sola cadena de herencia. Se pone aún peor cuando agrega otro a la mezcla.

Las dificultades que mencioné con Pegasus en MI y las relaciones Rectangle / Square son los resultados de un diseño sin experiencia para las clases. Básicamente, evitar la herencia múltiple es una forma de ayudar a los desarrolladores principiantes a evitar que se disparen en el pie. Como todos los principios de diseño, tener disciplina y entrenamiento basados en ellos te permite descubrir a tiempo cuándo está bien romper con ellos. Consulte el Modelo de Adquisición de Habilidades de Dreyfus , en el nivel de Experto, su conocimiento intrínseco trasciende la dependencia de las máximas / principios. Puedes "sentir" cuando una regla no se aplica.

Y estoy de acuerdo en que de alguna manera hice trampa con un ejemplo del "mundo real" de por qué MI está mal visto.

Veamos un marco de UI. Específicamente, veamos algunos widgets que a primera vista pueden parecer simplemente una combinación de otros dos. Como un ComboBox. Un ComboBox es un TextBox que tiene una DropDownList compatible. Es decir. Puedo escribir un valor, o puedo seleccionar de una lista de valores predefinidos. Un enfoque ingenuo sería heredar el ComboBox de TextBox y DropDownList.

Pero su cuadro de texto deriva su valor de lo que el usuario ha escrito. Mientras que el DDL obtiene su valor de lo que selecciona el usuario. ¿Quién tiene precedente? El DDL podría haber sido diseñado para verificar y rechazar cualquier entrada que no estuviera en su lista original de valores. ¿Anulamos esa lógica? Eso significa que tenemos que exponer la lógica interna para que los herederos la anulen. O peor aún, agregue lógica a la clase base que solo existe para admitir una subclase (violando el Principio de inversión de dependencia ) .

Evitar el IM te ayuda a esquivar esta trampa por completo. Y podría llevarlo a extraer rasgos comunes y reutilizables de sus widgets de UI para que puedan aplicarse según sea necesario. Un excelente ejemplo de esto es la WPF Attached Property que permite una Elemento de marco en WPF para proporcionar una propiedad que otro elemento de marco puede usar sin heredar del elemento de marco principal.

Por ejemplo, una Cuadrícula es un panel de diseño en WPF y tiene propiedades adjuntas de Columna y Fila que especifican dónde se debe colocar un elemento secundario en la disposición de la cuadrícula. Sin propiedades adjuntas, si quiero organizar un botón dentro de una cuadrícula, el botón tendría que derivarse de la cuadrícula para que pudiera tener acceso a las propiedades de columna y fila.

Los desarrolladores tomaron este concepto aún más lejos y usaron las propiedades adjuntas como una forma de componente de comportamiento (por ejemplo, aquí está mi publicación sobre cómo hacer un GridView ordenable usando propiedades adjuntas escritas antes de que WPF incluyera un DataGrid). El enfoque ha sido reconocido como un patrón de diseño XAML denominado Comportamientos adjuntos .

Esperemos que esto proporcione un poco más de información sobre por qué la herencia múltiple suele ser mal vista.

    
respondido por el Michael Brown 14.11.2013 - 17:41
60
  

¿Hay algo que no estoy viendo aquí?

Permitir la herencia múltiple hace que las reglas sobre sobrecargas de funciones y despacho virtual sean decididamente más complicadas, así como la implementación del lenguaje en el diseño de objetos. Esto impacta bastante a los diseñadores / implementadores de lenguajes, y eleva la barra ya alta para lograr un lenguaje hecho, estable y adoptado.

Otro argumento común que he visto (y hecho a veces) es que al tener dos + clases base, su objeto casi invariablemente viola el Principio de Responsabilidad Única. O bien las dos + clases básicas son buenas clases independientes con su propia responsabilidad (causando la violación) o son tipos parciales / abstractos que trabajan juntos para hacer una única responsabilidad cohesiva.

En este otro caso, tienes 3 escenarios:

  1. A no sabe nada acerca de B - Genial, podrías combinar las clases porque tuviste suerte.
  2. A sabe acerca de B: ¿Por qué A simplemente no heredó de B?
  3. A y B se conocen el uno al otro. ¿Por qué no solo hiciste una clase? ¿Qué beneficio se obtiene al hacer que estas cosas estén tan acopladas pero sean parcialmente reemplazables?

Personalmente, creo que la herencia múltiple tiene una mala reputación, y que un sistema bien hecho de composición de estilo de rasgo sería realmente poderoso / útil ... pero hay muchas maneras en que puede implementarse mal, y mucho Por razones, no es una buena idea en un lenguaje como C ++.

[editar] con respecto a tu ejemplo, eso es absurdo. Un Kia tiene electrónica. tiene un motor. Del mismo modo, su electrónica tiene una fuente de alimentación, que simplemente es una batería de automóvil. Herencia, y mucho menos la herencia múltiple no tiene lugar allí.

    
respondido por el Telastyn 14.11.2013 - 17:25
29

La única razón por la que no se permite es porque facilita que las personas se disparen en el pie.

Lo que generalmente se sigue en este tipo de discusión son los argumentos para determinar si la flexibilidad de tener las herramientas es más importante que la seguridad de no disparar. No hay una respuesta decididamente correcta a ese argumento, porque como la mayoría de las otras cosas en la programación, la respuesta depende del contexto.

Si sus desarrolladores se sienten cómodos con MI, y MI tiene sentido en el contexto de lo que está haciendo, entonces lo extrañará en un lenguaje que no lo admita. Al mismo tiempo, si el equipo no se siente cómodo con él, o si no existe una necesidad real de hacerlo y la gente lo usa 'solo porque pueden', entonces eso es contraproducente.

Pero no, no existe un argumento absolutamente convincente que demuestre que la herencia múltiple es una mala idea.

EDIT

Las respuestas a esta pregunta parecen ser unánimes. Por el bien de ser el defensor del diablo, proporcionaré un buen ejemplo de herencia múltiple, donde no hacerlo conduce a hacks.

Suponga que está diseñando una aplicación de mercados de capital. Necesita un modelo de datos para los valores. Algunos valores son productos de capital (acciones, fideicomisos de inversión inmobiliaria, etc.) otros son deuda (bonos, bonos corporativos), otros son derivados (opciones, futuros). Entonces, si estás evitando el IM, harás un árbol de herencia muy claro y simple. Una acción heredará la equidad, Bond heredará la deuda. Genial hasta ahora, pero ¿qué pasa con los derivados? ¿Se pueden basar en productos similares a las acciones o productos similares a débitos? Ok, supongo que haremos que nuestro árbol de herencia se ramifique más. Tenga en cuenta que algunos derivados se basan en productos de capital, productos de deuda o ninguno de los dos. Así que nuestro árbol de herencia se está complicando. Luego aparece el analista de negocios y le dice que ahora admitimos valores indexados (opciones de índice, opciones de índice de futuro). Y estas cosas pueden basarse en la equidad, la deuda o el derivado. ¡Esto se está poniendo sucio! ¿Mi opción futura de índice deriva índice de acciones > Stock- > Option- >? ¿Por qué no Equity- > Stock- > Index- > Option? ¿Qué pasa si un día encuentro ambos en mi código (esto sucedió; historia real)?

El problema aquí es que estos tipos fundamentales se pueden mezclar en cualquier permutación que naturalmente no se derive una de la otra. Los objetos están definidos por una relación es una , por lo que la composición no tiene ningún sentido. La herencia múltiple (o el concepto similar de mixins) es la única representación lógica aquí.

La solución real para este problema es tener los tipos de Equidad, Deuda, Derivados, Índices definidos y combinados usando herencia múltiple para crear su modelo de datos. Esto creará objetos que tienen sentido y se prestan fácilmente para la reutilización del código.

    
respondido por el MrFox 14.11.2013 - 20:03
11

Las otras respuestas aquí parecen estar llegando principalmente a la teoría. Así que aquí hay un ejemplo concreto de Python, simplificado, en el que me he estrellado rápidamente, lo que requiere una buena cantidad de refactorización:

class Foo(object):
   def zeta(self):
      print "foozeta"

class Bar(object):
   def zeta(self):
      print "barzeta"

   def barstuff(self):
      print "barstuff"
      self.zeta()

class Bang(Foo, Bar):
   def stuff(self):
      self.zeta()
      print "---"
      self.barstuff()

z = Bang()
z.stuff()

Bar se escribió asumiendo que tenía su propia implementación de zeta() , lo que generalmente es una suposición bastante buena. Una subclase debe anularla según corresponda para que haga lo correcto. Desafortunadamente, los nombres fueron casualmente los mismos: hicieron cosas bastante diferentes, pero Bar ahora estaba llamando a la implementación de Foo :

foozeta
---
barstuff
foozeta

Es bastante frustrante cuando no se producen errores, la aplicación comienza a actuar de forma ligeramente incorrecta y el cambio de código que la causó (creando Bar.zeta ) no parece ser el problema.

    
respondido por el Izkata 14.11.2013 - 21:00
8

Yo diría que no hay ningún problema real con MI en el idioma correcto . La clave es permitir estructuras diamantadas, pero es necesario que los subtipos proporcionen su propia anulación, en lugar de que el compilador elija una de las implementaciones en función de alguna regla.

Hago esto en Guava , un idioma en el que estoy trabajando. Una característica de Guava es que podemos invocar la implementación de un método de supertipo específico. Así que es fácil indicar qué implementación de supertipo debe ser "heredada", sin ninguna sintaxis especial:

type Sequence[+A] {
  String toString() {
    return "[" + ... + "]";
  }
}

type Set[+A] {
  String toString() {
    return "{" + ... + "}";
  }
}

type OrderedSet[+A] extends Sequence[A], Set[A] {
  String toString() {
    // This is Guava's syntax for statically invoking instance methods
    return Set.toString(this);
  }
}

Si no le damos a OrderedSet su propio toString , obtendríamos un error de compilación. Sin sorpresas.

Me parece que el MI es particularmente útil con las colecciones. Por ejemplo, me gusta usar un tipo RandomlyEnumerableSequence para evitar declarar getEnumerator para matrices, deques, etc.:

type Enumerable[+A] {
  Source[A] getEnumerator();
}

type Sequence[+A] extends Enumerable[A] {
  A get(Int index);
}

type RandomlyEnumerableSequence[+A] extends Sequence[A] {
  Source[A] getEnumerator() {
    ...
  }
}

type DynamicArray[A] extends MutableStack[A],
                             RandomlyEnumerableSequence[A] {
  // No need to define getEnumerator.
}

Si no tuviéramos MI, podríamos escribir un RandomAccessEnumerator para que lo usen varias colecciones, pero tener que escribir un breve método getEnumerator todavía agrega repetitivo.

De manera similar, MI es útil para heredar implementaciones estándar de equals , hashCode y toString para colecciones.

    
respondido por el Daniel Lubarov 15.11.2013 - 06:45
7

La herencia, múltiple o no, no es tan importante. Si dos objetos de tipo diferente son sustituibles, eso es lo que importa, incluso si no están vinculados por herencia.

Una lista vinculada y una cadena de caracteres tienen poco en común, y no es necesario que estén vinculados por herencia, pero es útil si puedo usar una función length para obtener el número de elementos en cualquiera de ellos.

La herencia es un truco para evitar la implementación repetida del código. Si la herencia le ahorra trabajo y la herencia múltiple le ahorra aún más trabajo en comparación con la herencia única, esa es toda la justificación que se necesita.

Sospecho que algunos idiomas no implementan la herencia múltiple muy bien, y para los profesionales de esos idiomas, eso es lo que significa la herencia múltiple. Mencione la herencia múltiple a un programador de C ++, y lo que viene a la mente es algo sobre los problemas cuando una clase termina con dos copias de una base a través de dos rutas de herencia diferentes, y si se debe usar virtual en una clase base, y la confusión sobre cómo Se llaman destructores, y así sucesivamente.

En muchos idiomas, la herencia de clase se confunde con la herencia de símbolos. Cuando deriva una clase D de una clase B, no solo está creando una relación de tipo, sino que debido a que estas clases también sirven como espacios de nombres léxicos, está tratando con la importación de símbolos del espacio de nombres B al espacio de nombres D, además de La semántica de lo que está sucediendo con los tipos B y D en sí. La herencia múltiple, por lo tanto, trae problemas de conflicto de símbolos. Si heredamos de card_deck y graphic , los cuales "tienen" un método draw , ¿qué significa para draw el objeto resultante? Un sistema de objetos que no tiene este problema es el de Common Lisp. Quizás no por casualidad, la herencia múltiple se usa en los programas Lisp.

Mal implementado, cualquier inconveniente (como la herencia múltiple) debería ser odiado.

    
respondido por el Kaz 14.11.2013 - 20:39
1

Por lo que puedo decir, parte del problema (además de hacer que su diseño sea un poco más difícil de entender (sin embargo, más fácil de codificar)) es que el compilador va a ahorrar suficiente espacio para sus datos de clase, lo que permite Gran cantidad de memoria desperdiciada en el siguiente caso:

(Es posible que mi ejemplo no sea el mejor, pero trate de obtener la esencia del espacio de memoria múltiple para el mismo propósito, fue lo primero que me vino a la mente: P)

Considere un DDD donde la clase de perro se extiende desde canino y mascota, un canino tiene una variable que indica la cantidad de comida que debe comer (un número entero) con el nombre dietKg, pero una mascota también tiene otra variable para ese propósito generalmente bajo el mismo nombre (a menos que establezca otro nombre de variable, entonces tendrá que codificar código adicional, que fue el problema inicial que quería evitar, para manejar y mantener la integridad de las variables de Bouth), entonces tendrá dos espacios de memoria para el mismo propósito, para evitar esto, tendrá que modificar su compilador para reconocer ese nombre bajo el mismo espacio de nombres y simplemente asignar un espacio de memoria a esos datos, que desafortunadamente no se puede determinar en el tiempo de compilación.

Por supuesto, podría diseñar un lenguaje para especificar que tal variable ya tenga un espacio definido en otro lugar, pero al final, el programador debe especificar dónde está ese espacio de memoria al que se refiere esta variable (y nuevamente código adicional). ).

Confíe en mí, la gente que está implementando este pensamiento realmente duro acerca de todo esto, pero me alegra que haya preguntado, su tipo de perspectiva es la que cambia los paradigmas;) y, considerando esto, no estoy diciendo que sea imposible (pero se deben implementar muchas suposiciones y un compilador de múltiples fases, y uno realmente complejo), solo digo que aún no existe, si inicia un proyecto para su propio compilador capaz de hacer "esto" (herencia múltiple), permítame Sé que estaré encantado de unirme a tu equipo.

    
respondido por el Ordiel 14.11.2013 - 17:52
-1

Desde hace bastante tiempo, realmente nunca se me ocurrió cuán diferentes son algunas de las tareas de programación de otras, y cuánto ayuda si los lenguajes y patrones utilizados se adaptan al espacio del problema.

Cuando trabaja solo o aislado en su mayoría en el código que escribió, es un espacio de problema completamente diferente al de heredar un código base de 40 personas en la India que trabajaron en él durante un año antes de entregárselo sin ayuda de transición. p>

Imagine que acaba de ser contratado por la compañía de sus sueños y que luego heredó esa base de código. Además, imagine que los consultores habían estado aprendiendo acerca de (y, por lo tanto, están fascinados con) la herencia y la herencia múltiple ... ¿Podría imaginarse en qué podría estar trabajando?

Cuando hereda el código, la característica más importante es que es comprensible y las piezas están aisladas, por lo que se pueden trabajar de forma independiente. Claro que cuando escribes por primera vez las estructuras de código, como la herencia múltiple, podría ahorrarte un poco de duplicación y parecería adaptarse a tu estado de ánimo lógico en ese momento, pero el siguiente tipo solo tiene más cosas para desenredar.

Cada interconexión en su código también hace que sea más difícil de entender y modificar las piezas de forma independiente, doblemente con herencia múltiple.

Cuando trabajas como parte de un equipo, deseas apuntar al código más simple posible que no te da ninguna lógica redundante (eso es lo que realmente significa DRY, no es que no debas escribir mucho, simplemente que nunca tienes que cambiar tu Código en 2 lugares para resolver un problema!)

Hay formas más sencillas de lograr el código SECO que la herencia múltiple, por lo que incluirlo en un idioma solo puede abrirlo a los problemas insertados por otros que podrían no estar en el mismo nivel de comprensión que usted. Es incluso tentador si su idioma es incapaz de ofrecerle una manera simple / menos compleja de mantener su código en SECO.

    
respondido por el Bill K 16.11.2013 - 05:22
-3

El mayor argumento en contra de la herencia múltiple es que se pueden proporcionar algunas habilidades útiles y algunos axiomas útiles en un marco que lo restringe severamente (*), pero no se puede proporcionar y / o mantener sin tales restricciones. Entre ellos:

  • La capacidad de tener múltiples módulos compilados por separado incluye clases que heredan de otras clases de módulos y recompila un módulo que contiene una clase base sin tener que recompilar cada módulo que hereda de esa clase base.

  • La capacidad de un tipo para heredar una implementación miembro de una clase primaria sin que el tipo derivado tenga que volver a implementarla

  • El axioma de que cualquier instancia de objeto puede ser upcast o downcast directamente a sí mismo o cualquiera de sus tipos básicos, y dichos upcasts y downcasts, en cualquier combinación o secuencia, siempre conservan la identidad

  • El axioma de que si una clase derivada anula y encadena a un miembro de clase base, el miembro de base invocará al miembro base directamente, y no se invocará en ningún otro lugar.

(*) Por lo general, requiere que los tipos que admiten herencia múltiple se declaren como "interfaces" en lugar de clases, y no permitan que las interfaces hagan todo lo que pueden hacer las clases normales.

Si uno desea permitir la herencia múltiple generalizada, otra cosa debe ceder. Si X e Y heredan de B, ambos anulan el mismo miembro M y encadenan a la implementación base, y si D hereda de X e Y pero no anula M, luego, dada una instancia q de tipo D, ¿qué debería (( B) q) .M () hacer? No permitir un reparto de este tipo violaría el axioma que dice que cualquier objeto se puede convertir a cualquier tipo de base, pero cualquier comportamiento posible del reparto y la invocación de un miembro violaría el axioma con respecto al encadenamiento de métodos. Uno podría requerir que las clases solo se carguen en combinación con la versión particular de una clase base contra la que se compilaron, pero eso a menudo es incómodo. Hacer que el tiempo de ejecución se niegue a cargar cualquier tipo en el que cualquier ancestro pueda ser alcanzado por más de una ruta podría ser viable, pero limitaría enormemente la utilidad de la herencia múltiple. Permitir rutas de herencia compartidas solo cuando no existan conflictos crearía situaciones en las que una versión antigua de X sería compatible con una Y antigua o nueva, y una Y antigua sería compatible con una X antigua o nueva, pero la nueva X y la nueva Y lo serían. ser compatible, incluso si ninguno de los elementos que de por sí debería suponer un cambio importante.

Algunos lenguajes y marcos de trabajo permiten la herencia múltiple, según la teoría de que lo que se obtiene de MI es más importante que lo que debe darse para permitirlo. Sin embargo, los costos de MI son significativos y, en muchos casos, las interfaces proporcionan el 90% de los beneficios de MI a una pequeña fracción del costo.

    
respondido por el supercat 14.11.2013 - 23:14