Sabemos ya que es cada una y como hacer uso de ellas. ¿Cuál es mejor? ¿Cuál se debe usar? Cada una tiene ventajas e inconvenientes. Sin embargo siempre se debe favorecer la composición sobre la herencia. La razón es simple: la composición es mas flexible y evita que se creen dependencias. No todo son ventajas ya que la composición resulta menos eficiente al no delegar en la cadena de prototipos.

El problema del gorila y la banana

¿Animales de nuevo? En este caso el ejemplo no es mío sino de una cita que ilustra muy bien un problema que a menudo nos encontramos en la herencia de clases.

"The problem with object-oriented languages is they’ve got all this implicit environment that they carry around with them. You wanted a banana but what you got was a gorilla holding the banana and the entire jungle." ~ Joe Armstrong

Esto viene a decir que para conseguir una pequeña funcionalidad debemos hacernos cargo también de todo lo que lleva por detrás de forma implícita. Es decir, la banana depende del gorila, que a su vez depende de la jungla.

La composición favorece la modularidad y evita el acople de dependencias. Un alto acoplamiento no solo nos acarrea cargar con dependencias que no necesitamos sino también poder meternos en un problema grave si necesitamos hacer cambios en dichas dependencias.

¿Que pasaría si necesitamos que la temperatura de la jungla disminuya? El gorila ya no puede vivir allí y por tanto no tenemos quien sujete la banana. ¿Y si creamos una especie de gorila polar? Perfecto, el gorila polar puede vivir en una jungla fria, sin embargo las bananas no se dan en climas fríos. Pero podemos crear algo que no sea una banana pero que sea como una banana ¿no? También es posible, el gorila polar podría tener una manzana-sabor-banana. ¿Y si la jungla aumenta su temperatura? Ni el gorila ni el gorila polar podrán vivir allí.

Podríamos tener toda una variedad de fauna, sitios y frutas y nunca estaríamos a salvo de necesitar más opciones que a su vez crean nuevas dependencias.

En esta ocasión nos alejaremos del mundo animal y expondré un caso práctico para reflejar las diferencias, con ventajas e inconvenientes, de la herencia y la composición.

Ejemplo 1 - Fábrica de vehículos

Nos han pedido hacer un software para una fábrica que se encarga de crear vehículos. En concreto coches, motos y barcos. Podemos por tanto empezar por hacer diferencia entre terrestres y acuáticos, ya que unos navegan y otros circulan.

const waterVehicle = { sail(){ console.log('Sailing') } };  
const groundVehicle = { drive(){ console.log('Driving') } };  

Ahora definiremos los vehículos que nos han pedido haciendo uso de la herencia para que adquieran las propiedades que son comunes a cada uno.

const car = Object.create(groundVehicle);  
car.wheels = 4;  
const bike = Object.create(groundVehicle);  
bike.wheels = 2;  
const boat = Object.create(waterVehicle);  

Ya podemos crear, por ejemplo, objetos que hereden de los prototipos que hemos creado.

const myCar = Object.create(car);  
const myBike = Object.create(bike);  
const myBoat = Object.create(boat);

myCar.drive(); // 'Driving'  
myBike.wheels; // 2  
myBoat.sail(); // 'Sailing'  

El mercado de vehículos demanda que haya coches que puedan circular fuera de asfalto, por lo que podemos crear un objeto que herede de car y que usaremos como prototipo para crear vehículos todocamino.

const suv = Object.create(car);  
suv.offroad = function(){ console.log('Doing some off-road!') };

const mySuv = Object.create(suv);  
mySuv.drive();  // 'Driving'  
mySuv.wheels;   // 4  
mySuv.offroad() // 'Doing some off-road!'  

Pero los clientes se han vuelto demasiado cómodos y ahora demandan que haya vehículos anfibios que puedan ir por mar y carretera. Tendremos que crear una nueva clase que soporte ambas modalidades o crear una que herede de las que ya tenemos y nos ahorramos algo de trabajo.

