跳到内容

1.2 泛型与型变:生产者、消费者和运行时表示

工坊里的容器既要复用算法,又要阻止错误类型被写进去;继承关系一进入读写接口就不再直观。

泛型让容器和算法保留元素类型,但一旦类型参数既能被读出又能被写入,子类型关系就必须更谨慎。

可变容器通常是不变的

假设允许 Box<Cat> 当作 Box<Animal>

text
cats: Box<Cat>
animals: Box<Animal> = cats
animals.set(Dog)
cat = cats.get()  ← 现在取出了 Dog

所以同时读写的 Box<T> 对 T 通常 invariant。

只生产 T 的接口可以 covariant:若 Cat <: AnimalProducer<Cat> 可当作 Producer<Animal>。只消费 T 的接口可以 contravariant:能处理任意 Animal 的消费者当然能处理 Cat

Java 使用 use-site variance:

java
List<? extends Animal> source; // 可安全读作 Animal,不能随意写
List<? super Cat> sink;        // 可写入 Cat,读出只能视为 Object

PECS 是记忆法:Producer Extends, Consumer Super。它不是语法规则的替代;一个参数若既读又写,通常保持精确不变类型。

Array covariance 是带运行时检查的历史选择

Java 数组允许 Cat[] 赋给 Animal[],但写入 Dog 时抛 ArrayStoreException。数组在运行时知道组件类型,而泛型 List<Cat> 不允许同样赋值,在编译期阻止问题。

这说明“语言允许赋值”不代表完全静态安全;有些兼容性把错误推迟到运行时。

Erasure 与 Reification 是实现选择

Java 泛型主要通过 erasure 编译:参数化类型映射到原始类/边界,编译器插入 cast,并在需要保持多态时生成 bridge method。结果是 List<String>List<Integer> 通常共享同一个运行时 class,部分类型参数不可反射。

因此不能创建 new T()new T[],也不能直接测试 x instanceof List<String>。需要运行时类型时,可显式传 Class<T>、类型令牌或使用 reified generic 的语言机制。

Rust/C++ 常对具体类型实例化专用代码(monomorphization),能优化值布局和静态分派,但可能增加编译时间与代码体积。动态字典/type-class translation 则在运行时传递能力实现。没有一种策略在所有场景都更快。

Bounds 表达算法真正需要的能力

java
static <T extends Comparable<? super T>> T max(List<? extends T> xs)

这个签名说明:输入生产 T;T 能与自己或其上层类型比较。复杂约束的目的不是炫技,而是把实现依赖写进接口,让调用方和编译器都能检查。

过度通用会产生难懂错误与 API。若业务只有三个明确类型,封闭代数数据类型可能比十层泛型更清晰。

泛型 API 检查

  • 类型参数是生产、消费还是双向使用?
  • 运行时是否需要知道 T,当前实现保留多少类型信息?
  • 是否有 unchecked cast,它的安全不变量在哪里证明?
  • null、空集合和异常怎样进入类型契约?
  • 通用性是否真的带来复用,还是把领域约束藏进文档?

下一课用 sum/product types 与 exhaustive matching,让“不合法状态不可表示”从口号变成数据建模方法。

参考资料

Built with VitePress | Software Systems Atlas