【译】关于 Unicode 的最基本的知识

二十年前,Joel Spolsky 写道:

没有所谓的纯文本。
拥有一个字符串却不知道其所使用的编码是毫无意义的。你再也不能把头埋在沙子里假装“纯”文本就是 ASCII 了。

在过去的 20 年里发生了很多变化。2003 年,主要问题是:这是什么编码?
到了 2023 年,这不再是一个问题:有 98% 的概率是 UTF-8。终于!我们可以再次把头埋在沙子里了!

现在的问题是:我们如何正确使用 UTF-8?让我们来看看吧!

什么是 Unicode?

Unicode 是一个旨在统一所有人类语言的标准,无论是过去还是现在,并使它们能够与计算机进行交互。

在实践中,Unicode 是一个将不同字符分配给唯一数字的表格。

例如:

  • 拉丁字母 A 被分配了数字 65。
  • 阿拉伯字母 Seen س 是 1587。
  • 片假名字母 Tu ツ 是 12484。
  • 音乐符号 G 谱号 𝄞 是 119070。
  • 💩 是 128169。

Unicode 将这些数字称为码点。
由于全世界的人都同意哪些数字对应哪些字符,我们都同意使用 Unicode,因此我们可以阅读彼此的文本。

1
Unicode == 字符 ⟷ 码点。

Unicode 有多大?

目前,最大定义的码点是 0x10FFFF。这给了我们大约 110 万个码点的空间。
约有 17 万个,即 15%,目前已定义。另外 11% 保留供私人使用。其余约 80 万个码点目前未分配。它们在未来可能成为字符。
大致看起来是这样的:

大方块 == 平面 == 65,536 个字符。小方块 == 256 个字符。整个ASCII占据了左上角小红方块的一半。

什么是私用区?

这些是保留给应用程序开发人员使用的码点,不会由 Unicode 自身定义。

例如,Unicode 中没有苹果标志的位置,所以苹果将其放在了 U+F8FF,这位于私用区块内。在任何其他字体中,它会呈现为缺失的字形 􀣺,但在 macOS 预装字体中,您会看到。私用区域主要由图标字体使用:

U+1F4A9 是什么意思?

这是写码点值的惯例。前缀U+表示 Unicode,而1F4A9是一个十六进制的码点数。

哦,U+1F4A9 具体来说就是 💩。

那 UTF-8 又是什么呢?

UTF-8 是一种编码方式。编码是指我们如何将码点存储在内存中。

最简单的 Unicode 编码方式是 UTF-32。它简单地将码点存储为 32 位整数。因此,U+1F4A9 变成了 00 01 F4 A9,占用了四个字节。UTF-32 中的任何其他码点也将占用四个字节。由于最高定义的码点是 U+10FFFF,任何码点都保证能够容纳。

UTF-16UTF-8则不那么直接,但最终目标是相同的:将码点编码为字节。

编码是你作为程序员实际上会处理的内容。

UTF-8 有多少字节?

UTF-8 是一种可变长度的编码方式。一个码点可能被编码为一到四个字节的序列。

工作原理如下:

1
2
3
4
5
码点	            字节1	        字节2	      字节3	    字节4
U+0000..007F 0xxxxxxx
U+0080..07FF 110xxxxx 10xxxxxx
U+0800..FFFF 1110xxxx 10xxxxxx 10xxxxxx
U+10000..10FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

如果将此与 Unicode 表格结合起来,您会发现英语被编码为 1 个字节,西里尔字母、拉丁欧洲语言、希伯来语和阿拉伯语需要 2 个字节,中文、日文、韩文、其他亚洲语言和 Emoji 需要 3 或 4 个字节。

几个重要点如下:

  • 首先,UTF-8 ASCII 兼容。码点 0..127,即以前的 ASCII,用一个字节编码,而且是完全相同的字节。U+0041(A,拉丁大写字母 A)只是 41,一个字节。
    任何纯 ASCII 文本也是有效的 UTF-8 文本,而且任何只使用码点 0..127 UTF-8 文本都可以直接读取为 ASCII
  • 其次,UTF-8 对于基本拉丁字符来说是空间高效的。这是它比 UTF-16 的主要优势之一。对于世界各地的文本来说可能不公平,但对于像 HTML 标签或 JSON 键这样的技术字符串来说是合理的。
    平均而言,UTF-8 对于非英文计算机来说通常是一个相当不错的选择。而对于英文来说,则无与伦比。
  • 第三,UTF-8 具有内置的错误检测和恢复功能。第一个字节的前缀总是与第 2-4 字节不同。这样,您始终可以确定自己是否正在查看完整且有效的 UTF-8 字节序列,或者是否有什么东西丢失了(例如,您跳到了序列的中间)。然后,您可以通过向前或向后移动直到找到正确序列的开始位置来纠正这个问题。

