第 10 章 / 共 58 章 卷二 运算与控制

错误处理

程序总会遇到意外——区别在于,你是接住它,还是被它带走。

本章导读

前面九章里我们已经"偶遇"过不少错误:除零、断言、as 失败、数组越界。它们的下场并不相同——有的可以被捕获处理,有的直接终结进程。本章把错误处理正式讲透:try/catch/finally 捕获与清理、throw 抛出与分类、error 对象、assert 断言,以及最重要的一课——哪些错误接得住,哪些接不住。

xlang 的错误模型刻意简单:用户侧只抛字符串描述,运行时包装成统一的 error 对象,按"分类"匹配 catch。学完本章,你写的程序将在意外面前保持体面。

本章学习目标

掌握 try/catch/finally 全形态;会用 throw 抛出默认与分类错误;读得懂 error 对象的 kind/message/位置字段;会写分类 catch 并理解匹配顺序与继承;理解异常跨函数传播与空 throw 重抛;掌握 assert 两种形态;背下"可捕获异常 vs 不可捕获 fatal"对照表。

10.1 try/catch/finally

三段式结构:try 包住可能出错的代码,catch 接住错误,finally 无论成败都执行:

basic.xl
try
    throw "boom"
catch e
    println(e.kind)       // Error
    println(e.message)    // boom
finally
    println("done")
end
> xl run basic.xl Error boom done

允许的组合: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"写法。

finally 的典型用途

释放资源:关闭文件、归还锁、恢复全局状态。原则是"try 里申请,finally 里归还"。第 22 章文件 IO 和第 33 章并发会反复用到这个模式。

10.2 throw 与错误分类

抛出错误用 throw,两种形态:

throws.xl
throw "boom"                      // 形态一:字符串,分类为 Error
throw ValueError "bad value"     // 形态二:指定分类 + 字符串

形态一中,throw 后面是一个 string 表达式,运行时包装为 kind = "Error" 的错误对象。形态二在字符串前加内置错误分类名,让错误带上精确的种类——调用方就能按种类分别处理(10.4 节)。

实测可用的内置分类包括 Error、RuntimeError、ValueError、FileError、ArithmeticError、ZeroDivisionError、OverflowError、AssertionError 等。写不存在的分类名,编译期直接拒绝:

# bad.xl 内容:throw NotExistKind "x" > xl run bad.xl bad.xl:2:11: [sem] 未知异常类型

两条限制:抛出的描述必须是能静态确认的 string——var 动态字符串不能进 throw;分类名必须是语言预定义的分类,用户暂不能自定义新分类(想表达业务错误,用 ValueError 加有辨识度的 message 是惯例)。

typed.xl
try
    throw ValueError "age cannot be negative"
catch e
    println(e.kind + " / " + e.message)
end
> xl run typed.xl ValueError / age cannot be negative

10.3 error 对象

catch 到的 e 是 error 类型对象。当前版本可用的字段就四个——多访问一个都会被编译器点名:

字段类型内容示例
e.kindstring错误分类名"ZeroDivisionError"
e.messagestring错误描述"integer division by zero"
e.lineint抛出位置的行号2
e.columnint抛出位置的列号5
# fields.xl 内容: # try # throw "x" # catch e # println(e.source) ← 文档提过但尚未实现 > xl run fields.xl fields.xl:4:15: [sem] error only supports kind/message/file/line/column fields

报错信息里还透露了一个 file 字段——实测主脚本中它为空字符串(抛出位置就是当前文件),跨文件场景才有意义。设计文档规划的 cause、stack、code 等 richer 字段属于长期目标,当前不可用。

日常用法:kind 用于分流,message 用于展示,line/column 用于日志定位。

10.4 分类 catch 与匹配顺序

catch 可以带分类:catch 分类名 e 只接这一类错误;多个 catch 分支按源码顺序匹配,命中即止;兜底的无分类 catch e 必须放在最后:

classified.xl
int z = 0
try
    int x = 1 / z
catch ValueError e
    println("值错误")
catch ArithmeticError e
    println("算术错误: " + e.kind)     // 命中这里
catch e
    println("其他: " + e.kind)
end
> xl run classified.xl 算术错误: ZeroDivisionError

这个例子同时展示了分类继承:除零产生的 ZeroDivisionError 是 ArithmeticError 的子分类,所以被第二个分支接住;而 e.kind 报告的仍是真实种类 "ZeroDivisionError"。当前继承层级:

分类父分类由谁产生
Error—(根)throw "..." 的默认分类
RuntimeErrorError运行时通用错误
ArithmeticErrorRuntimeError算术类错误父类
ZeroDivisionErrorArithmeticError整数除零 / 模零
OverflowErrorArithmeticErrorINT64_MIN / -1 等除法溢出
ValueErrorError用户 throw 常用
AssertionErrorErrorassert 失败

工程推论:catch 分支从具体到一般排列——先 ZeroDivisionError,再 ArithmeticError,最后兜底 catch e。顺序写反,具体分支就成永远到不了的死代码。

10.5 异常传播与重新抛出

函数内抛出的异常,如果没被本函数捕获,就沿调用链向上传播,直到遇到能接住它的 catch:

prop.xl
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
> xl run prop.xl caught in main: ZeroDivisionError

catch 块里写空 throw(不带任何表达式)表示"处理不了,原样再抛"——kind、message、位置全部保留:

rethrow.xl
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
> xl run rethrow.xl log: ZeroDivisionError outer: ZeroDivisionError / integer division by zero

"记日志 + 空 throw"是中间层的标准姿势:既不隐瞒问题,也不放弃现场。想在中间层转换错误语义也可以——catch 里抛一个新分类的错误(如把 ZeroDivisionError 转成 ValueError),但原始错误不会自动附带,必要时把 e.message 拼进新消息。

一路无人接住会怎样?异常冲到顶层,进程打印诊断并以退出码 1 终止:

> xl run uncaught.xl uncaught.xl:2:11: uncaught ZeroDivisionError: integer division by zero

格式:文件:行:列: uncaught 分类: 描述。位置信息精确到抛出点,调试体验相当友好。

10.6 assert 断言

断言声明"此处必须为真"的程序假设,两种形态:

asserts.xl
assert count > 0                          // 形态一:仅条件
assert path != "", "path is empty"       // 形态二:条件 + 说明

断言失败抛出 AssertionError——它是可捕获的异常,不是 fatal:

caught_assert.xl
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
> xl run caught_assert.xl AssertionError / assert failed AssertionError / one must exceed two

无消息时默认描述是 "assert failed",带消息时就是你的原文。条件必须是 bool(assert 1 报 [sem] assert 条件必须是 bool),消息必须是静态可确认的 string。

断言的使用哲学:assert 检查"绝不应该发生"的内部不变量,throw 报告"可能会发生"的外部错误。参数校验用 throw(调用方可能传错),内部状态自检用 assert(错了说明自己有 bug)。

10.7 异常 vs fatal 的边界

把前十章的实测结果汇总成一张表——这是本章最需要背下来的内容:

错误性质表现出处
整数除零 / 模零异常,可捕获ZeroDivisionError第 6 章
INT64_MIN / -1异常,可捕获OverflowError(integer division overflow)本章实测
assert 失败异常,可捕获AssertionError本章
throw 抛出异常,可捕获指定分类或 Error本章
as 转换失败fatal,不可捕获无消息,退出码 4第 7 章
数组越界fatal,不可捕获无消息,退出码 3本章实测
算术溢出(加减乘)不报错补码环绕第 6 章
浮点除零不报错得 inf(IEEE 语义)本章实测
# oob.xl 内容: # try # auto a = [1, 2, 3] # println(string(a[10])) # catch e # println("caught") # end # println("after") > xl run oob.xl (无任何输出,进程退出码 3——catch 和 after 都没执行)

设计逻辑(回链第 7 章拾遗 3):异常给"调用方可能合理处理"的错误,fatal 给"继续执行只会扩大破坏"的 bug。数组越界和 as 失败意味着程序逻辑已错,xlang 选择立即终止而不是让你在错误状态上继续裸奔。工程纪律随之清晰:

  • 越界防护靠事前判断(长度检查、9.7 节的 in),不靠 try;
  • as 之前必有 is(第 8 章模式);
  • try/catch 留给真正的可恢复错误:IO、解析、业务校验。

深入细节

拾遗 1:为什么 throw 只让抛字符串

很多语言允许抛任意对象(Java 的 Exception 子类、Python 的任意实例),xlang 却只允许 throw "..." 和 throw 分类 "..."。设计文档的理由:用户写法保持简单,运行时统一包装为 error 对象,C/XRT 边界稳定——xlang 源码里根本不允许构造 error,错误对象只有一种来源。这带来一个 subtle 的好处:任何 catch 到的 e 都保证有 kind/message,不用防御"万一抛上来的不是标准错误"。第 38 章 C 混编时,native 错误也会桥接成同一种 error,模型端到端统一。

拾遗 2:设计文档与实现的落差——return/break 其实能进 try

设计文档写明:RC 实现中 try/catch/finally 内"暂不允许 return、break、continue"。但笔者实测:try { return 1 } finally { ... } 正常编译运行,finally 先于 return 执行;循环内 try { break } 同样工作正常。这说明实现已悄悄越过了文档宣称的边界——本书以编译器实测为准记录为"可用",但保留一句忠告:在文档追认之前,try 内的控制流转移属于"实现领先于承诺"的区域,关键项目里保守使用。

拾遗 3:finally 在 return 路上也会执行——怎么实现的

用 xl cgen 观察生成代码会发现,xlang 的 try/finally 没有依赖 C 不存在的异常机制,而是编译为一套"错误状态轮询 + goto 清理标签"的结构:每个可能出错的操作后检查 xl6_error_has(),有错就 goto 到对应的清理标签;return 路径同样先穿过 finally 生成的清理代码再真正返回。理解了这一点,"finally 必执行"就从魔法变成了可推理的代码布局——而这套零成本异常模型也正是 xlang 能直接把异常映射到 C ABI 边界的原因。

⚠️ 常见错误

以下报错全部来自真实编译器输出(文件路径已缩短)。

坑 1:以为 try 能兜住数组越界
# bad.xl 内容: # try # auto a = [1, 2, 3] # println(string(a[99])) # catch e # println("safe") # end > xl run bad.xl (无输出,退出码 3)

原因:数组越界是 fatal 不是异常,catch 根本来不及执行。正确做法:访问前检查 i < a.length,或用 for-in 遍历让语言替你管边界。

坑 2:兜底 catch 写在分类 catch 前面
# bad.xl 内容: # try # int x = 1 / z # catch e ← 兜底在前 # println("first") # catch e2 # println("second") # end > xl run bad.xl bad.xl:6:1: [sem] catch e 必须位于所有分类 catch 之后

原因:无分类 catch 是"接一切"的兜底,必须排最后;编译器直接禁止把它放前面。分类分支按从具体到一般的顺序排,越具体越靠前。

坑 3:throw 一个没注册过的分类名
# bad.xl 内容:throw MyError "custom" > xl run bad.xl bad.xl:2:11: [sem] 未知异常类型

原因:错误分类是语言预定义集合,不能自定义。正确做法:用相近的内置分类(业务校验用 ValueError),把细分信息写进 message——throw ValueError "order: quantity negative" 这样的"前缀 + 详情"风格便于日志检索。

坑 4:访问 error 还没实现的字段
# bad.xl 内容: # try # throw "x" # catch e # println(e.stack) # end > xl run bad.xl bad.xl:4:15: [sem] error only supports kind/message/file/line/column fields

原因:设计文档里的 cause/stack/code 等字段是长期规划,当前只有 kind/message/file/line/column。报错信息本身列出了可用字段清单,照着用即可。

本章小结

知识点写法/规则要点
捕获try ... catch e ... finally ... endcatch/finally 至少其一;finally 必执行
抛出throw "..." / throw 分类 "..."只抛 string;分类须内置
error 对象kind/message/line/columnkind 分流、message 展示、位置定位
分类 catchcatch ZeroDivisionError e顺序匹配;父类接子类;兜底最后
传播沿调用链向上空 throw 原样重抛;未捕获 → uncaught 诊断 + 退出码 1
assertassert cond [, "msg"]AssertionError,可捕获;条件须 bool
fatal 边界as 失败(4) / 越界(3)不可捕获;靠事前判断防护

复习题

✍️ 先遮住答案想一想
  1. catch ArithmeticError e 能接住 ZeroDivisionError 吗?接住后 e.kind 报告什么?
  2. 空 throw 与 throw e.message 的区别是什么?
  3. 为什么 xlang 不允许 throw 一个 var 动态字符串?
  4. assert 和 throw 都可被捕获,它们的用途分界在哪里?
  5. 数组越界为什么是 fatal 而不是异常?由此得出的编码纪律是什么?
参考答案
  1. 能——ZeroDivisionError 是 ArithmeticError 的子分类;e.kind 报告真实种类 "ZeroDivisionError"。
  2. 空 throw 保留原错误的 kind、message、位置原样上抛;throw e.message 创建 kind 为 Error 的新错误,原分类和位置信息丢失。
  3. 异常边界要求类型确定:throw 的设计要求表达式能静态确认为 string,避免错误对象携带不确定类型穿越异常边界。
  4. assert 守内部不变量(失败说明自己有 bug),throw 报外部可恢复错误(调用方、环境、输入问题)。
  5. 越界说明程序逻辑已错,继续执行只会扩大破坏;fatal 立即终止保护现场。纪律:访问前查长度或用 for-in,绝不靠 try 兜越界。

编程练习

练习 1:安全除法器

编写 safe_div.xl:函数 safeDiv(int a, int b) as int 在 b 为 0 时 throw ValueError "divisor is zero",否则正常返回商。调用方分别用 0 和非 0 除数调用,catch ValueError 打印友好提示。

参考思路:体会"把运行时的 ZeroDivisionError 前置为语义更明确的 ValueError"——这是库函数的专业姿态。

练习 2:错误转换层

编写 layered.xl:底层函数可能抛 ZeroDivisionError;中间层 catch 它、打印日志、转成 throw RuntimeError "compute failed: " + e.message;顶层只 catch RuntimeError 并打印。观察三层各看到什么。

参考思路:中间层记得手动拼接 e.message 保留原始信息——转换错误时原始上下文不会自动随行。

练习 3:断言自检

编写 bank.xl:模拟余额变量 balance,写 withdraw 函数:金额非正时 throw ValueError;扣款后用 assert balance >= 0, "balance went negative" 自检。分别用合法金额、负金额、透支金额测试,观察三种路径的输出。

参考思路:参数问题用 throw(调用方的错),余额守恒用 assert(自己的不变量)——亲手体会 10.6 节的分工哲学。