wandres.dev
CLASES Y PROPIEDADES · estado con contrato

Herencia: final por defecto, open y override

En Kotlin las clases y los miembros son finales salvo que los abras con open, override es obligatorio y a su vez abre, las clases abstractas son open implicitamente, y todo ello responde al problema de la clase base fragil que Java dejo sin resolver.

⏱ 18 min

Kotlin invirtió el valor por defecto más caro de Java. Allí, toda clase es heredable y todo método es sobrescribible salvo que escribas final, de modo que quien no piensa en herencia acaba publicando una sin saberlo. Aquí ocurre lo contrario: nada se puede extender hasta que lo declares open, y esa inversión no es una preferencia de estilo sino la aplicación literal de un consejo que la comunidad de Java lleva repitiendo desde 2001. Diseña y documenta para la herencia, o prohíbela. Kotlin eligió que prohibirla fuera lo que pasa cuando no haces nada.

🎯 Al terminar esta lección sabrás
  • Entender que clases y miembros son finales por defecto y qué habilita exactamente open.
  • Usar override con precisión, incluyendo final override y la sobrescritura de propiedades.
  • Distinguir clase abstracta, clase open e interfaz, y elegir entre ellas con criterio.
  • Argumentar el problema de la clase base frágil y por qué cerrar por defecto lo mitiga.

Final por defecto

Una clase de Kotlin no se puede heredar, y un miembro no se puede sobrescribir, mientras no lo digas explícitamente:

class Repositorio                    // final: nadie puede heredar de aqui
// class Cache : Repositorio()       // error: Repositorio es final

open class Servicio {                // ahora si es heredable
    fun cerrar() {}                  // pero este miembro sigue siendo final
    open fun ejecutar() {}           // este si se puede sobrescribir
}

Fíjate en que open en la clase y open en el miembro son decisiones separadas. Abrir la clase permite extenderla; no dice nada sobre qué métodos puede tocar quien extienda. Esa separación te deja publicar una clase heredable cuyo esqueleto de comportamiento sigue siendo tuyo, exponiendo como puntos de extensión solo los que has pensado y documentado.

La regla tiene tres excepciones que conviene tener claras. Los miembros de una interfaz son abiertos siempre, porque una interfaz existe precisamente para implementarse. Los miembros marcados abstract son abiertos por definición, y la clase que los contiene tiene que ser abstract. Y un miembro que ya es override queda abierto de nuevo, salvo que lo cierres a mano.

open, override y la cadena que se reabre

override no es opcional ni decorativo: es obligatorio, y sin él el compilador rechaza el código. Esto elimina de golpe dos errores clásicos de Java, el de creer que sobrescribes cuando en realidad declaras un método nuevo por una firma mal escrita, y el de sobrescribir por accidente algo que la clase base añadió en una versión posterior.

open class Figura {
    open val lados: Int = 0
    open fun dibujar() = println("figura")
}

open class Poligono : Figura() {
    override val lados: Int = 3
    final override fun dibujar() = println("poligono")  // se cierra aqui
}

class Triangulo : Poligono() {
    // override fun dibujar() {}   // error: dibujar es final en Poligono
}

El detalle que casi nadie recuerda es el del párrafo anterior: override implica open. Si sobrescribes un método en un nivel intermedio de la jerarquía, cualquier subclase más abajo podrá volver a sobrescribirlo, aunque tú creyeras haber puesto el punto final. final override es la forma de cerrar la cadena, y en clases intermedias suele ser lo que de verdad querías.

Las propiedades siguen la misma mecánica con una asimetría importante: puedes sobrescribir un val con un var, porque añades una capacidad, pero no un var con un val, porque quitarías el setter que el contrato del padre ya prometía. También puedes sobrescribir una propiedad con campo por una calculada, y viceversa, ya que —como viste en la lección anterior— desde fuera solo hay accesores.

🏛️

Clase abstracta

Aporta estado e implementación parcial, y obliga a completar lo que declara abstract. Es open implícitamente. Úsala cuando las subclases comparten datos y un esqueleto de comportamiento real.

🔌

Interfaz

Declara capacidades sin estado propio, admite implementaciones por defecto y se puede implementar varias veces. Es lo que quieres cuando lo compartido es el contrato, no la maquinaria.

🔒

Clase final

El valor por defecto. Se compone en lugar de extenderse, y por eso su implementación sigue siendo libre para siempre. Debería ser el noventa por ciento de tus clases.

🧬

Sealed

Jerarquía cerrada y conocida en compilación, que estudiarás más adelante. Da polimorfismo con exhaustividad comprobada, que suele ser lo que se buscaba al abrir una clase.

La razón de diseño: la clase base frágil

El motivo real de cerrar por defecto tiene nombre propio: el problema de la clase base frágil. Ocurre cuando una clase padre cambia su implementación interna de una forma perfectamente correcta y, aun así, rompe subclases que ni siquiera conoce.

open class ListaContadora : ArrayList<String>() {
    var añadidos = 0
        private set

    override fun add(element: String): Boolean {
        añadidos++
        return super.add(element)
    }

    override fun addAll(elements: Collection<String>): Boolean {
        añadidos += elements.size
        return super.addAll(elements)   // si addAll llama a add, se cuenta dos veces
    }
}

