wandres.dev
INTERFACES Y DELEGACIÓN · componer sin heredar

Composición frente a herencia: el argumento completo, y sus excepciones

Prefiere la composición a la herencia es el consejo más repetido y peor entendido del diseño orientado a objetos, porque casi siempre se transmite como eslogan y casi nunca con el argumento que lo sostiene ni con la lista de casos donde es falso. Esta lección reconstruye el razonamiento desde su origen —el problema de la clase base frágil y la dependencia indocumentada de los métodos de una clase en sí misma—, muestra por qué Kotlin lo tiene incorporado en sus valores por defecto, delimita el terreno donde la delegación gana sin discusión y defiende con la misma seriedad los tres escenarios en los que la herencia sigue siendo la respuesta correcta.

⏱ 20 min

Ningún consejo de diseño ha sido tan citado y tan poco entendido como el de preferir la composición a la herencia. Repetido como eslogan produce arquitecturas donde nadie hereda de nada y todo el mundo escribe envoltorios, incluso allí donde la herencia era la respuesta obvia y correcta. Entendido de verdad, es un criterio afilado que se puede aplicar caso por caso, porque tiene una premisa concreta —heredar acopla a los detalles internos de otra clase, y esos detalles nadie los documenta— y unas fronteras concretas donde deja de valer. Este nivel entero ha estado preparando este momento: sabes qué puede una interfaz, sabes qué genera by y sabes exactamente dónde se detiene el envío dinámico. Con eso ya se puede juzgar, que es distinto de obedecer.

🎯 Al terminar esta lección sabrás
  • Reconstruir el problema de la clase base frágil y la dependencia de una clase en sus propias llamadas internas.
  • Explicar cómo los valores por defecto de Kotlin convierten ese consejo en una decisión explícita del programador.
  • Delimitar los escenarios donde la delegación gana y los costes concretos que trae consigo.
  • Defender los tres casos en que la herencia sigue siendo la elección correcta y saber reconocerlos.

La clase base frágil

El argumento clásico no dice que la herencia sea mala: dice que heredar de una clase te hace depender de cómo esa clase se llama a sí misma, y que esa información no forma parte de su contrato público. Si escribes una subclase de una colección que cuenta las inserciones sobrescribiendo el método de añadir uno, tu cuenta será correcta o doble según que la implementación de añadir varios se apoye o no en la de añadir uno. Ese detalle no está en la firma, no está en el tipo y frecuentemente tampoco está en la documentación. El día que cambie —en una versión menor de una librería que tú no controlas— tu subclase pasará a estar mal sin que nadie haya tocado tu código y sin que ningún compilador te avise.

Hay un segundo filo, menos citado y más molesto: la superclase también pierde libertad. Añadir un método nuevo a una clase abierta puede chocar con un método que alguna subclase ya había definido, y el choque irá desde el error de compilación hasta la sobrescritura accidental de algo que la subclase creía privado suyo. Una jerarquía abierta es un compromiso bidireccional que nadie firmó por escrito.

// El acoplamiento que no se ve: esto es correcto o incorrecto
// según cómo esté implementado internamente el supertipo.
open class ColeccionInstrumentada<T> : ArrayList<T>() {
    var insertados = 0
        private set

    override fun add(element: T): Boolean {
        insertados++
        return super.add(element)
    }
}

Kotlin responde a esto con un valor por defecto y no con un consejo: las clases y los miembros son finales salvo que escribas open. La consecuencia es que la herencia deja de ser lo que ocurre por omisión y pasa a ser una afirmación deliberada: he diseñado esta clase para que la extiendan, sé qué métodos se llaman entre sí y me comprometo a mantenerlo. Quien no puede firmar esa frase no debe escribir open.

ℹ️
Una sola bala

Una clase de Kotlin tiene como máximo una superclase y tantas interfaces como quiera. Eso convierte la herencia en un recurso escaso y las interfaces en un recurso abundante, lo cual ya es, por sí solo, media respuesta a la pregunta de este nivel.

Dónde gana la composición

La delegación no gana siempre: gana en unos escenarios muy reconocibles.

Gana cuando quieres interceptar sin reinterpretar: métricas, trazas, reintentos, control de acceso, confinamiento en un hilo. Son capas que envuelven un contrato entero y modifican una porción minúscula. Gana cuando quieres restringir: exponer solo lectura sobre algo mutable, o publicar un subconjunto del contrato de un objeto interno. Gana cuando el tipo que quieres extender no es tuyo, porque envolver funciona igual esté abierto o cerrado, sea final o sellado. Gana cuando hay varios ejes de variación que combinar, ya que una jerarquía de herencia solo puede ordenar uno y termina multiplicando clases. Y gana en pruebas, porque sustituir un delegado es trivial y sustituir una superclase no.

