Kotlin 集合处理:map、filter、groupBy 实用指南
1. 概览
大部分业务代码都是在变换列表:提取几个字段、按条件过滤、分组求和。Kotlin 的标准库内置了 map、filter 和 groupBy 扩展函数,所以你无需额外依赖。
本文逐一介绍这些函数及其重要变体,然后将它们组合成完整的处理管道。文章最后包含测试验证和常见错误列表。
2. 样本数据
本文所有例子都使用同一份订单列表:
enum class Status { NEW, PAID, CANCELLED }
data class Order(
val id: Int,
val customer: String,
val status: Status,
val total: Int, // 单位:千元
val coupon: String? = null
)
val orders = listOf(
Order(1, "An", Status.PAID, 120, coupon = "SALE10"),
Order(2, "Bình", Status.NEW, 80),
Order(3, "An", Status.CANCELLED, 50),
Order(4, "Chi", Status.PAID, 200, coupon = "VIP"),
Order(5, "Bình", Status.PAID, 45),
)
orders 的类型是 List<Order>,即一个不可变列表。下面的函数不会修改原列表,每次调用都返回一个新的集合。
3. map:转换每个元素
3.1. map 基础用法
map 接收一个 lambda (T) -> R,将其应用到每个元素并返回 List<R>:
val ids = orders.map { it.id }
val customers = orders.map { it.customer }
println(ids)
println(customers)
[1, 2, 3, 4, 5]
[An, Bình, An, Chi, Bình]
结果始终与原列表有相同数量的元素且保持顺序不变。如果只想要唯一值,可以追加 .distinct():orders.map { it.customer }.distinct() 得到 [An, Bình, Chi]。
对于简单的属性访问,可以用属性引用代替 lambda:orders.map(Order::id)。
3.2. mapNotNull:转换并移除 null
当 lambda 可能返回 null 时,使用 mapNotNull 既可以转换又可以过滤掉 null 值:
val coupons: List<String> = orders.mapNotNull { it.coupon }
println(coupons)
[SALE10, VIP]
返回类型是 List<String>,不是 List<String?>。这种方式比 map { it.coupon }.filterNotNull() 更简洁且类型安全。
3.3. mapIndexed:需要知道索引
val lines = orders.mapIndexed { index, order -> "${index + 1}. 订单 #${order.id} - ${order.total}元" }
lines.forEach(::println)
1. 订单 #1 - 120元
2. 订单 #2 - 80元
3. 订单 #3 - 50元
4. 订单 #4 - 200元
5. 订单 #5 - 45元
3.4. map 作用在 Map 上
对于 Map,调用 map 会返回 List。如果想让结果仍是 Map,应使用 mapValues 或 mapKeys:
val prices = mapOf("cafe" to 30, "tra" to 25)
val labels = prices.map { (name, price) -> "$name: ${price}k" } // List<String>
val withVat = prices.mapValues { (_, price) -> price * 110 / 100 } // Map<String, Int>
println(labels)
println(withVat)
[cafe: 30k, tra: 25k]
{cafe=33, tra=27}
4. filter:保留满足条件的元素
4.1. filter 和 filterNot
filter 接收一个谓词 (T) -> Boolean 并保留谓词返回 true 的元素:
val paid = orders.filter { it.status == Status.PAID }
val notCancelled = orders.filterNot { it.status == Status.CANCELLED }
println(paid.map { it.id })
println(notCancelled.map { it.id })
[1, 4, 5]
[2, 4, 5]
等等,第二行有误。订单 1 没有被取消,所以应该出现。正确的结果是:
[1, 4, 5]
[1, 2, 4, 5]
filterNot 只是 filter { !(...) } 更易读的写法。当否定条件使代码难以阅读时,应使用它。
4.2. partition:一次遍历分成两份
如果你需要既满足又不满足条件的元素,不要调用 filter 两次。partition 返回一个包含两个列表的 Pair:
val (bigOrders, smallOrders) = orders.partition { it.total >= 100 }
println(bigOrders.map { it.id })
println(smallOrders.map { it.id })
[1, 4]
[2, 3, 5]
解构 val (a, b) 有助于为每一半清晰命名。
4.3. filterIsInstance:按类型过滤
处理异质列表时,filterIsInstance<T>() 既能过滤又能智能转型:
sealed interface Event
data class OrderPlaced(val orderId: Int) : Event
data class OrderShipped(val orderId: Int, val carrier: String) : Event
val events: List<Event> = listOf(OrderPlaced(1), OrderShipped(1, "GHN"), OrderPlaced(2))
val shipped: List<OrderShipped> = events.filterIsInstance<OrderShipped>()
println(shipped.map { it.carrier })
[GHN]
不需要手动用 as 强制转型。结果已经具有 List<OrderShipped> 的类型。
5. groupBy:按键分组
5.1. groupBy 基础用法
groupBy 接收一个选择键的函数并返回 Map<K, List<T>>:
val byCustomer: Map<String, List<Order>> = orders.groupBy { it.customer }
println(byCustomer.mapValues { (_, list) -> list.map { it.id } })
{An=[1, 3], Bình=[2, 5], Chi=[4]}
返回的 Map 是 LinkedHashMap。键的顺序按其首次出现的顺序,每个分组内的元素顺序也被保持。
5.2. groupBy 配合 valueTransform
第二个重载允许在分组时同时转换值,所以你不需要单独调用 mapValues:
val totalsByCustomer: Map<String, List<Int>> =
orders.groupBy(keySelector = { it.customer }, valueTransform = { it.total })
println(totalsByCustomer)
{An=[120, 50], Bình=[80, 45], Chi=[200]}
5.3. 分组后的聚合
最常见的模式是分组后为每个分组计算一个值:
val revenueByStatus: Map<Status, Int> = orders
.groupBy { it.status }
.mapValues { (_, list) -> list.sumOf { it.total } }
println(revenueByStatus)
{PAID=365, NEW=80, CANCELLED=50}
5.4. groupingBy:聚合而无需中间列表
groupBy 总是为每个分组构建完整的 List。如果你只需要计数或求和,groupingBy 更高效,因为它直接累积:
val orderCount: Map<String, Int> = orders.groupingBy { it.customer }.eachCount()
val spentByCustomer: Map<String, Int> = orders
.groupingBy { it.customer }
.fold(0) { acc, order -> acc + order.total }
println(orderCount)
println(spentByCustomer)
{An=2, Bình=2, Chi=1}
{An=170, Bình=125, Chi=200}
groupingBy 返回一个 Grouping 对象,尚未进行任何计算。聚合操作只在调用 eachCount、fold、reduce 或 aggregate 时执行。
5.5. groupBy 与 associateBy 的区别
这两个函数经常被混淆:
val lastOrderByCustomer: Map<String, Order> = orders.associateBy { it.customer }
println(lastOrderByCustomer.mapValues { it.value.id })
{An=3, Bình=5, Chi=4}
associateBy 创建 Map<K, T>,即每个键对应一个元素。当键重复时,后来的元素会覆盖前面的元素。因此,只在键唯一时使用 associateBy,例如 associateBy { it.id }。如果键可能重复,应使用 groupBy。
6. 组合成处理管道
上述函数可以链接在一起。下面的例子按客户计算已支付订单的收入,然后按降序排列:
fun paidRevenueRanking(orders: List<Order>): List<Pair<String, Int>> =
orders
.filter { it.status == Status.PAID }
.groupBy { it.customer }
.mapValues { (_, list) -> list.sumOf { it.total } }
.toList()
.sortedByDescending { it.second }
println(paidRevenueRanking(orders))
[(Chi, 200), (An, 120), (Bình, 45)]
处理管道依次执行以下步骤:
filter保留订单 1、4 和 5。groupBy+mapValues产生{An=120, Chi=200, Bình=45}。toList()将Map转换为List<Pair<String, Int>>,以便排序。sortedByDescending按收入排序。
注意:
List上的每一步都是贪心的。它遍历全部数据并创建新列表。对于几千个元素来说可以忽略不计。对于大数据或只需要前几个元素(take、first)的情况,应转换为asSequence()以实现逐元素的延迟处理:
val firstTwoBigPaid = orders.asSequence()
.filter { it.status == Status.PAID }
.map { it.id }
.take(2)
.toList()
println(firstTwoBigPaid)
[1, 4]
使用 Sequence,处理链在获取到足够的 2 个元素后立即停止。订单 5 不会被考虑。
7. 为处理管道编写测试
在 pom.xml 中添加 JUnit 5 和 AssertJ。Kotlin 项目需要已包含 kotlin-maven-plugin:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class OrderStatsTest {
@Test
fun `按已支付订单的收入排序`() {
val result = paidRevenueRanking(orders)
assertThat(result).containsExactly("Chi" to 200, "An" to 120, "Bình" to 45)
}
@Test
fun `groupingBy eachCount 正确计算每个客户的订单数`() {
val count = orders.groupingBy { it.customer }.eachCount()
assertThat(count).containsExactlyEntriesOf(mapOf("An" to 2, "Bình" to 2, "Chi" to 1))
}
@Test
fun `filter 不修改原列表`() {
orders.filter { it.total > 100 }
assertThat(orders).hasSize(5)
}
}
containsExactly 检查内容和顺序。这很重要,因为排名结果取决于顺序。containsExactlyEntriesOf 也比较条目顺序,因此验证了 LinkedHashMap 保持顺序的特性。
8. 常见错误
- 两次使用
filter且条件相反。 代码会遍历列表两次且容易出现条件偏差。应使用partition。 - 为可能重复的键使用
associateBy。 数据会被覆盖但不会报错。这是一个很难发现的 bug。 - 仅为计数或求和而使用
groupBy。 这会创建不必要的中间列表。groupingBy { }.eachCount()或.fold()更简洁且高效。 - 在
map中引起副作用。 例如在map内调用paymentService.charge(it)。map用于转换数据。如果需要对每个元素执行操作,应使用forEach来明确意图。 - 使用
map { }.filterNotNull()。 应替换为mapNotNull。 - 对所有情况都使用
Sequence。 对小列表,Sequence通常较慢,因为创建迭代器和 lambda 有成本。只在数据大、管道长或有提前终止操作时转用Sequence。 - 在循环内多次对同一数据调用
groupBy。 应计算一次,将Map保存到变量中,然后查询。
注意: 当数据来自数据库时,应使用 SQL(
WHERE、GROUP BY)进行过滤和分组,而不是将全部数据加载到内存然后调用filter/groupBy。集合函数适用于已在内存中且大小适中的数据。
9. 总结
map 转换每个元素,filter 保留满足条件的元素,而 groupBy 根据键将元素分组成 Map。每个函数都有专门的变体:
mapNotNull、mapIndexed、mapValues用于转换。filterNot、partition、filterIsInstance用于过滤。groupingBy、associateBy用于分组和创建Map。
选择正确的变体有助于代码更简洁,避免 bug。将它们组合成处理管道时,应使用 AssertJ 编写测试来验证结果的内容和顺序。在数据量大或需要提前终止时,应考虑使用 Sequence。