在我写了《为什么系统编程选 Zig 而不是 Rust》之后,下一个显而易见的问题就是 C。Rust 的问题在于安全性,以及我愿意为它接受多少繁琐仪式。C 的问题则不同,因为 Zig 和 C 想要的是同样的东西。两者都是没有垃圾回收器的小型语言,并且都贴近机器的实际行为。所以这篇文章不是在讲离开 C 去用什么完全不同的东西。它讲的是 C 中那些耗费我时间的地方,以及 Zig 如何处理它们。所有代码都在 Mac 上用 Zig 0.17 和 clang 编译过。
C 做得对的地方 链接到标题
C 是 UNIX 的语言。内核、C 库,以及最终被其他所有语言调用的大多数系统库,都是用 C 写的,而 man 手册也用 C 原型和 C 示例来描述它们。一个 1990 年代的 C 程序今天仍然可以编译,而且几乎每一种曾经制造过的 CPU 都有 C 编译器。这门语言小到一本书——Kernighan 和 Ritchie 的《The C Programming Language》——就能覆盖它的全部内容。Zig 本身并不试图取代这些。它直接与 C 库对话,并在需要时使用 C ABI。我写这篇文章不是因为我认为 C 不好;C 是我最喜欢的编程语言!我写它是因为我不再想用 C 写新代码了。
不用 goto 的清理 链接到标题
我最不喜欢的 C 代码,是那种要获取多个资源、并在中途失败时必须按相反顺序释放资源的函数。通常的答案,也是 Linux 内核采用的答案,是 goto:
struct user *user_new(const char *name, size_t n_groups)
{
struct user *u = malloc(sizeof *u);
if (u == NULL)
goto fail;
u->name = strdup(name);
if (u->name == NULL)
goto fail_user;
u->groups = calloc(n_groups, sizeof *u->groups);
if (u->groups == NULL)
goto fail_name;
return u;
fail_name:
free(u->name);
fail_user:
free(u);
fail:
return NULL;
}
它能工作,但清理代码离分配位置很远,而且标签的顺序必须手动与分配的顺序匹配。同一个函数在 Zig 中看起来是这样:
fn newUser(allocator: std.mem.Allocator, name: []const u8, n_groups: usize) !*User {
const u = try allocator.create(User);
errdefer allocator.destroy(u);
u.name = try allocator.dupe(u8, name);
errdefer allocator.free(u.name);
u.groups = try allocator.alloc(u32, n_groups);
return u;
}
每个 errdefer 都紧挨着它所撤销的分配,并且只在函数返回错误时运行。Zig 会按相反顺序运行它们,所以没有需要保持顺序的标签。我更喜欢的是,我可以测试每一条失败路径。std.testing.FailingAllocator 类型会根据 fail_index 的值,在第 1 次、第 2 次或第 3 次分配时失败。它下面的测试分配器会报告任何未释放的内存:
test "newUser does not leak when an allocation fails" {
for (0..3) |i| {
var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = i });
try std.testing.expectError(error.OutOfMemory, newUser(failing.allocator(), "mihalis", 4));
}
}
$ zig test user.zig
1/1 user.test.newUser does not leak when an allocation fails...OK
All 1 tests passed.
在 C 版本中测试同样的三条路径,需要给 malloc() 包一层,或者使用外部工具。
切片知道自己的长度 链接到标题
在 C 中,传给函数的数组会变成指针,长度则单独传递,如果确实传了的话。下面这个 C 函数有一个差一错误,用了 <= 而不是 <:
int sum(const int *a, size_t n)
{
int total = 0;
for (size_t i = 0; i <= n; i++)
total += a[i];
return total;
}
对于一个 {1, 2, 3} 数组,它会在我的 Mac 上打印 sum = 7 并以 0 退出。多出来的 1 来自数组之后内存中的任意内容。无论是带 -Wall -Wextra 的 clang 还是程序本身都不会报错。在 Zig 中,切片携带其长度,所以同样的错误会在调试模式和安全模式下让程序停止:
fn sum(a: []const i32) i32 {
var total: i32 = 0;
for (0..a.len + 1) |i| total += a[i];
return total;
}
$ zig run sum.zig
thread 1278459 panic: index out of bounds: index 3, len 3
sum.zig:5:38: 0x1023c5b4b in sum (sum)
for (0..a.len + 1) |i| total += a[i];
^
在真实的 Zig 代码中,你会写成 for (a) |x|,这个错误根本不可能发生。字符串的工作方式相同。Zig 字符串是一个带长度的字节切片,因此没有会遍历内存寻找 0 字节的 strlen() 调用,也不会因为有人忘了终止符而出现少一个字节的缓冲区。
无法忽略的错误 链接到标题
在 C 中,函数通过返回 -1 或 NULL 并设置 errno、返回错误码,或 man page 描述的其他方式来报告错误。没有任何机制强制你检查。很多 C 代码调用 close() 和 fclose() 时不看返回值,编译器也认为没问题。在 Zig 中,错误是返回类型的一部分,编译器会拒绝编译忽略错误的调用。你必须使用 try、catch 或 if。失败路径就在源代码中,紧挨着调用处,这也是我喜欢 Go 的地方。
用 comptime 代替预处理器 链接到标题
C 预处理器处理文本,对 C 一无所知。经典的 #define MAX(a, b) ((a) > (b) ? (a) : (b)) 会对其一个参数求值两次,而大型 C 项目通常为它支持的每个操作系统准备大量 #ifdef 行。在 Zig 中,编译时运行的代码就是普通的 Zig 代码。我不写 #ifdef __linux__,而是写 if (builtin.os.tag == .linux),编译器会丢弃不适用分支。泛型数据结构是接收一个类型并返回一个类型的函数。编译器会像检查程序其余部分一样检查所有这些。我在 Zig comptime 中写过这一点。
更少未定义的意外 链接到标题
在 C 中,有符号整数溢出是未定义行为。下面的 C 程序给 INT_MAX 加 1:
int add(int a, int b)
{
return a + b;
}
int main(void)
{
printf("%d\n", add(INT_MAX, 1));
return 0;
}
用 clang 编译后,它会打印 -2147483648 并以 0 退出,但 C 标准允许编译器做任何事,包括删除它认为永远不会运行的代码。在 Zig 中,任何整数(有符号或无符号)的溢出都会在调试模式和安全模式下被检查,程序会以 panic: integer overflow 停止,并显示发生位置的行。当我想要回绕时,我写 +%;当我想要结果停在最大值时,我写 +|。
Zig 也是一个 C 编译器 链接到标题
zig cc 命令是一个 C 编译器,可以代替 gcc 或 clang 使用。它还可以开箱即用地交叉编译。在我的 Mac 上,zig cc -target x86_64-linux-musl -o sum sum.c 会创建一个静态 Linux 可执行文件,无需安装其他东西。在调试模式下,zig cc 还会启用未定义行为 sanitizer,所以上面用 zig cc 编译的 add() 程序会以 panic: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int' 停止。build.zig 文件可以在 Zig 文件旁边编译 C 文件,所以你可以一次一个文件地把 C 项目迁移到 Zig,并继续使用相同的构建。如果你的 Zig 代码需要 C 头文件,Zig 0.17 移除了 @cImport(),你要改用 zig translate-c。我在 @cImport() Is gone in Zig 0.17 中写过这一点。
诚实的取舍 链接到标题
Zig 并不是把所有问题都解决了的 C。它不会阻止你在释放内存之后继续使用它,也不会阻止你保留一个指向已不存在的栈变量的指针。在 fast 和 small 模式下,大部分安全检查会被关闭,你又回到了未定义行为,和 C 里一样。C 还具有某种 Zig 目前尚不具备的稳定性。Zig 0.17 发布时,我书里 152 个 Zig 文件中有 35 个不得不修改,而几十年前的 C 代码仍然能编译。C 有更多的工具、更多的书、更多懂它的人,以及针对 Zig 尚不支持芯片的编译器。如果你为只有厂商 C 编译器的微控制器写代码,选择已经替你做好了。
结论 链接到标题
C 不会消失,我仍然大量阅读 C 代码,因为 UNIX 源代码和大多数系统库都是用 C 写的,它们的 man page 用 C 原型来记录它们。但对于新代码,我选择 Zig。它让我写出和 C 同一类代码,贴近机器且没有运行时,同时编译器和调试模式能抓住许多 C 会一声不吭放过的错误。
用 Zig 编程愉快!
想学 Zig?看看我的书 Systems Programming with Zig。