Kotlin 實戰範例 (8) 功能擴充、例外處理

Kotlin 實戰範例

Kotlin 的擴充功能為什麼比類別的繼承好;例外處理又是如何和 Java 不同?

8.1 功能擴充

Kotlin 提供一個功能擴充的機制,可以讓我們在原有的類別上增加新功能。以往我們有新功能的需求時,必須先繼承該類別,然後在這個子類別中建立新的功能。這樣的做法會讓結構變得不靈活,因為每繼承一次,就是對該類別的強藕合,並且會產生一個部分功能相同的新類別,在 Java 中為了解決這個問題,會使用 Decorator 設計模式 用來動態增加功能,但是這些解決方案都沒有 Kotlin 內建的擴充機制來的方便。

8.1.1 擴充函式

我們是針對類別來擴充,因此以類別做為接收型別 (receiver type),在類別名稱後面以 . 接上擴充函式的名稱並定義其內容,請看範例:

// 針對 Number 類別建立擴充函式 toDollarString()
fun Number.toDollarString(): String {
  return "$$this" // this 指 Number 本身所建立的物件
}

println(1.23.toDollarString()) // $1.23
println(100.toDollarString()) // $100

這個例子是擴充 Number 類別,它是 IntDouble 等等數字型別的父類別,針對它擴充的話,所有數字型別都會受影響。我們擴充的新功能叫做 toDollarString(),它會將數字轉換成含有錢字號 $ 開頭的字串,所以回傳型別為 String 。擴充函式裡的 this 會對應到接收物件,也就是在 . 前面的那個物件,例如本例中的 1.23;在字串模板中使用變數的值要寫成 "$this",但是我們還要在前面加上錢字號,最後結果就是 "$$this"

8.1.2 擴充函式依附在型別上

功能擴充實際上並沒有真的修改它所擴充的類別。當我們定義了一個擴充功能時,並沒有新增任何新成員到這個類別中,而只是針對這個型別實體化的物件新增了一個可以透過 . 來呼叫的新函式而已。

功能擴充是靜態地被解析,意即擴充函式是「由型別決定」,而不是由執行時期建立的物件決定,以下範例使用物件的方法 (執行時期建立) 來和擴充函式 (由型別決定) 比較,看看它們的差異:

open class A {
  // 物件的方法
  open fun hi() {
    println("Hi A")
  }
}
// 繼承 A 類別
class B : A() {
  // 物件的方法
  override fun hi() {
    println("Hi B")
  }
}
// A 類別的擴充函式
fun A.hello() {
  println("Hello A")
}
// B 類別的擴充函式
fun B.hello() {
  println("Hello B")
}
// 參數為 A 型別的函式 <== 注意它的型別
fun sayHello(a: A) {
  a.hi()  // 呼叫物件的方法
  a.hello() // 呼叫型別的擴充函式
}
fun main() {
  sayHello(A()) // 傳入 A 物件
  // Hi A
  // Hello A
  sayHello(B()) // 傳入 B 物件
  // Hi B
  // Hello A <== 注意這個
}

hi() 方法在編譯時期並不會被建立,而是直到 (執行時期) 使用 AB 類別產生物件時才建立;相對於擴充函式 hello() 則是在編譯時期就建立,因此它是「附加在這個型別」之上,也就是「認型別不認物件」。當我們建立 B() 物件並將其傳入 sayHello(a: A) 函式時 (由於 B 繼承 A,因此可以接受所有 A 類別的子類別的物件傳入),它的 a.hi() 是呼叫傳入物件 B 的方法,但是擴充函式根本不管它是哪個物件,而只看參數上的型別 (a: A) ,它就呼叫 A 型別的擴充函式。

8.1.3 成員函式優先

當擴充函式和成員函式 (方法) 名稱一樣時,以成員函式優先,擴充函式會被遮蔽,請看範例:

class A {
  // A 物件的方法
  fun hi() { 
    println("呼叫方法") 
  }
}
// A 型別的擴充函式
fun A.hi() {
  println("呼叫擴充函式") 
}
// 建立 A 物件並呼叫 hi()
A().hi()
// [結果] 呼叫方法

但是擴充函式可以對成員函式 (方法) 多載 (overloading),請看範例:

class A {
  fun hi() { 
    println("呼叫方法") 
  }
}
// 多載:函式名稱相同,但參數不同
fun A.hi(msg: String) { 
  println("$msg 呼叫擴充函式") 
}
// 建立 A 物件
val a = A()
a.hi()   // 呼叫方法
a.hi("Hi") // Hi 呼叫擴充函式

