wandres.dev
CONTEXT PARAMETERS · dependencias implícitas

Sintaxis y semántica: declarar, proveer y resolver un contexto

La forma completa de los parámetros de contexto tal como quedaron estables en Kotlin 2.4: cómo se declaran sobre funciones y propiedades, por qué llevan nombre obligatorio y no son receptores implícitos, cómo se provee un argumento de contexto en el sitio de llamada, qué diagnóstico exacto emite el compilador cuando no encuentra ninguno y qué restricciones acompañan a las propiedades y a la sobrescritura.

⏱ 20 min

La sintaxis de los parámetros de contexto cabe en una línea y la semántica cabe en tres reglas, lo cual es engañoso, porque cada una de esas reglas es el resultado de descartar una alternativa que parecía razonable. Un parámetro de contexto se declara delante de la función, lleva nombre obligatorio, no se convierte en receptor implícito y se resuelve por tipo a partir de lo que haya disponible en el ámbito de la llamada. Ese conjunto de decisiones produce un mecanismo que se comporta como un parámetro normal en todo salvo en una cosa: quien llama no lo escribe. Esta lección construye el modelo mental completo, desde la declaración hasta el error de compilación, y deja claro qué está garantizado y qué no.

🎯 Al terminar esta lección sabrás
  • Declarar parámetros de contexto sobre funciones y propiedades con la sintaxis estable de Kotlin 2.4.
  • Explicar por qué el valor recibido no es un receptor implícito y qué se gana con esa decisión.
  • Proveer argumentos de contexto en el sitio de llamada y enumerar qué valores del ámbito son candidatos.
  • Reconocer los diagnósticos de contexto ausente y de contexto ambiguo y las restricciones sobre propiedades.

Declarar y usar

La declaración se escribe delante del resto de la firma, con la palabra clave context seguida de una lista de pares de nombre y tipo entre paréntesis. Cada par es un parámetro de contexto: tiene nombre, tiene tipo y se comporta dentro del cuerpo exactamente igual que un parámetro de valor, con la única diferencia de que ningún sitio de llamada lo menciona.

interface Logger {
    fun info(mensaje: String)
    fun error(mensaje: String, causa: Throwable? = null)
}

context(log: Logger)
fun procesar(pedido: Pedido): Respuesta {
    log.info("procesando ${pedido.id}")     // hay que nombrarlo
    return Respuesta(pedido.total)
}

Detente en la tercera línea del cuerpo, porque ahí está la decisión central del diseño. El servicio se usa escribiendo log.info, con el nombre delante, y no simplemente info. Un parámetro de contexto no es un receptor implícito: no aporta sus miembros al ámbito, no responde a this y no compite con nada en la resolución de nombres. La consecuencia inmediata es que declarar tres contextos no llena el autocompletado con los miembros de tres tipos, y que renombrar un método de Logger no puede romper silenciosamente un cuerpo que llamaba a otra cosa con el mismo nombre. Esta es la diferencia esencial respecto del diseño anterior, y la tercera lección del nivel cuenta por qué costó tanto llegar a ella.

Se pueden declarar varios contextos a la vez, cada uno con su nombre, y combinarlos con un receptor de extensión y con parámetros de valor ordinarios. El orden de lectura de una firma completa es el que sugiere la escritura: primero lo que viene del ámbito, luego el sujeto de la operación, luego los argumentos que quien llama decide.

context(log: Logger, tx: Transaccion)
fun Pedido.confirmar(cupon: Cupon?): Confirmacion {
    log.info("confirmando $id")             // this es el Pedido, no el Logger
    tx.registrar(this)
    return Confirmacion(id, cupon)
}

Cuando un contexto solo hace falta para propagarlo a una llamada más profunda y su cuerpo nunca lo menciona, se puede declarar con el nombre _. Es una declaración anónima: el valor sigue existiendo, sigue exigiéndose en el sitio de llamada y sigue pasándose hacia abajo, pero el cuerpo no puede referirse a él. Sirve para documentar la exigencia sin introducir un identificador que nadie usa.

context(_: Transaccion)
fun eliminar(id: String) = repositorio.borrar(id)   // exige la transaccion sin nombrarla

Cómo se resuelve en el sitio de llamada

Quien llama a procesar no escribe ningún logger. Lo que hace el compilador es buscar, en el ámbito de esa llamada, un valor cuyo tipo sea el requerido o un subtipo suyo, y pasarlo. El conjunto de valores elegibles se llama argumentos de contexto disponibles, y lo componen dos clases de cosas: los parámetros de contexto de las declaraciones que envuelven la llamada, y los receptores implícitos que haya en ese punto, incluidos el receptor de despacho, el de extensión y los que introducen funciones como with o run.

