qinghuan
← 回到知识探索

后端现场 / 可下载小项目

空的 List<Channel> 为什么不能直接复制成 EnumSet?

从通知渠道的输入列表出发,比较空列表、空枚举集合和只读包装,找到运行时类型信息在哪里丢了。

展开本文目录
  1. 下载完整练习包
  2. 第一步:把请求名称和业务含义先固定
  3. 第二步:空列表没有元素可以告诉工厂是哪种枚举
  4. 第三步:变量声明类型不改变实际集合对象
  5. 第四步:补集需要先说清“全部”包括谁
  6. 运行单文件,按实际输出逐项核对
  7. 记住这三点
  8. 别把例子用过头
  9. 自己试一小步
  10. 查原始资料
开始前,会这些就够了
  • 先理解枚举常量,以及 List 与 Set 在重复元素上的区别。
  • 会读泛型参数;知道接口变量可以指向不同具体实现。
  • 先读 枚举与 record:写清状态和数据:先理解枚举固定可选项,再看集合怎样记录枚举类别。
遇到不熟悉的词,先看这 2 个解释
枚举集合的类别信息
EnumSet除了保存哪些项被选中,还保留这些项属于哪种枚举。
枚举集合的补集
从当前枚举的所有常量中去掉已经包含的项。
打开完整术语小抄 ↗

一个接口接收通知渠道名称,可能是 [SMS, EMAIL],也可能是空列表,表示用户暂时没有选择渠道。代码先把名称转成 Channel,再用 EnumSet.copyOf 复制。非空请求一直正常,空请求却突然抛异常。

泛型已经写明 List<Channel>,为什么还不够?这个实验把“集合中有没有元素”和“集合对象自身记不记得枚举类别”分开观察。输入来自程序内模拟的请求字段,不需要启动服务器。

把例子带到自己的电脑上

下载完整练习包

需要 JDK 17 或更新版本,使用 Java 标准库;制作时实测 Windows / JDK 17.0.20.1 / 21.0.12。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • EnumInputDemo.java:完整可运行示例
  • README.md:运行说明与独立练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和产物验证记录
  • SHA256SUMS.txt:内容摘要

在这个文件夹打开 PowerShell,逐行运行

java -version
javac -version
javac -encoding UTF-8 --release 17 EnumInputDemo.java
java -cp . EnumInputDemo

解压到独立练习文件夹,在含 EnumInputDemo.java 的目录打开终端运行。Windows 内存模拟渠道输入;空List/EnumSet/Collection变量/只读包装、重复/顺序、补集、未知名称与空白;JDK17/21各11项断言。未测JSON绑定、数据库、并发或真实渠道启用。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

第一步:把请求名称和业务含义先固定

decode 逐个调用 Channel.valueOf,把严格匹配的名称转换成枚举值。实验允许 EMAIL、SMS、PUSH 三种名称,空列表代表没有选择;未知名称和额外空白会被拒绝。

这只是本例的输入契约。若接口希望忽略大小写或去掉空白,应明确加上那一步并覆盖测试;不能为了让示例通过就偷偷改变输入。

非空列表包含 SMS、EMAIL、SMS。转换后的 EnumSet 去掉重复项,按枚举声明顺序迭代,所以最终输出 EMAIL、SMS。这里验证的是集合语义,并没有真正订阅或发送任何通知。

第二步:空列表没有元素可以告诉工厂是哪种枚举

decodedEmpty 的变量类型是 List<Channel>,但它的实际对象是普通 ArrayList。copyOf 收到一个没有元素的普通集合,无法从第一个元素推导枚举类别,因此这条路径抛 IllegalArgumentException。

selected 改为先创建 EnumSet.noneOf(Channel.class),明确把类别交进去,然后 addAll。即使后面没有任何元素,这个 EnumSet 仍知道自己属于 Channel。

空元素数量和缺少类别信息是不同事实。把空输入随手替换为 null 会丢掉“用户选择为空”的业务含义,也不会解决集合工厂所需的类别来源。

第三步:变量声明类型不改变实际集合对象

代码把同一个空 EnumSet 放进 Collection<Channel> 变量,再调用 copyOf,仍然成功。虽然重载看到的是 Collection,这个参数的实际对象仍是 EnumSet,可以走保留类别信息的复制路径。

随后给空 EnumSet 套上 Collections.unmodifiableSet,复制却失败。只读包装能把查询转交给原集合,但传进 copyOf 的实际对象已经不是 EnumSet。其底层有类别信息,不代表这个工厂会穿透任意包装去寻找。

因此排错时要同时记录变量声明、实际对象类型、是否为空、是否套了包装。只盯着尖括号里的 Channel,容易漏掉真正改变的那层对象。

第四步:补集需要先说清“全部”包括谁

从 EMAIL、SMS 求补集,结果是 PUSH;对空 Channel 集合求补集,结果是三个渠道全体。所谓补集,是当前枚举类型的全部常量减去已有元素。

这可以用来显示“尚未选择的渠道”,却不能不经业务判断就自动开通所有剩余渠道。如果以后枚举增加常量,“全部”的范围也会变化,配置迁移需要另行决定。

实跑只验证三个固定枚举常量、请求列表转换、类型信息、包装与补集。没有接入 JSON 框架、数据库或通知服务,也没有把 EnumSet 当作并发容器。

