简体   繁体   中英

How to use Java lambdas to collect elements in a list of a new type?

I've been struggling reading the javadocs to determine how to use lambdas to elegantly combine a list of rows of one type into a grouped-up list of another type.

I've figured out how to use the Collectors.groupingBy syntax to get the data into a Map<String, List<String>> but since the results will be used in a variety of later function calls... I'd ideally like to have these reduced to a list of objects which contain the new mapping.

Here are my data types, RowData is the source... I want to get the data combined into a list of CodesToBrands :

class RowData {
    private String id;
    private String name;

    public RowData() {

    }

    public RowData(String id, String name) {
        this.id = id;
        this.name = name;
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

class CodeToBrands {
    private String code;
    private List<String> brands = new ArrayList<>();

    public String getCode() {
        return code;
    }

    public void setCode(String code) {
        this.code = code;
    }

    public List<String> getBrands() {
        return brands;
    }

    public void addBrands(List<String> brands) {
        this.brands.addAll(brands);
    }

    public void addBrand(String brand) {
        this.brands.add(brand);
    }
}

Here's the test I'm writing to try and figure it out...

@Test
public void testMappingRows() {
    List<RowData> rows = new ArrayList<>();
    rows.add(new RowData("A", "Foo"));
    rows.add(new RowData("B", "Foo"));
    rows.add(new RowData("A", "Bar"));
    rows.add(new RowData("B", "Zoo"));
    rows.add(new RowData("C", "Elf"));

    // Groups a list of elements to a Map<String, List<String>>
    System.out.println("\nMapping the codes to a list of brands");
    Map<String, List<String>> result = rows.stream()
                    .collect(Collectors.groupingBy(RowData::getId, Collectors.mapping(RowData::getName, Collectors.toList())));

    // Show results are grouped nicely
    result.entrySet().forEach((entry) -> {
        System.out.println("Key: " + entry.getKey());
        entry.getValue().forEach((value) -> System.out.println("..Value: " + value));
    });

    /**Prints:
     * Mapping the codes to a list of brands
        Key: A
        ..Value: Foo
        ..Value: Bar
        Key: B
        ..Value: Foo
        ..Value: Zoo
        Key: C
        ..Value: Elf*/

    // How to get these as a List<CodeToBrand> objects where each CodeToBrand objects to avoid working with a Map<String, List<String>>?
    List<CodeToBrands> resultsAsNewType;
}

Can anyone provide any help in trying to get this same overall result in a easier-to-use datatype?

Thanks in advance

You could do it in one pass using Collectors.toMap :

Collection<CodeToBrands> values = rows.stream()
    .collect(Collectors.toMap(
        RowData::getId,
        rowData -> {
            CodeToBrands codeToBrands = new CodeToBrands();
            codeToBrands.setCode(rowData.getId());
            codeToBrands.addBrand(row.getName());
            return codeToBrands;
        },
        (left, right) -> {
            left.addBrands(right.getBrands());
            return left;
        }))
    .values();

Then, if you need a List instead of a Collection , simply do:

List<CodeToBrands> result = new ArrayList<>(values);

The code above could be simplified if you had a specific constructor and a merge method in the CodeToBrands class:

public CodeToBrands(String code, String brand) {
    this.code = code;
    this.brands.add(brand);
}

public CodeToBrands merge(CodeToBrands another) {
    this.brands.addAll(another.getBrands());
    return this;
}

Then, simply do:

Collection<CodeToBrands> values = rows.stream()
    .collect(Collectors.toMap(
        RowData::getId,
        rowData -> new CodeToBrands(rowData.getId(), rowData.getName()),
        CodeToBrands::merge))
    .values();

You can simply chain further operations after grouping and collect the result like so:

List<CodeToBrands> resultSet = rows.stream()
                .collect(Collectors.groupingBy(RowData::getId,
                        Collectors.mapping(RowData::getName, Collectors.toList())))
                .entrySet()
                .stream()
                .map(e -> {
                     CodeToBrands codeToBrand = new CodeToBrands();
                     codeToBrand.setCode(e.getKey());
                     codeToBrand.addBrands(e.getValue());
                     return codeToBrand;
                 }).collect(Collectors.toCollection(ArrayList::new));

This approach creates a stream over the entrySet after grouping and then simply maps each Map.Entry<String, List<String>> into a CodeToBrands instance and then finally we accumulate the elements into a list implementation.

Another approach would be using the toMap collector:

List<CodeToBrands> resultSet = rows.stream()
         .collect(Collectors.toMap(RowData::getId,
                 valueMapper -> new ArrayList<>(Collections.singletonList(valueMapper.getName())),
                 (v, v1) -> {
                     v.addAll(v1);
                     return v;
                 })).entrySet()
                    .stream()
                    .map(e -> {
                        CodeToBrands codeToBrand = new CodeToBrands();
                        codeToBrand.setCode(e.getKey());
                        codeToBrand.addBrands(e.getValue());
                        return codeToBrand;
                }).collect(Collectors.toCollection(ArrayList::new));

This approach is quite similar to the above but just "another" way to go about it. So, this specific overload of the toMap collector takes a key mapper ( RowData::getId in this case) which produces the keys for the map.

The function valueMapper -> new ArrayList<>(Collections.singletonList(valueMapper.getName())) is the value mapper which produces the map values.

Finally, the function (v, v1) -> {...} is the merge function, used to resolve collisions between values associated with the same key.

The following chained functions are the same as the first example shown.

An easy way to do it is creating a constructor in CodeToBrands using its fields:

public CodeToBrands(String code, List<String> brands) {
    this.code = code;
    this.brands = brands;
}

Then simply map each entry to a new CodeToBrands instance:

List<CodeToBrands> resultsAsNewType = result
        .entrySet()
        .stream()
        .map(e -> new CodeToBrands(e.getKey(), e.getValue()))
        .collect(Collectors.toList());

The other answers seem to be keen on first creating the original map you already have, then creating CodeToBrands in a second go.

IIUC, you wanted this in one go, which you can do by creating your own Collector . All it needs to do is collect into your target class directly, instead of creating a list first.

// the target map
Supplier<Map<String,CodeToBrands>> supplier = HashMap::new;

// for each id, you want to reuse an existing CodeToBrands,
// or create a new one if we don't have one already
BiConsumer<Map<String,CodeToBrands>,RowData> accumulator = (map, rd) -> {
    // this assumes a CodeToBrands(String id) constructor
    CodeToBrands ctb = map.computeIfAbsent(rd.getId(), CodeToBrands::new);
    ctb.addBrand(rd.getName());
};

// to complete the collector, we need to be able to combine two CodeToBrands objects
BinaryOperator<Map<String,CodeToBrands>> combiner = (map1, map2) -> {
    // add all map2 entries to map1
    for (Entry<String,CodeToBrands> entry : map2.entrySet()) {
        map1.merge(entry.getKey(), entry.getValue(), (ctb1, ctb2) -> {
            // add all ctb2 brands to ctb1 and continue collecting in there
            ctb1.addBrands(ctb2.getBrands());
            return ctb1;
        });
    }
    return map1;
};

// now, you have everything, use these for a new Collector
Collector<RowData,?,Map<String,CodeToBrands>> collector =
    Collector.of(supplier, accumulator, combiner);

// let's give it a go
System.out.println("\nMapping the codes by new collector");
Map<String,CodeToBrands> map = rows.stream().collect(collector);
map.forEach((s, ctb) -> {
    System.out.println("Key: " + s);
    ctb.getBrands().forEach((value) -> System.out.println("..Value: " + value));
});

Now if you insist on having a List instead, maybe do new ArrayList<>(map.entrySet()) , or collectingAndThen() ; but in my experience, you might as well use the Set it returns for almost all use cases.

You can boil down the code by using the lambdas directly, but I figured it be easier to follow to do it step by step.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM