Google Guava Maps

Google Guava Maps

Google Guava Maps has many static utility methods operating on Map instances (Map, SortedMap, NavigableMap, BiMap etc.,).

Maps#immutableEntry and immutableEnumMap

Maps#immutableEntry

The immutableEntry method takes a key (of type K) and a value (of type V) and returns an immutable map entry (of type Map.Entry<K, V>).

Map.Entry<String, Integer> immutableEntry = Maps.immutableEntry("a", 1);
System.out.println(immutableEntry); //a=1
System.out.println(immutableEntry.getKey()); //a
System.out.println(immutableEntry.getValue()); //1

Since the returned entry is immutable, the setValue() method throws UnsupportedOperationException.

In Java 9+, we can use Map.entry() method to achieve the same result (if the key and value are non-null).

Note: Refer to the Convenience Factory Methods for Collections post to learn about the other factory methods added in JDK 9.

Map.Entry<String, Integer> immutableEntry = Map.entry("a", 1);
System.out.println(immutableEntry); //a=1

Maps#immutableEnumMap

The immutableEnumMap takes a map instance whose keys are enum instances and returns an immutable map having the same entries. The returned map is backed up by an EnumMap. The iteration order of the returned map follows the enum’s iteration order.

Let us say we have the below enum and a map which maps an enum instance to a String as shown below.

enum Color {
    RED,
    YELLOW,
    GREEN,
    BLUE;
}
Map<Color, String> map = Map.of(
        Color.RED, "red",
        Color.YELLOW, "yellow",
        Color.GREEN, "green",
        Color.BLUE,"blue"
);

Using Maps.immutableEnumMap, we can get back an ImmutableMap which is backed by an EnumMap.

ImmutableMap<Color, String> immutableMap = Maps.immutableEnumMap(map);
System.out.println(immutableMap); //{RED=red, YELLOW=yellow, GREEN=green, BLUE=blue}

Filter methods

We will now look at three methods: filterKeys, filterValues and filterEntries. We will use the below two maps for the examples in this section.

private static final Map<Employee, Integer> employeeSalaries = new HashMap<>(Map.of(
    new Employee("John", 24), 10000,
    new Employee("Joe", 21), 9000,
    new Employee("Mary", 26), 15000,
    new Employee("Tim", 27), 8000
));

private static final BiMap<String, Integer> biMap = HashBiMap.create(Map.of(
    "a", 1,
    "b", 2,
    "cc", 3
));

where Employee is a simple Java record as shown below.

record Employee(String name, int age) {

}

Maps.filterKeys

The Maps#filterKeys takes a Map and a Predicate (keyPredicate) and returns a map which contains all the entries whose keys satisfy the passed predicate. In other words, it invokes the predicate for each key in the map. If the predicate returns true, the entry will be part of the resultant map.

Map<Employee, Integer> filteredMap = Maps.filterKeys(employeeSalaries, 
                    e -> e.age >= 25);
System.out.println(filteredMap);
System.out.println(filteredMap.size());

In the above code, we use the employeeSalaries map with a filter which returns true if an employee’s age is 25 or more. Thus, the resultant map will have entries only if the employee is aged 25 or more. The result of the filtering is shown below.

{Employee[name=Tim, age=27]=8000, Employee[name=Mary, age=26]=15000}

The map result it returns is a view of the original map and hence changes to one will affect the other. Let us now add a new value through the returned view.

filteredMap.put(new Employee("Lucy", 27), 12000);

Since the new entry’s key passes the predicate, it will be added to the underlying map as well.

System.out.println(filteredMap);
System.out.println(employeeSalaries);

Prints,

{Employee[name=Lucy, age=27]=12000, Employee[name=Tim, age=27]=8000, Employee[name=Mary, age=26]=15000}
{Employee[name=Lucy, age=27]=12000, Employee[name=John, age=24]=10000, Employee[name=Tim, age=27]=8000, Employee[name=Mary, age=26]=15000, Employee[name=Joe, age=21]=9000}

If we add an entry whose key doesn’t satisfy the predicate, then it will throw an IllegalArgumentException.

//throws java.lang.IllegalArgumentException
filteredMap.put(new Employee("Matt", 21), 12000);

When we remove an entry through the returned map view, it will remove from the underlying map only if it satisfies the predicate.

filteredMap.remove(new Employee("Lucy", 27));
System.out.println(filteredMap);
System.out.println(employeeSalaries);

This results in the earlier added entry (employee Lucy) being removed.

{Employee[name=Tim, age=27]=8000, Employee[name=Mary, age=26]=15000}
{Employee[name=John, age=24]=10000, Employee[name=Tim, age=27]=8000, Employee[name=Mary, age=26]=15000, Employee[name=Joe, age=21]=9000}

If we attempt to remove John, the corresponding entry won’t be removed as it doesn’t pass the predicate (age ≥ 25).

filteredMap.remove(new Employee("John", 24));
System.out.println(filteredMap);
System.out.println(employeeSalaries);

Prints,

{Employee[name=Tim, age=27]=8000, Employee[name=Mary, age=26]=15000}
{Employee[name=John, age=24]=10000, Employee[name=Tim, age=27]=8000, Employee[name=Mary, age=26]=15000, Employee[name=Joe, age=21]=9000}

Note: If you don’t need a live view, then you can copy the filtered map and use it.

Finally, there are three overloaded filterKeys methods which take a SortedMap, NavigableMap or a BiMap. An example with a BiMap is shown below.

Map<String, Integer> filteredBiMap = Maps.filterKeys(biMap, s -> s.length() == 1);
System.out.println(filteredBiMap); //{b=2, a=1}

Maps.filterValues

The Maps#filterValues is similar to the filterKeys method. It takes a Map instance and a Predicate (valuePredicate) and applies each entry’s value to the predicate. If it satisfies the predicate, the entry will be part of the result map. As earlier, the returned map is a view.

Using the same map example (employeeSalaries), let us pass a predicate which returns true if the salary is greater than or equal to 10,000.

Map<Employee, Integer> filteredMap = Maps.filterValues(employeeSalaries, 
        salary -> salary >= 10000);
System.out.println(filteredMap);
System.out.println(filteredMap.size());

The result is that we get only two entries.

{Employee[name=John, age=24]=10000, Employee[name=Mary, age=26]=15000}

As seen with filterKeys, we can add new entries through the returned view. If the value passes the predicate test, then it will add the entry to the underlying map. If not, it will throw an IllegalArgumentException. Code examples follow.

filteredMap.put(new Employee("Lucy", 27), 12000);
System.out.println(filteredMap);
System.out.println(employeeSalaries);

Result of adding a new entry is,

{Employee[name=Lucy, age=27]=12000, Employee[name=John, age=24]=10000, Employee[name=Mary, age=26]=15000}
{Employee[name=Lucy, age=27]=12000, Employee[name=John, age=24]=10000, Employee[name=Tim, age=27]=8000, Employee[name=Mary, age=26]=15000, Employee[name=Joe, age=21]=9000}

We cannot add an entry which fails the predicate check, as shown below.

//throws java.lang.IllegalArgumentException
filteredMap.put(new Employee("Matt", 21), 8000);

Again, as before, the remove call works only if the value mapped to the passed key satisfies the predicate. In this example, the salary of Lucy is 12000, which passes the predicate and hence the entry can be removed.

filteredMap.remove(new Employee("Lucy", 27));
System.out.println(filteredMap);
System.out.println(employeeSalaries);

Results in,

{Employee[name=John, age=24]=12000, Employee[name=Mary, age=26]=15000}
{Employee[name=John, age=24]=12000, Employee[name=Tim, age=27]=8000, Employee[name=Mary, age=26]=15000, Employee[name=Joe, age=21]=9000}

The below remove call has no effect as the value (9000) doesn’t pass the predicate.

filteredMap.remove(new Employee("Joe", 21));

There are three other overloaded filterValues which take a SortedMap, NavigableMap or a BiMap. An example with a BiMap follows.

Map<String, Integer> filteredBiMap = Maps.filterValues(biMap, v -> v % 2 == 1);
System.out.println(filteredBiMap); //{a=1, cc=3}

Maps.filterEntries

The Maps#filterEntries works by taking a map and a Predicate<? super Map.Entry> (entryPredicate) and returns a map (live view) containing the mappings which satisfy the passed predicate.

Let us pass a predicate which checks if the employee’s age ≥ 25 and if the salary (value) ≥ 10000.

Map<Employee, Integer> filteredMap = Maps.filterEntries(employeeSalaries,
        entry -> entry.getKey().age >= 25 && entry.getValue() >= 10000);
System.out.println(filteredMap);
System.out.println(filteredMap.size());

