wandres.dev
CONTEXT PARAMETERS · dependencias implícitas

Resolución y ambigüedad: el orden de búsqueda y sus fronteras

Dónde busca exactamente el compilador un argumento de contexto, cómo decide entre dos candidatos y cuándo se rinde. Incluye el cambio de Kotlin 2.3.20 que retiró el privilegio de especificidad de las declaraciones con contexto en la resolución de sobrecarga, el comportamiento de las referencias invocables que solo empezaron a compilar en 2.3 y la interacción completa con los receptores de extensión.

⏱ 21 min

Un mecanismo implícito solo es utilizable si sus reglas de resolución son enunciables de memoria, porque cada vez que alguien tenga que abrir la especificación para saber qué valor se está pasando, el mecanismo habrá fracasado en su propósito. Las reglas de los parámetros de contexto son afortunadamente cortas y encajan con lo que ya sabes de la resolución de receptores en Kotlin: se busca por tipo, del ámbito más interno hacia el más externo, y ante un empate dentro de un mismo nivel el compilador se niega a decidir. Alrededor de ese núcleo hay tres zonas que sí exigen estudio deliberado, porque en ellas el contexto se cruza con maquinaria que ya existía: la resolución de sobrecarga, que cambió de comportamiento en 2.3.20; las referencias invocables, que no compilaban en absoluto hasta 2.3; y las funciones de extensión, cuyo receptor es a la vez candidato y competidor.

🎯 Al terminar esta lección sabrás
  • Enunciar el orden de búsqueda de un argumento de contexto y los dos diagnósticos que puede producir.
  • Explicar el cambio de Kotlin 2.3.20 sobre la especificidad de las sobrecargas con contexto y su efecto en código existente.
  • Predecir el tipo y el momento de captura de una referencia invocable a una declaración con contexto.
  • Resolver las colisiones entre receptores de extensión y parámetros de contexto del mismo tipo.

El orden de búsqueda

Ante una llamada que exige un contexto de tipo T, el compilador construye la lista de candidatos disponibles en ese punto y elige. Son candidatos los parámetros de contexto de todas las declaraciones que envuelven la llamada y todos los receptores implícitos vigentes, es decir, el receptor de despacho de la clase, el receptor de extensión de la función y los que introducen las funciones de ámbito con receptor. Un candidato es aplicable si su tipo es T o un subtipo de T.

La búsqueda avanza del ámbito más interno hacia el más externo y se detiene en cuanto un nivel aporta al menos un candidato aplicable. Esto significa que un contexto declarado cerca oculta a uno declarado lejos, exactamente igual que una variable local oculta a un campo, y que la ocultación es deliberada y útil: permite reemplazar un servicio para una región concreta sin tocar nada de lo que hay alrededor.

context(log: Logger)
fun raiz(pedidos: List<Pedido>) {
    val detallado: Logger = ConDetalle(log)

    procesar(pedidos.first())          // usa el contexto externo: log

    with(detallado) {
        procesar(pedidos.last())       // usa el mas interno: detallado
    }
}

Conviene tener presente qué significa exactamente un nivel, porque de eso depende todo lo demás. Introducen nivel el cuerpo de una función con contextos declarados, el cuerpo de una clase respecto de su receptor de despacho, el cuerpo de una función de extensión respecto de su receptor y cada bloque de una función de ámbito con receptor. Dentro de un mismo nivel puede haber varios candidatos a la vez, y ahí es donde nace el conflicto; entre niveles distintos nunca hay conflicto, solo ocultación.

Si el nivel donde la búsqueda se detiene aporta dos o más candidatos aplicables, el compilador no aplica ningún desempate y emite AMBIGUOUS_CONTEXT_ARGUMENT. Si ningún nivel aporta ninguno, emite NO_CONTEXT_ARGUMENT. Es importante entender que la ocultación entre niveles no es un accidente tolerado sino el mecanismo principal de composición del que dispone la característica. Envolver una región en un with que aporta un servicio decorado sustituye ese servicio para todo lo que ocurra dentro, sin tocar ninguna de las funciones implicadas ni sus firmas, y eso permite añadir trazas, medir tiempos o redirigir una escritura a una transacción anidada de forma completamente local. Es lo mismo que hacen los interceptores de otros ecosistemas, pero comprobado por el compilador y visible en el texto.

La ausencia de reglas de desempate es una decisión de diseño, no una carencia: cualquier criterio automático entre dos servicios del mismo tipo sería arbitrario, y adivinar mal aquí produce errores silenciosos de la peor especie.

flowchart TD
A[Se necesita un contexto de tipo T] --> B[Nivel mas interno]
B --> C{Cuantos candidatos aplicables}
C -- Uno --> D[Resuelto]
C -- Dos o mas --> E[AMBIGUOUS CONTEXT ARGUMENT]
C -- Ninguno --> F[Subir al nivel siguiente]
F --> C
F -- No quedan niveles --> G[NO CONTEXT ARGUMENT]
💡
La regla práctica que evita el noventa por ciento de las ambigüedades

Usa un tipo dedicado por capacidad y no reutilices tipos generales como contexto. Dos contextos de tipo String en el mismo ámbito son ambiguos por construcción, y dos de tipo Logger obligan a reorganizar el código para separarlos en niveles. Si necesitas dos servicios parecidos a la vez, dales tipos distintos, aunque uno sea una interfaz de una sola línea que envuelve al otro. El coste es una declaración y el beneficio es que la resolución vuelve a ser trivial.

El cambio de 2.3.20 en la resolución de sobrecarga

Aquí está el detalle histórico que más código ha movido de sitio, y conviene entenderlo con precisión porque cambia el significado de programas que compilaban. Cuando dos sobrecargas del mismo nombre son aplicables a una llamada, Kotlin decide con sus reglas de especificidad habituales, que comparan los tipos de los parámetros. La pregunta que el diseño original tuvo que responder es qué hacer cuando una de las dos declara contextos y la otra no.

fun registrar(evento: Evento) = almacen.guardar(evento)

context(log: Logger)
fun registrar(evento: Evento) {
    log.info("registrando ${evento.id}")
    almacen.guardar(evento)
}

context(log: Logger)
fun flujo(evento: Evento) {
    registrar(evento)     // cual de las dos se llama
}

Hasta la versión 2.3.20, la respuesta era la segunda: una declaración con parámetros de contexto se consideraba más específica que una sin ellos, de modo que ganaba siempre que el contexto estuviera disponible. La intención era permitir el patrón de la versión enriquecida que se activa sola cuando hay un logger a mano, y sobre el papel resultaba atractivo. En la práctica producía un efecto muy desagradable: el significado de una llamada dependía de si en ese punto del programa había o no un valor de cierto tipo en el ámbito, algo que puede cambiar por razones tan remotas como añadir un with varias líneas más arriba o declarar un contexto en la función que contiene a la que contiene a esta.

El fallo era más profundo de lo que sugiere la anécdota. Cualquier regla de resolución que consulte el ámbito para elegir entre implementaciones convierte una propiedad no local del código, qué hay en el entorno, en un factor del comportamiento, y las propiedades no locales son precisamente las que un lector no puede verificar y una revisión no puede detectar. Una importación nueva, una refactorización que envuelve un bloque, incluso una dependencia transitiva que introduce un tipo en el ámbito, bastaban para cambiar qué código se ejecutaba sin que ningún archivo modificado lo mostrara.

En 2.3.20 ese privilegio se retiró. Declarar contextos ya no aporta especificidad, y el par del ejemplo deja de resolverse en silencio hacia la versión contextual: si nada más las distingue, la llamada es ambigua y hay que desambiguar a mano. El código que dependía del comportamiento antiguo cambia de sentido y, en el mejor de los casos, deja de compilar. Que fuera posible retirar un privilegio de resolución sin proceso de obsolescencia se explica únicamente porque la característica seguía en beta: es el mismo argumento que justificó eliminar los receptores de contexto, y el mismo que conviene recordar antes de activar cualquier bandera experimental en un proyecto con vocación de durar.

