Kotlin学习

Kotlin程序设计初级篇

注意: 在开始学习之前,推荐各位小伙伴有一定的编程语言基础,前置课程:《JavaSE 教程》或《C 语言程序设计》如果没有其他语言的基础,在学习Kotlin时会非常吃力,这门语言语法糖多到爆炸。

Kotlin是一种现代但已经成熟的编程语言,旨在让开发人员更快乐。它简洁、安全、可与Java和其他语言互操作,并提供了许多在多个平台之间重用代码的方法。它由JetBrains公司于2011年设计和开发,并在2016年正式发布。Kotlin旨在解决Java语言在编码效率和代码质量方面存在的问题,并且与Java语言完全兼容。Kotlin通过简化语法、提供更强大的功能以及减少样板代码的编写,使得开发者能够更高效地编写清晰、简洁而又安全的代码。

Kotlin语言名字的来源是基于一个古老斯拉夫部落的名字。JetBrains开发Kotlin的初衷是为了在Android开发上取代Java,并且作为一门通用的编程语言。Kotlin通过减少样板代码和增加现代化的语言特性,提供了更好的工具和库来简化Android应用开发。由于Kotlin的设计理念和特性吸引了广泛的开发者关注,它也迅速被接受并得到了广泛的使用。现在,Kotlin已成为一门流行的编程语言,被许多开发者用于Android应用开发、服务器端开发以及其他领域的软件开发中。

官方网站:https://www.jetbrains.com/opensource/kotlin/

Kotlin 是一种现代化的静态类型编程语言,具有以下优势:

  1. 与Java互操作性强:Kotlin 可以与现有的 Java 代码无缝地互操作,允许开发者在现有的项目中逐步采用 Kotlin,而不需要重写整个项目。这使得 Kotlin 成为 Android 应用开发的理想选择。
  2. 简洁易读:Kotlin 的语法简洁并具有更好的可读性,减少了样板代码的编写。相比 Java,Kotlin 可以使用更少的代码来实现同样的功能,从而提高开发效率。
  3. 空安全性:Kotlin 对空值进行了更好的处理。在 Kotlin 中,变量默认是非空的,如果需要使用可能为空的值,需要显式声明类型为可空。这有助于减少空指针异常的发生。
  4. 函数式编程支持:Kotlin 支持函数式编程的特性,如高阶函数、lambda 表达式和函数式编程的集合操作等。这些特性可以让开发者编写更简洁、可维护的代码,并提高代码的表达能力。
  5. 扩展函数:Kotlin 允许开发者为某个类添加新的方法,而不需要修改该类的源代码。这种扩展函数的特性可以为开发者提供更灵活的方式来扩展现有的类库。
  6. 协程支持:Kotlin 引入了协程(coroutine)机制,使得异步操作更易于管理和编写。通过使用协程,开发者可以使用顺序的方式编写并发代码,并避免了回调地狱的问题。

总的来说,Kotlin 是一门功能丰富、可读性高、与 Java 无缝互操作的编程语言,适用于 Android、Web 后端开发等多种场景。

正是因为Kotlin与Java的高度兼容性,再加上简洁、安全、互操作性强等特点,让Kotlin一度成为Android开发的官方指定语言。并且随着时代的发展,现在它不仅仅可以开发安卓应用程序,也可以开发iOS程序,甚至开发Java后端、开发桌面应用程序等。其简洁高效的语法也受到一众开发者追捧。

从下节课开始,我们就来正式学习一下Kotlin语言,Kotlin,启动!


走进新语言

欢迎大家进入到Kotlin程序设计的学习中,我们将从开发环境配置开始,为各位小伙伴讲解。

编程语言可以被视为人与计算机之间进行交流的方式。它是一种用于编写计算机程序的形式化语言,用于描述计算机任务的操作步骤、算法和数据结构。简单来说,就是以计算机能听懂的语言告诉计算机我们要做什么,然后让计算机来做我们想做的事情,从而解决我们生活中各种各样的问题。

编程语言可以分为多种类型,包括低级语言和高级语言。低级语言(如汇编语言)与计算机硬件更接近,对计算机底层操作进行更精细的控制,但编写和理解起来较为复杂。高级语言(如Java、Python等)则更加易读、易写,并提供了更高层次的抽象,使得程序员能够更专注于问题的解决和算法的设计。而我们这里要学习的Kotlin语言,也属于高级语言的一种,能够使用我们人类更容易理解的语法来编写程序。

开发环境配置

要开发Kotlin程序,我们首先需要安装Java环境,我们一般使用Kotlin都是在JVM平台上进行开发(Kotlin同样可以开发系统原生程序、JavaScript程序、安卓程序、iOS程序等)因为Java支持跨平台,能在编译后再任意平台上运行,因此,我们将JVM环境中学习Kotlin程序的开发,接下来我们要安装两个环境:

  • Java 8 环境
  • Kotlin 1.9.0 环境

首先我们来安装Java 8 环境,这里我们需要去下载JDK,这里推荐安装免费的ZuluJDK:https://www.azul.com/downloads/?version=java-8-lts&package=jdk

在这里选择自己的操作系统对应的安装包:

比如Windows下,我们就选择.msi的安装包即可(MacOS、Linux下同样选择对应的即可)

下载完成后,我们直接双击安装:

**注意,这里不建议各位小伙伴去修改安装的位置!**新手只建议安装到默认位置(不要总担心C盘不够,该装的还是要装,尤其是这种环境,实在装不下就去将其他磁盘的空间分到C盘一部分)如果是因为没有安装到默认位置出现了任何问题,你要是找不到大佬问的话,又得重新来一遍,就很麻烦。

剩下的我们只需要一路点击Next即可,安装完成之后,我们打开CMD命令窗口(MacOS下直接打开“终端”)来验证一下(要打开CMD命令窗口,Windows11可以直接在下面的搜索框搜索cmd即可,或者直接在文件资源管理器路径栏输入cmd也可以)

我们直接输入java命令即可:

如果能够直接输出内容,说明环境已经安装成功了,正常情况下已经配置好了,我们不需要手动去配置什么环境变量,所以说安装好就别管了。

输入java -version可以查看当前安装的JDK版本:

只要是1.8.0就没问题了,后面的小版本号可能你们会比我的还要新。

接着是Kotlin 1.9.0 环境,我们需要前往:https://github.com/JetBrains/kotlin/releases 下载最新的Kotlin编译器并进行安装:

这里我们可以直接解压然后拖入到刚刚Java安装的同级目录下,我这里是 C:\Program Files 文件夹,也可以自定义位置,但是不推荐,因为很多小伙伴配环境直接配到自闭。

然后我们需要手动配置一下环境变量,打开系统环境变量配置:

添加路径 C:\Program Files\kotlinc\bin 到Path环境变量下即可,然后我们依然打开CMD查看是否安装成功,输入kotlinc -version来查看安装情况:

这样我们就完成了所有环境的安装,我们可以来体验一下编写并且编译运行一个简单的Kotlin程序,我们新建一个文本文档,命名为Main.txt(如果没有显示后缀名,需要在文件资源管理器中开启一下)然后用记事本打开,输入以下内容:

1
2
3
fun main() {
    println("Hello, World!")
}

现在看不懂代码没关系,直接用就行,我们后面会一点一点讲解的。

编辑好之后,保存退出,接着我们将文件的后缀名称修改为.kt这是Java源程序文件的后缀名称:

此时我们打开CMD,注意要先进入到对应的路径下,比如我们现在的路径:

我们使用cd命令先进入到这个目录下:

要编译一个Kotlin程序,我们需要使用kotlinc命令来进行,将我们的程序编译为jar包,并包含Kotlin的运行时依赖:

1
kotlinc Main.kt -include-runtime -d Main.jar

执行后,可以看到目录下多出来了一个.jar文件,这是一个打包好的标准Java程序:

接着我们就可以将其交给JVM运行了,我们直接使用java -jar命令即可:

可以看到打印了一个 Hello World! 字样,这样我们的第一个Kotlin程序就成功运行了。

IDEA安装与使用

前面我们介绍了Kotlin开发环境的安装以及成功编译运行了我们的第一个Kotlin应用程序。

但是我们发现,如果我们以后都使用记事本来进行Kotlin程序开发的话,是不是效率太低了点?我们还要先编辑,然后要改后缀,还要敲命令来编译,有没有更加方便一点的写代码的工具呢?这里我们要介绍的是:IntelliJ IDEA(这里不推荐各位小伙伴使用Eclipse,因为操作上没有IDEA这么友好)

IDEA准确来说是一个集成开发环境(IDE),它集成了大量的开发工具,编写代码的错误检测、代码提示、一键完成编译运行等,非常方便。

下载地址:IntelliJ IDEA:JetBrains 功能强大、符合人体工程学的 Java IDE

我们直接点击下载即可,注意要下载下面的社区版,不要下载到终极版了:

这个软件本身是付费的,比较贵,而且最近还涨价了,不过这里我们直接下载面的社区版本就行了(JavaSE学习阶段不需要终极版,但是建议有条件的还是申请一个,功能更强大,体验更友好)

下载好之后,直接按照即可,这个不强制要求安装到C盘,自己随意,但是注意路径中不要出现中文!

这里勾选一下创建桌面快捷方式就行:

安装完成后,我们直接打开就可以了:

此时界面是全英文,如果各位小伙伴看得惯,可以直接使用全英文的界面(使用英文界面可以认识更多的专业术语词汇,但是可能看起来没中文那么直观,而且IDEA本身功能就比较多,英语不好的小伙伴就很头疼)这里还是建议英语不好的小伙伴使用中文界面,要使用中文只需要安装中文插件即可:

我们打开Plugins插件这一栏,然后直接在插件市场里面搜索Chinese,可以找到一个中文语言包的插件,我们直接Install安装即可,安装完成后点击重启,现在就是中文页面了:

如果各位小伙伴不喜欢黑色主题,也可以修改为白色主题,只需要在自定义中进行修改即可,一共四种主题,我们还可以在下面的设置中开启新UI以及更换各种字体、字体大小等个性化内容。

如果你之前使用过其他IDE编写代码,这里还支持按键映射(采用其他IDE的快捷键方案)有需要的可以自己修改一下:

接下来,我们来看看如何使用IDEA编写Kotlin程序,IDEA是以项目的形式对一个Java程序进行管理的,所以说我们直接创建一个新的项目,点击新建项目:

此时来到创建页面:

  • 名称: 你的Java项目的名称,随便起就行,尽量只带英文字母和数字,不要出现特殊字符和中文。
  • 位置: 项目的存放位置,可以自己根据情况修改,同样的,路径中不要出现中文。
  • 语言: IDEA支持编写其他语言的项目,但是这里我们直接选择Java就行了。
  • 构建系统: 在JavaSE阶段一律选择IntelliJ就行了,Maven我们会在JavaWeb之后进行讲解,Gradle会在安卓开发教程中介绍。
  • JDK: 就是我们之前安装好的JDK,如果是默认路径安装,这里会自动识别(所以说不要随便去改,不然这些地方就很麻烦)

当然,如果JDK这里没有自动识别到,那么就手动添加一下:

没问题之后,我们直接创建项目:

进入之后,可以看到已经自动帮助我们创建好了一个kt源文件,跟我们之前的例子是一样的。要编译运行我们的Kotlin程序,只需要直接点击左边的三角形(启动按钮)即可:

点击之后,会在下方自动开始构建:

完成之后,就可以在控制台看到输出的内容了:

我们可以看到新增加了一个out目录,这里面就是刚刚编译好的.class文件,这种文件是Java的字节码文件,可以直接运行在JVM中:

IDEA非常强大,即使是编译之后的字节码文件,也可以反编译回原代码的样子:

如果我们想写一个新的Kotlin项目,可以退出当前项目重新创建:

此时项目列表中就有我们刚刚创建的Java项目了:

如果你还想探索IDEA的其他功能,可以点击欢迎页最下方的学习:

会有一个专门的引导教程项目,来教你如何使用各项功能:

熟悉了IDEA的使用之后,下节课我们就可以正式地开始学习Kotlin语言的语法了。

程序代码基本结构

还记得我们之前使用的示例代码吗?

1
2
3
fun main() {
    println("Hello World!")
}

这段代码要实现的功能很简单,就是将 Hello World 这段文本信息输出到控制台。

在编写代码时,注意需要区分大小写,Kotlin语言严格区分大小写,如果我们没有按照规则来编写,那么就会出现红色波浪线报错:

只要源代码中存在报错的地方,就无法正常完成编译得到字节码文件,强行运行会提示构建失败:

注意这里包括的花括号是成对出现的,并且一一对应。

所以说各位小伙伴在编写代码时一定要注意大小写。然后第二行,准确的说是最外层花括号内部就是:

1
2
3
fun main() {

}

可以看到外面使用花括号前添加了fun main(),这是我们整个程序的入口点,我们的Kotlin程序也是从这里开始从上往下执行的。而其中的println语句就是用于打印其括号中包裹的文本,我们可以看到这个文本信息使用了""进行囊括,否则会报错:

1
println("Hello World!")

这段代码的意思就是将双引号括起来的内容(字符串,我们会在后面进行讲解)输出(打印)到控制台上。

比如下面的代码,我们就可以实现先打印Hello World!,然后再打印 KFC vivo 50 到控制台:

1
2
3
4
fun main() {
    println("Hello World!")
    println("KFC vivo 50")
}

效果如下:

注意我们上面编写的打印语句其实是函数的调用(后续会进行讲解)不能写到同一行中,否则编译器会认为是同一句代码,同样会导致编译不通过:

如果实在要写到同一行,那么我们需要在上一句代码最后添加;来表示上一段的结束:

再比如下面的代码:

这里我们尝试在中途换行或是添加空格,因为没有添加分号,所以说编译器依然会认为是一行代码,因此编译不会出现错误,能够正常通过。当然,为了代码写得工整和规范,我们一般不会随意换行或是添加没必要的空格。注意随意换行和空格仅限于可分割区域,比如println本身是一个函数的完整名称,这就不能从中间直接断开,否则语义就完全不一样了。

程序注释编写

我们在编写代码时,可能有些时候需要标记一下这段代码表示什么意思:

但是如果直接写上文字的话,会导致编译不通过,因为这段文字也会被认为是程序的一部分。

这种情况,我们就需要告诉编译器,这段文字是我们做的笔记,并不是程序的一部分,那么要怎么告诉编译器这不是代码呢?很简单,我们只需要在前面加上双斜杠就可以了:

添加双斜杠之后(自动变成了灰色),后续的文本内容只要没有发生换行,那么都会被认为是一段注释,并不属于程序,在编译时会被直接忽略,之后这段注释也不会存在于程序中。但是一旦发生换行那就不行了:

那要是此时注释很多,一行写不完,我们想要编写很多行的注释呢?我们可以使用多行注释标记:

多行可以使用/**/的组合来囊括需要编写的注释内容。

当然还有一种方式就是使用/**来进行更加详细的文档注释:

这种注释可以用来自动生成文档,当我们鼠标移动到Main上时,会显示相关的信息,我们可以自由添加一些特殊的注释,比如作者、时间等信息,也可以是普通的文字信息。

这样,我们编写Kotlin程序的基本规则就讲解完毕了,从下一个小节开始,我们将先给各位小伙伴介绍我们的基本数据类型。


变量与基本类型

我们的程序不可能永远都只进行上面那样的简单打印操作,有些时候可能需要计算某些数据,此时我们就需要用到变量了。那么,什么是变量呢?我们在数学中其实已经学习过变量了:

变量,指值可以变的量。变量以非数字的符号来表达,一般用拉丁字母。变量的用处在于能一般化描述指令的方式。结果只能使用真实的值,指令只能应用于某些情况下。变量能够作为某特定种类的值中任何一个的保留器。

比如一个公式 x + 2 = 6 此时x就是一个变量,变量往往代表着某个值,比如这里的x就代表的是4这个值。在Kotlin中,我们也可以让变量去代表一个具体的值,并且变量的值是可以发生变化的,在程序中,我们也可以使用变量,并且变量具有类型。

计算机中的二进制表示(选学)

进入到变量的学习之前,我们需要先补充一下计算机的底层知识,否则各位小伙伴后面听起来会很困难。

在计算机中,所有的内容都是二进制形式表示。十进制是以10为进位,如9+1=10;二进制则是满2进位(因为我们的计算机是电子的,电平信号只有高位和低位,你也可以暂且理解为通电和不通电,高电平代表1,低电平代表0,由于只有0和1,因此只能使用2进制表示我们的数字!)比如1+1=10=2^1+0,一个位也叫一个bit,8个bit称为1字节,16个bit称为一个字,32个bit称为一个双字,64个bit称为一个四字,我们一般采用字节来描述数据大小。

注意这里的bit跟我们生活中的网速MB/s是不一样的,小b代表的是bit,大B代表的是Byte字节(8bit = 1Byte字节),所以说我们办理宽带的时候,100Mbps这里的b是小写的,所以说实际的网速就是100/8 = 12.5 MB/s了。

十进制的7 -> 在二进制中为 111 = 2^2 + 2^1 + 2^0

现在有4个bit位,最大能够表示多大的数字呢?

  • 最小:0000 => 0
  • 最大:1111 => 23+22+21+20 => 8 + 4 + 2 + 1 = 15

在Kotlin中,无论是小数还是整数,他们可以带有符号,因此,首位就作为我们的符号位,还是以4个bit为例,首位现在作为符号位(1代表负数,0代表正数):

  • 最小:1111 => -(22+21+2^0) => -7
  • 最大:0111 => +(22+21+2^0) => +7 => 7

现在,我们4bit能够表示的范围变为了-7~+7,这样的表示方式称为原码。虽然原码表示简单,但是原码在做加减法的时候,很麻烦!以4bit位为例:

1+(-1) = 0001 + 1001 = 怎么让计算机去计算?(虽然我们知道该去怎么算,但是计算机不知道!)

我们得创造一种更好的表示方式!于是我们引入了反码

  • 正数的反码是其本身
  • 负数的反码是在其原码的基础上, 符号位不变,其余各个位取反

经过上面的定义,我们再来进行加减法:

1+(-1) = 0001 + 1110 = 1111 => -0 (直接相加,这样就简单多了!)

思考:1111代表-0,0000代表+0,在我们实数的范围内,0有正负之分吗?0既不是正数也不是负数,那么显然这样的表示依然不够合理!根据上面的问题,我们引入了最终的解决方案,那就是补码,定义如下:

  • 正数的补码就是其本身 (不变!)
  • 负数的补码是在其原码的基础上, 符号位不变, 其余各位取反, 最后+1(即在反码的基础上+1,此时1000表示-8)
  • 对补码再求一次补码就可得该补码对应的原码。

比如-7原码为1111,反码为1000,补码就是1001了,-6原码为1110,反码为1001,补码就是1010。所以在补码下,原本的1000就作为新增的最小值-8存在。

所以现在就已经能够想通,-0已经被消除了!我们再来看上面的运算:

1+(-1) = 0001 + 1111 = (1)0000 => +0 (现在无论你怎么算,也不会有-0了!)

所以现在,1111代表的不再是-0,而是-1,相应的,由于消除-0,负数多出来一个可以表示的数(1000拿去表示-8了),那么此时4bit位能够表示的范围是:-8~+7(Kotlin使用的就是补码!)在了解了计算机底层的数据表示形式之后,我们再来学习这些基本数据类型就会很轻松了。

变量的声明与使用

要声明一个变量,我们需要使用以下格式:

1
var [变量名称] : [数据类型]

这里的数据类型我们会在下节课开始逐步讲解,比如整数就是Int类型,不同类型的变量可以存储不同的类型的值。后面的变量名称顾名思义,就像x一样,这个名称我们可以随便起一个,但是注意要满足以下要求:

  • 标识符可以由大小写字母、数字、下划线(_)和美元符号($)组成,但是不能以数字开头。
  • 变量不能重复定义,大小写敏感,比如A和a就是两个不同的变量。
  • 不能有空格、@、#、+、-、/ 等符号。
  • 应该使用有意义的名称,达到见名知意的目的(一般我们采用英文单词),最好以小写字母开头。
  • 不可以是 true 和 false。
  • 不能与Kotlin语言的关键字或是基本数据类型重名

当然各位小伙伴没必要刻意去进行记忆有哪些关键字,我们会在学习的过程中逐步认识到这些关键字。新手要辨别一个单词是否为关键字,只需要通过IDEA的高亮颜色进行区分即可,比如:

深色模式下,关键字会高亮为橙色,浅色模式下会高亮为深蓝色,普通的代码都是正常的灰白色。

比如现在我们想要定义一个整数(Int)类型的变量a,那么就可以这样编写:

1
2
3
fun main() {
    var a : Int
}

但是这个变量一开始没有任何值,比如现在我们要让这个变量表示10,那么就可以将10赋值给这个变量:

1
2
3
fun main() {
    var a : Int = 10
}

不过由于变量在一开始就被赋值为10这个整数,此时类型是确定的,Kotlin的编译器非常聪明,它支持自动推断类型,这里会自动将变量a的类型推断为Int类型,我们可以直接省略掉后面的Int类型:

1
2
3
fun main() {
    var a = 10
}

或者我们可以在使用时再对其进行赋值:

1
2
3
4
fun main() {
    var a : Int
    a = 10
}

是不是感觉跟数学差不多?这种写法对于我们人来说,实际上是很好理解的,意思表达很清晰。为了更直观地查看变量的值,我们可以直接将变量的值也给打印到控制台:

1
2
3
4
fun main() {
    var a = 10
    println(a)
}

变量的值也可以在中途进行修改:

1
2
3
4
5
fun main() {
    var a = 666
    a = 777
    println(a)   //这里打印得到的就是777
}

变量的值也可以直接指定为其他变量的值:

1
2
3
4
5
fun main() {
    var a = 10
    var b = a //直接让b等于a,那么a的值就会给到b
    println(b) //这里输出的就是10了
}

我们还可以让变量与数值之间做加减法(运算符会在后面详细介绍):

1
2
3
4
5
fun main() {
    var a = 9   //a初始值为9
    a = a + 1   //a = a + 1也就是将a+1的结果赋值给a,跟数学是一样的,很好理解对吧
    println(a)  //最后得到的结果就是10了
}

对于那些只读的变量,我们可以将其表示为一个常量,使用val关键字:

1
2
3
4
fun main() {
    val a = 666 //使用val关键字,表示这是一个常量
    a = 777;    //常量的值不允许发生修改
}

编译时得到报错:

常量的值只有第一次赋值可以修改,其他任何情况下都不行:

1
2
3
4
fun main() {
    val a: Int
    a = 777;
}

至此,声明变量和常量我们就介绍完毕了,下一部分我们将介绍常见的一些数据类型。

数字类型介绍

前面我们了解了如何创建变量,并进行使用,但是我们知道,不同的数据往往对应着不同的类型,比如整数我们使用的就是Int,而这一部分我们将学习更多的基本数据类型。

Kotlin提供了一组表示数字的内置类型,对于整数,有四种不同大小的类型,因此,值范围:

类型 大小(位) 最小值 最大值
Byte 8 -128 127
Short 16 -32768 32767
Int 32 -2,147,483,648 (-2^31) 2,147,483,647(2^31-1)
Long 64 -9,223,372,036,854,775,808 (-2^63) 9,223,372,036,854,775,807(2^63 - 1)

为什么不同的数据类型有着值范围呢?这是因为我们的计算机底层是采用0和1表示数据的,并且数据的表示位数有限,我们以二进制来计算,就像下面这样:

1 + 1 = 10

可能很多小伙伴会好奇,为什么1 + 1得到的结果是数字十?这是因为二进制中只有0和1,因此只要满二就进一,所以就变成这样的结果了,如果各位是初次学习,可能会不太好理解。

这里以上面的8位大小的Byte类型为例,在计算机底层存储数据时,只有8个bit位(一个bit位就可以表示一个0或1)来存储它,那么它能表示的最大值和最小值就是:

00000000 ~ 11111111 转换为十进制就是 0 ~ 255

不过为了能够表示负数,计算机一般使用补码进行表示,所以,上面的最小值和最大值就变成了-128 ~ 127了。

默认情况下,我们使用的常量数字都是Int类型,除非它的大小已经超出Int类型能够表示的最大范围,在超出Int类型可以表示的最大范围之后,默认为Long类型:

1
2
3
4
val one = 1 // Int
val threeBillion = 3000000000 // Long
val oneLong = 1L // 我们也可以在数字后面添加大写字母L来表示这是一个Long类型的数值
val oneByte: Byte = 1   //Int类型数据也可以在符合其他类型范围时自动转换

对于一些比较长的数字,我们可能需要使用类似于分隔符一类的东西来方便我们计数,比如:

银行往往把1000000000这种长数字记为1,000,000,000,这样看起来会更直观

在Kotlin中也可以像这样去编写:

1
val a = 1_000_000_000

数字类型不仅可以写成十进制,也可以以十六进制或是二进制表示(Kotlin不支持八进制表示)只需要添加对应的前缀即可,比如一个十六进制数据:

1
val a = 0xAF

因为十六进制中大于等于十的数据没有对应的阿拉伯数字可以表示,所以在计算机中就以ABCDEF来替代这无法表示的6个数字。并且我们需要在数字前面添加0x表示这是16进制的数字,接下来是2进制:

1
val a = 0b1001   //0b前缀表示二进制数据,后面的1010对应着十进制的9

除了整数类型外,Kotlin还为无符号整数提供以下类型:

  • UByte:一个无符号8位整数,范围从0到255
  • UShort:无符号16位整数,范围从0到65535
  • UInt:一个无符号32位整数,范围从0到2^32 - 1
  • ULong:一个无符号64位整数,范围从0到2^64 - 1

为了使无符号整数更易于使用,Kotlin同样提供了用后缀标记,该后缀表示无符号类型(类似于上面的Long类型添加L字母)

  • 使用uU字母作为后缀表示无符号整数。而具体的类型是根据前面变量的类型确定的,如果变量没有提供类型,编译器将根据数字的大小使用UIntULong

    1
    2
    3
    4
    5
    6
    
    val b: UByte = 1u  // UByte类型, 由变量提供的类型
    val s: UShort = 1u // UShort类型, 由变量提供的类型
    val l: ULong = 1u  // ULong类型, 由变量提供的类型
    
    val a1 = 42u    // UInt类型,根据数字大小自动推断得到
    val a2 = 0xFFFF_FFFF_FFFFu // ULong类型,根据数字大小自动推断得到
    
  • uLUL可以将文字直接标记为无符号Long类型:

    1
    
    val a = 1UL // ULong类型,直接使用后缀标记
    

对于小数来说,Kotlin提供符合IEEE 754标准的浮点类型FloatDoubleFloat为IEEE 754标准中的单精度数据,而`Double位标准中的双精度数据,对于单双精度,本质上就是能够表示的小数位精度,双精度比单精度的小数精度更高。

这些类型的大小不同,并为不同精度的浮点数提供存储:

类型 大小(位) 符号与尾数位数 阶码位数 小数位数
Float 32 24 8 6-7
Double 64 53 11 15-16

我们也可以直接创建小数类型的DoubleFloat变量,小数部分与整数部分由一个小数点(.)隔开,编译器默认情况下会将所有的小数自动推断为推断Double类型:

1
2
3
val pi = 3.1415 // 默认推断为Double类型
val one: Double = 1 // 这种写法是错误的,因为1不是小数,无法编译通过
val one: Double = 1.0 // 但是这种写法就是对的,因为这样表示就是小数,即使小数位是0

由于默认是Double类型,如果我们要明确指定值为Float类型,那么需要添加后缀fF,并且由于精度问题,如果该值包含超过6-7位小数,则会丢失一部分精度:

1
2
val e = 2.7182818284 // Double类型的数值
val e: Float = 2.7182818284f // 这里表示为Float会导致精度折损,得到2.7182817

与其他一些语言不同,Kotlin中的数字类型没有隐式转换的操作,例如,一个Double类型的变量无法将其值赋值给Int类型变量:

如果需要将一个整数转换为小数,我们会在后面学习函数之后再给各位小伙伴讲解如何调用函数进行显示类型转换。

数字类型的运算

Kotlin支持数学上标准的算术运算集,例如:+-*/% 并且这些运算符都是通过运算符重载实现的具体功能,我们会在后续的章节中讲解Kotlin的运算符重载机制,这里各位小伙伴就当做是普通的运算操作即可。

Kotlin支持运算符重载,运算符重载是一种允许程序员重新定义运算符的语言特性,通过运算符重载,您可以为自定义的类或数据类型定义一些特定操作的行为。

其中加减乘除操作这里就不做介绍了,而%符号用于取余操作,也就是计算前面的数整除后面的数得到的余数:

1
2
3
4
5
println(1 + 2)   //计算1加上2的结果
println(2_500_000_000L - 1L)   //计算2500000000减去1的结果
println(3.14 * 2.71)   //计算3.14与2.71的乘积
println(10.0 / 3)   //计算10除以3得到的结果
println(10 / 3)   //10除以3得到的余数为1

以上运算都比较简单,但是注意在除法运算中,只有两个操作数中出现小数,除法的结果才是小数,如果两个操作数都是整数,那么得到的结果也是整数,并且直接丢失小数位(不会四舍五入)

1
println(5 / 2)    //结果是2,而不是2.5

同样的,除了直接使用字面量来进行运算,我们也可以将定义的变量参与到运算中:

1
2
3
4
fun main() {
    val a = 10
    println(a / 2)
}

注意,在Kotlin中不同的算数运算符,它们的优先级也不一样:

1
println(1 + 2 * 3)

在数学中,乘法运算的优先级比加法运算更高,因此我们需要先计算乘法,再计算加法,而在Kotlin中是一样的,乘法和除法运算符的优先级是高于加法运算符的,所以说上面算出来的结果是7,同样的,我们数学中使用括号来提升某些运算的优先级,在Kotlin中同样可以,比如:

1
println((1 + 1) * 3)   //使用小括号来强制提升优先级

有些时候,我们可能想要让某个变量的值增加一定数值,比如下面这样:

1
2
var a = 10
a = a + 9   //让a等于a+9的结果

对于这种让变量本身加减乘除某个值的情况,可以使用赋值运算符简化:

1
2
3
a += 9   //等价于 a = a + 9
a /= 9   //等价于 a = a / 9
a %= 2   //等价于 a = a % 2

如果我们只是希望某个变量自增或自减1,那么我们可以像这样去写:

1
2
3
4
5
6
fun main() {
    var a = 10
    a++    //使用两个++表示自增1
    println(a)     //打印得到11
  	a--    //使用两个--表示自减1
}

不过,这个双++符号,可以放在变量的前后,都能实现自增操作:

1
2
var a = 10
++a   //最终效果等价于a++

但是他们有一个本质区别,就是++在前面,a是先自增再得到结果,而++在后面,是a先得到结果,再进行自增,比如:

1
2
3
4
5
fun main() {
    var a = 10
    println(a++)   //这里++在后面,打印a的值依然是10,但是结束之后a的值就变成11了
    println(++a)   //这里++在前面,打印a的值是这里先自增之后的结果,就是12了
}

对于新手来说,这个很容易搞混,所以说一定要记清楚。

Kotlin提供了一组整数的位运算操作,可以直接在二进制层面上与数字表示的位进行操作,不过只适用于IntLong类型的数据:

  • shl(bits)– 有符号左移
  • shr(bits)– 有符号右移
  • ushr(bits)– 无符号右移
  • and(bits)– 按位与
  • or(bits)– 按位或
  • xor(bits)– 按位异或
  • inv()– 取反

这里我们从按位与开始讲解,比如下面的两个数:

1
2
3
4
5
6
fun main() {
    val a = 9
    val b = 3
    val c = a and b //进行按位与运算
    println(c)
}

按位与实际上就是让这两个数每一位都进行比较,如果这一位两个数都是1,那么结果就是1,否则就是0:

  • a = 9 = 1001
  • b = 3 = 0011
  • c = 1 = 0001(因为只有最后一位,两个数都是1,所以说结果最后一位是1,其他都是0)

同样的,按位或,其实就是只要任意一个为1(不能同时为0)那么结果就是1:

1
2
3
4
5
6
fun main() {
    val a = 9
    val b = 3
    val c = a or b
    println(c)
}
  • a = 9 = 1001
  • b = 3 = 0011
  • c =11= 1011(只要上下有一个是1或者都是1,那结果就是1)

按位异或的意思就是只有两边不相同的情况下,结果才是1,也就是说一边是1一边是0的情况:

  • a = 9 = 1001
  • b = 3 = 0011
  • c =10= 1010(从左往右第二位、第四位要么两个都是0,要么两个都是1,所以说结果为0)

按位取反操作跟前面的正负号一样,只操作一个数,最好理解,如果这一位上是1,变成0,如果是0,变成1:

  • 127 = 01111111
  • -128 = 10000000

所以说计算的结果就是-128了。

除了以上的四个运算符之外,还有位移运算符,比如:

