2

第8章 泛型

通常情况的类和函数,我们只需要使用具体的类型即可:要么是基本类型,要么是自定义的类。但是在集合类的场景下,我们通常需要编写可以应用于多种类型的代码,我们最简单原始的做法是,针对每一种类型,写一套刻板的代码。这样做,代码复用率会很低,抽象也没有做好。我们能不能把“类型”也抽象成参数呢?是的,当然可以。

Java 5 中引入泛型机制,实现了“参数化类型”(Parameterized Type)。参数化类型,顾名思义就是将类型由原来的具体的类型参数化,类似于方法中的变量参数,此时类型也定义成参数形式,我们称之为类型参数,然后在使用时传入具体的类型(类型实参)。

我们知道,在数学中泛函是以函数为自变量的函数。类比的来理解,编程中的泛型就是以类型为变量的类型,即参数化类型。这样的变量参数就叫类型参数(Type Parameters)。

本章我们来一起学习一下Kotlin泛型的相关知识。

8.1 为何引入泛型

《Java编程思想 》(第4版)中提到:有许多原因促成了泛型的出现,而最引人注意的一个原因,就是为了创建容器类 (集合类)。

集合类可以说是我们在写代码过程中最最常用的类之一。我们先来看下没有泛型之前,我们的集合类是怎样持有对象的。在Java中,Object类是所有类的根类。为了集合类的通用性,把元素的类型定义为Object,当放入具体的类型的时候,再作相应的强制类型转换。

这是一个示例代码:

class RawArrayList {
    public int length = 0;
    private Object[] elements; // 把元素的类型定义为Object

    public RawArrayList(int length) {
        this.length = length;
        this.elements = new Object[length];
    }

    public Object get(int index) {
        return elements[index];
    }

    public void add(int index, Object element) {
        elements[index] = element;
    }
}

一个简单的测试代码如下

public class RawTypeDemo {

    public static void main(String[] args) {
        RawArrayList rawArrayList = new RawArrayList(4);
        rawArrayList.add(0, "a");
        rawArrayList.add(1, "b");
        System.out.println(rawArrayList);

        String a = (String)rawArrayList.get(0); 
        System.out.println(a);

        String b = (String)rawArrayList.get(1);
        System.out.println(b);

        rawArrayList.add(2, 200);
        rawArrayList.add(3, 300);
        System.out.println(rawArrayList);

        int c = (int)rawArrayList.get(2);
        int d = (int)rawArrayList.get(3);
        System.out.println(c);
        System.out.println(d);

        String x = (String)rawArrayList.get(2); //Exception in thread "main" java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String
        System.out.println(x);

    }

}

我们可以看出,在使用原生态类型(raw type)实现的集合类中,我们使用的是Object[]数组。这种实现方式,存在的问题有两个:

  1. 向集合中添加对象元素的时候,没有对元素的类型进行检查,也就是说,我们往集合中添加任意对象,编译器都不会报错。
  2. 当我们从集合中获取一个值的时候,我们不能都使用Object类型,需要进行强制类型转换。而这个转换过程由于在添加元素的时候没有作任何的类型的限制跟检查,所以容易出错。例如上面代码中的:
String x = (String)rawArrayList.get(2); //Exception in thread "main" java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String

对于这行代码,编译时不会报错,但是运行时会抛出类型转换错误。能不能让编译器来解决这样的样板化的类型转换代码呢?当我们向rawArrayList 添加元素的时候

rawArrayList.add(0, "a");

就限定其元素类型只能为String,那么在后面的获取元素的时候,自动强制转型为String 呢?

 String a = (String)rawArrayList.get(0);

这个元素类型 String 的信息,我们存放到 一个“类型参数”中,然后在编译器层面引入相应的类型检查和自动转换机制,这样就可以解决这个类型安全使用的问题。这也正是引入的泛型的基本思想。

泛型最主要的优点就是让编译器追踪参数类型,执行类型检查和类型转换。因为由编译器来保证类型转换不会失败。如果依赖我们程序员自己去追踪对象类型和执行转换,那么运行时产生的错误将很难去定位和调试,然而有了泛型,编译器 可以帮助我们执行大量的类型检查,并且可以检测出更多的编译时错误。在这一点上,泛型跟我们第3章中所讲到的“可空类型”实现的空指针安全,在思想上有着异曲同工之妙。

8.2 在类、接口和函数上使用泛型

泛型类、泛型接口和泛型方法具备可重用性、类型安全和高效等优点。在集合类API中大量地使用了泛型。在Java 中我们可以为类、接口和方法分别定义泛型参数,在Kotlin中也同样支持。本节我们分别介绍Kotlin中的泛型接口、泛型类和泛型函数。

8.2.1 泛型接口

我们举一个简单的Kotlin泛型接口的例子。

interface Generator<T> { // 类型参数放在接口名称后面: <T> 
    operator fun next(): T  // 接口函数中直接使用类型 T
}

测试代码

fun testGenerator() {
    val gen = object : Generator<Int> { // 对象表达式
        override fun next(): Int {
            return Random().nextInt(10)
        }
    }
    println(gen.next())
}

这里我们使用object 关键字来声明一个Generator实现类,并在lambda表达式中实现了next() 函数。

Kotlin 中 Map 和 MutableMap 接口的定义也是一个典型的泛型接口的例子。

public interface Map<K, out V> {
    ...
    public fun containsKey(key: K): Boolean
    public fun containsValue(value: @UnsafeVariance V): Boolean
    public operator fun get(key: K): V?
    ...
    public val keys: Set<K>
    public val values: Collection<V>
    public val entries: Set<Map.Entry<K, V>>
}

public interface MutableMap<K, V> : Map<K, V> {
    public fun put(key: K, value: V): V?
    public fun remove(key: K): V?
    public fun putAll(from: Map<out K, V>): Unit
    ...
}

例如,我们使用 mutableMapOf 函数来实例化一个可变Map

>>> val map = mutableMapOf<Int,String>(1 to "a", 2 to "b", 3 to "c")
>>> map
{1=a, 2=b, 3=c}

其中,mutableMapOf 函数签名如下

fun <K, V> mutableMapOf(vararg pairs: Pair<K, V>): MutableMap<K, V>

这里类型参数 K,V 当泛型类型被实例化和使用时,它将被一个实际的类型参数所替代。在 mutableMapOf<Int,String> 中,放置K, V 的位置被具体的Int 和 String 类型所替代。

泛型可以用来限制集合类持有的对象类型,这样使得类型更加安全。当我们在一个集合类里面放入了错误类型的对象,编译器就会报错:

>>> map.put("5","e")
error: type mismatch: inferred type is String but Int was expected
map.put("5","e")
        ^

Kotlin中有类型推断的功能,有些类型参数可以直接省略不写。mutableMapOf<Int,String> 后面的类型参数 <Int,String> 可以省掉不写:

>>> val map = mutableMapOf(1 to "a", 2 to "b", 3 to "c")
>>> map
{1=a, 2=b, 3=c}

8.2.2 泛型类

我们直接声明一个带类型参数的 Container 类

class Container<K, V>(var key: K, var value: V)

为了方便测试,我们重写 toString() 函数

class Container<K, V>(var key: K, var value: V){ // 在类名后面声明泛型参数<K, V> , 多个泛型使用逗号隔开
    override fun toString(): String {
        return "Container(key=$key, value=$value)"
    }
}

测试代码

fun testContainer() {
    val container = Container<Int, String>(1, "A") // <K, V> 被具体化为<Int, String>
    println(container) // container = Container(key=1, value=A)
}

8.2.3 泛型函数

在泛型接口和泛型类中,我们都在类名和接口名后面声明了泛型参数。而实际上,我们也可以直接在类或接口中的函数,或者直接在包级函数中直接声明泛型参数。代码示例如下

class GenericClass {
    fun <T> console(t: T) { // 类中的泛型函数
        println(t)
    }
}
interface GenericInterface {
    fun <T> console(t: T) // 接口中的泛型函数
}
fun <T : Comparable<T>> gt(x: T, y: T): Boolean { // 包中的泛型函数
    return x > y
}

8.3 类型上界

在上面的例子中,我们有看到 gt(x:T, y:T) 函数的签名中有个 T : Comparable<T>

fun <T : Comparable<T>> gt(x: T, y: T): Boolean

这里的 T : Comparable<T> ,表示 Comparable<T>是类型 T 的上界。也就是告诉编译器,类型参数 T 代表的都是实现了 Comparable<T> 接口的类,这样等于告诉编译器它们都实现了compareTo方法。如果没有这个类型上界声明,我们就无法直接使用 compareTo ( > )操作符。也就是说,下面的代码编译不通过

fun <T> gt(x: T, y: T): Boolean {
    return x > y // 编译不通过
}

8.4 协变与逆变

Java 泛型的通配符有两种形式:

  • 子类型上界限定符 ? extends T 指定类型参数的上限(该类型必须是类型T或者它的子类型)
  • 超类型下界限定符 ? super T 指定类型参数的下限(该类型必须是类型T或者它的父类型)

我们称之为类型通配符(Type Wildcard)。通配符在类型系统中具有重要的意义,它们为一个泛型类所指定的类型集合提供了一个有用的类型范围。

Number 类型(简记为F) 是 Integer 类型(简记为C)的父类型,我们把这种父子类型关系简记为:C => F (C 继承 F);而List<Number>, List<Integer>的代表的泛型类型信息,我们分别简记为 f(F), f(C)。

那么我们可以这么来描述协变和逆变:

当 C => F 时, 如果有 f(C) => f(F), 那么 f 叫做协变;

当 C => F 时, 如果有 f(F) => f(C), 那么 f 叫做逆变。
如果上面两种关系都不成立则叫做不变。

协变与逆变可以用下图来简单说明

协变与逆变

协变和逆协变都是类型安全的。

8.4.1 协变

在Java中数组是协变的,下面的代码是可以正确编译运行的:

        Integer[] ints = new Integer[3];
        ints[0] = 0;
        ints[1] = 1;
        ints[2] = 2;
        Number[] numbers = new Number[3];
        numbers = ints;
        for (Number n : numbers) {
            System.out.println(n);
        }

在Java中,因为 Integer 是 Number 的子类型,数组类型 Integer[] 也是 Number[] 的子类型,因此在任何需要 Number[] 值的地方都可以提供一个 Integer[] 值。Java中数组协变的意思可以用下图简单说明

Java 数组协变

Java中泛型是非协变的。如下图所示

泛型不是协变的

也就是说, List<Integer> 不是 List<Number> 的子类型,试图在要求 List<Number> 的位置提供 List<Integer> 是一个类型错误。下面的代码,编译器是会直接报错的:

        List<Integer> integerList = new ArrayList<>();
        integerList.add(0);
        integerList.add(1);
        integerList.add(2);
        List<Number> numberList = new ArrayList<>();
        numberList = integerList; // 编译错误:类型不兼容

编译器报错提示如下:

编译错误:类型不兼容

Java中泛型和数组的不同行为,的确引起了许多混乱。就算我们使用通配符,这样写:

List<? extends Number> list = new ArrayList<Number>();  
list.add(new Integer(1)); //error  

仍然是报错的:

add元素错误信息

这通常会让我们感到困惑:为什么Number的对象可以由Integer实例化,而ArrayList<Number>的对象却不能由ArrayList<Integer>实例化?list中的<? extends Number>声明其元素是Number或Number的派生类,为什么不能add Integer? 为了弄清楚这些问题,我们需要了解Java中的逆变和协变以及泛型中通配符用法。

List<? extends Number> list = new ArrayList<>();  

这里的子类型 C 就是 Number类及其子类(例如Number、Integer、Float等) ,表示的是 Number 类或其子类。父类 F 就是上界通配符: ? extends Number。

当 C => F ,这个关系成立:f(C) => f(F) , 这就是协变。我们把 f(F) 具体化为 List<? extends Number>, f(C) 具体化为 List<Integer> 、List<Float>等。 协变代表的意义就是: List<? extends Number> 是 List<Integer> 、List<Float>等的父类型。如下图所示

协变

代码示例

List<? extends Number> list1 = new ArrayList<Integer>();  
List<? extends Number> list2 = new ArrayList<Float>();  

但是这里不能向list1、list2添加除null以外的任意对象。

        list1.add(null); // ok
        list2.add(null);// ok
        list1.add(new Integer(1)); // error
        list2.add(new Float(1.1f)); // error

List<Integer>可以添加Interger及其子类;
List<Float>可以添加Float及其子类;
List<Integer>、List<Float>等都是 List<? extends Number>的子类型。

现在问题来了,如果能将Float的子类添加到 List<? extends Number>中,那么也能将Integer的子类添加到 List<? extends Number>中, 那么这时候 List<? extends Number> 里面将会持有各种Number子类型的对象(Byte,Integer,Float,Double等)。而这个时候,当我们再使用这个list的时候,元素的类型就会混乱。我们不知道哪个元素会是Integer或者Float 。Java为了保护其类型一致,禁止向List<? extends Number>添加任意对象,不过可以添加空对象null。

禁止向List&lt;? extends Number&gt;添加任意对象

8.4.2 逆变

我们先用一段代码举例

List<? super Number> list = new ArrayList<Object>();  

这里的子类型 C 是 ? super Number , 父类型 F 是 Number 的父类型(例如:Object类)。

当 C => F , 有 f(F) => f(C) , 这就是逆变。我们把 f (C) 具体化为 List<? super Number> ,f(F) 具体化为List<Object> 。逆变的意思就是说 List<? super Number> 是 List<Object> 的父类型。如下图所示

逆变

代码示例:

List<? super Number> list3 = new ArrayList<Number>();  
List<? super Number> list4 = new ArrayList<Object>();  
list3.add(new Integer(3));  
list4.add(new Integer(4));  

在逆变类型中,我们可以向其中添加元素。例如,我们可以向 List<? super Number > list4 变量中添加Number及其子类对象。

8.4.3 PECS

现在问题来了:我们什么时候用extends什么时候用super呢?《Effective Java》给出了答案:

PECS: producer-extends, consumer-super

下面我们通过实例来说明PECS的具体含义。

首先,我们声明一个简单的Stack 泛型类如下

public class Stack<E>{  
    public Stack();  
    public void push(E e):  
    public E pop();  
    public boolean isEmpty();  
}  

要实现pushAll(Iterable<E> src)方法,将src的元素逐一入栈

public void pushAll(Iterable<E> src){  
    for(E e : src)  
        push(e)  
}  

假设有一个Stack<Number>(类型参数E 具体化为 Number 类型)实例化的对象stack,src有 Iterable<Integer> 与 Iterable<Float>,那么在调用pushAll方法时会发生type mismatch错误,因为Java中泛型是不可变的,Iterable<Integer>与 Iterable<Float>都不是Iterable<Number>的子类型。

因此,pushAll(Iterable<E> src)方法签名应改为

// Wildcard type for parameter that serves as an E producer  
public void pushAll(Iterable<? extends E> src) {  
    for (E e : src)   // out T, 从src中读取数据,producer-extends
        push(e);  
}  

这样就实现了泛型的协变。同时,我们从src中读取的数据都能保证是E类型及其子类型的对象。

现在,我们再看 popAll(Collection<E> dst)方法,该方法将Stack中的元素依次取出add到dst中,如果不用通配符实现:

// popAll method without wildcard type - deficient!  
public void popAll(Collection<E> dst) {  
    while (!isEmpty())  
        dst.add(pop());    
}  

同样地,假设有一个实例化Stack<Number>的对象stack,dst为Collection<Object>;调用popAll方法是会发生type mismatch错误,因为Collection<Object>不是Collection<Number>的子类型。

因而,popAll(Collection<E> dst) 方法应改为:

// Wildcard type for parameter that serves as an E consumer  
public void popAll(Collection<? super E> dst) {  // 保证dst中的元素都是E类型或者E的父类型
    while (!isEmpty())  
        dst.add(pop());   // in T, 向dst中写入数据, consumer-super
}  

因为 pop() 返回的数据类型是E, 而dst中的元素都是E类型或者E的父类型,所以我们可以安全地写入E类型的数据。

Naftalin与Wadler将PECS称为 Get and Put Principle

java.util.Collectionscopy方法中(JDK1.7)完美地诠释了PECS:

public static <T> void copy(List<? super T> dest, List<? extends T> src) {  
    int srcSize = src.size();  
    if (srcSize > dest.size())  
        throw new IndexOutOfBoundsException("Source does not fit in dest");  
  
    if (srcSize < COPY_THRESHOLD ||  
        (src instanceof RandomAccess && dest instanceof RandomAccess)) {  
        for (int i=0; i<srcSize; i++)  
            dest.set(i, src.get(i));  
    } else {  
        ListIterator<? super T> di=dest.listIterator();   // in T, 写入dest数据
        ListIterator<? extends T> si=src.listIterator();   // out T, 读取src数据
        for (int i=0; i<srcSize; i++) {  
            di.next();  
            di.set(si.next());  
        }  
    }  
}  

8.5 out T 与 in T

正如上文所讲的,在 Java 泛型里,有通配符这种东西,我们要用 ? extends T 指定类型参数的上限,用 ? super T 指定类型参数的下限。

而Kotlin 抛弃了这个东西,直接实现了上文所讲的PECS的规则。Kotlin 引入了投射类型 out T 代表生产者对象,投射类型 in T 代表消费者对象。Kotlin使用了投射类型( projected type ) out T 和 in T 来实现了类型通配符同样的功能。

我们用代码示例简单讲解一下:

public static <T> void copy(List<? super T> dest, List<? extends T> src) {  
        ...
        ListIterator<? super T> di=dest.listIterator();   // in T, 写入dest数据
        ListIterator<? extends T> si=src.listIterator();   // out T, 读取src数据
         ...
}  

List<? super T> dest 是消费数据的对象,数据会被写入到 dest 对象中,这些数据该对象被“吃掉”了(Kotlin中叫in T)。

List<? extends T> src 是生产提供数据的对象。src 会“吐出”数据(Kotlin中叫out T)。

在Kotlin中,我们把那些只能保证读取数据时类型安全的对象叫做生产者,用 out T 标记;把那些只能保证写入数据安全时类型安全的对象叫做消费者,用 in T 标记。

如果你觉得太晦涩难懂,就这么记吧:

out T 等价于 ? extends T
in T 等价于 ? super T

8.6 类型擦除

Java和Kotlin 的泛型实现,都是采用了运行时类型擦除的方式。也就是说,在运行时,这些类型参数的信息将会被擦除。

泛型是在编译器层次上实现的。生成的 class 字节码文件中是不包含泛型中的类型信息的。例如在代码中定义的List<Object>和List<String>等类型,在编译之后都会变成List。JVM看到的只是List,而由泛型附加的类型信息对JVM来说是不可见的。

关于泛型的很多奇怪特性都与这个类型擦除的存在有关,比如:泛型类并没有自己独有的Class类对象。比如Java中并不存在List<String>.class或是List<Integer>.class,而只有List.class。对应地在Kotlin中并不存在MutableList<Fruit>::class, 而只有 MutableList::class 。

类型擦除的基本过程也比较简单:

  • 首先,找到用来替换类型参数的具体类。这个具体类一般是Object。如果指定了类型参数的上界的话,则使用这个上界。
  • 其次,把代码中的类型参数都替换成具体的类。同时去掉出现的类型声明,即去掉<>的内容。比如, T get() 就变成了Object get(), List<String> 就变成了List。
  • 最后,根据需要生成一些桥接方法。这是由于擦除了类型之后的类可能缺少某些必须的方法。这个时候就由编译器来动态生成这些方法。

当了解了类型擦除机制之后,我们就会明白是编译器承担了全部的类型检查工作。编译器禁止某些泛型的使用方式,也正是为了确保类型的安全性。

本章小结

泛型是一个非常有用的东西。尤其在集合类中。我们可以发现大量的泛型代码。有了泛型,我们可以拥有更强大更安全的类型检查、无需手工进行类型转换,并且能够开发更加通用的泛型算法。


陈光剑
499 声望183 粉丝