Java Collectors

No fim de um stream, os elementos precisam ir para algum lugar: uma lista, um conjunto, um mapa, um texto, um relatório agrupado. A operação terminal collect faz isso, e a classe Collectors ("coletores") traz as receitas prontas: toList, toMap, joining, groupingBy… Com elas, a Ana responde em poucas linhas perguntas como "quantos alunos há em cada cidade?" e "qual a média de cada cidade?".

Você vai aprender
  • A diferença entre toList() (Java 16) e Collectors.toList().
  • toSet e toCollection (para escolher a coleção).
  • toMap, e o erro IllegalStateException: Duplicate key.
  • joining: juntar textos com separador, prefixo e sufixo.
  • groupingBy: agrupar (alunos por cidade), com counting, averagingDouble e mapping.
  • partitioningBy: dividir em dois grupos (aprovados e reprovados).

A ideia: a última estação da esteira

Voltando à linha de montagem da aula de Streams: as estações filtram e transformam; no fim, alguém precisa embalar o que chegou. Pode ser numa caixa comum (uma lista), numa caixa sem repetidos (um conjunto), em caixas separadas por etiqueta (um mapa agrupado) ou num relatório de uma linha (um texto). Cada coletor é um jeito de embalar.

stream ... .collect(Collectors.ALGUMA_RECEITA(...))

Todos os exemplos usam import java.util.stream.Collectors;. Os alunos da Ana agora têm cidade:

record Aluno(String nome, String cidade, double nota) {
    public String toString() {
        return nome;          // só o nome, para as saídas ficarem curtas
    }
}

Para listas: toList() × Collectors.toList()

Existem duas formas de terminar um stream numa lista, e elas não são iguais:

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

public class Main {
    public static void main(String[] args) {
        List<String> nomes = List.of("Carla", "Ana", "Bruno");

        List<String> a = nomes.stream().sorted().collect(Collectors.toList());
        a.add("Duda");
        System.out.println("Collectors.toList(): " + a + " " + a.getClass().getSimpleName());

        List<String> b = nomes.stream().sorted().toList();
        System.out.println("toList(): " + b);
        b.add("Duda");
    }
}
$ javac Main.java
$ java Main
Collectors.toList(): [Ana, Bruno, Carla, Duda] ArrayList      « mutável: o add funcionou
toList(): [Ana, Bruno, Carla]
Exception in thread "main" java.lang.UnsupportedOperationException
	at java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:159)
	at java.base/java.util.ImmutableCollections$AbstractImmutableCollection.add(ImmutableCollections.java:164)
	at Main.main(Main.java:15)
FormaDesdeA lista pode mudar?Quando usar
.toList()Java 16+Não (imutável, como List.of)O padrão hoje: mais curto, e ninguém altera o resultado por engano.
.collect(Collectors.toList())Java 8Na prática sim (hoje é uma ArrayList, mas a documentação não garante)Código para Java 8 a 15, ou quando você vai alterar a lista depois.
.collect(Collectors.toCollection(ArrayList::new))Java 8Sim, garantidoQuando a lista precisa ser mutável.
.collect(Collectors.toUnmodifiableList())Java 10NãoO equivalente ao toList() antes do Java 16.

Para conjuntos: toSet e toCollection

import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;

public class Main {
    public static void main(String[] args) {
        List<String> cidades = List.of("Recife", "Natal", "Recife", "Olinda", "Natal", "Recife");

        Set<String> conjunto = cidades.stream().collect(Collectors.toSet());
        System.out.println("toSet(): " + conjunto);

        TreeSet<String> ordenado = cidades.stream().collect(Collectors.toCollection(TreeSet::new));
        System.out.println("toCollection(TreeSet::new): " + ordenado);
    }
}
$ javac Main.java
$ java Main
toSet(): [Recife, Natal, Olinda]                  « sem repetidos, ordem de HashSet
toCollection(TreeSet::new): [Natal, Olinda, Recife]   « sem repetidos e em ordem

