Java toMap – 对 list 分组聚合

这是我参与新手入门的第3篇文章。

在使用数据库的时候,我们经常对数据分组求和等操作。在 Java 中有 list 这样的数据结构,非常类似数据表记录中的一行。因此,也有很多场景会对 list 分组聚合。

准备基础数据

图怪兽_2a493bf78b7262972ce0eb36177c94f4_17428.jpg

首先定义一个 Book 对象,这里有几个简单的属性。为了简化代码,用到了 lombok 注解 @Data ,他会生成 setters/getters 等方法,还有一个全参数构造器 @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(427, "十年踪迹", "写给普通人看的网页开发课", 9.9),
            new Book(110, "阿面", "Uniapp 从入门到进阶", 29.9),
            new Book(188, "十年踪迹", "前端工程师进阶 10 日谈", 29.9),
            new Book(99, "程序员十三", "Vue 商城项目开发实战", 49)
    );
复制代码

原生 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));
复制代码

打印的结果:

{
	427: {
		"author": "十年踪迹",
		"id": 427,
		"name": "写给普通人看的网页开发课",
		"price": 9.9
	},
	99: {
		"author": "程序员十三",
		"id": 99,
		"name": "Vue 商城项目开发实战",
		"price": 49.0
	},
	188: {
		"author": "十年踪迹",
		"id": 188,
		"name": "前端工程师进阶 10 日谈",
		"price": 29.9
	},
	110: {
		"author": "阿面",
		"id": 110,
		"name": "Uniapp 从入门到进阶",
		"price": 29.9
	}
}
复制代码

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 含义相同。

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

{
	427: {
		"author": "十年踪迹",
		"id": 427,
		"name": "写给普通人看的网页开发课",
		"price": 9.9
	},
	99: {
		"author": "程序员十三",
		"id": 99,
		"name": "Vue 商城项目开发实战",
		"price": 49.0
	},
	188: {
		"author": "十年踪迹",
		"id": 188,
		"name": "前端工程师进阶 10 日谈",
		"price": 29.9
	},
	110: {
		"author": "阿面",
		"id": 110,
		"name": "Uniapp 从入门到进阶",
		"price": 29.9
	}
}
复制代码

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=427, author=十年踪迹, name=写给普通人看的网页开发课, price=9.9)
	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": "Uniapp 从入门到进阶",
        "price": 29.9
    },
    "程序员十三": {
        "author": "程序员十三",
        "id": 99,
        "name": "Vue 商城项目开发实战",
        "price": 49
    },
    "十年踪迹": {
        "author": "十年踪迹",
        "id": 188,
        "name": "前端工程师进阶 10 日谈",
        "price": 29.9
    }
}
复制代码

效果能达到,但是经过测试发现,当 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 方法可以增加程序健壮性,避免非必要的程序异常。

数据分组

有时候,我们希望对 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));
复制代码

得到的结果:

{
    "阿面": "Uniapp 从入门到进阶",
    "程序员十三": "Vue 商城项目开发实战",
    "十年踪迹": "写给普通人看的网页开发课;前端工程师进阶 10 日谈"
}
复制代码

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

每本书的价格

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

得到的结果:

{
    "前端工程师进阶 10 日谈": 29.9,
    "Vue 商城项目开发实战": 49,
    "写给普通人看的网页开发课": 9.9,
    "Uniapp 从入门到进阶": 29.9
}
复制代码

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

每位作者著作的价格

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

得到的结果:

{
    "阿面": 29.9,
    "程序员十三": 49,
    "十年踪迹": 39.8
}
复制代码

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

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

        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));
复制代码

得到的结果:

{
    "阿面": 29.9,
    "程序员十三": 49,
    "十年踪迹": 29.9
}
复制代码

在 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": "Uniapp 从入门到进阶",
            "price": 29.9
        }
    ],
    "程序员十三": [
        {
            "author": "程序员十三",
            "id": 99,
            "name": "Vue 商城项目开发实战",
            "price": 49
        }
    ],
    "十年踪迹": [
        {
            "author": "十年踪迹",
            "id": 427,
            "name": "写给普通人看的网页开发课",
            "price": 9.9
        },
        {
            "author": "十年踪迹",
            "id": 188,
            "name": "前端工程师进阶 10 日谈",
            "price": 29.9
        }
    ]
}
复制代码

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

总结

图怪兽_fab244a4c6fe23c4a3caf625655c36c9_98154.jpg
回顾一下,完整的 toMap 参数含义:

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

有些需求,如果用 SQL 写,我们可以很快给出答案。但是如果原始数据 list 已经提供,用程序来控制有时候也很容易。如果能多从不同的数据结构如 list 与 map 转换来看待问题,说不定只需要一行代码就可以解决棘手的问题。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享