实用Unicode

翻译: https://nedbatchelder.com/text/unipain.html

这是我在PyCon 2012做的一次分享。你在这个页面看到的是演示稿和描述文字,全屏版的演示稿在这里,也可以直接看这个分享视频(国内 国外)。

点击演示稿会调到全屏模式。 因为里面使用了Symbola字体,所以你需要在那些特殊符号出现之前就先下载这个字体。

大家好,我是Ned Batchelder。我写Python已经10多年了,也就是说,大家犯过的绝大多数Unicode错误我也都犯过。

如果你像绝大多数Python程序员那样:编了一个挺好的应用,一切看起来运行得都很好。然后突然有一天不知哪里冒出来一个奇怪的字符,然后你的代码抛出一个Unicode错误,然后就没有然后了……

你大体知道该怎么改,不外乎在抛异常的地方加个encode或者decode,但是接着别的地方又开始抛UnicodeError了。所以你就继续去报错的地方加encode或者decode。你像打地鼠一样折腾一番之后,问题看起来好像解决了。

过了几天,别的地方出现了另一个特殊字符,你又得像打地鼠那样解决问题。

也就是说,一开始你的程序跑得挺好,后来你被惹恼了而且很不爽,因为Unicode问题耗费了太多时间,关键是你知道并没有彻底改好,最后你开始讨厌自己了。所以你得出的结论是,你讨厌Unicode。

你压根就不想了解稀奇古怪的字符集,你只想写一个不让你烦心的程序。

其实你可以不必像打地鼠那样折腾。Unicode并不简单,但它也并不难。只要你了解一些背景知识和行为准则,就可以轻松和优雅地解决Unicode问题了。

下面我会告诉大家5条常识和3条使用技巧,用以解决大家遇到的Unicode问题。下面的内容会涵盖Unicode基础,如何在Python 2和Python 3里操作。具体的做法可能有差别,但路数基本是一样的。

世界&Unicode

我们先从Unicode基础知识讲起。

常识1:计算机里的一切都是字节,磁盘上的文件就是一堆字节,网络连接里传输的也是字节。大家写的程序输入/输出的一切数据都是字节,无一例外。

程序读写字节,这些字节本身没有任何意义,所以我们需要做一些约定,赋予它们以意义。

为了呈现文本,我们用了ASCII编码将近50年。每个字节都被赋予了上面95个符号中的一个。当我给大家发了一个值为65的字节,大家就知道我想表达是大写的A,前提是大家提前知道每个字节对应的是哪个字符。

ISO Latin 1, 或者8859-1也是ASCII编码,只不过额外扩展了96个符号。

Windows在此基础上又添加了27个符号,搞出来个CP1252。这基本就是大家能用一个字节表示的符号的上限了,因为没有多少空间可以用来添加新的符号了。

使用这样的字符集,我们最多可以表示256种字符。但是常识2是,我们有很多种方式让世界上的文本字符超过256种。单个字节根本无法呈现世界范围内的任何一个文本。在我们暗无天日的打地鼠的日子里,我们多么希望全世界的人都说英语,但是这是不可能的。人们需要很多符号才能进行交流。

常识1和常识2一起,构成了我们的计算机架构和人民群众需求之间的根本性矛盾。

人们曾经做了很多种尝试,试图解决这一个矛盾。(其中一些)像ASCII那样,使用单字节字符编码,在字节和字符之间做了(另外几种)映射。每一种都假装常识2不存在。

那些单字节编码方案,都解决不了这个问题。每个都只适用于一小部分人类语言。他们没能解决全球性文本问题。

人们曾经尝试创建一些双字节字符集,但是它们仍然是有局限性的,仅能在小范围内使用。有很多特定范围内的标准,但是都没有大到足以包含人类所需的所有符号。

这时Unicode被设计出来,成为解决陈旧字符编码问题的大杀器。Unicode为字符赋予整型(integer)值,即编码点(code point)。它有110w个编码点,并且目前为止仅使用了11w个,所以有足够多的空间应对未来的增长。

Unicode的目标是包罗万象。它以ASCII开头,包含了成千上万个符号,其中就有著名的雪人符号,覆盖了世界上所有的写作体系,并且还在源源不断地被扩展。例如,最新的版本包含了”一坨翔”的符号。

这里有6个奇异的Unicode符号。Unicode编码点用带有U+前缀的4/5/6位十六进制形式表示。每一个符号都有一个用大写ASCII表示的独一无二的全名。

上面的字符串看起来像”Python”但是没用一个ASCII字符。

尽管Unicode包含了我们用到的所有字符,但是我们仍然要解决常识1:计算机使用字节。出于存储和传输的目的,我们仍然需要一种以字节的形式呈现Unicode编码点的方法。

Unicode标准定义了一些以字节呈现编码点的方式。这叫做编码。

UTF-8是我们能最熟悉的而且也是使用最广的Unicode存储、传输的编码方式。它为每个编码点使用了不定数量的字节。编码点的值越大,在UTF-8中使用的字节数越多。ASCII里的字符(在UTF-8中)都使用一个字节,对应的值也跟ASCII里的相同,所以ASCII只是UTF-8的一个子集。

上面的奇异字符串就是以UTF-8的形式展示的。ASCII符号H和i是单字节,其他符号根据它们的编码点的值,使用了2个、3个字节。也有些Unicode编码点需要4个字节,但是我们这里没有用到。

Python 2

OK,理论讲完了,现在让我们说说Python 2。在这次的演示稿中,Python 2的例子都有在右上角有个大大的2,Python 3的例子有大大的3。

在Python 2中,有两种不同的字符串数据类型。一个普通又老式的字符串常量返回”str”对象,以byte形式存储。如果大家使用了”u”前缀,大家会可以拿到一个”unicode”对象,以编码点形式存储。在一个Unicode字符串常量中,大家可以用反斜线-u的方式(\u)插入Unicode编码点。

注意,”字符串”这个词是个坑。”str”和”unicode”都是字符串,我们经常图一时方便就把它们都或者一个叫成”字符串”了,但是为了表述得更直观,我们最好用更加准确的名字(“str”和”unicode”)。

字节字符串(str)和Unicode字符串(unicode)都有一个方法可以把自己转换成对立的那种字符串。Unicode字符串有一个.encode()方法可以生成字节字符串,字节字符串有一个.decode()方法可以生成Unicode字符串。它们都接收一个参数,用以指定操作所需的编码名称。

我们定义一个名叫my_unicode的Unicode字符串,可以看到它有9个字符。我们使用UTF-8编码方式创建名叫my_utf8的byte字符串,它有19个字节。如大家所料,使用UTF-8反向解码得到了原始的Unicode字符串。

不幸的是,如果用于编码、解码的数据不符合特定的编码方案,就开始报错了。这里我们尝试把奇异Unicode字符串编码成ASCII。结果失败了,因为ASCII只能呈现0到127范围内的符号,我们的Unicode字符串含有该范围之外的编码点。

抛出的UnicodeEncodeError以”codec”的形式(coder/decoder的缩写)指出了使用的编码名称,同时也指出了导致问题的字符所在的实际位置。

解码同样也会报错。这里我们尝试将UTF-8字符串解码成ASCII,同样报了UnicodeDecodeError,也是因为ASCII只能接收不大于127的值,而我们的UTF-8字符串有些字节超过了这个范围。

即使是UTF-8,也不能解码任意字节序列。接下来我们尝试解码一些随机垃圾数据,同样产生了UnicodeDecodeError错误。实际上,UTF-8的一个优点是,存在无法解码的字节序列,这样可以帮我们构建鲁棒性更好的系统:不接受非法数据,只接受合法数据。

编码或者解码时可以为无法处理的数据指定处理方式。encode和decode方法有一个可选的第二位的参数可以指定这类策略。缺省值是”strict”,表示抛出一个异常,正如我们所见。

“replace”值表示给我们一个标准的替换字符。在编码时,这个替换字符是问号(?),所以任何一个不能被指定编码格式编码的编码点都会产生一个”?”。

其他错误处理方式更有用。”xmlcharrefreplace”生成一个HTML/XML符号实体引用,所以\u01B4 变为 “ƴ” (十六进制 01B4 是十进制的 436.)。如果大家需要为HTML文件输出Unicode的话,这就非常有用了。

注意,不同的错误处理策略用于不同的错误原因。”replace”是一种在无法解析数据时的一种防御机制,它会导致信息丢失。”xmlcharrefreplace”保留了全部的原始信息,它用于向接受XML转义的场景输出数据。

大家同样可以在解码的时候指定错误处理策略。”ignore”会丢弃不能被正确解码的字节。”replace”会在问题字节的位置插入Unicode U+FFFD,”替代字符(REPLACEMENT CHARACTER)”。注意,因为解码器不能解码有问题的数据,所以它也不清楚应该插入多少个Unicode字符。在把(上面)我们的UTF-8字节解码成ASCII的时候,产生16个替代字符,每个不能被解码的字节一个替代字符,尽管这些字节实际上只代表了6个Unicode字符。

Python 2会在大家操作Unicode字符串和字节字符串的时候,为大家提供一些便利。如果当大家试图在一个Unicode字符串和一个字节字符串上执行一项字符串操作的时候,Python 2会自动把字节字符串解码,生成一个新的Unicode字符串,然后在这两个Unicode字符串上完成相应的字符串操作。

例如,当我们尝试为一个Unicode字符串”Hello”拼接上字节”world”的时候,其结果是一个Unicode字符串”Hello world”。Python 2替我们把字节字符串”world”用ASCII编解码方式解码了。这种隐式解码的编解码方式通过调用sys.getdefaultencoding()方法获得。

使用ASCII方式隐式解码,是因为这是唯一安全的猜测:ASCII应用如此广泛并且是众多编码方案的子集,以至于它不太可能误报。

当然,这些隐式解码不能避免所有解码错误。如果大家尝试拼接一个字节字符串和一个Unicode字符串而且恰好这个字节字符串不能被解码为ASCII,这时候就会报UnicodeDecodeError。

这就是各种Unicode错误的痛苦之源。大家的代码无意中混用了Unicode字符串和字节字符串,而且只要这些数据都是ASCII(编码格式)的,隐式转换就会使字符串操作默默成功。但是只要有非ASCII字符以某种方式混入大家的程序,隐式解码就会失败,然后就开始报UnicodeDecodeError。

Python 2的哲学是,Unicode字符串和字节字符串让大家混淆,但是它可以通过在二者之间隐式自动转换来减轻大家的负担,就像在int和float之间的自动转换那样。int到float的转换一定不会失败,但是字节字符串到Unicode字符串的转换却有可能失败。

Python 2默默掩饰了字节字符串到Unicode字符串的转换,这让编写处理ASCII文本的代码非常容易。但大家为此付出的代价是,遇到非ASCII数据时报错。

有很多种方法可以拼接2种字符串,它们都会把字节解码成Unicode,所以大家在使用的时候必须要格外注意。

这里我们用一个ASCII格式化字符串和Unicode数据。格式化字符串会被解码成Unicode,然后格式化才会执行,生成一个Unicode字符串。

下面我们把二者交换一下:一个Unicode格式化字符串和一个字节字符串,也会可以生成一个Unicode字符串,因为字节字符串的数据会被按照ASCII解码。

甚至只是尝试打印一个Unicode字符串都会发生一次隐式编码:输出总是字节,所以Unicode字符串在被打印之前,就必须被编码成字节。

下一个才真正叫人困惑:我们要把一个字节字符串编码成UTF-8,但却得到一个错误,说不能按照解码成ASCII!问题在于字节字符串不能被编码:记住一点,编码是指把Unicode转成字节。所以想要完成我们期望的编码,Python 2需要一个Unicode字符串,这个字符串可以被隐式地解码成ASCII字节。

所以大家想编码成UTF-8,但却得到了一个关于解码ASCII的错。这个错误值得我们好好研究,因为它提供了关于字符串被执行了哪些操作的细节以及操作为何失败的原因。

最后,我们把一个ASCII字符串编码成UTF-8,纯属娱乐,Unicode字符串会被编码。为了让它可行,Python执行了相同的隐式解码,以得到一个用于编码的Unicode字符串,因为这个字符串是ASCII的,所以能成,然后Python把它编码成UTF-8,生成了一个原始的字节字符串,因为ASCII是UTF-8的一个子集。

这是最重要的一条常识:字节和Unicode都很重要,二者大家必须都能处理。大家不能假装一切都是字节,或者一切都是Unicode。大家需要根据场景选择性使用它们,并且在需要的时候显式地转换它们。

Python 3

我们已经见识了Python 2的Unicode痛苦之源,现在我们看一下Python 3。Python 2和Python 3之间最大的不同在于它们对于Unicode的态度。

跟Python 2一样,Python 3也有2种字符串类型,一个用于Unicode,一个用于字节,但是他们的名字却不同。

现在,大家通过一个普通字符串常量获取的”str”类型被存储成Unicode,而”bytes”类型存储成字节。大家可以通过一个b前缀创建字节字符串常量。

所以,在Python 2中的”str”现在被叫做”bytes”,Python 2中的”unicode”现在被叫做”str”。这比Python 2中的名字更有意义,因为Unicode指的是大家期望所有的文本该如何存储,而字节字符串仅用于大家处理字节的情况。

Python 3对Unicode的支持中,最大的改变是,不再自动解码字节字符串。如果大家尝试拼接一个字节字符串和一个Unicode字符串,大家总是会得到一个错误,无论涉及什么数据!

所有我展示的Python 2静默转换字节字符串到Unicode字符串,以完成相关操作的例子,都会在Python 3中报错。

另外,如果一个Unicode字符串和一个字节字符串包含相同的ASCII字节,那么Python 2会认为二者是相同的,这在Python 3中是不行的。这样做的一个后果便是,Unicode字典key不能被字节字符串key检索到,反过来也是,这跟Python 2不一样。

这就根本性地改变了Python 3中Unicode痛点的本质原因。在Python 2中,只要大家只使用ASCII数据,把Unicode和字节混用是没有问题的。但是在Python 3中,这会立即失败,不管是什么数据。

也就是说,Python 2的痛点是延后的:大家认为自己的程序是正确的,直到遇到奇异字符之后它才失败。

在Python 3中,大家的代码会立即失败,即使大家处理的只是ASCII,大家必须显式处理字节和Unicode之间的差异。

Python 3对待字节和Unicode的态度比较严苛。大家被迫在自己的代码中清楚的知道自己处理的是什么。这很有争议,以至于大家时常感到痛苦。

受这个新约束的影响,Python 3改变了大家读文件的方式。Python一直支持2种模式读取文件:二进制和文本。在Python 2中,读取模式仅影响行尾,在Unix平台上甚至没有任何影响。

在Python 3中,这两种模式会产生不同的结果。当大家以文本模式(用”r”或者干脆默认模式)打开一个文件,从文件中读到的数据被隐式解码成Unicode,所以大家得到的是str对象。

如果大家以二进制方式打开一个文件(使用”rb”模式),那么从文件中读到的数据就是字节,它们不会被做任何加工。

从字节到Unicode的隐式转换所使用的编码方式由locale.getpreferredencoding()获取,但有时候它并不能返回大家期望的结果。例如,当我们读取hi_utf8.txt的时候,它被使用地域最优的编码方式解码,因为我是在Windows上创建的示例,所以编码方式被选成了”CP-1252”。与ISO 8859-1类似, CP-1252是一种单字节字符编码,它只接受字节值而且不会抛出UnicodeDecodeError错误。这也就是说,它会快快乐乐地解码数据,即使这些数据不是CP-1252,然后生成一堆乱码。

为了能够正确读取文件,大家需要指定一种编码类型。现在open()函数有一个可选的编码类型参数可以设置。

拨云见日

好了,我们该如何解决这些痛点?好消息是,解决的规则很容易记住,而且它们在Python 2和Python 3中相同。

正如我们在常识1中看到的那样,大家程序的输入/输出必须是字节。但是大家没不要在自己程序内部处理这些字节。最佳策略是尽量早地解码输入的字节,生成Unicode。大家在自己的程序中通篇使用Unicode,然后当输出数据时,尽量晚地编码成字节。

这就创建了一个Unicode三明治:外面是字节,里面是Unicode。

注意一点,有时大家用的某些类库可能会为我们完成这些转换。这种类库可能接受Unicode输入或者可以输出Unicode,然后这些类库会自行处理从/到字节的边界转换。例如,Django接受Unicode,JSON模块也是。

第2条规则是,大家必须知道自己正在处理哪种数据。大家在的程序的任何位置,都需要知道自己拿到的是字节字符串还是Unicode字符串。这个不能猜,而应该是意料之中的事。

另外,如果大家拿到一个字节字符串,打算把它当做一个文本处理的时候,一定要知道它用了何种编码方式。

如果大家在调试代码的时候,不能只是简单地打印一下看看值是什么。大家应该关注它的类型(type),而且应该关注它的repr值,看看到底大家拿到的是什么数据。

我说过,大家必须理解自己的字节字符串用了何种编码格式。这就是常识4:大家不能通过测验的方式确定字节字符串的编码格式。大家需要通过别的方式确定。例如,很多协议都包含了设置编码格式的方法。这里我们从HTTP,HTML,XML和Python代码里找了几个例子。大家也可以通过事先的设计来获取编码格式,例如,数据源的规格说明里可能已经设置了编码格式。

有很多种方法可以去猜测字节的编码格式,但是它们也仅仅是些猜测而已。必须通过其他方式来获取编码格式,这也是唯一的方式。

这个例子里,我们找了一个包含奇异的Unicode符号的字符串,以UTF-8方式编码,然后它错误地被以各种编码格式解码。正如大家看到的那样,用错误的编码格式解码,可能会成功,但是产生的却是错误的字符。大家的程序无法判定它的解码结果出了错,只有当人们尝试阅读这些文本时,大家才会发现出幺蛾子了。

这是常识4的一个很好的例证:同一个字节流,可以被很多不同的编码格式解码。这些字节本身无法预示它们用了何种编码格式。

顺便提一下,专门有一个词来称呼这种乱七八糟的显示,来源于日本人,他们年复一年地解决这些东西:乱码(Mojibake)。

不幸的是,因为字节的编码格式和字节的文本本身是分别交换的,所以有时候编码格式会设置错误。例如,大家可能从Web服务器上拉下来一个HTML页面,在HTTP header里声明页面使用8859-1编码,但实际上它可能使用UTF-8编码的。

某些情况下,编码格式不匹配不会报错,只是产生一堆乱码。其他情况下,编码格式与字节数据不匹配会抛出一个UnicodeError之类的错误。

毋庸置疑的是:大家必须专门测试自己的代码对Unicode的支持情况。为此,大家需要用Unicode数据贯穿测试自己的代码。如果大家是一个英语语言国家的人,这么做可能会有困难,因为很多非ASCII数据不容易阅读。幸运的是,有大量Unicode编码点可以让大家,以英语语言国家的人能够读得懂的方式,构建复杂的Unicode字符串。

这里有一个过度古怪的文本,一段可读的”伪ASCII”文本,和一段颠倒的文本。这类文本的一个好的来源一些网站,它们向中学生提供类似的文本,让他们可以往社交网站上发。(类似非主流的QQ状态)

根据每个应用程序的具体情况,大家可能需要深入挖掘Unicode世界的一些更复杂的主题。有很多细节我都没有覆盖到,因为他们可能过于专业。我管它叫常识5½,因为大家可能不会遇到这类问题。

回顾一下,这里是五个不可回避的常识:

  1. 大家程序的所有输入/输出都是字节。
  2. 我们的世界需要超过256个符号用于文字交流。
  3. 大家的程序必须同时处理字节和Unicode。
  4. 一个字节流不会告诉大家它用了何种编码格式。
  5. 编码格式可能会被错误指定。

这里有三条有用的建议,在大家构建自己的软件的时候要牢记,可以使自己的代码纯用Unicode(而不是Unicode和字节混用):

  1. Unicode三明治:让自己代码里的文本都以Unicode形式使用,然后在尽量靠近边界的地方进行转换。
  2. 明白自己拿到的字符串到底是什么:你必须要明白哪些字符串是Unicode,哪些是字节,对于拿到的字节字符串,它们使用了何种编码格式。
  3. 测试代码对Unicode的支持程度。在测试套件中贯穿使用古怪的文本,以确定你覆盖了全部情况。

如果大家遵循这些建议,大家就可以编写稳定又良好的可以很好处理Unicode的代码,而且不管遇到多变态的Unicode都不会崩溃。

其他可能对大家有用的资源:

Joel Spolsky写的程序员必知必会 之 Unicode和字符集,涵盖了Unicode如何运作以及为何如此。它讲的不是Python知识,但是比我的分享要好!

如果大家要处理任意Unicode字符的语义方面的问题, 那么Python标准库里的unicodedata模块为此提供了很多有用的函数。

为了测试Unicode,有很多”炫酷”文本生成器,用于生成社交网络上用的好玩的符号。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值