Kotlin 2.4: As Três Mudanças que Moveram Minha Mão no Teclado
O Kotlin 2.4.0 chegou com um changelog extenso, mas apenas três recursos mudaram a forma como eu realmente digito: context parameters estáveis, explicit backing fields e (ainda atrás de uma flag) name-based destructuring. Este é o meu recorte de engenheiro backend, verificado contra o compilador 2.4.0, mais a remoção do K1 que tive que colocar no calendário.
O Kotlin 2.4.0 foi lançado este mês, e as notas de release são longas: context parameters estáveis, explicit backing fields, annotation use-site targets, API UUID estável, verificações de ordenação, suporte a Java 26 e a remoção do compilador K1, entre outros. Li a lista inteira. Depois, voltei para alguns dos meus próprios arquivos e os reescrevi contra o 2.4 para ver quais linhas realmente mudaram.
A resposta honesta: três recursos mudaram a forma como eu digito, e uma remoção mudou como eu organizei minha semana. Todo o resto é uma nota de rodapé que vou apreciar no dia em que esbarrar nela. Este é o recorte opinativo, escrito da cadeira de um engenheiro backend em vez de um Android.
Context parameters: passar o logger sem passar o logger
Este é o que eu vou buscar primeiro. Os context parameters se tornaram Estáveis no 2.4 (issue de rastreamento KT-72222), substituindo o experimento mais antigo de context receivers.
O problema que eles resolvem é a dependência que toda função em um caminho de chamada precisa, mas ninguém quer na assinatura. Um logger com escopo de requisição é o exemplo mais limpo. Um clock, um handle de transação ou um tenant id funcionam da mesma forma. Você ou passa o valor por toda função como um argumento que ela na verdade não usa, ou recorre a uma thread-local e perde toda a segurança de tipos.
Um context parameter é uma terceira opção: a função declara que precisa de um valor de algum tipo de seu entorno, e o compilador o fornece no call site sem você escrever o argumento.
Aqui está um arquivo autocontido. Ele compila e roda no Kotlin 2.4.0 sem flags.
import java.util.UUID
// Uma coisa que todo handler precisa mas ninguém quer na assinatura:
// um logger com escopo de requisição que marca linhas com o request id.
class RequestLog(val requestId: UUID) {
fun info(message: String) = println("[$requestId] $message")
}
data class Order(val id: String, val cents: Long)
// O context parameter diz: "Preciso de um RequestLog do meu entorno."
// Os chamadores nunca passam explicitamente; o compilador resolve por tipo.
context(log: RequestLog)
fun charge(order: Order): Long {
log.info("charging order ${order.id} for ${order.cents}c")
return order.cents
}
context(log: RequestLog)
fun refund(order: Order): Long {
log.info("refunding order ${order.id}")
return -order.cents
}
// Um único `with(log)` abre o escopo; tudo dentro vê o logger.
fun handleRequest(order: Order) {
val log = RequestLog(UUID.nameUUIDFromBytes("req-42".toByteArray()))
with(log) {
val net = charge(order) + refund(order)
log.info("net effect: ${net}c")
}
}
fun main() {
handleRequest(Order("A-100", 2599))
}Execute com kotlinc orders.kt -include-runtime -d orders.jar && java -jar orders.jar. Ele imprime três linhas, cada uma marcada com o mesmo request id, e charge e refund nunca nomearam o logger na chamada.
A linha que vale estudar é context(log: RequestLog). O nome log torna a dependência referenciável dentro do corpo, algo que os context receivers sem nome não conseguiam fazer de forma limpa. No call site, charge(order) não carrega logger; o bloco with(log) colocou um RequestLog em escopo, e o compilador o casou por tipo.
Esse casamento é a parte que você precisa entender antes de se comprometer com o recurso. Pela minha leitura do código de resolução FIR, a resolução de contexto é uma busca real por escopo orientada por tipo, não um truque sintático. Para cada context parameter, o compilador conta os valores correspondentes em escopo e decide pela contagem.
O diagrama acima é todo o modelo mental. Zero valores do tipo certo em escopo, e a chamada reporta NoContextArgument e não compila. Exatamente um, e ele é passado. Dois ou mais, e você recebe AmbiguousContextArgument — o compilador se recusa a adivinhar entre dois loggers igualmente válidos. Essa é a proteção: uma dependência implícita que está faltando ou é ambígua é um erro de compilação, nunca uma escolha silenciosa em tempo de execução.
O trade-off é a legibilidade, e é real. Um leitor escaneando charge(order) não consegue ver que um logger está fluindo. Você trocou um argumento explícito-mas-barulhento por um invisível-mas-quieto. Minha regra ao reescrever um handler assim: context parameters valem a pena para valores transversais que genuinamente atravessam um grafo de chamadas profundo — um logger, um clock, uma transação. No momento em que dois valores do mesmo tipo podem coexistir em escopo, você está a um refactor de um erro de ambiguidade, e a implicitude para de compensar.
Uma ressalva específica para o 2.4: nomear o argumento no call site, como charge(log = primary), ainda é experimental atrás de -Xexplicit-context-arguments. O recurso de linguagem que liga isso por padrão está previsto para o 2.5. Então no 2.4 você tem a forma implícita estável, mas ainda não pode desambiguar por nome sem a flag opt-in.
Explicit backing fields aposentam a propriedade shadow privada
A segunda mudança que continuo usando é explicit backing fields, também Estável no 2.4. O número do tracker, KT-14663, é uma das requests abertas mais antigas no Kotlin, o que mostra quanto tempo o padrão que ele elimina existe.
O padrão é a propriedade que você expõe como um tipo read-only enquanto seu backing field guarda um tipo mais amplo e mutável. No Android é o par _state / state StateFlow, mas o formato aparece em muito código backend: um buffer de métricas, uma lista acumuladora, qualquer coisa internamente mutável e read-only para chamadores. O modo antigo precisa de duas declarações:
private val _items = mutableListOf<Int>()
val items: List<Int> get() = _itemsDois nomes para um pedaço de estado, só porque o tipo interno e o tipo externo diferem. Explicit backing fields colapsam isso em uma única propriedade:
class Counter {
val total: List<Int>
field = mutableListOf<Int>()
fun add(n: Int) { total.add(n) } // dentro: MutableList<Int>
}Compilei e executei exatamente isso no 2.4.0. Dentro da classe, total resolve para MutableList<Int>, então total.add(n) funciona. Fora, total é List<Int> e não expõe nenhuma mutação. Uma propriedade, duas visibilidades do mesmo objeto, sem propriedade shadow prefixada com _.
As restrições valem conhecer antes de buscar o recurso. Pela minha leitura do checker, um explicit backing field é permitido apenas em um val final, nunca em um var, propriedade open, ou em propriedades de interface, abstratas, expect ou extension. A visibilidade do backing field deve ser mais restritiva do que a da propriedade. A lógica é consistência: se a propriedade pudesse ser sobrescrita, uma subclasse poderia fornecer um tipo de campo diferente, e um chamador não conseguiria mais raciocinar sobre qual tipo ela carrega. A restrição a val final remove essa ambiguidade. Para código backend isso aterrissa menos frequentemente do que para um Android ViewModel, mas toda vez que tenho uma propriedade "mutável dentro, imutável fora", a shadow agora se foi.
Name-based destructuring, ainda atrás de uma flag
A terceira mudança não foi lançada como estável, e quero ser preciso sobre isso porque é fácil interpretar mal a cobertura da release. Name-based destructuring é experimental no 2.4, atrás de -Xname-based-destructuring. Estou incluindo porque ela já mudou como eu escrevo novos consumidores de data class em branches em que estou disposto a usar feature flag, e porque corrige uma classe de bug que já vivi de verdade.
O destructuring posicional vincula por ordem, não por nome. A armadilha clássica:
data class Person(val name: String, val age: Int)
val (age, name) = person // compila, ambos estão erradosIsso compila. age recebe o nome e name recebe a idade, porque posição é tudo o que o compilador usa. Adicione um campo no meio da data class depois, e todo destructuring posicional downstream silenciosamente se desloca.
Name-based destructuring vincula pelo nome da propriedade. Verifiquei isso no 2.4.0 com -Xname-based-destructuring=only-syntax:
(val age, val name) = person // vincula por nome, ordem irrelevanteAqui age recebe person.age e name recebe person.name, independentemente da ordem em que escrevi ou da ordem em que a data class declara. O bytecode é inalterado; este é um recurso do call site. A flag tem três modos que vale conhecer: only-syntax habilita a nova forma parentizada, name-mismatch avisa quando um destructuring posicional antigo usa nomes que não combinam com as propriedades, e complete liga a forma curta. A JetBrains sinalizou estabilização para uma release posterior.
Ainda não estou colocando isso no main. Mas só o modo name-mismatch vale um experimento de CI: ele expõe todo destructuring posicional existente em que os nomes das variáveis já mentem sobre qual campo elas carregam.
A remoção do K1 que tive que agendar
A mudança breaking no 2.4 é estrutural: o frontend do compilador K1 sumiu. Desde o 2.0, o K2 é o padrão, mas o K1 persistia atrás de -language-version 1.9 para projetos que precisavam dele. No 2.4 essa saída de emergência foi removida. O compilador agora rejeita -language-version 1.9 diretamente; a versão de linguagem mais baixa que aceita é 2.0.
Se seu build ainda fixa languageVersion = "1.9" em algum lugar — uma convenção Gradle, um módulo teimoso, defaults de um plugin de terceiros — o 2.4 não vai compilá-lo. Esse é o item de linha que tive que colocar no calendário em vez de absorver no dia do upgrade. A correção não é difícil, mas não é automática: encontre toda versão de linguagem fixada, abaixe para pelo menos 2.0, e recompile contra a resolução mais estrita do K2, que pega algumas construções que o K1 deixava passar.
Para usuários de Kotlin Multiplatform há uma mudança pareada: partial linkage agora está permanentemente ativo. A flag -Xpartial-linkage está depreciada, e apenas -Xpartial-linkage-loglevel sobrevive para controlar quão barulhento um link degradado fica. Se você dependia de desligar partial linkage, esse switch sumiu.
O restante das adições da biblioteca padrão do 2.4 são as notas de rodapé que mencionei. isSorted, isSortedBy e isSortedDescending chegam em iteráveis, arrays e sequences; eles fazem short-circuit no primeiro par fora de ordem em vez de varrer a coleção inteira. O tipo kotlin.uuid.Uuid agora está estável, então parsear e formatar UUIDs não precisa de opt-in — embora generateV4 e generateV7 para criar novos ainda sejam experimentais. UInt.toBigInteger() e ULong.toBigInteger() substituem as antigas conversões baseadas em string na JVM. Bom de ter, nenhum deles mudou uma linha que eu já tinha escrito.
O que realmente fazer com isso
- Busque context parameters para um valor transversal que atravessa um grafo de chamadas profundo — logger, clock, transação. Pare no momento em que dois valores do mesmo tipo possam compartilhar um escopo, ou você trocará código quieto por um erro
AmbiguousContextArgument. - Use explicit backing fields onde quer que tenha um
valfinal que é mutável dentro e read-only fora. Apague a propriedade shadow prefixada com_. Lembre-se de que é apenas paravalfinal. - Experimente name-based destructuring no modo
name-mismatchem CI antes de adotar. Ele sinaliza todo destructuring posicional cujos nomes de variáveis já discordam da data class. - Antes de atualizar, faça grep por
languageVersion = "1.9"em todo o seu build e abaixe para 2.0+. O K1 sumiu; um 1.9 fixado quebra o build.
Quando evitar cada um: pule context parameters para dependências pontuais que uma única função precisa — um argumento simples é mais claro. Pule explicit backing fields em propriedades var ou open; o compilador não vai deixar de qualquer forma. Mantenha name-based destructuring fora do main até estabilizar. E não trate a remoção do K1 como opcional — é a única mudança no 2.4 que falha de forma fechada.
Curtindo? Talvez goste disso aqui.
Nada parecido — quer tentar outro ângulo?
Posts Relacionados
Auditando um serviço Scala contra as quatro restrições regenerativas de Chad Fowler
Levei um serviço Scala de processamento de pedidos das minhas anotações pelas quatro restrições regenerativas de Chad Fowler. Duas passaram de graça, duas forçariam um redesign de verdade. Aqui está o que aprendi sobre onde "módulo fracamente acoplado" termina e "componente regenerativo" começa, e quais partes do redesign eu de fato pagaria.
Log de Eventos como Fonte da Verdade Transforma Evolução de Schema em um Problema Eterno
Quando o log é a fonte da verdade, toda mudança de schema é permanente. Um passo a passo em Kotlin/Avro do rename que passou na verificação do Schema Registry e corrompeu silenciosamente todos os eventos antigos, mais os invariantes de Protobuf e Avro que agora mantenho fixados acima da minha mesa.
Virtual Threads Depois do JEP 491: O Gargalo Se Moveu
O JEP 491 removeu o problema de pinning do `synchronized` que mantinha as virtual threads fora da produção. A pergunta interessante agora não é se habilitar ou não — é qual gargalo aparece em seguida. Um guia de campo para serviços Spring Boot / Kotlin rodando em JDK 24+.
