封装 · 多态分派 · 接口 vs 抽象类 · sealed · record · 泛型 · 类型擦除 · checkcast · Signature · 桥方法

面向对象和泛型:订单 9527 是怎么被“管住”的

面向对象管的是“谁能改、改了以后跑哪段代码”:字段藏起来,只开放按规则改的方法;同一行 d.apply(sum),运行时按对象的真实类去找实现。泛型管的是“容器里装的是什么”,但它只活在编译期:javac 检查完就把类型参数擦掉,在取出元素的地方补一条强转,只在 class 文件的签名里留个底。不懂这一点,就会把错放进去的数据一路带到很远的地方才炸。

全景:一个订单对象的一生 · 第 5、6 站地图:J01 · 面向对象、接口与抽象类、泛型和类型擦除实验:JDK 17.0.20 实测

①为什么需要:一个订单,三种约束

在全景第 5 站,这一步要解决的是:new 出来的 Order 要在内存里活很久(集合、延迟队列都拉着它),这期间谁都能拿到它的引用,可状态只能按“已创建 → 已付款 / 已取消”走,订单行也不能被外面偷偷改掉;它装的订单行必须都是 OrderLine。第 6 站接着要解决:三四种优惠,算价代码只写一遍,以后加一种也不用改它。

全景第 5 站 · new Order class Order private,外面看不见 long id = 9527 long userId = 1024 List<OrderLine> lines Status status 对外开放的方法 pay() 只能从 CREATED cancel() 付了就不行 lines() 只读视图 sum() 598.00 全景第 6 站 · 算价 BigDecimal pay = d.apply(sum); 同一行代码,d 是哪种优惠就跑哪种算法 None598.00 Minus(20)578.00 Percent(10)538.20 满 300减 30568.00 约束 1状态只能按规则改,订单行不被外面改 → 封装 约束 2lines 里只能是 OrderLine,错了在编译期就拦住 → 泛型 约束 3新增一种优惠,算价那行不用改 → 接口和多态 约束 4优惠就这几种,漏处理要能发现 → sealed
需求J01 的过关要求是“讲清天天在用的东西”。这几个概念写了好几年,但“泛型到运行时还剩什么”“接口和抽象类到底差在哪”常常说不清,面试也爱问。所以④讲透机制,⑥给面试官会怎么问。

②没有它会怎样:用 Map 装订单,用字符串分优惠

Java 1.4 时代和很多脚本语言就是这么写的:数据放进 Map,类型靠字符串判断,取出来自己强转。能跑,但下面三处都会出事。

老写法 没有类、没有泛型、没有多态
Map order = new HashMap();          // 原始类型
order.put("id", 9527L);
order.put("status", "CREATED");
order.put("lines", lines);

// 任何人、任何地方都能改状态
order.put("status", "CANCELED");   // 拼错了也没人管

// 取出来要自己强转
List ls = (List) order.get("lines");
OrderLine l = (OrderLine) ls.get(0);

// 每加一种优惠,就要找到所有 if 改一遍
if ("MINUS".equals(type))   pay = sum.subtract(off);
else if ("PCT".equals(type)) pay = sum.multiply(rate);
// 新加的 "FULL" 忘了写:不报错,悄悄不打折

疼在哪

  • 状态谁都能改:付过款的订单被别处一行 put 改成取消,还拼错成 CANCELED。规则散落在调用方,没有一个地方守着。
  • 类型错了很晚才发现:有人往 lines 里放了个字符串,放的时候不报错,等到某处 (OrderLine) ls.get(0) 才抛 ClassCastException,离放错的地方十万八千里。
  • 分支散落、漏了不知道:按字符串分支的 if 写在好几个地方,加一种优惠要全找到,漏一处就静默算错价。
  • 缺的就是这页的三样:类把数据和规则锁在一起,接口加多态让“哪种算法”由对象自己决定,泛型把“装的是什么”交给编译器检查。

不懂精华时的典型误用:类写了,但 getter、setter 全部生成、字段全开放,和 Map 没有区别;泛型写了,但某个老接口收的是原始类型 List,检查就在那里漏掉了(⑤里会亲眼看到)。

③相邻方案:表达“一族不同行为”和“类型安全的容器”

