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?".
- A diferença entre
toList()(Java 16) eCollectors.toList(). toSetetoCollection(para escolher a coleção).toMap, e o erroIllegalStateException: Duplicate key.joining: juntar textos com separador, prefixo e sufixo.groupingBy: agrupar (alunos por cidade), comcounting,averagingDoubleemapping.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)
| Forma | Desde | A 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 8 | Na 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 8 | Sim, garantido | Quando a lista precisa ser mutável. |
.collect(Collectors.toUnmodifiableList()) | Java 10 | Não | O 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
mapdentro 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 grupo | Cada 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).
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
| Coletor | Resultado |
|---|---|
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":
2. Agrupar
Complete para agrupar os alunos por cidade:
3. Contar por grupo
Complete o coletor que conta os elementos de cada grupo:
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; comcounting,averagingDouble,mapping… resume cada grupo.partitioningBy(predicado)divide emfalse/true.
Próxima aula: Optional, a caixinha que pode estar vazia e que você já viu no findFirst e no max.