Kotlin 知识梳理(13) - 运行时的泛型

Kotlin 知识梳理系列文章

Kotlin 知识梳理(1) - Kotlin 基础
Kotlin 知识梳理(2) - 函数的定义与调用
Kotlin 知识梳理(3) - 类、对象和接口
Kotlin 知识梳理(4) - 数据类、类委托 及 object 关键字
Kotlin 知识梳理(5) - lambda 表达式和成员引用
Kotlin 知识梳理(6) - Kotlin 的可空性
Kotlin 知识梳理(7) - Kotlin 的类型系统
Kotlin 知识梳理(8) - 运算符重载及其他约定
Kotlin 知识梳理(9) - 委托属性
Kotlin 知识梳理(10) - 高阶函数:Lambda 作为形参或返回值
Kotlin 知识梳理(11) - 内联函数
Kotlin 知识梳理(12) - 泛型类型参数


一、本文概要

本文是对<<Kotlin in Action>>的学习笔记,如果需要运行相应的代码可以访问在线环境 try.kotlinlang.org,这部分的思维导图为:

二、运行时的泛型:擦除和实化类型参数

2.1 运行时的泛型

Java一样,Kotlin的泛型在运行时也被擦除了,这意味着 泛型类实例不会携带用于创建它的类型实参的信息

例如,如果你创建了一个List<String>,在运行时你只能看到它是一个List,不能识别出列表本打算包含的是String类型的元素。

接下来我们谈谈伴随着擦除类型信息的约束,因为类型实参String没有被存储下来,你不能检查它们。例如,你不能判断一个列表是一个包含字符串的列表还是包含其它对象的列表,也就是说,在is检查中不可能使用类型实参中的类型,例如

fun main(args: Array<String>) {
    val authors = listOf("first", "second")
    if (authors is List<Int>) {}
}

将会在编译时抛出下面的异常:

>> Cannot check for instance of erased type

Kotlin不允许使用 没有指定类型实参的泛型类型,如果希望检查一个值是否是列表,而不是set或者其它对象,可以使用特殊的 星号投影 语法来做这个检查:

if (value is List<*>)

实际上,泛型类型拥有的每个类型形参都需要一个*,现在你可以认为它就是 拥有未知类型实参的泛型类型

asas?转换中仍然可以使用一般的泛型类型,但是如果该类 有正确的基础类型但类型实参是错误的,转换也不会失败,因为在运行时转换发生的时候类型实参是未知的。因此,这样的转换会导致编译器发出unchecked cast的警告,例如下面这段程序:

fun printSum(c: Collection<*>) {
    val intList = c as? List<Int>
            ?: throw IllegalArgumentException("List is expected")
    println(intList.sum())
}

fun main(args: Array<String>) {
    //(1) 正常运行。
    printSum(listOf(1, 2, 3))
    //(2) as 检查是成功的,但是调用 intList.sum() 方法时会抛出异常。
    printSum(listOf("a", "b", "c"))
}

(2)调用时,并不会抛出IllegalArgumentException异常,而是在调用sum函数时才发生,因为sum函数试着从列表中读取Number值然后把它们加在一起,把String当做Number使用的尝试会导致运行时的ClassCastException

假如在编译期,Kotlin已经知道了相应的类型信息,那么is检查是允许的:

fun printSum(c: Collection<Int>) {
    if (c is List<Int>) {
        println(c.sum())
    }
}

fun main(args: Array<String>) {
    printSum(listOf(1, 2, 3))
}

c是否拥有类型List<Int>的检查是可行的,因为我们将函数类型的形参类型声明为了Collection<Int>,因此编译期就确定了集合包含的是整型数字。

不过,Kotlin有特殊的语法结构可以允许你 在函数体中使用具体的类型实参,但只有inline函数可以,接下来让我们来看看这个特性。

2.2 声明带实化类型参数的函数

Kotlin泛型在运行时会被擦除,这意味着如果你有一个泛型类的实例,你无法弄清楚在这个实例创建时用的究竟是哪些类型实参。泛型函数的实参类型也是这样,在调用泛型函数的时候,在函数体中你不能决定调用它用的类型实参。

//将会在编译时抛出 "Cannot check for instance of erased type : T" 的异常
fun <T> isA(value : Any) = value is T

内联函数的类型形参能够被实化

只有一种例外可以避免这种限制:内联函数。内联函数的类型形参能够被实化,意味着你可以 在运行时引用实际的类型实参。前面我们介绍过内联函数的两个优点:

  • 编译器会把每一次函数调用都替换成函数实际的代码实现
  • 如果该函数使用了lambdalambda的代码也会内联,所以不会创建匿名类

这里,我们介绍它一个新的优点:对于泛型函数来说,它们的类型参数可以被实化。我们将方面的函数修改如下,声明为inline并且用reified标记类型参数,就能用该函数检查value是不是T的实例:

inline fun <reified T> isA(value: Any) = value is T

fun main(args: Array<String>) {
    println(isA<String>("abc"))
    println(isA<String>(123))
}

运行结果为:

>> true
>> false

filterIsIntance函数可以接收一个集合,选择其中那些指定类的实例,然后返回这些被选中的实例:

fun main(args: Array<String>) {
    val items = listOf("one", 2, "three")
    println(items.filterIsInstance<String>())
}

运行结果为:

[one, three]

该函数的简化实现为:

inline fun <reified T> Iterable<*>.filterIsIntance() : List<T> {
    val destination = mutableListOf<T>()
    for (element in this) {
        if (element is T) {
            destination.add(element)
        }
    }
}

为什么实化只对内联函数有效

我们之所以可以在inline函数中使用element is T这样的判断,而不能在普通的类或函数中执行的原因是因为:编译器把 实现内联函数的字节码 插入每一次调用发生的地方,每次你 调用带实化类型参数的函数 时,编译器都知道这次特定调用中 用作类型实参的确切类型,因此,编译器可以生成 引用作为类型实参的具体类 的字节码。

因为生成的字节码引用了具体类,而不是类型参数,它不会被运行时发生类型擦除。注意,带reified类型参数的inline函数不能在Java代码中调用,普通的内联函数可以像常规函数那样在Java中调用 - 它们可以被调用而不能被内联。带实化类型参数的函数需要额外的处理,来把类型实参的值替换到字节码中,所以它们必须永远是内联的,这样它们不可能用Java那样普通的方式调用。

2.3 使用实化类型参数代替类引用

另一种实化类型参数的常见使用场景是接收java.lang.Class类型参数的API构建适配器。例如JDK中的ServiceLoader,它接收一个代表接口或抽象类的java.lang.Class,并返回实现了该接口的实例。

val serviceImpl = ServiceLoader.load(Service::class.java)

::class.java的语法展现了如何获取java.lang.Class对应的Kotlin类,这和Java中的Service.Class是完全等同的,现在我们用 带实化类型参数的函数 重写这个例子:

val serviceImpl = loadService<String>()

loadService的定义为如下,要加载的服务类 现在被指定成了loadService 函数的类型实参

inline fun <reified T> loadService() {
    //把 "T::class" 当成类型形参的类访问。
    return ServiceLoader.load(T::class.java)
}

这种用在普通类上的::class.java语法也可以同样用在实化类型参数上,使用这种语法会产生对应到指定为类型参数的类的java.lang.Class,你可以正常地使用它,最后我们以一个startActivity的调用来结束本节的讨论:

inline fun <reified T : Activity> Context.startActivity {
    val intent = new Intent(this, T::class.java)
    startActivity(intent)
}

>> startActivity<DetailActivity>()

2.4 实化类型参数的限制

我们可以按下面的方式来使用实化类型参数

  • 用在类型检查和类型转换中:is!isasas?
  • 使用Kotlin反射API::class
  • 获取对应的java.lang.Class::class.java
  • 作为调用其它函数的类型实参

不能做下面的事情:

  • 创建指定为类型参数的类的实例
  • 调用类型参数类的伴生对象的方法
  • 调用 带实化类型参数函数 的时候使用 非实化类型形参作为类型实参
  • 把类、属性或者非内联函数的类型参数标记为reified,因为实化类型参数只能用在内联函数上,使用实化类型参数意味着函数和所有传给它的lambda都会被内联,如果内联函数使用lambda的方法导致lambda不能被内联,或者你不想lambda因为性能的关系被内联,可以使用noinline修饰符。

三、变型:泛型和子类型化

变型的概念描述了拥有 相同基础类型不同类型实参 的类型之间是如何关联的,例如List<String>List<Any>之间如何关联。

