第08章 泛型

7 阅读17分钟

本文参考:Java 中的泛型(两万字超全详解)

一、泛型的设计思想

泛型设计思想:类型参数化它允许你在定义类、接口或方法时,先不指定具体的操作类型,而是使用一个通用的占位符(如 <T>)。直到你真正使用它的时候,才传入具体的类型。

二、泛型的核心作用

泛型的核心作用:

  • 作用一:编译时类型检查
    它能把运行时的“类转换异常”(ClassCastException)提前暴露在编译阶段,在编译阶段生成字节码时进行类型的擦除,主要解决兼容老版本代码。
  • 作用二:自动类型转换
    在编译阶段编译器自动插入类型转换指令。防止“上层(Object)原始数据”被错误地赋值给“底层(具体类型)引用
  • 作用三:实现通用算法和复用
    你只需要写一个泛型方法/类,就能适配多种数据类型,避免了为每一种类型都重写一遍逻辑(比如排序算法、集合框架如 ArrayList 就是靠泛型支持所有对象)。
  • 作用四:桥接方法生成
    在编译阶段生成桥接方法。主要解决类型擦除后子类重写方法参数类型不一致导致的覆盖失败问题。
// 父类:泛型节点
class Node<T> {
    public void set(T data) {
        System.out.println("父类执行,数据是:" + data);
    }
}

// 子类:指定 T 为 Integer
class MyNode extends Node<Integer> {
    // 你写的重写方法
    @Override
    public void set(Integer data) {
        System.out.println("子类执行,整数是:" + data);
    }
}

// 测试多态
public class Test {
    public static void main(String[] args) {
        Node<Integer> node = new MyNode(); // 父类引用指向子类对象
        node.set(123); // 期望输出“子类执行”
    }
}

1). 编译期的“双重标准”(根源)
父类中包含的是未知的类型占位符(<T>),编译时必须擦除为最顶层的边界类型(此处为 Object),因此父类方法签名永久固定为 set(Object);而子类在继承时已明确指定了具体类型(Integer),因为该类型并非占位符而是真实类,所以子类手写的 set(Integer) 不予擦除,原样保留

2). 桥接方法的“填坑作用”(手段)
由于父类签名是 set(Object),子类签名是 set(Integer),二者参数不同,在 JVM 层面不构成重写,多态面临失效。为了弥补这一“签名断层”,编译器在子类字节码中自动合成一个参数为 Object 的桥接方法。这个桥接方法在签名上与父类完全一致,充当了“合法的重写者”。

3). 运行时的“多态流转”(结果)
当通过父类引用(Node<Integer>)调用 set() 时,JVM 严格遵循多态机制,根据运行类型(即子类对象) 查找签名完全匹配的方法。此时,子类中的桥接方法(set(Object))恰好命中,随后桥接方法内部完成强制类型转换,并最终回调至程序员手写的真实业务方法(set(Integer))。至此,既满足了 Java 语法规范,又保证了泛型代码在多态场景下的正确执行。

三、泛型类的定义

泛型类是指在类声明时通过 类型参数  来指定类中某些成员(字段、方法参数、返回值等)的具体类型,提高代码的复用性和类型安全。

3.1 基本语法

public class Animal<T> {
    T name;          // 使用类型参数 T 定义字段
    int age;         // 普通字段

    public Animal(T name, int age) {
        this.name = name;
        this.age = age;
    }
}
  • 类型参数用尖括号 <> 声明,放在类名之后。

  • 类型参数相当于一个占位符,在创建对象时会被实际类型替换(如 Animal<String>)。

  • 类型参数的命名通常使用单个大写字母,常见的有:

    • T – Type(类型)
    • E – Element(元素,常用于集合)
    • K – Key(键)
    • V – Value(值)

3.2 静态成员与泛型类的类型参数

泛型类中的静态方法和静态字段不能使用该类声明的类型参数。原因如下:

  • 类型参数的确定发生在 创建泛型类对象 时(即 new Animal<String>())。
  • 而静态成员在 类加载 时就已经完成初始化,并且可以直接通过类名调用,此时泛型类的类型参数尚未明确。因此,静态上下文无法感知具体的类型参数,强行使用会导致编译错误。
