首先理清三个概念:

  • 字符:是一个信息单位,简单来说是一个汉字、韩文、拉丁字母、数字等等,我们把多个字符构成的集合称为字符集。
  • 字节:计算机存储数据的单元,一个字节为 8 bit。
  • 编码:用不同内容的字节表示不同的字符。

计算机所有的信息,无论是写入文件还是通过网络传输,都需要编码成对应的具体字节流,以便传输和存储。如何用字节高效的表达字符,并且具备良好的兼容性,这是字符编码面对的核心问题。

字符编码发展历史

1967 年,软件工程师们耳熟能详的 ASCII(American Standard Code for Information Interchange: 美国信息交换标准代码) 第一次以规范标准类型发表,共有 128 个字符,对应一个字节中的 0-127。那时候是 IBM 主宰计算机的时代,计算机作为昂贵的设施,绝大部分用于欧美的军工、企业和科研领域,所以 ASCII 码很好的满足当时(英语和西欧语言国家)的要求。

由于 ASCII 未定义 128-255 的字符,就导致了各大软硬件公司定义了私有的 128-255 字符,这些字符在不同的产商计算机代表不同的含义。更复杂的是,随着微机的普及和互联网爆炸式的增长,计算机走入到各国的寻常百姓家。世界上存在多种语言,有些语言如中日韩语的字符多达数万个,根本无法用 ASCII 表达,所以不同的计算机公司(微软、IBM、HP 等)在不同的机型为不同的语言创造成百上千种兼容 ASCII 的字符集和编码方案,这类方案通称为 ANSI 编码,您可以在 Character Sets And Code Pages At The Push Of A Button 查看部分字符集,这些标准不一的字符集带来了严重的兼容性问题,比如 strcpy,strlen 在不同的编码下产生不同的结果,极大的增加成本和阻碍信息的交流。

当内业意识到为全球语言制定一个统一字符集和编码标准的重要性后,终于在八十年代成立如下两个工作组,前期搞笑的闭门干着相同的事情 —— 为全球语言制定统一的字符集:

  • 国际标准化组织(ISO):制定了 USC(Universal Character Set),包括一系列 ISOxxxx 的字符集版本,以及对应 USC2,USC4 等编码标准。
  • 统一码联盟:制定了 Unicode 标准,包括 unicode 字符集,UTF-8, UTF-16 等编码标准。

数年之后,双方都意识到世界不需要两份标准,于是互相合并工作成果,致力保持字符集一致。从 1996 年发布的 unicode 2.0 起,这两套标准基本保持相同的字库和字码,即一致的字符集。从此,unicode 字符集一骑绝尘,相应的 UTF-8 逐步成为文本、网站、DB 等领域的主流编码方案。从 HTTP 协议的 charset=”UTF-8”、python 文件注释的 “# -*- coding: UTF-8 -*-“、Shell 中的 LC_CTYPE=UTF-8,再到 mysql 的 default-character-set=utf8,你都能看到无处不在的 UTF-8。

Unicode & UTF-8

Unicode

目前 Unicode 收集大部分语言的字符,并为每个字符分配唯一的编号。1991 年发布的第一个标准 unicode 1.0.0 只包含 7 千多个字符,覆盖拉丁、阿拉伯、希腊等字符数量较少的语言。一年后发布的 1.0.1 收入 2 万多个中日韩统一表意文字,到 1999 年发布 unicode 3.0 时,已经纳入近 5 万个字符,覆盖了常见的语言字符。随着各种语言字符的不断加入,如今的 unicode 字符集已经包含 14 万多个字符,覆盖了绝大部分国家的语言,从易经的六十四卦符号,再到古老的埃及象形文字,抑或是盲文,乃至麻将和扑克牌,都已纳入到如今的 unicode 字符集。

unicode 编号

Unicode 将这些字符集分为 17 平面(组),每个平面可容纳 65536 字符,其中第一个平面叫做基本平面,字符编号为 U+0000 - U+FFFF,基本涵盖日常所用的字符。剩下的 16 个平面字符编码为 U+10000-U+10FFFF,或用于后期纳入的字符,或保留未用,或作为私人用途。

UTF-8

Unicode 字符集有 UTF-8 、UTF-16 和 UTF-32 等多种编码方案,其中 UTF-8 是目前最广为通用的编码。让我们从简单的 UTF-32 开始,逐步理解这些编码方案。

UTF-32 是定长编码,即所有 unicode 字符都用 4 个字节表示,它可以表达 40 多亿个字符,远远超出 unicode 的数量。UTF-32 的编码规则非常简单明了,即字符的编号和编码后的数值一一对应。比如编号为 U+0061(十进制为 97) 的字符 “a”,其 UTF-32 的编码为 0x00000061,编号为 U+4E2D(十进制为 20013) 的 “中”,其 UTF-32 的编码为 0x00004E2D。UTF-32 的缺点也很明显,非常浪费空间,特别用来表示拉丁和数字字符时,其所占空间是 ASCII 的四倍。

utf-32 编码

存在严重浪费空间的 UTF-32 编码方案对于欧美国家特别不友好,且不兼容 ASCII 码,故难以得到广泛的推广。有没有一种编码方式,能够准确的编码 unicode 字符,又能高效的节省空间呢?答案就是 UTF-8,作为一种变长编码,它完全兼容 ASCII,对于编号 0-127 unicode 字符,采用一个字节表示,对于其它编号的字符,通常采用 2-4 个字节表示。其编码原理如下图:

Unicode符号范围(十六进制) UTF-8编码方式(二进制), x 表示可以编码的位
0000 0000-0000 007F 0xxxxxxx
0000 0080-0000 07FF 110xxxxx 10xxxxxx
0000 0800-0000 FFFF 1110xxxx 10xxxxxx 10xxxxxx
0001 0000-0010 FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
  • 对于编号为 0x0-0x7F(0-127) 字符,采用一个字节表示,该字节第一位为 0,后面七位和编号一样,所以它兼容 ASCII 码。
  • 对于编号为 0x80-0x10FFFF(128-1114111) 字符,采用 2-4 个字节表示,第一个字节高位起连续的 1 代表编码的字节数,剩下字节永远以 10 开头。以编号为 U+4E2D 的 “中” 为例,它需要三个字节表示,其编号用二进制表示为 0b100111000101101,相应的编码过程如下:
Unicode  0b      100   111000   101101(0b100111000101101, 即 U+4E2D)
         0b 1110xxxx 10xxxxxx 10xxxxxx
UTF-8    0b 11100100 10111000 10101101(0b111001001011100010101101,即 0xE4B8AD)

utf-8

UTF-16 同样为变长编码,采用 2 个或者 4 个字节,它没有得到广泛的应用,详细介绍请自行参考相关文档。

各个语言的现状

本节主要介绍下常见几种语言字符串编码方案。

Golang

当 2009 年 Google 开源 go 时,UTF-8 已成了主流的编码,故称为其默认的编码方案。在 go 中,string 类型本质上是采用 utf-8 编码后的 byte array。

s := "你好hello"
fmt.Println(len(s))  // 输出 11

for i := 0; i < len(s); i ++ {
	fmt.Printf("%d %d; ", i, s[i])
}
// 输出 0 228; 1 189; 2 160; 3 229; 4 165; 5 189; 6 104; 7 101; 8 108; 9 108; 10 111;

for i, r := range s {
	fmt.Printf("%d %d %d; ", i, s[i], r)
}
// 输出 0 228 20320; 3 229 22909; 6 104 104; 7 101 101; 8 108 108; 9 108 108; 10 111 111; %                                                                                                  

需要注意的是,对于 for range 遍历,golang 会将字符串转成 unicode,故其遍历的粒度是 unicode 字符,在处理汉字时,一般应采用 for range 遍历字符串。

Python

诞生于 1991 年的 Python 默认编码为 ASCII,这种情况一直持续到 Python2,所以对于包含中文字符的 python 代码文件,需要在文件头部添加如下信息,以告诉解释器采用 UTF-8 解码代码文件。

#-*- coding:utf-8 -*-

相信很多同行和我一样,在 python2 处理中文字符串时深受 UnicodeEncodeError 的困扰,这是因为 python 存在两种类型的字符串,分别叫 str 字符串和 unicode 字符串,它们可以混用,python2 底层做了隐式的处理,以便处理 ASCII 更简单,却给非 ASCII 场景留下了隐患。

>>> a = "你好"
>>> type(a)
<type 'str'>
>>> a
'\xe4\xbd\xa0\xe5\xa5\xbd'
>>> len(a)
6

>>> b = u"你好"
>>> type(b)
<type 'unicode'>
>>> b
u'\u4f60\u597d'
>>> len(b)
2

其中 str 字符串本质是 byte 序列,而 unicode 字符串本质是 unicode 编号序列,以上述例子为例,二者之间还存在如下转换:

# unicode_string.encode -> byte_string
# byte_string.decode -> unicode_string

>>> a.decode("utf-8")
u'\u4f60\u597d'
>>> b.encode("utf-8")
'\xe4\xbd\xa0\xe5\xa5\xbd'

因为 python2 的默认编码为 ASCII,如果上述编解码过程中没有正确指定 UTF-8,将会报如下错误:

>>> a.decode()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'ascii' codec can't decode byte 0xe4 in position 0: ordinal not in range(128)
>>> b.encode()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)

我认为 python3 最大的变化之一就是改变默认编码和字符串类型,其默认编码修改为 utf-8,字符串类型即为 unicode 字符串。而 python2 中的 str 字符串在 python3 则为字节序列,二者不能混用,这就最大避免了混用不同类型字符串带来的问题。一个良好的习惯就是从文件或者网络读取数据后,一律将输入解码成 unicode,并在 unicode 的维度处理,只有在输出到文件或者网络时,将其编码成字节序。

Java

Java 诞生于 1995 年,那时 unicode 逐步成为主流的字符集,并且它的数量只有小几万,所以设计者采用两个字节表示一个字符,即 USC2 定长编码。在字符集不超过 6 万多时,虽然存在一定的空间浪费,但是整体而言简单方便。随着 Unicode 纳入越来越多的字符,由于 UTF-16 和 UCS-2 之间兼容性较好,且能表达所有的 unicode 字符,java 从 USC2 过渡到 UTF-16。当遇上生僻字和 emoji 表情时,string.length() 返回的往往是出乎意料的结果。

经验

总结个人经历的字符编码教训,得出三条经验:

  • 数据的存储和传输尽量采用 UTF-8 编码:无论是网络的序列化,还是往文件或者是数据库写数据,一律采用 UTF-8 编码。
  • 从文件或者网络读取数据时,及时将输入解码成 unicode,并从 unicode 的维度处理数据,当数据在输出到文件或者网络时,及时将其编码成 UTF-8 字节流。
  • 写程序时尽量完全使用英文,包括日志、注释等等。

参考资料