是时候了:defer技术规范

logo img The Pasture

是时候了:defer 技术规范

2025 年 3 月 15 日 在 2025 年 2 月于奥地利格拉茨举行的 WG14 会议之后,我现在对 defer TS 的最终状态充满信心,现在是时候了。

… 是时候做什么了?

现在是我写这篇博文的时候了,让每个人都为实施 defer 的热潮做好准备,以使 defer 在 C 编程语言中取得成功。如果你像编写 GCC 补丁的 Navi、实现早期规范中的 defer 并发现它既简单又有帮助的 slimcc 的维护者,以及其他一些非常酷和伟大的人一样聪明而时髦,你可以跳到 (DRAFT) ISO/DIS 25755 - defer Technical Specification 并开始!但是,对于其他人……

什么是 defer

对于有大智慧的人,从 10,000 米的高度来看,defer ⸺ 以及即将到来的 TS 25755 ⸺ 是一种 通用 的基于块/作用域的“撤销”机制,允许你确保无论发生什么,都会运行一组行为(语句)。虽然除了本文将讨论的内容之外还有很多很多用途,但 defer 通常用于涵盖以下情况:

以及更多更多。对于 C++ 人来说,他们会说“等一下,这听起来像析构函数!”,只需 跳到下面 阅读关于 C++ 部分的内容,同时忽略所有关于 defer、WG14、投票和共识等等的内容。

对于其他人,我们将使用一系列 printf 来构造(或未能构造)一个短语来介绍一些非常简单的 defer 示例,只是为了了解它是如何工作的。这是一个基本示例,展示了它的一些核心属性:

#include <stdio.h>
int main () {
	const char* s = "this is not going to appear because it's going to be reassigned";
	defer printf(" bark!\"");
	defer printf("%s", s);
	defer {
		defer printf(" woof");
		printf(" says");
	}
	printf("\"dog");
	s = " woof";
	return 0;
}

此程序的输出如下:

$> ./a.out
"dog says woof woof bark!"

以下原则变得显而易见:

这构成了 defer 功能的核心,也是我们构建、比较和评估这一新功能的基础。

“构建?” 等等……你只是从头开始完全捏造的吗?

谢天谢地,不是。这是一种长期以来通过各种方式实现的特性,例如:

它背后有很多工作和理解,以及大量的现有实践。它的变体存在于 Apple 的 MacOS SDK、Swift 的 C 部分、Linux Kernel、GTK 的 g_autoptr(和 qemu 的 Lockable)等等。它还在许多其他语言中以完全相同的格式出现,包括 C++(使用 RAII)、Zig(使用 defer)和 Swift(也使用 defer,但也使用 guard 功能)。这当然引出了一个问题:如果它在各种风格中都有如此多的现有实现,并且有如此多年的经验,为什么它会进入技术规范(或简称“TS”),而不是直接进入 C 标准?嗯,老实说,有两个原因。

第一个原因是,供应商声称他们可以将其放入 C 中 ⸺ 并使其在全球范围内可用 ⸺ 比放入 C 工作草案中更快。就我个人而言,我不确定我是否相信这里的供应商;他们已经将许多特性放入 C 中,甚至将 C 的后期版本移植到 C 的早期版本中。但是,我真的不觉得我想与供应商争论一个无聊的特性,这个特性存在于 C 编译器中几乎与我活着的时间一样长,所以我只是相信他们的话。

第二个,更不幸的原因是,defer 是在我拿到它之前被提出的。它的状态不好,没有准备好进行标准化,关于 defer 应该是什么的想法有点混乱。这是公平的,因为许多最初的论文都是探索性的:问题是,当我们必须发布 C23 版本时,对新特性存在(轻微的)恐慌,并且投入了大量精力试图将 defer 简化为可以使用的东西。从以前不基于现有实践的优柔寡断的状态转变为具体的东西导致委员会拒绝了这个想法,并声明如果它回来,它应该作为 TS 回来。

我可以争辩说这是不公平的,因为那次投票是基于较旧的、尚未准备好且受到 C23 压力影响的论文版本。较旧的论文讨论了各种想法,例如是否在 defer 语句处按值捕获变量(灾难性的),或者 defer 是否应该像 Go 一样附加到更高的作用域/函数作用域(也是灾难性的),以及编写 for 循环是否会累积(可能无限)的额外空间和分配,以存储在作用域结束时运行所需的变量和其他数据(哎呀!)。这些恶作剧都不再适用,但我们仍然必须进入 TS,即使它是现有实践的工作方式的镜像(事实上,不如 现有实践强大)。最近,我们对它应该进入 TS 还是直接进入 IS(国际标准;基本上是工作草案)进行了新的投票。两者都有支持和共识,但对 TS 的共识_更多_。

不过,这真的不值得争论,所以进入 defer TS。

我唯一的担心是,Microsoft 会像往常一样,忽略其他人所做的事情,并且只使用 defer TS 而不取得任何进展。(就像他们对大多数 GNU 或 Clang 或非 Microsoft 扩展、一些技术报告和一些 TS 所做的那样。)因此,我们将获得经验的唯一地方是已经非常依赖编译器特性存在的地方。但是,我非常愿意感到惊喜。它可以由用户要求 Microsoft 通过他们的 User Voice/Feature Request 提交门户来使他们的一些 C 代码更安全来驱动。但是,自从有时间以来,来自 Microsoft 的消息始终是“只需编写 C++”,所以我可以想象我们也会在这里收到相同的消息,并且必须等到 defer 达到 C 标准,他们才能实现它。

尽管如此,这个 TS 对我来说会很有趣。我还有其他几个应该通过 TS 流程的想法;如果我能够观察到,在接下来的几年里,供应商们并不诚实地表示他们可以在编译器中多么快速地实现 defer ⸺ 如果他们_只有_一个 TS 来证明这一点!⸺ 这将强烈影响我对是否任何未来的改进都应该使用 TS 流程的看法。

所以我们会看到的!不过,与此同时,让我们来谈谈 defer 与其他语言中与其同名的前辈有何不同。

基于作用域

defer 的核心思想是,与 Go 的对应物不同,C 中的 defer 在词法上绑定,或者只是“翻译时”或“静态作用域”。这意味着 defer 根据其在程序中的词法位置,在块或其绑定到的作用域结束时无条件地运行。这使其具有明确的、确定性的行为,不需要额外的存储、不需要控制流跟踪、不需要巧妙的优化来减少内存占用,也不需要超出典型变量自动存储持续时间(即,normal-ass 变量)生命周期跟踪通常情况下的额外编译器基础设施。这是一个使用 mtx_t 的小例子:

#include <threads.h>
extern int do_sync_work(int id, mtx_t* m);
int main () {
	mtx_t m = {};
	if (mtx_init(&m, mtx_plain) != thrd_success) {
		return 1;
	}
	// we have successful initialization: destroy this when we're done
	defer mtx_destroy(&m);
	for (int i = 0; i < 12; ++i) {
		if (mtx_lock(&m) != thrd_success) {
			// return exits both the loop and the main() function,
			// defer block called:
			// - mtx_destroy
			return 1;
		}
		// now that we have succesfully init & locked,
		// make sure unlock is called whenever we leave
		defer mtx_unlock(&m);
		// …
		// do a bunch of stuff!
		// …
		if (do_sync_work(i, &m) == 0) {
			// something went wrong: get out of there!
			// return exits both the loop and the main() function,
			// defer blocks called:
			// - mtx_unlock
			// - mtx_destroy
			return 1;
		}
		
		// re-does the loop, and thus:
		// defer block called:
		// - mtx_unlock
	}
	// defer block called:
	// - mtx_destroy
	return 0;
}

上面评论注释中的关键要点是:无论你是从 for 循环的第 6 次迭代中提前 return,还是由于循环之后的某个时候的错误代码而提前退出:

值得注意的是,只有当执行仍在 for 循环内部时,并且只有在 defer 传递之后从该特定作用域退出时,才会发生 mtx_unlock 调用。这与 Go 有一个重要的区别,在 Go 中,每个 defer 实际上都从其当前上下文中“提升”并附加到在它周围的_函数本身_的末尾运行。这倾向于将其视为“在函数退出之前关于某些错误条件的最后一分钟检查”,但它对简单的代码产生了一些破坏性的后果。例如,从上面的代码中取出以下代码,稍微简化和修改,使其成为一个看起来很正常的 Go 程序:

package main
import (
	"fmt"
	"sync"
)
var x = 0
func work(wg *sync.WaitGroup, m *sync.Mutex) {
	defer wg.Done()	
	for i := 0; i < 42; i++ {
		m.Lock()
		defer m.Unlock()
		x = x + 1
	}
}

func main() {
	var w sync.WaitGroup
	var m sync.Mutex
	for i := 0; i < 20; i++ {
		w.Add(1)
		go work(&w, &m)
	}
	w.Wait()
	fmt.Println("final value of x", x)
}

这个程序在 Godbolt 上的输出是:

Killed - processing time exceeded
Program terminated with signal: SIGKILL
Compiler returned: 143

是的,没错:它永远不会完成运行。这是因为这段代码死锁了:defer 调用被提升到 func workfor 循环的外部。这意味着它调用 m.Lock(),执行递增,循环,然后尝试再次调用 m.Lock()。这是一个经典的死锁情况,并且经常发生在大多数 Go 用户身上,他们不得不添加一个小小的警告。“使用立即调用的函数来限制 defer 的范围”就是其中一个快速的警告:

package main
import (
	"fmt"
	"sync"
)
var x = 0
func work(wg *sync.WaitGroup, m *sync.Mutex) {
	defer wg.Done()	
	for i := 0; i < 42; i++ {
		func() {
			m.Lock()
			defer m.Unlock()
			x = x + 1
		}()
	}
}

func main() {
	var w sync.WaitGroup
	var m sync.Mutex
	for i := 0; i < 20; i++ {
		w.Add(1)
		go work(&w, &m)
	}
	w.Wait()
	fmt.Println("final value of x", x)
}

这个 运行而不会锁定 Godbolt 的资源,直到 SIGKILL。当然,这是一种病态行为;虽然它对于简单的、直接的用例(“捕获错误并对其采取行动”)效果很好,但不幸的是,它会导致其他有问题的行为。这就是为什么 defer TS 中的版本不会强烈依赖于函数定义的范围(或立即调用的 lambda),而是直接依赖于最里面的块及其关联的作用域。这也突出了 defer 的另一个重要特性,当我们在使用像 C 这样的语言时需要它(并且也适用于 Zig 和 Swift)。

直接引用变量

也称为“按引用捕获”,defer 块直接引用其作用域中的变量(例如,好像 defer 捕获了指向作用域中所有内容的指针,然后自动解引用这些指针,以便你可以直接将先前的 foo 称为 foo)。人们有时对此感到困惑,但出于安全性和可用性的考虑,这个选择非常明显。回顾上面的例子,如果 defer 块复制 m 值,以便 lock/unlock 配对的调用实际上作用于不同的实体,那么就会出现严重的问题。这将是一种不同类型的混乱,甚至 Go 都没有尝试过,任何语言都不应该尝试。

当你有像 defer 这样的内联的、基于作用域的、编译时特性,它不创建“对象”并且不能“移动”到不同的作用域时,直接按引用捕获是可以的。直接引用变量是完全可以的。你不需要小心并担心捕获,或者通过复制来抢先小心以“安全”。与 RAII 对象不同,defer 哪里也去不了。你不需要明确它如何访问本地作用域中的事物,因为 defer 不能_离开_该作用域。这也是不遵循 Go 的脚步的次要结果;通过不将其限定到函数,不必担心,例如,for 循环或 if 语句中的 C 风格的自动存储持续时间变量是否需要“生命周期扩展”到整个函数的作用域。

直接变量引用和保持事物基于作用域意味着 defer 不需要“存储”其执行直到函数结束,也不需要记录谓词或跟踪分支来知道某些任意外部作用域结束时采取哪个 defer。事实上,对于任何 defer 块,defer TS 的行为模型几乎都是获取 defer 块内的所有代码,并将其转储到该作用域的每个翻译时(编译时)出口。这适用于提前 returnbreaking/continueing 出循环作用域,以及 gotoing 到一个标签。

哦,甚至是 goto

一般来说,禁止 goto 跳过 defer 或跳入 defer 中的语句序列。它可以跳回到该作用域中 defer 之前。尝试使用 switchbreak/continue(带或不带标签)和其他东西也是如此。这里有一些如果你尝试它不会编译的例子:

#include <stdlib.h>
int main () {
	void* p = malloc(1);
	switch (1) {
		defer free(p); // No.
	default:
		defer free(p); // fine
		break;
	}
	return 0;
}

int main () {
	switch (1) {
	default:
		defer {
			break; // No.
		}
	}
	for (;;) {
		defer {
			break; // No.
		}
	}
	for (;;) {
		defer {
			continue; // No.
		}
	}
	return 0;
}

同样重要的是要注意,在执行方面未到达的 defer 不会影响它们之前的事物。也就是说,这仍然是一个漏洞:

#include <stdlib.h>
int main () {
	void* p = malloc(1);
	return 0; // scope is exited here, `defer` is unreachable
	defer free(p); // p is leaked!!
}

与禁止 breakgotocontinue 类似,return 也不能退出 defer 块:

int main () {
	defer { return 24; } // No.
	return 5;
}

不过,如果你是 __attribute__((cleanup(...)))__try/__finally 的忠实用户,你会发现这些限制实际上比今天镜像的现有实践所允许的更严格。

等等……现有实践现在可以做什么?

前面部分中写到的禁令有点偏离了现有实践。__attribute__((cleanup(...)))__try/__finally ⸺ 分别存在于 GCC/Clang/tcc/etc. 和 MSVC 中的原始版本 ⸺ 允许一些(诅咒的)使用 goto,抢先 return,以及更多在那些特定于实现的 defer 类型中。

一个 MSVC 例子 (使用 Godbolt):

