Java 泛型总结(三):通配符的使用
简介
前两篇文章介绍了泛型的基本用法、类型擦除以及泛型数组。在泛型的使用中,还有个重要的东西叫通配符,本文介绍通配符的使用。
这个系列的另外两篇文章:
- Java泛型总结(一):基本用法与类型擦除
- Java泛型总结(二):泛型与数组
数组的协变
在了解通配符之前,先来了解一下数组。Java中的数组是协变的,什么意思?看下面的例子:
classFruit{} classAppleextendsFruit{} classJonathanextendsApple{} classOrangeextendsFruit{} publicclassCovariantArrays{ publicstaticvoidmain(String[]args){ Fruit[]fruit=newApple[10]; fruit[0]=newApple();//OK fruit[1]=newJonathan();//OK //RuntimetypeisApple[],notFruit[]orOrange[]: try{ //CompilerallowsyoutoaddFruit: fruit[0]=newFruit();//ArrayStoreException }catch(Exceptione){System.out.println(e);} try{ //CompilerallowsyoutoaddOranges: fruit[0]=newOrange();//ArrayStoreException }catch(Exceptione){System.out.println(e);} } }/*Output: java.lang.ArrayStoreException:Fruit java.lang.ArrayStoreException:Orange *///:~
main方法中的第一行,创建了一个Apple数组并把它赋给Fruit数组的引用。这是有意义的,Apple是Fruit的子类,一个Apple对象也是一种Fruit对象,所以一个Apple数组也是一种Fruit的数组。这称作数组的协变,Java把数组设计为协变的,对此是有争议的,有人认为这是一种缺陷。
尽管Apple[]可以“向上转型”为Fruit[],但数组元素的实际类型还是Apple,我们只能向数组中放入Apple或者Apple的子类。在上面的代码中,向数组中放入了Fruit对象和Orange对象。对于编译器来说,这是可以通过编译的,但是在运行时期,JVM能够知道数组的实际类型是Apple[],所以当其它对象加入数组的时候就会抛出异常。
泛型设计的目的之一是要使这种运行时期的错误在编译期就能发现,看看用泛型容器类来代替数组会发生什么:
//CompileError:incompatibletypes: ArrayListflist=newArrayList ();
上面的代码根本就无法编译。当涉及到泛型时,尽管Apple是Fruit的子类型,但是ArrayList
使用通配符
从上面我们知道,List
上边界限定通配符
利用形式的通配符,可以实现泛型的向上转型:
publicclassGenericsAndCovariance{ publicstaticvoidmain(String[]args){ //Wildcardsallowcovariance: Listflist=newArrayList(); //CompileError:can'taddanytypeofobject: //flist.add(newApple()); //flist.add(newFruit()); //flist.add(newObject()); flist.add(null);//Legalbutuninteresting //WeknowthatitreturnsatleastFruit: Fruitf=flist.get(0); } }
上面的例子中,flist的类型是List 我们可以把它读作:一个类型的List,这个类型可以是继承了Fruit的某种类型。注意,这并不是说这个List可以持有Fruit的任意类型。通配符代表了一种特定的类型,它表示“某种特定的类型,但是flist没有指定”。这样不太好理解,具体针对这个例子解释就是,flist引用可以指向某个类型的List,只要这个类型继承自Fruit,可以是Fruit或者Apple,比如例子中的newArrayList
如上所述,通配符List表示某种特定类型(Fruit或者其子类)的List,但是并不关心这个实际的类型到底是什么,反正是Fruit的子类型,Fruit是它的上边界。那么对这样的一个List我们能做什么呢?其实如果我们不知道这个List到底持有什么类型,怎么可能安全的添加一个对象呢?在上面的代码中,向flist中添加任何对象,无论是Apple还是Orange甚至是Fruit对象,编译器都不允许,唯一可以添加的是null。所以如果做了泛型的向上转型(Listflist=newArrayList
另一方面,如果调用某个返回Fruit的方法,这是安全的。因为我们知道,在这个List中,不管它实际的类型到底是什么,但肯定能转型为Fruit,所以编译器允许返回Fruit。
了解了通配符的作用和限制后,好像任何接受参数的方法我们都不能调用了。其实倒也不是,看下面的例子:
publicclassCompilerIntelligence{ publicstaticvoidmain(String[]args){ Listflist= Arrays.asList(newApple()); Applea=(Apple)flist.get(0);//Nowarning flist.contains(newApple());//Argumentis‘Object' flist.indexOf(newApple());//Argumentis‘Object' //flist.add(newApple());无法编译 } }
在上面的例子中,flist的类型是List ,泛型参数使用了受限制的通配符,所以我们失去了向其中加入任何类型对象的例子,最后一行代码无法编译。
但是flist却可以调用contains和indexOf方法,它们都接受了一个Apple对象做参数。如果查看ArrayList的源代码,可以发现add()接受一个泛型类型作为参数,但是contains和indexOf接受一个Object类型的参数,下面是它们的方法签名:
publicbooleanadd(Ee) publicbooleancontains(Objecto) publicintindexOf(Objecto)
所以如果我们指定泛型参数为时,add()方法的参数变为?extendsFruit,编译器无法判断这个参数接受的到底是Fruit的哪种类型,所以它不会接受任何类型。
然而,contains和indexOf的类型是Object,并没有涉及到通配符,所以编译器允许调用这两个方法。这意味着一切取决于泛型类的编写者来决定那些调用是“安全”的,并且用Object作为这些安全方法的参数。如果某些方法不允许类型参数是通配符时的调用,这些方法的参数应该用类型参数,比如add(Ee)。
当我们自己编写泛型类时,上面介绍的就有用了。下面编写一个Holder类:
publicclassHolder{ privateTvalue; publicHolder(){} publicHolder(Tval){value=val;} publicvoidset(Tval){value=val;} publicTget(){returnvalue;} publicbooleanequals(Objectobj){ returnvalue.equals(obj); } publicstaticvoidmain(String[]args){ Holder Apple=newHolder (newApple()); Appled=Apple.get(); Apple.set(d); //Holder Fruit=Apple;//Cannotupcast Holderfruit=Apple;//OK Fruitp=fruit.get(); d=(Apple)fruit.get();//Returns‘Object' try{ Orangec=(Orange)fruit.get();//Nowarning }catch(Exceptione){System.out.println(e);} //fruit.set(newApple());//Cannotcallset() //fruit.set(newFruit());//Cannotcallset() System.out.println(fruit.equals(d));//OK } }/*Output:(Sample) java.lang.ClassCastException:ApplecannotbecasttoOrange true *///:~
在Holer类中,set()方法接受类型参数T的对象作为参数,get()返回一个T类型,而equals()接受一个Object作为参数。fruit的类型是Holder,所以set()方法不会接受任何对象的添加,但是equals()可以正常工作。
下边界限定通配符
通配符的另一个方向是“超类型的通配符“:?superT,T是类型参数的下界。使用这种形式的通配符,我们就可以”传递对象”了。还是用例子解释:
publicclassSuperTypeWildcards{ staticvoidwriteTo(Listapples){ apples.add(newApple()); apples.add(newJonathan()); //apples.add(newFruit());//Error } }
writeTo方法的参数apples的类型是List 它表示某种类型的List,这个类型是Apple的基类型。也就是说,我们不知道实际类型是什么,但是这个类型肯定是Apple的父类型。因此,我们可以知道向这个List添加一个Apple或者其子类型的对象是安全的,这些对象都可以向上转型为Apple。但是我们不知道加入Fruit对象是否安全,因为那样会使得这个List添加跟Apple无关的类型。
在了解了子类型边界和超类型边界之后,我们就可以知道如何向泛型类型中“写入”(传递对象给方法参数)以及如何从泛型类型中“读取”(从方法中返回对象)。下面是一个例子:
publicclassCollections{ publicstaticvoidcopy(Listdest,Listsrc) { for(inti=0;i src是原始数据的List,因为要从这里面读取数据,所以用了上边界限定通配符:,取出的元素转型为T。dest是要写入的目标List,所以用了下边界限定通配符:,可以写入的元素类型是T及其子类型。
无边界通配符
还有一种通配符是无边界通配符,它的使用形式是一个单独的问号:List>,也就是没有任何限定。不做任何限制,跟不用类型参数的List有什么区别呢?
List>list表示list是持有某种特定类型的List,但是不知道具体是哪种类型。那么我们可以向其中添加对象吗?当然不可以,因为并不知道实际是哪种类型,所以不能添加任何类型,这是不安全的。而单独的Listlist,也就是没有传入泛型参数,表示这个list持有的元素的类型是Object,因此可以添加任何类型的对象,只不过编译器会有警告信息。
总结
通配符的使用可以对泛型参数做出某些限制,使代码更安全,对于上边界和下边界限定的通配符总结如下:
- 使用Listlist这种形式,表示list可以引用一个ArrayList(或者其它List的子类)的对象,这个对象包含的元素类型是C的子类型(包含C本身)的一种。
- 使用Listlist这种形式,表示list可以引用一个ArrayList(或者其它List的子类)的对象,这个对象包含的元素就类型是C的超类型(包含C本身)的一种。
大多数情况下泛型的使用比较简单,但是如果自己编写支持泛型的代码需要对泛型有深入的了解。这几篇文章介绍了泛型的基本用法、类型擦除、泛型数组以及通配符的使用,涵盖了最常用的要点,泛型的总结就写到这里。
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,同时也希望多多支持毛票票!