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