Pular para o conteúdo

Trilha de Kotlin · parte 20 de 59

Propriedades delegadas, operadores e destructuring

Delegates com by (lazy, observable, vetoable e um próprio), operadores com operator fun e destructuring com componentN: o que cada um resolve e onde engana.

intermediárioPublicada em · 11 min de leitura

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

Propriedades delegadas, operadores e

Quando você pede o saldo ao gerente, ele liga para a secretária, que consulta o sistema e repassa o número. Quem pergunta acha que falou com o gerente. Uma propriedade delegada é isso: a leitura parece direta, mas quem responde é outro objeto. O limite da analogia é que a secretária é uma pessoa, e um é código que roda a cada acesso. Se ele lança exceção, quem leu a propriedade recebe a exceção.

Esta aula reúne três recursos: propriedades delegadas com by, sobrecarga de operadores com operator fun e desestruturação com componentN. Os três traduzem uma sintaxe curta em chamadas a funções de nome fixo. A armadilha comum é não saber qual função está sendo chamada.

Você precisa de Classes, objetos e propriedades e do require de Exceções e primeiro teste automatizado. A delegação de classe com by está em Interfaces, delegação e classes open e não é repetida aqui. Os trechos rodaram com Kotlin 2.4.21 e Gradle 9.7.0 pelo wrapper, no OpenJDK 25 deste Mac. Não rodei com JDK 21. Os blocos não têm linha package, e as posições de erro contam a partir da primeira linha.

o mapa da aula

A ordem é: o problema, o mecanismo, os delegates da biblioteca, um delegate próprio, operadores com um exemplo de dinheiro, e destructuring. Erros, exercícios, glossário, limites e fontes fecham a aula.

1. o problema que os delegates resolvem

Uma propriedade simples guarda um valor. Muitas guardam também uma regra: calcular só na primeira leitura, avisar quando mudam, recusar valores fora de uma faixa. Sem delegate, essa regra vira getter e setter escritos à mão, repetidos em cada propriedade. O delegate tira a regra da propriedade e a põe num objeto reaproveitável.

2. como um delegate funciona

Uma propriedade delegada usa by com o objeto que responde pelas leituras e escritas. O getter gerado chama getValue nesse objeto, passando o dono e um KProperty, que descreve a propriedade. Um var também chama setValue. As duas funções precisam de operator, e o tipo devolvido é o da propriedade. Uma val precisa só de getValue. A documentação diz que o delegate fica numa propriedade oculta, dados$delegate.

3. os delegates da biblioteca

: calcular na primeira leitura

lazy roda o bloco na primeira leitura e guarda o resultado. As leituras seguintes devolvem o guardado.

class Relatorio {
    val dados: List<Int> by lazy {
        println("carregando dados")
        listOf(1, 2, 3)
    }
}

fun main() {
    val relatorio = Relatorio()
    println("relatório criado")
    println(relatorio.dados.size)
    println(relatorio.dados.size)
}
relatório criado
carregando dados
3
3

A figura mostra o caminho de uma leitura, inclusive a falha.

Propriedade by lazy: a primeira leitura roda o bloco e guarda o valor, as leituras seguintes devolvem o guardado, e exceção no bloco não guarda nada
figura 1 · Propriedade by lazy: a primeira leitura calcula, as seguintes devolvem o valor g

O caminho verde devolve o valor guardado. O azul calcula e guarda. O vermelho mostra a falha: a exceção sai do bloco e nada é guardado.

Por padrão, a documentação diz que lazy é sincronizado: o valor é calculado em apenas uma thread, e todas veem o mesmo resultado. Oito threads lendo a mesma propriedade, com um bloco que dorme 100 ms, deram uma única execução. Um caso não prova a garantia, que vem da documentação.

A documentação não diz o que acontece quando o bloco lança exceção. Testei: a primeira leitura lançou, e a segunda rodou o bloco de novo e devolveu 42. Isso foi observado, não documentado.

e : reagir à escrita

Delegates.observable chama um bloco depois de cada escrita. Delegates.vetoable chama o bloco antes, e se ele devolver false o valor não muda.

import kotlin.properties.Delegates

class Termometro {
    var grau: Int by Delegates.observable(20) { _, antigo, novo ->
        println("de $antigo para $novo")
    }
}

class Idade {
    var anos: Int by Delegates.vetoable(0) { _, _, novo -> novo >= 0 }
}

