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 这么淫霸的函数,所以洗洗睡吧。