Bootstrap

Java编程思想之【泛型擦除】


前言

还记得几年前去一间公司面试的时候,面试官问的技术方面的问题,其中一个就是关于擦除的问题,当时的我第一次接触面试有点紧张而且对擦除这个术语还不太了解(说白了当时我就是一个技术小白,现在也差不多啦 ╮( ̄▽  ̄)╭ ),所以支支吾吾没怎么回答上来,幸好本人人品爆表,没回答上来也顺利入职了\( ^ ▽ ^ )/。

由于当时没回答上来的尴尬情景依然历历在目,所以这次写这篇博文的目的以弥补技术上的模糊点为主,如果以下表述上有误请各位技术大佬加以指正,灰常感谢! ( * ^ _ ^ * )

一、简单泛型

这边先简述一下泛型的理论知识。

1.定义

在程序编码中一些包含类型参数的类型,也就是说泛型的参数只可以代表类,不能代表个别对象。

2.作用

泛型的出现,主要原因是为了创造容器类。相对于数组而言,容器类更加灵活。事实上,所有程序在运行时都要求你持有一大堆对象,所以容器类算得上最具重用性的类库之一。

3.泛型

在 Java SE5 (JDK1.5) 以后,新增了一个类型参数 T ,与其使用Object,我们更喜欢暂时不指定类型,而是稍后再决定具体使用什么类型,要达到这个目的,需要使用到类型参数 T ,下面例子中,T 就是类型参数:

public class Holder<T> {

    private T a;

    public Holder(T a) {
        this.a = a;
    }

    public void set(T a) {
        this.a = a;
    }

    public T get() {
        return a;
    }
}(~ o ~)~zZ

二、泛型擦除

1.擦除的神秘之处

比较两种不同泛型的ArrayList,代码如下:

/**
 * @Author: Tony Peng
 **/
public class EraseTypeEquivalence {

    public static void main(String[] args) {
        Class stringType = new ArrayList<String>().getClass();
        Class integerType = new ArrayList<Integer>().getClass();
        System.out.println("类型参数是否相同:" + ((stringType == integerType) ? "相同" : "不相同"));

    }

}~(@ ^_^ @)~

运行结果如下:
运行结果1
这时, ArrayList<String>ArrayList<Integer> 很容易被认为是不同类型。不同类型在行为方面肯定不同,例如,如果尝试将一个Integer放入ArrayList<String>,所得到的行为(将失败)与把一个Integer放入ArrayList<Integer>(将成功)所得到的行为完全不同。但是上面的程序会认为它们是相同的类型。

下面示例是对这个谜题的一个补充:

class Frob {}
class Fnorkle {}
class Quark<Q> {}
class Particle<POSITION, MOMENTUM> {}

/**
 * @Author: Tony Peng
 **/
public class LostInformation {
    
    public static void main(String[] args) {
        List<Frob> list = new ArrayList<>();
        Map<Frob, Fnorkle> map = new HashMap<>(16);
        Quark<Fnorkle> quark = new Quark<>();
        Particle<Long, Double> particle = new Particle<>();
        
        System.err.println(Arrays.toString(list.getClass().getTypeParameters()));
        System.err.println(Arrays.toString(map.getClass().getTypeParameters()));
        System.err.println(Arrays.toString(quark.getClass().getTypeParameters()));
        System.err.println(Arrays.toString(particle.getClass().getTypeParameters()));
        
    }
    
}*@ ο @*

运行结果如下:
运行结果3
根据JDK文档描述,Class.getTypeParameters()将“返回一个TypeVariable对象数组,表示有泛型声明所声明的类型参数”,但事实上是:在泛型代码内部,无法获得任何有关泛型参数类型的信息

Java泛型是使用擦除来实现的,这意味着当你在使用泛型时,任何具体的类型信息都被擦除了。因此List<String>和List<Integer>在运行时事实上是相同类型。这两种形式都被擦除成他们的“原生”类型,即List。

这边补充一下常用的被泛型化的集合类,如下:

集合类泛型定义
ArrayListArrayLiset<E>
HashMapHashMap<K,V>
HashSetHashSet<E>
VectorVector<E>

下面代码简单演示一下 泛化边界 遇到的问题:

/**
 * @Author: Tony Peng
 **/
class HasF {
    public void f() {
        System.out.println("HasF.f()");
    }
}

//这边必须重用extends关键字来确定边界,否则调用obj.f()会报错,找不到方法
class Manipulator<T extends HasF> {
    private T obj;
    public Manipulator(T x) {
        obj = x;
    }
    public void manipulate() {
        obj.f();
    }
}

public class Manipulation {
    public static void main(String[] args) {
        HasF hf = new HasF();
        Manipulator<HasF> manipulator = new Manipulator<>(hf);
        manipulator.manipulate();
    }
}(⊙ o ⊙)

由于有了擦除,如果没有协助泛型类重用extends关键字给定了泛型类边界,Java编译器无法将manipulate()必须能够在obj上调用f()这一需求映射到HasF拥有f()这一事实上。

我们说泛型类型参数将擦除到它的第一个边界,我们还提到了类型参数的擦除。编译器实际上会把类型参数替换为它的擦除,就像上面示例一样,T擦除到了HasF,就好像在类的声明中用HasF替换了T一样。

迁移兼容性

在基于擦除的实现中,泛型类型被当作第二类类型处理,即不能在某些重要的上下文环境中使用的类型。泛型类型只有在静态类型检查期间才出现,在此之后,程序中的所有泛型类型都将被擦除,替换为它们的非泛型上界。例如,诸如List<T>这样的类型注解都将被擦除为List,而普通的类型变量在未指定边界的情况下将被擦除为Object。

擦除的核心动机是它使得泛化的客户端可以用非泛化的类库来使用,反之亦然,这经常被称为“迁移兼容性”。

擦除的问题

擦除主要的正当理由是从非泛化代码到泛化代码的转变过程,以及在不破坏现有类库的情况下,将泛型融入Java语言。

擦除的代价是显著的。泛型不能用于显示地引用运行时类型的操作之中,例如转型、instanceof和new表达式。因为所有关于参数的类型信息都丢失了,无论何时,当你在编写泛型代码时,必须时刻提醒自己,你只是看起来好像拥有有关参数的类型信息而已。

如果你编写了下面这样的代码段:

class Foo<T>{
    T var;
}

那么,看起来当你在创建Foo的实例时;

	Foo<Cat> f = new Foo<Cat>();

class Foo中代码应该知道现在工作于Cat之上,而泛型语法也在强烈暗示:在整个类中的各个地方,类型T都在被替换。但事实并非如此,无论何时,当你在编写这个类的代码时,必须提醒自己:“不,它只是一个Object。”

另外,擦除和迁移兼容性意味着,使用泛型并不是强制的,如下代码演示:

/**
 * @Author: Tony Peng
 **/
class GenericBase<T>{
    private T element;
    public void set(T arg){
        arg = element;
    }
    public T get(){
        return element;
    }
}

class Derived1<T> extends GenericBase<T>{}
class Derived2 extends GenericBase{}
//	class Derived3 extends GenericBase<?>{}
//  java: 意外的类型
//    需要: 不带限制范围的类或接口
//    找到: ?

public class ErasureAndInheritance {
    public static void main(String[] args) {
        Derived2 d2 = new Derived2();
        Object obj = d2.get();
        d2.set(obj);
    }
}(*^^*) 

可以推断,Derived3产生错误意味着编译器期望得到一个原生基类。

2.擦除的补偿

擦除丢失了在泛型代码中执行某些操作的能力。任何运行时需要知道确切类型信息的操作都将无法工作。

演示代码如下:

/**
 * @Author: Tony Peng
 **/
public class Erased<T> {
    private final int SIZE = 100;

    public void f(Object arg) {
        if (arg instanceof T){}              // Error
        T var = new T();                     // Error
        T[] array = new T();                 // Error
        T[] array = (T[]) new Object[SIZE];  // Unchecked warning
    }
}*∩ _ ∩*

