封装 · 多态分派 · 接口 vs 抽象类 · sealed · record · 泛型 · 类型擦除 · checkcast · Signature · 桥方法
面向对象和泛型:订单 9527 是怎么被“管住”的
面向对象管的是“谁能改、改了以后跑哪段代码”:字段藏起来,只开放按规则改的方法;同一行 d.apply(sum),运行时按对象的真实类去找实现。泛型管的是“容器里装的是什么”,但它只活在编译期:javac 检查完就把类型参数擦掉,在取出元素的地方补一条强转,只在 class 文件的签名里留个底。不懂这一点,就会把错放进去的数据一路带到很远的地方才炸。
全景:一个订单对象的一生 · 第 5、6 站地图:J01 · 面向对象、接口与抽象类、泛型和类型擦除实验:JDK 17.0.20 实测
①为什么需要:一个订单,三种约束
在全景第 5 站,这一步要解决的是:new 出来的 Order 要在内存里活很久(集合、延迟队列都拉着它),这期间谁都能拿到它的引用,可状态只能按“已创建 → 已付款 / 已取消”走,订单行也不能被外面偷偷改掉;它装的订单行必须都是 OrderLine。第 6 站接着要解决:三四种优惠,算价代码只写一遍,以后加一种也不用改它。
②没有它会怎样:用 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/switch | String type 加分支 | 简单直白 | 分支散落各处,加一种要全改,漏了编译器不管 |
| 抽象类 | abstract class Discount | 能放共同字段和构造逻辑 | Java 只能单继承,子类被绑死在这一棵树上;record 不能继承类 |
| 接口 | interface Discount | 只约定能力,谁都能实现,一个类能实现多个 | 不能有实例字段;实现散落各处,不知道一共有几种 |
| sealed 接口 + record | sealed 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 一起守门
- 1
private字段只有本类的代码能读写。编译时 javac 检查,类加载后 JVM 验证访问也会再查一次。所以order.status = PAID在类外面编译就过不了,想改只能调pay(),规则就写在pay()里,只此一处。 - 2引用会泄露内部:构造器直接存外面传进来的 List,外面一改订单就跟着变。所以要防御性复制进来、只读视图出去(⑤里实测:外面清空原列表,订单里还在)。
- 3反射能用
setAccessible(true)绕过private,JDK 9 起模块系统又加了一道:JDK 内部的类默认不让反射打开(全景第 4 站要加--add-opens才能看 HashMap 内部)。封装防的是误用,不是攻击。
④-2多态:d.apply(sum) 在运行时查表找实现
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)
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
最少语法
// 类:字段私有,按规则改
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 →
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);
}
$ 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,⑥ 第一圈会解释。
⑥细节·规范·优化
注意事项:不知道就会出事故
- 现象
- 老接口收
List,往里加了字符串,没报错;另一个模块遍历时 ClassCastException,栈里根本没有放错的那段代码。⑤ 第 4 段 - 根源
- 运行时对象不知道元素类型,检查只在编译期;原始类型关掉了检查,强转在取出处才执行。
- 做法
- 不用原始类型;编译开
-Xlint:unchecked并把警告当错误;排查时用Collections.checkedList(list, OrderLine.class)包一层,让错误在放进去时就抛。
- 现象
new FullReduction(300, 30)打出“满 300 减 null”。⑤ 第 2 段- 根源
- 构造时先跑父类构造器,这时子类字段
off还没赋值;可describe()是虚方法,已经分派到子类的版本。 - 做法
- 构造器里只调
private或final方法;需要“构造完再做”的事,放进工厂方法。
- 现象
- 写了
log(Discount d)和log(Minus m),传进Discount d = new Minus(20),跑的是log(Discount)。 - 根源
- 重载由静态类型在编译期决定;只有方法的接收者(点号左边)在运行时分派。
- 做法
- 要按运行时类型分支,就把行为放进类里(多态),或者 JDK 21 用模式匹配的 switch。
- 现象
Object[] arr = new String[1]; arr[0] = 1;能编译,运行时ArrayStoreException;同样的事换成List<Object> l = new ArrayList<String>()编译就不过。javac 17 实测- 根源
- 数组在运行时知道元素类型,所以敢协变、在写入时检查;泛型运行时不知道,只能在编译期规定“不变”。
- 做法
- 优先用集合而不是数组;泛型代码里别建
T[],需要时传Class<T>或IntFunction<T[]>。
- 现象
- Lombok 一个
@Data,Order 的setStatus、setLines全开放,状态在十几个地方被改。 - 根源
- 封装的价值在“只有一个地方能改”。setter 把这个地方拆成了所有调用方。
- 做法
- 实体只暴露有业务含义的方法(
pay、cancel);值对象用 record。
- 现象
- 按全部字段生成
equals/hashCode的订单放进 HashSet,状态一改,contains就找不到它了。 - 根源
- hashCode 变了,对象还躺在旧的桶里(全景第 4 站的桶下标)。
- 做法
- 实体按 id 判等;做 key 的类型用不可变的 record。
使用规范
List<OrderLine> lines = new ArrayList<>(),方法参数和返回值同理。
换实现时要改一大片。
有身份、有状态变化的(Order)用类,字段私有;不会变的(OrderLine、Product、命令、DTO)用 record。
值对象被改、实体被随意改。
优惠、支付方式、订单事件这种“就这几种”的,用 sealed 接口;对外开放扩展的插件点才用普通接口。
新加一种却漏了处理。
只从参数里读:List<? extends OrderLine>;只往里写:List<? super OrderLine>;又读又写就不用通配符。口诀 PECS(Producer Extends, Consumer Super)。
API 太死,List<子类> 传不进来。
抽象类只在确实要共享字段和骨架时用;“复用一段代码”优先抽成一个被持有的对象。
单继承被占用、父类一改子类全碎(脆弱基类)。
给已有接口加方法、又不想让实现类全编译失败时才用,不用它塞业务状态逻辑。
接口变成伪抽象类,两个接口撞名。
深处:面试官会接着问的
桥方法:擦除以后,多态怎么还成立
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 16 | record、instanceof 模式匹配转正 |
| JDK 17 | sealed 转正(生产里这套 ⑤ 能直接用) |
| JDK 21 | switch 模式匹配、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)。
- 反射、注解与 SPI:
Type体系(ParameterizedType、TypeVariable、WildcardType)怎么读 Signature - JIT 内联缓存:单态、双态、多态调用点各有多快,
-XX:+PrintInlining实测 - 设计模式:策略、模板方法、工厂,正好是这页的接口、抽象类、构造器坑
- Valhalla 值类型和泛型特化:Java 想把
List<int>补回来(还没正式发布,跟进即可)
↺回到⑤:改造你的案例
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。