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
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.
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.
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
operatorque 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
operatorque 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,component2e 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.
- Propriedades delegadas: https://kotlinlang.org/docs/delegated-properties.html
- Sobrecarga de operadores: https://kotlinlang.org/docs/operator-overloading.html
- Declarações de desestruturação: https://kotlinlang.org/docs/destructuring-declarations.html
Relacionados
- Trilha de Kotlin
- Nova ordem da trilha de Kotlin
- Classes, objetos e propriedades
- Interfaces, delegação e classes open
- data class, enum e value class
- Coleções e lambdas