C的老毛病?用Zig解决

译文 精选
开发 前端
C的特性使其成为一种非常契合其预期用途的语言。然而,这并不意味着它的设计决策按照今天的标准是完美无缺的。 如今,Zig横空出世,作为一种新的系统编程语言受到了相当多的关注。

作者丨Aryan Ebrahimpour

策划丨诺亚

C是一种低级系统编程语言,几乎没有对内存的抽象,因此内存管理完全由开发人员自己负责,并且对汇编的抽象最少(但表达能力足以支持一些通用概念,例如类型系统)。它也是一种非常可移植的编程语言,因此如果编写正确,即使它具有一些晦涩的架构,也可以在你的烤面包机上运行。

C的特性使其成为一种非常契合其预期用途的语言。然而,这并不意味着它的设计决策按照今天的标准是完美无缺的。 如今,Zig横空出世,作为一种新的系统编程语言受到了相当多的关注。

Zig将自己定位为更好的C语言。但Zig是如何实现这一目标的呢?在本文中,我们的目的是研究与C相关的一些问题,并探讨Zig打算如何解决这些问题。

目录一览

  • Comptime文本替换预处理
  • 内存管理和Zig分配器
  • 十亿美元的错误与Zig Optional
  • 指针算术与Zig Slice
  • 显式内存对齐
  • 数组作为值
  • 错误处理
  • 一切都是一种表达
  • C 有更复杂的语法需要处理

1、Comptime文本替换预处理

使用预处理器替换源代码中的文本并不是C所独有的。它在C创建之前就已经存在,并且可以追溯到早期的示例,例如IBM 704 计算机的SAP汇编器。下面是一个AMD64汇编代码片段的示例,它定义了一个pushr宏,并根据其参数将其替换为push或:pushf。

amd64-macro.asm

%macro pushr 1
%ifidn %1, rflags
pushf
%else
push %1
%endif
%endmacro

%define regname rcx

pushr rax
pushr rflags
pushr regname

C是对汇编的最小抽象,采用了相同的方法来支持宏,可以轻松地变成脚枪。举个小例子:

footgun-macro.c

#define SQUARE(x) x * x

int result = SQUARE(2 + 3)

你可能期望这段代码设置to的值。然而,由于宏函数的文本替换性质,展开的结果是,其求值为11,而不是25。(2 + 3)的平方= (2 + 3)^2 = 25SQUARE2 + 3 * 2 + 3

为了使其正确工作,确保所有宏都正确,加上括号至关重要:

#define SQUARE(x) ((x)*(x))

C不会容忍这样的错误,也不会好心地通知你。错误可能在很久以后,在程序中完全不相关的部分的另一个输入中显示出来。

另一方面,Zig通过引入参数和函数,为这类任务采用了更加直观的方法。这使我们能够在编译时而不是运行时执行函数。下面是同一个C语言宏在Zig: comptimesSQUARE中

fn square(x: anytype) @TypeOf(x) {
    return x * x;
}

const result = comptime square(2 + 3); // result = 25, at compile-time

Zig编译器的另一个优点是它能够对输入执行类型检查,即使它是。在使用Zig调用函数时,如果使用的类型不支持该操作符,则会导致编译时类型错误:anytypessquare *

const result = comptime square("hello"); // compile time error: type mismatch

Comptime允许在编译时执行任意代码

comptime-example.zig

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。Nofibonacci在运行时被调用。

Zig的comptime计算还可以涵盖C语言的一些小特性:例如,在最小值为-2^15=-32768且最大值为(2^15)-1=32767的平台中signed,不可能在C中将类型的最小值写signed为文字常量。

signed x = -32768; // not possible in C

这是因为在C中-32768实际上is-1 * 32768并且32768不在signed类型的边界内。然而,在Zig中,-1 * 32768是编译时评估。

const x: i32 = -1 * 32768; // Valid in Zig

2、内存管理和Zig分配器

正如我前面提到的,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(); <- this line is missing
    try list.append('☔');

    try std.testing.expect(list.items.len == 1);
}

上面的Zig代码利用内置函数std.testing.allocator来初始化anArrayList并允许你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提供了几个内置分配器,包括但不限于:

  • FixedBufferAllocator
  • GeneralPurposeAllocator
  • TestingAllocator
  • c_allocator
  • StackFallbackAllocator
  • LoggingAllocator

你总是可以实现自己的分配器。

3、十亿美元的错误与Zig Optional

这段C代码突然崩溃,除了让你知道SIGSEGV到底发生了什么之外,没有任何线索:

struct MyStruct {
    int myField;
};

int main() {
    struct MyStruct* myStructPtr = NULL;
    int value;

    value = myStructPtr->myField;  // Accessing field of uninitialized struct

    printf("Value: %d\n", value);

    return 0;
}

另一方面,Zig没有任何参考资料。它具有可选类型,在开头用问号表示。只能给可选类型赋值,并且只能在使用关键字或简单地通过表达式检查它们是否为null时引用它们(null引用曾被快速排序算法的创造者托尼·霍尔称为"十亿美元错误")。否则,你将最终面临编译错误。

const Person = struct {
    age: u8
};

const maybe_p: Person = null; // compile error: expected type 'Person', found '@Type(.Null)'

const maybe_p: ?Person = null; // OK

std.debug.print("{}", { maybe_p.age }); // compile error: type '?Person' does not support field access

std.debug.print("{}", { (maybe_p orelse Person{ .age = 25 }).age }); // OK

if (maybe_p) |p| {
    std.debug.print("{}", { p.age }); // OK
}

4、指针算术与Zig Slice

在C语言中,地址被表示为一个数值,这使得开发人员可以对指针执行算术运算。该特性使C开发人员能够通过操作地址来访问和修改任意内存位置。

指针算术通常用于操作或访问数组的特定部分或有效地在动态分配的内存块中导航等任务,而不需要复制。然而,由于C语言的无情本质,指针算术很容易导致诸如分段错误或未定义行为等问题,从而使调试成为真正的痛苦。 

大多数此类问题可以使用Slices来解决。切片提供了一种更安全、更直观的方式来操作和访问数组或内存部分:

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

5、显式内存对齐

每种类型都有一个对齐号,它定义了该类型合法的内存地址。对齐以字节为单位,它确保变量的起始地址可以被对齐值整除。例如:

  • 该u8类型的自然对齐方式为1,这意味着它可以驻留在任何内存地址中。
  • 该u16类型具有2的自然对齐方式,这意味着它只能驻留在地址可被2整除的内存位置中,例如0、2、4、6、8等...
  • 该u32类型具有4的自然对齐方式,这意味着它只能驻留在地址可被4整除的内存位置中,例如0、4、8、12、16等...

CPU强制执行这些对齐要求。如果变量的类型未正确对齐,可能会导致程序崩溃(例如分段错误)或导致非法指令。

现在我们将unsigned int在下面的代码中故意创建一个指向an的未对齐指针。此代码将在大多数CPU上运行时崩溃:

int main() {
    unsigned int* ptr;
    char* misaligned_ptr;

    char buffer[10];

    // Intentionally misalign the pointer so it won't be evenly divisible by 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 };

    // Intentionally misalign the pointer so it won't be evenly divisible by 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);
                             ^

即使你尝试使用显式欺骗zig @alignCast,Zig也会在安全构建模式下向生成的代码添加指针对齐安全检查,以确保指针按照承诺对齐。因此,如果运行时对齐错误,它会出现恐慌,并显示一条消息和跟踪信息,以便你了解问题出在哪里。这是C不会为你做的事情:

pub fn main() void {
    var buffer = [_]u8{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

    // Intentionally misalign the pointer so it won't be evenly divisible by 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});
}
// Compiles OK

在运行时你将收到:

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());
                                                                ^

6、数组作为值

C语言的语义定义了数组总是作为引用传递

void f(int arr[100]) { ... } // passed by ref
void f(int arr[]) { ... }    // passed by ref

C中的解决方案是创建一个包装器结构并传递该结构:

struct ArrayWrapper
{
    int arr[SIZE];
};

void modify(struct ArrayWrapper temp) { // passed by value using a wrapper struct
    // ...
}

在Zig中它就可以工作

fn foo(arr: [100]i32) void { // pass array by value
    
}

fn foo(arr: *[100]i32) void { // pass array by reference
    
}

7、错误处理

许多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关键字,但它们与其他语言无关,因为Zig没有例外

Try x是,的快捷方式,xcatch |err| return err通常用于不适合处理错误的地方。

总的来说,Zig的错误处理机制类似于C,但有类型系统的支持。

8、一切都是一种表达

从高级语言到C语言,你可能会错过以下功能:

IIFE.js

let firstName = Some "Tom"
let lastName = None

let displayName =
    match firstName, lastName with
    | Some x, Some y -> $"{x} {y}"
    | Some x, _ -> x
    | _, Some y -> y
    | _ -> "(no name)"

Zig的美妙之处在于,你可以将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从该块break blk:返回一个值。

9、C有更复杂的语法需要处理

看看这个C类型:

char * const (*(* const bar)[5])(int )

这声明bar为指向返回char常量指针的函数(int)的指针的数组5的常量指针。不管什么意思。

甚至还有像cdecl.org这样的工具 可以帮助你阅读C类型并为你人性化。我很肯定,对于实际的C开发人员来说,处理此类类型可能并不那么具有挑战性。有些人有幸拥有这种能力,能够阅读神的语言。但对于像我这样宁愿让事情变得简单的人来说,Zig类型更容易阅读和维护。

10、结论

在这篇博文中,我们讨论了C语言的一些问题,这些问题导致人们寻找或创建替代过去遗留下来的语言。

总之,Zig通过以下方式解决了这些问题:

  • Zig Comptimes
  • Zig 分配器
  • Zig Optionals
  • Zig Slices
  • Zig 显式对齐
  • Zig 阵列
  • Zig 错误类型
  • Zig 表达式

原文链接:https://avestura.dev/blog/problems-of-c-and-how-zig-addresses-them

责任编辑:武晓燕 来源: 51CTO技术栈
相关推荐

2021-12-08 22:24:14

Windows 11操作系统微软

2023-08-29 18:49:41

2023-11-16 15:10:39

RustJavaZig

2020-11-17 06:04:59

ZigC语言

2023-12-05 18:22:12

Go程序员Zig

2020-04-21 15:22:35

ChromeFirefox浏览器

2020-03-03 18:56:37

开源软件协作

2019-10-18 15:35:16

Python编程语言高级用法

2021-11-04 05:46:20

Windows 11内置应用程序微软

2017-05-15 16:30:49

NoSQLMySQLOracle

2023-03-29 08:36:33

国产数据库开源

2019-03-27 09:40:49

程序员技能开发者

2011-06-07 10:28:51

程序员

2018-01-24 16:32:01

数据目录数据蔓延企业

2011-04-28 15:08:54

打印机热转印色带问题

2009-08-06 10:35:27

C# lock thi

2011-06-19 17:59:05

打印机常见问题

2009-08-19 22:36:08

Ubuntu安装VMw

2010-05-06 17:13:18

Unix命令

2023-10-30 10:29:50

C++最小二乘法
点赞
收藏

51CTO技术栈公众号