1. 程式人生 > >有關 Python 2 和 Sublime Text 中文 Unicode 編碼問題的分析與理解

有關 Python 2 和 Sublime Text 中文 Unicode 編碼問題的分析與理解

問題背景:

相信很多用 Sublime Text 來寫 Python 2 的同學都遇到過以下這個問題(例如這位同學 /t/100435 和這位同學 /t/163012 ):
在 Sublime Text 裡用 Cmd (Ctrl) + B 執行程式碼 print u'中文',想要打印出 unicode 型別的字串時,會出現以下報錯:

UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)

傳說中的 Python 2 編碼坑(笑)

而同樣的 print u'中文'

程式碼在 Mac 的終端裡卻能正常打印出 “中文” 結果,沒有任何報錯。

雖然在網上能查到多種解決方法,但一直以來知其然而不知其所以然,不瞭解為什麼那些方法能解決問題的真正原因,也不知道為什麼同樣的程式碼在終端裡就可以執行而在 Sublime Text 裡就不行了?

因此我研究學習了下這個問題相關的一些 Python 2 編碼問題,在這裡分享下我的理解。

以下屬於新手向,參考了網上多篇文章,如有錯誤,望指正。

先說下我的環境:

  • Mac OS X
  • Python 2.7
  • Sublime Text 3

分析:

Python 在向控制檯 (console) print 的時候,因為控制檯只能看得懂由 bytes(位元組序列)組成的字串,而 Python 中 "unicode" 物件儲存的是 code points(碼點),因此 Python 需要將輸出中的 "unicode" 物件用編碼轉換為儲存 bytes(位元組序列)的 "str" 物件後,才能進行輸出。

而在報錯裡看到 UnicodeEncodeError, 那就說明 Python 在將 unicode 轉換為 str 時使用了錯誤的編碼。而為什麼是 'ascii' 編碼呢?那是因為 Python 2 的預設編碼就是 ASCII,可以通過以下命令來檢視 Python 的預設編碼:

>>> import sys
>>> print sys.getdefaultencoding()
ascii

所以此時在 Sublime Text 裡執行 print u'中文',實際上等於是運行了:

print u'中文'.encode('ascii')

ASCII 編碼無法對 unicode 的中文進行編碼,因此就報錯了。

那為什麼同樣的程式碼 print u'中文' 在 Mac 的終端裡卻能正常輸出中文,難道是因為終端下的 Python 2 的預設編碼不是 ASCII?非也,在終端下執行 sys.getdefaultencoding() 結果一樣是 ascii。那同樣是 ascii 為什麼會有不同的結果?難倒這裡 Python 用了另外一個編碼來轉換?

是的,其實 Python 在 print unicode 時真正涉及到的是另一組編碼:stdin/stdout/stderr 的編碼,也就是標準輸入、標準輸出和標準錯誤輸出的編碼。可以通過以下命令來檢視,這裡是在我的終端下執行的結果:

>>> import sys
>>> print sys.stdin.encoding
UTF-8
>>> print sys.stdout.encoding
UTF-8
>>> print sys.stderr.encoding
UTF-8

在正常情況下,Python 2 在 print unicode 時用來轉換的編碼並不是 Python 的預設編碼 sys.getdefaultencoding(),而是 sys.stdout.encoding 所設的編碼。

因為在我的終端下 Python 的 sys.stdout.encoding 編碼是 UTF-8,所以在終端裡執行 print u'中文' 時,實際上是等於運行了:

print u'中文'.encode('UTF-8')

編碼正確,執行正常,因此沒有報錯。

在類 UNIX 系統下,Python 應該是通過環境變數 LC_CTYPE 來判斷 stdin/stdout/stderr 的編碼的。因此一般只要將 shell 的 LANG 環境變數設定對為 **_**.UTF-8 後,應該就能在終端裡直接 print 出 unicode 型別的字串了,而不需要在 print 時手動加上 .encode('utf-8') 進行編碼了。

但在 Sublime Text 裡事情就沒那麼美好了。在 Sublime Text 裡執行檢視 stdout 編碼的命令,發現:

import sys
print sys.stdout.encoding
-----------------------------"""
None
[Finished in 0.1s]

結果甚至不是 'ascii' 而是 None。可能是因為 Sublime Text 的 Build System 是用 subprocess.Popen 來執行 Python 的,導致 Python 無法判斷出正確的 stdin/stdout/stderr 編碼,於是都變成 None 了。

這種情況也發生在輸出的目標是管道的情況下:

$ python -c 'import sys; print sys.stdout.encoding' | tee /tmp/foo.txt
None

那麼在這種 sys.stdout.encodingNone 情況下的 print unicode 怎麼辦呢?答案就是 Python 只能很無奈地使用 sys.getdefaultencoding() 的預設編碼 ascii 來對 unicode 進行轉換了。這樣就出現了本文開頭所說的那個 UnicodeEncodeError 問題。

總結一下 Python 2 向控制檯 print 輸出時的流程:

  1. Python 啟動時,當它發現當前的輸出是連線到控制檯的時候,它會根據一些環境變數,例如環境變數 LC_CTYPE,來設法判斷出 sys.stdin/stdout/stderr.encoding 編碼值。
  2. 當 Python 無法判斷出所需的編碼時,它會將 sys.stdin/stdout/stderr.encoding 的值設定為 None
  3. print 時判斷字串是否是 unicode 型別。
  4. 如果是的話,並且 sys.stdout.encoding 不為 None 時,就使用 sys.stdout.encoding 編碼對 unicode 編碼成 str 後輸出。
  5. 如果 sys.stdout.encodingNone 的話,就使用 sys.getdefaultencoding() 預設編碼來對 unicode 進行轉換成 str 後輸出。

    if sys.stdout.encoding:
        print unicode.encode(sys.stdout.encoding)
    else:
        print unicode.encode(sys.getdefaultencoding())
    

解決方法:

解決方法 1:

先說最不正確的解決方法:在檔案頭部加上

import sys
reload(sys)
sys.setdefaultencoding('utf-8')

這種方法通過 dirty hack 的方式在 Python 剛啟動時更改了 Python 的預設編碼為 utf-8。此後:

>>> print sys.getdefaultencoding()
utf-8

但就本文所討論的問題來說,這個方法並不是真正地直接解決了問題。就如上述所說,Python 只是在 sys.stdout.encodingNone 時才會使用預設編碼來轉換需要 print 的 unicode 字串。那萬一在 sys.stdout.encoding 存在,但為 ascii 的情況下呢?這樣即使更改了 Python 的預設編碼,同樣還是會出現 UnicodeEncodeError 報錯。所以對本問題來說,這個方法治標不治本。

除此之外,很多人都用這個方法來解決 Python 2 下遇到的其它各種各樣的編碼問題,在 v2ex 的各種 Python 編碼問題討論帖中也常常能見到有人推薦用這個方法來解決問題的。
但實際上很多大牛都不推薦用這個方法來解決 Python 2 的編碼問題,這裡引用下 StackOverflow 相關回答 裡的一句話:

the use of sys.setdefaultencoding() has always been discouraged

為什麼這個方法不被推薦呢?我們來看下 Python 文件裡對這個 function 是怎麼說的:

This function is only intended to be used by the site module implementation and, where needed, by sitecustomize. Once used by the site module, it is removed from the sys module’s namespace.

可以看到這個方法原本就不是使用者向的方法,並沒有打算讓使用者用這個方法來更改 Python 2 的預設編碼。

那為什麼不建議我們更改 Python 的預設編碼呢?
這裡引用 Python 核心開發者、Python Unicode 支援的設計者和實現者: Marc-André Lemburg,他在一個郵件列表上的回覆

The only supported default encodings in Python are:

Python 2.x: ASCII

Python 3.x: UTF-8

If you change these, you are on your own and strange things will
start to happen. The default encoding does not only affect
the translation between Python and the outside world, but also
all internal conversions between 8-bit strings and Unicode.

Hacks like what's happening in the pango module (setting the
default encoding to 'utf-8' by reloading the site module in
order to get the sys.setdefaultencoding() API back) are just
downright wrong and will cause serious problems since Unicode
objects cache their default encoded representation.

Please don't enable the use of a locale based default encoding.

If all you want to achieve is getting the encodings of
stdout and stdin correctly setup for pipes, you should
instead change the .encoding attribute of those (only).

--

Marc-Andre Lemburg

eGenix.com

從此可見,Python 2 唯一支援的內部編碼只有 ASCII,更改其預設編碼為其它編碼可能會導致各種各樣奇怪的問題。在這裡他也說了使用 sys.setdefaultencoding() 的方法是徹徹底底的錯誤,正確的方法應該是更改 stdout 和 stdin 的編碼。

所以這個方法是最不正確的填坑方法,請大家慎用。

解決方法 2:

然後說說應當是姿勢最正確的、也是大家都懂的方法:

print 的時候顯式地用正確的編碼來對 unicode 型別的字串進行 encode('正確的編碼') 為 str 後, 再進行輸出。
而在 print 的時候,這個正確的編碼一般就是 sys.stdout.encoding 的值。但也正如上述所說,這個值並不是一直是可靠的,因此需要根據所使用的平臺和控制檯環境來判斷出這個正確的編碼。

而在 Mac 下這個正確的編碼一般都是 utf-8,因此若不考慮跨環境的話,可以無腦地一直用 encode('utf-8') 和 decode('utf-8') 來進行輸入輸出轉換。

在我的經驗中,這個策略也是解決 Python 2 其它 unicode 相關編碼問題的最佳方法。在 PyCon 2012 的一個演講中(關於 Python Unicode 問題很好的一個演講,這裡有演講稿的中文翻譯版),對這個方法有一個很形象的比喻:

Unicode sandwich

因為在程式中進進出出的只有儲存 bytes(位元組序列)的 str。因此最好的策略是將輸入的 bytes 馬上解碼成 unicode,而在程式內部中均使用 unicode,而當在進行輸出的時候,儘早將之編碼成 bytes。

也就是要形成一個 Unicode 三明治(如圖), bytes 在外, Unicode 在內。在邊界的地方儘早進行 decodeencode。不要在內部混用 str 和 unicode,儘可能地讓程式處理的字串都為 Unicode。

解決方法 3:

雖然解決方法 2 是最正確的方式,但是有時候在 Sublime Text 裡除錯些小指令碼,實在是懶得再在每個 print 語句後面寫一個尾巴 .encode('utf-8')。那麼有沒有辦法能讓 Sublime Text 像在終端裡一樣直接就能 print u'中文' 呢?也就是說能不能解決 sys.stdin/stdout/stderr.encodingNone 的情況呢?

答案肯定是有的,一種方法是用類似更改預設編碼的方法一樣,用 dirty hack 的方式在 Python 程式碼中去顯式地更改 sys.stdin/stdout/stderr.encoding 的值。一樣是不推薦,我也沒嘗試過,在這裡就不詳說了。

另一種方法則是通過設定 PYTHONIOENCODING 環境變數來強制要求 Python 設定 stdin/stdout/stderr 的編碼值為我們想要的,這是一個相對比較乾淨的解決方法。見文件:

PYTHONIOENCODING

Overrides the encoding used for stdin/stdout/stderr, in the syntax encodingname:errorhandler. The :errorhandler part is optional and has the same meaning as in str.encode().

New in version 2.6.

在 Mac 下對全域性 GUI 程式設定環境變數的方法是:使用 launchctl setenv <<key> <value>, ...> 命令對所有 launchd 啟動的未來子程序設定環境變數。

在這裡順便科普下,為什麼對所有 launchd 啟動的未來子程序設定環境變數可以使得對 Mac 下所有 GUI 程式生效。這是因為 launchd 是 OS X 系統啟動後執行的第一個非核心程序。我們可以在 activity monitor(活動監視器)裡看到,它的 pid 是很帥氣的 1。而之後所有的程序都將是它的子程序。
另外還可以通過 launchd 在 Mac 下實現類 crontab 的功能。

launchctl setenv 命令設定的全域性環境變數會在電腦重啟後失效,因此就需要通過上面說的 launchd 的開機啟動任務的功能來在重啟後再設定一遍環境變數,其配置方法可以參考這裡。也因為這個原因,我並沒有使用這個方法來設定 PYTHONIOENCODING 環境變數。

而 Sublime Text 提供了一個設定 Build System 環境變數的方法,這個方法各平臺的 Sublime Text 都適用。

設定 Sublime Text 的 Python Build System 環境變數的步驟如下:

  1. 將 Sublime Text 預設的 Python Build System 的配置檔案 Python.sublime-build(找到這個檔案的最好方法是安裝外掛 PackageResourceViewer)複製一份到 Sublime Text 的 /Packages/User 資料夾下(在 Mac 和 Sublime Text 3 下這個路徑是 ~/Library/Application Support/Sublime Text 3/Packages/User)。
  2. 開啟編輯新複製來的 Python.sublime-build 檔案,如下加上一行設定 PYTHONIOENCODING 環境變數為 UTF-8 編碼的內容,並儲存:
{
    "shell_cmd": "python -u \"$file\"",
    "file_regex": "^[ ]*File \"(...*?)\", line ([0-9]*)",
+   "env": {"PYTHONIOENCODING": "utf8"},
    "selector": "source.python"
}

這樣一來終於在這麼長的文章後能在 Sublime Text 裡直接執行 print u'中文',而不用再出現萬惡的 UnicodeEncodeError 了。

既然都研究到這了,不妨我們試試把 PYTHONIOENCODING 設定成其它編碼看看會出現什麼情況,例如設定成簡體中文 Windows 的預設編碼 cp936:"env": {"PYTHONIOENCODING": "cp936"}

import sys
print sys.stdout.encoding
print u'你好'
----------------------------------"""
cp936
[Decode error - output not utf-8]
[Finished in 0.1s]

[Decode error - output not utf-8],這就是 Sublime Text 在 Windows 下可能會出現的問題(例如這兩位同學 /t/45391 /t/88428 )。這是因為 Sublime Text 的 Build System 預設是用 utf-8 編碼去解讀執行的輸出的,而我們指定了讓 Python 用 cp936 編碼來生成 str 字串進行輸出,那麼就會出現 Sublime Text 無法識別輸出的情況了。
同樣在對終端 export PYTHONIOENCODING=cp936 後,在終端下 print u'你好' 輸出的就會是 ��� 這樣的亂碼。

解決辦法之一就是同樣在 Python.sublime-build 檔案裡設定 "env": {"PYTHONIOENCODING": "utf8"} 來使得輸出統一為 utf-8。

或者是更改 Sublime Text 的 Build System 所接受的輸出編碼,將其改為一致的 cp936 編碼,同樣也是更改 Python.sublime-build 檔案,加入一行:

{
    "shell_cmd": "python -u \"$file\"",
    "file_regex": "^[ ]*File \"(...*?)\", line ([0-9]*)",
+   "encoding": "cp936",
    "selector": "source.python"
}

那我們再試試把這兩個設定同時都加到 Python.sublime-build 檔案裡,也就是讓 Python 輸出 utf8 編碼的字串,而讓 Sublime Text 用 cp936 編碼來解讀,看看會發生什麼情況?

