Trilha de Kotlin · parte 16 de 59
Coleções imutáveis e Sequence
Aula da fase 3 da trilha de Kotlin: Qual a diferença entre `List` e `Sequence` em uma cadeia de `map` e `filter`, e quando a alocação de coleções intermediárias importa?
intermediárioPublicada em · 12 min de leitura
Uma coleção é um lugar onde você guarda vários valores, e quase todo serviço Spring processa coleções: linhas de uma consulta, itens de um pedido, eventos de uma fila. O Kotlin separa duas coisas que o Java costuma misturar, a leitura e a escrita. Uma List é uma visão somente leitura, e uma MutableList é a que permite alterar. Além disso, a biblioteca padrão oferece dois jeitos de encadear operações como filter e map: o que processa a coleção inteira a cada etapa, e o que processa um elemento de cada vez, chamado Sequence. Esta aula explica as diferenças, o mecanismo por trás delas e os erros que aparecem quando alguém confunde as duas coisas.
Uma comparação que ajuda: pense numa linha de montagem. Na versão List, cada etapa monta todas as peças antes de a peça seguinte começar, e entre uma etapa e outra existe um estoque intermediário que ocupa espaço. Na versão Sequence, cada peça atravessa a linha inteira antes de a próxima entrar. A produção final é a mesma, mas o estoque intermediário muda, e o caso em que a peça para no meio do caminho também muda.
o mapa da aula
Começamos pela diferença entre leitura e imutabilidade, porque a palavra “imutável” é usada de forma imprecisa. Depois vem o pipeline com List, com a saída real mostrando a ordem de execução, e o pipeline com Sequence, com a mesma entrada. A seguir, a fonte infinita e o reaproveitamento de uma Sequence, dois usos que só existem por causa da avaliação preguiçosa. A última parte mostra useLines, a forma idiomática de ler um arquivo grande sem carregá-lo na memória, e o erro que acontece quando o stream é usado depois de fechado. Todo código foi compilado e executado com o kotlinc 2.1.21.
1. leitura e imutabilidade são garantias diferentes
A interface List<E> do Kotlin não tem métodos de alteração. Ela expõe só leitura: get, size, iteração. Isso é uma restrição de tipo, feita em tempo de compilação. Não significa que o objeto por trás dela não muda. Veja o exemplo:
fun main() {
val mutavel = mutableListOf(1)
val visao: List<Int> = mutavel
mutavel.add(2)
println("visão: $visao")
}
A saída é visão: [1, 2]. A variável visao tem o tipo List, mas aponta para a mesma lista que mutavel, e por isso enxerga o elemento adicionado depois. O código não tem nenhum erro, e é esse o motivo de muitos bugs que aparecem quando uma classe expõe List como se fosse uma cópia protegida. Se você precisa de uma lista que nunca mude depois de criada, a garantia vem de copiá-la na fronteira, com toList(), e não do tipo que a função devolve.
Vale a distinção com o que o Java oferece. Collections.unmodifiableList também cria uma visão, e não uma cópia, então ela tem o mesmo problema. A diferença é que, em Kotlin, a intenção aparece no tipo: quem recebe uma List sabe que não deve alterá-la, mas precisa lembrar que a origem pode mudar.
2. o pipeline com List: cada etapa termina antes da próxima
Quando você encadeia filter e map numa List, cada função da biblioteca percorre a coleção inteira, monta uma nova lista com o resultado e entrega essa lista à etapa seguinte. Para ver a ordem exata, o exemplo a seguir imprime uma linha em cada chamada:
fun main() {
val numeros = (1..4).toList()
val listaFinal = numeros
.filter { println(" filter $it"); it % 2 == 0 }
.map { println(" map $it"); it * 10 }
println("resultado List: $listaFinal")
}
A saída real é esta:
filter 1
filter 2
filter 3
filter 4
map 2
map 4
resultado List: [20, 40]
Todos os filter rodam antes de qualquer map. Entre as duas etapas existe uma lista intermediária com os valores 2 e 4, que foi alocada só para ser lida pela etapa seguinte e depois descartada. Para uma coleção pequena, isso é irrelevante. Para uma coleção grande, cada etapa é um percurso completo e uma nova alocação, e a figura mostra esse custo em forma de estoque entre as etapas.
A figura compara as duas formas lado a lado. O grupo da esquerda mostra a List, em que a lista de dois elementos aparece como alocada no meio do caminho. O grupo da direita mostra a Sequence, em que cada elemento passa pelo filter e pelo map antes do próximo começar. A seta com o rótulo um por vez é o ponto central: a Sequence não gera uma coleção por etapa.
3. o pipeline com Sequence: elemento por elemento, sob demanda
Uma Sequence é uma fonte de valores que só produz o próximo elemento quando alguém pede. As funções intermediárias, como filter e map, não executam nada; apenas montam a receita. A execução acontece quando uma função terminal, como toList, first ou count, consome a sequência. Esse estilo se chama avaliação preguiçosa, ou lazy.
fun main() {
val numeros = (1..4).toList()
val seq = numeros.asSequence()
.filter { println(" filter $it"); it % 2 == 0 }
.map { println(" map $it"); it * 10 }
println("antes de consumir: nada foi impresso acima")
println("primeiro: ${seq.first()}")
}
A saída real é:
antes de consumir: nada foi impresso acima
filter 1
filter 2
map 2
primeiro: 20
A linha antes de consumir aparece antes de qualquer filter, e isso prova que nada rodou até a terminal. Depois, o elemento 1 é descartado pelo filtro, o 2 passa pelo filtro e pelo map, e first encerra a cadeia. Os elementos 3 e 4 nunca são sequer examinados. A parada antecipada é o ponto em que a Sequence se distingue da List: o terminal first() encerra a cadeia assim que tem o resultado, e a figura da seção 2 mostra o mesmo caminho em forma de fluxo.
Note que a saída da versão List tinha filter 4 e a da Sequence não tem. Nenhuma das duas é mais correta: a List sempre processa tudo, e a Sequence processa o necessário para a terminal. Se o seu código precisa de todos os elementos de qualquer forma, a diferença desaparece, e o custo extra da Sequence passa a ser uma despesa sem benefício.
4. fontes infinitas e o papel do take
A avaliação preguiçosa permite algo que a List não permite: trabalhar com uma fonte sem fim. Uma List precisa existir inteira na memória, então uma lista infinita é impossível. Uma Sequence só produz valores quando pedidos, então uma sequência sem fim funciona, desde que alguma terminal ou função como take encerre a cadeia.
fun main() {
val quadrados = generateSequence(1) { it + 1 }
.map { it * it }
.take(4)
.toList()
println("quadrados: $quadrados")
}
A saída é quadrados: [1, 4, 9, 16]. O generateSequence produz números sem parar. O map eleva cada um ao quadrado apenas quando é pedido. O take(4) conta os elementos e, ao chegar ao quarto, avisa a cadeia de que não precisa de mais nada. A regra prática é que toda fonte infinita precisa de um limite na cadeia, e esquecê-lo faz o programa rodar para sempre ou até estourar algum recurso.
A figura mostra o fluxo da cadeia de cima para a esquerda até o resultado. A fonte à esquerda não tem fim, e o rótulo 1, 4, 9, 16 nas setas mostra que só quatro valores atravessam a cadeia. O take(4) é o componente que conta, e a seta até o toList() marca o momento em que a coleção final é materializada.
5. reexecução: a Sequence não guarda resultado
Uma Sequence é uma receita e não um resultado. Cada terminal executa a cadeia inteira de novo, incluindo efeitos colaterais como println, leitura de banco ou chamada HTTP. Veja o exemplo:
fun main() {
val reuso = listOf(1, 2).asSequence().map { println(" calcula $it"); it }
println("primeira terminal: ${reuso.toList()}")
println("segunda terminal: ${reuso.toList()}")
}
A saída é esta:
calcula 1
calcula 2
primeira terminal: [1, 2]
calcula 1
calcula 2
segunda terminal: [1, 2]
O calcula aparece duas vezes, uma para cada terminal. Se a etapa fosse uma consulta ao banco, o banco seria consultado duas vezes sem que o código deixasse isso claro. Quando o resultado precisa ser usado mais de uma vez, a solução é transformar a sequência em uma List uma única vez, com toList(), e reaproveitar essa lista.
6. useLines: ler um arquivo sem carregá-lo inteiro
O caso típico de Sequence em backend é processar um arquivo grande, como um CSV de exportação com milhões de linhas. Ler tudo com readLines cria uma List com todas as linhas na memória. Já useLines abre o arquivo, entrega uma Sequence de linhas para um bloco e fecha o arquivo ao sair do bloco. O processamento acontece dentro do bloco, linha por linha.
import java.io.File
fun main() {
File("linhas.txt").writeText((1..5).joinToString("\n") { "linha $it" })
val total = File("linhas.txt").useLines { linhas ->
linhas.filter { it.endsWith("2") || it.endsWith("4") }.count()
}
println("linhas que terminam em 2 ou 4: $total")
}
A saída é linhas que terminam em 2 ou 4: 2. O count é terminal e roda dentro do bloco, enquanto o arquivo ainda está aberto. Esse é o único lugar seguro para consumir a sequência de useLines.
A figura abaixo mostra o ciclo de vida do stream, e o erro que a aula quer evitar.
A figura mostra que o arquivo é aberto na entrada do bloco e fechado na saída. O caminho verde é o uso correto, com count dentro do bloco. O caminho vermelho é o erro: a Sequence é devolvida para fora, o arquivo já está fechado, e a chamada a count depois do bloco falha.
O erro acontece de fato. Se você escrever val vazado = File("linhas.txt").useLines { it } e depois chamar vazado.count(), a execução mostra:
falhou: IOException: Stream closed
A mensagem diz exatamente o que aconteceu: o leitor por trás da sequência foi fechado quando o bloco terminou. A regra é que a sequência de useLines não sai do bloco. Se o método precisa devolver dados, ele deve devolver o resultado já consumido, uma List ou um valor agregado.
7. quando Sequence compensa e quando não compensa
A pergunta de desempenho desta aula não tem resposta numérica aqui, porque nenhuma medição foi feita nesta sessão, e um número sem medição seria inventado. O que dá para afirmar é o mecanismo. Uma Sequence cria objetos intermediários para cada etapa, e cada elemento atravessa uma cadeia de chamadas. A List cria uma coleção por etapa, mas percorre cada uma em um laço simples. Qual dos dois custa mais depende do tamanho da coleção, do número de etapas, de quantas vezes a cadeia é executada e do que cada lambda faz. Em coleções pequenas, o custo de montar a cadeia pode superar o ganho. Em arquivos grandes, a diferença entre carregar tudo e processar uma linha de cada vez costuma ser a diferença entre caber na memória e não caber.
Algumas operações quebram o benefício da sequência. sorted precisa ver todos os elementos antes de devolver o primeiro, então a sequência acaba armazenando tudo em um buffer. Para medir a diferença num caso real, use JMH e um dado representativo, como a aula de performance da fase 3 descreve.
8. erros comuns e boas práticas
O primeiro erro é achar que List é imutável e expô-la como se fosse uma cópia. A boa prática é copiar com toList() na fronteira da classe quando o estado interno não pode vazar.
O segundo erro é usar asSequence() em qualquer lugar por achar que é mais rápido. Em coleções pequenas, uma List é simples e costuma bastar. A sequência compensa quando há muitas etapas, uma fonte grande ou uma fonte infinita.
O terceiro erro é consumir a mesma sequência em duas terminais, como na seção 5, e não perceber que um efeito colateral rodou duas vezes. A boa prática é tratar a sequência como uma receita de uso único e materializar o resultado quando precisar reaproveitá-lo.
O quarto erro é devolver a sequência de useLines para fora do bloco, como mostrou a seção 6. O sintoma é uma exceção de stream fechado longe da origem do problema, o que torna o diagnóstico mais difícil.
Pergunta de entrevistaQuando Sequence compensa e quando não compensa?
List executa cada etapa sobre a coleção inteira e cria listas intermediárias. Sequence passa elemento por elemento, e só executa quando há uma operação terminal, como toList ou first. Ela compensa com muitos elementos, com filtros que param cedo, ou com fontes infinitas. Para coleções pequenas, o custo de montar a sequência pode ser maior que o ganho. Também lembro que uma Sequence não guarda resultado: cada operação terminal reexecuta o pipeline.
9. exercícios
- Escreva uma função que recebe um
Filee devolve a contagem de linhas que contêm a palavraerro, usandouseLines. Depois troque porreadLinese compare a forma do código. Não compare tempos sem medir. - Crie uma
SequencecomgenerateSequenceque produz os números de Fibonacci, e usetakepara obter os dez primeiros. Explique por que a versão comListnão funcionaria sem um limite. - Escreva uma cadeia com
mapque imprime cada elemento, e chame duas terminais na mesma sequência. Conte quantas vezes oprintlnaparece e explique por quê.
10. glossário
Somente leitura: tipo que não expõe métodos de alteração, sem garantir que o objeto por trás não muda.
Imutável: objeto que não muda depois de criado. Diferente de somente leitura.
Avaliação preguiçosa: estratégia em que a computação só acontece quando o resultado é pedido.
Terminal: operação que consome a sequência e produz um resultado, como toList, first ou count.
Função intermediária: operação que transforma a sequência sem consumi-la, como filter e map.
Stream: fluxo de dados lido de uma fonte, como um arquivo, que pode ser consumido uma vez.
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. Nenhuma medição de desempenho foi feita nesta aula. A documentação oficial de Sequence do Kotlin não foi consultada nesta sessão, e a descrição do mecanismo se baseia nos resultados de execução apresentados.
relacionados
- Trilha de Kotlin
- Curriculo de um Desenvolvedor Kotlin
- Variância e genéricos reified
- Funções inline e custo de lambdas
- Tratamento funcional de erros
- Anatomia de Slices, Arrays e Maps