Zig AstGen: AST => ZIR

Mitchell Hashimoto

Zig AstGen:AST => ZIR

本文是 Zig 编译器内部原理 系列的一部分。

在构建完抽象语法树(AST)之后,许多编译器的下一步是生成中间表示(IR)。AST 是一棵树,而 IR 通常开始为程序的各个块(一个文件、一个函数等)创建一个指令序列。这种指令序列的格式更容易进行分析以进行优化,并转换为可执行的机器代码。

Zig 编译器有多种中间表示。第一种生成的 IR 形式称为 Zig 中间表示(ZIR)。AST 通过一个内部称为 “AstGen” 的阶段直接转换为 ZIR。ZIR 是一种无类型的 IR 形式,为每个 Zig 文件生成。

ZIR 长什么样?

在深入探讨 ZIR 的内部结构及其创建过程之前,我们先快速看一个 ZIR 的样例。你现在不需要理解这些内容。

这是一个非常小的 Zig 文件:

const result = 42;export fn hello() u8 {    return result;}

以及该文件生成的 ZIR:

%0 = extended(struct_decl(parent, Auto, {  [24] result line(0) hash(193c9ec04fd3f33e5e849a85f0c3b7fd): %1 = block_inline({    %2 = int(42)    %3 = break_inline(%1, %2)  }) node_offset:1:1  [32] export hello line(2) hash(322ad1340dd6faf97c80f152e26dfdd4): %4 = block_inline({    %11 = func(ret_ty={      %5 = break_inline(%11, @Ref.u8_type)    }, body={      %6 = dbg_stmt(2, 5)      %7 = extended(ret_type()) node_offset:4:5      %8 = decl_val("result") token_offset:4:12      %9 = as_node(%7, %8) node_offset:4:12      %10 = ret_node(%9) node_offset:4:5    }) (lbrace=1:22,rbrace=3:1) node_offset:3:8    %12 = break_inline(%4, %11)  }) node_offset:3:8}, {}, {})

注意:ZIR 不一定是一个稳定的格式,因此上面的输出可能与今天的 Zig 编译器输出不匹配。不过,作为第一次见识 ZIR,已经足够接近了。

关于 ZIR 需要注意的主要一点是,程序的实际逻辑开始被分解为更细粒度的 指令。ZIR 指令可以通过它们的索引来引用,如上面输出中 % 符号后面的内容所示。例如,指令 9 是将 result 引用转换为 hello 函数的返回类型。

想看到更多 ZIR?如果你从源码编译 Zig,可以使用 zig ast-check -t <file> 命令来转储任何 Zig 文件的 ZIR。编写简单的 Zig 结构并转储其 ZIR 是学习 AstGen 步骤的好方法。本页面不会讲解从源码编译 Zig 的步骤。

为什么 ZIR 是无类型的?

ZIR 是一种无类型的中间格式。这并不意味着 ZIR 不感知类型,而是意味着类型尚未完全求值。Zig 的一个语言定义特性是将类型作为一等值使用,并将 comptime 求值作为实现泛型类型的机制。因此,类型有可能在 AST 中以及 ZIR 格式中 仍然是未知的

这在 ZIR 输出示例中最容易看出。首先,让我们看一个 基本有类型 的形式:

const result: u32 = 42;
%0 = extended(struct_decl(parent, Auto, {  [10] a line(0) hash(63c8310741c228c128abf6692d07292d): %1 = block_inline({    %2 = int(42)    %3 = as_node(@Ref.u32_type, %2) node_offset:1:16    %4 = break_inline(%1, %3)  }) node_offset:1:1}, {}, {})

指令 %3 将无类型的 42 值转换为 u32。在这个小例子中,ZIR 看起来是完全有类型的,但仍然存在无类型的指令。指令 %2 是一个 “无类型” 的常量 42。它是无类型的,因为常量 42 仍然可以被强制转换为 u8u16u32 等。直到我们的代码将这个无类型常量赋给一个有类型的常量时,ZIR 才会生成类型强制转换指令 as_node

接下来,让我们看一个 确定无类型 的形式:

const t = bool;const result: t = 42;
%0 = extended(struct_decl(parent, Auto, {  [16] t line(0) hash(243086356f9a6b0669ba4a7bb4a990e4): %1 = block_inline({    %2 = break_inline(%1, @Ref.bool_type)  }) node_offset:1:1  [24] result line(1) hash(8c4cc7d2e9f1310630b3af7c4e9162bd): %3 = block_inline({    %4 = decl_val("t") token_offset:2:10    %5 = as_node(@Ref.type_type, %4) node_offset:2:10    %6 = int(42)    %7 = as_node(%5, %6) node_offset:2:14    %8 = break_inline(%3, %7)  }) node_offset:2:1}, {}, {})

在这个场景中,result 的类型是常量 t。你可能会认为在这个例子中常量 t 是显而易见已知的,但 Zig 允许 t 的赋值是 任意 comptime 表达式(只要所有值都是 comtime 已知的,这几乎就是整个语言)。这是 Zig 一个极其强大的特性。

考虑到动态的可能性,你可以看到赋值给 result 的 ZIR 要复杂得多。指令 %4%5 加载由 t 标识的值,并将其强制转换为 type 类型(一种表示类型而非值的类型)。然后指令 %7 是我们熟悉的 as_node 强制转换,但这次类型操作数引用的是一条 ZIR 指令 %5 的结果,而不是一个静态值。

最后一个更极端的例子:

const std = @import("std");const result: std.ArrayListUnmanaged(u8) = .{};

我不会展示它的 ZIR,因为它非常庞大。result 的类型是通过 comtime 求值函数 ArrayListUnmanaged 生成的泛型类型。ZIR 无法为 result 指向一个预定义的类型,因为它在 comtime 求值之前根本是未知的。

这就是 ZIR 无类型 的原因。ZIR 是为 comtime 求值和进一步语义分析而准备的中间形式。在 comtime 阶段之后,所有类型都将是已知的,可以形成一个完全有类型的中间表示(这称为 AIR,是 AstGen 之后的阶段的结果)。

AstGen 的结构

“AstGen” 是将 AST 转换为 ZIR 的阶段。AstGen 的源码位于 src/AstGen.zig。AstGen 不是公开导出的结构体,它只是用于管理 ZIR 生成过程的内部状态。外部调用者调用 generate 函数,该函数接收一个 AST 树并返回整棵树的 ZIR。

AstGen 结构体有许多用于内部状态的字段。我们不会逐一介绍,但下面展示了一些重要的字段。下面结构体中字段的展示顺序与源码中的顺序不同:

const Astgen = struct {    gpa: Allocator,    arena: Allocator,    tree: *const Ast,    instructions: std.MultiArrayList(Zir.Inst) = .{},    extra: ArrayListUnmanaged(u32) = .{},    string_bytes: ArrayListUnmanaged(u8) = .{},    // other fields, not covered...};

第一组 gpaarenatree 是 AstGen 过程的输入。gpa 用于分配生命周期超过生成过程的数据。arena 用于分配仅在 ZIR 生成期间使用的临时数据,这些数据在将 ZIR 返回给用户之前被释放。而 tree 是正在被转换为 ZIR 的 AST。

为什么 “arena” 叫做 “arena”?“arena 分配器” 是一类内存分配器,它一次性分配和释放整个内存区域,而不是逐个跟踪和释放单个条目。它常用于共享同一生命周期的分配,因为释放整块内存比跟踪单个条目要容易得多,性能也更好。有关分配器和 Zig 的更多基础知识,请参见演讲 “What’s a Memory Allocator Anyways?”

第二组 instructionsextrastring_bytes 是 AstGen 过程的输出。这一组非常重要,因为它是最终生成的 ZIR 的核心结构:

  • instructions 是一个指令列表。该列表中的每个条目对应一条指令。例如,查看本页前面给出的 ZIR 输出,该列表的第 9 个条目就是 as_node(%7, %8) 指令。
  • extra 是 ZIR 指令可能需要存储的额外数据。这与 AST 节点及其 extra_data 字段遵循相同的模式。如果你不理解 AST 的 extra_data,现在是复习的好时机,因为这个模式在 AstGen 中随处可见!
  • string_bytes 是一个用于标识符、字符串字面量、文档注释等的 字符串驻留池。所有静态字符串都作为 AstGen 的一部分被驻留,ZIR 指令引用的是这个字符串字节列表中的偏移量,而不是存储字符串本身的副本。

ZIR 指令的结构

在深入探讨将 AST 转换为 ZIR 的操作之前,我将用大量的篇幅描述单条 ZIR 指令的格式。我建议仔细阅读并理解本节,因为这是理解 ZIR 构建的基础构件。

ZIR 实际上是一个指令列表。指令的格式与 AST 节点所用的模式有很强的相似性,因此本页面可能会略过一些结构细节。在这些情况下,我会特别指出它们的相似之处。

pub const Inst = struct {    tag: Tag,    data: Data,    pub const Tag = enum(u8) {        add,        addwrap,        // many more...    };    pub const Data = union {        // various fields that can be set depending on tag    };};

这与 AST 节点 非常 相似。“tag” 模式与 AST 节点相同,但标签是完全不同的集合。例如,整数字面量的 AST 是 .integer_literal,无论整数的大小如何。对于 ZIR,这转换为 .int.int_big,取决于整数是否能放入 u64

每条指令都有与之关联的数据。data 联合体中的活动字段取决于 tag 值。数据可以直接存储在 data 字段中(例如整数字面量值),或者 data 字段可以是指向其他位置信息的引用。

与 AST 节点一样,ZIR 也有一个 extra 字段。它的行为与 AST 节点的 extra_data 字段大致相同:某些数据条目可能指向 extra 字段中的一个条目,从那里开始的若干字段将包含与 ZIR 指令相关的特定值。更多细节请参见 Zig 解析器页面以及下面的示例。

静态值

最简单的 ZIR 指令是包含静态值的指令。让我们看一个简单静态整数值的 ZIR 以及为其生成的 ZIR。

const x = 42;
%0 = extended(struct_decl(parent, Auto, {  [7] x line(0) hash(5b108956afe84d38689dcd3f2d652f04): %1 = block_inline({    %2 = int(42)    %3 = break_inline(%1, %2)  }) node_offset:1:1}, {}, {})

相关的指令是 %2int(42)。如你所见,静态值 42 被直接编码到指令本身中。在内部,它的表示形式如下所示:

Zir.Inst{    .tag = .int,    .data = .{        .int = 42,    },}

这是 ZIR 指令最简单的形式:数据直接嵌入指令中。在这种情况下,data 的活动标签是 .int,它嵌入了静态整数值。

更常见的情况是数据被引用,或者标签包含无法直接放入 Inst 结构的更复杂信息。下面将展示这方面的示例。

Refs

许多 ZIR 指令包含对值的引用。例如,一元 ! 运算符(布尔非)可以前缀任何表达式,如静态值 !true、变量 !myVar、函数调用 !myFunc() 等。理解 ZIR 如何编码引用非常重要,因为它们非常常见。

ZIR 引用有一个专用类型 Zir.Inst.Ref。这是一个 非穷举枚举。定义的标签用于原始值或非常常见的值。否则,该值是对另一条 ZIR 指令索引的引用。

带标签的 Refs

让我们看一个例子来了解它是如何工作的:

const x = !true;

这将产生以下 ZIR:

%0 = extended(struct_decl(parent, Auto, {  [7] result line(0) hash(0be0bb45d9cb29941abcc19d4176dde6): %1 = block_inline({    %2 = bool_not(@Ref.bool_true) node_offset:1:16    %3 = break_inline(%1, %2)  }) node_offset:1:1}, {}, {})

看指令 %2。它包含我们的 bool_not 指令(! 运算符),参数是 @Ref.bool_truebool_not 指令接受单个操作数。在内部,这是一个大致具有以下结构的 Inst(为示例省略了一些不重要的字段):

Zir.Inst{    .tag = .bool_not,    .data = .{        .un_node = .{ .operand = Ref.bool_true },    },}

Ref.bool_trueRef 枚举的标签之一,表示静态值 true。还有更多这样的标签。例如,每个内置类型都有一个。虽然下面的例子毫无意义,但它确实会产生有效的 ZIR(编译器稍后会对它报错):

const x = !u8; // ZIR: bool_not(@Ref.u8_type)

对于众所周知的原始类型和值,会使用带标签的 Ref 值。

指向指令的 Refs

对于非众所周知的值,Ref 表示的是指令索引。下面是另一个例子及其生成的相关 ZIR。

const input = true;const x = !input;
%0 = extended(struct_decl(parent, Auto, {  [13] input line(0) hash(1af5eb6ed7d8836d8d54ff390bb38c7d): %1 = block_inline({    %2 = break_inline(%1, @Ref.bool_true)  }) node_offset:1:1  [21] result line(1) hash(4ba2a2df9e05a2d7963b6c1eb80fdcea): %3 = block_inline({    %4 = decl_val("input") token_offset:2:17    %5 = as_node(@Ref.bool_type, %4) node_offset:2:17    %6 = bool_not(%5) node_offset:2:16    %7 = break_inline(%3, %6)  }) node_offset:2:1}, {}, {})

查看 ref 的关键指令是 %6。我们可以再次看到熟悉的 bool_not 指令,但这次操作数是对另一条指令的引用:%5。如果你继续沿着指令链追踪,你应该能直观地理解它是在读取 input 作为布尔值的值。

在内部,该指令大致如下所示:

Zir.Inst{    .tag = .bool_not,    .data = .{        .un_node = .{ .operand = Ref.typed_value_map.len + 5 },    },}

或者更简单地说:带标签值的总数加上 5。同样,要确定一个 Ref 值是标签还是索引,我们可以检查该值是否大于标签数量。如果是,则该值减去标签长度即为指令。

这种操作非常常见,因此有两个公共函数用于此目的:indexToRefrefToIndex。在上面的例子中,我们将操作数设置为 indexToRef(5),如果你调用 refToIndex(operand),你会得到 5

const ref_start_index: u32 = Inst.Ref.typed_value_map.len;pub fn indexToRef(inst: Inst.Index) Inst.Ref {    return @intToEnum(Inst.Ref, ref_start_index + inst);}pub fn refToIndex(inst: Inst.Ref) ?Inst.Index {    const ref_int = @enumToInt(inst);    if (ref_int >= ref_start_index) {        return ref_int - ref_start_index;    } else {        return null;    }}

Extra Data

一些 ZIR 指令包含对 extra 字段的引用,该字段有时也被称为 “trailing” 数据。这与 AST 节点中的 extra_data 字段非常相似,因此这里不会详细介绍编码/解码。如果你不了解或不熟悉 AST 节点中的 extra_data 字段,我建议先复习那一部分。

让我们看一个额外数据的示例:

const x = 1 + 2;
%0 = extended(struct_decl(parent, Auto, {  [10] x line(0) hash(48fa081b63af0a1c7f2d11a7bf9fbbc3): %1 = block_inline({    %2 = int(2)    %3 = add(@Ref.one, %2) node_offset:1:13    %4 = break_inline(%1, %3)  }) node_offset:1:1}, {}, {})

这个输出的 ZIR 建立在我们上面已经学过的所有内容之上!有一个静态值(指令 %2),有一个带标签的 ref(指令 %3 中的 @Ref.one),还有一个引用另一条指令的 ref(指令 %3 中的 %2 操作数)。

二元加法运算使用了 extra。在 ZIR 文本输出中这一点并不明显,因为 ZIR 渲染器理解 add 指令并对来自 extra 的数据进行美化打印(指令 %3)。在内部,它看起来是这样的:

// InstructionZir.Inst{    .tag = .add,    .data = .{        .pl_node = .{ .payload_index = 7 },    },}// Exra data[ ..., Ref.one, %2, ... ]

该指令使用 pl_node(“payload node” 的缩写)数据标签。它包含一个字段 payload_index,指向 extra 数组中的起始字段,可以在那里找到附加数据。通过查看 .add 标签的注释,存储在 extra 字段中的结构是一个 Zir.Inst.Bin,它有 lhsrhs。在这个例子中,lhs = Ref.onerhs = %2

与 AST 节点类似,AstGen 源码包含一个辅助函数 addExtra,它以类型安全的方式编码结构化的额外数据。这个模式在 Zig 解析器页面的 AST 节点部分有更详细的描述。

额外数据本身可以包含静态值、其他 Zig.Inst.Ref 值等等。你需要查看每个标签的源码才能理解它编码了哪些值。

更多数据类型

还有许多其他数据类型,但我不会一一详尽介绍。我认为上述数据类型对于理解用于编码 ZIR 指令信息的更广泛模式尤为重要。

AstGen 的组件

AstGen 过程有许多在构建 ZIR 时使用的通用共享组件。这些组件代表通用或共享的逻辑,对于理解 AstGen 的完整行为至关重要。这些组件中的大多数是 AstGen 中每个函数的参数。

这不是 AstGen 每个特性的详尽列表,但重点介绍了一些关键项目。

作用域

AstGen 引入了作用域感知,使得可以引用 “父” 作用域中的标识符、在使用未定义标识符时记录错误,或检测标识符遮蔽。

作用域使用 AstGen.zig 中的 Scope 结构体定义。作用域是多态的,可以是许多子类型之一。这是使用 Zig 中一种常见模式 @fieldParentPtr 实现的。解释这种模式超出了本页面的范围,因为它是 Zig 中的常见模式。

有七种作用域 “类型”(在撰写本文时):

  • Scope.Top - 表示一个文件的最外层作用域。它始终是最顶层的父作用域,没有父作用域。该作用域不跟踪任何附加数据。
  • GenZir - 该结构体通常表示 Zig 中的一个 “块”,但用于跨 AST 节点跟踪大量附加状态。它跟踪当前块标签(如果有)、指令列表、我们是否处于 comtime 位置等。
  • Scope.Namespace - 该作用域包含一个可被引用的无序声明集合。“无序” 是关键词,该作用域通常是结构体、联合体等块内的子作用域。例如:结构体变量可以引用文件中稍后定义的变量,而函数体则不能。一个是 Namespace,另一个不是。
  • Scope.LocalValScope.LocalPtr - 一个已定义的单个标识符(如变量或常量)。随着标识符被定义,会创建一个此类型的新子作用域,使其现在 “处于作用域内”。这与 Namespace 不同,因为它恰好表示一个声明,而不是一个无序列表。
  • Scope.Defer - 围绕 defererrdefer 的作用域。它用于跟踪 defer 的存在,以便可以在所有退出点生成指令。

作用域有一个 parent 字段,可用于向上遍历作用域。这就是标识符解析的工作方式。

字符串驻留

字符串值(字节数组)不会直接存储在 ZIR 指令中。字符串被驻留并存储在一个连续的 string_bytes 数组中。字符串值开头的索引存储在 ZIR 指令中。这意味着共享字符串(如标识符)只存储一次。

ZIR 是到目前为止流水线中第一个存储字符串的结构。AST 节点存储 token 索引,token 存储它们在源代码中的起始和结束偏移量。这要求 AST 和源代码保持可用。在 AstGen 之后,AST 和源代码可以被释放。这使得解析极大的 Zig 程序并只在内存中存储我们需要的内容成为可能。

结果位置

ResultLoc 结构体跟踪表达式树的最终结果应该写入的位置。在遍历 AST 并生成 ZIR 的过程中,某个写操作的值可能被深度嵌套,AstGen 需要知道最终值要写入哪里。

考虑以下示例:

const x = blk1: {  switch (someUnion) {    .someTag => break :blk1 42,    else => break :blk1 0,  }}

在最外层作用域,我们写入常量 x。如果你想象一下这棵 AST 树,我们必须嵌套进入一个块,然后是一个 switch,然后是一个 switch 分支,最后是一个带标签的 break 语句。在至少递归四层函数之后,ResultLoc 就是 AstGen 知道将带标签 break 的值写入何处的方式。

ResultLoc 是一个带标签的联合体,有许多可能的结果类型。它的注释很完善,我建议阅读源码。这里解释几个示例标签:

  • .discard - 表示赋值被丢弃,因为它是一个丢弃标识符 _ 的右侧。在这种情况下,AstGen 知道不生成任何存储指令,我们可以直接丢弃该值。
  • .ty - 赋值给某个有类型的值。这会生成一个 as_node,在返回之前强制转换结果值。
  • .ptr - 赋值正在写入某个内存位置。这会生成一个 store 指令,以便将结果写入该内存位置。

生成 ZIR

现在让我们了解 AstGen 如何将 AST 转换为 ZIR。抽象地说,AST 是通过遍历树并为每个节点生成指令来转换为 ZIR 的。AstGen 急切地将整个 AST 转换为 ZIR(它不会惰性求值——这种模式将在后面的编译器阶段出现)。

重要的是,AstGen 引入了我们的第一个重要的 语义验证 层。例如,AstGen 验证标识符是否已定义、不会遮蔽外部作用域,并知道如何遍历父作用域。回想一下,AST 构建引入了 结构验证:它确保像 x pub == 7 这样的 token 序列会引发语法错误,但它不验证标识符 x 是否已定义。而分词器本身只执行 逐 token 验证:它验证 72 是一个有效的 token,而 72a 不是(标识符不能以数字开头)。

学习 ZIR 如何生成的最简单的起点是查看 expr 函数。它为 Zig 语言中任何有效的表达式生成 ZIR。我发现从语言最简单的组件开始,然后逐步构建是最容易的,因为 IR 生成是一个深度递归的过程。

整数字面量

让我们从一个简单的静态整数值开始:

42

它被解析为 AST 节点标签 .integer_literal,通过巨大的 switch 从 expr 进入 integerLiteral 函数。integerLiteral 函数转载如下:

fn integerLiteral(gz: *GenZir, rl: ResultLoc, node: Ast.Node.Index) InnerError!Zir.Inst.Ref {    const tree = astgen.tree;    const main_tokens = tree.nodes.items(.main_token);    const int_token = main_tokens[node];    const prefixed_bytes = tree.tokenSlice(int_token);    if (std.fmt.parseInt(u64, prefixed_bytes, 0)) |small_int| {        const result: Zir.Inst.Ref = switch (small_int) {            0 => .zero,            1 => .one,            else => try gz.addInt(small_int),        };        return rvalue(gz, rl, result, node);    } else |err| switch (err) {        error.InvalidCharacter => unreachable, // Caught by the parser.        error.Overflow => {},    }    // ... other paths}

这里内容很多!这就是为什么我从最简单的例子开始。如果我从一个函数声明之类的东西开始,我会陷入太多细节,以至于学习起来会让人不知所措。看整数字面量你可能仍然有点不知所措,但这已经是最简单的了,让我们深入进去吧。

首先,我们使用 AST 查找整数字面量的 token。然后,我们可以使用 token 的起始位置,通过 tree.tokenSlice 获取与该 token 关联的字节。这会得到字符串 "42",更具体地说:两个字节 42

接下来,我们尝试将该字符数组解析为无符号 64 位整数。如果数字太大,我们会落到 “其他路径” 的注释处,那是关于存储 “大整数” 的复杂性,我们不会在这里探讨。对于这个例子,我们假设所有整数常量都小于或等于 18,446,744,073,709,551,615(无符号 64 位整数的最大值)。

无符号?负数怎么办?负数前面的 - 前缀被存储为一个单独的 AST 节点,并在 ZIR 中单独生成一个一元运算。整数字面量始终是正数。

在我们的示例中,解析数字将会成功,因为 42 可以解析为有效的 u64。接下来,我们处理值为 01 的特殊情况,因为它们有特殊的带标签 ref。否则,我们生成一条 .int ZIR 指令,并将指令索引存储在 result 中。

最后,我们调用 rvalue,它将 ResultLoc(前面已介绍)语义应用于该值,以确定我们是否需要原样返回它、将其转换为已知类型、将其存储到内存位置等。在许多情况下,这将是一个空操作,只会简单地返回 .int ZIR 指令。

加法

让我们从静态整数值开始,做一些加法:

42 + 1

它被解析为 AST 节点标签 .add,通过 expr 进入 simpleBinOp

fn simpleBinOp(    gz: *GenZir,    scope: *Scope,    rl: ResultLoc,    node: Ast.Node.Index,    op_inst_tag: Zir.Inst.Tag,) InnerError!Zir.Inst.Ref {    const astgen = gz.astgen;    const tree = astgen.tree;    const node_datas = tree.nodes.items(.data);    const result = try gz.addPlNode(op_inst_tag, node, Zir.Inst.Bin{        .lhs = try reachableExpr(gz, scope, .none, node_datas[node].lhs, node),        .rhs = try reachableExpr(gz, scope, .none, node_datas[node].rhs, node),    });    return rvalue(gz, rl, result, node);}

这是我们第一个递归的例子。它构建一条 .add ZIR 指令,其数据 lhsrhs 分别通过递归构建左表达式和右表达式的 ZIR 来填充。左表达式是 42,右表达式是 1。我们从整数字面量的探索中知道,它们会变成 .int 指令。

生成的 ZIR 大致如下:

%1 = int(42)%2 = add(%1, @Ref.one)

赋值

接下来,让我们把加法结果赋给一个无类型常量:

const x = 42 + 1;

你在 expr 中找不到对应这种情况的处理,因为这不是表达式,而是语句。你也不会找到 statement 函数,因为在 Zig 中,变量赋值只能在容器(结构体)或块(即函数体)内进行。你会在 containerMembersblockExprStmts 中找到它。前者用于结构体体,后者用于函数体。我认为先看 blockExprStmts 更容易,不过 containerMembers 也是有效的下一步,两者都有教育意义。

让我们看 blockExprStmts,它有一个 switch,对于变量声明(包括常量)会进入 varDeclvarDecl 很大!我不会在下面贴出完整的函数。我会贴出最常见的代码路径:

const type_node = var_decl.ast.type_node;const result_loc: ResultLoc = if (type_node != 0) .{    .ty = try typeExpr(gz, scope, type_node),} else .none;const init_inst = try reachableExpr(gz, scope, result_loc, var_decl.ast.init_node, node);const sub_scope = try block_arena.create(Scope.LocalVal);sub_scope.* = .{    .parent = scope,    .gen_zir = gz,    .name = ident_name,    .inst = init_inst,    .token_src = name_token,    .id_cat = .@"local constant",};return &sub_scope.base;

让我们从顶部部分开始。这部分只是递归并求值类型表达式(如果有的话)和初始化表达式。对于常量 const x: t = initt 是类型表达式,init 是初始化表达式。对于我们的例子,我们没有类型表达式,我们的初始化表达式是我们已经知道如何构建的 .add 指令。

接下来,我们创建一个新的 LocalVal 作用域来表示这个值。该作用域具有标识符名称 x、值指令 init_inst,并指向传递给 varDecl 函数的父作用域。该作用域是这个函数的返回值,调用者 blockExprStmts 从该语句开始将当前作用域替换为这个新作用域,以便后续的语句和表达式可以引用这个赋值。

我们之前的例子返回 ZIR 指令索引,而这个返回的是一个作用域。注意,对 x 的命名赋值不会生成 ZIR 指令。如果你查看生成的 ZIR,你不会在任何地方看到 x

%2 = int(42)%3 = add(%2, @Ref.one)

x作用域 中,作用域跟踪着值指令 inst。如果 x 被引用,我们会知道它的值来自那条指令,并相应地引用它。例如,让我们看看以下代码在函数体中的 ZIR:

const x = 42 + 1;const y = x;
%2 = int(42)%3 = add(%2, @Ref.one) // x assignment%4 = ensure_result_non_error(%3) // y assignment

注意,y 的赋值(指令 %4)直接来自指令 %3。在 ZIR 中我们不需要显式的内存加载/存储,因为我们可以精确地引用指令结果。

请注意,当最终生成机器代码时,后端可能需要确定加载/存储(取决于计算机体系结构),但中间表示不需要做这个决定。

作为给读者的练习:下一步,我建议查看语句 const y = x,了解标识符引用的工作原理。这将解释上面的 ZIR 是如何生成的,并且是迈向未来更复杂内容的一个良好基石。

完成 AstGen 过程

AstGen 过程对每个文件运行一次,递归地构建整个文件的 ZIR。在函数结束时,调用者会收到一个 Zir 值。

借助本页面的细节,你应该能够跟随任何 Zig 语言结构,学习 ZIR 是如何生成的。记住,经常使用 zig ast-check 命令来查看 Zig 编译器实际生成的内容,并以此作为研究哪些函数的指南。

接下来是 Sema 过程

原文由 Mitchell Hashimoto 发布

本文章由 stealth/ox-alpha 进行翻译