再看Java泛型

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/ghost_Programmer/article/details/74385251

泛型对于稍有经验的Java使用者来说应该都不陌生,总的来说应该也算不上特别深奥的东西。但最近发现,如果不把关于它的很多细节和使用思路整理清晰,有的时候还真容易猛地一下犯糊涂。或者说想象一下,如果面试的时候涉及到相关的知识点,是否虽然你平时也经常都在使用它,却真不一定能条理清晰的讲清楚一二三。故特此整理,希望自己尽量能由浅入深 逐步递进的重新回顾关于泛型的一些细节 以及理清其关于其各种使用形式的关系和条理。


为什么需要泛型?

显然,想要更加深入的掌握某个东西,我们一定要做到“知其然且更知其所以然”。所以,在一切开始之前,能够明确为什么在Java里我们会需要使用泛型,对我们去更好的掌握它的使用是很有帮助的。

现在,假定我们想要设计一个“魔法盒子”,这个盒子可用于收纳任何一种类型的物品。那么想一想这一设计的难点在哪呢?显然是我们无法预见盒子未来的使用者究竟会将什么类型的东西放进盒子里,但却又必须确保东西都能够得以被存放进来。

那么,作为一个OO程序员,显然应该深谙“万物介对象”之道。所以在Java里,想要容纳任一类型的物品,本质其实就是能够存储任一类型的对象。OK,那么因为Java中所有类都有一个通用的超类Object,所以根据Java中继承里支持 向上转型 的特性,我们可以这样设计该盒子:

    public class MagicBox{

        private Object obj;

        public Object getObj() {
            return obj;
        }

        public void setObj(Object obj) {
            this.obj = obj;
        }

    }

现在,我们可以向这个盒子里放入任何类型的东西。我们可以放入音乐播放器;或者也可以放入图书阅读器:

public class Genericity {

    public static class MusicPlayer{

        public void playMusic(){};
    }

    public static class BookReader{
        public void read(){};
    }

    public static void main(String[] args) {
        MagicBox box = new MagicBox();

        box.setObj(new MusicPlayer());
        // or
        box.setObj(new BookReader());

    }

}

显然,以上的操作都是行得通的。那么问题会出在哪呢?假设现在我们想要从盒子里取出音乐播放器听听歌:

        MusicPlayer player = (MusicPlayer) box.getObj();
        player.playMusic();

我们了解Java中关于继承 多态的内部机制,所以不难预见取出音乐播放器播放歌曲的行为肯定会因为向下转型涉及到强制类型转换。那么,此时如果我们之前放进盒子里的是书而并非播放器的话,运行时异常:ClassCaseException就悄悄的来到你身边了。这就意味着我们的程序在运行时是存在风险的,通俗的来讲:就代表着我们所编写的代码健壮性不够。这其实就是为什么我们需要使用泛型的重要原因。我们预见了风险,则显然需要手段来规避风险。


泛型的定义

泛型可以定义在类上,接口上,也可以定义在方法上。Java中对于泛型的定义规则非常简单,我们以下述代码为例,不加赘述:

    public class ClassName<E>{} // 定义于类

    public interface InterfaceName<E>{} // 定义于接口

    public <T> void methodName(){} // 定义于方法

好,既然知道了泛型的定义规则。我们现在开始着手去改良一下我们的魔法盒子,将其由之前使用Object的方式改为使用泛型:

public class MagicBox<E>{

    private E obj;

    public E getObj() {
        return obj;
    }

    public void setObj(E obj) {
        this.obj = obj;
    }

}

泛型的实例化

public class Genericity {

    public static void main(String[] args) {

        ArrayList<String> stringBox = new ArrayList<String>(); 

    }   

}

这样的代码显然我们无比熟悉,这里其实就是在做针对声明在容器类ArrayList上的泛型的实例化工作。同理,当我们开始使用魔法盒子的时候,也需要实例化我们定义在其内部的泛型E。

        MagicBox<MusicPlayer> box = new MagicBox<MusicPlayer>();

