Zig 的数组结构体 (2024)

Hacker News Top 工具

摘要

说明 Zig 的 comptime 和类型反射如何支持创建像 MultiArrayList 这样的数组结构体 (SoA) 数据结构,从而提升高性能应用中的缓存性能。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/06/08 12:16

# Zig 的数组化结构体(Struct of Arrays) | Andreas Hohmann 来源:https://andreashohmann.com/zig-struct-of-arrays/ Andreas Hohmann 2024年5月4日 \#软件 (https://andreashohmann.com/tags/software/) \#Zig (https://andreashohmann.com/tags/zig/) \#数据导向编程 (https://andreashohmann.com/tags/data-oriented-programming/) \#编译期计算 (https://andreashohmann.com/tags/comptime/) 如果你想见识 Zig 编译期计算(comptime)的强大,`MultiArrayList` (https://github.com/ziglang/zig/blob/master/lib/std/multi_array_list.zig) 就是最好的例子。这个集合将结构体列表的数据以“结构体数组”(SoA)而非“数组结构体”(AoS)的形式存储。这一技术是**数据导向设计** (https://en.wikipedia.org/wiki/Data-oriented_design) 和**数组编程** (https://en.wikipedia.org/wiki/Array_programming)(APL 永存!)的核心手段,广泛应用于游戏引擎、科学计算和编译器等高性能场景。后者(指编译器)很可能就是它已经出现在 Zig 标准库中的原因(参见 Andrew Kelley 的演讲 **实用数据导向设计** (https://vimeo.com/649009599))。 为一个结构体生成对应的“结构体数组”类型,这是一种非平凡的类型操作——我原本期待这只有在 TypeScript 或 Scala 这类面向类型的语言中才能看到,而不是 Zig 这种底层系统编程语言。Zig 是如何做到的?实际上,它“只是”编译期执行和反射而已。 在 Zig 中,类型是编译期值。它们可以赋值给常量,作为参数传递给(编译期)函数,并由这些函数返回。这一点在类型定义的语法中就已经体现。例如,一个具名结构体类型是通过用一个匿名结构体声明初始化一个常量来定义的。 `` 1const Token = struct { 2 kind: enum { id, string, number }, 3 data: []const u8, 4}; 5 6test "token size" { 7 try testing.expectEqual(24, @sizeOf(Token)); 8 try testing.expectEqual(2400, @sizeOf([100]Token)); 9} `` 测试结果显示,在我的 64 位机器上,这个结构体占用 24 字节:`data` 切片(指针和长度)各占 8 字节,`kind` 枚举和对齐占用 8 字节。100 个 Token 结构体的数组需要 2400 字节。 而 `MultiArrayList` (https://github.com/ziglang/zig/blob/master/lib/std/multi_array_list.zig) 将所需内存降到 1700 字节:数据切片占用 1600 字节,kind 占用 100 字节。下面的测试通过使用一个精确为 1704 字节的固定缓冲区分配器来演示这一点(我不清楚为什么多了 4 字节)。 `` 1const TokenList = std.MultiArrayList(Token); 2 3test "token lists" { 4 var buffer: [1704]u8 = undefined; 5 var fba = std.heap.FixedBufferAllocator.init(&buffer); 6 const allocator = fba.allocator(); 7 8 var tokens = TokenList{}; 9 try tokens.setCapacity(allocator, 100); 10 defer tokens.deinit(allocator); 11 12 tokens.appendAssumeCapacity(.{ .kind = .number, .data = "1000" }); 13 try testing.expectEqual(Token{ .kind = .number, .data = "1000" }, tokens.get(0)); 14} `` 这背后的原理是什么?类型通过一个编译期(`comptime`)参数(类型为 `type`)传递给函数。这是 Zig 泛型类型(如标准库中的集合)的基础。下面是一个固定大小的数组列表的最小版本(真正的实现请参见 `array_list.zig` (https://github.com/ziglang/zig/blob/master/lib/std/array_list.zig))。 `` 1pub fn FixedArrayList(comptime T: type) type { 2 return struct { 3 const Self = @This(); 4 5 items: []T, 6 allocator: Allocator, 7 8 pub fn init(allocator: Allocator, length: usize) Allocator.Error!Self { 9 return .{ 10 .allocator = allocator, 11 .items = try allocator.alloc(T, length), 12 }; 13 } 14 15 pub fn deinit(self: *Self) void { 16 self.allocator.free(self.items.ptr[0..self.items.len]); 17 } 18 }; 19} 20 21test "allocates array list" { 22 const allocator = testing.allocator; 23 24 const n = 10; 25 const PointList = FixedArrayList(Point); 26 var points = try PointList.init(allocator, n); 27 defer points.deinit(); 28 29 points.items[5] = .{ .x = 10, .y = 20 }; 30 try testing.expectEqual(20, points.get(5).y); 31} `` 这个结构体并不是非常有用,因为它只是包装了一个切片——大多数情况下我们可以直接使用切片本身。但它演示了一个简单的类型生成器函数。类型参数 `T` 只用于 `items` 切片的类型,我们不需要知道 `T` 的内部结构。 为了给给定的结构体构造“结构体数组”,我们需要更进一步。我们的类型构造函数必须能够查看原始结构体的字段及其类型,从而构造出新的“结构体数组”类型。Zig 提供了一个编译期反射 API 来实现这一目的。 为了感受这个 API,让我们定义一个用于点的类型构造函数,其中包含显式的坐标 `x1`、`x2`、`x3` 等等。我们的类型函数接受坐标类型 `T` 和维度 `n`,并返回一个结构体,每个维度对应一个字段。 `` 1pub fn PointN(comptime T: type, comptime N: comptime_int) type { 2 var fields: [N]std.builtin.Type.StructField = undefined; 3 for (0..N) |i| { 4 var num_buf: [128]u8 = undefined; 5 fields[i] = .{ 6 .name = std.fmt.bufPrintZ(&num_buf, "x{d}", .{i + 1}) catch unreachable, 7 .type = T, 8 .default_value = null, 9 .is_comptime = false, 10 .alignment = @alignOf(T), 11 }; 12 } 13 return @Type(.{ 14 .Struct = .{ 15 .is_tuple = false, 16 .layout = .Auto, 17 .decls = &.{}, 18 .fields = &fields, 19 }, 20 }); 21} 22 23test "n-dimensional point" { 24 const P3 = PointN(i32, 3); 25 const p = P3{ .x1 = 10, .x2 = 20, .x3 = 30 }; 26 try expectEqual(p.x2, 20); 27} `` 我们使用 `@Type` (https://ziglang.org/documentation/master/#Type) 函数动态构建结构体类型。字段的元数据是一个 `StructField` 对象的数组。这里我们仍然没有用到类型 `T` 的内部结构(除了通过 `@alignOf` (https://ziglang.org/documentation/master/#alignOf) 获取其对齐方式),但我们已经可以看到 Zig 用于描述结构体和字段的元数据结构。最后缺失的一块是一种获取给定类型元数据的方式。Zig 的 `@typeInfo` (https://ziglang.org/documentation/master/#typeInfo) 函数正是为此而生。 `MultiArrayList` (https://github.com/ziglang/zig/blob/master/lib/std/multi_array_list.zig) 将所有数据存储在一个单一的字节数组(https://github.com/ziglang/zig/blob/6a65561e3e5f82f126ec4795e5cd9c07392b457b/lib/std/multi_array_list.zig#L22)中。这个数组是原始结构体各字段数组的连接。为了优化对齐,这些子数组按照其对齐方式(对应字段的对齐)以**降序**(https://github.com/ziglang/zig/blob/6a65561e3e5f82f126ec4795e5cd9c07392b457b/lib/std/multi_array_list.zig#L139-L173)排列。生成的“结构体数组”类型将字段大小和字段索引排列(由于排序导致)保存在 `sizes` (https://github.com/ziglang/zig/blob/6a65561e3e5f82f126ec4795e5cd9c07392b457b/lib/std/multi_array_list.zig#L142) 结构中。该结构用于计算总分配大小(`capacityInBytes` (https://github.com/ziglang/zig/blob/6a65561e3e5f82f126ec4795e5cd9c07392b457b/lib/std/multi_array_list.zig#L537))以及每个字段切片(https://github.com/ziglang/zig/blob/6a65561e3e5f82f126ec4795e5cd9c07392b457b/lib/std/multi_array_list.zig#L198)的偏移量。 `MultiArrayList` (https://github.com/ziglang/zig/blob/master/lib/std/multi_array_list.zig) 其余代码实现了添加、删除、调整大小以及转换为结构体切片的各种方法。考虑到其中底层指针和索引的巧妙操作,代码相当易读。 Zig 的编译期类型构造也有其局限性。例如,无法像我们为 `PointN` 结构体构造字段那样动态生成方法。但这是反射 API 当前的限制,并非类型生成函数本身的根本问题,未来该限制可能会被解除。编译期函数方法的主要优点是:我们不需要学习一门新的语言(比如 Rust 的过程宏),尽管我们确实需要学习反射 API。