public class Animal<T> {
    private static T staticField;    // 编译错误:静态字段不能使用 T
    public static T staticMethod(T t) { // 编译错误:静态方法不能使用 T
        return t;
    }
}

💡 解决办法:如果静态方法需要具有泛型能力,应将其定义为 泛型方法(详见第四章),其类型参数独立于类的类型参数。

3.3 多个类型参数

泛型类可以同时声明多个类型参数,用逗号分隔,例如常见的 Map<K, V>

public class MyMap<K, V> {
    private K key;
    private V value;

    public MyMap(K key, V value) {
        this.key = key;
        this.value = value;
    }

    public K getKey() {
        return key;
    }

    public V getValue() {
        return value;
    }
}

使用时可以指定多个具体类型:

MyMap<String, Integer> map = new MyMap<>("age", 18);
String key = map.getKey();    // 类型安全,无需强制转换
Integer value = map.getValue();

3.4 泛型类的继承与实现

  • 子类在继承泛型父类时,可以选择 保留父类的类型参数 或 指定具体类型
  • 若子类也是泛型类,其类型参数可以与父类不同,但通常需要保持一致或增加新参数。

四、泛型方法的定义

泛型方法是指独立于类(或接口)泛型参数的方法,它可以在普通类、泛型类或接口中声明。泛型方法的类型参数在 方法返回值之前 声明,作用域仅限于该方法内部。

4.1 基本语法

public class Animal<T> {
    private T name;  // 类的类型参数

    public void setName(T name) {   // 使用类的类型参数
        this.name = name;
    }

    // 泛型方法,类型参数 <X> 独立于类的 <T>
    public static <X> X print(X x) {
        System.out.println(x);
        return x;
    }
}
  • 泛型方法的类型参数声明置于修饰符(如 public static)之后,返回值类型之前。
  • 方法中可以使用该类型参数定义参数、返回值、局部变量等。
  • 泛型方法的类型参数与类的类型参数 相互独立,即使名称相同也互不影响(但为了清晰,建议使用不同的名称)。

4.2 静态泛型方法

静态方法可以定义自己的泛型参数,这解决了静态成员无法使用类类型参数的问题。静态泛型方法在类加载时即可确定其泛型参数(根据调用时传入的实际参数推断)。

public class Utils {
    public static <T> T getMiddle(T a, T b, T c) {
        return (a == null ? (b == null ? c : b) : a); // 简单示例
    }
}

// 调用时类型推断:
String s = Utils.getMiddle("A", "B", "C");
Integer i = Utils.getMiddle(1, 2, 3);

五、泛型接口的定义

泛型接口与泛型类类似,在接口声明时引入类型参数,让实现类来决定具体类型或继续保留泛型。

5.1 基本语法

// 定义一个泛型接口
public interface Inter<T> {
    void show(T t);
}

5.2 实现方式一:在实现类中指定具体类型

实现类在实现泛型接口时,可以直接为类型参数指定一个具体类型,此时实现类可以不再声明泛型参数。

public class Animal<T> implements Inter<String> {
    T name;          // 类自己的类型参数,与接口无关

    @Override
    public void show(String s) {   // 接口的 T 被替换为 String
        System.out.println(s);
    }
}

这种方式使得实现类与接口的具体类型解耦,但该类仍可以拥有自己的泛型参数(如 T),两者互不干扰。

5.3 实现方式二:保留泛型参数

实现类也可以选择保留接口的泛型参数,即将接口的类型参数作为实现类的类型参数,此时实现类本身也必须是泛型类。

public class Animal<T> implements Inter<T> {
    T name;

    @Override
    public void show(T s) {   // 接口的 T 与类的 T 保持一致
        System.out.println(s);
    }
}

这种方式使得实现类的类型参数与接口同步,灵活性更高。

5.4 泛型接口中的静态成员限制

与泛型类相同,泛型接口中的静态方法和静态字段不能使用接口定义的类型参数。因为在加载时无法确定具体的泛型类型。

public interface Inter<T> {
    static T value;   // 编译错误
    static void print(T t) { } // 编译错误
}

5.5 泛型接口的默认方法和静态方法(Java 8+)

Java 8 起支持接口的 default 方法和 static 方法,但 default 方法可以使用接口的类型参数,因为它属于实例方法;而 static 方法仍然不能使用类型参数。

public interface Inter<T> {
    default T defaultValue() {
        return null;   // 可以使用 T
    }

    static void staticMethod() {
        // 不能使用 T
    }
}

小结

组件能否使用自身声明的类型参数能否使用外部类的类型参数备注
泛型类(非静态成员)实例成员可用
泛型类(静态成员)类加载时类型未确定
泛型方法✅(独立参数)✅(如果所在类是泛型类,可以混用)类型参数在方法调用时确定
泛型接口(非静态成员)default 方法也可使用
泛型接口(静态成员)与类同理

六、通配符

6.1 通配符的概念与必要性

在泛型编程中,由于类型擦除编译时类型检查的限制,泛型类型之间并不支持多态。例如,下面的代码会编译错误:

List<Number> list = new ArrayList<Integer>(); // 编译错误

虽然 Integer 是 Number 的子类,但 List<Integer> 并不是 List<Number> 的子类型。这种设计是为了避免类型安全问题——如果允许这种赋值,那么就可以向 List<Number> 中添加 DoubleFloat 等对象,而该集合实际存储的是 Integer,在取出时就会发生 ClassCastException

为了在保持类型安全的前提下,允许泛型类型在某种范围内灵活使用,Java 提供了通配符(Wildcard)机制。通配符用 ? 表示,它代表一种未知的类型,可以作为泛型类型参数的实际类型实参,从而解决类型参数之间的子类型关系问题。

通配符的三种形式:

通配符形式含义也称为
<?>无限制通配符(任意类型)无限定通配符
<? extends T>类型参数的上界为 TT 及其子类)上界通配符
<? super T>类型参数的下界为 TT 及其超类,直至 Object下界通配符

关键点:  通配符本身也是类型实参,它和 StringInteger 一样,是具体化后的类型,只不过它表示的是一个未知的范围受限的类型。


6.2 上界通配符 <? extends T>

6.2.1 基本用法

使用上界通配符可以将类型参数限制在某个类及其子类范围内,这样就能安全地将子类泛型对象赋值给父类通配符引用。

// 正确:List<? extends Number> 可以接受 List<Integer>
List<? extends Number> list = new ArrayList<Integer>();

示例:定义泛型算术类(使用上界通配符)

/**
 * 算术类,限定类型参数为 Number 的子类
 */
public class Arithmetic<T extends Number> {
    private final T value1;
    private final T value2;

    public Arithmetic(T value1, T value2) {
        this.value1 = value1;
        this.value2 = value2;
    }

    public double add() {
        return value1.doubleValue() + value2.doubleValue();
    }
}

// 测试类
public class Test {
    public static void main(String[] args) {
        Arithmetic<Integer> intArith = new Arithmetic<>(1, 2);
        process(intArith); // 可以传递,因为 Integer 是 Number 的子类
    }

    // 方法参数使用上界通配符,可以接收任意 Arithmetic<Number子类> 对象
    public static void process(Arithmetic<? extends Number> arith) {
        System.out.println(arith.add());
    }
}

6.2.2 上界通配符的“只读”特性(不能写)

虽然 <? extends T> 让引用变得更加灵活,但它也带来了一个重要的限制:不能向该集合中添加元素(除了 null

List<? extends Number> list = new ArrayList<Integer>();

list.add(10);      // 编译错误
list.add(3.14);    // 编译错误
list.add(null);    // 允许,因为 null 可以赋值给任何引用类型

原因分析:
List<? extends Number> 表示这是一个未知具体类型的 List,它可能是 List<Integer>List<Double> 或 List<Float>。编译器无法确定该集合实际存储的是哪种子类型,因此禁止添加任何具体类型的元素null 除外),以防止在读取时发生类型转换异常。

6.2.3 上界通配符的典型使用场景:读取数据

上界通配符非常适合用于读取集合中的元素,因为可以安全地将元素赋值给上界类型的引用。

public class Test {
    public static void main(String[] args) {
        List<Integer> ints = Arrays.asList(1, 2, 3);
        List<Double> doubles = Arrays.asList(1.1, 2.2, 3.3);
        
        printList(ints);
        printList(doubles);
    }

