Kotlin(二)类与对象

image-20230730164654567
Kotlin程序设计中级篇函数创建和使用函数再谈变量递归函数实用库函数介绍高阶函数与lambda表达式内联函数类与对象类的定义与对象创建对象的初始化类的成员函数再谈基本类型运算符重载函数中缀函数空值和空类型解构声明包和导入访问权限控制封装、继承和多态类的封装类的继承属性的覆盖顶层Any类抽象类接口类的扩展
Kotlin程序设计中级篇
我们在前面已经学习了Kotlin程序设计的基础篇,本章我们将继续介绍更多Kotlin特性,以及面向对象编程。
函数
其实函数我们在一开始就在使用了:
我们程序的入口点就是
main
函数,我们只需要将我们的程序代码编写到主函数中就可以运行了,不过这个函数只是由我们来定义,而不是我们自己来调用。当然,除了主函数之外,我们一直在使用的println
也是一个函数,不过这个函数是标准库中已经实现好了的,现在是我们在调用这个函数:那么,函数的具体定义是什么呢?
函数是完成特定任务的独立程序代码单元。
其实简单来说,函数是为了完成某件任务而生的,可能我们要完成某个任务并不是一行代码就可以搞定的,但是现在可能会遇到这种情况:
我们每次要做这个任务时,都要完完整整地将任务的每一行代码都写下来,如果我们的程序中多处都需要执行这个任务,每个地方都完整地写一遍,实在是太臃肿了,有没有一种更好的办法能优化我们的代码呢?
这时我们就可以考虑使用函数了,我们可以将我们的程序逻辑代码全部编写到函数中,当我们执行函数时,实际上执行的就是函数中的全部内容,也就是按照我们制定的规则执行对应的任务,每次需要做这个任务时,只需要调用函数即可。
我们来看看,如何创建和使用函数。
创建和使用函数
Kotlin函数使用
fun
关键字声明:其中函数名称也是有要求的,并不是所有的字符都可以用作函数名称,它的命名规则与变量的命名规则基本一致,所以这里就不一一列出了。函数不仅仅需要完成我们的任务,可能某些函数还需要告诉我们结果,我们同样可以将函数返回的结果赋值给变量或是参与运算等等,当然如果我们的函数只需要完成任务,不需要告诉我们结果,返回值类型可以不填,我们先从最简单的开始:
我们要调用这个函数也很简单,只需要像下面这样就可以了:
不过,有些时候,我们可能需要外部传入一些参数来使用,比如:
这里我们在函数的小括号中填入的就是形式参数,这代表调用函数时需要传入的数据,比如这里就是我们要打印的字符串,而实际在调用函数时,填入的内容就是实际参数:
还有一些时候,我们的函数可能需要返回一个计算的结果给调用者,我们也可以设定函数的返回值:
带返回值的函数,调用之后得到的返回值,可以由变量接收,或是直接作为其他函数的参数:
注意这个
return
关键字在执行之后,是不会继续执行之后的内容的:有些时候,我们也可以设计一些参数带有默认值的函数,如果在调用函数时不填入参数,那么就使用我们一开始设置好的默认值作为实际传入的参数:
在调用函数时,我们可以手动指定传入的参数对应的是哪一个形式参数:
对于一些内容比较简单的函数,比如上面仅仅是计算两个参数的和,我们可以直接省略掉花括号,像这样编写:
这里还需要注意一下,函数的形式参数默认情况下为常量,无法进行修改,只能使用:

image-20230730215022812
比较奇葩的是,函数内部也可以定义函数:
函数内的函数作用域是受限的,我们只能在函数内部使用:
内部函数可以访问外部函数中的变量:
最后,我们不能同时编写多个同名函数,这会导致冲突:

image-20231224002414385
但是,如果多个同名函数的参数不一致,是允许的:
我们在调用这个函数时,编译器会根据我们传入的实参自动匹配使用的函数是哪一个:
以上适用于形参列表不同的情况,如果仅仅是返回值类型不同的情况,同样是不允许的:

image-20231224002803600
像这种编写同名但不同参数的函数,我们称为函数的重载。
再谈变量
前面我们学习了如何使用变量,只不过当时我们仅仅是在main函数中使用的局部变量,我们也可以将变量的作用域进行提升,将其直接变成一个顶级定义:
此时,这个变量可以被所有的函数使用:
以上也只是对变量的一些简单使用,现在变量的作用域被提升到顶层,它可以具有更多的一些特性,那么,我们就再来重新认识一下变量,声明一个变量的完整语法如下:
前面的我们知道,但是这个getter和setter是个什么鬼?对于这种顶层定义的变量(包括后面类中会用到的成员属性变量)可以具这两个可选的函数,它们本质上是一个get和set函数:
- getter:用于获取这个变量的值,默认情况下直接返回当前这个变量的值
- setter:用于修改这个变量的值,默认情况下直接对这个变量的值进行修改
我们在使用这种全局变量时,对于变量的获取和设定,本质上都是通过其getter和setter函数来完成的,只不过默认情况下不需要我们去编写,程序编译之后,有点像这样的结果:
而对于其使用,在编译之后,会变成这样:
是不是感觉好神奇,一个变量都能搞这么多花样,这其实是为了后续多态的一些性质而设计的(下一章讲解)
可以看到,在默认情况下,变量的获取就是直接返回,设置就是直接修改,不过有些时候我们可能希望修改这些变量获取或修改时执行的操作,我们可以手动编写:
这里使用的field准确的说应该是Kotlin提供的”后备字段”,因为我们使用getter和setter本质上替代了原有的获取和修改方式,使其变得更像是函数的调用,因此,为了能够继续像之前使用一个变量那样去操作它本身,就有了这个后备字段。
最后得到的就是:

image-20230823214656414
甚至还可以写成这样,在获取的时候执行一些操作:
同样的,设置的时候也可以自定义:
因此,一个变量有些时候可能会写成这样:
当然,默认情况下其实没有必要去重写get和set除非特殊需求。
递归函数
我们前面学习了如何调用函数,实际上函数自己也可以调用自己。
肯定会有小伙伴疑问,函数自己调用自己有什么意义?反而还会导致函数无限的调用下去,无穷无尽,确实,如果不加限制地让函数自己调用自己:

image-20230821034317397
就会出现这种
爆栈
的情况,这是因为程序的内存是有限的,不可能无限制的继续调用下去,因此,在自我调用到一定的深度时,会被强制终止。所以说这玩意有啥用呢?如果我们对递归函数加以一些限制,或许会有意想不到的发现:这个函数最终调用起来就像这样:
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
关键字来实现:实际上在编译之后,会变成这样:

image-20230821040623152
可以看到它变成了一个普通的循环操作,这也是编译器的功劳,同样的,对于斐波那契数列:
实用库函数介绍
Kotlin为我们内置了大量实用的库函数,我们可以使用这些库函数来快速完成某些操作。
比如我们前面使用的
println
就是Kotlin提供的库函数,我们可以使用这个函数快速进行数据打印:那既然现在有输出,能不能让用户输入,然后我们来读取呢?
我们可以在控制台输入一段文本,然后回车结束:

image-20230731011757655
Kotlin提供的运算符实际上只能进行一些在小学数学中出现的运算,但是如果我们想要进行乘方、三角函数之类的高级运算,就没有对应的运算符能够做到,而此时我们就可以使用数学工具类来完成。
当然,三角函数肯定也是安排上了的:
可能在某些情况下,计算出来的浮点数会得到一个很奇怪的结果:

image-20230731010301773
正常来说,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表达式来表示一个函数实例:
是不是感觉特别简便?

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

image-20230730230633880
是不是感觉玩的非常高级?还有更高级的在后面呢!
我们接着来看,如果我们现在想要调用一个高阶函数,最直接的方式就是下面这样:
当然我们也可以直接把一个Lambda作为参数传入作为实际参数使用:
不过这样还不够简洁,在Kotlin中,如果函数的最后一个形式参数是一个函数类型,可以直接写在括号后面,就像下面这样:
由于小括号里面此时没有其他参数了,还能继续省,直接把小括号也给干掉:
当然,如果在这之前有其他的参数,只能写成这样了:
这种语法也被称为 尾随lambda表达式,能省的东西都省了,不过只有在最后一个参数是函数类型的情况下才可以,如果不是最后一位,就没办法做到尾随了。
最后需要特别注意的是,在Lambda中没有办法直接使用
return
语句返回结果,而是需要用到之前我们学习流程控制时用到的标签:如果是函数调用的尾随lambda表达式,默认的标签名字就是函数的名字:
不过,为什么要这么麻烦呢,还要打标签才能返回,这不多此一举么?这个问题我们会在下一节内联函数中进行讲解。
内联函数
使用高阶函数会可能会影响运行时的性能:每个函数都是一个对象,而且函数内可以访问一些局部变量,但是这可能会在内存分配(用于函数对象和类)和虚拟调用时造成额外开销。
为了优化性能,开销可以通过内联Lambda表达式来消除。使用
inline
关键字会影响函数本身和传递给它的lambdas,它能够让方法的调用在编译时,直接替换为方法的执行代码,什么意思呢?比如下面这段代码:由于test函数是内联函数,在编译之后,会原封不动地把代码搬过去:
同样的,如果是一个高阶函数,效果那就更好了:
由于test函数是内联的高阶函数,在编译之后,不仅会原封不动地把代码搬过去,还会自动将传入的函数参数贴到调用的位置:
内联会导致编译出来的代码变多,但是同样的换来了性能上的提升,不过这种操作仅对于高阶函数有显著效果,普通函数实际上完全没有内联的必要,也提升不了多少性能。
注意,内联函数中的函数形参,无法作为值给到变量,只能调用:

image-20230731131403842
同样的,由于内联,导致代码被直接搬运,所以Lambda中的return语句可以不带标签,这种情况会导致直接返回:
上述代码的运行结果就是,直接结束,两句println都不会打印,这种情况被称为非局部返回。
回到上一节最后我们提出的问题,实际上,在Kotlin中Lambda表达式支持一个叫做”标签返回”(labeled return)的特性,这使得你能够从一个Lambda表达式中返回一个值给外围函数,而不是简单地返回给Lambda表达式所在的最近的封闭函数,就像下面这样:
效果跟上面是完全一样的,为了避免这种情况,我们也可以像之前一样将标签写为@test来防止非局部返回。
有些时候,可能一个内联的高阶函数中存在好几个函数参数,但是我们希望其中的某一个函数参数不使用内联,能够跟之前一样随意当做变量使用:
最后编译出来的结果,类似于:
由于目前知识的学习还不太够,函数我们只能先暂时告一段落,在后续的学习中我们会继续认识更多函数的特性。
类与对象
在之前,我们一直在使用顶层定义:
而学习了类之后,这些内容也可以定义到类中,作为类的属性存在。
类的概念我们在生活中其实已经听说过很多了。
人类、鸟类、鱼类… 所谓类,就是对一类事物的描述,是抽象的、概念上的定义,比如鸟类,就泛指所有具有鸟类特征的动物。比如人类,不同的人,有着不同的性格、不同的爱好、不同的样貌等等,但是他们根本上都是人,所以说可以将他们抽象描述为人类。
对象是某一类事物实际存在的每个个体,因而也被称为实例(instance)我们每个人都是人类的一个实际存在的个体。

image-20220919203119479
所以说,类就是抽象概念的人,而对象,就是具体的某一个人。
- A:是谁拿走了我的手机?
- B:是个人。(某一个类型)
- A:我还知道是个人呢,具体是谁呢?
- B:是XXX。(具体某个对象)
而在Kotlin中,也可以像这样进行编程,我们可以定义一个类,然后进一步创建许多这个类的实例对象,像这种编程方式,我们称为面向对象编程,我们除了去使用Kotlin给我们提供的类型之外,我们也可以使用自己定义的类。
类的定义与对象创建
前面我们介绍了什么是类,什么是对象,首先我们就来看看如何去定义一个类。
Kotlin中的类使用关键字
class
声明,我们可以直接在默认的Main.kt文件中编写:我们在对类进行命名时,一般使用英文单词,并且首字母大写,跟变量命名一样,不能出现任何的特殊字符。
除了直接在某个.kt文件中直接编写之外,为了规范,我们一般将一个类单独创建一个文件,我们可以右键
src
目录:
image-20230730165458965
这里选择新建,然后选择Kotlin类/文件选项,然后创建一个类:

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

image-20230730165605898
这是一个非常简单的类,但是肯定远远不够。
既然是学生类,那么肯定有学生相关的一些属性,比如名字、性别、年龄等等,那么怎么才能给这个类添加一些属性呢?我们需要指定类的构造函数,构造函数也是函数的一种,但是它是专用于对象的创建,Kotlin中的类可以添加一个主构造函数和一个或多个次要构造函数。主构造函数是类定义的一部分,像下面这样编写:
如果主构造函数没有任何注释或可见性修饰符,则可以省略
constructor
关键字,如果类中没有其他内容要写,可以直接省略花括号,最后就变成这样了:但是,这里仅仅是定义了构造函数的参数,这还不是类的属性,那么我们要怎么才能定义为类的属性呢?我们可以为这些属性添加
var
或val
关键字来表示这个属性是可变还是不变的:这跟我们之前使用变量基本一致:
val
:不可变属性
var
:可变属性
这样才算是定义了类的属性,我们也可以给这些属性设置初始值:
除了将属性添加到构造函数中,我们也可以将这些属性直接作为类的成员变量写到类中,但是这种情况必须要配一个默认值,否则无法通过编译:
这样我们就可以不编写主构造函数也能定义属性,但是这里仍然会隐式生成一个无参的构造函数,为了构造函数能够方便地传值初始化,也可以像这样写:
当然,如果各位不希望这些属性在一开始就有初始值,而是之后某一个时刻去设定初始值,我们也可以为其添加懒加载:
并且,像这样编写的类成员变量,也可以自定义对应的getter和setter属性:
那么,现在我们定义了主构造函数之后,该怎么去使用它呢?
跟我们调用普通函数一样,这里的函数名称就是类的名称,如果一个类没有编写构造函数,那么这个类默认情况下使用一个无参构造函数创建:
如果是有构造函数的类,我们只需要填写需要的参数即可,调用之后,类的属性就是这里我们给进去的参数了:
这样,我们就成功创建出了一个名字为小明的学生类型对象,但是这个对象仅仅是创建出来还不行,我们肯定需要去使用它。
实际上,我们可以像之前使用基本类型一样,使用对象,我们也可以使用一个变量去接收生成出来的对象:
有一个我们需要注意的点,这里的stu存放的是对象的引用,而不是本体,我们可以通过对象的引用来间接操作对象。
这里,我们将变量p2赋值为p1的值,那么实际上只是传递了对象的引用,而不是对象本身的复制,这跟我们前面的基本数据类型有些不同,p2和p1都指向的是同一个对象(如果你学习过C语言,它就类似于指针一样的存在)

image-20220919211443657
我们可以来测试一下:
但是如果我们像这样去编写:
我们可以使用
.
运算符来访问对象的属性,比如我们要访问小明这个学生对象的属性:获取和修改都是可以的:
注意,不同对象的属性是分开独立存放的,虽然都是统一由类完成定义,但是每个对象都有一个自己的空间,修改一个对象的属性并不会影响到另一个相同类型的对象:
除了直接使用主构造函数创建对象外,我们也可以添加一些次要构造函数,比如我们的学生可以只需要一个名字就能完成创建,我们可以直接在类中编写一个次要构造函数:
如果该类有一个主构造函数,则每个次要构造函数需要通过另一个次要构造函数直接或间接委托给主构造函数。委托到同一类的另一个构造函数是
this
关键字完成的:如果一个类没有主构造函数,那么我们也可以直接在在类中编写次要构造函数,但是不需要主动委托一次主构造函数,他这里会隐式包含,所以说我们直接写就行了:
次要构造函数和主构造函数一样,都可以用于对象的创建:
并且次要构造函数可以编写自定义的函数体:
因此,主构造函数相比次要(辅助)构造函数:
- 主构造函数: 可以直接在主构造函数中定义类属性,使用更方便,但是主构造函数只能存在一个,并且无法编写函数体,只有为类属性做初始化赋值的效果。
- 辅助(次要)构造函数: 可以存在多个,并且可以自定义函数体,但是无法像主构造函数那样定义类属性,并且当类具有主构造函数时,所有次要构造函数必须直接或间接地调用主构造函数。
Kotlin语言本身比较灵活,类中并不是一定需要主构造函数,全部写辅助构造函数也是可以的,但是再怎么都得有构造函数。
下一部分我们接着来讨论对象的初始化。
对象的初始化
在对象创建时,我们可能需要做一些初始化工作,我们可以使用初始化代码块来完成,初始化代码块使用init关键字来完成。假如我们希望对象在创建的时候,如果年龄不足18岁,那么就设定为18岁:
这样,我们在创建对象的时候,就会在创建的时候自动执行初始化代码块里面的代码:
可以看到初始化操作开始执行了:

image-20230731181721090
初始化操作不仅仅可以有一个,也可以有很多个:
对于将成员属性写到类中的情况,同样是按照顺序向下执行,比如:

image-20230731195222026
因为成员变量a是在初始化代码块的后面才初始化的,这里会报错。
如果一个类具有次要构造函数,那么我们也可以直接在次要构造函数中编写一些初始化代码:
当我们使用对应的次要构造函数时,就会执行次要构造函数中的初始化代码了。
这里需要注意一下,次要构造函数实际上需要先执行主构造函数,而在执行主构造函数时,会优先将之前我们讲解的初始化代码块执行,比如下面的代码:
无论是有主构造函数还是没有主构造函数(会生成一个默认的无参构造函数)都会先执行。
类的成员函数
现在我们的类有了属性,我们可以为创建的这些对象设定不同的属性值,比如每个人的名字都不一样,性别不一样,年龄不一样等等。只不过光有属性还不行,对象还需要具有一定的行为,就像我们人可以行走,可以跳跃,可以思考一样。
而对象也可以做出一些行为,我们可以通过定义函数来实现,类的函数和我们之前编写的函数有一些区别,它是属于这个类的,我们之前使用的函数都是直接编写在Kt文件中,它们都是顶级函数。
要使用类的成员函数,我们只能通过对象来进行调用:
是不是稍微有一些体会了?好像真的是我们在让对象执行一个动作一样。在类的成员函数中,我们可以直接访问当前类对象中的一些属性,比如我们这里的用户名和年龄:
注意,这里我们访问的name和age属性,是当前这个对象的name和age属性。比如:

image-20220920101033325
注意,下面这种情况,我们需要特殊处理:
如果函数中的变量存在歧义,那么优先使用作用域最近的一个,比如函数形参的name作用域更近,那么这里的name拿到的一个是形参name,而不是类的成员属性name。
如果我们需要获取的是类中的成员属性,需要使用
this
关键字来表示当前类:默认情况下,如果作用域不冲突,使用类中属性
this
可以省略。在类中,我们同样可以定义多个同名但不同参数的函数实现重载:
实际上类中的函数使用起来跟我们之前定义的大差不差,只不过多了更多用法而已。
再谈基本类型
在Kotlin中,万物皆为对象,实际上我们在上一章学习的全部基本类型,都是官方为我们提供的类。
现在我们学习了类与对象的知识,就可以来重新认识一下这些基本类型,实际上这些基本类型同样是类,也具有一些属性,以及一些类中的成员函数。实际上在上一章中,我们就已经开始使用类和对象了,我们对这些基本类型的操作同样是在操作对象:
特别说明: 在Kotlin中,虽然编码时万物皆对象,但是在最终编译时,会根据上下文进行优化性能,大部分情况下会优先编译为Java原生基本数据类型(不是对象)而另一部分情况下才会编译为Java中的Integer包装类型。因此很容易出现以下迷惑行为:各位小伙伴可以在完整学习Java和后续Kotlin内容之后再来探究这个问题。
既然这些基本类型也是类,那么肯定同样具有成员属性和成员函数,我们可以使用这些成员方法方便我们的项目开发,比如我们之前遇到的一个很麻烦的问题,不同类型的数无法相互转换:

image-20230731233455602
这些时候可能我们需要将对应类型的数据转换为其他类型,那么该怎么办呢,实际上,在这些基本类型中都提供了对应类型转换成员函数,这里我们可以使用
toInt
来直接将Double类型的数据转换为Int类型:这样就可以编译通过了。同样的,每个基本类型都有对应的类型转换函数,而且非常全面,比如Int类型:

image-20230731233807465
有了这些成员函数,就大幅度方便了我们的类型转换,再比如我们常见的String类型,也有很多函数可以使用:
不过需要注意的是,我们在前面就说过,字符串一旦创建就是不可变的,因此,字符串中所有的函数得到的新字符串,都是重新创建的一个新的对象,而不是在原本的字符串上进行修改。
我们继续来看看一些有意思的函数,比如我们想批量替换字符串中的某些内容:
将字符串中所有的字母
o
替换为a
,直接使用replace函数就能直接生成替换之后的字符串了。又比如我们要判断某个字符串是否以指定文本开头:可以看到这里经过判断得到了一个Boolean类型的结果,还有很多用于判断字符串是否为空、是否有空格等等的函数:
我们还发现,这些基本类型中有一些比较特殊的函数,比如
plus
函数:
image-20230801000753879
这个函数在类中定义长这样:
这个函数添加了一个
operator
关键字,这个是什么呢?这其实是运算符重载,能够自定义运算符实现的功能,我们之前使用这些数字进行运算,比如加减乘除,实际上都是这些基本类型在类中重载了运算符实现的,下一部分,我们就来介绍一下运算符重载函数。运算符重载函数
Kotlin支持为程序中已知的运算符集提供自定义实现,这些运算符具有固定的符号表示(如
+
或*
)以及对应的优先级,要实现运算符重载,请为相应类型提供具有对应运算符指定名称的成员函数,而当前的类对象,则直接作为对应运算符左边的操作数,如果是一元运算符(比如++自增运算符,只需要本事)则直接作为操作数参与运算。比如,现在我们想要为我们自定义的类型支持加法运算:

image-20230801001740893
我们可以直接在类定义中添加一个固定名称(名称是预设好的,不能自己想写什么写什么)的函数,这里的加法运算就是
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的功能完全一致
此时,两个函数都匹配这里的运算符使用,编译器不知道该用哪一个了,因此就会出现歧义:

image-20230801004754437
比较运算符只需要实现一个函数即可:
运算符 | 对应的函数名称 |
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函数必须满足以下要求:- 必须是成员函数。
- 只能有一个参数。
- 参数不能有默认值。
我们可以像下面这样编写:
我们在使用时,也非常方便,真的就像在使用一个运算符一样:
得到的结果显而易见:

image-20230821023203951
当然,我们也可以把它当做一个普通的函数进行调用,效果是完全等价的:
这里需要注意一下:
中缀函数调用的优先级低于算术运算符、类型转换和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
的,否则会报错,无法编译通过:
image-20230801010807824
这是因为所有的类型默认都是非空类型,非空类型的变量是不允许被赋值为null的,这直接在编译阶段就避免了其他语言中经常存在的空指针问题。
那么,如果我们希望某个变量在初始情况下使用
null
而不去引用某一个具体对象,该怎么做呢,此时我们需要将变量的类型修改为可空类型,只需在类型名称的后面添加一个?
即可:既然现在是可空类型,那么很多问题就会出现了,比如当一个变量为
null
时,此时如果使用类中的一些成员方法或是获取成员属性时,会出现一些问题:
image-20230801011417154
这里由于我们操作的是一个空类型,它有可能值为
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文件都挤在一起,这个时候我们就需要用到包了。

image-20230821025349332
我们可以直接右键新建一个软件包,软件包的包名建议以域名格式进行命名,例如:
- com.baidu
- cn.itbaima
这类似于我们平时在浏览器中访问的网站地址,只不过是反过来的,这样就能很明确是哪一家公司或哪一个人制作的产品了。
这里我们随便创建一个:

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

image-20230821025738398
所有不在默认包下kt文件,必须在顶部声明所属的包,比如这里的Test.kt就放在
com.test
这个包中,因此顶部必须使用package关键字进行包声明,IDEA非常智能,在创建时就自动帮助我们生成好了。我们可以继续像之前一样,编写类或是函数:不过,由于现在kt文件存放在了一个明确的包中,如果我们要在这个包以外的其他地方使用,会出现一些问题:

image-20230821030210198
当我们使用其他包中kt文件定义的类或函数时,会直接提示未解析的引用,这是因为默认情况下只有同包内的内容可以相互使用,而现在我们使用的是其他包中的内容,我们需要先进行导入操作:
这样,我们在导入之后就可以正常使用了,当然,如果一个包中定义的内容太多,我们需要大量使用,也可以使用
*
一次性导入全部内容:实际上官方提供的库,也是来自于不同的包,但是Kotlin在默认情况下会自动导入一些包,不需要我们明确指定:
比如我们之前用到的一些基本类型,都是在
kotlin
这个包中定义的。
image-20230821030757225
注意:在不同的平台下,还会有更多默认导入的包,比如Java平台下,就会默认导入
java.lang.*
和kotlin.jvm.*
这两个包。在有些情况下,可能会出现名称冲突的情况:
结果显而易见,这里会优先使用导入的函数,而不是在当前文件中定义的同名函数。那么该如何去解决这种冲突的情况呢?我们可以使用
as
关键字来为导入的内容起个新的名字:这样就可以很好地消除存在歧义的情况了,最后总结一下,使用
import
关键字支持导入以下内容:- 顶级函数和属性
- 在单例对象中声明的函数和属性(下一章介绍)
- 枚举常量(下一章介绍)
访问权限控制
有些时候,我们可能不希望别人使用我们的所有内容,比如:
在上面的例子中,有一个函数是我们不希望被外部调用的,但是经过前面的学习,我们只需要使用
import
关键字就能直接导入,那有没有办法能够控制一下其他地方对于当前文件一些可能私有函数或是其他内容的访问呢?我们可以使用可见性控制来处理。在类、对象、接口、构造函数和函数,以及属性上,可以为其添加 可见性修饰符 来控制其可见性,在Kotlin中有四个可见性修饰符,它们分别是:
private
、protected
、internal
和public
,默认可见性是public
,在使用顶级声明时,不同可见性的访问权限如下:- 如果不使用可见性修饰符,则默认使用
public
,这意味着这里声明的内容将在任何地方可访问。
- 如果使用
private
修饰符,那么声明的内容只能在当前文件中可访问。
- 如果使用
internal
修饰符,它将在同一模块中可见(当前的项目中可以随意访问,与public没大差别,但是如果别人引用我们的项目,那么无法使用)
- 顶级声明不支持使用
protected
修饰符。
因此,在默认情况下,我们定义的内容都是可以访问的,而想要实现上面的效果,我们可以为其添加
private
修饰符:这样,当其他地方使用时,就会报错:

image-20230821033355689
在类中定义成员属性时,不同可见性的访问权限如下:
private
意味着该成员仅在此类中可见(包括其所有成员)
protected
与private
的可见性类似,外部无法使用,但在子类中可以使用(子类会在下一章中介绍)
internal
意味着本项目中任何地方都会看到其internal
成员,但是别人引用我们项目时不行。
public
意味着任何地方都可以访问。
比如下面的例子:
有了访问控制,我们就可以更加明确地表示哪些内容是可以访问,而哪些是内部使用的。
封装、继承和多态
封装、继承和多态是面向对象编程的三大特性。
封装,把对象的属性和函数结合成一个独立的整体,隐藏实现细节,并提供对外访问的接口。继承,从已知的一个类中派生出一个新的类,叫子类。子类实现了父类所有非私有化的属性和函数,并根据实际需求扩展出新的行为。多态,多个不同的对象对同一消息作出响应,同一消息根据不同的对象而采用各种不同的函数。
正是这三大特性,能够让我们的Kotlin程序更加生动形象。
类的封装
封装的目的是为了保证变量的安全性,使用者不必在意具体实现细节,而只是通过外部接口即可访问类的成员,如果不进行封装,类中的实例变量可以直接查看和修改,可能给整个程序带来不好的影响,因此在编写类时一般将成员变量私有化,外部类需要使用Getter和Setter函数来查看和设置变量。从这里开始,我们前面学习的权限访问控制就开始起作用了。
我们可以将之前的类进行改进:
现在,外部需要获取一个学生对象的属性时,只能使用特定的函数进行获取,而不像之前一样可以随意访问对象的属性:
这样的好处显而易见,其他地方只能拿到在内部某个成员属性引用的对象,而没办法像之前那样直接修改Student对象中某个成员属性。
同样的,如果要运行外部对对象中的属性进行修改,那么我们也可以提供对应的set函数:
等等,这不就是我们之前讲的属性的getter和setter函数吗,没错,哪怕我们不手动编写,成员属性也会存在默认的。但是,除了直接赋值之外我们也可以设置更多参数才能给学生改名字:
我们自己封装好的名字设置方法暴露给外部使用,而不让外部直接操作名字。
我们甚至还可以将主构造函数改成私有的,需要通过其他的构造函数来构造:
封装思想其实就是把实现细节给隐藏了,外部只需知道这个函数是什么作用,如何去用,而无需关心实现,要用什么由类自己提供好,而不需要外面来操作类内部的东西去完成(你让我做一件事情,我自己的事情自己做,不要你来帮我安排)封装就是通过访问权限控制来实现的。
类的继承
前面我们介绍了类的封装,我们接着来看一个非常重要特性:继承。
在定义不同类的时候存在一些相同属性,为了方便使用可以将这些共同属性抽象成一个父类,在定义其他子类时可以继承自该父类,减少代码的重复定义,根据前面的访问权限等级,子类可以使用父类中所有非私有的成员。
比如说我们一开始使用的学生,那么实际上学生根据专业划分,所掌握的技能也会不同,比如体育生会运动,美术生会画画,土木生会搬砖,计算机生会因为互联网寒冬找不到工作,因此,我们可以将学生这个大类根据不同的专业进一步地细分出来:

image-20230821192959617
虽然我们划分出来这么多的类,但是其本质上还是学生,也就是说学生具有的属性,这些划分出来的类同样具有,但是,这些划分出来的类同时也会拥有他们自己独特的技能。就好比大学里的学生无论什么专业都会打游戏,都会睡觉,逃课,考试抄答案,四六级过不了,只不过他们专业不同,学的的方向不一样,也就掌握了其他专业不具备的技能。
在Kotlin中,我们可以使用继承操作来实现这样的结构,默认情况下,Kotlin类是“终态”的(不能被任何类继承)要使类可继承,请用
open
关键字标记需要被继承的类:我们可以像下面这样来创建一个继承学生的类:
类的继承可以不断向下,但是同时只能继承一个类,在Kotlin中不支持多继承,只不过套娃还是可以的:
当一个类继承另一个类时,属性会被继承,可以直接访问父类中定义的属性,除非父类中将属性的访问权限修改为
private
,那么子类将无法访问(但是依然是继承了这个属性的)比如下面的例子:是不是感觉非常人性化,子类继承了父类的全部能力,同时还可以扩展自己的独特能力,就像一句话说的: 龙生龙凤生凤,老鼠儿子会打洞。这里需要特别注意一下,因为子类相当于是父类的扩展,但是依然保留父类的特性,所以说,在对象创建并初始化的时候,不仅会对子类进行初始化,也会优先对父类进行初始化:
实际上这里就是在构造这个子类对象之前,调用了一次父类的构造函数,而我们用于继承指定的构造函数,就是会被调用的那一个。
因此,如果父类存在一个有参构造函数,子类同样必须在构造函数中调用:
如果父类存在多个构造函数,可以任选一个:
当子类只存在辅助构造函数时,需要使用super关键字来匹配父类的构造函数:
也可以去匹配子类中其他构造函数:
如果子类既有主构造函数,也有辅助构造函数,那么其他辅助构造函数只能直接或间接调用主构造函数:
是不是感觉玩法太多,都眼花缭乱了?实际上只要各位小伙伴心里面清楚下面的规则,就很好理解上面这一堆写法了:
- 构造函数相当于是这个类初始化的最基本函数,在构造对象时一定要调用
- 主构造函数因为可能存在一些类的属性,所以说必须在初始化时调用,不能让这些属性初始化时没有初始值
- 子类因为是父类的延展,因此,子类在初始化时,必须先初始化父类,就好比每个学生都有学生证,这是属于父类的属性,如果子类在初始化时可以不去初始化父类,那岂不是美术生可以没有学生证?显然是不对的。
优先级关系:父类初始化 > 子类主构造 > 子类辅助构造
属性的覆盖
有些时候,我们可以希望子类继承父类的某些属性,但是我们可能希望去修改这些属性的默认实现。比如,美术生虽然也是学生,也会打招呼,但是可能他们打招呼的方式跟普通的学生不太一样,我们能否对打招呼这个函数的默认实现进行修改呢?
我们可以使用
override
关键字来表示对于一个属性的重写(覆盖)就像这样:覆盖之后,当我们使用子类进行打招呼时,函数会按照我们覆盖的内容执行,而不是原本的:

image-20230823200019143
同样的,类的某个变量也是可以进行覆盖的:
是不是感觉很神奇?不过对于可变的变量,似乎下面这样来的更方便?
有些时候为了方便,比如在父类中的属性,我们可以直接在子类的主构造函数中直接覆盖:
虽然现在已经很方便了,但是现在又来了一个新的需求,打招呼不仅要有子类的特色,同时也要保留父类原有的实现,这个时候该怎么办呢?我们可以使用
super
关键字来完成:这样,我们在覆盖原本的函数时,也可以执行原本的实现,在一些对函数内容进行增强的常见,这种用法非常常见:

image-20230823201931679
不过,由于存在我们之前讲解的的初始化顺序,下面的这种情况需要特别注意:
由于父类初始化在子类之前,此时子类还未开始初始化,其覆盖的属性此时没有初始值,根据不同平台的实现,可能会出现一些问题,比如JVM平台下,没有初始化的对象引用默认为
null
,那么这里就会直接报空指针异常:
image-20230823203609819
很神奇对吧,这里的
name
属性明明是一个非可空的String类型,居然还会出现null
的情况报空指针,因此,对于这些使用了open
关键字的属性(函数、变量等)只要是在初始化函数、构造函数中使用,IDEA都会给出警告:
image-20230823204016350
我们接着来讲一个很绕的东西,在使用一些子类的时候,我们实际上可以将其当做其父类来进行使用:
之所以支持这样去使用,是因为子类本身就是对父类的延伸,因此将其当做父类使用,也是没有问题的。就好比我们无论是美术生还是体育生,都可以当做学生来用,都可以送去厂里实习打螺丝,不然不给毕业证。
只不过,如果我们将一个对象作为其父类使用,那么在使用时也只能使用其父类的一些属性,就相当于我们在使用一个父类的对象:

image-20230823210031003
即使我们很清楚这里引用的对象是一个美术生,但是只能当做普通学生来用,这在后面的集合类中会经常用到,因为集合类往往存在多种不同的实现,但是我们只需要关心怎么用就行了,并且为了方便更换实现,所以一般使用集合类对应的接口来作为变量的类型。
那么,如果子类重写了父类的某个函数,此时我们以父类的形式去使用,结果会怎么样?

image-20230823210424758
可以看到,虽然当做父类来使用,但是其本质是不会变的,所以说,这里执行的结果依然是子类的覆盖实现。
那么,如果项目中很多这种明明是子类但是拿来当做父类用,我们怎么去判断使用的对象到底是什么类型的呢?我们可以使用
is
关键字来进行类型判断,以下面的三个类为例:现在我们进行类型判断:
可以看到,使用is关键字可以精准地对类型进行判断,只要判断的对象是这个类或是这个类的子类,那么就会返回true作为结果。
如果我们明确某个变量引用的对象是什么类型,可以使用
as
关键字来进行强制类型转换:不过,编译器非常智能,它可以根据当前的语境判断的类型自动进行类型转换:
此时IDEA中会出现提示:

image-20230823224118184
不仅仅是if判断的场景、包括when、while,以及
&&
||
等运算符都支持智能转换,只要上下文语境符合就能做到:不仅仅是这种场景,比如我们前面讲解的可空类型,同样支持这样的智能转换:
在处理一些可空类型时,为了防止出现异常,我们可以使用更加安全的
as?
运算符:有了这些操作,类和对象在我们使用的过程中就逐渐开始千变万化了,后面我们还会继续认识更多的多态特性。
顶层Any类
在我们不继承任何类的情况下,实际上Kotlin会有一个默认的父类,所有的类默认情况下都是继承自Any类的。
这个类的定义如下:
由于默认情况下类都是继承自Any,因此Any中定义的函数也是被继承到子类中了。
首先我们来看这个
equals
函数,它实际上是对==
这个运算符的重载,我们之前在使用一些基本类型的时候,就经常使用==
来判断这些类型是否相同,比如Int类型的数据:经过前面的学习,我们知道这些基本类型本质上也是定义的类,实际上它们也是通过重写这个函数来实现这些比较操作的(一些基本类型会根据不同的平台进行编译优化,没法看源码)
我们可以看到,这个函数接受的参数类型是一个
Any?
类型:到目前为止,我们认识了Kotlin中两种相等的判断方式:
- 结果上 相等 (
==
等价于equals()
)
- 引用上 相等 (
===
判断两个变量是否都是引用的同一个对象)
我们在使用
equals
比较两个可空对象是否相等时,就像这样:实际上会被翻译为:
当然可能会有小伙伴疑问,那不等于判断呢?实际上是一样的:
我们也可以为我们自己编写的类型重写
equals
函数,比如我们希望Student类型当名字和年龄相等时,就可以使用==
来判断为true,我们可以像这样编写:此时我们已经将其比较操作重写,我们可以来试试看:
默认情况下,如果我们不重写类的
equals
函数,那么会直接对等号两边的变量进行引用判断===
判断是否为同一个对象。只不过,可以很清楚地看到IDEA提示我们:
image-20230903022730000
实际上在我们重写类的
equals
函数时,根据约定,必须重写对于的hashCode函数,至于为什么,我们会在后续的集合类部分中进行介绍,这里我们暂时先不对hashCode函数进行讲解。接着我们来看下一个,
toString
函数用于快速将对象转换为字符串,只不过默认情况下,会像这样:
image-20230903024131154
可以看到打印的结果是对象的
类型@十六进制哈希值
的形式,在某些情况下,可能我们更希望的是转换对象的一些成员属性,这样我们可以更直观的看到对象的属性具有什么值:现在得到的结果,就是我们自定义的结果了:

image-20230903024524946
抽象类
有些情况下,我们设计的类可能仅仅是作为给其他类继承使用的类,而其本身并不需要创建任何实例对象,比如:
可以看到,在上面这个例子中,Student类的
hello
函数在子类中都会被重写,所以说除非在子类中调用父类的默认实现,否则一般情况下,父类中定义的函数永远都不会被调用。就像我们说一个学生会怎么考试一样,实际上学生怎么考试是一个抽象的概念,但是由于学生的种类繁多,美术生怎么考试和体育生怎么考试,才是具体的一个实现。所以说,我们可以将学生类进行进一步的抽象,让某些函数或字段完全由子类来实现,父类中不需要提供实现。我们可以使用
abstract
关键字来将一个类声明为抽象类:当一个子类继承自抽象类时,必须要重写抽象类中定义的抽象属性和抽象函数:
这是强制要求的,如果不进行重写将无法通过编译。同时,抽象类是不允许直接构造对象的,只能使用其子类:

image-20230903031350955
当然,抽象类不仅可以具有抽象的属性,同时也具有普通类的性质,同样可以定义非抽象的属性或函数:
同时,抽象类也可以继承自其他的类(可以是抽象类也可以是普通类)
虽然抽象类可以继承一个普通的类,但是这依然不改变它是抽象类的本质,子类依然要按照上面的要求进行编写。
接口
由于Kotlin中不存在多继承的操作,我们可以使用接口来替代。
前面我们认识了抽象类,它可以具有一些定义而不实现的内容,而接口比抽象类还要抽象,一般情况下,他只代表某个确切的功能!也就是只能包含函数或属性的定义,所有的内容只能是
abstract
的,它不像类那样完整。接口一般只代表某些功能的抽象,接口包含了一系列内容的定义,类可以实现这个接口,表示类支持接口代表的功能。比如,学生具有以下功能:
- 打游戏
- 睡懒觉
- 逃课
- 考试作弊
我们可以将这一系列功能拆分成一个个的接口,然后让学生实现这些接口,来表示学生支持这些功能。
在Kotlin中,要声明接口,我们可以使用
interface
关键字:可以看到,接口相比于抽象类来说,更加的纯粹,它不像类那样可以具有什么确切的属性,一切内容都是抽象的,只能由子类来实现。
只不过,在接口中声明的属性可以是抽象的,也可以为Getter提供默认实现。在接口中声明的属性无法使用
field
后背字段,因此在接口中声明的Setter无法使用field
进行赋值:为了应对变化多端的需求,接口也可以为函数编写默认实现:
这样一看,这函数可以写默认的实现那接口似乎变得不那么抽象了?这用着感觉好像跟抽象类没啥区别啊?接口跟类的最大区别其实就是状态的保存,这从上面的成员属性我们就可以看的很清楚。
接口也可以继承自其他接口,直接获得其他接口中的定义:
是不是感觉接口的玩法非常有意思?只不过玩的过程中,可能也会遇到一些麻烦,比如下面的这种情况:
这种情况下,我们需要手动解决冲突,比如我们希望Student类采用接口B的默认实现:
对于接口,我们可以像之前一样,将变量的类型设定为一个接口的类型,当做某一个接口的实现来使用,同时也支持
is
、as
等关键字进行类型判断和转换:是不是感觉跟之前使用起来是差不多的?其实只要前面玩熟悉了,后面还是很简单的。
类的扩展
Kotlin提供了扩展类或接口的操作,而无需通过类继承或使用装饰器等设计模式,来为某个类添加一些额外的函数或是属性,我们只需要通过一个被称为扩展的特殊声明来完成。
例如,您可以从无法修改的第三方库中为类或接口编写新函数,这些函数可以像类中其他函数那样进行调用,就像它们是类中的函数一样,这种机制被称为扩展函数。还有扩展属性,允许您为现有类定义新属性。
比如我们想为String类型添加一个自定义的操作:

image-20231224000802923
是不是感觉很神奇?通过这种机制,我们可以将那些第三方类不具备的功能强行进行扩展,来方便我们的操作。
注意,类的扩展是静态的,实际上并不会修改它们原本的类,也不会将新成员插入到类中,仅仅是将我们定义的功能变得可调用,使用起来就像真的有一样。同时,在编译时也会明确具体调用的扩展函数:
由于类的扩展是静态的,因此在编译出现歧义时,只会取决于形参类型。
如果是类本身就具有同名同参数的函数,那么扩展的函数将失效:
不过,我们如果通过这种方式实现函数的重载,是完全没有问题的:
同样的,类的属性也是可以通过这种形式来扩展的,但是有一些小小的要求:

image-20231224133250495
可以看到直接扩展属性是不允许的,前面我们说过,扩展并不是真的往类中添加属性,因此,扩展属性本质上也不会真的插入一个成员字段到类的定义中,这就导致并没有变量去存储我们的数据,我们只能明确定义一个getter和setter来创建扩展属性,才能让它使用起来真的像是类的属性一样:
由于扩展属性并没有真正的变量去存储,而是使用get和set函数来实现,所以,像前面认识的field这种后备字段,就无法使用了。

image-20231224140003005
还有一个需要注意的时,我们在不同包中定义的扩展属性,同样会受到访问权限控制,需要进行导入才可以使用:
除了直接在顶层定义类的扩展之外,我们也可以在一个类中定义其他类的扩展,并且在定义时可以直接使用其他类提供的属性:
像这种扩展,由于是在类中定义,因此也仅限于类内部使用,比如:
扩展属性无法访问那些本就不应该被当前作用域访问的类属性,即使它是对某个类的扩展,比如下面这种情况:

image-20231224142935236
在名称发生冲突时,需要特别处理:
定义在类中的扩展也可以跟随类的继承结构,进行重写:
局部扩展也是可以的,我们可以在某个函数里面编写扩展,但作用域仅限于当前函数:
如果我们将一个扩展函数作为参数给到一个函数类型变量,那么同样需要再具体操作之前增加类型名称才可以:
可以看到,此函数的类型是
String.() -> Int
,也就是说它是专门针对于String类型编写的扩展函数,没有参数,返回值类型为Int,并使用Lambda表达式进行赋值,同时这个函数也是属于String类型的,只能由对象调用,或是主动传入一个相同类型的对象作为参数才能直接调用。可能这里会有些绕不太好理解,需要同学们多去思考。总结一下,扩展属性更像是针对于原本类编写的外部工具函数,而绝不是对原有类的修改。
————————————————
版权声明:本文为柏码知识库版权所有,禁止一切未经授权的转载、发布、出售等行为,违者将被追究法律责任。
原文链接:https://www.itbaima.cn/document/t7lnl87f74f3v1ju
Loading...