1. Introdução
Na vida real, quase sempre é preciso agrupar dados. Por exemplo, separar estudantes por cursos, dividir produtos por categorias, separar maiores de idade de crianças e assim por diante.
Sem o Stream API, essas tarefas eram feitas manualmente: iterar a coleção, verificar o critério e adicionar na lista correta no Map. Eis um código típico à moda antiga:
Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee employee : employees) {
String dept = employee.getDepartment();
byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(employee);
}
Funciona, mas parece que você está separando macarrão em potes à mão. E se precisar agrupar por vários critérios? Ou ainda somar valores por grupos? O código cresce e fica ilegível.
O Stream API e coletores especiais (groupingBy, partitioningBy) permitem fazer isso em uma ou duas linhas — e ainda parecer um verdadeiro mago do Java.
2. Coletor groupingBy: agrupamento por critério
Ideia principal
groupingBy é um coletor que transforma um fluxo de elementos em um Map, no qual a chave é o resultado da função-classificadora e o valor é a lista de elementos correspondentes a esse critério.
Assinatura:
Collectors.groupingBy(Function<T, K>)
- T — tipo do elemento no fluxo,
- K — tipo da chave (grupo) retornada pela função.
Exemplo simples: agrupar funcionários por departamento
Suponha que temos a classe:
public class Employee {
private final String name;
private final String department;
// ... construtor e getters
public Employee(String name, String department) {
this.name = name;
this.department = department;
}
public String getName() { return name; }
public String getDepartment() { return department; }
}
E uma coleção de funcionários:
List<Employee> employees = List.of(
new Employee("Alisa", "IT"),
new Employee("Bob", "HR"),
new Employee("Klara", "IT"),
new Employee("Denis", "Finance"),
new Employee("Eva", "HR")
);
Agrupamos por departamento:
Map<String, List<Employee>> byDepartment = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment));
O que obtivemos?
- Chave: nome do departamento (String).
- Valor: lista de funcionários desse departamento (List<Employee>).
Imprimir o resultado:
byDepartment.forEach((dept, emps) -> {
System.out.println(dept + ": " +
emps.stream().map(Employee::getName).toList());
});
Saída:
IT: [Alisa, Klara]
HR: [Bob, Eva]
Finance: [Denis]
Como isso funciona por baixo do capô?
Primeiro, para cada elemento do fluxo, calcula-se a chave (por exemplo, getDepartment()). Se essa chave já existe no Map, o elemento é adicionado à lista correspondente. Se a chave não existe, é criada uma nova lista.
Analogia
Imagine que você separa cartas por pastas: para cada carta você verifica se já existe uma pasta com o nome necessário; caso não exista, cria uma nova e coloca a carta nela.
3. Coletor partitioningBy: divisão em dois grupos
Às vezes, não é tanto “agrupar por valor”, e sim dividir a coleção em duas partes por um critério lógico (true/false). Por exemplo, funcionários com salário acima e abaixo de um certo limite; estudantes — em “aprovado/reprovado”.
Para isso, existe o coletor especial — partitioningBy.
Assinatura:
Collectors.partitioningBy(Predicate<T>)
Predicate — função que retorna um valor booleano.
Exemplo: dividir funcionários por nível salarial
Suponha que temos:
public class Employee {
private final String name;
private final int salary;
// ... construtor e getters
public Employee(String name, int salary) {
this.name = name;
this.salary = salary;
}
public String getName() { return name; }
public int getSalary() { return salary; }
}
E a lista:
List<Employee> employees = List.of(
new Employee("Alisa", 120_000),
new Employee("Bob", 80_000),
new Employee("Klara", 150_000),
new Employee("Denis", 95_000)
);
Vamos dividir em “ricos” e “modestos”:
Map<Boolean, List<Employee>> partitioned = employees.stream()
.collect(Collectors.partitioningBy(e -> e.getSalary() > 100_000));
- true — funcionários com salário maior que 100_000.
- false — os demais.
Imprimir:
System.out.println("Ricos: " +
partitioned.get(true).stream().map(Employee::getName).toList());
System.out.println("Modestos: " +
partitioned.get(false).stream().map(Employee::getName).toList());
Resultado:
Ricos: [Alisa, Klara]
Modestos: [Bob, Denis]
Quando usar partitioningBy e quando groupingBy?
- Se houver mais de dois grupos — use groupingBy.
- Se forem apenas dois grupos por critério booleano — use partitioningBy: é mais rápido e mais claro.
4. Agrupamentos aninhados: agrupar por vários critérios
Às vezes queremos agrupar não apenas por um critério, mas “aninhar” um agrupamento dentro de outro. Por exemplo, agrupar funcionários primeiro por departamento e, dentro do departamento, por cargo.
Exemplo:
Suponha que na nossa classe Employee exista também o campo position:
public class Employee {
private final String name;
private final String department;
private final String position;
// ... construtor e getters
}
Agrupamento aninhado:
Map<String, Map<String, List<Employee>>> byDeptAndPosition = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment,
Collectors.groupingBy(Employee::getPosition)));
- Chave externa — departamento.
- Chave interna — cargo.
- Valor — lista de funcionários.
Como acessar os funcionários do departamento IT com o cargo “Developer”?
List<Employee> itDevs = byDeptAndPosition
.getOrDefault("IT", Map.of())
.getOrDefault("Developer", List.of());
Esquema visual
Map<Department, Map<Position, List<Employee>>>
└─ "IT"
├─ "Developer" -> [Alisa, Klara]
└─ "QA" -> [Boris]
└─ "HR"
└─ "Recruiter" -> [Denis]
5. Exemplos práticos de agrupamento
Exemplo 1: Agrupar strings por tamanho
List<String> words = List.of("cat", "dog", "elephant", "bee", "ant", "dolphin");
Map<Integer, List<String>> byLength = words.stream()
.collect(Collectors.groupingBy(String::length));
byLength.forEach((len, ws) -> System.out.println(len + ": " + ws));
Saída:
3: [cat, dog, bee, ant]
8: [elephant, dolphin]
Exemplo 2: Agrupar números por paridade
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
Map<String, List<Integer>> byParity = numbers.stream()
.collect(Collectors.groupingBy(n -> n % 2 == 0 ? "even" : "odd"));
System.out.println(byParity);
// {odd=[1, 3, 5], even=[2, 4, 6]}
Exemplo 3: Usando partitioningBy para strings
Vamos separar as strings que começam com "A" e as demais:
List<String> names = List.of("Alice", "Bob", "Anna", "Charlie");
Map<Boolean, List<String>> byA = names.stream()
.collect(Collectors.partitioningBy(s -> s.startsWith("A")));
System.out.println("A-names: " + byA.get(true)); // [Alice, Anna]
System.out.println("Other: " + byA.get(false)); // [Bob, Charlie]
5. Nuances úteis
Como usar o resultado do agrupamento
Com frequência, depois do agrupamento queremos não apenas obter um Map, mas processá-lo de alguma forma:
- Iterar por todos os grupos e exibir informações.
- Encontrar o grupo com a maior quantidade de elementos.
- Para cada grupo, calcular, por exemplo, soma ou média — mais sobre isso na próxima aula.
Exemplo: exibir a quantidade de funcionários em cada departamento
byDepartment.forEach((dept, emps) ->
System.out.println(dept + ": " + emps.size() + " funcionários"));
Comparação com a implementação manual
Para fixar: veja como ficaria o agrupamento por departamento “à moda antiga”:
Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee e : employees) {
String dept = e.getDepartment();
byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(e);
}
E como fica com o Stream API:
Map<String, List<Employee>> byDepartment = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment));
Conclusão: menos código, menos erros, maior legibilidade.
Dicas
Se você precisa agrupar não em um List, mas, por exemplo, em um Set — use como segundo parâmetro Collectors.toSet():
Collectors.groupingBy(Employee::getDepartment, Collectors.toSet())
É possível agregar diretamente: por exemplo, obter a quantidade de funcionários em cada departamento:
Collectors.groupingBy(Employee::getDepartment, Collectors.counting())
Mas isso fica para a próxima aula!
Após partitioningBy sempre haverá duas chaves: true e false. Mesmo se um dos grupos estiver vazio.
Após agrupamentos aninhados, a estrutura torna-se uma “árvore”: Map dentro de Map e assim por diante.
6. Erros comuns em agrupamento e partitioning
Erro nº 1: Tipo de resultado incorreto. Iniciantes muitas vezes esperam que o resultado de groupingBy seja apenas um List<T>, e não um Map<K, List<T>>. Como resultado, tentam chamar métodos de lista e recebem erro de compilação. Lembre-se: agrupamento sempre resulta em Map!
Erro nº 2: NullPointerException ao acessar um grupo inexistente. Se você tentar obter a lista por uma chave que não existe — receberá null. Use getOrDefault(key, List.of()) ou verifique a existência da chave com containsKey.
Erro nº 3: Uso de partitioningBy para tarefas com vários grupos. partitioningBy — apenas para dois grupos (true/false). Se houver mais grupos — use groupingBy.
Erro nº 4: Modificar coleções dentro do stream. Não tente modificar as coleções de origem ou o Map dentro do stream — isso levará a erros inesperados. Faça todo o processamento via Stream API e coletores.
Erro nº 5: Estrutura não óbvia de agrupamentos aninhados. Após um groupingBy aninhado, o resultado é um Map dentro de Map. Não se esqueça de extrair os dados corretamente (por exemplo, com getOrDefault), caso contrário você terá um ClassCastException.
GO TO FULL VERSION