Kotlin 實戰範例 (10) Pair、解構式宣告、範圍函式

Kotlin 實戰範例

Kotlin 有幾個特別一提的功能,例如 Pair 及 Triple 可以將兩個及三個資料放在一起,對於暫時傳遞資料用非常方便,搭配解構式宣告使用起來更加方便。範圍函式是一種很特別的函式,基本上完全不使用它也不會怎麼樣,但是如果用得好,能夠讓程式碼更加結構化,在閱讀上語義更清楚。

10.1 Pair 及 Triple

在某些情況下,我們需要將兩個或三個資料結合成一組來傳遞,這些資料一般來說沒有什麼相關性或只是為了暫時傳遞使用,因此特地去建立一個物件來用不合理、也不方便。

Kotlin 標準庫提供兩個資料類別讓我們方便結合資料,兩個一組用 Pair ,三個一組用 Triple。使用方法很簡單,第一個參數對應到 first 屬性、第二個參數對應到 second 屬性、第三個參數對應到 third 屬性,請看範例:

// 為了區隔內建的 plus 函式,這裡故意命名為 myPlus
fun myPlus(a: Int, b: Int): Pair<String, Int> {
  val result = a + b
  val text = "$a + $b = "
  return Pair(text, result)
}

fun myPlus(a: Int, b: Int, c: Int): Triple<String, Int, Boolean> {
  val result = a + b + c
  val text = "$a + $b + $c = "
  val isPositive = (result >= 0)
  return Triple(text, result, isPositive)
}

fun main() {
  // 使用屬性
  val a = myPlus(1, 2)
  println("${a.first}${a.second}")
  // 1 + 2 = 3
  
  // 使用解構式
  val (textA, resultA) = myPlus(2, 3)
  println("$textA$resultA")
  // 2 + 3 = 5
  
  // 使用屬性
  val b = myPlus(1, 2, 3)
  println("${b.first}${b.second} (${b.third})")
  // 1 + 2 + 3 = 6 (true)
  
  // 使用解構式
  val (textB, resultB, isPositiveB) = myPlus(4, 5, 6)
  println("$textB$resultB ($isPositiveB)")
  // 4 + 5 + 6 = 15 (true)
}

需要注意的是,如果我們的資料是有明確意義的,或是在其他地方仍然會使用的,就不適合使用 PairTriple,建議建立專屬的類別才能表達其意義。

10.2 解構式宣告

解構就是建構的逆轉,建構式依照屬性建立一個物件,解構式用來分解物件以取得它的屬性。解構式宣告 (Destructuring Declarations) 是指,將物件解構後的屬性直接指派給變數,這麼做的好處是可以一次做到「宣告變數」及「指派屬性值」兩個動作。使用解構式宣告時,必須以括號 () 來包圍解構後對應的變數。

10.2.1 Data Classes 的解構式宣告

宣告 data class 時,編譯器會幫我們產生和屬性相對應的 componentN() 函式,這些函式可以用來存取屬性,也可以被用在解構式宣告中,請看範例:

data class User(
  var id: Int = 0,
  var name: String = "",
  var age: Int = 0
)
 
fun main() {
  val user = User(
    id = 1,
    name = "Tony",
    age = 5
  )
 
  // 一般我們需要使用屬性時會這麼寫
  println("${user.id}, ${user.name}, ${user.age}")
 
  // Kotlin 編譯時自動建立了 componentN() 方法
  // 依序對應到屬性(示範用,實務上不要這麼寫)
  println("${user.component1()}, " +
          "${user.component2()}, " +
          "${user.component3()}")
 
  // 使用解構式宣告時會自動呼叫對應的 componentN() 方法
  val (id, name, age) = user
  // 上面這行其實編譯器幫我們做了以下的事
  // val id = user.component1()
  // val name = user.component2()
  // val age = user.component3()
  // 這樣用起來是不是方便多了
  println("$id, $name, $age")
}

實務上很常在集合中使用,當我們疊代集合時,可以使用解構式宣告讓存取屬性的動作方便一些,請看範例:

fun main() {
  val users = listOf(
    User(1, "Tony", 5),
    User(2, "Tom", 9),
    User(3, "Tiffany", 8)
  )
 
  // 一般寫法
  for (user in users) {
    println("${user.id}, ${user.name}, ${user.age}")
  }
 
  // 使用解構式宣告
  for ((id, name, age) in users) {
    println("$id, $name, $age")
  }
}

是不是簡單、易寫又易讀。任何可以呼叫 componentN() 函式的項目都能使用解構式宣告,例如函式回傳值:

fun makeUser(): User {
  return User(1, "Tony", 5)
}
 
fun main() {
  // 直接把回傳值解構來建立變數
  val (id, name, age) = makeUser()
  println("$id, $name, $age")
}

10.2.2 Map 集合的解構式宣告

Map 集合的項目由一組鍵值對 (key-value) 表示,每個項目可以解構成兩個變數,一個表示 key、一個表示 value,請看範例:

fun main() {
  val a = mapOf(1 to "Tony", 2 to "Tom", 3 to "Tiffany")
 
  // 解構式宣告
  // 這裡的 id, name 變數名稱是我們付予它的意義
  // 在原始 Map 集合中並沒有表明 key-value 是什麼
  for ((id, name) in a) {
    println("$id, $name")
  }
}

能夠對 Map 執行解構式宣告,是因為 Kotlin 對 Map 實作了 iterator() 函式及對應鍵值對的 component1()component2() 兩個函式,它的實作如下:

operator fun <K, V> Map<K, V>.iterator(): Iterator<Map.Entry<K, V>> = entrySet().iterator()
operator fun <K, V> Map.Entry<K, V>.component1() = getKey()
operator fun <K, V> Map.Entry<K, V>.component2() = getValue()

10.2.3 在 Lambda 表達式中使用解構式

Lambda 表達式的參數如果是資料類別或 Map 集合,也可以直接使用解構式,請看範例:

fun main() {
  val a = mapOf(1 to "Tony", 2 to "Tom", 3 to "Tiffany")
  // 在 Lambda 中使用預設參數,參數的意義不明
  a.forEach {
    println("${it.key}, ${it.value}")
  }
  
  // 在 Lambda 中直接解構,必須以括號包圍,否則會視為參數
  a.forEach { (id, name) ->
    println("$id, $name")
  }
 
  // 沒有使用括號會視為參數的名稱
  a.forEach { user ->
    println("${user.key}, ${user.value}")
  }
}

10.2.4 針對用不到的變數使用底線

如果我們在使用解構式宣告時,有些屬性用不到想要忽略,可以使用底線 _ 來略過它們,請看範例:

val (_, name, _) = makeUser()

這樣就只會建立 name 這個變數,使用 _ 的屬性將不會呼叫其對應的 componentN() 方法。

10.3 範圍函式

Kotlin 標準庫裡有幾個函式很特別,它們的唯一的目的就是用來執行區塊 (block) 裡的程式。當我們在物件上使用 Lambda 表達式來呼叫此類函式時,它能將物件的脈絡資訊 (context) 限制在這個區塊裡,形成暫時的範圍 (scope) 而不受外界影響,在這個範圍裡,我們可以存取該物件的屬性及方法,這類函式被稱為範圍函式 (Scope Functions),總共有五個:letrunwithapplyalso ,這五個函式定義在 Any 類別中,屬於擴充函式,因為 Any 類別是所有類別的根類別,因此所有類別都能使用範圍函式。

全部的範圍函式都是做一樣的事:執行區塊裡的程式碼。差異在於該物件在區塊中怎麼被使用及函式執行後的結果是什麼,先來看個簡單的例子,此範例使用 let

data class Car(private var gas: Int) {
  fun move() {
    gas -= 1
  }
}

fun main() {
  // 一般寫法
  val car = Car(10)
  println(car) // Car(gas=10)
  car.move()
  car.move()
  println(car) // Car(gas=8)
 
  // 使用範圍函式
  Car(10).let {
    println(it) // Car(gas=10)
    it.move()
    it.move()
    println(it) // Car(gas=8)
  }
}

