程式設計人員 Kotlin 新手上路課程 5.2:一般

本程式碼研究室是程式設計課程 Kotlin 新手上路課程的一部分。使用程式碼研究室逐步完成程式碼課程後,您將能充分發揮本課程的潛能。視您的知識而定,您或許可以略過某些部分。本課程的適用對象為熟悉物件導向語言,且想要學習 Kotlin 的程式設計人員。

引言

本程式碼研究室將介紹一般類別、函式和方法,以及其在 Kotlin 中的運作方式。

本課程並非只建立一個範例應用程式,而是用來建立您的知識,但彼此之間互不相關,因此您可以熟悉自己熟悉的部分。許多產品是透過水族箱主題進行連結。如果您想看完整的水族箱故事,請參考程式設計程式 Kotlin 新手上路課程

須知事項

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

課程內容

  • 如何使用一般類別、方法和函式

執行步驟

  • 建立一般類別並新增限制條件
  • 建立inout類型
  • 建立一般函式、方法和擴充函式

一般簡介

Kotlin 和許多程式設計語言一樣,都有一般類型。一般類型可讓您使用一般類別,讓課程更有彈性。

假設您導入的 MyList 類別含有項目清單。如果您沒有通用,就必須為每種類型導入新版 MyList:一個用於 Double,一個用於 String,一個用於 Fish。您可以利用通用範本將清單設為通用,以便存放任何類型的物件。就像將類型設為可配合許多類型的萬用字元。

如要定義一般類型,請在類別名稱後方加上角括號 <T>。(您可以使用其他字母或較長的名稱,不過一般類型的慣例為 T)。

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

您可以將 T 視為一般類型。get() 的傳回類型為 T,而 addItem() 的參數類型為 T。當然,一般名單相當實用,因此 List 類別內建在 Kotlin 中。

步驟 1:建立類型階層

在這個步驟中,您會建立一些要在下一個步驟中使用的類別。子類別在先前的程式碼研究室中已經介紹,但以下提供簡短說明。

  1. 為了讓範例不會顯得雜亂,請在 src 下方建立新套件並呼叫 generics
  2. 在「一般」套件中,建立新的 Aquarium.kt 檔案。這樣您就能使用相同名稱重新定義相同名稱,而不發生衝突,因此這個程式碼研究室的其他程式碼則放入這個檔案中。
  3. 建立供水類型的類型階層。首先請將 WaterSupply 設為 open 類別,以便做為子類別。
  4. 新增布林值 var 參數 needsProcessing。這樣系統就會自動建立可變動的屬性,以及 getter 和 setter。
  5. 建立延伸 WaterSupplyTapWater 子類別,並傳遞 trueneedsProcessing,因為觸控水含有不適合魚類添加劑。
  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 不需要處理,但必須使用 filter() 方法篩選 LakeWater。篩選完畢後,您不需要重新處理,因此在 filter() 中設定 needsProcessing = false
class FishStoreWater : WaterSupply(false)

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

如需其他資訊,請參閱 Kotlin 中有關繼承的課程。

步驟 2:建立一般類別

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

  1. Aquarium.kt 中定義 Aquarium 類別,並在類別名稱後方加上括號。<T>
  2. Aquarium 中加入 waterSupply 類型的不可變更屬性 waterSupply
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.任何類型 (包括String) 可以傳入的限制。

  1. genericsExample() 中建立一個其他的 Aquarium,傳遞 nullwaterSupply。如果 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 後移除 ?,明確地將 Any 類型設為 T
class Aquarium<T: Any>(val waterSupply: T)

在這種情況下,Any 稱為一般限制條件。任何類型都可以傳送 T,前提是該類型不得為 null

  1. 您真正想要確保 T 只能傳遞一個 WaterSupply (或其其中一個子類別)。將 Any 替換為 WaterSupply 可定義較籠統的一般限制條件。
class Aquarium<T: WaterSupply>(val waterSupply: T)

步驟 4:新增更多檢查事項

在這個步驟中,您將瞭解 check() 函式如何確保程式碼正常運作。check() 函式是 Kotlin 中的標準程式庫函式。它作為聲明,如果其引數的評估為 false,它就滿於 IllegalStateException

  1. addWater() 方法新增至 Aquarium 類別以加入水,並利用 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() 中加入程式碼,以 LakeWater 建立 Aquarium 並加上一些水。
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:定義結束類型

  1. Aquarium 類別中,將 T: WaterSupply 變更為 out 類型。
class Aquarium<out T: WaterSupply>(val waterSupply: T) {
    ...
}
  1. 在同一個類別中,在類別外宣告 addItemTo() 函式。這個函式會預期 AquariumWaterSupply
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 類型

in 類型與 out 類型類似,但在一般類型中只能傳遞至函式而非傳回的一般類型。如果您嘗試傳回 in 類型,就會收到編譯器錯誤。在此範例中,您將會定義 in 類型做為介面的一部分。

  1. Aquarium.kt 中定義 Cleaner 介面,以便使用一般限制為 WaterSupply 的一般 T。由於這個參數只會做為 clean() 的引數使用,因此您可以將其設為 in 參數。
interface Cleaner<in T: WaterSupply> {
    fun clean(waterSupply: T)
}
  1. 要使用 Cleaner 接口,請製作一個 TapWaterCleaner 類,通過添加化碼器為 Cleaner 執行 TapWater 清塗。
class TapWaterCleaner : Cleaner<TapWater> {
    override fun clean(waterSupply: TapWater) =   waterSupply.addChemicalCleaners()
}
  1. Aquarium 類別中,更新 addWater() 以擷取 Cleaner 類型的 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 設為 Aquarium,然後使用 TapWater 來加入一些用水,再使用清潔劑操作。它會視需要使用清潔劑。
fun genericsExample() {
    val cleaner = TapWaterCleaner()
    val aquarium = Aquarium(TapWater())
    aquarium.addWater(cleaner)
}

Kotlin 會使用 inout 類型的資訊,確保您的程式碼安全無虞地使用一般用途。「Out」和「in」很容易記住:out 類型可以傳回傳回值,in 類型可以當做引數傳入。

如果您想進一步瞭解類型和排除類型的問題,請參閱說明文件,深入瞭解問題類型。

在這項工作中,您將瞭解通用函式的使用時機和使用時機。一般而言,每當函式接受一個具有一般類型的類別引數時,將為一般函式。

步驟 1:使用一般函式

  1. generics/Aquarium.kt 中,製作一個 isWaterClean() 函數,需要Aquarium。您必須指定參數的一般類型;其中一個選項是使用 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() 方法,將 R 限制為一般參數 R (T 已在使用中),如果 waterSupply 類型為 R,則傳回 true。這應該是您之前宣告的函式,但在 Aquarium 類別中。
fun <R: WaterSupply> hasWaterSupplyOfType() = waterSupply is R
  1. 請注意,最後一個 R 會以紅色底線標示。將滑鼠遊標移到指標上方,即可查看錯誤的定義。
  2. 如要進行 is 檢查,您必須告知 Kotlin 該類型為 reified (真實) 類型,且可在函式中使用。方法很簡單,只要在 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 即可。使用 star-投影語法是指定各種配對的便利方法。當您使用星號投影功能時,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 新手課程:歡迎參加這堂課程。