Chapter 16Project Table Generator

项目

概述

在这个项目中,我们将15中的思想转化为一个实用的工作流程:在编译时生成小的查找表,并在运行时以零开销使用它们。这种技术消除了热循环中的分支,用常量数据替代重复工作,并使代码保持简单。我们将采取“测量优先”的心态,并展示何时表格有用以及何时不值得增加二进制文件大小。

我们将实现三个自包含的演示:

  • ASCII分类表:常数时间字符分类(数字/字母/空格/标点)
  • Popcount表:字节的快速位计数,可组合用于更大的聚合
  • 乘法表:一个紧凑渲染的参数化N×N矩阵

每个示例都使用Zig现代的stdout写入器(参见Writergate更改),并在直接运行时打印可见结果。参见v0.15.2ascii.zig

学习目标

  • 设计简单、可读且快速的编译时表格构建器。15
  • 权衡利弊:代码大小与速度,灵活性与“内置”常量。41
  • 使用std.Io.Writer以最少的分配清晰地格式化和呈现表格。47

ASCII分类表

我们构建了一个256项的表格,将字节映射到数字/字母/空格/标点的位掩码。在运行时,我们总结一个输入字符串。“标点符号”集来自isPrint && !isAlphanumeric && !isWhitespace(对ASCII足够)。

Zig
const std = @import("std");

/// Helper function to obtain a buffered standard output writer.
/// Uses a static buffer to avoid repeated allocations.
/// 辅助函数以获取带缓冲区的标准输出写入器。
/// 使用静态缓冲区以避免重复分配。
fn stdout() *std.Io.Writer {
    const g = struct {
        var buf: [4096]u8 = undefined;
        var w = std.fs.File.stdout().writer(&buf);
    };
    return &g.w.interface;
}

/// Bit flags representing ASCII character classes.
/// Multiple flags can be combined using bitwise OR.
/// 表示 ASCII 字符类的位标志。
/// 可以使用按位 OR 组合多个标志。
const Class = struct {
    pub const digit: u8 = 0x01;  // 0-9
    // 数字
    pub const alpha: u8 = 0x02;  // A-Z, a-z
    // 字母
    pub const space: u8 = 0x04;  // space, newline, tab, carriage return
    // 空格、换行符、制表符、回车符
    pub const punct: u8 = 0x08;  // punctuation characters
    // 标点符号字符
};

/// Builds a lookup table mapping each byte (0-255) to its character class flags.
/// This function runs at compile time, producing a constant table embedded in the binary.
/// 构建一个查找表,将每个字节(0-255)映射到其字符类标志。
/// 此函数在编译时运行,产生一个嵌入在二进制文件中的常量表。
fn buildAsciiClassTable() [256]u8 {
    // Initialize all entries to 0 (no class flags set)
    // 将所有条目初始化为 0(未设置类标志)
    var t: [256]u8 = .{0} ** 256;

    // Iterate over all possible byte values at compile time
    // 在编译时遍历所有可能的字节值
    comptime var b: usize = 0;
    inline while (b < 256) : (b += 1) {
        const ch: u8 = @intCast(b);
        var m: u8 = 0;  // Accumulator for class flags
        // 类标志的累加器

        // Check if character is a digit (0-9)
        // 检查字符是否为数字 (0-9)
        if (ch >= '0' and ch <= '9') m |= Class.digit;

        // Check if character is alphabetic (A-Z or a-z)
        // 检查字符是否为字母 (A-Z 或 a-z)
        if ((ch >= 'A' and ch <= 'Z') or (ch >= 'a' and ch <= 'z')) m |= Class.alpha;

        // Check if character is whitespace (space, newline, tab, carriage return)
        // 检查字符是否为空白字符(空格、换行符、制表符、回车符)
        if (ch == ' ' or ch == '\n' or ch == '\t' or ch == '\r') m |= Class.space;

        // Check if character is punctuation (printable, non-alphanumeric, non-whitespace)
        // 检查字符是否为标点符号(可打印、非字母数字、非空白字符)
        if (std.ascii.isPrint(ch) and !std.ascii.isAlphanumeric(ch) and !std.ascii.isWhitespace(ch)) m |= Class.punct;

        // Store the computed flags for this byte value
        // 存储此字节值的计算标志
        t[b] = m;
    }
    return t;
}

