原创

groupingBy 不接 null 分类键:换成 HashMap 供应器也没用

异常信息写着 element cannot be mapped to a null key,结果容器又明明是HashMap,为什么groupingBy不让放空键?因为拒绝发生在收集器调用分类器以后,还没有轮到结果Map决定能不能保存null。

这也是把Map供应器改成 HashMap::new 仍然失败的原因。可定制输出容器,并不代表可以改写收集器的分类键契约。空分类需要先变成一个明确的业务状态。

下面的JDK 17样本保留两条有地区数据和一条未知地区数据。未知不是坏记录,不应在统计中被悄悄过滤。

import java.util.*;
import java.util.stream.Collectors;
public class NullGrouping {
    record Person(String region) { }
    record Bucket(boolean known, String value) {
        static Bucket of(String value) { return value == null ? new Bucket(false, "") : new Bucket(true, value); }
    }
    public static void main(String[] args) {
        List<Person> input = List.of(new Person("east"), new Person(null), new Person("east"));
        try { input.stream().collect(Collectors.groupingBy(Person::region)); throw new AssertionError(); }
        catch (NullPointerException expected) { System.out.println("default=NULL_KEY_REJECTED"); }
        try { input.stream().collect(Collectors.groupingBy(Person::region, HashMap::new, Collectors.counting())); throw new AssertionError(); }
        catch (NullPointerException expected) { System.out.println("hashmap=NULL_KEY_REJECTED"); }
        Map<Bucket,Long> result = input.stream().collect(Collectors.groupingBy(
                p -> Bucket.of(p.region()), Collectors.counting()));
        System.out.println("known=" + result.get(Bucket.of("east")));
        System.out.println("unknown=" + result.get(Bucket.of(null)));
        System.out.println("total=" + result.values().stream().mapToLong(Long::longValue).sum());
    }
}

保存为 NullGrouping.java,执行 java NullGrouping.java:

default=NULL_KEY_REJECTED
hashmap=NULL_KEY_REJECTED
known=2
unknown=1
total=3

不要把未知分类伪装成某个正常字符串

最省事的修复是把null换成UNKNOWN,但如果真实地区字段也允许字面UNKNOWN,两类数据就被合并了。空字符串同样可能是业务值,不能默认它永远闲置。

样本用一个有明确字段的Bucket表达已知与未知;Optional也可以承载是否存在。选择哪种形式取决于后续接口怎样使用,重点是哨兵不能与合法数据碰撞。Map最后若要序列化给前端,通常更适合转成包含type、value和count的记录列表,而不是依赖复杂对象键的toString作为对外协议。

若业务决定缺少地区就是无效输入,可以在分组前报告校验错误;若决定这类记录不参与统计,可以显式过滤,并同时说明分母变化。它们与“按未知地区分组”不是同一种修复,不能为了让collect不抛异常就随便选择。

null 记录与 null 字段也要分开

例子中的Person对象都存在,只是其中一项region为空。若流元素本身也可能为null,直接调用Person::region会在更早的位置失败。两个输入问题需要不同处理,不应在分类器外包一个catch所有异常都返回UNKNOWN。

这样的catch还会掩盖分类代码真正的编程错误,例如非法转换、越界或缺失配置。未分类应由明确数据状态触发,异常则保留为可诊断失败;把两者合并,结果桶数量看起来完整,原因却已经丢失。

分组以后还要决定结果可变性。Collectors没有承诺普通groupingBy返回的Map和下游List具有某个固定实现、线程安全或不可修改性。若需要稳定交付,应在边界上生成适合协议的快照,而不是强转为HashMap后依赖内部行为。

可以定制的层次,与不能靠供应器改变的层次

groupingBy(classifier, mapFactory, downstream)允许指定结果Map与每组累积方式。按计数、求和或收集列表都属于下游职责;分类键能否为空,却仍受上游分类过程约束。换TreeMap还会增加自己的键排序条件,不能解决原来的null分类。

本次程序同时运行默认和显式HashMap两条失败路径,正是为了把层次分开。修正后的计数总数为三,说明未知记录没有丢;这比“现在不报错了”更有业务意义。分组成功以后,仍应核对所有桶的计数之和是否等于约定范围内的记录数,避免一次异常修复悄悄改变统计口径。

