作者丨Aryan Ebrahimpour
策劃丨諾亞
C是一種低級系統(tǒng)編程語言,幾乎沒有對內存的抽象,因此內存管理完全由開發(fā)人員自己負責,并且對匯編的抽象最少(但表達能力足以支持一些通用概念,例如類型系統(tǒng))。它也是一種非??梢浦驳木幊陶Z言,因此如果編寫正確,即使它具有一些晦澀的架構,也可以在你的烤面包機上運行。
C的特性使其成為一種非常契合其預期用途的語言。然而,這并不意味著它的設計決策按照今天的標準是完美無缺的。 如今,Zig橫空出世,作為一種新的系統(tǒng)編程語言受到了相當多的關注。
Zig將自己定位為更好的C語言。但Zig是如何實現(xiàn)這一目標的呢?在本文中,我們的目的是研究與C相關的一些問題,并探討Zig打算如何解決這些問題。
目錄一覽
- Comptime文本替換預處理
- 內存管理和Zig分配器
- 十億美元的錯誤與Zig Optional
- 指針算術與Zig Slice
- 顯式內存對齊
- 數(shù)組作為值
- 錯誤處理
- 一切都是一種表達
- C 有更復雜的語法需要處理
1、Comptime文本替換預處理
使用預處理器替換源代碼中的文本并不是C所獨有的。它在C創(chuàng)建之前就已經存在,并且可以追溯到早期的示例,例如IBM 704 計算機的SAP匯編器。下面是一個AMD64匯編代碼片段的示例,它定義了一個pushr宏,并根據(jù)其參數(shù)將其替換為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的值。然而,由于宏函數(shù)的文本替換性質,展開的結果是,其求值為11,而不是25。(2 + 3)的平方= (2 + 3)^2 = 25SQUARE2 + 3 * 2 + 3
為了使其正確工作,確保所有宏都正確,加上括號至關重要:
#define SQUARE(x) ((x)*(x))
C不會容忍這樣的錯誤,也不會好心地通知你。錯誤可能在很久以后,在程序中完全不相關的部分的另一個輸入中顯示出來。
另一方面,Zig通過引入?yún)?shù)和函數(shù),為這類任務采用了更加直觀的方法。這使我們能夠在編譯時而不是運行時執(zhí)行函數(shù)。下面是同一個C語言宏在Zig: comptimesSQUARE中
fn square(x: anytype) @TypeOf(x) {
return x * x;
}
const result = comptime square(2 + 3); // result = 25, at compile-time
Zig編譯器的另一個優(yōu)點是它能夠對輸入執(zhí)行類型檢查,即使它是。在使用Zig調用函數(shù)時,如果使用的類型不支持該操作符,則會導致編譯時類型錯誤:anytypessquare *
const result = comptime square("hello"); // compile time error: type mismatch
Comptime允許在編譯時執(zhí)行任意代碼
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函數(shù),然后在編譯時調用該函數(shù)來設置的值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這樣使用手動內存管理的語言中,內存管理不當可能會導致嚴重的安全后果。在最好的情況下,它可能導致拒絕服務,在最壞的情況下,它可以讓攻擊者執(zhí)行任意代碼。許多語言試圖通過施加編碼限制或使用垃圾收集器消除整個問題來減少這種責任。然而,Zig采用了一種不同的方法。
Zig同時提供了幾個優(yōu)勢:
- 手動內存管理:你做你的。內存的控制權在你手中。沒有像Rust那樣的編碼限制。
- 沒有隱藏分配:在你不知道并允許它發(fā)生的情況下,不會在堆上分配任何東西。Zig利用Allocator類型來實現(xiàn)這一點。任何在堆上分配的函數(shù)都會接收一個Allocator作為參數(shù)。任何不這樣做的東西都不會在堆上分配,這是肯定的。
- 避免內存泄漏的安全工具,例如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代碼利用內置函數(shù)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
你總是可以實現(xiàn)自己的分配器。
3、十億美元的錯誤與Zig Optional
這段C代碼突然崩潰,除了讓你知道SIGSEGV到底發(fā)生了什么之外,沒有任何線索:
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引用曾被快速排序算法的創(chuàng)造者托尼·霍爾稱為"十億美元錯誤")。否則,你將最終面臨編譯錯誤。
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語言中,地址被表示為一個數(shù)值,這使得開發(fā)人員可以對指針執(zhí)行算術運算。該特性使C開發(fā)人員能夠通過操作地址來訪問和修改任意內存位置。
指針算術通常用于操作或訪問數(shù)組的特定部分或有效地在動態(tài)分配的內存塊中導航等任務,而不需要復制。然而,由于C語言的無情本質,指針算術很容易導致諸如分段錯誤或未定義行為等問題,從而使調試成為真正的痛苦。
大多數(shù)此類問題可以使用Slices來解決。切片提供了一種更安全、更直觀的方式來操作和訪問數(shù)組或內存部分:
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、顯式內存對齊
每種類型都有一個對齊號,它定義了該類型合法的內存地址。對齊以字節(jié)為單位,它確保變量的起始地址可以被對齊值整除。例如:
- 該u8類型的自然對齊方式為1,這意味著它可以駐留在任何內存地址中。
- 該u16類型具有2的自然對齊方式,這意味著它只能駐留在地址可被2整除的內存位置中,例如0、2、4、6、8等...
- 該u32類型具有4的自然對齊方式,這意味著它只能駐留在地址可被4整除的內存位置中,例如0、4、8、12、16等...
CPU強制執(zhí)行這些對齊要求。如果變量的類型未正確對齊,可能會導致程序崩潰(例如分段錯誤)或導致非法指令。
現(xiàn)在我們將unsigned int在下面的代碼中故意創(chuàng)建一個指向an的未對齊指針。此代碼將在大多數(shù)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;
}
使用低級語言會帶來其自身的挑戰(zhàn),例如管理內存對齊。犯錯誤可能會導致崩潰,而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也會在安全構建模式下向生成的代碼添加指針對齊安全檢查,以確保指針按照承諾對齊。因此,如果運行時對齊錯誤,它會出現(xiàn)恐慌,并顯示一條消息和跟蹤信息,以便你了解問題出在哪里。這是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、數(shù)組作為值
C語言的語義定義了數(shù)組總是作為引用傳遞
void f(int arr[100]) { ... } // passed by ref
void f(int arr[]) { ... } // passed by ref
C中的解決方案是創(chuàng)建一個包裝器結構并傳遞該結構:
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都有錯誤碼的概念,其中函數(shù)的返回值要么表示成功狀態(tài),要么表示發(fā)生的特定錯誤的整數(shù)。
Zig使用相同的方法來處理錯誤,但是通過在類型系統(tǒng)中以更有用和更具表現(xiàn)力的方式捕獲錯誤,改進了這個概念。
Zig中的錯誤集類似于枚舉。但是,整個編譯過程中的每個錯誤名稱都會被分配一個大于0的無符號整數(shù)。
錯誤集類型和正常類型可以使用!操作符用于形成錯誤聯(lián)合類型(例如: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,但有類型系統(tǒng)的支持。
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常量指針的函數(shù)(int)的指針的數(shù)組5的常量指針。不管什么意思。
甚至還有像cdecl.org這樣的工具 可以幫助你閱讀C類型并為你人性化。我很肯定,對于實際的C開發(fā)人員來說,處理此類類型可能并不那么具有挑戰(zhàn)性。有些人有幸擁有這種能力,能夠閱讀神的語言。但對于像我這樣寧愿讓事情變得簡單的人來說,Zig類型更容易閱讀和維護。
10、結論
在這篇博文中,我們討論了C語言的一些問題,這些問題導致人們尋找或創(chuàng)建替代過去遺留下來的語言。
總之,Zig通過以下方式解決了這些問題:
- Zig Comptimes
- Zig 分配器
- Zig Optionals
- Zig Slices
- Zig 顯式對齊
- Zig 陣列
- Zig 錯誤類型
- Zig 表達式
原文鏈接:https://avestura.dev/blog/problems-of-c-and-how-zig-addresses-them