《Java 编程入门》11.1 泛型类与方法

从仓储接口重复实现和强制类型转换的真实问题出发,讲清泛型类、泛型方法与有界类型参数如何把类型检查前移到编译期;再把 Repository<T, ID> 与 Result<T> 落到 library 项目,给出可运行的注册图书代码,并列出原始类型、基本类型参数等常见错误的真实报错。

本节目标:能写出泛型类、泛型方法和泛型接口,理解有界类型参数的作用,并把 library 的仓储层改造成 Repository<T, ID>,同时引入 Result<T> 表达「成功或失败」。
适用版本:JDK 25 LTS(Temurin 25.0.5);泛型语法在 JDK 21 上没有差异。

上一节留下的问题

第 10 章结束时,library 的仓储层能按 ISBN 存书、按会员号存人、按借阅号存借阅记录。为了做到这一点,我们写了三个很像的接口:

public interface BookRepository {
    void save(Book book);
    Book findByIsbn(String isbn);
    List<Book> findAll();
}

public interface MemberRepository {
    void save(Member member);
    Member findById(String id);
    List<Member> findAll();
}

LoanRepository 还要再写一遍,区别只是类型不同。方法体也几乎是复制粘贴:一个 Map、一次 put、一次 get、一次 values()。新增一种实体就要新增一套接口和实现,想写一个「给任何仓储都加统计」的工具方法也没有统一的类型可依赖。

一个直觉方案是把值统一存成 Object:

public final class ObjectRepository {
    private final Map<String, Object> store = new HashMap<>();

    public void save(String id, Object value) {
        store.put(id, value);
    }

    public Object findById(String id) {
        return store.get(id);
    }
}

类型信息在 get 的那一刻就丢了,调用方必须自己强转:

ObjectRepository repo = new ObjectRepository();
repo.save("M001", new Member("M001", "张三", "zhang@example.com"));
Book book = (Book) repo.findById("M001");

本机 JDK 25 运行这段代码的输出是:

Exception in thread "main" java.lang.ClassCastException: class com.plume.library.domain.Member cannot be cast to class com.plume.library.domain.Book (com.plume.library.domain.Member and com.plume.library.domain.Book are in unnamed module of loader 'app')
	at com.plume.library.demo.Demo11_0.main(Demo11_0.java:25)

编译器完全没意见,因为 Object 转任何引用类型都合法;错误被推迟到运行时,而且离真正写错的地方很远。泛型要解决的正是这件事:把容器里装的是什么类型写进类型系统,让编译器替你检查。

泛型类:把类型写成参数

先看一个最小的泛型类。它只做一件事:装一个值。

public final class Box<T> {
    private final T value;

    public Box(T value) {
        this.value = value;
    }

    public T get() {
        return value;
    }
}

T 是类型参数(type parameter),Book、String 这些是类型实参(type argument)。使用时:

Box<Book> bookBox = new Box<>(new Book("978-7-111-54742-6", "Effective Java", "Joshua Bloch", 2018));
Book book = bookBox.get();

bookBox.get() 的返回类型是 Book,不需要强转。右边 new Box<>(...) 里的 <> 叫钻石运算符(diamond),让编译器根据左边的 Box<Book> 推断类型实参。

泛型方法:类型参数放在返回值前

类型参数不一定属于整个类。只在一个方法里用到的类型,可以声明在方法上。下面这个方法找列表里的最大值:

public static <T extends Comparable<T>> T max(List<T> values) {
    if (values.isEmpty()) {
        throw new IllegalArgumentException("empty list");
    }
    T best = values.get(0);
    for (T value : values) {
        if (value.compareTo(best) > 0) {
            best = value;
        }
    }
    return best;
}

<T extends Comparable<T>> 是上界(upper bound):类型实参必须能和自己比较。String、Integer、LocalDate 都满足,调用时不需要写类型实参:

System.out.println(max(List.of("b", "a", "c")));
System.out.println(max(List.of(3, 1, 2)));

输出:

c
3