/// Counts occurrences of each character class in the input string.
/// Uses the precomputed lookup table for O(1) classification per character.
/// 计算输入字符串中每个字符类的出现次数。
/// 使用预计算的查找表对每个字符进行 O(1) 分类。
fn countKinds(s: []const u8) struct { digits: usize, letters: usize, spaces: usize, punct: usize } {
    // Build the classification table (happens at compile time)
    // 构建分类表(在编译时发生)
    const T = buildAsciiClassTable();

    // Initialize counters for each character class
    // 为每个字符类初始化计数器
    var c = struct { digits: usize = 0, letters: usize = 0, spaces: usize = 0, punct: usize = 0 }{};

    // Iterate through each byte in the input string
    // 遍历输入字符串中的每个字节
    var i: usize = 0;
    while (i < s.len) : (i += 1) {
        // Look up the class flags for the current byte
        // 查找当前字节的类标志
        const m = T[s[i]];

        // Test each flag and increment the corresponding counter
        // 测试每个标志并增加相应的计数器
        if ((m & Class.digit) != 0) c.digits += 1;
        if ((m & Class.alpha) != 0) c.letters += 1;
        if ((m & Class.space) != 0) c.spaces += 1;
        if ((m & Class.punct) != 0) c.punct += 1;
    }

    // Return the counts as an anonymous struct
    // 以匿名结构返回计数
    return .{ .digits = c.digits, .letters = c.letters, .spaces = c.spaces, .punct = c.punct };
}

pub fn main() !void {
    // Get buffered output writer
    // 获取带缓冲区的输出写入器
    const out = stdout();

    // Define test string containing various character classes
    // 定义包含各种字符类的测试字符串
    const s = "Hello, Zig 0.15.2!  \t\n";

    // Count each character class in the test string
    // 计算测试字符串中每个字符类
    const c = countKinds(s);

    // Print the input string
    // 打印输入字符串
    try out.print("input: {s}\n", .{s});

    // Print the computed counts for each character class
    // 打印每个字符类的计算计数
    try out.print("digits={} letters={} spaces={} punct={}\n", .{ c.digits, c.letters, c.spaces, c.punct });

    // Ensure buffered output is written to stdout
    // 确保将缓冲输出写入 stdout
    try out.flush();
}
运行
Shell
$ zig run ascii_class_table.zig
输出
Shell
input: Hello, Zig 0.15.2!

digits=4 letters=8 spaces=6 punct=4

像这样的表格消除了内层循环中重复的分支。保持派生逻辑易于审计,并尽可能优先使用std.ascii助手。15

字节的Popcount表

我们不为每个字节调用位操作例程,而是烘焙一个256项的popcount表,并对输入进行归约。这可以从玩具示例扩展到“计算缓冲区中设置的位”的原始操作。

Zig
const std = @import("std");

/// Returns a reference to a buffered stdout writer.
/// The buffer and writer are stored in a private struct to persist across calls.
/// 返回带缓冲区的 stdout 写入器的引用。
/// 缓冲区和写入器存储在私有结构中以在调用间持续存在。
fn stdout() *std.Io.Writer {
    const g = struct {
        // Static buffer for stdout writes—survives function returns
        // 用于 stdout 写入的静态缓冲区——在函数返回后保持存活
        var buf: [4096]u8 = undefined;
        // Writer wraps stdout with the buffer; created once
        // 写入器用缓冲区包装 stdout;创建一次
        var w = std.fs.File.stdout().writer(&buf);
    };
    // Return pointer to the writer's generic interface
    // 返回指向写入器通用接口的指针
    return &g.w.interface;
}

/// Counts the number of set bits (1s) in a single byte using bit manipulation.
/// Uses a well-known parallel popcount algorithm that avoids branches.
/// 使用位操作计算单个字节中设置位(1)的数量。
/// 使用已知的避免分支的并行 popcount 算法。
fn popcountByte(x: u8) u8 {
    var v = x;
    // Step 1: Count bits in pairs (2-bit groups)
    // Subtracts neighbor bit from each 2-bit group to get counts 0-2
    // 步骤 1:以对(2 位组)计数位
    // 从每个 2 位组中减去相邻位以获得计数 0-2
    v = v - ((v >> 1) & 0x55);
    // Step 2: Count bits in nibbles (4-bit groups)
    // Adds adjacent 2-bit counts to get nibble counts 0-4
    // 步骤 2:以半字节(4 位组)计数位
    // 添加相邻的 2 位计数以获得半字节计数 0-4
    v = (v & 0x33) + ((v >> 2) & 0x33);
    // Step 3: Combine nibbles and mask low 4 bits (result 0-8)
    // Adding the two nibbles gives total count, truncate to u8
    // 步骤 3:组合半字节并屏蔽低 4 位(结果 0-8)
    // 添加两个半字节得到总计数,截断为 u8
    return @truncate(((v + (v >> 4)) & 0x0F));
}

