Day04 - 綠鬣蜥初級兵新手訓練營:Zig 基礎語法(下)

走在時代前沿的前言

嗨諸位,我 CX330,我又回來了。昨天帶大家看了很多複合類型,今天是新手訓練營的最後一天啦!今天要帶大家看看 Zig 的可選類型、錯誤處處理和流程控制的部分。

然後跟大家分享一下,牛肉湯昨天是社團博覽會,依慣例我們結束都會去吃個宵夜,但我跟白臉貓還沒寫完我們的鐵人賽,結果我們昨天就在宵夜店拿著電腦在修修改改,滿好玩的經驗。

疊甲

中華民國刑法第 362 條:「製作專供犯本章之罪之電腦程式,而供自己或他人犯本章之罪,致生損害於公眾或他人者,處五年以下有期徒刑、拘役或科或併科六十萬元以下罰金。」

本系列文章涉及多種惡意程式的技術,旨在提升個人技術能力與資安意識。本人在此強烈呼籲讀者,切勿使用所學到的知識與技術從事任何違法行為!

Zig 版本

本系列文章中使用的 Zig 版本號為 0.14.1。

可選類型

Zig 裡面,為了解決 C 語言遺留的諸多安全性問題,提供了許多解決方案,而可選類型就是重要的其中之一。它的符號為 ??T 表示該類型的值可以是 null 或是 T 類型。下面舉個例子:

// normal integer
const normal_int: i32 = 7777;
// i32 optional integer, can be null or i32
const optional_int: ?i32 = 1337;

在很多時候,可選類型都會用於指針上,是為了防止出現 null 所帶來的各種問題(甚至被說是電腦科學中最嚴重的錯誤)。

透過可選類型,我們可以預防這樣的問題發生。這樣的解決方法是相對保守的,他同時兼顧了程式碼的可讀性和執行時的效率。而 Rust 在這方面顯得更為激進,會大幅增加程式設計師在設計程式碼的時候的心智負擔;相對而言 Zig 則採取了折衷的方案,在編譯期進行簡單的檢測,且檢測出錯誤的話通常很好修正。但是這樣的方案的缺點是不能保證運行時百分之百安全,因為可選類型只能避免空指針的問題,但不能避免 Wild pointers 或是 Dangling pointers 等其他問題。

至於可選類型如何解構,讓其變回 T 的類型呢?在 Zig 裡面有很多種方式,除了待會在「流程控制」的章節會講到的使用 || 捕獲值以外,最直觀的就是使用 .? 的方式解構,有點像是解引用指針的 .* 一樣

除了可選類型的變數以外,null 不能被賦值給任何其他的類型的變數。以下舉例個例子比較一下 C 跟 Zig 在這方面的處理。我們先來看一下 C 程式碼。

void *malloc(size_t size);

struct Foo *do_a_thing(void) {
    char *ptr = malloc(1234);
    if (!ptr) return NULL;
    // ...
}

接著我們看一下 Zig 的實現。

extern fn malloc(size: usize) ?*u8;

fn doAThing() ?*Foo {
	const ptr = malloc(1234) orelse return null;
	_ = ptr; // ...
}

在這邊,我們先介紹一下這個 extern 是用來導入外部定義的符號,也就是會去 Link 標準 libc 的 malloc,它是 POSIX 標準之一。之後我們通過 orelse 解構了可選類型,以保證 ptr 是個合法可用的指針。如果 malloc 返回了 null,則會直接返回 null

錯誤處理

錯誤集合

錯誤集合(Error set)就是一個枚舉,不過這個枚舉裡面都是存放各種 Errors。

const std = @import("std");

const FileOpenError = error{
    AccessDenied,
    OutOfMemory,
    FileNotFound,
};

const AllocationError = error{
    OutOfMemory,
};

pub fn main() void {
    const err = foo(AllocationError.OutOfMemory);
    if (err == FileOpenError.OutOfMemory) {
        std.debug.print("error is OutOfMemory\n", .{});
    }
}

fn foo(err: AllocationError) FileOpenError {
    return err;
}

錯誤集合中包含了若干個錯誤。一個錯誤也可以同時存在於多個不同的錯誤集合中,例如範例程式碼中的 OutOfMemory 錯物就同時被包含在 FileOpenErrorAllocationError 中。錯誤可以從子集隱式轉換為超集。例如,範例中通過函數 foo 將子集 AllocationError 轉換到了超集 FileOpenError

也可以直接使用匿名的錯誤集合:

const err = (error{FileNotFound}).FileNotFound;

但這樣寫起來太過於冗長且囉唆,故 Zig 提供了一個簡短的、等價的寫法:

const err = error.FileNotFound;

全局錯誤集合

anyerror 指的是全局錯誤集合,它含有編譯單元中的所有錯誤,是其他所有錯誤集合的超集。任何錯誤集合都可以隱式轉換為全局錯誤集,但反之不然。從 anyerror 到其他錯誤集合的轉換都需要顯示進行。

錯誤聯合類型

上面講到的兩種錯誤集合在平常寫程式應該是不太常遇到,但是這邊的錯誤聯合類型卻是很常很常會遇到的

以下是一個將英文字符串解析為數字的範例:

const std = @import("std");
const maxInt = std.math.maxInt;

pub fn parseU64(buf: []const u8, radix: u8) !u64 {
    var x: u64 = 0;

    for (buf) |c| {
        const digit = charToDigit(c);

        if (digit >= radix) {
            return error.InvalidChar;
        }

        // x *= radix
        var ov = @mulWithOverflow(x, radix);
        if (ov[1] != 0) return error.OverFlow;

        // x += digit
        ov = @addWithOverflow(ov[0], digit);
        if (ov[1] != 0) return error.OverFlow;
        x = ov[0];
    }

    return x;
}

fn charToDigit(c: u8) u8 {
    return switch (c) {
        '0'...'9' => c - '0',
        'A'...'Z' => c - 'A' + 10,
        'a'...'z' => c - 'a' + 10,
        else => maxInt(u8),
    };
}

注意這邊的 parseU64 函數的返回值是 !u64,這代表說這個函數返回的值會是一個 u64 類型或是一個錯誤。我們沒有在 ! 左側指定錯誤集合,故編譯器會自動推斷。

Catch

catch 用來在發生錯誤的時候提供一個默認的值,舉例來說:

fn doAThing(str: []u8) void {
	const number = parseU64(str, 10) catch 13;
	_ = number;
	// ...
}

number 必須是一個類型為 u64 的值,當發生錯誤的時候,number 將會被賦值為 13。當然,也可以在進入 catch 分支之後執行一些複雜操作再返回所需要的值:

fn doAThing(str: []u8) void {
	const number = parseU64(str, 10) catch blk: {
		break :blk 13;
	};
	_ = number;
}

Try

try 用來在出現錯誤的時候直接把錯誤返回給上一層,若沒有錯誤則正常運行。

fn doAThing(str: []u8) !void {
	const number = try parseU64(str, 10);
	_ = number;
}

try 會去評估錯誤聯合類型的表達式,若表達式的結果為錯誤,則直接從當前函數返回該錯誤;否則,就會解構並使用其值。

並且因為在這邊的 parseU64 是有可能回傳錯誤的,並且我們又用了 try 關鍵字,代表這個錯誤是有可能被再返回到上一層,也就是 doAThing 這個函數的,所以他的返回值必須加上一個 ! 變成 !void

流程控制

條件

基礎的 ifif elseelse if 語句就不再贅述,就跟 C 或是其他的語言一樣,就不過多贅述。同時有個很常用的功能是用來比較枚舉的類型,跟 C 滿像的,像是:

const Num = enum {
	one,
	two,
	three,
	four,
};

const n = Num.one;
if (n == Num.one) {
	std.debug.print("{}\n", {n});
}

三元表達式

Zig 中沒有像 C 語言那樣的 a ? b : c 的語法,它的三元表達式是透過 if else 來實現的,具體做法如下:

const a = 5;
const b = 4;
const c = if (a == b) 1337 else 7331; 

解構可選類型

if 來解構可選類型的操作十分簡單:

const val: ?u32 = null;
if (val) |real_b| {
	_ = real_b;
}

這樣子我們解構後,我們捕獲到的 real_b 就是 u32 類型了,但是要注意我們捕獲的值是 Read-only 的!如果我們想要修改值的內容,要捕獲對應的指針:

var c: ?u32 = 3;
if (c) |*value| {
	value.* = 2;
}

解構錯誤聯合類型

這部分類似於解構可選類型一樣。

const val: anyerror!u32 = 0;
if (val) |value| {
	try expect(value == 0);
} else |err| {
	_ = err;
	unreachable;
}

以上的程式碼中,else 分支捕獲到的就是錯誤,也就是說 err 的類型就會是我們在第一行宣告的 anyerror。如果只是要檢測錯誤,我們可以這樣寫:

if (val) |_| {} else |err| {
	try expect(err == error.BadValue);
}

同樣的,和解構可選類型一樣,可以捕獲指針來操作值:

var. val: anyerror!u32 = 3;
if (val) |*value| {
	value.* = 9;
} else |_| {
	unreachable;
}