{
    "shell_cmd": "python -u \"$file\"",
    "file_regex": "^[ ]*File \"(...*?)\", line ([0-9]*)",
+   "env": {"PYTHONIOENCODING": "utf8"},
+   "encoding": "cp936",
    "selector": "source.python"
}
print u'你好'
----------------------"""
浣犲ソ
[Finished in 0.1s]

笑,居然不是 [Decode error - output not cp936],而是這麼喜感的 “浣犲ソ”

這是因為 “你好” 的 utf-8 編碼剛好和 “浣犲ソ” 的 cp936 編碼重合了,都是 '\xe6\xb5\xa3\xe7\x8a\xb2\xe3\x82\xbd',所以使用 cp936 編碼去解讀的 Sublime Text 就認為這段字串就是 “浣犲ソ” 而顯示了出來。

>>> print repr('浣犲ソ')  # cp936 編碼
'\xe6\xb5\xa3\xe7\x8a\xb2\xe3\x82\xbd'
>>> print repr(u'你好'.encode('utf-8'))  # utf-8 編碼
'\xe6\xb5\xa3\xe7\x8a\xb2\xe3\x82\xbd'

附帶解決的問題:IDLE 的互動模式裡無法輸入中文

我偶爾會用 Python 2 自帶的 IDLE 快速測試一兩行程式碼,但在我的 Mac 下的 IDLE 互動模式裡輸入中文會出現報錯:

>>> '中文'
Unsupported characters in input

這個問題在 v2ex 上同樣有同學問過: /t/44975 ,而他是在 Windows 下出現的,所以這個問題可能是普遍的。我原本以為這個問題同樣是因為上述的 stdin/stdout/stderr 的編碼問題而造成,就想順便解決掉。然而即使設定全域性環境變數 PYTHONIOENCODING 為 utf-8 後仍舊不管用,IDLE 裡輸入中文還是會報錯,sys.stdin.encoding 編碼還依舊是 us-ascii。

後來搜尋後發現,貌似這個問題是由 IDLE 輸入輸出的內部實現機制導致的,可能跟 stdin/stdout/stderr 沒有關係。根據這裡所說,IDLE 的互動模式下會根據機子的本地語言環境設定來判斷編碼,再用其對輸入進行轉換後再執行,而在我的 Mac 下這個編碼是 ascii,所以導致了 Unsupported characters in input

而我搜到了一個可行的解決方法,其通過在 IDLE 的 IO 相關原始碼(lib/python2.7/idlelib/IOBinding.py)中插入一行程式碼強行覆蓋變數 encoding 的值為 'utf-8' 來解決這個問題。

不過後來經過我測試後發現,在 Mac 下其實更為簡單的一個解決方法是,設定 IDLE 的環境變數 LANG"en_US.UTF-8"。同樣我不想通過 launchctl 設定全域性環境變數來解決,而我採用的解決方法是:

  1. 開啟編輯 IDLE.app/Contents/MacOS/IDLE 檔案。
  2. 在大概第 24 行的地方插入一行設定環境變數 LANG 的語句:
+   os.environ["LANG"] = "en_US.UTF-8"  # 第 24 行
    os.environ["PYTHONEXECUTABLE"] = executable
    os.environ["DYLD_LIBRARY_PATH"] = libdir

儲存檔案,重新開啟 IDLE 就可以在其互動模式裡輸入中文了。