🧅

Capas apilables

Cada decorador es independiente y el orden de apilado es una decisión de quien construye el objeto, no del autor de las clases. Con herencia, el orden queda congelado en la jerarquía.

🧷

Acoplamiento acotado

Dependes de la interfaz, no de cómo el delegado implementa sus métodos. Ninguna refactorización interna suya puede volver incorrecto tu código en silencio.

🎛️

Ejes independientes

Persistencia, caché y validación son tres decisiones ortogonales. Como jerarquía darían un producto cartesiano de clases; como delegación, una lista de envoltorios.

Evolución tardía

Puedes añadir una capa a un sistema en producción sin tocar ninguna clase existente, porque el punto de extensión es el constructor y no el tipo.

El caso del producto cartesiano merece un momento de atención porque es el argumento más difícil de refutar. Con tres decisiones binarias e independientes, una jerarquía honesta necesita ocho clases y ninguna de ellas puede reutilizar el código de las demás salvo duplicándolo o inventando niveles intermedios artificiales. Con delegación necesitas tres envoltorios y los combinas en tiempo de ejecución en cualquiera de las ocho formas, más las que se te ocurran después. La herencia obliga a decidir la combinatoria al escribir el código; la composición la difiere al momento de construir el objeto, que es cuando de verdad se conoce.

flowchart TD
A[Quiero reutilizar comportamiento] --> B[Es una variante cerrada del mismo concepto]
B -->|Si| C[Jerarquia sealed o clase abstracta]
B -->|No| D[Necesito interceptar o combinar capacidades]
D -->|Si| E[Interfaz mas delegacion con by]
D -->|No| F[Funcion de orden superior o extension]

También tiene costes, y conviene decirlos sin adornos. Cuesta un objeto más y una llamada más por miembro, casi siempre irrelevante porque el compilador de la máquina virtual las funde, pero no siempre en un bucle cerrado. Cuesta identidad: el envoltorio no es el envuelto, y todo lo que dependa de igualdad o de referencia lo notará. Y cuesta el problema que estudiaste en la lección anterior, el de las llamadas internas que no suben. Hay además una trampa específica: si entregas tu envoltorio a un marco de trabajo que guarda la referencia que recibe y luego devuelve al objeto interno en un callback, el resto del sistema verá al envuelto y no a tu capa.

Dónde la herencia sigue ganando

Hay tres escenarios donde sustituir herencia por delegación es un error de diseño, no una mejora.

El primero son las jerarquías selladas. Cuando modelas una suma cerrada de variantes —los estados posibles de una petición, los tipos de un token, los nodos de un árbol sintáctico— lo que quieres no es reutilizar comportamiento sino declarar que el conjunto de casos está completo, para que el compilador verifique la exhaustividad de cada when. Eso solo lo da una jerarquía, y la delegación no tiene nada que ofrecer ahí.

sealed interface Resultado<out T> {
    data class Exito<T>(val valor: T) : Resultado<T>
    data class Fallo(val causa: Throwable) : Resultado<Nothing>
    data object Cargando : Resultado<Nothing>
}

El segundo es la base con estado e invariantes. Si las variantes comparten campos y un constructor que garantiza una condición, una clase abstracta es el sitio natural: la interfaz no puede guardar nada, y reproducir ese estado por delegación obliga a duplicarlo o a inyectarlo en cada implementación con la disciplina en manos del programador.

El tercero es el método plantilla diseñado y cerrado, cuando la base y sus extensiones viven en el mismo módulo, evolucionan juntas y quien escribe la base documenta qué llama a qué. Ahí no hay clase base frágil, porque no hay distancia entre autor y extensor. El consejo original de preferir composición nació pensando en librerías publicadas y consumidas por desconocidos; aplicado con literalismo dentro de un módulo cohesionado, sustituye una jerarquía clara por un enjambre de envoltorios.

💡
Formulación operativa

Herencia para es un caso de; interfaces para sabe hacer; delegación para envuelve a. Si la frase natural que describe la relación entre los dos tipos usa el verbo tener y no el verbo ser, ya tienes la respuesta antes de escribir una línea.

El mismo problema, escrito de las dos formas

