Propriedades delegadas em Kotlin: lazy e observable
1. Visão geral
Kotlin permite que você delegue a leitura e escrita de uma propriedade para outro objeto através da palavra-chave by. Esse mecanismo é chamado de delegated properties. A biblioteca padrão oferece vários delegates muito frequentemente usados. Os mais comuns são lazy para inicialização preguiçosa, observable para monitorar mudanças e vetoable para bloquear mudanças inválidas.
Depois de ler este artigo, você saberá como usar cada delegate e escolher o modo de thread-safety correto para lazy. Você também aprenderá a escrever testes para eles e quais situações evitar.
2. Configuração
Os delegates deste artigo estão disponíveis em kotlin-stdlib, então você não precisa adicionar nenhuma biblioteca. Para escrever testes, adicione JUnit 5 e AssertJ:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
Se você usa Spring Boot 3.x, spring-boot-starter-test já inclui ambas as bibliotecas.
3. Como funcionam as delegated properties
Quando você escreve val x: T by someDelegate, o compilador gera um field oculto para manter o delegate. O getter de x chama someDelegate.getValue(...). Se x é var, o setter chama setValue(...).
Você pode escrever um delegate simples para ver o mecanismo claramente:
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
class TrimmedString(private var value: String = "") : ReadWriteProperty<Any?, String> {
override fun getValue(thisRef: Any?, property: KProperty<*>): String = value
override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
this.value = value.trim()
}
}
class Customer {
var name: String by TrimmedString()
}
fun main() {
val customer = Customer()
customer.name = " Nguyễn Văn An "
println("[${customer.name}]")
}
Explicação de cada parte:
TrimmedStringimplementaReadWriteProperty, com dois métodosgetValueesetValue.- Cada vez que você atribui
customer.name, Kotlin chamasetValue, e a string étrim()ada antes de ser salva. - O parâmetro
propertyinforma qual propriedade está sendo acessada.observableusa justamente esse parâmetro para informar qual propriedade mudou.
[Nguyễn Văn An]
Você raramente escreverá um delegate, pois a biblioteca padrão já tem os mais usados.
4. Inicialização preguiçosa com by lazy
4.1. Uso básico
lazy recebe um lambda. O lambda é executado apenas na primeira vez que você lê a propriedade. O resultado é armazenado em cache para todas as leituras subsequentes.
class ReportService {
val exchangeRates: Map<String, Double> by lazy {
println("Carregando taxas de câmbio...")
mapOf("USD" to 25_400.0, "EUR" to 27_300.0)
}
}
fun main() {
val service = ReportService()
println("Serviço criado")
println(service.exchangeRates["USD"])
println(service.exchangeRates["EUR"])
}
Resultado:
Serviço criado
Carregando taxas de câmbio...
25400.0
27300.0
Há três pontos importantes:
- Criar
ReportServicenão dispara o carregamento das taxas. - A linha “Carregando taxas de câmbio…” é impressa apenas uma vez, embora a propriedade seja lida duas vezes.
lazyfunciona apenas comval. Escrevervar x by lazy { ... }causa erro de compilação, porqueLazynão possuisetValue.
lazy é útil para valores que custam caro criar mas nem sempre são necessários. Exemplos: uma regex complexa, uma configuração lida de arquivo, ou um cliente que chama um serviço externo.
4.2. Modo thread-safety
Por padrão, lazy usa LazyThreadSafetyMode.SYNCHRONIZED. Este modo garante que o lambda seja executado exatamente uma vez, mesmo que múltiplas threads acessem simultaneamente. Você pode mudar o modo quando necessário:
class PriceCalculator {
// Apenas uma thread executa o inicializador, outras aguardam
val taxTable: Map<String, Double> by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
mapOf("VN" to 0.1, "SG" to 0.09)
}
// Múltiplas threads podem executar o inicializador, o primeiro valor é mantido
val discountRules: List<String> by lazy(LazyThreadSafetyMode.PUBLICATION) {
listOf("BLACK_FRIDAY", "TET_SALE")
}
// Sem sincronização, use apenas quando a propriedade é acessada de uma única thread
val formatter: java.text.DecimalFormat by lazy(LazyThreadSafetyMode.NONE) {
java.text.DecimalFormat("#,###")
}
}
fun main() {
val calculator = PriceCalculator()
println(calculator.taxTable["VN"])
println(calculator.discountRules.first())
println(calculator.formatter.format(1_250_000))
}
0.1
BLACK_FRIDAY
1,250,000
Você pode escolher o modo de acordo com esta tabela:
| Modo | Inicializador executa | Quando usar |
|---|---|---|
SYNCHRONIZED |
Exatamente 1 vez | Padrão, seguro para a maioria dos casos |
PUBLICATION |
Pode múltiplas vezes, mantém primeiro resultado | Inicializador barato, sem side effects, quer evitar lock |
NONE |
Não garantido | Código executado em uma única thread (thread de UI, loop interno) |
Atenção:
NONEé um pouco mais rápido porque não tem lock. Se você usa em um bean Spring singleton acessado por múltiplas requisições simultaneamente, o inicializador pode executar várias vezes. Diferentes threads podem ver instâncias diferentes. Não mude o modo só para “otimizar” sem medir antes.
4.3. Quando o inicializador lança exceção
Se o lambda lança uma exceção, o valor não é armazenado. A próxima leitura executará o lambda novamente:
class ConfigLoader {
var attempts = 0
val apiKey: String by lazy {
attempts++
if (attempts < 2) error("Falha ao ler arquivo de configuração")
"secret-key-123"
}
}
fun main() {
val loader = ConfigLoader()
runCatching { loader.apiKey }.onFailure { println("Erro: ${it.message}") }
println(loader.apiKey)
println("Número de tentativas: ${loader.attempts}")
}
Erro: Falha ao ler arquivo de configuração
secret-key-123
Número de tentativas: 2
Este comportamento é útil quando o erro é temporário. Mas se o inicializador é muito custoso e o erro se repete, cada acesso terá esse custo novamente.
4.4. Verificar se já foi inicializado
Se você precisa saber se um valor já foi criado, mantenha o objeto Lazy em uma variável separada e delegue para ele:
class CacheHolder {
private val productCacheDelegate = lazy { mutableMapOf<String, Int>() }
val productCache: MutableMap<String, Int> by productCacheDelegate
fun isCacheReady(): Boolean = productCacheDelegate.isInitialized()
}
fun main() {
val holder = CacheHolder()
println(holder.isCacheReady())
holder.productCache["SKU-1"] = 10
println(holder.isCacheReady())
}
false
true
Essa técnica é útil quando você quer liberar recursos no desligamento, mas apenas se o recurso foi realmente criado.
4.5. lazy ou lateinit?
Esses dois são frequentemente confundidos:
lazy: usa comval. O valor é calculado pela propriedade mesma, na primeira leitura.lateinit: usa comvare tipos não-nulos, não se aplica a primitivos comoInt. O valor é atribuído de fora depois, por exemplo via dependency injection ou método@BeforeEachem testes.
Se você sabe como calcular o valor dentro da classe, use lazy. Se o valor é “injetado” de fora, use lateinit.
5. Monitorar mudanças com Delegates.observable
Delegates.observable recebe um valor inicial e um callback. O callback é executado depois que o novo valor foi atribuído.
import kotlin.properties.Delegates
enum class OrderStatus { NEW, PAID, SHIPPED, DELIVERED }
class Order(val id: String) {
val history = mutableListOf<String>()
var status: OrderStatus by Delegates.observable(OrderStatus.NEW) { property, old, new ->
history += "${property.name}: $old -> $new"
}
}
fun main() {
val order = Order("ORD-001")
order.status = OrderStatus.PAID
order.status = OrderStatus.SHIPPED
order.status = OrderStatus.SHIPPED
order.history.forEach(::println)
}
status: NEW -> PAID
status: PAID -> SHIPPED
status: SHIPPED -> SHIPPED
Explicação:
OrderStatus.NEWé o valor inicial. Atribuir esse valor não dispara o callback.- O callback recebe três parâmetros:
property(metadados),old(valor antigo) enew(valor novo). - A terceira linha mostra que o callback executa mesmo quando você atribui o mesmo valor.
Se você quer reagir apenas quando o valor realmente muda, filtre dentro do callback:
var status: OrderStatus by Delegates.observable(OrderStatus.NEW) { _, old, new ->
if (old != new) history += "$old -> $new"
}
observable é comumente usado para auditar registros, marcar um objeto como “dirty” precisando ser salvo, ou atualizar um valor derivado.
6. Bloquear valores inválidos com Delegates.vetoable
vetoable é semelhante a observable, mas o callback é executado antes da atribuição e retorna um Boolean. Se o callback retorna false, o valor antigo é mantido.
import kotlin.properties.Delegates
class CartItem(val sku: String) {
var quantity: Int by Delegates.vetoable(1) { _, _, new ->
new in 1..99
}
}
fun main() {
val item = CartItem("SKU-42")
item.quantity = 5
println(item.quantity)
item.quantity = 0
println(item.quantity)
item.quantity = 150
println(item.quantity)
}
5
5
5
As atribuições 0 e 150 são rejeitadas silenciosamente, e quantity permanece 5.
Atenção: Como
vetoablenão lança exceção, o código que chama não saberá que a atribuição falhou. Para dados de negócio importantes, como valor de pagamento, use um setter customizado comrequire(...)para que o erro seja evidente.vetoableé útil quando ignorar valores inválidos é o comportamento desejado, como limitar valores de um slider na UI.
7. Escrever testes para delegates
Os testes abaixo verificam três comportamentos principais: lazy inicializa apenas uma vez, observable registra histórico, e vetoable rejeita valores inválidos.
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
class DelegatedPropertiesTest {
class ExpensiveHolder(private val counter: AtomicInteger) {
val value: String by lazy {
counter.incrementAndGet()
Thread.sleep(50)
"ready"
}
}
@Test
fun `lazy executa inicializador uma vez mesmo com múltiplas threads acessando`() {
val counter = AtomicInteger()
val holder = ExpensiveHolder(counter)
val executor = Executors.newFixedThreadPool(8)
repeat(8) { executor.submit { holder.value } }
executor.shutdown()
executor.awaitTermination(5, TimeUnit.SECONDS)
assertThat(holder.value).isEqualTo("ready")
assertThat(counter.get()).isEqualTo(1)
}
@Test
fun `observable registra cada mudança de status`() {
val order = Order("ORD-002")
order.status = OrderStatus.PAID
order.status = OrderStatus.DELIVERED
assertThat(order.history).containsExactly(
"status: NEW -> PAID",
"status: PAID -> DELIVERED"
)
}
@Test
fun `vetoable mantém valor antigo quando rejeitado`() {
val item = CartItem("SKU-7")
item.quantity = 10
item.quantity = -3
assertThat(item.quantity).isEqualTo(10)
}
}
O primeiro teste usa 8 threads lendo value simultaneamente. Com o modo padrão SYNCHRONIZED, counter sempre será 1. Se você mudar para LazyThreadSafetyMode.NONE e rodar várias vezes, o teste falhará ocasionalmente. É uma forma rápida de verificar a diferença entre os modos.
8. Erros comuns e quando não usar
Usar delegate em entidades JPA. O field real gerado pelo compilador é status$delegate, com tipo ObservableProperty, não OrderStatus. Hibernate não consegue fazer map desse tipo naturalmente. Com entidades, use fields comuns com setters customizados ou entity listeners.
Lambda de lazy retém referências. O lambda é mantido até ser executado. Se ele captura um objeto grande e a propriedade nunca é lida, o objeto permanece na memória.
Ler lazy muito cedo no init. Se o lambda usa uma propriedade declarada abaixo, e você lê a propriedade lazy dentro de init, pode receber um valor não inicializado, como null ou 0.
Substituir mecanismos do framework. No Spring, não use by lazy para criar beans ou gerenciar ciclo de vida de um connection pool. Use @Lazy ou ObjectProvider, para o container ainda gerenciar o fechamento de recursos.
Side effects pesados em observable. O callback executa sincronamente no setter. Chamar HTTP ou escrever banco de dados no callback torna uma atribuição simples lenta e propensa a erros. Publique um event em vez de fazer trabalho pesado ali.
Confundir que observable é thread-safe. observable e vetoable não têm sincronização. Se múltiplas threads atribuem, o callback pode receber old incorreto.
9. Conclusão
Delegated properties ajudam você a separar a lógica de acesso de propriedades da classe, tornando o código mais limpo e legível. Use by lazy para valores custosos criar, e mantenha o modo SYNCHRONIZED padrão a menos que tenha motivo claro para mudar. Use Delegates.observable quando precisa reagir a cada mudança, e lembre que o callback executa mesmo quando o valor não muda. Use Delegates.vetoable quando quer silenciosamente ignorar valores inválidos; quando precisa reportar erro claramente, use require no setter.
Uma vez que você entende getValue e setValue, pode escrever delegates customizados para suas necessidades, como normalizar strings, ler valores de um Map ou registrar acessos. Apenas evite usá-los onde o framework precisa ver o field real, como entidades JPA.