第3章 Kotlin 可空类型与类型系统

第3章 Kotlin 可空类型与类型系统

跟Java、C和C ++ 一样, Kotlin也是“静态类型编程语言”。

通常,编程语言中的类型系统中定义了

  1. 如何将数值和表达式归为不同的类型
  2. 如何操作这些类型
  3. 这些类型之间如何互相作用

我们在编程语言中使用类型的目的是为了让编译器能够确定类型所关联的对象需要分配多少空间。

类型系统在各种语言之间有非常大的不同,主要的差异存在于编译时期的语法,以及运行时期的操作实现方式。在每一个编程语言中,都有一个特定的类型系统。静态类型在编译时期时,就能可靠地发现类型错误。因此通常能增进最终程序的可靠性。然而,有多少的类型错误发生,以及有多少比例的错误能被静态类型所捕捉,仍有争论。

本章我们简单介绍一下Kotlin的类型系统。

3.1 类型系统

定型(typing,又称类型指派)赋予一组比特某个意义。类型通常和存储器中的数值或对象(如变量)相联系。因为在电脑中,任何数值都是以一组比特简单组成的,硬件无法区分存储器地址、脚本、字符、整数、以及浮点数。类型可以告知程序和程序设计者,应该怎么对待那些比特。

3.1.1 类型系统的作用

使用类型系统,编译器可以检查无意义的、无效的、类型不匹配等错误代码。这也正是强类型语言能够提供更多的代码安全性保障的原因之一。

另外,静态类型检查还可以提供有用的信息给编译器。跟动态类型语言相比,由于有了类型的显式声明,静态类型的语言更加易读好懂。

有了类型我们还可以更好地做抽象化、模块化的工作。这使得我们可以在较高抽象层次思考并解决问题。例如,Java中的字符数组 char[] s = {'a', 'b', 'c'} 和字符串类型 String str = "abc" 就是最简单最典型的抽象封装实例。

字符数组

jshell> char[] s = {'a','b','c'}
s ==> char[3] { 'a', 'b', 'c' }

jshell> s[0]
$3 ==> 'a'

jshell> s[1]
$4 ==> 'b'

jshell> s[2]
$5 ==> 'c'

字符串

jshell> String str = "abc"
str ==> "abc"

jshell> str.toCharArray();
$7 ==> char[3] { 'a', 'b', 'c' }

3.1.2 Java的类型系统

Java的类型系统可以简单用下面的图来表示:

第3章 Kotlin 可空类型与类型系统
Java的类型系统

关于Java中的null,有很多比较坑的地方。例如

int i = null; // type mismatch : cannot convert from null to int
short s = null; //  type mismatch : cannot convert from null to short
byte b = null: // type mismatch : cannot convert from null to byte
double d = null; //type mismatch : cannot convert from null to double
 
Integer io = null; // this is ok
int j = io; // this is also ok, but NullPointerException at runtime

存储方式区别

基本数据类型与引用数据型在创建时,内存存储方式区别如下:

  • 基本数据类型在被创建时,在栈上给其划分一块内存,将数值直接存储在栈上(性能高)。

  • 引用数据型在被创建时,首先在栈上给其引用(句柄)分配一块内存,而对象的具体信息存储在堆内存上,然后由栈上面的引用指向堆中对象的地址。

3.1.3 Kotlin的类型系统

Java是一个近乎纯洁的面向对象编程语言,但是为了编程的方便还是引入了基本数据类型,但是为了能够将这些基本数据类型当成对象操作,Java为每一个基本数据类型都引入了对应的包装类型(wrapper class),int的包装类就是Integer,从Java 5开始引入了自动装箱/拆箱机制,使得二者可以相互转换。
Java 为每个原始类型提供了包装类型:

  • 原始类型: boolean,char,byte,short,int,long,float,double
  • 包装类型:Boolean,Character,Byte,Short,Integer,Long,Float,Double

Kotlin中去掉了原始类型,只有“包装类型”, 编译器在编译代码的时候,会自动优化性能,把对应的包装类型拆箱为原始类型。