地区报表需要区分“没填”和“写错”

再把分类桶放进一个完整调用场景:访问记录允许不提供地区,空白也按缺失处理;east与west是唯一合法代码,大小写和两端空白可以被规范化。拼错的eats必须失败,不能也进入缺失桶。

这个区分能避免报表悄悄掩盖上游协议故障。缺失率上升与非法代码上升,通常需要不同处理;一律把任何无法识别的东西记成unknown,会让真正的格式回归消失在一个正常桶里。

保存为RegionReport.java,用JDK 17执行java RegionReport.java:

import java.util.*;
import java.util.stream.Collectors;
public class RegionReport {
    enum Region { EAST, WEST, MISSING }
    record Visit(long id, String rawRegion) { }
    static Region classify(Visit visit) {
        Objects.requireNonNull(visit, "visit");
        if (visit.rawRegion() == null || visit.rawRegion().isBlank()) return Region.MISSING;
        return switch (visit.rawRegion().strip().toLowerCase(Locale.ROOT)) {
            case "east" -> Region.EAST;
            case "west" -> Region.WEST;
            default -> throw new IllegalArgumentException("unknown region code");
        };
    }
    static Map<Region,Long> report(List<Visit> input) {
        return input.stream().collect(Collectors.groupingBy(RegionReport::classify,
                () -> new EnumMap<>(Region.class), Collectors.counting()));
    }
    public static void main(String[] args) {
        List<Visit> input = List.of(new Visit(1, "east"), new Visit(2, " EAST "),
                new Visit(3, ""), new Visit(4, null), new Visit(5, "west"));
        Map<Region,Long> counts = report(input);
        for (Region region : Region.values()) System.out.println(region + "=" + counts.getOrDefault(region, 0L));
        System.out.println("total=" + counts.values().stream().mapToLong(Long::longValue).sum());
        try { report(List.of(new Visit(6, "eats"))); throw new AssertionError(); }
        catch (IllegalArgumentException expected) { System.out.println("bad_code=REJECTED"); }
    }
}

输出:

EAST=2
WEST=1
MISSING=2
total=5
bad_code=REJECTED

分类函数保证成功返回非null枚举,EnumMap按这份明确模型存储结果。打印时遍历Region.values,而不依赖HashMap的迭代顺序,并用getOrDefault为没有观测的已知桶补零。零不是凭空新增数据,而是报表预先定义的展示范围。

原始记录中两条east、一个west、两条缺失,总计五。非法代码路径独立拒绝,不交付一份看似完整的部分报表。若产品需要逐项容错,返回值就应同时带成功统计和失败记录清单,不能只捕获异常继续计数却不告诉调用者少了几条。

计数之前先命名记录单位

这里统计的是访问记录数,不是独立用户数。Visit.id表示每条记录,代码没有按用户去重。若后来需求改成独立用户的地区分布,需要先决定同一用户多份地区信息冲突时以哪份为准,再按身份去重,不能仅把counting换成一个Set就认为口径已一致。

报告中缺失也属于输入范围,所以所有桶相加应等于本次成功接收的输入记录数。若主动过滤缺失,分母就变了,应在结果元数据中说明。这个校验与null异常修复没有直接语法关系,却能防止一次“让分组能跑”的改动改变报表含义。

扩展地区字典,要改的是协议而不只是集合

新增合法地区时,可以扩展枚举或引入有版本的地区字典,并一起调整展示桶与历史数据处理。不要让任意外部字符串直接成为无限动态标签,否则错误输入可能制造大量小桶,既难读也可能增加内存或指标基数。

归一化规则同样属于字段契约。本文允许大小写与首尾空白差异;若某种标识大小写有意义,就不能照搬toLowerCase。把原始值保留在输入或错误记录中,有助于定位非法代码,而规范化分类值则用于稳定聚合。

最后,返回Map仍是局部统计结构。对外接口可以转换成明确的行对象,并声明地区代码、显示名称和数量,避免把Java枚举名称无意固定成永久客户端协议。聚合工具只负责按已确定的分类收集数据,哪些状态能合并、哪些状态必须拒绝,仍需要这份输入模型做决定。

正文到此结束
评论插件初始化中...
Loading...
本文目录