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

Propiedades en interfaces: declarar la forma sin decidir el almacén

Una interfaz puede declarar propiedades y hasta implementarlas, pero nunca puede guardarlas. Detrás de esa frase hay un cambio de modelo mental que muchos programadores nunca terminan de hacer: en Kotlin una propiedad no es un campo, es un par de accesores, y solo cuando el compilador necesita almacenamiento aparece el campo de respaldo. Esta lección desmonta esa equivalencia falsa, recorre las cuatro formas legítimas de satisfacer una propiedad declarada en una interfaz, explica por qué el identificador que da acceso al respaldo simplemente no existe ahí dentro, y mide el precio real de una propiedad que se recalcula en cada lectura.

⏱ 17 min

Casi todo el mundo llega a Kotlin arrastrando la ecuación de Java entre propiedad y campo, y esa ecuación es exactamente lo que impide entender por qué una interfaz puede declarar val nombre: String sin guardar nada. En Kotlin la propiedad es un concepto del lenguaje que se materializa en accesores; el campo de respaldo es un detalle de implementación que el compilador añade solo cuando hace falta. Una interfaz declara la forma observable del tipo —qué se puede leer, qué se puede escribir— y delega en cada implementación la decisión de dónde vive realmente ese valor: en un campo, en un cálculo, en una consulta a otro objeto o en ningún sitio. Separar contrato de almacén es lo que permite que la misma interfaz la cumpla una clase de datos, un adaptador sobre una base de datos y un doble de prueba.

🎯 Al terminar esta lección sabrás
  • Distinguir la propiedad como par de accesores del campo de respaldo que a veces la sostiene.
  • Declarar propiedades abstractas y propiedades con accesor implementado dentro de una interfaz.
  • Enumerar las formas válidas de satisfacer esa declaración desde la clase, incluida la sustitución de val por var.
  • Reconocer el coste de recalcular en cada lectura y las trampas de orden de inicialización.

Una propiedad es un par de accesores

Cuando declaras una propiedad en una interfaz no estás reservando memoria: estás exigiendo métodos. Un val se traduce en un getter abstracto; un var, en un getter y un setter abstractos. Todo lo demás es idéntico a declarar funciones.

interface Sesion {
    val id: String            // exige un getter
    var expiraEn: Long        // exige getter y setter

    val activa: Boolean       // implementada: se deriva de las anteriores
        get() = expiraEn > System.currentTimeMillis()
}

activa tiene cuerpo y sigue sin tener estado, porque su getter no lee ningún campo propio: lee otro miembro del contrato. Ese es el único tipo de propiedad implementable en una interfaz, y de nuevo aparece el patrón del nivel: la interfaz escribe el cálculo y deja abiertos los datos que lo alimentan.

Lo que no cabe es igual de instructivo. No puedes escribir un inicializador —val id: String = "x" no compila—, no puedes usar el identificador field dentro de un accesor porque no hay campo al que referirse, y no puedes delegar la propiedad con by, porque el delegado es un objeto que tendría que residir en algún sitio y ese sitio sería, otra vez, un campo por instancia.

ℹ️
El mismo contrato, visto desde Java

Un val id: String en una interfaz de Kotlin se ve desde Java como un método getId(). No hay ningún campo público, ningún atributo, nada que un framework basado en reflexión sobre campos pueda encontrar. Si integras con librerías que inspeccionan campos en lugar de accesores, esta es la primera fuente de sorpresas.

Dónde vive el valor

La clase que implementa tiene cuatro caminos, y elegir entre ellos es una decisión de diseño real, no una formalidad sintáctica.

// 1. Campo generado por el constructor primario
class SesionMemoria(override val id: String) : Sesion {
    override var expiraEn: Long = 0L                    // 2. campo en el cuerpo
}

// 3. Cálculo puro: ningún campo
class SesionDerivada(private val token: Token) : Sesion {
    override val id: String get() = token.subject
    override var expiraEn: Long
        get() = token.exp
        set(valor) { token.exp = valor }                // 4. delegación manual a otro objeto
}

SesionMemoria guarda; SesionDerivada no guarda absolutamente nada y sin embargo cumple el mismo contrato con idéntica corrección. Desde fuera son indistinguibles, y esa indistinguibilidad es el objetivo entero del ejercicio.

