CodeGym /Kursy /JAVA 25 SELF /Metody groupingBy i partitioningBy (Collectors)

Metody groupingBy i partitioningBy (Collectors)

JAVA 25 SELF
Poziom 31, Lekcja 2
Dostępny

1. Wprowadzenie

W prawdziwym życiu niemal zawsze trzeba grupować dane. Na przykład podzielić studentów według roczników, rozbić produkty na kategorie, oddzielić pełnoletnich od dzieci i tak dalej.

Bez Stream API takie zadania rozwiązywało się ręcznie: iteracja po kolekcji, sprawdzenie cechy, dodanie do odpowiedniej listy w Map. Oto typowy „oldskulowy” kod:

Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee employee : employees) {
    String dept = employee.getDepartment();
    byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(employee);
}

Działa, ale wygląda tak, jakbyś ręcznie sortował makaron do słoików. A jeśli trzeba pogrupować według kilku cech? Albo jeszcze policzyć sumy w grupach? Kod rozrasta się i staje się nieczytelny.

Stream API i specjalne kolektory (groupingBy, partitioningBy) pozwalają zrobić to w jednej–dwóch linijkach — i przy tym wyglądać jak prawdziwy czarodziej Javy.

2. Kolektor groupingBy: grupowanie według cechy

Główna idea

groupingBy — to kolektor, który przekształca strumień elementów w Map, gdzie klucz to wynik funkcji wyznaczającej klucz, a wartość — lista elementów odpowiadających temu kluczowi.

Sygnatura:

Collectors.groupingBy(Function<T, K>)
  • T — typ elementu w strumieniu,
  • K — typ klucza (grupy) zwracany przez funkcję.

Prosty przykład: grupowanie pracowników według działu

Załóżmy, że mamy klasę:

public class Employee {
    private final String name;
    private final String department;
    // ... konstruktor i gettery
    public Employee(String name, String department) {
        this.name = name;
        this.department = department;
    }
    public String getName() { return name; }
    public String getDepartment() { return department; }
}

I kolekcję pracowników:

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")
);

Grupujemy według działu:

Map<String, List<Employee>> byDepartment = employees.stream()
    .collect(Collectors.groupingBy(Employee::getDepartment));

Co otrzymaliśmy?

  • Klucz: nazwa działu (String).
  • Wartość: lista pracowników tego działu (List<Employee>).

Wydrukujmy wynik:

byDepartment.forEach((dept, emps) -> {
    System.out.println(dept + ": " +
        emps.stream().map(Employee::getName).toList());
});

Wynik:

IT: [Alisa, Klara]
HR: [Bob, Eva]
Finance: [Denis]

Jak to działa pod maską?

Najpierw dla każdego elementu strumienia obliczany jest klucz (np. getDepartment()). Jeśli taki klucz już istnieje w Map, element zostaje dodany do odpowiedniej listy. Jeśli klucza nie ma — tworzona jest nowa lista.

Analogia

Wyobraź sobie, że sortujesz listy do folderów: dla każdego listu sprawdzasz, czy istnieje już folder z odpowiednią nazwą, jeśli nie — zakładasz nowy i wkładasz tam list.

3. Kolektor partitioningBy: podział na dwie grupy

Czasem nie chodzi o „grupowanie według wartości”, lecz po prostu o podział kolekcji na dwie części według logicznego predykatu (true/false). Na przykład pracowników z pensją powyżej i poniżej pewnego progu, studentów — na „zdał/nie zdał”.

Do tego służy specjalny kolektor — partitioningBy.

Sygnatura:

Collectors.partitioningBy(Predicate<T>)

Predicate — funkcja zwracająca wartość boolean.

Przykład: podział pracowników według poziomu pensji

Załóżmy, że mamy:

public class Employee {
    private final String name;
    private final int salary;
    // ... konstruktor i gettery
    public Employee(String name, int salary) {
        this.name = name;
        this.salary = salary;
    }
    public String getName() { return name; }
    public int getSalary() { return salary; }
}

I listę:

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)
);

Podzielmy na „bogatych” i „skromnych”:

Map<Boolean, List<Employee>> partitioned = employees.stream()
    .collect(Collectors.partitioningBy(e -> e.getSalary() > 100_000));
  • true — pracownicy z pensją większą niż 100_000.
  • false — pozostali.

Wydruk:

System.out.println("Bogaci: " +
    partitioned.get(true).stream().map(Employee::getName).toList());
System.out.println("Skromni: " +
    partitioned.get(false).stream().map(Employee::getName).toList());

Wynik:

Bogaci: [Alisa, Klara]
Skromni: [Bob, Denis]

Kiedy używać partitioningBy, a kiedy groupingBy?

  • Jeśli grup jest więcej niż dwie — użyj groupingBy.
  • Jeśli są tylko dwie grupy według wartości boolean — użyj partitioningBy: jest szybszy i czytelniejszy.

4. Zagnieżdżone grupowania: grupowanie według kilku cech

Czasem chcemy pogrupować nie tylko według jednej cechy, lecz „zagnieździć” jedno grupowanie w drugim. Na przykład najpierw według działu, a w obrębie działu — według stanowiska.

Przykład:

