Scala 可变 Set 操作:从性能问题到最佳实践

上一篇我们聊了不可变Set------它O(1)的contains、它的集合运算,在大数据开发里无处不在。但有个问题:如果你要在一个循环里不断往Set里加元素,用不可变Set就会很别扭。这时候你需要的就是可变Set。

很多人学Scala的时候,记住了"不可变优先"这个原则,然后就走到另一个极端------什么都用不可变Set。直到有一天,他写了这么一段代码:

scala 复制代码
// 用不可变Set动态收集数据
var seen = Set.empty[String]
for (line <- lines) {
  if (needProcess(line)) {
    seen = seen + line
  }
}

看起来没问题对吧?每次seen + line返回一个新Set,然后重新赋值给seen。但当seen里有十万个元素的时候,每次+操作都要重新构建整个哈希表------这个性能开销,你品,你细品。

这时候你就需要可变Set了。

这篇文章不讲"可变Set是什么"这种入门废话,直接说干货:为什么不可变Set在动态收集场景下不够用?可变Set的底层是怎么实现的?什么时候你该用可变Set而不是不可变Set?


先从一个性能问题说起

假设你要写一个函数:给定一个文件的所有行,返回不重复的行集合。

用不可变Set怎么写?

scala 复制代码
// 写法一:用不可变Set
var seen = Set.empty[String]
for (line <- lines) {
  seen = seen + line
}
seen

逻辑很直白:遍历每一行,如果不在集合里就加进去。

但问题在哪?seen + line是O(n)的------因为不可变Set每次add都要创建一个新的哈希表结构,把旧元素复制过去。十万行数据,就是一百亿次操作。

怎么优化?把var换成val,把不可变Set换成可变Set:

scala 复制代码
// 写法二:用可变Set
import scala.collection.mutable

val seen = mutable.Set.empty[String]
for (line <- lines) {
  seen += line
}
seen.toSet  // 最后转成不可变Set输出

现在seen += line是摊销O(1)的------因为可变Set是原地修改哈希表,不需要复制整个结构。整个函数的复杂度就变成了O(n)。

从几分钟降到几秒,就是这一行改动的差别。

这就是可变Set存在的意义:在需要动态收集数据的场景下,可变Set的性能远好于不可变Set。


可变Set的底层:它就是个可变的哈希表

很多人以为可变Set和不可变Set是两种完全不同的东西。其实它们的底层思路是一样的------都是基于哈希表。

区别在于:

  • 不可变Set:每次add都返回一个新Set,内部会尽量复用旧结构(结构共享),但在动态添加场景下,复用效果有限
  • 可变Set:直接在原哈希表上修改,add/remove都是原地操作
scala 复制代码
import scala.collection.mutable

// 创建可变HashSet
val set = mutable.HashSet(1, 2, 3, 4, 5)

// add:原地添加
set += 6
// HashSet(1, 2, 3, 4, 5, 6)

// remove:原地删除
set -= 3
// HashSet(1, 2, 4, 5, 6)

// contains:和不可变Set一样,O(1)
set.contains(4)    // true

看出来了吗?可变Set的API和不可变Set几乎一样------都是+=、-=、contains。区别就是:可变Set是原地修改,不可变Set是返回新对象。

这也是为什么Scala的集合体系这么优雅------不管可变还是不可变,它们共享同一套API。你学会了一个,就学会了所有。


可变Set的几种实现

和不可变Set一样,可变Set也有几种不同的实现,对应不同的场景。

HashSet:最常用的可变Set

scala 复制代码
import scala.collection.mutable.HashSet

val set = HashSet(1, 2, 3)
set += 4

最常用的可变Set,底层就是个可变哈希表。add/remove/contains都是摊销O(1)。99%的场景用它就够了。

LinkedHashSet:保持插入顺序

scala 复制代码
import scala.collection.mutable.LinkedHashSet

val set = LinkedHashSet[String]()
set += "c"
set += "a"
set += "b"
// LinkedHashSet("c", "a", "b") ------ 保持插入顺序

有时候你既要去重,又要保持元素的插入顺序------这时候用LinkedHashSet。它底层是哈希表加链表,既能O(1)查找,又能保持插入顺序。

这个在做日志解析、做事件追踪的时候特别好用------你既要去重,又要保持事件的发生顺序。


可变Set vs 不可变Set:怎么选?

这是最常见的困惑。我给一个简单的判断标准:

你是在"构建"一个Set,还是在"使用"一个Set?

  • 在"构建"------数据是动态收集的,你不知道最终有多少元素------用可变Set
  • 在"使用"------数据已经有了,你要做查找、做集合运算------用不可变Set

具体到实际场景:

场景 用什么
循环里动态收集元素 可变Set
构建一个Set后要在多个地方传递 不可变Set
需要频繁add/remove 可变Set
做并集/交集/差集 不可变Set
方法参数传入的Set 不可变Set(别假设它是可变的)
方法返回的Set 不可变Set(对外暴露不可变的)

最佳实践:可变构建,不可变输出

这是Scala集合体系的老传统了:内部用可变Set构建,对外用不可变Set输出。

scala 复制代码
def processLines(lines: List[String]): Set[String] = {
  val seen = mutable.HashSet[String]()
  for (line <- lines) {
    if (needProcess(line)) {
      seen += line
    }
  }
  seen.toSet  // 最后转成不可变Set输出
}

这个模式的好处是:

  1. 内部高效:用可变Set构建,性能好
  2. 外部安全:对外暴露不可变Set,调用方拿到的是线程安全的
  3. 边界清晰:可变的东西只在方法内部,不会泄露出去

这和ListBuffer的模式是一模一样的------可变收集,不可变输出。


和Java HashSet对比

Java也有HashSet,也有LinkedHashSet。那Scala的可变Set和Java的HashSet到底差在哪?

1. 运算符风格

java 复制代码
// Java
Set<String> set = new HashSet<>();
set.add("a");
set.remove("a");
set.contains("a");
scala 复制代码
// Scala
import scala.collection.mutable
val set = mutable.Set[String]()
set += "a"
set -= "a"
set.contains("a")

写法上的差别只是表面。真正的差别在这几点:

2. 和Scala集合体系无缝互转

Java的HashSet要转成List、转成数组,都得自己折腾。Scala的可变Set和不可变Set、List、Array之间互转都是一行代码的事。

3. 默认是不可变的

Java里,你new的HashSet默认就是可变的。你要想要不可变的,得自己用Collections.unmodifiableSet()包一层------而且这只是个"视图",你拿着原始引用还是能改。

Scala里,默认的Set就是不可变的。你要可变的,得显式importmutable.Set。

这个设计哲学的差别和List是一样的:把可变性上升到类型层面,编译器帮你检查。


几个真正影响性能的细节

1. toSet是O(n),不是O(1)

和ListBuffer的toList不一样,可变Set的toSet不是零拷贝的------它需要把可变哈希表复制成一个不可变Set。所以如果你最后不需要转成不可变Set,那就别转。

但大多数情况下,这个复制的开销是值得的------因为你对外暴露不可变Set,调用方用起来更安全。

2. 可变Set不是线程安全的

这个不用多说了,可变集合都不是线程安全的。如果你在多线程环境下需要共享Set,要么用不可变Set,要么自己加锁,要么用并发集合。

3. 大数据量下,Set的内存开销比List大

Set要维护哈希表,内存开销比List大。如果你的数据量特别大------比如几百万个元素------那Set的内存占用可能是List的好几倍。这时候你要权衡:你要O(1)的contains,还是要省内存?


写在最后

我写这篇文章的时候,特意没罗列可变Set有多少个方法------因为那些东西官方文档都有,查一下就行。

真正值得花时间理解的是:为什么需要可变Set?

不是因为"它就是Java HashSet的Scala版",而是因为在动态收集数据的场景下,不可变Set的性能不够好。你要在循环里不断往Set里加元素,用不可变Set就是O(n²)的灾难,用可变Set就是O(n)的高效。

但可变Set也不是万能的。它只适合"构建"场景------你在一个方法内部,动态收集一批数据,最后输出一个不可变Set。一旦数据收集完了,你就该把它转成不可变Set,在系统里安全地传递。

这就是Scala集合体系的核心哲学:可变用于构建,不可变用于传递。

理解了这个,你就理解了Scala集合设计的精髓。你不会再纠结"为什么不直接用Java的HashSet"------因为你知道,这不是哪个更好的问题,而是设计哲学的区别。

Java的集合是"命令式"的:你有一个可变容器,你往里面加东西、删东西,容器一直在变。

Scala的集合是"函数式"的:数据是不可变的,你操作数据的时候会生成新的数据。而在这个过程中,如果你真的需要一个可变的临时容器,Scala也给你准备好了------就是可变Set。

它不是"更好的HashSet",它是函数式编程世界里,那个让你可以安全地"作弊"的小窗口。平时你守规矩,用不可变Set;但当你真的需要动态构建数据的时候,可变Set给你提供了一个高效的临时工作台。

相关推荐
时文鲜读1 小时前
算力基建提速,数据中心安全建设与工业可靠性如何同步升级?
大数据·人工智能·安全
AllData公司负责人2 小时前
【产品便捷使用一】AIIData 数据中台:企业基础版核心能力支持,一站式全链路数据处理实操指南
大数据·前端·数据治理·数据可视化·数据开发·数据服务·数据接入
QY_Research_9 小时前
2026房地产NFT平台创业机遇与落地指南分析报告
大数据·人工智能·物联网·机器人·制造
全栈陈序员12 小时前
【Git】从入门到精通(五)—— git add:将文件加入暂存区
大数据·git·elasticsearch
KKKlucifer13 小时前
国内数据安全服务的产品智能化转型:大模型赋能下的技术能力重构分析
大数据·数据库·重构
故七月15 小时前
AI 答案里的竞品共现:怎么看出赛道格局
大数据·人工智能
全栈陈序员15 小时前
【Git】从入门到精通(七)—— git status:查看仓库状态
大数据·git·elasticsearch
fengye20716116 小时前
第三章 编译cpython
大数据·elasticsearch·搜索引擎
大大大大晴天16 小时前
每天认识一个组件:Apache Flink
大数据