⚠️
El patrón que dejó de funcionar

Si diseñaste una API con dos versiones de la misma operación, una silenciosa y otra que registra, confiando en que la segunda se eligiera sola dentro de un ámbito con logger, ese diseño ya no es viable y tampoco lo era conceptualmente. Dale nombres distintos a las dos operaciones, o deja solo la contextual y exige el contexto siempre. La regla que hay detrás del cambio se puede enunciar así: el contexto determina si una llamada es legal, nunca cuál de varias llamadas ocurre.

Referencias invocables y extensiones

Las referencias invocables con dos puntos dobles sobre declaraciones que exigen contexto sencillamente no compilaban en las primeras versiones de la característica, y ese agujero se cerró en 2.3. El comportamiento estable es el que cabía esperar por analogía con las referencias vinculadas del nivel doce: los argumentos de contexto se resuelven y se capturan en el punto donde se construye la referencia, no donde se invoca, y el tipo función resultante no menciona los contextos.

context(log: Logger)
fun formatear(p: Pedido): String = "${p.id}: ${p.total}"

context(log: Logger)
fun preparar(pedidos: List<Pedido>): List<String> {
    val f: (Pedido) -> String = ::formatear    // captura log aqui
    return pedidos.map(f)                      // se invoca sin contexto visible
}

La consecuencia práctica es doble y hay que tenerla clara. Por un lado, una referencia se puede pasar libremente a cualquier API que espere un tipo función ordinario, sin contaminarla con nada relativo al contexto. Por otro, esa referencia lleva dentro el servicio que había cuando se creó, así que si la almacenas y la invocas mucho después, seguirá usando aquel servicio y no el que esté vigente entonces. Es la misma disciplina que exige cualquier captura, con el agravante de que aquí lo capturado no aparece escrito en ninguna parte.

Hay un caso límite que conviene tener localizado: una referencia construida en un punto donde el contexto no está disponible no compila, aunque el sitio donde luego se invoque sí lo tuviera. La resolución ocurre en la construcción y no admite aplazamiento, de modo que mover una referencia dentro o fuera de un bloque with es un cambio semántico y no un cambio de estilo. Cuando lo que quieres es aplazar de verdad la elección del servicio, la respuesta no es una referencia sino una lambda declarada donde el contexto exista.

La interacción con las extensiones tiene tres puntos que merecen enunciado explícito. Antes de enumerarlos conviene fijar la asimetría que los explica todos: el receptor de extensión es un dato de entrada que quien llama elige y escribe, mientras que el contexto es un dato de entrada que el entorno aporta. Cuando ambos coinciden en tipo, el compilador tiene delante dos valores indistinguibles para él y perfectamente distinguibles para quien escribe, y de esa asimetría nacen todos los problemas de esta sección.

El primero es que una función puede declarar contextos y receptor de extensión a la vez, y ambos son cosas distintas: el receptor se escribe en el sitio de llamada, delante del punto, y los contextos no se escriben. El segundo es que el receptor de extensión vigente es candidato a argumento de contexto para las llamadas que haya dentro del cuerpo, lo cual es cómodo y a la vez es la fuente de ambigüedades más frecuente cuando el tipo del receptor coincide con el de un contexto. El tercero es que dentro del cuerpo this designa siempre y solo al receptor de extensión, nunca a un contexto, que es la propiedad que hace legible todo el diseño.

context(log: Logger)
fun Logger.duplicado(): Unit {
    // aqui hay dos valores de tipo Logger a la vez: this y log
    info("via receptor")          // resuelve por receptor de extension
    log.info("via contexto")      // resuelve por nombre
    // una llamada que exija un contexto Logger sera ambigua
}
🪜

