开发者撰文比较 Zig 与 C 在系统编程中的取舍
来源标题:Why Zig over C for Systems Programming
一位开发者发文解释为何在系统编程中更倾向 Zig 而非 C,重点在于 C 中耗费时间的部分以及 Zig 的应对方式。作者指出 Zig 与 C 目标相同,都是无 GC、贴近机器的小型语言,Zig 可直接调用 C 库并使用 C ABI。文中代码在 Mac 上以 Zig 0.17 和 clang 编译。
以下正文由 AI 翻译自 Hacker News 最新(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。
写完《Why Zig Over Rust for Systems Programming》之后,下一个显而易见的问题就是 C。Rust 那个问题关乎安全性,以及为了安全性我愿意接受多少繁琐的仪式。C 的问题不一样,因为 Zig 和 C 想要的东西是一样的。两者都是没有垃圾回收器的小语言,都贴近机器的实际行为。所以这篇文章不是讲为了某个完全不同的东西而离开 C,而是讲 C 中那些让我耗费时间的部分,以及 Zig 如何处理它们。所有代码都在 Mac 上用 Zig 0.17 和 clang 编译过。
C 是 UNIX 的语言。内核、C 库,以及其他所有语言最终调用的系统库,大多都是用 C 写的,man page 也用 C 原型和 C 示例来描述它们。一个 1990 年代的 C 程序今天仍然能编译,而且几乎每一款曾经制造过的 CPU 都有 C 编译器。这门语言小到一本《The C Programming Language》(Kernighan 和 Ritchie 著)就能覆盖全部内容。Zig 本身并不试图取代这一切。它直接与 C 库对话,需要时也使用 C ABI。我写这篇文章不是因为我觉得 C 不好;C 是我最喜欢的编程语言!我写它是因为我不想再用 C 写新代码了。
我最不喜欢的 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 的值,在第一次、第二次或第三次分配时失败。它下面的测试分配器会报告任何未被释放的内存:
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 里,slice 自带长度,所以同样的错误在 debug 和安全模式下会让程序停止:
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 字符串是带长度的字节 slice,所以没有 strlen() 调用去遍历内存寻找 0 字节,也不会因为有人忘了终止符而出现短一个字节的缓冲区。
在 C 里,函数通过返回 -1 或 NULL 并设置 errno 来报告错误,或者返回错误码,或者采用 man page 描述的其他方式。没有任何东西强制你检查。很多 C 代码调用 close() 和 fclose() 时不看返回值,编译器也毫无意见。在 Zig 里,错误是返回类型的一部分,编译器会拒绝编译忽略错误的调用。你必须使用 try、catch 或 if。失败路径就在源码里,紧挨着调用处,这也正是我喜欢 Go 的地方。
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;
(原文较长,此处译出前 6000 字,完整内容请点「打开原文」。)
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。