Zig comptime 棒极了

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

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

引子

编程通过自动化地处理数据极大地提升了生产力。而元编程则让我们可以像处理数据一样处理代码,以此将编程的力量反向作用于编程自身。而在底层编程中,我想元编程可能带来最大的优势,因为那些高级概念必须得精确映射到某些低级操作。然而,除了函数式编程语言外,我一直觉得各编程语言对元编程的实现并不理想。因此,当我看到 Zig 把元编程列为一个主要特性时,我提起了很大的兴趣。

说实话,刚开始使用 Zig 的 comptime 时,我的体验相当糟糕。那些概念对我而言很陌生,而想要实现预期的效果也很困难。不过后来,当我转换了思路,一切都迎刃而解了,由此,我突然就喜欢上了它。现在,为了帮助你更快地走上这条探索之路,下面我将介绍六种不同的“视角”来理解 comptime。每个视角都从不同的角度,帮助你将已有的编程知识应用到 Zig 中。

这并不是一本完整涵盖了 comptime 的所有所需知识的详细指南。相反,它更侧重于提供多种策略,从不同视角帮助你全面地理解该如何以 comptime 的角度思考问题。

为了明确起见,所有示例都是有效的 Zig 代码,但示例中的转换只是概念性的,它们并不是 Zig 实际的实现方式。

视角0: 忽略它

我说我喜欢这个特性,却又立刻叫你忽略它,这确实有点怪。但我认为此处正是 Zig comptime 威力所体现的地方,所以我将从这里出发。Zig Zen 中的第三条是“倾向于阅读代码,而不是编写代码。”确实,能够轻松地阅读代码在各种情况下都很重要,因为它是建立概念理解的基础,而这种理解也是调试或修改代码所必需的。

元编程很容易让人陷入“只写代码”的境地。如果你在使用基于宏的元编程或代码生成器,那么代码就会变成两种版本:源代码和展开后的代码。这个额外的间接层使得从阅读到调试代码的整个过程都变得更加困难。当你要改变程序的行为时,你不仅需要确定生成的代码应该是什么样的,还需要弄清楚该如何通过元编程来生成这些代码。

但在 Zig 中,这些额外的开销是完全不需要的。你可以简单地忽略代码在不同时间执行这一隐形的前提条件,而在概念上直接将运行时和编译时的区别忽略掉再来理解那些代码。为了演示这一点,让我们一步一步来看两个不同的代码示例。第一个是普通的运行时代码,第二个则是利用了 comptime 的代码。

普通的运行时代码

1
2
3
4
5
6
7
8
pub fn main() void {
    const array: [3]i64 = .{1,2,3};
    var sum: i64 = 0;
    for (array) |value| {
        sum += value;
    }
    std.debug.print("array's sum is {d}.\n", .{sum});
}

点击“下一步”逐步执行程序,观察状态的变化。这个例子很简单:对一组数字求和。现在我们来做些奇怪的事:对一个结构体的字段求和。虽然这个例子有些牵强,但却能够很好地展示这一概念。

基于 comptime 的代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
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("struct's sum is {d}.\n", .{sum});
}

与数组求和的例子相比,这个 comptime 示例引入的新东西几乎是微不足道的。这正是 comptime 的重点!这段代码的可执行文件效率和你在 C 中为结构体类型手写一个求和函数一样高效,而它却看起来像是你在使用支持运行时反射的语言编写的。虽然这不是 Zig 实际的工作方式,但这也不完全是一个纯粹的理论练习:Zig 核心团队正在开发一个调试器,允许你像这个例子一样逐步执行混合了编译时和运行时的代码。

Zig 中有很多基于 comptime 且远远不止这样简单的类型反射,但你只需要阅读那些代码、完全无需深入了解其中有关 comptime 的细节就可以理解它们在干什么。当然,如果你想使用 comptime 编写代码,则不能仅仅止步于此,让我们继续深入。

视角1: 泛型

泛型在 Zig 中并不是一个特定的功能。相反,Zig 中的仅仅一小部分的 comptime 特性就可以提供用来处理你进行泛型编程所需的一切。这种视角虽然不能让你完全理解 comptime,但它确实为你提供了一个入口点,借此,你可以完成基于元编程的许多任务。

要使一个类型成为泛型,只需将其定义包裹在一个接受类型并返回类型的函数中。(译注:由于 Zig 中类型是一等公民,所以面向类型的编程是合法且常见的)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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("struct's sum is {d}.\n", .{my_struct.sumFields()});
}

泛型函数也可以如此实现。

