【Java】Java中的泛型


一、泛型

1.1 泛型的概念

在Java中,泛型是一种在编译时期类型检查的机制,它使得我们能够创建具有通用行为的类、接口和方法,以适应不同类型的数据。通过使用泛型,可以提高代码的复用性、类型安全性以及可读性

泛型是在JDK 1.5 引入的新语法,通俗来讲,泛型就是适应多种类型从代码上来看,就是对类型实现了参数化,即从传入的类型参数来确定数据的具体类型。

1.2 为什么要有泛型

首先通过一个案例来说明:实现一个类,类中包含一个数组成员,使得数组中可以存放任何类型的数据,也可以根据成员方法返回数组中某个元素的值。

实现思路:在Java中,因为所有类的父类都是Object类,因此可以将数组的类型定义为Object[]类型,这样就能存放任何类型的元素了。

实现代码如下:

class MyArray {
    public Object[] array = new Object[10];

    public Object getPos(int pos) {
        return this.array[pos];
    }

    public void setVal(int pos, int val) {
        this.array[pos] = val;
    }
}

public class Test {
    public static void main(String[] args) {
        MyArray myArray = new MyArray();
        myArray.setVal(0, 10);
        myArray.setVal(1, "hello"); // 字符串也可以存放
        String ret = myArray.getPos(1); // 编译报错
        System.out.println(ret);
    }
}

上述代码定义了一个名为MyArray的类,其中包含一个类型为Object的数组array,长度为10。类中还包含了getPossetVal方法,分别用于获取指定位置的元素和设置指定位置的值。

  • Test类的main方法中,首先创建了一个MyArray对象myArray。然后,通过setVal方法在位置0设置了一个整数值10,并在位置1设置了一个字符串值"hello"。然而,在尝试使用getPos方法获取位置1的值并将其赋给字符串变量ret时,会导致编译错误。

  • 这是因为getPos方法返回的是Object类型,而我们尝试将其赋给String类型的变量,这会引发类型转换错误。尽管字符串可以存储在Object数组中,但在使用时需要进行相应的类型转换才能得到正确的结果。

虽然上面定义的数组可以存放任何类型的数据,但是在更多的情况下,我们更希望创建的一个数组只支持存放一种类型的数据,而不是同时支持多种类型。所以泛型的主要目的就是指定当前的容器存放特定类型的数据,在代码编译的时候,推导出指定的类型,被对传入的数据进行类型的检查。

二、泛型类

2.1 基本语法

泛型类的语法如下:

class ClassName<T> {
    // 泛型类的成员变量和方法
}

在上述语法中,ClassName是泛型类的名称,T是类型参数的占位符,可以是任何合法的标识符,表示在使用泛型时所代表的具体类型。在泛型类中,可以使用类型参数 T 来声明成员变量、方法参数、方法返回类型等,从而使这些成员具有通用性,可以适用于不同类型的数据。

2.2 使用示例

将上面的MyArray修改为泛型版本:

class MyArray<T> {
    public T[] array;

    public MyArray(int size) {
        this.array = (T[]) new Object[size];
    }

    public T getPos(int pos) {
        return this.array[pos];
    }

    public void setVal(int pos, T val) {
        this.array[pos] = val;
    }
}

public class Test {
    public static void main(String[] args) {
        MyArray<Integer> myArray = new MyArray<>(10);
        myArray.setVal(0, 10);
        // myArray.setVal(1, "hello"); // 编译错误
        Integer num = myArray.getPos(0);
        System.out.println(num);
    }
}

在上述修改后的代码中,将MyArray类修改为泛型类,并使用类型参数T来表示数组中的元素类型。在构造函数中,使用类型转换将Object数组转换为T类型的数组。然后,可以在main方法中创建一个MyArray<Integer>对象,并使用泛型方法进行类型安全的操作。

但需要注意的是,由于Java的类型擦除机制,泛型数组的创建会导致编译器发出警告。在这种情况下,我们使用了类型转换(T[])来创建数组,但是在运行时,无法获取确切的泛型类型信息。因此,需要注意对泛型数组的操作,并尽可能避免在泛型数组上进行类型检查和类型转换的操作。

2.3 类型推导

当编译器可以根据上下文推导出类型实参时,可以省略类型实参的填写,例如:

MyArray<Integer> list = new MyArray<>(); // 可以推导出实例化需要的类型实参为 Integer

2.4 裸类型

裸类型是一个泛型类但没有带着类型实参,例如 MyArray 就是一个裸类型:

MyArray list = new MyArray();

注意: 我们不要自己去使用裸类型,裸类型是为了兼容老版本的 API 保留的机制。

三、泛型的编译机制

3.1 擦除机制

Java中的泛型是通过擦除机制(Type Erasure)来实现的。擦除机制是指在编译时将泛型类型的信息擦除,并将泛型类型视为其上界(或Object类型)。

擦除机制的主要目的是为了兼容泛型类型与非泛型类型的代码,使得泛型在编译后的字节码中不再包含具体的泛型类型信息,以保持与Java早期版本的兼容性。

具体来说,擦除机制的几个重要特点如下:

  1. 类型擦除:在编译时,所有泛型类型参数都会被擦除,被替换为它们的上界(或Object类型)。例如,List<T>会被擦除为List<Object>

  2. 类型边界:泛型类型的类型参数在擦除时会被替换为其上界。如果没有显式指定上界,将默认使用Object作为上界。

  3. 类型转换:在需要进行泛型类型的转换时,会自动生成桥方法(Bridge Method)来处理类型转换的问题。

  4. 限制操作:由于擦除机制,泛型类型在运行时无法获得泛型类型的具体信息。因此,无法直接创建泛型类型的实例、获取泛型类型的具体参数等操作。

尽管擦除机制限制了在运行时对泛型类型的具体操作,但在编译时,编译器仍会进行类型检查,并确保泛型类型的类型安全性。

需要注意的是,擦除机制并不适用于所有情况。例如,在获取泛型类的父类或接口时,可以通过反射获得泛型类型的具体参数。此外,在使用特定的标记(如Class<T>)或使用通配符(如List<?>)时,编译器会生成额外的信息来帮助泛型类型的处理。

总之,擦除机制是Java泛型的实现方式之一,通过在编译时擦除泛型类型的具体信息来保持与旧版本Java代码的兼容性。尽管擦除机制存在一些限制,但它仍能提供编译时的类型检查和类型安全性。

3.2 为什么不能实例化泛型类型数组

  1. 初始化泛型数组对象

在Java中,不能直接实例化泛型类型的数组,这是因为Java的泛型是通过类型擦除(Type Erasure)来实现的。类型擦除是指在编译时将泛型类型的相关信息擦除,并在运行时将泛型类型视为其上界(或Object类型)。

由于类型擦除的机制,泛型类型的数组在运行时实际上是Object类型的数组。因此,当我们尝试实例化一个泛型类型的数组时,会发生编译器警告或错误。

例如,在以下代码中:

T[] array = new T[10]; // 编译错误

由于类型擦除,编译器无法确定T的具体类型,因此无法直接创建泛型类型的数组。为了解决这个问题,可以使用类型转换来创建一个Object数组,并在需要时进行类型转换。

以下是一个示例,展示了如何创建泛型类型的数组:

@SuppressWarnings("unchecked")
T[] array = (T[]) new Object[10];

需要注意的是,由于类型擦除,这种方式创建的数组并不是类型安全的,可能会导致运行时的类型转换异常。因此,在使用泛型类型的数组时,需要格外小心,并确保在操作数组元素时进行正确的类型检查和类型转换。

  1. 泛型数组类型不能做方法的返回值

例如以下代码:

class MyArray<T> {
    public T[] array = (T[]) new Object[10];

    public T getPos(int pos) {
        return this.array[pos];
    }

    public void setVal(int pos, T val) {
        this.array[pos] = val;
    }

    public T[] getArray() {
        return array;
    }
}

public class TestDemo {
    public static void main(String[] args) {
        MyArray<Integer> myArray1 = new MyArray<>();
        Integer[] strings = myArray1.getArray();
    }
}

在这个代码中,泛型类MyArray<T>有一个泛型数组array,用于存储元素。在getArray()方法中,返回了泛型数组array

然而,这段代码会引发ClassCastException异常,因为在Java中无法直接创建具体类型的泛型数组。在这种情况下,使用(T[]) new Object[10]语法创建的实际上是一个Object[]数组,而不是T[]泛型数组。

为了避免这个异常,可以修改代码以返回一个类型擦除后的数组,即将getArray()方法的返回类型修改为Object[]

public Object[] getArray() {
    return array;
}

这样修改后,可以成功编译和运行代码,但需要在使用返回的数组时进行类型转换和类型检查。

四、泛型的上界

泛型的上界(Upper Bound)是指限定泛型类型参数必须是指定的类型或其子类型。通过指定上界,可以限制泛型类型参数的范围,使其只能接受特定的类型或其子类型。

在Java中,使用 extends 关键字来指定泛型的上界。基本语法如下:

class MyClass<T extends SomeClass> {
    // 泛型类型参数 T 受限于 SomeClass 或其子类
    // 其他代码...
}

上界示例:将MyArray的泛型类型参数上界设置为Number,示例代码如下:

class MyArray<T extends Number> {
    private T[] array;

