突破痛点!Zig 为 C 语言提供绝佳解决方案
【编者按】本文主要介绍号称 “改进版的 C 语言” 的编程语言:Zig。本文分析了 C 语言的一些问题,例如预处理器、空指针、内存管理、类型系统、错误处理等,并展示了 Zig 是如何解决或改进这些问题的。文章还展示了 Zig 的一些特性,例如编译时计算、切片、块表达式等。原文链接:https://avestura.dev/blog/problems-of-c-and-how-zig-address
【编者按】本文主要介绍号称 “改进版的 C 语言” 的编程语言:Zig。本文分析了 C 语言的一些问题,例如预处理器、空指针、内存管理、类型系统、错误处理等,并展示了 Zig 是如何解决或改进这些问题的。文章还展示了 Zig 的一些特性,例如编译时计算、切片、块表达式等。
原文链接:https://avestura.dev/blog/problems-of-c-and-how-zig-addresses-them
未经允许,禁止转载!
作者 | Aryan Ebrahimpour 译者 | 明明如月
责编 | 夏萌
出品 | CSDN(ID:CSDNnews)
C 是一门底层的系统编程语言,几乎没有对内存的抽象,所以内存管理全靠你自己,对汇编的抽象也很少,但足以支持一些如类型系统等通用概念。它也是一门适应性非常强的编程语言。如果编写得当,即使你的厨房烤箱具有一些奇特的架构,它也可以在上面运行。
C 语言的设计特点使其非常适合用于底层系统编程。然而,这并不意味着其设计决策在今天的标准下无懈可击。在这篇博客中,我们将讨论一些 C 语言存在的问题。这些问题导致人们多次试图创建用于取代 C 语言的备选语言。
Zig 编程语言作为一种新的系统编程语言,定位自身为改进版的 C 语言,引起了相当多的关注。Zig 是如何实现这一目标的呢?在这篇博客中,我们的目标是研究一些与 C 语言相关的问题,并探讨 Zig 是如何解决这些问题的。
差异对比表
[Comptime](https://avestura.dev/blog/problems-of-c-and-how-zig-addresses-them#comptime-over-textual-replacement-preprocessing) 取代文本替换预处理
内存管理,以及 Zig [Allocator](https://avestura.dev/blog/problems-of-c-and-how-zig-addresses-them#memory-management-and-zig-allocators)
十亿美元的错误 vs Zig Optionals
指针运算 vs Zig [Slice](https://avestura.dev/blog/problems-of-c-and-how-zig-addresses-them#pointer-arithmetics-vs-zig-slices)
明确的内存对齐
数组作为值
错误处理
一切皆表达式
C 语言面临更复杂的语法处理
Comptime 取代文本替换预处理
使用预处理器在源代码中替换文本并非C 语言特有。这在 C 语言诞生之前就已经存在,早在 IBM 704 计算机的 SAP 汇编器中就有类似的例子。以下是一个 AMD64 汇编片段的例子,它定义了一个 pushr 宏,并根据其参数将其替换为 push 或 pushf:
C 语言作为汇编的最小抽象,采用了同样的方法来支持宏,但这很容易引发问题。以下是一个小例子:
%macro pushr 1
%ifidn %1, rflags
pushf
%else
push %1
%endif
%endmacro
%define regname rcx
pushr rax
pushr rflags
pushr regname
可能会期望此代码将 result 的值设置为 (2 + 3)^2 = 25。然而,由于 SQUARE 宏函数的文本替换特性,展开结果为 2 + 3 * 2 + 3,计算结果为 11,而不是 25。
要使这段代码能正常,至关重要的是确保所有的宏都正确地加上了括号:
#define SQUARE(x) x * x
int 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-time
Zig 编译器的另一个优点是能对输入进行类型检查,即使它是 anytype。当在 Zig 中调用 square 函数时,如果使用了不支持 * 操作符的类型,将导致编译时类型错误:
const result = comptime square("hello"); // compile time error: type mismatch
Comptime 允许在编译时执行任意代码:
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)-1=32767,在 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 同时提供了几个优势:
手动内存管理:内存的控制权在你手中。没有像 Rust 那样的编码限制。
没有隐藏的分配:没有任何东西会在堆上分配,除非你知道并允许它这么做。Zig 使用 Allocator 类型来实现这一点。任何在堆上分配的函数都会接收一个 Allocator 作为参数。任何不这样做的都不会在堆上分配,这是有保证的。
避免内存泄漏的安全工具,例如 std.heap.GeneralPurposeAllocator
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.zig
1/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:
.../test
Zig 内置的 Allocator 有哪些?
Zig 提供了几个内置的分配器,包括但不限于:
FixedBufferAllocator
GeneralPurposeAllocator
TestingAllocator
c_allocator
StackFallbackAllocator
LoggingAllocator
Zig 还支持你自定义分配器。
亿万美元的错误 vs Zig Optionals
这段 C 代码会突然崩溃,除了一个 SIGSEGV,什么线索都没有,让你不知所措:
struct MyStruct {
int myField;
};
int main() {
struct MyStruct* myStructPtr = NULL;
int value;
value = myStructPtr->myField; // 访问未初始化结构的字段
printf("Value: %d\n", value);
return 0;
}
Zig 没有任何 null 引用。它有可选类型,用问号在前表示。你只能把 null 分配给可选类型,并且只有在你检查了它们不是 null 的情况下才能引用它们,使用 orelse 关键字或者简单的 if 表达式就可以。否则,将会导致编译错误。
const Person = struct {
age: u8
};
const maybe_p: Person = null; // 编译错误: 预期类型为 'Person',找到 '@Type(.Null)'
const maybe_p: ?Person = null; // OK
std.debug.print("{}", { maybe_p.age }); // 编译错误: 类型 '?Person' 不支持字段访问
std.debug.print("{}", { (maybe_p orelse Person{ .age = 25 }).age }); // OK
if (maybe_p) |p| {
std.debug.print("{}", { p.age }); // OK
}
Zig null 的技术保证:
可选指针的大小保证与指针相同。
可选的 null 值保证是地址 0。
指针运算 vs Zig Slice
在 C 语言中,地址是用一个数值来表示的,这允许开发者对指针进行算术运算。这个特性使得 C 语言开发者能够通过操作地址来访问和修改任意内存位置。
指针算术常用于诸如操作或访问数组的特定部分或高效地遍历动态分配的内存块等任务,而无需进行复制。然而,由于 C 语言的不宽容性,指针算术很容易导致诸如段错误或未定义行为等问题,使得调试成为一种真正的痛苦。
大多数这类问题可以用 Slice 来解决。Slice 提供了一种更安全、更直观的方式来操作和访问数组或内存区域:
var arr = [_]u32{ 1, 2, 3, 4, 5, 6 }; // 1, 2, 3, 4, 5, 6
const slice1 = arr[1..5]; // 2, 3, 4, 5
const slice2 = slice1[1..3]; // 3, 4
显式内存对齐
每种类型都有一个对齐数,它定义了该类型的合法内存地址。对齐是以字节为单位,它保证了变量的起始地址能被对齐值整除。例如:
u8 类型的自然对齐值为 1,意味着它可以存在于任何内存地址。
u16 类型的自然对齐值为 2,意味着它只能存在于内存地址能被 2 整除的位置,比如 0, 2, 4, 6, 8 等...
u32 类型的自然对齐值为 4,意味着它只能存在于内存地址能被 4 整除的位置,比如 0, 4, 8, 12, 16 等...
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 alignment
var ptr: *u32 = @ptrCast(*u32, misaligned_ptr);
^
.\main.zig:61:36: note: '*u8' has alignment 1
var ptr: *u32 = @ptrCast(*u32, misaligned_ptr);
^
.\main.zig:61:30: note: '*u32' has alignment 4
var ptr: *u32 = @ptrCast(*u32, misaligned_ptr);
^
即使你试图用一个显式的 @alignCast 来欺骗 Zig,Zig 在安全构建模式下也会在生成的代码中添加一个指针对齐安全检查,以确保指针按照承诺的方式对齐。所以如果运行时对齐错误,它会用一条信息和一个追踪来告诉你问题出在哪里。
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, // u16
result: T
}
而 errorCode 的 0 情况被认为是 "ok" 情况。当一个函数返回 !T 时,它实际上是 u16 enum + T
Zig 错误的技术保证:
同一个错误名多次出现会被分配相同的整数值。
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 = null;
var buf: [16]u8 = undefined;
const displayName = blk: {
if (firstName != null and lastName != null) {
const string = std.fmt.bufPrint(&buf, "{s} {s}", .{ firstName, lastName }) catch unreachable;
break :blk string;
}
if (firstName != null) break :blk firstName;
if (lastName != null) 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 通过以下方式解决了这些问题:
Zig 编译时计算
Zig 分配器
Zig 可选类型
Zig 切片
Zig 显式对齐
Zig 数组
Zig 错误类型
Zig 表达式
感谢我的朋友 Thomas 对这篇博客进行了技术审查。
本文的参考资料:
Gustedt, J. (2019). Modern C. Manning.
Zhirkov, I. (2017). Low-Level Programming: C, Assembly, and Program Execution on Intel x86-64 Architecture. Apress.
Zig 语言参考手册。[在线]。ziglang.org/documentation/master
Zig 学习。[在线]。ziglearn.org
你使用 C 语言时是否也遇到过这些问题?有没有哪些问题文中没有提到?你是否已经尝试过使用 Zig?如果有,你觉得它的哪些特性最有用?欢迎在评论区分享你的经验。
推荐阅读:
▶华为申请注册盘古大模型商标;英伟达 A800 一周涨价超 30%;Apache Tomcat 10.1.11 发布 |极客头条
更多推荐
所有评论(0)