🏗️

Campo del constructor

override val id: String en la lista de parámetros. El compilador genera el campo y el getter. Es la opción por defecto para datos que el objeto recibe ya calculados.

📦

Campo del cuerpo

Una propiedad declarada dentro de la clase con inicializador. Necesaria cuando el valor se calcula al construir o cambia durante la vida del objeto.

🧮

Cálculo puro

Un getter que deriva el valor de otros miembros. Cero memoria, coste en cada lectura, y ninguna posibilidad de que el valor quede desincronizado.

🔀

Lectura de otro objeto

El accesor consulta a un colaborador. Es delegación escrita a mano y es la forma habitual de cumplir un contrato sobre datos que viven fuera del objeto.

Elegir entre las cuatro no es indiferente aunque el tipo sea el mismo. El campo cuesta memoria y puede quedar obsoleto si la fuente de verdad cambia por otro camino; el cálculo nunca miente pero se paga en cada lectura; la consulta a un colaborador introduce una dependencia y, con ella, la posibilidad de que leer una propiedad lance una excepción. Ninguna de esas tres características aparece en la firma, y por eso la elección pertenece a la clase: es precisamente el tipo de decisión que una interfaz no debe imponer.

Hay además una asimetría que conviene tener presente: una clase puede ampliar un val de la interfaz convirtiéndolo en var, pero nunca al revés. La razón es de subtipado puro: quien pidió poder leer sigue pudiendo leer si además se puede escribir, mientras que quien pidió poder escribir se quedaría sin la mitad del contrato si le entregas algo de solo lectura.

flowchart TD
P[Propiedad declarada en la interfaz] --> G[Getter abstracto]
P --> S[Setter abstracto solo si es var]
G --> A[Campo del constructor]
G --> B[Campo del cuerpo]
G --> C[Calculo sin campo]
G --> D[Lectura de otro objeto]

Lo que cuesta no tener campo

Una propiedad implementada en la interfaz se recalcula en cada lectura. No hay memoización posible ahí dentro, porque memoizar es guardar, y guardar es tener estado. Si el cálculo recorre una colección o toca la red, has escrito una llamada cara con aspecto de acceso a atributo, y quien la use en un bucle no tendrá ninguna pista sintáctica de lo que está pagando.

interface Informe {
    val lineas: List<String>
    val resumen: String                       // se recorre entero en cada lectura
        get() = lineas.joinToString(limit = 3)
}

class InformeCacheado(override val lineas: List<String>) : Informe {
    override val resumen: String by lazy { calcular() }      // el caché vive en la clase
    private fun calcular(): String = lineas.joinToString(limit = 3)
}

El patrón es el que conviene recordar: la interfaz define qué significa el resumen, y la clase decide cuántas veces se paga. La delegación con by lazy es legal aquí porque el delegado ya vive en una clase con campos.

La segunda trampa es de orden de inicialización. Si un accesor implementado en la interfaz lee una propiedad abstracta que la clase satisface con un campo inicializado en el cuerpo, y algo invoca ese accesor durante la construcción, leerás el valor por defecto del campo en lugar del valor previsto. Es el mismo problema de siempre con los miembros virtuales llamados desde constructores, con el agravante de que aquí la llamada tiene aspecto de simple lectura de dato.

interface Etiquetado {
    val nombre: String
    val etiqueta: String get() = nombre.uppercase()
}

class Producto : Etiquetado {
    // Se ejecuta antes de que nombre tenga valor: falla o miente.
    val cabecera = etiqueta
    override val nombre: String = "teclado"
}

La regla que evita el problema es la de siempre, pero conviene enunciarla en los términos de este nivel: durante la construcción de un objeto, todo miembro heredado —de una superclase o de una interfaz— puede estar viendo una versión a medio construir de ti mismo. Si necesitas ese valor al construir, calcúlalo tú explícitamente en vez de pedírselo a un accesor heredado.

⚠️
Regla operativa para propiedades implementadas en interfaces