我们在这里将MagicBox的泛型类型实例化为了MusicPlayer类型,完成对象的实例化工作过后,我们便又能向盒子里放入音乐播放器了。但不同之处在于,此时如果再向盒子里放入图书阅读器BookReader,则会导致编译异常:

        // comiple error
        box.setObj(new BookReader());

由此,我们不难想到:这种模式让我们得以确保被放入盒子里的,绝对是我们实例化泛型时,所声明的类型对象。所以,显然这时在我们取出对象时,就无需进行类型转换的工作了,因为这显然是多余的了。

        MusicPlayer player = box.getObj();
        player.playMusic();

OK,理理思路,现在我们肯定已经清楚为什么泛型能够规避我们之前说到的风险了。因为如今当你编写出可能导致这种风险的代码的时候,编译器就会告诉你从而让你规避这种风险代码。所以,我们也可以明确泛型程序设计所带来的两个好处:

  • 代码复杂性的减少:避免了在代码中使用强制类型转换。
  • 代码安全/健壮性的增强:将运行时异常“ClassCastException”提前到了编译时检测异常。

当然,不知道你注意到没有:我们定义在类上的泛型的实例化工作实际上是与该类的对象的实例化工作紧密相连的。所以不难想到,如果我们想要在类的静态方法中使用该泛型域,肯定是不行的,这个道理就像如同在静态方法使用类的实例域是一个道理。所以如果想要在静态方法中使用泛型,那么该泛型变量就必须被定义在这个静态方法上面。而且同样的,我们也就不难想到,如下的代码肯定也是会导致编译错误的:

public class MagicBox<T>{

    private static T obj;

}

但是,到了这里,如果是作为刚开始学习或者解除泛型的朋友,相信会有可能冒出一种想法:照这么玩尼玛是不是有点坑啊?意思是我把泛型实例化成了MusicPlayer后,就只能存放这种类型的对象啦?那它到底“泛”在哪了呢?到底是不是如此呢?现在我们定义一个MusicPlayer的升级版MusicPlayerPlus,除了播放音乐之外,它还多出了一个播放视频的功能:

    public class MusicPlayerPlus extends MusicPlayer{
        public void playVideo(){};
    }

    public static void main(String[] args) {
        MagicBox<MusicPlayer> box = new MagicBox<MusicPlayer>();
        box.setObj(new MusicPlayer());
        box.setObj(new MusicPlayerPlus());

    }

上述代码是OK的,所以其实虽然我们在实例化泛型时指定的类型是MusicPlayer,但其实隶属于MusicPlayer继承体系内的其所有子类实际上都是适用的。其实这也不难理解,因为我们说到了泛型之所以出现,其实是为了规避我们之前说到的类型转换存在的风险,但在这里MusicPlayerPlus是声明的泛型类型MusicPlayer的子类,显然向上转型是不存在类型转换风险的。所以同理,假设我们这时想要取出MusicPlayerPlus播放音乐,仍然是OK的:

        box.setObj(new MusicPlayerPlus());
        MusicPlayer player = box.getObj();

但是这时候如果想要执行播放视频的操作,则就是行不通的了,因为我们从box取出的对象默认都是为MusicPlayer 类型。当然了,是不是说绝对不行呢?显然不是的,因为还是可以像下面这样来操作:

        MusicPlayerPlus player = (MusicPlayerPlus) box.getObj();
        player.playVideo();

但是这样就又回到了我们的老问题,会存在类型转换异常的风险。所以我们不应该这样去编写代码或者说这样去设计程序。如果我们预见会有播放视频的需求,我们更应该将程序设计为将泛型实例化为MusicPlayerPlus类型。


边界

现在我们回顾和整理了对于泛型最基础和常见的应用,但很多时候的需求仅仅通过之前的方式是不足以支撑我们实现的。我们一步一步来:首先,假定我们的需求发生了改变,现在我们的魔法盒子只允许用于存放水果了:

public class Genericity {

    static class Fruit{}

    static class Apple extends Fruit{}

    static class Grape extends Fruit{}

    static class BlackCurrant extends Grape{}

