Kotlin 實戰範例 (7) 高階函式

Kotlin 實戰範例

Kotlin 最重要的特性應該就是高階函式了,有了高階函式我們就能做到許多單純物件導向程式語言無法做到的事,像是把函式當成值來傳遞。要瞭解高階函式要先知道什麼是頭等函式,以及其他重要的程式特性:函式型別、匿名函式、Labmda 表達式等等。

Kotlin 支援頭等函式 (first-class function),因此可以把函式當成值儲存在變數中、當成參數來傳遞或是當成其他高階函式 (higher-order function) 的回傳值。為了實現這個機制,Kotlin 使用一系列函式型別 (function types) 來表示函式,並提供一組專門的語言結構 Lambda 表達式。

高階函式和頭等函式有點不一樣,但兩者關係非常密切,因此一個程式語言通常會同時支援高階及頭等函式,而不會只單獨支援一種。頭等函式是把函式當成值,如前面所說的,值可以被儲存在變數中、當成參數值及函式的回傳值;高階函式則是為其他函式工作的函式,意即一個函式可以把另一個函式當成參數,或是回傳一個函式。

7.1 函式型別

Kotlin 使用大家所熟悉的函式型別 (function types) 宣告方式,像這樣 (Int) -> String ,在箭號 -> 左邊為參數,以括號 () 包圍,右邊為回傳值,如果無回傳值則使用 Unit 型別,函式型別意思就是把函式當成資料型別,請看範例:

// 使用函式型別 (Int) -> String 宣告一個變數
// 指派給它一個符合該函式型別的匿名函式
// fun (n: Int): String 必須符合 (Int) -> String 函式型別
val a: (Int) -> String = fun (n: Int): String {
  return "Number is $n"
}
// 使用此變數就像在呼叫函式
println(a(1))
// [結果]
// Number is 1

要宣告一個函式型別必須有輸入參數及回傳值的完整格式,如果是空參數及無回傳值則要寫成這樣 () -> Unit ,回傳值不能省略,即使無回傳值也必須指定為 Unit 型別。

7.1.1 實體化函式型別

要實體化一個函式型別有 3 種方式:

  1. 使用程式碼區塊
    • 使用匿名函式,例:fun(n: Int): String = "Number is $n"
    • 使用 Lambda 表達式,例:{ a, b -> a + b }
  2. 使用可呼叫的參考
    • 函式參考,例:::isOddString::toInt
    • 屬性參考,例:List<Int>::size
    • 建構式參考,例:::User
  3. 使用實作函式型別介面的自訂類別的實體 (即物件)

使用方式依序示範如下:

fun main() {
  // 匿名函式
  val a:(Int) -> String = fun (n: Int): String = "Number is $n"
  println(a(1)) // Number is 1

  // Lambda 表達式
  val b: (Int, Int) -> Int = { m, n -> m + n }
  println(b(1, 1)) // 2

  // 函式參考 (toInt 是一個函式)
  val c: (String) -> Int = String::toInt
  println(c("3")) // 3

  // 屬性參考 (size 是一個屬性)
  val d: (List<Int>) -> Int = List<Int>::size
  println(d(listOf(1, 2, 3, 4))) // 4

  // 建構式參考 (User 是一個類別)
  val e: (String) -> User = ::User
  println(e("Five").name) // Five

  // 實作函式型別介面的物件 A
  val f: (Int) -> String = A()
  println(f(6)) // 或 f.invoke(6)
  // Number is 6
}

data class User(val name: String)

// (Int) -> String 是函式型別介面
// A 類別實作了此函式型別介面
class A : (Int) -> String {
  override operator fun invoke(n: Int): String = "Number is $n"
}

要使用函式參考、屬性參考或建構式參考,只要在參考名稱前面加上雙冒號 :: 即可,這是一種讓我們存取傳入物件的函式、屬性或建構式的簡便方式。

7.1.2 呼叫函式型別的實體

函式型別分為無接收型別,如前一節的例子,及有接收型別 (receiver type) 兩種,呼叫語法略有不同。

無接收型別的函式型別的值可以透過它的 invoke(...) 運算子來呼叫,像是 f.invoke(x) 或是直接 f(x)

有接收型別的函式型別,第一個參數必須是這個接收型別實體化的物件,但是也可以將這個接收型別物件放到前面透過點 . 符號來呼叫,語法看起來就像是擴充函式 (extension function),請看範例:

fun main() {
  /* ===== 有接收型別的函式型別 ===== */
  // String. 為接收型別
  // 呼叫時 String 必須是第一個參數,或前置
  val a: String.(Int) -> Int = fun String.(m: Int): Int {
    return this.toInt() + m
  }
 
  // 可用 Lambda 改寫如下
  // val a: String.(Int) -> Int = { m -> this.toInt() + m }

  // 以下 3 種呼叫方式均可
  println(a.invoke("1", 2))
  println(a("1", 2))
  println("1".a(2)) // 將第一個參數前置

  /* ===== 無接收型別的函式型別 ===== */
  val b: (Int, Int) -> Int = fun (m: Int, n: Int): Int {
    return m - n
  }
  // 可用 Lambda 改寫如下
  // val b: (Int, Int) -> Int = { m, n -> m - n }

  // 少了一種呼叫方式,因為它沒有接收型別
  println(b.invoke(1, 2))
  println(b(1, 2))
}

