coding……
但行好事 莫问前程

Java编程拾遗『泛型——通配符&约束』

1. 泛型通配符

固定的泛型类型系统使用起来不是一直那么方便,比如Pair<Integer>并不是Pair<Number>的子类型,所以不能将Pair<Integer>对象赋值给pair<Number>类型的引用。假如在方法定义时指定了某一种泛型类型,那么就只能使用该类型的泛型对象了,使用起来不是很方便。所以Java泛型中引入通配符类型(仍是安全的),来解决这一问题。

1.1 通配符的子类型限定

上篇文章在讲泛型上界是某个具体变量时,为了解决可以将Pair<Integer>对象赋值给Pair<Number>类型的引用这一问题,写了这么个方法:

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;
}

这种方式通过重新声明类型变量E,达到非固定的泛型类型的效果(E可以是T的任何子类型,比如Pair<Integer>,E可以是Number任何子类)。但同时这种写法也是比较繁琐的,要先声明一个泛型变量,然后再使用。Java中有一种更加巧妙的使用使用非固定泛型类型的解决方案——通配符类型。比如通过通配符类型,上述方法可以这样定义:

public boolean hasEqualContent(Pair<? extends 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;
}

Pair<? extends T>表示任何泛型Pair类型,它的类型参数是传入的T的子类。比如T传入的是Number,即Pair<Number>,那么Pair<? extends T>可以是Pair<Number>、Pair<Double>,但不能是Pair<String>。通过这样的通配符类型限定,达到使用非固定通配符的效果。可以如下方式调用:

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));
    }
}

这里T是Number类型,那么Pair<? extends T>可以匹配任何类型参数是Number子类的泛型Pair类型,那么Pair<Integer>肯定也不再话下了。下面总结一下<E extends T>和<? extends T>的区别:

  • <T extends E>用于定义类型变量,它声明了一个类型变量T,可放在泛型类定义中类名后面、泛型方法返回值前面。
  • <? extends E>用于实例化类型参数,它用于实例化泛型变量,?是个具体的类型,只是这个具体类型是未知的,只知道它是E或E的某个子类型。

虽然两种写法不一样,但可以达成相同目标,比如,前面例子中,下面两种写法都可以:

public <E extends T> boolean hasEqualContent(Pair<E> other);
public boolean hasEqualContent(Pair<? extends T> other);

一般能使用泛型通配符解决的,通过类型变量也同样能实现,但是通配符类型更加简洁,可读性也比较好。

下面来看一个问题,使用Pair<? extends Number>引用指向Pair<Integer>会破坏Pair<Integer>吗?

Pair<Integer> integerPair = new Pair<>(1, 2);
Pair<? extends Number> extendNumberPair = integerPair; //OK
extendNumberPair.setFirst(3.33); //编译报错

对于setFirst方法的调用会有一个编译错误(setSecond方法一样),所以不会破坏Pair<Integer>。反过来想,如果允许setFirst操作,那么将允许把extendNumberPair的first设置为任何Number的子类型,同时因为extendNumberPair和integerPair指向同一内存空间,那么对extendNumberPair的修改可能会使integerPair中出现非Integer类型的成员,破坏了泛型的安全性,所以干脆禁止对通配符泛型的写操作。要了解其中的原因,可以看一下类型Pair<? extends Number>,其方法似乎是这样的:

? extends Number getFist();
void setFirst(? extends Number);

这样将不可能调用setFirst方法,因为编译器只知道setFirst方法的参数是Number的某个子类型,但不知道具体是什么类型,所以拒绝传递任何特定的类型。但是用getFirst操作就不会出现这个问题,因为getFirst的返回值赋值给Number类型的引用是完全合法的。也就造成一个现象:子类型限定通配符是只读的

子类型限定通配符之间的关系如下所示:

1.2 通配符的超类型限定

上节讲的通配符的子类型限定跟上篇文章讲的类型变量的限定十分相似,除此之外,泛型通配符还有一个附加能力,即可以指定一个超类型限定,如下所示:

? super E

超类型限定跟之前讲的子类型限定完全相反,表示匹配E的某个父类型。上面讲过子类型限定通配符是只读的,下面看一下超类型限定通配符的读写特性。首先在Pair类中添加一个copyTo方法,用于讲当前Pair对象拷贝到另一个Pair对象中,如下:

public void copyTo(Pair<T> destPair) {
    destPair.setFirst(this.getFirst());
    destPair.setSecond(this.getSecond());
}

可以通过如下方式调用:

Pair<Integer> srcIntegerPair = new Pair<>(1, 2);
Pair<Integer> destIntegerPair = new Pair<>();
srcIntegerPair.copyTo(destIntegerPair);

因为方法类型变量时T,所以方法只能实现实现,将一个特定类型的Pair对象拷贝到另一个相应类型的Pair。当我们如下调用时,肯定会编译报错:

Pair<Number> numberPair = new Pair<>();
Pair<Integer> integerPair = new Pair<>(1, 2);
integerPair.copyTo(numberPair); //编译报错

原因很简单,期望的参数类型是Pair<Integer>,Pair<Number>并不适用。但是这种需求也是合理的,用一个可以存放Number的Pair存放Integer对象是合法的操作,如果实现这种操作,就可以使用通配符的超类型限定,如下:

public void copyTo(Pair<? super T> destPair) {
    destPair.setFirst(this.getFirst());
    destPair.setSecond(this.getSecond());
}

可以看到,跟子类型限定不同的是,超类型限定的destPair是支持set操作的。下面来简单分析一下,为什么超类型限定支持写操作?

Pair<Integer> integerPair = new Pair<>(1, 2);
Pair<? super Integer> superIntegerPair = integerPair; //OK
superIntegerPair.setFirst(3); //OK
Object first = superIntegerPair.getFirst(); //OK, 但是只能用Object接收
System.out.println();

使用Pair<? super Integer>类型的引用,来接收Pair<Integer>对象肯定是没问题的,因为<? super Integer>匹配的就是Integer或者Integer的父类型。所以经过赋值操作后,integerPair和superIntegerPair会指向同一个地址,superIntegerPair在进行set操作时,由于类型限定,只允许set Integer或者Integer的父类型的对象,所以在取数据时,肯定可以安全的转换为Integer(不会报ClassCastException),不会破坏泛型的类型安全,所以超类型限定通配符是可写的。在进行读取时,由于返回值类型为<? super Integer>(Integer或Integer的父类型),所以无法确认具体用什么类型的引用接收,只能使用Object类型的引用来接收结果,所以超类型限定通配符也是“可读”的,只是对返回值类型有限定

通过上述示例,可以发现超类型限定通配符有个很明显的好处:可以将子类对象写入到父类型的泛型类对象中。除此之外,超类型限定通配符还有一个比较常见的用法,用于泛型接口Comparable的类型限定,这样做的好处是是比较方法更加灵活。回顾一下之前在工具类ArrayAlg中写的一个泛型方法minMax,如下:

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);
}

现在我们自定义一个类Base,并实现了Comparable接口,重写了compareTo方法,如下:

public class Base implements Comparable<Base> {
    private Integer score;

    public Base(Integer score) {
        this.score = score;
    }

    public Integer getScore() {
        return score;
    }

    @Override
    public int compareTo(Base o) {
        if (this.getScore() < o.getScore()) {
            return -1;
        } else if (this.getScore() > o.getScore()) {
            return 1;
        } else {
            return 0;
        }
    }

    @Override
    public String toString() {
        return "Base{" +
                "score=" + score +
                '}';
    }
}

然后定义一个继承自Base的子类Child,并希望复用父类的比较规则(未重写compareTo方法),如下:

public class Child extends Base {
    public Child(int score) {
        super(score);
    }
}

调用工具类ArrayAlg的minMax方法,获取Base数组的最大最小值,如下:

public static void main(String[] args) {
    Base base0 = new Base(3);
    Base base1 = new Base(5);
    Base base2 = new Base(1);
    Base[] bases = new Base[]{base0, base1, base2};
    Pair<Base> baseMinMaxPair = ArrayAlg.minMax(bases);
    System.out.println(baseMinMaxPair);
}

运行结果:

Pair{first=Base{score=1}, second=Base{score=5}}

一切都是正常的,但是如果通过相同的方式获取一个Child数组的最大最小值时,就会出问题:

类型不匹配,原因就是因为通过minMax的参数类型Child[],编译器推断出T为类型为Child,声明时类型T的要求是extends Comparable<T>,而Child并没有实现Comparable<Child>,它实现的是Comparable<Base>,所以会报类型不匹配的错误,这时候可以通过超类型限定来解决这一问题,如下:

public static <T extends Comparable<? super T>> Pair<T> minMax(T[] array)

通过将泛型类型变量声明为<T extends Comparable<? super T>>,那么当T为Child类型时,因为Child实现了Comparable<Base>,符合extends Comparable<? super T>限定。通过这个示例,可以发现超类型限定通配符另一个好处:可以使父类型的比较方法可以应用于子类对象。

在讲子类型限定通配符的时候,讲过子类型通配符可以实现的,都可以通过声明类型变量实现,比如:

public boolean hasEqualContent(Pair<? extends T> other)
等价于
public <E extends T> boolean hasEqualContent(Pair<E> other)

但是超类型限定通配符没有对应的类型变量声明,没有<T super E>这种形式的限定

//合法
public void copyTo(Pair<? super T> destPair)
//但是没有下面这种形式的限定!!!
public <E super T>void copyTo(Pair<E> destPair)

超类型限定通配符之间的关系如下所示:

1.3 无限定通配符

Java中还存在一种通配符类型,无限定通配符。例如,Pair<?>。看起来,这好像与原始的Pair类型一样。实际上,有很大的不同。类型Pair<?>的方法可以想像为如下形式:

? getFirst();
void setFirst(?);

getFirst的返回值只能赋给一个Object类型的引用。setFirst方法不能被调用,甚至不能用Object调用(无限定通配符是只读的)。Pair<?>和Pair的本质区别在于:可以用任意的Object对象调用原始的Pair类的set方法。

为什么要使用这样脆弱的类型?因为它对许多简单的操作非常有用,例如,下面这个方法将用来测试一个pair是否包含一个null的引用,它不需要实际的类型。

public static boolean hasNulls(Pair<?> p) {
	return p.getFirst() == null || p.getSecond() == null;
}

通过将hasNulls转换成泛型方法,可以避免使用通配符类型:

public static <T> boolean hasNulls(Pair<T> p);

但是需要注意的是,通配符不是类型变量,在代码中,不能使用”?”作为一种类型,比如我们想写一个交换Pair元素的方法,是不能通过如下方式实现的:

public static void swap(Pair<?> p) {
	? t = p.getFirst(); // Error, 通配符?不是一个类型
	p.setFirst(p.getSecond());
	p.setSecond(t);
}

但是这种需求下,又必须定义一个临时变量,用于保存第一个元素。幸运的是,这种情况下可以通过一个辅助方法swapHelper实现,如下:

public static <T> void swapHelper(Pair<T> p) {
	T t = p.getFirst();
	p.setFirst(p.getSecond());
	p.setSecond(t);
}

swapHelper是个泛型方法,而swap不是,它具有固定的Pair<?>类型的参数。然后,就可以swap调用swapHelper:

public static void swap(Pair<?> p) {
	swapHelper(p);
}

swap可以调用swapHelper,而带类型参数的swapHelper可以写入。Java容器类中也有类似这样的用法,公共的API是通配符形式,形式更简单,但内部调用带类型参数的方法。下面总结一下通配符的使用:

  • <?>和<? extends E>用于实现更为灵活的读取,它们可以用类型参数的形式替代,但通配符形式更为简洁。
  • <? super E>用于实现更为灵活的写入和比较,不能被类型参数形式替代。

2. 泛型类的继承规则

上篇文章我们提到过,Integer是Number的子类,但是Pair<Integer>并不是Pair<Number>的子类,绝对不能把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);

也就是讲,无论S和T有什么关系,Pair<S>和Pair<T>都是没有联系的,如下所示:

但是,Java中允许将参数化类型转化为一个原始类型。比如,Pair<Integer>是Pair的一个子类型。因为泛型时Java 5引入的新特性,所以在与遗留代码衔接时,这个特性非常重要。但是会失去泛型的安全性,如下:

Pair<Integer> integerPair = new Pair<>(1, 2);
Pair pair = integerPair;
pair.setFirst(1.0); //OK,编译通过
Integer first = (Integer) pair.getFirst(); //运行时异常,ClassCastException

另外泛型类也可以扩展和实现其他泛型类,比如Java API中的ArrayList<T>实现了List<T>接口。所以,一个ArrayList<Integer>可以被转化为一个List<Integer>。但是ArrayList<Integer>并不是ArrayList<Number>的子类型,也不是List<Number>的子类型。如下所示:

3. 泛型使用的约束

泛型在使用时,有一些限制,如果不满足限制条件,就会编译报错大多数限制,都是由类型擦除引起的

3.1 不能使用基本类型实例化类型参数

类型擦除之后,类型变量会被替换为限定类型(无限定类型就替换为Object)。以Pair<T>为例,擦除之后,Pair类中存在Object类型的域,Object不能用来存储基本类型值。因此没有Pair<int>,只有Pair<Integer>。解决办法就是使用基本类型的包装类。

3.2 运行时类型检查只适用于原始类型

Java中,每个类都有一份类型信息,而每个对象也都保存着其对应类型信息的引用。这个类型信息也是一个对象,它的类型为Class,Class本身也是一个泛型类,每个类的类型对象可以通过<类名>.class的方式引用,比如String.class,Integer.class。这个类型对象也可以通过对象的getClass()方法获得,比如:

Class<?> cls = "hello".getClass();

这个类型对象只有一份,与泛型无关,所以Java不支持类似如下写法:

Pair<Integer>.class

一个泛型对象的getClass方法的返回值与原始类型对象也是相同的,比如说,下面代码的输出都是true:

Pair<Integer> p1 = new Pair<Integer>(1,100);
Pair<String> p2 = new Pair<String>("hello","world");
System.out.println(Pair.class==p1.getClass());
System.out.println(Pair.class==p2.getClass());

instanceof是运行时判断,所以,Java中也不支持如下写法:

if(p1 instanceof Pair<Integer>)

不过,Java支持这么写:

if(p1 instanceof Pair<?>)

3.3 不能创建参数化类型的数组

在Java中,不允许实例化参数化类型的数组,比如一下写法是错误的:

Pair<Integer>[] table = new Pair<Integer>[]; //Error

编译报错,为什么会这样呢,我们来分析一下原因。

上面讲过Pair<Integer>和Pair<Number>之间没有任何关系,不能将Pair<Integer>的对象赋值给Pair<Number>类型的引用。但是对于数组来讲,Integer数组对象是允许赋值给Number数组类型的引用的,数组的如下操作是允许的:

Integer[] integerArray = new Integer[10];
Number[] numberArray = integerArray;
Object[] objectArray = integerArray;

为数组是Java直接支持的概念,Java虚拟机知道数组元素的实际类型,它知道Object和Number都是Integer的父类型,所以这个操作是允许的。但是如果使用不当,会引起运行时异常,如下:

Integer[] integerArray = new Integer[10];
Object[] objectArray = integerArray;
objectArray[0] = "zhuoli"; //编译通过,运行时会报ArrayStoreException

编译时在进行类型检查时,Java编译器认为允许将String对象写入 一个Object数组中,编译通过。但是运行时,Java虚拟机知道objectArray的实际类型是Integer,所以试图写入一个String对象时会报ArrayStoreException。

如果Java允许创建泛型数组,则会发生非常严重的问题:

Pair<Integer>[] table = new Pair<Integer>[]; //假如可以创建,实际报错
Object[] objectArray = table;
objectArray[0] = new Pair<String>("zhuo", "li");

如果可以创建泛型数组table,那它就可以赋值给Object类型的数组objectArray,最后一行赋值操作,既不会引起编译错误,也不会触发运行时异常,因为Pair<Double,String>的运行时类型是Pair,和objectArray的运行时类型Pair[]是匹配的。但我们知道,它的实际类型是不匹配的,在程序的其他地方,当把objectArray[0]当做Pair<Integer进行处理的时候,一定会触发异常。也就是说,如果允许创建泛型数组,那就可能会有上面这种错误操作,它既不会引起编译错误,也不会像数组那样立即触发运行时异常,相当于埋下了一颗炸弹,不定什么时候爆发,为避免这种情况,Java干脆就禁止创建泛型数组。

需要注意的是,Java中只是不允许创建泛型数组,但是声明类型为Pair<String>[]的变量仍是合法的。只是不能用new Pair<Stirng>[10]初始化这个变量。可以通过声明通配符类型的数组,然后进行类型转换,如下:

Pair<Integer> table = (Pair<Integer>) new Pair<?>[10]; //编译通过

但是带来的一个不好的结果就是不安全,比如在table[0]中存入一个Pair<String>,然后对table[0].getFirst()调用一个Integer类方法,会得到一个ClassCastException。在Java中,如果要收集参数化类型对象,只有一种安全有效的方法:使用泛型容器,如下:

List<Pair<Integer>> integerPairList = new ArrayList<>();
integerPairList.add(new IntegerPair<>(1, 2));
integerPairList.add(new IntegerPair<>(3, 4));

在向泛型容器中添加元素时,编译期就可以保证泛型容器中元素的类型,所以之后的使用不会产生安全问题。

3.4 不能实例化类型变量

不能通过类型参数创建对象,比如,T是类型参数,下面写法都是非法的:

T elm = new T();
T[] arr = new T[10];
Class<?> clazz = T.class;

因为类型擦除,会将T替换为Object,只能创建Object类型的对象,而无法创建T类型的对象,容易引起误解,所以Java干脆禁止这么做。如果确实希望根据类型创建对象,则需要设计API接受类型对象,即Class对象,并使用Java中的反射机制构建,如下:

public static <T> Pair<T> makePair(Class<T> cl) {
	try {
		return new Pair<>(cl.newInstance(), cl.newInstance());
	} catch (Exception exception) {
		reutrn null;
	}
}

然后这样调用:

Pair<String> p = Pair.makePair(String.class);

同时也不能构造一个类型参数数组,比如如下操作是错误的:

public static <T extends Comparable> T[] minMax(T[] a) {
	T[] mm = new T[2]; //Error

	//……
}

类型擦除会让这个方法永远构造Object[2]数组。所以如果数组仅仅作为一个类的私有实例域,就可以将这个数组声明为Object[],并在获取元素时进行类型转换。比如Java API中,ArrayList就是这么实现的:

public class ArrayList<E> {
	private Object[] elements;

	@SuppressWarnings("unchecked")
	public E get(int n) {
		return (E) elements[n];
	}

	public void set(int n, E e) {
		elements[n] = e; //不需要进行转换
	}
}

但是在上述minMax方法中,如果使用Object数组,那么实现如下:

public static <T extends Comparable> T[] minMax(T[] a) {
	Object[] mm = new Object[2];
	//……
	return (T[]) mm;
}

这里的强制类型转换T[]只是一个假象,因为擦除的原因,结果只会是Object[](跟不转效果一样)。调用:

String[] ss = minMax("Tom", "Michael", "Mary");

编译期不会给任何警告,但是运行时,将Object[]引用赋值给String[]变量时,将会发生ClassCastException异常。在这种情况下,可以利用反射,调用Array.newInstance:

public static <T extends Comparable> T[] minMax(T[] a) {
	T[] mm = (T[]) Array.newInstance(a.getClass().getComponentType(), 2);
	//……
	return (T[]) mm;
}

这就是为什么ArrayList中的toArray方法方法存在两种形式的原因,这个之后在讲泛型容器的时候再详细讲解一下:

Object[] toArray();
T[] toArray(T[] result);

第二个方法接收一个数组参数,如果数组足够大,就使用这个数组。否则,会使用Result的类型构建一个足够大的新数组。

3.5 泛型类类型参数不能用于静态变量和方法

对于泛型类声明的类型参数,可以在实例变量和方法中使用,但在静态变量和静态方法中是不能使用的。类似下面这种写法是非法的:

public class Singleton<T> {

    private static T instance; // Error,T为泛型类类型参数
    
    public synchronized static T getInstance(){ //Error,T为泛型类类型参数
        if(instance==null){
             // 创建实例
        }
        return instance;
    }
}    

如果合法的话,那么对于每种实例化类型,都需要有一个对应的静态变量和方法。但由于类型擦除,Singleton类型只有一份,静态变量和方法都是类型的属性,且与类型参数无关,所以不能使用泛型类类型参数。

另外需要注意的是,这里讲的类型参数是泛型类声明的类型参数,对于泛型方法中声明的类型参数是可以在静态方法中使用的,如下:

public static <T extends Comparable<? super T>> Pair<T> minMax(T[] array);

3.6 注意擦除后的冲突

由于泛型擦除机制,类型会被替换为Object,可能会引发一些编译冲突。比如,在泛型类Pair中添加一个equals方法,如下:

考虑一个Pair<String>,从概念上讲,它有两个方法,如下:

boolean equals(String) //defined in Pair<T>
boolean equals(Object) //inherited from Object

但是方法擦除后,上面的方法会变为boolean equals(Object),与Object.equals方法冲突,补救的办法就是重新命名冲入的equals方法

泛型规范中,还提到另一个比较重要的原则:一个类或者类型变量不能同时成为两个接口类型的子类,而这两个接口是同一个接口的不通参数化。听起来比较绕,现在通过下面这个例子来感受一下:

之前我们介绍过一个例子,有两个类Base和Child,如下:

class Base implements Comparable<Base>

class Child extends Base

Child没有专门实现Comparable接口,上节我们说Base类已经有了比较所需的全部信息,所以Child没有必要实现,可是如果Child希望自定义这个比较方法呢?直觉上,可以这样修改Child类:

class Child extends Base implements Comparable<Child>{
    @Override
    public int compareTo(Child o) {
        
    }
    //...
}

遗憾的是,Java编译器会提示错误,Comparable接口不能被实现两次,且两次实现的类型参数还不同,一次是Comparable<Base>,一次是Comparable<Child>。为什么不允许呢?因为类型擦除后,实际上只能有一个。

那Child有什么办法修改比较方法呢?只能是重写Base类的实现,如下所示:

class Child extends Base {
    @Override
    public int compareTo(Base o) {
        if(!(o instanceof Child)){
            throw new IllegalArgumentException();
        }
        Child c = (Child)o;
        //...
        return 0;
    }
    //...
}

以上就是泛型使用的一些约束,虽然比较繁琐,但一般并不需要特别去记忆,因为用错的时候,Java开发环境和编译器会有相应提示,只要按照提示修改即可。

参考链接:

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

赞(0) 打赏
Zhuoli's Blog » Java编程拾遗『泛型——通配符&约束』
分享到: 更多 (0)

评论 抢沙发

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