【Java心得总结四】Java泛型下——万恶的擦除

时间:2023-12-27 11:08:37

一、万恶的擦除

我在自己总结的【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版》

上面在通配符这里本人理解还不是很透彻,以后我也会根据自己理解修改整理。