javajava-streamcollectorsgroupingby

Group objects by two properties in using groupBy() in Java 8


I have a Person class like below:

public class Person {
    private int id;
    private String name;
    private int score;

    // constructor, getters, etc.
}

And I have a list of Persone objects and I want to group them into a Map.

Main class:

public class Main {
    public static void main(String[] args) {
        Person person1 = new Person(1, "John", 4);
        Person person2 = new Person(2, "John", 3);
        Person person3 = new Person(2, "John", 4);

        List<Person> personList = new ArrayList<>();
        personList.add(person1);
        personList.add(person2);
        personList.add(person3);

        Map<String, List<Person>> personByName = personList.stream()
                .collect(groupingBy(Person::getName));

        System.out.println(personByName);
    }

}

It gives the result:

{John=[Person{id=1, name='John', score=4},
       Person{id=2, name='John', score=3},
       Person{id=2, name='John', score=4}]}

How can I group by person name, but still distinguish two people by id by keeping them separatelly inside the map?

I want the result to look like this:

{John=[Person{id=1, name='John', score=4}],
 John=[Person{id=2, name='John', score=3}, Person{id=2, name='John', score=4}]}

Solution

  • How can I group by person name, but still distinguish two people by id by keeping them separatelly inside the map?

    It's inherently impossible to have two identical keys in a Map.

    But there are some options to group the data in such a way.

    Note: ID is usually expected to be unique, but in your example it's not, so I will blind on it. Although we might use score as the second property for grouping, I'll proceed with grouping person objects by ID

    To group people by name and then by id you can create a nested map Map<String,Map<Integer,List<Person>>>, which will associate each unique name with a map that allows to retrieve a having a particular id (and obviously the same name).

    That's how it might be implemented:

     List<Person> personList = new ArrayList<>();
    Collections.addAll(personList,               // with Java 9+ use List.of() instead
        new Person(1, "John", 4),
        new Person(2, "John", 3),
        new Person(2, "John", 4)
    );
    
    Map<String, Map<Integer, List<Person>>> personByNameAndId = personList.stream()
        .collect(Collectors.groupingBy(
            Person::getName,
            Collectors.groupingBy(Person::getId)
        ));
            
    personByNameAndId.forEach((name, personById) -> {
        System.out.println(name);
        personById.forEach((id, people) -> people.forEach(p -> System.out.println("id " + id + " -> " + p)));
    });
    

    Output:

    John
    id 1 -> Person{id=1, name='John', score=4}
    id 2 -> Person{id=2, name='John', score=3}
    id 2 -> Person{id=2, name='John', score=4}
    

    It's worth to mention that it's advisable to avoid using nested collections because they are cumbersome and you might introduce a bug if you're not attentive enough when dialing with. For example, with map of maps, you can accidentally swap the order of keys while invoking get(), and because Map.get() expects an argument of type Object the code would still compile.

    Another option would be to introduce an object that would hold the name and id of a person. It can be implemented either as a class (which would be the only option with Java 8):

    public class IdName {
        private final int id;
        private final String name;
        
        public IdName(Person p) {
            this(p.getId(), p.getName());
        }
        
        // getters, all-args-constructor, equals/hashcode
    }
    

    Or as a Java 16 record:

    public record IdName(int id, String name) {
        public IdName(Person p) {
            this(p.getId(), p.getName());
        }
    }
    

    In order to facilitate convention Person into IdName you can introduce either a constructor of IdName which expects an instance of Person (as shown above), or convenience method toIdName() in the Person class.

    That how to group people using this custom object, a key and retrieve from the map a List<Person> that share name and id with the given person:

    Map<IdName, List<Person>> personByNameAndId = personList.stream()
        .collect(Collectors.groupingBy(IdName::new));
    
    Person john = new Person(2, "John", 0);
    personByNameAndId.get(new IdName(john))
        .forEach(System.out::println);
    

    Output:

    Person{id=2, name='John', score=3}
    Person{id=2, name='John', score=4}