Kotlin 中的类可以有类型参数,就像在 Java 中一样:
1 | class Box<T>(t: T) { |
要创建此类的实例,只需提供类型参数:
1 | val box: Box<Int> = Box<Int>(1) |
但是如果参数可以推断出来,例如,从构造函数参数,可以省略类型参数:
1 | val box = Box(1) // 1 has type Int, so the compiler figures out that it is Box<Int> |
差异
Java 类型系统最棘手的方面之一是通配符类型。 Kotlin 没有这些。相反,Kotlin 具有声明站点差异和类型预测。
思考一下为什么 Java 需要这些神秘的通配符。首先,Java 中的泛型类型是不变的,这意味着 List<String>
不是 List<Object>
的子类型。如果 List 不是不变的,它不会比 Java 的数组好,因为下面的代码会编译但在运行时会导致异常:
1 | List<String> strs = new ArrayList<String>(); |
Java 禁止此类事情以保证运行时安全。但这有影响。例如,考虑 Collection 接口中的 addAll()
方法。这个方法的签名是什么?直观地说,会这样写:
1 | interface Collection<E> ... { |
但是,将无法执行以下操作(这是非常安全的):
1 | void copyAll(Collection<Object> to, Collection<String> from) { |
这就是为什么 addAll()
的实际签名如下:
1 | interface Collection<E> ... { |
通配符类型参数 ? extends E
表示此方法接受 E
的对象集合或 E
的子类型的对象集合,而不仅仅是 E
本身。这意味着可以安全地从 items 中读取 E
(此集合的元素是 E
的子类的实例),但不能写入它,因为不知道哪些对象符合 E
的未知子类型。作为此限制的回报,会得到所需的行为: Collection<String>
是 Collection<? extends Object>
的子类型。换句话说,带有扩展绑定(上限)的通配符使类型 协变。
理解其工作原理的关键相当简单:如果只能从集合中获取 item,那么使用字符串集合并从中读取 object 就可以了。相反,如果只能将 item 放入集合中,则可以将 object 的集合放入其中:在 Java 中有 List<? super String>
,是 List<Object>
的超类型。
后者称为 逆变,只能在 List<? super String>
上调用将 String 作为参数的方法。(例如,可以调用 add(String) 或 set(int, String))。如果你在 ListT
的东西,你不会得到一个字符串,而是一个 object。
声明站点差异
假设有一个通用接口 Source<T>
没有任何将 T
作为参数的方法,只有返回 T
的方法:
1 | interface Source<T> { |
然后,将 Source<String>
实例的引用存储在 Source<Object>
类型的变量中是完全安全的 - 无需调用消费者方法。但是 Java 不知道这一点,并且仍然禁止它:
1 | void demo(Source<String> strs) { |
要解决此问题,应该声明 Source<? extends Object>
。这样做是没有意义的,因为可以像以前一样在这样的变量上调用所有相同的方法,因此更复杂的类型不会增加任何值。但是编译器不知道这一点。
在 Kotlin 中,有一种方法可以向编译器解释这类事情。这称为 声明站点差异:可以对 Source 的类型参数 T
进行注释,以确保它仅从 Source<T>
的成员返回(产生),而不会被消费。为此,请使用 out
修饰符:
1 | interface Source<out T> { |
一般规则是:当类 C 的类型参数 T
被声明为 out
时,它可能只出现在 C 的成员中的 out
位置,但作为回报,C<Base>
可以安全地成为 C<Derived>
的超类型。
换句话说,可以说类 C 在参数 T
中是 协变 的,或者说 T
是协变类型参数。可以将 C 视为 T
的生产者,而不是 T
的消费者。
out
修饰符称为差异注释,由于它是在类型参数声明站点提供的,因此它提供了声明站点差异。这与 Java 的使用站点差异形成对比,其中类型使用中的通配符使类型协变。
除了 out
,Kotlin 还提供了一个互补的差异注解:in
。它使类型参数 逆变,这意味着它只能被消费而不能被生产。逆变类型的一个很好的例子是 Comparable
:
1 | interface Comparable<in T> { |
in
和 out
这两个词似乎是不言自明的(因为它们已经在 C# 中成功使用了一段时间),所以上面提到的助记符并不是真正需要的。事实上,它可以在更高的抽象层次上重新表述:
类型预测
使用站点差异:类型预测
很容易将类型参数 T
声明为 out
并避免在使用站点上进行子类型化的麻烦,但实际上某些类不能被限制为只返回 T
! Array 就是一个很好的例子:
1 | class Array<T>(val size: Int) { |
这个类在 T
中既不能是协变的,也不能是逆变的。这会带来一定的不灵活性。考虑以下函数:
1 | fun copy(from: Array<Any>, to: Array<Any>) { |
此函数应该将 item 从一个数组复制到另一个数组。让我们尝试在实践中应用它:
1 | val ints: Array<Int> = arrayOf(1, 2, 3) |
在这里会遇到同样熟悉的问题:Array<T>
在 T
中是不变的,因此 Array<Int>
和 Array<Any>
都不是另一个的子类型。为什么不?同样,这是因为 copy 可能有意外的行为,例如,它可能会尝试将 String
写入 from
,如果实际上在那里传递了一个 Int
数组,稍后将抛出 ClassCastException
。
To prohibit the copy function from writing to from, you can do the following:
1 | fun copy(from: Array<out Any>, to: Array<Any>) { ... } |
这是类型预测,这意味着 from
不是一个简单的数组,而是一个受限(预测)数组。只能调用返回类型参数 T
的方法,这意味着只能调用 get()
。这是我们使用站点差异的方法,它对应于 Java 的 Array<? extends Object>
,同时稍微简单一些。
也可以使用 in
预测类型:
1 | fun fill(dest: Array<in String>, value: String) { ... } |
Array<in String>
对应 Java 的 Array<? super String>
。这意味着可以将 CharSequence 数组或 Object 数组传递给 fill()
函数。
Star-projections
有时你想说你对类型参数一无所知,但你仍然想以一种安全的方式使用它。这里安全的方法是定义泛型类型的投影,该泛型类型的每个具体实例化都将是该投影的子类型。
Kotlin provides so-called star-projection syntax for this:
- 对于
Foo<out T : TUpper>
,其中T
是具有上限TUpper
的协变类型参数,Foo<*>
等效于Foo<out TUpper>
。这意味着当T
未知时,可以安全地从Foo<*>
读取TUpper
的值。 - 对于
Foo<in T>
,其中T
是逆变类型参数,Foo<*>
等效于Foo<in Nothing>
。这意味着当T
未知时,无法以安全的方式向Foo<*>
写入任何内容。 - 对于
Foo<T : TUpper>
,其中T
是具有上限TUpper
的不变类型参数,Foo<*>
等效于读取值的Foo<out TUpper>
和写入值的Foo<in Nothing>
。
如果泛型类型有多个类型参数,则每个类型参数都可以独立投影。例如,如果类型声明为接口 Function<in T, out U>
可以使用以下星形投影:
Function<*, String>
meansFunction<in Nothing, String>
.Function<Int, *>
meansFunction<Int, out Any?>
.Function<*, *>
meansFunction<in Nothing, out Any?>
.
泛型函数
1 | fun <T> singletonList(item: T): List<T> { |
1 | <T> T[] toArray(T[] a); |
泛型约束
可以替代给定类型参数的所有可能类型的集合可能受到泛型约束的限制。
上界
最常见的约束类型是上界,它对应于 Java 的 extends 关键字:
1 | fun <T : Comparable<T>> sort(list: List<T>) { ... } |
冒号后指定的类型为上界,表示只能用 Comparable<T>
的子类型替换 T
。例如:
1 | sort(listOf(1, 2, 3)) // OK. Int is a subtype of Comparable<Int> |
默认上限(如果没有指定)是 Any?
。在尖括号内只能指定一个上界。如果同一类型参数需要多个上界,则需要单独的 where 子句:
1 | fun <T> copyWhenGreater(list: List<T>, threshold: T): List<String> |
传递的类型必须同时满足 where
子句的所有条件。在上面的例子中,T
类型必须同时实现 CharSequence
和 Comparable
。
类型擦除
Kotlin 对泛型声明使用执行的类型安全检查是在编译时完成的。在运行时,泛型类型的实例不包含有关其实际类型参数的任何信息。类型信息已被擦除。例如,Foo<Bar>
和 Foo<Baz?>
的实例被擦除为 Foo<*>
。
因此,没有通用的方法来检查泛型类型的实例是否在运行时使用某些类型参数创建,并且编译器禁止此类 is
-检查。
无法在运行时检查到具有具体类型参数的泛型类型的类型转换,例如,作为 List<String>
的 foo。当高级程序逻辑暗示类型安全但编译器无法直接推断时,可以使用这些未经检查的强制转换。编译器对未检查的强制转换发出警告,并且在运行时,仅检查非泛型部分(相当于 foo as List<*>
)。
泛型函数调用的类型参数也只在编译时检查。在函数体内,类型参数不能用于类型检查,并且类型转换为类型参数(foo as T
)是未检查的。但是,inline
函数的reified类型参数在调用站点被内联函数体中的实际类型参数替换,因此可用于类型检查和强制转换,对泛型类型的实例具有与上述相同的限制。
Java泛型
泛型的本质是参数化类型或者参数化多态的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数,这种参数类型能够用在类、接口和方法的创建中,分别构成泛型类、泛型接口和泛型方法。
Java的泛型实现方式叫作 “类型擦除式泛型”。而 C# 选择的泛型实现方式是 “具现化式泛型”。C# 里面泛型无论在程序源码里面、编译后的中间语言表示里面,抑或是运行期的CLR里面都是切实存在的,List
下面Java代码都是不合法的:
1 | public class TypeErasureGenerics<E> { |
上面这些是Java泛型在编码阶段产生的不良影响,如果说这种使用层次上的差别还可以通过多写几行代码、方法中多加一两个类型参数来解决的话,性能上的差距则是难以用编码弥补的。C# 2.0 引入了泛型之后,带来的显著优势之一便是对比起Java在执行性能上的提高,因为在使用平台提供的容器类型时,无需像Java里那样不厌其烦的拆箱和装箱,如果在Java中要避免这种损失,就必须构造一个与数据类型相关的容器类(譬如 IntFloatHashMap)。显然,这除了引入更多代码造成复杂度提高、复用性降低之外,更是丧失了泛型本身的存在价值。
Java的类型擦除式泛型无论在使用效果上还是运行效率上,几乎是全面落后与 C# 的具现化式泛型,而它唯一优势是在于实现这种泛型的影响范围上:擦除式泛型的实现几乎只需要在Javac编译器作出改进即可,不需要改动字节码、不需要改动Java虚拟机,也保证了以前没有使用泛型的库可以直接运行在 Java 5.0 之上。
在没有泛型的时代,由于Java中的数组是支持协变的(现在也支持),对应的集合类也可以存入不同类型的元素,类似于下面这样的代码尽管不提倡,但是完全可以正常编译成 Class 文件。
1 | Object[] x = new String[10]; |
为了保证这些编译出来的Class文件可以在Java 5.0引入泛型之后继续运行,设计者面前大体上有两条路可以选择:
- 需要泛型化的类型(主要是容器类型),以前有的就保持不变,然后平行地加一套泛型化版本的新类型。
- 直接把已有的类型泛型化,即让所有需要泛型化的已有类型都原地泛型化,不添加任何平行于已有类型的泛型版。
在 JDK 1.2 时,遗留代码规模尚小,Java就引入过新的集合类,并且保留了旧集合类不动。这导致了直到现在标准类库中还有Vector(老)和 ArrayList(新)、有Hashtable(老)和 HashMap(新)等两套容器代码并存,如果当时再摆弄出像 Vector(老)、ArrayList(新)、Vector(老但有泛型)、ArrayList(新且有泛型)这样的容器集合,可能叫骂声会比今天听到的更响更大。
类型擦除
由于Java选择了第二条路,直接把已有的类型泛型化,要让所有需要泛型化的已有类型,譬如 ArrayList,原地泛型化后变成了 ArrayList<T>
,而且保证以前直接用 ArrayList 的代码在泛型新版本里必须还能继续用这同一个容器,这就必须让所有泛型化的实例类型,譬如 ArrayList<Integer>
、ArrayList<String>
这些全部自动成为 ArrayList 的子类型才能可以,否则类型转换就是不完全的。由此就引出了 “裸类型”(Raw Type)的概念,裸类型应被视为所有该类型泛型化实例的共同父类型,只有这样,下面代码中的赋值才是被系统允许的从子类到父类的安全转型。
1 | ArrayList<Integer> i = new ArrayList<Integer>(); |
接下来的问题是该如何实现裸类型。这里又有了两种选择:一种是在运行期由Java虚拟机来自动地、真实地构造出 ArrayList<Integer>
这样的类型,并且自动实现从 Arraylist<Integer>
派生自 ArrayList 的继承关系来满足裸类型的定义;另外一种是索性简单粗暴地直接在编译时把 ArrayList<Integer>
还原回 ArrayList,只在元素访问、修改时自动插入一些强制类型转换和检查指令,这样看起来也是能满足需要。
下面代码是一段简单的Java泛型例子,看一下它编译后的实际样子是怎样的。
1 | public static void main(String[] args) { |
把这段Java代码编译成class文件,然后再用字节码反编译工具反编译后,会发现泛型都不见了,程序又变回了Java泛型出现之前的写法,泛型类型都变回了裸类型,只在元素访问时插入了从 Object 到 String 的强制转型代码,如下所示。
1 | public static void main(String[] args) { |
再举几个例子。首先,使用擦除法实现泛型直接导致了对原始类型数据的支持又成了新的麻烦,譬如将上面的示例代码修改一下,变成下面这个样子。
1 | ArrayList<int> i = new ArrayList<int>(); |
这种情况下,一旦把泛型信息擦除后,到要插入强制转型代码的地方就没办法往下做了,因为不支持 int、long 与 Object 之间的强制转型。当时Java给出的解决方案一如既往的简单粗暴:索性就不支持原生类型的泛型了,都用 ArrayList<Integer>
、ArrayList<Long>
,反正都做了自动的强制类型转换,遇到原生类型时把装箱、拆箱也自动做了得了。这个决定后面导致了无数构造包装类的装箱、拆箱的开销,成为Java泛型慢的重要原因。
第二,运行期无法取到泛型类型信息,会让一些代码变得相当啰嗦,譬如之前代码示例中罗列的几种Java不支持的泛型用法,都是由于运行期Java虚拟机无法取得泛型类型而导致的。像下面这样,去写一个泛型版本的从 List 到数组的转换方法,由于不能从 List 中取得参数化类型 T,所以不得不从一个额外参数中再传入一个数组的组件类型进去,实属无奈。
1 | public static <T> T[] convert(List<T> list, Class<T> componentType) { |