Java 使用 Collectors.toMap() 方法将 list 转 map

在日常代码编写过程中,经常需要对数据做聚合、分组等转换。

使用原生的 Java 代码写起来比较冗长,在 Java 8 引入的 Collectors.toMap() 可以方便地进行 list 与 map 之间进行转换。

数据初始化

首先定义一个 Book 对象,这里有几个简单的属性。为了简化代码,用到了 lombok 注解 @Data ,再加一个全参数构造器 @AllArgsConstructor

package com.mapull.demo.entity;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class Book {
    /**
     * 序号
     */
    private Integer id;
    /**
     * 作者
     */
    private String author;
    /**
     * 书名
     */
    private String name;
    /**
     * 价格
     */
    private double price;
}

这里主要是为了将 list 转换为 map,因此先初始化一个 list 。

    static List<Book> list = Arrays.asList(
            new Book(200, "大冰", "摸摸头", 32.80),
            new Book(110, "大冰", "好吗,好的", 39.50),
            new Book(41, "天下霸唱", "鬼吹灯", 88.20),
            new Book(99, "番茄", "盘龙", 59.60)
    );

原生 Java 转换代码

使用通俗的方式将 list 转换为 map,并将结果使用 fastjson 打印到控制台。

        Map<Integer, Book> map = new HashMap<>(3);
        for(Book book: list){
            map.put(book.getId(), book);
        }
        System.out.println(JSON.toJSONString(map));

打印的结果:

{
    200: {
        "author": "大冰",
        "id": 200,
        "name": "摸摸头",
        "price": 32.8
    },
    41: {
        "author": "天下霸唱",
        "id": 41,
        "name": "鬼吹灯",
        "price": 88.2
    },
    99: {
        "author": "番茄",
        "id": 99,
        "name": "盘龙",
        "price": 59.6
    },
    110: {
        "author": "大冰",
        "id": 110,
        "name": "好吗,好的",
        "price": 39.5
    }
}

Java 8 链式编程

在 Java 8 的 Collectors 中有 toMap 方法,方法签名:

public static <T, K, U> Collector<T, ?, Map<K,U>> toMap(Function<? super T, ? extends K> keyMapper,Function<? super T, ? extends U> valueMapper) {
        return toMap(keyMapper, valueMapper, throwingMerger(), HashMap::new);
}

看起来非常复杂,入参是两个 Function,分别代表 map 的 key 和 value 的生成策略。

Java 8 的 stream 流改写上面的代码

        Map<Integer, Book> collect = list.stream().collect(Collectors.toMap(Book::getId, book -> book));
        System.out.println(JSON.toJSONString(collect));

改写后,有效代码只有一行。

实际上,由于上面的场景太常见,大多数场景都是取对象中的某一个值作为 key ,整个对象作为 value 。java 8 还提供了一个函数指代自身。

于是再次改写上面的代码:

        Map<Integer, Book> collect = list.stream().collect(Collectors.toMap(Book::getId, Function.identity()));
        System.out.println(JSON.toJSONString(collect));

这里的 Function.identity()book -> book 含义相同。

控制台打印的结果,也是和上面相同的。

{
    99: {
        "author": "番茄",
        "id": 99,
        "name": "盘龙",
        "price": 59.6
    },
    200: {
        "author": "大冰",
        "id": 200,
        "name": "摸摸头",
        "price": 32.8
    },
    41: {
        "author": "天下霸唱",
        "id": 41,
        "name": "鬼吹灯",
        "price": 88.2
    },
    110: {
        "author": "大冰",
        "id": 110,
        "name": "好吗,好的",
        "price": 39.5
    }
}

Duplicate key xx 异常

上面是通过 id 来对 book 分组,在控制台能看到正确的结果。

下面我们通过作者 author 字段进行分组,可以很容易地写出如下代码:

        Map<String, Book> collect = list.stream().collect(Collectors.toMap(Book::getAuthor, Function.identity()));
        System.out.println(JSON.toJSONString(collect));

仅仅将 getId 换成了 getAuthor,运行后发现并没有如期打印正确结果,而是报错了:

    Exception in thread "main" java.lang.IllegalStateException: Duplicate key Book(id=200, author=大冰, name=摸摸头, price=32.8)
    at java.util.stream.Collectors.lambda$throwingMerger$0(Collectors.java:133)

通过查阅java 源代码发现:

toMap 的第三个参数调用了throwingMerger()方法,这个方法在干什么呢?

通过方法的注释,可以看出,这个方法的作用是在遇到 map 的 key 冲突时,如何解决冲突。

