前言
Kotlin 是一门面向对象的语言,不同于面向过程的语言(如C语言),面向对象的语言是可以创建类并使用它的。
类,是对事物的一种抽象和封装。比如,我们可以将“人”、“书”、“动物”封装成一个类。类中包含了字段和函数。
字段就表示该类事物所拥有的属性,比如说人的字段可以有姓名、年龄、性别。而函数则表示该类事物能够发生的行为,比如人可以吃饭、睡觉、打豆豆。
通过这种类的封装,我们可以很方便地创建这些类的对象,然后调用对象中的字段和函数来满足实际的编程需求,这就是面向对象编程(OOP)最基本的思想。
类与对象
那我们现在就来创建一个 Person 类。右键包名,新建 Kotlin Class 文件,命名为 Person。
创建完成后,会自动生成如下代码:
class Person {
}
这就是一个最基本的 Kotlin 类,也是一个空的类实现,使用 class 关键字来声明。
现在,我们可以往这个类中添加一些字段和方法,比如添加 name、age 字段,以及一个 eat() 函数。
class Person {
// 年龄
var age : Int = 0
// 姓名
var name : String = ""
// 吃饭
fun eat(){
println("${name}, who is only ${age} years old, is eating")
}
}
类创建好了,我们就可以创建该类的对象(实例化),代码很简单,就一行:
fun main(){
val person = Person()
}
不像 Java 那样需要使用 new 关键字,而是像调用一个函数一样,就可以创建一个类的对象(实例),我们使用了person 变量来接收这个创建的对象。
有了对象之后,我们就可以访问该对象的属性,调用它的函数了,像这样:
fun main(){
val person = Person()
person.name = "Rose"
person.age = 21
person.eat()
}
运行结果:
这就是面向对象编程最基本的用法,我们将事物抽象封装成一个类,然后定义该类中属性和函数,接下来对类进行实例化,最后通过对象的属性和方法来完成具体任务。
继承与构造函数
面向对象编程有三大特性:封装、继承和多态。我们已经看完了封装,接下来看看继承。
继承
继承是面向对象编程中的一个非常重要的特性,它可以让我们创建一个类来继承另一个类的属性和方法,从而实现代码复用。
其实非常好理解,假如我们要定义一个 Student 学生类,学生除了需要有学号、班级的属性外,同时学生也是人,需要有年龄、姓名属性和吃饭的行为。如果在 Student 类中重复定义 name、age 属性和 eat() 函数就会显得有些冗余。这时,我们就可以让 Student 类继承 Person 类,这样 Student 类就自动拥有了 Person 类中定义的属性和函数,我们可以在这个基础之上,在 Student 类中定义自己独有的属性和函数,比如前面说的学号、班级属性。
那我们来实现吧,首先创建一个 Student 类,并添加学号和班级两个属性:
class Student{
var sno = "" // 学号
var grade = 0 // 班级
}
然后让 Student 类继承 Person 类。
不过在继承之前,先要让Person类可以被继承。因为在 Kotlin 中任何一个非抽象类默认都是不可以被继承的,相当于在类的声明前加上了 final 关键字修饰。
抽象类除外,因为抽象类本身是无法创建实例的,必须要有子类去继承它才能创建实例,所以抽象类必须可以被继承才行,要不然就没有意义了。
为什么这么设计呢?
因为类和变量一样最好是不可变的,如果一个类允许被继承,你不知道子类是怎么实现的。所以一个类如果不是专门用于继承的,应该主动加上 final 关键字,以禁止它可以被继承。Kotlin 在设计时就是遵循了这条编程规范,所以才会这样。
那我们要让 Person 类可以被继承,也很简单,只需显式地在类声明前面加上一个 open 关键字就行了,它会将类变得“开发”,像这样:
open class Person {
...
}
现在就可以让 Student 类继承 Person 类了,在 Java 中继承的关键字是 extends,而在 Kotlin 简化为了一个冒号,如下:
class Student : Person() {
var sno = ""
var grade = 0
}
你可能注意到了 Person 后还有一对括号(),你会疑问,这是干嘛的?
这就涉及到了 Kotlin 中的主构造函数和次构造函数。先简要说说:当一个类继承另一个类时,子类的初始化过程中也要对父类进行初始化,这就是构造函数的作用。
主构造函数
任何一门面向对象的语言都有构造函数的概念,Kotlin 也不例外,它将构造函数分为了两种:主构造函数和次构造函数。
主构造函数是你最常用的函数,每个类默认都会有一个不带参数的主构造函数,例如:
class Student() /*括号() 表示调用主构造函数*/ : Person() {
var sno = ""
var grade = 0
}
因为这个构造函数不带任何参数,所以括号默认可以省略,省略后,就变为了我们刚刚定义的Student类了:
class Student : Person() {
var sno = ""
var grade = 0
}
我们也可以显式地指定带参数的主构造函数,像这样:
主构造函数的特点是没有函数体(大括号{})
class Student(val sno: String, val grade: Int) : Person() {
}
这时,当我们创建该类的实例时,就必须传入构造函数所要求的参数:
val student = Student(sno = "123", grade = 2)
这样就创建了一个 Student 类的对象,并指定了学生的学号和年级,另外因为这些属性在对象创建后没有再被重复赋值了,所以使用 val 来声明它们。
主构造函数没有函数体,但如果你想在主构造函数中写一些初始化的逻辑,你可以写在 Kotlin 给我们提供的 init 代码块中,它会在主构造函数执行后执行:
class Student(val sno: String, val grade: Int) : Person() {
init {
println("student number is ${sno},grade is ${grade}" )
}
}
那到现在,和Person()后的括号()有什么关系?
Java 在继承中有一个规定:子类的构造函数必须调用父类中的构造函数,Kotlin 也遵循了这个规定。
所以 : Person()
这部分即表示继承的父类是 Person,又表示在 Student 对象实例化时调用了父类Person的无参主构造函数。
那为什么不在 init 代码块中调用父类的构造函数呢?因为大多数情况下,我们是不需要写 init 结构体的。并且因为 Kotlin 要求父类的初始化必须在子类的初始化逻辑(包括
init
块和属性的初始化)开始之前完成。
class Student(val sno: String, val grade: Int) : Person() {
}
现在我们来改造一下之前的 Person 类,将其姓名、年龄属性都放在主构造函数中,通过它来初始化。同时修改 Student 类的主构造函数,添加两个参数用于接收父类初始化需要的参数,并将这两个参数传入 Person 的构造函数。
完整代码如下:
open class Person(val age: Int, val name: String) {
// 吃饭
fun eat() {
println("${name}, who is only ${age} years old, is eating")
}
}
class Student(val sno: String, val grade: Int, age: Int, name: String) : Person(age, name) {
init {
println("student number is ${sno},grade is ${grade}")
}
}
这里没有将 name、age 参数使用 val 或 var 声明,是因为它们仅用于传递给父类的构造函数,如果也使用 val 或 var声明,就会成为 Student 类自身的属性,导致与父类中继承而来的同名的name和age属性冲突。
测试一下:
fun main() {
val student = Student(sno = "123", grade = 2, age = 21, name = "Jack")
student.eat()
}
运行结果:
现在我们就基本掌握了主构造函数的用法。但 Kotlin 的构造函数还包括次构造函数。
次构造函数
虽然主构造函数能满足大部分需求,但有时我们可能需要提供更多的方式来创建对象。一个类只能有一个主构造函数,但是可以有多个次构造函数,次构造函数也能实例化一个类,只不过和主构造函数不同的是,它是有函数体的。
次构造函数是通过 constructor 关键字来定义的,Kotlin 规定:当一个类既有主构造函数,又有次构造函数时,那么所有的次构造函数必须直接或间接地调用主构造函数(使用 this 关键字)。
比如,给Student类添加几个次构造函数:
class Student(val sno: String, val grade: Int, age: Int, name: String) : Person(age, name) {
// 次构造函数,直接调用主构造函数
constructor(name: String, age: Int) : this(sno = "", grade = 0, name = name, age = age) {
}
// 次构造函数,间接调用主构造函数
constructor() : this(name = "", age = 0) {
}
}
其实你基本上用不到次构造函数的,因为 Kotlin 可以给函数的参数设置默认值,一定程度上可以替代次构造函数。
例如,我们给 Student 类的主构造函数的参数添加默认值,可以完成次构造函数的功能效果:
class Student(
val sno: String = "",
val grade: Int = 0,
age: Int = 0,
name: String = ""
) : Person(age, name) {
}
fun main() {
val student1 = Student(sno = "1002", grade = 4, name = "Lily", age = 20) // 全参数构造
val student2 = Student(name = "Tom", age = 18) // sno 和 grade 使用默认值
val student3 = Student() // 所有参数都使用默认值
}
接下来我们再来看一种特殊的情况:一个类只有次构造函数,没有主构造函数。
当一个类没有显式地定义主构造函数(类名后没有括号)且定义了次构造函数时,它就是没有主构造函数的。在这种情况下,如果该类需要继承父类,就必须在每个次构造函数中通过 super
关键字调用父类的构造函数。
代码如下:
class Student : Person {
val sno: String
val grade: Int
// 次构造函数
constructor(name: String, age: Int, sno: String, grade: Int) : super(name = name, age = age) {
this.sno = sno
this.grade = grade
}
}
并且由于Student类现在是没有主构造函数,既然没有主构造函数了,继承Person类的时候也就不需要再加上括号了,我们在次构造函数中调用父类的构造函数。
接口
接口是面向对象中的另一个重要概念,它是用于实现多态的重要前提。一个类都可以实现多个接口。
基本用法
我们可以在接口中定义一系列的抽象函数(没有函数体的方法),然后由具体的类去实现这些函数。
比如,我们定义一个Study接口,在内部定义了抽象函数 readBooks() 和 doHomework():
interface Study {
fun readBooks()
fun doHomework()
}
然后,我们可以让 Student 类去实现这个接口,在Kotlin中实现接口不是使用 Java 的 implements 关键字,和继承父类一样,也是通过冒号 :
来声明,用逗号 ,
分隔父类和各个接口:
class Student(name: String, age: Int) : Person(name = name, age = age), Study {
// 使用 override 关键字来实现接口中的方法
override fun readBooks() {
println("${name}, who is only ${age} years old, is reading books")
}
override fun doHomework() {
println("${name}, who is only ${age} years old, is doing his homework")
}
}
我们看到接口的后面并没有加上括号,因为接口本身没有任何构造函数。实现接口中的方法时,必须使用 override 关键字。
让我们在 main 函数中测试一下多态的特性:
fun main() {
val student = Student("Jack", 19)
doStudy(student)
}
// 任何实现了 Study 接口的对象都可以传递给它
fun doStudy(study: Study) {
study.readBooks()
study.doHomework()
}
运行结果:
以上就是多态的特性:我们创建的 Student 类的实例被传入了 doStudy() 函数。该函数接收一个 Study 类型的参数,由于我们的Student类实现了Study接口,所以Student类的实例是可以被当做Study 类型传递给 doStudy() 函数的。然后我们可以调用 Study 接口中定义的 readBooks() 和 doHomework() 方法。
为了让接口的功能更加灵活,Kotlin允许我们为接口中的抽象函数添加默认实现。这意味着实现该接口时,可以选择性地重写这些带有默认实现的方法,如果不重写,就会自动使用该接口提供的默认行为。
如下所示,我们给 Study 接口的 doHomework()函数添加一个默认实现:
interface Study {
fun readBooks() // 还是抽象方法,需要被实现
fun doHomework(){ // 提供了默认实现
println("do homework")
}
}
注意:接口中也能定义属性,可以是抽象属性,需要实现类提供,也可以是非抽象属性。
现在,当一个类去实现这个接口时,只会要求强制实现 readBooks() 函数,doHomework()函数可以选择性地实现,如果不实现,会使用接口提供的默认实现:
class Student(name: String, age: Int) : Person(name = name, age = age), Study {
override fun readBooks() {
println("${name}, who is only ${age} years old, is reading books")
}
}
fun main() {
val student = Student("Jack", 19)
doStudy(student)
}
再次运行一下:
在了解了如何定义和实现接口后,我们来看看 Kotlin 如何控制类及其成员(函数、属性)的可见性。
函数(及属性)的可见性修饰符
Java 中有 public、private、protected和default(什么都不写)这4种可见性修饰符,Kotlin中也有4种,分别是 public、private、protected 和internal。使用时,只需定义在 fun (或 val / var)关键字的前面即可。
Java和Kotlin中这些可见性修饰符的异同:
-
private 修饰符的作用在两个语言都是一样的,表示只对当前类的内部可见。
-
public 修饰符的作用也是一致的,表示对所有类都可见,在Kotlin中默认的修饰符就是 public,而在 Java 中 default 才是默认的。
-
protected 修饰符在Java中表示对当前类、子类和同一包路径下的类可见,在Kotlin中则表示只对当前类和子类可见
-
internal 修饰符在Kotlin中表示只对同一模块中的类可见。比如,自己写的一个模块拿去给别人使用,有些函数只允许在模块的内部调用,不想暴露给外部,就可以把这些函数声明为 internal。
修饰符 | Java | Kotlin |
---|---|---|
public | 所有类可见 | 所有类可见(默认) |
private | 当前类可见 | 当前类可见 |
protected | 当前类、子类、同一包路径下的类可见 | 当前类、子类可见 |
default | 同一包路径下的类可见(默认) | 无 |
internal | 无 | 同一模块中的类可见 |
数据类与单例类
Kotlin 提供了一些特殊类的声明方式,例如数据类和单例类。
数据类
数据类用于将数据库中的数据映射到程序中,为编程逻辑提供了数据模型的支持。简单来说,就是持有数据。
数据类通常需要重写equals()、hashCode()、toString()这几个方法。其中,equals()方法用于判断两个对象是否相等。hashCode()方法作为equals()的配套方法,也需要一起重写,否则会导致HashMap、HashSet等hash相关的系统类无法正常工作。toString()方法用于日志输出和调试,否则一个数据类对象默认打印出来是它的内存地址。
比如,在 Java 中要实现一个数据类,可能需要这样写:
class Book(val bookName: String, val author: String) {
override fun toString(): String {
return "Book(bookName='$bookName', author='$author')"
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as Book
if (bookName != other.bookName) return false
if (author != other.author) return false
return true
}
override fun hashCode(): Int {
var result = bookName.hashCode()
result = 31 * result + author.hashCode()
return result
}
}
代码很长,但大多是无实际意义的,是固定的模版。所以 Kotlin 提供了一个 data 关键字,帮助我们简化数据类的创建:
data class Book(val bookName: String, val author: String) {
}
简化后的代码与之前的代码是完全等效的,仅仅在class 前添加 data 关键字就将 Book 类声明为了数据类,Kotlin 会根据主构造函数中的参数,自动地生成equals()、hashCode()、toString()等方法。
另外当一个类中没有任何代码时,尾部的大括号{}可以省略,代码进一步简化:
data class Book(val bookName: String, val author: String)
我们来测试一下:
fun main() {
val book1 = Book(bookName = "剑来", author = "烽火戏诸侯")
val book2 = Book(bookName = "龙族", author = "江南")
println(book1) // Book(bookName=剑来, author=烽火戏诸侯)
println("book1 equals book2 " + (book1 == book2)) // book1 equals book2 false
}
单例类
接下来我们再来看看Kotlin中独有的单例类。
单例模式是最常用、最基础的设计模式之一,它可以用于避免创建重复的对象。比如,我们希望某个类在全局范围内最多存在一个实例,我们就可以使用单例模式。这对于管理共享资源(如配置信息、数据库连接池)或全局状态非常有用,可以避免因重复创建对象带来的开销和潜在的状态不一致问题。
比如,在 Java 中你可以这样实现单例模式:
public class Singleton {
private static Singleton instance;
private Singleton() {
}
public synchronized static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
public void singletonTest() {
System.out.println("singletonTest is called.");
}
}
首先将构造函数设为私有,禁止外部创建Singleton的实例,然后给外部提供了一个getInstance()静态方法用于获取Singleton的实例。在方法中,如果当前Singleton实例为null,就创建一个,否则返回现有的实例。这就是单例模式的工作机制。
当我们想要调用singletonTest()方法时,可以这样写:
public static void main(String[] args) {
Singleton singleton = Singleton.getInstance();
singleton.singletonTest();
}
而在 Kotlin 中,创建一个单例类的方式特别简单,只需将 class 关键字改为 object 关键字即可:
object Singleton {
fun singletonTest() {
println("singletonTest is called.")
}
}
这就是一个单例类Singleton,调用内部的函数也很简单:
fun main() {
Singleton.singletonTest()
}
它看起来像是静态方法的调用,但其实Kotlin会在背后自动帮我们创建了一个Singleton类的实例,并且保证全局只会存在一个Singleton实例。