《Java 高级编程》1.1 泛型边界:从 Book 到 Repository<T, ID>

libraryd 的仓储接口从每个实体一份复制粘贴,收敛到 Repository<T, ID>。本节解释两个类型参数各自约束什么、有界类型参数如何把可比较性写进签名、编译器生成的桥方法从哪来,并用 javap 看清擦除后的真实字节码,最后给出泛型仓储的边界清单。

本节目标:把 libraryd 里重复的仓储方法收敛成 Repository<T, ID>,并说清楚泛型能挡住哪些错误、挡不住哪些错误。
适用版本:JDK 25(Temurin 25.0.5),javac --release 25。

承接的问题

卷二结束时,libraryd 的 persistence 模块里有三套几乎一样的仓储:BookRepository 按 ISBN 查书,MemberRepository 按会员号查人,LoanRepository 按借阅号查借阅记录。三个类的 findById、findAll、save、deleteById 方法体复制粘贴,差别只有实体类型和主键类型。

复制粘贴的直接后果不是代码多,而是参数类型可以悄悄换错。BookRepository.findById(String isbn) 和 LoanRepository.findById(long id) 各自成立,但调用方很容易把会员号传给借阅仓储,编译器只能看到「都是字符串」或「都能转成 long」,帮不上忙。更麻烦的是,新增 Reservation 实体时,又要复制第四套。

这一节要回答三个问题:

  • Repository<T, ID> 的两个类型参数分别约束什么,为什么不能合并成一个。
  • 什么时候需要给类型参数加上界,上界如何改变方法签名。
  • 编译器为泛型子类生成的桥方法是什么,为什么 javap 里会多出几个方法。

最后会把泛型边界整理成一张可操作的检查表,后面 1.2 的型变和 1.3 的擦除都从这里继续。

两个类型参数各自约束什么

先把仓储端口写成两个参数:

package com.plume.library.domain;

import java.util.List;
import java.util.Optional;

public interface Repository<T, ID> {
    Optional<T> findById(ID id);
    List<T> findAll();
    void save(T entity);
    void deleteById(ID id);
}

T 是实体类型,ID 是主键类型。它们没有继承关系,也不应该被合并成 Repository<T> 再用 Object id。合并之后,findById 的签名退化成 findById(Object id),调用方可以传任何对象,编译器不再检查主键类型。两个参数的价值就在于把「Book 的主键是 String、Loan 的主键是 long」写进类型。

具体实体接口只需要绑定一次:

public interface BookRepository extends Repository<Book, String> {
    List<Book> findByAuthor(String author);
}

此时 BookRepository 继承了 Optional<Book> findById(String)、void save(Book) 等方法。调用方拿到的是具体类型,不需要强制转换。LoanRepository 则写成 extends Repository<Loan, Long>,主键用包装类型而不是 long,原因在后面的常见错误里说明。

泛型不是只在接口上摆样子。实现类必须给出具体类型,否则编译器会要求它继续声明类型参数:

public final class InMemoryBookRepository implements BookRepository {

    private final Map<String, Book> books = new LinkedHashMap<>();

    @Override
    public Optional<Book> findById(String isbn) {
        return Optional.ofNullable(books.get(isbn));
    }

    @Override
    public List<Book> findAll() {
        return List.copyOf(books.values());
    }

    @Override
    public void save(Book entity) {
        books.put(entity.isbn(), entity);
    }

    @Override
    public void deleteById(String isbn) {
        books.remove(isbn);
    }

    @Override
    public List<Book> findByAuthor(String author) {
        return findAll().stream()
                .filter(book -> book.author().equals(author))
                .toList();
    }
}

这里没有任何反射或强制转换,Map<String, Book> 的键和值都由类型参数固定。findByAuthor 是 Book 特有的查询,放在子接口而不是 Repository 上,避免所有实体都被迫实现用不到的方法。

有界类型参数:把可比较性写进签名

泛型方法的类型参数可以带上界。最常见的需求是排序:不是所有 T 都能比较,只有实现了 Comparable 的类型才可以。把上界写进签名,错误会在编译期暴露:

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

public final class LibrarySorter {