该方法的默认实现如下:

    private static <T> BinaryOperator<T> throwingMerger() {
    return (u,v) -> { throw new IllegalStateException(String.format("Duplicate key %s", u)); };

默认情况下,java 不知道该如何处理这种数据,于是直接抛出异常 “Duplicate key “。

java 提供了下面的方法,便于我们在出现 key 冲突时,自定义处理逻辑。

public static <T, K, U> Collector<T, ?, Map<K,U>> toMap(Function<? super T, ? extends K> keyMapper,Function<? super T, ? extends U> valueMapper,BinaryOperator<U> mergeFunction) {
    return toMap(keyMapper, valueMapper, mergeFunction, HashMap::new);
}

如果在遇到 key 冲突时,将旧值丢弃,存入新值,就可以这样写:

        Map<String, Book> collect = list.stream().collect(Collectors.toMap(Book::getAuthor, Function.identity(), (oldValue, newValue) -> newValue));
        System.out.println(JSON.toJSONString(collect));

(oldValue, newValue) -> newValue 相当于重写了 mergeFunction

控制台打印的结果:

{
    "大冰": {
        "author": "大冰",
        "id": 110,
        "name": "好吗,好的",
        "price": 39.5
    },
    "番茄": {
        "author": "番茄",
        "id": 99,
        "name": "盘龙",
        "price": 59.6
    },
    "天下霸唱": {
        "author": "天下霸唱",
        "id": 41,
        "name": "鬼吹灯",
        "price": 88.2
    }
}

效果能达到,但是经过测试发现,当 list 中数据顺便变化时,得到的结果不一致。

原因是,在我们重写 mergeFunction 时,新值和旧值不确定,第一次取到的值是旧值,之后冲突时处理的值是新值。

那有啥办法可以控制每次返回的结果都可控呢,我们可以定一个规则:id 大的值留下,id 小的值舍弃。于是,可以

        Map<String, Book> collect = list.stream().collect(Collectors.toMap(Book::getAuthor, Function.identity(), (oldValue, newValue) -> newValue.getId() > oldValue.getId() ? newValue : oldValue));
        System.out.println(JSON.toJSONString(collect));

试验发现,无论怎么改动 list 中数据顺序,输出的结果都是相同的。

当然效果是达到了,但是代码着实有点丑,长长的三元表达式 (oldValue, newValue) -> newValue.getId() > oldValue.getId() ? newValue : oldValue 很不利于程序维护。

为此,可以将代码做如下改动:

        Map<String, Book> collect = list.stream().collect(Collectors.toMap(Book::getAuthor, Function.identity(), BinaryOperator.maxBy(Comparator.comparingInt(Book::getId))));
        System.out.println(JSON.toJSONString(collect));

这里用到了BinaryOperator.maxByComparator.comparingInt使得程序可读性高了很多。

在项目实际使用时,非常建议重写这个 merge 方法,因为很难从数据角度控制 key 不重复,已定义 merge 方法可以增加程序健壮性,避免非必要的程序异常。

转换为 其他 Map 实现

实际上, toMap 有四个参数,最后一个参数可以用来指定生成的 Map 是哪种实现。

默认的 toMap 使用到了 HashMap ,这是最常用的 Map 实现。不过我们应该要知道,Map 还有其他实现形式,如果 TreeMap 。

JDK 源码的方法签名:

public static <T, K, U, M extends Map<K, U>> Collector<T, ?, M> toMap(Function<? super T, ? extends K> keyMapper,Function<? super T, ? extends U> valueMapper,BinaryOperator<U> mergeFunction,Supplier<M> mapSupplier) {
        BiConsumer<M, T> accumulator
                = (map, element) -> map.merge(keyMapper.apply(element),
                                              valueMapper.apply(element), mergeFunction);
        return new CollectorImpl<>(mapSupplier, accumulator, mapMerger(mergeFunction), CH_ID);
}

如果要得到 TreeMap 的结构,比如想得到 key 以 id 排序的 Map 结构:

        Map<Integer, Book> collect = list.stream().collect(Collectors.toMap(Book::getId, Function.identity(), (oldValue, newValue) -> newValue, TreeMap::new));
        System.out.println(JSON.toJSONString(collect));

控制台结果:

{
    41: {
        "author": "天下霸唱",
        "id": 41,
        "name": "鬼吹灯",
        "price": 88.2
    },
    99: {
        "author": "番茄",
        "id": 99,
        "name": "盘龙",
        "price": 59.6
    },
    110: {
        "author": "大冰",
        "id": 110,
        "name": "好吗,好的",
        "price": 39.5
    },
    200: {
        "author": "大冰",
        "id": 200,
        "name": "摸摸头",
        "price": 32.8
    }
}

可以看到,返回的结果是通过 id 排序的,而实际代码里我们并没有写排序逻辑。

线程安全的 Map 方法 toConcurrentMap

上面已经提到了,toMap 的第四个参数可以指定构造的 Map 类型,默认得到的 HashMap。

在很多场景下,我们需要线程安全的 ConcurrentHashMap,在 Collectors 中提供了一个专门用来构建线程安全的 Map 的方法 toConcurrentMap

该方法使用可和 toMap 类似,也有多个重载方法,用于应对不同的场景。

        Map<Integer, Book> collect = list.stream().collect(Collectors.toConcurrentMap(Book::getId, Function.identity()));
        System.out.println(JSON.toJSONString(collect));

实际得到的结果,和 toMap 相同:

{
    99: {
        "author": "番茄",
        "id": 99,
        "name": "盘龙",
        "price": 59.6
    },
    200: {
        "author": "大冰",
        "id": 200,
        "name": "摸摸头",
        "price": 32.8
    },
    41: {
        "author": "天下霸唱",
        "id": 41,
        "name": "鬼吹灯",
        "price": 88.2
    },
    110: {
        "author": "大冰",
        "id": 110,
        "name": "好吗,好的",
        "price": 39.5
    }
}

数据分组

有时候,我们希望对 list 中数据简单分组,用 toMap 可以方便地实现。

每位作者的书名

        Map<String, String> collect = list.stream().collect(Collectors.toMap(Book::getAuthor, Book::getName, (oldValue, newValue) -> oldValue + ";" + newValue));
        System.out.println(JSON.toJSONString(collect));

得到的结果:

{
    "大冰": "好吗,好的; 摸摸头",
    "番茄": "盘龙",
    "天下霸唱": "鬼吹灯"
}

因为有的作者有多本书,书名中间用 分号 隔开。

每本书的价格

        Map<String, Double> collect = list.stream().collect(Collectors.toMap(Book::getName, Book::getPrice));
        System.out.println(JSON.toJSONString(collect));

得到的结果:

{
    "摸摸头": 32.8,
    "鬼吹灯": 88.2,
    "好吗,好的": 39.5,
    "盘龙": 59.6
}

上面已经可以清晰地显示每本书名以及对应的价格。

每位作者著作的价格

        Map<String, Double> collect = list.stream().collect(Collectors.toMap(Book::getAuthor, Book::getPrice, (oldValue, newValue) -> oldValue + newValue));
        System.out.println(JSON.toJSONString(collect));

得到的结果:

{
    "大冰": 72.3,
    "番茄": 59.6,
    "天下霸唱": 88.2
}

上面的代码,我们使用了加法运算符对两个数求和。很多时候,这样的代码是表达不清晰的。因为两个字符串也可以用 + 来拼接。

我们可以用下面的代码来让代码含义更清晰:

        Map<String, Double> collect = list.stream().collect(Collectors.toMap(Book::getAuthor, Book::getPrice, Double::sum));
        System.out.println(JSON.toJSONString(collect));

Double::sum 更便于理解这块代码逻辑。

每位作者著作中定价最高的那本书

获取每组数据中最大的一条,相当于分组,排序,取最大值。在 MySQL 中,写出这个 sql 都得好大一会儿工夫。

        Map<String, Double> collect = list.stream().collect(Collectors.toMap(Book::getAuthor, Book::getPrice, (oldValue, newValue) -> newValue > oldValue ? newValue : oldValue));
        System.out.println(JSON.toJSONString(collect));

得到的结果:

{
    "大冰": 39.5,
    "番茄": 59.6,
    "天下霸唱": 88.2
}

在 java 中,只用了一行代码,每位作者的书籍最高定价一目了然。

当然,还是那个问题,三元表达式,写的时候一时爽,之后维护排错的时候就难了,于是可以如下改造:

        Map<String, Double> collect = list.stream().collect(Collectors.toMap(Book::getAuthor, Book::getPrice, BinaryOperator.maxBy(Comparator.comparingDouble(p -> (p)))));
        System.out.println(JSON.toJSONString(collect));

上面的例子仅仅展示了使用 toMap 如何实现业务需求,实际上, Java 8 提供了很多 API 来简化代码,提高开发效率。

扩展

日常使用中,对 list 分组,我们常常也会用到 groupingBy 来做。

例如,通过作者名对数据分组:

        Map<String, List<Book>> collect = list.stream().collect(Collectors.groupingBy(Book::getAuthor));
        System.out.println(JSON.toJSONString(collect));

得到的数据:

{
    "大冰": [
        {
            "author": "大冰",
            "id": 110,
            "name": "好吗,好的",
            "price": 39.5
        },
        {
            "author": "大冰",
            "id": 200,
            "name": "摸摸头",
            "price": 32.8
        }
    ],
    "番茄": [
        {
            "author": "番茄",
            "id": 99,
            "name": "盘龙",
            "price": 59.6
        }
    ],
    "天下霸唱": [
        {
            "author": "天下霸唱",
            "id": 41,
            "name": "鬼吹灯",
            "price": 88.2
        }
    ]
}

分组返回的一个 key 对应一组数据。

总结

回顾一下,完整的 toMap 参数含义:

  • keyMapper:Key 的映射函数,用于获取 map
  • valueMapper:Value 的映射函数
  • mergeFunction:当 Key 冲突时,调用的合并方法
  • mapSupplier:Map 构造器,在需要返回特定的 Map 时使用
标签: