Kotlin 中的集合

1. 准备工作

在此 Codelab 中,您将详细了解集合以及 Kotlin 中的 lambda 和高阶函数。

前提条件

  • 对之前的 Codelab 中介绍的 Kotlin 概念有基本的了解。
  • 熟悉如何使用 Kotlin 园地创建和修改 Kotlin 程序。

学习内容

  • 如何使用包括集和映射在内的集合
  • lambda 的基础知识
  • 高阶函数的基础知识

所需条件

2. 了解集合

集合是一组相关项,例如一系列字词或一组员工记录。集合中包含的项可以有序也可以无序,可以具有唯一性也可以不具唯一性。您已经学过一种类型的集合,那就是列表。列表项是有序的,但不必是唯一的。

与列表一样,Kotlin 也会区分可变集合和不可变集合。Kotlin 提供了许多函数,用于添加或删除项以及查看和操控集合。

创建列表

在此任务中,您将复习如何创建数字列表并对其排序。

  1. 打开 Kotlin 园地
  2. 将任意代码替换为以下代码:
fun main() {
    val numbers = listOf(0, 3, 8, 4, 0, 5, 5, 8, 9, 2)
    println("list:   ${numbers}")
}
  1. 点按绿色箭头运行程序,并查看显示的结果:
list:   [0, 3, 8, 4, 0, 5, 5, 8, 9, 2]
  1. 该列表包含 0 到 9 之间的 10 个数字。有些数字多次出现,而有些则根本没有显示。
  2. 列表项的顺序很重要:第一项是 0,第二项是 3,依此类推。除非您做出更改,否则列表项将保持此顺序不变。
  3. 回想之前的 Codelab 内容可知,列表中有许多内置函数,例如返回按升序排序的列表副本的 sorted()。在 println() 之后,将一行代码添加到程序中,用于输出经过排序的列表副本:
println("sorted: ${numbers.sorted()}")
  1. 再次运行程序并查看结果:
list:   [0, 3, 8, 4, 0, 5, 5, 8, 9, 2]
sorted: [0, 0, 2, 3, 4, 5, 5, 8, 8, 9]

对数字进行排序后,更容易看出每个数字在列表中出现的次数或者是否根本没有显示。

详细了解集

Kotlin 中另一种类型的集合是集是一组相关项,但与列表不同,其中不能存在任何重复项,而且顺序也不重要。某个项可以在集中也可以不在集中,但如果在集中,那么集中只能包含该项一个的副本。这与数学中“一套”的概念相似。比如,您看过一套书,即使您反复读其中的一本书,也不能改变这本书属于这一套书的事实。

  1. 将以下几行代码添加到程序中,以将列表转换为集:
val setOfNumbers = numbers.toSet()
println("set:    ${setOfNumbers}")
  1. 运行程序并查看结果:
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]

结果中包含原列表中的所有数字,但每个数字都只出现了一次。请注意,这些数字的顺序与原列表中的顺序相同,但这个顺序对集来说并不重要。

  1. 定义一个可变集和一个不可变集,并通过添加下列几行代码使用同一组数字(但数字的顺序不同)对其进行初始化:
val set1 = setOf(1,2,3)
val set2 = mutableSetOf(3,2,1)
  1. 添加一行代码以输出两个集是否相等:
println("$set1 == $set2: ${set1 == set2}")
  1. 运行程序并查看新的结果:
[1, 2, 3] == [3, 2, 1]: true

即使一个集可变一个集不可变,而且两个集中的项顺序不同,这两个集也被视为相等,因为它们包含完全相同的一组项。

您可能会对集执行的一项主要操作是使用 contains() 函数检查某个特定项是否包含在集中。您以前看过 contains(),但当时是将其用于列表。

  1. 将以下这行代码添加到程序中,用于输出 7 是否包含在集中:
println("contains 7: ${setOfNumbers.contains(7)}")
  1. 运行程序并查看更多结果:
contains 7: false

您也可以尝试使用一个包含在集中的值对其进行测试。

上面的所有代码汇总如下:

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() 执行求两个集的交集 (∩) 或并集 (∪) 等运算。

详细了解映射

您在此 codelab 中要了解的最后一种类型的集合是映射或字典。映射是一组键值对,用于在给定特定键的情况下轻松查找值。键具有唯一性,每个键只映射到一个值,但值可以重复。映射中的值可以是字符串、数字或对象,甚至可以是列表或集等其他集合。

b55b9042a75c56c0.png

当您有成对数据并可根据键来识别每对数据时,映射将非常有用。键“映射到”对应的值。

  1. 在 Kotlin 园地中,使用以下代码替换所有代码,以创建一个可变映射,用于存储人名及其年龄:
fun main() {
    val peopleAges = mutableMapOf<String, Int>(
        "Fred" to 30,
        "Ann" to 23
    )
    println(peopleAges)
}

这将创建一个从 String(键)到 Int(值)的可变映射,初始化包含两个条目的映射,并输出项。

  1. 运行程序并查看结果:
{Fred=30, Ann=23}
  1. 如需将更多条目添加到该映射中,可以使用 put() 函数,传入键和值:
peopleAges.put("Barbara", 42)
  1. 您还可以使用简写表示法来添加条目:
peopleAges["Joe"] = 51

以下是上面的所有代码:

fun main() {
    val peopleAges = mutableMapOf<String, Int>(
        "Fred" to 30,
        "Ann" to 23
    )
    peopleAges.put("Barbara", 42)
    peopleAges["Joe"] = 51
    println(peopleAges)
}
  1. 运行程序并查看结果:
{Fred=30, Ann=23, Barbara=42, Joe=51}

如上所述,键(名称)具有唯一性,但值(年龄)可以重复。如果尝试使用一个相同的键添加一个项,您认为会发生什么情况?

  1. println() 之前,添加以下这行代码:
peopleAges["Fred"] = 31
  1. 运行程序并查看结果:
{Fred=31, Ann=23, Barbara=42, Joe=51}

"Fred" 不会再次添加,但其映射到的值会更新为 31

可以看出来,映射可以快速将键映射到值,在代码中非常有用!

3. 使用集合

虽然不同类型的集合具有不同的特性,但它们有许多相同的行为。如果是可变集合,那么您可以添加或移除项。您可以枚举所有项、查找特定项,有时还可以将一种类型的集合转换为另一种类型。您先前就进行过这样的转换,使用 toSet()List 转换为 Set。以下是使用集合时可以用到的一些实用函数。

forEach

假设您要输出 peopleAges 中的项,并且包括人名和年龄。例如:"Fred is 31, Ann is 23,...",等等。您在之前的某个 Codelab 中曾学过 for 循环,因此您可以使用 for (people in peopleAges) { ... } 编写一个循环。

不过,枚举集合中的所有对象是一项常见的操作,所以 Kotlin 提供了 forEach() 来遍历所有项并对每个项执行操作。

  1. 在园地中,在 println() 之后添加以下代码:
peopleAges.forEach { print("${it.key} is ${it.value}, ") }

它与 for 循环类似,但更简洁一点。forEach 使用特殊标识符 it,而不是由您为当前项指定变量。

请注意,调用 forEach() 方法时不需要添加圆括号,只需将代码传入花括号 {} 即可。

  1. 运行程序并查看更多结果:
Fred is 31, Ann is 23, Barbara is 42, Joe is 51,

结果已非常接近您想要的内容,只是末尾多了一个英文逗号。

将集合转换为字符串是一项常见的操作,末尾多出分隔符也是一个常见问题。您将在后面的步骤中了解如何处理此问题。

map

map() 函数(不应与上面的映射或字典集合混淆)对集合中的每个项应用转换。

  1. 在程序中,将 forEach 语句替换为以下这行代码:
println(peopleAges.map { "${it.key} is ${it.value}" }.joinToString(", ") )
  1. 运行程序并查看更多结果:
Fred is 31, Ann is 23, Barbara is 42, Joe is 51