我們可以看到範圍函式並沒有什麼特別,不過就是把物件的操作放在一個區塊裡,但是這麼做可以讓相關的程式碼有明確的關聯性,在閱讀上可以一眼就看出這些程式碼是有關係的,對比一般寫法,我們得看完全部的程式碼才能大概知道有哪幾行程式碼是有關係的;就像是我們的桌面上擺滿了各式各樣的東西,要找的時候要花點時間,但是如果用個盒子把相關的物品放在一起,找起來就比較容易,這就是 Kotlin 設計範圍函式的用意。

由於這些範圍函式都很像,要選擇哪一個來用就有點傷腦筋,主要是看我們的意圖並且在我們的專案中保持一致性。以下會說明這幾種範圍函式的區別及使用慣例。

10.3.1 範圍函式的區別

範圍函式主要有兩個區別:

  • 物件的脈絡資訊 (context) 的引用方式
  • 回傳的結果值

10.3.1.1 物件的脈絡資訊:this 或 it

在範圍函式裡,可以透過物件的脈絡資訊來參考到該物件,主要有兩種引用方式,一種是接收者 this ,即物件本身,另一種是 Lambda 表達式的預設參數 it ,這兩種引用方式都是指向物件本身,只是使用上有些不同,所以要使用哪一個可以依情況做決定,請看範例:

fun main() {
  val a = "Hello"
 
  // run 使用 this
  a.run {
    val l = this.length
    println(l)
    // this 可省略
    println(length)
  }
  
  // let 使用 it
  a.let {
    val l = it.length
    println(l)
  }
}

this

參考到接收者脈絡資訊的函式有 runwithapply 三個。在 Lambda 表達式中可以省略 this 關鍵字,它指向該物件 (接收者) 本身,這樣雖然可以讓程式碼更簡潔一些,但是卻不容易和外部物件或函式做出區別,因為這個原因,通常我們只會在存取物件的成員時才會用這三個範圍函式,請看以下範例:

data class User(
  var id: Int,
  var name: String = "",
  var age: Int = 0
)

fun main() {
  // 1.一般宣告物件時最常用的寫法,但是不清楚參數的意義
  val a = User(1, "Tony", 5)  
  // 2.可以加上參數名稱,但變得太長不易閱讀
  val b = User(id = 1, name = "Tony", age = 5)  
  // 3.某些情況下會調整成這樣
  val c = User(
    id = 1,
    name = "Tony",
    age = 5
  )  
  // 4.範圍函式可以解決這個問題
  val d = User(id = 1).apply {
    name = "Tony"
    age = 5
  }
}

此例的前三種寫法很常見,適合用在屬性數量比較少的情況,某些情況是物件的屬性太多或是參數名稱太長,這時才看得出範圍函式的好處。

it

以 Lambda 表達式的預設參數做為物件的脈絡資訊,此類範圍函式有 letalso 兩個。在 Lambda 表達式中,物件的參考會以預設的參數名稱 it 來表示,它非常適合用在呼叫其他函式時傳遞參數,請看範例:

fun log(msg: String) {
  println("INFO: $msg")
}

fun main() {
  // 一般寫法
  val a = (1..10).random()
  log("random number: $a")
  
  // 使用範圍函式
  val b = (1..10).random().also {
    log("random number: $it")
  }

  // 如果不想用預設的 it,也可以自定名稱
  val c = (1..10).random().also { number ->
    log("random number: $number")
  }
}

我們可以看到,一般寫法會是獨立的兩行程式碼,我們不容易一眼看出這兩行是有密切關係的;改用範圍函式時,我們可以很明確地把相關的程式碼放在一起。

10.3.1.2 回傳值

範圍函式的另一個不同之處在於回傳值:

  • applyalso 回傳脈絡資訊物件
  • letrunwith 回傳 Lambda 表達式的運算結果

