Java 入坑 Kotlin 必看 —— 類、物件和介面

amadan發表於2021-09-09
  • Kotlin 類、物件和介面

    Kotlin 的類和介面在概念上跟 Java 是一樣的,但是用法存在一些差別,比如繼承的寫法、建構函式和可見性修飾符的不同等,此外還有一些 Java 中沒有的概念,如資料類、密封類、委託和 object 關鍵字等。下面從類和介面的定義開始,感受一下 Kotlin 的非凡之處吧!

    類和介面的定義

    類與繼承和 open、final 以及 abstract 關鍵字

    跟 Java 一樣,Kotlin 使用 class 關鍵字來定義一個類。

    class Animal {
        fun eat() {
            ...
        }
        
        fun move() {
            ...
        }
    }
    複製程式碼

    在 Java 中,一個類除了被手動加上 final 關鍵字,它都能被任意一個類繼承並重寫它的非 final 方法,這就可能會導致某些子類出現不符合其父類的設計初衷,特別是在多人協作的開發環境下。

    這類問題被 Kotlin 語言設計者注意到了並切引起了他們的重視,因此,在 Kotlin 中的類和方法預設都是 final 的,如果要繼承或者重寫一個類和方法,必須將他們顯式地宣告為 open

    open class Animal {
        fun eat() {
            ...
        }
        
        open fun move() {
            ...
        }
    }
    複製程式碼

    繼承該類的時候,需要在類名後面加上冒號後再寫被繼承的類名,在 Kotlin 中使用冒號代替了 Java 中的 extend關鍵字。

    class Dog : Animal {
        override fun move() {
            ...
        }
    }
    複製程式碼

    同樣,Kotlin 中可以使用 abstract 關鍵字將一個類宣告稱抽象類,但它不能被例項化。抽象方法也可以覆蓋父類的 open 方法,抽象方法始終是 open 的且必須被子類實現。

    abstract class Bird : Animal {
        abstract fun song() 
        
        override abstract fun move()
    }
    複製程式碼

    介面

    Kotlin 中的介面同樣使用 interface 關鍵字來定義,可以在方法中加入預設的方法體。

    interface Machine {
        fun component()
        fun control() {
            ...
        }
    }
    複製程式碼

    與類的繼承類似,實現/繼承介面方法是在類名/介面名後面加上冒號再寫被實現/繼承的介面名。

    實現介面:

    class Electric : Machine {
        override fun component() {
            ...
        }
        
        override fun control() {
            ...
        }
    }
    複製程式碼

    繼承介面:

    interface Computer : Machine {
        fun IODevice()
    }
    複製程式碼

    可見性修飾符

    可見性修飾符用於宣告一個類或者介面的可見範圍,類似於 Java,Kotlin 中使用 publicprivateprotected 關鍵字作為可見性修飾符。跟 Java 不同的是,Kotlin 預設的可見性是 public 的,並且沒有 “包私有” 的概念,同時新增 internal 關鍵字用來表示 “模組內部可見“。

    public

    public 是 kotlin 預設的可見性修飾符,表示任何地方可見。

    private

    • 如果使用 private 宣告一個類成員,則表示該類成員只在類中可見;
    • 如果使用 private 對一個類進行宣告,則表示這個類只在宣告該類中的檔案中可見,除此之外,Kotlin 還有頂層函式和頂層屬性的概念,如果用 private 宣告頂層函式或者頂層屬性,同樣也只能在宣告它的檔案中對其可見。

    protected

    • 使用 protected 宣告的類成員除了跟 private 一樣,還夠對子類可見;
    • protected 不適用於頂層宣告。

    internal

    使用 internal 修飾符表示只在模組內部可見。

    一個模組就是一組一起編譯的 Kotlin 檔案。這有可能是一個 Intellij IDEA 模組、一個 Eclipse 專案、一個 Maven 或 Gradle 專案或者一組使用呼叫 Ant 任務進行編譯的檔案。

    類的構造方法(函式)

    Kotlin 將構造方法分為了主構造方法和次構造方法,主構造方法作為類頭在類體外部宣告,次構造方法在類體內部宣告。

    主構造方法

    主構造方法作為類頭的一部分存在,它跟在類名(與可選的型別引數)後。

    class Animal constructor(name: String) {
        ...
    }
    複製程式碼

    如果主構造方法沒有可見性修飾符或者註解,則可以省略 constructor 關鍵字。

    class Animal(name: String) {
        ...
    }
    複製程式碼

    主構造方法中不能包含其它任何程式碼,因此,如果需要初始化程式碼,則需要把這一部分程式碼放在以 init 關鍵字作為字首的**初始化塊(initializer blocks)**中。

    class Animal(name: String) {
        init {
             val outPutName = "It's the Animal call: $name"
        }
    }
    複製程式碼

    如果類中的某個屬性使用主構造方法的引數來進行初始化,可以通過使用 val 關鍵字對主建構函式的引數進行修飾,以簡化程式碼。

    class Animal(val name: String) {
        init {
             val outPutName = "It's the Animal call: $name"
        }
        
    
        fun showName() {
            println(name)
        }
    }
    複製程式碼

    次構造方法

    次構造方法在類體內使用 constructor 關鍵字進行定義,如果類有一個主構造方法,每個次構造方法需要委託給主構造方法, 可以直接委託或者通過其它次構造方法間接委託。委託到同一個類的另一個構造方法用 this 關鍵字即可。

    class Animal(val name: String) {
        init {
             val outPutName = "It's the Animal call: $name"
        }
        
        // 直接委託給主構造方法
        constructor(name: String, age: Int): this(name) {
            ...
        }
        
        // 通過上面的構造方法間接委託給主構造方法
        constructor(name: String, age: Int, type: Int): this(name, age) {
            ...
        }
    }
    複製程式碼

    初始化塊中的程式碼實際上會成為主建構函式的一部分。委託給主建構函式會作為次建構函式的第一條語句,因此所有初始化塊中的程式碼都會在次建構函式體之前執行。即使該類沒有主建構函式,這種委託仍會隱式發生,並且仍會執行初始化塊。

    內部類和巢狀類

    巢狀類

    在一個類內部定義的另外一個類預設為巢狀類。

    class OuterClz {
        var num = 1
    
        class NestedClz {
            fun show() {
                // 編譯報錯
                println(num)
            }
        }
    }
    複製程式碼

    巢狀類不持有它所在外部類的引用。

    內部類

    在 class 關鍵字前面加上 inner 關鍵字則定義了一個內部類。

    class OuterClz {
        var num = 1
    
        inner class InnerClz {
            fun show() {
                // 編譯通過
                println(num)
            }
        }
    }
    複製程式碼

    內部類持有了它所在外部類的引用,因此可以訪問外部類的成員。

    與 Java 對比

    在 Java 中靜態內部類是不會持有外部類引用的,相當於 Kotlin 的巢狀類;而非靜態內部類則持有外部類的引用,相當於 Kotlin 的內部類。

    列舉類

    有時候為了型別安全,需要將某個屬性所有可能的值列舉出來,開發者只能使用該列舉類中定義的列舉常量。

    列舉類的定義

    定義一個列舉類需要用到 enumclass 關鍵字。

    enum class Color {
    	RED, GREEN, BLUE
    }
    複製程式碼

    與 Java 相同,列舉類中可以宣告屬性和方法。

    enum class Color(val r: Int, val g: Int, val b: Int) {
        RED(255, 0, 0),
        GREEN(0, 255, 0),
        BLUE(0, 0, 255);
    
        fun rgb () = Integer.toHexString((r * 256 + g) * 256 + b)
    }
    複製程式碼

    當宣告列舉常量的時候,需要提供該常量所需的屬性值,並且需要在宣告完成後加上分號

    列舉類的使用

    Kotlin 中的 when 可以使用任何物件,因此,可以使用 when 表示式來判斷列舉型別。

    var myColor = Color.RED
    
    when (myColor) {
        Color.RED -> println("It's a red color")
        Color.GREEN -> println("It's a green color")
        Color.BLUE -> println("It's a blue color")
    }
    複製程式碼

    需要注意的是,如果在 when 表示式中沒有 case 到所有的列舉常量,編譯器並不會報錯。

    var myColor = Color.RED
    
    when (myColor) {
        Color.RED -> println("It's a red color")
        Color.GREEN -> println("It's a green color")
    }
    複製程式碼

    但是會建議你處理所有可能的情況(新增 “BLUE” 分支或者 “else” 分支)。

    'when' expression on enum is recommended to be exhaustive, add 'BLUE' branch or 'else' branch instead

    密封類

    如果一個父類 Animal 只有兩個分別是 Bird 和 Dog 的子類,在 when 表示式中處理所有情況的時候如果程式碼寫成如下:

    open class Animal {
        fun doSomething() {
    
        }
    }
    
    class Bird(val name: String) : Animal()
    
    class Dog(val name: String) : Animal()
    
    fun main() {
        fun showName(animal: Animal) =
                when (animal) {
                    is Dog -> println("It's the dog name ${animal.name}")
                    is Bird -> println("It's the bird name ${animal.name}")
                }
    }
    複製程式碼

    這時編譯器會提示錯誤:必須加上 else 分組。

    extends_when_error

    這時候,如果想寫出簡潔的程式碼,密封類就派上用場了。

    密封類用來表示受限的類繼承結構:當一個值為有限集中的型別、而不能有任何其他型別時。在某種意義上,他們是列舉類的擴充套件:列舉型別的值集合也是受限的,但每個列舉常量只存在一個例項,而密封類的一個子類可以有可包含狀態的多個例項。

    密封類的定義

    密封類的定義需要在類名前面加上 sealed 關鍵字。

    sealed class Animal
    複製程式碼

    使用密封類的時候需要注意幾點:

    • 密封類是具有 open 屬性的,子類可以直接繼承它;
    • 密封類的直接子類必須在與它自身相同的檔案中定義(Kotlin 1.1 之前子類的定義被限制在密封類的內部),但是密封類的間接子類可以放在任何位置;
    • 密封類是抽象的,意味著它不能被例項化並且可以擁有抽象成員;
    • 密封類的構造方法預設為 private 並且不允許擁有非 private 構造方法。

    密封類的使用

    使父類變成密封類之後,意味著對可能建立的子類做出了限制,when 表示式中處理了所有 Animal 類的子類的情況,因此不需要 “else” 分支。

    sealed class Animal {
        fun doSomething() {
    
        }
    }
    
    class Bird(val name: String) : Animal()
    
    class Dog(val name: String) : Animal()
    
    fun main() {
        fun showName(animal: Animal) =
                when (animal) {
                    is Dog -> println("It's the dog name ${animal.name}")
                    is Bird -> println("It's the bird name ${animal.name}")
                }
    }
    複製程式碼

    當你為 Animal 類新增一個新的子類且沒有修改 when 表示式內容的時候的時候,IDE 會編譯報錯提醒你沒有覆蓋所有情況。

    sealed_class_when_error

    資料類

    使用 Java 的時候,不免會需要一些 Entity 類來承載資料,有時候還需要重寫 toString、equals 或者 hashCode 方法,而這些方法的寫法千遍一律,有些 IDE 還能夠自動生成這些方法。但是 Kotlin 中的資料類能夠很好地避免這些情況,使程式碼看起來更加簡潔。

    資料類的定義

    資料類的定義需要在類名前面加上 data 關鍵字。

    data class User(val name: String, val gender: Int, val age: Int)
    複製程式碼

    資料類的定義必須保證以下條件:

    • 主構造方法至少又一個引數,且引數必須標記為 var 或者 val;
    • 資料類不能是抽象、開放、密封或者內部的。

    資料類的使用

    資料類定義完成之後,編譯器自動從主建構函式中宣告的所有屬性匯出以下成員:

    1. equals() 和 hashCode() 方法:

      通常用於物件例項之間的比較。

    2. toString() 方法:

      fun main() {
          var user = User("guanpj", 1, 18)
          println(user.toString())
      }
      複製程式碼

      輸出結果為:User(name=guanpj, gender=1, age=18)

    3. componentN 函式:

      componentN 稱為解構函式,簡單來說就是把一個物件解構成多個變數以便使用。在 data 類中如果有 N 個變數,則編譯器會生成 N 個解構函式(component1、component2 ... componentN)按順序對應這 N 個變數。使用方法如下:

      fun main() {
          var user = User("guanpj", 1, 18)
          var (name, gender, age) = user
          println("My name is $name and I'm $age years old.")
      }
      複製程式碼

      輸出結果:My name is guanpj and I'm 18 years old.

      需要注意的是,data 類中的這些 componentN 函式不允許提供顯式實現。

    4. copy() 函式:

      使用 copy() 函式能夠生成一個與該物件具有相同屬性的物件,並且可以修改部分屬性。

      fun main() {
          var user = User("guanpj", 1, 18)
          var newUser = user.copy("gpj")
          println("My name is ${newUser.name} and I'm ${newUser.age} years old.")
      }
      複製程式碼

      輸出結果為:My name is gpj and I'm 18 years old.

      同樣,copy() 函式也不允許提供顯式實現。

    委託

    雖然 ”委託“ 這個設計思想在各個程式語言中都或多或少地有所體現,但是 Kotlin 直接在語法上對 委託模式做了支援,Kotlin 支援類層面的委託和屬性的委託,下面分別從這個兩個方面講解委託模式在 Kotlin 中的使用。

    類委託

    前面已經提到過,Kotlin 在設計之初就考慮到因繼承帶來的 “脆弱的基類” 問題,因此把類預設視作 final 型別的,當需要擴充套件某些類的時候,手動將它們標記成 open 並且在擴充套件的過程中注意相容性。

    假設你有一個需求,需要統計一個集合新增元素的次數,你使用一個 CountingSet 來實現 MutableCollection 介面,並擴充套件了 add() 和 addAll() 方法,其他方法直接交給成員變數 innerSet 來處理。

    class CountingSet<T>() : MutableCollection<T>  {
        val innerSet: MutableCollection<T> = HashSet<T>()
    
        var objectsAdded = 0
    
        override fun add(element: T) : Boolean {
            objectsAdded++
            return innerSet.add(element)
        }
    
        override fun addAll(c: Collection<T>): Boolean {
            objectsAdded += c.size
            return innerSet.addAll(c)
        }
    
        override val size: Int
            get() = innerSet.size
    
        override fun contains(element: T): Boolean  = innerSet.contains(element)
    
        override fun containsAll(elements: Collection<T>): Boolean = 				       innerSet.containsAll(elements)
    
        override fun isEmpty(): Boolean = innerSet.isEmpty()
    
        override fun clear() = innerSet.clear()
    
        override fun iterator(): MutableIterator<T> = innerSet.iterator()
    
        override fun remove(element: T): Boolean = innerSet.remove(element)
    
        override fun removeAll(elements: Collection<T>): Boolean = innerSet.removeAll(elements)
    
        override fun retainAll(elements: Collection<T>): Boolean = innerSet.retainAll(elements)
    }
    複製程式碼

    可以看到,除了 add() 和 addAll() 方法,其他所有的方法都需要重寫並交給 innerSet 去實現,這樣勢必會產生比較多的模板程式碼,使用類委託便可以很好地解決這個問題。

    class CountingSet<T>(val innerSet: MutableCollection<T> = HashSet<T>()) 
        : MutableCollection<T> by innerSet {
        
        var objectsAdded = 0
    
        override fun add(element: T) : Boolean {
            objectsAdded++
            return innerSet.add(element)
        }
    
        override fun addAll(c: Collection<T>): Boolean {
            objectsAdded += c.size
            return innerSet.addAll(c)
        }
    }
    複製程式碼

    通過在超型別列表中使用 by 關鍵字進行委託,編譯器將會生成轉發給 innerSet 的所有 MutableCollection 中的方法,如果有覆蓋方法,編譯器將使用覆蓋的方法而不是委託物件中的方法。

    屬性委託

    同樣,一個屬性也可以將它的訪問器邏輯委託給一個輔助物件,屬性委託的寫法為:

    val/var <屬性名>: <型別> by <表示式>

    程式碼表示如下:

    class MyClz {
        var p: String by Delegate("abc")
    }
    複製程式碼

    對於 val 和 var 型別的屬性來說,它的 get()(和 set())方法將會被委託給 Delegate 類的 getter()(和 setter)方法。

    class Delegate<T>(default: T) {
        private var value = default
    
        operator fun getValue(thisRef: Any?, property: KProperty<*>): T {
            return value
        }
    
        operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
            this.value = value
        }
    }
    複製程式碼

    使用屬性委託需要注意:

    1. 對於一個 val (只讀) 屬性,委託類必須提供一個名為 getValue 的方法,並使用 operator 關鍵字修飾,該方法接收以下引數:
      • thisRef —— 必須與 屬性所有者 型別(對於擴充套件屬性——指被擴充套件的型別)相同或者是它的超型別;
      • property —— 必須是型別 KProperty<*> 或其超型別。
    2. 對於一個 var(可讀寫) 屬性,委託類必須額外提供一個名為 setValue 的方法,並使用 operator 關鍵字修飾,該方法接收以下引數:
      • thisRef —— 同上;
      • property —— 同上;
      • new value —— 必須與屬性同型別或者是它的超型別。

    另外,Kotlin 提供了 ReadOnlyPropertyReadWriteProperty 介面以方便實現 val 和 var 屬性的委託,只需實現這兩個介面並重寫它的方法即可。

    class Delegate<T>(default: T) : ReadWriteProperty<Any?, T> {
        private var value = default
    
        override fun getValue(thisRef: Any?, property: KProperty<*>): T {
            return value
        }
    
        override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
            this.value = value
        }
    }
    複製程式碼

    物件——object

    Kotlin 中的 object 被視為物件,但是跟物件導向中的 ”物件“ 卻不太一樣,它的功能非常強大,它可以定義一個單例、實現類似 Java 中的靜態方法的功能以及建立匿名內部類,Kotlin 中的 object 是擁有某個具體狀態的例項,出事化後不會再改變。

    物件宣告

    應該很多人跟我一樣,剛從 Java 轉過 Kotlin 的時候都會遇到一個疑惑:Kotlin 中是怎樣定義單例的?事實上,通過物件宣告,在 Kotlin 中定義單例簡直易如反掌,通過 object 關鍵字,可以定義一個物件宣告。

    object DataManager {
        val data: Set<Person> = setOf()
    
        fun doSomething() {
            for (person in data) {
    			...
            }
        }
    }
    複製程式碼

    與變數一樣,物件宣告允許你使用物件名加.字元的方式來呼叫方法和訪問屬性:

    fun main() {
        DataManager.data
        DataManager.doSomething()
    }
    複製程式碼

    物件宣告具有以下特點:

    1. 物件宣告的初始化過程是執行緒安全的;
    2. 個物件宣告也可以包含屬性、方法、初始化語句塊等的宣告;
    3. 物件宣告在定義的時候立即建立,因此不允許有任何構造方法;
    4. 物件宣告同樣可以繼承自類和介面。

    基於以上幾點,物件宣告完全符合單例模式的要求。

    伴生物件

    同樣的,從其他語言轉到 Kotlin 的程式設計師可能會遇到另外一個問題 —— Kotlin 中怎樣在一個類中定義一個靜態方法?在 Kotlin 中,如果想要直接通過容器類名稱來訪問這個物件的方法和屬性的能力,不再需要顯式地指明物件的名稱,就需要使用到伴生物件的概念了,伴生物件的定義如下:

    class MyClz {
        companion object {
            var myVariable = "My variable"
            
            fun doSomething() {
                ...
            }
        }
    }
    複製程式碼

    現在就可以像 Java 中呼叫靜態變數和靜態方法的方式一樣呼叫 myVariable 變數和 doSomething() 方法了!

    fun main() {
        MyClz.myVariable
        MyClz.doSomething()
    }
    複製程式碼

    與物件宣告 一樣,伴生物件也可以實現介面。

    interface MyInterface {
        fun doSomething()
    }
    
    class MyClz {
        companion object : MyInterface {
            override fun doSomething() {
                ...
            }
        }
    }
    
    fun main() {
        // MyClz 類的名字可以被當作 MyInterface 例項
        var myInstant: MyInterface = MyClz
        myInstant.doSomething()
    }
    複製程式碼

    物件表示式

    object 關鍵字不僅僅能用來宣告單例模式的物件,還能用來宣告匿名物件,與 Java 匿名內部類只能擴充套件一個類或實現一個介面不同 , Kotlin 的匿名物件可以實現多個介面或者不實現介面,我們稱之為物件表示式

    interface MyInterface {
        fun doSomething()
        fun doOtherthing()
    }
    
    val myInstant = object : MyInterface {
        override fun doSomething() {
            ...
        }
    
        override fun doOtherthing() {
            ...
        }
    }
    複製程式碼

    另外一個跟 Java 不同的點就是,物件表示式中可以直接訪問建立它的函式中的非 final 變數。

    class MyClz {
        var mVariable = "This is my variable."
    
        val myInstant = object : MyInterface {
            override fun doSomething() {
                println(mVariable)
            }
    
            override fun doOtherthing() {
            }
        }
    }
    複製程式碼

    小結

    • 使用物件宣告可以定義一個單例類;

    • 使用伴生物件可以實現類似 Java 中呼叫靜態變數和靜態方法的功能;

    • 物件宣告和伴生物件都可以實現介面;

    • 物件表示式可以作為 Java 中匿名內部類的替代品,並且使用起來更加方便。

    • 物件宣告是在第一次被訪問到時延遲初始化的,之後訪問不會被初始化、物件表示式是在每次使用到的時候立即初始化並執行的,每次都會建立一個新的物件、伴生物件是在相應的類被載入(解析)的時候初始化的。

相關文章