Zig 文本处理:Unicode、正则与高性能字符串

文本处理看似简单,实则暗藏 Unicode 编码、字素簇、规范化与内存分配等陷阱。本文讲解 UTF-8 编码原理、码点与字素簇的区别、Zig 标准库的字符串切片与遍历 API、大小写转换与 NFC 规范化、正则表达式引擎实现、分词与搜索,并给出日志解析与 CSV 处理的高性能实战。

引言

「文本处理」常被当作简单任务,直到你遇到一个 emoji 把字符串长度算错、一个组合字符让相等比较失败、或者一个 UTF-8 边界把字符劈成两半。这些问题的根源在于:人们习惯把字符串当字节数组,但人类感知的「字符」和字节之间隔着三层抽象。

本文从 UTF-8 编码原理讲起,覆盖码点/字素簇的区分、Zig 的字符串 API、规范化、正则引擎实现与高性能字符串构建,最后用日志解析与 CSV 处理两个实战收尾。代码基于 Zig 0.13/0.14。

前置:切片与数组、字符串所有权与分配。


目录


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–0x7F10xxxxxxx
0x80–0x7FF2110xxxxx
0x800–0xFFFF31110xxxx
0x10000–0x10FFFF411110xxx

后续字节一律是 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-8std.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;截断必须回退到字符边界。


相关阅读

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「系统编程」更多文章

  1. Zig 解析器与编译器前端:词法分析、递归下降与 AST
  2. Zig 算法与数据结构实战:哈希表、树、图与排序
  3. Zig 游戏开发实战:raylib、ECS 架构与游戏循环