    static class Chicken{}

    public static void main(String[] args) {
        MagicBox<Fruit> box = new MagicBox<Fruit>();
        box.setObj(new Fruit());
        box.setObj(new Apple());
        box.setObj(new Grape());
        box.setObj(new BlackCurrant());
        box.setObj(new Chicken()); // complile error
    }

}

我们很可能会想到如上代码所示的实现方式,没错,这时候我们想要向盒子里放入鸡肉的时候,是会出现编译异常的,问题在于:如果把泛型实例化为Chicken类型,那么放置鸡肉肯定是没问题的:

        MagicBox<Chicken> box = new MagicBox<Chicken>();
        box.setObj(new Chicken());

那么,很显然的,当前的MagicBox设计已经无法支撑我们的需求,我们需要新的技术来实现我们的需求。从而也引出了一个新的点,即:泛型的边界

class MagicBox<T extends Fruit>{

    private T obj;

    public T getObj() {
        return obj;
    }

    public void setObj(T obj) {
        this.obj = obj;
    }

}

在定义泛型时,这里由之前的”T”修改为了”T extends Fruit”。这就是所谓的泛型的边界,这里的extends就是用于限定泛型的边界的,当然了,它的用意与用于继承时相似但并不完全相同。”T extends Fruit”的代表含义是:该泛型域必须被实例化为Fruit或者Fruit的子类类型;同时,extends用于限定声明的泛型的边界时,也可以用于接口,此时则代表泛型必须被实例化为该实现了该边界接口的类。如果声明的类型参数继承自某个类,并同时实现了多个接口,那么其继承的类必须被声明在上限中的第一个,多个接口之间用&隔开。例如:

<T extends Fruit&Comparable<T>&Serializable>

到目前为止,我们所有的整理都是基于将泛型定义在类上的基础之上。那么,现在我们来看看关于将泛型定义在方法上时一些的细节。文章开始,我们就回顾了,定义一个使用泛型的方法并不复杂。例如:

public class Genericity {


    public static void main(String[] args) {
        Genericity g = new Genericity();
        g.test("test");
    }


    public <T> void test(T t){
        System.out.println(t);
    }
}

那么,让现在我们思考一个问题:什么时候应该将泛型定义在类上,什么时候则又应该将泛型定义在方法上呢?因为我们可以想到,上述的方法其实完全也可以用下面这样将泛型定义在类上的方式代替:

public class Genericity<T> {

    public static void main(String[] args) {
        Genericity<String> g = new Genericity<String>();
        g.test("test");
    }


    public void test(T t){
        System.out.println(t);
    }
}

其实很显而易见的是,当泛型被定义在类上,该类的所有实例域都可以访问到该泛型。而定义于某个方法时,则只有该方法能够访问这个泛型。所以我们不难得出结论,可以简单的总结为:

  • 假设在类的内部需要持有该泛型类型的实例对象的时候,泛型应该定义在类上。
  • 当类中的多个方法都需要访问和使用该泛型的时候,泛型应该定义在类上。
  • 当只有某个方法自身需访问和使用该泛型,则应该定义在该方法上。并且静态方法想要使用泛型时,泛型只能被定义在方法上。

OK,我们接着推进。前面我们已经对声明在类上的泛型使用了边界,而与类相同,声明在方法上的泛型当然也可以使用边界。比如,现在我们需要定义一个方法,该方法可以选出数组中最大的对象:

public class Genericity {

    public static void main(String[] args) {
        Integer [] intArray = new Integer[]{1,3,5,8,12};
        System.out.println(getMaxInArray(intArray));
        String [] strArray = new String[]{"张三","李四","王麻子"};
        System.out.println(getMaxInArray(strArray));
    }


    public static <T extends Comparable<T>> T getMaxInArray(T[] t){
        if (t == null || t.length == 0)
            return null;

        T max = t[0];
        for (int i = 1; i < t.length; i++) {
            if (t[i].compareTo(max) > 0) {
                max = t[i];
            }
        }
        return max;

    }
}

这里,我们的目的是通过对象的compareTo方法来比较数组中的各个元素的大小。那么,显然只有实现了Comparable接口的对象才具备compareTo方法,所以我们在声明泛型时应该说明该泛型类型必须是实现了Comparable接口的类。这与之前我们说到类上的泛型的范围界定的含义相同。所以,如下的代码将编译错误,因为int是基础数据类型,并没有实现Comparable接口。

        int [] intArray = new int[]{1,3,5,8,12};
        System.out.println(getMaxInArray(intArray));

泛型通配符

好了,现在我们回想一下,还记得我们之前从魔法盒子里取出音乐播放器播放音乐的事情吗?假设现在我们想要将这一行为进行单独封装该怎么办?显然就应该类似下面的代码:

    public static void playMusic(MagicBox<MusicPlayer> musicBox){
        musicBox.getObj().play();
    }

但这个时候就会有一个很蛋疼的事情出现,看下面的代码:

        MagicBox<MusicPlayer> musicBox =  new MagicBox<MusicPlayer>();
        MagicBox<MusicPlayerPlus> plusBox =  new MagicBox<MusicPlayerPlus>();
        playMusic(musicBox);// it's ok
        playMusic(plusBox); // complie error

没错,playMusic(plusBox);这行代码将出现编译错误,因为了解关于Java继承的设计思想,所以这其实会让人感到费解。但Java编译器此时的思想就是这样的,它的逻辑类似于:

  • MusicPlayerPlus IS-A MusicPlayer;
  • 但装MusicPlayerPlus的盒子 IS-NOT-A 装MusicPlayer的盒子。

为了弥补这种缺陷,就需要借助一个新的东西,叫做泛型通配符:“?”。简单来说,假设我们将之前的代码修改为如下形式,则任何类型的MagicBox都能作为参数传给该方法了:

public static void playMusic(MagicBox<?> musicBox)

上界通配符与下界通配符

那么我们怎么才能实现 只允许将存放音乐播放器类型的MagicBox作为参数传入的需求呢?还记得我们之前说到的泛型的边界吗?事实上,泛型通配符也能配合边界使用,所以有了之前的经验,我们很容易想到该如何实现这一需求:

public static void playMusic(MagicBox<? extends MusicPlayer> musicBox)

在这里”? extends MusicPlayer”又称为泛型的上界通配符,即该方法能够接收存放MusicPlayer的MagicBox以及所有存放MusicPlayer
子类的MagicBox。而既然存在泛型的上界通配符,显然与之对应的,多半也会存在泛型的下界通配符。以我们之前定义的水果继承体系里的类型为例:

public static void eatFruit(MagicBox<? super Grape> fruitBox)

这里的”? super Grape”即为泛型的下界通配符,它的意义就在于接收一切存放Grape以及Grape的超类类型的MagicBox。也就是说,即使MagicBox< Objcet >也是可以作为参数传入的;但如果是存放Grape的子类黑加仑葡萄MagicBox< BlackCurrant>就不行了。


擦除与泛型的本质

话到这里,细心一点的朋友就会发现:当我们在为定义的泛型设定边界的时候,其实并没有出现类似下面这样的东西呢:

class MagicBox<T super Grape>

显然没有出现是因为这样的代码是无法编译通过的,也就是说,Java本身的机制是不支持这种意在为定义的泛型设定“下边界”的操作的。然而,这是为什么呢?这其实就涉及到了Java中泛型的实现方式/本质及泛型的擦除了。

class MagicBox<T>

我们有没有想过,为什么之前当我们在类MagicBox定义了这样的一个泛型T,就能将该泛型实例化为任何类型,进而存放对象了呢?其实这个原理并不复杂,我们需要明白的是:所谓的泛型实际上只是被Java编译器所支持的一项技术,而真正运行Java程序的虚拟机其实并不认识它们。由此,我们自己其实也不难想象,当程序正式开始运行后,所谓的泛型类型肯定是要还原为Java虚拟机认识的类型才行的。这其实就是所谓的泛型擦除的工作。以我们之前的代码为例,当泛型擦除后,其实际效果就等同于:

    public class MagicBox{

        private Object obj;

        public Object getObj() {
            return obj;
        }

        public void setObj(Object obj) {
            this.obj = obj;
        }

    }

