Neko

Post

How to Build a Custom Collector in Java

Crafting Custom Collectors: A Step-by-Step Guide to Building Your Own in Java

In Java, the Stream API introduced in Java 8 has revolutionized the way we process collections of objects. Central to this functionality are collectors, which provide a way to accumulate elements of a stream into a single result. While the standard collectors provided by the Java library cover many common use cases, there are times when you need to define your own custom collector to achieve specific behavior. This article will guide you through the process of building a custom collector in Java.

  1. What is a Collector?

  2. Standard Collectors

  3. Building a Custom Collector

  4. Redefining the Collector Methods

  5. Example of a Custom Collector

  6. Benefits of Custom Collectors

  7. Conclusion

What is a Collector?

A collector in Java is a mechanism used to accumulate elements of a stream into a single result, such as a collection, a string, or a summary. Collectors are typically used with the Stream.collect() method, which processes elements in a stream and produces a result. They encapsulate the logic of how the elements should be collected, making the process more streamlined and efficient.

Collectors fit into the Stream API as terminal operations, which means they are applied at the end of a stream pipeline to produce a result or a side effect. This design allows for the easy composition of various operations on data.

Standard Collectors

Java provides a wide array of standard collectors in the Collectors utility class. Here are a few commonly used ones:

Collectors.toList(): Collects elements into a List

1
List<String> list = stream.collect(Collectors.toList());

Collectors.toSet(): Collects elements into a Set

1
Set<String> set = stream.collect(Collectors.toSet());

Collectors.joining(): Concatenates elements into a single String

1
String result = stream.collect(Collectors.joining(", "));

Collectors.groupingBy(): Groups elements by a classifier function

1
Map<Integer, List<String>> groupedByLength = stream.collect(Collectors.groupingBy(String::length));

These standard collectors are highly useful, but there are times when the provided functionality is not enough, and a custom collector becomes necessary.

Building a Custom Collector

Custom collectors are particularly useful when you need to perform specialized operations that are not covered by the standard collectors. For example, you might want to collect elements into a custom data structure or perform complex transformations during the collection process.

To build a custom collector, you need to redefine four methods:

  1. supplier()

  2. accumulator()

  3. combiner()

  4. finisher()

These methods correspond to different stages of the collection process.

Redefining the Collector Methods

1. supplier() method provides a new instance of the result container. It’s like the starting point of the collection process.

1
Supplier<List<String>> supplier = ArrayList::new;

2. accumulator() method defines how the elements will be accumulated into the result container.

1
BiConsumer<List<String>, String> accumulator = List::add;

3. combiner() method defines how two result containers will be combined. This is particularly important for parallel processing.

1
2
3
4
BinaryOperator<List<String>> combiner = (list1, list2) -> {
    list1.addAll(list2);
    return list1;
};

4. finisher() method performs the final transformation of the result container into the desired result type.

1
Function<List<String>, List<String>> finisher = Function.identity();

Example of a Custom Collector

Let’s create a custom collector that collects elements into an unmodifiable list.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import java.util.*;
import java.util.function.*;
import java.util.stream.Collector;

public class CustomCollectors {
    public static <T> Collector<T, List<T>, List<T>> toUnmodifiableList() {
        return new Collector<T, List<T>, List<T>>() {
            @Override
            public Supplier<List<T>> supplier() {
                return ArrayList::new;
            }

            @Override
            public BiConsumer<List<T>, T> accumulator() {
                return List::add;
            }

            @Override
            public BinaryOperator<List<T>> combiner() {
                return (list1, list2) -> {
                    list1.addAll(list2);
                    return list1;
                };
            }

            @Override
            public Function<List<T>, List<T>> finisher() {
                return Collections::unmodifiableList;
            }

            @Override
            public Set<Characteristics> characteristics() {
                return Collections.emptySet();
            }
        };
    }

    public static void main(String[] args) {
        List<String> result = List.of("a", "b", "c")
                                  .stream()
                                  .collect(toUnmodifiableList());
        System.out.println(result);
    }
}

Benefits of Custom Collectors

Custom collectors offer several benefits:

  • Flexibility: They allow you to tailor the collection process to your specific needs.

  • Reusability: Once defined, custom collectors can be reused across different projects.

  • Performance Optimization: Custom collectors can be optimized for specific scenarios, potentially improving performance.

Conclusion

Building a custom collector in Java is a powerful way to extend the capabilities of the Stream API. By defining the supplier(), accumulator(), combiner(), and finisher() methods, you can create collectors that precisely fit your needs. Custom collectors provide flexibility, reusability, and can help optimize performance. Try building your own custom collectors to see how they can enhance your Java projects.

This post is licensed under CC BY 4.0 by the author.