教你如何完全解析Kotlin中的类型系统

5,577 阅读24分钟

简述: 已经很久没有更新文章,这大概是2019年第二篇文章了,有很多小伙伴们都在公众号留言说是不是断更了、是不是跑路了。在这里统一回复下我还好,并没有跑路哈,只是在思考接下来文章主要方向在哪? 如何在提升自己的同时可以帮助他人,以及这段时间也在不断认清自己和了解自己,发现自己哪里不足以及如何及时地查漏补缺。下面进入正题:

Kotlin类型系统其中涉及到一个很重要的概念就是大家常说的可空性以及为什么Kotlin相比Java在一定程度上能降低空指针异常。此外在Kotlin中完全采用和Java不同思路来定义它的类型系统。也正因为这样类型系统天然具有让Kotlin在空指针异常出现的频率明显低于Java出现的频率的优势。此外Kotlin考虑使用和Java完全不同类型系统,以及它是如何去做到极大兼容和互操作。

一、首先思考几个概念

在进入Kotlin类型系统之前,我们不妨先一起来思考以下几个概念,如果不明确这几个概念很难从根本上去理解Kotlin类型系统,以及Kotlin在类型系统方面为什么优于Java。

  • 1、类型的本质

类型本质是什么呢? 为什么变量拥有类型? 这两个问题在维基百科上给出了很好的回答. 类型实际上就是对数据的分类,决定了该类型上可能的值以及该类型的值上可以完成的操作。 需要特别去注意一下后面的阐述: "该类型上可能的值以及该类型的值上可以完成的操作。" 因为Java的类型系统其实并没有100%符合这个规则,所以这也是Java类型系统所存在的问题,下面会做出具体的分析。

  • 2、类与类型

关于 类型估计很多开发者往往忽略它们之间的区别,因为在真正的应用场景并不会区分这么细。我们在使用中往往会把类等同于类型,实际上是完全不同两个东西。其实在Java中也有体现,例如List<String>、Lis<Integer>List,对于前者List<String>List<Integer>只能是类型不能说是类, 而对于List它既可以是List类也可以是类型(Java中的原生类型)。其实在Kotlin则把这个概念提升到一个更高的层次,因为Kotlin中每个类多了一个可空类型,例如String类就对应两种类型String类型和String?可空类型。而在Java中除了泛型类型,每个类只对应一种类型(就是类的本身),所以往往被忽略。

我们可以把Kotlin中的类可分为两大类(Java也可以这样划分): 泛型类非泛型类

非泛型类

先说非泛型类也就是开发中接触最多的一般类,一般的类去定义一个变量的时候,它的实际就是这个变量的类型。例如: var msg: String 这里我们可以说Stringmsg变量的类型是一致的。但是在Kotlin中还有一种特殊的类型那就是可空类型,可以定义为var msg: String?,这里的Stringmsg变量的String?类型就不一样了。所以在Kotlin中一个一般至少对应两种类型. 所以类和类型不是一个东西。

泛型类

泛型类比非泛型类要更加复杂,实际上一个泛型类可以对应无限种类型。为什么这么说,其实很容易理解。我们从前面文章知道,在定义泛型类的时候会定义泛型形参,要想拿到一个合法的泛型类型就需要在外部使用地方传入具体的类型实参替换定义中的类型形参。我们知道在Kotlin中List是一个类,它不是一个类型。由它可以衍生成无限种泛型类型例如List<String>、List<Int>、List<List<String>>、List<Map<String,Int>>

  • 3、子类、子类型与超类、超类型

我们一般说子类就是派生类,该类一般会继承它的超类。例如: class Student: Person(),这里的Student一般称为Person的子类, PersonStudent的超类。

子类型和超类型定义则完全不一样,我们从上面类和类型区别就知道一个类可以有很多类型,那么子类型不仅仅是想子类那样继承关系那么严格。 子类型定义的规则一般是这样的: 任何时候如果需要的是A类型值的任何地方,都可以使用B类型的值来替换的,那么就可以说B类型是A类型的子类型或者称A类型是B类型的超类型。可以明显看出子类型的规则会比子类规则更为宽松。那么我们可以一起分析下面几个例子:

注意: 某个类型也是它自己本身的子类型,很明显Person类型的值任意出现地方,Person肯定都是可以替换的。属于子类关系的一般也是子类型关系。像String类型值肯定不能替代Int类型值出现的地方,所以它们不存在子类型关系

再来看个例子,所有类的非空类型都是该类对应的可空类型的子类型,但是反过来说就不行,就比如Person非空类型是Person?可空类型的子类型,很明显嘛,任何Person?可空类型出现值的地方,都可以使用Person非空类型的值来替换。其实这些我在开发过程中是可以体会得到的,比如细心的同学就会发现,我们在Kotlin开发过程,如果一个函数接收的是一个可空类型的参数,调用的地方传入一个非空类型的实参进去是合法的。但是如果一个函数接收的是非空类型参数,传入一个可空类型的实参编译器就会提示你,可能存在空指针问题,需要做非空判断。 因为我们知道非空类型比可空类型更安全。来幅图理解下:

二、Java类型系统存在空指针异常的本质问题

有了上述关于类型本质的阐述,我们一起来看下Java中的一些基本类型来套用类型本质的定义,来看看有什么问题。

  • 使用类型的定义验证int类型:

例如一个int类型的变量,那么表明它只能存储int类型的数据,我们都知道它用4个字节存储,数值表示范围是-2147483648 ~ 2147483647,那么规定该类型可能存在的值,然后我们可以对该类型的值进行运算操作。似乎没毛病,int类型和类型本质阐述契合的是如此完美。但是String类型呢?也是这样的吗?请接着往下看

  • 使用类型的定义验证String类型或其他定义类对应的类型:

例如一个String类型的变量,在Java中它却可以存在两种值: 一个是String类的实例另一种则是null。然后我们可以对这些值进行一些操作,第一种String类实例当然允许你调用String类所有操作方法,但是对于第二种null值,操作则非常有限,如果你强行使用null值去操作String类中的操作方法,那么恭喜你,你将获得一个NullPointerException空指针异常。在Java中为了程序的健壮性,这就要求开发者对String类型的值还得需要做额外的判断,然后再做相应的处理,如果不做额外判断处理那么就很容易得到空指针异常。 这就出现同一种类型变量存在多种值,却不能得到平等一致的对待。对比上述int类型的存在的值都是一致对待,所有该类型上所有可能的值都可以进行相同的运算操作。下面接着看着一个很有趣例子:

貌似连Java中的instanceof都不承认null是一个String类型的值。这两种值的操作也完全不一样: 真实的String允许你调用它的任何方法,而null值只允许非常有限的操作。那么Kotlin类型系统是如何解决这样的问题的呢? 请接着往下看。

三、Kotlin类型系统如何解决问题(为什么会设计出可空类型)

Java中的类型系统中String类型或其他自定义类的类型,貌似和类型本质定义不太符合,该类型的所有可能值却被区别对待,存在二义性。还得额外判断,直接问题就是给开发者带来了额外负担得做非空判断,一旦处理不好就会出现空指针导致程序崩溃。这就是Java中引发空指针问题的本质。

抓住问题的本质,Kotlin做一个很伟大的举措那就是类型的拆分,将Kotlin中所有的类型拆分成两种: 一种是非空类型,另一种则是可空类型;其中非空类型变量不允许null值的赋值操作,换句话说就是String非空类型只存在String类的实例不存在null值,所以针对String非空类型的值你可以大胆使用String类所有相关方法,不存在二义性。 当然也会存在null情况,那就可以使用可空类型,在使用可空类型的变量的时候编译器在编译时期会做针对可空类型做一定判断,如果存在可空类型的变量操作该对应类的方法,就提示你需要做额外判空处理,这时候开发者就根据提示去做判空处理了,想象下都这样处理了,你的Kotlin代码还会出现空指针吗?(但是有一点很重要就是定义了一个变量你需要明确它是可空还是非空,如果定义了可空类型你就需要对它负责,并且编译器也会提示帮助你对它做额外判空处理。)。一起来看下几个例子:

  • 1、非空类型变量或常量不能接收null值

  • 2、非空类型的变量或常量中is(相当于java中instanceof)

  • 3、可空类型的变量或常量直接操作相应方法会有明显的编译错误并提示判空操作

然而上面那些都是Java给不了你的,所以Java程序中一般会存在三种状态: 一种佛系判空,经常会出现空指针问题。另一种就是一股脑全部判空,可是代码中充斥着if-else代码,可读性非常差。最后一种就是非常熟悉程序逻辑以及数据流向的开发者可以正常判断出哪里需要判空处理,哪里可以不需要,这一种对开发者要求极高,因为人总是会犯错的。

四、可空类型

  • 1、安全调用运算符 "?."

?.相当于判空处理,如果不为null就执行?.后面的表达式,否则就返回null

text?.substring(0,2) //相当于 if(text != null) text.substring(0,2) else null

其实Kotlin为了类型判空处理可算是操碎了心,我们都知道在Java中做判空处理无非就是if-else? xxx : xxx三目运算符来实现。但是有时候出现嵌套判空的时候整个代码就是一个“箭头”,可读性就很差了。由以上例子可知?.if-else省了很多代码,这还无法完全显露它的优点,下面这个例子就更加明显了。

Java中的if-else 嵌套处理

Kotlin中的安全调用运算符?.链式调用处理

对比两种方式的实现你会不会觉得Kotlin也许更适合你呢,利用?.链式调用的方式把嵌套if-else处理解开了。

  • 2、Elvis运算符 "?:"

如果?:前面表达式为null, 就执行?:后面的表达式,它一般会和?.一起使用。(注意: 它与Java中的? xxx : xxx 三目运算符不一样) carbon (29).png

  • 3、安全类型转化运算符 as?

如果类型转化失败就返回null值,否则返回正确的类型转化后的值

val student = person as? Student//相当于 if(person is Student) person as Student else null
  • 4、非空断言运算符 !!契约(contract) 简化非空表达式

非空断言运算符!!, 是强制告诉编译器这个变量的值不可能null,存在使用风险。一旦存在为null直接抛出空指针异常

很多Kotlin开发者很厌恶这个操作符,觉得写起来不优雅很影响代码的可读性,关于如何避免在Kotlin的代码中使用 !! 操作符。请参考我之前的一篇文章 [译]如何在你的Kotlin代码中移除所有的!!(非空断言).

其实是非空断言的使用场景是存在的,例如你已经在一个函数中对某个变量进行判空处理了,但是后面逻辑中再次使用到了它并且你可以确定它不可能为空,可能此时编译器无法识别它是否是非空,但由于它又是一个可空类型,那么它又会提示你进行判空处理,很烦人是不,很多人这时候可能就采用了 !! 确实缺乏可读性。

针对上述问题,除了之前文章中给出解决方案,这次又提供一个新的解决方案,那就是契约(实际上主动告诉编译器某个规则,这样它就不会提示做判空处理了) 契约官方正式提出来是Kotlin1.3的版本,虽然还处于Experimental(比如自定义契约)中,但是实际上Kotlin内部代码,早就使用了契约。具体使用可参考我之前的一篇文章 JetBrains开发者日见闻(二)之Kotlin1.3的新特性(Contract契约与协程篇) 一起来看下内置契约是如何解决这个问题的。

一起来瞅瞅内置契约的内部实现源码

通过上述我们可以知道在Kotlin中拥有着与Java中完全不一样的类型系统。在Java中是不存在所谓的可空类型和非空类型。但是我们都知道Kotlin与Java的互操性很强,几乎是完全兼容Java。那么Kotlin是如何兼容Java中的变量类型的呢?我们在Kotlin中肯定需要经常调用Java代码,有的人可能会回答说Java中使用@NotNull和@Nullable注解来标识。确实Kotlin可以识别多种不同风格的注解,包括javax.annotationandroid.support.annotationorg.jetbrains.annotation等。但是一些之前的第三方库并没有写的这么规范,显然无法通过这种方式完全解决这个问题。

所以Kotlin引入一种新的概念叫做: 平台类型,平台类型本质上就是Kotlin不知道可空性信息的类型,既可以把它当做可空类型又可以把它当做非空类型。 这就意味你要像Java代码中一样对你在这个类型上做的操作负全部责任,说的有味道点就是你在Java中拉的便便,Kotlin是不会给你擦屁股的。所以对于Java中函数参数,Kotlin去调用的时候系统默认会处理可空类型(为了安全性考虑),如果你明确了不为空,可以直接把它修改为非空类型,系统也是不为报编译错误的,但是一旦这样处理了,你必须保证不能为空。

那么问题来了,很多人就疑问出于安全性考虑为什么不直接全部转化可空类型呢? 实际上这种方案看似可行,实际上有点不妥,对于一些明确不可能为空的变量还需要做大量额外的判空操作就显得冗余。否则非空类型就没有存在的意义了。

五、基本数据类型和其他基本类型

  • 1、基本数据类型

我们都知道在Java中针对基本数据类型和包装类型做了区分。例如一个基本数据类型int的变量直接存储了它的值。而一个引用类型(包装类型) String的变量仅仅存储的是指向该对象的内存地址的引用。基本数据类型有着天然的高效存储以及传递的优势,但是不能直接调用这些类型的方法,而且在Java中集合中不能将它作为泛型实参类型。

实际上在Kotlin中并没有像Java那样分为了基本数据类型和包装类型,在Kotlin中永远是同一种类型。很多人估计会问了既然在Kotlin中基本数据类型和包装类型是一样的,那么是不是意味着Kotlin是使用引用类型来保存数据呢?是不是非常低效呢?不是这样的,Kotlin在运行时尽量会把Int等类型转换成Java中的int基本数据类型,而遇到类似集合或泛型的时候就会转化成Java中对应的Integer等包装类型。这实际上是一个底层优化,至于什么场景转化成int,什么场景转化成Integer,关于这块可以参考之前一篇有关内联类自动装箱和拆箱的文章: [译]Kotlin中内联类的自动装箱和高性能探索(二)

基本数据类型也分为可空类型和非空类型, 具体可参考如下的类型层次结构图:

  • 2、Any和Any?类型

Any类型是所有非空类型的超类型,Any?类型则是所有的类型的超类型,即是非空类型的超类型也是所有可空类型的超类型。因为Any?是Any的超类型。具体的层次可参考下面这张图:

  • 3、Unit类型

Unit类型也即是Kotlin中的空类型,相当于Java中的void类型,默认情况下它可以被省略

  • 4、Nothing类型

Nothing类型是所有类型的子类型,它既是所有非空类型的子类型也是所有可空类型的子类型,因为Nothing是Nothing?的子类型,然而Nothing?又是所有可空类型的子类型。 具体可以看下如下的层次结构图:

六、集合和数组类型

  • 1、可变集合与只读集合之间的区别和联系(以Collection集合为例) Collection只读集合与MutableCollectio可变集合区别:

在Collection只具有访问元素的方法,不具有类似add、remove、clear之类的方法,而在MutableCollection中则相比Collection多出了修改元素的方法。

Collection只读集合与MutableCollectio可变集合联系:

MutableCollection实际上是Collection集合接口的子接口,他们之间是继承关系。

  • 2、集合之间类的关系

通过Collection.kt文件中可以了解到有这些集合Iterable(只读迭代器)和MutableIterable(可变迭代器)、Collection和MutableCollection、List和MutableList、Set和MutableSet、Map和MutableMap。那么它们之间的类关系图是怎样的。

Iterable和MutableIterable接口分别是只读和可变集合的父接口,Collection继承Iterable然后List、Set接口继承自Collection,Map接口比较特殊它是单独的接口,然后MutableMap接口是继承自Map.

  • 3、Java中的集合与Kotlin中集合对应关系

我们刚刚说到在Kotlin中集合的设计与Java不一样,但是每一个Kotlin的接口都是其对应的Java集合接口的一个实例,也就是在Kotlin中集合与Kotlin中的集合存在一定的对应关系。Java中的ArrayList类和HashSet类实际上Kotlin中的MutableList和MutableSet集合接口的实现类。把这种关系加上,上面的类关系图可以进一步完善。

  • 4、集合的初始化

由于在Kotlin中集合主要分为了只读集合和可变集合,那么初始化只读集合和可变集合的函数也不一样。以List集合为例,对于只读集合初始化一般采用listOf()方法对于可变集合初始化一般采用mutableListOf()或者直接创建ArrayList<E>,因为mutableListOf()内部实现也是也还是采用创建ArrayList,这个ArrayList实际上是Java中的java.util.ArrayList<E>,只不过在Kotlin中使用typealias(关于typealias的使用之前博客有过详细介绍)取了别名而已。关于具体内容请参考这个类kotlin.collections.TypeAliasesKt实现

  • 5、集合使用的注意事项

注意点一: 在代码的任何地方都优先使用只读集合,只在需要修改集合的情况下才去使用可变集合

注意点二: 只读集合不一定是不可变的,关于这个只读和不可变类似于val的只读和不可变原理。

注意点三: 不能把一个只读类型的集合作为参数传递给一个带可变类型集合的函数。

  • 6、平台类型的集合转化规则

正如前面所提及的可空性平台类型一样,Kotlin中无法知道可空性信息的类型,既可以把它当做可空类型又可以把它当做非空类型。集合的平台类型和这个类似,在Java中声明的集合类型的变量也被看做平台类型一个平台类型的集合本质上就是可变性未知的集合,Kotlin中可以把它看做是只读的集合或者是可变的集合. 实际上这都不是很重要,因为你只需要根据你的需求选择即可,想要执行的所有操作都能正常工作,它不像可空性平台存在额外判断操作以及空指针风险。

注意: 可是当你决定使用哪一种Kotlin类型表示Java中集合类型的变量时,需要考虑以下三种情况:

  • 1、集合是否为空?

如果为空转换成Kotlin中集合后面添加 ?,例如Java中的List<String>转化成Kotlin中的List<String>?

  • 2、集合中的元素是否为空?

如果为空转换成Kotlin中集合泛型实参后面添加 ?,例如Java中的List<String>转化成Kotlin中的List<String?>

  • 3、操作方法会不会修改集合?(集合的只读或可变)

如果是只读的,例如Java中的List<String>转化成Kotlin中的List<String>;如果是可变的,例如Java中的List<String>转化成Kotlin中的MutableList<String>.

注意: 当然上面三种情况可以一种或多种同时出现,那么转化成Kotlin中的集合类型也是多种情况最终重组的类型。

七、总结

到这里有关Kotlin的类型系统基本就说得差不多,该涉及到的内容基本都涉及了。其实仔细去体会下为什么Kotlin的类型系统要如此设计,确实是它一定道理的。我们经常听别人夸Kotlin比Java优点是啥,很多人都说少了很多空指针异常,但是为什么能Kotlin相比Java有更少的空指针异常相信这篇文章也足够回答你了吧。

接下来再扯点别的大家都知道Android开发已经进入了一个平稳期了, 泡沫逐渐散去, 那么对Android开发者的要求也会越来越高,只会使用的API时代早已经过去了,所以开发者需要不断调整自己不断提升自己的能力来面对这些变化。分析过源码的小伙伴就知道看懂源码其中最关键点就是源码中使用的数据结构算法以及使用一些高级的设计模式。正因为这样后期文章方向会针对数据结构算法、设计模式、源码分析这块做一定输出,近期计划是每周一篇Kotlin相关文章(原创或翻译),每周一篇设计模式相关和每周一篇数据结构算法相关(结合LeetCode上的题目)

欢迎关注Kotlin开发者联盟,这里有最新Kotlin技术文章,每周会不定期翻译一篇Kotlin国外技术文章。如果你也喜欢Kotlin,欢迎加入我们~~~

Kotlin系列文章,欢迎查看:

原创系列:

Effective Kotlin翻译系列

翻译系列:

实战系列: