C 的老毛病?用 Zig 解決

作者丨 Aryan Ebrahimpour

策劃丨諾亞

C 是一種低級系統編程語言,幾乎沒有對內存的抽象,因此內存管理完全由開發人員自己負責,並且對彙編的抽象最少(但表達能力足以支持一些通用概念,例如類型系統)。它也是一種非常可移植的編程語言,因此如果編寫正確,即使它具有一些晦澀的架構,也可以在你的烤麪包機上運行。

C 的特性使其成爲一種非常契合其預期用途的語言。然而,這並不意味着它的設計決策按照今天的標準是完美無缺的。 如今,Zig 橫空出世,作爲一種新的系統編程語言受到了相當多的關注。

Zig 將自己定位爲更好的 C 語言。但 Zig 是如何實現這一目標的呢?在本文中,我們的目的是研究與 C 相關的一些問題,並探討 Zig 打算如何解決這些問題。

目錄一覽

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

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

內存管理和 Zig 分配器

正如我前面提到的,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(); <- 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 提供了幾個內置分配器,包括但不限於:

你總是可以實現自己的分配器。

十億美元的錯誤與 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
}

指針算術與 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

顯式內存對齊

每種類型都有一個對齊號,它定義了該類型合法的內存地址。對齊以字節爲單位,它確保變量的起始地址可以被對齊值整除。例如:

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

數組作爲值

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
    
}

錯誤處理

許多 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,但有類型系統的支持。

一切都是一種表達

從高級語言到 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: 返回一個值。

C 有更復雜的語法需要處理

看看這個 C 類型:

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

這聲明 bar 爲指向返回 char 常量指針的函數 (int) 的指針的數組 5 的常量指針。不管什麼意思。

甚至還有像 cdecl.org 這樣的工具 可以幫助你閱讀 C 類型併爲你人性化。我很肯定,對於實際的 C 開發人員來說,處理此類類型可能並不那麼具有挑戰性。有些人有幸擁有這種能力,能夠閱讀神的語言。但對於像我這樣寧願讓事情變得簡單的人來說,Zig 類型更容易閱讀和維護。

結論

在這篇博文中,我們討論了 C 語言的一些問題,這些問題導致人們尋找或創建替代過去遺留下來的語言。

總之,Zig 通過以下方式解決了這些問題:

原文鏈接:

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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/z8cL_kFe9E8X1wqLgHpbDA