1
2
3
4
5
6
7
8
9
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("Answer: {d}{d}\n", .{a, b});
}

当然,也可以通过使用特殊类型 anytype 来推断参数的类型,而这通常在参数的类型对函数签名的其余部分没有影响时使用。(译注:此时要限制 a, b, c 的类型相同,所以此处不用 anytype )

视角2:编译时运行的标准代码

这是一个古老的故事: 增加一种自动执行命令的方法。当然,你还需要变量。 哦,还有条件。 拜托,能给我循环吗?这些看似合理的需求,最终导致这些自动化命令变得越来越复杂,甚至演变成一个完整的宏语言。 但 Zig 不同, 在运行时、编译时,甚至是构建系统中都使用了相同的语言。

考虑经典的 Fizz Buzz。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
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 算法时,人们总是忽略一个事实:标准的 Fizz Buzz 问题只需要输出前100个数字的结果。既然输出是固定的,那为什么不直接预先计算出答案,然后输出呢?(由此,我时常认为那些有关优化讨论有些滑稽的。) 我们可以使用相同的 Fizz Buzz 函数来实现这一点。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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 来计算写入的字节数(但会丢弃实际写入的字节),以确定总长度。然后再根据该长度创建 full_fizzbuzz 数组来保存实际数据。

仅对关键部分进行计时,预计算版本的运行速度约快 9 倍。当然,这个例子过于简单,以至于总执行时间受到很多其他因素的影响,但你不难借此明白这其中 comptime 对于性能优化的意味。

comptime 和运行时之间有一些小的区别。比如,只有 comptime 可以访问类型为 comptime_int、comptime_float 或 type 的变量。此外,一些函数只有 comptime 参数,这使它们仅限于编译时环境。相对的,只有运行时才能进行系统调用和那些依赖系统调用的函数。如果你的代码不使用这些特性,那么它在编译时和运行时中的表现将是一样的。

视角3:程序特化

译者注:程序特化(Partial Evaluation)是一种编译优化技术,主要是:在编译期预先计算部分表达式或代码路径,以减少运行时计算开销,提前生成更具体的代码实现。

现在我们要进入有趣的部分。

译注:请参考下面的代码和代码后的解释理解这句话。

代码求值的一种方式是将输入替换为其运行时值,然后反复将第一个表达式替换为求值形式,直到表达式为基本元素。这在计算机科学理论上下文中很常见,在某些函数式语言中也是如此。作为后续示例的铺垫,我们将使用数组求和来展示这个过程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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("array's sum is {d}.\n", .{sum});
}

程序特化是一种可以向函数传递部分(但不一定是全部)参数的技术。 在这种情况下,可以对只使用已知值的表达式进行替换。 这样就产生了一个新函数,它只接受仍然未知的参数。 comtime 可以看作是在编译过程中进行的部分求值。 再看一下 sum 结构的例子,我们就会发现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
onst 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 的字段时,记得更新 sum 函数"这样的由于依赖于 MyStruct 具体字段而预防功能失效的注释。 基于 comptime 的版本在 MyStruct 的任何字段变更时都可以正确地自动处理。

视角4:Comptime 求值,运行时代码生成

这与程序特化(Partial Evaluation)非常相似。这里有两个版本的代码,输入(编译前)和输出(编译后)。输入代码由编译器运行。如果一个语句在编译时是可知的,它就会被直接求值。但是如果一个语句需要某些运行时的值,那么这个语句就会被添加到输出代码中。

让我们以数组求和为例来说明这个过程:

输入这一段代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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;
    }
};

生成出的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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 语句,会直接输出两条路径。

自然,这样做的后果是死代码永远不会被语义分析。也就是说,一个无效的函数并不总是会在实际被使用之前产出相应的编译错误。(对此你可能需要适应一段时间)然而,这也使得编译更加高效(译注:部分地弥补了 Zig 暂不支持增量编译的缺陷),并允许更自然的外观条件编译,这里没有 #ifdef (译注:谢天谢地~)!

值得注意的是, comptime 在 Zig 的设计中是个很基本的设计, 所有的 Zig 代码都通过这个虚拟机运行,包括没有明显使用 comptime 的函数。 即使是简单的类型名称,如函数参数,实际上也是在 comptime 中评估类型变量的表达式。 这就是上面泛型示例的工作原理。 这也意味着您可以酌情使用更复杂的表达式来计算类型。

