Trilha de Kotlin · parte 19 de 59
Funções inline e custo de lambdas
Aula da fase 3 da trilha de Kotlin: Quando uma função `inline` elimina a alocação do lambda, e quando essa otimização não faz diferença?
avançadoPublicada em · 12 min de leitura
Lambdas são a forma mais comum de passar comportamento para uma função em Kotlin. filter, map, let, use e forEach recebem lambdas o tempo todo. Por trás dessa sintaxe leve existe um custo de implementação: uma lambda que é passada como argumento precisa existir como algum objeto na JVM, e chamá-la exige uma chamada a esse objeto. A palavra inline muda essa implementação. Em vez de criar o objeto e chamá-lo, o compilador copia o corpo da função e da lambda para o ponto de chamada. Esta aula explica o que acontece em cada caso, mostra o bytecode que comprova a diferença, e trata da parte que mais confunde: quando o inline elimina a alocação e quando ele não muda nada que importe para o resultado.
Uma comparação de cozinha: uma receita chamada por número obriga o cozinheiro a ir ao arquivo, pegar a folha e voltar. Uma receita inline é copiada para o caderno do cozinheiro, e ele segue sem sair da bancada. O prato é o mesmo, e a diferença está no tempo gasto indo ao arquivo, que é justamente o que o compilador economiza. Só que o cozinheiro também tem de copiar mais páginas no caderno, e nem toda receita vale a cópia.
o mapa da aula
Começamos pelo custo de uma lambda sem inline, com o bytecode real mostrando o objeto de função criado. Depois vemos o que muda com inline, e por que a biblioteca padrão marca tantas funções dessa forma. A seção seguinte trata dos modificadores noinline e crossinline, que existem para os casos em que a cópia não é permitida, e do return não-local, que é o efeito colateral mais útil e também o mais fácil de errar. Por fim, tratamos da relação com as coleções da fase 2: inline remove o objeto de lambda, mas não remove a lista intermediária, e essa diferença é a que mais pesa em serviços. Todo código foi compilado com o kotlinc 2.1.21 e inspecionado com javap.
1. o custo de uma lambda comum
Considere uma função comum que recebe uma lambda e a executa no meio de um trabalho:
fun medirComum(nome: String, bloco: () -> Unit) {
println("início $nome")
bloco()
println("fim $nome")
}
Quando o chamador escreve medirComum("b") { println("corpo") }, a lambda vira um objeto que implementa a interface funcional Function0. A função recebe esse objeto e chama bloco(), que por dentro é uma chamada ao método invoke do objeto. Para ver isso no bytecode, compilamos a classe e usamos o javap com a opção de mostrar o código de cada método. O trecho abaixo vem do método comComum de uma classe de exemplo:
public final void comComum();
2: invokedynamic #69, 0 // InvokeDynamic #0:invoke:()Lkotlin/jvm/functions/Function0;
7: invokestatic #75 // Method A5Kt.medirComum:(Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V
A linha do invokedynamic mostra a criação do objeto de função, e a linha seguinte mostra a chamada a medirComum passando esse objeto. A geração de lambdas por invokedynamic é o padrão desde o Kotlin 2.0, como a documentação oficial da versão 2.0 descreve. Antes dela, o compilador gerava uma classe por lambda. O resultado prático continua o mesmo: existe um objeto e uma chamada a ele.
Essa criação não é gratuita, mas também não é sempre um problema. Um objeto de curta duração costuma ser barato, e a JVM tem mecanismos para reduzir o custo quando o objeto não escapa do método. A aula não mede esse custo, porque nenhuma medição foi feita nesta sessão. O que o bytecode mostra com certeza é que a função comum exige um objeto e uma chamada indireta.
2. o que muda com inline
Agora a versão inline da mesma função:
inline fun medirInline(nome: String, bloco: () -> Unit) {
println("início $nome")
bloco()
println("fim $nome")
}
O uso é idêntico, mas o compilador tem outra instrução. Ele substitui a chamada a medirInline pelo corpo da função, e substitui bloco() pelo corpo da lambda. Assim, o chamador recebe o código já expandido. Veja a saída de uma chamada com as duas versões:
fun main() {
medirInline("inline") { println("trabalho") }
medirComum("comum") { println("trabalho") }
}
A saída real é:
início inline
trabalho
fim inline
início comum
trabalho
fim comum
A saída é a mesma, porque o comportamento não mudou. O que muda está no bytecode do método comInline, da classe de exemplo. Ele tem concatenação de strings com StringBuilder para montar as mensagens, mas não contém invokedynamic nem referência a Function0. O corpo da lambda foi copiado para dentro do método. A figura resume a diferença.
A figura compara as duas formas. No grupo de cima, a função comum recebe um objeto de função, que é criado a cada chamada e depois invocado. No grupo de baixo, o corpo da função inline é colado no chamador e nenhum objeto aparece. O rótulo inline na seta marca o momento em que o compilador faz a cópia.
A biblioteca padrão usa esse mecanismo amplamente. let, also, apply, run, use, takeIf, repeat, forEach e o map de Iterable são inline. Por isso, usar essas funções com lambdas curtas não cria o objeto de função que uma função comum criaria.
3. return não-local: o efeito que só existe com inline
Como o corpo da lambda passa a ser parte do corpo do chamador, um return dentro dela pode sair da função externa, e não apenas da lambda. Esse é um efeito útil, e nas funções comuns ele não existe. O exemplo a seguir procura o primeiro número par de uma lista:
fun primeiroPar(numeros: List<Int>): Int? {
numeros.forEach { if (it % 2 == 0) return it }
return null
}
fun main() {
println(primeiroPar(listOf(1, 3, 4, 6)))
}
A saída é 4. O return it dentro do forEach encerra primeiroPar com o valor 4, e o laço não continua. Isso só funciona porque forEach é inline. Se ele fosse uma função comum, o return teria de sair apenas da lambda, e a função continuaria até o fim.
O mesmo efeito tem um custo de leitura. Quem vê um return dentro de uma lambda precisa saber se a função que recebe a lambda é inline. Se não for, o comportamento é outro, e o compilador avisa com uma mensagem clara quando o return não é permitido. Esse é o motivo de o Kotlin ter o modificador crossinline, descrito na seção seguinte.
Pergunta de entrevistaO que inline faz, e quando não vale a pena usá-lo?
Uma lambda comum vira um objeto em tempo de execução, com alocação e chamada indireta. Com inline, o compilador copia o corpo da função e da lambda no ponto da chamada, então não existe objeto da lambda. Isso também permite return não-local. Não vale a pena em funções grandes ou que não recebem lambda, porque o bytecode cresce sem ganho. Em código comum, o JIT costuma eliminar boa parte desse custo, então inline é ferramenta para casos específicos.
4. crossinline e noinline: quando a cópia não serve
Nem toda lambda pode ser copiada para o chamador. Se uma função inline passa a lambda para outro contexto de execução, como um Runnable que será executado em outra thread, a lambda precisa existir como objeto. Nesse caso o parâmetro é marcado com crossinline. Ele proíbe o return não-local, porque a lambda não roda mais no fluxo de quem chamou:
inline fun depois(crossinline acao: () -> Unit): Runnable = Runnable { acao() }
fun main() {
depois { println("depois") }.run()
}
A saída é depois. Se o lambda do chamador tentar um return simples, o compilador recusa com 'return' is prohibited here.. O crossinline protege a função inline de uma saída que não faria sentido no novo contexto.
O segundo modificador é noinline. Ele diz que um parâmetro específico não deve ser copiado, e que ele deve virar objeto mesmo dentro de uma função inline. Isso é necessário quando o parâmetro é guardado ou devolvido:
inline fun guardar(noinline acao: () -> Unit): () -> Unit = acao
fun main() {
guardar { println("guardado") }()
}
A saída é guardado. Sem noinline, o compilador não tem onde guardar a lambda, porque ela não existe como objeto. A compilação emitiu um aviso útil: expected performance impact from inlining is insignificant. Inlining works best for functions with parameters of function types.. O aviso aparece porque o único parâmetro de função está marcado como noinline, então a lambda não é copiada para o chamador e o ganho do inline fica mínimo.
Existe ainda uma restrição de visibilidade. Uma função inline pública não pode acessar membros privados, porque o corpo dela é copiado para outros módulos, onde o membro privado não existe. A mensagem do compilador é direta:
public-API inline function cannot access non-public-API function.
5. inline não remove a lista intermediária
A parte mais útil para um backend é entender o que inline não faz. Ele elimina o objeto de lambda, mas não elimina a coleção criada pela função. O map de Iterable é inline, e o bytecode da classe de exemplo mostra isso com clareza:
class Uso {
fun mapear(n: List<Int>): List<Int> = n.map { it * 2 }
}
Ao inspecionar mapear com javap, não aparece invokedynamic nem objeto de função para a lambda it * 2. Mas aparece new java/util/ArrayList e um laço que adiciona os resultados. A lambda foi copiada, e a lista de saída continua sendo criada. Como a seção 2 da aula de coleções mostrou, o filter seguido de map cria uma lista intermediária por etapa, e inline não muda isso.
A figura compara Iterable.map, que é inline, com Sequence.map, que não é.
A figura mostra duas linhas que parecem iguais no código, mas cujo custo é diferente. Na linha de cima, o map inline copia a lambda e cria um ArrayList novo, em rosa, porque a alocação continua. Na linha de baixo, o map de Sequence não é inline e chama o objeto de lambda para cada elemento, mas não cria lista até a terminal. A conclusão não é que uma das duas seja sempre mais rápida, mas que inline e Sequence atacam custos diferentes, e misturá-los sem entender isso leva a expectativas erradas.
6. quando inline não faz diferença que importe
Em muitos casos, o inline não muda nada de relevante. Quando a função é chamada poucas vezes, ou quando o trabalho dentro da lambda é caro perto do custo da chamada, o objeto de lambda é um detalhe. Quando a própria JVM consegue provar que o objeto não escapa do método, a análise de escape pode eliminar a alocação, o que a fase 3 trata com detalhe. Nesses casos, marcar uma função como inline é só cópia de código.
O custo de inline também existe. Como o corpo é copiado, o bytecode cresce a cada chamada, e uma função grande marcada como inline pode inflar as classes que a usam. Há também um custo de compatibilidade binária: se a biblioteca mudar o corpo de uma função inline, os módulos que a chamaram precisam ser recompilados, porque a versão antiga do corpo foi copiada para dentro deles.
Por isso a recomendação prática é marcar como inline funções pequenas que recebem lambdas, como as de escopo e de iteração, e evitar inline em funções grandes que apenas recebem um parâmetro de função. Para decidir com segurança, meça com JMH, não com intuição.
7. erros comuns e boas práticas
O primeiro erro é marcar tudo como inline por acreditar que é sempre mais rápido. O resultado é código inchado e um aviso do compilador que a equipe ignora. A boa prática é marcar inline quando o parâmetro é lambda e a função é curta.
O segundo erro é usar return dentro de uma lambda sem saber se a função é inline. Quem lê o código pode presumir um comportamento e encontrar outro. A boa prática é usar return@label quando a intenção é sair só da lambda, e deixar o return simples para o caso em que a saída da função externa é o desejado.
O terceiro erro é achar que inline resolve o custo de map e filter em coleções grandes. Ele remove o objeto de lambda, mas a lista intermediária continua. A solução, quando ela importa, é uma Sequence ou o reaproveitamento da estrutura, como a seção 5 da aula de coleções descreve.
O quarto erro é esquecer crossinline ou noinline e receber um erro de compilação sem entender a causa. A regra é simples: se a lambda é usada em outro contexto, como um objeto ou uma thread, ela precisa de crossinline ou de noinline.
8. exercícios
- Escreva uma função
inline fun <T> tempo(nome: String, bloco: () -> T): Tque imprime o nome, executa o bloco e devolve o resultado. Compile, rode e inspecione comjavapse a chamada cria um objeto de função. - Escreva uma função comum com o mesmo corpo e compare o
javapdas duas versões. Liste as diferenças no bytecode sem tirar conclusões de desempenho. - Crie um
inline funque recebe uma lambda e a guarda numa lista. Leia o erro do compilador e corrija comnoinline. Explique por que a cópia não serve nesse caso.
9. glossário
Lambda: função anônima passada como valor. Em Kotlin, é um objeto na JVM quando não é inline.
Inline: modificador que faz o compilador copiar o corpo da função e da lambda para o ponto de chamada.
Return não-local: return dentro de uma lambda que sai da função externa, permitido apenas em funções inline.
Crossinline: modificador que proíbe o return não-local em um parâmetro lambda de função inline.
Noinline: modificador que mantém um parâmetro lambda como objeto dentro de uma função inline.
Análise de escape: otimização da JVM que descobre quando um objeto não sai do método, permitindo removê-lo.
fontes
Os exemplos foram compilados e executados com o kotlinc 2.1.21 instalado junto ao IntelliJ IDEA nesta máquina, em 2026-10-20. A inspeção de bytecode usou o javap do JDK 21 instalado na mesma máquina. A informação de que a geração de lambdas por invokedynamic é o padrão desde o Kotlin 2.0 vem da página oficial https://kotlinlang.org/docs/whatsnew20.html, consultada em 2026-10-20. A aula não inclui medições de desempenho.