Zig 的 comptime 太强了

2025-01-23

xihale (翻译) | 2025-01-23

原文:https://www.scottredig.com/blog/bonkers_comptime/

译注:原文中的代码块是交互式,翻译时并没有移植。另外,由于 comptime 本身即是关键概念,并且下文的意思更侧重于 Zig comptime 的特性,故下文大多使用 comptime 代替编译时概念。

引言

编程让数据处理自动化成为可能,大幅提升了生产力。而元编程更进一层——它让我们能像操作数据一样操作代码本身。在系统编程领域,元编程的潜力尤其大,因为高级抽象必须精确映射到硬件层面。不过我发现,除了函数式语言,大多数语言的元编程体验都不怎么样。所以当 Zig 把元编程作为核心特性时,我立刻来了兴趣。

刚开始用 Zig 的 comptime 时,体验挺糟糕的。概念陌生,想实现点什么都很费劲。但后来我找到了正确的理解方式,突然一切都通了,一下子就喜欢上了。为了让你少踩坑,下面我用六种不同”视角”来解释 comptime。每个视角都从不同角度切入,帮你把已有的编程经验迁移到 Zig。

这不是 comptime 的完整参考手册。相反,它提供多种思考策略,帮你建立以 comptime 为核心的思维方式。

为了清晰,所有示例都是有效的 Zig 代码,但其中的转换只是为了说明概念——并不是 Zig 实际的工作方式。

视角 0:别管它

我说我喜欢 comptime,转头又说可以忽略它,这听着有点矛盾。但我觉得这正是 Zig comptime 最厉害的地方。

Zig 哲学第三条说”倾向于阅读代码,而不是编写代码”。能轻松读懂代码在任何情况下都至关重要——这是理解概念、调试和修改的基础。

元编程很容易让人陷入”只写难读”的境地。用宏系统或代码生成器时,代码会有源码和展开后两个版本。这个额外间接层让阅读和调试都变难。你想改程序行为时,不仅要搞清楚生成的代码该长什么样,还得琢磨怎么通过元编程生成它们。

但在 Zig 里,这些开销都不需要。你可以直接忽略”代码在不同时间执行”这个隐藏前提,把编译时和运行时的区别当作透明层来理解。来看一个渐进式的例子。

先看普通的运行时代码:

pub fn main() void {
    const array: [3]i64 = .{1, 2, 3};
    var sum: i64 = 0;
    for (array) |value| {
        sum += value;
    }
    std.debug.print("数组的和是 {d}。\n", .{sum});
}

这个很简单:求一组数的和。现在做点奇怪的——求结构体所有字段的和。虽然有点牵强,但它很好地展示思路。

再看一个真正基于 comptime 的实现:

const MyStruct = struct {
    a: i64,
    b: i64,
    c: i64,
};

pub fn main() void {
    const my_struct: MyStruct = .{
        .a = 1,
        .b = 2,
        .c = 3,
    };

    var sum: i64 = 0;
    inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| {
        sum += @field(my_struct, field_name);
    }
    std.debug.print("结构体的和是 {d}。\n", .{sum});
}

跟数组求和的例子比,这个 comptime 版本只多了几个关键字。这正是 comptime 的精髓!

这段代码生成的机器码效率等同于你在 C 里手写一个求和函数,但它看起来像是在用支持运行时反射的语言编程。虽然这不是 Zig 实际的工作方式,但也不是纯理论:Zig 团队正在开发一个调试器,可以让你像这样一步步执行混合了编译时和运行时的代码。

Zig 有很多基于 comptime 的神奇操作,远不止简单的类型反射。关键是你可以直接阅读那些代码,完全不用理解背后 comptime 的细节。当然,如果你想写 comptime 代码,还是得深入了解。继续。

视角 1:泛型

泛型在 Zig 里不是独立功能。恰恰相反,只用 comptime 的一小部分特性就足够实现所有泛型编程。这个视角虽然不能让你完全掌握 comptime,但它提供了一个切入点,让你能用元编程解决很多实际问题。

要让一个类型变成泛型,只需把它包在一个函数里——这个函数接受类型参数,返回类型。(译注:Zig 里类型是一等公民,所以”面向类型的编程”是完全合法的)

pub fn GenericMyStruct(comptime T: type) type {
    return struct {
        a: T,
        b: T,
        c: T,

        fn sumFields(my_struct: GenericMyStruct(T)) T {
            var sum: T = 0;
            const fields = comptime std.meta.fieldNames(GenericMyStruct(T));
            inline for (fields) |field_name| {
                sum += @field(my_struct, field_name);
            }
            return sum;
        }
    };
}

