Java基础-泛型

237 阅读16分钟

Java基础-泛型

1.概述

Java泛型(generics)是JDK 5中引入的一个新特性,允许在定义类和接口的时候使用类型参数(type parameter)。声明的类型参数在使用时用具体的类型来替换。 可用于泛型类、泛型接口、泛型方法。

如何理解上面的定义呢,类型参数是什么?

说起参数,都会想到定义方法时要定义形参,调用方法时要传入实参。当一个方法可以适配多种类型的参数,

可以将形参设为Object类型,等处理完毕后再强制转换为想要的类型,错误的类型转换会在运行时导致程序奔溃。

比如:

static void testGenerics() {
    List list = new ArrayList();
    String a = "A";
    Integer b = 2;
    list.add(a);
    list.add(b);
    for (Object o : list) {
        String tmp = (String) o;
        System.out.println(tmp);
    }
}

将Integer转Object再转为String类型,编译不会报错,但运行时会报错。

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

我们希望在写代码时尽早发现这类问题,在使用阶段可以像指定实参一样指定类型,泛型在使用阶段指订参事类型,在编译阶段就避免了错误的强制转换。

boolean add(E e);

上面的参数类型E是在初始化List时指定的,在编译阶段就会检查是否出错。

参数存在于方法中,类和接口中存一系列相关的方法,这就引出了泛型应用的三种常见使用方式泛型类、泛型接口、泛型方法。

2.相关概念

泛型参数列表<K,V>列表中大写字母随意定。

原始类型ArrayList

泛型类型ArrayList<E>

类型参数E

参数化类型ArrayList<Integer>

实际参数类型:Integer

类型绑定<T extends Fruit&Serializable> T 是Fruit的子类并且实现了Serializable接口。

通配符:?

限定通配符的上边界<? extends Number >

限定通配符的下边界<? super Integer >

桥接

协变

逆变

3.泛型应用

3.1 泛型方法

定义泛型方法,只需要将泛型参数列表<K,V>置于返回值之前。

public class GenericsMethod {
    public <K, V> V genericsMet(K input) {
        return (V) input;
    }
}

以上就是普通的泛型方法,指定输入类型为K,返回类型为V ,K和V的具体类型要到使用时才可以确定。

public static void main(String[] args) {
    GenericsMethod genericsMethod = new GenericsMethod();
    int a = genericsMethod.genericsMet(3); 
    String b = genericsMethod.genericsMet("dd");
    //String c = genericsMethod.genericsMet(3);
}

入参为3 是Integer类型,则K为Integer类型;承接返回值的a是Integer类型,所以V为Integer类型,如果a为String类型那么V为String类型。

入参列表中的泛型类型由实参类型决定,而返回值中的泛型类型由承接返回值的变量类型决定。

3.2 泛型类

定义泛型类,只需要在定义类时将泛型参数列表置于类名称之后。

public class GenericsClass<T> {
    private T args; // 定义泛型类型的成员变量
	public GenericsClass(T args) { // 泛型构造方法的参数类型也为T
    	this.args = args;
    }
    public T getArgs() { // 注意该方法不是泛型方法
        return args;
    }
    public void setArgs(T args) {
        this.args = args;
    }
}

以上泛型参数T的实际类型可以通过定义GenericsClass实例对象时确定,如下:

// 创建引用时指定泛型参数类型为Integer
GenericsClass<Integer> stringGenericsClass = new GenericsClass<Integer>();
// 在new对象时可以不指定返程参数类型
GenericsClass<String> stringGenericsClass1 = new GenericsClass<>();

在创建stringGenericsClass引用的时候确定了T为Integer,new 对象的过程指定的泛型参数要和创建引用时用的泛型参数一致。

当然创建引用时也可以不指定泛型参数,此时在泛型类中使用泛型的方法或成员变量定义的类型可以为任何的类型

GenericsClass stringGenericsClass = new GenericsClass<>(23);
stringGenericsClass.setArgs(24);
stringGenericsClass.setArgs("abcd");