The result is,

{Employee[name=Mary, age=26]=15000}

As before, we can add new entries and they will be added if they pass the predicate check (both key and value checked).

filteredMap.put(new Employee("Lucy", 27), 12000);
System.out.println(filteredMap);
System.out.println(employeeSalaries);

The result is,

{Employee[name=Lucy, age=27]=12000, Employee[name=Mary, age=26]=15000}
{Employee[name=Lucy, age=27]=12000, Employee[name=John, age=24]=10000, Employee[name=Tim, age=27]=8000, Employee[name=Mary, age=26]=15000, Employee[name=Joe, age=21]=9000}

If either the key or value (or both) doesn’t match the predicate, it throws an IllegalArgumentException.

//throws java.lang.IllegalArgumentException
filteredMap.put(new Employee("Matt", 21), 18000);

Removing an entry has an effect only if it matches the predicate (both key and value).

filteredMap.remove(new Employee("Lucy", 27));
System.out.println(filteredMap);
System.out.println(employeeSalaries);
{Employee[name=Mary, age=26]=15000}
{Employee[name=John, age=24]=10000, Employee[name=Tim, age=27]=8000, Employee[name=Mary, age=26]=15000, Employee[name=Joe, age=21]=9000}
filteredMap.remove(new Employee("Joe", 21)); //no-op

As with the other filter methods, there are overloaded versions supporting SortedMap, NavigableMap and a BiMap.

Map<String, Integer> filteredBiMap = Maps.filterEntries(biMap, 
                    e -> e.getKey().length() == 1 && e.getValue() %2 == 1);
System.out.println(filteredBiMap); //{a=1}

Transform methods

We will now look at the transformValues and transformEntries methods. We will use the below map for the examples in this section.

Map<String, Integer> map = new HashMap<>(Map.of(
      "aa", 1,
      "bbb", 2,
      "c", 3
));

Maps.transformValues

The transformValues method takes a map and a Function and returns a map view where each value is transformed result of applying the passed function. In other words, for each value in the map (of each map entry), it invokes the passed function and the result of that function call will be the new value in the returned map.

Map<String, Double> transformedMap = Maps.transformValues(map, 
          v -> Math.pow(2, v));
System.out.println(transformedMap);

In the above example, we pass a function which takes an integer (v) and computes Math.pow(2, v) and returns that as the result. The final map (view) we get will be of type Map<String, Double>. The result of the above code is shown below.

{aa=2.0, bbb=4.0, c=8.0}

Since the returned map is a view, we can remove entries through it. An example is shown below.

transformedMap.remove("bbb");
System.out.println(transformedMap); //{aa=2.0, c=8.0}
System.out.println(map); //{aa=1, c=3}

Attempts to add new entries through the returned view will throw an UnsupportedOperationException.

Note that the function is applied lazily (called when needed). To avoid the lazy evaluation, we can make a copy of the returned map.

Also, there are two other overloaded transformValues methods which take a NavigableMap and a SortedMap.

Maps.transformEntries

This is similar to the transformValues, but takes an instance of Maps.EntryTransformer rather than a Function. The EntryTransformer is a FunctionalInterface with the below signature. It has a single method called transformEntry which takes a key and a value and returns a new result. It is effectively a BiFunction.

@FunctionalInterface
public interface EntryTransformer<
      K extends @Nullable Object, V1 extends @Nullable Object, V2 extends @Nullable Object> {
 
    V2 transformEntry(@ParametricNullness K key, @ParametricNullness V1 value);
}

Using this in the Maps#transformEntries method, we can pass a function which takes both the key and value of a map entry and computes the new result. This new result will be the value in the returned map (view).

Using the same map example, we pass a function which computes result using both the key and the value as shown below.

Map<String, Double> transformedMap = Maps.transformEntries(map,
        (k, v) -> Math.pow(k.length(), v));
System.out.println(transformedMap);

The result is,

{aa=2.0, c=1.0, bbb=9.0}

Since it is a view, we can remove entries through it (but cannot add new entries).

transformedMap.remove("bbb");
System.out.println(transformedMap); //{aa=2.0, c=1.0}
System.out.println(map); // {aa=1, c=3}

As with the transformValues method, the function will be applied lazily and there are overloaded methods which operate on SortedMap and NavigableMap.

Maps.uniqueIndex

