Eliminando la Herencia Múltiple y el Diamante de la Muerte

Historial de revisiones
Revisión 1.014 de Abril de 2007
Diego Bravo Estrada - AMERICATI.COM
Primera versión

Tabla de contenidos

1. Introducción
2. Descripción del Problema
2.1. Problemas con la Herencia
2.2. Herencia Múltiple
2.3. Problemas con la Herencia Múltiple
3. Cómo evitar la Herencia Múltiple
3.1. Cómo evitar la Herencia Simple
3.2. Composición e Interfases en lugar de Herencia
3.3. Reemplazo de la herencia múltiple
4. Conclusiones

1. Introducción

Este documento describe los principales problemas asociados a la herencia múltiple así como su solución. Para los ejemplos se utiliza Java (y ocasionalmente C++.)

Este documento se distribuye bajo la licencia Creative Commons Attribution-ShareAlike 3.0 Unported License http://creativecommons.org/licenses/by-sa/3.0/

2. Descripción del Problema

Como es usual en estos casos, el problema se describe mejor mediante un ejemplo.

Suponiendo que estamos modelando un conjunto de edificios construídos con distintos propósitos. Cualquier edificio podría contar (o no) con un ascensor. Esto lo modelaremos así:

class Edificio {
        private boolean ascensor;
        public boolean getAscensor() {
                return ascensor;
        }
        public void setAscensor(boolean a) {
                ascensor = a;
        }
}

Pasamos ahora a modelar un Hotel, que es un edificio que cuenta con cierto número de habitaciones:

class Hotel extends Edificio {
        private int habitaciones;
        public int getHabitaciones() {
                return habitaciones;
        }
        public void setHabitaciones(int h) {
                habitaciones = h;
        }
}

Y finalmente un Retaurant, que es un edificio que cuenta con cierto número de mesas:

class Restaurant extends Edificio {
        private int mesas;
        public int getMesas() {
                return mesas;
        }
        public void setMesas(int h) {
                mesas = h;
        }
}

Como se aprecia, tanto Hotel como Restaurant son subclases de Edificio, y reutilizan la implementación de sus métodos getAscensor() y setAscensor(). Esta herencia ha permitido la reutilización del código, y posee tres ventajas adicionales muy importantes:

  • El diseño es muy sencillo y comprensible
  • Es muy eficiente
  • Polimorfismo

La sencillez salta a la vista. La eficiencia es también el resultado de la simplicidad en las estructuras que permiten generar código muy optimizado en tiempo de compilación.

El polimorfismo está referido a la posibilidad de utilizar la implementación particular de las subclases dependiendo del objeto particular en tiempo de ejecución. Esto suena complejo pero en realidad es muy sencillo si lo vemos con un ejemplo:

Supóngase por un momento que los edificios tienen un método que calcula su precio en función de su área y el número de pisos:

class Edificio {
        double area;
        int pisos;
        double getPrecio()
                {
                return 500*area*pisos;
                }
        ...

Los hoteles podrían especializar este método considerando que las habitaciones agregan valor:

class Hotel extends Edificio {
        double getPrecio()
                {
                return 1000*habitaciones +
                        ((Edificio)this).getPrecio();
                }
        }

Análogamente lo harían los restaurantes. Ahora bien, supóngase que otra aplicación se desarrolla para hallar impuestos en los edificios:

// class OtraAplicacion
double hallarImpuesto(Edificio ed)
        {
        return 0.19*ed.getPrecio();
        }

Nótese que esta aplicación se pudo haber desarrollado incluso antes de que existan las clases Hotel y Restaurant. Gracias al polimorfismo será luego posible reutilizar esta aplicación con hoteles y restaurantes sin tener que alterarla, y obteniendo siempre el resultado correcto:

// Usar la aplicacion
Hotel h=new Hotel();
...
impuesto=OtraAplicacion.hallarImpuesto(h);

En otras palabras, el código "antiguo" está haciendo uso de código "moderno" sin necesidad de haberlo adecuado'

2.1. Problemas con la Herencia

El único (gran) problema de este diseño es que no es flexible. En general, la herencia "acopla" las clases muy fuertemente, y a largo plazo termina impidiendo la reutilización del código así como generando inconvenientes en el mantenimiento.

Por ejemplo, podría ser deseable reutilizar el código de las clases "Hotel" y "Restaurant" en otros contextos: la lógica de la "reserva de una habitación" se podría reutilizar en la "reserva de boletos de avión"; pero como sabemos, es difícil introducir un edificio en un avión o viceversa.

Otro inconveniente surge cuando las clases base son modificadas. Típicamente las aplicaciones desplegadas acceden a todos los métodos de la jerarquía del objeto, y eso puede ocasionar inconvenientes en las actualizaciones:

Para nuestro ejemplo, supóngase que los diseñadores de la clase base "Edificio" (que podrían ser distintos a quienes derivan a partir de ella) deciden un buen día modificar el método getAscensor() renombrándolo a getOtis(). Entonces probablemente todas las aplicaciones que hacen uso de Hotel y Restaurant tendrán que ser modificadas, pues estas últimas no han controlado el acceso a los métodos de su superclase "Edificio". Lo ideal sería que los cambios producto de la actualización se reflejen únicamente en Hotel y Restaurant, mas no en todo el código externo.

2.2. Herencia Múltiple

Es natural reutilizar las clases que hemos definido hasta el momento a fin de modelar un edificio más especializado y que cumple funciones tanto de restaurant como de hotel en forma simultánea. El lenguaje Java no permite hacer esto directamente, pero la sintaxis de C++ equivalente sería algo así:

// C++
class HotelRestaurant : public Hotel, public Restaurant {
}

Esta construcción es la famosa herencia múltiple. Con esta definición, es posible invocar todas las implementaciones simultáneamente:

// C++
HotelRestaurant hr;
hr.setAscensor(true);
hr.setMesas(50);
hr.setHabitaciones(200);

Antes de ver cómo se puede implementar esto en Java, veamos los inconvenientes que hemos creado:

2.3. Problemas con la Herencia Múltiple

Nuestro caso permite ilustrar algunos problemas asociados a la herencia mútiple. En primer lugar, es perfectamente posible que las clases Hotel y Restaurant tengan métodos distintos con el mismo nombre. Por ejemplo, ambas clases podrían contar con un método llamado setTrabajadores() para registrar el número de trabajadores. En su invocación surge una clara ambigüedad, la cual se debe resolver con una especificación adicional en el código:

// C++
hr.setTrabajadores(80); // Error: Ambiguo
hr.Hotel::setTrabajadores(80); // Resolucion de la ambiguedad

El siguiente problema es más serio. ¿Qué ocurre con el ascensor? ¿Hay un ascensor para el hotel y otro para el restaurante? ¿o es un único ascensor para ambos? Estas preguntas ocurren cuando se modela una herencia mútiple en la que existe un antecesor común, y corresponde al famoso "diamante de la muerte", en honor a su representación en un diagrama de clases:

Diamante de la Muerte

La mejor solución a estos problemas consiste en no involucrarse en ellos en primer lugar (más adelante se explica cómo.) Pero creo que es instructivo apreciar rápidamente la solución de C++ a este asunto. A tal efecto C++ asume que el "HotelRestaurant" efectivamente cuenta con dos ascensores los cuales deben ser accesibles mediante una sintaxis similar a la apreciada en "setTrabajadores()"; sin embargo, también se permite modelar el caso de un ascensor común mediante la denominada (extrañamente) "herencia virtual". Para esto es menester definir "Hotel" y "Restaurant" de este modo:

class Hotel : public virtual Edificio {
...
class Restaurant : public virtual Edificio {
...

La herencia virtual trae sus propios problemas. Por ejemplo, asumiendo que el edificio tiene un constructor:

class Edificio {
        bool ascensor;
public:
        Edificio(bool e):ascensor(e){}
...

Las clases derivadas deberán tomarlo en consideración como es usual:

class Hotel : public virtual Edificio {
        bool habitaciones;
public:
        Hotel():Edificio(false){}
...
... (análogamente para Restaurant)

Sin embargo, el constructor de la clase "nieta" también deberá invocar explícitamente a la abuela (y de ser necesario, también a los padres):

class HotelRestaurant: public Hotel, public Restaurant {
public:
HotelRestaurant():Edificio(true){}
};

Esto ocurre debido a que existen "dos caminos" para inicializar a los atributos de la abuela (las dos clases padre), los cuales podrían traer distintos resultados para aquélla; debido a esto, el lenguaje fuerza a la nieta a resolver la ambigüedad. Claramente esto se torna confuso'

3. Cómo evitar la Herencia Múltiple

En pocas palabras, evitando cada una de las herencias. Sin embargo, en cada situación surgirán compromisos que deben ser considerados caso por caso.

A fin de facilitar la exposición, resolvamos primero un problema más sencillo: ¿cómo eliminar una herencia simple?

3.1. Cómo evitar la Herencia Simple

A partir de esta sección volvemos a utilizar el lenguaje Java.

Antes de presentar la solución a la herencia, es conveniente repensar en los beneficios que ella nos trajo:

  1. Reutilización del código
  2. Simplicidad/claridad
  3. Ser muy eficiente
  4. Permite el polimorfismo

La solución que se presenta a continuación sacrifica los puntos 2 y 3, manteniendo el punto uno, e incluso mejorando el cuarto. De esto quizá lo más lamentable sea la pérdida de simplicidad que obligará probablemente a documentar mejor el código (¡y a contratar mejores programadores') Sin embargo, si el código tiene que ser mantenido y adaptado en el futuro (lo cual ocurre al menos en un 95% de casos) esta complejidad añadida será pagada con creces al punto de tornarse insignificante en comparación a las alternativas típicas:

  1. Parches que hacen el código incomprensible e inseguro
  2. Un rediseño estructural que acarrea una reescritura total

Dado que se añadirán elementos de indirección al diseño, es de esperarse que éste sea algo menos eficiente, pero esta ineficiencia es insignificante en aplicaciones típicas.

3.2. Composición e Interfases en lugar de Herencia

La Composición de la mano con las Interfases corresponden a un mecanismo de eliminación de la herencia, conservando sus beneficios (con las consideraciones mencionadas en la sección anterior) y automáticamente disolviendo sus inconvenientes.

Reutilización del código

La composición permite reutilizar el código, lo cual hemos listado como el beneficio número 1 de la herencia.

Dada una herencia de la clase padre "P" hacia la clase hija "H", el reemplazo por composición consiste en agregar un miembro de tipo "P" en la definición de "H", y proporcionar los métodos adecuados de acceso a este miembro.

Por ejemplo, para el caso del Hotel con respecto al Edificio, la herencia se puede transformar en una composición así:

class Hotel { // Ya no extiende Edificio
        private Edificio edificio;
        public boolean getAscensor() {
                return edificio.getAscensor();
        }
        public void setAscensor(boolean a) {
                edificio.setAscensor(a);
        }
}

Como se ve, Hotel ya no se genera a partir de una herencia, y proporciona todos los servicios a las aplicaciones externas que originalmente proporcionaba cuando hija.

Un problema que mencionamos anteriormente para la herencia correspondía a los cambios en la clase base. Como indicamos, si getAscensor() era modificado a getOtis() ahora únicamente modificaríamos la clase Hotel:

class Hotel { // Ya no extiende Edificio
        public boolean getAscensor() {
                return edificio.getOtis();
        }
        ...
}

El resto de aplicaciones usuarias ya no tendría que ser actualizado'

La única objeción que se puede plantear aquí radica en la necesidad de escribir todos los métodos necesarios para el acceso al objeto de la composición. Esto ciertamente puede ser trabajoso (sobre todo si la clase base cambia con frecuencia y/o implementa muchos métodos) por lo que es un criterio adicional para el análisis.

Polimorfismo: Las interfases al rescate

El polimorfismo fue mencionado como la cuarta ventaja de la herencia.

Lamentablemente, la composición por sí sola no mantiene el polimorfismo. Por ejemplo, para nuestro caso de aplicación de cálculo de impuestos, no podríamos hacer:

Hotel h=new Hotel(); // Hotel ya no deriva de Edificio
...
impuesto=OtraAplicacion.hallarImpuesto(h); // Error!

Afortunadamente la solución no es compleja: crearemos una interface [1] que refleje todas las operaciones del edificio (que a fin de cuentas es lo único que realmente "ve" la "OtraAplicación"):

public interface IEdificio {
        public boolean getAscensor();
        public void setAscensor(boolean a);
        double getPrecio();
}

La aplicación de cálculo de impuestos (y cualquier aplicación externa a la jerarquía) deberá ahora asumir sólo una interfaz:

// class OtraAplicacion
double hallarImpuesto(IEdificio ed) // ya no Edificio!
        {
        return 0.19*ed.getPrecio();
        }

Finalmente, nuestro edificio, el hotel y el restaurante deben anunciar que implementan dicha interfaz, de modo tal que las aplicaciones externas acepten sus objetos:

class Edificio implements IEdificio {
...
}
class Hotel implements IEdificio {
...
}
class Restaurant implements IEdificio {
...
}

De este modo, la aplicación de cálculo de impuestos sigue siendo utilizable:

Hotel h=new Hotel(); // Implementa IEdificio
...
impuesto=OtraAplicacion.hallarImpuesto(h); // Ok!

Esto ilustra otro de los principios clave de la POO: "Programar contra interfases y no contra objetos": para nuestro caso, el polimorfismo no funcionaría si la aplicación de impuestos asumiera clases y no interfases.

El diagrama correspondiente se ilustra a continuación:

Composición e Interface

3.3. Reemplazo de la herencia múltiple

Con todo lo anterior se hace bastante evidente la manera de evitar las herencias múltiples: reemplazarlas por "composición/interface", y posiblemente manteniendo una herencia simple. Esto se debe analizar caso por caso en función de varias consideraciones.

Para el ejemplo del HotelRestaurant hay varias soluciones posibles:

Como primera alternativa, el HotelRestaurant podría no heredar de ninguna clase. Para esto se crearían las interfases asociadas al hotel y al restaurante, y se haría una composición con un miembro de cada una de estas clases.

Hay que anotar que ambas interfases contienen los métodos setAscensor() y getAscensor(). Asumiendo que efectivamente existen dos ascensores (uno para los alojados y otro para los comensales), la clase HotelRestaurant deberá proporcionar nuevos métodos que resuelven la ambigüedad:

class HotelRestaurant implements IHotel, IRestaurant {
        private Hotel hotel;
        private Restaurant restaurant;
        void setAscensorRestaurant(boolean v)
                {
                restaurant.setAscensor(v);
                }
        void setAscensorHotel(boolean v)
                {
                hotel.setAscensor(v);
                }
        ...

Si en cambio deseamos asumir que existe un único ascensor, podríamos utilizar el del miembro "hotel" (ignorando el del miembro "restaurant"), o viceversa:

class HotelRestaurant implements IHotel, IRestaurant {
        private Hotel hotel;
        private Restaurant restaurant;
        void setAscensor(boolean v)
                {
                hotel.setAscensor(v);
                }
        ...

Otra posibilidad consiste en ignorar ambos ascensores y agregar un miembro de tipo Edificio, con lo que obtenemos un acceso más "directo" al único ascensor:

class HotelRestaurant implements IHotel, IRestaurant {
        private Hotel hotel;
        private Restaurant restaurant;
        private Edificio edificio;
        void setAscensor(boolean v)
                {
                edificio.setAscensor(v);
                }
        ...

Tal como se aprecia, HotelRestaurant está implementando las interfases IHotel e IRestaurant lo cual es una forma de herencia múltiple, pero sin los inconvenientes asociados.

Esta última solución se aprecia en la figura:

Solución sin herencia

Nótese también que IHotel e IRestaurant deberían extenderse de IEdificio a fin de que las clases que los implementan sean polimórficas con el Edificio.

Por ejemplo, la codificación de la interfaz IHotel es:

public interface IHotel extends IEdificio {
        public int getHabitaciones();
        public void setHabitaciones(int h);
        // setAscensor() y getAscensor() se heredan
}

Una solución distinta consiste en hacer HotelRestaurant derivado de Hotel, y hacer composición con Restaurant (o al revés.) Probablemente resulte un poco menos de escritura de código, aunque será algo menos flexible.

4. Conclusiones

  1. La herencia (simple o múltiple) suele resultar inflexible y su uso debe sopesarse con las futuras necesidades de mantenimiento
  2. La composición y las interfaces permiten eliminar las herencias a costa de un diseño algo más complejo
  3. Las aplicaciones que utilizan una jerarquía de clases deben programarse

desde el inicio contra las respectivas interfases, y rara vez contra las mismas clases

AMERICATI EIRL



[1] C++ no posee interfases como construcción del lenguaje, pero una clase sin atributos y con métodos virtuales puros consigue el mismo efecto.