技术作者撰文对比 Zig 与 C 在系统编程中的差异
来源标题:Why Zig over C for Systems Programming
一位技术作者在发表《为何系统编程选择 Zig 而非 Rust》后,又撰文比较 Zig 与 C。他指出两者都是无 GC 的小型语言、贴近底层,Zig 并不试图取代 C,而是直接调用 C 库并沿用 C ABI,文章聚焦 C 中耗费时间的部分以及 Zig 的应对方式。文中代码基于 Mac 上的 Zig 0.17 与 clang 编译。
以下正文由 AI 翻译自 Hacker News(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。
我写完《为什么在系统编程中选择 Zig 而不是 Rust》之后,接下来显而易见的问题就是 C。Rust 那个问题关乎安全性,以及为了安全我愿意接受多少繁琐的形式。C 的问题则不同,因为 Zig 和 C 想要的东西是一样的。两者都是没有垃圾回收器的小型语言,都贴近机器的实际行为。所以这篇文章讲的不是要离开 C 去拥抱某种完全不同的东西,而是 C 中那些耗费我时间的部分,以及 Zig 如何处理它们。所有代码都在 Mac 上用 Zig 0.17 和 clang 编译过。
C 是 UNIX 的语言。内核、C 库,以及每一种其他语言最终都要调用的绝大多数系统库,都是用 C 写的,而 man 手册用 C 原型和 C 示例来描述它们。一段 1990 年代的 C 程序今天依然能编译,而且几乎每一款曾经制造过的 CPU 都有对应的 C 编译器。这门语言小到一本书——Kernighan 和 Ritchie 的《The C Programming Language》——就能把它讲全。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; 这能work,但清理代码离分配点很远,而且标签的顺序必须手动与分配的顺序保持一致。同一个函数在 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 手册描述的其他方式。没有任何机制强制你检查。很多 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 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。