El error no está en la subclase ni en la clase base: está en que la subclase depende de un detalle no documentado, si addAll usa o no add por dentro. Esa dependencia es invisible en las firmas, no la comprueba ningún compilador y se rompe con un cambio que el autor de la base considera interno y seguro.

flowchart TD
A[La clase base cambia su implementacion interna] --> B[Las firmas publicas siguen identicas]
B --> C[Las subclases compilan sin ningun aviso]
C --> D[El comportamiento cambia en tiempo de ejecucion]
D --> E[Cerrar por defecto elimina la mayoria de estos caminos]
style A fill:#f38ba8,color:#11111b
style D fill:#f9e2af,color:#11111b
style E fill:#a6e3a1,color:#11111b

Al marcar open estás firmando algo más que un permiso: te comprometes a que las llamadas internas entre tus propios métodos formen parte del contrato, y por tanto a no cambiarlas sin considerarlo una ruptura. Cerrar por defecto convierte ese compromiso en algo que se contrae a propósito y no por omisión.

La alternativa que Kotlin pone a la misma distancia sintáctica es la delegación. Con by, un tipo implementa una interfaz reenviando automáticamente todas sus llamadas a otro objeto, y lo que antes exigía heredar se resuelve envolviendo:

interface Almacen {
    fun guardar(clave: String, valor: String)
    fun leer(clave: String): String?
}

class AlmacenMemoria : Almacen {
    private val mapa = mutableMapOf<String, String>()
    override fun guardar(clave: String, valor: String) { mapa[clave] = valor }
    override fun leer(clave: String) = mapa[clave]
}

class AlmacenContado(private val base: Almacen) : Almacen by base {
    var escrituras = 0
        private set

    override fun guardar(clave: String, valor: String) {
        escrituras++
        base.guardar(clave, valor)      // llamada explicita, sin contrato oculto
    }
}

Compara esta versión con la de la lista contadora. Aquí no existe la pregunta de si un método llama a otro por dentro, porque base es un objeto distinto al que solo se accede por su interfaz. El conteo no puede duplicarse, el cambio de implementación de AlmacenMemoria no puede alterar el resultado y todo lo que ambos tipos comparten está escrito en la interfaz. La herencia resolvía el mismo problema con menos texto y con un acoplamiento que nadie declaró.

ℹ️
Cuando el marco necesita clases abiertas

Frameworks como Spring generan subclases en tiempo de ejecución para inyectar proxies, y con clases finales no pueden. La solución es el plugin de compilador all-open, que abre automáticamente las clases con ciertas anotaciones —y kotlin-spring lo preconfigura—. En pruebas ocurre algo parecido: muchas librerías de dobles necesitaban clases abiertas, aunque hoy la mayoría ya sabe instrumentar clases finales. Que exista una salida técnica no cambia el criterio: si tu diseño solo funciona sobreescribiendo clases que nadie abrió, probablemente falta una interfaz.

La herencia es acoplamiento con contrato invisible; la composicion es acoplamiento declarado

Cerrar las clases por defecto no es desconfianza hacia quien hereda, es reconocer una asimetría que la sintaxis oculta. Cuando una clase implementa una interfaz, todo lo que comparte con el mundo está escrito en las firmas de esa interfaz: nombres, parámetros, tipos de retorno. Cuando una clase hereda de otra, comparte además algo que no está escrito en ninguna parte, que es la manera exacta en que los métodos de la base se llaman entre sí, en qué orden tocan el estado, cuáles son reentrantes y cuáles asumen haber corrido antes que otro. Ese contrato en la sombra no lo verifica el compilador, no aparece en la documentación salvo que alguien lo escriba a mano y se rompe con refactorizaciones que cualquier autor razonable consideraría internas. La herencia, por tanto, no acopla dos tipos: acopla dos implementaciones, y lo hace con una interfaz que nadie ha declarado nunca. Por eso el consejo clásico de preferir composición no es una moda de manuales: la composición te obliga a nombrar exactamente qué necesitas del otro objeto, lo escribe en una firma y deja libre todo lo demás. Kotlin lleva esa idea al nivel del lenguaje desde dos frentes a la vez: hace la herencia opt-in, para que abrir una clase sea un acto deliberado con consecuencias que aceptas, y le da a la composición una sintaxis tan corta como la herencia mediante la delegación con by, para que la alternativa correcta no sea también la más incómoda de escribir. El resultado práctico es que en un proyecto Kotlin bien diseñado las jerarquías profundas casi desaparecen y lo que queda son interfaces pequeñas, clases finales que las implementan y objetos que se pasan unos a otros. Ese código es más aburrido de leer y muchísimo más barato de cambiar, y esa es la única métrica que sobrevive al paso de los años.

⚔️ Cierra lo que no pensabas abrir
  1. Declara una clase open con un método no abierto e intenta sobrescribirlo. Explica por qué abrir la clase no abrió el miembro.
  2. Construye una jerarquía de tres niveles, sobrescribe un método en el intermedio y comprueba que el tercero puede volver a sobrescribirlo. Ciérralo con final override.
  3. Sobrescribe un val con un var y después intenta lo contrario. Justifica la asimetría en términos de contrato.
  4. Reproduce el conteo doble del ejemplo de la lista y arréglalo con composición en vez de herencia, delegando con by.
  5. Revisa tus clases open actuales y, para cada una, escribe la frase que documenta qué método llama a qué. Si no puedes escribirla, esa clase no debería estar abierta.