Kotlin系统类型分为可空类型和不可空类型。Kotlin中引入了可空类型,把有可能为null的值单独用可空类型来表示。这样就在可空引用与不可空引用之间划分出来一条明确的显式的“界线”。

Kotlin类型层次结构如下图所示:

第3章 Kotlin 可空类型与类型系统
Kotlin类型层次结构

通过这样显式地使用可空类型,并在编译期作类型检查,大大降低了出现空指针异常的概率。

对于Kotlin的数字类型而言,不可空类型与Java中的原始的数字类型对应。如下表所示

Kotlin Java
Int int
Long long
Float float
Double double

Kotlin中对应的可空数字类型就相当于Java中的装箱数字类型。如下表所示

Kotlin Java
Int? Integer
Long? Long
Float? Float
Double? Double

在Java中,从基本数字类型到引用数字类型的转换就是典型的装箱操作,例如int转为Integer。倒过来,从Integer转为 int 就是拆箱操作。同理,在Kotlin中非空数字类型Int 到可空数字类型Int? 需要进行装箱操作。 同时,非空的Int类型会被编译器自动拆箱成基本数据类型 int , 存储的时候也会存到栈空间。例如下面的代码,当为Int类型的时候,a === b 返回的是true; 而当为Int?的时候, a===b 返回的是false 。

>>> val a: Int = 1000
>>> val b:Int = 1000
>>> a===b
true
>>> a==b
true

上面返回的都是true,因为a,b它们都是以原始类型存储的,类似于Java中的基本数字类型。

>>> val a:Int? = 1000
>>> val b:Int? = 1000
>>> a==b
true
>>> a===b
false

我们可以看出,当 a, b 都为可空类型时, a 跟 b 的引用是不等的。

这里的“等于”号简单说明如下:

等于符号 功能说明
= 赋值,在逻辑运算时也有效
== 等于运算,比较的是值,而不是引用
=== 完全等于运算,不仅比较值,而且还比较引用,只有两者一致才为真

另外,Java中的数组也是一个较为特殊的类型。这个类型是 T[] , 这个方括号让我们觉得不大优雅。Kotlin中摒弃了这个数组类型声明的语法。Kotlin简单直接地使用Array类型代表数组类型。这个Array中定义了get, set 算子函数, 同时有一个size 属性代表数组的长度,还有一个返回数组元素的迭代子 Iterator<T>的函数iterator()。 完整的定义如下:

public class Array<T> {
    public inline constructor(size: Int, init: (Int) -> T)
    public operator fun get(index: Int): T
    public operator fun set(index: Int, value: T): Unit
    public val size: Int
    public operator fun iterator(): Iterator<T>
}

其中,构造函数我们可以这么用

>>> val square = Array(5, { i -> i * i })
>>> square.forEach(::println)
0
1
4
9
16

我们在编程过程中常用的boolean[], char[],byte[],short[],int[],long[],float[],double[] ,Kotlin直接使用了8个新的类型来对应这样的编程场景:

BooleanArray
ByteArray
CharArray
DoubleArray
FloatArray
IntArray
LongArray
ShortArray

3.2 可空类型

我想Java和Android开发者肯定早已厌倦了空指针异常(Null Pointer Exception)。这个讨厌的空指针异常在运行时总会在某个你意想不到的地方忽然出现,让我们感到措手不及。

自然而然地,人们会想到为何不能在编译时就提前发现这类空指针异常,并大量修复这些问题? 现代编程语言正是这么做的。

Kotlin自然也不例外。

在 Java 8中,我们可以使用 Optional 类型来表达可空的类型。

package com.easy.kotlin;

import java.util.Optional;
import static java.lang.System.out;

public class Java8OptionalDemo {

    public static void main(String[] args) {
        out.println(strLength(Optional.of("abc")));
        out.println(strLength(Optional.ofNullable(null)));
    }

    static Integer strLength(Optional<String> s) {
        return s.orElse("").length();
    }
}

运行输出:

3
0

但是,这样的代码,依然不是那么地优雅。

针对这方面 Groovy 提供了一种安全的属性/方法访问操作符 ?.

user?.getUsername()?.toUpperCase();

Swift 也有类似的语法, 只作用在 Optional 的类型上。

Kotlin中使用了Groovy里面的安全调用符,并简化了 Optional 类型的使用,直接通过在类型T后面加个?, 就表达了Optional<T>的意义。

上面 Java 8的例子,用 Kotlin 来写就显得更加简单优雅了:

package com.easy.kotlin

fun main(args: Array<String>) {
    println(strLength(null))
    println(strLength("abc"))
}

fun strLength(s: String?): Int {
    return s?.length ?: 0
}

其中,我们使用 String? 同样表达了 Optional<String>的意思,相比之下,哪个更简单?一目了然。

还有Java 8 Optional 提供的orElse

s.orElse("").length();

这个东东,在 Kotlin 是最最常见不过的 Elvis 运算符了:

s?.length ?: 0

相比之下,还有什么理由继续用 Java 8 的 Optional 呢?

3.3 安全操作符

扔掉Java中的一堆 null 的防御式样板代码吧!!!

当我们使用Java开发的时候,我们的代码大多是防御性的。如果我们不想遇到NullPointerException,我们就需要在使用它之前不停地去判断它是否为null。

Kotlin正如很多现代编程语言一样——是空安全的。因为我们需要通过一个可空类型符号 T? 来明确地指定一个对象类型 T 是否能为空。

我们可以像这样去写:

>>> val str: String = null
error: null can not be a value of a non-null type String
val str: String = null
                  ^

我们可以看到,这里不能通过编译, String 类型不能是null 。

一个可以赋值为null的String类型的正确姿势是:String? , 代码如下所示

>>> var nullableStr: String? = null
>>> nullableStr
null

我们再来看一下Kotlin中关于null的一些有趣的运算。

null跟null是相等的:

>>> null==null
true
>>> null!=null
false

null这个值比较特殊,null 不是Any类型

>>> null is Any
false

但是,null是Any?类型:

>>> null is Any?
true

我们来看看null对应的类型是什么:

>>> var a=null
>>> a
null
>>> a=1
error: the integer literal does not conform to the expected type Nothing?
a=1
  ^

从报错信息我们可以看出,null的类型是Nothing?。关于Nothing?我们将会在下面的小节中介绍。

3.3.1 安全调用符 ?.

我们不能直接使用可空的nullableStr来调用其属性或者方法

>>> nullableStr.length
error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?
nullableStr.length
           ^

上面的代码无法编译, nullableStr可能是null。我们需要使用安全调用符(?.) 来调用

>>> var nullableStr: String? = null
>>> nullableStr?.length
null
>>> nullableStr = "abc"
>>> nullableStr?.length
3

只有在 nullableStr != null 时才会去调用其length属性。

3.3.3 非空断言 !!

如果我们想只有在确保 nullableStr 不是null的情况下才能这么调用,否则抛出异常,我们可以使用断言操作符( !! )

>>> nullableStr = null
>>> nullableStr!!.length
kotlin.KotlinNullPointerException

3.3.2 Elvis运算符 ?:

使用Elvis操作符来给定一个在是null的情况下的替代值

>>> nullableStr
null
>>> var s= nullableStr?:"NULL"
>>> s
NULL

3.4 特殊类型

本节我们介绍Kotlin中的特殊类型:Unit,Nothing,Any以及其对应的可空类型Unit? , Nothing? , Any? 。

3.4.1 Unit类型

Kotlin也是面向表达式的语言。在Kotlin中所有控制流语句都是表达式(除了变量赋值、异常等)。

Kotlin中的Unit类型实现了与Java中的void一样的功能。

总的来说,这个Unit类型并没有什么特别之处。它的定义是:

package kotlin
public object Unit {
    override fun toString() = "kotlin.Unit"
}

不同的是,当一个函数没有返回值的时候,我们用Unit来表示这个特征,而不是null。

大多数时候,我们并不需要显式地返回Unit,或者声明一个函数的返回类型为Unit。编译器会推断出它。

代码示例:

>>> fun unitExample(){println("Hello,Unit")}
>>> val helloUnit = unitExample()
Hello,Unit
>>> helloUnit
kotlin.Unit
>>> println(helloUnit)
kotlin.Unit
>>> helloUnit is Unit
true

我们可以看出,变量helloUnit的类型是 kotlin.Unit 。

下面几种写法是等价的:

@RunWith(JUnit4::class)
class UnitDemoTest {
    @Test fun testUnitDemo() {
        val ur1 = unitReturn1()
        println(ur1) // kotlin.Unit
        val ur2 = unitReturn2()
        println(ur2) // kotlin.Unit
        val ur3 = unitReturn3()
        println(ur3) // kotlin.Unit
    }

    fun unitReturn1() {

    }

    fun unitReturn2() {
        return Unit
    }

    fun unitReturn3(): Unit {
    }
}

跟任何其他类型一样,它的父类型是Any。如果是一个可空的Unit?,它的父类型是Any?。

第3章 Kotlin 可空类型与类型系统
Unit类型结构

3.4.2 Nothing与Nothing?类型

在Java中,void不能是变量的类型。也不能被当做值打印输出。但是,在Java中有个包装类Void是 void 的自动装箱类型。如果你想让一个方法返回类型 永远是 null 的话, 可以把返回类型置为这个大写的V的Void类型。

代码示例:

    public Void voidDemo() {
        System.out.println("Hello,Void");
        return null;
    }

测试代码:

@RunWith(JUnit4.class)
public class VoidDemoTest {
    @Test
    public void testVoid() {
        VoidDemo voidDemo = new VoidDemo();
        Void v = voidDemo.voidDemo(); // Hello,Void
        System.out.println(v); // null
    }
}

这个Void对应Kotlin中的Nothing?。它的唯一可被访问到的返回值也是null。

如上面小节的Kotlin类型层次结构图所示,在Kotlin类型层次结构的最底层就是类型Nothing。

第3章 Kotlin 可空类型与类型系统
Nothing的类型层次结构

它的定义如下:

public class Nothing private constructor()

这个Nothing不能被实例化

>>> Nothing() is Any
error: cannot access '<init>': it is private in 'Nothing'
Nothing() is Any
^

从上面代码示例,我们可以看出 Nothing() 不可被访问。
如果一个函数的返回值是Nothing,这也就意味着这个函数永远不会有返回值。

但是,我们可以使用Nothing来表达一个从来不存在的返回值。例如EmptyList中的 get 函数


internal object EmptyList : List<Nothing>, Serializable, RandomAccess {
    override fun get(index: Int): Nothing = throw IndexOutOfBoundsException("Empty list doesn't contain element at index $index.")
    }
}

一个空的List调用get函数, 直接是抛出了IndexOutOfBoundsException ,这个时候我们就可以使用Nothing 作为这个get函数的返回类型,因为它永远不会返回某个值,而是直接抛出了异常。

再例如, Kotlin的标准库里面的exitProcess函数:

@file:kotlin.jvm.JvmName("ProcessKt")
@file:kotlin.jvm.JvmVersion
package kotlin.system
@kotlin.internal.InlineOnly
public inline fun exitProcess(status: Int): Nothing {
    System.exit(status)
    throw RuntimeException("System.exit returned normally, while it was supposed to halt JVM.")
}

注意:Unit与Nothing之间的区别: Unit类型表达式计算结果的返回类型是Unit。Nothing类型的表达式计算结果是永远不会返回的(跟Java中的void相同)。

Nothing?可以只包含一个值:null。代码示例:

>>> var nul:Nothing?=null
>>> nul = 1
error: the integer literal does not conform to the expected type Nothing?
nul = 1
      ^

>>> nul = true
error: the boolean literal does not conform to the expected type Nothing?
nul = true
      ^

>>> nul = null
>>> nul
null

从上面的代码示例,我们可以看出:Nothing?它唯一允许的值是null,被用作任何可空类型的空引用。

3.4.3 Any与Any?类型

就像Any是在非空类型层次结构的根,Any?是可空类型层次的根。

Any?是Any的超集,Any?是Kotlin的类型层次结构的最顶端。

第3章 Kotlin 可空类型与类型系统
Any与Any?类型

代码示例:

>>> 1 is Any
true
>>> 1 is Any?
true
>>> null is Any
false
>>> null is Any?
true
>>> Any() is Any?
true

3.5 类型检测与类型转换

3.5.1 is运算符

is运算符可以检查对象是否与特定的类型兼容(此对象是该类型,或者派生于该类型)。

is运算符用来检查一个对象(变量)是否属于某数据类型(如Int、String、Boolean等)。C#里面也有这个运算符。

is运算符类似Java的instanceof:

jshell> "abc" instanceof String
$10 ==> true

在Kotlin中,我们可以在运行时通过使用 is 操作符或其否定形式 !is 来检查对象是否符合给定类型:

>>> "abc" is String
true
>>> "abc" !is String
false

>>> null is Any
false
>>> null is Any?
true


代码示例:

@RunWith(JUnit4::class)
class ISTest {
    @Test fun testIS() {
        val foo = Foo()
        val goo = Goo()
        println(foo is Foo) //true 自己
        println(goo is Foo)// 子类 is 父类 = true
        println(foo is Goo)//父类 is 子类 = false
        println(goo is Goo)//true 自己
    }
}


open class Foo
class Goo : Foo()

3.5.2 类型自动转换

在Java代码中,当我们使用str instanceof String来判断其值为true的时候,我们想使用str变量,还需要显式的强制转换类型:

@org.junit.runner.RunWith(org.junit.runners.JUnit4.class)
public class TypeSystemDemo {
    @org.junit.Test
    public void testVoid() {
        Object str = "abc";
        if (str instanceof String) {
            int len = ((String)str).length();  // 显式的强制转换类型为String
            println(str + " is instanceof String");
            println("Length: " + len);

        } else {
            println(str + " is not instanceof String");
        }

        boolean is = "1" instanceof String;
        println(is);
    }

    void println(Object obj) {
        System.out.println(obj);
    }
}

而大多数情况下,我们不需要在 Kotlin 中使用显式转换操作符,因为编译器跟踪不可变值的 is-检查,并在需要时自动插入(安全的)转换:

    @Test fun testIS() {
        val len = strlen("abc")
        println(len) // 3
        val lens = strlen(1)
        println(lens) // 1
    }

    fun strlen(ani: Any): Int {
        if (ani is String) {
            return ani.length
        } else if (ani is Number) {
            return ani.toString().length
        } else if (ani is Char) {
            return 1
        } else if (ani is Boolean) {
            return 1
        }


        print("Not A String")
        return -1
    }

3.5.3 as运算符

as运算符用于执行引用类型的显式类型转换。如果要转换的类型与指定的类型兼容,转换就会成功进行;如果类型不兼容,使用as?运算符就会返回值null。

代码示例:

>>> open class Foo
>>> class Goo:Foo()
>>> val foo = Foo()
>>> val goo = Goo()


>>> foo as Goo
java.lang.ClassCastException: Line69$Foo cannot be cast to Line71$Goo

>>> foo as? Goo
null

>>> goo as Foo
[email protected]

我们可以看出,在Kotlin中,父类是禁止转换为子类型的。按照Liskov替换原则,父类转换为子类是对OOP的严重违反,不提倡、也不建议。严格来说,父类是不能转换为子类的,子类包含了父类所有的方法和属性,而父类则未必具有和子类同样成员范围,所以这种转换是不被允许的,即便是两个具有父子关系的空类型,也是如此。

本章小结

Kotlin通过引入可空类型,在编译时就大量“清扫了”空指针异常。同时,Kotlin中还引入了安全调用符(?.) 以及Elvis操作符( ?: ) , 使得我们的代码写起来更加简洁。

Kotlin的类型系统比Java更加简单一致,Java中的原始类型与数组类型在Kotlin中都统一表现为引用类型。

Kotlin中还引入了Unit,Nothing等特殊类型,使得没有返回值的函数与永远不会返回的函数有了更加规范一致的签名。

我们可以使用is 操作符来判断对象实例的类型,以及使用as 操作符进行类型的转换。