Skip to content

泛型基础 ​

S1-020必学进阶约 90 分钟JDK 21编程语言基础 · 集合

1. 本节定位 ​

集合的每一节都写了 List<String>、Map<String, Integer>——尖括号里的就是泛型。

本节回答三个问题:它解决了什么问题、怎么写、有哪些做不到的事(类型擦除带来的限制)。第三点尤其重要:它解释了为什么不能 new T[]、为什么 List<String> 与 List<Integer> 在运行时是同一个类。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出泛型解决的痛点(把运行时错误提前到编译期);
  • 写出泛型类与泛型方法,并说清类型参数的作用域;
  • 用 <T extends Number> 写带边界的泛型;
  • 说清 ? extends 与 ? super 的区别,并知道 PECS 原则;
  • 解释类型擦除的影响(不能 new T()、不能 new T[]、instanceof 不能用);
  • 认出"原生类型(raw type)"这个隐患。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

Java 5 之前没有泛型,集合只能存 Object:

java
List list = new ArrayList();
list.add("字符串");
list.add(42);                    // 编译通过!运行时会出问题

for (Object item : list) {
    String text = (String) item;  // 取出 42 时抛 ClassCastException
}

问题在于:编译器明明能看出 42 不是字符串,但它不管——因为类型信息在放进集合时就被"擦掉"了。

泛型把这件事提前到编译期:

java
List<String> list = new ArrayList<>();
list.add("字符串");
list.add(42);                    // 编译错误:不兼容的类型

核心价值:把运行时错误变成编译期错误。 编译期发现的问题改一行就好,运行时发现的问题要等上线、要排查、要回滚。

5. 核心概念 ​

5.1 泛型类 ​

java
public class Box<T> {            // T 是类型参数,使用时才确定
    private T value;

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

    public T get() { return value; }
}

Box<String> textBox = new Box<>("内容");     // T = String
Box<Integer> numberBox = new Box<>(42);      // T = Integer

Box<T> 是"模板",Box<String> 才是"具体类型"。调用方指定类型,编译器帮你检查。

5.2 泛型方法 ​

java
static <T> T first(List<T> list) {           // 类型参数写在返回值之前
    return list.get(0);
}

String value = first(List.of("a", "b"));     // 编译器推断 T = String
int number = first(List.of(1, 2, 3));        // T = Integer

泛型方法可以不依赖类:上面的 first 放在任何类里都能用,类型由调用处推断。

5.3 类型参数命名约定 ​

字母含义例子
TType(通用类型)Box<T>
EElement(集合元素)List<E>
K / VKey / ValueMap<K, V>
RResult(返回类型)Function<T, R>
?未知类型(通配符)List<?>

这些不是语法要求,而是约定——看到 E 就知道是"集合里的元素"。

5.4 泛型边界 ​

java
static <T extends Number> double sum(List<T> numbers) {
    double total = 0;
    for (T number : numbers) {
        total += number.doubleValue();       // 因为知道 T 一定有 doubleValue()
    }
    return total;
}

extends 在这里表示"上界"(可以是类或接口):T 必须是 Number 或其子类。

好处:在方法内部可以安全地调用 Number 的方法。没有边界时,T 只能当 Object 用。

注意:<T extends Number & Comparable<T>> 可以写多个上界,但类必须写在前面。

5.5 通配符:? extends 与 ? super ​

java
// ? extends:只读(可以取出,不能放入)
static double sumAll(List<? extends Number> numbers) {
    double total = 0;
    for (Number number : numbers) {          // 取出时当成 Number
        total += number.doubleValue();
    }
    return total;
}

// ? super:只写(可以放入,取出只能当 Object)
static void addIntegers(List<? super Integer> target) {
    target.add(1);                            // 可以放入 Integer
    target.add(2);
}

PECS 原则(Producer Extends, Consumer Super):

场景用法记忆
你从集合读数据(生产者)? extends TProducer Extends
你往集合写数据(消费者)? super TConsumer Super
既读又写用具体类型(不加通配符)

