不可变数据与 Enum/Stream
前四章一直有个幽灵在游荡:更新 Map 会返回新 Map,变量只能重绑定不能修改,列表偏爱头部操作……现在正面回答:Elixir 的所有数据都不可变,没有例外,没有开关。本章先算清这件事的成本账(剧透:比你想的便宜得多),再领取它的三份红利——放心共享、无锁并发,以及一整套优雅到犯规的集合处理工具。
「修改」从未发生
iex(1)> patient = %{bed: 7, hr: 72}
%{bed: 7, hr: 72}
iex(2)> updated = %{patient | hr: 96}
%{bed: 7, hr: 96}
iex(3)> patient
%{bed: 7, hr: 72}
第 2 行没有「改」任何东西:它制造了一个新 Map,旧的原封不动。Elixir 里所有看起来像修改的 API——Map.put、List.replace_at、String.upcase——全是这个模式:收旧值,还新值。变量层面允许的「重绑定」(x = 1; x = 2)只是把名牌挂到新值上,旧值本身从未被触碰,谁引用着它谁就还看得到原样。
这带来第一份红利:值可以放心递出去。把 Map 传给任何函数、任何进程,不用防御性拷贝,不用担心「它会不会在我背后改我的数据」——改不了,语言层面就没有这个动词。在 Kotlin 里你靠纪律(val、不可变集合、data class)逼近这个状态,Elixir 直接把纪律铸进了物理法则。
内存账:结构共享
「每次更新都造新值」听起来像内存灾难,其实不然,因为新旧值之间大量共享结构。以列表最清楚:[0 | list] 造出的新列表,只新建了一个头节点,尾巴整条直接指向旧列表——反正大家都不可变,共享绝对安全:
old = [1, 2, 3] # old ──> 1 ─> 2 ─> 3 new = [0 | old] # new ──> 0 ─┘(尾部三个节点零拷贝,与 old 共用)
Map 同理(内部是树形结构,更新只重建从根到被改键的一条路径,其余子树共享)。这就是为什么第 2 章说列表偏爱头部操作、++ 却要复制整个左侧——现在你能自己推出来了:往头部加,尾巴全共享;往尾部加,「尾巴不变」的前提没了,左边整条都得重建。不可变语言的性能直觉,核心就一条:什么被共享了,什么被重建了。
BEAM 坚持不可变还有一个更深的动机,直通第 7 章:每个进程独立堆、独立 GC 的前提,就是数据没有跨进程的可变引用——否则「回收我的堆」就得先问遍所有人。不可变性 → 进程完全隔离 → GC 各扫门前雪、停顿互不传染 → 百万进程与软实时成为可能。语言层的这条「不许改」,是九个九传说在数据层的地基。
没有 while,那怎么循环?递归
不可变世界里没有 i++,自然也没有 for/while 循环。基础答案是递归,而且上一章的模式匹配早就把刀递好了——空列表和非空列表本来就是两个可区分的形状:
defmodule MySum do def sum([]), do: 0 def sum([head | tail]), do: head + sum(tail) end
读法完全是数学归纳法:空列表的和是 0;非空列表的和是头 + 尾的和。没有循环变量,没有终止条件写错的空间——终止条件就是第一个子句的形状。
但这个版本有个隐患:head + sum(tail) 里,加法要等递归回来才能做,所以每层调用都得在栈上留一帧,列表一长栈就深。工程版是「带累加器的尾递归」:
defmodule MySum do def sum(list), do: sum(list, 0) defp sum([], acc), do: acc defp sum([head | tail], acc), do: sum(tail, acc + head) end
区别在最后一行:递归调用是函数体的最后一个动作,没有「回来还要算」的欠账。BEAM 保证对这种尾调用做优化——复用当前栈帧,等价于循环,常数栈深跑一亿个元素也不怕。「递归会爆栈」的老印象,在尾递归 + 虚拟机保证面前不成立。
Enum 三板斧:你其实很少手写递归
好消息:上面两个版本你日常都不用写。集合处理的高频模式早被抽象进 Enum 模块,先认三板斧:
iex(1)> hrs = [72, 96, 58, 110, 67] iex(2)> hrs |> Enum.map(&(&1 + 3)) [75, 99, 61, 113, 70] iex(3)> hrs |> Enum.filter(&(&1 > 90)) [96, 110] iex(4)> hrs |> Enum.reduce(0, &(&1 + &2)) 403 iex(5)> hrs |> Enum.sort() |> Enum.take(2) [58, 67]
- map:逐个变换,长度不变——「给每个读数加 3」;
- filter:按谓词筛选——「挑出偏高的」;
- reduce:带着累加器扫一遍,折叠成单值——「求和」。
顺带认识两个新面孔。1..1_000_000 这种写法是 Range(范围):它不是一百万个数的列表,而是「起点、终点、步长」三个数——占内存恒定,却能被 Enum 全家当集合遍历,是「造测试数据」和「跑 N 次」的标配。另一个是 Enum.with_index/1,给每个元素配上序号 [{elem, 0}, {elem, 1}, ...]——没有下标循环的世界里,这就是你要「i」时的答案。
reduce 值得多看一眼:它就是刚才手写的尾递归的封装(初始 acc,每个元素更新一次 acc),理论上 Enum 里其他所有函数都能用 reduce 实现——它是三板斧里的「万物之母」。当你想不到现成函数时,倒退到 reduce 总能写出来;写完再去 h Enum 里翻,九成能找到更贴切的现成货(sum、group_by、frequencies、chunk_every……全家族一百多个函数,值得专门刷一遍文档)。
Stream:先记账,不干活
Enum 有个特点:每一步都立刻算完、生成完整的中间列表。管道三层,就有两份中间列表被造出来又扔掉。数据量小无所谓,大了就是浪费;数据无限(比如持续到来的日志行)则根本没法用。解法是同名同用法的惰性版本——Stream:
iex(1)> s = 1..1_000_000 |> Stream.map(&(&1 * 3)) |> Stream.filter(&(rem(&1, 2) == 0)) #Stream<[enum: 1..1000000, funs: [...]]> iex(2)> s |> Enum.take(3) [6, 12, 18]
第 1 行没有做一百万次乘法——看返回值,它只是一张「待办配方」:数据源 + 两步变换的说明书。直到第 2 行 Enum.take(3) 来「兑现」,才按需拉动流水线,而且只拉到凑齐 3 个为止。规则:Stream 系函数记账,Enum 系函数结账;管道里可以全程 Stream,最后一步用 Enum 收口。
两个经典场景:①大文件——File.stream!("huge.log") |> Stream.map(&parse/1) |> Stream.filter(&error?/1) |> Enum.take(10),十 GB 的日志,内存里永远只有一行;②无限序列——Stream.iterate(1, &(&1 * 2)) 是全体 2 的幂,取多少算多少。
Enum 之于 Stream,恰好是 Kotlin 里 List 链式操作之于 Sequence(或者 Java Stream):前者每步吐中间集合,后者惰性拼装、终端操作触发。连忠告都一样——短小数据用急性(Enum),别为三个元素上惰性设备;大 / 无限数据必须用惰性。Kotlin 里 asSequence() 的切换点,就是这里 Enum 换 Stream 的切换点。
for:推导式,其实还是那三板斧
Elixir 也有 for,但它不是循环语句,是推导式表达式(有返回值!),等价于 map + filter 的组合拳,还能笛卡尔积:
iex(1)> for hr <- [72, 96, 110], hr > 90, do: {:alert, hr}
[alert: 96, alert: 110]
iex(2)> for x <- 1..3, y <- 1..3, x < y, do: {x, y}
[{1, 2}, {1, 3}, {2, 3}]
(第 1 行输出的 [alert: 96, ...] 又是关键字列表的显示形式——元组列表恰好都是「原子 + 值」时,iex 就这么印,数据没变。)何时用 for、何时用管道?社区口味:单一 map 或 filter 用管道,「多源组合 + 过滤 + 变换」一把梭时用 for 更清爽。
综合演练:一张病区报表
把全章工具装配成一条真实流水线。输入是几行采集记录(CSV 文本),要求输出「每个科室的平均心率」:
iex(1)> data = """
...(1)> 急诊,7,96
...(1)> 急诊,3,88
...(1)> 内科,12,72
...(1)> 内科,15,64
...(1)> """
iex(2)> data
...(2)> |> String.split("\n", trim: true)
...(2)> |> Enum.map(&String.split(&1, ","))
...(2)> |> Enum.map(fn [dept, _bed, hr] -> {dept, String.to_integer(hr)} end)
...(2)> |> Enum.group_by(fn {dept, _} -> dept end, fn {_, hr} -> hr end)
...(2)> |> Enum.map(fn {dept, hrs} -> {dept, Enum.sum(hrs) / length(hrs)} end)
[{"内科", 68.0}, {"急诊", 92.0}]
逐层复盘:切行(trim: true 是关键字列表选项,第 2 章)→ 切列 → 用模式匹配直接在匿名函数参数里解构(fn [dept, _bed, hr] ->,第 3 章的刀用在这里)→ group_by 按科室归堆 → 对每堆求平均。五步,零循环变量,零可变状态,每一步的中间结果都可以断开单独检查。这就是本卷四章合起来的样子——以后你写的每段 Elixir,基本都是这条流水线的变体。
两个高频性能病,都和本章直觉有关:①循环里 list ++ [item] ——每次复制整个左列表,O(n²);正确姿势是头插 [item | acc] 攒完最后 Enum.reverse/1 一次(或直接 reduce)。②对超大集合连打多个 Enum——每步一份完整中间列表;改 Stream。判断口诀就一句:这一步之后,整份中间结果有没有必要存在?
- 纸上画出
a = [2, 3]; b = [1 | a]; c = [0 | b]的内存结构,数数总共几个节点。 - 用 reduce 实现
my_max/1(非空数字列表求最大值),再找出 Enum 里的现成函数。 - 写一条流水线:1 到 10 万,平方,取出末两位是 44 的,只要前 5 个——用 Stream 还是 Enum?为什么?
- 解释:为什么不可变性是「进程独立 GC」的前提?(提示:可变引用要是跨了进程,回收谁说了算?)
数据的事到此齐活。下一章处理流程的分岔:case、with,以及那套贯穿整个生态的 {:ok, _} / {:error, _} 惯例——顺便回答一个哲学问题:错误,到底该防,还是该让它崩?