邀你一同畅游Kotlin类型系统,探索那些我们认为理所当然的语言结构背后的原理。
声明
本文中关于类型体系结构的灵感源自Nat Pryce的两年前的文章以及在2017 KotlinConf上Svetlana Ivanova 和 Christina Lee的演讲。
什么是类型?
首先讨论一下什么是类型?所谓类型就是编译器对于某个对象所期望的定义,它有哪些属性和方法可以调用,在哪里可以作为参数传值,在哪里能够被赋值。
在Kotlin中,每个变量,属性,参数和表达式在编译时都有类型 - 静态类型可以确保不会调用到不存在的方法,消除了动态语言在运行时可能发生的一些错误。
需要强调的是,类型不等同于类(type ≠ class)。每当声明类,接口,对象或者类型别名(typealias)时都会创建一个新的类型。事实上,伴随声明语句,都会创建大量的新的类型。譬如说定义一个空类:
class Hello
通过定义这个类,我们已经创建了Hello
和Hello?
两个类型。这是两个不同的类型,而且它们的区别很大。编译器会强制我们显示地对可能为空的Hello?
类型进行空指针判断,而在使用Hello
类型进行比较操作的时候就无须进行校验,因为它知道这样做是安全的。
通过类型参数创建类会引出更多的类型:
class List<T>
事实上,这个不受控制的T
类型参数引出了无限多的类型。可能是List<String>
,List<String>?
,List<String?>
,List<String?>?
,List<List<Int>>
,List<in String>
,List<*>
...
Any类型
与类(class)相似,类型也存在于某个体系之中。我们先抛开可空类型,只探讨非可空类型。这与相应的类体系结构一模一样。
https://www.kotlindevelopment.com/content/images/2018/09/typical_simplest.pngKotlin类型体系结构中的根节点是Any
类型(正如Any
类是类体系结构中的根节点一样)。这个类型定义了一些基本的函数,例如equals
,hashCode
以及toString
,Kotlin中所有的对象实例都具有这些函数,Any
是所有类型的超类型。
超类型(Supertype): Y具有一个X类型的超类型,说明任何Y类型的对象都可以替换X类型,其所表现的行为与X所期待的行为一致。(这就是里是替换原则, SOLID原则中的一种)
一个类如果没有显示地声明父类,就说明它继承自Any
类,所以由此类生成的(非空)类型的超类型就是Any
类型。上文中的Hello
类就是如此。Kotlin并不区分原生类型和包装类型,原生类型(Int
, Double
, Boolean
)的超类型同样是Any
。
子类型:与超类型相反。当且仅当X是Y的超类型的情况下,Y才是X的子类型。
(通常的数字类型并不是Any
的直接子类型,它们是Number
的子类型,Number
定义了数字类型的一些通用方法。)
平行的类型体系结构(可空类型和非空类型)
下面把可空类型加入到类型体系结构图中。非空类型和可空类型的关系简单明晰。非空类型是一个更为具体的类型,它能接受的赋值是可空类型的一个子集。
https://www.kotlindevelopment.com/content/images/2018/09/typical_hello_nulls.png将这个关系应用到每个类型上,就可以得到一个平行的类型体系结构图:
https://www.kotlindevelopment.com/content/images/2018/09/typical_nulls.png从上图可以看出实际上类型体系结构图的根类型是Any?
类型。也就是说Any?
类型的变量可以存储任意的对象。
校验是不是所有没有直接联系的类型都符合上面的类型体系结构。譬如说,在上图中Double
是Number?
的子类型。这也的确说得通,Double
可以产生Number?
所期待发生的所有行为。直觉告诉我们任何一个Double
类型的对象都可以安全地存储在Number?
类型的变量之中。
Elvis操作符
通常情况下当一个值有可能为null
,并且在它为null
的时候希望给它赋予一个具体的值,就会使用到Elvis操作符。相信你已经知道Elvis操作符的作用了:如果操作符的左侧是null
,就会返回右侧的结果。问题来了,整个表达式的返回类型是什么?
通常情况下,Elvis表达式会返回一个与左侧相同的非空类型对象:
val maybeString: String? = null
val definitelyString = maybeString ?: "replacement"
这个例子非常直观,正如我们所希望的那样,definitelyString
的类型是String
。
但是编译器是如何确定正确的类型?如果操作符的两侧返回不同的类型,而不是可空和非空的相同类型呢?
下面通过几个具体的例子解答这个问题。例子中涉及到的类型体系结构图:
https://www.kotlindevelopment.com/content/images/2018/09/animals_0.png左右两侧都是可空类型
假设Elvis操作符的两侧是Dog?
和Horse?
:
- 在体系结构图中找到这两个类型;
- 找出两个类型第一个共同的超类型,校验该类型是否符合。
两个类型第一个共同的超类型是Animal?
。正确答案似乎就是Animal?
,无论是Dog
,Horse
还是null
,Animal?
类型都可以持有这些值 - 通过排查,发现没有其他可能类型。通过IntelliJ的类型推导可以验证我们的猜想是正确的。
右侧为可空类型
将上面的表达式左侧改成非空类型。
https://www.kotlindevelopment.com/content/images/2018/09/animals_2_0.png- 在体系结构图中找到两个类型;
- 找到两个类型第一个共同的超类型。无论选择哪条路径,最终的结果都是相同的。
这次的路径稍长,但是结果也是显而易见的。使用IntelliJ可以验证结果的正确性。但是回过头来想一下,Elvis操作符显然是多余的 - 左侧的结果永远不可能为null
。尽管如此,如果忽略IDE的警告,表达式的返回值就是Animal?
。
左侧为可空类型
左侧为非空类型的例子没有意义,下面看一下最后一种情况:
https://www.kotlindevelopment.com/content/images/2018/09/animals_3_0.png- 在体系结构图中找到两个类型;
- 找到两个类型第一个共同的超类型。同样,这里存在多条路径,但是结果都是
Animal?
。
这次的结果似乎错了。仔细想一下,可空类型Animal?
是不是过于谨慎了。如果左侧的表达式结果为null
,就会使用右侧的结果,而表达式的右侧为非空类型Horse
。如果左侧的表达式结果非null
,则表达式的结果为非空类型Dog
。
也就是说表达式最合理的返回类型应该是Animal
。那么哪里出错了呢?为什么我们按照上面的方法得出的结果是可空类型?错就错在我们认为左侧的返回类型是可空的,而实际上Elvis表达式永远不会从左侧返回null
(只可能从右侧返回null
)。
为了纠正这个错误,左侧的表达式都返回非空类型,然后再找到两个类型的超类型。
https://www.kotlindevelopment.com/content/images/2018/09/animals_3_3.png在这个例子中,将可空类型Dog?
回退到非空类型Dog
,然后再找到它与Horse
的第一个共同的超类型 - Animal
。
Unit类型
在Kotlin中Unit
是一个特殊的类型。官方文档中将其描述为只有一个值的类型。查看Unit
源码可以发现Unit
被声明为单例object
。源码如下:
public object Unit {
override fun toString() = "kotlin.Unit"
}
编译器还针对Unit
做了一些特殊处理。
其中最为重要的一点就是:如果函数没有任何有意义的返回值,就会返回Unit
。虽然返回值没有意义,但是这么做可以让所有的函数都有相同的语法形式:一定会返回某个类型。与之相对应的,Java使用void
作为特殊的返回值 - 这时候就无法将返回类型赋值到某个变量上了。
在处理泛型时,Unit
同样好用 - 假设存在一个Task<T>
类型,需要有一个实例表示任务(Task)完成时没有结果,Java中就需要引入一个新的类型Void
,它与void
完全不同。在Kotlin中,则可以使用Unit
描述缺乏意义的数据。
Nothing 和 Nothing? 类型
一个永远不存在的值
乍看上去,Nothing
与Unit
完全相同。Nothing
是一个永远不会被实例化的类。它的源码非常简短:
public class Nothing private constructor()
在使用Nothing
时会引发一些有趣的结果。例如,假设函数的返回值为Nothing
,因为永远无法获取一个Nothing
的实例,所以用户就知道这个函数永远不会返回。这与返回Unit
的函数不同 - 它的确会返回,只不过返回值没有意义。
永远不会返回的函数长什么样?有可能这个函数包含一个无限循环,或者直接抛出异常:
fun loopy(): Nothing {
while (true) {
println("Loop!")
}
}
fun exceptional(): Nothing {
throw IllegalStateException()
}
虽然编译器知道函数的控制流,也了解它们没有return
语句,但编译器还是允许这些函数通过编译。即便这些函数只能返回Unit
,但是我们可以显示地标记函数的返回类型为Nothing
。
这么做有什么好处?此时IDE可以警告我们调用返回值为Nothing
的函数后面的代码永远不会被执行:
Nothing作为底层类型(Bottom Type)
编译器会对Nothing
类型做一些特殊处理。因为它永远不会被实例化,所以可以将它作为类型系统的底层类型
底层类型(Bottom Type):在类型系统中,底层类型就是所有类型的子类型。
引入Nothing
后的类型体系结构图:
为什么Nothing
能够作为底层类型?试想一下 - 如果你需要一个具体的类型,无论是Int
,Dog
还是AbstractSingletonProxyFactoryBean
,因为Nothing
永远不会被实例化,此时返回Nothing
是安全的。
例如,loopy
函数永远不会返回,将其结果复制给变量data,然后调用processData
函数,这时是可以通过类型检查的,即便这么做没有什么意义:
fun processData(data: List<String>) {
// Use data
}
fun main(args: Array<String>) {
val data: Nothing = loopy()
processData(data)
}
processData
的具体参数并不重要。它可以是任意类型,传递Nothing
都是安全的(实际上永远不可能真的把Nothing传入该函数)。
再举一个例子,这个例子会让Nothing
作为底层类型更具有说服力。
Nothing 和 Elvis操作符
下面把Nothing
和Elvis操作符放到一起讨论。
思考一下当Nothing
类型在Elvis
操作符的右侧。如果左侧的表达式结果非空,则会返回左侧的结果,然后继续执行后续的代码。然后,如果必须要计算右侧的值,正在执行的函数就会停止,因为计算右侧的结果要么永远无法终止要么就以抛出异常的形式终止。
举个例子:
fun calculate(someParam: Int?) {
val x = someParam ?: exceptional()
val y = x * 2
println(y)
}
注意第2行,x
是一个Int
类型的对象,这段代码编译正常,运行正确。直观上这是讲得通的。如果左侧为空,调用了exceptional()
函数,calcualte()
函数由于抛出异常将不会继续执行,其他情况x会赋值为一个非空的Int
类型。
再次看看类型体系结构图,Elvis表达式的返回类型应该是:
https://www.kotlindevelopment.com/content/images/2018/09/typical_nothing_elvis.png首先将Int?
类型回退到非空的Int
类型,然后找到Int
和Nothing
的第一个共同超类型,由于Nothing
是底层类型,所以最终的结果就是Int
类型!实际上任何左侧为可空类型,右侧为Nothing
的Elvis表达式都是如此:返回类型是左侧类型的非空类型。
这种方式非常优雅,可以针对null
值抛出异常,并且接下里的代码中可以方便地使用非空类型。
返回Nothing
类型就不需要将抛出异常包装到函数中。在Kotlin中throw
也是一个表达式,并且它的类型是Nothing
。所以代码也可以这么写:
fun calculate(someParam: Int?) {
val x = someParam ?: throw IllegalArgumentException("someParam must not be null")
val y = x * 2
println(y)
}
此外,还有另外一个简单的表达式可以处理这种情况 - return
表达式。可以在参数无效的时候停止执行该函数。
fun calculate(someParam: Int?) {
val x = someParam ?: return
val y = x * 2
println(y)
}
在这里我们构造了一个本质上与参数具有相同类型的变量,即浪费资源又让人难以理解,特别是对于一个长函数。在Google I/O ‘17 年的演讲上我看到了一个小技巧:
fun calculate(someParam: Int?) {
someParam ?: return
val y = someParam * 2
println(y)
}
为什么这么写能够行得通?由于Elvis表达式(和其他表达式一样)即便它的值没有被使用也会进行计算,所以如果参数为null
,就会从函数中返回。而下一行中,someParam
会被智能转换成Int
。(使用throw
也是可以的。)
throw return
由于return
和throw
表达式的返回类型都是Nothing
,所以在Kotlin中,下面这行奇怪的语句是可以编译通过的:
fun oddity() {
throw return return throw return
}
为什么这都可以编译通过?return
和throw
都有一个参数需要在执行前进行计算,throw
的参数是Throwable
,return
则是根据函数的返回类型 - 在这个例子中,就是Unit
,我举的这个例子很特别,但是如果返回类型是Int
时,以return 82
结尾也是可以的。
所以,第一个throw
执行时,需要一个Throwable
参数。紧挨着的return
表达式返回类型是Nothing
,当然也是一个Throwable
。同样的道理,无论函数的返回值是什么类型,写throw
语句或者return
接着一个return
语句都能通过类型检查,因为Nothing
都符合返回类型的要求。
现在应该能理解为什么上面的代码能够编译了。当运行这段代码时发生了什么呢?第一个表达式的参数需要被计算(evaluate),而它的参数是第二个表达式,所以紧接着第二个表达式的参数需要被计算,如此往复,直到最后这条链的最后,只有最后一个表达式被执行(execute)了,因为无论是throw
还是return
都会退出当前函数。
Nothing?
当提出Nothing
作为底层类型时,在类型体系结构图中更新了两个新的类型,目前为止我们都只是在讨论非空类型Nothing
。那么可空类型是否合理?它对于我们是否有用?
Nothing?
是所有可空类型的子类型,也就是说Nothing?
可以在任何要求是可空类型的地方被使用。我们都知道有一个值符合这个描述 - null
。
可以通过将null
赋值给某个变量验证我们的猜想。
val x = null
可以通过反射或者IDE检查x
的类型是不是Nothing?
。也许你永远都不会将某个不可变的变量声明为null
,因为你将无法对它再次赋值(。
一个典型的错误如下:
var x = null
x = "foo"
虽然可空的String?
类型完美契合x
的要求,但是编译器的类型推导还是会提示错误:
Type mismatch. Required: Nothing? Found: String
希望这个错误没有吓坏你,而是帮助你快速定位错误,然后通过显示地指定x
的类型为String?
解决这个错误。
推荐阅读
想要了解更多关于Nothing
方面的内容,推荐阅读下面两篇文章。
- http://oneeyedmen.com/nothing-can-save-us.html [译者注:这个网页打不开]
- Kotlin’s Nothing: Its Usefulness in Generics
总结
希望你能从本文中学到一些新东西,并且能够领会到类型系统的巧妙之处。别忘了尝试使用throw return
语句。
网友评论