Pular para o conteúdo

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

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

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.

Lambda como objeto contra corpo copiado. medirComum(...) → Function0 (novo objeto); Function0 → bloco() (invoke()); medirInline(...) → Corpo copiado (inline)
figura 1 · Lambda como objeto contra corpo copiado

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 é.

inline remove o lambda, não a lista. [1, 2] → map inline; map inline → ArrayList [2, 4]; [1, 2] → map; map → Sequence
figura 2 · inline remove o lambda, não a lista

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

  1. Escreva uma função inline fun <T> tempo(nome: String, bloco: () -> T): T que imprime o nome, executa o bloco e devolve o resultado. Compile, rode e inspecione com javap se a chamada cria um objeto de função.
  2. Escreva uma função comum com o mesmo corpo e compare o javap das duas versões. Liste as diferenças no bytecode sem tirar conclusões de desempenho.
  3. Crie um inline fun que recebe uma lambda e a guarda numa lista. Leia o erro do compilador e corrija com noinline. 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.

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
?