19

Suppose I have a group of bumper cars, which have a size, a color and an identifier ("car code") on their sides.

class BumperCar {
    int size;
    String color;
    String carCode;
}

Now I need to map the bumper cars to a List of DistGroup objects, which each contains the properties size, color and a List of car codes.

class DistGroup {
    int size;
    Color color;
    List<String> carCodes;

    void addCarCodes(List<String> carCodes) {
        this.carCodes.addAll(carCodes);
    }
}

For example,

[
    BumperCar(size=3, color=yellow, carCode=Q4M),
    BumperCar(size=3, color=yellow, carCode=T5A),
    BumperCar(size=3, color=red, carCode=6NR)
]

should result in:

[
    DistGroup(size=3, color=yellow, carCodes=[ Q4M, T5A ]),
    DistGroup(size=3, color=red, carCodes=[ 6NR ])
]

I tried the following, which actually does what I want it to do. But the problem is that it materializes the intermediate result (into a Map) and I also think that it can be done at once (perhaps using mapping or collectingAndThen or reducing or something), resulting in more elegant code.

List<BumperCar> bumperCars = …;
Map<SizeColorCombination, List<BumperCar>> map = bumperCars.stream()
    .collect(groupingBy(t -> new SizeColorCombination(t.getSize(), t.getColor())));

List<DistGroup> distGroups = map.entrySet().stream()
    .map(t -> {
        DistGroup d = new DistGroup(t.getKey().getSize(), t.getKey().getColor());
        d.addCarCodes(t.getValue().stream()
            .map(BumperCar::getCarCode)
            .collect(toList()));
        return d;
    })
    .collect(toList());

How can I get the desired result without using a variable for an intermediate result?

Edit: How can I get the desired result without materializing the intermediate result? I am merely looking for a way which does not materialize the intermediate result, at least not on the surface. That means that I prefer not to use something like this:

something.stream()
    .collect(…) // Materializing
    .stream()
    .collect(…); // Materializing second time

Of course, if this is possible.


Note that I omitted getters and constructors for brevity. You may also assume that equals and hashCode methods are properly implemented. Also note that I'm using the SizeColorCombination which I use as group-by key. This class obviously contains the properties size and color. Classes like Tuple, Pair, Entry or any other class representing a combination of two arbitrary values may also be used.
Edit: Also note that an ol' skool for loop can be used instead, of course, but that is not in the scope of this question.

MC Emperor
  • 22,334
  • 15
  • 80
  • 130
  • 2
    Just as a side note, `groupingBy()` does group the values into a `List` by default so `toList()` may be omitted – Lino Jan 18 '19 at 12:35
  • 1
    The idea of using streams is to make code more readable, more self-explanatory (at the cost of performance), or massively parallelizable without boilerplate code. It's not a modern one-solution-fits-all replacement of the old ways. The code you provided is cryptic at best. I suggest using a classic for-loop which is much cleaner in this case. – Mark Jeronimus Jan 18 '19 at 12:37
  • @Lino You're right. I removed it. – MC Emperor Jan 18 '19 at 12:53
  • @MarkJeronimus That's right, that's why I'm not satisfied with my current solution and looking for an elegant way to achieve just the same result—if it exists. Otherwise I will gladly revert to the classic loop. – MC Emperor Jan 18 '19 at 12:58

5 Answers5

8

If we assume that DistGroup has hashCode/equals based on size and color, you could do it like this:

bumperCars
    .stream()
    .map(x -> {
        List<String> list = new ArrayList<>();
        list.add(x.getCarCode());
        return new SimpleEntry<>(x, list);
    })
    .map(x -> new DistGroup(x.getKey().getSize(), x.getKey().getColor(), x.getValue()))
    .collect(Collectors.toMap(
        Function.identity(),
        Function.identity(),
        (left, right) -> {
            left.getCarCodes().addAll(right.getCarCodes());
            return left;
        }))
    .values(); // Collection<DistGroup>
Naman
  • 27,789
  • 26
  • 218
  • 353
Eugene
  • 117,005
  • 15
  • 201
  • 306
  • Thanks, this code works for me. Note that I, after using this code, have tweaked the code a little, so the bumper cars are directly mapped to a `DistGroup` using `.map(t -> new DistGroup(t.getSize(), t.getColor(), new ArrayList<>(Arrays.asList(t.getCarCode()))))`. Then I collect it with `toMap` with the same arguments as your code, but with the first argument being `t -> new SimpleEntry<>(t.getColor(), t.getSize())` instead of `Function.identity()`. – MC Emperor Jan 21 '19 at 09:04