8.1.4 允許接收型別為 null

如果我們想讓擴充功能允許接收型別為 null,只要在接收型別後面加上問號 ? 即可,當然我們必須在函式內額外檢查 null,然後看是要丟出例外或是回傳預設值,請看範例:

// 只要在接收型別後面加上問號,擴充函式就允許 null
fun Number?.toDollarString(): String {
  return if (this == null) "0" else "$$this"
}
// 建立一個允許 null 的變數
var a : Int? = null
println(a.toDollarString()) // $0
a = 123
println(a.toDollarString()) // $123

8.1.5 擴充功能的使用動機

在寫 Java 的時候,我們常常會建立許多工具類別,像是字串工具或檔案工具等等,工具類別的用途是針對特定問題提供通用的解決方法,讓我們省去撰寫重覆的程式碼。Java 本身提供針對集合處理的工具 java.util.Collections,我們可以使用它來處理集合,示範如下:

/* Java */
import java.util.List;
import java.util.ArrayList;
import java.util.Collections;

public class Jpp {
  public static void main(String[] args) {
    List<String> names = new ArrayList<>();
    names.add("Tony");
    names.add("John");
    names.add("Tom");
    // 使用集合工具置換 John 和 Tom 的順序
    Collections.swap(names, 1, 2); // <== 注意這裡的用法
  }
}

其實使用上沒什麼問題,但是我們想要更簡單一點,不想老是寫那個 Collections 的類別名稱,我們可以使用 Java 的靜態匯入功能,直接匯入這些 static 方法,修改如下:

/* Java */
// 改用靜態匯入
import static java.util.Collections.;
// 原本的
// Collections.swap(names, 1, 2);
// 只要寫靜態方法名稱即可
swap(names, 1, 2);

這樣有好一點,但是在使用及閱讀上不是那麼直覺,如果我們使用 Kotlin 的擴充功能,對 ArrayList 直接擴充一個 swap 函式呢?我們來實作看看:

// 對 ArrayList 擴充一個 swap 函式
fun <E> ArrayList<E>.swap(i: Int, j: Int) {
  val temp = this[i]
  this[i] = this[j]
  this[j] = temp
}

fun main() {
  val names = arrayListOf("Tony", "John", "Tom")
  names.swap(1, 2) // <== 這樣如何,是不是比較直覺
  println(names)
  // [Tony, Tom, John]
}

註:<E> 是泛型。

在 Java 中,如果原始類別沒有提供我們想用的方法,就只有兩種選擇,繼承後加入新功能或是額外建立工具類別,但就算是這麼做,我們也不可能預先想到未來可能會需要什麼功能而一次把它們實作出來,於是我們在日後可能又要繼承並新建一個類別,或是在工具類別中再加入新功能,漸漸地整個架構將變得龐大而不易維護。

使用擴充功能的優點就是,只在有需要的時候才追加新功能,在使用這個新功能時,就像是呼叫原始類別所提供的方法一樣。

8.2 例外處理

程式會出錯可以概分為兩種可能,一種是程式邏輯錯誤,通常是思慮不周導致寫出錯誤的程式碼,程式可能無法通過編譯,或是執行到此行時就一定會發生錯誤,這種錯誤是可以也必須被修正的;另一種錯誤是在執行時才「有可能」出現的錯誤,也就是在某些特定情況下才會發生,這種錯誤被稱為「例外錯誤」,例如讀取檔案,在檔案存在的情況下程式會正常執行,但是若檔案不存在就會發生錯誤,這種情況我們必須當成例外來特別處理。

例外也可以被我們自行丟出 (throw)。某些情況下,我們的函式或類別不想處理這個例外錯誤,我們可以將其丟出去,給更上層的程式碼去處理;相對於丟出例外,我們就必須補捉 (catch) 例外,這樣才能處理它,當我們知道某個程式碼區塊可能會發生 (或丟出) 例外,我們就必須補捉它們並做出必要的處理。

在明知會發生例外錯誤的地方而不去補捉它們的話,程式有可能因此而崩潰;但是如果因為怕程式崩潰,補捉了例外錯誤卻完全不做任何處理,則會增加我們日後除錯的困難度。對於例外錯誤處理有個準則:「除非知道怎麼處理這個例外,否則就不要加以補捉」,務必記住這一點。

8.2.1 補捉例外

