1 泛型基础
1.1 什么是泛型(Generics)
- 官方是这样介绍的:
JDK 5.0 introduces several new extensions to the Java programming language. One of these is the introduction of generics.
Introduced in J2SE 5.0, this long-awaited enhancement to the type system allows a type or method to operate on objects of various types while >providing compile-time type safety. It adds compile-time type safety to the Collections Framework and eliminates the drudgery of casting.
关键字:
- type : 类型
- compile-time : 编译期(敲黑板)
- Collections Framework: 多用于集合框架
简而言之,泛型(也有翻译成通用类型的)就是JDK 5.0引入的一项新特性。这个新特性增强了程序在编译时期对类型安全性的检查。尤其是在集合框架方面。
1.2 泛型的使用
我相信几乎大部分人对于新技术栈的第一需求是想知道的是如何使用,大概有哪些功能,而不是想去了解它有什么优点,以及一大堆原则和概念。而我个人觉得学习也应该这样,首先要去学会咋用,在用的过程中遇到了实际问题,再去探索原理,进而明白为什么会遇见这样的问题,从而达到循序渐进逐渐深入的学习过程。所以开门见山,这篇文章首先会告诉你如何开始使用泛型
泛型的使用主要为两个方面,官方文档将其分为通用类型(Generic Type)和通用方法(Generic method)。
通用类型很多人又习惯将其再分为通用接口和通用类,这也就是为什么网上一搜泛型,就会有一大堆泛型接口,泛型类,泛型方法使用大全,balabala之类的博文。而我个人觉得,由于Java多态的存在,官方将泛型接口,泛型类统归为通用类型更为合适。
好的,不多BB直接上例子
1.2.1 通用类型和通用方法声明
- 通用类型使用语法如下:
// 泛型接口
interface GenericsInterface {
T getT();
}
// 泛型类
puclic class GenericsClass {
private T t;
// 注意 这不是泛型方法
T getT() {
// ...
}
}
- 通用方法使用语法如下:
public class Util {
// 这个是通用方法...
public static boolean compare(Pair p1, Pair p2) {
return p1.getKey().equals(p2.getKey()) && p1.getValue().equals(p2.getValue());
}
}
public class Pair {
private K key;
private V value;
public Pair(K key, V value) {
this.key = key;
this.value = value;
}
public void setKey(K key) { this.key = key; }
public void setValue(V value) { this.value = value; }
public K getKey() { return key; }
public V getValue() { return value; }
}
可以看出,在普通的类型或者方法的合适位置加上<类型参数>,比如上边栗子中的<>
称之为钻石符(The Diamond)(这里不是很严谨,甚至说有点问题,后边会说明,但不会影响你的理解),将T
称之为类型变量(type parameters)
注意点:
- 必须要使用<类型参数>声明。所以上边泛型接口GenericsInterface
中的getT()方法不是泛型方法 - 对于通用类型,声明位置在类型名的后边,对于通用方法声明位置在返回值前
是不是很简单?
到这里,你肯定还有一个疑问,什么是类型参数,钻石符中的T
是什么。其实,官方将钻石符中间写的参数即上文的T
就称作类型参数,也称类型变量(官方称之为: type parameter)。当然紧接着,你肯定又会问,那就只能用T
吗,只能写一个吗?我们接着往下看
1.2.2 类型参数命名约定
按照约定,类型参数名称是单个大写字母。这与你已经知道的变量命名约定形成鲜明对比,并且有充分的理由:没有该约定,将很难分辨类型变量与普通类或接口名称之间的区别。当有多个类型参数时,用",
"分隔, 同你声明方法时,分隔入参的语法一致。
最常用的类型参数名称是:
- E - Element (Java Collections Framework广泛使用)
- K - Key
- N - Number
- T - Type
- V - Value
- S,U,V etc. - 2nd, 3rd, 4th types
当然,如果你想,26个英文字母或是单词儿随便用,前提没人打你~~
1.2.3 泛型的调用
显而易见,想知道如何使用泛型,光知道如何声明可不够,肯定还需要知道如何调用,接下来就来看一下,泛型需要如何调用。具体调用语法如下:
- 通用方法的调用
我们以上文的Utils中compare方法为例,则有:
Pair p1 = new Pair(1, "apple");
Pair p2 = new Pair(2, "pear");
boolean same = Util.compare(p1, p2);
- 通用类型对象的创建
List intList = new ArrayList();
List strList = new ArrayList();
GenericsClass genericsClass = new GenericsClass();
可见
相对于普通类型对象,创建类型对象时有如下变化
- 声明引用时,在类型后用
<类型变量>
的方式指明传入的类型参数类型- 实例化对象时,在对象后用
<类型变量>
的方式指明这是一个什么类型的泛型对象- 类型变量可以是你指定的任何非基本类型:任何类类型,任何接口类型,任何数组类型,甚至是另一个类型变量
这时可能会有人有疑问,那下边这些算什么呢?
List intList = new ArrayList();
List strList = new ArrayList();
GenericsClass genericsClass = new GenericsClass();
List objectList = new ArrayList();
这时,我们需要引入一个新的概念->原始类型(raw type
)
1.2.4 原始类型(raw type)和 Unchecked Error Messages(未检查的错误消息)
1.2.4.1 原始类型(raw type)
对于原始类型,官方是这么定义的
A raw type is the name of a generic class or interface without any type arguments
通俗点讲,就是我们在声明一个泛型类或者泛型接口时未传入类型参数
用代码来表现就是上边的:
GenericsClass genericsClass = new GenericsClass();
List objectList = new ArrayList();
敲黑板:注意这里用到词是arguments(实参)而不是parameter(形参),也从侧面强调了,创建一个泛型类型时,钻石符中写的是parameter(形参),声明了当前泛型类型所接受的类型参数,而实例化一个泛型对象时,是需要传入具体类型的,例如String,Interger等等......也就是arguments(实参)
这也就很好理解,为什么官方文档中在Why Use Generics?这一引言中写到
In a nutshell, generics enable types (classes and interfaces) to be parameters when defining classes, interfaces and methods. Much like the more familiar formal parameters used in method declarations, type parameters provide a way for you to re-use the same code with different inputs. The difference is that the inputs to formal parameters are values, while the inputs to type parameters are types.
好吧,我知道有人懒,翻译一下就是(我也是google的2333):
简而言之,泛型在定义类,接口和方法时使类型(类和接口)成为参数。 与方法声明中使用的更熟悉的形式参数非常相似,类型参数为你提供了一种使用不同输入重复使用相同代码的方法。 区别在于形式参数的输入是值,而类型参数的输入是类型。
回到正题,此时则有
1.我们称GenericsClass
和List
为GenericsClass
和List
的原始类型
2.原始类型是一个相对概念,即GenericsClass这个类本身如果不是一个泛型类,那么它也不再会有泛型和原始类型之分
1.2.4.2 Unchecked Error Messages(未检查的错误消息)
相信,很多人都在IDE中遇见过相关错误提示,但是究竟是为什么呢?我又为什么将它和原始类型放在同一章节下呢?
因为在JDK 5.0之前是没有泛型特性的,许多API类(例如 Collections 类)不是通用的。为了向后兼容,JDK允许将参数化类型分配给其原始类型,即。
GenericsClass genericsClass = new GenericsClass();
但是当将一个原始类型分配给参数化类型时,你将会受到IDE的Unchecked assignment
编译警告
GenericsClass genericsClass = new GenericsClass(); // Unchecked assignment
当你使用原始类型调用参数化类型的相关方法时,也会收到IDE的Unchecked call 方法名()的警告
不要问为啥突然贴张图, 只是想告诉你上边的Unchecked assignment
的警告不是我编的(其实懒得写了)。当然还是鼓励自己试试,毕竟实践出真知
如上所述,当将原始类型与泛型类型混用时,在编译时,你则有可能会收到如下警告
Note: Example.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.
当使用旧的API操作原始类型时可能会出现这种情况,如下例所示:
public class WarningDemo {
public static void main(String[] args){
Box bi;
bi = createBox();
}
static Box createBox(){
return new Box();
}
}
术语“未检查”表示编译器没有足够的类型信息来执行确保类型安全所需的所有类型检查。尽管编译器会给出提示,但默认情况下禁用“未检查”警告。要查看所有“未检查”的警告,请使用 -Xlint:unchecked 重新编译。
使用 -Xlint:unchecked 重新编译前面的示例将显示以下附加信息:
WarningDemo.java:4: warning: [unchecked] unchecked conversion
found : Box
required: Box
bi = createBox();
^
1 warning
要完全禁用未检查的警告,请使用 -Xlint:unchecked 标志。 @SuppressWarnings("unchecked") 注解禁止未检查的警告。(引用于官方文档)
1.3 使用泛型的好处
说了这么多,那么使用泛型又有什么好处呢?
简而言之,泛型在定义类,接口和方法时使类型(类和接口)成为参数。与方法声明中使用的更熟悉的形式参数非常相似,类型参数为你提供了一种使用不同输入重复使用相同代码的方法。区别在于形式参数的输入是值,而类型参数的输入是类型。
与非泛型代码相比,使用泛型的代码具有许多优点:
- 在编译时进行更强的类型检查。Java编译器将强类型检查应用于通用代码,如果代码违反类型安全,则会发出错误。修复编译时错误比修复运行时错误容易,后者可能很难找到。
- 消除类型转换。以下不带泛型的代码段需要强制转(最常见的List.add(new Object)的例子网上到处都是,不再赘述)
- 使程序员能够实现通用算法。通过使用泛型,程序员可以实现对不同类型的集合进行工作,可以自定义并且类型安全且易于阅读的泛型算法
2 泛型进阶
2.1 泛型参数的限定
当需要限制可以在参数化类型中用作类型参数的类型的时候,就需要用到限定参数的相关内容了。
如果对数字进行操作的方法可能只希望接受 Number 或其子类的实例。这时我们就可以通过extends
来指定参数上边界。
栗子:
public void inspect(U u){
System.out.println("T: " + t.getClass().getName());
System.out.println("U: " + u.getClass().getName());
}
除了限制可用于实例化泛型类型的类型之外,限定类型参数还允许你调用在范围中定义的方法:
class GenericsClass {
private T t;
private boolean isHasIntent() {
return t.getIntent() == null;
}
}
很明显, Activity的子类当然可以调用父类的getIntent()方法
2.1.1 多重限定
除了简单给出一个限定,一个类型参数还可以有多个限定:
这里的注意点是:
- 如果类型A,B,C中有一个是类比如示例中的A,必须将A排在首位,否则报错。接口只需要排在限定类后,相互之间顺序不做要求
- A,B,C 三个类型中只可以有一个类,但可以有多个接口,因为Java支持单继承,但是可以多实现。
2.1.2 通用算法
我们上文提到了使用泛型的好处之一就是可以实现通用算法,那么具体怎么做呢?其实就是通过泛型的边界限定来做到。
考虑以下方法:
public static int countGreaterThan(T[] anArray, T elem) {
int count = 0;
for (T e : anArray)
if (e > elem) // compiler error
++count;
return count;
}
}
该方法的实现很简单,但是不能编译,因为大于运算符( > )仅适用于基本类型,例如short
、int
、 double
、 long
、 float
、 byte
和 char
。你不能使用 > 运算符比较对象。要解决此问题,请使用 Comparable 接口限定的类型参数:
public interface Comparable {
public int compareTo(T o);
}
最终有:
public static > int countGreaterThan(T[] anArray, T elem) {
int count = 0;
for (T e : anArray)
if (e.compareTo(elem) > 0)
++count;
return count;
}
2.2 泛型的继承和子类型
我们知道,在Java中只要类型兼容,即可将一个对象赋值给另一个对象,例如你可以将任意类的对象分配给Object类型的引用,因为Object是Java中所有类的超类,若我们现在有两个类Apple类和Fruit类,Apple类继承自Fruit类,则可以有如下代码:
Apple apple = new Apple ();
Fruit fruit = new Fruit();
fruit = apple ;
因为apple 之于Fruit 是面向对象中老生常淡的 “IS A”的关系,因为苹果就是一种水果。
同样的对于如下方法:
public void addFruit(Fruit fruit) {}
addFruit(new Apple());
addFruit(new Fruit());
代码可以通过编译并成功运行
但考虑如下方法,还可以正常运行吗?
public void addFruit(List fruitList) {}
public void test() {
List appleList = new ArrayList<>();
appleList.add(new Apple());
addFruit(appleList);
}
编写代码,测试,IDE将提示:
List can not be applied to List。
就如IDE提示所见,addFruit(...)
方法接收List
类型的参数,而List
不可以分配给List
类型,在使用泛型进行编程时,这是一个常见的误解,但它是一个重要的概念。
即List
与List
并没有任何继承关系,他们拥有共同的超类Object。
-
具体关系,见官方贴图:
2.3 类型推断
类型推断是Java编译器查看每个方法调用和相应声明以确定使调用适用的类型参数的能力。推断算法确定参数的类型,以及确定结果是否被分配或返回的类型(如果有)。最后,推断算法尝试找到与所有参数一起使用的最具体的类型。
通俗的说就是:IDE根据方法的声明和调用和返回值,最后确定兼容三者的确切类型。
- 如推断确定传递给 pick 方法的第二个参数的类型为Serializable :
static T pick(T a1, T a2) { return a2; }
Serializable s = pick("d", new ArrayList());
- 不知道在前边泛型的使用时,我们是这么调用泛型方法以及实例化泛型类型的
Pair p1 = new Pair(1, "apple");
Pair p2 = new Pair(2, "pear");
boolean same = Util.compare(p1, p2);
可能会有人有疑问,我记得代码应该是这么写的呀:
Pair p1 = new Pair<>(1, "apple");
Pair p2 = new Pair<>(2, "pear");
boolean same = Util.compare(p1, p2);
这是因为,JDK后续版本引入了类型推断,你可以用一组空的类型参数(<>
)替换调用通用类的构造函数所需的类型参数,只要编译器可以从上下文中推断类型参数即可。这对尖括号被非正式地称为(The Diamond)。也就是说前边的
并不可以被叫做The Diamond。
这里需要注意的是,不要写为:
Pair p2 = new Pair(2, "pear");
理由在前边说过,因为Pair()
构造函数引用的是Pair
原始类型,而非参数化类型即泛型。也就是说在参数化类型(泛型类和接口)上的类型推断,<>
是必不可少的
- 一点点题外话,在编写
Klass
示例时候,试了一下
class Klass {
public Klass(T t) {
}
}
IDE会给出 Type Parameter T hides Type Parameter T
,同时Klass
类名后的T变灰,此时若要实例化Klass的参数化类型
- 若构造参数中传入的类型与声明参数化类型的引用时<>中的类型一致,如:
Klass klass = new Klass(1);
则构造方法后可以使用<>,进行类型推断,即可以直接写为
Klass klass = new Klass();
- 但当二者类型不一致时,如:
Klass klass = new Klass("test");
构造方法类型参数必须传入与Klass
引用声明时传入的类型参数相同的具体类型,比如示例中的Integer
,否则IDE 给出警告"Cannot infer arguments"无法推断实参类型(这里不确定翻译为实参类型是否准确,但从官方文档中得知,在介绍泛型时)
具体原因还不是很清楚,以后明白后回来填坑,当然创建参数化类型及泛型类或接口时,尽量避免这么写,以免造成不必要的困扰
2.4 通配符
2.4.1 上限通配符
对于方法
public static void processNumList(List list) {
for (Foo elem : list) {
// ...
}
}
若想放款参数类型限制,比如现想该参数可以接收List
的参数化类型列表,可以这么做
public static void processNumList(List extends Number> list) {
for (Number elem : list) {
// ...
}
}
此时Number
类中的定义的任何方法都可以在elem
上使用,因为Number
的子类使用其方法也就合情合理。
2.4.2 无限通配符
示例:
public static void printList(List> list) {
for (Object elem: list)
System.out.print(elem + " ");
System.out.println();
}
使用场景:
- 如果你正在编写一个可以使用
Object
类中提供的功能实现的方法。 - 当代码使用通用类中不依赖于类型参数的方法时。例如, List.size 或 List.clear 。事实上,Class 之所以这么经常使用,是因为 Class 中的大部分方法都不依赖于T。
2.4.3 下界通配符
public static void processNumList(List super Integer> list) { }
2.4.4 PECS原则
PECS其实是 Producer ,extends , Consumer ,Super.缩写,那么该如何理解PECS原则呢?
2.4.4.1 首先看PE,即Producer ,extends
假定现在有一个列表List extends Father>
,通过上边的介绍可以知道,此列表中存放的对象都是Father
类子类的实例或者是Father
类本身的实例。
那好,我们再假定Son
继承自Father
,那么下列代码可以成功运行吗?
List extends Father> list = new ArrayList<>();
list.add(new Son());
肯定会有人觉得可以,Son
不是Father
的子类吗?其实不是的,IDE会告诉我们:
Required type: capture of ? extends Father
Provided: Son
用IDE语言解释就是 :类型 Son
≠? extends Father
,即如果我们把类型Son
看做类型A
,把类型? extends Father
看做类型B
,IDE认为A
并不可以被当做B
对待
其实,我个人是这么理解的:
假定
Son2
同Son
一样,也继承了Father
。此时如果Son2
和Son
的实例都允许添加进List extends Father>
,那么这个列表中就会混乱的含有Son2
和Son
的实例,这明显违反了泛型的设计初衷-类型安全。
这也从侧面说明了为什么下面这种使用方法是被允许的,因为我将一个纯净的只含Integer
实例的列表传给了getNumObject
方法,我在取出时,全部按照Numer
的引用类型进行get,即使需要转型,我也可以很方便的完成。当然,要明确知道此时列表存放的都是Integer
类型的实例。
public void test() {
List integerList = new ArrayList<>();
getNumObject(integerList);
}
public void getNumObject(List extends Number> numbers) {
for (Number number : numbers) {
System.out.println(number.intValue());
}
}
就此看来列表List extends Father>是不是很像一个生产者一样(也经常被叫做只读列表),只会单方面产出产品,而不可以消费产品,当然它也不是严格意义上的不能添加任何元素,你还可以对它进行如下操作
- 可以添加
null
- 可以调用
clear
- 可以获取迭代器( iterator )和调用
remove
- 可以捕获通配符和写入从列表中读取的元素
2.4.4.2 CS与Consumer ,Super
介绍完 Producer和extends之后,我们再来看一下Consumer和Super的关系,思考如下代码是否可以正常运行:
List super Integer> intList = new ArrayList<>();
int intValue = intList.get(0);
不管你觉得能与不能,IDE都会给出一下错误提示,
Required type: int
Provided: capture of ? super Integer
即类型? super Integer
不能被看做是int
类型。这里就更好理解了,假设List super Integer>
中第一个元素放的是Number
类的实例,这时intList.get(0)
得到的是一个Numer
实例,明显不可以被看做是int
类型的。因为int
和Number
在JDK中是is-A
的关系,反之明显不成立。而又正是因为is-A
的约束关系,以下代码成立,因为取出来的已经是下边界 Integer
类型,被看做任何的父类型都是说的通的
List super Integer> intList = new ArrayList<>();
intList.add(1);
至此,可以看出
List super Integer>
更像是一个消费者,只消费,不产出,或称之为只写列表。当然同只读列表,只写列表也不是严格意义的只写,你仍然可以在它身上调用
- 可以添加
null
- 可以调用
clear
- 可以获取迭代器( iterator )和调用
remove
- 可以捕获通配符和写入从列表中读取的元素
2.4.4.3 使用场景
利用只读,只写的特性,我们可以定义一个更规范的copy
方法如下:
public void copy(List extends Integer> src, List super Integer> dest) {
for (Integer intValue : src) {
dest.add(intValue);
}
}
2.4.4.4 使用准则
可能有时候,你会在决定使用何种通配符而抓耳挠腮,这时,请参照如下准则:
- 使用上限通配符定义输入变量,使用
extends
关键字。- 使用下限通配符定义输出变量,使用
super
关键字。- 如果可以使用
Object
类中定义的方法访问输入变量,请使用无界通配符(?
)- 如果代码需要同时使用输入和输出变量来访问变量,则不要使用通配符
2.5 泛型擦除
最近没时间,抽空来补完,但是如果你都看到这里了说明你是认真的想把泛型搞懂,那么我建议可以先去别的地方先学习一下泛型擦除,因为这部分知识还是比较重要的