《Java 高级编程》1.3 类型擦除与类型令牌:TypeReference 与反射补偿

libraryd 的 JSON 层要反序列化 Page<Book>,但 Page.class 只保留原始类型,Book 这个实参在运行时已经消失。本节用真实编译错误说明擦除的边界,实现一个基于匿名子类的 TypeRef,并把 Page<Book>、Map<String, List<Book>> 的类型令牌接到接口响应解码上。

本节目标:说清泛型实参在运行时为什么消失,并用类型令牌把 Page<Book> 这类嵌套泛型保留下来。
适用版本:JDK 25(Temurin 25.0.5),javac --release 25。

承接的问题

1.2 的 Pages.map 能在编译期把 Page<Book> 映射成 Page<BookSummary>,因为方法签名里写着 Page<? extends T>。但 libraryd 的 HTTP 层还要做另一件事:把客户端传来的 JSON 反序列化成 Page<Book>。

问题在于,JSON 解码器拿到的方法参数通常只有一个 Class<?>:

Page<Book> page = codec.read(json, Page.class);

Page.class 的类型是 Class<Page>,不是 Class<Page<Book>>。Book 这个实参在编译后已经被擦除,Page.class 无法告诉解码器 items 里应该装什么。解码器只能把 items 读成 List<Map<String, Object>> 或 List<Object>,调用方再逐个强转。更糟的是,嵌套一层 Map<String, List<Book>> 时,连强转都写不出来。

这一节要回答:

  • 擦除到底擦掉了什么,哪些信息还在 class 文件里。
  • 类型令牌为什么能恢复 Page<Book>,它依赖的是哪一段元数据。
  • 类型令牌在什么情况下会失效,什么时候必须显式传入 Class<T>。

擦除擦掉了什么

先做最小对照。两个不同实参的 ArrayList,运行时是同一个类:

List<String> titles = new ArrayList<>();
List<Integer> years = new ArrayList<>();
System.out.println("sameClass=" + (titles.getClass() == years.getClass()));
System.out.println("className=" + titles.getClass().getName());
System.out.println("isList=" + (titles instanceof List<?>));

本机输出:

sameClass=true
className=java.util.ArrayList
isList=true

getClass() 返回的是擦除后的原始类,泛型实参不在其中。instanceof List<String> 也不成立,编译器只允许 instanceof List<?>。这不是实现偷懒,而是泛型的设计选择:为了兼容 2004 年之前的集合 API,泛型信息只保留在编译期和 class 文件的签名属性里,不进入对象的运行时类型。

第二组对照说明擦除对重载的影响:

static void print(List<String> values) {
}

static void print(List<Integer> values) {
}

本机编译错误(用 -J-Duser.language=en 得到的英文输出):

error: name clash: print(List<Integer>) and print(List<String>) have the same erasure

两个方法擦除后都是 print(List),JVM 无法区分。泛型不能用来做重载区分,只能靠参数个数或擦除后不同的原始类型。

第三组是反射能看到的部分。Page 的 items() 方法在 class 文件里保留了泛型签名:

Method items = Page.class.getMethod("items");
System.out.println("pageItemsReturn=" + items.getGenericReturnType().getTypeName());

本机输出:

pageItemsReturn=java.util.List<T>

注意这里得到的是 List<T>,不是 List<Book>。Page 类本身声明的是 record Page<T>,T 是类型变量;只有当一个具体子类或匿名子类把 T 绑定成 Book 时,反射才能从 getGenericSuperclass() 里读到 Page<Book>。这就是类型令牌的入口。

类型令牌:用匿名子类保住实参

类型令牌的核心是一个抽象类,它在构造时读取自己的泛型父类:

package com.plume.library.domain;

import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;

public abstract class TypeRef<T> {

    private final Type type;

    protected TypeRef() {
        Type superclass = getClass().getGenericSuperclass();
        if (!(superclass instanceof ParameterizedType parameterized)) {
            throw new IllegalStateException("TypeRef 必须通过匿名子类创建");
        }
        this.type = parameterized.getActualTypeArguments()[0];
    }

    public Type type() {
        return type;
    }

    public String describe() {
        return type.getTypeName();
    }

    public Class<?> rawType() {
        return switch (type) {
            case Class<?> clazz -> clazz;
            case ParameterizedType parameterized -> (Class<?>) parameterized.getRawType();
            default -> throw new IllegalStateException("无法取得原始类型: " + type);
        };
    }
}

用法是创建一个空的匿名子类:

TypeRef<List<Book>> books = new TypeRef<List<Book>>() {
};
TypeRef<Page<Book>> page = new TypeRef<Page<Book>>() {
};
TypeRef<Map<String, List<Book>>> nested = new TypeRef<Map<String, List<Book>>>() {
};

匿名子类的 class 文件里,getGenericSuperclass() 返回的是 TypeRef<java.util.List<Book>> 这样的 ParameterizedType。getActualTypeArguments()[0] 取出 List<Book>,再往下可以继续拆。本机输出:

booksType=java.util.List<com.plume.library.domain.Book>
pageType=com.plume.library.domain.Page<com.plume.library.domain.Book>
pageRaw=com.plume.library.domain.Page
nestedType=java.util.Map<java.lang.String, java.util.List<com.plume.library.domain.Book>>
wildcardType=java.util.List<? extends com.plume.library.domain.Book>

Jackson 的 TypeReference、Gson 的 TypeToken 用的是同一个技巧:让调用方在类型出现的那个位置写一次具体实参,框架通过匿名子类的泛型父类把它读回来。区别只在后续如何把 Type 映射成反序列化器。

Page<Book> 的令牌可以直接交给解码器。解码器拿到 ParameterizedType 后,知道原始类型是 Page,第一个实参是 Book,于是可以:

  1. 用 Page 的 canonical constructor 或反射创建实例。
  2. 把 items 的每个元素按 Book 的 record component 映射。
  3. 把 total、page、size 按 long、int 读取。

如果只传 Page.class,第 2 步没有 Book 的信息,只能得到 LinkedHashMap。类型令牌补的就是这一步。

类型令牌的边界

类型令牌不是万能的。它依赖「泛型实参写在一个真实存在的 class 文件里」,所以下面几种写法会失效。

在泛型方法里创建令牌。 下面的代码能编译,但拿不到调用方的实参:

static <T> TypeRef<T> genericRef() {
    return new TypeRef<T>() {
    };
}

本机输出:

genericRef=T class=sun.reflect.generics.reflectiveObjects.TypeVariableImpl

genericRef() 的匿名子类在编译时只知道 T,不知道调用方后来把 T 绑定成了什么。type 是一个 TypeVariable,describe() 返回 "T"。要恢复具体类型,必须在调用点写 new TypeRef<Book>() {},或者把 Class<T> 作为参数传进来。

通配符只能保留形状,不能保留具体元素。 new TypeRef<List<? extends Book>>() {} 得到的是 java.util.List<? extends Book>。这对「读取并检查」够用,但如果要创建 List<Book> 实例,仍然需要知道下界具体是谁。通配符表达的是「某个未知子类型」,不是「Book 本身」。

