☰
Kotlin集合操作详解:intersect、subtract、union、minus、plus与retainAll的用法与坑
2026/10/17 15:38:01 网站建设 项目流程

做后端接口或 Android 列表联动的时候,集合操作基本是每天都要写的。两个列表找公共部分、两个集合合并去重、一批配置里挑出已经下线的项目……这类需求你当然可以用循环加 equals 一行行磨,但大多数时候真的没必要。Kotlin 标准库给了intersect/subtract/union/minus/plus/retainAll这一组集合间操作,每一个都能省掉一整段手写过滤逻辑。这篇文章不打算列 API 文档,而是把这几个函数从返回类型、底层行为、复杂度到实际业务里的坑全部拆开,适合刚用 Kotlin 没多久、想把集合操作真正用得明白的同学,也适合那种被 List 和 Set 返回值差异坑过的老手。

1. 六个集合操作逐个拆解:签名、行为与返回类型

先明确一个总前提:intersect、subtract、union、minus、plus这五个都是返回新集合的扩展函数,调用之后原集合完全不变;只有retainAll是直接改原集合的就地操作。这是理解它们编程语义的第一把钥匙。

1.1 intersect:求交集,永远返回 Set

intersect(other)返回调用者和other的共有元素集合。看名字就知道它对应数学上的“交集”。

val listA = listOf(1, 2, 2, 3, 5) val listB = listOf(2, 3, 4) val inter = listA.intersect(listB) println(inter) // [2, 3]

两个细节值得注意。第一,结果类型是Set,不是List。哪怕调用者明明是List,结果里的重复元素也会被压缩,listA里有两个 2,交集中只会出现一次。第二,它是infix函数,所以可以写成listA intersect listB,阅读起来非常接近自然语言。

在处理权限、标签这类天然去重的数据时直接用它,代码可读性会提升一大截。后面我会专门说它和retainAll的行为差异,很多初学者在这里翻车。

1.2 subtract:求差集,同样去重返回 Set

subtract(other)返回调用者中有、而other中没有的元素,返回类型也是Set,结果顺序按调用者的遍历顺序排列。

val installed = listOf("Kotlin", "Java", "Android", "Java") val enabled = listOf("Kotlin", "Compose") val disabled = installed.subtract(enabled) println(disabled) // [Java, Android]

这里installed里 Java 出现了两次,但因为结果是Set,只保留了一个。如果你希望“差集之后仍然保留原有的重复元素”,不能用subtract,要看下面的minus。这是两个函数最核心的区别,业务上经常踩中。

1.3 union:求并集,帮你并完还帮你去重

union(other)返回两个集合中所有元素,去重后的结果。结果的顺序规则是:调用者元素在前,other里新增的元素按遍历顺序跟在后面。

val local = listOf("A", "B", "C") val remote = listOf("C", "D", "E") val merged = local union remote println(merged) // [A, B, C, D, E]

如果你把local换成listOf("A", "A", "B"),结果仍然是[A, B, C, D, E],不会因为左侧重复而多出元素。正因为这种“去重并合并”的特性,union非常适合做标签合并、用户 ID 汇总这类场景。

不过要注意,union的顺序依赖调用者类型。如果调用者是HashSet,元素本身是无序的,结果顺序也无从保证;如果需要固定顺序,调用者用LinkedHashSet或List更靠谱。

1.4 minus:减法的两种形态

minus和subtract看起来都像“差集”,实际差异很大。

minus有两种重载:minus(element)移除单个元素,minus(elements)移除一批元素。最关键的是,它保留调用者的集合类型——List.minus返回List,Set.minus返回Set,并且对List而言,重复的未匹配元素会原样保留。

val nums = listOf(1, 2, 2, 3, 3, 3) // 单个元素:移除所有匹配的 2 println(nums.minus(2)) // [1, 3, 3, 3] // 多个元素:移除所有匹配的 2 和 3 println(nums.minus(listOf(2, 3))) // [1]

注意,nums.minus(listOf(2, 3))结果里 1 之后什么都没剩,因为 2 和 3 全部被过滤掉了。这是“移除所有匹配项”的语义,不是“每种元素只移除一次”。

对比一下:

val nums = listOf(1, 2, 2, 3, 3, 3) val subResult = nums.subtract(listOf(2, 3)) // Set: [1] val minusResult = nums.minus(listOf(2, 3)) // List: [1]

当调用者是List且包含重复时,subtract和minus的差异才能真正体现出来:

val data = listOf(1, 1, 2, 2, 3) val other = listOf(2, 4) println(data.subtract(other)) // Set: [1, 3] println(data.minus(other)) // List: [1, 1, 3]

subtract因为返回Set而去重了 1;minus返回List,两个 1 都保留。如果你的业务依赖“元素出现次数”,一定要区分这俩。

minus还是operator函数,因此可以直接写成list - set的形式:

val filtered = data - setOf(2, 4) println(filtered) // [1, 1, 3]

可读性和手写过滤相比,舒服非常多。

1.5 plus:加法的两种形态

plus也支持element和Iterable两种重载,并且保留调用者集合类型。List.plus返回List,追加元素时不会去重;Set.plus返回Set,重复元素自动被忽略。

val list = listOf(1, 2) val newList = list + 3 println(newList) // [1, 2, 3] val list2 = list + listOf(3, 4) println(list2) // [1, 2, 3, 4] val set = setOf(1, 2) val newSet = set + listOf(2, 3) println(newSet) // [1, 2, 3]

union和plus的最大区别就在这儿:union总是返回Set,天然去重;而plus的结果类型和调用者一致。如果你用List求“并集”并想保留重复项,plus反而更符合直觉。举个容易踩的例子:

val a = listOf(1, 1, 2) val b = setOf(1, 3) println(a union b) // [1, 2, 3] println(a + b) // [1, 1, 2, 1, 3]

a + b的结果里出现了三个 1:原有的两个复制一份,再追加进来的 1 再算一个。很多程序员第一次看到这个输出会愣一下,所以用之前想清楚你要的是数学集合意义上的并集,还是普通的列表拼接。

plus还有对应的+=操作符。需要注意的是,对MutableList或MutableSet使用+=,等价于调用plusAssign,会尽量就地修改;对val声明的不可变集合使用+=,实际是生成了一个新集合然后重新赋值。这个细节我在后面第 4 章展开。

1.6 retainAll:唯一的就地修改操作

retainAll(elements)是MutableCollection接口的成员方法,不是顶层扩展函数。它直接修改调用集合,只保留那些同时出现在elements中的元素,并返回一个Boolean表示集合是否真的发生了变化。

val localConfig = mutableListOf("a", "b", "c", "d") val latestConfig = listOf("b", "c", "e") val changed = localConfig.retainAll(latestConfig) println(localConfig) // [b, c] println(changed) // true

两个特点决定了它的应用场景:

  1. 就地修改:不产生新对象,适合在状态需要原地更新的地方使用,能省掉一次集合拷贝。
  2. 保留 List 语义:如果调用者是MutableList,重复元素会按原顺序保留。例如mutableListOf(1, 2, 2, 3).retainAll(listOf(2, 3))的结果是[2, 2, 3],两个 2 都会留下。

这个“保留重复”的特点让它和intersect的“返回 Set 去重”呈现完全不同的行为。intersect适合计算,retainAll适合就地维护一个需要保序、允许重复的列表。

2. 这些操作是怎么算的:底层实现与复杂度

知道了函数行为只是第一步。真正把它们用好,还得理解底层实现方式,特别是标准库怎样对待不同集合类型、怎样优化contains判断,以及这些优化对你的代码有什么影响。

2.1 不可变与可变的边界

前面说了五个函数返回新集合,只有retainAll修改原集合。这个设计对应的是一个编程习惯:在数据流和函数式风格中,不可变集合安全、方便链式调用、方便并发传递。比如:

val oldTags = listOf("Kotlin", "Java", "Android") val newTags = setOf("Kotlin", "Compose", "Android") val added = newTags subtract oldTags // {Compose} val removed = oldTags subtract newTags // {Java} val merged = oldTags union newTags // {Kotlin, Java, Android, Compose}

这一串计算完全不碰原来变量,后面维护起来很安心。retainAll则相反,适合“有一个共享状态列表,需要定期同步为某个集合的子集”的场景。判断该用哪一类,简单标准就是:你会不会需要用这条列表的旧值?会,就用不可变操作;不会,且不想频繁产生新对象,考虑retainAll。

2.2 标准库的隐藏优化:小集合不走 Set

标准库在实现subtract、minus这些需要判断“元素是否存在”的操作时,并不是无脑把另一边转成HashSet。它有一个内部工具方法,会根据情况选择策略:

  • 如果other本身是Set,直接复用,不做拷贝;
  • 如果other是Collection且元素数量较少,就直接做线性contains检查;
  • 如果other元素较多,才转成HashSet加速查询。

这个阈值我记得是 3 个元素左右。也就是说,如果你只移除 1~2 个元素,标准库认为线性扫描的开销比新建HashSet更小,就不会额外走哈希路线。这个细节对我们日常工作有个启发:不要盲目地把小集合手动转成HashSet再传给这些函数,标准库比你更在乎小集合上的分配开销。

intersect的实现思路则是先把调用者转成Set(如果还不是),然后遍历other,逐个用contains判断是否保留。结果集合用LinkedHashSet承载,所以元素的出现顺序由other的遍历顺序决定。这也解释了为什么intersect(listOf(2, 1, 2), listOf(1, 2, 1))的结果是[1, 2]而不是[2, 1]——结果顺序跟随第二个参数other的遍历顺序。

union更直接:先把调用者toSet(),放进LinkedHashSet,然后遍历other,把新元素依次add进去。调用者原有的重复元素只保留最早出现的那个,其它全部去掉。

2.3 复杂度对比:List 与 Set 拉开差距

集合操作性能的关键,基本落在contains的复杂度上。ArrayList.contains是 O(n),HashSet.contains是 O(1),TreeSet.contains是 O(log n)。所以:

  • 如果你用ArrayList调用intersect,它先把调用者转成HashSet(O(n)),再遍历other(O(m)),每次contains都是 O(1)。整体 O(n + m),这里因为other是 List,遍历时必须一个个看,总成本 O(n + m)。
  • 如果你是手写双重循环去求交集,那就是 O(n*m),数据量一上来立刻卡顿。

所以标准库本身并不慢,性能瓶颈常常出现在你把other传成超大List而contains不得不线性扫描的时候。举个例子,largeSet.intersect(hugeList)在遍历hugeList时每次contains都是 O(1),整体很大程度上取决于hugeList的遍历长度;如果反向调用hugeList.intersect(largeSet),同样遍历largeSet就快很多。

实际业务里,我一般会遵循一个经验:需要频繁做集合运算的数据,优先用Set而不是List存储。不仅语义上符合“去重”的诉求,后续union、subtract、intersect的性能也稳得多。仅在需要保序、允许重复时才用List,并且在列表量级过千后,慎重地对它做intersect这类运算。

2.4 equals/hashCode:一切集合操作的地基

集合操作判断“两个元素是不是同一个”,靠的是equals。使用HashSet作为实现时,还会用到hashCode。这带来一个容易被忽略的前提:你的元素类如果没有正确实现equals和hashCode,集合操作的结果就是错误的,而且不一定报错。

典型反例是随便写一个普通class,没有重写equals,两个内容一样的对象被当成两个不同元素:

class Tag(val name: String) val local = listOf(Tag("Android"), Tag("Kotlin")) val remote = listOf(Tag("Kotlin")) val common = local.intersect(remote) println(common.size) // 0,因为 Tag 没有值相等性

这里Tag("Kotlin")和Tag("Kotlin")是两个不同引用,默认equals按引用比较,结果自然为空。解决方式要么用data class,要么手动重写equals和hashCode,并且两者必须同时保持一致:equals相等的对象,hashCode必须相等,否则HashSet定位时会漏判。

如果你用的是现成的实体类,比如数据库映射对象,要特别留神:有些生成工具只重写了部分字段,而业务上你认为“ID 相同就算同一个实体”,那集合里的相等语义其实没对上。此时更稳妥的做法是映射成 ID 或主键再做集合操作,把比较维度收窄到确定字段上。

3. 典型业务场景实战:从权限到去重

讲了原理和函数行为,下面直接进代码场景。这几个操作单独用都很简单,真正有价值的是组合起来解决真实需求。

3.1 权限过滤:intersect 一行代码

后台返回用户角色列表,前端判断用户是否拥有某些角色中的任意一个,最常见的朴素写法是:

fun hasAnyRole(userRoles: Set<String>, requiredRoles: Set<String>): Boolean { for (role in requiredRoles) { if (role in userRoles) return true } return false }

改成集合操作后:

fun hasAnyRole(userRoles: Set<String>, requiredRoles: Set<String>): Boolean = (userRoles intersect requiredRoles).isNotEmpty()

可读性确实提升不少。不过在性能上要留个心眼:intersect会新建一个LinkedHashSet,如果这是高频热路径,比如每个列表项的权限判断都调一次,可以考虑用any配合in来短路:

fun hasAnyRole(userRoles: Set<String>, requiredRoles: Set<String>): Boolean = requiredRoles.any { it in userRoles }

这段逻辑在遇到第一个匹配角色时立即返回,不需要构建完整交集。我的经验是:数据量小、调用频率低,优先用intersect讲清楚意图;数据量大、调用频繁,选any保性能。

3.2 标签去重与统计:union/minus 的组合

同步用户标签是典型场景。假设本地有一份历史标签,远端来了一份最新标签,你需要知道新增哪些、删掉哪些、合并结果是什么:

val oldTags = setOf("Kotlin", "Java", "Android", "MVP") val latestTags = setOf("Kotlin", "Compose", "Clean Architecture") val addedTags = latestTags subtract oldTags val removedTags = oldTags subtract latestTags val mergedTags = oldTags union latestTags println("新增: $addedTags") // 新增: [Compose, Clean Architecture] println("删除: $removedTags") // 删除: [Java, Android, MVP] println("合并: $mergedTags") // 合并: [Kotlin, Java, Android, MVP, Compose, Clean Architecture]

三个计算共用一组输入,没有任何中间副作用。提交给后端时,addedTags和removedTags可以直接作为增量和减量参数,避免全量上传,接口压力小很多。类似地,两个数据源的 ID 差异同步也可以照搬这套写法。

3.3 动态配置同步:retainAll 就地更新

当你的本地缓存列表需要“跟上服务器配置,过期项移除”时,retainAll比重新赋值更加优雅。

val localFeatureIds = mutableListOf("feature_a", "feature_b", "feature_old") val configFeatureIds = setOf("feature_a", "feature_b", "feature_c") if (localFeatureIds.retainAll(configFeatureIds)) { // 集合发生变化,说明有配置下线,触发缓存清理 flushCache(localFeatureIds) } println(localFeatureIds) // [feature_a, feature_b]

注意两点:首先,localFeatureIds必须是MutableList或MutableSet,retainAll不能用于不可变集合;其次,返回值true表示有元素被移除,这个布尔值非常适合直接驱动“是否需要通知 UI 刷新”的逻辑。如果你用intersect做同样的事,就得额外写一次赋值,还会丢掉重复项语义,不适合这种需要原地维护列表的场景。

3.4 链式不可变更新:minus 与 plus 的接力

Kotlin 里常见一种“老数据剔除 + 新数据追加”的组合更新模式,利用 operator 重载写起来非常自然:

val original = listOf("User1", "User2", "User3", "User4") val removed = setOf("User1", "User4") val added = listOf("User5", "User6") val next = (original minus removed) + added println(next) // [User2, User3, User5, User6]

这段代码用minus做整体过滤,用plus做末尾追加,全程不产生对原集合的修改,适合放在状态管理器里,每次用户操作返回一个新列表。链式写法还能继续接别的集合操作,比如再来一次去重:

val next = ((original minus removed) + added).toSet()

整体上代码像在描述数据变换过程,可读性比 for 循环强出一个量级。这是我个人在项目里最喜欢用的一组操作。

4. 踩坑记录:常见误区与排查思路

最后集中说几个我实际遇到过、且经常在初学者代码里见到的坑。这些问题不算隐蔽,但只要踩一次,定位就要花不少时间。

4.1 retainAll 不是并发安全的,别在遍历时改

retainAll是就地修改,如果同一时间另一个线程正在遍历这个集合,很容易触发并发修改异常或读到不一致的中间状态。解决办法是拷贝副本后再操作,或者干脆用不可变操作替代:

val snapshot = ArrayList(localFeatureIds) // 多线程环境下,对副本做 retainAll 更安全 snapshot.retainAll(configFeatureIds)

如果只是单线程内部维护状态,就地修改没问题;一旦集合被多个协程或线程共享,我还是建议优先复制一份再改,或者直接用不可变操作生成新集合。

4.2 误以为 subtract 会保留 List 重复项

很多从 Java 或 Python 转过来的同学,第一次用subtract处理List,发现结果突然“少了一堆重复元素”,往往会怀疑自己写错。其实这就是Set返回类型的正常行为。我见过一个实际案例:购物车结账逻辑里,商品被List存储以记录重复购买数量,同事用subtract从购物车里移除“已下架商品”,结果重复购买的同款商品全部被压缩成一份,结算金额怎么算都不对。正确写法换成了minus:

val cart = listOf("商品A", "商品A", "商品B") val offShelf = setOf("商品A") println(cart.subtract(offShelf)) // [商品B] println(cart.minus(offShelf)) // [商品B]

这里因为商品A全部被移除了,看不出区别;换成移除“商品B”会更明显:

val cart = listOf("商品A", "商品A", "商品B") val offShelf = setOf("商品B") println(cart.subtract(offShelf)) // [商品A] println(cart.minus(offShelf)) // [商品A, 商品A]

一个去重一个保留重复,语义差很大,下单前一定要确认。

4.3 Map 不是 Iterable,直接用集合操作会报错

Map本身不是Iterable,你没法直接写mapA.intersect(mapB)。遇到需要比较两个 Map 键的场景,先取keys再用集合操作:

val configA = mapOf("a" to 1, "b" to 2) val configB = mapOf("b" to 3, "c" to 4) val commonKeys = configA.keys intersect configB.keys // [b] val newKeys = configB.keys subtract configA.keys // [c] val removedKeys = configA.keys subtract configB.keys // [a]

plus和minus对 Map 有专门重载,行为完全不同:mapA + mapB做的是键值合并,后面的会覆盖前面;mapA - "a"是按 key 移除。所以别拿集合操作的语义硬套 Map,键值数据有自己的规则。

4.4 null 元素没有你想的那么难缠

List和Set里都可以放null,集合操作处理起来基本正常。null在HashSet里有专门的桶位置,equals判断也兼容。容易出问题的是可空泛型和plus的推断:当你写listOf(1, 2) + null时,编译器会要求接收者元素类型可空,否则直接编译失败。这个属于类型层面的提示,不算运行时坑,不过新手看到报错会以为是函数用错了,其实只是泛型不匹配。

另外retainAll里如果参数集合包含null,那么原集合里的null会被保留,其余元素按规则过滤:

val values = mutableListOf(null, "a", "b") values.retainAll(listOf(null, "b")) println(values) // [null, b]

这符合直觉,不用额外处理,知道行为即可。

4.5 嵌套集合的相等性:看着一样,实际不是一个元素

当集合元素本身是另一个集合时,equals的判断是按内部元素一步步比较的,但这要求内部元素也正确实现了相等性。比如listOf(listOf(1, 2))和listOf(listOf(1, 2))用intersect是能匹配上的,因为内部列表符合值相等逻辑。但如果你用的是自定义List实现,或者内部元素是可变对象且字段发生了修改,相等性判断就容易出偏差。

这类问题很难靠集合操作本身排查,更实用的方法是在设计阶段就把集合元素做成不可变的数据类,避免运行中元素状态漂移。至少我在实际项目里遇到的一次“交集莫名为空”,最后就是查到了某个元素类的字段在别处被悄悄改掉,导致hashCode前后不一致。

写在最后的小建议

罗列完这组函数,我自己每次写集合运算都会在心里过一遍:需要链式处理不可变数据,优先选intersect、subtract、union、minus、plus;需要修改现有列表、保留重复项时,才考虑retainAll。另外,数据量超过几千以后多留一个心眼,能提前把平行集合换成Set就把性能余量留出来。踩过几次“看起来是集合数量少,实际是重复数据被意外去重”的坑之后,我在项目里定了一条小规矩:凡是集合操作的结果要被持久化或者参与金额计算,必须先跑一遍重复项检查,确保subtract没有吃掉你不想丢的数据。没准哪天这条规矩就能帮你省下一个通宵。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询