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。

kotlincollectionmapfiltergroupBy