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

Interfaces con implementación por defecto: cuerpo sí, estado nunca

Una interfaz de Kotlin puede llevar código: métodos con cuerpo, propiedades con accesores calculados y hasta funciones privadas auxiliares. Lo que no puede llevar es un solo byte de estado por instancia, y esa frontera no es un capricho heredado de la JVM sino la condición exacta que vuelve segura la herencia múltiple de comportamiento. Esta lección delimita qué cabe dentro de una interfaz, sigue el rastro de un cuerpo hasta el bytecode que genera el compilador —con las dos reescrituras que ha sufrido ese mecanismo y lo que implican para la compatibilidad binaria— y termina en el conflicto que aparece cuando dos interfaces aportan la misma firma con implementaciones distintas.

⏱ 18 min

Una interfaz de Kotlin no es la interfaz de Java 6 que solo declaraba firmas, ni es una clase abstracta a la que le hubieran quitado el constructor. Es un punto intermedio deliberado: puede transportar código y no puede transportar nada que ocupe memoria en la instancia. Esa asimetría es el corazón del diseño. Puedes heredar de tantas interfaces como quieras precisamente porque ninguna de ellas trae consigo un campo susceptible de duplicarse, de inicializarse dos veces o de quedar en un orden ambiguo. Todo lo interesante de este nivel se apoya en esa regla, así que conviene entenderla no como una prohibición que hay que sortear sino como el contrato que hace posible componer comportamiento sin heredar de nadie.

🎯 Al terminar esta lección sabrás
  • Delimitar con precisión qué puede contener una interfaz y por qué el estado por instancia queda fuera.
  • Escribir métodos con cuerpo y funciones privadas auxiliares dentro de una interfaz sin romper esa regla.
  • Explicar qué genera el compilador en la JVM a partir de un cuerpo de interfaz y qué consecuencias tiene sobre la compatibilidad binaria.
  • Resolver el conflicto cuando dos interfaces aportan la misma firma, usando super cualificado.

Cuerpo sí, campo no

Dentro de una interfaz caben cuatro cosas: miembros abstractos, miembros con implementación, propiedades sin respaldo y funciones privadas de apoyo. No caben ni un constructor, ni un bloque init, ni el identificador field que da acceso al campo de respaldo dentro de un accesor. Esa lista negativa es toda la definición de no tener estado.

interface Auditor {
    val origen: String                        // abstracta: declara un getter

    fun registrar(evento: String)             // abstracta: sin cuerpo

    fun registrarConSello(evento: String) {   // con implementación
        registrar(sello() + " " + evento)
    }

    private fun sello(): String = "[" + origen + "]"   // auxiliar, no visible fuera
}

Fíjate en lo que ocurre en registrarConSello: el cuerpo llama a registrar, que es abstracto, y a origen, que también lo es. Una interfaz con implementación no resuelve el problema completo, sino que compone lo que aún no existe. Escribe el algoritmo y deja agujeros tipados que alguien tendrá que rellenar. Ese es el patrón de método plantilla, salvo que aquí no hay superclase que reservar.

Todos los miembros de una interfaz son implícitamente public y open; no puedes marcarlos final porque una interfaz no tiene autoridad para cerrar nada: no sabe quién la implementará ni cuántas otras interfaces convivirán con ella en la misma clase. Las funciones private con cuerpo sí están permitidas y son el sitio correcto para la lógica auxiliar que no forma parte del contrato.

📐

Miembros abstractos

Firmas sin cuerpo y propiedades sin accesor. Son las obligaciones que la interfaz impone y los agujeros que sus propios algoritmos usan para completarse.

🧩

Miembros con cuerpo

Funciones y accesores implementados que solo pueden apoyarse en otros miembros del contrato. Se heredan por envío dinámico y se pueden sobrescribir siempre.

🔒

Ayudantes privados

Funciones private con implementación, invisibles desde fuera. Existen para que un método plantilla no tenga que exponer sus pasos intermedios en el contrato público.

🚫

Nada que ocupe memoria

Ni constructor, ni bloque init, ni campo de respaldo, ni delegación de propiedades. Cualquier cosa que necesite una dirección por instancia queda fuera.

Merece la pena insistir en el matiz del método plantilla, porque es el uso más productivo de esta capacidad y el que más se confunde con la herencia. Cuando una interfaz implementa una operación en términos de sus miembros abstractos, no está compartiendo código por comodidad: está fijando un algoritmo y publicando los puntos exactos por los que se puede variar. Quien implementa la interfaz no elige cómo se compone el resultado, elige qué piezas alimentan la composición. Esa es una relación mucho más estrecha y mucho más honesta que la de una superclase abierta, porque el conjunto de puntos de variación está declarado en el propio tipo en vez de esconderse en el orden de las llamadas internas.

