为什么要使用泛型程序设计?
一般的类和方法,只能使用具体的类型:要么是基本类型,要么是自定义类的对应类型;如果要编写可以应用于多种类型的代码,这种刻板的限制对代码的束缚就会很大。
----摘自原书Ordinary classes and methods work with specific types: either
primitives or class types. If you are writing code that might be used
across more types, this rigidity can be overconstraining.
----摘自英文版
从原书的第一段话引出,其实泛型的出现是为了让编写的代码可以应用于多种类型,解除只能使用具体类型的限制,这也就是参数化类型的概念。
泛型出现的契机
泛型是在Java SE5出现的,也就是说java5版本之前的java是不存在泛型的概念的。而Java5这个版本增加了泛型设计其中重要的一个原因就是:优雅的安全的让容器类解除只能使用具体类型的束缚,从而适用于多种类型。
下面以ArrayList为例比较前后差异,证明泛型的优雅和安全:
- java1.4版本
public class ArrayList // 省略继承和实现
{
transient Object[] elementData; // 用于存储ArrayList对象的数组
public Object get(int index) { . . . } // 获取数组对象
public void add(Object o) { . . . } // 添加数组对象
}
- java5版本
public class ArrayList
{
transient Object[] elementData;
public E get(int index) {...}
public boolean add(E e) {...}
从两者对比可以看出,java1.4版本是使用Object类作为对象存取的的出参入参,这样的好处自然是可以让ArrayList类满足编写一次适用于多种类型的代码设计,可是这样就暴露以下几个问题了:
-
- 当获取一个值时必须进行强制类型转换
ArrayList strs = new ArrayList();
strs.add("hello");
String str = (String) strs.get(0);
这里如果不加(String)强制转换,那么代码在编译期就会报错:Incompatible types,并提示files.get(0)返回的是一个Object对象可是接收的是String类型对象,需要做类型强制转换。
-
- 当添加一个值时没有在编译器做类型错误检査
ArrayList files = new ArrayList();
files.add(new File("./hello.text"));
File file = (File)files.get(0); // 正常
String file = (String)files.get(0); // 编译器正常,运行期报错
代码在编译期不会出错,可是在运行期的时候就会报强转错误:java.lang.ClassCastException,这个问题其实也就是使用Object类的弊端了。
既然java1.4出现了上述问题,那么现在就是用java5版本的泛型来解决上述的问题,如下:
-
- 当获取一个值时必须进行强制类型转换(解决)
// 这里第二个<>省略了String,是因为类型推导
ArrayList strs = new ArrayList<>();
strs.add("hello");
String str = strs.get(0);
可以看出java5之后之后就不需要做类型强转了,这是因为get方法的返回类型已经在实例化的时候被
-
- 当添加一个值时没有在编译器做类型错误检査(解决)
ArrayList files = new ArrayList<>();
files.add(new File("./../Fibonacci.java"));
File file = (File)files.get(0); // 正常
String file = (String)files.get(0); // 编译期就报错了
可以看出1.5之后之后就不需要可能到线上才会发现的bug,在编写代码的时候编辑器就给提前给提示:Inconvertible types; cannot cast 'java.io.File' to 'java.lang.String'(禁止File类型转换成String类型了)
虽然泛型解决的问题还有很多,但是总的来说都是为了:更优雅的更安全的让容器类解除只能使用具体类型的束缚,从而适用于多种类型。
泛型的语法&使用范围
下面我们就来正式讲一下泛型的语法,以及使用范围:
泛型接口
-
语法定义:
- 定义泛型:接口名之后定义该类会使用到的所有泛型。
- 引用泛型:除了static方法因不能使用外部实例参数外,其他继承、实现、成员变量,成员方法等都可使用。
- 泛型实参:通过继承类的实例化时传入,不传默认是Object
- 语法结构:
interface 接口名 <定义泛型标识> extends 父接口名 <引用泛型标识> {
public 引用泛型标识 var;
...
}
- 案例 —— 生成器接口:
/**
* 生成器是一种专门负责创建对象的类,是类似工厂模式,不同的是工厂模式一般需要入参,而生成器不需要。
* 即生成器是:无需额外的信息就可以知道如何创建对象,一般来说生成器只会定义一个创建对象的方法。
* 本例子中的创建对象方法就是next
* @param
*/
public interface Generator {
T next();
}
泛型类
-
语法定义:
- 定义泛型:类名之后定义该类会使用到的所有泛型。
- 引用泛型:除了static方法因不能使用外部实例参数外,其他继承、实现、成员变量,成员方法,方法返回值等都可使用。
- 泛型实参:类的实例化时钻石符放在类名之后,如:
new ArrayList
()
- 语法结构:
class 类名 <定义泛型标识>
extends 父类名 <引用泛型标识>, implements 接口名 <引用泛型标识> {
private 引用泛型标识 var;
...
}
- 案例 —— 生成器具体实现类:
public class TestBasicGenerator implements Generator {
private Class type = null;
// 本来下面写法会更优雅一点,但是因为泛型的类型擦除导致这种写法是会报错的
// private Class type = T.class;
public TestBasicGenerator(Class clazz) {
type = clazz;
}
public static Generator gen(Class clazz) {
return new TestBasicGenerator(clazz);
}
public T next() {
try {
return (T) type.newInstance();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) {
// 书上的方法,通过静态泛型方法:简单
Generator gen = TestBasicGenerator.gen(Coffee.class);
Coffee c = gen.next();
// 课后习题,通过实例化方法:复杂
Generator gen1 = new TestBasicGenerator(Coffee.class);
Coffee c1 = gen1.next();
}
}
泛型方法
-
语法定义:
- 定义泛型:该方法修饰符之后定义该方法会使用到的所有泛型。
- 引用泛型:包括返回值、参数、内部变量,内部方法等,除非该方法是static方法,否则也可以使用类上定义的泛型,两者不冲突。
-
泛型实参:
- 显示传递:方法调用时钻石符放在方法名之前,'.'号之后,如:
New.
,一般建议用显示传递,方便阅读。show("hello") - 隐式传递:无需传递,编译器会根据上下文(入参或者返回值接收变量)推导出具体的类型,如:
New.show("hello")
、Map
(前者根据是非赋值语句的入参推导,后者根据赋值语句的接收变量推导)。> sls = New.map()
- 显示传递:方法调用时钻石符放在方法名之前,'.'号之后,如:
- 语法结构:
public class 类名 {
public <定义泛型标识> 返回类型 方法名(引用泛型标识 形参名) {
...
}
}
- 案例 —— 创建常用容器对象的工具类(简略版):
public class New {
public static Map map() {
return new HashMap();
}
public static void show(T title) {
System.out.println(title);
}
// Examples:
public static void main(String[] args) {
Map> sls = New.map();
// 显示传递
New.show("hello");
// 隐式传递
New.show("hello");
// 编译器会报show方法不能传递String类型,证明显示传递为主,隐式传递为辅
New.show("hello");
}
}
可变参数与泛型方法
可变参数方法可以与泛型无缝结合:
- 案例 —— 入参聚合:
public class GenericVarargs {
public static List makeList(T... args) {
List result = new ArrayList();
for(T item : args)
result.add(item);
return result;
}
public static void main(String[] args) {
ls = makeList("A", "B", "C");
System.out.println(ls); // 打印出:[A, B, C]
}
}
泛型边界
有时您可能希望在泛型类、泛型方法、泛型接口中限制一下传入的泛型实参的类型。例如,对数字进行操作的方法可能只想接受Number子类或者父类的实例,那这个时候就需要用到边界通配符了:
泛型上界类型通配符
使用上界限制类型参数,需要借助extends
关键之,先在<>
中写类型参数的标志号,后跟extends
,最后才是上界类型(注意:上界类型可以多个,但是最多只允许一个类搭配多个接口,类还必须写在第一位,因为java是单继承多实现),用法一般只用于泛型方法
、泛型接口
、泛型类
这三处语法中的定义泛型的地方。
- 复杂案例如下(仅做案例):
// 表示类型实参只能是Number的子类,并且该子类还要实现List接口,否则编译报错(上界不包含上)
public class TestTypeErasure> {
...
}
- 上界通配符
泛型下届类型通配符
使用下届限制类型参数,需要借助super
关键之,先在<>
中写无界通配符?
,后跟super
,最后才是下届具体类型或泛型标识符(注意:这里就只能一个了),用法一般只用于限制容器类值。
- 复杂案例如下(仅做案例):
// 表示类型实参可以是Integer类型,或者Integer的父类(下届包含下)
public static void addNumbers(List super Integer> list) {
for (int i = 1; i <= 10; i++) {
list.add(i);
}
}
泛型无界类型通配符
上面讲下届通配符时需要借助无界通配符,但是它不止有哪一种写法,还可以直接在<>
中写?
,表示不限定类型参数,类似。但不同的是使用了无界通配符
<>
,就限定使用add/addAll这样的插入方法插入非null
值,只能通过赋值来实现,所以一般>
只用在方法的形参中。
- 案例如下:
// 表示类型实参可以是Integer类型,或者Integer的父类(下届包含下)
public class TestBounds{
public static void printList(List> list) {
list.add(null); // 正常
list1.add(2); // 编译报错
}
public static void main(String[] args) {
List
泛型的实现原理——类型擦除(type erasure)
什么是类型擦除?
1、Replace all type parameters in generic types with their bounds or Object if the type parameters are unbounded. The produced bytecode, therefore, contains only ordinary classes, interfaces, and methods.
2、Insert type casts if necessary to preserve type safety.
3、Generate bridge methods to preserve polymorphism in extended generic types.
---摘自oracle官网java8文档1、替换所有泛型类型中的类型参数为其边界,如果无边界,将替换为Object。因此,生成的字节码仅包含普通的类,接口和方法。
2、如有必要,插入类型强转以保持类型安全。
3、生成桥接方法以保留继承泛型类型中的多态性。
从官网描述上看程序在解析成字节码之后,会把:定义泛型的地方擦除;使用泛型的地方用边界(上界)替换;传入泛型实参的地方也擦除,如果可能造成类型安全问题,就加类型强转;如果父类在类型擦除之后不合符子类的调用了,子类会增加桥接方法来保留多态。
- 案例证明——TestTypeErasure类:
public class TestTypeErasure {
public T num;
public void test(E arg) {
System.out.println(arg);
}
public static void main(String[] args) {
new ArrayList();
}
}
- java6对TestTypeErasure的字节码反编译之后:
public class generics.yjm.TestTypeErasure extends java.lang.Object{
public java.lang.Number num;
public generics.yjm.TestTypeErasure();
Code:
0: aload_0
1: invokespecial #1; //Method java/lang/Object."":()V
4: return
public void test(java.lang.Object);
Code:
0: getstatic #2; //Field java/lang/System.out:Ljava/io/PrintStream;
3: aload_1
4: invokevirtual #3; //Method java/io/PrintStream.println:(Ljava/lang/Object;)V
7: return
public static void main(java.lang.String[]);
Code:
0: new #4; //class java/util/ArrayList
3: dup
4: invokespecial #5; //Method java/util/ArrayList."":()V
7: pop
8: return
}
从java6的反编译的代码中可以分析出,原来的T
被编译成了java.lang.Number
,E
被替换成了java.lang.Object
,
被擦除不存了,符合上述原则
- java8反编译之后:
public class generics.yjm.TestTypeErasure {
public T num;
public generics.yjm.TestTypeErasure();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
public void test(E);
Code:
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: aload_1
4: invokevirtual #3 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
7: return
public static void main(java.lang.String[]);
Code:
0: new #4 // class java/util/ArrayList
3: dup
4: invokespecial #5 // Method java/util/ArrayList."":()V
7: pop
8: return
}
从java8的反编译的代码中可以分析出,原来的T
还是T
,E
还是E
,
被擦除了。但仔细分析代码,其实和java6的反编译的效果仍然是一样的,只是java8保留了标识号来做占位符而已(此说法只为了说服自己,如果有更好的解释欢迎告知,不胜感激)。
为什么使用类型擦除?
- 我们已经知道泛型是在java SE5才出现的产物,出现的主要原因就是为了解决容器类的类型安全问题,可是因为要遵循java的版本迭代原则:二进制兼容(Binary Compatibility)原则,从而折中的采用了类型擦除这样的方法来实现java版的泛型。
泛型限制
那既然java的泛型是一个折中版,总是有一些限制是要注意的,如下:
- 泛型不支持基本类型
- 无法创建类型参数(泛型)的实例
- 静态字段static不能修饰类型参数
- 无法使用类型参数进行强制转换或者instanceof
- 无法创建参数化类型的数组
- 无法创建,捕获或抛出参数化类型的对象
- 两个方法,在其他条件相同的情况下,只是泛型不同不能当做是方法的重载,会编译出错
泛型标志号的通用定义
泛型的标志号的范围是这26个字母的大小写,因为标志号太多,为了增加代码可读性,让每个标志号有自己的含义,就默认有了下面一套规范(非强制规范,只是为了方便理解和阅读):
- 集合泛型类型:E或者T,如:
ArrayList
- 映射泛型类型:K,V,如:
Map
- 数值泛型类型:N
- 字符泛型类型:S
- 布尔值泛型类型:B
总的来说,命名规则就是:方便理解
文章涉及的小知识点
-
类型推导(type inference):
- 类型推导与泛型类:是指,编译器会在编译期根据变量声明时的泛型类型自动推断出实例化的泛化类型,当然要求就是java6版本中不可以省略<>(术语:diamond,我喜欢称之为钻石符)即,
ArrayList
,只可以简写成strs = new ArrayList () ArrayList
,java7及以上可以省略。strs = new ArrayList<>() - 类型推导与泛型方法:如上述的方法隐式传递就是方法的类型推导,不一样的是钻石符可以省略。
- 类型推导与泛型类:是指,编译器会在编译期根据变量声明时的泛型类型自动推断出实例化的泛化类型,当然要求就是java6版本中不可以省略<>(术语:diamond,我喜欢称之为钻石符)即,
-
二进制兼容原则:指在相同系统环境中,高版本的Java编译低版本的java文件产生的二进制要与低版本编译出来的二进制兼容(如:java8版本编译java7语法写的java类生成的二进制要和在java7时编译出来的二进制兼容),也就是所谓的向后兼容:
- Java 8(完全二进制与Java 7兼容)
- Java 7(大多数二进制与Java 6兼容)
- Java 6(主要是与Java 5兼容的二进制文件,加上一些模糊处理程序在规范之外生成类文件的注释,因此这些类文件可能无法运行)
- Java 5(大多数二进制与Java 1.4.2兼容,加上与混淆器相同的注释)
- JAVA 1.0-1.4.2(大多数二进制版本与以前的版本兼容,一些前向兼容性的注释甚至可以工作,但没有经过测试)
- getTypeParameters方法作用:返回在类上申明的泛型的标识符,并合并成数组放回,如果类上未申明未泛型标识符,那就返回空数组。
- 桥接方法生成案例:
// 编译前:
public class Node {
public T data;
public Node(T data) { this.data = data; }
public void setData(T data) {
System.out.println("Node.setData");
this.data = data;
}
}
public class MyNode extends Node {
public MyNode(Integer data) { super(data); }
public void setData(Integer data) {
System.out.println("MyNode.setData");
super.setData(data);
}
}
// 编译后:
public class Node {
public Object data;
public Node(Object data) { this.data = data; }
public void setData(Object data) {
System.out.println("Node.setData");
this.data = data;
}
}
public class MyNode extends Node {
public MyNode(Integer data) { super(data); }
// 因为super(data)缘故,编译器会生成桥接方法,委托给原始的setData方法
public void setData(Object data) {
setData((Integer) data);
}
public void setData(Integer data) {
System.out.println("MyNode.setData");
super.setData(data);
}
}
-
List>、List、List
- List>:限制除了能使用add/addAll等方法插入null值,其他类型都不可以。也包含泛型的特性,可以是任意一种实参类型。
- List:无限制,可以是任意一种或多种具体类型,但缺少泛型给予的编译期类型安全保障。
- List
- List extends Object>:和List>基本相同,只是多加了一个限制,只能是Object类型的子类。
- 上下边界通配符不能同时使用(废话)。
文章引用
oracle官网——类型擦除