数组类型可以保留,但创建仍然要 Class。 new TypeRef<Book[]>() {} 的 describe() 是 com.plume.library.domain.Book[],rawType() 是 [Lcom.plume.library.domain.Book;。这能告诉解码器目标是一个 Book 数组,但 Array.newInstance 还需要组件类型;从 Type 到 Class 的转换要显式处理。

局部变量的泛型不可见。 List<Book> books = ... 的 books 变量本身没有运行时类型参数,只有它的声明位置(字段、方法参数、方法返回值、父类)可能带签名。类型令牌之所以有效,是因为匿名子类的父类声明带签名,不是因为变量名或赋值语句。

在 libraryd 里落地

HTTP 层解码分页响应时,把类型令牌作为显式参数:

package com.plume.library.web;

import com.plume.library.domain.Book;
import com.plume.library.domain.Page;
import com.plume.library.domain.TypeRef;

public final class BookJson {

    private final JsonCodec codec;

    public BookJson(JsonCodec codec) {
        this.codec = codec;
    }

    public Page<Book> decodePage(String json) {
        return codec.read(json, new TypeRef<Page<Book>>() {
        });
    }

    public String encodePage(Page<Book> page) {
        return codec.write(page, new TypeRef<Page<Book>>() {
        });
    }
}

JsonCodec.read 的签名从 read(String, Class<T>) 扩展成 read(String, TypeRef<T>)。这样调用方在写 new TypeRef<Page<Book>>() {} 的时候,就把 Book 带进了运行时。编码方向同样需要令牌,因为序列化器要知道 items 里每个元素按哪个类型写字段;只给 Page.class 时,它只能按 Object 处理。

第二个落点是通用的 API 响应包装。libraryd 的接口统一返回 ApiResponse<T>,里面的 data 可能是 Book、Page<Book> 或 List<Book>:

public record ApiResponse<T>(T data, String requestId) {
}

如果客户端只知道 ApiResponse.class,data 会被读成 LinkedHashMap。正确的调用是:

TypeRef<ApiResponse<Page<Book>>> type = new TypeRef<ApiResponse<Page<Book>>>() {
};
System.out.println("apiType=" + type.describe());

本机输出:

apiType=com.plume.library.web.ApiResponse<com.plume.library.domain.Page<com.plume.library.domain.Book>>

这一行 describe() 的输出就是解码器需要的全部形状信息。再往下,ApiResponse 的 data 是 Page<Book>,Page 的 items 是 List<Book>,每一层都能从 ParameterizedType 递归拆开。

第三个落点是仓储的泛型方法。不要试图在 Repository<T, ID> 内部用 new TypeRef<T>() {} 恢复 T;接口方法里的 T 在运行时是 TypeVariable。如果某个实现需要具体类型,把 Class<T> 或 TypeRef<T> 作为构造参数传进来:

public final class ReflectiveBookRepository implements Repository<Book, String> {

    private final Class<Book> bookType;

    public ReflectiveBookRepository(Class<Book> bookType) {
        this.bookType = bookType;
    }

    public Class<Book> bookType() {
        return bookType;
    }
}

这段代码看起来笨,但它把「运行时需要类型」这件事显式化了。泛型端口不需要知道 Class,只有需要反射的实现才需要。

常见错误与边界

「直接 new TypeRef<List<Book>>() 不带花括号。」 TypeRef 是抽象类,不能实例化。有人会写成 new TypeRef<List<Book>>() 然后奇怪为什么编译失败;必须写成 new TypeRef<List<Book>>() {},让编译器生成一个匿名子类,父类签名才会出现在 class 文件里。

「用 getClass() 取泛型实参。」 new TypeRef<Page<Book>>() {}.getClass() 得到的是匿名子类的 Class,它的 getGenericSuperclass() 才是 TypeRef<Page<Book>>。如果直接对 Page.class 调用 getTypeParameters(),得到的是 T,不是 Book。取错层次是类型令牌最常见的 bug。

「假设 getGenericSuperclass() 一定是 ParameterizedType。」 如果 TypeRef 被一个有名字的子类继承,比如 class BookPageRef extends TypeRef<Page<Book>>,那 getGenericSuperclass() 仍然是 TypeRef<Page<Book>>,没问题。但如果有人写 class RawRef extends TypeRef,父类就没有参数化,强转会抛 ClassCastException。构造器里用 instanceof 检查并抛出明确异常,比让强转在别处爆更好。

「把类型令牌当成运行时类型检查。」 TypeRef<List<Book>> 只描述「期望的形状」,不会让 List<Object> 变成 List<Book>。它适合驱动反序列化和 schema 生成,不适合替代 instanceof 或参数校验。反序列化完成后,仍然要校验 ISBN 格式、年份范围等业务约束。

「在热路径上反复创建令牌。」 new TypeRef<Page<Book>>() {} 会生成一个匿名类,虽然类只加载一次,但对象每次都会创建。把它缓存成 static final 字段:

private static final TypeRef<Page<Book>> PAGE_OF_BOOKS = new TypeRef<Page<Book>>() {
};

这样既避免重复分配,也让类型声明集中在一个地方。

小结

  • 泛型实参在运行时被擦除,List<String> 和 List<Integer> 共用 ArrayList 类,instanceof List<String> 不成立。
  • 擦除影响重载:参数化类型不同的方法不能共存,编译器会报 name clash ... same erasure。
  • class 文件仍保留声明位置的泛型签名;getGenericSuperclass() 是类型令牌读取实参的入口。
  • 类型令牌依赖匿名子类把实参写进父类签名;泛型方法里的 new TypeRef<T>() {} 只能得到 TypeVariable。
  • Page<Book>、Map<String, List<Book>>、ApiResponse<Page<Book>> 都能用同一套 ParameterizedType 递归拆解。
  • 需要在运行时创建实例或数组时,仍然要显式传入 Class<T>;类型令牌不替代运行时类型检查。

到这里,第 1 章的类型系统部分结束。第 2 章换一个方向:类型在编译期能表达的东西已经定下来了,接下来看运行时还能做什么——从 Class、Method 和访问控制开始。

阅读导航:上一节:1.2 型变与通配符:PECS 在查询 API 里的落地 · 下一节:2.1 反射核心:Class、Method 与访问控制 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发