。。。。没错,我们没有看错,这其实不就回到了我们文章最初谈到的实现方式吗?其实就是这样的,所以例如我们后续的set操作,最终仍然是以Object形式保存到MagicBox里的。不同的是:例如我们将泛型声明和实例化为了Fruit类型,这时候编译器会确保只有Fruit继承体系中的对象能够被保存到MagicBox,这样在取出时,虽然还是会涉及到向下转型,但是因为编译器已经知道我们存入的对象肯定属于Fruit体系,所以由Object向下转型为Fruit类型的这个行为是能够得到安全保障的。所以总的来说:其实泛型技术最大的作用仍旧是将原本运行期可能存在的类型转换风险提前到了编译期,从而来避免这种风险。然后我们回到泛型的边界的话题:

class MagicBox<T extends Fruit>

而像这种为泛型设定了边界的情况,则原始类型用限定的第一个类型变量代替。在上述代码中,就意味着T将被擦除为原始类型Fruit。那么我们就可以思考我们之前的问题了,假设使用super来设定边界,编译器该怎么进行擦除呢?

class MagicBox<T super Grape>

我们就假定T将被擦除为原始类型Grape,那么就代表着,经过泛型擦除后的MagicBox的代码就应该类似如下:

class MagicBox{

    private Grape grape;

    public Grape getGrape() {
        return grape;
    }

    public void setGrape(Grape grape) {
        this.grape = grape;
    }

}

很显然,这个时候我们根据之前所谓的泛型下界通配符的使用经验,例如准备向盒子里放入一个Fruit,那么显然是不行的。这也就是为什么在定义泛型时,并不支持使用super的原因。这也是我们应该记住的:不要因为使用泛型下界通配符的经验,想当然的以为定义泛型时也可以使用super。


上/下界通配符的副作用

我们还需要了解的是,上/下界通配符也并不是十全十美的,伴随着也会有一定的副作用。首先以上界通配符来说,其会导致对象的set,即存入的操作失效。

    public static void main(String[] args) {
        MagicBox<? extends Fruit> box = new MagicBox<Fruit>();
        box.setObj(new Apple());    // compile error
    }

其实我们不难理解为何会导致这种错误,以上述代码为例,如上形式的上界通配符的限定规则下,意味着我们其实可以将其泛型实例化为Apple,Grape等任何Fruit继承体系内的类型。也就是说,编译器这时无法明确该类声明将最终将被实例化成何种类型。那么假设实例化为苹果,显然我们就无法往里添加葡萄。因为存在这种无法预知的错误风险,所以编译器则直接强制选择此种情况下不允许我们进行赋值,也就是存放的操作。

那么,相对应的,下界通配符的使用所带来的副作用又是如何表现的呢?看下面的代码:

    public static void main(String[] args) {
        MagicBox<? super Grape> box  = new MagicBox<Fruit>();
        box.setObj(new Grape());
        box.setObj(new BlackCurrant());
        box.setObj(new Fruit()); // compile error
        box.setObj(new Object());// compile error
        Object obj = box.getObj();
        Grape grape = box.getObj();// compile error
    }

由此可以观察到:当我们把下界设定为葡萄时,可以向其中进行存放葡萄或者其子类黑加仑的操作,但包括Fruit,Object等一系列超类的存放却是不允许的。同时,在读取时则只能以Object的方式进行读取。其实原因仍然不难分析,下限设定为葡萄意味着:在最低程度上我们可以把泛型实例化为葡萄,这就意味着无论如何,至少将葡萄以及葡萄的子类进行存放是OK的,但其父类就不确定了。而在读取时,比如我们想以葡萄的类型进行读取,但因为我们也有可能之前存入的是水果,所以显然无法保证取出的东西一定能够向下转型为葡萄,所以这种情况编译器只能强制我们只能以最超类Object为类型进行读取。

