【Java心得总结四】Java泛型下——万恶的擦除
一、万恶的擦除
我在自己总结的【Java心得总结三】Java泛型上——初识泛型这篇博文中提到了Java中对泛型擦除的问题,考虑下面代码:
import java.util.*;
public class ErasedTypeEquivalence {
public static void main(String[] args) {
Class c1 = new ArrayList<String>().getClass();
Class c2 = new ArrayList<Integer>().getClass();
System.out.println(c1 == c2);
}
}/* Output:
true
*///:~
在代码的第4行和第5行,我们分别定义了一个接受String类型的List和一个接受Integer类型的List,按照我们正常的理解,泛型ArrayList<T>虽然是相同的,但是我们给它传了不同的类型参数,那么c1和2的类型应该是不同的。但是结果恰恰想法,运行程序发现二者的类型时相同的。这是为什么呢?这里就要说到Java语言实现泛型所独有的——擦除(万恶啊)
即当我们声明List<String>和List<Integer>时,在运行时实际上是相同的,都是List,而具体的类型参数信息String和Integer被擦除了。这就导致一个很麻烦的问题:在泛型代码内部,无法获得任何有关泛型参数类型的信息 (摘自《Java编程思想第4版》)。
为了体验万恶的擦除的“万恶”,我们与C++做一个比较:
C++模板:
#include <iostream>
using namespace std;
template<class T> class Manipulator {
T obj;
public:
Manipulator(T x) { obj = x; }
void manipulate() { obj.f(); }
};
class HasF {
public:
void f() { cout << "HasF::f()" << endl; }
};
int main() {
HasF hf;
Manipulator<HasF> manipulator(hf);
manipulator.manipulate();
} /* Output:
HasF::f()
///:~
在这段代码中,我们声明了一个模板(即泛型)类Manipulator,这个类接收一个T类型的对象,并在内部调用该对象的f方法,在main我们向Manipulator传入一个拥有f方法的类HasF,然后代码很正常的通过编译而且顺利运行。
C++代码里其实有一个很奇怪的地方,就是在代码第7行,我们利用传入的T类型对象来调用它的f方法,那么我怎么知道你传入的类型参数T类型是否有方法f呢?但是从整个编译来看,C++中确实实现了,并且保证了整个代码的正确性(可以验证一个没有方法f的类传入,就会报错)。至于怎么做到,我们稍后会略微提及。
OK,我们将这段代码用Java实现下:
Java泛型:
public class HasF {
public void f() { System.out.println("HasF.f()"); }
}
class Manipulator<T> {
private T obj;
public Manipulator(T x) { obj = x; }
// Error: cannot find symbol: method f():
public void manipulate() { obj.f(); }
}
public class Manipulation {
public static void main(String[] args) {
HasF hf = new HasF();
Manipulator<HasF> manipulator =
new Manipulator<HasF>(hf);
manipulator.manipulate();
}
} ///:~
大家会发现在C++我们很方便就能实现的效果,在Java里无法办到,在代码第7行给出了错误提示,就是说在Manipulator内部我们无法获知类型T是否含有方法f。这是为什么呢?就是因为万恶的擦除引起的,在Java代码运行的时候,它会将泛型类的类型信息T擦除掉,就是说运行阶段,泛型类代码内部完全不知道类型参数的任何信息。如上面代码,运行阶段Manipulator<HasF>类的类型信息会被擦除,只剩下Mainipulator,所以我们在Manipulator内部并不知道传入的参数类型时HasF的,所以第8行代码obj调用f自然就会报错(就是我哪知道你有没有f方法啊)
综上,我们可以看出擦除带来的代价:在泛型类或者说泛型方法内部,我们无法获得任何类型信息,所以泛型不能用于显示的引用运行时类型的操作之中,例如转型、instanceof操作和new表达式。例如下代码:
public class Animal<T>{
T a;
public Animal(T a){
this.a = a;
}
// error!
public void animalMove(){
a.move();
}
// error!
public void animalBark(){
a.bark();
}
// error!
public void animalNew(){
return new T();
}
// error!
public boolean isDog(){
return T instanceof Dog;
}
}
public class Dog{
public void move(){
System.out.println("dog move");
}
public void bark(){
System.out.println("wang!wang!);
}
}
public static void main(String[] args){
Animal<Dog> ad = new Animal<Dog>();
}
我们声明一个泛化的Animal类,之后声明一个Dog类,Dog类可以移动move(),吠叫bark()。在main中将Dog作为类型参数传递给Animal<Dog>。而在代码的第8行和第11行,我们尝试调用传入类的函数move()和bark(),发现会有错误;在代码16行,我们试图返回一个T类型的对象即new一个,也会得到错误;而在代码20行,当我们试图利用instanceof判断T是否为Dog类型时,同样是错误!
另外,我这里想强调下Java泛型是不支持基本类型的(基本类型可参见【Java心得总结一】Java基本类型和包装类型解析)感谢CCQLegend
所以还是上面我们说过的话:在泛型代码内部,无法获得任何有关泛型参数类型的信息 (摘自《Java编程思想第4版》),我们在编写泛化类的时候,我们要时刻提醒自己,我们传入的参数T仅仅是一个Object类型,任何具体类型信息我们都是未知的。
二、为什么Java用擦除
上面我们简单阐述了Java中泛型的一个擦除问题,也体会到它的万恶,给我们编程带来的不便。那Java开发者为什么要这么干呢?
这是一个历史问题,Java在版本1.0中是不支持泛型的,这就导致了很大一批原有类库是在不支持泛型的Java版本上创建的。而到后来Java逐渐加入了泛型,为了使得原有的非泛化类库能够在泛化的客户端使用,Java开发者使用了擦除进行了折中。
所以Java使用这么具有局限性的泛型实现方法就是从非泛化代码到泛化代码的一个过渡,以及不破坏原有类库的情况下,将泛型融入Java语言。
三、怎么解决擦除带来的烦恼
解决方案1:
不要使用Java语言。这是废话,但是确实,当你使用python和C++等语言,你会发现在这两种语言中使用泛型是一件非常轻松加随意的事情,而在Java中是事情要变得复杂得多。如下示例:
python:
class Dog:
def speak(self):
print "Arf!"
def sit(self):
print "Sitting"
def reproduce(self):
pass class Robot:
def speak(self):
print "Click!"
def sit(self):
print "Clank!"
def oilChange(self) :
pass def perform(anything):
anything.speak()
anything.sit() a = Dog()
b = Robot()
perform(a)
perform(b)
python的泛型使用简直称得上写意,定义两个类:Dog和Robot,然后直接用anything来声明一个perform泛型方法,在这个泛型方法中我们分别调用了anything的speak()和sit()方法。
C++
class Dog {
public:
void speak() {}
void sit() {}
void reproduce() {}
}; class Robot {
public:
void speak() {}
void sit() {}
void oilChange() {
}; template<class T> void perform(T anything) {
anything.speak();
anything.sit();
} int main() {
Dog d;
Robot r;
perform(d);
perform(r);
} ///:~
C++中的声明相对来说条条框框多一点,但是同样能够实现我们要达到的目的
Java:
public interface Performs {
void speak();
void sit();
} ///:~
class PerformingDog extends Dog implements Performs {
public void speak() { print("Woof!"); }
public void sit() { print("Sitting"); }
public void reproduce() {}
}
class Robot implements Performs {
public void speak() { print("Click!"); }
public void sit() { print("Clank!"); }
public void oilChange() {}
}
class Communicate {
public static <T extends Performs> void perform(T performer) {
performer.speak();
performer.sit();
}
}
public class DogsAndRobots {
public static void main(String[] args) {
PerformingDog d = new PerformingDog();
Robot r = new Robot();
Communicate.perform(d);
Communicate.perform(r);
}
}
Java代码很奇怪的用到了一个接口Perform,然后在代码16行定义泛型方法的时候指明了<T extends Perform>(泛型方法的声明方式请见:【Java心得总结三】Java泛型上——初识泛型),声明泛型的时候我们不是简单的直接<T>而是确定了一个边界,相当于告诉编译器:传入的这个类型一定是继承自Perform接口的,那么T就一定有speak()和sit()这两个方法,你就放心的调用吧。
可以看出Java的泛型使用方式很繁琐,程序员需要考虑很多事情,不能够按照正常的思维方式去处理。因为正常我们是这么想的:我定义一个接收任何类型的方法,然后在这个方法中调用传入类型的一些方法,而你有没有这个方法,那是编译器要做的事情。
其实在python和C++中也是有这个接口的,只不过它是隐式的,程序员不需要自己去实现,编译器会自动处理这个情况。
解决方案2:
当然啦,很多情况下我们还是要使用Java中的泛型的,怎么解决这个头疼的问题呢?显示的传递类型的Class对象:
从上面的分析我们可以看出Java的泛型类或者泛型方法中,对于传入的类型参数的类型信息是完全丢失的,是被擦除掉的,我们在里面连个new都办不到,这时候我们就可以利用Java的RTTI即运行时类型信息(后续博文)来解决,如下:
class Building {}
class House extends Building {}
public class ClassTypeCapture<T> {
Class<T> kind;
T t;
public ClassTypeCapture(Class<T> kind) {
this.kind = kind;
}
public boolean f(Object arg) {
return kind.isInstance(arg);
}
public void newT(){
t = kind.newInstance();
}
public static void main(String[] args) {
ClassTypeCapture<Building> ctt1 =
new ClassTypeCapture<Building>(Building.class);
System.out.println(ctt1.f(new Building()));
System.out.println(ctt1.f(new House()));
ClassTypeCapture<House> ctt2 =
new ClassTypeCapture<House>(House.class);
System.out.println(ctt2.f(new Building()));
System.out.println(ctt2.f(new House()));
}
}/* Output:
true
false
true
*///:~
在前面的例子中我们利用instanceof来判断类型失败,因为泛型中类型信息已经被擦除了,代码第10行这里我们使用动态的isInstance(),并且传入类型标签Class<T>这样的话我们只要在声明泛型类时,利用构造函数将它的Class类型信息传入到泛化类中,这样就补偿擦除问题
而在代码第13行这里我们同样可利用工厂对象Class对象来通过newInstance()方法得到一个T类型的实例。(这在C++中完全可以利用t = new T();实现,但是Java中丢失了类型信息,我无法知道T类型是否拥有无参构造函数)
(上面提到的Class、isInstance(),newInstance()等Java中类型信息的相关后续博文中我自己再总结)
解决方案3:
在解决方案1中我们提到了,利用边界来解决Java对泛型的类型擦除问题。就是我们声明一个接口,然后在声明泛化类或者泛化方法的时候,显示的告诉编译器<T extends Interface>其中Interface是我们任意声明的一个接口,这样在内部我们就能够知道T拥有哪些方法和T的部分类型信息。
四、通配符之协变、逆变
在使用Java中的容器的时候,我们经常会遇到类似List<? extends Fruit>这种声明,这里问号?就是通配符。Fruit是一个水果类型基类,它的导出类型有Apple、Orange等等。
协变:
class Fruit {}
class Apple extends Fruit {}
class Jonathan extends Apple {}
class Orange extends Fruit {}
public class CovariantArrays {
public static void main(String[] args) {
Fruit[] fruit = new Apple[10];
fruit[0] = new Apple(); // OK
fruit[1] = new Jonathan(); // OK
// Runtime type is Apple[], not Fruit[] or Orange[]:
try {
// Compiler allows you to add Fruit:
fruit[0] = new Fruit(); // ArrayStoreException
} catch(Exception e) { System.out.println(e); }
try {
// Compiler allows you to add Oranges:
fruit[0] = new Orange(); // ArrayStoreException
} catch(Exception e) { System.out.println(e); }
}
} /* Output:
java.lang.ArrayStoreException: Fruit
java.lang.ArrayStoreException: Orange
*///:~
首先我们观察一下数组当中的协变(协变就是子类型可以被当作基类型使用),Java数组是支持协变的。如上述代码,我们会发现声明的一个Apple数组用Fruit引用来存储,但是当我们往里添加元素的时候我们只能添加Apple对象及其子类型的对象,如果试图添加别的Fruit的子类型如Orange,那么在编译器就会报错,这是非常合理的,一个Apple类型的数组很明显不能放Orange进去;但是在代码13行我们会发现,如果想要将Fruit基类型的对象放入,编译器是允许的,因为我们的数组引用是Fruit类型的,但是在运行时编译器会发现实际上Fruit引用处理的是一个Apple数组,这是就会抛出异常。
然而我们把数组的这个操作翻译到List上去,如下:
public class GenericsAndCovariance {
public static void main(String[] args) {
// Wildcards allow covariance:
List<? extends Fruit> flist = new ArrayList<Apple>();
// Compile Error: can’t add any type of object:
// flist.add(new Apple());
// flist.add(new Fruit());
// flist.add(new Object());
flist.add(null); // Legal but uninteresting
// We know that it returns at least Fruit:
Fruit f = flist.get(0);
}
} ///:~
我们这里使用了通配符<? extends Fruit>,可以理解为:具有任何从Fruit继承的类型的列表。我们会发现不仅仅是Orange对象不允许放入List,这时候极端的连Apple都不允许我们放入这个List中。这说明了一个问题List是不能像数组那样拥有协变性。
这里为什么会出现这样的情况,通过查看ArrayList的源码我们会发现:当我们声明ArrayList<? extends Fruit>中的add()的参数也变成了"? extends Fruit",这时候编译器无法知道你具体要添加的是Fruit的哪个具体子类型,那么它就会不接受任何类型的Fruit。
但是这里我们发现我们能够正常的get()出一个元素的,很好理解,因为我们声明的类型参数是<? extends Fruit>,编译器肯定可以安全的将元素返回,应为我知道放在List中的一定是一个Fruit,那么返回就好。
逆变:
上面我们发现get方法是可以的,那么当我们想用set方法或者add方法的时候怎么办?就可以使用逆变即超类型通配符。如下:
public class SuperTypeWildcards {
static void writeTo(List<? super Apple> apples) {
apples.add(new Apple());
apples.add(new Jonathan());
// apples.add(new Fruit()); // Error
}
} ///:~
这里<? super Apple>意即这个List存放的是Apple的某种基类型,那么我将Apple或其子类型放入到这个List中肯定是安全的。
总结一下:
<? super T>逆变指明泛型类持有T的基类,则T肯定可以放入
<? extends T>指明泛型类持有T的导出类,则返回值一定可作为T的协变类型返回
说了这么多,总结了一堆也发现了Java泛型真的很渣,不好用,对程序员的要求会更高一些,一不小心就会出错。这也就是我们使用类库中的泛化类时常看到各种各样的警告的原因了。。。
参考——《Java编程思想第4版》
上面在通配符这里本人理解还不是很透彻,以后我也会根据自己理解修改整理。
【Java心得总结四】Java泛型下——万恶的擦除的更多相关文章
- Java编程的逻辑 (37) - 泛型 (下) - 细节和局限性
本系列文章经补充和完善,已修订整理成书<Java编程的逻辑>,由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买,京东自营链接:http:/ ...
- Java学习笔记四:Java的八种基本数据类型
Java的八种基本数据类型 Java语言提供了八种基本类型.六种数字类型(四个整数型,两个浮点型),一种字符类型,还有一种布尔型. Java基本类型共有八种,基本类型可以分为三类,字符类型char,布 ...
- 菜鸡的Java笔记 第四 - java 基础运算符
数学运算符,逻辑运算,三目运算,位运算 double d2 = 314e2; //采用科学计数法的写法,表示10的2次方.= 31400.0 代码写的越简单越好 简化运算符 代码:x=x+y 可以 ...
- Java 学习笔记 (四) Java 语句优化
这个问题是从headfirst java看到的. 需求: 一个移动电话用的java通讯簿管理系统,要求最有效率的内存使用方法. 下面两段程序的优缺点,哪个占用内存更少. 第一段: Contact[]c ...
- 【Java心得总结三】Java泛型上——初识泛型
一.函数参数与泛型比较 泛型(generics),从字面的意思理解就是泛化的类型,即参数化类型.泛型的作用是什么,这里与函数参数做一个比较: 无参数的函数: public int[] newIntAr ...
- 【Java心得总结六】Java容器中——Collection
在[Java心得总结五]Java容器上——容器初探这篇博文中,我对Java容器类库从一个整体的偏向于宏观的角度初步认识了Java容器类库.而在这篇博文中,我想着重对容器类库中的Collection容器 ...
- 【Java心得总结五】Java容器上——容器初探
在数学中我们有集合的概念,所谓的一个集合,就是将数个对象归类而分成为一个或数个形态各异的大小整体. 一般来讲,集合是具有某种特性的事物的整体,或是一些确认对象的汇集.构成集合的事物或对象称作元素或是成 ...
- 【Java心得总结七】Java容器下——Map
我将容器类库自己平时编程及看书的感受总结成了三篇博文,前两篇分别是:[Java心得总结五]Java容器上——容器初探和[Java心得总结六]Java容器中——Collection,第一篇从宏观整体的角 ...
- Java进阶(四)Java反射TypeToken解决泛型运行时类型擦除问题
在开发时,遇到了下面这条语句,不懂,然习之. private List<MyZhuiHaoDetailModel> listLottery = new ArrayList<MyZhu ...
随机推荐
- 基于webdriver的jmeter性能测试-Selenium IDE
前言: 由于某些项目使用了WebGL技术,需要高版本的Firefox和Chrome浏览器才能支持浏览,兼容性很弱,导致Loadrunner和jmeter(badboy)无法正常进行录制脚本.因此我们采 ...
- Android-Parcelable
Parcelable和Serializable的区别: android自定义对象可序列化有两个选择一个是Serializable和Parcelable 一.对象为什么需要序列化 1.永久 ...
- SSH学习笔记
Struts2登录模块处理流程: 浏览器发送请求http://localhost/appname/login.action,到web应用服务器: 容器接收到该请求,根据web.xml的配置,服务器将请 ...
- Code First开发系列之管理并发和事务
返回<8天掌握EF的Code First开发>总目录 本篇目录 理解并发 理解积极并发 理解消极并发 使用EF实现积极并发 EF的默认并发 设计处理字段级别的并发应用 实现RowVersi ...
- ABP理论学习之领域服务
返回总目录 本篇目录 介绍 IDomainService接口和DomainService类 样例 创建一个接口 服务实现 调用应用服务 一些讨论 何不只使用应用服务 如何强制使用领域服务 介绍 领域服 ...
- 【Java并发编程实战】-----“J.U.C”:ReentrantLock之一简介
注:由于要介绍ReentrantLock的东西太多了,免得各位客官看累,所以分三篇博客来阐述.本篇博客介绍ReentrantLock基本内容,后两篇博客从源码级别分别阐述ReentrantLock的l ...
- vmware 安装xp 流水账
1. 分区 PQ分区.1个区,C盘,NTFS. 2. 安装XP 进入ghost,不要选择一键. 然后fromImage, d:\xxx\GHO
- .NET 新标准介绍
本文介绍如何使用 .NET 标准,更容易地实现向 .NET Core 迁移.文中会讨论计划包含的 APIs,跨构架兼容性如何工作以及这对 .NET Core 意味着什么. 如果你对细节感兴趣,这篇文章 ...
- Redis初级介绍
1 什么是Redis Redis(REmote DIctionary Server,远程数据字典服务器)是开源的内存数据库,常用作缓存或者消息队列. Redis的特点: Redis存在于内存,使用硬盘 ...
- Android—自定义Dialog
在 Android 日常的开发中,Dialog 使用是比较广泛的.无论是提示一个提示语,还是确认信息,还是有一定交互的(弹出验证码,输入账号密码登录等等)对话框. 而我们去看一下原生的对话框,虽然随着 ...