The uniqueIndex takes an Iterable or an Iterator as the first argument and a Function to transform each element of the passed Iterable or Iterator. The result will be a map keyed by the result of the function invocation and with value as the element in the Iterable (or Iterator).

In the below example, we pass a list (fruit names) and a function (Function<String, Integer>) which converts a string to an integer representing its length. As a result, we get an ImmutableMap<Integer, String> where the key is the fruit name’s length and the value is the actual name of the fruit.

List<String> fruits = List.of("apple", "orange", "pear");
ImmutableMap<Integer, String> map = Maps.uniqueIndex(fruits, String::length);
System.out.println(map); //{5=apple, 6=orange, 4=pear}

If more than one element can map to the same result (and thus same key in the resultant map), it will throw an IllegalArgumentException. For such cases, use Multimaps.index.

List<String> fruits = List.of("apple", "orange", "banana");
//java.lang.IllegalArgumentException: Multiple entries with same key: 6=banana and 6=orange. To index multiple values under a key, use Multimaps.index.
//map = Maps.uniqueIndex(fruits, String::length);

The Java Stream equivalent code to achieve the same result is shown below.

fruits = List.of("apple", "orange", "pear");
map = fruits.stream()
        .collect(ImmutableMap.toImmutableMap(String::length, Function.identity()));
System.out.println(map);

Maps.subMap

We pass a NavigableMap and a Google Guava Range and it returns a map (view) whose keys are contained by the passed range.

Here, we pass a TreeMap and a Range created with Range.greaterThan(”b”) and hence the result has only the map entries with keys which are greater than “b”.

TreeMap<String, Integer> map = new TreeMap<>(Map.of(
    "a", 1,
    "b", 2,
    "c", 3,
    "d", 4,
    "e", 5
));
NavigableMap<String, Integer> subMap = Maps.subMap(map, Range.greaterThan("b"));
System.out.println(subMap); //{c=3, d=4, e=5}

Some more examples follow.

System.out.println(Maps.subMap(map, Range.atLeast("b"))); //{b=2, c=3, d=4, e=5}

System.out.println(Maps.subMap(map, Range.lessThan("b"))); //{a=1}

System.out.println(Maps.subMap(map, Range.atMost("b"))); //{a=1, b=2}

Maps.asConverter

The asConverter method takes a BiMap and returns a Converter which converts values using the passed Bimap. Its inverse view converts values using the bimap’s inverse.

Here, we have a BiMap from which we create a Converter. When using the convert method, it uses the passed BiMap to perform the conversion.

BiMap<String, Integer> biMap = HashBiMap.create(Map.of(
        "a", 1,
        "b", 2,
        "c", 3
));
Converter<String, Integer> converter = Maps.asConverter(biMap);
System.out.println(converter.convert("a")); //1

It will throw an IllegalArgumentException when there is no appropriate mapping present for the passed value.

//java.lang.IllegalArgumentException: No non-null mapping present for input: d
System.out.println(converter.convert("d"));

The inverse conversion using the reverse converter is shown below.

Converter<Integer, String> reverseConverter =  converter.reverse();
System.out.println(reverseConverter.convert(2)); //b

Maps.fromProperties

This creates an ImmutableMap<String, String> from a Properties instance. This can be helpful since properties have Map<Object, Object>, but mostly have string keys and values. Once converted using this, we can work with String types.

The below code builds an ImmutableMap out of a Properties instance.

Properties properties = new Properties();
properties.put("version", "1.1.2");
properties.put("name", "TheName");
properties.put("group", "TheGroup");

System.out.println(properties); //{name=TheName, version=1.1.2, group=TheGroup}

ImmutableMap<String, String> propertiesMap = Maps.fromProperties(properties);
System.out.println(propertiesMap); //{version=1.1.2, name=TheName, group=TheGroup}

Note: Maps#fromProperties will work only if the keys and values are strings.

Maps – asMap and toMap

Maps.asMap

The asMap method accepts a Set<K> and a Function<K, V> and returns a map (view) whose keys are the contents of the set and whose values are the result of applying the passed function. The values are computed lazily and on demand. If you need an immutable copy, make a copy of the result or use Maps#toMap (covered in the next section).

Here, we have a set of fruits (HashSet). We use the Maps#asMap method to build a Map<String, Integer> where the value is computed by returning the length of the fruit name.

Set<String> fruits = new HashSet<>(Set.of("apple", "pear","orange"));
System.out.println(fruits); //[orange, apple, pear]

