第4章 实战项目:Base64 编码器

第4章 实战项目:Base64 编码器

理论知识已经储备完毕,现在是时候动手写点代码了。本章我们将实现一个经典的算法:Base64 编码

选择这个项目是因为它能完美地串联起我们之前学到的知识点:

  • 位操作 (Bitwise Operations):Base64 的核心逻辑。
  • 内存管理:我们需要为编码后的字符串动态分配内存。
  • 数组与切片:高效处理字节流。

Base64 算法原理

在写代码之前,必须先搞懂 Base64 是怎么工作的。

Base64 的核心思想是将二进制数据(8-bit 字节)重新编码为 64 个可打印字符。

编码规则:

  1. 3变4:每 3 个字节(Total: 24 bits)为一组。
  2. 重组:将这 24 bits 重新划分为 4 组,每组 6 bits。
  3. 查表:将这 6 bits 的值(范围 0-63)作为索引,去 Base64 字符表中查找对应的字符。

字符表

  • A-Z (0-25)
  • a-z (26-51)
  • 0-9 (52-61)
  • + (62)
  • / (63)
  • = (填充字符)

为什么是 3 个字节?

因为 3 和 4 的最小公倍数是 12,而我们需要凑齐 24 bits (3 * 8 = 24, 4 * 6 = 24)。

填充 (Padding)

如果原始数据的长度不是 3 的倍数怎么办?

  • 剩 1 个字节:补 2 个 =
  • 剩 2 个字节:补 1 个 =

步骤 1:创建查找表

首先,我们需要定义用于映射的字符表。

const base64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

这实际上是一个字符串字面量,类型为 *const [64:0]u8。我们可以像访问数组一样通过索引访问它,例如 base64_chars[0]'A'base64_chars[63]'/'

步骤 2:估算输出大小

在进行编码之前,我们需要知道编码后的字符串有多长,以便分配足够的内存。

公式很简单:OutputLength = 4 * ceil(InputLength / 3)。 即:每 3 个输入字节产生 4 个输出字符。如果有剩余(1 或 2 个字节),也会补齐为 4 个字符。

在 Zig 中实现这个计算逻辑:

const std = @import("std");

fn calc_encode_length(input: []const u8) usize {
    if (input.len == 0) return 0;
    
    // 计算需要多少个 4 字符块
    // (input.len + 2) / 3 是一种整数向上取整的技巧
    const n_output_blocks = (input.len + 2) / 3;
    
    return n_output_blocks * 4;
}

test "calc length" {
    try std.testing.expectEqual(calc_encode_length("M"), 4);
    try std.testing.expectEqual(calc_encode_length("Ma"), 4);
    try std.testing.expectEqual(calc_encode_length("Man"), 4);
    try std.testing.expectEqual(calc_encode_length("Light w"), 12);
}

步骤 3:核心编码逻辑

现在我们来实现 base64_encode 函数。这个函数需要:

  1. 接收一个分配器(因为我们要分配内存)。
  2. 接收输入数据(字节切片)。
  3. 返回编码后的字节切片,或者错误。
fn base64_encode(allocator: std.mem.Allocator, input: []const u8) ![]u8 {
    if (input.len == 0) {
        return allocator.alloc(u8, 0);
    }

    // 1. 准备内存
    const n_out = calc_encode_length(input);
    const out_buffer = try allocator.alloc(u8, n_out);
    // 注意:这里我们不使用 defer free,因为所有权要移交给调用者

    var i: usize = 0; // 输入索引
    var j: usize = 0; // 输出索引

    // 2. 循环处理每 3 个字节
    while (i < input.len) {
        // 累加器:用于暂存 24 bits 数据
        var accumulator: u32 = 0;
        var n_bits: u5 = 0; // 当前累加了多少 bit

        // 尝试读取 3 个字节
        const remaining = input.len - i;
        const n_process = if (remaining >= 3) 3 else remaining;

        var k: usize = 0;
        while (k < n_process) : (k += 1) {
            // 将字节移入累加器
            // 第一个字节移到高位,依此类推
            const byte = input[i + k];
            const shift = 16 - (k * 8); 
            // 注意:u8 移位会自动提升为 u32 吗?Zig 不会隐式转换
            // 所以要先 cast
            const val = @as(u32, byte) << @as(u5, @intCast(shift));
            accumulator |= val;
            n_bits += 8;
        }

        // 现在累加器里有 8, 16 或 24 bits
        // 我们需要按 6 bits 一组取出

        // 计算需要生成的字符数 (总是 4 个,包含填充)
        // 实际上我们总是生成 4 个字符,只是如果输入不够,有些是 '='
        
        // 让我们换个思路,直接硬编码处理逻辑
        // 因为 3->4 的逻辑非常固定
        
        // 处理第一个 6-bit
        const idx1 = (accumulator >> 18) & 0x3F;
        out_buffer[j] = base64_chars[idx1];
        
        // 处理第二个 6-bit
        const idx2 = (accumulator >> 12) & 0x3F;
        out_buffer[j+1] = base64_chars[idx2];

        // 处理第三个 6-bit (如果输入只剩 1 字节,这里是填充)
        if (n_process > 1) {
            const idx3 = (accumulator >> 6) & 0x3F;
            out_buffer[j+2] = base64_chars[idx3];
        } else {
            out_buffer[j+2] = '=';
        }

        // 处理第四个 6-bit (如果输入剩 1 或 2 字节,这里是填充)
        if (n_process > 2) {
            const idx4 = accumulator & 0x3F;
            out_buffer[j+3] = base64_chars[idx4];
        } else {
            out_buffer[j+3] = '=';
        }

        i += n_process;
        j += 4;
    }

    return out_buffer;
}

