Python文本:编码、格式化

Published on 2017 - 01 - 24

对大多数读者来说,文本应该是最熟悉的数据类型了,因此我们从文本入手,首先介绍一些 Python 中有关字符串操作的强大特性。

Unicode

ASCII 诞生于 20 世纪 60 年代,那时的计算机还和冰箱差不多大,运算速度也仅仅比人力稍快一些。众所周知,计算机的基本存储单元是字节(byte),它包含 8 位 / 比特(bit),可以存储 256 种不同的值。出于一些设计目的,ASCII 只使用了 7 位(128 种取值):26 个大写字母、26 个小写字母、10 个阿拉伯数字、一些标点符号、空白符以及一些不可打印的控制符。

不幸的是,世界上现存的字符远远超过了 ASCII 所能支持的 128 个。设想在一个只有 ASCII 字符的世界中,你可以在咖啡厅点个热狗作为晚餐,但永远也点不到美味的 Gewürztraminer 酒。为了支持更多的字母及符号,人们已经做出了许多努力,其中有些成果你可能见到过。例如下面这两个:

  • Latin-1 或 ISO 8859-1
  • Windows code page 1252

上面这些编码规则使用全 8 比特(ASCII 只使用了 7 比特)进行编码,但这明显不够用,尤其是当你需要表示非印欧语系的语言符号时。Unicode 编码是一种正在发展中的国际化规范,它可以包含世界上所有语言以及来自数学领域和其他领域的各种符号。

Unicode Code Charts 页面 包含了通往目前已定义的所有字符集的链接,且包含字符图示。最新的版本定义了超过 110 000 种字符,每一种都有自己独特的名字和标识数。这些字符被分成了若干个 8 比特的集合,我们称之为平面(plane)。前 256 个平面为基本多语言平面(basic multilingual plane)。你可以在维基百科中查看更多关于 Unicode 平面的信息)。

Python 3中的Unicode字符串

Python 3 中的字符串是 Unicode 字符串而不是字节数组。这是与 Python 2 相比最大的差别。在 Python 2 中,我们需要区分普通的以字节为单位的字符串以及 Unicode 字符串。

如果你知道某个字符的 Unicode ID,可以直接在 Python 字符串中引用这个 ID 获得对应字符。下面是几个例子。

  • 用 \u 及 4 个十六进制的数字可以从 Unicode 256 个基本多语言平面中指定某一特定字符。其中,前两个十六进制数字用于指定平面号(00 到 FF),后面两个数字用于指定该字符位于平面中的位置索引。00 号平面即为原始的 ASCII 字符集,字符在该平面的位置索引与它的 ASCII 编码一致。
  • 我们需要使用更多的比特位来存储那些位于更高平面的字符。Python 为此而设计的转义序列以 \U 开头,后面紧跟着 8 个十六进制的数字,其中最左一位需为 0。
  • 你也可以通过 \N{name} 来引用某一字符,其中 name 为该字符的标准名称,这对所有平面的字符均适用。在 Unicode 字符名称索引页可以查到字符对应的标准名称。

Python 中的 unicodedata 模块提供了下面两个方向的转换函数:

  • lookup()——接受不区分大小写的标准名称,返回一个 Unicode 字符;
  • name()——接受一个 Unicode 字符,返回大写形式的名称。

下面的例子中,我们将编写一个测试函数,它接受一个 Python Unicode 字符,查找它对应的名称,再用这个名称查找对应的 Unicode 字符(它应该与原始字符相同):

>>> def unicode_test(value):
...         import unicodedata
...         name = unicodedata.name(value)
...         value2 = unicodedata.lookup(name)
...         print('value="%s", name="%s", value2="%s"' % (value, name, value2))
...

用一些字符来测试一下吧。首先试一下纯 ASCII 字符:

>>> unicode_test('A')
value="A", name="LATIN CAPITAL LETTER A", value2="A"

ASCII 标点符号:

>>> unicode_test('$')
value="$", name="DOLLAR SIGN", value2="$"

