Unicode 字符串处理
normalpcer · · 科技·工程
字符编码:从 ASCII 到 Unicode
计算机本质上是处理数字运算的机器。为了用计算机处理文本信息,需要把每个可能的字符,通过某种方式映射到整数上,这个过程就叫字符编码。
ASCII 是最早的,也是目前最通用的字符编码。它用数字
| 字符 | 编码 |
|---|---|
0 |
48 |
A |
65 |
a |
97 |
字符输入输出的本质仍然是数字交换。例如 putchar('a'),本质上是“要求操作系统,把 97 这个数字作为字符显示在控制台上”。
起初,人们以为 ASCII 已经可以表示全部字符。然而随着计算机的推广,这 128 个字符完全无法满足更多语言的表达需求。一些欧洲国家开始利用
例如,我们希望计算机输出编码为 163 的字符。
在面向英语国家的 Latin-1(ISO-8859-1)编码中,163 对应的是英镑符号 £;但在俄语常用的 KOI8-R 编码中,163 对应的却是西里尔字母 п(俄语中“п”发音类似英文“p”)。所以对于同样的数据,如果字符编码不同,显示出的字符也会大不相同。
同一个数字 163,在不同编码规则下被映射成了完全不同的字符;而文件在磁盘里只忠实地存储着 163 这个字节,至于它该被解读成 £ 还是 п,全看读取时用了哪种编码规则。规则对不上,文字自然就“乱”了。
而无论是硬盘存储、网络传输等各种场景,本质上记录的都是每个字节对应的数字。所以,若是打开文件时使用的字符编码,和编写文件时使用的编码不同,就会变成毫无意义的文字,这就是“乱码”。
很显然,单个字节是不足以表示各种字符的,于是出现了宽字符(一个字符占用两字节或更多)和变长字符(一个字符可能占用的字节数不同)。GB2312 是最早的中文编码方式之一,使用一个字节表示英文(兼容 ASCII)、两个字节表示中文,是一种变长编码。
人们迫切需要一种编码,可以表达全球的语言,于是 Unicode 出现了。它收集和整理全球所有语言的文字,希望可以创造一种通用的编码,彻底结束当前的这种乱象。
开始时,人们发现,两个字节可以表示 65536 种字符,认为这已经足够了。这就是 UCS-2 编码,统一使用双字节的字符(定长编码),用来表示不同语言中的文字。大量的程序开始使用这种编码处理文本。
后来,随着越来越多的文字和符号加入了 Unicode 字符集,两个字节已经无法满足需要。最终,Unicode 决定采用以下三种编码方式:
- UTF-8:变长编码,空间浪费较少,完全兼容 ASCII。
- UTF-16:变长编码,每个字符至少占用 2 字节。
- UTF-32:定长编码,每个字符至少占用 4 字节。
现在的 UTF-16 几乎失去了优势,但是由于历史依赖,仍然有一定的使用;而 UTF-8 由于它的这些优点,成为了绝对的主流编码。
关于 Unicode 的深入解析
关于 Unicode 及其编码,有一些较为复杂的概念,本章节将会稍加讲解。
Unicode 字符集
Unicode 把大量的文字和符号编成了一个字符集,并且给每一个字符都设定了一个 Unicode 数字编号。例如汉字“一”的编号是 0x4e00(19968),“乙”是 0x4e59(20057)。
然而,这里的“字符”和用户感知的字符还不太相同,更合适的称呼叫做“码位”(code point)。例如字符 Á 就是通过两个码位拼接的(\u0041\u0301)。
UTF-8 编码
UTF-8 指定了如何将一个 Unicode 码位转换为内存中的字节序列,例如把汉字“一”变为 e4 b8 80 这三个字节。
具体来讲,会从以下几种方案中,选定最小的足以存储该字符的方案。均通过二进制展示,? 代替任意一个 0 或 1(左侧为低地址)。
- 1 字节:
0??? ????。 - 2 字节:
110? ???? '10?? ????。 - 3 字节:
1110 ???? '10?? ???? '10?? ????。 - 4 字节:
1111 0??? '10?? ???? '10?? ???? '10?? ????。
这里的问号部分,需要将对应字符的 Unicode 编号转换为二进制,按照低地址对应数字高位的顺序填充。
例如,编号为 0b11111100000 的码位,字节序列为 1101 1111 1010 0000。
现状:秩序与混乱并行
UTF-8 当前已经统一了大多数领域,例如几乎所有的网页和现代化软件。
但是,尤其在 Windows 系统上,很多老旧程序编码依然混乱,乱码问题依然存在。
为了理清发展的整体脉络,让我们再次回顾历史。
那时,Unicode 组织已经建立,并且收集了很多的常用文字和符号,也提出了 UCS-2 编码。
在此之前,程序想要输出一串文字,会向系统提供一个字节流(const char *),但是系统也不知道具体的编码方式,于是引入了“代码页”的设定,由代码页决定内容的编码方式。默认会使用系统语言对应的编码,例如中文使用 GBK(CP936)。
随着 UCS-2 的流行,人们开始使用一个名为“宽字符”的工具处理多语言,即 C/C++ 中的类型 wchar_t。它在当时是一个非常好用的工具,把文本处理简化成了一个二选一:
- 纯 ASCII 场景,使用窄字符
char即可,不需要依赖“本地编码”。 - 否则,统一使用宽字符
wchar_t,完美支持全部语言。
而且宽字符代表的 UCS-2 是一种定长编码,自然支持和 char * 同样的随机访问等操作。这些特点都大大简化了人们对这些字符的处理,而 C/C++ 等语言的标准库的字符串操作也都为宽字符提供了相同工具,Java 等新兴语言更是直接采用它作为底层实现。
然而随着 Unicode 字符集的膨胀,人们发现两个字节的 65536 种可能性已经即将被穷尽。随着作为 UCS-2 后继的 UTF-16 成为变长编码,“宽字符一统世界”的想法成为了梦幻泡影。UTF-32 的空间占用过大,而 UTF-16 也失去了“操作简便”的优势,UTF-8 成为了一个不错的选择。
Mac/Linux 系统选择了接纳 UTF-8,而一直以强大“历史兼容性”著称的 Windows,选择坚守 UTF-16。如今的 Windows API 仍旧分为两个版本:
- ANSI 版本:窄字符
char,使用用户本地编码。 - Unicode 版本:宽字符
wchar_t,统一使用 UTF-16 编码。
而平时使用的 cin 等操作,往往都属于前者,使用用户本地编码(在中文系统下默认为 GBK)。很多较为老旧的软件,往往也都在使用本地编码。
其实 Windows 在现在已经尝试进行改变。大约五年前,Windows 推出了一个系统设置,允许把 ANSI 模式下的默认编码设置成 UTF-8,不过可能导致一些软件乱码,破坏兼容性,所以至今都还是“beta”状态。
从原理到实践:实现简易 unicode-string 库
正如上文所说,当前的乱码问题确实存在,并且 C++ 标准库并没有提供好用的解决方案处理 Unicode 字符串。
尽管早已出现成熟的第三方库进行相关处理,但是出于探索和学习的目的,我尝试自己编写了一个简易的库,希望可以满足一些对多语言字符的处理要求。
欢迎大家来尝试使用,个人能力有限,未经完善测试,更多是作为一次实验。如果它出现了任何问题,或者你有任何疑惑,欢迎和我进行交流。
概述
本库实现了以下两个类及其相关操作:
unicode_string:Unicode 字符串。unicode_char:Unicode 码位(字符)。
本库允许以码位为单位控制 Unicode 字符串(对于中文场景可以和字符等价),并且可以自动处理多种系统环境下输入输出的编码。同时通过“代理对象”的设计,可以兼容 reverse、shuffle 等标准库算法。
底层设计
综合考虑实现难度和可用程度,我选择将 Unicode 码点作为基本元素处理,并在以下简称为“字符”。
字符串内部将码位按照整数进行存储,逐字节存储数据(使用 vector<uint8_t>),但是为了支持 unicode_char 的内部也是直接用整数存储码位,在输入输出时自动转换为 UTF-8。
直接存储码位数值,信息密度会略高于 UTF-8,例如 ASCII 字符仍然是 1 字节,但是常用的中文字符可以缩减到 2 字节。
基础功能
下标访问
当进行下标访问的时候,程序需要跳转到相应内存位置,读取接下来 1~4 个字节的内容。显然这种情况,不能直接返回引用(因为这片内存的大小不定),所以我设计成:下标访问返回右值,提供额外的 assign_at 方法允许修改内容。
迭代器
我对于迭代器的设计比较复杂,分别实现了常量迭代器和可变迭代器。
- 常量迭代器:解引用直接返回对应右值,类似下标访问。
- 可变迭代器:使用类似
bitset或者vector<bool>的“代理”设计,来模拟引用的行为。
代理(proxy)不可以完全模拟引用的行为,为了避免一些潜在的错误使用,我决定要求用户必须通过显式调用才能使用可变迭代器,具体地:
begin()/end()返回常量迭代器。mut_begin()/mut_end()返回可变迭代器。to_mut()返回一个包装类,调用它的begin()/end()相当于调用当前对象的mut_begin()/mut_end()。
下标访问不返回代理对象也是同理。
输入输出
可以直接使用 iostream 进行输入输出(例如 cin/cout)。正如上文所说,它们和系统交互是通过 ANSI 编码,所以我选择通过调用 Windows API(通过预处理器条件编译,不会影响其他系统),在主函数开始前将系统代码页设置为 65001,这样程序就会使用 UTF-8 字节流和系统交互。
使用示例
可以正常使用很多的标准库算法。
using namespace unicode;
unicode_string s;
std::cin >> s;
// 打乱字符串
std::mt19937 rng{std::random_device{}()};
std::ranges::shuffle(s.to_mut(), rng);
std::cout << s << "\n";
for (auto ch/*unicode_char*/ : s) {
std::cout << ch << " ";
}
输入:
这是一段测试文本
可能的输出:
段文这是本试一测
段 文 这 是 本 试 一 测
扩展功能
我并没有实现字符串拼接、插入元素等功能,如有需要,可在此基础上自行扩展。
但是为了方便使用,我还提供了一些额外的扩展功能。
更方便的初始化
引入 unicode::unicode_literals 命名空间之后,可以使用字面量后缀来快速创建 unicode_string。
using namespace unicode::unicode_literals;
auto s = "你好!"_utf8; // 源代码文件编码需要是 utf-8
auto t = "喵~"_ansi; // 源文件编码需要是本地编码,一些老旧的编辑器上可能有用
// s, t 类型都是 unicode_string
std::cout << s << t << "\n";
标准库支持
unicode_string 支持比较(Unicode 编码的字典序)和哈希,可以使用 set、unordered_map 等容器存储。
unicode_string 支持 std::format 相关操作。
Unicode 编码操作
可以查询字符的 unicode 编码。
unicode_char ch = U'可'; // 从 char32_t 转换
auto ord = ch.ord();
auto chr = unicode_char::chr(ord);
std::cout << ord << ' ' << chr << '\n';
输出:
21487 可
类型转换
支持从 std::string 或 std::u8string 等类型构造 unicode_string,也可以通过 string() 和 u8string() 函数转换为对应类型。
完整代码
见 云剪贴板。