还有一些重要的结论:

  • 您无法通过计算字节来确定字符串的长度。
  • 您不能随机跳到字符串的中间并开始阅读。
  • 您不能通过在任意字节偏移处进行切割来获得子字符串。您可能会切断部分字符。

那些这样做的人最终会遇到这个坏小子:�

什么是�?

U+FFFD,即替换字符,只是 Unicode 表中的另一个码点。当应用程序和库检测到 Unicode 错误时,它们可以使用它。

如果你把一个码点的一半切掉,剩下的部分就没什么可做的了,除了显示一个错误。这时就会使用�。

1
2
3
var bytes = "Аналитика".getBytes("UTF-8");
var partial = Arrays.copyOfRange(bytes, 0, 11);
new String(partial, "UTF-8"); // => "Анал�"

UTF-32 是否对所有情况更简单?

不。

UTF-32 在操作码点时非常好用。确实,如果每个码点总是 4 个字节,那么 strlen(s) == sizeof(s) / 4substring(0, 3) == bytes[0, 12] 等等。

问题在于,你不想在码点上操作。码点不是写作的单位;一个码点并不总是一个单独的字符。你应该遍历的是“扩展字符簇”,简称为字符簇。

字符簇是在特定书写系统上下文中的最小可区别单位。例如,ö 是一个字符簇。é 也是一个字符簇。还有 각。基本上,字符簇就是用户认为是单个字符的东西。

问题在于,在 Unicode 中,有些字符簇由多个码点编码!

例如,é(一个单一的字符簇)在 Unicode 中被编码为 e(U+0065 拉丁小写字母 e)+ ´(U+0301 组合重音符号)。两个码点!

它也可能超过两个:

1
2
3
4
☹️ 是 U+2639 + U+FE0F
👨🏭 是 U+1F468 + U+200D + U+1F3ED
🚵🏻♀️ 是 U+1F6B5 + U+1F3FB + U+200D + U+2640 + U+FE0F
y̖̠͍̘͇͗̏̽̎͞ 是 U+0079 + U+0316 + U+0320 + U+034D + U+0318 + U+0347 + U+0357 + U+030F + U+033D + U+030E + U+035E

据我所知,没有限制。

请记住,我们在这里谈论的是码点。即使在最宽的编码 UTF-32 中,👨🏭 仍然需要三个 4 字节单位来编码。而且它仍然需要被视为单个字符。

如果这个比喻有帮助的话,我们可以把 Unicode 本身(没有任何编码)看作是可变长度的。

扩展字符簇是一个或多个 Unicode 码点的序列,必须被视为单个、不可分割的字符。

因此,我们遇到了与可变长度编码相同的所有问题,但现在是在码点级别上:你不能只取序列的一部分,它总是应该作为一个整体被选择、复制、编辑或删除。

不尊重字符簇会导致类似于以下的错误:

或者

使用 UTF-32 而不是 UTF-8 不会使你在处理扩展字符簇方面变得更容易。而扩展字符簇才是你应该关心的。

码点 — 🥱。字符簇 — 😍。

Unicode 是否只因为表情符号而难以理解?
实际上并不是。扩展字符簇也用于活跃使用的语言。例如:

1
2
3
4
ö(德语)是一个单独的字符,但包含多个码点(U+006F U+0308)。
ą́(立陶宛语)是 U+00E1 U+0328。
각(韩语)是 U+1100 U+1161 U+11A8。
所以,不,这不仅仅是关于表情符号的问题。

“🤦🏼♂️”.length 是多少?
这个问题启发自这篇精彩的文章。

不同的编程语言会给出不同的答案。

Python 3:

1
2
>>> len("🤦🏼♂️")
5

JavaScript / Java / C#:

1
2
>> "🤦🏼♂️".length 
7

Rust:

1
2
println!("{}", "🤦🏼♂️".len());
// => 17

你可以猜到,不同的语言使用不同的内部字符串表示(UTF-32、UTF-16、UTF-8),并以它们存储字符的单位来报告长度(整数、短整数、字节)。

