oop - poo - ¿Cuál es el problema exacto con la herencia múltiple?
implementacion de herencia multiple en java (11)
Puedo ver a la gente preguntando todo el tiempo si la herencia múltiple debería incluirse en la próxima versión de C # o Java. Las personas de C ++, que tienen la suerte de tener esta habilidad, dicen que esto es como darle una cuerda a alguien para que eventualmente se ahorque.
¿Cuál es el problema con la herencia múltiple? ¿Hay alguna muestra concreta?
El Sistema de objetos Common Lisp (CLOS) es otro ejemplo de algo que admite MI al tiempo que evita los problemas de estilo C ++: la herencia tiene un valor predeterminado razonable , mientras que le permite decidir explícitamente cómo, digamos, llamar el comportamiento de un súper .
El diamante no es un problema, siempre y cuando no uses nada como la herencia virtual de C ++: en herencia normal, cada clase base se asemeja a un campo miembro (en realidad están distribuidos en RAM de esta manera), dándote algo de azúcar sintáctica y una capacidad adicional para anular más métodos virtuales. Eso puede imponer cierta ambigüedad en tiempo de compilación, pero eso suele ser fácil de resolver.
Por otro lado, con la herencia virtual, con demasiada facilidad se sale de control (y luego se convierte en un desastre). Considere como ejemplo un diagrama de "corazón":
A A
/ / / /
B C D E
/ / / /
F G
/ /
H
En C ++ es completamente imposible: tan pronto como F
y G
se fusionan en una sola clase, sus A
s se fusionan también, punto. Eso significa que nunca se puede considerar que las clases base son opacas en C ++ (en este ejemplo, se debe construir A
en H
por lo que debe saber que está presente en algún lugar de la jerarquía). En otros idiomas, puede funcionar, sin embargo; por ejemplo, F
y G
podrían declarar explícitamente a A como "interno", prohibiendo así la fusión consecuente y haciéndose efectivamente sólidos.
Otro ejemplo interesante ( no específico de C ++):
A
/ /
B B
| |
C D
/ /
E
Aquí, solo B
usa herencia virtual. Entonces E
contiene dos B
s que comparten el mismo A
De esta forma, puede obtener un puntero A*
que apunte a E
, pero no puede convertirlo en un puntero B*
aunque el objeto sea realmente B
ya que dicho reparto es ambiguo, y esta ambigüedad no puede detectarse en tiempo de compilación. (a menos que el compilador vea todo el programa). Aquí está el código de prueba:
struct A { virtual ~A() {} /* so that the class is polymorphic */ };
struct B: virtual A {};
struct C: B {};
struct D: B {};
struct E: C, D {};
int main() {
E data;
E *e = &data;
A *a = dynamic_cast<A *>(e); // works, A is unambiguous
// B *b = dynamic_cast<B *>(e); // doesn''t compile
B *b = dynamic_cast<B *>(a); // NULL: B is ambiguous
std::cout << "E: " << e << std::endl;
std::cout << "A: " << a << std::endl;
std::cout << "B: " << b << std::endl;
// the next casts work
std::cout << "A::C::B: " << dynamic_cast<B *>(dynamic_cast<C *>(e)) << std::endl;
std::cout << "A::D::B: " << dynamic_cast<B *>(dynamic_cast<D *>(e)) << std::endl;
std::cout << "A=>C=>B: " << dynamic_cast<B *>(dynamic_cast<C *>(a)) << std::endl;
std::cout << "A=>D=>B: " << dynamic_cast<B *>(dynamic_cast<D *>(a)) << std::endl;
return 0;
}
Además, la implementación puede ser muy compleja (depende del lenguaje, ver la respuesta de benjismith).
El principal problema con la herencia múltiple se resume muy bien con el ejemplo de tloach. Al heredar de múltiples clases base que implementan la misma función o campo, el compilador debe tomar una decisión sobre qué implementación heredar.
Esto empeora cuando heredas de varias clases que heredan de la misma clase base. (herencia de diamantes, si dibujas el árbol de herencia obtienes una forma de diamante)
Estos problemas no son realmente problemáticos para que un compilador los supere. Pero la elección que el compilador tiene que hacer aquí es bastante arbitraria, esto hace que el código sea mucho menos intuitivo.
Me parece que al hacer un buen diseño de OO nunca necesito una herencia múltiple. En los casos en que lo necesito, generalmente encuentro que he estado usando la herencia para reutilizar la funcionalidad mientras que la herencia solo es apropiada para las relaciones "is-a".
Hay otras técnicas como mixins que resuelven los mismos problemas y no tienen los problemas que tiene la herencia múltiple.
El problema más obvio es con la anulación de funciones.
Digamos que tiene dos clases A y B, ambas definen un método "doSomething". Ahora define una tercera clase C, que hereda de A y B, pero no anula el método "doSomething".
Cuando el compilador inicia este código ...
C c = new C();
c.doSomething();
... ¿qué implementación del método debería usar? Sin más aclaraciones, es imposible para el compilador resolver la ambigüedad.
Además de anular, el otro gran problema con la herencia múltiple es el diseño de los objetos físicos en la memoria.
Los lenguajes como C ++ y Java y C # crean un diseño fijo basado en direcciones para cada tipo de objeto. Algo como esto:
class A:
at offset 0 ... "abc" ... 4 byte int field
at offset 4 ... "xyz" ... 8 byte double field
at offset 12 ... "speak" ... 4 byte function pointer
class B:
at offset 0 ... "foo" ... 2 byte short field
at offset 2 ... 2 bytes of alignment padding
at offset 4 ... "bar" ... 4 byte array pointer
at offset 8 ... "baz" ... 4 byte function pointer
Cuando el compilador genera código de máquina (o bytecode), usa esos desplazamientos numéricos para acceder a cada método o campo.
La herencia múltiple lo hace muy complicado.
Si la clase C hereda tanto de A como de B, el compilador tiene que decidir si distribuye los datos en orden AB o en orden BA.
Pero ahora imagine que está llamando métodos a un objeto B. ¿Es realmente solo una B? ¿O es realmente un objeto C llamado polimórficamente, a través de su interfaz B? Dependiendo de la identidad real del objeto, el diseño físico será diferente, y es imposible conocer el desplazamiento de la función para invocar en el sitio de llamada.
La forma de manejar este tipo de sistema es eliminar el enfoque de diseño fijo, permitiendo que cada objeto sea consultado para su diseño antes de intentar invocar las funciones o acceder a sus campos.
Así que ... para resumir, es un dolor en el cuello para los autores de compiladores admitir herencia múltiple. Entonces, cuando alguien como Guido van Rossum diseña python, o cuando Anders Hejlsberg diseña c #, saben que el apoyo a la herencia múltiple hará que las implementaciones del compilador sean mucho más complejas y, presumiblemente, no creen que el beneficio valga la pena.
La herencia múltiple es una de esas cosas que no se usa a menudo, y puede ser mal utilizada, pero a veces es necesaria.
Nunca entendí que no añadiera una función, solo porque podría ser mal utilizada, cuando no hay buenas alternativas. Las interfaces no son una alternativa a la herencia múltiple. Por un lado, no le permiten hacer cumplir las condiciones previas o posteriores. Al igual que cualquier otra herramienta, debe saber cuándo es apropiado usarla y cómo usarla.
Los problemas que mencionan no son realmente tan difíciles de resolver. De hecho, por ejemplo, Eiffel lo hace perfectamente bien. (y sin introducir opciones arbitrarias ni lo que sea)
Por ejemplo, si heredas de A y B, ambas tienen el método foo (), entonces por supuesto no quieres una elección arbitraria en tu clase C heredando tanto de A como de B. Debes redefinir foo para que quede claro lo que será utilizado si se llama a c.foo () o si no, debe cambiar el nombre de uno de los métodos en C. (podría convertirse en barra ())
También creo que la herencia múltiple a menudo es bastante útil. Si miras las bibliotecas de Eiffel, verás que se usa en todas partes y, personalmente, me perdí la función cuando tuve que volver a programar en Java.
No creo que el problema del diamante sea un problema, consideraría ese sofisma, nada más.
El peor problema, desde mi punto de vista, con herencia múltiple es RAD: víctimas y personas que dicen ser desarrolladores, pero que en realidad están atascados con medio conocimiento (en el mejor de los casos).
Personalmente, estaría muy feliz si finalmente pudiera hacer algo en Windows Forms como este (no es el código correcto, pero debería darle la idea):
public sealed class CustomerEditView : Form, MVCView<Customer>
Este es el problema principal que tengo al no tener herencia múltiple. PUEDES hacer algo similar con las interfaces, pero hay lo que yo llamo "código s ***", es este doloroso y repetitivo c *** que tienes que escribir en cada una de tus clases para obtener un contexto de datos, por ejemplo.
En mi opinión, no debería haber ninguna necesidad, ni lo más mínimo, de CUALQUIER repetición de código en un lenguaje moderno.
No hay nada de malo en la herencia múltiple en sí. El problema es agregar herencia múltiple a un lenguaje que no se diseñó teniendo en cuenta la herencia múltiple desde el principio.
El lenguaje de Eiffel admite la herencia múltiple sin restricciones de una manera muy eficiente y productiva, pero el lenguaje fue diseñado desde ese comienzo para respaldarlo.
Esta característica es compleja de implementar para los desarrolladores de compiladores, pero parece que ese inconveniente podría ser compensado por el hecho de que un buen soporte de herencia múltiple podría evitar el soporte de otras características (es decir, sin necesidad de Interfaz o Método de Extensión).
Creo que apoyar la herencia múltiple o no es más una cuestión de elección, una cuestión de prioridades. Una característica más compleja requiere más tiempo para ser implementada y operativa correctamente y puede ser más controvertida. La implementación de C ++ puede ser la razón por la cual la herencia múltiple no se implementó en C # y Java ...
Uno de los objetivos de diseño de frameworks como Java y .NET es hacer posible que el código compilado funcione con una versión de una biblioteca precompilada, para funcionar igualmente bien con las versiones posteriores de esa biblioteca, incluso si esas versiones subsiguientes agregar nuevas funciones. Mientras que el paradigma normal en lenguajes como C o C ++ es distribuir ejecutables vinculados estáticamente que contienen todas las bibliotecas que necesitan, el paradigma en .NET y Java es distribuir aplicaciones como colecciones de componentes que están "vinculados" en tiempo de ejecución .
El modelo COM que precedió a .NET intentó utilizar este enfoque general, pero en realidad no tenía herencia; en cambio, cada definición de clase definía efectivamente tanto una clase como una interfaz del mismo nombre que contenía todos sus miembros públicos. Las instancias eran del tipo de clase, mientras que las referencias eran del tipo de interfaz. Declarar una clase como derivada de otra equivalía a declarar que una clase implementaba la interfaz de la otra y requería que la nueva clase implementara nuevamente todos los miembros públicos de las clases de las que derivaba. Si Y y Z derivan de X, y luego W deriva de Y y Z, no importará si Y y Z implementan los miembros de X de manera diferente, porque Z no podrá usar sus implementaciones; tendrá que definir su propio. W podría encapsular instancias de Y y / o Z, y encadenar sus implementaciones de métodos de X a través de las suyas, pero no habría ambigüedad en cuanto a lo que deberían hacer los métodos de X: harían lo que el código de Z les ordenara explícitamente.
La dificultad en Java y .NET es que el código puede heredar miembros y tener acceso a ellos se refiere implícitamente a los miembros principales. Supongamos que uno tiene clases WZ relacionadas como se indica arriba:
class X { public virtual void Foo() { Console.WriteLine("XFoo"); }
class Y : X {};
class Z : X {};
class W : Y, Z // Not actually permitted in C#
{
public static void Test()
{
var it = new W();
it.Foo();
}
}
Parecería que W.Test()
debería crear una instancia de W llamada la implementación del método virtual Foo
definido en X
Supongamos, sin embargo, que Y y Z estaban en realidad en un módulo compilado por separado, y aunque se definieron como arriba cuando se compilaron X y W, luego se cambiaron y volvieron a compilar:
class Y : X { public override void Foo() { Console.WriteLine("YFoo"); }
class Z : X { public override void Foo() { Console.WriteLine("ZFoo"); }
Ahora, ¿cuál debería ser el efecto de llamar a W.Test()
? Si el programa tuvo que estar enlazado estáticamente antes de la distribución, la etapa de enlace estático podría ser capaz de discernir que aunque el programa no tenía ambigüedad antes de que Y y Z cambiaran, los cambios en Y y Z han hecho las cosas ambiguas y el enlazador podría negarse construir el programa a menos que o hasta que se resuelva dicha ambigüedad. Por otro lado, es posible que la persona que tiene tanto W como las nuevas versiones de Y y Z sea alguien que simplemente quiera ejecutar el programa y no tenga ningún código fuente para nada de eso. Cuando se W.Test()
, ya no estaría claro qué debería hacer W.Test()
, pero hasta que el usuario intentara ejecutar W con la nueva versión de Y y Z, no habría manera de que ninguna parte del sistema pudiera reconocer que había un problema (a menos que W se considerara ilegítimo incluso antes de los cambios en Y y Z).
digamos que tiene los objetos A y B que ambos son heredados por C. A y B ambos implementan foo () y C no. Llamo a C.foo (). ¿Qué implementación se elige? Hay otros problemas, pero este tipo de cosas es grande.
una ambigüedad que surge cuando dos clases B y C heredan de A, y la clase D hereda de B y C. Si hay un método en A que B y C han overridden , y D no lo anula, entonces qué versión del método hereda D: el de B, o el de C?
... Se llama el "problema del diamante" debido a la forma del diagrama de herencia de clase en esta situación. En este caso, la clase A está en la parte superior, tanto B como C por separado debajo de ella, y D une las dos juntas en la parte inferior para formar una forma de diamante ...