Unicode 货币字符:

>>> unicode_test('\u20ac')
value="€", name="EURO SIGN", value2="€"

假设想在 Python 字符串中存储 café 这个词。一种方式是从其他文件或者网站中复制粘贴出来,但这并不一定成功,只能祈祷一切正常:

>>> place = 'café'
>>> place
'café'

示例中之所以成功是因为我是从以 UTF-8 编码的文本源复制粘贴过来的。

有没有什么办法能够直接指定末尾的 é 字符呢?如果你查看了 E 索引 下的字符会发现,我们所需字符 E WITH ACUTE, LATIN SMALL LETTER 对应的 Unicode 值为 00E9。我们用刚刚的 name() 函数和 lookup() 函数来检测一下,首先用编码值查询字符名称:

>>> unicodedata.name('\u00e9')
'LATIN SMALL LETTER E WITH ACUTE'

接着,通过名称查询对应的编码值:

>>> unicodedata.lookup('E WITH ACUTE, LATIN SMALL LETTER')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: "undefined character name 'E WITH ACUTE, LATIN SMALL LETTER'"

为了方便查阅,Unicode 字符名称索引页列出的字符名称是经过修改的,因此与由 name() 函数得到的名称有所不同。如果需要将它们转化为真实的 Unicode 名称(Python 使用的),只需将逗号舍去,并将逗号后面的内容移到最前面即可。据此,我们应将 E WITH ACUTE, LATIN SMALL LETTER 改为 LATIN SMALL LETTER E WITH ACUTE:

>>> unicodedata.lookup('LATIN SMALL LETTER E WITH ACUTE')
'é'

现在,可以通过字符名称或者编码值来指定 café 这个词了:

>>> place = 'caf\u00e9'
>>> place
'café'
>>> place = 'caf\N{LATIN SMALL LETTER E WITH ACUTE}'
>>> place
'café'

上面的代码中,我们将 é 直接插入了字符串中。也可以使用拼接来构造字符串:

>>> u_umlaut = '\N{LATIN SMALL LETTER U WITH DIAERESIS}'
>>> u_umlaut
'ü'
>>> drink = 'Gew' + u_umlaut + 'rztraminer'
>>> print('Now I can finally have my', drink, 'in a', place)
Now I can finally have my Gewürztraminer in a café

字符串函数 len 可以计算字符串中 Unicode 字符的个数,而不是字节数:

>>> len('$')
1
>>> len('\U0001f47b')
1

使用UTF-8编码和解码

对字符串进行处理时,并不需要在意 Python 中 Unicode 字符的存储细节。

但当需要与外界进行数据交互时则需要完成两件事情:

  • 将字符串编码为字节;
  • 将字节解码为字符串。

如果 Unicode 包含的字符种类不超过 64 000 种,我们就可以将字符 ID 统一存储在 2 字节中。遗憾的是,Unicode 所包含的字符种类远不止于此。诚然,我们可以将字符 ID 统一编码在 3 或 4 字节中,但这会使空间开销(内存和硬盘)增加 3 到 4 倍。

两位为 Unix 开发者所熟知的大神 Ken Thompson 和 Rob Pike 在新泽西共用晚餐时解决了这个问题,他们在餐桌垫上设计出了 UTF-8 动态编码方案。这种方案会动态地为每一个 Unicode 字符分配 1 到 4 字节不等:

  • 为 ASCII 字符分配 1 字节;
  • 为拉丁语系(除西里尔语)的语言分配 2 字节;
  • 为其他的位于基本多语言平面的字符分配 3 字节;
  • 为剩下的字符集分配 4 字节,这包括一些亚洲语言及符号。

UTF-8 是 Python、Linux 以及 HTML 的标准文本编码格式。这种编码方式简单快速、字符覆盖面广、出错率低。在代码中全都使用 UTF-8 编码会是一种非常棒的体验,你再也不需要不停地转化各种编码格式。

编码

