Dive Into Python3 中文版(二)

Chapter 4 字符串

" I’m telling you this ’cause you’re one of my friends. My alphabet starts where your alphabet ends! " — Dr. Seuss, On Beyond Zebra!

在开始之前需要掌握的一些知识

你是否知道 Bougainville 人有世界上最小的字母表?他们的 Rotokas 字母表只包含了 12 个字母: A, E, G, I, K, O, P, R, S, T, U, 和 V。另一方面,像汉语,日语和韩语这些语言,它们则有成千上万个字符。当然啦,英语共有 26 个字母 — 如果把大写和小写分别计算的话,52 个 — 外加少量的标点符号,比如*!@#$%&*

当人们说起“文本”,他们通常指显示在屏幕上的字符或者其他的记号;但是计算机不能直接处理这些字符和标记;它们只认识位(bit)和字节(byte)。实际上,从屏幕上的每一块文本都是以某种*字符编码(character encoding)*的方式保存的。粗略地说就是,字符编码提供一种映射,使屏幕上显示的内容和内存、磁盘内存储的内容对应起来。有许多种不同的字符编码,有一些是为特定的语言,比如俄语、中文或者英语,设计、优化的,另外一些则可以用于多种语言的编码。

在实际操作中则会比上边描述的更复杂一些。许多字符在几种编码里是共用的,但是在实际的内存或者磁盘上,不同的编码方式可能会使用不同的字节序列来存储他们。所以,你可以把字符编码当做一种解码密钥。当有人给你一个字节序列 — 文件,网页,或者别的什么 — 并且告诉你它们是“文本”时,就需要知道他们使用了何种编码方式,然后才能将这些字节序列解码成字符。如果他们给的是错误的“密钥”或者根本没有给你“密钥”,那就得自己来破解这段编码,这可是一个艰难的任务。有可能你使用了错误的解码方式,然后出现一些莫名其妙的结果。

你所了解的关于字符串的知识都是错的。