7.1.3 兩種函式型別的互換

有接收型別的函式型別可以和無接收型別的函式型別互換,也就是 A.(B) -> C 相等於 (A, B) -> C ,請看範例:

fun main() {
  // 無接收型別的函式型別
  val a: (Int, Int) -> String = 
    fun (m: Int, n: Int): String {
      return "$m + $n = ${m + n}"
    }

  // 將無接收型別的函式型別,指派給有接收型別的函式型別
  val b: Int.(Int) -> String = a

  // 呼叫方式如下
  println(a(1, 2))
  println(b(1, 2))
  println(1.b(2))
}



7.2 匿名函式

匿名函式就是沒有名稱的函式,我們可以將其指派給變數來使用,如前面函式型別的範例,或是將其當成另一個函式的參數,請看範例:

fun main() {
  /*
  // 這是一個匿名函式
  fun (m: Int, n: Int): String {
    return "$m + $n = ${m + n}"
  }

  // 可以使用函式表達式語法來簡化
  fun (m: Int, n: Int): String = "$m + $n = ${m + n}"

  // 匿名函式單獨存在並沒有任何意義,必須指派給變數或其他函式的參數
  */

  // 將匿名函式指派給變數
  val a: (Int, Int) -> String = 
    fun (m: Int, n: Int): String = "$m + $n = ${m + n}"

  // 依據型別推論,可以簡化成這樣
  val a1: (Int, Int) -> String = 
    fun (m, n) = "$m + $n = {m + n}"
  // 或這樣 (在熟悉 Kotlin 之後建議這種寫法)
  val a2 = fun (m: Int, n: Int) = "$m + $n = {m + n}"

  // 這是一個高階函式
  // 接受 (Int, Int) -> String 這種函式型別的參數
  fun demo(x: (Int, Int) -> String) {
    println(x(1, 2))
  }

  // 將擁有「型別相符的函式型別的匿名函式」的變數傳入
  demo(a)

  // 通常會直接將匿名函式當成函式的參數傳入,而不用另外宣告一個變數
  demo(fun (m: Int, n: Int) = "$m + $n = ${m + n}")
}

7.3 Lambda 表達式

Lambda 表達式可以讓匿名函式的語法更精簡。Lambda 表達式的語法一定被大括號 {} 包圍,裡面由箭號 -> 分成左右兩邊,參數宣告在箭號左邊,必須指定資料型別,如果能夠型別推斷則可以省略;箭號右邊是函式的主體。假如推斷的回傳值型別不是 Unit ,表示會有回傳值,那在函式主體中的最後一行 (也可能只有一行) 敍述就會被當成回傳值。

我們將前面匿名函式的例子改用 Lambda 表達式重寫如下:

fun main() {
  /* 
  // 這是一個匿名函式
  fun (m: Int, n: Int): String {
    return "$m + $n = {m + n}"
  }
  // 以 Lambda 表達式改寫
  { m: Int, n: Int -> "$m + $n = {m + n}" }
  // 這裡是語法示範,不管是匿名函式或 Lambda 表達式都不會單獨存在 
  */

  // 將匿名函式指派給變數
  val a: (Int, Int) -> String = 
    fun (m: Int, n: Int): String = "$m + $n = ${m + n}"
  // 使用 Lambda 改寫
  val aL = { m: Int, n: Int -> "$m + $n = ${m + n}" }

  // 這是一個高階函式
  // 接受 (Int, Int) -> String 這種函式型別的參數
  fun demo(x: (Int, Int) -> String) {
    println(x(1, 2))
  }
  // 將擁有「符合函式型別的匿名函式」的變數傳入
  demo(a)
  // 改成直接將匿名函式當成函式的參數傳入
  demo(fun (m: Int, n: Int) = "$m + $n = ${m + n}")
  // 改成 Lambda 表達式
  demo({ m, n -> "$m + $n = ${m + n}" })
  // 但是強烈建議寫成這樣,見下節說明
  demo() { m, n -> "$m + $n = ${m + n}" }
  // 最後簡化成這樣 (這是熟練之後的最佳語法)
  demo { m, n -> "$m + $n = ${m + n}" }
}

7.3.1 Lambda 作為最後一個參數

Kotlin 的 Lambda 表達式有一個特性,如果高階函式的最後一個參數是函式,當 Lambda 表達式當成該參數的值傳入時,可以將其移到括號 () 後面,而如果這個 Lambda 表達式是唯一的參數,還可以省略括號 (),請看以下幾個範例:

fun main() {
  /* 只有一個參數,並且是函式型別 */
  fun demo(x: (Int, Int) -> Int) {
    println(x(1, 2))
  }
  // 使用方式
  demo { m, n -> m + n }

  /* 有兩個參數,最後一個是函式型別 */
  fun demo2(a: Int, x: (Int, Int) -> Int) {
    println(a + x(1, 2))
  }
  // 不推薦的使用方式
  demo2(1, { m, n -> m + n })
  // 較佳的使用方式
  demo2(1) { m, n -> m + n }
}

