阮一峰老师文章的常识性错误之 Unicode 与 UTF-8

By 刘志军 , 2017-02-27, 分类: 基础技术

unicode

阮一峰老师对普及计算机基础技术功不可没,但毕竟老师不是神,因此也避免不了对某些概念有一些错误的理解,《字符编码笔记:ASCII,Unicode 和 UTF-8 》 是阮老师10年前写的一篇关于字符编码的科普文章,现在用 Google 搜关键字该文章依然名列前茅,可见他的文章有多大影响力,不过这是后话,但里面的内容是否正确是值得商榷的事。

话说天下大势,分久必合,合久必分,在字符编码世界里也遵循这样一种历史规律。从 ASCII 码到 EASCII(ISO8859),发展到后来的群雄并起,各国家地区拥有自己的编码格式,中国有 GBK,日本有 JIS,台湾有 BIG5,然而这是一个混战的年代,大家各自为政,没有统一的编码标准,交流起来极其麻烦。字符编码成为了程序员最头疼的问题之一

于是在 1991 年,国际标准化组织和统一码联盟组织各自开发了 ISO/IEC 10646(USC)和 Unicode 项目。他们两的野心都不小,一统江湖,千秋万载。不过很快双方都意识到世界上并不需要两个不兼容的字符集。于是他们就编码问题进行了一次非常友好地会晤,决定彼此把工作内容合并,项目还是独立存在,各自发布各自的标准,前提是两者必须保持兼容。不过由于 Unicode 这一名字比较好记,因而它使用更为广泛,成为了事实上的统一编码标准。

以上是对字符编码历史的一个简要回顾,Unicode 究竟是什么东西,它是一种编码格式吗?中文维基百科对 Unicode 的解释也是让人一头雾水,摸不着头脑。那么来看看阮老师怎么说:

可以想象,如果有一种编码,将世界上所有的符号都纳入其中。每一个符号都给予一个独一无二的编码,那么乱码问题就会消失。这就是Unicode,就像它的名字都表示的,这是一种所有符号的编码。

这句话读起来很拗口,有三个地方出现了「编码」二字。不知阮老师对「编码」的理解是什么?但可以肯定的是这三个「编码」在这句话里面不是同一个意思。因此有必要先解释一下「编码」到底是什么意思。

「编码」在汉语里可以作动词使用,编码就是把一个字符(严格一点说是字符在字符集中的编号 code point)转换成一个字节序列,以便在网络传输或者存储到文本中。比如「好」在 Unicode 中的编号是 U+597d,经过 UTF-8 编码后会转换成二进制序列是 '\xe5\xa5\xbd' 。

>>> a = u"好"
>>> a
u'\u597d'
>>> b = a.encode("utf-8")
>>> b
'\xe5\xa5\xbd'
>>>

「编码」还可以做名词使用,作为名词使用时,就是指一种具体的编码实现方式,比如 ASCII 编码,GBK 编码,UTF-8 编码,都叫做编码,是一种具体的实实在在的编码格式。

把编码概念弄清楚了之后,我们就可以来定义 Unicode 了。其实 Unicode 是一个囊括了世界上所有字符的字符集,其中每一个字符都对应有唯一的编码值(code point),然而它并不是一种什么编码格式,仅仅是字符集而已。 Unicode 字符要存储要传输怎么办,它不管,具体怎么编码,你们可以自己去实现,可以用 UTF-8、UTF-16、甚至用 GBK 来编码也是可以的。比如:

>>> a = u"好"
>>> a
u'\u597d'
>>> b = a.encode("gbk")
>>> b
'\xba\xc3'

「好」用 GBK 编码转后就是用两个字节表示,用 UTF-8 编码就是 3 个字节,同一个字符用不同的编码方式占用的字节长度不一。

阮老师谈到 Unicode 的问题 时说:

这里就有两个严重的问题,第一个问题是,如何才能区别 Unicode 和 ASCII ?

把 Unicode 和 ASCII 码作为字符集来理解时,需要区分二者吗?不需要,因为 Unicode 是在 ASCII 的基础之上建立的,比如 ASCII 码字符集中的 「A」对应的 code ponit 是 0x41,Unicode 码是 U+0041,两者是一样的。至于中日韩文字用 ASCII 根本没法表示,所以也不存在混淆的说法。他又接着说:

计算机怎么知道三个字节表示一个符号,而不是分别表示三个符号呢?

计算机怎么不知道是用几个字节表示呢?你把字符存储到文本的时候肯定是要指定一种编码格式的,既然指定了编码格式,那么读取的时候也按照该种编码格式反过来读就行了。老师把这也当做一个问题就有点牵强附会了。

再来看阮老师说 Unicode 的第二个问题:

第二个问题是,我们已经知道,英文字母只用一个字节表示就够了,如果Unicode统一规定,每个符号用三个或四个字节表示,那么每个英文字母前都必然有二到三个字节是0,这对于存储来说是极大的浪费,文本文件的大小会因此大出二三倍,这是无法接受的。

Unicode 并没有统一规定每个符号用三个或者四个字节表示。Unicode 只规定了每个字符对应到唯一的代码值(code point),代码值 从 0000 ~ 10FFFF 共 1114112 个值 ,真正存储的时候需要多少个字节是由具体的编码格式决定的。比如:字符 「A」用 UTF-8 的格式编码来存储就只占用1个字节,用 UTF-16 就占用2个字节,而用 UTF-32 存储就占用4个字节。

再看来看这张图:

windows-notepad.jpg

阮老师对 Unicode 编码的解释是:

Unicode编码指的是UCS-2编码方式,即直接用两个字节存入字符的Unicode码。这个选项用的little endian格式。

阮老师全文并没有对 UCS-2 做任何解释,导致很多读者非常疑惑,因此,我有必要在这里解释一下。

UTF( Unicode Transformation Format)编码 和 USC(Universal Coded Character Set) 编码分别是 Unicode 、ISO/IEC 10646 编码体系里面两种编码方式,UCS 分为 UCS-2 和 UCS-4,而 UTF 常见的种类有 UTF-8、UTF-16、UTF-32。因为两种字符集是相互兼容的,所以这几种具体的编码格式也有着对应的等值关系。

UCS-2 是使用两个定长的字节来表示一个字符,UTF-16 也是使用两个字节,不过 UTF-16 是变长的(网上很多错误的说法说 UTF-16是定长的),遇到两个字节没法表示时,会用4个字节来表示,因此 UTF-16 可以看作是在 UCS-2 的基础上扩展而来的。而 UTF-32 与 USC-4 是完全等价的。

再回到这张图片,之所以在 Windows下有 Unicode 编码这样一种说法,其实是 Windows 的一种错误表示方法,或许是因为历史原因还是其他问题一直沿用至今。这因为如此,导致绝大多数初学者误以为 Unicode 就是一种编码格式,所以 Windows 有时也是误人子弟啊。反正你就理解为 UTF-16 编码就是。

阮老师对什么是大端和小端的解释显得很唐突。为什么会有大端小端?也解释的含糊其词:

上一节已经提到,Unicode码可以采用UCS-2格式直接存储。以汉字"严"为例,Unicode码是4E25,需要用两个字节存储,一个字节是4E,另一个字节是25。存储的时候,4E在前,25在后,就是Big endian方式;25在前,4E在后,就是Little endian方式。因此,第一个字节在前,就是"大头方式"(Big endian),第二个字节在前就是"小头方式"(Little endian)。

小端就是低位字节放在内存的低地址端,高位字节放在内存的高地址端。与之相反,大端就是高位字节放在内存的低地址端,低位字节放在内存的高地址端。举例来说,字节序列 '0x12 34 56 78' 在内存中的表示形式为:

# 小端模式
低地址 ------------------> 高地址
0x78  |  0x56  |  0x34  |  0x12

# 大端模式
低地址 -----------------> 高地址
0x12  |  0x34  |  0x56  |  0x78

至于为什么会有大端和小端之分呢?对于 16 位或者 32 位的处理器,由于寄存器宽度大于一个字节,那么必然存在着一个如何将多个字节排放的问题,因为不同机器类型读取双字节的时候方式不一样,因此就导致了大端存储模式和小端存储模式的存在,两者并没有孰优孰劣。

前面我已经解释过了 UCS-2 可以理解为 UTF-16 编码,汉字「严」的 Unicode 编号是 U+4E25,存储的时候到底需要几个字节,跟具体的编码格式有关,如果是用 UTF-8 编码,结果为 'e4 b8 a5',需要 3 个字节来存储。用 UTF-16 编码,结果为 '4e 25',用两个字节存储,UTF-32 就要用4个字节了。因为 UTF-16 和 UTF-32 都是以双字节为单位来储存字符的,双字节就存在大小端的问题了。而 UTF-8 的编码单元是1个字节,所以就不用考虑字节序问题。

他又说:

Unicode规范中定义,每一个文件的最前面分别加入一个表示编码顺序的字符,这个字符的名字叫做"零宽度非换行空格"(ZERO WIDTH NO-BREAK SPACE),用FEFF表示。这正好是两个字节,而且FF比FE大1。

如果一个文本文件的头两个字节是FE FF,就表示该文件采用大头方式;如果头两个字节是FF FE,就表示该文件采用小头方式

这里就错得有点离谱了。在 UTF-16 中,FEFF 是字节顺序标记(byte order mark,BOM),用来标记编码后的字节序列高位在前还是低位在前。小端低位在前,高位在后,用 「FFFE」 标识。大端高位在前,低位在后,用 「FEFF」 标识。

yan

上图就是我分别用 UTF-16LE(小端)和 UTF-16BE(大端)保存的「严」的十进制显示方式。而 FEFF 作为零宽度非换行空格仅仅是它出现在字节序列的中间时的作用,用户看起来就是一个空格,不过从 Unicode3.2 开始就只能规定 FEFF 只能出现在字节流的开头,用于标记字节序(大小端)。

最后是我自己的补充,UTF-8、UTF-16 各自的应用场景和优缺点。

UTF-8 的优势是:它以单字节为单位用 1~4 个字节来表示一个字符,兼容了 ASCII,在数据传输和存储过程中节省了空间,其二是UTF-8 不需要考虑大小端问题。这两点都是 UTF-16 的劣势。

不过对于中文字符,用 UTF-8 就要用3个字节,而 UTF-16 只需2个字节。而UTF-16 的优点是在计算字符串长度,执行索引操作时速度会很快。Java 内部使用 UTF-16 编码方案。而 Python3 使用 UTF-8。UTF-8 编码在互联网领域应用更加广泛。

以上就是我对字符编码的一点个人理解,不一定全部正确,但希望同样能给你带来一些思考。阮一峰老师的这篇文章虽然有些错误的地方,但只要不盲崇,带着怀疑的态度看待问题,也一定有不一样的收获。

参考链接:


关注公众号「Python之禅」,回复「1024」免费获取Python资源

python之禅