Flipadismos de mecanismos en programación orientada a objetos
Recomendaba un viejo profesor mío, que si un libro de programación orientada a objetos empezaba hablándote de herencia, lo tirases a la basura. No lo comparto, ni creo que lo dijera literalmente o tiraríamos todos a la basura, pero se entiende el punto.
Estaba pensando en escribir algún día sobre COI (Composition over Inheritance) tras experimentar seriamente con posibilidades en JavaScript/TypeScript. Hoy no es ese día, antes me gustaría dejar por escrito que la mayoría de nuestros problemas surgen porque nos venimos arriba aplicando mecanismos para resolver problemas que no tenemos.
Sobre clases e interfaces
Es importante entender realmente que en ingeniería de software, la palabra más importante en diseño es “interfaz”, a lo que muchas veces llamamos también contrato.
El objetivo de las clases es definir e implementar atributos y comportamientos que tiene un tipo en sí (a atributos, campos, métodos, etc, les llamamos “miembros”). A todos los miembros públicos, los que percibimos desde “fuera” cuando manipulamos una clase y objeto, es a lo que llamamos interfaz, o API, o como se ha dicho, contrato.
Una clase es instanciable, puede crearse objetos con dichos atributos y comportamientos, define su interfaz y la implementación de la misma, cumple su propio contrato.
La abstracción
Las clases no dan problemas per se, el problema llega en sistemas jerárquicos o taxonómicos profundos, donde creemos que inevitablemente necesitamos crear variaciones.
Tradicionalmente se expone la herencia con ejemplos tan bobos como:
class Animal {
  comer() { console.log("Comiendo") }
}
class Pajaro extends Animal {
  volar() { console.log("Volando") }
}
class Pinguino extends Pajaro {
  // No queremos fly
  volar() { throw new Error("Los pingüinos no vuelan") }
}Ejemplos como este me he encontrado a punta pala en ámbito académico. Son especializaciones sin fundamento. Tipos diferenciados “porque sí”. Transmiten la idea equivocada de que lo primero al diseñar es crear jerarquías. En la realidad especializar o generalizar es de las acciones más duras y últimas a las que debemos recurrir. Suele presentarse estos casos o bien para enseñar lo que es la herencia o bien como antiejemplo metido con calzador para hablar de composición.
Veamos un problema un poco más real (no por ello bueno), con decisiones de diseño habituales sin tanta profundidad. Supongamos que modelando alguna especie de juego de tablero, tenemos animales que realizan movimientos, algunos con capacidad de volar y otros no. Eso sí, todos pueden comer. Una tendencia natural suele ser la de generalizar creando una clase abstracta:
abstract class Animal {
  comer() { console.log("Comiendo") }
  // No implementamos move
  abstract mover(hacia: Posicion): void; 
}
class Mono extends Animal {
  mover(hacia: Posicion) { 
    // ... lógica del movimiento normal
  }
}
class Loro extends Animal {
  mover(hacia: Posicion) { 
    // ... lógica del movimiento volando
  }
}Nótese que se ha simplificado la “jerarquía”. Esto es un ejemplo más realista y simple que nos permite manifestar los problemas de la herencia de igual forma. Un error común que debemos evitar activamente es especializar y crear subtipos intermedios a la primera característica que vemos diferente.
Aquí el mayor impacto de diseño lo introduce la necesidad de generar un contrato parcial. Una clase abstracta es precisamente una clase que no termina de implementarse (y tampoco puede ser instanciada) sino que delega en sus clases hijas los detalles de implementación que le puedan faltar.
Quiere decir “si quieres ser de tipo Animal, tendrás que implementar una lógica de movimiento”.
Ahora viene el verdadero problema. ¿Qué pasa si tenemos 20 tipos de animales distintos y queremos reglas de movimiento distintas para algunos pero que otros (la mayoría) se mantengan igual?
Llega el momento en que mucha gente empieza a ponerse nerviosa porque va a tener código duplicado, que necesita un caso base, y recurre a cosas aparentemente más simples como:
abstract class Animal {
  comer() { console.log("Comiendo") }
  // Ahora Animal sí implementa mover, por defecto movimiento terrestre.
  mover(hacia: Posicion) {
    // ... lógica de movimiento más común, terrestre.
  }
}
class Mono extends Animal {
    // Nada que implementar.
}
class Loro extends Animal {
  override mover(hacia: Posicion) { 
    // los tipo Loro reemplazan su comportamiento de movimiento.
  }
}Y entonces hay quien se flipa más y dice “voy a refactorizar y limpiar esto un poco”, llegando a resultados como:
class Animal {
  comer() { console.log("Comiendo") }
  // Ahora Animal sí implementa mover, por defecto movimiento terrestre.
  mover(hacia: Posicion) {
    // lógica de movimiento normal, terrestre.
  }
}
class Ave extends Animal {
  override mover(hacia: Posicion) { 
    // los tipo Ave reemplazan comportamiento de movimiento por defecto.
  }
}
Todo parece muy bonito, muy obvio… el problema es que se está diseñando y remodelando todas las clases basándose en UNA CARACTERÍSTICA. En El Mundo Real®, las clases no solo tienen más características exclusivas sino que además tienen la mala costumbre de mezclar más de una responsabilidad (sobre esto se puede hablar largo y tendido). No solo tienen un único comportamiento que pueda ser diferente entre distintos subtipos.
¿Qué haría alguien que está comenzando y no conoce estos mecanismos?
Paradójicamente suelen proponer algo mejor. Quiero que veas el siguiente código y te preguntes si entiendes su propósito:
class Animal {
  puedeVolar: boolean
  ...
  comer() { console.log("Comiendo") }
  mover(hacia: Posicion) {
    if ( this.puedeVolar ) {
      // Lógica de movimiento de vuelo
    } else {
      // Lógica de movimiento terrestre 
    }
  }
}
¿Qué tenemos aquí? Una variable de instancia (un campo, o atributo si es público, o propiedad en los mundos de JS) que define si es o no volador. Podemos indicar esto en tiempo de construcción. Y una bifurcación, dependiendo de ese campo booleano. Todo surgiendo de lo más básico de la programación estructurada, variable + control de flujo.
En nuestro sistema de ejemplo, si algo puede o no volar solo afecta a la forma en la que se mueve, y es una característica que pueden o no tener una gran parte de animales, independientemente de su “reino”. Por ejemplo, un pingüino no puede volar por mucha ave que sea, pero una cabra con un JetPack sí.
Este código es mucho más fácil de mantener, su intención sigue siendo clara, la responsabilidad de mover sigue siendo la misma y sigue siendo única (mover al animal a una posición). La diferencia es que ahora tenemos un atributo para identificar si el animal puede volar y una acción para cada caso. ¿Esto es escalable?, ¿es una práctica adecuada en POO?, me dan igual esas preguntas, lo que me importa es:
- Se entiende, su propósito queda claro
- Es testable
- Es sostenible
- Es fácil generalizar en otro momento si llegase a ser necesario
Lo último es lo más importante, si estás pensando “pero Álex, ¿y si algo que puede volar pudiera en un futuro tener mayores diferencias en otros campos y comportamientos?, ¿o si una función u otra parte del sistema necesitara trabajar con animales estrictamente voladores?“.
Te puedes responder con otra pregunta: “¿Y si no?”, ¿por qué no esperar a tener la necesidad?, quizás resulta que nunca la llegas a tener y habrás añadido complejidad a tu código y trabajado más para nada. Además, ¿dónde pones el límite?, ¿por qué parar solo en la necesidad de diferenciar voladores entonces? Puestos al “por si acaso”, genera cientos de clases que nadie te ha pedido.
Aquí puede surgir otro tipo de flipadismo, especialmente al ver ese booleano, el de tener demasiado en cuenta el principio abierto-cerrado o identificar una supuesta baja escalabilidad. Peca de exactamente lo mismo, estás pensando en diseño anticipado y asumiendo que tu código no puede modificarse en cualquier momento para cumplir con el principio que consideres oportuno cuando sea necesario o con demandas de escalabilidad.
Incluso acercándote a la necesidad, podrías no llegar a necesitar aún la generalización. ¿Necesitas asegurar que solo se trabaja con lo que sea volador? Filtra, usa guards, o con una interfaz ligera Volador { isVolador: true }, que te fuerce a usarlo como “as Volador” en tiempo de compilación o con validadores de tipos en el caso de TS (funciones booleanas que devuelven : is Volador). Hay muchas más opciones sencillas que ayudan a mantener una naturaleza polimórfica sin recurrir a generalizaciones.
Por supuesto, luego está la composición y le implementación de interfaces funcionales y/o segregadas habilitantes (implements Volador) pero ese es tema gordo para otro día.
En resumen, no te flipes. Sé que es más fácil decirlo que hacerlo, que no manejamos bien la incertidumbre, nos genera ansiedad y odiamos postergar decisiones. Recomiendo echar un ojo a la presentación de Eduardo Ferro en el CAS Vitoria de 2016, “El arte del patadón pa’lante”, un recurso que tengo ya quemadísimo, pero es que ha envejecido muy bien. Oro puro.