输出的内容正确,并且没有额外的英文逗号!一行代码中包含的内容非常多,所以我们要更详细地介绍一下。

  • peopleAges.mappeopleAges 中的每个项应用转换,并以转换后的项创建一个新集合
  • 花括号 {} 中的部分定义了要对每个项应用的转换。转换接受键值对并将其转换为字符串,例如将 <Fred, 31> 转换为 Fred is 31
  • joinToString(", ") 将转换后的集合中的每个项添加到字符串中并以 , 分隔,而且它还知道不要在最后一个项后添加此分隔符
  • 所有上述代码都以 .(点运算符)链接到一起,正如您在之前的 Codelab 中对函数调用和属性访问所做的一样

filter

集合的另一项常见操作是查找符合特定条件的项。filter() 函数根据表达式返回集合中的匹配项。

  1. println() 之后,添加以下这几行代码:
val filteredNames = peopleAges.filter { it.key.length < 4 }
println(filteredNames)

同样请注意,调用 filter 不需要圆括号,it 表示列表中的当前项。

  1. 运行程序并查看更多结果:
{Ann=23, Joe=51}

在本例中,表达式会获取键(一个 String)的长度并检查其是否小于 4。任何匹配项(即名称少于 4 个字符的项)都被添加到新集合中。

对映射应用过滤条件时返回的类型是一个新映射 (LinkedHashMap)。您可以对该映射执行其他处理,也可以将其转换为列表等其他类型的集合。

4. 了解 lambda 和高阶函数

lambda

我们来回顾一下前面的这个示例:

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

代码中有一个变量 (peopleAges) 和对其调用的一个函数 (forEach)。函数名称后不是用圆括号括起来的参数,而是用花括号 {} 括起来的一些代码。同样的格式也出现在上一步使用 mapfilter 函数的代码中。这里对 peopleAges 变量调用了 forEach 函数,并使用花括号中的代码。

这就像在花括号中编写一小段函数,但没有函数名称。这种想法(没有名称但可立即用作表达式的函数)是一个非常有用的概念,称为 lambda 表达式,简称 lambda。

由此引出一个重要的主题,那就是如何借助 Kotlin 以有效的方式与函数交互。您可以将函数存储在变量和类中,将函数作为实参传递,甚至可以返回函数。您可以像处理 IntString 等其他类型的变量那样处理它们。

函数类型

为了支持这种行为,Kotlin 提供了函数类型,供您根据函数的输入形参和返回值定义特定类型的函数。它显示为以下格式:

函数类型示例:(Int) -> Int

具有上述函数类型的函数必须接受类型为 Int 的形参并返回类型为 Int 的值。在函数类型表示法中,形参列于圆括号中(如有多个形参,以英文逗号分隔)。接着是一个箭头 ->,后跟返回值类型。

哪种类型的函数满足此条件?您可以用一个 lambda 表达式将整数输入的值乘以三,如下所示。在 lambda 表达式的语法中,形参在前(以红色框突出显示),后跟函数箭头,再跟函数主体(以紫色框突出显示)。lambda 中的最后一个表达式是返回值。

252712172e539fe2.png

您甚至可以将 lambda 存储在变量中,如下图所示。该语法与声明基本数据类型的变量(如 Int)时使用的语法类似。请注意变量名称(黄色框)、变量类型(蓝色框)和变量值(绿色框)。triple 变量存储了一个函数。其类型为函数类型 (Int) -> Int,值为 lambda 表达式 { a: Int -> a * 3}

  1. 在园地中试一试此代码。定义并调用 triple 函数,向其传递一个数字(例如 5)。4d3f2be4f253af50.png
fun main() {
    val triple: (Int) -> Int = { a: Int -> a * 3 }
    println(triple(5))
}
  1. 生成的输出应如下所示:
15
  1. 在花括号中,您可以省略对形参的显式声明 (a: Int) 也省略函数箭头 (->),而只包含函数主体。更新 main 函数中声明的 triple 函数并运行代码。
val triple: (Int) -> Int = { it * 3 }
  1. 输出应该相同,但是现在 lambda 编写得更简洁!如需查看 lambda 的更多示例,请查看此资源
15

高阶函数