/// Builds a 256-entry lookup table at compile time.
/// Each entry [i] holds the number of set bits in byte value i.
/// 在编译时构建 256 条目查找表。
/// 每个条目 [i] 持有字节值 i 中设置位的数量。
fn buildPopcountTable() [256]u8 {
    // Initialize table with zeros (all 256 entries)
    // 用零初始化表(所有 256 条目)
    var t: [256]u8 = .{0} ** 256;
    // Compile-time loop index (required for inline while)
    // 编译时循环索引(内联 while 需要)
    comptime var i: usize = 0;
    // Unrolled loop: compute popcount for each possible byte value
    // 展开循环:计算每个可能的字节值的 popcount
    inline while (i < 256) : (i += 1) {
        // Store the bit count for byte value i
        // 存储字节值 i 的位数
        t[i] = popcountByte(@intCast(i));
    }
    // Return the fully populated table as a compile-time constant
    // 将完全填充的表作为编译时常量返回
    return t;
}

pub fn main() !void {
    // Acquire the buffered stdout writer
    // 获取带缓冲区的 stdout 写入器
    const out = stdout();

    // Generate the popcount lookup table at compile time
    // 在编译时生成 popcount 查找表
    const T = buildPopcountTable();

    // Test data: array of bytes to analyze
    // 测试数据:要分析的字节数组
    const bytes = [_]u8{ 0x00, 0x0F, 0xF0, 0xAA, 0xFF };

    // Accumulator for total set bits across all test bytes
    // 跨所有测试字节的总设置位累加器
    var sum: usize = 0;

    // Sum up set bits by indexing into the precomputed table
    // 通过索引到预计算表来累加设置位
    for (bytes) |b| sum += T[b];

    // Print label for the output
    // 打印输出标签
    try out.print("bytes: ", .{});

    // Print each byte in hex format with spacing
    // 以带间距的十六进制格式打印每个字节
    for (bytes, 0..) |b, idx| {
        // Add space separator between bytes (not before first)
        // 在字节之间添加空格分隔符(不在第一个之前)
        if (idx != 0) try out.print(" ", .{});
        // Format as 0x-prefixed 2-digit hex (e.g., 0x0F)
        // 格式化为 0x 前缀的 2 位十六进制(例如,0x0F)
        try out.print("0x{X:0>2}", .{b});
    }

    // Print the final sum of all set bits
    // 打印所有设置位的最终总和
    try out.print(" -> total set bits = {}\n", .{sum});

    // Flush the buffered writer to ensure all output appears
    // 刷新带缓冲区的写入器以确保所有输出出现
    try out.flush();
}
运行
Shell
$ zig run popcount_table.zig
输出
Shell
bytes: 0x00 0x0F 0xF0 0xAA 0xFF -> total set bits = 20

在许多工作负载中,CPU的POPCNT指令(或std.math.popCount)已经很快。只有当你的配置文件显示它对你的数据访问模式和平台有帮助时才选择表格。52

参数化乘法表(N×N)

这里的表格维度是一个comptime参数,因此编译器会展开生成并存储一个紧凑的[N][N]u16。我们格式化一个12×12的“乘法表”,并只打印一个子集以保持输出可读。

Zig
const std = @import("std");

/// Returns a reference to a buffered stdout writer.
/// The buffer and writer are stored in a private struct to persist across calls.
/// 返回带缓冲区的 stdout 写入器的引用。
/// 缓冲区和写入器存储在私有结构中以在调用间持续存在。
fn stdout() *std.Io.Writer {
    const g = struct {
        // Static buffer for stdout writes—survives function returns
        // 用于 stdout 写入的静态缓冲区——在函数返回后保持存活
        var buf: [8192]u8 = undefined;
        // Writer wraps stdout with the buffer; created once
        // 写入器用缓冲区包装 stdout;创建一次
        var w = std.fs.File.stdout().writer(&buf);
    };
    // Return pointer to the writer's generic interface
    // 返回指向写入器通用接口的指针
    return &g.w.interface;
}

/// Builds an N×N multiplication table at compile time.
/// Each cell [i][j] holds (i+1) * (j+1) (1-indexed).
/// 在编译时构建 N×N 乘法表。
/// 每个单元格 [i][j] 持有 (i+1) * (j+1)(1 索引)。
fn buildMulTable(comptime N: usize) [N][N]u16 {
    // Declare the result table; will be computed entirely at compile time
    // 声明结果表;将完全在编译时计算
    var t: [N][N]u16 = undefined;

    // Outer loop: row index (compile-time variable required for inline while)
    // 外循环:行索引(内联 while 需要编译时变量)
    comptime var i: usize = 0;
    inline while (i < N) : (i += 1) {
        // Inner loop: column index
        // 内循环:列索引
        comptime var j: usize = 0;
        inline while (j < N) : (j += 1) {
            // Store (row+1) * (col+1) in the table
            // 在表中存储 (row+1) * (col+1)
            t[i][j] = @intCast((i + 1) * (j + 1));
        }
    }
    // Return the fully populated table as a compile-time constant
    // 将完全填充的表作为编译时常量返回
    return t;
}

pub fn main() !void {
    // Acquire the buffered stdout writer
    // 获取带缓冲区的 stdout 写入器
    const out = stdout();

    // Table dimension (classic 12×12 times table)
    // 表维度(经典的 12×12 乘法表)
    const N = 12;

    // Generate the multiplication table at compile time
    // 在编译时生成乘法表
    const T = buildMulTable(N);

    // Print header line
    // 打印标题行
    try out.print("{s}x{s} multiplication table (partial):\n", .{ "12", "12" });

    // Print only first 6 rows to keep output concise (runtime loop)
    // 只打印前 6 行以保持输出简洁(运行时循环)
    var i: usize = 0;
    while (i < 6) : (i += 1) {
        // Print all 12 columns for this row
        // 打印此行的所有 12 列
        var j: usize = 0;
        while (j < N) : (j += 1) {
            // Format each cell right-aligned in a 4-character field
            // 将每个单元格格式化为 4 字符字段中的右对齐
            try out.print("{d: >4}", .{T[i][j]});
        }
        // End the row with a newline
        // 用换行符结束行
        try out.print("\n", .{});
    }

    // Flush the buffered writer to ensure all output appears
    // 刷新带缓冲区的写入器以确保所有输出出现
    try out.flush();
}
运行
Shell
$ zig run mult_table.zig
输出
Shell
12x12 multiplication table (partial):
   1   2   3   4   5   6   7   8   9  10  11  12
   2   4   6   8  10  12  14  16  18  20  22  24
   3   6   9  12  15  18  21  24  27  30  33  36
   4   8  12  16  20  24  28  32  36  40  44  48
   5  10  15  20  25  30  35  40  45  50  55  60
   6  12  18  24  30  36  42  48  54  60  66  72

inline while/for构造需要编译时已知的边界;将它们与comptime var索引配对可以使意图明确。除非有理由展开,否则请选择普通循环。15

注意与警告

  • 二进制大小与速度:表格会占用内存。当它们从热路径中移除有意义的工作并且你的二进制预算允许时使用它们。41
  • 可移植性:ASCII分类很简单;Unicode需要不同的策略(范围/页面的表格或库)。47
  • I/O:示例使用Zig 0.15.2 std.Io.Writer接口,并在接口中包含一个缓冲区——不要忘记调用flush()

练习

  • 扩展ASCII表,添加更多类别(十六进制数字、控制字符),并打印任意输入文件的直方图。
  • 在编译时生成一个crc32crc16表,并在运行时针对已知测试向量进行验证(作为一个小型端到端演示)。15
  • 参数化乘法表的单元格格式器,以在不同宽度下对齐;测量对可读性和代码大小的影响。47

替代方案和边缘情况

  • 表格失效:如果输入形状改变(例如,从ASCII切换到UTF-8码点),请显著地记录假设,并引入编译时断言以尽早捕捉误用。37
  • 微架构效应:根据缓存行为,有分支的例程可能比表格遍历的性能更好;使用实际数据进行分析。42
  • 对于远大于CPU缓存的表格,请考虑按需生成、分块或从磁盘加载预计算资产,而不是嵌入到二进制文件中。28

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.