本章导读

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概念

三种编码的形态差异

1UTF-8标量编为 1~4 字节;ASCII 兼容;XRT 主线与网络默认
2UTF-16标量编为 1~2 个 16 位单元;增补平面(如 😀)需代理对
3UTF-32标量定长 4 字节;内部计算方便,传输罕见

"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:

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;
}
$ gcc -O1 -DXRT_MODULE_ALL -I single impl.c examples/charset/unicode/main.c -lws2_32 -liphlpapiUTF-16 units: 9XRT 你好 😀scalar slice: 你好

刚才发生了什么。 ① xrtUtf8ViewTo16 把 UTF-8 视图转成拥有式 UTF-16 数组(严格模式),出参带回单元数 9——不是 8:😀 的代理对占了 2 单元,"单元数 ≠ 标量数"的最直观教具。② xrtUtf16ViewTo8 再转回来,输出原样——往返零损失,严格模式保证双向都是合法序列。③ xrtUtf8Slice 按标量下标切出 你好——切点落在字符边界,即使给的字节位置恰好是"你"的中间字节,函数也会对齐到边界而不是切出半字。Windows 边界的标准用法就是这两步:出系统调用前 To16、回来后 To8。

完整程序:带 BOM 的转码封包

来自 examples/charset/transcode/main.c:

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;
}
$ gcc -O1 -DXRT_MODULE_ALL -I single impl.c examples/charset/transcode/main.c -lws2_32 -liphlpapiUTF-16 LE bytes with BOM: 14

刚才发生了什么。 ① 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 截断,是混用的标准姿势。

常见错误

坑 1:按字节截断用户文本

症状:中文/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 */
坑 2:转码错误被静默吞掉

症状:多语言数据流转一圈后部分字符变成 � 或凭空消失;上游编码 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 混合的输入验证无乱码。

挑战:Windows 文件名桥

写一对 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 个条目)

点击查看完整签名、参数约束、返回值与错误说明