程式設計人員的 Kotlin 新手上路課程 5.2:泛型

這個程式碼研究室是「程式設計人員的 Kotlin 新手上路課程」的一部分。如果您按部就班完成程式碼研究室,就能充分體驗到本課程的價值。視您的知識多寡而定,您或許能略過某些部分。本課程適用於熟悉物件導向語言,且想學習 Kotlin 的程式設計師。

簡介

在本程式碼研究室中,您將瞭解泛型類別、函式和方法,以及這些項目在 Kotlin 中的運作方式。

本課程的設計目標是協助您累積知識,但各單元之間彼此半獨立,因此您可以略過熟悉的部分,不必建構單一範例應用程式。為將這些範例連結在一起,許多範例都使用水族館主題。如要查看完整的魚缸故事,請參閱 Udacity 的程式設計人員 Kotlin 新手上路課程

必備知識

  • Kotlin 函式、類別和方法的語法
  • 如何在 IntelliJ IDEA 中建立新類別並執行程式

課程內容

  • 如何使用泛型類別、方法和函式

學習內容

  • 建立泛型類別並新增限制
  • 建立 inout 類型
  • 建立泛型函式、方法和擴充功能函式

泛型簡介

Kotlin 與許多程式設計語言一樣,都有泛型。泛型可讓您製作泛型類別,進而大幅提升類別的彈性。

假設您要實作 MyList 類別,其中包含項目清單。如果沒有泛型,您需要為每個型別實作新版 MyList:一個用於 Double、一個用於 String,一個用於 Fish。使用泛型時,您可以將清單設為泛型,因此可以保留任何類型的物件。這就像將型別設為可適用於多種型別的萬用字元。

如要定義泛型,請在類別名稱後方加上尖括號 <T>,並在尖括號中放入 T。(您可以使用其他字母或較長的名稱,但泛型型別的慣例是 T)。

class MyList<T> {
    fun get(pos: Int): T {
        TODO("implement")
    }
    fun addItem(item: T) {}
}

您可以參照 T,就像參照一般型別一樣。get() 的傳回類型為 T,而 addItem() 的參數類型為 T。當然,泛型清單非常實用,因此 Kotlin 內建 List 類別。

步驟 1:建立型別階層

在本步驟中,您會建立一些類別,以便在下一個步驟中使用。我們在先前的程式碼研究室中介紹過子類別,但這裡會簡要複習。

  1. 為保持範例簡潔,請在 src 下建立名為 generics 的新套件。
  2. generics 套件中,建立新的 Aquarium.kt 檔案。這樣一來,您就能使用相同名稱重新定義項目,不會發生衝突,因此本程式碼研究室的其餘程式碼都會寫入這個檔案。
  3. 建立供水類型階層。首先,請將 WaterSupply 設為 open 類別,以便加入子類別。
  4. 新增布林值 var 參數 needsProcessing。這會自動建立可變動的屬性,以及 getter 和 setter。
  5. 建立擴充 WaterSupply 的子類別 TapWater,並傳遞 true 做為 needsProcessingneedsProcessing,因為自來水含有對魚有害的添加物。
  6. TapWater 中,定義名為 addChemicalCleaners() 的函式,在淨水後將 needsProcessing 設為 falseneedsProcessing 屬性可從 TapWater 設定,因為該屬性預設為 public,且可供子類別存取。以下是已完成的程式碼。
package generics

open class WaterSupply(var needsProcessing: Boolean)

class TapWater : WaterSupply(true) {
   fun addChemicalCleaners() {
       needsProcessing = false
   }
}
  1. 建立 WaterSupply 的另外兩個子類別,分別命名為 FishStoreWaterLakeWaterFishStoreWater 不需要處理,但 LakeWater 必須使用 filter() 方法進行篩選。篩選後就不需要再次處理,因此請在 filter() 中設定 needsProcessing = false
class FishStoreWater : WaterSupply(false)

class LakeWater : WaterSupply(true) {
   fun filter() {
       needsProcessing = false
   }
}

如需更多資訊,請參閱先前的 Kotlin 繼承課程。

步驟 2:建立泛型類別

在這個步驟中,您會修改 Aquarium 類別,以支援不同類型的供水。

  1. Aquarium.kt 中定義 Aquarium 類別,並在類別名稱後方的方括號中加入 <T>
  2. 將類型為 T 的不可變動屬性 waterSupply 新增至 Aquarium
class Aquarium<T>(val waterSupply: T)
  1. 編寫名為 genericsExample() 的函式。這不是類別的一部分,因此可以放在檔案頂層,就像 main() 函式或類別定義一樣。在函式中建立 Aquarium,並傳遞 WaterSupply。由於 waterSupply 參數是泛型,因此您必須在角括號 <> 中指定型別。
fun genericsExample() {
    val aquarium = Aquarium<TapWater>(TapWater())
}
  1. genericsExample() 中,程式碼可以存取水族館的 waterSupply。由於屬於 TapWater 類型,因此您可以呼叫 addChemicalCleaners(),不必進行任何類型轉換。
fun genericsExample() {
    val aquarium = Aquarium<TapWater>(TapWater())
    aquarium.waterSupply.addChemicalCleaners()
}
  1. 建立 Aquarium 物件時,您可以移除角括號和括號之間的內容,因為 Kotlin 具有型別推論功能。因此建立執行個體時,不需要說兩次「TapWater」。系統可以從 Aquarium 的引數推斷類型,但仍會建立 TapWater 類型的 Aquarium
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    aquarium.waterSupply.addChemicalCleaners()
}
  1. 如要查看發生了什麼情況,請在呼叫 addChemicalCleaners() 前後輸出 needsProcessing。以下是完成的函式。
fun genericsExample() {
    val aquarium = Aquarium<TapWater>(TapWater())
    println("water needs processing: ${aquarium.waterSupply.needsProcessing}")
    aquarium.waterSupply.addChemicalCleaners()
    println("water needs processing: ${aquarium.waterSupply.needsProcessing}")
}
  1. 新增 main() 函式來呼叫 genericsExample(),然後執行程式並觀察結果。
fun main() {
    genericsExample()
}
⇒ water needs processing: true
water needs processing: false

步驟 3:提供更具體的資訊

「泛型」是指幾乎任何項目都可以傳遞,有時這會造成問題。在這個步驟中,您會讓 Aquarium 類別更具體地說明可放入的內容。

  1. genericsExample() 中建立 Aquarium,傳遞 waterSupply 的字串,然後輸出水族館的 waterSupply 屬性。
fun genericsExample() {
    val aquarium2 = Aquarium("string")
    println(aquarium2.waterSupply)
}
  1. 執行程式並觀察結果。
⇒ string

結果是您傳遞的字串,因為 Aquarium 不會對 T.Any 類型設下任何限制,包括 String 在內,任何類型都可以傳遞。

  1. genericsExample() 中建立另一個 Aquarium,並傳遞 null 做為 waterSupply。如果 waterSupply 為空值,則列印 "waterSupply is null"
fun genericsExample() {
    val aquarium3 = Aquarium(null)
    if (aquarium3.waterSupply == null) {
        println("waterSupply is null")
    }
}
  1. 執行程式並觀察結果。
⇒ waterSupply is null

為什麼建立 Aquarium 時可以傳遞 null?這是因為根據預設,T 代表可為空值的 Any? 型別,也就是型別階層頂端的型別。這與您先前輸入的內容相同。

class Aquarium<T: Any?>(val waterSupply: T)
  1. 如要禁止傳遞 null,請移除 Any 後方的 ?,明確將 T 設為 Any 類型。
class Aquarium<T: Any>(val waterSupply: T)

在此情況下,Any 稱為「泛型限制」。也就是說,只要不是 null,任何型別都可以傳遞至 T

  1. 您真正想做的是確保只有 WaterSupply (或其子類別之一) 可以傳遞至 T。將 Any 替換為 WaterSupply,定義更具體的泛型限制。
class Aquarium<T: WaterSupply>(val waterSupply: T)

步驟 4:新增更多檢查

在本步驟中,您將瞭解 check() 函式,確保程式碼行為符合預期。check() 函式是 Kotlin 中的標準程式庫函式。這項函式會做為判斷提示,如果引數的計算結果為 false,就會擲回 IllegalStateException

  1. Aquarium 類別中新增 addWater() 方法來加水,並使用 check() 確保不必先處理水。
class Aquarium<T: WaterSupply>(val waterSupply: T) {
    fun addWater() {
        check(!waterSupply.needsProcessing) { "water supply needs processing first" }
        println("adding water from $waterSupply")
    }    
}

在這種情況下,如果 needsProcessing 為 true,check() 就會擲回例外狀況。

  1. genericsExample() 中,加入程式碼來製作 Aquarium,然後加水。LakeWater
fun genericsExample() {
    val aquarium4 = Aquarium(LakeWater())
    aquarium4.addWater()
}
  1. 執行程式,您會收到例外狀況,因為水必須先經過濾。
⇒ Exception in thread "main" java.lang.IllegalStateException: water supply needs processing first
        at Aquarium.generics.Aquarium.addWater(Aquarium.kt:21)
  1. 先呼叫函式來過濾水,再將水加入 Aquarium。現在執行程式時,不會擲回例外狀況。
fun genericsExample() {
    val aquarium4 = Aquarium(LakeWater())
    aquarium4.waterSupply.filter()
    aquarium4.addWater()
}
⇒ adding water from generics.LakeWater@880ec60

上述內容涵蓋泛型的基本概念。下列工作涵蓋更多內容,但重要概念是瞭解如何使用泛型限制條件宣告及使用泛型類別。

在這項工作中,您將瞭解泛型中的「輸入」和「輸出」型別in 類型只能傳遞至類別,無法傳回。out 型別是只能從類別傳回的型別。

查看 Aquarium 類別,您會發現只有在取得 waterSupply 屬性時,才會傳回泛型型別。沒有任何方法會將 T 型別的值做為參數 (在建構函式中定義除外)。Kotlin 可讓您為這種情況定義 out 型別,並推斷型別安全使用的位置相關額外資訊。同樣地,您也可以為只會傳遞至方法 (不會傳回) 的泛型類型定義 in 類型。這樣 Kotlin 就能對程式碼安全性進行額外檢查。

inout 類型是 Kotlin 類型系統的指令。說明整個型別系統超出本訓練營的範圍 (相當複雜),但編譯器會適當標記未標示 inout 的型別,因此您必須瞭解這些型別。

步驟 1:定義 out 型別

  1. Aquarium 類別中,將 T: WaterSupply 變更為 out 類型。
class Aquarium<out T: WaterSupply>(val waterSupply: T) {
    ...
}
  1. 在同一個檔案中,於類別外部宣告函式 addItemTo(),該函式預期 WaterSupplyAquarium
fun addItemTo(aquarium: Aquarium<WaterSupply>) = println("item added")
  1. genericsExample() 呼叫 addItemTo(),然後執行程式。
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    addItemTo(aquarium)
}
⇒ item added

Kotlin 可確保 addItemTo() 不會對泛型 WaterSupply 執行任何型別不安全的動作,因為它已宣告為 out 型別。

  1. 如果移除 out 關鍵字,編譯器會在呼叫 addItemTo() 時傳回錯誤,因為 Kotlin 無法確保您不會對該型別執行任何不安全的動作。

步驟 2:定義輸入型別

in 類型與 out 類型類似,但適用於只會傳遞至函式,不會傳回的泛型。如果您嘗試傳回 in 型別,就會收到編譯器錯誤。在本範例中,您將定義 in 型別做為介面的一部分。

  1. Aquarium.kt 中,定義採用泛型 TCleaner 介面,該泛型會限制為 WaterSupply。由於這只會做為 clean() 的引數,因此您可以將其設為 in 參數。
interface Cleaner<in T: WaterSupply> {
    fun clean(waterSupply: T)
}
  1. 如要使用 Cleaner 介面,請建立實作 CleanerTapWaterCleaner 類別,以便加入化學物質來清潔 TapWater
class TapWaterCleaner : Cleaner<TapWater> {
    override fun clean(waterSupply: TapWater) =   waterSupply.addChemicalCleaners()
}
  1. Aquarium 類別中,更新 addWater() 以取得 T 類型的 Cleaner,並在加入前先清潔水。
class Aquarium<out T: WaterSupply>(val waterSupply: T) {
    fun addWater(cleaner: Cleaner<T>) {
        if (waterSupply.needsProcessing) {
            cleaner.clean(waterSupply)
        }
        println("water added")
    }
}
  1. 更新 genericsExample() 範例程式碼,建立 TapWaterCleaner、含 TapWaterAquarium,然後使用清潔劑加水。並視需要使用清潔劑。
fun genericsExample() {
    val cleaner = TapWaterCleaner()
    val aquarium = Aquarium(TapWater())
    aquarium.addWater(cleaner)
}

Kotlin 會使用 inout 型別資訊,確保您的程式碼安全地使用泛型。Outin 很容易記住:out 型別可做為傳回值向外傳遞,in 型別可做為引數向內傳遞。

如要進一步瞭解類型內和類型外的問題,請參閱說明文件。

在本工作中,您將瞭解泛型函式和使用時機。一般來說,只要函式採用具有泛型類型的類別引數,就適合建立泛型函式。

步驟 1:建立泛型函式

  1. generics/Aquarium.kt 中,建立採用 AquariumisWaterClean() 函式。您需要指定參數的泛型型別,其中一個選項是使用 WaterSupply
fun isWaterClean(aquarium: Aquarium<WaterSupply>) {
   println("aquarium water is clean: ${aquarium.waterSupply.needsProcessing}")
}

但這表示 Aquarium 必須有 out 型別參數,才能呼叫。有時 outin 的限制過多,因為您需要同時使用輸入和輸出類型。您可以將函式設為泛型,移除 out 需求。

  1. 如要將函式設為泛型,請在關鍵字 fun 後方加上角括號,並加入泛型 T 和任何限制,在本例中為 WaterSupply。將 Aquarium 變更為受 T 限制,而非 WaterSupply
fun <T: WaterSupply> isWaterClean(aquarium: Aquarium<T>) {
   println("aquarium water is clean: ${!aquarium.waterSupply.needsProcessing}")
}

TisWaterClean() 的型別參數,用於指定水族館的一般型別。這個模式非常常見,建議您花點時間瞭解。

  1. 在函式名稱後方和括號前方,以角括號指定型別,即可呼叫 isWaterClean() 函式。
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    isWaterClean<TapWater>(aquarium)
}
  1. 由於引數 aquarium 的型別推論,不需要型別,因此請移除。執行程式並觀察輸出內容。
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    isWaterClean(aquarium)
}
⇒ aquarium water is clean: false

步驟 2:使用具體化型別建立泛型方法

您也可以將泛型函式用於方法,即使是在有自己泛型型別的類別中也一樣。在這個步驟中,您要將泛型方法新增至 Aquarium,檢查是否為 WaterSupply 型別。

  1. Aquarium 類別中,宣告方法 hasWaterSupplyOfType(),該方法會採用受限於 WaterSupply 的泛型參數 R (已使用 T),並在 waterSupply 屬於 R 型別時傳回 true。這類似於您先前宣告的函式,但位於 Aquarium 類別中。
fun <R: WaterSupply> hasWaterSupplyOfType() = waterSupply is R
  1. 請注意,最後的 R 會加上紅色底線。將指標懸停在該圖示上,即可查看錯誤內容。
  2. 如要執行 is 檢查,您需要告知 Kotlin 型別是「具體化」或實際型別,且可在函式中使用。如要這麼做,請在 fun 關鍵字前面加上 inline,並在泛型 R 型別前面加上 reified
inline fun <reified R: WaterSupply> hasWaterSupplyOfType() = waterSupply is R

類型具體化後,您就可以像使用一般類型一樣使用,因為內嵌後就是實際類型。也就是說,您可以使用型別執行 is 檢查。

如果您未使用 reified,Kotlin 就不會允許 is 檢查,因為類型「不夠真實」。這是因為非具體化型別僅在編譯階段可用,無法在執行階段供程式使用。我們會在下一節中進一步討論。

  1. 傳遞 TapWater 做為型別。與呼叫泛型函式類似,呼叫泛型方法時,請在函式名稱後方使用角括號和型別。執行程式並觀察結果。
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    println(aquarium.hasWaterSupplyOfType<TapWater>())   // true
}
⇒ true

步驟 3:建立擴充功能函式

您也可以對一般函式和擴充函式使用具體化型別。

  1. Aquarium 類別外部,針對 WaterSupply 定義名為 isOfType() 的擴充功能函式,檢查傳遞的 WaterSupply 是否為特定型別,例如 TapWater
inline fun <reified T: WaterSupply> WaterSupply.isOfType() = this is T
  1. 呼叫擴充功能函式,就像呼叫方法一樣。
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    println(aquarium.waterSupply.isOfType<TapWater>())  
}
⇒ true

只要是 Aquarium,無論是哪種型別 (AquariumTowerTank 或其他子類別),都可以使用這些擴充函式。Aquarium使用星號投影語法可輕鬆指定各種相符項目。使用星號投影時,Kotlin 也會確保您不會執行任何不安全的動作。

  1. 如要使用星號投影,請在 Aquarium 後方加上 <*>。將 hasWaterSupplyOfType() 移至擴充函式,因為它並非 Aquarium 核心 API 的一部分。
inline fun <reified R: WaterSupply> Aquarium<*>.hasWaterSupplyOfType() = waterSupply is R
  1. 將呼叫變更為 hasWaterSupplyOfType(),然後執行程式。
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    println(aquarium.hasWaterSupplyOfType<TapWater>())
}
⇒ true

在先前的範例中,您必須將泛型型別標示為 reified,並將函式設為 inline,因為 Kotlin 需要在執行階段 (而非僅在編譯階段) 瞭解這些型別。

Kotlin 只會在編譯時使用所有泛型。編譯器會藉此確保您安全地執行所有操作。所有泛型型別都會在執行階段遭到清除,因此先前才會出現有關檢查已清除型別的錯誤訊息。

結果顯示,編譯器可以建立正確的程式碼,而不必將泛型型別保留到執行階段。但這表示有時您會執行編譯器不支援的操作,例如對泛型型別進行 is 檢查。因此 Kotlin 新增了具體化或實際類型。

如要進一步瞭解具體化型別和型別抹除,請參閱 Kotlin 說明文件

本課程著重於泛型,這對於讓程式碼更具彈性且更容易重複使用非常重要。

  • 建立泛型類別,讓程式碼更具彈性。
  • 新增泛型限制,限制與泛型搭配使用的型別。
  • 搭配泛型使用 inout 型別,提供更完善的型別檢查,限制傳遞至類別或從類別傳回的型別。
  • 建立通用函式和方法,處理通用型別。例如:
    fun <T: WaterSupply> isWaterClean(aquarium: Aquarium<T>) { ... }
  • 使用泛型擴充功能函式,將非核心功能新增至類別。
  • 有時需要具體化型別,是因為型別抹除。與泛型不同,具體化型別會持續到執行階段。
  • 使用 check() 函式,確認程式碼是否正常執行。例如:
    check(!waterSupply.needsProcessing) { "water supply needs processing first" }

Kotlin 說明文件

如要進一步瞭解本課程的任何主題,或遇到問題,請前往 https://kotlinlang.org

Kotlin 教學課程

https://try.kotlinlang.org 網站提供豐富的教學課程 (稱為 Kotlin Koans)、網頁式解譯器,以及附有範例的完整參考文件。

Udacity 課程

如要查看這個主題的 Udacity 課程,請參閱「程式設計人員的 Kotlin 新手上路課程」。

IntelliJ IDEA

IntelliJ IDEA 的說明文件位於 JetBrains 網站。

本節列出的作業可由課程講師指派給學習本程式碼研究室的學員。講師可自由採取以下行動:

  • 視需要指派作業。
  • 告知學員如何繳交作業。
  • 為作業評分。

講師可以視需求使用全部或部分建議內容,也可以自由指派任何其他合適的作業。

如果您是自行學習本程式碼研究室,不妨利用這些作業驗收學習成果。

回答這些問題

第 1 題

下列何者為泛型類型的命名慣例?

<Gen>

<Generic>

<T>

<X>

第 2 題

對泛型允許的型別設下限制,稱為:

▢ 一般限制

▢ 一般限制

▢ 釐清

▢ 一般類型限制

第 3 題

具體化意義:

▢ 物件的實際執行影響已計算完畢。

▢ 類別已設定受限制的項目索引。

▢ 一般類型參數已轉換為實際類型。

▢ 觸發遠端錯誤指標。

繼續下一個課程:6. 函式操控

如要查看課程總覽,包括其他程式碼研究室的連結,請參閱「程式設計人員的 Kotlin 新手上路課程:歡迎參加本課程。