3.1 为什么存在变型:给函数传递实参

假设你有一个接受List<Any>作为实参的函数,那么把List<String>类型的变量传递给这个函数是否安全呢?我们来看下面两个例子:

  • 第一个例子
fun printContents(list: List<Any>) {
    println(list.joinToString())
}

fun main(args: Array<String>) {
    printContents(listOf("abc", "bac"))
}

这上面的函数可以正常地工作,函数把每个元素都当作Any对待,而且因为每个字符都是Any,因此这是完全安全的,运行结果为:

>> abc, bac
  • 第二个例子,与之前不同,它会修改列表:
fun addAnswer(list : MutableList<Any>) {
    list.add(42)
}

fun main(args: Array<String>) {
    val strings = mutableListOf("abc", "bac")
    addAnswer(strings)
}

这里声明了一个类型为MutableList<String>的变量strings,然后尝试把它传递给一个接收MutableList<Any>的函数,编译器将不会通过调用。

因此,当我们将一个字符串列表传递给期望Any对象的列表时,如果 函数添加或者替换了 列表中的元素(通过MutableList来推断)就是不安全的,因为这样会产生类型不一致的可能,否则它就是安全的。

3.2 类、类型和子类型

变量的类型 规定了 变量的可能值,有时候我们会把类型和类当成同样的概念使用,但它们不一样。

类、类型

非泛型类

对于非泛型类来说,类的名称可以直接当作类型使用。例如,var x : String声明了一个可以保存String类的实例的变量,而var x : String?声明了它的可空类型版本,这意味着 一个Kotlin类都可以用于构造至少两种类型

泛型类

要得到一个合法的类型,需要首先得到一个泛型类,并用一个作为 类型实参的具体类型 替换泛型类的 类型形参

List是一个类而不是类型,下面列举出来的所有替代品都是合法的类型:List<Int>List<String?>List<List<String>>,每一个 泛型类都可能生成潜在的无限数量的类型

子类型

子类型的含义为:

任何时候如果需要的是类型A的值,能够使用类型B的值当做A的值,类型B就称为类型A的子类型。

例如IntNumber的子类型,但Int不是String的子类型,这个定义还表明了任何类型都可以被认为是它自己的子类型。

超类型

超类型子类型 的反义词

如果AB的子类型,那么B就是A的超类型。

编译器在每一次给变量赋值或者给函数传递实参的时候都要做这项检查:

  • 只有 值的类型变量类型的子类型 时,才允许存储变量的值
  • 只有当 表达式的类型函数参数的类型的子类型 时,才允许把该表达式传给函数

子类、子类型

在简单情况下,子类和子类型本质上是一样的,例如Int类是Number的子类,因此Int类型是Number类型的子类型。

一个非空类型是它的可空版本的子类型,但它们都对应着同一个类,你始终能够在可空类型的变量中存储非空类型值。

当开始涉及泛型类时,子类型和子类之间的差异就显得格外重要。正如我们上面见到的,MutableList<String>不是MutableList<Any>的子类型。

对于泛型类MutableList而言,无论AB是什么关系,MutableList<A>既不是MutableList<B>的子类型也不是它的超类型,它就被称为 在该类型参数上是不变型的

Java中的所有类都是不变型的。在前一节中,我们见到了List类,对它来说,子类型化规则不一样,Kotlin中的List接口表示的是只读集合。如果AB的子类型,那么List<A>就是List<B>的子类型,这样的类或者接口被称为 协变的

3.3 协变:保留子类型化关系

一个协变类是一个泛型类,如果AB的子类型,那么Producer<A>就是Producer<B>的子类型,我们说 子类型化被保留了

Kotlin中,要声明类在某个类型参数上是可以协变的,在该类型参数的名称前加上out关键字即可,下面例子就可以表达为:Producer类在类型参数T上是可以协变的。

interface Producer<out T> {
    fun produce() : T
}

将一个类的类型参数标记为协变的,在 该类型实参没有精确匹配到函数中定义的类型形参时,可以让该类的值作为这些函数的实参传递,也可以作为这些函数的返回值

你不能把任何类都变成协变的,这样不安全。让类在某个类型参数变为协变,限制了该类中对该类型参数使用 的可能性,要保证类型安全,你只能用在所谓的out位置,意味着这个类 只能生产类型T的值而不能消费它们

在类成员的声明中类型参数的使用分为inout位置,考虑这样一个类,它声明了一个类型参数T并包含了一个使用T的函数:

  • 如果函数把T当成返回类型,我们说它在out位置,这种情况下,该函数生产类型为T的值
  • 如果T用作函数参数的类型,它就在in的位置,这样函数消费类型为T的值。

因此类型参数T上的关键字有两层含义:

  • 子类型化会被保留,即前面谈到的Producer<Cat>Producer<Animal>的子类型
  • T只能用在out位置

在构造方法的参数上使用 out

构造方法的参数既不在in位置,也不再out位置,即使类型参数声明成了out,仍然可以在构造方法参数的声明中使用它。

class Herd<out T : Animal> (vararg animals : T) { ... }

如果把类的实例当成一个更泛化的类型的实例使用,变型会防止该实例被误用,不能调用存在潜在危险的方法。构造方法不是那种在实例创建之后还能调用的方法,因此它不会有潜在的危险。

然而,如果你在构造方法的参数上使用了关键字varval,同时就会声明一个gettersetter,因此,对只读属性来说,类型参数用在了out位置,而可变属性在outin位置都使用了它。

class Herd<T : Animal> (var leadAnimal : T, vararg animals : T) { ... }

上面这个例子中,T不能用out标记,因为类包含属性leadAnimalsetter,它在in位置用到了T

位置规则只覆盖了类外部可见的 API

位置规则只覆盖了类外部可见的api,私有方法的参数既不在in位置,也不在out位置,变型规则只会防止外部使用者对类的误用,但不会对类自己的实现起作用。

class Herd<out T : Animal> (private var leadAnimal : T, vararg animals : T) { ... }

现在可以安全地让HerdT上协变,因为属性leadAnimal被声明成了私有。

3.4 逆变:反转子类型化关系

逆变的概念可以看成是协变的镜像,对一个逆变类来说,它的子类型化关系与用作类型实参的类的子类型化关系是相反的:如果BA的子类型,那么Consumer<A>就是Consumer<B>的子类型。

Comparator接口为例,这个接口定义了一个compare方法,用于比较两个指定的对象:

interface Comparator<in T> {
    fun compare(e1 : T, e2 : T) : Int { ... }
}

这个接口方法只是消费类型为T的值,这说明T只在in位置使用,因此它的声明之前用了in关键字。

一个为特定类型的值定义的比较器显然可以比较该类型任意子类型的值,例如,如果有一个Comparator<Any>,可以用它比较任意具体类型的值。

val anyComparator = Comparator<Any> { e1, e2 -> e1.hashCode() - e2.hashCode() }
val strings : List<String> = ...
strings.sortedWith(anyComparator)

sortedWith期望一个Comparator<String>,传给它一个能比较更一般的类型的比较器是安全的。如果你要在特定类型的对象上执行比较,可以使用能处理该类型或者它的超类型的比较器。

这说明Comparator<Any>Comparator<String>的子类型,其中AnyString的超类型。不同类型之间的子类型关系这些类型的比较器之间的子类型关系 截然相反。

in关键字的意思是,对应类型的值是传递进来给这个类的方法的,并且被这些方法消费。和协变的情况类似,约束类型参数的使用将导致特定的子类型化关系。

一个类可以在一个类型参数上协变,同时在另外一个类型参数上逆变。Function接口就是一个经典的例子:

interface Function1<in P, out R> {
    operator fun invoke(p : P) : R
}

这意味着对这个函数类型的第一类型参数来说,子类型化反转了,而对于第二个类型参数来说,子类型化保留了。例如,你有一个高阶函数,该函数尝试对你所有的猫进行迭代,你可以把一个接收动物的lambda传递给它。

fun enumerate(f : (Cat) -> Number) { ... }
fun Animal.getIndex() : Int = ...

>> enumerate(Animal :: getIndex)

更多文章,欢迎访问我的 Android 知识梳理系列:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,117评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,963评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,897评论 0 240
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,805评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,208评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,535评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,797评论 2 311
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,493评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,215评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,477评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,988评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,325评论 2 252
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,971评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,807评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,544评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,455评论 2 266

推荐阅读更多精彩内容