sumAll(List<? extends Number>) 能接受 List<Integer>、List<Double>、List<Number>——这比写死 List<Number> 更灵活。

5.6 类型擦除:泛型的"真相" ​

java
Box<String> textBox = new Box<>("内容");
Box<Integer> numberBox = new Box<>(42);

textBox.getClass() == numberBox.getClass();        // true!
textBox.getClass().getName();                      // GenericsLesson$Box

泛型只存在于编译期。编译后类型参数被"擦除"(erase)成它的上界(没有上界就是 Object),运行时的类里没有任何 String / Integer 的信息。

这解释了泛型的所有限制:

不能做的事原因
new T()运行时不知道 T 是什么,无法调用构造方法
new T[10]同上
if (obj instanceof List<String>)运行时没有类型参数信息
static T field;静态成员属于类,而类型参数属于实例
重载 foo(List<String>) 与 foo(List<Integer>)擦除后签名相同,冲突

为什么 Java 要这么做:为了向后兼容——Java 5 之前的代码用的是 List(原生类型),如果泛型保留到运行时,老代码就全崩了。这是一个工程折中。

5.7 其他替代方案 ​

java
// 需要运行时类型信息时:传 Class 对象
static <T> T create(Class<T> type) throws Exception {
    return type.getDeclaredConstructor().newInstance();
}

// 需要泛型数组时:用 List<T> 替代,或传 Class<T> 辅助创建

这是 《反射基础》 反射 会展开的内容。

6. 递进示例 ​

6.1 最小示例:泛型类 ​

java
Box<String> box = new Box<>("内容");
System.out.println(box.get().length());      // 取出即可用,无需强转

6.2 业务示例:通用结果包装 ​

java
public class Result<T> {
    private final boolean success;
    private final T data;
    private final String error;

    public T getData() { return data; }
}

Result<List<Order>> result = service.query();     // 类型信息一路传递
List<Order> orders = result.getData();            // 不需要强转

这是真实项目里最常见的泛型用法:让类型信息沿着调用链传下去。

6.3 反例:三个"擦除"引起的错误 ​

java
// 错误一:泛型数组
T[] array = new T[10];                        // 编译错误

// 错误二:instanceof 带类型参数
if (obj instanceof List<String>) { }          // 编译错误

// 错误三:静态字段用类型参数
class Box<T> {
    static T defaultValue;                    // 编译错误
}

7. 完整可运行代码 ​

文件:labs/collections/src/main/java/cn/kb/learn/collection/GenericsLesson.java

java
package cn.kb.learn.collection;

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

public class GenericsLesson {
    /** 泛型类:类型参数由使用者指定 */
    static class Box<T> {
        private T value;

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

        T get() {
            return value;
        }

        void set(T value) {
            this.value = value;
        }

        @Override
        public String toString() {
            return "Box[" + value + "]";
        }
    }

    /** 泛型方法:类型参数写在返回值之前 */
    static <T> T first(List<T> list) {
        if (list == null || list.isEmpty()) {
            throw new IllegalArgumentException("列表不能为空");
        }
        return list.get(0);
    }

    /** 泛型边界:只接受 Number 及其子类 */
    static <T extends Number> double sum(List<T> numbers) {
        double total = 0;
        for (T number : numbers) {
            total += number.doubleValue();
        }
        return total;
    }

    /** 通配符上界:适合"只能读"的场景 */
    static double sumAll(List<? extends Number> numbers) {
        double total = 0;
        for (Number number : numbers) {
            total += number.doubleValue();
        }
        return total;
    }

    /** 通配符下界:适合"只能写"的场景 */
    static void addIntegers(List<? super Integer> target) {
        target.add(1);
        target.add(2);
    }

