概述
在各种日志、tty 输出中,我们总是能够发现各种编码不正确的字符。
�😸� `\xef\xbf\xbd\xf0\x9f\x98\xb8\xef\xbf\xbd`
'\xe7\xb2\xbe\xe5\xa6\x99'
`<<"ä½ å¥½">>`
遇到这种情况,我们下意识地会产生三个想法:
- 这是什么(原本的内容应该是什么)?
- 从哪里来的?
- 为什么会这样?
- 我该怎么处理好?
对于我个人的理解,乱码只不过是「一种对于文本类数据的错误==解读==或者==展示==」。
结论(造成的原因):
- 编码不当 encoding issue。比如,使用 utf8 编码的文本数据使用 gbk 解码。
- 字体缺失 character missing in font。
- 文本数据被错误的截断 data was not properly splited。在网络传输或者储存的时候被程序不恰当的处理了。
接下来,分享一下本人对于这些相关的问题整理的信息。
准备工作
我们以 Python3 为例,先学习一些简单且有必要的相关处理手段。
Python3 中用来处理字符的数据类型有以下:
'精妙' | <class 'str'> | <class 'str'> | 2 |
b'\xe7\xb2\xbe\xe5\xa6\x99' | <class 'bytes'> | <class 'int'> | 6 |
这个地方需要注意,'str' 中的每一个元素(element),py3 可不仅仅是range 256。请看:
Python2:
Python 2.7.18
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: chr() arg not in range(256)
Python3:
Python 3.9.1
>>> chr(0x70ce)
'烎'
可以很明显的看到,b'\xe7\xb2\xbe\xe5\xa6\x99'
这个长度为6的 bytes 就是精妙
这两个汉字的 utf8 编码后二进制数据。它等价于bytes([0xe7, 0xb2, 0xbe, 0xe5, 0xa6, 0x99]])
。
转换 bytes
<-> str
>>> bs = '精妙'.encode('utf8') # str to bytes/binary
>>> type(bs), len(bs), type(bs[0])
(<class 'bytes'>, 6, <class 'int'>)
>>> bs2 = bytes('精妙', 'utf8') # alternative way to convert
>>> bs2
b'\xe7\xb2\xbe\xe5\xa6\x99'
>>> origin_s = bs.decode('utf8') # bytes to str
>>> origin_s, type(origin_s), len(origin_s), type(origin_s[0])
('精妙', <class 'str'>, 2, <class 'str'>)
有多种构造二进制的方法
cons_byte = bytes([231, 178, 190, 229, 166, 153])
cons_byte2 = b'\xe7\xb2\xbe\xe5\xa6\x99'
>>> cons_byte, cons_byte2
(b'\xe7\xb2\xbe\xe5\xa6\x99', b'\xe7\xb2\xbe\xe5\xa6\x99')
请留意,当我们拿到一块二进制数据的时候。即便知道他是字符串编码成的数据,在不清楚编码方式的情况下,我们是没有办法直接还原原始的字符数据的。
这种时候,如果大家都约定内存中的 string
用 unicode,二进制都用 utf8 编码,那就会非常方便。拿到一个 binary 直接进行 decode utf8 即可。
如果我们不知道未知的 bytes 数据编码类型,那么可以尝试用 chardet 来分析:
>>> chardet.detect(b'\xe7\xb2\xbe\xe5\xa6\xfe')
{'encoding': 'ISO-8859-1', 'confidence': 0.73, 'language': ''}
>>> chardet.detect(b'\xe7\xb2\xbe\xe5\xa6\x99')
{'encoding': 'utf-8', 'confidence': 0.7525, 'language': ''}
有时候,bytes 数据出现了一些问题(IO error 或者程序 bug),虽然我们知道它是怎么编码的,但是 decode 的时候仍然会出错。此时可以尝试设置一下 decode 函数的 errors 参数来碰碰运气:
这里我们把 xe5\xa6\x99 改成 xe5\xa6\==xfe== 把原始的二进制数据改成一个不合法的 utf8 编码的 bytes。
>>> b'\xe7\xb2\xbe\xe5\xa6\xfe'.decode('utf8', errors='strict')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'utf-8' codec can't decode bytes in position 3-4: invalid continuation byte
>>> b'\xe7\xb2\xbe\xe5\xa6\xfe'.decode('utf8', errors='ignore')
'精'
可以看到decode 函数很努力地把前三个 bytes 对应的汉字正确的解析出来了。实际是非常不推荐大家在程序中这么写的,毕竟找到问题才是正道(而不是掩盖过去)。
乱码的几种形态
➊ 编码不当。
我们尝试对于 utf 编码的「你好棒棒哒」,分别使用 gbk 和 ASC II 方式来解析:
>>> r = '你好棒棒哒'.encode('utf8')
>>> r
b'\xe4\xbd\xa0\xe5\xa5\xbd\xe6\xa3\x92\xe6\xa3\x92\xe5\x93\x92'
>>> r.decode('gbk', errors='ignore')
'浣犲ソ妫掓掑搾'
>>> ''.join([chr(c) for c in r])
'ä½\xa0好æ£\x92æ£\x92å\x93\x92'
看看这个 浣犲ソ妫掓掑搾
和 ä½\xa0好æ£\x92æ£\x92å\x93\x92
,是不是有那股味了?
➊附➀ 上古时代的==锟斤拷==和==烫烫烫==
大概15年前,有过写win32程序的朋友大概都有一些印象。我们也可以尝试复现一下:
锟斤拷似乎是由 unicode 的
0xFFFD
引发的:>>> [chr(0xFFFD)]*10 ['�', '�', '�', '�', '�', '�', '�', '�', '�', '�'] >>> ''.join([chr(0xFFFD)]*10).encode('utf8') b'\xef\xbf\xbd\xef\xbf\xbd\xef\xbf\xbd\xef\xbf\xbd\xef\xbf\xbd\xef\xbf\xbd\xef\xbf\xbd\xef\xbf\xbd\xef\xbf\xbd\xef\xbf\xbd' >>> ''.join([chr(0xFFFD)]*10).encode('utf8').decode('gbk') '锟斤拷锟斤拷锟斤拷锟斤拷锟斤拷'
同样的,把一块二进制每个 byte 写为某个默认值(0xCC),再乱解码就有了==烫烫烫==:
>>> bytes([0xCD]*10).decode('gbk', errors='ignore') '屯屯屯屯屯' >>> bytes([0xCC]*10).decode('gbk', errors='ignore') '烫烫烫烫烫'
➋ 字体缺失
这个就更好理解了,你用的字体里面没有那个字符。大部分情况下显示的是。
字符、unicode、Emoji 和编码。
为了澄清乱码的概念,我们有必要先搞清楚是计算机系统中的「字符」。
首先要定义字符的意义,我们先不定义它,举一些例子出来:
- 汉语中的一个汉字(例如
字
)是==一个==字符,这种观念肯定是深入人心的。 - ASCII 中的可见字符(例如
A
)是一个字符。 - 😂 请注意,这不是一个图片。
- 有一些不可见的控制字符也是字符。
我们先来研究一下那个流行的「笑哭脸」符号:
>>> s = '😂'
>>> s
'😂'
>>> s.encode('utf8')
b'\xf0\x9f\x98\x82'
>>> [hex(b) for b in s.encode('utf8')]
['0xf0', '0x9f', '0x98', '0x82']
>>> bytes([0xf0, 0x9f, 0x98, 0x82]).decode('utf8')
'😂'
>>> chr(0x1f602)
'😂'
>>> ord('😂')
128514
>>> hex(ord('😂'))
'0x1f602'
实际上,这哭脸符号是一个 unicode「字符(character)」。
- 它是一个字符,再次强调。
- 它的解释是:"face with tears of joy"。
- 它的 unicode 编号是
U+1F602
。 - 对应的 utf-8 编码是
0xf0 0x9f 0x98 0x82
,一共4个字节。 - 它处于 unicode 的 Emoticons 块(范围 U+1F600 - U+1F64F),也就是我们通常所说的
Emoji
。
如何构造一个 emoji 的 unicode 字符呢?我们可以有多种方式构造这个字符,比如
- 通过 unicode 编号
chr(0x1F602)
- 通过二进制数据解码
bytes([0xf0, 0x9f, 0x98, 0x82]).decode('utf8')
。
同时,我们还了解到了在 Python3 中 bytes / str / unicode 的关系:
另外,这个😂,它还有2个变种,具体可以参考 unicode 相关的 wikipedia 页面。
有趣的记录
大小写的困惑
>>> 'BAfflE' 'BAfflE' >>> 'BAfflE'.upper() 'BAFFLE' >>> 'BAfflE'.upper() == 'BAFFLE' True >>> 'BAfflE' == ''BAfflE'.upper() lower() False >>> len('BAfflE') 4 >>> len('BAfflE'.upper()) 6
参考:
第二篇参考中,作者的观点主要是:
作者发现,在当时的 Python 库中,对于 unicode 的,字符串的「取长」、「逆转」、「截取」、「转换大小写」、「遍历」等操作,在当时的 string
类型中都不能够很好的处理。
因此他希望能够有一个直接的,对 unicode 进行类似 list 操作的支持。这一点对我的启发也比较大。