Conviene ver una vez el contraste sobre un caso concreto: un repositorio al que hay que añadir caché. Con herencia, la subclase se cuela dentro del objeto y depende de qué métodos de la base llaman a cuáles.

open class RepositorioBase {
    open fun leer(id: String): String? = consultarBaseDeDatos(id)
    open fun leerVarios(ids: List<String>) = ids.mapNotNull { leer(it) }
    protected open fun consultarBaseDeDatos(id: String): String? = TODO()
}

class RepositorioConCache : RepositorioBase() {
    private val cache = mutableMapOf<String, String?>()
    override fun leer(id: String): String? =
        cache.getOrPut(id) { super.leer(id) }
    // Funciona solo mientras leerVarios siga llamando a leer.
}

Con delegación, la caché es una capa que no sabe ni le importa cómo está hecho el repositorio de dentro, y a cambio tienes que decidir explícitamente cada operación que quieres interceptar.

interface Repositorio {
    fun leer(id: String): String?
    fun leerVarios(ids: List<String>): List<String>
}

class RepositorioConCache(
    private val origen: Repositorio
) : Repositorio by origen {
    private val cache = mutableMapOf<String, String?>()
    override fun leer(id: String): String? = cache.getOrPut(id) { origen.leer(id) }
    override fun leerVarios(ids: List<String>) = ids.mapNotNull { leer(it) }
}

La segunda versión es un poco más larga y bastante más honesta: obliga a enumerar las operaciones cubiertas en lugar de confiar en un comportamiento interno que nadie prometió. Ese trabajo extra no es burocracia, es la deuda que la primera versión te dejaba a plazos.

La herencia empaqueta identidad y reutilización en un solo mecanismo, y casi todo el arte consiste en no pagar la primera cuando solo querías la segunda

La razón por la que este debate lleva treinta años vivo es que la herencia hace tres cosas a la vez y el lenguaje no te deja pedirlas por separado: establece un subtipado —el hijo es sustituible por el padre—, hereda una representación —los campos del padre viven dentro del hijo— y comparte identidad, de modo que las llamadas internas del padre se resuelven contra las sobrescrituras del hijo. Cuando de verdad necesitas las tres, la herencia es insustituible y cualquier intento de emularla con composición es una traducción torpe y más frágil. El problema es que la mayoría de las veces solo querías una. Querías subtipado para poder pasar tu objeto donde se espera el contrato, y para eso basta una interfaz, que además puedes cumplir muchas veces sin gastar tu única ranura de herencia. Querías reutilizar código, y para eso basta con tener dentro un objeto que ya lo tiene, sin heredar de él. Querías especializar un comportamiento concreto, y para eso basta con envolver e interceptar ese método. En cada uno de esos casos, heredar te da lo que pediste y te cobra además lo que no pediste: te cobra la ranura, te cobra el acoplamiento a los detalles internos del padre, y te cobra el compromiso de que el padre no cambie cómo se llama a sí mismo. Kotlin no resuelve el dilema por ti, pero sí cambia el precio relativo de las opciones de forma que el camino barato coincida con el camino sensato: las clases nacen cerradas, así que abrir una es un acto consciente; las interfaces admiten cuerpos, así que compartir algoritmos no obliga a heredar; by genera el reenvío, así que componer ya no cuesta cuarenta líneas de aburrimiento; y sealed da jerarquías cerradas y verificables para el único caso donde la jerarquía era, desde el principio, lo que querías. El criterio final que resume el nivel entero cabe en una pregunta: ¿necesitas que el compilador sepa que este objeto es aquel, o solo que sabe hacer aquello? Si la respuesta es lo segundo, y casi siempre lo es, ya no tienes que heredar de nadie.

⚔️ Juzga tres jerarquías de tu código
  1. Elige la clase open más antigua de tu proyecto y escribe, sin mirar el código, qué métodos suyos se llaman entre sí. Comprueba si acertaste: esa diferencia es la deuda que llevas.
  2. Toma una subclase que solo añada una capa transversal y reescríbela como decorador con by. Compara el número de líneas y la lista de cosas de las que ahora dependes.
  3. Busca una jerarquía con dos ejes de variación mezclados y separa uno de ellos en una interfaz delegada. Cuenta cuántas clases desaparecen.
  4. Localiza un sealed de tu código y explica por qué convertirlo en composición perdería una garantía concreta del compilador.
  5. Vuelve a la clase del punto uno y decide una de dos cosas: documenta por escrito su contrato de extensión, o quítale el open y ofrece delegación en su lugar.