Tina Tang's Blog

在哪裡跌倒了,就在哪裡躺下來

0%

Android筆記(21)-Kotlin中的Collections

在這個程式碼研究室中,您將進一步了解集合(collection),以及 Kotlin 中的 lambdas 和高階函式。

學習目標

  • 如何使用 sets 和 maps 等集合(collection)
  • 瞭解 lambdas 的基本概念
  • 高階函式的基本概念

了解Collection

集合(collection)是指一組相關項目,例如字詞清單或一組員工記錄。集合可以包含排序或未排序的項目,這些項目可以是獨一無二的或重複的。您已經瞭解一種類型的集合,那就是list。list 中 item 是有序的,但 item 可以重複。

如同 list,Kotlin 可區分可變(mutable)集合和不可變(immutable)集合。Kotlin 提供多項功能,可用於新增或刪除項目、檢視及操控集合。

建立List

在這項工作中,您會建立數字列表(list),然後加以排序。

  1. 開啟 Kotlin Playground
  2. 使用下列程式碼取代所有程式碼:
1
2
3
4
fun main() {
val numbers = listOf(0, 3, 8, 4, 0, 5, 5, 8, 9, 2)
println("list: ${numbers}")
}
  1. 如要執行程式,請輕觸綠色箭頭,然後查看畫面上顯示的結果:
1
list:   [0, 3, 8, 4, 0, 5, 5, 8, 9, 2]
  1. 這個 list 包含 0 到 9 這 10 個數字。某些數字會重複出現數次,而有些則一次也沒有出現。

  2. 在這個 list 中,item 的順序很重要:第一個 item 是 0,第二個 item 是 3,依此類推。除非您變更順序,否則這些 item 都會按照這個順序顯示。

  3. 回想一下,先前的程式碼研究室中提到過,list 有許多內建函式 (例如 sorted() 函式,會傳回遞增排序后的 list )。在 println() 之後,為程式新增一行以列印排序後的 list:

1
println("sorted: ${numbers.sorted()}")
  1. 再次執行程式並查看結果:
1
2
list:   [0, 3, 8, 4, 0, 5, 5, 8, 9, 2]
sorted: [0, 0, 2, 3, 4, 5, 5, 8, 8, 9]

將數字排序,方便您查看個別數字在 list 中出現的次數。

了解Set

Kotlin 中的另一種集合類型為集(Set)。這是由相關 item 組成的一個群組,但和 list 不同,set中不允許出現重複的 item,但是 item 之間是無序的。一個 item 可以包含於一個 set 或不包含與一個 set,只要位於組合中,則該 item 僅能在該 set 中出現一次。這與數學中的集合概念類似。舉例來說,有一組您已讀的書籍。閲讀某本書多次並不影響您閱讀過的書籍這一集合。

  1. 將以下幾行指令新增至程式,即可將 list 轉換成 set:
1
2
val setOfNumbers = numbers.toSet()
println("set: ${setOfNumbers}")
  1. 執行程式並查看結果:
1
2
3
list:   [0, 3, 8, 4, 0, 5, 5, 8, 9, 2]
sorted: [0, 0, 2, 3, 4, 5, 5, 8, 8, 9]
set: [0, 3, 8, 4, 5, 9, 2]

結果包含原始 list 中的所有數字,但每個數字只會出現一次。請注意,這些數字的順序與原始 list 中的順序相同,但這個順序對 set 來說無關緊要。

  1. 定義可變(mutable)集合和不可變(immutable)集合,並以相同的數字組初始化這些 set,方法是新增下列幾行程式碼:
1
2
val set1 = setOf(1,2,3)
val set2 = mutableSetOf(3,2,1)
  1. 新增一行以列印二者是否相等:
1
println("$set1 == $set2: ${set1 == set2}")
  1. 執行您的程式並查看新的結果:
1
[1, 2, 3] == [3, 2, 1]: true

即使只有一個 set 是可變的,而當中的 item 順序也不同,系統仍然會將其視為相同的 set,因為這二者包含完全相同的 item 組合。

您對 set 執行的主要作業之一是,透過 contains() 函式檢查特定 item 是否包含於某個 set 中。

  1. 如果 set 中包含 7,新增如下一行以列印:
1
println("contains 7: ${setOfNumbers.contains(7)}")
  1. 執行您的程式並查看額外的結果:
1
contains 7: false

上述所有程式碼:

1
2
3
4
5
6
7
8
9
10
11
fun main() {
val numbers = listOf(0, 3, 8, 4, 0, 5, 5, 8, 9, 2)
println("list: ${numbers}")
println("sorted: ${numbers.sorted()}")
val setOfNumbers = numbers.toSet()
println("set: ${setOfNumbers}")
val set1 = setOf(1,2,3)
val set2 = mutableSetOf(3,2,1)
println("$set1 == $set2: ${set1 == set2}")
println("contains 7: ${setOfNumbers.contains(7)}")
}

和數學中的集合一樣,在 Kotlin 中,您也可以運用 intersect()union() 對兩個集合執行 交集(Intersection) (∩)聯集(Union) (∪) 運算。

了解Map

在本程式碼研究室中,您要了解的最後一個集合類型是 Map 或 Dictionary(字典)。Map 是一組 鍵/值(key-value) 組合,可讓您以特定 key 輕鬆查詢 value。key 不得重複,每個 key 只能對應至一個 value,但 value 可以重複。Map 中的 value 可以是字串(strings)、數字(numbers)或物件(objects),甚至是列表( list)或一組 set 等其他集合。

如果您有成對的 data,map 就相當實用,您可以基於它的 value 辨別出每一對 data。該 key 會對應到相對應的 value。

  1. 在 Kotlin playground 中,請將所有程式碼替換成這段程式碼,以建立可修改的 map 來儲存使用者名稱和年齡:
1
2
3
4
5
6
7
fun main() {
val peopleAges = mutableMapOf<String, Int>(
"Fred" to 30,
"Ann" to 23
)
println(peopleAges)
}

這項操作會將 String (key) 的 mutable map 對應到 Int (value)、將兩個 item 初始化,然後列印 item。

  1. 執行程式並查看結果:
1
{Fred=30, Ann=23}
  1. 如要在 map 中加入更多 item,您可以使用 put() 函式傳入 key和 value:
1
peopleAges.put("Barbara", 42)
  1. 您也可以使用簡寫標記來新增 item:
1
peopleAges["Joe"] = 51

以下是上述所有程式碼:

1
2
3
4
5
6
7
8
9
fun main() {
val peopleAges = mutableMapOf<String, Int>(
"Fred" to 30,
"Ann" to 23
)
peopleAges.put("Barbara", 42)
peopleAges["Joe"] = 51
println(peopleAges)
}
  1. 執行程式並查看結果:
1
{Fred=30, Ann=23, Barbara=42, Joe=51}

如上所述,key (姓名) 不得重複,但value (年齡) 可以重複。當您嘗試使用同一個鍵新增 item 時,會發生什麼情況?

  1. println() 之前加入這行程式碼:
1
peopleAges["Fred"] = 31
  1. 執行程式並查看結果:
1
{Fred=31, Ann=23, Barbara=42, Joe=51}

系統不會再次新增 key "Fred",但其所對應的 value 會更新為 31


使用Collection

雖然性質各不相同,但各種類型的集合(collection)有很多共同點。如果是可變集合,您可以新增或移除item。您可以列舉集合中的所有item、尋找特定item,有時也可以將某種集合轉換成另一種集合。你先前曾執行過,透過 toSet()List 轉換成 Set。以下是使用集合的幾個實用函式。

forEach

假設您想列印「peopleAges」中的項目,並在其中納入使用者姓名和年齡。例如 "Fred is 31, Ann is 23,..."、等。您曾在先前的程式碼研究室中學到 for 這個迴圈,因此可以使用 for (people in peopleAges) { ... } 撰寫迴圈。

不過,列舉集合中的所有項目都是常見的作業,因此 Kotlin 提供的 forEach() 會分析所有項目,並在各個項目中執行。

  1. 在 playground 中,將下列程式碼加到 println() 後方:
1
peopleAges.forEach { print("${it.key} is ${it.value}, ") }

這與 for 迴圈類似,但有點複雜。forEach 會使用特殊 ID it 為目前的項目指定變數,
請注意,在呼叫 forEach() 方法時無需加上括號,只要以大括號 {} 傳送程式碼即可。

  1. 執行您的程式並查看額外的結果:
1
Fred is 31, Ann is 23, Barbara is 42, Joe is 51,

這與您需要的非常接近,但結尾有一個額外的逗號。
將集合(collection)轉換為字串(string)是一種常見的作業,而結尾的分隔符也是一個常見問題。我們將在下列步驟中說明相關處理方式。

Map

map() 函式 (不應與上方 map 或 dictionary collection 混淆) 會將 collection 中的每個 item 套用到轉換中的每個 item。

  1. 將程式中的 forEach 陳述式替換成這一行:
1
println(peopleAges.map { "${it.key} is ${it.value}" }.joinToString(", ") )

執行您的程式並查看額外的結果:

1
Fred is 31, Ann is 23, Barbara is 42, Joe is 51

可產生正確的輸出內容,而且沒有多餘的逗號!有一行有太多工作,請密切關注。

  • peopleAges.map 會為 peopleAges 中的每個 item 套用一項轉換,並建立新的轉換 item 集合
  • 大括號 {} 中的部分會定義每個 item 的轉換作業。轉換作業會使用 key/value 組合,並將其轉換為 string,例如 <Fred, 31> 會轉換為 Fred is 31
  • joinToString(", ") 會將轉換集合中的每個 item 新增至 string 中,並以 , 分隔,但不知道是否要將該 item 新增到最後一個 item
  • 以上所有 item 會以 . (點號運算子) 鏈結在一起,就像您在先前的程式碼研究室中對函式呼叫和屬性存取作業所做的一樣

Filter

集合(collection) 的另一個常見操作是尋找與特定條件相符的 item。filter() 函式會根據運算式傳回相符 collection 中的 item。

  1. println() 之後,新增以下這行:
1
2
val filteredNames = peopleAges.filter { it.key.length < 4 }
println(filteredNames)

另請注意,filter 的呼叫不需要括號,而 it 是指 list 中目前的 item。

執行您的程式並查看額外的結果:

1
{Ann=23, Joe=51}

在此情況下,運算式會取得 key 的長度 (String),並檢查其是否小於 4。任何符合條件的項目 (也就是名稱少於 4 個半形字元) 都會新增至新的 collection。

filter 套用至對應時,系統會傳回新的類型 (LinkedHashMap)。您可以在對應上進行額外的處理,或將其轉換成其他類型的 collection,例如 list。


了解Lambda和高階函式

Lambda

讓我們再回到先前的範例:

1
peopleAges.forEach { print("${it.key} is ${it.value}") }

有一變數 (peopleAges) 呼叫了函式 (forEach)。在函式名稱前方加上括號時,你會在大括號後方看到大括號 {} 的部分程式碼。程式碼中也會使用上一個步驟中的 mapfilter 函式。forEach 函式會透過 peopleAges 變數呼叫,並使用大括號中的程式碼。

就好像您用大括號表示小函式,卻並沒有函式名稱。這個沒有名稱而可立即用作運算式的函式是很實用的概念,也就是所謂的「lambda 運算式」,簡稱 lambda

這是一個重要主題,可讓您瞭解如何利用 Kotlin 功能強大的函式與函式互動。您可將函式儲存在變數和類別中、將引數傳遞為引數,甚至傳回函式。就像其他變數 (例如 IntString) 的變數一樣。

注意: 由於 lambda 通常只有單一參數,因此 Kotlin 提供了簡要參數。Kotlin 以隱含識別碼 it 做為一個 lambda 參數 (使用單一參數)。

函式類型

為了啟用這種類型的行為,Kotlin 提供了一個「函式類型(function types)」,可讓您根據其輸入參數和傳回值定義特定類型的函式。格式如下:

函式類型範例:(Int) -> Int