相似文章

# 引导 Zig Fmt 在过去的几个月里,我一直在研究 Zig 格式化工具(`zig fmt`)的演变方向,最终形成了[这份提案](https://github.com/ziglang/zig/issues/20078)。由于这是一个颇具争议的话题,我想在这篇文章中详细阐述其中的权衡考量。 ## 现状 `zig fmt` 是一个固执己见的格式化工具:它会将 Zig 代码格式化为单一的规范形式,且不受用户配置的影响。这类工具(如 `gofmt`、`prettier`)有一个显著优势:当整个生态系统都使用同一格式化工具时,就能消除围绕风格的无谓争论,并确保所有代码的外观一致。 然而,现有的 `zig fmt` 存在一个问题:它实际上并不是完全固执己见的——格式化结果会根据用户的输入而变化。 以下面这个例子为例: ```zig const x = foo(1, 2, 3); ``` 如果你在最后一个参数后面加上一个逗号: ```zig const x = foo(1, 2, 3,); ``` `zig fmt` 会将其格式化为: ```zig const x = foo( 1, 2, 3, ); ``` 这意味着代码的格式化方式(单行还是多行)取决于用户是否添加了尾随逗号。此外,`zig fmt` 对于其他一些构造也并非完全固执己见,例如注释的位置。 ## 问题所在 为什么这是个问题呢?毕竟,根据尾随逗号来决定格式化方式,这是一种广为人知且合理的约定。 问题在于,这种方案实际上给了用户两种选择:单行格式和多行格式。这意味着用户必须做出决定,而这恰恰是格式化工具本应消除的那类决策。 更糟糕的是,这个决定并没有一个客观正确的答案,因为适合单行还是多行,往往取决于行的长度——而行的长度会随着变量名、参数等的变化而改变。 举个例子,假设你有: ```zig const x = foo(1, 2, 3); ``` 这段代码很短,放在一行完全没问题。但如果函数名变长了呢? ```zig const x = a_longer_function_name(1, 2, 3); ``` 还是挺短的。那如果更长呢? ```zig const x = a_much_much_longer_function_name(argument_one, argument_two, argument_three); ``` 这行已经相当长了,或许应该换成多行格式: ```zig const x = a_much_much_longer_function_name( argument_one, argument_two, argument_three, ); ``` 但在当前的 `zig fmt` 机制下,你需要手动添加尾随逗号来触发这个格式化。如果你重构代码,将函数名改短,或者把参数替换为更短的名称,那么多行格式可能就不再必要了——但 `zig fmt` 不会自动帮你切换回单行格式,因为它会把尾随逗号视为"保持多行"的明确指令。 ## 解决方案 解决方案是让 `zig fmt` 基于行长度自动决定使用单行还是多行格式。具体来说:如果一个表达式能放在一行内(不超过某个长度限制,比如 100 个字符),就使用单行格式;否则,使用多行格式。 这正是 `prettier` 的工作方式,也是大多数现代格式化工具所采用的方案。 这意味着尾随逗号将不再具有语义上的格式化含义。你可以写: ```zig const x = foo(1, 2, 3,); ``` `zig fmt` 会根据行长度自动决定使用哪种格式,而不是盲目地遵循尾随逗号的指示。 ## 争议点 这项改动之所以有争议,主要有以下几个原因: **1. 人们习惯了现有的行为** 很多 Zig 开发者已经习惯于用尾随逗号来控制格式化。改变这一行为会打破他们的工作流程。 **2. 基于行长度的格式化可能产生令人惊讶的结果** 当你重命名一个变量,导致某行超过了长度限制,整个表达式的格式可能会突然从单行变成多行。这种"蝴蝶效应"可能让人感到困惑。 **3. 需要确定合适的行长度限制** 100 个字符?80 个字符?这本身也是一个需要决策的问题,尽管它只是一次性的决策,而不是每次写代码都要面对的决策。 ## 结论 尽管存在争议,我认为基于行长度的自动格式化是正确的方向。它让 `zig fmt` 真正成为一个固执己见的格式化工具,消除了用户需要做出的格式化决策,并确保代码在重构后始终保持最优的格式。 这与 Zig 语言的整体设计哲学是一致的:减少不必要的复杂性,让工具为开发者做出明智的决策,从而让开发者能够专注于真正重要的事情。