但是!如果你问任何正常人,一个不用担心计算机内部事务的人,他们会给你一个明确的答案:1。🤦🏼♂️ 字符串的长度是 1。

这就是扩展字符簇的全部意义所在:人类感知为单个字符的内容。在这种情况下,🤦🏼♂️ 显然是一个单一的字符。

事实上,🤦🏼♂️ 由 5 个码点组成(U+1F926 U+1F3FB U+200D U+2642 U+FE0F),这只是实现的细节。它不应该被拆分,不应该被计为多个字符,文本光标不应该位于其中,不应该被部分选择等等。

就所有意图和目的而言,这是文本的一个原子单位。在内部,它可以以任何方式编码,但对于用户接口,它应该被视为一个整体。

唯有 Swift Elixir 这两种现代语言做对了:

Swift:

1
2
print("🤦🏼♂️".count)
// => 1

Elixir:

1
2
String.length("🤦🏼♂️")
// => 1

基本上,有两个层面:

    1. 内部,面向计算机。如何复制字符串、通过网络发送它们、存储在磁盘上等。这就是你需要像 UTF-8 这样的编码的地方。Swift 内部使用 UTF-8,但它也可以是 UTF-16 UTF-32。重要的是你只用它来整体复制字符串,而不是分析它们的内容。
    1. 外部,面向人类接口。用户界面中的字符计数。取前 10 个字符以生成预览。在文本中搜索。像.count .substring 这样的方法。Swift 给你一个假装字符串是一个字符簇序列的视图。这个视图的行为就像任何人类所期望的一样:对于 "🤦🏼♂️".count,它会给出 1。

希望更多的语言尽快采用这种设计。

给读者的问题:你认为 "ẇ͓̞͒͟͡ǫ̠̠̉̏͠͡ͅr̬̺͚̍͛̔͒͢d̠͎̗̳͇͆̋̊͂͐".length 应该是多少?

那我如何检测扩展字符簇呢?

不幸的是,大多数语言都选择了简单的方式,让你通过 1-2-4 字节的块来迭代字符串,而不是通过字符簇。

这毫无意义,也没有语义,但由于它是默认的,程序员不假思索,结果我们看到了损坏的字符串:

“我知道了,我会使用一个库来做 strlen()!”—— 从未有人说过。

但这正是你应该做的!使用一个合适的 Unicode 库!是的,即使是像 strlenindexOfsubstring 这样的基本操作也要使用!

例如:

  • C/C++/Java:使用 ICU。这是Unicode自己的库,编码了所有有关文本分割的规则。
  • C#:使用 TextElementEnumerator,据我所知它与 Unicode 保持同步更新。
  • Swift:只需使用 stdlibSwift 默认就做对了。
  • UPD:Erlang/Elixir 似乎也在做正确的事情。
  • 对于其他语言,可能有 ICU 的库或绑定。
  • 自己动手实现。Unicode 以机器可读格式发布规则和表格,上述所有库都是基于它们构建的。

但无论你选择哪种方式,请确保它是基于足够新的Unicode版本(目前是 15.1),因为字符簇的定义会随着版本的变化而变化。例如,Java 的 java.text.BreakIterator 就不适用:它基于一个非常旧的 Unicode 版本,没有更新。

1
使用库

在我看来,整个情况都很可惜。Unicode 应该成为每种语言的 stdlib 的一部分。它是互联网的通用语言!而且它甚至不是什么新鲜事:我们已经使用 Unicode 生活了 20 年了。

等等,规则在变化吗?

是的!是不是很酷?

(我知道,其实不酷)

大约从 2014 年开始,Unicode 每年都会发布一个主要修订版的标准。这就是你获取新表情符号的地方 —— Android 和 iOS 的秋季更新通常会包含最新的 Unicode 标准以及其他内容。

对我们而言令人沮丧的是,定义字符簇的规则每年也在变化。今天被认为是两个或三个单独码点的序列,明天可能就成为一个字符簇!没有办法知道!或做好准备!

更糟糕的是,你自己的应用的不同版本可能在不同的 Unicode 标准上运行,并报告不同的字符串长度!

但这就是我们生活的现实。在这里你没有真正的选择。如果你想保持相关性并提供良好的用户体验,就不能忽视UnicodeUnicode更新。所以,做好准备,接受并更新。

1
每年更新

为什么 “Å” !== “Å” !== “Å”?

将任何一个复制到你的 JavaScript 控制台中:

1
2
3
"Å" === "Å"
"Å" === "Å"
"Å" === "Å"

你得到了什么?False?你应该得到 false,这不是一个错误。

还记得之前我说过 ö 是两个码点,U+006F U+0308 吗?基本上,Unicode 提供了多种写法来表示像 ö 或 Å 这样的字符。你可以:

  • 从普通的拉丁 A 加上一个组合字符来组合 Å,
  • 或者,也有一个预先组合的码点 U+00C5,可以为你完成这项工作。

它们看起来相同(Å vs Å),它们应该工作相同,就所有意图和目的而言,它们被认为是完全相同的。唯一的区别在于字节表示。

这就是为什么我们需要归一化。有四种形式:

  • NFD 尝试将一切都分解为最小可能的片段,并在存在多个片段时按照规范顺序对片段进行排序。
  • NFC 相反,尝试将一切都组合成预组合形式,如果存在的话。

对于某些字符,Unicode 中还有它们的多个版本。例如,有 U+00C5 拉丁大写字母 A 加上圆环,但也有看起来相同的U+212B安斯特伦符号。

这些在归一化过程中也会被替换:

  • NFD 和 NFC 被称为“规范化归一化”。另外两种形式是“兼容性归一化”:
  • NFKD 尝试将一切都分解,并将视觉变体替换为默认值。
  • NFKC 尝试将一切都组合在一起,同时替换视觉变体为默认值。

视觉变体是单独的 Unicode 码点,它们表示相同的字符,但应该呈现不同的样式。比如,① 或 ⁹ 或 𝕏。我们希望能够在像 “𝕏²” 这样的字符串中找到 “x” 和 “2”,不是吗?

所有这些都有自己的码点,但它们也都是 X。

为什么 fi 连字甚至有自己的码点?不知道。在一百万个字符中会发生很多事情。

1
在比较字符串或搜索子字符串之前,请进行归一化!

Unicode 是与地区相关的

俄罗斯名字 Nikolay 的写法如下:

并在 Unicode 中编码为 U+041D 0438 043A 043E 043B 0430 0439

保加利亚名字 Nikolay 的写法如下:

并在 Unicode 中编码为 U+041D 0438 043A 043E 043B 0430 0439。完全相同!

等等!计算机如何知道何时渲染保加利亚风格的字形,何时使用俄罗斯字形?

简短的回答是:它不知道。不幸的是,Unicode 不是一个完美的系统,它有很多缺点。其中之一是将同一个码点分配给应该看起来不同的字形,比如西里尔小写字母 K 和保加利亚小写字母 K(都是 U+043A)。

据我所知,亚洲人遭受的情况要严重得多:许多中国、日本和韩国的象形文字被分配相同的码点,但它们的书写方式却大不相同:

Unicode 的动机是为了节省码点空间(我猜的)。如何渲染的信息应该在字符串之外传递,作为地区/语言元数据。

不幸的是,它未能实现 Unicode 的最初目标:

[…] 没有需要指定任何语言中的任何字符的转义序列或控制代码。

在实践中,对地区的依赖带来了许多问题:

  • 作为元数据,地区信息经常丢失。
  • 人们不限于单一的地区。例如,我可以阅读和写作英语(美国)、英语(英国)、德语和俄语。我应该将我的计算机设置为哪个地区呢?
  • 很难混合搭配。比如保加利亚文本中的俄罗斯名字,反之亦然。为什么不呢?这是互联网,各种文化的人都在这里交流。
  • 没有地方指定地区。甚至制作上述两个截图也是不平凡的,因为在大多数软件中,没有下拉菜单或文本输入来更改地区。
  • 在需要的时候,我们只能靠猜。例如,Twitter 尝试从推文文本本身猜测地区(因为还有其他地方可以获取它吗?),有时会猜错:

为什么 String::toLowerCase() 接受 Locale 作为参数?

另一个不幸的地区依赖的例子是 Unicode 处理土耳其语中无点 i 的方式。

与英语不同,土耳其语有两种 i 变体:有点的和无点的。Unicode 决定重新使用 ASCII 中的 I 和 i,并仅添加两个新的码点:İ 和 ı。

不幸的是,这导致了相同输入在 toLowerCase/toUpperCase 上的行为不同:

1
2
3
4
5
6
7
8
var en_US = Locale.of("en", "US");
var tr = Locale.of("tr");

"I".toLowerCase(en_US); // => "i"
"I".toLowerCase(tr); // => "ı"