编码是将字符串转化为一系列字节的过程。字符串的 encode() 函数所接收的第一个参数是编码方式名。可选的编码方式列在了表 1 中。

[表1:编码方式]

编码 说明
'ascii' 经典的 7 比特 ASCII 编码
'utf-8' 最常用的以 8 比特为单位的变长编码
'latin-1' 也被称为 ISO 8859-1 编码
'cp-1252' Windows 常用编码
'unicode-escape' Python 中 Unicode 的转义文本格式,\uxxxx 或者 \Uxxxxxxxx

你可以将任何 Unicode 数据以 UTF-8 的方式进行编码。我们试着将 Unicode 字符串 '\u2603' 赋值给 snowman:

>>> snowman = '\u2603'

snowman 是一个仅包含一个字符的 Unicode 字符串,这与它存储所需的字节数没有任何关系:

>>> len(snowman)
1

下一步将这个 Unicode 字符编码为字节序列:

>>> ds = snowman.encode('utf-8')

就像我之前提到的,UTF-8 是一种变长编码方式。在这个例子中,单个 Unicode 字符 snowman 占用了 3 字节的空间:

>>> len(ds)
3
>>> ds
b'\xe2\x98\x83'

现在,len() 返回了字节数(3),因为 ds 是一个 bytes 类型的变量。

当然,你也可以使用 UTF-8 以外的编码方式,但该 Unicode 字符串有可能无法被指定的编码方式处理,此时 Python 会抛出异常。例如,如果你想要使用 ascii 方式进行编码,必须保证待编码的字符串仅包含 ASCII 字符集里的字符,不含有任何其他的 Unicode 字符,否则会出现错误:

>>> ds = snowman.encode('ascii')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeEncodeError: 'ascii' codec can't encode character '\u2603'
in position 0: ordinal not in range(128)

encode() 函数可以接受额外的第二个参数来帮助你避免编码异常。它的默认值是 'strict',如上例所示,当函数检测到需要处理的字符串包含非 ASCII 字符时,会抛出 UnicodeEncodeError 异常。当然,该参数还有别的可选值,例如 'ignore' 会抛弃任何无法 进行编码的字符:

>>> snowman.encode('ascii', 'ignore')
b''

'replace' 会将所有无法进行编码的字符替换为 ?:

>>> snowman.encode('ascii', 'replace')
b'?'

'backslashreplace' 则会创建一个和 unicode-escape 类似的 Unicode 字符串:

>>> snowman.encode('ascii', 'backslashreplace')
b'\\u2603'

下面的代码可以用于创建网页中使用的字符实体串:

>>> snowman.encode('ascii', 'xmlcharrefreplace')
b'&#9731;'

解码

解码是将字节序列转化为 Unicode 字符串的过程。我们从外界文本源(文件、数据库、网站、网络 API 等)获得的所有文本都是经过编码的字节串。重要的是需要知道它是以何种方式编码的,这样才能逆转编码过程以获得 Unicode 字符串。

问题是字节串本身不带有任何指明编码方式的信息。之前我也提到过从网站随意复制粘贴文本的风险,你也可能遇到过网页乱码的情况,本应是 ASCII 字符的位置却被奇怪的字符占据了,这些都是编码和解码的方式不一致导致的。

创建一个 place 字符串,赋值为 'café':

>>> place = 'caf\u00e9'
>>> place
'café'
>>> type(place)
<class 'str'>

将它以 UTF-8 格式编码为 bytes 型变量,命名为 place_bytes:

>>> place_bytes = place.encode('utf-8')
>>> place_bytes
b'caf\xc3\xa9'
>>> type(place_bytes)
<class 'bytes'>

注意,place_bytes 包含 5 个字节。前 3 个字节的内容与 ASCII 一样(UTF-8 的强大之处),最后两个字节用于编码 'é'。现在,将字节串转换回 Unicode 字符串:

>>> place2 = place_bytes.decode('utf-8')
>>> place2
'café'

一切正常,这是因为编码和解码使用的都是 UTF-8 格式。如果使用其他格式进行解码会发生什么?

>>> place3 = place_bytes.decode('ascii')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'ascii' codec can't decode byte 0xc3 in position 3:
ordinal not in range(128)

ASCII 解码器会抛出异常,因为字节值 0xc3 在 ASCII 编码中是非法值。对于另一些使用 8 比特编码的方式而言,位于 128(十六进制 80)到 255(十六进制 FF)之间的 8 比特的字符集可能是合法的,但解码得到的结果显然与 UTF-8 不同:

>>> place4 = place_bytes.decode('latin-1')
>>> place4
'café'
>>> place5 = place_bytes.decode('windows-1252')
>>> place5
'café'

这个故事告诉我们:尽可能统一使用 UTF-8 编码。况且它出错率低,兼容性好,可以表达所有的 Unicode 字符,编码和解码的速度又快,这么多优点,何乐而不为?

格式化

Python 有两种格式化字符串的方式,我们习惯简单地称之为旧式(old style)和新式(new style)。这两种方式在 Python 2 和 Python 3 中都适用(新式格式化方法适用于 Python 2.6 及以上)。旧式格式化相对简单些,因此我们从它开始。

使用%的旧式格式化

旧式格式化的形式为 string % data。其中 string 包含的是待插值的序列。表 2 展示了最简单的插值序列,它仅由 % 以及一个用于指定数据类型的字母组成。

[表2:转换类型]

%s 字符串
%d 十进制整数
%x 十六进制整数
%o 八进制整数
%f 十进制浮点数
%e 以科学计数法表示的浮点数
%g 十进制或科学计数法表示的浮点数
%% 文本值 % 本身

下面是一些简单的例子。首先格式化一个整数:

>>> '%s' % 42
'42'
>>> '%d' % 42
'42'
>>> '%x' % 42
'2a'
>>> '%o' % 42
'52'

接着是浮点数:

>>> '%s' % 7.03
'7.03'
>>> '%f' % 7.03
'7.030000'
>>> '%e' % 7.03
'7.030000e+00'
>>> '%g' % 7.03
'7.03'

整数和字面值 %:

>>> '%d%%' % 100
'100%'

下面是一些关于字符串和整数的插值操作:

>>> actor = 'Richard Gere'
>>> cat = 'Chester'
>>> weight = 28

>>> "My wife's favorite actor is %s" % actor
"My wife's favorite actor is Richard Gere"

>>> "Our cat %s weighs %s pounds" % (cat, weight)
'Our cat Chester weighs 28 pounds'

字符串内的 %s 意味着需要插入一个字符串。字符串中出现 % 的次数需要与 % 之后所提供的数据项个数相同。如果只需插入一个数据,例如前面的 actor,直接将需要插入的数据置于 % 后即可。如果需要插入多个数据,则需要将它们封装进一个元组(以圆括号为界,逗号分开),例如上例中的 (cat, weight)。

尽管 weight 是一个整数,格式化串中的 %s 也会将它转化为字符串型。

你可以在 % 和指定类型的字母之间设定最大和最小宽度、排版以及填充字符,等等。

我们来定义一个整数 n、一个浮点数 f 以及一个字符串 s:

>>> n = 42
>>> f = 7.03
>>> s = 'string cheese'

使用默认宽度格式化它们:

>>> '%d %f %s' % (n, f, s)
'42 7.030000 string cheese'

为每个变量设定最小域宽为 10 个字符,右对齐,左侧不够用空格填充:

>>> '%10d %10f %10s' % (n, f, s)
'        42   7.030000 string cheese'

和上面的例子使用同样的域宽,但改成左对齐:

>>> '%-10d %-10f %-10s' % (n, f, s)
'42         7.030000   string cheese'

这次仍然使用之前的域宽,但是设定最大字符宽度为 4,右对齐。这样的设置会截断超过长度限制的字符串,并且将浮点数的精度限制在小数点后 4 位:

>>> '%10.4d %10.4f %10.4s' % (n, f, s)
'      0042     7.0300       stri'

去掉最小域宽为 10 的限制:

>>> '%.4d %.4f %.4s' % (n, f, s)
'0042 7.0300 stri'

最后,改变一下上面例子的硬编码方式,将域宽、字符宽度等设定作为参数:

>>> '%*.*d %*.*f %*.*s' % (10, 4, n, 10, 4, f, 10, 4, s)
'      0042     7.0300       stri'

使用{}和format的新式格式化

旧式格式化方式现在仍然兼容。Python 2(将永远停止在 2.7 版本)会永远提供对旧式格式化的支持。然而,如果你在使用 Python 3,新式格式化更值得推荐。

新式格式化最简单的用法如下所示:

>>> '{} {} {}'.format(n, f, s)
'42 7.03 string cheese'

旧式格式化中传入参数的顺序需要与 % 占位符出现的顺序完全一致,但在新式格式化里,可以自己指定插入的顺序:

>>> '{2} {0} {1}'.format(f, s, n)
'42 7.03 string cheese'

0 代表第一个参数 f;1 代表字符串 s;2 代表最后一个参数,整数 n。

参数可以是字典或者命名变量,格式串中的标识符可以引用这些名称:

>>> '{n} {f} {s}'.format(n=42, f=7.03, s='string cheese')
'42 7.03 string cheese'

下面的例子中,我们试着将之前作为参数的 3 个值存到一个字典中,如下所示:

>>> d = {'n': 42, 'f': 7.03, 's': 'string cheese'}

下面的例子中,{0} 代表整个字典,{1} 则代表字典后面的字符串 'other':

>>> '{0[n]} {0[f]} {0[s]} {1}'.format(d, 'other')
'42 7.03 string cheese other'

上面这些例子都是以默认格式打印结果的。旧式格式化允许在 % 后指定参数格式,但在新式格式化里,将这些格式标识符放在 : 后。首先使用位置参数的例子:

>>> '{0:d} {1:f} {2:s}'.format(n, f, s)
'42 7.030000 string cheese'

接着使用相同的值,但这次它们作为命名参数:

>>> '{n:d} {f:f} {s:s}'.format(n=42, f=7.03, s='string cheese')
'42 7.030000 string cheese'

新式格式化也支持其他各类设置(最小域宽、最大字符宽、排版,等等)。

下面是一个最小域宽设为 10、右对齐(默认)的例子:

>>> '{0:10d} {1:10f} {2:10s}'.format(n, f, s)
'        42   7.030000 string cheese'

与上面例子一样,但使用 > 字符设定右对齐显然要更为直观:

>>> '{0:>10d} {1:>10f} {2:>10s}'.format(n, f, s)
'        42   7.030000 string cheese'

最小域宽为 10,左对齐:

>>> '{0:<10d} {1:<10f} {2:<10s}'.format(n, f, s)
'42         7.030000   string cheese'

最小域宽为 10,居中:

>>> '{0:^10d} {1:^10f} {2:^10s}'.format(n, f, s)
'    42      7.030000  string cheese'

新式格式化与旧式格式化相比有一处明显的不同:精度(precision,小数点后面的数字)对于浮点数而言仍然代表着小数点后的数字个数,对于字符串而言则代表着最大字符个数,但在新式格式化中你无法对整数设定精度:

>>> '{0:>10.4d} {1:>10.4f} {2:10.4s}'.format(n, f, s)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: Precision not allowed in integer format specifier
>>> '{0:>10d} {1:>10.4f} {2:>10.4s}'.format(n, f, s)
'        42     7.0300       stri'

最后一个可设定的值是填充字符。如果想要使用空格以外的字符进行填充,只需把它放在 : 之后,其余任何排版符(<、>、^)和宽度标识符之前即可:

>>> '{0:!^20s}'.format('BIG SALE')
'!!!!!!BIG SALE!!!!!!'

参考文档