    // 方法可以接收任何 Number 子类的 List,并安全读取
    public static void printList(List<? extends Number> list) {
        for (Number n : list) {   // 上界为 Number,可以安全取出
            System.out.print(n + " ");
        }
        System.out.println();
    }
}

6.2.4 上界通配符总结

操作是否允许说明
读取(get)✅ 允许可以安全地赋值给上界类型 T 或其父类引用
写入(add)❌ 不允许(除 null)因为未知具体子类型,无法保证类型安全
适用场景生产者(Producer)——从结构中读取数据遵循 PECS 原则(见后文 6.5)

6.3 下界通配符 <? super T>

6.3.1 基本用法

下界通配符将类型参数限制在某个类及其超类范围内(直至 Object),从而允许将子类对象安全地添加到集合中。

// 正确:List<? super Integer> 可以接受 List<Number> 或 List<Object>
List<? super Integer> list = new ArrayList<Number>();

6.3.2 下界通配符的“可写”特性(可以写,但读取受限)

与上界通配符相反,下界通配符允许向集合中添加元素,但读取时只能取出 Object 类型(因为无法确定具体上界)。

List<? super Number> list = new ArrayList<Number>();

list.add(10);          // 允许(Integer 是 Number 的子类)
list.add(3.14);        // 允许(Double 是 Number 的子类)
list.add(new Object());// 编译错误!因为 Object 不是 Number 的子类

// 读取:只能作为 Object 接收
Object obj = list.get(0);   // 允许
Number num = list.get(0);   // 编译错误!不能确保元素是 Number 类型

原因分析:
List<? super Number> 的下界是 Number,即该集合可能是 List<Number>List<Object>等。编译器只知道该集合至少能容纳 Number 类型的元素,因此可以添加 Number 及其子类对象;但读取时,无法确定元素的具体父类型,只能安全地赋值给 Object,因为在传递前可能集合已有其它数据。

6.3.3 下界通配符的典型使用场景:写入数据

下界通配符非常适合用于向集合中写入数据。

public class Test {
    public static void main(String[] args) {
        List<Number> numberList = new ArrayList<>();
        fillNumbers(numberList);
        System.out.println(numberList); // [99, 3.14]

        List<Object> objectList = new ArrayList<>();
        fillNumbers(objectList); // 也可以传递 Object 类型的 List
        System.out.println(objectList); // [99, 3.14]
    }

    // 该方法可以向任何 Number 及其超类的 List 中添加 Number 对象
    public static void fillNumbers(List<? super Number> list) {
        list.add(99);      // Integer 是 Number 子类
        list.add(3.14);    // Double 是 Number 子类
        // list.add("text"); // 编译错误,String 不是 Number 子类
    }
}

6.3.4 下界通配符的局限:读取受限

如果需要同时读取下界通配符集合中的元素,只能使用 Object 引用,因为这是最安全的类型。

public static void printSuper(List<? super Number> list) {
    for (Object obj : list) {   // 只能使用 Object 遍历
        System.out.println(obj);
    }
    // for (Number n : list) { } // 编译错误
}

6.3.5 下界通配符总结

操作是否允许说明
读取(get)✅ 允许,但只能赋值给 Object因为未知具体上界,只能取出最宽类型
写入(add)✅ 允许添加 T 及其子类对象因为下界已知,可以安全添加
适用场景消费者(Consumer)——向结构中写入数据遵循 PECS 原则(见后文 6.5)

6.4 无限定通配符 <?>

无限定通配符表示类型参数没有任何限制,即可以是任意类型。它通常用于不依赖具体类型的操作(如判断集合是否为空、获取集合大小等),或者用于同时满足读取和写入都不重要的场景。

List<?> list = new ArrayList<String>();
list.add("hello"); // 编译错误!不能添加任何元素(除 null)
Object o = list.get(0); // 可以读取,但只能赋值给 Object

由于没有上下界限制,<?> 集合既不能写入具体元素(只能写 null),读取也只能得到 Object。它的主要作用是绕过类型检查,实现一些与类型无关的通用操作。

典型用法:

public static boolean isEmpty(List<?> list) {
    return list == null || list.size() == 0;
}

public static void clear(List<?> list) {
    list.clear(); // 清除元素与类型无关,允许
}

6.5 PECS 原则(Producer Extends, Consumer Super)