2

Solution-1

Just merging the two steps into one:

List<DistGroup> distGroups = bumperCars.stream()
        .collect(Collectors.groupingBy(t -> new SizeColorCombination(t.getSize(), t.getColor())))
        .entrySet().stream()
        .map(t -> {
            DistGroup d = new DistGroup(t.getKey().getSize(), t.getKey().getColor());
            d.addCarCodes(t.getValue().stream().map(BumperCar::getCarCode).collect(Collectors.toList()));
            return d;
        })
        .collect(Collectors.toList());

Solution-2

Your intermediate variable would be much better if you could use groupingBy twice using both the attributes and map the values as List of codes, something like:

Map<Integer, Map<String, List<String>>> sizeGroupedData = bumperCars.stream()
        .collect(Collectors.groupingBy(BumperCar::getSize,
                Collectors.groupingBy(BumperCar::getColor,
                        Collectors.mapping(BumperCar::getCarCode, Collectors.toList()))));

and simply use forEach to add to the final list as:

List<DistGroup> distGroups = new ArrayList<>();
sizeGroupedData.forEach((size, colorGrouped) ->
        colorGrouped.forEach((color, carCodes) -> distGroups.add(new DistGroup(size, color, carCodes))));

Note: I've updated your constructor such that it accepts the card codes list.

DistGroup(int size, String color, List<String> carCodes) {
    this.size = size;
    this.color = color;
    addCarCodes(carCodes);
}

Further combining the second solution into one complete statement(though I would myself favor the forEach honestly):

List<DistGroup> distGroups = bumperCars.stream()
        .collect(Collectors.groupingBy(BumperCar::getSize,
                Collectors.groupingBy(BumperCar::getColor,
                        Collectors.mapping(BumperCar::getCarCode, Collectors.toList()))))
        .entrySet()
        .stream()
        .flatMap(a -> a.getValue().entrySet()
                .stream().map(b -> new DistGroup(a.getKey(), b.getKey(), b.getValue())))
        .collect(Collectors.toList());
Naman
  • 27,789
  • 26
  • 218
  • 353
0

You can collect by by using BiConsumer that take (HashMap<SizeColorCombination, DistGroup> res, BumperCar bc) as parameters

Collection<DistGroup> values = bumperCars.stream()
        .collect(HashMap::new, (HashMap<SizeColorCombination, DistGroup> res, BumperCar bc) -> {
                SizeColorCombination dg = new SizeColorCombination(bc.color, bc.size);
                DistGroup distGroup = res.get(dg);
                if(distGroup != null) {
                    distGroup.addCarCode(bc.carCode);
                }else {
                    List<String> codes = new ArrayList();
                    distGroup = new DistGroup(bc.size, bc.color, codes);
                    res.put(dg, distGroup);
                }
                },HashMap::putAll).values();
SEY_91
  • 1,615
  • 15
  • 26
0

Check out my library AbacusUtil:

StreamEx.of(bumperCars)
         .groupBy(c -> Tuple.of(c.getSize(), c.getColor()), BumperCar::getCarCode)
         .map(e -> new DistGroup(e.getKey()._1, e.getKey()._2, e.getValue())
         .toList();
123-xyz
  • 619
  • 4
  • 5
0

I came up with another solution.

First, we tweak the DistGroup class a little:

class DistGroup {

    private int size;

    private String color;

    private List<String> carCodes;

    private DistGroup(int size, String color, List<String> carCodes) {
        this.size = size;
        this.color = color;
        this.carCodes = carCodes;
    }

    // So we can use a method reference nicely
    public static DistGroup ofBumperCar(BumperCar car) {
        return new DistGroup(car.size(), car.color(), new ArrayList<>(Arrays.asList(car.carCode())));
    }

    public DistGroup merge(DistGroup other) {
        assert size == other.size;
        assert Objects.equals(color, other.color);

        this.carCodes.addAll(other.carCodes);
        return this;
    }
}

Note that I removed the addCarCodes method, since it was not needed.

Now we can actually get the desired result quite easily utilizing the Collectors::toMap method.

Collection<DistGroup> dists = cars.stream()
    .collect(Collectors.toMap(
        car -> new SizeColorGroup(car.size(), car.color()),
        DistGroup::ofBumperCar,
        DistGroup::merge
    ))
    .values();
MC Emperor
  • 22,334
  • 15
  • 80
  • 130