const amphiVehicle = Object.create(waterVehicle);  
amphiVehicle.drive = function() { console.log('Driving') };

const myAmphi = Object.create(amphiVehicle)  
myAmphi.drive() // 'Driving'  
myAmphi.sail()  // 'Sailing'  

El vehículo anfibio ha causado furor, pero resulta demasiado caro para la mayoría, quieren algo mas pequeño y barato. Por restricciones de fábrica esto solo es posible en vehículos offroad ya que para llegar al mar necesitamos pasar por tierra. Así que crearemos un nuevo objeto que pueda navegar y conserve las propiedades que ya tenemos.

const aquasuv = Object.create(suv);  
aquasuv.sail = function() { console.log('Sailing') };  
aquasuv.offroad(); // 'Doing some off-road!'  

¿Que pasaría si ahora los clientes demandaran un vehículo off-road y un barco que pudieran volar? ¿Modificamos los prototipos suv y boat para añadir esta funcionalidad? Estaríamos repitiendo el mismo código en dos sitios. ¿Creamos un nuevo prototipo airVehicle y luego creamos objetos que hereden de él? Repetiríamos aún más código ya que tendriamos que implementar de nuevo todas las funcionalidades del off-road y el barco. Hemos llegado sin querer a una situación donde cualquier cambio supone gran cantidad de esfuerzo y costes, y nos llegan noticias de que los directivos quieren hacer nuevos cambios.

Ejemplo 2 - Recortes de presupuesto

La fábrica anterior se ha hecho demasiado grande y costosa, y los directivos quieren desplazarse a un nuevo lugar más pequeño, ahorrando costes pero conservando la misma funcionalidad que tenemos. Nos hemos metido en un problema porque en el nuevo sitio no hay espacio para crear tantos prototipos de vehículos distintos. Sin embargo alguien nos sugiere una idea: crear líneas de montaje que añadan funcionalidades a un vehículo básico en lugar de hacerlo en base a un prototipo. Tenemos muchos tipos de vehículo, pero realmente solo hacen 3 cosas: drive, sail y offroad. Dicho y hecho.

const road = { drive(){ console.log('Driving') } };  
const offroad = { offroad(){ console.log('Doing some off-road!') } };  
const water = { sail(){ console.log('Sailing') } };  

Usaremos por tanto composición para crear los nuevos vehículos. Pero nos indican que la estrategia de venta ha cambiado para ahorrar aún mas costes, ahora los clientes tienen que hacer los pedidos directamente a fábrica. Los indecisos que no especifiquen lo que quieren se les enviará un vehículo de prueba. ¿Cómo automatizamos este proceso? Utilizaremos una función factory.

function vehicles(model) {  
  switch(model) {
    case 'car':
      return Object.assign({ wheels: 4 }, road);

    case 'bike':
      return Object.assign({ wheels: 2 }, road);

    case 'suv':
      return Object.assign({ wheels: 4 }, road, offroad);

    case 'boat':
      return Object.assign({}, water);

    case 'amphi':
      return Object.assign({}, water, road);

    case 'aquasuv':
      return Object.assign({ wheels: 4 }, water, road, offroad);

    default:
      return { test() { console.log('Test vehicle') } };
  }
}

const car = vehicles('car');  
car.drive();    // 'Driving'

const aquasuv = vehicles('aquasuv');  
aquasuv.sail(); // 'Sailing'  

Como vemos la nueva fábrica es capaz de crear vehículos con la misma funcionalidad y con mucho menos código y esfuerzo. Los directivos están contentos y nos felicitan por el trabajo realizado. La economía comienza a recuperarse.

Ejemplo 3 - Abaratamiento de costes

