跳到内容

第 9 卷 / 第 1 章 / 第 2 课

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 <: Animal,Producer<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 泛型主要通过类型擦除编译:参数化类映射到原始类,类型变量映射到最左边界;编译器插入转换,并按需生成桥接方法(bridge method)。两个普通 ArrayList 对象即使分别声明为存放 String 和 Integer,运行时仍是同一个类。声明元数据可以保留泛型签名,但无法对任意列表对象调用 getClass() 就读出元素类型实参。

不能直接写 new T() 或 new T[]。若 x 的静态类型是 Object,x instanceof List<String> 也不合法,因为它无法检查被擦除的元素类型。Java 16 起允许部分无需未检查转换的参数化测试,例如把 List<Integer> 测试为 ArrayList<Integer>;运行时仍只检查类,不逐个检查元素。API 需要显式运行时类型信息时,可传入 Class<T>、工厂或类型令牌。

Rust/C++ 常对具体类型实例化专用代码(monomorphization),能优化值布局和静态分派,但可能增加编译时间与代码体积。字典传递(dictionary passing)把所需操作的实现作为额外参数传入,编译器也可能对它专门化或内联。没有一种策略在所有场景都更快。

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

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

这是方法签名示意:输入提供可视为 T 的值;T 提供的比较操作能接受 T 或其父类型。约束把实现所需能力写进接口,供调用方和编译器检查。

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

动手区分编译期与运行期检查 ​

把下面代码保存为 VarianceDemo.java,用 JDK 17 或更高版本运行 java VarianceDemo.java。前面的短 Java 片段只展示声明与签名,并非独立程序。

java
import java.util.ArrayList;
import java.util.List;

class VarianceDemo {
    public static void main(String[] args) {
        List<Integer> integers = new ArrayList<>(List.of(1, 2));
        List<? extends Number> source = integers;
        List<? super Integer> sink = new ArrayList<Number>();
        sink.add(source.get(0).intValue());
        System.out.println(sink.get(0));
        // source.add(3);
        // List<Number> numbers = integers;

        Number[] array = new Integer[] {1};
        try {
            array[0] = 1.5;
        } catch (ArrayStoreException expected) {
            System.out.println(expected.getClass().getSimpleName());
        }

        Object unknown = integers;
        System.out.println(unknown instanceof List<?>);
        // System.out.println(unknown instanceof List<Integer>);
        System.out.println(integers instanceof ArrayList<Integer>);
    }
}

四行输出依次为 1、ArrayStoreException、true、true。分别取消每条注释:通过生产者视图添加元素、把 List<Integer> 赋给 List<Number>、把未知对象测试为 List<Integer>,都应编译失败。数组写入则能通过编译,在运行时失败。编译器和运行时各管一段,谁也没让那个 Double 悄悄变成 Integer。

泛型 API 检查 ​

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

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

参考资料 ​

Built with VitePress | Software Systems Atlas