详细解读java中的泛型以及通配符的上下界

376 阅读8分钟

泛型

1.什么是泛型?

  在我们以往敲代码、定义类或者方法的时候,只能使用具体的类型,比如基本类型、自定义类型。如果我要编写一个可以应用于多种类型的代码,那么以往的方式就显得无力了。让下面的例子来引入泛型:

  实现一个类,类中包含一个数组成员,使得数组中可以存放任何类型的数据,换言之,就是能让这个数组能灵活的存不同类型的元素,也可以根据成员方法返回数组中某个下标的值。

class Test{

    public Object[] array = new Object[10];

    public Object getVal(int index){
        return this.array[index];
    }

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




public class Main {

    public static void main(String[] args) {
        
        Test test = new Test();
        
        test.setVal(0,100);
        test.setVal(1,30.3);
        test.setVal(2,"Hello World");
    }
}

  我们可以很快的想到借助Object类型来完成这种操作。array确实是存了很多的类型,我们现在尝试读取元素:

public class Main {

    public static void main(String[] args) {

        Test test = new Test();

        test.setVal(0,100);
        test.setVal(1,30.3);
        test.setVal(2,"Hello World");

        int a = (int)test.getVal(0);
        double d = (double)test.getVal(1);
        String str = (String) test.getVal(2);

        System.out.println(a);
        System.out.println(d);
        System.out.println(str);
    }
}


结果:
100
30.3
Hello World

  由于getVal方法返回的是Object类型,所以要强制类型转换。但是这种方法有一个缺陷,就是我们必须得提前知道我们读取的那个下标的元素类型,这显然是非常麻烦的。

  虽然在这种情况下,当前数组任何数据都可以存放,但是,更多情况下,我们还是希望他只能够持有一种数据类型,并且是我们想让它存什么类型它就存什么类型,而不用定义多的数组。这就要用到泛型,那么泛型就是类型的参数化

2.泛型的语法

  什么是类型参数化?就是把类型当成参数,让我们指定其类型。

class 类名称 <T> {

    // 这里可以使用类型参数
    //比如:
    <T> a = ....;
}

//new一个泛型类:
    类名称<T> = new 类名称<>;  //后一个<>里的T可以省略。

<>里面是 类型 形参列表,可以写任意的字母,代表占位符,就类似于方法里形参变量

  我们对上面案例的代码进行优化:

class Test<T>{

    public T[] array = (T[]) new Object[10];//T[] array = (T[]) new Object[10] 写法是很危险的。

    public T getVal(int index){
        return this.array[index];
    }

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

public class Main {

    public static void main(String[] args) {

        //我想让数组存 字符串 类型
        Test<String> test = new Test<>();// 可以推导出实例化需要的类型实参为 String,所以后面不用写(下同)
        test.setVal(0,"Hello");
        test.setVal(1,"World");
        System.out.println(test.getVal(0));
        System.out.println(test.getVal(1));

        //我想让数组存 整数
        Test<Integer> test1 = new Test<>();
        test1.setVal(0,100);
        test1.setVal(1,200);
        System.out.println(test1.getVal(0));
        System.out.println(test1.getVal(1));
    }
}

结果:
Hello
World
100
200

  上述代码就体现了泛型应用所带来的优点,我们只需传入想要的类型数组就存什么类型,并且不需要强制类型转换,应用到其它地方也是相同的道理。

补充:

  1.泛型也可以如下定义:

class ClassName<T1, T2, ..., Tn> extends ParentClass<T1> {
// 可以只使用部分类型参数
}

3.擦除机制

  泛型是怎么编译的?我们看看上面代码的汇编:

  这里借助了jclasslib bytecode viewer这个插件,可以用来看汇编代码:

image.png

image.png

  可以看到,在编译的过程当中,JVM将所有的T替换为Object,这种机制就叫做擦除机制。

  上面提到T[] array = (T[]) new Object[10] 写法是很危险的,为什么?:

image.png

  像上面的代码编译器并没有报错,我们直接执行:

image.png

  原因是在编译的时候,getArray()方法的返回值类型被擦除成Object类型了,返回的Object数组里面,可能存放的是任何的数据类型,可能是Double,可能是Person,运行的时候,直接转给String类型的数组,编译器认为是不安全的。这种写法编译器不显示报错,容易写出BUG,所以不推荐这种写法。

4.泛型的上界

  语法:

class 泛型类名称<类型形参 extends 类型边界> {
...
}

  这里先举一个案例来更好的理解泛型的上界。

问题:实现一个泛型类,提供一个方法,用来求出传入的数组中的最大值。

image.png

  上面求数组最大值的逻辑是没问题的,但是为什么会报错呢?因为这里没有把类型定死,数组里的元素可能是一个个对象,而对象之间的不能用大于小于号来比较,那我们就用compareTo()方法来比较。

image.png

  为什么又报错误了?这是因为T这个类型可能没有实现Comparable接口,也可能实现了,编译器不知道你到实没实现,T的范围太大了,所以得给它一个约束。

image.png

  <T extends Comparable<T>>表示传进来的类型必须实现Comparable接口或者是该接口;如果extends后面是一个类,则表示传进来的类型必须是该类的子类或者该类,不然报错。这就给T加上了一种界限,这就叫上界。

  注意,这里上界不管是类还是接口都是使用extends,没有implements

补充:上面介绍过的擦除机制会把T擦成Object类型,而当给T加了上界后,它就擦成了上界的类型,这里就是Comparable类型。

image.png

5.泛型方法

  我们还是根据上面的案例来引入泛型方法。

  就上面的求数组最大值案例来说,其实现方法有点不够好,每次求最大值的时候我们还要new一个Test对象,这也太麻烦啦。欸,想到这是不是就想到了static了,我们加个static不就行啦?

image.png

  唉我去,怎么报错了?当我们调用getMax静态方法的时候,开始加载Test类,然而这时就上面代码而言是没有机会将T的类型确定的。想一下我们没有加static的时候,是先new一个Test对象,顺便传了<Integer>进去确定了T,然后再调用getMax方法。而现在我们是直接类名.getMax,就没有机会传入<Integer>,所以报错了。

  正确的写法:

class Test  {

    //将泛型类型参数写在方法上
    public static <T extends Comparable<T>> T getMax(T[] arr){
        T max = arr[0];
        for (int i = 1; i < arr.length; i++) {
            if(max.compareTo(arr[i]) < 0){
                max = arr[i];
            }
        }
        return max;
    }
}

public class Main {

    public static void main(String[] args) {
        Integer[] arr = {1,2,3,4,5,6,10};
        int max = Test.<Integer>getMax(arr);//这里的<Integer>可以省略:Test.getMax(arr)
        System.out.println(max);
    }
}



结果:
10

  泛型方法语法:

方法限定符 <类型形参列表> 返回值类型 方法名称(形参列表) { ... }

  把类型形参列表放在方法上面,那这就是泛型方法。

补充:

  不止是静态方法可以这么写,普通方法也可以,上面的代码也可以写成:

class Test  {

    public  <T extends Comparable<T>> T getMax(T[] arr){
        T max = arr[0];
        for (int i = 1; i < arr.length; i++) {
            if(max.compareTo(arr[i]) < 0){
                max = arr[i];
            }
        }
        return max;
    }
}

通配符

1.什么是通配符?

  我们现在已经熟悉了泛型的使用、以及其应用场景,那么通配符与泛型是什么关系呢?通配符的使用场景又是什么呢?

  我们实现一个类,里面一个成员变量、两个方法,分别用来存取变量。Main里用一个方法来打印变量:

class Test<T>{
    private T message;

    public T getMessage() {
        return message;
    }

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


public class Main{
    public static void main(String[] args) {

        Test<String> test = new Test<>();
        test.setMessage("Hello");
        print(test);
    }

    //打印
    public static void print(Test<String> test){
        System.out.println(test.getMessage());
    }
}

结果:
Hello

  上面的代码你是否能看出一些端倪?那就是print方法写得不好,如果我传入的类型不为String的时候就会报错。

image.png

  那怎么解决呢?用方法重载?

image.png

