Unicode 字符串处理

· · 科技·工程

字符编码:从 ASCII 到 Unicode

计算机本质上是处理数字运算的机器。为了用计算机处理文本信息,需要把每个可能的字符,通过某种方式映射到整数上,这个过程就叫字符编码。

ASCII 是最早的,也是目前最通用的字符编码。它用数字 0127 编码了英文字母、数字、标点符号和一些控制字符。例如,以下是一些常用的 ASCII 编码:

字符 编码
0 48
A 65
a 97

字符输入输出的本质仍然是数字交换。例如 putchar('a'),本质上是“要求操作系统,把 97 这个数字作为字符显示在控制台上”。

起初,人们以为 ASCII 已经可以表示全部字符。然而随着计算机的推广,这 128 个字符完全无法满足更多语言的表达需求。一些欧洲国家开始利用 128255 这些码位,于是出现了各式各样的编码,以满足各自的需求。从这时起,计算机的字符编码系统就开始变得愈发混乱。

例如,我们希望计算机输出编码为 163 的字符。

在面向英语国家的 Latin-1(ISO-8859-1)编码中,163 对应的是英镑符号 £;但在俄语常用的 KOI8-R 编码中,163 对应的却是西里尔字母 п(俄语中“п”发音类似英文“p”)。所以对于同样的数据,如果字符编码不同,显示出的字符也会大不相同。

同一个数字 163,在不同编码规则下被映射成了完全不同的字符;而文件在磁盘里只忠实地存储着 163 这个字节,至于它该被解读成 £ 还是 п,全看读取时用了哪种编码规则。规则对不上,文字自然就“乱”了。

而无论是硬盘存储、网络传输等各种场景,本质上记录的都是每个字节对应的数字。所以,若是打开文件时使用的字符编码,和编写文件时使用的编码不同,就会变成毫无意义的文字,这就是“乱码”。

很显然,单个字节是不足以表示各种字符的,于是出现了宽字符(一个字符占用两字节或更多)和变长字符(一个字符可能占用的字节数不同)。GB2312 是最早的中文编码方式之一,使用一个字节表示英文(兼容 ASCII)、两个字节表示中文,是一种变长编码。

人们迫切需要一种编码,可以表达全球的语言,于是 Unicode 出现了。它收集和整理全球所有语言的文字,希望可以创造一种通用的编码,彻底结束当前的这种乱象。

开始时,人们发现,两个字节可以表示 65536 种字符,认为这已经足够了。这就是 UCS-2 编码,统一使用双字节的字符(定长编码),用来表示不同语言中的文字。大量的程序开始使用这种编码处理文本。

后来,随着越来越多的文字和符号加入了 Unicode 字符集,两个字节已经无法满足需要。最终,Unicode 决定采用以下三种编码方式:

现在的 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(左侧为低地址)。

这里的问号部分,需要将对应字符的 Unicode 编号转换为二进制,按照低地址对应数字高位的顺序填充。

例如,编号为 0b11111100000 的码位,字节序列为 1101 1111 1010 0000

现状:秩序与混乱并行

UTF-8 当前已经统一了大多数领域,例如几乎所有的网页和现代化软件。

但是,尤其在 Windows 系统上,很多老旧程序编码依然混乱,乱码问题依然存在。

为了理清发展的整体脉络,让我们再次回顾历史。

那时,Unicode 组织已经建立,并且收集了很多的常用文字和符号,也提出了 UCS-2 编码。

在此之前,程序想要输出一串文字,会向系统提供一个字节流(const char *),但是系统也不知道具体的编码方式,于是引入了“代码页”的设定,由代码页决定内容的编码方式。默认会使用系统语言对应的编码,例如中文使用 GBK(CP936)。

随着 UCS-2 的流行,人们开始使用一个名为“宽字符”的工具处理多语言,即 C/C++ 中的类型 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 仍旧分为两个版本:

而平时使用的 cin 等操作,往往都属于前者,使用用户本地编码(在中文系统下默认为 GBK)。很多较为老旧的软件,往往也都在使用本地编码。

其实 Windows 在现在已经尝试进行改变。大约五年前,Windows 推出了一个系统设置,允许把 ANSI 模式下的默认编码设置成 UTF-8,不过可能导致一些软件乱码,破坏兼容性,所以至今都还是“beta”状态。

从原理到实践:实现简易 unicode-string 库

正如上文所说,当前的乱码问题确实存在,并且 C++ 标准库并没有提供好用的解决方案处理 Unicode 字符串。

尽管早已出现成熟的第三方库进行相关处理,但是出于探索和学习的目的,我尝试自己编写了一个简易的库,希望可以满足一些对多语言字符的处理要求。

欢迎大家来尝试使用,个人能力有限,未经完善测试,更多是作为一次实验。如果它出现了任何问题,或者你有任何疑惑,欢迎和我进行交流。

概述

本库实现了以下两个类及其相关操作:

本库允许以码位为单位控制 Unicode 字符串(对于中文场景可以和字符等价),并且可以自动处理多种系统环境下输入输出的编码。同时通过“代理对象”的设计,可以兼容 reverseshuffle 等标准库算法。

底层设计

综合考虑实现难度和可用程度,我选择将 Unicode 码点作为基本元素处理,并在以下简称为“字符”。

字符串内部将码位按照整数进行存储,逐字节存储数据(使用 vector<uint8_t>),但是为了支持 O(1) 的随机访问,我采用了一个比较直接的方法——添加空隙,对齐元素,而对齐量取决于最宽的一个字符。这是一个设计权衡,牺牲一些空间,但是有更高的随机访问速度。unicode_char 的内部也是直接用整数存储码位,在输入输出时自动转换为 UTF-8。

直接存储码位数值,信息密度会略高于 UTF-8,例如 ASCII 字符仍然是 1 字节,但是常用的中文字符可以缩减到 2 字节。

基础功能

下标访问

当进行下标访问的时候,程序需要跳转到相应内存位置,读取接下来 1~4 个字节的内容。显然这种情况,不能直接返回引用(因为这片内存的大小不定),所以我设计成:下标访问返回右值,提供额外的 assign_at 方法允许修改内容。

迭代器

我对于迭代器的设计比较复杂,分别实现了常量迭代器和可变迭代器。

代理(proxy)不可以完全模拟引用的行为,为了避免一些潜在的错误使用,我决定要求用户必须通过显式调用才能使用可变迭代器,具体地:

下标访问不返回代理对象也是同理。

输入输出

可以直接使用 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 编码的字典序)和哈希,可以使用 setunordered_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::stringstd::u8string 等类型构造 unicode_string,也可以通过 string()u8string() 函数转换为对应类型。

完整代码

见 云剪贴板。