5.11 泛型:in、out、where
12 分钟阅读
5.11 泛型:in、out、where
与 Java 一样,Kotlin 中的类可以有类型参数:
| |
要创建这类类的实例,只需提供类型实参:
| |
如果编译器能够推断出类型实参(例如根据构造函数实参),就不需要显式指定它们:
| |
型变
Java 类型系统中最棘手的一方面是通配符类型(参见 Java 泛型 FAQ)。Kotlin 没有这些通配符,取而代之的是声明处型变和类型投影。
Java 中的型变与通配符
我们来想一想为什么 Java 需要这些神秘的通配符。首先,Java 中的泛型类型是不变的,也就是说 List<String> 不是 List<Object> 的子类型。如果 List 不是不变的,那它就不会比 Java 的数组好到哪里去,因为下面这段代码将能编译通过,但在运行时会抛异常:
| |
Java 禁止这类行为以保证运行时安全。但这也带来了一些影响。例如,考虑 Collection 接口中的 addAll() 方法。它的签名是什么?直观上你会这样写:
| |
但这样一来,你就无法做下面这件事(而它完全是安全的):
| |
这就是为什么 addAll() 的实际签名是下面这样的:
| |
通配符类型实参 ? extends E 表示该方法接受包含 E 或 E 的子类型的对象的集合,而不仅是 E 本身。这意味着你可以安全地从 items 中读取 E(该集合的元素是 E 子类的实例),但不能向其中写入,因为你不知道哪些对象符合那个未知的 E 子类型。作为这一限制的回报,你得到了期望的行为:Collection<String> 是 Collection<? extends Object> 的子类型。换句话说,带 extends 界定(上界)的通配符让该类型成为协变的。
理解它为什么可行的关键其实很简单:如果你只能从集合中取出元素,那么使用一个 String 集合并从中读取 Object 是没问题的。反过来说,如果你只能向集合中放入元素,那么使用一个 Object 集合并向其中放入 String 也没问题:Java 中有 List<? super String>,它接受 String 或其任何超类型。
后者称为逆变,在 List<? super String> 上你只能调用把 String 作为实参的方法(例如可以调用 add(String) 或 set(int, String))。如果你调用 List<T> 中返回 T 的方法,得到的就不是 String,而是 Object。
Joshua Bloch 在他的书 Effective Java, 3rd Edition 中很好地解释了这个问题(第 31 条:“Use bounded wildcards to increase API flexibility”)。他把只从中读取的对象称为生产者(Producers),把只向其写入的对象称为消费者(Consumers)。他建议:
“为获得最大的灵活性,在表示生产者或消费者的输入参数上使用通配符类型。”
随后他提出了下面这个助记方法:PECS 代表 Producer-Extends, Consumer-Super(生产者用 extends,消费者用 super)。
注意: 如果你使用生产者对象,比如
List<? extends Foo>,就不允许在该对象上调用add()或set(),但这并不意味着它是不可变的:例如,没有什么能阻止你调用clear()来移除列表中的所有元素,因为clear()根本不接受任何参数。通配符(或其他类型的型变)唯一保证的是类型安全。不可变性是完全不同的另一个话题。
声明处型变
假设有一个泛型接口 Source<T>,它没有任何把 T 作为参数的方法,只有返回 T 的方法:
| |
那么,把 Source<String> 实例的引用存储在 Source<Object> 类型的变量中是完全安全的——没有任何消费者方法可以调用。但 Java 并不知道这一点,仍然禁止这样做:
| |
要解决这个问题,你应当声明 Source<? extends Object> 类型的对象。这么做没有意义,因为在这种变量上你能调用的方法与之前完全相同,所以更复杂的类型并没有带来任何价值,只是编译器不知道这一点而已。
在 Kotlin 中,有办法把这类信息告诉编译器。这称为声明处型变:你可以给 Source 的类型参数 T 加上标注,确保它只会从 Source<T> 的成员中返回(生产),而从不被消费。为此,请使用 out 修饰符:
| |
一般规则是:当类 C 的类型参数 T 被声明为 out 时,它只能出现在 C 成员的 out 位置,但作为回报,C<Base> 可以安全地成为 C<Derived> 的超类型。
换句话说,你可以说类 C 在参数 T 上是协变的,或者说 T 是一个协变类型参数。你可以把 C 看作 T 的生产者,而不是 T 的消费者。
out 修饰符称为型变注解,由于它是在类型参数的声明处提供的,因此提供的是声明处型变。这与 Java 的使用处型变形成对比,后者通过类型使用处的通配符让类型变为协变。
除 out 之外,Kotlin 还提供了互补的型变注解:in。它让类型参数成为逆变的,也就是说它只能被消费、不能被生产。逆变类型的一个好例子是 Comparable:
| |
in 和 out 这两个词看起来不言自明(它们在 C# 中已经成功使用很长时间了),所以上面提到的助记方法其实并不需要。实际上它可以在更高的抽象层次上重新表述:
存在主义变换:消费者 in,生产者 out! :-)
类型投影
使用处型变:类型投影
把一个类型参数 T 声明为 out 并因此避免使用处的子类型问题非常容易,但有些类实际上无法被限制为只返回 T!Array 就是一个很好的例子:
| |
这个类在 T 上既不能是协变的,也不能是逆变的。这就带来了一些不灵活之处。考虑下面这个函数:
| |
这个函数本意是把元素从一个数组复制到另一个数组。我们来实际运用一下:
| |
这里你遇到了同样熟悉的问题:Array<T> 在 T 上是不变的,因此 Array<Int> 和 Array<Any> 都不是对方的子类型。为什么不行?同样是因为 copy 可能有意外行为,例如它可能试图向 from 写入一个 String,而如果你实际传入的是一个 Int 数组,之后就会抛出 ClassCastException。
为禁止 copy 函数向 from 写入,你可以这样做:
| |
这就是类型投影,也就是说 from 不再是一个普通数组,而是一个受限(被投影)的数组。你只能调用返回类型参数 T 的方法,在本例中意味着只能调用 get()。这是我们实现使用处型变的方式,它对应 Java 的 Array<? extends Object>,但稍微更简单一些。
你也可以用 in 对类型进行投影:
| |
Array<in String> 对应 Java 的 Array<? super String>。这意味着你可以把 String、CharSequence 或 Object 的数组传给 fill() 函数。
星投影
有时你想表达自己对类型实参一无所知,但仍希望以安全的方式使用它。这里的安全方式就是定义这样一种泛型类型的投影:该泛型的每个具体实例化都是该投影的子类型。
为此,Kotlin 提供了所谓的星投影语法:
- 对于
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>。
如果一个泛型类型有多个类型参数,每个类型参数都可以独立投影。例如,如果类型声明为 interface Function<in T, out U>,你可以使用以下星投影:
Function<*, String>表示Function<in Nothing, String>。Function<Int, *>表示Function<Int, out Any?>。Function<*, *>表示Function<in Nothing, out Any?>。
注意: 星投影与 Java 的原始类型非常相似,但是安全的。
捕获类型
当你使用诸如 out T 或 in T 这样的类型投影时,编译器会在内部把未知的具体类型表示为捕获类型。捕获类型是一种具有已知上下界的未知类型。
捕获类型无法显式书写,因此你不能在 Kotlin 代码中直接写出它们。不过,你经常可以在编译器诊断信息中看到捕获类型,例如 CapturedType(out X)。例如,下面这条类型不匹配诊断信息中就包含捕获类型:
| |
对于读操作,编译器使用捕获类型的上界;对于写操作,它使用下界来判断哪些值在类型上是安全的:
| |
在这个例子中:
- 变量
array的类型是投影类型Array<out CharSequence>。编译器把被投影的类型实参out CharSequence表示为上界为CharSequence、下界为Nothing的捕获类型。 - 对于
get()操作,编译器把捕获类型近似为其上界CharSequence,并推断item的类型为CharSequence。 - 对于
set()操作,捕获类型的下界是Nothing。由于Nothing没有任何实例,向投影类型写入值在类型上不安全,因此会报错。
泛型函数
类不是唯一可以有类型参数的声明。函数也可以。类型参数放在函数名之前:
| |
要调用泛型函数,请在函数名之后的调用处指定类型实参:
| |
如果类型实参可以从上下文推断出来,就可以省略它们,因此下面的例子同样可行:
| |
泛型约束
可以替换给定类型参数的所有可能类型的集合,可以通过泛型约束加以限制。
上界
最常见的约束类型是上界,它对应 Java 的 extends 关键字:
| |
冒号之后指定的类型就是上界,表示只能把 Comparable<T> 的子类型替换给 T。例如:
| |
默认上界(如果没有指定)是 Any?。尖括号内只能指定一个上界。如果同一个类型参数需要多个上界,就需要单独的 where 子句:
| |
传入的类型必须同时满足 where 子句的所有条件。在上面的例子中,T 类型必须同时实现 CharSequence 和 Comparable。
确定非空类型
为了更容易与泛型 Java 类和接口互操作,Kotlin 支持把泛型类型参数声明为确定非空。
要把泛型类型 T 声明为确定非空,请用 & Any 声明该类型。例如:T & Any。
确定非空类型必须有一个可空的上界。
声明确定非空类型最常见的用例,是你要重写一个把 @NotNull 作为参数的 Java 方法。例如,考虑 load() 方法:
| |
要在 Kotlin 中成功重写 load() 方法,需要把 T1 声明为确定非空:
| |
如果只使用 Kotlin,你不太可能需要显式声明确定非空类型,因为 Kotlin 的类型推断会替你处理。
类型擦除
Kotlin 对泛型声明用法执行类型安全检查是在编译期完成的。在运行时,泛型类型的实例不保存任何关于其实际类型实参的信息。这种类型信息被称为已擦除。例如 Foo<Bar> 和 Foo<Baz?> 的实例都会被擦除为 Foo<*>。
泛型的类型检查与转换
由于类型擦除,无法通用地在运行时检查某个泛型类型的实例是否是用特定类型实参创建的,编译器也禁止诸如 ints is List<Int> 或 list is T(类型参数)这样的 is 检查。不过,你可以对实例进行针对星投影类型的检查:
| |
类似地,当你已经在静态(编译期)检查过某个实例的类型实参时,可以进行涉及该类型非泛型部分的 is 检查或转换。注意这种情况下要省略尖括号:
| |
同样的语法(省略类型实参)也可用于不考虑类型实参的转换:list as ArrayList。
泛型函数调用的类型实参也只在编译期检查。在函数体内部,类型参数不能用于类型检查,而向类型参数的转换(foo as T)是未受检的。唯一的例外是带具体化类型参数的内联函数,它们的实际类型实参会在每个调用处内联,因此可以对类型参数进行类型检查和转换。不过,上面描述的限制对于用在检查或转换内部的泛型类型实例仍然适用。例如在类型检查 arg is T 中,如果 arg 本身就是泛型类型的实例,它的类型实参仍会被擦除。
| |
未受检的转换
向带具体类型实参的泛型类型(例如 foo as List<String>)的转换无法在运行时检查。当类型安全由高层程序逻辑保证、但编译器无法直接推断时,可以使用这类未受检的转换。见下面的例子。
| |
最后一行中的转换会出现一条警告。编译器无法在运行时完全检查它,也不保证映射中的值是 Int。
为避免未受检的转换,你可以重新设计程序结构。在上面的例子中,你可以使用 DictionaryReader<T> 和 DictionaryWriter<T> 接口,并为不同类型提供类型安全的实现。你可以引入合理的抽象,把未受检的转换从调用处移到实现细节中。正确使用泛型型变也会有所帮助。
对于泛型函数,使用具体化类型参数可以让 arg as T 这类转换变成受检的,除非 arg 的类型有自己的被擦除的类型实参。
可以用 @Suppress("UNCHECKED_CAST") 注解出现未受检转换的语句或声明,从而抑制该警告:
| |
在 JVM 上:数组类型(
Array<Foo>)会保留其元素被擦除类型的信息,向数组类型的转换会被部分检查:元素类型的可空性和实际类型实参仍然会被擦除。例如,如果foo是持有任意List<*>(无论是否可空)的数组,那么转换foo as Array<List<String>?>会成功。
类型实参的下划线运算符
下划线运算符 _ 可以用于类型实参。当其他类型已经显式指定时,可以用它自动推断该实参的类型:
| |