背景
HTTP 协议基于文本传输,字符编码将文本变为二进制,二进制编码将二进制变为文本。TCP 协议基于二进制传输,数据读取时需要处理字节序。本文将介绍常见的字符编码、二进制编码及字节序,并一探 Golang 中的实现。
字符编码
引言:如何把“Hello world”变成字节?
-
Step1:得到要表示的全量字符(字符表)
-
Step2:为每个字符指定一个整数编号(编码字符集)
-
Step3:将编号映射成有限长度比特值(字符编码表)
字符是各种文字和符号的总称,包括各国家文字、标点符号、图形符号、数字等。全世界共使用 5651 种语言,其中使用人数超过 5000 万的语言有 13 种,每种语言有自己的字符。汉语中,一个汉字就是一个字符。英语中,一个字母就是一个字符。甚至看不见的也可以是字符(如控制字符)。字符的集合即为字符表,如英文字母表,阿拉伯数字表。ASCII 码表中一共有 128 个字符。
编码字符集(CCS:Coded Character Set)
为字符表中的每个字符指定一个编号(码点,Code Point),即得到编码字符集。常见有 ASCII 字符集、Unicode 字符集、GB2312 字符集、BIG5 字符集、 GB18030 字符集等。ASCII 字符集中一共有 128 个字符,包括了 94 个可打印字符(英文大小写字母 52 个、阿拉伯数字 10 个、西文符号 32 个)和 34 个控制符或通信专用字符,码点值范围为[0, 128)
,如下图所示。Unicode 字符集是一个很大的集合,现有容量将近 2^21 个字符,码点值范围为[0, 2^20+2^16)
。
字符编码表(CEF:Character Encoding Form)
编码字符集只定义了字符与码点的映射,并没有规定码点的字节表示方式。由于 1 个字节可以表示 256 个编号,足以容纳 ASCII 字符集,因此ASCII 编码的规则很简单:直接将码点值用 uint8 表示即可。对于 Unicode 字符集,容纳 2^21 至少需要 3 字节。可以采用类似 ASCII 的编码规则:直接将编码点值用 uint32 表示即可,这正是 UTF-32 编码。
这种一刀切的定长编码方式虽然简单粗暴,弊端也很明显:对于纯英文文本,UTF-32 编码空间占用将是 ACSII 编码的 4 倍,造成极大的空间浪费,几乎没什么人用。有没有更优雅的解决方案?当然,这就是 UTF-8 和 UTF-16,两种当前比较流行的 Unicode 编码方式。
UTF-8
历史告诉我们,成功的设计往往具有包容性。UTF-8 是一个典型,漂亮的实现了对 ASCII 码的向后兼容,以保证可以被大众接受。UTF-8 是目前互联网上使用最广泛的一种 Unicode 编码方式,它的最大特点就是可变长,随码点变换长度(从 1 字节到 4 字节)。text
大道至简,优雅的设计一定是简单的,UTF-8 的编码规则也诠释了这一点。编码规则如下:
-
<=127(U+7F)的码点采用单字节编码,与 ASCII 保持一致;
-
>127(U+7F)的码点采用 N 字节(N 属于 2,3,4)编码,首字节的前 N 位为 1,第 N+1 位为 0,剩余 N-1 个字节的前两位都为 10,剩下的二进制位使用字符的码点来填充。
其中(U+7F)表示 Unicode 的十六进制码点值,即 127。如果觉得编码规则抽象,结合下表更加清晰:
Unicode 码点范围 | 码点数量 | UTF-8 编码格式 |
---|---|---|
0000 0000 ~ 0000 007F | 2^7 | 0xxxxxxx |
0000 0080 ~ 0000 07FF | 2^11 - 2^7 | 110xxxxx 10xxxxxx |
0000 0800 ~ 0000 FFFF | 2^16 - 2^11 | 1110xxxx 10xxxxxx 10xxxxxx |
0001 0000 ~ 0010 FFFF | 2^20 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
举个例子,如“汉”的 Unicode 码点是 U+6C49(110 1100 0100 1001),根据上表可得需要 3 字节编码,填充码点值后得到 0xE6 0xB7 0x89(11100110 10110001 10001001)。
根据编码规则,解码也很简单,关键是如何判断连续的字节数:首字节连续 1 的个数即为字节数。
需要一提的是,在 MySQL 中,utf8 是“虚假的 utf8”,最大只支持 3 个字节,如果建表时选择 CHARSET=utf8,会导致很多特殊字符和 emoji 表情都无法插入。utf8mb4 才是“真正的 utf8”,mb4 即most bytes 4
。为什么 MySQL 中 utf8 最大只支持 3 字节?历史原因,在 MySQL 刚开发那会儿,Unicode 空间只有 2^16,Unicode 委员会还在做 “65535 个字符足够全世界用了”的美梦呢。
UTF-16
在 C/C++ 中遇到的wchar_t
类型或 Java 中的char
类型,这些类型占内存两个字节,因为 Unicode 中常用的字符都处于[U+0, U+FFFF]
(基本平面)的范围之内,因此两个字节几乎可以覆盖大部分的常用字符,这正是 UTF-16 编码的一个前提。
相比 UTF-32 与 UTF-8,UTF-16 编码是一个折中:小于(U+FFFF)2^16 的码点(基本平面)使用 2 字节编码,大于(U+FFFF)2^16 的码点(辅助码点)使用 4 字节编码。由于基础平面空间会占用 2 字节的所有比特位,无法像 UTF-8 那样留有“10”前缀。那么问题来了:当我们遇到两个节时,如何判断是 2 字节编码还是 4 字节编码?
UTF-16 的编码的另一个前提:在基本平面内,[U+D800, U+DFFF]
是一个空段(空间大小为 2^11),这些码点不对应任何字符。因此,这个空段可以用来映射辅助平面的字符。
辅助平面容量为 2^20,至少需要 20 个二进制位,UTF-16 将这 20 个二进制位分成两半,前 10 位映射在 U+D800 到 U+DBFF(空间大小 2^10),称为高位(H),后 10 位映射在 U+DC00 到 U+DFFF(空间大小 2^10),称为低位(L)。
映射方式采用线性映射。Unicode3.0 中给出了辅助平面字符的转换公式:
H = Math.floor((c-0x10000) / 0x400) + 0xD800
L = (c - 0x10000) % 0x400 + 0xDC00
也就是说,一个辅助平面的码点,被拆成两个基本平面的空段码点表示。如果双字节的值在[U+D800, U+DBFF]
中,则要和后续相邻的双字节一同解码。具体编码规则为:
-
<= (U+FFFF)的码点采用双字节编码,直接将码点使用 uint16 表示;
-
> (U+FFFF)的码点采用 4 字节编码,作差计算码点溢出值,将溢出值用 uint20 表示后,前 10 位映射到
[U+D800, U+DBFF]
,后 10 位映射到[U+DC00, U+DFFF]
;
小结: 定长编码的优点是转换规则简单直观,查找效率高,缺点是空间浪费,以及不可扩展。如果 Unicode 字符集进一步扩充,UTF-16 和 UTF-32 都将不可用,而 UTF-8 具有更强的可扩展性。
Golang 中字符编码
不像 C++、Java 等语言支持五花八门的字符编码,Golang 遵从“大道至简”的原则:全给老子用 UTF-8。所以 go 程序员再也不用担心乱码问题,甚至可以用汉字和表情包写代码,string 与字节数组转换也是直接转换,十分酸爽。
func TestTemp(t *testing.T) {
来自打工人的问候()
}
func 来自打工人的问候() {
问候语 := "早安,打工人😁"
fmt.Println(问候语)
bytes := []byte(问候语)
fmt.Println(hex.EncodeToString(bytes))
}
// 执行结果-->
早安,打工人😁
e697a9e5ae89efbc8ce68993e5b7a5e4babaf09f9881
值得一提的是,Golang 中 string 的底层模型就是字节数组,所以类型转换过程中无需编解码。也因此,Golang 中 string 的底层模型是字节数组,其长度并非字符数,而是对应字节数。如果要取字符数,需要先将字符串转换为字符数组。字符类型(rune)实际上是 int32 的别名,即用 UTF-32 编码表示字符。
func TestTemp(t *testing.T) {
fmt.Println(len("早")) // 3
fmt.Println(len([]byte("早"))) // 3
fmt.Println(len([]rune("早")) // 1
}
// rune is an alias for int32 and is equivalent to int32 in all ways. It is
// used, by convention, to distinguish character values from integer values.
type rune = int32
再看一下 go 中 utf-8 编码的具体实现。首先获取字符的码点值,然后根据范围判断字节数,根据对应格式生成编码值。如果是无效的码点值,或码点值位于空段,则返回U+FFFD
(即 �)。解码过程不再赘述。
// EncodeRune writes into p (which must be large enough) the UTF-8 encoding of the rune.
// It returns the number of bytes written.
func EncodeRune(p []byte, r rune) int {
// Negative values are erroneous. Making it unsigned addresses the problem.