Python 的 print 語句有一個很奇怪的 bug。它的功能是向控制台輸出字符,這本身不是問題。但是 Python 內部是支持 Unicode 字符串的,而 Unicode 字符串在用 print 輸出時 print 要進行一次從 Unicode 到 ANSI/MBCS 編碼的編碼,編碼后才會以 8-bit 流輸出結果。
編碼就編碼吧,這也是很正常的。對於控制台程序來說,輸出可能被重定向到文本文件。如果不指定編碼,重定向時就不知道以何種 8-bit 字節流寫入文本文件,所以,輸出到控制台的東西理論上也應該是經過編碼的 8-bit 流。綜上所述,確實有必要進行一次 WCHAR 到 char 的轉碼。
但是問題在於,Python 的 print 語句在轉碼時,居然用的是 strict 規則。即,待輸出字符串若含有當前代碼頁之外的字符,就會在轉碼過程中出現不可轉碼的文字,從而拋出 exception。print 語句又不處理這個 exception,導致一個平平常常 print 語句竟然會引起 Python 程序的異常!這簡直是不可思議。
比如說你寫了這么一段代碼:
a = u'測試啊'
print a
然后把控制台切到某個不包含這些漢字的編碼頁例如 437,輸入 chcp 437。然后再運行這段程序,就會看到異常。實際上直接輸出到控制台的是另外一種 UnicodeEncodeError 異常,因為控制台設置了代碼 頁,Python 會試圖轉碼到那個代碼頁。而更典型的(使開發者發現問題的)異常通常是把輸出重定向到文件時,看到的下面這個更典型的異常:
UnicodeEncodeError: 'ascii' codec can't encode character u'\xa1' in position 0-2: ordinal not in range(128)
注意,控制台直接輸出有異常,重定向輸出也會有異常。這兩種異常在系統內部具體過程不同,但原理都是一樣的。就是 python 遇到了它認為不能把 Unicode 字符編碼成 8-bit 流的情況。區別在於,輸出到控制台時,python 會試圖按照控制台設置的代碼頁去編碼,而重定向時干脆就按 ASCII 編碼,那自然是只有128以內的字符才能顯示出來。由此可以看出,輸出到控制台時產生的異常更隱蔽,因為絕大部分程序員都是在一種編碼下編碼+開發的,很 少有考慮到這方面的情況。在一種編碼下開發,寫進代碼的字符串,以及從文本讀出來的字符串,通常也能在這個編碼下在控制台輸出,從而把問題的發現推遲到了 用戶(使用了不同代碼頁)階段,或是推遲到了重定向輸出的時候(因為重定向默認用 ASCII 編碼,字符集最小)。知道了原因,會覺得錯誤可以理解。
說句題外話,令我最不能理解的是,一個好好的 print 語句,輸出字符串也不是 zero-terminated,不存在燙燙燙燙過了越到不可訪問內存崩潰了的結果,竟然會導致程序異常!首先別跟我說讓程序員去控制print 里字符串的內容,這有的時候程序根本控制不了。比如,讀出一個文件並顯示內容的時候。也別跟我說去 try-except,連 print 都失敗了你叫程序員情何以堪啊?看來只能想想辦法自己解決這個問題了。
首先要說明的是,既然事關控制台,要做 8-bit 流的輸入輸出,就沒有完美的解決方案。我個人的建議是,在 Windows 下,一切字符串操作,都應該盡可能使用 WCHAR 及相關函數。遇到需要跨平台和網絡傳輸的情況,再使用 UTF-8 編碼的 char 字符串。在與古老的 ANSI/MBCS 程序交互時,在嚴格限制的情況下使用該種編碼的 char 字符串。盡管並沒有完美的解決方案,在實際情況中,Windows 下 Python 程序也許應該可以有更好的表現。
解決方案一、最簡單解決重定向異常的方法是:
import sys
reload(sys)
sys.setdefaultencoding("utf-8")
然后再輸出就可以了。直接調 sys.setdefaultencoding() 這個函數是不行的,必須要 reload 一次。具體原因可以參見http://docs.python.org/library/sys.html,我就沒有深入研究了。
這個不會影響控制台直接輸出,只會影響重定向,所以最好是寫 utf-8 反正連 Windows 的記事本都可以打開 UTF-8 的文本。當然這么做也有不足,就是如果某一個程序,調用了你寫的 Python 程序,把輸出重定向到它的窗口里,這時這個程序很可能是按系統默認編碼去解碼的,用戶就看到一片亂碼了。這個沒什么好辦法,要么外圍程序做好點可以設置控 制台解碼,要么你就只能獲取一下當前控制台編碼設置(不知道 Python 里有沒有好方法,我可以用 Windows API 做到),當然這樣的話就無法防止異常了……
解決方案二、用 print a.encode("gbk", "replace") 取代 print a:
對控制台來說,由於輸出的是字節流,所以具體顯示成什么字符,取決於控制台的代碼頁設置。輸出重定向也是一樣,取決於你打開文件的方式。如果打開文件發現亂碼了,那你要說:一定是我打開的方式不對!
這個方案好處在於可以讓程序完全像使用了 Windows ANSI 函數的程序那樣工作。輸入、輸出全都是按某個特定編碼來做的,仿佛程序內部固化的字符串就是按某個特定編碼寫的。不過,程序里有幾千個 print 就得換幾千次就不說了,萬一你換漏了,又要出悲劇。
當然,既然完全像一個 Windows ANSI 程序的行為,那么不可避免的問題就是亂碼。假設你所有字符串都按 GBK 在輸入輸出時編碼了,那如果用戶設置的控制台代碼頁根本就不是 GBK 呢?又亂碼了不是……而且既然我輸入輸出都是 GBK,干嘛程序內部還要用 Unicode 呢?大概就只是為了防止內部處理時即出現異常吧。
最關鍵的是這實在不是一個程序員的作風。就沒有自動化一點的方案嗎?
解決方案三、更改 sys.stdout 的編碼:
既然問題出在 sys.stdout 的編碼往往不能滿足字符集需求上,為什么不直接更改它的編碼呢?http://www.doughellmann.com/PyMOTW/codecs/ 提供了一種方案:
import sys, codecs
sys.stdout = codecs.getwriter('utf-8')(sys.stdout)
這個方案的好處就是它同時影響控制台直接輸出和重定向輸出,比方案一強,已經達到了方案二的水平。不過它面臨一個方案二沒有而方案一還有的問題,就是如果設 置的不是 "utf-8",那么就有可能出 UnicodeEncodeError。如果設置的是 "utf-8",那就要面臨配套設施不完善而看到的亂碼問題。
最要命的是,其實你是根本無法在控制台設置成 cp65001 的情況下讓程序正常運行的!這是方案二也會同樣遇到的問題。假設我們設置了 utf-8,要想在控制台正常閱讀輸出結果,那也就要把控制台用 chcp 65001 設置成 UTF-8。但是,設置之后,python 會以為當前代碼頁叫 "cp65001",不認,會出這個錯誤:
LookupError: unknown encoding: cp65001
呃,好吧,這也是有辦法可以解決的,出自 http://stackoverflow.com/questions/878972/windows-cmd-encoding-change-causes-python-crash:
import codecs
codecs.register(lambda name: name == 'cp65001' and codecs.lookup('utf-8') or None)
這樣 Python 就認 "cp65001" 這個東西就是 "utf-8" 的別名了。這樣,你就可以在控制台 chcp 65001 然后看到輸出字符了。不過遺憾的是,這只是理論上的。實際上如果你 print a 的時候第一個字符不是純 ASCII 的,即 Unicode 碼在 128 以上,根本無法正常顯示。我們不妨把前面學到的知識都拼起來,寫一段代碼,期望它能正常工作吧:
#coding=utf-8
a = u'測試啊'
import sys
reload(sys)
sys.setdefaultencoding("utf-8")
import codecs
codecs.register(lambda name: name == 'cp65001' and codecs.lookup('utf-8') or None)
print a.encode("utf-8", "replace")
實際上運行結果是:
���試啊Traceback (most recent call last):
File "C:\Python25\Test1.py", line 11, in
print a.encode("utf-8", "replace")
IOError: [Errno 2] No such file or directory
這莫名其妙的 IOError 是怎么回事?而且字符串第一個字符也無法正常顯示,會變成若干個“�”。該字符在 UTF-8 中是幾個字節,就有幾個“�”字符。我™想破了腦袋也想不出 Python 是怎么寫出這樣的 bug 來的!注意,不是說第一個字符是純 ASCII 就可以了,只是那樣做的話輸出來的異常信息是可以看,但是異常還是有的。如果是用 sys.stdout = codecs.getwriter() 法直接 print a 的話,出現的錯誤是:
���試啊Traceback (most recent call last):
File "C:\Python25\Test1.py", line 13, in
print a
File "C:\Python25\lib\codecs.py", line 304, in
write self.stream.write(data)
IOError: [Errno 0] Error
所以實際上是根本沒法用的。我測試的版本是 Python 2.5.2,不知道后續版本是否有改進。
而且還有一個問題是如果你 chcp 65001 之后,打過一些漢字或者用 type 顯示過文件,就會發現怎么光標的位置都不對啊!換行也不對啊喂后面怎么好多東西超出去了看不到啊!
沒錯恭喜你遇到了最頭疼的問題!在 cp65001 下,並不像那些中國、日本、韓國的代碼頁下面那樣區分全角和半角,所有的字符在計算光標的時候都占同樣的寬度,但是字體渲染仍然正常。也就是說,如果(假 設一行設置的是 80 個字符)你在一行里寫了 80 個漢字,那么前 40 個渲染的時候就已經把整行占滿了,可是沒有自動換行,自動換行要到 80 列才有,所以后 40 個漢字就看不見了。
坑爹呀。
遺憾的是這還根本沒有解決辦法。要想讓全角字符正確地占兩個半角字符的寬度,就只能用一些支持這個特性的代碼頁,比如 cp936,就是 GBK。當然,這樣就不能顯示全部 Unicode 字符了,萬一有用戶輸入了這個,就只能被替換成 ? 或者其它什么東西了。
所以說,只要還跟該死的 char 字節流打交道,跟 stdout 打交道,就沒法有一個完美方案。
解決方案四、徹底不使用stdout:
這堆亂七八糟的事情從根本上來說是因為控制台的 stdout 只能接受 8-bit 字節流,也就是 char,所以才有了這么多有的沒的編碼問題。如果能夠讓 python 在用 print 的時候底層使用一個接受 WCHAR 的函數來做事,也許事情就有很大轉機。
事實上,還是在 http://stackoverflow.com/questions/878972/windows-cmd-encoding-change-causes-python-crash 就有一篇終極解決方案。它用接受 WCHAR 的 Windows API 做控制台輸出,而同時把重定向交由原有方式處理,在兼顧重定向的情況下,實現了控制台下最完美的輸出方案。
首先請看代碼:
import sys
if sys.platform == "win32":
import codecs
from ctypes import WINFUNCTYPE, windll, POINTER, byref, c_int
from ctypes.wintypes import BOOL, HANDLE, DWORD, LPWSTR, LPCWSTR, LPVOID
original_stderr = sys.stderr
# If any exception occurs in this code, we'll probably try to print it on stderr,
# which makes for frustrating debugging if stderr is directed to our wrapper.
# So be paranoid about catching errors and reporting them to original_stderr,
# so that we can at least see them.
def _complain(message):
print >>original_stderr, isinstance(message, str) and message or repr(message)
# Work around <http://bugs.python.org/issue6058>.
codecs.register(lambda name: name == 'cp65001' and codecs.lookup('utf-8') or None)
# Make Unicode console output work independently of the current code page.
# This also fixes <http://bugs.python.org/issue1602>.
# Credit to Michael Kaplan <http://blogs.msdn.com/b/michkap/archive/2010/04/07/9989346.aspx>
# and TZOmegaTZIOY
# <http://stackoverflow.com/questions/878972/windows-cmd-encoding-change-causes-python-crash/1432462#1432462>.
try:
# <http://msdn.microsoft.com/en-us/library/ms683231(VS.85).aspx>
# HANDLE WINAPI GetStdHandle(DWORD nStdHandle);
# returns INVALID_HANDLE_VALUE, NULL, or a valid handle
#
# <http://msdn.microsoft.com/en-us/library/aa364960(VS.85).aspx>
# DWORD WINAPI GetFileType(DWORD hFile);
#
# <http://msdn.microsoft.com/en-us/library/ms683167(VS.85).aspx>
# BOOL WINAPI GetConsoleMode(HANDLE hConsole, LPDWORD lpMode);
GetStdHandle = WINFUNCTYPE(HANDLE, DWORD)(("GetStdHandle", windll.kernel32))
STD_OUTPUT_HANDLE = DWORD(-11)
STD_ERROR_HANDLE = DWORD(-12)
GetFileType = WINFUNCTYPE(DWORD, DWORD)(("GetFileType", windll.kernel32))
FILE_TYPE_CHAR = 0x0002
FILE_TYPE_REMOTE = 0x8000
GetConsoleMode = WINFUNCTYPE(BOOL, HANDLE, POINTER(DWORD)) \
(("GetConsoleMode", windll.kernel32))
INVALID_HANDLE_VALUE = DWORD(-1).value
def not_a_console(handle):
if handle == INVALID_HANDLE_VALUE or handle is None:
return True
return ((GetFileType(handle) & ~FILE_TYPE_REMOTE) != FILE_TYPE_CHAR
or GetConsoleMode(handle, byref(DWORD())) == 0)
old_stdout_fileno = None
old_stderr_fileno = None
if hasattr(sys.stdout, 'fileno'):
old_stdout_fileno = sys.stdout.fileno()
if hasattr(sys.stderr, 'fileno'):
old_stderr_fileno = sys.stderr.fileno()
STDOUT_FILENO = 1
STDERR_FILENO = 2
real_stdout = (old_stdout_fileno == STDOUT_FILENO)
real_stderr = (old_stderr_fileno == STDERR_FILENO)
if real_stdout:
hStdout = GetStdHandle(STD_OUTPUT_HANDLE)
if not_a_console(hStdout):
real_stdout = False
if real_stderr:
hStderr = GetStdHandle(STD_ERROR_HANDLE)
if not_a_console(hStderr):
real_stderr = False
if real_stdout or real_stderr:
# BOOL WINAPI WriteConsoleW(HANDLE hOutput, LPWSTR lpBuffer, DWORD nChars,
# LPDWORD lpCharsWritten, LPVOID lpReserved);
WriteConsoleW = WINFUNCTYPE(BOOL, HANDLE, LPWSTR, DWORD, POINTER(DWORD), \
LPVOID)(("WriteConsoleW", windll.kernel32))
class UnicodeOutput:
def __init__(self, hConsole, stream, fileno, name):
self._hConsole = hConsole
self._stream = stream
self._fileno = fileno
self.closed = False
self.softspace = False
self.mode = 'w'
self.encoding = 'utf-8'
self.name = name
self.flush()
def isatty(self):
return False
def close(self):
# don't really close the handle, that would only cause problems
self.closed = True
def fileno(self):
return self._fileno
def flush(self):
if self._hConsole is None:
try:
self._stream.flush()
except Exception, e:
_complain("%s.flush: %r from %r"
% (self.name, e, self._stream))
raise
def write(self, text):
try:
if self._hConsole is None:
if isinstance(text, unicode):
text = text.encode('utf-8')
self._stream.write(text)
else:
if not isinstance(text, unicode):
text = str(text).decode('utf-8')
remaining = len(text)
while remaining > 0:
n = DWORD(0)
# There is a shorter-than-documented limitation on the
# length of the string passed to WriteConsoleW (see
# <http://tahoe-lafs.org/trac/tahoe-lafs/ticket/1232>.
retval = WriteConsoleW(self._hConsole, text,
min(remaining, 10000),
byref(n), None)
if retval == 0 or n.value == 0:
raise IOError("WriteConsoleW returned %r, n.value = %r"
% (retval, n.value))
remaining -= n.value
if remaining == 0: break
text = text[n.value:]
except Exception, e:
_complain("%s.write: %r" % (self.name, e))
raise
def writelines(self, lines):
try:
for line in lines:
self.write(line)
except Exception, e:
_complain("%s.writelines: %r" % (self.name, e))
raise
if real_stdout:
sys.stdout = UnicodeOutput(hStdout, None, STDOUT_FILENO,
'<Unicode console stdout>')
else:
sys.stdout = UnicodeOutput(None, sys.stdout, old_stdout_fileno,
'<Unicode redirected stdout>')
if real_stderr:
sys.stderr = UnicodeOutput(hStderr, None, STDERR_FILENO,
'<Unicode console stderr>')
else:
sys.stderr = UnicodeOutput(None, sys.stderr, old_stderr_fileno,
'<Unicode redirected stderr>')
except Exception, e:
_complain("exception %r while fixing up sys.stdout and sys.stderr" % (e,))
# While we're at it, let's unmangle the command-line arguments:
# This works around <http://bugs.python.org/issue2128>.
GetCommandLineW = WINFUNCTYPE(LPWSTR)(("GetCommandLineW", windll.kernel32))
CommandLineToArgvW = WINFUNCTYPE(POINTER(LPWSTR), LPCWSTR, POINTER(c_int)) \
(("CommandLineToArgvW", windll.shell32))
argc = c_int(0)
argv_unicode = CommandLineToArgvW(GetCommandLineW(), byref(argc))
argv = [argv_unicode[i].encode('utf-8') for i in xrange(0, argc.value)]
if not hasattr(sys, 'frozen'):
# If this is an executable produced by py2exe or bbfreeze, then it will
# have been invoked directly. Otherwise, unicode_argv[0] is the Python
# interpreter, so skip that.
argv = argv[1:]
# Also skip option arguments to the Python interpreter.
while len(argv) > 0:
arg = argv[0]
if not arg.startswith(u"-") or arg == u"-":
break
argv = argv[1:]
if arg == u'-m':
# sys.argv[0] should really be the absolute path of the module source,
# but never mind
break
if arg == u'-c':
argv[0] = u'-c'
break
# if you like:
sys.argv = argv
簡單來說這段代碼做了這么幾個事:
1、如果輸出到控制台,改用 WriteConsoleW()。
2、如果輸出被重定向,用 utf-8 編碼輸出。
3、用 GetCommandLineW() 和 CommandLineToArgvW() 獲取命令行參數,在最后一行取代 sys.argv 傳入的參數。
這個是我目前能找到的最完美的解決方案了。在控制台下也能不出錯,在重定向的時候也可以按 UTF-8 去編碼成 char 字節流。唯一的問題是 Python 2.5.2 里似乎沒有 LPVOID。我用 c_void_p 取代 LPVOID,似乎是可行的。
當然,它仍然有前述不可避免的問題。例如在非原生支持漢字的代碼頁(簡 936 繁 950 日 932 韓 949)下,光標和換行的位置會出問題。如 果對漢字顯示有很高的要求,不妨調用 Windows API 設置一下控制台的代碼頁。此外,輸出重定向到外圍程序時,如果外圍程序不能設置按 UTF-8 解碼,就會看到亂碼的問題也依然存在。這些問題,就留待讀者自行解決吧。
最后,特別說明一下以上問題都是 Windows 平台限定的。Linux 下問題沒有這么顯著(現在的Linux發行版本多數都設置了默認代碼頁為 UTF-8),而且就算用戶代碼頁不是 UTF-8,也沒有 Windows 下 WriteConsoleW 這么淫霸的函數,所以洗洗睡吧。