模式匹配:= 不是赋值
这一章要没收你写了十几年的等号。在 Elixir 里,= 不叫赋值符,叫匹配操作符:它做的事更接近数学里的方程——「让左边和右边相等,若能成立,顺便求出未知数」。这个看似激进的重新定义,是整门语言的灵魂手术刀:它替你拆开数据、替你分派逻辑、替你在第一时间揪出「形状不对」的值。学会它,后面十章的代码你都能一眼看懂。
从一个方程开始
iex(1)> x = 1 1 iex(2)> 1 = x 1 iex(3)> 2 = x ** (MatchError) no match of right hand side value: 1
第一行看着像赋值,先按下不表。看第二行:1 = x——在任何 C 系语言里这是语法错误(不能给字面量赋值),这里却好好地返回了 1。因为 = 在做的是匹配:左边是 1,右边是 1,方程成立,通过。第三行 2 = x,方程不成立,当场抛出 MatchError。
那第一行呢?规则是:匹配时,左边未绑定的变量可以吸收右边对应位置的值,好让方程成立。x = 1 就是「x 是未知数,解得 x = 1」。所谓赋值,只是匹配的一个特例。这个视角一换,魔法开始了——左边可以放的不只是一个变量,而是任何形状。
解构:形状对上,值自动就位
iex(1)> {:ok, code} = {:ok, 200}
{:ok, 200}
iex(2)> code
200
iex(3)> {:ok, code} = {:error, :timeout}
** (MatchError) no match of right hand side value: {:error, :timeout}
iex(4)> %{hr: bpm} = %{bed: 7, hr: 72, spo2: 98}
%{bed: 7, hr: 72, spo2: 98}
iex(5)> bpm
72
第 1 行:左边是「一个二元组,第一格必须是原子 :ok,第二格是未知数 code」。右边形状吻合、字面量吻合,于是 code 解得 200。第 3 行:第一格 :ok 对不上 :error,整个匹配当场失败——「取值」和「校验」在同一个动作里完成了,这正是 {:ok, _} / {:error, _} 惯例(第 6 章)的地基。
第 4 行是 Map 的规矩,务必记住:Map 匹配是「部分匹配」——模式里只写你关心的键,右边多出来的键一概不管。反过来,模式里写了的键必须存在。元组则是「全量匹配」,长度不同直接失败。不关心的位置用下划线 _ 占坑,或用 _名字 留个注释性的名字:
iex(1)> {_, code, _msg} = {:ok, 200, "stable"}
{:ok, 200, "stable"}
iex(2)> code
200
[head | tail]:列表的天生剖面
上一章说列表是链表:一个头节点,拖着剩下的整条。模式匹配为这个结构准备了专用剖刀:
iex(1)> [head | tail] = [1, 2, 3] [1, 2, 3] iex(2)> head 1 iex(3)> tail [2, 3] iex(4)> [first, second | rest] = [:a, :b, :c, :d] [:a, :b, :c, :d] iex(5)> rest [:c, :d] iex(6)> [h | t] = [] ** (MatchError) no match of right hand side value: []
注意最后一行:空列表没有头,[h | t] 匹配不上。这不是缺陷而是特性——「非空列表」和「空列表」在模式层面就是两个可区分的形状,第 5 章写递归时,这正好就是递归体和终止条件的分界线。
pin ^:这个变量,按旧值查验
说回第一节按下不表的事。Elixir 允许重绑定:x = 1 之后再写 x = 2,合法,x 从此指向 2(旧值没被修改,只是名字换了主人)。那如果我不想让 x 吸收新值,而是想用 x 现在的值去做校验呢?给它别上一枚图钉:
iex(1)> expected = :ok
:ok
iex(2)> {^expected, code} = {:ok, 200}
{:ok, 200}
iex(3)> {^expected, code} = {:error, 500}
** (MatchError) no match of right hand side value: {:error, 500}
^expected 的意思是:「这个位置不是未知数,拿 expected 已有的值(:ok)当字面量用」。忘写 pin 是新手第一大隐性 bug:你以为在校验,实际在悄悄重绑定,匹配永远成功,错误静默溜走。化验台的预设里有一组对照实验,一会儿试。
还有一条规则:同一个模式里,重复出现的变量必须解出同一个值。{a, a} = {1, 1} 成立,{a, a} = {1, 2} 失败——「两格必须相等」不用写任何 if。
切字符串,甚至切报文
上一章埋的伏笔来了:字符串是二进制,所以模式匹配也能切它。最常用的是前缀匹配:
iex(1)> "床位 " <> no = "床位 07" "床位 07" iex(2)> no "07"
更狠的是二进制专用模式 <<>>,可以按位切开一段原始数据。看一眼就好,不必现在掌握——这是 Erlang 当年解析电信协议的看家刀法:
<<version::4, ihl::4, _tos::8, total_len::16, rest::binary>> = packet # version = 4, ihl = 5, total_len = 84 —— 无需任何位运算
在 Kotlin 里做同样的事,是一串 shr、and 0xF 的位运算杂技;在这里,报文的「形状」直接写在等号左边。
多子句函数:把 if 挪到函数头上
模式匹配真正的主战场不在 =,而在函数定义。Elixir 允许同名函数写多个子句,调用时从上到下逐个试匹配,第一个匹配成功的执行:
defmodule Triage do
def check({:hr, bpm}) when bpm > 100, do: "心动过速,优先"
def check({:hr, bpm}) when bpm < 50, do: "心动过缓,优先"
def check({:hr, _bpm}), do: "心率正常"
def check({:spo2, pct}) when pct < 90, do: "血氧偏低,吸氧"
def check({:spo2, _pct}), do: "血氧正常"
def check(_other), do: "未知体征,人工分诊"
end
iex(1)> Triage.check({:hr, 120})
"心动过速,优先"
iex(2)> Triage.check({:spo2, 97})
"血氧正常"
iex(3)> Triage.check(:什么鬼)
"未知体征,人工分诊"
逐条拆解:when 后面的表达式叫 guard(守卫),在形状匹配之后追加条件判断,只能用一小撮纯函数,不能调用自己写的任意函数——因为 guard 必须保证无副作用、可被虚拟机放心优化。白名单里最常用的:比较运算(>、==)、算术、and/or/not、类型检查全家(is_atom、is_binary、is_map……)、in(when dept in [:急诊, :icu])、以及 map_size、length 这类纯查询。子句顺序即优先级,兜底子句(_other)放最后,如果放到最前面,编译器还会警告你后面的子句永远匹配不到。
体会一下这种写法替代了什么:一个 check(sign) 函数,里面 if-else 六层嵌套、instanceof 判断、提前 return——全部消失,每个分支变成一行「形状 → 结果」的对照表。控制流被声明成了数据的形状。这是你从 Elixir 能带走的最重要的思维方式之一。
最接近的 Kotlin 体验是 when 表达式 + 密封类的 smart cast,或者解构声明 val (ok, code) = pair。但有两个本质差距:①Kotlin 解构靠 componentN 约定,不做校验,形状错了要么编译不过要么运行时才炸;Elixir 的模式匹配「解构」和「断言」是同一动作。②Kotlin 的 when 在函数体内,Elixir 的多子句在函数签名上——每个分支是独立函数体,天然短小。
函数头上的 =:局部和整体都要
多子句还有一个高频姿势:参数既想拆开用,又想保留整体。在函数头里用 = 把两者同时绑定:
def alert(%{hr: bpm} = patient) when bpm > 100 do
notify(patient) # 用整体
log("心率 #{bpm} 偏高") # 用局部
end
模式 %{hr: bpm} = patient 读作:「传进来的值既要匹配 %{hr: bpm}(顺便解出 bpm),也整个绑定给 patient」。两个名字指向同一份不可变数据,零拷贝。没有这个技巧,你就得写 def alert(patient) 然后函数体里再拆一次——guard 里也用不上 bpm 了。几乎每个 Phoenix 控制器、每个 GenServer 回调(第 8 章)都长这样,现在记住,后面眼熟。
MatchError 是好事:第一道免疫防线
你可能已经隐隐不安:到处都可能 MatchError,程序岂不是很脆?恭喜,你摸到了这门语言的哲学开关。看一个真实惯用法:
{:ok, content} = File.read("vitals.csv")
# 读到了就往下走;读不到,当场 MatchError,这一行就是案发现场
在「必须成功,失败即 bug」的场合,social contract 就是直接匹配 {:ok, ...}:如果文件必然存在却读不到,说明部署或环境坏了,立刻在离病灶最近的地方倒下,比揣着 nil 继续跑、在三层调用之外抛出 NullPointerException 好得多。而「失败是正常业务分支」的场合,才用 case 分别处理 :ok 和 :error(第 6 章展开)。
这套「断言形状」的写法还会跟着你进测试代码:ExUnit 的 assert {:ok, %{status: 200}} = call_api(),一行同时断言了成功、形状和状态码——测试里最常见的断言方式,就是一次模式匹配。
把模式匹配理解成免疫系统:每一次匹配都是一次抗原检验,形状不对的数据在进入函数体之前就被拦截,错误暴露在最早、信息量最大的位置。第 9 章你会看到这套免疫系统的另一半:进程崩溃后监督树负责重启。「早死早超生」加「死了有人管」,合起来才是 let it crash 的完整含义——本章只是上半句。
动手:模式匹配化验台
这台化验仪真实实现了本章的匹配规则(不是录像):左边写模式,右边写值,按「化验」看绑定结果或 MatchError。预设按钮从上到下,恰好是本章的知识点复查单——特别做一组 pin 实验:环境里 x = 1,模式 ^x 去匹配 2,再把 pin 去掉对比。
- 在化验台上构造三个必然失败的匹配:形状不合、字面量不合、pin 值不合,各一个。
- 用一个模式同时取出
%{status: 200, body: %{name: n}}里的 n——嵌套模式直接写。 - 写一个
grade/1多子句函数(纸上写即可):90 以上返回 :a,60 以上返回 :pass,其余 :fail。要求零 if。 - 解释给同事:为什么忘写 pin 的 bug 很难被发现?它失败的方式是什么?
手术刀已开刃。下一章把它装到流水线上:函数、模块,以及那根把数据一路蒸馏下去的管道 |>。