具有上述函式類型的函式必須採用 Int 類型的參數,並傳回 Int 類型的值。在函式類型標記中,這些參數會以括號列出 (如有多個參數,請以半形逗號分隔)。旁邊有箭頭 ->,後面接著傳回類型。

哪些函式符合這項條件?如下所示,您可以使用 lambda 運算式將整數輸入的值改成三倍,如下所示。針對 lambda 運算式的語法,參數會先顯示 (以紅色方塊醒目顯示),後面接著函式箭頭,然後是函式內文 (以紫色方塊醒目顯示)。lambda 中的最後一個運算式是傳回值。

您甚至可以將 lambda 儲存在變數中,如下圖所示。這個語法類似於宣告基本資料類型 (例如 Int) 的變數時所用的方式。觀察變數名稱 (黃色方塊)、變數類型 (藍色方塊) 和變數值 (綠色方塊)。triple 變數會儲存函式。其類型為 (Int) -> Int 的函式類型,且值為 lambda 運算式 { a: Int -> a * 3}

  1. 在遊樂場裡試用這個程式碼。你可以傳送 triple 函式 (如 5) 來呼叫並呼叫它。
1
2
3
4
fun main() {
val triple: (Int) -> Int = { a: Int -> a * 3 }
println(triple(5))
}
  1. 產生的輸出內容應為:
1
15
  1. 在大括號中,可明確宣告參數 (a: Int)、省略函式箭頭 (->),而是只包含函式主體。更新 main 函式中宣告的 triple 函式,然後執行程式碼。
1
val triple: (Int) -> Int = { it * 3 }
  1. 輸出結果應該相同,但僅以更精簡的方式呈現 lambda!如需更多 lambda 的範例,請參考這個資源
1
15

⭐️ 嘗試修改一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
fun main() {
/* function type: 輸入Int 回傳Int */
val triple: (Int) -> Int = { a: Int -> a * 3 }
println(triple(6))

/* function type: 輸入String 回傳String */
val name: (String) -> String = { a: String -> "My name is " + a }
println(name("Tina"))

/* function type: 輸入String 回傳Int */
val nameLen: (String) -> Int = { a: String -> a.length }
println(nameLen("Tina"))
}

執行結果:

1
2
3
18
My name is Tina
4

高階函式

您現在已學會在 Kotlin 中操縱函式的彈性了,接著我們要談談另一個功能強大的概念,也就是高階函式(Higher-order functions)。這只是將函式 (在本例中為 lambda) 傳遞給其他函式,或從另一個函式傳回函式

結果指出,mapfilterforEach 函式都是高階函式的範例,因為它們都會將函式視為參數。(在傳送至這個 filter 較高順序函式的 lambda 中,您可以省略單一參數和箭頭符號,也可以使用 it 參數)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
fun main() {
val peopleAges = mutableMapOf<String, Int>(
"Fred" to 30,
"Ann" to 23
)
peopleAges.put("Barbara", 42)
peopleAges["Joe"] = 51
peopleAges["Fred"] = 31
println(peopleAges)

/* it 代表 peopleAges 中的 item */
/* forEach */
peopleAges.forEach { print("${it.key} is ${it.value}, ") }

/* map */
peopleAges.map { print("${it.key} is ${it.value}, ") }
println(peopleAges.map { "${it.key} is ${it.value}" }.joinToString(", "))

/* filter */
println(peopleAges.filter { it.key.length < 4 })
}

以下是高優先順序函式的範例:sortedWith()

如果想排序字串 list,可以使用內建的 sorted() 方法集合。不過,如果您想用字串長度為 list 排序,就必須撰寫一些程式碼來比較兩個字串的長度。Kotlin 可讓您將 lambda 傳遞至 sortedWith() 方法,藉此達成此目標。

注意: 如要比較兩個要排序的 objects,慣例是在第一個 objects 小於第二個時傳回小於 0 的值,如果二者大小相同,則會傳回 0;如果第一個 objects 大於第二個 objects,則會傳回大於 0 的值。

  • objects1 < objects2 回傳 小於 0 的值
  • objects1 == objects2 回傳 0
  • objects1 > objects2 回傳 大於 0 的值
  1. 在 Playground 中,使用這個程式碼建立 name list,並按 name 排序輸出該 list:
1
2
3
4
fun main() {
val peopleNames = listOf("Fred", "Ann", "Barbara", "Joe")
println(peopleNames.sorted())
}

執行結果:

1
[Ann, Barbara, Fred, Joe]
  1. 現在請將 lambda 傳送至 sortedWith() 函式,按名稱長度排序 list。lambda 應採用相同類型的兩個參數並傳回 Int。在 main() 函式的 println() 陳述式後方加上這行程式碼。
1
println(peopleNames.sortedWith { str1: String, str2: String -> str1.length - str2.length })
  1. 執行程式並查看結果。
1
2
[Ann, Barbara, Fred, Joe]
[Ann, Joe, Fred, Barbara]

傳送至 sortedWith()lambda 有兩個參數,str1Stringstr2 則為 String。然後,您就會依次看到函式箭頭和函式主體。

請記住,lambda 中的最後一個運算式是傳回 value。在這種情況下,系統會傳回第一個字串和第二個字串長度之間的差異,也就是 Int。符合排序所需的項目:

  • 如果 str1.length < str2.length,則會傳回小於 0 的值
  • 如果 str1.length == str2.length,系統會傳回 0
  • 如果 str1.length > str2.length,就會傳回大於 0 的值

透過一次比較兩個 Strings 的方式,sortedWith() 函式會輸出 list,list 將按照長度遞增排序。

Android 中的 OnClickListener 和 OnKeyListener

統整一下上述內容與您目前學到的 Android 知識,您會發現其實在之前的程式碼研究室就用過 lambda,例如在小費計算機應用程式中為按鈕設定點按事件監聽器時:

1
calculateButton.setOnClickListener{ calculateTip() }

只要使用 lambda 來設定點擊事件監聽器,就能快速完成。編寫上述程式碼的長版方法如下所示,並與精簡版相比。您不一定要知道長版版本的所有詳細資料,但需要注意這兩種版本的模式。

觀察 lambdaOnClickListener 中的 onClick() 方法具有相同函式類型的方式 (使用一個 View 引數並傳回 Unit,表示沒有傳回值)。

由於 Kotlin 中名為 SAM (Single-Abstract-Method) 轉換,您可以縮短程式碼的版本。Kotlin 會將 lambda 轉換為一個實作單一抽象方法 onClick()OnClickListener 物件(object)。只要確認 lambda 函式類型與抽象函式的函式類型相符即可。

由於 lambda 中一律不使用 view 參數,因此可略過該參數。接著我們只具備 lambda 中的功能主體。

1
calculateButton.setOnClickListener { calculateTip() }

這些概念非常具有挑戰性,因此請耐心累積經驗,您需要一些時間才能沉澱所學。再舉另一個例子回想一下您在小費計算機的「Cost of service」文字欄位中設定的按鍵事件監聽器,以便在使用者按下 Enter 鍵後把螢幕小鍵盤隱藏起來。

1
costOfServiceEditText.setOnKeyListener { view, keyCode, event -> handleKeyEvent(view, keyCode) }

查詢 OnKeyListener 時,抽象方法擁有下列參數 onKey(View v, int keyCode, KeyEvent event),並傳回 Boolean。由於 Kotlin 中的 SAM 轉換功能,您可以將 lambda 傳送至 setOnKeyListener()。請確認 lambda 的函式類型為 (View, Int, KeyEvent) -> Boolean

以下是使用的 運算式。參數的值為 ViewkeyCodeevent。函式主體包含使用 handleKeyEvent(view, keyCode) 並傳入 Boolean 的參數。

注意: 如果您並未在函式內文中使用 lambda 參數,則可為 _ 命名,以便更清晰易懂。這段程式碼的行為相同。

1
costOfServiceEditText.setOnKeyListener { view, keyCode, _ -> handleKeyEvent(view, keyCode) }

製作字詞List

現在,請將您學到的 collectionslambdashigher order functions(高階函式) 全部納入考量,然後應用在實際用途中。

