第 10 章 · 错误处理与资源清理
解决什么问题:函数做不到它承诺的事时,怎么告诉调用方,调用方又怎么保证打开的东西被关上。
四种语言的态度:Python、JS/TS、C# 用异常,Go 用返回值;清理靠 with、finally、using、defer。
从配置文件里读出服务要监听的端口号。这件小事会以三种方式失败:文件可能不存在;文件在,内容却是 abc 这样的非数字;内容是数字,却是 70000 这样超出 1–65535 的值。三种失败性质不同:文件不存在是环境问题;内容不是数字是数据问题,得有人去改文件;超出范围则是我们自己定的规则,操作系统和解析函数都不会替我们检查。程序在临时目录里造出三个文件(缺的那个故意不造),对四种情况各打印一行,最后不管中间出了什么错,都把临时目录删干净。
函数读不到文件时该返回什么?C 语言的老办法是返回特殊值(比如 -1)或设置全局变量 errno,可是调用方忘了检查,程序就带着 -1 往下跑,到很远的地方才莫名其妙地出错。现代语言有两大流派。一是异常(exception):出错处抛出一个异常对象,正常执行立即中断,运行时沿调用栈(call stack)一层层往上找处理它的代码,中间各层不用写任何代码。二是错误值(error value):错误是一个普通的值,和正常结果一起返回,每一层都要显式检查,要么就地处理,要么补一句说明交给上一层。
看两栏里虚线的条数:左栏只有一条,从 open 直接跳回 report,load_port 里没有一行和这个错误有关的代码;右栏每一层都有一条显式的返回虚线,错误经过 loadPort 时还被包上了一层说明。
两大流派都要区分两类失败。文件不存在、内容写错是可预期的错误,应该处理并给出清楚的提示;数组越界、访问空引用是程序的缺陷(bug),应该让程序立刻崩溃并留下调用栈,尽早修掉。异常语言用同一套机制表达两者,只靠类型区分,所以“只接你认识的类型”格外要紧;Go 把前者交给错误值,后者交给单独的 panic。
打开的文件、网络连接、锁,都必须在用完后释放。写成“打开、处理、关闭”三行,处理一步一旦抛出异常,关闭那行就不会执行;错误值流派里,每多一个提前 return 的分支,就多一处要记得关闭。所以四种语言都提供由语言保证的清理写法:进入时登记,离开时不管正常结束还是中途出错,登记的清理都一定执行。
看两条路径汇到同一个“清理一定执行”的节点:正常结束和中途抛错要等清理做完才重新分开。
最后,错误要带上下文。最底层的错误往往只说“找不到文件”,不说是哪个文件;每一层往上传时补一句自己在做什么,最终的信息才能直接指出问题。做法有两种:包装(wrapping),用新错误把旧错误裹在里面;异常链(exception chaining),新异常记住自己是在处理哪个旧异常时产生的。共同的要求是包装之后原错误仍然认得出来,调用方还能问“根本原因是不是文件不存在”。
Python 怎么做
Section titled “Python 怎么做”Python 的错误处理是原生语法:raise 抛出,try/except/finally 捕获和收尾,with 管理资源。社区习惯叫 EAFP(Easier to Ask for Forgiveness than Permission,请求原谅比请求许可容易):直接打开文件,失败了再接异常,而不是先检查文件在不在。
import tempfilefrom pathlib import Path
class ConfigError(Exception): """配置文件内容有问题。"""
def load_port(path: Path) -> int: with path.open(encoding="utf-8") as f: # 文件不存在时这里抛 FileNotFoundError;离开 with 时文件一定关闭 raw = f.read().strip() try: port = int(raw) except ValueError: raise ConfigError(f"端口不是数字:{raw}") from None if not 1 <= port <= 65535: raise ConfigError(f"端口超出范围:{port}") return port
def report(name: str, path: Path) -> None: try: print(f"{name}: 端口 {load_port(path)}") except FileNotFoundError: print(f"{name}: 配置文件不存在") except ConfigError as e: print(f"{name}: {e}")
with tempfile.TemporaryDirectory() as tmp: base = Path(tmp) (base / "bad.conf").write_text("abc", encoding="utf-8") (base / "range.conf").write_text("70000", encoding="utf-8") (base / "ok.conf").write_text("8080\n", encoding="utf-8") report("missing", base / "missing.conf") report("bad", base / "bad.conf") report("range", base / "range.conf") report("ok", base / "ok.conf")
# 输出:# missing: 配置文件不存在# bad: 端口不是数字:abc# range: 端口超出范围:70000# ok: 端口 8080运行:python port.py。异常是类,按继承组成层次:open 找不到文件时抛 FileNotFoundError,它属于 OSError 家族(同族还有 PermissionError 等);int("abc") 抛 ValueError。多个 except 按书写顺序匹配,第一个“是这个类或其子类”的分支胜出。自定义异常继承 Exception 即可,load_port 把 ValueError 翻译成 ConfigError,调用方不必知道内部用的是 int()。
在 except 块里抛出新异常时,Python 默认把旧异常存进新异常的 __context__,打印时两段回溯一起出现;raise … from e 把旧异常显式记为直接原因 __cause__,from None 则表示旧异常只是实现细节,不必显示。with 后面的对象叫上下文管理器(context manager):离开块时文件一定关闭,TemporaryDirectory() 一定删掉目录。注意 path.open() 在进入 with 之前求值,文件打不开时没有东西要关,异常直接从这一行抛出。
底层怎么实现。 CPython 把正在传播的异常记在线程状态里。抛出时,解释器在当前函数的异常表(exception table)里找覆盖当前字节码位置的处理器(handler),这张表在编译时生成;找不到就结束当前帧(frame),回到调用者的帧接着找,一路把经过的帧记进回溯(traceback),这个过程叫栈展开(stack unwinding)。3.11 之前每进入一个 try 都要执行一条指令登记处理器;3.11 起改为查表,不抛出时没有任何额外指令,代价全部挪到抛出的那一刻:创建异常对象、逐帧查表、构造回溯。
with 是语法糖:先调用管理器的 __enter__(),把块内代码放进一个隐含的 try,不管块怎样结束都调用 __exit__(exc_type, exc, tb)。__exit__ 返回真值就吞掉异常;文件对象的 __exit__ 关闭文件后返回 None,所以异常照常往上走。
- 裸
except:连KeyboardInterrupt和SystemExit都接住,Ctrl+C 和sys.exit()都失效。这两个类特意直接继承BaseException,好让except Exception放过它们。 - 在
finally里写return:展开走到finally时函数正常返回,正在传播的异常就此消失,调用方拿到一个看似正常的值。3.14 起编译器对此发出SyntaxWarning。 - 以为异常慢而先用
if检查:3.11 以后try不抛就不花钱;而“先查文件在不在、再打开”两步之间文件可能被删,留下竞态条件(race condition)。
JS/TS 怎么做
Section titled “JS/TS 怎么做”JavaScript 的异常也是原生语法:throw 抛出,try/catch/finally 捕获和收尾。TypeScript 在语法上什么也没加,只把 catch 变量的类型收紧了。
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";import { tmpdir } from "node:os";import { join } from "node:path";
class ConfigError extends Error {}
function loadPort(path: string): number { const raw = readFileSync(path, "utf8").trim(); // 文件不存在时抛 code 为 ENOENT 的 Error const port = Number(raw); if (!Number.isInteger(port)) throw new ConfigError(`端口不是数字:${raw}`); if (port < 1 || port > 65535) throw new ConfigError(`端口超出范围:${port}`); return port;}
function report(name: string, path: string): void { try { console.log(`${name}: 端口 ${loadPort(path)}`); } catch (e) { if (e instanceof ConfigError) console.log(`${name}: ${e.message}`); else if ((e as { code?: string }).code === "ENOENT") console.log(`${name}: 配置文件不存在`); else throw e; }}
const base = mkdtempSync(join(tmpdir(), "port-"));try { writeFileSync(join(base, "bad.conf"), "abc"); writeFileSync(join(base, "range.conf"), "70000"); writeFileSync(join(base, "ok.conf"), "8080\n"); report("missing", join(base, "missing.conf")); report("bad", join(base, "bad.conf")); report("range", join(base, "range.conf")); report("ok", join(base, "ok.conf"));} finally { rmSync(base, { recursive: true, force: true }); // 相当于 Python 的 with:不管上面有没有抛错都执行}
// 输出:// missing: 配置文件不存在// bad: 端口不是数字:abc// range: 端口超出范围:70000// ok: 端口 8080运行:tsx port.ts。throw 后面可以是任何值,throw 42 也合法,所以 TS 在 strict 下把 catch (e) 的 e 定为 unknown,必须先收窄类型才能读它的属性。一个 catch 不能按类型分成几个分支,认不出的错误要 throw e 原样抛回,否则等于全部吞掉。Node 的文件函数抛出普通的 Error,种类靠字符串属性 code 区分(ENOENT 是不存在,EACCES 是没权限)。ES2022 起,new Error("读配置失败", { cause: e }) 把原错误存进 cause。
await 一个被拒绝(rejected)的 Promise 时,拒绝原因在 await 处作为异常抛出,所以 async 函数里照样用 try/catch;没人处理的拒绝,Node 15 起默认让进程退出。显式资源管理(Explicit Resource Management)提案带来了 using 声明,TS 5.2 起能编译它;本章代码要在 Node 22 上原样运行,所以用 finally 保证 rmSync 执行。
底层怎么实现。 V8 把函数编译成字节码时,同时生成一张处理器表(handler table),记录每段 try 覆盖的范围和入口。throw 时引擎在当前帧的表里找处理器,找不到就弹出这一帧,到调用者那里接着找;finally 也登记在表里,块无论怎样离开都先经过它。调用栈在 Error 对象创建时抓取并写进 stack,不是在 throw 时,所以抛一个字符串不会留下任何栈信息。异步函数里逃出的任何异常(包括第一个 await 之前的)都会变成它返回的 Promise 的拒绝,等有人 await 时再交出去;await 之后恢复执行时,原来的调用者早已返回,本来也无栈可展开。
- 忘了
await:try { saveConfig(); } catch {}里,异步函数返回的 Promise 被拒绝,那时try早已结束,catch接不到,错误成了未处理的拒绝,进程退出。 JSON.parse失败抛的是内置的SyntaxError。配置改成 JSON 而report仍只认ConfigError和ENOENT,少个逗号的文件就会走到throw e把程序打崩。Number(raw)比另外三种语言的解析宽松:"1e3"、"0x50"、"8080.0"都能通过,空串变成 0,然后报“超出范围:0”;要严格就先用/^\d+$/检查。- 抛非
Error的值没有stack,e.message是undefined;跨 realm(iframe、vm模块的上下文)时instanceof也会失效,因为每个 realm 有自己的一份Error。
C# 怎么做
Section titled “C# 怎么做”C# 的异常是原生语法:throw 抛出,try/catch/finally 捕获和收尾,using 管理资源,异常类都派生自 System.Exception。.NET 的态度是异常只留给少见的情况,经常会失败的操作,标准库另配一个不抛异常的版本。
using System;using System.IO;
var dir = Directory.CreateTempSubdirectory("port-");try{ File.WriteAllText(Path.Combine(dir.FullName, "bad.conf"), "abc"); File.WriteAllText(Path.Combine(dir.FullName, "range.conf"), "70000"); File.WriteAllText(Path.Combine(dir.FullName, "ok.conf"), "8080\n"); Report("missing", Path.Combine(dir.FullName, "missing.conf")); Report("bad", Path.Combine(dir.FullName, "bad.conf")); Report("range", Path.Combine(dir.FullName, "range.conf")); Report("ok", Path.Combine(dir.FullName, "ok.conf"));}finally{ dir.Delete(recursive: true); // 不管上面有没有抛错都执行}
static int LoadPort(string path){ string raw; using (var reader = new StreamReader(path)) // 文件不存在时这里抛 FileNotFoundException;离开 using 时文件一定关闭 { raw = reader.ReadToEnd().Trim(); } if (!int.TryParse(raw, out var port)) throw new ConfigError($"端口不是数字:{raw}"); if (port is < 1 or > 65535) throw new ConfigError($"端口超出范围:{port}"); return port;}
static void Report(string name, string path){ try { Console.WriteLine($"{name}: 端口 {LoadPort(path)}"); } catch (FileNotFoundException) { Console.WriteLine($"{name}: 配置文件不存在"); } catch (ConfigError e) { Console.WriteLine($"{name}: {e.Message}"); }}
class ConfigError(string message) : Exception(message);
// 输出:// missing: 配置文件不存在// bad: 端口不是数字:abc// range: 端口超出范围:70000// ok: 端口 8080运行:dotnet run port.cs。FileNotFoundException 派生自 IOException,再往上到 Exception。catch 按顺序匹配,父类写在子类前面会让子类分支永远到不了,编译器直接报错。ConfigError 用 C# 12 的主构造函数(primary constructor)一行写完。int.TryParse 解析失败时返回 false,不抛 FormatException;同样的模式还有 Dictionary.TryGetValue。
catch 可以带过滤器(exception filter):catch (HttpRequestException e) when (e.StatusCode == HttpStatusCode.NotFound) 只在条件为真时接住,否则当作没匹配,继续往上找。using 块结束时调用 Dispose(),适用于任何实现了 IDisposable 的类型;C# 8 起的 using var reader = …; 不写花括号,到所在作用域结束时释放;异步释放的资源用 await using。
底层怎么实现。 .NET 的异常处理分两遍(two-pass)。第一遍从抛出点逐帧往上,只查找不执行,找到第一个类型匹配且 when 为真的 catch;第二遍才真正展开栈,依次执行途中各层的 finally,再进入那个 catch。这个模型来自 Windows 的结构化异常处理(Structured Exception Handling,SEH);.NET 9 起运行时换用移植自 NativeAOT 的新实现,但仍是两遍。所以 when 运行时栈还没展开,日志能看到抛出时的完整现场。
抛出要构造异常对象、抓取栈跟踪(stack trace)、把栈走两遍,代价很高;在热点路径上用 Parse 加 catch,异常的开销会远超解析本身,这就是 TryParse 系列存在的原因。using 则是编译器的改写:using (var r = …) { … } 展开成先赋值、再 try { … } finally { if (r != null) r.Dispose(); },源码里没有 finally,生成的 IL 里有。
catch (Exception)接住一切:NullReferenceException这样的 bug 也被当成“配置有问题”报告,真正的缺陷被藏起来,第一遍扫描到这里就停了,上层为它准备的处理器永远等不到它。- 在
catch里用throw e;重抛,栈跟踪从这一行重新开始,原始出错位置丢失;throw;继续原来那次传播,栈跟踪完整。 async void的异常接不到:async Task方法的异常存进返回的Task,await时重新抛出;async void没有Task可存,异常直接抛到同步上下文上,调用方的try/catch无能为力,控制台程序会因此崩溃。
Go 怎么做
Section titled “Go 怎么做”Go 没有异常。错误是普通的返回值,类型是内置接口 error;清理用 defer;真正的 bug 才用 panic。三样都是语言本身提供的,errors 和 fmt 包补上包装和判断的工具。
package main
import ( "errors" "fmt" "io" "os" "path/filepath" "strconv" "strings")
// ConfigError 表示配置文件内容有问题。type ConfigError struct{ Msg string }
func (e *ConfigError) Error() string { return e.Msg }
func loadPort(path string) (int, error) { f, err := os.Open(path) if err != nil { return 0, fmt.Errorf("打开配置:%w", err) // 包住原始错误,调用方仍能用 errors.Is 认出它 } defer f.Close() // 相当于 Python 的 with:函数返回前一定执行
data, err := io.ReadAll(f) if err != nil { return 0, err } raw := strings.TrimSpace(string(data)) port, err := strconv.Atoi(raw) if err != nil { return 0, &ConfigError{"端口不是数字:" + raw} } if port < 1 || port > 65535 { return 0, &ConfigError{fmt.Sprintf("端口超出范围:%d", port)} } return port, nil}
func report(name, path string) { port, err := loadPort(path) var cfgErr *ConfigError switch { case errors.Is(err, os.ErrNotExist): fmt.Printf("%s: 配置文件不存在\n", name) case errors.As(err, &cfgErr): fmt.Printf("%s: %s\n", name, cfgErr.Msg) case err != nil: panic(err) // 没预料到的错误:不吞掉 default: fmt.Printf("%s: 端口 %d\n", name, port) }}
func must(err error) { if err != nil { panic(err) }}
func main() { dir, err := os.MkdirTemp("", "port-") must(err) defer os.RemoveAll(dir) must(os.WriteFile(filepath.Join(dir, "bad.conf"), []byte("abc"), 0o644)) must(os.WriteFile(filepath.Join(dir, "range.conf"), []byte("70000"), 0o644)) must(os.WriteFile(filepath.Join(dir, "ok.conf"), []byte("8080\n"), 0o644)) report("missing", filepath.Join(dir, "missing.conf")) report("bad", filepath.Join(dir, "bad.conf")) report("range", filepath.Join(dir, "range.conf")) report("ok", filepath.Join(dir, "ok.conf"))}
// 输出:// missing: 配置文件不存在// bad: 端口不是数字:abc// range: 端口超出范围:70000// ok: 端口 8080运行:go run port.go。error 接口只有 Error() string 一个方法,*ConfigError 实现了它就是错误。约定是把 error 放在最后一个返回值,成功时为 nil,调用方紧接着写 if err != nil;main 里用小函数 must 把“失败就 panic”收拢起来。
fmt.Errorf("打开配置:%w", err) 在原消息前加上前缀,同时保留原错误,可以用 Unwrap() 取回(Go 1.13 起)。errors.Is 沿 Unwrap 链逐层检查,每层先比 ==、再问它的 Is 方法;本例认出 os.ErrNotExist,靠的是链尾 syscall.Errno 的 Is 方法;errors.As 沿链找第一个类型匹配的错误赋给 cfgErr。os.ErrNotExist 这种专供比较的错误值叫哨兵错误(sentinel error)。defer 的参数在 defer 语句执行时就求值,调用本身等到函数返回前才发生。recover 只在延迟调用的函数里有效,能截住 panic。
底层怎么实现。 错误既然是普通返回值,出错时就没有任何特殊过程:return 0, err 和 return port, nil 一样,只是把两个值放进返回位置,没有表要查,也没有栈要展开。代价是每一层多一个 if,而这个分支本身几乎不花钱。
defer 的模型是每个 goroutine 带一条延迟调用链:执行 defer 语句时,运行时把这次调用(函数和求好的参数)压到链头,函数返回前从链头起依次执行,即后进先出(LIFO)。Go 1.14 起编译器对常见情况做开放编码(open-coded defer):defer 不在循环里、数量不多时,直接把调用内联到每个返回点前,开销接近普通函数调用。panic 才真正展开栈:它逐层执行各层已登记的延迟调用,某个延迟函数调用了 recover,展开就停下;一直没人 recover,程序打印栈跟踪,以退出码 2 结束。
- 写了
if err != nil却忘了return:程序带着零值的port往下走,监听 0 号端口时操作系统会随便分配一个空闲端口,全程不报错。编译器只检查变量有没有被用过,不检查用得对不对。 - 在循环里
defer f.Close():这些调用要等整个函数返回才执行,处理一万个文件就同时开着一万个,可能撞上文件句柄上限,循环里的defer也用不上开放编码。办法是把循环体抽成函数。 defer f.Close()丢掉了Close的返回值。写文件时,有的文件系统要到关闭时才报告写入失败,这个错误被扔掉,函数却返回nil,调用方以为数据已经写好。
| 维度 | Python | JS/TS | C# | Go |
|---|---|---|---|---|
| 支持程度 | 原生 | 原生 | 原生 | 原生 |
| 实现机制 | 栈展开 + 异常表 | 引擎栈展开 | 两遍扫描的结构化异常处理 | 返回值 + defer 栈 |
| 错误的表示 | Exception 子类的实例 |
任何值,通常是 Error 子类 |
Exception 子类的实例 |
实现 error 接口的值 |
| 抛出与捕获 | raise / try … except |
throw / try … catch |
throw / try … catch |
return 0, err / if err != nil |
| 按种类区分 | 多个 except 分支 |
instanceof、e.code |
多个 catch 分支、when 过滤器 |
errors.Is、errors.As |
| 包装与链 | raise … from e |
new Error(msg, { cause: e }) |
InnerException |
fmt.Errorf("…%w", err) |
| 编译器是否强制处理 | 否 | 否 | 否 | 否;没用到的 err 变量会报错 |
| 资源清理 | with |
try … finally |
using |
defer |
| 不可恢复的错误 | 同一套异常,没人接就退出 | 同一套异常,没人接就退出 | 同一套异常,没人接就退出 | panic,可 recover |
| 异步里的错误 | 在 await 处抛出 |
Promise 拒绝,在 await 处抛出 |
存进 Task,在 await 处抛出 |
goroutine 的错误要经 channel 送回 |
差别最大的是“抛出与捕获”一行:前三种语言里错误自己沿栈跳到处理器;Go 里错误由每一层亲手传递。“资源清理”一行四种写法不同,保证的却是同一件事。
异常要解决的是错误码被忽略:C 的返回码不检查也能往下跑,异常则没人接就让程序停下。这个机制早年出现在 PL/I、CLU 等语言里,经 C++ 和 Java 普及,Python、JavaScript、C# 都沿用了它。代价是控制流不可见:读 load_port 时看不出哪一行会让函数中途离开,任何一次调用都可能是出口。所以三种异常语言都必须配一个保证执行的清理机制,否则看不见的出口会漏掉文件和锁。
Java 更进一步,推出受检异常(checked exception):方法必须在签名里声明可能抛出的异常,调用方不处理也不声明就编译不过。实践中底层每多一种异常,整条调用链的签名都要改,很多人干脆写空的 catch 应付编译器,错误反而被吞掉,这一做法被广泛认为得不偿失。Anders Hejlsberg 设计 C# 时明确以版本演进和规模为理由不做受检异常,对比表里 C# 这一格因此是“否”。
Go 的设计者在官方 FAQ 里说,把异常和 try-catch-finally 绑在一起会让代码变绕,还会诱使程序员把打开文件失败这样的普通错误也当成“异常情况”。他们回到返回值,但补上了 C 缺的三样东西:多返回值让错误不必挤占正常结果的位置;defer 让清理不必在每个 return 前重写;%w 和 errors.Is 让错误逐层带上上下文又不丢失身份。Rob Pike 2015 年的文章 “Errors are values” 概括了这种态度:错误就是值,可以像别的值一样存、传、组合。后果是错误路径一目了然,代价是 if err != nil 占去大量行数;简化语法的提案(包括 2019 年的 try 内建函数)都没被采纳,2025 年 Go 团队撰文表示在可预见的将来不再推进这方面的语法改动。
JavaScript 的宽松来自历史:1995 年的第一版没有异常,try/catch/throw 到 1999 年的 ECMAScript 3 才加入,那时语言早已是什么值都能传的动态类型,throw 也就没有限定只能抛 Error。TypeScript 改变不了运行时,只能把 catch 变量定为 unknown。单线程事件循环又带来另一个后果:回调执行时原来的调用者早已返回,错误无法沿栈传回,于是异步错误被装进 Promise 的拒绝,await 时再抛出;C# 的 Task 和 Python 的 asyncio 走的是同一条路。Go 的 goroutine 没有调用者在等,错误只能经 channel 送回,没被 recover 的 panic 则直接结束整个进程。
实现机制决定代价落在哪里。CPython 3.11 以后和 .NET 都让 try 不花钱、抛出很贵。.NET 里方法调用本身很便宜,抛出的相对代价格外显眼,所以标准库为常见失败另配了 TryParse;CPython 解释执行,抛出的相对代价没那么突出,社区又一向推崇 EAFP,标准库就直接用异常表示普通失败:int() 失败抛 ValueError,字典查不到键抛 KeyError。Go 把代价均摊到每一次检查上,出错和不出错几乎一样快。
旁注:别的语言
Section titled “旁注:别的语言”Rust 没有异常。可能失败的函数返回 Result<T, E>,这是一个枚举,要么是 Ok(值),要么是 Err(错误);不处理 Err 就拿不到里面的值,等于由类型系统强制执行 Go 的约定。? 运算符在遇到 Err 时立即把它返回给上一层(必要时转换错误类型),把 Go 的 if err != nil { return err } 压成了一个字符。
fn load_port(path: &str) -> Result<u16, ConfigError> { let raw = std::fs::read_to_string(path)?; // 假设 ConfigError 实现了从 io::Error 的转换 // 范围检查从略:这里 70000 会被当成“不是数字” raw.trim().parse().map_err(|_| ConfigError::NotNumber(raw.clone()))}Java 的受检异常要求方法用 throws 声明可能抛出的异常,编译器强制调用方处理或继续声明,它的后果上一节已经讲过。
- 只接你能处理的错误。Python、C# 里写具体的异常类型(C# 可加
when),TS 里认不出的一律throw e,Go 里认不出的错误包一层再返回;最外层负责打印并退出。 - 错误信息带上下文:至少写出文件名和出问题的值。Python 用
raise … from e,TS 用{ cause: e },C# 把原异常作为innerException参数传给新异常,Go 用fmt.Errorf("读取 %s:%w", path, err)。 - 清理永远交给语言机制:
with、finally、using、defer,不要在每条路径上手写close。提前返回的分支一多,手写的关闭迟早漏掉一处。 - 写库时给调用方可靠的判断依据:在 Python、TS、C# 里定义自己的异常基类,调用方一个
except/catch就能接住这个库的所有错误;在 Go 里导出可比较的哨兵错误(如var ErrEmpty = errors.New("…"))或错误类型,让调用方用errors.Is、errors.As判断,而不是匹配错误消息的字符串。
给本章的程序增加第四种失败:“文件为空”。在临时目录里再造一个内容为空串的 empty.conf,在 missing 之后报告它,输出一行 empty: 配置文件为空,其余四行不变。用 Python 和 Go 各实现一遍。
提示:Python 里它应该是 ConfigError,还是一个新的异常类型?Go 里,如果调用方需要把“文件为空”和“不是数字”区分开,你会用哨兵错误、新的错误类型,还是给 ConfigError 加字段?不加处理的话,空文件现在会被报告成什么?
- 错误处理有两大流派:异常让错误自动沿调用栈向上跳到处理器,中间层看不见;错误值让每一层显式检查和传递。
- 可预期的错误要处理并给出清楚的提示,程序的缺陷应该让程序崩溃;异常语言靠类型区分二者,Go 用
error和panic分开表达。 - 资源清理由语言保证:
with调用__exit__,using编译成try/finally加Dispose(),defer在函数返回前后进先出地执行。 - 实现机制决定代价:CPython 3.11 以后和 .NET 让
try不花钱、抛出很贵,所以 .NET 为常见失败配了TryParse;Go 没有展开过程,开销均摊在每一次检查上。 - 错误要带上下文,而且包装后仍认得出原错误:
raise … from、cause、InnerException、%w配合errors.Is做的都是这件事。