有界类型参数的价值在于:如果传入没有实现 Comparable 的类型,比如自定义的 Member,编译器会在调用点报错,而不是等到 compareTo 执行时才发现。上界也可以写多个,但类只能有一个且必须写在最前面,例如 <T extends Comparable<T> & Serializable>。

泛型接口:Repository<T, ID>

把上面的思路用到仓储层,接口可以收成一个:

public interface Repository<T, ID> {
    T save(T entity);

    Optional<T> findById(ID id);

    List<T> findAll();

    boolean deleteById(ID id);

    long count();
}

两个类型参数各有分工:T 是实体类型,ID 是主键类型。Book 用 ISBN 作主键,主键是 String;Loan 用自增编号作主键,主键是 Long。同一个接口能同时表达这两种仓储。

具体仓储只需要继承并补充领域查询:

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

通用实现也不需要知道具体实体是什么:

public class InMemoryRepository<T, ID> implements Repository<T, ID> {
    private final Map<ID, T> store = new LinkedHashMap<>();
    private final Function<T, ID> idOf;

    public InMemoryRepository(Function<T, ID> idOf) {
        this.idOf = idOf;
    }

    @Override
    public T save(T entity) {
        store.put(idOf.apply(entity), entity);
        return entity;
    }

    @Override
    public Optional<T> findById(ID id) {
        return Optional.ofNullable(store.get(id));
    }

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

    @Override
    public boolean deleteById(ID id) {
        return store.remove(id) != null;
    }

    @Override
    public long count() {
        return store.size();
    }
}

这里有一个看起来多余的设计:为什么把 idOf 作为构造参数传进来,而不是在 save 里直接调用 entity.getId()?因为 T 在运行时已经被擦除了,编译器不知道 T 有没有 getId(),甚至不知道 T 是不是一个类。Function<T, ID> 把「怎么从实体取主键」变成一段显式传入的代码,既绕开擦除限制,也让主键策略可以按实体定制。

图书仓储的实现就很薄了:

public final class InMemoryBookRepository
        extends InMemoryRepository<Book, String>
        implements BookRepository {

    public InMemoryBookRepository() {
        super(Book::isbn);
    }

    @Override
    public List<Book> findByAuthor(String author) {
        List<Book> result = new ArrayList<>();
        for (Book book : findAll()) {
            if (book.author().equals(author)) {
                result.add(book);
            }
        }
        return List.copyOf(result);
    }
}

Result:把成功和失败写进返回类型

泛型不只用于容器。「注册图书」有两种正常结果:注册成功,或者 ISBN 已存在。后者是用户输入问题,不是程序故障,适合放进返回值。先定义一个只可能有两种形态的结果类型:

public sealed interface Result<T> permits Result.Ok, Result.Err {

    record Ok<T>(T value) implements Result<T> { }

    record Err<T>(String message) implements Result<T> { }

    static <T> Result<T> ok(T value) {
        return new Ok<>(value);
    }

    static <T> Result<T> err(String message) {
        return new Err<>(message);
    }

    default <R> Result<R> map(Function<? super T, ? extends R> mapper) {
        if (this instanceof Ok<T> ok) {
            return Result.ok(mapper.apply(ok.value()));
        }
        if (this instanceof Err<T> err) {
            return Result.err(err.message());
        }
        throw new AssertionError("unreachable: " + getClass());
    }
}

Result<T> 是泛型接口,Ok<T> 和 Err<T> 是它的两个泛型实现;permits 把实现限定在这两个 record 上。map 把 Result<T> 变成 Result<R>,成功时转换值,失败时原样传递错误消息。这是泛型方法在接口默认方法里的典型用法。

LibraryService.registerBook 返回 Result<Book>:

public Result<Book> registerBook(Book book) {
    if (books.findById(book.isbn()).isPresent()) {
        return Result.err("ISBN 已存在: " + book.isbn());
    }
    return Result.ok(books.save(book));
}

