突破痛点!Zig 为 C 语言提供绝佳解决方案

张开发
2026/4/18 10:33:54 15 分钟阅读

分享文章

突破痛点!Zig 为 C 语言提供绝佳解决方案
C 是一门底层的系统编程语言几乎没有对内存的抽象所以内存管理全靠你自己对汇编的抽象也很少但足以支持一些如类型系统等通用概念。它也是一门适应性非常强的编程语言。如果编写得当即使你的厨房烤箱具有一些奇特的架构它也可以在上面运行。C 语言的设计特点使其非常适合用于底层系统编程。然而这并不意味着其设计决策在今天的标准下无懈可击。在这篇博客中我们将讨论一些 C 语言存在的问题。这些问题导致人们多次试图创建用于取代 C 语言的备选语言。Zig 编程语言作为一种新的系统编程语言定位自身为改进版的 C 语言引起了相当多的关注。Zig 是如何实现这一目标的呢在这篇博客中我们的目标是研究一些与 C 语言相关的问题并探讨 Zig 是如何解决这些问题的。差异对比表Comptime 取代文本替换预处理使用预处理器在源代码中替换文本并非C 语言特有。这在 C 语言诞生之前就已经存在早在 IBM 704 计算机的 SAP 汇编器中就有类似的例子。以下是一个 AMD64 汇编片段的例子它定义了一个 pushr 宏并根据其参数将其替换为 push 或 pushfC 语言作为汇编的最小抽象采用了同样的方法来支持宏但这很容易引发问题。以下是一个小例子%macro pushr 1%ifidn %1, rflagspushf%elsepush %1%endif%endmacro%define regname rcxpushr raxpushr rflagspushr regname可能会期望此代码将 result 的值设置为 (2 3)^2 25。然而由于 SQUARE 宏函数的文本替换特性展开结果为 2 3 * 2 3计算结果为 11而不是 25。要使这段代码能正常至关重要的是确保所有的宏都正确地加上了括号#define SQUARE(x) x * xint result SQUARE(2 3)C 语言不会容忍这种错误也不会友善地提醒你这些错误。错误仍然可能会在程序的其他位置甚至是在后续的输入中出现。然而Zig 采用了一种更直观的方法来处理此类任务引入了 comptime 参数和函数。这使我们能够在编译时执行函数而不是运行时。以下是 Zig 中的 C SQUARE 宏fn square(x: anytype) TypeOf(x) {return x * x;}const result comptime square(2 3); // result 25, at compile-timeZig 编译器的另一个优点是能对输入进行类型检查即使它是 anytype。当在 Zig 中调用 square 函数时如果使用了不支持 * 操作符的类型将导致编译时类型错误const result comptime square(hello); // compile time error: type mismatchComptime 允许在编译时执行任意代码const std import(std);fn fibonacci(index: u32) u32 {if (index 2) return index;return fibonacci(index - 1) fibonacci(index - 2);}pub fn main void {const foo comptime fibonacci(7);std.debug.print({}, .{ foo });}这个 Zig 程序定义了一个 fibonacci 函数然后在编译时调用该函数设置 foo 的值。在运行时没有调用 fibonacci。Zig 的 comptime 计算也可以覆盖一些小的 C 语言的特性例如在一个平台上最小的 signed 值是 -2^15-32768最大值是 (2^15)-132767在 C 语言中无法将 signed 类型的最小值写为一个字面常数。signed x -32768; // not possible in C这是因为在 C 语言中-32768 实际上是 -1 * 32768而 32768 并不在 signed 类型的边界内。然而在 Zig 中-1 * 32768 是一个编译时的计算。const x: i16 -1 * 32768; // Valid in Zig内存管理与 Zig Allocator我曾经提到C语言对内存几乎没有抽象。这既有利也有弊有着巨大的力量也伴随着巨大的责任。在像 C 这样的手动内存管理语言中如果管理不善可能会带来严重的安全问题。最好的情况可能只会导致服务拒绝而最糟糕的情况可能会让攻击者执行任意的代码。许多语言试图通过施加编码限制或者使用垃圾收集器来避免这个问题。然而Zig 采取了不同的方式。Zig 同时提供了几个优势Zig 不会像 Rust 那样限制你的编码方式帮助你保持安全避免泄漏但仍然让你可以像在 C 中那样随心所欲。我个人认为这可能是一个方便的折衷。const std import(std);test detect leak {var list std.ArrayList(u21).init(std.testing.allocator);// defer list.deinit; - 这行缺失了try list.append();try std.testing.expect(list.items.len 1);}上述 Zig 代码使用内置的 std.testing.allocator 来初始化一个 ArrayList并让你 allocate 和 free并测试你是否在泄漏内存zig test testing_detect_leak.zig1/1 test.detect leak... OK[gpa] (err): memory address 0x7f23a1c3c000 leaked:.../lib/zig/std/array_list.zig:403:67: 0x21ef54 in ensureTotalCapacityPrecise (test)const new_memory try self.allocator.alignedAlloc(T, alignment, new_capacity);^.../lib/zig/std/array_list.zig:379:51: 0x2158de in ensureTotalCapacity (test)return self.ensureTotalCapacityPrecise(better_capacity);^.../lib/zig/std/array_list.zig:426:41: 0x2130d7 in addOne (test)try self.ensureTotalCapacity(self.items.len 1);^.../lib/zig/std/array_list.zig:207:49: 0x20ef2d in append (test)const new_item_ptr try self.addOne;^.../testing_detect_leak.zig:6:20: 0x20ee52 in test.detect leak (test)try list.append();^.../lib/zig/test_runner.zig:175:28: 0x21c758 in mainTerminal (test)} else test_fn.func;^.../lib/zig/test_runner.zig:35:28: 0x213967 in main (test)return mainTerminal;^.../lib/zig/std/start.zig:598:22: 0x20f4e5 in posixCallMainAndExit (test)root.main;^All 1 tests passed.1 errors were logged.1 tests leaked memory.error: the following test command failed with exit code 1:.../testZig 内置的 Allocator 有哪些Zig 提供了几个内置的分配器包括但不限于Zig 还支持你自定义分配器。亿万美元的错误 vs Zig Optionals这段 C 代码会突然崩溃除了一个 SIGSEGV什么线索都没有让你不知所措struct MyStruct {int myField;};int main {struct MyStruct* myStructPtr ;int value;value myStructPtr-myField; // 访问未初始化结构的字段printf(Value: %d\n, value);return 0;}Zig 没有任何 引用。它有可选类型用问号在前表示。你只能把 分配给可选类型并且只有在你检查了它们不是 的情况下才能引用它们使用 orelse 关键字或者简单的 if 表达式就可以。否则将会导致编译错误。const Person struct {age: u8};const maybe_p: Person ; // 编译错误: 预期类型为 Person找到 Type(.)const maybe_p: ?Person ; // OKstd.debug.print({}, { maybe_p.age }); // 编译错误: 类型 ?Person 不支持字段访问std.debug.print({}, { (maybe_p orelse Person{ .age 25 }).age }); // OKif (maybe_p) |p| {std.debug.print({}, { p.age }); // OK}Zig 的技术保证指针运算 vs Zig Slice在 C 语言中地址是用一个数值来表示的这允许开发者对指针进行算术运算。这个特性使得 C 语言开发者能够通过操作地址来访问和修改任意内存位置。指针算术常用于诸如操作或访问数组的特定部分或高效地遍历动态分配的内存块等任务而无需进行复制。然而由于 C 语言的不宽容性指针算术很容易导致诸如段错误或未定义行为等问题使得调试成为一种真正的痛苦。大多数这类问题可以用 Slice 来解决。Slice 提供了一种更安全、更直观的方式来操作和访问数组或内存区域var arr [_]u32{ 1, 2, 3, 4, 5, 6 }; // 1, 2, 3, 4, 5, 6const slice1 arr[1..5]; // 2, 3, 4, 5const slice2 slice1[1..3]; // 3, 4显式内存对齐每种类型都有一个对齐数它定义了该类型的合法内存地址。对齐是以字节为单位它保证了变量的起始地址能被对齐值整除。例如CPU 强制执行这些对齐要求。如果一个变量的类型没有正确对齐它可能导致程序崩溃比如段错误或者 illegal instruction错误。现在我们故意在下面的代码中创建一个未对齐的 unsigned int 指针。这段代码在大多数 CPU 上运行时会崩溃int main {unsigned int* ptr;char* misaligned_ptr;char buffer[10];// 故意让指针未对齐使其不能被 4 整除misaligned_ptr buffer 3;ptr (unsigned int*)misaligned_ptr;unsigned int value *ptr;printf(Value: %u\n, value);return 0;}使用低级语言会带来一些挑战比如管理内存的对齐。如果出错了可能会导致崩溃而 C 不会帮你检查。那么 Zig 呢让我们用 Zig 写一段类似的代码pub fn main void {var buffer [_]u8{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };// 故意让指针未对齐使其不能被 4 整除var misaligned_ptr buffer[3];var ptr: *u32 ptrCast(*u32, misaligned_ptr);const value: u32 ptr.*;std.debug.print(Value: {}\n, .{value});}如果你编译上面的代码因为存在一个对齐问题Zig 会报错并阻止编译.\main.zig:61:21: error: cast increases pointer alignmentvar ptr: *u32 ptrCast(*u32, misaligned_ptr);^.\main.zig:61:36: note: *u8 has alignment 1var ptr: *u32 ptrCast(*u32, misaligned_ptr);^.\main.zig:61:30: note: *u32 has alignment 4var ptr: *u32 ptrCast(*u32, misaligned_ptr);^即使你试图用一个显式的 alignCast 来欺骗 ZigZig 在安全构建模式下也会在生成的代码中添加一个指针对齐安全检查以确保指针按照承诺的方式对齐。所以如果运行时对齐错误它会用一条信息和一个追踪来告诉你问题出在哪里。C 则不会pub fn main void {var buffer [_]u8{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };// 故意让指针未对齐使其不能被 4 整除var misaligned_ptr buffer[3];var ptr: *u32 ptrCast(*u32, alignCast(4, misaligned_ptr));const value: u32 ptr.*;std.debug.print(Value: {}\n, .{value});}// 编译成功运行时你会收到main.zig:61:50: 0x7ff6f16933bd in ain (main.obj)var ptr: *u32 ptrCast(*u32, alignCast(4, misaligned_ptr));^...\zig\lib\std\start.zig:571:22: 0x7ff6f169248e in td.start.callMain (main.obj)root.main;^...\zig\lib\std\start.zig:349:65: 0x7ff6f1691d87 in td.start.WinStartup (main.obj)std.os.windows.kernel32.ExitProcess(initEventLoopAndCallMain);^酷毙了数组作为值C 语言的语义规定数组总是作为引用传递void f(int arr[100]) { ... } // 传递引用void f(int arr[]) { ... } // 传递引用C 语言的解决方案是创建一个 包装 结构体并传递结构体struct ArrayWrapper{int arr[SIZE];};void modify(struct ArrayWrapper temp) { // 使用包装结构体传递值// ...}而在 Zig 中这样就可以了fn foo(arr: [100]i32) void { // 传递数组值}fn foo(arr: *[100]i32) void { // 传递数组引用}错误处理许多 C 语言的 API 有错误码的概念即函数的返回值表示成功状态或者一个指示具体错误的整数。Zig 也使用同样的方法来处理错误但是在类型系统中对这个概念进行了更有用和更富表现力的改进。Zig 中的错误集合就像一个枚举。但是整个编译过程中的每个错误名都会被分配一个大于 0 的无符号整数。一个错误集合类型和一个普通类型可以用 ! 运算符组合成一个错误联合类型例如FileOpenError!u16。这种类型的值可能是一个错误值或者是普通类型的值。const FileOpenError error{AccessDenied,OutOfMemory,FileNotFound,};const maybe_error: FileOpenError!u16 10;const no_error maybe_error catch 0;Zig 确实有 try 和 catch 关键字但是它们和其他语言中的 try 和 catch 没有关系因为 Zig 没有异常。try x 是 x catch |err| return err的简写通常用在不适合处理错误的地方。总体来说Zig 的错误处理机制类似于 C但是有类型系统的支持。Zig 如何在运行时判断返回值是表示错误码还是实际输出!T 可以看作是struct {errorCode: GlobalErrorEnum, // u16result: T}而 errorCode 的 0 情况被认为是 ok 情况。当一个函数返回 !T 时它实际上是 u16 enum TZig 错误的技术保证同一个错误名多次出现会被分配相同的整数值。const FileOpenError error {AccessDenied,OutOfMemory,FileNotFound,};const AllocationError error {OutOfMemory,};// AllocationError.OutOfMemory FileOpenError.OutOfMemory一切皆表达式如果你是从其他高级语言转向 C 语言你可能会想念一些像这样的特性const firstName Tom;const lastName undefined;const displayName ( {if(firstName lastName)return ${firstName} ${lastName};if(firstName)return firstName;if(lastName)return lastName;return (no name);})Zig 的美妙之处在于可以把代码块当作表达式来使用。const result if (x) a else b;一个更复杂的例子const firstName: ?*const [3:0]u8 Tom;const lastName: ?*const [3:0]u8 ;var buf: [16]u8 undefined;const displayName blk: {if (firstName ! and lastName ! ) {const string std.fmt.bufPrint(buf, {s} {s}, .{ firstName, lastName }) catch unreachable;break :blk string;}if (firstName ! ) break :blk firstName;if (lastName ! ) break :blk lastName;break :blk (no name);};每个代码块都可以有一个标签比如 :blk并且可以用 break blk: 从该代码块中跳出并返回一个值。C 语言面临更复杂的语法处理看看这个 C 类型char * const (*(* const bar)[5])(int)这声明了 bar 为一个常量指针指向一个包含 5 个指针的数组这些指针指向一个函数int返回一个常量指针指向 char。不管这是什么意思。当然也有一些工具工具比如 cdecl.org可以帮助你阅读 C 类型并用人类容易理解的语言对其进行解释。我相当确定对于实际的 C 开发者来说处理这样的类型可能并没有那么困难。有些人天生就有能力阅读复杂语言。但对于像我这样喜欢简单明了的普通人来说Zig 类型更容易阅读和维护。一段有趣且合法的 C 代码inline int volatile long typedef _Atomic _Complex const long unsigned A;一段有趣且合法的 Zig 代码var x: *allowzero align(8) addrspace(.generic) const volatile u8 align(8)addrspace(.generic) linksection(unused_feature_section) undefined;结论在这篇博客文章中我们讨论了一些 C 语言存在的问题这些问题促使人们寻求或创造替代方案。总而言之Zig 通过以下方式解决了这些问题感谢我的朋友 Thomas 对这篇博客进行了技术审查。本文的参考资料你使用 C 语言时是否也遇到过这些问题有没有哪些问题文中没有提到你是否已经尝试过使用 Zig如果有你觉得它的哪些特性最有用欢迎在评论区分享你的经验。

更多文章