在 Kotlin 中,Throwable 是所有例外 Exception 及錯誤 Error 的根類別。每個例外都有 message (錯誤訊息)、cause (造成錯誤的原因) 及 stackTrace (堆疊追蹤) 等屬性。

要明確丟出一個例外物件,可以使用關鍵字 throw

throw Exception("Oops!")

要補捉例外可以用 try-catch-finally 所組成的區塊:

try {
  // 可能發生例外的程式碼
  // 或明確丟出例外
  throw Exception("Oops!")
} catch (e: Exception /* 要捕捉的例外類型 */) {
  // 在發生例外時要如何處理
  println(e.message) // Oops!
} finally {
  // 非必要,無論有無捕捉到例外都會執行
  println("finally")
}

我們將有可能發生例外的程式碼放在 try 區塊。

catch 區塊可以指定要補捉 try 區塊中發生的哪些例外,例外的階層越高,就越能補捉到越多的例外,例如指定 IOException 時,如果發生 FileNotFoundException 也會被補捉到,因為 IOExceptionFileNotFoundException 的父類別,當然如果直接指定 Exception 這個所有例外的父類別,則不管哪一種例外都會被補捉。catch 區塊可以針對不同例外做處理而有多個,但記得要把階層低的 (子類別) 放前面,高的 (父類別) 放後面,例如:

try {
  // ...
} catch (e: FileNotFoundException) {
  // ...
} catch (e: IOException) {
  // ...
} catch (e: Exception) {
  // ...
}

finally 區塊可以省略,如果有指定,則無論 catch 有無發生它都會被執行,它的用途通常是用來釋放資源,例如關閉檔案,因此不管讀寫檔案成功或失敗,「最後」一定會執行關閉檔案的動作,兩種執行路徑分別為「發生例外」 try -> catch -> finally 或「沒有發生例外」 try -> finally

catch 區塊也可以省略,但是無論如何,catchfinally 區塊至少要有一個存在。

另外要注意的是,try-catch 區塊並不會終止程式,而是 try 區塊中發生例外那行程式碼之後的程式碼被跳過,改而執行 catch 區塊中的內容,而在 try-catch 區塊之後的程式碼依然會正常執行;相反地,如果發生例外時沒有 try-catch 區塊去補捉,整個程式就可能在此終止運行,請看範例:

fun demo(a: Int) {
  try {
    if (a <= 0) {
      throw Exception("這行之後會轉移到 catch 區塊")
      // println("這行永遠不會被執行")
    }
    println("如果 a > 0 ,這行才會被執行")
  } catch(e: Exception) {
    println("例外訊息: ${e.message}")
  }
  println("在區塊之後的程式碼繼續被執行")

  // 如果這裡丟出例外,因為沒有補捉它,程式將終止
  // throw Exception()
  // println("這行永遠不會被執行")
}

fun main() {
  demo(1)
  // 如果 a > 0 ,這行才會被執行
  // 在區塊之後的程式碼繼續被執行

  demo(-1)
  // 例外訊息: 這行之後會轉移到 catch 區塊
  // 在區塊之後的程式碼繼續被執行
}

簡單的說,例外的發生及捕捉有可能改變部分程式碼的運行流程。

8.2.2 try 表達式

在 Kotlin 中,try-catch 可以是一個表達式,因此我們可以將其寫在 = 的右邊,請看範例:

fun main() {
  val a = "AAA"
  val b: Int? = try { a.toInt() } 
    catch (e: NumberFormatException) { null }
}

如果將 a 變數轉換成整數發生例外錯誤,就給它 null,因此變數 b 的值不是整數就是 null。如果此例不使用表達式,我們將不得不把 b 變數以 var 來宣告,像這樣:

fun main() {
  val a = "AAA"
  var b: Int?
  try {
    b = a.toInt()
  } catch (e: NumberFormatException) {
    b = null
  }
}

這樣寫的缺點是,b 變數是可變的,除非之後的程式碼需要更改 b 變數的值,否則將會產生不必要的風險,請記住優先使用 val 宣告的準則。

8.2.3 已檢查的例外

Kotlin 沒有「已檢查的例外」 (checked exceptions)。

在 Java 中,例外被分成兩種,一種是 Checked Exceptions,另一種是 Unchecked Exceptions ,兩者的差別是,編譯器會去檢查我們的程式碼是否有處理 Checked Exceptions,它之所以為「已檢查的例外」就表示編譯器認為我們會檢查此例外,如果編譯時發現我們沒做,就會發生錯誤而無法編譯。

