Stream很好,Map很酷,但答应我别用toMap


JDK 8Java 引入了让人欲罢不能的 stream 流处理,可以说已经成为了我日常开发中不可或缺的一部分。

当完成一次流处理之后需要返回一个集成对象时,已经肌肉记忆的敲下 collect(Collectors.toList()) 或者 collect(Collectors.toSet())。你可能会想,toListtoSet 都这么便捷顺手了,当又怎么能少得了 toMap() 呢。

答应我,一定打消你的这个想法,否则这将成为你噩梦的开端。

什么?你不信,没有什么比代码让人更痛彻心扉,让我们直接上代码。

让我们先准备一个用户实体类。

@Data
@AllArgsConstructor  
public class User {  
  
    private int id;  
    
    private String name;  
}

假设有这么一个场景,你从数据库读取 User 集合,你需要将其转为 Map 结构数据,keyvalue 分别为 useridname

很快,你啪的一下就写出了下面的代码:

public class UserTest {
    @Test
    public void demo() {  
        List<User> userList = new ArrayList<>();
        // 模拟数据
        userList.add(new User(1, "Alex"));  
        userList.add(new User(1, "Beth"));

        Map<Integer, String> map = userList.stream()  
                .collect(Collectors.toMap(User::getId, User::getName));  
        System.out.println(map);  
    }
}

运行程序,你已经想好了开始怎么摸鱼,结果啪的一下 IllegalStateException 报错就拍你脸上,你定睛一看怎么提示 Key 值重复。

作为优秀的八股文选手,你清楚的记得 HashMap 对象 Key 重复是进行替换。你不信邪,断点一打,堆栈一看,硕大的 uniqKeys 摆在了面前,凭借四级 424 分的优秀战绩你顿时菊花一紧,点开一看,谁家好人 map key 还要去重判断啊。

好好好,这么玩是吧,你转身打开浏览器一搜,原来需要自己手动处理重复场景,啪的一下你又重新改了一下代码:

public class UserTest {
    @Test
    public void demo() {  
        List<User> userList = new ArrayList<>();
        // 模拟数据
        userList.add(new User(1, "Alex"));  
        userList.add(new User(2, null));
        
        Map<Integer, String> map = userList.stream()  
                .collect(Collectors.toMap(User::getId, User::getName, (oldData, newData) -> newData));  
        System.out.println(map);  
    }
}

再次执行程序,你似乎已经看到知乎的摸鱼贴在向你招手了,结果啪的一下 NPE 又拍在你那笑容渐渐消失的脸上。

静下心来,本着什么大风大浪我没见过的心态,断点堆栈一气呵成,而下一秒你又望着代码陷入了沉思,我是谁?我在干什么?

鼓起勇气,你还不信今天就过不去这个坎了,大手一挥,又一段优雅的代码孕育而生。

public class UserTest {
    @Test
    public void demo() {  
        List<User> userList = new ArrayList<>();
        // 模拟数据
        userList.add(new User(1, "Alex"));  
        userList.add(new User(1, "Beth"));
        userList.add(new User(2, null));
        
        Map<Integer, String> map = userList.stream()  
                .collect(Collectors.toMap(  
                    User::getId,  
                    it -> Optional.ofNullable(it.getName()).orElse(""),  
                    (oldData, newData) -> newData)  
                );
        System.out.println(map);  
    }
}

优雅,真是太优雅了,又是 Stream 又是 Optional,可谓是狠狠拿捏技术博文的 G 点了。

这时候你回头一看,我需要是什么来着?这 TM 不是一个循环就万事大吉了吗,不信邪的你回归初心,回归了 for 循环的怀抱,又写了一版。

public class UserTest {
    @Test
    public void demo() {  
        List<User> userList = new ArrayList<>();
        // 模拟数据
        userList.add(new User(1, "Alex"));  
        userList.add(new User(1, "Beth"));
        userList.add(new User(2, null));
  
        Map<Integer, String> map = new HashMap<>();  
        userList.forEach(it -> {  
            map.put(it.getId(), it.getName());  
        });  
        System.out.println(map);
    }
}

看着运行完美无缺的代码,你一时陷入了沉思,数分钟过去了,你删除了 for 循环,换上 StreamOptional 不羁的外衣,安心的提交了代码,这口细糠一定也要让好同事去尝一尝。

让我们回到 HashMap 的身上,作为八股十级选手而言的你,什么数据结构红黑树可谓信手拈来,但我们今天不谈八股,只聊聊背后的一些设计理念。

众所周知,在 HashMap 中有且仅允许存在一个 keynull 的元素,当 key 已存在默认的策略是进行覆盖,比如下面的示例最终 map 的值即 {null=2}

Map<Integer, Integer> map = new HashMap<>();  
map.put(null, 1);  
map.put(null, 2);  
System.out.println(map);

