Funciones de scope en Kotlin: let, run, apply, also, with
1. Descripción general
Kotlin tiene cinco funciones que se ven muy parecidas: let, run, with, apply y also. Todas ellas ejecutan un bloque de código sobre un objeto, pero difieren en cómo accedes a ese objeto y en el valor que la función retorna. Muchas personas eligen una al azar y el código sigue funcionando, pero será más difícil de leer.
Este artículo explica dos criterios para distinguir las funciones de scope e ilustra cada una con ejemplos concretos. Luego escribirás tests para verificar su comportamiento y verás los errores más comunes. Después de leerlo, podrás elegir la función correcta para cada situación.
2. Dos criterios para distinguir
Toda función de scope recibe una lambda y la ejecuta sobre un context object. Solo necesitas responder dos preguntas:
- ¿Accedes al objeto a través de
this(receiver) o a través deit(parámetro)? - ¿La función retorna el resultado de la lambda o retorna el propio objeto?
| Función | Context object | Valor de retorno | ¿Es extension? |
|---|---|---|---|
let |
it |
Resultado de lambda | Sí |
run |
this |
Resultado de lambda | Sí (y tiene versión sin extension) |
with |
this |
Resultado de lambda | No, el objeto es parámetro |
apply |
this |
El propio objeto | Sí |
also |
it |
El propio objeto | Sí |
Puedes recordar rápidamente en pares:
applyyalsoretornan el objeto, así que son adecuadas para configurar un objeto o insertar side effects en medio de una cadena de llamadas.let,runywithretornan el resultado, así que son adecuadas para transformar un objeto en otro valor.
Los siguientes ejemplos usan algunas clases simples:
data class Customer(val name: String, val email: String?)
class Order(val id: String) {
val items = mutableListOf<Double>()
var discountPercent = 0
}
class Invoice {
var number: String = ""
var amount: Double = 0.0
var paid: Boolean = false
}
3. let: transformar y manejar valores nullable
let pasa el objeto a la lambda bajo el nombre it y retorna el resultado de la lambda. El uso más común es combinarlo con safe call ?. para ejecutar código solo cuando el valor no es null:
fun sendWelcome(customer: Customer) {
customer.email?.let { email ->
println("Enviar email de bienvenida a $email")
}
}
fun main() {
sendWelcome(Customer("Ana", "ana@example.com"))
sendWelcome(Customer("Beto", null))
}
Enviar email de bienvenida a ana@example.com
Para el cliente Beto, email es null. La expresión ?. se detiene ahí y la lambda no se ejecuta. Dentro de la lambda, email tiene tipo String en lugar de String?, así que puedes usarlo sin necesidad de verificarlo más. Aquí hemos nombrado el parámetro como email en lugar de it para que el código sea más legible.
let también es útil cuando quieres transformar un valor sin necesidad de crear una variable intermedia:
val domain = Customer("Ana", "ana@example.com").email
?.let { it.substringAfter("@") }
?: "desconocido"
println(domain) // example.com
4. run: calcular basado en el objeto
run es como let, solo que el objeto se accede a través de this. Como this puede omitirse, llamas directamente a las propiedades y métodos del objeto:
val order = Order("ORD-42").apply {
items += listOf(100_000.0, 50_000.0)
discountPercent = 10
}
val total = order.run {
val subtotal = items.sum()
subtotal - subtotal * discountPercent / 100
}
println(total) // 135000.0
Dentro del bloque run, items y discountPercent son propiedades de order. El valor retornado es la última expresión, es decir, el monto después del descuento.
run también tiene una versión que no es extension. La usas para agrupar varias sentencias que producen un valor, sin dejar que las variables temporales se filtren al scope externo:
val connectionUrl = run {
val host = System.getenv("DB_HOST") ?: "localhost"
val port = System.getenv("DB_PORT") ?: "5432"
"jdbc:postgresql://$host:$port/shop"
}
Después de este bloque, host y port ya no existen. Solo connectionUrl se conserva.
5. with: agrupar múltiples llamadas al mismo objeto
with funciona igual que run, pero no es una extension. El objeto se pasa como primer parámetro:
val summary = with(order) {
"Pedido $id: ${items.size} productos, descuento $discountPercent%"
}
println(summary)
Pedido ORD-42: 2 productos, descuento 10%
Semánticamente, with(order) { ... } se lee como “con este pedido, haz…”. Esta función es adecuada cuando el objeto definitivamente no es null y necesitas llamar a varios de sus miembros. Si el objeto puede ser null, debes usar order?.run { ... }, ya que with no se puede combinar con ?..
6. apply: configurar el objeto
apply accede al objeto a través de this y retorna el propio objeto. Esta es la forma idiomática para inicializar y configurar un objeto en la misma expresión:
val invoice = Invoice().apply {
number = "INV-001"
amount = 250_000.0
paid = false
}
println("${invoice.number} - ${invoice.amount}") // INV-001 - 250000.0
El resultado de la lambda se descarta y apply siempre retorna el Invoice que acaba de crear. Encontrarás apply frecuentemente cuando trabajes con clases Java que usan setters, por ejemplo al configurar un bean en Spring:
@Bean
fun restTemplate(): RestTemplate = RestTemplate().apply {
requestFactory = SimpleClientHttpRequestFactory().apply {
setConnectTimeout(2_000)
setReadTimeout(5_000)
}
}
7. also: añadir side effects sin romper la cadena de llamadas
also accede al objeto a través de it y también retorna el propio objeto. Esta función es adecuada para cosas “de paso” como logging, validación o métricas, cuando estas acciones no cambian el flujo principal:
fun createOrder(id: String): Order =
Order(id).also { println("Se creó el pedido ${it.id}") }
fun main() {
val order = createOrder("ORD-7")
.also { it.items += 99_000.0 }
.also { println("Número de productos: ${it.items.size}") }
}
Se creó el pedido ORD-7
Número de productos: 1
Como el objeto se accede a través de it y no a través de this, aún puedes usar this de la clase externa sin confusión. Esta es también la razón por la que also es más legible que apply cuando la lambda principalmente usa el objeto en lugar de configurarlo.
Hay un pequeño truco usando also para intercambiar dos variables:
var a = 1
var b = 2
a = b.also { b = a }
println("$a $b") // 2 1
La expresión b.also { ... } retorna el valor anterior de b (que es 2), mientras que la lambda asigna b = a (que es 1) antes de que ocurra la asignación a a.
8. ¿Qué función elegir?
Puedes usar estas reglas:
- Necesitas ejecutar código cuando el valor no es
null, o necesitas transformar un valor: usa?.let. - Necesitas calcular un valor basado en múltiples miembros del objeto: usa
run, owithsi el objeto definitivamente no esnull. - Necesitas agrupar varias sentencias en una expresión: usa
run { }sin receiver. - Necesitas inicializar o configurar un objeto: usa
apply. - Necesitas hacer logging, validación o añadir side effects manteniendo el objeto: usa
also.
9. Verificar el comportamiento
Para asegurarte de que entiendes correctamente los valores de retorno, escribe algunos tests. Primero, añade la dependencia a pom.xml:
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>2.0.21</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
</dependencies>
Si tu proyecto es Spring Boot 3.x, spring-boot-starter-test ya incluye JUnit 5 y AssertJ, así que no necesitas declarar estas dos librerías por separado.
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class ScopeFunctionsTest {
@Test
fun `apply y also retornan el propio objeto`() {
val invoice = Invoice()
val fromApply = invoice.apply { amount = 100.0 }
val fromAlso = invoice.also { it.paid = true }
assertThat(fromApply).isSameAs(invoice)
assertThat(fromAlso).isSameAs(invoice)
assertThat(invoice.amount).isEqualTo(100.0)
assertThat(invoice.paid).isTrue()
}
@Test
fun `let run with retornan el resultado de la lambda`() {
val order = Order("ORD-1").apply { items += listOf(10.0, 20.0) }
val count: Int = order.let { it.items.size }
val sum: Double = order.run { items.sum() }
val label: String = with(order) { "Pedido $id" }
assertThat(count).isEqualTo(2)
assertThat(sum).isEqualTo(30.0)
assertThat(label).isEqualTo("Pedido ORD-1")
}
@Test
fun `let no se ejecuta cuando el valor es null`() {
var called = false
Customer("Beto", null).email?.let { called = true }
assertThat(called).isFalse()
}
}
El primer test usa isSameAs para verificar que apply y also retornan la referencia exacta original, no una copia. El segundo test declara tipos explícitos (Int, Double, String) para ver claramente qué retorna cada función. El tercer test confirma que ?.let ignora el valor null.
10. Errores comunes
10.1. this oscurecido en apply
Dentro de apply, el receiver más cercano tiene prioridad. Por lo tanto, una propiedad del mismo nombre en la clase externa será oscurecida:
class InvoiceFactory(private val amount: Double) {
fun create() = Invoice().apply {
amount = amount // asigna Invoice.amount a sí mismo, el resultado sigue siendo 0.0
}
}
Tienes dos formas de corregirlo. La primera es ser explícito con el receiver usando this@InvoiceFactory.amount. La segunda es cambiar a also:
fun create() = Invoice().also { it.amount = amount }
10.2. Trampa de ?.let { } ?: ...
Muchas personas escriben x?.let { ... } ?: fallback con la intención de que sea como un if/else. Esta forma de escribir no es equivalente. Si la lambda retorna null, la rama ?: también se ejecuta:
val cache = mapOf<String, String>()
val email: String? = "ana@example.com"
email?.let { cache[it] } ?: println("Sin email")
Sin email
Aquí email no es null, pero cache[it] retorna null, así que el mensaje de todos modos se imprime aunque no es correcto. Cuando necesites hacer ramificación explícita, usa if (email != null) ... else ....
10.3. Anidar demasiados niveles
Cuando anidasfunciones de scope una dentro de otra, it y this apuntarán a muchos objetos diferentes y el lector tendrá que rastrear qué es cada uno:
// Difícil de leer
order.let { o -> o.items.firstOrNull()?.let { price -> price.run { this * 2 } } }
Nota: No debes anidar más de un nivel. Si se requiere, da nombres explícitos a los parámetros en lugar de usar
it. Aún mejor es separar en una variable local o función aparte.
10.4. Usar función de scope solo porque puedes
El fragmento val name = customer.let { it.name } no proporciona nada comparado con val name = customer.name. Las funciones de scope son útiles cuando ayudan a eliminar variables temporales, manejar null o agrupar código de configuración. Si no hacen eso, no las uses.
11. Conclusión
Las cinco funciones de scope de Kotlin solo difieren en dos aspectos: si el objeto se accede a través de this o it, y si la función retorna el objeto o el resultado de la lambda. De ahí derivan sus usos correspondientes: apply para configurar, also para añadir side effects, let para manejar valores nullable y transformar, mientras que run y with para calcular basado en el objeto. También debes cuidarte del this oscurecido, la trampa de ?.let ?:, y anidar demasiados niveles. Finalmente, recuerda que el objetivo de las funciones de scope es hacer el código más legible, no más corto a cualquier costo.