fun main() {
    val termometro = Termometro()
    termometro.grau = 25
    termometro.grau = 25
    val idade = Idade()
    idade.anos = 30
    idade.anos = -5
    println(idade.anos)
}
de 20 para 25
de 25 para 25
30

A segunda escrita de 25 disparou o bloco mesmo sem mudança, como a documentação descreve para cada atribuição. A idade ficou em 30 porque o vetoable devolveu false para -5. A documentação não diz o que esse false faz, e o efeito foi observado.

Um Map também pode ser delegate, com a propriedade lendo a chave de mesmo nome. Não testei essa variante.

4. escrever um delegate próprio

Quando a biblioteca não cobre o caso, implemente ReadWriteProperty, de kotlin.properties. O exemplo encaixa o volume numa faixa de 0 a 10 com coerceIn.

import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty

class Faixa(private var valor: Int, private val minimo: Int, private val maximo: Int) :
    ReadWriteProperty<Any?, Int> {
    override fun getValue(thisRef: Any?, property: KProperty<*>): Int = valor

    override fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) {
        valor = value.coerceIn(minimo, maximo)
        println("${property.name} = $valor")
    }
}

class Mixer {
    var volume: Int by Faixa(5, 0, 10)
}

fun main() {
    val mixer = Mixer()
    mixer.volume = 42
    println(mixer.volume)
    mixer.volume = -3
    println(mixer.volume)
}
volume = 10
10
volume = 0
0

A primeira linha de cada par vem do setValue. Recusar um valor, como o vetoable, ou ajustá-lo, como esta faixa, é regra de negócio, e a sintaxe é a mesma. O projeto tinha só a biblioteca padrão, e property.name funcionou.

Pergunta de entrevistaComo funciona o by lazy?

by lazy guarda um valor calculado na primeira leitura. A propriedade delega a um objeto Lazy, que executa o bloco uma vez e guarda o resultado. Nas leituras seguintes, devolve o valor guardado. Por padrão é sincronizado, então é seguro em concorrência, mas tem custo de lock. Serve para dados caros que talvez nem sejam usados. Vale lembrar que o valor não é recalculado se o bloco depender de um estado que muda depois.

5. quando usar e quando não

Use um delegate quando a mesma regra aparece em várias propriedades, ou quando o nome do delegate já explica a regra, como lazy. Para um único if num setter, o setter escrito à mão é mais claro.

Não use quando a regra depende de muita coisa de fora: um erro dentro do delegate sobe para quem leu a propriedade, e quem depura precisa abrir o delegate. Não medi custo; num laço apertado, meça antes.

6. sobrecarga de operadores

Você não cria símbolo novo nem muda a precedência, mas decide o significado. Uma função de nome fixo, marcada com operator, faz o símbolo chamá-la. a + b chama plus, -a chama unaryMinus, a[i] chama get, a[i] = v chama set, a in b chama contains em b, a(i) chama invoke, e a > b chama compareTo, que devolve Int. A documentação cita === e !== como não sobrecarregáveis. O ! vira not().

Sem o operator, o compilador recusa o +:

class Dinheiro(val centavos: Long) {
    fun plus(outro: Dinheiro): Dinheiro = Dinheiro(centavos + outro.centavos)
}

fun main() {
    val soma = Dinheiro(100) + Dinheiro(50)
    println(soma.centavos)
}
e: .../src/main/kotlin/Main.kt:6:30 'operator' modifier is required on 'fun plus(outro: Dinheiro): Dinheiro' defined in 'Dinheiro'.

A coluna 30 aponta para o +. A função existe, mas sem o modificador o compilador não a usa como operador.

7. exemplo único: dinheiro

Este exemplo atravessa as seções 7 e 8. Dinheiro guarda centavos e moeda. Soma e subtração conferem a moeda, porque somar reais com dólares não faz sentido. Carteira guarda um Dinheiro por moeda, e ali get, set e contains fazem sentido. Conversor mostra invoke.

import kotlin.math.roundToLong

data class Dinheiro(val centavos: Long, val moeda: String) {
    private fun conferirMoeda(outro: Dinheiro) {
        require(moeda == outro.moeda) { "moedas diferentes: $moeda e ${outro.moeda}" }
    }

    operator fun plus(outro: Dinheiro): Dinheiro {
        conferirMoeda(outro)
        return Dinheiro(centavos + outro.centavos, moeda)
    }

