Unicode入门介绍和学习总结

2020-10-23 17:47:16 浏览数 (1)

[TOC]

0x01 前言介绍

描述:Unicode(中文:万国码、国际码、统一码、单一码)是计算机科学领域里的一项业界标准。它对世界上大部分的文字系统进行了整理、编码,使得计算机可以用更为简单的方式来呈现和处理文字。 Unicode至今仍在不断增修,每个新版本都加入更多新的字符。目前最新的版本为2019年5月公布的12.1该版本只新增了一个字符即日本新年号令和的合字。

简单的说:Unicode是一套通用的字符集,包含世界上的大部分文字,也就是说Unicode是可以表示中文的

序言:程序员对 Unicode 这个名字发自内心的恐惧和敬畏。我们都知道在我们的软件中应该 “支持 Unicode”。 但 Unicode 很深奥,它有上千页的 ![Unicode 标准][1] ,还有几十页的补充附录、报告和 ![注解][2],简直太吓人了。即使 Unicode 诞生 30 多年后,程序员们还觉得它很神秘。 – Nathan Reed

Unicode相关点:

  • 字符集
  • 字符串处理
  • Unicode 文本
  • 字体
  • 文本布局
  • 形状
  • 渲染
  • 本地化;

Unicode存在多样性和内在复杂性: 当你开始学习 Unicode,有一件事情很明显,就是它和你熟悉的字符集(比如 ASCII)相比Unicode 复杂性要高了一大截。这不仅仅是指 Unicode 包含了很多的字符,虽然这是一个方面。Unicode 还有很多内部结构,特性和特殊情况,使其不只是人们所认为的纯粹的 “字符集”。

当面对所有的复杂性时,尤其是作为工程师,很难不问自己,”为什么我们需要这么多?真的有必要吗?可以简化吗?”

描述:然而Unicode 的目标是准确地表示全世界的书写系统writing systems。Unicode 协会的目标是”让全世界的人们不论什么语言都可以使用电脑”,所以你可想见,书面语言的多样性是巨大的!迄今为止,Unicode 支持135 种不同的书写系统,包含约 1100 种语言,但目前还有超过 100 种书写系统没有支持[3],包括现代的和已成为历史的,Unicode 协会还在努力将其加进来。

鉴于分支的多样性,要表示它们必然是一个复杂的项目。Unicode 接受了它的多样性,接受了任务(包含所有人类的书写系统)中的内在复杂性,它没有在名字简化上做太多取舍,但是它对需要完善任务的地方的规则,做了异常处理。

此外,Unicode 承诺不仅支持单一语言的文本,还支持多种语言共存于一个文本中——引进了更多的复杂性。

大多数编程语言都有处理底层文本操作的的库,但是作为程序员,你仍然需要知道一些 Unicode 特性 ,知道何时怎样去应用它。要了解这些东西可能得花些时间动动脑筋,但别灰心——想想有数以亿计的人,如果你的软件支持他们的语言,那他们也可以使用你的软件的。所以,拥抱复杂吧!

Unicode 是字符集?

Unicode 与字符编码有什么区别?

0x01 Unicode 原理
1.编码空间

描述:我们先从几个大的方向入手Unicode 的基本元素 —— 它的 “字符”,虽然这种叫法不是太贴切——被称作编码点Code Point。编码点通过数字来区分,通常写成 16 进制的形式再加前缀”U ”,例如 U 0041 表示拉丁字母 “A”[4] 、U 03B8 表示 希腊字母 “θ”[5]。每个编码点都有一个简称,还有一些其他属性,Unicode 字符数据库[6] 对此有详细说明。

所有编码点组成的集合被称作编码空间Code Space。Unicode 编码空间包含 1,114,112 个编码点。然而,其中只有128,237 个编码点 —— 编码空间的 12% 被赋值,目前。还有很多空间用来增长!Unicode 还保留了另外 137,468 字符 作为 “自用” 空间,这些字符没有标准的含义,可以被个人应用所使用。

2.空间分配

描述:为了对编码空间的布局有个了解,把它可视化会比较直观。下面是整个编码空间的布局,一个像素代表一个编码点。使用小方块来表示以保证视觉的一致性;每个小方块是 16×16 = 256 个编码点,每个大方块是一个面有256*256= 65536 个 编码点。

总共加起来有 17 个大方块(面板)如你所见,被使用的区域分布有点稀疏,但都集中在前三个面里。

  • 白色表示未用空间;
  • 蓝色表示已用空间;
  • 绿色表示自用区域;
  • 小的红色区域是代理区 surrogates

WeiyiGeek.

  1. 0号面板也被称作 “基本多语言面板Basic Multilingual Plane(简称 BMP)”。BMP 包含现代文本所需的基本所有字符,包括拉丁文、斯拉夫文、希腊文、汉字(中国),日文、朝鲜文、阿拉伯文、希伯来文、梵文(印度)等等。

(过去,编码空间只有 BMP 而已—— Unicode 最初设想是 一个 16 Bit 的编码,只包含 65536 个字符。在 1996 年扩充到现在的规模。然而,绝大多数现代字符属于 BMP。)

  1. 1号面板包含历史上的文字,比如苏美尔楔形文字和埃及象形文字,还有 emoji 和其他各种符号。
  2. 2号面板包含一大块不常用的和历史上的汉字字符,剩下的面是空的,
  3. 14号面板中有一小部分被用作格式化字符;
  4. 15-16号面板全部保留自用。
3.书写系统

让我们放大前三个面板,因为这是最重要的部分:

WeiyiGeek.

这张图用颜色表示了 Unicode 中135 种不同的书写系统。你可以看到汉字(蓝色)和朝鲜语(棕色)占了 BMP 很大一部分(右边的大方块)。与之相对,此图中所有的欧洲,中东,南亚语言加起来刚好占了 BMP 的第一行。

编码空间的很多区域都和更早的编码兼容或相同。例如Unicode 的前 128 个字符就是 ASCII 的拷贝。显然是对兼容性很有好处——很容易无损的从小编码转向 Unicode (反过来也一样,只要没有使用小编码之外的字符)。

4.使用频率

可视化编码空间还有一个有趣的方法,就是看使用频率的分布——换句话说,就是每个编码点在真实世界中使用的频率。0-2 号面的热力图是基于来自维基百科 和 推特(所有语言)的大量文本所得。频率增长的方向是黑(没出现)、红、黄、白。

WeiyiGeek.

你可以看到,绝大多书样本文本都分布在 BMP 中,有些零散的使用来自1-2 号面。最大的异常是 emoji,它点亮了 1 号面最底下那的几个小方块。

5.编码分类(通用转换格式)

描述:Unicode虽然统一了全世界字符的编码,但没有规定如何存储。由于存在符号/字符用的空间是存在不同的,比如中文字符符号就要用三个或四个字节表示,而英文字符只占用一个字节,所以英文字母前都必然有二到三个字节是0;

为了解决这个问题就出现了一些中间格式的字符集,他们被称为通用转换格式即UTF(Unicode Transformation Format)。 常见的UTF格式有:UTF-7, UTF-7.5, UTF-8,UTF-16, 以及 UTF-32

代码语言:javascript复制
# Unicode的实现方式
UTF-8 使用一至四个字节为每个字符编码
UTF-16 使用二或四个字节为每个字符编码
UTF-32 使用四个字节为每个字符编码

举个例子:Unicode规定了一个中文字符 “我”对应的unicode是”u6211”,但是在UTF-8和UTF-16等不同的实现方式下,这个二进制code的存储方式是不一样的。

我们知道 Unicode 编码点通过它们在编码空间中的下标来定义, 范围从U 0000 到 U 10FFFF但是在内存或文件中编码点如何用字节表示呢?

对计算机友好的最省事方式是用 32 (2^32 = 4 294 967 296 )位整数来存储编码点下标。这样做是可行但是每个字符用 4 个字节有点浪费。当你处理大量文本的时候,使用 32 位整数存储 Unicode 会占用大量额外存储、内存、带宽等。

最常见的是你会看到 Unicode 文本被编码为UTF-8 或 UTF-16。这些都是可变长度编码分别由 8-bit 或 16-bit 或者 32bit 为一个单元组成。这些方案中,下标值较小的编码点占用的字节数也少,会节省不少内存。这样做的代价是处理 UTF-8/16 需要以编程的方式来处理会慢一些。

于是Unicode 有了几个紧凑的编码 :

  • UTF-32:32位整数编码被称作 UTF-32(UTF=”Unicode Transformation Format”),但是很少被用来存储。顶多作为临时内部表示出现,用来检查或操作字符串中的编码点。
  • UTF-8使用可变长度字节来储存 Unicode字符,例如ASCII字母继续使用1字节储存,重音文字、希腊字母或西里尔字母等使用2字节来储存,而常用的汉字就要使用3字节。辅助平面字符则使用4字节。
    • UTF-8:在 UTF-8 中,每个编码点依据下标值,被存储为 1 到 4 个字节,基本保证覆盖多种字符文字;
    • UTF-8 使用二进制前缀系统,在此系统中每个字符的最高位的几个比特表明它是否是单个字节,多字节序列的开始,或中间字节;剩余的比特连接起来表示编码点的下标。下面的表格展示了 UTF-8 是如何编码的:

TF-8 (二进制)

编码点 (二进制)

范围

0xxxxxxx

xxxxxxx

U 0000–U 007F

110xxxxx 10yyyyyy

xxxxxyyyyyy

U 0080–U 07FF

1110xxxx 10yyyyyy 10zzzzzz

xxxxyyyyyyzzzzzz

U 0800–U FFFF

11110xxx 10yyyyyy 10zzzzzz 10wwwwww

xxxyyyyyyzzzzzzwwwwww

U 10000–U 10FFFF

UTF-8 有一个方便的属性,即最开始128 个字符(ASCII字符)被编码为单个字节,所有的非 ASCII 字符被编码为 128-255。这产生了两个好处。首先,任何已经是 ASCII 编码的字符串和文件无需转换就可以被 UTF-8 识别。其次,大量的广泛使用的编程惯例——比如 NULL 结尾,分隔符(换行、制表符、逗号,斜杠)等——在 UTF-8 中也是可用的。ASCII 字节不会出现在非 ASCII 编码点中,所以搜索以 NULL 结尾或分隔符结尾的字符串是可以的。

使扩展遗留 ASCII 程序和 API 来处理 UTF-8 字符变得简单。UTF-8 被广泛运用在 Unix、Linux 和网络世界中,还有许多程序员主张 UTF-8 应该作为任何地方的默认编码

然而UTF-8 还不能全面替代 ASCII。例如,遍历字符串中的 “字符” 的代码需要解码 UTF-8 并遍历编码点(或字位簇grapheme cluster),而不是字节。当你测量字符串 “长度” 时,你得考虑是要字节长度,还是编码点长度,还是文本渲染的宽度为单位的长度还是其它长度 UTF-8编码格式下,一个汉字需要至少3个char才能表示(也让汉字在网络传输上存在劣势,占用太多流量).

  • UTF-16 你可能遇到的另一个编码是 UTF-16,它使用 16-bit 字,每个字符被存储为 1 个或 2 个字节=16bit.和 UTF-8 一样,我们可以用二进制前缀的形式表示 UTF-16 的编码规则:

UTF-16(二进制)

编码点(二进制)

范围

xxxxxxxxxxxxxxxx

xxxxxxxxxxxxxxxx

U 0000–U FFFF

110110xxxxxxxxxx 110111yyyyyyyyyy

xxxxxxxxxxyyyyyyyyyy 0x10000

U 10000–U 10FFFF

但是通常人们谈到 UTF-16 是因为它涉及到了一个在编码点术语中被称作”代理surrogate”的东西。所有在范围U D800-U DFFF(或在其他范围) 中的编码点,这些和上表中二进制前缀 110110 和 110111 匹配的编码点——是 UTF-16 中的保留区域,它们自身不表示任何有效的字符。它们仅用于上面 2 个字的编码模式中,被称作”代理对surrogate pair”,代理编码点在任何其他情况下都是非法的!它们不能出现在 UTF-8 和 UTF-32 中。

在过去,UTF-16 是1996 年之前的 Unicode 版本的派生物,那时只有 65536 个编码点。初衷是不应有不同的编码,Unicode 应该是简单的16-bit 字符集。后来编码空间被扩充用来表示不常用的(仍然重要)的汉字字符,这是 Unicode 设计者之前没计划的。代理区在那时被引进,直说了吧作为拼凑,允许16-bit 编码访问新的编码点。

如今Javascript 使用 UTF-16 作为其标准的字符串表示:如果你问一个字符串的长度,或遍历它等,结果都以 16-bit 的字为单位,同时任何 BMP 之外的编码点都用代理对表示。UTF-16 也被微软 WIN32 API 使用;尽管 Win32 同时支持 8-bit 和 16-bit 字符串,但是 8-bit 版本仍然莫名其妙地不支持 UTF-8——只支持使用旧编码的代码,像 ANSI。这使得 UTF-16 成为在 Windows 上获得 Unicode 支持的唯一方法。

顺便说一下,UTF-16 字符可以大端存储,也可以小端存储。Unicode 在这个问题上没有说明,虽然它确实鼓励一个惯例,即把 U FEFF 零宽无间断间隔[8]这个字符放到 UTF-16 文件开头作为字节序标识[9],来消除字节序问题。(如果文件和系统的字节序不同,BOM(Byte Order Mark) 会被解码为 U-FFFE,这不是一个有效的编码点。)

6.组合标记

Unicode 中,字符比单独的编码点更复杂!

Unicode 包含一个系统,可以合并多个编码点,动态组合字符。此系统用各种方式增加灵活性,而不引起编码点的巨大组合膨胀。

例如,在欧洲语言中,组合标记出现在变音符和字母的使用中。 Unicode 支持各种各样的变音符号,包括尖音符号的和重音符号、元音变音符号、变音符号等等。所有这些变音符可以被使用在任何字母表的字母中。事实上,多个变音符号可以被使用在一个字母上。

如果 Unicode 试图为每个字母组合或变音符组合分配一个独立的编码点,事情会变得无法控制。相反,动态组合系统可以让你构造你想要的任何字符,通过以一个基础编码点(字母)开始然后附加额外的编码点,被称作”组合标识”,来指定变音符。当一个文字渲染器看到字符串中有这样的序列时,它会自动堆叠变音符到基础字母的上面或下面来造出一个组合字符。

例如,带重音的字符”Á” 会被表示成由两个编码点组成的字符串:U 0041 “A” 拉丁大写字母 a[10] 加上 U 0301 “◌́”组合尖音符号[11]。这个字符串自动被渲染成单个字符:”Á”

如今,Unicode 还包含许多 “预设的” 编码点,每个表示一个被使用过的组合,例如 U 00C1 “Á” 带锐音符的拉丁大写字母A [12]或 U 1EC7 “ệ” 带扬抑符和下点的小写拉丁字母 e[13]。

我怀疑这些大多继承自融入 Unicode 的旧编码,来保证兼容性。实际上,对于欧洲语言中的大多数常见的带变音符号的字母都有预设,所以文本中动态组合用的不多。

可是,组合标志系统确实允许任意数量的变音符号被叠加到任何基础字符上。使用归谬法的 Zalgo 文本![][14],它通过随机叠加任意数量的变音符号在每个字母上,让它溢出行距,产生混乱现象。(如下图)

WeiyiGeek.

Unicode 中出现动态组合字符的其他地区: 阿拉伯文和希伯来文中的元音标记[15] 。这些语言中,单词通常由元音拼写。它们有变音符号标记元音(用在字典,语言教学材料,儿童教材,等地方)。这些变音符号用组合标记表示。

  • 希伯来文,带注音符号: אֶת דַלְתִּי הֵזִיז הֵנִיעַ, קֶטֶב לִשְׁכַּתִּי יָשׁוֹד
  • 正常文本(不带注音符号): את דלתי הזיז הניע, קטב לשכתי ישוד

天成体(梵文)[16],这种文字被用在印度北部,梵文和其他南亚语言中,用组合标记标识特定元音的附加到辅音字母上。例如,”ह” “ि” = “हि” (“h” “i” = “hi”)。

表示音节的朝鲜字符,但是它被称作Jamo[17] ,用来表示音节中的元音和辅音。当然也有为朝鲜文预制的编码点,同时也可以动态组合它们的 jamo。例如,”ᄒ” “ᅡ” “ᆫ” = “한” (“h” “a” “n” = “han”).

7.规范等价性

Unicode 中,预设字符和动态组合系统并存。后果就是有多种方法表示同一个字符串——不同编码点序列产生相同用户可感知的字符。

例如,我们之前看到的,表示字符 “Á”,我们可以用一个编码点 U 00C1 ,也可以用两个编码点 U 0041 和U 0301。

另一个歧义来源是一个字符中的多个注音符号。当两个注音符号作用在同意个基本字符上面时,注音符号的顺序很重要,例如,都在上面:”ǡ” (点然后长音符)和 “ā̇” (长音符然后点)是不一样的。 然而,当音节运用在不同边时,例如。一个在上边一个在下边,编码点的顺序不会影响渲染(是可以调换得)。此外,一个有多个音节的字符,它可能会由一个预制的编码点再加其余的编码点来表示。

例如,越南字母”ệ” 可以用以下五种方式表示:

  • 完全预设:U 1EC7 “ệ”
  • 部分预设:U 1EB9 “ẹ” U 0302 “◌̂”
  • 部分预设:U 00EA “ê” U 0323 “◌̣”
  • 完全分解:U 0065 “e” U 0323 “◌̣” U 0302 “◌̂”
  • 完全分解:U 0065 “e” U 0302 “◌̂” U 0323 “◌̣” Unicode 把这样的字符串集合称作 “规范等价”字符

在搜索、排序、渲染、文本选择等操作中,规范等价字符应该被同等对待。这影响到了你如何实现文本的操作。例如,假设你的程序有”查找”操作,用户搜索 “ệ”,理论上应当找到如上所有出现的所有版本的 “ệ”!

8.形式正规化

要解决如何处理等值字符串的问题,Unicode 定义了几种正规形式:是几种把字符串转化成规范形式的方法,这样它们就可以被逐点比较(或按字节比较)。

  • “NFD” 正规化方法,完全分解每个字符到基本部件和组合标记,去掉字符串中任何预制的编码点。还会按渲染位置排列每个组合标记,举个例子,在字母底下的注音符号要比在上边的靠前。(不会重排有相同渲染位置的注音符号,因为它们的位置关系是可视的,前面提到过。)
  • “NFC” 正规化方法,反过来,尽可能的把编码点替换成预制编码点(先尝试已有得编码点)。如果使用了不常用的注音符号组合,可能不会有任何预制的编码点,这种情况下 NFC 仍然替换它可以替换的,然后留下组合标志(和 NFD 一样,还是会按渲染顺序重新排序)。

还有一些方法被称作 NFKD 和NFKC。 这里的 “K” 指的是兼容性分解,它包含了某种程度上”相似”但是视觉上不同的字符。但我不打算讲这些。

  • 字位簇 grapheme cluster 如上所见,Unicode 包含多种情况,用户认为的一个”字符” 事实上底下可能由多个编码点组成。Unicode 使用「字位簇」的概念来表示这种情况。一个由一个或多个编码点组成的字符串构成一个 “用户感知的字符”。
  • UAX #29[18] 为字位丛定义了精确的规则。它大约是 “一个基本的编码点接着任意数量的组合标记”,但是真实的定义有点复杂;它包含了朝鲜语字母,和 emoji ZWJ 序列。

字位簇主要被用在文本编辑:它们对光标和文本选择来说是最明显的单元。使用字位簇,确保在复制和粘贴文本时不会突然丢掉一些符号,同时左右方向键也总是以一个可见字符的距离移动,等等。

另一个用到字位簇的地方是,执行字符串长度限制——比如在数据库域中。其实,底层的限制可能是类似 UTF-8 中的字节长度之类的东西,你不能简单的通过截断字节的方式来限制长度。至少,你得 “舍去” 最近的编码点;但更好的是,舍去最近的字位簇 除此以外,你可以通过舍弃它的一个注音符号破坏一个字符,中断一个 jamo 序列或 ZWJ 序列

字符乱码

描述: 为什么会出现字符乱码的情况? 答:编码的字符集与解码的字符集不一致导致的;

比如:发电报的例子来说 发报员使用”美式摩尔斯电码”将情报转换成电报,收报员接收到电报之后,通过”现代国际摩尔斯电码”进行破译。那么得到的情报内容就可能完全看不懂,这就是乱码了。 就像在计算机领域,我们把一串中文字符通过UTF-8进行编码传输给别人,别人拿到这串文字之后通过GBK进行解码,得到的内容就会是“锟届瀿锟斤拷雮傡锟斤拷直锟斤拷锟”,这就是乱码。

基础示例:

代码语言:javascript复制
public static void main(String[] args) throws UnsupportedEncodingException {
    String s = "漫话编程!";
    byte[] bytes = s.getBytes(Charset.forName("GBK"));  //获取字符编码为GBK并且转换成为bytes字节
    System.out.println("GBK编码,GBK解码:"   new String(bytes, "GBK"));
    System.out.println("GBK编码,GB18030解码:"   new String(bytes, "GB18030"));
    System.out.println("GBK编码,UTF-8解码:"   new String(bytes, "UTF-8"));
}

//将中文字符,通过GBK编码,再使用UTF-8解码,得到的字符就是一串问号这就是乱码了。
GBK编码,GBK解码:漫话编程!
GBK编码,GB18030解码:漫话编程!
GBK编码,UTF-8解码:????????

小插曲:锟斤拷的前世今生

WeiyiGeek.

描述:因为Unicode是一直在更新的,在这个过程中定有一些比较新的字符他是无法表示的。或者即使Unicode发布了新版纳入了某个文字,但是很多软件系统并未升级也会有这样的问题。 就像生活中一些手机厂商新出的那些emoji表情,在自己的手机上可以正常显示,发到其他品牌的手机上可能就无法显示这其实也是字符集不支持导致的。

发生以上情况时无法显示的时候也需要有一个字符来表示的,在Unicode中这个字符就是 �,他是Unicode中定义的一个特殊字符。 也就是"0xFFFD REPLACEMENT CHARACTER"所有无法表示的字符都会通过这个字符来表示 Unicode官方有关于这个符号的介绍,从上表中可以看到,他的10进制表示是65533,在UTF-8下他的16进制形式是’0xEF 0xBF 0xBD’(三个字节)。

WeiyiGeek.

如果有两个连续的字符都无法显示,如”� �” ,那么在UTF-8编码下,16进制表示为:

代码语言:javascript复制
0xEF 0xBF 0xBD 
0xEF 0xBF 0xBD

以上这段编码,如果放到GBK中进行解码的话,因为GBK中一个汉字两个字节,那么结果就是:

代码语言:javascript复制
0xEF 0xBF, 0xBD 0xEF, 0xBF 0xBD
#即
0xEFBF #锟
0xBDEF #斤
0xBFBD #拷

所以以后再见到锟斤拷,第一时间想到UTF-8和GBK的转换问题准没错。

除了锟斤拷以外还有两组比较经典的乱码,分别是”烫烫烫”和”屯屯屯”,这两个乱码产生自VC这是debug模式下VC对内存的初始化操作。 VC会把栈中新分配的内存初始化为0xcc(字符打印烫),而把堆中新分配的内存初始化为0xcd(字符打印屯)。

附录总结

从程序员的角度来看,关于 Unicode 还有很多东西可以讲!我还没有深入一些有趣的主题,比如映射、排序、兼容性分解和容易混淆的词,Unicode 正则表达式,和双向文本。还有个我没谈到的是实现主题——如何有效存储和查找分布稀疏的编码点数据,或着如何优化 UTF-8 解码、字符串比较和NFC 标准化。

Unicode 是个令人着迷的复杂系统。在字节和编码点之前有多对一的映射,除此之外编码点和”字符”之间也有(某些情况下多对多)多对一的映射关系。在每个角落都有古怪的特例。没人声称表示全部书写系统很容易,但很明显我们不会回到使用不兼容编码来拼凑的艰难岁月了。

0 人点赞