kotlin入门(五)面对对象编程(上)

163 阅读16分钟

和很多现代高级语言一样,Kotlin也是面向对象的,因此理解什么是面向对象编程对我们来说就非常重要了。关于面向对象编程的解释,你可以去看很多标准化、概念化的定义,但是我觉得那些定义只有本来就懂的人才能看得懂,而不了解面向对象的人,即使看了那些定义还是不明白什么才是面向对象编程。

因此,这里我想用自己的理解来向你解释什么是面向对象编程。不同于面向过程的语言(比如C语言),面向对象的语言是可以创建类的。类就是对事物的一种封装,比如说人、汽车、房屋、书等任何事物,我们都可以将它封装一个类,类名通常是名词。而类中又可以拥有自己的字段和函数,字段表示该类所拥有的属性,比如说人可以有姓名和年龄,汽车可以有品牌和价格,这些就属于类中的字段,字段名通常也是名词。而函数则表示该类可以有哪些行为,比如说人可以吃饭和睡觉,汽车可以驾驶和保养等,函数名通常是动词。

通过这种类的封装,我们就可以在适当的时候创建该类的对象,然后调用对象中的字段和函数来满足实际编程的需求,这就是面向对象编程最基本的思想。当然,面向对象编程还有很多其他特性,如继承、多态等,但是这些特性都是建立在基本的思想之上的,理解了基本思想之后,其他的特性我们可以在后面慢慢学习。

1.类与对象

现在我们就按照刚才所学的基本思想来尝试进行面向对象编程。首先创建一个Person类。右击 最开始创建的包→New→Kotlin File/Class,在弹出的对话框中输入“Person”。 对话框在默认情况下自动选中的是创建一个File,File通常是用于编写Kotlin顶层函数和扩展函 数的,我们可以点击展开下拉列表进行切换,步骤也可以见下图1图2。

图1.png

图2.png

创建后会看见如下代码:

    class Person {
    }

这是一个空的类实现,可以看到,Kotlin中也是使用class关键字来声明一个类的,这一点和Java一致。现在我们可以在这个类中加入字段和函数来丰富它的功能,这里我准备加入name和age字段,以及一个eat()函数,因为任何一个人都有名字和年龄,也都需要吃饭。

class Person {
    var name = ""
    var age = 0
    fun eat() {
        println(name + " is eating. He is " + age + " years old.")
    }
}

简单解释一下,这里使用var关键字创建了name和age这两个字段,这是因为我们需要在创建对象之后再指定具体的姓名和年龄,而如果使用val关键字的话,初始化之后就不能再重新赋值了。接下来定义了一个eat()函数,并在函数中打印了一句话,非常简单。

Person类已经定义好了,接下来我们看一下如何对这个类进行实例化,代码如下所示:

val p = Person()

Kotlin中实例化一个类的方式和Java是基本类似的,只是去掉了new关键字而已。之所以这么设计,是因为当你调用了某个类的构造函数时,你的意图只可能是对这个类进行实例化,因此即使没有new关键字,也能清晰表达出你的意图。Kotlin本着最简化的设计原则,将诸如new、行尾分号这种不必要的语法结构都取消了。

上述代码将实例化后的类赋值到了p这个变量上面,p就可以称为Person类的一个实例,也可以称为一个对象。

下面我们开始在main()函数中对p对象进行一些操作:

fun main() {
    val p = Person()
    p.name = "三大"
    p.age = 2022
    p.eat()
}

这里将p对象的姓名赋值为 三大 ,年龄赋值为2022,然后调用它的eat()函数,运行结果如下图1所示。

图1.png

这就是面向对象编程最基本的用法了,简单概括一下,就是要先将事物封装成具体的类,然后将事物所拥有的属性和能力分别定义成类中的字段和函数,接下来对类进行实例化,再根据具体的编程需求调用类中的字段和方法即可。

2.继承与构造函数

现在我们开始学习面向对象编程中另一个极其重要的特性——继承。继承也是基于现实场景总结出来的一个概念,其实非常好理解。比如现在我们要定义一个Student类,每个学生都有自己的学号和年级,因此我们可以在Student类中加入sno和grade字段。但同时学生也是人呀,学生也会有姓名和年龄,也需要吃饭,如果我们在Student类中重复定义name、age字段和eat()函数的话就显得太过冗余了。这个时候就可以让Student类去继承Person类,这样Student就自动拥有了Person中的字段和函数,另外还可以定义自己独有的字段和函数。

这就是面向对象编程中继承的思想,很好理解吧?接下来我们尝试用Kotlin语言实现上述功能。右击右击最开始创建的包→New→Kotlin File/Class,在弹出的对话框中输入“Student”,并选择创建一个Class,你可以通过上下按键快速切换创建类型。

点击“OK”完成创建,并在Student类中加入学号和年级这两个字段,代码如下所示:

class Student {
    var sno = ""
    var grade = 0
}

现在Student和Person这两个类之间是没有任何继承关系的,想要让Student类继承Person类,我们得做两件事才行。

1.1(如何继承)第一件事,使Person类可以被继承

可能很多人会觉得奇怪,尤其是有Java编程经验的人。一个类本身不就是可以被继承的吗?为什么还要使Person类可以被继承呢?这就是Kotlin不同的地方,在Kotlin中任何一个非抽象类默认都是不可以被继承的,相当于Java中给类声明了final关键字。之所以这么设计,其实和val关键字的原因是差不多的,因为类和变量一样,最好都是不可变的,而一个类允许被继承的话,它无法预知子类会如何实现,因此可能就会存在一些未知的风险。,如果一个类不是专门为继承而设计的,那么就应该主动将它加上final声明,禁止它可以被继承。

很明显,Kotlin在设计的时候遵循了这条编程规范,默认所有非抽象类都是不可以被继承的。之所以这里一直在说非抽象类,是因为抽象类本身是无法创建实例的,一定要由子类去继承它才能创建实例,因此抽象类必须可以被继承才行,要不然就没有意义了。由于Kotlin中的抽象类和Java中并无区别,这里我就不再多讲了。

既然现在Person类是无法被继承的,我们得让它可以被继承才行,方法也很简单,在Person类的前面加上open关键字就可以了,如下所示:

open  class Person {
    var name = ""
    var age = 0
    fun eat() {
        println(name + " is eating. He is " + age + " years old.")
    }
}

加上open关键字之后,我们就是在主动告诉Kotlin编译器,Person这个类是专门为继承而设计的,这样Person类就允许被继承了。

1.2(如何继承)第二件事,要让Student类继承Person类。

在Java中继承的关键字是extends,而在Kotlin中变成了一个冒号,写法如下

class Student : Person() {
    var sno = ""
    var grade = 0
}

继承的写法如果只是替换一下关键字倒也挺简单的,但是为什么Person类的后面要加上一对括号呢?Java中继承的时候好像并不需要括号。对于初学Kotlin的人来讲,这对括号确实挺难理解的,也可能是Kotlin在这方面设计得太复杂了,因为它还涉及主构造函数、次构造函数等方面的知识,这里我尽量尝试用最简单易懂的讲述来让你理解这对括号的意义和作用,同时顺便学习一下Kotlin中的主构造函数和次构造函数。

任何一个面向对象的编程语言都会有构造函数的概念,Kotlin中也有,但是Kotlin将构造函数分成了两种:主构造函数和次构造函数。

2.1主构造函数

主构造函数将会是你最常用的构造函数,每个类默认都会有一个不带参数的主构造函数,当然 你也可以显式地给它指明参数。主构造函数的特点是没有函数体,直接定义在类名的后面即 可。比如下面这种写法:

class Student(val sno: String, val grade: Int) : Person() {
    //    var sno = ""  
    //    var grade = 0
}

这里我们将学号和年级这两个字段都放到了主构造函数当中,这就表明在对Student类进行实例化的时候,必须传入构造函数中要求的所有参数。比如:

val student = Student("小明", 13)

这样我们就创建了一个Student的对象,同时指定该学生的学号是小明,年级是13。另外,由于构造函数中的参数是在创建实例的时候传入的,不像之前的写法那样还得重新赋值,因此我们可以将参数全部声明成val(想设成var也可以)。

你可能会问,主构造函数没有函数体,如果我想在主构造函数中编写一些逻辑,该怎么办呢?

Kotlin给我们提供了一个init结构体,所有主构造函数中的逻辑都可以写在里面:

class Student(val sno: String, val grade: Int) : Person() {
    //    var sno = ""
    //    var grade = 0
    init {
        println("sno is " + sno)
        println("grade is " + grade)
    }
}

这里我只是简单打印了一下学号和年级的值,现在如果你再去创建一个Student类的实例,一定会将构造函数中传入的值打印出来。

到这里为止都还挺好理解的吧?但是这和那对括号又有什么关系呢?这就涉及了Java继承特性中的一个规定,子类中的构造函数必须调用父类中的构造函数,这个规定在Kotlin中也要遵守。那么回头看一下Student类,现在我们声明了一个主构造函数,根据继承特性的规定,子类的构造函数必须调用父类的构造函数,可是主构造函数并没有函数体,我们怎样去调用父类的构造函数呢?

你可能会说,在init结构体中去调用不就好了。这或许是一种办法,但绝对不是一种好办法,因为在绝大多数的场景下,我们是不需要编写init结构体的。

Kotlin当然没有采用这种设计,而是用了另外一种简单但是可能不太好理解的设计方式:括号子类的主构造函数调用父类中的哪个构造函数,在继承的时候通过括号来指定。因此再来看一遍这段代码,你应该就能理解了吧。

class Student(val sno: String, val grade: Int) : Person() {
    …………
}

在这里,Person类后面的一对空括号表示Student类的主构造函数在初始化的时候会调用Person类的无参数构造函数,即使在无参数的情况下,这对括号也不能省略。

而如果我们将Person改造一下,将姓名和年龄都放到主构造函数当中,如下所示:

open class Person(val name: String, val age: Int) {
    ...
}

此时你的Student类一定会报错,当然,如果你的main()函数还保留着之前创建Person实例的代码,那么这里也会报错,但是它和我们接下来要讲的内容无关,你可以自己修正一下,或者干脆直接删掉这部分代码。

现在回到Student类当中,它一定会提示如下图1所示的错误。

图1.png

这里出现错误的原因也很明显,Person类后面的空括号表示要去调用Person类中无参的构造函数,但是Person类现在已经没有无参的构造函数了,所以就提示了上述错误。

如果我们想解决这个错误的话,就必须给Person类的构造函数传入name和age字段,可是Student类中也没有这两个字段呀。很简单,没有就加呗。我们可以在Student类的主构造函数中加上name和age这两个参数,再将这两个参数传给Person类的构造函数,代码如下所示:

class Student(val sno: String, val grade: Int,val name1: String, val age1: Int) : Person(name1, age1) {
    //    var sno = ""
    //    var grade = 0
    init {
        println("sno is " + sno)
        println("grade is " + grade)
    }
}

注意,我们在Student类的主构造函数中增加name和age这两个字段时,不能再将它们声明成val,因为在主构造函数中声明成val或者var的参数将自动成为该类的字段,这就会导致和父类中同名的name和age字段造成冲突。因此,这里的name和age参数前面我们不用加任何关键字,让它的作用域仅限定在主构造函数当中即可。

现在就可以通过如下代码来创建一个Student类的实例:

val student = Student("我是学号", 13, "小明", 23)

2.2次构造函数

学到这里,我们就将Kotlin的主构造函数基本掌握了,是不是觉得继承时的这对括号问题也不是那么难以理解?但是,Kotlin在括号这个问题上的复杂度并不仅限于此,因为我们还没涉及Kotlin构造函数中的另一个组成部分——次构造函数。

其实你几乎是用不到次构造函数的,Kotlin提供了一个给函数设定参数默认值的功能,基本上可以替代次构造函数的作用,我们会在本章最后学习这部分内容。但是考虑到知识结构的完整性,我决定还是介绍一下次构造函数的相关知识,顺便探讨一下括号问题在次构造函数上的区别。

你要知道,任何一个类只能有一个主构造函数,但是可以有多个次构造函数。次构造函数也可以用于实例化一个类,这一点和主构造函数没有什么不同,只不过它是有函数体的。

Kotlin规定,当一个类既有主构造函数又有次构造函数时,所有的次构造函数都必须调用主构造函数(包括间接调用)。这里我通过一个具体的例子就能简单阐明,代码如下:

class Student(val sno: String, val grade: Int,val name1: String, val age1: Int) : Person(name1, age1) {
    //    var sno = ""
    //    var grade = 0
    init {
        println("sno is " + sno)
        println("grade is " + grade)
    }
    
    //次构造函数
    constructor(name: String, age: Int) : this("", 0, name, age) {
    }
    constructor() : this("", 0) {
    }
}

次构造函数是通过constructor关键字来定义的,这里我们定义了两个次构造函数:第一个次构造函数接收name和age参数,然后它又通过this关键字调用了主构造函数,并将sno和grade这两个参数赋值成初始值;第二个次构造函数不接收任何参数,它通过this关键字调用了我们刚才定义的第一个次构造函数,并将name和age参数也赋值成初始值,由于第二个次构造函数间接调用了主构造函数,因此这仍然是合法的。

那么现在我们就拥有了3种方式来对Student类进行实体化,分别是通过不带参数的构造函数、通过带两个参数的构造函数和通过带4个参数的构造函数,对应代码如下所示:

val student1 = Student()
val student2 = Student( "小明", 23)
val student3 = Student("我是学号", 13, "小明", 23)

这样我们就将次构造函数的用法掌握得差不多了,但是到目前为止,继承时的括号问题还没有进一步延伸,暂时和之前学过的场景是一样的。

那么接下来我们就再来看一种非常特殊的情况:类中只有次构造函数,没有主构造函数。这种情况真的十分少见,但在Kotlin中是允许的。当一个类没有显式地定义主构造函数且定义了次构造函数时,它就是没有主构造函数的。我们结合代码来看一下:

class Student : Person { //person后面没有括号
    constructor(name: String, age: Int) : super(name, age) {
    }
}

注意这里的代码变化,首先Student类的后面没有显式地定义主构造函数,同时又因为定义了次构造函数,所以现在Student类是没有主构造函数的。那么既然没有主构造函数,继承Person类的时候也就不需要再加上括号了。其实原因就是这么简单,只是很多人在刚开始学习Kotlin的时候没能理解这对括号的意义和规则,因此总感觉继承的写法有时候要加上括号,有时候又不要加,搞得晕头转向的,而在你真正理解了规则之后,就会发现其实还是很好懂的。

另外,由于没有主构造函数,次构造函数只能直接调用父类的构造函数,上述代码也是将this关键字换成了super关键字,这部分就很好理解了,因为和Java比较像,我也就不再多说了。这一章我们对Kotlin的继承和构造函数的问题探究得比较深,同时这也是很多人新上手Kotlin时比较难理解的部分,希望你能好好掌握这部分内容。

下节我们再接着讲接口与一些特殊类的用法