你肯定见过这样的网页,在撇号(')该出现的地方被奇怪的像问号的字符替代了。这种情况通常意味着页面的作者没有正确的声明其使用的编码方式,浏览器只能自己来猜测,结果就是一些正确的和意料之外的字符的混合体。如果原文是英语,那只是不方便阅读而已;在其他的语言环境下,结果可能是完全不可读的。

现有的字符编码各类给世界上每种主要的语言都提供了编码方案。由于每种语言的各不相同,而且在以前内存和硬盘都很昂贵,所以每种字符编码都为特定的语言做了优化。上边这句话的意思是,每种编码都使用数字(0–255)来代表这种语言的字符。比如,你也许熟悉 ASCII 编码,它将英语中的字符都当做从 0–127 的数字来存储。(65 表示大写的“A”,97 表示小写的“a”,_&_c。)英语的字母表很简单,所以它能用不到 128 个数字表达出来。如果你懂得 2 进制计数的话,它只使用了一个字节内的 7 位。

西欧的一些语言,比如法语,西班牙语和德语等,比英语有更多的字母。或者,更准确的说,这些语言含有与变音符号(diacritical marks)组合起来的字母,像西班牙语里的ñ。这些语言最常用的编码方式是 CP-1252,又叫做“windows-1252”,因为它在微软的视窗操作系统上被广泛使用。CP-1252 和 ASCII 在 0–127 这个范围内的字符是一样的,但是 CP-1252 为ñ(n-with-a-tilde-over-it, 241),Ü(u-with-two-dots-over-it, 252)这类字符而扩展到了 128–255 这个范围。然而,它仍然是一种单字节的编码方式;可能的最大数字为 255,这仍然可以用一个字节来表示。

然而,像中文,日语和韩语等语言,他们的字符如此之多而不得不需要多字节编码的字符集。即,使用两个字节的数字(0–255)代表每个“字符”。但是就跟不同的单字节编码方式一样,多字节编码方式之间也有同样的问题,即他们使用的数字是相同的,但是表达的内容却不同。相对于单字节编码方式它们只是使用的数字范围更广一些,因为有更多的字符需要表示。

在没有网络的时代,“文本”由自己输入,偶尔才会打印出来,大多数情况下使用以上的编码方案是可行的。那时没有太多的“纯文本”。源代码使用 ASCII 编码,其他人也都使用字处理器,这些字处理器定义了他们自己的格式(非文本的),这些格式会连同字符编码信息和风格样式一起记录其中,_&_c。人们使用与原作者相同的字处理软件读取这些文档,所以或多或少地能够使用。

现在,我们考虑一下像 email 和 web 这样的全球网络的出现。大量的“纯文本”文件在全球范围内流转,它们在一台电脑上被撰写出来,通过第二台电脑进行传输,最后在另外一台电脑上显示。计算机只能识别数字,但是这些数字可能表达的是其他的东西。Oh no! 怎么办呢。。好吧,那么系统必须被设计成在每一段“纯文本”上都搭载编码信息。记住,编码方式是将计算机可读的数字映射成人类可读的字符的解码密钥。失去解码密钥则意味着混乱不清的,莫名其妙的信息,或者更糟。

现在我们考虑尝试把多段文本存储在同一个地方,比如放置所有收到邮件的数据库。这仍然需要对每段文本存储其相关的字符编码信息,只有这样才能正确地显示它们。这很困难吗?试试搜索你的 email 数据库,这意味着需要在运行时进行编码之间的转换。很有趣是吧…

现在我们来分析另外一种可能性,即多语言文档,同一篇文档里来自几种不同语言的字符混在一起。(提示:处理这样文档的程序通常使用转义符在不同的“模式(modes)”之间切换。噗!现在是俄语 koi8-r 模式,所以 241 代表 Я;噗噗!现在到了 Mac Greek 模式,所以 241 代表 ώ。)当然,你也会想要搜索这些文档。

现在,你就哭吧,因为以前所了解的关于字符串的知识都是错的,根本就没有所谓的“纯文本”。

Unicode

Unicode 入门。

Unicode 编码系统为表达任意语言的任意字符而设计。它使用 4 字节的数字来表达每个字母、符号,或者表意文字(ideograph)。每个数字代表唯一的至少在某种语言中使用的符号。(并不是所有的数字都用上了,但是总数已经超过了 65535,所以 2 个字节的数字是不够用的。)被几种语言共用的字符通常使用相同的数字来编码,除非存在一个在理的语源学(etymological)理由使不这样做。不考虑这种情况的话,每个字符对应一个数字,每个数字对应一个字符。即不存在二义性。不再需要记录“模式”了。U+0041总是代表'A',即使这种语言没有'A'这个字符。

初次面对这个创想,它看起来似乎很伟大。一种编码方式即可解决所有问题。文档可包含多种语言。不再需要在各种编码方式之间进行“模式转换“。但是很快,一个明显的问题跳到我们面前。4 个字节?只为了单独一个字符‽ 这似乎太浪费了,特别是对像英语和西语这样的语言,他们只需要不到 1 个字节即可以表达所需的字符。事实上,对于以象形为基础的语言(比如中文)这种方法也有浪费,因为这些语言的字符也从来不需要超过 2 个字节即可表达。

有一种 Unicode 编码方式每 1 个字符使用 4 个字节。它叫做 UTF-82,因为 32 位 = 4 字节。UTF-32 是一种直观的编码方式;它收录每一个 Unicode 字符(4 字节数字)然后就以那个数字代表该字符。这种方法有其优点,最重要的一点就是可以在常数时间内定位字符串里的第N个字符,因为第N个字符从第4×Nth个字节开始。另外,它也有其缺点,最明显的就是它使用 4 个“诡异”的字节来存储每个“诡异”的字符…

尽管有 Unicode 字符非常多,但是实际上大多数人不会用到超过前 65535 个以外的字符。因此,就有了另外一种 Unicode 编码方式,叫做 UTF-16(因为 16 位 = 2 字节)。UTF-16 将 0–65535 范围内的字符编码成 2 个字节,如果真的需要表达那些很少使用的“星芒层(astral plane)”内超过这 65535 范围的 Unicode 字符,则需要使用一些诡异的技巧来实现。UTF-16 编码最明显的优点是它在空间效率上比 UTF-32 高两倍,因为每个字符只需要 2 个字节来存储(除去 65535 范围以外的),而不是 UTF-32 中的 4 个字节。并且,如果我们假设某个字符串不包含任何星芒层中的字符,那么我们依然可以在常数时间内找到其中的第N个字符,直到它不成立为止这总是一个不错的推断…

但是对于 UTF-32 和 UTF-16 编码方式还有一些其他不明显的缺点。不同的计算机系统会以不同的顺序保存字节。这意味着字符U+4E2D在 UTF-16 编码方式下可能被保存为4E 2D或者2D 4E,这取决于该系统使用的是大尾端(big-endian)还是小尾端(little-endian)。(对于 UTF-32 编码方式,则有更多种可能的字节排列。)只要文档没有离开你的计算机,它还是安全的 — 同一台电脑上的不同程序使用相同的字节顺序(byte order)。但是当我们需要在系统之间传输这个文档的时候,也许在万维网中,我们就需要一种方法来指示当前我们的字节是怎样存储的。不然的话,接收文档的计算机就无法知道这两个字节4E 2D表达的到底是U+4E2D还是U+2D4E

为了解决这个问题,多字节的 Unicode 编码方式定义了一个“字节顺序标记(Byte Order Mark)”,它是一个特殊的非打印字符,你可以把它包含在文档的开头来指示你所使用的字节顺序。对于 UTF-16,字节顺序标记是U+FEFF。如果收到一个以字节FF FE开头的 UTF-16 编码的文档,你就能确定它的字节顺序是单向的(one way)的了;如果它以FE FF开头,则可以确定字节顺序反向了。

不过,UTF-16 还不够完美,特别是要处理许多 ASCII 字符时。如果仔细想想的话,甚至一个中文网页也会包含许多的 ASCII 字符 — 所有包围在可打印中文字符周围的元素(element)和属性(attribute)。能够在常数时间内找到第Nth个字符当然非常好,但是依然存在着纠缠不休的星芒层字符的问题,这意味着你不能保证每个字符都是 2 个字节长,所以,除非你维护着另外一个索引,不然就不能真正意义上的在常数时间内定位第N个字符。另外,朋友,世界上肯定还存在很多的 ASCII 文本…

另外一些人琢磨着这些问题,他们找到了一种解决方法:

UTF-8 The range of integers used to code the abstract characters is called the codespace. A particular integer in this set is called a code point. When an abstract character is mapped or assigned to a particular code point in the codespace, it is then referred to as an encoded character. <–>

UTF-8 是一种为 Unicode 设计的*变长(variable-length)*编码系统。即,不同的字符可使用不同数量的字节编码。对于 ASCII 字符(A-Z, _&_c.)UTF-8 仅使用 1 个字节来编码。事实上,UTF-8 中前 128 个字符(0–127)使用的是跟 ASCII 一样的编码方式。像ñ和ö这样的“扩展拉丁字符(Extended Latin)”则使用 2 个字节来编码。(这里的字节并不是像 UTF-16 中那样简单的 Unicode 编码点(unicode code point);它使用了一些位变换(bit-twiddling)。)中文字符比如“中”则占用了 3 个字节。很少使用的“星芒层字符”则占用 4 个字节。

缺点:因为每个字符使用不同数量的字节编码,所以寻找串中第N个字符是一个 O(N)复杂度的操作 — 即,串越长,则需要更多的时间来定位特定的字符。同时,还需要位变换来把字符编码成字节,把字节解码成字符。

优点:在处理经常会用到的 ASCII 字符方面非常有效。在处理扩展的拉丁字符集方面也不比 UTF-16 差。对于中文字符来说,比 UTF-32 要好。同时,(在这一条上你得相信我,因为我不打算给你展示它的数学原理。)由位操作的天性使然,使用 UTF-8 不再存在字节顺序的问题了。一份以 UTF-8 编码的文档在不同的计算机之间是一样的比特流。

概述

在 Python 3,所有的字符串都是使用 Unicode 编码的字符序列。不再存在以 UTF-8 或者 CP-1252 编码的情况。也就是说,“这个字符串是以 UTF-8 编码的吗?不再是一个有效问题。”UTF-8 是一种将字符编码成字节序列的方式。如果需要将字符串转换成特定编码的字节序列,Python 3 可以为你做到。如果需要将一个字节序列转换成字符串,Python 3 也能为你做到。字节即字节,并非字符。字符在计算机内只是一种抽象。字符串则是一种抽象的序列。

 9

'深'

'深入 Python 3' 
  1. 为了创建一个字符串,将其用引号包围。Python 字符串可以通过单引号(')或者双引号(")来定义。
  2. 内置函数len()可返回字符串的长度,字符的个数。这与获得列表,元组,集合或者字典的长度的函数是同一个。Python 中,字符串可以想像成由字符组成的元组。
  3. Just like getting individual items out of a list, you can get individual characters out of a string using index notation. 与取得列表中的元素一样,也可以通过下标记号取得字符串中的某个字符。
  4. 类似列表,可以使用+操作符来连接(concatenate)字符串。

格式化字符串

字符串可以使用单引号或者双引号来定义。

我们再来看一看humansize.py

 1024: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']}

def approximate_size(size, a_kilobyte_is_1024_bytes=True):

    Keyword arguments:
    size -- file size in bytes
    a_kilobyte_is_1024_bytes -- if True (default), use multiples of 1024
                                if False, use multiples of 1000

    Returns: string

    if size < 0:

    multiple = 1024 if a_kilobyte_is_1024_bytes else 1000
    for suffix in SUFFIXES[multiple]:
        size /= multiple
        if size < multiple:

    raise ValueError('number too large') 
  1. 'KB', 'MB', 'GB'… 这些是字符串。
  2. 函数的文档字符串(docstring)也是字符串。当前的文档字符串占用了多行,所以它使用了相邻的 3 个引号来标记字符串的起始和终止。
  3. 这 3 个引号代表该文档字符串的终止。
  4. 这是另外一个字符串,作为一个可读的提示信息传递给异常。
  5. 瓦哦…那是什么?

Python 3 支持把值格式化(format)成字符串。可以有非常复杂的表达式,最基本的用法是使用单个占位符(placeholder)将一个值插入字符串。

>>> username = 'mark'

"mark's password is PapayaWhip" 
  1. 不,PapayaWhip真的不是我的密码。
  2. 这里包含了很多知识。首先,这里使用了一个字符串字面值的方法调用。字符串也是对象,对象则有其方法。其次,整个表达式返回一个字符串。最后,{0}{1} 叫做替换字段(replacement field),他们会被传递给format()方法的参数替换。

复合字段名

在前一个例子中,替换字段只是简单的整数,这是最简单的用法。整型替换字段被当做传给format()方法的参数列表的位置索引。即,{0}会被第一个参数替换(在此例中即username),{1}被第二个参数替换(password),_&_c。可以有跟参数一样多的替换字段,同时你也可以使用任意多个参数来调用format()。但是替换字段远比这个强大。

>>> import humansize

>>> si_suffixes
['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']

'1000KB = 1MB' 
  1. 不需要调用humansize模块定义的任何函数我们就可以抓取到其所定义的数据结构:国际单位制(SI, 来自法语 Système International)的后缀列表(以 1000 为进制)。
  2. 这一句看上去有些复杂,其实不是这样的。{0}代表传递给format()方法的第一个参数,即si_suffixes。注意si_suffixes是一个列表。所以{0[0]}指代si_suffixes的第一个元素,即'KB'。同时,{0[1]}指代该列表的第二个元素,即:'MB'。大括号以外的内容 — 包括1000,等号,还有空格等 — 则按原样输出。语句最后返回字符串为'1000KB = 1MB'

{0}会被 format()的第 1 个参数替换,{1}则被其第 2 个参数替换。

这个例子说明格式说明符可以通过利用(类似)Python 的语法访问到对象的元素或属性。这就叫做复合字段名(compound field names)。以下复合字段名都是“有效的”。

  • 使用列表作为参数,并且通过下标索引来访问其元素(跟上一例类似)
  • 使用字典作为参数,并且通过键来访问其值
  • 使用模块作为参数,并且通过名字来访问其变量及函数
  • 使用类的实例作为参数,并且通过名字来访问其方法和属性
  • 以上方法的任意组合

为了使你确信的确如此,下面这个样例就组合使用了上面所有方法:

>>> import humansize
>>> import sys
>>> '1MB = 1000{0.modules[humansize].SUFFIXES[1000][0]}'.format(sys)
'1MB = 1000KB' 

下面是描述它如何工作的:

  • sys模块保存了当前正在运行的 Python 实例的信息。由于已经导入了这个模块,因此可以将其作为format()方法的参数。所以替换域{0}指代sys模块。
  • sys.modules is a dictionary of all the modules that have been imported in this Python instance. The keys are the module names as strings; the values are the module objects themselves. So the replacement field {0.modules} refers to the dictionary of imported modules. sys.modules是一个保存当前 Python 实例中所有已经导入模块的字典。模块的名字作为字典的键;模块自身则是键所对应的值。所以{0.modules}指代保存当前己被导入模块的字典。
  • sys.modules['humansize']即刚才导入的humansize模块。所以替换域{0.modules[humansize]}指代humansize模块。请注意以上两句在语法上轻微的不同。在实际的 Python 代码中,字典sys.modules的键是字符串类型的;为了引用它们,我们需要在模块名周围放上引号(比如 'humansize')。但是在使用替换域的时候,我们在省略了字典的键名周围的引号(比如 humansize)。在此,我们引用PEP 3101:字符串格式化高级用法,“解析键名的规则非常简单。如果名字以数字开头,则它被当作数字使用,其他情况则被认为是字符串。”
  • sys.modules['humansize'].SUFFIXES是在humansize模块的开头定义的一个字典对象。 {0.modules[humansize].SUFFIXES}即指向该字典。
  • sys.modules['humansize'].SUFFIXES[1000]是一个 SI(国际单位制)后缀列表:['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']。所以替换域{0.modules[humansize].SUFFIXES[1000]}指向该列表。
  • sys.modules['humansize'].SUFFIXES[1000][0]即 SI 后缀列表的第一个元素:'KB'。因此,整个替换域{0.modules[humansize].SUFFIXES[1000][0]}最后都被两个字符KB替换。

格式说明符

但是,还有一些问题我们没有讲到!再来看一看humansize.py中那一行奇怪的代码:

if size < multiple:
    return '{0:.1f} {1}'.format(size, suffix) 

{1}会被传递给format()方法的第二个参数替换,即suffix。但是{0:.1f}是什么意思呢?它其实包含了两方面的内容:{0}你已经能理解,:.1f则不一定了。第二部分(包括冒号及其后边的部分)即格式说明符(format specifier),它进一步定义了被替换的变量应该如何被格式化。

☞格式说明符的允许你使用各种各种实用的方法来修饰被替换的文本,就像 C 语言中的printf()函数一样。我们可以添加使用零填充(zero-padding),衬距(space-padding),对齐字符串(align strings),控制 10 进制数输出精度,甚至将数字转换成 16 进制数输出。

在替换域中,冒号(:)标示格式说明符的开始。“.1”的意思是四舍五入到保留一们小数点。“f”的意思是定点数(与指数标记法或者其他 10 进制数表示方法相对应)。因此,如果给定size 为698.24suffix’GB’,那么格式化后的字符串将是’698.2 GB’,因为698.24被四舍五入到一位小数表示,然后后缀’GB’再被追加到这个串最后。

>>> '{0:.1f} {1}'.format(698.24, 'GB')
'698.2 GB' 

想了解格式说明符的复杂细节,请参阅 Python 官方文档关于格式化规范的迷你语言

其他常用字符串方法

除了格式化,关于字符串还有许多其他实用的使用技巧。

 ... sult of years of scientif-
... ic study combined with the
... experience of years.'''

['Finished files are the re-',
 'sult of years of scientif-',
 'ic study combined with the',
 'experience of years.']

finished files are the re-
sult of years of scientif-
ic study combined with the
experience of years.

6 
  1. 我们可以在 Python 的交互式 shell 里输入多行(multiline)字符串。一旦我们以三个引号标记多行字符串的开始,按ENTER键,Python shell 会提示你继续这个字符串的输入。连续输入三个结束引号以终止该字符串的输入,再敲ENTER键则会执行该条命令(在当前例子中,把这个字符串赋给变量s)。
  2. splitlines()方法以多行字符串作为输入,返回一个由字符串组成的列表,列表的元素即原来的单行字符串。请注意,每行行末的回车符没有被包括进去。
  3. lower()方法把整个字符串转换成小写的。(类似地,upper()方法执行大写化转换操作。)
  4. count()方法对串中的指定的子串进行计数。是的,在那一句中确实出现了 6 个字母“f”。

还有一种经常会遇到的情况。比如有如下形式的键-值对列表 key1=value1&key2=value2,我们需要将其分离然后产生一个这样形式的字典{key1: value1, key2: value2}

>>> query = 'user=pilgrim&database=master&password=PapayaWhip'

>>> a_list
['user=pilgrim', 'database=master', 'password=PapayaWhip']

>>> a_list_of_lists
[['user', 'pilgrim'], ['database', 'master'], ['password', 'PapayaWhip']]

>>> a_dict
{'password': 'PapayaWhip', 'user': 'pilgrim', 'database': 'master'} 
  1. split()方法使用一个参数,即指定的分隔符,然后根据这个分隔符将串分离成一个字符串列表。此处,分隔符即字符“&”,它还可以是其他的内容。
  2. 现在我们有了一个字符串列表,其中的每个串由三部分组成:键,等号和值。我们可以使用列表解析来遍历整个列表,然后利用第一个等号标记将每个字符串再分离成两个子串。(理论上,值也可以包含等号标记,如果执行'key=value=foo'.split('='),那么我们会得到一个三元素列表['key', 'value', 'foo']。)
  3. 最后,通过调用dict()函数 Python 会把那个包含列表的列表(list-of-lists)转换成字典对象。

☞上一个例子跟解析 URL 的请求参数(query parameters)很相似,但是真实的 URL 解析实际上比这个复杂得多。如果需要处理 URL 请求参数,我们最好使用urllib.parse.parse_qs()函数,它可以处理一些不常见的边缘情况。

字符串的分片

定义一个字符串以后,我们可以截取其中的任意部分形成新串。这种操作被称作字符串的分片(slice)。字符串分片跟列表的分片(slicing lists)原理是一样的,从直观上也说得通,因为字符串本身就是一些字符序列。

>>> a_string = 'My alphabet starts where your alphabet ends.'

'alphabet'

'alphabet starts where your alphabet en'

'My'

'My alphabet starts'

' where your alphabet ends.' 
  1. 我们可以通过指定两个索引值来获得原字符串的一个“slice”。该操作的返回值是一个新串,依次包含了从原串中第一个索引位置开始,直到但是不包含第二个索引位置之间的所有字符。
  2. 就像给列表做分片一样,我们也可以使用负的索引值来分片字符串。
  3. 字符串的下标索引是从 0 开始的,所以a_string[0:2]会返回原字符串的前两个元素,从a_string[0]开始,直到但不包括a_string[2]
  4. 如果省略了第一个索引值,Python 会默认它的值为 0。所以a_string[:18]a_string[0:18]的效果是一样的,因为从 0 开始是被 Python 默认的。
  5. 同样地,如果第 2 个索引值是原字符串的长度,那么我们也可以省略它。所以,在此处a_string[18:]a_string[18:44]的结果是一样的,因为这个串的刚好有 44 个字符。这种规则存在某种有趣的对称性。在这个由 44 个字符组成的串中,a_string[:18]会返回前 18 个字符,而a_string[18:]则会返回除了前 18 个字符以外字符串的剩余部分。事实上a_string[:n]总是会返回串的前n个字符,而a_string[n:]则会返回其余的部分,这与串的长度无关。

String vs. Bytes

字节即字节;字符是一种抽象。一个不可变(immutable)的 Unicode 编码的字符序列叫做string。一串由 0 到 255 之间的数字组成的序列叫做bytes对象。

 >>> by
b'abcde'

<class 'bytes'>

5

>>> by
b'abcde\xff'

6

97

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'bytes' object does not support item assignment 
  1. 使用“byte 字面值”语法b''来定义bytes对象。byte 字面值里的每个字节可以是 ASCII 字符或者是从\x00\xff编码了的 16 进制数。
  2. bytes对象的类型是bytes
  3. 跟列表和字符串一样,我们可以通过内置函数len()来获得bytes对象的长度。
  4. 使用+操作符可以连接bytes对象。操作的结果是一个新的bytes对象。
  5. 连接 5 个字节的和 1 个字节的bytes对象会返回一个 6 字节的bytes对象。
  6. 一如列表和字符串,可以使用下标记号来获取bytes对象中的单个字节。对字符串做这种操作获得的元素仍为字符串,而对bytes对象做这种操作的返回值则为整数。确切地说,是 0–255 之间的整数。
  7. bytes对象是不可变的;我们不可以给单个字节赋上新值。如果需要改变某个字节,可以组合使用字符串的切片和连接操作(效果跟字符串是一样的),或者我们也可以将bytes对象转换为bytearray对象。
>>> by = b'abcd\x65'

>>> barr
bytearray(b'abcde')

5

>>> barr
bytearray(b'fbcde') 
  1. 使用内置函数bytearray()来完成从bytes对象到可变的bytearray对象的转换。
  2. 所有对bytes对象的操作也可以用在bytearray对象上。
  3. 有一点不同的就是,我们可以使用下标标记给bytearray对象的某个字节赋值。并且,这个值必须是 0–255 之间的一个整数。

我们决不应该这样混用 bytes 和 strings。

>>> by = b'd'
>>> s = 'abcde'

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: can't concat bytes to str

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: Can't convert 'bytes' object to str implicitly

1 
  1. 不能连接bytes对象和字符串。他们两种不同的数据类型。
  2. 也不允许针对字符串中bytes对象的出现次数进行计数,因为串里面根本没有bytes。字符串是一系列的字符序列。也许你是想要先把这些字节序列通过某种编码方式进行解码获得字符串,然后对该字符串进行计数?可以,但是需要显式地指明它。Python 3 不会隐含地将 bytes 转换成字符串,或者进行相反的操作。
  3. 好巧啊…这一行代码刚好给我们演示了使用特定编码方式将bytes对象转换成字符串后该串的出现次数。

所以,这就是字符串与字节数组之间的联系了:bytes对象有一个decode()方法,它使用某种字符编码作为参数,然后依照这种编码方式将bytes对象转换为字符串,对应地,字符串有一个encode()方法,它也使用某种字符编码作为参数,然后依照它将串转换为bytes对象。在上一个例子中,解码的过程相对直观一些 — 使用 ASCII 编码将一个字节序列转换为字符串。同样的过程对其他的编码方式依然有效 — 传统的(非 Unicode)编码方式也可以,只要它们能够编码串中的所有字符。

 >>> len(a_string)
9

>>> by
b'\xe6\xb7\xb1\xe5\x85\xa5 Python'
>>> len(by)
13

>>> by
b'\xc9\xee\xc8\xeb Python'
>>> len(by)
11

>>> by
b'\xb2`\xa4J Python'
>>> len(by)
11

>>> roundtrip
'深入 Python'
>>> a_string == roundtrip
True 
  1. a_string是一个字符串。它有 9 个字符。
  2. by是一个bytes对象。它有 13 个字节。它是通过a_string使用 UTF-8 编码而得到的一串字节序列。
  3. by还是一个bytes对象。它有 11 个字节。它是通过a_string使用GB18030编码而得到的一串字节序列。
  4. 此时的by仍旧是一个bytes对象,由 11 个字节组成。它又是一种完全不同的字节序列,我们通过对a_string使用Big5编码得到。
  5. roundtrip是一个字符串,共有 9 个字符。它是通过对by使用 Big5 解码算法得到的一个字符序列。并且,从执行结果可以看出,roundtripa_string是完全一样的。

补充内容:Python 源码的编码方式

Python 3 会假定我们的源码 — .py文件 — 使用的是 UTF-8 编码方式。

☞Python 2 里,.py文件默认的编码方式为 ASCII。Python 3 的源码的默认编码方式为 UTF-8

如果想使用一种不同的编码方式来保存 Python 代码,我们可以在每个文件的第一行放置编码声明(encoding declaration)。以下声明定义.py文件使用 windows-1252 编码方式:

# -*- coding: windows-1252 -*- 

从技术上说,字符编码的重载声明也可以放在第二行,如果第一行被类 UNIX 系统中的hash-bang命令占用了。

#!/usr/bin/python3
# -*- coding: windows-1252 -*- 

了解更多信息,请参阅PEP 263: 指定 Python 源码的编码方式

进一步阅读

关于 Python 中的 Unicode:

关于 Unicode 本身:

关于其他的编码方式:

关于字符串及其格式化:

Updated October 7, 2009 • Difficulty level: ♦♦♦♢♢

Chapter 5 正则表达式

" Some people, when confronted with a problem, think “I know, I’ll use regular expressions.” Now they have two problems. " — Jamie Zawinski

深入

所有的现代编程语言都有内建字符串处理函数。在 python 里查找,替换字符串的方法是:index()、 find()、split()、 count()、 replace()等。但这些方法都只是最简单的字符串处理。比如:用 index()方法查找单个子字符串,而且查找总是区分大小写的。为了使用不区分大小写的查找,可以使用 s.lower()或者 s.upper(),但要确认你查找的字符串的大小写是匹配的。replace() 和 split() 方法有相同的限制。

如果使用 string 的方法就可以达到你的目的,那么你就使用它们。它们速度快又简单,并且很容易阅读。但是如果你发现自己要使用大量的 if 语句,以及很多字符串函数来处理一些特例,或者说你需要组合调用 split() 和 join() 来切片、合并你的字符串,你就应该使用正则表达式。

正则表达式有强大并且标准化的方法来处理字符串查找、替换以及用复杂模式来解析文本。正则表达式的语法比我们的程序代码更紧凑,格式更严格,比用组合调用字符串处理函数的方法更具有可读性。甚至你可以在正则表达式中嵌入注释信息,这样就可以使它有自文档化的功能。

☞如果你在其他语言中使用过正则表达式(比如 perl,javascript 或者 php),python 的正则表达式语法和它们的很像。阅读 re 模块的摘要信息可以了解到一些处理函数以及它们参数的一些概况。

案例研究: 街道地址

下面一系列的示例的灵感来自于现实生活中我几年前每天的工作。我需要把一些街道地址导入一个新的系统,在这之前我要从一个遗留的老系统中清理和标准化这些街道地址。下面这个例子展示我怎么解决这个问题。

>>> s = '100 NORTH MAIN ROAD'

'100 NORTH MAIN RD.'
>>> s = '100 NORTH BROAD ROAD'

'100 NORTH BRD. RD.'

'100 NORTH BROAD RD.'

'100 NORTH BROAD RD.' 
  1. 我的目的是要标准化街道的格式。而‘ROAD’总是在.RD 的前面。刚开始我以为只需要简单的使用 string 的 replace()方法就可以。所有的数据都是大写的,因此不会出现大小写不匹配的问题。而查找的字符串‘ROAD’也是一个常量。在这个简单的例子中 s.replace()可以很好的工作。
  2. 事实上,不幸的是,我很快发现一个问题,在一些地址中‘ROAD’出现了两次,一个是前面的街道名里带了‘ROAD’,一个是‘ROAD’本身。repalce()发现了两个就把他们都给替换掉了。这意味着,我的地址错了。
  3. 为了解决地址中出现超过一个‘ROAD’子字符串的问题,你可能会这么考虑:只在地址的最后四个字符中查找和替换‘‘ROAD’(s[-4:])。然后把剩下的字符串独立开来处理(s[:-4])。这个方法很笨拙。比如,这个方法会依赖于你要替换的字符串长度(如果你用‘.ST’来替换‘STREET’,就需要在 s[-6:]中查找‘STREET’,然后再取 s[:-6]。你难道还想半年后回来继续修改 BUG?反正我是不想。
  4. 是时候转换到正则表达式了。在 python 中,所有的正则表达式相关功能都包含在 re 模块中。
  5. 注意第一个参数‘ROAD ’,这是一个匹配‘ R O A D ’仅仅出现在字符串结尾的正则表达式。 ’,这是一个匹配‘ROAD’仅仅出现在字符串结尾的正则表达式。 ,这是一个匹配ROAD仅仅出现在字符串结尾的正则表达式。 表示“字符串结尾”。(还有一个相应的表示“字符串开头”的字符 ^ )。正则表达式模块的 re.sub()函数可以做字符串替换,它在字符串 s 中用正则表达式‘ROAD$’来搜索并替换成‘RD.’。它只会匹配字符串结尾的‘ROAD’,而不会匹配到‘BROAD’中的‘ROAD’,因为这种情况它在字符串的中间。

^ 匹配字符串开始. $ 匹配字符串结尾

继续我的处理街道地址的故事。我很快发现,在之前的例子中,匹配地址结尾的‘ROAD’不够好。因为并不是所有的地址结尾都有它。一些地址简单的用一个街道名结尾。大部分的情况下不会有问题,但如果街道的名字就叫‘BROAD’,这个时候,正则表达式会匹配到‘BROAD’的最后 4 个字符,这并不是我想要的。

>>> s = '100 BROAD'
>>> re.sub('ROAD$', 'RD.', s)
'100 BRD.'

'100 BROAD'

'100 BROAD'
>>> s = '100 BROAD ROAD APT. 3'

'100 BROAD ROAD APT. 3'

'100 BROAD RD. APT 3' 
  1. 我真正想要的‘ROAD’,必须是匹配到字符串结尾,并且是独立的词(他不能是某个比较长的词的一部分)。为了在正则表达式中表达这个独立的词,你可以使用‘\b’。它的意思是“在右边必须有一个分隔符”。在 python 中,比较复杂的是‘\’字符必须被转义,这有的时候会导致‘\’字符传染(想想可能还要对\字符做转义的情况)。这也是为什么 perl 中的正则表达式比 python 的简单的原因之一。另一方面,perl 会在正则表达式中混合其他非正则表达式的语法,如果出现了 bug,那么很难区分这个 bug 是在正则表达式中,还是在其他的语法部分。
  2. 为了解决‘\’字符传染的问题,可以使用原始字符串。这只需要在字符串的前面添加一个字符‘r’。它告诉 python,字符串中没有任何字符需要转义。‘\t’是一个制表符,但 r‘\t’只是一个字符‘\’紧跟着一个字符 t。我建议在处理正则表达式的时候总是使用原始字符串。否则,会因为理解正则表达式而消耗大量时间(本身正则表达式就已经够让人困惑的了)。
  3. 哎,不幸的是,我发现了更多的地方与我的逻辑背道而驰。街道地址包含了独立的单词‘ROAD’,但并不是在字符串尾,因为街道后面还有个单元号。因为’ROAD’并不是最靠后,就不能匹配,因此 re.sub()最后没有做任何的替换,只是返回了一个原始的字符串,这并不是你想要的。
  4. 为了解决这个问题,我删除了正则表达式尾部的$,然后添加了一个\b。现在这个正则表达式的意思是“在字符串的任意位置匹配独立的‘ROAD’单词”不管是在字符串的结束还是开始,或者中间的任意一个位置。

案例研究: 罗马数字

你肯定见过罗马数字,即使你不认识他们。你可能在版权信息、老电影、电视、大学或者图书馆的题词墙看到(用 Copyright MCMXLVI” 表示版权信息,而不是用 “Copyright 1946”),你也可能在大纲或者目录参考中看到他们。这种系统的数字表达方式可以追溯到罗马帝国(因此而得名)。

在罗马数字中,有七个不同的数字可以以不同的方式结合起来表示其他数字。

  • I = 1
  • V = 5
  • X = 10
  • L = 50
  • C = 100
  • D = 500
  • M = 1000

下面是几个通常的规则来构成罗马数字:

  • 大部分时候用字符相叠加来表示数字。I 是 1, II 是 2, III 是 3。VI 是 6(挨个看来,是“5 和 1”的组合),VII 是 7,VIII 是 8。
  • 含有 10 的字符(I,X,C 和 M)最多可以重复出现三个。为了表示 4,必须用同一位数的下一个更大的数字 5 来减去一。不能用 IIII 来表示 4,而应该是 IV(意思是比 5 小 1)。40 写做 XL(比 50 小 10),41 写做 XLI,42 写做 XLII,43 写做 XLIII,44 写做 XLIV(比 50 小 10 并且比 5 小 1)。
  • 有些时候表示方法恰恰相反。为了表示一个中间的数字,需要从一个最终的值来减。比如:9 需要从 10 来减:8 是 VIII,但 9 确是 IX(比 10 小 1),并不是 VIII(I 字符不能重复 4 次)。90 是 XC,900 是 CM。
  • 表示 5 的字符不能在一个数字中重复出现。10 只能用 X 表示,不能用 VV 表示。100 只能用 C 表示,而不是 LL。
  • 罗马数字是从左到右来计算,因此字符的顺序非常重要。DC 表示 600,而 CD 完全是另一个数字 400(比 500 小 100)。CI 是 101,IC 不是一个罗马数字(因为你不能从 100 减 1,你只能写成 XCIX,表示比 100 小 10,且比 10 小 1)。

检查千位数

怎么验证一个字符串是否是一个合法的罗马数字呢?我们可以每次取一个字符来处理。因为罗马数字总是从高位到低位来书写。我们从最高位的千位开始。表示 1000 或者更高的位数值,方法是用一系列的 M 来重复表示。

>>> import re

<_sre.SRE_Match object at 0106FB58>

<_sre.SRE_Match object at 0106C290>

<_sre.SRE_Match object at 0106AA38>

<_sre.SRE_Match object at 0106F4A8> 
  1. 这个模式有三部分。表示必须从字符串开头匹配。如果没有指定,这个模式将在任意位置匹配 M,这个可能并不是你想要的。你需要确认是否要匹配字符串开始的 M,还是匹配单个 M 字符。因为它重复了三次,你要在一行中的任意位置匹配 0 到 3 次的 M 字符。$匹配字符串结束。当它和匹配字符串开始的^一起使用,表示匹配整个字符串。没有任何一个字符可在 M 的前面或者后面。
  2. re 模块最基本的方法是 search()函数。它使用正则表达式来匹配字符串(M)。如果成功匹配,search()返回一个匹配对象。匹配对象中有很多的方法来描述这个匹配结果信息。如果没有匹配到,search()返回 None。你只需要关注 search()函数的返回值就可以知道是否匹配成功。‘M’被正则表达式匹配到了。原因是正则表达式中的第一个可选的 M 匹配成功,第二个和第三个被忽略掉了。
  3. ‘MM’匹配成功。因为正则表达式中的第一个和第二个可选的 M 匹配到,第三个被忽略。
  4. ‘MMM’匹配成功。因为正则表达式中的所有三个 M 都匹配到。
  5. ‘MMMM’匹配失败。正则表达式中所有三个 M 都匹配到,接着正则表达式试图匹配字符串结束,这个时候失败了。因此 search()函数返回 None。
  6. 有趣的是,空字符串也能匹配成功,因为正则表达式中的所有 M 都是可选的。

检查百位数

? 表示匹配是可选的

百位的匹配比千位复杂。根据值的不同,会有不同的表达方式。

  • 100 = C
  • 200 = CC
  • 300 = CCC
  • 400 = CD
  • 500 = D
  • 600 = DC
  • 700 = DCC
  • 800 = DCCC
  • 900 = CM

因此会有四种可能的匹配模式:

  • CM
  • CD
  • 可能有 0 到 3 个字符 C(0 个表示千位为 0)。
  • D 紧跟在 0 到 3 个字符 C 的后面。

这两个模式还可以组合起来表示:

  • 一个可选的 D,后面跟着 0 到 3 个字符 C。

下面的例子展示了怎样在罗马数字中验证百位。

>>> import re

<_sre.SRE_Match object at 01070390>

<_sre.SRE_Match object at 01073A50>

<_sre.SRE_Match object at 010748A8>

<_sre.SRE_Match object at 01071D98> 
  1. 这个正则表达式的写法从上面千位的匹配方法接着往后写。检查字符串开始(^),然后是千位,后面才是新的部分。这里用圆括号定义了三个不同的匹配模式,他们是用竖线分隔的:CM,CD 和 D?C?C?C?(这表示是一个可选的 D,以及紧跟的 0 到 3 个可选的字符 C)。正则表达式按从左到右的顺序依次匹配,如果第一个 CM 匹配成功,用竖线分隔这几个中的后面其他的都会被忽略。
  2. ‘MCM’匹配成功。因为第一个 M 匹配到,第二个和第三个 M 被忽略。后面的 CM 匹配到(因此后面的 CD 和 D?C?C?C?根本就不被考虑匹配了)。MCM 在罗马数字中表示 1900。
  3. ‘MD’匹配成功。因为第一个 M 匹配到,第二个和第三个 M 被忽略。然后 D?C?C?C?匹配到 D(后面的三个 C 都是可选匹配的,都被忽略掉)。MD 在罗马数字中表示 1500。
  4. ‘MMMCCC’匹配成功。因为前面三个 M 都匹配到。后面的 D?C?C?C?匹配 CCC(D 是可选的,它被忽略了)。MMMCCC 在罗马数字中表示 3300。
  5. ‘MCMC’匹配失败。第一个 M 被匹配,第二个和第三个 M 被忽略,然后 CM 匹配成功。紧接着$试图匹配字符串结束,但后面是 C,匹配失败。C 也不能被 D?C?C?C?匹配到,因为 CM 和它只能匹配其中一个,而 CM 已经匹配过了。
  6. 有趣的是,空字符串仍然可以匹配成功。因为所有的 M 都是可选的,都可以被忽略。并且后面的 D?C?C?C?也是这种情况。

哈哈,看看正则表达式如此快速的处理了这些令人厌恶的东西。你已经可以找到千位数和百位数了!后面的十位和个位的处理和千位、百位的处理是一样的。但我们可以看看怎么用另一种方式来写这个正则表达式。

使用语法{n,m}

{1,4} 匹配 1 到 4 个前面的模式

在上一节中,你处理过同样的字符可以重复 0 到 3 次的情况。实际上,还有另一种正则表达式的书写方式可以表达同样的意思,而且这种表达方式更具有可读性。首先看看我们在前面例子中使用的方法。

>>> import re
>>> pattern = '^M?M?M?$'

<_sre.SRE_Match object at 0x008EE090>
>>> pattern = '^M?M?M?$'

<_sre.SRE_Match object at 0x008EEB48>
>>> pattern = '^M?M?M?$'

<_sre.SRE_Match object at 0x008EE090>

>>> 
  1. 正则表达式匹配字符串开始,然后是第一个可选的字符 M,但没有第二个和第三个 M(没问题!因为他们是可选的),接着是字符串结尾。
  2. 正则表达式匹配字符串开始,然后是第一个和第二个 M,第三个被忽略(因为它是可选的),最后匹配字符串结尾。
  3. 正则表达式匹配字符串开始,然后是三个 M,接着是字符串结尾。
  4. 正则表达式匹配字符串开始,然后是三个 M,但匹配字符串结尾失败(因为后面还有个 M)。因此,这次匹配返回 None。
 <_sre.SRE_Match object at 0x008EEB48>

<_sre.SRE_Match object at 0x008EE090>

<_sre.SRE_Match object at 0x008EEDA8>

>>> 
  1. 这个正则表达式的意思是“匹配字符串开始,然后是任意的 0 到 3 个 M 字符,再是字符串结尾”。0 和 3 的位置可以写任意的数字。如果你想表示可以匹配的最小次数为 1 次,最多为 3 次 M 字符,可以写成 M{1,3}。
  2. 匹配字符串开始,然后匹配了 1 次 M,这在 0 到 3 的范围内,接着是字符串结尾。
  3. 匹配字符串开始,然后匹配了 2 次 M,这在 0 到 3 的范围内,接着是字符串结尾。
  4. 匹配字符串开始,然后匹配了 3 次 M,这在 0 到 3 的范围内,接着是字符串结尾。
  5. 匹配字符串开始,然后匹配了 3 次 M,这在 0 到 3 的范围内,但无法匹配后面的字符串结尾。正则表达式在字符串结尾之前最多允许匹配 3 次 M,但这里有 4 个。因此本次匹配返回 None。

检查十位和个位

现在,我们继续解释正则表达式匹配罗马数字中的十位和个位。下面的例子是检查十位。

>>> pattern = '^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)$'

<_sre.SRE_Match object at 0x008EEB48>

<_sre.SRE_Match object at 0x008EEB48>

<_sre.SRE_Match object at 0x008EEB48>

<_sre.SRE_Match object at 0x008EEB48>

>>> 
  1. 匹配字符串开始,然后是第一个可选的 M,接着是 CM,XL,以及字符串结尾。记住:(A|B|C)的意思是“只匹配 A,B 或者 C 中的一个”。你匹配了 XL,因此 XC 和 L?X?X?X?被忽略,紧接着将检查字符串结尾。MCMXL 在罗马数字中表示 1940。
  2. 匹配字符串开始,然后是第一个可选的 M,接着是 CM。后面的 L 被 L?X?X?X?匹配,这里忽略掉 L 后面所有的 X。然后检查字符串结尾。MCML 在罗马数字中表示 1950。
  3. 匹配字符串开始,然后是第一个可选的 M,接着是 CM,还有可选的 L 以及第一个 X,跳过后面的第二个和第三个 X。然后检查字符串结尾。MCMLX 表示 1960。
  4. 匹配字符串开始,然后是第一个可选的 M,接着是 CM,还有可选的 L 以及所有的三个 X。然后是字符串结尾。MCMLXXX 表示 1980。
  5. 匹配字符串开始,然后是第一个可选的 M,接着是 CM,还有可选的 L 以及所有的三个 X。但匹配字符串结尾失败。因为后面还有一个 X。整个匹配失败,返回 None。MCMLXXXX 不是一个合法的罗马数字。

(A|B) 匹配 A 模式或者 B 模式中的一个

个位数的匹配是同样的模式,我会告诉你细节以及最终结果。

>>> pattern = '^M?M?M?(CM|CD|D?C?C?C?)(XC|XL|L?X?X?X?)(IX|IV|V?I?I?I?)$' 

使用{n,m}的语法来替代上面的写法会是什么样子呢?下面的例子展示了这种新的语法。

>>> pattern = '^M{0,3}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})$'

<_sre.SRE_Match object at 0x008EEB48>

<_sre.SRE_Match object at 0x008EEB48>

<_sre.SRE_Match object at 0x008EEB48>

<_sre.SRE_Match object at 0x008EEB48> 
  1. ^匹配字符串开始,然后表达式 M{0,3}可以匹配 0 到 3 个的 M。这里只能匹配一个 M,也是可以的。接着,D?C{0,3}可以匹配一个可选的 D,以及 0 到 3 个可能的 C。这里我们实际只有一个 D 可以匹配到,正则表达式中的 C 全部忽略。往后,L?X{0,3}只能匹配到一个可选的 L,没有 X。接着 V?I{0,3}匹配到一个可选的 V,没有字符 I。最后$匹配字符串结束。MDLV 表示 1555。
  2. ^匹配字符串开始,然后匹配到 2 个 M,D?C{0,3}匹配到可选的 D,以及 1 个可能的 C。往后,L?X{0,3}匹配到可选的 L 和 1 个 X。接着 V?I{0,3}匹配可选的 V 以及 1 个可选的 I 字符。最后匹配字符串结束。MMDCLXVI 表示 2666。
  3. ^匹配字符串开始,然后是 3 个 M,D?C{0,3}匹配到可选的 D,以及 3 个 C。往后,L?X{0,3}匹配可选的 L 和 3 个 X。接着 V?I{0,3}匹配可选的 V 以及 3 个 I。最后匹配字符串结束。MMMDCCCLXXXVIII 表示 3888。这是你不用扩展语法写出来的最长罗马数字。
  4. 靠近一点,(我就像一个魔术师:“靠近一点,孩子们。我要从帽子里拿出一只兔子。”)^匹配字符串开始,然后 M 可以不被匹配(因为是匹配 0 到 3 次),接着匹配 D?C{0,3},这里跳过了可选的 D,并且也没有匹配到 C,下面 L?X{0,3}也一样,跳过了 L,没有匹配 X。V?I{0,3}也跳过了 V,匹配了 1 个 I。然后匹配字符串结尾。太让人惊奇了!

如果你一次性就理解了上面所有的例子,那你会做的比我还好!现在想象一下以前的做法,在一个大程序用条件判断和函数来处理现在正则表达式处理的内容,或者想象一下前面写的正则表达式。我们发现,那些做法一点也不漂亮。

现在我们来研究一下怎么让你的正则表达式更具有维护性,但表达的意思却是相同的。

松散正则表达式

到目前为止,你只是处理了一些小型的正则表达式。就像你所看到的,他们难以阅读,甚至你不能保证半年后,你还能理解这些东西,并指出他们是干什么的。所以你需要在正则表达式内部添加一些说明信息。

python 允许你使用松散正字表达式来达到目的。松散正字表达式和普通紧凑的正则表达式有两点不同:

  • 空白符被忽略。空格、制表符和回车在正则表达式中并不会匹配空格、制表符、回车。如果你想在正则表达式中匹配他们,可以在前面加一个\来转义。
  • 注释信息被忽略。松散正字表达式中的注释和 python 代码中的一样,都是以#开头直到行尾。它可以在多行正则表达式中增加注释信息,这就避免了在 python 代码中的多行注释。他们的工作方式是一样的。

下面是一个更加清楚的例子。我们再来看看把上面的紧凑正则表达式改写成松散正字表达式后的样子。

>>> pattern = '''
    ^                   # beginning of string
    M{0,3}              # thousands - 0 to 3 Ms
    (CM|CD|D?C{0,3})    # hundreds - 900 (CM), 400 (CD), 0-300 (0 to 3 Cs),
                        #            or 500-800 (D, followed by 0 to 3 Cs)
    (XC|XL|L?X{0,3})    # tens - 90 (XC), 40 (XL), 0-30 (0 to 3 Xs),
                        #        or 50-80 (L, followed by 0 to 3 Xs)
    (IX|IV|V?I{0,3})    # ones - 9 (IX), 4 (IV), 0-3 (0 to 3 Is),
                        #        or 5-8 (V, followed by 0 to 3 Is)
    $                   # end of string
    '''

<_sre.SRE_Match object at 0x008EEB48>

<_sre.SRE_Match object at 0x008EEB48>

<_sre.SRE_Match object at 0x008EEB48> 
  1. 注意,如果要使用松散正则表达式,需要传递一个叫 re.VERBOSE 的参数。就像你看到的那样,正则表达式中有很多空白符,他们都被忽略掉了。还有一些注释信息,当然也被正则表达式忽略掉。当空白符和注释信息被忽略掉后,这个正则表达式和上面的是完全一样的,但是它有更高的可读性。
  2. 匹配字符串开始,然后是 1 个 M,接着是 CM,还有一个 L 和三个 X,后面是 IX,最后匹配字符串结尾。
  3. 匹配字符串开始,然后是 3 个 M,接着是 D 和三个 C,以及三个 X,一个 V,三个 I,最后匹配字符串结尾。
  4. 这个不能匹配成功。为什么呢?因为他没有 re.VERBOSE 标记。因此 search()会把他们整个当成一个紧凑的正则表达式,包括里面的空白符。python 不会自动检测一个正则表达式是否是松散正则表达式,而需要明确的指定。⁂

案例研究: 解析电话号码

\d 匹配所有 0-9 的数字. \D 匹配除了数字外的所有字符.

到目前为止,我们主要关注于整个表达式是否能匹配到,要么整个匹配,要么整个都不匹配。但正则表达式还有更加强大的功能。如果正则表达式成功匹配,你可以找到正则表达式中某一部分匹配到什么。

这个例子来自于我在真实世界中遇到的另一个问题。这个问题是:解析一个美国电话号码。客户想用自由的格式来输入电话号码(在单个输入框),这需要存储区域码,交换码以及后四码(美国的电话分为区域码、交换码和后四码)。我在网上搜索,发现了很多解决这个问题的正则表达式,但是它们都能不完全满足我的要求。

下面是我要接受的电话号码格式:

  • 800-555-1212
  • 800 555 1212
  • 800.555.1212
  • (800) 555-1212
  • 1-800-555-1212
  • 800-555-1212-1234
  • 800-555-1212x1234
  • 800-555-1212 ext. 1234
  • work 1-(800) 555.1212 #1234

样式够多的!在上面的例子中,我知道区域码是 800,交换码是 555,以及最后的后四码是 1212。如果还有分机号,那就是 1234。

我们来解决这个电话号码解析问题。下面的例子是第一步。

 ('800', '555', '1212')

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'NoneType' object has no attribute 'groups' 
  1. 我们通常从左到右的阅读正则表达式。首先是匹配字符串开始位置,然后是(\d{3})。\d{3}表示什么意思?\d 表示任意的数字(0 到 9),{3}表示一定要匹配 3 个数字。这个是你前面看到的{n,m}表示方法。把他们放在圆括号中,表示必须匹配 3 个数字,并且把他们记做一个组。分组的概念我们后面会说到。然后匹配一个连字符,接着匹配另外的 3 个数字,他们也同样作为一个组。然后又是一个连字符,后面还要准确匹配 4 个数字,他们也作为一位分组。最后匹配字符串结尾。
  2. 为了使用正则表达式匹配到的这些分组,需要对 search()函数的返回值调用 groups()方法。它会返回一个这个正则表达式中定义的所有分组结果组成的元组。在这里,我们定义了三个分组,一个三个数字,另一个是三个数字,以及一个四个数字
  3. 这个正则表达式并不是最终答案。因为它还没有处理有分机号的情况。为了处理这种情况,必须要对这个正则表达式进行扩展。
  4. 这是为什么你不能在产品代码中链式调用 search()和 groups()的原因。如果 search()方法匹配不成功,也就是返回 None,这就不是返回的一个正则表达式匹配对象。它没有 groups()方法,所以调用 None.groups()将会抛出一个异常。(当然,在你的代码中,这个异常很明显。在这里我说了我的一些经验。)
 ('800', '555', '1212', '1234')

>>> 

>>> 
  1. 这个正则表达式和前面的一样。匹配了字符串开始位置,然后是一个三个数字的分组,接着一个连字符,又是一个三个数字的分组,又是一个连字符,然后一个四个数字的分组。这三个分组匹配的内容都会被记忆下来。和上面不同的是,这里多匹配了一个连字符以及一个分组,这个分组里的内容是匹配一个或更多个数字。最后是字符串结尾。
  2. 现在 groups()方法返回有四个元素的元组。因为正则表达式现在定义了四个组。
  3. 不幸的是,这个正则表达式仍然不是最终答案。因为它假设这些数字是有连字符分隔的。实际上还有用空格,逗号和点分隔的情况。这就需要用更加通用的解决方案来匹配这些不同的分隔符。
  4. 噢,这个正则表达式不但不能做到你想要的,而且还不如上一个了!因为我们现在不能匹配没有分机号的电话号码。这绝对不是你想要的。如果有分机号,你希望取到,但如果没有,你同样也希望匹配到电话号码其他的部分。

下面的例子展示了正则表达式中怎么处理电话号码中各个部分之间使用了不同分隔符的情况。

 ('800', '555', '1212', '1234')

('800', '555', '1212', '1234')

>>> 

>>> 
  1. 注意了!你匹配了字符串开始,然后是 3 个数字的分组,接着是\D+,这是什么?好吧,\D 匹配除了数字以外的任意字符,+的意思是一个或多个。因此\D+匹配一个或一个以上的非数字字符。这就是你用来替换连字符的东西,它用来匹配不同的分隔符。
  2. 用\D+替换-,意味着你可以匹配分隔符为空格的情况。
  3. 当然,分隔符为连字符一样可以正确工作。
  4. 不幸的是,这仍然不是最终答案。因为这里我们假设有分隔符的存在,如果是根本就没有空格或者是连字符呢?
  5. 天啊,它仍然没有解决分机号的问题。现在你有两个问题没有解决,但是我们可以用相同的技术来解决他们。

下面的例子展示用正则表达式处理电话号码没有分隔符的情况。

 ('800', '555', '1212', '1234')

('800', '555', '1212', '1234')

('800', '555', '1212', '')

>>> 
  1. 这里和上面唯一不同的地方是,把所有的+换成了*。号码之间的分隔符不再用\D+来匹配,而是使用\D*。还记得+表示一个或更多吧?好,现在可以解析号码之间没有分隔符的情况了。
  2. 你看,它真的可以工作。为什么呢?首先匹配字符串开始,然后是 3 个数字的分组(800),分组匹配的内容会被记忆下来。然后是 0 个非数字分隔字符,然后又是 3 个数字的分组(555),同样也会被记忆下来。后面是 0 个非数字字符,接着是 4 个数字的分组(1212),然后又是 0 个非数字字符,还有一个任意个数字的分机号(1234)。最后匹配字符串结尾。
  3. 其他字符作为分隔符一样可以工作。这里点替代了之前的连字符,分机号的前面还可以是空格和 x。
  4. 最后我们解决了这个长久以来的问题:分机号是可选的。如果分机号不存在,groups()仍然可以返回一个 4 元素的元组,只是第四个元素为空字符串。
  5. 我讨厌坏消息。这还没有结束。还有什么问题呢?在区域码前面还可能有其他字符。但正则表达式假设区域码在字符串的开头。没关系,你还可以使用 0 个或更多的非数字字符串来跳过区位码前面的字符。

下面的例子展示怎么处理电话号码前面还有其他字符的情况。

 ('800', '555', '1212', '1234')

('800', '555', '1212', '')

>>> 
  1. 现在除了在第一个分组之前要用\d*匹配 0 个或更多非数字字符外,这和前面的例子是相同的。注意你不会对这些非数字字符分组,因为他们不在圆括号内,也就是说不是一个组。如果发现有这些字符,这里只是跳过他们,然后开始对后面的区域码匹配、分组。
  2. 即使区位码之前有圆括号,你也可以成功的解析电话号码了。(右边的圆括号已经处理,它被\D*匹配成一个非数字字符。)
  3. 这只是一个全面的检查,来确认以前能正确工作的现在仍然可以正确工作。因为首字符是可选的,因此首先匹配字符串开始,0 个非数字字符,然后是三个数字并分组,接着是一个非数字字符,后面是三个数字并且分组,然后又是一个非数字分隔符,又是一个 4 个数字且分组,还有 0 个非数字字符,以及 0 个数字并且分组。最后匹配字符串结尾。
  4. 还有问题。为什么不能匹配这个电话号码?因为在区域码前面还有一个 1,但你假设的是区位码前面的第一个字符是非数字字符(\d*)

我们回过头看看。到目前为止,所有的正则表达式都匹配了字符串开始位置。但现在在字符串的开头可能有一些你想忽略掉的不确定的字符。为了匹配到想要的数据,你需要跳过他们。我们来看看不明确匹配字符串开始的方法。

 ('800', '555', '1212', '1234')

('800', '555', '1212', '')

('800', '555', '1212', '1234') 
  1. 注意正则表达式没有^。不会再匹配字符串开始位置了。正则表达式不会匹配整个字符串,而是试图找到一个字符串开始匹配的位置,然后从这个位置开始匹配。
  2. 现在,你可以正确的解析出字符串开头有不需要的字符、数字或者其他分隔符的情况了。
  3. 全面性检查,同样正常工作了。
  4. 这里也仍然可以工作。

看看正则表达式失控有多快?快速回顾一下之前的例子。你能说出他们的区别吗?

你看到了最终的答案(这就是最终答案!如果你发现还有它不能正确处理的情况,我也不想知道了 )。在你忘掉它之前,我们来把它改写成松散正则表达式吧。

>>> phonePattern = re.compile(r'''
                # don't match beginning of string, number can start anywhere
    (\d{3})     # area code is 3 digits (e.g. '800')
    \D*         # optional separator is any number of non-digits
    (\d{3})     # trunk is 3 digits (e.g. '555')
    \D*         # optional separator
    (\d{4})     # rest of number is 4 digits (e.g. '1212')
    \D*         # optional separator
    (\d*)       # extension is optional and can be any number of digits
    $           # end of string
    ''', re.VERBOSE)

('800', '555', '1212', '1234')

('800', '555', '1212', '') 
  1. 除了这里是用多行表示的以外,它和上面最后的那个是完全一样的。它一样可以处理之前的相同的情况。
  2. 最后我们的全面检查也通过。很好,你终于完成了。

小结

这只是正则表达式能完成的工作中的冰山一角。换句话说,尽管你可能很受打击,相信我,你已经不是什么都不知道了。

现在,你应该已经熟悉了下面的技巧:

  • ^ 匹配字符串开始位置。
  • $ 匹配字符串结束位置。
  • \b 匹配一个单词边界。
  • \d 匹配一个数字。
  • \D 匹配一个任意的非数字字符。
  • x? 匹配可选的 x 字符。换句话说,就是 0 个或者 1 个 x 字符。
  • x* 匹配 0 个或更多的 x。
  • x+ 匹配 1 个或者更多 x。
  • x{n,m} 匹配 n 到 m 个 x,至少 n 个,不能超过 m 个。
  • (a|b|c) 匹配单独的任意一个 a 或者 b 或者 c。
  • (x) 这是一个组,它会记忆它匹配到的字符串。你可以用 re.search 返回的匹配对象的 groups()函数来获取到匹配的值。

正则表达式非常强大,但它也并不是解决每一个问题的正确答案。你需要更多的了解来判断哪些情况适合使用正则表达式。某些时候它可以解决你的问题,某些时候它可能带来更多的问题。

Chapter 6 闭合 与 生成器

" My spelling is Wobbly. It’s good spelling but it Wobbles, and the letters get in the wrong places. " — Winnie-the-Pooh

深入

出于传递所有理解的原因,我一直对语言非常着迷。我指的不是编程语言。好吧,是编程语言,但同时也是自然语言。使用英语。英语是一种七拼八凑的语言,它从德语、法语、西班牙语和拉丁语(等等)语言中借用了大量词汇。事实上,“借用”是不恰当的词汇,“掠夺”更加符合。或者也许叫“同化“——就像博格人(译注:根据维基百科资料,Borg 是《星际旅行》虚构宇宙中的一个种族,该译法未经原作者映证)。是的,我喜欢这样。

我们就是博格人。你们的语言和词源特性将会被添加到我们自己的当中。抵抗是徒劳的。

在本章中,将开始学习复数名词。以及返回其它函数的函数、高级正则表达式和生成器。但首先,让我们聊聊如何生成复数名词。(如果还没有阅读《正则表达式》一章,现在也许是个好时机读一读。本章将假定您理解了正则表达式的基础,并迅速进入更高级的用法。)

如果在讲英语的国家长大,或在正规的学校学习过英语,您可能对下面的基本规则很熟悉 :

  • 如果某个单词以 S 、X 或 Z 结尾,添加 ES 。Bass 变成 bassesfax 变成 faxes,而 waltz 变成 waltzes
  • 如果某个单词以发音的 H 结尾,加 ES;如果以不发音的 H 结尾,只需加上 S 。什么是发音的 H ?指的是它和其它字母组合在一起发出能够听到的声音。因此 coach 变成 coachesrash 变成 rashes,因为在说这两个单词的时候,能够听到 CH 和 SH 的发音。但是 cheetah 变成 cheetahs,因为 H 不发音。
  • 如果某个单词以发 I 音的字母 Y 结尾,将 Y 改成 IES;如果 Y 与某个原因字母组合发其它音的话,只需加上 S 。因此 vacancy 变成 vacancies,但 day 变成 days
  • 如果所有这些规则都不适用,只需加上 S 并作最好的打算。

(我知道,还有许多例外情况。Man 变成 menwoman 变成 women,但是 human 变成 humansMouse 变成 micelouse 变成 lice,但 house 变成 housesKnife 变成 kniveswife 变成 wives,但是 lowlife 变成 lowlifes。而且甚至我还没有开始提到那些原型和复数形式相同的单词,就像 sheepdeerhaiku。)

其它语言,当然是完全不同的。

让我们设计一个 Python 类库用来自动进行英语名词的复数形式转换。我们将以这四条规则为起点,但要记住的不可避免地还要增加更多规则。

我知道,让我们用正则表达式!

因此,您正在看着单词,至少是英语单词,也就是说您正在看着字符的字符串。规则说你必须找到不同的字符组合,然后进行不同的处理。这听起来是正则表达式的工作!

[下载 plural1.py]

import re

def plural(noun):          

    elif re.search('[^aeioudgkprt]h$', noun):
        return re.sub('$', 'es', noun)       
    elif re.search('[^aeiou]y$', noun):      
        return re.sub('y$', 'ies', noun)     
    else:
        return noun + 's' 
  1. 这是一条正则表达式,但它使用了在 《正则表达式》 一章中没有讲过的语法。中括号表示“匹配这些字符的其中之一”。因此 [sxz] 的意思是: “sxz”,但只匹配其中之一。对 $ 应该很熟悉了,它匹配字符串的结尾。经过组合,该正则表达式将测试 noun 是否以 sxz 结尾。
  2. re.sub() 函数执行基于正则表达式的字符串替换。

让我们看看正则表达式替换的细节。

>>> import re

<_sre.SRE_Match object at 0x001C1FA8>

'Mork'

'rook'

'oops' 
  1. 字符串 Mark 包含 abc 吗?是的,它包含 a
  2. 好了,现在查找 abc,并将其替换为 oMark 变成了 Mork
  3. 同一函数将 rock 转换为 rook
  4. 您可能会认为该函数会将 caps 转换为 oaps,但实际上并是这样。re.sub 替换 所有的 匹配项,而不仅仅是第一个匹配项。因此该正则表达式将 caps 转换为 oops,因为无论是 c 还是 a 均被转换为 o

接下来,回到 plural() 函数……

def plural(noun):          
    if re.search('[sxz]$', noun):            

        return re.sub('$', 'es', noun)

        return re.sub('y$', 'ies', noun)     
    else:
        return noun + 's' 
  1. 此处将字符串的结尾(通过 $ 匹配)替换为字符串 es 。换句话来说,向字符串尾部添加一个 es 。可以通过字符串链接来完成同样的变化,例如 noun + 'es',但我对每条规则都选用正则表达式,其原因将在本章稍后更加清晰。
  2. 仔细看看,这里出现了新的变化。作为方括号中的第一个字符, ^ 有特别的含义:非。[^abc] 的意思是:“ 除了 abc 之外的任何字符”。因此 [^aeioudgkprt] 的意思是除了 aeioudgkprt 之外的任何字符。然后该字符必须紧随一个 h,其后是字符串的结尾。所匹配的是以 H 结尾且 H 发音的单词。
  3. 此处有同样的模式:匹配以 Y 结尾的单词,而 Y 之前的字符 不是 aeiou。所匹配的是以 Y 结尾,且 Y 发音听起来像 I 的单词。

让我们看看“否定”正则表达式的更多细节。

>>> import re

<_sre.SRE_Match object at 0x001C1FA8>

>>> 
>>> re.search('[^aeiou]y$', 'day')
>>> 

>>> 
  1. vacancy 匹配该正则表达式,因为它以 cy 结尾,且 c 并非 aeiou
  2. boy 不匹配,因为它以 oy 结尾,可以明确地说 y 之前的字符不能是 oday 不匹配,因为它以 ay 结尾。
  3. pita 不匹配,因为它不以 y 结尾。
 'vacancies'
>>> re.sub('y$', 'ies', 'agency')
'agencies'

'vacancies' 
  1. 该正则表达式将 vacancy 转换为 vacancies ,将 agency 转换为 agencies,这正是想要的结果。注意,它也会将 boy 转换为 boies,但这永远也不会在函数中发生,因为我们首先进行了 re.search 以找出永远不应进行该 re.sub 操作的单词。
  2. 顺便,我还想指出可以将该两条正则表达式合并起来(一条查找是否应用该规则,另一条实际应用规则),使其成为一条正则表达式。它看起来是下面这个样子:其中多数内容看起来应该很熟悉:使用了在 案例研究:分析电话号码 中用到的记忆分组。该分组用于保存字母 y 之前的字符。然后在替换字符串中,用到了新的语法: \1,它表示“嘿,记住的第一个分组呢?把它放到这里。”在此例中, 记住了 y 之前的 c ,在进行替换时,将用 c 替代 c,用 ies 替代 y 。(如果有超过一个的记忆分组,可以使用 \2\3 等等。)

正则表达式替换功能非常强大,而 \1 语法则使之愈加强大。但是,将整个操作组合成一条正则表达式也更难阅读,而且也没有直接映射到刚才所描述的复数规则。刚才所阐述的规则,像 “如果单词以 S 、X 或 Z 结尾,则添加 ES 。”如果查看该函数,有两行代码都在表述“如果以 S 、X 或 Z 结尾,那么添加 ES 。”它没有之前那种模式更直接。

函数列表

现在要增加一些抽象层次的内容。我们开始时定义了一系列规则:如果这样,那样做;否则前往下一条规则。现在让我们对部分程序进行临时的复杂化,以简化另一部分。

import re

def match_sxz(noun):
    return re.search('[sxz]$', noun)

def apply_sxz(noun):
    return re.sub('$', 'es', noun)

def match_h(noun):
    return re.search('[^aeioudgkprt]h$', noun)

def apply_h(noun):
    return re.sub('$', 'es', noun)

    return re.search('[^aeiou]y$', noun)

    return re.sub('y$', 'ies', noun)

def match_default(noun):
    return True

def apply_default(noun):
    return noun + 's'

         (match_h, apply_h),
         (match_y, apply_y),
         (match_default, apply_default)
         )

def plural(noun):           

        if matches_rule(noun):
            return apply_rule(noun) 
  1. 现在,每条匹配规则都有自己的函数,它们返回对 re.search() 函数调用结果。
  2. 每条应用规则也都有自己的函数,它们调用 re.sub() 函数以应用恰当的复数变化规则。
  3. 现在有了一个 rules 数据结构——一个函数对的序列,而不是一个函数(plural())实现多个条规则。
  4. 由于所有的规则被分割成单独的数据结构,新的 plural() 函数可以减少到几行代码。使用 for 循环,可以一次性从 rules 这个数据结构中取出匹配规则和应用规则这两样东西(一条匹配对应一条应用)。在 for 循环的第一次迭代过程中, matches_rule 将获取 match_sxz,而 apply_rule 将获取 apply_sxz。在第二次迭代中(假定可以进行到这一步), matches_rule 将会赋值为 match_h,而 apply_rule 将会赋值为 apply_h 。该函数确保最终能够返回某个值,因为终极匹配规则 (match_default) 只返回 True,意思是对应的应用规则 (apply_default) 将总是被应用。

变量 “rules” 是一系列函数对。

该技术能够成功运作的原因是 Python 中一切都是对象,包括了函数。数据结构 rules 包含了函数——不是函数的名称,而是实际的函数对象。在 for 循环中被赋值后,matches_ruleapply_rule 是可实际调用的函数。在第一次 for 循环的迭代过程中,这相当于调用 matches_sxz(noun),如果返回一个匹配值,将调用 apply_sxz(noun)

如果这种附加抽象层令你迷惑,可以试着展开函数以了解其等价形式。整个 for 循环等价于下列代码:

 def plural(noun):
    if match_sxz(noun):
        return apply_sxz(noun)
    if match_h(noun):
        return apply_h(noun)
    if match_y(noun):
        return apply_y(noun)
    if match_default(noun):
        return apply_default(noun) 

这段代码的好处是 plural() 函数被简化了。它处理一系列其它地方定义的规则,并以通用的方式对它们进行迭代。

  1. 获取某匹配规则
  2. 是否匹配?然后调用应用规则,并返回结果。
  3. 不匹配?返回步骤 1 。

这些规则可在任何地方以任何方式定义。plural() 函数并不关心。

现在,新增的抽象层是否值得呢?嗯,还没有。让我们考虑下要向函数中新增一条规则时该如何操作。在第一例中,将需要新增一条 if 语句到 plural() 函数中。在第二例中,将需要新增两个函数, match_foo()apply_foo(),然后更新 rules 序列以指定新的匹配和应用函数按照其它规则按顺序调用。

但是对于下一节来说,这只是一个跳板而已。让我们继续……

匹配模式列表

其实并不是真的有必要为每个匹配和应用规则定义各自的命名函数。它们从未直接被调用,而只是被添加到 rules 序列并从该处被调用。此外,每个函数遵循两种模式的其中之一。所有的匹配函数调用 re.search(),而所有的应用函数调用 re.sub()。让我们将模式排除在考虑因素之外,使新规则定义更加简单。

import re

def build_match_and_apply_functions(pattern, search, replace):

        return re.search(pattern, word)

        return re.sub(search, replace, word) 
  1. build_match_and_apply_functions() 函数用于动态创建其它函数。它接受 patternsearchreplace 三个参数,并定义了 matches_rule() 函数,该函数通过传给 build_match_and_apply_functions() 函数的 pattern 及传递给所创建的 matchs_rules() 函数的 word 调用 re.search() 函数,哇。
  2. 应用函数的创建工作采用了同样的方式。应用函数只接受一个参数,并使用传递给 build_match_and_apply_functions() 函数的 searchreplace 参数、以及传递给要创建 apply_rule() 函数的 word 调用 re.sub()。在动态函数中使用外部参数值的技术称为 闭合【closures】。基本上,常量的创建工作都在创建应用函数过程中完成:它接受一个参数 (word),但实际操作还加上了另外两个值(searchreplace),该两个值都在定义应用函数时进行设置。
  3. 最后,build_match_and_apply_functions() 函数返回一个包含两个值的元组:即刚才所创建的两个函数。在这些函数中定义的常量( match_rule() 函数中的 pattern 函数,apply_rule() 函数中的 searchreplace )与这些函数呆在一起,即便是在从 build_match_and_apply_functions() 中返回后也一样。这真是非常酷的一件事情。

但如果此方式导致了难以置信的混乱(应该是这样,它确实有点奇怪),在看看如何使用之后可能会清晰一些。

 (
    ('[sxz]$',           '$',  'es'),
    ('[^aeioudgkprt]h$', '$',  'es'),
    ('(qu|[^aeiou])y$',  'y$', 'ies'),

  )

         for (pattern, search, replace) in patterns] 
  1. 我们的复数形式“规则”现在被定义为 字符串 的元组的元组(而不是函数)。每个组的第一个字符串是在 re.search() 中用于判断该规则是否匹配的正则表达式。各组中的第二和第三个字符串是在 re.sub() 中将实际用于使用规则将名词转换为复数形式的搜索和替换表达式。
  2. 此处的后备规则略有变化。在前例中,match_default() 函数仅返回 True,意思是如果更多的指定规则无一匹配,代码将简单地向给定词汇的尾部添加一个 s。本例则进行了一些功能等同的操作。最后的正则表达式询问单词是否有一个结尾($ 匹配字符串的结尾)。当然,每个字符串都有一个结尾,甚至是空字符串也有,因此该规则将始终被匹配。因此,它实现了 match_default() 函数同样的目的,始终返回 True:它确保了如果没有更多的指定规则用于匹配,代码将向给定单词的尾部增加一个 s
  3. 本行代码非常神奇。它以 patterns 中的字符串序列为参数,并将其转换为一个函数序列。怎么做到的?通过将字符串“映射”到 build_match_and_apply_functions() 函数。也就是说,它接受每组三重字符串为参数,并将该三个字符串作为实参调用 build_match_and_apply_functions() 函数。 build_match_and_apply_functions() 函数返回一个包含两个函数的元组。也就是说该 规则 最后的结尾与前例在功能上是等价的:一个元组列表,每个元组都是一对函数。第一个函数是调用 re.search() 的匹配函数;而第二个函数调用 re.sub() 的应用函数。

此版本脚本的最前面是主入口点—— plural() 函数。

def plural(noun):

        if matches_rule(noun):
            return apply_rule(noun) 
  1. 由于 规则 列表与前例中的一样(实际上确实相同),因此毫不奇怪 plural() 函数基本没有发生变化。它是完全通用的,它以规则函数列表为参数,并按照顺序调用它们。它并不关系规则是如何定义的。在前例中,它们被定义为各自命名的函数。现在它们通过将 build_match_and_apply_functions() 函数的输出映射为源字符串的列表来动态创建。这没有任何关系; plural() 函数将以同样方式运作。

匹配模式文件

目前,已经排除了重复代码,增加了足够的抽象性,因此复数形式规则可以字符串列表的形式进行定义。下一个逻辑步骤是将这些字符串放入一个单独的文件中,因此可独立于使用它们的代码来进行维护。

首先,让我们创建一份包含所需规则的文本文件。没有花哨的数据结构,只有空白符分隔的三列字符串。将其命名为 plural4-rules.txt.

[sxz]$               $    es
[^aeioudgkprt]h$     $    es
[^aeiou]y$          y$    ies
$                    $    s 

下面看看如何使用该规则文件。

import re

    def matches_rule(word):
        return re.search(pattern, word)
    def apply_rule(word):
        return re.sub(search, replace, word)
    return (matches_rule, apply_rule)

rules = []

                pattern, search, replace)) 
  1. build_match_and_apply_functions() 函数没有发生变化。仍然使用了闭合技术:通过外部函数中定义的变量来动态创建两个函数。
  2. 全局的 open() 函数打开文件并返回一个文件对象。此例中,将要打开的文件包含了名词复数形式的模式字符串。with 语句创建了叫做 *context【上下文】*的东西:当 with 块结束时,Python 将自动关闭文件,即便是在 with 块中引发了例外也会这样。在 《文件》 一章中将学到关于 with 块和文件对象的更多内容。
  3. for line in &lt;fileobject&gt; 代码从打开的文件中读取数据,并将文本赋值给 line 变量。在 《文件》 一章中将学到更多关于读取文件的内容。
  4. 文件中每行都有三个值,单它们通过空白分隔(制表符或空白,没有区别)。要将它们分开,可使用字符串方法 split()split() 方法的第一个参数是 None,表示“对任何空白字符进行分隔(制表符或空白,没有区别)”。第二个参数是 3,意思是“针对空白分隔三次,丢弃该行剩下的部分。”像 [sxz]$ $ es 这样的行将被分割为列表 ['[sxz]$', '$', 'es'],意思是 pattern 获得值 '[sxz]$'search 获得值 '$',而 replace 获得值 'es'。对于短短的一行代码来说确实威力够大的。
  5. 最后,将 patternsearchreplace 传入 build_match_and_apply_functions() 函数,它将返回一个函数的元组。将该元组添加到 rules 列表,最终 rules 将储存 plural() 函数所预期的匹配和应用函数列表。

此处的改进是将复数形式规则独立地放到了一份外部文件中,因此可独立于使用它的代码单独对规则进行维护。代码是代码,数据是数据,生活更美好。

生成器

如果有个通用 plural() 函数解析规则文件不就更棒了吗?获取规则,检查匹配,应用相应的转换,进入下一条规则。这是 plural() 函数所必须完成的事,也是 plural() 函数必须做的事。

def rules(rules_filename):
    with open(rules_filename, encoding='utf-8') as pattern_file:
        for line in pattern_file:
            pattern, search, replace = line.split(None, 3)
            yield build_match_and_apply_functions(pattern, search, replace)

def plural(noun, rules_filename='plural5-rules.txt'):
    for matches_rule, apply_rule in rules(rules_filename):
        if matches_rule(noun):
            return apply_rule(noun)
    raise ValueError('no matching rule for {0}'.format(noun)) 

这段代码到底是如何运作的?让我们先看一个交互式例子。

>>> def make_counter(x):
...     print('entering make_counter')
...     while True:

...         print('incrementing x')
...         x = x + 1
... 

<generator object at 0x001C9C10>

entering make_counter
2

incrementing x
3

incrementing x
4 
  1. make_counter 中出现的 yield 命令的意思是这不是一个普通的函数。它是一次生成一个值的特殊类型函数。可以将其视为可恢复函数。调用该函数将返回一个可用于生成连续 x 值的 生成器【Generator】
  2. 为创建 make_counter 生成器的实例,仅需像调用其它函数那样对它进行调用。注意该调用并不实际执行函数代码。可以这么说,是因为 make_counter() 函数的第一行调用了 print(),但实际并未打印任何内容。
  3. make_counter() 函数返回了一个生成器对象。
  4. next() 函数以一个生成器对象为参数,并返回其下一个值。对 counter 生成器第一次调用 next() ,它针对第一条 yield 语句执行 make_counter() 中的代码,然后返回所产生的值。在此情况下,该代码输出将为 2,因其仅通过调用 make_counter(2) 对生成器进行初始创建。
  5. 对同一生成器对象反复调用 next() 将确切地从上次调用的位置开始继续,直到下一条 yield 语句。所有的变量、局部数据等内容在 yield 时被保存,在 next() 时被恢复。下一行代码等待被执行以调用 print() 以打印出 incrementing x 。之后,执行语句 x = x + 1。然后它继续通过 while 再次循环,而它再次遇上的第一条语句是 yield x,该语句将保存所有一切状态,并返回当前 x 的值(当前为 3)。
  6. 第二次调用 next(counter) 时,又进行了同样的工作,但这次 x4

由于 make_counter 设置了一个无限循环,理论上可以永远执行该过程,它将不断递增 x 并输出数值。还是让我们看一个更加实用的生成器用法。

斐波那奇生成器

“yield” 暂停一个函数。“next()” 从其暂停处恢复其运行。

def fib(max):

    while a < max: 
  1. 斐波那契序列是一系列的数字,每个数字都是其前两个数字之和。它从 0 和 1 开始,初始时上升缓慢,但越来越快。启动该序列需要两个变量:从 0 开始的 a,和从 1 开始的 b
  2. a 是当前序列中的数字,因此对它进行 yield 操作。
  3. b 是序列中下一个数字,因此将它赋值给 a,但同时计算下一个值 (a + b) 并将其赋值给 b 以供稍后使用。注意该步骤是并行发生的;如果 a3b5,那么 a, b = b, a + b 将会把 a 设置 5b 之前的值),将 b 设置为 8ab 之前值的和)。

因此,现在有了一个连续输出斐波那契数值的函数。当然,还可以使用递归来完成该功能,但这个方式更易于阅读。同样,它也与 for 循环合作良好。

>>> from fibonacci import fib

0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987

[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987] 
  1. 可以在 for 循环中直接使用像 fib() 这样的生成器。for 循环将会自动调用 next() 函数,从 fib() 生成器获取数值并赋值给 for 循环索引变量。(n
  2. 每经过一次 for 循环, nfib()yield 语句获取一个新值,所需做的仅仅是输出它。一旦 fib() 的数字用尽(a 大于 max,即本例中的 1000), for 循环将会自动退出。
  3. 这是一个很有用的用法:将一个生成器传递给 list() 函数,它将遍历整个生成器(就像前例中的 for 循环)并返回所有数值的列表。

复数规则生成器

让我们回到 plural5.py 看看该版本的 plural() 函数是如何运作的。

def rules(rules_filename):
    with open(rules_filename, encoding='utf-8') as pattern_file:
        for line in pattern_file:

def plural(noun, rules_filename='plural5-rules.txt'):

        if matches_rule(noun):
            return apply_rule(noun)
    raise ValueError('no matching rule for {0}'.format(noun)) 
  1. 此处没有太神奇的代码。由于规则文件中每行都靠包括以空白相间的三个值,因此使用 line.split(None, 3) 获取三个“列”的值并将它们赋值给三个局部变量。
  2. 然后使用了 yield。 但生产了什么呢?通过老朋友—— build_match_and_apply_functions() 动态创建的两个函数,这与之前的例子是一样的。换而言之, rules()按照需求连续生成匹配和应用函数的生成器。
  3. 由于 rules() 是生成器,可直接在 for 循环中使用它。对 for 循环的第一次遍历,可以调用 rules() 函数打开模式文件,读取第一行,从该行的模式动态创建一个匹配函数和应用函数,然后生成动态创建的函数。对 for 循环的第二次遍历,将会精确地回到 rules() 中上次离开的位置(在 for line in pattern_file 循环的中间)。要进行的第一项工作是读取文件(仍处于打开状态)的下一行,基于该行的模式动态创建另一匹配和应用函数,然后生成两个函数。

通过第四步获得了什么呢?启动时间。在第四步中引入 plural4 模块时,它读取了整个模式文件,并创建了一份所有可能规则的列表,甚至在考虑调用 plural() 函数之前。有了生成器,可以轻松地处理所有工作:可以读取规则,创建函数并试用它们,如果该规则可用甚至可以不读取文件剩下的部分或创建更多的函数。

失去了什么?性能!每次调用 plural() 函数,rules() 生成器将从头开始——这意味着重新打开模式文件,并从头开始读取,每次一行。

要是能够两全其美多好啊:最低的启动成本(无需对 import 执行任何代码),同时 最佳的性能(无需一次次地创建同一函数)。哦,还需将规则保存在单独的文件中(因为代码和数据要泾渭分明),还有就是永远不必两次读取同一行。

要实现该目标,必须建立自己的生成器。在进行此工作之前,必须对 Python 的类进行学习。

深入阅读

Chapter 7 类 & 迭代器

" 东是东,西是西,东西不相及 " — 拉迪亚德·吉卜林

深入

生成器是一类特殊 迭代器。 一个产生值的函数 yield 是一种产生一个迭代器却不需要构建迭代器的精密小巧的方法。 我会告诉你我是什么意思。

记得 菲波拉稀生成器吗? 这里是一个从无到有的迭代器:

[下载 fibonacci2.py]

class Fib:
    '''生成菲波拉稀数列的迭代器'''

    def __init__(self, max):
        self.max = max

    def __iter__(self):
        self.a = 0
        self.b = 1
        return self

    def __next__(self):
        fib = self.a
        if fib > self.max:
            raise StopIteration
        self.a, self.b = self.b, self.a + self.b
        return fib 

让我们一行一行来分析。

class Fib: 

类(class)?什么是类?

类的定义

Python 是完全面向对象的:你可以定义自己的类,从你自己或系统自带的类继承,并生成实例。

在 Python 里定义一个类非常简单。就像函数一样, 没有分开的接口定义。 只需定义类就开始编码。 Python 类以保留字 class 开始, 后面跟类名。 技术上来说,只需要这么多就够了,因为一个类不是必须继承其他类。

  1. 类名是 PapayaWhip, 没有从其他类继承。 类名通常是大写字母分隔, 如EachWordLikeThis, 但这只是个习惯,并非必须。
  2. 你可能猜到,类内部的内容都需缩进,就像函数中的代码一样, if 语句, for 循环, 或其他代码块。第一行非缩进代码表示到了类外。

PapayaWhip 类没有定义任何方法和属性, 但依据句法,应该在定义中有东西,这就是 pass 语句。 这是 Python 保留字,意思是“继续,这里看不到任何东西”。 这是一个什么都不做的语句,是一个很好的占位符,如果你的函数和类什么都不想做(删空函数或类)。

☞Python 中的pass 就像 Java 或 C 中的空大括号对 ({}) 。

很多类继承自其他类, 但这个类没有。 很多类有方法,这个类也没有。 Python 类不是必须有东西,除了一个名字。 特别是 C++ 程序员发现 Python 类没有显式的构造和析构函数会觉得很古怪。 尽管不是必须, Python 类 可以 具有类似构造函数的东西: __init__() 方法。

__init__() 方法

本示例展示 Fib 类使用 __init__ 方法。

class Fib: 
  1. 类同样可以 (而且应该) 具有docstring, 与模块和方法一样。
  2. 类实例创建后,__init__() 方法被立即调用。很容易将其——但技术上来说不正确——称为该类的“构造函数” 。 很容易,因为它看起来很像 C++ 的构造函数(按约定,__init__() 是类中第一个被定义的方法),行为一致(是类的新实例中第一片被执行的代码), 看起来完全一样。 错了, 因为__init__() 方法调用时,对象已经创建了,你已经有了一个合法类对象的引用。

每个方法的第一个参数,包括 __init__() 方法,永远指向当前的类对象。 习惯上,该参数叫 self。 该参数和 C++或 Java 中 this 角色一样, 但 self 不是 Python 的保留字, 仅仅是个命名习惯。 虽然如此,请不要取别的名字,只用 self; 这是一个很强的命名习惯。

__init__() 方法中, self 指向新创建的对象; 在其他类对象中, 它指向方法所属的实例。尽管需在定义方法时显式指定self ,调用方法时并 必须明确指定。 Python 会自动添加。

实例化类

Python 中实例化类很直接。 实例化类时就像调用函数一样简单,将 __init__() 方法需要的参数传入。 返回值就是新创建的对象。

>>> import fibonacci2

<fibonacci2.Fib object at 0x00DB8810>

<class 'fibonacci2.Fib'>

'' 
  1. 你正创建一个 Fib 类的实例(在fibonacci2 模块中定义) 将新创建的实例赋给变量fib。 你传入一个参数 100, 这是Fib__init__()方法作为max参数传入的结束值。
  2. fibFib 的实例。
  3. 每个类实例具有一个内建属性, __class__, 它是该对象的类。 Java 程序员可能熟悉 Class 类, 包含方法如 getName()getSuperclass() 获取对象相关元数据。 Python 里面, 这类元数据由属性提供,但思想一致。
  4. 你可访问对象的 docstring ,就像函数或模块中的一样。 类的所有实例共享一份 docstring

☞Python 里面, 和调用函数一样简单的调用一个类来创建该类的新实例。 与 C++ 或 Java 不一样,没有显式的 new 操作符。

实例变量

继续下一行:

class Fib:
    def __init__(self, max): 
  1. self.max是什么? 它就是实例变量。 与作为参数传入 __init__() 方法的 max完全是两回事。 self.max 是实例内 “全局” 的。 这意味着可以在其他方法中访问它。
class Fib:
    def __init__(self, max):

    .
    .
    .
    def __next__(self):
        fib = self.a 
  1. self.max__init__() 方法中定义……
  2. ……在 __next__() 方法中引用。

实例变量特定于某个类的实例。 例如, 如果你创建 Fib 的两个具有不同最大值的实例, 每个实例会记住自己的值。

>>> import fibonacci2
>>> fib1 = fibonacci2.Fib(100)
>>> fib2 = fibonacci2.Fib(200)
>>> fib1.max
100
>>> fib2.max
200 

菲波拉稀迭代器

现在 你已经准备学习如何创建一个迭代器了。 迭代器就是一个定义了 __iter__() 方法的类。

[下载 fibonacci2.py]

 self.max = max

        self.a = 0
        self.b = 1
        return self

        fib = self.a
        if fib > self.max:

        self.a, self.b = self.b, self.a + self.b 
  1. 从无到有创建一个迭代器, fib 应是一个类,而不是一个函数。
  2. “调用” Fib(max) 会创建该类一个真实的实例,并以max做为参数调用__init__() 方法。 __init__() 方法以实例变量保存最大值,以便随后的其他方法可以引用。
  3. 当有人调用iter(fib)的时候,__iter__()就会被调用。(正如你等下会看到的, for 循环会自动调用它, 你也可以自己手动调用。) 在完成迭代器初始化后,(在本例中, 重置我们两个计数器 self.aself.b), __iter__() 方法能返回任何实现了 __next__() 方法的对象。 在本例(甚至大多数例子)中, __iter__() 仅简单返回 self, 因为该类实现了自己的 __next__() 方法。
  4. 当有人在迭代器的实例中调用next()方法时,__next__() 会自动调用。 随后会有更多理解。
  5. __next__() 方法抛出 StopIteration 异常, 这是给调用者表示迭代用完了的信号。 和大多数异常不同, 这不是错误;它是正常情况,仅表示迭代器没有值可产生了。 如果调用者是 for 循环, 它会注意到该 StopIteration 异常并优雅的退出。 (换句话说,它会吞掉该异常。) 这点神奇之处就是使用 for 的关键。
  6. 为了分离出下一个值, 迭代器的 __next__() 方法简单 return该值。 不要使用 yield ; 该语法上的小甜头仅用于你使用生成器的时候。 这里你从无到有创建迭代器,使用 return 代替。

完全晕了? 太好了。 让我们看如何调用该迭代器:

>>> from fibonacci2 import Fib
>>> for n in Fib(1000):
...     print(n, end=' ')
0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 

为什么?完全一模一样! 一字节一字节的与你调用 Fibonacci-as-a-generator (模块第一个字母大写)相同。但怎么做到的?

for 循环内有魔力。下面是究竟发生了什么:

  • 如你所见,for 循环调用 Fib(1000)。 这返回Fib 类的实例。 叫它 fib_inst
  • 背地里,且十分聪明的, for 循环调用 iter(fib_inst), 它返回迭代器。 叫它 fib_iter。 本例中, fib_iter == fib_inst, 因为 __iter__() 方法返回 self,但for 循环不知道(也不关心)那些。
  • 为“循环通过”迭代器, for 循环调用 next(fib_iter), 它又调用 fib_iter对象的 __next__() 方法,产生下一个菲波拉稀计算并返回值。 for 拿到该值并赋给 n, 然后执行n值的 for 循环体。
  • for循环如何知道什么时候结束?很高兴你问到。 当next(fib_iter) 抛出 StopIteration 异常时, for循环将吞下该异常并优雅退出。 (其他异常将传过并如常抛出。) 在哪里你见过 StopIteration 异常? 当然在 __next__() 方法。

复数规则迭代器

iter(f) 调用 f.iter next(f) 调用 f.next

现在到曲终的时候了。我们重写 复数规则生成器 为迭代器。

[下载plural6.py]

class LazyRules:
    rules_filename = 'plural6-rules.txt'

    def __init__(self):
        self.pattern_file = open(self.rules_filename, encoding='utf-8')
        self.cache = []

    def __iter__(self):
        self.cache_index = 0
        return self

    def __next__(self):
        self.cache_index += 1
        if len(self.cache) >= self.cache_index:
            return self.cache[self.cache_index - 1]

        if self.pattern_file.closed:
            raise StopIteration

        line = self.pattern_file.readline()
        if not line:
            self.pattern_file.close()
            raise StopIteration

        pattern, search, replace = line.split(None, 3)
        funcs = build_match_and_apply_functions(
            pattern, search, replace)
        self.cache.append(funcs)
        return funcs

rules = LazyRules() 

因此这是一个实现了 __iter__()__next__()的类。所以它可以 被用作迭代器。然后,你实例化它并将其赋给 rules 。这只发生一次,在 import 的时候。

让我们一口一口来吃:

class LazyRules:
    rules_filename = 'plural6-rules.txt'

    def __init__(self): 
  1. 当我们实例化 LazyRules 类时, 打开模式文件,但不读取任何东西。 (随后再进行)
  2. 打开模式文件之后,初始化缓存。 随后读取模式文件行的时候会用到它(在 __next__() 方法中) 。

我们继续之前,让我们近观 rules_filename。它没在 __iter__() 方法中定义。事实上,它没在任何方法中定义。它定义于类级别。它是 类变量, 尽管访问时和实例变量一样 (self.rules_filename), LazyRules 类的所有实例共享该变量。

>>> import plural6
>>> r1 = plural6.LazyRules()
>>> r2 = plural6.LazyRules()

'plural6-rules.txt'
>>> r2.rules_filename
'plural6-rules.txt'

>>> r2.rules_filename
'r2-override.txt'
>>> r1.rules_filename
'plural6-rules.txt'

'plural6-rules.txt'

>>> r1.rules_filename
'papayawhip.txt'

'r2-overridetxt' 
  1. 类的每个实例继承了 rules_filename 属性及它在类中定义的值。
  2. 修改一个实例属性的值不影响其他实例……
  3. ……也不会修改类的属性。可以使用特殊的 __class__ 属性来访问类属性(于此相对的是单独实例的属性)。
  4. 如果修改类属性, 所有仍然继承该实例的值的实例 (如这里的r1 ) 会受影响。
  5. 已经覆盖(overridden)了该属性(如这里的 r2 )的所有实例 将不受影响。

现在回到我们的演示:

self.cache_index = 0 
  1. 无论何时有人——如 for 循环——调用 iter(rules)的时候,__iter__() 方法都会被调用。
  2. 每个__iter__() 方法都需要做的就是必须返回一个迭代器。 在本例中,返回 self,意味着该类定义了__next__() 方法,由它来关注整个迭代过程中的返回值。
 .
        .
        .
        pattern, search, replace = line.split(None, 3)

            pattern, search, replace)

        return funcs 
  1. 无论何时有人——如 for 循环——调用 __next__() 方法, next(rules)都跟着被调用。 该方法仅在我们从后往前移动时比较好体会。所以我们就这么做。
  2. 函数的最后一部分至少应该眼熟。 build_match_and_apply_functions() 函数还没修改;与它从前一样。
  3. 唯一的不同是,在返回匹配和应用功能之前(保存在元组 funcs中),我们将其保存到 self.cache

从后往前移动……

 def __next__(self):
        .
        .
        .

            self.pattern_file.close()

        .
        .
        . 
  1. 这里有点高级文件操作的技巧。 readline() 方法 (注意:是单数,不是复数 readlines()) 从一个打开的文件中精确读取一行,即下一行。(文件对象同样也是迭代器! 它自始至终是迭代器……
  2. 如果有一行 readline() 可以读, line 就不会是空字符串。 甚至文件包含一个空行, line 将会是一个字符的字符串 '\n' (回车换行符)。 如果 line 是真的空字符串, 就意味着文件已经没有行可读了。
  3. 当我们到达文件尾时, 我们应关闭文件并抛出神奇的 StopIteration 异常。 记住,开门见山的说是因为我们需要为下一条规则找到一个匹配和应用功能。下一条规则从文件的下一行获取…… 但已经没有下一行了! 所以,我们没有规则返回。 迭代器结束。 (♫ 派对结束 ♫)

由后往前直到 __next__()方法的开始……

 def __next__(self):
        self.cache_index += 1
        if len(self.cache) >= self.cache_index:

        if self.pattern_file.closed:

        .
        .
        . 
  1. self.cache 将是一个我们匹配并应用单独规则的功能列表。 (至少那个应该看起来熟悉!) self.cache_index 记录我们下一步返回的缓存条目。 如果我们还没有耗尽缓存 (举例 如果 self.cache 的长度大于 self.cache_index),那么我们就会命中一条缓存! 哇! 我们可以从缓存中返回匹配和应用功能而不是从无到有创建。
  2. 另一方面,如果我们没有从缓存中命中条目, 并且 文件对象也已关闭(这会发生, 在本方法下面一点, 正如你从预览的代码片段中所看到的),那么我们什么都不能做。 如果文件被关闭,意味着我们已经用完了它——我们已经从头至尾读取了模式文件的每一行,而且已经对每个模式创建并缓存了匹配和应用功能。文件已经读完;缓存已经用完;我也快完了。等等,什么?坚持一下,我们几乎完成了。

放到一起,发生了什么事? 当:

  • 当模块引入时,创建了LazyRules 类的一个单一实例, 叫 rules, 它打开模式文件但并没有读取。
  • 当要求第一个匹配和应用功能时,检查缓存并发现缓存为空。 于是,从模式文件读取一行, 从模式中创建匹配和应用功能,并缓存之。
  • 假如,因为参数的缘故,正好是第一行匹配了。如果那样,不会有更多的匹配和应用会创建,也不会有更多的行会从模式文件中读取。
  • 更进一步, 因为参数的缘故,假设调用者再次调用 plural() 函数来让一个不同的单词变复数。 plural() 函数中的for 循环会调用iter(rules),这会重置缓存索引但不会重置打开的文件对象。
  • 第一次遍历, for循环会从rules中索要一个值,该值会调用其__next__()方法。然而这一次, 缓存已经被装入了一个匹配和应用功能对, 与模式文件中第一行模式一致。 由于对前一个单词做复数变换时已经被创建和缓存,它们被从缓存中返回。 缓存索引递增,打开的文件无需访问。
  • 假如,因为参数的缘故,这一轮第一个规则 匹配。 所以 for 循环再次运转并从 rules请求一个值。 这会再次调用 __next__() 方法。 这一次, 缓存被用完了——它仅有一个条目,而我们被请求第二个——于是 __next__() 方法继续。 从打开的文件中读取下一行,从模式中创建匹配和应用功能,并缓存之。
  • 该“读取创建并缓存”过程一直持续直到我们从模式文件中读取的规则与我们想变复数的单词不匹配。 如果我们确实在文件结束前找到了一个匹配规则,我们仅需使用它并停止,文件还一直打开。文件指针会留在我们停止读取,等待下一个 readline() 命令的地方。现在,缓存已经有更多条目了,并且再次从头开始来将一个新单词变复数,在读取模式文件下一行之前,缓存中的每一个条目都将被尝试。

我们已经到达复数变换的极乐世界。

  1. 最小化初始代价。import 时发生的唯一的事就是实例化一个单一的类并打开一个文件(但并不读取)。
  2. 最大化性能 前述示例会在每次你想让一个单词变复数时,读遍文件并动态创建功能。本版本将在创建的同时缓存功能,在最坏情况下,仅需要读完一遍文件,无论你要让多少单词变复数。
  3. 将代码和数据分离。 所有模式被存在一个分开的文件。代码是代码,数据是数据,二者永远不会交织。

☞这真的是极乐世界? 嗯,是或不是。 这里有一些LazyRules 示例需要细想的地方: 模式文件被打开(在 __init__()中),并持续打开直到读取最后一个规则。 当 Python 退出或最后一个LazyRules 类的实例销毁,Python 会最终关闭文件,但是那仍然可能会是一个很长的时间。如果该类是一个“长时间运行”的 Python 进程的一部分,Python 可能从不退出, LazyRules 对象就可能一直不会释放。

这种情况有解决办法。 不要在 __init__() 中打开文件并让其在一行一行读取规则时一直打开,你可以打开文件,读取所有规则,并立即关闭文件。或你可以打开文件,读取一条规则,用tell() 方法保存文件位置,关闭文件,后面再次打开它,使用seek() 方法 继续从你离开的地方读取。 或者你不需担心这些就让文件打开,如同本示例所做。 编程即是设计, 而设计牵扯到所有的权衡和限制。让一个文件一直打开太长时间可能是问题;让你代码太复杂也可能是问题。哪一个是更大的问题,依赖于你的开发团队,你的应用,和你的运行环境。

深入阅读

Chapter 8 高级迭代器

Chapter 8 高级迭代器

" Great fleas have little fleas upon their backs to bite ’em, And little fleas have lesser fleas, and so ad infinitum. " — Augustus De Morgan

深入

HAWAII + IDAHO + IOWA + OHIO == STATES. 或者,换个说法, 510199 + 98153 + 9301 + 3593 == 621246. 我在说是方言吗?不,这只是一个谜题。

让我来给你解释一下。

HAWAII + IDAHO + IOWA + OHIO == STATES
510199 + 98153 + 9301 + 3593 == 621246

H = 5
A = 1
W = 0
I = 9
D = 8
O = 3
S = 6
T = 2
E = 4 

像这样的谜题被称为cryptarithms 或者 字母算术(alphametics)。字母可以拼出实际的单词,而如果你把每一个字母都用0–9中的某一个数字代替后, 也同样可以#8220;拼出” 一个算术等式。关键的地方是找出每个字母都映射到了哪个数字。每个字母所有出现的地方都必须映射到同一个数字,数字不能重复, 并且“单词”不能以 0 开始。

最著名的字母算术谜题是SEND + MORE = MONEY

在这一章中,我们将深入一个最初由 Raymond Hettinger 编写的难以置信的 Python 程序。这个程序只用 14 行代码来解决字母算术谜题。

[下载 alphametics.py]

import re
import itertools

def solve(puzzle):
    words = re.findall('[A-Z]+', puzzle.upper())
    unique_characters = set(''.join(words))
    assert len(unique_characters) <= 10, 'Too many letters'
    first_letters = {word[0] for word in words}
    n = len(first_letters)
    sorted_characters = ''.join(first_letters) + \
        ''.join(unique_characters - first_letters)
    characters = tuple(ord(c) for c in sorted_characters)
    digits = tuple(ord(c) for c in '0123456789')
    zero = digits[0]
    for guess in itertools.permutations(digits, len(characters)):
        if zero not in guess[:n]:
            equation = puzzle.translate(dict(zip(characters, guess)))
            if eval(equation):
                return equation

if __name__ == '__main__':
    import sys
    for puzzle in sys.argv[1:]:
        print(puzzle)
        solution = solve(puzzle)
        if solution:
            print(solution) 

你可以从命令行运行这个程序。在 Linux 上, 运行情况看起来是这样的。(取决于你机器的速度,计算可能要花一些时间,而且不会有进度条。耐心等待就好了。)

you@localhost:~/diveintopython3/examples$ python3 alphametics.py "HAWAII + IDAHO + IOWA + OHIO == STATES"
HAWAII + IDAHO + IOWA + OHIO = STATES
510199 + 98153 + 9301 + 3593 == 621246
you@localhost:~/diveintopython3/examples$ python3 alphametics.py "I + LOVE + YOU == DORA"
I + LOVE + YOU == DORA
1 + 2784 + 975 == 3760
you@localhost:~/diveintopython3/examples$ python3 alphametics.py "SEND + MORE == MONEY"
SEND + MORE == MONEY
9567 + 1085 == 10652 

找到一个模式所有出现的地方

字母算术谜题解决者做的第一件事是找到谜题中所有的字母(A–Z)。

>>> import re

['16', '2', '4', '8']

['SEND', 'MORE', 'MONEY'] 
  1. re 模块是正则表达式的 Python 实现。它有一个漂亮的函数findall(),接受一个正则表达式和一个字符串作为参数,然后找出字符串中出现该模式的所有地方。在这个例子里,模式匹配的是数字序列。findall()函数返回所有匹配该模式的子字符串的列表。
  2. 这里正则表达式匹配的是字母序列。再一次,返回值是一个列表,其中的每一个元素是匹配该正则表达式的字符串。

这是另外一个稍微复杂一点的例子。

>>> re.findall(' s.*? s', "The sixth sick sheikh's sixth sheep's sick.")
[' sixth s', " sheikh's s", " sheep's s"] 

这是英语中最难的绕口令

很惊奇?这个正则表达式寻找一个空格,一个 s, 然后是最短的任何字符构成的序列(.*?), 然后是一个空格, 然后是另一个s。 在输入字符串中,我看见了五个匹配:

  1. The &lt;mark&gt;sixth s&lt;/mark&gt;ick sheikh's sixth sheep's sick.
  2. The sixth &lt;mark&gt;sick s&lt;/mark&gt;heikh's sixth sheep's sick.
  3. The sixth sick &lt;mark&gt;sheikh's s&lt;/mark&gt;ixth sheep's sick.
  4. The sixth sick sheikh's &lt;mark&gt;sixth s&lt;/mark&gt;heep's sick.
  5. The sixth sick sheikh's sixth &lt;mark&gt;sheep's s&lt;/mark&gt;ick.

但是re.findall()函数值只返回了 3 个匹配。准确的说,它返回了第一,第三和第五个。为什么呢?因为它不会返回重叠的匹配。第一个匹配和第二个匹配是重叠的,所以第一个被返回了,第二个被跳过了。然后第三个和第四个重叠,所以第三个被返回了,第四个被跳过了。最后,第五个被返回了。三个匹配,不是五个。

这和字母算术解决者没有任何关系;我只是觉得这很有趣。

在序列中寻找不同的元素

Sets 使得在序列中查找不同的元素变得很简单。

>>> a_list = ['The', 'sixth', 'sick', "sheik's", 'sixth', "sheep's", 'sick']

{'sixth', 'The', "sheep's", 'sick', "sheik's"}
>>> a_string = 'EAST IS EAST'

{'A', ' ', 'E', 'I', 'S', 'T'}
>>> words = ['SEND', 'MORE', 'MONEY']

'SENDMOREMONEY'

{'E', 'D', 'M', 'O', 'N', 'S', 'R', 'Y'} 
  1. 给出一个有若干字符串组成的列表,set()函数返回列表中不同的字符串组成的集合。把它想象成一个for循环可以帮助理解。从列表出拿出第一个元素,放到集合。第二个,第三个,第四个。第五个,等等, 它已经在集合里面了,因为 Python 集合不允许重复,所以它只被列出了一次。第六个。第七个又是一个重复的,所以它只被列出了一次。原来的列表甚至不需要事先排好序。
  2. 同样的技术也适用于字符串,因为一个字符串就是一个字符序列。
  3. 给出一个字符串列表, ''.join(a_list)将所有的字符串拼接成一个。
  4. 所以,给出一个字符串列表,这行代码返回这些字符串中出现过的不重复的字符。

字母算术解决者通过这个技术来建立谜题中出现的不同字符的集合。

unique_characters = set(''.join(words)) 

这个列表在接下来迭代可能的解法的时候将被用来将数字分配给字符。

作出断言

和很多编程语言一样,Python 有一个assert语句。这是它的用法。

 Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError: Only for very large values of 2 
  1. assert 语句后面跟任何合法的 Python 表达式。在这个例子里, 表达式 1 + 1 == 2 的求值结果为 True, 所以 assert 语句没有做任何事情。
  2. 然而, 如果 Python 表达式求值结果为 False, assert 语句会抛出一个 AssertionError.
  3. 你可以提供一个人类可读的消息,AssertionError异常被抛出的时候它可以被用于打印输出。

因此, 这行代码:

assert len(unique_characters) <= 10, 'Too many letters' 

…等价于:

if len(unique_characters) > 10:
    raise AssertionError('Too many letters') 

字母算术谜题使用这个assert 语句来排除谜题包含多于 10 个的不同的字母的情况。因为每个不同的字母对应一个不同的数字,而数子只有 10 个,含有多于 10 个的不同的字母的谜题是不可能有解的。

生成器表达式

生成表达式类似生成器函数,只不过它不是函数。

>>> unique_characters = {'E', 'D', 'M', 'O', 'N', 'S', 'R', 'Y'}

<generator object <genexpr> at 0x00BADC10>

69
>>> next(gen)
68

(69, 68, 77, 79, 78, 83, 82, 89) 
  1. 生成器表达式类似一个 yield 值的匿名函数。表达式本身看起来像列表解析, 但不是用方括号而是用圆括号包围起来。
  2. 生成器表达式返回迭代器。
  3. 调用 next(gen) 返回迭代器的下一个值。
  4. 如果你愿意,你可以将生成器表达式传给tuple(), list(), 或者 set()来迭代所有的值并且返回元组,列表或者集合。在这种情况下,你不需要一对额外的括号 — 将生成器表达式ord(c) for c in unique_characters 传给 tuple() 函数就可以了, Python 会推断出它是一个生成器表达式。

☞使用生成器表达式取代列表解析可以同时节省 CPU 和 内存(RAM)。如果你构造一个列表的目的仅仅是传递给别的函数,(比如 传递给tuple() 或者 set()), 用生成器表达式替代吧!

这里是到达同样目的的另一个方法, 使用生成器函数:

def ord_map(a_string):
    for c in a_string:
        yield ord(c)

gen = ord_map(unique_characters) 

生成器表达式功能相同但更紧凑。

计算排列… 懒惰的方法!

首先, 排列到底是个什么东西? 排列是一个数学概念。(取决于你在处理哪种数学,排列有好几个定义。在这里我们说的是组合数学, 如果你完全不知道组合数学是什么也不用担心。同往常一样, 维基百科是你的朋友。)

想法是这样的,你有某物件(可以是数字,可以是字母,也可以是跳舞的熊)的一个列表,接着找出将它们拆开然后组合成小一点的列表的所有可能。所有的小列表的大小必须一致。最小是 1,最大是元素的总数目。哦,也不能有重复。数学家说“让我们找出 3 个元素取 2 个的排列,” 意思是你有一个 3 个元素的序列,然后你找出所有可能的有序对。

 (1, 2)
>>> next(perms)
(1, 3)
>>> next(perms)

>>> next(perms)
(2, 3)
>>> next(perms)
(3, 1)
>>> next(perms)
(3, 2)

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration 
  1. itertools模块里有各种各样的有趣的东西,包括permutations()函数,它把查找排列的所有辛苦的工作的做了。
  2. permutations() 函数接受一个序列(这里是 3 个数字组成的列表) 和一个表示你要的排列的元素的数目的数字。函数返回迭代器,你可以在for 循环或其他老地方使用它。这里我遍历迭代器来显示所有的值。
  3. [1, 2, 3]取 2 个的第一个排列是(1, 2)
  4. 记住排列是有序的: (2, 1)(1, 2)是不同的。
  5. 这就是了。这些就是[1, 2, 3]取两个的所有排列。像(1, 1) 或者 (2, 2)这样的元素对没有出现,因为它们包含重复导致它们不是合法的排列。当没有更多排列的时候,迭代器抛出一个StopIteration异常。

itertools模块有各种各样的有趣的东西。

permutations()函数并不一定要接受列表。它接受任何序列 — 甚至是字符串。

>>> import itertools

>>> next(perms)

>>> next(perms)
('A', 'C', 'B')
>>> next(perms)
('B', 'A', 'C')
>>> next(perms)
('B', 'C', 'A')
>>> next(perms)
('C', 'A', 'B')
>>> next(perms)
('C', 'B', 'A')
>>> next(perms)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

[('A', 'B', 'C'), ('A', 'C', 'B'),
 ('B', 'A', 'C'), ('B', 'C', 'A'),
 ('C', 'A', 'B'), ('C', 'B', 'A')] 
  1. 字符串就是一个字符序列。对于查找排列来说,字符串'ABC'和列表 ['A', 'B', 'C']是等价的。
  2. ['A', 'B', 'C']取 3 个的第一个排列是('A', 'B', 'C')。还有 5 个其他的排列 — 同样的 3 个字符,不同的顺序。
  3. 由于permutations()函数总是返回迭代器,一个简单的调试排列的方法是将这个迭代器传给内建的list()函数来立刻看见所有的排列。

itertools模块中的其它有趣的东西

>>> import itertools

[('A', '1'), ('A', '2'), ('A', '3'), 
 ('B', '1'), ('B', '2'), ('B', '3'), 
 ('C', '1'), ('C', '2'), ('C', '3')]

[('A', 'B'), ('A', 'C'), ('B', 'C')] 
  1. itertools.product()函数返回包含两个序列的笛卡尔乘积的迭代器。
  2. itertools.combinations()函数返回包含给定序列的给定长度的所有组合的迭代器。这和itertools.permutations()函数很类似,除了不包含因为只有顺序不同而重复的情况。所以itertools.permutations('ABC', 2)同时返回('A', 'B') and ('B', 'A') (同其它的排列一起), itertools.combinations('ABC', 2) 不会返回('B', 'A') ,因为它和('A', 'B')是重复的,只是顺序不同而已。

[下载 favorite-people.txt]

 >>> names
['Dora\n', 'Ethan\n', 'Wesley\n', 'John\n', 'Anne\n',
'Mike\n', 'Chris\n', 'Sarah\n', 'Alex\n', 'Lizzie\n']

>>> names
['Dora', 'Ethan', 'Wesley', 'John', 'Anne',
'Mike', 'Chris', 'Sarah', 'Alex', 'Lizzie']

>>> names
['Alex', 'Anne', 'Chris', 'Dora', 'Ethan',
'John', 'Lizzie', 'Mike', 'Sarah', 'Wesley']

>>> names
['Alex', 'Anne', 'Dora', 'John', 'Mike',
'Chris', 'Ethan', 'Sarah', 'Lizzie', 'Wesley'] 
  1. 这个表达式将文本内容以一行一行组成的列表的形式返回。
  2. 不幸的是,(对于这个例子来说), list(open(filename)) 表达式返回的每一行的末尾都包含回车。这个列表解析使用rstrip() 字符串方法移除每一行尾部的空白。(字符串也有一个lstrip()方法移除头部的空白,以及strip()方法头尾都移除。)
  3. sorted() 函数接受一个列表并将它排序后返回。默认情况下,它按字母序排序。
  4. 然而,sorted()函数也接受一个函数作为key 参数, 并且使用 key 来排序。在这个例子里,排序函数是len(),所以它按len(each item)来排序。短的名字排在前面,然后是稍长,接着是更长的。

这和itertools模块有什么关系? 很高兴你问了这个问题。

…continuing from the previous interactive shell…
>>> import itertools

>>> groups
<itertools.groupby object at 0x00BB20C0>
>>> list(groups)
[(4, <itertools._grouper object at 0x00BA8BF0>),
 (5, <itertools._grouper object at 0x00BB4050>),
 (6, <itertools._grouper object at 0x00BB4030>)]

...     print('Names with {0:d} letters:'.format(name_length))
...     for name in name_iter:
...         print(name)
... 
Names with 4 letters:
Alex
Anne
Dora
John
Mike
Names with 5 letters:
Chris
Ethan
Sarah
Names with 6 letters:
Lizzie
Wesley 
  1. itertools.groupby()函数接受一个序列和一个 key 函数, 并且返回一个生成二元组的迭代器。每一个二元组包含key_function(each item)的结果和另一个包含着所有共享这个 key 结果的元素的迭代器。
  2. 调用list() 函数会“耗尽”这个迭代器, 也就是说 你生成了迭代器中所有元素才创造了这个列表。迭代器没有“重置”按钮。你一旦耗尽了它,你没法重新开始。如果你想要再循环一次(例如, 在接下去的for循环里面), 你得调用itertools.groupby()来创建一个新的迭代器。
  3. 在这个例子里,给出一个已经按长度排序的名字列表, itertools.groupby(names, len)将会将所有的 4 个字母的名字放在一个迭代器里面,所有的 5 个字母的名字放在另一个迭代器里,以此类推。groupby()函数是完全通用的; 它可以将字符串按首字母,将数字按因子数目, 或者任何你能想到的 key 函数进行分组。

itertools.groupby()只有当输入序列已经按分组函数排过序才能正常工作。在上面的例子里面,你用len() 函数分组了名字列表。这能工作是因为输入列表已经按长度排过序了。

Are you watching closely?

>>> list(range(0, 3))
[0, 1, 2]
>>> list(range(10, 13))
[10, 11, 12]

[0, 1, 2, 10, 11, 12]

[(0, 10), (1, 11), (2, 12)]

[(0, 10), (1, 11), (2, 12)]

[(0, 10), (1, 11), (2, 12), (None, 13)] 
  1. itertools.chain()函数接受两个迭代器,返回一个迭代器,它包含第一个迭代器的所有内容,以及跟在后面的来自第二个迭代器的所有内容。(实际上,它接受任何数目的迭代器,并把它们按传入顺序串在一起。)
  2. zip()函数的作用不是很常见,结果它却非常有用: 它接受任何数目的序列然后返回一个迭代器,其第一个元素是每个序列的第一个元素组成的元组,然后是每个序列的第二个元素(组成的元组),以此类推。
  3. zip() 在到达最短的序列结尾的时候停止。range(10, 14) 有四个元素(10, 11, 12, 和 13), 但是 range(0, 3)只有 3 个, 所以 zip()函数返回包含 3 个元素的迭代器。
  4. 相反,itertools.zip_longest()函数在到达最长的序列的结尾的时候才停止, 对短序列结尾之后的元素填入None值.

好吧,这些都很有趣,但是和字母算术谜题解决者有什么联系呢? 请看下面:

>>> characters = ('S', 'M', 'E', 'D', 'O', 'N', 'R', 'Y')
>>> guess = ('1', '2', '0', '3', '4', '5', '6', '7')

(('S', '1'), ('M', '2'), ('E', '0'), ('D', '3'),
 ('O', '4'), ('N', '5'), ('R', '6'), ('Y', '7'))

{'E': '0', 'D': '3', 'M': '2', 'O': '4',
 'N': '5', 'S': '1', 'R': '6', 'Y': '7'} 
  1. 给出一个字母列表和一个数字列表(两者的元素的形式都是 1 个字符的字符串), zip函数按顺序创建一组组字母,数字对。
  2. 为什么这很酷? 因为这个数据结构正好可以用来传递给dict()函数来创建以字母为键,对应数字为值的字典。(这不是实现这个目的唯一方法。你当然可以使用字典解析来直接创建字典。) 尽管字典的打印形式以另一个顺序列出了这些键值对(字典本身没有#8220;顺序” ), 但是你可以看见每一个字母都按charactersguess序列的原始顺序对应到了相应的数字。

算术谜题解决者使用这个技术对每一个可能的解法创建一个将谜题中的字母映射到解法中的数字的字典。

characters = tuple(ord(c) for c in sorted_characters)
digits = tuple(ord(c) for c in '0123456789')
...
for guess in itertools.permutations(digits, len(characters)):
    ...
 <mark>equation = puzzle.translate(dict(zip(characters, guess)))</mark> 

但是translate()方法是什么呢? 啊哈, 我们现在到了真正有趣的部分了。

一种新的操作字符串的方法

Python 字符串有很多方法。我们在字符串章节中学习了其中一些: lower(), count(), 和 format()。现在我要给你介绍一个强大但鲜为人知的操作字符串的技术: translate() 方法。

 {65: 79}

'MORK' 
  1. 字符串翻译从一个转换表开始, 转换表就是一个将一个字符映射到另一个字符的字典。实际上,“字符” 是不正确的 — 转换表实际上是将一个 *字节(byte)*映射到另一个。
  2. 记住,Python 3 中的字节是整形数。ord() 函数返回字符的 ASCII 码。在这个例子中,字符是 A–Z, 所以返回的是从 65 到 90 的字节。
  3. 一个字符串的translate()方法接收一个转换表,并用它来转换该字符串。换句话说,它将出现在转换表的键中的字节替换为该键对应的值。在这个例子里, 将MARK “翻译为” MORK.

现在你开始进入真正有趣的部分了。

这和解决字母算术谜题有什么关系呢?实际上,关系大着呢。

 >>> characters
(83, 77, 69, 68, 79, 78, 82, 89)

>>> guess
(57, 49, 53, 55, 48, 54, 56, 50)

>>> translation_table
{68: 55, 69: 53, 77: 49, 78: 54, 79: 48, 82: 56, 83: 57, 89: 50}

'9567 + 1085 == 10652' 
  1. 使用生成器表达式, 我们快速的计算出字符串中每个字符的字节值。charactersalphametics.solve()函数中的sorted_characters的示例值 .
  2. 使用另一个生成器表达式,我们快速的计算出字符串中每个数字的字节值。计算结果guess, 正好是alphametics.solve()函数中的itertools.permutations()函数返回值的格式。
  3. 通过将charactersguesszipping 出来的元素对序列构造出的字典来作为转换表。这正是alphametics.solve()for 循环里面干的事情。
  4. 最后我们将转换表传递给原始字符串的translate()方法。这会将字符串中的每个字母转化成相应的数字(基于characters中字母和guess中的数字)。结果是一个字符串形式的合法的 Python 表达式。

这相当令人难忘。但你能对正巧是一个合法 Python 表达式的字符串干什么呢?

将任何字符串作为 Python 表达式求值

这是谜题的最后一部分(或者说, 谜题解决者的最后一部分)。经过华丽的字符串操作,我们得到了类似'9567 + 1085 == 10652'这样的一个字符串。但那是一个字符串,字符串有什么好的?输入eval(), Python 通用求值工具。

>>> eval('1 + 1 == 2')
True
>>> eval('1 + 1 == 3')
False
>>> eval('9567 + 1085 == 10652')
True 

但是等一下,不止这些! eval() 并不限于布尔表达式。它能处理任何 Python 表达式并且返回任何数据类型。

>>> eval('"A" + "B"')
'AB'
>>> eval('"MARK".translate({65: 79})')
'MORK'
>>> eval('"AAAAA".count("A")')
5
>>> eval('["*"] * 5')
['*', '*', '*', '*', '*'] 

等一下,还没完呢!

>>> x = 5

25

25
>>> import math

2.2360679774997898 
  1. eval()接受的表达式可以引用在eval()之外定义的全局变量。如果(eval())在函数内被调用, 它也可以引用局部变量。
  2. 以及函数。
  3. 以及模块。

喂,等一下…

>>> import subprocess

'Desktop         Library         Pictures \
 Documents       Movies          Public   \
 Music           Sites' 
  1. subprocess 模块允许你执行任何 shell 命令并以字符串形式获得输出。
  2. 执行任意的 shell 命令可能会导致永久的(不好的)后果。

更坏的是,由于存在全局函数__import__(),它接收字符串形式的模块名,导入模块,并返回模块的引用。和eval()的能力结合起来,你可以构造一个单独的表达式来删除你所有的文件:

  1. 现在想象一下'rm -rf ~'的输出。实际上它不会有任何输出,但是你也不会有任何文件还留着。

eval() 是邪恶的

好吧, 邪恶部分是对来自非信任源的表达式进行求值。你应该只在信任的输入上使用eval()。当然,关键的部分是确定什么是“可信任的”。但有一点我敢肯定: 你应该将这个字母算术表达式放到网上最为一个小的 web 服务。不要错误的认为,“Gosh, 这个函数在求值以前做了那么多的字符串操作。我想不出 谁能利用这个漏洞。” 有人找出穿过这些字符串操作把危险的可执行代码放进来的方法的。(更奇怪的事情都发生过。), 然后你就得和你的服务器说再见了。

但是肯定有某种办法可以安全的求值表达式吧?将eval()放到一个不能访问和伤害外部世界的沙盒里面。嗯,对也不对。

>>> x = 5

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <module>
NameError: name 'x' is not defined

>>> import math

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <module>
NameError: name 'math' is not defined 
  1. 传给eval()函数的第二和第三个函数担当了求值表达式是的全局和局部名字空间的角色。在这个例子里,它们都是空的,意味着当字符串"x * 5"被求值的时候, 在全局和本地的名字空间都没有变量x, 所以 eval()抛出了一个异常。
  2. 你可以通过一个个列出的方式选择性在全局名字空间里面包含一些值。这些 — 并且这有这些 — 变量在求值的时候可用。
  3. 即使你刚刚导入了math模块, 你没有在传给eval()函数的名字空间里包含它,所以求值失败了。

哎呀,这很简单。 让我来做一个字母算术谜题的 Web 服务吧!

 25

2.2360679774997898 
  1. 即使你传入空的字典作为全局和局部名字空间,所有的 Python 内建函数在求值时还是可用的。所以pow(5, 2)可以工作, 因为 52是字面量,而pow()是内建函数。
  2. 很不幸 (如果你不明白为什么不幸,继续读。), __import__() 也是一个内建函数,所以它也能工作。

是的,这意味着即使你在调用eval()的时候显式的将全局和局部名字空间设置为空字典,你仍然可以做坏事。

>>> eval("__import__('subprocess').getoutput('rm /some/random/file')", {}, {}) 

哎呀. 幸亏我没有做那个字母算术 web 服务。存在任何安全的使用 eval()的方法吗? 嗯, 有也没有。

>>> eval("__import__('math').sqrt(5)",

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <module>
NameError: name '__import__' is not defined
>>> eval("__import__('subprocess').getoutput('rm -rf /')",

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<string>", line 1, in <module>
NameError: name '__import__' is not defined 
  1. 为了安全的求值不受信任的表达式, 你需要定义一个将"__builtins__" 映射为 None(Python 的空值)的全局名字空间字典. 在内部, “内建” 函数包含在一个叫做"__builtins__"的伪模块内。这个伪模块( 内建函数的集合) 在没有被你显式的覆盖的情况下对被求值的表达式是总是可用的。
  2. 请确保你覆盖的是__builtins__。 不是__builtin__, __built-ins__, 或者其它某个变量,否则程序还是可以运行但是会有巨大的风险。

那么eval()现在安全了? 嗯,是也不是。

>>> eval("2 ** 2147483647", 
  1. 即使不能访问到__builtins__, 你还是可以开启一个拒绝服务攻击。例如, 试图求22147483647次方会导致你的服务器的 CPU 利用率到达 100% 一段时间。(如果你在交互式 shell 中试验这个, 请多按几次 Ctrl-C来跳出来。) 技术上讲,这个表达式 最终将会返回一个值, 但是在这段时间里你的服务器将啥也干不了。

最后, Python 表达式的求值可能达到某种意义的“安全”的, 但结果是在现实生活中没什么用。如果你只是玩玩没有问题,如果你只给它传递安全的输入也没有问题。但是其它的情况完全是自找麻烦。

把所有东西放在一起

总的来说: 这个程序通过暴力解决字母算术谜题, 也就是通过穷举所有可能的解法。为了达到目的,它

  1. 通过re.findall()函数找到谜题中的所有字母
  2. 使用集合和set()函数找到谜题出现的所有不同的字母
  3. 通过assert语句检查是否有超过 10 个的不同的字母 (意味着谜题无解)
  4. 通过一个生成器对象将字符转换成对应的 ASCII 码值
  5. 使用itertools.permutations()函数计算所有可能的解法
  6. 使用translate()字符串方法将所有可能的解转换成 Python 表达式
  7. 使用eval()函数通过求值 Python 表达式来检验解法
  8. 返回第一个求值结果为True的解法

…仅仅 14 行代码.

进一步阅读

非常感谢 Raymond Hettinger 同意重现授权他的代码,因此我才能将它移植到 Python 3 并作为本章的基础。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值