int main () {
	__try {
		return 1;
	}
	__finally {
		return 5;
	}
	// main returns 5 ⸺ can stack this infinitely
}

一个 GCC 例子 (使用 Godbolt):

#include <stdio.h>
#include <stdlib.h>
int main () {
	__label__ loop_endlessly_and_crash;
	loop_endlessly_and_crash:;
	void horrible_crimes(void* pp) {
		void* p = *(void**)pp;
		printf("before goto...\n");
		goto loop_endlessly_and_crash; // this program never exits successfully or frees memory
		printf("after goto...\n");
		printf("deallocating...\n");
		free(p);
	}
	[[gnu::cleanup(horrible_crimes)]] void* p = malloc(1);
	printf("allocated...\n");
	printf("before label...\n");
	printf("after label...\n");
	return 0;
}

绝大多数人 ⸺ 委员会内外的人 ⸺ 都同意,允许在 defer 中直接这样做是邪恶的。我也个人认为我不喜欢它,尽管我实际上可以接受将来放宽约束,因为即使我不喜欢我从这里看到的东西,我仍然可以为“goto 离开 defer 块”或“returndefer 块中调用”写出有形的、可理解的、明确定义的行为。但是,我不会改变的是“goto 进入 defer 块”(goto 将执行带到哪个作用域的出口?),或者跳过给定作用域中的 defer 语句:没有明确的、明确的、定义良好的行为,并且随着额外的控制流,情况只会变得更糟。

但是,即使你无法从 TS 的延迟块返回,你仍然必须意识到 defer 何时以及如何实际运行与 return 语句或类似作用域转义中包含的实际表达式的关系。

defer 时序

匹配现有实践以及 C++ 析构函数,defer 在函数实际返回之前运行,但在返回值的计算_之后_运行。在像这样的语言中,这在简单的程序中是不可观察的。但是,在复杂的程序中,这_绝对_重要。例如,考虑以下代码:

#include <stddef.h>
extern int important_func_needs_buffer(size_t sz, void* p);
extern int* get_important_buffer(int* p_err, size_t* p_size, int val);
extern void drop_important_buffer(int val, size_t size);
int f (int val) {
	int err = 0;
	size_t size = 0;
	int* p = get_important_buffer(&err, &size, val);
	if (p == nullptr || err != 0) {
		return err;
	}
	defer {
		drop_important_buffer(val, size);
	}
	return important_func_needs_buffer(sizeof(*p) * size, p);
}
int main () {
	if (f(42) == 0) {
		printf("bro definitely cooked. peak.");
		return 0;
	}
	printf("what was bro cooking???");
	return 1;
}

有两种时间可以运行 defer 块及其 drop_important_buffer(...) 调用。

问题立即变得显而易见,这里:如果 deferreturn 语句中的表达式之前运行(在 important_func_needs_buffer(...) 之前),那么你实际上是在函数有机会使用它之前丢弃缓冲区。这是一张单程票,通往使用后释放或其他极其安全的反面恶作剧。因此,唯一逻辑和可行的选择是运行第二个选项,即 defer 块在评估 return 表达式之后运行,但在我们离开函数本身之前运行。

这确实使一些人感到沮丧,他们希望使用 defer 作为最后一分钟的“return 值更改”,如下所示:

int main (int argc, char* argv[]) {
	int val = 0;
	int* p_val = &val;
	defer {
		if ((argc % 2) == 0) {
			*p_val = 30;
		}
	}
	return val; // returns 0, not 30, even if argc is e.g. 2
}

但我更重视与现有实践(__try/__finally__attribute__((cleanup(...)))))、与 C++ 析构函数的兼容性,以及避免绝对的安全噩梦的兼容性。如果有人想要评估 return 表达式但仍然修改该值,他们可以编写一篇论文或向他们希望 defer { if (whatever) { return ...; } } 成为现实的实现提交反馈。这样,这种行为就被形式化了。而且,再说一遍,即使我个人不想编写像这样的代码或看到像这样的代码,对于在 defer 中评估 return 时会发生什么,仍然存在可检测的、有形的、完全定义良好的行为。这也不像,例如,Go 的 defer 那么复杂,因为 defer TS 使用了翻译时作用域的 defer

它不会导致“动态确定和执行的 defer 导致远距离的诡异行为”。仍然需要小心嵌套的 defer 覆盖返回值,或者后续的 defer 尝试更改返回值。(人们还必须争辩说,每个 defer 嵌套的 return 都需要评估其表达式,并且可能会被丢弃,除非进行优化以阻止它。)尽管需要回答所有这些问题,但它仍然很糟糕,我很高兴我们不必通过 defer 语句中的 return(或 gotobreakcontinue)。

… 那么编译时之外的控制流呢?

[](https://thephd.dev/<#run-time-style