简体   繁体   中英

Flatten a map after Collectors.groupingBy in java

I have list of students. I want to return list of objects StudentResponse classes that has the course and the list of students for the course. So I can write which gives me a map

Map<String, List<Student>> studentsMap = students.stream().
            .collect(Collectors.groupingBy(Student::getCourse,
                    Collectors.mapping(s -> s, Collectors.toList()
             )));

Now I have to iterate through the map again to create a list of objects of StudentResponse class which has the Course and List:

class StudentResponse {
     String course;
     Student student;

     // getter and setter
}

Is there a way to combine these two iterations?

Probably way overkill but it was a fun exercise :) You could implement your own Collector:

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.*;
import java.util.stream.Collector;
import java.util.stream.Collectors;

public class StudentResponseCollector implements Collector<Student, Map<String, List<Student>>, List<StudentResponse>> {

    @Override
    public Supplier<Map<String, List<Student>>> supplier() {
        return () -> new ConcurrentHashMap<>();
    }

    @Override
    public BiConsumer<Map<String, List<Student>>, Student> accumulator() {
        return (store, student) -> store.merge(student.getCourse(),
                new ArrayList<>(Arrays.asList(student)), combineLists());
    }

    @Override
    public BinaryOperator<Map<String, List<Student>>> combiner() {
        return (x, y) -> {
            x.forEach((k, v) -> y.merge(k, v, combineLists()));

            return y;
        };
    }

    private <T> BiFunction<List<T>, List<T>, List<T>> combineLists() {
        return (students, students2) -> {
            students2.addAll(students);
            return students2;
        };
    }

    @Override
    public Function<Map<String, List<Student>>, List<StudentResponse>> finisher() {
        return (store) -> store
                .keySet()
                .stream()
                .map(course -> new StudentResponse(course, store.get(course)))
                .collect(Collectors.toList());
    }

    @Override
    public Set<Characteristics> characteristics() {
        return EnumSet.of(Characteristics.UNORDERED);
    }
}

Given Student and StudentResponse:

public class Student {
    private String name;
    private String course;

    public Student(String name, String course) {
        this.name = name;
        this.course = course;
    }

    public String getName() {
        return name;
    }

    public String getCourse() {
        return course;
    }

    public String toString() {
        return name + ", " + course;
    }
}

public class StudentResponse {
    private String course;
    private List<Student> studentList;

    public StudentResponse(String course, List<Student> studentList) {
        this.course = course;
        this.studentList = studentList;
    }

    public String getCourse() {
        return course;
    }

    public List<Student> getStudentList() {
        return studentList;
    }

    public String toString() {
        return course + ", " + studentList.toString();
    }
}

Your code where you collect your StudentResponses can now be very short and elegant ;)

public class StudentResponseCollectorTest {

    @Test
    public void test() {
        Student student1 = new Student("Student1", "foo");
        Student student2 = new Student("Student2", "foo");
        Student student3 = new Student("Student3", "bar");

        List<Student> studentList = Arrays.asList(student1, student2, student3);

        List<StudentResponse> studentResponseList = studentList
                .stream()
                .collect(new StudentResponseCollector());

        assertEquals(2, studentResponseList.size());
    }
}

Not exactly what you've asked, but here's a compact way to accomplish what you want, just for completeness:

Map<String, StudentResponse> map = new LinkedHashMap<>();
students.forEach(s -> map.computeIfAbsent(
        s.getCourse(), 
        k -> new StudentResponse(s.getCourse()))
    .getStudents().add(s));

This assumes StudentResponse has a constructor that accepts the course as an argument and a getter for the student list, and that this list is mutable (ie ArrayList ) so that we can add the current student to it.

While the above approach works, it clearly violates a fundamental OO principle, which is encapsulation. If you are OK with that, then you're done. If you want to honor encapsulation, then you could add a method to StudentResponse to add a Student instance:

public void addStudent(Student s) {
    students.add(s);
}

Then, the solution would become:

Map<String, StudentResponse> map = new LinkedHashMap<>();
students.forEach(s -> map.computeIfAbsent(
        s.getCourse(), 
        k -> new StudentResponse(s.getCourse()))
    .addStudent(s));

This solution is clearly better than the previous one and would avoid a rejection from a serious code reviewer.

Both solutions rely on Map.computeIfAbsent , which either returns a StudentResponse for the provided course (if there exists an entry for that course in the map), or creates and returns a StudentResponse instance built with the course as an argument. Then, the student is being added to the internal list of students of the returned StudentResponse .

Finally, your StudentResponse instances are in the map values:

Collection<StudentResponse> result = map.values();

If you need a List instead of a Collection :

List<StudentResponse> result = new ArrayList<>(map.values());

Note: I'm using LinkedHashMap instead of HashMap to preserve insertion-order, ie the order of the students in the original list. If you don't have such requirement, just use HashMap .

First, your downstream collector ( mapping ) is redundant and hence you can simplify your code by using the groupingBy overload without a downstream collector.

Given a List<T> as the source, after using the groupingBy overload taking a classifier alone the result map is Map<K, List<T>> so the mapping operation can be avoided.

As for your question, you can use collectingAndThen :

students.stream()
        .collect(collectingAndThen(groupingBy(Student::getCourse), 
                   m -> m.entrySet()
                        .stream()
                        .map(a -> new StudentResponse(a.getKey(), a.getValue()))
                        .collect(Collectors.toList())));

collectingAndThen basically:

Adapts a Collector to perform an additional finishing transformation.

Just iterate over the entry set and map each entry to a StudentResponse :

List<StudentResponse> responses = studentsMap.entrySet()
        .stream()
        .map(e -> new StudentResponse(e.getKey(), e.getValue()))
        .collect(Collectors.toList());

This can be done in a very concise manner using the jOOλ library and its Seq.grouped method:

List<StudentResponse> responses = Seq.seq(students)
        .grouped(Student::getCourse, Collectors.toList())
        .map(Tuple.function(StudentResponse::new))
        .toList();

It assumes StudentResponse has a constructor StudentResponse(String course, List<Student> students) , and forwards to this constructor using the following Tuple.function overload.

As you can see from my other answer as well as shmosel's answer , you'll eventually need to invoke studentsMap.entrySet() to map each Entry<String, List<String>> in the resulting map to StudentResponse objects.

Another approach you could take is the toMap way; ie

Collection<StudentResponse> result = students.stream()
                .collect(toMap(Student::getCourse,
                        v -> new StudentResponse(v.getCourse(),
                                new ArrayList<>(singletonList(v))),
                        StudentResponse::merge)).values();

This essentially groups the Student object by their course ( Student::getCourse ) as with the groupingBy collector; then in the valueMapper function maps from Student to a StudentResponse and finally in the merge function utilises StudentResponse::merge in the case of a key collision.

The above has a dependency on the StudentResponse class having at least the following fields, constructor and methods:

class StudentResponse {
    StudentResponse(String course, List<Student> students) {
        this.course = course;
        this.students = students;
    }

    private List<Student> getStudents() { return students; }

    StudentResponse merge(StudentResponse another){
        this.students.addAll(another.getStudents());
        // maybe some addition merging logic in the future ...
        return this;
    }

    private String course;
    private List<Student> students;
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM