Kotlin类型系统

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

1. 梳理概念

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

1.1 类型的本质

类型本质是什么呢? 为什么变量拥有类型? 这两个问题在维基百科上给出了很好的回答:

类型实际上就是对数据的分类,决定了该类型上可能的值以及该类型的值上可以完成的操作。 需要特别去注意一下后面的阐述: “该类型上可能的值以及该类型的值上可以完成的操作。” 因为Java 的类型系统其实并没有 100% 符合这个规则,所以这也是 Java 类型系统所存在的问题,下面会做出具体的分析。

1.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>>

1.3 子类、子类型与超类、超类型

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

子类型和超类型定义则完全不一样,我们从上面类和类型区别就知道一个类可以有很多类型,那么子类型不仅仅是想子类那样继承关系那么严格。

子类型定义的规则一般是这样的: 任何时候如果需要的是A类型值的任何地方,都可以使用B类型的值来替换的,那么就可以说B类型是A类型的子类型或者称A类型是B类型的超类型。可以明显看出子类型的规则会比子类规则更为宽松。那么我们可以一起分析下面几个例子:

图片描述

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

再来看个例子,所有类的非空类型都是该类对应的可空类型的子类型,但是反过来说就不行,就比如Person非空类型是Person?可空类型的子类型,很明显嘛,任何Person?可空类型出现值的地方,都可以使用Person非空类型的值来替换。

其实这些我在开发过程中是可以体会得到的,比如细心的同学就会发现,我们在 Kotlin 开发过程,如果一个函数接收的是一个可空类型的参数,调用的地方传入一个非空类型的实参进去是合法的。但是如果一个函数接收的是非空类型参数,传入一个可空类型的实参编译器就会提示你,可能存在空指针问题,需要做非空判断。 因为我们知道非空类型比可空类型更安全。来幅图理解下:

图片描述

2. Java类型系统存在NPE的本质原因

有了上述关于类型本质的阐述,我们一起来看下 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 类型系统是如何解决这样的问题的呢? 请接着往下看。

3. Kotlin类型系统如何解决问题

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

抓住问题的本质,Kotlin 做一个很伟大的举措那就是类型的拆分,将 Kotlin 中所有的类型拆分成两种:一种是非空类型,另一种则是可空类型;其中非空类型变量不允许null值的赋值操作,换句话说就是String非空类型只存在String类的实例不存在null值,所以针对String非空类型的值你可以大胆使用String类所有相关方法,不存在二义性

当然也会存在 null 情况,那就可以使用可空类型,在使用可空类型的变量的时候编译器在编译时期会做针对可空类型做一定判断,如果存在可空类型的变量操作该对应类的方法,就提示你需要做额外判空处理,这时候开发者就根据提示去做判空处理了,想象下都这样处理了,你的 Kotlin 代码还会出现空指针吗?(但是有一点很重要就是定义了一个变量你需要明确它是可空还是非空,如果定义了可空类型你就需要对它负责,并且编译器也会提示帮助你对它做额外判空处理)。一起来看下几个例子:

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

图片描述

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

图片描述

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

图片描述

然而上面那些都是 Java 给不了你的,所以 Java 程序中一般会存在三种状态:一种佛系判空,经常会出现空指针问题。另一种就是一股脑全部判空,可是代码中充斥着if-else代码,可读性非常差。

最后一种就是非常熟悉程序逻辑以及数据流向的开发者可以正常判断出哪里需要判空处理,哪里可以不需要,这一种对开发者要求极高,因为人总是会犯错的。

4. 可空类型

4.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处理解开了。

4.2 Elvis运算符 "?:"

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

图片描述

4.3 安全类型转化运算符 as?

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

val student = person as? Student//相当于 if(person is Student) person as Student else null

4.4 非空断言运算符 !!契约(contract)

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

很多 Kotlin 开发者很厌恶这个操作符,觉得写起来不优雅很影响代码的可读性,关于如何避免在Kotlin 的代码中使用 !! 操作符。

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

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

图片描述

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

图片描述

4.5 兼容Java的平台类型

通过上述我们可以知道在 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 去调用的时候系统默认会处理可空类型(为了安全性考虑),如果明确了不为空,可以直接把它修改为非空类型,系统也是不为报编译错误的,但是一旦这样处理了,必须保证不能为空。

图片描述

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

5. 基本数据类型和其他基本类型

5.1 基本数据类型

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

实际上在Kotlin中并没有像Java那样分为了基本数据类型和包装类型,在Kotlin中永远是同一种类型。很多人估计会问了既然在Kotlin中基本数据类型和包装类型是一样的,那么是不是意味着Kotlin是使用引用类型来保存数据呢?是不是非常低效呢?不是这样的,Kotlin在运行时尽量会把Int等类型转换成Java中的int基本数据类型,而遇到类似集合或泛型的时候就会转化成Java中对应的Integer等包装类型。

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

5.2 Any 和 Any?类型

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

图片描述

5.3 Unit 类型

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

5.4 Nothing 类型

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

图片描述

6. 集合和数组类型

6.1 可变集合与只读集合之间的区别和联系(以Collection集合为例)

Collection 只读集合与 MutableCollectio 可变集合区别:

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

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

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

图片描述

6.2 集合之间类的关系

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

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

图片描述

6.3 Java 中的集合与 Kotlin 中集合对应关系

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

图片描述

6.4 集合的初始化

由于在 Kotlin 中集合主要分为了只读集合和可变集合,那么初始化只读集合和可变集合的函数也不一样。以 List 集合为例,对于只读集合初始化一般采用listOf()方法对于可变集合初始化一般采用mutableListOf()或者直接创建 ArrayList<E>

因为 mutableListOf() 内部实现也是也还是采用创建ArrayList,这个 ArrayList 实际上是 Java 中的 java.util.ArrayList<E>,只不过在 Kotlin 中使用 typealias (关于 typealias 的使用之前有过详细介绍)取了别名而已。关于具体内容请参考这个类kotlin.collections.TypeAliasesKt实现。

6.5 集合使用的注意事项

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

  • 只读集合不一定是不可变的,关于这个只读和不可变类似于val的只读和不可变原理。

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

6.6 平台类型的集合转化规则

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

Tips:可是当你决定使用哪一种 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>.

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

7. 总结

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