舉例來說,當我們使用 FileReader 讀取檔案時,它會丟出 FileNotFoundException,這個例外是 Exception 的子類別,屬於 Checked Exceptions ,如果我們沒有補捉這個例外,編譯器就會發出「unhandled exception」的警告,也就是說,只要是 Checked Exceptions 編譯器就會強迫我們去補捉這種例外。

Java 的例外類別階層圖如下:

例外

對於 Java 開發者來說,針對 Checked Exceptions 只有三種處理方式,第一種當然就是補捉並處理;第二種就是補捉但不做任何處理 (最不好的方式),或是捕捉後改而丟出 RuntimeException 讓原本的 Checked Exceptions 變成 Unchecked Exceptions (叫編譯器閉嘴);第三種就是完全不補捉,讓別處的程式碼去處理 (消極面對)。當這類程式碼很多的時候,不僅會非常混亂,也會造成開發效率低落 (必須不斷撰寫捕捉例外的程式碼)。

例外處理的準則就是「除非知道怎麼處理這個例外,否則就不要加以補捉」,目的是讓我們聚焦在功能性的問題上,而把例外移到別處去處理,這樣程式流程才會清楚明瞭、易於維護。如果因為被迫處理例外,但是又不知道該如何處理,於是在捕捉後隨意處理,這比不去補捉例外更危險。

Kotlin 不會強迫我們捕捉例外,我們可以自行決定該怎麼做。

8.3 特殊的 Nothing 型別

throw 在 Kotlin 中可以是一個表達式,所以我們可以和貓王運算符號 ?: 搭配使用,請看範例:

fun main() {
  val a = "A"
  val b = a.toIntOrNull() ?: throw NumberFormatException("Not a integer")
  // 只有在成功轉型成整數時才會執行以下這行
  println(b)
}

throw 表達式是一個特殊的型別 Nothing,這個型別沒有值,而是用來標記此處的程式碼永遠無法到達。我們可以使用 Nothing 來標記一個函式永遠不會回傳任何東西,就像是一個黑洞:

fun fail(message: String): Nothing {
  throw Exception(message)
}

fun main() {
  val a = "A"
  val b = a.toIntOrNull() ?: fail("Not a integer")
  println(b)
}

「不會回傳的函式」和「沒有回傳值的函式」是不同的,沒有回傳值的函式實際上還是會回傳 Unit ,程式的運行會藉由回傳 Unit 這個動作來離開函式;標記為 Nothing 回傳型別的函式則永遠也離不開此函式,當程式進入之後就終止了。

那這個 Nothing 除了讓程式終止有什麼用途嗎?有的!我們可以利用它強迫程式終止的特性,讓我們在程式碼中插入一個待處理的標記,我們可以看看 Kotlin 標準庫提供的 TODO() 函式的原始碼:

public inline fun TODO(): Nothing = throw NotImplementedError()
public inline fun TODO(reason: String): Nothing = throw NotImplementedError("An operation is not implemented: $reason")

這兩個 TODO() 函式回傳 Nothing 型別,並丟出 NotImplementedError ,我們不該捕捉這個例外錯誤,而是讓錯誤發生,告知我們還有未完成的程式碼,請看範例:

fun demo(a: Int): String {
  TODO("這個函式還沒實作")
  // 雖然沒有 return 但不會出錯
  // 因為 TODO 的 Nothing 型別讓這一行成為黑洞
}

fun main() {
  // ...
  demo(1) 
  // ... 這裡以下的程式不會執行,
  // 除非 demo() 函式完成實作並拿掉 TODO()
}

TODO() 函式可以用來做為待寫程式碼的標記,程式依然可以正常編譯,但是執行到 TODO() 函式時就會終止。


繼續閱讀:Kotlin 實戰範例 (9) 集合

完整內容可以參考電子書:Google Play Pubu 樂天 Kobo

本文網址:https://blog.tonycube.com/2020/09/kotlin-by-example-8-extensions-and-exceptions.html
Tony Blog 撰寫,請勿全文複製,轉載時請註明出處及連結,謝謝 😀

我要留言

留言小提醒:
1.回覆時間通常在晚上,如果太忙可能要等幾天。
2.請先瀏覽一下其他人的留言,也許有人問過同樣的問題。
3.程式碼請先將它編碼後再貼上。(線上編碼:http://bit.ly/1DL6yog)
4.文字請加上標點符號及斷行,難以閱讀者恕難回覆。
5.感謝您的留言,您的問題也可能幫助到其他有相同問題的人。