    public static <T extends Comparable<? super T>> T max(List<? extends T> values) {
        T best = values.getFirst();
        for (T value : values) {
            if (value.compareTo(best) > 0) {
                best = value;
            }
        }
        return best;
    }
}

T extends Comparable<? super T> 的意思是:T 必须能与自身或自身的父类型比较。写成 T extends Comparable<T> 也能工作,但对「父类实现 Comparable、子类继承」的类型更苛刻;? super T 放宽了这个限制。List<? extends T> 则表示传入 List<Book>、List<SubBook> 都可以,方法只读取不写入。

如果排序规则由调用方提供,上界应该落在 Comparator 上,而不是要求实体实现 Comparable:

public static <T> List<T> sortedCopy(List<? extends T> values,
                                     Comparator<? super T> order) {
    List<T> copy = new ArrayList<>(values);
    copy.sort(order);
    return List.copyOf(copy);
}

Comparator<? super T> 允许传入 Comparator<Object>、Comparator<Book>,只要它能比较 T。libraryd 的图书列表可以按年份、标题或作者排序,不需要 Book 实现 Comparable,也不需要在实体里塞一个「默认排序」。

本机把这几段放在一起跑:

BookRepository repository = new InMemoryBookRepository();
repository.save(new Book("9780132350884", "Clean Code", "Robert C. Martin", 2008));
repository.save(new Book("9780134685991", "Effective Java", "Joshua Bloch", 2018));

List<Book> recent = sortedCopy(repository.findAll(),
        Comparator.comparingInt(Book::year).reversed());
System.out.println("recent=" + recent.getFirst().title());

List<Integer> years = repository.findAll().stream().map(Book::year).toList();
System.out.println("maxYear=" + max(years));

本机输出:

recent=Effective Java
maxYear=2018

max 的实参是 List<Integer>,Integer 实现了 Comparable<Integer>,满足上界。把 Book 直接传给 max 会编译失败,因为 Book 没有实现 Comparable;这正是上界要挡住的情况。

桥方法与擦除的账单

泛型只存在于编译期。BookRepository 继承 Repository<Book, String> 之后,接口方法在字节码里被擦除成 findById(Object)、save(Object)。实现类写的是 findById(String)、save(Book),两者签名不一致,编译器会额外生成桥方法,把 Object 参数强转后转发给具体方法。

用 javap -c -p 看 InMemoryBookRepository 的真实输出(节选):

public java.util.Optional<com.plume.library.domain.Book> findById(java.lang.String);
  Code:
     0: aload_0
     1: getfield      #10                 // Field books:Ljava/util/Map;
     4: aload_1
     5: invokeinterface #16,  2           // InterfaceMethod java/util/Map.get:(Ljava/lang/Object;)Ljava/lang/Object;
    10: checkcast     #22                 // class com/plume/library/domain/Book
    13: invokestatic  #24                 // Method java/util/Optional.ofNullable:(Ljava/lang/Object;)Ljava/util/Optional;
    16: areturn

public java.util.Optional findById(java.lang.Object);
  Code:
     0: aload_0
     1: aload_1
     2: checkcast     #72                 // class java/lang/String
     5: invokevirtual #82                 // Method findById:(Ljava/lang/String;)Ljava/util/Optional;
     8: areturn

第二个 findById(Object) 就是桥方法。它没有写在源码里,是编译器为了满足接口的擦除签名而补的。save、deleteById 也有同样的桥方法。桥方法里的 checkcast 是泛型在运行时的最后一道检查:如果通过原始类型把 Integer 塞进 BookRepository.save,强转会抛 ClassCastException,而不是悄悄写进 Map。

这也解释了为什么泛型方法不能按「参数化类型」重载:

src/lab/ErasureErrors.java:10: error: name clash: print(List<Integer>) and print(List<String>) have the same erasure

两个 print 在字节码里都是 print(List),编译器无法区分。重载只能靠参数个数或擦除后不同的原始类型,不能靠泛型实参。

在 libraryd 里落地