这样做的另一个后果是,Zig 代码的静态分析要比大多数静态类型语言复杂得多,因为编译器需要运行很大一部分才能确定所有类型。 因此,在 Zig 工具链跟上之前,代码自动补全等编辑工具并不总是能很好地发挥作用。

视角5:直接生成代码(Textual Code Generation)

我在文章开头感叹元编程难度。然而,即使在 Zig 中,它仍然是一个强大的工具,在解决某些问题方面也占有一席之地。如果您熟悉这种元编程方法,对 Zig comptime 提供的功能可能会觉得有些残缺。比如, 怎么在写一段代码在运行时能够生成新代码?

但等等,上一个例子不就是这样吗? 如果你以正确的方式看待问题,写代码的代码和混合运行时代码之间存在着潜在的等价关系。

下有两例。第一个是一个元编程的示例,第二个是我们熟悉的 comptime 示例。这两个版本的代码有着相同的逻辑。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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", .{});
}

注意这里有两个转换:

  1. 在生成器中直接运行的代码是 comptime 的一部分
  2. 在生成器执行后输出的代码,成为运行时的一部分

我喜欢这个示例的另一点是,它展示了在 Zig 中使用类型信息作为输入来生成代码是多么简单。这个例子略过了类型名称和字段名称信息的来源。如果你使用其他形式的输入,比如 Zig 提供了 @embedFile,你可以像平常一样解析它。

回到泛型的例子,有一些值得强调的细微之处:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
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", .{});
}

以上 struct 字段的生成体现了上述两种转换方式,并且将两者混合在了一行中。 字段的类型表达式由生成器/运行时完成,而字段本身则作为运行时代码使用的定义。

在 comptime 下,引用类型名称的方式更加直接,可以直接使用函数,而不必将文本拼接成一个在代码生成中保持一致的名称。

这种观点有一个例外。 您可以创建字段名称在编译时就已确定的类型,但这样做需要调用一个内置函数,该函数包含一个字段定义列表。 因此,您无法在这些类型上定义方法等声明。 在实践中,这并不会限制代码的表达能力,但确实限制了你可以向其他代码公开哪些类型的 API。

与本节相关的是文本宏,如 C 语言中的文本宏。你可以做的大多数正常事情都可以在 comptime 中完成,尽管它们很少采用类似的形式。 不过,文本宏并不能做所有允许做的事情。 例如,你不能决定不喜欢某个 Zig 关键字,然后让宏代替你自己的关键字。 我认为这是一个正确的决定,尽管对于那些习惯了这种能力的人来说,这是一个艰难的过渡。 此外,Zig 参考了半个世纪以来的程序员在这方面的探索,所以它的选择要理智得多。

结论

在阅读 Zig 代码以理解代码行为时,考虑 comptime 并不是必要的。而当编写 comptime 代码时,我通常会将其视为程序特化(Partial Evaluation)的一种形式。然而,如果你知道如何使用不同的元编程方法解决问题,你很可能有能力将其翻译成 comptime 形式。

元编程中直接生成代码的方法的存在,就是我全力支持 Zig 风格的 comptime 元编程的原因。尽管,直接生成代码是几乎是最强大的,但是,在阅读和调试时忽略 comptime 的特性的元编程方法确是最简单的。正因如此,我给本文取名为《Zig comptime 棒极了》。

进一步阅读

Zig 并非一个仅仅依赖 comptime 这一特性的语言。你可以在官方网站上了解更多关于 Zig 的信息。

在这篇文章中,我多次使用相同的例子来展示不同的转换方式(代码->编译时和运行时),以简化展示的过程。这样做的缺点是,尽管谈论了很多,但实际上我并没有展示太多相关的内容。而语言参考文档详细介绍了编译时的具体特性。

如果您想看到更多示例,我建议您阅读一些 Zig 的标准库代码。以下是一些供有兴趣者参考的链接:

  • std.debug.print 是一个强大的泛型函数。许多语言在运行时解析它们的格式字符串,并很可能为字符串格式添加了一些特殊的效验器,以尽早捕获错误。而在 Zig 中,格式字符串是在编译时解析的,这样不仅生成了高效的最终代码,还在编译时完成了所有的校验。
  • ArrayList 是一个实现相对简单但功能齐全的泛型容器。

Zig 的函数可以具有几种不同的返回类型。但是,这并不是依赖于编译器中的某些魔法的操作,而只是典型的 comptime 的应用

如果您希望就本篇文章向我提出意见或更正,请发送电子邮件至 blogcomments@scottredig.com。 译者注:如果觉得翻译有问题,请提 PR 改正:https://github.com/zigcc/zigcc.github.io