要使用哪一類的範圍函式,取決於我們要如何在之後的程式碼中使用。

脈絡資訊物件

applyalso 回傳的是脈絡資訊物件 (Context object),也就是接收者即物件本身,因此它可以串在函式的呼叫鏈中,讓下個函式來使用,請看範例:

fun log(msg: String) {
  println("INFO: $msg")
}

fun main() {
  val a = mutableListOf<Int>()
    .also { log("Creating random number list") }
    .apply {
      // 加入 5 個範圍 1~10 的亂數
      repeat(5) {
        // 這裡省略 this,實際為 this.add()
        add((1..10).random())
      }
    }
    .also { log("Sorting the list") }
    .sorted()
    .apply { println(this) }
}
/*
INFO: Creating random number list
INFO: Sorting the list
[1, 1, 2, 5, 7]
*/

我們可以利用 also 函式回傳物件本身這個特性,做到執行運算並同時記錄到 log 的功能。以下為另外一個在函式中使用的範例:

// 完整寫法
fun makeRandomInt(): Int {
  return (1..10).random().also {
    log("random number: $it")
  }
}

// 極簡寫法
fun makeRandomInt() = (1..10).random().also {
  log("random number: $it")
}

Lambda 表達式運算的結果

letrunwith 會回傳 Lambda 表達式運算的結果,所以我們可以用來指派給變數,或是函式的呼叫鏈,請看範例:

fun main() {
  val a = mutableListOf<Int>()
  val countEven = a.run {
    // 加入 5 個範圍 1~10 的亂數
    repeat(5) {
     	add((1..10).random())
    }
    // 計算集合中的偶數有幾個
    // 寫在區塊中最後一行表示為回傳值
    count { it % 2 == 0 }
  }
  println(a) // [2, 1, 7, 9, 3]
  println(countEven) // 1
}

另外,我們也可以使用範圍函式來建立一個暫時性的變數存在範圍 (即區域變數),這些變數只會存在這個範圍中,不會影響到外面的程式碼,請看範例:

fun main() {
  val a = listOf("Tony", "Tom", "Tiffany", "Andy", "Bob")
  with(a) {
    val nameFirst = first { it.startsWith("T") }
    val nameLast = last { it.startsWith("T") }
    println("first is $nameFirst, last is $nameLast")
  	// first is Tony, last is Tiffany
  }
}

關於範圍函式的更多細節可以參考電子書的 8.4.2 節。

10.3.2 如何選擇

看到這裡,是不是還是一頭霧水,不知道該怎麼選擇。我們要先瞭解一件事,範圍函式不是必要使用的函式,不使用範圍函式依然能讓程式碼正常執行,但是如果使用的好,能夠讓程式碼更加結構化、更加易讀及維護,所以在選擇時,請朝這些條件來決定使用哪一個,以下會列出幾個幫助我們選擇的條件:

  • 在非 null 的物件中執行 Lambda:let
  • 藉由 Lambda 表達式將變數的生存範圍表示為區域變數:let
  • 物件的設定:apply
  • 物件的設定並且計算結果值:run
  • 在需要使用 Lambda 表達式來執行敍述的地方,以非擴充函式的方式:run
  • 加入額外的動作:also
  • 群組化呼叫物件的成員:with

範圍函式比較表:

範圍函式資訊脈絡物件回傳值是否以擴充函式表示
letit運算結果
withthis運算結果否:資訊脈絡物件以參數的方式傳入
runthis運算結果
run-運算結果否:無資訊脈絡物件
applythis資訊脈絡物件
alsoit資訊脈絡物件

withrun 可以是獨立區塊,而不以擴充函式表示,其他範圍函式以擴充函式表示時,前面會有接收者,請觀察前面的範例即可瞭解。

範圍函式雖然可以讓程式碼更加簡潔,但還是要避免過度使用,不要巢狀化範圍函式,並小心在函式呼叫鏈中使用。


下篇預告:Kotlin 實戰範例 (11) 應用實例:字串

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

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

我要留言

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