  方法重载是不行的!!!泛型只是类型参数化,描述的是泛型类里面成员的类型,而这里的Test<String> testTest<Integer> test的类型是Test类型,也就是说,<String> <Integer>并不参与类型的组成,在编译的时候就会把<String> <Integer>擦除掉。

  那么正确的方法就是要用到通配符:

public class Main{
    public static void main(String[] args) {

        Test<String> test = new Test<>();
        test.setMessage("Hello");
        print(test);

        Test<Integer> test1 = new Test<>();
        test1.setMessage(10);
        print(test1);
    }

    //打印 用到了 ? 通配符
    public static void print(Test<?> test){
        System.out.println(test.getMessage());
    }
}

结果:
Hello
10

  这里的就是通配符,是用来代表任意类型的。那么通配符的应用场景:方法形参表里出现泛型类的时候就会用到通配符,前提条件是这个方法不在这个泛型类里。

补充:通配符是不能修改数据的:

image.png

  原因是没法确定message具体类型,所以无法修改。

2.通配符上界

  通配符上界与泛型的上界是一样的道理,它的语法:

<? extends 上界>
<? extends Father>//可以传入的实参类型是Father或者Father的子类

  表示只能接收Father或者Father的子类,Father就是通配符的上界。

class Father{

}
class Son1 extends Father{

}
class Son2 extends Son1{

}



class Test<T>{
    private T message;

    public T getMessage() {
        return message;
    }

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


public class Main{
    public static void main(String[] args) {
        
        //传入 Test<Father>
        Test<Father> test = new Test<>();
        test.setMessage(new Father());
        print(test);

        //传入 Testt<Son1>
        Test<Son1> test1 = new Test<>();
        test1.setMessage(new Son1());
        print(test1);

        //传入 Test<Son2>
        Test<Son2> test2 = new Test<>();
        test2.setMessage(new Son2());
        print(test2);
    }

    //打印
    public static void print(Test<? extends Father> test){
        System.out.println(test.getMessage());
    }
}

结果:
demo6.Father@5cad8086
demo6.Son1@6e0be858
demo6.Son2@61bbe9ba

  通配符上界也是不能修改(set)数据的,因为test接收的是Father和它的子类,此时存储的元素应该是哪个子类无法确定。(如果能修改的话,你想一想,message的类型本来是Son,而你set一个Father类型,这是不是向下转型了,要知道向下转型是非常危险的。)

3.通配符下界

  通配符的下界与上界类似:

语法:

<? super 下界>
<? super Son>//代表 可以传入的实参的类型是Son或者Son的父类类型

  这里的Son就是通配符的下界。(下界下界,就表示你能传入的最低要求,这样更易理解);看看下面的案例:

class Father{

}

class Son1 extends Father{

}

class Son2 extends Son1{

}



class Test<T>{
    private T message;

    public T getMessage() {
        return message;
    }

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

public class Main{
    public static void main(String[] args) {


        Test<Father> test = new Test<>();
        test.setMessage(new Father());
        print(test);

        Test<Son1> test1 = new Test<>();
        test1.setMessage(new Son1());
        print(test1);

        Test<Son2> test2 = new Test<>();
        test2.setMessage(new Son2());
        print(test2);//错误的
    }

    //这里变为<? super Son1>
    public static void print(Test<? super Son1> test){
        System.out.println(test.getMessage());
    }
}

image.png

  这里Father、Son1能传,而Son2不能传,就是因为Son2不是Son1的父类。

  那么通配符的下界能否修改message呢?

image.png

  这里就与通配符的上界有区别了,通配符的下界能提交下界(Son1)的子类,不能提交下界(Son1)的父类

  解释:如果当message的类型是Son1或者Son1的父类的时候,我传入了Son1的子类,这时message不管是Son1或者Son1的父类,这里都是向上转型,所以可以编译。

  还要需要注意的是通配符的下界对数据(这里就是message)的读取是有要求的:

image.png

  当没有用变量接收的时候可以编译通过,而当用变量接收的时候就会报错,原因也是与向下转型有关,大家可以动动脑筋想一想。