泛型入门
Java集合有个缺点——把一个对象“丢进”集合里之后,集合就会“忘记”这个对象的数据类型,当再次取出该对象时,该对象的编译类型就变成了Object类型(其运行时类型没有变)。
Java9增强的“菱形”语法
<>)即可,Java可以推断尖括号里应该是什么信息。
Java9再次增强了“菱形”语法,它甚至允许在创建匿名内部类时使用菱形语法,Java可根据上下文来推断匿名内部类中泛型的类型。
深入泛型
定义泛型接口、类
public interface TestList<E> {
//在该接口里,E可作为类型使用
//下面方法可以使用E作为参数类型
void add(E x);
Iterator<E> iterator();
}
//定义接口时指定了一个泛型形参,该形参名为E
interface IteratroTest<E>{
//在接口里E完全可以作为类型使用
E next();
boolean hasNext();
}
//定义该接口时指定了两个泛型形参,其形参名为K,V
interface MapTest<K, V>{
Set<K> keySet();
V put(K key, V value);
}
//泛型形参任意大写字母都可以担任
泛型的实质:允许在定义接口、类时声明泛型形参,泛型形参在整个接口、类体内可当成类型使用,几乎所有可使用普通类型的地方都可以使用这种泛型形参。
注意:
包含泛型声明的类型可以在定义变量、创建对象时传入一个类型实参,从而可以动态地生成无数多个逻辑上的子类,但这种子类在物理上并不存在。
从泛型类派生子类
使用这些接口、父类时不能再包含泛型形参。
定义类、接口、方法时可以声明泛型形参,但是使用类、接口、方法时应该为泛型形参传入实际的类型,也就是要使用泛型实参。
调用方法时必须为所有的数据形参传入参数值,与调用方法不同的是,使用类、接口时也可以不为泛型形参传入实际的类型参数,即下面代码也是正确的。
//使用Apple类时,没有为T形参传入实际的类型参数
public class A extends Apple
像这种使用Apple类时省略泛型的形式被称为原始类型(raw type)。
并不存在泛型类
我们可以假设看作ArrayList<String>类当成是ArrayList的子类,事实上,ArrayList<String>类也确实像一种特殊的ArrayList类:该ArrayList<String>对象只能添加String对象作为集合元素。但实际上,系统并没有为ArrayList<String>生成新的class文件,而且也不会把ArrayList<String>当成新类来处理。
静态方法、静态初始化块或者静态变量的声明和初始化中不允许使用泛型形参。
泛型类中的静态方法和静态变量不可以使用泛型类所声明的泛型类型参数。 因为泛型类中的泛型参数的实例化是在定义泛型类型对象(例如ArrayList<Integer>)的时候指定的,而静态变量和静态方法不需要使用对象来调用。对象都没有创建,如何确定这个泛型参数是何种类型,所以当然是错误的。
instanceof运算符后不能使用泛型类。如下代码是错误的:
java.util.Collection<String> cs = new java.util.ArrayList<>();
//下面代码编译时引起错误:instanceof运算符后不能使用泛型
if (cs instanceof java.util.ArrayList<String>){...}
类型通配符
当使用一个泛型类时(包括声明变量和创建对象两种情况),都应该为这个泛型类传入一个类型实参。如果没有传入类型实际参数,编译器就会提出泛型警告。
注意
如果Foo是Bar的一个子类型(子类或者子接口),而G是具有泛型声明的类或接口,G<Foo>并不是G<Bar>的子类型!这一点非常值得注意,因为它与大部分人的习惯认为是不同的。
Java在泛型的设计原则是,只要代码在编译时没有出现警告,就不会遇到运行时ClassCastException异常。
注意:
数组和泛型有所不同,假设Foo是Bar的一个子类型(子类或者子接口),那么Foo[]依然是Bar[]的子类型;但G<Foo>不是G<Bar>的子类型。Foo[]自动向上转型为Bar[]的方式被称为型变。也就是说,Java的数组支持型变,但Java集合并不支持型变。
使用类型通配符
?),将一个问号作为类型实参传给List集合,写作:List<?>(意思是元素类型未知的List)。这个问号(?)被称为通配符,它的元素类型可以匹配任何类型。
List<Object>与List<?>并不等同,List<Object>是List<?>的子类。还有不能往List<?> list里添加任意对象,除了null。
注意:
List<?>这种写法可以适用于任何支持泛型声明的接口和类,比如写成Set<?>、Collection<?>、Map<?,?>等。
但这种带通配符的List仅表示它是各种泛型List的父类,并不能把元素加入到其中。
设定类型通配符的上限
一种特殊情形,程序不希望这个List<?>是任何泛型List的父类,只希望它代表某一类泛型List的父类。
Java提供了被限制的泛型通配符:
//它表示泛型形参必须是Shape子类的List
List<? extends Shape>
List<? extends Shape>是受限制通配符的例子,此处的问号(?)表示一个未知的类型,就像前面看到的通配符一样。但是此处的这个未知类型一定是Shape的子类型(也可以是Shape本身),因此可以把Shape称为这个通配符的上限(Upper bound)。
类似地,由于程序无法确定这个受限制的通配符的具体类型,所以不能把Shape对象或其子类的对象加入这个泛型集合中。例如,下面代码就是错误的:
public void addRectangle(List<? extends Shape> shapes{
//下面代码引起编译错误
shapes.add(0,new Rectangle());
}
与使用普通通配符相似的是,shapes.add()的第二个参数类型是? extends Shape,它表示Shape未知的子类,程序无法确定这个类型是什么,所以无法将任何对象添加到这种集合中。
只能从集合中获取元素(取出的元素总是上限的类型),不能向集合中添加元素(因为编译器无法确定集合元素实际是哪种子类型)。
协变。
对于协变的泛型类来说,它只能调用泛型类型作为返回值类型的方法(编译器会将该方法返回值当成通配符上限的类型);而不能调用泛型类型作为参数的方法。
口诀是:协变只出不进!
注意:
对于指定通配符上限的泛型类,相当于通配符上限是Object。
设定类型通配符的下限
//下面的E是定义TreeSet类时的泛型形参
TreeSet(Comparator<? super E> c)
逆变。
对于逆变的泛型集合来说,编译器只知道集合元素是下限的父类型,但具体是哪种父类型则不确定。因此,这种逆变的泛型集合能向其中添加元素(因为实际赋值的集合元素总是逆变声明的父类),从集合中取出元素时只能被当成Object类型处理(编译器无法确定取出的到底是哪个父类的对象)。
- < ? super E> 用于灵活写入或比较,使得对象可以写入父类型的容器,使得父类型的比较方法可以应用于子类对象。
- < ? extends E> 用于灵活读取,使得方法可以读取 E 或 E 的任意子类型的容器对象。
为了获得最大限度的灵活性,要在表示 生产者或者消费者 的输入参数上使用通配符,使用的规则就是:生产者有上限、消费者有下限:
PECS: producer-extends, costumer-super
https://www.jianshu.com/p/2bf15c5265c5
通配符引入协变、逆变
协变
Java泛型是不变的,可有时需要实现协变,在两个类型之间建立某种类型的向上转型关系,怎么办呢?这时,通配符派上了用场。
public class GenericsAndCovariance {
public static void main(String[] args) {
List<? extends Fruit> flist = new ArrayList<Apple>();
flist.add(new Apple()); // 编译错误
flist.add(new Fruit()); // 编译错误
flist.add(new Object()); // 编译错误
}
}
现在flist的类型是<? extends Fruit>,extends指出了泛型的上界为Fruit,<? extends T>称为子类通配符,意味着某个继承自Fruit的具体类型。使用通配符可以将ArrayList<Apple>向上转型了,也就实现了协变。
然而,事情变得怪异了,观察上面代码,你再也不能往容器里放入任何东西,甚至连Apple都不行。
原因在于,List<? extends Fruit>也可以合法的指向一个List<Orange>,显然往里面放Apple、Fruit、Object都是非法的。编译器不知道List<? extends Fruit>所持有的具体类型是什么,所以一旦执行这种类型的向上转型,你就将丢失掉向其中传递任何对象的能力。
类比数组,尽管你可以把Apple[]向上转型成Fruit[],然而往里面添加Fruit和Orange等对象都是非法的,会在运行时抛出ArrayStoreException异常。泛型把类型检查移到了编译期,协变过程丢掉了类型信息,编译器拒绝所有不安全的操作。
逆变
我们还可以走另外一条路,就是逆变。
public class SuperTypeWildcards {
static void writeTo(List<? super Apple> apples) {
apples.add(new Apple());
apples.add(new Jonathan());
apples.add(new Fruit()); // 编译错误
}
}
我们重用了关键字super指出泛型的下界为Apple,<? super T>称为超类通配符,代表一个具体类型,而这个类型是Apple的超类。这样编译器就知道向其中添加Apple或Apple的子类型(例如Jonathan)是安全的了。但是,既然Apple是下界,那么可以知道向这样的List中添加Fruit是不安全的。
PECS
上面说的可能有点绕,那么总结下:什么使用extends,什么时候使用super。《Effective Java》给出精炼的描述:producer-extends, consumer-super(PECS)。
说直白点就是,从数据流来看,extends是限制数据来源的(生产者),而super是限制数据流入的(消费者)。例如上面SuperTypeWildcards类里,使用<? super Apple>就是限制add方法传入的类型必须是Apple及其子类型。
仿照上面的代码,我写了个ExtendTypeWildcards类,可以看出<? extends Apple>限制了get方法返回的类型必须是Apple及其父类型。
public class ExtendTypeWildcards {
static void readFrom(List<? extends Apple> apples) {
Apple apple = apples.get(0);
Jonathan jonathan = apples.get(0); // 编译错误
Fruit fruit = apples.get(0);
}
}
设定泛型形参的上限
定义泛型形参时设定上限,用于表示传给该泛型形参的实际类型要么是该上限类型,要么是该上限类型的子类。下面程序示范了这种用法:
T extends Number
public class Apple<T extends Number> {
T col;
public static void main(String[] args) {
Apple<Integer> ai = new Apple<Integer>();
Apple<Double> ad = new Apple<Double>();
//下面代码将引发编译异常,下面代码试图把String类型传给T形参
//但String不是Number的子类型,所以引起编译错误
Apple<String> as = new Apple<String>(); //1
}
}
上面程序定义了一个Apple泛型类,该Apple类的泛型形参的上限是Number类,这表明使用Apple类时为T形参传入的实际类型参数只能是Number或Number类的子类。上面程序在1处将引起编译错误:类型T的上限是Number类型,而此处传入的实际类型是String类型,既不是Number类型,也不是Number的子类型,所以将会导致编译错误。
在一种更极端的情况下,程序需要为泛型形参设定多个上限(至多有一个父类上限,可以有多个接口上限),表名该泛型必须是其父类的子类(是父类本身也行),并且实现多个上限接口。如下代码所示:
//表明T类型必须是Number类或其子类,并必须实现java.io.Serializable接口
public class Apple<T extends Number & java.io.Serializable> {
...
}
泛型方法
泛型方法的语法格式如下:
修饰符 <T , S> 返回值类型 方法名(形参列表){
//方法体...
}
泛型方法的方法签名比普通方法的方法签名多了泛型形参声明,泛型形参声明以尖括号括起来,多个泛型形参之间以逗号(,)隔开,所有的泛型形参声明放在方法修饰符和方法返回值类型之间。
只能在该方法里使用,而接口、类声明中定义的泛型则可以在整个接口、类中使用。
每个方法和每个方法中的T是独立互不影响的,也就是说就算都命名为T,但是命名空间不是同一个。
泛型方法和类型通配符的区别
大多数时候都可以使用泛型方法来代替类型通配符。
异常
return或throw语句,这两个语句都会导致该方法立即结束,但是系统执行这两个语句并不会结束该方法,而是去寻找该异常处理流程中是否包含finally块,如果没有finally块,程序立即执行finally块——只有当finally块执行完成后,系统才会再次跳回来执行try块、catch块里的return或throw语句;如果finally块里也使用了return或throw等导致方法结束的语句,finally块已经中止了方法,系统将不会跳回去执行try块、catch块里的任何代码。
Java9增强的自动关闭资源的try语句
提示:
Java7几乎把所有的“资源类”(包括文件IO的各种类、JDBC编程的Connection、Statement等接口)进行了改写,改写后资源类都实现了AutoCloseable或Closeable接口。
如果程序需要,自动关闭资源的try语句后也可以带多个catch块和一个finally块。
Java9再次增强了这种try语句,Java9不要求在try后的圆括号内声明并创建资源,只需要自动关闭的资源有final修饰或者是有效的final(effectively final),Java9允许将资源变量放在try后的圆括号内。上面程序在Java9中可改写为如下形式:
提示:
Closeable接口是AutoCloseable的子接口,可以被自动关闭的资源类要么实现AutoCloseable接口,要么实现Closeable接口。Closeable接口里的Close()方法声明跑出来IOException,因此它的实现类在实现close()方法时只能声明抛出IOException或其子类;AutoCloseable接口里的close()方法声明跑出来Exception,因此它的实现类在实现close()方法时可以声明抛出任何异常。
方法重写时声明抛出异常的限制
子类方法声明抛出的异常类型应该是父类方法声明抛出的异常类型的子类或相同,子类方法声明抛出的异常不允许比父类方法声明抛出的异常多。
使用Checked异常至少存在如下两大不变之处:
- 对于程序中的Checked异常,Java要求必须显式捕获并处理该异常,或者显式声明抛出该异常。这样就增加了编程复杂度。
- 如果在方法中显式声明抛出Checked异常,将会导致方法签名与异常耦合,如果该方法是重写父类的方法,则该方法抛出的异常还会受到被重写方法所抛出异常的限制。
在大部分时候推荐使用Runtime异常,而不使用Checked异常。尤其当程序需要自行抛出异常时,使用Runtime异常将更加简洁。
当使用Runtime异常时,程序无须在方法中声明抛出Checked异常,一旦发生自定义错误,程序只管抛出Runtime异常即可。
如果程序需要在合适的地方捕获异常并对异常进行处理,则一样可以使用try...catch块来捕获Runtime异常。
使用Runtime异常是比较省事的方式,使用这种方式既可以享受“正常代码和错误处理分离”,“保证程序具有较好的健壮性”的优势,又可以避免因为使用Checked异常带来的编程繁琐性。
但Checked异常也有其优势——Checked异常能在编译时提醒程序员代码可能存在的问题,提醒程序员必须注意处理该异常,或者声明该异常由该方法调用者来处理,从而避免程序员因为粗心而忘记处理异常的错误。
使用throw抛出异常
异常是一种很“主观”的说法,以下雨为例,假设大家约好明天去爬山郊游,如果第二天下雨了,这种情况会打破既定计划,就属于一种异常;但对于正在企盼天降甘露的农民而言,如果第二天下雨了,他们正好随雨追肥,这就完全正常。
很多时候,系统是否要抛出异常,可能需要根据应用的业务需求来决定,如果程序中的数据、执行与既定的业务需求不服,这就是一种异常。由于与业务需求不符而产生的异常,必须由程序员来决定抛出,系统无法抛出这种异常。
如果需要在程序中自行抛出异常,则应使用throw语句,throw语句可以单独使用,throw语句抛出的不是异常类,而是一个异常实例,而且每次只能抛出一个异常实例。throw语句的语法格式如下:
throw ExceptionInstance;
如果throw语句抛出的异常时Cheked异常,则该throw语句要么处于try块里,显式捕获该异常,要么放在一个带throws声明抛出的方法中,即把该异常交给该方法的调用者处理;如果throw语句抛出的异常是Runtime异常,则该语句无须放在try块里,也无须放在带throws声明抛出的方法中;程序既可以显式使用try...catch来捕获并处理该异常,也可以完全不理会该异常,把该异常交给该方法调用者处理。
异常处理规则
成功的异常处理应该实现如下4个目标:
- 使程序代码混轮最小化。
- 捕获并保留诊断信息。
- 通知合适的人员。
- 采用合适的方式结束异常活动。
不可过度使用异常
不可否认,Java的异常机制缺失方便,但滥用异常机制也会带来一些负面影响。过度使用异常主要有两个方面:
- 把异常和普通错误混淆在一起,不再编写任何错误处理代码,而是以简单地抛出异常来代替所有的错误处理。
- 使用异常处理来代替流程控制。
不要使用过于庞大的try块
正确的做法是,把大块的try块分割成多个可能出现异常的程序段落,并把他们放在单独的try块中,从而分别捕获并处理异常。
避免使用Catch All语句
所谓Catch All语句指的是一种异常捕获模块,它可以处理程序发生的所有可能异常。例如,如下代码片段:
try{
//可能引发Checked异常的代码
}catch(Throwable t){
//进行异常处理
t.printStackTrace();
}
不可否认,每个程序员都曾经用过这种异常处理方式;但在编写程序时就应避免使用这种异常处理方式。这种处理方式有如下两点不足之处:
- 所有的异常都采用相同的处理方式,这将导致无法对不同的异常分情况处理,如果要分情况处理,则需要在catch块中使用分支语句进行控制,这时得不偿失的做法。
- 这种捕获方式可能将程序中的错误、Runtime异常等可能导致程序终止的情况全部捕获到,从而“压制”了异常。如果出现了一些“关键”异常,那么此异常也会被“静悄悄”地忽略。
实际上,Catch All语句不过是一种通过避免错误处理而加快进度的机制,应尽量避免在实际应用中使用这种语句。
不要忽略捕获到的异常
不要忽略异常!既然已经捕获到异常,那catch块理应做些有用的事情——处理并修复这个错误。
- 处理异常。总之,Checked异常,程序应该尽量修复。
- 重新抛出异常。把当前运行环境下能做的事情尽量做完,然后进行异常转译,把异常包装成当前层的异常,重新抛出给上层调用者。
- 在合适的层处理异常。