茴香豆的“茴”字有几种写法,你知道么?读完这篇笔记你就知道了。本文的写作尽量做到面向所有读者,不论技术背景。
本文的三个小节按照从通用原理到特殊应用的方式排列。
计算机是如何储存文字的
这是一个基础的问题。以最朴素的 ASCII 码为例:字符 ‘A’ 可以映射到码位 65,再以8位无符号整数的方法存储,得到字节 0x41 (对应二进制表示:00100001)。我们可以提取出计算机存储文字的通用的流程:
文字 (字符,Character) -> 自然数(码位,Codepoint)-> 字节流
ASCII 一共有 128 个码位,远远不足以对应世界上的字符。为了存储和表示更多字符,一般使用 Unicode 标准。
从字符到码位
Unicode 标准定义了一个世界上诸多字符到 [0, 17*2^16) 这些自然数的映射。这个码位范围按照 Unicode 标记标准,应该书写为 U+0000 ~ U+10FFFF (该标准规定标记为 U+ 后面跟十六进制数,该数字不足四位时在前面补 0 到四位)。
每组 2^16 连续的码点被称为一个平面,共有 17 个平面。0 号平面是基本多语言平面,包含了常用的文字。
实验:用 Python 转换字符和 Unicode 码位。(后面的代码默认使用 Python,用 “>>>” 表示在交互式解释器 REPL 中执行)
>>> ord('字')
23383
>>> chr(23383)
'字'
从码位到字节流
码位转换为字节流的流程可以有诸多。比如,我们当然可以用 3 个字节表示 Unicode 的每个码点。然而,这样并不一定高效,因为有些码点(字符)总是比其它字符出现得更频繁,如果能在不引起歧义的情况下用更少的字节表示它,显然有利于文字信息在计算机中的存储和传输。
这是信息论中的源编码问题(Source Coding),期望码位长度是与输入字符的分布相关的。显然,我们希望出现频繁的字符编码更短。这是不同的国家地区使用不同编码标准的内在原因。举例而言,中文字符在英语国家中不常见,所以它在 UTF-8 编码标准中需要 3 个字节,而在中国的 GB 2312 编码标准中就只需要 2 个字节。
然而,为了计算机的互通性,势必要约定世界范围内互通的标准,UTF-8 就是其中之一。
从码位到字节流:UTF-8 编码器
UTF-8 既可以指编码标准(Character Coding Standard),也可以指这个标准的实现也就是编码器(codec)。它是 Unicode 标准的一部分。具体而言,它将每个 Unicode 码用 1~4 个字节表示,用以下规则转换:
First code point | Last code point | Byte 1 | Byte 2 | Byte 3 | Byte 4 |
---|---|---|---|---|---|
U+0000 | U+007F | 0xxxxxxx | |||
U+0080 | U+07FF | 110xxxxx | 10xxxxxx | ||
U+0800 | U+FFFF | 1110xxxx | 10xxxxxx | 10xxxxxx | |
U+10000 | U+10FFFF | 11110xxx | 10xxxxxx | 10xxxxxx | 10xxxxxx |
每个 Unicode 码到底要用几个字节,取决于码点范围:
- 用一个字节表示的,共 128 个码位,恰好与 ASCII 标准的 128 个码位相对应,所以与 ASCII 标准后向兼容
- 用两个字节表示的码点总体而言是拉丁字母。
- 三个字节以内的码点范围(U+0000 to U+FFFF)是基本多语言平面中的所有字符。中日韩文字需要用三个字节表示。
从这张表中,我们还可以获得如下“肉眼解码 UTF-8” 的技巧:
- 16进制以 8,9,A,B 开头的字节是”接续字节”,其余情况是“起始字节”
- 起始字节 0~7 是 ASCII 码,C,D 代表两个字节,E 代表三个字节,F 代表四个字节
- 例如,中日韩文字的 UTF-8 编码形如
\xe?\{8,9,a,b}?\{8,9,a,b}?
从这张表中,我们还可以验证 UTF-8 编码是前缀码 (prefix code)。这个良好的性质保证,当我们把很多字符的编码拼接在一起,形成一个字节流,也可以通过从前向后线性扫描一次,解码出所有字符。例如:
>>> "哥德尔 Gödel".encode('utf-8').hex()
'e593a5e5beb7e5b0942047c3b664656c'
# e5 93 a5 哥
# e5 be b7 德
# e5 b0 94 尔
# 20 <空格>
# 47 G
# c3 b6 ö
# 64 d
# 65 e
# 6c l
我们手动演算其中字符 ‘ö’ 的编码:
Code point for ö is U+00F6, falls in the second category
=> use the 110xxxxx 10xxxxxx format, need 11 x's
=> 00F6 is 000111 10110
=> the two bytes are 11000011 10110110 = C3 B6
该用什么解码器?
面对文件或者网络流中的字节流,该用什么解码器来解码?显然是与编码器为同一个标准的解码器。编码、解码的标准不一致,就会发生乱码或错误:
>>> '你好'.encode('utf-8')
b'\xe4\xbd\xa0\xe5\xa5\xbd'
>>> b'\xe4\xbd\xa0\xe5\xa5\xbd'.decode('gbk')
'浣犲ソ'
>>> '你好'.encode('gbk')
b'\xc4\xe3\xba\xc3'
>>> b'\xc4\xe3\xba\xc3'.decode('utf-8')
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xc4 in position 0: invalid continuation byte
我们可能需要通过外部信息来判断编码标准,如文件其它位置的说明。
我们也可以通过字节流本身来猜测编码器:上面我们已经讨论了 UTF-8 编码器编码出的字节流的特点,不同编码器输出的字节流一定有一些区分特征。很多文本编辑器都内置了这一功能,在你打开文件时就能够猜测一个编码标准用以解码。
ASCII 中的控制字符
了解了通用的文字编码方法,我们现在感兴趣 ASCII 中的若干控制字符(Control Characters),也就是不会被打印出的字符。经常使用终端,就会遇见它们。一些示例的控制字符如下:
^ | C0 | Abbr | Name | Effect |
---|---|---|---|---|
^J | 0x0A | LF | Line Feed | Moves to next line. |
^M | 0x0D | CR | Carriage Return | Moves the cursor to column zero. |
^[ | 0x1B | ESC | Escape | Starts all the escape sequences |
表格第一列是脱字符表示法(Caret Notation),本意是在键盘上通过按住 Ctrl(对应^) + 大写字母来输入不可见字符。(不过好像这个表示法和 Ctrl + C 等快捷键有冲突?)。总之,这一传统在 Unix Shell 中有所保留:
- 可以用 ^C (end of text) 来终端一个运行中的进程
- 可以用 ^D 来退出 shell 或者表明输入结束(标准输入的结束。例如 C System Call
read(0,buffer,sizeof(buffer));
在收到 ^D 后就会停止读取标准输入)
表中的 0x1B (ESC) 具有特殊地位,能够开启转义序列 (Escape Sequence):大意就是从 ESC 之后的若干字符不能按字面义理解,而要特殊解读。
ANSI 转义序列中的 CSI 命令
ANSI 转义序列标准被很多类 Unix 系统的终端采用。这个标准中,CSI 命令(Control Sequence Introducer Commands)是以 ESC [
开头的转义序列,最为常用。在编程语言源代码中,这个前缀经常写作 \e[
或者 \033[
。
在这里枚举一些在使用终端时有用或者有趣的 CSI 命令:
echo -e '\e[?25h'
显示光标echo -e '\e[?25l'
隐藏光标echo -e '\e[31;46mxx'
显示彩色的 ‘xx’
Trivia
异体字的 Unicode 编码
不同东亚书写系统的“字”,很多字形之间只有微小的差异,且语言学上共通。“异体字”描述的也是相似的现象。这种现象在计算机中也有对应:
戶 户 戸
这种现象一般分为两种情况,
- 可能是因为页面上字符的 Unicode 本身就不同
- 也可能是因为你使用的软件的功劳(如浏览器、MS Word 等有字体、格式功能),而字符的 Unicode 是相同的(可以通过粘贴到命令行终端中验证)
对于异体字,历史上有些地区将这些异体字分别编码,所以有些异体字在 Unicode 中也是分别编码的。例如,“户”的三种异体字被分别编码:
>>> chr(0x6236)
'戶'
>>> chr(0x6237)
'户'
>>> chr(0x6238)
'戸'
Unicode 与 Emoji
【注:实际渲染出的 Emoji 很可能因阅读本文所用的软件平台而异】
Unicode 支持 Emoji:
>>> chr(128514)
'😂'
这并不稀奇,有趣的是 Unicode 定义了 Emoji 的“合成“规则。
两个字符定义一个 Emoji
base_emoji = '\U0001F926'
print("Base Face Palm Emoji:", base_emoji)
for i in range(4):
skin_tone_modifier = chr(ord('\U0001F3FC')+i)
grinning_face_with_skin_tone = base_emoji + skin_tone_modifier
print("Skin Tone Modifier:", skin_tone_modifier)
print("Combined Emoji with Skin Tone Modifier:", grinning_face_with_skin_tone)
输出如下
Base Face Palm Emoji: 🤦
Skin Tone Modifier: 🏼 Combined Emoji with Skin Tone Modifier: 🤦🏼
Skin Tone Modifier: 🏽 Combined Emoji with Skin Tone Modifier: 🤦🏽
Skin Tone Modifier: 🏾 Combined Emoji with Skin Tone Modifier: 🤦🏾
Skin Tone Modifier: 🏿 Combined Emoji with Skin Tone Modifier: 🤦🏿
通过 ZWJ 衔接
woman_emoji = '\U0001F469' # Woman
man_emoji = '\U0001F468' # Man
girl_emoji = '\U0001F467' # Girl
zwj = '\U0000200D'
family_emoji = woman_emoji + zwj + man_emoji + zwj + girl_emoji
print("Family Emoji:", family_emoji)
输出如下:👩👨👧
Leave a Reply