coding……
但行好事 莫问前程

Java编程拾遗『泛型——基本概念』

泛型是比较常见的一种类型,在Java API容器类以及一些工具类中,都有很多应用。泛型是Java 5中新增的一种类型机制,用于满足在1999年指定的最早的Java规范之一(JSR 14)。使用泛型机制写的程序代码要比那些杂乱地使用Object变量然后再进行强制类型转换的代码,具有更好的安全性可读性。泛型对于集合类尤其有用,甚至可以讲,Java泛型的引入也是为了更好的支持Java容器。

1. 为什么要使用泛型程序设计?

“泛型”的意思就是广泛的类型,类、接口和方法代码不再与具体类型绑定在一起,可以应用于非常广泛的类型,同一套代码,可以用于多种数据类型。在Java中增加泛型类之前,泛型程序设计是用继承实现的,比如ArrayList类就维护了一个Object引用的数组:

//before generic classes
public class ArrayList {

	private Object[] elementData;
	……
	
	public Object get(int i) {
		……
	}

	public void add(Object o) {
		……
	}
}

这样实现有两个问题,当获取一个值时必须进行强制类型转换。

ArrayList files = new ArrayList();
String fileName = (String) files.get(0);

此外,这里没有错误检查,可以向数组列表中添加任何类的对象:

files.add(new File("……"));

这个调用,编译和运行都不会出错。但是在其他地方,如果将get的结果强制转换成Stirng类型,就会产生一个错误。这里很明显违背了一个原则,就是错误要尽早的发现,最好编译期就发现。如上错误,隐藏到运行期才发现,这很明显是不安全的。还有ArrayList作为一个容器,对外并没有表现出具体是哪种类型的容器,可读性也不好。

而泛型提就是提供一种机制,使程序代码应用于广泛的类型时,可以更安全,也具有更好的可读性。比如Java 5之后Java API的ArrayList类,就可以使用类型参数,来指示容器中元素的类型:

ArrayList<String> files = new ArrayList<String>();

类型参数带来的第一点好处就是更好的可读性,一看就知道列表中存储的是String对象。另外,如果向上面那样,向泛型files列表中添加一个file对象,在编译期就会报错,不会将错误隐藏到运行期,也就是具有更高的安全性。由于可以保证泛型中只会存在一种类型的对象,所以在取数据时,就避免了强制类型转换的环节(事实上是泛型类内部处理了),使用起来也更加方便。

注:Java 7之后,泛型类构造函数中可以省略泛型类型,省略的类型可以从变量的类型中推断得出。

ArrayList<String> files = new ArrayList<>();

2. 泛型类

泛型类就是指,具有一个或多个类型变量的类。如下定义一个类pair,表示任意类型的一对数据:

public class Pair<T> {

    private T first;
    private T second;

    public Pair() {

    }

    public Pair(T first, T second) {
        this.first = first;
        this.second = second;
    }

    public T getFirst() {
        return first;
    }

    public T getSecond() {
        return second;
    }

    public void setFirst(T first) {
        this.first = first;
    }

    public void setSecond(T second) {
        this.second = second;
    }
}

上述Pair类就是一个泛型类,与普通类相比,Pair类引入了一个类型变量T,用尖括号(<>)括起来,并放在类名的后面,T表示类型参数,泛型就是类型参数化,处理的数据类型不是固定的,而是可以作为参数传入,类型变量T在泛型类中可以用来指定方法的返回值类型成员变量局部变量的类型。类中的成员变量first、second都是T类型的。泛型类中可以有多个类型变量,比如Pair类中,第一个成员变量和第二个成员变量使用不同类型:

public class Pair<T, U> {

    private T first;
    private U second;

    ……
}

泛型类使用时,用具体的类型替换类型变量就可以实例化泛型类型。比如当使用Pair<String>时,可以将泛型类想象成一个普通类,如下:

//成员变量
private String first;
private String second;

//构造函数
public Pair()
public Pair(String first, String second)

//getter and setter
public String getFirst()
public String getSecond()
public void setFirst(String first)
public void setSecond(String second)

换句话说,可以将泛型类看作普通类的工厂。下面用一个例子,来展示一下Pair泛型类的使用,自定义一个工具类ArrayAlg,其中有一个minMax方法,用于获取一个String数组的最大最小值,最大最小值用Pair存储,如下:

public class ArrayAlg {

    public static Pair<String> minMax(String[] array) {
        if (array == null || array.length == 0) {
            return null;
        }

        String min = array[0];
        String max = array[0];
        for (int i = 1; i < array.length; i++) {
            if (min.compareTo(array[i]) > 0) {
                min = array[i];
            }

            if (max.compareTo(array[i]) < 0) {
                max = array[i];
            }
        }
        return new Pair<>(min, max);
    }
    
}

但是对于一个工具类来说,只能用于获取String数组的最大最小值,看起来好像是不符合”标准”的,也就是讲minMax方法可以用来获取任意类型数组中的最大最小值,这个在后面的泛型方法中仔细讲述。

3. 泛型方法

前面讲了如何定义一个泛型类,其实在Java中,也可以定义泛型方法,用于处理多个类型数据,一个方法是不是泛型类,跟它所处的类是不是泛型类没有任何关系。只要在方法声明中存在类型变量声明,那么这个方法就是泛型方法。比如,在之前的工具类ArrayAlg中,我要新加一个方法,用于获取一个数组的中间位置的元素,这个数组的类型是不定的,这时候就可以使用泛型方法,如下:

public static <T> T getMiddle(T[] array) {
    if (array == null || array.length == 0) {
        return null;
    }
    
    return array[array.length / 2];
}

方法声明中存在泛型变量声明,所以该方法是个泛型方法,并且是定义在普通类中的一个泛型方法。另外要注意的是,类型变量要放在修饰符(这里是public static)后,返回值类型前。当调用一个泛型方法时,需要在方法名前的尖括号中传入具体类型,如下:

String[] array = new String[]{"A", "B", "C", "D"};
String middle = ArrayAlg.<String>getMiddle(array);

但是这种情况下(大多数情况下),方法调用时可以省略<String>类型参数,编译器有足够的信息能够推断出所调用的方法,根据传入的array类型为String可以判断出T一定是String。也就是说,可以这样调用:

String[] array = new String[]{"A", "B", "C", "D"};
String middle = ArrayAlg.getMiddle(array);

下面回到之前讲可以通过泛型方法设计一个通用的获取所有类型数组最大最小值的方法,如下:

public static <T extends Comparable<T>> Pair<T> minMax(T[] array) {
    if (array == null || array.length == 0) {
        return null;
    }

    T min = array[0];
    T max = array[0];
    for (int i = 1; i < array.length; i++) {
        if (min.compareTo(array[i]) > 0) {
            min = array[i];
        }

        if (max.compareTo(array[i]) < 0) {
            max = array[i];
        }
    }
    return new Pair<>(min, max);
}

对于”<T extends Comparable<T>>“这里特殊讲一下,Comparable<T>是接下来要讲的泛型接口,<T extends Comparable<T>>是一个递归类型限制,可以解读为T表示一种数据类型,必须实现Comparable接口,且必须可以与相同类型的元素进行比较(因为方法中T类型对象要使用comPareTo方法,如果不限定的话,T就是Object,是无法解析compareTo方法的)。

4. 泛型接口

Java中,接口也可以是泛型的,比如Java API中的Comparable和Comparator接口都是泛型接口,如下:

public interface Comparable<T> {
    public int compareTo(T o);
}
public interface Comparator<T> {
    int compare(T o1, T o2);
    boolean equals(Object obj);
}

实现接口时,应该指定具体的类型,比如,对Integer类,如下:

public final class Integer extends Number implements Comparable<Integer>{
    public int compareTo(Integer anotherInteger) {
        return compare(this.value, anotherInteger.value);
    }
    //...
}

通过implements Comparable<Integer>,Integer实现了Comparable接口,指定了实际类型参数为Integer,表示Integer只能与Integer对象进行比较。

5. 类型变量的限定

使用泛型时,如果无特殊说明,对于类型变量,我们会默认其为Object类型。但是像在泛型方法中讲的minMax方法,泛型的对象要使用某个特殊的方法(比如compareTo),那么Object类型肯定是不行的。所以Java中,支持给泛型变量指定一个上界,对类型变量加以约束,这样我们在处理泛型变量时,就会把它当作指定的上界类型,而不是Object类型。这个上界是通过extends这个关键字来表示的,并且上界可以是某个具体的类,或者某个具体的接口,也可以是其他的类型参数

5.1 上界是具体类

比如,上面的Pair类,可以定义一个子类NumberPair,限定类型变量必须为Number或其子类型,如下:

public class NumberPair<U extends Number> extends Pair<U> {

    public NumberPair(U first, U second) {
        super(first, second);
    }
}    

限定类型后,就可以使用该类型的方法了,比如说,对于NumberPair类,first和second变量就可以当做Number进行处理了,可以定义一个求和方法,如下:

public double sum(){
    return getFirst().doubleValue() + getSecond().doubleValue();
}

限定类型后,如果类型使用错误,编译器会提示,可以这样使用NumberPair:

NumberPair<Integer> pair = new NumberPair<>(1, 2);
double sum = pair.sum();

NumberPair泛型类型指定为Integer,如果指定为String就会编译报错,因为String并不是Number的子类。

5.2 上界是接口

类型变量上界除了是具体的类之外,还可以是某个接口,比如在泛型方法中讲的minMax方法的类型变量,上界为Comparable<T>,表示上界是一种数据类型,必须实现Comparable接口,且必须可以与相同类型的元素进行比较。

public static <T extends Comparable<T>> Pair<T> minMax(T[] array) {
    if (array == null || array.length == 0) {
        return null;
    }

    T min = array[0];
    T max = array[0];
    for (int i = 1; i < array.length; i++) {
        if (min.compareTo(array[i]) > 0) {
            min = array[i];
        }

        if (max.compareTo(array[i]) < 0) {
            max = array[i];
        }
    }
    return new Pair<>(min, max);
}

<T extends Comparable<T>>也称为 递归类型限制。

5.3 上界是类型变量

除了上面讲的上界是某个类或者某个接口,Java中也允许上节是其他的类型变量。比如在泛型类Pair中,我想新增一个泛型方法,用于比较Pair中first和second的数值是否相等,直觉上,我们可以这样实现代码:

public boolean hasEqualContent(Pair<T> other) {
    return new BigDecimal(getFirst().toString()).compareTo(new BigDecimal(other.getFirst().toString())) == 0
            && new BigDecimal(getSecond().toString()).compareTo(new BigDecimal(other.getSecond().toString())) == 0;
}

然后我们有这样的需求,比较一个内容为Number的Pair和内容为Integer的Pair的first和second数值是否都相等。因为Integer是Number的子类,可以将Integer对象赋值给Number类型的引用。所以直觉上我们也许会觉得Pair<Integer>也可以赋值给Pair<Number>类型的引用。当面临这个需求时,我们也许会这样使用:

Pair<Number> numberPair = new Pair<>(1.0, 2);
Pair<Integer> integerPair = new Pair<>(1, 2);
System.out.println(numberPair.hasEqualContent(integerPair));

但是很不幸,上面的用法是错误的,编译报错,报错信息如下:

报错信息很明显,不能把Pair<Integer>对象赋值给Pair<Number>引用,来看一下原因:

Pair<Number> numberPair = new Pair<>();
Pair<Integer> integerPair = new Pair<>();

//假如下面这行是合法的
numberPair = integerPair;

/*numberPair引用地址跟IntegerPair引用地址一致
修改numberPair的firs会同步影响到integerPair
造成一个结果—integerPair中存入了一个浮点数*/
numberPair.setFirst(1.0);

很明显这样破坏了泛型类型安全的保证,所以不能把Pair<Integer>对象赋值给Pair<Number>类型的引用。

这里强调一下,虽然Integer是Number的子类,但是Pair<Integer>并不是Pair<Number>的子类,绝对不能把Pair<Integer>对象赋值给Pair<Number>类型的引用

再回到上面那个比较Pair的first和second数值的问题,无论是Pair内部数值是Number还是Integer,数值都是可比较的,比较Number和Integer数值的大小算得上是合理需求。也就是讲要有一种机制保证可以用一个引用变量接收Pair<Integer>对象,并且还要保持泛型的特性,这时候就可以用类型限定来解决,如下:

public <E extends T> boolean hasEqualContent(Pair<E> other) {
    return new BigDecimal(getFirst().toString()).compareTo(new BigDecimal(other.getFirst().toString())) == 0
            && new BigDecimal(getSecond().toString()).compareTo(new BigDecimal(other.getSecond().toString())) == 0;
}

T是泛型类Pair的类型变量,E是泛型方法hasEqualContent的类型变量,E的上界是T,这样使用时就没有问题了,如下:

public class GenericTest {
    public static void main(String[] args) {
        Pair<Number> numberPair = new Pair<>(1.0, 2);
        Pair<Integer> integerPair = new Pair<>(1, 2);
        System.out.println(numberPair.hasEqualContent(integerPair));
    }
}

运行结果:

true

6. 泛型代码和虚拟机

Java泛型类只是对于Java编译器的概念,Java编译器会把泛型代码转换为普通的非泛型代码,对于Java虚拟机而言,所有的对象都属于普通类,Java虚拟机是不会感知类型变量的存在的。所有的泛型类型,对于Java虚拟机来讲都有一个相应的原始类型,原始类型相当于泛型类型删掉类型参数,擦除类型变量并替换为限定类型(无限定的类型变量替换为Object)之后的形式。比如泛型类Pair<T>的原始类型如下:

public class Pair {

    private Object first;
    private Object second;

    public Pair() {

    }

    public Pair(Object first, Object second) {
        this.first = first;
        this.second = second;
    }

    public Object getFirst() {
        return first;
    }

    public Object getSecond() {
        return second;
    }

    public void setFirst(Object first) {
        this.first = first;
    }

    public void setSecond(Object second) {
        this.second = second;
    }
}

因为T是一个无限定类型变量,所以在擦除时,替换为Object。擦除的结果就是一个普通类,就像泛型引入Java之前的实现那样。程序中可以包含很多种类型的Pair,比如Pair<Stirng>,Pair<Integer>,擦除类型后就变成原始的Pair类型了。

对于无限定的类型变量,擦除类型变量时会替换成Object。对于有限定的类型变量,擦除类型变量时会使用第一个限定的类型变量来替换。如下:

public class Interval<T extends Comparable<T> & Serializable> implements Serializable {
	private T lower;
	private T upper;

	//……
	
	public Interval(T first, T second) {
		if (first.compareTo(second) <=0) {
			this.lower = first;
			this.upper = second;
		} else {
			this.lower = second;
			this.upper = first;
		}
	}
}

原始类型如下:

public class Interval implements Serializable {
	private Comparable lower;
	private Comparable upper;

	//……
	
	public Interval(Comparable first, Comparable second) {
		//……
	}
}

如果上述类型限定为:class Interval<T extends Serializable & Comparable>,那么变量擦除时,会使用Serializable替换T,所以从Java虚拟机中拿到的结果肯定都是Serializable类型的,这时候就会留给编译器很多强制类型转换的工作。比如:Comparable tmp =  interval.getLower(),如果Serializable限定在前的前提下,编译器要完成将Serializable对象强转为Comparable的过程。

6.1 泛型表达式

通过上的分析,可以知道,我们使用泛型时,传入的具体类型是不被Java虚拟机感知的,由于泛型擦除的机制,当我们调用泛型类中的方法,如果返回类型是类型变量,虚拟机只能给出被擦除后的类型结果。比如我们上面的示例,泛型类Pair<T> 中有一个方法:

public T getFirst() {
	return this.first;
}

擦除后的方法如下:

public Object getFirst() {
	return this.first;
}

当我们如下使用时:

Pair<String> stringPair = new Pair<>("this", "is");
String first = stringPair.getFirst();

会发现一些问题,因为stringPair.getFirst方法返回的时Object类型的变量,但是first却是String类型的引用。如果没有强制类型转换特殊处理,这样的赋值肯定是不允许的。其实之所以能这么赋值,是因为对于泛型类中的方法,如果返回擦除后的类型,编译器会插入强制类型转换的逻辑。所以stringPair.getFirst()这个方法调用其实对应两条虚拟机指令:

  • 对擦除后的原始方法getFirst()调用
  • 将返回的Object类型强制转换为String类型

6.2 类型擦除与多态(运行时)

之前的文章Java编程拾遗『对象和类』讲过,Java中的多态分为运行时分为编译时多态(重载)和运行时多态(覆盖)。下面我们来看一下运行时多态的概念:

Java中,基类的引用变量不仅可以指向基类的实例对象,也可以指向其子类的实例对象。同样,接口的引用变量也可以指向其实现类的实例对象。当使用接口(基类)的引用变量进行方法调用时,并不是直接调用父类的方法,而是在方法运行时,通过引用变量的具体指向,决定具体方法调用(指向子类实例对象,那么调用具体子类的方法;指向父类实例对象就调用父类的方法,如果子类中没有覆盖父类该方法,会调用父类的方法。)。也就是方法在程序运行才动态绑定,因此通过覆盖实现的多态也被称为运行时多态。

看一个例子:

public class Father {
    public void method(Object object) {
        System.out.println("Father method, parameter is :" + object);
    }
}

父类中有一个方法method,参数为Object类型。

public class Son extends Father {

    public void method(Date date) {
        System.out.println("Son overload method, parameter is :" + date);
    }
}

子类Son继承父类,子类中有一个方法参数类型为Date的method,根据覆盖的定义可知,子类method没有覆盖父类method(因为方法签名不同)。下面通过如下方式调用:

public class GenericTest {
    public static void main(String[] args) {

        Father son = new Son();
        son.method(new Date());
    }
}

新建一个子类对象,并赋值给父类的引用。这时候通过父类引用调用method,看一下运行结果:

Father method, parameter is :Fri Nov 09 09:54:32 CST 2018

说明如果子类中没有覆盖父类方法,通过父类引用调用方法时,最终会执行父类的方法。下面看一下继承泛型类,并希望重写父类方法时的情况:

public class DateInterval extends Pair<Date> {
    @Override
    public void setSecond(Date second) {
        if (second.compareTo(getFirst()) >= 0) {
            super.setSecond(second);
        }
    }
}

子类DateInterval继承自Pair<Date>,并在子类中重写了父类setSecond方法,确保第二个值不小于第一个值。直觉上是没什么问题的,但是如果考虑到泛型的擦除,好像就会有问题,因为对于Java虚拟机来讲,Pair<Date>类是下面这个样子:

public class Pair {

    private Object first;
    private Object second;

    //……

    public Object getFirst() {
        return first;
    }

    public Object getSecond() {
        return second;
    }

    public void setFirst(Object first) {
        this.first = first;
    }

    public void setSecond(Object second) {
        this.second = second;
    }
}

像上面讲的例子,setSecond参数是Object类型的,DateInterval中的setSecond方法并没有覆盖父类的setSecond方法,如果没有经过特殊处理,肯定是无法达到多态的效果的。

Pair<Date> dateInterval = new DateInterval();
dateInterval.setSecond(new Date());

如果没有特殊处理,这样的调用是调用不到子类的setSecond方法的,只能调用到父类的setSecond方法,达不到多态的效果。但是同时可以看到子类中的setSecond(Date date)方法上标注了@Overide,父类引用变量也只能看到setSecond(Date date)这一个方法,说明子类中setSecond(Date date)确实又覆盖了父类的方法。

上面的例子中,子类没有覆盖父类的方法,通过父类的引用变量可以看到两个方法。

泛型的情况下,父类的引用变量只能看到一个方法,对比一下可以确认,子类的setSecond方法确实覆盖了父类的方法。那么到底是通过什么形式实现覆盖的?

其实是因为Java编译器为我们生成了一个桥方法,如下:

public void setSecond(Object second) {
    setSecond((Date) second);
}

新增了这样一个桥方法,当执行dateInterval.setSecond(new Date())时,通过多态的特性,会执行子类的setSecond(Object second)方法,这个方法中又调用了子类的setSecond(Date date)方法,达到执行子类”重写”的方法的效果。所以总结起来就是,泛型擦除,有可能会与多态冲突,这时候Java编译器会自动为我们生成一个桥方法,实现多态。下面我们通过反编译class文件,来证实一下桥方法的存在。

首先在子类中覆盖父类的setSecond和getSecond方法:

public class DateInterval extends Pair<Date> {
    @Override
    public void setSecond(Date second) {
        if (second.compareTo(getFirst()) >= 0) {
            super.setSecond(second);
        }
    }

    @Override
    public Date getSecond() {
        return super.getSecond();
    }
}

反编译:

可以看到Java编译有两个setSecond方法,其中一个是编译器为我们生成的桥方法。另外需要注意的是,对于getSecond,编译器也为我们生成了一个桥方法,但是却产生一个结果:一个类中有两个方法签名完全一样的方法:

public Date getSecond();
public Object getSecond();

在正常Java编码中,是不允许这样写的,会编译报错。但是在Java虚拟机中,用参数类型和返回值类型确定一个方法,因此编译器可能产生两个仅返回值类型不同的方法字节码,但是虚拟机能够正确的处理这种情况。对于Java中的泛型,需要知道:

  • Java虚拟机中没有泛型,只有普通类和方法
  • 所有的类型参数,都用它们的限定类型替换(没有限定类型替换为Object)
  • Java编译器会生成桥方法用于保持多态的特性
  • 使用泛型时,并不需要显示强制类型转换,因为这一过程,被Java编译器实现了

参考链接:

  1. 《Java核心技术 卷Ⅰ》
  2. 《Java编程的逻辑》
  3.  java 泛型详解-绝对是对泛型方法讲解最详细的,没有之一
  4. Java泛型详解
  5. java 泛型的类型擦除与桥方法

赞(0) 打赏
Zhuoli's Blog » Java编程拾遗『泛型——基本概念』
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址