GenericsClass stringGenericsClass1 = new GenericsClass<>("abc");
stringGenericsClass1.setArgs(25);
stringGenericsClass1.setArgs(new Person());

3.3 泛型接口

定义泛型接口,只需要在定义接口时,在接口名称后添加泛型参数列表。这和泛型类定义一样。

public interface GenericsInterface<T,V> {
    V transform(T arg);
}
  • 在使用泛型接口时,可以在实现类中继续使用泛型参数,不明确泛型类型。
// 继续使用接口中的泛型参数,将具体类型的指定留到该类使用阶段。
public class GenericsInterfaceImpl<K,V> implements GenericsInterface<K,V> {
    @Override
    public V transform(K arg) {
        return null;
    }
}
  • 当然也可以在接口中指定泛型类型,此时实现类无需添加泛型参数列表,明确泛型类型。
// 在实现接口时就指定<K,V> 的具体类型。
public class GenericsInterfaceImpl implements GenericsInterface<Integer,String> {
    @Override
    public String transform(Integer arg) {
        return null;
    }
}

3.4 类型绑定

以上泛型变量都是派生自Object类,所以在泛型方法的内部实现中,T变量只能使用Object自带的方法。这大大局限了泛型变量T能实现的功能。

如何为泛型变量T添加更多能力呢??? 这就是类型绑定的作用,通过为T绑定更多接口或者父类,T就可以使用这些接口和父类中的方法。

类型绑定使用extends关键字,首先明确这和继承是不一样的。

定义绑定<T extends Comparable>

多重绑定<T extends Comparable & Car & Serializable>

示例1:绑定接口

// 先定义一个接口
public interface Comparable<T> {
	public int compareTo(T o);
}

// 在方法泛型参数中绑定该Comparable接口
public <G extends Comparable> G max(G... input) {
    G maxNode = input[0];
    for (G tmp : input) {
        if (maxNode.compareTo(tmp) < 0) {
            maxNode = tmp;
        }
    }
    return maxNode;
}

以上泛型参数类型G就可以使用compareTo方法。

示例2:绑定类

// 先定义一个基类
public class Car {
    private String name;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

// 定义两个继承类
class ACar extends Car {
    public ACar() {
        setName("ACar");
    }
    @Override
    public String getName() {
        return "AType" + super.getName();
    }
}
class BCar extends Car {
    public BCar() {
        setName("BCar");
    }
    @Override
    public String getName() {
        return "BType" + super.getName();
    }
}

定义一个绑定类的泛型方法。

public static <T extends Car> String getCarName(T car) {
    return car.getName();
}

调用泛型方法。

String aCarName = getCarName(new ACar());
String bCarName = getCarName(new BCar());
System.out.println(aCarName);
System.out.println(bCarName);

3.5 泛型通配符

通配符只能在创建泛型类的引用中使用,用于填充泛型T,不能用在泛型定义过程中。

无边界通配符<?>

通配符的意义就是它是一个未知的符号,可以是代表任意的类。 通配符是用来在创建引用时填充泛型T的。

// 在创建泛型引用时,需要明确类型,后面接对应泛型类实例。
GenericsClass<String> stringGenericsClass2 = new GenericsClass<>("abc");
GenericsClass<Integer> stringGenericsClass3 = new GenericsClass<Integer>(23);

// 使用通配符?的引用,可以接多种泛型类实例。
GenericsClass<?> stringGenericsClass = new GenericsClass<>(23);
stringGenericsClass = new GenericsClass<String>("abc");
stringGenericsClass = new GenericsClass<Integer>(33);

// 不指定泛型类型时,同样可以接多种泛型类实例。
GenericsClass stringGenericsClass1 = new GenericsClass<>("abc");
stringGenericsClass1 = new GenericsClass<String>("abc");
stringGenericsClass1 = new GenericsClass<Integer>(23);

因为一个泛型类,如果省略了填充类型,默认填充的是无边界通配符。

限定通配符的上边界 <? extends Number > 上边界只能读不能写

<? extends Number >限定了通配符的上边界,限定了上边界的泛型变量,只能接受上边界及其子类的泛型实例。

// 定义一个继承体系
public class Person {}
public class Employee extends Person {}
public class Manager extends Employee {}
public class CTO extends Manager{}

这里我们看下具体使用

public static void topLimitDemo(){
    // 泛型上界规定了list只能持有T为Manager及其子类的容器实例。
    List<? extends Manager> list;
    
    // list 无法指向T为 Person 或 Employee的容器实例。
    // list = new ArrayList<Person>();
    // list = new ArrayList<Employee>();
    
    // list 只能指向Manager及其子类的容器实例。
    list = new ArrayList<Manager>();
    list = new ArrayList<CTO>();
}

规定了上边界的泛型变量只能读不能存。

// 存入元素
// list.add(new Manager());
// list.add(new CTO());

// 读取时因为子类对象向上转型为Manager所以是可以成功的。
Manager tmp = list.get(0);

无法存入元素

单看list只知道它指向的是Manager及其子类的容器实例,但无法确定具体类型,所以add时编译器无法确定是否能正确转型。 假设list指向的是new ArrayList();是无法将一个Manager对象转为CTO对象,所以无法添加成功。

可以读取元素

读取元素是按向上转型的,读取时因为子类对象向上转型为Manager所以是可以成功的。

限定通配符的下边界:<? super Integer > 下边界只能写不能读

<? super Integer > 限定了通配符的下边界,限定了下边界的泛型变量,只能接受下边界及其父类的泛型实例。

// 泛型下界规定了list只能持有T为Manager及其父类的容器实例。
List<? super Manager> list;

// list 只能持有Manager及其父类的容器实例。
list = new ArrayList<Person>();
list = new ArrayList<Employee>();
list = new ArrayList<Manager>();
// list 无法指向T为Manager子类的容器实例。
//list = new ArrayList<CTO>();

上面可以看出同一个list 可以接受T为Manager及其父类的容器实例。

可以存入元素

// 存入元素只能存入Manager或者Manager子类的元素。
list.add(new CTO());
list.add(new Manager());
// 无法存入父类元素
// list.add(new Employee());
// list.add(new Person());

无法读取元素,这里指编译器无法判断得到实例元素的具体类型,只会被认定为Object。

// 读取时无法得到容器内元素具体类型,返回为Object类型
Object o = list.get(0);
// CTO cto = list.get(0);
// Manager manager = list.get(1);

小结:

  • 如果你想从一个数据类型里获取数据,使用 ? extends 通配符(能取不能存)
  • 如果你想把对象写入一个数据结构里,使用 ? super 通配符(能存不能取)
  • 如果你既想存,又想取,那就别用通配符。

4.泛型实现原理

要理解泛型的实现原理得先从类型擦除(Type Erasure)讲起

4.1 类型擦除

java的泛型基本上完全在编译器中实现,用于编译器执行类型检查和类型判断,然后生成普通的非泛型的字节码,这种实现技术为“擦除”(erasure) 。如何理解呢?看下面的例子

示例1:

public static void main(String[] args) {
    Class a = new ArrayList<String>().getClass();
    Class b = new ArrayList<Integer>().getClass();
    System.out.println(a == b);
    // out: true
}

明明是两个不同的泛型容器,但实际生成的字节码是相同的,如下:

public static void main(String[] args) {
    Class a = (new ArrayList()).getClass();
    Class b = (new ArrayList()).getClass();
    System.out.println(a == b);
}

在生成的Java字节代码中是不包含泛型中的类型信息的。使用泛型的时候加上的类型参数,会被编译器在编译的时候去掉。这个过程就称为类型擦除。

示例2:

我们知道java在编译阶段进行类型检查,向一个Integer容器添加String是无法通过编译的。

List<Integer> list = new ArrayList<Integer>();
list.add(33);
// list.add("abc"); 无法通过编译

try {
    Method method = list.getClass().getMethod("add", Object.class);
    method.invoke(list,"abc");
} catch (Exception e) {
    e.printStackTrace();
}
System.out.println(list); // out:[33, abc]

因为填充的泛型类型Integer在字节码中被擦除了,被替换为Object;在运行时只要是一个Object类型就可以add进List。反射是在运行时调用方法,跳过了编译器的类型检查,所以可以将String添加到一个指明类型是Integer的List中。

类型擦除的过程

首先找到用来替换类型参数的具体类,一般是Object,如果指定了类型参数的上界则使用上界类型。将代码中的类型参数都替换成具体类,去掉类型声明,如:T get()变成Object get() 去掉<> 。最后由于类型擦除后少了部分方法,则需要生成一些桥接方法作为补充。

类型擦除的缺陷

泛型类型由于类型擦除,源代码中添加的类型信息都被移除了,所以有些运行期的操作无法实现,比如:转型,instanceof 和 new。

public class Erased<T> {
    private static final int SIZE = 100;
    public static void f(Object arg) {
        //编译不通过
        if (arg instanceof T) {}
        //编译不通过
        T var = new T();
        //编译不通过
        T[] array = new T[SIZE];
        //编译不通过
        T[] array = (T) new Object[SIZE];
    }
}

类型判断解决办法

通过使用定义一个工具类,使用Class的isInstance可以解决泛型实例类型比较的问题。

public class ClassType<T> {
    Class<T> typeClass;
    public ClassType(Class<T> typeClass) {
        this.typeClass = typeClass;
    }
    public boolean isInstance(Object obj) {
        return typeClass.isInstance(obj);
    }
}

使用方式如下

public static void main(String[] args) {
    ClassType<Employee> classType =new ClassType<>(Employee.class);
    System.out.println(classType.isInstance(new Manager()));
    System.out.println(classType.isInstance(new Employee()));
    System.out.println(classType.isInstance(new Person()));
}

4.2 桥方法

因为 java 在编译源码时, 会进行 类型擦除, 导致泛型类型被替换限定类型(无限定类型就使用 Object). 因此为保持继承和重载的多态特性, 编译器会生成 桥方法.

什么是桥方法? 下面看个例子就清楚了。

public class Car<T> {
    // 车装的货物
    private T goods;
    public T getGoods() {
        return goods;
    }
    public void setGoods(T goods) {
        this.goods = goods;
    }
}

public class Truck extends Car<String>{
    @Override
    public void setGoods(String goods) {
        
    }
    @Override
    public String getGoods() {
        return super.getGoods();
    }
}

因为类型擦除,所以父类的泛型T被替换成Object,本来继承重载了

下面是class文件反编译得出。

public class Truck extends Car{
    public Truck() { }
    // 子类中方法
    public void setGoods(String goods) {
        
    }
    public String getGoods() {
        return (String)super.getGoods();
    }
    // 父类中方法,底层调用了子类中的方法,这就是 桥方法
    public volatile void setGoods(Object obj) {
        setGoods((String)obj);
    }
    public volatile Object getGoods() {
        return getGoods();
    }
}

父类中的setGoods方法,底层依旧调用的是子类中的setGoods方法。

4.3 协变、逆变

逆变和协变需要从java中继承机制说起,子类继承父类那么可以在使用父类的时候使用子类替换。

如果B类是A类的派生类,那么B类的引用可以赋值给A类的引用。

java中使用赋值一般有两个地方,

(1)使用运算符显式赋值

Person person = new Employee();

使用父类型引用person持有子类型实例new Employee() 对象的引用。

(2)函数传参赋值

static void createHat(Person person){
	System.out.println(person);
}

createHat(new Employee());

createHat 显示接收一个Person类型参数,我们传入一个子类型实例对象new Employee()同样可以编译通过正常运行。

小结:所以,Java中赋值操作一般左右类型相同,或者引用类型是实例类型的父类。实参是

但还有一类常见的赋值操作并不符合,即数组和容器的赋值。

Person[] peoples = new Employee[5];
List<? extends Person> personList = new ArrayList<Employee>();
List<? super Employee> personList1 = new ArrayList<Person>();
// Employee[] employees = new Person[5];
// List<Person> personList = new ArrayList<Employee>();
// List<Employee> employeeList = new ArrayList<Person>();

不同类型的数组、不同类型的容器为什么可以相互兼容? 这里就要涉及到协变和逆变的概念。

模式定义:假设F(x)是Java中的一种代码模式,x是其中可变的部分。

协变:如果B是A的子类,F(B)也能享受F(A)的待遇,子类实例享受父类待遇,那么F模式是协变的。

逆变:如果B是A的子类,F(A)也能享受F(B)的待遇,父类实例享受子类待遇,那么F模式是逆变的。

不变:如果F(A)和F(B)不享受任何继承待遇,那么F模式是不变的。

Java中的协变和逆变

协变:如果一个父类型容器引用可以持有一个子类型容器实例对象,则称发生了协变。如:

数组

Person[] peoples = new Employee[3];

Person 是Employee的父类,persons引用可以持有Employee数组实例,因为在Java中,数组是自带协变的。

虽然数组是协变的,但实际运行时添加元素到数组中去,依旧会做类型检查;如下面语句编译时不会报错,但在运行时会抛出错误。

Person[] peoples = new Employee[4];
peoples[0] = new Person();// 运行报错,子类实例类型的数组不能添加一个父类对象。
peoples[1] = new Manager();// Employee类型的数组实例可以接受 Employee及其子类Manager对象。

数组的协变设计,没有在编译阶段发现潜在问题,而将错误抛出延迟到了运行阶段,这是其为人诟病的地方。不过数组支持协变后,java.util.Arrays#equals(java.lang.Object[], java.lang.Object[])这种类型的函数就不需要为每种可能的数组类型去分别实现一次了。数组的协变设计有历史版本兼容性方面的考虑等,Java的每一个设计可能不是最优的,但确实是设计者在当时的情况下可以做出的最好选择。

列表

// List<Person> personList = new ArrayList<Employee>(); 无法编译

泛型容器没有自带协变所以personList不能持有Employee类型的泛型容器。

通过使用泛型通配符上边界,容器类可以实现协变。

// 可以通过编译
List<? extends Person> personList = new ArrayList<Employee>();

但限定了通配符上边界会导致该容器只能读不能写,读到的元素也会被统一为上边界元素。

// 不能写 ,可以添加null
// personList.add(new Person()); 无法编译
// personList.add(new Employee()); 无法编译

// 只能读
Object object = personList.get(0);
Person person = personList.get(0);
Employee employee = (Employee) personList.get(0);
Manager manager = (Manager) personList.get(0);

不能写是因为,List在编译时已经将泛型擦除成Object,运行阶段无法做类型检查,只能根据变量声明在编译阶段进行类型检查,而List<? extends Person> 代表可以容纳任何Person子类,无法得出具体的类型,所以插入任何类型都是不安全的。

读的时候可以将子类实例对象转为上边界类型,转到具体子类需要强制转换。

逆变:如果一个父类型容器引用持有父类型容器实例,可以向其添加子类型实例变量。则称为逆变。

List<? super Employee> a = new ArrayList<Employee>();
List<? super Person> b = new ArrayList<Person>();
a = b;// 子类型可以接受父类型 实例容器

PECS原则 Producer Extends,Consumer Super

因为使用<? extends T>后,如果泛型参数作为返回值,用T接收一定是安全的,也就是说使用这个函数的人可以知道你生产了什么东西;

而使用<? super T>后,如果泛型参数作为入参,传递T及其子类一定是安全的,也就是说使用这个函数的人可以知道你需要什么东西来进行消费。

比如Java8新增的函数接口java.util.function.Consumer#andThen方法就体现了Consumer Super这一原则。

参考:

Java泛型详解, by jamesehng

java 泛型详解-绝对是对泛型方法讲解最详细的,没有之一, by ViVieLeieLei

java基础巩固笔记(2)-泛型, by brianway

Java 泛型进阶, by 于晓飞93

夯实JAVA基本之一——泛型详解(2):高级进阶, by 启舰

java泛型:擦除/桥方法/协变(不要在新代码中使用原生态类型) ---- effective java notes,by soullines

Java进阶知识点:协变与逆变, by 爱养花的码农