额外一提,说到这里可能会有初学的朋友产生疑问:虽然如此,但我们不是在实例化时已经明确指定了泛型类型吗?比如:

MagicBox<? super Grape> box  = new MagicBox<Fruit>();

这里我们明确将泛型实例化为Fruit类型,却无法存放Furit对象。有这种疑问我们可以回想一下Java的动态绑定。举个例子可能更容易理解:

Parent p = new Son();

这句代码我们应该就很熟悉了,道理是一样的,在这里我们声明了一个Parent类型的对象引用p,但最终实例化指向的对象类型是Son类型。动态绑定的机制使的我们能够用这样的方式来声明和实例化对象,但这种机制是在运行时进行的。而Java编译器无法完成这些工作,它对于类型的判定显然是通过声明的对象引用的类型来确定的。所以在编译期,即使我们实际指向的对象类型是Son,但通过引用p也是无法调用到Son当中的方法的。现在我们应该就不难理解上/下界通配符所导致的副作用了。


有点乱?捋捋清楚

OK,书到这里,我们回顾和整理了关于泛型的许多规则和细节。但如果我们没有经过一定时间和经验的实际使用,其实很容易对泛型不同地方的使用感到有点混乱,起码我自己是有过这样的感受。所以为了更好的使用泛型,我们可以把混乱的地方捋捋清楚。

首先,我们一定要再次明确几个概念的不同,我个人将其总结为:定义泛型,实例化泛型和限定泛型。

  • 定义泛型
public class MagicBox<T>
public class MagicBox<T extends Fruit>
public <T extend MusicPlayer>void playMusic(T t)

我们需要明白,使用类似上面这样的代码时,我们的目的是在定义和声明一个全新的泛型类型。这也是一切的前提,因为只有当某个泛型被成功定义,才可能有后续的使用。定义泛型的时候都伴随着“T”,“E”之类的大写字母,因为就像我们定义一个类时需要类名一样,显然泛型也需要一个名字。

  • 实例化泛型
    MagicBox<Fruit> box = new MagicBox<Fruit>();

    ...

    public static void main(String[] args) {        
        test(new String("123"));
    }

    public static <T> void test(T t){

    }

上述的两种方式实际上都是在对我们定义的泛型类型进行实例化。同样的,这个道理就好比我们定义好了一个类,使用时则需要声明和实例化该类的对象。我们可以这样理解:当我们定义一个泛型时,所谓的泛型其实就是指一个广泛的类型范围;其实也就是相当于说,比如我们在定义一个类或者方法时,会需要使用某个类型对象,但我们在定义的时候还无法知道该类或者该方法的调用者到底会提供哪种类型的对象,所以我们只能提供一个范围。而调用者在其使用的时候,则必须要先明确这个类型究竟是什么才行,并且该类型必须属于我们提供的类型范围之内。这里,调用者明确这个类型的工作实际就是所谓的泛型的实例化。

  • 限定泛型

这里个人觉得是未熟练掌握泛型使用之前最容易迷糊和混淆的一个点。其实我们我们可以将之前的两点作为基础,将泛型边界的限定工作也分为两个类型:定义时的边界限定与声明时的边界限定(个人归纳)。

首先,我们再总结一次定义时的边界限定:

    // 1.
    static class MagicBox<T extends Fruit>{}
    // 2.
    static <T extends Fruit> void test(T t){}

如上代码所述,这里做的都是在定义泛型时 对泛型的边界进行限定。那么我们继续,接着看看在声明时对泛型进行边界限定,又是如何实现的:

    static class MagicBox<T>{}

    public static void main(String[] args) {
        //1.
        MagicBox<? extends Fruit> t = new MagicBox<Apple>(); 
    }

    // 2.
    public static void test(MagicBox<? extends Fruit> t){};
    }