现在,您已经开始明白 Kotlin 中的函数操控方式的灵活性,接下来我们要介绍另一个非常有用的概念,高阶函数。这个概念就是指将一个函数(在本例中为 lambda)传递给另一个函数,或从另一个函数返回一个函数。

因此,mapfilterforEach 函数都是高阶函数的例子,因为它们都接受函数作为形参(在传递给此 filter 高阶函数的 lambda 中,可以省略仅有的一个形参和箭头符号,还可以使用 it 形参)。

peopleAges.filter { it.key.length < 4 }

下面是一个新高阶函数的示例:sortedWith()

如果要对字符串列表排序,可以使用集合的内置 sorted() 方法。但是,如果要按字符串的长度对列表排序,就需要编写一些代码来获取两个字符串的长度并对其进行比较。在 Kotlin 中,您可以将一个 lambda 传递给 sortedWith() 方法来做到这一点。

  1. 在园地中,使用以下代码创建一个名称列表,并输出按名称排序的该列表:
fun main() {
    val peopleNames = listOf("Fred", "Ann", "Barbara", "Joe")
    println(peopleNames.sorted())
}
  1. 现在,将一个 lambda 传递给 sortedWith() 函数,输出按名称长度排序的该列表。lambda 应接受同一类型的两个参数,并返回一个 Int。在 main() 函数中的 println() 语句后添加以下这行代码。
println(peopleNames.sortedWith { str1: String, str2: String -> str1.length - str2.length })
  1. 运行程序并查看结果。
[Ann, Barbara, Fred, Joe]
[Ann, Joe, Fred, Barbara]

传递给 sortedWith() 的 lambda 有两个形参:str1(是一个 String)和 str2(也是一个 String)。其后是函数箭头,后跟函数主体。

7005f5b6bc466894.png

请注意,lambda 中的最后一个表达式是返回值。在本例中,它将返回第一个字符串的长度与第二个字符串的长度之间的差(是一个 Int)。这与排序需要返回的值一致:如果 str1str2 短,将返回一个小于 0 的值。如果 str1str2 长度相同,将返回 0。如果 str1 的长度大于 str2,将返回一个大于 0 的值。通过一系列比较(一次比较两个 Strings),sortedWith() 函数会输出一个列表,其中的名称按长度递增的顺序排列。

Android 中的 OnClickListener 和 OnKeyListener

将此与您迄今学过的 Android 知识联系起来,可以发现您在之前的 Codelab 中已经使用过 lambda,例如,当您为 Tip Calculator 应用中的按钮设置点击监听器时:

calculateButton.setOnClickListener{ calculateTip() }

使用 lambda 设置点击监听器非常简单、方便。下面显示了采用长格式编写上述代码的方式,并与简写版本进行了比较。您不必了解长格式版本的所有细节,但是要注意两个版本之间的一些模式。

29760e0a3cac26a2.png

请注意,lambda 的函数类型与 OnClickListener 中的 onClick() 方法相同(接受一个 View 实参并返回 Unit,这意味着没有返回值)。

我们之所以能够简化代码,要归功于 Kotlin 中的 SAM(单一抽象方法)转换。Kotlin 将 lambda 转换为 OnClickListener 对象,由其实现单一抽象方法 onClick()。您只需确保 lambda 函数类型与抽象函数的函数类型匹配即可。

由于 lambda 中从不使用 view 参数,因此可以省略该参数。如此一来,lambda 中就只包含函数主体。

calculateButton.setOnClickListener { calculateTip() }

这些概念比较难掌握,请耐心学习,您需要付出一些时间并积累经验才能领会。我们再来看一个例子。回想一下,您在 Tip Calculator 应用中对“Cost of service”文本字段设置了按键监听器,因此屏幕键盘可在按 Enter 键时隐藏。

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

下图显示了上面使用的 lambda 表达式。其形参包括 view、keyCode 和 event。函数主体由 handleKeyEvent(view, keyCode) 构成,它使用传入的形参并返回一个 Boolean

f73fe767b8950123.png

5. 创建单词列表

现在,让我们将您学到的所有集合、lambda 和高阶函数知识应用到一个实际用例中。