同时 HashMap 对于 value 的值并没有额外限制,只要你愿意,你甚至可以放几百万 value 为空的元素像下面这个例子:

Map<Integer, Integer> map = new HashMap<>();  
map.put(1, null);
map.put(2, null);
map.put(3, null);
map.put(4, null);
map.put(5, null);
System.out.println(map);

这也就引出了今天的重点!

stream 中使用 Collectors.toMap() 时,如果你不注意还是按照惯性思维那么它就会让你感受一下什么叫做暴击。就像上一篇文章提到的其异常触发机制,但却并不知道为什么要这么设计?

作为网络冲浪小能手,我反手就是在 stackoverflow 发了提问,咱虽然笨但主打一个好学。

值得一提的是,评论区有个老哥回复的有点意思,他的回复如下:

用我三脚猫的英语水平翻译一下,大概意思如下:

因为人家 toMap() 并没有说返回的是 HashMap,所以你凭什么想要人家遵循跟 HashMap 一样的规则呢?

我滴个乖乖,他讲的似乎好有道理的样子。

我一开始也差点信了,但其实你认真看 toMap() 的内部实现,你会发现其返回的不偏不倚正好就是 HashMap

如果你还不信,以上篇文章的代码为例,执行后获取其类型可以看到输出就是 HashMap

这时候我的 CPU 又烧了,这还是我认识的 HashMap,怎么开始跟 stream 混之后就开始六亲不认了,是谁说的代码永远不会变心的?

一切彷佛又回到了起点,为什么在新的 stream 中不遵循大家已经熟悉规范,而是要改变习惯对此做出限制?

stackoverflow 上另外的一个老哥给出的他的意见:

让我这个四级 751 分老手再给大家做个免费翻译官简化一下观点:

Collectors.toMap() 的文档中已经标注其并不保证返回 Map 的具体类型,以及是否可变、序列化性以及是否线程安全,而 JDK 拥有众多的版本,可能在你的环境已经平稳运行了数年,但换个环境之后在不同的 JDK 下可能程序就发生了崩溃。因此,这些额外的保障实际上还帮了你的忙。

回头去看 toMap() 方法上的文档说明,确实也像这位老哥提到的那样。

而在 HashMap 中允许 KeyValue 为空带来的一个问题在此时也浮现了出来,当存入一个 value 为空的元素时,再后续执行 get() 再次读取时,存在一个问题那就是二义性。

很显然执行 get() 返回的结果将为空,那这个空究竟是 Map 中不存在这个元素?还是我存入的元素其 value 为空?这一点我想只有老天爷知道,而这种二义性所带来的问题在设计层面显然是一个失误。

那么到这里,我们就可以得到一个暴论:HashMap 允许 key 和 value 为空就是 JDK 留下的“屎山”!

为了验证这一结论,我们可以看看在新的 ConcurrentHashMapJDK 是怎么做的?查看源码可以看到,在 put() 方法的一开始就执行了 keyvalue 的空值校验,也验证了上面的猜想。

这还原不够支撑我们的结论,让我们继续深挖这背后还有什么猫腻。

首先让我看看是谁写的 ConcurrentHashMap,在 openjdkGitHub 仓库类文档注释可以看到主要的开发者是 Doug Lea

Doug Lea 又是何方大佬,通过维基百科的可以看到其早期是 Java 并发社区的主席,他参与了一众的 JDK 并发设计工作,可谓吾辈偶像。

在网络搜罗相关的资讯找到对应的话题,虽然图中的链接已经不存在了,但还是能从引用的内容看出其核心的原因正是为了规避的结果的模糊性,与前文我们讨论的二义性不尽相同。

那为什么 JDK 不同步更新 HashMap 的设计理念,在新版 HashMap 中引入 keyvalue 的非空校验?

我想剩下的理由只有一个:HashMap 的使用范围实在太广,就算是 JDK 自己也很难在不变更原有结构的基础上进行改动,而在 JDK 1.2 便被提出并广泛应用,对于一个发展了数十年的语言而言,兼容性是十分重要的一大考量。

因此,我们可以看到,在后续推出的 Map 中,往往对 keyValue 都作了进一步的限制,而对于 HashMap 而言,可能 JDK 官方也是有心无力吧。

到这里基本也就盖棺定论了,但本着严谨的态度大胆假设小心求证,让我们再来看看大家伙的意见,万一不小心就被人网暴了。

stackoverflow 上一篇相关的回答中看到,许多人都认为 HashMap 支持空值是一个存在缺陷的设计。

感兴趣的小伙伴可以去原帖查看,这里我就不再展开介绍了,原帖链接:Why does Map.of not allow null keys and values?

看到这里,下次别人或者老板再说你写的代码是屎山的时候,请昂首挺胸自信的告诉他 JDk 都会犯错,我写的这点又算得了什么?


文章作者: 烽火戏诸诸诸侯
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 烽火戏诸诸诸侯 !
  目录