Switch

講完了 if-else 條件語句,來講一下 switch 語句,我們直接看一段程式碼。

const num: u8 = 5;
switch (num) {
	5 => {
		std.debug.print("this is 5\n", .{});
	},
	else => {
		std.debug.print("this is not 5\n", .{});
	},
}

要注意的是,Switch 語句會要求窮盡所有可能的分支,或者要有一個 else 分支來處理未匹配的情況。

以上就是 Switch 語句的最基礎的用法,也有更進階的用法,比如它支持使用 , 分割多個匹配的項目、用 ... 匹配一個範圍、類似循環中的 label 的語法等等,這裡也舉個例子:

const a: u64 = 201;
const b :u64 = 100;

const c = switch (a) {
	1, 2, 3 => 0,
	5...200 => 1,
	201 => blk: {
		const c: u64 = 5;
		break :blk c * 2 + 1;
	},
	else => 9,
}

// result:
// c will be 11

然後他也可以用作為表達式,比如:

const os_msg = switch (builtin.target.os.tag) {
	.linux => "we found a linux user",
	else => "not a linux user...",
};

我們還可以用來捕獲 Tag unions 的標記,我們再舉個例子:

const Point = struct {
    x: u8,
    y: u8,
};
const Item = union(enum) {
    a: u32,
    c: Point,
    d,
    e: u32,
};

var a = Item{ .c = Point{ .x = 1, .y = 2 } };

const b = switch (a) {
    Item.a, Item.e => |item| item,
    Item.c => |*item| blk: {
        item.*.x += 1;
        break :blk 6;
    },
    Item.d => 8,
};

std.debug.print("{any}\n", .{b});

迴圈

基本上就和其他語言一樣,只有些微的語法差異。

For 迴圈

迭代陣列和切片:

const items = [_]i32{ 1, 2, 3, 4, 5 };
var sum: i32 = 0;

for (items) |value| {
	sum += value;
}

注意一下,這邊捕獲到的 value 是唯讀的,如果要修改其值,記得捕獲指針,如下。

迭代陣列和切片,並操作其值:

var items = [_]i32{ 1, 2, 3, 4, 5 };

for (&items) |*value| {
	value.* += 1;
}

迭代數字:

for (0..5) |i| {
	_ = i;
}

迭代索引值:

const items = [_]i32{ 1, 2, 3, 4, 5 };

for (items, 0..) |value, i| {
	_ = value;
	_ = i;
}

迭代多個目標(目標長度需一致):

const items1 = [_]i32{ 1, 2, 3 };
ocnst items2 = [_]i32{ 4, 5, 6 };

for (items1, items2) |i, j {
	_ = i;
	_ = j;
}

同時他也可以作為表達式來使用:

const items = [_]?i32{ 3, 4, null, 5};

const result = for (items) |values| {
	if (value == 5) {
		break value;
	}
} else 0;

While 迴圈

基本使用的話就很基本,就不多做介紹。這邊介紹一些酷的就好。While 迴圈和 if 類似,也會嘗試去解構可選類型,並在遇到 null 時會終止循環。

while (eventuallyNullSequence()) |value| {
	sum2 += value;
} else {
	std.debug.print("meet a null\n", .{});
}

上面的程式碼中,我們還可以使用 else 在碰到第一個 null 的時候被觸發執行,並退出循環。同時,當 |x| 的語法出現在 While 表達式的時候,其條件必須是可選類型。

除此之外,While 迴圈也可以解構錯誤聯合類型。它會自動捕獲錯誤和值,當錯誤發生時,程式會走到 else 分支執行並退出循環。

while (eventuallyErrorSequence()) |value| {
	sum1 += vlaue;
} else |err| {
	std.debug.print("meet an err: {}\n", .{err})
}

鐵人賽期 PoPoo,你今天轉 Po 了嗎?

OK 啦,結束我們的新手訓練營囉!不過當然,因為語法實在是太多太多可以講的了,所以我這三天也不可能說完所有的內容,僅能把一些常用的、需要知道的盡量講完,不過如果之後寫惡意程式有遇到一些我們沒有講解過的語法我會再補充說明的。

從明天開始就會來專注在惡意程式上了,還對語法不熟悉的可以趁今天語法的脆後一篇出來了一次複習三篇!

如果對惡意程式開發或是惡意程式分析有興趣的話,這個系列會很適合你!最後也感謝大家的閱讀,歡迎順手按讚留言訂閱轉發(轉發可以讓朋友們知道你都在讀這種很技術的文章,他們會覺得你好帥好強好電,然後開始裝弱互相吹捧)~明天見!