    @SuppressWarnings({"rawtypes", "unchecked"})
    public static void main(String[] args) {
        // 1. 不用泛型:把类型检查推到了运行时
        List rawList = new ArrayList();
        rawList.add("字符串");
        rawList.add(42);
        try {
            for (Object item : rawList) {
                String text = (String) item;
                System.out.println("取出并转为字符串: " + text);
            }
        } catch (ClassCastException exception) {
            System.out.println("原生类型导致运行时错误: ClassCastException(取出 42 时)");
        }

        // 2. 泛型类:编译期就知道类型
        Box<String> textBox = new Box<>("内容");
        Box<Integer> numberBox = new Box<>(42);
        System.out.println(textBox + " / " + numberBox);
        System.out.println("取出即用,无需强转,长度: " + textBox.get().length());

        // 3. 泛型方法:调用时自动推断
        System.out.println("第一个元素: " + first(List.of("a", "b")));
        System.out.println("第一个数字: " + first(List.of(1, 2, 3)));

        // 4. 泛型边界
        System.out.println("整数求和: " + sum(List.of(1, 2, 3)));
        System.out.println("小数求和: " + sum(List.of(1.5, 2.5)));

        // 5. 通配符
        System.out.println("通配符读取: " + sumAll(List.of(1, 2.5, 3L)));
        List<Number> numbers = new ArrayList<>();
        addIntegers(numbers);
        System.out.println("通配符写入: " + numbers);

        // 6. 类型擦除:运行时不保留类型参数
        System.out.println("运行时类型: " + textBox.getClass().getName());
        System.out.println("Box<String> 与 Box<Integer> 是同一个类: " + (textBox.getClass() == numberBox.getClass()));
    }
}

运行命令:

powershell
cd labs
./mvnw -pl collections compile
java -cp collections/target/classes cn.kb.learn.collection.GenericsLesson

预期输出:

text
取出并转为字符串: 字符串
原生类型导致运行时错误: ClassCastException(取出 42 时)
Box[内容] / Box[42]
取出即用,无需强转,长度: 2
第一个元素: a
第一个数字: 1
整数求和: 6.0
小数求和: 4.0
通配符读取: 6.5
通配符写入: [1, 2]
运行时类型: cn.kb.learn.collection.GenericsLesson$Box
Box<String> 与 Box<Integer> 是同一个类: true

前两行与最后一行的对比是本节的核心:

  • 第 1、2 行:没有泛型 → 错误在运行时才暴露;
  • 最后两行:有泛型 → 但运行时类型信息已经被擦除(Box<String> 与 Box<Integer> 是同一个类)。

泛型的价值不在运行时,而在编译期。

8. Debug 观察点 ​

  1. 在 textBox.getClass() == numberBox.getClass() 处断点,观察两个对象的类完全相同——擦除的直接证据;
  2. 在 first(List.of("a", "b")) 处 F7 进入 first,观察参数 list 在调试器里的类型是 List(没有 <String>)——调试器也不保留泛型信息;
  3. 给 sum 传入 List.of("a"),观察编译错误信息("类型参数 String 不在类型变量 T 的范围内");
  4. 试着写 new T[10](在泛型类内部),观察编译错误,理解擦除带来的限制。

9. 常见错误 ​

错误 1:使用原生类型 ​

java
List list = new ArrayList();          // 原生类型:丢掉所有类型检查
list.add("a");
list.add(42);                         // 编译通过,运行时可能出错

修复:始终写明类型参数。IDE 会给出 "Raw use of parameterized class" 警告。

错误 2:以为 List<Object> 能接收 List<String> ​

java
List<Object> objects = new ArrayList<String>();      // 编译错误

原因:泛型不支持协变。String 是 Object 的子类,但 List<String> 不是 List<Object> 的子类——否则就能往里放 Integer,破坏类型安全。

修复:用通配符 List<? extends Object>(等价于 List<?>)。

错误 3:把泛型当运行时检查 ​

java
if (list instanceof List<String>) { }        // 编译错误
if (list instanceof List<?>) { }             // 正确:只能判断是不是 List

错误 4:滥用通配符导致不能写 ​

java
static void addAll(List<? extends Number> target, Number value) {
    target.add(value);                       // 编译错误
}

修复:需要写入时用 ? super T(PECS 原则)。

10. 动手实验 ​

目标:写一个泛型工具类,并体会擦除的限制。

