What If Java Collections Had Eager Methods for Filter, Map, FlatMap?
Exploring high-protein iteration patterns, without any excess carbs
Three years ago, I worked with Nikhil Nanivadekar on an experimental collections framework in an open source kata repository, in preparation for a presentation we gave to the JCP Executive Committee in April 2020. I decided to write this blog to share the knowledge we learned with the broader Java community who may be interested in what we explored.
Read this blog if you want to learn how we were able to breathe new life into a twenty-five year old Java Collections framework using a few advanced Java language features. We all have to deal with legacy code bases and libraries. If you learn how to leverage the Java features discussed in this blog, you will be able to adopt strategies to keep your own legacy code bases both stable and able to evolve over long periods of time.
The Java language features I will demonstrate are as follows:
- Covariant Return Types (available since Java 5)
- Default and Static Methods on Interfaces (available since Java 8)
- Sealed Classes (final since Java 16)
Covariant Return Types is an important Java feature that I feel has gotten less coverage than the others listed above. The addition of Sequenced Collections in Java 21 brings some recent attention to this important feature that was added in Java 5. The SequencedCollection
interface has a method named reversed
that has covariant overrides with more specific return types in SequencedSet
, Deque
, List
, etc.
Default Methods and Static Methods on interfaces are the real hot sauce for the recipe we used in the experiment. Default Methods on Interfaces worked very well together in this experiment with Covariant Return Types. I will discuss these three features throughout the rest of this blog.
I wrote a blog about Sealed Classes previously. I will link to the separate blog with more detail about how I implemented Immutable collection types using Sealed Types.
A high-protein Java Collection experiment
We explored an approach of adding eager iteration patterns directly to Java Collection
interfaces. We used method names that matched the names of the lazy iteration patterns from the Java Stream
interface and Collector
utilities. The result was a set of new Read-only, Mutable, and Immutable Collection
interfaces which extend the standard Java Collection interfaces.
The following UML class diagram shows the new interfaces, in cyan and purple, that were designed and coded as part of the experiment. The purple interfaces were part of a separate but related experiment that leverages Sealed Classes to provide structural, contractual, and verifiable immutability.
Note: I will refer to the diagram below occasionally when explaining some of the code examples throughout the rest of this blog.

