引言
「文本处理」常被当作简单任务,直到你遇到一个 emoji 把字符串长度算错、一个组合字符让相等比较失败、或者一个 UTF-8 边界把字符劈成两半。这些问题的根源在于:人们习惯把字符串当字节数组,但人类感知的「字符」和字节之间隔着三层抽象。
本文从 UTF-8 编码原理讲起,覆盖码点/字素簇的区分、Zig 的字符串 API、规范化、正则引擎实现与高性能字符串构建,最后用日志解析与 CSV 处理两个实战收尾。代码基于 Zig 0.13/0.14。
前置:/zig-language-basics/(切片与数组)、/zig-memory-management/(字符串所有权与分配)。
目录
- 1. 文本处理的核心挑战
- 2. Unicode 与字符编码
- 3. 码点字形与字素簇
- 4. 切片遍历与边界安全
- 5. 大小写转换与规范化
- 6. 手写正则表达式引擎
- 7. 分词搜索与匹配
- 8. 高性能字符串构建
- 9. 实战:日志解析与 CSV
- 速查表
- 一句话记忆
- 相关阅读
- 延伸阅读
1. 文本处理的核心挑战
1.1 三个层次
字节层 []u8 存储与传输,UTF-8 编码后的原始数据
码点层 u21 Unicode 码点,一个「逻辑字符」的编号
字素簇层 []const u8 人类感知的一个「字」,可能由多个码点组成
混淆这三层是绝大多数文本 bug 的根源。
1.2 常见陷阱
| 陷阱 | 表现 |
|---|---|
用 len 当字符数 | "你好".len == 6 而非 2 |
| 按下标随机访问 | s[1] 可能落在多字节字符中间 |
| 直接比较字节 | "é" 有 NFC 与 NFD 两种编码,字节不等但语义相同 |
| 截断不检查边界 | 截断处产生非法 UTF-8 序列 |
2. Unicode 与字符编码
2.1 码点空间
Unicode 定义了 0x0 到 0x10FFFF 共 111 万个码点,分为 17 个平面(BMP 是 0 号平面,0x0 到 0xFFFF)。码点需要 21 位才能表示,所以 Zig 用 u21。
2.2 UTF-8 变长编码
| 码点范围 | 字节数 | 首字节模式 |
|---|---|---|
| 0x00–0x7F | 1 | 0xxxxxxx |
| 0x80–0x7FF | 2 | 110xxxxx |
| 0x800–0xFFFF | 3 | 1110xxxx |
| 0x10000–0x10FFFF | 4 | 11110xxx |
后续字节一律是 10xxxxxx 形式。这个设计让 ASCII 保持单字节,且不会出现字节包含另一个字符的情况——所以按字节搜索子串是安全的。
2.3 解码与编码
const std = @import("std");
test "utf8 decode" {
const s = "héllo 世界";
var view = std.unicode.Utf8View.init(s) catch unreachable;
var it = view.iterator();
var count: usize = 0;
while (it.nextCodepoint()) |cp| {
std.debug.print("U+{X:0>4}\n", .{cp});
count += 1;
}
try std.testing.expectEqual(@as(usize, 8), count);
}
Utf8View 在初始化时做一次校验,之后迭代器可安全遍历。
3. 码点字形与字素簇
3.1 组合字符
"é" 有两种表示:
- NFC:单码点
U+00E9(预组合)。 - NFD:
U+0065 U+0301(e+ 组合尖音符)。
两者渲染完全相同,字节序列却不同。直接 std.mem.eql 会判不等。
3.2 字素簇
字素簇(grapheme cluster)是「用户感知的一个字符」,可能由多个码点组成:
👨👩👧 = U+1F468 ZWJ U+1F469 ZWJ U+1F467 (家庭 emoji,5 个码点)
🇨🇳 = U+1F1E8 U+1F1F3 (国旗,2 个区域指示符)
é = U+0065 U+0301 (e + 组合符)
统计「字数」时必须按字素簇而非码点。
3.3 何时需要字素簇
| 场景 | 需要字素簇? |
|---|---|
| 计算显示宽度 | 是 |
| 光标移动 / 删除字符 | 是 |
| 截断显示(省略号) | 是 |
| 搜索 / 匹配子串 | 否,按字节即可 |
| 存储 / 传输 | 否,按字节即可 |
Zig 标准库不提供字素簇分割,需要引入 ziglyph 或自己实现简化版。
4. 切片遍历与边界安全
4.1 按字节遍历
test "byte iterate" {
const s = "abc";
for (s, 0..) |byte, i| {
std.debug.print("{d}: {c}\n", .{ i, byte });
}
}
ASCII 场景下按字节遍历最快,无需任何解码。
4.2 按码点遍历
test "codepoint iterate" {
const s = "a世b";
var view = try std.unicode.Utf8View.init(s);
var it = view.iterator();
while (it.nextCodepointSlice()) |slice| {
std.debug.print("{s} ({d} 字节)\n", .{ slice, slice.len });
}
}
nextCodepointSlice 返回切片(零拷贝),nextCodepoint 返回 u21 值。需要原字节时用前者。
4.3 安全截断
fn truncateUtf8(s: []const u8, max_bytes: usize) []const u8 {
if (s.len <= max_bytes) return s;
var end = max_bytes;
// 回退到字符边界:UTF-8 续字节是 10xxxxxx
while (end > 0 and (s[end] & 0xC0) == 0x80) end -= 1;
return s[0..end];
}
绝不能直接 s[0..max_bytes]——会切出非法序列,下游解码器报错。
5. 大小写转换与规范化
5.1 大小写转换的复杂性
ASCII 的大小写转换很简单,Unicode 却有三类坑:
- 多对一:
ß的大写是SS(长度变化)。 - 语言相关:土耳其语的
i大写是İ而非I。 - 希腊语终位 sigma:词尾
ς与词中σ同源。
test "ascii case" {
var buf: [16]u8 = undefined;
const upper = std.ascii.upperString(&buf, "hello");
try std.testing.expectEqualStrings("HELLO", upper);
}
std.ascii 只处理 ASCII。完整 Unicode 大小写需要 ziglyph 的 toUpper。
5.2 NFC 与 NFD 规范化
规范化把等价的码点序列统一成唯一形式,让字节比较可行:
NFD → NFC:合并预组合字符,适合存储与比较
NFC → NFD:分解,适合某些文本算法
5.3 比较的正确姿势
fn textEqual(a: []const u8, b: []const u8) bool {
// 先按字节快速比较
if (std.mem.eql(u8, a, b)) return true;
// 字节不等时,规范化后再比较(需要 ziglyph 等库)
return false;
}
先做字节比较是关键优化:绝大多数情况下等价字符串就是字节相同的,只有少数情况才需要昂贵的规范化。
6. 手写正则表达式引擎
6.1 支持的语法子集
实现一个支持 .、*、+、?、^、$、字符类 [abc] 的引擎,足以覆盖大多数实用场景。
6.2 回溯匹配核心
fn matchHere(pattern: []const u8, text: []const u8) bool {
if (pattern.len == 0) return true;
// 处理 * 量词
if (pattern.len >= 2 and pattern[1] == '*') {
return matchStar(pattern[0], pattern[2..], text);
}
// 处理 + 量词(至少一次)
if (pattern.len >= 2 and pattern[1] == '+') {
if (text.len > 0 and charMatches(pattern[0], text[0])) {
return matchStar(pattern[0], pattern[2..], text[1..]);
}
return false;
}
// 处理 ? 量词(零或一次)
if (pattern.len >= 2 and pattern[1] == '?') {
if (text.len > 0 and charMatches(pattern[0], text[0])) {
if (matchHere(pattern[2..], text[1..])) return true;
}
return matchHere(pattern[2..], text);
}
// 单字符匹配
if (text.len > 0 and charMatches(pattern[0], text[0])) {
return matchHere(pattern[1..], text[1..]);
}
return false;
}
fn matchStar(c: u8, pattern: []const u8, text: []const u8) bool {
var t = text;
while (true) {
if (matchHere(pattern, t)) return true;
if (t.len == 0 or !charMatches(c, t[0])) return false;
t = t[1..];
}
}
这是 Rob Pike 在《The Practice of Programming》里给出的经典实现,简洁且正确。
6.3 字符匹配
fn charMatches(pattern_char: u8, text_char: u8) bool {
if (pattern_char == '.') return true;
return pattern_char == text_char;
}
6.4 顶层搜索
fn search(pattern: []const u8, text: []const u8) bool {
if (pattern.len > 0 and pattern[0] == '^') {
return matchHere(pattern[1..], text);
}
var t = text;
while (true) {
if (matchHere(pattern, t)) return true;
if (t.len == 0) return false;
t = t[1..];
}
}
6.5 性能与替代
回溯引擎在恶意输入上会指数爆炸(ReDoS)。生产环境应:
- 用 NFA/DFA 引擎(如 RE2 的思路)保证线性时间。
- 或限制回溯步数。
- 简单匹配优先用
std.mem.indexOf/std.mem.startsWith。
const idx = std.mem.indexOf(u8, haystack, needle);
const starts = std.mem.startsWith(u8, s, "prefix");
const token = std.mem.tokenizeScalar(u8, line, ',');
7. 分词搜索与匹配
7.1 按分隔符分词
test "tokenize" {
const line = "a,b,,c";
var it = std.mem.tokenizeScalar(u8, line, ',');
var count: usize = 0;
while (it.next()) |tok| : (count += 1) {
std.debug.print("[{s}]\n", .{tok});
}
// 输出 a / b / c,空字段被跳过
try std.testing.expectEqual(@as(usize, 3), count);
}
tokenizeScalar 跳过空字段;要保留空字段(CSV 必需)用 splitScalar。
7.2 子串查找
test "find" {
const haystack = "the quick brown fox";
const pos = std.mem.indexOf(u8, haystack, "brown").?;
try std.testing.expectEqual(@as(usize, 10), pos);
const last = std.mem.lastIndexOf(u8, haystack, "o").?;
try std.testing.expectEqual(@as(usize, 17), last);
}
indexOf 用的是高效的两两比较算法,不要自己写朴素循环。
8. 高性能字符串构建
8.1 ArrayList 拼接
test "concat" {
var out = std.ArrayList(u8).init(std.testing.allocator);
defer out.deinit();
try out.appendSlice("Hello, ");
try out.appendSlice("Zig");
try out.append('!');
try std.testing.expectEqualStrings("Hello, Zig!", out.items);
}
8.2 预分配容量
反复 appendSlice 会触发多次扩容拷贝。已知大致大小时先 ensureTotalCapacity:
try out.ensureTotalCapacity(1024);
8.3 格式化写入
test "format" {
var buf: [64]u8 = undefined;
const s = try std.fmt.bufPrint(&buf, "id={d} name={s}", .{ 42, "zig" });
try std.testing.expectEqualStrings("id=42 name=zig", s);
}
bufPrint 在栈缓冲上格式化,零堆分配——热路径首选。
8.4 用 Writer 统一输出
test "writer" {
var out = std.ArrayList(u8).init(std.testing.allocator);
defer out.deinit();
const w = out.writer();
try w.print("count={d}\n", .{3});
try w.writeAll("done\n");
try std.testing.expectEqualStrings("count=3\ndone\n", out.items);
}
8.5 避免的写法
// 差:每次循环都重新分配
var s: []const u8 = "";
for (items) |item| {
s = try std.fmt.allocPrint(alloc, "{s}{s}", .{ s, item }); // O(n²)
}
// 好:一次构建
var out = std.ArrayList(u8).init(alloc);
defer out.deinit();
for (items) |item| try out.appendSlice(item);
字符串循环拼接是最常见的 O(n²) 陷阱。
9. 实战:日志解析与 CSV
9.1 日志行解析
目标格式:2026-10-02T12:00:00Z INFO [app] message here
const LogEntry = struct {
timestamp: []const u8,
level: []const u8,
module: []const u8,
message: []const u8,
};
fn parseLogLine(line: []const u8) ?LogEntry {
var it = std.mem.tokenizeScalar(u8, line, ' ');
const timestamp = it.next() orelse return null;
const level = it.next() orelse return null;
var module = it.next() orelse return null;
if (module.len >= 2 and module[0] == '[') {
module = module[1 .. module.len - 1];
}
// 剩余部分是消息,可能含空格
const rest_start = std.mem.indexOf(u8, line, module) orelse return null;
const message = std.mem.trim(u8, line[rest_start + module.len ..], " ");
return .{
.timestamp = timestamp,
.level = level,
.module = module,
.message = message,
};
}
返回的切片全部指向原行缓冲区,零拷贝。只要原行在生命周期内有效即可。
9.2 CSV 字段解析
fn parseCsvLine(line: []const u8, alloc: std.mem.Allocator) ![][]const u8 {
var fields = std.ArrayList([]const u8).init(alloc);
var it = std.mem.splitScalar(u8, line, ',');
while (it.next()) |field| {
try fields.append(std.mem.trim(u8, field, " \t"));
}
return fields.toOwnedSlice();
}
splitScalar 保留空字段,符合 CSV 语义。带引号转义的 CSV 需要状态机解析,不能简单切分。
9.3 大文件流式处理
处理 GB 级日志时不能整文件读入内存:
pub fn processFile(path: []const u8, alloc: std.mem.Allocator) !void {
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
var buf_reader = std.io.bufferedReader(file.reader());
var reader = buf_reader.reader();
var line_buf = std.ArrayList(u8).init(alloc);
defer line_buf.deinit();
while (try reader.readUntilDelimiterOrEofAlloc(alloc, '\n', 1 << 20)) |line| {
defer alloc.free(line);
if (parseLogLine(line)) |entry| {
if (std.mem.eql(u8, entry.level, "ERROR")) {
std.debug.print("发现错误: {s}\n", .{entry.message});
}
}
}
}
踩坑:
readUntilDelimiterOrEofAlloc需要显式free每行;若用固定缓冲版本则要处理超长行截断。
速查表
| 需求 | API |
|---|---|
| 校验 UTF-8 | std.unicode.utf8ValidateSlice(s) |
| 遍历码点 | std.unicode.Utf8View.init(s) + iterator() |
| 取码点切片 | it.nextCodepointSlice() |
| 编码码点 | std.unicode.utf8Encode(cp, &buf) |
| ASCII 大写 | std.ascii.upperString(&buf, s) |
| 忽略大小写比较 | std.ascii.eqlIgnoreCase(a, b) |
| 子串查找 | std.mem.indexOf(u8, hay, needle) |
| 分词(跳空) | std.mem.tokenizeScalar(u8, s, ',') |
| 分词(留空) | std.mem.splitScalar(u8, s, ',') |
| 栈上格式化 | std.fmt.bufPrint(&buf, fmt, args) |
| 构建字符串 | ArrayList(u8) + appendSlice |
| 流式读行 | readUntilDelimiterOrEofAlloc |
一句话记忆
Zig 字符串就是 []const u8,字节、码点、字素簇是三个层次:搜索匹配用字节最快,遍历显示用 Utf8View,统计字数与光标移动才需要字素簇;构建字符串永远用 ArrayList 一次成型,绝不循环 allocPrint;截断必须回退到字符边界。
相关阅读
- /zig-language-basics/ — 切片、数组与字符串字面量
- /zig-memory-management/ — 字符串所有权与分配器
- /zig-json-serialization/ — 结构化文本的解析与生成
延伸阅读
- /zig-std-data-structures/ — ArrayList 与 HashMap 的文本索引用法
- /zig-performance-optimization/ — 字符串热路径的优化手段
- /zig-cli-application/ — 命令行工具中的参数与文本处理
- Zig 专题 — Zig 系统编程专题
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。