在日常代码编写过程中,经常需要对数据做聚合、分组等转换。
使用原生的 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.maxBy
和Comparator.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 时使用