python,window控制台下编码问题

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