调用方不需要猜「返回 null 是成功还是失败」,也不需要捕获一个只用于表达业务分支的异常。类型签名已经写清楚了:要么拿到 Book,要么拿到一条消息。

在 library 里落地

命名说明:从本节起,示例里的 Book 组件名写作 year,对应 9.1–10.3 的 publishedYear;后面的 JSON 与 CLI 也统一用 year,两者指同一个字段。

把上面的零件串起来:

Repository<Book, String> books = new InMemoryRepository<>(Book::isbn);
books.save(new Book("978-7-111-54742-6", "Effective Java", "Joshua Bloch", 2018));

Optional<Book> found = books.findById("978-7-111-54742-6");
System.out.println("found   = " + found);
System.out.println("missing = " + books.findById("978-7-000-00000-0"));

Result<String> title = Result.ok(found.orElseThrow()).map(Book::title);
System.out.println("title   = " + title.orElseThrow());

Result<Book> duplicate = Result.err("ISBN 已存在: 978-7-111-54742-6");
System.out.println("mapped  = " + duplicate.map(Book::title));

本机 JDK 25 输出:

found   = Optional[Book[isbn=978-7-111-54742-6, title=Effective Java, author=Joshua Bloch, year=2018]]
missing = Optional.empty
title   = Effective Java
mapped  = Err[message=ISBN 已存在: 978-7-111-54742-6]

在真正的服务里,registerBook 返回的 Result<Book> 可以直接用 map(Book::title) 取标题。第 13 章会讨论 Result<T> 和异常的分工:哪些失败应该返回结果,哪些应该抛出异常。现在先记住一点:泛型让「成功值」和「失败消息」都能带上具体类型,而不是退化成 Object。

常见错误与边界

错误一:使用原始类型

泛型类可以不带类型实参使用,这叫原始类型(raw type):

List<String> names = new ArrayList<>();
List raw = names;
raw.add(42);
String first = names.get(0);

编译时只有一个警告:

警告: [unchecked] 对作为原始类型List的成员的add(E)的调用未经过检查
        raw.add(42);
               ^

运行时会在 names.get(0) 抛出:

Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String

原始类型把泛型检查全部关掉了。除非调用老 API,否则不要写裸类型;需要装任意类型时显式写 List<Object>。

错误二:基本类型不能做类型参数

List<int> values;
错误: 意外的类型
    List<int> values;
         ^
  需要: 引用
  找到:    int

类型参数必须是引用类型。需要装 int 时用 Integer,自动装箱让你能写 List<Integer> values = List.of(1, 2, 3),但类型实参本身仍然是 Integer。

错误三:静态成员不能使用类级类型参数

public class Box<T> {
    static T value;
}
错误: 无法从静态上下文中引用非静态 类型变量 T
    static T value;
           ^

T 是每个 Box 实例在编译期的类型参数,静态成员属于类本身,没有「哪个 Box 的 T」可言。需要泛型的静态工具方法时,把 <T> 声明在方法上。

小结

  • 泛型把类型实参写进类型系统,让 Box<Book> 的 get 直接返回 Book,把强转错误从运行时提前到编译期。
  • 泛型方法把 <T> 声明在返回值前,类型通常由参数推断;有界类型参数用 extends 约束能力。
  • Repository<T, ID> 用一个接口覆盖图书、会员、借阅三类仓储,领域查询放在子接口里。
  • 通用实现需要 Function<T, ID> 取主键,因为运行时拿不到 T 的方法;这是擦除带来的设计约束。
  • Result<T> 用 sealed 接口和两个泛型 record 表达成功与失败,map 可以在不拆包的情况下转换成功值。
  • 原始类型会关闭泛型检查,基本类型不能做类型参数,静态成员不能引用类级类型参数。

Repository<Book, String> 能装书,但一个「接受任意图书集合」的方法仍然会卡住:List<Book> 不是 List<Object>。下一节讲通配符,以及 PECS 这条几乎每个 Java API 都在用的规则。

阅读导航:上一节:10.3 遍历、排序与比较器 · 下一节:11.2 通配符与 PECS 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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