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。如果此条件为假,那么 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 显而易见为假,编译器仍会构建条件语句两个分支中的代码。要修复这个问题,你可以在函数调用前加上 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 检查为假,编译器将不会包含特定功能的代码。

这种微妙的行为很容易被忽略。每当我有这样的 comptime 检查时,我总是会在 CI 中同时加入两个分支,以确保我的程序在两种环境下都能构建。


comptime 非常棒

我喜欢 comptime。这是一个直到拥有它之后才意识到自己需要的功能,也是在使用其他语言时会想念的功能。这篇博文只展示了 comptime 的一个具体用例,但即使 comptime 仅有这个用途,它也依然是一个杀手级功能。

通过 comptime 进行条件编译,让我能够编写跨平台的代码,同时共享相同的文件和大量相同的代码。我不需要复杂的构建系统或外部工具来管理平台相关代码。我只需编写 Zig 代码,剩下的就交给编译器处理。

同样,comptime 还有很多其他可以做的事情,以及许多其他强大的特性。例如,comptime 中的类型与目标系统相匹配(因此指针大小等都是正确的)。还有更多!我鼓励你查看 Zig 文档和其他博文来了解更多,并尝试一下 Zig!

原文由 Mitchell Hashimoto 发布

本文章由 muse-spark-1.2-contributor 进行翻译