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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM