Kotlin 學習筆記 (12) — 物件

Andy Lu
15 min readOct 7, 2020

Object

如果只想用一個實例管理整個應用程式執行期間的一致性狀態,可以考慮定義一個單例 (Singleton)。

透過 object 關鍵字,便能定義一個只能產生一個實例的類別—單例。

使用 object 關鍵字的三種方式:

  1. 物件宣告 (object declaration)
  2. 物件運算式 (object expression)
  3. 伴生物件 (companion object)

物件宣告 (Object declaration)

Singleton design pattern

object Game{
private val player = Player("Madrigal")
private var currentRoom: Room = TownSquare()
init{
println("Welcome, adventurer!")
player.castFireball()
}
fun play(){
while(true){
//Play NyetHack
}
}
}
  • 因為物件宣告會自動實例化生成物件,所以無需自訂建構函數執行初始化任務。如果有相關的程式碼要在物件初始化時執行,可以選擇加入一個初始化區塊。
fun main(){
Game.play()
}
  • 直接使用物件的名稱加上函數的名稱,就可以不需經過實例化直接呼叫函數。

物件運算式 (Object expression)

anonymous implementation

有時候不一定非要定義一個新的命名類別不可。也許需要某個現有類別的一種變體實例,但只需要使用一次就行。事實上,對於這種用完即丟的類別實例,連命名都可以省略。

val abandonedTownSquare = object : TownSquare(){
override fun load() = "You anticipate applause, but no one is here..."
}
  • 此匿名類別依然遵循 object 關鍵字的一個規則,亦即一旦產生實體,該匿名類別只能存在唯一一個實例。當然,他的生命週期或作用範圍遠遠小於命名單例。

Android 會在 listener 的時候使用物件運算式,來替換 Java 中的匿名類別。

window.addMouseListener(object : MouseAdapter(){
override fun mouseClicked(e: MouseEvent) {/*...*/}

override fun mouseEntered(e: MouseEvent) {/*...*/}
})

伴生物件 (Companion object)

instance/factory design pattern

如果想將某個物件的初始化關聯至一個類別實例,便可以考慮伴生物件。透過 companion 修飾符,就能在類別中宣告一個伴生物件。一個類別只允許有一個伴生物件。

伴生物件的初始化分為兩種情況:

  1. 初始化包含伴生物件的類別時,就會同時初始化伴生物件。所以伴生物件,很適合存放和類別定義有上下文關係的單例資料。
  2. 只要直接存取伴生物件的某個屬性或函數,就會觸發伴生物件的初始化。
class PremadeWorldMap{
...
companion object{
private const val MAPS_FILEPATH = "nyethack.maps"
fun load() = File(MAPS_FILEPATH).readBytes()
}
}
  • 只有初始化 PremadeWorldMap 類別或呼叫 load 函數時,才會載入伴生物件的內容。此外,無論產生多少個 PremadeWorldMap 類別實例多少次,始終只會存在一個該伴生物件的實例。

Factory 設計模式 (Factory design pattern)

class MyClass private constructor(){
companion object Factory {
fun create(): MyClass = MyClass()
}
}
  • private 可見性修飾符將 MyClass 的建構式 constructor 不可對外公開。
  • 利用伴生物件設定一個建立類別的函數 create() ,外部的使用者就必須呼叫 MyClass 中的 create() 來建立。
fun main(){
MyClass.create()
MyClass.Factory.create() //Factory can be omitted

MyClass() //not allowed
}

伴生物件常常被用來替代使用 Java 的 public static final

public static final 是編譯期的常數

在 Kotlin 1.3 之後,可以在伴生物件加上 @JvmField ,那麼在轉成 Java Code 的時候,就會提升到相對應的位置並且加上 static。

//Kotlin
class Circle{
companion object{
@JvmField
val MAX_RADIUS = 20
}
}

轉成 Java Code 等於

//Java
class Circle {
public static final int MAX_RADIUS = 20;
}

如果沒有加上 @JvmField ,轉成 Java Code 的時候就只能內部存取(private)

//Java
class Circle {
private static final int MAX_RADIUS = 20;
}

巢狀類別 (Nested class)

不是所有類別中的類別都是匿名的。可以使用 class 關鍵字定義一個嵌入至另一個類別的命名類別。這樣定義代表嵌入的類別只與外面的類別有關聯,所以不需要放在外面。

如下例: GameInput 只與 Game 有關聯,所以將 GameInput 嵌入 Game 中。

object Game{
...
private class GameInput(arg: String?){
private val input = arg?: ""
val command = input.split(" ")[0]
val argument = input.split(" ").getOrElse(1, {""})
fun processCommand = when (command.toLowerCase()){
else -> commandNotFound()
}
private fun commandNotFound() = "I'm not quite sure what you're trying to do!"
}
}
  • Game 類別函式中可以使用 GameInput 的函式。
object Game{
...
fun play(){
while(true){
...
println(GameInput(readLine()).processCommand())
}
}
...
}

內部類別 (Inner class)

巢狀類別有一個問題,內側的類別無法使用外側類別的屬性。

如下:NestedClass 無法使用 OuterClass 的 intProp 屬性

class OuterClass{
private val intProp = 40
class NestedClass{
private val nestedIntProp = intProp
}
}

利用關鍵字 inner 加在巢狀類別宣告前,該巢狀類別成為內部類別 (inner class)

class OuterClass{
private val intProp = 40
inner class NestedClass{
private val nestedIntProp = intProp
}
}
  • 如此,就可以在內部類別取得外部類別的屬性。

資料類別 (Data class)

在物件導向程式設計中,我們會將相關的資料類型儲存在一個類別裡,方便在其他類別中取用。

Kotlin 提供了 data 關鍵字,在 class 前方用 data 修飾,該類別便成為了資料類別。

data class Gamer(val name:String, val health:Int)

我們知道,在 Kotlin 的所有類別都繼承於 Any ,所以我們可以在任何實例使用 Any 類別定義的函數。包括 toString()equals 以及 hashCode 等等...

在資料類別中,針對常用的函數,Kotlin 將它們的輸出稍做處理,方便使用者利用,例如

toString()

一般我們在類別呼叫其 toString() 時,我們經常發現吐出來的訊息不如我們所預期。

例如:@前方顯示的是類別名稱,後方顯示的是記憶體的位置,這對一般使用是沒有用途的。

Gamer@60e53b93

如果使用 data classtoString()

fun main() {
print(Gamer("andy", 100).toString())
}
//Gamer(name=andy, health=100)

我們會發現,顯示的資訊變得容易閱讀,使用 data class 讓我們不一定需要覆寫 toString() 。因為資料類別提供了客製化的 toString() 函數。

equals()

如果在一般類別使用 equals 來比較兩個實例是否相同,因為是用記憶體的位址來比較,所以縱使內容相同,也是會是 fail。

class Gamer1(val name:String, val health:Int)fun main(){
val gamer1 = Gamer1("Andy", 100)
val gamer2 = Gamer1("Andy", 100)
print(gamer1 == gamer2)
}
//false

若是改用 data class ,因為是用屬性的值來比較,所以兩個實例若有相同的屬性,則兩個類別相比較就會是相等。

fun main(){
val gamer1 = Gamer("Andy", 100)
val gamer2 = Gamer("Andy", 100)
print(gamer1 == gamer2)
}
//true

copy()

除了覆寫 Any 類的函數之外,資料類別也提供了一個方便的函 數 copy

利用 copy 函數,我們可以將一個實例複製一份,若有需要修改的屬性,可以在引數列修改即可。

例如:

fun main(){
var gamer1 = Gamer("Andy", 100)
var gamer2 = gamer1.copy(name = "Alex")
}
println(gamer1)
println(gamer2)
//Gamer(name=Andy, health=100)
//Gamer(name=Alex, health=100)

解構宣告 (Destructuring Declarations)

資料類別還支援解構宣告,什麼是解構宣告呢?以下面的範例解釋:

fun main(){
var (name, health) = Gamer("Andy", 100)
}

在資料類別的左方用括弧包起來,並按照 data class 屬性順序填入變數名稱, data class 就會自動將相對應的值傳入變數中。

如上例:name 為 Andy,health 為 100。

因為資料類別為自動產生主建構函數的屬性增加對應的元件函數。

如下:

data class Gamer (val name:String, val health: Int){
operator fun component1() = name //自動產生
operator fun component2() = health //自動產生
}

注意到了嗎?這邊是用 operator 關鍵字來定義解構函數,所以在其他的類別同樣也可以使用 operator 來定義解構函數。

我們可以將原本的資料類別加上一個解構函數。

data class Gamer (val name:String, val health: Int){
operator fun component3() = 10
}
fun main(){
var (name, health, point) = Gamer("Andy", 100)
}
//point = 10

資料類別需要注意的事:

  1. 至少需要一個帶一個參數的主建構函數。
  2. 主建構函數的參數必須要是 val 或是 var
  3. 不能使用 abstractopensealedinner 修飾符。

列舉類別 (Enuerated class)

簡稱「enum」,用來定義常數集合的特殊類別。

class 前方加上 enum 關鍵字,該類別即成為列舉類別。

enum class Direction{
NORTH,
EAST,
SOUTH,
WEST
}

可以直接用 . 引用列舉類別。

Direction.NORTH

每一個列舉類別的元素都包含兩個函數:name、oridinal

Direction.NORTH.name //NORTH
Direction.NORTH.oridinal //0

我們也可以替列舉類別增加主建構函數,

enum class Direction(val short_name:String){
NORTH("N"),
EAST("E"),
SOUTH("S"),
WEST("W");
}
Direction.NORTH.short_name //N

當用 when 判斷列舉類別時,若沒有將所有條件寫上,編譯器將會發出警告。

fun showDirection(direction: Direction){
when(direction){
Direction.NORTH -> TODO()
Direction.EAST -> TODO()
Direction.SOUTH -> TODO()
Direction.WEST -> TODO()
}
}
  • 將所有列舉類別的元素填入即可,或是用 eles branch 代表未加入的所有元素。

運算子重載 (Operator overloading)

要將內建運算子應用至自訂類別,必須先覆寫運算子函數,告訴編譯器要如何操作自訂類別。

data class Counter(val dayIndex: Int) {
operator fun plus(increment: Int): Counter {
return Counter(dayIndex + increment)
}
}
println(Counter(1)+2)
//Counter(dayIndex=3)

密封類別 (Sealed class)

在列舉類別中,若在 when 判斷式中沒有將所有的列舉類別元素填入,那麼編譯器將會發出警告。

密封類別也具有相同的效果,與列舉類別不同的是,針對不同的元素,可以有不同的建構子。

如下例:密封類別 StudentStatus 中,有三個類別,其中 Active 的建構子有一個參數,其餘兩個皆沒有。

sealed class StudentStatus{
object NotEnrolled :StudentStatus()
class Active(val courseId: String) : StudentStatus()
object Graduated: StudentStatus()
}
fun main(){
val student = Student(StudentStatus.Active("Kotlin101"))
studentMessage(student.status)
}
fun studentMessage(status: StudentStatus):String{
return when(status){
is StudentStatus.NotEnrolled -> "Please choose a churse!"
is StudentStatus.Active -> "You are enrolled in: ${status.courseId}"
is StudentStatus.Graduated -> "Congratulations!"
}
}

如果本篇文章有幫助到您,請 👏🏻鼓勵我。

謝謝 🙇🏻

參考

KotlinLang.org: Companion Objects

KotlinLang.org: Nested and Inner Classes

KotlinLang.org: Operator overloading

KotlinLang.org: @JvmStatic

--

--

Andy Lu

Android/Flutter developer, Kotlin Expert, like to learn and share.