上述的代码是什么情况呢?我们可以理解为,我们仍然希望此时的盒子只能存放水果。但问题在于,我们在定义该泛型并没有限定它的边界(范围)。于是,便应该如上述代码中的方式去限定泛型的边界,之所以个人把它归纳为声明时的边界限定,是因为此时我们是在声明该泛型所属类的对象引用的时候进行限定的。而上述代码中被注释为“1.”和“2.”的两种方式的区别就在于,“2.”的对象引用声明是声明在方法的参数列表中的而已。我们需要记住的是:

1、声明时的边界限定,其实都是通过边界通配符实现的;而边界通配符的使用则必须是以泛型已经被定义为前提的。并且!这时的泛型都是被定义在类或者接口上面的。因为定义在方法上的泛型通常都是在定义的地方进行边界限定,就像下面的代码,此时方法参数列表里显然是用不上边界通配符的:

public <T extends Fruit> void test(T t){}

2、定义时的边界限定只支持extends,因为涉及到泛型擦除。声明时的边界通配符既支持extends,也支持super,因为它不涉及泛型擦除(前面说到它建立在泛型已经被定义的基础上,这也就意味着这里不涉及操作关于程序运行时的泛型擦除工作),所以更像是更加纯粹的一个类型范围限定。

3、它们都影响着泛型的实例化工作,但又存在不同的分工。个人觉得文字不太好描述清楚,通过代码能够有更直观的理解:

这里写图片描述

留意上图中的三句代码,我们可以看见虽然它们都导致了编译错误,但是错误的原因又有所区别。分析一下:首先,我们在定义泛型T时,已经将其边界限定为了Fruit。于是:

  • 对于第一种方式,在声明时我们又将泛型限定为下界是Grape,这代表我们将泛型实例化为Grape或者Fruit都是可以的,因为Grape和Fruit都属于 在定义时限定的 Fruit边界以内,所以这样声明是合法的。问题出在我们最终将其实例化为Object类型,我们知道虽然这符合“? super Grape”的限定,但却违反了“T extends Fruit”,所以导致编译错误。
  • 对于第二种方式,声明部分同理,依然合法。不同在于,实例化的类型Apple变成了符合“T extends Fruit”却不符合“? extends Grape”,所以依然导致编译错误。
  • 第三种则因为边界通配符的限定已经违反了定义泛型时的边界限定,则声明时就已经导致编译错误。

由此我们可以总结得出:定义时的边界限定与声明时的边界限定,都会影响泛型的实例化工作;而声明时的边界限定,即边界通配符的限定 可以理解为:对于 定义时的边界限定 的进一步限定,但其限定范围必须属于 定义时的边界限定 之内;只有同时符合两种边界限定,才能够正确实例化泛型。


通过返回类型指定泛型类型

回想一下,我们已经说到当定义好某个泛型后,开始要实际使用这个泛型时,我们都会对泛型进行实例化,即明确指定这个泛型的实际类型。就像下面这样:

    public static void main(String[] args) {        
        MagicBox<Fruit> box = new MagicBox<Fruit>();
        test("test");
    }

    public static <T> void test(T t){
        System.out.println(t);
    }

就像这里的代码里展示的一样,通常定义在类上的泛型,我们会在实例化该类的对象时明确该泛型的类型;而通常对于定义在方法上的类型,我们则是在传入参数的时候其实就等同于间接的指定了泛型的类型。

这都很容易理解。那么,现在我们来看一种更容易让人感到困惑的情况。如果我们有兴趣去研究ArrayList的源码,通常就会注意到Arrays类当中的一个方法叫做CopyOf,它的源码如下:

    public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {
        T[] copy = ((Object)newType == (Object)Object[].class)
            ? (T[]) new Object[newLength]
            : (T[]) Array.newInstance(newType.getComponentType(), newLength);
        System.arraycopy(original, 0, copy, 0,
                         Math.min(original.length, newLength));
        return copy;
    }

对于上面的代码,假设我们处于一个对于泛型还不那么熟悉的阶段,是绝对会让人感到懵逼的。可以看到该方法上定义了两个泛型分别是T和U;同时泛型T被作为该方法的返回类型;并且其接受的第三个参数,参数类型为Class类,而这个类自身其实也是定义了一个泛型T的,同时在参数声明这里,对于它自身的泛型还用上了上界通配符。