pub fn main() void {
    const my_struct: GenericMyStruct(i64) = .{
        .a = 1,
        .b = 2,
        .c = 3,
    };
    std.debug.print("结构体的和是 {d}。\n", .{my_struct.sumFields()});
}

泛型函数也是同样的道理:

fn quadratic(
    comptime T: type,
    a: T,
    b: T,
    c: T,
    x: T
) T {
    return a * x * x + b * x + c;
}

pub fn main() void {
    const a = quadratic(f32, 21.6, 3.2, -3, 0.5);
    const b = quadratic(i64, 1, -3, 4, 2);
    std.debug.print("结果:{d} 和 {d}\n", .{a, b});
}

当然,你也可以用特殊的 anytype 让编译器推断参数类型。这通常用在对函数签名其他部分没有影响的参数上。(译注:比如这里的 a、b、c、x 都要是同一种类型,所以直接指定 T 更合适)

视角 2:在编译时运行普通代码

这是个老故事了:想加个自动执行命令的功能?好,还得有变量。哦,再加条件判断。等等,循环呢?这些看似合理的需求最终把简单的宏系统搞成了怪物。但 Zig 不同——运行时、编译时甚至构建系统都用的是同一种语言。

来看经典的 Fizz Buzz 问题:

fn fizzBuzz(writer: std.io.AnyWriter) !void {
    var i: usize = 1;

    while (i <= 100) : (i += 1) {
        if (i % 3 == 0 and i % 5 == 0) {
            try writer.print("fizzbuzz\n", .{});
        } else if (i % 3 == 0) {
            try writer.print("fizz\n", .{});
        } else if (i % 5 == 0) {
            try writer.print("buzz\n", .{});
        } else {
            try writer.print("{d}\n", .{i});
        }
    }
}

pub fn main() !void {
    const out_writer = std.io.getStdOut().writer().any();
    try fizzBuzz(out_writer);
}

确实很简单。但每次讨论 Fizz Buzz 优化时,人们总忘了这问题只需输出前 100 个数字的结果——既然输出是固定的,为什么不提前算好呢?(所以我觉得那些优化讨论有点小题大做)

我们可以用同一个 fizzBuzz 函数来实现预计算:

pub fn main() !void {
    const full_fizzbuzz = comptime init: {
        var cw = std.io.countingWriter(std.io.null_writer);
        fizzBuzz(cw.writer().any()) catch unreachable;

        var buffer: [cw.bytes_written]u8 = undefined;
        var fbs = std.io.fixedBufferStream(&buffer);
        fizzBuzz(fbs.writer().any()) catch unreachable;

        break :init buffer;
    };

    const out_writer = std.io.getStdOut().writer().any();
    try out_writer.writeAll(&full_fizzbuzz);
}

这里 comptime 关键字表示它后面的代码块在编译期间运行。这个块被标记为”init”,以便通过 break 语句产出值。

我们先用一个 null_writer 来计算要写多少字节(但丢弃数据),然后根据这个长度创建正好大小的 buffer 来保存实际内容。

只计关键部分的执行时间,预计算版本大概快 9 倍。当然这个例子太简单了,总执行时间受很多因素影响,但你能从中感受到 comptime 对性能的意义。

comptime 和运行时之间有些小区别:比如只有 comptime 能访问 comptime_int、comptime_float 或 type 类型的变量;有些函数只接受 comptime 参数,只能在编译时使用。相反,只有运行时才能做系统调用和依赖系统调用的函数。如果你的代码不涉及这些,它在 comptime 和运行时表现完全一样。

视角 3:程序特化

(Partial Evaluation 是一种编译优化技术:在编译期预先计算部分表达式或代码路径,减少运行时开销,生成更具体的版本)

现在进入更有趣的部分。

代码求值的一种思路是:把输入替换成它的运行时值,然后反复把第一个表达式替换成求值结果,直到表达式变成基本元素。这在函数式编程理论里很常见。接下来我用数组求和来展示这个过程:

pub fn main() void {
    const array: [3]i64 = .{1, 2, 3};
    var sum: i64 = 0;

    for (array) |value| {
        sum += value;
    }
    // 展开后相当于:
    {
        const value = array[0];
        sum += value;
    }
    {
        const value = array[1];
        sum += value;
    }
    {
        const value = array[2];
        sum += value;
    }

    std.debug.print("数组的和是 {d}。\n", .{sum});
}

程序特化可以接受函数的部分(但不一定是全部)参数。对于那些只使用已知值的表达式,直接替换成结果。这就产生了一个新函数,只接受仍然未知的参数。comptime 可以看作是在编译过程中进行的一部分求值。

再看结构体求和例子:

const MyStruct = struct {
    a: i64,
    b: i64,
    c: i64,

    fn sumFields(my_struct: MyStruct) i64 {
        var sum: i64 = 0;
        inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| {
            sum += @field(my_struct, field_name);
        }

        // 展开后相当于:
        {
            const field_name = "a";
            sum += @field(my_struct, field_name);
        }
        {
            const field_name = "b";
            sum += @field(my_struct, field_name);
        }
        {
            const field_name = "c";
            sum += @field(my_struct, field_name);
        }

        // 进一步简化:
        sum += my_struct.a;
        sum += my_struct.b;
        sum += my_struct.c;

        return sum;
    }
};

上面这个展开过程是手动演示的,实际上是由 Zig 的 comptime 自动完成的。好处是你可以直接写出独立、完整的实现逻辑,不需要写”如果修改了 MyStruct 的字段,记得更新 sumFields”这种维护性注释。

基于 comptime 的版本会在 MyStruct 的任何字段变更时自动正确处理,这就是程序特化的思想。

视角 4:comptime 求值与运行时代码生成

这和程序特化很像。想象有两个版本的代码:编译前的(输入)和编译后的(输出)。输入代码由编译器运行。如果一个语句在编译时就能确定,它就会被直接求值掉;如果需要运行时值,这个语句就会被保留到输出代码里。

还是用结构体求和来演示这个过程:

// 这段是输入代码(由编译器处理):
const MyStruct = struct {
    a: i64,
    b: i64,
    c: i64,

    fn sumFields(my_struct: MyStruct) i64 {
        var sum: i64 = 0;
        inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| {
            sum += @field(my_struct, field_name);
        }
        return sum;
    }
};
// 编译器生成的代码:
const MyStruct = struct {
    a: i64,
    b: i64,
    c: i64,

    fn sumFields(my_struct: MyStruct) i64 {
        var sum: i64 = 0;
        sum += my_struct.a;
        sum += my_struct.b;
        sum += my_struct.c;
        return sum;
    }
};

这才是最接近 Zig 编译器处理 comptime 的方式。主要差别在于:Zig 首先把你的代码语法解析成虚拟机的字节码,然后运行这个虚拟机——这就是 comptime 的实现机制。这个虚拟机能评估任何它能处理的东西,并为需要运行时处理的部分生成新字节码(稍后转换成机器码)。有运行时输入的条件语句,比如 if,会直接输出两条分支。

这样做的副作用是:死代码永远不会被语义分析。也就是说,一个无效的函数不一定会在实际被使用时就报编译错误。(你可能需要适应这点)但这也让编译更高效,并且支持更自然的条件编译——这里没有 #ifdef 那种东西!

值得注意:comptime 在 Zig 的设计中非常底层,所有 Zig 代码(包括那些不明显使用 comptime 的)都会经过这个虚拟机。连简单的类型名(比如函数参数)都是在 comptime 中被评估的表达式。这就是前面泛型示例的工作原理。也正因如此,你可以用更复杂的表达式来计算类型。

另一个后果是:Zig 的静态分析比大多数静态类型语言复杂得多,因为编译器需要运行大量代码才能确定所有类型。所以,在工具链完善之前,IDE 的代码补全等功能可能不太稳定。

视角 5:直接代码生成

我在开头吐槽过元编程的难度。但即使在 Zig 里,元编程仍然是强大的工具,在某些场景下不可或缺。如果你熟悉其他语言的元编程方式,可能会觉得 Zig 的 comptime 功能有些残缺——比如,怎么在运行时生成新代码?

但等等,上一个例子不就是吗?只要换个角度看,“写代码的代码”和”混合编译时和运行时代码”之间其实是等价的。

看两个例子。第一个是元编程生成代码,第二个是我们熟悉的 comptime 版本。两段逻辑等价:

// 元编程版本:代码生成器
pub fn writeSumFn(
    writer: std.io.AnyWriter,
    type_name: []const u8,
    field_names: [][]const u8
) !void {
    try writer.print("fn sumFields(value: {s}) i64 {{\n", .{type_name});
    try writer.print("var sum: i64 = 0;\n", .{});

    for (field_names) |field_name| {
        try writer.print("sum += value.{s};\n", .{field_name});
    }

    try writer.print("return sum;\n", .{});
    try writer.print("}}\n", .{});
}
// 等价的 comptime 版本:
fn sumFields(my_struct: MyStruct) i64 {
    var sum: i64 = 0;
    inline for (comptime std.meta.fieldNames(MyStruct)) |field_name| {
        sum += @field(my_struct, field_name);
    }
    return sum;
}

注意这里两次转换的分界线:

  1. 在生成器里执行的代码属于 comptime
  2. 生成器输出后的代码属于运行时

我喜欢这个例子的另一个原因:它展示了用 Zig 把类型信息当输入来生成代码有多自然。这个例子没展示类型名和字段名从哪来。如果你用其他输入形式,Zig 有 @embedFile 完全可以直接读取和解析。

回到泛型例子,有个值得强调的微妙之处:

pub fn writeMyStructOfType(
    writer: std.io.AnyWriter,
    T: []const u8
) !void {
    try writer.print("const MyStruct_{s} = struct {{\n", .{T});
    try writer.print("a: {s},\n", .{T});
    try writer.print("b: {s},\n", .{T});
    try writer.print("c: {s},\n", .{T});

    try writer.print("fn sumFields(value: MyStruct_{s}) {s} {{\n", .{T, T});
    try writer.print("var sum: {s} = 0;\n", .{T});
    const fields = [_][]const u8{ "a", "b", "c" };

    for (fields) |field_name| {
        try writer.print("sum += value.{s};\n", .{field_name});
    }
    try writer.print("return sum;\n", .{});
    try writer.print("}}\n", .{});
    try writer.print("}};\n", .{});
}

这个例子里结构体字段的生成同时体现了两种转换,而且混合在同一行里。字段的类型表达式是由生成器(运行时)完成的,而字段本身则是作为运行时代码使用的定义。

在 comptime 上下文中,引用类型名称的方式更直接:你直接用类型值就行,不需要像传统代码生成那样把文本拼成一个名字来保持一致。

有一种例外情况:你可以创建字段名在编译时就已经确定的类型,但这需要调用一个内置函数,它接受字段定义列表作为参数。所以你无法在这些类型上定义方法之类的声明。实践中这不会限制表达能力,但确实限制了你向其他代码公开的 API 种类。

说到文本宏——比如 C 那种——你大多数情况下都能在 comptime 里做到,尽管形式不太一样。但文本宏做不到的事,comptime 也做不到。比如你不能因为不喜欢某个 Zig 关键字就写个宏来代替它。我认为这是正确的决定,虽然对习惯了这个能力的人来说会难受一阵。再说,Zig 参考了半个世纪的编程语言设计经验,它的选择要理智得多。

结语

在读 Zig 代码来理解行为时,comptime 基本上不用考虑。而当我写 comptime 代码时,通常把它当作程序特化来用。不过,如果你知道用其他元编程方法怎么解决问题,你很可能能把它转化成 comptime 形式。

“直接代码生成”这种元编程方法的存在,正是我全力支持 Zig 这种 comptime 元编程的原因。直接生成代码几乎是功能最强大的元编程方式,但阅读和调试时”忽略 comptime”的方法又是最简单的。两者兼得,这就是《Zig 的 comptime 太强了》标题的由来。

延伸阅读

Zig 不止 comptime 这一个亮点。去官方网站可以了解更多。

本文多次用同一个例子展示不同转换来简化说明过程,这样做的缺点是:虽然说了很多,但实际没深入太多细节。语言参考文档详细介绍了编译时的具体特性。

想看更多示例,我建议直接读 Zig 标准库代码。这里有几个跳转点:

  • std.debug.print 是个强大的泛型函数。很多语言在运行时解析格式字符串,还会加校验器来尽早抓错。而在 Zig 里,格式字符串在编译时解析,不仅生成了高效的最终代码,还在编译时完成所有校验。

  • ArrayList 是一个实现简单但功能齐全的泛型容器。

Zig 的函数可以有多种返回类型。但这不靠什么编译器魔法,而是 comptime 的典型应用

如果你想对原文提出意见或更正,请发邮件至 blogcomments@scottredig.com。 译者注:如发现翻译问题,欢迎 PR 修改:https://github.com/zigcc/zigcc.github.io