De receptores de contexto a parámetros de contexto: la historia de un rediseño
Kotlin experimentó desde 1.6.20 con los receptores de contexto, los mantuvo cuatro años detrás de una bandera y acabó descartándolos por completo. Esta lección reconstruye qué prometía aquel diseño, qué cuatro problemas lo hundieron, qué se conservó del rediseño que entró en beta en 2.2 y quedó estable en 2.4, y cómo se migra código escrito con la versión antigua.
Los equipos de lenguajes rara vez retiran una característica que llevaba años en manos de sus usuarios, y cuando lo hacen conviene estudiar el episodio con atención, porque suele contener más enseñanza sobre diseño que diez características que salieran bien a la primera. Los receptores de contexto llegaron a Kotlin 1.6.20 en 2022 detrás de una bandera experimental, generaron una cantidad enorme de entusiasmo, sostuvieron librerías reales durante cuatro años y fueron finalmente descartados sin ruta de compatibilidad: el rediseño entró en beta en 2.2, los receptores desaparecieron alrededor de 2.3 y los parámetros de contexto se estabilizaron en 2.4. La distancia entre ambos diseños parece pequeña cuando se miran las dos sintaxis una al lado de la otra, y es enorme cuando se entiende qué significa cada una. Esta lección explica esa distancia.
- Reconstruir el diseño de los receptores de contexto y lo que prometía respecto de las alternativas de la época.
- Identificar los cuatro problemas estructurales que llevaron a descartarlo en lugar de a parchearlo.
- Distinguir con precisión qué cambió y qué se conservó en el rediseño estabilizado en Kotlin 2.4.
- Migrar código escrito con receptores de contexto y anticipar qué construcciones no tienen equivalente directo.
Cómo eran los receptores de contexto
La sintaxis antigua declaraba solo tipos, sin nombres, y hacía que cada uno de esos tipos entrara en el cuerpo como receptor implícito, con el mismo estatus que el receptor de una función de extensión o que el objeto de un with. Sus miembros quedaban directamente disponibles, sin cualificar.
// Diseno antiguo, retirado. Solo tipos, sin nombres.
context(Logger, Transaccion)
fun Pedido.confirmar(): Confirmacion {
info("confirmando $id") // miembro de Logger, sin cualificar
registrar(this) // miembro de Transaccion, sin cualificar
return Confirmacion(id)
}
Leído hoy, ese cuerpo ilustra por sí solo la promesa y el peligro. La promesa era la concisión total, una escritura donde el contexto desaparece de la vista y el código parece hablar el vocabulario del dominio; era, en el fondo, la generalización del receptor único de las extensiones a un número arbitrario de receptores, que es una idea elegante y perfectamente defendible sobre el papel. El peligro es que en ese cuerpo hay tres nombres, info, registrar e id, y para saber a qué pertenece cada uno hace falta conocer de memoria la superficie completa de tres tipos.
La versión antigua permitía además declarar contextos sobre clases, de modo que una clase entera podía existir solo dentro de un contexto, y sus instancias capturaban los receptores del punto de construcción. Esa capacidad, que a primera vista parece la más potente de todas, resultó ser una de las fuentes principales de confusión, porque mezclaba el ámbito de una región de código con el tiempo de vida de un objeto.
// Diseno antiguo. La instancia capturaba el contexto de su construccion.
context(Transaccion)
class Repositorio {
fun guardar(p: Pedido) = registrar(p) // que transaccion, y hasta cuando
}
La pregunta del comentario no tenía respuesta cómoda. Si el repositorio se construía dentro de una transacción y se guardaba en un campo, seguía apuntando a esa transacción mucho después de que la región hubiera terminado, y nada en el texto avisaba de ello. El diseño ofrecía la sintaxis de un ámbito y la semántica de una captura, que son cosas incompatibles, y esa contradicción se resolvió más tarde por la vía radical de prohibir el caso.
Los cuatro problemas que lo hundieron
El primero y decisivo es la contaminación del ámbito de nombres. Al convertir cada contexto en receptor implícito, todos los miembros de todos los tipos de contexto entraban a competir en la resolución de nombres con los miembros del receptor de extensión, con los del receptor de despacho, con las variables locales y con las funciones de nivel superior importadas. Añadir un método a una interfaz de contexto podía cambiar el significado de un cuerpo que llevaba dos años compilando, y hacerlo en silencio. La lista de autocompletado dejaba de ser útil en cuanto había dos contextos.
// Diseno antiguo. De donde sale cada uno de estos tres nombres.
context(Repositorio, Cache)
fun sincronizar(id: String) {
val local = leer(id) // de Cache, salvo que Repositorio tambien lo tenga
val remoto = buscar(id) // de Repositorio, hoy
guardar(remoto) // y manana, cuando Cache anada guardar, cambia
}
El comentario de la última línea describe un fallo silencioso real: si la biblioteca que publica Cache añade en su versión siguiente un método guardar compatible, el cuerpo sigue compilando y empieza a escribir en otro sitio. Ningún error, ninguna advertencia y ningún cambio en el archivo. Ese escenario, que en los receptores de extensión está acotado porque solo hay un receptor y los miembros ganan siempre a las extensiones, se multiplicaba por el número de contextos declarados.
El segundo es la imposibilidad de desambiguar. Como los contextos no tenían nombre, la única forma de referirse a uno concreto era la etiqueta de tipo, y esa forma se rompe justo cuando más falta hace: con dos contextos del mismo tipo, o con tipos genéricos donde la etiqueta no puede expresar los argumentos de tipo. El diseño no tenía respuesta para casos que aparecían en cuanto se usaba en serio.
El tercero es la colisión con las extensiones. Dentro de una función de extensión con contextos había varios this posibles, y las reglas de precedencia entre ellos eran correctas pero imposibles de recordar. La pregunta cuál es mi this aquí no debería tener que hacérsela nadie, y era la pregunta constante.
Se podría objetar que ese problema ya existía con las funciones de extensión y con with, y la objeción es justa a medias. Existía, sí, pero acotado a un receptor por nivel y con reglas de precedencia que un programador experimentado tenía interiorizadas. Los receptores de contexto multiplicaban ese único receptor por un número arbitrario y añadían un plano de resolución nuevo, con lo que la complejidad no crecía linealmente sino con el producto de los ámbitos vigentes.
El cuarto es de naturaleza distinta y probablemente el que inclinó la balanza: la ilegibilidad para quien lee sin editor. Un cuerpo escrito con receptores de contexto solo es comprensible con la ayuda del entorno de desarrollo, que colorea y resuelve cada nombre; en una revisión de código en un navegador, en un diff, en un registro de incidencias o en un libro, el mismo cuerpo es opaco. Un lenguaje cuya legibilidad depende de la herramienta ha externalizado un problema que le corresponde.
flowchart LR A[Receptores de contexto 1.6.20] --> B[Cuatro anos tras bandera] B --> C[KEEP de rediseno] C --> D[Parametros de contexto beta en 2.2] D --> E[Receptores eliminados hacia 2.3] E --> F[Estables en 2.4]
Los dos diseños nunca fueron sustituibles uno por otro y la migración es manual. El código escrito con la sintaxis antigua deja de compilar en las versiones donde los receptores ya no existen, y no hay una reescritura automática que sea correcta en todos los casos: pasar de un receptor implícito a un parámetro nombrado obliga a cualificar cada uso, y decidir la cualificación exige saber de qué contexto venía cada nombre, que es exactamente la información que el diseño antiguo escondía. Se puede alegar que la experimentalidad estaba anunciada y la bandera era explícita, pero el episodio dejó bastante código roto.
Qué cambió y qué se conservó
La tentación es resumir el rediseño como añadir nombres, y esa lectura se queda corta. Lo que cambió es el estatus semántico del valor: pasó de ser un receptor a ser un parámetro. Un receptor participa en la resolución de nombres, aporta miembros al ámbito y responde a this; un parámetro no hace ninguna de las tres cosas y solo existe bajo su identificador. Todo lo demás se deriva de ahí.
// Diseno estable. Nombres obligatorios y ningun receptor implicito.
context(log: Logger, tx: Transaccion)
fun Pedido.confirmar(): Confirmacion {
log.info("confirmando $id") // procedencia visible sin editor
tx.registrar(this) // this es inequivocamente el Pedido
return Confirmacion(id)
}
Conviene notar cuánto se conservó, porque el rediseño no fue una retirada sino un ajuste quirúrgico. Se conservó la posición sintáctica, delante de la declaración; se conservó la posibilidad de declarar varios contextos a la vez; se conservó la resolución por tipo de dentro hacia fuera; se conservó la propagación automática hacia las llamadas del cuerpo, que es toda la utilidad de la característica; y se conservó la compilación a parámetros ordinarios sin coste añadido. Lo que se retiró fue exclusivamente el estatus de receptor, y de esa única retirada se sigue todo lo demás.
Los cuatro problemas se disuelven a la vez, y merece la pena verificarlo uno por uno porque es un ejercicio limpio de diseño. No hay contaminación de nombres, porque los miembros de Logger solo existen detrás de log. La desambiguación es trivial, porque cada contexto tiene su identificador, y dos contextos del mismo tipo son perfectamente declarables aunque sean mala idea. La colisión con extensiones desaparece, porque solo hay un this, el del receptor de extensión. Y la legibilidad sin herramientas se recupera por completo: en un diff, log.info dice de dónde viene.
Hay además una ganancia que no estaba entre los objetivos declarados y que resultó decisiva para la adopción: el diseño nuevo es enseñable. Explicar los receptores de contexto exigía haber explicado antes los receptores implícitos, su orden de precedencia, las etiquetas de tipo y la interacción con las extensiones, es decir, cuatro conceptos previos y ninguno trivial. Explicar los parámetros de contexto exige una sola frase: son parámetros que quien llama no escribe. Todo lo demás se deduce de lo que cualquiera ya sabe sobre parámetros, y esa economía conceptual es una propiedad de diseño tan real como la corrección.
Junto a ese cambio central hubo otros tres que conviene tener presentes. Los contextos dejaron de admitirse sobre clases y quedaron restringidos a funciones y propiedades, con lo que la confusión entre ámbito y tiempo de vida se elimina por construcción. Las referencias invocables a declaraciones con contexto, que sencillamente no compilaban, empezaron a funcionar en 2.3. Y en 2.3.20 se retiró un privilegio de resolución que el diseño arrastraba desde el principio y que la lección siguiente estudia en detalle: hasta esa versión, ante dos sobrecargas aplicables, la que declaraba contextos se consideraba más específica que la que no, y ese trato de favor desapareció.
De tipos a nombres
Antes se declaraban tipos sueltos; ahora, pares de nombre y tipo. El uso pasa de implícito a cualificado y la procedencia vuelve al texto.
De receptor a parámetro
El valor ya no aporta miembros al ámbito ni responde a this. Es la diferencia real entre los dos diseños, y de ella se derivan las demás.
Fuera de las clases
Los contextos sobre declaraciones de clase desaparecieron. El ámbito describe regiones de código, no ciclos de vida de objetos.
Referencias y sobrecarga
Las referencias invocables llegaron en 2.3 y el privilegio de especificidad se retiró en 2.3.20. La característica se hizo predecible antes de estabilizarse.
Queda una construcción sin equivalente directo y conviene anticiparla antes de empezar a migrar. En el diseño antiguo era posible declarar una función de extensión dentro de una interfaz de contexto y llamarla desde cualquier cuerpo que declarara ese contexto, sin cualificar nada, porque el contexto era receptor y sus extensiones entraban en el ámbito con él. Con parámetros de contexto eso deja de funcionar tal cual: la extensión sigue existiendo, pero para invocarla hay que traer su receptor de despacho al ámbito, normalmente envolviendo la llamada en un with sobre el propio parámetro de contexto. Es un patrón muy usado en los DSL escritos con la sintaxis antigua y es el punto donde una migración por búsqueda y reemplazo se rompe.
interface Formateador {
fun Dinero.legible(): String // extension dentro del tipo de contexto
}
context(fmt: Formateador)
fun resumen(total: Dinero): String =
with(fmt) { total.legible() } // hay que traer fmt al ambito para usarla
Para cada declaración con la sintaxis antigua, ponle nombre a cada tipo de contexto, compila y deja que el compilador señale cada nombre que dejó de resolverse; cualifica esos usos uno a uno. Cuando un contexto era una clase, extrae las funciones a nivel superior y declara el contexto en cada una. Cuando un cuerpo llame a una extensión declarada dentro de un tipo de contexto, comprueba que ese tipo siga estando disponible como argumento de contexto, porque ese es el caso que más silenciosamente cambia de significado.
La lección que deja este rediseño trasciende con mucho a Kotlin, y se puede formular como un principio general sobre cualquier mecanismo de paso implícito: la implicitud es aceptable en el punto de transferencia y es tóxica en el punto de uso. Los dos diseños que has visto son el experimento controlado perfecto para verlo, porque resuelven el mismo problema, con la misma tecnología de compilador y con el mismo equipo detrás, y solo se diferencian en dónde colocan la frontera de lo implícito. Los receptores de contexto la colocaban tarde: el valor entraba sin escribirse y además se usaba sin escribirse, de modo que un identificador cualquiera del cuerpo podía provenir de la clase, del receptor de extensión, de un contexto, de otro contexto, de una importación o del ámbito local, y el texto no ofrecía ninguna pista para distinguirlos. Eso no es concisión sino pérdida de información, y la diferencia entre ambas cosas está en si lo omitido es recuperable leyendo. Los parámetros de contexto la colocan pronto: el valor entra sin escribirse, que es donde la omisión ahorra trabajo real y no destruye nada, porque quien llama ya sabe en qué región está; y a partir de ahí el valor es tan explícito como cualquier otro, con su nombre, su tipo y su navegabilidad. El resultado es que la única pregunta que un lector se puede llegar a hacer, de dónde salió este contexto, tiene una respuesta acotada y local, mientras que la pregunta que planteaba el diseño antiguo, a qué de las seis cosas posibles pertenece este nombre, no la tenía. Hay una segunda enseñanza, más incómoda, sobre el proceso: los receptores de contexto pasaron cuatro años tras una bandera experimental y ese periodo no fue un desperdicio sino el mecanismo que permitió descubrir los cuatro problemas con código real antes de comprometerse para siempre. Un lenguaje que promete compatibilidad indefinida solo puede permitirse rediseñar así de a fondo si mantiene la disciplina de no estabilizar nada hasta que la comunidad lo haya castigado en producción, y el precio de esa disciplina lo pagan quienes adoptan lo experimental. Saber a qué versión de esa apuesta te estás sumando, cada vez que activas una bandera de compilador, forma parte del oficio tanto como conocer la sintaxis.
- Escribe una función con dos contextos en la sintaxis antigua, tal como aparece en la lección, y traduce su cuerpo a la sintaxis estable cualificando cada nombre.
- Elige dos interfaces cualesquiera de tu proyecto que tengan un método con el mismo nombre y explica qué pasaría al usarlas juntas como receptores de contexto.
- Busca una clase tuya que solo tenga sentido dentro de un ámbito y conviértela en funciones de nivel superior con contexto. Anota qué pierdes y qué ganas.
- Justifica en tres frases por qué la eliminación de los contextos sobre clases no fue una restricción arbitraria sino la corrección de una confusión conceptual.
- Revisa qué banderas experimentales tienes activadas hoy en tu compilador y decide, para cada una, si aceptarías reescribir a mano todo el código que dependa de ella.