表达优惠这一族怎么写好在哪输在哪
类型码 + if/switchString type 加分支简单直白分支散落各处,加一种要全改,漏了编译器不管
抽象类abstract class Discount能放共同字段和构造逻辑Java 只能单继承,子类被绑死在这一棵树上;record 不能继承类
接口interface Discount只约定能力,谁都能实现,一个类能实现多个不能有实例字段;实现散落各处,不知道一共有几种
sealed 接口 + recordsealed interface … permits …接口的好处,再加上“就这几种”由编译器记着要 JDK 17;加一种实现要改 permits 列表
类型安全的容器做法运行时还知道元素类型吗代价
原始类型 + 强转Java 1.4不知道全靠自觉,错了在取出时才炸
Java 泛型编译期检查,然后擦除对象不知道;声明处的签名还留着不能 new T()、不能 instanceof List<String>,基本类型要装箱
C# 泛型运行时保留(具体化)知道运行时和类库要跟着改,老的二进制要分两套
C++ 模板每种类型参数生成一份代码编译成了不同的类型代码膨胀,编译慢

本页学:Java 的类、接口、抽象类、sealed 和 record,以及 Java 这种“擦除式”泛型。选择规则:有状态、有身份的用类;不会变的值用 record;一族行为用接口,种类封闭就加 sealed;要共享字段和骨架代码时才上抽象类。

④原理:封装靠访问控制,多态靠运行时查表,泛型靠编译期检查加擦除

先知道这些
  • 字节码:javac 把 .java 编译成 .class,里面是 JVM 指令,javap -c 能看到,比如调用接口方法的 invokeinterface、强转的 checkcast。
  • 对象在堆上:每个对象开头有一个对象头,里面有一个指针指向它的类元数据(HotSpot 叫 Klass)。细节在 JVM 内存结构。
  • 静态类型和运行时类型:Discount d = new Minus(20),变量 d 的静态类型是 Discount,它指向的对象运行时类型是 Minus。

④-1封装:编译器和 JVM 一起守门

④-2多态:d.apply(sum) 在运行时查表找实现

编译期:字节码里只有接口方法的名字 aload d aload sum invokeinterface Discount.apply 运行时:顺着对象找到它真正的类 变量 d静态类型 Discount 引用 堆上的对象 对象头:类指针 → off = 20 Minus 的类元数据(Klass) 方法表:接口方法 → 这个类的实现 label() → Discount 的默认实现 apply() → Minus.apply toString()… → record 生成的 执行 Minus.apply 598 − 20 = 578 编译时不知道是哪个类,留到运行时查 重写(override)按对象的类在运行时选;重载(overload)按变量的静态类型在编译期就选好了,写进字节码,运行时不再变。
HotSpot 里,类调用走 vtable(invokevirtual),接口调用走 itable(invokeinterface),都是“按对象的类查一张表”。查表听起来慢,但 JIT 发现某个调用点只见过一两种类时,会直接内联那个实现,几乎没有开销(按文档)。实测 ⑤ 的 javap -c 里,算价那行就是 invokeinterface Discount.apply。

④-3接口和抽象类:一个只约定能力,一个能带状态

interface Discount约定“能做什么”

  • 抽象方法:BigDecimal apply(BigDecimal)
  • default 方法(JDK 8)、static 方法(JDK 8)、private 方法(JDK 9)
  • 常量(自动是 public static final)
  • 没有实例字段,没有构造器
  • 一个类可以实现多个接口;record、enum 也能实现

abstract class ThresholdDiscount“是什么”加上一部分实现

  • 实例字段:threshold
  • 构造器:子类必须先调 super(threshold)
  • 模板方法:apply 写死“满了才减”,reduce 留给子类
  • 只能单继承:继承了它,就不能再继承别的类
  • record 不能继承它(record 已经隐式继承 java.lang.Record)
为什么接口不能有实例字段?一个类可以实现多个接口。如果两个接口都带字段,同名字段放哪、谁先初始化,就是 C++ 多继承的“菱形问题”。Java 的取舍是:状态只能单继承(类),能力可以多继承(接口)。JDK 8 加 default 方法,是为了给 Collection 加 stream() 时不让所有老实现类编译失败,加的仍然只是行为,不是状态。两个接口的 default 方法撞名时,编译器要求实现类自己覆盖、说清楚用谁的。

④-4泛型:从源码到运行,一步一步看它剩下什么

1你写的源码

class Order {
    private final List<OrderLine> lines;
    …
}
List<OrderLine> ls = order.lines();
OrderLine first = ls.get(0);           // 不用强转
ls.add("一行字符串");                    // ✕ 编译错误

类型参数 <OrderLine> 是写给编译器看的:它记住 ls 里只能是 OrderLine。

2javac 检查

ls.add("一行字符串");
error: incompatible types: String cannot be converted to OrderLine

List<Object> objs = new ArrayList<String>();
error: incompatible types: ArrayList<String> cannot be converted to List<Object>

所有类型检查都在这一步做完。第二个错误叫“不变性”:List<String> 不是 List<Object>,否则就能往字符串列表里塞数字。实测 javac 17 的报错原文。

3擦除:类型参数换成它的上界

List ls = order.lines();               // List<OrderLine> → List
OrderLine first = (OrderLine) ls.get(0);  // get 返回 Object,编译器补一个强转

检查通过后,T 换成上界(没写就是 Object),List<OrderLine> 换成 List。为了让你不用手写强转,编译器在每个取出元素的地方插一条 checkcast。实测 javap -c:invokeinterface Iterator.next:()Ljava/lang/Object; 后面紧跟 checkcast OrderLine。

4class 文件:描述符被擦了,签名还留着

private final java.util.List<OrderLine> lines;
  descriptor: Ljava/util/List;                   // JVM 链接、调用只看它
  Signature: Ljava/util/List<LOrderLine;>;        // 留给反射和编译器看

实测 javap -v Order.class。字段、方法、类声明上写的泛型,会以 Signature 属性留在 class 文件里,所以反射 getGenericType() 能读出 List<OrderLine>,Jackson 正是靠它知道 JSON 数组里每个元素该 new 成什么(全景第 2 站)。

5运行时:对象本身不知道自己装的是什么

new ArrayList<OrderLine>().getClass() == new ArrayList<String>().getClass()
→ true(都是 java.util.ArrayList)

List raw = lines;  raw.add("一行字符串");     // 不报错
for (OrderLine l : lines) …               // 这里才抛 ClassCastException

堆上的 ArrayList 里就是一个 Object[]。只有声明上留着签名,对象上没有。所以原始类型一旦绕过第 2 步的检查,错的元素就能一路混进去,直到第 3 步插的那条 checkcast 才炸。实测 见⑤第 4 段。

④-5为什么非要擦除:JDK 5 的约束

Java 选的:擦除JDK 5,2004 年

  • JVM 和 class 文件格式几乎不用改,泛型只是 javac 的事
  • 老代码不重新编译也能和新代码互相调用:老库收 List,新代码传 List<String>,照样能用
  • 集合类库只要一份,ArrayList 还是那个 ArrayList
  • 代价:不能 new T()、new T[]、instanceof List<String>;over(List<String>) 和 over(List<Integer>) 擦除后同名同参,不能重载;List<int> 不行,只能装箱

没选的:运行时保留C# 2.0 的做法

  • 运行时知道 List<int>,能 new T(),值类型不用装箱
  • 虚拟机和 class 文件要一起改,所有类库都要出泛型版
  • 老的 List 和新的 List<T> 是两套类型,老代码要么重写,要么在两套之间来回转
  • C# 当时代码存量小,扛得住;Java 已经有十年的类库和海量的二进制 jar
决定胜负的约束:兼容。JDK 5 要让已有的、已经编译好的 jar 不改一行就继续跑,还能和带泛型的新代码互相传集合。擦除是唯一能满足这一条的方案。⑥ 里大部分泛型的坑,都是这一条的代价。

最少语法

// 类:字段私有,按规则改
class Order { private Status status; void pay() { … } }
// 不会变的值
record OrderLine(long skuId, String name, BigDecimal price, int qty) {}
// 一族行为,种类封闭(JDK 17)
sealed interface Discount permits None, Minus, Percent, ThresholdDiscount { BigDecimal apply(BigDecimal sum); }
// 带状态的骨架
abstract non-sealed class ThresholdDiscount implements Discount { protected final BigDecimal threshold; … }
// 泛型方法和上界
static <T extends Comparable<? super T>> T max(List<? extends T> xs) { … }

通配符 ? extends / ? super 怎么选、桥方法、反射读泛型:放到第⑥步。

⑤亲眼看到:交易靶场的 Order 和 Discount,在 JDK 17 上跑一遍

一个 Maven 模块,没有依赖。两个文件:Model.java(Product、OrderLine、Order、Discount 一族)和 Demo.java(6 段,每段对应④的一个点)。完整代码:study-labs/java/javase/oop-generics-erasure →

1Model.java 节选
class Order {
    private final List<OrderLine> lines;
    private Status status = Status.CREATED;

    Order(long id, long userId, List<OrderLine> lines) {
        …
        this.lines = new ArrayList<>(lines);   // 防御性复制
    }
    List<OrderLine> lines() {
        return Collections.unmodifiableList(lines);
    }
    void pay()    { 只能从 CREATED 到 PAID }
    void cancel() { 付了款就抛异常 }
}

sealed interface Discount
        permits Discount.None, Discount.Minus,
                Discount.Percent, ThresholdDiscount {
    BigDecimal apply(BigDecimal sum);
    default String label() { … }
    record Minus(BigDecimal off) implements Discount {
        public BigDecimal apply(BigDecimal sum) {
            return sum.subtract(off).max(BigDecimal.ZERO);
        }
    }
    …
}

abstract non-sealed class ThresholdDiscount
        implements Discount {
    protected final BigDecimal threshold;
    protected ThresholdDiscount(BigDecimal t) {
        this.threshold = t;
        System.out.println("[构造中] " + describe()); // 坑
    }
    public final BigDecimal apply(BigDecimal sum) {
        return sum.compareTo(threshold) >= 0
             ? reduce(sum) : sum;
    }
    protected abstract BigDecimal reduce(BigDecimal sum);
}
2运行 JDK 17.0.20,2 核 ARM
$ cd java && mvn -q -pl javase/oop-generics-erasure compile
$ java -cp javase/oop-generics-erasure/target/classes \
    labs.javase.oop_generics_erasure.Demo
== 1. 封装 ==
  外面清空了原列表,订单里还有 1 行,合计 598.00
  order.lines().add(...) → UnsupportedOperationException
  付款后 cancel() → 9527 已付款,不能直接取消
== 2. 多态 ==
    [构造中] 满 300 减 null
  None          Discount$None      598.00
  Minus         Discount$Minus     578.00
  Percent       Discount$Percent   538.20
  FullReduction FullReduction      568.00
== 3. 擦除 ==
  a.getClass() == b.getClass():true
  Order.lines 的签名:java.util.List<OrderLine>
  它的运行时类型:java.util.List
== 4. 原始类型绕过检查 ==
  addSomething 之后 size = 2,没有报错
  读到 机械键盘
  ClassCastException 在 main():66 抛出,不在 addSomething 里
  class java.lang.String cannot be cast to class OrderLine
== 5. 桥方法 ==
  你写的  public int Demo$ById.compare(Order,Order)
  bridge  public int Demo$ById.compare(Object,Object)

$ javap -c -p Demo.class | grep -E 'apply|checkcast'
  invokeinterface Discount.apply:(BigDecimal)BigDecimal
  invokeinterface Iterator.next:()Ljava/lang/Object;
  checkcast       OrderLine

输出略去了包名前缀。第 2 段那行“满 300 减 null”和第 4 段的 ClassCastException,⑥ 第一圈会解释。

到这里已经能交差了:交易靶场的 Order 状态被锁住,优惠一族用 sealed 接口表达,新加一种优惠算价代码不用改;你也亲眼看到泛型在运行时只剩签名。
已知还没处理的细节:构造器里调可覆盖方法(⑥-1)、原始类型漏检查(⑥-1)、通配符怎么写(⑥-2)、桥方法和反射读泛型(⑥-3)。

⑥细节·规范·优化

第一圈

注意事项:不知道就会出事故

