本章导读
XRT 的文本主线是 UTF-8:字符串视图(第 3 章)与解析器全家默认吃 UTF-8。但真实世界有三个出口需要别的编码:Windows API 要 UTF-16、历史数据可能是 GBK/Latin-1、网络协议有时指定 UTF-32——charset 模块就是这些边界上的转换层。本章讲三件事:标量与字节的关系("字符数"为什么和"字节数"不同)、UTF-8/16/32 的全向转码(带 BOM 封包与错误策略)、按标量下标的安全切片(不切断多字节字符)。第 25 章的距离/相似度函数也住在这里——它们按 Unicode 标量计数,是"字节思维"升级为"字符思维"的示范。
27.1引入
一段 "XRT 你好 😀",用 strlen 数是 14 字节,但"字符"只有 8 个——中文每字 3 字节、emoji 是 4 字节。这个差距不是学术问题:截断用户名时按字节切,半个"你"字变成乱码;按字节计算长度限制,中文用户比英文用户少用三分之二的字符数;排序与比较按字节走,拼音序与用户期望完全无关。三个 bug 的共同根因:把字节当字符。
charset 模块的核心抽象是 Unicode 标量值(code point)——它才是"一个字符"的机器表示(近似地;严格说还有组合字符,见契约区)。字节是编码后的形态:UTF-8 把标量编成 1~4 字节、UTF-16 编成 1~2 个 16 位单元(增补平面字符要一对"代理项")、UTF-32 定长 4 字节。本章的全部 API 都围绕"标量 ↔ 各种字节形态"的转换展开。
27.2概念
三种编码的形态差异
"XRT 你好 😀" 的实测(主示例输出):UTF-16 是 9 个单元——8 个标量里 7 个各占 1 单元,😀 在增补平面要一对代理项占 2 单元。单元数不是字符数、字节数更不是——这三个计量(字节/单元/标量)在多语言文本里永远不同,回答"多长"之前先问"按什么计量"。
全向转码与 BOM
xrtTranscode 在 UTF-8/16/32(各含 LE/BE)之间全向转换:输入输出都是字节视图,目标编码由 xencoding 枚举声明(字节序也在枚举里),BOM 封包是独立的布尔参数。BOM 的取舍:协议已声明字节序就不加(HTTP 头、JSON 都有自己的规则);文件落盘建议加——文本编辑器打开不乱码。
错误策略:STRICT / REPLACE
输入里有非法序列怎么办?两种策略显式选择:XUTF_STRICT 直接失败(错误槽记录位置——适合"输入必须合法"的边界校验);XUTF_REPLACE 替换为替代字符 U+FFFD(适合展示路径——用户看到 □ 而不是崩溃);只有 STRICT 与 REPLACE 两档。默认应当用 STRICT——静默替换会掩盖上游的编码 bug,REPLACE 留给"已经确认要给人看"的最后一步。
按标量的安全操作
字节下标切在多字节字符中间是乱码之源。xrtUtf8Slice 按标量下标切 UTF-8——切点自动对齐字符边界;xrtUtf8Distance / xrtUtf8Similarity 按标量计编辑距离(第 25 章速查表里预告过它们住在这里);xrtUtf8Count 给出标量数。用户可见的截断、计数、比较一律用这一族,内部传输的字节操作才用字符串族。
27.3示例
完整程序:UTF-8 ↔ UTF-16 严格往返与标量切片
来自仓库范例 examples/charset/unicode/main.c:
*/ #include <stdio.h> #include <xrt.h> int main(void) { /* "XRT 你好 😀":你好 各 3 字节,😀 是 4 字节 emoji。 */ xstrview Text = XRT_STR_LITERAL("XRT \xE4\xBD\xA0\xE5\xA5\xBD \xF0\x9F\x98\x80"); uint16* pUtf16; /* 拥有式:转换出的 UTF-16 数组 */ uint16* pUtf16Copy; /* 拥有式:视图复制产物 */ str sUtf8; /* 拥有式:回转 UTF-8 */ size_t iUnits = 0; /* 出参:UTF-16 单元数 */ xstrview Word; /* 借用:标量切片结果 */ /* 去 UTF-16:严格模式下任何非法序列都会失败并设置线程错误。 */ pUtf16 = xrtUtf8ViewTo16(Text, XUTF_STRICT, &iUnits); if ( pUtf16 == NULL ) { return 1; } */ pUtf16Copy = xrtUtf16DupView(xrtUtf16View(pUtf16, iUnits)); if ( pUtf16Copy == NULL ) { xrtFree(pUtf16); return 1; } /* 回转 UTF-8:长度出参不需要时传 NULL。 */ sUtf8 = xrtUtf16ViewTo8(xrtUtf16View(pUtf16, iUnits), XUTF_STRICT, NULL); if ( sUtf8 == NULL ) { xrtFree(pUtf16Copy); xrtFree(pUtf16); return 1; } printf("UTF-16 units: %llu\n%s\n", (unsigned long long)iUnits, sUtf8); */ if ( !xrtUtf8Slice(Text, 4, 2, &Word) ) { xrtFree(sUtf8); xrtFree(pUtf16Copy); xrtFree(pUtf16); return 1; } printf("scalar slice: %.*s\n", (int)Word.Size, Word.Data); /* 三个拥有式资源逆序释放。 */ xrtFree(sUtf8); xrtFree(pUtf16Copy); xrtFree(pUtf16); return 0; }
刚才发生了什么。 ① xrtUtf8ViewTo16 把 UTF-8 视图转成拥有式 UTF-16 数组(严格模式),出参带回单元数 9——不是 8:😀 的代理对占了 2 单元,"单元数 ≠ 标量数"的最直观教具。② xrtUtf16ViewTo8 再转回来,输出原样——往返零损失,严格模式保证双向都是合法序列。③ xrtUtf8Slice 按标量下标切出 你好——切点落在字符边界,即使给的字节位置恰好是"你"的中间字节,函数也会对齐到边界而不是切出半字。Windows 边界的标准用法就是这两步:出系统调用前 To16、回来后 To8。
完整程序:带 BOM 的转码封包
来自 examples/charset/transcode/main.c:
*/ #include <stdio.h> #include <xrt.h> int main(void) { /* 源文本 "XRT 你好"(UTF-8 字面量,"你好"各占 3 字节)。 */ xbytesview Source = XRT_BYTES_LITERAL("XRT \xE4\xBD\xA0\xE5\xA5\xBD"); bytes pUtf16; /* 拥有式:转换产物 */ size_t iSize = 0; */ pUtf16 = xrtTranscode(Source, XENCODING_UTF8, XENCODING_UTF16_LE, XUTF_STRICT, true, &iSize); if ( pUtf16 == NULL ) { return 1; } /* 打印产物总字节数(含 BOM),证明转换真实发生。 */ printf("UTF-16 LE bytes with BOM: %llu\n", (unsigned long long)iSize); /* 拥有式产物由 xrtFree 释放。 */ xrtFree(pUtf16); return 0; }
刚才发生了什么。 ① xrtTranscode 的目标是 UTF-16 LE 并带 BOM——输出字节流以 FF FE 开头,接收方一眼识别编码与字节序。② 字节数 14 可笔算:6 个 BMP 内码点各 2 字节 = 12,加 BOM 2 字节——转码输出的大小是可推导的,缓冲规划不必靠猜。③ 全向转换意味着 UTF-32 → UTF-8、Latin-1 → UTF-16 这些组合走同一个入口——参数声明两端编码,不为一对组合学一个函数。
27.4契约
- UTF-8 主线:XRT 字符串族与解析器默认 UTF-8;转换只发生在系统与协议边界。
- 严格默认:非法序列用
XUTF_STRICT整体失败;REPLACE 显式选择且只用于展示路径。 - 三种计量:字节数 / UTF-16 单元数 / 标量数永远可能不同——先问计量再问长度。
- 标量安全:用户可见的截断/计数/比较用 Utf8 家族按标量操作;字节操作限于内部传输。
- 拥有式产物:转码与 To16/To8 返回拥有式数组,按其声明释放(
xrtFree)。 - BOM 取舍:协议已声明则不加;文件落盘建议加。
流式解码与增量校验
分块到达的数据(网络流、大文件)不能等"全部字节凑齐再校验"——多字节字符可能正好被切在两块的边界上。xutf8state 流式解码器为此而生:xrtUtf8StateInit 开场、xrtUtf8StateFeed 逐块喂入(bFinal 标记最后一块)、xrtUtf8StateError 读错误位置——跨块边界的不完整字符由状态机记住,不会误判为非法。这是"严格校验"与"流式处理"两个需求的合流点,第 35 章的行读取器与卷七的协议流都会用到这个形态。校验大文件编码合法性的正确姿势就是它:流式喂入、最后一块后检查状态,全程零整文件缓冲。
组合字符与用户感知字符
本章一直小心地说"标量"而不说"字符",因为 Unicode 里两者不总相等:带音调的字母可以是"基础字母 + 组音符号"两个标量,emoji 族还有 ZWJ 序列(家庭、职业组合)可达十余个标量。标量计数对绝大多数业务够用,但"用户感知字符数"(grapheme cluster)是更严的口径——输入框的"还剩几个字"提示、光标移动的步进,严格说都该按字簇。XRT 的立场:标量层是公共地基(本章家族),字簇级需求在展示层按需处理(多数项目用标量即可,遇到投诉再升级——但要知道差异存在,才能在遇到时认出它)。把这段话记下来,未来某天"长度不对"的用户反馈来找你时,你知道往哪查——这也解释了为什么本章通篇用"标量"这个精确词而不是顺口的"字符"。
与第 25 章的分工再确认
一句话分工:字节操作找 string(第 25 章),字符操作找 charset(本章)。查找、切分、拼接这些"内容处理"按字节工作且 UTF-8 下安全(分隔符通常 ASCII);计数、截断、宽度、距离这些"用户可见"的操作必须按标量走本章家族。两个家族的视图类型一致(xstrview),可以无缝串用——按字节 Find 定位、按标量 Slice 截断,是混用的标准姿势。
常见错误
症状:中文/emoji 用户名末尾偶现乱码或 �;特定长度刚好切在多字节字符中间时必现。
原因:UTF-8 里一个字符 1~4 字节,字节下标没有字符边界的概念——切在中间就产生非法序列。
if ( strlen(sName) > 32 ) { sName[32] = 0; /* 字节级截断:可能切掉半个"你" */ }
xstrview Name = (xstrview){ sName, strlen(sName) }; if ( xrtUtf8Count(Name) > 16 ) { Name = xrtUtf8Slice(Name, 0, 16); /* 按标量截断,边界自动对齐 */ } /* Name 是视图:需要零结尾时再 Dup */
症状:多语言数据流转一圈后部分字符变成 � 或凭空消失;上游编码 bug 永远不被发现。
原因:REPLACE/IGNORE 策略把非法输入"处理掉"了——输出看起来正常,数据已经损坏且无告警。
/* 边界校验用 REPLACE——上游发来坏数据也被无声洗白 */ xrtTranscode(Input, XENCODING_UTF8, XENCODING_UTF16_LE, XUTF_REPLACE, true, ...);
/* 系统边界先严格校验,确认合法后再谈转换 */ if ( xrtTranscode(Input, XENCODING_UTF8, XENCODING_UTF16_LE, XUTF_STRICT, true, ...) == NULL ) { RejectInput(); /* 非法序列在这里被拒绝——bug 在源头暴露 */ }
编程练习
对 "XRT 你好 😀" 分别输出 UTF-8 字节数、UTF-16 单元数、标量数(14 / 9 / 8);再造一个纯 emoji 字符串与一个纯中文字符串分别复测——三种计量的差距随内容构成变化,各记一组数字,值得亲手多看几组。
实现 truncate_utf8(视图, 最大标量数):按标量截断、边界对齐、超长时末尾补省略号(… 也是 3 字节,别忘了计入)。用 10 组含中英 emoji 混合的输入验证无乱码。
写一对 open_utf8(路径)/read_dir_utf8():内部路径转 UTF-16 调用宽字符系统 API、返回的 UTF-16 结果转回 UTF-8——严格模式,非法序列报错带位置。验收标准:中文路径的创建/打开/枚举全程往返无损;构造一个含代理项孤立字节的非法路径,错误报告指出问题单元的位置。
小结
| 知识点 | 速查 |
|---|---|
| 主线 | UTF-8;转换只在系统/协议边界发生,边界两侧各转一次 |
| 三种计量 | 字节 / UTF-16 单元 / 标量——先问计量再答长度 |
| 转码 | xrtTranscode 全向;BOM 参数化;输出大小可推导 |
| 错误策略 | STRICT 默认(边界校验);REPLACE 显式用于展示路径 |
| 标量安全 | xrtUtf8Slice/Utf8Count/Utf8Distance 按字符操作 |
| 平台边界 | Windows API 走 To16 进、To8 出 |
| BOM | 协议已声明不加;文件落盘建议加(布尔参数独立控制) |
| 流式 | xutf8state 逐块喂入校验(Init/Feed/Error 三步),跨块边界不误判 |
| 字符口径 | 标量 ≠ 用户感知字符(字簇);业务多数用标量,展示层按需升级 |
📖 本章涉及的 API 参考手册:charset(81 个条目)
点击查看完整签名、参数约束、返回值与错误说明