Read-Only Interfaces
The Read-only List
, Set
, and Bag
interfaces are in cyan. These interfaces allow for common behaviors to exist between Mutable and Immutable versions of List
, Set
, and Bag
. There is no relationship between java.util.List
and the Read-only List
, or java.util.Set
and the Read-only Set
. This allows these new interfaces to be contractually read-only. By contractually read-only, I mean there are no mutating methods on the interfaces like add
or remove
.
Mutable Interfaces
The Mutable interfaces all extend the corresponding interfaces in the java.util
package. Since there is no Bag
type in java.util
, MutableBag
only extends java.util.Collection
through its inheritance relationship with MutableCollection
.
Immutable Interfaces
The Immutable interfaces only extend java.lang.Iterable
and the read-only interfaces. The relationship to java.lang.Iterable
is through the read-only RichIterable
interface.
In the rest of the blog, we’ll explore of some of the advanced Java language features we used to add high-protein eager iteration patterns to Java Collections. We’ll start by revisiting the coding overhead required to use Java Stream
to solve a simple filtering problem. We will filter
even numbers from a List
, Set
, and Bag
.
High-carb iteration patterns with Java Stream
In order to use Java Stream
with a java.util.Collection
, you first have to call a “bun” method on a Collection
with the name stream
or parallelStream
. The “protein” methods like filter
, map
, flatMap
, can then be called on the Stream
interface. Lazy methods like filter
and map
require an additional “bun” operation like forEach
or collect
with a Collector
to be called in order for the code to execute.
Lazy filter example with Stream on MutableList
The following code examples show how to filter
evens from a List
of Integer
using a Java Stream
with a MutableList
. MutableList
is a new interface we created.
@Test
public void filterEvensFromListToList()
{
// Static of() method on MutableList interface
MutableList<Integer> list = MutableList.of(1, 2, 3);
// Lazy filter on Stream - 2 Carbs, 1 Protein
List<Integer> lazy = list.stream()
.filter(each -> each % 2 == 0)
.toList();
// Static of() method on ImmutableList interface
ImmutableList<Integer> expected = ImmutableList.of(2);
Assertions.assertEquals(expected, lazy);
}
There are static methods named of
on MutableList
and ImmutableList
that create instances of these interfaces.
Using Java Stream
, we have to call stream
, filter
, and toList
. There are two bun methods (stream
and toList
), and one protein method (filter
) in the middle.
Lazy filter example with Stream on MutableSet
Let’s see what the code looks like if we make the source collection a MutableSet
.
@Test
public void filterEvensFromSetToSet()
{
// Static of() method on MutableSet interface
MutableSet<Integer> set = MutableSet.of(1, 2, 3);
// Lazy filter on Stream - 2 Carbs, 1 Protein, 1 Utility Method
Set<Integer> lazy = set.stream()
.filter(each -> each % 2 == 0)
.collect(Collectors.toSet());
// Static of() method on ImmutableSet interface
ImmutableSet<Integer> expected = ImmutableSet.of(2);
Assertions.assertEquals(expected, lazy);
}
There are static methods named of
on MutableSet
and ImmutableSet
that create instances of these interfaces.
Using Java Stream
, we have to call stream
, filter
, collect
, and toSet
. There are two bun methods (stream
and collect
), one protein method (filter
) in the middle and an extra utility method (Collectors.toSet
). The extra utility method is necessary because there is no toSet
method available directly on Stream
.
Lazy filter example with Stream on MutableBag
Let’s look at the code necessary to filter
a Collection
type the JDK does not include — MutableBag
.
@Test
public void filterEvensFromBagToBag()
{
// Static of() method on MutableBag interface
MutableBag<Integer> bag = MutableBag.of(1, 2, 3);
// Lazy filter on Stream - 2 Carbs, 1 Protein, 1 Utility, 1 Method Ref
Bag<Integer> lazy = bag.stream()
.filter(each -> each % 2 == 0)
.collect(Collectors.toCollection(MutableBag::empty));
// Static of() method on ImmutableBag interface
ImmutableBag<Integer> expected = ImmutableBag.of(2);
Assertions.assertEquals(expected, lazy);
}
There are static methods named of
on MutableBag
and ImmutableBag
that create instances of these interfaces.
Using Java Stream
, we have to call stream
, filter
, collect
, toCollection
, and create a method reference for empty
. There are two bun methods (stream
and collect
), one protein method (filter
) in the middle, and an extra utility method (Collectors.toCollection
) with a method reference (MutableBag::empty
). The extra utility method and method reference are necessary because there is no Bag
type in the JDK, and no toBag
method on Stream
.
Now let’s begin the tour of the high-protein iteration patterns we added on Collection
interfaces.
High-protein iteration patterns with Collections
The difference between lazy methods on the Stream
interface, and eager methods directly on Collection
interfaces as depicted in the UML class diagram above is subtle and significant. With eager methods available directly on Collection
interfaces, the usage and implementation code is simplified. I wrote the following blog in April 2020 to explain why eager is easier to learn than lazy.
I usually prefer using eager algorithms by default, and lazy algorithms when I see an opportunity for an optimization. Both eager and lazy algorithms are useful so I always want both available in my toolkit.
With eager iteration patterns, the extra “bun” methods (e.g. stream
, collect
, toList
) are no longer necessary, and you can call the high-protein methods like filter
and map
directly on the Collection
itself. The return type of these methods on each Collection
type are Collection
types themselves, and each subtype can provide covariant overrides that return the most specific type that is appropriate for the given subtype. In the case of a single protein method being used, performance may be improved as well using an eager iteration pattern over a lazy iteration pattern.
Eager filter example with MutableList
The following code examples show how to filter
evens from a List
of Integer
using a MutableList
eager implementation of filter
.
@Test
public void filterEvensFromMutableListToMutableList()
{
// Static of() method on MutableList interface
MutableList<Integer> list = MutableList.of(1, 2, 3);
// Eager filter with covariant return on MutableList - 1 Protein
MutableList<Integer> eager =
list.filter(each -> each % 2 == 0);
// Static of() method on ImmutableList interface
ImmutableList<Integer> expected = ImmutableList.of(2);
Assertions.assertEquals(expected, eager);
}
The Covariant Return Type of the eager filter
method for a MutableList
is a MutableList
.
Eager filter with MutableSet and MutableBag
The solutions using an eager filter
method for MutableSet
and MutableBag
will look very similar to the one for MutableList
above. The Covariant Return Type of the filter
method for a MutableSet
is a MutableSet
. The Covariant Return Type of the filter
method for a MutableBag
is a MutableBag
.
Now we’ll explore some of the neat language features that made these examples possible.
Static Methods on Interfaces
The first thing we will look at is the implementation of the Static Methods on MutableList
and ImmutableList
that I used to create instances of these two interfaces in the filter
tests above.
MutableList Static Methods
There are several static methods added to the MutableList
interface. I only use the of
method in the filter
example above.

These methods allow a developer to create an empty
MutableList
, construct a MutableList
with one element, or construct a MutableList
from a vararg array using an overloaded of
method.
Notice there is new class named ArrayList2
created in the static
of
methods of MutableList
. The implementation of ArrayList2
was trivial. ArrayList2
simply extends ArrayList
, and implements the MutableList
interface, and overrides some constructors.
The following is the full class definition for ArrayList2
.

ArrayList2
defines no state, and no behavior beyond what is available in ArrayList
. The existing java.util.ArrayList
class could implement this interface, and that should be the only change needed on ArrayList
. No changes would have to be made to the current java.util.List
interface.
MutableSet and MutableBag Static Methods
Similar to MutableList
, the MutableSet
and MutableBag
interfaces have static methods named empty
and of
. For MutableSet
, the implementation returned will be a HashSet2
. HashSet2
extends java.util.HashSet
, and implements the MutablSet interface. It only overrides the constructors from java.util.HashSet
.
A Bag
type does not exist in the JDK, so the HashBag
class referenced in the MutableBag
empty
and of
methods had to be implemented from scratch. HashBag
contains the basic implementation details for a MutableBag
implementation. HashBag
leverages a new class called HashMap2
which extends java.util.HashMap
. You can explore this code on your own and will see a MutableMap
interface which is implemented by HashMap2
.
Now we’ll explore the immense possibilities that Default Methods on Interfaces creates.
Default Methods on Interfaces
Since Java 8, we have been able to define default
methods on interfaces. A default
method allows you to define the implementation of a method on an interface. Classes that implement the interface will get the default
implementation of the method, unless they provide a suitable override. This theoretically makes it “safe” to add new behaviors to interfaces in a library without breaking existing implementations. We know there are some caveats to this safety, especially if a library has existed for decades and been used extensively. Generally, unless there are collisions with methods defined in the wild, the default
method feature works very well.
I wanted to see how far we could get defining new behaviors for Java Collection
types by only defining default
methods on extension interfaces like MutableList
. We were able to get quite far. As you can see in ArrayList2
and HashSet2
, we didn’t have to add any new behaviors on the classes . With the exception of constructor overrides, all we had to do was extend the interfaces.
In the rest of this section, I am discuss the eager implementations of filter
, map
, and flatMap
. The rest of the behaviors we added can be seen in the class diagram above and investigated by browsing the code.
RichIterable Interface
At the top of the hierarchy is an interface named RichIterable
. Abstract methods exist for filter
, map
, flatMap
are defined on RichIterable
. There are also default
overloaded implementations of each of the methods which take a target collection as a parameter.

RichIterable filter
Here is the code for the abstract
and default
filter
methods defined on RichIterable
.

The filter
with target
default method can take any Collection
implementation as an argument, and that type becomes the return type of the method. This method is used in each of the subtypes to reduce code duplication.
RichIterable map
Here is the code for the abstract
and default
map
methods defined on RichIterable
.

The map
with target
default
method can take any Collection
implementation as an argument, and that specific subtype is defined to be the return type of the method.
RichIterable flatMap
Here is the code for the abstract
and default
flatMap
methods defined on RichIterable
.

The flatMap
with target default method can take any MutableCollection
implementation as an argument, and that specific subtype is defined to be the return type of the method.
MutableCollection
is used here as the parent return type instead of Collection
because flatMap
takes a Function
that returns an Iterable
. The Collection
type only has an addAll
method that takes a Collection
. The MutableCollection
interface defines a default
addAllIterable
method which can take any Iterable
as a parameter.
Here is the code for addAllIterable
defined on the MutableCollection
interface, which extends RichIterable
.

