GOTO Considered Totally Sick (for error handling in C)
本文为原文前 6,000 字符的节选翻译,完整内容请查看原文。
GOTO Considered Totally Sick (for error handling in C)
GOTO Considered Totally Sick (for error handling in C) Oct. 25th, 2026 I recently started writing C again for the first time since 2018, and handling errors has been an especially difficult adjustment coming from languages like Go and Zig that coddle me with their wonderful defer and errdefer. Then I read some emails from John Carmack on inlined code that gave me an epiphany on writing cleaner error handling code, and now I want to share it with the world.
《GOTO 被认为非常糟糕(用于 C 语言的错误处理)》2026 年 10 月 25 日。我最近自 2018 年以来首次重新开始编写 C 语言代码,由于习惯了 Go 和 Zig 这种用美妙的 defer 和 errdefer 来“宠溺”我的语言,处理错误成了一个特别困难的适应过程。后来,我读到了一些 John Carmack 关于内联代码的邮件,这让我对编写更简洁的错误处理代码有了顿悟,现在我想与全世界分享它。
But First Let’s Defer I write Go all day at my dayjob, and error handling is pretty trivial. When there’s an error we wrap it and bubble it up, and let some top dog up the call stack figure out what they want to do with it. If there is resource cleanup involved, it’s made easy with defer:
首先,让我们谈谈 Defer。我在日常工作中整天都在写 Go,错误处理非常简单。当出现错误时,我们将其包装并向上冒泡,让调用栈上层的某个“大佬”决定如何处理它。如果涉及资源清理,defer 让这一切变得简单:
type Doer interface {
MightError(int) error
Lock()
Unlock()
}
func SomeFunc(d Doer) error {
d.Lock()
defer d.Unlock()
if err := d.MightError(3); err != nil {
return errors.Wrap(err, "Doer.MightError()")
}
// the rest of this very important function has been hidden from you for esoteric reasons
}
Sometimes there’s more complicated handling, but in 99% of scenarios it’s that simple because Go doesn’t have manual memory allocation and you don’t need to worry about cleaning it up when there’s an error.
有时会有更复杂的处理,但在 99% 的情况下就是这么简单,因为 Go 没有手动内存分配,你不需要担心在出错时清理内存。
Zig on the other hand does require you to manually manage memory, which I find a little more difficult because I’m not experienced enough for it to be second nature like it is when I write Go. Here’s a snippet from my toy multiplexer that uses errdefer:
另一方面,Zig 确实要求你手动管理内存,我觉得这有点困难,因为我还没有足够的经验让它像写 Go 时那样成为第二天性。这是我的玩具多路复用器中使用 errdefer 的代码片段:
fn init(allocator: std.mem.Allocator, env_map: *std.process.Environ.Map, cfg: Config) !Model {
const sh = env_map.get("SHELL") orelse "/bin/sh";
const shell_z = try std.fmt.allocPrintSentinel(allocator, "{s}", .{sh}, 0);
errdefer allocator.free(shell_z);
const posix_block = try env_map.createPosixBlock(allocator, .{});
errdefer posix_block.deinit(allocator);
const tabs: []Tab = try allocator.alloc(Tab, cfg.commands.len);
errdefer allocator.free(tabs);
const pipes: []Pipe = try allocator.alloc(Pipe, cfg.commands.len);
errdefer allocator.free(pipes);
// all done with allocations (and errors)
errdefer comptime unreachable;
// the rest of this function has been stolen by bilgerats
}
I’m allocating memory for several types, and because any one of the allocations can fail I need to handle freeing up each succesful memory allocation that preceded the failure. Zig’s errdefer makes this easy, there’s no manual tracking of what needs to be freed at any point, because the deferred statements will execute in reverse order if the function returns an error. Also I’m following the Reserve First pattern, which is where I learned errdefer comptime unreachable from.
我正在为几种类型分配内存,由于任何一次分配都可能失败,我需要处理释放失败前每一个成功的内存分配。Zig 的 errdefer 让这一切变得简单,不需要在任何时候手动跟踪需要释放的内容,因为如果函数返回错误,延迟语句将以相反的顺序执行。此外,我遵循了“先预留(Reserve First)”模式,这也是我从中学到 errdefer comptime unreachable 的地方。
This is the C Part Now, enter C. There’s no defer or errdefer. I’ve painted myself into a corner with some ugly code a few times because I’m so used to those tools that I don’t regularly plan ahead for what my error handling control flow will look like. Until recently I’ve been writing code that looks like this:
这是 C 语言的部分。现在,进入 C 语言的世界。这里没有 defer 或 errdefer。我曾几次因为太习惯那些工具而没有预先规划错误处理的控制流,导致自己陷入了编写丑陋代码的困境。直到最近,我还在写这样的代码:
struct S { int foo; };
S** some_func(const char** paths, const size_t n) {
struct S** s = calloc(n, sizeof(S*));
if (s == NULL) { return NULL; }
for (int i = 0; i < n; i++) {
s[i] = malloc(sizeof(S));
if (s[i] == NULL) {
for (int j = 0; j < i; j++) { free(s[j]); }
free(s);
return NULL;
}
int fd = open(paths[i], O_RDONLY);
if (fd == -1) {
for (int j = 0; j <= i; j++) { // note the <= instead of <
free(s[j]);
}
free(s);
return NULL;
}
struct stat buffer;
if (fstat(fd, &buffer) == -1) {
for (int j = 0; j <= i; j++) {
free(s[j]);
}
free(s);
close(fd);
return NULL;
}
*(s[i]) = (struct S){ .foo = buffer.st_size };
close(fd);
}
return s;
}
Do you see how resource cleanup is slightly different across each error handling if-block? Yuck! Yeah yeah yeah, I know it’s a terrible, contrived example, and nobody would ever write C like this. Except me like 3 weeks ago. I knew something was wrong when I wrote code like this, but I was hopelessly defer-brained in a world where defer hadn’t been born yet. Some classmates had mentioned using goto but I couldn’t really wrap my head around it. (We can’t share code with eachother otherwise the university would have us killed).
你看到每个错误处理 if 块中的资源清理方式有何不同了吗?真恶心!是的,我知道这是一个糟糕且刻意的例子,没人会这样写 C 代码。除了 3 周前的我。当我写出这样的代码时,我知道有些不对劲,但在一个 defer 尚未诞生的世界里,我无可救药地沉迷于 defer 的思维模式。一些同学提到过使用 goto,但我真的无法理解它。(我们不能互相分享代码,否则大学会杀了我们)。
Then when I read those John Carmack emails something clicked. Carmack doesn’t mention goto explicitly, but these two thoughts in particular were the special sauce I needed to help me understand (emphasis mine):
然后,当我读到那些 John Carmack 的邮件时,我突然开窍了。Carmack 没有明确提到 goto,但这两个想法特别是我理解所需的“秘方”(重点是我加的):
A quote from Henry Spencer describing control flow in some flight software: It disallowed both subroutine calls and backward branches, except for the one at the bottom of the main loop. Control flow went forward only. Sometimes one piece of code had to leave a note for a later piece telling it what to do, but this worked out well for testing: all data was allocated statically, and monitoring those variables gave a clear picture of most everything the software was doing.
引用 Henry Spencer 描述某些飞行软件控制流的话:它既不允许子程序调用,也不允许向后跳转,除了主循环底部的那个。控制流只能向前。有时一段代码必须给后续代码留下一张便条告诉它该做什么,但这在测试中效果很好:所有数据都是静态分配的,监控这些变量可以清楚地了解软件正在执行的大部分操作。
And Carmack on moving code out of large conditional blocks (emphasis mine): Enclosing pages of code inside a conditional or loop statement does have readability and awareness drawbacks, so leaving that code in a separate function may still be justified, but in some cases it is still possible to move the code to another place where its execution wouldn’t be conditional, or just execute it at all times and inhibit the results in some way with a very small conditional block.
以及 Carmack 关于将代码移出大型条件块的观点(重点是我加的):将大量代码封装在条件语句或循环语句中确实存在可读性和认知上的缺陷,因此将这些代码留在单独的函数中可能是合理的,但在某些情况下,仍然可以将代码移动到执行不再受条件限制的地方,或者始终执行它,并以某种方式通过一个非常小的条件块来抑制结果。
Ohmygodigetitnow. Instead of having multiple exit points scattered throughout my function, each performing their own resource cleanup, I can put all resource cleanup behind a single exit point at the very end of the function. Guarding cleanup behind the default return, only reachable using a goto, is kinda like a psuedo-errdefer. Hooray! Hooray for John Carmack and Henry Spencer!! Now let’s rewrite the function above using this brand new discovery:
天哪,我终于明白了。与其在函数中分散多个退出点,每个点都执行自己的资源清理,不如将所有资源清理放在函数末尾的单个退出点之后。将清理工作置于默认返回之后,且仅能通过 goto 到达,这有点像伪 errdefer。万岁!为 John Carmack 和 Henry Spencer 万岁!!现在让我们用这个新发现重写上面的函数:
struct S { int foo; };
S** some_func(const char** paths, const size_t n) {
struct S** s = calloc(n, sizeof(S*));
if (s == NULL) { return NULL; }
for (int i = 0; i < n; i++) { s[i] = NULL; }
for (int i = 0; i < n; i++) {
int fd = open(pa