toCollection recebe um Supplier que fabrica a coleção vazia; a referência ao construtor TreeSet::new é o jeito mais curto de escrevê-lo. Serve para qualquer coleção: LinkedHashSet::new, ArrayDeque::new…


Para mapas: toMap

toMap monta um mapa. Você diz duas funções: uma que tira a chave de cada elemento e outra que tira o valor.

import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;

record Aluno(String nome, String cidade, double nota) { }

public class Main {
    public static void main(String[] args) {
        List<Aluno> turma = List.of(
            new Aluno("Ana", "Recife", 9.5), new Aluno("Bruno", "Natal", 7.0),
            new Aluno("Carla", "Recife", 8.0), new Aluno("Duda", "Olinda", 6.0));

        Map<String, Double> notas = turma.stream()
                .collect(Collectors.toMap(Aluno::nome, Aluno::nota));
        System.out.println("toMap(nome, nota): " + notas);
        System.out.println("nota da Carla: " + notas.get("Carla"));

        Map<String, Double> ordenado = turma.stream()
                .collect(Collectors.toMap(Aluno::nome, Aluno::nota, (x, y) -> x, TreeMap::new));
        System.out.println("em TreeMap: " + ordenado);
    }
}
$ javac Main.java
$ java Main
toMap(nome, nota): {Bruno=7.0, Carla=8.0, Ana=9.5, Duda=6.0}     « um HashMap: ordem não garantida
nota da Carla: 8.0
em TreeMap: {Ana=9.5, Bruno=7.0, Carla=8.0, Duda=6.0}
Collectors.toMap(Aluno::nome, Aluno::nota)
Chave = o nome de cada aluno; valor = a nota.
(x, y) -> x
Terceiro argumento: o que fazer se duas chaves forem iguais (aqui, "fique com o primeiro"). Veja o erro logo abaixo.
TreeMap::new
Quarto argumento: qual mapa criar. Para ter esse, é obrigatório passar o terceiro também.

A armadilha: chave repetida

A Ana tenta montar um mapa "cidade → aluno". Mas duas alunas moram em Recife:

Este código tem um erro de propósito.

        Map<String, String> porCidade = turma.stream()
                .collect(Collectors.toMap(Aluno::cidade, Aluno::nome));
        System.out.println(porCidade);
$ javac Main.java
$ java Main
Exception in thread "main" java.lang.IllegalStateException: Duplicate key Recife (attempted merging values Ana and Carla)
	at java.base/java.util.stream.Collectors.duplicateKeyException(Collectors.java:135)
	at java.base/java.util.stream.Collectors.lambda$uniqKeysMapAccumulator$0(Collectors.java:182)
	at java.base/java.util.stream.ReduceOps$3ReducingSink.accept(ReduceOps.java:169)
	at java.base/java.util.AbstractList$RandomAccessSpliterator.forEachRemaining(AbstractList.java:722)
	at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:570)
	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:560)
	at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:921)
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:265)
	at java.base/java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:723)
	at Main.main(Main.java:14)

"Estado ilegal: chave duplicada Recife (tentou juntar os valores Ana e Carla)". Diferente do put de um mapa comum, que substituiria em silêncio, o toMap recusa chaves repetidas, a não ser que você diga como juntá-las. A pilha é longa porque o stream passa por várias peças internas; a linha que importa é a última, Main.java:14.

A solução é o terceiro argumento, a função de junção (um BinaryOperator que recebe o valor antigo e o novo):

        Map<String, String> porCidade = turma.stream()
                .collect(Collectors.toMap(Aluno::cidade, Aluno::nome, (a, b) -> a + ", " + b, TreeMap::new));
        System.out.println(porCidade);
$ java Main
{Natal=Bruno, Olinda=Duda, Recife=Ana, Carla}

Funciona, mas juntar textos com vírgula é um remendo. Quando uma chave pode ter vários valores, o coletor certo é o groupingBy, logo abaixo.


Para textos: joining

joining ("juntando") concatena um stream de Strings, com separador opcional e, se quiser, um prefixo e um sufixo:

import java.util.List;
import java.util.stream.Collectors;