Lobsters Hottest

# 两个让 `zig fmt` 更好用的技巧 Zig 配备了一个内置的代码格式化工具 `zig fmt`。与其他语言的格式化工具不同,`zig fmt` 是"可操控的"——某些语法结构会影响格式化的输出结果。本文将介绍两个实用技巧。 ## 技巧一:尾随逗号控制布局 `zig fmt` 会根据是否存在尾随逗号来决定参数的排列方式。 **没有尾随逗号**时,格式化工具会尝试将所有参数放在同一行: ```zig const result = myFunction(argument1, argument2, argument3); ``` **有尾随逗号**时,格式化工具会将每个参数单独放在一行: ```zig const result = myFunction( argument1, argument2, argument3, ); ``` 这个规则同样适用于函数定义的参数列表、结构体字段、枚举变体等场景。 ```zig // 单行:无尾随逗号 const Point = struct { x: f32, y: f32 }; // 多行:有尾随逗号 const Point = struct { x: f32, y: f32, }; ``` 这意味着你可以通过添加或删除尾随逗号来主动控制格式化的输出,而不必与格式化工具"博弈"。想要多行展示?加上尾随逗号。想要单行展示?去掉它。 同样的逻辑也适用于换行符。如果你在参数之间手动添加了换行符,`zig fmt` 会尊重这个选择并保留多行格式——前提是同时带有尾随逗号。 ## 技巧二:数组的列式格式化 对于数值数组,`zig fmt` 支持一种特殊的列式格式化方式,非常适合用来表示矩阵或表格数据。 只需在数组元素之间手动插入换行符,`zig fmt` 就会将数据对齐成整洁的列式布局: ```zig // 格式化前(你写的) const matrix = [_]f32{ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, }; ``` ```zig // 格式化后(zig fmt 输出) const matrix = [_]f32{ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, }; ``` `zig fmt` 会识别出你在每行放置了相同数量的元素,并将各列对齐,使代码更具可读性。这对于表示变换矩阵、查找表或任何具有内在行列结构的数据来说极为方便。 ```zig // 一个更直观的例子:查找表 const sine_table = [_]f32{ 0.000, 0.174, 0.342, 0.500, 0.643, 0.766, 0.866, 0.940, 0.985, 1.000, 0.985, 0.940, 0.866, 0.766, 0.643, 0.500, }; ``` ## 小结 `zig fmt` 的"可操控"设计哲学让格式化工具成为你的合作伙伴,而不是独裁者: - **尾随逗号** → 强制多行展开 - **无尾随逗号** → 允许单行折叠 - **手动换行 + 统一列数** → 触发列式对齐 掌握这两个技巧,你就能在享受自动格式化便利的同时,保留对代码视觉呈现的精确控制。

用 Zig 写一个 C 编译器

Hacker News Top

一位开发者记录了用 Zig 语言、按照 Nora Sandler 的教程系列构建名为 paella 的 C 编译器的全过程。

Zig 构建速度正在提升

Mitchell Hashimoto

Zig 0.15 相比 0.14 在编译时性能有显著提升,构建脚本编译时间从约 7 秒降至约 1.7 秒,完整构建时间从 41 秒降至 32 秒,且仍使用 LLVM。本文重点介绍了自托管后端和增量编译方面的进展。