字符编码实战

2021-03-07 20:23:27 浏览数 (1)

数字

在计算机中,所有的信息最终只会表现为 0 和 1,所以计算机看到的数据是这样的。

代码语言:txt复制
00010101010001001000110001100101010101

那么就带来一个问题,怎么用二进制来表示我们程序中需要使用的信息呢,比如 数字、字符、表情等等。

首先数字的问题比较好解决。把特定长度 01 串看成一个二进制数字就可以。比如 int8 就表示一个 8bit 长度的二进制,也就是1个字节表示了一个 int8 类型的数字,这个数字的能表示的范围是 -128, 127,一共 256 个数字,这点比较好理解,因为 8位数字最多只可能表示 256 种情况,从 00000000 -> 11111111。至于数字和二进制的对应关系,这点和补码这种设计有关,简单来说就是正数的补码:与原码相同,比如 7 的补码表示是 00000111, 而负数的补码则是所有位取反并加一,比如 -7 的补码是 11111001

对于更大范围的数字,比如 int32 能表示点范围为 -2147483648到2147483647,需要占用 4个字节的长度,而 int64 (在 64位机器上, int == int64), 能表示的范围有 -9223372036854775808 到9223372036854775807,它要占用 8 个字节的长度。由于占用了超过一个字节的长度,又带来另外一个问题:即字节顺序(端序)的问题,即这几个字节是怎么排列呢,是高位放在前面还是高位放在后面?有兴趣的可以参考这里

另外浮点数的表示方法则要稍微复杂一点,不过这不是本文的重点,有兴趣点可以看这里。

AscII 码

解决了表示数字的问题,接下来就是字符的问题。为了解决这个问题,首先出现了 AscII 码. AscII 码虽然使用一个字节表示,但是实际只占用了其中的 7 个bit,表示了共计 128 个字符,第一个 bit 统一为 0。其中 32 个为控制字符【即不可打印,用作控制】,剩下的为可见字符。在 python 中比较为人熟知的函数 chr, ord 就是用来做 AscII 码和 对应字符的转换的,比如下面的例子

代码语言:txt复制
>>> chr(65)
'A'
>>> chr(97)
'a'
>>> ord('A')
65

所有的字符表可以参考这里 和下面点表格(来自维基百科)

imageimage
imageimage

Unicode 编码

AscII 编码固然简单,但是只能解决英语世界的字母问题,用于表示中文等其他语言,显然是不够的。所以,中国制定了 GB2312 编码,用来把中文编进去。GB/T 2312标准共收录6763个汉字,其中一级汉字3755个,二级汉字3008个。每个汉字及符号以两个字节来表示。GBK 可以看成是 GB2312 的兼容扩展,共收入 21886 个汉字和图形符号。举个例子:"啊" 在 支持 GBK/GB2312 的大多数程序中,会以两个字节,0xB0(第一个字节)0xA1(第二个字节)储存

代码语言:txt复制
# python2
>>> u'啊'.encode("gbk")
'xb0xa1

世界上的语言实在是太多了,每个都像 GBK 这样搞个字符集可是太让人头大了,而且可能会有冲突。于是出现了 Unicode,中文又称万国码。从名字也可以看出,Unicode 的出现是志向远大,要解决万国语言的编码问题的。目前最新的版本为2020年3月公布的13.0.0,已经收录超过13万个字符。unicode 在几乎所有的语言当中都被支持。在表示一个 Unicode 的字符时,通常会用"U "(u)然后紧接着一组十六进制的数字来表示这一个字符。比如 "啊"就可以表示为 'u554a'.

代码语言:txt复制
# python2
>>> u"啊"
u'u554a'
# python2
>>> print(u'u554a')
啊

# golang 
>>> fmt.Println("u554a")
啊

上面的例子演示了在 python2 和 golang 中是怎么解析 'u554a' 这样一个字符串的:他们都会把他们理解为一个 unicode,并且在 print 的时候做另一个对人友好的显示处理,使的人能看到他代表的字符:'啊'。这里可能有人要疑惑了,那么 '啊' 这个子在内存中到底是怎么存在的呢。难道就是 "u554a"? 其实用下面一个例子可以说明,在大部分点语言中,unicode 表示的字符都用 utf-8 表示【下面会介绍 utf8】,而在 python2 中,他真的是是个 unicode.

代码语言:txt复制
# golang 
>>> fmt.Println([]byte("啊"))
[229 149 138] 【 == xe5x95x8a 】

# python2
>>> s=u"啊"
>>> s[0]
u'u554a'

UTF8

需要注意的是,Unicode 只是一个符号集【比如前面介绍的 "u554a",它只是 unicode 的编码,实际上存储并不一定是这个样子,具体看上面那个例子】,它只规定了符号的二进制代码,却没有规定这个二进制代码应该如何存储。比如,汉字严的 Unicode 是十六进制数 4E25,转换成二进制数足足有 15 位(100111000100101),也就是说,这个符号的表示至少需要2个字节。表示其他更大的符号,可能需要 3 个字节或者 4 个字节,甚至更多。

这里就有两个严重的问题:

  • 第一个问题是,如何才能区别 Unicode 和 ASCII ?计算机怎么知道三个字节表示一个符号,而不是分别表示三个符号呢?
  • 第二个问题是,我们已经知道,英文字母只用一个字节表示就够了,如果 Unicode 统一规定,每个符号用三个或四个字节表示,那么每个英文字母前都必然有二到三个字节是0,这对于存储来说是极大的浪费,文本文件的大小会因此大出二三倍, 举个例子字符 'A' 使用ASCII 表示只需要1个字节,即 '01000001',而使用 unicode 则需要两个字节 '00000000 01000001',如果更过分,你直接使用 unicode 表示的字符串,则需要六个字节, 即 'u0041'。