    public MyArray(int size) {
        this.array = (T[]) new Number[size]; // 注意:在泛型中无法直接创建 T 类型数组,可以用 Number 类型代替
    }

    public T getPos(int pos) {
        return array[pos];
    }

    public void setVal(int pos, T val) {
        this.array[pos] = val;
    }
}

public class Test {
    public static void main(String[] args) {
        MyArray<Integer> myArray = new MyArray<>(10);
        myArray.setVal(0, 10);
        myArray.setVal(1, 20);

        MyArray<Double> myArray2 = new MyArray<>(5);
        myArray2.setVal(0, 3.14);
        myArray2.setVal(1, 2.71);
    }
}

在上面的示例中,泛型类 MyArray 有一个泛型类型参数 T,其上界被限制为 Number 类型。这意味着泛型类型参数 T 必须是 Number 或其子类,因此可以使用 IntegerDouble 等继承自 Number 的类型作为泛型参数。同时在 MyArray 类的构造函数中,由于无法直接创建 T 类型的数组,因此使用了 Number 类型的数组来代替。

五、泛型方法

泛型方法是定义在类中的方法,可以在方法中使用独立于类定义的泛型类型参数。通过泛型方法,可以在方法级别上使用泛型,灵活地处理不同类型的数据。

泛型方法的语法如下:

public <T> void methodName(T parameter) {
    // 泛型方法的实现
    // 可以使用泛型类型参数 T 进行操作
}

上述语法中,<T>表示泛型类型参数的声明,可以在方法的返回类型之前或方法参数列表之前进行声明。在方法内部,可以使用泛型类型参数 T 进行操作,包括创建对象、调用方法、进行类型转换等。

下面是一个示例,包括非静态和静态的泛型方法:

public class GenericMethods {
    public <T> void swap(T[] array, int i, int j) {
        T temp = array[i];
        array[i] = array[j];
        array[j] = temp;
    }

    public static <T> void printArray(T[] array) {
        for (T element : array) {
            System.out.println(element);
        }
    }

    public static void main(String[] args) {
        Integer[] intArray = {1, 2, 3, 4, 5};
        String[] stringArray = {"Hello", "World", "Java"};

        GenericMethods genericMethods = new GenericMethods();
        genericMethods.swap(intArray, 1, 3);
        genericMethods.printArray(intArray);

        GenericMethods.printArray(stringArray);
    }
}

在上述示例中,swap方法是一个非静态的泛型方法,用于交换数组中两个元素的位置。printArray方法是一个静态的泛型方法,用于打印数组中的元素。

main方法中,创建了一个GenericMethods对象,并分别调用了swapprintArray方法来演示泛型方法的使用。在swap方法中,传递了一个整型数组,并交换了索引为1和3的元素的位置。在printArray方法中,传递了一个字符串数组,并打印了数组中的所有元素。

通过泛型方法,可以在不同的方法中使用不同类型的泛型,提高代码的灵活性和可重用性。

六、通配符

通配符的作用

在Java的泛型中,? 代表的就是通配符。通配符是用来解决泛型无法协变的问题,例如:如果 StudentPerson 的子类,那么List<Student> 也应该是 List<Person> 的子类,但是泛型是不支持这样的父子类关系的。

因为泛型 T 是确定的类型,一旦类型参数传入就定下来了,而通配符则更为灵活或者说是不确定,更多的是用于扩充类型参数的范围

例如下面的代码:

class Message<T> {
    private T message;

    public T getMessage() {
        return message;
    }

    public void setMessage(T message) {
        this.message = message;
    }
}

public class TestDemo {
    public static void main(String[] args) {
        Message<String> message = new Message<>();
        message.setMessage("Hello, world!");
        fun(message);
    }

    public static void fun(Message<String> temp) {
        System.out.println(temp.getMessage());
    }
}

现在运行上面的代码没有任何问题,如果现在将泛型的类型传入的不是String,而是Integer,此时程序就会出错了。

public class TestDemo {
    public static void main(String[] args) {
        Message<Integer> message = new Message<>();
        message.setMessage(99);
        fun(message); // 编译错误,只能接收 Message<String>
    }

    public static void fun(Message<String> temp) {
        System.out.println(temp.getMessage());
    }
}

因为在给定的代码中,泛型类型参数被指定为String,并且fun方法也接受Message<String>作为参数。如果尝试将其他类型(如Integer)的Message对象传递给fun方法,会导致编译错误。这是因为fun方法的参数类型被限定为Message<String>,无法接受其他类型的Message对象。

如果希望fun方法能够接受不同类型的Message对象,可以使用通配符来实现。修改的fun代码如下:

public static void fun(Message<?> temp) {
    System.out.println(temp.getMessage());
}

