Conditionally Disabling Code with Comptime in Zig

Mitchell Hashimoto

在 Zig 中用 Comptime 條件式停用程式碼

原文由 Mitchell Hashimoto 發布,訂閱此部落格

本文是Zig comptime 使用案例系列的其中一篇。

Zig 有一個非常強大的功能叫做comptime。comptime 讓你能在編譯時期執行 Zig 程式碼。這不是什麼特殊的巨集語言或 AST 操作,就只是會在編譯時期執行的標準 Zig 程式碼。唯一的限制是 comptime 程式碼不能有副作用(不能呼叫 syscall、不能做 IO 等等)。

老實說,剛開始研究 Zig 的時候,我覺得這只是個噱頭。我心想:「到底有多少事情會真的想在 comptime 做呢?」用了兩年 Zig 來開發有一定規模的專案後,comptime 已經無所不在,而且是我最喜歡的 Zig 特性。

comptime 本身是個很大的主題。所以與其全面深入探討 comptime,我想先介紹一個特別實用的模式:用 comptime 條件式地停用程式碼。


為什麼要條件式地停用程式碼?

條件式地停用程式碼是軟體開發中常見的模式。以下是幾個很常見的原因:

  1. 平台相關的程式碼:你可能有個函式在 macOS 和 Linux 上的實作不同。

  2. 除錯用的程式碼:你可能有些程式碼只在除錯時有用,會想在正式版建置中停用它。

  3. 建置設定:你可能想根據建置時期的設定來省略或調整某個功能。

在 Python 或 JavaScript 這類動態語言中,通常只要在執行時期用一個基本的 if 敘述就能選擇正確的程式碼路徑。因為動態語言只會在執行時期才評估程式碼,所以可以避免走到可能無法運作的路徑。

然而在編譯式語言中,你不能用 if 敘述來條件式地停用程式碼,因為編譯器必須編譯並連結所有在執行時期可能會走到的程式碼路徑。

除了讓程式能編譯過,在編譯時期就省略程式碼還有個好處:可以讓執行檔更小,也能避免可能帶來效能成本的執行時期條件判斷。


非 Zig 的作法

我們來看看其他編譯式語言如何處理這個問題。如果你對這部分沒興趣,也可以直接跳到下一節,看我們如何用 Zig 的 comptime 來解決這個問題。

C 系語言

在 C 系語言中,經常會看到用前置處理器來條件式地停用程式碼。舉個例子:

#define FLAG

void my_function() {
    #ifdef FLAG
    // This code will only be included if FLAG is defined.
    #else
    // This code will only be included otherwise.
    #endif
}

這實際上就是在編譯時期對程式碼做樣板化。前置處理器會先執行,在文字層級上轉換程式碼,然後編譯器才會編譯它。第一個主要缺點是,前置處理器本身就是一套有自己語法與規則的獨立語言。

第二個主要缺點是,前置處理器的能力非常有限。C 程式設計師通常得依賴功能強大的建置系統來產生正確的前置處理器定義。而建置系統本身通常又有自己的語言來產生這些定義。

Go

在 Go 中,編譯時期的程式碼省略是以檔案為單位,透過 build tag 來完成的。舉個例子:

// +build mytag

func myFunction() {
    // This code will only be included if the file is built with the "mytag"
    // build tag.
}

對於平台相關的程式碼,也可以使用檔名後綴來區分。這不是 Go 教學,所以我就不細談了,重點是條件式編譯是以檔案為單位來處理的。

對於這種作法有各種主觀的看法。我認為其中客觀上不好的部分是,你仍然得用某種前置處理器語言來決定是否要包含某個 tag,而這通常又得交給建置系統、Makefile 等等去處理。


Zig 的 Comptime

Zig 的 comptime 讓你直接用 Zig 來條件式地停用程式碼。以下是平台相關程式碼最簡單的範例:

const builtin = @import("builtin");

fn myFunction() void {
    if (comptime builtin.os.tag == .macos) {
        // This code will only be included if the target OS is macOS.
        return;
    }

    // This code will be included for all other operating systems.
}

首先要注意的是,這段程式碼就是標準的 Zig。沒有前置處理器語言,也沒有特殊語法。其次,Zig 編譯器夠聰明,知道因為這個 if 敘述以提早離開作結,所以如果條件恆為真,就不需要再編譯後面的東西。在這個例子中,如果目標 OS 是 macOS,編譯器就只會編譯第一個區塊。

關於 comptime 關鍵字的說明:上面的 comptime 關鍵字其實是不必要的。如果所有可用的資訊在編譯時期就已經確定,Zig 會自動在編譯時期評估條件。由於 builtin 全是編譯時期常數,所以 comptime 關鍵字是多餘的。我加上它只是為了讓範例更明確。

以下是來自我正在開發的一個實際專案的更複雜範例:

if ((comptime adwaita.versionAtLeast(1, 4, 0)) and
    adwaita.enabled(&config) and
    adwaita.versionAtLeast(1, 4, 0)) {
    // This code will only be included if we're building with
    // libadwaita available and the version is at least 1.4.0
    // at both build and runtime.
}

這展現了 comptime 就是標準 Zig 的威力。在這個例子中,我們混合了 comptime 和非 comptime 的條件。第一個條件是 comptime,其餘則是執行時期的條件。

第一個 comptime 條件會檢查我們的建置設定是否已啟用 libadwaita,而且建置時期擁有的版本至少是 1.4.0。如果這個條件為假,Zig 編譯器就知道其餘條件再怎麼樣也不可能讓這個區塊被包含進來,所以根本不會去編譯區塊內的其餘程式碼。

重要的是,這代表我在執行時期的條件以及程式碼區塊中都可以放心使用 adwaita 函式庫,即使函式庫不存在、版本不對,或我參考了只存在於新版函式庫中的型別,也不會造成編譯錯誤。

為什麼要呼叫兩次 adwaita.versionAtLeast(1, 4, 0) 第一次是建置時期的檢查,確認我們有可用的 libadwaita 且版本至少為 1.4.0。第二次則是執行時期的檢查,確認動態連結的函式庫版本至少為 1.4.0。這讓我們可以在函式庫版本較新的系統上編譯這段程式碼,然後在版本較舊的系統上執行,也能處理其他各種組合情況。

為了更凸顯這點,來想想如果在 C 裡要怎麼做。在 C 中,我們大概會用像 CMake 這樣的建置系統,它會產生一個一次性的小程式並嘗試編譯,以決定是否要定義某個前置處理器旗標。唉。🤮


Comptime 的陷阱

使用 comptime 來條件式地停用程式碼時,有些陷阱需要注意。首先,comptime 不會跨越函式邊界,除非使用了 comptime 關鍵字。舉例來說,如果我們把上面的條件抽成一個函式,就不會如預期運作:

fn myFunction() void {
    if (hasFeature()) {
        // Feature-specific code.
    } else {
        // Default code.
    }
}

fn hasFeature() bool {
    return false;
}

在上面的例子中,即使 hasFeature 明顯回傳 false,編譯器還是會編譯條件式兩個分支的程式碼。要修正這個問題,你可以在函式呼叫前加上 comptime

fn myFunction() void {
    if (comptime hasFeature()) {
        // Feature-specific code.
    } else {
        // Default code.
    }
}

不過,如果 hasFeature 混合了 comptime 和非 comptime 的條件,這樣做就沒用了。這種情況下,你需要將函式 inline:

fn myFunction() void {
    if (hasFeature()) {
        // Feature-specific code.
    } else {
        // Default code.
    }
}

inline fn hasFeature() bool {
    return (comptime comptimeCheck()) and runtimeCheck();
}

這樣就能如預期運作了:如果 comptime 檢查為假,編譯器就不會包含該功能相關的程式碼。

這個細微的行為很容易被忽略。每當我有像這樣的 comptime 檢查時,我一定會在 CI 中同時測試兩個分支,確保程式在兩種環境下都能建置成功。


Comptime 真的很棒

我很愛 comptime。這是一個直到擁有後才發現自己需要的功能,也是我在用其他語言時會想念的功能。這篇文章只展示了 comptime 的一個特定使用案例,但就算 comptime 就只有這個用途,也已經是個殺手級功能了。

透過 comptime 進行條件式編譯,讓我能夠在共用相同檔案與大量相同程式碼的同時,撰寫跨平台的程式。我不需要複雜的建置系統或外部工具來管理平台相關的程式碼。只要寫 Zig 程式碼,剩下的就交給編譯器處理。

除此之外,comptime 還能做非常多其他事情,也有許多其他強大的特性。舉例來說,comptime 中的型別會符合目標系統(所以像指標大小這類資訊都是正確的)。還有更多精彩之處!我鼓勵你去看看 Zig 文件和其他部落格文章來了解更多,也歡迎試試 Zig!

本文章由 muse-spark-1.2-contributor 進行翻譯

留言