Que el getter sea barato, puro y libre de efectos. En cuanto necesites caché, entrada y salida, o cualquier cosa que se note en un perfilador, conviértelo en función con nombre de verbo o baja la implementación a la clase. Una propiedad promete al lector que leerla es gratis; incumplir esa promesa es una mentira que el sistema de tipos no puede detectar por ti.

Cuando dos interfaces piden lo mismo

Las propiedades participan de las mismas reglas de conflicto que las funciones, porque en el fondo son funciones. Si dos interfaces declaran la misma propiedad y al menos dos aportan implementación, la clase tiene que decidir con un override explícito, y puede apoyarse en super cualificado dentro del accesor.

interface ConNombreCorto { val nombre: String get() = "corto" }
interface ConNombreLargo { val nombre: String get() = "largo y descriptivo" }

class Elemento : ConNombreCorto, ConNombreLargo {
    override val nombre: String
        get() = super<ConNombreLargo>.nombre
}

El caso más interesante es el que no da error: dos interfaces que declaran la misma propiedad de forma abstracta y con el mismo tipo. Ahí no hay conflicto, hay unificación: una sola implementación satisface ambos contratos, y la clase acaba cumpliendo dos interfaces con un único campo. Es la razón por la que conviene que las propiedades de un contrato se llamen como el concepto del dominio y no como el papel que juegan en esa interfaz concreta; los nombres bien elegidos hacen que las capacidades encajen sin fricción, y los mal elegidos obligan a escribir accesores puente que no aportan nada.

La interfaz describe la superficie observable del objeto y guarda silencio deliberado sobre su representación

El motivo profundo por el que una interfaz declara propiedades pero no las guarda no es una restricción técnica de la JVM que Kotlin heredara sin remedio, sino la formulación más limpia que existe de un principio muy viejo: un contrato debe hablar de lo que se observa y callar sobre cómo se produce. Cuando declaras val id: String en una interfaz estás afirmando una única cosa —que cualquier objeto de este tipo puede responder a la pregunta cuál es tu identificador— y estás renunciando explícitamente a decir si esa respuesta vendrá de un campo, de una concatenación, de una fila en una tabla o de un valor generado la primera vez que alguien pregunte. Esa renuncia es la que compra toda la flexibilidad posterior. Es lo que permite que el mismo contrato lo cumpla una data class inmutable en un test, un adaptador que consulta un ORM en producción y un objeto perezoso que solo materializa el identificador cuando alguien lo mira, sin que ninguna línea del código cliente cambie ni se entere. Si el lenguaje permitiera declarar un campo en la interfaz, cada implementación heredaría no solo una obligación sino también una decisión de representación, y esa decisión viajaría por todo el grafo de tipos contaminando implementaciones para las que no tiene sentido: la que consulta la base de datos tendría un campo que nunca usa, la perezosa no podría serlo, y la inmutable pagaría memoria por un dato que ya tiene en otra forma. La consecuencia práctica de todo esto es un criterio de diseño que va mucho más allá de la sintaxis: cuando escribas una interfaz, pregúntate por cada miembro si describe algo que el mundo exterior necesita poder observar o si describe cómo lo estás guardando tú. Lo primero pertenece al contrato. Lo segundo pertenece a la clase, y colarlo en la interfaz es la forma más silenciosa de convertir una abstracción en una plantilla de implementación, es decir, de perder exactamente aquello por lo que declaraste una interfaz en lugar de una clase.

⚔️ Separa el contrato del almacén
  1. Toma una interfaz tuya y clasifica cada propiedad: ¿describe algo observable del dominio o filtra cómo lo guardas? Baja a la clase todo lo segundo.
  2. Escribe dos implementaciones del mismo contrato, una con campos y otra puramente calculada, y comprueba que un mismo test pasa con ambas sin tocarse.
  3. Convierte un val de la interfaz en var desde una implementación concreta y observa que compila; intenta lo contrario y lee el error.
  4. Implementa una propiedad en la interfaz con un getter caro y mídela dentro de un bucle. Después cachéala en la clase con by lazy y compara.
  5. Provoca la trampa de inicialización: haz que un accesor implementado en la interfaz se invoque durante la construcción de una clase cuyo campo aún no está asignado, y explica el valor que obtienes.