Java Stream's
Collectors
methods fit most use-cases. They allow returning either a Collection
or a scalar. For the former, you use one of the toXXX()
method, for the latter, one of the reducing()
one.Let's imagine an e-commerce platform that implements a shopping cart. The cart is modeled as the following:
This diagram might translate into the following (abridged) code:
public class Product {
private final Long id; // 1
private final String label; // 1
private final BigDecimal price; // 1
public Product(Long id, String label, BigDecimal price) {
this.id = id;
this.label = label;
this.price = price;
}
@Override
public boolean equals(Object object ) { ... } // 2
@Override
public int hashCode() { ... } // 2
}
id
public class Cart {
private final Map<Product, Integer> products = new HashMap<>(); // 1
public void add(Product product) {
add(product, 1);
}
public void add(Product product, int quantity) {
products.merge(product, quantity, Integer::sum);
}
public void remove(Product product) {
products.remove(product);
}
public void setQuantity(Product product, int quantity) {
products.put(product, quantity);
}
public Map<Product, Integer> getProducts() {
return Collections.unmodifiableMap(products); // 2
}
}
Product
; the value is the quantity.Once we have defined how we store data in memory, we need to design how to display the cart on-screen. We know that the checkout screen needs to show two different bits of information:
Here's the corresponding code:
public record CartRow(Product product, int quantity) { // 1
public CartRow(Map.Entry<Product, Integer> entry) {
this(entry.getKey(), entry.getValue());
}
public BigDecimal getRowPrice() {
return product.getPrice().multiply(new BigDecimal(quantity));
}
}
CartRow
is a value object. We can model it as a Java 16 record
.var rows = cart.getProducts()
.entrySet()
.stream()
.map(CartRow::new)
.collect(Collectors.toList()); // 1
var price = cart.getProducts()
.entrySet()
.stream()
.map(CartRow::new)
.map(CartRow::getRowPrice) // 2
.reduce(BigDecimal.ZERO, BigDecimal::add); // 3
One of the main limitations of Java streams is that you can only consume them once. The reason is that streamed objects are not necessarily immutable (though they can be). Hence, executing the same stream twice might not be idempotent.
Therefore, to get both the rows and the price, we need to create two streams from the cart. From one stream, we will get the rows and from the other the price. This is not the way.
We want to collect both rows and the price from a single stream. We need a custom
Collector
that returns both in one pass as a single object.public class PriceAndRows {
private BigDecimal price; // 1
private final List<CartRow> rows = new ArrayList<>(); // 2
PriceAndRows(BigDecimal price, List<CartRow> rows) {
this.price = price;
this.rows.addAll(rows);
}
PriceAndRows() {
this(BigDecimal.ZERO, new ArrayList<>());
}
}
Here's a summary of the
Collector
interface. For more details, please check this previous post.supplier()
: Supply the base object to start fromaccumulator()
: Describe how to accumulate the current streamed item to the containercombiner()
: If the stream is parallel, describe how to merge themfinisher()
: If the mutable container type is not the returned type, describe how to transform the former into the lattercharacteristics()
: Provide meta-data to optimize the stream |Given this, we can implement the
Collector
accordingly:private class PriceAndRowsCollector
implements Collector<Map.Entry<Product, Integer>, PriceAndRows, PriceAndRows> {
@Override
public Supplier<PriceAndRows> supplier() {
return PriceAndRows::new; // 1
}
@Override
public BiConsumer<PriceAndRows, Map.Entry<Product, Integer>> accumulator() {
return (priceAndRows, entry) -> { // 2
var row = new CartRow(entry);
priceAndRows.price = priceAndRows.price.add(row.getRowPrice());
priceAndRows.rows.add(row);
};
}
@Override
public BinaryOperator<PriceAndRows> combiner() {
return (c1, c2) -> { // 3
c1.price = c1.price.add(c2.price);
var rows = new ArrayList<>(c1.rows);
rows.addAll(c2.rows);
return new PriceAndRows(c1.price, rows);
};
}
@Override
public Function<PriceAndRows, PriceAndRows> finisher() {
return Function.identity(); // 4
}
@Override
public Set<Characteristics> characteristics() {
return Set.of(Characteristics.IDENTITY_FINISH); // 4
}
}
PriceAndRows
.PriceAndRows
.PriceAndRows
can be combined by summing their total price and aggregating their respective rows.Designing the
Collector
is a bit involved, but using the custom collector is as easy as:var priceAndRows = cart.getProducts()
.entrySet()
.stream()
.collect(new PriceAndRowsCollector());
You can solve most use cases with one of the out-of-the-box collectors provided in the
Collectors
class. However, some require to implement a custom Collector
, e.g., when you need to collect more than a single collection or a single scalar.While it may seem complicated if you never developed one before, it's not. You only need a bit of practice. I hope this post might help you with it.
You can find the source code of this post on GitHub in Maven format.
Originally published at A Java Geek on May 2nd, 2021
Previously published at https://blog.frankel.ch/real-world-stream-collector/