Kotlin的面向對象編程,深入討論繼承寫法的問題

本文同步發表於我的微信公衆號,掃一掃文章底部的二維碼或在微信搜索 郭霖 即可關注,每個工作日都有文章更新。

很多人可能都不知道,或者是已經忘記這件事了,但是我自己承諾過要寫的東西,我是不會忘記的。

記得是在去年十月份的時候,我在騰訊課堂做了一場線上直播課程,給大家講解Kotlin的基礎知識。不過當時我並沒有做過提前試講,只是在PPT上規劃了一下大體內容,因此時間預估得非常不準確。本來計劃是準備直播大概一個半小時,最後直播了三個小時還沒講完,因此爲了趕時間不得不砍掉了一些本來要講的內容。

當時提到了一個Kotlin在繼承時括號書寫的問題,這部分內容比較有疑惑性,不太利於初學者理解。本來我是計劃在直播時要講這個問題的,但由於時間實在不夠後來還是跳過了這部分內容。不過當時的彈幕裏大家對這個問題的呼聲很強烈,很多人都要求我不要跳過,那麼最後跟大家商討下來就是我答應會在之後專門寫一篇文章來講解這個問題。

不過這篇文章並沒有很快到來,之後我就開始忙趣享GIF上線的事情,後來又開始忙推出開源版的事情,年後回來在出版社的催促下又開始緊鑼密鼓地寫《第一行代碼 第3版》。那麼直到最近,《第一行代碼 第3版》已經完成了部分章節的編寫,並且在講解Kotlin繼承這部分內容時我特意講了一下這個括號問題,那麼今天我就講這部分內容分享出來,從而兌現之前對大家的承諾。

由於括號這個問題是作用在Kotlin的繼承特性之上的,並不能獨立存在,因此本篇文章同時還會涵蓋Kotlin繼承與面向對象方面的知識。

類與對象

首先Kotlin中定義一個類很簡單,如下所示:

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對象進行一些操作:

fun main() {
    val p = Person()
    p.name = "Jack"
    p.age = 19
    p.eat()
}

這裏將p對象的姓名賦值爲Jack,年齡賦值爲19,然後調用它的eat()函數,運行結果下圖所示。

這就是Kotlin面向對象編程最基本的用法了。

繼承與構造函數

現在我們開始學習面向對象編程中另一個極其重要的特性,繼承。

繼承也是基於對現實場景所總結出來的一個概念,其實非常好理解。比如現在我們要定義一個Student類,每個學生都有自己的學號和年級,因此我們可以在Student類中加入sno和grade字段。

但同時學生也是人呀,學生也會有姓名和年齡,也需要吃飯,如果我們在Student類中重複定義name、age字段和eat()函數的話就顯得太過冗餘了。

這個時候就可以讓Student類去繼承Person類,這樣Student就自動擁有了Person中的字段和函數,另外還可以定義自己獨有的字段和函數。

這就是面向對象編程中繼承的思想,很好理解吧?接下來我們嘗試用Kotlin語言實現上述功能。創建一個Student類,並在Student類中加入學號和年級這兩個字段,代碼如下所示:

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

現在Student和Person這兩個類之間是沒有任何繼承關係的,想要讓Student類繼承Person類,我們得做兩件事才行。

第一件事,使Person類可以被繼承。這點可能很多人會覺得奇怪,尤其是有Java編程經驗的人。一個類本身不就是可以被繼承的嗎,爲什麼還要使Person類可以被繼承呢?

這就是Kotlin不同的地方,在Kotlin中任何一個非抽象類默認都是不可以被繼承的,相當於Java中給類聲明瞭final關鍵字。

之所以這麼設計其實和val關鍵字的原因是差不多的,因爲類和變量一樣,最好都是不可變的,而一個類允許被繼承的話,它無法預知子類會如何實現,因此可能就會存在一些未知的風險。

在《Effective Java》這本書中有明確提到,如果一個類不是專門爲繼承而設計的,那麼就應該主動將它加上final聲明,禁止它可以被繼承。

很明顯,Kotlin在設計的時候遵循了這條編程規範,默認所有非抽象類都是不可以被繼承的。之所以我這裏一直在說非抽象類,是因爲抽象類本身是無法創建實例的,一定要由子類去繼承它才能創建實例,因此抽象類必須可以被繼承才行,要不然也就沒有意義了。

既然現在Person類是無法被繼承的,我們得讓它可以被繼承才行,方法也很簡單,在Person類的前面加上open關鍵字就可以了,如下所示:

open class Person {
    ...
}

加上open關鍵字之後,我們就是在主動告訴Kotlin編譯器,Person這個類是專門爲繼承而設計的,這樣Person類就允許被繼承了。

做完了第一件事,接下來第二件事就是要讓Student類繼承Person類。在Java中繼承的關鍵字是extends,而在Kotlin中變成了一個冒號,寫法如下:

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

繼承的寫法如果只是替換一下關鍵字倒也挺簡單的,但是爲什麼Person類的後面要加上一對括號呢?Java中繼承的時候好像並不需要括號。

對於初學Kotlin的來人講,這對括號確實挺難理解的,也可能是Kotlin在這方面設計得太複雜了,因爲它還牽扯到主構造函數、次構造函數等方面的知識,這裏我儘量嘗試用最簡單易懂的講述來讓大家理解這對括號的意義和作用,同時順便學習一下Kotlin中的主、次構造函數。

任何一個面向對象的編程語言都會有構造函數的概念,Kotlin中也有,但是Kotlin將構造函數分成了兩種,主構造函數和次構造函數。

主構造函數將會是大家最最常用的構造函數,每個類默認都會有一個不帶參數的主構造函數,當然我們也可以顯式地給它指明參數。主構造函數的特點是沒有函數體,直接定義在類名的後面即可。比如下面這種寫法:

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

這裏我們將學號和年級這兩個字段都放到了主構造函數當中,這就表明在對Student類進行實例化的時候,必須得傳入構造函數中要求的所有參數。比如:

val student = Student("a123", 5)

這樣我們就創建了一個Student的對象,同時指定該學生的學號是a123,年級是5。另外由於構造函數中的參數是在創建實例的時候傳入的,不像之前的寫法那樣還得重新賦值,因此我們可以將參數全部聲明成val。

那或許有的朋友可能會問了,主構造函數沒有函數體,如果我想在主構造函數中編寫一些邏輯該怎麼辦呢?Kotlin給我們提供了一個init結構體,所有主構造函數中的邏輯都可以寫在這裏:

class Student(val sno: String, val grade: Int) : Person() {
    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類一定會報錯,如下圖所示:

這裏出現錯誤的原因也很明顯,Person類後面的空括號表示要去調用Person類中無參的構造函數,但是Person類現在已經沒有無參的構造函數了,所以就提示了上述錯誤。

如果我們想解決這個錯誤的話,就必須給Person類的構造函數傳入name和age字段,可是Student類中也沒有這兩個字段呀,很簡單,沒有就加唄。我們可以在Student類的主構造函數中加上name和age這兩個參數,然後再將這兩個參數傳給Person類的構造函數即可,代碼如下所示:

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類的實例:

val student = Student("a123", 5, "Jack", 19)

學到這裏,我們就將Kotlin的主構造函數基本掌握了,同時是不是覺得繼承時的這對括號問題也並不是那麼難理解?但是,Kotlin在括號這個問題上的複雜度還沒有到此爲止,因爲我們還沒涉及到Kotlin構造函數中的另一個組成部分,次構造函數。

其實次構造函數我本來是不太想講的,因爲我們幾乎上用不到它。Kotlin提供了一個給函數設定參數默認值的功能,基本上可以替代次構造函數的作用。但是考慮到知識結構的完整性,我決定還是介紹一下次構造函數的相關知識,順便探討一下括號問題在次構造函數上的區別。

首先要知道,任何一個類只能有一個主構造函數,但是可以有任意多個次構造函數。次構造函數也可以用於去實例化一個類,這點和主構造函數沒有什麼不同,只不過它是有函數體的。

Kotlin規定,當一個類既有主構造函數又有次構造函數時,所有的次構造函數都必須得調用主構造函數(包括間接調用),這裏我通過一個具體的例子就能簡單闡明清楚了,代碼如下:

class Student(val sno: String, val grade: Int, name: String, age: Int) : 
Person(name, age) { 
    constructor(name: String, age: Int) : this("", 0, name, age) {
    }

    constructor() : this("", 0) {
    }
}

次構造函數是通過constructor關鍵字來定義的,這裏我們定義了兩個次構造函數。第一個次構造函數接收name和age參數,然後它又通過this關鍵字調用了主構造函數,並將sno和grade這兩個參數賦值成初始值。第二個次構造函數不接收任何參數,它通過this關鍵字調用了我們剛纔定義的第一個次構造函數,並將name和age參數也賦值成初始值,由於第二個次構造函數間接調用了主構造函數,因此這仍然是合法的。

那麼現在我們就擁有了三種方式來對Student類進行實體化,分別是通過不帶參數的構造函數,通過帶兩個參數的構造函數,以及通過帶四個參數的構造函數,對應代碼如下所示:

val student1 = Student()
val student2 = Student("Jack", 19)
val student3 = Student("a123", 5, "Jack", 19)

這樣我們就將次構造函數的用法掌握得差不多了,但是到目前爲止,繼承時的括號問題還沒有進一步延伸,暫時和之前學過的場景是一樣的。

那麼接下來我們就再來看一種比較特殊的情況,類中只有次構造函數,沒有主構造函數。這種情況真的是非常非常少見,但在Kotlin中是允許的。當一個類沒有顯式地定義主構造函數,且定義了次構造函數時,它就是沒有主構造函數的。我們還是結合着代碼來看一下:

class Student : Person {
    constructor(name: String, age: Int) : super(name, age) {
    }
}

注意這裏的代碼變化,首先Student類的後面沒有顯示地定義主構造函數,同時又因爲定義了次構造函數,所以現在Student類是沒有主構造函數的。那麼既然沒有主構造函數,繼承Person類的時候也就不需要再加上括號了。其實原因就是這麼簡單,只是很多人在剛開始學習Kotlin的時候沒能理解這對括號的意義和規則,因此總感覺繼承的寫法有時候要加上括號,有時候又不要加,搞得暈頭轉向的,而當你真正理解了之後會發現其實還是很簡單的。

另外由於沒有主構造函數,次構造函數只能直接調用父類的構造函數,上述代碼也是將this關鍵字換成了super關鍵字,這部分就很好理解了,因爲和Java比較像,我也就不再多說了。

好了,關於Kotlin繼承方面的知識以及這個比較讓人費解的括號問題就講到這裏,相信不少朋友心中的疑惑都已經解開了吧。本篇文章其實是從《第一行代碼 第3版》第2章中提取出來的一小節內容,這本書目前我正在創作中,全書的代碼都會使用Kotlin重寫,並且加入豐富的Kotlin語言講解,以及Android 8.0、9.0、10.0系統新特性,Jetpack架構組件等新知識的講解。預計將在今年年底完稿,明年年初出版,也希望大家到時可以多多支持。


關注我的技術公衆號,每個工作日都有優質技術文章推送。

微信掃一掃下方二維碼即可關注:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章