run, with y apply: el objeto como receptor
Las tres funciones de ámbito que convierten el objeto en receptor implícito comparten un mecanismo, la lambda con receptor, y se reparten tres trabajos distintos: configurar un objeto recién construido y devolverlo con apply, agrupar operaciones sobre un mismo receptor para obtener un resultado con run y con with, y aislar un bloque de cálculo como expresión con la variante de run que no tiene receptor. Esta lección examina el código que genera cada forma, la diferencia real entre run y with más allá de la sintaxis, la relación de estas funciones con la construcción de objetos en la interoperabilidad con Java, y los riesgos concretos que introduce un receptor implícito dentro de una clase.
Una lambda con receptor es la construcción sobre la que se apoya buena parte de lo que hace reconocible al Kotlin idiomático, desde estas tres funciones hasta los constructores de HTML, las configuraciones de compilación y cualquier lenguaje específico de dominio escrito en el lenguaje. Su efecto es sencillo de enunciar y profundo en sus consecuencias: dentro del bloque, un objeto que estaba fuera pasa a comportarse como si fuera el objeto actual, y todos sus miembros quedan al alcance sin cualificar. Eso convierte un bloque corriente en algo parecido a un ámbito nuevo del lenguaje, donde las reglas de resolución de nombres han cambiado deliberadamente. apply, run y with son las tres formas en que la librería estándar ofrece ese poder sin ceremonia, y la elección entre ellas no es de estilo: cada una dice al lector una cosa distinta sobre qué se va a hacer dentro del bloque y qué va a salir de él.
- Usar
applypara configurar un objeto recién construido y devolverlo, entendiendo por qué su bloque descarta el valor. - Agrupar con
runy conwithvarias operaciones sobre un mismo receptor y obtener un resultado de ellas. - Emplear
runsin receptor para aislar un bloque de cálculo como expresión con ámbito propio. - Anticipar los riesgos del receptor implícito dentro de una clase y decidir cuándo conviene evitarlo.
apply: configurar y devolver el mismo objeto
apply es la única función de ámbito que combina receptor implícito con retorno del propio objeto, y esa combinación describe con exactitud una tarea muy concreta: tomar algo recién creado, ponerle estado y entregarlo listo. Su bloque se declara con retorno Unit, lo que en el sistema de tipos significa que cualquier valor calculado dentro se descarta a propósito; el bloque existe por sus efectos sobre el receptor y por nada más.
val cliente = HttpClient().apply {
tiempoDeEspera = 30.seconds
reintentos = 3
cabeceras["Accept"] = "application/json"
interceptores += Registro(nivel = Nivel.BASICO)
}
El valor de esta forma se aprecia mejor cuando se compara con lo que sustituye. Sin ella hacen falta una variable mutable declarada antes, cuatro líneas que repiten su nombre y un identificador que sigue vivo y modificable después del bloque de configuración. Con ella la construcción es una sola expresión, el resultado puede asignarse a un val y no existe ningún estado a medio configurar accesible desde fuera. Ese último punto es el que más importa en la práctica: apply convierte una secuencia de mutaciones en una expresión atómica desde el punto de vista de quien lee.
// Sin apply: estado a medio configurar visible y mutable
var c = HttpClient()
c.tiempoDeEspera = 30.seconds
c.reintentos = 3
c.cabeceras["Accept"] = "application/json"
// Con apply: una expresion, un val, ningun intermedio observable
val cliente = HttpClient().apply { /* ... */ }
Hay un límite que conviene fijar desde el principio y que la última lección del nivel desarrolla: apply describe una configuración, y una configuración es una lista de asignaciones. En cuanto el bloque contiene condicionales, bucles o llamadas cuyo resultado se examina, ha dejado de ser una configuración y se ha convertido en un procedimiento sin nombre metido dentro de una expresión. El remedio no es prohibir la función sino extraer ese procedimiento a un método con nombre y dejar el apply para lo que sí es declarativo.
Cuando se consume una librería Java escrita con el patrón de objeto mutable configurable, la traducción idiomática no es replicar el encadenamiento de métodos sino envolver la construcción en un apply. El resultado se lee como una declaración de estado deseado, funciona con cualquier propiedad expuesta por la clase original aunque no haya sido pensada para encadenarse, y produce un valor asignable a un val. Es también el motivo por el que casi ningún código Kotlin necesita escribir constructores fluidos propios.
run y with: agrupar operaciones y obtener un resultado
run y with ocupan el mismo casillero de los dos ejes: reciben el objeto como receptor y devuelven lo que produzca el bloque. Sirven para agrupar varias operaciones sobre un mismo objeto y extraer de ellas un valor, y su ganancia frente a escribir las mismas líneas sueltas es que el prefijo repetido desaparece y que el resultado queda visible como última expresión del bloque.
val descripcion = with(usuario) {
val edad = calcularEdad(fechaNacimiento)
"$nombre, $edad anos, $ciudad"
}
val puerto = configuracion.run {
validar()
puertoBase + desplazamiento
}
La diferencia entre ambas es puramente gramatical y sin embargo determina cuál encaja en cada sitio. with es una función suelta que recibe el objeto como primer argumento, de modo que encabeza un bloque y se lee como una preposición; al no ser extensión, no admite llamada segura y sobre un valor nulable obliga a comprobar antes. run es una extensión, de modo que se escribe después del punto, encaja en mitad de una cadena y admite la forma ?.run para actuar solo cuando el receptor existe. La regla de selección que resiste el uso es directa: si el objeto ya está al final de una expresión, run; si el bloque abre un párrafo de código sobre un objeto que ya tiene nombre, with.
flowchart TD
A[Quiero un receptor implicito] --> B{Que sale del bloque}
B -- El objeto configurado --> C[apply]
B -- Un resultado calculado --> D{Donde esta el objeto}
D -- Al final de una cadena --> E[run como extension]
D -- Ya tiene nombre y abre bloque --> F[with]
D -- No hay objeto --> G[run sin receptor]run sin receptor: el bloque como expresión
Existe una segunda función llamada run que no es extensión de nada y que simplemente ejecuta un bloque devolviendo su valor. No pertenece a los dos ejes porque no hay ningún objeto implicado, y su utilidad es distinta: convierte una secuencia de instrucciones en una expresión, lo que permite inicializar un val con lógica de varias líneas sin ensuciar el ámbito exterior con los intermedios.
val configuracion = run {
val base = leerFichero("base.conf")
val local = leerFichero("local.conf")
fusionar(base, local)
}
Aquí base y local existen únicamente dentro del bloque, y la propiedad queda declarada como inmutable en una sola expresión. La misma forma resuelve dos situaciones más que aparecen a menudo: inicializar una propiedad de clase que necesita varios pasos, evitando el rodeo de una función privada de un solo uso, y proporcionar un ámbito etiquetable donde escribir un retorno no local simulado, según el idioma que se estudió al tratar las etiquetas. Conviene recordar que este run es inline, de modo que el bloque no crea ningún objeto y el coste en tiempo de ejecución es nulo.
class Servicio {
private val politica: Politica = run {
val bruto = leerPolitica()
val ajustada = aplicarValoresPorDefecto(bruto)
validar(ajustada)
ajustada
}
}
La única precaución con esta forma es que resulta invisible en las trazas y en la navegación del editor: un bloque anónimo no tiene nombre que buscar, no aparece en un índice de símbolos y no se puede probar por separado. Mientras el bloque sea corto y su propósito quede claro por la propiedad que inicializa, esa invisibilidad no cuesta nada. En cuanto crece o en cuanto alguien quiera probarlo, una función privada con nombre vuelve a ser la mejor opción, y no hay ningún mérito en evitarla.
`apply` declara estado
Configura y devuelve el mismo objeto. El bloque descarta su valor porque existe solo por sus efectos sobre el receptor.
`run` y `with` calculan
Agrupan operaciones sobre un receptor y entregan un resultado. Se diferencian en la forma de llamada, no en la semántica.
`run` sin receptor aísla
Convierte varias instrucciones en una expresión con ámbito propio, sin objeto y sin coste, ideal para inicializaciones compuestas.
Lo que cuesta un receptor implícito
El receptor implícito no es gratis, y su precio se paga en resolución de nombres. Dentro del bloque conviven al menos dos this: el del objeto entregado por la función de ámbito y el de la clase que rodea a la llamada. Cuando ambos tienen miembros con el mismo nombre, gana el más interno, y el compilador no emite ninguna advertencia porque la resolución es exactamente la que las reglas del lenguaje prescriben. El resultado es una clase de error especialmente desagradable: el código compila, ejecuta y hace algo distinto de lo que su autor creía.
class Formulario {
var titulo: String = "sin titulo"
fun construir(): Panel = Panel().apply {
titulo = "Alta de usuario" // asigna a Panel.titulo
this@Formulario.titulo = "Alta" // asigna al de la clase
}
}
Merece la pena entender por qué el compilador no puede ayudar aquí. Un receptor implícito no es una excepción a las reglas de resolución de nombres sino una aplicación estricta de ellas: cuando un identificador aparece sin cualificar, el compilador recorre los ámbitos de dentro hacia fuera y se queda con la primera coincidencia. Que esa coincidencia sea la que el autor quería es una cuestión de intención, y las intenciones no se comprueban. Por eso el problema no se detecta con herramientas y solo se evita con disciplina de escritura, que es exactamente el motivo de que los equipos acaben escribiendo reglas sobre estas funciones.
La forma cualificada this@Formulario es la salida cuando hace falta alcanzar el receptor exterior, y su sola presencia en una línea es una señal de que el bloque está gestionando dos contextos a la vez. Hay dos precauciones que evitan casi todos los casos. La primera es preferir also con argumento nombrado cuando el bloque toque miembros de dos objetos distintos, porque un nombre explícito nunca oculta nada. La segunda es no anidar dos funciones con receptor sobre objetos de tipos relacionados, situación en la que ni siquiera el editor ayuda a distinguir a quién pertenece cada miembro.
Conviene mirar apply, run y with como lo que realmente son, que no es un trío de atajos sino la aparición más modesta de la construcción que sostiene la mitad expresiva del lenguaje. Un tipo función con receptor, escrito T.() -> R, le dice al compilador que dentro de ese bloque las reglas de resolución de nombres serán distintas de las del código que lo rodea, porque habrá un objeto adicional cuyos miembros están disponibles sin prefijo. Eso es, con toda literalidad, la capacidad de definir un ámbito nuevo desde una librería, sin tocar la gramática del lenguaje ni pedir nada al equipo del compilador. De ahí sale todo lo demás: los constructores de HTML y de interfaces declarativas, donde cada nivel de anidamiento es un receptor distinto que expone justo los hijos legales en ese punto; los ficheros de compilación de Gradle, donde el bloque de dependencias entiende palabras que no significan nada fuera de él; los constructores de pruebas y los verificadores; y la técnica de restringir qué receptores externos son visibles desde dentro para que un lenguaje específico de dominio no permita escribir combinaciones sin sentido. Estas tres funciones son ese mismo mecanismo aplicado al caso más pequeño imaginable, un solo objeto y un solo nivel, y por eso son el mejor sitio para desarrollar la intuición antes de encontrárselo a escala. La lección que conviene extraer, y que se paga cara cuando se ignora, es que abrir un receptor implícito es un acto con coste cognitivo real: se gana brevedad y se pierde la certeza inmediata de a quién pertenece cada nombre que aparece dentro. Ese intercambio es excelente cuando el bloque es corto y su receptor es evidente, y se vuelve ruinoso cuando el bloque crece, cuando se anida o cuando el objeto entregado se parece demasiado a la clase que lo rodea. Quien entiende que la construcción define un ámbito, y no simplemente que ahorra escribir un punto, sabe exactamente en qué momento debe cerrarlo.
- Coge una construcción tuya con variable mutable y varias asignaciones y conviértela en una sola expresión con
applyasignada a unval. - Escribe la misma lógica con
runy conwithy decide cuál publicarías, justificando la elección solo por la posición del objeto en la expresión. - Sustituye una función privada de un solo uso por un bloque
runsin receptor y comprueba qué identificadores dejan de existir en el ámbito exterior. - Provoca deliberadamente una colisión de nombres entre el receptor de un
applyy una propiedad de la clase envolvente, resuélvela conthiscualificado y después reescríbela conalso. - Toma un
applycuyo bloque supere las diez líneas y divídelo en dos, argumentando qué recupera el lector con la división.