public class Main {
    public static void main(String[] args) {
        List<String> nomes = List.of("Ana", "Bruno", "Carla");
        System.out.println(nomes.stream().collect(Collectors.joining()));
        System.out.println(nomes.stream().collect(Collectors.joining(", ")));
        System.out.println(nomes.stream().collect(Collectors.joining(", ", "Presentes: [", "]")));
        System.out.println(nomes.stream().map(String::toUpperCase).collect(Collectors.joining(" | ")));
        System.out.println(String.join(" e ", nomes));
    }
}
$ javac Main.java
$ java Main
AnaBrunoCarla
Ana, Bruno, Carla                 « separador só ENTRE os elementos
Presentes: [Ana, Bruno, Carla]    « separador, prefixo, sufixo
ANA | BRUNO | CARLA
Ana e Bruno e Carla

Repare que o separador não sobra no fim (o reduce da aula de Streams deixava um espaço). A última linha mostra String.join, que faz o mesmo direto com uma lista, sem stream; use-o quando não houver filter nem map no caminho. O joining só aceita Strings: para números, faça antes .map(String::valueOf).

Missão: use Collectors.joining com separador "; ", prefixo "Lista: " e sufixo ".". A saída deve ser Lista: Ana; Bruno; Carla.

import java.util.List;
import java.util.stream.Collectors;

public class Main {
    public static void main(String[] args) {
        List<String> nomes = List.of("Ana", "Bruno", "Carla");
        String lista = nomes.stream().collect(Collectors.joining());
        System.out.println(lista);
    }
}
import java.util.List;
import java.util.stream.Collectors;

public class Main {
    public static void main(String[] args) {
        List<String> nomes = List.of("Ana", "Bruno", "Carla");
        String lista = nomes.stream().collect(Collectors.joining("; ", "Lista: ", "."));
        System.out.println(lista);
    }
}

Agrupar: groupingBy

A pergunta da Ana: "quem mora em cada cidade?". groupingBy ("agrupando por") recebe uma função que diz a etiqueta de cada elemento, e devolve um mapa: etiqueta → lista dos elementos com aquela etiqueta. Os trechos abaixo são partes de um mesmo programa, com seis alunos:

import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;

record Aluno(String nome, String cidade, double nota) {
    public String toString() {
        return nome;
    }
}

public class Main {
    public static void main(String[] args) {
        List<Aluno> turma = List.of(
            new Aluno("Ana", "Recife", 9.5), new Aluno("Bruno", "Natal", 7.0),
            new Aluno("Carla", "Recife", 8.0), new Aluno("Duda", "Olinda", 6.0),
            new Aluno("Edu", "Natal", 4.5), new Aluno("Fabi", "Recife", 7.5));

        Map<String, List<Aluno>> grupos = turma.stream()
                .collect(Collectors.groupingBy(Aluno::cidade));
        System.out.println("groupingBy: " + grupos);
$ java Main
groupingBy: {Recife=[Ana, Carla, Fabi], Natal=[Bruno, Edu], Olinda=[Duda]}

Uma linha de código fez o que exigiria um mapa, um laço, um if (!mapa.containsKey(…)) e um add. O resultado é um Map<String, List<Aluno>>: cada cidade aponta para a lista dos seus alunos, na ordem original. O mapa é um HashMap, então a ordem das cidades não é garantida.

O segundo coletor: o que fazer com cada grupo

O groupingBy aceita mais dois argumentos: qual mapa criar (por exemplo, TreeMap::new, para as cidades saírem em ordem) e um coletor para cada grupo (em vez de uma lista de alunos, uma contagem, uma média, uma lista de nomes…):

        Map<String, List<String>> nomes = turma.stream()
                .collect(Collectors.groupingBy(Aluno::cidade, TreeMap::new,
                        Collectors.mapping(Aluno::nome, Collectors.toList())));
        System.out.println("nomes por cidade: " + nomes);

        Map<String, Long> quantos = turma.stream()
                .collect(Collectors.groupingBy(Aluno::cidade, TreeMap::new, Collectors.counting()));
        System.out.println("counting: " + quantos);

        Map<String, Double> medias = turma.stream()
                .collect(Collectors.groupingBy(Aluno::cidade, TreeMap::new, Collectors.averagingDouble(Aluno::nota)));
        System.out.println("averagingDouble: " + medias);
$ java Main
nomes por cidade: {Natal=[Bruno, Edu], Olinda=[Duda], Recife=[Ana, Carla, Fabi]}
counting: {Natal=2, Olinda=1, Recife=3}
averagingDouble: {Natal=5.75, Olinda=6.0, Recife=8.333333333333334}
groupingBy(Aluno::cidade, TreeMap::new, …)
Agrupa por cidade num TreeMap: cidades em ordem alfabética.
Collectors.mapping(Aluno::nome, Collectors.toList())
"Em cada grupo, transforme cada aluno no nome e junte numa lista." É um map dentro do grupo.
Collectors.counting()
"Em cada grupo, conte." O resultado é Long.
Collectors.averagingDouble(Aluno::nota)
"Em cada grupo, a média das notas." Recife: (9.5 + 8.0 + 7.5) / 3 = 8.333…, com as casas do double; para mostrar bonito, use printf.
Coletor do grupoCada grupo vira
(nenhum)uma List dos elementos
counting()quantos são (Long)
averagingDouble(f) / averagingInt(f)a média de f
summingDouble(f) / summingInt(f)a soma de f
mapping(f, toList())a lista de f de cada um
mapping(f, joining(", "))um texto com f de cada um
toSet()um conjunto dos elementos
maxBy(comp) / minBy(comp)o maior / menor (num Optional)

Dividir em dois: partitioningBy

Quando a etiqueta é só "sim" ou "não", use partitioningBy ("particionando"). Ele recebe um Predicate e devolve um mapa com exatamente duas chaves: false e true.

        Map<Boolean, List<String>> aprovados = turma.stream()
                .collect(Collectors.partitioningBy(a -> a.nota() >= 7,
                        Collectors.mapping(Aluno::nome, Collectors.toList())));
        System.out.println("partitioningBy: " + aprovados);
        System.out.println("aprovados: " + aprovados.get(true));
$ java Main
partitioningBy: {false=[Duda, Edu], true=[Ana, Bruno, Carla, Fabi]}
aprovados: [Ana, Bruno, Carla, Fabi]

As duas chaves existem sempre, mesmo que um dos grupos fique vazio (com groupingBy, uma etiqueta sem ninguém nem aparece). O segundo argumento é opcional e funciona como no groupingBy.


Contas sem agrupar

Os mesmos coletores de conta funcionam sozinhos, sobre o stream inteiro:

        System.out.println(turma.stream().collect(Collectors.counting()));
        System.out.println(turma.stream().collect(Collectors.averagingDouble(Aluno::nota)));
        System.out.println(turma.stream().collect(Collectors.summingDouble(Aluno::nota)));
        System.out.println(turma.stream().collect(Collectors.summarizingDouble(Aluno::nota)));

Com os quatro primeiros alunos (Ana, Bruno, Carla, Duda):

$ java Main
4
7.625
30.5
DoubleSummaryStatistics{count=4, sum=30,500000, min=6,000000, average=7,625000, max=9,500000}

Sem agrupar, prefira os atalhos do stream: count(), mapToDouble(Aluno::nota).average(), .sum() (veja Streams). Repare de novo nas vírgulas do summarizingDouble: o texto segue o idioma do computador (pt_BR).


Quando dá errado

IllegalStateException: Duplicate key … (attempted merging values …)

O que aconteceu: toMap encontrou duas vezes a mesma chave. A mensagem diz qual chave e quais valores colidiram.

Como consertar: se cada chave tem vários valores, use groupingBy. Se deve ficar só um, passe a função de junção: toMap(k, v, (antigo, novo) -> antigo) (ou novo, ou Math::max…).

UnsupportedOperationException ao alterar o resultado

O que aconteceu: toList(), toUnmodifiableList() e afins devolvem coleções imutáveis.

Como consertar: Collectors.toCollection(ArrayList::new), ou copie: new ArrayList<>(resultado).

As chaves do mapa saíram fora de ordem

O que aconteceu: toMap e groupingBy criam um HashMap por padrão.

Como consertar: passe a fábrica do mapa: TreeMap::new (ordem das chaves) ou LinkedHashMap::new (ordem de chegada).


Referência

ColetorResultado
toList() / toUnmodifiableList()List (mutável na prática / imutável)
toSet()Set
toCollection(TreeSet::new)A coleção que você escolher
toMap(chave, valor)Map; chave repetida: IllegalStateException
toMap(chave, valor, junção, Mapa::new)Map com regra de junção e tipo escolhido
joining(sep, prefixo, sufixo)String
groupingBy(etiqueta)Map<etiqueta, List>
groupingBy(etiqueta, Mapa::new, coletor)Map<etiqueta, resultado do coletor>
partitioningBy(predicado)Map<Boolean, List> com false e true
counting(), averagingDouble(f), summingInt(f), summarizingDouble(f)Contagem, média, soma, estatísticas
mapping(f, coletor)Transforma antes de coletar (dentro de grupos)

Exercícios

1. Juntar com vírgula

Complete para gerar "Ana, Bruno, Carla":

String s = nomes.stream().collect(Collectors.);

2. Agrupar

Complete para agrupar os alunos por cidade:

Map<String, List<Aluno>> g = turma.stream().collect(Collectors.);

3. Contar por grupo

Complete o coletor que conta os elementos de cada grupo:

Collectors.groupingBy(Aluno::cidade, Collectors.)

4. O que acontece com toMap(Aluno::cidade, Aluno::nome) se dois alunos moram na mesma cidade?

5. Quais chaves tem o mapa devolvido por partitioningBy?

6. stream.toList() devolve uma lista…

Missão final: conte quantos alunos há em cada cidade com groupingBy + counting, num TreeMap (para as cidades saírem em ordem). A saída deve ser {Natal=2, Olinda=1, Recife=3}.

import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;

record Aluno(String nome, String cidade, double nota) { }

public class Main {
    public static void main(String[] args) {
        List<Aluno> turma = List.of(
            new Aluno("Ana", "Recife", 9.5), new Aluno("Bruno", "Natal", 7.0),
            new Aluno("Carla", "Recife", 8.0), new Aluno("Duda", "Olinda", 6.0),
            new Aluno("Edu", "Natal", 4.5), new Aluno("Fabi", "Recife", 7.5));

        Map<String, Long> porCidade = null;
        System.out.println(porCidade);
    }
}
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;

record Aluno(String nome, String cidade, double nota) { }

public class Main {
    public static void main(String[] args) {
        List<Aluno> turma = List.of(
            new Aluno("Ana", "Recife", 9.5), new Aluno("Bruno", "Natal", 7.0),
            new Aluno("Carla", "Recife", 8.0), new Aluno("Duda", "Olinda", 6.0),
            new Aluno("Edu", "Natal", 4.5), new Aluno("Fabi", "Recife", 7.5));

        Map<String, Long> porCidade = turma.stream()
                .collect(Collectors.groupingBy(Aluno::cidade, TreeMap::new, Collectors.counting()));
        System.out.println(porCidade);
    }
}

Resumo

  • collect(Collectors.…) é a operação terminal que "embala" o stream.
  • toList() (Java 16+) é imutável; Collectors.toList() é a forma antiga; toCollection(X::new) escolhe a coleção.
  • toMap(chave, valor) recusa chaves repetidas (IllegalStateException: Duplicate key) a menos que receba uma função de junção.
  • joining(sep, prefixo, sufixo) junta textos sem sobrar separador.
  • groupingBy(etiqueta, Mapa::new, coletor) agrupa; com counting, averagingDouble, mapping… resume cada grupo.
  • partitioningBy(predicado) divide em false/true.

Próxima aula: Optional, a caixinha que pode estar vazia e que você já viu no findFirst e no max.