Map<String, Integer> map = Maps.asMap(fruits, String::length);
System.out.println(map);  //{orange=6, apple=5, pear=4}

We can remove elements through the returned map. But it doesn’t support put operations.

map.remove("pear");
System.out.println(map); //{orange=6, apple=5}
System.out.println(fruits); //[orange, apple]

There are two other overloaded asMap methods operating on SortedSet and NavigableSet. They return a SortedMap and a NavigableMap, respectively. An example of using a NavigableSet on asMap is shown below.

TreeSet<String> fruitsSorted = new TreeSet<>(Set.of("apple", "pear","orange"));
System.out.println(fruitsSorted); //[apple, orange, pear]

NavigableMap<String, Integer> mapSorted = Maps.asMap(fruitsSorted, String::length);
System.out.println(mapSorted); //{apple=5, orange=6, pear=4}

Maps.toMap

The Maps#toMap takes an Iterator<K> (or Iterable<K>) and a Function<K, V> and returns an immutable map whose keys are the distinct elements from the Iterator (or Iterable) and values for each key are computed using the passed function.

Here, we pass the list of fruit name along with a Function<String, Integer> which returns the length of the passed string (fruit name).

List<String> fruits = new ArrayList<>(List.of("apple", "pear","orange"));
System.out.println(fruits); //[apple, pear, orange]
Map<String, Integer> map = Maps.toMap(fruits, String::length); //{apple=5, pear=4, orange=6}

If the passed list has duplicate elements, it is unspecified whether it invokes the function more than once or not.

Maps.toImmutableEnumMap

It returns a Collector which accumulates the elements into an ImmutableMap whose keys and values are derived by applying the passed mapping functions to the input elements. The resultant map is specialized for enum key types.

Let us say we have these two simple record types and a List<Student> instance.

record Student(String name, List<ScoreDetails> scoreDetails) {
}

record ScoreDetails(Subject subject, int score) {
}

enum Subject {
    ENGLISH,
    MATH,
    SCIENCE
}

List<Student> students = List.of(
        new Student("Adam", List.of(
                new ScoreDetails(Subject.MATH, 81),
                new ScoreDetails(Subject.ENGLISH, 89)
        )),
        new Student("Lucy", List.of(
                new ScoreDetails(Subject.SCIENCE, 76)
        ))
);

We create a stream out of the list and use Stream#flatMap (to return the scores) and Stream#collect on it using Maps.toImmutableEnumMap. The keyFunction extracts the subject and the valueFunction returns the score. As a result, we get an ImmutableMap mapping from the subject to the score.

ImmutableMap<Subject, Integer> scores = students.stream()
        .flatMap(s -> s.scoreDetails.stream())
        .collect(Maps.toImmutableEnumMap(m -> m.subject, m -> m.score));
System.out.println(scores); //{ENGLISH=89, MATH=81, SCIENCE=76}

If the mapped keys contain duplicates, then it throws an IllegalArgumentException. Note that Collectors#toMap throws an IllegalStateException in such cases.

To work with such cases, we can use the overloaded Maps#toImmutableEnumMap to pass a merge function. Then it will use that function to merge the computed values among duplicate keys.

Let us update the input so that there will be duplicates. To achieve this, I’ve added a ScoreDetails instance for ENGLISH for both the students.

students = List.of(
        new Student("Adam", List.of(
                new ScoreDetails(Subject.MATH, 81),
                new ScoreDetails(Subject.ENGLISH, 89)
        )),
        new Student("Lucy", List.of(
                new ScoreDetails(Subject.SCIENCE, 76),
                new ScoreDetails(Subject.ENGLISH, 98)
        ))
);

Now, we pass the merge function argument to take the maximum among the values. Hence, for ENGLISH there will be only one result (the maximum score) in the result map.

scores = students.stream()
        .flatMap(s -> s.scoreDetails.stream())
        .collect(Maps.toImmutableEnumMap(m -> m.subject, m -> m.score,
                Math::max));

System.out.println(scores); //{ENGLISH=98, MATH=81, SCIENCE=76}

Maps.difference

I have a separate post on Google Guava Maps#difference which explains the three overloaded Maps#difference method in depth.

Conclusion

This concludes the post on Google Guava Maps utility class. We looked at many utility methods operating on Map instances in this post.

I have several other posts which cover the other useful utilities in the Google Guava library. Do check it out if interested.

Leave a Reply