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:

  • TrimmedString implementa ReadWriteProperty, com dois métodos getValue e setValue.
  • Cada vez que você atribui customer.name, Kotlin chama setValue, e a string é trim()ada antes de ser salva.
  • O parâmetro property informa qual propriedade está sendo acessada. observable usa 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 ReportService não dispara o carregamento das taxas.
  • A linha “Carregando taxas de câmbio…” é impressa apenas uma vez, embora a propriedade seja lida duas vezes.
  • lazy funciona apenas com val. Escrever var x by lazy { ... } causa erro de compilação, porque Lazy não possui setValue.

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 com val. O valor é calculado pela propriedade mesma, na primeira leitura.
  • lateinit: usa com var e tipos não-nulos, não se aplica a primitivos como Int. O valor é atribuído de fora depois, por exemplo via dependency injection ou método @BeforeEach em 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) e new (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 vetoable nã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 com require(...) 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.

kotlinpropriedades delegadaslazyobservabledelegates