De dentro hacia fuera

La búsqueda se detiene en el primer nivel con candidatos. Un contexto cercano oculta a uno lejano, y esa ocultación es la forma de sustituir servicios por región.

⚖️

Sin desempate

Dos candidatos aplicables en el mismo nivel producen error. El compilador prefiere pararte a elegir por ti entre dos servicios equivalentes.

🎯

Sin privilegio de sobrecarga

Desde 2.3.20 declarar contexto no hace a una función más específica. El contexto decide la legalidad de la llamada, no su destino.

📌

Referencias que capturan

Una referencia invocable fija los contextos en el momento de crearse y su tipo función no los menciona. Vigila dónde la construyes.

El contexto decide si puedes llamar, jamás a quién llamas

Si te quedas con una sola frase de esta lección, que sea la del título de este recuadro, porque condensa una tesis de diseño de lenguajes que costó una versión menor entera aprender en público. Un sistema de resolución implícita tiene dos poderes posibles y son de naturalezas radicalmente distintas. El primero es el poder de habilitar: comprobar que en este punto del programa existe cierto servicio y, si existe, dejar que la llamada ocurra pasándoselo. Ese poder es aditivo, es verificable de un vistazo y su fallo es siempre un error de compilación ruidoso que señala el sitio exacto. El segundo es el poder de seleccionar: mirar qué hay en el ámbito y usar esa información para elegir entre varias implementaciones aplicables. Ese poder parece una generalización natural del primero y es en realidad otra cosa, porque convierte el ámbito léxico en un canal de despacho, y un canal de despacho invisible es lo más parecido que existe a un programa que se reescribe solo según dónde lo pegues. El diseño original de Kotlin concedió los dos poderes casi sin darse cuenta, mediante una regla de especificidad que parecía inocente, y la comunidad tardó poco en descubrir que producía código donde mover una función de archivo, envolver una llamada en un bloque o añadir una dependencia transitiva podía cambiar qué se ejecutaba, sin ningún error, sin ninguna advertencia y sin ningún cambio visible en el diff. En 2.3.20 el segundo poder se retiró, y el mecanismo quedó reducido al primero justo antes de estabilizarse. Es exactamente la misma frontera que Kotlin ya había trazado en otros lugares por la misma razón: las extensiones se resuelven estáticamente y nunca se sobrescriben, los miembros ganan siempre a las extensiones, y ahora el contexto habilita pero no despacha. La regla general, la que puedes llevarte a cualquier lenguaje, dice que lo implícito puede aportar información, pero no debe tomar decisiones, porque una decisión que nadie escribió es una decisión que nadie puede revisar, y el software que no se puede revisar leyendo es software cuyo comportamiento solo se descubre ejecutándolo. Que Kotlin haya recorrido ese camino hacia atrás, retirando poder de una característica en beta a cambio de predecibilidad, dice bastante sobre qué clase de lenguaje quiere ser.

⚔️ Fuerza los límites de la resolución
  1. Escribe una función con contexto que dentro abra un with sobre otro valor del mismo tipo y comprueba, imprimiendo algo distintivo, cuál de los dos gana en cada llamada.
  2. Provoca deliberadamente un AMBIGUOUS_CONTEXT_ARGUMENT declarando dos contextos del mismo tipo y resuélvelo de dos formas: separando ámbitos y creando un tipo dedicado.
  3. Recrea el par de sobrecargas de la lección y razona qué habría hecho el compilador antes de 2.3.20 y qué hace ahora. Corrige el diseño sin apoyarte en la resolución.
  4. Construye una referencia invocable dentro de un ámbito, guárdala en una propiedad de la clase e invócala desde fuera del ámbito. Explica qué servicio se usa y por qué.
  5. Declara una extensión sobre el mismo tipo que uno de sus contextos y enumera todos los valores de ese tipo visibles en el cuerpo. Decide si el código merece existir.