第4章 实战项目:Base64 编码器
第4章 实战项目:Base64 编码器
理论知识已经储备完毕,现在是时候动手写点代码了。本章我们将实现一个经典的算法:Base64 编码。
选择这个项目是因为它能完美地串联起我们之前学到的知识点:
- 位操作 (Bitwise Operations):Base64 的核心逻辑。
- 内存管理:我们需要为编码后的字符串动态分配内存。
- 数组与切片:高效处理字节流。
Base64 算法原理
在写代码之前,必须先搞懂 Base64 是怎么工作的。
Base64 的核心思想是将二进制数据(8-bit 字节)重新编码为 64 个可打印字符。
编码规则:
- 3变4:每 3 个字节(Total: 24 bits)为一组。
- 重组:将这 24 bits 重新划分为 4 组,每组 6 bits。
- 查表:将这 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 函数。这个函数需要:
- 接收一个分配器(因为我们要分配内存)。
- 接收输入数据(字节切片)。
- 返回编码后的字节切片,或者错误。
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 编码器。
关键知识点回顾
通过这个项目,我们复习并应用了:
- 内存分配:手动调用
allocator.alloc申请内存。 - 所有权管理:函数内分配,返回给调用者,调用者负责
free。这是 Zig 中常见的模式。 - 位操作:
<<(左移),>>(右移),|(按位或),&(按位与)。 - 类型转换:
@as(u32, ...)用于显式类型提升,防止溢出。 - 单元测试:使用
std.testing和test块验证逻辑。
这只是个开始。在下一章,我们将探索更高级的主题:指针,揭开 Zig 内存操作的另一层面纱。