等一下,上面的逻辑虽然直观,但在处理 while 循环和剩余字节判断时略显啰嗦。我们可以优化一下结构。

通常 Base64 实现会将“处理整块(3字节)”和“处理剩余块”分开,这样更高效且逻辑更清晰。

让我们重构一下:

fn base64_encode(allocator: std.mem.Allocator, input: []const u8) ![]u8 {
    if (input.len == 0) return allocator.alloc(u8, 0);

    const n_out = calc_encode_length(input);
    const out = try allocator.alloc(u8, n_out);
    
    var i: usize = 0;
    var j: usize = 0;

    // 1. 快速路径:处理所有的 3 字节完整块
    const len_rounded_down = (input.len / 3) * 3;
    while (i < len_rounded_down) : (i += 3) {
        // 将 3 个 u8 拼成一个 u24 (存在 u32 里)
        const b0 = input[i];
        const b1 = input[i+1];
        const b2 = input[i+2];

        const val = (@as(u32, b0) << 16) | (@as(u32, b1) << 8) | @as(u32, b2);

        // 拆分成 4 个 6-bit 索引
        out[j]   = base64_chars[(val >> 18) & 0x3F];
        out[j+1] = base64_chars[(val >> 12) & 0x3F];
        out[j+2] = base64_chars[(val >> 6) & 0x3F];
        out[j+3] = base64_chars[val & 0x3F];
        
        j += 4;
    }

    // 2. 处理剩余的 1 或 2 个字节
    const remaining = input.len - i;
    if (remaining > 0) {
        var val: u32 = (@as(u32, input[i]) << 16);
        if (remaining == 2) {
            val |= (@as(u32, input[i+1]) << 8);
        }

        out[j]   = base64_chars[(val >> 18) & 0x3F];
        out[j+1] = base64_chars[(val >> 12) & 0x3F];

        if (remaining == 2) {
            out[j+2] = base64_chars[(val >> 6) & 0x3F];
            out[j+3] = '=';
        } else {
            out[j+2] = '=';
            out[j+3] = '=';
        }
    }

    return out;
}

这个版本清晰多了!

  • 位移技巧@as(u32, b0) << 16。注意在 Zig 中,必须先将 u8 转换为 u32 才能左移超过 8 位,否则会溢出报错(Debug 模式下)。这是 Zig 安全性的体现。
  • 掩码& 0x3F (二进制 0011 1111) 用于取出低 6 位。

步骤 4:编写测试

没有测试的代码是不可信的。Zig 内置的测试框架非常方便。

test "base64 encode" {
    // 使用测试分配器,它会自动检测内存泄漏!
    const allocator = std.testing.allocator;

    // 测试用例来自 RFC 4648
    const cases = .{
        .{ "", "" },
        .{ "f", "Zg==" },
        .{ "fo", "Zm8=" },
        .{ "foo", "Zm9v" },
        .{ "foob", "Zm9vYg==" },
        .{ "fooba", "Zm9vYmE=" },
        .{ "foobar", "Zm9vYmFy" },
    };

    // inline for 循环展开,编译时生成测试代码
    inline for (cases) |case| {
        const input = case[0];
        const expected = case[1];

        const actual = try base64_encode(allocator, input);
        defer allocator.free(actual); // 别忘了释放内存!

        try std.testing.expectEqualStrings(expected, actual);
    }
}

把所有代码保存到一个文件(如 base64.zig),然后运行:

zig test base64.zig

如果你看到了 All 1 tests passed.,恭喜你!你已经成功用 Zig 实现了一个内存安全且逻辑正确的 Base64 编码器。

关键知识点回顾

通过这个项目,我们复习并应用了:

  1. 内存分配:手动调用 allocator.alloc 申请内存。
  2. 所有权管理:函数内分配,返回给调用者,调用者负责 free。这是 Zig 中常见的模式。
  3. 位操作<< (左移), >> (右移), | (按位或), & (按位与)。
  4. 类型转换@as(u32, ...) 用于显式类型提升,防止溢出。
  5. 单元测试:使用 std.testingtest 块验证逻辑。

这只是个开始。在下一章,我们将探索更高级的主题:指针,揭开 Zig 内存操作的另一层面纱。


原文出处: https://pedropark99.github.io/zig-book/