美文网首页Android知识
Java学习日常:泛型

Java学习日常:泛型

作者: Happioo | 来源:发表于2017-11-10 17:29 被阅读0次

    附上思维导图。这篇博客主要讲了如下知识点。

    泛型.png

    看完了《Thinking in Java》的第十五章泛型,着实被震了一惊。看之前以为泛型就是泛型,看完之后却发现Java的泛型是通过编译时的擦除在继承和多态的基础上实现的。

    因为擦除的缘故,Java中的泛型在并不能使用运行时的信息。又因为本质上是继承和多态,类型参数的范围被限制到了边界处。Java的泛型机制更像是泛型机制的一个子集。相比之下,C++的模版(C++中的泛型机制)就显得强大许多,通过模版还能实现编译时多态,用前期绑定实现多态,提高运行时效率。

    虽然Java的泛型机制不如C++中的模版那样完整,但还是有很大作用的,像提供了编译期的类型检查,标明类型参数也可以提高程序的表达性,还有自动地转型。


    一、泛型的目的

    泛型的主要目的是为了解耦类或方法与所用类型之间的约束,使代码具有最广泛的的表达能力。看下面这一个例子,在这个例子中,我们要做一个计算机。

    public class Computer{
    
        private MechanicalHarddisk mHarddisk;   // 机械硬盘
        
        Computer(MechanicalHarddisk harddisk){
            mHarddisk = harddisk;
        }
        
        public Data readDisk(){
            return mHarddisk.read();
        }
        
        public void writeDisk(Data data){
            mHarddisk.write(data);
        }
    }
    

    可以看到,这个类有个很大的问题,它只能装机械硬盘。我们的Computer类被他所用的MechanicalHarddisk类型约束起来了。如果我们以后还需要使用固态硬盘的类,我们就只能重写一个了。而通过泛型,我们可以把所用的类型通过一个类型参数从类中抽离出来,来解耦这种约束。使用泛型实现的Computer类如下。

    public class Computer<T extends Harddisk> {
        private T mHarddisk;    // 硬盘
        
        Computer(Harddisk harddisk){
            mHarddisk = harddisk;
        }
        
        public Data readDisk(){
            return mHarddisk.read();
        }
        
        public void writeDisk(Data data){
            mHarddisk.write(data);
        }
    }
    

    可以看到,我们把硬盘的类型抽成了一个类型参数T,其中T extends Harddisk是对类型参数T的一种约束,表示T的类型必须是Harddisk类的子类,从语义上说即T必须是个硬盘。后面讲到Java泛型的实现时我们会发现这个约束对于这个例子是必加的。

    自此约束就不复存在了。如果,我们需要一个使用机械硬盘的计算机类,那就是Computer<MechanicalHarddisk>,而如果需要一个使用固态硬盘的计算机类,那就是Computer<SolidstateHarddisk>,如果以后有了新品种的硬盘,那就把类型参数指定为新品种就好了。


    二、泛型的实现

    Java泛型的实现从本质上来讲就是继承和多态。同样以我们的Computer类为例,我们也可以通过如下的方式进行解耦。

    public class Computer{
        private Harddisk mHarddisk; // 硬盘
        
        Computer(Harddisk harddisk){
            mHarddisk = harddisk;
        }
        
        public Data readDisk(){
            return mHarddisk.read();
        }
        
        public void writeDisk(Data data){
            mHarddisk.write(data);
        }
    }
    

    因为MechanicalHarddiskSolidstateHarddisk也是一个Harddisk,它们继承自Harddisk,所以可以通过一个Harddisk引用来持有各种硬盘对象。而因为多态性,通过这个Harddisk引用将访问到所引对象的具体类型中的方法,如果对象的具体类型为一个MechanicalHarddisk,那么就会访问MechanicalHarddiskread方法write方法。

    为了将所用类型从类中解耦出来,我们只要将所有用到该类型的地方用一个继承链上的边界类型代替即可,在这个例子中,边界类型就是Harddisk

    通过这种方式,我们就可以把类中所用类型放宽到边界类型及其子类型。对于这个例子来说,解耦到这个程度可以说是“刚刚好,不多也不少"。

    擦除

    就像我们在上面说的那样,Java泛型的实现方式就是将类型参数用边界类型替换,在上面的例子中就是把THarddisk替换。这种实现方式看上去就像是把具体的类型(某种硬盘,机械的或者是固态的),擦除到了边界类型(它们的父类Harddisk)。在Java中,所有的类型都最终继承自一个唯一的类型,Object类型,这种继承结构被称为单根继承结构。在不指明边界的情况下,即不用T extends xxx指明边界时,边界类型即为Object类型

    对泛型的处理全部集中在编译期,在编译时,编译器会执行如下操作。

    • 会将泛型类的类型参数都用边界类型替换。
    • 对于传入对象给方法形参的指令,编译器会执行一个类型检查,看传入的对象是不是类型参数所指定的类型。
    • 对于返回类型参数表示对象的指令,也会执行一个类型检查,还会插入一个自动的向下转型,将对象从边界类型向下转型到类型参数所表示的类型。

    以一个容器类为例。

    public class Holder<T> {
    
        private T obj;
    
        public void set(T obj){
            this.obj = obj;
        }
        
        public T get(){
            return obj;
        }
        
        public static void main(String[] args){
            Holder<Integer> holder = new Holder<>();
            holder.set(1);
            Integer obj = holder.get();
        }       
    }
    
    • 在编译时,该类中的所有的T都会被替换为边界类型Object
    • 对于holder.set(1)这条指令,编译器会检查实参是不是一个Integer,虽然这里的1int类型,但是因为自动包装机制的存在,他会被转化为一个Integer,因此能够通过类型检查。
    • 而对于Integer obj = holder.get()这条指令,编译器也会进行类型检查,并且自动插入一个Object类型Integer类型的转型操作。

    三、擦除给Java泛型带来的问题

    在上一节中,我们可以看到,通过擦除可以很容易地将泛型的一些像特性引入到继承加多态的实现方式上去。

    但是这种实现方式,其实有些问题。在编译时,由于擦除,类型参数T被替换为了边界类型,这使得那些需要知道参数类型T的确切类型的操作都不能正常工作(注意是T这个类型参数的确切类型而不是所引对象的类型)。

    public class Erased<T>{
        public void erase(String[] args){
            if(args instanceof T){}
            T var = new T();
            T[] array = new T[100];
        }
    }
    

    这段代码中的三条语句在编译时都会报错。由于擦除,这三条语句运行的结果和我们期望的是不同的,比如我们要使用Erased<Integer>,那么我们想要的运行方式是这样的。

        if(args instanceof Integer){}
        Integer var = new Integer();
        Integer[] array = new Integer[100];
    

    但实际上,如果能运行的话,结果是这样的。

        if(args instanceof Object){}
        Object var = new Object();
        Object[] array = new Object[100];
    

    和我们所期望的相去甚远呀。所以为了避免出现这种情况,编译器并不会允许出现这样的代码。接下来让我们来依次解决这些情况。

    在泛型类中使用类型信息

    上面提到擦除带来的问题是参数类型T的确切类型在编译时被擦除到边界类型,使那些需要知道T确切类型的操作都不能正常工作。那既然少了个类型信息,那我们传个进去不就好了吗,我们可以把T对应的Class对象传到泛型类里面。之后要用到参数类型T的地方通过操作Class对象即可。如new T()可以通过class对象newInstance()来实现,但这个方法只适用于无参构造函数,如果有参数的要求,可以通过反射来实现,instanceof操作符和创建数组也可以通过反射来完成。

    另一种思路是将需要T确切类型的操作从泛型类中抽离出来,做成一个接口。以new T()为例。

    class Erased<T>{
        public void init(IFactory<T> factory){
            T t = factory.create();  // 此处即为new T()的工厂方法的实现
        }
    }
    
    interface IFactory<T>{
        T create();
    }
    
    class IntegerFactory implements IFactory<Integer>{
        public Integer create(){
            return new Integer(10);
        }
    }
    
    public class newTwithFactory{
        public static void main(String[] args){
            Erased<Integer> erased = new Erased<>();
            erased.init(new IntegerFactory());
        }
    }
    

    在这个例子中,把new T()操作抽成了一个接口IFactory<T>,对不同的类型参数,需要实现一个生产相应类型的工厂,并将它传入到泛型类中。其实,前面说的Class对象也可以看作是一个工厂,只不过这个工厂是Java类库中实现好的,我们只要拿来用就好了。这两种实现方式还有一个不同,在使用工厂时,编译器会提供编译期类型检查,而使用Class对象,是在运行时进行检查的。

    为什么Java的泛型会采用擦除的实现方式

    其实Java在一开始是不支持泛型的,那个时候Java中的容器类都是以继承加多态的形式实现的。直到Java SE,泛型才被引入到Java中。但此时为了兼容以前的代码并且将类库改写为泛型的形式,Java的设计者才决定使用擦除。


    四、受约束的类型参数

    让我们先来看一段代码。

    class Communicate {
        public static <T> void perform(T performer){
            performer.speak();
            performer.play();
        }   
    }
    
    class Dog {
        public void speak(){
            System.out.println("Dog speak!");
        }
        
        public void play(){
            System.out.println("Dog play!");
        }
    }
    
    public class CommunicateExample {
        public static void main(String[] args){
            Communicate.perform(new Dog());
        }   
    }
    

    Communicate类中,有一个perform()方法,在其中执行了传入对象的speak()方法play()方法,为了解耦这个方法和它接受的参数类型之间的约束,我们将接收参数抽成了一个类型参数,由调用者指定。在理想的情况下,它应该能为含有speak()方法play()方法的所有类型提供服务。就像上面这个例子一样,Dog类有这两个方法,就应该能作为这个方法的实参。但在Java中,这样并不行,上面的代码根本通不过编译。

    原因是还是因为编译时会执行擦除,类型参数T会被擦除到边界类型Object,显然,Object类中没有speak()方法play()方法,编译自然就通不过了。所以,我们必须要给他指定一个具有这两个方法的边界类型,就像下面这样。

    interface Performs {
        void speak();
        void play();
    }
    
    class Communicate {
        public static <T extends Performs> void perform(T performer){
            performer.speak();
            performer.play();
        }   
    }
    

    而要使用这个方法的类型,必须实现这一个接口(如果是边界是一个类,那就是继承这个类),就像下面这样。

    class Dog implements Performs {
        public void speak(){
            System.out.println("Dog speak!");
        }
        
        public void play(){
            System.out.println("Dog play!");
        }
    }
    

    修改后的代码就能通过编译了。但这明显是对泛型的一种限制,因为参数类型必须继承自特定的类或者特定的接口,而不是我们希望的“有这两个方法就行了”。

    当然,我们也有对应的解决方案。

    1. 第一种,我们可以利用反射,在Communicate.perform()方法得到这个对象的Class对象,然后可以通过Class对象获得这两个方法的Method对象,最后执行这两个方法即可。如果这个对象没有这两个方法,程序将会在运行时抛出异常,代码相对优雅,但因为借助了反射,运行速度会稍微慢一点。
    2. 第二种,我们可以利用适配器模式。为需要使用这个方法的类型再创建一个适配类型,适配类型继承原类型,再实现方法所需接口即可,会产生额外的类型,运行速度比反射的实现方式要快。

    五、奇怪的warning

    在学习以下知识点之前,我们需要先学习以下什么是原生类型。在使用上,原生类型就是不指名参数类型的泛化类型,以ArrayList类为例。

    // 泛型类型
    ArrayList<Integer> intList = new ArrayList<>();
    // 原生类型
    ArrayList rawList = new ArrayList();
    

    从本质上讲它们其实是同一个类型,不懂的同学回到擦除那再看一遍。相当于我们前面讲的计算机类和硬盘那个例子中的两个实现,一个是泛型的实现方式,一种是不带泛型,纯多态的方式。

    在使用泛型的过程中,常常会出现以下的unchecked warning

    Note: Example.java uses unchecked or unsafe operations.
    Note: Recompile with -Xlint:unchecked for details.
    

    这通常出现在将原生类型转化为泛化类型的指令中。如:

    public class WarningDemo {
        public static void main(String[] args){
            Box<Integer> bi;
            bi = createBox();
        }
    
        static Box createBox(){
            return new Box();
        }
    }
    

    在这种情况下,我们会得到如上的单个警告。为了获得详细信息,我们可以按他说的做,用-Xlint:unchecked来编译。得到的详细信息如下:

    WarningDemo.java:4: warning: [unchecked] unchecked conversion
    found   : Box
    required: Box<java.lang.Integer>
            bi = createBox();
                          ^
    1 warning
    

    这主要是因为编译器没有足够的类型信息来进行类型检查,因为编译器并不知道原生类型对应的类型参数是什么。


    六、Java泛型的作用

    虽然Java的泛型机制不是那么完善,但对Java编程还是有挺大的帮助的。当然,并没有什么非常重大的突破,毕竟本质还是继承加多态,并不是真正的泛型。

    Java泛型所做的工作其实就是使我们在使用泛化代码时显式指明类型。比如,在没有引入泛型前,我们这样使用一个存放Integer对象ArrayList容器

    ArrayList list = new ArrayList();
    list.add(10);
    ...
    list.add(2.345);    //  可以通过编译,因为ArrayList的边界类型为Object,但是发生了猫在狗列表的问题
    

    在引入泛型后,我们这样写。

    ArrayList<Integer> list = new ArrayList<>();
    list.add(10);
    ...
    list.add(2.345);    // 编译时就会报错,因为我们要的是一个Integer的列表
    

    在引入泛型后,提高了程序的表达性。我们不再用含糊不清的ArrayList,我们通过ArrayLIst<Integer>指明了他是一个存放Integer对象ArrayList。此外,为了保证这个ArrayLIst<Integer>中确实是存放了Integer,编辑器提供了编译期类型检查,在传递实参给类型参数所代表的形参时,检查了其是否是正确的类型。同时在方法传出类型参数对应对象时,提供了自动转型和参数检查,使传出的对象确实是指定的类型。

    注意: 是在使用泛化代码时指名类型,在写泛化代码时我们需要尽可能地忘记类型来使代码能适用于更多的类型。

    另外,注意不要滥用泛型,因为在Java中,我们用泛型能完成的泛化代码,通过多态也能写出来,而且写起来还比泛型代码简单(因为Java泛型的各种缺陷使写泛型代码非常困难,但是泛型代码用起来是很简单的),而且可读性更强(注意是泛化代码的可读性,在使用时泛型类的可读性更高)。在写泛型代码前,需要想一想这个类的使用时可表达性是否值得我们去写那么一个复杂的泛型类。

    借用网上的一句话:泛型泛型,如果型不泛的话还泛什么型。


    七、感想

    其实泛型是多态中的一种,它被称为参数化多态。而我们平时说的多态其实是多态中的子类型多态。在C++中,对这两种多态都有很好的支持,而且有完整的泛型编程。但在Java中说是泛型,其实还是子类型多态,这也是Java的一大缺憾吧。但换一个角度来想,Java也因此显得更存粹更简单了。

    相关文章

      网友评论

        本文标题:Java学习日常:泛型

        本文链接:https://www.haomeiwen.com/subject/bzwbmxtx.html