La fábrica lleva meses funcionando de forma correcta, sin embargo el departamento de contabilidad nos informa que estamos gastando demasiados recursos en la fabricación de piezas para nuestros vehículos. Las piezas se fabrican una y otra vez en la propia fábrica cada vez que creamos un vehículo y nos sugieren que deleguemos este proceso a otra fábrica externa que pueda hacerlo por nosotros y que simplemente nos encarguemos de montar los vehículos y personalizar el color.

Sin embargo dicha fábrica necesita saber primero que tipo de vehículos vamos a fabricar para crear un prototipo de cada uno. Nos reunimos con ellos y diseñamos todos los modelos que necesitamos.

const protos = {  
  car:     Object.assign({ wheels: 4 }, road),
  bike:    Object.assign({ wheels: 2 }, road),
  suv:     Object.assign({ wheels: 4 }, road, offroad),
  aquasuv: Object.assign({ wheels: 4 }, road, offroad, water);
  boat:    Object.assign({}, water),
  amphi:   Object.assign({}, water, road)
};

Tenemos todos los prototipos creados y estamos listos para empezar a montar nuevos vehículos, por tanto modificaremos la fábrica para que se adapte a la nueva forma de trabajar. Haremos uso de Object.create para delegar en los prototipos que la fábrica externa nos provee.

function vehicles(model, color) {  
  if (protos[model]) {
    return Object.assign( Object.create(protos[model]), { color } );
  } else {
    return { test() { console.log('Test vehicle') } };
  }
}

Como vemos la nueva fábrica es mucho mas eficiente y menos compleja. Simplemente comprobamos que el prototipo que necesitamos exista y devolvemos un nuevo objeto que delegue en él. Si no, devolvemos el de prueba.

Hemos combinado la delegación de prototipos (herencia) con la composición de objetos para lograr tener lo mejor de ambos. Como vemos es posible anidar las funciones Object.create y Object.assign de forma simple para conseguirlo. ¿Que pasaría si necesitáramos añadir nuevos modelos a nuestra fábrica? Añadamos por ejemplo un avión y un barco que sea capaz de volar.

const air = { flying(){ console.log('Flying') } };

const protos = {  
  car:      Object.assign({ wheels: 4 }, road),
  bike:     Object.assign({ wheels: 2 }, road),
  suv:      Object.assign({ wheels: 4 }, road, offroad),
  aquasuv:  Object.assign({ wheels: 4 }, road, offroad, water);
  boat:     Object.assign({}, water),
  airboat:  Object.assign({}, water, air),
  amphi:    Object.assign({}, water, road),
  plane:    Object.assign({ wings: 2 }, air);
};

Solo necesitamos crear un nuevo objeto air y modificar el objeto protos. La función factory vehicles se queda exactamente igual. ¿Y si un cliente nos pide que modifiquemos solo su vehículo para que pueda volar? Podemos simplemente componer ese vehículo con las funcionalidad que nos pide sin crear un nuevo prototipo.

Object.assign(customerVehicle, air);  

Conclusión

La herencia resulta útil cuando tenemos métodos que se repiten en muchos objetos que se comportan de la misma manera. Si tenemos 1000 objetos que se comportan como un coche, podemos entonces crear un prototipo car en el que deleguen y no copiar 1000 veces las mismas cosas. Sin embargo debe evitarse crear cadenas largas de prototipos, no más allá de 1 o 2 enlaces, para evitar las prácticas que nos llevan al problema del Ejemplo 1.

Al final del capítulo Javascript #3 comenté que no se debe recaer en el uso de las "nuevas clases" de ES6 a menos que se sepa lo se está haciendo. El problema de la herencia y las clases recae no en su uso sino lo que condicionan cuando las usamos, se vuelven adictivas y tarde o temprano recaeremos de nuevo en situaciones que se solucionarían de forma sencilla gracias a la composición.

"Toma la píldora azul: el cuento termina, despiertas en tu cama y creerás lo que quieras creer. Toma la píldora roja: permaneces en el país de las maravillas y te mostraré qué tan profundo llega el agujero del conejo." - The Matrix (1997)