编写高质量代码:改善Java程序的151个建议(第7章:泛型和反射___建议93~97)
泛型可以减少强制类型的转换,可以规范集合的元素类型,还可以提高代码的安全性和可读性,正式因为有这些优点,自从Java引入泛型后,项目的编码规则上便多了一条:优先使用泛型。
反射可以“看透” 程序的运行情况,可以让我们在运行期知晓一个类或实例的运行状况,可以动态的加载和调用,虽然有一定的性能忧患,但它带给我们的遍历远远大于其性能缺陷。
建议93:Java的泛型是可以擦除的
Java泛型(Generic) 的引入加强了参数类型的安全性,减少了类型的转换,它与C++中的模板(Temeplates) 比较类似,但是有一点不同的是:Java的泛型在编译器有效,在运行期被删除,也就是说所有的泛型参数类型在编译后会被清除掉,我们来看一个例子,代码如下:
1 public class Foo { 2 //arrayMethod接收数组参数,并进行重载 3 public void arrayMethod(String[] intArray) { 4 5 } 6 7 public void arrayMethod(Integer[] intArray) { 8 9 } 10 //listMethod接收泛型List参数,并进行重载 11 public void listMethod(List<String> stringList) { 12 13 } 14 public void listMethod(List<Integer> intList) { 15 16 } 17 }
程序很简单,编写了4个方法,arrayMethod方法接收String数组和Integer数组,这是一个典型的重载,listMethod接收元素类型为String和Integer的list变量。现在的问题是,这段程序是否能编译?如果不能?问题出在什么地方?
事实上,这段程序时无法编译的,编译时报错信息如下:
这段错误的意思:简单的的说就是方法签名重复,其实就是说listMethod(List<Integer> intList)方法在编译时擦除类型后是listMethod(List<E> intList)与另一个方法重复。这就是Java泛型擦除引起的问题:在编译后所有的泛型类型都会做相应的转化。转换规则如下:
- List<String>、List<Integer>、List<T>擦除后的类型为List
- List<String>[] 擦除后的类型为List[].
- List<? extends E> 、List<? super E> 擦除后的类型为List<E>.
- List<T extends Serializable & Cloneable >擦除后的类型为List< Serializable>.
明白了这些规则,再看如下代码:
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
list.add("abc");
String str = list.get(0);
}
进过编译后的擦除处理,上面的代码和下面的程序时一致的:
public static void main(String[] args) {
List list = new ArrayList();
list.add("abc");
String str = (String) list.get(0);
}
Java编译后字节码中已经没有泛型的任何信息了,也就是说一个泛型类和一个普通类在经过编译后都指向了同一字节码,比如Foo<T>类,经过编译后将只有一份Foo.class类,不管是Foo<String>还是Foo<Integer>引用的都是同一字节码。Java之所以如此处理,有两个原因:
- 避免JVM的大换血。C++泛型生命期延续到了运行期,而Java是在编译期擦除掉的,我们想想,如果JVM也把泛型类型延续到运行期,那么JVM就需要进行大量的重构工作了。
- 版本兼容:在编译期擦除可以更好的支持原生类型(Raw Type),在Java1.5或1.6...平台上,即使声明一个List这样的原生类型也是可以正常编译通过的,只是会产生警告信息而已。
明白了Java泛型是类型擦除的,我们就可以解释类似如下的问题了:
- 泛型的class对象是相同的:每个类都有一个class属性,泛型化不会改变class属性的返回值,例如:
public static void main(String[] args) { List<String> list = new ArrayList<String>(); List<Integer> list2 = new ArrayList<Integer>(); System.out.println(list.getClass()==list2.getClass()); }
以上代码返回true,原因很简单,List<String>和List<Integer>擦除后的类型都是List,没有任何区别。
2.泛型数组初始化时不能声明泛型,如下代码编译时通不过:
List<String>[] listArray = new List<String>[];
原因很简单,可以声明一个带有泛型参数的数组,但不能初始化该数组,因为执行了类型擦除操作,List<Object>[]与List<String>[] 就是同一回事了,编译器拒绝如此声明。
3.instanceof不允许存在泛型参数
以下代码不能通过编译,原因一样,泛型类型被擦除了:
List<String> list = new ArrayList<String>(); System.out.println(list instanceof List<String>);
建议94:不能初始化泛型参数和数组
泛型类型在编译期被擦除,我们在类初始化时将无法获得泛型的具体参数,比如这样的代码:
class Test<T> { private T t = new T(); private T[] tArray = new T[5]; private List<T> list = new ArrayList<T>(); }
这段代码有神么问题呢?t、tArray、list都是类变量,都是通过new声明了一个类型,看起来非常相似啊!但这段代码是编译不过的,因为编译器在编译时需要获得T类型,但泛型在编译期类型已经被擦除了,所有new T()和 new T[5]都会报错(有人可能会有疑问,泛型类型可以擦除为顶级Object,那T类型擦除成Object不就可以编译了吗?这样也不行,泛型只是Java语言的一部分,Java语言毕竟是一个强类型、编译型的安全语言,要确保运行期的稳定性和安全性就必须要求在编译器上严格检查)。可为什么new ArrayList<T>()却不会报错呢?
这是因为ArrayList表面是泛型,其实已经在编译期转为Object了,我们来看一下ArrayList的源代码就清楚了,代码如下:
1 public class ArrayList<E> extends AbstractList<E> implements List<E>, 2 RandomAccess, Cloneable, java.io.Serializable { 3 // 容纳元素的数组 4 private transient Object[] elementData; 5 6 // 构造函数 7 public ArrayList() { 8 this(10); 9 } 10 11 // 获得一个元素 12 public E get(int index) { 13 rangeCheck(index); 14 // 返回前强制类型转换 15 return elementData(index); 16 } 17 /* 其它代码略 */ 18 19 }
注意看elementData的定义,它容纳了ArrayList的所有元素,其类型是Object数组,因为Object是所有类的父类,数组又允许协变(Covariant),因此elementData数组可以容纳所有的实例对象。元素加入时向上转型为Object类型(E类型转换为Object),取出时向下转型为E类型,如此处理而已。
在某些情况下,我们需要泛型数组,那该如何处理呢?代码如下:
1 class Test<T> { 2 // 不再初始化,由构造函数初始化 3 private T t; 4 private T[] tArray; 5 private List<T> list = new ArrayList<T>(); 6 7 // 构造函数初始化 8 public Test() { 9 try { 10 Class<?> tType = Class.forName(""); 11 t = (T) tType.newInstance(); 12 tArray = (T[]) Array.newInstance(tType, 5); 13 } catch (Exception e) { 14 e.printStackTrace(); 15 } 16 } 17 }
此时,运行就没有什么问题了,剩下的问题就是怎么在运行期获得T的类型,也就是tType参数,一般情况下泛型类型是无法获取的,不过,在客户端调用时多传输一个T类型的class就会解决问题。
类的成员变量是在类初始化前初始化的,所以要求在初始化前它必须具有明确的类型,否则就只能声明,不能初始化。
建议95:强制声明泛型的实际类型
Arrays工具类有一个方法asList可以把一个变长参数或数组转变为列表,但是它有一个缺点:它所生成的list长度是不可变的,而这在我们的项目开发中有时会很不方便。如果你期望生成的列表长度可变,那就需要自己来写一个数组的工具类了,代码如下:
1 class ArrayUtils { 2 // 把一个变长参数转化为列表,并且长度可变 3 public static <T> List<T> asList(T... t) { 4 List<T> list = new ArrayList<T>(); 5 Collections.addAll(list, t); 6 return list; 7 } 8 }
这很简单,与Arrays.asList的调用方式相同,我们传入一个泛型对象,然后返回相应的List,代码如下:
public static void main(String[] args) { // 正常用法 List<String> list1 = ArrayUtils.asList("A", "B"); // 参数为空 List list2 = ArrayUtils.asList(); // 参数为整型和浮点型的混合 List list3 = ArrayUtils.asList(1, 2, 3.1); }
这里有三个变量需要说明:
(1)、变量list1:变量list1是一个常规用法,没有任何问题,泛型实际参数类型是String,返回结果就是一个容纳String元素的List对象。
(2)、变量list2:变量list2它容纳的是什么元素呢?我们无法从代码中推断出list2列表到底容纳的是什么元素(因为它传递的参数是空,编译器也不知道泛型的实际参数类型是什么),不过,编译器会很聪明地推断出最顶层类Object就是其泛型类型,也就是说list2的完整定义如下:
List<Object> list2 = ArrayUtils.asList();
如此一来,编译器就不会给出" unchecked "警告了。现在新的问题又出现了:如果期望list2是一个Integer类型的列表,而不是Object列表,因为后续的逻辑会把Integer类型加入到list2中,那该如何处理呢?
强制类型转换(把asList强制转换成List<Integer>)?行不通,虽然Java泛型是编译期擦出的,但是List<Object>和List<Integer>没有继承关系,不能强制转换。
重新声明一个List<Integer>,然后读取List<Object>元素,一个一个地向下转型过去?麻烦,而且效率又低。
最好的解决办法是强制声明泛型类型,代码如下:
List<Integer> intList = ArrayUtils.<Integer>asList();
就这么简单,asList方法要求的是一个泛型参数,那我们就在输入前定义这是一个Integer类型的参数,当然,输出也是Integer类型的集合了。
(3)、变量list3:变量list3有两种类型的元素:整数类型和浮点类型,那它生成的List泛型化参数应该是什么呢?是Integer和Float的父类Number?你太高看编译器了,它不会如此推断的,当它发现多个元素的实际类型不一致时就会直接确认泛型类型是Object,而不会去追索元素的公共父类是什么,但是对于list3,我们更期望它的泛型参数是Number,都是数字嘛,参照list2变量,代码修改如下:
List<Number> list3 = ArrayUtils.<Number>asList(1, 2, 3.1);
Number是Integer和Float的父类,先把三个输入参数、输出参数同类型,问题是我们要在什么时候明确泛型类型呢?一句话:无法从代码中推断出泛型的情况下,即可强制声明泛型类型。
建议96:不同的场景使用不同的泛型通配符
Java泛型支持通配符(Wildcard),可以单独使用一个“?”表示任意类,也可以使用extends关键字表示某一个类(接口)的子类型,还可以使用super关键字表示某一个类(接口)的父类型,但问题是什么时候该用extends,什么该用super呢?
(1)、泛型结构只参与 “读” 操作则限定上界(extends关键字)
阅读如下代码,想想看我们的业务逻辑操作是否还能继续:
public static <E> void read(List<? super E> list) { for (Object obj : list) { // 业务逻辑操作 } }
从List列表中读取元素的操作(比如一个数字列表中的求和计算),你觉得方法read能继续写下去吗?
答案是:不能,我们不知道list到底存放的是什么元素,只能推断出E类型是父类,但问题是E类型的父类又是什么呢?无法再推断,只有运行期才知道,那么编码器就无法操作了。当然,你可以把它当做是Object类来处理,需要时再转换成E类型---这完全违背了泛型的初衷。在这种情况下,“读” 操作如果期望从List集合中读取数据就需要使用extends关键字了,也就是要界定泛型的上界,代码如下:
public static <E> void read(List<? extends E> list) { for (E e : list) { // 业务逻辑操作 } }
此时,已经推断出List集合中取出的元素时E类型的元素。具体是什么类型的元素就要等到运行期才确定了,但它一定是一个确定的类型,比如read(Arrays.asList("A"))调用该方法时,可以推断出List中的元素类型是String,之后就可以对List中的元素进行操作了。如加入到另外的List<E>中,或者作为Map<E,V>的键等。
(2)、泛型结构只参与“写” 操作则限定下界(使用super关键字)
先看如下代码能否编译:
public static <E> void write(List<? extends Number> list){ //加入一个元素 list.add(123); }
编译失败,失败的原因是list中的元素类型不确定,也就是编译器无法推断出泛型类型到底是什么,是Integer类型?是Double?还是Byte?这些都符合extends关键字的定义,由于无法确定实际的泛型类型,所以编译器拒绝了此类操作。
在此种情况下,只有一个元素时可以add进去的:null值,这是因为null是一个万用类型,它可以是所有类的实例对象,所以可以加入到任何列表中。
Object是否可以?不可以,因为它不是Number子类,而且即使把List变量修改为List<? extends Object> 类型也不能加入,原因很简单,编译器无法推断出泛型类型,加什么元素都是无效的。
在这种“写”的操作的情况下,使用super关键字限定泛型的下界才是正道,代码如下:
public static <E> void write(List<? super Number> list){ //加入元素 list.add(123); list.add(3.14); }
甭管它是Integer的123,还是浮点数3.14,都可以加入到list列表中,因为它们都是Number的类型,这就保证了泛型类的可靠性。
对于是要限定上界还是限定下界,JDK的Collections.copy方法是一个非常好的例子,它实现了把源列表的所有元素拷贝到目标列表中对应的索引位置上,代码如下:
1 public static <T> void copy(List<? super T> dest, List<? extends T> src) { 2 int srcSize = src.size(); 3 if (srcSize > dest.size()) 4 throw new IndexOutOfBoundsException("Source does not fit in dest"); 5 6 if (srcSize < COPY_THRESHOLD || 7 (src instanceof RandomAccess && dest instanceof RandomAccess)) { 8 for (int i=0; i<srcSize; i++) 9 dest.set(i, src.get(i)); 10 } else { 11 ListIterator<? super T> di=dest.listIterator(); 12 ListIterator<? extends T> si=src.listIterator(); 13 for (int i=0; i<srcSize; i++) { 14 di.next(); 15 di.set(si.next()); 16 } 17 } 18 }
源列表是用来提供数据的,所以src变量需要界定上界,要有extends关键字。目标列表是用来写数据的,所以dest变量需要界定下界,带有super关键字。
如果一个泛型结构既用作 “读” 操作又用作“写操作”,那该如何进行限定呢?不限定,使用确定的泛型类型即可,如List<E>.
建议97:警惕泛型是不能协变和逆变的
什么叫协变和逆变?
在编程语言的类型框架中,协变和逆变是指宽类型和窄类型在某种情况下(如参数、泛型、返回值)替换或交换的特性,简单的说,协变是一个窄类型替换宽类型,而逆变则是用宽类型覆盖窄类型。其实,在Java中协变和逆变我们已经用了很久了,只是我们没发觉而已,看如下代码:
class Base { public Number doStuff() { return 0; } } class Sub extends Base { @Override public Integer doStuff() { return 0; } }
子类的doStuff方法返回值的类型比父类方法要窄,此时doStuff方法就是一个协变方法,同时根据Java的覆写定义来看,这又属于覆写。那逆变是怎么回事呢?代码如下:
class Base {
public void doStuff(Integer i) {
}
}
class Sub extends Base {
@Override
public void doStuff(Number n) {
}
}
子类的doStuff方法的参数类型比父类要宽,此时就是一个逆变方法,子类扩大了父类方法的输入参数,但根据覆写的定义来看,doStuff不属于覆写,只是重载而已。由于此时的doStuff方法已经与父类没有任何关系了,只是子类独立扩展出的一个行为,所以是否声明为doStuff方法名意义不大,逆变已经不具有特别的意义了,我们重点关注一下协变,先看如下代码是否是协变:
public static void main(String[] args) { Base base = new Sub(); }
base变量是否发生了协变?是的,发生了协变,base变量是Base类型,它是父类,而其赋值却是在子类实例,也就是用窄类型覆盖了宽类型。这也叫多态,两者同含义。
说了这么多,下面再再来想想泛型是否支持协变和逆变呢,答案是:泛型既不支持协变,也不支持逆变。为什么会不支持呢?
(1)、泛型不支持协变:数组和泛型很相似,一个是中括号,一个是尖括号,那我们就以数组为参照对象,看如下代码:
public static void main(String[] args) { //数组支持协变 Number [] n = new Integer[10]; //编译不通过,泛型不支持协变 List<Number> list = new ArrayList<Integer>(); }
ArrayList是List的子类型,Integer是Number的子类型,里氏替换原则在此行不通了,原因就是Java为了保证运行期的安全性,必须保证泛型参数的类型是固定的,所以它不允许一个泛型参数可以同时包含两种类型,即使是父子类关系也不行。
泛型不支持协变,但可以使用通配符模拟协变,代码如下:
//Number子类型(包括Number类型) 都可以是泛型参数类型 List<? extends Number> list = new ArrayList<Integer>();
" ? extends Number " 表示的意思是,允许Number的所有子类(包括自身) 作为泛型参数类型,但在运行期只能是一个具体类型,或者是Integer类型,或者是Double类型,或者是Number类型,也就是说通配符只在编码期有效,运行期则必须是一个确定的类型。
(2)、泛型不支持逆变
java虽然允许逆变存在,但在对类型赋值上是不允许逆变的,你不能把一个父类实例对象赋给一个子类类型变量,泛型自然也不允许此种情况发生了。但是它可以使用super关键字来模拟实现,代码如下:
//Integer的父类型(包括Integer)都可以是泛型参数类型 List<? super Integer> list = new ArrayList<Number>();
" ? super Integer " 的意思是可以把所有的Integer父类型(自身、父类或接口) 作为泛型参数,这里看着就像是把一个Number类型的ArrayList赋值给了Integer类型的List,其外观类似于使用一个宽类型覆盖一个窄类型,它模拟了逆变的实现。
泛型既不支持协变,也不支持逆变,带有泛型参数的子类型定义与我们经常使用的类类型也不相同,其基本类型关系如下表所示:
泛型通配符QA | |
问 | 答 |
Integer是Number的子类型? | 正确 |
ArrayList<Integer> 是List<Integer> 的子类型? | 正确 |
Integer[]是 Number[]的子类型? | 正确 |
List<Integer> 是 List<Number> 的子类型? | 错误 |
List<Integer> 是 List<? extends Integer> 的子类型? | 错误 |
List<Integer> 是 List<? super Integer> 的子类型? | 错误 |
Java的泛型是不支持协变和逆变的,只是能够实现逆变和协变 |