💡
Las constantes sí pueden vivir en la interfaz

La prohibición es de estado por instancia, no de estado en absoluto. Un companion object dentro de la interfaz es un objeto único y estático, así que ahí sí puedes guardar constantes, valores por defecto o incluso una fábrica. Lo que nunca tendrás es un campo que se instancie una vez por cada objeto que implemente la interfaz.

Del cuerpo al bytecode

En la JVM, una interfaz solo puede declarar campos static final, de modo que el estado sobra por construcción. Lo que no era obvio es dónde poner el cuerpo. Durante años Kotlin no usó los métodos default de Java 8: compilaba el cuerpo a un método estático dentro de una clase anidada generada, DefaultImpls, y en cada clase implementadora emitía un método puente que delegaba en ella.

class AuditorConsola(override val origen: String) : Auditor {
    override fun registrar(evento: String) = println(evento)
    // El compilador añadía aquí un registrarConSello que llamaba a
    // Auditor.DefaultImpls.registrarConSello(this, evento)
}

Esa estrategia tenía una consecuencia muy concreta y muy cara: como el puente se copiaba en cada implementación en el momento de compilarla, añadir un método con cuerpo a una interfaz publicada no bastaba para que las clases ya compiladas contra ella lo tuvieran. Funcionaba al recompilar y fallaba en el binario ya distribuido.

flowchart LR
I[Interfaz con cuerpo] --> D[Metodo default real en la interfaz]
I --> C[Clase anidada DefaultImpls]
D --> N[Clases compiladas con el modo actual]
C --> V[Binarios antiguos que esperan el puente]

Después llegó el modificador experimental que emitía métodos default de verdad, y con Kotlin 2.2 esa opción se estabilizó como -jvm-default con el modo permisivo por defecto: la interfaz publica métodos default reales y conserva DefaultImpls para no romper a quien compiló contra la versión anterior. Existe además un modo que suprime esa clase de compatibilidad cuando controlas todo el grafo de dependencias y quieres el bytecode limpio.

Nada de esto existe fuera de la JVM. En Kotlin/Native y en Kotlin/JS el compilador tiene libertad para representar la interfaz como quiera, y el modelo que debes tener en la cabeza es el semántico: un cuerpo en la interfaz es un método virtual con implementación heredable. La discusión de DefaultImpls importa cuando publicas una librería con destino JVM y te preocupa la compatibilidad binaria.

Cuando dos interfaces dicen lo mismo

Si una clase hereda dos implementaciones distintas de la misma firma, el compilador no elige por ti: exige un override explícito. Y aquí Kotlin es más estricto que Java, porque también lo exige cuando una interfaz declara el miembro abstracto y la otra lo implementa. Java aplicaría la regla de que lo concreto gana; Kotlin te obliga a declarar tu intención.

interface Persistente {
    fun guardar() { println("persistiendo") }
    fun cerrar()
}

interface Cacheable {
    fun guardar() { println("cacheando") }
    fun cerrar() { println("cache limpiada") }
}

class Documento : Persistente, Cacheable {
    override fun guardar() {
        super<Persistente>.guardar()
        super<Cacheable>.guardar()
    }
    override fun cerrar() = super<Cacheable>.cerrar()
}

La sintaxis super<Persistente>.guardar() es la única forma de nombrar una implementación heredada concreta cuando hay ambigüedad, y solo funciona para supertipos directos. No puedes saltarte un nivel ni alcanzar la implementación de una interfaz que herede otra interfaz intermedia. Esa restricción es deliberada: el envío dinámico sigue siendo el mecanismo normal, y super cualificado es una fuga controlada que el lenguaje limita al vecindario inmediato.

Observa también que el override obligatorio no te obliga a elegir: puedes llamar a las dos implementaciones, a ninguna, o escribir algo completamente distinto. El compilador no arbitra el conflicto, solo se niega a resolverlo en tu lugar. Esa negativa es exactamente lo que distingue la herencia múltiple de comportamiento —resoluble, local y explícita— de la herencia múltiple de estado, que no admite una resolución tan barata.

⚠️
La ambigüedad se resuelve en la clase, no en la interfaz

Si dos interfaces chocan, el error aparece en la clase que las combina, no en las interfaces. Quien publica una interfaz con métodos de nombre genérico —ejecutar, cerrar, guardar— está sembrando conflictos futuros en código que todavía no existe. Nombra los miembros de tus interfaces con la especificidad de su dominio, no con la del verbo.

