3.1 不可变性:把状态钉死在岩壁上


3.1 不可变性:把状态钉死在岩壁上

不可变数据一旦创建就不能被修改,"变更"实际是产生新值。这不是纪律约束,而是 Scala 的默认设计:val、不可变集合、样例类 copy 全部围绕它展开。

一个 bug 的两种命运

需求:把订单列表里每个订单的价格打八折。可变写法:

// 可变风格:就地修改 for (o <- orders) o.price = o.price * 0.8 // 原数据没了

这个循环跑完,原始价格永久丢失;如果 orders 还被别的线程读着,读者可能看到"改了一半"的列表。不可变写法:

val discounted = orders.map(o => o.copy(price = o.price * 0.8)) // orders 原样,discounted 是新列表

新值产生后旧值安然无恙:回滚等于丢掉新值,并发读不需要锁,缓存键不会"变质的键",equals 结果永不过期。可变状态是 bug 的万有引力,不可变性的本质是消掉这个引力场。

可变与不可变的时间线对比

可变与不可变的时间线对比

不可变的成本与化解

每次 copy 听起来浪费。实际收益账:

  • 结构共享:不可变 List 的 tail 与原列表物理共享,copy 一个元素变更是 O(1) 级别的构造,不是全量拷贝。
  • 无锁:读操作永不阻塞,第六章并发的地基。
  • 免防御式拷贝:Java 里跨层传 List 常要 clone 一份防改,不可变世界里这层防御整体消失。

代价确实存在:热点循环里高频"变更"会制造大量短命对象,好在 JVM 的分代 GC 恰好擅长回收短命对象。真正的性能问题出现时,再局部换 4.3 节的可变集合,而不是反过来全局可变。

惰性求值:不可变的搭档

lazy val 把求值推迟到首次访问,且只求一次:

class Report(rows: Seq[Row]): lazy val summary: Summary = Summary(rows) // 昂贵计算,谁用谁触发

lazy + val 的组合还能打破初始化顺序问题;但注意惰性带来副作用时序的不确定性,含副作用的表达式别用 lazy 包。

图 3-1 持久化数据结构的结构共享

图 3-1 持久化数据结构的结构共享

完整案例:并发环境下的计数器

背景:一个网关要统计每分钟的请求数,多个工作线程同时上报。可变版本的直觉写法是 var count += 1 或 AtomicInteger;不可变版本把"计数"变成值的流转。先看两种写法,再看结果差异:

// 可变版本:状态原地变化,任何持有引用的线程都可能读到中间态 class MutableCounter: private var n = 0 def inc(): Unit = n += 1 def get: Int = n // 不可变版本:每次递增产出新值,旧值永远有效 final case class Counter(n: Int): def inc: Counter = copy(n = n + 1) def get: Int = n val steps = Counter(0).inc.inc.inc.inc // 链式推进 steps.get // 4

操作层面:把计数器改成 case class 后,"累加"变成对集合的一次 fold——requests.foldLeft(Counter(0))((c, _) => c.inc)。结果层面:任何线程拿到某个 Counter 值的瞬间,它就是快照,不存在"读到一半"的可能。解读:不可变并不消灭变化,而是把变化从"时间上的原地修改"改写成"空间上的值序列",每个中间值都可回放、可审计。变式:当真的需要跨线程汇聚计数时,用 Actor(6.2 节)或并发原子引用承载"当前最新值",但值本身仍然不可变。

不可变结构的共享原理

val base = List(1, 2, 3) val a = 0 :: base // 新列表只是新结点指向 base,O(1) val b = 9 :: base // 同样 O(1),两个头结点共享同一条尾巴

持久化数据结构让"复制"变成廉价的结构共享:既然没人能改,就能放心共享内部结点。这是不可变风格在性能上不落下风的关键——不是每次都深拷贝,而是每次只造变化的增量。

概念辨析:不可变对象 vs 不可变变量

val 只保证引用不变,不保证对象内部不变:val buf = ArrayBuffer(1) 之后 buf += 2 完全合法。真正的不可变是"对象图谱整体冻结",由不可变数据结构保证。面试常考这个区分,工程上则要看 mutable.Set 这类名字背后的集合本身是否可变。

边界情况:不可变风格的成本账

不可变不是免费的,要会算三笔账。第一笔是频繁更新:对 List 头部操作是 O(1),但随机下标更新是 O(n),高频随机写要换 Vector(分块树结构,读写都近似 O(log n));第二笔是大对象拷贝:copy 是浅拷贝共享字段,但字段本身若是可变集合就会漏气,构造时就要用 toList 冻结;第三笔是 interning 压力:大量短命小对象在 JVM 上由分代收集兜底,现代 GC 对此很擅长,不必提前焦虑。结论是"默认不可变,热路径实测后局部放开",而不是教条化。

val xs = (1 to 1000000).toVector val ys = xs.updated(500000, -1) // Vector 更新:近似 O(log n),结构共享 xs(500000) // 仍是原值 500001 —— 旧值未受影响

一个相关的概念辨析收尾:纯函数(无副作用、引用透明)与不可变数据是两件事——用不可变数据也能写出不纯的函数(比如往日志文件里写东西);反过来,纯函数操作可变局部变量在语义上仍可接受,只要可变性不逃逸出函数边界。工程上把两者一起遵守,引用透明这个性质才真正成立:任何表达式都可以安全地用它的值替换,这也是测试与并发推理能够简单化的数学根基。下一节讲函数组合时,你会看到引用透明如何让函数像乐高一样随意拼装而不出意外。

本节要点回顾

  • 变更即新建:copy 产生新值,旧值永在,回滚与并发读都免费。
  • 结构共享让"每次新建"在持久化数据结构上代价可控。
  • 性能策略:默认不可变,profile 证实的热点局部换可变。
  • lazy val:一次性的延迟求值,配纯计算用。

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U