You might notice the Pattern Matching for instanceof
feature is used in this method.
MutableList Interface
We’ll start with the MutableList
interface to explain how the three default methods with Covariant Return Types are defined. The default
implementations of map
, filter
, flatMap
provided on RichIterable
help make the implementations on the subtypes trivial. The overrides are mostly providing the Covariant Return Types by calling the parent method passing in a specific type.

MutableList filter
Here is the code for the default
filter
method defined on MutableList
.

This method overrides the abstract
filter
method defined in RichIterable
which returns RichIterable
. The filter
method on MutableList
has a Covariant Return Type of MutableList
. This means that if you call filter
on a MutableList
you get a MutableList
back as a result.
MutableList map
Here is the code for the default
map
method defined on MutableList
.

This method overrides the abstract
map
method defined in RichIterable
which returns RichIterable
. The map
method on MutableList
has a Covariant Return Type of MutableList
. This means that if you call map
on a MutableList
you get a MutableList
back as a result.
MutableList flatMap
Here is the code for the default flatMap
method defined on MutableList
.

This method overrides the abstract
flatMap
method defined in RichIterable
which returns RichIterable
. The flatMap
method on MutableList
has a Covariant Return Type of MutableList
. This means that if you call flatMap
on a MutableList
you get a MutableList
back as a result.
MutableSet and MutableBag Interfaces
The default
implementations of filter
, map
, and flatMap
on the MutableSet
and MutableBag
interfaces will look very similar to the ones defined on MutableList
. They build upon the same filter
, map
, and flatMap
methods defined in RichIterable
and return more specific types.

