美文网首页Kotlin
[译]漫谈Kotlin类型

[译]漫谈Kotlin类型

作者: HanderWei | 来源:发表于2018-09-29 10:05 被阅读354次

    原文:Typical Kotlin

    邀你一同畅游Kotlin类型系统,探索那些我们认为理所当然的语言结构背后的原理。

    声明

    本文中关于类型体系结构的灵感源自Nat Pryce的两年前的文章以及在2017 KotlinConf上Svetlana IvanovaChristina Lee的演讲。

    什么是类型?

    首先讨论一下什么是类型?所谓类型就是编译器对于某个对象所期望的定义,它有哪些属性和方法可以调用,在哪里可以作为参数传值,在哪里能够被赋值。

    在Kotlin中,每个变量,属性,参数和表达式在编译时都有类型 - 静态类型可以确保不会调用到不存在的方法,消除了动态语言在运行时可能发生的一些错误。

    需要强调的是,类型不等同于类(type ≠ class)。每当声明类,接口,对象或者类型别名(typealias)时都会创建一个新的类型。事实上,伴随声明语句,都会创建大量的新的类型。譬如说定义一个空类:

    class Hello
    

    通过定义这个类,我们已经创建了HelloHello?两个类型。这是两个不同的类型,而且它们的区别很大。编译器会强制我们显示地对可能为空的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.png

    Kotlin类型体系结构中的根节点是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?类型的变量可以存储任意的对象。

    校验是不是所有没有直接联系的类型都符合上面的类型体系结构。譬如说,在上图中DoubleNumber?的子类型。这也的确说得通,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?:

    https://www.kotlindevelopment.com/content/images/2018/09/animals_1_0.png
    1. 在体系结构图中找到这两个类型;
    https://www.kotlindevelopment.com/content/images/2018/09/animals_1_1.png
    1. 找出两个类型第一个共同的超类型,校验该类型是否符合。
    https://www.kotlindevelopment.com/content/images/2018/09/animals_1_2.png

    两个类型第一个共同的超类型是Animal?。正确答案似乎就是Animal?,无论是DogHorse还是nullAnimal?类型都可以持有这些值 - 通过排查,发现没有其他可能类型。通过IntelliJ的类型推导可以验证我们的猜想是正确的。

    右侧为可空类型

    将上面的表达式左侧改成非空类型。

    https://www.kotlindevelopment.com/content/images/2018/09/animals_2_0.png
    1. 在体系结构图中找到两个类型;
    https://www.kotlindevelopment.com/content/images/2018/09/animals_2_1.png
    1. 找到两个类型第一个共同的超类型。无论选择哪条路径,最终的结果都是相同的。
    https://www.kotlindevelopment.com/content/images/2018/09/animals_2_2.png

    这次的路径稍长,但是结果也是显而易见的。使用IntelliJ可以验证结果的正确性。但是回过头来想一下,Elvis操作符显然是多余的 - 左侧的结果永远不可能为null。尽管如此,如果忽略IDE的警告,表达式的返回值就是Animal?

    左侧为可空类型

    左侧为非空类型的例子没有意义,下面看一下最后一种情况:

    https://www.kotlindevelopment.com/content/images/2018/09/animals_3_0.png
    1. 在体系结构图中找到两个类型;
    https://www.kotlindevelopment.com/content/images/2018/09/animals_3_1.png
    1. 找到两个类型第一个共同的超类型。同样,这里存在多条路径,但是结果都是Animal?
    https://www.kotlindevelopment.com/content/images/2018/09/animals_3_2.png

    这次的结果似乎错了。仔细想一下,可空类型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? 类型

    一个永远不存在的值

    乍看上去,NothingUnit完全相同。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的函数后面的代码永远不会被执行:

    https://www.kotlindevelopment.com/content/images/2018/09/unreachable_nothing.PNG

    Nothing作为底层类型(Bottom Type)

    编译器会对Nothing类型做一些特殊处理。因为它永远不会被实例化,所以可以将它作为类型系统的底层类型

    底层类型(Bottom Type):在类型系统中,底层类型就是所有类型的子类型。

    引入Nothing后的类型体系结构图:

    https://www.kotlindevelopment.com/content/images/2018/09/typical_nothing.png

    为什么Nothing能够作为底层类型?试想一下 - 如果你需要一个具体的类型,无论是IntDog还是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类型,然后找到IntNothing的第一个共同超类型,由于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

    由于returnthrow表达式的返回类型都是Nothing,所以在Kotlin中,下面这行奇怪的语句是可以编译通过的:

    fun oddity() {
        throw return return throw return
    }
    

    为什么这都可以编译通过?returnthrow都有一个参数需要在执行前进行计算,throw的参数是Throwablereturn则是根据函数的返回类型 - 在这个例子中,就是Unit,我举的这个例子很特别,但是如果返回类型是Int时,以return 82结尾也是可以的。

    所以,第一个throw执行时,需要一个Throwable参数。紧挨着的return表达式返回类型是Nothing,当然也是一个Throwable。同样的道理,无论函数的返回值是什么类型,写throw语句或者return接着一个return语句都能通过类型检查,因为Nothing都符合返回类型的要求。

    现在应该能理解为什么上面的代码能够编译了。当运行这段代码时发生了什么呢?第一个表达式的参数需要被计算(evaluate),而它的参数是第二个表达式,所以紧接着第二个表达式的参数需要被计算,如此往复,直到最后这条链的最后,只有最后一个表达式被执行(execute)了,因为无论是throw还是return都会退出当前函数。

    Nothing?

    当提出Nothing作为底层类型时,在类型体系结构图中更新了两个新的类型,目前为止我们都只是在讨论非空类型Nothing。那么可空类型是否合理?它对于我们是否有用?

    https://www.kotlindevelopment.com/content/images/2018/09/typical_nothing_null_only.png

    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方面的内容,推荐阅读下面两篇文章。

    1. http://oneeyedmen.com/nothing-can-save-us.html [译者注:这个网页打不开]
    2. Kotlin’s Nothing: Its Usefulness in Generics

    总结

    希望你能从本文中学到一些新东西,并且能够领会到类型系统的巧妙之处。别忘了尝试使用throw return语句。

    个人博客

    Hander's Blog

    相关文章

      网友评论

        本文标题:[译]漫谈Kotlin类型

        本文链接:https://www.haomeiwen.com/subject/qarvoftx.html