    operator fun minus(outro: Dinheiro): Dinheiro = this + -outro

    operator fun times(quantidade: Int): Dinheiro = Dinheiro(centavos * quantidade, moeda)

    operator fun unaryMinus(): Dinheiro = Dinheiro(-centavos, moeda)

    operator fun compareTo(outro: Dinheiro): Int {
        conferirMoeda(outro)
        return centavos.compareTo(outro.centavos)
    }
}

class Carteira {
    private val saldos = mutableMapOf<String, Dinheiro>()

    operator fun get(moeda: String): Dinheiro = saldos[moeda] ?: Dinheiro(0, moeda)

    operator fun set(moeda: String, valor: Dinheiro) {
        require(valor.moeda == moeda) { "valor em ${valor.moeda} não cabe em $moeda" }
        saldos[moeda] = valor
    }

    operator fun contains(moeda: String): Boolean = moeda in saldos
}

class Conversor(private val taxa: Double) {
    operator fun invoke(valor: Dinheiro, moedaDestino: String): Dinheiro =
        Dinheiro((valor.centavos * taxa).roundToLong(), moedaDestino)
}

fun main() {
    val cafe = Dinheiro(850, "BRL")
    val pao = Dinheiro(325, "BRL")
    println(cafe + pao)
    println(cafe - pao)
    println(pao * 3)
    println(-pao)
    println(cafe > pao)

    val carteira = Carteira()
    carteira["BRL"] = cafe
    println(carteira["BRL"])
    println(carteira["USD"])
    println("BRL" in carteira)
    println("USD" in carteira)

    val dolar = Conversor(0.2)
    println(dolar(cafe, "USD"))
}
Dinheiro(centavos=1175, moeda=BRL)
Dinheiro(centavos=525, moeda=BRL)
Dinheiro(centavos=975, moeda=BRL)
Dinheiro(centavos=-325, moeda=BRL)
true
Dinheiro(centavos=850, moeda=BRL)
Dinheiro(centavos=0, moeda=USD)
true
false
Dinheiro(centavos=170, moeda=USD)

carteira["USD"] devolve zero sem gravar nada, por isso "USD" in carteira continua false. E roundToLong arredonda a conversão.

Operadores do código, como a + b, c com chave k ou d(valor, moeda), viram chamadas a funções de nome fixo, como plus, get e invoke, declaradas na classe
figura 2 · Cada operador vira uma chamada a uma função de nome fixo na classe

O que você escreve está em laranja, e a função que a classe declara, em verde. Cada seta, rotulada com “vira”, é a tradução do compilador. São 18 elementos, acima da recomendação do ArchFlow, de propósito.

8. o limite do bom senso

Um operador confunde quando o símbolo não tem leitura natural para o tipo. Por isso invoke só existe em Conversor: num Dinheiro, valor(2) seria uma pergunta sem resposta. Regra prática: se você precisa de uma frase para explicar o operador, use uma função com nome, como converter.

9. destructuring

val (a, b) = objeto vira, segundo a documentação, objeto.component1() e objeto.component2(), por posição. A data class já gera essas funções, como mostrou data class, enum e value class. Os outros casos vêm da biblioteca padrão:

data class Dinheiro(val centavos: Long, val moeda: String)

fun main() {
    val (centavos, moeda) = Dinheiro(850, "BRL")
    println("$centavos $moeda")

    val (menor, maior) = Pair(3, 9)
    println("$menor até $maior")

    val (a, b, c) = Triple("ana", 30, true)
    println("$a $b $c")

    val (chave, valor) = mapOf("BRL" to 1).entries.first()
    println("$chave -> $valor")

    for ((codigo, taxa) in mapOf("BRL" to 1, "USD" to 5)) println("$codigo: $taxa")

    for ((indice, nome) in listOf("ana", "bia").withIndex()) println("$indice $nome")
}
850 BRL
3 até 9
ana 30 true
BRL -> 1
BRL: 1
USD: 5
0 ana
1 bia

Map.Entry tem a chave como component1 e o valor como component2, por isso o for sobre um mapa funciona. O _ ignora um componente sem chamar a função correspondente, segundo a documentação. As lambdas de Coleções e lambdas usam o mesmo mecanismo.

escrito à mão

Qualquer classe serve, desde que declare component1 e component2 com operator:

class Faixa(val inicio: Int, val fim: Int) {
    operator fun component1() = inicio
    operator fun component2() = fim
}

fun main() {
    val (inicio, _) = Faixa(10, 20)
    val (_, fim) = Faixa(10, 20)
    println("$inicio e $fim")
}
10 e 20

A correspondência é por posição, e o nome das variáveis não é conferido. Se alguém trocar a ordem das funções, o código continua compilando e recebe os valores trocados.

10. erros comuns e boas práticas

componentN sem operator

class Faixa(val inicio: Int, val fim: Int) {
    fun component1() = inicio
    fun component2() = fim
}

fun main() {
    val (inicio, fim) = Faixa(10, 20)
    println("$inicio e $fim")
}
e: .../src/main/kotlin/Main.kt:7:10 'operator' modifier is required on 'fun component1(): Int' defined in 'Faixa'.
e: .../src/main/kotlin/Main.kt:7:18 'operator' modifier is required on 'fun component2(): Int' defined in 'Faixa'.

O compilador aponta as duas funções. A correção é o modificador.

outros dois tropeços

Sem plusAssign, total += x vira total = total + x, e com val isso não compila: use var. E lazy só tem getValue, então var com lazy também não compila.

boas práticas

Prefira um delegate da biblioteca, ou um próprio com nome claro, a getter e setter repetidos. Use lazy com val.

11. exercícios com respostas

exercício guiado: dividir o dinheiro

Antes de olhar a resposta, acrescente à Dinheiro uma função operator fun div(divisor: Int): Dinheiro e imprima Dinheiro(1000, "BRL") / 4 e / 3. Antes de rodar, diga quantos centavos sai o segundo.

data class Dinheiro(val centavos: Long, val moeda: String) {
    operator fun div(divisor: Int): Dinheiro = Dinheiro(centavos / divisor, moeda)
}

fun main() {
    val mil = Dinheiro(1000, "BRL")
    println(mil / 4)
    println(mil / 3)
}
Dinheiro(centavos=250, moeda=BRL)
Dinheiro(centavos=333, moeda=BRL)

O segundo resultado é 333 porque a divisão de Long trunca, e o resto de 1 centavo desaparece sem aviso.

exercício 2: ler uma desestruturação

Sem rodar, diga o que este código imprime. Depois confira.

data class Dinheiro(val centavos: Long, val moeda: String)

fun main() {
    for ((letra, n) in listOf('a' to 1, 'b' to 2)) println(letra)
    val (x, _, z) = Triple(1, 2, 3)
    println("$x e $z")
    val (_, moedaSozinha) = Dinheiro(500, "USD")
    println(moedaSozinha)
}
a
b
1 e 3
USD

O laço imprime a primeira parte de cada par. No Triple, o _ pula o 2. E moedaSozinha recebe o segundo componente de Dinheiro.

12. glossário

  • Delegate: objeto que responde pelas leituras e escritas de uma propriedade declarada com by.
  • getValue e setValue: funções operator que o delegate precisa ter.
  • lazy: delegate que calcula o valor na primeira leitura.
  • observable: delegate que chama um bloco depois de cada escrita.
  • vetoable: delegate que chama um bloco antes da escrita, e não grava se ele devolver false.
  • ReadWriteProperty: interface para escrever um delegate próprio.
  • KProperty: descrição da propriedade passada ao delegate, com o nome em name.
  • Sobrecarga de operador: função operator que faz um símbolo funcionar com o seu tipo.
  • invoke: função que faz um objeto ser chamado como função.
  • Destructuring: atribuição de várias variáveis pelas funções componentN.
  • componentN: funções component1, component2 e assim por diante, chamadas por posição.
  • Underscore: _, que ignora um componente na desestruturação.

o que não foi confirmado ou medido

Os trechos rodaram só com OpenJDK 25, Kotlin 2.4.21 e Gradle 9.7.0. Não testei com JDK 21.

A thread-safety do lazy vem da documentação; o teste com oito threads foi um caso só. A nova tentativa após exceção no lazy, o efeito do false no vetoable e o disparo do observable com valor igual foram observados, e a documentação consultada não os detalha.

Não testei Map como delegate, nem a troca de ordem das funções componentN, nem os cliques do IntelliJ. O custo de acesso não foi medido.

Fontes

Consultadas em 04/11/2026.

Relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?