libraryd-domain 只放端口和领域类型,libraryd-persistence 放实现。落地时遵守三条:

  1. Repository<T, ID> 只放四个通用方法,实体特有查询放在子接口。
  2. 主键类型用包装类型或不可变值对象,不用基本类型。
  3. 需要排序、过滤的通用算法写成泛型方法,上界和通配符写在签名上,不写在注释里。

Page<T> 是另一个必须提前定下来的泛型类型:

public record Page<T>(List<T> items, long total, int page, int size) {
    public Page {
        items = List.copyOf(items);
    }

    public int totalPages() {
        return size <= 0 ? 0 : (int) ((total + size - 1) / size);
    }
}

Page<Book> 和 Page<Member> 在源码里是不同类型,调用方不能把 Page<Book> 传给需要 Page<Member> 的方法。但到了运行时,两者都是 Page,items 都是 List。1.3 会利用这个差异做类型令牌,也会说明为什么不能靠 getClass() 恢复 Book 这个实参。

最后把通用查询收敛到一个地方。BookRepository 可以继承默认方法,但默认方法只能调用 Repository 已有的方法:

public interface BookRepository extends Repository<Book, String> {
    List<Book> findByAuthor(String author);

    default List<Book> findAllOrEmpty() {
        return findAll();
    }
}

默认方法适合「所有实现都一样的薄封装」,不适合放数据库逻辑。JDBC 实现会覆盖 findAll,默认方法自然调用到覆盖后的版本;但默认方法里不能假设 T 有某个字段,因为 T 在 Repository 层面是未知的。

常见错误与边界

「用原始类型接收泛型返回值。」 写 List books = repository.findAll(); 能编译,但之后 books.add("not a book") 也能编译,直到某处读取才抛 ClassCastException。原始类型会把泛型检查整体关掉,编译器只给一条 unchecked 警告。除非在跟遗留 API 交界,否则不要写原始类型。

「把基本类型当类型实参。」 Repository<Book, int> 直接编译失败:

error: unexpected type
class Prim { Repository<Book, int> repo; }
                              ^
  required: reference
  found:    int

类型参数只能接受引用类型,主键用 Integer 或 Long。这也意味着 findById(null) 在包装类型下是合法的,仓储实现要明确是否接受 null,不要把「找不到」和「参数为空」混成一个 Optional.empty()。

「在静态成员里使用类的类型参数。」 Repository<T, ID> 里写 static T create() 编译失败,因为静态成员不属于某个具体实例,运行时没有 T 的信息。需要静态工厂时,把类型参数声明在方法上:static <T> T create(Class<T> type)。

「以为 T 在运行时可用。」 new T()、new T[10]、T.class 都不成立。泛型擦除后 T 只剩上界(默认 Object)。需要在运行时创建数组或实例,必须把 Class<T> 或工厂对象显式传进来。

「把桥方法当成业务方法。」 用反射遍历 getDeclaredMethods() 时会看到 findById(Object) 这样的桥方法,Method.isBridge() 返回 true。做注解扫描或审计时应该跳过桥方法,否则同一个方法会被处理两次。

小结

  • Repository<T, ID> 用两个类型参数分别固定实体和主键,合并成 Object 会把主键类型检查让给运行时。
  • 实体特有查询放在子接口,通用方法留在 Repository,避免所有实体被迫实现用不到的方法。
  • 有界类型参数把「可比较」写进签名,<T extends Comparable<? super T>> 和 Comparator<? super T> 是两种不同的约束方式。
  • 泛型在字节码里被擦除,编译器会为具体实现生成桥方法,桥方法里的 checkcast 是运行时的最后一道检查。
  • 原始类型、基本类型实参、静态上下文、new T[] 是四个高频边界,遇到时先看擦除后的签名。
  • Page<T> 在源码里保留元素类型,运行时只剩 Page;需要恢复实参时,1.3 的类型令牌才有用。

泛型解决了「一个仓储适配多种实体」的问题,但查询 API 还会遇到另一个问题:方法参数到底该写 List<Book>、List<? extends Book> 还是 List<? super Book>。下一节用 PECS 把这三个选择定下来。

阅读导航:上一节:《Java 高级编程》目录 · 下一节:1.2 型变与通配符:PECS 在查询 API 里的落地 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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