坑 1原始类型让错元素混进去,在很远的地方炸← ④-4 擦除
现象
老接口收 List,往里加了字符串,没报错;另一个模块遍历时 ClassCastException,栈里根本没有放错的那段代码。⑤ 第 4 段
根源
运行时对象不知道元素类型,检查只在编译期;原始类型关掉了检查,强转在取出处才执行。
做法
不用原始类型;编译开 -Xlint:unchecked 并把警告当错误;排查时用 Collections.checkedList(list, OrderLine.class) 包一层,让错误在放进去时就抛。
坑 2父类构造器调了子类覆盖的方法,读到 null← ④-2 运行时分派
现象
new FullReduction(300, 30) 打出“满 300 减 null”。⑤ 第 2 段
根源
构造时先跑父类构造器,这时子类字段 off 还没赋值;可 describe() 是虚方法,已经分派到子类的版本。
做法
构造器里只调 private 或 final 方法;需要“构造完再做”的事,放进工厂方法。
坑 3以为重载也看运行时类型← ④-2 重载在编译期选
现象
写了 log(Discount d) 和 log(Minus m),传进 Discount d = new Minus(20),跑的是 log(Discount)。
根源
重载由静态类型在编译期决定;只有方法的接收者(点号左边)在运行时分派。
做法
要按运行时类型分支,就把行为放进类里(多态),或者 JDK 21 用模式匹配的 switch。
坑 4数组协变,泛型不变,混用出事← ④-4 不变性
现象
Object[] arr = new String[1]; arr[0] = 1; 能编译,运行时 ArrayStoreException;同样的事换成 List<Object> l = new ArrayList<String>() 编译就不过。javac 17 实测
根源
数组在运行时知道元素类型,所以敢协变、在写入时检查;泛型运行时不知道,只能在编译期规定“不变”。
做法
优先用集合而不是数组;泛型代码里别建 T[],需要时传 Class<T> 或 IntFunction<T[]>。
坑 5实体全开 setter,封装形同虚设← ④-1 封装
现象
Lombok 一个 @Data,Order 的 setStatus、setLines 全开放,状态在十几个地方被改。
根源
封装的价值在“只有一个地方能改”。setter 把这个地方拆成了所有调用方。
做法
实体只暴露有业务含义的方法(pay、cancel);值对象用 record。
坑 6可变对象当 HashMap 的 key 或放进 HashSet← ④-1 引用会泄露
现象
按全部字段生成 equals / hashCode 的订单放进 HashSet,状态一改,contains 就找不到它了。
根源
hashCode 变了,对象还躺在旧的桶里(全景第 4 站的桶下标)。
做法
实体按 id 判等;做 key 的类型用不可变的 record。
第二圈

使用规范

声明用接口,new 时才写实现类

List<OrderLine> lines = new ArrayList<>(),方法参数和返回值同理。

换实现时要改一大片。

实体用类,值用 record

有身份、有状态变化的(Order)用类,字段私有;不会变的(OrderLine、Product、命令、DTO)用 record。

值对象被改、实体被随意改。

种类封闭的一族,用 sealed

优惠、支付方式、订单事件这种“就这几种”的,用 sealed 接口;对外开放扩展的插件点才用普通接口。

新加一种却漏了处理。

通配符:读用 extends,写用 super

只从参数里读:List<? extends OrderLine>;只往里写:List<? super OrderLine>;又读又写就不用通配符。口诀 PECS(Producer Extends, Consumer Super)。

API 太死,List<子类> 传不进来。

组合优先于继承

抽象类只在确实要共享字段和骨架时用;“复用一段代码”优先抽成一个被持有的对象。

单继承被占用、父类一改子类全碎(脆弱基类)。

default 方法只用来演进接口

给已有接口加方法、又不想让实现类全编译失败时才用,不用它塞业务状态逻辑。

接口变成伪抽象类,两个接口撞名。

第三圈

深处:面试官会接着问的

桥方法:擦除以后,多态怎么还成立

ById implements Comparator<Order> 只写了 compare(Order, Order)。可 Comparator 擦除后的方法是 compare(Object, Object),JVM 按描述符找方法,找不到你写的那个。

所以 javac 补了一个 compare(Object, Object),里面两条 checkcast Order,再调你写的方法。实测 反射 isBridge() 为 true,javap -c 能看到这两条强转。协变返回值(子类覆盖时返回更具体的类型)也是靠桥方法。

既然擦掉了,Jackson 怎么知道 List 里是什么

靠④-4 第 4 步的 Signature:字段、方法参数、父类声明上的泛型会留下来。所以:

  • 字段 List<OrderLine> lines:反射 getGenericType() 读得到。
  • 局部变量 new ArrayList<OrderLine>():读不到,对象上没有。
  • 要把 JSON 直接读成 List<OrderLine>,就写 new TypeReference<List<OrderLine>>(){}:那对 {} 造了一个匿名子类,它的父类声明上带着完整泛型,Jackson 用 getGenericSuperclass() 读出来。Spring 的 ParameterizedTypeReference 同理。

版本演进:Java 的类型系统一直在加东西

JDK 5泛型(擦除)、注解、枚举
JDK 7菱形 new ArrayList<>()
JDK 8接口 default、static 方法
JDK 9接口 private 方法;模块系统限制反射
JDK 16record、instanceof 模式匹配转正
JDK 17sealed 转正(生产里这套 ⑤ 能直接用)
JDK 21switch 模式匹配、record 模式转正(全景里靶场用的写法)

JDK 8 项目没有 record、sealed:值对象写成 final 类 + final 字段 + 手写 equals/hashCode,一族行为用普通接口,“就这几种”只能靠约定。

sealed 到底封在哪

编译后 Discount.class 多了一个 PermittedSubclasses 属性,列着 4 个类名(实测 javap -v)。编译器和 JVM 都查它:别处写 final class Coupon implements Discount,javac 报 class is not allowed to extend sealed class: Discount;就算绕过 javac,类加载时也会被拒。

子类必须表态:final(到此为止)、sealed(继续封)或 non-sealed(从这里放开)。⑤ 的 ThresholdDiscount 选了 non-sealed,所以 FullReduction 能继承它。

面试官会怎么问
接口和抽象类有什么区别,什么时候用哪个?

接口约定能力,没有实例字段和构造器,可以多实现;抽象类能带状态、构造器和骨架代码,只能单继承。能用接口就用接口;需要共享字段、强制构造顺序或模板方法时才用抽象类。追问“为什么接口不能有字段”:多继承状态会有菱形问题(④-3)。

什么是类型擦除?擦了之后运行时还有类型信息吗?

编译期检查完,把类型参数换成上界,在取值处插 checkcast。对象上没有,声明上的 Signature 属性还在,反射能读到字段、方法、父类的泛型。为什么擦除:JDK 5 要和已有二进制兼容(④-5)。

List<String> 能赋值给 List<Object> 吗?数组呢?

泛型不能(不变),数组能(协变),但数组写错类型会在运行时抛 ArrayStoreException。因为数组运行时知道元素类型,泛型不知道(坑 4)。

重载和重写分别在什么时候确定?

重载在编译期按参数的静态类型选,写进字节码;重写在运行时按接收者对象的类查方法表(④-2,坑 3)。

下一圈

↺回到⑤:改造你的案例

1. 让编译器替你找漏

在 Model.java 外面新建 final class Coupon implements Discount,先编译看报错(实测 class is not allowed to extend sealed class),再把它加进 permits。

用到:sealed(⑥-3)

2. 修掉“满 300 减 null”

把 ThresholdDiscount 构造器里的打印挪走,改成 static FullReduction of(…) 工厂方法,构造完再打印;对比输出。

用到:坑 2

3. 让错误提前

把 addSomething(List raw) 的参数改成 List<OrderLine>,看报错从运行时挪到编译期;再试试用 Collections.checkedList 包住 lines,看异常栈落在哪一行。

用到:坑 1

自测

1. 用一句话说出这页的精华。

类把数据和改它的规则锁在一起,接口加多态让对象自己决定跑哪段代码;泛型只在编译期检查,运行时被擦掉、只在声明的签名里留底。

2. 用 Map 装订单、用字符串分优惠,最疼的是哪几处?

状态谁都能改;类型错了在很远的地方才炸;分支散落,加一种优惠漏改了不报错(②)。

3. 原始类型导致的 ClassCastException,为什么栈里看不到放错的那段代码?

因为运行时 List 不检查元素类型,放进去时什么都没发生;强转是编译器插在取出处的 checkcast(④-4 第 3、5 步)。

4. 预测:把 Order.lines() 改成直接 return lines;,构造器也不复制,⑤ 第 1 段会输出什么?

外面清空原列表后,订单里是 0 行,合计 0;order.lines().add(...) 也不会抛异常,能直接往订单里加行。引用泄露,封装失效(④-1 第 2 条)。

5. 预测:在 Demo 里写 void show(List<OrderLine> a) 和 void show(List<Product> a) 两个重载,会怎样?

编译失败:name clash: … have the same erasure。两者擦除后都是 show(List),描述符一样(④-5 代价;javac 17 实测报错)。

6. 放回全景:第 5 站前后各发生了什么?这一页讲的东西坏了,整条链路会怎样?

前面是第 4 站在 HashMap 里查到 88 号商品,后面是第 6 站用 Discount 算价、第 8 站把 Order 放进 userOrders、第 10 站 Jackson 读 getter 序列化。如果 Order 不封装、把内部 List 直接交出去,第 8 站之后任何拿到它的代码都能改订单行,第 10 站发出去的金额和第 6 站算的对不上;如果 lines 被原始类型塞进了别的东西,要到第 10 站序列化或某次遍历时才 ClassCastException,变成一个 500;如果 Order.lines 的签名丢了(比如声明成 List 或 Object),第 2、10 站的 Jackson 就不知道元素该是 OrderLine,只能读成 Map。