fun main() {
    val log: Logger = ConsolaLogger()

    // procesar(pedido)          // error de compilacion: no hay contexto

    with(log) {
        procesar(pedido)          // el receptor de with sirve de argumento de contexto
    }
}

context(log: Logger)
fun flujoCompleto(pedidos: List<Pedido>) =
    pedidos.map { procesar(it) }  // el contexto propio sirve al llamar hacia abajo

Conviene subrayar que la búsqueda es por tipo y solo por tipo: el compilador no mira nombres, no mira anotaciones y no consulta ningún registro. Un valor cuyo tipo sea el requerido o cualquier subtipo suyo es candidato, y un valor de otro tipo no lo es aunque se llame igual que el parámetro de contexto. De ahí se sigue la primera regla práctica de todo el nivel, que la última lección convertirá en criterio de diseño: el tipo de un contexto tiene que ser específico y propio, porque es la única llave que abre la puerta.

La segunda forma es la interesante y la que define el estilo: una función que ya declara el contexto se lo pasa gratis a todo lo que llame dentro, sin escribir nada. El arrastre de la lección anterior desaparece de las firmas intermedias, pero no desaparece de la comprobación: cada eslabón sigue declarando lo que exige, y el compilador sigue verificando la cadena entera.

flowchart TD
A[Sitio de llamada] --> B{Hay un valor del tipo requerido}
B -- Parametro de contexto en un ambito envolvente --> C[Se pasa ese]
B -- Receptor implicito de with o de la clase --> D[Se pasa ese]
B -- No hay ninguno --> E[Error NO CONTEXT ARGUMENT]
B -- Hay dos en el mismo ambito --> F[Error AMBIGUOUS CONTEXT ARGUMENT]

Los dos errores del diagrama son los que vas a ver de verdad y conviene reconocerlos por su nombre. El primero, NO_CONTEXT_ARGUMENT, aparece cuando en el punto de llamada no hay ningún valor del tipo pedido; el mensaje señala la llamada y menciona el parámetro de contexto que quedó sin satisfacer. Es un error de compilación puro y ese es todo el objetivo del mecanismo: la dependencia que antes se descubría en ejecución ahora se descubre al compilar. El segundo, AMBIGUOUS_CONTEXT_ARGUMENT, aparece cuando hay más de un candidato aplicable en el mismo nivel de ámbito y el compilador se niega a adivinar; la lección siguiente estudia el orden de búsqueda que decide cuándo dos candidatos están o no en el mismo nivel.

💡
Cómo se lee un error de contexto ausente

El error se emite en el sitio de llamada, no en la declaración, y esa es la clave para depurarlo rápido. Cuando aparece, no te preguntes qué le falta a la función que estás llamando: pregúntate qué región del programa debería haber abierto el ámbito y no lo hizo. La corrección casi nunca es añadir un parámetro; suele ser envolver la llamada en la función que provee el contexto o declarar el mismo contexto en la función que la contiene, propagando la exigencia un nivel hacia arriba hasta llegar al punto donde el servicio realmente existe.

Propiedades, sobrescritura y límites

Una propiedad también puede declarar contexto, y el caso natural es el de la propiedad calculada que depende de un servicio ambiental, como una representación formateada que necesita conocer la localización.

context(fmt: Formateador)
val Pedido.totalLegible: String
    get() = fmt.moneda(total)

Aquí hay una restricción que conviene grabar: una propiedad con parámetros de contexto no puede tener campo de respaldo, de modo que tiene que declarar un get explícito y no admite inicializador ni delegación por lazy. La razón es de aritmética elemental: un campo existe una vez por instancia, mientras que el valor de una propiedad contextual depende de qué contexto haya en cada lectura, así que puede dar resultados distintos en dos lecturas de la misma instancia. Guardar eso en un campo no tendría ningún sentido.

La misma lógica explica una restricción vecina que sorprende menos cuando se ve así: una propiedad contextual tampoco admite delegación, porque un delegado se instala una vez y la propiedad tendría que consultarlo con un contexto que cambia en cada lectura. En ambos casos el compilador no está prohibiendo algo útil, sino impidiendo escribir una contradicción.

context(fmt: Formateador)
val Pedido.totalLegible: String get() = fmt.moneda(total)   // correcto

// context(fmt: Formateador)
// val Pedido.cacheado: String by lazy { fmt.moneda(total) } // no compila