在实际使用通配符时,有一个重要的指导原则——PECS(Producer Extends, Consumer Super),它帮助我们决定何时使用 extends,何时使用 super

  • 如果泛型参数是“生产者”(Producer) ,即它提供数据(从中读取),使用 <? extends T>
  • 如果泛型参数是“消费者”(Consumer) ,即它消费数据(向其中写入),使用 <? super T>

经典示例:Java 集合框架中的 Collections.copy() 方法

public static <T> void copy(List<? super T> dest, List<? extends T> src) {
    // src 是生产者(提供元素),使用 extends
    // dest 是消费者(接收元素),使用 super
}
  • src 是源列表,它提供元素供复制,所以用 ? extends T(只能读)。
  • dest 是目标列表,它接收元素,所以用 ? super T(只能写)。

牢记 PECS:当需要从结构中获取数据时用 extends,当需要放入数据时用 super


6.6 通配符的捕获与辅助方法

在某些场景下,通配符类型无法直接进行某些操作(如交换两个元素),因为编译器不知道具体类型。这时可以通过通配符捕获(Wildcard Capture)来协助处理。

public static void swap(List<?> list, int i, int j) {
    swapHelper(list, i, j);
}

// 辅助方法,使用具体类型参数 T 捕获通配符
private static <T> void swapHelper(List<T> list, int i, int j) {
    T temp = list.get(i);
    list.set(i, list.get(j));
    list.set(j, temp);
}

在 swapHelper 中,编译器将 ? 推断为具体的类型 T,从而允许交换操作。这是通配符的一种常用技巧。


6.7 通配符使用的注意事项

  1. 通配符不能用于创建实例
    不能使用 new ArrayList<? extends Number>(),因为通配符不是具体类型,无法实例化。

  2. 通配符不能用于泛型类定义中的类型参数
    通配符只能出现在使用泛型的地方(如方法参数、变量声明),不能作为类或接口的类型参数声明。

    // 错误:不能在类声明中使用通配符
    public class MyClass<?> { } 
    
    // 错误:不能在类声明中使用通配符
    public class MyClass<? extend Number> { } 
    
  3. 通配符与泛型方法的选择
    如果方法需要多次使用同一个类型参数(如交换两个元素),推荐使用泛型方法(带类型参数),而不是通配符。泛型方法更灵活,且能保持类型关系。

  4. 通配符不能同时读写
    <? extends T> 只能读不能写,<? super T> 只能写(读受限),<?> 几乎不能写。如果需要同时读写,应使用具体泛型参数。


6.8 通配符总结对比表

通配符可读性(获取元素)可写性(添加元素)典型使用场景
<? extends T>✅ 可以安全读取为 T 类型❌ 不能添加(除 null)生产者,提供数据
<? super T>⚠️ 只能读取为 Object✅ 可以添加 T 及其子类消费者,接收数据
<?>⚠️ 只能读取为 Object❌ 不能添加(除 null)类型无关的操作

6.9 扩展

public class Arithmetic<T super Number> {
    private List<T> value1;

}

1. 语法规则差异

在 Java 中,super 和 extends 的使用场景被严格区分:

使用位置允许的语法作用
声明阶段class/interface/method 后的 <>只能用 extends(如 <T extends Number>定义类型参数的上限
使用阶段(变量、参数、返回值中的 <>extends 和 super 都可以用定义通配符的上界? extends T)或下界? super T)。

编译器在看到 public class Arithmetic<T super Number> 时,直接判定为非法语法,连编译期都过不去。


2. 为什么 Java 要禁止在类声明中使用 super?(逻辑层面的原因)

这不仅仅是语法刁难,而是有深刻的逻辑硬伤。我们来分析一下,如果 Java 允许你这么写,会引发什么灾难。

类里面写了 private List<T> value1;。假设编译器允许 T super Number,意味着 T 是 Number 的某个父类(可能是 NumberSerializableComparable 或 Object)。

问题来了:在类的内部代码中,这个 T 到底是什么?

  • 因为 T 可能是 Object,也可能是 Number。编译器无法确定 T 具体是哪一个。
  • 如果 T 是 Object,你在类里面就无法调用 Number 特有的方法(比如 intValue())。
  • 如果 T 是 Number,你在类里面存放 Integer 是安全的(因为向上转型)。