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 是一种现代化的静态类型编程语言,具有以下优势:
- 与Java互操作性强:Kotlin 可以与现有的 Java 代码无缝地互操作,允许开发者在现有的项目中逐步采用 Kotlin,而不需要重写整个项目。这使得 Kotlin 成为 Android 应用开发的理想选择。
- 简洁易读:Kotlin 的语法简洁并具有更好的可读性,减少了样板代码的编写。相比 Java,Kotlin 可以使用更少的代码来实现同样的功能,从而提高开发效率。
- 空安全性:Kotlin 对空值进行了更好的处理。在 Kotlin 中,变量默认是非空的,如果需要使用可能为空的值,需要显式声明类型为可空。这有助于减少空指针异常的发生。
- 函数式编程支持:Kotlin 支持函数式编程的特性,如高阶函数、lambda 表达式和函数式编程的集合操作等。这些特性可以让开发者编写更简洁、可维护的代码,并提高代码的表达能力。
- 扩展函数:Kotlin 允许开发者为某个类添加新的方法,而不需要修改该类的源代码。这种扩展函数的特性可以为开发者提供更灵活的方式来扩展现有的类库。
- 协程支持: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
(如果没有显示后缀名,需要在文件资源管理器中开启一下)然后用记事本打开,输入以下内容:
|
|
现在看不懂代码没关系,直接用就行,我们后面会一点一点讲解的。
编辑好之后,保存退出,接着我们将文件的后缀名称修改为.kt
这是Java源程序文件的后缀名称:
此时我们打开CMD,注意要先进入到对应的路径下,比如我们现在的路径:
我们使用cd
命令先进入到这个目录下:
要编译一个Kotlin程序,我们需要使用kotlinc
命令来进行,将我们的程序编译为jar包,并包含Kotlin的运行时依赖:
|
|
执行后,可以看到目录下多出来了一个.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语言的语法了。
程序代码基本结构
还记得我们之前使用的示例代码吗?
|
|
这段代码要实现的功能很简单,就是将 Hello World 这段文本信息输出到控制台。
在编写代码时,注意需要区分大小写,Kotlin语言严格区分大小写,如果我们没有按照规则来编写,那么就会出现红色波浪线报错:
只要源代码中存在报错的地方,就无法正常完成编译得到字节码文件,强行运行会提示构建失败:
注意这里包括的花括号是成对出现的,并且一一对应。
所以说各位小伙伴在编写代码时一定要注意大小写。然后第二行,准确的说是最外层花括号内部就是:
|
|
可以看到外面使用花括号前添加了fun main()
,这是我们整个程序的入口点,我们的Kotlin程序也是从这里开始从上往下执行的。而其中的println
语句就是用于打印其括号中包裹的文本,我们可以看到这个文本信息使用了""
进行囊括,否则会报错:
|
|
这段代码的意思就是将双引号括起来的内容(字符串,我们会在后面进行讲解)输出(打印)到控制台上。
比如下面的代码,我们就可以实现先打印Hello World!,然后再打印 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使用的就是补码!)在了解了计算机底层的数据表示形式之后,我们再来学习这些基本数据类型就会很轻松了。
变量的声明与使用
要声明一个变量,我们需要使用以下格式:
|
|
这里的数据类型我们会在下节课开始逐步讲解,比如整数就是Int
类型,不同类型的变量可以存储不同的类型的值。后面的变量名称顾名思义,就像x
一样,这个名称我们可以随便起一个,但是注意要满足以下要求:
- 标识符可以由大小写字母、数字、下划线(_)和美元符号($)组成,但是不能以数字开头。
- 变量不能重复定义,大小写敏感,比如A和a就是两个不同的变量。
- 不能有空格、@、#、+、-、/ 等符号。
- 应该使用有意义的名称,达到见名知意的目的(一般我们采用英文单词),最好以小写字母开头。
- 不可以是 true 和 false。
- 不能与Kotlin语言的关键字或是基本数据类型重名
当然各位小伙伴没必要刻意去进行记忆有哪些关键字,我们会在学习的过程中逐步认识到这些关键字。新手要辨别一个单词是否为关键字,只需要通过IDEA的高亮颜色进行区分即可,比如:
深色模式下,关键字会高亮为橙色,浅色模式下会高亮为深蓝色,普通的代码都是正常的灰白色。
比如现在我们想要定义一个整数(Int)类型的变量a
,那么就可以这样编写:
|
|
但是这个变量一开始没有任何值,比如现在我们要让这个变量表示10,那么就可以将10赋值给这个变量:
|
|
不过由于变量在一开始就被赋值为10这个整数,此时类型是确定的,Kotlin的编译器非常聪明,它支持自动推断类型,这里会自动将变量a的类型推断为Int类型,我们可以直接省略掉后面的Int类型:
|
|
或者我们可以在使用时再对其进行赋值:
|
|
是不是感觉跟数学差不多?这种写法对于我们人来说,实际上是很好理解的,意思表达很清晰。为了更直观地查看变量的值,我们可以直接将变量的值也给打印到控制台:
|
|
变量的值也可以在中途进行修改:
|
|
变量的值也可以直接指定为其他变量的值:
|
|
我们还可以让变量与数值之间做加减法(运算符会在后面详细介绍):
|
|
对于那些只读的变量,我们可以将其表示为一个常量,使用val
关键字:
|
|
编译时得到报错:
常量的值只有第一次赋值可以修改,其他任何情况下都不行:
|
|
至此,声明变量和常量我们就介绍完毕了,下一部分我们将介绍常见的一些数据类型。
数字类型介绍
前面我们了解了如何创建变量,并进行使用,但是我们知道,不同的数据往往对应着不同的类型,比如整数我们使用的就是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类型:
|
|
对于一些比较长的数字,我们可能需要使用类似于分隔符一类的东西来方便我们计数,比如:
银行往往把1000000000这种长数字记为1,000,000,000,这样看起来会更直观
在Kotlin中也可以像这样去编写:
|
|
数字类型不仅可以写成十进制,也可以以十六进制或是二进制表示(Kotlin不支持八进制表示)只需要添加对应的前缀即可,比如一个十六进制数据:
|
|
因为十六进制中大于等于十的数据没有对应的阿拉伯数字可以表示,所以在计算机中就以ABCDEF来替代这无法表示的6个数字。并且我们需要在数字前面添加0x表示这是16进制的数字,接下来是2进制:
|
|
除了整数类型外,Kotlin还为无符号整数提供以下类型:
UByte
:一个无符号8位整数,范围从0到255UShort
:无符号16位整数,范围从0到65535UInt
:一个无符号32位整数,范围从0到2^32 - 1ULong
:一个无符号64位整数,范围从0到2^64 - 1
为了使无符号整数更易于使用,Kotlin同样提供了用后缀标记,该后缀表示无符号类型(类似于上面的Long
类型添加L字母)
-
使用
u
或U
字母作为后缀表示无符号整数。而具体的类型是根据前面变量的类型确定的,如果变量没有提供类型,编译器将根据数字的大小使用UInt
或ULong
: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类型,根据数字大小自动推断得到
-
uL
和UL
可以将文字直接标记为无符号Long类型:1
val a = 1UL // ULong类型,直接使用后缀标记
对于小数来说,Kotlin提供符合IEEE 754标准的浮点类型Float
和Double
,Float
为IEEE 754标准中的单精度数据,而`Double位标准中的双精度数据,对于单双精度,本质上就是能够表示的小数位精度,双精度比单精度的小数精度更高。
这些类型的大小不同,并为不同精度的浮点数提供存储:
类型 | 大小(位) | 符号与尾数位数 | 阶码位数 | 小数位数 |
---|---|---|---|---|
Float |
32 | 24 | 8 | 6-7 |
Double |
64 | 53 | 11 | 15-16 |
我们也可以直接创建小数类型的Double
和Float
变量,小数部分与整数部分由一个小数点(.
)隔开,编译器默认情况下会将所有的小数自动推断为推断Double
类型:
|
|
由于默认是Double类型,如果我们要明确指定值为Float
类型,那么需要添加后缀f
或F
,并且由于精度问题,如果该值包含超过6-7位小数,则会丢失一部分精度:
|
|
与其他一些语言不同,Kotlin中的数字类型没有隐式转换的操作,例如,一个Double
类型的变量无法将其值赋值给Int
类型变量:
如果需要将一个整数转换为小数,我们会在后面学习函数之后再给各位小伙伴讲解如何调用函数进行显示类型转换。
数字类型的运算
Kotlin支持数学上标准的算术运算集,例如:+
,-
,*
,/
,%
并且这些运算符都是通过运算符重载实现的具体功能,我们会在后续的章节中讲解Kotlin的运算符重载机制,这里各位小伙伴就当做是普通的运算操作即可。
Kotlin支持运算符重载,运算符重载是一种允许程序员重新定义运算符的语言特性,通过运算符重载,您可以为自定义的类或数据类型定义一些特定操作的行为。
其中加减乘除操作这里就不做介绍了,而%符号用于取余操作,也就是计算前面的数整除后面的数得到的余数:
|
|
以上运算都比较简单,但是注意在除法运算中,只有两个操作数中出现小数,除法的结果才是小数,如果两个操作数都是整数,那么得到的结果也是整数,并且直接丢失小数位(不会四舍五入)
|
|
同样的,除了直接使用字面量来进行运算,我们也可以将定义的变量参与到运算中:
|
|
注意,在Kotlin中不同的算数运算符,它们的优先级也不一样:
|
|
在数学中,乘法运算的优先级比加法运算更高,因此我们需要先计算乘法,再计算加法,而在Kotlin中是一样的,乘法和除法运算符的优先级是高于加法运算符的,所以说上面算出来的结果是7,同样的,我们数学中使用括号来提升某些运算的优先级,在Kotlin中同样可以,比如:
|
|
有些时候,我们可能想要让某个变量的值增加一定数值,比如下面这样:
|
|
对于这种让变量本身加减乘除某个值的情况,可以使用赋值运算符简化:
|
|
如果我们只是希望某个变量自增或自减1,那么我们可以像这样去写:
|
|
不过,这个双++符号,可以放在变量的前后,都能实现自增操作:
|
|
但是他们有一个本质区别,就是++在前面,a是先自增再得到结果,而++在后面,是a先得到结果,再进行自增,比如:
|
|
对于新手来说,这个很容易搞混,所以说一定要记清楚。
Kotlin提供了一组整数的位运算操作,可以直接在二进制层面上与数字表示的位进行操作,不过只适用于Int
和Long
类型的数据:
shl(bits)
– 有符号左移shr(bits)
– 有符号右移ushr(bits)
– 无符号右移and(bits)
– 按位与or(bits)
– 按位或xor(bits)
– 按位异或inv()
– 取反
这里我们从按位与开始讲解,比如下面的两个数:
|
|
按位与实际上就是让这两个数每一位都进行比较,如果这一位两个数都是1,那么结果就是1,否则就是0:
- a = 9 = 1001
- b = 3 = 0011
- c = 1 = 0001(因为只有最后一位,两个数都是1,所以说结果最后一位是1,其他都是0)
同样的,按位或,其实就是只要任意一个为1(不能同时为0)那么结果就是1:
|
|
- 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 = 00000001
- 4 = 00000100(左移两位之后,1跑到前面去了,尾部使用0填充,此时就是4)
我们发现,左移操作每进行一次,结果就会x2,所以说,除了直接使用*
进行乘2的运算之外,我们也可以使用左移操作来完成。
同样的,右移操作就是向右移动每一位咯:
|
|
跟上面一样,右移操作可以快速进行除以2的计算。对于负数来说,左移和右移操作不会改变其符号位上的数字,符号位不受位移操作影响:
|
|
我们也可以使用考虑符号位的右移操作,一旦考虑符号位,那么符号会被移动:
|
|
比如:
- -1 = 11111111 11111111 11111111 11111111
- 右移: 01111111 11111111 11111111 11111111(无符号右移使用0填充高位)
此时得到的结果就是正数的最大值 2147483647 了,注意,不存在无符号左移操作。
最后我们再总结一下不同运算符的优先级,对应的优先级从上往下依次减弱:
- 一元运算符:例如 ++、–、+、-、!、~
- 乘法和除法运算符:*、/、%
- 加法和减法运算符:+、-
- 位移运算符:shl、shr、ushr
- 按位与运算符:and
- 按位或运算符:or
- 按位异或运算符:xor
- 逻辑运算符:&&、||
- 比较运算符:>、>=、<、<=、==、!=
- 区间运算符:..
- 赋值运算符:=、+=、-=、*=、/=、%=
当然,这里列出的部分运算符各位小伙伴可能还没有遇到,不过在后续的学习中,我们会慢慢认识的,届时各位小伙伴可以回顾一下这里。
布尔类型介绍
布尔类型是Kotlin中的一个比较特殊的类型,它并不是存放数字的,而是状态,它有下面的两个状态:
- true - 真
- false - 假
布尔类型(boolean)只有true
和false
两种值,也就是要么为真,要么为假,布尔类型的变量通常用作流程控制判断语句(不同于C语言,C语言中一般使用0表示false,除0以外的所有数都表示true)
|
|
如果给一个其他的值,会无法编译通过:
布尔值除了可以直接赋值得到,也可以通过一些关系运算得到,常见的关系运算有大于、小于以及等于,所有的关系运算在下方:
- 判断两个数是否相等:
a == b
和a != b
- 判断数之间大小:
a < b
,a > b
,a <= b
,a >= b
- 判断数是否在指定范围中:
a..b
,x in a..b
,x !in a..b
比如我们想判断变量a和变量b的值是否相同:
|
|
可以看到,通过逻辑运算得到的结果,都是true或false,也就是我们这里学习的Boolean类型值。在Kotlin中,我们为了快速判断某个数是否在一个区间内,可以直接使用 a..b
来表示一个数学上[a, b]
这样的闭区间,比如我们这里要判断变量a
的值是否在1~10之间:
|
|
对于Boolean类型的变量之间,也有一些逻辑运算符用于进行组合条件判断:
||
– 逻辑或运算&&
– 逻辑与运算!
– 取反运算
其中取反运算最好理解,它可以让true变成false,false变为true,比如:
|
|
对于逻辑与和逻辑或运算,我们可以像这样去使用:
|
|
与运算符要求左右两边同时为真,得到的结果才是真,否则一律为假,而或运算就是要求两边只要有一边为真,结果就是真,除非两边同时为false,那么就没戏了。
不过需要注意的是,在与运算中,第一个判断表达式得到了false
之后,此时不会再继续运行第二个表达式,而是直接得到结果false
(逻辑运算符会出现短路的情况,只要第一个不是真,就算第二个是真也不可能了,所以说为了效率,后续就不用再判断了,在使用时一定要注意这一点)同样的,或运算下当发现第一个判断表达式为true时,也不会继续向后执行了,因为结果已经是顶真了。
字符类型介绍
字符类型也是一个重要的基本数据类型,它可以表示计算机中的任意一个字符(包括中文、英文、标点等一切可以显示出来的字符)字符由Char
类型表示,字符值用单引号:'1'
囊括:
|
|
注意,字符只能表示一单个字符,我们之前遇到的字符串跟字符不一样,关于字符串我们会在下节课进行介绍。
我们打印出来的也是单个字符:
那么可能会有小伙伴好奇,字符类型在计算机底层是怎么进行存储的呢?实际上每个字符在计算机中都会对应一个字符码,首先我们需要介绍ASCII码:
比如我们的英文字母A
要展示出来,那就是一个字符的形式,而其对应的ASCII码值为65,我们可以使用.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
即可,比如✓这个符号:
|
|
除了能像这样表示一个特殊字符,我们也可以使用一些其他的转义字符来表示各种东西:
\t
– 选项卡\b
– 退格\n
– 换行(LF)\r
– 回车(CR)\'
– 单引号\"
– 双引号\\
–反斜杠\$
– 美元符号
这些转义字符都是为了防止在特殊情况下无法表示某些字符,而给我们的替代方案,后续各位小伙伴在使用时可以回来参考一下。
字符串类型介绍
字符串类是一个比较特殊的类型,它用于保存字符串。我们知道,基本类型Char
可以保存一个2字节的Unicode字符,而字符串则是一系列字符的序列,它的类型名称为String
。
字符串通常由双引号""
囊括,它可以表示一整串字符:
|
|
注意,字符串中的字符一旦确定,无法进行修改,只能重新创建。
如果我们需要再字符串中换行,需要用到转义字符,字符串中同样支持使用转义字符:
|
|
不过,字符串只能写一行,有时候有点不太够用,可能我们想要打印多行文本,我们除了用\n
转义字符来换行之外,也可以直接使用三个双引号"""
来表示一个原始字符串,但是原始字符串无法使用转义字符:
|
|
效果如下:
可以看到确实是够原始的,把我代码里面的缩进都给打印出来了,这样肯定不是我们希望的样子,我们希望的仅仅是一个简单换行而已,那这里该怎么去处理呢?后面我们在讲解函数之后,会额外补充这里的内容。
有时候为了方便,我们可以将不同的字符串拼接使用:
|
|
字符串除了和字符串拼接之外,也可以和其他类型进行拼接:
|
|
但是我们需要注意字符串拼接的顺序,只能由字符串拼接其他类型,如果是其他类型拼接字符串,可能会出现问题:
但是现在我们就是希望其他类型的数据拼在最前面,这里应该怎么做呢?我们可以使用字符串模版来完成:
|
|
如果要添加到前面:
|
|
出现这种情况除了用空格去解决之外,我们也可以添加一个花括号:
|
|
由于美元符用于模版表达式了,所以说如果我们希望在字符串中仅仅表示$这个字符,那么我们需要用到转义:
|
|
至此,关于Kotlin的变量与基本类型的内容我们就暂时告一段落了,不过在后面学习了更多知识后,我们还会回顾这些基本类型,了解他们的更多用法,并且认识我们唯一没有在这一部分介绍的数组类型。
流程控制
经过前面的学习,我们知道,程序都是从上往下依次运行的,但是,仅仅是这样还不够,我们需要更加高级的控制语句来使得程序更加有趣。比如,判断一个整数变量,大于1则输出yes,小于1则输出no,这时我们就需要用到选择结构来帮助我们完成条件的判断和程序的分支走向。
在前面我们介绍了运算符,我们可以通过逻辑运算符和关系运算符对某些条件进行判断,并得到真或是假的结果。这一部分我们将继续使用这些运算符进行各种判断,以及实现流程控制。
选择结构(if-else)
某些时候,我们希望进行判断,只有在条件为真时,才执行某些代码,这种情况就需要使用到选择分支语句,首先我们来认识一下if
语句:
|
|
if
的小括号中需要我们传入一个Boolean类型的结果,可以是一个Boolean变量,也可以是一个判断语句,反正只能接受true和false两种结果,比如下面的这个例子:
|
|
if
会进行判断,只有判断成功时才会执行紧跟着的语句,否则会直接跳过,注意,如果我们想要在if中执行多行代码,需要使用代码块将这些代码囊括起来(实际上代码块就是将多条语句复合到一起,使用花括号囊括)所以说,我们以后使用if时,如果分支中有多行代码需要执行,就需要添加花括号,如果只有一行代码,花括号可以直接省略,包括我们后面会讲到的else、while、for语句都是这样的,就像下面这样:
|
|
如果我们希望判断条件为真时执行某些代码,条件为假时执行另一些代码,我们可以在后面继续添加else语句:
|
|
if-else
语句就像两个分支,跟据不同的判断情况从而决定下一步该做什么,这跟我们之前认识的三元运算符性质比较类似。
那如果此时我们需要判断多个分支呢?比如我们现在希望判断学生的成绩,不同分数段打印的等级不一样,比如90以上就是优秀,70以上就是良好,60以上是及格,其他的都是不及格,那么这种我们又该如何判断呢?要像这样进行连续判断,我们需要使用else-if
来完成:
|
|
当然,if
分支语句还支持嵌套使用,比如我们现在希望低于60分的同学需要补习,0-30分需要补Java,30-60分需要补C++,这时我们就需要用到嵌套:
|
|
除了if自己可以进行嵌套使用之外,其他流程控制语句同样可以嵌套使用,也可以与其他流程控制语句混合嵌套使用。这样,我们就可以灵活地使用if
来进行各种条件判断了。
除了直接执行语句之外,我们也可以将if和else用作结果判断,比如:
|
|
这类似于其他语言,如Java和C中的三元运算,不过Kotlin中没有那样的三元运算符,只能使用上面的表达式,对于多行代码块的情况,默认最后一行作为返回的结果:
|
|
注意,如果需要这种返回结果的表达式,那么必须要存在else
分支,否则不满足条件岂不是没结果了?
选择结构(when)
前面我们介绍了if语句,我们可以通过一个if语句轻松地进行条件判断,然后根据对应的条件,来执行不同的逻辑,当然除了这种方式之外,我们也可以使用when
语句来实现,它更适用于多分支的情况:
when
定义具有多个分支的条件表达式。它类似于类似Java和C语言中的switch
语句,它简单的形式看起来像这样:
|
|
比如现在我们要根据学生的等级进行分班,学生有ABC三个等级:
|
|
如果将when用作表达式,则else分支必须存在,除非编译器能推断出所有可能的情况都包含分支条件,比如下面的例子:
|
|
以下情况就可以不需要else语句:
|
|
在when
语句中,遇到以下情况,携带else
分支是必须的:
when
分支中仅有一个Boolean
类型、枚举 或 密封,以及用于判断的目标变量是可空的情况(后面会讲解)when
分支没有包括该判断目标的所有可能的值。
有时候我们可能希望某些值都属于同一个情况,可以使用逗号将其条件组合成一行:
|
|
我们也可以使用任意表达式(不仅仅是常量)作为分支条件,比如之前的if-else案例中我们判断学生成绩:
|
|
包括我们之后学习的类型判断is
表达式、函数调用等,都可以在这里作为分支条件。
循环结构(for)
通过前面的学习,我们了解了如何使用分支语句来根据不同的条件执行不同的代码,我们接着来看第二种重要的流程控制语句:循环语句。
我们在某些时候,可能需要批量执行某些代码:
|
|
遇到这种情况,我们由于还没学习循环语句,那么就只能写N次来实现这样的多次执行。但是如果此时要求我们将一句话打印100遍、1000遍、10000遍,那么我们岂不是光CV代码就要搞一下午?
现在,要解决这种问题,我们可以使用for循环语句来多次执行:
|
|
这里的可遍历目标有很多,比如:
- 数组
- 区间
- 任何实现了运算符重载函数iterator的类
这里我们只学习了区间,我们来看看如何使用,比如我们要打印一段话3遍:
|
|
打印结果为:
可以看到,每一次遍历出来的变量i
,其实就是每次遍历的下一个目标,比如这里是1..3的区间,那么得到的依次就是1、2、3这三个结果了,唯一需要注意的是,这里的i
是局部的,只在for
循环内部可用(包括嵌套的内部)并不是整个main中都可以使用:
默认情况下,每一轮循环都会向后+1,我们也可以自由控制每一轮增加多少,也就是步长:
|
|
这样,打印出来的数据会按照步长进行增长:
那如果我们需要从10到1倒着进行遍历呢?我们可以将..
替换为downTo
来使用:
|
|
我们可以使用调试来观察每一轮的变化,调试模式跟普通的运行一样,也会执行我们的Java程序,但是我们可以添加断点,也就是说当代码运行到断点位置时,会在这里暂停,我们可以观察当代码执行到这个位置时各个变量的值:
调试模式在我们后面的学习中非常重要,影响深远,所以说各位小伙伴一定要学会。调试也很简单,我们只需要点击右上角的调试选项即可(图标像一个小虫子一样,因为调试的英文名称是Debug)
调试开始时,我们可以看到程序在断点位置暂停了:
此时我们可以观察到当前的变量i
的值,也可以直接在下方的调试窗口中查看:
随着循环的进行,i的值也会逐渐自增。
和之前的if
一样,for循环同样支持嵌套使用:
|
|
上面的代码中,外层循环会执行3轮,而整个循环体又是一个循环语句,那么也就是说,每一轮循环都会执行里面的整个循环,里面的整个循环会执行3,那么总共就会执行3 x 3次,也就是9次打印语句。
我们也可以在循环过程中提前终止或是加速循环的进行,这里我们需要认识两个新的关键字:
|
|
我们可以使用continue
关键字来跳过本轮循环,直接开启下一轮。这里的跳过是指,循环体中,无论后面有没有未执行的代码,一律不执行,比如上面的判断如果成功,那么将执行continue
进行跳过,虽然后面还有打印语句,但是不会再去执行了,而是直接结束当前循环,开启下一轮。
在某些情况下,我们可能希望提前结束循环:
|
|
我们可以使用break
关键字来提前终止整个循环,和上面一样,本轮循环中无论后续还有没有未执行的代码,都不会执行了,而是直接结束整个循环,跳出到循环外部。
虽然使用break和continue关键字能够更方便的控制循环,但是注意在多重循环嵌套下,它只对离它最近的循环生效(就近原则):
|
|
这里的continue
加速的对象并不是外层的for,而是离它最近的内层for循环,break
也是同样的规则:
|
|
那么,要是我们就是想要终止或者是加速外层循环呢?我们可以为循环语句打上标记:
|
|
关于for语句的更多用法,我们会在后续的学习中继续认识。
循环结构(while)
前面我们介绍了for循环语句,我们接着来看第二种while循环,for循环要求我们给一个可遍历的目标,而while相当于是一个简化版本,它只需要我们填写循环的维持条件即可,比如:
|
|
相比for循环,while循环更多的用在不明确具体的结束时机的情况下,而for循环更多用于明确知道循环的情况,比如我们现在明确要进行循环10次,此时用for循环会更加合适一些,又比如我们现在只知道当i
大于10时需要结束循环,但是i
在循环多少次之后才不满足循环条件我们并不知道,此时使用while就比较合适了。
|
|
上面的这种情况就非常适合使用while循环。
和for循环一样,while也支持使用break和continue来进行循环的控制,以及嵌套使用:
|
|
我们也可以反转循环判断的时机,可以先执行循环内容,然后再做循环条件判断,这里要用到do-while
语句:
|
|
Kotlin程序设计中级篇
我们在前面已经学习了Kotlin程序设计的基础篇,本章我们将继续介绍更多Kotlin特性,以及面向对象编程。
函数
其实函数我们在一开始就在使用了:
|
|
我们程序的入口点就是main
函数,我们只需要将我们的程序代码编写到主函数中就可以运行了,不过这个函数只是由我们来定义,而不是我们自己来调用。当然,除了主函数之外,我们一直在使用的println
也是一个函数,不过这个函数是标准库中已经实现好了的,现在是我们在调用这个函数:
|
|
那么,函数的具体定义是什么呢?
函数是完成特定任务的独立程序代码单元。
其实简单来说,函数是为了完成某件任务而生的,可能我们要完成某个任务并不是一行代码就可以搞定的,但是现在可能会遇到这种情况:
|
|
我们每次要做这个任务时,都要完完整整地将任务的每一行代码都写下来,如果我们的程序中多处都需要执行这个任务,每个地方都完整地写一遍,实在是太臃肿了,有没有一种更好的办法能优化我们的代码呢?
这时我们就可以考虑使用函数了,我们可以将我们的程序逻辑代码全部编写到函数中,当我们执行函数时,实际上执行的就是函数中的全部内容,也就是按照我们制定的规则执行对应的任务,每次需要做这个任务时,只需要调用函数即可。
我们来看看,如何创建和使用函数。
创建和使用函数
Kotlin函数使用fun
关键字声明:
|
|
其中函数名称也是有要求的,并不是所有的字符都可以用作函数名称,它的命名规则与变量的命名规则基本一致,所以这里就不一一列出了。函数不仅仅需要完成我们的任务,可能某些函数还需要告诉我们结果,我们同样可以将函数返回的结果赋值给变量或是参与运算等等,当然如果我们的函数只需要完成任务,不需要告诉我们结果,返回值类型可以不填,我们先从最简单的开始:
|
|
我们要调用这个函数也很简单,只需要像下面这样就可以了:
|
|
不过,有些时候,我们可能需要外部传入一些参数来使用,比如:
|
|
这里我们在函数的小括号中填入的就是形式参数,这代表调用函数时需要传入的数据,比如这里就是我们要打印的字符串,而实际在调用函数时,填入的内容就是实际参数:
|
|
还有一些时候,我们的函数可能需要返回一个计算的结果给调用者,我们也可以设定函数的返回值:
|
|
带返回值的函数,调用之后得到的返回值,可以由变量接收,或是直接作为其他函数的参数:
|
|
注意这个return
关键字在执行之后,是不会继续执行之后的内容的:
|
|
有些时候,我们也可以设计一些参数带有默认值的函数,如果在调用函数时不填入参数,那么就使用我们一开始设置好的默认值作为实际传入的参数:
|
|
在调用函数时,我们可以手动指定传入的参数对应的是哪一个形式参数:
|
|
对于一些内容比较简单的函数,比如上面仅仅是计算两个参数的和,我们可以直接省略掉花括号,像这样编写:
|
|
这里还需要注意一下,函数的形式参数默认情况下为常量,无法进行修改,只能使用:
比较奇葩的是,函数内部也可以定义函数:
|
|
函数内的函数作用域是受限的,我们只能在函数内部使用:
|
|
内部函数可以访问外部函数中的变量:
|
|
最后,我们不能同时编写多个同名函数,这会导致冲突:
但是,如果多个同名函数的参数不一致,是允许的:
|
|
我们在调用这个函数时,编译器会根据我们传入的实参自动匹配使用的函数是哪一个:
|
|
以上适用于形参列表不同的情况,如果仅仅是返回值类型不同的情况,同样是不允许的:
像这种编写同名但不同参数的函数,我们称为函数的重载。
再谈变量
前面我们学习了如何使用变量,只不过当时我们仅仅是在main函数中使用的局部变量,我们也可以将变量的作用域进行提升,将其直接变成一个顶级定义:
|
|
此时,这个变量可以被所有的函数使用:
|
|
以上也只是对变量的一些简单使用,现在变量的作用域被提升到顶层,它可以具有更多的一些特性,那么,我们就再来重新认识一下变量,声明一个变量的完整语法如下:
|
|
前面的我们知道,但是这个getter和setter是个什么鬼?对于这种顶层定义的变量(包括后面类中会用到的成员属性变量)可以具这两个可选的函数,它们本质上是一个get和set函数:
- getter:用于获取这个变量的值,默认情况下直接返回当前这个变量的值
- setter:用于修改这个变量的值,默认情况下直接对这个变量的值进行修改
我们在使用这种全局变量时,对于变量的获取和设定,本质上都是通过其getter和setter函数来完成的,只不过默认情况下不需要我们去编写,程序编译之后,有点像这样的结果:
|
|
而对于其使用,在编译之后,会变成这样:
|
|
是不是感觉好神奇,一个变量都能搞这么多花样,这其实是为了后续多态的一些性质而设计的(下一章讲解)
可以看到,在默认情况下,变量的获取就是直接返回,设置就是直接修改,不过有些时候我们可能希望修改这些变量获取或修改时执行的操作,我们可以手动编写:
|
|
这里使用的field准确的说应该是Kotlin提供的"后备字段",因为我们使用getter和setter本质上替代了原有的获取和修改方式,使其变得更像是函数的调用,因此,为了能够继续像之前使用一个变量那样去操作它本身,就有了这个后备字段。
最后得到的就是:
甚至还可以写成这样,在获取的时候执行一些操作:
|
|
同样的,设置的时候也可以自定义:
|
|
因此,一个变量有些时候可能会写成这样:
|
|
当然,默认情况下其实没有必要去重写get和set除非特殊需求。
递归函数
我们前面学习了如何调用函数,实际上函数自己也可以调用自己。
|
|
肯定会有小伙伴疑问,函数自己调用自己有什么意义?反而还会导致函数无限的调用下去,无穷无尽,确实,如果不加限制地让函数自己调用自己:
就会出现这种爆栈
的情况,这是因为程序的内存是有限的,不可能无限制的继续调用下去,因此,在自我调用到一定的深度时,会被强制终止。所以说这玩意有啥用呢?如果我们对递归函数加以一些限制,或许会有意想不到的发现:
|
|
这个函数最终调用起来就像这样:
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个数这类问题,我们也可以使用递归来实现:
|
|
是不是感觉递归函数非常神奇?它甚至可以解决一些动态规划问题、一些分治算法等。
不过,这种函数的效率就非常低了,相比循环来说,使用递归解决斐波那契问题,时间复杂度会呈指数倍增长,且n大于20时基本可以说很卡了(可以想象一下,每一个fib(n)都会分两个出去,实际上这个中间存在大量重复的计算)
那么,有没有办法可以将这种尾部作为返回值进行递归的操作优化一下呢?我们可以使用tailrec
关键字来实现:
|
|
实际上在编译之后,会变成这样:
可以看到它变成了一个普通的循环操作,这也是编译器的功劳,同样的,对于斐波那契数列:
|
|
实用库函数介绍
Kotlin为我们内置了大量实用的库函数,我们可以使用这些库函数来快速完成某些操作。
比如我们前面使用的println
就是Kotlin提供的库函数,我们可以使用这个函数快速进行数据打印:
|
|
那既然现在有输出,能不能让用户输入,然后我们来读取呢?
|
|
我们可以在控制台输入一段文本,然后回车结束:
Kotlin提供的运算符实际上只能进行一些在小学数学中出现的运算,但是如果我们想要进行乘方、三角函数之类的高级运算,就没有对应的运算符能够做到,而此时我们就可以使用数学工具类来完成。
|
|
当然,三角函数肯定也是安排上了的:
|
|
可能在某些情况下,计算出来的浮点数会得到一个很奇怪的结果:
|
|
正常来说,sinπ的结果应该是0才对,为什么这里得到的是一个很奇怪的数?这个E是干嘛的,这其实是科学计数法的10,后面的数就是指数,上面的结果其实就是:
- 1.2246467991473532×10−161.2246467991473532×10−16
其实这个数是非常接近于0,这是因为精度问题导致的,所以说实际上结果就是0。
我们也可以计算对数函数:
|
|
还有一些比较特殊的计算:
|
|
向上取整就是找一个大于当前数字的最小整数,向下取整就是砍掉小数部分。注意,如果是负数的话,向上取整就是去掉小数部分,向下取整就是找一个小于当前数字的最大整数。
高阶函数与lambda表达式
注意: 这一部分比较难理解,如果看不懂可以后面回来看。
Kotlin中的函数属于一等公民,它支持很多高级特性,甚至可以被存储在变量中,可以作为参数传递给其他高阶函数并从中返回,就想使用普通变量一样。 为了实现这一特性,Kotlin作为一种静态类型的编程语言,使用了一系列函数类型来表示函数,并提供了一套特殊的语言结构,例如lambda表达式。
那么这里说的高阶函数是什么,lambda表达式又是什么呢?
正是得益于函数可以作为变量的值进行存储,因此,如果一个函数接收另一个函数作为参数,或者返回值的类型就是一个函数,那么该函数称为高阶函数。
要声明函数类型,需要按照以下规则:
- 所有函数类型都有一个括号,并在括号中填写参数类型列表和一个返回类型,比如:
(A, B) -> C
表示一个函数类型,该类型表示接受类型A
和B
的两个参数并返回类型C
的值的函数。参数类型列表可为空的,比如() -> A
,注意,即使是Unit
返回类型也不能省略。
我们可以像下面这样编写:
|
|
同样的,作为函数的参数也可以像这样表示:
|
|
函数类型的变量,我们可以将其当做一个普通的函数进行调用:
|
|
由于函数可以接受函数作为参数,所以说你看到这样的套娃场景也不奇怪:
|
|
不过这样写可能有些时候不太优雅,我们可以为类型起别名来缩短名称:
|
|
那么,函数类型我们知道如何表示了,如何具体表示一个函数呢?我们前面都是通过fun
来声明函数:
|
|
而现在我们的变量也可以直接表示这个函数:
|
|
除了引用现成的函数之外,我们也可以使用匿名函数,这是一种没有名称的函数:
|
|
匿名函数除了没名字之外,其他的用法跟函数是一样的。
最后,我们来看看今天的重量级嘉宾,不要小看了Kotlin的语法,我们也可以使用Lambda表达式来表示一个函数实例:
|
|
是不是感觉特别简便?
对于参数有多个的情况,我们也可以这样进行编写:
|
|
是不是感觉玩的非常高级?还有更高级的在后面呢!
我们接着来看,如果我们现在想要调用一个高阶函数,最直接的方式就是下面这样:
|
|
当然我们也可以直接把一个Lambda作为参数传入作为实际参数使用:
|
|
不过这样还不够简洁,在Kotlin中,如果函数的最后一个形式参数是一个函数类型,可以直接写在括号后面,就像下面这样:
|
|
由于小括号里面此时没有其他参数了,还能继续省,直接把小括号也给干掉:
|
|
当然,如果在这之前有其他的参数,只能写成这样了:
|
|
这种语法也被称为 尾随lambda表达式,能省的东西都省了,不过只有在最后一个参数是函数类型的情况下才可以,如果不是最后一位,就没办法做到尾随了。
最后需要特别注意的是,在Lambda中没有办法直接使用return
语句返回结果,而是需要用到之前我们学习流程控制时用到的标签:
|
|
如果是函数调用的尾随lambda表达式,默认的标签名字就是函数的名字:
|
|
不过,为什么要这么麻烦呢,还要打标签才能返回,这不多此一举么?这个问题我们会在下一节内联函数中进行讲解。
内联函数
使用高阶函数会可能会影响运行时的性能:每个函数都是一个对象,而且函数内可以访问一些局部变量,但是这可能会在内存分配(用于函数对象和类)和虚拟调用时造成额外开销。
为了优化性能,开销可以通过内联Lambda表达式来消除。使用inline
关键字会影响函数本身和传递给它的lambdas,它能够让方法的调用在编译时,直接替换为方法的执行代码,什么意思呢?比如下面这段代码:
|
|
由于test函数是内联函数,在编译之后,会原封不动地把代码搬过去:
|
|
同样的,如果是一个高阶函数,效果那就更好了:
|
|
由于test函数是内联的高阶函数,在编译之后,不仅会原封不动地把代码搬过去,还会自动将传入的函数参数贴到调用的位置:
|
|
内联会导致编译出来的代码变多,但是同样的换来了性能上的提升,不过这种操作仅对于高阶函数有显著效果,普通函数实际上完全没有内联的必要,也提升不了多少性能。
注意,内联函数中的函数形参,无法作为值给到变量,只能调用:
同样的,由于内联,导致代码被直接搬运,所以Lambda中的return语句可以不带标签,这种情况会导致直接返回:
|
|
上述代码的运行结果就是,直接结束,两句println都不会打印,这种情况被称为非局部返回。
回到上一节最后我们提出的问题,实际上,在Kotlin中Lambda表达式支持一个叫做"标签返回"(labeled return)的特性,这使得你能够从一个Lambda表达式中返回一个值给外围函数,而不是简单地返回给Lambda表达式所在的最近的封闭函数,就像下面这样:
|
|
效果跟上面是完全一样的,为了避免这种情况,我们也可以像之前一样将标签写为@test来防止非局部返回。
|
|
有些时候,可能一个内联的高阶函数中存在好几个函数参数,但是我们希望其中的某一个函数参数不使用内联,能够跟之前一样随意当做变量使用:
|
|
最后编译出来的结果,类似于:
|
|
由于目前知识的学习还不太够,函数我们只能先暂时告一段落,在后续的学习中我们会继续认识更多函数的特性。
类与对象
在之前,我们一直在使用顶层定义:
|
|
而学习了类之后,这些内容也可以定义到类中,作为类的属性存在。
类的概念我们在生活中其实已经听说过很多了。
人类、鸟类、鱼类… 所谓类,就是对一类事物的描述,是抽象的、概念上的定义,比如鸟类,就泛指所有具有鸟类特征的动物。比如人类,不同的人,有着不同的性格、不同的爱好、不同的样貌等等,但是他们根本上都是人,所以说可以将他们抽象描述为人类。
对象是某一类事物实际存在的每个个体,因而也被称为实例(instance)我们每个人都是人类的一个实际存在的个体。
所以说,类就是抽象概念的人,而对象,就是具体的某一个人。
- A:是谁拿走了我的手机?
- B:是个人。(某一个类型)
- A:我还知道是个人呢,具体是谁呢?
- B:是XXX。(具体某个对象)
而在Kotlin中,也可以像这样进行编程,我们可以定义一个类,然后进一步创建许多这个类的实例对象,像这种编程方式,我们称为面向对象编程,我们除了去使用Kotlin给我们提供的类型之外,我们也可以使用自己定义的类。
类的定义与对象创建
前面我们介绍了什么是类,什么是对象,首先我们就来看看如何去定义一个类。
Kotlin中的类使用关键字class
声明,我们可以直接在默认的Main.kt文件中编写:
|
|
我们在对类进行命名时,一般使用英文单词,并且首字母大写,跟变量命名一样,不能出现任何的特殊字符。
除了直接在某个.kt文件中直接编写之外,为了规范,我们一般将一个类单独创建一个文件,我们可以右键src
目录:
这里选择新建,然后选择Kotlin类/文件选项,然后创建一个类:
文件创建完成后,默认也会为我们生成类的定义,并且类名称与创建的类文件是一模一样的:
这是一个非常简单的类,但是肯定远远不够。
既然是学生类,那么肯定有学生相关的一些属性,比如名字、性别、年龄等等,那么怎么才能给这个类添加一些属性呢?我们需要指定类的构造函数,构造函数也是函数的一种,但是它是专用于对象的创建,Kotlin中的类可以添加一个主构造函数和一个或多个次要构造函数。主构造函数是类定义的一部分,像下面这样编写:
|
|
如果主构造函数没有任何注释或可见性修饰符,则可以省略constructor
关键字,如果类中没有其他内容要写,可以直接省略花括号,最后就变成这样了:
|
|
但是,这里仅仅是定义了构造函数的参数,这还不是类的属性,那么我们要怎么才能定义为类的属性呢?我们可以为这些属性添加var
或val
关键字来表示这个属性是可变还是不变的:
|
|
这跟我们之前使用变量基本一致:
val
:不可变属性var
:可变属性
这样才算是定义了类的属性,我们也可以给这些属性设置初始值:
|
|
除了将属性添加到构造函数中,我们也可以将这些属性直接作为类的成员变量写到类中,但是这种情况必须要配一个默认值,否则无法通过编译:
|
|
这样我们就可以不编写主构造函数也能定义属性,但是这里仍然会隐式生成一个无参的构造函数,为了构造函数能够方便地传值初始化,也可以像这样写:
|
|
当然,如果各位不希望这些属性在一开始就有初始值,而是之后某一个时刻去设定初始值,我们也可以为其添加懒加载:
|
|
并且,像这样编写的类成员变量,也可以自定义对应的getter和setter属性:
|
|
那么,现在我们定义了主构造函数之后,该怎么去使用它呢?
跟我们调用普通函数一样,这里的函数名称就是类的名称,如果一个类没有编写构造函数,那么这个类默认情况下使用一个无参构造函数创建:
|
|
如果是有构造函数的类,我们只需要填写需要的参数即可,调用之后,类的属性就是这里我们给进去的参数了:
|
|
这样,我们就成功创建出了一个名字为小明的学生类型对象,但是这个对象仅仅是创建出来还不行,我们肯定需要去使用它。
实际上,我们可以像之前使用基本类型一样,使用对象,我们也可以使用一个变量去接收生成出来的对象:
|
|
有一个我们需要注意的点,这里的stu存放的是对象的引用,而不是本体,我们可以通过对象的引用来间接操作对象。
|
|
这里,我们将变量p2赋值为p1的值,那么实际上只是传递了对象的引用,而不是对象本身的复制,这跟我们前面的基本数据类型有些不同,p2和p1都指向的是同一个对象(如果你学习过C语言,它就类似于指针一样的存在)
我们可以来测试一下:
|
|
但是如果我们像这样去编写:
|
|
我们可以使用.
运算符来访问对象的属性,比如我们要访问小明这个学生对象的属性:
|
|
获取和修改都是可以的:
|
|
注意,不同对象的属性是分开独立存放的,虽然都是统一由类完成定义,但是每个对象都有一个自己的空间,修改一个对象的属性并不会影响到另一个相同类型的对象:
|
|
除了直接使用主构造函数创建对象外,我们也可以添加一些次要构造函数,比如我们的学生可以只需要一个名字就能完成创建,我们可以直接在类中编写一个次要构造函数:
|
|
如果该类有一个主构造函数,则每个次要构造函数需要通过另一个次要构造函数直接或间接委托给主构造函数。委托到同一类的另一个构造函数是this
关键字完成的:
|
|
如果一个类没有主构造函数,那么我们也可以直接在在类中编写次要构造函数,但是不需要主动委托一次主构造函数,他这里会隐式包含,所以说我们直接写就行了:
|
|
次要构造函数和主构造函数一样,都可以用于对象的创建:
|
|
并且次要构造函数可以编写自定义的函数体:
|
|
因此,主构造函数相比次要(辅助)构造函数:
- 主构造函数: 可以直接在主构造函数中定义类属性,使用更方便,但是主构造函数只能存在一个,并且无法编写函数体,只有为类属性做初始化赋值的效果。
- 辅助(次要)构造函数: 可以存在多个,并且可以自定义函数体,但是无法像主构造函数那样定义类属性,并且当类具有主构造函数时,所有次要构造函数必须直接或间接地调用主构造函数。
Kotlin语言本身比较灵活,类中并不是一定需要主构造函数,全部写辅助构造函数也是可以的,但是再怎么都得有构造函数。
下一部分我们接着来讨论对象的初始化。
对象的初始化
在对象创建时,我们可能需要做一些初始化工作,我们可以使用初始化代码块来完成,初始化代码块使用init关键字来完成。假如我们希望对象在创建的时候,如果年龄不足18岁,那么就设定为18岁:
|
|
这样,我们在创建对象的时候,就会在创建的时候自动执行初始化代码块里面的代码:
|
|
可以看到初始化操作开始执行了:
初始化操作不仅仅可以有一个,也可以有很多个:
|
|
对于将成员属性写到类中的情况,同样是按照顺序向下执行,比如:
因为成员变量a是在初始化代码块的后面才初始化的,这里会报错。
如果一个类具有次要构造函数,那么我们也可以直接在次要构造函数中编写一些初始化代码:
|
|
当我们使用对应的次要构造函数时,就会执行次要构造函数中的初始化代码了。
这里需要注意一下,次要构造函数实际上需要先执行主构造函数,而在执行主构造函数时,会优先将之前我们讲解的初始化代码块执行,比如下面的代码:
|
|
无论是有主构造函数还是没有主构造函数(会生成一个默认的无参构造函数)都会先执行。
类的成员函数
现在我们的类有了属性,我们可以为创建的这些对象设定不同的属性值,比如每个人的名字都不一样,性别不一样,年龄不一样等等。只不过光有属性还不行,对象还需要具有一定的行为,就像我们人可以行走,可以跳跃,可以思考一样。
而对象也可以做出一些行为,我们可以通过定义函数来实现,类的函数和我们之前编写的函数有一些区别,它是属于这个类的,我们之前使用的函数都是直接编写在Kt文件中,它们都是顶级函数。
|
|
要使用类的成员函数,我们只能通过对象来进行调用:
|
|
是不是稍微有一些体会了?好像真的是我们在让对象执行一个动作一样。在类的成员函数中,我们可以直接访问当前类对象中的一些属性,比如我们这里的用户名和年龄:
|
|
注意,这里我们访问的name和age属性,是当前这个对象的name和age属性。比如:
|
|
注意,下面这种情况,我们需要特殊处理:
|
|
如果函数中的变量存在歧义,那么优先使用作用域最近的一个,比如函数形参的name作用域更近,那么这里的name拿到的一个是形参name,而不是类的成员属性name。
如果我们需要获取的是类中的成员属性,需要使用this
关键字来表示当前类:
|
|
默认情况下,如果作用域不冲突,使用类中属性this
可以省略。
在类中,我们同样可以定义多个同名但不同参数的函数实现重载:
|
|
实际上类中的函数使用起来跟我们之前定义的大差不差,只不过多了更多用法而已。
再谈基本类型
在Kotlin中,万物皆为对象,实际上我们在上一章学习的全部基本类型,都是官方为我们提供的类。
现在我们学习了类与对象的知识,就可以来重新认识一下这些基本类型,实际上这些基本类型同样是类,也具有一些属性,以及一些类中的成员函数。实际上在上一章中,我们就已经开始使用类和对象了,我们对这些基本类型的操作同样是在操作对象:
|
|
特别说明: 在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类型:
|
|
这样就可以编译通过了。同样的,每个基本类型都有对应的类型转换函数,而且非常全面,比如Int类型:
有了这些成员函数,就大幅度方便了我们的类型转换,再比如我们常见的String类型,也有很多函数可以使用:
|
|
不过需要注意的是,我们在前面就说过,字符串一旦创建就是不可变的,因此,字符串中所有的函数得到的新字符串,都是重新创建的一个新的对象,而不是在原本的字符串上进行修改。
我们继续来看看一些有意思的函数,比如我们想批量替换字符串中的某些内容:
|
|
将字符串中所有的字母o
替换为a
,直接使用replace函数就能直接生成替换之后的字符串了。又比如我们要判断某个字符串是否以指定文本开头:
|
|
可以看到这里经过判断得到了一个Boolean类型的结果,还有很多用于判断字符串是否为空、是否有空格等等的函数:
|
|
我们还发现,这些基本类型中有一些比较特殊的函数,比如plus
函数:
这个函数在类中定义长这样:
|
|
这个函数添加了一个operator
关键字,这个是什么呢?这其实是运算符重载,能够自定义运算符实现的功能,我们之前使用这些数字进行运算,比如加减乘除,实际上都是这些基本类型在类中重载了运算符实现的,下一部分,我们就来介绍一下运算符重载函数。
运算符重载函数
Kotlin支持为程序中已知的运算符集提供自定义实现,这些运算符具有固定的符号表示(如+
或*
)以及对应的优先级,要实现运算符重载,请为相应类型提供具有对应运算符指定名称的成员函数,而当前的类对象,则直接作为对应运算符左边的操作数,如果是一元运算符(比如++自增运算符,只需要本事)则直接作为操作数参与运算。
比如,现在我们想要为我们自定义的类型支持加法运算:
我们可以直接在类定义中添加一个固定名称(名称是预设好的,不能自己想写什么写什么)的函数,这里的加法运算就是plus
函数,我们直接开始编写就可以了:
|
|
这样,我们就成功重载了加法运算符,可以直接上手使用:
|
|
是不是感觉很简单?只需要将我们需要的对应运算符直接重载,编写好对应的计算规则,就可以直接使用对应的运算符进行计算。
我们也可以试试看重载一些一元运算符,比如取反运算符:
|
|
我们来尝试使用一下:
|
|
最后,我们列出常见的一些运算符对应的函数名称,首先是一元运算符:
符号 | 对应的函数名称 |
---|---|
+a |
a.unaryPlus() |
-a |
a.unaryMinus() |
!a |
a.not() |
a-- |
a.dec() +见下文 |
a++ |
a.inc() +见下文 |
其中inc()
和dec()
函数比较特殊,它们必须返回一个值,该值将分配给使用++
或--
操作的变量,而不是改变执行inc
或dec
操作的对象,意思就是执行后应该得到一个新生成的对象,然后变量的值直接引用到这个新的对象,因为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
,这种情况可能会与上面的基本操作产生歧义,比如下面的情况:
|
|
可以看到,上面的函数中,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) |
这是索引访问运算符,使用方括号进行表示。
中缀函数
实际上中缀函数在我们之前很多时候都有出现,比如位运算:
|
|
这里的shl
并不是一个运算符,而是一段自定义的英文单词,像这种运算符是怎么做到的呢?
这其实是中缀函数,用infix
关键字标记的函数被称为中缀函数,在使用时,可以省略调用的点和括号进行调用,Infix函数必须满足以下要求:
- 必须是成员函数。
- 只能有一个参数。
- 参数不能有默认值。
我们可以像下面这样编写:
|
|
我们在使用时,也非常方便,真的就像在使用一个运算符一样:
|
|
得到的结果显而易见:
当然,我们也可以把它当做一个普通的函数进行调用,效果是完全等价的:
|
|
这里需要注意一下:
中缀函数调用的优先级低于算术运算符、类型转换和
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
同时,如果需在类中使用中缀函数,必须明确函数的调用方(接收器)比如:
|
|
对于中缀函数的使用还是比较简单的。
空值和空类型
所有的变量除了引用一个具体的值之外,还有一种特殊的值可以使用,那就是null
,它代表空值,也就是不引用任何对象。
在其他语言中,比如Java中null
是一个非常常见的值,因为在某些情况下,引用类型的变量默认值就是null,这就经常会导致程序中出现一些空指针导致的异常,在Kotlin中,对空值处理是非常严格的,正常情况下,我们的变量是不能直接赋值为null
的,否则会报错,无法编译通过:
这是因为所有的类型默认都是非空类型,非空类型的变量是不允许被赋值为null的,这直接在编译阶段就避免了其他语言中经常存在的空指针问题。
那么,如果我们希望某个变量在初始情况下使用null
而不去引用某一个具体对象,该怎么做呢,此时我们需要将变量的类型修改为可空类型,只需在类型名称的后面添加一个?
即可:
|
|
既然现在是可空类型,那么很多问题就会出现了,比如当一个变量为null
时,此时如果使用类中的一些成员方法或是获取成员属性时,会出现一些问题:
这里由于我们操作的是一个空类型,它有可能值为null
,我们可以想象一下,如果一个变量不引用任何对象,此时我们又去让对象做一些事情(执行函数)这不是在搞笑吗,压根就没这个对象,难道让空气去执行操作吗?这显然是不对的,这样就会导致我们上面所说的空指针异常。
此时,为了安全,我们就需要对变量进行判断,看看其是否为null
然后才能去做一些正常情况下该做的事情:
|
|
可以看到,我们只要能确保某个空类型变量的值不为空,那么就可以正常执行操作。当然,实际上在这个if内部,因为已经判断不为null了,所以str被智能类型转换为非空类型,这也是Kotlin语言非常人性化的地方。
不过在有些情况下,我们可能已经非常清楚,这里的str一定不为null,即使它是一个可空类型变量,我们可以像这样做,来告诉编译器,我们这里一定是安全的,只管执行就好:
|
|
虽然使用非空断言操作符能够进行强制操作,但是这样实际上并不安全,它同样存在安全问题,也许我们有没考虑到的情况会导致这里为null呢,也说不定吧?对于一些我们拿不定具体会不会出现null的情况,有没有更好的解决办法呢?
Kotlin为我们提供了一种更安全的空类型操作,要安全地访问可能包含null
值的对象的属性,请使用安全调用运算符?.
,如果对象的属性为null
则安全调用运算符返回null
,像下面这样:
|
|
这里的调用结果存在两种情况:
- 如果str为null,那么这里得到的结果就是null,并且不会正常执行后面的操作
- 如果str不为null,那就正常返回这里本应该得到的结果
因此,使用安全调用运算符后,如果遇到null的情况,那么这里不会正常进行原本的操作,而是直接返回null
作为结果,这在有些时候非常好用,比如我们希望一个学生类型的变量在为null
时就不执行对应的语句:
|
|
不过在有些时候,可能我们希望如果变量为null,在使用安全调用运算符时,返回一个我们自定义的结果,而不是null,这时该怎么做呢?我们可以使用Elvis运算符:
|
|
这里我们使用了Elvis运算符来判断左侧是否为null,如果左侧为null,那么这里直接得到右侧的自定义值,这个运算符长得巨像其他语言里面的三元运算符,Kotlin拿来干这事了。
解构声明
有时候,我们在使用对象时可能需要访问它们内部的一些属性:
|
|
这样看起来不太优雅,有没有更好的方式呢,比如这里能不能直接得到Student对象内部的name和age熟悉作为变量使用?当然是可以的,我们可以直接像下面这样编写:
|
|
要让一个类的属性支持解构,我们只需添加约定的函数即可,在Kotlin中,我们可以自定义解构出来的结果,而具体如何获取,需要定义一个componentN函数并通过返回值的形式返回解构的结果:
|
|
添加用于解构的函数在之后,我们就可以使用解构操作了:
|
|
如果我们只想要使用第二个参数,而第一个参数不需要,可以直接使用_
来忽略掉:
|
|
解构同样可以用在Lambda表达式中:
|
|
解构语法在遍历集合类和数组时同样适用,我们会在后面进行讲解。
包和导入
在之前,无论我们创建的是Kotlin源文件还是Kotlin类文件,都是在默认的包下进行的,也就是直接在kotlin/src目录创建的。
但是有些时候,我们可能希望将一些模块按功能进行归类,而不是所有的kt文件都挤在一起,这个时候我们就需要用到包了。
我们可以直接右键新建一个软件包,软件包的包名建议以域名格式进行命名,例如:
- com.baidu
- cn.itbaima
这类似于我们平时在浏览器中访问的网站地址,只不过是反过来的,这样就能很明确是哪一家公司或哪一个人制作的产品了。
这里我们随便创建一个:
我们可以将kt文件直接创建在这个包中:
所有不在默认包下kt文件,必须在顶部声明所属的包,比如这里的Test.kt就放在com.test
这个包中,因此顶部必须使用package关键字进行包声明,IDEA非常智能,在创建时就自动帮助我们生成好了。我们可以继续像之前一样,编写类或是函数:
|
|
不过,由于现在kt文件存放在了一个明确的包中,如果我们要在这个包以外的其他地方使用,会出现一些问题:
当我们使用其他包中kt文件定义的类或函数时,会直接提示未解析的引用,这是因为默认情况下只有同包内的内容可以相互使用,而现在我们使用的是其他包中的内容,我们需要先进行导入操作:
|
|
这样,我们在导入之后就可以正常使用了,当然,如果一个包中定义的内容太多,我们需要大量使用,也可以使用*
一次性导入全部内容:
|
|
实际上官方提供的库,也是来自于不同的包,但是Kotlin在默认情况下会自动导入一些包,不需要我们明确指定:
- kotlin.*
- kotlin.annotation.*
- kotlin.collections.*
- kotlin.comparisons.*
- kotlin.io.*
- kotlin.ranges.*
- kotlin.sequences.*
- kotlin.text.*
比如我们之前用到的一些基本类型,都是在kotlin
这个包中定义的。
注意:在不同的平台下,还会有更多默认导入的包,比如Java平台下,就会默认导入java.lang.*
和kotlin.jvm.*
这两个包。
在有些情况下,可能会出现名称冲突的情况:
|
|
结果显而易见,这里会优先使用导入的函数,而不是在当前文件中定义的同名函数。那么该如何去解决这种冲突的情况呢?我们可以使用as
关键字来为导入的内容起个新的名字:
|
|
这样就可以很好地消除存在歧义的情况了,最后总结一下,使用import
关键字支持导入以下内容:
- 顶级函数和属性
- 在单例对象中声明的函数和属性(下一章介绍)
- 枚举常量(下一章介绍)
访问权限控制
有些时候,我们可能不希望别人使用我们的所有内容,比如:
|
|
在上面的例子中,有一个函数是我们不希望被外部调用的,但是经过前面的学习,我们只需要使用import
关键字就能直接导入,那有没有办法能够控制一下其他地方对于当前文件一些可能私有函数或是其他内容的访问呢?我们可以使用可见性控制来处理。
在类、对象、接口、构造函数和函数,以及属性上,可以为其添加 可见性修饰符 来控制其可见性,在Kotlin中有四个可见性修饰符,它们分别是:private
、protected
、internal
和public
,默认可见性是public
,在使用顶级声明时,不同可见性的访问权限如下:
- 如果不使用可见性修饰符,则默认使用
public
,这意味着这里声明的内容将在任何地方可访问。 - 如果使用
private
修饰符,那么声明的内容只能在当前文件中可访问。 - 如果使用
internal
修饰符,它将在同一模块中可见(当前的项目中可以随意访问,与public没大差别,但是如果别人引用我们的项目,那么无法使用) - 顶级声明不支持使用
protected
修饰符。
因此,在默认情况下,我们定义的内容都是可以访问的,而想要实现上面的效果,我们可以为其添加private
修饰符:
|
|
这样,当其他地方使用时,就会报错:
在类中定义成员属性时,不同可见性的访问权限如下:
private
意味着该成员仅在此类中可见(包括其所有成员)protected
与private
的可见性类似,外部无法使用,但在子类中可以使用(子类会在下一章中介绍)internal
意味着本项目中任何地方都会看到其internal
成员,但是别人引用我们项目时不行。public
意味着任何地方都可以访问。
比如下面的例子:
|
|
有了访问控制,我们就可以更加明确地表示哪些内容是可以访问,而哪些是内部使用的。
封装、继承和多态
封装、继承和多态是面向对象编程的三大特性。
- 封装,把对象的属性和函数结合成一个独立的整体,隐藏实现细节,并提供对外访问的接口。
- 继承,从已知的一个类中派生出一个新的类,叫子类。子类实现了父类所有非私有化的属性和函数,并根据实际需求扩展出新的行为。
- 多态,多个不同的对象对同一消息作出响应,同一消息根据不同的对象而采用各种不同的函数。
正是这三大特性,能够让我们的Kotlin程序更加生动形象。
类的封装
封装的目的是为了保证变量的安全性,使用者不必在意具体实现细节,而只是通过外部接口即可访问类的成员,如果不进行封装,类中的实例变量可以直接查看和修改,可能给整个程序带来不好的影响,因此在编写类时一般将成员变量私有化,外部类需要使用Getter和Setter函数来查看和设置变量。从这里开始,我们前面学习的权限访问控制就开始起作用了。
我们可以将之前的类进行改进:
|
|
现在,外部需要获取一个学生对象的属性时,只能使用特定的函数进行获取,而不像之前一样可以随意访问对象的属性:
|
|
这样的好处显而易见,其他地方只能拿到在内部某个成员属性引用的对象,而没办法像之前那样直接修改Student对象中某个成员属性。
同样的,如果要运行外部对对象中的属性进行修改,那么我们也可以提供对应的set函数:
|
|
等等,这不就是我们之前讲的属性的getter和setter函数吗,没错,哪怕我们不手动编写,成员属性也会存在默认的。但是,除了直接赋值之外我们也可以设置更多参数才能给学生改名字:
|
|
我们自己封装好的名字设置方法暴露给外部使用,而不让外部直接操作名字。
我们甚至还可以将主构造函数改成私有的,需要通过其他的构造函数来构造:
|
|
封装思想其实就是把实现细节给隐藏了,外部只需知道这个函数是什么作用,如何去用,而无需关心实现,要用什么由类自己提供好,而不需要外面来操作类内部的东西去完成(你让我做一件事情,我自己的事情自己做,不要你来帮我安排)封装就是通过访问权限控制来实现的。
类的继承
前面我们介绍了类的封装,我们接着来看一个非常重要特性:继承。
在定义不同类的时候存在一些相同属性,为了方便使用可以将这些共同属性抽象成一个父类,在定义其他子类时可以继承自该父类,减少代码的重复定义,根据前面的访问权限等级,子类可以使用父类中所有非私有的成员。
比如说我们一开始使用的学生,那么实际上学生根据专业划分,所掌握的技能也会不同,比如体育生会运动,美术生会画画,土木生会搬砖,计算机生会因为互联网寒冬找不到工作,因此,我们可以将学生这个大类根据不同的专业进一步地细分出来:
虽然我们划分出来这么多的类,但是其本质上还是学生,也就是说学生具有的属性,这些划分出来的类同样具有,但是,这些划分出来的类同时也会拥有他们自己独特的技能。就好比大学里的学生无论什么专业都会打游戏,都会睡觉,逃课,考试抄答案,四六级过不了,只不过他们专业不同,学的的方向不一样,也就掌握了其他专业不具备的技能。
在Kotlin中,我们可以使用继承操作来实现这样的结构,默认情况下,Kotlin类是“终态”的(不能被任何类继承)要使类可继承,请用open
关键字标记需要被继承的类:
|
|
我们可以像下面这样来创建一个继承学生的类:
|
|
类的继承可以不断向下,但是同时只能继承一个类,在Kotlin中不支持多继承,只不过套娃还是可以的:
|
|
当一个类继承另一个类时,属性会被继承,可以直接访问父类中定义的属性,除非父类中将属性的访问权限修改为private
,那么子类将无法访问(但是依然是继承了这个属性的)比如下面的例子:
|
|
是不是感觉非常人性化,子类继承了父类的全部能力,同时还可以扩展自己的独特能力,就像一句话说的: 龙生龙凤生凤,老鼠儿子会打洞。这里需要特别注意一下,因为子类相当于是父类的扩展,但是依然保留父类的特性,所以说,在对象创建并初始化的时候,不仅会对子类进行初始化,也会优先对父类进行初始化:
|
|
实际上这里就是在构造这个子类对象之前,调用了一次父类的构造函数,而我们用于继承指定的构造函数,就是会被调用的那一个。
因此,如果父类存在一个有参构造函数,子类同样必须在构造函数中调用:
|
|
如果父类存在多个构造函数,可以任选一个:
|
|
当子类只存在辅助构造函数时,需要使用super关键字来匹配父类的构造函数:
|
|
也可以去匹配子类中其他构造函数:
|
|
如果子类既有主构造函数,也有辅助构造函数,那么其他辅助构造函数只能直接或间接调用主构造函数:
|
|
是不是感觉玩法太多,都眼花缭乱了?实际上只要各位小伙伴心里面清楚下面的规则,就很好理解上面这一堆写法了:
- 构造函数相当于是这个类初始化的最基本函数,在构造对象时一定要调用
- 主构造函数因为可能存在一些类的属性,所以说必须在初始化时调用,不能让这些属性初始化时没有初始值
- 子类因为是父类的延展,因此,子类在初始化时,必须先初始化父类,就好比每个学生都有学生证,这是属于父类的属性,如果子类在初始化时可以不去初始化父类,那岂不是美术生可以没有学生证?显然是不对的。
优先级关系:父类初始化 > 子类主构造 > 子类辅助构造
属性的覆盖
有些时候,我们可以希望子类继承父类的某些属性,但是我们可能希望去修改这些属性的默认实现。比如,美术生虽然也是学生,也会打招呼,但是可能他们打招呼的方式跟普通的学生不太一样,我们能否对打招呼这个函数的默认实现进行修改呢?
我们可以使用override
关键字来表示对于一个属性的重写(覆盖)就像这样:
|
|
覆盖之后,当我们使用子类进行打招呼时,函数会按照我们覆盖的内容执行,而不是原本的:
同样的,类的某个变量也是可以进行覆盖的:
|
|
是不是感觉很神奇?不过对于可变的变量,似乎下面这样来的更方便?
|
|
有些时候为了方便,比如在父类中的属性,我们可以直接在子类的主构造函数中直接覆盖:
|
|
|
|
虽然现在已经很方便了,但是现在又来了一个新的需求,打招呼不仅要有子类的特色,同时也要保留父类原有的实现,这个时候该怎么办呢?我们可以使用super
关键字来完成:
|
|
这样,我们在覆盖原本的函数时,也可以执行原本的实现,在一些对函数内容进行增强的常见,这种用法非常常见:
不过,由于存在我们之前讲解的的初始化顺序,下面的这种情况需要特别注意:
|
|
|
|
由于父类初始化在子类之前,此时子类还未开始初始化,其覆盖的属性此时没有初始值,根据不同平台的实现,可能会出现一些问题,比如JVM平台下,没有初始化的对象引用默认为null
,那么这里就会直接报空指针异常:
很神奇对吧,这里的name
属性明明是一个非可空的String类型,居然还会出现null
的情况报空指针,因此,对于这些使用了open
关键字的属性(函数、变量等)只要是在初始化函数、构造函数中使用,IDEA都会给出警告:
我们接着来讲一个很绕的东西,在使用一些子类的时候,我们实际上可以将其当做其父类来进行使用:
|
|
之所以支持这样去使用,是因为子类本身就是对父类的延伸,因此将其当做父类使用,也是没有问题的。就好比我们无论是美术生还是体育生,都可以当做学生来用,都可以送去厂里实习打螺丝,不然不给毕业证。
只不过,如果我们将一个对象作为其父类使用,那么在使用时也只能使用其父类的一些属性,就相当于我们在使用一个父类的对象:
即使我们很清楚这里引用的对象是一个美术生,但是只能当做普通学生来用,这在后面的集合类中会经常用到,因为集合类往往存在多种不同的实现,但是我们只需要关心怎么用就行了,并且为了方便更换实现,所以一般使用集合类对应的接口来作为变量的类型。
那么,如果子类重写了父类的某个函数,此时我们以父类的形式去使用,结果会怎么样?
|
|
可以看到,虽然当做父类来使用,但是其本质是不会变的,所以说,这里执行的结果依然是子类的覆盖实现。
那么,如果项目中很多这种明明是子类但是拿来当做父类用,我们怎么去判断使用的对象到底是什么类型的呢?我们可以使用is
关键字来进行类型判断,以下面的三个类为例:
|
|
现在我们进行类型判断:
|
|
可以看到,使用is关键字可以精准地对类型进行判断,只要判断的对象是这个类或是这个类的子类,那么就会返回true作为结果。
如果我们明确某个变量引用的对象是什么类型,可以使用as
关键字来进行强制类型转换:
|
|
不过,编译器非常智能,它可以根据当前的语境判断的类型自动进行类型转换:
|
|
此时IDEA中会出现提示:
不仅仅是if判断的场景、包括when、while,以及&&
||
等运算符都支持智能转换,只要上下文语境符合就能做到:
|
|
不仅仅是这种场景,比如我们前面讲解的可空类型,同样支持这样的智能转换:
|
|
在处理一些可空类型时,为了防止出现异常,我们可以使用更加安全的as?
运算符:
|
|
有了这些操作,类和对象在我们使用的过程中就逐渐开始千变万化了,后面我们还会继续认识更多的多态特性。
顶层Any类
在我们不继承任何类的情况下,实际上Kotlin会有一个默认的父类,所有的类默认情况下都是继承自Any类的。
这个类的定义如下:
|
|
由于默认情况下类都是继承自Any,因此Any中定义的函数也是被继承到子类中了。
首先我们来看这个equals
函数,它实际上是对==
这个运算符的重载,我们之前在使用一些基本类型的时候,就经常使用==
来判断这些类型是否相同,比如Int类型的数据:
|
|
经过前面的学习,我们知道这些基本类型本质上也是定义的类,实际上它们也是通过重写这个函数来实现这些比较操作的(一些基本类型会根据不同的平台进行编译优化,没法看源码)
我们可以看到,这个函数接受的参数类型是一个Any?
类型:
|
|
到目前为止,我们认识了Kotlin中两种相等的判断方式:
- 结果上 相等 (
==
等价于equals()
) - 引用上 相等 (
===
判断两个变量是否都是引用的同一个对象)
我们在使用equals
比较两个可空对象是否相等时,就像这样:
|
|
实际上会被翻译为:
|
|
当然可能会有小伙伴疑问,那不等于判断呢?实际上是一样的:
|
|
我们也可以为我们自己编写的类型重写equals
函数,比如我们希望Student类型当名字和年龄相等时,就可以使用==
来判断为true,我们可以像这样编写:
|
|
此时我们已经将其比较操作重写,我们可以来试试看:
|
|
默认情况下,如果我们不重写类的equals
函数,那么会直接对等号两边的变量进行引用判断===
判断是否为同一个对象。只不过,可以很清楚地看到IDEA提示我们:
实际上在我们重写类的equals
函数时,根据约定,必须重写对于的hashCode函数,至于为什么,我们会在后续的集合类部分中进行介绍,这里我们暂时先不对hashCode函数进行讲解。
接着我们来看下一个,toString
函数用于快速将对象转换为字符串,只不过默认情况下,会像这样:
|
|
可以看到打印的结果是对象的类型@十六进制哈希值
的形式,在某些情况下,可能我们更希望的是转换对象的一些成员属性,这样我们可以更直观的看到对象的属性具有什么值:
|
|
现在得到的结果,就是我们自定义的结果了:
抽象类
有些情况下,我们设计的类可能仅仅是作为给其他类继承使用的类,而其本身并不需要创建任何实例对象,比如:
|
|
可以看到,在上面这个例子中,Student类的hello
函数在子类中都会被重写,所以说除非在子类中调用父类的默认实现,否则一般情况下,父类中定义的函数永远都不会被调用。
就像我们说一个学生会怎么考试一样,实际上学生怎么考试是一个抽象的概念,但是由于学生的种类繁多,美术生怎么考试和体育生怎么考试,才是具体的一个实现。所以说,我们可以将学生类进行进一步的抽象,让某些函数或字段完全由子类来实现,父类中不需要提供实现。我们可以使用abstract
关键字来将一个类声明为抽象类:
|
|
当一个子类继承自抽象类时,必须要重写抽象类中定义的抽象属性和抽象函数:
|
|
这是强制要求的,如果不进行重写将无法通过编译。同时,抽象类是不允许直接构造对象的,只能使用其子类:
当然,抽象类不仅可以具有抽象的属性,同时也具有普通类的性质,同样可以定义非抽象的属性或函数:
|
|
同时,抽象类也可以继承自其他的类(可以是抽象类也可以是普通类)
|
|
虽然抽象类可以继承一个普通的类,但是这依然不改变它是抽象类的本质,子类依然要按照上面的要求进行编写。
接口
由于Kotlin中不存在多继承的操作,我们可以使用接口来替代。
前面我们认识了抽象类,它可以具有一些定义而不实现的内容,而接口比抽象类还要抽象,一般情况下,他只代表某个确切的功能!也就是只能包含函数或属性的定义,所有的内容只能是abstract
的,它不像类那样完整。接口一般只代表某些功能的抽象,接口包含了一系列内容的定义,类可以实现这个接口,表示类支持接口代表的功能。
比如,学生具有以下功能:
- 打游戏
- 睡懒觉
- 逃课
- 考试作弊
我们可以将这一系列功能拆分成一个个的接口,然后让学生实现这些接口,来表示学生支持这些功能。
在Kotlin中,要声明接口,我们可以使用interface
关键字:
|
|
可以看到,接口相比于抽象类来说,更加的纯粹,它不像类那样可以具有什么确切的属性,一切内容都是抽象的,只能由子类来实现。
只不过,在接口中声明的属性可以是抽象的,也可以为Getter提供默认实现。在接口中声明的属性无法使用field
后背字段,因此在接口中声明的Setter无法使用field
进行赋值:
|
|
|
|
为了应对变化多端的需求,接口也可以为函数编写默认实现:
|
|
这样一看,这函数可以写默认的实现那接口似乎变得不那么抽象了?这用着感觉好像跟抽象类没啥区别啊?接口跟类的最大区别其实就是状态的保存,这从上面的成员属性我们就可以看的很清楚。
接口也可以继承自其他接口,直接获得其他接口中的定义:
|
|
是不是感觉接口的玩法非常有意思?只不过玩的过程中,可能也会遇到一些麻烦,比如下面的这种情况:
|
|
这种情况下,我们需要手动解决冲突,比如我们希望Student类采用接口B的默认实现:
|
|
对于接口,我们可以像之前一样,将变量的类型设定为一个接口的类型,当做某一个接口的实现来使用,同时也支持is
、as
等关键字进行类型判断和转换:
|
|
是不是感觉跟之前使用起来是差不多的?其实只要前面玩熟悉了,后面还是很简单的。
类的扩展
Kotlin提供了扩展类或接口的操作,而无需通过类继承或使用装饰器等设计模式,来为某个类添加一些额外的函数或是属性,我们只需要通过一个被称为扩展的特殊声明来完成。
例如,您可以从无法修改的第三方库中为类或接口编写新函数,这些函数可以像类中其他函数那样进行调用,就像它们是类中的函数一样,这种机制被称为扩展函数。还有扩展属性,允许您为现有类定义新属性。
比如我们想为String类型添加一个自定义的操作:
|
|
是不是感觉很神奇?通过这种机制,我们可以将那些第三方类不具备的功能强行进行扩展,来方便我们的操作。
注意,类的扩展是静态的,实际上并不会修改它们原本的类,也不会将新成员插入到类中,仅仅是将我们定义的功能变得可调用,使用起来就像真的有一样。同时,在编译时也会明确具体调用的扩展函数:
|
|
由于类的扩展是静态的,因此在编译出现歧义时,只会取决于形参类型。
如果是类本身就具有同名同参数的函数,那么扩展的函数将失效:
|
|
不过,我们如果通过这种方式实现函数的重载,是完全没有问题的:
|
|
同样的,类的属性也是可以通过这种形式来扩展的,但是有一些小小的要求:
可以看到直接扩展属性是不允许的,前面我们说过,扩展并不是真的往类中添加属性,因此,扩展属性本质上也不会真的插入一个成员字段到类的定义中,这就导致并没有变量去存储我们的数据,我们只能明确定义一个getter和setter来创建扩展属性,才能让它使用起来真的像是类的属性一样:
|
|
由于扩展属性并没有真正的变量去存储,而是使用get和set函数来实现,所以,像前面认识的field这种后备字段,就无法使用了。
还有一个需要注意的时,我们在不同包中定义的扩展属性,同样会受到访问权限控制,需要进行导入才可以使用:
|
|
除了直接在顶层定义类的扩展之外,我们也可以在一个类中定义其他类的扩展,并且在定义时可以直接使用其他类提供的属性:
|
|
像这种扩展,由于是在类中定义,因此也仅限于类内部使用,比如:
|
|
扩展属性无法访问那些本就不应该被当前作用域访问的类属性,即使它是对某个类的扩展,比如下面这种情况:
在名称发生冲突时,需要特别处理:
|
|
定义在类中的扩展也可以跟随类的继承结构,进行重写:
|
|
局部扩展也是可以的,我们可以在某个函数里面编写扩展,但作用域仅限于当前函数:
|
|
如果我们将一个扩展函数作为参数给到一个函数类型变量,那么同样需要再具体操作之前增加类型名称才可以:
|
|
可以看到,此函数的类型是String.() -> Int
,也就是说它是专门针对于String类型编写的扩展函数,没有参数,返回值类型为Int,并使用Lambda表达式进行赋值,同时这个函数也是属于String类型的,只能由对象调用,或是主动传入一个相同类型的对象作为参数才能直接调用。可能这里会有些绕不太好理解,需要同学们多去思考。
总结一下,扩展属性更像是针对于原本类编写的外部工具函数,而绝不是对原有类的修改。
Kotlin程序设计高级篇
在学习了前面的内容之后,相信各位小伙伴应该对Kotlin这门语言有了一些全新的认识,我们已经了解了大部分的基本内容,从本章开始,就是对我们之前所学的基本内容的进一步提升。
泛型
在前面我们学习了最重要的类和对象,了解了面向对象编程的思想,注意,非常重要,面向对象是必须要深入理解和掌握的内容,不能草草结束。在本章节,我们还会继续深入,从泛型开始,再到我们的集合类学习,循序渐进。
什么是泛型
为了统计学生成绩,要求设计一个Score对象,包括课程名称、课程号、课程成绩,但是成绩分为两种,一种是以优秀、良好、合格
来作为结果,还有一种就是 60.0、75.5、92.5
这样的数字分数,可能高等数学这门课是以数字成绩进行结算,而计算机网络实验这门课是以等级进行结算,这两种分数类型都有可能出现,那么现在该如何去设计这样的一个Score类呢?
现在的问题就是,成绩可能是String
类型,也可能是Int
类型,如何才能更好的去存可能出现的两种类型呢?
|
|
虽然这样看起来很不错,但是Any毕竟是所有类型的顶级父类,在编译阶段并不具有良好的类型判断能力,很容易出现以下的情况:
|
|
使用Any类型作为引用虽然可以做到任意类型存储,但是对于使用者来说,由于是Any类型,所以说并不能直接判断存储的类型到底是String还是Int,取值只能进行强制类型转换,显然无法在编译期确定类型是否安全,项目中代码量非常之大,进行类型比较又会导致额外的开销和增加代码量,如果不经比较就很容易出现类型转换异常,代码的健壮性有所欠缺。
所以说这种解决办法虽然可行,但并不是最好的方案,我们需要使用一个更好的东西来实现: 泛型
泛型其实就一个待定类型,我们可以使用一个特殊的名字表示泛型,泛型在定义时并不明确是什么类型,而是需要到使用时才会确定对应的泛型类型,Kotlin中的类可以具有类型参数:
|
|
可以看到,它相比普通的类型,仅仅多了一个<T>
表示类型参数,那么如何使用呢?
|
|
既然可以做到使用时明确,那现在我们应该怎么去设计这个类呢?
|
|
泛型将数据类型的确定控制在了编译阶段,在编写代码的时候就能明确泛型的类型,如果类型不符合,将无法通过编译,同时,如果我们这里填入的参数明确是一个String类型的值,创建时不需要指定T的类型也会自动匹配:
|
|
而泛型类型在类内部使用时,由于无法确定具体类型,也只能当做Any类去使用:
因为泛型本身就是对某些待定类型的简单处理,如果都明确要使用什么类型了,那大可不必使用泛型。还有,不能通过这个不确定的类型变量就去直接创建对象:
还有,由于泛型在创建时就已经确定,因此即使都是Score类,由于类型参数的不同也会导致不通用:
有了泛型之后,我们再来使用一些类型就非常方便了,并且泛型并不是每个类只能存在一个,我们可以一次性定义多个类型参数:
|
|
多个不同的类型参数代表不同的类型,这些都可以在使用时明确,并且互不影响。
Kotlin还提供了下划线运算符可以自动推断类型:
1 2 3 4 5
fun <K: Comparable<V>, V> test() { } //类型参数中第一个类型参数可以直接推断得到 fun main() { test<Int, _>() //由于前面的类型本身就是Comparable<Int>的子类,已经明确了V的类型,后面就没必要再写一次了,直接使用下划线运算符进行推断即可 }
感觉使用场景应该比较少,了解就行。
当然,不只是类,包括接口、抽象类,都是可以支持泛型的:
|
|
子类在继承时,可以选择将父类的泛型参数给明确为某一类型,或是使用子类定义的泛型参数作为父类泛型参数的实参使用:
|
|
除了在类上定义泛型之外,我们也可以在函数上定义:
|
|
甚至在使用函数类型的参数时,我们可以使用泛型来代表不确定的类型:
|
|
在这之后,我们还会遇到更多官方提供的泛型函数,尤其是下一章的数组和集合部分。
官方高阶扩展函数
为了我们开发的便利,官方提供了一系列内置的高阶函数,大部分都是通过扩展函数形式定义,我们可以使用来简化我们的代码。
我们之前在使用时或许就已经发现了:
那么怎么依靠它们来简化我们的代码呢?比如下面的代码:
|
|
由于传入的是一个可空类型,这导致我们在使用时非常不方便,每次都需要进行判断,有没有更优雅一点的方式来处理呢?
|
|
太优雅了,同样的操作,原本繁杂的调用直接简化成了简单的几句代码,真是舒服啊!
我们来介绍一下这些函数时如何使用的,这里以apply为例,这个函数功能是简化我们对某个对象的操作并在最后返回对象本身,在Standard.kt中是这样定义的:
|
|
可以看到,这个函数也是以扩展函数定义的T可以代表任何类型,所有的类都可以使用这个预设的扩展函数,并且它的参数是一个T.() -> Unit
函数类型的,很明显这是一个高阶函数,并且最后一个参数就是函数类型,后续可以结合我们之前讲解的简化代码。
这个参数非常有意思,比如我们原来需要这样编写:
|
|
我们现在可以进行代码优化:
|
|
什么鬼,怎么突然就变得这么简单了?我们一个一个来看:
|
|
我们可以直接将对这个对象全部的操作搬进来,然后在一个Lambda里面就能完成,接着我们对这个对象的其他操作,可以直接在后续编写,因为返回的也是这个对象本身,所以,使用这些预设的高阶函数,在很多情况下都能省掉我们不少代码量。
这里我们来看几个比较常用的:
-
let
:用于执行一个lambda表达式并将得到的结果作为返回值返回。1 2 3 4 5
//对当前对象进行操作,得到一个新的类型值并作为结果返回 public inline fun <T, R> T.let(block: (T) -> R): R { ... return block(this) //调用我们传入的函数,并将结果作为let返回值 }
-
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类型对象本身 }
-
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来完成:
|
|
对于takeIf的使用就像下面这样:
|
|
一个很复杂的工作,可能需要很多行代码才能搞定,但是现在借助这些预设的高阶扩展函数,我们就可以以更简短的代码完成。
还有一个比较有意思的:
|
|
用起来就像这样:
|
|
除了我们上面提到的这些,其实在Standard.kt还提供了更多有意思的工具函数,由于篇幅有限,还请各位小伙伴自行探索。
协变与逆变*
注意: 这一部分相当有难度,请务必将前面的泛型概念理解到位,否则很难继续学习。
我们在前面介绍了泛型的基本使用,实际上就是一个待定的类型,我们在使用时可以指定具体的类型,并在编译时检查类型是否匹配,保证运行时类型的安全性,就像下面这样:
|
|
一旦泛型变量类型确定,后续将一直固定使用此类型,并且不兼容其他类型:
但是现在存在这样一个问题,我们如果使用某个类型的父类呢,会不会出现类型不匹配的情况?
可以看到,即使是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
关键字用于标记一个类型参数作为逆变,可以实现父类到子类的转换。
那么该怎么使用呢,非常简单:
|
|
虽然看上去非常难理解,但是简单来说,其实就是为类型添加一个可以转换子类的性质,out
作用就是使类型支持协变,可以支持泛型从父类转换为子类,但是不能子类转父类,比如这里使用Any就没法成功接受。相反的,如果我们标记某个类型为in
,那么这个类型就是逆变的,可以由父类向下转化:
|
|
用树形图展示,关系如下:
在使用这种协变或逆变类型时,具体使用的类型就变得不确定了,导致不同的界限会有不同的效果,比如下面:
|
|
|
|
在使用out
和in
之后,类型的使用就可以更加灵活,但是这样会存在一定的安全隐患,比如下面的代码:
|
|
为了解决这种情况,Kotlin对于out或in的类型进行了限制,比如设置了out的情况下:
属性的setter操作被限制,无法通过编译,因为这可能会导致不安全的操作发生,而in也是同理的:
|
|
因此,在使用in
时,属性的getter操作被限制,会提示类型不匹配,得到的类型也是Any? 无法通过编译,同样是因为可能存在不安全的操作。不仅仅是属性,包括所有函数的参数、返回值,都会受到限制:
|
|
因此,对于in和out来说,协变和逆变的属性将其限制为了生产者和消费者:
- 使用
out
修饰的泛型不能用作函数的参数,对应类型的成员变量setter也会被限制,只能当做一个生产者使用。 - 使用
in
修饰的泛型不能用作函数的返回值,对应类型的成员变量getter也会被限制,只能当做一个消费者使用。
在了解了这么多泛型的知识之后,相信各位小伙伴已经感受到泛型的巧妙而又复杂的设计了。
最后,在有些时候,我们可能并不在乎到底使用哪一个类型,我们希望一个变量可以接受任意类型的结果,而不是去定义某一个特定的上界或下界。在Kotlin泛型中,星号(*
)代表了一种特殊的类型投影,可以代表任意类型:
|
|
同样的,由于不确定具体类型,使用时只能是Any?类型,跟上面in的情况一样,这里就不做演示了,下一章我们还会继续探讨更多*
的默认情况。
泛型界限*
注意: 这一部分相当有难度,请务必将前面的泛型概念理解到位,否则很难继续学习。
前面我们介绍了协变和逆变,使得泛型的类型可以灵活变化使用,而我们在定义类的时候,在类型参数位置也可以进行限制。
比如有一个新的需求,现在没有String类型的成绩了,但是成绩依然可能是整数,也可能是小数,这时我们不希望用户将泛型指定为除数字类型外的其他类型,这又该怎么去实现呢?
|
|
使用类似于继承的语法来完成类型的上界限制,定义后,使用时的具体类型只能是我们指定的上界类型或是上界类型的子类,不得是其他类型,否则一律报错:
在默认情况下,如果我们不指定,那么上界类型就是Any?,而现在,我们在使用时就只能将类型指定为Number的子类了。
如果我们需要设定多个上界,比如必须同时是某两个类型的子类(或接口实现)像这样多个约束设定,我们需要使用where
关键字:
|
|
通过设定上界,能够更加规范类的使用。
有时候为了方便,我们也可以直接在类定义的时候直接将类型参数指定为out
或是in
来使得其协变或逆变:
|
|
这样我们使用时就可以实现类型自动适应:
|
|
同样的,我们前面说了在添加in
或out
后会限制相应的行为来保证类型的安全性,在定义类的一些函数或属性的时候都会得到警告:
在了解了类型界限相关内容之后,我们再来看看*
类型投影在不同情况下的默认类型,比如:
-
对于
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?>
。
泛型的使用可以很简单也可以很复杂,想要完全把这个搞明白还是需要多练多理解才能达到。
类型擦除*
注意: 这一部分相当有难度,请务必将前面的泛型概念理解到位,否则很难继续学习。
前面我们介绍了泛型的使用,以及各种高级功能,但是实际上,泛型的类型检查仅仅只存在于编译阶段,在源代码编译之后,实际上并不会保留任何关于泛型类型的内容,这便是类型擦除。
比如下面的类型:
|
|
在编译时候,会自动擦除类型:
|
|
如果存在上界,那么擦除后会是上界的类型:
|
|
|
|
由于在运行时不存在泛型的概念,因此,很多操作都是不允许的,比如类型判断:
|
|
包括我们在使用这个泛型类时:
|
|
因此,正是为了保证类型擦除之后程序能够安全运行,才有了上面这么多限制。
对于内联函数,泛型擦除的处理会有一些不同,得益于它的内联性质,内联函数的代码是在编译时期直接插入到调用处的,在编译之后具体类型必须要存在,否则会出现问题(因为类型可以明确)因此其泛型参数的具体类型信息是可用的,编译器可以使用这些信息来生成更具体的字节码。这意味着,对于内联函数的泛型参数,并不会像非内联函数那样发生类型擦除。
|
|
内联函数编译后,类型直接保留:
|
|
Kotlin的内联函数还有一个功能是可以使用具化的类型参数(reified
关键字)具化类型参数允许在函数体内部检测泛型类型,因为这些类型信息会被编译器内嵌在调用点。但是,这只适用于内联函数,因为类型信息在编译时是可知的,并且实际类型会被编译到使用它们的地方,使用也很简单:
|
|
具化类型参数仅适用于内联函数。
数组
前面我们介绍了泛型,它可以实现在编写代码阶段的类型检查,现在我们就可以正式进入到数组的学习当中了。
假设出现一种情况,我们想记录100个数字,要是采用定义100个变量的方式可以吗?是不是有点太累了?这种情况我们就可以使用数组来存放一组相同类型的数据。
在Kotlin中,数组是Array类型的对象。
创建数组
数组是相同类型数据的有序集合,数组可以代表任何相同类型的一组内容,其中存放的每一个数据称为数组的一个元素,我们来看看如何创建一个数组,在Kotlin中有两种创建方式:
- 官方预设工具函数,如
arrayOf()
、arrayOfNulls()
以及emptyArray()
- 使用类
Array
构造函数创建。
比如我们要创建一个包含5个数字的数组,那么我们可以像这样:
|
|
这里得到的结果类型为Array,它是一个泛型类
|
|
可以看到,数组本质就是一个Array类型的对象,其类型参数就是我们存储的元素类型,由于使用构造函数创建数组稍微有些复杂,我们将其放到后面进行介绍。
注意: 数组在创建完成之后,数组容量和元素类型是固定不变的,后续无法进行修改。
|
|
既然现在创建好了数组,那么该如何去访问数组里面的内容呢?
|
|
由于数组存放的是一组元素,我们在访问每个元素时需要告诉程序我们要访问的是哪一个,而每个元素都有一个自己的下标地址,下标从0开始从左往右依次递增排列,比如我们要访问第一个元素那么下标就是0,第三个元素下标就是2,以此类推:
|
|
注意,在使用数组时,我们只能访问数组可以访问的范围,如果我们获取一个范围之外的元素,会得到错误,比如当前的数组的大小是5那么也就只能包含5个元素,此时我们去访问第六个元素,显然是错误的:
|
|
我们也可以使用[]
修改数组中指定下标元素的值:
|
|
还有一个要注意的是,我们直接打印这个数组对象并不能得到数组里面每个元素的值,而是一堆看不懂的东西:
具体原因可以通过学习Java后进行了解,如果各位小伙伴需要打印数组中的每一个元素,我们只能一个一个打印,可以使用一个for循环语句来完成:
|
|
不过,在Kotlin中,这样编写并不优雅,我们有更好的方式去遍历数组中的每一个元素,在之前我们学习for循环语句时,谈到使用in来遍历一个可遍历的目标,而数组就是满足这个条件的,我们可以直接遍历它:
|
|
当然,如果我们还是希望按照数组的索引进行遍历,也可以使用:
|
|
如果你想同时遍历索引和元素本身,也可以使用withIndex函数,它会生成一系列IndexedValue对象:
|
|
在使用forin时,我们也可以对待遍历的元素进行结构操作,当然,前提是这些对象类型支持解构,比如这里的IndexedValue就支持解构,所以我们可以在遍历时直接使用解构之后的变量进行操作:
|
|
如果需要使用Lambda表达式快速处理里面的每一个元素,也可以使用forEach
高阶函数:
|
|
如果只是想打印数组里面的内容,快速查看,我们可以使用:
|
|
我们接着来看一下如何使用构造函数来创建数组,首先构造函数时这样定义的:
|
|
比如我们希望创建一个字符串数组:
|
|
利用这种特性,我们可以快速创建一个全是同一个值的数组:
|
|
还可以快速搞一个平方数数组:
|
|
不过,其实一般情况下使用arrayOf
都可以解决大部分情况了,还有它的变种,大概介绍一下:
|
|
下一节课我们接着学习更多数组的操作。
使用数组
现在我们已经学习了如何创建数组,实际上官方库提供了很多数组的扩展函数,方便我们对于数组的使用,我们现在就来看看吧。
对于两个数组来说,如果我们要比较它们之间是否包含相同的内容,需要使用特殊的比较函数:
|
|
要拷贝一个数组的内容并生成一个新的数组,可以:
|
|
copyOf函数可以指定拷贝的长度或是拷贝的范围,使用更加灵活一些:
|
|
|
|
还有一个比较类似操作,但是可以使用Range进行分割:
|
|
两个数组也可以直接拼接到一起,形成一个长度为10的新数组,按顺序拼接:
|
|
快速查找元素肯定也是不在话下的:
|
|
不过,可能会有小伙伴好奇,这里的contains
函数传入的对象,是如何进行判断的?比如我要删除某一个元素,程序是如何将数组内的对象与传入的对象进行比较得出是相同的元素呢?我们来看下面这个例子:
|
|
怎么回事?我们这明明传入的是两个内容一样的对象啊,为什么是false呢?直接看源码:
|
|
我们在前面介绍过,使用==的判断实际上取决于equals函数的重写,如果要让两个对象实现我们自定义的判断,需要重写对应类型的equals函数,否则无法实现自定义比较,默认情况下判断的是两个对象是否为同一个对象,所以,我们可以尝试重写一下:
|
|
现在得到的结果就是我们希望的样子了。
也可以快速判断和获取元素:
|
|
我们也可以快速将一个数组的内容进行倒序排放:
|
|
|
|
如果我们想要直接将数组中元素打乱,也有一个快速洗牌的函数将所有元素顺序重新随机分配:
|
|
打乱了想重新还原成有序的数组咋办?
|
|
注意,排序操作并不是任何类型上来就支持的,由于这里我们使用的是基本类型Int,它默认实现了Comparable接口,这个接口用于告诉程序我们的排序规则,所以,如果数组中的元素是未实现Comparable接口的类型,那么无法支持排序操作。
我们可以来尝试实现一下:
|
|
这样,我们自定义的类型就支持比较和排序了:
|
|
还有可以快速填充数组内容的函数:
|
|
好了,就先介绍这么多吧,到这里也才介绍了数组操作的一半,后面到了集合类我们再来介绍更多使用的扩展函数,因为集合数组都是支持的。
可变长参数
前面我们介绍了数组的使用,不知道各位小伙伴有没有疑惑,在使用arrayOf时,里面的参数为什么可以随意填写不同数量?
|
|
函数的参数数量不是固定的吗?怎么还能动态加?难道我们前面学的是假的函数?这其实是因为这个函数使用了可变长参数的原因,它可以实现同一个类型的参数任意填写:
|
|
但是需要注意的事,可变长参数在函数的形参列表里面只能存在一个,下面这几种情况:
|
|
那么,这种可变长参数在函数中如何使用呢?我们可以将其当做一个Array来使用:
|
|
这样一看就简单多了,可变长参数本质就是一个数组。
那么既然可变长参数是一个数组,我们可不可以直接把数组作为参数给到一个可变长参数中呢?
|
|
这就有点不太合理了,反正都是数组为啥我不能直接传个数组进去当做实参呢,因此Kotlin给我们提供了一个扩展运算符(*
)此运算符将数组的每个元素作为单个参数传递:
|
|
别急,你以为这样就结束了吗,它还可以混着用:
|
|
因此,如果我们需要将一个数组的内容复制到一个新的数组中,直接这样操作就好了:
|
|
原生类型数组
在之前,我们使用了大量基本类型数组,比如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类型没有任何继承关系,但是它们有同样的方法属性集,使用起来区别不大,优先使用基本类型数组,可以使得程序免得到一定优化,增加效率:
|
|
这些原生类型数组也有一些额外的扩展,比如快速求和:
|
|
还有求平均值之类的:
|
|
快速获取最大值和最小值:
|
|
其他使用基本一致,这里就不多进行介绍了。
嵌套数组
有些时候,单个维度的数组似乎无法满足我们的需求。比如我们现在6个元素为一组存储,现在共需要存储4组这样的数据,我们不可能去定义4个一样的数组吧?这个时候就需要用到嵌套数组了。
存放数组的数组,相当于将维度进行了提升,比如下面的就是一个2x10的数组:
二维数组看起来更像是一个平面,同理,三维数组就是一个立方体空间,四位数组就进入到我们人类无法理解的范围了,由很多个三维组成(物理上解释或许是时间轴?)
那么像这样的多维度数组如何创建呢?这里我们以二维数组为例,三维四维同理:
|
|
可以看到,我们使用arrayOf去囊括多个IntArray,这样,最外层的Array相当于是保存多个IntArray的Array,也就实现了我们上面的二维数组效果了。当然像这样也是可以的:
|
|
嵌套数组看起来可能有些绕,但是其实仔细分析之后还是比较简单的。
我们在使用二维数组时:
|
|
所以,如果我们要获取位于整个二维矩阵左上角的第一个元素,可以像这样:
|
|
对于这种二维数组,如果需要遍历,我们同样可以使用for循环来完成,不过需要两层for才可以搞定:
|
|
由于现在数组内存放的是数组,我们在比较两个嵌套数组的内容是否相同时,需要使用深度比较:
|
|
这里还有一个知识误区,虽然我们使用的看起来确实类似于二维数组,但是每一个数组的长度并不需要是相同的:
|
|
甚至类型也可以不一样:
|
|
不过正常情况下,我们还是会按照标准的二维数组来使用,这样更加规范一些。
集合类
前面我们学习了数组的使用,虽然数组可以很方便地存储一组数据,但是它存在诸多限制:
- 长度是固定的,无法扩展
- 无法做到在数组中像列表那样插入或者删除元素
显然,在很多情况下,数组虽然可以存储一组数据,但是它并不好用,我们需要借助更加强大的集合类来实现更多高级功能。在Kotlin中,默认提供了以下类型的集合:
- List: 有序的集合,通过索引访问元素,可以包含重复元素,比如电话号码:它就是一组数字,并且顺序很重要,而且数字可以重复。
- Set: 不包含重复元素的集合,它更像是数学中的集合,一般情况下不维护元素顺序,例如,彩票上的数字:都是独一无二的,并且它们的顺序不重要。
- Map: 是一组键值对,其中每一个键不可重复存在,每个键都映射到恰好一个值(值可以重复存在)这跟数学上的映射关系很像。它经常用于存储(员工ID -> 员工具体信息)这样的结构。
所有集合类都是继承自Collection接口(Map除外)我们可以看看这个接口的定义:
|
|
这个接口定义了集合的基本操作,以及核心属性,而由集合顶层接口衍生的不同集合类,也都有自己的定义。集合类一般都是以接口类型的变量进行使用,因为不同的集合可能存在不同的集合实现类,为了使用起来更加通用,我们往往会使用集合类的接口进行操作。
下面就让我们一个一个认识吧。
List集合
List就像它的名字一样,就是一个列表,它存储一组有序的数据,比如我们看到的餐厅菜单,还有游戏的排行榜,每一道菜、每一个玩家都是按顺序向下排列的,并且根据情况,我们可以自由地在某个位置插入或删除一个新的元素,列表的长度也会动态发生变化,List集合就是为了这些功能而生的。
要创建一个List集合非常简单,就跟我们之前创建数组一样:
|
|
我们发现,使用List集合之后,很多操作其实跟数组是基本一样的,它同样可以存储一组元素,以及修改。
除了可以使用数组支持的操作之外,为了能够作为列表使用,还有很多新的操作,比如我们希望在末尾添加一个新的元素到列表中:
|
|
我们可以在整个列表之间的任意位置插入,但是同样不能出现越界的情况:
|
|
既然可以插入元素,同样的也可以删除元素:
|
|
可以看到,列表相比我们传统的数组来说,完整地支持了增删改查这四个操作,使用起来也会更加方便。
当然,有些时候可能我们希望获取一个只读的列表:
|
|
类似于数组,还有多种列表创建函数:
|
|
|
|
或是使用构造函数来创建一个列表:
|
|
如果我们需要遍历一个列表,同样很简单,跟数组完全一样:
|
|
集合也支持加法和减法运算:
|
|
使用还是非常简单的。
Set集合
Set集合非常特殊,虽然它也可以保存一组数据,但是它不允许存在重复元素,我们无法让Set集合中同时存在两个一样的元素,这在一些需要去重的场景中非常实用,这跟数学中定义的集合非常相似。
创建一个Set集合很简单:
|
|
与列表一样,可以随意插入元素,元素默认在尾部插入,顺序为插入顺序:
|
|
不过Set默认不支持在指定位置插入元素,只能尾插,同时我们也不能通过下标去访问Set中的元素,这是因为Set底层采用的并不是线性数据结构存储,而是用了哈希表或是树形结构(感兴趣的小伙伴可以看一下另一期数据结构与算法篇教程)而内部元素的顺序则是采用的其他形式进行维护的。
不过,我们到是可以借助迭代器来获取当前顺序上的第N个元素:
|
|
有关迭代器的知识,我们放在后面进行讲解。
同时,由于Set更接近于数学上的集合概念,Kotlin为我们准备了很多集合之间的操作:
|
|
虽然集合相关操作也可以应用于List集合,但是计算得到的结果始终是Set集合:
|
|
对于Set集合,官方也有很多预设的函数用于创建:
|
|
|
|
|
|
|
|
|
|
最后我们来讲解一个前面就买下伏笔的问题,这里我们创建了一个Student类型的Set集合:
|
|
虽然我们插入了两个相同的数据,但是它们本质上是两个对象,只是内容相同,所以,Set中会认为它们不同,同时得到保存:
为了解决这种问题,我们之前采用的是重写equals
函数来重新定义比较规则,这样就可以实现内容相同判断为同一个了:
|
|
再次执行程序,我们发现似乎没什么卵用:
什么鬼,这明明都把比较规则给自定义了,怎么还是不能判断为同一个呢?我们之前难道学的是个假的吗?我们注意到类上有一个警告,提示我们没有重写了eq函数但是没有定义hashCode:
这个hashCode是什么鬼?实际上Set集合默认采用的是哈希表进行数据存储(详情请看数据结构与算法篇视频教程)而哈希表在存储数据时,需要通过一个哈希函数计算出对象的哈希值,如果两个对象的哈希值相同,那么在哈希表中就会认定为是同一个元素,如果不相同,那么会认定为不同的两个元素,因此,这里我们仅仅重写equals只能满足部分集合类的使用,而到了Set这里包括后面的Map就开始不行了。
我们可以看到,在Any类中确实定义了一个hashCode函数,这个就是用于计算对象的哈希值的:
|
|
在默认情况下,对象的哈希值得到的结果是对象在内存中存放的地址,以Int类型表示:
|
|
因此,上面两个对象由于存放在不同的地址,所以得到的哈希值肯定是不一样的,既然现在我们仅仅只是比较对象的名称和年龄是否相同,我们可以修改一下哈希函数的计算规则:
|
|
现在再次进行操作:
所以,以后我们在重写equals
函数时,为了能够适配所有的集合类,我们还需将其hashCode函数一并重写,来保证一致性。
Map映射
Map是一个非常特殊的集合类型,它存储了一些的键值对,那么什么是键值对呢?
可以看到,学校里面的学号对应了每一个学生,我们只需要知道某一个学生的学号,就可以快速查找这个学生的姓名、年龄、性别等信息,而Map就是为了存储这样的映射关系而存在的。
首先我们来看,如何定义一个键值对,官方为我们提供了一个非常方便的中缀函数:
|
|
我们只需要指定:
|
|
这样,我们就成功创建出了一个映射关系,但是这仅仅是单个映射,如果我们想要存储所有学生的学号映射关系,需要使用Map来实现,使用Map也非常简单:
|
|
这样我们就成功地将所有的键值对存储在Map中了,我们接着来看看如何去访问,比如现在我们要查找指定学号的学生:
|
|
可以看到,使用方式与前面的集合类和数组非常类似,只不过访问的不再是下标,而是对应的Key。同时,这里得到的结果是一个可空类型的对象,为什么会像这样呢?
|
|
当Map中不存在指定Key时,会直接得到null
作为结果,因此我们在处理从Map返回的Value时,需要考虑空指针问题。
|
|
注意: Map中的键值对存储,只能通过Key去访问Value,而不能通过Value来反向查找Key,映射关系是单向的。
我们可以直接获取到Key和Value的集合:
|
|
遍历一个Map也很简单:
|
|
我们再来看看如何向Map中添加新的键值对:
|
|
你甚至还能像这样添加:
|
|
不过需要注意的是,在Map中同样的Key只能存在一个,这跟Set是一样的:
|
|
当插入一个键值对时,如果存在相同Key会直接覆盖掉之前的键值对,我们可以通过函数的返回值来得到被覆盖的结果:
|
|
我们也可以直接移除不需要的键值对,同样是通过Key进行移除:
|
|
各种花式移除:
|
|
如果我们需要直接移除Value为某个Key的键值对,可以像这样:
|
|
|
|
有些时候,Map返回的结果是可空类型给我们造成了很多麻烦,可以通过以下方式解决:
|
|
|
|
有了Map之后,我们在处理一些映射关系的时候就很方便了。跟Set一样,官方也提供了多种多样的集合:
|
|
迭代器
我们在一开始提到,集合类型的顶层接口都是一个叫做Collection的接口:
|
|
而在Iterable接口中,就定义了一个用于生成迭代器的函数:
|
|
不仅仅是集合类,甚至在Array类中也定义了这样的函数:
|
|
迭代器是每个集合类、数组都可以生成的东西,它的作用很简单,就是用于对内容的遍历:
|
|
那么这个迭代器该如何使用呢?先来看接口定义:
|
|
通过使用迭代器,我们就可以实现对集合中的元素的进行遍历,就像我们遍历数组那样,它的运作机制大概是:
一个新的迭代器就像上面这样,默认有一个指向集合中第一个元素的指针:
每一次next
操作,都会将指针后移一位,直到完成每一个元素的遍历,此时再调用next
将不能再得到下一个元素。至于为什么要这样设计,是因为集合类的实现方案有很多,可能是链式存储,也有可能是数组存储,不同的实现有着不同的遍历方式,而迭代器则可以将多种多样不同的集合类遍历方式进行统一,只需要各个集合类根据自己的情况进行对应实现就行了。
实际上迭代器的功能设计非常纯粹,就是看有没有下一个,有的话就拿出来,所以使用迭代器可以直接用一个while循环搞定:
|
|
迭代器的出现,使得无论我们使用哪一种集合,都可以使用同样的方式对元素进行遍历,这统一了遍历操作的使用:
|
|
注意,迭代器的使用是一次性的,用了之后就不能用了,如果需要再次进行遍历操作,那么需要重新生成一个迭代器对象。
只要是重写了operator fun iterator()
函数的类型,都可以使用for..in这样的语法去进行遍历:
|
|
因此,数组和集合类都支持使用for循环遍历也就不奇怪了,哪怕是我们自己定义的类,只要实现这个函数都是支持的:
|
|
包括我们前面使用的Range语法,其本身也是一个支持生成迭代器的类:
|
|
实际上,所有使用for..in语法的操作,最后都会被编译为使用迭代器的while操作:
|
|
|
|
是不是突然觉得有点豁然开朗?至此,我们已经完成解释清楚for..in操作的原理了。
特别的,对于List来说,它还有一个非常特殊的ListIterator迭代器:
|
|
ListIterator迭代器是普通迭代器的强化版本,它可以实现列表中元素的双向遍历,并且可以得到当前迭代的元素下标。
最后,我们再来探讨一个之前可能遇到过的问题:
|
|
此程序运行会直接得到一个报错:
在JVM环境下,Kotlin默认不支持在迭代时修改集合里面的内容,无论是插入新的元素还是移除元素,都会触发并发修改异常。为了解决这个问题,Kotlin为所有的MutableCollection(所有非只读集合类)提供了一个特殊的用于生成MutableIterator的函数,只要我们使用的不是只读的集合类,都可以获得这个特殊的迭代器,它支持在遍历时对元素进行删除:
|
|
有关迭代器的相关知识就先到这里了。
集合与数组扩展操作
前面我们介绍了Kotlin提供的几个常用集合类,我们在使用这些集合类的时候,为了更加方便,官方提供了很多用于集合、数组类型的扩展操作,我们来学习一下吧,因为这些扩展操作数组和集合都可以使用,我们就尽量以List为例进行讲解。
首先是数组跟集合的联动,有些时候我们可能拿到的是一个数组对象,但是我们希望将其转换为集合类进行操作,我们可以使用数组提供的集合快速转换函数来进行转换:
|
|
这样,如果我们发现数组无法满足我们对于其元素的操作,可以直接转换为集合类进行操作,方便你我。
接下来是映射操作(注意这里说的map跟我们前面说的集合Map是两个概念,别搞混了)它可以将集合类、数组的元素类型进行转换,比如我们现在有一个字符串集合,但是我们现在希望把它变成记录每一个字符串长度的Int集合,该怎么做呢?
|
|
我们可以利用这种操作来为里面的每一个元素添加编号:
|
|
利用映射操作,我们可以快速对集合中是元素依次进行修改,也可以对集合中的元素进行类型转换,非常方便。
对于Map类型,我们还可以单独对所有Key或是Value进行操作:
|
|
我们接着来看下一个,压缩操作,它可以将当前集合元素和另一个集合中具有相同索引的元素组合起来,生成一个装满Pair的列表:
|
|
利用压缩操作我们可以快速将两个List集合揉成一个Map集合:
|
|
既然能压缩还能解压:
|
|
有些时候我们还可以使用另一种方式将普通集合转换为Map映射,比如associate操作:
|
|
还有对应的反向操作:
|
|
如果你觉得以上两种方式都不是很灵活,你也可以自己根据情况自行构建一个Pair作为结果返回:
|
|
我们接着来看,对于一些嵌套集合和数组来说,有时候处理里面的数据会变得很棘手:
|
|
那么有没有办法能够把这个嵌套的集合内所有的集合全部拆出来,全部存在一个不嵌套的集合中呢?我们可以使用扁平化操作:
|
|
结合之前学习的映射操作,我们还可以在展平元素的同时对元素进行映射,非常适合下面这种情况:
|
|
可以看到,这个List很恶心,它内层存放的集合是被套在一个对象中的,更准确的说,这是一个List<Container>
类型的列表,但是现在我们希望的是取出里面每一个对象存储的List然后拿来展平,可以像这样:
|
|
其实还有一个joinToString
函数,但是前面数组部分已经讲解过了,使用方式是一样的,这里就不做介绍了。
有时候我们想要移除集合中某些不满足条件的元素,我们可以使用过滤操作来完成:
|
|
|
|
还有快速过滤所有空值的操作:
|
|
甚至还可以快速过滤出指定类型的集合:
|
|
通过过滤操作可以快速筛选出我们需要的那些元素,当然,如果我们既需要筛选出来的元素,也需要筛选出去的元素,我们可以使用分区操作:
|
|
不愧是Kotlin,甚至连一个筛选功能都可以做的这么全面。还有专门用于测试元素是否满足条件的:
|
|
我们接着来看非常实用的分组操作,它可以将元素按类别进行分组,以Map的形式返回:
|
|
我们接着来看对于集合的裁剪相关操作,首先是对一个集合进行切片,比如我们只想要其中一段元素:
|
|
前面我们介绍了嵌套集合的扁平化,那有没有办法吧扁平化的集合给重新分块呢?
|
|
有关集合相关的扩展操作,我们就先介绍到这里了,想要了解更多集合特性的小伙伴请参考官网:https://kotlinlang.org/docs/collections-overview.html
序列
除了集合,Kotlin标准库还包含另一种类型:序列(Sequence
)与集合不同,序列不包含元素,它在运行时生成元素,Sequence与Iterable接口功能相似,接口定义如下,同样只包含一个生成迭代器的函数:
|
|
那既然功能一样,为什么要专门搞一个序列呢?这不是多此一举吗?序列实际上是一个延迟获取数据的集合,只有需要元素时才会生产元素并提供给外部,包括所有对元素操作,并不是一次性全部处理,而是根据情况选择合适的元素进行操作。使用序列能够在处理大量数据时获得显著的性能提升。
要创建一个序列非常简单,使用generateSequence函数即可:
|
|
可以看到generateSequence得到的结果并没有在一开始执行println,因为序列的数据处理是惰性的,在我们添加
|
|
此时控制台才开始打印生成的Lambda函数。同样的,所有扩展操作同样是惰性的,我们可以来比较一下:
|
|
可以看到,在直接使用集合的情况下,整个工序是按照顺序在向下执行的,并且每一道工序都会对所有的元素依次进行操作,但是实际上根据我们的要求,最后只需要两个满足条件的即可,如果这个是一个数据量非常庞大的集合,会导致执行效率很低。我们现在换成序列试试看:
|
|
可以看到,序列根据我们的操作流程,对数据的操作也进行了优化,执行次数明显减少,并且使用序列后只有我们从序列读取数据时才会开始执行我们定义好的工序,可见,序列执行的各种操作,仅仅是记录到序列中,并没有在一开始就执行,而是需要的时候才开始获取,因此才可以做到上面这样的操作。
这与Java中的Stream非常相似。
当然,序列并不是随时随地都可以使用的,我们还是要根据实际情况决定是否要使用序列,如果在数据量特别庞大的情况下,使用序列处理会更好,但是如果数据量很小,使用序列反而会增加开销。
特殊类型介绍
除了我们之前学习的普通class类型之外,Kotlin还为我们提供了更多种类的类型,以应对不同的情况。
这些特殊类型本质上依然是class但是存在一些限制或是特殊情况。
数据类型
对于那些只需要保存数据的类型,我们常常需要为其重写toString
、equals
等函数,针对于这种情况下,Kotlin为我们提供了专门的数据类,数据类不仅能像普通类一样使用,并且自带我们需要的额外成员函数,比如打印到输出、比较实例、复制实例等。
声明一个数据类非常简单:
|
|
数据类声明后,编译器会根据主构造函数中声明的所有属性自动为其生成以下函数:
.equals()
/.hashCode()
.toString()
生成的字符串格式类似于"User(name=John, age=42)"
.componentN()
与按声明顺序自动生成用于解构的函数.copy()
用于对对象进行拷贝
我们可以来试试看:
|
|
当然,为了确保生成代码的一致性和有效性,数据类必须满足以下要求:
- 主构造函数中至少有一个参数。
- 主构造函数中的参数必须标记为
val
或var
。 - 数据类不能是抽象的、开放的、密封的或内部的。
此外,数据类的成员属性生成遵循有关成员继承的以下规则:
-
如果数据类主体中
.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()
函数提供显式实现。
注意,编译器会且只会根据主构造函数中定义的属性生成对应函数,如果有些时候我们不希望某些属性被添加到自动生成的函数中,我们需要手动将其移出主构造函数:
|
|
此时生成的所有函数将不会再考虑age属性:
|
|
数据类自带一个拷贝对象的函数,使用.copy()
函数复制对象,允许您更改其一些属性,而其余的保持不变。此函数对上述User
类的实现如下:
|
|
在copy函数还可以在拷贝过程中手动指定属性的值:
|
|
枚举类型
我们知道,在Kotlin中有一个Boolean类型,它只有两种结果,要么为false要么为true,这代表它的两种状态真和假。有些时候,可能两种状态并不能满足我们的需求,比如一个交通信号灯,它具有三种状态:红灯、黄灯和绿灯。
如果我们想要存储和表示自定义的多种状态,使用枚举类型就非常合适:
|
|
枚举类的值只能是我们在类中定义的那些枚举,不可以存在其他的结果,枚举类型同样也是一个普通的类,只是存在值的限制。
要使用一个枚举类的对象,可以通过类名直接获取定义好的枚举:
|
|
同样的,枚举类也可以具有成员:
|
|
我们可以像普通类那样正常使用枚举类的成员:
|
|
枚举类型可以用于when
表达式进行判断,因为它的状态是有限的:
|
|
在枚举类中也可以编写抽象函数,抽象函数需要由枚举自行实现:
|
|
如果枚举类实现了某个接口,同样可以像这样去实现:
|
|
|
|
枚举类也为我们准备了很多的函数:
|
|
|
|
匿名类和伴生对象
有些时候,可能我们并不需要那种通过class
关键字定义的对象,而是以匿名的形式创建一个临时使用的对象,在使用完之后就不再需要了,这种情况完全没有必要定义一个完整的类型,我们可以使用匿名类的形式去编写。
|
|
可以看到,匿名类除了没名字之外,也可以定义成员,只不过这种匿名类不能定义任何构造函数,因为它是直接创建的,这种写法我们也可以叫做对象表达式。
匿名类不仅可以直接定义,也可以作为某个类的子类定义,或是某个接口的实现:
|
|
|
|
可以看到,平时我们无法直接实例化的接口或是抽象类,可以通过匿名类的形式得到一个实例化对象。
我们再来看下面这种情况:
|
|
根据我们上面学习的用法,如果我们想要使用其匿名类,可以像这样编写:
|
|
特别的,对于只存在一个抽象函数的接口称为函数式接口或单一抽象方法(SAM)接口,函数式接口可以有N个非抽象成员,但是只能有一个抽象成员。对于函数式接口,可以使用我们前面介绍的Lambda表达式来使代码更简洁:
|
|
我们再来看下面这种情况:
|
|
我们在调用test时,也可以写的非常优雅:
|
|
正是因为有了匿名类,所以有些时候我们通过函数得到的结果,可能并不是某个具体定义的类型,也有可能是直接采用匿名形式创建的匿名类对象:
|
|
object
关键字除了用于声明匿名类型,也可以用于声明单例类。单例类是什么意思呢?就像它的名字一样,在整个程序中只能存在一个对象,也就是单个实例,不可以创建其他的对象,始终使用的只能是那一个对象。
|
|
|
|
用起来与Java中的静态属性挺像的,只不过性质完全不一样。单例类的这种性质在很多情况下都很方便,比如我们要编写某些工具操作,可以直接使用单例类的形式编写。
现在我们希望一个类既支持单例类那样的直接调用,又支持像一个普通class那样使用,这时该怎么办呢?
我们可以使用半生对象来完成,实际上就是将一个单例类写到某个类的内部:
|
|
伴生对象在Student类加载的时候就自动创建好了,因此我们可以实现直接使用。
委托模式
在有些时候,类的继承在属性的扩展上起到了很大的作用,通过继承我们可以直接获得某个类的全部属性,而不需要再次进行编写,不过,现在有了一个更好的继承替代方案,那就是委托模式(在设计模式中也有提及)名字虽然听着很高级,但是其实很简单,比如我们现在有一个接口:
|
|
正常情况下,我们需要编写一个它的实现类:
|
|
现在我们换一个思路,我们再来创建一个实现类:
|
|
这就是一个非常典型的委托模型,且大量实践已证明委托模式是实现继承的良好替代方案。
Kotlin对于这种模式同样给予了原生支持:
|
|
这样就可以轻松实现委托模式了。
除了类可以委托给其他对象之外,类的成员属性也可以委托给其他对象:
|
|
不过,自己去定义一个类来进行委托实在是太麻烦了,Kotlin在标准库中也为我们提供了大量的预设函数:
|
|
也可以设置观察者,实时观察变量的变化:
|
|
属性也可以直接将自己委托给另一个属性:
|
|
相信各位应该能猜到,这样委托给其他属性,当前属性的值修改,会直接导致其他属性的值也会修改,相反同样它们已经被相互绑定了。
属性也可以被委托给一个Map来进行存储:
|
|
注意,在使用不可变的Map时,只能给val类型变量进行委托,因为不可修改。
密封类型
有些时候,我们可能会编写一些类给其他人使用,但是我们不希望他们随意继承使用我们提供的类,我们只希望在我们提供的框架内部自己进行使用,这时我们就可以将类或接口设定为密封的。
密封类的所有直接子类在编译时都是已知的。不得在定义密封类的模块和包之外出现其他子类。例如,第三方项目无法在其代码中扩展您的密封类。因此,密封类的每个实例都有一个来自预设好的类型,且该类型在编译该类时是已知的。
|
|
当我们在其他包中使用这个密封类,在其他包或模块中无法使用:
|
|
密封类将类的使用严格控制在了模块内部,包括密封接口及其实现也是如此:一旦编译了具有密封接口的模块,就不会出现新的实现类。
从某种意义上说,密封类类似于枚举类:枚举类型的值数量也受到限制,由我们自己定义,但每个枚举变量仅作为单个实例存在,而密封类的子类可以有多个实例,每个实例都有自己的状态。密封类本身也是抽象的,它不能直接实例化,并且可以具有abstract
成员:
|
|
密封类继承后也可以使其不继续密封,让外部可以正常使用:
|
|
但是由于类A是密封的,因此所有继承自A的类只能是我们自己写的,别人无法编写继承A的类,除非我们将某个继承A的类设定为open特性,允许继承。因此,这也进一步证明密封类在一开始就确定了有哪些子类。
由于密封类能够确定,所以在使用when进行类型判断时,也是有限的:
|
|
密封类的应用场景其实远不止这些,由于篇幅有限,这里就不展开讲解了。
异常机制
在理想的情况下,我们的程序会按照我们的思路去运行,按理说是不会出现问题的,但是,代码实际编写后并不一定是完美的,可能会有我们没有考虑到的情况,如果这些情况能够正常得到一个错误的结果还好,但是如果直接导致程序运行出现问题了呢?
我们来看下面这段代码:
|
|
1怎么可能去除以0呢,数学上有明确规定,0不能做除数,所以这里得到一个异常:
那么这个异常到底是什么样的一种存在呢?当程序运行出现我们没有考虑到的情况时,就有可能出现异常或是错误!它们在默认情况下会强行终止我们的程序。
异常的使用
我们在之前其实已经接触过一些异常了,比如数组越界异常,空指针异常,算术异常等,他们其实都是异常类型,我们的每一个异常也是一个类,他们都继承自Throwable
类!异常类型本质依然类的对象,但是异常类型支持在程序运行出现问题时抛出(也就是上面出现的红色报错)也可以提前声明,告知使用者需要处理可能会出现的异常!
每个异常对象都包含一条消息、一个堆栈跟踪和一个可选原因。
我们自己也可以抛出异常,要抛出异常对象,请使用throw
出表达式:
|
|
可以看到,控制台出现了下面的报错:
所以,我们平时看到的那些丰富多彩的异常,其实大部分都是由程序主动抛出的。
我们也可以依葫芦画瓢,自定义我们自己的异常类:
|
|
是不是感觉很简单,异常的出现就是为了方便我们快速判断程序的错误。我们可以在异常打印出来的栈追踪信息中得到当前程序出现问题的位置:
这里指示的很明确,是我们的Main.kt文件第四行代码出现了异常。
异常的处理
当程序没有按照我们理想的样子运行而出现异常时(JVM平台下,默认会交给JVM来处理,JVM发现任何异常都会立即终止程序运行,并在控制台打印栈追踪信息)现在我们希望能够自己处理出现的问题,让程序继续运行下去,就需要对异常进行捕获,比如:
|
|
现在我们希望能够手动处理这种情况,即使发生异常也要继续下去,我们可以使用try-catch语句块来完成:
|
|
我们可以将代码编写到try
语句块中,只要是在这个范围内发生的异常,都可以被捕获,使用catch
关键字对指定的异常进行捕获,这里我们捕获的是ArrayIndexOutOfBoundsException数组越界异常:
可以看到,当我们捕获异常之后,程序可以继续正常运行,并不会像之前一样直接结束掉。
注意,catch中捕获的类型只能是Throwable的子类,也就是说要么是抛出的异常,要么是错误,不能是其他的任何类型。
我们可以在catch
语句块中对捕获到的异常进行处理:
|
|
当代码可能出现多种类型的异常时,我们希望能够分不同情况处理不同类型的异常,就可以使用多重异常捕获:
|
|
最后,当我们希望,程序运行时,无论是否出现异常,都会在最后执行任务,可以交给finally
语句块来处理:
|
|
注意:try
语句块至少要配合catch
或finally
中的一个。
try
也可以当做一个表达式使用,这意味着它可以有一个返回值:
|
|
针对于空类型,我们也可以在判断为空时直接抛出异常:
|
|