Las demás restricciones dibujan con bastante nitidez el alcance del mecanismo. Los parámetros de contexto se declaran sobre elementos invocables, es decir, funciones y propiedades, y no sobre clases ni sobre constructores: una clase que necesita un servicio para vivir lo recibe por constructor, como siempre, porque su dependencia no es de ámbito sino de identidad. Al sobrescribir un miembro que declara contextos, la implementación tiene que declarar los mismos tipos en el mismo orden, aunque puede elegir otros nombres, exactamente igual que ocurre con los nombres de los parámetros de valor.

🏷️

Nombre obligatorio

Cada contexto se declara con nombre y tipo. El cuerpo lo usa por su nombre, nunca como receptor, y por eso no contamina la resolución de nombres.

🫥

El contexto anónimo

Declarado con el nombre _, exige el servicio sin poder usarlo. Sirve para propagar un servicio hacia funciones más profundas dejando la restricción visible en la firma.

🚫

Sin campo de respaldo

Una propiedad con contexto necesita get explícito, porque su valor depende del ámbito de cada lectura y no de la instancia.

🧱

Solo sobre invocables

Funciones y propiedades sí; clases y constructores no. La dependencia de una clase es de identidad y va por constructor.

⚠️
Un contexto no es un valor en tiempo de ejecución nuevo

Conviene desactivar cualquier intuición de magia: en la JVM, un parámetro de contexto se compila como un parámetro más de la función, colocado delante de los demás. No hay búsqueda dinámica, no hay tabla de servicios y no hay coste distinto del de pasar una referencia. Todo el trabajo lo hace el compilador al resolver la llamada, y por eso el mecanismo es incomparablemente más seguro que una variable de hilo: sobrevive a los cambios de hilo de un despachador de corrutinas porque, sencillamente, viaja por la pila como cualquier argumento.

Nombrar sin escribir: por qué el nombre obligatorio es la decisión que hace utilizable todo lo demás

Si tuvieras que resumir el diseño estable de los parámetros de contexto en una sola frase, la frase sería que el valor tiene nombre en la declaración y no tiene nombre en la llamada, y merece la pena entender por qué esa asimetría es exactamente la correcta. Todo mecanismo de paso implícito de argumentos vive en una tensión entre dos cosas que quiere a la vez: quiere que quien llama no escriba, porque escribir cuarenta veces el mismo logger es el problema que venimos a resolver, y quiere que quien lee el cuerpo sepa de dónde sale cada cosa, porque un cuerpo lleno de identificadores de procedencia desconocida es ilegible. La solución ingenua sacrifica lo segundo por lo primero, y consiste en volcar todo el contexto dentro del ámbito léxico del cuerpo como si fuera propio: eso es lo que hacían los receptores de contexto, es lo que hace un objeto singleton importado con asterisco, y es el motivo de que ambos terminen produciendo código donde una llamada aparentemente local resulta ser un método de un servicio que nadie ve. Kotlin acabó eligiendo lo contrario, y lo hizo tras descartar la primera versión de la característica: dentro del cuerpo el contexto es un identificador ordinario, tan visible y tan localizable como un parámetro, con su tipo declarado dos líneas más arriba y con la misma navegación del editor que cualquier otro; fuera, en el sitio de llamada, desaparece. La implicitud queda confinada a un solo punto del programa, el de la invocación, y ese punto es precisamente donde la información sobraba, porque quien llama ya está dentro de la región que definió el contexto y no necesita repetirlo. Esa separación entre implícito al pasar y explícito al usar es lo que convierte un mecanismo peligroso en un mecanismo revisable, y es la razón de que el juicio de la última lección de este nivel pueda formularse en términos de diseño de API y no en términos de si conviene o no fiarse de la magia. La magia se ha reducido a una pregunta con respuesta comprobable por el compilador: en este punto del programa, qué servicios están abiertos.

⚔️ Convierte una cadena arrastrada
  1. Coge la cadena de tres o más funciones que identificaste en la lección anterior y reescríbela declarando el servicio como parámetro de contexto en cada eslabón.
  2. Elimina el parámetro en las funciones intermedias que solo lo reenviaban y comprueba qué se rompe al compilar. Lo que se rompa marca la frontera real del ámbito.
  3. Provee el contexto desde la raíz con with y observa qué ocurre si borras esa envoltura: identifica el diagnóstico exacto que emite el compilador.
  4. Declara una función que exija dos contextos y llámala desde un punto donde solo hay uno. Lee el mensaje y explica por qué señala el sitio de llamada y no la declaración.
  5. Convierte una propiedad calculada que dependía de un formateador en una propiedad con contexto y razona por qué no podrías haber usado by lazy en ella.