这么多关于泛型的使用方式被同时集中到了同一个方法上面,如果不认真分析下,其实真的很容易让人感到困扰。为了更好的理清我们的思路,我们可以通过自己的方式逐步的来剖析这个方法中如此多的泛型使用方式的意义。

如果对于Java反射有一定的理解,我们就知道我们其实不仅仅只能依靠new的方式去创建一个类的对象,还可以依靠类的class对象通过其newInstance方法得到对象。那么,假设我们就以此功能来创建一个思想与CopyOf类似的方法:

   public static <T> T getT(Class<? extends T> clazz){
        try {
            return clazz.newInstance();
        } catch (InstantiationException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }

        return null;
    }

由此看到,这个方法的定义其实与CopyOf是基本类似的,可以说我们只是减少了泛型和参数的数量,以及简化实际的功能代码。现在我们则一步一步的来开始分析。

首先,假设我们暂且抛开返回类型不看,那么其实该方法就仅仅是在方法上定义了一个泛型T而已,有了之前的基础,这肯定很容易理解了。不同的是,这时我们定义的泛型T并没有直接作为方法的参数去使用,实际上这里方法接受的参数类型是Class类型。

前面也说到,作为Class类来讲,它自身在类上也是定义了一个泛型的。那么这里的参数形式也就不难理解了:其实就是之前我们说到的在声明时的泛型边界限定,这里的方式为上界通配符的限定。而有意思的一点就是:这里的上界不再是某个具体类型,而变成了我们定义在方法上的T,这可能就会让人有点迷惑。

那么其实就可以这样理解:假设我们把这里的T更换回例如之前的Fruit,相信一下其意义就变得很清楚了。而与此同理,其实这里把上界限定为T,其最终效果可以认为其实就等同于:

public static <T> T getT(Class<?> clazz)
//或
public static <T> T getT(Class<? extends Object> clazz)

也就是说,某种意义上来讲,这时的上界是没有限定的,我们传入任何类的Class对象其实都是行得通的。代码验证一下:

    public static void main(String[] args) {        
        getT(Object.class);
        getT(Apple.class);
        getT(Chicken.class);
        getT(ArrayList.class);
    }

事实证明以上的代码都是Ok的,那么我们就会疑惑:既然如此,在这里使用上界通配符是为了什么呢?其实关键就在于这里我们方法的返回类型也涉及了泛型T,看下面的代码:

    public static void main(String[] args) {        
        Grape grape = getT(Grape.class);
        Grape blackCurrant = getT(BlackCurrant.class);
        Grape grape1 = getT(Apple.class); // complie error
    }

这时我们就能从上面的代码中分析出一点端倪了,我们可以看到,此时的情况是 我们试图通过getT方法来获取一个Grape(葡萄)对象,即涉及到了方法的返回类型,那么:

  • 通过传入Grape或者其子类BlackCurrant类的class对象,都是可以成功返回对象的。
  • 但是当我们传入Apple(苹果)的class对象时,则会导致编译错误。

此时,造成这种异常的原因就不难分析了:我们对方法接受的Class类型参数使用了上界通配符,限定其上界为T;与此同时,泛型T也被作为该方法的返回类型。

而当我们写出类似“Grape grape = getT(Grape.class);”这样的代码的时候,实际上就可以理解为,此时我们需要该方法的返回类型为Grape(葡萄)。那么注意了!这其实可以理解为我们通过方法的返回类型来实例化了我们定义在该方法上的泛型T的实际类型为Grape。这也就是为什么:在这个时候,我们传入Grape(葡萄)或者其子类BlackCurrant(黑加仑)作为参数都是可以的,而传入Apple(苹果)则导致编译错误的原因了。

由此我们其实也就可以得知:事实上通过方法的返回类型也是可以实例化泛型类型的,并且某种程度上来讲,它的优先级还要高于在参数中实例化的泛型的类型。

展开阅读全文

没有更多推荐了,返回首页