泛型
本章思维导图
泛型入门
Java集合有个缺点——把一个对象“丢进”集合里之后,集合就会“忘记”这个对象的数据类型,当再次取出该对象时,该对象的编译类型就变成了Object类型(其运行时类型没变)。
编译时不检查类型的异常
下面代码将会看到编译时不检查类型所导致的异常。
import java.util.ArrayList;
import java.util.List;
public class ListErr {
public static void main(String[] args) {
// 创建一个只想保存字符串的List集合
List strList = new ArrayList();
strList.add("十年寒窗无人问");
strList.add("纵使相逢应不识");
// "不小心"把一个Integer对象"丢进"了集合
strList.add(5);
strList.forEach(str -> System.out.println(((String) str).length()));
}
}
上面程序创建了一个List集合,而且只希望该List集合保存字符串对象——但程序不能进行任何限制,上面程序将引发ClassCastException异常。
使用泛型
从Java5以后,Java引入了参数化类型(parameterized type)的概念,允许程序在创建集合时指定集合元素的类型。Java参数化类型被称为泛型(Generic)。
创建这种特殊集合的方法是:在集合接口 、类后增加尖括号,尖括号里放一个数据类型,即表明这个集合接口、集合类只能保存特定类型的对象。从而使集合自动记住所有集合元素的数据类型,从而无须对集合元素进行强制类型转换。
Java9增强的”菱形“语法
在Java7以前,如果使用带泛型的接口、类定义变量,那么调用构造器创建对象时构造器的后面也必须带泛型,这显得有些多余了。例如如下两条语句。
List strList = new ArrayList();
Map scores = new HashMap();
上面两条语句中的构造器后面的尖括号部分完全是多余的,在Java7以前这是必需的,不能省略。从Java7开始,Java允许在构造器后不需带完整的泛型信息,只要给出一对尖括号(<>)即可,Java可以推断尖括号里应该是什么泛型信息。上面两条代码可以改写为如下形式。
List strList = new ArrayList<>();
Map scores = new HashMap<>();
Java9再次增强了”菱形“语法,它甚至允许在创建匿名内部类时使用菱形语法,Java可根据上下文来推断匿名内部类中泛型的类型。下面代码示范了在匿名内部类中使用菱形语法。
interface Foo {
void test(T t);
}
public class AnnoymousDiamond {
public static void main(String[] args) {
// 指定Foo类中泛型为String
Foo f = new Foo<>() {
// test()方法的参数类型为String
public void test(String t) {
System.out.println("test方法的t参数为: " + t);
}
};
// 使用泛型通配符,此时相当于通配符的上限为Object
Foo> fo = new Foo<>() {
// tes()方法的参数类型为Object
public void test(Object t) {
System.out.println("test方法的Object参数为: " + t);
}
};
// 使用泛型通配符,通配符的上限为Number
Foo extends Number> fn = new Foo<>() {
// 此时test()方法的参数类型为Number
public void test(Number t) {
System.out.println("test方法的Number参数为: " + t);
}
};
}
}
上面的代码定义了带泛型声明的接口。
深入泛型
所谓泛型,就是允许在定义类、接口、方法时使用类型的形参,这个类型形参(或叫泛型)将在声明变量、创建对象、调用方法时动态地指定(即传入实际的类型参数,也可称为类型实参)。Java5改写了集合框架中的全部接口,为这些接口、类增加了泛型支持,从而可以在声明集合变量、创建集合对象时传入类型实参。
定义泛型接口、类
下面是Java5改写后List接口、Iterator接口、Map的代码片段。
// 定义接口时制定了一个泛型形参,该形参名为E
public interface List {
// 在该接口里,E可作为类型使用
// 下面方法可以使用E作为参数类型
void add(E x);
Iterator iterator();
...
}
// 定义接口时指定了一个泛型形参,该形参名为E
public interface Iterator {
// 在该接口里E完全可作为类型使用
E next();
boolean hasNext();
...
}
// 定义接口时指定了一个泛型形参,该形参名为E
public interface Map {
// 在该接口里K、V完全可作为类型使用
Set keySet();
V put(K key, V value);
...
}
允许在定义接口、类时声明泛型形参,泛型形参在整个接口、类体内可当成类型使用,几乎所有可使用普通类型的地方都可以使用这种泛型形参。
可以为任何类、接口增加泛型声明,并不是只有集合类才可以使用泛型声明。
从泛型类派生子类
当创建了带泛型声明的接口、父类之后,可以为该接口创建实现类,或从该父类派生子类,当使用这些接口、父类时不能再包含泛型形参。
方法中的形参代表变量、常量、表达式等数据。定义方法时可以声明数据形参,调用方法时必须为这些数据形参传入实际的数据;与此类似的是,定义类、接口、方法时可以声明泛型形参,使用类、接口、方法时应该为泛型形参传入实际的类型。
如果想从Apple类派生一个子类,如下代码。
// 使用Apple类时,为T形参传入String类型
public class A extends Apple
调用方法时必须为所有的数据形参传入参数值,与调用方法不同的是,使用类、接口时也可以不为泛型形参传入实际的类型参数,下面代码也是正确的。
// 使用Apple类时,没有为T形参传入实际的类型参数
public class A extends Apple
像这种使用Apple类时省略泛型的形式被称为原始类型(raw type)。
如果使用原始类型的形式继承父类,Java编译器可能发出警告:使用了未经检查或不安全的操作——这就是泛型检查的警告,如果希望看到该警告提示的更详细信息,则可以通过为javac命令增加-Xlint:unchecked
选项来实现。
并不存在泛型类
当一个类使用了泛型,系统并没有为该类生成新的class文件,而且也不会将该类当成新类来处理。不管泛型的实际类型参数是什么,它们在运行时总有同样的类。
不管泛型形参传入哪一种类型实参,对于Java来说,它们依然被当成同一个类处理,在内存中也只占用一块,因此在静态方法、静态初始化块或者静态变量的声明和初始化中不允许使用泛型形参。下面程序演示了这种错误。
public class R {
// 下面代码错误,不能在静态变量声明中使用泛型形参
static T info;
T age;
public void foo(T msg) {}
// 下面代码错误,不能在静态方法声明中使用泛型形参
public static void bar(T msg) {}
由于系统中并不会真正生成泛型类,所以instanceof运算符后面不能使用泛型类。下面代码是错误的。
Collection cs = new ArrayList<>();
// 下面代码编译时引发错误:instanceof运算符后不能使用泛型
if (cs instanceof ArrayList) {
...
}
类型通配符
当使用一个泛型类时(包括声明变量和创建对象两种情况),都应该为这个泛型类传入一个类型实参。如果没有传入类型实际参数,编译器就会提出泛型警告。
如果Foo是Bar的一个子类型(子类或者子接口),而G是具有泛型声明的类或接口,G
在数组中,程序可以直接把一个Integer[]数组赋给一个Number[]变量。如果试图把一个Double对象保存到该Number[]数组中,编译可以通过,但在运行时抛出ArrayStoreException异常。
Java在泛型设计时进行了改进,不再允许把List
Java泛型设计原则是,只要代码在编译时没有出现警告,就不会遇到运行时ClassCastException异常。
数组和泛型有所不同,假设Foo是Bar的一个子类型(子类或者子接口),那么Foo[]依然是Bar[]的子类型;但G
使用类型通配符
为了表示各种泛型List的父类,可以使用类型通配符,类型通配符是一个问号(?),将一个问号作为类型实参传给List集合,写作:List>(意思是元素类型未知的List)。这个问号(?)被称为通配符,它的元素类型可以匹配任何类型。例如代码。
public void test(List> c) {
for (int i = 0; i < c.size(); i++) {
System.out.println(c.get(i));
}
}
现在使用任何类型的List来调用它,程序依然可以访问集合c中的元素,其类型是Object,这永远是安全的,因为不管List的真实类型是什么,它包含的都是Object。
但这种带通配符的List仅表示它是各种泛型List的父类,并不能把元素加入到其中。例如下代码,将会引起编译错误。
List> c = new ArrayList();
// 下面代码引起编译错误
c.add(new Object());
因为程序无法确定c集合中的元素类型,所以不能向其中添加对象。
程序可以调用get()
方法来返回List>集合指定索引处的元素,其返回值是一个未知类型,但可以肯定,它总是一个Object。
设定类型通配符的上限
为了表示List集合的所有元素是一个类F的子类,Java泛型提供了被限制的泛型通配符。被限制的泛型通配符表示如下:
// 它表示泛型形参必须是F子类的List
List extends F>
List extends F>
是受限制通配符的例子,此处的问号(?)代表一个未知类型,但是一定是F类的子类型(也可以是F本身),因此可以把F称为这个通配符的上限(upper bound)。
类似地,由于程序无法确定这个受限制的通配符的具体类型,所以不能把F对象或其子类的对象加入这个泛型集合中。例如下代码是错误的。
public void addFs(List extends F> fs) {
// 下面代码引起编译错误
fs.add(0, new S());
}
这种指定通配符上限的集合,只能从集合中取元素(取出的元素总是上限的类型),不能向集合中添加元素(因为编译器没法确定集合元素实际是哪种子类型)。
对于更广泛的泛型来说,指定通配符上限就是为了支持类型型变。比如Foo是Bar的子类,这样A
对于协变的泛型类来说,它只能调用泛型类型作为返回值类型的方法(编译器会将该方法返回值当成通配符上限的类型);而不能调用泛型类型作为参数的方法。口诀是:协变只出不进。
对于指定通配符上限的类型类,相当于通配符上限是Object。
设定类型通配符的下限
通配符下限用 super 类型>
的方式来指定,通配符下限的作用与通配符上限的作用恰好相反。
指定通配符的下限就是为了支持类型型变。比如Foo是Bar的子类,当程序需要一个A super Bar>变量时,程序可将A
对于逆变的泛型集合来说,编译器只知道集合元素是下限的父类型,但具体是哪种父类型则不确定。因此,这种逆变的泛型集合能向其中添加元素(因为实际赋值的集合元素总是逆变声明的父类),从集合中取元素时只能被当成Object类型处理(编译器无法确定取出的到底是哪个父类的对象)。
设定泛型形参的上限
Java泛型不仅允许在使用通配符形参时设定上限,而且可以在定义泛型形参时设定上限,用于表示传给该泛型形参的实际类型要么是该上限类型,要么是该上限类型的子类。
public class Apple {
T col;
public static void main(String[] args) {
Apple ai = new Apple<>();
Apple ad = new Apple<>();
// 下面代码将引发异常 下面代码试图把String类型传给T形参
// 但String不是Number的子类型 所以引起编译错误
Apple as = new Apple<>();
}
}
上面代码定义了一个Apple泛型类,该Apple类的泛型形参的上限是Number类,这表明Apple类是为T形参传入的实际类型只能是Number类或Number类的子类。
在一种更极端的情况下,程序需要为泛型形参设定多个上限(至多一个父类上限,可以有多个接口上限),表明该泛型形参必须是其父类的子类(父类本身也行),并且实现多个上限接口。
// 表明T类型必须是Number类或其子类,并必须实现java.io.Serializable接口
public class Apple {
...
}
与类同时继承父类、实现接口类似的是,为泛型形参指定多个上限时,所以的接口上限必须位于类上限之后(类上限位于第一位)。
泛型方法
定义泛型方法
Java5提供的泛型方法(Generic Method),在声明方法时定义一个或多个泛型形参。泛型方法的语法格式如下。
修饰符 返回值类型 方法名(形参列表) {
// 方法体...
}
泛型形参声明以尖括号括起来,多个泛型形参之间以逗号(,)隔开,所有的泛型形参声明放在方法修饰符和方法返回值类型之间。
与接口、类声明中定义的泛型不同的是,方法声明中定义的泛型只能在该方法里使用,而接口、类声明中定义的泛型则可以在整个接口、类中使用。
与类、接口中使用泛型参数不同的是,方法的泛型参数无须显示传入实际类型参数,系统可以知道为泛型实际传入的类型,因为编译器根据实参推断出泛型所代表的类型,它通常推断出最直接的类型。
为了让编译器能准确地推断出泛型方法中泛型的类型,不要制作迷惑。如下程序。
public class ErrorTest {
// 声明一个泛型方法,该泛型方法中带一个T泛型形参
static void test(Collection from, Collection to) {
for (T ele : from) {
to.add(ele);
}
}
public static void main(String[] args) {
List
该方法中的两个形参from、to的类型都是Collection
,这要求调用该方法时的两个集合实参中的泛型类型相同,否则编译器无法准确地推断出泛型方法中泛型形参的类型。。
可将代码改成如下。
public class RightTest {
// 声明一个泛型方法,该泛型方法中带一个T泛型形参
static void test(Collection extends T> from, Collection to) {
for (T ele : from) {
to.add(ele);
}
}
public static void main(String[] args) {
List
上面代码改变了test()
方法签名,将该方法的前一个形参类型改为Collection extends T>
,这种采用类型通配符的表示方式,只要test()
方法的前一个Collection集合里的元素类型是后一个Collection集合里元素类型的子类即可。
泛型方法和类型通配符的区别
大多数的时候都可以使用泛型方法来代替类型通配符。例如,对于Java的Colletion接口中两个方法定义:
public interface Collection {
boolean containsAll(Collection> c);
boolean addAll(Colletion extends E> c);
...
}
上面集合中两个方法的形参都采用了类型通配符的形式,也可以采用泛型方法的形式,如下所示。
public interface Collection {
boolean containsAll(Collection c);
boolean addAll(Collection c);
...
}
上面方法使用了
泛型形式,这时定义泛型形参时设定上限。
上面两个方法中泛型形参T只使用了一次,泛型形参T产生的唯一效果是可以在不同的调用点传入不同的实际类型。对于这种情况,应该使用通配符:通配符就是被设计用来支持灵活的子类化的。
泛型方法允许泛型形参被用来表示方法的一个或多个参数之间的类型依赖,或者方法返回值与参数之间的类型依赖关系。如果没有这样的类型依赖关系,就不应该使用泛型方法。
如果某个方法中一个形参(a)的类型或返回值的类型依赖于另一个形参(b)的类型,则形参(b)的类型声明不应该使用通配符——因为形参(a)或返回值的类型依赖于该形参(b)的类型,如果形参(b)的类型无法确定,程序就无法定义形参(a)的类型。在这种情况下,只能考虑使用在方法签名中声明泛型——也就是泛型方法。
如果有需要,也可以同时使用泛型方法和通配符,如Java的Colletions.copy()
方法。
public class Colletions {
public static void copy(List dest, List extends T> src) {
...
}
}
上面的copy方法中的dest和src存在明显的依赖关系,从源List中复制出来的元素,必须可以存放在目标List中,所以源List集合元素的类型只能是目标集合元素的类型的子类型或者它本身。但JDK定义src形参类型时使用的是类型匹配符,而不是泛型方法。这是因为:该方法无须向src集合中添加元素,也无须修改src集合里的元素,所以可以使用类型通配符,无须使用泛型方法。
简而言之,指定上限的类型通配符支持协变,因此这种协变的集合可以安全地取出元素(协变只出不进),因此无须使用泛型方法。
当然,也可以将上面的方法签名改为使用泛型方法,不使用类型通配符,如下所示。
class Collections {
public static void copy(List dest, List src) {
...
}
}
这个方法签名可以代替前面的方法签名。但注意上面的泛型形参S,它仅使用了一次,其他参数的类型、方法返回值类型都不依赖与它,那泛型形参S就没有存在的必要,即可以用通配符来代替S。使用通配符比使用泛型方法(在方法签名中显式声明泛型形参)更加清晰和准确,因此Java设计该方法时采用了通配符,而不是泛型方法。
类型通配符和泛型方法还有一个显著的区别:类型通配符既可以在方法签名中定义形参的类型,也可以用于定义变量的类型;但泛型方法中的泛型形参必须在对应方法显式声明。
Java7的菱形语法与泛型构造器
Java也允许在构造器签名中声明泛型形参。一旦定义了泛型构造器,在调用构造器时,不仅可以让Java根据数据参数的类型来推断泛型形参的类型,而且也可以显式地为构造器中的泛型形参指定实际的类型。如下程序。
class Foo {
public Foo(T t) {
System.out.println(t);
}
}
public class GenericConstructor {
public static void main(String[] args) {
// 泛型构造器中的T类型为String
new Foo("好好学习");
// 泛型构造器中的T类型为Integer
new Foo(1024);
// 显式指定泛型构造器中T类型为String
// 传给Foo构造器的实参也是String对象 完全正确
new Foo("天天向上");
// 显式指定泛型构造器中T类型为String
// 传给Foo构造器的实参是Double对象 下面代码出错
new Foo(3.14);
}
}
Java7新增的菱形语法,允许调用构造器时在构造器后使用一对尖括号来代表泛型信息。但如果程序显式指定了泛型构造器中声明的泛型形参的实际类型,则不可以使用菱形语法。如下程序所示。
class MyClass {
public MyClass(T t) {
System.out.println("t参数值为: " + t);
}
}
public class GenericDiamondTest {
public static void main(String[] args) {
// MyClass类声明中的E形参是String类型
// 泛型构造器中声明的T形参是Integer类型
MyClass mc1 = new MyClass<>(9);
// 显式指定泛型构造器中声明的T形参是Integer类型
MyClass mc2 = new MyClass(9);
// MyClass类声明中E形参是String类型
// 如果显式指定泛型构造器中声明的T形参是Integer类型
// 此时就不能使用菱形语法 下面代码是错的
MyClass mc3 = new MyClass<>(9);
}
}
泛型方法与方法重载
因为泛型即允许设定通配符上限,也允许设定通配符的下限,从而允许在一个类里包含如下两个方法定义。
public class MyUtils {
public static void copy(Collection dest, Collection extends T> src) {
...
}
public static T copy(Collection super T> dest, Collection src) {
...
}
}
这两个方法参数都是Collection对象,前一个集合里的集合元素类型是后一个集合里集合元素类型的父类。如果只是在该类中定义这两个方法不会有任何错误,但只要调用这个方法就会引起编译错误。例如下代码。
List ln = new ArrayList<>();
List li = new ArrayList<>();
copy(ln, li);
调用copy()
方法既可以匹配第一个copy方法也可以匹配第二个copy方法,编译器无法确定想调用哪个copy方法,所以引起编译错误。
Java8改进的类型推断
Java8改进了泛型方法的类型推断能力,类型推断主要有如下两方面。
- 可通过调用方法的上下文来推断泛型的目标类型。
- 可在方法调用链中,将推断得到的泛型传递到最后一个方法。
class MyUtil {
public static MyUtil nil() {
return null;
}
public static MyUtil cons(Z head, MyUtil tail) {
return null;
}
E head() {
return null;
}
}
public class InferenceTest {
public static void main(String[] args) {
// 可以通过方法赋值的目标参数来推断泛型为String
MyUtil ls = MyUtil.nil();
// 无须使用下面语句在调用nil()方法时指定泛型的类型
MyUtil mu = MyUtil.nil();
// 可调用cons()方法所需的参数类型来推断泛型为Integer
MyUtil.cons(42, MyUtil.nil());
// 无须使用下面语句在调用nil()方法时指定泛型的类型
MyUtil.cons(42, MyUtil.nil());
}
}
前两个调用nil()
类方法作用完全相同,第一个无须在调用nil()
方法时显式指定泛型参数为String,这是因为程序需要将该方法返回值赋值给MyUtil
类型,因此系统可以自动推断出此处的泛型参数为String类型。
后两个调用cons()
方法作用也完全相同,第一个无须再调用cons()
方法时显式指定泛型参数为Integer,这是因为程序将nil()
方法返回值作为了cons()
方法的第二个参数,而程序可根据cons()
方法的第一个参数(42)推断出此处的泛型参数为Integer类型。
虽然Java8增强了泛型推断的能力,但泛型推断不是万能的,如下代码就是错误的。
// 希望系统能推断出调用nil()方法时泛型为String类型
// 但实际上Java8依然推断不出来,所以下面代码报错
String s = MyUtil.nil().head();
因此,上面这行代码必须显式指定泛型的实际类型,即将代码改为如下形式:
String s = MyUtil.nil().head();
擦除和转换
允许再使用带泛型声明的类时不指定实际的类型。如果没有为这个泛型类指定实际的类型,此时被称作raw type(原始类型),默认是声明该泛型形参时指定的第一个上限类型。
当把一个具有泛型信息的对象赋给另一个没有泛型信息的变量时,所有在尖括号之间的类型信息都将被扔掉。比如一个List
类型被转换为List,则该List对集合元素的类型检查变成了泛型参数的上限(即Object)。下面程序示范了这种擦除。
class Milk {
T size;
public Milk() {}
public Milk(T size) {
this.size = size;
}
public T getSize() {
return size;
}
public void setSize(T size) {
this.size = size;
}
}
public class ErasureTest {
public static void main(String[] args) {
Milk a = new Milk<>(6);
// a的getSize()方法返回Integer
Integer as = a.getSize();
// 把a对象赋给Milk变量,丢失尖括号里的类型信息
Milk b = a;
// b只知道size的类型是Number类
Number size1 = b.getSize();
// 下面代码引起编译错误
Integer size2 = b.getSize();
}
}
当把a赋给一个不带泛型信息的b变量时,编译器就会丢失a对象的泛型信息,即所有尖括号里的信息都会丢失——因为Milk的泛型形参上限是Number类,所以编译器依然知道b的getSize()
方法返回Number类型,但具体是Number的哪个子类就不清楚了。
从逻辑上看,List
是List的子类,如果直接把一个List对象赋给一个List
对象应该引起编译错误,但实际上不会。对泛型而言,可以直接把一个List对象赋给一个List
对象,编译器仅仅提示“未检查的转换”,如下程序。
public class ErasureTest2 {
public static void main(String[] args) {
List li = new ArrayList<>();
li.add(6);
li.add(9);
List list = li;
// 下面代码引起“为经检查的转换”警告,编译、运行时完全正常
List ls = list;
// 但只要访问ls里的元素,如下面代码将引起运行时异常
System.out.println(ls.get(0));
}
}
当把这个List
对象赋给一个List类型后,编译器就会丢失前者的泛型信息,即丢失集合里元素的类型信息,这就是典型的擦除。当试图把该集合里的元素当成String类型对象取出时,将引发ClassCastException异常。
泛型与数组
Java泛型有一个很重要的设计原则——如果一段代码在编译时没有提出“[unchecked]未经检查的转换”警告,则程序在运行时不会引发ClassCastException异常。正是基于这个原因,所以数组元素的类型不能包含泛型变量或泛型形参,除非是无上限的类型通配符。但可以声明元素类型包含泛型变量或泛型形参的数组。也就是说,只能声明List
形式的数组,但不能创建ArrayList
这样的数组对象。
加入Java支持创建ArrayList
// 下面代码实际上是不允许的
List[] lsa = new ArrayList[10];
// 将lsa向上转型位Object[]类型的变量
Object[] oa = lsa;
List li = new ArrayList<>();
li.add(3);
// 将List对象作为oa的第二个元素
// 下面代码没有任何警告
oa[1] = li;
// 下面代码也不会有任何警告,但将引发ClassCastException异常
String s = lsa[1].get(0);
如果第一行代码是合法的,势必在最后一行引发运行时异常,这就违背了Java泛型的设计原则。
如果将程序改成如下形式:
// 下面代码编译时有“[unchecked] 未经检查的转换”警告
List[] lsa = new ArrayList[10];
// 将lsa向上转型位Object[]类型的变量
Object[] oa = lsa;
List li = new ArrayList<>();
li.add(3);
oa[1] = li;
// 下面代码引起ClassCastException异常
String s = lsa[1].get(0);
不允许创建List
类型的对象,但可以创建一个类型为ArrayList[10]
的数组对象。只是在第一行会有编译警告。
Java允许创建无上限的通配符泛型数组,例如new ArrayList>[10]
,在这种情况下,程序不得不进行强制类型转换。在进行强制类型转换之前应通过instanceof
运算符来保证它的数据类型。如下代码。
List>[] lsa = new ArrayList>[10];
Object[] oa = lsa;
List li = new ArrayList<>();
li.add(3);
oa[1] = li;
Object target = lsa[1].get(0);
if (target instanceof String) {
// 下面代码安全
String s = (String)target;
}
与此类似的是,创建元素类型是泛型类型的数组对象也将导致编译错误。如下所示。
T[] makeArray(Collection coll) {
// 下面代码导致编译错误
return new T[coll.size()];
}
由于类型变量在运行时并不存在,而编译器无法确定实际类型是什么,因此编译器报错。