程序总会遇到意外——区别在于,你是接住它,还是被它带走。
前面九章里我们已经"偶遇"过不少错误:除零、断言、as 失败、数组越界。它们的下场并不相同——有的可以被捕获处理,有的直接终结进程。本章把错误处理正式讲透:try/catch/finally 捕获与清理、throw 抛出与分类、error 对象、assert 断言,以及最重要的一课——哪些错误接得住,哪些接不住。
xlang 的错误模型刻意简单:用户侧只抛字符串描述,运行时包装成统一的 error 对象,按"分类"匹配 catch。学完本章,你写的程序将在意外面前保持体面。
掌握 try/catch/finally 全形态;会用 throw 抛出默认与分类错误;读得懂 error 对象的 kind/message/位置字段;会写分类 catch 并理解匹配顺序与继承;理解异常跨函数传播与空 throw 重抛;掌握 assert 两种形态;背下"可捕获异常 vs 不可捕获 fatal"对照表。
三段式结构:try 包住可能出错的代码,catch 接住错误,finally 无论成败都执行:
try throw "boom" catch e println(e.kind) // Error println(e.message) // boom finally println("done") end
允许的组合:try/catch、try/finally、try/catch/finally——catch 和 finally 至少有一个。finally 的语义是"收尾必做":不管 try 块正常结束还是抛出异常,finally 都执行;实测即便在 try/catch 里 return,finally 也会先跑完再放行(见 10.5 节)。
catch e 声明的变量 e 只在 catch 块内有效,类型是 error(下一节详述)。catch 变量名必须写出——没有省略变量名的"裸 catch"写法。
释放资源:关闭文件、归还锁、恢复全局状态。原则是"try 里申请,finally 里归还"。第 22 章文件 IO 和第 33 章并发会反复用到这个模式。
抛出错误用 throw,两种形态:
throw "boom" // 形态一:字符串,分类为 Error throw ValueError "bad value" // 形态二:指定分类 + 字符串
形态一中,throw 后面是一个 string 表达式,运行时包装为 kind = "Error" 的错误对象。形态二在字符串前加内置错误分类名,让错误带上精确的种类——调用方就能按种类分别处理(10.4 节)。
实测可用的内置分类包括 Error、RuntimeError、ValueError、FileError、ArithmeticError、ZeroDivisionError、OverflowError、AssertionError 等。写不存在的分类名,编译期直接拒绝:
两条限制:抛出的描述必须是能静态确认的 string——var 动态字符串不能进 throw;分类名必须是语言预定义的分类,用户暂不能自定义新分类(想表达业务错误,用 ValueError 加有辨识度的 message 是惯例)。
try throw ValueError "age cannot be negative" catch e println(e.kind + " / " + e.message) end
catch 到的 e 是 error 类型对象。当前版本可用的字段就四个——多访问一个都会被编译器点名:
| 字段 | 类型 | 内容 | 示例 |
|---|---|---|---|
e.kind | string | 错误分类名 | "ZeroDivisionError" |
e.message | string | 错误描述 | "integer division by zero" |
e.line | int | 抛出位置的行号 | 2 |
e.column | int | 抛出位置的列号 | 5 |
报错信息里还透露了一个 file 字段——实测主脚本中它为空字符串(抛出位置就是当前文件),跨文件场景才有意义。设计文档规划的 cause、stack、code 等 richer 字段属于长期目标,当前不可用。
日常用法:kind 用于分流,message 用于展示,line/column 用于日志定位。
catch 可以带分类:catch 分类名 e 只接这一类错误;多个 catch 分支按源码顺序匹配,命中即止;兜底的无分类 catch e 必须放在最后:
int z = 0 try int x = 1 / z catch ValueError e println("值错误") catch ArithmeticError e println("算术错误: " + e.kind) // 命中这里 catch e println("其他: " + e.kind) end
这个例子同时展示了分类继承:除零产生的 ZeroDivisionError 是 ArithmeticError 的子分类,所以被第二个分支接住;而 e.kind 报告的仍是真实种类 "ZeroDivisionError"。当前继承层级:
| 分类 | 父分类 | 由谁产生 |
|---|---|---|
Error | —(根) | throw "..." 的默认分类 |
RuntimeError | Error | 运行时通用错误 |
ArithmeticError | RuntimeError | 算术类错误父类 |
ZeroDivisionError | ArithmeticError | 整数除零 / 模零 |
OverflowError | ArithmeticError | INT64_MIN / -1 等除法溢出 |
ValueError | Error | 用户 throw 常用 |
AssertionError | Error | assert 失败 |
工程推论:catch 分支从具体到一般排列——先 ZeroDivisionError,再 ArithmeticError,最后兜底 catch e。顺序写反,具体分支就成永远到不了的死代码。
函数内抛出的异常,如果没被本函数捕获,就沿调用链向上传播,直到遇到能接住它的 catch:
func div(int a, int b) as int return a / b // b 为 0 时在此抛出 end try println(string(div(10, 0))) catch e println("caught in main: " + e.kind) end
catch 块里写空 throw(不带任何表达式)表示"处理不了,原样再抛"——kind、message、位置全部保留:
func work() try int z = 0 int x = 1 / z catch e println("log: " + e.kind) // 记日志 throw // 原样上抛 end end try work() catch e println("outer: " + e.kind + " / " + e.message) end
"记日志 + 空 throw"是中间层的标准姿势:既不隐瞒问题,也不放弃现场。想在中间层转换错误语义也可以——catch 里抛一个新分类的错误(如把 ZeroDivisionError 转成 ValueError),但原始错误不会自动附带,必要时把 e.message 拼进新消息。
一路无人接住会怎样?异常冲到顶层,进程打印诊断并以退出码 1 终止:
格式:文件:行:列: uncaught 分类: 描述。位置信息精确到抛出点,调试体验相当友好。
断言声明"此处必须为真"的程序假设,两种形态:
assert count > 0 // 形态一:仅条件 assert path != "", "path is empty" // 形态二:条件 + 说明
断言失败抛出 AssertionError——它是可捕获的异常,不是 fatal:
try assert 1 > 2 catch e println(e.kind + " / " + e.message) end try assert 1 > 2, "one must exceed two" catch e println(e.kind + " / " + e.message) end
无消息时默认描述是 "assert failed",带消息时就是你的原文。条件必须是 bool(assert 1 报 [sem] assert 条件必须是 bool),消息必须是静态可确认的 string。
断言的使用哲学:assert 检查"绝不应该发生"的内部不变量,throw 报告"可能会发生"的外部错误。参数校验用 throw(调用方可能传错),内部状态自检用 assert(错了说明自己有 bug)。
把前十章的实测结果汇总成一张表——这是本章最需要背下来的内容:
| 错误 | 性质 | 表现 | 出处 |
|---|---|---|---|
| 整数除零 / 模零 | 异常,可捕获 | ZeroDivisionError | 第 6 章 |
| INT64_MIN / -1 | 异常,可捕获 | OverflowError(integer division overflow) | 本章实测 |
| assert 失败 | 异常,可捕获 | AssertionError | 本章 |
| throw 抛出 | 异常,可捕获 | 指定分类或 Error | 本章 |
| as 转换失败 | fatal,不可捕获 | 无消息,退出码 4 | 第 7 章 |
| 数组越界 | fatal,不可捕获 | 无消息,退出码 3 | 本章实测 |
| 算术溢出(加减乘) | 不报错 | 补码环绕 | 第 6 章 |
| 浮点除零 | 不报错 | 得 inf(IEEE 语义) | 本章实测 |
设计逻辑(回链第 7 章拾遗 3):异常给"调用方可能合理处理"的错误,fatal 给"继续执行只会扩大破坏"的 bug。数组越界和 as 失败意味着程序逻辑已错,xlang 选择立即终止而不是让你在错误状态上继续裸奔。工程纪律随之清晰:
很多语言允许抛任意对象(Java 的 Exception 子类、Python 的任意实例),xlang 却只允许 throw "..." 和 throw 分类 "..."。设计文档的理由:用户写法保持简单,运行时统一包装为 error 对象,C/XRT 边界稳定——xlang 源码里根本不允许构造 error,错误对象只有一种来源。这带来一个 subtle 的好处:任何 catch 到的 e 都保证有 kind/message,不用防御"万一抛上来的不是标准错误"。第 38 章 C 混编时,native 错误也会桥接成同一种 error,模型端到端统一。
设计文档写明:RC 实现中 try/catch/finally 内"暂不允许 return、break、continue"。但笔者实测:try { return 1 } finally { ... } 正常编译运行,finally 先于 return 执行;循环内 try { break } 同样工作正常。这说明实现已悄悄越过了文档宣称的边界——本书以编译器实测为准记录为"可用",但保留一句忠告:在文档追认之前,try 内的控制流转移属于"实现领先于承诺"的区域,关键项目里保守使用。
用 xl cgen 观察生成代码会发现,xlang 的 try/finally 没有依赖 C 不存在的异常机制,而是编译为一套"错误状态轮询 + goto 清理标签"的结构:每个可能出错的操作后检查 xl6_error_has(),有错就 goto 到对应的清理标签;return 路径同样先穿过 finally 生成的清理代码再真正返回。理解了这一点,"finally 必执行"就从魔法变成了可推理的代码布局——而这套零成本异常模型也正是 xlang 能直接把异常映射到 C ABI 边界的原因。
以下报错全部来自真实编译器输出(文件路径已缩短)。
原因:数组越界是 fatal 不是异常,catch 根本来不及执行。正确做法:访问前检查 i < a.length,或用 for-in 遍历让语言替你管边界。
原因:无分类 catch 是"接一切"的兜底,必须排最后;编译器直接禁止把它放前面。分类分支按从具体到一般的顺序排,越具体越靠前。
原因:错误分类是语言预定义集合,不能自定义。正确做法:用相近的内置分类(业务校验用 ValueError),把细分信息写进 message——throw ValueError "order: quantity negative" 这样的"前缀 + 详情"风格便于日志检索。
原因:设计文档里的 cause/stack/code 等字段是长期规划,当前只有 kind/message/file/line/column。报错信息本身列出了可用字段清单,照着用即可。
| 知识点 | 写法/规则 | 要点 |
|---|---|---|
| 捕获 | try ... catch e ... finally ... end | catch/finally 至少其一;finally 必执行 |
| 抛出 | throw "..." / throw 分类 "..." | 只抛 string;分类须内置 |
| error 对象 | kind/message/line/column | kind 分流、message 展示、位置定位 |
| 分类 catch | catch ZeroDivisionError e | 顺序匹配;父类接子类;兜底最后 |
| 传播 | 沿调用链向上 | 空 throw 原样重抛;未捕获 → uncaught 诊断 + 退出码 1 |
| assert | assert cond [, "msg"] | AssertionError,可捕获;条件须 bool |
| fatal 边界 | as 失败(4) / 越界(3) | 不可捕获;靠事前判断防护 |
catch ArithmeticError e 能接住 ZeroDivisionError 吗?接住后 e.kind 报告什么?throw e.message 的区别是什么?"ZeroDivisionError"。throw e.message 创建 kind 为 Error 的新错误,原分类和位置信息丢失。编写 safe_div.xl:函数 safeDiv(int a, int b) as int 在 b 为 0 时 throw ValueError "divisor is zero",否则正常返回商。调用方分别用 0 和非 0 除数调用,catch ValueError 打印友好提示。
参考思路:体会"把运行时的 ZeroDivisionError 前置为语义更明确的 ValueError"——这是库函数的专业姿态。
编写 layered.xl:底层函数可能抛 ZeroDivisionError;中间层 catch 它、打印日志、转成 throw RuntimeError "compute failed: " + e.message;顶层只 catch RuntimeError 并打印。观察三层各看到什么。
参考思路:中间层记得手动拼接 e.message 保留原始信息——转换错误时原始上下文不会自动随行。
编写 bank.xl:模拟余额变量 balance,写 withdraw 函数:金额非正时 throw ValueError;扣款后用 assert balance >= 0, "balance went negative" 自检。分别用合法金额、负金额、透支金额测试,观察三种路径的输出。
参考思路:参数问题用 throw(调用方的错),余额守恒用 assert(自己的不变量)——亲手体会 10.6 节的分工哲学。