偶尔可以绕过这些问题来编程,但有时必须通过引入类型标签来对擦除进行补偿。这意味着需要显示地传递你的类型的Class对象,以便你可以在类型表达式中使用它。
例如在上面演示代码中对使用instanceof的尝试最终失败了,因为其类型信息已经被擦除了,如果引入类型标签,就可以转而使用动态的isInstance():

class Building {}
class House extends Building {}

/**
 * @Author: Tony Peng
 **/
public class ClassTypeCapture<T> {
    Class<T> kind;

    public ClassTypeCapture(Class<T> kind) {
        this.kind = kind;
    }

    public boolean f(Object arg) {
        return kind.isInstance(arg);
    }

    public static void main(String[] args) {
        ClassTypeCapture<Building> ctt1 = new ClassTypeCapture<Building>(Building.class);
        System.out.println("Building: " + ctt1.f(new Building()));
        System.out.println("House: " + ctt1.f(new House()));
    }
}*> . <*

运行结果:
运行结果4
编译器将确保类型标签可以匹配泛型参数。

在class Erase<T>中对创建一个new T()的尝试将无法实现,部分原因是因为擦除,而另一部分原因是因为编译器不能验证T具有默认(无参)构造器。

Java中解决方案是传递一个工厂对象,并使用它来创建新的实例。最便利的工厂对象就是Class对象,因此如果使用类型标签,那么你就可以使用newInstance()来创建这个类型的新对象,演示代码如下:

class ClassAsFactory<T> {
    T x;
    public ClassAsFactory(Class<T> kind) {
        try {
            x = kind.newInstance();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

class Employee {}

/**
 * @Author: Tony Peng
 **/
public class InstantiateGenericType {
    public static void main(String[] args) {
        ClassAsFactory<Employee> fe = new ClassAsFactory<Employee>(Employee.class);
        System.out.println("ClassAsFactory<Employee> succeed");
        try {
            ClassAsFactory<Integer> fi = new ClassAsFactory<Integer>(Integer.class);
        } catch (Exception e) {
            System.out.println("ClassAsFactory<Employee> failed");
        }
    }
}(๑ ¯∀¯ ๑)

运行结果:
运行结果4
这可以编译,但是会因ClassAsFactory<Integer>而失败,因为Integer没有任何默认的构造器。因为这个错误不是在编译器捕获的,所以Sun的伙计们对这种方式并不赞成,他们建议使用显式工厂,并将其限制其类型,使得只能接受实现了这个工厂的类,演示代码如下:

interface FactoryI<T> {
    T create();
}

class Foo2<T>{
    private T x;
    public <F extends FactoryI<T>> Foo2(F factory){
        x = factory.create();
    }
}

class IntegerFactory implements FactoryI<Integer>{
    @Override
    public Integer create(){
        return new Integer(0);
    }
}

class Widget {
    public static class Factory implements FactoryI<Widget>{
        @Override
        public Widget create(){
            return new Widget();
        }
    }
}

/**
 * @author Tony Peng
 */
public class FactoryConstraint {
    public static void main(String[] args) {
        new Foo2<Integer>(new IntegerFactory());
        new Foo2<Widget>(new Widget.Factory());
    }
}(* °▽° *)

注意,这确实只是传递Class<T>的一种变体。两种方式都传递了工厂对象,Class<T>碰巧是内建的工厂对象,而上面的方式创建了一个显式的工厂对象,但是你却获得了编译器检查。

另一种方法是模板方法设计模式。在下面的示例中,get()是模板方法,而create()是在子类中定义的,用来产生子类类型的对象:

abstract class GenericWithCreate<T> {
    final T element;
    GenericWithCreate() {
        element = create();
    }
    abstract T create();
}

class X {}

class Creator extends GenericWithCreate<X>{
    @Override
    X create(){
        return new X();
    }
    void f(){
        System.out.println(element.getClass().getSimpleName());
    }
}

/**
 * @author Tony Peng
 */
public class CreatorGeneric {
    public static void main(String[] args) {
        Creator c = new Creator();
        c.f();
    }
}φ(≧ ω ≦*)

运行结果如下:
运行结果5

泛型数组

正如你在class Erase<T>中所见,不能创建泛型数组。一般的解决方案是在任何想要创建泛型数组的地方都使用ArrayList:

/**
 * @Author: Tony Peng
 **/
public class ListOfGenerics<T> {
    private List<T> array = new ArrayList<T>();
    public void add(T item) {
        array.add(item);
    }
    public T get(int index) {
        return array.get(index);
    }
}φ(゜ ▽ ゜*)

这里你将获得数组行为,以及由泛型提供的编译期的类型安全。

有时,你仍旧希望创建泛型类型的数组(例如,ArrayList内部使用的是数组)。有趣的是,可以按照编译器喜欢的方式来定义一个引用,例如:

class Generic<T> {}

/**
 * @author Tony Peng
 */
public class ArrayOfGenericReference {
    static Generic<Integer>[] gia;
}( ̄ ‘i  ̄;)

编译器将接受这个警告,而不会产生任何警告。但是,永远都不能创建这个确切类型的数组(包括类型参数),因此这有一点令人困惑。既然所有数组无论它们持有的类型如何,都具有相同的结构(每个数组槽位的尺寸和数组的布局),那么看起来你应该能够创建一个Object数组,并将其转型为所希望的数组类型。事实上这可以编译,但是不能运行,它将产生ClassCase-Exception:

/**
 * @Author: Tony Peng
 **/
public class ArrayOfGeneric {
    static final int SIZE = 100;
    static Generic<Integer>[] gia;

    public static void main(String[] args) {
        //这段代码运行会报ClassCastException异常
        gia = (Generic<Integer>[]) new Object[SIZE];
        gia = (Generic<Integer>[]) new Generic[SIZE];
        System.out.println(gia.getClass().getSimpleName());
        gia[0] = new Generic<Integer>();

        //java: 不兼容的类型: java.lang.Object无法转换为Generic<java.lang.Integer>
        gia[1] = new Object();
        
        //java: 不兼容的类型: Generic<java.lang.Double>无法转换为Generic<java.lang.Integer>
        gia[2] = new Generic<Double>();
    }
}(ノ へ  ̄、)

运行结果如下:
运行结果5
问题在于数组将跟踪它们的实际类型,而这个类型是在数组被创建时确定的,因此,即使gia已经被转型为Generic<Integer>[],但是这个信息只存在于编译期。在运行时,它仍旧是Object数组,而这将引发问题。成功创建泛型数组的唯一方式就是创建一个被擦除类型的新数组,然后对其转型。

让我们看一个更复杂的示例。考虑一个简单的泛型数组包装器:

/**
 * @Author: Tony Peng
 **/
public class GenericArray<T> {
    private T[] array;
    public GenericArray(int sz) {
        array = (T[]) new Object[sz];
    }
    public void put(int index, T item) {
        array[index] = item;
    }
    public T get(int index) {
        return array[index];
    }

    public T[] rep() {
        return array;
    }

    public static void main(String[] args) {
        GenericArray<Integer> gai = new GenericArray<Integer>(10);
        //这里会报ClassCastException异常
        Integer[] ia = gai.rep();
        //这样写就不会报异常
        Object[] oa = gai.rep();
    }
}(๑´ ㅂ `๑)

与前面相同,我们并不能声明T[] array = new T[sz],因此我们创建了一个对象数组,然后将其转型。

rep()方法将返回T[],它在main()中将用于gai,因此应该是Integer[],但是如果调用它,并尝试着将结果作为Integer[]引用来捕获,就会得到ClassCastException,这还是因为实际的运行时类型是Object[]。

因为有了擦除,数组的运行时类型就只能是Objec[]。如果我们立即将其转型为T[],那么在编译期该数组的实际类型就将丢失,而编译器可能会错过某些潜在的错误检查。正因为这样,最好是在集合内部使用Object[],然后当你使用的数组元素时,添加一个对T的转型,让我们看看这是如何作用于GenericArray示例的:

/**
 * @Author: Tony Peng
 **/
public class GenericArray2<T> {
    private Object[] array;

    public GenericArray2(int sz) {
        array = new Object[sz];
    }

    public void put(int index, T item) {
        array[index] = item;
    }

    public T get(int index) {
        return (T) array[index];
    }

    public T[] rep() {
        return (T[]) array;
    }

    public static void main(String[] args) {
        GenericArray2<Integer> gai = new GenericArray2<Integer>(10);
        for (int i = 0; i < 10; i++) {
            gai.put(i, i);
        }
        for (int i = 0; i < 10; i++) {
            System.out.print(gai.get(i) + "   ");
        }
        System.out.println();
        try {
            Integer[] ia = gai.rep();
        } catch (Exception e) {
            System.err.println(e);
        }
    }
}(ー `´ ー)

运行结果如下:
运行结果6
初看起来,这好像没有多大变化,只是转型挪了地方。但是,现在的内部表示是Object[]而不是T[]。当get()被调用时,它会将对象转型为T,这实际上是正确的类型,因此这是安全的,然而你调用rep(),它还是尝试着将Object[]转型为T[],这仍旧是不正确的,在运行时产生异常。因此,没有任何方式可以推翻底层的数组类型,它只能是Object[]。在内部将array当作Object[]而不是T[]处理的优势是:我们不太可能忘记这个数组的运行时类型,从而意外地引入缺陷(尽管大多数也可能是所有这类缺陷都可以在运行时快速地探测到)。

对于新代码,应该传递一个类型标记。在这种话情况下,GenericArray看起来会像下面这样:

/**
 * @Author: Tony Peng
 **/
public class GenericArrayWithTypeToken<T> {
    private T[] array;

    public GenericArrayWithTypeToken(Class<T> type, int sz) {
        array = (T[]) Array.newInstance(type, sz);
    }

    public void put(int index, T item) {
        array[index] = item;
    }

    public T get(int index) {
        return array[index];
    }

    public T[] rep() {
        return array;
    }

    public static void main(String[] args) {
        //这样就能运行了
        GenericArrayWithTypeToken<Integer> gai = new GenericArrayWithTypeToken<Integer>(Integer.class, 10);
        Integer[] ia = gai.rep();
    }
}(lll¬ ω ¬)

类型标记Class<T>被传递到构造器中,以便从擦初中恢复,使得我们可以创建需要的实际类型的数组。一旦我们获得了实际类型,就可以返回它,并获得想要的结果,就像在main()中看到的那样。该数组的运行时类型是确切类型T[]。

遗憾的是,如果查看Java SE5标准类库中的源代码,你就会看到从Object数组到参数化类型的转型遍及各处。例如,下面是经过整理和简化后的从Collection中复制ArrayList的构造器:

    public ArrayList(Collection c) {
        size = c.size();
        elementData = (E[])new Object[size];
        c.toArray(elementData);
    }

如果你通读ArrayList.java,就会发现它充满了这种转型。

Neal Gafter(Java SE5的领导开发者之一)在他的博客中指出,在重写Java类库时,他十分懒散,而我们不应该像他那样。Neal还指出,在不破坏现有接口的情况下,他将无法修改某些Java类库代码。因此,即使在Java类库源代码中出现了某些惯用法,也不能表示这就是正确的解决之道。当查看类库代码时,你能认为它就是应该在自己代码中遵循的示例。

三、结语

以上内容均参考自《Java编程思想》这本Java圣经。

这次边工作边学习,写这篇大概用了一个礼拜的时间,大概把以前关于擦除相关知识的模糊点给补上了。

即使自己有一定的工作经验了,基础知识仍还有很多需要加强的地方,虽然工作也能学习,因时间紧迫,所获得的知识笼统,只知其一不知其二,只有遇到问题时才深入研究,但知识体系紧锣密鼓,一环扣一环,代码学习之路渊远流长,工作之余不忘学习。

学习之初,出于好奇。
工作之初,出于热情。
莫忘初心,持之以恒。 ♪ ( ^ ∇ ^ * )
在这里插入图片描述

;