Default and Static Methods in Interfaces, combined with Covariant Return Types allowed us to provide significant extensions to the existing Java Collections Framework implementations.
Sealed Classes for Immutable Collection Types
I wrote about using Sealed Classes to implement ImmutableCollection
types in Java providing structural, contractual, and verifiable immutability. The following blog explains the approach in detail. I hope you will enjoy reading this separate, but related experiment in collections design.
Testing the code with the Pet Kata
I decided to open up my IntelliJ IDE and take a look at the experimental collections framework again, and see how much of the Eclipse Collections Pet Kata I could solve using the types and implementations that Nikhil Nanivadekar and I had worked on back in early 2020. In order to implement the Pet kata, I needed to add some missing functionality. I spent a couple hours adding the necessary methods with tests. I added notEmpty
, containsBy
, countByEach
, and groupByEach
in a Pull Request to the Code Katas repo. I also refactored the the original filter
, map
, and flatMap
implementations. I extracted overloads of these methods that take target collections up to RichIterable
. This removed a lot of duplicate for loop code.
The types I used from the experimental framework in the Pet Kata were ImmutableList
, Bag
, MutableBag
, MutableList
, MutableSet
, MutableListMultimap
.
The eager methods I used in the Pet Kata were containsBy
, countBy
, map
, MutableBag.empty
, MutableList.of
, ImmutableList.of
, MutableBag.of
, ImmutableList.empty
, map
, filter
, filterNot
, anyMatch
, allMatch
, count
, findFirst
, flatMap
, countByEach
, groupBy
, groupByEach
.
The following are my code solutions to exercises one through three of the Eclipse Collections Pet Kata. I am using Java 21 records for the Person
and Pet
classes. You can compare my solutions below, with the checked in solutions in the Pet Kata which use Eclipse Collections.
Person record
public record Person(String firstName, String lastName, ImmutableList<Pet> pets)
{
public String getFullName()
{
return this.firstName + ' ' + this.lastName;
}
public boolean named(String name)
{
return name.equals(this.getFullName());
}
public boolean hasPet(PetType petType)
{
return this.pets.containsBy(Pet::type, petType);
}
public boolean hasPet(String petEmoji)
{
return this.hasPet(PetType.fromEmoji(petEmoji));
}
public Bag<PetType> getPetTypes()
{
return this.pets.countBy(Pet::type);
}
public Bag<String> getPetEmojis()
{
return this.getPetTypes().map(Object::toString, MutableBag.empty());
}
public IntStream getPetAges()
{
return this.pets.stream().mapToInt(Pet::age);
}
public boolean isPetPerson()
{
return this.pets.notEmpty();
}
}
Pet record
public record Pet(PetType type, String name, int age)
{
@Override
public String toString()
{
return this.type.toString();
}
}
PetType Enum
public enum PetType
{
CAT("🐱"),
DOG("🐶"),
HAMSTER("🐹"),
TURTLE("🐢"),
BIRD("🐦"),
SNAKE("🐍");
private final String emoji;
PetType(String emoji)
{
this.emoji = emoji;
}
@Override
public String toString()
{
return this.emoji;
}
public static PetType fromEmoji(String searchEmoji)
{
return Stream.of(PetType.values())
.filter(petType -> petType.emoji.equals(searchEmoji))
.findFirst()
.orElse(null);
}
}
PetDomainForKata
public abstract class PetDomainForKata
{
protected MutableList<Person> people;
@BeforeEach
public void setUp() throws Exception
{
this.people = MutableList.of(
new Person("Mary", "Smith",
ImmutableList.of(new Pet(PetType.CAT, "Tabby", 2))),
new Person("Bob", "Smith" ,
ImmutableList.of(
new Pet(PetType.CAT, "Dolly", 3),
new Pet(PetType.DOG, "Spot", 2))),
new Person("Ted", "Smith",
ImmutableList.of(new Pet(PetType.DOG, "Spike", 4))),
new Person("Jake", "Snake",
ImmutableList.of(new Pet(PetType.SNAKE, "Serpy", 1))),
new Person("Barry", "Bird",
ImmutableList.of(new Pet(PetType.BIRD, "Tweety", 2))),
new Person("Terry", "Turtle",
ImmutableList.of(new Pet(PetType.TURTLE, "Speedy", 1))),
new Person("Harry", "Hamster",
ImmutableList.of(
new Pet(PetType.HAMSTER, "Fuzzy", 1),
new Pet(PetType.HAMSTER, "Wuzzy", 1))),
new Person("John", "Doe", ImmutableList.empty())
);
}
public Person getPersonNamed(String fullName)
{
return this.people.findFirst(person -> person.named(fullName))
.orElse(null);
}
}
Exercise1Test
public class Exercise1Test extends PetDomainForKata
{
@Test
public void getFirstNamesOfAllPeople()
{
MutableList<String> firstNames =
this.people.map(Person::firstName);
var expectedFirstNames =
MutableList.of("Mary", "Bob", "Ted", "Jake", "Barry", "Terry", "Harry", "John");
Assertions.assertEquals(expectedFirstNames, firstNames);
}
@Test
public void getNamesOfMarySmithsPets()
{
Person person = this.getPersonNamed("Mary Smith");
ImmutableList<Pet> pets = person.pets();
ImmutableList<String> names =
pets.map(Pet::name);
Assertions.assertEquals(
"Tabby",
names.stream().collect(Collectors.joining("")));
}
@Test
@DisplayName("getPeopleWithCats 🐱")
public void getPeopleWithCats()
{
MutableList<Person> peopleWithCats =
this.people.filter(person -> person.hasPet(PetType.CAT));
var expectedLastNames = MutableList.of("Smith", "Smith");
Assertions.assertEquals(
expectedLastNames,
peopleWithCats.map(Person::lastName));
}
@Test
@DisplayName("getPeopleWithoutCats 🐱")
public void getPeopleWithoutCats()
{
MutableList<Person> peopleWithoutCats =
this.people.filterNot(person -> person.hasPet(PetType.CAT));
var expectedLastNames =
MutableList.of("Smith", "Snake", "Bird", "Turtle", "Hamster", "Doe");
Assertions.assertEquals(
expectedLastNames,
peopleWithoutCats.map(Person::lastName));
}
}
Exercise2Test
public class Exercise2Test extends PetDomainForKata
{
@Test
@DisplayName("doAnyPeopleHaveCats 🐱?")
public void doAnyPeopleHaveCats()
{
Predicate<Person> predicate =
person -> person.hasPet("🐱");
Assertions.assertTrue(this.people.anyMatch(predicate));
}
@Test
public void doAllPeopleHavePets()
{
boolean result =
this.people.allMatch(Person::isPetPerson);
Assertions.assertFalse(result);
}
@Test
@DisplayName("howManyPeopleHaveCats 🐱?")
public void howManyPeopleHaveCats()
{
int count =
this.people.count(person -> person.hasPet("🐱"));
Assertions.assertEquals(2, count);
}
@Test
public void findMarySmith()
{
Person result =
this.people.findFirst(person -> person.named("Mary Smith"))
.orElse(null);
Assertions.assertEquals("Mary", result.firstName());
Assertions.assertEquals("Smith", result.lastName());
}
@Test
@DisplayName("findPetNamedSerpy 🐍")
public void findPetNamedSerpy()
{
MutableList<Pet> petList =
this.people.flatMap(Person::pets);
Pet serpySnake =
petList.findFirst(pet -> pet.name().equals("Serpy"))
.orElse(null);
Assertions.assertEquals("🐍", serpySnake.type().toString());
}
@Test
public void getPeopleWithPets()
{
MutableList<Person> petPeople =
this.people.filter(Person::isPetPerson);
Assertions.assertEquals(7, petPeople.size());
}
@Test
public void getAllPetTypesOfAllPeople()
{
MutableSet<PetType> petTypes =
this.people.flatMap(Person::getPetTypes, MutableSet.empty());
var expected =
MutableSet.of(PetType.CAT,
PetType.DOG,
PetType.TURTLE,
PetType.HAMSTER,
PetType.BIRD,
PetType.SNAKE);
Assertions.assertEquals(expected, petTypes);
}
@Test
public void getAllPetEmojisOfAllPeople()
{
MutableSet<String> petEmojis =
this.people.flatMap(Person::getPetEmojis, MutableSet.empty());
var expected =
MutableSet.of("🐱", "🐶", "🐢", "🐹", "🐦", "🐍");
Assertions.assertEquals(expected, petEmojis);
}
@Test
public void getFirstNamesOfAllPeople()
{
MutableList<String> firstNames =
this.people.map(Person::firstName);
var expected =
MutableList.of("Mary", "Bob", "Ted", "Jake", "Barry", "Terry", "Harry", "John");
Assertions.assertEquals(expected, firstNames);
}
@Test
@DisplayName("doAnyPeopleHaveCatsRefactor 🐱?")
public void doAnyPeopleHaveCatsRefactor()
{
boolean peopleHaveCatsLazy =
this.people.stream().anyMatch(person -> person.hasPet("🐱"));
Assertions.assertTrue(peopleHaveCatsLazy);
boolean peopleHaveCatsEager =
this.people.anyMatch(person -> person.hasPet("🐱"));
Assertions.assertTrue(peopleHaveCatsEager);
}
@Test
@DisplayName("doAllPeopleHaveCatsRefactor 🐱?")
public void doAllPeopleHaveCatsRefactor()
{
boolean peopleHaveCatsLazy =
this.people.stream().allMatch(person -> person.hasPet("🐱"));
Assertions.assertFalse(peopleHaveCatsLazy);
boolean peopleHaveCatsEager =
this.people.allMatch(person -> person.hasPet("🐱"));
Assertions.assertFalse(peopleHaveCatsEager);
}
@Test
@DisplayName("getPeopleWithCatsRefactor 🐱?")
public void getPeopleWithCatsRefactor()
{
MutableList<Person> peopleWithCats =
this.people.filter(person -> person.hasPet("🐱"));
Assertions.assertEquals(2, peopleWithCats.size());
}
@Test
@DisplayName("getPeopleWithoutCatsRefactor 🐱?")
public void getPeopleWithoutCatsRefactor()
{
MutableList<Person> peopleWithoutCats =
this.people.filterNot(person -> person.hasPet("🐱"));
Assertions.assertEquals(6, peopleWithoutCats.size());
}
}
Exercise3Test
public class Exercise3Test extends PetDomainForKata
{
@Test
public void getCountsByPetEmojis()
{
MutableList<PetType> petTypes =
this.people.flatMap(Person::pets).map(Pet::type);
Map<String, Long> petEmojiCounts =
petTypes.stream()
.collect(Collectors.groupingBy(Object::toString,
Collectors.counting()));
var expectedMap =
Map.of("🐱", 2L, "🐶", 2L, "🐹", 2L, "🐍", 1L, "🐢", 1L, "🐦", 1L);
Assertions.assertEquals(expectedMap, petEmojiCounts);
MutableBag<String> counts =
this.people.countByEach(Person::getPetEmojis);
var expected =
MutableBag.of("🐱", "🐱", "🐶", "🐶", "🐹", "🐹", "🐍", "🐢", "🐦");
Assertions.assertEquals(expected, counts);
}
@Test
public void getPeopleByLastName()
{
MutableListMultimap<String, Person> lastNamesToPeople =
this.people.groupBy(Person::lastName);
Assertions.assertEquals(3, lastNamesToPeople.get("Smith").size());
}
@Test
public void getPeopleByTheirPetTypes()
{
MutableListMultimap<PetType, Person> petTypesToPeople =
this.people.groupByEach(Person::getPetTypes);
Assertions.assertEquals(2, petTypesToPeople.get(PetType.CAT).size());
Assertions.assertEquals(2, petTypesToPeople.get(PetType.DOG).size());
Assertions.assertEquals(2, petTypesToPeople.get(PetType.HAMSTER).size());
Assertions.assertEquals(1, petTypesToPeople.get(PetType.TURTLE).size());
Assertions.assertEquals(1, petTypesToPeople.get(PetType.BIRD).size());
Assertions.assertEquals(1, petTypesToPeople.get(PetType.SNAKE).size());
}
@Test
public void getPeopleByTheirPetEmojis()
{
MutableListMultimap<String, Person> petEmojisToPeople =
this.people.groupByEach(Person::getPetEmojis);
Assertions.assertEquals(2, petEmojisToPeople.get("🐱").size());
Assertions.assertEquals(2, petEmojisToPeople.get("🐶").size());
Assertions.assertEquals(2, petEmojisToPeople.get("🐹").size());
Assertions.assertEquals(1, petEmojisToPeople.get("🐢").size());
Assertions.assertEquals(1, petEmojisToPeople.get("🐦").size());
Assertions.assertEquals(1, petEmojisToPeople.get("🐍").size());
}
}
Whither Java Collections 2.0?
In April 2020, we gave a presentation to the Java Community Process (JCP) Executive Committee (EC) proposing the idea of creating a JSR for Collections 2.0. We built this experimental collections framework to demonstrate a potential future that is possible using all of the amazing features of the Java programming language, and lessons learned from various collections libraries. We placed the experimental code in the same module as the Deck of Cards Kata, which is a code kata which can be and has been used to compare and contrast various Java collection frameworks, as well as JVM Languages. The Java language has gotten even better since we gave this presentation, which was given about a month after Java 14 was released.
Two roads diverged in a wood, and I —
I took the one less traveled by,
And that has made all the difference.-Robert Frost
I have abandoned pushing for a JSR for Collections 2.0. I have walked down the less travelled path, twice now! I have spent 20 years working on an open source collections library for Java. Eclipse Collections has proven itself useful in hundreds of thousands of production use cases, with a design that inspired some of the design choices we took in this experimental framework. Eclipse Collections continues to bring me joy collaborating on solving useful problems with so many developers in open source I may never meet in person. I will continue my work on Eclipse Collections, regardless of whither Java Collections eventually provide new or existing interfaces with eager iteration patterns.
I still believe there is value in providing eager iteration patterns directly on Java Collection types in the JDK. I wrote this blog so others might learn from this experiment, either for their own Java library work or perhaps even to get involved in helping drive the continued evolution of the existing Java Collections framework.
Why not just update the existing Java Collection interfaces?
If you’re wondering why methods like filter
, map
, and flatMap
haven’t been added to java.util.Collection
, java.util.List
, java.util.Set
, the following answer on StackOverflow might help.
The approach we took in the experimental collections framework by introducing new interfaces like RichIterable
, MutableCollection
, MutableList
, etc. reduces the potential conflict surface area problem significantly.
One idea for evolving the existing Java Collections Framework would be to evolve the existing implementations (e.g. ArrayList
, HashSet
) by having them implement new interface extensions. The following diagram is one possible path for this evolution.

Takeaways from this blog
The Java Language evolution over the past decade has been truly amazing. Default and Static methods on Interfaces make many new designs possible for Java library developers. There are some “gotchas” out there, especially when dealing with diamond hierarchies, but the possibilities are incredible. I hope this blog demonstrates what is possible using some of the language features together.
I encourage folks to check out the code in the experiment and maybe try some experiments of their own with Covariant Return Types, Default and Static methods for Interfaces, and Sealed Classes. We all benefit when we learn new approaches to solving problems.
Thank you for reading this far! Best of luck on your journey, on whichever paths you choose!
I am the creator of and committer for the Eclipse Collections OSS project, which is managed at the Eclipse Foundation. Eclipse Collections is open for contributions.