假设您要创建一个 Android 应用来玩文字游戏或学习词汇。该应用可能与下图类似,为字母表中的每个字母提供一个按钮:

7539df92789fad47.png

点击字母 A 会调出一个简短的列表,包含以字母 A 开头的一些单词,余者依此类推。

您需要一个单词集合,但应该是哪种类型的集合呢?如果该应用将包含以字母表中每个字母开头的一些单词,您就需要通过一种办法来查找或整理以给定字母开头的所有单词。为提高挑战性,您还需要在用户每次运行应用时从集合中选择不同的单词。

首先,从单词列表开始。在真实的应用中,您需要使用更长的单词列表,并包含以字母表中所有字母开头的单词,但现在使用一个简短的列表足以。

  1. 使用以下代码替换 Kotlin 园地中的代码:
fun main() {
    val words = listOf("about", "acute", "awesome", "balloon", "best", "brief", "class", "coffee", "creative")
}
  1. 如需获取以字母 B 开头的单词的集合,可以将 filter 与 lambda 表达式结合使用。添加以下几行代码:
val filteredWords = words.filter { it.startsWith("b", ignoreCase = true) }
println(filteredWords)

如果字符串以指定的字符串开头,startsWith() 函数将返回 true。您也可以指示它忽略大小写,因此“b”将与“b”或“B”匹配。

  1. 运行程序并查看结果:
[balloon, best, brief]
  1. 请注意,应用需要随机显示单词。借助 Kotlin 集合,您可以使用 shuffled() 函数随机打乱集合中的项并创建集合的副本。将过滤后的单词也改为打乱顺序:
val filteredWords = words.filter { it.startsWith("b", ignoreCase = true) }
    .shuffled()
  1. 运行程序并查看新的结果:
[brief, balloon, best]

由于单词是随机打乱的,因此您看到的单词可能顺序不同。

  1. 您并不需要使用所有单词(尤其是在实际单词列表很长的情况下),只需使用几个单词即可。您可以使用 take() 函数获取集合中的前 N 个项。使过滤后的单词只包含打乱后的前两个单词:
val filteredWords = words.filter { it.startsWith("b", ignoreCase = true) }
    .shuffled()
    .take(2)
  1. 运行程序并查看新的结果:
[brief, balloon]

同样,由于单词是随机打乱的,因此每次运行程序时看到的单词可能都不同。

  1. 最后,对于需要对每个字母的随机单词列表进行排序的应用,与之前一样,您可以使用 sorted() 函数返回包含已排序项的集合的副本:
val filteredWords = words.filter { it.startsWith("b", ignoreCase = true) }
    .shuffled()
    .take(2)
    .sorted()
  1. 运行程序并查看新的结果:
[balloon, brief]

上面的所有代码汇总如下:

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)
}
  1. 尝试更改代码,创建只含一个以字母 c 开头的随机单词的列表。您必须对上面的代码做出哪些更改?
val filteredWords = words.filter { it.startsWith("c", ignoreCase = true) }
    .shuffled()
    .take(1)

在实际应用中,您需要对字母表中的每个字母应用过滤器,不过现在您已经知道如何生成每个字母的单词列表了!

集合功能强大且非常灵活,不仅作用广泛,而且不拘泥于通过一种方式发挥作用。随着您越来越深入地了解编程,您将了解如何判定适合解决手头问题的集合类型以及处理该问题的最佳方式。

lambda 和高阶函数让集合的使用变得更方便、更简洁。以下概念非常有用,所以我们一再强调这些概念。

6. 总结

  • 集合是一组相关项
  • 集合可以是可变的,也可以是不可变的
  • 集合可以有序,也可以无序
  • 集合可要求项具有唯一性,也可允许重复
  • Kotlin 支持不同类型的集合,包括列表、集和映射
  • Kotlin 提供了许多用于处理和转换集合的函数,包括 forEachmapfiltersorted
  • lambda 是没有名称但可立即作为表达式传递的函数。例如,{ a: Int -> a * 3 }
  • 高阶函数是指将一个函数传递给另一个函数,或从另一个函数返回一个函数

7. 了解更多内容