Kotlin入门
Kotlin入门
一、变量和函数
1.1 变量
- 在Kotlin中定义变量的方式和Java区别很大,在Java中如果想要定义一个变量,需要在变量前面声明这个变量的类型,比如说
int a
表示a
是一个整型变量,String b
表示b
是一个字符串变量。而Kotlin中定义一个变量,只允许在变量前声明两种关键字:val
和var
。val
(value的简写)用来声明一个不可变的变量,这种变量在初始赋值之后就再也不能重新赋值,对应Java中的final
变量。var
(variable的简写)用来声明一个可变的变量,这种变量在初始赋值之后仍然可以再被重新赋值,对应Java中的非final
变量。
- 仅仅使用
val
或者var
来声明一个变量,那么编译器怎么能知道这个变量是什么类型呢?这也是Kotlin比较有特色的一点,它拥有出色的类型推导机制。 - 🌰举个栗子
1 | fun main(){ |
- 在上述代码中,我们使用
val
关键字定义了一个变量a
,并将它赋值为10,这里a
就会被自动推导成整型变量。因为既然你要把一个整数赋值给a
,那么a
就只能是整型变量,而如果你要把一个字符串赋值给a
的话,那么a
就会被自动推导成字符串变量,这就是Kotlin的类型推导机制。 - 但是Kotlin的类型推导机制并不总是可以正常工作的,比如说如果我们对一个变量延迟赋值的话,Kotlin就无法自动推导它的类型了。这时候就需要显式地声明变量类型才行,Kotlin提供了对这一功能的支持,语法如下所示:
1 | val a: Int = 10 |
- 在上述代码中我们显式的声明了变量
a
为Int
类型,这个时候 Kotlin就不会再进行类型推导了。如果此时我们再尝试将一个 字符串复制给a
,那么编译器会抛出一个类型不匹配的异常。 - Kotlin中
Int
的首字母是大写的,而Java中int
的首字母是小写的。不要小看这一个字母大小写的差距,这表示Kotlin完全抛弃了Java中的基本数据类型,全部使用了对象数据类型。在Java中int
是关键字,而在Kotlin中Int
变成了一个类,它拥有自己的方法和继承结构。下表列出了Java中的每一个基本数据类型在Kotlin中对应的对象数据类型。
Java基本数据类型 | Kotlin对象数据类型 | 数据类型说明 |
---|---|---|
int | Int | 整型 |
long | Long | 长整型 |
short | Short | 短整型 |
float | Float | 单精度浮点型 |
double | Double | 双精度浮点型 |
boolean | Boolean | 布尔型 |
char | Char | 字符型 |
byte | Byte | 字节型 |
- 在Java中,除非你主动在变量前声明了
final
关键字,否则这个变量就是可变的。然而这并不是一件好事,当项目变得越来越复杂,参与开发的人越来越多时,你永远不知道一个可变的变量会在什么时候被谁给修改了,即使它原本不应该被修改,这就经常会导致出现一些很难排查的问题。因此,一个好的编程习惯是,除非一个变量明确允许被修改,否则都应该给它加上final
关键字。 - 但是,不是每个人都能养成这种良好的编程习惯。我相信至少有90%的Java程序员没有主动在变量前加上
final
关键字的意识,仅仅因为Java对此是不强制的。因此,Kotlin在设计的时候就采用了和Java完全不同的方式,提供了val
和var
这两个关键字,必须由开发者主动声明该变量是可变的还是不可变的。 - 小诀窍:就是永远优先使用
val
来声明一个变量,而当val
没有办法满足你的需求时再使用var
。这样设计出来的程序会更加健壮,也更加符合高质量的编码规范。
1.2 函数
- 函数是用来运行代码的载体,我们可以在一个函数里编写很多行代码,当运行这个函数时,函数中的所有代码会全部运行。就像
main()
函数就是一个函数,只不过它比较特殊,是程序的入口函数,即程序一旦运行,就是从main()
函数开始执行的。 - 但是只有一个main()函数的程序显然是很初级的,和其他编程语言一样,
Kotlin
也允许我们自由地定义函数,语法规则如下:
1 | fun methodName (param1: Int, param2: Int): Int{ |
- 首先
fun
(function的简写)是定义函数的关键字,无论你在Kotlin
中定义什么函数,都一定要使用fun
来声明。 - 其次这里的
methodName
是函数名,我们可以给它取任何名字,但是最好能够做到见文知意。良好的编程习惯是函数名最好要有一定的意义,能表达这个函数的作用是什么。 - 函数名后面紧跟着一对括号,里面可以声明该函数接收什么参数,参数的数量可以是任意多个,例如上述示例就表示该函数接收两个
Int
类型的参数。参数的声明格式是“参数名: 参数类型”,其中参数名也是可以随便定义的,这一点和函数名类似。如果不想接收任何参数,那么写一对空括号就可以了。 - 参数括号后面的那部分是可选的,用于声明该函数会返回什么类型的数据,上述示例就表示该函数会返回一个
Int
类型的数据。如果你的函数不需要返回任何数据,这部分可以直接不写。 - 最后两个大括号之间的内容就是函数体了,我们可以在这里编写一个函数的具体逻辑。由于上述示例中声明了该函数会返回一个
Int
类型的数据,因此在函数体中我们简单地返回了一个0。
最后我们再来学习一个Kotlin函数的语法糖:当一个函数中只有一行代码时,Kotlin允许我们不必编写函数体,可以直接将唯一的一行代码写在函数定义的尾部,中间用等号连接即可。
- 🌰举个栗子:我们现在编写一个返回两数之间最大数的函数,我们可以这样写
1 | fun largerNumber(a: Int, b: Int): Int{ |
- 在这个函数中,函数体内只有一行代码,此时我们可以不写函数体,简化如下:
1 | fun largerNumber(a: Int, b: Int): Int = max(a, b) |
- 使用这种语法,
return
关键字也可以省略了,等号足以表达返回值的意思。另外,之前还讲过Kotlin出色的类型推导机制,在这里它也可以发挥重要的作用。由于max()
函数返回的是一个Int
值,而我们在largerNumber()
函数的尾部又使用等号连接了max()
函数,因此Kotlin可以推导出largerNumber()
函数返回的必然也是一个Int
值,这样就不用再显式地声明返回值类型了,代码可以进一步简化成如下形式:
1 | fun largerNumber(a: Int, b: Int) = max(a, b) |
二、程序的逻辑控制
程序的执行语句主要分为3种:顺序语句、条件语句和循环语句。顺序语句很好理解,就是代码一行一行地往下执行就可以了,但是这种“愣头青”的执行方式在很多情况下并不能满足我们的编程需求,这时就需要引入条件语句和循环语句了
Kotlin的条件语句主要有两种实现方式:if
和 when
2.1 条件语句
2.1.1 if条件语句
- 首先学习
if
,Kotlin中的if
语句和Java中的if
语句几乎没有任何区别 - 🌰举个栗子:
1 | fun largerNumber(a: Int, b: Int): Int{ |
- 也可以这样写:
1 | fun largerNumber(a: Int, b: Int): Int{ |
- 为什么可以写成上述代码呢?这是因为Kotlin中的
if
语句相比于Java有一个额外的功能,它是可以有返回值的,返回值就是if
语句每一个条件中最后一行代码的返回值。 - 结合之前讲的语法糖,我们可以再简化一下:
1 | fun largerNumber(a: Int, b: Int) = if(a > b){ |
- 但是上述代码还不是最精简的,我们甚至可以这样写:
1 | fun largerNumber(a: Int, b: Int) = if (a > b) a else b |
2.1.2 when条件语句
- 接下来我们开始学习
when
。Kotlin中的when
语句有点类似于Java中的switch
语句,但它又远比switch
语句强大得多; - 首先,在Java中
switch
只能传入整型或短于整型的变量作为条件,JDK 1.7之后增加了对字符串变量的支持,但如果你的判断逻辑使用的并非是上述几种类型的变量,switch
就不再适用了;其次,switch
中的每个case
条件都要在最后主动加上一个break
,否则执行完当前case
之后会依次执行下面的case
,这一特性曾经导致过无数奇怪的bug,就是因为有人忘记添加break
; - 而 Kotlin中的
when
语句不仅解决了上述痛点,还增加了许多更为强大的新特性,有时它比if语句还要简单好用。 - 🌰 举个栗子
- 现在我们编写一个demo,输入一个单词,返回其数字。我们先用if语句来实现:
1 | fun getNumber(words: String) = if( words = "one"){ |
- 虽然上述代码确实可以实现我们想要的功能,但是写了这么多的if和else,我们会发现代码很冗余。没错,当你的判断条件非常多的时候,就是应该考虑使用when语句的时候,现在我们将代码改成如下写法:
1 | fun getNumber(words: String) = when (words) { |
- 在 **Kotlin **中
when
语句和if
语句一样都有返回值,所以我们仍然可以使用单行代码函数的语法糖; - 在 Kotlin中
when
语句允许传入一个任意类型的参数,然后可以在when
的结构体中定义一系列的条件,格式是:
1 | 匹配值 -> {执行逻辑} |
- 当你的执行逻辑只有一行代码时,{ }可以省略。除了精确匹配之外,
when
语句还允许进行类型匹配。那么什么是类型匹配呢? - 🌰 举个栗子
1 | fun checkNumber(num: Number) { |
- 上述代码中,
is
关键字就是类型匹配的核心,它相当于Java中的instanceof
关键字。由于checkNumber()
函数接收一个Number
类型的参数,这是Kotlin内置的一个抽象类,像Int
、Long
、Float
、Double
等与数字相关的类都是它的子类,所以这里就可以使用类型匹配来判断传入的参数到底属于什么类型,如果是Int
型或Double
型,就将该类型打印出来,否则就打印不支持该参数的类型。 - 哦对了,在 Kotlin 中
when
语句还可以不传参数,🌰 举个栗子
1 | fun getNumber(words: String) = when { |
2.2 循环语句
学完条件语句后,接下来我们学习 Kotlin 中的循环语句。
- 熟悉Java的人应该都知道,Java中主要有两种循环语句:
while
循环和for
循环。而Kotlin也提供了while
循环和for
循环,其中while
循环不管是在语法还是使用技巧上都和Java中的while
循环没有任何区别。所以我们直接学习for
循环。 - Kotlin在for循环方面做了很大幅度的修改,Java中最常用的for-i循环在Kotlin中直接被舍弃了,而Java中另一种for-each循环则被Kotlin进行了大幅度的加强,变成了for-in循环,所以我们只需要学习for-in循环的用法就可以了。
- 在开始学习for-in循环之前,先来学习一个区间的概念,因为这也是Java中没有的东西。我们可以使用如下Kotlin代码来表示一个区间:
1 | val range = 0..10 |
这种语法结构虽然看上去挺奇怪的,但在Kotlin中,它是完全合法的。上述代码表示创建了一个0到10的区间,并且两端都是闭区间,这意味着0到10这两个端点都是包含在区间中的,用数学的方式表达出来就是**[0, 10]**。
- 其中,
..
是创建两端闭区间的关键字,在..
的两边指定区间的左右端点就可以创建一个区间了。 - 有了区间之后,我们就可以通过
for-in
循环来遍历这个区间. - 🌰 举个栗子:
1 | fun main(){ |
这就是for-in
循环最简单的用法了,我们遍历了区间中的每一个元素,并将它打印出来。
但是在很多情况下,双端闭区间却不如单端闭区间好用。为什么这么说呢?相信你一定知道数组的下标都是从0开始的,一个长度为10的数组,它的下标区间范围是0到9,因此左闭右开的区间在程序设计当中更加常用。Kotlin中可以使用until
关键字来创建一个左闭右开的区间,如下所示:
1 | val range = 0 until 10 |
上述代码表示创建了一个0到10的左闭右开区间,它的数学表达方式是**[0, 10)**。
当然,我们在 Kotlin 中也可以创建一个降序区间,使用 downTo
关键字,如下所示:
1 | val range = 10 downTo 1 |
默认情况下,for-in
循环每次执行循环时会在区间范围内递增1,相当于Java for-i
循环中i++
的效果,而如果你想跳过其中的一些元素,可以使用step
关键字:🌰 举个栗子:
1 | fun main(){ |
上述代码表示在遍历**[1, 10]**这个区间的时候,每次执行循环都会在区间范围内递增2,相当于for-i
循环中i = i + 2
的效果。当然 downTo
关键字创建的降序区间也是可以使用 step
关键字的。
总的来说,for-in
循环并没有传统的for-i
循环那样灵活,但是却比for-i
循环要简单好用得多,而且足够覆盖大部分的使用场景。如果有一些特殊场景使用for-in
循环无法实现的话,我们还可以改用while
循环的方式来进行实现。
三、面向对象编程
和很多现代高级语言一样,Kotlin
也是面向对象的,因此理解什么是面向对象编程对我们来说就非常重要了。不同于面向过程的语言(比如C语言),面向对象的语言是可以创建类的。类就是对事物的一种封装,比如说人、汽车、房屋、书等任何事物,我们都可以将它封装一个类,类名通常是名词。而类中又可以拥有自己的字段和函数,字段表示该类所拥有的属性,比如说人可以有姓名和年龄,汽车可以有品牌和价格,这些就属于类中的字段,字段名通常也是名词。而函数则表示该类可以有哪些行为,比如
说人可以吃饭和睡觉,汽车可以驾驶和保养等,函数名通常是动词。
通过这种类的封装,我们就可以在适当的时候创建该类的对象,然后调用对象中的字段和函数来满足实际编程的需求,这就是面向对象编程最基本的思想。当然,面向对象编程还有很多其他特性,如继承、多态等,但是这些特性都是建立在基本的思想之上的。
3.1 类与对象
在 Kotlin中 也是使用 class
关键字来声明一个类的,这一点和Java一致;这里创建一个 Person
类,并且加入 字段(属性)和 函数(方法)
1 | class Person { |
这里我们创建了 name
和 age
字段(属性) 然后还定义了一个 eat()
函数(方法)并在其中打印了一句话。
接下来我们实例化一个 Person
对象,Kotlin中实例化一个类的方式和Java是基本类似的,只是去掉了new
关键字而已。之所以这么设计,是因为当你调用了某个类的构造函数时,你的意图只可能是对这个类进行实例化,因此即使没有new
关键字,也能清晰表达出你的意图。Kotlin本着最简化的设计原则,将诸如new
、行尾分号这种不必要的语法结构都取消了。
实例化对象:
1 | val p = Person() |
我们可以在 main()
函数中对p对象进行一些操作,比如:
1 | fun main(){ |
这就是面向对象编程最基本的用法了,简单概括一下,就是要先将事物封装成具体的类,然后将事物所拥有的属性和能力分别定义成类中的字段和函数,接下来对类进行实例化,再根据具体的编程需求调用类中的字段和方法即可。
3.2 继承与构造函数
3.2.1 继承
继承—面向对象编程中另一个极其重要的特性。继承也是基于现实场景总结出来的一个概念,其实非常好理解。比如现在我们要定义一个Student
类,每个学生都有自己的学号和年级,因此我们可以在Student
类中加入sno
和grade
字段。但同时学生也是人呀,学生也会有姓名和年龄,也需要吃饭,如果我们在Student
类中重复定义name
、age
字段和eat()
函数的话就显得太过冗余了。这个时候就可以让Student
类去继承Person
类,这样Student
就自动拥有了Person
中的字段和函数,另外还可以定义自己独有的字段和函数。这就是面向对象编程中的继承思想。
接下来实现让 Student
类继承 Person
类,我们要先做两件事:
第一件事:使 Person
类可以被继承,对你没看错,就是先让父类可被继承。可能很多人会觉得奇怪,尤其是有Java编程经验的人。一个类本身不就是可以被继承的吗?为什么还要使Person
类可以被继承呢?这就是Kotlin不同的地方,在Kotlin中任何一个非抽象类默认都是不可以被继承的,相当于Java中给类声明了final
关键字。之所以这么设计,其实和val
关键字的原因是差不多的,因为类和变量一样,最好都是不可变的,而一个类允许被继承的话,它无法预知子类会如何实现,因此可能就会存在一些未知的风险。Effective Java这本书中明确提到,如果一个类不是专门为继承而设计的,那么就应该主动将它加上final
声明,禁止它可以被继承。
很明显,Kotlin在设计的时候遵循了这条编程规范,默认所有非抽象类都是不可以被继承的。之所以这里一直在说非抽象类,是因为抽象类本身是无法创建实例的,一定要由子类去继承它才能创建实例,因此抽象类必须可以被继承才行,要不然就没有意义了。
那我们应该如何让 Person
类可被继承呢?其实很简单!只需要在类前面加上 open
关键字就可以啦!如下:
1 | open class Person{ |
加上open
关键字之后,我们就是在主动告诉Kotlin编译器,Person
这个类是专门为继承而设计的,这样Person
类就允许被继承了。
第二件事: 让 Student
类继承 Person
类,在Java中继承的关键字是extends
,而在Kotlin中变成了一个冒号。写法如下:
1 | class Student : Person(){ |
继承的写法如果只是替换一下关键字倒也挺简单的,但是为什么Person
类的后面要加上一对括号呢?Java中继承的时候好像并不需要括号。这对括号还涉及 Kotlin
中 主构造函数、次构造函数等方面的知识.
3.2.2 构造函数
任何一个面向对象的编程语言都会有构造函数的概念,Kotlin中也有,但是Kotlin将构造函数分成了两种:主构造函数和次构造函数。
3.2.2.1 主构造函数
主构造函数将会是我们最常用的构造函数,每个类默认都会有一个不带参数的主构造函数,当然也可以显式地给它指明参数。主构造函数的特点是没有函数体,直接定义在类名的后面即可。比如下面这种写法:
1 | class Student (val sno: String, val grade: Int) : Person(){ |
这里我们将学号和年级这两个字段都放到了主构造函数当中,这就表明在对Student
类进行实例化的时候,必须传入构造函数中要求的所有参数。比如:
1 | val student = Student("123", 5) |
这样我们就创建了一个Student
的对象,同时指定该学生的学号是123
,年级是5
。另外,由于构造函数中的参数是在创建实例的时候传入的,不像之前的写法那样还得重新赋值,因此我们可以将参数全部声明成val
。
当然啦,我们也可以在主构造函数当中写一些逻辑,Kotlin给我们提供了一个 init
结构体,所有主构造函数的逻辑都可以写在里面:
1 | class Student (val sno: String, val grade: Int) : Person() { |
上述代码中,我们打印了学号以及年级。到这里为止都还挺好理解的,但是这和那对括号又有什么关系呢?这就涉及了Java继承特性中的一个规定,子类中的构造函数必须调用父类中的构造函数,这个规定在Kotlin中也要遵守。
看一下Student类,现在我们声明了一个主构造函数,根据继承特性的规定,子类的构造函数必须调用父类的构造函数,可是主构造函数并没有函数体,我们怎样去调用父类的构造函数呢?你可能会说,在init
结构体中去调用不就好了。这或许是一种办法,但绝对不是一种好办法,因为在绝大多数的场景下,我们是不需要编写init
结构体的。
Kotlin当然没有采用这种设计,而是用了另外一种简单但是可能不太好理解的设计方式:括号。子类的主构造函数调用父类中的哪个构造函数,在继承的时候通过括号来指定。因此再来看一遍这段代码,你应该就能理解了吧。
1 | class Student (val sno: String, val grade: Int) : Person(){ |
在这里,Person
类后面的一对空括号表示Student
类的主构造函数在初始化的时候会调用Person
类的无参数构造函数,即使在无参数的情况下,这对括号也不能省略。
此时如果我们更改一下 Person
类,将姓名和年龄放到主构造函数中,如下:
1 | open class Person (val name: String, val age: Int) |
此时我们在Student
再去使用空括号调用Person
类的无参构造函数肯定会报错,因为此时Person
类的主构造函数需要 name
和 age
两个参数。
要解决这个问题也很简单,给Person
类的构造函数传入name
和age
字段就好了。可是问题又来了Student
类中也没有这两个字段啊,其实我们可以在 Student
类的主构造函数中 加上 name
和 age
这两个参数,然后再将这两个参数传给Person
类的构造函数就行了。如下:
1 | class Student (val sno: String, val grade: Int, name: String, age: Int) : Person(name, age){ |
⭐注意,我们在Student
类的主构造函数中增加name
和age
这两个字段时,不能再将它们声明成val
,因为在主构造函数中声明成val
或者var
的参数将自动成为该类的字段,这就会导致和父类中同名的name
和age
字段造成冲突。因此,这里的name
和age
参数前面我们不用加任何关键字,让它的作用域仅限定在主构造函数当中即可。
现在可以通过如下代码来创建一个Student
类的实例:
1 | val student = Student("123", 5, "Charlie", 24) |
3.2.2.2 次构造函数
任何一个类只能有一个主构造函数,但是可以有多个次构造函数。次构造函数也可以用于实例化一个类,这一点和主构造函数没有什么不同,只不过它是有函数体的。其实我们几乎是用不到次构造函数的,Kotlin提供了一个给函数设定参数默认值的功能,基本上可以替代次构造函数的作用。
Kotlin规定,当一个类既有主构造函数又有次构造函数时,所有的次构造函数都必须调用主构造函数(包括间接调用)。
🌰举个栗子:
1 | class Student(val sno: String, val grade: Int, name: String, age: Int){ |
次构造函数是通过constructor
关键字来定义的,这里我们定义了两个次构造函数:第一个次构造函数接收name
和age
参数,然后它又通过this
关键字调用了主构造函数,并将sno
和grade
这两个参数赋值成初始值;第二个次构造函数不接收任何参数,它通过this
关键字调用了我们刚才定义的第一个次构造函数,并将name
和age
参数也赋值成初始值,由于第二个次构造函数间接调用了主构造函数,因此这仍然是合法的。
那么现在我们就拥有了3种方式来对Student
类进行实体化,分别是通过不带参数的构造函数、通过带两个参数的构造函数和通过带4个参数的构造函数,对应代码如下所示:
1 | val student1 = Student() |
接下来我们就再来看一种非常特殊的情况:类中只有次构造函数,没有主构造函数。这种情况真的十分少见,但在Kotlin中是允许的。当一个类没有显式地定义主构造函数且定义了次构造函数时,它就是没有主构造函数的。代码如下:
1 | class Student: Person{ |
注意这里的代码变化,首先Student
类的后面没有显式地定义主构造函数,同时又因为定义了次构造函数,所以现在Student
类是没有主构造函数的。那么既然没有主构造函数,继承Person
类的时候就不用继承其主构造函数,也就不需要再加上括号了。
另外,由于没有主构造函数,次构造函数只能直接调用父类的构造函数,上述代码也是将this
关键字换成了super
关键字。
3.3 接口
接口是用于实现多态编程的重要组成部分。我们都知道,Java是单继承结构的语言,任何一个类最多只能继承一个父类,但是却可以实现任意多个接口,Kotlin也是如此。
我们可以在接口中定义一系列的抽象行为,然后由具体的类去实现。下面还是通过具体的代码来学习一下,首先创建一个Study
接口,并在其中定义几个学习行为。
1 | interface Study { |
接下来就可以让Student
类去实现Study
接口
1 | class Student(val sno: String, val grade: Int, name: String, age: Int) : Person(name, age), Study { |
熟悉Java的人一定知道,Java中继承使用的关键字是extends
,实现接口使用的关键字是implements
,而Kotlin中统一使用冒号,中间用,
进行分隔。上述代码就表示Student
类继承了Person
类,同时还实现了Study
接口。另外接口的后面不用加上括号,因为它没有构造函数可以去调用。Study接口中定义了readBooks()
和doHomework()
这两个待实现函数,因此Student
类必须实现这两个函数。Kotlin中使用override
关键字来重写父类或者实现接口中的函数,这里我们只是简单地在实现的函数中打印了一行信息。
接下来在 main()
函数中调用如下代码:
1 | fun main(args: Array<String>) { |
Person
类如下:
1 | open class Person(val name: String, val age: Int) { |
为了让接口的功能更加灵活,Kotlin还增加了一个额外的功能:允许对接口中定义的函数进行默认实现。其实Java在JDK1.8之后也开始支持这个功能了,因此总体来说,Kotlin和Java在接口方面的功能仍然是一模一样的。
对接口中的函数进行默认实现的具体实现如下(修改Study
接口):
1 | interface Study { |
在 Study
接口中,我们新增了一个 sleep()
函数,并且默认实现了。如果接口中的一个函数拥有了函数体,这个函数体中的内容就是它的默认实现。现在当一个类去实现Study
接口时,只会强制要求实现readBooks()
和doHomework()
函数,而sleep
函数则可以自由选择实现或者不实现,不实现时就会自动使用默认的实现逻辑。
现在回到Student
类当中,你会发现如果我们删除了doHomework()
和readBooks()
函数,代码是会提示错误的,而删除sleep()
函数则不会。
以上就是Kotlin面向对象编程中最主要的一些内容,接下来我们再学习一个和Java相比变化比较大的部分——函数的可见性修饰
符。
熟悉Java的人一定知道,Java中有public
、private
、protected
和default
(什么都不写)这4种函数可见性修饰符。Kotlin中也有4种,分别是public
、private
、protected
和internal
,需要使用哪种修饰符时,直接定义在fun
关键字的前面即可。接下来详细介绍一下Java和Kotlin中这些函数可见性修饰符的异同。
首先private
修饰符在两种语言中的作用是一模一样的,都表示只对当前类内部可见。public
修饰符的作用虽然也是一致的,表示对所有类都可见,但是在Kotlin中public
修饰符是默认项,而在Java
中default
才是默认项。前面我们定义了那么多的函数,都没有加任何的修饰符,所以它们默认都是public
的。protected
关键字在Java中表示对当前类、子类和同一包路径下的类可见,在Kotlin
中则表示只对当前类和子类可见。Kotlin
抛弃了Java中的default
可见性(同一包路径下的类可见),引入了一种新的可见性概念,只对同一模块中的类可见,使用的是internal
修饰符。比如我们开发了一个模块给别人使用,但是有一些函数只允许在模块内部调用,不想暴露给外部,就可以将这些函数声明成internal
。
修饰符 | Java | kotlin |
---|---|---|
public | 所有类可见 | 所有类可见(默认) |
private | 当前类可见 | 当前类可见 |
protected | 当前类、子类、同一包路径下的类可见 | 当前类、子类可见 |
default | 同一包路径下的类可见(默认) | 无 |
internal | 无 | 同一模块中的类可见 |
3.4 数据类与单例类
3.4.1 数据类
在一个规范的系统架构中,数据类通常占据着非常重要的角色,它们用于将服务器端或数据库中的数据映射到内存中,为编程逻辑提供数据模型的支持。或许你听说过MVC、MVP、MVVM之类的架构模式,不管是哪一种架构模式,其中的M指的就是数据类。
数据类通常需要重写equals()
、hashCode()
、toString()
这几个方法。其中,equals()
方法用于判断两个数据类是否相等。hashCode()
方法作为equals()
的配套方法,也需要一起重写,否则会导致HashMap
、HashSet
等hash相关的系统类无法正常工作。toString()
方法用于提供更清晰的输入日志,否则一个数据类默认打印出来的就是一行内存地址。
比如在 Java中 我们要实现一个手机数据类,我们要这样写:
1 | public class Cellphone{ |
看上去挺复杂的吧?关键是这些代码还是一些没有实际逻辑意义的代码,只是为了让它拥有数据类的功能而已。而同样的功能使用Kotlin来实现就会变得极其简单:
1 | data class Cellphone(val brand: String, val price: Double) { |
对!你没有看错,在 Kotlin当中,当我们需要一个数据类的时候只需要在这个类前声明了data关键字。当在一个类前面声明了data
关键字时,就表明你希望这个类是一个数据类,Kotlin会根据主构造函数中的参数帮你将equals()
、hashCode()
、toString()
等固定且无实际逻辑意义的方法自动生成,从而大大减少了开发的工作量。
3.4.2 单例类
掌握了数据类的使用技巧之后,接下来我们再来看另外一个Kotlin中特有的功能——单例类。
想必你一定听说过单例模式吧,这是最常用、最基础的设计模式之一,它可以用于避免创建重复的对象。比如我们希望某个类在全局最多只能拥有一个实例,这时就可以使用单例模式。当然单例模式也有很多种写法,这里演示一种最常见的Java写法:
1 | public class Singleton { |
先来看下这段代码,为了禁止外部创建Singleton
的实例,我们使用private
关键字将 Singleton
的构造函数私有化,然后给外部提供了一个getInstance()
静态方法用于获取Singleton
的实例。在getInstance()
方法中,我们判断如果当前缓存的Singleton
实例为null
,就创建一个新的实例,否则直接返回缓存的实例即可,这就是单例模式的工作机制。
如果我们想调用单例类中的方法,也很简单,比如想调用上述的singletonTest()
方法,就可以这样写:
1 | Singleton singleton = Singleton.getInstance(); |
虽然Java中的单例实现并不复杂,但是Kotlin明显做得更好,它同样是将一些固定的、重复的逻辑实现隐藏了起来,只暴露给我们最简单方便的用法。
在Kotlin
中创建一个单例类的方式极其简单,只需要将class
关键字改成object
关键字即可。初始化代码如下所示:
1 | object Singleton { |
可以看到,在Kotlin中我们不需要私有化构造函数,也不需要提供getInstance()
这样的静态方法,只需要把class
关键字改成object
关键字,一个单例类就创建完成了。而调用单例类中的函数也很简单,比较类似于Java中静态方法的调用方式:
1 | Singleton.singletonTest() |
这种写法虽然看上去像是静态方法的调用,但其实Kotlin在背后自动帮我们创建了一个Singleton类的实例,并且保证全局只会存在一个Singleton实例。
四、Lambda编程
可能很多Java程序员对于Lambda编程还比较陌生,但其实这并不是什么新鲜的技术。许多现代高级编程语言在很早之前就开始支持Lambda
编程了,但是Java却直到JDK 1.8之后才加入了Lambda
编程的语法支持。因此,大量早期开发的Java和Android程序其实并未使用Lambda
编程的特性。
Kotlin从第一个版本开始就支持了Lambda
编程,并且Kotlin中的Lambda
功能极为强大,我甚至认为Lambda
才是Kotlin的灵魂所在。
4.1 集合的创建和遍历
集合的函数式API是用来入门Lambda
编程的绝佳示例,不过在此之前,我们得先学习创建集合的方式才行。
传统意义上的集合主要就是List
和Set
,再广泛一点的话,像Map
这样的键值对数据结构也可以包含进来。List
、Set
和Map
在Java中都是接口,List
的主要实现类是ArrayList
和LinkedList
,Set
的主要实现类是HashSet
,Map
的主要实现类是HashMap
,熟悉Java的人对这些集合的实现类一定不会陌生。
现在我们提出一个需求,创建一个包含许多水果名称的集合。如果是在Java中你会怎么实现?我们首先想到的是创建一个 ArrayList
实例,然后再将水果的名称一个个添加到集合中。当然啦,在 kotlin
中我们也可以这么做。
1 | val list = ArrayList<String>() |
但是这种初始化集合的方式比较烦琐,为此Kotlin专门提供了一个内置的listOf()
函数来简化初始化集合的写法,如下所示:
1 | val list = listof("Apple", "Banana", "Orange", "Pear", "Grape") |
可以看到,这里仅用一行代码就完成了集合的初始化操作。之前在学习循环语句时提到过:for-in
循环不仅可以用来遍历区间,还可以用来遍历集合。现在我们就尝试一下使用for-in
循环来遍历这个水果集合:
1 | fun main(){ |
不过需要注意的是,listOf()
函数创建的是一个不可变的集合。你也许不太能理解什么叫作不可变的集合,因为在Java中这个概念不太常见。不可变的集合指的就是该集合只能用于读取,我们无法对集合进行添加、修改或删除操作。
至于这么设计的理由,和val
关键字、类默认不可继承的设计初衷是类似的,可见Kotlin在不可变性方面控制得极其严格。那如果我们确实需要创建一个可变的集合呢?也很简单,使用mutableListOf()
函数就可以了,示例如下:
1 | fun main(){ |
前面我们介绍的都是List集合的用法,实际上Set
集合的用法几乎与此一模一样,只是将创建集合的方式换成了setOf()
和mutableSetOf()
函数而已。大致代码如下:
1 | fun main(){ |
⭐需要注意,Set
集合中是不可以存放重复元素的,如果存放了多个相同的元素,只会保留其中一份,这是和List
集合最大的不同之处。
最后再来看一下Map
集合的用法。Map是一种键值对形式的数据结构,因此在用法上和List
、Set
集合有较大的不同。传统的Map
用法是先创建一个HashMap
的实例,然后将一个个键值对数据添加到Map
中。比如这里我们给每种水果设置一个对应的编号,就可以这样写:
1 | fun main(){ |
使用第一种写法,是因为这种写法和Java语法是最相似的,因此可能最好理解。但其实在Kotlin中并不建议使用put()
和get()
方法来对Map
进行添加和读取数据操作,而是更加推荐使用一种类似于数组下标的语法结构,比如向Map
中添加一条数据就可以像第二种方法这么写:
1 | val hashMap = HashMap<String, Int>() |
而从Map中读取一条数据就可以这么写:
1 | val number = map["Apple"] |
当然,这仍然不是最简便的写法,因为Kotlin毫无疑问地提供了一对mapOf()
和mutableMapOf()
函数来继续简化Map
的用法。在mapOf()
函数中,我们可以直接传入初始化的键值对组合来完成对Map集合的创建,如下:
1 | val map1 = mapOf<String, Int>("Apple" to 1, "Banana" to 2, "Orange" to 3, "Pear" to 4, "Grape" to 5) |
这里的键值对组合看上去好像是使用to
这个关键字来进行关联的,但其实to
并不是关键字,而是一个infix
函数.
最后,遍历Map
集合中的数据也是使用 for-in
循环,如下:
1 | fun showMap(map: Map<String, Int>){ |
这段代码主要的区别在于,在for-in
循环中,我们将Map的键值对变量一起声明到了一对括号里面,这样当进行循环遍历时,每次遍历的结果就会赋值给这两个键值对变量,最后将它们的值打印出来。
4.2 集合的函数式API
集合的函数式API
有很多个,这里重点学习函数式API
的语法结构,也就是Lambda
表达式的语法结构。
首先实现一个需求:在一个水果集合里面找到单词最长的那个水果,我们可以这样写:
1 | val listLength = listOf<String>("Apple", "Banana", "Orange", "Pear", "Grape") |
上述代码使用的是打擂台的方法找出单词最长的那个水果,我们还可以使用集合的函数式API,这可以让我们的功能变的更加容易:
1 | val listLength = listOf<String>("Apple", "Banana", "Orange", "Pear", "Grape") |
上面的代码只用了一行就找出了单词最长的那个水果,是怎么做到的呢?一起来学习下!
首先来看一下Lambda
的定义,如果用最直白的语言来阐述的话,Lambda
就是一小段可以作为参数传递的代码。从定义上看,这个功能就很厉害了,因为正常情况下,我们向某个函数传参时只能传入变量,而借助Lambda
却允许传入一小段代码。这里两次使用了“一小段代码”这种描述,那么到底多少代码才算一小段代码呢?Kotlin对此并没有进行限制,但是通常不建议在Lambda
表达式中编写太长的代码,否则可能会影响代码的可读性。
Lambda
表达式的语法结构如下:
1 | {参数名1: 参数类型, 参数名2: 参数类型 -> 函数体} |
这是Lambda
表达式最完整的语法结构定义。首先最外层是一对大括号,如果有参数传入到Lambda
表达式中的话,我们还需要声明参数列表,参数列表的结尾使用一个->
符号,表示参数列表的结束以及函数体的开始,函数体中可以编写任意行代码(虽然不建议编写太长的代码),并且最后一行代码会自动作为Lambda
表达式的返回值。
当然,在很多情况下,我们并不需要使用Lambda
表达式完整的语法结构,而是有很多种简化的写法。还是回到刚才找出最长单词水果的需求,前面使用的函数式API的语法结构看上去好像很特殊,但其实maxBy
就是一个普通的函数而已,只不过它接收的是一个Lambda
类型的参数,并且会在遍历集合时将每次遍历的值作为参数传递给Lambda
表达式。maxBy
函数的工作原理是根据我们传入的条件来遍历集合,从而找到该条件下的最大值,比如说想要找到单词最长的水果,那么条件自然就应该是单词的长度了。
理解了maxBy函数的工作原理之后,我们就可以开始套用刚才学习的Lambda表达式的语法结构,并将它传入到maxBy函数中了,如下所示:
1 | val listLength = listOf<String>("Apple", "Banana", "Orange", "Pear", "Grape") |
可以看到,maxBy
函数实质上就是接收了一个Lambda
参数而已,并且这个Lambda
参数是完全按照刚才学习的表达式的语法结构来定义的,因此这段代码应该算是比较好懂的。
这种写法虽然可以正常工作,但是比较啰嗦,可简化的点也非常多,下面我们就开始对这段代码一步步进行简化。
首先,我们不需要专门定义一个lambda
变量,而是可以直接将lambda
表达式传入maxBy
函数当中,因此第一步简化如下所示:
1 | val listLength = listOf<String>("Apple", "Banana", "Orange", "Pear", "Grape") |
然后Kotlin规定,当Lambda
参数是函数的最后一个参数时,可以将Lambda
表达式移到函数括号的外面,如下所示:
1 | val listLength = listOf<String>("Apple", "Banana", "Orange", "Pear", "Grape") |
接下来,如果Lambda
参数是函数的唯一一个参数的话,还可以将函数的括号省略:
1 | val listLength = listOf<String>("Apple", "Banana", "Orange", "Pear", "Grape") |
由于Kotlin拥有出色的类型推导机制,Lambda
表达式中的参数列表其实在大多数情况下不必声明参数类型,因此代码可以进一步简化成:
1 | val listLength = listOf<String>("Apple", "Banana", "Orange", "Pear", "Grape") |
最后,当Lambda
表达式的参数列表中只有一个参数时,也不必声明参数名,而是可以使用it
关键字来代替,那么代码就变成了:
1 | val listLength = listOf<String>("Apple", "Banana", "Orange", "Pear", "Grape") |
怎么样?通过一步步推导的方式,我们就得到了和一开始那段函数式API一模一样的写法,是不是现在理解起来就非常轻松了呢?
接下来我们就再来学习几个集合中比较常用的函数式API,集合中的map
函数是最常用的一种函数式API,它用于将集合中的每个元素都映射成一个另外的值,映射的规则在Lambda
表达式中指定,最终生成一个新的集合。比如,这里我们希望让所有的水果名都变成大写模式,就可以这样写:
1 | fun main(){ |
map
函数的功能非常强大,它可以按照我们的需求对集合中的元素进行任意的映射转换,上面只是一个简单的示例而已。除此之外,你还可以将水果名全部转换成小写,或者是只取单词的首字母,甚至是转换成单词长度这样一个数字集合,只要在Lambda
表示式中编写你需要的逻辑即可。
我们再来学习另外一个比较常用的函数式API——filter
函数。顾名思义,filter
函数是用来过滤集合中的数据的,它可以单独使用,也可以配合刚才的map
函数一起使用。
1 | fun main(){ |
我们继续学习两个比较常用的函数式API——any
和all
函数。其中any
函数用于判断集合中是否至少存在一个元素满足指定条件,all
函数用于判断集合中是否所有元素都满足指定条件。由于这两个函数都很好理解,我们就直接通过代码示例学习了:
1 | fun main(){ |
这里还是在Lambda
表达式中将条件设置为5个字母以内的单词,那么any
函数就表示集合中是否存在5个字母以内的单词,而all
函数就表示集合中是否所有单词都在5个字母以内。
这样我们就将Lambda
表达式的语法结构和几个常用的函数式API的用法都学习完了,虽然集合中还有许多其他函数式API,但是只要掌握了基本的语法规则,其他函数式API的用法只要看一看文档就能掌握了.
4.3 Java函数式API的使用
现在我们已经学习了Kotlin中函数式API的用法,但实际上在Kotlin中调用Java方法时也可以使用函数式API,只不过这是有一定条件限制的。具体来讲,如果我们在Kotlin代码中调用了一个Java方法,并且该方法接收一个Java单抽象方法接口参数,就可以使用函数式API。Java单抽象方法接口指的是接口中只有一个待实现方法,如果接口中有多个待实现方法,则无法使用函数式API。
🌰 举个栗子:
Java原生API中有一个最为常见的单抽象方法接口——Runnable
接口。这个接口中只有一个待实现的run()
方法,定义如下:
1 | public interface Runnable { |
根据前面的讲解,对于任何一个Java方法,只要它接收Runnable
参数,就可以使用函数式API。那么什么Java方法接收了Runnable
参数呢?这就有很多了,不过Runnable
接口主要还是结合线程来一起使用的,因此这里我们就通过Java的线程类Thread
来学习一下。
Thread
类的构造方法中接收了一个Runnable
参数,我们可以使用如下Java代码创建并执行一个子线程:
1 | new Thread(new Runnable(){ |
⭐注意:这里使用了匿名类的写法,我们创建了一个Runnable
接口的匿名类实例,并将它传给了Thread
类的构造方法,最后调用Thread
类的start()
方法执行这个线程。
而如果将这段代码翻译成 Kotlin
版本,写法如下:
1 | Thread(object: Runnable { |
Kotlin中匿名类的写法和Java有一点区别,由于Kotlin完全舍弃了new
关键字,因此创建匿名类实例的时候就不能再使用new
了,而是改用了object
关键字。这种写法虽然算不上复杂,但是相比于Java的匿名类写法,并没有什么简化之处。
但是别忘了,目前Thread
类的构造方法是符合Java函数式API的使用条件的,下面我们就看看如何对代码进行精简,如下所示:
1 | Thread(Runnable { |
这段代码明显简化了很多,既可以实现同样的功能,又不会造成任何歧义。因为Runnable
类中只有一个待实现方法,即使这里没有显式地重写run()
方法,Kotlin也能自动明白Runnable
后面的Lambda
表达式就是要在run()
方法中实现的内容。
另外,如果一个Java方法的参数列表中有且仅有一个Java单抽象方法接口参数,我们还可以将接口名进行省略,这样代码就变得更加精简了:
1 | Thread({ |
不过到这里还没有结束,和之前Kotlin中函数式API的用法类似,当Lambda
表达式是方法的最后一个参数时,可以将Lambda
表达式移到方法括号的外面。同时,如果Lambda
表达式还是方法的唯一一个参数,还可以将方法的括号省略,最终简化结果如下:
1 | Thread{ |
五、空指针检查
某国外机构做了一个统计,Android系统上崩溃率最高的异常类型就是空指针异常(NullPointerException)。相信不只是Android,其他系统上也面临着相同的问题。若要分析其根本原因的话,我觉得主要是因为空指针是一种不受编程语言检查的运行时异常,只能由程序员主动通过逻辑判断来避免,但即使是最出色的程序员,也不可能将所有潜在的空指针异常全部考虑到。
先来看一段代码:
1 | public void doStudy(Study study){ |
这段代码安全吗?不一定,因为这要取决于调用方传入的参数是什么,如果我们向doStudy()
方法传入了一个null
参数,那么毫无疑问这里就会发生空指针异常。因此,更加稳妥的做法是在调用参数的方法之前先进行一个判空处理,如下所示:
1 | public void doStudy(Study study){ |
这样就能保证不管传入的参数是什么,这段代码始终都是安全的。
由此可以看出,即使是如此简单的一小段代码,都有产生空指针异常的潜在风险,那么在一个大型项目中,想要完全规避空指针异常几乎是不可能的事情,这也是它高居各类崩溃排行榜首位的原因。
5.1 可空类型系统
然而,Kotlin却非常科学地解决了这个问题,它利用编译时判空检查的机制几乎杜绝了空指针异常。虽然编译时判空检查的机制有时候会导致代码变得比较难写,但是不用担心,Kotlin提供了一系列的辅助工具,让我们能轻松地处理各种判空情况。
是回到刚才的doStudy()
函数,现在将这个函数再翻译回Kotlin版本,代码如下所示:
1 | fun doStudy(study: Study){ |
这段代码看上去和刚才的Java版本并没有什么区别,但实际上它是没有空指针风险的,因为Kotlin默认所有的参数和变量都不可为空,所以这里传入的Study
参数也一定不会为空,我们可以放心地调用它的任何函数。如果你尝试向doStudy()
函数传入一个null参数,则它会报错: Null can not be a value of a non-null type Study
也就是说,Kotlin将空指针异常的检查提前到了编译时期,如果我们的程序存在空指针异常的风险,那么在编译的时候会直接报错,修正之后才能成功运行,这样就可以保证程序在运行时期不会出现空指针异常了。
那如果我们的业务逻辑就是需要某个参数或者变量为空该怎么办呢?不用担心,Kotlin提供了另外一套可为空的类型系统,只不过在使用可为空的类型系统时,我们需要在编译时期就将所有潜在的空指针异常都处理掉,否则代码将无法编译通过。
那么可为空的类型系统是什么样的呢?很简单,就是在类名的后面加上一个问号。比如,Int表示不可为空的整型,而Int?就表示可为空的整型;String表示不可为空的字符串,而String?就表示可为空的字符串。
回到刚才的doStudy()
函数,如果我们希望传入的参数可以为空,那么就应该将参数的类型由Study
改成Study?
1 | fun doStudy(study: Study?){ |
可以看到,现在在调用doStudy()
函数时传入null
参数,就不会再提示错误了。但是,在doStudy()
函数中调用参数的readBooks()
和doHomework()
方法时,却出现了一个红色下滑线的错误提示,这又是为什么呢?
由于我们将参数改成了可为空的Study?
类型,此时调用参数的readBooks()
和doHomework()
方法都可能造成空指针异常,因此Kotlin在这种情况下不允许编译通过。我们只需要把空指针异常都处理掉就可以了,比如做个判断处理,如下所示:
1 | fun doStudy(study: Study?){ |
现在代码就可以正常编译通过了,并且还能保证完全不会出现空指针异常。
为了在编译时期就处理掉所有的空指针异常,通常需要编写很多额外的检查代码才行。如果每处检查代码都使用if
判断语句,则会让代码变得比较啰嗦,而且if
判断语句还处理不了全局变量的判空问题。为此,Kotlin专门提供了一系列的辅助工具,使开发者能够更轻松地进行判空处理.接下来一一学习!
5.2 判空辅助工具
- 首先学习最常用的
?.
操作符。这个操作符的作用非常好理解,就是当对象不为空时正常调用相应的方法,当对象为空时则什么都不做。比如以下的判空处理代码:
1 | if (a != null){ |
这段代码使用 ?.
操作符就可以简化为:
1 | a?.doSomething() |
了解了 ?.
操作符后 doStudy()
函数就可以优化成:
1 | for doStudy(study: Study?){ |
可以看到,这样我们就借助?.
操作符将if
判断语句去掉了。可能你会觉得使用if
语句来进行判空处理也没什么复杂的,那是因为目前的代码还非常简单,当以后我们开发的功能越来越复杂,需要判空的对象也越来越多的时候,你就会觉得?.
操作符特别好用了。
- 接下来再来学习另外一个非常常用的
?:
操作符。这个操作符的左右两边都接收一个表达式,如果左边表达式的结果不为空就返回左边表达式的结果,否则就返回右边表达式的结果。这个操作符和三目运算符a ? b : c
类似但又有差异.观察如下代码:
1 | val c = if(a != null){ |
这段代码使用了 ?:
操作符就可以简化成:
1 | val c = a ?: b |
接下来通过一个具体的例子来结合使用 ?.
和 ?:
这两个操作符,从而加深理解。
比如我们现在编写一个函数用来获得一段文本的长度,传统写法如下:
1 | fun getTextLength(text: String?): Int{ |
由于文本是可能为空的,因此我们需要先进行一次判空操作,如果文本不为空就返回它的长度,如果文本为空就返回0。
这段代码看上去也并不复杂,但是我们却可以借助操作符让它变得更加简单,如下所示:
1 | fun getTextLength(text: String?) = text?.length ?: 0 |
这里我们将?.
和?:
操作符结合到了一起使用,首先由于text
是可能为空的,因此我们在调用它的length
字段时需要使用?.
操作符,而当text
为空时,text?.length
会返回一个null
值,这个时候我们再借助?:
操作符让它返回0
。
不过Kotlin的空指针检查机制也并非总是那么智能,有的时候我们可能从逻辑上已经将空指针异常处理了,但是Kotlin的编译器并不知道,这个时候它还是会编译失败.
1 | var content: String? = "hello" |
这里我们定义了一个可为空的全局变量content
,然后在main()
函数里先进行一次判空操作,当content
不为空的时候才会调用printUpperCase()
函数,在printUpperCase()
函数里,我们将content
转换为大写模式,最后打印出来。
看上去好像逻辑没什么问题,但是很遗憾,这段代码一定是无法运行的。因为printUpperCase()
函数并不知道外部已经对content
变量进行了非空检查,在调用toUpperCase()
方法时,还认为这里存在空指针风险,从而无法编译通过。
在这种情况下,如果我们想要强行通过编译,可以使用非空断言工具,写法是在对象的后面加上!!
,如下所示:
1 | fun printUpperCase(){ |
这种写法就是在告知 Kotlin,我非常确信这里的对象不会为空,所以不用你来帮我做空指针检查了,如果出现问题,你可以直接抛出空指针异常,后果由我自己承担。虽然这样编写代码确实可以通过编译,但是当你想要使用非空断言工具的时候,最好提醒一下自己,是不是还有更好的实现方式。你最自信这个对象不会为空的时候,其实可能就是一个潜在空指针异常发生的时候。
- 最后我们再来学习一个比较与众不同的辅助工具 —
let
。let
既不是操作符,也不是什么关键字,而是一个函数。这个函数提供了函数式的 API的编程接口,并将原始调用对象作为参数传递到Lambda
表达式中。示例代码如下:
1 | obj.let { obj2 -> |
可以看到,这里调用了obj
对象的let
函数,然后Lambda
表达式中的代码就会立即执行,并且这个obj
对象本身还会作为参数传递到Lambda
表达式中。不过,为了防止变量重名,这里我将参数名改成了obj2
,但实际上它们是同一个对象,这就是let
函数的作用。
let
函数属于Kotlin中的标准函数,那这个let
函数和空指针检查有什么关系呢?其实let
函数的特性配合?.
操作符可以在空指针检查的时候起到很大的作用。
就回到上面的 doStudy()
函数当中,目前代码如下所示:
1 | for doStudy(study: Study?){ |
虽然这段代码我们通过?.
操作符优化之后可以正常编译通过,但其实这种表达方式是有点啰嗦的,如果将这段代码准确翻译成使用if
判断语句的写法,对应的代码如下:
1 | for doStudy(study: Study?){ |
也就是说,本来我们进行一次if
判断就能随意调用study
对象的任何方法,但受制于?.
操作符的限制,现在变成了每次调用study
对象的方法时都要进行一次if
判断。
这个时候就可以结合使用?.
操作符和let
函数来对代码进行优化了,如下所示:
1 | fun doStudy(study: Study?){ |
上述代码的意思是,?.
操作符表示对象为空时什么都不做,对象不为空时就调用let
函数,而let
函数会将study
对象本身作为参数传递到Lambda
表达式中,此时的study
对象肯定不为空了,我们就能放心地调用它的任意方法了。
外还记得Lambda
表达式的语法特性吗?当Lambda
表达式的参数列表中只有一个参数时,可以不用声明参数名,直接使用it
关键字来代替即可,那么代码就可以进一步简化成:
1 | fun doStudy(study: Study?){ |
let
函数是可以处理全局变量的判空问题的,而if
判断语句则无法做到这一点。比如我们将doStudy()
函数中的参数变成一个全局变量,使用let
函数仍然可以正常工作,但使用 if
判断句则会提示错误。
六、Kotlin中的小魔术
其实就是一些Kotlin小技巧啦~
6.1 字符串内嵌表达式
Kotlin
从一开始就支持了字符串内嵌表达式的功能,可以直接将表达式写在字符串里面,即使是构建非常复杂的字符串,也会变得轻而易举。
首先来看一下Kotlin
中字符串内嵌表达式的语法规则:
1 | "hello, ${obj.name}. nice to meet u" |
可以看到,Kotlin允许我们在字符串里嵌入${}
这种语法结构的表达式,并在运行时使用表达式执行的结果替代这一部分内容。另外,当表达式中仅有一个变量的时候,还可以将两边的大括号省略,如下所示:
1 | "hello $name . nice to meet u" |
6.2 函数的参数默认值
其实之前在学习次构造函数用法的时候我就提到过,次构造函数在Kotlin中很少用,因为Kotlin提供了给函数设定参数默认值的功能,它在很大程度上能够替代次构造函数的作用。
具体来讲,我们可以在定义函数的时候给任意参数设定一个默认值,这样当调用此函数时就不会强制要求调用方为此参数传值,在没有传值的情况下会自动使用参数的默认值。
给参数设定默认值的方式也很简单,观察如下代码:
1 | fun printParams(num: Int, str: String = "hello"){ |
可以看到,这里我们给printParams()
函数的第二个参数设定了一个默认值,这样当调用printParams()
函数时,可以选择给第二个参数传值,也可以选择不传,在不传的情况下就会自动使用默认值。
而如果我们想要 printParams()
中的 num
参数使用默认值应该怎么写呢?
1 | fun printParams(num: Int = 100, str: String){ |
函数像上面这样写是没有问题的,那么我们该如何调用呢?模仿刚才的写法肯定是行不通的,因为编译器会认为我们想把字符串赋值给第一个num
参数,从而报类型不匹配的错误。
不过不用担心,Kotlin提供了另外一种神奇的机制,就是可以通过键值对的方式来传参,从而不必像传统写法那样按照参数定义的顺序来传参。比如调用printParams()
函数,我们还可以这样写:
1 | printParams(str = "world", num = 123) |
此时哪个参数在前哪个参数在后都无所谓,Kotlin可以准确地将参数匹配上。而使用这种键值对的传参方式之后,我们就可以省略num
参数了.
1 | fun printParams(num: Int = 100, str: String){ |
现在我们已经掌握了如何给函数设定参数默认值,那么为什么说这个功能可以在很大程度上替代次构造函数的作用呢?
回忆一下当初我们学习次构造函数时所编写的代码:
1 | class Student(val sno: String, val grade: Int, name: String, age: Int){ |
上述代码中有一个主构造函数和两个次构造函数,次构造函数在这里的作用是提供了使用更少参数来对Student类进行实例化的方式。无参的次构造函数会调用两个参数的次构造函数,并将这两个参数赋值成初始值。两个参数的次构造函数会调用4个参数的主构造函数,并将缺失的两个参数也赋值成初始值。
这种写法在Kotlin中其实是不必要的,因为我们完全可以通过只编写一个主构造函数,然后给参数设定默认值的方式来实现,代码如下所示:
1 | class Student(val sno: String = "", val grade: Int = 0, name: String = "", age: Int = 0) |