C语言存在的问题及Zig语言如何改进,差异对比全在这

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,要是有的话,你认为它的哪些特性是最为有用的,欢迎于评论区去分享你的相关经验。

相关推荐
CPUOS20102 小时前
嵌入式C语言高级编程之MVC设计模式
c语言·设计模式·mvc
青梅橘子皮2 小时前
C语言---指针的应用以及一些面试题
c语言·开发语言·算法
零号全栈寒江独钓5 小时前
基于c/c++实现linux/windows跨平台获取ntp网络时间戳
linux·c语言·c++·windows
爱编码的小八嘎8 小时前
C语言完美演绎8-10
c语言
爱编码的小八嘎11 小时前
C语言完美演绎8-4
c语言
零号全栈寒江独钓14 小时前
基于c/c++实现linux/windows跨平台ntp时间戳服务器
linux·c语言·c++·windows
我能坚持多久15 小时前
String类常用接口的实现
c语言·开发语言·c++
CPUOS201015 小时前
嵌入式C语言高级编程之单一职责原则
c语言·开发语言·单一职责原则
Severus_black16 小时前
顺序表、单链表经典算法题分享(未完待续...)
c语言·数据结构·算法·链表