Interfaz con cuerpo o clase abstracta

La pregunta natural al final de todo esto es qué queda entonces para la clase abstracta, y la respuesta es limpia: le queda el estado y le queda el constructor. Si lo que compartes entre las implementaciones es un algoritmo, la interfaz lo hace igual de bien y además deja libre la ranura de herencia para otra cosa. Si lo que compartes son campos, un invariante que hay que establecer al construir o una inicialización con orden, la clase abstracta es el único sitio donde eso se puede expresar.

// Algoritmo compartido: interfaz. Se puede combinar con otras.
interface Reintentable {
    val intentos: Int
    fun ejecutar(): Boolean
    fun ejecutarConReintentos(): Boolean =
        (1..intentos).any { ejecutar() }
}

// Estado compartido e invariante: clase abstracta.
abstract class Conexion(protected val destino: String) {
    init { require(destino.isNotBlank()) }
    abstract fun abrir()
}

En la práctica la combinación más frecuente y más sana es tener las dos cosas: una interfaz que define el contrato y lo que se pueda derivar de él, y opcionalmente una clase abstracta de conveniencia que lo implemente parcialmente con estado para quien quiera heredar. Quien no pueda heredar —porque ya tiene superclase, porque no controla el tipo, porque prefiere componer— sigue teniendo la interfaz disponible.

La ausencia de estado no es una carencia de la interfaz: es exactamente lo que le permite ser múltiple

La pregunta que casi nadie se hace es por qué una interfaz puede tener código pero no campos, cuando una clase abstracta puede tener ambas cosas, y la respuesta contiene toda la teoría de la herencia múltiple. Heredar comportamiento de varias fuentes es un problema resoluble: si dos interfaces aportan la misma firma, hay exactamente una ambigüedad, es local, es detectable en tiempo de compilación y se resuelve escribiendo un override que decida. El compilador puede plantarse y exigir una respuesta porque la pregunta es finita y está bien planteada. Heredar estado de varias fuentes es un problema cualitativamente distinto y mucho peor. Si dos supertipos aportaran un campo con el mismo nombre, la ambigüedad ya no sería sobre qué código ejecutar sino sobre cuánta memoria reservar y cuántas veces inicializarla; y si un tipo apareciera dos veces en el grafo de herencia por caminos distintos, habría que decidir si su estado se duplica o se comparte, decisión que los lenguajes que la han intentado —la herencia virtual de C++ es el caso canónico— pagan con reglas de inicialización que casi nadie sabe recitar de memoria y con un coste de acceso a campos que deja de ser un desplazamiento constante. Kotlin, como Java antes, corta el nudo por el único sitio que lo permite mantener simple: separa lo que se puede multiplicar sin coste —contratos y algoritmos, que son inmateriales y se resuelven por envío dinámico— de lo que no se puede multiplicar sin decidir —los campos, que son memoria concreta con un ciclo de vida concreto—. Por eso una clase tiene exactamente una superclase, que es la que le da forma en memoria, y tantas interfaces como quiera, que le dan capacidades. Y por eso el consejo práctico de este nivel no es evita las clases abstractas, sino algo más fino: cuando dudes entre una interfaz con cuerpos y una clase abstracta, pregúntate si lo que quieres compartir es un algoritmo o una representación. Si es un algoritmo, la interfaz te deja componerlo con otros diez. Si es una representación, la clase abstracta es el sitio, y estás gastando tu única bala de herencia: asegúrate de que vale la pena.

⚔️ Empuja una interfaz hasta su frontera
  1. Coge una clase abstracta tuya que solo tenga métodos y ningún campo, y conviértela en interfaz. Comprueba qué se rompe y qué mejora al liberar la ranura de herencia.
  2. Escribe una interfaz con un método plantilla que llame a dos miembros abstractos, y una función privada de apoyo. Verifica que la función privada no es visible desde la clase implementadora.
  3. Descompila con javap una clase que implemente esa interfaz y localiza el método default en el bytecode de la interfaz y la clase DefaultImpls, si tu modo de compilación la genera.
  4. Provoca deliberadamente un conflicto: dos interfaces con la misma firma, una abstracta y otra con cuerpo, implementadas por la misma clase. Lee el error y resuélvelo con super cualificado.
  5. Intenta declarar un var con respaldo dentro de una interfaz. Anota el mensaje exacto del compilador: es la formulación oficial de la regla que has estudiado.