"i".toUpperCase(en_US); // => "I"
"i".toUpperCase(tr); // => "İ"

所以不,你不能在不知道字符串所用语言的情况下将字符串转换为小写。

我住在美国/英国,我甚至需要在意吗?

  • 引号 “ ” ‘ ’,
  • 撇号 ’,
  • 破折号 – —,
  • 不同变体的空格(数字、hair、不断行),
  • 项目符号 • ■ ☞,
  • 除了 $ 外的货币符号(这有点告诉你是谁发明了计算机,不是吗?):€ ¢ £,
  • 数学符号—加号 + 和等号 = 是 ASCII 的一部分,但减号 − 和乘号 × 不是 ¯_(ツ)_/¯,
  • 各种其他符号 © ™ ¶ † §。

见鬼,你甚至不能拼写 cafépiñatanaïve 而不用 Unicode。所以是的,我们都在一起,甚至包括美国人在内。

什么是代理对?

这追溯到 Unicode v1 。 Unicode 的第一个版本应该是固定宽度的。准确地说,是 16 位固定宽度:

他们相信 65,536 个字符足够覆盖所有人类语言了。他们几乎是对的!

当他们意识到需要更多的码点时,UCS-2(一个没有代理对的 UTF-16 原始版本)已经在许多系统中使用了。16 位,固定宽度,只给你提供了 65,536 个字符。你能怎么办?

Unicode 决定将这些 65,536 个字符中的一些分配给编码更高码点的字符,实质上将固定宽度的 UCS-2 转换为可变宽度的 UTF-16。

代理对是用于编码单个 Unicode 码点的两个 UTF-16 单元。例如,D83D DCA9(两个 16 位单元)编码了一个码点,即 U+1F4A9。

代理对中的前 6 位用于掩码,留下 2×10 个空闲位:

1
2
3
   高代理        低代理
D800 ++ DC00
1101 10?? ???? ???? ++ 1101 11?? ???? ????

从技术上讲,代理对的两半也可以看作是 Unicode 码点。在实践中,从 U+D800 U+DFFF 的整个范围都被分配为“仅供代理对使用”。从那里开始的码点甚至在任何其他编码中都不被视为有效。

UTF-16 还在使用吗?

是的!

固定宽度编码覆盖所有人类语言的承诺非常吸引人,以至于许多系统都急于采用它。其中包括微软 WindowsObjective-CJavaJavaScript.NETPython 2QTSMSCD-ROM

自那时以来,Python 已经升级了,CD-ROM 已经过时,但其他系统仍然使用 UTF-16,甚至 UCS-2。因此,UTF-16 在那里作为内存表示存活下来。

在今天的实际情况中,UTF-16 的可用性与 UTF-8 大致相同。它也是可变长度的;计算 UTF-16 单元与计算字节或码点一样无用,图形簇仍然令人头疼,等等。唯一的区别在于内存需求。

UTF-16 的唯一缺点是其他一切都是 UTF-8,因此每次从网络或磁盘读取字符串时都需要进行转换。

还有,有趣的事实是:Unicode 所有的平面数(17)是由 UTF-16 中的代理对能够表示的内容决定的。

结论

总结一下:

  • Unicode 胜利了。
  • UTF-8 是传输和静态数据中最流行的编码。
  • UTF-16 有时仍然被用作内存表示。
  • 字符串的两个最重要的视图是字节(分配内存/复制/编码/解码)和扩展图形簇(所有语义操作)。
  • 使用码点进行字符串迭代是错误的。它们不是书写的基本单位。一个图形簇可能由多个码点组成。
  • 要检测图形簇边界,您需要使用 Unicode 表。
  • 对于所有 Unicode 相关的事情,甚至是像 strlenindexOfsubstring 这样的乏味任务,都要使用 Unicode 库。
  • Unicode 每年更新,规则有时会改变。
  • 在比较之前,Unicode 字符串需要进行规范化。
  • Unicode 对于某些操作和渲染依赖于区域设置。
  • 所有这些即使对于纯英文文本也很重要。

总的来说,是的,Unicode 不是完美的,但事实证明

  • 有一种编码可以一次覆盖所有可能的语言,
  • 全世界都同意使用它,
  • 我们可以完全忘记编码和转换等等一切繁琐的事情

这是一个奇迹。把这篇文章发给你的程序员同行,让他们也了解一下。

纯文本是存在的,它是用 UTF-8 编码的。