05_generic_03_type_erasure

第五十九章 类型擦除(Type Erasure)

1 简介

类型擦除(Type Erasure)是Java语言为了实现泛型编程而引入的概念。它在为Java语言提供便利的同时,也给开发人员带来了不少的困惑。所以本章着重介绍Type Erasure的概念、应用场景以及对开发人员的影响。

2 泛型编程(Generic Programming)

泛型编程的设计初衷是为了代码复用(Code Reuse)。在开发过程中,常常会遇到同一逻辑应用于不同类型变量的场景。在下面的例子中,比较两个对象是否相等的逻辑可以应用于比较两个整数,也可以应用于比较两个浮点数。那么,如果没有泛型编程的支持,开发人员不得不为每一种数据类型提供一个函数,如下面的代码所示。注意,泛型编程不支持基本数据类型(Primitive Data Type)。也就是说,在本章的例子中,不能使用int代替Integer,或者double代替Double。

public class NumericComparator {
    public static Boolean equals (Integer x, Integer y) {  // 本例不能使用int代替Integer
        return x.equals(y);
    }
    
    public static Boolean equals (Double x, Double y) {  // 本例不能使用double代替Double
        return x.equals(y);
    }
    
    public static void main(String[] args) {
        System.out.println(equals(1, 2));  // 打印 false
        System.out.println(equals(1.5, 2.3));  // 打印 false
    }
}

从上述代码可以看出,两个函数的逻辑是一致的。通过Integer.equals()和Double.equals()方法比较x与y的值。唯一不同的是两个函数的输入参数的类型是不同的。因此,为了复用这一逻辑,Java语言支持泛型编程,即:函数的输入参数的类型是可变的。具体的类型需根据函数调用的场景来推导。例如:

public class NumericComparator {
    public static <T> Boolean equals (T x, T y) {
        return x.equals(y);
    }
	
	public static void main(String[] args) {
	    System.out.println(equals(1, 2));  // 打印 false
		System.out.println(equals(1.5, 2.3));  // 打印 false
	}
}

在上面的代码中,函数equals既可以应用于两个整数,也可以应用于两个浮点数。那么,Java是如何实现泛型编程的呢?

3 类型擦除(Type Erasure)

Java是采用类型擦除(Type Erasure)实现泛型编程的。当上述的代码经编译器编译后,编译器会得到如下的等效代码。可以看出,参数x与y的类型被擦除了,用以Object代替,因为所有的对象都是继承自Object类的,所以,equals方法适用于所有的对象。这种擦除泛型对象类型的过程被称为Type Erasure。显然,这样的好处是,Java编译器得到了极大的简化,它不需要扫描所有代码以获得泛型类型T的真实类型。但是,在执行Type Erasure过程之前,编译器还是需要检查参数类型的。

public class NumericComparator {
    public static Boolean equals (Object x, Object y) {
        return x.equals(y);
    }
	
	public static void main(String[] args) {
	    System.out.println(equals(1, 2)); // 打印 false
        System.out.println(equals(1.5, 2.3); // 打印 false
	}
}

另外,开发人员还可以为泛型类型设置上界或者下界。例如,在下面的代码中,传入的参数只能是Number类或者其子类。所以,在Type Erasure过程中,Java编译器会使用Number类来替代泛型类T。

public class NumericComparator {
    public static <T extends Number> Boolean equals (T x, T y) {
        return x.equals(y);
    }
}

上述代码会被编译成下面的等效代码。

public class NumericComparator {
    public static Boolean equals (Number x, Number y) {
        return x.equals(y);
    }
}

如果泛型参数是数组呢?Type Erasure过程会将其转换为Object[]。如果类型参数T是泛型类呢?Type Erasure过程会将G<T>擦除为G,以此类推。所以,在如下代码的main函数中,能够在NumericComparator类中查找到函数名称为equals,入参是两个Object[]类型的对象或者两个List类型的对象。

import java.util.List;
import java.util.Arrays;
import java.lang.reflect.Method;

public class NumericComparator {
    public static <T> Boolean equals (T[] x, T[] y) {
        return x.equals(y);
    }

    public static <T> Boolean equals (List<T> x, List<T> y) {
        return x.equals(y);
    }

    public static void main(String[] args) {
        try {
            Method equalsMethod = NumericComparator.class.getMethod("equals", Object[].class, Object[].class);
            System.out.println(equalsMethod.getName());  // 打印 equals
            equalsMethod = NumericComparator.class.getMethod("equals", List.class, List.class);
            System.out.println(equalsMethod.getName());  // 打印 equals
        } catch (Exception ex) {}
	}
}

4 泛型函数中获取类型信息

Type Erasure过程主要是帮助编译器适应泛型函数或者泛型类的各种不同类型的参数。在泛型函数或者泛型类中,类型参数被擦除了,但是,对象的真实类型并没有改变。所以,仍然可以在运行时获得对象的类型。

import java.util.List;
import java.util.Arrays;

public class NumericComparator {
    public static <T> Boolean equals (T x, T y) {
        System.out.println(x.getClass().getName()); // 打印 java.lang.Integer
        return x.equals(y);  
    }

    public static <T> Boolean equals (List<T> x, List<T> y) {
        System.out.println(x.getClass().getName()); // 打印 java.util.Arrays$ArrayList
        return x.equals(y);  
    }
	
	public static void main(String[] args) {
        System.out.println(equals(1, 2)); // 打印 false
        System.out.println(equals(Arrays.asList(1, 2), Arrays.asList(1, 2))); // 打印 true
	}
}

但是,在上述第二个equals方法中,我们只能得到x和y的类型是Arrays类的一个内部类ArrayList,而无法获得类型T的真实类型。一个直接而有效的获取T的类型的方法是,方法的调用者需要额外的提供T的Class对象。这是由Type Erasure概念引入的一个问题。如下代码将equals函数修改为三个输入参数,最后一个参数为T的Class对象。(提示:在Java虚拟机中,每个类都有一个Class对象来描述这个类的元数据,例如:Class对象包含了该类包含的成员变量、成员方法、继承关系等信息。)

import java.util.List;
import java.util.Arrays;

public class NumericComparator {
    public static <T> Boolean equals (List<T> x, List<T> y, Class cls) {
        System.out.println(cls.getName()); // 打印 java.lang.Integer
        return x.equals(y);  
    }
	
	public static void main(String[] args) {
        System.out.println(equals(Arrays.asList(1, 2), Arrays.asList(1, 2), Integer.class)); // 打印 true
	}
}

5 桥方法(Bridge Method)

当继承一个泛型类时,Type Erasure过程也会给我们带来一些麻烦。如下例所示,NumericHolder是一个泛型基类。它有一个泛型成员变量obj。开发人员可以通过setData()方法设置成员变量obj。IntegerHolder继承自NumericHolder。IntegerHolder覆盖了基类的方法setData()。

public class NumericHolder <T> {
    protected T obj = null;
	
	public void setData(T obj) {
        this.obj = obj;
	}
}

public class IntegerHolder extends NumericHolder<Integer> {
    @Override
    public void setData (Integer obj) {
        super.setData(obj);
    }
}

如前文所述,经编译器编译后,在Type Erasure过程中,NumericHolder<T>会被转换为NumericHolder<Object>。这给我们带来了一个问题,即IntegerHolder.setData()不再覆盖NumericHolder.setData()方法,因为在转换后,两个方法有着不同的输入参数类型;一个是Integer,另一个是Object。

public class NumericHolder <Object> {
    protected Object obj = null;
	
	public void setData(Object obj) {
        this.obj = obj;
	}
}

public class IntegerHolder extends NumericHolder<Integer> {
    public void setData (Integer obj) {
        super.setData(obj);
    }
}

为了解决这个问题,Java编译器会在子类中自动生成一个桥方法(Bridge Method),其方法签名(Method Signature)与基类中的方法保持一致。因此,编译器生成的IntegerHolder类的等效代码如下所示。IntegerHolder.setData(Object)是自动生成的桥方法。它像是一座桥一样,将所有调用IntegerHolder.setData(Object)的逻辑全部转到IntegerHolder.setData(Integer)上。这样达到了两个目的。其一,当使用多态特性时,子类IntegerHolder覆盖了基类的NumericHolder的setData(Object)方法,因此,调用setData(Object)会触发多态机制。其二,当使用的对象与传入的参数不匹配时,程序会抛出异常(在IntegerHolder.setData(Object)方法中的强制类型转换处)。

在大多数情况下,桥方法的存在不会引起开发人员的注意,它可能仅仅只是默默的存在于调用栈中。

public class IntegerHolder extends NumericHolder<Integer> {
    @Override
    public void setData(Object obj) { // 桥方法
        setData((Integer)obj);
    }
    
    public void setData (Integer obj) {
        super.setData(obj);
    }
}

6 Reifiable Types

在Java程序编译的过程中,类型擦除过程可能会"擦除"一些类型,因此,在运行时,那些被"擦除"的类型是不会出现在运行的程序中的。由此,Java语言出现了一个新的概念Reifiable Types。Reifiable Types是指那些会出现在运行时的数据类型;换句话说,Reifiable Types的类型不会被类型擦除过程"擦除"。

Reifiable Types包括:1) 非泛型的类或者接口;2) 带参数的类型,但是参数是未绑定限制的类型;3) 基本数据类型;4) 数组类型,且每个元素也是Reifiable Types类型的数据。

7 结语

Type Erasure是Java实现泛型编程的一个重要概念。因为在Type Erasure过程中,具体的类型会被转换为抽象的类型,因此,转换后所得到的函数/类能适应各种场景。然而,Type Erasure的过程中,函数的签名被修改了,所以,Type Erasure也给函数重载、函数覆盖和多态机制带来不少问题。在开发过程中,开发人员需要多留意泛型函数或者泛型类中类型参数的变化,以及对程序的影响。我们将会在下一章详细讲解Type Erasure对函数重载与覆盖的影响。

上一章
下一章

注册用户登陆后可留言

Copyright  2019 Little Waterdrop, LLC. All Rights Reserved.