它们造成的结果是:1)出现了 Unicode 的多种存储方式,也就是说有许多种不同的二进制格式,可以用来表示 Unicode。2)Unicode 在很长一段时间内无法推广,直到互联网的出现。

于是又出现了目前互联网上最广泛采用的一种Unicode 的实现方式:UTF8。UTF-8 最大的一个特点,就是它是一种变长的编码方式。他是一种针对Unicode的可变长度字符编码,也是一种前缀码。它可以用一至四个字节对Unicode字符集中的所有有效编码点进行编码,属于Unicode标准的一部。【自2009年以来,UTF-8一直是万维网的最主要的编码形式,在所有网页中,UTF-8编码应用率高达94.3%,可以说已经是字符的显示方式的事实标准了】

UTF8 有如下的优点:

  • ASCII是UTF-8的一个子集。即兼容 ASCII
  • UTF-8 和 UTF-16 都是可扩展标记语言文档(XML)的标准编码。所有其它编码都必须通过显式或文本声明来指定。
  • 任何面向字节的字符串搜索算法都可以用于UTF-8的数据。
  • UTF-8字符串可以由一个简单的算法可靠地识别出来。就是,一个字符串在任何其它编码中表现为合法的UTF-8的可能性很低

更多细节可以参考这里

UTF8 与 python

在 python 中,尤其是 python2 中,字符串的处理一直是很令人头疼的问题(愿天堂没有 python2). 根本原因是 python2 的字符串是 ASCII 编码的,也就是说 python 中的一个 string,它只能表示一个 ASCII 编码 的字符串,如果要表示 unicode 字符串怎么办呢,python2 新增了一种类型叫做 unicode, 这种类型,或者类似 u"xxx" 这样的字符串就表示这是一个 unicode 字符串。为了便于 unicode 和 str 之间转换,又有 encode/decode 函数。比如下面的例子.

代码语言:txt复制
>>> isinstance("", str)
True
>>> isinstance(u"", str)
False
>>> isinstance(u"", unicode)
True
>>> isinstance(u"".encode('utf8'), str)
True

这种处理带来了很多问题,一是带来了编码和存储的混淆,事实上,开发者在编程语言中只需要了解 utf8 就行了,并不需要了解 unicode。因为 unicode只是一种编码,他甚至不是一种存储形式。而 python2 似乎把这一切都搞错了。大量的中文代码中直接实用 unicode 存储,实际上对于一个 unicode 字符使用了6位甚至 更多的长度,严重浪费了存储空间。二是带来了使用的复杂性,应该经历过 python2开发的同学,都曾经被类似这样的错误困扰过吧 UnicodeDecodeError: 'ascii' codec can't decode byte 0xe5 in position 0: ordinal not in range(128)

UTF8 与 go

golang 中的字符串和 python3 中比较类似,形式上都是简单的字节数组。字节数组和 string 之间可以简单的 使用 string, []byte 函数进行转换。

golang 中的字符串(注意是 string literals,因为 string value 实际可以包含任意的 bytes)都是 utf8 的,包括代码中定义的字符串。go 中的 string 可以直接转换为 []byte,但是对于 utf8 串,我们在处理的时候往往更关注的是 "character" 即一个一个的字符,而不是 byte。为了解决这个问题,golang 中引入了 rune 的概念,用于表示 "character"。具体看下面这个例子:

代码语言:txt复制
const nihongo = "日本語"
for index, runeValue := range nihongo {
    // 这里 runeValue 就是一个 rune 类型
    // %#U 会同时打印出 Unicode value 和  printed representation
    fmt.Printf("%#U starts at byte position %dn", runeValue, index)
}

U 65E5 '日' starts at byte position 0
U 672C '本' starts at byte position 3
U 8A9E '語' starts at byte position 6

golang 中大部分的 unicode、utf8 相关的 library 都在 strconv/quote.go 和 unicode/utf8; unicode/utf16 下面。

代码语言:txt复制
import (
	"fmt"
	"strconv"
	"unicode/utf8"
)

func main() {
	a := "你好"
	fmt.Println(strconv.QuoteToASCII(a))
	
    for i, w := 0, 0; i < len(a); i  = w {
        runeValue, width := utf8.DecodeRuneInString(a[i:])
        fmt.Printf("%#U starts at byte position %dn", runeValue, i)
        w = width
    }
}

UTF8 与 MySQL

首先补充一下 utf8/ utf16 的表示范围,这里 BMP 可以理解为 unicode 的一层,即一个范围,这个范围里面包含了几乎所有的语言字符,包括很多符号。

代码语言:txt复制
UTF-8:
    1 byte: Standard ASCII
    2 bytes: Arabic, Hebrew, most European scripts (most notably excluding Georgian)
    3 bytes: BMP
    4 bytes: All Unicode characters
UTF-16:
    2 bytes: BMP
    4 bytes: All Unicode characters

那么 MySQL 的问题来了: MySQL 的"utf8"实际上不是真正的 UTF-8, 而是只包含了 3 个字节以及以下的 utf8, 所以他只是 utf8 的一个子集,对于超过 3 个字节的 utf8 mysql 就无法存储。MySQL 一直没有修复这个问题,在 2010 年发布了一个叫作 "utf8mb4" 的字符集,绕过了这个问题。

这样带来的问题是什么呢,对于大部分的中文字符实际是没问题的,因为大部分中文字符都在两个字符的范围内部,但是对于少部分字符,还有现在很常用的表情符号,MYSQL 的 utf8 就不能存储了。举个例子,

0 人点赞