7.3.2 隱含的單一參數名稱:it

Lambda 表達式如果只有一個參數,而編譯器可以識別這個函式型別,我們就可以省略宣告這個唯一參數的動作,Kotlin 會隱含的以 it 這個名稱來宣告這個唯一參數,我們可以直接使用它,請看以下範例:

fun main() {
  // 建立一個有接收型別的高階函式 ()
  // 接收型別為 Int.
  // 傳入的參數為 (Int) -> Boolean 函式型別
  // 最後回傳布林值
  // 完整寫法
  // fun Int.demo(f: (Int) -> Boolean): Boolean {
  //   return f(this)
  // }

  // 簡化改寫如下,return 換成 = 並省略 {}
  fun Int.demo(f: (Int) -> Boolean) = f(this)

  // 1. 使用時,傳入一個匿名函式
  1.demo(fun (x: Int): Boolean { return x % 2 == 0 })

  // 2. 簡化改寫如下 (由回傳值即可推斷出資料型態,因此省略)
  1.demo(fun (x: Int) = x % 2 == 0)

  // 3. 使用 Lambda 改寫
  1.demo({ x -> x % 2 == 0 })

  // 4. Lambda 是最後一個參數,可以把它放在括號後面,並省略括號
  1.demo { x -> x % 2 == 0 }

  // 5. 因為此 Lambda 只有一個參數,可以直接用隱含宣告的 it
  // 熟練之後,程式碼將會都是這種超簡潔的樣式
  1.demo { it % 2 == 0 }

  // 測試看看結果,判斷是否為偶數
  println(1.demo { it % 2 == 0 }) // false
  println(2.demo { it % 2 == 0 }) // true
  println(3.demo { it % 2 == 0 }) // false

  // 使用高階函式的好處,我們可以隨時換掉程式邏輯
  1.demo { it % 2 != 0 } // 判斷是否為奇數
  1.demo { it > 0 } // 判斷是否大於 0
  1.demo { it in 0..100 } // 判斷是否介於 0 到 100
}

7.3.3 匿名函式和 Lambda 表達式的差異

匿名函式的參數必須用括號 () 包圍,Lambda 表達式則不需要。Lambda 表達式無法明確指定回傳值的資料型別,只能透過運算結果推斷,如果非要指定回傳值的資料型別,就只能使用匿名函式。

對於 return 關鍵字的使用,在匿名函式中可以順利離開該函式,但在 Lambda 表達式中並不能這麼做,唯一能做的就是使用 return@函式名稱 明確指定。

7.4 行內函式

7.4.1 何時使用行內函式

高階函式雖然能讓程式架構變得靈活,但是有個小缺點,由於每一個高階函式都是一個物件,而且會因為和外部變數產生關聯而形成閉包,因此在執行時會有額外的效能損耗。對於函式物件和類別的記憶體分配及虛擬呼叫,也會增加額外的執行時間。

Kotlin 為了解決這個問題,提供了行內函式 (Inline Functions),它會將 Lambda 表達式「行內化」,也就是將 Lambda 表達式替換成函式的內容,而不是透過呼叫的方式,簡單的說,就是編譯器會幫我們把行內函式的程式碼複製貼上到原本的 Lambda 表達式的地方,以 Kotlin 原始碼說明:

// 這是 Kotlin 原始碼其中的一段程式
public inline fun println(message: Int) {
  System.out.println(message)
}

// 我們會這麼使用它
println(1)
println(2)

// 編譯後會變這樣
System.out.println(1)
System.out.println(2)

我們在使用行內函式時,不會有「呼叫函式」的動作,因為編譯器將行內函式的內容複製一份到使用它的地方,少掉「呼叫函式」的動作能夠增加執行時的效率。

要將函式「行內化」只要在函式前面加上 inline 修飾字即可。 inline 修飾字會影響高階函式本身和傳給它的 Lambda 表達式,所有的內容都將複製一份到呼叫它的地方。

但是行內函式不是完全沒有缺點,如果過度使用行內函式會導致程式碼增加,尤其是在迴圈中,程式碼的行數將隨著迴圈數增加,假設我們的行內函式有 10 行程式碼,如果使用迴圈跑 10 次,程式碼行數將會是 10 x 10 = 100 行。

因此應該避免程式碼行數過多的行內函式,如果我們使用得當,就能夠同時擁有較快的執行效率但又不會造成程式碼過度肥大。

7.4.2 禁止使用行內函式

如果我們只想要部分參數使用行內函式,其他要禁止使用,可以使用 noinline 修飾字標記要禁止的函式,請看範例:

// 參數 g 禁止使用行內函式
inline fun demo(f: () -> Unit, noinline g: () -> Unit) {
  // ...
}


繼續閱讀:Kotlin 實戰範例 (8) 功能擴充、例外處理

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



本文網址:https://blog.tonycube.com/2020/08/kotlin-by-example-7-higher-order-functions.html
Tony Blog 撰寫,請勿全文複製,轉載時請註明出處及連結,謝謝 😀

我要留言

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