类与对象&包&访问权限
- 类的定义
- 类的概念
- 定义和使用类
- 对象的初始化
- 类的成员函数
- 再谈基本类型
- 运算符重载函数
- 中缀函数
- 空值和空类型
- 解构
- 包和导入
- 访问权限控制
- 内部类
类的定义
在之前,我们一直在使用顶层定义:
在Kotlin中,顶层定义(Top-level Definitions)是指在Kotlin文件的顶层直接声明的代码元素,这些元素不嵌套在任何类或函数内部。
val a = 20 //直接在kt文件中定义变量fun message() { //直接在kt文件中定义函数println("我是测试方法")
}
而学习了类之后,这些内容也可以定义到类中,作为类的属性存在。
类的概念
类的概念我们在生活中其实已经听说过很多了。
人类、鸟类、鱼类… 所谓类,就是对一类事物的描述,是抽象的、概念上的定义,比如鸟类,就泛指所有具有鸟类特征的动物。比如人类,不同的人,有着不同的性格、不同的爱好、不同的样貌等等,但是他们根本上都是人,所以说可以将他们抽象描述为人类。
对象是某一类事物实际存在的每个个体,因而也被称为实例(instance)我们每个人都是人类的一个实际存在的个体。
所以说,类就是抽象概念的人,而对象,就是具体的某一个人。
- A:是谁拿走了我的手机?
- B:是个人。(某一个类型)
- A:我还知道是个人呢,具体是谁呢?
- B:是XXX。(具体某个对象)
而在Kotlin中,也可以像这样进行编程,我们可以定义一个类,然后进一步创建许多这个类的实例对象,像这种编程方式,我们称为面向对象编程,我们除了去使用Kotlin给我们提供的类型之外,我们也可以使用自己定义的类。
定义和使用类
Kotlin中的类使用关键字class
声明,我们可以直接在默认的Main.kt文件中编写:
class Student {//在没有任何内容时,花括号可以省略
}
我们在对类进行命名时,一般使用英文单词,并且首字母大写,跟变量命名一样,不能出现任何的特殊字符(除非使用了反引号)。
除了直接在某个.kt文件中直接编写之外,为了规范,我们一般将一个类单独创建一个文件,我们可以右键src
目录;选择新建,然后选择Kotlin类/文件选项,然后创建一个类;文件创建完成后,默认也会为我们生成类的定义,并且类名称与创建的类文件是一模一样的。
这是一个非常简单的类,但是肯定远远不够。
既然是学生类,那么肯定有学生相关的一些属性,比如名字、性别、年龄等等,那么怎么才能给这个类添加一些属性呢?我们需要指定类的构造函数,构造函数也是函数的一种,但是它是专用于对象的创建,Kotlin中的类可以添加一个主构造函数和一个或多个次要构造函数。主构造函数是类定义的一部分,像下面这样编写:
class Student constructor(name: String, age: Int) {//比如学生有name和age属性,那么我们可以在类名后面constructor的括号中编写,并用逗号隔开//这里跟定义变量差不多,也是变量名称:类型,这些作为类的成员属性,后续可以在类中使用
}
如果主构造函数没有任何注释或可见性修饰符,则可以省略constructor
关键字,如果类中没有其他内容要写,可以直接省略花括号,最后就变成这样了:
class Student(name: String, age: Int)
但是,这里仅仅是定义了构造函数的参数,这还不是类的属性,那么我们要怎么才能定义为类的属性呢?我们可以为这些属性添加var
或val
关键字来表示这个属性是可变还是不变的:
class Student(var name: String, val age: Int)
这跟我们之前使用变量基本一致:
val
:不可变属性var
:可变属性
这样才算是定义了类的属性,我们也可以给这些属性设置初始值:
class Student(var name: String, val age: Int = 18) //默认每个学生18岁
除了将属性添加到构造函数中,我们也可以将这些属性直接作为类的成员变量写到类中,但是这种情况必须要配一个默认值,否则无法通过编译:
class Student {var name: String = "" //必须配一个默认值var age: Int = 0
}
这样我们就可以不编写主构造函数也能定义属性,但是这里仍然会隐式生成一个无参的构造函数,为了构造函数能够方便地传值初始化,也可以像这样写:
class Student(name: String, age: Int) {var name: String = name //通过构造函数传递过来var age: Int = age
}
当然,如果各位不希望这些属性在一开始就有初始值,而是之后某一个时刻去设定初始值,我们也可以为其添加懒加载:
class Student {lateinit var name: String //懒加载的属性可以不用在一开始赋值,但是在下一次使用之前一定要先完成赋值,否则报错var age: Int = 0
}
并且,像这样编写的类成员变量,也可以自定义对应的getter和setter属性:
class Shape(var width: Int, var height: Int) {val area get() = width * height
}
lateinit
关键字用于表示一个非空类型的变量,其初始化值会在稍后的某个时刻被赋值。然而,
lateinit
关键字不能用于基本类型,因为它的值已经明确定义了(不是非空类型),如果你想在 Kotlin 中延迟初始化一个基本类型的变量,你可以考虑使用可空类型(如Int?
)并将其初始化为null
。然后,你可以在稍后的某个时刻将值赋给这个变量。这样做与使用lateinit
关键字类似,但适用于基本类型。
那么,现在我们定义了主构造函数之后,该怎么去使用它呢?
跟我们调用普通函数一样,这里的函数名称就是类的名称,如果一个类没有编写构造函数,那么这个类默认情况下使用一个无参构造函数创建:
fun main() {//我们可以直接使用 类名() 的形式创建对象Student()
}
如果是有构造函数的类,我们只需要填写需要的参数即可,调用之后,类的属性就是这里我们给进去的参数了:
fun main() {//我们可以直接使用 类名(参数, 参数...) 的形式创建Student("小明", 18)
}
这样,我们就成功创建出了一个名字为小明的学生类型对象,但是这个对象仅仅是创建出来还不行,我们肯定需要去使用它。
实际上,我们可以像之前使用基本类型一样,使用对象,我们也可以使用一个变量去接收生成出来的对象:
fun main() {//使用Student类型的变量接收构造方法得到的对象var stu: Student = Student("小明", 18)
}
有一个我们需要注意的点,这里的stu存放的是对象的引用,而不是本体,我们可以通过对象的引用来间接操作对象。
fun main() {val p1 = Student("小明", 18)val p2 = p1
}
这里,我们将变量p2赋值为p1的值,那么实际上只是传递了对象的引用,而不是对象本身的复制,这跟我们前面的基本数据类型有些不同,p2和p1都指向的是同一个对象(如果你学习过C语言,它就类似于指针一样的存在)
我们可以来测试一下:
fun main() {val s1 = Student("小明", 18)val s2 = s1println(s1 === s2) //使用 === 可以判断两个变量引用的是不是同一个对象
}
但是如果我们像这样去编写:
fun main() {val s1 = Student("小明", 18)val s2 = Student("小明", 18) //即使名字和年龄一样,但是由于这里重新创建了一次对象println(s1 === s2) //这里比较的就不是同一个对象了
}
我们可以使用.
运算符来访问对象的属性,比如我们要访问小明这个学生对象的属性:
fun main() {val stu = Student("小明", 18)println("对象的name = ${stu.name}, age = ${stu.age}")
}
获取和修改都是可以的:
fun main() {val stu = Student("小明", 18)stu.name = "大明"stu.age = 10 //由于age属性是val,所以说无法修改,只能读取
}
注意,不同对象的属性是分开独立存放的,虽然都是统一由类完成定义,但是每个对象都有一个自己的空间,修改一个对象的属性并不会影响到另一个相同类型的对象:
fun main() {val stu1 = Student("小明", 18)val stu2 = Student("小明", 18)stu1.name = "小红"println("${stu1.name}, ${stu2.name}")
}
除了直接使用主构造函数创建对象外,我们也可以添加一些次要构造函数,比如我们的学生可以只需要一个名字就能完成创建,我们可以直接在类中编写一个次要构造函数:
class Student(var name: String, val age: Int) {constructor(name: String) : this(name, 18)
}
如果该类有一个主构造函数,则每个次要构造函数需要通过另一个次要构造函数直接或间接委托给主构造函数。委托到同一类的另一个构造函数是this
关键字完成的:
class Student(var name: String, val age: Int) {//这里可以使用constructor关键字继续声明次要构造函数//次要构造函数中的参数仅仅是表示传入的参数,不能像主构造函数那样定义属性//这里的this表示是当前这个类,this()就是调用当前类的构造函数constructor(name: String) : this(name, 18) //这里其实是调用主构造函数,并且参数只有name,年龄直接给个默认值18
}
如果一个类没有主构造函数,那么我们也可以直接在在类中编写次要构造函数,但是不需要主动委托一次主构造函数,他这里会隐式包含,所以说我们直接写就行了:
class Student {constructor(name: String) //注意,这里的参数不是类属性,仅仅是一个形参!
}
次要构造函数和主构造函数一样,都可以用于对象的创建:
fun main() {val stu1 = Student("小明", 18)val stu2 = Student("小红")
}
并且次要构造函数可以编写自定义的函数体:
open class Student {constructor(str: String) { //在使用辅助构造函数初始化对象时,会执行里面的内容println("我的名字是: $str")}
}
因此,主构造函数相比次要(辅助)构造函数:
- **主构造函数:**可以直接在主构造函数中定义类属性,使用更方便,但是主构造函数只能存在一个,并且无法编写函数体,只有为类属性做初始化赋值的效果。
- **辅助(次要)构造函数:**可以存在多个,并且可以自定义函数体,但是无法像主构造函数那样定义类属性,并且当类具有主构造函数时,所有次要构造函数必须直接或间接地调用主构造函数。
Kotlin语言本身比较灵活,类中并不是一定需要主构造函数,全部写辅助构造函数也是可以的,但是再怎么都得有构造函数。
对象的初始化
在对象创建时,我们可能需要做一些初始化工作,我们可以使用初始化代码块来完成,初始化代码块使用init关键字来完成。假如我们希望对象在创建的时候,如果年龄不足18岁,那么就设定为18岁:
class Student(var name: String, var age: Int) { //由于主构造函数无法编写函数体//因此我们可以在init的花括号中编写初始化代码//注意这段初始化代码块,是在上面的类属性被赋值之后才执行的,所以说能拿到已经赋值的age属性init {println("我是初始化操作")if(age < 18) age = 18println("初始化操作结束")}
}
这样,我们在创建对象的时候,就会在创建的时候自动执行初始化代码块里面的代码:
fun main() {val stu = Student("小明", 15)println(stu.age)
}
初始化操作不仅仅可以有一个,也可以有很多个:
class Student {//注意,多个初始化操作时,从上往下按顺序执行init {println("我是一号初始化操作")}init {println("我是二号初始化操作")}
}
对于将成员属性写到类中的情况,同样是按照顺序向下执行,比如:
因为成员变量a是在初始化代码块的后面才初始化的,这里会报错。
如果一个类具有次要构造函数,那么我们也可以直接在次要构造函数中编写一些初始化代码:
class Student(var name: String, var age: Int) {constructor(name: String) : this(name, 18) {println("我是次要构造函数中的语句")}
}
当我们使用对应的次要构造函数时,就会执行次要构造函数中的初始化代码了。
这里需要注意一下,次要构造函数实际上需要先执行主构造函数,而在执行主构造函数时,会优先将之前我们讲解的初始化代码块执行,比如下面的代码:
class Student(var name: String, var age: Int) {init {println("我是初始化代码块")}constructor(name: String) : this(name, 18) {println("我是次要构造函数")}
}
无论是有主构造函数还是没有主构造函数(会生成一个默认的无参构造函数)都会先执行。
类的成员函数
现在我们的类有了属性,我们可以为创建的这些对象设定不同的属性值,比如每个人的名字都不一样,性别不一样,年龄不一样等等。只不过光有属性还不行,对象还需要具有一定的行为,就像我们人可以行走,可以跳跃,可以思考一样。
而对象也可以做出一些行为,我们可以通过定义函数来实现,类的函数和我们之前编写的函数有一些区别,它是属于这个类的,我们之前使用的函数都是直接编写在Kt文件中,它们都是顶级函数。
class Student(var name: String, var age: Int) {//这个函用于跟大家打招呼fun hello(){println("大家好啊")}
}
要使用类的成员函数,我们只能通过对象来进行调用:
fun main() {val stu = Student("小明", 18)//调用类中的成员方法,同样使用.运算符即可stu.hello() //让小明这个对象给大家打招呼
}
是不是稍微有一些体会了?好像真的是我们在让对象执行一个动作一样。在类的成员函数中,我们可以直接访问当前类对象中的一些属性,比如我们这里的用户名和年龄:
class Student(var name: String, var age: Int) {fun hello(){println("大家好啊,我叫$name,今年${age}岁了")}
}
注意,这里我们访问的name和age属性,是当前这个对象的name和age属性。比如:
fun main() {val stu = Student("小明", 18)stu.hello() //让小明这个对象给大家打招呼val stu2 = Student("小红", 17)stu2.hello() //让小红这个对象给大家打招呼
}
注意,下面这种情况,我们需要特殊处理:
class Student(var name: String, var age: Int) {//此时函数的参数也有一个name变量,而类的成员也有一个name属性fun hello(name: String){//这里得到的name是哪一个?println("大家好啊,我叫$name,今年${age}岁了")}
}
如果函数中的变量存在歧义,那么优先使用作用域最近的一个,比如函数形参的name作用域更近,那么这里的name拿到的一个是形参name,而不是类的成员属性name。
如果我们需要获取的是类中的成员属性,需要使用this
关键字来表示当前类:
fun hello(name: String){//使用this关键字表示当前对象,这样就可以指定这里是类中的this了println("大家好啊,我叫${this.name},今年${age}岁了")
}
默认情况下,如果作用域不冲突,使用类中属性this
可以省略。
在类中,我们同样可以定义多个同名但不同参数的函数实现重载:
class Student(private var name: String, private var age: Int) {fun hello() = println("大家好啊,我叫${this.name},今年${age}岁了")fun hello(gender: String) = println("大家好啊,我叫${this.name},今年${age}岁了,性别${gender}")
}
实际上类中的函数使用起来跟我们之前定义的大差不差,只不过多了更多用法而已。
再谈基本类型
在Kotlin中,万物皆为对象,实际上我们在之前学习的全部基本类型,都是官方为我们提供的类。
现在我们学习了类与对象的知识,就可以来重新认识一下这些基本类型,实际上这些基本类型同样是类,也具有一些属性,以及一些类中的成员函数。实际上在上一章中,我们就已经开始使用类和对象了,我们对这些基本类型的操作同样是在操作对象:
fun main() {var a = 10 //这里其实是一个Int类型的对象,值为10,而a持有的是对这个Int对象的引用var b = a //这里的b复制了对上面Int类型对象的引用
}
特别说明: 在Kotlin中,虽然编码时万物皆对象,但是在最终编译时,会根据上下文进行优化性能,大部分情况下会优先编译为Java原生基本数据类型(不是对象)而另一部分情况下才会编译为Java中的Integer包装类型。因此很容易出现以下迷惑行为:
val a: Int = 12345 val b: Int = 12345 println(a === b) //true
val a: Int? = 12345 val b: Int? = 12345 println(a === b) //false
各位小伙伴可以在完整学习Java和后续Kotlin内容之后再来探究这个问题。
注:由于优化,-128-127用===判断也是true
既然这些基本类型也是类,那么肯定同样具有成员属性和成员函数,我们可以使用这些成员方法方便我们的项目开发,比如我们之前遇到的一个很麻烦的问题,不同类型的数无法相互转换:
这些时候可能我们需要将对应类型的数据转换为其他类型,那么该怎么办呢,实际上,在这些基本类型中都提供了对应类型转换成员函数,这里我们可以使用toInt
来直接将Double类型的数据转换为Int类型:
fun main() {var a: Int = 1.25.toInt() //使用类中的类型转换函数
}
这样就可以编译通过了。同样的,每个基本类型都有对应的类型转换函数,而且非常全面,比如Int类型:
有了这些成员函数,就大幅度方便了我们的类型转换,再比如我们常见的String类型,也有很多函数可以使用:
fun main() {val a = "HelloWorld"//使用lowercase和uppercase可以快速将字符串中的字母进行大小写转换println(a.lowercase())println(a.uppercase())
}
不过需要注意的是,我们在前面就说过,字符串一旦创建就是不可变的,因此,字符串中所有的函数得到的新字符串,都是重新创建的一个新的对象,而不是在原本的字符串上进行修改。
我们继续来看看一些有意思的函数,比如我们想批量替换字符串中的某些内容:
fun main() {val a = "Hello World!"println(a.replace("o", "a"))
}
将字符串中所有的字母o
替换为a
,直接使用replace函数就能直接生成替换之后的字符串了。又比如我们要判断某个字符串是否以指定文本开头:
fun main() {val a = "Hello World!"println(a.startsWith("Hel"))
}
可以看到这里经过判断得到了一个Boolean类型的结果,还有很多用于判断字符串是否为空、是否有空格等等的函数:
fun main() {val a = "Hello World!"a.isBlank()a.isEmpty()
}
我们还发现,这些基本类型中有一些比较特殊的函数,比如plus
函数。
public operator fun plus(other: Long): Long
这个函数添加了一个operator
关键字,这个是什么呢?这其实是运算符重载,能够自定义运算符实现的功能,我们之前使用这些数字进行运算,比如加减乘除,实际上都是这些基本类型在类中重载了运算符实现的,下一部分,我们就来介绍一下运算符重载函数。
运算符重载函数
Kotlin支持为程序中已知的运算符集提供自定义实现,这些运算符具有固定的符号表示(如+
或*
)以及对应的优先级,要实现运算符重载,请为相应类型提供具有对应运算符指定名称的成员函数,而当前的类对象,则直接作为对应运算符左边的操作数,如果是一元运算符(比如++自增运算符,只需要本身)则直接作为操作数参与运算。
比如,现在我们想要为我们自定义的类型支持加法运算:
我们可以直接在类定义中添加一个固定名称(名称是预设好的,不能自己想写什么写什么)的函数,这里的加法运算就是plus
函数,我们直接开始编写就可以了:
class Student(var name: String, var age: Int) {//注意,二元运算符必须带一个形参,表示右侧的操作数,返回值为计算出来的结果//形参和结果可以是任意类型,我们还可以提供多次编写同名的运算符重载函数来适配不同的类型operator fun plus(another: Student): Student{//比如这里我们希望两个学生对象相加,得到的结果为名字相加,年龄相加的一个新学生return Student(this.name + another.name, this.age + another.age)}
}
这样,我们就成功重载了加法运算符,可以直接上手使用:
fun main() {val a = Student("小米", 18)val b = Student("华为", 19)val c = a + bprintln("运算之后得到的新学生,名称:${c.name},年龄:${c.age}")
}
是不是感觉很简单?只需要将我们需要的对应运算符直接重载,编写好对应的计算规则,就可以直接使用对应的运算符进行计算。
我们也可以试试看重载一些一元运算符,比如取反运算符:
class Student(var name: String, var age: Int) {//比如取反操作就是把当前学生的名字反过来operator fun not() : Student {this.name = this.name.reversed()//这里可以直接在当前对象上进行操作,然后返回当前对象return this}
}
我们来尝试使用一下:
fun main() {//直接在这里使用!运算符val a = !Student("小米", 18)println("运算之后得到的新学生,名称:${a.name},年龄:${a.age}")
}
最后,我们列出常见的一些运算符对应的函数名称,首先是一元运算符:
符号 | 对应的函数名称 |
---|---|
+a | a.unaryPlus() |
-a | a.unaryMinus() |
!a | a.not() |
a-- | a.dec() +见下文 |
a++ | a.inc() +见下文 |
其中inc()
和dec()
函数比较特殊,它们必须返回一个值,该值将分配给使用++
或--
操作的变量,而不是改变执行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 until 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
,这种情况可能会与上面的基本操作产生歧义,比如下面的情况:
class Student(var name: String, var age: Int) {//同时定义plus和plusAssignoperator fun plus(another: Student) : Student {return this}operator fun plusAssign(another: Student) : Unit{}
}
可以看到,上面的函数中,plus
运算符在重载之后,运算结果与当前类型是相同的,这种情况下,就会出现一个问题:
- plus: 算式 a = a + b 可以成立,因为返回类型相同,可以重新赋值给a
- plusAssign:为算式 a = a + b 的缩写,与plus的功能完全一致
此时,两个函数都匹配这里的运算符使用,编译器不知道该用哪一个了,因此就会出现歧义:
比较运算符只需要实现一个函数即可:
运算符 | 对应的函数名称 |
---|---|
a > b | a.compareTo(b) > 0 |
a < b | a.compareTo(b) < 0 |
a >= b | a.compareTo(b) >= 0 |
a <= b | a.compareTo(b) <= 0 |
所有比较都会转换为compareTo
函数调用,此函数返回Int
值,这个值用于判断是否满足条件。
Kotlin非常强大,甚至连小括号都能重载:
运算符 | 对应的函数名称 |
---|---|
a() | a.invoke() |
a(i) | a.invoke(i) |
a(i, j) | a.invoke(i, j) |
a(i_1, ..., i_n) | a.invoke(i_1, ..., i_n) |
直接使用变量名称+()
来进行使用,感觉很像函数的调用,但是又不是,就很奇怪,不过确实很强大就是了。
还有一些运算符,以我们目前所学知识还无法进行讲解,后续在各位小伙伴学习之后,可以回顾一下:
运算符 | 对应的函数名称 |
---|---|
a[i] | a.get(i) |
a[i, j] | a.get(i, j) |
a[i_1, ..., i_n] | a.get(i_1, ..., i_n) |
a[i] = b | a.set(i, b) |
a[i, j] = b | a.set(i, j, b) |
a[i_1, ..., i_n] = b | a.set(i_1, ..., i_n, b) |
这是索引访问运算符,使用方括号进行表示。
中缀函数
实际上中缀函数在我们之前很多时候都有出现,比如位运算:
println(i shl 1)
这里的shl
并不是一个运算符,而是一段自定义的英文单词,像这种运算符是怎么做到的呢?
这其实是中缀函数,用infix
关键字标记的函数被称为中缀函数,在使用时,可以省略调用的点和括号进行调用,Infix函数必须满足以下要求:
- 必须是成员函数。
- 只能有一个参数。
- 参数不能有默认值。
我们可以像下面这样编写:
class Student(var name: String, var age: Int) {//这个中缀函数实现了将给定字符串与当前对象的名字拼接并返回infix fun test(string: String) : String{return name + string}
}
我们在使用时,也非常方便,真的就像在使用一个运算符一样:
fun main() {val student = Student("小明", 18)println(student test "我爱你")
}
得到的结果显而易见:
当然,我们也可以把它当做一个普通的函数进行调用,效果是完全等价的:
fun main() {val student = Student("小明", 18)println(student.test("崴泥"))
}
这里需要注意一下:
中缀函数调用的优先级低于算术运算符、类型转换和
rangeTo
运算符,例如以下表达式就是等效的:
1 shl 2 + 3
相当于1 shl (2 + 3)
0 until n * 2
相当于0 until (n * 2)
xs union ys as Set<*>
相当于xs union (ys as Set<*>)
(类型转换会在下一章多态进行介绍)另一方面,infix函数调用的优先级高于布尔运算符
&&
和||
、is
-和in
-checks以及其他一些运算符的优先级。这些表达式也是等价的:
a && b xor c
相当于a && (b xor c)
a xor b in c
相当于(a xor b) in c
同时,如果需在类中使用中缀函数,必须明确函数的调用方(接收器)比如:
class MyStringCollection {infix fun add(s: String) { /*...*/ }fun build() {this add "abc" // 正确add("abc") // 正确//add "abc" // 错误: 没有指定调用方或无法隐式表达}
}
对于中缀函数的使用还是比较简单的。
空值和空类型
所有的变量除了引用一个具体的值之外,还有一种特殊的值可以使用,那就是null
,它代表空值,也就是不引用任何对象。
在其他语言中,比如Java中null
是一个非常常见的值,因为在某些情况下,引用类型的变量默认值就是null,这就经常会导致程序中出现一些空指针导致的异常,在Kotlin中,对空值处理是非常严格的,正常情况下,我们的变量是不能直接赋值为null
的,否则会报错,无法编译通过:
这是因为所有的类型默认都是非空类型,非空类型的变量是不允许被赋值为null的,这直接在编译阶段就避免了其他语言中经常存在的空指针问题。
那么,如果我们希望某个变量在初始情况下使用null
而不去引用某一个具体对象,该怎么做呢,此时我们需要将变量的类型修改为可空类型,只需在类型名称的后面添加一个?
即可:
fun main() {var str: String? = null
}
既然现在是可空类型,那么很多问题就会出现了,比如当一个变量为null
时,此时如果使用类中的一些成员方法或是获取成员属性时,会出现一些问题:
这里由于我们操作的是一个空类型,它有可能值为null
,我们可以想象一下,如果一个变量不引用任何对象,此时我们又去让对象做一些事情(执行函数)这不是在搞笑吗,压根就没这个对象,难道让空气去执行操作吗?这显然是不对的,这样就会导致我们上面所说的空指针异常。
此时,为了安全,我们就需要对变量进行判断,看看其是否为null
然后才能去做一些正常情况下该做的事情:
fun main() {var str: String? = null//这里直接通过if语句判断str变量是否为null,如果不是才执行if (str != null) {println(str.length) //现在就可以编译通过了}
}
可以看到,我们只要能确保某个空类型变量的值不为空,那么就可以正常执行操作。当然,实际上在这个if内部,因为已经判断不为null了,所以str被智能类型转换为非空类型,这也是Kotlin语言非常人性化的地方。
不过在有些情况下,我们可能已经非常清楚,这里的str一定不为null,即使它是一个可空类型变量,我们可以像这样做,来告诉编译器,我们这里一定是安全的,只管执行就好:
fun main() {var str: String? = null//使用非空断言操作符!!.来明确不会出现null问题println(str!!.length)
}
虽然使用非空断言操作符能够进行强制操作,但是这样实际上并不安全,它同样存在安全问题,也许我们有没考虑到的情况会导致这里为null呢,也说不定吧?对于一些我们拿不定具体会不会出现null的情况,有没有更好的解决办法呢?
Kotlin为我们提供了一种更安全的空类型操作,要安全地访问可能包含null
值的对象的属性,请使用安全调用运算符?.
,如果对象的属性为null
则安全调用运算符返回null
,像下面这样:
fun main() {var str: String? = nullprintln(str?.length)
}
这里的调用结果存在两种情况:
- 如果str为null,那么这里得到的结果就是null,并且不会正常执行后面的操作
- 如果str不为null,那就正常返回这里本应该得到的结果
因此,使用安全调用运算符后,如果遇到null的情况,那么这里不会正常进行原本的操作,而是直接返回null
作为结果,这在有些时候非常好用,比如我们希望一个学生类型的变量在为null
时就不执行对应的语句:
fun main() {val stu: Student? = nullstu?.hello()
}
不过在有些时候,可能我们希望如果变量为null,在使用安全调用运算符时,返回一个我们自定义的结果,而不是null,这时该怎么做呢?我们可以使用Elvis运算符:
fun main() {val str: String? = null//Elvis运算符 ?: 左侧为空值检测目标,右侧为检测到null时返回的结果val len: Int = str?.length ?: 0
}
这里我们使用了Elvis运算符来判断左侧是否为null,如果左侧为null,那么这里直接得到右侧的自定义值,这个运算符长得巨像其他语言里面的三元运算符,Kotlin拿来干这事了。
解构
有时候,我们在使用对象时可能需要访问它们内部的一些属性:
fun main() {val student = Student("小明", 18)println(student.name) //访问name属性println(student.age)
}
这样看起来不太优雅,有没有更好的方式呢,比如这里能不能直接得到Student对象内部的name和age熟悉作为变量使用?当然是可以的,我们可以直接像下面这样编写:
fun main() {val student = Student("小明", 18)val (a, b) = student //从Student对象中将其属性解构出来,很优雅println("名字: $a, 年龄: $b")
}
要让一个类的属性支持解构,我们只需添加约定的函数即可,在Kotlin中,我们可以自定义解构出来的结果,而具体如何获取,需要定义一个componentN函数并通过返回值的形式返回解构的结果:
class Student(var name: String, var age: Int) {operator fun component1() = name //使用component1表示解构出来的第一个参数operator fun component2() = age //使用component2表示解构出来的第二个参数operator fun component3... //以此类推
}
添加用于解构的函数在之后,我们就可以使用解构操作了:
val (a, b) = student //解构出来的参数按顺序就是componentN的结果了
如果我们只想要使用第二个参数,而第一个参数不需要,可以直接使用_
来忽略掉:
val (_, b) = student
println("年龄: $b")
解构同样可以用在Lambda表达式中:
val func: (Student) -> Unit = { (a, b) -> //使用括号包括结构出来的两个参数println("名字: $a, 年龄: $b")
}val func2: (Student, Int) -> Unit = { (a, b), i ->println("名字: $a, 年龄: $b")println(i)
}val func3: (Student, Int) -> Unit = { (_, b), i ->println("名字: $a, 年龄: $b")println(i)
}
解构语法在遍历集合类和数组时同样适用,我们会在后面进行讲解。
包和导入
在之前,无论我们创建的是Kotlin源文件还是Kotlin类文件,都是在默认的包下进行的,也就是直接在kotlin/src目录创建的。
但是有些时候,我们可能希望将一些模块按功能进行归类,而不是所有的kt文件都挤在一起,这个时候我们就需要用到包了。
我们可以直接右键新建一个软件包,软件包的包名建议以域名格式进行命名,例如:
- com.baidu
- cn.itbaima
这类似于我们平时在浏览器中访问的网站地址,只不过是反过来的,这样就能很明确是哪一家公司或哪一个人制作的产品了。
我们创建一个包后,可以将kt文件创建到这个包中。所有不在默认包下的kt文件,必须在顶部声明所属的包,比如这里的Test.kt就放在com.test
这个包中,因此顶部必须使用package关键字进行包声明,IDEA非常智能,在创建时就自动帮助我们生成好了。我们可以继续像之前一样,编写类或是函数:
package com.testvar a = 20fun message() {println("我是测试方法")
}class User
不过,由于现在kt文件存放在了一个明确的包中,如果我们要在这个包以外的其他地方使用,会出现一些问题:
当我们使用其他包中kt文件定义的类或函数时,会直接提示未解析的引用,这是因为默认情况下只有同包内的内容可以相互使用,而现在我们使用的是其他包中的内容,我们需要先进行导入操作:
import com.test.User //使用import关键字进行导入,导入时需要输入 包名.类型/顶级函数名称 来完成
import com.test.message
import com.test.afun main() {val user = User()message()
}
这样,我们在导入之后就可以正常使用了,当然,如果一个包中定义的内容太多,我们需要大量使用,也可以使用*
一次性导入全部内容:
import com.test.* //导入此包下的全部内容fun main() {val user = User()message()
}
实际上官方提供的库,也是来自于不同的包,但是Kotlin在默认情况下会自动导入一些包,不需要我们明确指定:
- kotlin.*
- kotlin.annotation.*
- kotlin.collections.*
- kotlin.comparisons.*
- kotlin.io.*
- kotlin.ranges.*
- kotlin.sequences.*
- kotlin.text.*
比如我们之前用到的一些基本类型,都是在kotlin
这个包中定义的。
注意:在不同的平台下,还会有更多默认导入的包,比如Java平台下,就会默认导入java.lang.*
和kotlin.jvm.*
这两个包。
在有些情况下,可能会出现名称冲突的情况:
import com.test.messagefun main() {message() //这里调用的,到底是导入的message函数,还是当前kt文件定义的函数呢?
}fun message(){println("Goodbye World!")
}
结果显而易见,这里会优先使用导入的函数,而不是在当前文件中定义的同名函数。那么该如何去解决这种冲突的情况呢?我们可以使用as
关键字来为导入的内容起个新的名字:
import com.test.message as outer //将导入的message函数名字改为outerfun main() {message() //此时这里调用的就是下面的message函数了
}fun message(){println("Goodbye World!")
}
这样就可以很好地消除存在歧义的情况了,最后总结一下,使用import
关键字支持导入以下内容:
- 顶级函数和属性
- 在单例对象中声明的函数和属性(后面介绍)
- 枚举常量(后面介绍)
访问权限控制
有些时候,我们可能不希望别人使用我们的所有内容,比如:
package com.testfun message() {println("我是测试方法")
}fun inner(){//我们不希望这个函数能够在其他地方被调用
}
在上面的例子中,有一个函数是我们不希望被外部调用的,但是经过前面的学习,我们只需要使用import
关键字就能直接导入,那有没有办法能够控制一下其他地方对于当前文件一些可能私有函数或是其他内容的访问呢?我们可以使用可见性控制来处理。
在类、对象、接口、构造函数和函数,以及属性上,可以为其添加 可见性修饰符 来控制其可见性,在Kotlin中有四个可见性修饰符,它们分别是:private
、protected
、internal
和public
,默认可见性是public
,在使用顶级声明时,不同可见性的访问权限如下:
- 如果不使用可见性修饰符,则默认使用
public
,这意味着这里声明的内容将在任何地方可访问。 - 如果使用
private
修饰符,那么声明的内容只能在当前文件中可访问。 - 如果使用
internal
修饰符,它将在同一模块中可见(当前的项目中可以随意访问,与public没大差别,但是如果别人引用我们的项目,那么无法使用) - 顶级声明不支持使用
protected
修饰符。
因此,在默认情况下,我们定义的内容都是可以访问的,而想要实现上面的效果,我们可以为其添加private
修饰符:
private fun inner(){//我们不希望这个函数能够在其他地方被调用
}
这样,当其他地方使用时,就会报错:
在类中定义成员属性时,不同可见性的访问权限如下:
private
意味着该成员仅在此类中可见(包括其所有成员)protected
与private
的可见性类似,外部无法使用,但在子类中可以使用(子类会在后面介绍)internal
意味着本项目中任何地方都会看到其internal
成员,但是别人引用我们项目时不行。public
意味着任何地方都可以访问。
比如下面的例子:
class Student(private var name: String, //name属性无法被外部访问,因为是私有的internal var age: Int) { //age可以被外部访问,但是无法再其他项目中访问到private constructor() : this("", 10) //这个无参构造无法被外部访问,因为是私有的
}
有了访问控制,我们就可以更加明确地表示哪些内容是可以访问,而哪些是内部使用的。
内部类
在 Kotlin 中,inner
关键字用于声明内部类(也称为嵌套类),它指定了内部类与其外部类之间的关系。使用 inner
关键字声明的内部类有以下特点:
- 实例绑定:
inner
类的实例是隐式地绑定到其外部类实例的。这意味着你不能有一个没有外部类实例的inner
类实例。换句话说,你不能直接实例化一个inner
类,而必须先有一个外部类的实例。 - 访问外部类成员:
inner
类可以访问其外部类的所有成员,包括私有成员。这是因为inner
类是外部类的一个“部分”,它们之间的关系非常紧密。 - 命名和可见性:
inner
类在外部类的成员作用域内是可见的,并且可以使用外部类的名称作为其前缀来引用。例如,如果你有一个名为Outer
的外部类和一个inner
类Inner
,则可以使用Outer.Inner
来引用这个内部类。 - 构造器中的
this
:在inner
类的构造器中,this
关键字引用的是inner
类自身的实例,而如果要引用外部类的实例,则需要使用this@外部类名称
。
实际上,没加innel
修饰的内部类,kotlin会把它定义为静态的内部类,这将导致在内部类中,无法获取外部类的实例对象;加了innel
关键字后,kotlin才会把它定义为成员内部类。
class A {fun test() {B().hello()}inner class B {fun hello() {println(this@A)}}
}