Załóżmy, że nasza klasa Employee ma jeszcze pole position:

public class Employee {
    private final String name;
    private final String department;
    private final String position;
    // ... konstruktor i gettery
}

Zagnieżdżone grupowanie:

Map<String, Map<String, List<Employee>>> byDeptAndPosition = employees.stream()
    .collect(Collectors.groupingBy(Employee::getDepartment,
        Collectors.groupingBy(Employee::getPosition)));
  • Zewnętrzny klucz — dział.
  • Wewnętrzny klucz — stanowisko.
  • Wartość — lista pracowników.

Jak pobrać pracowników z działu IT na stanowisku „Developer”?

List<Employee> itDevs = byDeptAndPosition
    .getOrDefault("IT", Map.of())
    .getOrDefault("Developer", List.of());

Schemat wizualny

Map<Department, Map<Position, List<Employee>>>
└─ "IT"
    ├─ "Developer" -> [Alisa, Klara]
    └─ "QA"        -> [Boris]
└─ "HR"
    └─ "Recruiter" -> [Denis]

5. Praktyczne przykłady grupowania

Przykład 1: Grupowanie łańcuchów według długości

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));

Wynik:

3: [cat, dog, bee, ant]
8: [elephant, dolphin]

Przykład 2: Grupowanie liczb według parzystości

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]}

Przykład 3: Użycie partitioningBy dla łańcuchów

Podzielmy łańcuchy na zaczynające się od "A" i wszystkie pozostałe:

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. Przydatne niuanse

Jak używać wyniku grupowania

Często po grupowaniu chcemy nie tylko otrzymać Map, ale też jakoś ją przetworzyć:

  • Przejrzeć wszystkie grupy i wypisać informacje.
  • Znaleźć grupę z maksymalną liczbą elementów.
  • Dla każdej grupy policzyć np. sumę lub średnią — o tym szerzej w następnym wykładzie.

Przykład: wypisać liczbę pracowników w każdym dziale

byDepartment.forEach((dept, emps) ->
    System.out.println(dept + ": " + emps.size() + " pracowników"));

Porównanie z implementacją ręczną

Dla utrwalenia: tak wyglądałoby grupowanie według działu „po staremu”:

Map<String, List<Employee>> byDepartment = new HashMap<>();
for (Employee e : employees) {
    String dept = e.getDepartment();
    byDepartment.computeIfAbsent(dept, k -> new ArrayList<>()).add(e);
}

A tak z użyciem Stream API:

Map<String, List<Employee>> byDepartment = employees.stream()
    .collect(Collectors.groupingBy(Employee::getDepartment));

Wniosek: mniej kodu, mniej błędów, lepsza czytelność.

Wskazówki

Jeśli trzeba pogrupować nie do List, ale np. do Set — użyj jako drugiego parametru Collectors.toSet():

Collectors.groupingBy(Employee::getDepartment, Collectors.toSet())

Można od razu agregować: na przykład otrzymać liczbę pracowników w każdym dziale:

Collectors.groupingBy(Employee::getDepartment, Collectors.counting())

Ale o tym — w następnym wykładzie!

Po partitioningBy zawsze będą dwa klucze: true i false. Nawet jeśli jedna z grup jest pusta.

Po zagnieżdżonych grupowaniach struktura staje się „drzewem”: Map wewnątrz Map i tak dalej.

6. Typowe błędy przy grupowaniu i partitioningu

Błąd nr 1: Nieprawidłowy typ wyniku. Początkujący często oczekują, że wynik groupingBy to po prostu List<T>, a nie Map<K, List<T>>. W efekcie próbują wywoływać metody listy i dostają błąd kompilacji. Pamiętaj: grupowanie to zawsze Map!

Błąd nr 2: NullPointerException przy odwołaniu do nieistniejącej grupy. Jeśli próbujesz pobrać listę po nieistniejącym kluczu — otrzymasz null. Użyj getOrDefault(key, List.of()) albo sprawdź obecność klucza przez containsKey.

Błąd nr 3: Użycie partitioningBy do zadań z wieloma grupami. partitioningBy — tylko do dwóch grup (true/false). Jeśli grup jest więcej — użyj groupingBy.

Błąd nr 4: Modyfikacja kolekcji wewnątrz strumienia. Nie próbuj modyfikować źródłowych kolekcji ani Map wewnątrz strumienia — to prowadzi do nieoczekiwanych błędów. Całą obróbkę rób przez Stream API i kolektory.

Błąd nr 5: Nieoczywista struktura zagnieżdżonych grupowań. Po zagnieżdżonym groupingBy wynik to Map wewnątrz Map. Pamiętaj, by poprawnie wydobywać dane (np. przez getOrDefault), w przeciwnym razie dostaniesz ClassCastException.

1
Zadanie
JAVA 25 SELF, poziom 31, lekcja 2
Niedostępne
Organizacja zespołu w korporacji "Innowacyjne Rozwiązania" 🏢
Organizacja zespołu w korporacji "Innowacyjne Rozwiązania" 🏢
1
Zadanie
JAVA 25 SELF, poziom 31, lekcja 2
Niedostępne
Sortowanie egzotycznych owoców 🍎🍌
Sortowanie egzotycznych owoców 🍎🍌
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION