Kotlin 面向对象编程:从类到高级特性

58 阅读16分钟

前言

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()
}

运行结果:

image.png

这就是面向对象编程最基本的用法,我们将事物抽象封装成一个类,然后定义该类中属性和函数,接下来对类进行实例化,最后通过对象的属性和方法来完成具体任务。

继承与构造函数

面向对象编程有三大特性:封装、继承和多态。我们已经看完了封装,接下来看看继承。

继承

继承是面向对象编程中的一个非常重要的特性,它可以让我们创建一个类来继承另一个类的属性和方法,从而实现代码复用。

其实非常好理解,假如我们要定义一个 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()
}

运行结果:

image.png

现在我们就基本掌握了主构造函数的用法。但 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()
}

运行结果:

image.png

以上就是多态的特性:我们创建的 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)
}

再次运行一下:

image.png

在了解了如何定义和实现接口后,我们来看看 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。

修饰符JavaKotlin
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实例。