1
2
3
4
fun main() {
    val c = 1 shl 2 //shl表示左移运算
    println(c)
}
  • 1 = 00000001
  • 4 = 00000100(左移两位之后,1跑到前面去了,尾部使用0填充,此时就是4)

我们发现,左移操作每进行一次,结果就会x2,所以说,除了直接使用*进行乘2的运算之外,我们也可以使用左移操作来完成。

同样的,右移操作就是向右移动每一位咯:

1
2
3
4
fun main() {
    val c = 8 shr 2  //shr表示右移运算
    println(c)
}

跟上面一样,右移操作可以快速进行除以2的计算。对于负数来说,左移和右移操作不会改变其符号位上的数字,符号位不受位移操作影响:

1
2
3
4
fun main() {
    val c = -8 shr 2   //这里得到的依然是个负数
    println(c)
}

我们也可以使用考虑符号位的右移操作,一旦考虑符号位,那么符号会被移动:

1
2
3
4
fun main() {
    val c = -1 ushr 1 //无符号右移是ushr,移动会直接考虑符号位
    println(c)
}

比如:

  • -1 = 11111111 11111111 11111111 11111111
  • 右移: 01111111 11111111 11111111 11111111(无符号右移使用0填充高位)

此时得到的结果就是正数的最大值 2147483647 了,注意,不存在无符号左移操作。

最后我们再总结一下不同运算符的优先级,对应的优先级从上往下依次减弱:

  1. 一元运算符:例如 ++、–、+、-、!、~
  2. 乘法和除法运算符:*、/、%
  3. 加法和减法运算符:+、-
  4. 位移运算符:shl、shr、ushr
  5. 按位与运算符:and
  6. 按位或运算符:or
  7. 按位异或运算符:xor
  8. 逻辑运算符:&&、||
  9. 比较运算符:>、>=、<、<=、==、!=
  10. 区间运算符:..
  11. 赋值运算符:=、+=、-=、*=、/=、%=

当然,这里列出的部分运算符各位小伙伴可能还没有遇到,不过在后续的学习中,我们会慢慢认识的,届时各位小伙伴可以回顾一下这里。

布尔类型介绍

布尔类型是Kotlin中的一个比较特殊的类型,它并不是存放数字的,而是状态,它有下面的两个状态:

  • true - 真
  • false - 假

布尔类型(boolean)只有truefalse两种值,也就是要么为真,要么为假,布尔类型的变量通常用作流程控制判断语句(不同于C语言,C语言中一般使用0表示false,除0以外的所有数都表示true)

1
val a: Boolean = true

如果给一个其他的值,会无法编译通过:

布尔值除了可以直接赋值得到,也可以通过一些关系运算得到,常见的关系运算有大于、小于以及等于,所有的关系运算在下方:

  • 判断两个数是否相等:a == ba != b
  • 判断数之间大小:a < ba > ba <= ba >= b
  • 判断数是否在指定范围中:a..bx in a..bx !in a..b

比如我们想判断变量a和变量b的值是否相同:

1
2
3
4
5
6
7
8
fun main() {
    val a = 10
    val b = 8
    println(a == b)  //判断a是否等于b(注意等号要写两个,因为单等号为赋值运算)
    println(a >= b)   //判断a是否大于等于b
    println(a < b)   //判断a是否小于b
  	val c: Boolean = a != b   //判断a是否不等于b并将结果赋值给变量c
}

可以看到,通过逻辑运算得到的结果,都是true或false,也就是我们这里学习的Boolean类型值。在Kotlin中,我们为了快速判断某个数是否在一个区间内,可以直接使用 a..b 来表示一个数学上[a, b]这样的闭区间,比如我们这里要判断变量a的值是否在1~10之间:

1
2
3
4
5
6
fun main() {
    val a = 10
    println(a in 1..10)   //这里1..10表示1~10这个闭区间,使用in关键字来进行判断
  	println(a in 1..<10)   //这里1..<10表示1~10这个前闭后开区间,使用in关键字来进行判断
  	println(a !in 1..10)   //相反的,使用!in判断是否不在这个区间
}

对于Boolean类型的变量之间,也有一些逻辑运算符用于进行组合条件判断:

  • ||– 逻辑或运算
  • &&– 逻辑与运算
  • !– 取反运算

其中取反运算最好理解,它可以让true变成false,false变为true,比如:

1
2
3
4
5
6
fun main() {
    val a = 10
    val b = 20
    val c = a > b   //这里很明显c应该为false
    println(!c)   //这里进行了取反操作并打印,那么结果就是true了
}

对于逻辑与和逻辑或运算,我们可以像这样去使用:

1
2
3
4
5
6
fun main() {
    val a = 10
    val b = 0
    println(100 >= a && b >= 60)  //我们可以使用与运算符连接两个判断表达式,只有两边都为true结果才是true
    println(100 >= a || b >= 60)  //我们可以使用或运算符连接两个判断表达式,只要两边任意一个为true结果就是true
}

与运算符要求左右两边同时为真,得到的结果才是真,否则一律为假,而或运算就是要求两边只要有一边为真,结果就是真,除非两边同时为false,那么就没戏了。

不过需要注意的是,在与运算中,第一个判断表达式得到了false之后,此时不会再继续运行第二个表达式,而是直接得到结果false(逻辑运算符会出现短路的情况,只要第一个不是真,就算第二个是真也不可能了,所以说为了效率,后续就不用再判断了,在使用时一定要注意这一点)同样的,或运算下当发现第一个判断表达式为true时,也不会继续向后执行了,因为结果已经是顶真了。

字符类型介绍

字符类型也是一个重要的基本数据类型,它可以表示计算机中的任意一个字符(包括中文、英文、标点等一切可以显示出来的字符)字符由Char类型表示,字符值用单引号:'1'囊括:

1
2
val c: Char = 'A'
println(c)

注意,字符只能表示一单个字符,我们之前遇到的字符串跟字符不一样,关于字符串我们会在下节课进行介绍。

我们打印出来的也是单个字符:

那么可能会有小伙伴好奇,字符类型在计算机底层是怎么进行存储的呢?实际上每个字符在计算机中都会对应一个字符码,首先我们需要介绍ASCII码:

比如我们的英文字母A要展示出来,那就是一个字符的形式,而其对应的ASCII码值为65,我们可以使用.code来获取某个字符对应的ASCII码,比如下面这样:

1
2
3
4
fun main() {
    val c: Char = 'A'
    println(c.code)   //这里就会打印字符对应的ASCII码
}

得到结果为:

字符型占据2个字节的空间用于存放数据:

  • char 字符型(16个bit,也就是2字节,它不带符号)范围是0 ~ 65535

不过,这里的字符表里面不就128个字符吗,那char干嘛要两个字节的空间来存放呢?我们发现表中的字符远远没有我们所需要的那么多,这里只包含了一些基础的字符,中文呢?那么多中文字符(差不多有6000多个),用ASCII编码表那128个肯定是没办法全部表示的,但是我们现在需要在电脑中使用中文,这时,我们就需要扩展字符集了。

Unicode是一个用于表示文本字符的标准字符集。它包含了世界上几乎所有的已知字符,包括不同国家和地区的字母、数字、标点符号、符号图形以及特殊的控制字符。

与Unicode不同,ASCII(American Standard Code for Information Interchange)是一个只包含128个字符的字符集。它最初是为了在计算机系统中传输基本英语字符而设计的。ASCII字符集包含了常见的拉丁字母、数字、标点符号以及一些特殊字符。

Unicode采用了一个更加广泛的字符编码方案,包括了不同的字符集编码,比如UTF-8和UTF-16等。UTF-8是一种可变长度的编码方案,它可以用来表示Unicode中的任意字符,且向后兼容ASCII字符集。而UTF-16则是一种固定长度的编码方案,它使用两个字节来表示一个Unicode字符。

与ASCII相比,Unicode的主要优势在于它能够表示各种不同的语言和字符,而不仅仅限于英语字符。这使得Unicode成为全球通用的字符编码标准,为不同国家和地区的语言提供了统一的编码方式。

所以,一个Char就能表示几乎所有国家语言的字符,这样就很方便了。

接着我们来介绍一下转译字符,对于一些我们平时很难直接通过键盘或是输入法打出来的字符,比如一些特殊符号:

这些符号我们没办法直接打出来,但是现在我们又想要表示它们,该怎么做呢?我们可以使用转义来将这些字符对应的Unicode编码转换为对应的字符,只需要在前面加上\u即可,比如✓这个符号:

1
2
3
4
fun main() {
    val c = '\u2713'   //符号✓对应的Unicode编码为10003,这里需要转换为16进制表示,结果为0x2713
    println(c)
}

除了能像这样表示一个特殊字符,我们也可以使用一些其他的转义字符来表示各种东西:

  • \t – 选项卡
  • \b – 退格
  • \n – 换行(LF)
  • \r – 回车(CR)
  • \' – 单引号
  • \" – 双引号
  • \\ –反斜杠
  • \$ – 美元符号

这些转义字符都是为了防止在特殊情况下无法表示某些字符,而给我们的替代方案,后续各位小伙伴在使用时可以回来参考一下。

字符串类型介绍

字符串类是一个比较特殊的类型,它用于保存字符串。我们知道,基本类型Char可以保存一个2字节的Unicode字符,而字符串则是一系列字符的序列,它的类型名称为String

字符串通常由双引号""囊括,它可以表示一整串字符:

1
val str: String = "Hello World"

注意,字符串中的字符一旦确定,无法进行修改,只能重新创建。

如果我们需要再字符串中换行,需要用到转义字符,字符串中同样支持使用转义字符:

1
2
3
4
fun main() {
    val text = "Hello\nWorld"
    println(text)
}

不过,字符串只能写一行,有时候有点不太够用,可能我们想要打印多行文本,我们除了用\n转义字符来换行之外,也可以直接使用三个双引号"""来表示一个原始字符串,但是原始字符串无法使用转义字符:

1
2
3
4
5
6
7
8
9
fun main() {
    val text = """
    这是第一行
    这第二行
    别\n了,没用
    真牛逼啊,这功能隔壁Java15才有
    """
    println(text)
}

效果如下:

可以看到确实是够原始的,把我代码里面的缩进都给打印出来了,这样肯定不是我们希望的样子,我们希望的仅仅是一个简单换行而已,那这里该怎么去处理呢?后面我们在讲解函数之后,会额外补充这里的内容。

有时候为了方便,我们可以将不同的字符串拼接使用:

1
2
3
4
5
6
fun main() {
    val str1 = "Hello"
    val str2 = "World"
    val str = str1 + str2
    println(str)   //使用 + 来拼接两个字符串,得到的结果就是两个字符串合在一起的结果
}

字符串除了和字符串拼接之外,也可以和其他类型进行拼接:

1
2
3
4
5
fun main() {
    val a = 10
    val text = "这是拼接的值" + a
    println(text)   //打印出来就是与其他类型的拼接结果
}

但是我们需要注意字符串拼接的顺序,只能由字符串拼接其他类型,如果是其他类型拼接字符串,可能会出现问题:

但是现在我们就是希望其他类型的数据拼在最前面,这里应该怎么做呢?我们可以使用字符串模版来完成:

1
2
3
4
5
fun main() {
    val a = 10
    val text = "这是拼接的值$a"  //这里的$为模版表达式,可以直接将后面跟着的变量或表达式以字符串形式替换到这个位置
    println(text)
}

如果要添加到前面:

1
val text = "$a 这是拼接的值" //注意这里$a之后必须空格,否则会把后面的整个字符串认为这个变量的名字

出现这种情况除了用空格去解决之外,我们也可以添加一个花括号:

1
2
val text = "${a}这是拼接的值"  //添加花括号就可以消除歧义了
val text = "${a > 0}这是拼接的值"  //花括号中也可以写成表达式

由于美元符用于模版表达式了,所以说如果我们希望在字符串中仅仅表示$这个字符,那么我们需要用到转义:

1
2
3
4
5
val text = "\$这是美元符"   //普通字符串直接使用\$表示
//原始字符串要套个娃
val str = """
  ${'$'}这是美元符
	"""

至此,关于Kotlin的变量与基本类型的内容我们就暂时告一段落了,不过在后面学习了更多知识后,我们还会回顾这些基本类型,了解他们的更多用法,并且认识我们唯一没有在这一部分介绍的数组类型。


流程控制

经过前面的学习,我们知道,程序都是从上往下依次运行的,但是,仅仅是这样还不够,我们需要更加高级的控制语句来使得程序更加有趣。比如,判断一个整数变量,大于1则输出yes,小于1则输出no,这时我们就需要用到选择结构来帮助我们完成条件的判断和程序的分支走向。

在前面我们介绍了运算符,我们可以通过逻辑运算符和关系运算符对某些条件进行判断,并得到真或是假的结果。这一部分我们将继续使用这些运算符进行各种判断,以及实现流程控制。

选择结构(if-else)

某些时候,我们希望进行判断,只有在条件为真时,才执行某些代码,这种情况就需要使用到选择分支语句,首先我们来认识一下if语句:

1
if (条件判断) 判断成功执行的代码;

if的小括号中需要我们传入一个Boolean类型的结果,可以是一个Boolean变量,也可以是一个判断语句,反正只能接受true和false两种结果,比如下面的这个例子:

1
2
3
4
5
6
fun main() {
    val a = 10
    if(a == 12)  //只有当a判断等于12时,才会执行下面的打印语句
        println("Hello World!")
    println("我是后续的语句")  //if只会对紧跟着的一行代码生效,后续的内容无效
}

if会进行判断,只有判断成功时才会执行紧跟着的语句,否则会直接跳过,注意,如果我们想要在if中执行多行代码,需要使用代码块将这些代码囊括起来(实际上代码块就是将多条语句复合到一起,使用花括号囊括)所以说,我们以后使用if时,如果分支中有多行代码需要执行,就需要添加花括号,如果只有一行代码,花括号可以直接省略,包括我们后面会讲到的else、while、for语句都是这样的,就像下面这样:

1
2
3
4
5
6
7
8
fun main() {
    val a = 15
    if (a > 10) {    //只有判断成功时,才会执行下面的代码块中内容,否则直接跳过
        println("a大于10")
        println("a的值为:$a")
    }
    println("我是外层")
}

如果我们希望判断条件为真时执行某些代码,条件为假时执行另一些代码,我们可以在后面继续添加else语句:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fun main() {
    val a = 15
    if (a > 10) {    //只有判断成功时,才会执行下面的代码块中内容,否则直接跳过
        println("a大于10")
        println("a的值为:$a")
    } else {   //当判断不成功时,会执行else代码块中的代码
        println("a小于10")
        println("a的值为:$a")
    }
    println("我是外层")
}

if-else语句就像两个分支,跟据不同的判断情况从而决定下一步该做什么,这跟我们之前认识的三元运算符性质比较类似。

那如果此时我们需要判断多个分支呢?比如我们现在希望判断学生的成绩,不同分数段打印的等级不一样,比如90以上就是优秀,70以上就是良好,60以上是及格,其他的都是不及格,那么这种我们又该如何判断呢?要像这样进行连续判断,我们需要使用else-if来完成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fun main() {
    val score = 2
    if (score >= 90) //90分以上才是优秀
        println("优秀")
    else if (score >= 70) //当上一级if判断失败时,会继续判断这一级
        println("良好")
    else if (score >= 60)
        println("及格")
    else  //当之前所有的if都判断失败时,才会进入到最后的else语句中
        println("不及格")
}

当然,if分支语句还支持嵌套使用,比如我们现在希望低于60分的同学需要补习,0-30分需要补Java,30-60分需要补C++,这时我们就需要用到嵌套:

1
2
3
4
5
6
7
8
9
fun main() {
    val score = 2
    if (score < 60) {   //先判断不及格
        if (score > 30) //在内层再嵌套一个if语句进行进一步的判断
            println("学习C++")
        else
            println("学习Java")
    }
}

除了if自己可以进行嵌套使用之外,其他流程控制语句同样可以嵌套使用,也可以与其他流程控制语句混合嵌套使用。这样,我们就可以灵活地使用if来进行各种条件判断了。

除了直接执行语句之外,我们也可以将if和else用作结果判断,比如:

1
2
3
4
5
fun main() {
    val score = 2
  	//这里判断socre是否大于60,是就得到Yes,否就得到No,并且可以直接赋值给变量
    val res = if (score > 60) "Yes" else "No"
}

这类似于其他语言,如Java和C中的三元运算,不过Kotlin中没有那样的三元运算符,只能使用上面的表达式,对于多行代码块的情况,默认最后一行作为返回的结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fun main() {
    val score = 2
    val res = if (score > 60) {
        println("不错啊期末没挂科")
        "Yes"   //代码块默认最后一行作为返回结果
    } else {
        println("不会有人Java期末还要挂科吧")
        "No"
    }
}

注意,如果需要这种返回结果的表达式,那么必须要存在else分支,否则不满足条件岂不是没结果了?

选择结构(when)

前面我们介绍了if语句,我们可以通过一个if语句轻松地进行条件判断,然后根据对应的条件,来执行不同的逻辑,当然除了这种方式之外,我们也可以使用when语句来实现,它更适用于多分支的情况:

when定义具有多个分支的条件表达式。它类似于类似Java和C语言中的switch语句,它简单的形式看起来像这样:

1
2
3
4
5
6
7
when (目标) {
    匹配值1 -> 代码...   //我们需要传入一个目标,比如变量,或是计算表达式等
    匹配值2 -> 代码...   //如果目标的值等于我们这里给定的匹配值,那么就执行case后面的代码
    else -> {
        代码...    //如果以上条件都不满足,就进入else中(可以没有),类似于之前的if-elseif-else
    }
}

比如现在我们要根据学生的等级进行分班,学生有ABC三个等级:

1
2
3
4
5
6
7
8
fun main() {
    val c = 'A'
    when (c) {
        'A' -> println("去尖子班!准备冲刺985大学!")
        'B' -> println("去平行班!准备冲刺一本!")
        'C' -> println("去职高深造。")
    }
}

如果将when用作表达式,则else分支必须存在,除非编译器能推断出所有可能的情况都包含分支条件,比如下面的例子:

1
2
3
4
5
6
7
8
fun main() {
    val c = 'A'
    val numericValue = when (c) {
        'B' -> 0
        'A' -> 1
        else -> 2    //还有其他情况,这里必须添加else,不然其他情况岂不是没返回的东西?
    }
}

以下情况就可以不需要else语句:

1
2
3
4
5
6
7
8
9
fun main() {
    val c = true
    val numericValue = when (c) {
        false -> 0
        true -> 1
        // 由于Boolean只具备真和假条件,这里的'else' 就不再强制要求
      	// 这同样适用于比如枚举类等
    }
}

when语句中,遇到以下情况,携带else分支是必须的:

  • when分支中仅有一个Boolean类型、枚举 或 密封,以及用于判断的目标变量是可空的情况(后面会讲解)
  • when分支没有包括该判断目标的所有可能的值。

有时候我们可能希望某些值都属于同一个情况,可以使用逗号将其条件组合成一行:

1
2
3
4
when (x) {
    0, 1 -> print("x == 0 or x == 1")
    else -> print("otherwise")
}

我们也可以使用任意表达式(不仅仅是常量)作为分支条件,比如之前的if-else案例中我们判断学生成绩:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fun main() {
    val score = 10
    val grade = when(score) {
      	//使用in判断目标变量值是否在指定范围内
        in 100..90 -> "优秀"
        in 89..80 -> "良好"
        in 79..70 -> "及格"
        in 69..60 -> "牛逼"
        else -> "不及格"
    }
}

包括我们之后学习的类型判断is表达式、函数调用等,都可以在这里作为分支条件。

循环结构(for)

通过前面的学习,我们了解了如何使用分支语句来根据不同的条件执行不同的代码,我们接着来看第二种重要的流程控制语句:循环语句。

我们在某些时候,可能需要批量执行某些代码:

1
2
3
4
5
fun main() {
    println("大烟杆嘴里塞,我只抽第五代")   //把这句话给我打印三遍
    println("大烟杆嘴里塞,我只抽第五代")
    println("大烟杆嘴里塞,我只抽第五代")
}

遇到这种情况,我们由于还没学习循环语句,那么就只能写N次来实现这样的多次执行。但是如果此时要求我们将一句话打印100遍、1000遍、10000遍,那么我们岂不是光CV代码就要搞一下午?

现在,要解决这种问题,我们可以使用for循环语句来多次执行:

1
for (遍历出来的单个目标变量 in 可遍历目标) 循环体

这里的可遍历目标有很多,比如:

  • 数组
  • 区间
  • 任何实现了运算符重载函数iterator的类

这里我们只学习了区间,我们来看看如何使用,比如我们要打印一段话3遍:

1
2
3
4
fun main() {
    for (i in 1..3)  //这里直接写入1..3表示1~3这个区间
        println("大烟杆嘴里塞,我只抽第五代:$i")
}

打印结果为:

可以看到,每一次遍历出来的变量i,其实就是每次遍历的下一个目标,比如这里是1..3的区间,那么得到的依次就是1、2、3这三个结果了,唯一需要注意的是,这里的i是局部的,只在for循环内部可用(包括嵌套的内部)并不是整个main中都可以使用:

默认情况下,每一轮循环都会向后+1,我们也可以自由控制每一轮增加多少,也就是步长:

1
2
3
4
5
fun main() {
    for (i in 1..10 step 2) {
        println(i)
    }
}

这样,打印出来的数据会按照步长进行增长:

那如果我们需要从10到1倒着进行遍历呢?我们可以将..替换为downTo来使用:

1
2
3
4
5
fun main() {
    for (i in 10 downTo 1) {
        println(i)   //这里得到的就是10到1倒着排列的范围了
    }
}

我们可以使用调试来观察每一轮的变化,调试模式跟普通的运行一样,也会执行我们的Java程序,但是我们可以添加断点,也就是说当代码运行到断点位置时,会在这里暂停,我们可以观察当代码执行到这个位置时各个变量的值:

调试模式在我们后面的学习中非常重要,影响深远,所以说各位小伙伴一定要学会。调试也很简单,我们只需要点击右上角的调试选项即可(图标像一个小虫子一样,因为调试的英文名称是Debug)

调试开始时,我们可以看到程序在断点位置暂停了:

此时我们可以观察到当前的变量i的值,也可以直接在下方的调试窗口中查看:

随着循环的进行,i的值也会逐渐自增。

和之前的if一样,for循环同样支持嵌套使用:

1
2
3
4
5
fun main() {
    for (i in 0..2)  //外层循环执行3次
        for (j in 0..2)  //内层循环也执行3次
            println("外层$i,内层$j")
}

上面的代码中,外层循环会执行3轮,而整个循环体又是一个循环语句,那么也就是说,每一轮循环都会执行里面的整个循环,里面的整个循环会执行3,那么总共就会执行3 x 3次,也就是9次打印语句。

我们也可以在循环过程中提前终止或是加速循环的进行,这里我们需要认识两个新的关键字:

1
2
3
4
5
for (i in 0..2) {
    if (i == 1) continue  //比如我们希望当i等于1时跳过这一轮,不执行后面的打印
    println("在这么冷的天")
    println("当前i的值为:$i")
}

我们可以使用continue关键字来跳过本轮循环,直接开启下一轮。这里的跳过是指,循环体中,无论后面有没有未执行的代码,一律不执行,比如上面的判断如果成功,那么将执行continue进行跳过,虽然后面还有打印语句,但是不会再去执行了,而是直接结束当前循环,开启下一轮。

在某些情况下,我们可能希望提前结束循环:

1
2
3
4
5
6
7
fun main() {
    for (i in 0..2) {
        if (i == 1) break //我们希望当i等于1时提前结束
        println("伞兵一号卢本伟准备就绪!")
        println("当前i的值为:$i")
    }
}

我们可以使用break关键字来提前终止整个循环,和上面一样,本轮循环中无论后续还有没有未执行的代码,都不会执行了,而是直接结束整个循环,跳出到循环外部。

虽然使用break和continue关键字能够更方便的控制循环,但是注意在多重循环嵌套下,它只对离它最近的循环生效(就近原则):

1
2
3
4
5
6
7
8
fun main() {
    for (i in 1..3) {
        for (j in 1..3) {
            if (i == j) continue  //当i == j时加速循环
            println("$i, $j")
        }
    }
}

这里的continue加速的对象并不是外层的for,而是离它最近的内层for循环,break也是同样的规则:

1
2
3
4
5
6
7
8
fun main() {
    for (i in 1..3) {
        for (j in 1..3) {
            if (i == j) break //当i == j时终止循环
            println("$i, $j")
        }
    }
}

那么,要是我们就是想要终止或者是加速外层循环呢?我们可以为循环语句打上标记:

1
2
3
4
5
6
7
8
fun main() {
    outer@ for (i in 1..3) {   //在循环语句前,添加 标签@ 来进行标记
        inner@ for (j in 1..3) {
            if (i == j) break@outer  //break后紧跟要结束的循环标记,当i == j时终止外层循环
            println("$i, $j")
        }
    }
}

关于for语句的更多用法,我们会在后续的学习中继续认识。

循环结构(while)

前面我们介绍了for循环语句,我们接着来看第二种while循环,for循环要求我们给一个可遍历的目标,而while相当于是一个简化版本,它只需要我们填写循环的维持条件即可,比如:

1
while(循环条件) 循环体;

相比for循环,while循环更多的用在不明确具体的结束时机的情况下,而for循环更多用于明确知道循环的情况,比如我们现在明确要进行循环10次,此时用for循环会更加合适一些,又比如我们现在只知道当i大于10时需要结束循环,但是i在循环多少次之后才不满足循环条件我们并不知道,此时使用while就比较合适了。

1
2
3
4
5
6
7
8
fun main() {
    var i = 100 //比如现在我们想看看i不断除以2得到的结果会是什么,但是循环次数我们并不明确

    while (i > 0) {   //现在唯一知道的是循环条件,只要大于0那么就可以继续除
        println(i)
        i /= 2 //每次循环都除以2
    }
}

上面的这种情况就非常适合使用while循环。

和for循环一样,while也支持使用break和continue来进行循环的控制,以及嵌套使用:

1
2
3
4
5
6
7
8
fun main() {
    var i = 100
    while (i > 0) {
        if (i < 10) break
        println(i)
        i /= 2
    }
}

我们也可以反转循环判断的时机,可以先执行循环内容,然后再做循环条件判断,这里要用到do-while语句:

1
2
3
4
5
6
7
8
fun main() {
    var i = 0 //比如现在我们想看看i不断除以2得到的结果会是什么,但是循环次数我们并不明确

    do {  //无论满不满足循环条件,先执行循环体里面的内容
        println("Hello World!")
        i++
    } while (i < 10) //再做判断,如果判断成功,开启下一轮循环,否则结束
}

Kotlin程序设计中级篇

我们在前面已经学习了Kotlin程序设计的基础篇,本章我们将继续介绍更多Kotlin特性,以及面向对象编程。

函数

其实函数我们在一开始就在使用了:

1
2
3
fun main() {
		println("Hello World")
}

我们程序的入口点就是main函数,我们只需要将我们的程序代码编写到主函数中就可以运行了,不过这个函数只是由我们来定义,而不是我们自己来调用。当然,除了主函数之外,我们一直在使用的println也是一个函数,不过这个函数是标准库中已经实现好了的,现在是我们在调用这个函数:

1
println("Hello World!");    //直接通过 函数名称(参数...) 的形式调用函数

那么,函数的具体定义是什么呢?

函数是完成特定任务的独立程序代码单元。

其实简单来说,函数是为了完成某件任务而生的,可能我们要完成某个任务并不是一行代码就可以搞定的,但是现在可能会遇到这种情况:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
fun main() {
    var a = 10

    println("H") //比如下面这三行代码就是我们要做的任务
    println("A")
    a += 10

    if (a > 20) {
        println("H") //这里我们还需要执行这个任务
        println("A")
        a += 10
    }

    when (a) {
        30 -> {
            println("H") //这里又要执行这个任务
            println("A")
            a += 10
        }
    }
}

我们每次要做这个任务时,都要完完整整地将任务的每一行代码都写下来,如果我们的程序中多处都需要执行这个任务,每个地方都完整地写一遍,实在是太臃肿了,有没有一种更好的办法能优化我们的代码呢?

这时我们就可以考虑使用函数了,我们可以将我们的程序逻辑代码全部编写到函数中,当我们执行函数时,实际上执行的就是函数中的全部内容,也就是按照我们制定的规则执行对应的任务,每次需要做这个任务时,只需要调用函数即可。

我们来看看,如何创建和使用函数。

创建和使用函数

Kotlin函数使用fun关键字声明:

1
2
3
fun 函数名称([函数参数...]): 返回值类型 {
    //函数体
}

其中函数名称也是有要求的,并不是所有的字符都可以用作函数名称,它的命名规则与变量的命名规则基本一致,所以这里就不一一列出了。函数不仅仅需要完成我们的任务,可能某些函数还需要告诉我们结果,我们同样可以将函数返回的结果赋值给变量或是参与运算等等,当然如果我们的函数只需要完成任务,不需要告诉我们结果,返回值类型可以不填,我们先从最简单的开始:

1
2
3
4
//这个函数用于打印一段文本
fun hello(): Unit {  //本质上应该是返回Unit类型,这个类型表示空,类似于Java中的void,默认情况下可以省略
    println("PHP是世界上最好的语言.kt")
}

我们要调用这个函数也很简单,只需要像下面这样就可以了:

1
2
3
fun main() {
    hello()     //调用函数只需使用 函数名() 即可
}

不过,有些时候,我们可能需要外部传入一些参数来使用,比如:

1
2
3
fun say(message: String){   //在定义函数时,可以将参数写到
    println("我说:$message")
}

这里我们在函数的小括号中填入的就是形式参数,这代表调用函数时需要传入的数据,比如这里就是我们要打印的字符串,而实际在调用函数时,填入的内容就是实际参数:

1
2
3
4
5
6
7
8
fun main() {
  	//在调用带参数的函数时,必须填写实参,否则无法编译通过
  	//这里填入的内容就是实际参数
    say("你干嘛")
  	//也可以将变量作为实际参数传入
  	val str: String = "哎哟"
    say(str)
}

还有一些时候,我们的函数可能需要返回一个计算的结果给调用者,我们也可以设定函数的返回值:

1
2
3
4
//这个函数用于计算两个Int数之和
fun sum(a: Int, b: Int) : Int {
    return a + b  //使用return语句将结果返回
}

带返回值的函数,调用之后得到的返回值,可以由变量接收,或是直接作为其他函数的参数:

1
2
3
4
5
fun main() {
    var result = sum(1, 2)   //获取函数返回值
    println(result)
    println(sum(2, 4))  //直接打印函数返回值
}

注意这个return关键字在执行之后,是不会继续执行之后的内容的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fun main() {
    println(test(-2))
    println(test(10))
}

fun test(i: Int): String{
    if(i > 0) return "Hello"
  	println("继续")
    return "World"   //如果满足上面条件,在执行return之后,后续无论有没有执行完,都不会再往下了
}

有些时候,我们也可以设计一些参数带有默认值的函数,如果在调用函数时不填入参数,那么就使用我们一开始设置好的默认值作为实际传入的参数:

1
2
3
4
5
6
7
fun main() {
    test()   //调用函数时,如果对应参数有默认值,可以不填
}

fun test(text: String = "我是默认值"){
    println(text)
}

在调用函数时,我们可以手动指定传入的参数对应的是哪一个形式参数:

1
2
3
4
5
6
7
8
fun main() {
    test(b = 3)  //这里如果只想填写第二个参数b,我们可以直接指定吧实参给到哪一个形参
  	test(3)   //这种情况就是只填入第一个实参
}

fun test(a: Int = 6, b: Int = 10): Int {
    return a + b
}

对于一些内容比较简单的函数,比如上面仅仅是计算两个参数的和,我们可以直接省略掉花括号,像这样编写:

1
2
fun test(a: Int = 6, b: Int = 10): Int = a + b   //函数的结果直接缩减为 = a + b 效果跟之前是一样的
fun test(a: Int = 6, b: Int = 10) = a + b  //返回类型可以自动推断,这里可以吧返回类型省掉

这里还需要注意一下,函数的形式参数默认情况下为常量,无法进行修改,只能使用:

比较奇葩的是,函数内部也可以定义函数:

1
2
3
4
5
fun outer(){
    fun inner(){
        //函数内部定义的函数,无限套娃
    }
}

函数内的函数作用域是受限的,我们只能在函数内部使用:

1
2
3
4
5
6
fun outer(){
    fun inner(){
    }

    inner()
}

内部函数可以访问外部函数中的变量:

1
2
3
4
5
6
fun outer(){
    val a = 10;
    fun inner(){
        println(a)
    }
}

最后,我们不能同时编写多个同名函数,这会导致冲突:

但是,如果多个同名函数的参数不一致,是允许的:

1
2
fun test() = println("A")
fun test(str: String) = println("B")  //参数列表不一致

我们在调用这个函数时,编译器会根据我们传入的实参自动匹配使用的函数是哪一个:

1
2
3
4
5
...

fun main() {
    test("")  //结果为B
}

以上适用于形参列表不同的情况,如果仅仅是返回值类型不同的情况,同样是不允许的:

像这种编写同名但不同参数的函数,我们称为函数的重载

再谈变量

前面我们学习了如何使用变量,只不过当时我们仅仅是在main函数中使用的局部变量,我们也可以将变量的作用域进行提升,将其直接变成一个顶级定义:

1
2
3
4
5
var str: String = "尊嘟假嘟"   //跟定义函数一样,直接写在Kt文件中

fun main() {
    ...
}

此时,这个变量可以被所有的函数使用:

1
2
3
4
var str: String = "尊嘟假嘟"

fun main() = println(str)  //作用域的提升,使得变量可以被随意使用
fun test() = println(str)

以上也只是对变量的一些简单使用,现在变量的作用域被提升到顶层,它可以具有更多的一些特性,那么,我们就再来重新认识一下变量,声明一个变量的完整语法如下:

1
2
3
var <propertyName>[: <PropertyType>] [= <property_initializer>]
    [<getter>]
    [<setter>]

前面的我们知道,但是这个getter和setter是个什么鬼?对于这种顶层定义的变量(包括后面类中会用到的成员属性变量)可以具这两个可选的函数,它们本质上是一个get和set函数:

  • getter:用于获取这个变量的值,默认情况下直接返回当前这个变量的值
  • setter:用于修改这个变量的值,默认情况下直接对这个变量的值进行修改

我们在使用这种全局变量时,对于变量的获取和设定,本质上都是通过其getter和setter函数来完成的,只不过默认情况下不需要我们去编写,程序编译之后,有点像这样的结果:

1
2
3
4
5
6
7
8
9
var name: String = "小明"

fun getName() : String {   //编译时自动生成了对应变量的get函数
    return this.name
}

fun setName(name: String) {  //编译时自动生成了set函数
   this.name = name;
}

而对于其使用,在编译之后,会变成这样:

1
2
3
fun main() {
    println(getName())   //获取name时本质上是调用getName函数
}

是不是感觉好神奇,一个变量都能搞这么多花样,这其实是为了后续多态的一些性质而设计的(下一章讲解)

可以看到,在默认情况下,变量的获取就是直接返回,设置就是直接修改,不过有些时候我们可能希望修改这些变量获取或修改时执行的操作,我们可以手动编写:

1
2
var str: String = "尊嘟假嘟"
    get() = field + field   //使用filed代表当前这个变量(字段)的值,这里返回值拼接的结果

这里使用的field准确的说应该是Kotlin提供的"后备字段",因为我们使用getter和setter本质上替代了原有的获取和修改方式,使其变得更像是函数的调用,因此,为了能够继续像之前使用一个变量那样去操作它本身,就有了这个后备字段。

最后得到的就是:

甚至还可以写成这样,在获取的时候执行一些操作:

1
2
3
4
5
6
7
var str: String = "尊嘟假嘟"
    get() {
        println("获取变量的值:")   //获取的时候打印一段文本
        return field + "666"
    }

fun main() = println(str)

同样的,设置的时候也可以自定义:

1
2
3
4
5
6
var str: String = "尊嘟假嘟"
    get() = field + field
    set(value) {    //这里的value就是给过来的值
        println("设置变量的值")
        field = value   //注意,对于val类型的变量,没有set函数,因为不可变
    }

因此,一个变量有些时候可能会写成这样:

1
val str get() = "你干嘛"

当然,默认情况下其实没有必要去重写get和set除非特殊需求。

递归函数

我们前面学习了如何调用函数,实际上函数自己也可以调用自己。

1
2
3
fun test(){
    test()   //我自己调用自己
}

肯定会有小伙伴疑问,函数自己调用自己有什么意义?反而还会导致函数无限的调用下去,无穷无尽,确实,如果不加限制地让函数自己调用自己:

就会出现这种爆栈的情况,这是因为程序的内存是有限的,不可能无限制的继续调用下去,因此,在自我调用到一定的深度时,会被强制终止。所以说这玩意有啥用呢?如果我们对递归函数加以一些限制,或许会有意想不到的发现:

1
2
3
4
5
6
7
8
9
fun main() {
    test(5)  //计算0-5的和
}

//这个函数实现了计算0-n的和的功能
fun test(n: Int): Int{
    if(n <= 0) return 0  //当n等于0的时候就不再向下,而是直接返回0
    return n + test(n - 1)  //n不为0就返回当前的n加上test参数n-1的和
}

这个函数最终调用起来就像这样:

test(5) = 5 + test(4) = 5 + 4 + test(3) = … = 5 + 4 + 3 + 2 + 1 + 0

可以看到,只要合理使用递归函数,加以一定的结束条件,反而能够让我们以非常简洁的形式实现一个需要循环来完成的操作。

我们可以再来看一个案例:

斐波那契数列是一个非常经典的数列,它的定义是:前两个数是1和1,之后的每个数都是前两个数的和。

斐波那契数列的前几个数字依次是:1, 1, 2, 3, 5, 8, 13, 21, 34, 55, …

对于求解斐波那契数列第N个数这类问题,我们也可以使用递归来实现:

1
2
3
4
5
6
7
8
fun main() {
    println(fib(5))
}

fun fib(n: Int): Int{
    if(n <= 2) return 1   //我们知道前两个一定是1,所以直接返回
    return fib(n - 1) + fib(n - 2)   //当前fib(n)的结果就是前两个结果之和,直接递归继续找
}

是不是感觉递归函数非常神奇?它甚至可以解决一些动态规划问题、一些分治算法等。

不过,这种函数的效率就非常低了,相比循环来说,使用递归解决斐波那契问题,时间复杂度会呈指数倍增长,且n大于20时基本可以说很卡了(可以想象一下,每一个fib(n)都会分两个出去,实际上这个中间存在大量重复的计算)

那么,有没有办法可以将这种尾部作为返回值进行递归的操作优化一下呢?我们可以使用tailrec关键字来实现:

1
2
3
4
tailrec fun test(n: Int, sum: Int = 0): Int {
    if(n <= 0) return sum   //到底时返回累加的结果
    return test(n - 1, sum + n)  //不断累加
}

实际上在编译之后,会变成这样:

可以看到它变成了一个普通的循环操作,这也是编译器的功劳,同样的,对于斐波那契数列:

1
2
3
tailrec fun fib(n: Int, prev: Int = 0, next: Int = 1): Int {
    return if (n == 0) prev else fib(n - 1, next, prev + next)  //从0和1开始不断向后,直到n为0就返回
}

实用库函数介绍

Kotlin为我们内置了大量实用的库函数,我们可以使用这些库函数来快速完成某些操作。

比如我们前面使用的println就是Kotlin提供的库函数,我们可以使用这个函数快速进行数据打印:

1
2
3
fun main() {
    println("Hello World")  //这里其实就是在调用函数,传入了一个String类型的参数
}

那既然现在有输出,能不能让用户输入,然后我们来读取呢?

1
2
3
4
fun main() {
    val text = readln()
    println("读取到用户输入:$text")
}

我们可以在控制台输入一段文本,然后回车结束:

Kotlin提供的运算符实际上只能进行一些在小学数学中出现的运算,但是如果我们想要进行乘方、三角函数之类的高级运算,就没有对应的运算符能够做到,而此时我们就可以使用数学工具类来完成。

1
2
3
4
5
6
7
8
9
import kotlin.math.*    //我们需要使用import来引入某些库,这样才能使用库函数

fun main() {
    1.0.pow(4.0)  //我们可以使用pow方法直接计算a的b次方
    abs(-1);    //abs方法可以求绝对值
    max(19, 20);    //快速取两个数的最大值
    min(2, 4);   //快速取最小值
    sqrt(9.0);    //求一个数的算术平方根
}

当然,三角函数肯定也是安排上了的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fun main() {
    //这里我们可以直接使用库中预设好的PI
    sin(PI / 2);     //求π/2的正弦值,这里我们可以使用预置的PI进行计算
    cos(PI);       //求π的余弦值
    tan(PI / 4);    //求π/4的正切值

    asin(1.0);     //三角函数的反函数也是有的,这里是求arcsin1的值
    acos(1.0);
    atan(0.0);
}

可能在某些情况下,计算出来的浮点数会得到一个很奇怪的结果:

1
2
3
fun main() {
    println(sin(Math.PI));
}

正常来说,sinπ的结果应该是0才对,为什么这里得到的是一个很奇怪的数?这个E是干嘛的,这其实是科学计数法的10,后面的数就是指数,上面的结果其实就是:

  • 1.2246467991473532×10−161.2246467991473532×10−16

其实这个数是非常接近于0,这是因为精度问题导致的,所以说实际上结果就是0。

我们也可以计算对数函数:

1
2
3
4
5
6
7
8
fun main() {
    ln(E)    //e为底的对数函数,其实就是ln,我们可以直接使用Math中定义好的e
    log10(100.0)    //10为底的对数函数
  	log2(8.0)    //2为底的对数函数
    //利用换底公式,我们可以弄出来任何我们想求的对数函数
    val a = ln(4.0) / ln(2.0) //这里是求以2为底4的对数,log(2)4 = ln4 / ln2
    println(a)
}

还有一些比较特殊的计算:

1
2
3
4
fun main() {
    ceil(4.5) //通过使用ceil来向上取整
    floor(5.6) //通过使用floor来向下取整
}

向上取整就是找一个大于当前数字的最小整数,向下取整就是砍掉小数部分。注意,如果是负数的话,向上取整就是去掉小数部分,向下取整就是找一个小于当前数字的最大整数。

高阶函数与lambda表达式

注意: 这一部分比较难理解,如果看不懂可以后面回来看。

Kotlin中的函数属于一等公民,它支持很多高级特性,甚至可以被存储在变量中,可以作为参数传递给其他高阶函数并从中返回,就想使用普通变量一样。 为了实现这一特性,Kotlin作为一种静态类型的编程语言,使用了一系列函数类型来表示函数,并提供了一套特殊的语言结构,例如lambda表达式。

那么这里说的高阶函数是什么,lambda表达式又是什么呢?

正是得益于函数可以作为变量的值进行存储,因此,如果一个函数接收另一个函数作为参数,或者返回值的类型就是一个函数,那么该函数称为高阶函数。

要声明函数类型,需要按照以下规则:

  • 所有函数类型都有一个括号,并在括号中填写参数类型列表和一个返回类型,比如:(A, B) -> C 表示一个函数类型,该类型表示接受类型AB的两个参数并返回类型C的值的函数。参数类型列表可为空的,比如() -> A,注意,即使是Unit返回类型也不能省略。

我们可以像下面这样编写:

1
2
3
//典型的函数类型 (参数...) -> 类型  小括号中间是一个剪头一样的符号,然后最后是返回类型
var func0: (Int) -> Unit  //这里的 (Int) -> Unit 表示这个变量存储的是一个有一个int参数并且没有返回值的函数
var func1: (Double, Double) -> String   //同理,代表两个Double参数返回String类型的函数

同样的,作为函数的参数也可以像这样表示:

1
2
fun test(other: (Int) -> String){
}

函数类型的变量,我们可以将其当做一个普通的函数进行调用:

1
2
3
fun test(other: (Int) -> String){
    println(other(1))  //这里提供的函数接受一个Int参数返回string,那么我们可以像普通函数一样传入参数调用它
}

由于函数可以接受函数作为参数,所以说你看到这样的套娃场景也不奇怪:

1
var func: (Int) -> ((String) -> Double)

不过这样写可能有些时候不太优雅,我们可以为类型起别名来缩短名称:

1
2
3
4
5
typealias HelloWorld = (String) -> Double

fun main() {
    var func: HelloWorld
}

那么,函数类型我们知道如何表示了,如何具体表示一个函数呢?我们前面都是通过fun来声明函数:

1
2
3
fun test(str: String): Int {
    return 666
}

而现在我们的变量也可以直接表示这个函数:

1
2
3
4
5
6
7
8
9
fun main() {
  	//这个变量表示的也是(String) -> Int这种类型的函数
    var func: (String) -> Int = ::test   //使用双冒号来引用一个现成的函数(包括我们后续会学习的成员函数、构造函数等)
}

//这个函数正好与上面的变量表示的函数类型一致
fun test(str: String): Int {
    return 666
}

除了引用现成的函数之外,我们也可以使用匿名函数,这是一种没有名称的函数:

1
2
3
4
5
6
fun main() {
    val func: (String) -> Int = fun(str: String): Int {  //这里写了fun关键字后,并没有编写函数名称,这种函数就是匿名函数,因为在这里也不需要什么名字,只需要参数列表函数体
        println("这是传入的内容$str")
        return 666
    }
}

匿名函数除了没名字之外,其他的用法跟函数是一样的。

最后,我们来看看今天的重量级嘉宾,不要小看了Kotlin的语法,我们也可以使用Lambda表达式来表示一个函数实例:

1
2
3
4
5
6
7
fun main() {
    var func: (String) -> Int = {  //一个Lambda表达式只需要直接在花括号中编写函数体即可
        println("这是传入的参数$it")   //默认情况下,如果函数只有一个参数,我们可以使用it代表传入的参数
        666   //跟之前的if表达式一样,默认最后一行为返回值
    }
  	func("HelloWorld!")
}

是不是感觉特别简便?

对于参数有多个的情况,我们也可以这样进行编写:

1
2
3
4
5
6
7
8
9
fun main() {
    val func: (String, String) -> Unit = { a, b ->   //我们需要手动添加两个参数这里的形参名称,不然没法用他两
        println("这是传入的参数$a, 第二个参数$b")   //直接使用上面的形参即可
    }
  	val func2: (String, String) -> Unit = { _, b ->
        println("这是传入的第二个参数$b")   //假如这里不使用第一个参数,也可以使用_下划线来表示不使用
    }
    func("Hello", "World")
}

是不是感觉玩的非常高级?还有更高级的在后面呢!

我们接着来看,如果我们现在想要调用一个高阶函数,最直接的方式就是下面这样:

1
2
3
4
5
6
7
8
fun main() {
    val func: (Int) -> String = { "收到的参数为$it" }
    test(func)
}

fun test(func: (Int) -> String) {
    println(func(66))
}

当然我们也可以直接把一个Lambda作为参数传入作为实际参数使用:

1
2
3
fun main() {
    test({ "收到的参数为$it" })
}

不过这样还不够简洁,在Kotlin中,如果函数的最后一个形式参数是一个函数类型,可以直接写在括号后面,就像下面这样:

1
test() { "收到的参数为$it" }

由于小括号里面此时没有其他参数了,还能继续省,直接把小括号也给干掉:

1
test { "收到的参数为$it" }   //干脆连小括号都省了,这语法真的绝

当然,如果在这之前有其他的参数,只能写成这样了:

1
2
3
4
5
6
7
8
fun main() {
    test(1) { "收到的参数为$it" }
}

//这里两个参数,前面还有一个int类型参数,但是同样的最后一个参数是函数类型
fun test(i: Int, func: (Int) -> String) {
    println(func(66))
}

这种语法也被称为 尾随lambda表达式,能省的东西都省了,不过只有在最后一个参数是函数类型的情况下才可以,如果不是最后一位,就没办法做到尾随了。

最后需要特别注意的是,在Lambda中没有办法直接使用return语句返回结果,而是需要用到之前我们学习流程控制时用到的标签:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
fun main() {
    val func: (Int) -> String = test@{
        //比如这里判断到it大于10就提前返回结果
        if(it > 10) return@test "我是提前返回的结果"
        println("我是正常情况")
        "收到的参数为$it"
    }
    test(func)
}

fun test(func: (Int) -> String) {
    println(func(66))
}

如果是函数调用的尾随lambda表达式,默认的标签名字就是函数的名字:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fun main() {
    testName {  //默认使用函数名称
        if(it > 10) return@testName "我是提前返回的结果"
        println("我是正常情况")
        "收到的参数为$it"
    }
}

fun testName(func: (Int) -> String) {
    println(func(66))
}

不过,为什么要这么麻烦呢,还要打标签才能返回,这不多此一举么?这个问题我们会在下一节内联函数中进行讲解。

内联函数

使用高阶函数会可能会影响运行时的性能:每个函数都是一个对象,而且函数内可以访问一些局部变量,但是这可能会在内存分配(用于函数对象和类)和虚拟调用时造成额外开销。

为了优化性能,开销可以通过内联Lambda表达式来消除。使用inline关键字会影响函数本身和传递给它的lambdas,它能够让方法的调用在编译时,直接替换为方法的执行代码,什么意思呢?比如下面这段代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fun main() {
    test()
}

//添加inline表示内联函数
inline fun test(){
    println("这是一个内联函数")
  	println("这是一个内联函数")
  	println("这是一个内联函数")
}

由于test函数是内联函数,在编译之后,会原封不动地把代码搬过去:

1
2
3
4
5
fun main() {
    println("这是一个内联函数")   //这里是test函数第一行,直接搬过来
  	println("这是一个内联函数")
  	println("这是一个内联函数")
}

同样的,如果是一个高阶函数,效果那就更好了:

1
2
3
4
5
6
7
8
9
fun main() {
    test { println("打印:$it") }
}

//添加inline表示内联函数
inline fun test(func: (String) -> Unit){
    println("这是一个内联函数")
    func("HelloWorld")
}

由于test函数是内联的高阶函数,在编译之后,不仅会原封不动地把代码搬过去,还会自动将传入的函数参数贴到调用的位置:

1
2
3
4
5
fun main() {
    println("这是一个内联函数")   //这里是test函数第一行
  	val it = "HelloWorld"  //这里是函数内传入的参数
    println("打印:$it")  //第二行是调用传入的函数,自动贴过来
}

内联会导致编译出来的代码变多,但是同样的换来了性能上的提升,不过这种操作仅对于高阶函数有显著效果,普通函数实际上完全没有内联的必要,也提升不了多少性能。

注意,内联函数中的函数形参,无法作为值给到变量,只能调用:

同样的,由于内联,导致代码被直接搬运,所以Lambda中的return语句可以不带标签,这种情况会导致直接返回:

1
2
3
4
5
6
7
8
9
fun main() {
    test { return }  //内联高阶函数的Lambda参数可以直接写return不指定标签
    println("调用上面方法之后")
}

inline fun test(func: (String) -> Unit){
    func("HelloWorld")
    println("调用内联函数之后")
}

上述代码的运行结果就是,直接结束,两句println都不会打印,这种情况被称为非局部返回

回到上一节最后我们提出的问题,实际上,在Kotlin中Lambda表达式支持一个叫做"标签返回"(labeled return)的特性,这使得你能够从一个Lambda表达式中返回一个值给外围函数,而不是简单地返回给Lambda表达式所在的最近的封闭函数,就像下面这样:

1
2
3
4
5
6
7
8
9
fun main() {
    test { return@main }  //标签可以直接指定为外层函数名称main来提前终止整个外部函数
    println("调用上面方法之后")
}

inline fun test(func: (String) -> Unit){
    func("HelloWorld")
    println("调用内联函数之后")
}

效果跟上面是完全一样的,为了避免这种情况,我们也可以像之前一样将标签写为@test来防止非局部返回。

1
2
3
4
fun main() {
    test { return@test }  //这样就只会使test返回,而不会影响到外部函数了
    println("调用上面方法之后")
}

有些时候,可能一个内联的高阶函数中存在好几个函数参数,但是我们希望其中的某一个函数参数不使用内联,能够跟之前一样随意当做变量使用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fun main() {
    test({ println("我是一号:$it") }, { println("我是二号:$it") })
}

//在不需要内联的函数形参上添加noinline关键字,来防止此函数的调用内联
inline fun test(func: (String) -> Unit, noinline func2: (Int) -> Unit){
    println("这是一个内联函数")
    func("HelloWorld")
  	var a = func2  //这样就不会报错,但是不会内联了
    func2(666)
}

最后编译出来的结果,类似于:

1
2
3
4
5
6
7
8
fun main() {
    println("这是一个内联函数")
    val it = "HelloWorld"
    println("打印:$it")
  	//第二个参数由于不是内联,这里依然作为Lambda使用
    val func2: (Int) -> Unit = { println("我是二号:$it") }
    func2(666)
}

由于目前知识的学习还不太够,函数我们只能先暂时告一段落,在后续的学习中我们会继续认识更多函数的特性。


类与对象

在之前,我们一直在使用顶层定义:

1
2
3
4
5
val a = 20   //直接在kt文件中定义变量

fun message() {   //直接在kt文件中定义函数
    println("我是测试方法")
}

而学习了类之后,这些内容也可以定义到类中,作为类的属性存在。

类的概念我们在生活中其实已经听说过很多了。

人类、鸟类、鱼类… 所谓类,就是对一类事物的描述,是抽象的、概念上的定义,比如鸟类,就泛指所有具有鸟类特征的动物。比如人类,不同的人,有着不同的性格、不同的爱好、不同的样貌等等,但是他们根本上都是人,所以说可以将他们抽象描述为人类。

对象是某一类事物实际存在的每个个体,因而也被称为实例(instance)我们每个人都是人类的一个实际存在的个体。

所以说,类就是抽象概念的人,而对象,就是具体的某一个人。

  • A:是谁拿走了我的手机?
  • B:是个人。(某一个类型)
  • A:我还知道是个人呢,具体是谁呢?
  • B:是XXX。(具体某个对象)

而在Kotlin中,也可以像这样进行编程,我们可以定义一个类,然后进一步创建许多这个类的实例对象,像这种编程方式,我们称为面向对象编程,我们除了去使用Kotlin给我们提供的类型之外,我们也可以使用自己定义的类。

类的定义与对象创建

前面我们介绍了什么是类,什么是对象,首先我们就来看看如何去定义一个类。

Kotlin中的类使用关键字class声明,我们可以直接在默认的Main.kt文件中编写:

1
2
3
class Student {
    //在没有任何内容时,花括号可以省略
}

我们在对类进行命名时,一般使用英文单词,并且首字母大写,跟变量命名一样,不能出现任何的特殊字符。

除了直接在某个.kt文件中直接编写之外,为了规范,我们一般将一个类单独创建一个文件,我们可以右键src目录:

这里选择新建,然后选择Kotlin类/文件选项,然后创建一个类:

文件创建完成后,默认也会为我们生成类的定义,并且类名称与创建的类文件是一模一样的:

这是一个非常简单的类,但是肯定远远不够。

既然是学生类,那么肯定有学生相关的一些属性,比如名字、性别、年龄等等,那么怎么才能给这个类添加一些属性呢?我们需要指定类的构造函数,构造函数也是函数的一种,但是它是专用于对象的创建,Kotlin中的类可以添加一个主构造函数和一个或多个次要构造函数。主构造函数是类定义的一部分,像下面这样编写:

1
2
3
4
class Student constructor(name: String, age: Int) {
    //比如学生有name和age属性,那么我们可以在类名后面constructor的括号中编写,并用逗号隔开
  	//这里跟定义变量差不多,也是变量名称:类型,这些作为类的成员属性,后续可以在类中使用
}

如果主构造函数没有任何注释或可见性修饰符,则可以省略constructor关键字,如果类中没有其他内容要写,可以直接省略花括号,最后就变成这样了:

1
class Student(name: String, age: Int)

但是,这里仅仅是定义了构造函数的参数,这还不是类的属性,那么我们要怎么才能定义为类的属性呢?我们可以为这些属性添加varval关键字来表示这个属性是可变还是不变的:

1
class Student(var name: String, val age: Int)

这跟我们之前使用变量基本一致:

  • val:不可变属性
  • var:可变属性

这样才算是定义了类的属性,我们也可以给这些属性设置初始值:

1
class Student(var name: String, val age: Int = 18)  //默认每个学生18岁

除了将属性添加到构造函数中,我们也可以将这些属性直接作为类的成员变量写到类中,但是这种情况必须要配一个默认值,否则无法通过编译:

1
2
3
4
class Student {
    var name: String = ""   //必须配一个默认值
    var age: Int = 0
}

这样我们就可以不编写主构造函数也能定义属性,但是这里仍然会隐式生成一个无参的构造函数,为了构造函数能够方便地传值初始化,也可以像这样写:

1
2
3
4
class Student(name: String, age: Int) {
    var name: String = name   //通过构造函数传递过来
    var age: Int = age
}

当然,如果各位不希望这些属性在一开始就有初始值,而是之后某一个时刻去设定初始值,我们也可以为其添加懒加载:

1
2
3
4
class Student {
    lateinit var name: String   //懒加载的属性可以不用在一开始赋值,但是在下一次使用之前一定要先完成赋值,否则报错
    var age: Int = 0
}

并且,像这样编写的类成员变量,也可以自定义对应的getter和setter属性:

1
2
3
class Shape(var width: Int, var height: Int) {
    val area get() = width * height
}

那么,现在我们定义了主构造函数之后,该怎么去使用它呢?

跟我们调用普通函数一样,这里的函数名称就是类的名称,如果一个类没有编写构造函数,那么这个类默认情况下使用一个无参构造函数创建:

1
2
3
4
fun main() {
  	//我们可以直接使用 类名() 的形式创建对象
    Student()
}

如果是有构造函数的类,我们只需要填写需要的参数即可,调用之后,类的属性就是这里我们给进去的参数了:

1
2
3
4
fun main() {
  	//我们可以直接使用 类名(参数, 参数...) 的形式创建
    Student("小明", 18)
}

这样,我们就成功创建出了一个名字为小明的学生类型对象,但是这个对象仅仅是创建出来还不行,我们肯定需要去使用它。

实际上,我们可以像之前使用基本类型一样,使用对象,我们也可以使用一个变量去接收生成出来的对象:

1
2
3
4
fun main() {
  	//使用Student类型的变量接收构造方法得到的对象
    var stu: Student = Student("小明", 18)
}

有一个我们需要注意的点,这里的stu存放的是对象的引用,而不是本体,我们可以通过对象的引用来间接操作对象。

1
2
3
4
fun main() {
    val p1 = Student("小明", 18)
    val p2 = p1
}

这里,我们将变量p2赋值为p1的值,那么实际上只是传递了对象的引用,而不是对象本身的复制,这跟我们前面的基本数据类型有些不同,p2和p1都指向的是同一个对象(如果你学习过C语言,它就类似于指针一样的存在)

我们可以来测试一下:

1
2
3
4
5
fun main() {
    val s1 = Student("小明", 18)
    val s2 = s1
    println(s1 === s2)  //使用 === 可以判断两个变量引用的是不是同一个对象
}

但是如果我们像这样去编写:

1
2
3
4
5
fun main() {
    val s1 = Student("小明", 18)
    val s2 = Student("小明", 18)   //即使名字和年龄一样,但是由于这里重新创建了一次对象
    println(s1 === s2)  //这里比较的就不是同一个对象了
}

我们可以使用.运算符来访问对象的属性,比如我们要访问小明这个学生对象的属性:

1
2
3
4
fun main() {
    val stu = Student("小明", 18)
    println("对象的name = ${stu.name}, age = ${stu.age}")
}

获取和修改都是可以的:

1
2
3
4
5
fun main() {
    val stu = Student("小明", 18)
    stu.name = "大明"
    stu.age = 10   //由于age属性是val,所以说无法修改,只能读取
}

注意,不同对象的属性是分开独立存放的,虽然都是统一由类完成定义,但是每个对象都有一个自己的空间,修改一个对象的属性并不会影响到另一个相同类型的对象:

1
2
3
4
5
6
fun main() {
    val stu1 = Student("小明", 18)
    val stu2 = Student("小明", 18)
    stu1.name = "小红"
    println("${stu1.name}, ${stu2.name}")
}

除了直接使用主构造函数创建对象外,我们也可以添加一些次要构造函数,比如我们的学生可以只需要一个名字就能完成创建,我们可以直接在类中编写一个次要构造函数:

1
2
3
class Student(var name: String, val age: Int) {
    constructor(name: String) : this(name, 18)
}

如果该类有一个主构造函数,则每个次要构造函数需要通过另一个次要构造函数直接或间接委托给主构造函数。委托到同一类的另一个构造函数是this关键字完成的:

1
2
3
4
5
6
class Student(var name: String, val age: Int) {
  	//这里可以使用constructor关键字继续声明次要构造函数
  	//次要构造函数中的参数仅仅是表示传入的参数,不能像主构造函数那样定义属性
  	//这里的this表示是当前这个类,this()就是调用当前类的构造函数
    constructor(name: String) : this(name, 18)  //这里其实是调用主构造函数,并且参数只有name,年龄直接给个默认值18
}

如果一个类没有主构造函数,那么我们也可以直接在在类中编写次要构造函数,但是不需要主动委托一次主构造函数,他这里会隐式包含,所以说我们直接写就行了:

1
2
3
class Student {
    constructor(name: String)  //注意,这里的参数不是类属性,仅仅是一个形参!
}

次要构造函数和主构造函数一样,都可以用于对象的创建:

1
2
3
4
fun main() {
    val stu1 = Student("小明", 18)
    val stu2 = Student("小红")
}

并且次要构造函数可以编写自定义的函数体:

1
2
3
4
5
open class Student {
    constructor(str: String) {   //在使用辅助构造函数初始化对象时,会执行里面的内容
        println("我的名字是: $str")
    }
}

因此,主构造函数相比次要(辅助)构造函数:

  • 主构造函数: 可以直接在主构造函数中定义类属性,使用更方便,但是主构造函数只能存在一个,并且无法编写函数体,只有为类属性做初始化赋值的效果。
  • 辅助(次要)构造函数: 可以存在多个,并且可以自定义函数体,但是无法像主构造函数那样定义类属性,并且当类具有主构造函数时,所有次要构造函数必须直接或间接地调用主构造函数。

Kotlin语言本身比较灵活,类中并不是一定需要主构造函数,全部写辅助构造函数也是可以的,但是再怎么都得有构造函数。

下一部分我们接着来讨论对象的初始化。

对象的初始化

在对象创建时,我们可能需要做一些初始化工作,我们可以使用初始化代码块来完成,初始化代码块使用init关键字来完成。假如我们希望对象在创建的时候,如果年龄不足18岁,那么就设定为18岁:

1
2
3
4
5
6
7
8
9
class Student(var name: String, var age: Int) {  //由于主构造函数无法编写函数体
  	//因此我们可以在init的花括号中编写初始化代码
  	//注意这段初始化代码块,是在上面的类属性被赋值之后才执行的,所以说能拿到已经赋值的age属性
    init {
        println("我是初始化操作")
        if(age < 18) age = 18
        println("初始化操作结束")
    }
}

这样,我们在创建对象的时候,就会在创建的时候自动执行初始化代码块里面的代码:

1
2
3
4
fun main() {
    val stu = Student("小明", 15)
    println(stu.age)
}

可以看到初始化操作开始执行了:

初始化操作不仅仅可以有一个,也可以有很多个:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Student {
    //注意,多个初始化操作时,从上往下按顺序执行
    init {
        println("我是一号初始化操作")
    }

    init {
        println("我是二号初始化操作")
    }
}

对于将成员属性写到类中的情况,同样是按照顺序向下执行,比如:

因为成员变量a是在初始化代码块的后面才初始化的,这里会报错。

如果一个类具有次要构造函数,那么我们也可以直接在次要构造函数中编写一些初始化代码:

1
2
3
4
5
class Student(var name: String, var age: Int) {
    constructor(name: String) : this(name, 18) {
        println("我是次要构造函数中的语句")
    }
}

当我们使用对应的次要构造函数时,就会执行次要构造函数中的初始化代码了。

这里需要注意一下,次要构造函数实际上需要先执行主构造函数,而在执行主构造函数时,会优先将之前我们讲解的初始化代码块执行,比如下面的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Student(var name: String, var age: Int) {

    init {
        println("我是初始化代码块")
    }

    constructor(name: String) : this(name, 18) {
        println("我是次要构造函数")
    }
}

无论是有主构造函数还是没有主构造函数(会生成一个默认的无参构造函数)都会先执行。

类的成员函数

现在我们的类有了属性,我们可以为创建的这些对象设定不同的属性值,比如每个人的名字都不一样,性别不一样,年龄不一样等等。只不过光有属性还不行,对象还需要具有一定的行为,就像我们人可以行走,可以跳跃,可以思考一样。

而对象也可以做出一些行为,我们可以通过定义函数来实现,类的函数和我们之前编写的函数有一些区别,它是属于这个类的,我们之前使用的函数都是直接编写在Kt文件中,它们都是顶级函数。

1
2
3
4
5
6
class Student(var name: String, var age: Int) {
    //这个函用于跟大家打招呼
    fun hello(){
        println("大家好啊")
    }
}

要使用类的成员函数,我们只能通过对象来进行调用:

1
2
3
4
5
fun main() {
    val stu = Student("小明", 18)
  	//调用类中的成员方法,同样使用.运算符即可
    stu.hello()  //让小明这个对象给大家打招呼
}

是不是稍微有一些体会了?好像真的是我们在让对象执行一个动作一样。在类的成员函数中,我们可以直接访问当前类对象中的一些属性,比如我们这里的用户名和年龄:

1
2
3
4
5
class Student(var name: String, var age: Int) {
    fun hello(){
        println("大家好啊,我叫$name,今年${age}岁了")
    }
}

注意,这里我们访问的name和age属性,是当前这个对象的name和age属性。比如:

1
2
3
4
5
6
7
fun main() {
    val stu = Student("小明", 18)
    stu.hello()  //让小明这个对象给大家打招呼

    val stu2 = Student("小红", 17)
    stu2.hello()  //让小红这个对象给大家打招呼
}

注意,下面这种情况,我们需要特殊处理:

1
2
3
4
5
6
7
class Student(var name: String, var age: Int) {
    //此时函数的参数也有一个name变量,而类的成员也有一个name属性
    fun hello(name: String){
        //这里得到的name是哪一个?
        println("大家好啊,我叫$name,今年${age}岁了")
    }
}

如果函数中的变量存在歧义,那么优先使用作用域最近的一个,比如函数形参的name作用域更近,那么这里的name拿到的一个是形参name,而不是类的成员属性name。

如果我们需要获取的是类中的成员属性,需要使用this关键字来表示当前类:

1
2
3
4
fun hello(name: String){
    //使用this关键字表示当前对象,这样就可以指定这里是类中的this了
    println("大家好啊,我叫${this.name},今年${age}岁了")
}

默认情况下,如果作用域不冲突,使用类中属性this可以省略。

在类中,我们同样可以定义多个同名但不同参数的函数实现重载:

1
2
3
4
class Student(private var name: String, private var age: Int) {
    fun hello() = println("大家好啊,我叫${this.name},今年${age}岁了")
    fun hello(gender: String) = println("大家好啊,我叫${this.name},今年${age}岁了,性别${gender}")
}

实际上类中的函数使用起来跟我们之前定义的大差不差,只不过多了更多用法而已。

再谈基本类型

在Kotlin中,万物皆为对象,实际上我们在上一章学习的全部基本类型,都是官方为我们提供的类。

现在我们学习了类与对象的知识,就可以来重新认识一下这些基本类型,实际上这些基本类型同样是类,也具有一些属性,以及一些类中的成员函数。实际上在上一章中,我们就已经开始使用类和对象了,我们对这些基本类型的操作同样是在操作对象:

1
2
3
4
fun main() {
    var a = 10   //这里其实是一个Int类型的对象,值为10,而a持有的是对这个Int对象的引用
    var b = a    //这里的b复制了对上面Int类型对象的引用
}

特别说明: 在Kotlin中,虽然编码时万物皆对象,但是在最终编译时,会根据上下文进行优化性能,大部分情况下会优先编译为Java原生基本数据类型(不是对象)而另一部分情况下才会编译为Java中的Integer包装类型。因此很容易出现以下迷惑行为:

1
2
3
val a: Int = 12345
val b: Int = 12345
println(a === b)   //true
1
2
3
val a: Int? = 12345
val b: Int? = 12345
println(a === b)   //false

各位小伙伴可以在完整学习Java和后续Kotlin内容之后再来探究这个问题。

既然这些基本类型也是类,那么肯定同样具有成员属性和成员函数,我们可以使用这些成员方法方便我们的项目开发,比如我们之前遇到的一个很麻烦的问题,不同类型的数无法相互转换:

这些时候可能我们需要将对应类型的数据转换为其他类型,那么该怎么办呢,实际上,在这些基本类型中都提供了对应类型转换成员函数,这里我们可以使用toInt来直接将Double类型的数据转换为Int类型:

1
2
3
fun main() {
    var a: Int = 1.25.toInt()  //使用类中的类型转换函数
}

这样就可以编译通过了。同样的,每个基本类型都有对应的类型转换函数,而且非常全面,比如Int类型:

有了这些成员函数,就大幅度方便了我们的类型转换,再比如我们常见的String类型,也有很多函数可以使用:

1
2
3
4
5
6
fun main() {
    val a = "HelloWorld"
  	//使用lowercase和uppercase可以快速将字符串中的字母进行大小写转换
    println(a.lowercase())
    println(a.uppercase())
}

不过需要注意的是,我们在前面就说过,字符串一旦创建就是不可变的,因此,字符串中所有的函数得到的新字符串,都是重新创建的一个新的对象,而不是在原本的字符串上进行修改。

我们继续来看看一些有意思的函数,比如我们想批量替换字符串中的某些内容:

1
2
3
4
fun main() {
    val a = "Hello World!"
    println(a.replace("o", "a"))
}

将字符串中所有的字母o替换为a,直接使用replace函数就能直接生成替换之后的字符串了。又比如我们要判断某个字符串是否以指定文本开头:

1
2
3
4
fun main() {
    val a = "Hello World!"
    println(a.startsWith("Hel"))
}

可以看到这里经过判断得到了一个Boolean类型的结果,还有很多用于判断字符串是否为空、是否有空格等等的函数:

1
2
3
4
5
fun main() {
    val a = "Hello World!"
    a.isBlank()
    a.isEmpty()
}

我们还发现,这些基本类型中有一些比较特殊的函数,比如plus函数:

这个函数在类中定义长这样:

1
public operator fun plus(other: Long): Long

这个函数添加了一个operator关键字,这个是什么呢?这其实是运算符重载,能够自定义运算符实现的功能,我们之前使用这些数字进行运算,比如加减乘除,实际上都是这些基本类型在类中重载了运算符实现的,下一部分,我们就来介绍一下运算符重载函数。

运算符重载函数

Kotlin支持为程序中已知的运算符集提供自定义实现,这些运算符具有固定的符号表示(如+*)以及对应的优先级,要实现运算符重载,请为相应类型提供具有对应运算符指定名称的成员函数,而当前的类对象,则直接作为对应运算符左边的操作数,如果是一元运算符(比如++自增运算符,只需要本事)则直接作为操作数参与运算。

比如,现在我们想要为我们自定义的类型支持加法运算:

我们可以直接在类定义中添加一个固定名称(名称是预设好的,不能自己想写什么写什么)的函数,这里的加法运算就是plus函数,我们直接开始编写就可以了:

1
2
3
4
5
6
7
8
class Student(var name: String, var age: Int) {
    //注意,二元运算符必须带一个形参,表示右侧的操作数,返回值为计算出来的结果
  	//形参和结果可以是任意类型,我们还可以提供多次编写同名的运算符重载函数来适配不同的类型
    operator fun plus(another: Student): Student{
        //比如这里我们希望两个学生对象相加,得到的结果为名字相加,年龄相加的一个新学生
        return Student(this.name + another.name, this.age + another.age)
    }
}

这样,我们就成功重载了加法运算符,可以直接上手使用:

1
2
3
4
5
6
fun main() {
    val a = Student("小米", 18)
    val b = Student("华为", 19)
    val c = a + b
    println("运算之后得到的新学生,名称:${c.name},年龄:${c.age}")
}

是不是感觉很简单?只需要将我们需要的对应运算符直接重载,编写好对应的计算规则,就可以直接使用对应的运算符进行计算。

我们也可以试试看重载一些一元运算符,比如取反运算符:

1
2
3
4
5
6
7
8
class Student(var name: String, var age: Int) {
    //比如取反操作就是把当前学生的名字反过来
    operator fun not() : Student {
        this.name = this.name.reversed()
        //这里可以直接在当前对象上进行操作,然后返回当前对象
        return this
    }
}

我们来尝试使用一下:

1
2
3
4
5
fun main() {
  	//直接在这里使用!运算符
    val a = !Student("小米", 18)
    println("运算之后得到的新学生,名称:${a.name},年龄:${a.age}")
}

最后,我们列出常见的一些运算符对应的函数名称,首先是一元运算符:

符号 对应的函数名称
+a a.unaryPlus()
-a a.unaryMinus()
!a a.not()
a-- a.dec()+见下文
a++ a.inc()+见下文

其中inc()dec()函数比较特殊,它们必须返回一个值,该值将分配给使用++--操作的变量,而不是改变执行incdec操作的对象,意思就是执行后应该得到一个新生成的对象,然后变量的值直接引用到这个新的对象,因为Int类型就是这样的,比如a++的操作步骤如下:

  • a的初始值存储到临时存储a0
  • a0.inc()的结果分配给a
  • 返回a0作为表达式的结果。

同样的,++a的操作步骤如下:

  • a.inc()的结果分配给a
  • 作为表达式的结果返回a的新值。

认识完了一元运算符,我们接着来看一些基本二元运算符:

符号 对应的函数名称
a + b a.plus(b)
a - b a.minus(b)
a * b a.times(b)
a / b a.div(b)
a % b a.rem(b)
a..b a.rangeTo(b)
a..<b a.rangeUntil(b)
符号 对应的函数名称
a in b b.contains(a)
a !in b !b.contains(a)

对于in这种运算,必须返回Boolean类型的结果。

还有一些自增简化运算符:

符号 对应的函数名称
a += b a.plusAssign(b)
a -= b a.minusAssign(b)
a *= b a.timesAssign(b)
a /= b a.divAssign(b)
a %= b a.remAssign(b)

这类运算符都是将运算结果赋值给左边的操作数,比如a = a + b等价于a += b,这种情况可能会与上面的基本操作产生歧义,比如下面的情况:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Student(var name: String, var age: Int) {
		//同时定义plus和plusAssign
    operator fun plus(another: Student) : Student {
        return this
    }

    operator fun plusAssign(another: Student) : Unit{

    }
}

可以看到,上面的函数中,plus运算符在重载之后,运算结果与当前类型是相同的,这种情况下,就会出现一个问题:

  • plus: 算式 a = a + b 可以成立,因为返回类型相同,可以重新赋值给a
  • plusAssign:为算式 a = a + b 的缩写,与plus的功能完全一致

此时,两个函数都匹配这里的运算符使用,编译器不知道该用哪一个了,因此就会出现歧义:

比较运算符只需要实现一个函数即可:

运算符 对应的函数名称
a > b a.compareTo(b) > 0
a < b a.compareTo(b) < 0
a >= b a.compareTo(b) >= 0
a <= b a.compareTo(b) <= 0

所有比较都会转换为compareTo函数调用,此函数返回Int值,这个值用于判断是否满足条件。

Kotlin非常强大,甚至连小括号都能重载:

运算符 对应的函数名称
a() a.invoke()
a(i) a.invoke(i)
a(i, j) a.invoke(i, j)
a(i_1, ..., i_n) a.invoke(i_1, ..., i_n)

直接使用变量名称+()来进行使用,感觉很像函数的调用,但是又不是,就很奇怪,不过确实很强大就是了。

还有一些运算符,以我们目前所学知识还无法进行讲解,后续在各位小伙伴学习之后,可以回顾一下:

运算符 对应的函数名称
a[i] a.get(i)
a[i, j] a.get(i, j)
a[i_1, ..., i_n] a.get(i_1, ..., i_n)
a[i] = b a.set(i, b)
a[i, j] = b a.set(i, j, b)
a[i_1, ..., i_n] = b a.set(i_1, ..., i_n, b)

这是索引访问运算符,使用方括号进行表示。

中缀函数

实际上中缀函数在我们之前很多时候都有出现,比如位运算:

1
println(i shl 1)

这里的shl并不是一个运算符,而是一段自定义的英文单词,像这种运算符是怎么做到的呢?

这其实是中缀函数,用infix关键字标记的函数被称为中缀函数,在使用时,可以省略调用的点和括号进行调用,Infix函数必须满足以下要求:

  • 必须是成员函数。
  • 只能有一个参数。
  • 参数不能有默认值。

我们可以像下面这样编写:

1
2
3
4
5
6
class Student(var name: String, var age: Int) {
  	//这个中缀函数实现了将给定字符串与当前对象的名字拼接并返回
    infix fun test(string: String) : String{
        return name + string
    }
}

我们在使用时,也非常方便,真的就像在使用一个运算符一样:

1
2
3
4
fun main() {
    val student = Student("小明", 18)
    println(student test "我爱你")
}

得到的结果显而易见:

当然,我们也可以把它当做一个普通的函数进行调用,效果是完全等价的:

1
2
3
4
fun main() {
    val student = Student("小明", 18)
    println(student.test("崴泥"))
}

这里需要注意一下:

中缀函数调用的优先级低于算术运算符、类型转换和rangeTo运算符,例如以下表达式就是等效的:

  • 1 shl 2 + 3相当于1 shl (2 + 3)
  • 0 until n * 2相当于0 until (n * 2)
  • xs union ys as Set<*>相当于xs union (ys as Set<*>)(类型转换会在下一章多态进行介绍)

另一方面,infix函数调用的优先级高于布尔运算符&&||is-和in-checks以及其他一些运算符的优先级。这些表达式也是等价的:

  • a && b xor c相当于a && (b xor c)
  • a xor b in c相当于(a xor b) in c

同时,如果需在类中使用中缀函数,必须明确函数的调用方(接收器)比如:

1
2
3
4
5
6
7
8
9
class MyStringCollection {
    infix fun add(s: String) { /*...*/ }

    fun build() {
        this add "abc"   // 正确
        add("abc")       // 正确
        //add "abc"        // 错误: 没有指定调用方或无法隐式表达
    }
}

对于中缀函数的使用还是比较简单的。

空值和空类型

所有的变量除了引用一个具体的值之外,还有一种特殊的值可以使用,那就是null,它代表空值,也就是不引用任何对象。

在其他语言中,比如Java中null是一个非常常见的值,因为在某些情况下,引用类型的变量默认值就是null,这就经常会导致程序中出现一些空指针导致的异常,在Kotlin中,对空值处理是非常严格的,正常情况下,我们的变量是不能直接赋值为null的,否则会报错,无法编译通过:

这是因为所有的类型默认都是非空类型,非空类型的变量是不允许被赋值为null的,这直接在编译阶段就避免了其他语言中经常存在的空指针问题。

那么,如果我们希望某个变量在初始情况下使用null而不去引用某一个具体对象,该怎么做呢,此时我们需要将变量的类型修改为可空类型,只需在类型名称的后面添加一个?即可:

1
2
3
fun main() {
    var str: String? = null
}

既然现在是可空类型,那么很多问题就会出现了,比如当一个变量为null时,此时如果使用类中的一些成员方法或是获取成员属性时,会出现一些问题:

这里由于我们操作的是一个空类型,它有可能值为null,我们可以想象一下,如果一个变量不引用任何对象,此时我们又去让对象做一些事情(执行函数)这不是在搞笑吗,压根就没这个对象,难道让空气去执行操作吗?这显然是不对的,这样就会导致我们上面所说的空指针异常。

此时,为了安全,我们就需要对变量进行判断,看看其是否为null然后才能去做一些正常情况下该做的事情:

1
2
3
4
5
6
7
fun main() {
    var str: String? = null
  	//这里直接通过if语句判断str变量是否为null,如果不是才执行
    if (str != null) {
        println(str.length)  //现在就可以编译通过了
    }
}

可以看到,我们只要能确保某个空类型变量的值不为空,那么就可以正常执行操作。当然,实际上在这个if内部,因为已经判断不为null了,所以str被智能类型转换为非空类型,这也是Kotlin语言非常人性化的地方。

不过在有些情况下,我们可能已经非常清楚,这里的str一定不为null,即使它是一个可空类型变量,我们可以像这样做,来告诉编译器,我们这里一定是安全的,只管执行就好:

1
2
3
4
5
fun main() {
    var str: String? = null
  	//使用非空断言操作符!!.来明确不会出现null问题
    println(str!!.length)
}

虽然使用非空断言操作符能够进行强制操作,但是这样实际上并不安全,它同样存在安全问题,也许我们有没考虑到的情况会导致这里为null呢,也说不定吧?对于一些我们拿不定具体会不会出现null的情况,有没有更好的解决办法呢?

Kotlin为我们提供了一种更安全的空类型操作,要安全地访问可能包含null值的对象的属性,请使用安全调用运算符?.,如果对象的属性为null则安全调用运算符返回null,像下面这样:

1
2
3
4
fun main() {
    var str: String? = null
    println(str?.length)
}

这里的调用结果存在两种情况:

  • 如果str为null,那么这里得到的结果就是null,并且不会正常执行后面的操作
  • 如果str不为null,那就正常返回这里本应该得到的结果

因此,使用安全调用运算符后,如果遇到null的情况,那么这里不会正常进行原本的操作,而是直接返回null作为结果,这在有些时候非常好用,比如我们希望一个学生类型的变量在为null时就不执行对应的语句:

1
2
3
4
fun main() {
    val stu: Student? = null
    stu?.hello()
}

不过在有些时候,可能我们希望如果变量为null,在使用安全调用运算符时,返回一个我们自定义的结果,而不是null,这时该怎么做呢?我们可以使用Elvis运算符:

1
2
3
4
5
fun main() {
    val str: String? = null
  	//Elvis运算符 ?: 左侧为空值检测目标,右侧为检测到null时返回的结果
    val len: Int = str?.length ?: 0
}

这里我们使用了Elvis运算符来判断左侧是否为null,如果左侧为null,那么这里直接得到右侧的自定义值,这个运算符长得巨像其他语言里面的三元运算符,Kotlin拿来干这事了。

解构声明

有时候,我们在使用对象时可能需要访问它们内部的一些属性:

1
2
3
4
5
fun main() {
    val student = Student("小明", 18)
    println(student.name)  //访问name属性
    println(student.age)
}

这样看起来不太优雅,有没有更好的方式呢,比如这里能不能直接得到Student对象内部的name和age熟悉作为变量使用?当然是可以的,我们可以直接像下面这样编写:

1
2
3
4
5
fun main() {
    val student = Student("小明", 18)
    val (a, b) = student   //从Student对象中将其属性解构出来,很优雅
    println("名字: $a, 年龄: $b")
}

要让一个类的属性支持解构,我们只需添加约定的函数即可,在Kotlin中,我们可以自定义解构出来的结果,而具体如何获取,需要定义一个componentN函数并通过返回值的形式返回解构的结果:

1
2
3
4
5
class Student(var name: String, var age: Int) {
    operator fun component1() = name   //使用component1表示解构出来的第一个参数
    operator fun component2() = age    //使用component2表示解构出来的第二个参数
  	operator fun component3...  //以此类推
}

添加用于解构的函数在之后,我们就可以使用解构操作了:

1
val (a, b) = student   //解构出来的参数按顺序就是componentN的结果了

如果我们只想要使用第二个参数,而第一个参数不需要,可以直接使用_来忽略掉:

1
2
val (_, b) = student
println("年龄: $b")

解构同样可以用在Lambda表达式中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
val func: (Student) -> Unit = { (a, b) ->  //使用括号包括结构出来的两个参数
    println("名字: $a, 年龄: $b")
}

val func2: (Student, Int) -> Unit = { (a, b), i ->
    println("名字: $a, 年龄: $b")
    println(i)
}

val func3: (Student, Int) -> Unit = { (_, b), i ->
    println("名字: $a, 年龄: $b")
    println(i)
}

解构语法在遍历集合类和数组时同样适用,我们会在后面进行讲解。

包和导入

在之前,无论我们创建的是Kotlin源文件还是Kotlin类文件,都是在默认的包下进行的,也就是直接在kotlin/src目录创建的。

但是有些时候,我们可能希望将一些模块按功能进行归类,而不是所有的kt文件都挤在一起,这个时候我们就需要用到包了。

我们可以直接右键新建一个软件包,软件包的包名建议以域名格式进行命名,例如:

  • com.baidu
  • cn.itbaima

这类似于我们平时在浏览器中访问的网站地址,只不过是反过来的,这样就能很明确是哪一家公司或哪一个人制作的产品了。

这里我们随便创建一个:

我们可以将kt文件直接创建在这个包中:

所有不在默认包下kt文件,必须在顶部声明所属的包,比如这里的Test.kt就放在com.test这个包中,因此顶部必须使用package关键字进行包声明,IDEA非常智能,在创建时就自动帮助我们生成好了。我们可以继续像之前一样,编写类或是函数:

1
2
3
4
5
6
7
8
9
package com.test

var a = 20

fun message() {
    println("我是测试方法")
}

class User

不过,由于现在kt文件存放在了一个明确的包中,如果我们要在这个包以外的其他地方使用,会出现一些问题:

当我们使用其他包中kt文件定义的类或函数时,会直接提示未解析的引用,这是因为默认情况下只有同包内的内容可以相互使用,而现在我们使用的是其他包中的内容,我们需要先进行导入操作:

1
2
3
4
5
6
7
8
import com.test.User   //使用import关键字进行导入,导入时需要输入 包名.类型/顶级函数名称 来完成
import com.test.message
import com.test.a

fun main() {
    val user = User()
    message()
}

这样,我们在导入之后就可以正常使用了,当然,如果一个包中定义的内容太多,我们需要大量使用,也可以使用*一次性导入全部内容:

1
2
3
4
5
6
import com.test.*   //导入此包下的全部内容

fun main() {
    val user = User()
    message()
}

实际上官方提供的库,也是来自于不同的包,但是Kotlin在默认情况下会自动导入一些包,不需要我们明确指定:

比如我们之前用到的一些基本类型,都是在kotlin这个包中定义的。

注意:在不同的平台下,还会有更多默认导入的包,比如Java平台下,就会默认导入java.lang.*kotlin.jvm.*这两个包。

在有些情况下,可能会出现名称冲突的情况:

1
2
3
4
5
6
7
8
9
import com.test.message

fun main() {
    message()   //这里调用的,到底是导入的message函数,还是当前kt文件定义的函数呢?
}

fun message(){
    println("Goodbye World!")
}

结果显而易见,这里会优先使用导入的函数,而不是在当前文件中定义的同名函数。那么该如何去解决这种冲突的情况呢?我们可以使用as关键字来为导入的内容起个新的名字:

1
2
3
4
5
6
7
8
9
import com.test.message as outer //将导入的message函数名字改为outer

fun main() {
    message()   //此时这里调用的就是下面的message函数了
}

fun message(){
    println("Goodbye World!")
}

这样就可以很好地消除存在歧义的情况了,最后总结一下,使用import关键字支持导入以下内容:

  • 顶级函数和属性
  • 在单例对象中声明的函数和属性(下一章介绍)
  • 枚举常量(下一章介绍)

访问权限控制

有些时候,我们可能不希望别人使用我们的所有内容,比如:

1
2
3
4
5
6
7
8
9
package com.test

fun message() {
    println("我是测试方法")
}

fun inner(){
    //我们不希望这个函数能够在其他地方被调用
}

在上面的例子中,有一个函数是我们不希望被外部调用的,但是经过前面的学习,我们只需要使用import关键字就能直接导入,那有没有办法能够控制一下其他地方对于当前文件一些可能私有函数或是其他内容的访问呢?我们可以使用可见性控制来处理。

在类、对象、接口、构造函数和函数,以及属性上,可以为其添加 可见性修饰符 来控制其可见性,在Kotlin中有四个可见性修饰符,它们分别是:privateprotectedinternalpublic,默认可见性是public,在使用顶级声明时,不同可见性的访问权限如下:

  • 如果不使用可见性修饰符,则默认使用public,这意味着这里声明的内容将在任何地方可访问。
  • 如果使用private修饰符,那么声明的内容只能在当前文件中可访问。
  • 如果使用internal修饰符,它将在同一模块中可见(当前的项目中可以随意访问,与public没大差别,但是如果别人引用我们的项目,那么无法使用)
  • 顶级声明不支持使用protected修饰符。

因此,在默认情况下,我们定义的内容都是可以访问的,而想要实现上面的效果,我们可以为其添加private修饰符:

1
2
3
private fun inner(){
    //我们不希望这个函数能够在其他地方被调用
}

这样,当其他地方使用时,就会报错:

在类中定义成员属性时,不同可见性的访问权限如下:

  • private意味着该成员仅在此类中可见(包括其所有成员)
  • protectedprivate的可见性类似,外部无法使用,但在子类中可以使用(子类会在下一章中介绍)
  • internal意味着本项目中任何地方都会看到其internal成员,但是别人引用我们项目时不行。
  • public意味着任何地方都可以访问。

比如下面的例子:

1
2
3
4
class Student(private var name: String, //name属性无法被外部访问,因为是私有的
              internal var age: Int) {  //age可以被外部访问,但是无法再其他项目中访问到
    private constructor() : this("", 10)  //这个无参构造无法被外部访问,因为是私有的
}

有了访问控制,我们就可以更加明确地表示哪些内容是可以访问,而哪些是内部使用的。

封装、继承和多态

封装、继承和多态是面向对象编程的三大特性。

  • 封装,把对象的属性和函数结合成一个独立的整体,隐藏实现细节,并提供对外访问的接口。
  • 继承,从已知的一个类中派生出一个新的类,叫子类。子类实现了父类所有非私有化的属性和函数,并根据实际需求扩展出新的行为。
  • 多态,多个不同的对象对同一消息作出响应,同一消息根据不同的对象而采用各种不同的函数。

正是这三大特性,能够让我们的Kotlin程序更加生动形象。

类的封装

封装的目的是为了保证变量的安全性,使用者不必在意具体实现细节,而只是通过外部接口即可访问类的成员,如果不进行封装,类中的实例变量可以直接查看和修改,可能给整个程序带来不好的影响,因此在编写类时一般将成员变量私有化,外部类需要使用Getter和Setter函数来查看和设置变量。从这里开始,我们前面学习的权限访问控制就开始起作用了。

我们可以将之前的类进行改进:

1
2
3
4
class Student(private var name: String, private var age: Int) {
    fun getName(): String = name
    fun getAge(): Int = age
}

现在,外部需要获取一个学生对象的属性时,只能使用特定的函数进行获取,而不像之前一样可以随意访问对象的属性:

1
2
3
4
5
fun main() {
    var student = Student("", 1)
    //student.name   这样就不行了
    println(student.getName())
}

这样的好处显而易见,其他地方只能拿到在内部某个成员属性引用的对象,而没办法像之前那样直接修改Student对象中某个成员属性。

同样的,如果要运行外部对对象中的属性进行修改,那么我们也可以提供对应的set函数:

1
2
3
4
5
6
7
class Student(private var name: String, private var age: Int) {
    ...

    fun setName(name: String){   //使用set函数来修改
        this.name = name
    }
}

等等,这不就是我们之前讲的属性的getter和setter函数吗,没错,哪怕我们不手动编写,成员属性也会存在默认的。但是,除了直接赋值之外我们也可以设置更多参数才能给学生改名字:

1
2
3
4
5
6
7
class Student(private var name: String, private var age: Int) {

    fun setName(name: String, upper: Boolean){
      	//判断是否upper来决定最终赋值的名字大写还是小写
        this.name = if (upper) name.uppercase() else name.lowercase()
    }
}

我们自己封装好的名字设置方法暴露给外部使用,而不让外部直接操作名字。

我们甚至还可以将主构造函数改成私有的,需要通过其他的构造函数来构造:

1
2
3
class Student private constructor(private var name: String, private var age: Int) {
    constructor() : this("", 18)
}

封装思想其实就是把实现细节给隐藏了,外部只需知道这个函数是什么作用,如何去用,而无需关心实现,要用什么由类自己提供好,而不需要外面来操作类内部的东西去完成(你让我做一件事情,我自己的事情自己做,不要你来帮我安排)封装就是通过访问权限控制来实现的。

类的继承

前面我们介绍了类的封装,我们接着来看一个非常重要特性:继承。

在定义不同类的时候存在一些相同属性,为了方便使用可以将这些共同属性抽象成一个父类,在定义其他子类时可以继承自该父类,减少代码的重复定义,根据前面的访问权限等级,子类可以使用父类中所有非私有的成员。

比如说我们一开始使用的学生,那么实际上学生根据专业划分,所掌握的技能也会不同,比如体育生会运动,美术生会画画,土木生会搬砖,计算机生会因为互联网寒冬找不到工作,因此,我们可以将学生这个大类根据不同的专业进一步地细分出来:

虽然我们划分出来这么多的类,但是其本质上还是学生,也就是说学生具有的属性,这些划分出来的类同样具有,但是,这些划分出来的类同时也会拥有他们自己独特的技能。就好比大学里的学生无论什么专业都会打游戏,都会睡觉,逃课,考试抄答案,四六级过不了,只不过他们专业不同,学的的方向不一样,也就掌握了其他专业不具备的技能。

在Kotlin中,我们可以使用继承操作来实现这样的结构,默认情况下,Kotlin类是“终态”的(不能被任何类继承)要使类可继承,请用open关键字标记需要被继承的类:

1
2
3
4
open class Student {  //被继承的类我们称为父类
  	val xxx = "学生证"
    fun hello() = println("我会打招呼")
}

我们可以像下面这样来创建一个继承学生的类:

1
2
3
4
class ArtStudent : Student() {  //以调用构造函数的形式进行声明
  	//这个类就是Student类的子类
  	fun draw() = println("我会画画")   //子类中也可以继续编写自己独有的函数
}

类的继承可以不断向下,但是同时只能继承一个类,在Kotlin中不支持多继承,只不过套娃还是可以的:

1
2
3
4
open class Student
open class ArtStudent: Student()  //继承了一级,相当于Student的儿子
open class SuperArtStudent: ArtStudent()  //继承了两级,相当于Student的孙子
class SuperBigArtStudent: SuperArtStudent()  //继承了三级,相当于Student的祖孙

当一个类继承另一个类时,属性会被继承,可以直接访问父类中定义的属性,除非父类中将属性的访问权限修改为private,那么子类将无法访问(但是依然是继承了这个属性的)比如下面的例子:

1
2
3
4
5
6
fun main() {
    var student = ArtStudent()
    student.hello()   //虽然这里是ArtStudent对象,但是由于其继承的是Student,因此包含Student中的属性
  	student.draw()   //自己的属性也可以使用
  	print(student.xxx)   //不止函数,父类中的成员字段也是没有问题的
}

是不是感觉非常人性化,子类继承了父类的全部能力,同时还可以扩展自己的独特能力,就像一句话说的: 龙生龙凤生凤,老鼠儿子会打洞。这里需要特别注意一下,因为子类相当于是父类的扩展,但是依然保留父类的特性,所以说,在对象创建并初始化的时候,不仅会对子类进行初始化,也会优先对父类进行初始化:

1
2
3
4
5
6
7
8
9
open class Student() {
    init { println("父类初始化") }
    fun hello() = println("我会打招呼")
}

class ArtStudent() : Student() {
    init { println("子类初始化") }
    fun draw() = println("我会画画")
}

实际上这里就是在构造这个子类对象之前,调用了一次父类的构造函数,而我们用于继承指定的构造函数,就是会被调用的那一个。

因此,如果父类存在一个有参构造函数,子类同样必须在构造函数中调用:

1
2
3
4
5
6
7
8
9
open class Student(name: String, age: Int) {
    fun hello() = println("我会打招呼")
}

//子类必须适配其父类的构造函数,因为需要先对父类进行初始化
//其实就是去调用一次父类的构造函数,填入需要的参数即可,这里的参数可以是当前子类构造方法的形参,也可以是直接填写的一个参数
class ArtStudent(name: String, age: Int) : Student(name, 18) {
    fun draw() = println("我会画画")
}

如果父类存在多个构造函数,可以任选一个:

1
2
3
4
5
6
7
8
9
open class Student() {
    constructor(str: String) : this()
    constructor(str: String, age: Int) : this()
    fun hello() = println("我会打招呼")
}

class ArtStudent : Student("小明", 18) {  //任选一个父类构造函数即可
    fun draw() = println("我会画画")
}

当子类只存在辅助构造函数时,需要使用super关键字来匹配父类的构造函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
open class Student {
    constructor(str: String)
    constructor(str: String, age: Int)
    fun hello() = println("我会打招呼")
}

//子类不写主构造函数时,可以直接在冒号后面添加父类类名
class ArtStudent : Student {
    constructor(str: String) : super(str)   //使用super来调用父类构造函数,super表示父类(超类)
    constructor(str: String, age: Int) : super(str, age)
    fun draw() = println("我会画画")
}

也可以去匹配子类中其他构造函数:

1
2
3
4
5
class ArtStudent : Student {
    constructor(str: String) : this(str, 18)   //也可以调用子类其他构造函数,但是其他构造函数依然要间接或直接调用父类构造函数
    constructor(str: String, age: Int) : super(str, age)
    fun draw() = println("我会画画")
}

如果子类既有主构造函数,也有辅助构造函数,那么其他辅助构造函数只能直接或间接调用主构造函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
open class Student() {
    constructor(str: String) : this()
    constructor(str: String, age: Int) : this()
    fun hello() = println("我会打招呼")
}

class ArtStudent() : Student() {
    constructor(str: String) : this()  //正确,必须直接或间接调用主构造函数
    constructor(str: String, age: Int) : super(str, age)   //报错,不能绕过主构造函数去匹配父类构造函数
    fun draw() = println("我会画画")
}

是不是感觉玩法太多,都眼花缭乱了?实际上只要各位小伙伴心里面清楚下面的规则,就很好理解上面这一堆写法了:

  • 构造函数相当于是这个类初始化的最基本函数,在构造对象时一定要调用
  • 主构造函数因为可能存在一些类的属性,所以说必须在初始化时调用,不能让这些属性初始化时没有初始值
  • 子类因为是父类的延展,因此,子类在初始化时,必须先初始化父类,就好比每个学生都有学生证,这是属于父类的属性,如果子类在初始化时可以不去初始化父类,那岂不是美术生可以没有学生证?显然是不对的。

优先级关系:父类初始化 > 子类主构造 > 子类辅助构造

属性的覆盖

有些时候,我们可以希望子类继承父类的某些属性,但是我们可能希望去修改这些属性的默认实现。比如,美术生虽然也是学生,也会打招呼,但是可能他们打招呼的方式跟普通的学生不太一样,我们能否对打招呼这个函数的默认实现进行修改呢?

我们可以使用override关键字来表示对于一个属性的重写(覆盖)就像这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
open class Student {
  	//注意,跟类一样,函数必须添加open关键字才能被子类覆盖
    open fun hello() = println("我会打招呼")
}

class ArtStudent : Student() {
    fun draw() = println("我会画画")
  	//在子类中编写一个同名函数,并添加override关键字,我们就可以在子类中进行覆盖了,然后编写自己的实现
    override fun hello() = println("哦哈哟")
}

覆盖之后,当我们使用子类进行打招呼时,函数会按照我们覆盖的内容执行,而不是原本的:

同样的,类的某个变量也是可以进行覆盖的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
open class Student {
    open val test: String = "测试"
    fun hello() = println("我会打招呼: $test")  //这里拿到的test就会变成被覆盖掉的
}

class ArtStudent : Student() {
  	//对父类的变量进行覆盖,类型必须一样
    override val test: String = "干嘛"
    fun draw() = println("我会画画")
}

是不是感觉很神奇?不过对于可变的变量,似乎下面这样来的更方便?

1
2
3
4
5
6
7
8
9
open class Student {
    var test: String = "测试"
    fun hello() = println("我会打招呼: $test")
}

class ArtStudent : Student() {
    init { test = "干嘛" }
    fun draw() = println("我会画画")
}

有些时候为了方便,比如在父类中的属性,我们可以直接在子类的主构造函数中直接覆盖:

1
2
3
4
5
6
7
8
9
open class Student {
    open val name: String  = "大明"
    fun hello() = println("我会打招呼,我叫: $name")
}

//在主构造函数中覆盖,也是可以的,这样会将构造时传入的值进行覆盖
class ArtStudent(override val name: String) : Student() {
    fun draw() = println("我会画画")
}
1
2
3
4
fun main() {
    val student = ArtStudent("小红")
    student.hello()
}

虽然现在已经很方便了,但是现在又来了一个新的需求,打招呼不仅要有子类的特色,同时也要保留父类原有的实现,这个时候该怎么办呢?我们可以使用super关键字来完成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
open class Student {
    open fun hello() = println("我会打招呼")
}

class ArtStudent : Student() {
    fun draw() = println("我会画画")
    override fun hello() {   //覆盖父类函数
        super.hello()   //使用super.xxx来调用父类的函数实现,这里super同样表示父类
        println("哦哈哟")  //再写自己的逻辑
    }
}

这样,我们在覆盖原本的函数时,也可以执行原本的实现,在一些对函数内容进行增强的常见,这种用法非常常见:

不过,由于存在我们之前讲解的的初始化顺序,下面的这种情况需要特别注意:

1
2
3
4
5
6
7
8
open class Student {
    open val name: String = "小明"
    init { println("我的名字是: ${name.length}") }  //这里拿到的name实际上是还未初始化的子类name
}

class ArtStudent : Student() {
    override val name = "大明"
}
1
2
3
fun main() {
    val student = ArtStudent()
}

由于父类初始化在子类之前,此时子类还未开始初始化,其覆盖的属性此时没有初始值,根据不同平台的实现,可能会出现一些问题,比如JVM平台下,没有初始化的对象引用默认为null,那么这里就会直接报空指针异常:

很神奇对吧,这里的name属性明明是一个非可空的String类型,居然还会出现null的情况报空指针,因此,对于这些使用了open关键字的属性(函数、变量等)只要是在初始化函数、构造函数中使用,IDEA都会给出警告:

我们接着来讲一个很绕的东西,在使用一些子类的时候,我们实际上可以将其当做其父类来进行使用:

1
2
3
fun main() {
    val student: Student = ArtStudent()   //使用Student类型的变量接收一个ArtStudent类型的对象引用
}

之所以支持这样去使用,是因为子类本身就是对父类的延伸,因此将其当做父类使用,也是没有问题的。就好比我们无论是美术生还是体育生,都可以当做学生来用,都可以送去厂里实习打螺丝,不然不给毕业证。

只不过,如果我们将一个对象作为其父类使用,那么在使用时也只能使用其父类的一些属性,就相当于我们在使用一个父类的对象:

即使我们很清楚这里引用的对象是一个美术生,但是只能当做普通学生来用,这在后面的集合类中会经常用到,因为集合类往往存在多种不同的实现,但是我们只需要关心怎么用就行了,并且为了方便更换实现,所以一般使用集合类对应的接口来作为变量的类型。

那么,如果子类重写了父类的某个函数,此时我们以父类的形式去使用,结果会怎么样?

1
2
3
4
5
6
7
open class Student {
    open fun hello() = println("大家好")
}

class ArtStudent : Student() {
    override fun hello() = println("我姓🐴我叫🐴牛逼")
}

可以看到,虽然当做父类来使用,但是其本质是不会变的,所以说,这里执行的结果依然是子类的覆盖实现。

那么,如果项目中很多这种明明是子类但是拿来当做父类用,我们怎么去判断使用的对象到底是什么类型的呢?我们可以使用is关键字来进行类型判断,以下面的三个类为例:

1
2
3
open class Student
class ArtStudent : Student()
class SportStudent : Student()

现在我们进行类型判断:

1
2
3
4
5
6
fun main() {
    val student: Student = ArtStudent()
    println(student is ArtStudent)   //true,因为确实是这个类型
    println(student is SportStudent)   //false,因为不是这个类型
    println(student is Student)   //true,因为是这个类型的子类
}

可以看到,使用is关键字可以精准地对类型进行判断,只要判断的对象是这个类或是这个类的子类,那么就会返回true作为结果。

如果我们明确某个变量引用的对象是什么类型,可以使用as关键字来进行强制类型转换:

1
2
3
4
5
6
7
fun main() {
    val student: Student = ArtStudent()
    if(student is ArtStudent) {
        val artStudent = student as ArtStudent;
        artStudent.draw()  //强制类型转换之后,可以直接变回原本的类型去使用
    }
}

不过,编译器非常智能,它可以根据当前的语境判断的类型自动进行类型转换:

1
2
3
4
val student: Student = ArtStudent()
if(student is ArtStudent) {
    student.draw()
}

此时IDEA中会出现提示:

不仅仅是if判断的场景、包括when、while,以及&& || 等运算符都支持智能转换,只要上下文语境符合就能做到:

1
2
3
4
5
6
7
fun main() {
    val student: Student? = ArtStudent()
  	//很明显这里是当student为ArtStudent时,根据语境直接智能转换
    while (student is ArtStudent)  student.draw()
    //很明显如果这前面已经判断为真了,那肯定是这个类型,后面也可以智能转换
  	if(student is ArtStudent && student.draw())
}

不仅仅是这种场景,比如我们前面讲解的可空类型,同样支持这样的智能转换:

1
2
3
4
5
6
fun main() {
    val student: Student? = ArtStudent()
  	student?.hello()
    if (student != null)   //判断到如果不为null
  			student.hello()  	 //根据语境student智能转换为了非空Student类型
}

在处理一些可空类型时,为了防止出现异常,我们可以使用更加安全的as?运算符:

1
2
3
4
fun main() {
    val student: Student? = ArtStudent()
    val artStudent: ArtStudent? = student as? ArtStudent  //当student为null时,不会得到异常,而是返回null作为结果
}

有了这些操作,类和对象在我们使用的过程中就逐渐开始千变万化了,后面我们还会继续认识更多的多态特性。

顶层Any类

在我们不继承任何类的情况下,实际上Kotlin会有一个默认的父类,所有的类默认情况下都是继承自Any类的。

这个类的定义如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
 * Kotlin类继承结构中的根类. 所有Kotlin中的类都会直接或间接将Any作为父类
 */
public open class Any {
    /**
     * 判断某个对象是否"等于"当前对象,这里同样是对运算符"=="的重载,而具体判断两个对象相等的操作需要由子类来定义
     * 在一些特定情况下,子类在重写此函数时应该保证以下要求:
     * * 可反身: 对于任意非空值 `x`, 表达式 `x.equals(x)` 应该返回true
     * * 可交换: 对于任意非空值 `x` 和 `y`, `x.equals(y)` 当且仅当 `y.equals(x)` 返回true时返回true
     * * 可传递: 对于任意非空值 `x`, `y`, 和 `z`, 如果 `x.equals(y)` 和 `y.equals(z)` 都返回true, 那么 `x.equals(z)` 也应该返回真
     * * 一致性: 对于任意非空值 `x` 和 `y`, 在多次调用 `x.equals(y)` 函数时,只要不修改在对象的“equals”比较中使用的信息,那么应当始终返回同样的结果
     * * 永不等于空: 对于任意非空值 `x`, `x.equals(null)` 应该始终返回false
     */
    public open operator fun equals(other: Any?): Boolean

    /**
     * 返回当前对象的哈希值,它具有以下约束:
     *
     * * 对同一对象多次调用该函数时,只要不修改对象上的equals比较中使用的信息,那么此函数就必须始终返回相同的整数
     * * 如果两个对象通过`equals`函数判断为true,那么这两个对象的哈希值也应该相同
     */
    public open fun hashCode(): Int

    /**
     * 将此对象转换为一个字符串,具体转换为什么样子的字符串由子类自己决定
     */
    public open fun toString(): String
}

由于默认情况下类都是继承自Any,因此Any中定义的函数也是被继承到子类中了。

首先我们来看这个equals函数,它实际上是对==这个运算符的重载,我们之前在使用一些基本类型的时候,就经常使用==来判断这些类型是否相同,比如Int类型的数据:

1
2
3
4
5
6
fun main() {
    val a = 10
    val b = 20
    println(a == b)
    println(a.equals(b))  //跟上面的写法完全一样
}

经过前面的学习,我们知道这些基本类型本质上也是定义的类,实际上它们也是通过重写这个函数来实现这些比较操作的(一些基本类型会根据不同的平台进行编译优化,没法看源码)

我们可以看到,这个函数接受的参数类型是一个Any?类型:

1
public open operator fun equals(other: Any?): Boolean  //我们上节课说到一个子类也可以被当做父类类型的变量去使用,所以说equals判断接受的参数为了满足不同的类型变量之间进行比较,直接使用顶层Any作为参数(考虑到会用到可空类型,所以说直接用了Any?作为参数类型)

到目前为止,我们认识了Kotlin中两种相等的判断方式:

  • 结果上 相等 (== 等价于 equals())
  • 引用上 相等 (=== 判断两个变量是否都是引用的同一个对象)

我们在使用equals比较两个可空对象是否相等时,就像这样:

1
a == b

实际上会被翻译为:

1
a?.equals(b) ?: (b === null)  //a如果为null那就直接判断b是不是也为null,否则直接调用a的equals函数并让b作为参数

当然可能会有小伙伴疑问,那不等于判断呢?实际上是一样的:

1
2
3
4
5
6
fun main() {
    val a = "10"
    val b = "20"
    println(a != b)
    println(!a.equals(b))  //等价于上面的写法
}

我们也可以为我们自己编写的类型重写equals函数,比如我们希望Student类型当名字和年龄相等时,就可以使用==来判断为true,我们可以像这样编写:

1
2
3
4
5
6
7
8
9
class Student(val name: String, val age: Int) {
    override fun equals(other: Any?): Boolean {
        if(this === other) return true  //如果引用的是同一个对象,肯定是true不多逼逼
        if(other !is Student) return false //如果要判断的对象根本不是Student类型的,那也不用继续了
        if(name != other.name) return false  //判断名字是否相同
        if(age != other.age) return false  //判断年龄是否相同
        return true   //都没问题,那就是相等了
    }
}

此时我们已经将其比较操作重写,我们可以来试试看:

1
2
3
4
5
6
7
8
fun main() {
    val a = Student("小明", 18)
    val b = Student("小红", 17)
    val c = Student("小明", 18)
    println(a == a)    //返回true因为就是自己
    println(a == b)    //返回false因为名字和年龄不一样
    println(a == c)    //返回true因为名字和年龄完全一样
}

默认情况下,如果我们不重写类的equals函数,那么会直接对等号两边的变量进行引用判断===判断是否为同一个对象。只不过,可以很清楚地看到IDEA提示我们:

实际上在我们重写类的equals函数时,根据约定,必须重写对于的hashCode函数,至于为什么,我们会在后续的集合类部分中进行介绍,这里我们暂时先不对hashCode函数进行讲解。

接着我们来看下一个,toString函数用于快速将对象转换为字符串,只不过默认情况下,会像这样:

1
2
3
4
5
fun main() {
    val a = Student("小明", 18)
    println(a.toString())
    println(a)  //println默认情况下会直接调用对象的toString并打印,所以跟上面是一样的
}

可以看到打印的结果是对象的类型@十六进制哈希值的形式,在某些情况下,可能我们更希望的是转换对象的一些成员属性,这样我们可以更直观的看到对象的属性具有什么值:

1
2
3
4
5
class Student(val name: String, val age: Int) {
    override fun toString(): String {  //直接重写toString函数
        return "Student(name='$name', age=$age)"
    }
}

现在得到的结果,就是我们自定义的结果了:

抽象类

有些情况下,我们设计的类可能仅仅是作为给其他类继承使用的类,而其本身并不需要创建任何实例对象,比如:

1
2
3
4
5
6
7
8
9
open class Student protected constructor() {  //无法构造这个父类,要求使用子类
    open fun hello() = println("Hello World!")
}
class ArtStudent: Student() {
    override fun hello() = println("原神")  //两个子类都对hello进行了实现,采用各自的方式
}
class SportStudent: Student() {
    override fun hello() = println("启动")
}

可以看到,在上面这个例子中,Student类的hello函数在子类中都会被重写,所以说除非在子类中调用父类的默认实现,否则一般情况下,父类中定义的函数永远都不会被调用。

就像我们说一个学生会怎么考试一样,实际上学生怎么考试是一个抽象的概念,但是由于学生的种类繁多,美术生怎么考试和体育生怎么考试,才是具体的一个实现。所以说,我们可以将学生类进行进一步的抽象,让某些函数或字段完全由子类来实现,父类中不需要提供实现。我们可以使用abstract关键字来将一个类声明为抽象类:

1
2
3
4
5
6
//使用abstract表示这个是一个抽象类
abstract class Student {
    abstract val type: String  //抽象类中可以存在抽象成员属性
    abstract fun hello()   //抽象类中可以存在抽象函数
  	//注意抽象的属性不能为private,不然子类就没法重写了
}

当一个子类继承自抽象类时,必须要重写抽象类中定义的抽象属性和抽象函数:

1
2
3
4
class ArtStudent: Student() {
    override val type: String = "美术生"
    override fun hello() = println("原神,启动!")
}

这是强制要求的,如果不进行重写将无法通过编译。同时,抽象类是不允许直接构造对象的,只能使用其子类:

当然,抽象类不仅可以具有抽象的属性,同时也具有普通类的性质,同样可以定义非抽象的属性或函数:

1
2
3
4
5
abstract class Student {
    abstract val type: String
    abstract fun hello()
    fun test() = println("不会有人玩到大三了才开始学Java吧")  //定义非抽像属性或函数,在子类中不强制要求重写
}

同时,抽象类也可以继承自其他的类(可以是抽象类也可以是普通类)

1
2
3
4
open class Test   //直接继承一个普通的类
abstract class Student: Test(){
    ...
}

虽然抽象类可以继承一个普通的类,但是这依然不改变它是抽象类的本质,子类依然要按照上面的要求进行编写。

接口

由于Kotlin中不存在多继承的操作,我们可以使用接口来替代。

前面我们认识了抽象类,它可以具有一些定义而不实现的内容,而接口比抽象类还要抽象,一般情况下,他只代表某个确切的功能!也就是只能包含函数或属性的定义,所有的内容只能是abstract的,它不像类那样完整。接口一般只代表某些功能的抽象,接口包含了一系列内容的定义,类可以实现这个接口,表示类支持接口代表的功能。

比如,学生具有以下功能:

  • 打游戏
  • 睡懒觉
  • 逃课
  • 考试作弊

我们可以将这一系列功能拆分成一个个的接口,然后让学生实现这些接口,来表示学生支持这些功能。

在Kotlin中,要声明接口,我们可以使用interface关键字:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
interface A {
    val x: String  //接口中所有属性默认都是abstract的(可省略关键字)
    fun sleep()   //接口中所有函数默认都是abstract的(可省略关键字)
}
interface B {
    fun game()
}
class Student: A, B {   //接口的实现与类的继承一样,直接写到后面,多个接口用逗号隔开
    override val x: String = "测试"   //跟抽象类一样,接口中的内容是必须要实现的
    override fun sleep() = println("管他什么早八不早八的,睡舒服再说")
    override fun game() = println("读大学就该玩游戏玩到爽")
}

可以看到,接口相比于抽象类来说,更加的纯粹,它不像类那样可以具有什么确切的属性,一切内容都是抽象的,只能由子类来实现。

只不过,在接口中声明的属性可以是抽象的,也可以为Getter提供默认实现。在接口中声明的属性无法使用field后背字段,因此在接口中声明的Setter无法使用field进行赋值:

1
2
3
4
interface A {
    val x: String
        get() = "666"  //只能重写getter,不能直接赋值,因为默认情况下getter是返回的field的值,但是接口里不让用
}
1
2
3
4
5
interface A {
    var x: String
        get() = "666"
        set(value) {  /* 默认的setter会直接报错,因为使用了field字段 */ }
}

为了应对变化多端的需求,接口也可以为函数编写默认实现:

1
2
3
4
interface A {
    //接口中的函数可以具有默认实现,默认情况下是open的,除非private掉
    fun sleep() = println("管他什么早八不早八的,睡舒服再说")
}

这样一看,这函数可以写默认的实现那接口似乎变得不那么抽象了?这用着感觉好像跟抽象类没啥区别啊?接口跟类的最大区别其实就是状态的保存,这从上面的成员属性我们就可以看的很清楚。

接口也可以继承自其他接口,直接获得其他接口中的定义:

1
2
3
4
5
6
7
8
interface A{
    fun sleep() = println("管他什么早八不早八的,睡舒服再说")
}
interface B{
    fun game() = println("读大学就该玩游戏玩到爽")
}
interface C: A, B   //接口的继承写法是一样的,并且接口继承接口是支持多继承的
class Student: C    //直接获得ABC三个接口的功能

是不是感觉接口的玩法非常有意思?只不过玩的过程中,可能也会遇到一些麻烦,比如下面的这种情况:

1
2
3
4
5
6
7
interface A{
    fun sleep() = println("管他什么早八不早八的,睡舒服再说")
}
interface B{
    fun sleep() = println("7点起床学Java了,不能再睡了")
}
class Student: A, B  //由于A和B都具有sleep函数,那现在到底继承谁的呢?

这种情况下,我们需要手动解决冲突,比如我们希望Student类采用接口B的默认实现:

1
2
3
4
5
class Student: A, B {
    override fun sleep() {  //手动重写sleep函数,自行决定如何处理冲突
        super<B>.sleep()  //使用super关键字然后添加尖括号指定对应接口,并手动调用接口对应函数
    }
}

对于接口,我们可以像之前一样,将变量的类型设定为一个接口的类型,当做某一个接口的实现来使用,同时也支持isas等关键字进行类型判断和转换:

1
2
3
4
5
fun main() {
    val a: A = Student()
    a.sleep()  //直接当做A接口用(只能使用A接口中定义的内容)
    println(a is B)  //判断a引用的对象是否为B接口的实现类
}

是不是感觉跟之前使用起来是差不多的?其实只要前面玩熟悉了,后面还是很简单的。

类的扩展

Kotlin提供了扩展类或接口的操作,而无需通过类继承或使用装饰器等设计模式,来为某个类添加一些额外的函数或是属性,我们只需要通过一个被称为扩展的特殊声明来完成。

例如,您可以从无法修改的第三方库中为类或接口编写新函数,这些函数可以像类中其他函数那样进行调用,就像它们是类中的函数一样,这种机制被称为扩展函数。还有扩展属性,允许您为现有类定义新属性。

比如我们想为String类型添加一个自定义的操作:

1
2
3
4
5
6
7
//为官方的String类添加一个新的test函数,使其返回自定义内容
fun String.test() = "666"

fun main() {
    val text = "Hello World"
    println(text.test())  //就好像String类中真的有这个函数一样
}

是不是感觉很神奇?通过这种机制,我们可以将那些第三方类不具备的功能强行进行扩展,来方便我们的操作。

注意,类的扩展是静态的,实际上并不会修改它们原本的类,也不会将新成员插入到类中,仅仅是将我们定义的功能变得可调用,使用起来就像真的有一样。同时,在编译时也会明确具体调用的扩展函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
open class Shape
class Rectangle: Shape()

fun Shape.getName() = "Shape"
fun Rectangle.getName() = "Rectangle"  //虽然这里同时扩展了父类和子类的getName函数

fun printClassName(s: Shape) {  //但由于这里指定的类型是Shape,因此编译时也只会使用Shape扩展的getName函数
    println(s.getName())
}

fun main() {
    printClassName(Rectangle())
}

由于类的扩展是静态的,因此在编译出现歧义时,只会取决于形参类型。

如果是类本身就具有同名同参数的函数,那么扩展的函数将失效:

1
2
3
4
5
6
7
8
9
class Test {
    fun hello() = println("你干嘛")
}

fun Test.hello() = println("哎哟")

fun main() {
    Test().hello()   //你干嘛
}

不过,我们如果通过这种方式实现函数的重载,是完全没有问题的:

1
2
3
4
5
6
7
8
9
class Test {
    fun hello() = println("你干嘛")
}

fun Test.hello(str: String) = println(str)  //重载一个不同参数的同名函数

fun main() {
    Test().hello("不错")  //有效果
}

同样的,类的属性也是可以通过这种形式来扩展的,但是有一些小小的要求:

可以看到直接扩展属性是不允许的,前面我们说过,扩展并不是真的往类中添加属性,因此,扩展属性本质上也不会真的插入一个成员字段到类的定义中,这就导致并没有变量去存储我们的数据,我们只能明确定义一个getter和setter来创建扩展属性,才能让它使用起来真的像是类的属性一样:

1
2
3
4
5
6
7
val Student.gender: String
    get() = "666"

fun main() {
    val stu = Student()
    println(stu.gender)
}

由于扩展属性并没有真正的变量去存储,而是使用get和set函数来实现,所以,像前面认识的field这种后备字段,就无法使用了。

还有一个需要注意的时,我们在不同包中定义的扩展属性,同样会受到访问权限控制,需要进行导入才可以使用:

1
2
3
4
5
6
import com.test.gender

fun main() {
    val stu = Student()
    println(stu.gender)
}

除了直接在顶层定义类的扩展之外,我们也可以在一个类中定义其他类的扩展,并且在定义时可以直接使用其他类提供的属性:

1
2
3
4
5
6
7
8
9
class A {
    fun hello() = "Hello World"
}

class B {
    fun A.test() {
        hello()   //直接在类A的扩展函数中调用A中定义的函数
    }
}

像这种扩展,由于是在类中定义,因此也仅限于类内部使用,比如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class A {
    fun hello() = "Hello World"
}

class B (private val a: A){
    private fun A.test() = hello() + "!!!"
    fun world() = println(a.test())   //只能在类中通过A的实例使用扩展函数
}

fun main() = B(A()).world()

扩展属性无法访问那些本就不应该被当前作用域访问的类属性,即使它是对某个类的扩展,比如下面这种情况:

在名称发生冲突时,需要特别处理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class A {
    fun hello() = "Hello World"
}

class B (private val a: A){
    private fun A.test() {
        hello()   //直接使用优先匹配被扩展类中的方法
        this.hello()   //扩展函数中的this依然指的是被扩展的类对象
        this@B.hello()   //这里调用的才是下面的
    }

    fun hello() = "Bye World"
}

定义在类中的扩展也可以跟随类的继承结构,进行重写:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
open class A {
    open fun Student.test() = "AAA"
    fun hello() = println(Student().test())
}

class B : A() {
    override fun Student.test() = "BBB"  //对父类定义的扩展函数进行重写
}

fun main() {
    A().hello()
    B().hello()
}

局部扩展也是可以的,我们可以在某个函数里面编写扩展,但作用域仅限于当前函数:

1
2
3
4
fun main() {
    fun String.test() = ""
    "".test()
}

如果我们将一个扩展函数作为参数给到一个函数类型变量,那么同样需要再具体操作之前增加类型名称才可以:

1
2
3
4
5
6
7
8
fun main() {
  	//因为是对String类型的扩展函数,需要String.前缀
    val func: String.() -> Int = {
        this.length   //跟上面一样,扩展函数中的this依然指的是被扩展的类对象
    }
    println("sahda".func())  //可以直接对符合类型的对象使用这个函数
  	func("Hello")  //如果是直接调用,那么必须要传入对应类型的对象作为首个参数,此时this就指向我们传入的参数
}

可以看到,此函数的类型是String.() -> Int ,也就是说它是专门针对于String类型编写的扩展函数,没有参数,返回值类型为Int,并使用Lambda表达式进行赋值,同时这个函数也是属于String类型的,只能由对象调用,或是主动传入一个相同类型的对象作为参数才能直接调用。可能这里会有些绕不太好理解,需要同学们多去思考。

总结一下,扩展属性更像是针对于原本类编写的外部工具函数,而绝不是对原有类的修改。

Kotlin程序设计高级篇

在学习了前面的内容之后,相信各位小伙伴应该对Kotlin这门语言有了一些全新的认识,我们已经了解了大部分的基本内容,从本章开始,就是对我们之前所学的基本内容的进一步提升。

泛型

在前面我们学习了最重要的类和对象,了解了面向对象编程的思想,注意,非常重要,面向对象是必须要深入理解和掌握的内容,不能草草结束。在本章节,我们还会继续深入,从泛型开始,再到我们的集合类学习,循序渐进。

什么是泛型

为了统计学生成绩,要求设计一个Score对象,包括课程名称、课程号、课程成绩,但是成绩分为两种,一种是以优秀、良好、合格 来作为结果,还有一种就是 60.0、75.5、92.5 这样的数字分数,可能高等数学这门课是以数字成绩进行结算,而计算机网络实验这门课是以等级进行结算,这两种分数类型都有可能出现,那么现在该如何去设计这样的一个Score类呢?

现在的问题就是,成绩可能是String类型,也可能是Int类型,如何才能更好的去存可能出现的两种类型呢?

1
2
3
4
5
6
7
8
class Score(var name: String, var id: String, var value: Any) {
    //因为Any是所有类型的父类,因此既可以存放Int也能存放String
}

fun main() {
    Score("数据结构与算法基础", "EP074512", "优秀")  //文字和数字都可以存
    Score("计算机操作系统", "EP074533", 95)
}

虽然这样看起来很不错,但是Any毕竟是所有类型的顶级父类,在编译阶段并不具有良好的类型判断能力,很容易出现以下的情况:

1
2
3
4
5
fun main() {
    val score = Score("数据结构与算法基础", "EP074512", "优秀")
  	...
    val a: Int = score.value as Int   //获取成绩需要进行强制类型转换
}

使用Any类型作为引用虽然可以做到任意类型存储,但是对于使用者来说,由于是Any类型,所以说并不能直接判断存储的类型到底是String还是Int,取值只能进行强制类型转换,显然无法在编译期确定类型是否安全,项目中代码量非常之大,进行类型比较又会导致额外的开销和增加代码量,如果不经比较就很容易出现类型转换异常,代码的健壮性有所欠缺。

所以说这种解决办法虽然可行,但并不是最好的方案,我们需要使用一个更好的东西来实现: 泛型

泛型其实就一个待定类型,我们可以使用一个特殊的名字表示泛型,泛型在定义时并不明确是什么类型,而是需要到使用时才会确定对应的泛型类型,Kotlin中的类可以具有类型参数:

1
2
class Score<T>(var name: String)
//这里的T就是一个待定的类型,同样是这个类具有的,我们称为泛型参数

可以看到,它相比普通的类型,仅仅多了一个<T>表示类型参数,那么如何使用呢?

1
2
3
4
fun main() {
  	//在创建对象时,再来明确使用的是什么类型,同样使用尖括号填写
    val score = Score<Int>("数据结构与算法")
}

既然可以做到使用时明确,那现在我们应该怎么去设计这个类呢?

1
2
3
4
5
6
7
8
class Score<T>(var name: String, var id: String, var value: T)
//我们在定义类型参数后,T就是一个待定类型,我们可以直接将value属性的类型定义为T

fun main() {
    val score = Score<String>("数据结构与算法基础", "EP074512", "优秀")
  	//在使用时,使用<String>来明确Score的值类型,此时value的类型也会变成String
    val value: String = score.value  //得到的直接就是String类型的结果
}

泛型将数据类型的确定控制在了编译阶段,在编写代码的时候就能明确泛型的类型,如果类型不符合,将无法通过编译,同时,如果我们这里填入的参数明确是一个String类型的值,创建时不需要指定T的类型也会自动匹配:

1
val score = Score("数据结构与算法基础", "EP074512", "优秀")  //自动匹配为String类型

而泛型类型在类内部使用时,由于无法确定具体类型,也只能当做Any类去使用:

因为泛型本身就是对某些待定类型的简单处理,如果都明确要使用什么类型了,那大可不必使用泛型。还有,不能通过这个不确定的类型变量就去直接创建对象:

还有,由于泛型在创建时就已经确定,因此即使都是Score类,由于类型参数的不同也会导致不通用:

有了泛型之后,我们再来使用一些类型就非常方便了,并且泛型并不是每个类只能存在一个,我们可以一次性定义多个类型参数:

1
class Test<K, V>(val key: K, val value: V)

多个不同的类型参数代表不同的类型,这些都可以在使用时明确,并且互不影响。

Kotlin还提供了下划线运算符可以自动推断类型:

1
2
3
4
5
fun <K: Comparable<V>, V> test() {  }   //类型参数中第一个类型参数可以直接推断得到

fun main() {
    test<Int, _>()  //由于前面的类型本身就是Comparable<Int>的子类,已经明确了V的类型,后面就没必要再写一次了,直接使用下划线运算符进行推断即可
}

感觉使用场景应该比较少,了解就行。

当然,不只是类,包括接口、抽象类,都是可以支持泛型的:

1
2
3
interface Test<T> {

}

子类在继承时,可以选择将父类的泛型参数给明确为某一类型,或是使用子类定义的泛型参数作为父类泛型参数的实参使用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
abstract class A<T> {
    abstract fun test(): T
}

class B: A<String>() {  //子类直接明确为String类型
    override fun test(): String = "Hello World" //明确后所有用到泛型的地方都要变成具体类型
}

abstract class C<D>: A<D>() {  //子类也有泛型参数D
    abstract override fun test(): D
}

fun main() {
    val b = B()
    println(b.test())
}

除了在类上定义泛型之外,我们也可以在函数上定义:

1
2
3
4
5
6
//在函数名称前添加<T>来增加类型参数,之后函数的返回值或是参数都可以使用这个类型
fun <T> test(t: T): T = t

fun main() {
    val value: String = test("Hello World")  //调用函数时自动明确类型
}

甚至在使用函数类型的参数时,我们可以使用泛型来代表不确定的类型:

1
2
3
4
5
6
7
fun <T> test(func: (Int) -> T) : T {  //只要是有类型的地方都可以用T代替
 		...
}

fun <T> test2(func: T.() -> Unit) {  //甚至还可以是T类型的扩展函数
		...
}

在这之后,我们还会遇到更多官方提供的泛型函数,尤其是下一章的数组和集合部分。

官方高阶扩展函数

为了我们开发的便利,官方提供了一系列内置的高阶函数,大部分都是通过扩展函数形式定义,我们可以使用来简化我们的代码。

我们之前在使用时或许就已经发现了:

那么怎么依靠它们来简化我们的代码呢?比如下面的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Student(var name: String, var age: Int) {
    fun hello() = println("大家好,我是$name")
}

fun test(student: Student?): Student? {
    student?.name = "小明"  //不优雅!!!!
    student?.age = 18
    student?.hello()
  	returun student;
}

由于传入的是一个可空类型,这导致我们在使用时非常不方便,每次都需要进行判断,有没有更优雅一点的方式来处理呢?

1
2
3
4
5
fun test(student: Student?): Student? = student?.apply {
    this.name = "小明"
    this.age = 18
    this.hello()
}

太优雅了,同样的操作,原本繁杂的调用直接简化成了简单的几句代码,真是舒服啊!

我们来介绍一下这些函数时如何使用的,这里以apply为例,这个函数功能是简化我们对某个对象的操作并在最后返回对象本身,在Standard.kt中是这样定义的:

1
2
3
4
5
public inline fun <T> T.apply(block: T.() -> Unit): T {
    ...
    block()   //调用我们传入的函数
    return this   //返回当前T类型对象本身
}

可以看到,这个函数也是以扩展函数定义的T可以代表任何类型,所有的类都可以使用这个预设的扩展函数,并且它的参数是一个T.() -> Unit函数类型的,很明显这是一个高阶函数,并且最后一个参数就是函数类型,后续可以结合我们之前讲解的简化代码。

这个参数非常有意思,比如我们原来需要这样编写:

1
2
3
4
5
fun main() {
    val student: Student = Student("小明", 18)
    student.name = "大明"
    student.hello()
}

我们现在可以进行代码优化:

1
2
3
4
5
fun main() {
    Student("小明", 18).apply {
        this.name = "大明"
    }.hello()
}

什么鬼,怎么突然就变得这么简单了?我们一个一个来看:

1
Student("小明", 18).apply{  }  //调用Apply后,我们需要传入一个Lambda表达式,也就是我们要如何操作这个对象

我们可以直接将对这个对象全部的操作搬进来,然后在一个Lambda里面就能完成,接着我们对这个对象的其他操作,可以直接在后续编写,因为返回的也是这个对象本身,所以,使用这些预设的高阶函数,在很多情况下都能省掉我们不少代码量。

这里我们来看几个比较常用的:

  1. let:用于执行一个lambda表达式并将得到的结果作为返回值返回。

    1
    2
    3
    4
    5
    
    //对当前对象进行操作,得到一个新的类型值并作为结果返回
    public inline fun <T, R> T.let(block: (T) -> R): R {
       	...
        return block(this)  //调用我们传入的函数,并将结果作为let返回值
    }
    
  2. also:用于执行一个lambda表达式并返回对象本身,跟apply功能一致像,但是采用的是it参数形式传递给Lambda当前对象。

    1
    2
    3
    4
    5
    6
    
    //对当前对象进行操作,并返回当前对象本身
    public inline fun <T> T.also(block: (T) -> Unit): T {
        ...
        block(this)   //调用我们传入的函数
        return this   //返回当前T类型对象本身
    }
    
  3. run:用于执行一个lambda表达式并将得到的结果作为返回值返回,它跟let一样,使用this传递当前对象,可以看到接受的参数是一个扩展函数。

    1
    2
    3
    4
    
    public inline fun <T, R> T.run(block: T.() -> R): R {
        ...
        return block()
    }
    

由此可见,let和run功能相近,apply和also功能相近,只是它们传递对象方式不同,所以说这个就别搞混了。

还有一个比较好用的是,有时候我们可能需要对象满足某些条件才处理,我们可以使用takeIf来完成:

1
2
3
4
5
6
7
8
9
public inline fun <T> T.takeIf(predicate: (T) -> Boolean): T? {
    ...
    return if (predicate(this)) this else null  //传入一个用于判断的函数,根据结果返回对象本身或是null
}

public inline fun <T> T.takeUnless(predicate: (T) -> Boolean): T? {
    ...
    return if (!predicate(this)) this else null  //跟上面相反
}

对于takeIf的使用就像下面这样:

1
2
3
4
5
fun main() {
    val str = "Hello World"
  	//判断字符串长度是否大于7,大于就返回一个重复一次的字符串,否则原样返回
    val myStr = str.takeIf { it.length > 7 }?.let { it + it } ?: str
}

一个很复杂的工作,可能需要很多行代码才能搞定,但是现在借助这些预设的高阶扩展函数,我们就可以以更简短的代码完成。

还有一个比较有意思的:

1
2
3
4
public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
    ...
    return receiver.block()  //手动传入一个现有的变量,然后通过这个变量去调用传入的Lamdba
}

用起来就像这样:

1
2
3
4
fun main() {
    val str = "Hello World"
    val len = with(str) { this.length }
}

除了我们上面提到的这些,其实在Standard.kt还提供了更多有意思的工具函数,由于篇幅有限,还请各位小伙伴自行探索。

协变与逆变*

注意: 这一部分相当有难度,请务必将前面的泛型概念理解到位,否则很难继续学习。

我们在前面介绍了泛型的基本使用,实际上就是一个待定的类型,我们在使用时可以指定具体的类型,并在编译时检查类型是否匹配,保证运行时类型的安全性,就像下面这样:

1
2
3
4
5
6
class Test<T>(var data: T)

fun main() {
    val test1: Test<String> = Test("Hello")
    val test2: Test<Int> = Test(10)
}

一旦泛型变量类型确定,后续将一直固定使用此类型,并且不兼容其他类型:

但是现在存在这样一个问题,我们如果使用某个类型的父类呢,会不会出现类型不匹配的情况?

可以看到,即使是Int类型的父类Number,也无法接收其子类类型的结果,这就很奇怪了,我们前面说过一个类可以被当做其父类使用(因为父类具有属性什么子类一定也有)会自动完成隐式类型转换,但是为什么到了泛型这里就不行了呢?

为了探究这个问题,我们先从几个概念开始说起,假设Int类型是Number类型的子类,正常情况下只能子类转换为父类,泛型类型Test<T>存在以下几种形变:

  • 协变 (Covariance):因为Int是Number的子类,所以Test<Int>同样是Test<Number>的子类,可以直接转换
  • 逆变(Contravariance):跟上面相反,Test<Number>可以直接转换为Test<Int>,前置是后者的子类
  • 抗变 (Invariant):Test<Int>Test<Number>没半毛钱关系,无法互相转换

而在Kotlin的泛型中,默认就是抗变的,即使两个类型存在父子关系,到编译器这里也不认账,但是实际上我们需要的可能是协变或是逆变,为了处理这种情况,Kotlin提供了两个关键字供我们使用:

  • out 关键字用于标记一个类型参数作为协变,可以实现子类到父类的转换。
  • in 关键字用于标记一个类型参数作为逆变,可以实现父类到子类的转换。

那么该怎么使用呢,非常简单:

1
2
3
4
5
fun main() {
    val test1: Test<Int> = Test(888)
  	//使用out关键字使得此类型协变,可以代表Number及其子类
    val test2: Test<out Number> = test1  //此时就可以正常接受子类Int了
}

虽然看上去非常难理解,但是简单来说,其实就是为类型添加一个可以转换子类的性质,out作用就是使类型支持协变,可以支持泛型从父类转换为子类,但是不能子类转父类,比如这里使用Any就没法成功接受。相反的,如果我们标记某个类型为in,那么这个类型就是逆变的,可以由父类向下转化:

1
2
3
4
5
fun main() {
    val test1: Test<Any> = Test(888)
  	//使用in关键字使得此类型逆变,可以代表Number及其父类
    val test2: Test<in Number> = test1  //Any是Number的父类,逆变
}

用树形图展示,关系如下:

在使用这种协变或逆变类型时,具体使用的类型就变得不确定了,导致不同的界限会有不同的效果,比如下面:

1
2
3
4
5
fun main() {
  	//协变类型在使用时会变成上界,因为无论子类是什么,都是继承自上界类型的
    val test: Test<out Number> = Test(888)
    var data: Number = test.data
}
1
2
3
4
5
fun main() {
  	//逆变类型在使用时由于没有上界,具体使用哪个父类也不清楚,所以只能是Any?类型了
    val test: Test<in Number> = Test(888)
    var data: Any? = test.data
}

在使用outin之后,类型的使用就可以更加灵活,但是这样会存在一定的安全隐患,比如下面的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
open class A
class B: A()
class C: A()

fun main() {
    val test1: Test<B> = Test(B())  //这里存放的都是B类型的数据
    val test2: Test<out A> = test1  //此时test2与test1是同一个对象,但是test2是out A
    test2.data = C()  //由于C是A的子类,按照正常情况来说可以直接用(但实际上这句会报错)
  	val data: B = test1.data  //这下搞笑了,拿到的类型应该是C,结果接收的类型是B
}

为了解决这种情况,Kotlin对于out或in的类型进行了限制,比如设置了out的情况下:

属性的setter操作被限制,无法通过编译,因为这可能会导致不安全的操作发生,而in也是同理的:

1
2
3
4
5
fun main() {
    val test1: Test<A> = Test(B())  //这里存的是B类型的对象
    val test2: Test<in C> = test1   //直接使用in C接收得到
    val data: C = test2.data   //此时得到的结果应该也可以是C才对,那肯定是错的
}

因此,在使用in时,属性的getter操作被限制,会提示类型不匹配,得到的类型也是Any? 无法通过编译,同样是因为可能存在不安全的操作。不仅仅是属性,包括所有函数的参数、返回值,都会受到限制:

1
2
3
4
5
fun main() {
    val test1: Test<B> = Test(B())
    val test2: Test<out A> = test1
    test2.test(C())  //报错,因为这里存在消费行为
}

因此,对于in和out来说,协变和逆变的属性将其限制为了生产者和消费者:

  • 使用out修饰的泛型不能用作函数的参数,对应类型的成员变量setter也会被限制,只能当做一个生产者使用。
  • 使用in修饰的泛型不能用作函数的返回值,对应类型的成员变量getter也会被限制,只能当做一个消费者使用。

在了解了这么多泛型的知识之后,相信各位小伙伴已经感受到泛型的巧妙而又复杂的设计了。

最后,在有些时候,我们可能并不在乎到底使用哪一个类型,我们希望一个变量可以接受任意类型的结果,而不是去定义某一个特定的上界或下界。在Kotlin泛型中,星号(*)代表了一种特殊的类型投影,可以代表任意类型:

1
2
3
4
fun main() {
    var test: Test<*> = Test(888)  //由于此时使用了*表示任意类型,无论类型如何变化,都可以被此变量接收
    test = Test("Hello")
}

同样的,由于不确定具体类型,使用时只能是Any?类型,跟上面in的情况一样,这里就不做演示了,下一章我们还会继续探讨更多*的默认情况。

泛型界限*

注意: 这一部分相当有难度,请务必将前面的泛型概念理解到位,否则很难继续学习。

前面我们介绍了协变和逆变,使得泛型的类型可以灵活变化使用,而我们在定义类的时候,在类型参数位置也可以进行限制。

比如有一个新的需求,现在没有String类型的成绩了,但是成绩依然可能是整数,也可能是小数,这时我们不希望用户将泛型指定为除数字类型外的其他类型,这又该怎么去实现呢?

1
2
//设定类型参数上界,必须是Number或是Number的子类
class Score<T : Number>(private val name: String, private val id: String, val value: T)

使用类似于继承的语法来完成类型的上界限制,定义后,使用时的具体类型只能是我们指定的上界类型或是上界类型的子类,不得是其他类型,否则一律报错:

在默认情况下,如果我们不指定,那么上界类型就是Any?,而现在,我们在使用时就只能将类型指定为Number的子类了。

如果我们需要设定多个上界,比如必须同时是某两个类型的子类(或接口实现)像这样多个约束设定,我们需要使用where关键字:

1
2
3
4
5
6
7
8
class Score<T>(private val name: String, private val id: String, val value: T)
        where T : Comparable<T>, T : Number
				//where后跟上多个需要同时匹配的类型

fun main() {
  	//由于Int同时实现了Comparable接口以及继承自Number,所以满足多个条件,可以使用
    var score: Score<Int> = Score("数据结构与算法", "EP710214", 6)
}

通过设定上界,能够更加规范类的使用。

有时候为了方便,我们也可以直接在类定义的时候直接将类型参数指定为out或是in来使得其协变或逆变:

1
2
3
4
5
6
7
interface Test<out T> {
    fun test(): T   //使用T类型作为返回值
}

interface Test<in T> {
    fun test(t: T)  //使用T类型作为参数
}

这样我们使用时就可以实现类型自动适应:

1
2
3
4
5
6
7
interface Test<out T> {
    fun test(): T
}

fun test(test: Test<Int>) {
    val a: Test<Number> = test  //协变
}

同样的,我们前面说了在添加inout后会限制相应的行为来保证类型的安全性,在定义类的一些函数或属性的时候都会得到警告:

在了解了类型界限相关内容之后,我们再来看看*类型投影在不同情况下的默认类型,比如:

  • 对于Foo<out T : TUpper>,其中T是与上界TUpper的协变类型参数,Foo<*>等价于Foo<out TUpper>,就像下面这样:

    1
    2
    3
    4
    5
    6
    
    class Test<out T : Number>(val data: T)  //因为限制了out,因此作为生产者,这里只能使用val
    
    fun main() {
        val test: Test<*> = Test(10)  //虽然使用了*表示不确定,但是由于类型参数本身存在上界
        var data: Number = test.data  //所以类型读取后可以直接当做上界类型Number使用
    }
    
  • 对于Foo<in T>,其中T是逆变类型参数,Foo<*>等价于Foo<in Nothing>,无法安全地将属性给到消费者消费:

    1
    2
    3
    4
    5
    6
    7
    8
    
    class Test<in T> {
        fun set(t: T) { }   //因为限制了in,因此只能作为消费者,这里用函数的形式
    }
    
    fun main() {
        val test: Test<*> = Test<Int>()
        test.set(10)   //编译错误,set中参数类型为Nothing,不允许任何值
    }
    
  • 对于Foo<T : TUpper>,其中T是具有上界TUpper的抗变类型参数,在读取数据时Foo<*>等价于Foo<out TUpper>,写入数据时等价于Foo<in Nothing>,就像这样:

    1
    2
    3
    4
    5
    6
    7
    
    class Test<T: Number>(var data: T)
    
    fun main() {
        val test: Test<*> = Test(10)
        var data: Number = test.data  //正常通过
        test.data = 10   //编译错误,Setter for 'data' is removed by type projection
    }
    

如果一个泛型类有多个类型参数,每个类型参数都可以独立使用*表示不确定,例如类型为interface Function<in T, out U>,您可以使用以下星形投影:

  • Function<*, String>等价于Function<in Nothing, String>
  • Function<Int, *>等价于Function<Int, out Any?>
  • Function<*, *>等价于Function<in Nothing, out Any?>

泛型的使用可以很简单也可以很复杂,想要完全把这个搞明白还是需要多练多理解才能达到。

类型擦除*

注意: 这一部分相当有难度,请务必将前面的泛型概念理解到位,否则很难继续学习。

前面我们介绍了泛型的使用,以及各种高级功能,但是实际上,泛型的类型检查仅仅只存在于编译阶段,在源代码编译之后,实际上并不会保留任何关于泛型类型的内容,这便是类型擦除。

比如下面的类型:

1
2
3
4
5
6
7
class Test<T>(private var data: T) {
    fun test(t: T) : T {
        val tmp = data
        data = t
        return tmp
    }
}

在编译时候,会自动擦除类型:

1
2
3
4
5
6
7
class Test(private var data: Any?) {  //最后还是全部变成Any?类型了
    fun test(t: Any?) : Any? {
        val tmp = data
        data = t
        return tmp
    }
}

如果存在上界,那么擦除后会是上界的类型:

1
class Test<T : Number>(private var data: T)
1
class Test(private var data: Number)   //擦除后类型变成上界类型

由于在运行时不存在泛型的概念,因此,很多操作都是不允许的,比如类型判断:

1
2
3
4
5
class Test<T>(private var data: T) {
    fun isType(obj: Any) : Boolean {
        return obj is T   //编译错误,由于类型擦除,运行时根本不存在T的类型
    }
}

包括我们在使用这个泛型类时:

1
2
3
4
5
fun main() {
    val test: Test<Int> = Test(10)
    println(test is Test<Double>)   //编译错误,由于类型擦除,无法判断具体的类型
  	println(test is Test)  //编译通过,判断是不是这个类还是没问题的
}

因此,正是为了保证类型擦除之后程序能够安全运行,才有了上面这么多限制。

对于内联函数,泛型擦除的处理会有一些不同,得益于它的内联性质,内联函数的代码是在编译时期直接插入到调用处的,在编译之后具体类型必须要存在,否则会出现问题(因为类型可以明确)因此其泛型参数的具体类型信息是可用的,编译器可以使用这些信息来生成更具体的字节码。这意味着,对于内联函数的泛型参数,并不会像非内联函数那样发生类型擦除。

1
2
3
4
5
6
7
8
inline fun <T> test(value: T): T {
    val value2 : T = value
    return value2
}

fun main() {
    val data: String = test("Hello World!")
}

内联函数编译后,类型直接保留:

1
2
3
4
5
fun main() {
    val value: String = "Hello World!"
    val value2: String = value   //直接以String类型变量编译到程序中
    val data: String = value2
}

Kotlin的内联函数还有一个功能是可以使用具化的类型参数(reified 关键字)具化类型参数允许在函数体内部检测泛型类型,因为这些类型信息会被编译器内嵌在调用点。但是,这只适用于内联函数,因为类型信息在编译时是可知的,并且实际类型会被编译到使用它们的地方,使用也很简单:

1
2
3
4
5
6
7
8
//添加reified关键字具化类型参数
inline fun <reified T> isType(value: Any): Boolean {
    return value is T  //这样就可以在函数里面使用这个类型了
}

fun main() {
    println(isType<String>("666"))
}

具化类型参数仅适用于内联函数。

数组

前面我们介绍了泛型,它可以实现在编写代码阶段的类型检查,现在我们就可以正式进入到数组的学习当中了。

假设出现一种情况,我们想记录100个数字,要是采用定义100个变量的方式可以吗?是不是有点太累了?这种情况我们就可以使用数组来存放一组相同类型的数据。

在Kotlin中,数组是Array类型的对象。

创建数组

数组是相同类型数据的有序集合,数组可以代表任何相同类型的一组内容,其中存放的每一个数据称为数组的一个元素,我们来看看如何创建一个数组,在Kotlin中有两种创建方式:

比如我们要创建一个包含5个数字的数组,那么我们可以像这样:

1
val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)  //直接在arrayOf函数中添加每一个元素

这里得到的结果类型为Array,它是一个泛型类

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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

    //当前数组大小(可以看到是val类型的,一旦确定不可修改)
    public val size: Int

    //迭代运算重载(后面讲解)
    public operator fun iterator(): Iterator<T>
}

可以看到,数组本质就是一个Array类型的对象,其类型参数就是我们存储的元素类型,由于使用构造函数创建数组稍微有些复杂,我们将其放到后面进行介绍。

注意: 数组在创建完成之后,数组容量和元素类型是固定不变的,后续无法进行修改。

1
2
3
4
5
fun main() {
    val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
    array.size = 10   //编译错误,长度不可修改
    val arr: Array<String> = array  //编译错误,类型不匹配
}

既然现在创建好了数组,那么该如何去访问数组里面的内容呢?

1
2
3
4
fun main() {
    val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
    println(array[0])   //使用[]运算符来访问指定下标上的元素
}

由于数组存放的是一组元素,我们在访问每个元素时需要告诉程序我们要访问的是哪一个,而每个元素都有一个自己的下标地址,下标从0开始从左往右依次递增排列,比如我们要访问第一个元素那么下标就是0,第三个元素下标就是2,以此类推:

1
2
3
4
fun main() {
    val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
    println("数组中的第二个元素是${array[1]}")
}

注意,在使用数组时,我们只能访问数组可以访问的范围,如果我们获取一个范围之外的元素,会得到错误,比如当前的数组的大小是5那么也就只能包含5个元素,此时我们去访问第六个元素,显然是错误的:

1
2
println("数组中的第六个元素是${array[5]}")  //已经超出可访问范围了
println("数组中的第?个元素是${array[-1]}")  //下标从0开始,怎么可能有-1呢

我们也可以使用[]修改数组中指定下标元素的值:

1
2
3
4
5
fun main() {
    val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
    array[0] = 10  //修改第一个元素的值
    println("数组中的第一个元素是${array[0]}")
}

还有一个要注意的是,我们直接打印这个数组对象并不能得到数组里面每个元素的值,而是一堆看不懂的东西:

具体原因可以通过学习Java后进行了解,如果各位小伙伴需要打印数组中的每一个元素,我们只能一个一个打印,可以使用一个for循环语句来完成:

1
2
3
4
5
6
fun main() {
    val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
    for (i in 0..<array.size) {   //从0循环到array.size前一位
        println(array[i])   //每一个依次打印即可
    }
}

不过,在Kotlin中,这样编写并不优雅,我们有更好的方式去遍历数组中的每一个元素,在之前我们学习for循环语句时,谈到使用in来遍历一个可遍历的目标,而数组就是满足这个条件的,我们可以直接遍历它:

1
2
3
4
5
6
fun main() {
    val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
    for (element in array) {
        println(element)   //从第一个元素开始依次遍历,element就是每一个元素了
    }
}

当然,如果我们还是希望按照数组的索引进行遍历,也可以使用:

1
2
3
4
val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
for (i in array.indices) {   //indices返回的是数组的有效索引范围,这里就是0 ~ 4
    println(array[i])
}

如果你想同时遍历索引和元素本身,也可以使用withIndex函数,它会生成一系列IndexedValue对象:

1
2
//关于data class我们会在下一篇中讲解
public data class IndexedValue<out T>(public val index: Int, public val value: T) //包含元素本身和索引

在使用forin时,我们也可以对待遍历的元素进行结构操作,当然,前提是这些对象类型支持解构,比如这里的IndexedValue就支持解构,所以我们可以在遍历时直接使用解构之后的变量进行操作:

1
2
3
4
val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
for ((index, item) in array.withIndex()) {  //使用withIndex解构后可以同时遍历索引和元素
    println("元素$item,位置: $index")
}

如果需要使用Lambda表达式快速处理里面的每一个元素,也可以使用forEach高阶函数:

1
2
3
4
5
val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
array.forEach { println(it) }   //只带元素的
array.forEachIndexed { index, item ->   //同时带索引和元素的
    println("元素$item,位置: $index")
}

如果只是想打印数组里面的内容,快速查看,我们可以使用:

1
2
3
4
5
val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
println(array.joinToString())  //使用joinToString将数组中的元素转换为字符串,默认使用逗号隔开:7, 3, 9, 1, 6
println(array.joinToString(" - ", "> ", " <"))  //自定义分隔符,前后缀: > 7 - 3 - 9 - 1 - 6 <
println(array.joinToString(limit = 1, truncated = "..."))  //甚至可以限制数量,多余的用自定义的字符串...代替: 7, ...
println(array.joinToString() { (it * it).toString() })   //自定义每一个元素转换为字符串的结果

我们接着来看一下如何使用构造函数来创建数组,首先构造函数时这样定义的:

1
2
3
4
5
/**
 * size: 不必多说,数组的大小
 * init: 初始化操作,这个操作会根据数组大小,循环调用传入的函数size次,并且将对应的下标作为参数,我们需要在函数中返回当前数组元素类型的结果,这样就会自动填充到数组的对应位置上
 */
public inline constructor(size: Int, init: (Int) -> T)

比如我们希望创建一个字符串数组:

1
2
3
4
5
6
fun main() {
    val array: Array<String> = Array(5) { "我是元素$it" }   //其中返回值为自定义的字符串,这样就会自动填充到对应位置
    for (s in array) {
        println(s)
    }
}

利用这种特性,我们可以快速创建一个全是同一个值的数组:

1
val array: Array<Double> = Array(5) { 1.5 }  // 1.5, 1.5, 1.5, 1.5 ...

还可以快速搞一个平方数数组:

1
val array: Array<Int> = Array(10) { it * it }   // 0, 1, 4, 9, 16 ...

不过,其实一般情况下使用arrayOf都可以解决大部分情况了,还有它的变种,大概介绍一下:

1
2
val array: Array<Int> = emptyArray<Int>()   //创建容量为0的数组
val array: Array<Int?> = arrayOfNulls(10)   //创建元素可空的数组

下一节课我们接着学习更多数组的操作。

使用数组

现在我们已经学习了如何创建数组,实际上官方库提供了很多数组的扩展函数,方便我们对于数组的使用,我们现在就来看看吧。

对于两个数组来说,如果我们要比较它们之间是否包含相同的内容,需要使用特殊的比较函数:

1
2
3
4
5
6
fun main() {
    val array1: Array<Int> = arrayOf(1, 2, 3, 4, 5)  //两个内容相同的数组
    val array2: Array<Int> = arrayOf(1, 2, 3, 4, 5)
    println(array1 == array2)   //不可以使用==来判断数组内容是否相同,不支持
    println(array1.contentEquals(array2))   //需要使用扩展函数contentEquals来进行内容比较
}

要拷贝一个数组的内容并生成一个新的数组,可以:

1
2
3
4
5
fun main() {
    val array1: Array<Int> = arrayOf(1, 2, 3, 4, 5)
    val array2: Array<Int> = array1.copyOf()   //使用copyOf来拷贝数据内容并创建一个新的数组存储
    println(array2 === array1)  //false,不是同一个对象
}

copyOf函数可以指定拷贝的长度或是拷贝的范围,使用更加灵活一些:

1
2
val array2: Array<Int?> = array1.copyOf(10)
//在拷贝时指定要拷贝的长度,如果小于数组长度则只保留前面一部分内容,如果大于则在末尾填充null,因此返回的类型是Int?可空
1
2
val array2: Array<Int> = array1.copyOfRange(1, 3)  //从第二个元素开始拷贝到第四个元素前为止,共2个元素
//使用copyOfRange拷贝指定下标范围上的元素

还有一个比较类似操作,但是可以使用Range进行分割:

1
2
val array1 = arrayOf(1, 2, 3, 4, 5)
val array2 = array1.sliceArray(1..3)   //从第二个元素到第四个元素共三个元素的数组

两个数组也可以直接拼接到一起,形成一个长度为10的新数组,按顺序拼接:

1
2
3
val array1 = arrayOf(1, 2, 3, 4, 5)
val array2 = arrayOf(6, 7, 8, 9, 10)
val array3 = array1 + array2

快速查找元素肯定也是不在话下的:

1
2
3
4
5
val array = arrayOf(13, 16, 27, 32, 38)
println(array.contains(13))   //判断数组中是否包含3这个元素
println(array in 13)   //跟contains函数效果一样,判断数组中是否包含3这个元素
println(array.indexOf(26))    //寻找指定元素的下标位置
println(array.binarySearch(16))    //二分搜索某个元素的下标位置(效率吊打上面那个函数,但是需要数组元素有序,具体原因可以学习数据结构与算法了解)

不过,可能会有小伙伴好奇,这里的contains函数传入的对象,是如何进行判断的?比如我要删除某一个元素,程序是如何将数组内的对象与传入的对象进行比较得出是相同的元素呢?我们来看下面这个例子:

1
2
3
4
5
6
class Student(val name: String, val age: Int)

fun main() {
    val array = arrayOf(Student("小明", 18), Student("小红", 17))
    println(array.contains(Student("小明", 18)))   //结果为false
}

怎么回事?我们这明明传入的是两个内容一样的对象啊,为什么是false呢?直接看源码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public operator fun <@kotlin.internal.OnlyInputTypes T> Array<out T>.contains(element: T): Boolean {
    return indexOf(element) >= 0  //调用内部indexOf函数看看能不能找到这个元素的下标
}

public fun <@kotlin.internal.OnlyInputTypes T> Array<out T>.indexOf(element: T): Int {
    if (element == null) {
       ...
    } else {
        for (index in indices) {   //直接通过遍历的形式对数组内的元素一个一个进行判断
            if (element == this[index]) {   //可以看到,这里判断使用的是==运算符,这下就好说了
                return index
            }
        }
    }
    return -1
}

我们在前面介绍过,使用==的判断实际上取决于equals函数的重写,如果要让两个对象实现我们自定义的判断,需要重写对应类型的equals函数,否则无法实现自定义比较,默认情况下判断的是两个对象是否为同一个对象,所以,我们可以尝试重写一下:

1
2
3
4
5
6
7
8
9
class Student(val name: String, val age: Int) {
    override fun equals(other: Any?): Boolean {
        if(this === other) return true  //如果引用的是同一个对象,肯定是true不多逼逼
        if(other !is Student) return false //如果要判断的对象根本不是Student类型的,那也不用继续了
        if(name != other.name) return false  //判断名字是否相同
        if(age != other.age) return false  //判断年龄是否相同
        return true   //都没问题,那就是相等了
    }
}

现在得到的结果就是我们希望的样子了。

也可以快速判断和获取元素:

1
2
3
4
val array = arrayOf(1, 2, 3, 4, 5)
println(array.any())   //判断数组是否为空数组(容量为0)
println(array.first())   //快速获取首个元素
println(array.last())    //快速获取最后一个元素

我们也可以快速将一个数组的内容进行倒序排放:

1
2
val array1: Array<Int> = arrayOf(1, 2, 3, 4, 5)
val array2: Array<Int> = array1.reversedArray()   //翻转数组元素顺序,并生成新的数组
1
2
3
val array1: Array<Int> = arrayOf(1, 2, 3, 4, 5)
array1.reverse()   //直接在原数组上进行元素顺序翻转
array1.reverse(1, 3)   //仅翻转指定下标范围内的元素

如果我们想要直接将数组中元素打乱,也有一个快速洗牌的函数将所有元素顺序重新随机分配:

1
2
val array1: Array<Int> = arrayOf(1, 2, 3, 4, 5)
array1.shuffle()  //使用shuffle函数将数组中元素顺序打乱

打乱了想重新还原成有序的数组咋办?

1
2
3
array1.sort()   //使用sort函数对数组中元素进行排序,排序规则可以自定义
array1.sort(1, 3)   //仅排序指定下标范围内的元素
array1.sortDescending()   //按相反顺序排序

注意,排序操作并不是任何类型上来就支持的,由于这里我们使用的是基本类型Int,它默认实现了Comparable接口,这个接口用于告诉程序我们的排序规则,所以,如果数组中的元素是未实现Comparable接口的类型,那么无法支持排序操作。

我们可以来尝试实现一下:

1
2
3
4
5
6
7
//首先类型需要实现Comparable接口,泛型参数T填写为当前类型
class Student(private val name: String, private val age: Int) : Comparable<Student> {
  	//接口中就这样一个函数需要实现,这个是比较的核心算法,要参数是另一个需要比较的元素,然后返回值是一个Int
  	//使用当前对象this和给定对象other进行比较,如果返回小于0的数,说明当前对象应该排在前面,反之排后面,返回0表示同样的级别
    override fun compareTo(other: Student): Int = this.age - other.age
    override fun toString(): String = "$name ($age)"
}

这样,我们自定义的类型就支持比较和排序了:

1
2
val array1 = arrayOf(Student("小明", 18), Student("小红", 17))
array1.sort()

还有可以快速填充数组内容的函数:

1
2
val array1 = arrayOf(1, 2, 3, 4, 5)
array1.fill(10)   //重新将数组内部元素全部填充为10

好了,就先介绍这么多吧,到这里也才介绍了数组操作的一半,后面到了集合类我们再来介绍更多使用的扩展函数,因为集合数组都是支持的。

可变长参数

前面我们介绍了数组的使用,不知道各位小伙伴有没有疑惑,在使用arrayOf时,里面的参数为什么可以随意填写不同数量?

1
2
3
arrayOf(1, 2, 3, 4, 5)
arrayOf(1, 2, 3)
arrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9)

函数的参数数量不是固定的吗?怎么还能动态加?难道我们前面学的是假的函数?这其实是因为这个函数使用了可变长参数的原因,它可以实现同一个类型的参数任意填写:

1
2
3
4
5
6
7
fun test(vararg strings: String) {
	//使用vararg关键字将参数标记为可变长参数
}

fun main() {
    test("AAA", "BBB", "CCC")   //在使用时,只要是同类型的参数可以填写任意数量
}

但是需要注意的事,可变长参数在函数的形参列表里面只能存在一个,下面这几种情况:

1
2
3
fun test(vararg strings: String, a: Int) { ... }   //编译通过
fun test(a: Int, vararg strings: String) { ... }   //编译通过
fun test(vararg a: Int, vararg strings: String) { ... }    //编译错误,存在多个可变长参数

那么,这种可变长参数在函数中如何使用呢?我们可以将其当做一个Array来使用:

1
2
3
fun test(vararg strings: String) {
    var str: Array<out String> = strings  //在函数中得到的是一个Array<out String>类型的数组
}

这样一看就简单多了,可变长参数本质就是一个数组。

那么既然可变长参数是一个数组,我们可不可以直接把数组作为参数给到一个可变长参数中呢?

1
2
3
4
fun main() {
    val array = arrayOf("AAA", "BBB", "CCC")
    test(array)   //编译错误,这里需要的是多个String,但是传入的类型是Array<String>
}

这就有点不太合理了,反正都是数组为啥我不能直接传个数组进去当做实参呢,因此Kotlin给我们提供了一个扩展运算符(*)此运算符将数组的每个元素作为单个参数传递:

1
2
3
4
fun main() {
    val array = arrayOf("AAA", "BBB", "CCC")
    test(*array)   //编译通过,虽然看起来有点像C语言的指针
}

别急,你以为这样就结束了吗,它还可以混着用:

1
2
val array = arrayOf("AAA", "BBB", "CCC")
test("111", *array, "DDD", "EEE")   //前面后面甚至还能继续写

因此,如果我们需要将一个数组的内容复制到一个新的数组中,直接这样操作就好了:

1
2
val array = arrayOf("AAA", "BBB", "CCC")
val array2 = arrayOf(*array)

原生类型数组

在之前,我们使用了大量基本类型数组,比如Array<Int>Array<Double>Array<Char>等等,这些包含基本类型的数组往往在编译时可以得到优化(比如JVM平台会直接编译为基本类型数组,如int[]double[]等,可以免去装箱拆箱开销)Kotlin提供了预设的原生类型数组:

原生类型数组 相当于Java
BooleanArray boolean[]
ByteArray byte[]
CharArray char[]
DoubleArray double[]
FloatArray float[]
IntArray int[]
LongArray long[]
ShortArray short[]

这些类型与Array类型没有任何继承关系,但是它们有同样的方法属性集,使用起来区别不大,优先使用基本类型数组,可以使得程序免得到一定优化,增加效率:

1
2
3
4
5
fun main() {
  	//使用arrayOf的变种intArrayOf快速生成IntArray
    val array: IntArray = intArrayOf(7, 3, 9, 1, 6)
    array.forEach { println(it) }
}

这些原生类型数组也有一些额外的扩展,比如快速求和:

1
2
val array: IntArray = intArrayOf(7, 3, 9, 1, 6)
println(array.sum())  //快速求和操作,获得数组中所有元素之和

还有求平均值之类的:

1
2
val array: IntArray = intArrayOf(7, 3, 9, 1, 6)
println(array.average())   //求整个数组的平均数

快速获取最大值和最小值:

1
2
3
val array: IntArray = intArrayOf(7, 3, 9, 1, 6)
println(array.min())
println(array.max())

其他使用基本一致,这里就不多进行介绍了。

嵌套数组

有些时候,单个维度的数组似乎无法满足我们的需求。比如我们现在6个元素为一组存储,现在共需要存储4组这样的数据,我们不可能去定义4个一样的数组吧?这个时候就需要用到嵌套数组了。

存放数组的数组,相当于将维度进行了提升,比如下面的就是一个2x10的数组:

二维数组看起来更像是一个平面,同理,三维数组就是一个立方体空间,四位数组就进入到我们人类无法理解的范围了,由很多个三维组成(物理上解释或许是时间轴?)

那么像这样的多维度数组如何创建呢?这里我们以二维数组为例,三维四维同理:

1
val arr: Array<IntArray> = arrayOf(intArrayOf(1, 2), intArrayOf(3, 4), intArrayOf(5, 6))

可以看到,我们使用arrayOf去囊括多个IntArray,这样,最外层的Array相当于是保存多个IntArray的Array,也就实现了我们上面的二维数组效果了。当然像这样也是可以的:

1
2
3
//存放9个Array<Int>数组的数组,其中每个Array<Int>的长度为4,内容为0填充
// { {0,0,0,0}, {0,0,0,0}, {0,0,0,0} ... }
val arr: Array<Array<Int>> = Array(9) { Array(4) { 0 } }

嵌套数组看起来可能有些绕,但是其实仔细分析之后还是比较简单的。

我们在使用二维数组时:

1
2
3
val arr: Array<IntArray> = arrayOf(intArrayOf(1, 2), intArrayOf(3, 4), intArrayOf(5, 6))
val array: IntArray = arr[0]   //获取二维数组的第一个元素,得到内层存放的数组
val item: Int = array[0]   //再从内存存放的数组中拿到第一个元素

所以,如果我们要获取位于整个二维矩阵左上角的第一个元素,可以像这样:

1
2
3
val arr: Array<IntArray> = arrayOf(intArrayOf(1, 2), intArrayOf(3, 4), intArrayOf(5, 6))
//这里使用了两个[]运算符,第一个处理最外层数组,第二个才是对内层数组的操作
val item: Int = arr[0][0]

对于这种二维数组,如果需要遍历,我们同样可以使用for循环来完成,不过需要两层for才可以搞定:

1
2
3
4
5
6
val arr: Array<IntArray> = arrayOf(intArrayOf(1, 2), intArrayOf(3, 4), intArrayOf(5, 6))
for (ints in arr) {   //最外层遍历外层数组中存放的每一个内层数组
    for (int in ints) {     //内层循环遍历每一个内层数组
        println(int)   //得到每一个内层数组的值
    }
}

由于现在数组内存放的是数组,我们在比较两个嵌套数组的内容是否相同时,需要使用深度比较:

1
2
3
4
5
6
fun main() {
    val arr1: Array<IntArray> = arrayOf(intArrayOf(1, 2), intArrayOf(3, 4), intArrayOf(5, 6))
    val arr2: Array<IntArray> = arrayOf(intArrayOf(1, 2), intArrayOf(3, 4), intArrayOf(5, 6))
    println(arr1.contentEquals(arr2))   //此函数只会简单比较里面的每一个元素,当里面每个元素是数组时不会再继续去比较第二层第三层等
    println(arr1.contentDeepEquals(arr2))  //此函数会一直深入比较每一层,多维使用这个比较
}

这里还有一个知识误区,虽然我们使用的看起来确实类似于二维数组,但是每一个数组的长度并不需要是相同的:

1
2
val arr: Array<IntArray> = arrayOf(intArrayOf(1, 3, 4, 5), intArrayOf(2, 9))
//这里第一个数组长度为4,第二个为2

甚至类型也可以不一样:

1
2
//只要内层使用Any类型,就可以接收所有类型的嵌套数组
val arr: Array<Array<out Any>> = arrayOf(arrayOf(1, 3, 4, 5), arrayOf("AAA", "BBB"))

不过正常情况下,我们还是会按照标准的二维数组来使用,这样更加规范一些。

集合类

前面我们学习了数组的使用,虽然数组可以很方便地存储一组数据,但是它存在诸多限制:

  • 长度是固定的,无法扩展
  • 无法做到在数组中像列表那样插入或者删除元素

显然,在很多情况下,数组虽然可以存储一组数据,但是它并不好用,我们需要借助更加强大的集合类来实现更多高级功能。在Kotlin中,默认提供了以下类型的集合:

  • List: 有序的集合,通过索引访问元素,可以包含重复元素,比如电话号码:它就是一组数字,并且顺序很重要,而且数字可以重复。
  • Set: 不包含重复元素的集合,它更像是数学中的集合,一般情况下不维护元素顺序,例如,彩票上的数字:都是独一无二的,并且它们的顺序不重要。
  • Map: 是一组键值对,其中每一个键不可重复存在,每个键都映射到恰好一个值(值可以重复存在)这跟数学上的映射关系很像。它经常用于存储(员工ID -> 员工具体信息)这样的结构。

所有集合类都是继承自Collection接口(Map除外)我们可以看看这个接口的定义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public interface Collection<out E> : Iterable<E> {  //继承了可迭代接口(后面讲解)
    //集合的大小
    public val size: Int
    //判断集合是否为空
    public fun isEmpty(): Boolean
    //集合是否包含某个元素,可用in运算符判断
    public operator fun contains(element: @UnsafeVariance E): Boolean
  	//生成迭代器(后面讲解)
    override fun iterator(): Iterator<E>
  	//是否包含另一个集合中所有的内容
    public fun containsAll(elements: Collection<@UnsafeVariance E>): Boolean
}

这个接口定义了集合的基本操作,以及核心属性,而由集合顶层接口衍生的不同集合类,也都有自己的定义。集合类一般都是以接口类型的变量进行使用,因为不同的集合可能存在不同的集合实现类,为了使用起来更加通用,我们往往会使用集合类的接口进行操作。

下面就让我们一个一个认识吧。

List集合

List就像它的名字一样,就是一个列表,它存储一组有序的数据,比如我们看到的餐厅菜单,还有游戏的排行榜,每一道菜、每一个玩家都是按顺序向下排列的,并且根据情况,我们可以自由地在某个位置插入或删除一个新的元素,列表的长度也会动态发生变化,List集合就是为了这些功能而生的。

要创建一个List集合非常简单,就跟我们之前创建数组一样:

1
2
3
4
5
6
fun main() {
    val list: MutableList<String> = mutableListOf(1, 2, 3, 4)   //使用mutableListOf来创建一个可修改的List集合
    list[0] = 10   //修改某个位置上的元素
    println(list[1])   //获取某个位置上的元素
    println(list)    //打印出来的结果也是非常标准的: [10, 2, 3, 4]
}

我们发现,使用List集合之后,很多操作其实跟数组是基本一样的,它同样可以存储一组元素,以及修改。

除了可以使用数组支持的操作之外,为了能够作为列表使用,还有很多新的操作,比如我们希望在末尾添加一个新的元素到列表中:

1
2
3
val list = mutableListOf(1, 2, 3, 4)
list.add(5)   //使用add函数添加一个新的元素到列表末尾
println(list)   //列表自动增长,得到[1, 2, 3, 4, 5]

我们可以在整个列表之间的任意位置插入,但是同样不能出现越界的情况:

1
2
val list = mutableListOf(1, 2, 3, 4)
list.add(2, 666)   //将666插入到第三个元素的位置上去

既然可以插入元素,同样的也可以删除元素:

1
2
3
val list = mutableListOf("AAA", "BBB", "CCC", "DDD")
list.removeAt(2)  //使用removeAt可以删除指定位置上的元素
list.remove("DDD")    //使用remove可以删除指定元素

可以看到,列表相比我们传统的数组来说,完整地支持了增删改查这四个操作,使用起来也会更加方便。

当然,有些时候可能我们希望获取一个只读的列表:

1
2
val list: List<String> = listOf("AAA", "BBB", "CCC", "DDD")  //使用listOf生成的列表是只读的
list[0] = "XXX"   //在修改时会直接提示不支持

类似于数组,还有多种列表创建函数:

1
2
val array = arrayOf("AAA", null, "CCC", "DDD")
val list: List<String> = listOfNotNull(*array)   //使用listOfNotNull可以自动去除所有null的元素,再创建只读列表
1
val list: List<String> = emptyList()   //返回空列表

或是使用构造函数来创建一个列表:

1
2
val list: List<String> = List(3){ "TZ" }  //跟数组一样,不多说了
println(list)

如果我们需要遍历一个列表,同样很简单,跟数组完全一样:

1
2
3
4
5
6
7
8
val list: List<String> = listOf("AAA", "BBB", "CCC", "DDD")
for (s in list) {  //使用forin来快速遍历数组中的每一个元素
    println(s)
}

for ((index, item) in list.withIndex()) {
    println("元素$item, 下标: $index")
}

集合也支持加法和减法运算:

1
2
3
4
5
6
fun main() {
    val l1 = listOf("AAA", "DDD", "CCC")
    val l2 = listOf("BBB", "CCC", "EEE")
    println(l1 + l2)   //合并两个List的内容,顺序直接在后面拼接: [AAA, DDD, CCC, BBB, CCC, EEE]
    println(l1 - l2)   //让前面的集合减去与后面集合存在重复内容的部分: [AAA, DDD]
}

使用还是非常简单的。

Set集合

Set集合非常特殊,虽然它也可以保存一组数据,但是它不允许存在重复元素,我们无法让Set集合中同时存在两个一样的元素,这在一些需要去重的场景中非常实用,这跟数学中定义的集合非常相似。

创建一个Set集合很简单:

1
2
3
4
5
fun main() {
  	//使用mutableSetOf来创建一个Set集合
    val set: Set<String> = mutableSetOf("AAA", "BBB", "BBB", "CCC")
    println(set)   //由于不允许出现重复元素,得到 [AAA, BBB, CCC]
}

与列表一样,可以随意插入元素,元素默认在尾部插入,顺序为插入顺序:

1
2
val set: MutableSet<String> = mutableSetOf("AAA", "DDD", "CCC")
set.add("BBB")

不过Set默认不支持在指定位置插入元素,只能尾插,同时我们也不能通过下标去访问Set中的元素,这是因为Set底层采用的并不是线性数据结构存储,而是用了哈希表或是树形结构(感兴趣的小伙伴可以看一下另一期数据结构与算法篇教程)而内部元素的顺序则是采用的其他形式进行维护的。

不过,我们到是可以借助迭代器来获取当前顺序上的第N个元素:

1
2
val set = linkedSetOf("AAA", "DDD", "CCC")
println(set.elementAt(1))   //elementAt是通过迭代器的遍历顺序取得对应位置的元素

有关迭代器的知识,我们放在后面进行讲解。

同时,由于Set更接近于数学上的集合概念,Kotlin为我们准备了很多集合之间的操作:

1
2
3
4
5
6
val set1 = mutableSetOf("AAA", "DDD", "CCC")
val set2 = mutableSetOf("BBB", "CCC", "EEE")
println(set1 union set2)   //求两个集合的并集: [AAA, DDD, CCC, BBB, EEE]  Set的+运算与这个效果一样
println(set1 intersect set2)   //求两个集合的交集: [CCC]
println(set1 subtract set2)  //求集合1在集合2中的的差集: [AAA, DDD]  Set的-运算与这个效果一样
println((set1 - set2) union (set2 - set1))   //并集减去交集

虽然集合相关操作也可以应用于List集合,但是计算得到的结果始终是Set集合:

1
2
3
4
5
6
fun main() {
    val l1 = listOf("AAA", "DDD", "CCC")
    val l2 = listOf("BBB", "CCC", "EEE")
    val set: Set<String> = l1 union l2   //得到的结果是一个Set集合
    println(set)
}

对于Set集合,官方也有很多预设的函数用于创建:

1
2
val set = hashSetOf("AAA", "DDD", "BBB")   //创一个不重复且无序的Set集合
println(set)   //遍历顺序并不是添加顺序: [AAA, BBB, DDD]
1
2
val set = linkedSetOf("AAA", "DDD", "BBB")  //跟mutableSetOf一样得到一个不重复且有序的Set集合
println(set)
1
2
val set1 = setOf("AAA", "DDD", "BBB")   //只读的Set集合
val set2 = setOfNotNull("AAA", "DDD", "BBB", null)   //自动过滤Null元素的Set集合
1
val set = sortedSetOf("AAA", "DDD", "BBB")   //元素自动排序的Set集合,可以自定义排序规则
1
2
val hashSet = HashSet<String>()  //创一个不重复且无序的Set集合
val linkedHashSet = LinkedHashSet<String>()   //跟mutableSetOf一样得到一个不重复且有序的Set集合

最后我们来讲解一个前面就买下伏笔的问题,这里我们创建了一个Student类型的Set集合:

1
2
3
4
5
6
7
8
9
class Student(private val name: String, private val age: Int) {
    override fun toString(): String = "$name ($age)"
}

fun main() {
    val set = mutableSetOf(Student("小明", 18))
    set.add(Student("小明", 18))
    println(set)
}

虽然我们插入了两个相同的数据,但是它们本质上是两个对象,只是内容相同,所以,Set中会认为它们不同,同时得到保存:

为了解决这种问题,我们之前采用的是重写equals函数来重新定义比较规则,这样就可以实现内容相同判断为同一个了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Student(private val name: String, private val age: Int) {
    override fun toString(): String = "$name ($age)"
    override fun equals(other: Any?): Boolean {   //跟之前一样,添加自定义的比较方式
        if(this === other) return true
        if(other !is Student) return false
        if(name != other.name) return false
        if(age != other.age) return false
        return true
    }
}

再次执行程序,我们发现似乎没什么卵用:

什么鬼,这明明都把比较规则给自定义了,怎么还是不能判断为同一个呢?我们之前难道学的是个假的吗?我们注意到类上有一个警告,提示我们没有重写了eq函数但是没有定义hashCode:

这个hashCode是什么鬼?实际上Set集合默认采用的是哈希表进行数据存储(详情请看数据结构与算法篇视频教程)而哈希表在存储数据时,需要通过一个哈希函数计算出对象的哈希值,如果两个对象的哈希值相同,那么在哈希表中就会认定为是同一个元素,如果不相同,那么会认定为不同的两个元素,因此,这里我们仅仅重写equals只能满足部分集合类的使用,而到了Set这里包括后面的Map就开始不行了。

我们可以看到,在Any类中确实定义了一个hashCode函数,这个就是用于计算对象的哈希值的:

1
2
3
4
5
6
7
/**
 * 计算并返回对象的哈希值,哈希函数的计算结果需要满足以下标准:
 *
 * * 标准1: 对同一个对象调用此函数时,应该始终返回同一个哈希值,除非重写过类的equals函数,修改过比较方式
 * * 标准2: 如果两个对象使用equals函数判断的结果为相同,那么它们计算得到的哈希值也应该相同
 */
public open fun hashCode(): Int

在默认情况下,对象的哈希值得到的结果是对象在内存中存放的地址,以Int类型表示:

1
println(Any().hashCode())   //结果为内存地址位置: 295530567

因此,上面两个对象由于存放在不同的地址,所以得到的哈希值肯定是不一样的,既然现在我们仅仅只是比较对象的名称和年龄是否相同,我们可以修改一下哈希函数的计算规则:

1
2
3
4
5
6
7
8
9
class Student(private val name: String, private val age: Int) {
    ...

    override fun hashCode(): Int {
        var result = name.hashCode()  //仅计算name和age属性的哈希值
        result = 31 * result + age.hashCode()
        return result   //这样,当name和age的哈希值与另一个对象的一致时,得到的结果就是一样的了
    }
}

现在再次进行操作:

所以,以后我们在重写equals函数时,为了能够适配所有的集合类,我们还需将其hashCode函数一并重写,来保证一致性。

Map映射

Map是一个非常特殊的集合类型,它存储了一些的键值对,那么什么是键值对呢?

可以看到,学校里面的学号对应了每一个学生,我们只需要知道某一个学生的学号,就可以快速查找这个学生的姓名、年龄、性别等信息,而Map就是为了存储这样的映射关系而存在的。

首先我们来看,如何定义一个键值对,官方为我们提供了一个非常方便的中缀函数:

1
public infix fun <A, B> A.to(that: B): Pair<A, B> = Pair(this, that)

我们只需要指定:

1
val pair: Pair<Int, String> = 10001 to "小明"   //得到一个由学号指向学生名称的键值对

这样,我们就成功创建出了一个映射关系,但是这仅仅是单个映射,如果我们想要存储所有学生的学号映射关系,需要使用Map来实现,使用Map也非常简单:

1
2
3
4
5
6
val map: MutableMap<Int, Student> = mutableMapOf(
    10001 to Student("小明", 18),
    10002 to Student("小红", 17),
    10003 to Student("小刚", 19)
)
//使用mutableMapOf可以放入多个键值对,并生成一个Map对象

这样我们就成功地将所有的键值对存储在Map中了,我们接着来看看如何去访问,比如现在我们要查找指定学号的学生:

1
val student: Student? = map[10001]   //使用[]运算符通过Key查找Value

可以看到,使用方式与前面的集合类和数组非常类似,只不过访问的不再是下标,而是对应的Key。同时,这里得到的结果是一个可空类型的对象,为什么会像这样呢?

1
2
val student1: Student? = map[10001]   //得到小明这个对象
val student2: Student? = map[10005]   //Map中根本没有键为10005的键值对,得到的结果是null

当Map中不存在指定Key时,会直接得到null作为结果,因此我们在处理从Map返回的Value时,需要考虑空指针问题。

1
2
3
4
map.contains(1)   //判断是否包含指定Key
map.containsKey(1)   //同上
10001 in map    //同上
map.containsValue(Student("小明", 18))   //判断是包含指定Value

注意: Map中的键值对存储,只能通过Key去访问Value,而不能通过Value来反向查找Key,映射关系是单向的。

我们可以直接获取到Key和Value的集合:

1
2
val keys: MutableSet<Int> = map.keys   //以Set形式存储的[10001, 10002, 10003]
val value: Collection<Student> = map.values    //以Collection接口类型返回的 [小明 (18), 小红 (17), 小刚 (19)] 具体类型是Map的内部类实现的

遍历一个Map也很简单:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
map.forEach { (k, v) -> println("键: $k, 值 $v") }  //forEach提供两个参数,key和value

for (entry in map) {   //使用for循环也可以直接安排,这里得到的是一个entry
    entry.key
    entry.value
}

for ((key, value) in map) {  //也可以可以直接写成这样
    println("键: $key, 值 $value")
}

我们再来看看如何向Map中添加新的键值对:

1
2
map[10004] = Student("小强", 18)   //跟之前一样,直接对着赋值就行了
map.put(10004, Student("小强", 18))  //使用函数也可以,跟上面效果一样

你甚至还能像这样添加:

1
2
3
4
5
val newMap = map + (10004 to Student("小强", 18))   //添加新的键值对并生成一个新的Map
map += (10004 to Student("小强", 18))  //直接添加键值对到当前Map里面
map += mapOf(10004 to Student("小强", 18))  //或者添加其他Map到此Map中
map.putAll(mapOf(10004 to Student("小强", 18)))   //跟上面一样
map.putAll(setOf(10004 to Student("小强", 18)))   //键值对集合也可以的

不过需要注意的是,在Map中同样的Key只能存在一个,这跟Set是一样的:

1
2
map[10003] = Student("小强", 18)   //此时Map中已经存在Key为10003的键值对了,这会导致之前的结果被覆盖
println(map)   //结果为 {10001=小明 (18), 10002=小红 (17), 10003=小强 (18)}

当插入一个键值对时,如果存在相同Key会直接覆盖掉之前的键值对,我们可以通过函数的返回值来得到被覆盖的结果:

1
2
val old = map.put(10003, Student("小强", 18))   //put的返回值如果没有覆盖元素返回null,否则返回被覆盖的元素
println("被覆盖的$old")   //被覆盖的小刚 (19)

我们也可以直接移除不需要的键值对,同样是通过Key进行移除:

1
2
val old = map.remove(10001)   //使用remove函数移除指定键值对
println("被移除的$old")

各种花式移除:

1
2
map -= 10001   //等价于 map.remove(10001)
map -= listOf(10001, 10002)   //是的你没猜错,这个是批量移除

如果我们需要直接移除Value为某个Key的键值对,可以像这样:

1
map.values.remove(Student("小明", 18))   //直接从values中移除,会使得整个键值对被移除
1
2
3
4
5
6
7
val map: MutableMap<Int, Student> = mutableMapOf(
    10001 to Student("小明", 18),
    10002 to Student("小红", 17),
    10003 to Student("小明", 18)   //这里存在两个一样的元素
)
map.values.remove(Student("小明", 18))   //通过这种方式移除也只会移除按顺序下来的第一个
println(map)  // {10002=小红 (17), 10003=小明 (18)}

有些时候,Map返回的结果是可空类型给我们造成了很多麻烦,可以通过以下方式解决:

1
2
3
4
//使用getOrDefault在没有结果时返回给定的默认值
var student: Student = map.getOrDefault(10004, Student("小强", 16))
//跟上面一样,只不过是使用函数式返回默认值
var student: Student = map.getOrElse(10004){ Student("小强", 16) }
1
2
3
//这个不仅能返回默认值,还能顺手把默认值给加到Map里面去,很方便
var student: Student = map.getOrPut(10004){ Student("小强", 16) }
println(map)  //结果为 {10001=小明 (18), 10002=小红 (17), 10003=小刚 (19), 10004=小强 (16)}

有了Map之后,我们在处理一些映射关系的时候就很方便了。跟Set一样,官方也提供了多种多样的集合:

1
2
3
4
5
6
7
val map1 = mapOf(1 to "AAA")   //只读Map
val map2 = hashMapOf(1 to "AAA")   //不保存Key顺序的Map
val map3 = linkedMapOf(1 to "AAA")   //保存Key顺序的Map,跟mutableMapOf一样
val map4 = sortedMapOf(1 to "AAA")   //根据排序规则自动对Key排序的Map
val map5 = emptyMap<Int, String>()   //空Map
val hashMap = HashMap<Int, String>()   //采用构造函数创建的HashMap,不保存Key顺序的Map,同map2
val linkedHashSet = LinkedHashMap<Int, String>()   //采用构造函数创建的LinkedHashMap,保存Key顺序的Map,同map3

迭代器

我们在一开始提到,集合类型的顶层接口都是一个叫做Collection的接口:

1
2
3
public interface Collection<out E> : Iterable<E> {  //继承自Iterable接口
    ...
}

而在Iterable接口中,就定义了一个用于生成迭代器的函数:

1
2
3
4
5
6
public interface Iterable<out T> {
    /**
     * Returns an iterator over the elements of this object.
     */
    public operator fun iterator(): Iterator<T>
}

不仅仅是集合类,甚至在Array类中也定义了这样的函数:

1
2
3
4
5
6
7
8
public class Array<T> {
    ...

    /**
     * Creates an [Iterator] for iterating over the elements of the array.
     */
    public operator fun iterator(): Iterator<T>
}

迭代器是每个集合类、数组都可以生成的东西,它的作用很简单,就是用于对内容的遍历:

1
2
val list = listOf("AAA", "BBB", "CCC")
val iterator: Iterator<String> = list.iterator()   //通过iterator函数得到迭代器对象

那么这个迭代器该如何使用呢?先来看接口定义:

1
2
3
4
5
6
7
public interface Iterator<out T> {
    //获取下一个待遍历元素
    public operator fun next(): T

    //如果还有元素没有遍历,那么返回true否则返回false,而这个函数也是运算符重载函数正好对应着 for in 操作
    public operator fun hasNext(): Boolean
}

通过使用迭代器,我们就可以实现对集合中的元素的进行遍历,就像我们遍历数组那样,它的运作机制大概是:

一个新的迭代器就像上面这样,默认有一个指向集合中第一个元素的指针:

每一次next操作,都会将指针后移一位,直到完成每一个元素的遍历,此时再调用next将不能再得到下一个元素。至于为什么要这样设计,是因为集合类的实现方案有很多,可能是链式存储,也有可能是数组存储,不同的实现有着不同的遍历方式,而迭代器则可以将多种多样不同的集合类遍历方式进行统一,只需要各个集合类根据自己的情况进行对应实现就行了。

实际上迭代器的功能设计非常纯粹,就是看有没有下一个,有的话就拿出来,所以使用迭代器可以直接用一个while循环搞定:

1
2
3
4
val iterator: Iterator<String> = list.iterator()
while (iterator.hasNext()) {   //使用while不断判断是否存在下一个
    println(iterator.next())   //每次循环都取出一个
}

迭代器的出现,使得无论我们使用哪一种集合,都可以使用同样的方式对元素进行遍历,这统一了遍历操作的使用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
fun <T> forEach(iterator: Iterator<T>) {   //统一接收迭代器对象
    while (iterator.hasNext()) {
        println(iterator.next())
    }
}

fun main() {  //现在无论什么集合/数组都可以按照统一的方式去进行处理
    forEach(listOf("AAA", "BBB", "CCC").iterator())
    forEach(setOf("AAA", "BBB", "CCC").iterator())
    forEach(arrayOf("AAA", "BBB", "CCC").iterator())
    forEach(mapOf(1 to "AAA", 2 to "BBB",3 to "CCC").iterator())
}

注意,迭代器的使用是一次性的,用了之后就不能用了,如果需要再次进行遍历操作,那么需要重新生成一个迭代器对象。

只要是重写了operator fun iterator()函数的类型,都可以使用for..in这样的语法去进行遍历:

1
2
3
for (s in listOf("AAA", "BBB", "CCC")) {
   ...
}

因此,数组和集合类都支持使用for循环遍历也就不奇怪了,哪怕是我们自己定义的类,只要实现这个函数都是支持的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class Test : Iterable<String> {    //这个接口实不实现其实都无所谓
  	//实现这个运算符重载函数iterator是必须要的,否则不支持
    override operator fun iterator(): Iterator<String> = TestIterator()

    class TestIterator: Iterator<String> {  //自己随便写一个迭代器实现
        override fun hasNext(): Boolean = true
        override fun next(): String = "666"
    }
}

fun main() {
    val test = Test()
    for (s in test) {
        println(s)
    }
}

包括我们前面使用的Range语法,其本身也是一个支持生成迭代器的类:

1
2
val range: IntRange = 1..3
val iterator: Iterator<Int> = range.iterator()

实际上,所有使用for..in语法的操作,最后都会被编译为使用迭代器的while操作:

1
2
3
4
val list = mutableListOf("AAA", "BBB", "CCC")   //编译前
for (s in list) {
    list.add("DDD")
}
1
2
3
4
5
6
val list = mutableListOf("AAA", "BBB", "CCC")   //编译后
val iterator: Iterator<String> = list.iterator()
while (iterator.hasNext()) {
    val next = iterator.next()
    println(next)
}

是不是突然觉得有点豁然开朗?至此,我们已经完成解释清楚for..in操作的原理了。

特别的,对于List来说,它还有一个非常特殊的ListIterator迭代器:

1
2
3
4
val iterator: ListIterator<String> = list.listIterator()   //使用listIterator函数来获取ListIterator
println(iterator.next())  //不仅可以正着迭代
println(iterator.nextIndex())   //还可以直接告诉你下一个迭代的是List的第几个元素
println(iterator.previous())   //还能反着来

ListIterator迭代器是普通迭代器的强化版本,它可以实现列表中元素的双向遍历,并且可以得到当前迭代的元素下标。

最后,我们再来探讨一个之前可能遇到过的问题:

1
2
3
4
val list = mutableListOf("AAA", "BBB", "CCC")
for (s in list) {   //在遍历List时,不断往里面添加新的元素
    list.add("DDD")
}

此程序运行会直接得到一个报错:

在JVM环境下,Kotlin默认不支持在迭代时修改集合里面的内容,无论是插入新的元素还是移除元素,都会触发并发修改异常。为了解决这个问题,Kotlin为所有的MutableCollection(所有非只读集合类)提供了一个特殊的用于生成MutableIterator的函数,只要我们使用的不是只读的集合类,都可以获得这个特殊的迭代器,它支持在遍历时对元素进行删除:

1
2
3
4
5
6
val list = mutableListOf("AAA", "BBB", "CCC")
val iterator: MutableIterator<String> = list.iterator()
while (iterator.hasNext()) {
  	iterator.next()
    iterator.remove()   //删除当前迭代器已经遍历的最后一个元素
}

有关迭代器的相关知识就先到这里了。

集合与数组扩展操作

前面我们介绍了Kotlin提供的几个常用集合类,我们在使用这些集合类的时候,为了更加方便,官方提供了很多用于集合、数组类型的扩展操作,我们来学习一下吧,因为这些扩展操作数组和集合都可以使用,我们就尽量以List为例进行讲解。

首先是数组跟集合的联动,有些时候我们可能拿到的是一个数组对象,但是我们希望将其转换为集合类进行操作,我们可以使用数组提供的集合快速转换函数来进行转换:

1
2
3
4
5
val array = arrayOf("AAA", "BBB", "CCC")
val list: List<String> = array.toList()
val list: MutableList<String> = array.toMutableList()
val set: Set<String> = array.toSet()
val set: MutableSet<String> = array.toMutableSet()

这样,如果我们发现数组无法满足我们对于其元素的操作,可以直接转换为集合类进行操作,方便你我。

接下来是映射操作(注意这里说的map跟我们前面说的集合Map是两个概念,别搞混了)它可以将集合类、数组的元素类型进行转换,比如我们现在有一个字符串集合,但是我们现在希望把它变成记录每一个字符串长度的Int集合,该怎么做呢?

1
2
val list = listOf("AAA", "BB", "CCCCC")
val lenList: List<Int> = list.map { it.length }   //使用map函数,传入自定义的转换操作函数,就可以对元素进行转换了

我们可以利用这种操作来为里面的每一个元素添加编号:

1
2
3
val list = listOf("AAA", "BBB", "CCC")  //使用mapIndexed还可以额外附带一个index参数
val mapList: List<String> = list.mapIndexed { index, it -> "$index.${it}" }
println(mapList)  //结果 [0.AAA, 1.BBB, 2.CCC] 快速编号操作

利用映射操作,我们可以快速对集合中是元素依次进行修改,也可以对集合中的元素进行类型转换,非常方便。

对于Map类型,我们还可以单独对所有Key或是Value进行操作:

1
2
3
val numbersMap = mapOf("key1" to 1, "key2" to 2, "key3" to 3)
println(numbersMap.mapKeys { it.key.uppercase() })   //对所有的Key进行Map操作
println(numbersMap.mapValues { it.value + it.key.length })

我们接着来看下一个,压缩操作,它可以将当前集合元素和另一个集合中具有相同索引的元素组合起来,生成一个装满Pair的列表:

1
2
3
4
val list1 = listOf(1, 2, 3)
val list2 = listOf("AAA", "BBB", "CCC")
val pairs: List<Pair<Int, String>> = list1.zip(list2)
println(pairs)

利用压缩操作我们可以快速将两个List集合揉成一个Map集合:

1
2
3
val map = mutableMapOf<Int, String>()
map.putAll(list1.zip(list2))
println(map)  //结果 {1=AAA, 2=BBB, 3=CCC}

既然能压缩还能解压:

1
2
3
val list = listOf(1 to "AAA", 2 to "BBB", 3 to "CCC")  //把合在一起的Pair每个元素都分开
val unzipList: Pair<List<Int>, List<String>> = list.unzip()  //转换出来是一个存放两个List的Pair
println(unzipList)

有些时候我们还可以使用另一种方式将普通集合转换为Map映射,比如associate操作:

1
2
3
4
val list = listOf("AAA", "BBB", "CCC")
//使用associateWith快速构建以列表中每个元素为Key的Map映射
val associate: Map<String, Int> = list.associateWith { it.length }   //提供一个函数,返回值作为生成的Map对应Key的Value
println(associate)  //结果 {AAA=3, BBB=3, CCC=3}

还有对应的反向操作:

1
2
3
4
val list = listOf("AAA", "BBB", "CCC")
//使用associateBy快速构建以列表中每个元素为Value的Map映射
val associate: Map<Int, String> = list.associateBy { it.length }   //提供一个函数,返回值作为生成的Map对应Value的Key
println(associate)   //结果{3=CCC},因为上面生产出来的Key全是3,覆盖完只剩下最后一个了

如果你觉得以上两种方式都不是很灵活,你也可以自己根据情况自行构建一个Pair作为结果返回:

1
val associate: Map<String, Int> = list.associate { it to it.length }  //返回一个Pair

我们接着来看,对于一些嵌套集合和数组来说,有时候处理里面的数据会变得很棘手:

1
2
3
val list = listOf(listOf("AAA", "BBB"), listOf("CCC", "DDD"))
//现在我们想要遍历这个嵌套List中的每一个元素,需要两层for循环
list.forEach { it.forEach { item -> println(item) } }

那么有没有办法能够把这个嵌套的集合内所有的集合全部拆出来,全部存在一个不嵌套的集合中呢?我们可以使用扁平化操作:

1
2
3
val list = listOf(listOf("AAA", "BBB"), listOf("CCC", "DDD"))
val flatten: List<String> = list.flatten()   //使用flatten函数将嵌套集合扁平化
println(flatten)   //可以看到内容自动被展平了 [AAA, BBB, CCC, DDD]

结合之前学习的映射操作,我们还可以在展平元素的同时对元素进行映射,非常适合下面这种情况:

1
2
//把下面这个给展平
val list = listOf(Container(listOf("AAA", "BBB")), Container(listOf("CCC", "DDD")))

可以看到,这个List很恶心,它内层存放的集合是被套在一个对象中的,更准确的说,这是一个List<Container>类型的列表,但是现在我们希望的是取出里面每一个对象存储的List然后拿来展平,可以像这样:

1
2
3
//使用flatMap函数进行操作,支持自定义获取列表然后再进行扁平化操作
val flatList: List<String> = list.flatMap { it.list }   //通过Lambda将每一个Container映射为List
println(flatList)   //结果为:[AAA, BBB, CCC, DDD]

其实还有一个joinToString函数,但是前面数组部分已经讲解过了,使用方式是一样的,这里就不做介绍了。

有时候我们想要移除集合中某些不满足条件的元素,我们可以使用过滤操作来完成:

1
2
3
4
val list = listOf("AAA", "BB", "CCC")
//使用filter来过滤不满足条件的元素,这里的意思是只保留长度大于2的字符串
val filterList: List<String> = list.filter { it.length > 2 }
println(filterList)  //结果为:[AAA, CCC]
1
2
3
4
val numbersMap = mapOf("key1" to 1, "key2" to 2, "key3" to 3, "key11" to 11)
//Map同样支持这样的操作
val filteredMap = numbersMap.filter { (key, value) -> key.endsWith("1") && value > 10}
println(filteredMap)

还有快速过滤所有空值的操作:

1
2
3
val list = listOf("AAA", null, "BBB", null)
val filterList: List<String> = list.filterNotNull()
println(filterList)   //结果 [AAA, BBB]

甚至还可以快速过滤出指定类型的集合:

1
2
3
val list = listOf("AAA", Any(), "BBB", 123, 3.14)
val filterList: List<String> = list.filterIsInstance<String>()  //快速过滤出所有的String元素
println(filterList)   //结果 [AAA, BBB]

通过过滤操作可以快速筛选出我们需要的那些元素,当然,如果我们既需要筛选出来的元素,也需要筛选出去的元素,我们可以使用分区操作:

1
2
3
4
5
val list = listOf("AA", "BBB", "CC", "DDD")
//分区操作得到一个匹配列表和一个不匹配列表
val (match, mismatch) = list.partition { it.length > 2 }
println("匹配的列表: $match")
println("不匹配的列表: $mismatch")

不愧是Kotlin,甚至连一个筛选功能都可以做的这么全面。还有专门用于测试元素是否满足条件的:

1
2
3
4
val list = listOf("AA", "BBB", "CC", "DDD")
list.any { it.length > 2 }  //判断集合中是否至少存在一个元素满足条件
list.none { it.length > 2 }  //判断集合中是否不存在任何一个元素满足条件
list.all { it.length > 2 }   //判断集合中是否每一个元素都满足条件

我们接着来看非常实用的分组操作,它可以将元素按类别进行分组,以Map的形式返回:

1
2
3
val list = listOf("AA", "BBB", "CC", "DDD")
println(list.groupBy { it.length })  //按照字符串的长度进行分组
//得到 {2=[AA, CC], 3=[BBB, DDD]}

我们接着来看对于集合的裁剪相关操作,首先是对一个集合进行切片,比如我们只想要其中一段元素:

1
2
3
4
5
6
7
8
val list = listOf("AA", "BBB", "CC", "DDD")
println(list.slice(1..2))   //截取从第二个元素到第三个元素共两个元素的List片段,结果:[BBB, CC]
println(list.take(2))  //使用take获取从第一个元素开始的长度为N的元素片段,结果:[AA, BBB]
println(list.takeLast(2)) //同上,但是从尾部倒着来,结果:[CC, DDD]
println(list.drop(2))   //这个跟take是反着的,直接跳过前N个元素截取,结果:[DDD]
println(list.dropLast(2))  //不用多说了吧
println(list.takeWhile { it.length > 2 })   //从第一个元素开始,依次按照给定的函数进行判断,如果判断成功则添加到返回列表中,直到遇到一个不满足条件的就返回,这里的结果就是 [AA]
...

前面我们介绍了嵌套集合的扁平化,那有没有办法吧扁平化的集合给重新分块呢?

1
2
3
val list = listOf("AA", "BBB", "CC", "DDD")
//使用chunked进行分块,这里2个元素为一组进行分块,得到一个嵌套的集合
println(list.chunked(2))   //结果 [[AA, BBB], [CC, DDD]]

有关集合相关的扩展操作,我们就先介绍到这里了,想要了解更多集合特性的小伙伴请参考官网:https://kotlinlang.org/docs/collections-overview.html

序列

除了集合,Kotlin标准库还包含另一种类型:序列Sequence)与集合不同,序列不包含元素,它在运行时生成元素,Sequence与Iterable接口功能相似,接口定义如下,同样只包含一个生成迭代器的函数:

1
2
3
public interface Sequence<out T> {
    public operator fun iterator(): Iterator<T>
}

那既然功能一样,为什么要专门搞一个序列呢?这不是多此一举吗?序列实际上是一个延迟获取数据的集合,只有需要元素时才会生产元素并提供给外部,包括所有对元素操作,并不是一次性全部处理,而是根据情况选择合适的元素进行操作。使用序列能够在处理大量数据时获得显著的性能提升。

要创建一个序列非常简单,使用generateSequence函数即可:

1
2
3
4
val sequence: Sequence<Int> = generateSequence {
    println("生成一个新的元素")
    10   //返回值就是生成的元素
}

可以看到generateSequence得到的结果并没有在一开始执行println,因为序列的数据处理是惰性的,在我们添加

1
sequence.forEach { println(it) }

此时控制台才开始打印生成的Lambda函数。同样的,所有扩展操作同样是惰性的,我们可以来比较一下:

1
2
3
4
5
6
7
8
9
val list = listOf("AA", "BBB", "CCC", "DD", "EEE", "FF", "GGG", "HH")
//以下操作用于获取前两个长度大于2的字符串,并进行小写转换操作
val result = list.filter {
    println("进行过滤操作: $it")
    it.length > 2
}.map {
    println("进行小写转换操作")
    it.lowercase()
}.take(2)

可以看到,在直接使用集合的情况下,整个工序是按照顺序在向下执行的,并且每一道工序都会对所有的元素依次进行操作,但是实际上根据我们的要求,最后只需要两个满足条件的即可,如果这个是一个数据量非常庞大的集合,会导致执行效率很低。我们现在换成序列试试看:

1
2
3
4
5
6
7
8
9
//使用asSequence函数将集合转换为一个序列
val result = list.asSequence().filter {
    println("进行过滤操作: $it")
    it.length > 2
}.map {
    println("进行小写转换操作")
    it.lowercase()
}.take(2)
println(result)  //如果这句不执行,不获取元素,以上整个操作都是不会进行的

可以看到,序列根据我们的操作流程,对数据的操作也进行了优化,执行次数明显减少,并且使用序列后只有我们从序列读取数据时才会开始执行我们定义好的工序,可见,序列执行的各种操作,仅仅是记录到序列中,并没有在一开始就执行,而是需要的时候才开始获取,因此才可以做到上面这样的操作。

这与Java中的Stream非常相似。

当然,序列并不是随时随地都可以使用的,我们还是要根据实际情况决定是否要使用序列,如果在数据量特别庞大的情况下,使用序列处理会更好,但是如果数据量很小,使用序列反而会增加开销。

特殊类型介绍

除了我们之前学习的普通class类型之外,Kotlin还为我们提供了更多种类的类型,以应对不同的情况。

这些特殊类型本质上依然是class但是存在一些限制或是特殊情况。

数据类型

对于那些只需要保存数据的类型,我们常常需要为其重写toStringequals等函数,针对于这种情况下,Kotlin为我们提供了专门的数据类,数据类不仅能像普通类一样使用,并且自带我们需要的额外成员函数,比如打印到输出、比较实例、复制实例等。

声明一个数据类非常简单:

1
2
//在class前面添加data关键字表示为一个数据类
data class User(val name: String, val age: Int)

数据类声明后,编译器会根据主构造函数中声明的所有属性自动为其生成以下函数:

  • .equals()/.hashCode()
  • .toString()生成的字符串格式类似于"User(name=John, age=42)"
  • .componentN()与按声明顺序自动生成用于解构的函数
  • .copy()用于对对象进行拷贝

我们可以来试试看:

1
2
3
4
5
6
7
8
fun main() {
    val user1 = User("小明", 18)
    val user2 = User("小明", 18)
    println(user1)   //打印出来就是格式化的字符串 User(name=小明, age=18)
    println(user1 == user2)   //true,因为自动重写了equals函数
    val (name, age) = user1   //自动添加componentN函数,因此支持解构操作
    println("名称: $name, 年龄: $age")
}

当然,为了确保生成代码的一致性和有效性,数据类必须满足以下要求:

  • 主构造函数中至少有一个参数。
  • 主构造函数中的参数必须标记为valvar
  • 数据类不能是抽象的、开放的、密封的或内部的。

此外,数据类的成员属性生成遵循有关成员继承的以下规则:

  • 如果数据类主体中.equals() .hashCode().toString()等函数存在显式(手动)实现,或者在父类中有final实现,则不会自动生成这些函数,并使用现有的实现。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    data class User(val name: String, val age: Int) {
      	//如果已经存在toString的情况下,不会自动生成
        override fun toString(): String = "我是自定义的toString"
    }
    
    fun main() {
        val user = User("小明", 18)
        println(user)   //结果: 我是自定义的toString
    }
    
  • 如果超类型具有open .componentN()函数并返回兼容类型,则为数据类生成相应的函数,并覆盖超类型的函数。如果由于一些关键字导致无法重父类对应的函数会导致直接报错。

    1
    2
    3
    4
    5
    6
    
    abstract class AbstractUser {
      	//此函数必须是open的,否则无法被数据类继承
        open operator fun component1() = "卢本伟牛逼"
    }
    
    data class User(val name: String, val age: Int): AbstractUser()  //自动覆盖掉父类的component1函数
    
  • 不允许为.componentN().copy()函数提供显式实现。

注意,编译器会且只会根据主构造函数中定义的属性生成对应函数,如果有些时候我们不希望某些属性被添加到自动生成的函数中,我们需要手动将其移出主构造函数:

1
2
3
data class Person(val name: String) {
    var age: Int = 0   //age属性不会被处理
}

此时生成的所有函数将不会再考虑age属性:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
fun main() {
    val person1 = Person("John")
    val person2 = Person("John")
    person1.age = 10
    person2.age = 20

    println("person1 == person2: ${person1 == person2}")
    // person1 == person2: true
    println("person1 with age ${person1.age}: $person1")
    // person1 年龄为 10: Person(name=John)
    println("person2 with age ${person2.age}: $person2")
    // person2 年龄为 20: Person(name=John)
}

数据类自带一个拷贝对象的函数,使用.copy()函数复制对象,允许您更改其一些属性,而其余的保持不变。此函数对上述User类的实现如下:

1
2
3
4
5
6
7
8
data class User(val name: String, val age: Int)

fun main() {
    val user = User("小明", 18)
    val copyUser = user.copy()   //使用拷贝函数生成一个内容完全一样的新对象
    println(user == copyUser)
    println(user === copyUser)
}

在copy函数还可以在拷贝过程中手动指定属性的值:

1
2
val user = User("小明", 18)
println(user.copy(age = 17))   //结果为 User(name=小明, age=17)

枚举类型

我们知道,在Kotlin中有一个Boolean类型,它只有两种结果,要么为false要么为true,这代表它的两种状态真和假。有些时候,可能两种状态并不能满足我们的需求,比如一个交通信号灯,它具有三种状态:红灯、黄灯和绿灯。

如果我们想要存储和表示自定义的多种状态,使用枚举类型就非常合适:

1
2
3
4
//在类前面添加enum表示这是一个枚举类型
enum class LightState {
    GREEN, YELLOW, RED   //直接在枚举类内部写上所有枚举的名称,一般全部用大写字母命名
}

枚举类的值只能是我们在类中定义的那些枚举,不可以存在其他的结果,枚举类型同样也是一个普通的类,只是存在值的限制。

要使用一个枚举类的对象,可以通过类名直接获取定义好的枚举:

1
2
3
4
fun main() {
    val state: LightState = LightState.RED  //直接得到红灯
  	println(state.name)   //自带name属性,也就是我们编写的枚举名称,这里是RED
}

同样的,枚举类也可以具有成员:

1
2
3
4
5
6
//同样可以定义成员变量,但是不能命名为name,因为name拿来返回枚举名称了
enum class LightState(val color: String) {
    GREEN("绿灯"), YELLOW("黄灯"), RED("红灯");  //枚举在定义时也必须填写参数,如果后面还要编写成员函数之类的其他内容,还需在末尾添加分号结束

  	fun isGreen() = this == LightState.GREEN  //定义一个函数也是没问题的
}

我们可以像普通类那样正常使用枚举类的成员:

1
2
3
val state: LightState = LightState.RED
println("信号灯的颜色是: ${state.color}")
println("信号灯是否可以通行: ${state.isGreen()}")

枚举类型可以用于when表达式进行判断,因为它的状态是有限的:

1
2
3
4
5
6
7
val state: LightState = LightState.RED
val message: String = when(state) {
    LightState.RED -> "禁止通行"
    LightState.YELLOW -> "减速通行"
    LightState.GREEN -> "正常通行"
}
println(message)   //结果为: 禁止通行

在枚举类中也可以编写抽象函数,抽象函数需要由枚举自行实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
enum class LightState(val color: String) {
    GREEN("绿灯"){
        override fun test() = println("我是绿灯,表示可以通过")
    }, YELLOW("黄灯") {
        override fun test() = println("我是黄灯,是让你减速,不是让你踩油门加速过去")
    }, RED("红灯") {
        override fun test() = println("我是红灯,禁止通行")
    };
    abstract fun test()   //抽象函数
}

fun main() {
    val state: LightState = LightState.RED
    state.test()   //调用函数: 我是红灯,禁止通行
}

如果枚举类实现了某个接口,同样可以像这样去实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
interface Message {
    fun test()
}

enum class LightState(val color: String) : Message {
    GREEN("绿灯"){
        override fun test() = println("我是绿灯,表示可以通过")
    }, YELLOW("黄灯") {
        override fun test() = println("我是黄灯,是让你减速,不是让你踩油门加速过去")
    }, RED("红灯") {
        override fun test() = println("我是红灯,禁止通行")
    };
}
1
2
3
4
enum class LightState(val color: String) : Message {
    GREEN("绿灯"), YELLOW("黄灯"), RED("红灯");
    override fun test() = println("")
}

枚举类也为我们准备了很多的函数:

1
2
3
4
5
val state: LightState = LightState.valueOf("RED")   //通过valueOf函数以字符串名称的形式转换为对应名称的枚举
val state: LightState = enumValueOf<LightState>("RED")   //同上
println(state)
println(state.ordinal)   //枚举在第几个位置
println(state.name)   //枚举名称
1
2
3
val entries: EnumEntries<LightState> = LightState.entries  //一键获取全部枚举,得到的结果是EnumEntries类型的,他是List的子接口,因此可以当做List来使用
val values: Array<LightState> = enumValues<LightState>()   //或者像这样以Array形式获取到所有的枚举
println(entries)

匿名类和伴生对象

有些时候,可能我们并不需要那种通过class关键字定义的对象,而是以匿名的形式创建一个临时使用的对象,在使用完之后就不再需要了,这种情况完全没有必要定义一个完整的类型,我们可以使用匿名类的形式去编写。

1
2
3
4
5
val obj = object {   //使用object关键字声明一个匿名类并创建其对象可以直接使用变量接收得到的对象
  	val name: String = ""
    override fun toString(): String = "我是一个匿名类"   //匿名类默认继承于Any,可以直接重写其toString
}
println(obj)

可以看到,匿名类除了没名字之外,也可以定义成员,只不过这种匿名类不能定义任何构造函数,因为它是直接创建的,这种写法我们也可以叫做对象表达式

匿名类不仅可以直接定义,也可以作为某个类的子类定义,或是某个接口的实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
interface Person {
    fun chat()
}

fun main() {
    val obj: Person = object : Person {   //直接实现Person接口
        override fun chat() = println("牛逼啊")
    }
    obj.chat()  //当做Person的实现类使用
}
1
2
3
4
5
6
7
8
9
interface Person
open class Human(val name: String)

fun main() {
    val obj: Human = object : Human("小明"), Person {   //继承类时,同样需要调用其构造函数
        override fun toString() = "我叫$name"   //因为是子类,直接使用父类的属性也是没问题的
    }
    println(obj)
}

可以看到,平时我们无法直接实例化的接口或是抽象类,可以通过匿名类的形式得到一个实例化对象。

我们再来看下面这种情况:

1
2
3
interface KRunnable {
    fun invoke()   //此类型是一个接口且只包含一个函数
}

根据我们上面学习的用法,如果我们想要使用其匿名类,可以像这样编写:

1
2
3
4
5
6
7
8
fun main() {
    val runnable = object : KRunnable {   //使用匿名类的形式编写KRunnable的实现类对象
        override fun invoke() {
            println("我是函数invoke的实现")
        }
    }
    runnable.invoke()
}

特别的,对于只存在一个抽象函数的接口称为函数式接口单一抽象方法(SAM)接口,函数式接口可以有N个非抽象成员,但是只能有一个抽象成员。对于函数式接口,可以使用我们前面介绍的Lambda表达式来使代码更简洁:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
fun interface KRunnable {  //在接口声明前面添加fun关键字
    fun invoke()
}

...

fun main() {
    val runnable = KRunnable {   //支持使用Lambda替换
        println("我是函数invoke的实现")
    }
    runnable.invoke()
}

我们再来看下面这种情况:

1
2
3
4
5
6
7
fun interface Printer {
    fun print()
}

fun test(printer: Printer) {   //需要Printer接口实现对象
    printer.print()
}

我们在调用test时,也可以写的非常优雅:

1
2
3
4
5
fun main() {
    test {   //直接Lambda一步到位
        println("Hello World")
    }
}

正是因为有了匿名类,所以有些时候我们通过函数得到的结果,可能并不是某个具体定义的类型,也有可能是直接采用匿名形式创建的匿名类对象:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
open class Human(val name: String)

fun test() = object: Human("小明") {  //返回的一个匿名类对象
  	val age: Int = 10
    override fun toString() = "我叫$name"
}

fun main() {
    println(test().name)
    println(test().age)  //编译错误,因为返回的类型是Human,由于其匿名特性,只能当做Human使用
}

object关键字除了用于声明匿名类型,也可以用于声明单例类。单例类是什么意思呢?就像它的名字一样,在整个程序中只能存在一个对象,也就是单个实例,不可以创建其他的对象,始终使用的只能是那一个对象。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
object Singleton {   //声明的一个单例类
    private var name = "你干嘛"
    override fun toString() = "我叫$name"
}

fun main() {
    val singleton = Singleton  //通过类名直接得到此单例类的对象
  	//不可以通过构造函数的形式创建对象
    println(singleton)
}
1
2
3
4
5
6
7
object Singleton {
    fun test() = println("原神,启动!")
}

fun main() {
    Singleton.test()   //单例定义的函数直接使用类名即可调用
}

用起来与Java中的静态属性挺像的,只不过性质完全不一样。单例类的这种性质在很多情况下都很方便,比如我们要编写某些工具操作,可以直接使用单例类的形式编写。

现在我们希望一个类既支持单例类那样的直接调用,又支持像一个普通class那样使用,这时该怎么办呢?

我们可以使用半生对象来完成,实际上就是将一个单例类写到某个类的内部:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class Student(val name: String, val age: Int) {
  	//使用companion关键字在内部编写一个伴生对象,它同样是单例的
    companion object Tools {
      	//伴生对象定义的函数可以直接通过外部类名调用
        fun create(name: String, age: Int) = Student(name, age)
    }
}

fun main() {
  	//现在Student不仅具有对象的函数,还可以通过类名直接调用其伴生对象通过的函数
    val student = Student.create("小明", 18)
  	println(student.toString())
}

伴生对象在Student类加载的时候就自动创建好了,因此我们可以实现直接使用。

委托模式

在有些时候,类的继承在属性的扩展上起到了很大的作用,通过继承我们可以直接获得某个类的全部属性,而不需要再次进行编写,不过,现在有了一个更好的继承替代方案,那就是委托模式(在设计模式中也有提及)名字虽然听着很高级,但是其实很简单,比如我们现在有一个接口:

1
2
3
interface Base {
    fun print()
}

正常情况下,我们需要编写一个它的实现类:

1
2
3
class BaseImpl(val x: Int) : Base {
    override fun print() = println(x)
}

现在我们换一个思路,我们再来创建一个实现类:

1
2
3
class Derived(val base: Base): Base {   //将一个Base的实现类作为属性保存到类中,同样实现Base接口
    override fun print() = base.print()   //真正去实现这个接口的,实际上并不是当前类,而是被拉进来的那个替身
}

这就是一个非常典型的委托模型,且大量实践已证明委托模式是实现继承的良好替代方案。

Kotlin对于这种模式同样给予了原生支持:

1
2
3
4
5
6
7
8
9
interface Base {
    fun print()
}

class BaseImpl(val x: Int) : Base {
    override fun print() = println(x)
}

class Derived(val b: Base): Base by b  //使用by关键字将所有接口待实现操作委托给指定成员

这样就可以轻松实现委托模式了。

除了类可以委托给其他对象之外,类的成员属性也可以委托给其他对象:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import kotlin.reflect.KProperty

class Example {
    var p: String by Delegate()  //属性也可以使用by关键字委托给其他对象
}

// 委托的类
class Delegate {
  	//需要重载属性的获取和设置两个运算
    operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
        return "$thisRef, 这里委托了 ${property.name} 属性"
    }

    operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
        println("$thisRef${property.name} 属性赋值为 $value")
    }
}

fun main() {
    println(Example().p)
}

不过,自己去定义一个类来进行委托实在是太麻烦了,Kotlin在标准库中也为我们提供了大量的预设函数:

1
2
3
4
5
6
7
class Example {
    val p: String by lazy { "牛逼啊" }   //lazy为我们生成一个委托对象,这样在获取属性值的时候就会执行lazy里面的操作了,看起来效果就像是延迟执行一样,由于只能获取,所以说只支持val变量
}

fun main() {
    println(Example().p)
}

也可以设置观察者,实时观察变量的变化:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Example {
    var p: String by Delegates.observable("我是初始值") {
            prop, old, new ->
        println("检测到$prop 的值发生变化,旧值:$old -> 新值:$new")
    }
}

fun main() {
    Example().p = "你干嘛"
}

属性也可以直接将自己委托给另一个属性:

1
2
3
4
5
6
7
8
class Example(var a: String) {
    var p: String by ::a   //使用双冒号来委托给其他属性
}

fun main() {
    val example = Example("你干嘛")
    println(example.p)
}

相信各位应该能猜到,这样委托给其他属性,当前属性的值修改,会直接导致其他属性的值也会修改,相反同样它们已经被相互绑定了。

属性也可以被委托给一个Map来进行存储:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class User(map: MutableMap<String, Any>) {
    var name: String by map   //直接委托给外部传入的Map集合
    var age: Int by map   //变量的值从Map中进行存取
    override fun toString(): String = "名称: $name, 年龄: $age"
}

fun main() {
    val map: MutableMap<String, Any> = mutableMapOf(
        "name" to "John Doe",
        "age"  to 25
    )
    val user = User(map)
    println(user)   //名称: John Doe, 年龄: 25
  	map["age"] = 10   //映射的值修改会直接影响变量的值
    println(user)  //名称: John Doe, 年龄: 10
}

注意,在使用不可变的Map时,只能给val类型变量进行委托,因为不可修改。

密封类型

有些时候,我们可能会编写一些类给其他人使用,但是我们不希望他们随意继承使用我们提供的类,我们只希望在我们提供的框架内部自己进行使用,这时我们就可以将类或接口设定为密封的。

密封类的所有直接子类在编译时都是已知的。不得在定义密封类的模块和包之外出现其他子类。例如,第三方项目无法在其代码中扩展您的密封类。因此,密封类的每个实例都有一个来自预设好的类型,且该类型在编译该类时是已知的。

1
2
3
4
package com.test

sealed class A   //声明密封类很简单,直接添加sealed关键字即可
sealed class B: A()   //密封类同一个模块或包中可以随意继承,并且子类也可以是密封的

当我们在其他包中使用这个密封类,在其他包或模块中无法使用:

1
2
3
4
5
class C: A()   //编译错误,不在同一个模块

fun main() {
    val b = B()   //编译错误,不可以实例化
}

密封类将类的使用严格控制在了模块内部,包括密封接口及其实现也是如此:一旦编译了具有密封接口的模块,就不会出现新的实现类。

从某种意义上说,密封类类似于枚举类:枚举类型的值数量也受到限制,由我们自己定义,但每个枚举变量仅作为单个实例存在,而密封类的子类可以有多个实例,每个实例都有自己的状态。密封类本身也是抽象的,它不能直接实例化,并且可以具有abstract成员:

1
2
3
4
sealed class A
sealed class B: A() {
    abstract fun test()
}

密封类继承后也可以使其不继续密封,让外部可以正常使用:

1
2
3
4
sealed class A
class B: A()
class C: A()
class D: A() //不添加sealed关键字使其不再密封

但是由于类A是密封的,因此所有继承自A的类只能是我们自己写的,别人无法编写继承A的类,除非我们将某个继承A的类设定为open特性,允许继承。因此,这也进一步证明密封类在一开始就确定了有哪些子类。

由于密封类能够确定,所以在使用when进行类型判断时,也是有限的:

1
2
3
4
5
6
7
8
fun main() {
    val a: A = C()
    when(a) {
        is B -> println("是B")
        is C -> println("是C")
        is D -> println("是D")
    }
}

密封类的应用场景其实远不止这些,由于篇幅有限,这里就不展开讲解了。

异常机制

在理想的情况下,我们的程序会按照我们的思路去运行,按理说是不会出现问题的,但是,代码实际编写后并不一定是完美的,可能会有我们没有考虑到的情况,如果这些情况能够正常得到一个错误的结果还好,但是如果直接导致程序运行出现问题了呢?

我们来看下面这段代码:

1
2
3
4
5
6
7
fun main() {
    test(1, 0) //当b为0的时候,还能正常运行吗?
}

private fun test(a: Int, b: Int): Int {
    return a / b //没有任何的判断而是直接做计算
}

1怎么可能去除以0呢,数学上有明确规定,0不能做除数,所以这里得到一个异常:

那么这个异常到底是什么样的一种存在呢?当程序运行出现我们没有考虑到的情况时,就有可能出现异常或是错误!它们在默认情况下会强行终止我们的程序。

异常的使用

我们在之前其实已经接触过一些异常了,比如数组越界异常,空指针异常,算术异常等,他们其实都是异常类型,我们的每一个异常也是一个类,他们都继承自Throwable类!异常类型本质依然类的对象,但是异常类型支持在程序运行出现问题时抛出(也就是上面出现的红色报错)也可以提前声明,告知使用者需要处理可能会出现的异常!

每个异常对象都包含一条消息、一个堆栈跟踪和一个可选原因。

我们自己也可以抛出异常,要抛出异常对象,请使用throw出表达式:

1
2
3
4
fun main() {
  	//Exception继承自Throwable类,作为普通的异常类型
    throw Exception("牛逼")
}

可以看到,控制台出现了下面的报错:

所以,我们平时看到的那些丰富多彩的异常,其实大部分都是由程序主动抛出的。

我们也可以依葫芦画瓢,自定义我们自己的异常类:

1
2
3
4
5
class TestException(message: String) : Exception(message)

fun main() {
    throw TestException("自定义异常")
}

是不是感觉很简单,异常的出现就是为了方便我们快速判断程序的错误。我们可以在异常打印出来的栈追踪信息中得到当前程序出现问题的位置:

这里指示的很明确,是我们的Main.kt文件第四行代码出现了异常。

异常的处理

当程序没有按照我们理想的样子运行而出现异常时(JVM平台下,默认会交给JVM来处理,JVM发现任何异常都会立即终止程序运行,并在控制台打印栈追踪信息)现在我们希望能够自己处理出现的问题,让程序继续运行下去,就需要对异常进行捕获,比如:

1
2
val array = arrayOf(1, 2, 3)
println(array[3])   //数组长度根本没有4,很明显这里会出现异常

现在我们希望能够手动处理这种情况,即使发生异常也要继续下去,我们可以使用try-catch语句块来完成:

1
2
3
4
5
6
7
8
9
fun main() {
    try {    //使用try-catch语句进行异常捕获
        val array = arrayOf(1, 2, 3)
        println(array[3])
    } catch (e: ArrayIndexOutOfBoundsException) {
        //因为异常本身也是一个对象,catch中实际上就是用一个局部变量去接收异常
    }
    println("程序继续正常运行!")
}

我们可以将代码编写到try语句块中,只要是在这个范围内发生的异常,都可以被捕获,使用catch关键字对指定的异常进行捕获,这里我们捕获的是ArrayIndexOutOfBoundsException数组越界异常:

可以看到,当我们捕获异常之后,程序可以继续正常运行,并不会像之前一样直接结束掉。

注意,catch中捕获的类型只能是Throwable的子类,也就是说要么是抛出的异常,要么是错误,不能是其他的任何类型。

我们可以在catch语句块中对捕获到的异常进行处理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
fun main() {
    try {    //使用try-catch语句进行异常捕获
        val array = arrayOf(1, 2, 3)
        println(array[3])
    } catch (e: ArrayIndexOutOfBoundsException) {
        e.printStackTrace();   //打印栈追踪信息
        println("异常错误信息:"+e.message);   //获取异常的错误信息
    }
    println("程序继续正常运行!")
}

当代码可能出现多种类型的异常时,我们希望能够分不同情况处理不同类型的异常,就可以使用多重异常捕获:

1
2
3
4
5
6
7
8
9
try {
    //....
} catch (e: Exception) {  //父类型在前,会将子类的也捕获

} catch (e: NullPointerException) {   //因为NullPointerException是Exception的子类型,永远都不会进入这里

} catch (e: IndexOutOfBoundsException) {   //永远都不会进入这里

}

最后,当我们希望,程序运行时,无论是否出现异常,都会在最后执行任务,可以交给finally语句块来处理:

1
2
3
4
5
6
7
try {
    //....
} catch (e: Exception) {

} finally {
    println("lbwnb") //无论是否出现异常,都会在最后执行
}

注意:try语句块至少要配合catchfinally中的一个。

try也可以当做一个表达式使用,这意味着它可以有一个返回值:

1
2
3
4
5
fun main() {
    val input = readln()
    val a: Int? = try { input.toInt() } catch (e: NumberFormatException) { null }
    println(a)
}

针对于空类型,我们也可以在判断为空时直接抛出异常:

1
val s = person.name ?: throw IllegalArgumentException("Name required")
0%