步骤:

  1. 运行第 7 节代码,核对输出;
  2. 写泛型方法 <T> List<T> filter(List<T> source, Predicate<T> predicate):返回满足条件的元素(不修改入参);
  3. 写泛型类 Pair<K, V>:持有两个值,提供 key() 与 value() 方法,并支持"交换"(返回新的 Pair<V, K>);
  4. 写带边界的泛型方法 <T extends Comparable<T>> T max(List<T> values):返回最大值;
  5. 试着在泛型类里写 T[] array = new T[10],记录编译错误信息,并写出两种替代方案。

验收方式:能解释第 5 步的报错原因与替代方案。

扩展任务:对比 List<? extends Number> 与 List<Number> 作为参数时的差别——分别用 List<Integer>、List<Double>、List<Number> 调用,看哪些能通过编译。这能帮你彻底理解"通配符放宽了什么"。

11. 练习题 ​

基础题

  1. 判断:List<String> 与 List<Integer> 在运行时是不同的类。(对 / 错)
  2. 填空:泛型边界用 ______ 关键字;从集合读数据用 ______,写数据用 ______。
  3. 简答:泛型最核心的价值是什么?

代码阅读题

java
static <T> void copy(List<? super T> dest, List<? extends T> src) {
    for (T item : src) {
        dest.add(item);
    }
}

List<Number> numbers = new ArrayList<>();
copy(numbers, List.of(1, 2.5));
System.out.println(numbers);
  1. 输出是什么?copy 的参数为什么这样设计(提示:这是 Collections.copy 的真实签名)?

编程题

  1. 写一个泛型缓存类 Cache<K, V>:
    • 内部用 Map<K, V> 存储;
    • 方法 V get(K key, Function<K, V> loader):缓存命中直接返回,未命中则调用 loader 计算并缓存;
    • 方法 void put(K key, V value)、V getIfPresent(K key)、int size();
    • 说明泛型在这里带来的好处。

综合任务

  1. 设计一个"通用查询结果"类型:
    • PageResult<T>:包含 List<T> records、long total、int page、int size;
    • 提供 int totalPages()、boolean hasNext() 两个方法;
    • 写一个静态工厂方法 <T> PageResult<T> of(List<T> records, long total, int page, int size);
    • 再写一个方法 PageResult<String> mapToString() 或类似的转换方法,说明为什么转换后类型参数会变;
    • 用 PageResult<Order> 与 PageResult<String> 各创建一个实例验证。
分级提示

提示 1(方向):第 4 题的关键是 PECS——src 是生产者(读),dest 是消费者(写)。

提示 2(关键点):第 5 题的 get 方法用 computeIfAbsent 实现只需一行:return map.computeIfAbsent(key, loader)。

提示 3(接近答案):第 6 题的 mapToString 可以写成实例方法 PageResult<R> map(Function<T, R> mapper),用 records.stream().map(mapper).toList() 生成新结果。

12. 参考答案 ​

基础题
  1. 错。类型擦除后,List<String> 与 List<Integer> 在运行时都是 List(同一个类)。泛型只存在于编译期。
  2. extends、? extends T(PECS:Producer Extends)、? super T(Consumer Super)。
  3. 把类型错误从运行时提前到编译期。这让问题在编码阶段就被发现,而不是等到测试或上线。
代码阅读题(第 4 题)

输出 [1, 2.5]。

参数设计体现了 PECS:

  • src 是生产者(从它读取元素)→ 用 ? extends T,这样 List<Integer> 也能传进来;
  • dest 是消费者(向它写入元素)→ 用 ? super T,这样 List<Object>、List<Number> 都能作为目标。

如果写成 copy(List<T> dest, List<T> src),那么 copy(numbers, List.of(1, 2.5)) 就无法编译——因为 List.of(1, 2.5) 的类型是 List<Number>,而 numbers 也是 List<Number>,看起来能行;但换成 copy(numbers, List.of(1, 2))(List<Integer>)就不行了。

这就是通配符的价值:让方法接受更宽泛的实参类型,同时保持类型安全。

编程题(第 5 题)
java
package cn.kb.learn.collection;

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;

public class Cache<K, V> {
    private final Map<K, V> store = new HashMap<>();

    /** 命中直接返回,未命中用 loader 计算并缓存 */
    public V get(K key, Function<K, V> loader) {
        return store.computeIfAbsent(key, loader);
    }

    public void put(K key, V value) {
        store.put(key, value);
    }

    public Optional<V> getIfPresent(K key) {
        return Optional.ofNullable(store.get(key));
    }

    public int size() {
        return store.size();
    }

    public static void main(String[] args) {
        Cache<String, Integer> wordLength = new Cache<>();
        System.out.println("第一次(计算): " + wordLength.get("java", String::length));
        System.out.println("第二次(命中缓存): " + wordLength.get("java", key -> {
            System.out.println("  不会执行到这里");
            return -1;
        }));
        System.out.println("缓存大小: " + wordLength.size());
        System.out.println("不存在的 key: " + wordLength.getIfPresent("missing"));
    }
}

泛型带来的好处:Cache<String, Integer> 在编译期就保证"key 是字符串、value 是整数",取出时不用强转;同时这个类可以复用在任何 key/value 组合上(Cache<Long, Order>、Cache<String, List<User>>),代码只写一次。

预期输出:

text
第一次(计算): 4
第二次(命中缓存): 4
缓存大小: 1
不存在的 key: Optional.empty
综合任务(第 6 题)
java
package cn.kb.learn.collection;

import java.util.List;
import java.util.function.Function;

public class PageResult<T> {
    private final List<T> records;
    private final long total;
    private final int page;
    private final int size;

    private PageResult(List<T> records, long total, int page, int size) {
        this.records = List.copyOf(records);
        this.total = total;
        this.page = page;
        this.size = size;
    }

    public static <T> PageResult<T> of(List<T> records, long total, int page, int size) {
        return new PageResult<>(records, total, page, size);
    }

    /** 把每条记录转换成另一种类型,返回新的分页结果 */
    public <R> PageResult<R> map(Function<T, R> mapper) {
        return PageResult.of(records.stream().map(mapper).toList(), total, page, size);
    }

    public int totalPages() {
        return size <= 0 ? 0 : (int) Math.ceil((double) total / size);
    }

    public boolean hasNext() {
        return (long) page * size < total;
    }

    public List<T> records() {
        return records;
    }

    @Override
    public String toString() {
        return "PageResult{records=" + records + ", total=" + total
                + ", page=" + page + ", size=" + size + "}";
    }

    public static void main(String[] args) {
        PageResult<String> names = PageResult.of(List.of("张三", "李四"), 5, 1, 2);
        System.out.println(names);
        System.out.println("总页数: " + names.totalPages() + ",还有下一页: " + names.hasNext());

        PageResult<Integer> lengths = names.map(String::length);
        System.out.println("转换后类型参数变成 Integer: " + lengths);
    }
}

为什么转换后类型参数会变:map 方法的签名是 <R> PageResult<R> map(Function<T, R> mapper)——它把 T 转成了 R,所以返回的是 PageResult<R>。类型参数沿着转换链传播,编译器全程跟踪,这就是泛型在 API 设计里的核心作用。

预期输出:

text
PageResult{records=[张三, 李四], total=5, page=1, size=2}
总页数: 3,还有下一页: true
转换后类型参数变成 Integer: PageResult{records=[2, 2], total=5, page=1, size=2}

13. 掌握标准 ​

  • [ ] 能写出泛型类与泛型方法;
  • [ ] 会用 extends 写带边界的泛型;
  • [ ] 知道 PECS 原则,能解释 ? extends 与 ? super 的区别;
  • [ ] 能解释类型擦除及其带来的限制;
  • [ ] 不再使用原生类型,并能看懂编译器的相关警告。

14. 扩展资源 ​

15. 下一步学习 ​

《异常体系与处理》:把 《读懂 Java 错误信息》 的"读异常"升级为"设计异常"。

后续知识

暂无

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引