Python2 中文unicode乱码问题

时间:2023-02-10 08:21:04


在 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.encoding 为 None 情况下的 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.encoding 为 None 的话,就使用 sys.getdefaultencoding() 默认编码来对 unicode 进行转换成 str 后输出。

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

以上参考: https://wiki.python.org/moin/PrintFails

解决方法:

解决方法 1:

先说最不正确的解决方法:在文件头部加上

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

这种方法通过 dirty hack 的方式在 Python 刚启动时更改了 Python 的默认编码为 utf-8。此后:

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

但就本文所讨论的问题来说,这个方法并不是真正地直接解决了问题。就如上述所说,Python 只是在sys.stdout.encoding 为 None 时才会使用默认编码来转换需要 print 的 unicode 字符串。那万一在sys.stdout.encoding 存在,但为 ascii 的情况下呢?这样即使更改了 Python 的默认编码,同样还是会出现 UnicodeEncodeError 报错。所以对本问题来说,这个方法治标不治本。

除此之外,很多人都用这个方法来解决 Python 2 下遇到的其它各种各样的编码问题,在 v2ex 的各种 Python 编码问题讨论帖中也常常能见到有人推荐用这个方法来解决问题的。
但实际上很多大牛都不推荐用这个方法来解决 Python 2 的编码问题,这里引用下 * 相关回答里的一句话:

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 问题很好的一个演讲,这里有演讲稿的中文翻译版),对这个方法有一个很形象的比喻:

Python2 中文unicode乱码问题

因为在程序中进进出出的只有存储 bytes(字节序列)的 str。因此最好的策略是将输入的 bytes 马上解码成 unicode,而在程序内部中均使用 unicode,而当在进行输出的时候,尽早将之编码成 bytes。

也就是要形成一个 Unicode 三明治(如图), bytes 在外, Unicode 在内。在边界的地方尽早进行 decode和 encode。不要在内部混用 str 和 unicode,尽可能地让程序处理的字符串都为 Unicode。


解决方法 3:

虽然解决方法 2 是最正确的方式,但是有时候在 Sublime Text 里调试些小脚本,实在是懒得再在每个print 语句后面写一个尾巴 .encode('utf-8')。那么有没有办法能让 Sublime Text 像在终端里一样直接就能 print u'中文' 呢?也就是说能不能解决 sys.stdin/stdout/stderr.encoding 为 None 的情况呢?

答案肯定是有的,一种方法是用类似更改默认编码的方法一样,用 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 就可以在其交互模式里输入中文了。

转自 https://www.v2ex.com/t/163786