字符串编码:ASCII、Unicode、UTF8、Emoji

现实世界中有许多的语言文字,那么如何在计算机中显示这些文字呢?

根据计算机的性质,存储的都是 0 和 1,因此计算机只能处理数字,如果要处理文本(字符集合),就必须先把数字转换成字符才能处理,哪个数字对应哪个字符就是编码的过程。一个字节所能表示的最大整数是 255,能代表 255 个字符,如果要表示更多的字符就需要更多的字节,例如两个字节能表示的最大整数是 65535。

ASCII

ASCII 编码方式的问题很明显,只能表示英文,要表示中文汉字就没办法了,因此需要进行扩展。

GB2312 中文编码

ASCII 是针对英语设计的,一个 byte 表示一个字符是够的,但是汉语有几万个字符,加上其他语言就更多了,使用一个 byte 是远远不够的,咋办?多用几个字节嘛。为此中国规范了专门基于中文的 GB2312 编码。

但又有新的问题,每个语言都有自己的编码,在使用的时就会十分混乱,产生很多问题,例如前些年网上有很多乱码的问题,用 A 编码保存的文件,使用 B 编码打开,看到的内容就是混乱的,文字和数字对不上。

但是世界上有很多语言,日文使用 Shift_JIS 编码,韩国使用 Euc-kr 编码,各有各的标准。如果需要在一个文本中显示多种语言,就会不可避免地产生冲突,出现乱码。因为没有一种编码支持所有的语言。

咋办呢,搞一个支持所有语言的编码不就解决了,这就是 Unicode 规范。

Unicode 规范

为了一次搞定这个问题,定义了 Unicode 规范,为世界上每一个字符(文字)赋予唯一的编号

实现思路是这样的:从 0 开始,为每一个文字指定一个编号,称为码点(code point)。目前 Unicode 最新 版7.0 包含 109449 个码点,大部分是东亚文字,例如【中文】这两个字的码点:

// 中:编号 20013,16 进制 4e2d
// 文:编号 25991,16 进制 6587
// 在 go 语言中表示(码点表示法)
"\u4e2d\u6587" == "中文" // true

我们经常会看到 Unicode 字符 \u6587 的说法,严格来说这样的表述是错误的,这只是一个码点,代表一个字符。

Unicode 把这么多码点分成多个区域,每个区域可以存放 65536(2^16) 个字符,称为一个平面(plane),目前一共有 17 个平面。

最前面的 65536 个编号字符称为基本平面(BMP),码点从 0 ~ 2^16 - 1,对应 16 进制 0x0000 ~ 0xFFFF,所有常见的字符都放在这个平面,节省存储空间。

剩下的字符都放在剩下的 16 个辅助平面(SMP),码点范围 0x010000 ~ 0x10FFFF

Unicode 只规定了每个字符的码点(数字),到底用什么样的字节序表示这个码点,就涉及到编码方法。即 UTF(Unicode Transformation Format)编码,常见的有 UTF-8、UTF-16、UTF-32

UTF-32

这是最直观的编码方法,每个码点使用 4 个 byte 表示,字节内容一一对应码点,对应到【中文】两个字就是:

0x0000 4e2d // 中
0x0000 6587 // 文

缺点很明显,前面的 0x0000 都是对空间的浪费,尤其是表示英文,本来只要一个 byte 的 ASCII 搞成了 4 个 byte,空间大了 4 倍。因此,基本没有人使用这种编码方法。

UTF-8

UTF-8 相比 UTF-32 能够节省很多空间,其采用变长编码的方法,字符长度从 1 ~ 4 个 byte 不等,越常用的字符,byte 越短,英文只占一个 byte。

UTF-8 怎么编码字符呢?

UTF-8 完全兼容 ASCII,使用一个 byte 表示 ASCII,怎么做到的呢?

其实很简单,系统读取到的 UTF-8 编码文件是 0 和 1 的字节流,当取出一个字节(8 bit)时,赋予前面几位特殊的含义:

  • 如果第一位为 0,表示是一个独立的 ASCII 字符
  • 如果前两位是 1,第三位为 0,即 110,表示当前使用两个字节表示字符
  • 如果前三位是 1,第四位为 0,即 1110,表示当前使用三个字节表示字符
  • 如果前四位是 1,第五位为 0,即 11110,表示当前使用四个字节表示字符

因此根据前四位可以判断当前字符由几个字节表示。

还有一个规则是,如果第一个 bit 位是 1,第二个 bit 位是 0,则这个字节不是第一个字节。

这么设计的用意是什么?岂不是减少了多个 2 bit 存储空间?

仔细想想并不能去掉这个规则,如果去掉,就无法与 ASCII 的 0 开头区分了,也就无法兼容 ASCII 编码。使用 10 作为后续字节的开头就是为了避免与 ASCII 字符混淆,并确保编码的可读性和错误检测能力。

1st Byte2nd Byte3rd Byte4th ByteNumber of Free BitsMaxinum Expressible Unicode Value
0xxxxxxx7007F hex(127)
110xxxxx10xxxxxx(5 + 6) = 1107FF hex(2047)
1110xxxx10xxxxxx10xxxxxx(4 + 6 + 6) = 16FFFF hex(65535)
11110xxx10xxxxxx10xxxxxx10xxxxxx(3 + 6 + 6 + 6) = 2110FFFF hex(1114111)

可以看到,非首个 byte 都是使用 10 开头的,用作判断规则的,其他 bit 即 x 的位置填入对应的 UNICODE 码点表示对应的字符。

举个例子,例如【严】字的 UNICODE 码是 4E25(100111000100101),处于上表第三行的范围内,也就是 3 个 byte 表示:

1110xxxx 10xxxxxx 10xxxxxx 在这些 x 中填入 4E25 就得到【严】字的 UTF-8 编码:11100100 10111000 10100101 对应 16 进制:E4B8A5

Emoji

emoji 是象形文字(本质还是文字,只不过是图形符号),通过有以各种丰富多彩的形式在文本中使用。

UNICODE 有一个 emoji 委员会定期审核新加入的 emoji 码点。

例如 U+1F600 在 go 中使用如下方法表示:

r, _ := strconv.ParseInt(strings.TrimPrefix("\\U0001F600", "\\U"), 16, 32)
fmt.Println(string(r)) // 😀

总结

  • UTF-8 是 Unicode 规范的一种编码方式,而对比其它编码方式 UTF-8 更节省空间
  • UTF-8 在设计上通过给 0 开头赋予特殊含义,在后续字节上添加 10 标识兼容 ASCII