Conditionally Disabling Code with Comptime in Zig

Mitchell Hashimoto

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

本文是關於Zig comptime(編譯期) 應用案例系列的其中一篇。

Zig 有一個非常強大的功能叫做comptime。Comptime 能讓你在編譯期執行 Zig 程式碼。這不是特殊的巨集語言或 AST 操作;它就是在編譯期執行的標準 Zig 程式碼。唯一的真正限制是 comptime 程式碼不能有副作用(不能有系統呼叫、不能有 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 mytag

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

對於平台專屬程式碼,你也可以使用檔名後綴。這不是 Go 教學,所以我不會深入細節,但重點是條件式編譯是在檔案層級進行的。

對於這種作法有各種主觀的看法。我認為這種作法客觀上糟糕的地方在於,你仍然得使用某種前置處理器語言來決定是否要包含某個標籤,而這通常會被推給建置系統、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 敘述以離開條件作結,那麼如果條件顯然為真,就不需要編譯其他部分。在這個例子中,如果目標作業系統是 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。如果此條件為 false,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 的條件,這樣做就行不通了。在這種情況下,你需要將函式內聯:

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

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

這樣就能如預期般運作:如果 comptime 檢查為 false,編譯器就不會包含該功能專屬的程式碼。

這種細微的行為很容易被忽略。每當我有像這樣的 comptime 檢查時,我總是會在 CI 中加入兩個分支,以確保我的程式在兩種環境下都能建置。


Comptime 很棒

我熱愛 comptime。這是一個直到擁有後才發現自己需要的功能,也是我在其他語言中工作時會想念的功能。這篇部落格文章只展示了 comptime 的一種特定應用案例,但即使 comptime 只有這個用途,它依然是一個殺手級功能。

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

同樣地,comptime 還有很多其他用途,也有許多其他強大的特性。例如,comptime 中的型別會符合目標系統(因此像指標大小這類資訊都是正確的)。還有更多精彩之處!我鼓勵你去看看 Zig 文件和其他部落格文章以了解更多,並試試看 Zig!

原文由 Mitchell Hashimoto 發布

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