运行单文件,按实际输出逐项核对

保存为 EnumInputDemo.java,在源码目录执行下方命令;需要完整 JDK 17 或更新版本。无需 Maven、网络或第三方依赖。

2026 年 10 月 18 日在 Windows 上分别以 Microsoft OpenJDK 17.0.20.1 与 21.0.12、--release 17 编译运行,输出一致。每项断言失败都会终止程序;下方是本次真实输出。

展开收起代码 · 小实验java · 45 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · java
import java.util.*;
public class EnumInputDemo {
    enum Channel { EMAIL, SMS, PUSH }
    static int checks;
    static void check(boolean ok) { if(!ok) throw new AssertionError(); checks++; }
    static List<Channel> decode(List<String> names) {
        List<Channel> result=new ArrayList<>();
        for(String name:names) result.add(Channel.valueOf(name));
        return result;
    }
    static EnumSet<Channel> selected(List<String> names) {
        EnumSet<Channel> result=EnumSet.noneOf(Channel.class);
        result.addAll(decode(names)); return result;
    }
    static boolean copyFails(Collection<Channel> input) {
        try { EnumSet.copyOf(input); return false; }
        catch(IllegalArgumentException expected) { return true; }
    }
    public static void main(String[] args) {
        List<Channel> decodedEmpty=decode(List.of());
        check(copyFails(decodedEmpty));
        EnumSet<Channel> empty=selected(List.of());
        check(empty.isEmpty()); check(EnumSet.copyOf(empty).isEmpty());
        Collection<Channel> sameObject=empty;
        check(EnumSet.copyOf(sameObject).isEmpty());
        System.out.println("empty_List_copy=IllegalArgumentException; empty_EnumSet_copy=ok; Collection_reference_to_EnumSet=ok");
        Set<Channel> wrapped=Collections.unmodifiableSet(empty);
        check(copyFails(wrapped));
        System.out.println("unmodifiable_empty_wrapper_copy=IllegalArgumentException");
        EnumSet<Channel> picked=selected(List.of("SMS","EMAIL","SMS"));
        check(picked.equals(EnumSet.of(Channel.EMAIL,Channel.SMS)));
        check(new ArrayList<>(picked).equals(List.of(Channel.EMAIL,Channel.SMS)));
        System.out.println("input=[SMS, EMAIL, SMS]; selected="+picked);
        EnumSet<Channel> remaining=EnumSet.complementOf(picked);
        check(remaining.equals(EnumSet.of(Channel.PUSH)));
        check(EnumSet.complementOf(empty).equals(EnumSet.allOf(Channel.class)));
        System.out.println("not_selected="+remaining+"; complement_of_empty="+EnumSet.complementOf(empty));
        boolean unknown=false,space=false;
        try { selected(List.of("FAX")); } catch(IllegalArgumentException e) { unknown=true; }
        try { selected(List.of(" SMS ")); } catch(IllegalArgumentException e) { space=true; }
        check(unknown); check(space);
        System.out.println("unknown_name=rejected; uncontracted_whitespace=rejected");
        System.out.println("PASS checks="+checks);
    }
}

怎样运行:在源码所在目录,先单独执行 javac -encoding UTF-8 --release 17 EnumInputDemo.java;编译成功后,再执行 java -cp . EnumInputDemo。两条命令分别回车。2026-10-18 已在 Windows、JDK 17.0.20.1 / 21.0.12 编译运行此份源码并通过检查。Windows 内存模拟渠道输入;空List/EnumSet/Collection变量/只读包装、重复/顺序、补集、未知名称与空白;JDK17/21各11项断言。未测JSON绑定、数据库、并发或真实渠道启用。

运行后对照结果
empty_List_copy=IllegalArgumentException; empty_EnumSet_copy=ok; Collection_reference_to_EnumSet=ok
unmodifiable_empty_wrapper_copy=IllegalArgumentException
input=[SMS, EMAIL, SMS]; selected=[EMAIL, SMS]
not_selected=[PUSH]; complement_of_empty=[EMAIL, SMS, PUSH]
unknown_name=rejected; uncontracted_whitespace=rejected
PASS checks=11

记住这三点

  • 空普通集合无法提供 copyOf 所需的枚举类别。
  • noneOf(Class) 显式保留类别,空输入仍有正常含义。
  • 补集相对于枚举全部常量,不自动等于业务上应启用的集合。

别把例子用过头

  • 认为 List<Channel> 的泛型声明能替代运行时类别来源。
  • 用 null 代替业务上合法的空选择。
  • 假设只读包装仍是原来的 EnumSet 实例。
  • 把补集直接当成应自动启用的功能。

自己试一小步

输入区分“字段没传”和“明确传空列表”后,你会怎样构造默认渠道?

  1. 写出缺失、空列表、重复名称、未知名称四类请求的业务含义。
  2. 在不引入网络的情况下用一个明确的请求模型表示这些情况。
  3. 实现转换并保留空集合类型,新增一个枚举常量观察补集变化。
  4. 提交请求矩阵、程序、断言和“默认值何时应用”的说明。

做到这些,就算完成

  • 缺失与空列表不混为一谈。
  • 复制路径包含空列表、空EnumSet和包装对象。
  • 新增枚举常量不会被悄悄解释为用户已授权选择。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。