Funções de escopo no Kotlin: let, run, apply, also, with
1. Visão geral
Kotlin tem cinco funções que parecem muito semelhantes: let, run, with, apply e also. Todas executam um bloco de código em um objeto, mas diferem na forma como você acessa esse objeto e no valor que a função retorna. Muitas pessoas escolhem uma função aleatoriamente, e o código ainda funciona, mas será mais difícil de ler.
Este artigo explica dois critérios para diferenciar as funções de escopo e ilustra cada uma com exemplos específicos. Em seguida, você escreverá testes para verificar o comportamento delas e verá os erros comuns. Depois de ler, você escolherá a função correta para cada situação.
2. Dois critérios para diferenciação
Toda função de escopo recebe uma lambda e chama essa lambda em um context object. Você precisa apenas responder duas perguntas:
- Na lambda, você acessa o objeto através de
this(receiver) ou através deit(parâmetro)? - A função retorna o resultado da lambda ou retorna o próprio objeto?
| Função | Context object | Valor de retorno | É extension? |
|---|---|---|---|
let |
it |
Resultado da lambda | Sim |
run |
this |
Resultado da lambda | Sim (e tem versão sem extension) |
with |
this |
Resultado da lambda | Não, objeto é um parâmetro |
apply |
this |
O próprio objeto | Sim |
also |
it |
O próprio objeto | Sim |
Você pode memorizar rapidamente em pares:
applyealsoretornam o objeto, então eles são adequados para configurar o objeto ou inserir side effects no meio de uma cadeia de chamadas.let,runewithretornam o resultado, então eles são adequados para transformar o objeto em um valor diferente.
Os exemplos abaixo usam algumas classes simples compartilhadas:
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 e lidar com valores nullable
let passa o objeto para a lambda sob o nome it e retorna o resultado da lambda. O uso mais comum é combinado com safe call ?. para executar o código apenas quando o valor é diferente de null:
fun sendWelcome(customer: Customer) {
customer.email?.let { email ->
println("Enviando email de boas-vindas para $email")
}
}
fun main() {
sendWelcome(Customer("An", "an@example.com"))
sendWelcome(Customer("Bình", null))
}
Enviando email de boas-vindas para an@example.com
Para o cliente Bình, email é null. A expressão ?. para ali e a lambda não é executada. Na lambda, email tem tipo String e não String?, então você o usa sem precisar de verificações adicionais. Aqui, nomeamos o parâmetro como email em vez de it para tornar o código mais legível.
let também é útil quando você deseja transformar um valor sem precisar criar uma variável intermediária:
val domain = Customer("An", "an@example.com").email
?.let { it.substringAfter("@") }
?: "desconhecido"
println(domain) // example.com
4. run: computar com base no objeto
run é como let, exceto que o objeto é acessado através de this. Como this pode ser omitido, você chama diretamente propriedades e métodos do 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 do bloco run, items e discountPercent são propriedades de order. O valor retornado é a última expressão, ou seja, o valor após o desconto.
run também tem uma versão não extension. Você o usa para agrupar algumas instruções que criam um valor sem vazar variáveis temporárias para o escopo externo:
val connectionUrl = run {
val host = System.getenv("DB_HOST") ?: "localhost"
val port = System.getenv("DB_PORT") ?: "5432"
"jdbc:postgresql://$host:$port/shop"
}
Após este bloco, host e port não existem mais. Apenas connectionUrl é retido.
5. with: agrupar várias chamadas no mesmo objeto
with funciona como run, mas não é uma extension. O objeto é passado como primeiro parâmetro:
val summary = with(order) {
"Pedido $id: ${items.size} produtos, desconto $discountPercent%"
}
println(summary)
Pedido ORD-42: 2 produtos, desconto 10%
Semanticamente, with(order) { ... } lê-se como “com este pedido, faça…”. Esta função é apropriada quando o objeto é definitivamente diferente de null e você precisa chamar muitos de seus membros. Se o objeto pode ser null, você deve usar order?.run { ... }, porque with não pode ser combinado com ?..
6. apply: configurar o objeto
apply acessa o objeto através de this e retorna o próprio objeto. Esta é a forma idiomática de inicializar e configurar um objeto na mesma expressão:
val invoice = Invoice().apply {
number = "INV-001"
amount = 250_000.0
paid = false
}
println("${invoice.number} - ${invoice.amount}") // INV-001 - 250000.0
O resultado da lambda é ignorado e apply sempre retorna o Invoice recém-criado. Você frequentemente encontrará apply ao trabalhar com classes Java que usam setters, por exemplo, ao configurar um bean no Spring:
@Bean
fun restTemplate(): RestTemplate = RestTemplate().apply {
requestFactory = SimpleClientHttpRequestFactory().apply {
setConnectTimeout(2_000)
setReadTimeout(5_000)
}
}
7. also: adicionar side effects sem quebrar a cadeia de chamadas
also acessa o objeto através de it e também retorna o próprio objeto. Esta função é adequada para ações de “fazer algo a mais” como registrar em log, validar ou chamar métricas, quando essas ações não alteram o fluxo principal:
fun createOrder(id: String): Order =
Order(id).also { println("Pedido ${it.id} criado") }
fun main() {
val order = createOrder("ORD-7")
.also { it.items += 99_000.0 }
.also { println("Número de produtos: ${it.items.size}") }
}
Pedido ORD-7 criado
Número de produtos: 1
Como o objeto é acessado através de it e não através de this, você ainda pode usar this da classe externa sem confusão. Esta é também a razão pela qual also é mais legível que apply quando a lambda principalmente usa o objeto em vez de configura o objeto.
Um pequeno truque é usar also para trocar duas variáveis:
var a = 1
var b = 2
a = b.also { b = a }
println("$a $b") // 2 1
A expressão b.also { ... } retorna o valor antigo de b (que é 2), enquanto a lambda atribui b = a (que é 1) antes que a atribuição para a ocorra.
8. Qual função escolher?
Você pode usar as seguintes regras:
- Precisa executar código quando o valor é diferente de
null, ou precisa transformar o valor: use?.let. - Precisa calcular um valor de vários membros do objeto: use
run, ouwithse o objeto é definitivamente diferente denull. - Precisa agrupar algumas instruções em uma expressão: use
run { }sem receiver. - Precisa inicializar ou configurar um objeto: use
apply. - Precisa registrar em log, validar ou adicionar side effects enquanto ainda mantém o objeto: use
also.
9. Testando o comportamento
Para garantir que você compreende corretamente o valor retornado, escreva alguns testes. Primeiro, adicione a dependência ao 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>
Se o projeto é Spring Boot 3.x, spring-boot-starter-test já inclui JUnit 5 e AssertJ, então você não precisa declarar essas duas bibliotecas separadamente.
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class ScopeFunctionsTest {
@Test
fun `apply e also retornam o próprio 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 e with retornam o resultado da 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 não executa quando o valor é null`() {
var called = false
Customer("Bình", null).email?.let { called = true }
assertThat(called).isFalse()
}
}
O primeiro teste usa isSameAs para verificar que apply e also retornam a referência correta, não uma cópia. O segundo teste declara tipos explicitamente (Int, Double, String) para ver claramente o que cada função retorna. O terceiro teste confirma que ?.let ignora valores null.
10. Erros comuns
10.1. this é obscurecido dentro de apply
Dentro de apply, o receiver mais próximo tem prioridade. Portanto, uma propriedade com o mesmo nome na classe externa será obscurecida:
class InvoiceFactory(private val amount: Double) {
fun create() = Invoice().apply {
amount = amount // atribuir Invoice.amount a si mesmo, o resultado ainda é 0.0
}
}
Você tem duas maneiras de corrigir. A primeira maneira é especificar claramente o receiver usando this@InvoiceFactory.amount. A segunda maneira é mudar para also:
fun create() = Invoice().also { it.amount = amount }
10.2. A armadilha ?.let { } ?: ...
Muitas pessoas escrevem x?.let { ... } ?: fallback com significado similar ao if/else. Esta forma não é equivalente. Se a lambda retorna null, o branch ?: também executa:
val cache = mapOf<String, String>()
val email: String? = "an@example.com"
email?.let { cache[it] } ?: println("Nenhum email")
Nenhum email
Aqui email é diferente de null, mas cache[it] retorna null, então a mensagem ainda é impressa embora não esteja correto. Quando você precisa ramificar claramente, use if (email != null) ... else ....
10.3. Aninhamento muito profundo
Quando você aninha funções de escopo dentro umas das outras, it e this se referem a muitos objetos diferentes e o leitor precisa rastrear o que cada um é:
// Difícil de ler
order.let { o -> o.items.firstOrNull()?.let { price -> price.run { this * 2 } } }
Nota: Você não deve aninhar mais de um nível. Se obrigado a aninhar, nomeie explicitamente os parâmetros em vez de usar
it. Ainda melhor é separar em uma variável local ou função separada.
10.4. Usar funções de escopo apenas porque é possível
O trecho val name = customer.let { it.name } não traz nada em comparação com val name = customer.name. Funções de escopo são úteis quando ajudam você a remover variáveis temporárias, lidar com null ou agrupar código de configuração. Se não conseguir fazer essas coisas, não use.
11. Conclusão
As cinco funções de escopo do Kotlin diferem apenas em dois pontos: o objeto é acessado através de this ou it, e a função retorna o objeto ou o resultado da lambda. Daí você tem o uso correspondente: apply para configurar, also para adicionar side effects, let para lidar com valores nullable e transformar, enquanto run e with para computar com base no objeto. Você também precisa ter cuidado com this sendo obscurecido, com a armadilha ?.let ?:, e com aninhamento muito profundo. Por fim, lembre-se que o objetivo das funções de escopo é tornar o código mais legível, e não mais curto a qualquer custo.