Unicode 和 UTF-8 是什么关系?本文为你解惑
十几年前,国内 UTF-8 还不太流行,新手开发过程中,乱码问题是很常见的。记得我刚毕业那会,公司的系统是这样的:数据库 MySQL 使用的是 Latin-1 编码(ISO8859-1),程序源码使用的是 GBK,这样的情况,乱码真是满天飞~好在对数据库操作做了封装,编码问题在中间层处理了。那时候听到关于乱码问题的一个终极解决方案是:保证各个地方编码一致。
现如今,Unicode 相当普及,中文使用 UTF-8 编码虽然比 GBK 编码占用更多的空间,但一般都不会在乎。特别是使用了 Go 语言后,乱码问题更是没有了。在 Go 语言标准库中有 unicode 包,它下面还有 utf8 和 utf16 两个子包。也许有此疑问:又是 Unicode、又是 UTF-8、UTF-16,它们是什么关系?
本文试着为大家解决关于字符集和字符编码的困惑。
本文目录:
1、什么是字符集
字符集,从字面看,是多个字符的集合。字符是各种文字和符号的总称,包括各国家文字、标点符号、图形符号、数字等。因此不同的字符组合在一起就可以认为是不同的字符集。当然不是瞎组合。国人常见字符集如下:
2、什么是字符编码
大家都知晓,计算机只认 0 和 1 组成的二进制数。所以整数通过进制转换转为二进制就可以被计算机处理。然而像文本、音频、视频等这样的信息,如何转为二进制被计算机处理呢?这就需要进行编码。
编码(encode)是把数据从一种形式转换为另外一种形式的过程,它是一套算法。解码(decode)就是编码的逆过程。
比如对于 ASCII 字符集中的字符 1,转换为二进制是 00110001,这就是一次编码;将 00110001 显示为 1,涉及到一次解码。
字符编码,顾名思义,是对字符进行编码,是字符和二进制数据之间转换的算法,它们之间必须一一对应,这是本文需要讨论的主题。一个二进制叫做位,8 位称为“字节”,根据计算一个字节一共可组合出 256(2 的 8 次方)种不同的状态。
关于字符集和字符编码,使用 ASCII 进行总结说明:
ASCII 字符集是字母、数字、标点符号以及控制符(回车、换行、退格)等组成的 128 个字符。
ASCII 字符编码是将这 128 个字符转换为计算机可识别的二进制数据的一套规则(算法)。
因此当我们说 ASCII 时,一般同时指 ASCII 字符集和 ASCII 字符编码。通常,字符集会同时定义一套同名的字符编码规则。然而万事都有特例,比如 Unicode 就只是代表字符集,对应的字符编码有多种,比如 UTF-8、UTF-16 等。
3、Unicode 和 UTF 系列
现在知道了 Unicode 只是代表字符集,它的编码规则是通过 UTF 系列定义的。
那 Unicode 是什么?官方 有这么一句话:
Unicode provides a unique number for every character, no matter what the platform, program, or language is.
即 Unicode 为每一个字符提供了一个唯一的数字编码(代号),叫做 Code Point(码点)。这里可以查找你要查询某个字符的码点(Code Point):https://www.unicode.org/cgi-bin/GetUnihanData.pl。注意这里的码点不是字符编码,只是字符集而已。
所以,Unicode 字符集一个最主要的工作就是维护这样一个表,可以把它想象成是一个数据库表,里面存储着每一个字符对应的唯一 ID(即 Code Point),统一用 U+XXXX 来表示(X 为 16 进制的字符,因为字符很多,并不一定所有的字符都是 4 个 16 进制数,比如笑哭的 Emoji 表情,Code Point 是 U+1F602),如 「徐」U+5F90、「新」U+65B0。现在这个数据表已经拥有 100 多万的字符,还在不停的更新。
为什么会出现 Unicode 呢?因为之前的编码方式,大多只考虑自己国家,不同国家的编码方式不同,导致使用中国 GBK 编码的文档,在日本用本地编码打开就乱码了。标准总是在混乱中诞生的,于是一些国际组织制定出了全球统一的编码格式。这就是 Unicode。
大家可能见到过 USC,这是 ISO 制定的一种计算机行业标准,和 Unicode 目的是一样的。他们双方意识到不应该出现两种不同的国际标准。因此你可以认为它们是一样的。
了解了 Unicode,那为什么会有 UTF-8、UTF-16?
UTF 系列
前面说了 Unicode 本身主要工作是维护一个表,它并没有规定一个字符到底用几个字节来表示,只规定了每个字符对应到唯一的码点(code point),码点可以从 0000 ~ 10FFFF 共 1114112 个值 。
那为什么 Unicode 不规定字符编码呢?如果将 Unicode 码点直接当编码规则会如何?其实是有的,这就是 UTF-32。因为计算机没法确认两字节到底是表示 1 个字符还是 2 个字符,因此 UTF-32 粗暴的取最大值,所有字符都按 4 字节编码。很显然,这在空间上是极浪费的(英文文档直接大 3 倍),因此 UTF-32 很少使用。
UTF 是 Unicode Transformation Format (Unicode 转换格式)的首字母缩写,专门解决 Unicode 的编码问题。根据编码规则的不同有 UTF-8、UTF-16 和 UTF-32 等几种具体的方案。
由于 UTF-32 浪费空间,使用不多,本文着重介绍 UTF-8 和 UTF-16。
4、UTF-8
UTF-8,也可写为 UTF8,是 Unicode 的一种变长编码方案。它完全兼容 ASCII,同时避免了 UTF-16 和 UTF-32 中的字节序等复杂性。UTF-8 能够被广泛接受,跟其完全兼容 ASCII 有很大关系。
为什么叫 -8
?因为它将每个 Unicode 字符编码为 1~4 个八位元(octets)(八位元即一个字节),因此叫 -8
。其中字节的个数取决于分配给 Unicode 字符的整数值。
当面对 4 个字节,UTF-8 如何知晓应该把它当做 1 个字符解析、还是 2 个?亦或是 4 个?这就是 UTF-8 设计巧妙之处。
- 单字节可编码的 Unicode 范围:\u0000~\u007F(0~127)
- 双字节可编码的 Unicode 范围:\u0080~\u07FF(128~2047)
- 三字节可编码的 Unicode 范围:\u0800~\uFFFF(2048~65535)
- 四字节可编码的 Unicode 范围:\u10000~\u1FFFFF(65536~2097151)
UTF-8 从首字节就可以判断一个字符的 UTF-8 编码有几个字节, 具体判断逻辑就是,根据首字节二进制的起始内容:
- 如果以 0 开始,肯定是单字节编码;
- 如果以 110 开头,肯定是双字节编码;
- 如果以 1110 开头,肯定是三字节编码;
- 如果以 11110 开头,肯定是四字节编码;
除首字节之外,其他字节也有规定,如下:
字节数 | 用来表示码点的位数 | Unicode 十六进制码点范围 | 字节1 | 字节2 | 字节3 | 字节4 |
---|---|---|---|---|---|---|
1 | 7 | 0000 0000 - 0000 007F | 0xxxxxxx | |||
2 | 11 | 0000 0080 - 0000 07FF | 110xxxxx | 10xxxxxx | ||
3 | 16 | 0000 0800 - 0000 FFFF | 1110xxxx | 10xxxxxx | 10xxxxxx | |
4 | 21 | 0001 0000 - 0010 FFFF | 11110xxx | 10xxxxxx | 10xxxxxx | 10xxxxxx |
具体如何编码和解码呢?以中文“徐”为例,说明这个过程。
「徐」的 Unicode 码点是 0x5F90(二进制:101 1111 1001 0000),对照上面的表发现 0x5F90 位于第三行的范围,所以格式是 1110xxxx 10xxxxxx 10xxxxxx。接着从「徐」Unicode 码点的二进制数最后一位开始,按从右向左依次填充这个格式中的 x,多出的 x 用 0 补上。这样就得到了「徐」这个汉字的 UTF-8 编码:11100101 10111110 10010000,转成十六进制是 0xE5 0xBE 0x90。
解码的过程也十分简单:如果一个字节(编码的二进制表示)的第一位是 0 ,则说明这个字节对应一个字符;如果一个字节的第一位是 1,那么连续有多少个 1,就表示该字符占用多少个字节。还是以「徐」字为例,它的 UTF-8 编码的二进制表示是:11100101 10111110 10010000,根据规则,第一个字节的第一位是 1,且有三个 1,因此占用 3 个字节。根据上面表中第三行,将第一个字节开头的 1110 去掉,第二、第三个字节开头的 10 去掉,剩下的组合在一起,即:1011 11110 010000,这就是 0x5F90。
关于 UTF-8 的规范参考 rfc3629 ,这是 Ken Thompson 和 Rob Pike 等制定的,这两位也是 Go 语言的作者。
5、UTF-16
UTF-16 是 16-bit Unicode Transformation Format。有一点需要特别强调,它跟 UTF-8 一样,是可变长度的。代码点(Code Point)是用一个或两个 16 位代码单元编码的。至于网上有些资料说的 UTF-16 是固定 2 个字节长度编码,那其实不是 UTF-16,而是 UCS-2 (用于 2 字节通用字符集)。UTF-16 可以看做是它的父集。
这里涉及一个概念,简单介绍下。
基本多语言平面(BMP):UCS-2 只对这些字符进行了编码,一共能编码 65,536 个字符。后来不够用了,怎么办?IEEE 引入了 UCS-4,所有字符都用 4 字节编码,这太浪费空间了,因此出现了一个折中方案:UTF-16,即超出 U+FFFF 的部分使用 4 字节,这部分叫做辅助平面(SMP),码点范围 U+010000 到 U+10FFFF。
注意 UCS-2 已经过时了。
因为是变长的方案,就需要有办法标识到底是 2 个字节还是 4 个字节。上文讲解 UTF-8 时,判断首字节字节开头位即可。类似的,看如下一张表:
字节数 | 16 进制码点范围 | 16-bit code 1 | 16-bit code 2 |
---|---|---|---|
2 | U+0000 - U+D7FF | xxxxxxxxxxxxxxxx | |
2 | U+E000 - U+FFFF | xxxxxxxxxxxxxxxx | |
4 | U+00010000 - U+0010FFFF | 110110xxxxxxxxxx | 110111xxxxxxxxxx |
使用 UTF-16 最知名的是 Java,JVM 内部使用的就是 UTF-16 编码。因为 UTF-16 不兼容 ASCII,因此网络传输一般不会使用它。
但 Java 的编码方式其实没那么简单。一般地 Java 中是这样的:(来源于 《Java 核心技术手册》 一书)
- Java 源文件,可能使用本地编码(比如中国是 GB);
- 类文件(class 文件),使用 modified UTF-8(即做了部分修改的 UTF-8);
- 虚拟机,使用过 UTF-16
接下来,以汉字"?“为例,说明 UTF-16 编码方式是如何工作的。
汉字”?“的 Unicode 码点为 0x20BB7
,该码点显然超出了基本平面的范围(0x0000 - 0xFFFF),因此需要使用四个字节表示。首先用 0x20BB7 - 0x10000
计算出超出的部分,然后将其用 20 个二进制位表示(不足前面补 0 ),结果为0001000010 1110110111
。接着,将前 10 位映射到 U+D800 到 U+DBFF 之间,后 10 位映射到 U+DC00 到 U+DFFF 即可。U+D800
对应的二进制数为 1101100000000000
,直接填充后面的 10 个二进制位即可,得到 1101100001000010
,转成 16 进制数则为 0xD842
。同理可得,低位为 0xDFB7
。因此得出汉字”?“的 UTF-16 编码为 0xD842 0xDFB7
。
6、Go 中 Unicode 相关包
Go 语言让复杂的编码问题变得简单很多,极大的减轻了程序员的心智负担。为了方便对 unicode 字符串进行处理,Go 语言标准库提供三个包:unicode、unicode/utf8 和 unicode/utf16。
这里简单介绍下三个包的功能:
- unicode:unicode 提供数据和函数来测试 Unicode 代码点(Code Point,用 rune 存储)的某些属性。
- unicode/utf8:用于处理 UTF-8 编码的文本,提供一些常量和函数,包括在 rune(码点) 和 UTF-8 字节序列之间的转换。
- unicode/utf16:函数比较少,主要是 UTF-16 序列的编码和解码。
具体函数不讲解了,大家可以看标准库文档或通过我的开源书阅读相关章节:https://github.com/polaris1119/The-Golang-Standard-Library-by-Example。
着重介绍下 Go 中字符串的写法。
在 Go 语言中,字符串字面值有 4 种写法,比如「徐新华」可以这么写:
s1 := "徐新华"
s2 := "\u5F90\u65B0\u534E"
s3 := "\U00005F90\U000065B0\U0000534E"
s4 := "\xe5\xbe\x90\xe6\x96\xb0\xe5\x8d\x8e"
简单来生活就是 \u 紧跟四个十六进制数,\U 紧跟八个十六进制数。其中 \u 或 \U 代表后面是 Unicode 码点。而 \x 紧跟两个十六进制数,这些十六进制不是 Unicode 码点,而是 UTF-8 编码。
下面的代码有利于你的理解:
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := `徐新华`
var (
buf = make([]byte, 4)
n int
)
fmt.Println("字符\tUnicode码点\tUTF-8编码十六进制\tUTF-8编码二进制")
for _, r := range s {
n = utf8.EncodeRune(buf, r)
fmt.Printf("%q\t%U\t\t%X\t\t%b\n", r, r, buf[:n], buf[:n])
}
s2 := "\u5F90\u65B0\u534E"
s3 := "\U00005F90\U000065B0\U0000534E"
s4 := "\xe5\xbe\x90\xe6\x96\xb0\xe5\x8d\x8e"
fmt.Println(s2)
fmt.Println(s3)
fmt.Println(s4)
}
运行结果:
字符 Unicode码点 UTF-8编码十六进制 UTF-8编码二进制
'徐' U+5F90 E5BE90 [11100101 10111110 10010000]
'新' U+65B0 E696B0 [11100110 10010110 10110000]
'华' U+534E E58D8E [11100101 10001101 10001110]
徐新华
徐新华
徐新华
此外,关于字符串其他方面的处理,比如编码转换等,可以到 https://pkg.go.dev/golang.org/x/text 里找。
7、大小端问题(Little endian 和 Big endian)
最后聊一下大小端的问题。
一个字符使用多字节存储时,涉及到哪个在前哪个在后。以汉字「徐」为例,Unicode 码点是 5F90,需要用两个字节存储,一个字节是5F
,另一个字节是90
。存储的时候,5F
在前,90
在后,这就是 Big endian 方式;90
在前,5F
在后,这是 Little endian 方式。
这两个古怪的名称来自英国作家斯威夫特的《格列佛游记》。在该书中,小人国里爆发了内战,战争起因是人们争论,吃鸡蛋时究竟是从大头(Big-endian)敲开还是从小头(Little-endian)敲开。为了这件事情,前后爆发了六次战争,一个皇帝送了命,另一个皇帝丢了王位。
第一个字节在前,就是”大端方式”(Big endian),第二个字节在前就是”小端方式"(Little endian)。
那么很自然的,就会出现一个问题:计算机怎么知道某一个文件到底采用哪一种方式编码?
Unicode 规范定义,每一个文件的最前面分别加入一个表示编码顺序的字符,这个字符的名字叫做"零宽度非换行空格"(zero width no-break space),用 FEFF 表示。这正好是两个字节,而且 FF 比 FE 大1。
如果一个文本文件的头两个字节是 FE FF,就表示该文件采用大端方式;如果头两个字节是 FF FE,就表示该文件采用小端方式。
但从上面关于 UTF-8 编码的说明可以看出,虽然 UTF-8 存在多字节表示一个字符的情况,但顺序是固定的,没有字节序的问题。Unix 系统下,UTF-8 没有任何前置字符,但 Windows 下记事本保存的 UTF-8 文件会带上 BOM(Byte Order Mark),即 EF BB BF 这三个字节。关于这一点,Unicode 之父 Rob Pike 明确说 UTF-8 不需要 BOM,所以一开始 Go 源文件是不允许有 BOM 的,否则编译不通过,不过现在已经可以有了。但建议还是别带 BOM。
UTF-8 带 BOM 说不是为了区分字节序,而是为了更方便的知晓这是一个 UTF-8 文件。
你可以通过 hexdump 工具查看文件开始的字符。