Los dos ejes: receptor o argumento, lambda o receptor
Las funciones de ámbito de la librería estándar no son cinco herramientas independientes que haya que memorizar, sino el producto de dos decisiones ortogonales que se cruzan: cómo llega el objeto al interior del bloque, si como receptor implícito o como argumento con nombre, y qué devuelve la construcción entera, si el valor que produjo la lambda o el objeto con el que se entró. Esta lección deriva las combinaciones desde las firmas reales de la librería estándar, las coloca en una tabla única y muestra qué predice esa tabla sobre el encadenamiento, la legibilidad y los errores que aparecen una y otra vez.
Casi todo el material que circula sobre let, run, with, apply y also está escrito en forma de recetario, y ese es precisamente el motivo por el que tanta gente lleva años consultándolo sin llegar a interiorizarlo. Un recetario obliga a memorizar entradas independientes y no ofrece ninguna estructura que permita reconstruir la que se ha olvidado. La realidad del diseño es mucho más ordenada: no hay cinco funciones sueltas, hay dos decisiones binarias que se combinan, y las funciones son simplemente los casilleros que resultan de cruzarlas. La primera decisión es cómo llega el objeto al interior del bloque, si como receptor implícito o como parámetro ordinario. La segunda es qué sale de la construcción, si el valor calculado dentro o el objeto con el que se entró. En cuanto los dos ejes se hacen visibles, las firmas dejan de parecer arbitrarias, los nombres dejan de ser intercambiables y la pregunta deja de ser cuál me sé de memoria para convertirse en cuál de los cuadrantes necesito ahora.
- Distinguir el eje del acceso al objeto,
thisimplícito frente aitexplícito, leyendo directamente la firma de cada función. - Distinguir el eje del valor devuelto, resultado de la lambda frente al receptor original, y su efecto sobre el encadenamiento.
- Reconstruir de memoria la tabla completa de las funciones de ámbito a partir de las dos decisiones que la generan.
- Predecir, desde la posición en la tabla, qué tipo produce una llamada y si puede seguir encadenándose.
El eje del acceso: this frente a it
La primera decisión de cada función de ámbito es cómo entrega el objeto al bloque, y esa decisión está escrita literalmente en el tipo del parámetro. Cuando el bloque se declara como una lambda ordinaria, el objeto llega como argumento y se nombra con it o con el identificador que se le asigne. Cuando el bloque se declara como una lambda con receptor, el objeto pasa a ser el this del bloque y sus miembros quedan accesibles sin cualificar.
// Firmas de la libreria estandar, con los modificadores elididos
public inline fun <T, R> T.let(block: (T) -> R): R
public inline fun <T> T.also(block: (T) -> Unit): T
public inline fun <T, R> T.run(block: T.() -> R): R
public inline fun <T> T.apply(block: T.() -> Unit): T
public inline fun <T, R> with(receiver: T, block: T.() -> R): R
Conviene fijarse en que las cinco son extensiones salvo una, en que las cinco están marcadas como insertables y en que dos de ellas declaran el bloque con retorno Unit. Ninguno de esos tres detalles es decorativo: el primero decide qué formas de llamada admiten, el segundo garantiza que ninguna asigne un objeto en tiempo de ejecución y el tercero es la manera que tiene el sistema de tipos de decir que el valor calculado dentro del bloque no interesa. Leer estas firmas con atención ahorra literalmente todas las tablas mnemotécnicas que circulan sobre el asunto.
Toda la diferencia del primer eje cabe en el contraste entre (T) -> R y T.() -> R. En el primer caso el objeto es un argumento y el bloque es una función corriente; en el segundo el objeto es el receptor y el bloque es, a efectos de compilación, una función de extensión anónima. De ahí se deducen sin esfuerzo tres consecuencias prácticas que suelen enseñarse como reglas sueltas. La primera es que el argumento se puede renombrar y el receptor no: escribir el nombre del parámetro en lugar de it es la salida limpia para el anidamiento. La segunda es que el receptor implícito compite con el this de la clase envolvente y con cualquier propiedad homónima del ámbito exterior, mientras que el argumento nunca oculta nada. La tercera es que el receptor abrevia mucho cuando se tocan varios miembros seguidos, porque desaparece el prefijo repetido en cada línea.
class Panel {
var titulo: String = ""
fun preparar(v: Ventana) {
v.apply { titulo = "Alta" } // toca Ventana.titulo, no el de Panel
v.also { ventana -> ventana.titulo = "Alta" } // sin ambiguedad posible
}
}
El ejemplo anterior condensa el precio y el beneficio del primer eje en cuatro líneas. La versión con receptor es más corta y sería aún más corta si hubiera cinco asignaciones seguidas, pero el nombre titulo que aparece dentro del bloque podría pertenecer a dos objetos distintos y solo las reglas de resolución deciden a cuál. La versión con argumento es más larga en un identificador y no admite ninguna duda. Esa disyuntiva entre brevedad y certeza reaparecerá en las cuatro lecciones siguientes y es, en el fondo, la única decisión difícil de todo el nivel.
La elección entre this e it no es cuestión de gusto sino de cuántas veces aparece el objeto dentro del bloque. Con una o dos apariciones, un argumento con nombre explícito documenta qué es sin coste. Con cinco o seis accesos a miembros del mismo objeto, el receptor elimina un ruido que ya no aporta información. El punto de inflexión suele estar en tres.
El eje del retorno: el valor del bloque frente al objeto
La segunda decisión es qué tipo tiene la expresión completa. Aquí solo hay dos posibilidades y el tipo de retorno de la firma las delata sin ambigüedad: o bien la función devuelve R, el resultado de evaluar la lambda, o bien devuelve T, el mismo objeto sobre el que se invocó. Nótese que las funciones del segundo grupo declaran su bloque con retorno Unit, lo que es una forma de decir en el sistema de tipos que el valor calculado dentro se descarta deliberadamente.
val longitud: Int = texto.let { it.length } // devuelve R: el Int
val mismo: String = texto.also { log(it) } // devuelve T: el String
val nombre: String = usuario.run { "$pila $apellido" } // devuelve R
val creado: Usuario = Usuario().apply { activo = true } // devuelve T
La declaración explícita de los tipos en el ejemplo anterior no es habitual en código real, pero aquí cumple una función didáctica: hace visible que dos de esas cuatro líneas cambian el tipo de la expresión y las otras dos no. Quien sepa anticipar esa columna de tipos sin escribirla ha entendido el segundo eje por completo, y no volverá a sorprenderse cuando una cadena deje de compilar tres llamadas más abajo.
Este eje decide para qué sirve realmente cada función, porque determina qué se puede escribir después del punto. Las que devuelven R transforman: cierran una etapa y entregan algo distinto, de modo que la cadena continúa sobre el nuevo valor. Las que devuelven T son transparentes: intervienen sin alterar el flujo, de modo que la cadena continúa sobre el mismo valor y podrían eliminarse sin cambiar el tipo de la expresión. Esa transparencia es exactamente lo que las hace aptas para configurar y para observar, y lo que las hace inútiles para calcular.
flowchart TD
A[Necesito un bloque alrededor de un objeto] --> B{Que debe salir de la expresion}
B -- El valor calculado dentro --> C{Como accedo al objeto}
B -- El objeto original --> D{Como accedo al objeto}
C -- Como argumento it --> E[let]
C -- Como receptor this --> F[run o with]
D -- Como argumento it --> G[also]
D -- Como receptor this --> H[apply]La tabla que las coloca todas
Cruzar los dos ejes produce cuatro casilleros, y la librería estándar los llena con cinco nombres porque añade una variante sin objeto que no pertenece a ninguna casilla y merece figurar aparte.
| Función | Acceso al objeto | Devuelve | Forma de llamada |
|---|---|---|---|
let |
argumento, it |
resultado de la lambda | extensión |
run |
receptor, this |
resultado de la lambda | extensión |
with |
receptor, this |
resultado de la lambda | argumento, no extensión |
apply |
receptor, this |
el objeto receptor | extensión |
also |
argumento, it |
el objeto receptor | extensión |
run sin receptor |
no hay objeto | resultado del bloque | función suelta |
Antes de comentar la tabla conviene una advertencia sobre cómo usarla. No está pensada para consultarse en el momento de escribir, sino para dejar de necesitarse: si hay que mirarla, es porque todavía no se han interiorizado las dos preguntas que la generan, y esas dos preguntas se responden en un segundo mientras la tabla exige buscar una fila. La secuencia correcta es preguntarse primero qué debe salir de la expresión, porque eso reduce el conjunto a la mitad, y solo después cómo conviene acceder al objeto dentro del bloque.
Merece la pena detenerse en dos irregularidades de la tabla, porque son las que rompen la simetría y las que más confusión generan. La primera es que run y with ocupan el mismo casillero y se diferencian únicamente en la forma sintáctica: with recibe el objeto como argumento y por tanto no admite llamada segura con ?., mientras que run es una extensión y sí la admite. La segunda es que el cuadrante de receptor con retorno del objeto está ocupado por apply mientras que el cuadrante equivalente en el eje del argumento lo ocupa also, y de ahí procede el emparejamiento clásico entre ambas que tantas veces se enseña sin explicar de dónde sale.
Devuelve `R`, transforma
let, run y with cierran una etapa y entregan otro valor. Son el instrumento de la transformación y del cálculo, y cambian el tipo de lo que viene después del punto.
Devuelve `T`, atraviesa
apply y also son transparentes: la expresión conserva su tipo y podrían borrarse sin afectar a la firma. Su valor está en el efecto, no en el resultado.
La forma también cuenta
Todas son extensiones salvo with, y esa única excepción explica por qué with no encaja en una cadena con llamada segura y las demás sí.
Lo que la tabla predice sin consultar nada
Una vez interiorizados los dos ejes, casi todas las preguntas frecuentes se responden por deducción. Si alguien pregunta por qué su apply no compila cuando el bloque termina en un cálculo, la respuesta es que el bloque de apply se declara con retorno Unit y ese valor se descarta. Si alguien pregunta por qué una cadena se rompe después de un let, la respuesta es que let devuelve otro tipo y lo que sigue debe existir en ese tipo nuevo. Si alguien pregunta por qué dentro de un apply anidado se refiere al objeto equivocado, la respuesta es que los receptores implícitos se apilan y el más interno gana. Ninguna de esas explicaciones necesita recordar nada específico de la función implicada: bastan las dos coordenadas.
val puerto: Int = config.apply { timeout = 30 }.puerto // sigue siendo Config
val cifra: Int = config.run { timeout } // ya es Int
val texto: String? = quizaNulo?.let { it.trim() } // se apaga si es nulo
Hay una tercera predicción que conviene extraer porque afecta al rendimiento y suele malinterpretarse. Las cinco funciones están marcadas como insertables, de modo que ninguna crea un objeto lambda, ninguna añade una llamada virtual y ninguna aparece en una traza de pila. Usarlas no cuesta nada medible y evitarlas por motivos de rendimiento carece de fundamento. La consecuencia real de esa inserción es otra y sí importa: dentro del bloque se puede escribir un retorno no local que abandone la función envolvente, con las etiquetas y las restricciones que ya se estudiaron, y ese detalle explica por qué un return desnudo dentro de un let hace algo bastante más drástico de lo que espera quien lo escribe por primera vez.
fun buscar(id: Id): String {
cache[id]?.let { return it } // abandona buscar, no solo el let
return calcular(id)
}
Ese idioma es correcto, es frecuente y depende por completo de que let sea insertable. Sirve como recordatorio de que estas funciones no son una capa aparte del lenguaje sino funciones ordinarias sujetas a las mismas reglas que cualquier otra función de orden superior, y de que todo lo aprendido sobre lambdas se aplica dentro de sus bloques sin excepción.
Vale la pena preguntarse por qué la librería estándar decidió publicar cinco funciones cuando las dos decisiones que las generan producen cuatro combinaciones, y por qué no unificó run y with ni ofreció alias más descriptivos. La respuesta revela una tesis de diseño que atraviesa todo Kotlin y que conviene entender antes de opinar sobre estas funciones. Estas construcciones no existen para ahorrar caracteres, existen para trasladar información al lector en el punto exacto donde la necesita, y el vehículo elegido para transportarla es el nombre de la función más el tipo de su retorno. Cuando alguien lee apply sabe, antes de leer una sola línea del bloque, tres cosas ciertas: que dentro se configura algo, que la expresión conserva su tipo y que el valor calculado dentro no interesa a nadie. Cuando lee let sabe que va a salir otra cosa. Ese conocimiento anticipado es exactamente lo que se pierde si se sustituyen todas por una única función parametrizable, y es también lo que se pierde cuando alguien elige la función por costumbre en lugar de por significado, porque entonces el nombre miente y el lector deja de poder confiar en él. La duplicación aparente entre run y with responde a la misma lógica llevada a la sintaxis: son la misma semántica ofrecida en dos formas gramaticales distintas porque una encaja en una cadena de llamadas y la otra encabeza un bloque autónomo, y forzar una sola forma habría obligado a escribir código que se lee peor en uno de los dos contextos. La conclusión que conviene llevarse del nivel entero, y que las cuatro lecciones siguientes desarrollan, es que estas funciones son una capa de comunicación superpuesta a una capa de mecánica trivial; su mecánica se aprende en diez minutos y no volverá a plantear dudas, pero su uso correcto es una decisión editorial que se toma en cada línea y que separa el código que se lee del que hay que descifrar.
- Escribe de memoria las cinco firmas y comprueba en cuál aparece
T.() -> Ry en cuál(T) -> R. Explica qué implica cada una para el interior del bloque. - Coge una expresión tuya con
applyy sustitúyela poralsosin cambiar el comportamiento. Anota qué tuviste que tocar y por qué. - Toma una llamada con
withy conviértela enrun. Razona en qué situación esa conversión deja de ser mecánica. - Busca en tu código un
applycuyo bloque termine en una expresión con valor y verifica que ese valor se descarta. Decide si el nombre correcto era otro. - Redacta la regla de decisión en dos preguntas, sin nombrar ninguna función, y compruébala contra cinco usos reales de tu proyecto.