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)
}
需要注意的是,如果我們的資料是有明確意義的,或是在其他地方仍然會使用的,就不適合使用 Pair
或 Triple
,建議建立專屬的類別才能表達其意義。
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),總共有五個:let
、 run
、 with
、 apply
和 also
,這五個函式定義在 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
參考到接收者脈絡資訊的函式有 run
、with
和 apply
三個。在 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 表達式的預設參數做為物件的脈絡資訊,此類範圍函式有 let
和 also
兩個。在 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 回傳值
範圍函式的另一個不同之處在於回傳值:
apply
和also
回傳脈絡資訊物件let
、run
和with
回傳 Lambda 表達式的運算結果
要使用哪一類的範圍函式,取決於我們要如何在之後的程式碼中使用。
脈絡資訊物件
apply
和 also
回傳的是脈絡資訊物件 (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 表達式運算的結果
let
、run
和 with
會回傳 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
範圍函式比較表:
範圍函式 資訊脈絡物件 回傳值 是否以擴充函式表示 let it 運算結果 是 with this 運算結果 否:資訊脈絡物件以參數的方式傳入 run this 運算結果 是 run - 運算結果 否:無資訊脈絡物件 apply this 資訊脈絡物件 是 also it 資訊脈絡物件 是
with
和 run
可以是獨立區塊,而不以擴充函式表示,其他範圍函式以擴充函式表示時,前面會有接收者,請觀察前面的範例即可瞭解。
範圍函式雖然可以讓程式碼更加簡潔,但還是要避免過度使用,不要巢狀化範圍函式,並小心在函式呼叫鏈中使用。
下篇預告:Kotlin 實戰範例 (11) 應用實例:字串
完整內容可以參考電子書:Google Play Pubu 樂天 Kobo
由 Tony Blog 撰寫,請勿全文複製,轉載時請註明出處及連結,謝謝 😀
我要留言
留言小提醒:
1.回覆時間通常在晚上,如果太忙可能要等幾天。
2.請先瀏覽一下其他人的留言,也許有人問過同樣的問題。
3.程式碼請先將它編碼後再貼上。(線上編碼:http://bit.ly/1DL6yog)
4.文字請加上標點符號及斷行,難以閱讀者恕難回覆。
5.感謝您的留言,您的問題也可能幫助到其他有相同問題的人。