概述

在各种日志、tty 输出中,我们总是能够发现各种编码不正确的字符。

�😸�  `\xef\xbf\xbd\xf0\x9f\x98\xb8\xef\xbf\xbd`
'\xe7\xb2\xbe\xe5\xa6\x99'
`<<"你好">>`

遇到这种情况,我们下意识地会产生三个想法:

  • 这是什么(原本的内容应该是什么)?
  • 从哪里来的?
  • 为什么会这样?
  • 我该怎么处理好?
    对于我个人的理解,乱码只不过是「一种对于文本类数据的错误==解读==或者==展示==」。

结论(造成的原因):

  1. 编码不当 encoding issue。比如,使用 utf8 编码的文本数据使用 gbk 解码。
  2. 字体缺失 character missing in font。
  3. 文本数据被错误的截断 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 操作的支持。这一点对我的启发也比较大。

03-05 23:00