Python str() 引發的 UnicodeEncodeError

浮生若夢的程式設計發表於2018-03-06

起因

眾所周知,Python 2 中的 UnicodeEncodeError 與 UnicodeDecodeError 是比較棘手的問題,有時候遇到這類問題的發生,總是一頭霧水,感覺莫名其妙。甚至,《Fluent Python》的作者還提出了所謂“三明治模型”的東西來幫助解決此類問題(其實大可不必如此麻煩,後文有述)。

今天線上上遇到一個與此有關的小問題,感覺很有趣,水文一篇記錄之。

Bug 轉到我這裡時,看到現象自然是UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)這類莫名其妙的提示。然後翻 log,迅速找到對應的程式碼行,大概類似下面這種:

thrift_obj = ThriftKeyValue(key=str(xx_obj.name))  # 出錯行, xx_obj.name 是一個 str
複製程式碼

一開始,看見str(xx_obj.name),也不知道是手誤,還是故意為之,反正是學不會這種操作(應該每個專案裡面,或多或少都有這樣的神奇程式碼吧)。

分析

看異常的字面意思,大致就是:有某個串,正在被 ASCII 編碼器編碼,但是顯然該串超出了 ASCII 編碼器所規定的範圍,於是出錯。於是推測:

  • 哪裡應該有個什麼Unicode串(什麼串無所謂,反正只要超出 ASCII 的範圍就行),這裡應該是 xx_obj.name
  • 某處正在發生編碼動作,而且是偷偷地在搞(最煩這種隱式轉換了,Python 2 中很多),從程式碼看不出在哪裡。

左看右看,應該是 str() 這個內建函式,於是簡單地試了一下如下程式碼:

In [5]: u = u'中國'

In [6]: str(u)
---------------------------------------------------------------------------
UnicodeEncodeError                        Traceback (most recent call last)
<ipython-input-6-b3b94fb7b5a0> in <module>()
----> 1 str(u)

UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)
In [7]: b = u.encode('utf-8')

In [8]: str(b)
Out[8]: '\xe4\xb8\xad\xe5\x9b\xbd'


複製程式碼

果然如此。查閱文件一看,沒啥有價值的資訊,描述太模糊了:

class str(object='')
Return a string containing a nicely printable representation of an object. For strings, this returns the string itself. The difference with repr(object) is that str(object) does not always attempt to return a string that is acceptable to eval(); its goal is to return a printable string. If no argument is given, returns the empty string, ''.

For more information on strings see Sequence Types — str, unicode, list, tuple, bytearray, buffer, xrange which describes sequence functionality (strings are sequences), and also the string-specific methods described in the String Methods section. To output formatted strings use template strings or the % operator described in the String Formatting Operations section. In addition see the String Services section. See also unicode().
複製程式碼

我們的程式碼裡面(Python 2),每個 py 檔案都有這麼一行:

from __future__ import unicode_literals, absolute_import
複製程式碼

所以我推測 xx_obj.name 是要給 unicode 串,打 log 一看,果然如此。

解決

至此,要麼將 xx_obj.name 轉化成 str() 能認識的東西,在這裡至少不能是 unicode,應該是 bytes。不過我沒有這麼做,太醜陋了,二是改成這樣:

thrift_obj = ThriftKeyValue(key=xx_obj.name) # 這裡沒必要呼叫 str() ,估計前面能跑正常,是因為 name 恰好總是 ASCII 字元
複製程式碼

Bug 修復,其他功能也表現正常。

總結

前文講到,Python 2 中有較多這種隱式轉換,而且也沒啥文件說明,特別是加上 Windows環境和 print 操作時,報錯資訊更是看得人不明所以。《Fluent Python》中有講到所謂“三明治模型”來解決這一問題,還是蠻有啟發的。

不過,我一般遵循的原則是:只用 Unicode,讓任何地方都是 Unicode。方式如下:

  • 所有 py 檔案必須有如下檔案頭:
#!/usr/bin/env python
# -*- coding: utf-8 -*-
#

from __future__ import unicode_literals, absolute_import
複製程式碼
  • 接到外界的位元組串(從網路,從檔案等),先轉成 Unicode,不過抽取成函式更好,免得重複編碼:
API 的起名優點冗餘,主要是為了做到 “見名知義”


class UnicodeUtils(object):
    @classmethod
    def get_unicode_str(cls, bytes_str, try_decoders=('utf-8', 'gbk', 'utf-16')):
        """轉換成字串(一般是Unicode)"""
        
        if not bytes_str:
            return u''

        if isinstance(bytes_str, (unicode,)):
            return bytes_str

        for decoder in try_decoders:
            try:
                unicode_str = bytes_str.decode(decoder)
            except UnicodeDecodeError:
                pass
            else:
                return unicode_str

        raise DecodeBytesFailedException('decode bytes failed. tried decoders: %s' % list(try_decoders))

    @classmethod
    def encode_to_bytes(cls, unicode_str, encoder='utf-8'):
        """轉換成位元組串"""
        
        if unicode_str is None:
            return b''

        if isinstance(unicode_str, unicode):
            return unicode_str.encode(encoding=encoder)
        else:
            u = cls.get_unicode(unicode_str)
            return u.encode(encoding=encoder)
複製程式碼
  • 送到外界的東西,全部轉成 UTF-8 編碼的位元組串,見上面程式碼

相關文章