VOL.II · 解剖CH 06监护 06/13

控制流与错误哲学

解剖的最后一课,讲流程的分岔口,但真正的主题是错误观。Elixir 对错误的态度分三层:能预期的失败是普通的值,用元组装着到处传;没法预期的才是异常,而异常的归宿不是 catch,是让进程干脆地死掉。本章讲前两层的语法(case、with、raise),第三层「死掉之后呢」——那是下一卷整卷的事。读完这章,VOL.II 解剖完毕,你已经能读写地道的 Elixir 了。

case{:ok, _} 惯例with错误是值

case:模式匹配的分岔口形态

第 3 章的多子句函数,是模式匹配长在函数头上;case 是同一个能力长在表达式里:

case File.read("vitals.csv") do
  {:ok, content}      -> parse(content)
  {:error, :enoent}   -> "文件不存在,先去采集"
  {:error, reason}    -> "读取失败:#{inspect(reason)}"
end

规则和函数子句完全一致:从上往下试匹配,第一个命中的分支执行,分支里同样可以带 guard({:hr, n} when n > 100 ->)。两个要点:①case 是表达式,整个块的值就是命中分支的值,可以直接 result = case ... end;②一个都匹配不上会抛 CaseClauseError——和 MatchError 一样,这是特性不是缺陷:你没预料到的形状出现了,在第一现场倒下,别放它溜进深处。需要兜底就显式写 _ ->,把「我允许任何形状」写成看得见的代码。

顺带收编两个小角色。cond 用于「多个条件而非多个形状」的场合,相当于 if-else-if 链:

cond do
  bpm > 100 -> :tachy
  bpm < 50  -> :brady
  true      -> :normal   # 惯例兜底
end

if 只处理「一个条件、两个出口」的最简情形。有一个安静的陷阱值得点名:if 也是表达式,不写 else 时,条件不成立就返回 nilx = if ok?, do: 1 在 ok? 为假时,x 是 nil 而不是报错——接住这个 nil 的往往是三层之外的代码。凡是要用 if 的返回值,请把 else 写全。三者的选用几乎不用纠结:看形状用 case,看条件用 cond,只有真假用 if。Elixir 没有 switch、没有三元运算符、没有 else-if 关键字——上面三样已经覆盖了全部场景。

{:ok, _} / {:error, _}:错误是值

现在讲这门语言最重要的工程惯例。你已经见过很多次:File.read 返回 {:ok, content}{:error, reason}。这不是 File 模块的个人风格,而是整个生态的统一契约:任何「可能正常失败」的操作,都把结果装在带标签的元组里返回。

细品这个设计:错误不是被「抛出」到调用栈某个未知楼层,而是作为普通的返回值老老实实交到调用者手上。于是:

  • 函数签名即文档——看到返回 {:ok, _} | {:error, _},就知道它会失败、失败长什么样;
  • 处理错误 = 模式匹配,和处理成功用同一套语法,没有第二套控制流;
  • 想忽略错误?做不到——不拆元组你连成功的值都拿不出来。编译器没法强迫你处理异常,但数据形状可以。
▸ Android 类比

这就是 Kotlin 的 Result<T>、Arrow 的 Either、Rust 的 Result<T, E> 想要的东西——「错误编码进返回类型」。区别是生态位:Kotlin 里它是可选风格,和异常双轨并行,库之间约定不一;Elixir 里 tagged tuple 是全生态默认,标准库、三方库、你的代码说同一种方言,case 一接就走。统一本身就是生产力。

带 ! 的孪生函数:把选择权交给调用者

标准库里许多函数有个带感叹号的孪生兄弟:File.read/1 返回元组,File.read!/1 成功直接给值、失败直接抛异常。该用哪个?判据只有一条:这个失败,在你的场景里是「正常业务分支」还是「就是出 bug 了」?

# 用户上传的文件可能不存在 —— 失败是业务的一部分,用元组版
case File.read(user_upload) do
  {:ok, data} -> import(data)
  {:error, _} -> {:error, "请重新上传"}
end

# 应用自带的配置文件必须存在 —— 读不到就是部署坏了,用 ! 版
config = File.read!("config/wards.json")

第二种就是第 3 章说过的「形状断言」思路的正式版:必然成立的事,断言它;断言失败,离病灶最近的这一行就是案发现场。! 版本不是偷懒,是在声明「此处失败即 bug」。

with:多步流程的快乐路径

元组惯例有个软肋:多步连续操作时,case 会一层套一层。三步各自可能失败——解析请求、查病人、开医嘱:

case parse(params) do
  {:ok, req} ->
    case find_patient(req.bed) do
      {:ok, patient} ->
        case prescribe(patient, req.med) do
          {:ok, order} -> {:ok, order}
          err -> err
        end
      err -> err
    end
  err -> err
end

每层各处理一次失败、错误原样上抛,缩进却一层深过一层——这种代码在任何语言里都是评审时的叹气点。专治它的语法叫 with:

with {:ok, req}     <- parse(params),
     {:ok, patient} <- find_patient(req.bed),
     {:ok, order}   <- prescribe(patient, req.med) do
  {:ok, order}
else
  {:error, :not_found} -> {:error, "查无此床位"}
  {:error, reason}     -> {:error, reason}
end

读法:每行 模式 <- 表达式 依次执行,匹配成功就带着绑定进下一行;任何一行匹配失败,立刻带着那个失败值跳到 else(没写 else 就原样返回失败值)。成功路径从上到下一列排开,像管道一样可读,但每一步都自带「失败则弹出」的安全带。管道 |> 与 with 的分工由此清晰:步骤不会失败用管道,步骤会失败用 with

⚠ 病历警示

with 的经典坑:else 块要接住所有中途弹出的形状。如果 find_patient 失败时返回裸 :error(不带原因),而你的 else 只写了 {:error, reason},弹出的 :error 无匹配,当场 WithClauseError。对策有二:上游统一返回形状(推荐,团队规范问题);或 else 里加兜底子句。另一个原则:with 里只放会失败的步骤,纯计算(不会失败的)直接用 = 绑定塞在行间即可,别都挤进 <-

raise 与 try:存在,但很少见

Elixir 当然有异常:raise "boom"raise ArgumentError, message: "...",也有 try/rescue/after。但翻开一个成熟的 Elixir 代码库,你会惊讶于 try 的稀有程度。原因前面已经铺满了:可预期的失败走元组,不可预期的失败是 bug——而 bug 的正确处理方式不是就地 rescue 掩埋,是让它炸响。至于「炸了之后系统怎么不倒」,答案不在语言层,在下一卷的监督树。

所以 rescue 的合理场景其实很窄:边界处兜住第三方库的异常转成元组、资源清理(after)、框架的顶层防线。业务逻辑里出现 try/rescue,九成是把它当 Java 用了,评审时值得画个问号。

exit 与 throw:另外两个出口,先混脸熟

完整起见,BEAM 其实有三种「非常规出口」,严格程度递增地列在这里:①throw——非局部跳转,配 try/catch 用,唯一体面的场景是从深层遍历中提前带值跳出,极少见;②raise——本章讲的异常,给 bug 用;③exit——直接终止当前进程,比如 exit(:shutdown)。第三个此刻听起来吓人,「终止进程」在单进程世界里等于程序结束——但请带着这句话进下一卷:exit 信号是进程之间谈论生死的语言,监督者靠监听子进程的 exit 信号得知它死了、为什么死。第 9 章它会成为主角,这里只需要知道:它不是异常,rescue 接不住它,这是刻意的设计。

学会读崩溃报告

既然哲学是「该崩就崩」,读懂崩溃报告就是基本功。解剖一份典型的:

iex(1)> Triage.check("hr: 120")
** (FunctionClauseError) no function clause matching in Triage.check/1

    The following arguments were given to Triage.check/1:

        # 1
        "hr: 120"

    lib/triage.ex:2: Triage.check/1
    (elixir 1.20.0) lib/enum.ex:1712: Enum."-map/2-lists^map/1-1-"/2
    iex:1: (file)

从上往下读:①异常类型 FunctionClauseError——所有子句都没匹配上,是 MatchError 的函数版;②肇事参数原文——传进来的是字符串 "hr: 120" 而不是元组 {:hr, 120},病因一眼可见;③调用栈,格式统一为 文件:行号: 模块.函数/arity,最上面一行离病灶最近。Elixir 的报错在业界口碑极好,原因就是这份「参数原文 + 精确 M/F/A」的信息密度——模式匹配把错误拦在函数门口,门口自然知道来者是谁。养成习惯:先读异常类型,再读肇事值,最后才看栈,九成崩溃不用打开编辑器就能定位。

三层错误观,和一句预告

层级性质工具例子
预期的失败业务的正常分支{:error, _} + case/with文件不存在、用户不存在、超时
不可预期的失败bug 或环境损坏! 函数、形状断言、raise配置缺失、非法状态、断言失败
失败之后进程死亡与复活监督树(VOL.III)崩溃的进程被重启回干净状态

这张表值得存进你的长期记忆:第一层是日常(每天几十次的 case/with),第二层是纪律(敢于断言、敢于崩溃),第三层是底气(崩了有人管)。三层缺了任何一层,另外两层都站不住——只有元组没有监督树,你还是不敢崩;只有监督树没有元组惯例,业务错误和 bug 会搅成一锅。

注意第二层和第三层之间的裂缝:我们一直理直气壮地说「让它炸」,但炸掉的可是整个程序啊?——在大多数语言里,是的,所以大家不敢炸,只好层层防御。BEAM 敢,是因为炸掉的只是一个 2.6 KB 的进程,而进程有专人负责收尸和重启。「错误哲学」三个字里,语言语法只占三分之一,剩下三分之二是运行时结构。解剖课到此为止,下一章,我们去看心跳。

✚ 动手自测
  1. 写一个 safe_div(a, b):除数为 0 返回 {:error, :div_by_zero},否则 {:ok, 商};再写调用方的 case。
  2. 把三个连续的 Map.fetch/2(返回 {:ok, v}:error)串成 with,并处理裸 :error 的 else 陷阱。
  3. 判断题:解析用户输入的日期失败,该用 Date.from_iso8601/1 还是 Date.from_iso8601!/1?读取应用自带的国际化文案文件呢?
  4. 用一句话向同事解释「错误是值」和「异常留给 bug」的分界线。