Delegación con by: reenvío generado, y el envío que nunca vuelve
La cláusula by en la lista de supertipos convierte a Kotlin en el único lenguaje mayoritario donde el patrón decorador no cuesta una línea por método. El compilador escribe los reenvíos, guarda el delegado en un campo sintético y te deja sobrescribir solo lo que te interesa. Esta lección abre esa caja: qué miembros se generan exactamente y cuáles no, por qué equals y toString quedan fuera, cuándo se evalúa la expresión delegada, y sobre todo la limitación que hunde a quien no la conoce, que es que las llamadas internas del objeto delegado jamás alcanzan tus sobrescrituras.
Escribir un decorador a mano es una de las tareas más humillantes de la programación orientada a objetos: cuarenta métodos que no hacen nada salvo pasar la pelota, uno de los cuales hace algo interesante, y ninguna manera de que el compilador te avise si mañana la interfaz crece y olvidas uno. Kotlin resolvió esa humillación con dos caracteres en la lista de supertipos. Pero la delegación no es herencia disfrazada ni herencia mejorada: es una construcción distinta con garantías distintas, y la diferencia se paga siempre en el mismo sitio. Quien entiende qué genera el compilador y qué relación queda entre el decorador y el decorado usa by con precisión quirúrgica; quien lo trata como un atajo para heredar escribe bugs que solo aparecen cuando alguien llama al método equivocado.
- Describir exactamente qué miembros genera el compilador ante una cláusula
byy dónde guarda el delegado. - Identificar lo que queda fuera del reenvío, incluidos los miembros heredados de
Any. - Explicar por qué las llamadas internas del delegado no alcanzan las sobrescrituras del decorador.
- Construir un decorador real sobrescribiendo un solo método y reconocer cuándo esa técnica no es aplicable.
Qué escribe el compilador por ti
La forma canónica declara la interfaz en la lista de supertipos y, tras by, una expresión que produce un objeto que ya la cumple.
interface Repositorio {
fun leer(id: String): String?
fun escribir(id: String, valor: String)
fun borrar(id: String)
}
class RepositorioConTraza(
private val origen: Repositorio,
private val log: (String) -> Unit
) : Repositorio by origen {
override fun escribir(id: String, valor: String) {
log("escribir " + id)
origen.escribir(id, valor)
}
}
El compilador recorre los miembros de Repositorio, descarta los que tú has sobrescrito y genera un reenvío para cada uno de los restantes. Aquí eso significa que leer y borrar existen en la clase aunque no las hayas escrito, y que su cuerpo es una llamada al delegado. El delegado no se relee de la propiedad en cada uso: la expresión que sigue a by se evalúa una sola vez, durante la construcción, y su resultado se guarda en un campo sintético que suele aparecer en el bytecode con un nombre como $$delegate_0.
De ahí salen tres consecuencias que conviene fijar. Si la expresión delegada es una propiedad mutable, reasignarla después no cambia a quién reenvían los métodos generados. Si el delegado es también una propiedad tuya, hay dos referencias al mismo objeto: la tuya y la sintética. Y si no necesitas la tuya, puedes ahorrártela declarando el parámetro del constructor sin val y delegando directamente sobre él.
Hay una asimetría importante frente a la herencia: by solo funciona con interfaces. No puedes delegar en una clase, ni siquiera abierta, porque delegar exige un conjunto de miembros bien definido y virtualizable que solo un contrato puro garantiza. Eso empuja el diseño en una dirección concreta y saludable: para poder componer, primero hay que haber extraído la interfaz, y extraer la interfaz obliga a decidir qué forma parte del contrato y qué es implementación.
equals, hashCode y toString pertenecen a Any, no a la interfaz delegada, así que no se generan reenvíos para ellos. Un decorador sobre una lista imprimirá la referencia del objeto en lugar de sus elementos y no será igual a la lista que envuelve. Si tu envoltorio va a vivir en un conjunto, en una clave de mapa o en una aserción de test, tendrás que escribir esos tres a mano.
El envío que no vuelve
Aquí está el fondo del asunto. El delegado es un objeto independiente que fue construido sin saber que alguien lo iba a envolver: no tiene ninguna referencia al decorador. Cuando uno de sus propios métodos llama a otro método suyo, esa llamada se resuelve dentro de él y nunca sube al decorador.
class ListaContadora<T>(
private val interna: MutableList<T> = mutableListOf()
) : MutableList<T> by interna {
var altas = 0
private set
override fun add(element: T): Boolean {
altas++
return interna.add(element)
}
}
val lista = ListaContadora<String>()
lista.add("a") // altas pasa a 1
lista.addAll(listOf("b", "c")) // altas sigue siendo 1
addAll no está sobrescrito, así que se reenvía tal cual a interna.addAll, y esa implementación inserta llamando a su add, no al tuyo. El contador miente. Con herencia real esto habría funcionado, porque la subclase y la superclase son el mismo objeto y el envío virtual sube siempre al override más derivado.
Lo grave del ejemplo no es que falle, sino cómo falla: sin excepción, sin advertencia del compilador y sin ninguna pista en el tipo. El programa compila, los tests que solo usan add pasan, y el error aparece meses después en un informe donde las cifras no cuadran. Es exactamente la clase de fallo que un sistema de tipos no puede atrapar, porque no hay nada mal tipado: la delegación está haciendo con total corrección lo único que sabe hacer.
flowchart LR C[Llamada externa] --> D[Decorador] D --> R[Reenvio generado] R --> O[Objeto delegado] O --> S[Llamada interna del delegado] S --> O D -.no la alcanza.-> S
Merece la pena señalar el detalle simétrico: los métodos con cuerpo de la interfaz tampoco escapan a esta regla. Si el contrato trae una implementación por defecto y el delegado la ha sobrescrito, el reenvío generado llamará a la versión del delegado; si no la ha sobrescrito, llamará a la versión por defecto ejecutada sobre el delegado. En los dos casos, el receptor es el objeto de dentro. Tu decorador nunca aparece como receptor de nada que él mismo no haya declarado.
Esa flecha punteada es toda la lección. La delegación no crea una identidad compartida, crea una cadena de objetos, y la polimorfía se detiene en el primer eslabón. No es un defecto de Kotlin: es la definición de composición. Cualquier lenguaje que reenvíe en lugar de heredar tiene exactamente el mismo comportamiento, y la única forma de evitarlo es que la interfaz esté diseñada de modo que sus operaciones compuestas se apoyen en las primitivas a través del receptor externo, cosa que casi ninguna interfaz de biblioteca garantiza porque nunca lo prometió.
Decorar sin escribir métodos
Cuando la limitación anterior no aplica, la técnica es imbatible. Los casos buenos comparten un rasgo: el método que sobrescribes es una hoja del contrato, no una operación que otras invoquen internamente.
// Restringir: se expone solo lectura sobre algo mutable.
class SoloLectura<T>(fuente: List<T>) : List<T> by fuente
// Combinar dos capacidades sin una sola línea de reenvío manual.
interface Lector { fun leer(id: String): String? }
interface Escritor { fun escribir(id: String, v: String) }
class Almacen(
lector: Lector,
escritor: Escritor
) : Lector by lector, Escritor by escritor
La delegación múltiple es legal mientras las interfaces no compartan firmas; si las comparten, el compilador exige un override explícito igual que en el conflicto de implementaciones por defecto. Y como el conjunto de reenvíos se calcula al compilar la clase, añadir un miembro abstracto a la interfaz delegada no rompe nada: el compilador simplemente generará uno más la próxima vez.
Instrumentar
Métricas, trazas o auditoría alrededor de un contrato entero, tocando solo los métodos que interesan. El delegado no se entera y su corrección no cambia.
Restringir
Publicar un contrato más estrecho sobre un objeto más capaz, o exigir una comprobación de permisos antes de las operaciones sensibles.
Combinar
Reunir varias capacidades independientes en un tipo sin inventar una jerarquía que las ordene artificialmente en un solo eje.
Sustituir en pruebas
Envolver una implementación real y sobrescribir el único método que quieres controlar, sin reimplementar el contrato entero a mano.
Hay un patrón adicional que aprovecha que la expresión delegada es una expresión cualquiera y no necesariamente un parámetro: delegar en un objeto construido en el sitio. class Registro : MutableList<String> by mutableListOf() produce un tipo nominal propio que se comporta como una lista y que puede crecer con métodos suyos, sin exponer ni permitir sustituir el almacén interno. Es la forma más barata que existe en Kotlin de crear un tipo de dominio a partir de una colección sin heredar de ella.
La contrapartida de todo lo anterior es que el decorador no es sustituible por el decorado en términos de identidad. Si un marco de trabajo recibe tu envoltorio, lo registra y luego devuelve en una llamada de vuelta la referencia interna que él mismo guardó, el resto del sistema verá al objeto envuelto y tu capa desaparecerá del recorrido. Ese fallo no lo detecta ningún tipo y solo se manifiesta en ejecución, así que conviene comprobarlo la primera vez que un decorador cruza una frontera que no controlas.
Antes de decorar con by, pregúntate si el método que vas a sobrescribir es invocado por otros métodos de la misma interfaz, y si el objeto envuelto puede acabar comparándose o imprimiéndose. Si ambas respuestas son no, by es la herramienta perfecta. Si alguna es sí, escribe los reenvíos que hagan falta a mano o replantea la interfaz.
Hay una lectura ingenua de by que consiste en verlo como una forma más cómoda de heredar, y esa lectura produce siempre el mismo error: alguien sobrescribe un método esperando que todo el objeto pase por su versión, descubre meses después que la mitad de las rutas lo esquivan, y concluye que la delegación de Kotlin está rota. No lo está. Lo que ocurre es que herencia y delegación resuelven problemas emparentados con contratos opuestos, y el contrato de la delegación es más débil a propósito. En la herencia hay un objeto: la superclase y la subclase comparten identidad, comparten el puntero this, y por eso cualquier llamada interna de la superclase se resuelve dinámicamente contra la implementación más derivada. Esa propiedad es potentísima y es también exactamente la fuente del problema de la clase base frágil, porque significa que la superclase depende, sin saberlo y sin poder documentarlo del todo, de cómo sus propios métodos se llaman entre sí; cambiar un detalle interno de la superclase —que addAll deje de apoyarse en add— altera silenciosamente el comportamiento de subclases escritas por gente que nunca vio ese código. En la delegación hay dos objetos, la identidad no se comparte, y ese aislamiento es una garantía: el objeto delegado se comporta exactamente igual esté o no envuelto, su corrección es independiente de la tuya, y ninguna refactorización interna suya puede convertir tu decorador en un programa incorrecto de manera sorda. Lo que pierdes es la capacidad de reinterpretar el comportamiento interno del delegado; lo que ganas es que nada del delegado depende de ti. Vista así, la pregunta por qué mi override no se llama revela que estabas usando la herramienta equivocada: querías reinterpretar desde dentro, y la delegación solo sabe interceptar desde fuera. La conclusión práctica no es desconfiar de by, sino usarla con el modelo mental correcto —una frontera entre dos objetos, no una fusión— y reservar la herencia para las jerarquías donde alguien haya diseñado, documentado y congelado deliberadamente cómo se llaman entre sí los métodos de la base.
- Escribe la
ListaContadorade la lección, comprueba queaddAllno incrementa el contador y explica en una frase por dónde se escapa la llamada. - Arréglala sobrescribiendo también
addAlly cualquier otra operación de inserción. Anota cuántos métodos has necesitado y qué pasaría si la interfaz creciera. - Imprime y compara dos instancias de un decorador que delegue en
List. Después implementaequals,hashCodeytoStringy repite. - Localiza el campo sintético del delegado en el bytecode con
javapy verifica que la expresión delegada se evalúa una sola vez. - Diseña un decorador que sí sea seguro: elige una interfaz cuyos métodos no se llamen entre sí, sobrescribe uno para añadir métricas y justifica por qué en ese caso el reenvío basta.