在上述代码中,fun方法的参数类型被改为Message<?>,使用通配符?表示可以接受任何类型的Message对象。这样,无论传递的是Message<String>还是Message<Integer>,都可以通过编译,并在方法内部读取message的值。

通配符的上界和下界

通配符的上界和下界在泛型中用于限制类型参数的范围。

  1. 上界通配符(? extends T):表示泛型参数必须是某个类型的子类型(包括该类型本身)或该类型本身。上界通配符限制了所能接受的类型范围,使得泛型参数必须是指定类型或其子类型。在使用上界通配符时,可以读取通配符代表的类型的值,但无法写入或修改它。

  2. 下界通配符(? super T):表示泛型参数必须是某个类型的超类型(包括该类型本身)或该类型本身。下界通配符限制了所能接受的类型范围,使得泛型参数必须是指定类型或其超类型。在使用下界通配符时,可以写入或修改通配符代表的类型的值,但无法读取它。

示例:

假设有一个类层次结构如下:

class Fruit {
    // ...
}

class Apple extends Fruit {
    // ...
}

class Banana extends Fruit {
    // ...
}

现在,我们定义一个方法来处理水果的列表:

import java.util.List;

public class FruitProcessor {
    public static void printFruits(List<? extends Fruit> fruits) {
        for (Fruit fruit : fruits) {
            System.out.println(fruit);
        }
    }

    public static void addApple(List<? super Apple> apples) {
        apples.add(new Apple());
    }
}

在上面的示例中,printFruits方法接受一个上界通配符List<? extends Fruit>,它可以接受任何Fruit类型或其子类型的列表。这意味着我们可以将List<Apple>List<Banana>传递给该方法。

addApple方法接受一个下界通配符List<? super Apple>,它可以接受任何Apple类型或其父类型的列表。这意味着我们可以将List<Fruit>List<Object>传递给该方法。

示例的用法:

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

public class Main {
    public static void main(String[] args) {
        List<Apple> appleList = new ArrayList<>();
        appleList.add(new Apple());

        List<Banana> bananaList = new ArrayList<>();
        bananaList.add(new Banana());

        List<Fruit> fruitList = new ArrayList<>();
        fruitList.add(new Fruit());

        FruitProcessor.printFruits(appleList);
        FruitProcessor.printFruits(bananaList);
        FruitProcessor.printFruits(fruitList);

        List<Fruit> anotherFruitList = new ArrayList<>();
        FruitProcessor.addApple(anotherFruitList);
    }
}

在上述示例中,我们可以将List<Apple>List<Banana>List<Fruit>传递给printFruits方法,因为它们都是List<? extends Fruit>类型的。而我们也可以将List<Fruit>传递给addApple方法,因为它是

List<? super Apple>类型的。这展示了通配符的上界和下界的使用和灵活性。

七、包装类

7.1 基本数据类型和对应的包装类

包装类(Wrapper Class)是一种用于将基本数据类型包装成对象的类。在Java中,每个基本数据类型都有对应的包装类,用于在需要对象的上下文中使用基本数据类型。

下表显示了Java的基本数据类型及其对应的包装类:

基本数据类型包装类
booleanBoolean
byteByte
shortShort
intInteger
longLong
floatFloat
doubleDouble
charCharacter

这些包装类提供了许多有用的方法和功能,例如将基本数据类型转换为字符串、字符串转换为基本数据类型、数值计算等。此外,包装类还允许我们在需要使用对象而不是基本数据类型的上下文中操作基本数据类型。

7.2 装箱和拆箱

装箱(Boxing)是将基本数据类型转换为对应的包装类对象,而拆箱(Unboxing)是将包装类对象转换回基本数据类型。

装箱示例:

int num = 10;
Integer wrappedNum = Integer.valueOf(num);  // 装箱

拆箱示例:

Integer wrappedNum2 = Integer.valueOf(20);
int num2 = wrappedNum2.intValue();  // 拆箱

7.3 自动装箱和自动拆箱

在Java 5及以上版本中,引入了自动装箱(Autoboxing)和自动拆箱(Unboxing)的特性,使得装箱和拆箱更加方便。

自动装箱示例:

int num = 10;
Integer wrappedNum = num;  // 自动装箱

自动拆箱示例:

Integer wrappedNum2 = 20;
int num2 = wrappedNum2;  // 自动拆箱

通过自动装箱和自动拆箱,编译器会在需要的时候自动地进行类型转换,使得我们可以直接在基本数据类型和对应的包装类之间进行转换,无需显式调用装箱和拆箱的方法。

自动装箱和自动拆箱的特性使得代码更加简洁,提高了编程的便利性。然而,需要注意自动装箱和自动拆箱可能会带来一些性能上的开销,因为编译器在背后进行了额外的转换操作。在性能敏感的情况下,建议根据具体需求进行手动装箱和拆箱的操作。