假設您想要建立 Android 應用程式來玩文字遊戲或學習字彙。這個應用程式看起來會像這樣,每個字母中的每個字母都會顯示按鈕:

按一下字母 A,畫面上隨即會顯示一份以字母 A 開頭的幾個字詞 list 等等。

你需要收集一組字詞,但要使用哪一種集合(collections)?如果應用程式要包含一些字母開頭的字母,您就必須找到或整理所有以特定字母開頭的字詞。如要增加挑戰,建議您在每次使用者執行應用程式時,從產品素材資源集合中選擇不同的字詞。

首先是字詞 list。在真正的應用程式中,建議您建立更長的字詞 list,並且包括以字母表中所有字母做為開頭的字詞,不過現在我們只要一個簡短的 list 就足夠了。

  1. 使用下列程式碼取代 Kotlin Playground 中的程式碼:
1
2
3
fun main() {
val words = listOf("about", "acute", "awesome", "balloon", "best", "brief", "class", "coffee", "creative")
}
  1. 如要取得開頭為字母 B 的字詞集合,您可以使用 filter 搭配 lambda 運算式。新增以下幾行內容:
1
2
val filteredWords = words.filter { it.startsWith("b", ignoreCase = true) }
println(filteredWords)

如果字串開頭為指定字串,startsWith() 函式則會傳回 true。您也可以指定系統忽略大小寫,因此「b」的比對結果會包括「b」或「B」。

  1. 執行程式並查看結果:
1
[balloon, best, brief]
  1. 別忘了,您希望應用程式隨機挑選字詞。使用 Kotlin 集合時,您可以使用 shuffled() 函式,為隨機隨機分組的項目建立副本。同時變更篩選過的字詞。
1
2
val filteredWords = words.filter { it.startsWith("b", ignoreCase = true) }
.shuffled()

shuffled():回傳一個新 list,該 list 的元素隨機排列。

  1. 執行您的程式並查看新的結果:
1
2
3
第一次:[brief, best, balloon]
第二次:[best, brief, balloon]
第三次:[brief, best, balloon]
  • 由於這類字詞會隨機隨機播放,所以您看到的字詞可能會有不同的排列順序。
  1. 您不想使用所有字,您可以使用 take() 函式取得產品素材資源集合中的第一個項目。讓篩選後的字詞只包含前兩個重組字詞:
1
2
3
4
5
val filteredWords = words.filter { it.startsWith("b", ignoreCase = true) }
.shuffled()

println(filteredWords)
println(filteredWords.take(2))

take(n: Int):回傳包含前 n 個元素的 list。

  1. 執行您的程式並查看新的結果:
1
2
[best, balloon, brief]
[best, balloon]
  1. 最後,請針對該應用程式的隨機字詞 list 隨機排序。一如往常,您可以使用 sorted() 函式,傳回含有下列項目的集合副本:
1
2
3
4
val filteredWords = words.filter { it.startsWith("b", ignoreCase = true) }
.shuffled()
.take(2)
.sorted()
  1. 執行您的程式並查看新的結果:
    1
    [balloon, best]

上述所有程式碼:

1
2
3
4
5
6
7
8
fun main() {
val words = listOf("about", "acute", "awesome", "balloon", "best", "brief", "class", "coffee", "creative")
val filteredWords = words.filter { it.startsWith("b", ignoreCase = true) }
.shuffled()
.take(2)
.sorted()
println(filteredWords)
}

總結

  • 集合(collection) 是指一組相關項目
  • 集合(collection) 可以是 可變更(mutable)不可變更(immutable)
  • 集合(collection) 可以是有序或無序的
  • 集合(collection) 可以有唯一項目或允許重複項目
  • Kotlin 支援不同類型的 集合(collection),包括 listsetmap
  • Kotlin 提供了許多處理及轉換集合的功能,包括 forEachmapfiltersorted 等。
  • lambda 是一種不含名稱的函式,不可用來立即傳遞運算式。例如:{ a: Int -> a * 3 }
  • 高階函式(higher-order function) 指的是將函式傳送至其他函式,或傳回來自其他函式的函式。