Python-入门指南-从新手到大师-全-

Python 入门指南:从新手到大师(全)

原文:Beginning Python

协议:CC BY-NC-SA 4.0

一、即时黑魔法:基础知识

是时候开始黑了。在这一章中,你将学习如何通过说一种电脑能理解的语言来控制你的电脑:Python。这里没有什么特别难的,所以如果你知道你的计算机如何工作的基本原理,你应该能够跟随例子并自己尝试。我将介绍一些基础知识,从极其简单的开始,但是因为 Python 是一种如此强大的语言,所以您很快就能做相当高级的事情。

首先,您需要安装 Python,或者验证您已经安装了 Python。如果你运行的是 macOS 或者 Linux/UNIX,打开一个终端(Mac 上的终端应用),输入python,然后回车。您应该会收到一条欢迎消息,以下面的提示结束:

>>>

如果这样做了,您可以立即开始输入 Python 命令。但是,请注意,您可能有旧版本的 Python。如果第一行以Python 2而不是Python 3开头,您可能还是想安装一个更新的版本,因为 Python 3 引入了几个突破性的变化。

安装过程的细节当然会因您的操作系统和首选安装机制而异,但最直接的方法是访问 www.python.org ,在那里您应该可以找到下载页面的链接。这一切都是不言自明的——只需通过链接找到您的平台的最新版本,无论是 Windows、macOS、Linux/UNIX 还是其他平台。对于 Windows 和 Mac,您将下载一个安装程序,您可以运行它来实际安装 Python。对于 Linux/UNIX,您需要按照附带的说明自己编译源代码。如果你正在使用一个包管理器,比如 Homebrew 或者 APT,你可以用它来简化这个过程。

一旦你安装了 Python,试着启动交互式解释器。如果您使用命令行,您可以简单地使用python命令,或者如果您已经安装了旧版本,也可以使用python3。如果您更喜欢使用图形界面,可以启动 Python 安装附带的空闲应用。

交互式解释器

当您启动 Python 时,您会得到类似如下的提示:

Python 3.5.0 (default, Dec 5 2015, 15:03:35)
[GCC 4.2.1 Compatible Apple LLVM 7.0.0 (clang-700.1.76)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>

解释器的确切外观及其错误消息将取决于您使用的版本。这可能看起来不太有趣,但是相信我,它很有趣。这是你通往黑魔法世界的大门,是你控制电脑的第一步。用更实际的术语来说,它是一个交互式 Python 解释器。为了看看它是否有效,请尝试以下方法:

>>> print("Hello, world!")

当您按下 Enter 键时,会出现以下输出:

Hello, world!
>>>

如果你熟悉其他计算机语言,你可能习惯于用分号结束每一行。在 Python 中不需要这样做。一条线就是一条线,或多或少。如果你愿意,你可以添加一个分号,但它不会有任何效果(除非在同一行上有更多的代码),而且它不是一个常见的事情。

这里发生了什么?那个东西就是提示。你可以在这个空间写点东西,比如print "Hello, world!"。如果您按 Enter 键,Python 解释器会打印出字符串“Hello,world!”你会在下面看到一个新的提示。

如果你写的是完全不同的东西呢?尝试一下:

>>> The Spanish Inquisition
SyntaxError: invalid syntax
>>>

显然,翻译不明白这一点。 2 (如果运行的是 IDLE 以外的解释器,比如 Linux 的命令行版本,错误信息会略有不同。)解释器还指出了错误:它将通过给西班牙语一个红色背景来强调这个单词(或者,在命令行版本中,通过使用一个脱字符号,^)。

如果你喜欢,多和翻译玩玩。为了获得一些指导,尝试在提示符下输入命令help()并按 Enter 键。您可以按 F1 键获取有关空闲的帮助。否则,让我们继续努力。毕竟,当你不知道告诉它什么的时候,解释器就没什么意思了。

有什么事吗?。。怎么了?

在我们开始认真编程之前,我会试着给你一个什么是计算机编程的概念。简单地说,它告诉计算机做什么。电脑可以做很多事情,但是它们不太擅长独立思考。他们真的需要被灌输细节。你需要用计算机能理解的语言输入一个算法。算法只是一个程序或配方的花哨词汇——对如何做某事的详细描述。请考虑以下几点:

SPAM with SPAM, SPAM, Eggs, and SPAM:  First, take some SPAM.
Then add some SPAM, SPAM, and eggs.
If a particularly spicy SPAM is desired, add some SPAM.
Cook until done -- Check every 10 minutes.

这不是最奇特的食谱,但它的结构很有启发性。它由一系列要按顺序执行的指令组成。有些指示可以直接完成(“吃一些垃圾邮件”),而有些则需要一些深思熟虑(“如果想要一份特别辣的垃圾邮件”),而有些则必须重复几次(“每 10 分钟检查一次。”)

配方和算法由配料(对象、事物)和指令(语句)组成。在这个例子中,垃圾邮件和鸡蛋是配料,而指令包括添加垃圾邮件,烹饪给定的时间长度,等等。让我们从一些相当简单的 Python 成分开始,看看可以用它们做些什么。

数字和表达式

交互式 Python 解释器可以用作强大的计算器。尝试以下方法:

>>> 2 + 2

这应该会给你答案 4。这并不难。好吧,那这个呢:

>>> 53672 + 235253
288925

还是没印象?诚然,这是相当标准的东西。(我假设你已经用了足够多的计算器,知道1 + 2 * 3(1 + 2) * 3之间的区别。)所有常用的算术运算符都按预期工作。除法产生十进制数,称为浮点数(或浮点数)。

>>> 1 / 2
0.5
>>> 1 / 1
1.0

如果你宁愿舍弃小数部分而做整数除法,你可以用双斜线。

>>> 1 // 2
0
>>> 1 // 1
1
>>> 5.0 // 2.4
2.0

在 Python 的旧版本中,整数的普通除法曾经像这个双斜线一样工作。如果您正在使用 Python 2.x,您可以通过将以下语句添加到程序的开头(编写完整的程序将在后面描述)或在交互式解释器中简单地执行它来获得适当的划分:

>>> from __future__ import division

Note

如果不完全清楚,指令中的future两边用两个下划线包围:_ _future_ _

另一种方法是,如果您从命令行运行旧的 Python,提供命令行开关-Qnew。在本章后面的“回到 __ 未来 _ _”一节中有关于__future__的更详细的解释。

现在你已经看到了基本的算术运算符(加、减、乘、除),但是我没有提到整数除法的近亲。

>>> 1 % 2
1

这是余数(模数)运算符。x % y给出x除以y的余数。换句话说,就是用整数除法时剩下的部分。也就是说,x % yx - ((x // y) * y)是一样的。

>>> 10 // 3
3
>>> 10 % 3
1
>>> 9 // 3
3
>>> 9 % 3
0
>>> 2.75 % 0.5
0.25

这里的10 // 33,因为结果是向下舍入的。但是 3 × 3 是 9,所以你得到 1 的余数。当你用 9 除以 3 时,结果正好是 3,没有四舍五入。因此,余数为 0。如果你想像本章前面的食谱那样“每 10 分钟”检查一次,这可能是有用的。你可以简单地检查minute % 10是否为 0。(有关如何操作的描述,请参阅本章后面的边栏“预览:if 语句”。)正如您在最后一个例子中看到的,余数操作符同样适用于浮点数。它甚至适用于负数,这可能会有点混乱。

>>> 10 % 3
1
>>> 10 % -3
-2
>>> -10 % 3
2
>>> -10 % -3
-1

看着这些例子,它的工作原理可能不是很明显。如果看看整数除法的伴生运算,大概就更容易理解了。

>>> 10 // 3
3
>>> 10 // -3
-4
>>> -10 // 3
-4
>>> -10 // -3
3

考虑到除法是如何工作的,就不难理解余数是多少了。关于整数除法,需要理解的重要一点是,它是向下舍入的,对于负数来说是远离零的。这意味着-10 // 3被向下舍入到-4,而不是向上舍入到-3

我们要看的最后一个运算符是幂运算符。

>>> 2 ** 3
8
>>> -3 ** 2
-9
>>> (-3) ** 2
9

请注意,求幂运算符比求反运算符(一元减号)绑定得更紧,因此-3**2实际上与-(3**2)相同。如果你想计算(-3)**2,你必须明确地说出来。

十六进制八进制和二进制

为了结束这一节,我应该提到十六进制、八进制和二进制数是这样写的:

>>> 0xAF
175
>>> 010
8
>>> 0b1011010010
722

这两个数字的第一个数字都是零。(如果你不知道这是怎么回事,你可能还不需要这个。把它归档以备后用。)

变量

另一个你可能很熟悉的概念是变量。如果代数只是遥远的记忆,不要担心:Python 中的变量很容易理解。变量是代表(或引用)某个值的名称。例如,您可能希望名称x代表3。为此,只需执行以下命令:

>>> x = 3

这就是所谓的任务。我们将值3赋给变量x。换句话说,我们将变量x绑定到值(或对象)3。给变量赋值后,可以在表达式中使用该变量。

>>> x * 2
6

与其他一些语言不同,在将变量绑定到某个东西之前,不能使用它。没有“默认值”

Note

简单来说,Python 中的名称或标识符由字母、数字和下划线字符(_)组成。它们不能以数字开头,所以Plan9是有效的变量名,而9Plan不是。 3

声明

到目前为止,我们一直(几乎)专门研究表情,也就是食谱的成分。但是陈述——指令呢?

事实上,我作弊了。我已经介绍了两种类型的语句:print语句和赋值语句。语句和表达式有什么区别?你可以这样想:一个表达式是一些东西,而一个语句做一些事情。例如,2 * 24,而print(2 * 2)打印的是4。这两者的行为非常相似,所以它们之间的区别可能不是那么明显。

>>> 2 * 2
4
>>> print(2 * 2)
4

只要在交互式解释器中执行,就没有区别,但那只是因为解释器总是打印出所有表达式的值(使用与repr相同的表示——见本章后面的“字符串表示,str 和 repr”一节)。一般来说,Python 不是这样的。在本章的后面,你将会看到如何让程序在没有这个交互提示的情况下运行;简单地在你的程序中放一个像2 * 2这样的表达式不会有任何有趣的效果。 4 然而把print(2 * 2)放在那里,仍然会打印出4

Note

实际上,print是一个函数(这一章后面会详细介绍),所以我所说的print语句只是一个函数调用。在 Python 2.x 中,print有自己的语句类型,并且不在参数周围使用括号。

在处理赋值的时候,语句和表达式的区别更加明显。因为它们不是表达式,所以它们没有可以由交互式解释器打印出来的值。

>>> x = 3
>>>

您只是立即得到一个新的提示。然而,有些事情已经改变了。我们现在有了一个新的变量x,它现在被绑定到值3。从某种程度上来说,这是一种定义性的陈述:它们改变事物。例如,赋值改变变量,而print语句改变你的屏幕外观。

赋值可能是任何编程语言中最重要的语句类型,尽管现在可能很难理解它们的重要性。变量可能看起来只是临时的“存储”(就像烹饪食谱中的锅碗瓢盆),但变量的真正力量在于,你不需要知道它们持有什么值才能操纵它们。 5

例如,你知道x * y等于xy的乘积,即使你可能不知道xy是什么。所以,你可能会编写以不同方式使用变量的程序,而不知道程序运行时它们最终会保存(或引用)的值。

从用户那里获得输入

您已经看到,您可以在不知道变量的值的情况下编写带有变量的程序。当然,解释者最终必须知道这些值。所以我们怎么可能不知道呢?翻译只知道我们告诉它的东西,对吗?不一定。

你可能写了一个程序,别人可能会用。您无法预测用户将为程序提供什么样的价值。我们来看看有用的函数input。(一会儿我会对函数有更多的说明。)

>>> input("The meaning of life: ")
The meaning of life: 42
'42'

这里发生的是在交互式解释器中执行第一行(input(...))。它打印出字符串"The meaning of life: "作为新的提示。我输入42,然后按回车键。input的结果值就是那个数字(作为一段文本或字符串),它自动打印在最后一行。使用int将字符串转换成整数,我们可以构造一个稍微有趣一点的例子:

>>> x = input("x: ")
x: 34
>>> y = input("y: ")
y: 42
>>> print(int(x) * int(y))
1428

这里,Python 提示符处的语句(>>>)可能是一个已完成程序的一部分,输入的值(3442)将由某个用户提供。然后你的程序会打印出值1428,它是两者的乘积。当你写程序的时候,你不需要知道这些值,对吗?

Note

当您将程序保存在一个单独的文件中以便其他用户可以执行它们时,像这样获得输入会有用得多。在本章后面的“保存和执行程序”一节中,你会学到如何做到这一点

Sneak Peek: The if Statement

为了增加一点趣味,我将让你先睹为快一些你在第五章之前不应该了解的东西:语句。如果给定的条件为真,那么if语句允许您执行一个动作(另一个语句)。一种类型的条件是相等测试,使用相等运算符==。是的,这是一个双等号。(单人的是用来做作业的,记得吗?)

您将这个条件放在单词if之后,然后用冒号将其与下面的语句分开。

>>> if 1 == 2: print('One equals two')
...
>>> if 1 == 1: print('One equals one')
...
One equals one
>>>

当条件为假时,什么都不会发生。然而,当它为真时,冒号后面的语句(在本例中是一个print语句)被执行。还要注意,在交互式解释器中使用if语句时,需要在执行之前按两次 Enter 键。(原因将在第五章中变得清楚。)

因此,如果变量time被绑定到以分钟为单位的当前时间,您可以使用下面的语句来检查您是否“在整点”:

if time % 60 == 0: print('On the hour!')

功能

在“数字和表达式”一节中,我使用了指数运算符(**)来计算幂。事实上,您可以使用一个名为pow的函数来代替。

>>> 2 ** 3
8
>>> pow(2, 3)
8

函数就像一个小程序,你可以用它来执行一个特定的动作。Python 有很多函数,可以做很多奇妙的事情。事实上,您也可以创建自己的函数(稍后会详细介绍);所以我们经常把pow等标准函数称为内置函数。

像我在前面的例子中所做的那样使用一个函数叫做调用函数。您向它提供参数(在本例中是23),它向您返回值。因为它返回一个值,函数调用只是另一种类型的表达式,就像本章前面讨论的算术表达式一样。 6 事实上,你可以结合函数调用和运算符来创建更复杂的表达式(就像我之前对int所做的那样)。

>>> 10 + pow(2, 3 * 5) / 3.0
10932.666666666666

像这样的数字表达式中可以使用几个内置函数。例如,abs给出一个数的绝对值,round将浮点数舍入到最接近的整数。

>>> abs(-10)
10
>>> 2 // 3
0
>>> round(2 / 3)
1.0

注意最后两个表达式的区别。整数除法总是向下舍入,而round舍入到最接近的整数,平局则舍入到偶数。但是如果你想把一个给定的数字四舍五入呢?例如,您可能知道一个人 32.9 岁,但您想将其四舍五入到 32 岁,因为她实际上还没有 33 岁。Python 为此提供了一个函数(称为floor)——只是不能直接使用。和许多有用的函数一样,它可以在一个模块中找到。

模块

您可能认为模块是可以导入 Python 来扩展其功能的扩展。您可以用一个名为(很自然)import的特殊命令导入模块。上一节提到的函数floor位于一个名为math的模块中。

>>> import math
>>> math.floor(32.9)
32

注意这是如何工作的:我们用import导入一个模块,然后通过编写module.function来使用该模块中的函数。特别是对于这个操作,你实际上可以把这个数字转换成一个整数,就像我之前做的那样,使用来自input的结果。

>>> int(32.9)
32

Note

还有类似的函数可以转换成其他类型(例如,strfloat)。事实上,这些并不是真正的函数——它们是类。稍后我会有更多关于课程的内容。

然而,math模块还有其他几个有用的功能。比如floor的反义词是ceil(“天花板”的简称),求大于等于给定数的最小整数值。

>>> math.ceil(32.3)
33
>>> math.ceil(32)
32

如果您确定不会用给定的名称(从不同的模块)导入多个函数,您可能不想在每次调用函数时都写模块名。然后,您可以使用import命令的变体。

>>> from math import sqrt
>>> sqrt(9)
3.0

使用from module import function后,可以使用不带模块前缀的功能。

Tip

事实上,您可以使用变量来引用函数(以及 Python 中的大多数其他东西)。通过执行赋值foo = math.sqrt,可以开始使用foo计算平方根;例如,foo(4)产出2.0

cmath 和复数

sqrt函数用于计算一个数的平方根。让我们看看如果给它提供一个负数会发生什么:

>>> from math import sqrt
>>> sqrt(-1)
Traceback (most recent call last):    ...
ValueError: math domain error

或者,在某些平台上:

>>> sqrt(-1)
nan

Note

nan只是一个特殊值,意思是“不是数字”

如果我们把自己限制在实数及其浮点数形式的近似实现上,我们就不能求负数的平方根。负数的平方根是所谓的虚数,实数和虚数之和称为复数。Python 标准库有一个单独的处理复数的模块。

>>> import cmath
>>> cmath.sqrt(-1)
1j

注意,我在这里没有使用from ... import ...。如果我有,我就失去了我普通的sqrt。像这样的名字冲突可能是偷偷摸摸的,所以除非你真的想使用from版本,否则你可能应该坚持使用普通的import

1j是虚数的一个例子。这些数字后面有一个j(或J)。复杂的算术基本上是根据定义1j-1的平方根得出的。在不深入探讨这个话题的情况下,让我来展示最后一个例子:

>>> (1 + 3j) * (9 + 4j)
(-3 + 31j)

如您所见,这种语言内置了对复数的支持。

Note

Python 中没有单独的虚数类型。它们被视为实部为零的复数。

回到 __ 未来 _ _ _

有传言说吉多·范·罗苏姆(Python 的创造者)有一台时间机器——不止一次,当人们请求该语言的特性时,他们发现这些特性已经被实现了。当然,我们并不都被允许进入这个时间机器,但是 Guido 已经很好地将它的一部分以魔法模块__future__的形式构建到 Python 中。从中,我们可以导入将来会成为 Python 标准的特性,但这些特性还不是该语言的一部分。你在“数字和表达式”部分看到了这一点,在本书中你会不时碰到它。

保存和执行您的程序

交互式解释器是 Python 的一大优势。它使得实时测试解决方案和语言实验成为可能。如果你想知道某样东西是如何工作的,试一试就知道了!但是,当您退出时,您在交互式解释器中写的所有内容都将丢失。你真正想做的是编写你和其他人都能运行的程序。在本节中,您将学习如何做到这一点。

首先,你需要一个文本编辑器,最好是用于编程的。(如果你用的是微软 Word 之类的东西,我真的不太推荐,一定要把你的代码存成纯文本。)如果你已经在用 IDLE 了,那你就走运了。使用 IDLE,您可以简单地用文件新建文件创建一个新的编辑器窗口。将出现另一个窗口,但没有交互式提示。咻!首先输入以下内容:

print("Hello, world!")

现在选择 File Save 保存您的程序(实际上,它是一个纯文本文件)。一定要把它放在以后可以找到的地方,并给你的文件取一个合理的名字,比如hello.py。(.py结局意义重大。)

明白了吗?不要关闭有你的程序的窗口。如果有,只需再次打开它(文件打开)。现在,您可以使用运行模块来运行它。(如果您没有使用 IDLE,请参见下一节关于从命令提示符运行程序的内容。)

会发生什么?Hello, world!被打印在解释器窗口中,这正是我们想要的。解释器提示符可能消失了(取决于您使用的版本),但您可以通过按 Enter 键(在解释器窗口中)恢复它。

让我们将脚本扩展如下:

name = input("What is your name? ")
print("Hello, " + name  + "!")

如果您运行这个命令(记得首先保存它),您应该在解释器窗口中看到以下提示:

What is your name?

输入您的姓名(例如,Gumby)并按回车键。您应该得到这样的结果:

Hello, Gumby!

Turtle Power!

print语句对于基本示例很有用,因为它几乎在任何地方都有效。如果你想尝试视觉上更有趣的输出,你应该看看turtle模块,它实现了所谓的海龟图形。如果你有空闲和运行,turtle模块应该工作得很好,它让你画数字,而不是打印文本。虽然这是一种通常应该小心的做法,但是在玩海龟图形的时候,简单地从模块中导入所有的名字会很方便。

from turtle import *

一旦你弄清楚了你需要哪些函数,你就可以回到只导入那些函数的状态。

海龟图形的想法源于真正的海龟机器人,它们可以前后移动,并向左或向右转动给定的角度。此外,他们还携带一支笔,可以上下移动来确定笔是否接触到他们正在移动的纸张。turtle模块给你这样一个机器人的模拟。例如,你可以这样画一个三角形:

forward(100)
left(120)
forward(100)
left(120)
forward(100)

如果你运行这个,应该会出现一个新的窗口,有一个小箭头形状的“海龟”在移动,后面拖着一条线。要让它提起笔,你用penup(),再放下笔,pendown()。更多命令,请参考 Python 库参考( https://docs.python.org/3/library/turtle.html )的相关章节,对于绘图想法,请尝试在网上搜索海龟图形。当你学习更多的概念时,你可能想用海龟来代替更普通的print例子。玩海龟图形很快证明了我将向您展示的一些基本编程结构的必要性。(例如,你如何避免重复前面例子中的forwardleft命令?比如说,你会怎么画一个八边形而不是三角形?或者几个边数不同的正多边形,代码行尽量少?)

从命令提示符运行 Python 脚本

实际上,有几种方法可以运行你的程序。首先,让我们假设您面前有一个 DOS 窗口或 UNIX shell 提示符,并且包含 Python 可执行文件的目录(在 Windows 中称为python.exe,在 UNIX 中称为python)或包含可执行文件的目录(在 Windows 中)已经放在您的PATH环境变量中。 7 同样,让我们假设上一节的脚本(hello.py)在当前目录中。然后,您可以在 Windows 中使用以下命令执行您的脚本:

C:\>python hello.py

或 UNIX:

$ python hello.py

如您所见,命令是相同的。只有系统提示改变。

让你的脚本像普通程序一样运行

有时,您希望像执行其他程序(如 web 浏览器或文本编辑器)一样执行 Python 程序(也称为脚本),而不是显式使用 Python 解释器。在 UNIX 中,有一种标准的方法:让脚本的第一行以字符序列#!(称为 pound bang 或 shebang)开始,后跟解释脚本的程序的绝对路径(在我们的例子中是 Python)。即使您不太明白这一点,如果您想让它在 UNIX 上轻松运行,只需在脚本的第一行中输入以下内容:

#!/usr/bin/env python

无论 Python 二进制文件位于何处,这都应该运行脚本。如果您安装了不止一个版本的 Python,您可以使用更具体的可执行文件名称,比如python3,而不是简单的python

在实际运行脚本之前,您必须使其可执行。

$ chmod a+x hello.py

现在它可以这样运行(假设您的路径中有当前目录):

$ hello.py

如果这不起作用,请尝试使用./hello.py来代替,即使当前目录(.)不在您的执行路径中(一个负责任的系统管理员可能会告诉您这不应该),这也会起作用。

如果你愿意,你可以重命名你的文件,去掉后缀py,让它看起来更像一个普通的程序。

双击呢?

在 Windows 中,后缀(.py)是让你的脚本像程序一样运行的关键。尝试双击您在上一节中保存的文件hello.py。如果 Python 安装正确,会出现一个 DOS 窗口,提示“你叫什么名字?”然而,这样运行你的程序有一个问题。一旦你输入了你的名字,程序窗口会在你阅读结果之前关闭。程序完成时,窗口关闭。尝试通过在末尾添加以下行来更改脚本:

input("Press <enter>")

现在,在运行程序并输入您的名字后,您应该有一个包含以下内容的 DOS 窗口:

What is your name? Gumby
Hello, Gumby!
Press <enter>

一旦你按下回车键,窗口关闭(因为程序已经完成)。

评论

散列符号(#)在 Python 中有点特殊。当你把它放入你的代码时,它右边的所有东西都被忽略了(这就是为什么 Python 解释器没有被之前使用的/usr/bin/env卡住)。这里有一个例子:

# Print the circumference of the circle:
print(2 * pi * radius)

这里的第一行被称为注释,它有助于让程序更容易理解——当你回到旧代码时,对其他人和你自己都是如此。据说程序员的第一条戒律是“你应该评论”(尽管一些不太仁慈的程序员发誓说“如果很难写,就应该很难读”)。确保你的评论是有意义的,不要简单地重复代码中已经很明显的内容。无用的、多余的评论可能比没有更糟糕。例如,在下面的例子中,并不真正需要注释:

# Get the user's name:
user_name = input("What is your name?")

即使没有注释,让代码本身也是可读的,这总是一个好主意。幸运的是,Python 是编写可读程序的优秀语言。

用线串

那些东西是怎么回事?本章的第一个程序很简单

print("Hello, world!")

在编程教程中,习惯上是从这样的程序开始。问题是我还没有真正解释它是如何工作的。你知道print语句的基本知识(我稍后会对此进行更多的说明),但是什么是"Hello, world!"?它被称为字符串(如“一串字符”)。几乎每一个有用的、真实世界的 Python 程序中都有字符串,并且有许多用途。它们的主要用途是表示文本,例如感叹词“你好,世界!”

单引号字符串和转义引号

字符串是值,就像数字一样:

>>> "Hello, world!"
'Hello, world!'

不过,这个例子中有一点可能有点令人惊讶:当 Python 打印出我们的字符串时,它使用了单引号,而我们使用了双引号。有什么区别?其实没什么区别。

>>> 'Hello, world!'
'Hello, world!'

这里我们用单引号,结果是一样的。那么,为什么两者都允许呢?因为在某些情况下可能有用。

>>> "Let's go!"
"Let's go!"
>>> '"Hello, world!" she said'
'"Hello, world!" she said'

在前面的代码中,第一个字符串包含一个单引号(或者一个撇号,在这个上下文中我们应该这样称呼它),因此我们不能用单引号将字符串括起来。如果我们这样做了,翻译会抱怨(这是理所当然的)。

>>> 'Let's go!'
SyntaxError: invalid syntax

在这里,字符串是'Let',Python 不太知道如何处理后面的s(或者说,该行的其余部分)。

在第二个字符串中,我们使用双引号作为句子的一部分。因此,我们必须用单引号将字符串括起来,原因与前面所述相同。或者,实际上我们不必。只是方便而已。另一种方法是使用反斜杠字符(\)来转义字符串中的引号,如下所示:

>>> 'Let\'s go!'
"Let's go!"

Python 知道中间的单引号是字符串中的一个字符,而不是字符串的结尾。(尽管如此,Python 还是选择在打印字符串时使用双引号。)如你所料,双引号也是如此。

>>> "\"Hello, world!\" she said"
'"Hello, world!" she said'

像这样的转义引用可能是有用的,有时也是必要的。例如,如果您的字符串既包含单引号又包含双引号,就像在字符串'Let\'s say "Hello, world!"'中一样,如果没有反斜杠,您会怎么做?

Note

厌倦了反斜杠?正如你将在本章后面看到的,你可以通过使用长字符串和原始字符串(可以组合)来避免它们中的大多数。

串联字符串

只是为了继续鞭笞这个有点折磨人的例子,让我给你看另一种写同样字符串的方法:

>>> "Let's say " '"Hello, world!"'
'Let\'s say "Hello, world!"'

我简单地写了两个字符串,一个接一个,Python 自动将它们连接起来(使它们成为一个字符串)。这种机制不常使用,但有时会很有用。然而,只有当你实际上同时写两个字符串,直接一个接一个的时候,它才起作用。

>>> x = "Hello, "
>>> y = "world!"
>>> x y
SyntaxError: invalid syntax

换句话说,这只是编写字符串的一种特殊方式,而不是连接字符串的通用方法。那么,如何连接字符串呢?就像你添加数字一样:

>>> "Hello, " + "world!"
'Hello, world!'
>>> x = "Hello, "
>>> y = "world!"
>>> x + y
'Hello, world!'

字符串表示,str 和 repr

在这些例子中,您可能已经注意到 Python 打印出的所有字符串仍然是带引号的。这是因为它打印出的值可能是用 Python 代码编写的,而不是用户希望的样子。但是,如果使用print,结果就不同了。

>>> "Hello, world!"
'Hello, world!'
>>> print("Hello, world!")
Hello, world!

如果我们加入特殊的换行符代码\n,这种差异会更加明显。

>>> "Hello,\nworld!"
'Hello,\nworld!'
>>> print("Hello,\nworld!")
Hello,
world!

值通过两种不同的机制转换为字符串。您可以通过使用函数strrepr来访问这两种机制。 9 使用str,你可以以某种用户可能理解的合理方式将一个值转换成一个字符串,例如,在可能的情况下,将任何特殊的字符代码转换成相应的字符。但是,如果使用repr,通常会得到合法 Python 表达式形式的值表示。

>>> print(repr("Hello,\nworld!"))
'Hello,\nworld!'
>>> print(str("Hello,\nworld!"))
Hello,
world!

长字符串、原始字符串和字节

有一些有用的、稍微专门化的编写字符串的方法。例如,有一个自定义的语法用于编写包含换行符(长字符串)或反斜杠(原始字符串)的字符串。在 Python 2 中,也有一个单独的语法来编写带有不同种类的特殊符号的字符串,产生unicode类型的对象。语法仍然有效,但现在是多余的,因为 Python 3 中的所有字符串都是 Unicode 字符串。相反,引入了一种新的语法来指定一个bytes对象,大致相当于老式的字符串。正如我们将看到的,这些在 Unicode 编码的处理中仍然扮演着重要的角色。

采油套管

如果你想写一个很长的字符串,跨越几行,你可以用三重引号代替普通引号。

print('''This is a very long string.  It continues here.
And it's not over yet.  "Hello, world!"
Still here.''')

也可以用三重双引号,"""like this"""。请注意,由于独特的括起的引号,单引号和双引号都允许包含在内,而不会被反斜杠转义。

Tip

普通的字符串也可以跨几行。如果一行的最后一个字符是反斜杠,换行符本身会被“转义”并被忽略。例如:

print("Hello, \ world!")

会打印出Hello, world!。一般来说,表达式和语句也是如此。

>>> 1 + 2 + \
    4 + 5
12
>>> print \
    ('Hello, world')
Hello, world

原始字符串

Raw strings 对反斜杠不太挑剔,有时候会非常有用。 10 在普通的字符串中,反斜杠有一个特殊的作用:它转义东西,让你把平时不能直接写的东西放到你的字符串中。例如,正如我们所见,一个换行符被写成\n并可以被放入一个字符串中,如下所示:

>>> print('Hello,\nworld!')
Hello,
world!

这通常很好,但在某些情况下,这不是你想要的。如果您希望字符串包含一个反斜杠,后跟一个n会怎么样?你可能想把 DOS 路径名C:\nowhere放到一个字符串中。

>>> path = 'C:\nowhere'
>>> path
'C:\nowhere'

这看起来是正确的,直到你打印出来并发现它的缺陷。

>>> print(path)
C:
owhere

这不是我们想要的,对吧?那我们该怎么办?我们可以对反斜杠本身进行转义。

>>> print('C:\\nowhere')
C:\nowhere

这很好。但是对于长路径,你会有很多反斜线。

path = 'C:\\Program Files\\fnord\\foo\\bar\\baz\\frozz\\bozz'

在这种情况下,原始字符串非常有用。他们根本不把反斜杠当作特殊字符。你放入原始字符串的每个字符都保持你写的样子。

>>> print(r'C:\nowhere')
C:\nowhere
>>> print(r'C:\Program Files\fnord\foo\bar\baz\frozz\bozz')
C:\Program Files\fnord\foo\bar\baz\frozz\bozz

如您所见,原始字符串带有前缀r。看起来你可以在一个原始字符串中放入任何东西,这几乎是真的。引号必须照常转义,尽管这意味着在最后的字符串中也会有一个反斜杠。

>>> print(r'Let\'s go!')
Let\'s go!

原始字符串中唯一不能有的是一个单独的最后一个反斜杠。换句话说,原始字符串中的最后一个字符不能是反斜杠,除非您对它进行转义(然后您用来转义它的反斜杠也将是字符串的一部分)。鉴于前面的例子,这应该是显而易见的。如果最后一个字符(在最后一个引号之前)是一个未转义的反斜杠,Python 不知道是否结束字符串。

>>> print(r"This is illegal\")
SyntaxError: EOL while scanning string literal

好的,这是合理的,但是如果你希望原始字符串的最后一个字符是反斜杠呢?(例如,这可能是 DOS 路径的结尾。)好了,在这一节中我已经给了你一整套技巧,应该可以帮助你解决那个问题,但是基本上你需要把反斜杠放在一个单独的字符串中。下面是一个简单的方法:

>>> print(r'C:\Program Files\foo\bar' '\\')
C:\Program Files\foo\bar\

请注意,您可以对原始字符串使用单引号和双引号。甚至三重引号字符串也可能是原始的。

Unicode、bytes 和 bytearray

Python 字符串使用一种称为 Unicode 的方案来表示文本。对于大多数 basic 程序来说,这种工作方式是相当透明的,所以如果你愿意,你现在可以跳过这一节,根据需要阅读这个主题。然而,由于字符串和文本文件处理是 Python 代码的主要用途之一,所以至少浏览一下这一部分可能不会有什么坏处。

抽象地说,每个 Unicode 字符都由一个所谓的码位表示,码位就是它在 Unicode 标准中的编号。这使你能够以任何现代软件都可以识别的方式引用 129 种书写系统中的 120,000 多个字符。当然,您的键盘不会有成千上万个键,所以有一些通用的机制来指定 Unicode 字符,要么通过 16 位或 32 位十六进制文字(分别以\u\U作为前缀),要么通过它们的 Unicode 名称(使用\N{ name })。

>>> "\u00C6"

'Æ'

>>> "\U0001F60A"

'☺'

>>> "This is a cat: \N{Cat}"

'This is a cat:A326949_3_En_1_Figa_HTML.jpg

你可以通过搜索网页找到各种代码点和名称,使用你需要的字符的描述,或者你可以使用一个特定的网站,如 http://unicode-table.com

Unicode 的概念非常简单,但是它带来了一些挑战,其中之一就是编码问题。所有对象在内存或磁盘上都表示为一系列二进制数字——0 和 1——以八个字节或字节为一组,字符串也不例外。在像 C 这样的编程语言中,这些字节是完全公开的。字符串只是字节序列。例如,为了与 C 互操作,以及将文本写入文件或通过网络套接字发送,Python 有两种类似的类型,不可变的bytes和可变的bytearray。如果你愿意,你可以使用前缀b直接产生一个字节对象,而不是一个字符串:

>>> b'Hello, world!'
b'Hello, world!'

然而,一个字节只能容纳 256 个值,比 Unicode 标准所要求的要少得多。Python bytes文字只允许 ASCII 标准的 128 个字符,剩余的 128 个字节值需要转义序列,如十六进制值0xf0(即240)的\xf0

似乎这里唯一的区别是我们可用的字母表的大小。然而,这并不准确。乍一看,ASCII 和 Unicode 似乎都指非负整数和字符之间的映射,但有一个微妙的区别:Unicode 码位被定义为整数,而 ASCII 字符是由数字和二进制编码定义的。这看起来完全不起眼的一个原因是,整数 0–255 和一个八位二进制数之间的映射是完全标准的,几乎没有回旋的余地。问题是,一旦我们超越了单字节,事情就没那么简单了。简单地将每个码位表示为相应的二进制数的直接概括可能不合适。不仅存在字节顺序的问题(即使在对整数值进行编码时也会遇到这一问题),还存在空间浪费的问题:如果我们对每个代码点使用相同数量的字节进行编码,那么所有的文本都必须适应这样一个事实,即您可能希望包含一些安纳托利亚象形文字或一点帝国阿拉姆语。这种 Unicode 编码有一个标准,称为 UTF-32(Unicode 转换格式 32 位),但是如果您主要处理的是互联网上一种更常见语言的文本,这就相当浪费了。

然而,有一个绝对聪明的选择,主要是由计算机先驱肯·汤普逊设计的。它不使用完整的 32 位,而是使用可变编码,一些脚本的字节数比其他的少。假设你会更经常地使用这些脚本,这将节省你的空间,就像莫尔斯电码通过使用更少的点和破折号来节省你的精力一样。 11 特别是,ASCII 编码仍然用于单字节编码,保留了与旧系统的兼容性。但是,超出此范围的字符使用多个字节(最多六个)。让我们尝试使用 ASCII、UTF-8 和 UTF-32 编码将字符串编码成字节。

>>> "Hello, world!".encode("ASCII")
b'Hello, world!'
>>> "Hello, world!".encode("UTF-8")
b'Hello, world!'
>>> "Hello, world!".encode("UTF-32")
b'\xff\xfe\x00\x00H\x00\x00\x00e\x00\x00\x00l\x00\x00\x00l\x00\x00\x00o\x00\x00\x00,\x00\x00\x00 \x00\x00\x00w\x00\x00\x00o\x00\x00\x00r\x00\x00\x00l\x00\x00\x00d\x00\x00\x00!\x00\x00\x00'

如你所见,前两个是相等的,而最后一个要长得多。这是另一个例子:

>>> len("How long is this?".encode("UTF-8"))
17
>>> len("How long is this?".encode("UTF-32"))
72

一旦我们使用一些稍微奇特的字符,ASCII 和 UTF-8 之间的区别就显现出来了:

>>> "Hællå, wørld!".encode("ASCII")
Traceback (most recent call last):
  ...
UnicodeEncodeError: 'ascii' codec can't encode character '\xe6' in position 1: ordinal not in range(128)

这里的斯堪的纳维亚字母在 ASCII 中没有编码。如果我们真的需要 ASCII 编码(这肯定会发生),我们可以向encode提供另一个参数,告诉它如何处理错误。这里的正常模式是'strict',但是您可以使用其他模式来忽略或替换违规字符。

>>> "Hællå, wørld!".encode("ASCII", "ignore")
b'Hll, wrld!'
>>> "Hællå, wørld!".encode("ASCII", "replace")
b'H?ll?, w?rld!'
>>> "Hællå, wørld!".encode("ASCII", "backslashreplace")
b'H\\xe6ll\\xe5, w\\xf8rld!'
>>> "Hællå, wørld!".encode("ASCII", "xmlcharrefreplace")
b'Hællå, wørld!'

不过,在几乎所有情况下,使用 UTF-8 会更好,事实上这甚至是默认的编码。

>>> "Hællå, wørld!".encode()
b'H\xc3\xa6ll\xc3\xa5, w\xc3\xb8rld!'

这比"Hello, world!"示例稍长,而 UTF-32 编码在两种情况下长度完全相同。

就像字符串可以编码成字节一样,字节也可以解码成字符串。

>>> b'H\xc3\xa6ll\xc3\xa5, w\xc3\xb8rld!'.decode()
'Hællå, wørld!'

和以前一样,默认编码是 UTF-8。我们可以指定一个不同的编码,但是如果我们使用了错误的编码,我们要么得到一个错误消息,要么得到一个乱码字符串。对象本身不知道编码,所以你有责任跟踪你使用了哪一种编码。

与其使用encodedecode方法,不如简单地构造bytesstr(即字符串)对象,如下所示:

>>> bytes("Hællå, wørld!", encoding="utf-8")
b'H\xc3\xa6ll\xc3\xa5, w\xc3\xb8rld!'
>>> str(b'H\xc3\xa6ll\xc3\xa5, w\xc3\xb8rld!', encoding="utf-8")
'Hællå, wørld!'

如果您不确切知道您正在处理的类似字符串或类似字节的对象的类,那么使用这种方法会更通用一些,并且作为一般规则,您不应该对此太严格。

编码和解码最重要的用途之一是将文本存储在磁盘上的文件中。然而,Python 读写文件的机制通常会为您完成这项工作!只要你能接受 UTF-8 编码的文件,你就不需要担心这个问题。但是,如果您最终在应该是文本的地方看到了乱码,也许文件实际上是其他编码的,那么了解一下正在发生的事情会很有用。如果您想了解更多关于 Python 中 Unicode 的知识,请查看该主题的指南。 12

Note

你的源代码也是编码的,默认也是 UTF 8。如果您想使用其他编码(例如,如果您的文本编辑器坚持保存为除 UTF-8 以外的其他格式),您可以用特殊的注释来指定编码。

# -*- coding: encoding name -*-

encoding name替换为您正在使用的任何编码(大写或小写),例如utf-8或者更有可能是latin-1

最后,我们有bytearray,它是bytes的可变版本。在某种意义上,它就像一个可以修改字符的字符串——这是普通字符串做不到的。然而,它实际上更多地被设计成在幕后使用,如果作为类似字符串使用,就不太用户友好。例如,要替换一个字符,你必须给它分配一个在 0…255 范围内的int。所以如果你想真正插入一个字符,你必须得到它的序数值,使用ord

>>> x = bytearray(b"Hello!")
>>> x[1] = ord(b"u")
>>> x
bytearray(b'Hullo!')

快速总结

这一章涵盖了相当多的材料。在继续之前,让我们看看你学到了什么。

  • 算法:算法是告诉你如何执行一项任务的处方。当你给计算机编程时,你实际上是在用计算机能理解的语言描述一种算法,比如 Python。这种机器友好的描述称为程序,它主要由表达式和语句组成。
  • 表达式:表达式是代表一个值的计算机程序的一部分。例如,2 + 2是一个表达式,代表值4。简单表达式是通过使用运算符(如+%)和函数(如pow)从文字值(如2"Hello")构建的。更复杂的表达式可以通过组合更简单的表达式来创建(例如(2 + 2) * (3 - 1))。表达式也可能包含变量。
  • 变量:变量是代表一个值的名称。可以通过x = 2之类的赋值将新值赋给变量。作业是一种陈述。
  • 语句:语句是告诉计算机做某事的指令。这可能涉及到改变变量(通过赋值),把东西打印到屏幕上(比如print("Hello, world!")),导入模块,或者做许多其他的事情。
  • 函数:Python 中的函数就像数学中的函数一样:它们可以接受一些参数,然后返回一个结果。(在返回之前,它们实际上可能会做很多有趣的事情,当你在第六章中学习编写自己的函数时,你会发现这一点。)
  • 模块:模块是可以导入 Python 来扩展其功能的扩展。例如,math模块中有几个有用的数学函数。
  • 程序:您已经看到了编写、保存和运行 Python 程序的实用性。
  • 字符串:字符串非常简单——它们只是一些文本,用 Unicode 码表示字符。然而关于它们还有很多需要了解的。在这一章中,你已经看到了许多编写它们的方法,在第三章中,你将学到许多使用它们的方法。

本章的新功能

| 功能 | 描述 | | --- | --- | | `abs(number)` | 返回一个数字的绝对值。 | | `bytes(string, encoding[, errors])` | 用指定的错误行为对给定的字符串进行编码。 | | `cmath.sqrt(number)` | 返回平方根;适用于负数。 | | `float(object)` | 将字符串或数字转换为浮点数。 | | `help([object])` | 提供交互式帮助。 | | `input(prompt)` | 以字符串形式从用户处获取输入。 | | `int(object)` | 将字符串或数字转换为整数。 | | `math.ceil(number)` | 以浮点数形式返回一个数的上限。 | | `math.floor(number)` | 以浮点形式返回数字的下限。 | | `math.sqrt(number)` | 返回平方根;对负数不起作用。 | | `pow(x, y[, z])` | 返回 x 的 y 次方(以 z 为模)。 | | `print(object, ...)` | 打印参数,用空格分隔。 | | `repr(object)` | 返回值的字符串表示形式。 | | `round(number[, ndigits])` | 将数字舍入到给定的精度,并舍入到偶数。 | | `str(object)` | 将值转换为字符串。如果从`bytes`转换,您可以指定编码和错误行为。 |

方括号中的参数是可选的。

什么现在?

现在你已经知道了表达式的基础,让我们继续学习更高级的东西:数据结构。与处理简单的值(比如数字)不同,您将看到如何将它们组合成更复杂的结构,比如列表和字典。此外,您将进一步了解字符串。在第五章中,你会学到更多关于语句的知识,之后你就可以编写一些真正漂亮的程序了。

Footnotes 1

黑魔法攻击不同于破解,破解是一个描述计算机犯罪的术语。两者经常混淆,用法也在逐渐变化。正如我在这里所使用的,黑魔法的基本意思是“一边编程一边享受乐趣”

2

毕竟,没人指望西班牙宗教裁判所。。。

3

稍微简单一点的是,标识符名称的规则部分基于 Unicode 标准,正如在 https://docs.python.org/3/reference/lexical_analysis.html 的 Python 语言参考中所记录的。

4

如果你想知道——是的,它确实做了一些事情。它计算 2 和 2 的乘积。但是,结果不会保存在任何地方,也不会显示给用户;除了计算本身,它没有任何副作用。

5

请注意关于存储的报价。值不是存储在变量中的——它们存储在计算机内存的某个阴暗的深处,由变量引用。随着阅读的深入,你会发现不止一个变量可以引用同一个值。

6

如果您只是忽略返回值,函数调用也可以用作语句。

7

如果你不理解这句话,你也许应该跳过这一节。你其实不需要。

8

这种行为取决于您的操作系统和安装的 Python 解释器。例如,如果你已经在 macOS 中使用 IDLE 保存了文件,双击该文件就可以在 IDLE 代码编辑器中打开它。

9

其实,str是一个类,然而就像int. repr是一个函数一样。

10

在编写正则表达式时,原始字符串可能特别有用。你可以在第十章中了解更多。

11

这通常是一种重要的压缩方法,例如在霍夫曼编码中使用,霍夫曼编码是几种现代压缩工具的组成部分。

12

https://docs.python.org/3/howto/unicode.html

二、列表和元组

本章介绍了一个新概念:数据结构。数据结构是以某种方式构造的数据元素(例如数字或字符,甚至其他数据结构)的集合,例如通过对元素进行编号。Python 中最基本的数据结构是序列。序列中的每个元素都被分配了一个数字,即它的位置或索引。第一个索引是零,第二个索引是一,依此类推。一些编程语言从 1 开始给它们的序列元素编号,但是零索引约定有一个从序列开始的偏移的自然解释,负索引绕到结尾。如果你觉得这种编号有点奇怪,我可以保证你很快就会习惯。

本章从序列的概述开始,然后涵盖所有序列共有的一些操作,包括列表和元组。这些操作也适用于字符串,字符串将在一些例子中使用,尽管要完整地处理字符串操作,你必须等到下一章。处理完这些基础知识后,我们开始处理列表,看看它们有什么特别之处。在列表之后,我们来到元组,一种特殊用途的序列类型,类似于列表,除了你不能改变它们。

序列概述

Python 有几种内置的序列类型。本章集中讨论两个最常见的:列表和元组。字符串是另一种重要的类型,我将在下一章再次讨论。

列表和元组的主要区别在于,你可以改变列表,但不能改变元组。这意味着,如果您需要在进行过程中添加元素,列表可能是有用的,而如果出于某种原因,您不允许序列改变,元组可能是有用的。后者的原因通常是技术性的,与 Python 内部的工作方式有关。这就是为什么你可能会看到内置函数返回元组。对于你自己的程序,几乎在所有情况下你都可以用列表代替元组。(如第四章所述,一个显著的例外是使用元组作为字典键。这些列表是不允许的,因为不允许你修改键。)

当您想要处理值的集合时,序列非常有用。您可能有一个序列代表数据库中的一个人,第一个元素是他们的名字,第二个元素是他们的年龄。写成一个列表(列表中的项目用逗号分隔,并用方括号括起来),如下所示:

>>> edward = ['Edward Gumby', 42]

但是序列也可以包含其他序列,所以你可以列出这样的人,这就是你的数据库。

>>> edward = ['Edward Gumby', 42]
>>> john = ['John Smith', 50]
>>> database = [edward,  john]
>>> database
[['Edward  Gumby', 42], ['John Smith', 50]]

Note

Python 有一种称为容器的数据结构的基本概念,容器基本上是可以包含其他对象的任何对象。两种主要的容器是序列(比如列表和元组)和映射(比如字典)。虽然序列的元素是编号的,但是映射中的每个元素都有一个名称(也称为键)。在第四章中你会学到更多关于映射的知识。关于既不是序列也不是映射的容器类型的例子,请参见第十章中对集合的讨论。

常见顺序操作

您可以对所有序列类型执行某些操作。这些操作包括索引、切片、加法、乘法和成员检查。此外,Python 具有用于查找序列长度以及查找其最大和最小元素的内置函数。

Note

这里没有提到的一个重要操作是迭代。迭代序列意味着重复执行某些操作,对序列中的每个元素重复一次。要了解更多信息,请参见第五章中的“循环”一节。

索引

序列中的所有元素都从零开始向上编号。您可以使用一个数字单独访问它们,如下所示:

>>> greeting = 'Hello'
>>> greeting[0]
'H'

Note

字符串只是一系列字符。索引 0 指的是第一个元素,在本例中是字母 h。但是,与其他一些语言不同,它没有单独的字符类型。字符只是一个单元素字符串。

这就是所谓的索引。您使用索引来获取元素。所有的序列都可以用这种方式索引。当使用负索引时,Python 从右边开始计数,即从最后一个元素开始计数。最后一个元素位于位置–1。

>>> greeting[-1]
'o'

字符串文字(和其他序列文字,就此而言)可以直接索引,而不使用变量来引用它们。效果完全一样。

>>> 'Hello'[1]
'e'

如果函数调用返回一个序列,你可以直接索引它。例如,如果您只是对用户输入的年份的第四位数字感兴趣,您可以这样做:

>>> fourth = input('Year: ')[3]
Year: 2005
>>> fourth
'5'

清单 2-1 包含一个示例程序,它询问您年份、月份(从 1 到 12 的数字)和日期(1 到 31),然后打印出带有正确月份名称的日期等等。

# Print out a date, given year, month, and day as numbers

months = [
    'January',
    'February',
    'March',
    'April',
    'May',
    'June',
    'July',
    'August',
    'September',
    'October',
    'November',
    'December'
]

# A list with one ending for each number from 1 to 31
endings = ['st', 'nd', 'rd'] + 17 * ['th'] \
        + ['st', 'nd', 'rd'] +  7 * ['th'] \
        + ['st']

year    = input('Year: ')
month   = input('Month  (1-12): ')
day     = input('Day (1-31): ')

month_number = int(month)
day_number = int(day)

# Remember to subtract 1 from month and day to get a correct index
month_name = months[month_number-1]
ordinal = day + endings[day_number-1]

print(month_name + ' ' + ordinal + ', ' + year)

Listing 2-1.Indexing Example

使用此程序的会话示例如下:

Year: 1974
Month (1-12): 8
Day (1-31): 16
August 16th, 1974

最后一行是程序的输出。

限幅

正如您使用索引来访问单个元素一样,您也可以使用切片来访问一定范围的元素。您可以通过使用两个索引来实现,用冒号分隔。

>>> tag = '<a href="http://www.python.org">Python web site</a>'
>>> tag[9:30]
'http://www.python.org'
>>> tag[32:-4]
'Python web site'

如您所见,切片对于提取部分序列非常有用。这里的编号非常重要。第一个索引是要包含的第一个元素的编号。但是,最后一个索引是切片后第一个元素的编号。请考虑以下几点:

>>> numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
>>> numbers[3:6] [4, 5, 6]
>>> numbers[0:1] [1]

简而言之,您提供两个指数作为切片的限制,其中第一个是包含性的,第二个是排他性的。

一条漂亮的捷径

假设您想要访问数字的最后三个元素(来自前面的示例)。当然,你可以明确地这样做。

>>> numbers[7:10]
[8, 9, 10]

现在,索引 10 引用了元素 11——它并不存在,但在您想要的最后一个元素之后一步。明白了吗?如果你想从末尾开始数,你可以使用负指数。

>>> numbers[-3:-1]
[8, 9]

然而,看起来你不能这样访问最后一个元素。用 0 作为终点“一步之遥”的元素怎么样?

>>> numbers[-3:0]
[]

这不完全是想要的结果。事实上,任何时候一个切片中最左边的索引在序列中比第二个晚(在这种情况下,倒数第三个比第一个晚),结果总是一个空序列。幸运的是,您可以使用一个快捷方式:如果切片继续到序列的末尾,您可以简单地省略最后一个索引。

>>> numbers[-3:]
[8, 9, 10]

同样的事情从一开始就起作用。

>>> numbers[:3]
[1, 2, 3]

事实上,如果你想复制整个序列,你可以省去两个索引。

>>> numbers[:]
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

清单 2-2 包含一个小程序,提示您输入一个 URL(假设它是相当有限的形式 http://www.somedomainname.com )并提取域名。

# Split up a URL of the form http://www.something.com

url = input('Please enter the URL:')
domain = url[11:-4]

print("Domain name: " + domain)

Listing 2-2.Slicing Example

以下是该程序的运行示例:

Please enter the URL: http://www.python.org
Domain name: python

更长的台阶

切片时,可以指定(显式或隐式)切片的起点和终点。另一个参数通常是隐式的,就是步长。在常规切片中,步长为 1,这意味着切片从一个元素“移动”到下一个元素,返回开始和结束之间的所有元素。

>>> numbers[0:10:1]
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

在本例中,您可以看到切片包含另一个数字。正如你可能已经猜到的,这是明确的步长。如果步长设置为大于 1 的数,元素将被跳过。例如,步长为 2 将只包括开始和结束之间间隔的每隔一个元素。

>>> numbers[0:10:2]
[1, 3, 5, 7, 9]
numbers[3:6:3]
[4]

您仍然可以使用前面提到的快捷方式。例如,如果您想要序列中的第四个元素,您只需要提供步长为 4 的元素。

>>> numbers[::4]
[1, 5, 9]

自然地,步长不能为零——这不会让你去任何地方——但是它可以是负的,这意味着从右到左提取元素。

>>> numbers[8:3:-1]
[9, 8, 7, 6, 5]
>>> numbers[10:0:-2]
[10, 8, 6, 4, 2]
>>> numbers[0:10:-2]
[]
>>> numbers[::-2]
[10, 8, 6, 4, 2]
>>> numbers[5::-2]
[6, 4, 2]
>>> numbers[:5:-2]
[10, 8]

把事情做好可能需要一点思考。可以看到,第一个限制(最左边的)仍然是包含性的,而第二个限制(最右边的)是排他性的。当使用负步长时,您需要有一个高于第二个的第一个限制(起始索引)。可能有点令人困惑的是,当您隐式保留开始和结束索引时,Python 会做“正确的事情”——对于正步长,它会从开始向结束移动,对于负步长,它会从结束向开始移动。

添加序列

序列可以用加法(加)运算符连接。

>>> [1, 2, 3] + [4, 5,  6]
[1, 2, 3, 4, 5, 6]
>>> 'Hello,' + 'world!'
'Hello, world!'
>>> [1, 2, 3] + 'world!'
Traceback (innermost last):
 File "<pyshell>", line 1, in ?
    [1, 2, 3] + 'world!'
TypeError: can only concatenate list (not "string") to list

正如您在错误消息中看到的,您不能连接一个列表和一个字符串,尽管两者都是序列。一般来说,您不能连接不同类型的序列。

增加

将一个序列乘以数字 x 会创建一个新序列,其中原始序列重复 x 次:

>>> 'python' * 5
'pythonpythonpythonpythonpython'
>>> [42] * 10
[42, 42, 42, 42, 42, 42, 42, 42, 42, 42]

无、空列表和初始化

空列表简单地写成两个括号([])——里面什么也没有。如果你想要一个有 10 个元素的列表,但是里面没有任何有用的东西,你可以像以前一样使用[42]*10,或者更实际的使用[0]*10。你现在有一个包含十个零的列表。然而,有时您可能想要一个表示“什么都没有”的值,比如“我们还没有在这里放任何东西。”这时你使用的None. None是一个 Python 值,意思就是“这里什么都没有”所以如果你想初始化一个长度为 10 的列表,你可以这样做:

>>> sequence = [None] * 10
>>> sequence
[None, None, None, None, None, None, None, None, None, None]

清单 2-3 包含一个程序,它打印(到屏幕上)一个由字符组成的“框”,这个框在屏幕上居中,并适应用户提供的句子的大小。代码可能看起来很复杂,但基本上只是算术——计算出需要多少空格、破折号等等,以便正确放置。

# Prints a sentence in a centered "box" of correct width

sentence = input("Sentence: ")

screen_width = 80
text_width   = len(sentence)
box_width    = text_width + 6
left_margin  = (screen_width - box_width) // 2

print()
print(' ' * left_margin + '+'   + '-' * (box_width-2)  +   '+')
print(' ' * left_margin + '|  ' + ' ' * text_width     + '  |')
print(' ' * left_margin + '|  ' +       sentence       + '  |')
print(' ' * left_margin + '|  ' + ' ' * text_width     + '  |')
print(' ' * left_margin + '+'   + '-' * (box_width-2)  +   '+')
print()

Listing 2-3.Sequence (String) Multiplication Example

以下是运行示例:

Sentence: He's a very  naughty boy!

                        +-----------------------------+
                        |                             |
                        |  He's a very  naughty boy!  |
                        |                             |
                        +-----------------------------+

成员资格

要检查一个值是否能在序列中找到,可以使用in操作符。这个运算符与目前讨论的有些不同(比如乘法或加法)。它检查某事是否为真,并相应地返回一个值:True为真,False为假。这样的运算符称为布尔运算符,真值称为布尔值。在第五章的条件语句一节中,你会学到更多关于布尔表达式的知识。

下面是一些使用in操作符的例子:

>>> permissions = 'rw'
>>> 'w' in permissions
True
>>> 'x' in permissions
False
>>> users = ['mlh', 'foo', 'bar']
>>> input('Enter your user name: ') in users
Enter your user name: mlh
True
>>> subject = '$$$ Get rich now!!! $$$'
>>> '$$$' in subject
True

前两个例子使用成员测试来检查'w''x'是否分别出现在字符串permissions中。这可能是 UNIX 机器上的一个脚本,用于检查文件的写入和执行权限。下一个示例检查提供的用户名(mlh)是否在用户列表中。如果你的程序执行一些安全策略,这可能是有用的。(在这种情况下,您可能也想使用密码。)最后一个例子检查字符串subject是否包含字符串'$$$'。例如,这可以用作垃圾邮件过滤器的一部分。

Note

检查字符串是否包含' $$$ '的示例与其他示例略有不同。一般来说,in操作符检查一个对象是否是一个序列(或其他集合)的成员(即一个元素)。然而,字符串的唯一成员或元素是它的字符。所以,下面的话很有道理:

>>> 'P' in 'Python'
True

事实上,在 Python 的早期版本中,这是唯一处理字符串的成员资格检查——找出一个字符是否在字符串中。现在,您可以使用in操作符来检查任何字符串是否是另一个字符串的子字符串。

清单 2-4 显示了一个程序,它读入一个用户名,并对照一个数据库(实际上是一个列表)检查输入的 PIN 码,该数据库包含成对的(更多的列表)用户名和 PIN 码。如果在数据库中找到名称/PIN 对,则打印字符串'Access granted'。(在第一章中提到了if声明,并将在第五章中进行全面解释。)

# Check a user name and PIN code

database = [
    ['albert',  '1234'],
    ['dilbert', '4242'],
    ['smith',   '7524'],
    ['jones',   '9843']
]

username = input('User name: ')
pin = input('PIN code: ')

if [username, pin] in database: print('Access granted')

Listing 2-4.Sequence Membership Example

长度、最小值和最大值

内置函数lenminmax可能非常有用。函数len返回一个序列包含的元素数量。minmax分别返回序列的最小和最大元素。(在第五章的“比较操作符”一节中,你会学到更多关于比较对象的知识)

>>> numbers = [100, 34, 678]
>>> len(numbers)
3
>>> max(numbers)
678
>>> min(numbers)
34
>>> max(2, 3)
3
>>> min(9, 3, 2, 5)
2

除了可能的最后两个表达式之外,从前面的解释中应该清楚这是如何工作的。在这些函数中,maxmin不是用序列参数调用的;这些数字直接作为参数提供。

列表:Python 的主力

在前面的例子中,我使用了很多列表。您已经看到了它们是多么有用,但是本节讨论的是它们与元组和字符串的不同之处:列表是可变的——也就是说,您可以更改它们的内容——并且它们有许多有用的专用方法。

列表功能

因为字符串不能像列表一样被修改,所以有时从字符串创建列表会很有用。您可以使用list功能来完成此操作。1

>>> list('Hello')
['H', 'e', 'l', 'l', 'o']

注意list适用于所有类型的序列,而不仅仅是字符串。

Tip

要将字符列表(如前面的代码)转换回字符串,可以使用以下表达式:

''.join(somelist)

其中somelist是您的列表。关于这真正含义的解释,参见第三章中关于join的部分。

基本列表操作

您可以对列表执行所有标准的序列操作,如索引、切片、连接和乘法。但是列表的有趣之处在于它们可以被修改。在本节中,您将看到一些更改列表的方法:项目分配、项目删除、切片分配和列表方法。(注意,并不是所有的列表方法都会改变它们的列表。)

更改列表:项目分配

更改列表很容易。你只需使用普通赋值,如第一章所述。但是,您不用编写类似于x = 2的代码,而是使用索引符号来指定一个特定的现有位置,比如x[1] = 2

>>> x = [1, 1, 1]
>>> x[1] = 2
>>> x
[1, 2, 1]

Note

您不能分配到不存在的职位;如果您的列表长度为 2,则不能为索引 100 赋值。为此,您必须创建一个长度为 101(或更长)的列表。请参阅本章前面的“无、空列表和初始化”一节。

删除元素

从列表中删除元素也很容易。您可以简单地使用del语句。

>>> names = ['Alice', 'Beth', 'Cecil', 'Dee-Dee', 'Earl']
>>> del names[2]
>>> names
['Alice', 'Beth', 'Dee-Dee', 'Earl']

请注意塞西尔是如何完全消失的,列表的长度从五个缩减到四个。del语句可以用来删除列表元素以外的东西。它可以用于字典(见第四章)甚至变量。更多信息,请参见第五章。

分配给切片

切片是一个非常强大的特性,而且您可以为切片赋值,这使得它更加强大。

>>> name = list('Perl')
>>> name
['P', 'e', 'r', 'l']
>>> name[2:] = list('ar')
>>> name
['P', 'e', 'a', 'r']

所以你可以一次分配到几个位置。你可能想知道这有什么大不了的。你就不能一次分配一个给他们吗?当然,但是当您使用片段分配时,您也可以用长度不同于原始长度的序列替换片段。

>>> name = list('Perl')
>>> name[1:] = list('ython')
>>> name
['P', 'y', 't', 'h', 'o', 'n']

切片分配甚至可以用于插入元素,而无需替换任何原始元素。

>>> numbers = [1, 5]
>>> numbers[1:1] = [2, 3, 4]
>>> numbers
[1, 2, 3, 4, 5]

在这里,我基本上“替换”了一个空切片,从而真正插入了一个序列。您可以执行相反的操作来删除切片。

>>> numbers
[1, 2, 3, 4, 5]
>>> numbers[1:4] = []
>>> numbers
[1, 5]

你可能已经猜到了,最后这个例子相当于del numbers[1:4]。(现在为什么不尝试步长不为 1 的切片赋值呢?甚至可能是负面的?)

列出方法

方法是一个与某个对象紧密耦合的函数,可以是一个列表、一个数字、一个字符串或其他任何东西。一般来说,方法是这样调用的:

object.method(arguments)

一个方法调用看起来就像一个函数调用,除了对象放在方法名的前面,用一个点把它们分开。(在第七章中,你会得到关于什么是真正的方法的更详细的解释。)列表有几种方法允许您检查或修改其内容。

附加

方法用于将一个对象附加到列表的末尾。

>>> lst = [1, 2, 3]
>>> lst.append(4)
>>> lst
[1, 2, 3, 4]

你可能想知道为什么我选择了一个如此丑陋的名字作为我的名单。为什么不叫list?我可以这样做,但是您可能还记得,list是一个内置函数。如果我使用列表的名字,我就不能再调用这个函数了。对于给定的应用,您通常可以找到更好的名称。一个名字比如lst真的不能告诉你什么。例如,如果你的列表是一个价格列表,你可能应该称它为pricesprices_of_eggspricesOfEggs

同样重要的是要注意到,append像几个类似的方法一样,就地改变列表。这意味着它不是简单地返回一个新的、修改过的列表;而是直接修改旧的。这通常是您想要的,但有时可能会带来麻烦。当我在本章后面描述sort时,我将回到这个讨论。

清楚的

方法就地清除列表的内容。

>>> lst = [1, 2, 3]
>>> lst.clear()
>>> lst
[]

类似于切片赋值lst[:] = []

复制

方法复制一个列表。回想一下,普通的赋值只是将另一个名字绑定到同一个列表。

>>> a = [1, 2, 3]
>>> b = a
>>> b[1] = 4
>>> a
[1, 4, 3]

如果你想让ab成为单独的列表,你必须将b绑定到a的副本上。

>>> a = [1, 2, 3]
>>> b = a.copy()
>>> b[1] = 4
>>> a
[1, 2, 3]

这类似于使用a[:]list(a),两者都会复制a

数数

方法统计一个元素在列表中出现的次数。

>>> ['to', 'be', 'or', 'not', 'to',  'be'].count('to')
2
>>> x = [[1, 2], 1,  1,  [2, 1,  [1, 2]]]
>>> x.count(1)
2
>>> x.count([1, 2])
1

扩展

extend方法允许您通过提供一系列想要追加的值来一次追加几个值。换句话说,你原来的列表被另一个扩展了。

>>> a = [1, 2, 3]
>>> b = [4, 5, 6]
>>> a.extend(b)
>>> a
[1, 2, 3, 4, 5, 6]

这看起来类似于串联,但重要的区别是扩展序列(在本例中为a)被修改了。这与普通的连接不同,在普通的连接中会返回一个全新的序列。

>>> a = [1, 2, 3]
>>> b = [4, 5, 6]
>>> a + b
[1, 2, 3, 4, 5, 6]
>>> a
[1, 2, 3]

如您所见,连接列表看起来与上一个例子中的扩展列表完全相同,但是这次a没有改变。因为普通的连接必须创建一个包含ab副本的新列表,如果您想要的是这样的内容,那么它就不如使用extend有效:

>>> a = a + b

此外,这不是一个就地操作——它不会修改原始文件。extend的效果可以通过分配给切片来实现,如下所示:

>>> a = [1, 2, 3]
>>> b = [4, 5, 6]
>>> a[len(a):] = b
>>> a
[1, 2, 3, 4, 5, 6]

虽然这种方法有效,但可读性不太好。

指数

index方法用于搜索列表,以找到某个值第一次出现的索引。

>>> knights = ['We', 'are', 'the', 'knights', 'who', 'say', 'ni']
>>> knights.index('who')
4
>>> knights.index('herring')
Traceback (innermost last):
  File "<pyshell>", line 1, in ?
    knights.index('herring')
ValueError: list.index(x): x not in list

当您搜索单词'who'时,您会发现它位于索引 4。

>>> knights[4]
'who'

但是,当你搜索'herring'时,你会得到一个异常,因为根本找不到这个单词。

插入

方法用于将一个对象插入到一个列表中。

>>> numbers = [1, 2, 3, 5, 6, 7]
>>> numbers.insert(3, 'four')
>>> numbers
[1, 2, 3, 'four', 5, 6, 7]

extend一样,您可以用片分配来实现insert

>>> numbers = [1, 2, 3, 5, 6, 7]
>>> numbers[3:3] = ['four']
>>> numbers
[1, 2, 3, 'four', 5, 6, 7]

这可能很奇特,但是它很难像使用insert那样易读。

流行音乐

pop方法从列表中移除一个元素(默认情况下是最后一个)并返回它。

>>> x = [1, 2, 3]
>>> x.pop()
3
>>> x
[1, 2]
>>> x.pop(0)
1
>>> x
[2]

Note

pop 方法是唯一一个既修改列表又返回值的列表方法(除了None)。

使用pop,您可以实现一个称为堆栈的公共数据结构。像这样的一堆工作起来就像一堆盘子。你可以把盘子放在上面,也可以把盘子从上面拿走。最后一个放入堆栈的是第一个被移除的。(这个原则叫做后进先出,或 LIFO。)

两种堆栈操作(放入和取出)通常被称为 push 和 pop。Python 没有 push,但是可以用append代替。popappend方法反转彼此的结果,所以如果你压入(或追加)刚刚弹出的值,你会得到相同的堆栈。

>>> x = [1, 2, 3]
>>> x.append(x.pop())
>>> x
[1, 2, 3]

Tip

如果你想要一个先进先出(FIFO)队列,你可以用insert(0, ...)代替append。或者,你可以继续使用append,但是用pop(0)代替pop()。更好的解决方案是使用集合模块中的deque。更多信息见第十章。

移动

remove方法用于删除第一次出现的值。

>>> x = ['to', 'be', 'or', 'not', 'to', 'be']
>>> x.remove('be')
>>> x
['to', 'or', 'not', 'to', 'be']
>>> x.remove('bee')
Traceback (innermost last):
 File "<pyshell>", line 1, in ?
  x.remove('bee')
ValueError: list.remove(x): x not in list

如您所见,只有第一个匹配项被删除,如果它不在列表中,您就不能删除它(在本例中是字符串'bee')。

需要注意的是,这是一种“不返回原位更改”的方法。它修改列表,但不返回任何内容(与pop相反)。

反面的

reverse方法反转列表中的元素。(我想这并不奇怪。)

>>> x = [1, 2, 3]
>>> x.reverse()
>>> x
[3, 2, 1]

注意,reverse改变了列表并且不返回任何东西(例如,就像removesort)。

Tip

如果你想反向迭代一个序列,你可以使用reversed函数。不过,这个函数不返回列表;它返回一个迭代器。(你会在第九章学到更多关于迭代器的知识。)可以用list转换返回的对象。

>>> x = [1, 2, 3]
>>> list(reversed(x))
[3, 2, 1]

分类

sort方法用于就地排序列表。 3 排序“就地”意味着改变原始列表,使其元素按排序顺序排列,而不是简单地返回列表的排序副本。

>>> x = [4, 6, 2, 1, 7, 9]
>>> x.sort()
>>> x
[1, 2, 4, 6, 7, 9]

您已经遇到了几个修改列表而不返回任何内容的方法,在大多数情况下,这种行为是很自然的(例如,append)。但是我想在sort的例子中强调一下这种行为,因为很多人似乎被它迷惑了。当用户想要一个列表的排序副本,而不去管原始列表时,通常会出现这种混乱。一种直观(但错误)的方法如下:

>>> x = [4, 6, 2, 1, 7, 9]
>>> y = x.sort() # Don't do this!
>>> print(y)
None

因为sort修改了x但没有返回任何内容,所以最终得到一个排序的x和一个包含Noney。一种正确的做法是首先将y绑定到x的副本,然后对y进行排序,如下所示:

>>> x = [4, 6, 2, 1, 7, 9]
>>> y = x.copy()
>>> y.sort()
>>> x
[4, 6, 2, 1, 7, 9]
>>> y
[1, 2, 4, 6, 7, 9]

简单地将x赋给y是行不通的,因为xy会引用同一个列表。另一种获得列表排序副本的方法是使用sorted函数。

>>> x = [4, 6, 2, 1, 7, 9]
>>> y = sorted(x)
>>> x
[4, 6, 2, 1, 7, 9]
>>> y
[1, 2, 4, 6, 7, 9]

这个函数实际上可以用在任何序列上,但总是返回一个列表。 4

>>> sorted('Python')
['P', 'h', 'n', 'o', 't', 'y']

如果您想对元素进行逆序排序,您可以使用sort(或sorted),然后调用reverse方法,或者您可以使用reverse参数,如下一节所述。

高级排序

sort方法有两个可选参数:keyreverse。如果您想使用它们,通常通过名称来指定它们(所谓的关键字参数;你会在第六章了解更多。key参数类似于cmp参数:你提供一个函数,它被用在排序过程中。但是,函数不是直接用于确定一个元素是否小于另一个元素,而是用于为每个元素创建一个键,并根据这些键对元素进行排序。因此,举例来说,如果您想根据元素的长度对它们进行排序,您可以使用len作为键函数。

>>> x = ['aardvark', 'abalone', 'acme', 'add', 'aerate']
>>> x.sort(key=len)
>>> x
['add', 'acme', 'aerate', 'abalone', 'aardvark']

另一个关键字参数reverse,仅仅是一个真值(TrueFalse);你将在第五章中了解更多关于这些的内容,指出列表是否应该反向排序。

>>> x = [4, 6, 2, 1, 7, 9]
>>> x.sort(reverse=True)
>>> x
[9, 7, 6, 4, 2, 1]

sorted函数中也有keyreverse参数。在许多情况下,为key使用自定义函数会很有用。你将在第六章中学习如何定义你自己的函数。

Tip

如果你想了解更多关于排序的内容,你可以看看 https://wiki.python.org/moin/HowTo/Sorting 的“排序小指南”。

元组:不可变序列

元组是序列,就像列表一样。唯一的区别是元组不能改变。(您可能已经注意到了,字符串也是如此。)元组语法很简单——如果用逗号分隔一些值,就会自动得到一个元组。

>>> 1, 2, 3
(1, 2, 3)

正如您所看到的,元组也可能(并且经常)包含在括号中。

>>> (1, 2, 3)
(1, 2, 3)

空元组被写成两个不包含任何内容的括号。

>>> ()
()

因此,您可能想知道如何编写包含单个值的元组。这有点奇怪——即使只有一个值,也必须包含一个逗号。

>>> 42
42
>>> 42,
(42,)
>>> (42,)
(42,)

最后两个例子产生长度为 1 的元组,而第一个例子根本不是元组。逗号至关重要。单纯加括号也无济于事:(42)42一模一样。然而,一个单独的逗号可以完全改变表达式的值。

>>> 3 * (40 + 2)
126
>>> 3 * (40 + 2,)
(42, 42, 42)

tuple函数的工作方式与list非常相似:它接受一个序列参数并将其转换成一个元组。5 如果自变量已经是元组,则不变返回。

>>> tuple([1, 2, 3])
(1, 2, 3)
>>> tuple('abc')
('a', 'b', 'c')
>>> tuple((1, 2, 3))
(1, 2, 3)

正如您可能已经收集到的,元组并不复杂——除了创建它们和访问它们的元素之外,您真的不能对它们做太多事情,您可以像对其他序列一样做这些事情。

>>> x = 1, 2, 3
>>> x[1]
2
>>> x[0:2]
(1, 2)

元组的片也是元组,就像列表片本身是列表一样。

您需要了解元组有两个重要原因。

  • 它们可以用作映射中的键(和集合的成员);列表不能这样使用。你将在第四章学到更多的映射。
  • 它们由一些内置的函数和方法返回,这意味着您必须处理它们。只要你不试图去改变它们,“处理”它们最常见的方式就是把它们当成列表一样对待(除非你需要像indexcount这样的方法,而元组是没有的)。

一般来说,列表可能足以满足您的排序需求。

快速总结

让我们回顾一下本章中涉及的一些最重要的概念。

  • 序列:序列是一种数据结构,其中的元素被编号(从零开始)。序列类型的例子有列表、字符串和元组。其中,列表是可变的(您可以更改它们),而元组和字符串是不可变的(一旦创建,它们就是固定的)。可以通过切片来访问序列的各个部分,提供两个索引来指示切片的开始和结束位置。要更改列表,可以为其位置指定新值,或者使用赋值来覆盖整个切片。
  • 成员资格:用操作符in检查一个值是否能在一个序列(或其他容器)中找到。对字符串使用in是一个特例——它将允许您查找子字符串。
  • 方法:一些内置类型(比如列表和字符串,但不是元组)有许多有用的方法。这些有点像函数,除了它们与一个特定的值紧密相连。方法是面向对象编程的一个重要方面,我们在第七章中会讲到。

本章的新功能

| 功能 | 描述 | | --- | --- | | `len(seq)` | 返回序列的长度 | | `list(seq)` | 将序列转换为列表 | | `max(args)` | 返回一个序列或一组参数的最大值 | | `min(args)` | 返回一个序列或一组参数的最小值 | | `reversed(seq)` | 允许您反向迭代序列 | | `sorted(seq)` | 返回`seq`元素的排序列表 | | `tuple(seq)` | 将序列转换为元组 |

现在怎么办?

现在你已经熟悉了序列,让我们继续学习字符序列,也称为字符串。

Footnotes 1

它实际上是一个类,而不是一个函数,但是区别现在并不重要。

2

其实从 Python 2.2 版本开始,list就是一个类型,而不是一个函数。(这也是tuplestr的情况。)关于这个的完整故事,参见第九章的“子类化列表、字典和字符串”一节。

3

如果您感兴趣,从 Python 2.3 开始,sort方法使用稳定的排序算法。

4

实际上,sorted函数可以用在任何可迭代的对象上。在第九章中你会学到更多关于可迭代对象的知识。

5

listtuple不是一个真正的函数——它是一种类型。和list一样,你现在可以放心地忽略这一点。

三、使用字符串

你以前见过绳子,知道怎么做。您还了解了如何通过索引和切片来访问它们的单个字符。在本章中,您将看到如何使用它们来格式化其他值(例如,用于打印),并快速浏览一下使用字符串方法可以做的有用的事情,例如拆分、连接、搜索等等。

基本字符串操作

所有标准的序列操作(索引、切片、乘法、成员、长度、最小值和最大值)都与字符串有关,正如你在前一章所看到的。但是,请记住,字符串是不可变的,因此所有类型的项或片赋值都是非法的。

>>> website = 'http://www.python.org'
>>> website[-3:] = 'com'
Traceback (most recent call last):
  File "<pyshell#19>", line 1, in ?
  website[-3:] = 'com'
TypeError: object doesn't support slice assignment

字符串格式:短版本

如果您是 Python 编程的新手,您可能不需要 Python 字符串格式中所有可用的选项,所以我在这里给您一个简短的版本。如果您对细节感兴趣,可以看看下面的“字符串格式:长版本”一节。否则,只需阅读本文并跳到“字符串方法”一节

将值格式化为字符串是一项非常重要的操作,而且必须满足如此多样化的需求,因此多年来,这种语言中已经加入了多种方法。历史上,主要的解决方案是使用(恰当命名的)字符串格式化操作符,百分号。这个操作符的行为模拟了 C 语言中经典的printf函数。在%的左边,放置一个字符串(格式字符串);在它的右边,放置要格式化的值。你可以使用单个值,比如一个字符串或一个数字,你可以使用一组值(如果你想格式化多个值),或者,正如我在下一章讨论的,你可以使用字典。最常见的情况是元组。

>>> format = "Hello, %s. %s enough for ya?"
>>> values = ('world', 'Hot')
>>> format % values
'Hello, world. Hot enough for ya?'

格式字符串的%s部分称为转换说明符。它们标记了要插入值的位置。s意味着这些值应该像字符串一样被格式化;如果不是,他们将被转换为str。其他说明符导致其他形式的转换;例如,%.3f会将值格式化为具有三位小数的浮点数。

这种格式化方法仍然有效,并且在很多代码中仍然非常活跃,所以您可能会遇到它。您可能会遇到的另一种解决方案是所谓的模板字符串,这是不久前出现的一种简化基本格式化机制的尝试,例如,使用类似于 UNIX shells 的语法。

>>> from string import Template
>>> tmpl = Template("Hello, $who! $what enough for ya?")
>>> tmpl.substitute(who="Mars", what="Dusty")
'Hello, Mars! Dusty enough for ya?'

带等号的参数是所谓的关键字参数——你会在第六章中听到很多。在字符串格式化的上下文中,您可以将它们看作是向命名替换字段提供值的一种方式。

当编写新代码时,选择的机制是format string 方法,它结合并扩展了早期方法的优点。每个替换字段都用花括号括起来,可能包含一个名称,以及有关如何转换和格式化为该字段提供的值的信息。

最简单的情况是字段没有名称,或者每个名称只是一个索引。

>>> "{}, {} and {}".format("first", "second", "third")
'first, second and third'
>>> "{0}, {1} and {2}".format("first", "second", "third")
'first, second and third'

不过,索引不需要像这样排序。

>>> "{3} {0} {2} {1} {3} {0}".format("be", "not", "or", "to")
'to be or not to be'

命名字段正如预期的那样工作。

>>> from math import pi
>>> "{name} is approximately {value:.2f}.".format(value=pi, name="π")
'π is approximately 3.14.'

当然,关键字参数的顺序并不重要。在这种情况下,我还提供了一个格式说明符.2f,用冒号与字段名隔开,这意味着我们想要三位小数的浮点格式。如果没有指定,结果将如下所示:

>>> "{name} is approximately {value}.".format(value=pi, name="π")
'π is approximately 3.141592653589793.'

最后,在 Python 3.6 中,如果有与相应替换字段同名的变量,可以使用一种快捷方式。在这种情况下,您可以使用所谓的 f 字符串,以前缀f编写。

>>> from math import e
>>> f"Euler's constant is roughly {e}."
"Euler's constant is roughly 2.718281828459045."

在这里,名为e的替换字段只是在构造字符串时提取同名变量的值。这相当于以下稍微更明确的表达式:

>>> "Euler's constant is roughly {e}.".format(e=e)
"Euler's constant is roughly 2.718281828459045."

字符串格式:长版本

字符串格式化工具是广泛的,所以即使这个长版本也不能完全探究它的所有细节,但是让我们看看主要的组件。想法是我们在一个字符串上调用format方法,为它提供我们想要格式化的值。该字符串包含如何执行这种格式化的信息,用模板小型语言指定。每个值都被拼接到几个替换字段之一的字符串中,每个替换字段都用花括号括起来。如果希望在最终结果中包含文字大括号,可以在格式字符串中使用双大括号来指定,即{{}}

>>> "{{ceci n'est pas une replacement field}}".format()
"{ceci n'est pas une replacement field}"

格式字符串最令人兴奋的部分是在替换字段的内部,由以下部分组成,所有这些部分都是可选的:

  • 字段名。索引或标识符。这告诉我们哪个值将被格式化并拼接到这个特定的字段中。除了命名对象本身,我们还可以命名值的特定部分,比如一个列表的元素。
  • 转换标志。感叹号,后跟一个字符。目前支持的有r(代表repr)s(代表str)或者a(代表ascii)。如果提供了此标志,它将覆盖对象自身的格式化机制,并在进一步格式化之前使用指定的函数将其转换为字符串。
  • 格式说明符。冒号,后跟格式规范小型语言中的表达式。这让我们可以指定最终格式的细节,包括格式的类型(例如,字符串、浮点或十六进制数)、字段的宽度和数字的精度、如何显示符号和千位分隔符,以及各种形式的对齐和填充。

让我们更详细地看看其中的一些元素。

替换字段名称

在最简单的情况下,您只需向format提供未命名的参数,并在格式字符串中使用未命名的字段。然后,字段和参数按照给定的顺序配对。您还可以为参数提供名称,然后在替换字段中使用名称来请求这些特定的值。这两种策略可以自由混合。

>>> "{foo} {} {bar} {}".format(1, 2, bar=4, foo=3)
'3 1 4 2'

未命名参数的索引也可用于无序请求它们。

>>> "{foo} {1} {bar} {0}".format(1, 2, bar=4, foo=3)
'3 2 4 1'

然而,混合手动和自动字段编号是不允许的,因为这样会很快变得非常混乱。

但是您不必使用提供的值本身——您可以访问它们的一部分,就像在普通的 Python 代码中一样。这里有一个例子:

>>> fullname = ["Alfred", "Smoketoomuch"]
>>> "Mr {name[1]}".format(name=fullname)
'Mr Smoketoomuch'
>>> import math
>>> tmpl = "The {mod.__name__} module defines the value {mod.pi} for π"
>>> tmpl.format(mod=math)
'The math module defines the value 3.141592653589793 for π'

如您所见,我们可以对导入模块中的方法、属性或变量以及函数同时使用索引和点符号。(奇怪的__name__变量包含给定模块的名称。)

基本转换

一旦指定了字段应该包含的内容,就可以添加如何设置格式的说明。首先,您可以提供一个转换标志。

>>> print("{pi!s} {pi!r} {pi!a}".format(pi="π"))
π 'π' '\u03c0'

三个标志(sra)分别使用strreprascii进行转换。str函数通常创建一个看起来自然的字符串版本的值(在这种情况下,它对输入字符串不做任何事情);repr字符串试图创建给定值的 Python 表示(在本例中是一个字符串文字),而ascii函数坚持创建一个仅包含 ASCII 编码中允许的字符的表示。这类似于repr在 Python 2 中的工作方式。

您还可以指定要转换的值的类型,或者更确切地说,您希望它被视为哪种类型的值。例如,您可能提供了一个整数,但希望将其视为十进制数。您可以通过在格式规范中使用f字符(表示定点)来实现这一点,即在冒号分隔符之后。

>>> "The number is {num}".format(num=42)
'The number is 42'
>>> "The number is {num:f}".format(num=42)
'The number is 42.000000'

或者你更愿意把它格式化成二进制数字?

>>> "The number is {num:b}".format(num=42)
'The number is 101010'

有几个这样的类型说明符。列表见表 3-1 。

表 3-1。

String Formatting Type Specifiers

| 类型 | 意义 | | --- | --- | | `b` | 将整数格式化为二进制数字。 | | `c` | 将整数解释为 Unicode 码位。 | | `d` | 将整数格式化为十进制数字。整数的默认值。 | | `e` | 用`e`格式化科学记数法中的十进制数以指示指数。 | | `E` | 与`e`相同,但使用`E`表示指数。 | | `f` | 格式化具有固定小数位数的十进制数。 | | `F` | 与`f`相同,但将特殊值(`nan`和`inf`)格式化为大写。 | | `g` | 自动在固定记数法和科学记数法之间选择。十进制数的默认值,只是默认版本至少有一个小数。 | | `G` | 与`g`相同,但大写的是指数指示器和特殊值。 | | `n` | 与`g`相同,但插入了与地区相关的数字分隔符。 | | `o` | 将整数格式化为八进制数字。 | | `s` | 按原样格式化字符串。字符串的默认值。 | | `x` | 将整数格式化为带有小写字母的十六进制数字。 | | `X` | 与`x`相同,但有大写字母。 | | `%` | 将数字格式化为百分比(乘以 100,由`f`格式化,后跟`%`)。 |

宽度、精度和千位分隔符

当格式化浮点数(或其他更特殊的十进制数类型)时,默认是在小数点后显示六位数字,并且在所有情况下,默认是让格式化值正好具有显示它所需的宽度,没有任何类型的填充。当然,这些缺省值可能并不完全是您想要的,您可以根据自己的喜好,在格式规范中增加关于宽度和精度的细节。

宽度由整数表示,如下所示:

>>> "{num:10}".format(num=3)
'         3'
>>> "{name:10}".format(name="Bob")
'Bob       '

如你所见,数字和字符串的对齐方式不同。我们将在下一节回到对齐。

精度也是由整数指定的,但是它前面有一个句点,暗指小数点。

>>> "Pi day is {pi:.2f}".format(pi=pi)
'Pi day is 3.14'

这里,我已经明确指定了f类型,因为默认情况下处理精度的方式有点不同。(请参阅 Python 库参考以了解精确的规则。)当然可以把宽度和精度结合起来。

>>> "{pi:10.2f}".format(pi=pi)
'      3.14'

您实际上也可以对其他类型使用 precision,尽管您可能不经常需要这样做。

>>> "{:.5}".format("Guido van Rossum")
'Guido'

最后,您可以使用逗号来表示您想要千位分隔符。

>>> 'One googol is {:,}'.format(10**100)
'One googol is 10,000,000,000,000,000,000,000,000,000,000,000,000,000,000,000,000,000,000,000,000,000,000,000,000,000,000,000,000,000,000,000,000,000'

当与其他格式元素一起使用时,此逗号应该位于表示精度的宽度和句点之间。 1

符号、对齐和零填充

相当多的格式化机器是针对格式化数字的,例如,打印出一个精确对齐的数值表。宽度和精度使我们基本上达到了目的,但是如果我们包含负数,我们漂亮的输出仍然会被丢弃。正如你所看到的,字符串和数字的对齐方式不同;也许我们想改变这一点,例如,在一列数字中间加入一段文字?在宽度和精度数字之前,您可以放置一个“标志”,它可以是零、加号、减号或空白。零意味着该数字将被零填充。

>>> '{:010.2f}'.format(pi)
'0000003.14'

分别用<>^指定左对齐、右对齐和居中对齐。

>>> print('{0:<10.2f}\n{0:¹⁰.2f}\n{0:>10.2f}'.format(pi))
3.14
   3.14
      3.14

您可以用填充字符来增加对齐说明符,填充字符用来代替空格字符。

>>> "{:$¹⁵}".format(" WIN BIG ")
'$$$ WIN BIG $$$'

还有更专业的说明符=,它在符号和数字之间放置任何填充字符。

>>> print('{0:10.2f}\n{1:10.2f}'.format(pi, -pi))
      3.14
     -3.14
>>> print('{0:10.2f}\n{1:=10.2f}'.format(pi, -pi))
      3.14
-     3.14

如果还想包含正数的符号,可以使用说明符+(在对齐说明符之后,如果有的话),而不是默认的-。如果使用空格字符,正数将插入一个空格,而不是一个+

>>> print('{0:-.2}\n{1:-.2}'.format(pi, -pi)) # Default
3.1
-3.1
>>> print('{0:+.2}\n{1:+.2}'.format(pi, -pi))
+3.1
-3.1
>>> print('{0: .2}\n{1: .2}'.format(pi, -pi))
 3.1
-3.1

最后一个组件是 hash ( #)选项,它位于符号和宽度之间(如果它们存在的话)。这触发了另一种形式的转换,不同类型之间的细节有所不同。例如,对于二进制、八进制和十六进制转换,会添加一个前缀。

>>> "{:b}".format(42)
'101010'
>>> "{:#b}".format(42)
'0b101010'

对于各种类型的十进制数,它强制包含小数点(对于g,它保留十进制零)。

>>> "{:g}".format(42)
'42'
>>> "{:#g}".format(42)
'42.0000'

在清单 3-1 所示的例子中,我在相同的字符串上使用了两次字符串格式——第一次是将字段宽度插入到最终的格式说明符中。因为这些信息是由用户提供的,所以我不能硬编码字段宽度。

# Print a formatted price list with a given width

width = int(input('Please enter width: '))

price_width = 10
item_width  = width - price_width

header_fmt = '{{:{}}}{{:>{}}}'.format(item_width, price_width)
fmt        = '{{:{}}}{{:>{}.2f}}'.format(item_width, price_width)

print('=' * width)

print(header_fmt.format('Item', 'Price'))

print('-' * width)

print(fmt.format('Apples', 0.4))
print(fmt.format('Pears', 0.5))
print(fmt.format('Cantaloupes', 1.92))
print(fmt.format('Dried Apricots (16 oz.)', 8))
print(fmt.format('Prunes (4 lbs.)', 12))

print('=' * width)

Listing 3-1.String Formatting Example

以下是该程序的运行示例:

Please enter  width: 35
===================================
Item                          Price
-----------------------------------
Apples                         0.40
Pears                          0.50
Cantaloupes                    1.92
Dried  Apricots (16 oz.)       8.00
Prunes (4 lbs.)               12.00
===================================

字符串方法

您已经在列表中遇到了方法。字符串有更丰富的方法集,部分原因是字符串从string模块“继承”了它们的许多方法,在 Python 的早期版本中它们是作为函数存在的(如果你觉得有必要,你仍然可以在那里找到它们)。

因为有这么多的字符串方法,所以这里只描述一些最有用的方法。有关完整的参考,请参见附录 b。在字符串方法的描述中,您可以在本章(标有“另请参阅”)或附录 b 中找到对其他相关字符串方法的参考。

But String Isn’t Dead

尽管字符串方法已经完全抢了string模块的风头,但该模块仍然包含一个few常量和函数,它们不能作为字符串方法使用。以下是string 2 中一些有用的常数:

  • string.digits:包含数字 0-9 的字符串
  • string.ascii_letters:包含所有 ASCII 字母(大写和小写)的字符串
  • string.ascii_lowercase:包含所有小写 ASCII 字母的字符串
  • string.printable:包含所有可打印 ASCII 字符的字符串
  • string.punctuation:包含所有 ASCII 标点字符的字符串
  • string.ascii_uppercase:包含所有大写 ASCII 字母的字符串

尽管显式地处理 ASCII 字符,但这些值实际上是(未编码的)Unicode 字符串。

中心

center 方法通过用给定的填充字符(默认情况下是空格)填充字符串的任意一侧来使字符串居中。

>>> "The Middle by Jimmy Eat World".center(39)
'     The Middle by Jimmy Eat World     '
>>> "The Middle by Jimmy Eat World".center(39, "*")
'*****The Middle by Jimmy Eat World*****'

附录 B 中:ljustrjustzfill

发现

find方法在一个更大的字符串中查找子字符串。它返回找到子字符串的最左边的索引。如果没有找到,则返回-1。

>>> 'With a moo-moo here, and a moo-moo there'.find('moo')
7
>>> title = "Monty Python's Flying Circus"
>>> title.find('Monty')
0
>>> title.find('Python')
6
>>> title.find('Flying')
15
>>> title.find('Zirquss')
-1

在第二章中,我们第一次遇到会员,我们使用表达式'$$$' in subject创建了垃圾邮件过滤器的一部分。我们也可以使用find(在 Python 2.3 之前也可以使用,当时in只能在检查字符串中的单个字符成员时使用)。

>>> subject = '$$$ Get rich now!!! $$$'
>>> subject.find('$$$')
0

Note

字符串方法find不返回布尔值。如果find像这里一样返回 0,这意味着它已经在索引 0 处找到了子串。

您还可以提供搜索的起点,也可以选择结束点。

>>> subject = '$$$ Get rich now!!! $$$'

>>> subject.find('$$$')
0
>>> subject.find('$$$', 1) # Only supplying the start
20
>>> subject.find('!!!')
16
>>> subject.find('!!!', 0, 16) # Supplying start and end
-1

请注意,由起始值和终止值(第二个和第三个参数)指定的范围包括第一个索引,但不包括第二个索引。这是 Python 中的常见做法。

附录 B 中:rfindindexrindexcountstartswithendswith

加入

一个很重要的字符串方法,joinsplit的逆。它用于连接一个序列的元素。

>>> seq = [1, 2, 3, 4, 5]
>>> sep = '+'
>>> sep.join(seq) # Trying to join a list of numbers
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
TypeError: sequence item 0: expected string, int found
>>> seq = ['1', '2', '3', '4', '5']
>>> sep.join(seq) # Joining a list of strings
'1+2+3+4+5'
>>> dirs = '', 'usr', 'bin', 'env'
>>> '/'.join(dirs)
'/usr/bin/env'
>>> print('C:' + '\\'.join(dirs))
C:\usr\bin\env

如您所见,要连接的序列元素必须都是字符串。请注意,在最后两个例子中,我使用了一个目录列表,并简单地通过使用不同的分隔符(并在 DOS 版本中添加一个驱动器名)根据 UNIX 和 DOS/Windows 的约定对它们进行了格式化。

又见:split

降低

lower方法返回字符串的小写版本。

>>> 'Trondheim Hammer Dance'.lower()
'trondheim hammer dance'

如果您想要编写不区分大小写的代码,即忽略大写字母和小写字母之间差异的代码,这可能会很有用。例如,假设您想检查一个用户名是否在列表中。如果你的列表包含字符串'gumby'并且用户输入他的名字为'Gumby',你将找不到它。

>>> if 'Gumby' in ['gumby', 'smith', 'jones']: print('Found it!')
...
>>>

当然,如果你存储了'Gumby',用户写了'gumby',甚至是'GUMBY',也会发生同样的事情。解决这个问题的方法是在存储和搜索时将所有的名字都转换成小写。代码看起来会像这样:

>>> name  = 'Gumby'
>>> names  = ['gumby', 'smith', 'jones']
>>> if name.lower() in names: print('Found it!')
...
Found  it!
>>>

另见:isloweristitleisuppertranslate

附录 B 中:capitalizecasefoldswapcasetitleupper

Title Casing

lower相关的一个是title方法(参见附录 B ),它的标题是一个字符串——也就是说,所有单词都以大写字符开头,其他所有字符都是小写。然而,单词边界的定义方式可能会产生一些不自然的结果。

>>> "that's all folks".title()
"That'S All, Folks"

另一种选择是来自string模块的capwords函数。

>>> import string
>>> string.capwords("that's all, folks")
That's All, Folks"

当然,如果你想要一个真正大写正确的标题(这取决于你使用的风格——可能是小写冠词、并列连词、少于五个字母的介词等等),你基本上只能靠自己了。

替换

replace方法返回一个字符串,其中一个字符串的所有出现被另一个替换。

>>> 'This is a test'.replace('is', 'eez')
'Theez eez a test'

如果你曾经使用过文字处理程序的“搜索和替换”功能,你无疑会看到这种方法的用处。

又见:translate

附录 B 中:expandtabs

使分离

一个非常重要的字符串方法,splitjoin的逆方法,用于将一个字符串拆分成一个序列。

>>> '1+2+3+4+5'.split('+')
['1', '2', '3', '4', '5']
>>> '/usr/bin/env'.split('/')
['', 'usr', 'bin', 'env']
>>> 'Using   the   default'.split()
['Using', 'the', 'default']

请注意,如果没有提供分隔符,默认情况下将对所有连续的空白字符(空格、制表符、换行符等)进行拆分。

又见:join

附录 B 中:partitionrpartitionrsplitsplitlines

剥夺

strip方法返回一个字符串,其中左边和右边(而不是内部)的空白已经被去除。

>>> '    internal whitespace is kept    '.strip()
'internal whitespace is kept'

lower一样,strip在比较输入值和存储值时也很有用。让我们回到关于lower一节的用户名例子,假设用户无意中在他的名字后面输入了一个空格。

>>> names = ['gumby', 'smith', 'jones']
>>> name = 'gumby '
>>> if name in names: print('Found it!')
...
>>> if name.strip() in names: print('Found it!')
...
Found it!
>>>

您还可以通过在字符串参数中列出所有字符来指定要去除的字符。

>>> '*** SPAM * for * everyone!!! ***'.strip(' *!')
'SPAM * for * everyone'

仅在末尾执行剥离,因此内部星号不会被删除。

附录 B 中:lstriprstrip

翻译

replace类似,translate替换字符串的一部分,但与replace不同,translate只处理单个字符。它的优势在于可以同时进行多种替换,而且比replace更有效率。

这种方法有相当多的技术性用法(比如翻译换行符或其他依赖于平台的特殊字符),但是让我们考虑一个更简单(虽然有点傻)的例子。假设你想把一篇普通的英语文章翻译成带有德国口音的文章。为此,您必须用 k 替换字符 c,用 z 替换 s。

但是,在使用translate之前,必须先做一个翻译表。该转换表包含关于哪些 Unicode 码位应该转换成哪些码位的信息。您使用字符串类型str本身的maketrans方法来构建这样一个表。该方法采用两个参数:两个长度相等的字符串,其中第一个字符串中的每个字符都应该由第二个字符串中相同位置的字符替换。 3 在我们这个简单的例子中,代码如下所示:

>>> table = str.maketrans('cs', 'kz')

如果我们愿意,我们可以查看表内部,尽管我们只能看到 Unicode 码位之间的映射。

>>> table
{115: 122, 99: 107}

一旦有了翻译表,就可以将它用作translate方法的参数。

>>> 'this is an incredible test'.translate(table)
'thiz iz an inkredible tezt'

可选的第三个参数可以提供给maketrans,指定应该删除的字母。例如,如果你想模仿一个语速很快的德国人,你可以删除所有的空格。

>>> table = str.maketrans('cs', 'kz', ' ')
>>> 'this is an incredible test'.translate(table)
'thizizaninkredibletezt'

又见:replacelower

是我的弦…

有很多以is开头的字符串方法,比如isspaceisdigitisupper,它们决定你的字符串是否有某些属性(比如全部是空白、数字或大写),在这种情况下,这些方法返回True。否则,他们当然返回False

附录 B 中:isalnumisalphaisdecimalisdigitisidentifierislowerisnumericisprintableisspaceistitleisupper

快速总结

在本章中,你看到了使用字符串的两种重要方式。

  • 字符串格式化:模运算符(%)可用于将值拼接成包含转换标志的字符串,例如%s。您可以使用它以多种方式格式化值,包括右对齐或左对齐、设置特定的字段宽度和精度、添加符号(加号或减号)或用零进行左填充。
  • 字符串方法:字符串有很多方法。其中有一些极其有用(比如splitjoin),而另一些使用频率较低(比如istitlecapitalize)。

本章的新功能

| 功能 | 描述 | | --- | --- | | `string.capwords(s[, sep])` | 用`split`拆分`s`(使用`sep`),将项目大写,并用一个空格连接 | | `ascii(obj)` | 构造给定对象的 ASCII 表示形式 |

什么现在?

列表、字符串和字典是 Python 中三种最重要的数据类型。你已经看到了列表和字符串,猜猜接下来是什么?在下一章中,我们将看到字典不仅支持整数索引,还支持其他类型的键(如字符串或元组)。他们也有一些方法,虽然没有字符串那么多。

Footnotes 1

如果您想要一个依赖于地区的千位分隔符,您应该使用n类型。

2

有关该模块的更详细描述,请查看 Python 库参考( https://docs.python.org/3/library/string.html )的第 6.1 节。

3

你也可以提供一个字典,你将在下一章中学习到,将字符映射到其他字符,或者映射到None,如果它们要被删除的话。

四、字典:当索引不好用的时候

您已经看到,当您想要将值分组到一个结构中并通过数字引用每个值时,列表非常有用。在本章中,您将学习一种可以通过名称引用每个值的数据结构。这种类型的结构称为映射。Python 中唯一的内置映射类型是字典。字典中的值没有任何特定的顺序,而是存储在一个键下,这个键可以是一个数字、一个字符串甚至一个元组。

字典用途

名字字典应该给你一个关于这个结构用途的线索。一本普通的书是用来从头到尾阅读的。如果你愿意,你可以快速打开它到任何给定的页面。这有点像 Python 列表。另一方面,字典——无论是真正的字典还是它们的 Python 等价物——都是这样构建的,以便您可以轻松地查找特定的单词(键)来找到它的定义(值)。

在许多情况下,字典比列表更合适。以下是使用 Python 字典的一些示例:

  • 表示游戏棋盘的状态,每个键是一个坐标元组
  • 以文件名为关键字存储文件修改时间
  • 数字电话/地址簿

假设你有一份名单。

>>> names = ['Alice', 'Beth', 'Cecil', 'Dee-Dee', 'Earl']

如果你想创建一个小数据库来存储这些人的电话号码,你会怎么做?一种方法是再列一个清单。假设您只存储了他们的四位数扩展名。然后你会得到这样的结果:

>>> numbers = ['2341', '9102', '3158', '0142', '5551']

一旦你创建了这些列表,你可以查找塞西尔的电话号码如下:

>>> numbers[names.index('Cecil')]

'3158'

有效果,但是有点不实用。您真正想要做的是类似下面这样的事情:

>>> phonebook['Cecil']

'3158'

你猜怎么着?如果phonebook是一本字典,你就可以这么做。

创建和使用词典

字典是这样写的:

phonebook = {'Alice': '2341', 'Beth': '9102', 'Cecil': '3258'}

字典由键对(称为项)和它们对应的值组成。在这个例子中,名字是键,电话号码是值。每个键用冒号(:)和它的值分开,项目用逗号分开,整个用花括号括起来。一个空字典(没有任何条目)只写了两个花括号,像这样:{}

Note

键在一个字典中是惟一的(以及任何其他类型的映射)。值不需要在字典中是唯一的。

字典函数

您可以使用dict函数 1 从其他映射(例如,其他字典)或从(key, value)对的序列构建字典。

>>> items = [('name', 'Gumby'), ('age', 42)]

>>> d = dict(items)

>>> d

{'age': 42, 'name': 'Gumby'}

>>> d['name']

'Gumby'

它也可以与关键字参数一起使用,如下所示:

>>> d = dict(name='Gumby', age=42)

>>> d

{'age': 42, 'name': 'Gumby'}

虽然这可能是dict最有用的应用,但是您也可以将它与一个映射参数一起使用,以创建一个包含与映射相同的条目的字典。(如果不带任何参数使用,它返回一个新的空字典,就像其他类似的函数如listtuplestr。)如果另一个映射是一个字典(毕竟这是唯一的内置映射类型),您可以使用字典方法copy来代替,如本章后面所述。

基本字典操作

字典的基本行为在许多方面反映了序列的基本行为。

  • len(d)返回d中项目(键值对)的数量。
  • d[k]返回与键k相关联的值。
  • d[k] = v将值v与键k相关联。
  • del d[k]删除键为k的项目。
  • k in d检查d中是否有具有关键字k的项目。

虽然字典和列表有一些共同的特征,但也有一些重要的区别:

  • 键类型:字典键不一定是整数(尽管它们可能是)。它们可以是任何不可变的类型,比如浮点(实)数、字符串或元组。
  • 自动添加:您可以给一个键赋值,即使这个键一开始就不在字典中;在这种情况下,将创建一个新项目。不能给列表范围之外的索引赋值(不使用append或类似的东西)。
  • 成员:表达式k in d(其中d是一个字典)寻找一个键,而不是一个值。另一方面,表达式v in l(其中l是一个列表)寻找一个值,而不是一个索引。这可能看起来有点不一致,但当你习惯了,这其实是很自然的。毕竟,如果字典有给定的键,检查相应的值就很容易。

Tip

在字典中检查键成员比在列表中检查成员更有效。数据结构越大,差异就越大。

第一点——键可以是任何不可变的类型——是字典的主要优势。第二点也很重要。请看这里的区别:

>>> x = []

>>> x[42] = 'Foobar'

Traceback (most recent call last):

  File "<stdin>", line 1, in ?

IndexError: list assignment index out of range

>>> x = {}

>>> x[42] = 'Foobar'

>>> x

{42: 'Foobar'}

首先,我尝试将字符串'Foobar'赋给空列表中的位置 42——这显然是不可能的,因为该位置不存在。为了实现这一点,我必须用[None] * 43或别的什么来初始化x,而不是简单地初始化[]。然而,下一次尝试非常成功。这里我将'Foobar'赋值给一个空字典的键 42。你可以看到这里没有问题。一个新条目被简单地添加到字典中,我开始工作了。

清单 4-1 显示了电话簿示例的代码。

# A simple database

# A dictionary with person names as keys. Each person is represented as
# another dictionary with the keys 'phone' and 'addr' referring to their phone
# number and address, respectively.
people = {

    'Alice': {
        'phone': '2341',
        'addr': 'Foo drive 23'
    },

    'Beth': {
        'phone': '9102',
        'addr': 'Bar street 42'
    },

    'Cecil': {
        'phone': '3158',
        'addr': 'Baz avenue 90'
    }

}

# Descriptive labels for the phone number and address. These will be used
# when printing the output.
labels = {
    'phone': 'phone number',
    'addr': 'address'
}

name = input('Name: ')

# Are we looking for a phone number or an address?
request = input('Phone number (p) or address (a)? ')

# Use the correct key:
if request == 'p': key = 'phone'
if request == 'a': key = 'addr'

# Only try to print information if the name is a valid key in

# our dictionary:
if name in people: print("{}'s {} is {}.".format(name, labels[key], people[name][key]))

Listing 4-1.Dictionary Example

以下是该程序的运行示例:

Name: Beth
Phone number (p) or address (a)? p
Beth's phone number is 9102.

用字典格式化字符串

在第三章中,您看到了如何使用字符串格式化来格式化作为format方法的单独(命名或未命名)参数提供的值。有时候,以字典的形式收集一组命名的值可以使事情变得更容易。例如,字典可能包含各种信息,而您的格式字符串只会挑选出它需要的任何信息。您必须通过使用format_ map来指定您正在提供一个映射。

>>> phonebook
{'Beth': '9102', 'Alice': '2341', 'Cecil': '3258'}
>>> "Cecil's phone number is {Cecil}.".format_map(phonebook)
"Cecil's phone number is 3258."

当像这样使用字典时,你可以有任意数量的转换说明符,只要所有给定的键都能在字典中找到。这种字符串格式在模板系统中非常有用(在这种情况下使用 HTML)。

>>> template = '''<html>
... <head><title>{title}</title></head>
... <body>
... <h1>{title}</h1>
... <p>{text}</p>
... </body>'''
>>> data = {'title': 'My Home Page', 'text': 'Welcome to my home page!'}
>>> print(template.format_map(data))
<html>
<head><title>My Home Page</title></head>
<body>
<h1>My Home Page</h1>
<p>Welcome to my home page!</p>
</body>

字典方法

就像其他内置类型一样,字典也有方法。虽然这些方法非常有用,但您可能不像 list 和 string 方法那样经常需要它们。您可能想先浏览一下这一部分,以了解哪些方法是可用的,如果您需要确切地了解给定方法是如何工作的,稍后再回来。

清楚的

方法从字典中删除所有的条目。这是一个就地操作(像list.sort),所以它不返回任何东西(或者说,None)。

>>> d = {}
>>> d['name'] = 'Gumby'
>>> d['age'] = 42
>>> d
{'age': 42, 'name': 'Gumby'}
>>> returned_value = d.clear()
>>> d
{}
>>> print(returned_value)
None

这为什么有用?让我们考虑两种情况。这是第一个:

>>> x = {}
>>> y = x
>>> x['key'] = 'value'
>>> y
{'key': 'value'}
>>> x = {}
>>> x = {}
{'key': 'value'}

这是第二种情况:

>>> x = {}
>>> y = x
>>> x['key'] = 'value'
>>> y
{'key': 'value'}
>>> x.clear()
>>> y
{}

在这两个场景中,xy最初指的是同一个字典。在第一个场景中,我通过分配一个新的空字典来“清空”x。那一点也不影响y,它还是指原词典。这可能是您想要的行为,但是如果您真的想要删除原始字典中的所有元素,您必须使用clear。正如您在第二个场景中看到的,y之后也是空的。

复制

copy方法返回一个具有相同键值对的新字典(浅拷贝,因为值本身是相同的,而不是拷贝)。

>>> x = {'username': 'admin', 'machines': ['foo', 'bar', 'baz']}
>>> y = x.copy()
>>> y['username'] = 'mlh'
>>> y['machines'].remove('bar')
>>> y
{'username': 'mlh', 'machines': ['foo', 'baz']}
>>> x
{'username': 'admin', 'machines': ['foo', 'baz']}

如您所见,当您替换副本中的值时,原始值不受影响。但是,如果您修改一个值(就地,而不是替换它),原始值也会被更改,因为相同的值存储在那里(就像本例中的'machines'列表)。

避免该问题的一种方法是进行深层复制,复制值、它们包含的任何值等等。您可以使用copy模块中的函数deepcopy来完成这个任务。

>>> from copy import deepcopy
>>> d = {}
>>> d['names'] = ['Alfred', 'Bertrand']
>>> c = d.copy()
>>> dc = deepcopy(d)
>>> d['names'].append('Clive')
>>> c
{'names': ['Alfred', 'Bertrand', 'Clive']}
>>> dc
{'names': ['Alfred', 'Bertrand']}

方法

fromkeys方法用给定的键创建一个新字典,每个键都有一个默认的对应值None

>>> {}.fromkeys(['name', 'age'])
{'age': None, 'name': None}

这个例子首先构建一个空字典,然后调用这个字典的fromkeys方法来创建另一个字典——这是一个有点多余的策略。相反,您可以直接在dict上调用该方法,它(如前所述)是所有字典的类型。(类型和类的概念在第七章中有更详细的讨论。)

>>> dict.fromkeys(['name', 'age'])
{'age': None, 'name': None}

如果你不想使用None作为默认值,你可以提供你自己的默认值。

>>> dict.fromkeys(['name', 'age'], '(unknown)')
{'age': '(unknown)', 'name': '(unknown)'}

得到

get方法是访问字典条目的一种宽松方式。通常,当您试图访问字典中不存在的条目时,事情会变得非常糟糕。

>>> d = {}
>>> print(d['name'])
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
KeyError: 'name'

get却不是这样。

>>> print(d.get('name'))
None

如您所见,当您使用get访问一个不存在的键时,没有异常。相反,你得到的是值None。你可以提供你自己的“默认”值,然后用它来代替None

>>> d.get('name', 'N/A')
'N/A'

如果键在那里,get就像普通的字典查找一样工作。

>>> d['name'] = 'Eric'
>>> d.get('name')
'Eric'

清单 4-2 显示了清单 4-1 的程序的修改版本,它使用get方法来访问“数据库”条目。

# A simple database using get()

# Insert database (people) from Listing 4-1 here.

labels = {
    'phone': 'phone number',
    'addr': 'address'
}

name = input('Name: ')

# Are we looking for a phone number or an address?
request = input('Phone number (p) or address (a)? ')

# Use the correct key:
key = request # In case the request is neither 'p' nor 'a'
if request == 'p': key = 'phone'
if request == 'a': key = 'addr'

# Use get to provide default values:
person = people.get(name, {})
label = labels.get(key, key)
result = person.get(key, 'not available')

print("{}'s {} is {}.".format(name, label, result))

Listing 4-2.Dictionary Method Example

下面是这个程序的一个运行示例。请注意get al增加的灵活性如何让程序给出有用的响应,即使用户输入了我们没有准备好的值。

Name: Gumby
Phone number (p) or address (a)? batting average
Gumby's batting average is not available.

项目

items方法将字典中的所有条目作为条目列表返回,其中每个条目的形式都是(key, value)。这些项目不会以任何特定的顺序返回。

>>> d = {'title': 'Python Web Site', 'url': 'http://www.python.org', 'spam': 0}
>>> d.items()
dict_items([('url', 'http://www.python.org'), ('spam', 0), ('title', 'Python Web Site')])

返回值是一种称为字典视图的特殊类型。字典视图可以用于迭代(更多信息见第五章)。此外,您可以确定它们的长度并检查成员资格。

>>> it = d.items()
>>> len(it)
3
>>> ('spam', 0) in it
True

关于视图的一个有用之处是它们不复制任何东西;它们总是反映底层字典,即使您修改了它。

>>> d['spam'] = 1
>>> ('spam', 0) in it
False
>>> d['spam'] = 0
>>> ('spam', 0) in it
True

但是,如果您更愿意将项目复制到一个列表中(这是在旧版本的 Python 中使用项目时发生的情况),您总是可以自己做。

>>> list(d.items())
[('spam', 0), ('title', 'Python Web Site'), ('url', 'http://www.python.org')]

keys方法返回字典中键的字典视图。

流行音乐

pop方法可用于获取对应于给定键的值,然后从字典中移除键-值对。

>>> d = {'x': 1, 'y': 2}
>>> d.pop('x')
1
>>> d
{'y': 2}

popitem

popitem方法类似于list.pop,弹出列表的最后一个元素。然而,与list.pop不同的是,popitem弹出一个任意的条目,因为字典没有“最后一个元素”或任何顺序。如果您想以一种高效的方式逐个删除和处理项目(无需首先检索键列表),这可能非常有用。

>>> d = {'url': 'http://www.python.org', 'spam': 0, 'title': 'Python Web Site'}
>>> d.popitem()
('url', 'http://www.python.org')
>>> d
{'spam': 0, 'title': 'Python Web Site'}

虽然popitem类似于列表方法pop,但是没有与append(在列表末尾添加一个元素)等价的字典。因为字典没有顺序,这样的方法没有任何意义。

Tip

如果你想让popitem方法遵循一个可预测的顺序,看看来自collections模块的OrderedDict类。

设置默认值

setdefault方法有点类似于get,因为它检索与给定键相关联的值。除了get功能之外,setdefault设置与给定键相对应的值(如果它不在字典中)。

>>> d = {}
>>> d.setdefault('name', 'N/A')
'N/A'
>>> d
{'name': 'N/A'}
>>> d['name'] = 'Gumby'
>>> d.setdefault('name', 'N/A')
'Gumby'
>>> d
{'name': 'Gumby'}

如您所见,当缺少键时,setdefault返回默认值并相应地更新字典。如果键存在,则返回它的值,字典保持不变。默认是可选的,和get一样;如果它被遗漏了,就使用None

>>> d = {}
>>> print(d.setdefault('name'))
None
>>> d
{'name': None}

Tip

如果您想要整个字典的全局缺省值,请查看来自collections模块的defaultdict类。

更新

方法用另一个字典的条目更新一个字典。

>>> d = {
...     'title': 'Python Web Site',
...     'url': 'http://www.python.org',
...     'changed': 'Mar 14 22:09:15 MET 2016'
...  }
>>> x = {'title': 'Python Language Website'}
>>> d.update(x)
>>> d
{'url': 'http://www.python.org', 'changed':
'Mar 14 22:09:15 MET 2016', 'title': 'Python Language Website'}

所提供的字典中的条目被添加到旧字典中,用相同的键替换旧字典中的任何条目。

正如本章前面所讨论的,调用update方法的方式与调用dict函数(或类型构造函数)的方式相同。这意味着可以用映射、(key, value)对的序列(或其他可迭代对象)或关键字参数来调用update

价值观念

values方法返回字典中值的字典视图。与keys不同,values返回的视图可能包含重复。

>>> d = {}
>>> d[1] = 1
>>> d[2] = 2
>>> d[3] = 3
>>> d[4] = 1
>>> d.values()
dict_values([1, 2, 3, 1])

快速总结

在本章中,您学习了以下内容:

  • 映射:映射使您能够用任何不可变的对象来标记其元素,最常见的类型是字符串和元组。Python 中唯一的内置映射类型是字典。
  • 字典的字符串格式化:您可以通过使用format_map将字符串格式化操作应用于字典,而不是使用带有format的命名参数。
  • 字典方法:字典有相当多的方法,它们的调用方式与列表和字符串方法相同。

本章的新功能

| 功能 | 描述 | | --- | --- | | `dict(seq)` | 从`(key, value)`对(或映射或关键字参数)创建字典 |

什么现在?

您现在已经对 Python 的基本数据类型以及如何使用它们来构成表达式有了很多了解。你可能还记得第一章的内容,计算机程序有另一个重要的组成部分——语句。下一章会详细介绍它们。

Footnotes 1

dict函数根本不是一个真正的函数。它是一个类,就像listtuplestr一样。

五、条件、循环和其他一些语句

现在,我相信你已经有点不耐烦了。好吧——所有这些数据类型都很好,但是你真的不能用它们做很多事情,不是吗?

让我们加快一点速度。我们已经遇到了一些语句类型(print语句、import语句和赋值)。在进入条件和循环的世界之前,让我们先看看使用它们的更多方法。然后我们将看到列表理解是如何像条件和循环一样工作的,即使它们是表达式,最后我们将看看passdelexec

关于打印和导入的更多信息

随着您对 Python 了解的越来越多,您可能会注意到,您认为自己了解的 Python 的某些方面隐藏着一些功能,等待着给您惊喜。让我们来看看printimport中的几个这样的好特性。虽然print确实是一个函数,但它曾经是一个独立的语句类型,这就是我在这里讨论它的原因。

Tip

对于许多应用,日志记录(使用logging模块)将比使用print更合适。详见第十九章。

打印多个参数

您已经看到了如何使用print来打印一个表达式,它要么是一个字符串,要么自动转换成一个字符串。但是您实际上可以打印多个表达式,只要用逗号分隔它们:

>>> print('Age:', 42)
Age: 42

如您所见,每个参数之间插入了一个空格字符。如果您想要组合文本和变量值,而不使用字符串格式的全部功能,此行为会非常有用。

>>> name = 'Gumby'
>>> salutation = 'Mr.'
>>> greeting = 'Hello,'
>>> print(greeting, salutation, name)
Hello, Mr. Gumby

如果greeting字符串没有逗号,如何在结果中得到逗号?你不能只使用

print(greeting, ',', salutation, name)

因为这会在逗号前引入一个空格。一种解决方案如下:

print(greeting + ',', salutation, name)

它只是将逗号添加到问候语中。如果需要,您可以指定自定义分隔符:

>>> print("I", "wish", "to", "register", "a", "complaint", sep="_")
I_wish_to_register_a_complaint

您还可以指定一个自定义的结束字符串来替换默认的换行符。例如,如果您提供一个空字符串,您可以稍后在同一行继续打印。

print('Hello,', end='')
print('world!')

这个程序打印出Hello, world!1

将某物作为另一物导入

通常,当你从一个模块中导入一些东西时,你可以使用

import somemodule

或者使用

from somemodule import somefunction

或者

from somemodule import somefunction, anotherfunction, yetanotherfunction

或者

from somemodule import *

只有当您确定要从给定模块导入所有内容时,才应该使用第四个版本。但是如果你有两个模块,每个模块都包含一个名为open的函数,那么你会怎么做呢?您可以简单地使用第一个表单导入模块,然后使用如下函数:

module1.open(...)
module2.open(...)

但是还有另一个选择:您可以在末尾添加一个as子句,并提供您想要使用的名称,或者是整个模块的名称:

>>> import math as foobar
>>> foobar.sqrt(4)
2.0

或者对于给定的函数:

>>> from math import sqrt as foobar
>>> foobar(4)
2.0

对于open函数,您可以使用以下内容:

from module1 import open as open1
from module2 import open as open2

Note

有些模块,比如os.path,是分层排列的(互相在里面)。有关模块结构的更多信息,请参见第十章中的封装章节。

分配魔法

不起眼的赋值语句也有一些窍门。

序列解包

您已经看到了很多赋值的例子,包括变量和部分数据结构(比如列表中的位置和片,或者字典中的槽),但是还有更多。您可以同时执行几项不同的任务。

>>> x, y, z = 1, 2, 3
>>> print(x, y, z)
1 2 3

听起来没什么用?你可以用它来交换两个(或更多)变量的内容。

>>> x, y = y, x
>>> print(x, y, z)
2 1 3

其实我这里做的叫做序列解包(或者 iterable 解包)。我有一个值序列(或者一个任意的可迭代对象),我把它分解成一个变量序列。让我说得更明白些。

>>> values = 1, 2, 3
>>> values
(1, 2, 3)
>>> x, y, z = values
>>> x
1

当函数或方法返回元组(或其他序列或可迭代对象)时,这尤其有用。假设您想从字典中检索(并删除)一个任意的键值对。然后,您可以使用popitem方法,该方法就是这样做的,将该对作为元组返回。然后可以将返回的元组直接解包到两个变量中。

>>> scoundrel = {'name': 'Robin', 'girlfriend': 'Marion'}
>>> key, value = scoundrel.popitem()
>>> key
'girlfriend'
>>> value
'Marion'

这允许函数返回多个值,打包成一个元组,通过一次赋值就可以轻松访问。您解包的序列必须具有与您在=符号左侧列出的目标一样多的项目;否则,Python 会在执行赋值时引发异常。

>>> x, y, z = 1, 2
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: need more than 2 values to unpack
>>> x, y, z = 1, 2, 3, 4
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: too many values to unpack

您可以使用星号运算符(*)收集多余的值,而不是确保值的数量完全匹配。例如:

>>> a, b, *rest = [1, 2, 3, 4]
>>> rest
[3, 4]

您也可以将这个带星号的变量放在其他位置。

>>> name = "Albus Percival Wulfric Brian Dumbledore"
>>> first, *middle, last = name.split()
>>> middle
['Percival', 'Wulfric', 'Brian']

赋值的右边可能是任何类型的序列,但是带星号的变量最终总是包含一个列表。即使值的数量完全匹配,也是如此。

>>> a, *b, c = "abc"
>>> a, b, c
('a', ['b'], 'c')

同样的聚集也可以用在函数参数列表中(见第六章)。

链式分配

当您想要将几个变量绑定到同一个值时,可以使用链式赋值作为一种快捷方式。这看起来有点像上一节中的同步赋值,只是这里只处理一个值:

x = y = somefunction()

和这个一样:

y = somefunction()
x = y

请注意,前面的语句可能与

x = somefunction()
y = somefunction()

有关这方面的更多信息,请参阅本章后面关于标识运算符(is)的部分。

扩充分配

不用写x = x + 1,你可以只把表达式操作符(在这个例子中是+)放在赋值操作符(=)前面,然后写x += 1。这就是所谓的扩充赋值,它适用于所有的标准操作符,比如*/%等等。

>>> x = 2
>>> x += 1
>>> x *= 2
>>> x
6

它也适用于其他数据类型(只要二元运算符本身适用于这些数据类型)。

>>> fnord = 'foo'
>>> fnord += 'bar'
>>> fnord *= 2
>>> fnord
'foobarfoobar'

扩充赋值可以使你的代码更加紧凑和简洁,在很多情况下,可读性更好。

积木:缩进的快乐

块并不是一种真正的语句类型,而是在处理接下来的两节时需要用到的东西。

块是一组在条件为真时可以执行的语句(条件语句),可以执行多次(循环),等等。块是通过缩进代码的一部分来创建的,也就是说,在它的前面加上空格。

Note

您也可以使用制表符来缩进您的块。Python 将制表符解释为移动到下一个制表位,每八个空格有一个制表位,但是标准和优选的样式是只使用空格,不使用制表符,特别是每一级缩进有四个空格。

块中的每一行都必须缩进相同的量。以下是伪代码(不是真正的 Python 代码),展示了缩进是如何工作的:

this is a line
this is another line:
    this is another block
    continuing the same block
    the last line of this block
phew, there we escaped the inner block

在许多语言中,一个特殊的单词或字符(例如,begin{)用于开始一个块,另一个(例如,end})用于结束它。在 Python 中,冒号(:)用来表示一个块即将开始,然后该块中的每一行都缩进(相同的量)。当你回到与某个封闭块相同的缩进量时,你就知道当前块已经结束了。(许多编程编辑和 ide 都知道这种块缩进是如何工作的,并且可以帮助您不费吹灰之力就得到它。)

现在,让我们来看看这些积木可以用来做什么。

条件和条件语句

到目前为止,您已经编写了一个接一个地执行每个语句的程序。是时候超越这一点,让您的程序选择是否执行语句块了。

这就是那些布尔值的用途

现在你终于需要那些你反复遇到的真值(也叫布尔值,以乔治·布尔命名,他对真值做了很多聪明的事情)。

Note

如果你一直在密切关注,你会注意到第一章的边栏,“先睹为快:If 语句”,它描述了if语句。直到现在我才真正正式地介绍它,正如你将看到的,它比我到目前为止告诉你的要多一点。

当作为布尔表达式求值时(例如,作为if语句的条件),解释器认为下列值为假:

False    None    0    ""    ()    []    {}

换句话说,标准值FalseNone、所有类型(包括 float、complex 等等)的数字零、空序列(比如空字符串、元组、列表)和空映射(比如字典)都被认为是假的。其他的 2 都解释为真,包括特殊值True3

明白了吗?这意味着 Python 中的每一个值都可以被解释为真值,这一开始可能会有点混乱,但也可能极其有用。尽管你有所有这些真值可以选择,但“标准”真值是TrueFalse。在某些语言中(比如 2.3 版之前的 C 和 Python),标准的真值是 0(代表假)和 1(代表真)。事实上,TrueFalse并没有什么不同——它们只是 0 和 1 的美化版本,看起来不同,但行为相同。

>>> True
True
>>> False
False
>>> True == 1
True
>>> False == 0
True
>>> True + False + 42
43

所以现在,如果你看到一个逻辑表达式返回1或者0(很可能是老版本的 Python),你就会知道真正的意思是True或者False

布尔值TrueFalse属于bool类型,可以用来(就像例如liststrtuple)转换其他值。

>>> bool('I think, therefore I am')
True
>>> bool(42)
True
>>> bool('')
False
>>> bool(0)
False

因为任何值都可以用作布尔值,所以您很可能很少(如果有的话)需要这样的显式转换(也就是说,Python 会自动为您转换值)。

Note

虽然[]""都是假的(也就是bool([]) == bool("") == False),但是不相等(也就是[] != "")。这同样适用于其他不同类型的虚假物体(例如,可能更明显的例子() != False)。

条件执行和 if 语句

真值可以组合,我们将回到如何做,但让我们先看看你可以用它们做什么。尝试运行以下脚本:

name = input('What is your name? ')
if name.endswith('Gumby'):
    print('Hello, Mr. Gumby')

这是if语句,它允许您进行条件执行。这意味着,如果条件(在if之后,冒号之前的表达式)的计算结果为真(如前所述),则执行下面的块(在本例中,是一个单独的print语句)。如果条件为假,那么块就不会被执行(但是你猜到了,不是吗?).

Note

在第一章的侧栏“先睹为快:if 语句”中,该语句写在一行上。这相当于使用单行块,如前面的示例所示。

else 子句

在上一节的例子中,如果您输入一个以“Gumby”结尾的名字,方法name.endswith返回True,使得if语句进入该块,并且打印出问候语。如果您愿意,您可以添加一个替代语句,即else子句(之所以称为子句,是因为它实际上不是一个单独的语句,只是if语句的一部分)。

name = input('What is your name?')
if name.endswith('Gumby'):
    print('Hello, Mr. Gumby')
else:
    print('Hello, stranger')

在这里,如果第一个块没有被执行(因为条件被评估为 false),您将进入第二个块。这确实说明了阅读 Python 代码是多么容易,不是吗?只要大声朗读代码(从if开始),它听起来就像一个正常(或者不太正常)的句子。

还有一个if语句的近亲,叫做条件表达式。这是 Python 版本的 c 中的三元运算符。这是一个使用ifelse来确定其值的表达式:

status = "friend" if name.endswith("Gumby") else "stranger"

每当条件(紧跟在if之后的)为真时,表达式的值是提供的第一个值(在本例中为"friend"),否则是最后一个值(在本例中为"stranger")。

elif 条款

如果要检查几个条件,可以用elif,是“else if”的简称。它是一个if子句和一个else子句的组合——一个带有条件的else子句。

num = int(input('Enter a number: '))
if num > 0:
    print('The number is positive')
elif num < 0:
    print('The number is negative')
else:
    print('The number is zero')

嵌套块

让我们加入一些花哨的东西。您可以在其他if语句块中包含if语句,如下所示:

name = input('What is your name? ')
if name.endswith('Gumby'):
    if name.startswith('Mr.'):
        print('Hello, Mr. Gumby')
    elif name.startswith('Mrs.'):
        print('Hello, Mrs. Gumby')
    else:
        print('Hello, Gumby')
else:
    print('Hello, stranger')

在这里,如果名称以“Gumby”结尾,那么也要检查名称的开头——在第一个块中的一个单独的if语句中。注意这里elif的用法。最后一个选择(else子句)没有条件——如果没有其他选择,就使用最后一个。如果你愿意,你可以省去任何一个else条款。如果您省略了内部的else子句,则不以“先生”或“夫人”开头的姓名将被忽略(假设姓名是“Gumby”)。如果去掉外层的else子句,陌生人会被忽略。

更复杂的条件

这就是关于if语句的全部知识。现在让我们回到条件本身,因为它们是条件执行中真正有趣的部分。

比较运算符

也许条件中使用的最基本的操作符是比较操作符。它们被用来(惊奇,惊讶)比较事物。表 5-1 总结了比较运算符。

表 5-1。

The Python Comparison Operators

| 表示 | 描述 | | --- | --- | | `x == y` | `x`等于`y`。 | | `x < y` | `x`小于`y`。 | | `x > y` | `x`大于`y`。 | | `x >= y` | `x`大于等于`y`。 | | `x <= y` | `x`小于等于`y`。 | | `x !=  y` | `x`不等于`y`。 | | `x is y` | `x`和`y`是同一个对象。 | | `x is not y` | `x`和`y`是不同的对象。 | | `x in y` | `x`是容器的成员(如序列)`y`。 | | `x not in y` | `x`不是容器的成员(如序列)`y`。 |

Comparing Incompatible Types

理论上,您可以比较任意两个对象xy的相对大小(使用运算符,如<<=),并获得一个真值。然而,这样的比较只有在xy属于相同或密切相关的类型(比如两个整数或一个整数和一个浮点数)时才有意义。

就像把一个整数加到一个字符串上没有多大意义一样,检查一个整数是否小于一个字符串看起来也是相当没有意义的。奇怪的是,在 Python 之前的版本中,你可以这样做。即使您使用的是较旧的 Python,您也应该远离这种比较,因为结果完全是任意的,并且可能在程序的每次执行之间发生变化。在 Python 3 中,不再允许以这种方式比较不兼容的类型。

在 Python 中比较可以被链接起来,就像赋值一样——你可以把几个比较操作符放在一个链中,就像这样:0 < age < 100

其中一些操作符值得特别注意,将在下面的部分中描述。

相等运算符

如果你想知道两个事物是否相等,使用等式操作符,写成双等号,==

>>> "foo" == "foo"
True
>>> "foo" == "bar"
False

双倍?为什么不能像数学中那样,只用一个等号呢?我相信你足够聪明,可以自己解决这个问题,但是让我们试试吧。

>>> "foo" = "foo"
SyntaxError: can't assign to literal

单等号就是赋值运算符,用来换东西,这不是你比较东西的时候想做的。

is:标识运算符

is操作符很有趣。好像和==一样工作,其实不然。

>>> x = y = [1, 2, 3]
>>> z = [1, 2, 3]
>>> x == y
True
>>> x == z
True
>>> x is y
True
>>> x is z
False

直到最后一个例子,这看起来很好,但然后你会得到那个奇怪的结果:x不是z,尽管它们是相等的。为什么呢?因为is测试的是身份,而不是平等。变量xy已经被绑定到同一个列表,而z只是被绑定到另一个列表,该列表恰好以相同的顺序包含相同的值。它们可能是相等的,但它们不是同一个对象。

这看起来不合理吗?考虑这个例子:

>>> x = [1, 2, 3]
>>> y = [2, 4]
>>> x is not y
True
>>> del x[2]
>>> y[1] = 1
>>> y.reverse()

在这个例子中,我从两个不同的列表开始,xy。如你所见,x is not y(正好是x is y的逆),这你已经知道了。我稍微改变了一下列表,虽然它们现在是相等的,但是它们仍然是两个独立的列表。

>>> x == y
True
>>> x is y
False

在这里,很明显,这两个列表是相等的,但并不完全相同。

总结一下,用==看两个对象是否相等,用is看是否相同(同一个对象)。

Caution

避免将is用于基本的、不可变的值,如数字和字符串。由于 Python 内部处理这些对象的方式,结果是不可预测的。

in:成员运算符

我已经介绍了in操作符(在第二章,在“成员资格”一节)。它可以用在条件中,就像所有其他比较运算符一样。

name = input('What is your name?')
if 's' in name:
    print('Your name contains the letter "s".')
else:
    print('Your name does not contain the letter "s".')

字符串和序列比较

当字符串按字母顺序排序时,将根据它们的顺序进行比较。

>>> "alpha" < "beta"
True

排序是按字母顺序的,但是字母表都是 Unicode 的,按代码点排序。

>>> "A326949_3_En_5_Figa_HTML.jpg<A326949_3_En_5_Figb_HTML.jpg"

True

实际上,字符是按序数值排序的。字母的序数值可以用ord函数找到,它的逆函数是chr:

>>> ord("A326949_3_En_5_Figc_HTML.jpg

128585

>>> ord("A326949_3_En_5_Figd_HTML.jpg

128586

>>> chr(128584)

'A326949_3_En_5_Fige_HTML.jpg

这种方法非常合理且一致,但有时可能与您自己的排序方式背道而驰。例如,大写字母可能不符合您的要求。

>>> "a" < "B"
False

一个技巧是忽略大写和小写字母的区别,使用字符串方法lower。这里有一个例子(见第三章):

>>> "a".lower() < "B".lower()
True
>>> 'FnOrD'.lower() == 'Fnord'.lower()
True

其他序列也以同样的方式进行比较,除了用其他类型的元素代替字符。

>>> [1, 2] < [2, 1]
True

如果序列包含其他序列作为元素,则相同的规则适用于这些序列元素。

>>> [2, [1, 4]] < [2, [1, 5]]
True

布尔运算符

现在,你有很多返回真值的东西。(事实上,鉴于所有值都可以解释为真值,所有表达式都返回它们。)但是您可能想要检查不止一个条件。例如,假设您要编写一个程序,它读取一个数字并检查它是否在 1 和 10 之间(包括 1 和 10)。你可以这样做:

number = int(input('Enter a number between 1 and 10: '))
if number <= 10:
    if number >= 1:
        print('Great!')
    else:
        print('Wrong!')
else:
    print('Wrong!')

这是可行的,但是很笨拙。你必须在两个地方写print 'Wrong!'的事实应该提醒你这种笨拙。重复劳动不是一件好事。那你是做什么的?太简单了。

number = int(input('Enter a number between 1 and 10: '))
if number <= 10 and number >= 1:
    print('Great!')
else:
    print('Wrong!')

Note

我本可以(而且很可能应该)通过使用下面的链式比较使这个例子更加简单:1 <= number <= 10

and运算符是所谓的布尔运算符。它接受两个真值,如果都为真,则返回 true,否则返回 false。您还有两个这样的操作符,ornot。有了这三个,你可以以任何你喜欢的方式组合真值。

if ((cash > price) or customer_has_good_credit) and not out_of_stock:
    give_goods()

Short-Circuit Logic and Conditional Expressions

布尔运算符有一个有趣的特性:它们只计算需要计算的内容。比如表达式x and y要求xy都为真;所以如果x为假,表达式立即返回假,不用担心y。实际上,如果x为假,则返回x;否则返回y。(你能看出这是如何给出预期含义的吗?)这种行为被称为短路逻辑(或懒惰求值):布尔运算符通常被称为逻辑运算符,正如您所看到的,第二个值有时会“短路”。这也适用于or。在表达式xy中,如果x为真,则返回;否则,返回y。(你能看出这有什么意义吗?)注意,这意味着布尔运算符之后的任何代码(比如函数调用)都可能根本不会被执行。您可能会在如下代码中看到这种行为:

name = input('Please enter your name: ') or '<unknown>'

如果没有输入姓名,or表达式的值为'<unknown>'。在许多情况下,您可能希望使用条件表达式,而不是这种简单的技巧,尽管像前面这样的语句确实有它们的用途。

断言

有一个if语句的有用亲戚,工作原理或多或少是这样的(伪代码):

if not condition:
    crash program

现在,你到底为什么想要这样的东西?原因很简单,当一个错误条件出现时,你的程序崩溃要比很久以后才崩溃要好。基本上,您可以要求某些事情为真(例如,当检查函数所需的参数属性时,或者作为初始测试和调试期间的辅助)。语句中使用的关键字是assert

>>> age = 10
>>> assert 0 < age < 100
>>> age = -1
>>> assert 0 < age < 100
Traceback (most recent call last):
   File "<stdin>", line 1, in ?
AssertionError

如果你知道某个条件必须为真,程序才能正常工作,那么将assert语句作为检查点放在程序中是很有用的。

可以在条件后添加一个字符串来解释断言。

>>> age = -1
>>> assert 0 < age < 100, 'The age must be realistic'
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
AssertionError: The age must be realistic

现在你知道了如果一个条件为真(或假)该如何做某件事,但是你如何多次做某件事呢?例如,您可能想要创建一个程序来提醒您每月支付租金,但是使用我们到目前为止所看到的工具,您需要编写这样的程序(伪代码):

send mail
wait one month send mail
wait one month send mail
wait one month
(... and so on)

但是,如果您希望它继续这样做,直到您停止它呢?基本上,您想要这样的东西(同样,伪代码):

while we aren't stopped:
    send mail
    wait one month

或者,我们举个更简单的例子。假设你想打印出从 1 到 100 的所有数字。再说一次,你可以用愚蠢的方法。

print(1)
print(2)
print(3)
...
print(99)
print(100)

但你不是因为想做傻事才开始用 Python 的吧?

while 循环

为了避免前面示例中繁琐的代码,这样做是很有用的:

x = 1
while x <= 100:
    print(x)
    x += 1

现在,你如何在 Python 中做到这一点?你猜对了——你就是这样做的。没那么复杂吧。您还可以使用循环来确保用户输入姓名,如下所示:

name = ''
while not name:
     name = input('Please enter your name: ')
print('Hello, {}!'.format(name))

试着运行这个,然后在被要求输入你的名字时按下回车键。你会看到问题又出现了,因为name还是一个空字符串,计算结果为 false。

Tip

如果您只输入一个空格字符作为您的姓名,会发生什么情况?试试看。它被接受是因为带有一个空格字符的字符串不是空的,因此不是 false。这肯定是我们的小程序中的一个缺陷,但很容易纠正:只需将while not name改为while not name or name.isspace()或者,也许是while not name.strip()

对于循环

while语句非常灵活。它可用于在任何条件为真时重复代码块。虽然一般来说这可能很好,但有时您可能想要一些适合您特定需求的东西。一个这样的需求是为一组值(或者,实际上,序列或其他可迭代对象)的每个元素执行一个代码块。

Note

基本上,可迭代对象是任何可以迭代的对象(也就是说,在for循环中使用)。在第九章中你会学到更多关于可迭代和迭代器的知识,但是现在,你可以简单地把它们看作序列。

您可以使用for语句来实现这一点:

words = ['this', 'is', 'an', 'ex', 'parrot']
for word in words:
    print(word)

或者

numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
for number in numbers:
    print(number)

因为在一系列数字上迭代(循环的另一种说法)是一件常见的事情,Python 有一个内置函数来为您确定范围。

>>> range(0, 10)
range(0, 10)
>>> list(range(0, 10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

范围像切片一样工作。它们包括第一个限制(在本例中为 0),但不包括最后一个限制(在本例中为 10)。通常,您希望范围从 0 开始,如果您只提供一个限制(这将是最后一个),这实际上是假定的。

>>> range(10)
range(0, 10)

下面的程序写出从 1 到 100 的数字:

for number in range(1,101):
    print(number)

请注意,这比我之前使用的while循环要紧凑得多。

Tip

如果您可以使用for循环而不是while循环,您可能应该这样做。

遍历字典

要循环遍历字典的键,可以使用普通的for语句,就像处理序列一样。

d = {'x': 1, 'y': 2, 'z': 3}
for key in d:
    print(key, 'corresponds to', d[key])

您可以使用字典方法,比如keys来检索这些键。如果只对值感兴趣,您可以使用d.values。您可能还记得d.items将键值对作为元组返回。关于for循环的一个伟大之处是你可以在其中使用序列解包。

for key, value in d.items():
    print(key, 'corresponds to', value)

Note

和往常一样,字典元素的顺序是未定义的。换句话说,当迭代一个字典的键或值时,您可以确定您将处理所有的键或值,但是您不知道以什么顺序。如果顺序很重要,可以将键或值存储在一个单独的列表中,例如,在迭代之前进行排序。如果您希望您的映射记住其条目的插入顺序,您可以使用来自collections模块的类OrderedDict

一些迭代工具

Python 有几个函数在迭代序列(或其他可迭代对象)时很有用。其中一些可以在itertools模块中获得(在第十章中提到),但也有一些内置函数非常方便。

并行迭代

有时候你想同时迭代两个序列。假设您有以下两个列表:

names = ['anne', 'beth', 'george', 'damon']
ages = [12, 45, 32, 102]

如果您想打印出相应年龄的姓名,您可以执行以下操作:

for i in range(len(names)):
    print(names[i], 'is', ages[i], 'years old')

在这里,i作为循环索引的标准变量名(这些东西就是这么叫的)。并行迭代的一个有用工具是内置函数zip,它将序列“压缩”在一起,返回一个元组序列。返回值是一个特殊的 zip 对象,用于迭代,但是可以使用list进行转换,以查看其内容。

>>> list(zip(names, ages))
[('anne', 12), ('beth', 45), ('george', 32), ('damon', 102)]

现在我们可以解开循环中的元组了。

for name, age in zip(names, ages):
    print(name, 'is', age, 'years old')

zip功能可以处理任意多的序列。重要的是要注意当序列长度不同时zip会做什么:当最短的序列用完时它会停止。

>>> list(zip(range(5), range(100000000)))
[(0, 0), (1, 1), (2, 2), (3, 3), (4, 4)]

编号迭代

在某些情况下,您希望迭代一系列对象,同时能够访问当前对象的索引。例如,您可能想要替换字符串列表中包含子字符串'xxx'的每个字符串。当然有很多方法可以做到这一点,但是假设您想按照下面的思路做一些事情:

for string in strings:
    if 'xxx' in string:
        index = strings.index(string) # Search for the string in the list of strings
        strings[index] = '[censored]'

这是可行的,但是似乎没有必要在替换之前搜索给定的字符串。此外,如果您没有替换它,搜索可能会给您错误的索引(即,同一单词以前出现过的索引)。更好的版本如下:

index = 0
for string in strings:
    if 'xxx' in string:
        strings[index] = '[censored]'
    index += 1

这似乎也有点尴尬,虽然可以接受。另一种解决方案是使用内置函数enumerate

for index, string in enumerate(strings):
    if 'xxx' in string:
        strings[index] = '[censored]'

该函数允许您迭代索引-值对,其中索引是自动提供的。

反向排序迭代

让我们看看另外两个有用的函数:reversedsorted。它们类似于列表方法reversesort(其中sorted采用的参数类似于sort采用的参数),但它们作用于任何序列或可迭代对象,而不是就地修改对象,它们返回反转和排序的版本。

>>> sorted([4, 3, 6, 8, 3])
[3, 3, 4, 6, 8]
>>> sorted('Hello, world!')
[' ', '!', ',', 'H', 'd', 'e', 'l', 'l', 'l', 'o', 'o', 'r', 'w']
>>> list(reversed('Hello, world!'))
['!', 'd', 'l', 'r', 'o', 'w', ' ', ',', 'o', 'l', 'l', 'e', 'H']
>>> ''.join(reversed('Hello, world!'))
'!dlrow ,olleH'

注意,虽然sorted返回一个列表,但是reversed返回一个更神秘的可迭代对象,就像zip一样。你不需要担心这到底意味着什么;你可以在for循环或join等方法中使用它,没有任何问题。你只是不能对它进行索引或切片,或者直接对它调用 list 方法。为了执行这些任务,只需用list转换返回的对象。

Tip

我们可以使用小写的技巧来得到正确的字母排序。例如,您可以使用str.lower作为sortsortedkey参数。例如,sorted("aBc", key=str.lower)返回['a', 'B', 'c']

打破循环

通常,循环只是执行一个块,直到它的条件变为假,或者直到它用完了所有的序列元素。但是有时您可能想要中断循环,开始一个新的迭代(执行块的一个“回合”),或者简单地结束循环。

破裂

要结束一个循环,可以使用break。假设您想要找到小于 100 的最大平方(整数自乘的结果)。然后从 100 开始向下迭代到 0。当你找到一个方块时,没有必要继续,所以你只需break退出循环。

from math import sqrt
for n in range(99, 0, -1):
    root = sqrt(n)
    if root == int(root):
        print(n)
        break

如果你运行这个程序,它会打印出81并停止。请注意,我为range添加了第三个参数——这是步长,序列中每对相邻数字之间的差。它可以用来向下迭代,就像我在这里做的那样,步长值为负,它可以用来跳过数字。

>>> range(0, 10, 2)
[0, 2, 4, 6, 8]

继续

continue语句的使用频率低于break。它导致当前迭代结束,并“跳到”下一个迭代的开始。它基本上意味着“跳过循环体的其余部分,但不要结束循环。”如果你有一个大而复杂的循环体,并且有几个可能的原因要跳过它,这可能是有用的。在这种情况下,您可以使用continue,如下所示:

for x in seq:
    if condition1: continue
    if condition2: continue
    if condition3: continue

    do_something()
    do_something_else()
    do_another_thing()
    etc()

然而,在许多情况下,简单地使用一个if语句也是一样好的。

for x in seq:
    if not (condition1 or condition2 or condition3):
        do_something()
        do_something_else()
        do_another_thing()
        etc()

尽管continue可能是一个有用的工具,但它不是必不可少的。然而,break语句是你应该习惯的,因为它经常和while True一起使用,这将在下一节解释。

while True/break 成语

Python 中的forwhile循环非常灵活,但是每隔一段时间,您可能会遇到一个让您希望拥有更多功能的问题。例如,假设当用户在提示下输入单词时,您想要做一些事情,而当没有提供单词时,您想要结束循环。一种方法是这样的:

word = 'dummy'
while word:
    word = input('Please enter a word: ')
    # do something with the word:
    print('The word was', word)

以下是一个会话示例:

Please enter a word: first
The word was first
Please enter a word: second
The word was second
Please enter a word:

这正如所期望的那样工作。(据推测,你会用这个单词做一些比把它打印出来更有用的事情。)但是,如你所见,这段代码有点难看。要首先进入循环,您需要为word分配一个虚拟(未使用)值。像这样的虚拟值通常是你做事不太正确的信号。让我们设法摆脱它。

word = input('Please enter a word: ')
while word:
    # do something with the word:
    print('The word was ', word)
    word = input('Please enter a word: ')

这里哑元没有了,但是我有重复的代码(这也是一件坏事):我需要在两个地方使用相同的赋值和调用input。我该如何避免呢?我可以用while True / break这个成语。

while True:
    word = input('Please enter a word: ')
    if not word: break
    # do something with the word:
    print('The word was ', word)

while True部分给你一个永远不会自行终止的循环。相反,您将条件放在循环内部的一个if语句中,当条件满足时,该语句将调用break。因此,您可以在循环内部的任何地方终止循环,而不仅仅是在开始处(就像普通的while循环一样)。if/break代码自然地将循环分成两部分:第一部分负责设置(这部分将被普通的while循环复制),另一部分利用第一部分的初始化,前提是循环条件为真。

尽管您应该小心不要在代码中过于频繁地使用break(因为这会使您的循环更难阅读,尤其是如果您在一个循环中放入了不止一个break),但是这种特殊的技术是如此的常见,以至于大多数 Python 程序员(包括您自己)可能都能够理解您的意图。

循环中的 else 子句

当你在循环中使用break语句时,通常是因为你已经“发现”了一些东西,或者因为一些事情已经“发生”了。爆发的时候很容易去做一件事(比如print(n)),但有时候没爆发的时候可能会想做一件事。但是你怎么知道呢?你可以使用一个布尔变量,在循环之前将它设置为False,当你退出时将它设置为True。然后你可以用一个if声明来检查你是否越狱了。

broke_out = False
for x in seq:
    do_something(x)
    if condition(x):
        broke_out = True
        break
    do_something_else(x)
if not broke_out:
    print("I didn't break out!")

更简单的方法是在循环中添加一个else子句——只有在没有调用break的情况下才会执行。让我们重复使用上一节关于break的例子。

from math import sqrt
for n in range(99, 81, -1):
    root = sqrt(n)
    if root == int(root):
        print(n)
        break
else:
    print("Didn't find it!")

请注意,我将下限(独占)更改为81来测试else子句。如果你运行这个程序,它会打印出“没找到!”因为(正如你在break一节中看到的)100 以下的最大正方形是 81。您可以在for循环和while循环中使用continuebreakelse子句。

理解——有点不靠谱

列表理解是从其他列表中制作列表的一种方式(类似于集合理解,如果你知道数学中的这个术语的话)。它的工作方式类似于for循环,实际上非常简单。

>>> [x * x for x in range(10)]
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

该列表由range(10)中每个xx*x组成。很简单吗?如果只想打印出那些能被 3 整除的方块呢?然后你可以使用模操作符——当y被 3 整除时y % 3返回零。(注意只有在x能被 3 整除的情况下x*x才能被 3 整除。)你通过给它添加一个if部分来把它放入你的列表理解中。

>>> [x*x for x in range(10) if x % 3 == 0]
[0, 9, 36, 81]

还可以添加更多的for零件。

>>> [(x, y) for x in range(3) for y in range(3)]
[(0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2), (2, 0), (2, 1), (2, 2)]

作为比较,以下两个for循环构建了相同的列表:

result = []
for x in range(3):
    for y in range(3)
        result.append((x, y))

这可以与一个if子句结合,就像前面一样。

>>> girls = ['alice', 'bernice', 'clarice']
>>> boys = ['chris', 'arnold', 'bob']
>>> [b+'+'+g for b in boys for g in girls if b[0] == g[0]]
['chris+clarice', 'arnold+alice', 'bob+bernice']

这就给出了名字首字母相同的男女生对。

A Better Solution

男孩/女孩配对的例子不是特别有效,因为它检查每一个可能的配对。在 Python 中有很多方法可以解决这个问题。亚历克斯·马尔泰利建议如下:

girls = ['alice', 'bernice', 'clarice']
boys = ['chris', 'arnold', 'bob']
letterGirls = {}
for girl in girls:
    letterGirls.setdefault(girl[0], []).append(girl)
print([b+'+'+g for b in boys for g in letterGirls[b[0]]])

这个程序构建了一个名为letterGirls的字典,其中每个条目都有一个字母作为键,一个女孩名字的列表作为值。(setdefault字典方法在前一章中有描述。)在构建了这个字典之后,list comprehension 循环遍历所有的男孩,并且查找其名字以与当前男孩相同的字母开头的所有女孩。这样,列表理解不需要尝试男孩和女孩的每一种可能的组合,并检查第一个字母是否匹配。

使用普通的括号而不是方括号不会给你一个“元组理解”——你将最终得到一个生成器。更多信息请参见第九章中的边栏“多圈发电机”。但是,您可以使用花括号来执行字典理解。

>>> squares = {i:"{} squared is {}".format(i, i**2) for i in range(10)}
>>> squares[8]
'8 squared is 64'

for前面不是一个单一的表达式,而是用冒号分隔的两个表达式。这些将成为键和它们相应的值。

三个在路上

为了结束这一章,让我们快速看一下另外三个语句:passdelexec

什么都没发生!

有时候你什么都不需要做。这可能不经常发生,但当它发生时,知道您有pass语句是很好的。

>>> pass
>>>

这里没什么事情。

现在,你到底为什么想要一份什么都不做的声明?当您编写代码时,它可以作为一个占位符。例如,您可能已经编写了一个if语句,并且想要尝试一下,但是您缺少其中一个块的代码。请考虑以下几点:

if name == 'Ralph Auldus Melish':
    print('Welcome!')
elif name == 'Enid':
    # Not finished yet ...
elif name == 'Bill Gates':
    print('Access Denied')

这段代码不会运行,因为空块在 Python 中是非法的。要解决这个问题,只需在中间的块中添加一个pass语句。

if name == 'Ralph Auldus Melish':
    print('Welcome!')
elif name == 'Enid':
    # Not finished yet ...
    pass
elif name == 'Bill Gates':
    print('Access Denied')

Note

注释和pass语句组合的另一种方法是简单地插入一个字符串。这对未完成的函数(见第六章)和类(见第七章)特别有用,因为它们将充当文档字符串(在第六章中解释)。

用 del 删除

一般来说,Python 会删除不再使用的对象(因为不再通过任何变量或数据结构的一部分来引用它们)。

>>> scoundrel = {'age': 42, 'first name': 'Robin', 'last name': 'of Locksley'}
>>> robin = scoundrel
>>> scoundrel
{'age': 42, 'first name': 'Robin', 'last name': 'of Locksley'}
>>> robin
{'age': 42, 'first name': 'Robin', 'last name': 'of Locksley'}
>>> scoundrel = None
>>> robin
{'age': 42, 'first name': 'Robin', 'last name': 'of Locksley'}
>>> robin = None

起初,robinscoundrel都绑定到同一个字典。所以当我把None分配给scoundrel时,字典仍然可以通过robin获得。但是当我也将None赋值给robin时,字典突然在电脑的内存中浮动,而且没有名字。我无法检索或使用它,所以 Python 解释器(以其无限的智慧)简单地删除了它。(这叫垃圾收集。)注意,我也可以使用除了None之外的任何值。字典也会消失。

另一种方法是使用del语句(我们在第 2 和 4 章中使用它来删除序列和字典元素,还记得吗?).这不仅删除了对对象的引用,还删除了名称本身。

>>> x = 1
>>> del x
>>> x
Traceback (most recent call last):
  File "<pyshell#255>", line 1, in ?
    x
NameError: name 'x' is not defined

这看起来很简单,但实际上有时很难理解。例如,在下面的例子中,xy指的是同一个列表:

>>> x = ["Hello", "world"]
>>> y = x
>>> y[1] = "Python"
>>> x
['Hello', 'Python']

您可能认为删除了x,也就删除了y,但事实并非如此。

>>> del x
>>> y
['Hello', 'Python']

这是为什么呢?xy指的是同一个列表,但是删除x一点也不影响y。这是因为您只删除了名称,而不是列表本身(值)。事实上,在 Python 中没有办法删除值——您也不需要这样做,因为每当您不再使用该值时,Python 解释器就会自动删除。

用 exec 和 eval 执行和计算字符串

有时,您可能希望“动态地”创建 Python 代码,并将其作为一条语句来执行,或者作为一个表达式来求值。这有时可能接近于黑魔法——考虑你自己被警告了。execeval都是函数,但是exec曾经是一个独立的语句类型,而eval与它密切相关,所以我在这里讨论它们。

Caution

在本节中,您将学习执行存储在字符串中的 Python 代码。这是一个巨大的潜在安全漏洞。如果您执行一个字符串,其中部分内容是由用户提供的,那么您很少或根本无法控制您正在执行的代码。这在网络应用中尤其危险,比如通用网关接口(CGI)脚本,你将在第十五章中了解到。

高级管理人员

exec函数用于执行一个字符串。

>>> exec("print('Hello, world!')")
Hello, world!

然而,使用带有单个参数的exec语句很少是一件好事。在大多数情况下,您希望为它提供一个名称空间——一个放置变量的地方。否则,代码将破坏您的名称空间(即,更改您的变量)。例如,假设代码使用了名称sqrt

>>> from math import sqrt
>>> exec("sqrt = 1")
>>> sqrt(4)
Traceback (most recent call last):
  File "<pyshell#18>", line 1, in ?
    sqrt(4)
TypeError: object is not callable: 1

好吧,你为什么要做这种事?exec函数主要在动态构建代码串时有用。如果字符串是由从其他地方(可能是从用户那里)获得的部分构建的,那么您很难确定它将包含什么。所以为了安全起见,你给它一个字典,作为它的名称空间。

Note

名称空间或范围的概念是一个重要的概念。你将在下一章深入研究它,但是现在,你可以把一个名称空间想象成一个保存你的变量的地方,就像一个看不见的字典。因此,当你执行一个类似于x = 1的赋值时,你将键x和值1存储在当前的名称空间中,这个名称空间通常是全局名称空间(到目前为止,我们大部分时间都在使用这个名称空间),但也不是必须如此。

通过添加第二个参数可以做到这一点,这个参数是一个字典,它将作为代码字符串的名称空间。 4

>>> from math import sqrt
>>> scope = {}
>>> exec('sqrt = 1', scope)
>>> sqrt(4)
2.0
>>> scope['sqrt']
1

如您所见,潜在的破坏性代码没有覆盖sqrt函数。该函数就像它应该的那样工作,从exec赋值产生的sqrt变量可以从作用域中获得。

注意,如果你试图打印出scope,你会看到它包含了很多东西,因为名为__builtins__的字典是自动添加的,包含了所有的内置函数和值。

>>> len(scope)
2
>>> scope.keys()
['sqrt', '__builtins__']

evaluate 评价

一个类似于exec的内置函数是eval(代表“evaluate”)。正如exec执行一系列 Python 语句一样,eval计算一个 Python 表达式(用字符串编写)并返回结果值。(exec不返回任何东西,因为它本身就是一个语句。)例如,您可以使用以下代码制作一个 Python 计算器:

>>> eval(input("Enter an arithmetic expression: "))
Enter an arithmetic expression: 6 + 18 * 2
42

您可以用eval提供一个名称空间,就像用exec一样,尽管表达式很少像语句通常做的那样重新绑定变量。

Caution

尽管表达式通常不重新绑定变量,但它们肯定可以(例如,通过调用重新绑定全局变量的函数)。因此,对一段不受信任的代码使用 eval 并不比使用 exec 更安全。目前,在 Python 中没有执行不可信代码的安全方法。一种替代方法是使用 Python 的实现,比如 Jython(参见第十七章)并使用一些本地机制,比如 Java 沙箱。

Priming the Scope

为 exec 或 eval 提供命名空间时,也可以在实际使用命名空间之前将一些值放入。

>>> scope = {}
>>> scope['x'] = 2
>>> scope['y'] = 3
>>> eval('x * y', scope)
6

同样,一个 exec 或 eval 调用的作用域可以在另一个调用中再次使用。

>>> scope = {}
>>> exec('x = 2', scope)
>>> eval('x * x', scope)
4

你可以用这种方式构建相当复杂的程序,但是……你可能不应该这么做。

快速总结

在本章中,您看到了几种陈述。

  • 打印:您可以使用print语句打印几个值,用逗号分隔它们。如果以逗号结束语句,后面的print语句将在同一行继续打印。
  • 导入:有时您不喜欢您想要导入的函数的名称——也许您已经在其他地方使用了该名称。您可以使用import … as …语句在本地重命名一个函数。
  • 赋值:您看到了序列解包和链式赋值的神奇之处,您可以一次给几个变量赋值,并且通过增加赋值,您可以就地改变一个变量。
  • 块:块被用作通过缩进对语句进行分组的一种方式。它们用在条件和循环中,正如你在本书后面看到的,用在函数和类定义中,等等。
  • 条件语句:条件语句要么执行一个块,要么不执行,这取决于一个条件(布尔表达式)。几个条件句可以用if / elif / else串起来。这个主题的一个变体是条件表达式a if b else c
  • 断言:断言简单地断言某事(布尔表达式)为真,可选地用一个字符串解释为什么必须如此。如果表达式碰巧为假,断言会使你的程序暂停(或者实际上引发一个异常——详见第八章)。及早发现错误比让它在你的程序中偷偷摸摸直到你不知道它起源于哪里要好。
  • 循环:可以对序列中的每个元素(比如一组数字)执行一个块,也可以在条件为真时继续执行。要跳过剩余的块并继续下一次迭代,使用continue语句;要打破循环,使用break语句。可选地,您可以在循环末尾添加一个else子句,如果您没有在循环中执行任何break语句,该子句将被执行。
  • 理解:这些不是真正的语句——它们是看起来很像循环的表达式,这就是为什么我把它们和循环语句归为一类。通过列表理解,您可以从旧列表构建新列表,对元素应用函数,过滤掉不需要的列表,等等。这种技术非常强大,但是在许多情况下,使用普通的循环和条件语句(总是能完成工作)可能更具可读性。类似的表达可以用来构造字典。
  • passdelexeceval:pass语句什么也不做,例如,它可以用作占位符。del语句用于删除变量或数据结构的一部分,但不能用于删除值。exec函数用于执行一个字符串,就像它是一个 Python 程序一样。eval函数计算字符串中的表达式并返回结果。

本章的新功能

| 功能 | 描述 | | --- | --- | | `chr(n)` | 当传递序数`n` (0 ≤ n < 256)时,返回一个单字符字符串 | | `eval(source[, globals[, locals]])` | 将字符串作为表达式计算并返回值 | | `exec(source[, globals[, locals]])` | 将字符串作为语句进行计算和执行 | | `enumerate(seq)` | 产生适合迭代的`(index, value)`对 | | `ord(c)` | 返回单字符字符串的整数序数值 | | `range([start,] stop[, step])` | 创建整数列表 | | `reversed(seq)` | 以逆序产生`seq`的值,适合迭代 | | `sorted(seq[, cmp][, key][, reverse])` | 返回一个列表,其中包含按排序顺序排列的值`seq` | | `xrange([start,] stop[, step])` | 创建一个`xrange`对象,用于迭代 | | `zip(seq1, seq2,…)` | 创建适合并行迭代的新序列 |

什么现在?

现在你已经清除了基础。你可以实现任何你能想到的算法;您可以读入参数并打印出结果。在接下来的几章中,你将学到一些东西,这些东西将帮助你编写更大的程序,而不会丢失大局。这种东西叫做抽象。

Footnotes 1

这将只在脚本中工作,而不是在交互式 Python 会话中。在交互会话中,每个语句都将被单独执行(并打印其内容)。

2

至少当我们谈论内置类型时——正如你在第九章看到的,你可以影响你自己构造的对象被解释为真还是假。

3

正如 Python 老手 Laura Creighton 所说,这种区别更接近于有与无,而不是真与假。

4

事实上,您可以为 exec 提供两个名称空间,一个全局名称空间和一个本地名称空间。全局的必须是字典,但是本地的可以是任何映射。这同样适用于 eval。

六、抽象

在本章中,你将学习如何将语句分组到函数中,这使你能够告诉计算机如何做某事,并且只告诉它一次。你不需要一遍又一遍地给它同样详细的指示。这一章提供了对参数和作用域的全面介绍,你将学习什么是递归以及它能为你的程序做什么。

懒惰是一种美德

到目前为止,我们写的程序都很小,但是如果你想做得更大,你很快就会遇到麻烦。考虑一下,如果您在一个地方写了一些代码,同时需要在另一个地方使用它,会发生什么情况。例如,假设您编写了一段计算斐波那契数(一系列数字,其中每个数字都是前两个数字的和)的代码。

fibs = [0, 1]
for i in range(8):
    fibs.append(fibs[-2] + fibs[-1])

运行这个之后,fibs包含前十个斐波那契数列。

>>> fibs
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

如果你想一次计算前十个斐波纳契数,这是没问题的。您甚至可以将for循环改为使用动态范围,结果序列的长度由用户提供。

fibs = [0, 1]
num = int(input('How many Fibonacci numbers do you want? '))
for i in range(num-2):
    fibs.append(fibs[-2] + fibs[-1])
print(fibs)

但是,如果您还想将这些数字用于其他用途,该怎么办呢?您当然可以在需要时再次编写相同的循环,但是如果您编写了一段更复杂的代码,比如下载一组网页并计算所有使用的单词的频率,会怎么样呢?您是否仍然希望多次编写所有代码,每次需要时编写一次?不,真正的程序员不会这么做。真正的程序员是懒惰的——不是坏的懒惰,而是他们不做不必要的工作。

那么真正的程序员是怎么做的呢?他们使他们的程序更加抽象。您可以使前面的程序更加抽象,如下所示:

num = input('How many numbers do you want? ')
print(fibs(num))

这里只具体写这个程序特有的东西(读入数字,打印出结果)。实际上,计算斐波那契数列是以一种抽象的方式完成的:你只需告诉计算机去做。你没有具体说应该怎么做。您创建了一个名为fibs的函数,并在需要小 Fibonacci 程序的功能时使用它。如果你在几个地方都需要它的话,那就省了你很多力气。

抽象和结构

抽象作为一种节省劳动力的方式是有用的,但是它实际上比那更重要。这是让计算机程序为人类所理解的关键(这是必不可少的,无论你是在编写还是在阅读它们)。计算机本身对非常具体和特定的指令非常满意,但人类通常不满意。例如,如果你问我去电影院的方向,你不会希望我回答:“向前走 10 步,向左转 90 度,再走 5 步,向右转 45 度,走 123 步。”你很快就会迷失方向,不是吗?

现在,如果我告诉你“沿着这条街一直走到一座桥,穿过桥,电影院就在你的左边”,你一定会明白我的意思。重点是你已经知道如何走在街上,如何过桥。你也不需要关于如何做的明确指示。

你以类似的方式构建计算机程序。你的程序应该非常抽象,比如“下载页面,计算频率,打印每个单词的频率”这很容易理解。事实上,让我们现在就把这个高级描述翻译成 Python 程序。

page = download_page()
freqs = compute_frequencies(page)
for word, freq in freqs:
    print(word, freq)

通过阅读,任何人都可以理解这个程序是做什么的。但是,您还没有明确说明它应该如何做。你只需要告诉电脑下载网页并计算频率。这些操作的细节需要写在别的地方——在单独的函数定义中。

创建自己的函数

函数是您可以调用的东西(可能带有一些参数—您放在括号中的东西),它执行一个操作并返回值。 1 一般来说,你可以用内置函数callable来判断某个东西是否可调用。

>>> import math
>>> x = 1
>>> y = math.sqrt
>>> callable(x)
False
>>> callable(y)
True

正如您从上一节所知道的,创建函数是结构化编程的核心。那么如何定义一个函数呢?您可以通过def(或“函数定义”)语句来实现。

def hello(name):
     return 'Hello, ' + name + '!'

运行这个函数之后,您就有了一个新的可用函数,称为hello,它返回一个字符串,该字符串带有作为唯一参数给出的名称的问候语。您可以像使用内置函数一样使用该函数。

>>> print(hello('world'))
Hello, world!
>>> print(hello('Gumby'))
Hello, Gumby!

很整洁,是吧?考虑如何编写一个函数来返回斐波纳契数列。放轻松!您只需使用以前的代码,但不是从用户那里读入一个数字,而是将它作为参数接收。

def fibs(num):
     result = [0, 1]
     for i in range(num-2):
         result.append(result[-2] + result[-1])
     return result

运行这条语句后,您基本上已经告诉了解释器如何计算斐波那契数。现在你再也不用担心细节了。您只需使用函数fibs

>>> fibs(10)
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
>>> fibs(15)
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377]

在这个例子中,numresult的名字是任意的,但是return很重要。return语句用于从函数中返回一些东西(这也是我们在前面的hello函数中使用它的方式)。

记录功能

如果您想将您的函数文档化,以便您确信其他人以后会理解它们,您可以添加注释(以散列符号开头,#)。另一种写注释的方式就是简单的自己写字符串。这样的字符串在某些地方特别有用,比如紧接在一条def语句之后(以及在一个模块或一个类的开始——你会在第七章中了解到更多关于类的知识,在第十章中了解到更多关于模块的知识)。如果你把一个字符串放在一个函数的开头,它将作为函数的一部分被存储,并被称为 docstring。下面的代码演示了如何向函数中添加 docstring:

def square(x):
    'Calculates the square of the number x.'
    return x * x

可以像这样访问 docstring:

>>> square.__doc__
'Calculates the square of the number x.'

Note

_ _doc_ _是一个函数属性。你会在第七章学到更多关于属性的知识。属性名中的双下划线意味着这是一个特殊的属性。像这样的特殊或“魔法”属性将在第九章中讨论。

一个名为help的特殊内置函数可能非常有用。如果您在交互式解释器中使用它,您可以获得关于函数的信息,包括它的文档字符串。

>>> help(square)
Help on function square in module __main__:

square(x)
Calculates the square of the number x.

你会在第十章中再次遇到help函数。

不是真正函数的函数

从数学意义上来说,函数总是返回根据其参数计算出来的值。在 Python 中,有些函数不返回任何东西。在其他语言(如 Pascal)中,这样的函数可能被称为其他东西(如过程),但在 Python 中,函数就是函数,即使它在技术上不是。不返回任何东西的函数就是没有return语句。或者,如果他们确实有return语句,那么在return这个词之后就没有值了。

def test():
    print('This is printed')
    return
    print('This is not')

这里,return语句只是用来结束函数。

>>> x = test()
This is printed

如您所见,第二个print语句被跳过了。(这有点像在循环中使用break,除了你中断了函数。)但是如果test不返回任何东西,x指的是什么呢?让我们看看:

>>> x
>>>

什么都没有。让我们靠近一点看。

>>> print(x)
None

那是一个熟悉的值:None。所以所有的函数都会返回一些东西。只是你不告诉他们回什么,他们就回None

Caution

不要让这种默认行为绊倒你。如果你从if语句等内部返回值,确保你已经涵盖了所有情况,这样你就不会在调用者期待一个序列时意外返回None

参数的魔力

使用函数非常简单,创建函数也并不复杂。然而,参数的工作方式可能需要一些时间来适应。首先,让我们从基础做起。

价值观从何而来?

有时,当定义一个函数时,您可能想知道参数是从哪里得到它们的值的。

总的来说,你不应该担心这个。编写函数就是为程序的任何部分(甚至可能是其他程序)提供可能需要的服务。您的任务是确保函数在提供了可接受的参数的情况下正常工作,如果参数错误,最好以明显的方式失败。(一般来说,你可以用assert或异常来做这件事。你将在第八章中了解更多关于异常的内容。)

Note

在 def 语句中,写在函数名后面的变量通常被称为函数的形参。调用函数时提供的值称为实际参数或自变量。总的来说,我不会对区分太挑剔。如果它很重要,我会调用实际的参数值,以区别于形式参数,形式参数更像变量。

我可以更改参数吗?

所以你的函数通过它的参数得到一组值。你能改变他们吗?如果你做了会怎么样?参数和其他变量一样都是变量,所以它的工作方式和你预期的一样。给函数内部的参数赋一个新值根本不会改变外部世界。

>>> def try_to_change(n):
...     n = 'Mr.  Gumby'
...
>>> name = 'Mrs. Entity'
>>> try_to_change(name)
>>> name
'Mrs. Entity'

try_to_change内部,参数n获得了一个新值,但是正如你所看到的,这并不影响变量name。毕竟是完全不同的变量。就好像你做了这样的事情:

>>> name = 'Mrs. Entity'
>>> n = name             # This is almost what happens when passing a parameter
>>> n = 'Mr. Gumby'      # This is done inside the function
>>> name
'Mrs. Entity'

这里,结果是显而易见的。当变量n改变时,变量name不变。同样,当您重新绑定(赋值)函数内部的参数时,函数外部的变量不会受到影响。

Note

参数保存在所谓的局部范围内。作用域将在本章后面讨论。

字符串(以及数字和元组)是不可变的,这意味着你不能修改它们(也就是说,你只能用新值替换它们)。所以,关于它们作为参数就不多说了。但是考虑一下如果你使用一个可变的数据结构,比如一个列表,会发生什么。

>>> def change(n):
...     n[0]  = 'Mr. Gumby'
...
>>> names  = ['Mrs. Entity', 'Mrs. Thing']
>>> change(names)
>>> names
['Mr. Gumby', 'Mrs. Thing']

在本例中,参数发生了变化。这个例子和上一个例子有一个重要的区别。在前一个示例中,我们简单地给了局部变量一个新值,但是在这个示例中,我们实际上修改了变量names绑定到的列表。这听起来奇怪吗?其实也没那么奇怪。让我们在没有函数调用的情况下再做一次。

>>> names = ['Mrs. Entity', 'Mrs. Thing']
>>> n = names            # Again pretending  to  pass names  as a parameter
>>> n[0] = 'Mr. Gumby'   # Change the list
>>> names
['Mr.  Gumby', 'Mrs. Thing']

你以前见过这种事情。当两个变量引用同一个列表时,它们。。。参考同一个列表。真的就这么简单。如果你想避免这种情况,你必须复制一份清单。当对序列进行切片时,返回的切片总是一个副本。因此,如果你对整个列表做一个切片,你会得到一个拷贝。

>>> names = ['Mrs.  Entity', 'Mrs. Thing']
>>> n = names[:]

现在nnames包含两个独立的(不相同的)列表,它们是相等的。

>>> n is names
False
>>> n == names
True

如果你现在改变n(就像你在函数change里做的那样),它不会影响names

>>> n[0]  = 'Mr.  Gumby'
>>> n
['Mr.  Gumby', 'Mrs. Thing']
>>> names
['Mrs. Entity',  'Mrs. Thing']

让我们用change试试这一招。

>>> change(names[:])
>>> names
['Mrs. Entity',  'Mrs. Thing']

现在参数n包含了一个副本,您的原始列表是安全的。

Note

如果您想知道,函数的局部名称(包括参数)不会与函数外部的名称(即全局名称)冲突。有关这方面的更多信息,请参见本章后面对作用域的讨论。

为什么我要修改我的参数?

使用一个函数来改变一个数据结构(比如一个列表或者一个字典)是将抽象引入到你的程序中的一个好方法。假设你想写一个程序来存储名字,并允许你通过名字、中间名或姓来查找人们。您可以使用这样的数据结构:

storage = {}
storage['first'] = {}
storage['middle'] = {}
storage['last'] = {}

数据结构storage是一个有三个键的字典:'first''middle''last'。在每一个键下,都存储了另一个字典。在这些子字典中,您将使用姓名(名、中间名或姓)作为键,并插入人员列表作为值。例如,要将我添加到此结构中,您可以执行以下操作:

>>> me = 'Magnus Lie  Hetland'
>>> storage['first']['Magnus'] = [me]
>>> storage['middle']['Lie'] = [me]
>>> storage['last']['Hetland'] = [me]

在每个键下,存储一个人员列表。在这种情况下,列表只包含me

现在,如果您想要一个中间名为 Lie 的所有注册人员的列表,您可以执行以下操作:

>>> storage['middle']['Lie']
['Magnus Lie  Hetland']

正如您所看到的,向这个结构中添加人员有点繁琐,特别是当您有更多的人具有相同的名字、中间名或姓氏时,因为这时您需要扩展已经存储在该名称下的列表。让我们添加我的妹妹,并假设你不知道什么已经存储在数据库中。

>>> my_sister  = 'Anne Lie Hetland'
>>> storage['first'].setdefault('Anne', []).append(my_sister)
>>> storage['middle'].setdefault('Lie', []).append(my_sister)
>>> storage['last'].setdefault('Hetland', []).append(my_sister)
>>> storage['first']['Anne']
['Anne Lie  Hetland']
>>> storage['middle']['Lie']
['Magnus Lie Hetland', 'Anne Lie Hetland']

想象一下,编写一个像这样充满更新的大程序。它会很快变得非常笨拙。

抽象的要点是隐藏所有更新的血淋淋的细节,你可以用函数来做。我们先做一个函数来初始化一个数据结构。

def init(data):
    data['first'] = {}
    data['middle'] = {}
    data['last'] = {}

在前面的代码中,我只是将初始化语句移到了函数内部。你可以这样使用它:

>>> storage = {}
>>> init(storage)
>>> storage
{'middle': {}, 'last': {}, 'first': {}}

如您所见,该函数负责初始化,使得代码可读性更好。

Note

字典的键没有特定的顺序,所以当字典打印出来时,顺序可能会有所不同。如果您的解释器中的顺序不同,请不要担心。

在写存储名字的函数之前,让我们先写一个获取名字的函数。

def lookup(data, label, name):
    return data[label].get(name)

有了lookup,你可以带一个标签(比如'middle')和一个名字(比如'Lie'),得到一个返回的全名列表。换句话说,假设存储了我的名字,您可以这样做:

>>> lookup(storage, 'middle', 'Lie')
['Magnus Lie Hetland']

请注意,返回的列表与存储在数据结构中的列表是相同的,这一点很重要。因此,如果您更改列表,更改也会影响数据结构。(找不到人就不是这样;然后你简单的返回None。)

现在是时候编写在你的结构中存储一个名字的函数了(不要担心它对你来说没有意义)。

def store(data, full_name):
    names = full_name.split()
    if len(names) == 2: names.insert(1, '')
    labels = 'first', 'middle', 'last'

    for label, name in zip(labels, names):
        people = lookup(data, label, name)
        if people:
            people.append(full_name)
        else:
            data[label][name] = [full_name]

store功能执行以下步骤:

  1. 您输入函数,将参数datafull_name设置为您从外界接收的一些值。
  2. 你通过拆分full_name给自己做一个名为names的列表。
  3. 如果names的长度是 2(你只有名和姓),你插入一个空字符串作为中间名。
  4. 您将字符串'first''middle''last'作为一个元组存储在labels中。(您当然可以在这里使用列表;只是方便去掉括号。)
  5. 您使用zip函数组合标签和名称,使它们正确排列,对于每一对(label, name),您执行以下操作:
    • 获取属于给定标签和名称的列表。
    • full_name添加到列表中,或者根据需要插入一个新列表。

让我们试一试:

>>> MyNames  = {}
>>> init(MyNames)
>>> store(MyNames, 'Magnus Lie Hetland')
>>> lookup(MyNames, 'middle', 'Lie')
['Magnus Lie Hetland']

似乎很管用。让我们再试一些。

>>> store(MyNames, 'Robin Hood')
>>> store(MyNames, 'Robin Locksley')
>>> lookup(MyNames, 'first', 'Robin')
['Robin Hood', 'Robin Locksley']
>>> store(MyNames, 'Mr. Gumby')
>>> lookup(MyNames, 'middle', '')
['Robin Hood', 'Robin Locksley',  'Mr. Gumby']

如您所见,如果更多的人共享相同的名字、中间名或姓氏,您可以一起检索它们。

Note

这种应用非常适合面向对象编程,这将在下一章中解释。

如果我的参数是不可变的呢?

在一些语言中(比如 C++、Pascal 和 Ada),重新绑定参数并让这些变化影响函数外部的变量是一件很平常的事情。在 Python 中,这是不可能的。您只能修改参数对象本身。但是如果你有一个不可变的参数,比如一个数字呢?

抱歉,但是它不能被做。你应该做的是从你的函数中返回所有你需要的值(如果不止一个的话,作为一个元组)。例如,将变量的数值递增 1 的函数可以写成这样:

>>> def  inc(x): return x + 1
...
>>> foo = 10
>>> foo = inc(foo)
>>> foo
11

如果你真的想修改你的参数,你可以使用一个技巧,比如把你的值包装在一个列表中,就像这样:

>>> def inc(x): x[0] = x[0] + 1
...
>>> foo = [10]
>>> inc(foo)
>>> foo
[11]

不过,简单地返回新值会是一个更干净的解决方案。

关键字参数和默认值

我们到目前为止一直使用的参数被称为位置参数,因为它们的位置很重要——事实上,比它们的名称更重要。本节介绍的技术可以让您完全避开这些位置,虽然它们可能需要一些时间来适应,但是随着程序规模的增长,您会很快发现它们是多么有用。

考虑以下两个函数:

def  hello_1(greeting, name):
     print('{}, {}!'.format(greeting, name))

def  hello_2(name, greeting):
     print('{}, {}!'.format(name, greeting))

它们做的事情完全一样,只是参数名颠倒了。

>>> hello_1('Hello', 'world')
Hello, world!
>>> hello_2('Hello', 'world')
Hello, world!

有时(尤其是当您有许多参数时)顺序可能很难记住。为了方便起见,您可以提供参数的名称。

>>> hello_1(greeting='Hello', name='world')
Hello, world!

这里的顺序根本不重要。

>>> hello_1(name='world', greeting='Hello')
Hello, world!

然而,这些名字确实存在(你可能已经收集到了)。

>>> hello_2(greeting='Hello', name='world')
world, Hello!

以这样的名称提供的参数称为关键字参数。就其本身而言,关键字参数的关键优势在于它们可以帮助阐明每个参数的作用。不需要像这样使用一些奇怪和神秘的调用:

>>> store('Mr. Brainsample', 10, 20, 13, 5)

你可以用这个:

>>> store(patient='Mr. Brainsample', hour=10, minute=20, day=13, month=5)

尽管需要更多的输入,但是每个参数的作用是绝对清楚的。还有,如果你把顺序搞混了,也没关系。

然而,真正让关键字参数震撼的是,您可以给函数中的参数赋予默认值。

def hello_3(greeting='Hello', name='world'):
    print('{}, {}!'.format(greeting, name))

当一个参数有这样的默认值时,调用函数时就不需要提供了!根据具体情况,您可以不提供、提供一些或全部。

>>> hello_3()
Hello, world!
>>> hello_3('Greetings')
Greetings, world!
>>> hello_3('Greetings', 'universe')
Greetings,  universe!

如您所见,这适用于位置参数,只是如果您想要提供名称,则必须提供问候语。如果您只想提供姓名,而保留问候语的默认值,该怎么办?我相信你现在已经猜到了。

>>> hello_3(name='Gumby')
Hello, Gumby!

很漂亮,是吧?这还不是全部。您可以组合位置参数和关键字参数。唯一的要求是所有的位置参数都在前面。如果他们没有,解释器就不知道他们是哪几个(也就是说,他们应该在哪个位置)。

Note

除非您知道自己在做什么,否则您可能希望避免混合位置参数和关键字参数。当您有少量的强制参数和许多带有默认值的修改参数时,通常使用这种方法。

例如,我们的hello函数可能需要一个名称,但是允许我们(可选地)指定问候语和标点符号。

def hello_4(name, greeting='Hello', punctuation='!'):
    print('{}, {}{}'.format(greeting, name, punctuation))

这个函数可以用多种方式调用。以下是其中的一些:

>>> hello_4('Mars')
Hello, Mars!
>>> hello_4('Mars', 'Howdy')
Howdy, Mars!
>>> hello_4('Mars', 'Howdy', '...')
Howdy, Mars...
>>> hello_4('Mars', punctuation='.')
Hello, Mars.
>>> hello_4('Mars', greeting='Top of the morning to ya')
Top of the morning to ya, Mars!
>>> hello_4()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: hello_4() missing 1 required positional argument: 'name'

Note

如果我也给了name一个默认值,上一个例子就不会引发异常。

那是相当灵活的,不是吗?我们也不需要做太多来实现它。在下一节中,我们将变得更加灵活。

收集参数

有时允许用户提供任意数量的参数会很有用。例如,在名称存储程序中(在“为什么我要修改我的参数?”本章前面),一次只能存储一个姓名。如果能存储更多的名字就好了,就像这样:

>>> store(data, name1, name2, name3)

为了使这个有用,应该允许您提供尽可能多的名称。实际上,那是很有可能的。

尝试以下函数定义:

def print_params(*params):
    print(params)

这里,我似乎只指定了一个参数,但是它前面有一个奇怪的小星号(或星号)。这是什么意思?让我们用一个参数调用这个函数,看看会发生什么。

>>> print_params('Testing')
('Testing',)

你可以看到打印出来的是一个元组,因为里面有一个逗号。所以在一个参数前面用一个星把它放在一个元组里?params中的复数应该能给出正在发生的事情的线索。

>>> print_params(1, 2, 3)
(1, 2, 3)

参数前面的星号将所有值放入同一个元组中。可以说,它把他们聚集在一起。当然,我们已经在前一章的序列解包讨论中看到了这种确切的行为。在赋值中,带星号的变量收集列表中多余的值,而不是元组,但除此之外,这两种用法非常相似。让我们写另一个函数:

def print_params_2(title, *params):
    print(title)
    print(params)

试试看:

>>> print_params_2('Params:', 1, 2, 3)
Params:
(1, 2, 3)

所以星号的意思是“收集其余的位置参数”如果不给出任何要聚集的参数,params将是一个空元组。

>>> print_params_2('Nothing:')
Nothing:
()

就像赋值一样,带星号的参数可能出现在最后一个位置之外的其他位置。不过,与作业不同的是,您必须做一些额外的工作,并按名称指定最终参数。

>>> def in_the_middle(x, *y, z):
...     print(x, y, z)
...
>>> in_the_middle(1, 2, 3, 4, 5, z=7)
1 (2, 3, 4, 5) 7
>>> in_the_middle(1, 2, 3, 4, 5, 7)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: in_the_middle() missing 1 required keyword-only argument: 'z'

明星不收集关键词论据。

>>> print_params_2('Hmm...', something=42)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: print_params_2() got an unexpected keyword argument 'something'

我们可以收集那些有双星的。

>>> def print_params_3(**params):
...     print(params)
...
>>> print_params_3(x=1, y=2, z=3)
{'z':  3,  'x': 1,  'y': 2}

如你所见,我们得到的是字典而不是元组。这些不同的技术配合得很好。

def print_params_4(x, y, z=3, *pospar, **keypar):
    print(x, y, z)
    print(pospar)
    print(keypar)

这正如预期的那样工作。

>>> print_params_4(1, 2, 3, 5, 6, 7, foo=1, bar=2)
1 2 3
(5, 6, 7)
{'foo': 1, 'bar': 2}
>>> print_params_4(1, 2)
1 2 3
()
{}

通过结合所有这些技术,你可以做很多事情。如果你想知道一些组合是如何工作的(或者它是否被允许),就试试吧!(在下一节中,您将看到在调用函数时如何使用***,不管它们是否在函数定义中使用。)

现在,回到最初的问题:如何在存储名字的例子中使用它。解决方案如下所示:

def store(data, *full_names):
    for full_name in full_names:
        names = full_name.split()
        if len(names) == 2: names.insert(1, '')
        labels = 'first', 'middle', 'last'
        for label, name in zip(labels, names):
            people = lookup(data, label, name)
            if people:
                people.append(full_name)
            else:
                data[label][name] = [full_name]

使用这个函数就像使用以前的版本一样简单,以前的版本只接受一个名字。

>>> d = {}
>>> init(d)
>>> store(d, 'Han Solo')

但是现在你也可以这样做:

>>> store(d, 'Luke Skywalker', 'Anakin Skywalker')
>>> lookup(d, 'last', 'Skywalker')
['Luke Skywalker', 'Anakin Skywalker']

逆转过程

现在,您已经了解了如何在元组和字典中收集参数,但是实际上也可以“反过来”使用相同的两个操作符***。参数收集的反面可能是什么?假设我们有以下可用函数:

def add(x, y):
    return x + y

Note

您可以在operator模块中找到该函数的更有效版本。

此外,假设您有一个元组,其中有两个数字要相加。

params = (1, 2)

这或多或少与我们以前的做法相反。我们希望分发参数,而不是收集参数。这可以简单地通过在“另一端”使用*操作符来完成——也就是说,在调用函数时而不是在定义函数时。

>>> add(*params)
3

这也适用于参数列表的一部分,只要扩展部分是最后一部分。您可以对字典使用相同的技术,使用**操作符。假设您已经像以前一样定义了hello_3,您可以执行以下操作:

>>> params = {'name': 'Sir Robin', 'greeting': 'Well met'}
>>> hello_3(**params)
Well met, Sir Robin!

在定义和调用函数时都使用*(或**)将简单地传递元组或字典,所以你最好不要费心。

>>> def with_stars(**kwds):
...     print(kwds['name'], 'is', kwds['age'], 'years old')
...
>>> def without_stars(kwds):
...     print(kwds['name'], 'is', kwds['age'], 'years old')
...
>>> args = {'name': 'Mr. Gumby', 'age': 42}
>>> with_stars(**args)
Mr. Gumby is 42 years old
>>> without_stars(args)
Mr. Gumby is 42 years old

如你所见,在with_stars中,我在定义和调用函数时都使用了星号。在without_stars中,我没有在任何地方使用星星,但达到了完全相同的效果。因此,只有在定义函数(允许不同数量的参数)或调用函数(拼接字典或序列)时使用星号,星号才真正有用。

Tip

使用这些拼接操作符来“传递”参数可能是有用的,而不必太担心有多少个参数等等。这里有一个例子:

def foo(x, y, z, m=0, n=0):
    print(x, y, z, m, n)
def call_foo(*args, **kwds):
    print("Calling foo!")
    foo(*args, **kwds)

这在调用超类的构造函数时特别有用(详见第九章)。

参数实践

提供和接收参数的方式如此之多,很容易混淆。所以让我用一个例子把它们联系起来。首先,我们来定义一些函数。

def story(**kwds):
    return 'Once upon a time, there was a ' \
           '{job} called {name}.'.format_map(kwds)

def power(x, y, *others):
    if others:
        print('Received redundant parameters:', others)
    return pow(x, y)

def interval(start, stop=None, step=1):
    'Imitates range() for step > 0'
    if stop is None:                 # If the stop is not supplied ...
        start, stop = 0, start       # shuffle the parameters
    result = []

    i = start                        # We  start counting at the start index
    while i < stop:                  # Until the index reaches the stop index ...
        result.append(i)             # ... append the index to the result ...
        i += step                    # ... increment the index with the step (> 0)
    return result

现在让我们试一试。

>>> print(story(job='king', name='Gumby'))
Once upon a time, there was a king called Gumby.
>>> print(story(name='Sir Robin', job='brave knight'))
Once upon a time, there was a brave knight called Sir Robin.
>>> params = {'job': 'language', 'name': 'Python'}
>>> print(story(**params))
Once upon a time, there was a language called Python.
>>> del params['job']
>>> print(story(job='stroke of genius', **params))
Once upon a time, there was a stroke of genius called Python.
>>> power(2, 3)
8
>>> power(3, 2)
9
>>> power(y=3, x=2)
8
>>> params = (5,) * 2
>>> power(*params)
3125
>>> power(3, 3, 'Hello, world')
Received redundant parameters: ('Hello, world',)
27
>>> interval(10)
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> interval(1, 5)
[1, 2, 3, 4]
>>> interval(3, 12, 4)
[3, 7, 11]
>>> power(*interval(3, 7))
Received redundant parameters: (5, 6)
81

请随意试验这些函数和您自己的函数,直到您确信您理解了这些东西是如何工作的。

辖域

究竟什么是变量?你可以把它们看作是引用值的名字。所以,在赋值x = 1之后,名字x指的是值 1。这几乎就像使用字典一样,键引用值,只不过您使用的是一个“不可见”的字典。其实这离真相不远。有一个内置函数叫做vars,返回这个字典:

>>> x = 1
>>> scope = vars()
>>> scope['x']
1
>>> scope['x'] += 1
>>> x
2

Caution

一般来说,您不应该修改由vars返回的字典,因为根据官方 Python 文档,结果是未定义的。换句话说,你可能得不到你想要的结果。

这种“看不见的字典”被称为名称空间或作用域。那么,有多少个名称空间呢?除了全局作用域之外,每个函数调用都会创建一个新的作用域。

>>> def foo(): x = 42
...
>>> x = 1
>>> foo()
>>> x
1

这里foo改变(重新绑定)了变量x,但是你最后再看,它终究没有改变。这是因为当您调用foo时,会创建一个新的名称空间,用于foo中的块。赋值x = 42是在这个内部作用域(本地名称空间)中执行的,因此它不会影响外部(全局)作用域中的x。像这样在函数内部使用的变量称为局部变量(与全局变量相对)。这些参数的作用就像局部变量一样,所以让一个参数与全局变量同名是没有问题的。

>>> def output(x): print(x)
...
>>> x = 1
>>> y = 2
>>> output(y)
2

目前为止,一切顺利。但是如果你想访问函数内部的全局变量呢?只要你只想读取变量的值(也就是不想重新绑定),一般是没有问题的。

>>> def combine(parameter): print(parameter + external)
...
>>> external = 'berry'
>>> combine('Shrub')
Shrubberry

Caution

像这样引用全局变量是许多错误的来源。小心使用全局变量。

The Problem of Shadowing

读取全局变量的值一般来说不是问题,但有一件事可能会让它有问题。如果局部变量或参数与要访问的全局变量同名,则不能直接访问。全局变量被局部变量遮蔽。

如果需要,您仍然可以通过使用函数globals来访问全局变量,函数globalsvars的近亲,它返回一个包含全局变量的字典。(locals返回一个包含本地变量的字典。)

例如,在前面的例子中,如果你有一个名为 parameter 的全局变量,你不能从combine中访问它,因为你有一个同名的参数。然而,在紧要关头,你可以称之为globals()['parameter']

>>> def combine(parameter):
...     print(parameter + globals()['parameter'])
...
>>> parameter = 'berry'
>>> combine('Shrub')
Shrubberry

重新绑定全局变量(使它们引用某个新值)是另一回事。如果你给一个函数中的变量赋值,它会自动变成局部变量,除非你告诉 Python。你认为你如何告诉它让一个变量成为全局变量?

>>> x = 1
>>> def change_global():
...     global x
...     x = x + 1
...
>>> change_global()
>>> x
2

小菜一碟!

Nested Scopes

Python 函数可能是嵌套的——您可以将一个函数放在另一个函数中。这里有一个例子:

def foo():
    def bar():
        print("Hello, world!")
    bar()

嵌套通常不是那么有用,但是有一个特殊的应用非常突出:使用一个函数“创建”另一个函数。这意味着您可以(除其他外)编写如下函数:

def multiplier(factor):
    def multiplyByFactor(number):
        return number * factor
    return multiplyByFactor

一个函数在另一个函数内部,外部函数返回内部函数;也就是说,函数本身被返回,而不是被调用。重要的是返回的函数仍然可以访问定义它的作用域;换句话说,它携带着它的环境(以及相关的局部变量)!

每次调用外部函数时,内部函数都会被重新定义,并且每次变量factor都可能有一个新值。由于 Python 的嵌套作用域,这个来自外部局部作用域(属于multiplier)的变量稍后可以在内部函数中访问,如下所示:

>>> double = multiplier(2)
>>> double(5)
10
>>> triple = multiplier(3)
>>> triple(3)
9
>>> multiplier(5)(4)
20

multiplyByFactor这样存储其封闭范围的函数被称为闭包。

通常,您不能在外部作用域中重新绑定变量。不过,如果你愿意,你可以使用nonlocal关键字。它的使用方式与global非常相似,它允许你在外部(非全局)范围内给变量赋值。

递归

你已经学到了很多关于构造函数和调用函数的知识。你也知道函数可以调用其他函数。令人惊讶的是,函数可以自我调用。

如果你以前没有遇到过这类事情,你可能想知道这个单词递归是什么。它仅仅意味着引用(或者,在我们的例子中,“调用”)你自己。一个常见的(尽管公认是愚蠢的)定义是这样的:

recursion \ri-'k&r-zh&n\ n: see recursion.

如果你在谷歌搜索“递归”,你会看到类似的东西。

递归定义(包括递归函数定义)包括对它们所定义的术语的引用。根据您的经验,递归可能令人难以置信,也可能非常简单。为了更深入地理解它,你可能应该给自己买一本好的计算机科学教科书,但是使用 Python 解释器肯定会有所帮助。

一般来说,你不会想要我给递归这个词的递归定义,因为你不会得到任何东西。你查找递归,它再次告诉你查找递归,等等。类似的函数定义如下

def recursion():
    return recursion()

很明显,这没有任何作用——就像模拟词典的定义一样愚蠢。但是如果你运行它会发生什么呢?欢迎你来试试。你会发现程序过一会儿就会崩溃(引发一个异常)。理论上,它应该永远运行下去。但是,每次调用一个函数,都会消耗掉一点内存,在进行了足够多的函数调用之后(之前的调用还没有返回),就没有更多的空间了,程序以错误消息maximum recursion depth exceeded结束。

这个函数中的这种递归称为无限递归(就像以while True开始并且不包含breakreturn语句的循环是无限循环一样),因为它永远不会结束(理论上)。你想要的是一个递归函数,做一些有用的事情。一个有用的递归函数通常由以下部分组成:

  • 当函数直接返回值时的基本情况(对于最小可能的问题)
  • 递归案例,包含一个或多个对问题较小部分的递归调用

这里的要点是,通过将问题分解成更小的部分,递归不能永远进行下去,因为你总是以最小的可能问题结束,这被基本情况所覆盖。

所以你有一个函数调用它自己。但这怎么可能呢?这真的没有看起来那么奇怪。如前所述,每次调用一个函数,都会为该特定调用创建一个新的名称空间。这意味着当一个函数调用“它自己”时,你实际上在谈论两个不同的函数(或者说,同一个函数有两个不同的名称空间)。你可能会认为这是某个物种的一个生物在和同一物种的另一个生物说话。

两个经典:阶乘和幂

在这一节中,我们将研究两个经典的递归函数。首先,假设你要计算一个数 n 的阶乘,n 的阶乘定义为 n×(n–1)×(n–2)×。。。× 1.它被用于许多数学应用中(例如,计算 n 个人排成一行有多少种不同的方式)。怎么算的?你总是可以使用一个循环。

def factorial(n):
    result = n
    for i in range(1, n):
        result *= i
    return result

这是可行的,并且是一个简单的实现。基本上,它是这样做的:首先,它将结果设置为 n;然后,将结果依次乘以从 1 到 n–1 的每个数;最后,它返回结果。但是如果你愿意,你可以用不同的方式来做这件事。关键是阶乘的数学定义,可以表述如下:

  • 1 的阶乘是 1。
  • 大于 1 的数字 n 的阶乘是 n 和 n–1 的阶乘的乘积。

正如您所看到的,这个定义完全等同于本节开始时给出的定义。

现在考虑如何将这个定义实现为一个函数。一旦你理解了定义本身,它实际上是非常简单的。

def factorial(n):
    if n == 1:
       return 1
    else:
       return n * factorial(n - 1)

这是定义的直接实现。请记住,函数调用factorial(n)与调用factorial(n - 1)是不同的实体。

让我们考虑另一个例子。假设你要计算幂,就像内置函数pow,或者运算符**。你可以用几种不同的方法来定义一个数的(整数)次方,但让我们从一个简单的开始:power(x, n) ( xn次方)是数x乘以自身n - 1倍(这样x就作为因子n倍)。换句话说,power(2, 3)是 2 乘以自身的两倍,即 2 × 2 × 2 = 8。

这很容易实现。

def power(x, n):
    result = 1
    for i in range(n):
        result *= x
    return result

一个可爱而简单的小函数,但是你也可以把它的定义改成递归的:

  • power(x, 0)1对所有数字的x
  • n > 0power(x, n)xpower(x, n - 1)的乘积。

同样,如你所见,这给出了与更简单的迭代定义完全相同的结果。

理解定义是最难的部分——实现它很容易。

def power(x, n):
    if n == 0:
        return 1
    else:
        return x * power(x, n - 1)

同样,我只是将我的定义从稍微正式的文本描述翻译成编程语言(Python)。

Tip

如果一个函数或算法很复杂,很难理解,在实际实现之前用你自己的话清楚地定义它会很有帮助。这种“近似编程语言”的程序通常被称为伪代码。

那么递归的意义是什么?你就不能用循环代替吗?事实是是的,你可以,而且在大多数情况下,它可能会(至少稍微)更有效率。但是在许多情况下,递归可以更具可读性——有时更具可读性——特别是如果你理解了函数的递归定义。尽管可以想象你可以避免编写递归函数,但作为一名程序员,你很可能至少要理解其他人创建的递归算法和函数。

另一个经典:二分搜索法

作为递归在实践中的最后一个例子,让我们来看看称为二分搜索法的算法。

你可能知道这样一个游戏,你应该通过问 20 个是或否的问题来猜测某人在想什么。为了最大限度地利用你的问题,你试图将可能性的数量减少一半(或多或少)。例如,如果你知道主语是一个人,你可能会问:“你在想一个女人吗?”你不会一开始就问:“你是在想约翰·克立斯吗?”除非你有非常强烈的预感。对于那些更倾向于数字的人来说,这个游戏的一个版本是猜一个数字。比如,你的搭档在想 1 到 100 之间的一个数字,你要猜是哪个。当然,你可以猜 100 次,但是你真正需要多少次呢?

事实证明,你只需要七个问题。第一个是类似“数字大于 50 吗?”如果是,那你就问“是不是大于 75?”你一直把音程减半(把差分开),直到找到数字。你不用多想就能做到。

同样的策略可以用在许多不同的环境中。一个常见的问题是找出一个数字是否要在一个(排序的)序列中找到,甚至找出它在哪里。再一次,你遵循同样的程序:“数字在序列中间的右边吗?”如果不是,“是在第二节(左半部分中间偏右)吗?”等等。你对这个数字的可能值设定了一个上限和一个下限,并在每个问题中把这个区间分成两部分。

关键是这种算法自然地适合递归定义和实现。让我们先回顾一下定义,以确保我们知道自己在做什么:

  • 如果上下限相同,都是指数字的正确位置,所以返回。
  • 否则,找出区间的中间(上下界的平均值),找出数字是在右半部还是左半部。在适当的一半继续寻找。

递归情况的关键是数字是排序的,所以当你找到中间的元素时,你可以把它和你要找的数字进行比较。如果你的数字较大,那么它一定在右边,如果它较小,它一定在左边。递归部分是“在适当的一半中保持搜索”,因为搜索将完全按照定义中描述的方式执行。(请注意,搜索算法会返回该数字应该在的位置,如果它不在序列中,这个位置自然会被另一个数字占用。)

现在,您已经准备好实现二分搜索法了。

def search(sequence, number, lower, upper):
    if lower == upper:
         assert number == sequence[upper]
         return upper
    else:
        middle = (lower + upper) // 2
        if number > sequence[middle]:
            return search(sequence, number, middle + 1, upper)
        else:
            return search(sequence, number, lower, middle)

这确实做到了定义所说的:如果lower == upper,那么返回upper,这是上限。请注意,您假设(assert)您正在寻找的号码(number)实际上已经被找到(number == sequence[upper])。如果你还没有达到你的基本情况,你找到middle,检查你的数字是在左边还是右边,用新的限制递归调用search。您甚至可以通过使限制规格可选来使其更易于使用。您只需给lowerupper默认值,并在函数定义的开头添加以下条件:

def search(sequence, number, lower=0, upper=None):
    if upper is None: upper = len(sequence) - 1
    ...

现在,如果您不提供限制,它们将被设置为序列的第一个和最后一个位置。让我们看看这是否有效。

>>> seq = [34, 67, 8, 123, 4, 100, 95]
>>> seq.sort()
>>> seq
[4, 8, 34, 67, 95, 100, 123]
>>> search(seq, 34)
2
>>> search(seq, 100)
5

但是为什么要这么麻烦呢?首先,你可以简单地使用 list 方法index,如果你想自己实现它,你可以从头开始循环,直到找到数字。

当然,使用index就可以了。但是使用简单的循环可能有点低效。记得我说过你需要七个问题才能在 100 个数字中找到一个数字(或位置)吗?在最坏的情况下,循环显然需要 100 个问题。你会说“有什么大不了的”。但是如果列表有 100,000,000,000,000,000,000,000,000,000 个元素,并且有相同数量的问题和一个循环(对于 Python 列表来说可能有点不切实际),这种事情就开始变得重要了。二分搜索法将只需要 117 个问题。很有效率,是吧? 2

Tip

你可以在bisect模块中找到二分搜索法的标准实现。

Throwing Functions Around

到目前为止,您可能已经习惯了像使用其他对象(字符串、数字、序列等)一样使用函数,方法是将它们赋给变量,将它们作为参数传递,并从其他函数返回它们。一些编程语言(如 Scheme 或 Lisp)以这种方式使用函数来完成几乎所有事情。即使你通常不太依赖 Python 中的函数(你通常创建自己的对象——下一章会详细介绍),你也可以。

Python 有几个对这种“函数式编程”有用的函数:mapfilterreduce。在当前版本的 Python 中,mapfilter函数实际上并不那么有用,你可能应该使用列表理解来代替。您可以使用map将一个序列的所有元素传递给一个给定的函数。

>>> list(map(str, range(10))) # Equivalent to [str(i) for i in range(10)]
['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']

您使用filter根据布尔函数过滤出项目。

>>> def func(x):
...     return x.isalnum()
...
>>> seq = ["foo", "x41", "?!", "***"]
>>> list(filter(func, seq))
['foo', 'x41']

对于这个例子,使用列表理解意味着您不需要定义自定义函数。

>>> [x for x in seq if x.isalnum()]
['foo', 'x41']

实际上,有一个特性叫做 lambda 表达式, 3 ,它可以让你定义简单的内嵌函数(主要与mapfilterreduce一起使用)。

>>> filter(lambda x: x.isalnum(), seq)
['foo', 'x41']

列表理解不是更具可读性吗?

reduce函数不容易被列表理解所取代,但是你可能不会经常需要它的功能(如果曾经需要的话)。它将序列的前两个元素与给定的函数组合,将结果与第三个元素组合,依此类推,直到处理完整个序列,只剩下一个结果。例如,如果您想对一个序列的所有数字求和,您可以使用 reduce 和lambda x, y: x+y(仍然使用相同的数字)。 4

>>> numbers = [72, 101, 108, 108, 111, 44, 32, 119, 111, 114, 108, 100, 33]
>>> from functools import reduce
>>> reduce(lambda x, y: x + y, numbers)
1161

当然,这里你也可以使用内置函数 sum。

快速总结

在这一章中,你学习了一些关于抽象的东西,特别是函数:

  • 抽象:抽象是隐藏不必要细节的艺术。通过定义处理细节的函数,你可以使你的程序更加抽象。
  • 函数定义:函数是用def语句定义的。它们是从“外部世界”接收值(参数)的语句块,并且可能返回一个或多个值作为它们的计算结果。
  • 参数:函数以参数的形式接收它们需要知道的信息,参数是在调用函数时设置的变量。Python 中有两种类型的参数:位置参数和关键字参数。通过给参数赋予默认值,可以使参数成为可选的。
  • 作用域:变量存储在作用域(也称为名称空间)中。Python 中有两个主要作用域:全局作用域和局部作用域。范围可以是嵌套的。
  • 递归:一个函数可以调用它自己——如果它这样做了,这就叫做递归。用递归可以做的一切也可以通过循环来完成,但有时递归函数更具可读性。
  • 函数式编程:Python 提供了一些函数式编程的工具。其中有 lambda 表达式和mapfilterreduce函数。

本章的新功能

| 功能 | 描述 | | --- | --- | | `map(func, seq[, seq, ...])` | 将函数应用于序列中的所有元素 | | `filter(func, seq)` | 返回函数为真的那些元素的列表 | | `reduce(func, seq[, initial])` | 相当于`func(func(func(seq[0], seq[1]), seq[2]), ...)` | | `sum(seq)` | 返回`seq`的所有元素的总和 | | `apply(func[, args[, kwargs]])` | 调用函数,可选地提供参数 |

什么现在?

下一章通过面向对象编程将抽象提升到另一个层次。您将学习如何创建自己的对象类型(或类),以便与 Python 提供的对象(如字符串、列表和字典)一起使用,并学习这如何使您能够编写更好的程序。一旦你完成了下一章,你将能够编写一些真正大的程序,而不会迷失在源代码中。

Footnotes 1

实际上,Python 中的函数并不总是返回值。在这一章的后面你会学到更多。

2

事实上,据估计可观测宇宙中的粒子数量为 1087,你只需要 290 个问题就能分辨出它们!

3

“lambda”这个名字来自希腊字母,在数学中用来表示匿名函数。

4

实际上,代替这个 lambda 函数,您可以从operator模块中导入函数add,该模块为每个内置操作符都提供了一个函数。使用operator模块中的函数总是比使用自己的函数更有效。

七、更加抽象

在前几章中,你看了 Python 的主要内置对象类型(数字、字符串、列表、元组和字典);您看到了大量的内置函数和标准库;您甚至创建了自己的函数。现在,似乎只缺少一样东西——制作自己的物件。这就是你在本章要做的。

你可能想知道这有多有用。制作自己的物品可能很酷,但是你会用它们做什么呢?有了所有可用的字典、序列、数字和字符串,你就不能使用它们,让函数来完成工作吗?当然,但是创建自己的对象(尤其是对象的类型或类)是 Python 中的一个核心概念——事实上,它是如此核心,以至于 Python 被称为一种面向对象的语言(还有 Smalltalk、C++、Java 和许多其他语言)。在这一章中,你将学习如何制作物体。你学习了多态和封装、方法和属性、超类和继承——你学到了很多。所以让我们开始吧。

Note

如果你已经熟悉面向对象编程的概念,你可能知道构造函数。本章不讨论构造函数;有关详细讨论,请参见第九章。

物品的魔力

在面向对象编程中,术语对象泛指一组数据(属性)和一组访问和操作这些数据的方法。使用对象而不是坚持使用全局变量和函数有几个原因。对象的一些最重要的好处包括:

  • 多态性:你可以在不同类的对象上使用相同的操作,它们会像“魔术般”地工作
  • 封装:对外界隐藏对象如何工作的不重要的细节。
  • 继承:你可以从一般的对象创建特殊的对象类。

在许多面向对象编程的演示中,这些概念的顺序是不同的。首先介绍封装和继承,然后用它们来建模现实世界的对象。这一切都很好,但在我看来,面向对象编程最有趣的特性是多态性。也是最让大多数人困惑的特征(以我的经验)。因此,我从多态开始,试图说明光是这个概念就应该足以让你喜欢上面向对象编程。

多态性

术语多态性源于一个希腊词,意思是“有多种形式”基本上,这意味着即使你不知道一个变量指的是哪种对象,你仍然可以根据对象的类型(或类)对它执行不同的操作。例如,假设您正在为一个销售食品的商业网站创建一个在线支付系统。您的程序从系统的另一部分(或将来可能设计的其他类似系统)接收一个“购物车”的货物,您所需要担心的只是合计总数并向信用卡付款。

您的第一个想法可能是详细说明当您的程序收到货物时,货物必须如何表示。例如,您可能希望以元组的形式接收它们,如下所示:

('SPAM', 2.50)

如果你需要的只是一个描述性的标签和一个价格,这很好。但是不太灵活。让我们假设某个聪明的人开始了拍卖服务,作为网站的一部分——物品的价格逐渐降低,直到有人购买。如果用户可以将物品放入购物车,进行结账(你的系统部分),然后等到价格合适再点击支付按钮,那就太好了。

但是这不适用于简单的元组模式。要做到这一点,每次代码询问价格时,对象都需要检查它的当前价格(通过某种网络魔法)——它不能像在元组中那样被冻结。你可以通过构造一个函数来解决。

# Don't do it like this ...
def get_price(object):
    if isinstance(object, tuple):
        return object[1]
    else:
        return magic_network_method(object)

Note

这里的类型/类检查和isinstance的使用是为了说明一点——也就是说,类型检查通常不是一个令人满意的解决方案。尽可能避免类型检查。函数isinstance将在本章后面的“研究继承”一节中描述。

在前面的代码中,我使用函数isinstance来确定对象是否是一个元组。如果是,则返回其第二个元素;否则,调用一些“神奇”的网络方法。

假设网络已经存在,你已经解决了这个问题——暂时的。但是这仍然不是很灵活。如果某个聪明的程序员决定将价格表示为一个十六进制值的字符串,存储在字典中的键'price'下,该怎么办?没问题——你只需更新你的函数。

# Don't do it like this ...
def get_price(object):
    if isinstance(object, tuple):
        return object[1]
    elif isinstance(object, dict):
        return int(object['price'])
    else:
        return magic_network_method(object)

现在,想必你已经考虑到了所有的可能性吧?但是,假设有人决定添加一种新类型的字典,其价格存储在不同的键下。你现在做什么?你当然可以再次更新get_price,但是你能继续这样做多久呢?每当有人想以不同的方式实现一些有价格的对象时,你就需要重新实现你的模块。但是,如果你已经卖掉了你的模块,并转移到其他更酷的项目,客户会怎么做呢?显然,这是一种不灵活且不切实际的编码不同行为的方式。

那你会怎么做呢?您让对象自己处理操作。这听起来很明显,但是想想事情会变得多么简单。每一种新的对象类型都可以检索或计算出自己的价格,并将其返回给您—您所要做的就是提出要求。这就是多态(以及某种程度上的封装)出现的地方。

多态性和方法

您收到一个对象,但不知道它是如何实现的——它可能有许多“形状”中的任何一种你只知道你可以要求它的价格,这对你来说就足够了。你做那件事的方式应该是熟悉的。

>>> object.get_price()
2.5

像这样绑定到对象属性的函数叫做方法。您已经以字符串、列表和字典方法的形式遇到过它们。在那里,你也看到了一些多态性。

>>> 'abc'.count('a')
1
>>> [1, 2, 'a'].count('a')
1

如果你有一个变量x,你不需要知道它是一个字符串还是一个列表来调用count方法——不管怎样它都会工作(只要你提供一个字符作为参数)。

让我们做一个实验。标准库模块random包含一个名为choice的函数,它从序列中选择一个随机元素。让我们用它来给你的变量赋值。

>>> from random import choice
>>> x = choice(['Hello, world!', [1, 2, 'e', 'e', 4]])

执行完这个之后,x可以包含字符串'Hello, world!'或者列表[1, 2, 'e', 'e', 4]——你不知道,也不用担心。你所关心的是你在x中找到了多少次'e',不管x是一个列表还是一个字符串,你都能找到。通过像以前一样调用count方法,您会发现这一点。

>>> x.count('e')
2

在这种情况下,似乎名单胜出了。但关键是你不需要检查。您唯一的要求是x有一个名为count的方法,该方法将单个字符作为参数并返回一个整数。如果其他人创建了他自己的具有这个方法的对象类,对你来说没有关系——你可以像使用字符串和列表一样使用他的对象。

多态性有多种形式

每当你可以对一个对象“做一些事情”而不必确切知道它是什么类型的对象时,多态性就在起作用。这不仅仅适用于方法——我们已经以内置操作符和函数的形式大量使用了多态性。请考虑以下几点:

>>> 1 + 2
3
>>> 'Fish' + 'license'
'Fishlicense'

在这里,加号运算符(+)对数字(在这种情况下是整数)和字符串(以及其他类型的序列)都适用。为了说明这一点,假设您想要创建一个名为add的函数,它将两个事物相加。您可以简单地这样定义它(相当于来自operator模块的add函数,但是效率比它低):

def add(x, y):
    return x + y

这也适用于多种论点。

>>> add(1, 2)
3
>>> add('Fish', 'license')
'Fishlicense'

这可能看起来很傻,但关键是参数可以是任何支持加法的东西。 1 如果你想写一个函数,打印一个对象的长度信息,所需要的就是它有一个长度(即len函数将对它起作用)。

def length_message(x):
    print("The length of", repr(x), "is", len(x))

如您所见,该函数也使用了repr,但是repr是多态性的大师之一——它适用于任何东西。让我们看看如何:

>>> length_message('Fnord')
The length of 'Fnord' is 5
>>> length_message([1, 2, 3])
The length of [1, 2, 3] is 3

许多函数和操作符都是多态的——很可能你的大多数函数和操作符也是多态的,即使你并不希望它们是多态的。仅仅通过使用多态函数和操作符,多态就“消失”了事实上,实际上唯一可以破坏这种多态性的方法就是用函数进行显式类型检查,比如typeissubclass。如果可以的话,你真的应该避免以这种方式破坏多态性。重要的应该是对象按照您想要的方式运行,而不是它是否是正确的类型(或类)。然而,禁止类型检查的禁令不像以前那样绝对了。随着抽象基类和abc模块的引入(将在本章后面讨论),issubclass函数本身已经变得多态了!

Note

这里讨论的多态形式对 Python 编程方式至关重要,有时被称为 duck typing。这个术语来源于短语“如果它像鸭子一样嘎嘎叫……”更多信息,见 http://en.wikipedia.org/wiki/Duck_typing .

包装

封装是对外界隐藏不必要的细节的原则。这听起来像是多态——在这里,你也是在不知道对象内部细节的情况下使用它。这两个概念是相似的,因为它们都是抽象的原则。它们都帮助你处理程序的组成部分,而不关心不必要的细节,就像函数一样。

但是封装不同于多态。多态使您能够在不知道对象的类(对象的类型)的情况下调用对象的方法。封装使您能够使用对象,而不用担心它是如何构造的。听起来还差不多吗?让我们构造一个带有多态性但没有封装的例子。假设您有一个名为OpenObject的类(您将在本章后面学习如何创建类)。

>>> o = OpenObject() # This is how we create objects...
>>> o.set_name('Sir Lancelot')
>>> o.get_name()
'Sir Lancelot'

您创建一个对象(通过像调用函数一样调用该类)并将变量o绑定到它。然后你可以使用方法set_nameget_name(假设它们是被类OpenObject支持的方法)。一切似乎都很完美。然而,让我们假设o将其名称存储在全局变量global_name中。

>>> global_name
'Sir Lancelot'

这意味着当您使用类OpenObject的实例(对象)时,您需要担心global_name的内容。事实上,您必须确保没有人更改它。

>>> global_name = 'Sir Gumby'
>>> o.get_name()
'Sir Gumby'

如果你试图创建一个以上的OpenObject,事情会变得更加麻烦,因为它们都将处理同一个变量。

>>> o1 = OpenObject()
>>> o2 = OpenObject()
>>> o1.set_name('Robin Hood')
>>> o2.get_name()
'Robin Hood'

如您所见,设置一个的名称会自动设置另一个的名称—这并不是您想要的。

基本上,你想把对象视为抽象的。当你调用一个方法的时候,你不想去担心其他的事情,比如不去扰乱全局变量。那么,如何将名称“封装”在对象中呢?没问题。你让它成为一种属性。

属性是变量,是对象的一部分,就像方法一样;实际上,方法几乎就像绑定到函数的属性。(您将在本章后面的“属性、函数和方法”一节中看到方法和函数之间的重要区别。)如果您重写该类以使用一个属性而不是一个全局变量,并将其重命名为ClosedObject,它的工作方式如下:

>>> c = ClosedObject()
>>> c.set_name('Sir Lancelot')
>>> c.get_name()
'Sir Lancelot'

目前为止,一切顺利。但是据你所知,这仍然可以存储在一个全局变量中。让我们制作另一个对象。

>>> r = ClosedObject()
>>> r.set_name('Sir Robin')
r.get_name()
'Sir Robin'

在这里,您可以看到新对象的名称设置正确,这可能是您所期望的。但是现在第一个物体发生了什么呢?

>>> c.get_name()
'Sir Lancelot'

名字还在!这是因为对象有自己的状态。对象的状态由它的属性描述(例如,像它的名字)。对象的方法可能会改变这些属性。所以这就像把一堆函数(方法)放在一起,给它们访问一些变量(属性)的权限,它们可以在函数调用之间保存值。

在本章后面的“隐私回顾”一节中,你会看到关于 Python 封装机制的更多细节。

遗产

继承是应对懒惰的另一种方式(从积极的意义上来说)。程序员希望避免多次键入相同的代码。我们之前通过构造函数避免了这个问题,但是现在我将解决一个更微妙的问题。如果您已经有了一个类,并且想要创建一个非常相似的类,该怎么办?也许只是增加了一些方法?当创建这个新类时,您不希望将旧类中的所有代码复制到新类中。

例如,您可能已经有了一个名为Shape的类,它知道如何在屏幕上绘制自己。现在你想创建一个名为Rectangle的类,它也知道如何在屏幕上绘制自己,但是它还可以计算自己的面积。当Shape已经有一个运行良好的draw方法时,你不会想要做所有的工作来创建一个新的Shape方法。那你是做什么的?你让Rectangle继承了Shape的方法。你可以这样做,当在一个Rectangle对象上调用draw时,来自Shape类的方法被自动调用(参见本章后面的“指定超类”一节)。

班级

到目前为止,您已经对什么是类有了一种感觉——或者您可能已经不耐烦让我告诉您如何创建这些该死的东西。在进入技术细节之前,让我们看一下什么是类。

到底什么是阶级?

我一直在使用 class 这个词,它或多或少与 kind 或 type 等词同义。在许多方面,这就是类的确切含义——一种对象。所有对象都属于一个类,被称为该类的实例。

例如,如果你向窗外看,看到一只鸟,这只鸟就是类“birds”的一个实例。这是一个非常通用(抽象)的类,有几个子类;你的鸟可能属于“云雀”亚纲你可以认为“鸟”类是所有鸟的集合,而“云雀”类只是其中的一个子集。当属于一个类的对象形成属于另一个类的对象的子集时,第一个类被称为第二个类的子类。因此,“云雀”是“鸟”的一个子类相反,“鸟”是“云雀”的超类。

Note

在日常用语中,我们用复数名词来表示物体的类别,如“鸟”和“云雀”在 Python 中,习惯使用单数、大写的名词,比如 Bird 和 Lark。

当这样陈述时,子类和超类就很容易理解了。但是在面向对象编程中,子类关系具有重要的含义,因为一个类是由它支持的方法定义的。一个类的所有实例都有这些方法,所以所有子类的所有实例也必须有它们。定义子类仅仅是定义更多方法的问题(或者,可能覆盖一些现有的方法)。

例如,Bird可能提供方法fly,而Penguin(Bird的子类)可能添加方法eat_fish。当创建一个Penguin类时,您可能还想覆盖超类的一个方法,即fly方法。在Penguin实例中,这个方法要么什么都不做,要么可能引发一个异常(见第八章),因为企鹅不会飞。

Note

在 Python 的旧版本中,类型和类之间有明显的区别。内置对象有类型;您的自定义对象有类。您可以创建类,但不能创建类型。在 Python 2 的最新版本中,这种区别要小得多,而在 Python 3 中,这种区别已经消失了。

制作自己的课程

最后,你可以自己上课了!这里有一个简单的例子:

__metaclass__ = type          # Include this if you’re using Python 2

class Person:

    def set_name(self, name):
         self.name = name

    def get_name(self):
         return self.name

    def greet(self):
         print("Hello, world! I'm {}.".format(self.name))

Note

所谓的旧式和新式的班级是有区别的。除了 Python 3 之前的默认类之外,真的没有理由再使用旧式的类了。要在旧 Pythons 中获得新样式的类,应该将赋值__metaclass__ = type放在脚本或模块的开头。我不会在每个例子中明确地包含这个语句。还有其他的解决方案,比如子类化一个新样式的类(例如,object)。一会儿你会学到更多关于子类化的知识。如果您使用的是 Python 3,就没有必要担心这个问题,因为那里不存在旧式的类。你可以在第九章中找到更多相关信息。

这个例子包含三个方法定义,它们类似于函数定义,除了它们被写在一个class语句中。Person当然是班级的名字。class语句在定义函数的地方创建自己的名称空间。(请参阅本章后面的“类命名空间”一节。)这一切看起来都没问题,但你可能想知道这个self参数是什么。它指的是物体本身。那是什么东西?让我们举几个例子看看。

>>> foo = Person()
>>> bar = Person()
>>> foo.set_name('Luke Skywalker')
>>> bar.set_name('Anakin Skywalker')
>>> foo.greet()
Hello, world! I'm Luke Skywalker.
>>> bar.greet()
Hello, world! I'm Anakin Skywalker.

好吧,这个例子可能有点显而易见,但也许它澄清了self是什么。当我在foo上调用set_namegreet时,foo本身会自动作为第一个参数被传递——我把这个参数恰当地称为self。事实上,你可以随便叫它什么,但是因为它总是物体本身,按照惯例,它几乎总是被称为self

这里很明显为什么self是有用的,甚至是必要的。没有它,任何方法都不能访问对象本身——它们应该操作其属性的对象。和以前一样,这些属性也可以从外部访问。

>>> foo.name
'Luke Skywalker'
>>> bar.name = 'Yoda'
>>> bar.greet()
Hello, world! I'm Yoda.

Tip

另一种看法是,如果你碰巧知道fooPerson的一个实例,那么foo.greet()只是编写不太多态的Person.greet(foo)的一种简便方式。

属性、函数和方法

实际上,self参数(前面提到过)是方法和函数的区别。方法(或者更专业地说,绑定方法)的第一个参数绑定到它们所属的实例,所以您不必提供它。虽然您当然可以将一个属性绑定到一个普通的函数,但是它不会有那个特殊的self参数。

>>> class Class:
...     def method(self):
...         print('I have a self!')
...
>>> def function():
...     print("I don't...")
...
>>> instance = Class()
>>> instance.method() I have a self!
>>> instance.method = function
>>> instance.method() I don't...

请注意,self参数并不依赖于像instance.method那样调用方法。您可以自由使用引用同一方法的另一个变量。

>>> class Bird:
...     song = 'Squaawk!'
...     def sing(self):
...         print(self.song)
...
>>> bird = Bird()
>>> bird.sing()
Squaawk!
>>> birdsong = bird.sing
>>> birdsong()
Squaawk!

即使最后一个方法调用看起来完全像一个函数调用,变量birdsong引用了绑定的方法bird.sing,这意味着它仍然可以访问self参数(也就是说,它仍然绑定到类的同一个实例)。

隐私再探

默认情况下,您可以从“外部”访问对象的属性让我们回顾一下前面讨论封装时的例子。

>>> c.name
'Sir Lancelot'
>>> c.name = 'Sir Gumby'
>>> c.get_name()
'Sir Gumby'

有些程序员对此没有意见,但是有些人(比如 Smalltalk 的创建者,这种语言中对象的属性只能被同一对象的方法访问)认为这违背了封装的原则。他们认为对象的状态应该对外界完全隐藏(不可访问)。你可能想知道他们为什么采取如此极端的立场。每个对象管理自己的属性还不够吗?你为什么要把它们藏起来不让世人知道呢?毕竟,如果你只是在ClosedObject(本例中是c的类)上直接使用了name属性,你就不需要制作setNamegetName方法。

关键是其他程序员可能不知道(也许不应该知道)你的对象内部发生了什么。例如,ClosedObject可以在每次对象更改名称时向某个管理员发送电子邮件消息。这可能是set_name方法的一部分。但是当你直接设置c.name时会发生什么呢?什么都不会发生—不发送电子邮件。为了避免这种事情,你有私有属性。这些属性在对象外部是不可访问的;它们只能通过访问器方法来访问,比如get_nameset_name

Note

在第九章中,您将了解到属性,这是对访问器的一种强大的替代。

Python 不直接支持隐私,而是依赖程序员来知道何时从外部修改属性是安全的。毕竟,在使用一个对象之前,你应该知道如何使用该对象。然而,用一点小技巧来获得类似私有属性的东西是可能的。

要使一个方法或属性成为私有的(从外部不可访问),只需以两个下划线开始它的名字。

class Secretive:

    def __inaccessible(self):
        print("Bet you can't see me ...")

    def accessible(self):
        print("The secret message is:")
        self.__inaccessible()

现在inaccessible对于外部世界是不可访问的,而它仍然可以在类内部使用(例如,从accessible)。

>>> s = Secretive()
>>> s.__inaccessible()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: Secretive instance has no attribute '__inaccessible'
>>> s.accessible()
The secret message is:
Bet you can't see me ...

虽然双下划线有点奇怪,但这似乎是标准的私有方法,在其他语言中也可以找到。不那么标准的是实际发生的情况。在类定义中,所有以双下划线开头的名字都是通过在开头添加单下划线和类名来“翻译”的。

>>> Secretive._Secretive__inaccessible
<unbound method Secretive.__inaccessible>

如果您知道这在幕后是如何工作的,那么仍然有可能在类外访问私有方法,尽管您不应该这样做。

>>> s._Secretive__inaccessible()
Bet you can't see me ...

所以,简而言之,你不能确定其他人不会访问你的对象的方法和属性,但是这种乱用名字的行为是一个强烈的信号,表明他们不应该这样做。

如果你不想要名字混乱的效果,但是你仍然想发送一个信号让其他物体远离,你可以使用一个单独的下划线。这在很大程度上只是一个惯例,但有一些实际效果。例如,带下划线开头的名称不会通过带星号的导入(from module import *)导入。 2

类别命名空间

以下两种说法(或多或少)是等价的:

def foo(x): return x * x
foo = lambda x: x * x

两者都创建了一个返回其参数平方的函数,并将变量foo绑定到该函数。名字foo可以在全局(模块)范围内定义,也可以是某个函数或方法的局部。当定义一个类时,也会发生同样的事情:class语句中的所有代码都在一个特殊的名称空间中执行——类名称空间。该命名空间以后可由该类的所有成员访问。并非所有 Python 程序员都知道类定义只是被执行的代码段,但它可能是有用的信息。例如,您不局限于类定义块中的def语句。

>>> class C:
...     print('Class C being defined...')
...
Class C being defined...
>>>

好吧,这有点傻。但是请考虑以下情况:

class MemberCounter:
    members = 0
    def init(self):
        MemberCounter.members += 1

>>> m1 = MemberCounter()
>>> m1.init()
>>> MemberCounter.members
1
>>> m2 = MemberCounter()
>>> m2.init()
>>> MemberCounter.members
2

在前面的代码中,在类范围中定义了一个变量,所有成员(实例)都可以访问该变量,在本例中是为了计算类成员的数量。注意使用init来初始化所有的实例:我将在第九章中自动完成(也就是把它变成一个合适的构造函数)。

这个类范围变量也可以从每个实例中访问,就像方法一样。

>>> m1.members
2
>>> m2.members
2

在实例中重新绑定members属性会发生什么?

>>> m1.members = 'Two'
>>> m1.members
'Two'
>>> m2.members
2

新的members值已经被写入到m1的一个属性中,隐藏了类范围的变量。这反映了函数中局部和全局变量的行为,在第六章的“阴影问题”中有讨论。

指定超类

正如我在本章前面所讨论的,子类扩展了超类中的定义。在一个class语句中,通过在类名后面的括号中写下它来指明超类。

class Filter:
    def init(self):
        self.blocked = []
    def filter(self, sequence):
        return [x for x in sequence if x not in self.blocked]

class SPAMFilter(Filter): # SPAMFilter is a subclass of Filter
    def init(self): # Overrides init method from Filter superclass
        self.blocked = ['SPAM']

Filter 是一个用于过滤序列的通用类。实际上它没有过滤掉任何东西。

>>> f = Filter()
>>> f.init()
>>> f.filter([1, 2, 3])
[1, 2, 3]

Filter类的用处在于它可以作为其他类的基类(超类),比如从序列中过滤掉'SPAM'SPAMFilter

>>> s = SPAMFilter()
>>> s.init()
>>> s.filter(['SPAM', 'SPAM', 'SPAM', 'SPAM', 'eggs', 'bacon', 'SPAM'])
['eggs', 'bacon']

请注意SPAMFilter定义中的两个要点:

  • 我通过简单地提供一个新的定义来覆盖来自Filterinit的定义。
  • filter方法的定义是从Filter继承而来的,所以不需要重新编写定义。

第二点说明了为什么继承是有用的:我现在可以创建许多不同的过滤器类,所有的类都是Filter的子类,对于每个类,我可以简单地使用我已经实现的filter方法。说说有用的懒。。。

调查继承

如果想知道一个类是否是另一个类的子类,可以使用内置方法issubclass

>>> issubclass(SPAMFilter, Filter)
True
>>> issubclass(Filter, SPAMFilter)
False

如果你有一个类并且想知道它的基类,你可以访问它的特殊属性bases

>>> SPAMFilter.__bases__
(<class __main__.Filter at 0x171e40>,)
>>> Filter.__bases__
(<class 'object'>,)

以类似的方式,您可以通过使用isinstance来检查一个对象是否是一个类的实例。

>>> s = SPAMFilter()
>>> isinstance(s, SPAMFilter)
True
>>> isinstance(s, Filter)
True
>>> isinstance(s, str)
False

Note

使用isinstance通常不是好的做法。依赖多态性几乎总是更好。主要的例外是当你使用抽象基类和abc模块的时候。

如你所见,s是类SPAMFilter的(直接)成员,但它也是Filter的间接成员,因为SPAMFilterFilter的子类。另一种说法是,所有的SPAMFilter都是Filter的。正如您在前面的例子中看到的,isinstance也适用于类型,比如字符串类型(str)。

如果你只想找出一个对象属于哪个类,你可以使用__class__属性。

>>> s.__class__
<class __main__.SPAMFilter at 0x1707c0>

Note

如果你有一个新样式的类,通过设置__metaclass__ = type或者子类化object,你也可以使用类型来找到你的实例的类。对于旧式的类,type只是返回instance类型,而不管对象是哪个类的实例。

多个超类

我相信你注意到了上一节中一个看起来很奇怪的小细节:在bases中的复数形式。我说过你可以用它来寻找一个类的基类,这意味着它可能不止一个。事实上,情况就是这样。为了展示它是如何工作的,让我们创建几个类。

class Calculator:
    def calculate(self, expression):
        self.value = eval(expression)

class Talker:
    def talk(self):
        print('Hi, my value is', self.value)

class TalkingCalculator(Calculator, Talker):
    pass

子类(TalkingCalculator)本身什么都不做;它从它的超类继承它的所有行为。关键是它继承了来自Calculatorcalculate和来自Talkertalk,使它成为一个会说话的计算器。

>>> tc = TalkingCalculator()
>>> tc.calculate('1 + 2 * 3')
>>> tc.talk()
Hi, my value is 7

这被称为多重继承,它可以是一个非常强大的工具。然而,除非你知道你需要多重继承,否则你可能想远离它,因为在某些情况下,它会导致不可预见的并发症。

如果您使用多重继承,有一件事您应该注意:如果一个方法由两个或更多的超类以不同的方式实现(也就是说,您有两个同名的不同方法),您必须注意这些超类的顺序(在 class 语句中)。早期类中的方法会覆盖后期类中的方法。因此,如果前面例子中的Calculator类有一个名为talk的方法,它将覆盖Talkertalk方法(并使其不可访问)。颠倒它们的顺序,像这样:

class TalkingCalculator(Talker, Calculator): pass

会使Talkertalk方法可访问。如果超类共享一个公共超类,那么在寻找给定属性或方法时超类被访问的顺序被称为方法解析顺序(MRO ),并遵循一个相当复杂的算法。幸运的是,它工作得非常好,所以你可能不需要担心它。

接口和自省

“接口”的概念与多态性有关。当您处理多态对象时,您只关心它的接口(或“协议”)——世人皆知的方法和属性。在 Python 中,你不需要明确地指定一个对象的哪些方法需要作为一个参数被接受。比如,你不显式地写接口(就像你在 Java 里做的那样);你只是假设一个物体能做你要求它做的事情。如果不能,程序就会失败。

通常,您只是简单地要求对象符合某个接口(换句话说,实现某些方法),但是如果您愿意,您可以非常灵活地提出要求。您可以检查所需的方法是否存在,如果不存在,也许要做些别的事情,而不是仅仅调用这些方法并期待最好的结果。

>>> hasattr(tc, 'talk')
True
>>> hasattr(tc, 'fnord')
False

在前面的代码中,您发现tc(一个TalkingCalculator,如本章前面所述)有属性talk(它指的是一个方法),但没有属性fnord。如果你愿意,你甚至可以检查talk属性是否是可调用的。

>>> callable(getattr(tc, 'talk', None))
True
>>> callable(getattr(tc, 'fnord', None))
False

请注意,我没有在if语句中使用hasattr并直接访问属性,而是使用了getattr,这允许我提供一个默认值(在本例中为None),如果属性不存在,将使用该默认值。然后我在返回的对象上使用callable

Note

getattr的逆是setattr,可以用来设置物体的属性:

>>> setattr(tc, 'name', 'Mr. Gumby')
>>> tc.name
'Mr. Gumby'

如果您想查看存储在一个对象中的所有值,您可以检查它的__dict__属性。如果你真的想知道一个物体是由什么组成的,你应该看看inspect模块。它面向那些想要开发对象浏览器(使您能够以图形化方式浏览 Python 对象的程序)和其他需要这种功能的类似程序的相当高级的用户。有关探索对象和模块的更多信息,请参见第十章中的“探索模块”一节。

抽象基类

但是,您可以比手动检查单个方法做得更好。在其历史的大部分时间里,Python 几乎完全依赖于 duck typing,并且只是假设您拥有的任何对象都可以完成它的工作,也许使用hasattr进行一些检查来寻找某些必需方法的存在。显式指定接口的思想,在许多其他语言中可以找到,如 Java 和 Go,一些第三方模块提供了各种实现。尽管如此,官方的 Python 解决方案最终还是随着abc模块的引入而出现。这个模块提供了对所谓的抽象基类的支持。一般来说,抽象类只是一个不能或者至少不应该被实例化的类。它的工作是提供一组子类应该实现的抽象方法。这里有一个简单的例子:

from abc import ABC, abstractmethod

class Talker(ABC):
    @abstractmethod
    def talk(self):
        pass

在第九章中有更详细的描述。这里重要的是,您使用@abstractmethod将一个方法标记为抽象的——一个必须在子类中实现的方法。

Note

如果您使用的是旧版本的 Python,您将不会在abc模块中找到ABC类。然后你需要导入ABCMeta,并将(缩进的)行__metaclass__ = ABCMeta放在类定义的开头,就在class语句行的下面。如果您使用的是 Python 3.4 之前的版本,也可以使用Talker(metaclass=ABCMeta)而不是Talker(ABC)

抽象类(即具有抽象方法的类)最基本的属性是它没有实例。

>>> Talker()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class Talker with abstract methods talk

假设我们将它分成如下子类:

class Knigget(Talker):
    pass

我们没有覆盖talk方法,所以这个类也是抽象的,不能实例化。如果您尝试这样做,您会得到与上一条类似的错误消息。然而,我们可以重写它来实现所需的方法。

class Knigget(Talker):
    def talk(self):
        print("Ni!")

现在,实例化它就可以了。这是抽象基类的主要用途之一——也可能是 Python 中唯一正确使用isinstance的地方:如果我们首先检查给定的实例确实是一个Talker,我们就可以确信当我们需要它时,该实例将拥有talk方法。

>>> k = Knigget()
>>> isinstance(k, Talker)
True
>>> k.talk()
Ni!

尽管如此,我们仍然遗漏了画面中的一个重要部分——正如我之前暗示的,这个部分使isinstance更加多态。你看,抽象基类机制让我们本着鸭子打字的精神使用这种实例检查!我们不在乎你是什么——只在乎你能做什么(也就是你实现了哪些方法)。所以如果你实现了talk方法,但不是Talker的子类,你仍然应该通过我们的类型检查。所以让我们创建另一个类。

class Herring:
    def talk(self):
        print("Blub.")

作为一个谈话者,这应该很好——然而,它不是一个Talker

>>> h = Herring()
>>> isinstance(h, Talker)
False

当然,您可以简单地子类化Talker并完成它,但是您可能从其他人的模块中导入Herring,在这种情况下,这不是一个选项。比方说,不要创建HerringTalker的子类,你可以简单地将Herring注册为Talker,之后所有的鲱鱼都被正确地识别为说话者。

>>> Talker.register(Herring)
<class '__main__.Herring'>
>>> isinstance(h, Talker)
True
>>> issubclass(Herring, Talker)
True

然而,这里有一个潜在的弱点,它破坏了我们在直接子类化抽象类时看到的保证。

>>> class Clam:
...     pass
...
>>> Talker.register(Clam)
<class '__main__.Clam'>
>>> issubclass(Clam, Talker)
True
>>> c = Clam()
>>> isinstance(c, Talker)
True
>>> c.talk()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Clam' object has no attribute 'talk'

换句话说,isinstance返回True的事实应该被视为意图的表达。在这种情况下,Clam意为Talker。本着鸭子打字的精神,我们相信它会做好自己的工作——遗憾的是,它没有做到。

标准库提供了几个有用的抽象基类,例如在collections.abc模块中。关于abc模块的更多细节,请参见 Python 标准库参考。

关于面向对象设计的一些思考

很多书都是关于面向对象程序设计的,虽然这不是本书的重点,但我会给你一些提示:

  • 收集属于一起的东西。如果一个函数操作一个全局变量,那么这两者在一个类中作为属性和方法可能会更好。
  • 不要让对象变得太亲密。方法应该主要关心它们自己实例的属性。让其他实例管理它们自己的状态。
  • 少继承,尤其是多重继承。继承有时很有用,但在某些情况下会使事情变得不必要的复杂。多重继承可能很难正确处理,甚至更难调试。
  • 保持简单。保持你的方法简洁。根据经验,应该可以在 30 秒内阅读(并理解)你的大多数方法。至于其他的,尽量让它们少于一页或者一个屏幕。

在确定您需要哪些类以及它们应该具有哪些方法时,您可以尝试如下方式:

  1. 写下你的问题的描述(程序应该做什么?).在所有的名词、动词和形容词下面划线。
  2. 浏览名词,寻找潜在的类别。
  3. 浏览动词,寻找可能的方法。
  4. 浏览形容词,寻找潜在的属性。
  5. 给你的类分配方法和属性。

现在你有了面向对象模型的第一个草图。您可能还想考虑这些类和对象将具有什么样的责任和关系(比如继承或合作)。要优化模型,您可以执行以下操作:

  1. 写下(或想象出)一组用例——你的程序可能如何被使用的场景。尽量涵盖所有功能。
  2. 一步一步地考虑每一个用例,确保你的模型覆盖了你需要的一切。如果少了什么,就补充一下。如果有些事情不太对劲,那就改变它。继续,直到你满意为止。

当你有了一个你认为可行的模型,你就可以开始动手了。你可能需要修改你的模型或者修改你的程序的一部分。幸运的是,在 Python 中这很容易,所以不用担心。开始吧。(如果你想在面向对象编程方面得到更多的指导,请查阅第十九章中的推荐书目。)

快速总结

本章给你的不仅仅是关于 Python 语言的信息;它向你介绍了几个你可能完全陌生的概念。这里有一个总结:

  • 对象:对象由属性和方法组成。属性仅仅是作为对象一部分的变量,而方法或多或少是存储在属性中的函数。(绑定)方法和其他函数的一个区别是方法总是接收它们所属的对象作为它们的第一个参数,通常称为self
  • 类:一个类代表一组(或一种)对象,每个对象(实例)都有一个类。该类的主要任务是定义其实例将拥有的方法。
  • 多态性:多态性的特点是能够平等地对待不同类型和不同类的对象——您不需要知道一个对象属于哪个类就可以调用它的一个方法。
  • 封装:对象可以隐藏(或封装)它们的内部状态。在某些语言中,这意味着它们的状态(它们的属性)只能通过它们的方法获得。在 Python 中,所有属性都是公开可用的,但是程序员仍然应该小心直接访问对象的状态,因为他们可能无意中以某种方式使状态不一致。
  • 继承:一个类可能是一个或多个其他类的子类。然后子类继承超类的所有方法。您可以使用不止一个超类,这个特性可以用来组合正交的(独立的和不相关的)功能块。实现这一点的常见方式是使用一个核心超类以及一个或多个混合超类。
  • 接口和自省:一般来说,你不希望过于深入地刺激一个对象。你依赖多态性,调用你需要的方法。然而,如果你想知道一个对象有什么方法或属性,有一些函数可以帮你完成这项工作。
  • 抽象基类:使用abc模块,您可以创建所谓的抽象基类,这些抽象基类用于标识一个类应该提供的功能类型,而无需实际实现它。
  • 面向对象设计:关于如何(或者是否!)去做面向对象的设计。无论你站在这个问题的哪个方面,彻底理解你的问题并创造一个容易理解的设计是很重要的。

本章的新功能

| 功能 | 描述 | | --- | --- | | `callable(object)` | 确定对象是否可调用(如函数或方法) | | `getattr(object, name[, default])` | 获取属性的值,可以选择提供默认值 | | `hasattr(object, name)` | 已确定对象是否具有给定的属性 | | `isinstance(object, class)` | 已确定对象是否是类的实例 | | `issubclass(A, B)` | 确定`A`是否是`B`的子类 | | `random.choice(sequence)` | 从非空序列中选择一个随机元素 | | `setattr(object, name, value)` | 将对象的给定属性设置为`value` | | `type(object)` | 返回对象的类型 |

什么现在?

您已经学习了很多关于创建自己的对象的知识,以及这有多么有用。在一头扎进 Python 特殊方法的魔力之前(第九章),让我们用一个关于异常处理的小章节休息一下。

Footnotes 1

请注意,这些对象需要支持彼此相加。所以调用add(1, 'license')不起作用。

2

一些语言支持其成员变量(属性)的多种隐私级别。例如,Java 有四个不同的级别。Python 实际上并没有同等的隐私支持,尽管单双下划线在某种程度上给了你两个级别的隐私。

八、异常

当编写计算机程序时,通常有可能区分正常的事件过程和异常的事情。这种异常事件可能是错误(比如试图将一个数除以零),或者只是一些您不希望经常发生的事情。为了处理这样的异常事件,您可以在事件可能发生的任何地方使用条件(例如,让您的程序检查每个除法的分母是否为零)。然而,这不仅效率低、不灵活,而且会使程序难以辨认。您可能会忽略这些异常事件,只希望它们不会发生,但是 Python 提供了一种异常处理机制作为强大的替代方案。

在本章中,你将学习如何创建和引发你自己的异常,以及如何以各种方式处理异常。

什么是异常?

为了表示异常情况,Python 使用异常对象。当遇到错误时,它会引发异常。如果没有处理(或捕获)这样的异常对象,程序将终止于所谓的回溯(错误消息)。

>>> 1 / 0
Traceback (most recent call last):
   File "<stdin>", line 1, in ?
ZeroDivisionError: integer division or modulo by zero

如果这样的错误消息是您可以使用异常的全部,它们就不会很有趣了。然而,事实是,每个异常都是某个类的一个实例(在本例中是ZeroDivisionError),这些实例可能会以各种方式被引发和捕获,从而允许您捕获错误并对此采取措施,而不是让整个程序失败。

让事情出错。。。你的方式

如您所见,当出现问题时,异常会自动引发。在研究如何处理这些异常之前,让我们看看如何自己引发异常,甚至创建自己的异常。

加薪声明

要引发异常,可以使用带有参数的raise语句,该参数可以是类(应该是Exception的子类)或实例。当使用一个类时,一个实例被自动创建这里是一个例子,使用内置的异常类Exception:

>>> raise Exception
Traceback (most recent call last):
   File "<stdin>", line 1, in ?
Exception
>>> raise Exception('hyperdrive overload')
Traceback (most recent call last):
   File "<stdin>", line 1, in ?
Exception: hyperdrive overload

第一个例子raise Exception,引发了一个普通的异常,没有任何关于出错的信息。在前面的例子中,我添加了错误消息hyperdrive overload

许多内置类是可用的。表 8-1 描述了一些最重要的。您可以在 Python 库参考中的“内置异常”一节中找到所有这些异常的描述所有这些异常类都可以在您的raise语句中使用。

表 8-1。

Some Built-in Exceptions

| 类别名 | 描述 | | --- | --- | | `Exception` | 几乎所有异常的基类。 | | `AttributeError` | 当属性引用或赋值失败时引发。 | | `OSError` | 当操作系统无法执行任务(例如文件)时引发。有几个特定的子类。 | | `IndexError` | 对序列使用不存在的索引时引发。`LookupError`的子类。 | | `KeyError` | 在映射中使用不存在的键时引发。`LookupError`的子类。 | | `NameError` | 找不到名称(变量)时引发。 | | `SyntaxError` | 当代码格式错误时引发。 | | `TypeError` | 当内置操作或函数应用于错误类型的对象时引发。 | | `ValueError` | 当内置操作或函数应用于类型正确但值不正确的对象时引发。 | | `ZeroDivisionError` | 当除法或模运算的第二个参数为零时引发。 |
>>> raise ArithmeticError
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
ArithmeticError

自定义异常类

尽管内置的异常覆盖了很多领域,并且足够用于许多目的,但是有时您可能想要创建自己的异常。例如,在hyperdrive overload的例子中,用一个特定的HyperdriveError类来表示超空间引擎中的错误情况不是更自然吗?错误消息似乎已经足够了,但是正如您将在下一节(“捕获异常”)中看到的,您可以根据异常的类有选择地处理特定类型的异常。因此,如果您想用特殊的错误处理代码来处理超驱动器错误,您将需要一个单独的异常类。

那么,如何创建异常类呢?就像任何其他类一样——但是一定要子类化Exception(直接或间接,这意味着子类化任何其他内置异常都是可以的)。因此,编写一个自定义异常基本上相当于这样的内容:

class SomeCustomException(Exception): pass

真的没什么工作量吧?(如果您愿意,当然也可以向异常类添加方法。)

捕捉异常

如前所述,异常的有趣之处在于您可以处理它们(通常称为捕获或捕捉异常)。您可以使用try / except语句来实现这一点。假设您创建了一个程序,让用户输入两个数字,然后用一个除以另一个,如下所示:

x = int(input('Enter the first number: '))
y = int(input('Enter the second number: '))
print(x / y)

这将很好地工作,直到用户输入零作为第二个数字。

Enter the first number: 10
Enter the second number: 0
Traceback (most recent call last):
  File "exceptions.py", line 3, in ?
    print(x / y)
ZeroDivisionError: integer division or modulo by zero

为了捕捉异常并执行一些错误处理(在这种情况下,只需打印一条更加用户友好的错误消息),您可以像这样重写程序:

try:
    x = int(input('Enter the first number: '))
    y = int(input('Enter the second number: '))
    print(x / y)
except ZeroDivisionError:
    print("The second number can't be zero!")

看起来简单的if语句检查y的值会更容易使用,在这种情况下,这可能确实是一个更好的解决方案。但是如果你给你的程序增加更多的分部,你将需要每个分部有一个if语句;通过使用try / except,您只需要一个错误处理程序。

Note

异常从函数传播到它们被调用的地方,如果它们也没有被捕获,异常将“冒泡”到程序的顶层。这意味着您可以使用try / except来捕捉在其他人的函数中引发的异常。有关详细信息,请参阅本章后面的“异常和功能”一节。

听着,妈,别争了!

如果您已经捕获了一个异常,但是您想再次引发它(可以说是传递它),您可以不带任何参数地调用raise。(如果捕捉到异常,也可以显式提供异常,如本章后面的“捕捉对象”一节所述。)

作为这可能有用的一个例子,考虑一个具有“抑制”异常能力的计算器类。如果此行为被打开,计算器将打印出一条错误消息,而不是让异常传播。如果计算器在与用户的交互会话中使用,这是很有用的,但是如果在程序内部使用,引发一个异常会更好。因此,可以关闭消声。下面是这样一个类的代码:

class MuffledCalculator:
    muffled = False
    def calc(self, expr):
        try:
            return eval(expr)
        except ZeroDivisionError:
            if self.muffled:
                print('Division by zero is illegal')
            else:
                raise

Note

如果出现被零除的情况并且消音被打开,calc方法将(隐式地)返回 None。换句话说,如果打开消音,就不应该依赖返回值。

以下是如何使用该等级的示例,包括带消声和不带消声两种情况:

>>> calculator = MuffledCalculator()
>>> calculator.calc('10 / 2')
5.0
>>> calculator.calc('10 / 0') # No muffling
Traceback (most recent call last): File "<stdin>", line 1, in ?
  File "MuffledCalculator.py", line 6, in calc
     return eval(expr)
  File "<string>", line 0, in ?
ZeroDivisionError: integer division or modulo by zero
>>> calculator.muffled = True
>>> calculator.calc('10 / 0')
Division by zero is illegal

如你所见,当计算器没有被关闭时,ZeroDivisionError被捕获但被传递。

如果您无法处理异常,在except子句中使用不带参数的raise通常是一个不错的选择。不过,有时您可能想引发一个不同的异常。在这种情况下,导致您进入except原因的异常将被存储为您的异常的上下文,并将成为最终错误消息的一部分,例如:

>>> try:
...     1/0
... except ZeroDivisionError:
...     raise ValueError
...
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
ZeroDivisionError: division by zero

During handling of the above exception, another exception occurred:

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

您可以使用语句的raise ... from ...版本来提供自己的上下文异常,或者使用None来取消上下文。

>>> try:
...     1/0
... except ZeroDivisionError:
...     raise ValueError from None
...
Traceback (most recent call last):
  File "<stdin>", line 4, in <module>
ValueError

不止一个 except 子句

如果您再次运行上一节中的程序,并在提示符下输入一个非数字值,则会发生另一个异常。

Enter the first number: 10
Enter the second number: "Hello, world!"
Traceback (most recent call last):
  File "exceptions.py", line 4, in ?
     print(x / y)
TypeError: unsupported operand type(s) for /: 'int' and 'str'

因为except子句只寻找ZeroDivisionError异常,所以这一个会溜走并中止程序。为了捕捉这个异常,您可以简单地在同一个try / except语句中添加另一个except子句。

try:
   x = int(input('Enter the first number: '))
   y = int(input('Enter the second number: '))
   print(x / y)
except ZeroDivisionError:
   print("The second number can't be zero!")
except TypeError:
   print("That wasn't a number, was it?")

这一次使用if语句会更加困难。如何检查一个值是否可以用于除法?有很多方法,但是到目前为止,实际上最好的方法是简单地将这些值相除,看看是否可行。

还要注意异常处理并没有弄乱原始代码。添加大量的if语句来检查可能的错误条件很容易使代码变得难以阅读。

用一个块捕获两个异常

如果希望用一个块捕获多个异常类型,可以在一个元组中指定它们,如下所示:

try:
   x = int(input('Enter the first number: '))
   y = int(input('Enter the second number: '))
   print(x / y)
except (ZeroDivisionError, TypeError, NameError):
   print('Your numbers were bogus ...')

在前面的代码中,如果用户输入字符串或数字以外的内容,或者如果第二个数字是零,则打印相同的错误信息。当然,简单地打印一条错误消息并没有多大帮助。另一种选择是继续询问数字,直到除法运算成功。我将在本章后面的“一切顺利时”一节中向您展示如何做到这一点。

请注意,except子句中异常的括号非常重要。一个常见的错误是省略这些括号,在这种情况下,您可能会得到与您想要的不同的结果。有关解释,请参见下一节“捕捉对象”

抓住物体

如果您想在一个except子句中访问异常对象本身,您可以使用两个参数而不是一个。(注意,即使在捕捉多个异常时,您也只为except提供了一个参数——一个元组。)这可能是有用的(例如),如果你想让你的程序继续运行,但你想以某种方式记录错误(也许只是打印出来给用户)。下面是一个示例程序,它打印出异常(如果发生的话)但保持运行:

try:
    x = int(input('Enter the first number: '))
    y = int(input('Enter the second number: '))
    print(x / y)
except (ZeroDivisionError, TypeError) as e:
    print(e)

这个小程序中的except子句再次捕获了两种类型的异常,但是因为您也显式地捕获了对象本身,所以您可以将它打印出来,这样用户就可以看到发生了什么。(在本章后面的“当一切都好的时候”一节中,你会看到一个更有用的应用)

真正的包罗万象

即使程序处理了几种类型的异常,有些还是会漏掉。比如使用同一个除法程序,只需在提示符下试着按回车键,不用写任何东西。您应该得到一条错误消息和一些关于出错原因的信息(堆栈跟踪),如下所示:

Traceback (most recent call last):
  ...
ValueError: invalid literal for int() with base 10: ''

这个异常通过了try / except语句——这是正确的。你没有预见到这种情况会发生,也没有为此做好准备。在这些情况下,最好是程序立即崩溃(这样你就能看到哪里出了问题),而不是简单地用一个try / except语句隐藏异常,而这个语句并不是用来捕捉异常的。

然而,如果您确实想捕捉一段代码中的所有异常,您可以简单地从except子句中省略异常类。

try:
    x = int(input('Enter the first number: '))
    y = int(input('Enter the second number: '))
    print(x / y)
except:
    print('Something wrong happened ...')

现在你可以做任何你想做的事情。

Enter the first number: "This" is *completely* illegal 123
Something wrong happened ...

像这样捕捉所有异常是有风险的,因为它会隐藏您没有想到的错误以及您已经准备好的错误。它还将捕获用户试图通过Ctrl-C终止执行的行为,以及您调用的函数试图通过sys.exit终止执行的行为,等等。在大多数情况下,使用except Exception as e可能会更好,并且可能会对异常对象e进行一些检查。这将允许那些极少数不属于Exception子类的异常漏网。这包括SystemExitKeyboardInterrupt,它们是BaseException的子类,是Exception本身的超类。

当一切都好的时候

在某些情况下,除非发生不好的事情,否则执行一段代码是很有用的;与条件句和循环一样,您可以在try / except语句中添加一个else子句。

try:
   print('A simple task')
except:
   print('What? Something went wrong?')
else:
   print('Ah ... It went as planned.')

如果运行此命令,您会得到以下输出:

A simple task
Ah ... It went as planned.

使用这个else子句,您可以实现本章前面“用一个块捕获两个异常”一节中暗示的循环。

while True:
    try:
        x = int(input('Enter the first number: '))
        y = int(input('Enter the second number: '))
        value = x / y
        print('x / y is', value)
    except:
        print('Invalid input. Please try again.')
    else:
        break

在这里,只有当没有出现异常时,循环才被中断(通过else子句中的break语句)。换句话说,只要有错误发生,程序就会不断要求新的输入。以下是一个运行示例:

Enter the first number: 1
Enter the second number: 0
Invalid input. Please try again.
Enter the first number: 'foo'
Enter the second number: 'bar'
Invalid input. Please try again.
Enter the first number: baz
Invalid input. Please try again.
Enter the first number: 10
Enter the second number: 2
x / y is 5

如前所述,使用空的except子句的一个更好的替代方法是捕获Exception类的所有异常(这也将捕获任何子类的所有异常)。你不能 100%确定你能捕捉到所有的东西,因为你的try / except语句中的代码可能很淘气,使用老式的字符串异常,或者创建一个没有子类Exception的自定义异常。然而,如果您使用的是except Exception版本,您可以使用本章前面“捕获对象”一节中的技术在您的小除法程序中打印出一个更有指导意义的错误消息。

while True:
    try:
        x = int(input('Enter the first number: '))
        y = int(input('Enter the second number: '))
        value = x / y
        print('x / y is', value)
    except Exception as e:
        print('Invalid input:', e)
        print('Please try again')
    else:
        break

以下是运行示例:

Enter the first number: 1
Enter the second number: 0
Invalid input: integer division or modulo by zero
Please try again
Enter the first number: 'x' Enter the second number: 'y'
Invalid input: unsupported operand type(s) for /: 'str' and 'str'
Please try again
Enter the first number: quuux
Invalid input: name 'quuux' is not defined
Please try again
Enter the first number: 10
Enter the second number: 2
x / y is 5

最后。。。

最后是finally条款。在可能出现异常后,您可以用它来做内务处理。它与一个try子句相结合。

x = None
try:
    x = 1 / 0
finally:
    print('Cleaning up ...')
    del x

在前面的例子中,无论在try子句中出现什么异常,都保证会执行finally子句。在try子句之前初始化x的原因是,否则它将不会因为ZeroDivisionError而被赋值。当在finally子句中对其使用del时,这将导致一个异常,您不会捕捉到这个异常。

如果你运行这个,清理会在程序崩溃和烧毁之前进行。

Cleaning up ...
Traceback (most recent call last):
  File "C:\python\div.py", line 4, in ?
     x = 1 / 0
ZeroDivisionError: integer division or modulo by zero

虽然使用del删除变量是一种相当愚蠢的清理,但是finally子句对于关闭文件或网络套接字之类的东西可能非常有用。(你会在第十四章中了解到更多。)

您还可以在一个语句中组合使用tryexceptfinallyelse(或者只使用其中的三个)。

try:
    1 / 0
except NameError:
    print("Unknown variable")
else:
    print("That went well!")
finally:
    print("Cleaning up.")

异常和功能

异常和函数很自然地一起工作。如果一个异常是在函数内部引发的,并且没有在那里得到处理,它会传播(冒泡)到调用该函数的地方。如果它也不在那里处理,它将继续传播,直到到达主程序(全局范围),如果那里没有异常处理程序,程序将中止,并返回一个堆栈跟踪。让我们来看一个例子:

>>> def faulty():
...     raise Exception('Something is wrong')
...
>>> def ignore_exception():
...     faulty()
...
>>> def handle_exception():
...     try:
...         faulty()
...     except:
...         print('Exception handled')
...
>>> ignore_exception()
Traceback (most recent call last):
  File '<stdin>', line 1, in ?
  File '<stdin>', line 2, in ignore_exception
  File '<stdin>', line 2, in faulty
Exception: Something is wrong
>>> handle_exception()
Exception handled

如您所见,faulty中引发的异常通过faultyignore_exception传播,并最终导致堆栈跟踪。类似地,它传播到handle_exception,但是在那里用try / except语句处理。

异常的禅

异常处理并不复杂。如果您知道代码的某些部分可能会导致某种异常,并且您不希望程序在这种情况发生时以堆栈跟踪终止,那么您可以根据需要添加必要的try / excepttry / finally语句(或它们的某种组合)来处理它。

有时,您可以用条件语句完成与异常处理相同的事情,但是条件语句可能会变得不自然,可读性差。另一方面,有些事情看起来像是对if / else的自然应用,实际上用try / except可以实现得更好。让我们来看几个例子。

假设您有一个字典,您想打印存储在特定键下的值,如果它在那里的话。如果它不在那里,你不想做任何事情。代码可能是这样的:

def describe_person(person):
    print('Description of', person['name'])
    print('Age:', person['age'])
    if 'occupation' in person:
        print('Occupation:', person['occupation'])

如果您为该函数提供一个包含名称 Throatwobbler 红树林和年龄 42(但没有职业)的字典,您将得到以下输出:

Description of Throatwobbler Mangrove
Age: 42

如果您添加职业“camper”,您将得到以下输出:

Description of Throatwobbler Mangrove
Age: 42
Occupation: camper

代码很直观,但有点低效(尽管这里主要关心的是代码的简单性)。它必须查找键'occupation'两次——一次是查看键是否存在(在条件中),一次是获取值(打印出来)。另一个定义如下:

def describe_person(person):
    print('Description of', person['name'])
    print('Age:', person['age'])
    try:
        print('Occupation:', person['occupation'])
    except KeyError: pass

这里,该函数简单地假设键'occupation'存在。如果您认为正常情况下是这样的,这可以节省一些精力。将获取并打印该值——不需要额外的获取来检查它是否确实存在。如果这个键不存在,就会引发一个KeyError异常,这个异常被except子句捕获。

您可能还会发现try / except在检查对象是否具有特定属性时非常有用。比方说,你想检查一个对象是否有一个write属性。那么您可以使用这样的代码:

try:
    obj.write
except AttributeError:
    print('The object is not writeable')
else:
    print('The object is writeable')

这里的try子句只是访问属性,而不对它做任何有用的事情。如果一个AttributeError被引发,那么这个对象没有这个属性;否则,它具有属性。这是第七章(在“接口和自省”一节)中介绍的getattr解决方案的自然替代方案。你更喜欢哪一个在很大程度上取决于你的品味。

请注意,这里的效率增益并不大。(更像是非常非常微小。)一般来说(除非你的程序有性能问题),你不应该太担心这种优化。关键是在很多情况下,使用try / except语句比使用if / else语句更自然(更“Pythonic 化”),你应该养成尽可能使用它们的习惯。1

并不都是异常

如果您只是想提供一个警告,说明事情并不完全像它们应该的那样,您可以使用来自warnings模块的warn函数。

>>> from warnings import warn
>>> warn("I've got a bad feeling about this.")
__main__:1: UserWarning: I've got a bad feeling about this.
>>>

该警告只会显示一次。如果你再运行最后一行,什么也不会发生。

使用您的模块的其他代码可以禁止您的警告,或者只禁止特定种类的警告,使用来自同一个模块的filterwarnings函数,指定几个可能采取的动作之一,包括"error""ignore"

>>> from warnings import filterwarnings
>>> filterwarnings("ignore")
>>> warn("Anyone out there?")
>>> filterwarnings("error")
>>> warn("Something is very wrong!")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UserWarning: Something is very wrong!

如您所见,引发的异常是UserWarning。发出警告时,可以指定不同的异常或警告类别。这个异常应该是Warning的子类。如果您将警告转换为错误,将使用您提供的异常,但是您也可以使用它来专门筛选出给定类型的警告。

>>> filterwarnings("error")
>>> warn("This function is really old...", DeprecationWarning)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
DeprecationWarning: This function is really old...
>>> filterwarnings("ignore", category=DeprecationWarning)
>>> warn("Another deprecation warning.", DeprecationWarning)
>>> warn("Something else.")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UserWarning: Something else.

除了这个基本的用途之外,warnings模块还有一些高级的功能。如果你好奇,可以参考图书馆的参考资料。

快速总结

本章涵盖的主要主题如下:

  • 异常对象:异常情况(比如发生错误时)由异常对象表示。这些可以用几种方式操作,但是如果忽略,它们会终止你的程序。
  • 引发异常:可以用raise语句引发异常。它接受异常类或异常实例作为其参数。您还可以提供两个参数(一个异常和一个错误消息)。如果在一个except子句中不带参数调用raise,它会“重新引发”该子句捕获的异常。
  • 自定义异常类:您可以通过子类化Exception来创建自己的异常类型。
  • 捕捉异常:用一个try语句的except子句捕捉异常。如果没有在except子句中指定一个类,所有的异常都会被捕获。通过将类放在一个元组中,可以指定多个类。如果你给except两个参数,第二个被绑定到异常对象。在同一个try / except语句中可以有几个except子句,以对不同的异常做出不同的反应。
  • else从句:除了except之外,你还可以使用else从句。如果主try程序块中没有出现异常,则执行else子句。
  • 最后:如果您需要确保某些代码(例如,清理代码)得到执行,无论是否引发异常,您都可以使用try / finally。这段代码随后被放入finally子句中。
  • 异常和函数:当您在函数内部引发异常时,它会传播到调用该函数的地方。(方法也一样。)
  • 警告:警告类似于异常,但(通常)只是打印出一条错误消息。您可以指定一个警告类别,它是Warning的子类。

本章的新功能

| 功能 | 描述 | | --- | --- | | `warnings.filterwarnings(action, category=Warning, ...)` | 用于过滤掉警告 | | `warnings.warn(message, category=None)` | 用于发出警告 |

什么现在?

虽然你可能认为这一章的内容很特别(原谅我的双关语),但下一章真的很神奇。嗯,几乎是神奇的。

Footnotes 1

在 Python 中对try/except的偏爱通常可以通过少将·格蕾丝·赫柏的智慧之言来解释,“请求原谅比请求许可更容易。”这种简单地尝试做一些事情并处理任何错误的策略,而不是预先做大量的检查,被称为“三思而后行”。

九、魔术方法、属性和迭代器

在 Python 中,一些名字以一种特殊的方式拼写,有两个前导下划线和两个尾随下划线。你已经遇到了其中的一些(例如__future__)。这种拼写表明该名称具有特殊的意义——您永远不应该为自己的程序发明这样的名称。语言中一组非常突出的此类名称由神奇的(或特殊的)方法名称组成。如果您的对象实现了这些方法中的一个,Python 将在特定情况下(具体取决于名称)调用该方法。很少需要直接调用这些方法。

这一章讲述了一些重要的魔法方法(最著名的是__init__方法和一些处理项目访问的方法,允许你创建自己的序列或映射)。它还处理了两个相关的主题:属性(在以前版本的 Python 中通过魔法方法处理,但现在由property函数处理)和迭代器(使用魔法方法__iter__使它们能够在for循环中使用)。在这一章的最后你会发现一个丰富的例子,这个例子使用了你到目前为止学到的一些东西来解决一个相当困难的问题。

如果您没有使用 Python 3

不久前(在 2.2 版本中),Python 对象的工作方式发生了很大变化。这种变化有几个后果,其中大部分对初学 Python 的程序员来说并不重要。不过,有一点值得注意:即使您使用的是 Python 2 的最新版本,一些特性(比如属性和super函数)也不能在“旧式”类上工作。为了让你的类成为“新风格”,你要么把赋值语句__metaclass__ = type放在模块的顶部(如第七章所述),要么(直接或间接)子类化内置类object,或者其他一些新风格的类。考虑以下两个类:

class NewStyle(object):
    more_code_here

class OldStyle:
    more_code_here

这两个中,NewStyle是新型类;OldStyle是老派班。但是,如果文件以__metaclass__ = type开头,那么这两个类都将是新样式的。

Note

你也可以在你的类的类范围内给__metaclass__变量赋值。这将只设置那个类的元类。元类是其他类的类——这是一个相当高级的话题。

我没有在本书的所有例子中明确设置元类(或子类object)。但是,如果您不特别需要让您的程序与旧版本的 Python 兼容,我建议您将所有的类都变成新的样式,并一致地使用诸如super函数之类的特性(在本章后面的“使用超级函数”一节中有描述)。

注意,Python 3 中没有“旧式”的类,也不需要显式地子类化 object 或将元类设置为 type。所有的类都将隐式地成为object的子类——如果你没有指定一个超类,那么就是直接的,否则就是间接的。

构造器

我们要看的第一个魔术方法是构造函数。如果您以前从未听说过“构造函数”这个词,它基本上是我已经在一些例子中使用的初始化方法的一个别出心裁的名字,命名为__init__。然而,构造函数与普通方法的区别在于,构造函数是在对象创建后自动调用的。因此,我没有像现在这样做:

>>> f = FooBar()
>>> f.init()

构造函数使得简单地做到这一点成为可能:

>>> f = FooBar()

用 Python 创建构造函数真的很容易;简单地把这个init方法的名字从简单的旧init改成神奇的版本__init__

class FooBar:
    def __init__(self):
        self.somevar = 42

>>> f = FooBar()
>>> f.somevar
42

现在,这很好。但是你可能想知道如果给构造函数一些参数会发生什么。请考虑以下几点:

class FooBar:
    def __init__(self, value=42):
       self.somevar = value

你觉得你会怎么用这个?因为参数是可选的,所以您当然可以像什么都没发生一样继续下去。但是如果您想使用它(或者您没有使它成为可选的)呢?我相信你已经猜到了,但还是让我给你看看吧。

>>> f = FooBar('This is a constructor argument')
>>> f.somevar
'This is a constructor argument'

在 Python 中所有魔术方法中,__init__无疑是您使用最多的一种。

Note

Python 有一个魔术方法叫做__del__,也叫析构函数。它在对象被销毁(垃圾收集)之前被调用,但是因为你不能真正知道这何时(或者是否)发生,我建议你尽可能远离__del__

重写一般的方法,尤其是构造函数

在第七章中,你学习了关于继承的知识。每个类可能有一个或多个超类,它们从这些超类中继承行为。如果在类B的实例上调用了一个方法(或者访问了一个属性),但是没有找到,那么将搜索它的超类A。考虑以下两个类:

class A:
    def hello(self):
        print("Hello, I'm A.")

class B(A):
    pass

A定义了一个名为hello的方法,该方法由类B继承。下面是这些类如何工作的示例:

>>> a = A()
>>> b = B()
>>> a.hello()
Hello, I'm A.
>>> b.hello()
Hello, I'm A.

因为B没有定义自己的hello方法,所以当hello被调用时,原始消息被打印出来。

在子类中添加功能的一个基本方法是简单地添加方法。但是,您可能希望通过重写超类的一些方法来自定义继承的行为。例如,B可以覆盖hello方法。考虑一下B的这个修改过的定义:

class B(A):
    def hello(self):
        print("Hello, I'm B.")

使用这个定义,b.hello()将给出不同的结果。

>>> b = B()
>>> b.hello()
Hello, I'm B.

一般来说,重写是继承机制的一个重要方面,对于构造函数来说尤其重要。构造函数用来初始化新构造的对象的状态,除了超类的初始化代码之外,大多数子类都需要有自己的初始化代码。尽管所有方法的重写机制都是相同的,但是与重写普通方法相比,在处理构造函数时,您很可能会遇到一个更常见的问题:如果您重写了一个类的构造函数,则需要调用超类(您继承的类)的构造函数,否则可能会有对象未正确初始化的风险。

考虑下面的类,Bird:

class Bird:
    def __init__(self):
        self.hungry = True
    def eat(self):
        if self.hungry:
            print('Aaaah ...')
            self.hungry = False
        else:
            print('No, thanks!')

这个类定义了所有鸟类最基本的能力之一:进食。下面是一个如何使用它的例子:

>>> b = Bird()
>>> b.eat()
Aaaah ...
>>> b.eat()
No, thanks!

正如你从这个例子中看到的,一旦鸟吃了东西,它就不再饿了。现在考虑子类SongBird,它将歌唱添加到行为的曲目中。

class SongBird(Bird):
    def __init__(self):
        self.sound = 'Squawk!'
    def sing(self):
        print(self.sound)

SongBird类和Bird一样容易使用。

>>> sb = SongBird()
>>> sb.sing()
Squawk!

因为SongBirdBird的子类,它继承了eat方法,但是如果你试图调用它,你会发现一个问题。

>>> sb.eat()
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
  File "birds.py", line 6, in eat
     if self.hungry:
AttributeError: SongBird instance has no attribute 'hungry'

这个异常很清楚哪里出了问题:SongBird没有名为hungry的属性。为什么要这样?在SongBird中,构造函数被覆盖,新的构造函数不包含任何处理hungry属性的初始化代码。为了纠正这种情况,SongBird构造函数必须调用其超类Bird的构造函数,以确保基本的初始化发生。基本上有两种方法:调用超类的构造函数的未绑定版本,或者使用super函数。在接下来的两节中,我将解释这两种技术。

调用未绑定的超类构造函数

本节所描述的方法也许主要是有历史意义的。对于当前版本的 Python,使用super函数(如下一节所述)显然是一条可行之路。然而,许多现有的代码使用本节中描述的方法,所以您需要了解它。此外,它可能很有启发性——这是绑定方法和未绑定方法之间区别的一个很好的例子。

现在,让我们言归正传。如果你觉得这部分的标题有点吓人,放松。事实上,调用超类的构造函数非常容易(也非常有用)。我将首先给你上一节末尾提出的问题的解决方案。

class SongBird(Bird):
    def __init__(self):
        Bird.__init__(self)
        self.sound = 'Squawk!'
    def sing(self):
        print(self.sound)

只有一行被添加到了SongBird类中,包含代码Bird.__init__(self)。在我解释这到底意味着什么之前,让我向你展示一下这真的有效。

>>> sb = SongBird()
>>> sb.sing()
Squawk!
>>> sb.eat()
Aaaah ...
>>> sb.eat()
No, thanks!

但是为什么会这样呢?当从实例中检索方法时,方法的self参数自动绑定到实例(所谓的绑定方法)。你已经看过几个这样的例子了。但是,如果直接从类中检索方法(比如在Bird.__init__中),就没有要绑定的实例。因此,你可以自由提供任何你想要的self。这种方法称为未绑定,这解释了本节的标题。

通过将当前实例作为self参数提供给未绑定的方法,songbird 从其超类的构造函数中获得完整的处理(这意味着它已经设置了其hungry属性)。

使用超级功能

如果您没有受困于旧版本的 Python,那么super函数确实是一个不错的选择。它只适用于新类型的类,但是无论如何你都应该使用它们。它是用当前的类和实例作为它的参数来调用的,你对返回对象调用的任何方法都将从超类而不是当前的类中获取。因此,您可以使用super(SongBird, self),而不是在SongBird构造函数中使用Bird。此外,__init__方法可以以普通(绑定)方式调用。在 Python 3 中,super可以——并且通常应该——被不带任何参数地调用,并且会像“魔术般地”完成它的工作

以下是 bird 示例的更新版本:

class Bird:
    def __init__(self):
       self.hungry = True
    def eat(self):
        if self.hungry:
            print('Aaaah ...')
            self.hungry = False
        else:
            print('No, thanks!')

class SongBird(Bird):
    def __init__(self):
        super().__init__()
        self.sound = 'Squawk!'
    def sing(self):
        print(self.sound)

这种新式版本的工作原理与旧式版本一样:

>>> sb = SongBird()
>>> sb.sing()
Squawk!
>>> sb.eat()
Aaaah ...
>>> sb.eat()
No, thanks!

What’s So Super About Super?

在我看来,super函数比直接调用超类上的未绑定方法更直观,但这不是它唯一的优点。super函数其实挺聪明的,所以即使你有多个超类,也只需要用一次super(前提是所有超类构造函数也都用super)。此外,当使用旧风格的类时(例如,当你的两个超类共享一个超类时),一些难以理解的情况会被新风格的类和super自动处理。您不需要确切地理解它在内部是如何工作的,但是您应该知道,在大多数情况下,它明显优于调用超类的未绑定构造函数(或其他方法)。

那么,super到底回报了什么呢?通常情况下,您不需要担心它,您可以假装它返回您需要的超类。它实际上做的是返回一个超级对象,它将为您处理方法解析。当你访问它的一个属性时,它会检查你所有的超类(和超超类,等等),直到找到这个属性,或者引发一个AttributeError

项目访问

虽然__init__是迄今为止你会遇到的最重要的特殊方法,但是还有很多其他方法可以让你实现很多很酷的事情。本节描述的一组有用的神奇方法允许您创建行为类似序列或映射的对象。

基本的序列和映射协议非常简单。然而,要实现序列和映射的所有功能,有许多魔术方法要实现。幸运的是,有一些捷径,但我会得到这一点。

Note

Python 中经常使用 protocol 一词来描述管理某种形式行为的规则。这有点类似于第七章中提到的接口概念。该协议说明了您应该实现哪些方法以及这些方法应该做什么。因为 Python 中的多态性只基于对象的行为(而不是基于它的祖先,例如,它的类或超类等等),所以这是一个重要的概念:其他语言可能要求对象属于某个类或实现某个接口,而 Python 通常只要求它遵循某个给定的协议。所以,要成为序列,你要做的就是遵循序列协议。

基本序列和映射协议

序列和映射基本上是项目的集合。要实现它们的基本行为(协议),如果对象是不可变的,那么需要两个魔术方法,如果是可变的,那么需要四个。

  • __len__(self):这个方法应该返回集合中包含的项目数。对于一个序列,这只是元素的数量。对于映射,它将是键-值对的数量。如果__len__返回零(并且您没有实现__nonzero__,它覆盖了这个行为),那么对象在布尔上下文中被视为 false(与空列表、元组、字符串和字典一样)。
  • __getitem__(self, key):应该返回给定键对应的值。对于一个序列,密钥应该是一个从 0 到 n–1 的整数(或者,它可以是负数,如后面所述),其中 n 是序列的长度。对于一个映射,你可以有任何类型的键。
  • __setitem__(self, key, value):这个应该以与key相关联的方式存储value,以便以后可以用__getitem__检索。当然,您只为可变对象定义这个方法。
  • __delitem__(self, key):当有人在对象的一部分上使用了__del__语句,并且应该删除与key相关的元素时,就会调用这个函数。同样,只有可变的对象(而不是所有的对象——只有那些您希望删除项目的对象)应该定义这个方法。

对这些方法有一些额外的要求。

  • 对于一个序列,如果键是一个负整数,就应该用它从末尾开始计数。换句话说,对待x[-n]和对待x[len(x)-n]一样。
  • 如果键是不适当的类型(例如在序列上使用的字符串键),可能会引发TypeError
  • 如果序列的索引是正确的类型,但是在允许的范围之外,则应该引发IndexError

对于更广泛的接口,以及合适的抽象基类(Sequence),请查阅collections模块的文档。

让我们试一试——看看我们能否创建一个无限序列。

def check_index(key):
    """
    Is the given key an acceptable index?

    To be acceptable, the key should be a non-negative integer. If it
    is not an integer, a TypeError is raised; if it is negative, an
    IndexError is raised (since the sequence is of infinite length).
    """
    if not isinstance(key, int): raise TypeError
    if key < 0: raise IndexError

class ArithmeticSequence:

    def __init__(self, start=0, step=1):
        """
        Initialize the arithmetic sequence.

        start   - the first value in the sequence
        step    - the difference between two adjacent values
        changed - a dictionary of values that have been modified by
                  the user
        """
        self.start = start                        # Store the start value
        self.step = step                          # Store the step value
        self.changed = {}                         # No items have been modified

    def __getitem__(self, key):
        """
        Get an item from the arithmetic sequence.
        """
        check_index(key)

        try: return self.changed[key]             # Modified?
        except KeyError:                          # otherwise ...
            return self.start + key * self.step   # ... calculate the value

    def __setitem__(self, key, value):
        """
        Change an item in the arithmetic sequence.
        """
        check_index(key)

        self.changed[key] = value                 # Store the changed value

这实现了一个算术序列—一个数字序列,其中每个数字都比前一个数字大一个常数。第一个值由构造函数参数start给出(默认为零),而值之间的步长由step给出(默认为一)。你允许用户通过在一个叫做changed的字典中保留一般规则的例外来改变一些元素。如果元素没有被改变,它被计算为self.start + key * self.step

下面是一个如何使用该类的示例:

>>> s = ArithmeticSequence(1, 2)
>>> s[4]
9
>>> s[4] = 2
>>> s[4]
2
>>> s[5]
11

注意,我希望删除项目是非法的,这就是为什么我没有实现__del__:

>>> del s[4]
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
AttributeError: ArithmeticSequence instance has no attribute '__delitem__'

此外,该类没有__len__方法,因为它的长度是无限的。

如果使用了非法类型的索引,则引发一个TypeError,如果索引是正确的类型,但超出了范围(即,在本例中为负),则引发一个IndexError

>>> s["four"]
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
  File "arithseq.py", line 31, in __getitem__
     check_index(key)
  File "arithseq.py", line 10, in checkIndex
     if not isinstance(key, int): raise TypeError
TypeError
>>> s[-42]
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
  File "arithseq.py", line 31, in __getitem__
     check_index(key)
  File "arithseq.py", line 11, in checkIndex
     if key < 0: raise IndexError
IndexError

索引检查由我为此编写的实用函数check_index负责。

子类化列表、字典和字符串

虽然基本序列/映射协议的四个方法会让你走得更远,但是序列可能有许多其他有用的神奇和普通的方法,包括__iter__方法,我将在本章后面的“迭代器”一节中描述它。实现所有这些方法需要做大量的工作,而且很难做对。如果您只想要其中一个操作中的自定义行为,那么您需要重新实现所有其他操作是没有意义的。只是程序员的懒惰(也叫常识)。

那么你应该怎么做呢?神奇的词是继承。当您可以继承它们时,为什么要重新实现所有这些东西呢?标准库在collections模块中提供了抽象和具体的基类,但是您也可以简单地对内置类型本身进行子类化。因此,如果您想实现一个行为类似于内置列表的序列类型,您可以简单地子类化list

让我们做一个简单的例子——一个带有访问计数器的列表。

class CounterList(list):
    def __init__(self, *args):
        super().__init__(*args)
        self.counter = 0
    def __getitem__(self, index):
        self.counter += 1
        return super(CounterList, self).__getitem__(index)

CounterList类严重依赖于它的子类超类(list)的行为。任何未被CounterList覆盖的方法(如appendextendindex等)都可以直接使用。在被覆盖的两个方法中,super用于调用该方法的超类版本,只添加了初始化counter属性(在__init__中)和更新counter属性(在__getitem__中)的必要行为。

Note

覆盖__getitem__并不是捕获用户访问的可靠方法,因为还有其他访问列表内容的方法,比如通过pop方法。

下面是一个如何使用CounterList的例子:

>>> cl = CounterList(range(10))
>>> cl
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> cl.reverse()
>>> cl
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
>>> del cl[3:6]
>>> cl
[9, 8, 7, 3, 2, 1, 0]
>>> cl.counter
0
>>> cl[4] + cl[2]
9
>>> cl.counter
2

正如你所看到的,CounterList在大多数方面都和list一样工作。但是,它有一个counter属性(最初为零),每次访问一个列表元素时,这个属性就会增加。在执行加法cl[4] + cl[2]之后,计数器已经增加了两次,达到值 2。

更多的魔法

特殊的(神奇的)名字有很多用途——到目前为止,我向您展示的只是一个小小的尝试。大多数可用的魔法方法都是为了相当高级的用途,所以我在这里就不赘述了。但是,如果您感兴趣,可以模拟数字,制作可以像函数一样调用的对象,影响对象的比较方式,等等。有关哪些神奇方法可用的更多信息,请参见 Python 参考手册中的“特殊方法名”一节。

性能

在第七章中,我提到了访问器方法。访问器只是名字为getHeightsetHeight的方法,用于检索或重新绑定某些属性(这些属性可能是类的私有属性——参见第七章中的“隐私回顾”一节)。如果在访问给定的属性时必须采取某些动作,像这样封装状态变量(属性)可能很重要。例如,考虑下面的Rectangle类:

class Rectangle:
    def __init__(self):
        self.width = 0
        self.height = 0
    def set_size(self, size):
        self.width, self.height = size
    def get_size(self):
        return self.width, self.height

下面是一个如何使用该类的示例:

>>> r = Rectangle()
>>> r.width = 10
>>> r.height = 5
>>> r.get_size()
(10, 5)
>>> r.set_size((150, 100))
>>> r.width
150

get_sizeset_size方法是一个名为size的虚拟属性的访问器——这个虚拟属性就是由widthheight组成的元组。(可以随意用更令人兴奋的东西来代替这个,比如矩形的面积或者对角线的长度。)这段代码并没有直接错,但它有缺陷。使用这个类的程序员不需要担心它是如何实现的(封装)。如果有一天你想改变实现,使size成为一个真正的属性,并且widthheight是动态计算的,你将需要把它们包装在访问器中,并且任何使用该类的程序也必须重写。客户端代码(使用您的代码的代码)应该能够以相同的方式处理您的所有属性。

那么解决办法是什么呢?是否应该将所有属性包装在访问器中?当然,这是一种可能性。然而,如果您有许多简单的属性,这将是不切实际的(并且有点愚蠢),因为您将需要编写许多访问器,这些访问器除了检索或设置这些属性之外什么也不做,没有采取任何有用的操作。这有点复制粘贴编程或 cookiecutter 代码的味道,这显然是一件坏事(尽管在某些语言中这种特定问题很常见)。幸运的是,Python 可以为您隐藏访问器,使您的所有属性看起来都一样。那些通过它们的访问器定义的属性通常被称为属性。

Python 实际上有两种在 Python 中创建属性的机制。我将把重点放在最近的一个函数上,即property函数,它只适用于新型类。然后我会简单介绍一下如何用魔法方法实现属性。

属性函数

使用property函数非常简单。如果您已经编写了一个类,比如上一节中的Rectangle,那么您只需要添加一行代码。

class Rectangle:
    def __init__ (self):
        self.width = 0
        self.height = 0
    def set_size(self, size):
        self.width, self.height = size
    def get_size(self):
        return self.width, self.height
    size = property(get_size, set_size)

在这个新版本的Rectangle中,用property函数创建了一个属性,将访问函数作为参数(首先是 getter,然后是 setter),然后将名称size绑定到这个属性。在这之后,你不再需要担心事情是如何实现的,而是可以用同样的方式对待widthheightsize

>>> r = Rectangle()
>>> r.width = 10
>>> r.height = 5
>>> r.size
(10, 5)
>>> r.size = 150, 100
>>> r.width
150

正如您所看到的,size属性仍然受get_sizeset_size中的计算的影响,但是它看起来就像一个普通的属性。

Note

如果您的属性行为异常,请确保您使用的是新型类(通过直接或间接对 object 进行子类化,或者通过直接设置元类)。如果不是这样,属性的 getter 部分仍然可以工作,但是 setter 可能不行(取决于您的 Python 版本)。这可能有点令人困惑。

事实上,property函数也可以用零个、一个、三个或四个参数来调用。如果不带任何参数调用,则结果属性既不可读也不可写。如果只用一个参数(getter 方法)调用,则该属性是只读的。第三个(可选)参数是用于删除属性的方法(它不带参数)。第四个(可选)参数是 docstring。这些参数被称为fgetfsetfdeldoc——如果您想要一个只可写且有 docstring 的属性,可以将它们用作关键字参数。

尽管这一部分很短(证明了property函数的简单性),但它非常重要。寓意是:对于新型类,你应该使用property而不是访问器。

But How Does it Work?

如果你对房产如何变魔术感到好奇,我会在这里给你一个解释。如果你不在乎,就直接跳过。

事实上,property 并不是一个真正的函数——它是一个类,它的实例有一些魔术方法来完成所有的工作。正在讨论的方法是__get____set____delete__。这三种方法一起定义了所谓的描述符协议。实现这些方法的对象就是描述符。描述符的特殊之处在于它们是如何被访问的。比如,在读取一个属性时(具体来说,是在实例中访问时,但属性是在类中定义的),如果该属性被绑定到一个实现了__get__的对象上,那么这个对象就不会简单地被返回;相反,将调用__get__方法,并返回结果值。事实上,这是属性、绑定方法、静态和类方法(更多信息见下一节)和super的底层机制。

有关描述符的更多信息,请参见描述符使用指南( https://docs.python.org/3/howto/descriptor.html )。

静态方法和类方法

在讨论实现属性的老方法之前,让我们稍微绕一下,看看另外两个以类似于新样式属性的方式实现的特性。静态方法和类方法分别通过在staticmethodclassmethod类的对象中包装方法来创建。静态方法是在没有self参数的情况下定义的,它们可以直接在类本身上被调用。类方法是用一个类似于self的参数定义的,这个参数通常叫做cls。您也可以直接在类对象上调用类方法,但是cls参数会自动绑定到该类。这里有一个简单的例子:

class MyClass:

    def smeth():
        print('This is a static method')
    smeth = staticmethod(smeth)

    def cmeth(cls):
        print('This is a class method of', cls)
    cmeth = classmethod(cmeth)

像这样手工包装和替换方法的技术有点乏味。在 Python 2.4 中,像这样的包装方法引入了一种新的语法,称为 decorators。(它们实际上以包装器的形式与任何可调用对象一起工作,可以用在方法和函数上。)使用@操作符,通过在方法(或函数)上面列出一个或多个 decorators 来指定它们(以相反的顺序应用)。

class MyClass:

    @staticmethod
    def smeth():
        print('This is a static method')

    @classmethod
    def cmeth(cls):
        print('This is a class method of', cls)

一旦定义了这些方法,就可以像这样使用它们(也就是说,不需要实例化类):

>>> MyClass.smeth()
This is a static method
>>> MyClass.cmeth()
This is a class method of <class '__main__.MyClass'>

静态方法和类方法在 Python 中历史上并不重要,主要是因为在某种程度上,您总是可以使用函数或绑定方法来代替,但也因为在早期版本中并没有真正的支持。因此,即使您可能在当前代码中没有看到它们被大量使用,它们也有它们的用途(比如工厂函数,如果您听说过的话),并且您很可能会想到一些新的功能。

Note

实际上,您也可以对属性使用装饰语法。详情见property功能文档。

getattrsetattr,和朋友

拦截对象上的每个属性访问是可能的。除此之外,您可以用它来实现旧式类的属性(其中property不一定像它应该的那样工作)。要在访问属性时执行代码,必须使用一些魔术方法。下面四个提供了您需要的所有功能(在旧式类中,您只使用最后三个):

  • __getattribute__(self, name):取属性name时自动调用。(这仅在新型类上正确工作。)
  • __getattr__(self, name):当访问属性name且对象没有该属性时自动调用。
  • __setattr__(self, name, value):当试图将属性name绑定到value时自动调用。
  • __delattr__(self, name):试图删除属性name时自动调用。

尽管使用起来比property更棘手(在某些方面效率更低),但这些魔术方法非常强大,因为您可以用这些方法中的一种来编写代码,处理几个属性。(不过,如果你有选择的话,还是坚持用property。)

下面是第Rectangle个例子,这次用的是魔法方法:

class Rectangle:
    def __init__ (self):
        self.width = 0
        self.height = 0
    def __setattr__(self, name, value):
        if name == 'size':
            self.width, self.height = value
        else:
            self. __dict__[name] = value
    def __getattr__(self, name):
        if name == 'size':
            return self.width, self.height
        else:
            raise AttributeError()

如您所见,这个版本的类需要处理额外的管理细节。考虑这个代码示例时,请务必注意以下几点:

  • 即使所讨论的属性不是size,也会调用__setattr__方法。因此,该方法必须考虑两种情况:如果属性是size,则执行与之前相同的操作;否则使用魔法属性__dict__。它包含一个包含所有实例属性的字典。它被用来代替普通的属性赋值,以避免再次调用__setattr__(这会导致程序无休止地循环)。
  • 只有在没有找到正常属性的情况下,才会调用__getattr__方法,这意味着如果给定的名称不是size,则属性不存在,该方法会引发一个AttributeError。如果您想让类正确地使用内置函数,如hasattrgetattr,这一点很重要。如果名称是size,则使用之前实现中的表达式。

Note

就像有一个“死循环”陷阱与__setattr__相关一样,也有一个陷阱与__getattribute__相关。因为它拦截所有属性访问(在新型类中),所以它也会拦截对__dict__的访问!在__getattribute__中访问 self 属性的唯一安全的方法是使用超类的__getattribute__方法(使用super)。

迭代程序

我在前面的章节中已经简单地提到了迭代器(和可迭代对象)。在这一节中,我将详细介绍一些细节。我只介绍一个魔术方法,__iter__,它是迭代器协议的基础。

迭代器协议

迭代意味着将某件事重复几次——就像你对循环所做的那样。到目前为止,我只在for循环中迭代了序列和字典,但事实是你也可以迭代其他对象:实现了__iter__方法的对象。

__iter__方法返回一个迭代器,这个迭代器是任何带有一个叫做__next__的方法的对象,这个方法不需要任何参数就可以调用。当你调用__next__方法时,迭代器应该返回它的“下一个值”如果方法被调用并且迭代器没有更多的值要返回,它应该抛出一个StopIteration异常。有一个内置的方便函数叫做next你可以使用,其中next(it)相当于it.__next__()

Note

Python 3 中的迭代器协议有所改变。在旧协议中,迭代器对象应该有一个名为next而不是__next__的方法。

有什么意义?为什么不用列表呢?因为可能经常矫枉过正。如果你有一个函数可以一个接一个地计算值,你可能只需要一个接一个地计算它们——而不是一次全部,例如,在一个列表中。如果值的数量很大,列表可能会占用太多内存。但是还有其他原因:使用迭代器更通用、更简单、也更优雅。让我们看一个你不能用列表做的例子,仅仅因为列表需要无限长!

我们的“列表”是斐波那契数列。这些迭代器可能如下所示:

class Fibs:
    def __init__(self):
        self.a = 0
        self.b = 1
    def __next__(self):
        self.a, self.b = self.b, self.a + self.b
        return self.a
    def __iter__(self):
        return self

注意迭代器实现了__iter__方法,事实上,它将返回迭代器本身。在许多情况下,您会将__iter__方法放在另一个对象中,您会在for循环中使用该对象。然后返回你的迭代器。建议迭代器另外实现一个自己的__iter__方法(返回self,就像我在这里做的那样),这样它们自己可以直接在for循环中使用。

Note

更正式的说法是,实现__iter__方法的对象是 iterable,实现 next 的对象是迭代器。

首先做一个Fibs对象。

>>> fibs = Fibs()

然后,您可以在一个for循环中使用它——例如,查找大于 1000 的最小斐波那契数。

>>> for f in fibs:
...     if f > 1000:
...         print(f)
...         break
...
1597

这里,循环停止了,因为我在里面发出了一个break;如果我不这样做,for循环将永远不会结束。

Tip

内置函数iter可以用来从 iterable 对象中获取迭代器。

>>> it = iter([1, 2, 3])
>>> next(it)
1
>>> next(it)

2

它还可以用于从函数或其他可调用对象创建 iterable(有关详细信息,请参见库参考)。

从迭代器生成序列

除了对迭代器和可迭代对象进行迭代(这是您通常会做的),您还可以将它们转换成序列。在大多数可以使用序列的上下文中(除了索引或切片之类的操作),可以使用迭代器(或可迭代对象)来代替。一个有用的例子是使用list构造函数将迭代器显式转换为列表。

>>> class TestIterator:
...     value = 0
...     def __next__(self):
...         self.value += 1
...         if self.value > 10: raise StopIteration
...         return self.value
...     def __iter__(self):
...         return self
...
>>> ti = TestIterator()
>>> list(ti)
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

发电机

生成器(出于历史原因也称为简单生成器)对 Python 来说相对较新,并且(和迭代器一起)可能是多年来最强大的特性之一。然而,生成器的概念是相当先进的,它可能需要一段时间才能“点击”,你会看到它是如何工作的,或者它如何对你有用。请放心,虽然生成器可以帮助您编写真正优雅的代码,但是您当然可以在没有生成器的情况下编写任何您想要的程序。

生成器是一种用普通函数语法定义的迭代器。通过例子可以很好地说明发电机是如何工作的。让我们先来看看你是如何制作和使用它们的,然后在引擎盖下看一看。

制作发电机

制作发电机很简单;就像做一个函数一样。我相信你现在已经开始厌倦古老的斐波那契数列了,所以让我做点别的吧。我将创建一个函数来展平嵌套列表。该参数是一个列表,可能看起来像这样:

nested = [[1, 2], [3, 4], [5]]

换句话说,这是一个列表的列表。然后,我的函数应该按顺序给我这些数字。这里有一个解决方案:

def flatten(nested):
     for sublist in nested:
         for element in sublist:
             yield element

这个函数的大部分非常简单。首先,它遍历所提供的嵌套列表的所有子列表;然后,它按顺序遍历每个子列表的元素。例如,如果最后一行是print(element),这个函数就很容易理解了,对吗?

所以这里的新内容是yield语句。任何包含yield语句的函数都称为生成器。这不仅仅是命名的问题。它的行为将与普通函数截然不同。不同之处在于,你可以一次产生几个值,而不是像使用return那样返回一个值。每次产生一个值时(用yield,函数冻结;也就是说,它正好在那个点停止执行,等待被重新唤醒。如果是,它将从停止的地方继续执行。

我可以通过遍历生成器来利用所有的值。

>>> nested = [[1, 2], [3, 4], [5]]
>>> for num in flatten(nested):
...     print(num)
...
1
2
3
4
5

或者

>>> list(flatten(nested))
[1, 2, 3, 4, 5]

Loopy Generators

在 Python 2.4 中,引入了列表理解(参见第五章)的一个亲戚:生成器理解(或生成器表达式)。它与列表理解的工作方式相同,只是没有构造列表(并且“主体”没有立即循环)。相反,会返回一个生成器,允许您逐步执行计算。

>>> g = ((i + 2) ** 2 for i in range(2, 27))
>>> next(g)
16

正如你所看到的,这与列表理解在普通括号的使用上有所不同。在这种简单的情况下,我还不如使用列表理解。但是,如果您希望“包装”一个 iterable 对象(可能会产生大量的值),那么通过立即实例化一个列表,列表理解会使迭代的优点无效。

一个好处是,当在一对现有的括号内直接使用生成器理解力时,比如在函数调用中,不需要添加另一对括号。换句话说,您可以编写如下漂亮的代码:

sum(i ** 2 for i in range(10))

递归生成器

我在上一节中设计的生成器只能处理嵌套两层的列表,为此它使用了两个for循环。如果你有一组嵌套任意深的列表怎么办?例如,也许你用它们来表示一些树的结构。(您也可以用特定的树类来实现,但是策略是一样的。)每一级嵌套都需要一个for循环,但是因为不知道有多少级,所以必须改变解决方案,使之更加灵活。是时候转向递归的魔力了。

def flatten(nested):
    try:
        for sublist in nested:
            for element in flatten(sublist):
                yield element
    except TypeError:
        yield nested

当调用flatten时,您有两种可能性(处理递归时总是这样):基本情况和递归情况。在基本情况下,函数被告知展平单个元素(例如,一个数字),在这种情况下,for循环引发一个TypeError(因为您试图迭代一个数字),生成器简单地生成元素。

然而,如果你被告知要展平一个列表(或任何可迭代的列表),你需要做一些工作。您遍历所有的子列表(其中一些可能不是真正的列表),并对它们调用flatten。然后,通过使用另一个for循环来产生展平的子列表的所有元素。这可能看起来有点神奇,但它确实有效。

>>> list(flatten([[[1], 2], 3, 4, [5, [6, 7]], 8]))
[1, 2, 3, 4, 5, 6, 7, 8]

然而,这有一个问题。如果nested是一个字符串或类似字符串的对象,那么它就是一个序列,不会引发TypeError,但是你不想对它进行迭代。

Note

不应该在flatten函数中迭代类似字符串的对象有两个主要原因。首先,您希望将类似字符串的对象视为原子值,而不是应该展平的序列。第二,迭代它们实际上会导致无限递归,因为一个字符串的第一个元素是另一个长度为 1 的字符串,而该字符串的第一个元素是字符串本身!

为了解决这个问题,您必须在生成器的开头添加一个测试。尝试用一个字符串连接对象,并查看是否会产生一个TypeError是检查对象是否类似字符串的最简单、最快的方法。 1 这里是加了测试的发电机:

def flatten(nested):
    try:
        # Don't iterate over string-like objects:
        try: nested + ''
        except TypeError: pass
        else: raise TypeError
        for sublist in nested:
            for element in flatten(sublist):
                yield element
    except TypeError:
        yield nested

如你所见,如果表达式nested + ''引发了一个TypeError,它被忽略;但是,如果表达式没有引发一个TypeError,内部try语句的else子句会引发一个自己的TypeError。这将导致类似字符串的对象按原样产生(在外层的except子句中)。明白了吗?

这里有一个例子来说明这个版本也适用于字符串:

>>> list(flatten(['foo', ['bar', ['baz']]]))
['foo', 'bar', 'baz']

注意,这里没有进行类型检查。我不测试nested是否是一个字符串,只测试它的行为是否像一个字符串(也就是说,它可以与一个字符串连接)。这个测试的一个自然的替代方法是对字符串和类似字符串的对象使用带有一些抽象超类的isinstance,但是不幸的是没有这样的标准类。针对str的类型检查甚至对UserString也不起作用。

一般发电机

如果到目前为止您已经学习了这些示例,那么您或多或少会知道如何使用生成器。您已经看到生成器是一个包含关键字yield的函数。当它被调用时,函数体中的代码不会被执行。相反,返回一个迭代器。每次请求一个值时,生成器中的代码都会被执行,直到遇到一个yield或一个return。一个yield意味着一个值应该被输出。一个return意味着生成器应该停止执行(不再产生任何东西;return只有在生成器内部使用时才能不带参数调用)。

换句话说,生成器由两个独立的组件组成:生成器函数和生成器迭代器。生成器函数是由包含一个yielddef语句定义的。生成器迭代器是这个函数返回的内容。用不太精确的术语来说,这两个实体通常被视为一个整体,统称为发电机。

>>> def simple_generator():
        yield 1
...
>>> simple_generator
<function simple_generator at 153b44>
>>> simple_generator()
<generator object at 1510b0>

生成器函数返回的迭代器可以像其他迭代器一样使用。

生成器方法

我们可以在发电机开始运行后,通过使用发电机和“外部世界”之间的通信信道,为发电机提供值,通信信道有以下两个端点:

  • 外界可以访问生成器上一个名为send的方法,它的工作方式与next类似,只是它需要一个参数(要发送的“消息”——一个任意对象)。
  • 在挂起的生成器内部,yield现在可能被用作表达式,而不是语句。换句话说,当发电机恢复时,yield返回一个值——通过send从外部发送的值。如果使用了next,则yield返回None

注意,使用send(而不是next)只有在发电机暂停后才有意义(也就是说,在它碰到第一个yield后)。如果在此之前需要给生成器一些信息,可以简单地使用 generator-function 的参数。

Tip

如果你真的想在新启动的发电机上使用send,可以用None作为它的参数。

这里有一个相当愚蠢的例子来说明这个机制:

def repeater(value):
    while True:
        new = (yield value)
        if new is not None: value = new

下面是它的用法示例:

>>> r = repeater(42)
>>> next(r)
42
>>> r.send("Hello, world!")
"Hello, world!"

注意在yield表达式中使用了括号。虽然在某些情况下并不是绝对必要的,但是安全总比抱歉好,如果您以某种方式使用返回值,只需将yield表达式括在括号中。

生成器还有另外两种方法。

  • throw方法(用异常类型、可选值和回溯对象调用)用于在生成器内部引发异常(在yield表达式处)。
  • close方法(调用时不带参数)用于停止生成器。

close方法(需要时也由 Python 垃圾收集器调用)也是基于异常的。它在屈服点引发了GeneratorExit异常,所以如果你想在你的生成器中有一些清理代码,你可以在try / finally语句中包装你的yield。如果您愿意,您也可以捕获GeneratorExit异常,但是之后您必须重新引发它(可能在清理一点之后),引发另一个异常,或者简单地返回。在close被调用后试图从生成器产生一个值将导致RuntimeError

Tip

有关生成器方法以及它们如何将生成器转化为简单的协程的更多信息,请参见 PEP 342 ( www.python.org/dev/peps/pep-0342/ )。

模拟发电机

如果您需要使用旧版本的 Python,生成器是不可用的。下面是用普通函数模拟它们的简单方法。

从生成器的代码开始,在函数体的开头插入下面一行代码:

result = []

如果代码已经使用了名称result,你应该想出另一个。(无论如何,使用更具描述性的名称可能是个好主意。)然后替换此表格的所有行:

yield some_expression with this:
result.append(some_expression)

最后,在函数的末尾,添加这一行:

return result

尽管这可能不适用于所有的生成器,但它适用于大多数生成器。(比如它用无限生成器就失败了,当然不能把它们的值塞进一个列表。)

下面是重写为普通函数的flatten生成器:

def flatten(nested):
    result = []
    try:
        # Don't iterate over string-like objects:
        try: nested + ''
        except TypeError: pass
        else: raise TypeError
        for sublist in nested:
            for element in flatten(sublist):
                result.append(element)
    except TypeError:
        result.append(nested)
    return result

八个皇后

既然你已经了解了所有这些魔法,是时候让它发挥作用了。在本节中,您将看到如何使用生成器来解决一个经典的编程问题。

生成器和回溯

生成器是逐步构建结果的复杂递归算法的理想选择。如果没有生成器,这些算法通常需要您传递一个构建了一半的解决方案作为额外的参数,以便递归调用可以在其上构建。有了生成器,所有递归调用需要做的就是yield它们的部分。这就是我对前面的递归版本flatten所做的,您可以使用完全相同的策略来遍历图和树结构。

然而,在某些应用中,你不会马上得到答案;你需要尝试几种选择,你需要在递归的每一层都这样做。为了与现实生活进行类比,假设你要参加一个重要的会议。你不确定它在哪里,但是你面前有两扇门,会议室必须在其中一扇门的后面。你选择左边并穿过去。在那里,你面对另外两扇门。你选择了左边,但事实证明是错的。所以你原路返回,选择了正确的门,结果也是错的(请原谅这个双关语)。所以,你再次原路返回,到你开始的地方,准备在那里尝试正确的门。

Graphs and Trees

如果你以前从未听说过图和树,你应该尽快了解它们,因为它们是编程和计算机科学中非常重要的概念。要了解更多,你可能需要一本关于计算机科学、离散数学、数据结构或算法的书。对于一些简明的定义,您可以查看以下网页:

快速的网络搜索或者在维基百科( http://wikipedia.org )上浏览会找到很多材料。

这种回溯策略对于解决需要你尝试每种组合直到找到解决方案的问题非常有用。这样的问题是这样解决的:

# Pseudocode
for each possibility at level 1:
    for each possibility at level 2:
        ...
           for each possibility at level n:
               is it viable?

要用for循环直接实现这一点,你需要知道你会遇到多少层。如果这是不可能的,你使用递归。

问题

这是一个非常受欢迎的计算机科学难题:你有一个棋盘和八个皇后棋子放在上面。唯一的要求是没有一只蚁后威胁到其他蚁后;也就是说,您必须放置它们,以便没有两个皇后可以捕获对方。你是怎么做到的?皇后应该放在哪里?

这是一个典型的回溯问题:你为第一个皇后尝试一个位置(在第一排),前进到第二个,以此类推。如果你发现你不能放置一个皇后,你可以回到上一个位置,尝试另一个位置。最后,你要么穷尽所有可能性,要么找到解决办法。

在所述的问题中,提供给你的信息是只有八个皇后,但是让我们假设可以有任意数量的皇后。(这更类似于现实世界的回溯问题。)那你怎么解决呢?如果你想尝试自己解决,你现在应该停止阅读,因为我即将给你答案。

Note

你可以找到更有效的方法来解决这个问题。如果你想要更多的细节,网络搜索应该会找到大量的信息。

国家代表权

为了表示一个可能的解决方案(或者它的一部分),您可以简单地使用一个元组(或者一个列表)。元组的每个元素指示对应行的皇后的位置(即列)。所以如果state[0] == 3,你知道第一行的皇后位于第四列(我们从零开始计数,记得吗?).当在递归的一个级别(一个特定的行)工作时,您只知道上面的皇后有哪些位置,所以您可能有一个长度小于 8 的状态元组(或者不管皇后的数量是多少)。

Note

我完全可以使用列表而不是元组来表示状态。这种情况下主要是口味问题。一般来说,如果序列很小并且是静态的,那么元组可能是一个很好的选择。

发现冲突

让我们从做一些简单的抽象开始。要找到一个没有冲突的配置(没有皇后可以捕获另一个),你首先必须定义什么是冲突。为什么不把它定义为一个函数呢?

conflict函数被给定到目前为止皇后的位置(以状态元组的形式),并确定下一个皇后的位置是否产生任何新的冲突。

def conflict(state, nextX):
    nextY = len(state)
    for i in range(nextY):
        if abs(state[i] - nextX) in (0, nextY - i):
            return True
    return False

nextX参数是下一个皇后的建议水平位置(x 坐标,或列),而nextY是下一个皇后的垂直位置(y 坐标,或行)。这个函数对之前的每个皇后做一个简单的检查。如果下一个皇后和(nextX, nextY)有相同的 x 坐标或者在同一条对角线上,那么就发生了冲突,返回True。如果没有这样的冲突出现,False被返回。棘手的部分是下面的表达式:

abs(state[i] - nextX) in (0, nextY - i)

如果下一个皇后和前一个皇后之间的水平距离为零(同一列)或等于垂直距离(在对角线上),则为真。否则就是假的。

基本情况

八皇后问题实现起来可能有点棘手,但是对于生成器来说就没那么糟糕了。如果你不习惯递归,我不希望你自己想出这个解决方案。还要注意,这个解决方案并不是特别有效,所以对于大量的皇后,它可能会有点慢。

让我们从基础案例开始:末代女王。你想让她做什么?假设你想找到所有可能的解决方案。在这种情况下,你会期望她产生(生成)所有她能占据的位置(可能一个也没有)。你可以直接勾画出来。

def queens(num, state):
    if len(state) == num-1:
        for pos in range(num):
            if not conflict(state, pos):
                 yield pos

用人类的话来说,这意味着,“如果除了一个皇后之外所有的皇后都被放置好了,为最后一个皇后遍历所有可能的位置,并返回不会引起任何冲突的位置。”num参数是皇后总数,state参数是前几个皇后的位置元组。例如,假设你有四个皇后,前三个分别被赋予位置 1、3 和 0,如图 9-1 所示。(此时不要理会白皇后。)

A326949_3_En_9_Fig1_HTML.jpg

图 9-1。

Placing four queens on a 4 × 4 board

如图所示,每个皇后都有一个(水平)行,皇后的位置从顶部开始编号(从零开始,这在 Python 中很常见)。

>>> list(queens(4, (1, 3, 0)))
[2]

它非常有效。使用list只是强制生成器产生它的所有值。在这种情况下,只有一个职位合格。在图 9-1 中,白皇后被放在这个位置。(注意颜色没有特别的意义,也不是节目的一部分。)

递归情况

现在让我们转向解决方案的递归部分。当您覆盖了您的基本情况时,递归情况可以正确地假设(通过归纳)来自较低级别(具有较高数字的皇后)的所有结果都是正确的。所以你需要做的是在前面实现的queens函数的if语句中添加一个else子句。

你期望递归调用的结果是什么?你想要所有低级皇后的位置,对吗?假设它们以元组的形式返回。在这种情况下,您可能需要更改您的基本情况来返回一个元组(长度为 1),但我稍后会谈到这一点。

所以,你从“上面”得到一组位置,对于当前皇后的每个合法位置,你从“下面”得到一组位置你所要做的就是把你自己的位置放在前面,产生下面的结果:

...
else:
    for pos in range(num):
        if not conflict(state, pos):
           for result in queens(num, state + (pos,)):
               yield (pos,) + result

其中的for posif not conflict部分与之前的完全相同,因此您可以稍微重写一下以简化代码。让我们也添加一些默认参数。

def queens(num=8, state=()):
    for pos in range(num):
        if not conflict(state, pos):
           if len(state) == num-1:
              yield (pos,)
           else:
              for result in queens(num, state + (pos,)):
                  yield (pos,) + result

如果您觉得代码很难理解,您可能会发现用自己的话来表述它的作用会很有帮助。(你一定记得(pos,)中的逗号是使它成为一个元组所必需的,而不仅仅是一个带括号的值,对吗?)

queens生成器给出了所有的解决方案(即所有放置皇后的合法方式)。

>>> list(queens(3))
[]
>>> list(queens(4))
[(1, 3, 0, 2), (2, 0, 3, 1)]
>>> for solution in queens(8):
...   print solution
...
(0, 4, 7, 5, 2, 6, 1, 3)
(0, 5, 7, 2, 6, 3, 1, 4)
...
(7, 2, 0, 5, 1, 4, 6, 3)
(7, 3, 0, 2, 5, 1, 6, 4)
>>>

如果你用八个皇后运行queens,你会看到许多解决方案闪过。让我们看看有多少。

>>> len(list(queens(8)))
92

包装它

在离开 queens 之前,让我们让输出更容易理解一些。清晰的输出总是一件好事,因为它更容易发现错误。

def prettyprint(solution):
    def line(pos, length=len(solution)):
        return '. ' * (pos) + 'X ' + '. ' * (length-pos-1)
    for pos in solution:
        print(line(pos))

注意,我在prettyprint里面做了一个小小的助手函数。我把它放在那里,因为我想我在外面的任何地方都不需要它。在下面,我打印出一个随机的解决方案来满足自己,它是正确的。

>>> import random
>>> prettyprint(random.choice(list(queens(8))))
. . . . . X . .
. X . . . . . .
. . . . . . X .
X . . . . . . .
. . . X . . . .
. . . . . . . X
. . . . X . . .
. . X . . . . .

该“图纸”对应于图 9-2 中的图表。

A326949_3_En_9_Fig2_HTML.jpg

图 9-2。

One of many possible solutions to the Eight Queens problem

快速总结

你在这里看到了很多魔法。我们来盘点一下。

  • 新风格与旧风格的类:Python 中类的工作方式正在发生变化。最近的 Python 3.0 之前的版本有两种类型的类,旧式的很快就过时了。新样式的类是在 2.2 版本中引入的,它们提供了几个额外的特性(例如,它们可以使用superproperty,而旧样式的类则不能)。要创建一个新样式的类,您必须直接或间接地子类化object,或者设置__metaclass__属性。
  • 魔术方法:Python 中有几个特殊的方法(名称以双下划线开头和结尾)。这些方法在功能上有很大的不同,但是它们中的大多数都是由 Python 在特定环境下自动调用的。(例如,__init__是在对象创建后调用的。)
  • 构造函数:这些是许多面向对象语言中常见的,你可能会为你写的几乎每个类实现一个构造函数。构造函数被命名为init,并在对象创建后立即被自动调用。
  • 重写:一个类可以简单地通过实现方法来重写在其超类中定义的方法(或任何其他属性)。如果新方法需要调用被覆盖的版本,它可以直接从超类调用未绑定的版本(旧样式的类),或者使用super函数(新样式的类)。
  • 序列和映射:创建自己的序列或映射需要实现序列和映射协议的所有方法,包括像getitem__setitem__这样的神奇方法。通过子类化list(或者UserList)和dict(或者UserDict),可以省去很多工作。
  • 迭代器:迭代器只是一个拥有__next__方法的对象。迭代器可以用来迭代一组值。当没有更多的值时,next方法应该引发一个StopIteration异常。Iterable 对象有一个__iter__方法,它返回一个迭代器,可以在for循环中使用,就像序列一样。通常,迭代器也是可迭代的;也就是说,它有一个返回迭代器本身的__iter__方法。
  • 生成器:生成器函数(或方法)是包含关键字yield的函数(或方法)。当被调用时,generator-function 返回一个生成器,这是一种特殊类型的迭代器。您可以通过使用sendthrowclose方法从外部与一个活动的发电机交互。
  • 八皇后:八皇后问题在计算机科学中是众所周知的,很容易用生成器实现。目标是在棋盘上放置八个皇后,这样没有一个皇后可以攻击其他的皇后。

本章的新功能

| 功能 | 描述 | | --- | --- | | `iter(obj)` | 从可迭代对象中提取迭代器 | | `next(it)` | 推进一个迭代器并返回它的下一个元素 | | `property(fget, fset, fdel, doc)` | 返回一个属性;所有参数都是可选的 | | `super(class, obj)` | 返回`class`的超类的绑定实例 |

注意,itersuper可以用这里描述的参数之外的其他参数调用。有关更多信息,请参考标准 Python 文档。

什么现在?

现在您已经了解了 Python 语言的大部分内容。那么为什么还剩下那么多章节呢?嗯,还有很多要学,大部分是关于 Python 如何以各种方式连接到外部世界。然后我们有测试、扩展、打包和项目,所以我们还没有完成——远远没有。

Footnotes 1

感谢 Alex Martelli 指出这个习语以及在这里使用它的重要性。

十、自带电池

您现在已经了解了大部分基本的 Python 语言。虽然核心语言本身就很强大,但是 Python 为您提供了更多的工具。标准安装包括一组称为标准库的模块。你已经看到了其中的一些(比如mathcmath,但是还有更多。这一章向你展示了模块是如何工作的,以及如何探索它们,学习它们所提供的东西。然后这一章提供了标准库的概述,集中在几个精选的有用模块上。

模块

你已经知道如何制作自己的程序(或脚本)并执行它们。您还看到了如何使用import从外部模块获取函数到您的程序中。

>>> import math
>>> math.sin(0)
0.0

让我们来看看如何编写自己的模块。

模块是程序

任何 Python 程序都可以作为模块导入。假设您已经编写了清单 10-1 中的程序,并将其存储在一个名为hello.py的文件中。除了扩展名.py之外,文件的名称将成为模块的名称。

# hello.py
print("Hello, world!")
Listing 10-1.A Simple Module

你把它保存在哪里也很重要;在下一节中,您将了解到更多这方面的内容,但现在我们假设您将它保存在目录C:\python (Windows)或∼/python (UNIX/macOS)中。

然后,您可以通过执行以下命令(使用 Windows 目录)告诉您的解释器在哪里查找该模块:

>>> import sys
>>> sys.path.append('C:/python')

Tip

在 UNIX 中,不能简单地将字符串'∼/python'附加到sys.path上。你必须使用完整的路径(比如'/home/yourusername/python',或者,如果你想自动化它,使用sys.path.expanduser('∼/python')

这只是告诉解释器,除了正常查找的位置之外,还应该在目录C:\python中查找模块。完成这些之后,您就可以导入您的模块(它存储在文件C:\python\hello.py中,还记得吗?).

>>> import hello
Hello, world!

Note

当您导入一个模块时,您可能会注意到在您的源文件旁边出现了一个名为__ pycache__的新目录。(在旧版本中,你会看到带有后缀.pyc的文件。)这个目录包含的文件带有 Python 可以更高效处理的已处理文件。如果你以后导入同一个模块,Python 会导入这些文件,然后是你的.py文件,除非.py文件发生了变化;在这种情况下,会生成一个新的已处理文件。删除__pycache__目录没有坏处——会根据需要创建一个新目录。

如您所见,模块中的代码在您导入它时被执行。但是,如果您尝试再次导入它,什么也不会发生。

>>> import hello
>>>

为什么这次不管用?因为模块在导入时并不是真的要做什么(比如打印文本)。它们主要是用来定义事物,比如变量、函数、类等等。因为你只需要定义一次,所以多次导入一个模块和导入一次效果是一样的。

Why Only Once?

在大多数情况下,只导入一次的行为是一种实质性的优化,在一种特殊的情况下,它可能非常重要:如果两个模块相互导入。

在许多情况下,您可能会编写两个需要相互访问函数和类才能正常工作的模块。例如,您可能已经创建了两个模块——clientdbbilling——分别包含客户端数据库和计费系统的代码。您的客户端数据库可能包含对您的计费系统的调用(例如,每月自动向客户端发送账单),而计费系统可能需要从您的客户端数据库访问功能以正确计费。

如果每个模块都可以多次导入,那么这里就会出现问题。模块clientdb会导入billing,?? 又会导入clientdb,这……你明白了。你最终会得到一个导入的无限循环(无限递归,还记得吗?).但是,因为第二次导入模块时什么也没有发生,所以循环被中断了。

如果你坚持重载你的模块,你可以使用importlib模块中的reload函数。它采用单个参数(您想要重新加载的模块)并返回重新加载的模块。如果您对模块进行了更改,并希望这些更改在程序运行时反映在程序中,这可能会很有用。为了重新加载简单的hello模块(只包含一个print语句),我将使用以下代码:

>>> import importlib
>>> hello = importlib.reload(hello)
Hello, world!

在这里,我假设hello已经被导入(一次)。通过将reload的结果赋给hello,我已经用重新加载的版本替换了之前的版本。正如您可以从打印的问候中看到的,我确实在这里导入了模块。

如果你已经通过实例化模块bar中的类Foo创建了一个对象x,然后你重新加载bar,那么x引用的对象将不会以任何方式被重新创建。x仍将是旧版本Foo的实例(来自旧版本bar)。相反,如果您希望x基于重新加载模块中的新Foo,您将需要重新创建它。

模块是用来定义事物的

所以模块在第一次被导入程序时就被执行了。这似乎有点用,但不是很有用。使它们有价值的是它们(就像类一样)在之后保持它们的作用域。这意味着您定义的任何类或函数,以及您赋值的任何变量,都将成为模块的属性。这看似复杂,但实际上非常简单。

在模块中定义函数

假设您已经编写了一个类似清单 10-2 中的模块,并将它存储在一个名为hello2.py的文件中。还假设您已经将它放在 Python 解释器可以找到的地方,或者使用前一节中的sys.path技巧,或者使用后一节“使您的模块可用”中的更传统的方法

Tip

如果您使一个程序(它是用来执行的,而不是真正作为一个模块使用)以与其他模块相同的方式可用,那么您实际上可以使用 Python 解释器的-m开关来执行它。运行命令python -m progname args将运行带有命令行参数args的程序progname,前提是文件progname.py(注意后缀)与您的其他模块一起安装(也就是说,前提是您已经导入了progname)。

# hello2.py
def hello():
    print("Hello, world!")
Listing 10-2.A Simple Module Containing a Function

然后您可以像这样导入它:

>>> import hello2

然后执行模块,这意味着函数hello是在模块的作用域中定义的,所以可以像这样访问函数:

>>> hello2.hello()
Hello, world!

在模块的全局作用域中定义的任何名称都可以以同样的方式使用。你为什么想这么做?为什么不在主程序中定义所有的东西呢?

主要原因是代码重用。如果你把你的代码放在一个模块中,你可以在不止一个程序中使用它,这意味着如果你写了一个好的客户端数据库,并把它放在一个叫做clientdb的模块中,你可以在计费时,在发送垃圾邮件时(虽然我希望你不会),以及在任何需要访问你的客户端数据的程序中使用它。如果你没有把它放在一个单独的模块中,你将需要重写每一个程序的代码。所以,记住,要使你的代码可重用,就要模块化!(而且,没错,这肯定和抽象有关。)

在模块中添加测试代码

模块用于定义诸如函数和类之类的东西,但是每隔一段时间(实际上非常频繁),添加一些测试代码来检查事情是否如它们应该的那样工作是有用的。例如,如果你想确保hello函数正常工作,你可以将模块hello2重写为一个新的模块hello3,在清单 10-3 中定义。

# hello3.py
def hello():
    print("Hello, world!")

# A test:
hello()

Listing 10-3.A Simple Module with Some Problematic Test Code

这似乎是合理的——如果你把它作为一个普通的程序运行,你会发现它是有效的。然而,如果你把它作为一个模块导入,为了在另一个程序中使用hello函数,测试代码被执行,就像在本章的第一个hello模块中一样。

>>> import hello3
Hello, world!
>>> hello3.hello()
Hello, world!

这不是你想要的。避免这种行为的关键是检查模块是作为一个程序单独运行还是导入到另一个程序中。为此,您需要变量 __ name __。

>>> __name__
'__main__'
>>> hello3.__name__
'hello3'

可以看到,在“主程序”(包括解释器的交互提示)中,变量__name__的值为' __ main __ '。在导入的模块中,它被设置为该模块的名称。因此,通过加入一个if语句,你可以让你的模块的测试代码表现得更好,如清单 10-4 所示。

# hello4.py

def hello():
    print("Hello, world!")

def test():
    hello()

if __name__ == '__main__': test()

Listing 10-4.A Module with Conditional Test Code

如果将此作为程序运行,则执行hello函数;如果导入它,它的行为就像一个普通的模块。

>>> import hello4
>>> hello4.hello()
Hello, world!

如您所见,我已经将测试代码包装在一个名为test的函数中。我可以将代码直接放入if语句中;但是,通过将它放在一个单独的测试函数中,即使您已经将它导入到另一个程序中,您也可以测试该模块。

>>> hello4.test()
Hello, world!

Note

如果你写更彻底的测试代码,把它放在一个单独的程序中可能是个好主意。参见第十六章了解更多关于编写测试的信息。

使您的模块可用

在前面的例子中,我修改了sys.path,它包含一个目录列表(字符串形式),解释器应该在其中查找模块。但是,你一般不会想这么做。理想的情况是sys.path包含正确的目录(包含您的模块的目录)。有两种方法可以做到这一点:把你的模块放在正确的位置或者告诉解释器去哪里找。以下部分讨论了这两种解决方案。如果你想让你的模块容易被其他人使用,那就是另一回事了。Python 打包经历了一个日益复杂和多样化的阶段;现在,Python 打包权威机构正在对其进行控制和精简,但仍有许多内容需要消化。与其深入这个具有挑战性的主题,我建议您参考 Python 打包用户指南,可从packaging.python.org获得。

将您的模块放在正确的位置

将您的模块放在正确的位置——或者说,一个正确的位置——是非常容易的。只需要找出 Python 解释器在哪里寻找模块,然后把你的文件放在那里。如果您正在使用的机器上的 Python 解释器是由管理员安装的,并且您没有管理员权限,则您可能无法将您的模块保存在 Python 使用的任何目录中。然后,您将需要使用下一节中描述的替代解决方案:告诉解释器在哪里查找。

您可能还记得,目录列表(所谓的搜索路径)可以在sys模块的path变量中找到。

>>> import sys, pprint
>>> pprint.pprint(sys.path)
['C:\\Python35\\Lib\\idlelib',
 'C:\\Python35',
 'C:\\Python35\\DLLs',
 'C:\\Python35\\lib',
 'C:\\Python35\\lib\\plat-win',
 'C:\\Python35\\lib\\lib-tk',
 'C:\\Python35\\lib\\site-packages']

Tip

如果你的数据结构太大,无法在一行中显示,你可以使用pprint模块中的pprint函数,而不是普通的print语句。pprint是一个漂亮的打印功能,使打印输出更加智能。

当然,你可能得不到完全相同的结果。关键是,如果您希望您的解释器找到模块,这些字符串中的每一个都提供了放置模块的位置。尽管所有这些都可以工作,site-packages目录是最好的选择,因为它就是为这类事情准备的。浏览您的sys.path并找到您的site-packages目录,并将清单 10-4 中的模块保存在其中,但给它另起一个名字,比如another_hello.py。然后尝试以下方法:

>>> import another_hello
>>> another_hello.hello()
Hello, world!

只要你的模块位于site-packages这样的地方,你所有的程序都能够导入它。

告诉翻译往哪里看

出于多种原因,将模块放在正确的位置可能不是您的正确解决方案。

  • 您不希望 Python 解释器的目录与您自己的模块混杂在一起。
  • 您没有权限在 Python 解释器的目录中保存文件。
  • 你想把你的模块放在别的地方。

底线是,如果你把你的模块放在别的地方,你必须告诉解释器去哪里找。正如您之前看到的,一种方法是直接修改sys.path,但这并不是一种常见的方法。标准方法是将您的模块目录包含在环境变量PYTHONPATH中。

根据您使用的操作系统的不同,PYTHONPATH的内容会有所不同(参见侧栏“环境变量”),但基本上它就像sys.path——一个目录列表。

Environment Variables

环境变量不是 Python 解释器的一部分,而是操作系统的一部分。基本上,它们就像 Python 变量,但是它们是在 Python 解释器之外设置的。假设您使用的是bash shell,它可以在大多数类 UNIX 系统、macOS 和最新版本的 Windows 上使用。然后,您可以执行下面的语句将∼/python追加到您的PYTHONPATH环境变量中:

export PYTHONPATH=$PYTHONPATH:∼/python

如果您想让这个语句在您启动的所有 shells 中执行,那么您可以将它添加到主目录中的.bashrc文件中。有关以其他方式编辑环境变量的说明,您应该查阅系统文档。

作为使用 PYTHONPATH 环境变量的替代方法,您可能希望考虑所谓的路径配置文件。这些是扩展名为.pth的文件,位于某些特定的目录中,包含应该添加到sys.path中的目录名。有关详细信息,请参考site模块的标准库文档。

包装

要构建模块,您可以将它们分组到包中。包基本上就是另一种类型的模块。有趣的是,它们可以包含其他模块。模块存储在一个文件中(文件扩展名为.py),而包是一个目录。要让 Python 把它当作一个包,它必须包含一个名为__init__.py的文件。如果您像导入一个普通模块一样导入它,那么这个文件的内容将是包的内容。例如,如果您有一个名为constants的包,并且文件constants/__init__.py包含语句PI = 3.14,您将能够执行以下操作:

import constants
print(constants.PI)

要将模块放入包中,只需将模块文件放入包目录中。您也可以将包嵌套在其他包中。例如,如果你想要一个名为drawing的包,其中包含一个名为shapes的模块和一个名为colors的模块,你需要表 10-1 中显示的文件和目录(UNIX 路径名)。

表 10-1。

A Simple Package Layout

| 文件/目录 | 描述 | | --- | --- | | `∼/python/` | `PYTHONPATH`中的目录 | | `∼/python/drawing/` | 包目录(`drawing`包) | | `∼/python/drawing/__init__.py` | 包装代码(`drawing`模块) | | `∼/python/drawing/colors.py` | `colors`模块 | | `∼/python/drawing/shapes.py` | `shapes`模块 |

在这种设置下,以下语句都是合法的:

import drawing             # (1) Imports the drawing package
import drawing.colors      # (2) Imports the colors module
from drawing import shapes # (3) Imports the shapes module

在第一条语句之后,drawing中的__init__.py文件的内容将是可用的;然而,shapes号和colors号模块就不是了。在第二条语句之后,colors模块将是可用的,但是只能使用它的全名drawing.colors。在第三条语句之后,shapes模块就可用了,它的名字很短(也就是简单的shapes)。请注意,这些陈述只是示例。例如,不需要像我在这里所做的那样,在导入一个模块之前导入包本身。第二个声明很可能会自行执行,第三个也是如此。

探索模块

在我描述一些标准库模块之前,我将向您展示如何自己探索模块。这是一项很有价值的技能,因为在 Python 程序员的职业生涯中,您会遇到许多有用的模块,我不可能在这里一一介绍。当前的标准图书馆足够大,可以保证所有的书都是自己的(这样的书已经被写出来了)——而且它还在增长。每个版本都添加了新的模块,通常有些模块会有细微的变化和改进。此外,你肯定会在网上找到几个有用的模块,能够快速而容易地理解它们会使你的编程更加有趣。

模块里有什么?

探测模块最直接的方法是在 Python 解释器中研究它。当然,您需要做的第一件事是导入它。假设你听说了一个叫做copy的标准模块的传闻。

>>> import copy

没有引发异常——所以它存在。但是它有什么用呢?它包含什么?

使用目录

要找出一个模块包含什么,您可以使用dir函数,它列出一个对象的所有属性(以及模块的所有函数、类、变量等等)。如果你打印出dir(copy),你会得到一长串名字。(去吧,试试看。)这些名称中有几个以下划线开头——暗示(按照惯例)它们不应该在模块外使用。所以让我们用一点列表理解来过滤掉它们(如果你不记得这是如何工作的,查看第五章中关于列表理解的部分)。

>>> [n for n in dir(copy) if not n.startswith('_')]
['Error', 'PyStringMap', 'copy', 'deepcopy', 'dispatch_table', 'error', 'name', 't', 'weakref']

结果由来自dir(copy)的所有名字组成,这些名字的第一个字母没有下划线,应该比完整的列表更容易混淆。

all 变量

我在上一节中对小列表理解所做的是猜测我应该在copy模块中看到什么。但是,你可以直接从模块本身得到正确答案。在完整的dir(copy)列表中,你可能已经注意到了__all__这个名字。这是一个包含列表的变量,类似于我用 list comprehension 创建的列表,只是这个列表是在模块本身中设置的。让我们看看它包含了什么:

>>> copy.__all__
['Error', 'copy', 'deepcopy']

我的猜测没那么糟。我只得到几个不打算给我用的额外的名字。但是这个__all__列表是从哪里来的,为什么它真的在那里?第一个问题很容易回答。它是在copy模块中设置的,像这样(直接从copy.py复制过来的):

__all__ = ["Error", "copy", "deepcopy"]

那它为什么会在那里?它定义了模块的公共接口。更具体地说,它告诉解释器从这个模块导入所有名字意味着什么。所以如果你用这个:

from copy import *

您只能获得在__all__变量中列出的四个函数。例如,要导入PyStringMap,您需要显式地导入copy并使用copy.PyStringMap,或者使用from copy import PyStringMap

这样设置__all__在编写模块时也是一种有用的技术。因为您的模块中可能有许多其他程序可能不需要或不想要的变量、函数和类,所以出于礼貌将它们过滤掉。如果不设置__all__,带星号的导入中导出的名称默认为模块中不以下划线开头的所有全局名称。

获得帮助

到目前为止,您一直在利用自己的聪明才智和对各种 Python 函数和特殊属性的了解来探索copy模块。对于这种探索,交互式解释器是一个强大的工具,因为对语言的掌握是您能够深入探索一个模块的唯一限制。但是,有一个标准函数可以提供您通常需要的所有信息。那个函数叫做help。让我们在copy功能上试试:

>>> help(copy.copy)
Help on function copy in module copy:

copy(x)
    Shallow copy operation on arbitrary Python objects.

    See the module's __doc__ string for more info.

这告诉您,__copy__接受单个参数x,这是一个“浅层复制操作”但是它也提到了模块的__doc__字符串。那是什么?你可能还记得我在第六章中提到了文档字符串。docstring 只是一个写在函数开头的字符串,用来记录函数。然后,该字符串可以被函数属性__doc__引用。正如您从前面的帮助文本中所理解的,模块也可能有文档字符串(它们写在模块的开头),类也可能有文档字符串(它们写在类的开头)。

实际上,前面的帮助文本是从 copy 函数的 docstring 中提取的:

>>> print(copy.copy.__doc__)
Shallow copy operation on arbitrary Python objects.

    See the module's __doc__ string for more info.

与像这样直接检查 docstring 相比,使用help的优点是可以获得更多的信息,比如函数签名(也就是它所采用的参数)。尝试在模块本身上调用help,看看会得到什么。它打印出了很多信息,包括对copydeepcopy之间的差异的彻底讨论(本质上是deepcopy(x)将在x中找到的值复制为属性等等,而copy(x)只是复制x,将副本的属性绑定到与x相同的值)。

文件

当然,模块信息的一个自然来源是它的文档。我推迟了对文档的讨论,因为自己先检查一下模块通常会快得多。例如,你可能想知道,“再问一次range的参数是什么?”不用在 Python 书籍或标准 Python 文档中搜索关于range的描述,您可以直接查看。

>>> print(range.__doc__)
range(stop) -> range object
range(start, stop[, step]) -> range object

Return an object that produces a sequence of integers from start (inclusive)
to stop (exclusive) by step.  range(i, j) produces i, i+1, i+2, ..., j-1.
start defaults to 0, and stop is omitted!  range(4) produces 0, 1, 2, 3.
These are exactly the valid indices for a list of 4 elements.
When step is given, it specifies the increment (or decrement).

现在您已经对range函数有了一个精确的描述,并且因为您可能已经运行了 Python 解释器(在您编程时经常会对这样的函数感到疑惑),访问这些信息只需要几秒钟。

然而,并不是每个模块和每个函数都有一个好的 docstring(尽管它们应该有),有时您可能需要对事情如何工作进行更全面的描述。你从网上下载的大多数模块都有一些相关的文档。学习 Python 编程的一些最有用的文档是 Python 库参考,它描述了标准库中的所有模块。如果我想查找关于 Python 的一些事实,十有八九,我会在那里找到它。该库参考可在线浏览(在 https://docs.python.org/library )或下载,其他几个标准文档也是如此(如 Python 教程和 Python 语言参考)。所有的文档都可以从 Python 网站 https://docs.python.org 获得。

使用源

到目前为止,我讨论的探索技术对于大多数情况来说可能已经足够了。但是那些希望真正理解 Python 语言的人可能想知道关于一个模块的事情,如果不真正阅读源代码,这些事情是无法回答的。事实上,阅读源代码是学习 Python 的最好方法之一——除了自己编码。

做实际的阅读应该不是什么大问题,但是来源在哪里呢?假设您想阅读标准模块copy的源代码。你会在哪里找到它?一种解决方案是再次检查sys.path,并像解释器一样自己寻找它。一种更快的方法是检查模块的__file__属性。

>>> print(copy.__file__)
C:\Python35\lib\copy.py

在那里!您可以在代码编辑器(例如 IDLE)中打开copy.py文件,并开始检查它是如何工作的。如果文件名以.pyc结尾,就使用对应的以.py结尾的文件。

Caution

当在文本编辑器中打开一个标准库文件时,您会有意外修改它的风险。这样做可能会破坏该文件,所以当您关闭该文件时,请确保您没有保存任何可能已做的更改。

请注意,有些模块没有任何可供阅读的 Python 源代码。它们可能内置在解释器中(如sys模块),也可能用 C 编程语言编写。 1 (参见第十七章了解更多关于使用 c 扩展 Python 的信息)

标准图书馆:几个最爱

关于 Python 的短语“包括电池”最初是由 Frank Stajano 创造的,指的是 Python 丰富的标准库。当您安装 Python 时,您可以“免费”获得许多有用的模块(“电池”)因为有很多方法可以获得关于这些模块的更多信息(如本章第一部分所解释的),所以我在这里不包括完整的参考资料(这将占用太多的空间);相反,我将描述几个我最喜欢的标准模块,以激起您的探索欲望。你会在项目章节中遇到更多的标准模块(章节 20 到 29 )。模块描述并不完整,但强调了每个模块的一些有趣的功能。

[计]系统复制命令(system 的简写)

sys模块让您可以访问与 Python 解释器密切相关的变量和函数。其中一些如表 10-2 所示。

表 10-2。

Some Important Functions and Variables in the sys Module

| 函数/变量 | 描述 | | --- | --- | | `argv` | 命令行参数,包括脚本名称 | | `exit([arg])` | 退出当前程序,可以选择返回给定的返回值或错误信息 | | `modules` | 将模块名映射到加载的模块的字典 | | `path` | 可以找到模块的目录名列表 | | `platform` | 平台标识符,如`sunos5`或`win32` | | `stdin` | 标准输入流—类似文件的对象 | | `stdout` | 标准输出流—类似文件的对象 | | `stderr` | 标准错误流—类似文件的对象 |

变量sys.argv包含传递给 Python 解释器的参数,包括脚本名。

功能sys. exit退出当前程序。(如果在第八章中讨论的try / finally块内调用,finally子句仍然执行。)您可以提供一个整数来表示程序是否成功,这是一个 UNIX 约定。如果您依赖默认值(零,表示成功),那么在大多数情况下您可能不会有问题。或者,您可以提供一个字符串,它用作错误消息,对于试图找出程序停止原因的用户非常有用;然后,程序退出,并显示错误消息和指示失败的代码。

映射sys.modules将模块名映射到实际的模块。它仅适用于当前导入的模块。

模块变量sys.path在本章前面已经讨论过了。这是一个字符串列表,其中每个字符串都是一个目录名,当执行一个import语句时,解释器将在这个目录中寻找模块。

模块变量sys.platform(一个字符串)仅仅是解释器运行的“平台”的名称。这可能是一个表示操作系统的名称(例如sunos5win32,或者它可能表示某种其他类型的平台,例如 Java 虚拟机(例如java1.4.0),如果您运行的是 Jython 的话。

模块变量sys.stdinsys.stdoutsys.stderr是类似文件的流对象。它们代表了标准输入、标准输出和标准错误的标准 UNIX 概念。简单来说,sys.stdin是 Python 获取输入的地方(例如在input中使用的),而sys.stdout是它打印的地方。在第十一章你会学到更多关于文件(和这三个流)的知识。

例如,考虑以相反顺序使用打印参数的问题。当您从命令行调用 Python 脚本时,您可以在它后面添加一些参数,即所谓的命令行参数。这些将被放入列表sys.argv,Python 脚本的名称为sys.argv[0]。按照相反的顺序打印这些内容非常简单,如清单 10-5 所示。

# reverseargs.py
import sys
args = sys.argv[1:]
args.reverse()
print(' '.join(args))
Listing 10-5.Reversing and Printing Command-Line Arguments

如你所见,我复制了一份sys.argv。您可以修改原始参数,但是一般来说,不这样做更安全,因为程序的其他部分也可能依赖于包含原始参数的sys.argv。还注意到我跳过了sys.argv的第一个元素——剧本的名字。我用args.reverse()反转列表,但是我不能打印那个操作的结果。它是一个返回None的原地修改。另一种方法如下:

print(' '.join(reversed(sys.argv[1:])))

最后,为了让输出更漂亮,我使用了join字符串方法。我们来试试结果(假设其他某个 shell 的bash)。

$ python reverseargs.py this is a test
test a is this

os模块可以让你访问几个操作系统服务。os模块广泛;表 10-3 中只描述了几个最有用的函数和变量。除了这些,os及其子模块os.path包含了几个检查、构造和删除目录和文件的函数,以及操纵路径的函数(例如,os.path.splitos.path.join让你大部分时间忽略os.pathsep)。有关此功能的更多信息,请参见标准库文档。在那里你还可以找到对pathlib模块的描述,它提供了一个面向对象的路径操作接口。

表 10-3。

Some Important Functions and Variables in the os Module

| 函数/变量 | 描述 | | --- | --- | | `environ` | 使用环境变量映射 | | `system(command)` | 在子外壳中执行操作系统命令 | | `sep` | 路径中使用的分隔符 | | `pathsep` | 分隔路径的分隔符 | | `linesep` | 行分隔符(`'\n'`、`'\r'`或`'\r\n'`) | | `urandom(n)` | 返回`n`字节的强加密随机数据 |

映射os.environ包含本章前面描述的环境变量。例如,要访问环境变量PYTHONPATH,可以使用表达式os.environ['PYTHONPATH']。这种映射也可以用来改变环境变量,尽管不是所有的平台都支持。

功能os.system用于运行外部程序。还有其他用于执行外部程序的函数,包括execv,它退出 Python 解释器,将控制权交给执行的程序,以及popen,它创建一个类似文件的程序连接。

有关这些函数的更多信息,请参考标准库文档。

Tip

检查subprocess模块。它收集了os.systemexecvpopen函数的功能。

模块变量os.sep是路径名中使用的分隔符。UNIX(以及 Python 的 macOS 命令行版本)中的标准分隔符是/。Windows 中的标准是\\(单个反斜杠的 Python 语法),在旧的 macOS 中是:。(在某些平台上,os.altsep包含一个替代路径分隔符,比如 Windows 中的/。)

当对几个路径进行分组时,使用os.pathsep,就像在PYTHONPATH中一样。pathsep用于分隔路径名:UNIX/macOS 中的:和 Windows 中的;

模块变量os.linesep是文本文件中使用的行分隔符字符串。在 UNIX/OS X 中,这是一个单独的换行符(\n),而在 Windows 中,它是回车符和换行符的组合(\r\n)。

urandom函数使用一个依赖于系统的“真实的”(或者至少是加密的)随机性来源。如果你的平台不支持,你会得到一个NotImplementedError

例如,考虑启动 web 浏览器的问题。system命令可用于执行任何外部程序,这在 UNIX 等环境中非常有用,在这些环境中,您可以从命令行执行程序(或命令)来列出目录内容、发送电子邮件等等。但是它也可以用于启动带有图形用户界面的程序——比如网络浏览器。在 UNIX 中,您可以执行以下操作(假设您在/usr/bin/firefox有浏览器):

os.system('/usr/bin/firefox')

以下是 Windows 版本(再次使用您已安装的浏览器的路径):

os.system(r'C:\"Program Files (x86)"\"Mozilla Firefox"\firefox.exe')

注意,我很小心地将Program FilesMozilla Firefox用引号括起来;否则,底层 shell 会避开空白。(这对你的PYTHONPATH中的目录也很重要。)还要注意,这里必须使用反斜杠,因为 shell 会被正斜杠搞混。如果您运行这个程序,您会注意到浏览器试图打开一个名为Files"\Mozilla…的网站,这是命令中空格后面的部分。此外,如果您试图从空闲状态运行它,会出现一个 DOS 窗口,但浏览器不会启动,直到您关闭该 DOS 窗口。总而言之,这不完全是理想的行为。

另一个更适合这项工作的功能是 Windows 特有的功能os.startfile

os.startfile(r'C:\Program Files (x86)\Mozilla Firefox\firefox.exe')

正如您所看到的,os.startfile接受一个普通的路径,即使它包含空白(也就是说,不要像在os.system的例子中那样用引号将Program Files括起来)。

注意,在 Windows 中,你的 Python 程序在os.system(或os.startfile)启动了外部程序后,还在继续运行;在 UNIX 中,您的 Python 程序等待os.system命令完成。

A Better Solution: Webbrowser

os.system函数对很多事情都很有用,但是对于启动 web 浏览器的特定任务,有一个更好的解决方案:webbrowser模块。它包含一个名为open的功能,可以让你自动启动网络浏览器打开给定的网址。例如,如果您希望您的程序在 web 浏览器中打开 Python 网站(启动一个新的浏览器或使用一个已经运行的浏览器),您只需使用:

import webbrowser
webbrowser.open('http://www.python.org')

页面应该会弹出。

fileinput

在第十一章中你会学到很多关于读写文件的知识,但是这里有一个预览。fileinput模块使您能够轻松地遍历一系列文本文件中的所有行。如果像这样调用脚本(假设是 UNIX 命令行):

$ python some_script.py file1.txt file2.txt file3.txt

您将能够依次迭代file1.txtfile3.txt的行。还可以迭代提供给标准输入(sys.stdin)的行,还记得吗?),例如在 UNIX 管道中,使用标准的 UNIX 命令cat

$ cat file.txt | python some_script.py

如果您使用fileinput,在 UNIX 管道中用cat调用您的脚本就像将文件名作为命令行参数提供给脚本一样有效。表 10-4 中描述了fileinput模块最重要的功能。

表 10-4。

Some Important Functions in the fileinput Module

| 功能 | 描述 | | --- | --- | | `input([files[, inplace[, backup]])` | 促进多个输入流中的行的迭代 | | `filename()` | 返回当前文件的名称 | | `lineno()` | 返回当前(累计)行号 | | `filelineno()` | 返回当前文件中的行号 | | `isfirstline()` | 检查当前行是否是文件中的第一行 | | `isstdin()` | 检查最后一行是否来自`sys.stdin` | | `nextfile()` | 关闭当前文件并移动到下一个文件 | | `close()` | 关闭序列 |

fileinput.input是最重要的功能。它返回一个对象,您可以在一个for循环中迭代这个对象。如果您不想要默认的行为(其中fileinput找出要迭代的文件),您可以向该函数提供一个或多个文件名(作为一个序列)。您也可以将inplace参数设置为真值(inplace=True)来启用就地处理。对于您访问的每一行,您需要打印出一个替换行,它将被放回到当前的输入文件中。当您进行就地处理时,可选的backup参数为从原始文件创建的备份文件提供一个文件扩展名。

函数fileinput.filename返回当前所在文件的文件名(即包含当前正在处理的行的文件)。

函数fileinput.lineno返回当前行的编号。此计数是累积的,因此当您处理完一个文件并开始处理下一个文件时,行号不会被重置,而是从比前一个文件的最后一个行号多 1 的位置开始。

函数fileinput.filelineno返回当前文件中当前行的编号。每当您处理完一个文件并开始处理下一个文件时,文件行号将被重置并从 1 重新开始。

如果当前行是当前文件的第一行,函数fileinput.isfirstline返回真值;否则,它将返回一个假值。

如果当前文件为sys.stdin,函数fileinput.isstdin返回真值;否则,它返回 false。

函数fileinput.nextfile关闭当前文件并跳到下一个文件。跳过的行数不计入行数。如果您知道已经完成了当前文件,这可能会很有用,例如,如果每个文件都包含排序的单词,并且您正在查找特定的单词。如果您已经通过了单词在排序顺序中的位置,您可以安全地跳到下一个文件。

函数fileinput.close关闭整个文件链并完成迭代。

作为使用fileinput的一个例子,假设你写了一个 Python 脚本,你想给这些行编号。因为您希望程序在您完成这些后继续工作,所以您必须在每行的右边添加注释中的行号。要将它们对齐,可以使用字符串格式。让我们允许每个程序行最多 40 个字符,并在其后添加注释。清单 10-6 中的程序用fileinputinplace参数展示了一种简单的方法。

# numberlines.py

import fileinput

for line in fileinput.input(inplace=True):
    line = line.rstrip()
    num = fileinput.lineno()
    print('{:<50} # {:2d}'.format(line, num))

Listing 10-6.Adding Line Numbers to a Python Script

如果你自己运行这个程序,像这样:

$ python numberlines.py numberlines.py

您最终得到清单 10-7 中的程序。注意程序本身已经被修改,如果你像这样运行几次,你会在每一行有多个数字。回想一下,rstrip是一个字符串方法,它返回一个字符串的副本,其中右边的所有空格都被删除了(参见第三章中的“字符串方法”一节和附录 B 中的表 B-6)。

# numberlines.py                                   #  1
                                                   #  2
import fileinput                                   #  3
                                                   #  4
for line in fileinput.input(inplace=True):         #  5
    line = line.rstrip()                           #  6
    num = fileinput.lineno()                       #  7
    print('{:<50} # {:2d}'.format(line, num))      #  8
Listing 10-7.The Line Numbering Program with Line Numbers Added

Caution

小心使用inplace参数——这是破坏文件的一个简单方法。你应该仔细地测试你的程序,不要原地设置(这只是打印出结果),在你让它修改你的文件之前,确保程序工作。

关于使用fileinput的另一个例子,参见本章后面关于random模块的章节。

集合、堆和 Deques

有许多有用的数据结构,Python 支持一些更常见的数据结构。其中一些,如字典(或哈希表)和列表(或动态数组),是语言不可或缺的一部分。其他的,虽然有点外围,有时仍然可以派上用场。

设置

很久以前,集合是由sets模块中的Set类实现的。尽管您可能会在现有代码中遇到Set实例,但是您自己没有理由使用它们,除非您想向后兼容。在最近的版本中,集合由内置的set类实现。这意味着您不需要导入sets模块——您可以直接创建集合。

>>> set(range(10))
{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

集合由一个序列(或其他一些可迭代的对象)构成,或者用花括号明确指定。请注意,您不能用大括号指定空集,因为您最终会得到一个空字典。

>>> type({})
<class 'dict'>

相反,你需要不带参数地调用set。集合的主要用途是确定成员资格,因此重复项会被忽略:

>>> {0, 1, 2, 3, 0, 1, 2, 3, 4, 5}
{0, 1, 2, 3, 4, 5}

就像字典一样,集合元素的排序是非常随意的,不应该依赖它。

>>> {'fee', 'fie', 'foe'}
{'foe', 'fee', 'fie'}

除了检查成员资格之外,您还可以执行各种标准的集合运算(您可能从数学中已经知道了),例如并集和交集,方法是使用方法,或者使用与整数上的位运算相同的运算(请参见附录 B)。例如,您可以使用其中一个集合的union方法或按位 or 运算符|找到两个集合的并集。

>>> a = {1, 2, 3}
>>> b = {2, 3, 4}
>>> a.union(b)
{1, 2, 3, 4}
>>> a | b
{1, 2, 3, 4}

下面是一些其他的方法和它们对应的操作符;这些名称应该清楚地表明它们的含义:

>>> c = a & b
>>> c.issubset(a)
True
>>> c <= a
True
>>> c.issuperset(a)
False
>>> c >= a
False
>>> a.intersection(b)
{2, 3}
>>> a & b
{2, 3}
>>> a.difference(b)
{1}
>>> a - b
{1}
>>> a.symmetric_difference(b)
{1, 4}
>>> a ^ b
{1, 4}
>>> a.copy()
{1, 2, 3}
>>> a.copy() is a
False

还有各种到位操作,有相应的方法,还有基本方法addremove。有关详细信息,请参阅《Python 库参考》中关于集合类型的部分。

Tip

如果你需要一个函数来寻找,比如说,两个集合的并集,你可以简单地使用set类型的union方法的未绑定版本。这可能是有用的,例如,与reduce合作。

>>> my_sets = []
>>> for i in range(10):
...     my_sets.append(set(range(i, i+5)))
...
>>> reduce(set.union, my_sets)
{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}

集合是可变的,因此不能用作字典中的键。另一个问题是集合本身可能只包含不可变的(可散列的)值,因此可能不包含其他集合。因为集合的集合经常在实践中出现,这可能是一个问题。幸运的是,有frozenset类型,它代表不可变的(因此是可散列的)集合。

>>> a = set()
>>> b = set()
>>> a.add(b)
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
TypeError: set objects are unhashable
>>> a.add(frozenset(b))

frozenset构造函数创建给定集合的副本。每当您想要将一个集合用作另一个集合的成员或者作为字典的关键字时,它都是有用的。

另一个众所周知的数据结构是堆,一种优先级队列。优先级队列允许您以任意顺序添加对象,并在任何时候(可能在添加之间)找到(并可能删除)最小的元素。这比在列表中使用min要有效得多。

事实上,Python 中没有单独的堆类型——只有一个具有一些堆操作功能的模块。该模块名为heapq(q代表队列),包含六个函数(见表 10-5 ),其中前四个与堆操作直接相关。您必须使用列表作为堆对象本身。

表 10-5。

Some Important Functions in the fileinput Module

| 功能 | 描述 | | --- | --- | | `heappush(heap, x)` | 将`x`推到堆上 | | `heappop(heap)` | 弹出堆中最小的元素 | | `heapify(heap)` | 在任意列表上强制执行`heap`属性 | | `heapreplace(heap, x)` | 弹出最小的元素并推动 | | `x nlargest(n, iter)` | 返回`iter`的`n`个最大元素 | | `nsmallest(n, iter)` | 返回`iter`的`n`个最小元素 |

heappush函数用于向堆中添加一个项目。注意,您不应该在任何旧的列表中使用它——只能在通过使用各种堆函数构建的列表中使用。这样做的原因是元素的顺序很重要(即使它看起来有点杂乱无章;元素没有完全排序)。

>>> from heapq import *
>>> from random import shuffle
>>> data = list(range(10))
>>> shuffle(data)
>>> heap = []
>>> for n in data:
...     heappush(heap, n)
...
>>> heap
[0, 1, 3, 6, 2, 8, 4, 7, 9, 5]
>>> heappush(heap, 0.5)
>>> heap
[0, 0.5, 3, 6, 1, 8, 4, 7, 9, 5, 2]

元素的顺序并不像看起来那样随意。它们没有严格的排序顺序,但是有一个保证:位置i的元素总是大于位置i // 2的元素(或者相反,它小于位置2 * i2 * i + 1的元素)。这是底层堆算法的基础。这称为堆属性。

heappop函数弹出最小的元素,该元素总是在索引 0 处找到,并确保剩余元素中最小的元素接管这个位置(同时保留堆属性)。尽管弹出列表的第一个元素通常效率不是很高,但这在这里不是问题,因为heappop在幕后做了一些漂亮的洗牌。

>>> heappop(heap)
0
>>> heappop(heap)
0.5
>>> heappop(heap)
1
>>> heap
[2, 5, 3, 6, 9, 8, 4, 7]

heapify函数接受一个任意的列表,并通过最少的洗牌使其成为合法的堆(也就是说,它施加了堆属性)。如果你没有用heappush从头开始构建你的堆,这是在开始使用heappushheappop之前要使用的函数。

>>> heap = [5, 8, 0, 3, 6, 7, 9, 1, 4, 2]
>>> heapify(heap)
>>> heap
[0, 1, 5, 3, 2, 7, 9, 8, 4, 6]

heapreplace函数不像其他函数那样常用。它从堆中取出最小的元素,然后将一个新元素放入其中。这比一个heappop后面跟着一个heappush要高效一点。

>>> heapreplace(heap, 0.5)
0
>>> heap
[0.5, 1, 5, 3, 2, 7, 9, 8, 4, 6]
>>> heapreplace(heap, 10)
0.5
>>> heap
[1, 2, 5, 3, 6, 7, 9, 8, 4, 10]

heapq模块剩下的两个函数,nlargest(n, iter)nsmallest(n, iter),分别用于查找任何可迭代对象itern最大或最小元素。您可以通过使用排序(例如,使用sorted函数)和切片来做到这一点,但是堆算法更快,更节省内存(更不用说,更容易使用)。

德克(和其他收藏品)

当您需要按照添加元素的顺序删除元素时,双端队列会很有用。在collections模块中可以找到 deque 类型以及其他几种集合类型。

deque 是从一个 iterable 对象(就像集合一样)创建的,有几个有用的方法。

>>> from collections import deque
>>> q = deque(range(5))
>>> q.append(5)
>>> q.appendleft(6)
>>> q
deque([6, 0, 1, 2, 3, 4, 5])
>>> q.pop()
5
>>> q.popleft()
6
>>> q.rotate(3)
>>> q
deque([2, 3, 4, 0, 1])
>>> q.rotate(-1)
>>> q
deque([3, 4, 0, 1, 2])

deque 很有用,因为它允许在开始时(向左)有效地追加和弹出,这是列表所不能做到的。作为一个不错的副作用,您还可以有效地旋转元素(也就是说,将它们向左或向右移动,环绕两端)。Deque 对象也有extendextendleft方法,extend的工作方式类似于相应的 list 方法,extendleft的工作方式类似于appendleft。注意在extendleft中使用的 iterable 对象中的元素将以相反的顺序出现在队列中。

时间

time模块包含获取当前时间、操作时间和日期、从字符串中读取日期以及将日期格式化为字符串等功能。日期可以表示为一个实数(从“纪元”中的 1 月 1 日 0 点开始的秒数,这是一个依赖于平台的年份;对于 UNIX,它是 1970)或包含九个整数的元组。这些整数在表 10-6 中解释。例如,元组

表 10-6。

The Fields of Python Date Tuples

| 索引 | 田 | 价值 | | --- | --- | --- | | Zero | 年 | 例如,2000 年、2001 年等等 | | one | 月 | 在 1-12 的范围内 | | Two | 一天 | 在 1–31 的范围内 | | three | 小时 | 在 0–23 的范围内 | | four | 分钟 | 在 0–59 的范围内 | | five | 第二 | 在 0–61 的范围内 | | six | 工作日 | 在 0 到 6 的范围内,其中星期一为 0 | | seven | 儒略日 | 在 1–366 的范围内 | | eight | 日光节约时间 | 0、1 或–1 |
(2008, 1, 21, 12, 2, 56, 0, 21, 0)

表示 2008 年 1 月 21 日 12:02:56,这是一个星期一,也是一年中的第 21 天(没有夏令时)。

秒的范围是 0–61,表示闰秒和双闰秒。夏令时数字是一个布尔值(真或假),但如果使用–1,mktime(一个将这样的元组转换为自纪元以来以秒为单位测量的时间戳的函数)可能会得到正确的结果。表 10-7 中描述了time模块中一些最重要的功能。

表 10-7。

Some Important Functions in the time Module

| 功能 | 描述 | | --- | --- | | `asctime([tuple])` | 将时间元组转换为字符串 | | `localtime([secs])` | 将秒转换为本地时间的日期元组 | | `mktime(tuple)` | 将时间元组调整为本地时间 | | `sleep(secs)` | 休眠`secs`秒(不做任何事情) | | `strptime(string[, format])` | 将字符串解析为时间元组 | | `time()` | 当前时间(自纪元以来的秒数,UTC) |

函数time.asctime将当前时间格式化为字符串,如下所示:

>>> time.asctime()
'Mon Jul 18 14:06:07 2016'

如果不想要当前时间,也可以提供一个日期元组(比如那些由localtime创建的元组)。(对于更详细的格式化,您可以使用标准文档中描述的strftime函数。)

函数time.localtime将一个实数(从 epoch 开始的秒数)转换为一个日期元组,即本地时间。如果你想要世界时,用gmtime代替。

函数time.mktime将日期元组转换为从 epoch 开始的时间(以秒为单位);是localtime的逆。

函数time.sleep使解释器等待给定的秒数。

函数time.strptime将由asctime返回的格式的字符串转换为日期元组。(可选格式参数遵循与strftime相同的规则;请参见标准文档。)

函数time.time返回当前(世界)时间,以秒为单位。尽管历元可能因平台而异,但是您可以通过保存事件(比如函数调用)前后的time结果,然后计算差值,来可靠地确定某件事情的时间。关于这些函数的例子,请参见下一节,这一节将介绍random模块。

表 10-7 中显示的功能只是从time模块中选择的功能。本模块中的大多数功能执行的任务与本节中描述的任务相似或相关。如果您需要这里描述的函数没有涵盖的内容,可以看看 Python 库参考中关于time模块的部分;很有可能你会找到你想要的东西。

此外,还有两个与时间相关的模块可用:datetime(支持日期和时间算法)和timeit(帮助您对代码片段计时)。你可以在 Python 库参考中找到更多关于这两者的信息,并且timeit也在第十六章中有简要讨论。

随意

random模块包含返回伪随机数的函数,这对模拟或任何生成随机输出的程序都很有用。请注意,尽管这些数字看起来完全是随机的,但它们背后有一个可预测的系统。如果你需要真正的随机性(例如,对于密码术或任何与安全相关的东西),你应该检查一下os模块的urandom函数。在random模块中的SystemRandom类基于同样的功能,给你接近真实随机性的数据。

该模块中的一些重要功能如表 10-8 所示。

表 10-8。

Some Important Functions in the random Module

| 功能 | 描述 | | --- | --- | | `random()` | 返回一个随机实数 n,使得 0 ≤ n ≤ 1 | | `getrandbits(n)` | 以长整数的形式返回 n 个随机位 | | `uniform(a, b)` | 返回一个随机实数 n,使得`a` ≤ n ≤ `b` | | `randrange([start], stop, [step])` | 从`range(start, stop, step)`返回一个随机数 | | `choice(seq)` | 从序列`seq`中返回一个随机元素 | | `shuffle(seq[, random])` | 将序列`seq`打乱到位 | | `sample(seq, n)` | 从序列`seq`中选择`n`个随机的、唯一的元素 |

函数random.random是最基本的随机函数之一;它只是返回一个伪随机数 n,使得 0 ≤ n ≤ 1。除非这正是您所需要的,否则您可能应该使用提供额外功能的其他函数之一。函数random.getrandbits以整数的形式返回给定数量的位(二进制数字)。

当提供两个数值参数ab时,函数random.uniform返回一个随机的(均匀分布的)实数 n,使得a n ≤ b。例如,如果你想要一个随机的角度,你可以使用uniform(0, 360)

函数random.randrange是标准函数,用于生成一个范围内的随机整数,该范围是通过使用相同的参数调用range得到的。例如,要获得 1 到 10(包括 1 和 10)范围内的随机数,您可以使用randrange(1, 11)(或者,randrange(10) + 1),如果您想要一个小于 20 的随机奇数正整数,您可以使用randrange(1, 20, 2)

函数random.choice从给定的序列中(一致地)选择一个随机元素。

函数random.shuffle随机打乱一个(可变的)序列的元素,这样每一个可能的排序都是一样的。

函数random.sample从给定的序列中(一致地)选择给定数量的元素,确保它们都是不同的。

Note

对于统计倾向,有类似于uniform的其他函数返回根据各种其他分布采样的随机数,如贝塔变量、指数、高斯和其他几种分布。

让我们看一些使用random模块的例子。在这些例子中,我使用了前面描述的time模块中的几个函数。首先,让我们得到代表时间间隔极限的实数(2016 年)。您可以通过将日期表示为时间元组(使用-1表示一周中的某一天、一年中的某一天和夏令时,让 Python 自己计算)并对这些元组调用mktime来实现这一点:

from random import *
from time import *
date1 = (2016, 1, 1, 0, 0, 0, -1, -1, -1)
time1 = mktime(date1)
date2 = (2017, 1, 1, 0, 0, 0, -1, -1, -1)
time2 = mktime(date2)

然后你在这个范围内统一生成一个随机数(不包括上限):

>>> random_time = uniform(time1, time2)

然后你只需将这个数字转换回一个清晰的日期。

>>> print(asctime(localtime(random_time)))
Tue Aug 16 10:11:04 2016

对于下一个例子,让我们问用户要扔多少个骰子,每个骰子应该有多少面。抛模机构由randrangefor回路实现。

from random import randrange
num   = int(input('How many dice? '))
sides = int(input('How many sides per die? '))
sum = 0
for i in range(num): sum += randrange(sides) + 1
print('The result is', sum)

如果您将它放在脚本文件中并运行它,您会得到如下所示的交互:

How many dice? 3
How many sides per die? 6
The result is 10

现在假设你做了一个文本文件,其中每行文本包含一笔财富。然后,您可以使用前面描述的fileinput模块将运气放入一个列表中,然后随机选择一个。

# fortune.py
import fileinput, random
fortunes = list(fileinput.input())
print random.choice(fortunes)

在 UNIX 或 macOS 中,您可以在标准字典文件/usr/share/dict/words上测试这个,以获得一个随机单词。

$ python fortune.py /usr/share/dict/words
dodge

作为最后一个例子,假设您希望您的程序在每次按键盘上的 Enter 键时向您发牌,一次一张。此外,你要确保你不会得到同一张卡不止一次。首先,你做一副“卡片”——一串字符串。

>>> values = list(range(1, 11)) + 'Jack Queen King'.split()
>>> suits = 'diamonds clubs hearts spades'.split()
>>> deck = ['{} of {}'.format(v, s) for v in values for s in suits]

我们刚刚创建的这副牌不太适合玩纸牌游戏。让我们先看一些卡片:

>>> from pprint import pprint
>>> pprint(deck[:12])
['1 of diamonds',
 '1 of clubs',
 '1 of hearts',
 '1 of spades',
 '2 of diamonds',
 '2 of clubs',
 '2 of hearts',
 '2 of spades',
 '3 of diamonds',
 '3 of clubs',
 '3 of hearts',
 '3 of spades']

有点太有秩序了,不是吗?这很容易解决。

>>> from random import shuffle
>>> shuffle(deck)
>>> pprint(deck[:12])
['3 of spades',
 '2 of diamonds',
 '5 of diamonds',
 '6 of spades',
 '8 of diamonds',
 '1 of clubs',
 '5 of hearts',
 'Queen of diamonds',
 'Queen of hearts',
 'King of hearts',
 'Jack of diamonds',
 'Queen of clubs']

注意,为了节省空间,我只在这里打印了前 12 张卡片。你可以自己随意看一下整副牌。

最后,为了让 Python 在每次按下键盘上的 Enter 键时给你发一张牌,直到没有更多的牌,你只需创建一个小的while循环。假设您将创建卡片组所需的代码放入一个程序文件中,您只需在末尾添加以下内容:

while deck: input(deck.pop())

注意,如果您在交互式解释器中尝试这个while循环,您将在每次按 Enter 时得到一个空字符串。这是因为input返回你写的东西(其实什么都不是)并且会被打印出来。在一个正常的程序中,这个来自input的返回值被简单地忽略。要交互地“忽略”它,只需将input的结果赋给某个你不会再看到的变量,并将其命名为类似于ignore的东西。

搁置和 json

在下一章中,您将学习如何在文件中存储数据,但是如果您想要一个真正简单的存储解决方案,shelve模块可以为您完成大部分工作。你只需要给它提供一个文件名。在shelve中唯一感兴趣的功能是open。当被调用时(用一个文件名),它返回一个Shelf对象,你可以用它来存储东西。只要把它当作普通的字典(除了键必须是字符串),当你完成时(并希望东西保存到磁盘),调用它的close方法。

潜在的陷阱

重要的是要认识到由shelve.open返回的对象不是一个普通的映射,如下例所示:

>>> import shelve
>>> s = shelve.open('test.dat')
>>> s['x'] = ['a', 'b', 'c']
>>> s['x'].append('d')
>>> s['x']
['a', 'b', 'c']

'd'去哪里了?

解释很简单:当你在一个shelf对象中查找一个元素时,这个对象是从它的存储版本中重建的;当你给一个键分配一个元素时,它被存储。上例中发生的情况如下:

  • 列表['a', 'b', 'c']存储在s中的关键字'x'下。
  • 检索存储的表示,从中构造一个新的列表,并将'd'附加到副本中。此修改版本未被存储!
  • 最后,再次检索原始文件—没有'd'

要正确修改使用shelve模块存储的对象,您必须将一个临时变量绑定到检索到的副本,然后在副本被修改后再次存储该副本 2 :

>>> temp = s['x']
>>> temp.append('d')
>>> s['x'] = temp
>>> s['x']
['a', 'b', 'c', 'd']

还有一种方法可以解决这个问题:将open函数的writeback参数设置为true。如果这样做,您从工具架读取或分配给工具架的所有数据结构都将保留在内存中(缓存),只有在您关闭工具架时才会写回磁盘。如果您没有处理大量数据,并且不想担心这些事情,将writeback设置为 true 可能是个好主意。完成后,你必须确保关上架子;一种方法是将工具架用作上下文管理器,就像打开文件一样,这将在下一章中解释。

一个简单的数据库示例

清单 10-8 展示了一个使用shelve模块的简单数据库应用。

# database.py
import sys, shelve

def store_person(db):
    """
    Query user for data and store it in the shelf object
    """
    pid = input('Enter unique ID number: ')
    person = {}
    person['name'] = input('Enter name: ')
    person['age'] = input('Enter age: ')
    person['phone'] = input('Enter phone number: ')
    db[pid] = person

def lookup_person(db):
    """
    Query user for ID and desired field, and fetch the corresponding data from     the shelf object
    """
    pid = input('Enter ID number: ')
    field = input('What would you like to know? (name, age, phone) ')
    field = field.strip().lower()

    print(field.capitalize() + ':', db[pid][field])

def print_help():
    print('The available commands are:')
    print('store  : Stores information about a person')
    print('lookup : Looks up a person from ID number')
    print('quit   : Save changes and exit')
    print('?      : Prints this message')

def enter_command():
    cmd = input('Enter command (? for help): ')
    cmd = cmd.strip().lower()
    return cmd

def main():
    database = shelve.open('C:\\database.dat') # You may want to change this name
    try:
        while True:
            cmd = enter_command()
            if  cmd == 'store':
                store_person(database)
            elif cmd == 'lookup':
                lookup_person(database)
            elif cmd == '?':
                print_help()
            elif cmd == 'quit':
                return
    finally:
        database.close()

if name == '__main__': main()

Listing 10-8.A Simple Database Application

清单 10-8 中显示的程序有几个有趣的特性:

  • 一切都包装在函数中,使程序更加结构化。(一个可能的改进是将这些函数组合成一个类的方法。)
  • 主程序在main函数中,只有在__name__ == '__main__'时才会被调用。这意味着你可以将它作为一个模块导入,然后从另一个程序中调用main函数。
  • 我在main函数中打开一个数据库(shelf ),然后将它作为参数传递给需要它的其他函数。我也可以使用全局变量,因为这个程序很小,但是在大多数情况下最好避免使用全局变量,除非你有理由使用它们。
  • 在读入一些值后,我通过对它们调用striplower来修改版本,因为如果提供的键要与数据库中存储的键匹配,这两个键必须完全相同。如果你总是在用户输入的内容上使用striplower,你可以允许他们随意使用大写或小写字母以及额外的空格。另外,请注意,我在打印字段名称时使用了capitalize
  • 我已经使用了tryfinally来确保数据库正确关闭。您永远不知道什么时候可能会出错(并且您会得到一个异常),如果程序在没有正确关闭数据库的情况下终止,您可能会得到一个损坏的数据库文件,该文件实际上是无用的。通过使用tryfinally,你可以避免这种情况。我也可以使用书架作为上下文管理器,正如在第十一章中解释的那样。

所以,让我们把这个数据库拿出来兜一圈。下面是一个交互示例:

Enter command (? for help): ?
The available commands are:
store  : Stores information about a person
lookup : Looks up a person from ID number
quit   : Save changes and exit
?      : Prints this message
Enter command (? for help): store
Enter unique ID number: 001
Enter name: Mr. Gumby
Enter age: 42
Enter phone number: 555-1234
Enter command (? for help): lookup
Enter ID number: 001
What would you like to know? (name, age, phone) phone
Phone: 555-1234
Enter command (? for help): quit

这种互动并不十分有趣。我可以用一个普通的字典而不是shelf对象做完全相同的事情。但是现在我已经退出了这个程序,让我们看看当我重新启动它时会发生什么——也许是第二天?

Enter command (? for help): lookup
Enter ID number: 001
What would you like to know? (name, age, phone) name
Name: Mr. Gumby
Enter command (? for help): quit

如你所见,程序读入了我第一次创建的文件,Gumby 先生还在!

请随意试验这个程序,看看是否可以扩展它的功能并提高它的用户友好性。也许你能想出一个对你自己有用的版本?

Tip

如果您希望以一种其他语言编写的程序可以轻松阅读的形式保存数据,您可能希望研究 JSON 格式。Python 标准库提供了json模块来处理 JSON 字符串,在它们和 Python 值之间进行转换。

有些人在遇到问题时会想:“我知道,我会使用正则表达式。”现在他们有两个问题。——杰米·扎温斯基

re模块包含对正则表达式的支持。如果你听说过正则表达式,你可能知道它们有多强大;如果你没有,准备好大吃一惊吧。

但是,您应该注意,一开始掌握正则表达式可能有点棘手。关键是一次了解一点点——只需查找特定任务所需的部件。事先把它都记住是没有意义的。本节描述了re模块和正则表达式的主要特性,使您能够开始使用。

Tip

除了标准文档,Andrew Kuchling 的“正则表达式如何”( https://docs.python.org/3/howto/regex.html )是关于 Python 中正则表达式的有用信息来源。

什么是正则表达式?

正则表达式(也称为 regex 或 regexp)是可以匹配一段文本的模式。正则表达式最简单的形式就是一个普通的字符串,它匹配自身。换句话说,正则表达式'python'匹配字符串'python'。您可以将这种匹配行为用于诸如搜索文本中的模式、用一些计算值替换某些模式或者将文本拆分成片段之类的事情。

通配符

正则表达式可以匹配多个字符串,您可以通过使用一些特殊字符来创建这样的模式。例如,句点字符(点)匹配任何字符(换行符除外),因此正则表达式'.ython'将匹配字符串'python'和字符串'jython'。它还会匹配诸如'qython''+ython'' ython'之类的字符串(其中第一个字母是单个空格),但不会匹配诸如'cpython''ython'之类的字符串,因为句点匹配单个字母,既不是两个也不是零。

因为它匹配“任何内容”(除换行符之外的任何单个字符),所以句点被称为通配符。

转义特殊字符

普通角色匹配自己,不匹配其他。然而,特殊字符是一个不同的故事。例如,假设您想要匹配字符串'python.org'。是不是简单的用模式'python.org'?你可以,但那也会匹配'pythonzorg',例如,你可能不想要的。(点匹配除换行符以外的任何字符,记得吗?)为了让一个特殊字符表现得像正常字符一样,你要对它进行转义,就像我在第一章中演示的如何对字符串中的引号进行转义一样。你在它前面放一个反斜杠。因此,在这个例子中,您将使用'python\\.org',它将匹配'python.org'而不是其他。

注意,要得到一个反斜杠,这是re模块所需要的,你需要在字符串中写两个反斜杠——以避开解释器。因此,这里有两个级别的转义:(1)从解释器和(2)从re模块。(实际上,在某些情况下,您可以使用单个反斜杠并让解释器自动为您转义,但不要依赖它。)如果你厌倦了对折反斜杠,就用一个原始字符串,比如r'python\.org'

字符集

匹配任何字符都是有用的,但有时您需要更多的控制。您可以通过将子字符串括在括号中来创建所谓的字符集。这样的字符集将匹配它包含的任何字符。例如,'[pj]ython'将同时匹配'python''jython',但不匹配其他任何内容。还可以使用范围,比如'[a-z]'匹配从 a 到 z(按字母顺序)的任何字符,并且可以通过一个接一个地放置来组合这样的范围,比如'[a-zA-Z0-9]'匹配大小写字母和数字。(请注意,字符集将只匹配一个这样的字符。)

要反转字符集,首先放置字符^,如在'[^abc]'中匹配除 a、b 或 c 之外的任何字符。

Special Characters in Character Sets

通常,如果您希望特殊字符(如点、星号和问号)在模式中显示为文字字符,而不是作为正则表达式操作符,则必须用反斜杠对它们进行转义。在字符集内部,转义这些字符通常是不必要的(尽管完全合法)。但是,您应该记住以下规则:

  • 如果脱字符(^)出现在字符集的开头,您确实需要对它进行转义,除非您希望它充当求反运算符。(换句话说,除非是真心的,否则不要放在开头。)
  • 同样,右括号(])和破折号(-)必须放在字符集的开头,或者用反斜杠转义。(其实破折号也可能放在最后,如果你愿意的话。)
替代和子模式

当您让每个字母独立变化时,字符集很好,但是如果您只想匹配字符串'python''perl'呢?您不能用字符集或通配符来指定这样一个特定的模式。取而代之的是,您可以使用特殊的替代字符:竖线字符(|)。所以,你的模式应该是'python|perl'

然而,有时您不想在整个模式上使用选择操作符——只是它的一部分。要做到这一点,您需要用括号将部件或子模式括起来。前面的例子可以重写为'p(ython|erl)'。(注意术语子模式也可以应用于单个字符。)

可选和重复的子模式

通过在子模式后添加一个问号,可以使它成为可选的。它可能出现在匹配的字符串中,但不是严格要求的。例如,这个(有点不可读)模式:

r'(http://)?(www\.)?python\.org'

将匹配以下所有字符串(除此之外):

'http://www.python.org'
'http://python.org'
'www.python.org'
'python.org'

这些东西在这里一文不值:

  • 我对点进行了转义,以防止它们充当通配符。
  • 我使用了一个原始字符串来减少反斜杠的数量。
  • 每个可选子模式都用括号括起来。
  • 可选的子模式可能会出现,也可能不会出现,彼此独立。

问号意味着子模式可以出现一次,也可以根本不出现。其他一些操作符允许多次重复一个子模式。

  • (pattern)* : pattern重复零次或多次。
  • (pattern)+ : pattern重复一次或多次。
  • (pattern){m,n} : patternmn重复多次。

所以,比如r'w*\.python\.org'' www.python.org '还要配'.python.org'``'ww.python.org'``'wwwwwww.python.org'。同样,r'w+\.python\.org'匹配'w.python.org'但不匹配'.python.org',r'w{3,4}\.python\.org'只匹配' www.python.org ''w www.python.org '

Note

这里不严格地使用术语“匹配”来表示模式匹配整个字符串。match函数(见表 10-9 )只要求模式匹配字符串的开头。

表 10-9。

Some Important Functions in the re Module

| 功能 | 描述 | | --- | --- | | `compile(pattern[, flags])` | 用正则表达式从字符串创建模式对象 | | `search(pattern, string[, flags])` | 在`string`中搜索`pattern` | | `match(pattern, string[, flags])` | 匹配`string`开头的`pattern` | | `split(pattern, string[, maxsplit=0])` | 按`pattern`的出现次数拆分一个`string` | | `findall(pattern, string)` | 返回`string`中`pattern`的所有出现的列表 | | `sub(pat, repl, string[, count=0])` | 用`repl`替换`string`中出现的`pat` | | `escape(string)` | 转义`string`中的所有特殊正则表达式字符 |
字符串的开头和结尾

到目前为止,您只查看匹配整个字符串的模式,但是您也可以尝试查找匹配该模式的子串,例如匹配模式'w+'的字符串' www.python.org '的子串'www'。当您搜索类似这样的子字符串时,有时将该子字符串锚定在整个字符串的开头或结尾会很有用。例如,您可能希望在字符串的开头匹配'ht+p',而不是在其他任何地方。然后用一个脱字符号('^')来标记开头。例如,'^ht+p'会匹配' http://python.org '(就此而言还有'htttttp://python.org'),但不会匹配' www.http.org '。类似地,字符串的结尾可以用美元符号($)来表示。

Note

有关正则表达式运算符的完整列表,请参见 Python 库中的“正则表达式语法”一节。

re 模块的内容

如果你不能用正则表达式做任何事情,那么知道如何写正则表达式就没什么用了。re模块包含几个使用正则表达式的有用函数。表 10-9 中描述了一些最重要的方法。

函数re.compile将一个正则表达式(写成字符串)转换成一个模式对象,这可以用于更有效的匹配。如果在调用searchmatch等函数时使用表示为字符串的正则表达式,无论如何都必须在内部将其转换为正则表达式对象。通过使用compile功能,这样做一次,每次使用该模式时就不再需要这个步骤。模式对象作为方法具有搜索/匹配功能,所以re.search(pat, string)(其中pat是写成字符串的正则表达式)等价于pat.search(string)(其中pat是用compile创建的模式对象)。编译后的正则表达式对象也可以用在普通的re函数中。

函数re.search搜索一个给定的字符串,找到匹配给定正则表达式的第一个子字符串(如果有的话)。如果找到一个,则返回一个MatchObject(评估为真);否则,返回None(评估为假)。由于返回值的性质,该函数可用于条件语句中,如下所示:

if re.search(pat, string):
    print('Found it!')

但是,如果您需要关于匹配子串的更多信息,您可以检查返回的MatchObject。(您将在下一节了解更多关于MatchObject的信息。)

函数re.match试图匹配给定字符串开头的正则表达式。所以re.match('p', 'python')返回真(一个匹配对象),而re.match('p', ' www.python.org ')返回假(None)。

Note

如果模式与字符串的beginning匹配,match函数将报告匹配;不要求模式匹配整个字符串。如果你想这样做,你需要在你的模式后面加一个美元符号。美元符号将匹配字符串的结尾,从而“延长”匹配。

函数re.split根据模式的出现次数分割字符串。这类似于 string 方法split,除了您允许完整的正则表达式,而不仅仅是一个固定的分隔符字符串。例如,使用字符串方法split,您可以根据字符串', '的出现次数来拆分字符串,但是使用re.split,您可以根据空格字符和逗号的任意序列来拆分。

>>> some_text = 'alpha, beta,,,,gamma    delta'
>>> re.split('[, ]+', some_text)
['alpha', 'beta', 'gamma', 'delta']

Note

如果模式包含括号,则括号中的组分散在拆分的子字符串之间。例如,re.split('o(o)', 'foobar')将产生['f', 'o', 'bar']

从这个例子中可以看出,返回值是一个子字符串列表。maxsplit参数表示允许的最大分割数。

>>> re.split('[, ]+', some_text, maxsplit=2)
['alpha', 'beta', 'gamma    delta']
>>> re.split('[, ]+', some_text, maxsplit=1)
['alpha', 'beta,,,,gamma    delta']

函数re.findall返回给定模式的所有出现的列表。例如,要查找字符串中的所有单词,您可以执行以下操作:

>>> pat = '[a-zA-Z]+'
>>> text = '"Hm... Err -- are you sure?" he said, sounding insecure.'
>>> re.findall(pat, text)
['Hm', 'Err', 'are', 'you', 'sure', 'he', 'said', 'sounding', 'insecure']

或者你可以找到标点符号:

>>> pat = r'[.?\-",]+'
>>> re.findall(pat, text)
['"', '...', '--', '?"', ',', '.']

注意破折号(-)已经被转义,所以 Python 不会将其解释为字符范围的一部分(比如a-z)。

函数re.sub用于用给定的替换替换模式最左边的不重叠的出现。考虑以下示例:

>>> pat = '{name}'
>>> text = 'Dear {name}...'
>>> re.sub(pat, 'Mr. Gumby', text)
'Dear Mr. Gumby...'

有关如何更有效地使用该功能的信息,请参阅本章后面的“替换中的组号和功能”一节。

函数re.escape是一个实用函数,用于转义字符串中可能被解释为正则表达式操作符的所有字符。如果您有一个包含许多这些特殊字符的长字符串,并且您想要避免键入大量反斜杠,或者如果您从用户处获得一个字符串(例如,通过input函数)并且想要将它用作正则表达式的一部分,请使用此选项。下面是它如何工作的一个例子:

>>> re.escape('www.python.org')
'www\\.python\\.org'
>>> re.escape('But where is the ambiguity?')
'But\\ where\\ is\\ the\\ ambiguity\\?'

Note

在表 10-9 中,你会注意到一些函数有一个名为flags的可选参数。此参数可用于更改正则表达式的解释方式。有关这方面的更多信息,请参见 Python 库参考中关于re模块的部分。

匹配对象和组

当找到匹配时,试图将模式与字符串的一部分匹配的re函数都返回MatchObject对象。这些对象包含与模式匹配的子字符串的信息。它们还包含关于模式的哪些部分与子串的哪些部分匹配的信息。这些部分被称为组。

组只是一个被括在括号中的子模式。各组用左括号编号。零组是整个模式。所以,在这个模式中:

'There (was a (wee) (cooper)) who (lived in Fyfe)'

这些组如下:

0 There was a wee cooper who lived in Fyfe
1 was a wee cooper
2 wee
3 cooper
4 lived in Fyfe

通常,这些组包含特殊字符,如通配符或重复操作符,因此您可能有兴趣了解给定组匹配了什么。例如,在此模式中:

r'www\.(.+)\.com$'

组 0 将包含整个字符串,而组 1 将包含从'www.''.com'之间的所有内容。通过创建这样的模式,您可以提取字符串中您感兴趣的部分。

表 10-10 中描述了一些更重要的re匹配对象的方法。

表 10-10。

Some Important Methods of re Match Objects

| 方法 | 描述 | | --- | --- | | `group([group1, …])` | 检索给定子模式(组)的出现 | | `start([group])` | 返回给定组出现的起始位置 | | `end([group])` | 返回给定组出现的结束位置(独占限制,如在切片中) | | `span([group])` | 返回一个组的开始和结束位置 |

方法group返回模式中给定组匹配的(子)字符串。如果没有给出组号,则假定组为 0。如果只给定了一个组号(或者只使用默认值 0),则返回一个字符串。否则,返回对应于给定组号的字符串元组。

Note

除了整个比赛(第 0 组)之外,您只能有 99 个组,编号范围为 1-99。

方法start返回给定组出现的起始索引(默认为 0,整个模式)。

方法end类似于start,但是返回结束索引加 1。

方法span返回具有给定组的开始和结束索引的元组(start, end)(默认为 0,整个模式)。

以下示例演示了这些方法的工作原理:

>>> m = re.match(r'www\.(.*)\..{3}', 'www.python.org')
>>> m.group(1)
'python'
>>> m.start(1)
4
>>> m.end(1)
10
>>> m.span(1)
(4, 10)

替换中的组数和函数

在第一个使用re.sub的例子中,我简单地用另一个子串替换了一个子串——我可以很容易地用replace字符串方法完成这个任务(在第三章的“字符串方法”一节中有描述)。当然,正则表达式很有用,因为它允许您以更灵活的方式进行搜索,而且还允许您执行更强大的替换。

利用re.sub的最简单的方法是在替换字符串中使用组号。替换字符串中任何形式为'\\n'的转义序列都被模式中由组n匹配的字符串替换。例如,假设您想要将形式为'*something*'的单词替换为'<em>something</em>',其中前者是在纯文本文档(如电子邮件)中表达强调的正常方式,后者是相应的 HTML 代码(如在网页中使用的)。让我们首先构造正则表达式。

>>> emphasis_pattern = r'\*([^\*]+)\*'

注意正则表达式很容易变得难以阅读,所以使用有意义的变量名(可能还有一两个注释)是很重要的,如果有人(包括你!)将在某个时候查看代码。

Tip

让正则表达式更易读的一种方法是在re函数中使用VERBOSE标志。这允许你添加空白(空格字符、制表符、换行符等等)到你的模式中,这将被re忽略——除非你把它放在一个字符类中或者用反斜杠转义它。你也可以在这种冗长的正则表达式中加入注释。下面是一个等同于强调模式的模式对象,但是它使用了VERBOSE标志:

>>> emphasis_pattern = re.compile(r'''
... \*               # Beginning emphasis tag -- an asterisk
... (                # Begin group for capturing phrase
... [^\*]+           # Capture anything except asterisks
... )                # End group
... \*               # Ending emphasis tag
...            ''', re.VERBOSE)
...

现在我有了我的模式,我可以使用re.sub进行替换。

>>> re.sub(emphasis_pattern, r'<em>\1</em>', 'Hello, *world*!')
'Hello, <em>world</em>!'

如你所见,我已经成功地将文本从纯文本翻译成了 HTML。

但是,通过使用一个函数作为替换,您可以使您的替换更加强大。这个函数将被提供以MatchObject作为它唯一的参数,它返回的字符串将被用作替换。换句话说,您可以对匹配的子字符串做任何您想做的事情,并进行精心处理以生成它的替换。你会问,这种力量对你有什么用处?一旦您开始尝试正则表达式,您肯定会发现这种机制的无数用途。对于一个应用,请参阅本章后面的“样本模板系统”一节。

Greedy and Nongreedy Patterns

默认情况下,重复操作符是贪婪的,这意味着它们将尽可能地匹配。例如,假设我重写了强调程序以使用以下模式:

>>> emphasis_pattern = r'\*(.+)\*'

这匹配一个星号,后跟一个或多个字符,然后是另一个星号。听起来很完美,不是吗?但事实并非如此。

>>> re.sub(emphasis_pattern, r'<em>\1</em>', '*This* is *it*!')
'<em>This* is *it</em>!'

如您所见,该模式匹配了从第一个星号到最后一个星号的所有内容——包括中间的两个星号!这就是贪婪的含义:拿走你能拿走的一切。

在这种情况下,你显然不想要这种过分贪婪的行为。当您知道一个特定的字母是非法的时,前面文本中给出的解决方案(使用匹配除星号之外的任何字符的字符集)就可以了。但是让我们考虑另一种情况。如果你用形式'**something**'来表示强调会怎么样?现在,在强调短语中包含单个星号应该不成问题。但是如何避免太贪心呢?

实际上,这很简单——只需使用一个非 greedy 版本的重复操作符。所有的重复操作符都可以通过在它们后面加一个问号而变得不简洁。

>>> emphasis_pattern = r'\*\*(.+?)\*\*'
>>> re.sub(emphasis_pattern, r'<em>\1</em>', '**This** is **it**!')
'<em>This</em> is <em>it</em>!'

这里我使用了运算符+?而不是+,这意味着模式将匹配通配符的一次或多次出现,如前所述。但是,它将尽可能少地匹配,因为它现在不是 greedy。因此,它将只匹配到达下一个出现的'\*\*'所需的最小值,这是模式的结尾。如你所见,它工作得很好。

查找电子邮件的发件人

你曾经把电子邮件保存为文本文件吗?如果您看过,您可能已经看到它在顶部包含了许多基本上不可读的文本,类似于清单 10-9 中所示。

From foo@bar.baz Thu Dec 20 01:22:50 2008
Return-Path: <foo@bar.baz>
Received: from xyzzy42.bar.com (xyzzy.bar.baz [123.456.789.42])
        by frozz.bozz.floop (8.9.3/8.9.3) with ESMTP id BAA25436
        for <magnus@bozz.floop>; Thu, 20 Dec 2004 01:22:50 +0100 (MET)
Received: from [43.253.124.23] by bar.baz
        (InterMail vM.4.01.03.27 201-229-121-127-20010626) with ESMTP
        id <20041220002242.ADASD123.bar.baz@[43.253.124.23]>; Thu, 20 Dec 2004 00:22:42 +0000
User-Agent: Microsoft-Outlook-Express-Macintosh-Edition/5.02.2022
Date: Wed, 19 Dec 2008 17:22:42 -0700
Subject: Re: Spam
From: Foo Fie <foo@bar.baz>
To: Magnus Lie Hetland <magnus@bozz.floop>
CC: <Mr.Gumby@bar.baz>
Message-ID: <B8467D62.84F%foo@baz.com>
In-Reply-To: <20041219013308.A2655@bozz.floop> Mime- version: 1.0
Content-type: text/plain; charset="US-ASCII" Content-transfer-encoding: 7bit
Status: RO
Content-Length: 55
Lines: 6

So long, and thanks for all the spam!

Yours,
Foo Fie

Listing 10-9.A Set of (Fictitious) Email Headers

让我们试着找出这封邮件是谁发来的。如果您检查了文本,我相信您可以在这种情况下弄明白它(当然,特别是如果您查看邮件本身底部的签名)。但是你能看出一个大致的模式吗?如果没有电子邮件地址,如何提取发件人的姓名?或者你怎么能列出标题中提到的所有电子邮件地址呢?让我们先处理前一个任务。

包含发件人的行以字符串'From: '开始,以用尖括号(<>)括起来的电子邮件地址结束。您希望在这些括号之间找到文本。如果你使用fileinput模块,这应该是一个简单的任务。清单 10-10 给出了解决问题的程序。

Note

如果您愿意,可以不使用正则表达式来解决这个问题。您也可以使用email模块。

# find_sender.py
import fileinput, re
pat = re.compile('From: (.*) <.*?>$')
for line in fileinput.input():
    m = pat.match(line)
    if m: print(m.group(1))
Listing 10-10.A Program for Finding the Sender of an Email

然后,您可以像这样运行程序(假设电子邮件在文本文件message.eml中):

$ python find_sender.py message.eml
Foo Fie

关于该程序,您应该注意以下几点:

  • 我编译正则表达式以使处理更有效。
  • 我将想要提取的子模式放在括号中,使它成为一个组。
  • 我使用了一个非 greedy 模式,所以电子邮件地址只匹配最后一对尖括号(以防名称包含一些括号)。
  • 我使用一个美元符号来表示我希望模式匹配整行,一直到最后。
  • 在我尝试提取特定组的匹配之前,我使用一个if语句来确保我确实匹配了一些东西。

要列出标题中提到的所有电子邮件地址,您需要构造一个正则表达式,该表达式只匹配一个电子邮件地址。然后,您可以使用方法findall在每一行中查找所有出现的内容。为了避免重复,您将地址保存在一个集合中(在本章前面有所描述)。最后,提取密钥,对它们进行排序,并打印出来。

import fileinput, re
pat = re.compile(r'[a-z\-\.]+@[a-z\-\.]+', re.IGNORECASE)
addresses = set()

for line in fileinput.input():
    for address in pat.findall(line):
        addresses.add(address)
for address in sorted(addresses):
    print address

运行该程序时的结果输出(以清单 10-9 中的电子邮件消息作为输入)如下:

Mr.Gumby@bar.baz
foo@bar.baz
foo@baz.com
magnus@bozz.floop

注意,排序时,大写字母排在小写字母之前。

Note

我没有严格遵守这里的问题规范。问题是找到文件头中的地址,但在这种情况下,程序会找到整个文件中的所有地址。为了避免这种情况,如果发现空行,可以调用fileinput.close(),因为标题不能包含空行。或者,如果有多个文件,您可以使用fileinput.nextfile()开始处理下一个文件。

一个样本模板系统

模板是一个文件,您可以将特定的值放入其中,以获得某种完整的文本。例如,您可能有一个只需要插入收件人姓名的邮件模板。Python 已经有了一个高级的模板机制:字符串格式化。然而,使用正则表达式,您可以使系统更加高级。假设您想用 Python 中的表达式对something求值的结果替换所有出现的'[something]'(“字段”)。因此,这个字符串:

'The sum of 7 and 9 is [7 + 9].'

应该翻译成这样:

'The sum of 7 and 9 is 16.'

此外,您希望能够在这些字段中执行赋值,以便该字符串:

'[name="Mr. Gumby"]Hello, [name]'

应该翻译成这样:

'Hello, Mr. Gumby'

这听起来可能是一项复杂的任务,但是让我们回顾一下可用的工具。

  • 您可以使用正则表达式来匹配字段并提取它们的内容。
  • 您可以用eval评估表达式字符串,提供包含范围的字典。你可以在一个try / except语句中做到这一点。如果出现了SyntaxError,你可能有一个陈述(比如一个任务)要做,应该用exec来代替。
  • 您可以用exec执行赋值字符串(和其他语句),将模板的作用域存储在一个字典中。
  • 您可以使用re.sub将评估结果代入正在处理的字符串。突然,它看起来不那么令人生畏了,不是吗?

Tip

如果一项任务看起来令人生畏,把它分成小块总是有帮助的。此外,盘点一下你手头的工具,找出解决问题的方法。

参见清单 10-11 中的示例实现。

# templates.py

import fileinput, re

# Matches fields enclosed in square brackets:
field_pat = re.compile(r'\[(.+?)\]')

# We'll collect variables in this:
scope = {}

# This is used in re.sub:
def replacement(match):
    code = match.group(1)
    try:
        # If the field can be evaluated, return it:
        return str(eval(code, scope))
    except SyntaxError:
        # Otherwise, execute the assignment in the same scope ... exec code in scope
        # ... and return an empty string:
        return ''

# Get all the text as a single string:

# (There are other ways of doing this; see Chapter 11)
lines = []
for line in fileinput.input():
    lines.append(line)
text = ''.join(lines)

# Substitute all the occurrences of the field pattern:
print(field_pat.sub(replacement, text))

Listing 10-11.A Template System

简而言之,该程序执行以下操作:

  • 定义匹配字段的模式。
  • 创建一个字典作为模板的作用域。
  • 定义执行以下操作的替换函数:
    • 从比赛中抓取组 1 并将其放入code
    • 尝试使用作用域字典作为名称空间来评估code,将结果转换为字符串,并返回它。如果成功,那么这个字段就是一个表达式,一切正常。否则(即引发一个SyntaxError),进入下一步。
    • 执行用于计算表达式的同一命名空间(作用域字典)中的字段,然后返回一个空字符串(因为赋值不计算任何值)。
  • 使用fileinput读取所有可用的行,将它们放在一个列表中,并将其连接成一个大字符串。
  • 使用re.sub中的替换功能替换所有出现的field_pat,并打印结果。

Note

在 Python 的早期版本中,将这些行放入一个列表中,然后在末尾将它们连接起来,比这样做要高效得多:

text = ''
for line in fileinput.input():
    text += line

虽然这看起来很优雅,但是每个赋值都必须创建一个新的字符串,也就是旧的字符串加上新的字符串,这会导致资源的浪费并使你的程序变慢。在 Python 的旧版本中,这和使用join之间的差别可能是巨大的。在更新的版本中,使用+=操作符实际上可能会更快。如果性能对您很重要,您可以尝试这两种解决方案。如果你想要一种更优雅的方式来阅读一个文件的所有文本,看看第十一章。

所以,我们只用了 15 行代码就创建了一个非常强大的模板系统(不包括空格和注释)。我希望当您使用标准库时,您开始看到 Python 变得多么强大。让我们通过测试模板系统来结束这个例子。尝试在清单 10-12 所示的简单文件上运行它。

[x = 2]
[y = 3]
The sum of [x] and [y] is [x + y].
Listing 10-12.A Simple Template Example

您应该看到这个:

The sum of 2 and 3 is 5.

但是等等,还有更好的!因为我用过fileinput,所以可以依次处理几个文件。这意味着我可以用一个文件定义一些变量的值,然后用另一个文件作为插入这些值的模板。例如,我可能有一个定义如清单 10-13 所示的文件,名为magnus.txt,还有一个模板文件如清单 10-14 所示,名为template.txt

[name       = 'Magnus Lie Hetland' ]
[email      = 'magnus@foo.bar' ]
[language   = 'python' ]
Listing 10-13.Some Template Definitions

[import time]
Dear [name],

I would like to learn how to program. I hear you
use the [language] language a lot -- is it something I
should consider?

And, by the way, is [email] your correct email address?

Fooville, [time.asctime()]

Oscar Frozzbozz

Listing 10-14.A Template

import time语句不是赋值语句(这是我要处理的语句类型),但是因为我不挑剔,只使用简单的try / except语句,所以我的程序支持任何使用evalexec的语句或表达式。您可以像这样运行程序(假设有一个 UNIX 命令行):

$ python templates.py magnus.txt template.txt

您应该会得到类似如下的输出:

Dear Magnus Lie Hetland,

I would like to learn how to program. I hear you use the python language a lot -- is it something I
should consider?

And, by the way, is magnus@foo.bar your correct email address?

Fooville, Mon Jul 18 15:24:10 2016

Oscar Frozzbozz

尽管这个模板系统能够进行一些非常强大的替换,但它仍然有一些缺陷。例如,如果能以更灵活的方式编写定义文件就好了。如果用execfile执行,您可以简单地使用普通的 Python 语法。这也将解决在输出顶部出现所有空行的问题。

你能想出改进这个项目的其他方法吗?你能想出这个程序中使用的概念的其他用途吗?真正精通任何一种编程语言的最好方法就是尝试——测试它的局限性并发现它的优势。看看你是否能重写这个程序,使它更好地工作,满足你的需要。

其他有趣的标准模块

尽管这一章已经涵盖了很多内容,但我仅仅触及了标准库的皮毛。为了吸引您的兴趣,我将快速介绍几个更酷的库。

  • 在 UNIX 中,命令行程序通常使用各种选项或开关来运行。(Python 解释器就是一个典型的例子。)这些都会在sys.argv中找到,但是自己正确处理这些远非易事。argparse模块使得提供完整的命令行界面变得简单明了。
  • 这个模块使你能够编写一个命令行解释器,有点像 Python 交互式解释器。您可以定义自己的命令,用户可以在提示符下执行这些命令。也许你可以用它作为你某个程序的用户界面?
  • CSV 是逗号分隔值的缩写,这是许多应用(例如,许多电子表格和数据库程序)用来存储表格数据的一种简单格式。它主要用于不同程序之间的数据交换。csv模块让您可以轻松地读写 CSV 文件,并且它非常透明地处理格式中一些比较棘手的部分。
  • datetime:如果time模块不足以满足你的时间追踪需求,那么datetime也很有可能。它支持特殊的日期和时间对象,并允许您以各种方式构建和组合这些对象。该界面在许多方面比time模块更加直观。
  • 这个库可以让你计算两个序列有多相似。它还使您能够(从可能性列表中)找到与您提供的原始序列“最相似”的序列。例如,difflib可以用来创建一个简单的搜索程序。
  • 枚举类型是一种具有固定的、少量可能值的类型。许多语言都内置了这样的类型,但是如果您在 Python 中需要这样的类型,enum模块是您的好朋友。
  • 在这里,您可以找到这样的功能,它允许您只使用一个函数的一些参数(部分求值),稍后再填充其余的参数。在 Python 3.0 中,这是你可以找到filterreduce.hashlib的地方。有了这个模块,你可以从字符串中计算出小的“签名”(数字)。如果你计算两个不同字符串的签名,你几乎可以肯定这两个签名是不同的。您可以在大型文本文件中使用它。这些模块在加密和安全方面有多种用途。 3
  • 在这里,你有很多工具来创建和组合迭代器(或者其他可迭代的对象)。有链接 iterable 的函数,有创建永远返回连续整数的迭代器的函数(类似于range,但是没有上限),有重复遍历 iterable 的函数,还有其他有用的东西。
  • logging:简单地使用print语句来弄清楚你的程序中发生了什么是很有用的。如果您想在没有大量调试输出的情况下跟踪事情,您可以将这些信息写入日志文件。该模块为您提供了一套标准的工具,用于管理一个或多个中央日志,其中包括日志消息的多个优先级。
  • statistics:计算一组数字的平均值并不难,但是即使是偶数个元素,也要得到正确的中值,例如,实现总体和样本标准偏差的差异,就需要多一点小心。与其自己动手,不如使用statistics模块!timeitprofiletrace:timeit模块(及其附带的命令行脚本)是一个测量一段代码运行时间的工具。它有一些锦囊妙计,你可能应该使用它而不是time模块进行性能测量。profile模块(以及它的同伴模块pstats)可以用来对一段代码的效率进行更全面的分析。trace模块(和程序)可以给你一个覆盖率分析(也就是说,你的代码的哪些部分被执行了,哪些没有被执行)。例如,这在编写测试代码时会很有用。

快速总结

在本章中,你学习了模块:如何创建它们,如何探索它们,以及如何使用标准 Python 库中的一些模块。

  • 模块:模块基本上是一个子程序,其主要功能是定义事物,如函数、类和变量。如果一个模块包含任何测试代码,它应该放在一个if语句中,该语句检查name == '__main__'是否。如果模块在PYTHONPATH中,它们可以被导入。您用语句import foo导入存储在文件foo.py中的模块。
  • 包:包只是一个包含其他模块的模块。包被实现为包含名为__init__.py的文件的目录。
  • 探索模块:将模块导入交互式解释器后,可以用多种方式探索它。其中包括使用dir,检查__all__变量,以及使用help函数。文档和源代码也是信息和洞察力的极好来源。
  • 标准库:Python 附带了几个模块,统称为标准库。本章回顾了其中一些:
    • sys:一个模块,可以让你访问与 Python 解释器紧密相连的几个变量和函数。
    • os:一个模块,可以让你访问与操作系统紧密相连的几个变量和函数。
    • fileinput:一个模块,它使得在几个文件或流的行上迭代变得容易。
    • setsheapqdeque:三个模块,提供三种有用的数据结构。还有内置类型set的套装。
    • time:获取当前时间、操作和格式化时间和日期的模块。
    • 一个模块,具有生成随机数,从序列中选择随机元素,以及混洗列表元素的功能。
    • shelve:创建持久映射的模块,用给定的文件名将其内容存储在数据库中。
    • re:支持正则表达式的模块。

如果您想了解更多关于模块的信息,我再次建议您浏览 Python 库参考。读起来真的很有趣。

本章的新功能

| 功能 | 描述 | | --- | --- | | `dir(obj)` | 返回按字母顺序排列的属性名列表 | | `help([obj])` | 提供交互式帮助或关于特定对象的帮助 | | `imp.reload(module)` | 返回已经导入的模块的重新加载版本 |

现在怎么办?

如果您已经掌握了本章中的至少一些概念,那么您的 Python 能力可能已经向前迈进了一大步。有了唾手可得的标准库,Python 从强大变得极其强大。用你目前所学的知识,你可以编写程序来解决各种各样的问题。在下一章中,你将学到更多关于使用 Python 与外部世界的文件和网络进行交互,从而解决更大范围的问题。

Footnotes 1

如果模块是用 C 语言编写的,C 源代码应该是可用的。

2

感谢路德·比利塞特指出了这一点。

3

另请参见md5sha模块。

十一、文件之类的东西

到目前为止,我们主要处理驻留在解释器本身中的数据结构。我们的程序通过inputprint与外界进行了很少的互动。在这一章中,我们更进一步,让我们的程序瞥见一个更大的世界:文件和流的世界。本章描述的函数和对象将使您能够在程序调用之间存储数据,并处理来自其他程序的数据。

打开文件

您可以使用open函数打开文件,该函数位于io模块中,但会自动为您导入。它将文件名作为唯一的强制参数,并返回一个 file 对象。假设您在当前目录中存储了一个名为somefile.txt的文本文件(可能是用您的文本编辑器创建的),您可以像这样打开它:

>>> f = open('somefile.txt')

如果文件位于其他位置,您也可以指定文件的完整路径。但是,如果它不存在,您将看到一个异常回溯,如下所示:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
FileNotFoundError: [Errno 2] No such file or directory: 'somefile.txt'

如果您想通过向其中写入文本来创建文件,这并不完全令人满意。解决方案在open的第二个参数中找到。

文件模式

如果只使用文件名作为参数的话,你会得到一个可以读取的文件对象。如果您想要写入文件,您必须明确地声明,提供一个模式。open函数的模式参数可以有几个值,如表 11-1 所示。

表 11-1。

Most Common Values for the Mode Argument of the open Function

| 价值 | 描述 | | --- | --- | | `'r'` | 读取模式(默认) | | `'w'` | 写入模式 | | `'x'` | 独占写入模式 | | `'a'` | 附加方式 | | `'b'` | 二进制模式(添加到其他模式) | | `'t'` | 文本模式(默认,添加到其他模式) | | `'+'` | 读/写模式(添加到其他模式) |

显式指定读取模式与根本不提供模式字符串具有相同的效果。写入模式允许您写入文件,如果文件不存在,将创建该文件。独占写模式更进一步,如果文件已经存在,则引发一个FileExistsError。如果以写模式打开现有文件,现有内容将被删除或截断,并从文件的开头重新开始写。如果您想一直写到现有文件的末尾,请使用 append 模式。

可以将'+'添加到任何其他模式中,以指示允许读取和写入。所以,例如,'r+'可以在打开一个文本文件进行读写时使用。(为了有用,你可能也想使用seek;请参阅本章后面的侧栏“随机访问”。)注意,'r+''w+'有一个重要区别:后者会截断文件,而前者不会。

默认模式是'rt',这意味着您的文件被视为编码的 Unicode 文本。然后自动执行解码和编码,默认编码为 UTF-8。其他编码和 Unicode 错误处理策略可以使用encodingerrors关键字参数来设置。(有关 Unicode 的更多信息,请参见第一章。)还有一些换行符的自动翻译。默认情况下,行以'\n'结束。其他行尾('\r''\r\n')会在阅读时自动替换。在写入时,'\n'被替换为系统默认的行尾(os.linesep)。

通常,Python 使用所谓的通用换行符模式,在这种模式下,任何有效的换行符('\n''\r''\r\n')都会被识别,例如,通过后面讨论的readlines方法。如果您希望保持这种模式,但希望防止自动翻译到'\n',您可以向newline关键字参数提供一个空字符串,如open(name, newline='')所示。如果您想指定只有'\r''\r\n'被视为有效的行尾,请提供您喜欢的行尾。这种情况下,读的时候行尾不翻译,写的时候会用合适的行尾替换'\n'

如果您的文件包含非文本的二进制数据,如声音剪辑或图像,您肯定不希望执行这些自动转换。在这种情况下,您只需使用二进制模式('rb')来关闭任何特定于文本的功能。

还有一些更高级的可选参数,用于控制缓冲和更直接地处理文件描述符。参见 Python 文档,或者在交互式解释器中运行help(open)来了解更多信息。

基本文件方法

现在你知道如何打开文件了。下一步是用它们做一些有用的事情。在本节中,您将了解文件对象的一些基本方法,以及其他一些类似文件的对象,有时称为流。类似文件的对象只是支持一些与文件相同的方法,最明显的是读或写或者两者都支持。由urlopen返回的对象(见第十四章)就是一个很好的例子。例如,它们支持像readreadline这样的方法,但是不支持像writeisatty这样的方法。

Three Standard Streams

在第十章中,在关于sys模块的部分,我提到了三个标准流。这些是类似文件的对象,您可以对它们应用您对文件的大部分了解。

数据输入的标准来源是sys.stdin。当程序从标准输入中读取时,您可以通过键入文本来提供文本,或者您可以使用管道将其与另一个程序的标准输出相链接,如“管道输出”一节中所示

您输入给print的文本会出现在sys.stdout中。input的提示也在那里。写入sys.stdout的数据通常出现在你的屏幕上,但是可以通过管道重新路由到另一个程序的标准输入,如上所述。

错误消息(如堆栈跟踪)被写入sys.stderr,这与sys.stdout类似,但可以单独重新路由。

阅读和写作

文件最重要的功能是提供和接收数据。如果你有一个名为f的类文件对象,你可以用f.write写数据,用f.read读数据。和大多数 Python 功能一样,使用什么作为数据也有一定的灵活性,但是使用的基本类是strbytes,分别用于文本和二进制模式。

每次调用f.write(string)时,您提供的字符串会在您之前写入的字符串之后写入文件。

>>> f = open('somefile.txt', 'w')
>>> f.write('Hello, ')
7
>>> f.write('World!')
6
>>> f.close()

注意,当我处理完文件时,我调用了close方法。您将在本章后面的“关闭文件”一节中了解更多信息。读书也一样简单。只要记住告诉流你想要读取多少个字符(或者字节,在二进制模式下)。这里有一个例子(从我停止的地方继续):

>>> f = open('somefile.txt', 'r')
>>> f.read(4)
'Hell'
>>> f.read()
'o, World!'

首先,我指定要读取多少个字符(4),然后我简单地读取文件的其余部分(不提供数字)。请注意,我本可以从对open的调用中删除模式说明,因为'r'是默认值。

管道输出

在 bash 这样的 shell 中,您可以一个接一个地编写几个命令,用管道连接在一起,如下例所示:

$ cat somefile.txt | python somescript.py | sort

这个管道由三个命令组成。

  • cat somefile.txt:该命令只是将文件somefile.txt的内容写入标准输出(sys.stdout)。
  • python somescript.py:该命令执行 Python 脚本somescript。该脚本可能从其标准输入中读取数据,并将结果写入标准输出。
  • sort:该命令从标准输入(sys.stdin)中读取所有文本,按字母顺序对行进行排序,并将结果写入标准输出。

但是这些管道字符(|)有什么意义,somescript.py又是做什么的呢?管道将一个命令的标准输出与下一个命令的标准输入连接起来。聪明吧。所以你可以有把握地猜测,somescript.py从它的sys.stdin(这是cat somefile.txt写的)读取数据,并将一些结果写到它的sys.stdout(这是sort获取数据的地方)。

清单 11-1 中显示了一个使用sys.stdin的简单脚本(somescript.py)。文件somefile.txt的内容如清单 11-2 所示。

# somescript.py

import sys
text = sys.stdin.read()
words = text.split()
wordcount = len(words)
print('Wordcount:', wordcount)
Listing 11-1.Simple Script That Counts the Words in sys.stdin

Your mother was a hamster and your
father smelled of elderberries.
Listing 11-2.A File Containing Some Nonsensical Text

下面是cat somefile.txt | python somescript.py的结果:

Wordcount: 11

Random Access

在这一章中,我只把文件当作流——你只能严格按照顺序从头到尾读取数据。事实上,您还可以移动文件,通过使用两个文件对象方法seektell,只访问您感兴趣的部分(称为随机访问)。

方法seek(offset[, whence])将当前位置(执行读或写的位置)移动到offset描述的位置,whence. offset是一个字节(字符)计数。whence默认为io.SEEK_SET0,表示从文件开始偏移(偏移量必须为非负)。whence也可以设置为io.SEEK_CUR1(相对于当前位置移动;偏移量可能为负)或io.SEEK_END2(相对于文件末尾移动)。考虑这个例子:

>>> f = open(r'C:\text\somefile.txt', 'w')
>>> f.write('01234567890123456789')
20
>>> f.seek(5)
5
>>> f.write('Hello, World!')
13
>>> f.close()
>>> f = open(r'C:\text\somefile.txt')
>>> f.read()
'01234Hello, World!89'

方法tell()返回当前文件位置,如下例所示:

>>> f = open(r'C:\text\somefile.txt')
>>> f.read(3)
'012'
>>> f.read(2)
'34'
>>> f.tell()

5

读写线

其实我到现在一直在做的事情有点不切实际。我可以像一个字母一个字母地读一样地读一行行的文字。您可以使用readline方法读取一行(从您到目前为止的位置开始,直到并包括您遇到的第一个行分隔符的文本)。您可以在没有任何参数的情况下使用这个方法(在这种情况下,只读取并返回一行),或者使用一个非负整数,这是允许readline读取的最大字符数。所以如果some_file.readline()返回'Hello, World!\n',那么some_file.readline(5)返回'Hello'。要读取一个文件的所有行并将它们作为一个列表返回,使用readlines方法。

方法writelinesreadlines相反:给它一个字符串列表(或者,事实上,任何序列或可迭代对象),它将所有字符串写入文件(或流)。请注意,没有添加换行符;你需要自己添加这些。此外,没有writeline方法,因为你可以只使用write

关闭文件

你应该记得通过调用它们的close方法来关闭你的文件。通常,当你退出程序时(可能在此之前),file 对象会自动关闭,不关闭你正在读取的文件并不重要。然而,关闭这些文件没有坏处,而且可能有助于避免在某些操作系统和设置中保持文件被无用地“锁定”以防修改。这也避免了耗尽系统中打开文件的配额。

您应该总是关闭您已经写入的文件,因为 Python 可能会缓冲(出于效率原因,暂时存储在某个地方)您已经写入的数据,如果您的程序由于某种原因崩溃,数据可能根本不会写入文件。安全的做法是在你看完文件后关闭它们。如果您想重置缓冲并使您的更改在磁盘上的实际文件中可见,但您还不想关闭文件,您可以使用flush方法。但是,请注意,flush可能不允许同时运行的其他程序访问该文件,因为锁定的考虑取决于您的操作系统和设置。只要您可以方便地关闭文件,这是更可取的。

如果你想确定你的文件已经关闭,你可以使用一个try / finally语句,在finally子句中调用close

# Open your file here
try:
    # Write data to your file
finally:
    file.close()

事实上,有一种说法是专门为这种情况设计的——with说法。

with open("somefile.txt") as somefile:
     do_something(somefile)

with语句允许您打开一个文件,并为其指定一个变量名(在本例中为somefile)。然后,在语句体中向文件中写入数据(可能还会做其他事情),当到达语句结尾时,文件会自动关闭,即使这是由异常引起的。

Context Managers

with语句实际上是一个非常通用的构造,允许您使用所谓的上下文管理器。上下文管理器是一个支持两种方法的对象:__enter____exit__

__enter__方法没有参数。它在进入with语句时被调用,返回值被绑定到as关键字后的变量。

__exit__方法有三个参数:异常类型、异常对象和异常回溯。当离开方法时调用它(通过参数提供任何引发的异常)。如果__exit__返回 false,任何异常都将被抑制。

文件可以用作上下文管理器。它们的__enter__方法返回文件对象本身,而它们的__exit__方法关闭文件。有关这个强大但相当高级的特性的更多信息,请查看 Python 参考手册中对上下文管理器的描述。另请参见 Python 库参考中关于上下文管理器类型和contextlib的章节。

使用基本文件方法

假设somefile.txt包含清单 11-3 中的文本。你能用它做什么?

Welcome to this file
There is nothing here except
This stupid haiku
Listing 11-3.A Simple Text File

让我们试试你知道的方法,从read(n)开始。

>>> f = open(r'C:\text\somefile.txt')
>>> f.read(7)
'Welcome'
>>> f.read(4)
' to '
>>> f.close()

接下来是read():

>>> f = open(r'C:\text\somefile.txt')
>>> print(f.read())
Welcome to this file
There is nothing here except
This stupid haiku
>>> f.close()

这里是readline():

>>> f = open(r'C:\text\somefile.txt')
>>> for i in range(3):
        print(str(i) + ': ' + f.readline(), end='')
0: Welcome to this file
1: There is nothing here except
2: This stupid haiku
>>> f.close()

这里是readlines():

>>> import pprint
>>> pprint.pprint(open(r'C:\text\somefile.txt').readlines())
['Welcome to this file\n',
'There is nothing here except\n',
'This stupid haiku']

注意,在这个例子中,我依赖于文件对象被自动关闭。现在让我们试着写作,从write(string)开始。

>>> f = open(r'C:\text\somefile.txt', 'w')
>>> f.write('this\nis no\nhaiku')
13
>>> f.close()

运行这个之后,文件包含清单 11-4 中的文本。

this
is no
haiku
Listing 11-4.The Modified Text File

最后,这里是writelines(list):

>>> f = open(r'C:\text\somefile.txt')
>>> lines = f.readlines()
>>> f.close()
>>> lines[1] = "isn't a\n"
>>> f = open(r'C:\text\somefile.txt', 'w')
>>> f.writelines(lines)
>>> f.close()

运行这个之后,文件包含清单 11-5 中的文本。

this
isn't a
haiku
Listing 11-5.The Text File, Modified Again

迭代文件内容

现在您已经看到了文件对象呈现给我们的一些方法,并且您已经学习了如何获取这样的文件对象。对文件的一个常见操作是迭代它们的内容,在进行过程中重复执行一些操作。有很多方法可以做到这一点,你当然可以找到你最喜欢的,并坚持下去。但是,其他人可能做得不一样,要理解他们的程序,你应该知道所有的基本技术。

在本节的所有示例中,我使用一个名为process的虚构函数来表示每个字符或行的处理。你可以随意用你喜欢的任何方式来实现它。这里有一个简单的例子:

def process(string):
    print('Processing:', string)

更有用的实现可以将数据存储在数据结构中,计算总和,用re模块替换模式,或者添加行号。

此外,为了试验这些示例,您应该将变量filename设置为某个实际文件的名称。

一次一个字符(或字节)

迭代文件内容的一个最基本(但可能是最不常见)的方法是在一个while循环中使用read方法。例如,您可能想要循环文件中的每个字符(或者,在二进制模式下,每个字节)。你可以这样做,如清单 11-6 所示。如果你想读几个字符或字节的块,提供期望的长度给read

with open(filename) as f:
    char = f.read(1)
    while char:
        process(char)
        char = f.read(1)
Listing 11-6.Looping over Characters with read

这个程序之所以有效,是因为当到达文件末尾时,read方法返回一个空字符串,但在此之前,该字符串始终包含一个字符(因此布尔值为 true)。只要char为真,你就知道自己还没完。

如你所见,我已经重复了赋值char = f.read(1),代码重复通常被认为是一件坏事。(懒惰是一种美德,记得吗?)为了避免这种情况,我们可以使用第五章中介绍的while True / break技术。结果代码如清单 11-7 所示。

with open(filename) as f:
    while True:
        char = f.read(1)
        if not char: break
        process(char)
Listing 11-7.Writing the Loop Differently

正如在第五章中提到的,你不应该太频繁地使用break语句(因为它会使代码更难理解)。即便如此,清单 11-7 中所示的方法通常比清单 11-6 中所示的方法更受欢迎,这正是因为您避免了重复代码。

一次一行

当处理文本文件时,您通常感兴趣的是遍历文件中的行,而不是每个字符。你可以像我们处理字符一样,使用readline方法(之前在“读写行”一节中描述过),很容易地做到这一点,如清单 11-8 所示。

with open(filename) as f:
    while True:
        line = f.readline()
        if not line: break
        process(line)

Listing 11-8.Using readline in a while Loop

阅读一切

如果文件不太大,您可以使用不带参数的read方法(将整个文件作为一个字符串读取)或readlines方法(将文件读入一个字符串列表,其中每个字符串为一行)一次性读取整个文件。清单 11-9 和 11-10 展示了当您像这样阅读文件时,遍历字符和行是多么容易。注意,像这样将文件的内容读入字符串或列表,除了迭代之外,对其他事情也很有用。例如,您可以对字符串应用正则表达式,或者将行列表存储在某个数据结构中以备将来使用。

with open(filename) as f:
    for char in f.read():
        process(char)
Listing 11-9.Iterating over Characters with read

with open(filename) as f:
    for line in f.readlines():
        process(line)
Listing 11-10.Iterating over Lines with readlines

使用 fileinput 的惰性行迭代

有时你需要迭代一个非常大的文件中的行,而readlines会使用太多的内存。当然,您可以将while循环与readline一起使用,但是在 Python 中,当for循环可用时,最好使用它们。恰好他们在这种情况下。你可以使用一种叫做懒惰线迭代的方法——它之所以懒惰,是因为它只读取文件中实际需要的部分(或多或少)。

你已经在第十章中遇到了fileinput。清单 11-11 展示了你可能如何使用它。注意,fileinput模块负责打开文件。你只需要给它一个文件名。

import fileinput
for line in fileinput.input(filename):
    process(line)

Listing 11-11.Iterating over Lines with fileinput

文件迭代器

是时候采用最酷(也是最常见)的技术了。文件实际上是可迭代的,这意味着您可以在for循环中直接使用它们来迭代它们的行。参见清单 11-12 中的示例。

with open(filename) as f:
    for line in f:
        process(line)
Listing 11-12.Iterating over a File

在这些迭代例子中,我使用文件作为上下文管理器,以确保我的文件是关闭的。虽然这通常是一个好主意,但它不是绝对关键的,只要我不写入文件。如果你愿意让 Python 来处理收尾工作,你可以进一步简化这个例子,如清单 11-13 所示。这里,我没有将打开的文件赋给一个变量(就像我在其他例子中使用的变量f),因此我没有办法显式关闭它。

for line in open(filename):
    process(line)
Listing 11-13.Iterating over a File Without Storing the File Object in a Variable

注意sys.stdin是可迭代的,就像其他文件一样,所以如果您想迭代标准输入中的所有行,您可以使用以下形式:

import sys
for line in sys.stdin:
    process(line)

同样,你可以做所有你可以用迭代器做的事情,比如把它们转换成字符串列表(通过使用list(open(filename))),这就相当于使用readlines

>>> f = open('somefile.txt', 'w')
>>> print('First', 'line', file=f)
>>> print('Second', 'line', file=f)
>>> print('Third', 'and final', 'line', file=f)
>>> f.close()
>>> lines = list(open('somefile.txt'))
>>> lines
['First line\n', 'Second line\n', 'Third and final line\n']
>>> first, second, third = open('somefile.txt')
>>> first
'First line\n'
>>> second
'Second line\n'
>>> third
'Third and final line\n'

在本例中,请务必注意以下几点:

  • 我已经使用了print来写入文件。这将自动在我提供的字符串后添加新行。
  • 我对打开的文件使用序列解包,将每一行放在一个单独的变量中。(这并不是很常见的做法,因为您通常不知道文件中的行数,但是它展示了 file 对象的“可迭代性”。)
  • 我在写入文件后关闭它,以确保数据被刷新到磁盘。(你也看到了,我从里面看完就没关。草率,也许,但不是关键。)

快速总结

在本章中,你已经看到了如何通过文件和类似文件的对象与环境交互,这是 Python 中 I/O 最重要的技术之一。以下是本章的一些亮点:

  • 类似文件的对象:类似文件的对象(非正式地)是支持一组方法的对象,比如readreadline(可能还有writewritelines)。
  • 打开和关闭文件:通过提供文件名,用open函数打开一个文件。如果你想确保你的文件被关闭,即使出了问题,你也可以使用with语句。
  • 模式和文件类型:当打开一个文件时,你也可以提供一个模式,比如'r'表示读模式,或者'w'表示写模式。通过将'b'添加到您的模式中,您可以将文件作为二进制文件打开,并关闭 Unicode 编码和换行符。
  • 标准流:三个标准文件(stdinstdoutstderr,在sys模块中找到)是实现 UNIX 标准 I/O 机制(在 Windows 中也可用)的类似文件的对象。
  • 读写:使用方法read从文件或类似文件的对象中读取。你用方法write写。
  • 读写行:可以使用readlinereadlines从文件中读取行。你可以用writelines写文件。
  • 迭代文件内容:有许多方法可以迭代文件内容。最常见的是迭代一个文本文件的行,你可以通过简单地迭代文件本身来实现。还有其他方法,比如使用readlines,它们与旧版本的 Python 兼容。

本章的新功能

| 功能 | 描述 | | --- | --- | | `open(name, ...)` | 打开一个文件并返回一个 file 对象 |

什么现在?

现在你知道了如何通过文件与环境交互,但是如何与用户交互呢?到目前为止,我们只使用了inputprint,除非用户在你的程序可以读取的文件中写了一些东西,否则你真的没有任何其他工具来创建用户界面。这在下一章会有所改变,我将介绍带有窗口、按钮等的图形用户界面。

十二、图形用户界面

在这相当短的一章中,您将学习如何为您的 Python 程序制作图形用户界面(GUI)的基础知识——您知道,带有按钮和文本字段之类的东西的窗口。Python 事实上的标准 GUI 工具包是 Tkinter,它是标准 Python 发行版的一部分。但是,还有其他几个工具包可用。这有它的优点(更大的选择自由)和缺点(其他人不能使用你的程序,除非他们安装了相同的 GUI 工具包)。幸运的是,Python 可用的各种 GUI 工具包之间没有冲突,所以您可以安装任意多的不同 GUI 工具包。

本章简要介绍了 Tkinter 的使用,我们将在第二十八章对此进行介绍。Tkinter 很容易使用,但如果你想使用它的所有功能,还有很多东西要学。我在这里只讲一些皮毛,以便让你继续下去;关于更多的细节,你应该参考标准图书馆参考中关于图形用户界面的章节。在那里,您可以找到 Tkinter 的文档,以及到具有更深入信息的站点的链接,以及对其他 GUI 包使用的建议。

构建一个示例 GUI 应用

为了演示如何使用 Tkinter,我将向您展示如何构建一个简单的 GUI 应用。你的任务是编写一个基本程序,使你能够编辑文本文件。我们不打算写一个完全成熟的文本编辑器,而是坚持要点。毕竟,目标是演示 Python 中 GUI 编程的基本机制。

这个最小文本编辑器的要求如下:

  • 它必须允许你打开文本文件,给定它们的文件名。
  • 它必须允许您编辑文本文件。
  • 它必须允许您保存文本文件。
  • 它必须允许你退出。

当编写一个 GUI 程序时,画一个你想要的草图通常是很有用的。图 12-1 显示了一个简单的布局,它满足了我们的文本编辑器的需求。

A326949_3_En_12_Fig1_HTML.gif

图 12-1。

A sketch of the text editor

该界面的元素可以按如下方式使用:

  • 在按钮左侧的文本字段中键入文件名,然后点按“打开”以打开文件。

文件中包含的文本放在底部的文本字段中。

  • 您可以在大文本栏中随心所欲地编辑文本。
  • 如果要保存更改,请单击 save 按钮,该按钮将再次使用包含文件名的文本字段,并将大文本字段的内容写入文件。
  • 没有退出按钮——我们将只使用默认 Tkinter 菜单中的退出命令。

这可能看起来有点令人生畏的任务,但它真的是小菜一碟。

初步探索

首先,您必须导入tkinter。为了保持其名称空间的独立性并节省一些输入,您可能需要对其进行重命名。

import tkinter as tk

不过,如果您愿意的话,只导入它的所有内容也没什么坏处。对于一些初步的探索,让我们只使用交互式解释器。

>>> from tkinter import *

要启动 GUI,我们可以创建一个顶层组件或小部件,它将充当我们的主窗口。我们通过实例化一个Tk对象来做到这一点。

>>> top = Tk()

此时,应该会出现一个窗口。在普通程序中,我们会在这里插入对函数mainloop的调用,以进入 Tkinter 主事件循环,而不是简单地退出程序。在交互式解释器中不需要这样做,但是请随意尝试。

>>> mainloop()

当 GUI 仍在工作时,解释器似乎会挂起。要继续,请退出 GUI 并重新启动解释器。

各种小部件都有很明显的名字。例如,要创建一个按钮,需要实例化Button类。如果没有Tk实例,创建一个小部件也会实例化Tk,所以你可以直接进入。

>>> from tkinter import *
>>> btn = Button()

此时按钮不可见—您需要使用布局管理器(也称为几何管理器)来告诉 Tkinter 放置它的位置。我们将使用包管理器,它最简单的形式就是调用pack方法。

>>> btn.pack()

小部件有各种属性,我们可以用它们来修改它们的外观和行为。这些属性就像字典字段一样可用,所以如果我们想给按钮一些文本,所需要的只是一个赋值。

>>> btn['text'] = 'Click me!'

现在,您应该有一个类似如下的窗口:

A326949_3_En_12_Figa_HTML.jpg

向按钮添加一些行为也很简单。

>>> def clicked():
...     print('I was clicked!')
...
>>> btn['command'] = clicked

如果您现在单击按钮,您应该会看到打印出来的消息。

您可以使用config方法一次设置几个属性,而不是单独分配。

>>> btn.config(text='Click me!', command=clicked)

您还可以使用其构造函数来配置小部件。

>>> Button(text='Click me too!', command=clicked).pack()

布局

当我们在一个小部件上调用pack时,它被放置在它的父小部件或主部件中。主窗口小部件可以作为可选的第一个参数提供给构造函数;如果我们不提供,将使用主顶级窗口,如下面的代码片段所示:

Label(text="I'm in the first window!").pack()
second = Toplevel()
Label(second, text="I'm in the second window!").pack()

Toplevel类代表主窗口之外的顶层窗口,Label只是一个文本标签。

如果没有任何参数,pack 将简单地将窗口小部件堆叠在一个单独的居中的列中,从窗口的顶部开始。例如,下面的代码将生成一个又高又薄的窗口,其中只有一列按钮:

for i in range(10):
    Button(text=i).pack()

幸运的是,您可以调整小部件的位置和拉伸。你打包一个小部件的面是由side参数给定的,你提供LEFTRIGHTTOPBOTTOM。如果您希望小部件填充 x 或 y 方向上分配给它的空间,您可以指定一个值为XYBOTHfill。如果您希望它随着父窗口(在本例中是窗口)的增长而增长,您可以将expand设置为 true。还有其他选项,用于指定锚定和填充,虽然我不会在这里使用它们。要获得快速概览,您可以使用以下内容:

>>> help(Pack.config)

还有其他布局管理器选项,可能更适合您的口味,即gridplace。你在布局的小部件上调用这些方法,就像使用pack一样。为了避免麻烦,你应该为一个容器使用一个布局管理器,比如一个窗口。

grid方法允许您通过将对象放置在不可见表格的单元格中来对其进行布局;如果小部件跨越多行或多列,您可以通过指定一个rowcolumn以及可能的一个rowspancolumnspan来做到这一点。通过指定坐标xy,以及小部件的heightweight,方法允许您手动放置小部件。这很少是一个好主意,但有时可能需要。这两个几何图形管理器还具有附加参数,您可以使用以下命令找到这些参数:

>>> help(Grid.configure)
>>> help(Place.config)

事件处理

如您所见,我们可以通过设置command属性为按钮提供一个动作。这是事件处理的一种特殊形式,对此 Tkinter 也有一种更通用的机制:bind方法。您可以在想要处理给定类型事件的小部件上调用这个函数,指定事件的名称和要使用的函数。这里有一个例子:

>>> from tkinter import *
>>> top = Tk()
>>> def callback(event):
...     print(event.x, event.y)
...
>>> top.bind('<Button-1>', callback)
'4322424456callback'

这里,<Button-1>是使用左键(按钮 1)的鼠标点击(或等效操作)的名称。我们将它绑定到回调函数,每当我们在顶部窗口中单击时都会调用该函数。事件对象被传递给回调,根据事件的种类,它有不同的属性。例如,对于鼠标点击,它提供 x 和 y 坐标,在本例中打印出来。许多其他种类的事件是可用的。您可以使用以下命令来查找列表

>>> help(Tk.bind)

并且可以通过查阅先前描述的资源来找到进一步的信息。

最终方案

至此,我们已经大致了解了编写程序所需的内容。我们只需要找出用于小文本字段和大文本区域的小部件的名称。快速浏览文档告诉我们,Entry就是我们想要的单行文本字段。通过组合TextScrollbar可以构建一个多行、滚动的文本区域,但是在tkinter.scrolledtext模块中已经有了一个可用的实现。一个Entry的内容可以使用它的get方法提取,而对于ScrolledText对象,我们将使用deleteinsert方法,用适当的参数来指示文本中的位置。在我们的例子中,我们将使用'1.0'指定第一行和第零个字符(即在第一个字符之前),使用END指定文本的结尾,使用INSERT指定当前插入点。结果程序如清单 12-1 和图 12-2 所示。

A326949_3_En_12_Fig2_HTML.jpg

图 12-2。

The final text editor

from tkinter import *
from tkinter.scrolledtext import ScrolledText

def load():
    with open(filename.get()) as file:
        contents.delete('1.0', END)
        contents.insert(INSERT, file.read())

def save():
    with open(filename.get(), 'w') as file:
        file.write(contents.get('1.0', END))

top = Tk()
top.title("Simple Editor")

contents = ScrolledText()

contents.pack(side=BOTTOM, expand=True, fill=BOTH)

filename = Entry()
filename.pack(side=LEFT, expand=True, fill=X)

Button(text='Open', command=load).pack(side=LEFT)
Button(text='Save', command=save).pack(side=LEFT)

mainloop()

Listing 12-1.Simple GUI Text Editor

您可以使用以下步骤尝试编辑器:

  1. 运行程序。您应该得到一个类似于前面运行中的窗口。
  2. 在大文本区域中键入一些内容(例如,Hello, world!)。
  3. 在小文本字段中键入文件名(例如,hello.txt)。请确保该文件不存在,否则将被覆盖。
  4. 单击保存按钮。
  5. 退出程序。
  6. 重启程序。
  7. 在小文本字段中键入相同的文件名。
  8. 单击打开按钮。文件的文本应该重新出现在大文本区域。
  9. 随心所欲地编辑文件,然后再次保存。

现在你可以继续打开、编辑和保存,直到你厌倦为止。然后你就可以开始考虑改进了。例如,允许你的程序用urllib模块下载文件怎么样?

当然,你也可以考虑在你的程序中使用更多的面向对象的设计。例如,您可能希望将主应用作为一个定制应用类的实例来管理,该类具有设置各种小部件和绑定的方法。参见第二十八章中的一些例子。和任何 GUI 包一样,Tkinter 有很多小部件和其他类供您使用。您应该使用help(tkinter)或查阅文档来获取您想要使用的任何图形元素的信息。

用别的东西

大多数 GUI 工具包的基本原理大致相同。然而,不幸的是,当学习如何使用一个新的软件包时,需要花时间来找到通过所有细节的方法,使您能够准确地做您想要做的事情。因此,在决定使用哪个包之前,您应该慢慢来(例如,参见 Python 标准库参考中关于其他 GUI 包的部分),然后沉浸在它的文档中并开始编写代码。我希望这一章已经提供了理解该文档所需的基本概念。

快速总结

让我们再次回顾一下我们在本章中讲述的内容:

  • 图形用户界面(GUI):GUI 有助于使你的程序更加用户友好。不是所有的程序都需要图形用户界面,但是每当你的程序与用户交互时,图形用户界面可能会有所帮助。
  • Tkinter: Tkinter 是一个成熟的、广泛可用的 Python 跨平台 GUI 工具包。
  • 布局:通过直接指定组件的几何图形,可以非常简单地定位组件。然而,要使它们在调整包含它们的窗口大小时行为正常,您需要使用某种布局管理器。
  • 事件处理:用户执行的动作触发 GUI 工具包中的事件。为了有用,您的程序可能会被设置为对这些事件做出反应;否则,用户将无法与之交互。在 Tkinter 中,使用bind方法将事件处理程序添加到组件中。

什么现在?

就这样。你现在知道如何编写可以通过文件和图形用户界面与外界交互的程序。在下一章,你将学习许多程序系统的另一个重要组成部分:数据库。

十三、数据库支持

使用简单的纯文本文件只能做到这一步。是的,它们可以让你走得很远,但在某些时候,你可能需要一些额外的功能。你可能想要一些自动化连载,你可以求助于shelve(见第十章)和pickle(是shelve的近亲)。但是你可能想要比这更好的功能。例如,您可能希望自动支持对数据的并发访问,也就是说,允许多个用户读写基于磁盘的数据,而不会导致任何损坏的文件等。或者您可能希望能够同时使用许多数据字段或属性来执行复杂的搜索,而不是简单的shelve单键查找。有大量的解决方案可供选择,但是如果您希望这可以扩展到大量的数据,并且希望该解决方案易于被其他程序员理解,那么选择一种相对标准的数据库形式可能是一个好主意。

本章讨论了 Python 数据库 API,这是一种连接 SQL 数据库的标准化方法,并演示了如何使用该 API 执行一些基本的 SQL。最后一节还讨论了一些可供选择的数据库技术。

我不会给你一个关于关系数据库或 SQL 语言的教程。大多数数据库(如 PostgreSQL 或 MySQL,或本章中使用的 SQLite)的文档应该涵盖您需要了解的内容。如果你以前没有使用过关系数据库,你可能想看看 www.sqlcourse.com (或者只是在网上搜索一下这个主题)或者《SQL 查询入门》,第二版。,作者克莱尔·丘奇(Apress,2016)。

到目前为止,本章中使用的简单数据库(SQLite)当然不是唯一的选择。有几种流行的商业选择(如 Oracle 或 Microsoft SQL Server),以及一些可靠且广泛使用的开源数据库(如 MySQL、PostgreSQL 和 Firebird)。关于 Python 包支持的一些其他数据库的列表,请查看 https://wiki.python.org/moin/DatabaseInterfaces 。当然,关系(SQL)数据库并不是唯一的一种。有对象数据库,如 Zope 对象数据库(ZODB, http://zodb.org ),基于紧凑表的数据库,如 Metakit ( http://equi4.com/metakit ),甚至更简单的键值数据库,如 UNIX DBM ( https://docs.python.org/3/library/dbm.html )。还有各种各样越来越流行的 NoSQL 数据库,比如 MongoDB ( http://mongodb.com )、Cassandra ( http://cassandra.apache.org )和 Redis ( http://redis.io ),都可以从 Python 访问。

虽然这一章关注的是底层的数据库交互,但是您可以找到一些高级的库来帮助您抽象掉一些麻烦(例如,参见 http://sqlalchemy.orghttp://sqlobject.org ,或者在 Web 上搜索其他所谓的 Python 对象关系映射器)。

Python 数据库 API

正如我提到的,您可以从各种 SQL 数据库中进行选择,其中许多数据库在 Python 中都有相应的客户端模块(有些数据库甚至有几个)。所有数据库的大部分基本功能都是相同的,所以为使用其中一个数据库而编写的程序可能很容易——理论上——用于另一个数据库。在提供相同功能(或多或少)的不同模块之间切换的问题通常是它们的接口(API)不同。为了解决 Python 中数据库模块的这一问题,已经就标准数据库 API (DB API)达成一致。API 的当前版本(2.0)在 PEP 249,Python 数据库 API 规范 v2.0 中定义(可从 http://python.org/peps/pep-0249.html 获得)。

本节将为您提供基础知识的概述。我不会讨论 API 的可选部分,因为它们并不适用于所有数据库。你可以在上面提到的 PEP 或者官方 Python Wiki 中的数据库编程指南中找到更多信息(可以从 http://wiki.python.org/moin/DatabaseProgramming 获得)。如果您对所有 API 细节不感兴趣,可以跳过这一节。

全局变量

任何兼容的数据库模块(兼容,即与 DB API 版本 2.0 兼容)必须有三个全局变量,它们描述了模块的特性。这是因为 API 被设计得非常灵活,可以与几种不同的底层机制一起工作,而不需要太多的包装。如果您想让您的程序与几个不同的数据库一起工作,这可能是一件麻烦的事情,因为您需要涵盖许多不同的可能性。在许多情况下,更现实的做法是简单地检查这些变量,看看给定的数据库模块是否能被您的程序接受。如果不是,您可以简单地退出,给出一个适当的错误消息,或者引发一些异常。表 13-1 总结了全局变量。

表 13-1。

The Module Properties of the Python DB API

| 变量名 | 使用 | | --- | --- | | `apilevel` | 正在使用的 Python DB API 的版本 | | `threadsafety` | 模块的线程安全程度 | | `paramstyle` | SQL 查询中使用了哪种参数样式 |

API 级别(apilevel)只是一个字符串常量,给出了正在使用的 API 版本。根据 DB API 版,它可能有值'1.0'或值'2.0'。如果变量不存在,则模块不符合 2.0,并且您应该(根据 API)假设 DB API 版本 1.0 是有效的。在这里编写允许其他值的代码也不会有什么坏处(谁知道 DB API 的 3.0 版本什么时候会出来呢?).

线程安全级别(threadsafety)是从 0 到 3 的整数,包括 0 和 3。0 表示线程可能根本不共享模块,3 表示模块是完全线程安全的。值 1 表示线程可以共享模块本身,但不能共享连接(请参阅本章后面的“连接和游标”),值 2 表示线程可以共享模块和连接,但不能共享游标。如果你不使用线程(大多数时候,你可能不会),你根本不用担心这个变量。

参数样式(paramstyle)表示当您让数据库执行多个类似的查询时,如何将参数拼接到 SQL 查询中。值'format'表示标准的字符串格式(使用基本格式代码),因此,例如,您可以在想要拼接参数的地方插入%s。值'pyformat'表示扩展格式代码,用于老式的字典拼接,如%(foo)s。除了这些 Pythonic 风格之外,还有三种编写拼接字段的方式:'qmark'表示使用问号,'numeric'表示形式为:1:2的字段(其中数字是参数的数字),'named'表示类似于:foobar的字段,其中foobar是参数名称。如果参数样式看起来令人困惑,不要担心。对于 basic 程序,你不会需要它们,如果你需要了解一个特定的数据库接口是如何处理参数的,相关文档大概会有解释。

例外

API 定义了几个异常,使得细粒度的错误处理成为可能。然而,它们是在层次结构中定义的,所以您也可以用一个except块捕获几种类型的异常。(当然,如果您希望一切都运行良好,并且不介意万一出现问题时关闭程序,那么您可以完全忽略异常。)

异常等级如表 13-2 所示。异常应该在给定的数据库模块中全局可用。有关这些异常的更深入的描述,请参见 API 规范(前面提到的 PEP)。

表 13-2。

Exceptions Specified in the Python DB API

| 例外 | 超类 | 描述 | | --- | --- | --- | | `StandardError` |   | 所有异常的一般超类 | | `Warning` | `StandardError` | 发生非致命问题时引发 | | `Error` | `StandardError` | 所有错误条件的一般超类 | | `InterfaceError` | `Error` | 与界面而非数据库相关的错误 | | `DatabaseError` | `Error` | 与数据库相关的错误的超类 | | `DataError` | `DatabaseError` | 与数据相关的问题;例如,超出范围的值 | | `OperationalError` | `DatabaseError` | 数据库操作的内部错误 | | `IntegrityError` | `DatabaseError` | 关系完整性受损;例如,密钥检查失败 | | `InternalError` | `DatabaseError` | 数据库中的内部错误;例如无效光标 | | `ProgrammingError` | `DatabaseError` | 用户编程错误;例如,未找到表 | | `NotSupportedError` | `DatabaseError` | 请求了不支持的功能(例如回滚) |

连接和光标

为了使用底层数据库系统,您必须首先连接到它。为此,您可以使用名副其实的函数connect。它需要几个参数;具体哪一个取决于数据库。API 将表 13-3 中的参数定义为指南。建议将它们用作关键字参数,并遵循表中给出的顺序。参数应该都是字符串。

表 13-3。

Common Parameters of the connect Function

| 参数名称 | 描述 | 可选? | | --- | --- | --- | | `dsn` | 数据源名称。具体含义取决于数据库。 | 不 | | `user` | 用户名。 | 是 | | `password` | 用户密码。 | 是 | | `host` | 主机名。 | 是 | | `database` | 数据库名称。 | 是 |

你会在本章后面的“入门”一节以及第二十六章中看到使用connect功能的具体例子。

connect函数返回一个连接对象。这表示您当前与数据库的会话。连接对象支持表 13-4 所示的方法。

表 13-4。

Connection Object Methods

| 方法名称 | 描述 | | --- | --- | | `close()` | 关闭连接。连接对象及其光标现在不可用。 | | `commit()` | 如果支持,提交挂起的事务;否则,不执行任何操作。 | | `rollback()` | 回滚挂起的事务(可能不可用)。 | | `cursor()` | 返回连接的光标对象。 |

rollback方法可能不可用,因为并非所有数据库都支持事务。(事务只是动作的序列。)如果它存在,它将“撤销”任何尚未提交的事务。

commit方法总是可用的,但是如果数据库不支持事务,它实际上什么也不做。如果您关闭了一个连接,但仍有事务未提交,它们将隐式回滚—但前提是数据库支持回滚!所以如果你不想依赖这个,你应该在关闭连接之前提交。如果你提交了,你可能不需要太担心关闭你的连接;垃圾回收时会自动关闭。不过,如果你想安全起见,调用close不会让你损失那么多按键。

cursor方法将我们引向另一个主题:光标对象。您使用游标来执行 SQL 查询并检查结果。游标比连接支持更多的方法,可能会在你的程序中更加突出。表 13-5 给出了光标方法的概述,表 13-6 给出了属性的概述。

表 13-6。

Cursor Object Attributes

| 名字 | 描述 | | --- | --- | | `description` | 结果列描述的序列。只读。 | | `rowcount` | 结果中的行数。只读。 | | `arraysize` | 在`fetchmany`中返回多少行。默认值为 1。 |

表 13-5。

Cursor Object Methods

| 名字 | 描述 | | --- | --- | | `callproc(name[, params])` | 用给定的名称和参数调用命名的数据库过程(可选)。 | | `close()` | 关闭光标。光标现在不可用。 | | `execute(oper[, params])` | 执行 SQL 操作,可能带有参数。 | | `executemany(oper, pseq)` | 对序列中的每个参数集执行 SQL 操作。 | | `fetchone()` | 获取查询结果集的下一行作为一个序列,或`None`。 | | `fetchmany([size])` | 获取查询结果集的几行。默认大小为`arraysize`。 | | `fetchall()` | 将所有(剩余)行作为一系列序列提取。 | | `nextset()` | 跳到下一个可用的结果集(可选)。 | | `setinputsizes(sizes)` | 用于预定义参数的存储区域。 | | `setoutputsize(size[, col])` | 设置提取大数据值的缓冲区大小。 |

这些方法中的一些将在接下来的文本中更详细地解释,而一些(如setinputsizessetoutputsizes)将不被讨论。更多详情请咨询 PEP。

类型

为了正确地与底层 SQL 数据库进行互操作(这可能会对插入到某些类型的列中的值提出各种要求), DB API 定义了用于特殊类型和值的某些构造函数和常量(singletons)。例如,如果您想要向数据库添加一个日期,它应该用(例如)相应数据库连接模块的Date构造函数来构造。这允许连接模块在后台执行任何必要的转换。每个模块都需要实现表 13-7 中所示的构造函数和特殊值。有些模块可能不完全兼容。例如,sqlite3模块(下面讨论)不输出表 13-7 中的特殊值(STRINGROWID)。

表 13-7。

DB API Constructors and Special Values

| 名字 | 描述 | | --- | --- | | `Date(year, month, day)` | 创建保存日期值的对象 | | `Time(hour, minute, second)` | 创建保存时间值的对象 | | `Timestamp(y, mon, d, h, min, s)` | 创建保存时间戳值的对象 | | `DateFromTicks(ticks)` | 创建一个对象,该对象保存自纪元以来刻度的日期值 | | `TimeFromTicks(ticks)` | 创建一个保存刻度时间值的对象 | | `TimestampFromTicks(ticks)` | 从 ticks 创建一个保存时间戳值的对象 | | `Binary(string)` | 创建保存二进制字符串值的对象 | | `STRING` | 描述基于字符串的列类型(如`CHAR`) | | `BINARY` | 描述二进制列(如`LONG`或`RAW`) | | `NUMBER` | 描述数字列 | | `DATETIME` | 描述日期/时间列 | | `ROWID` | 描述行 ID 列 |

SQLite 和 PySQLite

如前所述,许多 SQL 数据库引擎都有相应的 Python 模块。大多数数据库引擎都是作为服务器程序运行的,即使安装它们也需要管理员权限。为了降低使用 Python DB API 的门槛,我选择使用一个名为 SQLite 的小型数据库引擎,它不需要作为独立的服务器运行,可以直接处理本地文件,而不是使用某种集中式数据库存储机制。

在最近的 Python 版本(从 2.5 开始)中,SQLite 的优势在于它的包装器(PySQLite,以sqlite3模块的形式)包含在标准库中。除非您自己从源代码编译 Python,否则很可能数据库本身也包括在内。您可能只想尝试“入门”一节中的程序片段如果它们可以工作,您就不需要费心分别安装 PySQLite 和 SQLite。

Note

如果您没有使用 PySQLite 的标准库版本,您可能需要修改 import 语句。有关更多信息,请参考相关文档。

Getting Pysqlite

如果您使用的是旧版本的 Python,则需要安装 PySQLite,然后才能使用 SQLite 数据库。可以从 https://github.com/ghaering/pysqlite 下载。

对于带有包管理器系统的 Linux 系统,您可以直接从包管理器获得 PySQLite 和 SQLite。你也可以使用 Python 自己的包管理器pip。也可以获得 PySQLite 和 SQLite 的源码包,自己编译。

如果您使用的是 Python 的最新版本,那么您肯定会使用 PySQLite。如果缺少什么,那就是数据库本身,SQLite(但同样,它也可能是可用的)。你可以从 SQLite 的网页上获取源码, http://sqlite.org 。(确保您获得了一个已经执行了自动代码生成的源包。)编译 SQLite 基本上就是按照附带的自述文件中的说明进行操作。当随后编译 PySQLite 时,您需要确保编译过程可以访问 SQLite 库和包含文件。如果您已经在某个标准位置安装了 SQLite,那么很可能 PySQLite 发行版中的安装脚本可以自己找到它。在这种情况下,您只需执行以下命令:

python setup.py build
python setup.py install

您可以简单地使用后一个命令,它将自动执行构建。如果这给你一堆错误信息,安装脚本可能没有找到需要的文件。确保您知道包含文件和库的安装位置,并明确地将它们提供给安装脚本。假设我在名为/home/mlh/sqlite/current的目录中就地编译了 SQLite 那么头文件可以在/home/mlh/sqlite/current/src中找到,库可以在/home/mlh/sqlite/current/build/lib中找到。为了让安装过程使用这些路径,编辑安装脚本setup.py。在这个文件中,你需要设置变量include_dirslibrary_dirs

include_dirs = ['/home/mlh/sqlite/current/src']
library_dirs = ['/home/mlh/sqlite/current/build/lib']

重新绑定这些变量后,前面描述的安装过程应该可以正常工作,不会出现错误。

入门指南

您可以将 SQLite 作为一个模块导入,命名为sqlite3(如果您使用的是 Python 标准库中的模块)。然后,您可以通过提供文件名(可以是文件的相对或绝对路径)直接创建到数据库文件的连接,如果数据库文件不存在,将会创建该连接。

>>> import sqlite3
>>> conn = sqlite3.connect('somedatabase.db')

然后,您可以从这个连接中获得一个光标。

>>> curs = conn.cursor()

然后,可以使用该游标来执行 SQL 查询。完成后,如果您做了任何更改,请确保提交它们,以便它们实际上保存到文件中。

>>> conn.commit()

您可以(也应该)在每次修改数据库时提交,而不仅仅是在准备关闭数据库时。当您准备关闭它时,只需使用close方法。

>>> conn.close()

一个示例数据库应用

作为一个例子,我将演示如何基于美国农业部(USDA)农业研究服务中心( https://www.ars.usda.gov )的数据构建一个小型营养数据库。它们的链接往往会移动一点,但您应该能够找到如下相关数据集。在他们的网页上,找到数据库和数据集页面(应该可以从研究下拉菜单中找到),并跟随链接到营养数据实验室。在这个页面上,你可以找到一个到美国农业部国家营养数据库的链接,在那里你可以找到许多不同的纯文本(ASCII)格式的数据文件,这正是我们喜欢的方式。点击下载链接,下载标题为“缩写”的 ASCII 链接所引用的 zip 文件。您现在应该得到一个 zip 文件,其中包含一个名为ABBREV.txt的文本文件,以及一个描述其内容的 PDF 文件。如果你很难找到这个特定的文件,任何旧的数据都可以。只需修改源代码以适应。

ABBREV.txt文件中的数据每行有一个数据记录,各字段用脱字符号(^)分隔。数值型字段直接包含数字,而文本型字段的字符串值用波浪符号()括起来。以下是一个示例行,为简洁起见删除了一些部分:

∼07276∼^∼HORMEL SPAM ... PORK W/ HAM MINCED  CND∼^ ... ^∼1 serving∼^^∼∼⁰

将这样一行代码解析成单独的字段就像使用line.split('^')一样简单。如果一个字段以波浪号开头,您知道它是一个字符串,可以使用field.strip('∼')来获取它的内容。对于其他(数字)字段,float(field)应该可以做到这一点,当然,当字段为空时除外。在下面几节中开发的程序将把这个 ASCII 文件中的数据转移到您的 SQL 数据库中,并让您对它们执行一些(半)有趣的查询。

Note

这个示例程序非常简单。关于 Python 中数据库使用的更高级的例子,参见第二十六章。

创建和填充表格

要实际创建数据库的表并填充它们,编写一个完全独立的一次性程序可能是最简单的解决方案。您可以运行这个程序一次,然后忘记它和原始数据源(ABBREV.txt文件),尽管保留它们可能是个好主意。

清单 13-1 中所示的程序创建一个名为food的表,其中包含一些适当的字段,读取文件ABBREV.txt,解析它(通过拆分行并使用实用函数convert转换各个字段),并在对curs.execute的调用中使用 SQL INSERT语句将从文本字段读取的值插入数据库。

Note

本来可以使用curs.executemany,提供从数据文件中提取的所有行的列表。在这种情况下,这可能会带来较小的加速,但如果使用联网的客户机/服务器 SQL 系统,可能会带来更大的加速。

import sqlite3

def convert(value):
    if value.startswith('∼'):
       return value.strip('∼')
    if not value:
       value = '0'
    return float(value)

conn = sqlite3.connect('food.db')
curs = conn.cursor()

curs.execute('''
CREATE TABLE food (

id TEXT PRIMARY KEY,
desc    TEXT,
water   FLOAT,
kcal    FLOAT,
protein FLOAT,
fat     FLOAT,
ash     FLOAT,
carbs   FLOAT,
fiber   FLOAT,
sugar   FLOAT
)
''')
query = 'INSERT INTO food VALUES (?,?,?,?,?,?,?,?,?,?)'
field_count = 10

for line in open('ABBREV.txt'):
    fields = line.split('^')
    vals = [convert(f) for f in fields[:field_count]]
    curs.execute(query, vals)

conn.commit()
conn.close()

Listing 13-1.Importing Data into the Database (importdata.py)

Note

在清单 13-1 中,我使用了paramstyle的“qmark”版本,即一个问号作为字段标记。如果您使用的是旧版本的 PySQLite,您可能需要使用%字符来代替。

当你运行这个程序时(与ABBREV.txt在同一个目录下),它会创建一个名为food.db的新文件,包含数据库的所有数据。

我鼓励您使用这个例子,使用其他输入,添加print语句,等等。

搜索和处理结果

使用数据库真的很简单。同样,您创建一个连接并从该连接中获取一个光标。使用execute方法执行 SQL 查询,并使用例如fetchall方法提取结果。清单 13-2 显示了一个小程序,它将 SQL SELECT条件作为命令行参数,并以记录格式打印出返回的行。您可以使用如下命令行进行尝试:

$ python food_query.py "kcal <= 100 AND fiber >= 10 ORDER BY sugar"

运行这个程序时,您可能会注意到一个问题。第一排,生橘皮,好像一点糖都没有。这是因为数据文件中缺少该字段。您可以改进导入脚本来检测这种情况,并插入None而不是一个实际值,以指示丢失的数据。那么您可以使用如下条件:

"kcal <= 100 AND fiber >= 10 AND sugar ORDER BY sugar"

这要求 sugar 字段在任何返回的行中都有真实数据。碰巧的是,这种策略也适用于当前数据库,在当前数据库中,这种条件将丢弃血糖水平为零的行。

您可能想尝试使用 ID 搜索特定食品的条件,比如用08323搜索可可豆。问题是 SQLite 以一种相当不标准的方式处理它的值。实际上,在内部,所有的值都是字符串,并且在数据库和 Python API 之间进行一些转换和检查。通常,这很好,但是这是一个你可能会遇到麻烦的例子。如果您提供值08323,它将被解释为数字8323,并随后被转换为字符串"8323"——一个不存在的 ID。人们可能已经预料到这里会出现一条错误消息,而不是这种令人惊讶且毫无帮助的行为,但是如果您小心谨慎,并且首先使用字符串"08323",您就不会有问题。

import sqlite3, sys

conn = sqlite3.connect('food.db')
curs = conn.cursor()

query = 'SELECT * FROM food WHERE ' + sys.argv[1]
print(query)
curs.execute(query)
names = [f[0] for f in curs.description]
for row in curs.fetchall():
    for pair in zip(names, row):
        print('{}: {}'.format(*pair))
    print()

Listing 13-2.
Food Database Query Program
(food_query.py)

Caution

这个程序接受用户的输入,并将其拼接成一个 SQL 查询。只要用户是您,并且您没有输入任何奇怪的内容,这就没问题。然而,使用这种输入偷偷插入恶意 SQL 代码来扰乱数据库是破解计算机系统的一种常见方式,称为 SQL 注入。除非您知道自己在做什么,否则不要将您的数据库或任何其他东西暴露给原始用户输入。

快速总结

本章简要介绍了如何让 Python 程序与关系数据库进行交互。这很简单,因为如果你掌握了 Python 和 SQL,那么这两者之间的耦合,以 Python DB API 的形式,是很容易掌握的。以下是本章涉及的一些概念:

  • Python DB API:这个 API 提供了一个简单的、标准化的接口,数据库包装器模块应该遵循这个接口,这样就可以更容易地编写适用于多种不同数据库的程序。
  • connections:connection 对象表示与 SQL 数据库的通信链接。通过使用cursor方法,您可以从中获得单个光标。您还可以使用 connection 对象来提交或回滚事务。完成数据库后,可以关闭连接。
  • 游标:游标用于执行查询和检查结果。可以一个接一个或一次检索多个(或全部)结果行。
  • 类型和特殊值:DB API 指定一组构造函数和特殊值的名称。构造函数处理日期和时间对象,以及二进制数据对象。特殊值代表关系数据库的类型,如STRINGNUMBERDATETIME
  • SQLite:这是一个小型的嵌入式 SQL 数据库,它的 Python 包装器包含在标准 Python 发行版中,名为sqlite3。它使用起来又快又简单,而且不需要设置单独的服务器。

本章的新功能

| 功能 | 描述 | | --- | --- | | `connect(...)` | 连接到一个数据库并返回一个连接对象 1 |

什么现在?

持久性和数据库处理是许多(如果不是大多数)大型程序系统的重要部分。许多这样的系统共有的另一个组成部分是网络,这将在下一章中讨论。

Footnotes 1

connect函数的参数取决于数据库。

十四、网络编程

在这一章中,我给出了 Python 可以帮助你编写程序的各种方式的一个例子,这些程序使用网络,比如互联网,作为一个重要的组成部分。Python 是一个非常强大的网络编程工具。有许多用于公共网络协议和它们之上的各种抽象层的库可用,因此您可以专注于程序的逻辑,而不是在线路上混排位。此外,很容易编写代码来处理可能没有现有代码的各种协议格式,因为 Python 非常擅长处理字节流中的模式(您已经在以各种方式处理文本文件中看到了这一点)。

因为 Python 有如此丰富的网络工具可供您使用,所以在这里我只能简单地介绍一下它的网络功能。你可以在这本书的其他地方找到一些例子。第十五章包含了对面向 web 的网络编程的讨论,后面章节中的几个项目使用网络模块来完成这项工作。如果您想了解更多关于 Python 中网络编程的知识,我可以热情地推荐 John Goerzen 的《Python 网络编程基础》( Apress,2004 ),该书非常透彻地论述了这个主题。

在本章中,我将向您概述 Python 标准库中可用的一些网络模块。接下来是对SocketServer类和它的朋友们的讨论,接下来是对可以同时处理几个连接的各种方法的简要介绍。最后,我给大家看一下 Twisted 框架,这是一个用 Python 编写网络化应用的丰富而成熟的框架。

Note

如果你有一个严格的防火墙,它可能会在你开始运行自己的网络程序时发出警告,并阻止它们连接到网络。您应该配置您的防火墙,让您的 Python 完成它的工作,或者,如果防火墙有一个交互界面,当被询问时,简单地允许连接。但是,请注意,任何连接到网络的软件都有潜在的安全风险,即使(或者特别是如果)是您自己编写的软件。

一些网络模块

您可以在标准库中找到大量的网络模块,以及其他地方的更多模块。除了那些明显主要处理网络的模块之外,还有几个模块(例如那些处理各种形式的网络传输数据编码的模块)可以被看作是与网络相关的。在这里,我对模块的选择相当严格。

该插座模块

网络编程中的一个基本组件是套接字。套接字基本上是一个两端都有程序的“信息通道”。这些程序可能在不同的计算机上(通过网络连接),并且可以通过套接字相互发送信息。Python 中的大多数网络编程隐藏了socket模块的基本工作方式,并且不直接与套接字交互。

套接字有两种:服务器套接字和客户端套接字。创建服务器套接字后,您告诉它等待连接。然后,它将监听某个网络地址(IP 地址和端口号的组合),直到客户端套接字连接。然后这两个人就可以交流了。

处理客户端套接字通常比处理服务器端要容易得多,因为无论客户端何时连接,服务器都必须准备好处理客户端,而且它必须处理多个连接,而客户端只是简单地连接、完成它的工作,然后断开连接。在本章的后面,我将通过SocketServer类族和 Twisted 框架讨论服务器编程。

套接字是来自socket模块的socket类的实例。它用最多三个参数进行实例化:一个地址族(默认为socket.AF_INET)、它是流(默认为socket.SOCK_STREAM)还是数据报(socket.SOCK_DGRAM)套接字,以及一个协议(默认为 0,应该没问题)。对于普通的套接字,您不需要提供任何参数。

服务器套接字使用它的bind方法,然后调用listen来监听给定的地址。然后,客户端套接字可以通过使用与在bind中使用的地址相同的connect方法连接到服务器。(例如,在服务器端,您可以使用socket.gethostname功能获取当前机器的名称。)在这种情况下,地址只是一个形式为(host, port)的元组,其中host是主机名(如www.example.com)port是端口号(整数)。listen方法接受一个参数,即其 backlog 的长度(在连接开始被禁止之前,允许排队等待接受的连接数)。

一旦服务器套接字正在侦听,它就可以开始接受客户端。这是使用accept方法完成的。这个方法将阻塞(等待)直到客户端连接,然后它将返回一个形式为(client, address)的元组,其中client是一个客户端套接字,address是一个地址,如前所述。服务器可以以它认为合适的方式处理客户端,然后开始等待新的连接,并再次调用accept。这通常是在无限循环中完成的。

Note

这里讨论的服务器编程形式称为阻塞式或同步网络编程。在本章后面的“多重连接”一节中,您将看到非阻塞或异步网络编程的例子,以及使用线程同时处理几个客户机的例子。

对于传输数据,套接字有两种方法:sendrecv(对于“接收”)。您可以用一个字符串参数调用send来发送数据,用一个期望的(最大)字节数调用recv来接收数据。如果你不确定用哪个数字,1024 是最好的选择。

清单 14-1 和 14-2 展示了一个非常简单的客户机/服务器对的例子。如果在同一台机器上运行它们(首先启动服务器),服务器应该打印出一条关于获得连接的消息,然后客户机应该打印出一条从服务器收到的消息。当服务器仍在运行时,您可以运行多个客户端。通过将客户机中对gethostname的调用替换为运行服务器的机器的实际主机名,您可以让两个程序通过网络从一台机器连接到另一台机器。

Note

您使用的端口号通常受到限制。在 Linux 或 UNIX 系统中,您需要管理员权限才能使用 1024 以下的端口。这些编号较低的端口用于标准服务,例如 web 服务器的端口 80(如果有)。此外,例如,如果您使用 Ctrl+C 停止服务器,您可能需要等待一段时间才能再次使用相同的端口号(您可能会得到“地址已在使用中”错误)。

import socket

s = socket.socket()

host = socket.gethostname()
port = 1234
s.bind((host, port))

s.listen(5)
while True:

    c, addr = s.accept()
    print('Got connection from', addr)
    c.send('Thank you for connecting')
    c.close()

Listing 14-1.A Minimal Server

import socket

s = socket.socket()

host = socket.gethostname()
port = 1234

s.connect((host, port))
print(s.recv(1024))

Listing 14-2.A Minimal Client

你可以在 Python 库参考和 Gordon McMillan 的 Socket 编程 HOWTO ( http://docs.python.org/dev/howto/sockets.html )中找到更多关于socket模块的信息。

urllib 和 urllib2 模块

在可用的网络库中,可能最划算的是urlliburllib2。它们使您能够通过网络访问文件,就像它们位于您的计算机上一样。通过一个简单的函数调用,几乎任何可以用统一资源定位器(URL)引用的东西都可以用作程序的输入。想象一下,如果你把这个和re模块结合起来,你会得到什么样的可能性:你可以下载网页,提取信息,并为你的发现创建自动报告。

这两个模块做的工作或多或少是一样的,只是urllib2有点“花哨”对于简单的下载,urllib是完全可以的。如果你需要 HTTP 认证或 cookies,或者你想编写扩展来处理你自己的协议,那么urllib2可能是你的正确选择。

打开远程文件

您可以像打开本地文件一样打开远程文件;不同的是,你只能使用读取模式,而不是open(或file),你使用来自urllib.request模块的urlopen

>>> from urllib.request import urlopen
>>> webpage = urlopen('http://www.python.org')

如果您在线,变量webpage现在应该包含一个类似文件的对象,链接到位于 http://www.python.org 的 Python 网页。

Note

如果你想尝试使用urllib,但目前不在线,你可以使用以file:开头的 URL 访问本地文件,比如file:c:\text\somefile.txt。(记得躲开你的反斜杠。)

urlopen返回的类似文件的对象支持closereadreadlinereadlines方法,以及迭代。

假设您想要提取刚刚打开的 Python 页面上“About”链接的(相对)URL。你可以用正则表达式做到这一点(关于正则表达式的更多信息,参见第十章中关于re模块的部分)。

>>> import re
>>> text = webpage.read()
>>> m = re.search(b'<a href="([^"]+)" .*?>about</a>', text, re.IGNORECASE)
>>> m.group(1)
'/about/'

Note

当然,如果 web 页面在编写之后发生了变化,您可能需要修改正则表达式。

检索远程文件

urlopen函数给了你一个可以读取的类似文件的对象。如果你想让urllib帮你下载文件,在本地文件中保存一份拷贝,你可以用urlretrieve来代替。它不是返回一个类似文件的对象,而是返回一个元组(filename, headers),其中filename是本地文件的名称(这个名称是由urllib自动创建的),而headers包含一些关于远程文件的信息。(这里我就忽略headers;如果你想了解更多,请查阅urllib的标准库文档。)如果您想为下载的副本指定一个文件名,您可以将它作为第二个参数提供。

urlretrieve('http://www.python.org', 'C:\\python_webpage.html')

这将检索 Python 主页并将其存储在文件C:\python_webpage.html中。如果不指定文件名,文件会被放在某个临时位置,供您打开(使用open函数),但是当您使用完它时,您可能希望将其删除,这样它就不会占用您的硬盘空间。要清理这样的临时文件,您可以不带任何参数地调用函数urlcleanup,它会为您处理好一切。

Some Utilities

除了通过 URL 读取和下载文件,urllib还提供了一些操作 URL 本身的功能。(以下假设对 URL 和 CGI 有一定的了解。)以下功能可用:

  • quote(string[, safe]):返回一个字符串,其中所有的特殊字符(在 URL 中有特殊意义的字符)都被 URL 友好的版本所替换(比如用%7E代替)。如果您有一个可能包含此类特殊字符的字符串,并且您想将它用作 URL,这将非常有用。安全字符串包含不应该这样编码的字符。默认为'/'
  • quote_plus(string[, safe]):类似于引号,但也用加号替换空格。
  • unquote(string):与quote相反。
  • unquote_plus(string):与quote_plus相反。
  • urlencode(query[, doseq]):将一个映射(如字典)或一个二元元组序列(形式为(key,value))转换为“URL 编码的”字符串,该字符串可用于 CGI 查询。(有关更多信息,请查看 Python 文档。)

其他模块

如上所述,除了本章明确讨论的模块,Python 库中和其他地方还有大量与网络相关的模块。表 14-1 列出了 Python 标准库中的一些网络相关模块。如表中所示,其中一些模块在本书的其他地方讨论过。

表 14-1。

Some Network-Related Modules in the Standard Library

| 组件 | 描述 | | --- | --- | | `asynchat` | `asyncore`的附加功能(参见第二十四章) | | `asyncore` | 异步套接字处理程序(参见第二十四章 | | `cgi` | 基本 CGI 支持(参见第十五章 | | `Cookie` | Cookie 对象操作,主要用于服务器 | | `cookielib` | 客户端 cookie 支持 | | `email` | 支持电子邮件(包括 MIME) | | `ftplib` | FTP 客户端模块 | | `gopherlib` | Gopher 客户端模块 | | `httplib` | HTTP 客户端模块 | | `imaplib` | IMAP4 客户端模块 | | `mailbox` | 阅读几种邮箱格式 | | `mailcap` | 通过 mailcap 文件访问 MIME 配置 | | `mhlib` | 访问 MH 邮箱 | | `nntplib` | NNTP 客户端模块(参见第二十三章 | | `poplib` | POP 客户端模块 | | `robotparser` | 支持解析 web 服务器机器人文件 | | `SimpleXMLRPCServer` | 一个简单的 XML-RPC 服务器(见第二十七章) | | `smtpd` | SMTP 服务器模块 | | `smtplib` | SMTP 客户端模块 | | `telnetlib` | Telnet 客户端模块 | | `urlparse` | 支持解释 URL | | `xmlrpclib` | XML-RPC 的客户端支持(参见第二十七章 |

SocketServer 和朋友

正如您在前面关于socket模块的章节中看到的,编写一个简单的套接字服务器并不困难。然而,如果你想超越基础,获得一些帮助会很好。SocketServer模块是标准库中几个服务器框架的基础,包括BaseHTTPServerSimpleHTTPServerCGIHTTPServerSimpleXMLRPCServerDocXMLRPCServer,所有这些都为基础服务器增加了各种特定的功能。

SocketServer包含四个基本类:TCPServer,用于 TCP 套接字流;UDPServer,用于 UDP 数据报套接字;还有更隐晦的UnixStreamServerUnixDatagramServer。你可能不需要最后三个。

要使用SocketServer框架编写服务器,您需要将大部分代码放在请求处理程序中。每当服务器收到一个请求(来自客户机的连接)时,就会实例化一个请求处理程序,并在其上调用各种处理程序方法来处理请求。具体调用哪些方法取决于所使用的特定服务器和处理程序类,您可以对它们进行子类化,使服务器调用一组自定义的处理程序。基本的BaseRequestHandler类将所有的动作放在处理程序的一个方法中,这个方法叫做handle,由服务器调用。然后,这个方法可以访问属性self.request中的客户端套接字。如果您正在处理一个流(如果您使用TCPServer,您可能就是这样),您可以使用类StreamRequestHandler,它设置了另外两个属性self.rfile(用于读取)和self.wfile(用于写入)。然后,您可以使用这些类似文件的对象与客户端进行通信。

SocketServer框架中的各种其他类实现了对 HTTP 服务器的基本支持,包括运行 CGI 脚本,以及对 XML-RPC 的支持(在第二十七章中讨论)。

清单 14-3 给出了清单 14-1 中最小服务器的SocketServer版本。它可以与清单 14-2 中的客户端一起使用。请注意,StreamRequestHandler负责在连接被处理后关闭连接。还要注意,将''作为主机名意味着您指的是运行服务器的机器。

from socketserver import TCPServer, StreamRequestHandler

class Handler(StreamRequestHandler):

    def handle(self):
        addr = self.request.getpeername()
        print('Got connection from', addr)
        self.wfile.write('Thank you for connecting')

server = TCPServer(('', 1234), Handler)
server.serve_forever()

Listing 14-3.A SocketServer-Based Minimal Server

您可以在《Python 库参考》和 John Goerzen 的《Python 网络编程基础》(Apress,2004)中找到关于SocketServer框架的更多信息。

多重连接

到目前为止讨论的服务器解决方案都是同步的:一次只有一个客户机可以连接并处理它的请求。如果一个请求需要一点时间,比如一个完整的聊天会话,重要的是可以同时处理多个连接。

您可以通过三种主要方式处理多个连接:分叉、线程化和异步 I/O。分叉和线程化可以非常简单地处理,通过使用任何SocketServer服务器的混合类(参见清单 14-4 和 14-5 )。即使您想自己实现它们,这些方法也很容易使用。然而,它们也有缺点。分叉会占用资源,如果您有许多客户端,可能无法很好地伸缩(尽管对于合理数量的客户端,在现代的 UNIX 或 Linux 系统上,分叉是非常有效的,如果您有一个多 CPU 系统,甚至会更有效)。线程可能会导致同步问题。在这里,我不会详细讨论这些问题(也不会深入讨论多线程),但是在接下来的几节中,我将向您展示如何使用这些技术。

Forks? Threads? What’s All This, Then?

万一你不知道分叉或线程,这里有一点澄清。分叉是一个 UNIX 术语。当您派生一个进程(一个正在运行的程序)时,您基本上复制了它,两个结果进程都从当前执行点继续运行,每个进程都有自己的内存副本(变量等)。一个进程(原始进程)将是父进程,而另一个进程(副本)将是子进程。如果你是科幻迷,你可能会想到平行宇宙;分叉操作在时间线中创建了一个分叉,最终你会得到两个独立存在的宇宙(两个过程)。幸运的是,这些进程能够确定它们是原始进程还是子进程(通过查看 fork 函数的返回值),因此它们可以采取不同的行动。(如果他们不能,那还有什么意义呢?这两个进程会做同样的工作,你只会让你的计算机陷入困境。)

在分叉服务器中,每个客户端连接都有一个子连接被分叉。父进程继续监听新的连接,而子进程处理客户端。当客户端满意时,子进程简单地退出。因为分叉的进程是并行运行的,所以客户端不需要相互等待。

因为分叉可能有点资源密集(每个分叉的进程都需要自己的内存),所以存在一种替代方法:线程化。线程是轻量级进程或子进程,它们都存在于同一个(真正的)进程中,共享相同的内存。不过,这种资源消耗的减少也带来了负面影响。因为线程共享内存,所以您必须确保它们不会互相干扰变量,或者试图同时修改相同的内容,从而造成混乱。这些问题属于“同步”的范畴有了现代的操作系统(除了微软 Windows,不支持 forking),forking 其实挺快的,现代的硬件对资源消耗的处理也比以前好很多。如果您不想为同步问题而烦恼,那么分叉可能是一个不错的选择。

然而,最好的办法可能是完全避免这种并行性。在本章中,您将找到基于选择功能的其他解决方案。另一种避免线程和分叉的方法是切换到无栈 Python ( http://stackless.com ),这是一个 Python 版本,旨在能够快速、无痛地在不同上下文之间切换。它支持一种类似线程的并行形式,称为微线程,比真正的线程伸缩性好得多。比如 EVE Online ( http://www.eve-online.com )中已经使用了无栈 Python 微线程来服务成千上万的用户。

异步 I/O 在底层实现起来有点困难。基本机制是select模块的select函数(在“带选择和轮询的异步 I/O”一节中有描述),这个函数很难处理。幸运的是,已经存在在更高层次上处理异步 I/O 的框架,为您提供了一个简单、抽象的接口来实现一个非常强大且可伸缩的机制。包含在标准库中的这种基本框架由第二十四章中讨论的asyncoreasynchat模块组成。Twisted(本章最后讨论)是一个非常强大的异步网络编程框架。

使用 SocketServer 进行分叉和线程处理

SocketServer框架创建分叉或线程服务器是如此简单,几乎不需要任何解释。清单 14-4 和 14-5 分别向您展示了如何从清单 14-3 分叉和线程化制作服务器。仅当handle方法需要很长时间才能完成时,分叉或线程行为才有用。请注意,分叉在 Windows 中不起作用。

from socketserver import TCPServer, ForkingMixIn, StreamRequestHandler

class Server(ForkingMixIn, TCPServer): pass

class Handler(StreamRequestHandler):

    def handle(self):
        addr = self.request.getpeername()
        print('Got connection from', addr)
        self.wfile.write('Thank you for connecting')

server = Server(('', 1234), Handler)
server.serve_forever()

Listing 14-4.A Forking Server

from socketserver import TCPServer, ThreadingMixIn, StreamRequestHandler

class Server(ThreadingMixIn, TCPServer): pass

class Handler(StreamRequestHandler):

    def handle(self):
        addr = self.request.getpeername()
        print('Got connection from', addr)
        self.wfile.write('Thank you for connecting')

server = Server(('', 1234), Handler)
server.serve_forever()

Listing 14-5.A Threading Server

带选择和轮询的异步 I/O

当服务器与客户机通信时,它从客户机接收的数据可能是断断续续的。如果你使用分叉和线程,这不是一个问题。当一个 parallel 等待数据时,其他 parallel 可能会继续处理自己的客户端。然而,另一种方法是只与在特定时刻确实有话要说的客户打交道。你甚至不需要听完它们——你只需要听一点点(或者说,读一点点),然后把它放回和其他的一样。

这是框架asyncore / asynchat(参见第章 24 )和 Twisted(参见下一节)所采用的方法。这种功能的基础是select函数或poll函数,两者都来自select模块。两者中,poll的可伸缩性更强,但它只在 UNIX 系统中可用(即在 Windows 中不可用)。

select函数将三个序列作为它的强制参数,将一个以秒为单位的可选超时作为它的第四个参数。这些序列是文件描述符整数(或带有返回这种整数的fileno方法的对象)。这些是我们正在等待的联系。这三个序列分别用于输入、输出和异常情况(错误等)。如果没有给出超时,select会阻塞(即等待),直到其中一个文件描述符准备好执行操作。如果给定了超时,select最多阻塞那么多秒,0 表示直接轮询(即没有阻塞)。select返回三个序列(一个三元组,即长度为三的元组),每个序列代表对应参数的一个活动子集。例如,返回的第一个序列将是一个输入文件描述符序列,其中有要读取的内容。

例如,序列可以包含文件对象(不在 Windows 中)或套接字。清单 14-6 显示了一个服务器使用select来服务几个连接。(注意,服务器套接字本身被提供给select,以便它可以在有新的连接准备好被接受时发出信号。)服务器是一个简单的记录器,它(在本地)打印出从其客户机接收到的所有数据。您可以通过使用 telnet 连接到它来测试它(或者通过编写一个简单的基于套接字的客户机来提供一些数据)。尝试用多个 telnet 连接进行连接,看看它是否可以同时为多个客户端提供服务(尽管它的日志将是来自两个客户端的输入的混合)。

import socket, select

s = socket.socket()

host = socket.gethostname()
port = 1234
s.bind((host, port))

s.listen(5)
inputs = [s]
while True:
     rs, ws, es = select.select(inputs, [], [])
     for r in rs:
          if r is s:
               c, addr = s.accept()
               print('Got connection from', addr)
               inputs.append(c)

     else:
          try:
               data = r.recv(1024)
               disconnected = not data
          except socket.error:
               disconnected = True

          if disconnected:
               print(r.getpeername(), 'disconnected')
               inputs.remove(r)
          else:
               print(data)

Listing 14-6.A Simple Server Using select

poll方法比select更容易使用。当您调用poll时,您会得到一个投票对象。然后,您可以使用 poll 对象的register方法向其注册文件描述符(或使用fileno方法的对象)。您可以稍后使用unregister方法再次移除这些对象。一旦您注册了一些对象(例如,套接字),您就可以调用poll方法(带有一个可选的超时参数)并获得一个形式为(fd, event)的成对列表(可能为空),其中fd是文件描述符,event告诉您发生了什么。这是一个位掩码,意味着它是一个整数,其中各个位对应于各种事件。各种事件是select模块的常量,在表 14-2 中有解释。要检查给定的位是否置位(即,给定的事件是否发生),可以使用按位 and 运算符(&),如下所示:

表 14-2。

Polling Event Constants in the select Module

| 事件名称 | 描述 | | --- | --- | | `POLLIN` | 从文件描述符中可以读取数据。 | | `POLLPRI` | 有紧急数据要从文件描述符中读取。 | | `POLLOUT` | 文件描述符已为数据做好准备,如果写入,将不会阻塞。 | | `POLLERR` | 一些错误条件与文件描述符相关联。 | | `POLLHUP` | 挂了。连接已经断开。 | | `POLLNVAL` | 无效请求。连接没有打开。 |
if event & select.POLLIN: ...

清单 14-7 中的程序是对清单 14-6 中服务器的重写,现在使用poll而不是select。注意,我已经添加了一个从文件描述符(ints)到套接字对象的映射(fdmap)。

import socket, select

s = socket.socket()

host = socket.gethostname()
port = 1234
s.bind((host, port))

fdmap = {s.fileno(): s}

s.listen(5)
p = select.poll()
p.register(s)
while True:

    events = p.poll()
    for fd, event in events:
        if fd in fdmap:
            c, addr = s.accept()
            print('Got connection from', addr)
            p.register(c)
            fdmap[c.fileno()] = c
        elif event & select.POLLIN:
            data = fdmap[fd].recv(1024)
            if not data: # No data -- connection closed
                print(fdmap[fd].getpeername(), 'disconnected')
                p.unregister(fd)
                del fdmap[fd]
            else:
                print(data)

Listing 14-7.A Simple Server Using poll

你可以在 Python 库参考( http://python.org/doc/lib/module-select.html )中找到更多关于selectpoll的信息。此外,阅读标准库模块asyncoreasynchat的源代码(可以在 Python 安装的asyncore.pyasynchat.py文件中找到)可能会有所启发。

扭曲的

Twisted,来自 Twisted Matrix Laboratories(http://twistedmatrix.com),是 Python 的一个事件驱动的网络框架,最初是为网络游戏开发的,现在被各种网络软件使用。在 Twisted 中,你实现事件处理程序,就像你在 GUI 工具包中做的一样(见第十二章)。事实上,Twisted 与几种常见的 GUI 工具包(Tk、GTK、Qt 和 wxWidgets)配合得非常好。在这一节中,我将介绍一些基本概念,并向您展示如何使用 Twisted 进行一些相对简单的网络编程。一旦掌握了基本概念,您就可以查阅 Twisted 文档(在 Twisted 网站上可以找到,还有相当多的其他信息)来进行一些更严肃的网络编程。Twisted 是一个非常丰富的框架,支持 web 服务器和客户端、SSH2、SMTP、POP3、IMAP4、AIM、ICQ、IRC、MSN、Jabber、NNTP、DNS 等等。

Note

在撰写本文时,Twisted 的全部功能仅在 Python 2 中可用,尽管该框架越来越多的部分正在被移植到 Python 3。本节剩余部分中的代码示例使用 Python 2.7。

下载和安装 Twisted

安装 Twisted 相当容易。首先,去扭曲矩阵网站( http://twistedmatrix.com ),从那里,跟随其中一个下载链接。如果您使用的是 Windows,请下载适用于您的 Python 版本的 Windows 安装程序。如果您使用的是其他系统,请下载一个源文件。(如果你用的是 Portage、RPM、APT、Fink 或者 MacPorts 之类的包管理器,你大概可以让它直接下载安装 Twisted。)Windows installer 是一个不言自明的分步向导。编译和解包可能需要一些时间,但你所要做的就是等待。要安装源归档文件,首先要解压它(使用tar,然后使用gunzipbunzip2,这取决于您下载的归档文件的类型),然后运行 Distutils 脚本。

python setup.py install

然后,您应该能够使用 Twisted。

编写扭曲的服务器

本章前面写的基本套接字服务器非常清楚。其中一些有明确的事件循环,寻找新的连接和新的数据。基于SocketServer的服务器有一个隐式的循环,其中服务器寻找连接并为每个连接创建一个处理程序,但是处理程序仍然必须明确地尝试读取数据。Twisted(像第asyncore / asynchat框架,在第二十四章中讨论)使用了一种更加基于事件的方法。要编写一个基本的服务器,您需要实现事件处理程序来处理诸如新客户端连接、新数据到达、客户端断开连接(以及许多其他事件)等情况。专门化的类可以在基本事件的基础上构建更精细的事件,比如包装“数据到达”事件,收集数据直到发现一个新行,然后分派一个“数据行到达”事件。

Note

有一件事我在本节中没有涉及,但却是 Twisted 的一个特征,那就是延迟和延迟执行的概念。有关更多信息,请参见 Twisted 文档(例如,可以从 Twisted 文档的 HOWTO 页面获得名为“延迟是美好的”的教程)。

您的事件处理程序是在协议中定义的。您还需要一个工厂,当新连接到达时,它可以构造这样的协议对象。如果您只想创建自定义协议类的实例,可以使用 Twisted 附带的工厂,即模块twisted.internet.protocol中的Factory类。当你写你的协议时,使用和你的超类来自同一个模块的Protocol。当您获得一个连接时,事件处理程序connectionMade被调用。当你失去一个连接,connectionLost就会被调用。通过处理器dataReceived从客户端接收数据。当然,您不能使用事件处理策略将数据发送回客户端——为此,您可以使用具有write方法的对象self.transport。它还有一个client属性,包含客户端地址(主机名和端口)。

清单 14-8 包含清单 14-6 和 14-7 中服务器的扭曲版本。我希望你同意扭曲的版本更简单,可读性更好。这涉及到一点点设置;您需要实例化Factory并设置它的protocol属性,以便它知道在与客户端通信时使用哪个协议(即您的自定义协议)。

然后,您开始在一个给定的端口监听,该工厂通过实例化协议对象来处理连接。您可以使用reactor模块中的listenTCP函数来完成此操作。最后,通过从同一个模块调用run函数来启动服务器。

from twisted.internet import reactor
from twisted.internet.protocol import Protocol, Factory

class SimpleLogger(Protocol):

    def connectionMade(self):
        print('Got connection from', self.transport.client)

    def connectionLost(self, reason):
        print(self.transport.client, 'disconnected')

    def dataReceived(self, data):
        print(data)

factory = Factory()
factory.protocol = SimpleLogger

reactor.listenTCP(1234, factory)
reactor.run()

Listing 14-8.A Simple Server Using Twisted

如果您使用 telnet 连接到该服务器进行测试,您可能会在每行输出中得到一个字符,这取决于缓冲等。您可以简单地使用sys.sout.write而不是print,但是在许多情况下,您可能希望一次得到一行,而不仅仅是任意的数据。为您编写一个处理这个问题的自定义协议会非常容易,但是事实上,已经有这样一个类可用了。模块twisted.protocols.basic包含几个有用的预定义协议,其中有LineReceiver。每当接收到一整行时,它实现dataReceived并调用事件处理程序lineReceived

Tip

如果在接收数据时除了使用lineReceived之外还需要做一些事情,这依赖于dataReceivedLineReceiver实现,那么可以使用由LineReceiver定义的新事件处理程序rawDataReceived

切换协议只需要最少的工作。清单 14-9 显示了结果。如果您在运行这个服务器时查看结果输出,您会看到换行符被去掉了;换句话说,使用print不会再给你双换行符。

from twisted.internet import reactor
from twisted.internet.protocol import Factory
from twisted.protocols.basic import LineReceiver

class SimpleLogger(LineReceiver):

    def connectionMade(self):
        print('Got connection from', self.transport.client)

    def connectionLost(self, reason):
        print(self.transport.client, 'disconnected')

    def lineReceived(self, line):
        print(line)

factory = Factory()
factory.protocol = SimpleLogger

reactor.listenTCP(1234, factory)
reactor.run()

Listing 14-9.An Improved Logging Server, Using the LineReceiver Protocol

正如前面提到的,Twisted 框架有比我在这里展示的更多的东西。如果你有兴趣了解更多,你应该查看在线文档,可以在 Twisted 网站( http://twistedmatrix.com )获得。

快速总结

这一章已经让你体验了几种用 Python 进行网络编程的方法。您选择哪种方法将取决于您的特定需求和偏好。一旦你做出选择,你很可能需要学习更多关于具体方法的知识。以下是本章涉及的一些主题:

  • 套接字和套接字模块:套接字是让程序(进程)可以通过网络进行通信的信息通道。socket模块提供了对客户机和服务器套接字的低级访问。服务器套接字在给定的地址监听客户端连接,而客户端只是直接连接。
  • urllib 和 urllib2:这些模块允许您从各种服务器读取和下载数据,给定数据源的 URL。urllib模块是一个更简单的实现,而urllib2是非常可扩展和强大的。两者都通过简单的函数工作,比如urlopen
  • SocketServer 框架:这是一个由同步服务器基类组成的网络,可以在标准库中找到,它让您可以非常容易地编写服务器。甚至还支持带有 CGI 的简单 web (HTTP)服务器。如果你想同时处理几个连接,你需要使用一个分叉或者线程混合类。
  • select 和 poll:这两个函数让您考虑一组连接,并找出哪些可以读写。这意味着您可以以循环的方式逐个为几个连接提供服务。这给人一种同时处理几个连接的错觉,尽管表面上代码有点复杂,但这是一个比线程或分叉更具可扩展性和效率的解决方案。
  • Twisted:这个框架来自 Twisted Matrix Laboratories,非常丰富和复杂,支持大多数主要的网络协议。尽管它很大,使用的一些习惯用法可能看起来有点陌生,但基本用法非常简单和直观。Twisted 框架也是异步的,所以它非常高效和可伸缩。如果您有 Twisted,它可能是许多定制网络应用的最佳选择。

本章的新功能

| 功能 | 描述 | | --- | --- | | `urllib.urlopen(url[, data[, proxies]])` | 从 URL 打开类似文件的对象 | | `urllib.urlretrieve(url[, fname[, hook[, data]]])` | 从 URL 下载文件 | | `urllib.quote(string[, safe])` | 引用特殊的 URL 字符 | | `urllib.quote_plus(string[, safe])` | 与`quote`相同,但将空格引为`+` | | `urllib.unquote(string)` | `quote`的反面 | | `urllib.unquote_plus(string)` | `quote_plus`的反面 | | `urllib.urlencode(query[, doseq])` | 编码用于 CGI 查询的映射 | | `select.select(iseq, oseq, eseq[, timeout])` | 找到准备好读/写的套接字 | | `select.poll()` | 为轮询套接字创建一个轮询对象 | | `reactor.listenTCP(port, factory)` | 扭曲函数;监听连接 | | `reactor.run()` | 扭曲函数;主服务器循环 |

什么现在?

你以为我们已经完成了网络的工作,是吗?没门儿。下一章讨论的是网络世界中一个非常专业且广为人知的实体:网络。

十五、Python 和 Web

这一章讲述了用 Python 进行 web 编程的一些方面。这是一个非常广阔的领域,但是我选择了三个主要的主题供你娱乐:屏幕抓取、CGI 和 mod_python。

此外,我还会为您提供一些指导,帮助您找到更高级的 web 应用和 web 服务开发的合适工具包。有关使用 CGI 的扩展示例,请参见第 25 和 26 章。有关使用特定 web 服务协议 XML-RPC 的示例,请参见第二十七章。

屏幕抓取

屏幕抓取是程序下载网页并从中提取信息的过程。这是一种有用的技术,只要在线页面上有您希望在程序中使用的信息,就可以使用这种技术。当然,如果所讨论的网页是动态的,也就是说,如果它随时间而变化,那么它就特别有用。否则,您可以只下载一次,然后手动提取信息。(当然,理想的情况是通过 web 服务获得信息,这将在本章后面讨论。)

从概念上讲,这种技术非常简单。你下载数据并分析它。例如,你可以简单地使用urllib,获取网页的 HTML 源代码,然后使用正则表达式(见第十章)或其他技术来提取信息。举例来说,假设您想从 Python Job Board 的 http://python.org/jobs 中提取各种雇主名称和网站。您浏览源代码,发现名称和 URL 可以像这样的链接找到:

<a href="/jobs/1970/">Python Engineer</a>

清单 15-1 显示了一个使用urllibre提取所需信息的示例程序。

from urllib.request import urlopen
import re
p = re.compile(’<a href="(/jobs/\\d+)/">(.*?)</a>’)
text = urlopen(’http://python.org/jobs’).read().decode()
for url, name in p.findall(text):
    print(’{} ({})’.format(name, url))
Listing 15-1.A Simple Screen-Scraping Program

代码当然可以改进,但它做得很好。然而,这种方法至少有三个缺点。

  • 正则表达式不完全可读。对于更复杂的 HTML 代码和更复杂的查询,表达式会变得更复杂,更难维护。
  • 它不处理 HTML 的特性,比如 CDATA 部分和字符实体(比如&amp;)。如果你遇到这样的野兽,程序将很有可能失败。
  • 正则表达式依赖于 HTML 源代码中的细节,而不是一些更抽象的结构。这意味着网页结构的微小变化都会破坏程序。(当你读到这里的时候,它可能已经坏了。)

下面几节讨论了基于正则表达式的方法所带来的问题的两种可能的解决方案。第一种是使用一个名为 Tidy 的程序(作为一个 Python 库)和 XHTML 解析。第二种是用一个叫美汤的库,专门用来刮屏的。

Note

使用 Python 还有其他的屏幕抓取工具。举例来说,你可能会想看看贾平凹的scrape.py(在 http://zesty.ca/python 找到的)。

Tidy 和 XHTML 解析

Python 标准库为解析 HTML 和 XML 等结构化格式提供了大量支持(参见 Python 库参考的“结构化标记处理工具”一节)。我将在第二十二章中更深入地讨论 XML 和 XML 解析。在这一节中,我只向您提供处理 XHTML 所需的工具,XHTML 是 HTML 5 规范描述的两种具体语法之一,它恰好是 XML 的一种形式。所描述的大部分内容应该同样适用于普通的 HTML。

如果每个 web 页面都包含正确有效的 XHTML,那么解析它的工作将会非常简单。问题是更老的 HTML 方言有点草率,有些人甚至不在乎那些草率方言的苛责。原因可能是,大多数 web 浏览器都相当宽容,会尽可能地呈现哪怕是最混乱、最无意义的 HTML。如果页面作者认为这是可以接受的,他们可能会感到满意。不过,这确实让屏幕抓取工作变得更加困难。

在标准库中解析 HTML 的一般方法是基于事件的;您可以编写事件处理程序,在解析器处理数据时调用它们。标准库模块html.parser将允许您以这种方式解析非常松散的 HTML,但是如果您想要基于文档结构提取数据(例如第二个二级标题之后的第一项),您将需要做一些大量的猜测,例如是否有丢失的标签。如果你愿意,当然欢迎你这样做,但是还有另一种方法:整洁。

什么是整洁?

Tidy 是一个用于修复格式不良和松散的 HTML 的工具。它可以以一种相当智能的方式修复一系列常见错误,完成许多您可能不愿意自己做的工作。它也是非常可配置的,允许你打开或关闭各种修正。

这是一个充满错误的 HTML 文件的例子,其中一些只是老式的 HTML,还有一些是完全错误的(你能发现所有的问题吗?):

<h1>Pet Shop
<h2>Complaints</h3>

<p>There is <b>no <i>way</b> at all</i> we can accept returned
parrots.

<h1><i>Dead Pets</h1>

<p>Our pets may tend to rest at times, but rarely die within the
warranty period.

<i><h2>News</h2></i>

<p>We have just received <b>a really nice parrot.

<p>It’s really nice.</b>

<h3><hr>The Norwegian Blue</h3>

<h4>Plumage and <hr>pining behavior</h4>
<a href="#norwegian-blue">More information<a>

<p>Features:
<body>
<li>Beautiful plumage

以下是 Tidy 修复的版本:

<!DOCTYPE html>
<html>
<head>
<title></title>
</head>
<body>
<h1>Pet Shop</h1>
<h2>Complaints</h2>
<p>There is <b>no <i>way</i></b> <i>at all</i> we can accept
returned parrots.</p>
<h1><i>Dead Pets</i></h1>
<p><i>Our pets may tend to rest at times, but rarely die within the
warranty period.</i></p>
<h2><i>News</i></h2>
<p>We have just received <b>a really nice parrot.</b></p>
<p><b>It’s really nice.</b></p>
<hr>
<h3>The Norwegian Blue</h3>
<h4>Plumage and</h4>
<hr>
<h4>pining behavior</h4>
<a href="#norwegian-blue">More information</a>
<p>Features:</p>
<ul>
<li>Beautiful plumage</li>
</ul>
</body>
</html>

当然,Tidy 不能解决一个 HTML 文件的所有问题,但是它确实确保了它的格式良好(也就是说,所有元素都正确嵌套),这使得解析它变得容易得多。

变得整洁

Tidy 库有几个 Python 包装器,哪一个是最新的看起来有点不同。如果您正在使用 pip,您可以使用以下命令查看您的选项:

$ pip search tidy

PyTidyLib 是一个不错的选择,您可以按如下方式安装它:

$ pip install pytidylib

不过,您不必为这个库安装包装器。如果您运行的是某种 UNIX 或 Linux 机器,那么您很可能有 Tidy 的命令行版本。不管你用的是什么操作系统,你都可以从 Tidy 网站( http://html-tidy.org )获得一个可执行的二进制文件。一旦你有了二进制版本,你就可以使用subprocess模块(或者一些popen函数)来运行 Tidy 程序。例如,假设您有一个名为messy.html的混乱的 HTML 文件,并且您的执行路径中有 Tidy 的命令行版本,下面的程序将在其上运行 Tidy 并打印结果:

from subprocess import Popen, PIPE

text = open(’messy.html’).read()
tidy = Popen(’tidy’, stdin=PIPE, stdout=PIPE, stderr=PIPE)

tidy.stdin.write(text.encode())
tidy.stdin.close()

print(tidy.stdout.read().decode())

如果Popen找不到tidy,您可能想要提供可执行文件的完整路径。

实际上,您很可能会从中提取一些有用的信息,而不是打印结果,如下面几节所示。

但是为什么是 XHTML 呢?

XHTML 和旧形式 HTML 的主要区别(至少对于我们当前的目的来说)是,XHTML 对显式关闭所有元素非常严格。所以在 HTML 中,你可以通过开始另一个段落来结束一个段落(用一个<p>标签),但是在 XHTML 中,你首先需要明确地结束一个段落(用一个</p>标签)。这使得 XHTML 更容易解析,因为您可以直接判断何时进入或离开各种元素。XHTML 的另一个优势(在本章中我不会充分利用)是它是一种 XML 方言,所以你可以在它上面使用各种漂亮的 XML 工具,比如 XPath。(关于 XML 的更多信息,请参见第二十二章;有关 XPath 用法的更多信息,请参见例如 http://www.w3schools.com/xml/xml:xpath.asp 。)

解析从 Tidy 获得的良好 XHTML 的一个非常简单的方法是使用标准库模块html. parser中的HTMLParser类。

使用 HTMLParser

使用HTMLParser仅仅意味着对它进行子类化,并覆盖各种事件处理方法,如handle_starttaghandle_data。表 15-1 总结了相关的方法以及它们何时被解析器(自动)调用。

表 15-1。

The HTMLParser Callback Methods

| 回调方法 | 什么时候叫? | | --- | --- | | `handle_starttag(tag, attrs)` | 当发现一个开始标签时,`attrs`是一个由`(name, value)`对组成的序列。 | | `handle_startendtag(tag, attrs)` | 对于空标签;默认句柄分别开始和结束。 | | `handle_endtag(tag)` | 当找到结束标签时。 | | `handle_data(data)` | 对于文本数据。 | | `handle_charref(ref)` | 对于形式为`&#ref;`的字符引用。 | | `handle_entityref(name)` | 用于形式为`&name;`的实体引用。 | | `handle_comment(data)` | 征求意见;仅用注释内容调用。 | | `handle_decl(decl)` | 用于形式为``的声明。 | | `handle_pi(data)` | 用于处理指令。 | | `unknown_decl(data)` | 读取未知声明时调用。 |

出于屏幕抓取的目的,您通常不需要实现所有的解析器回调(事件处理程序),并且您可能不需要构造整个文档的某种抽象表示(比如文档树)来找到您想要的东西。如果你只是跟踪找到你要找的东西所需的最少信息,你就成功了。(参见第二十二章,在用 SAX 解析 XML 的上下文中了解更多关于这个主题的内容。)清单 15-2 显示了一个解决与清单 15-1 相同问题的程序,但是这次使用了HTMLParser

from urllib.request import urlopen
from html.parser import HTMLParser

def isjob(url):
    try:
        a, b, c, d = url.split(’/’)
    except ValueError:
        return False
    return a == d == ’’ and b == ’jobs’ and c.isdigit()

class Scraper(HTMLParser):

    in_link = False

    def handle_starttag(self, tag, attrs):
        attrs = dict(attrs)
        url = attrs.get(’href’, ’’)
        if tag == ’a’ and isjob(url):
            self.url = url
            self.in_link = True
            self.chunks = []

    def handle_data(self, data):
        if self.in_link:
            self.chunks.append(data)

    def handle_endtag(self, tag):
        if tag == ’a’ and self.in_link:
            print(’{} ({})’.format(’’.join(self.chunks), self.url))
            self.in_link = False

text = urlopen(’http://python.org/jobs’).read().decode()
parser = Scraper()
parser.feed(text)
parser.close()

Listing 15-2.A Screen-Scraping Program Using the HTMLParser Module

有几件事值得注意。首先,我在这里已经放弃了 Tidy 的使用,因为网页中的 HTML 表现得足够好了。如果你幸运的话,你可能会发现你也不需要使用 Tidy。还要注意,我使用了一个布尔状态变量(属性)来跟踪我是否在一个相关的链接中。我在事件处理程序中检查并更新这个属性。其次,handle_starttagattrs参数是一个(key, value)元组列表,所以我使用了dict将它们转换成一个字典,我发现这样更容易管理。

handle_data方法(和chunks属性)可能需要一些解释。它使用了一种在基于事件的结构化标记(如 HTML 和 XML)解析中很常见的技术。我没有假设我会在对handle_data的一次调用中获得我需要的所有文本,而是假设我可能会通过不止一次调用获得几大块文本。这可能有几个原因——缓冲、字符实体、我忽略的标记等等——我只需要确保我得到了所有的文本。然后,当我准备好展示我的结果时(在handle_endtag方法中),我简单地将所有的块join在一起。为了实际运行解析器,我用文本调用它的feed方法,然后调用它的close方法。

与使用正则表达式相比,此类解决方案在某些情况下对输入数据的变化更具鲁棒性。尽管如此,您可能会反对它过于冗长,可能并不比正则表达式更清晰或更容易理解。对于一个更复杂的提取任务,支持这种解析的论点似乎更有说服力,但人们仍然觉得一定有更好的方法。而且,如果你不介意安装另一个模块,有。。。

美味的汤

Beautiful Soup 是一个漂亮的小模块,用于解析和剖析你有时在 Web 上找到的那种 HTML 草率且格式不良的那种。引用美汤网站( http://crummy.com/software/BeautifulSoup ):

You didn't write that terrible page. You just want to get some data from it. Beautiful soup is here to help.

下载安装美汤轻而易举。和大多数包一样,可以使用pip

$ pip install beautifulsoup4

你可能想做一个pip search来看看是否有一个更新的版本。安装了 Beautiful Soup 之后,从 Python Job Board 中提取 Python 作业的运行示例变得非常非常简单,可读性很强,如清单 15-3 所示。我现在浏览文档的结构,而不是检查 URL 的内容。

from urllib.request import urlopen
from bs4 import BeautifulSoup

text = urlopen(’http://python.org/jobs’).read()
soup = BeautifulSoup(text, ’html.parser’)

jobs = set()
for job in soup.body.section(’h2’):
    jobs.add(’{} ({})’.format(job.a.string, job.a[’href’]))

print(’\n’.join(sorted(jobs, key=str.lower)))

Listing 15-3.A Screen-Scraping Program Using Beautiful Soup

我简单地用我想要抓取的 HTML 文本实例化了BeautifulSoup类,然后使用各种机制来提取部分结果解析树。例如,我使用soup.body获取文档的主体,然后访问它的第一个section。我用’h2’作为参数调用结果对象,这相当于使用它的find_all方法,这给了我一个该部分中所有h2元素的集合。每一个都代表一项工作,我对它包含的第一个链接job.a感兴趣。string属性是它的文本内容,而a[’href’]href属性。我相信你已经注意到了,我在清单 15-3 中添加了setsorted(其中key函数被设置为忽略大小写差异)。这和美汤无关;这只是为了让程序更有用,通过消除重复和按排序打印名字。

如果你想把你的废料用于一个 RSS 提要(在本章后面讨论),你可以使用另一个与 Beautiful Soup 相关的工具,叫做 Scrape 'N' Feed(在 http://crummy.com/ software/ScrapeNFeed)。

使用 CGI 的动态网页

虽然本章的第一部分讨论了客户端技术,但现在我们换个角度来处理服务器端。本节讨论一种基本的 web 编程技术:公共网关接口(CGI)。CGI 是一种标准机制,通过这种机制,web 服务器可以将您的查询(通常通过 web 表单提供)传递给专用程序(例如,您的 Python 程序)并将结果显示为网页。这是一种创建 web 应用的简单方法,无需编写自己的专用应用服务器。有关 Python 中 CGI 编程的更多信息,请参见 Python 网站上的 Web 编程主题指南( http://wiki.python.org/moin/WebProgramming )。

Python CGI 编程中的关键工具是cgi模块。另一个在 CGI 脚本开发过程中非常有用的模块是cgitb——稍后在“用 cgitb 调试”一节中会有更多的介绍

在使你的 CGI 脚本可以通过网络访问(和运行)之前,你需要把它们放在一个网络服务器可以访问它们的地方,添加一个磅命令行,并设置适当的文件权限。这三个步骤将在下面的章节中解释。

步骤 1:准备 Web 服务器

我假设你可以访问网络服务器——换句话说,你可以把东西放到网上。通常,就是把你的网页、图片等放在一个特定的目录中(在 UNIX 中,通常称为public_html)。如果您不知道如何操作,您应该询问您的互联网服务提供商(ISP)或系统管理员。

Tip

如果您运行的是 macOS,那么 Apache web 服务器是操作系统安装的一部分。它可以通过“系统偏好设置”的“共享”偏好设置面板打开,方法是选中“Web 共享”选项。

如果您只是尝试一下,您可以使用http.server模块直接从 Python 运行一个临时 web 服务器。与任何模块一样,可以通过提供带有-m开关的 Python 可执行文件来导入和运行它。如果你添加--cgi到模块中,结果服务器将支持 CGI。请注意,服务器将在您运行它的目录中提供文件,所以请确保您在那里没有任何秘密。

$ python -m http.server --cgi

Serving HTTP on 0.0.0.0 port 8000 ...

如果您现在将浏览器指向http://127.0.0.1:8000http://localhost:8000,您应该会看到运行服务器的目录列表。您还应该看到服务器告诉您有关连接的信息。

你的 CGI 程序也必须放在一个可以通过网络访问的目录中。此外,它们必须以某种方式被识别为 CGI 脚本,这样 web 服务器就不只是将普通的源代码作为网页来提供。有两种典型的方法可以做到这一点:

  • 将脚本放在名为cgi-bin的子目录中。
  • 给你的脚本文件扩展名.cgi

具体的工作方式因服务器而异,同样,如果您有疑问,请咨询您的 ISP 或系统管理员。(例如,如果您正在使用 Apache,您可能需要打开相关目录的ExecCGI选项。)如果你正在使用来自http.server模块的服务器,你应该使用一个cgi-bin子目录。

步骤 2:添加磅爆炸线

当您将脚本放在正确的位置时(可能会给它指定一个特定的文件扩展名),您必须在脚本的开头添加一个井号。我在第一章中提到过,这是一种执行脚本的方式,不需要显式执行 Python 解释器。通常,这只是方便,但对于 CGI 脚本来说,这是至关重要的——没有它,web 服务器就不知道如何执行你的脚本。(据我所知,该脚本可以用其他编程语言编写,比如 Perl 或 Ruby。)一般来说,只需在脚本的开头添加以下行即可:

#!/usr/bin/env python

请注意,它必须是第一行。(前面没有空行。)如果这不起作用,您需要找出 Python 可执行文件的确切位置,并在磅爆炸行中使用完整路径,如下所示:

#!/usr/bin/python

如果你同时安装了 Python 2 和 3,你可能需要使用python3来代替。(这也可能与前面显示的env解决方案一起使用。)如果还是不行,那可能是你看不到的地方出了问题,也就是说,这条线以\r\n结尾,而不是简单的\n,你的网络服务器被弄糊涂了。确保您将文件保存为纯 UNIX 样式的文本文件。

在 Windows 中,使用 Python 二进制文件的完整路径,如下例所示:

#!C:\Python36\python.exe

步骤 3:设置文件权限

您需要做的最后一件事(至少如果您的 web 服务器运行在 UNIX 或 Linux 机器上)是设置适当的文件权限。您必须确保每个人都被允许读取和执行您的脚本文件(否则 web 服务器将无法运行它),但也要确保只有您被允许写入它(因此没有人可以更改您的脚本)。

Tip

有时,如果您在 Windows 中编辑一个脚本,并且它存储在 UNIX 磁盘服务器上(例如,您可能通过 Samba 或 FTP 访问它),在您对脚本进行更改后,文件权限可能会出错。因此,如果您的脚本无法运行,请确保权限仍然正确。

改变文件权限(或文件模式)的 UNIX 命令是chmod。简单地运行下面的命令(如果你的脚本叫做somescript.cgi),使用你的普通用户帐户,或者一个专门为这样的 web 任务设置的帐户。

chmod 755 somescript.cgi

完成所有这些准备工作后,您应该能够像打开网页一样打开脚本并执行它。

Note

您不应该在浏览器中将脚本作为本地文件打开。你必须用一个完整的 HTTP URL 打开它,这样你就可以通过网络(通过你的网络服务器)获取它。

你的 CGI 脚本通常不允许修改你电脑上的任何文件。如果您想允许它更改文件,您必须明确地授予它这样做的权限。你有两个选择。如果您有 root(系统管理员)权限,您可以为您的脚本创建一个特定的用户帐户,并更改需要修改的文件的所有权。如果您没有 root 访问权限,您可以设置该文件的文件权限,这样系统上的所有用户(包括 web 服务器用来运行 CGI 脚本的用户)都可以写入该文件。您可以使用以下命令设置文件权限:

chmod 666 editable_file.txt

Caution

使用文件模式 666 有潜在的安全风险。除非你知道自己在做什么,否则最好避开。

CGI 安全风险

一些安全问题与使用 CGI 程序有关。如果你允许你的 CGI 脚本写入你服务器上的文件,这种能力可能会被用来破坏数据,除非你仔细地编写你的程序。类似地,如果您评估用户提供的数据,就好像它是 Python 代码(例如,用execeval)或 shell 命令(例如,用os.system或使用subprocess模块),您就冒着执行任意命令的风险,这是一个巨大的风险。即使使用用户提供的字符串作为 SQL 查询的一部分也是有风险的,除非您非常小心地首先清理该字符串;所谓的 SQL 注入是攻击或闯入系统的一种常见方式。

一个简单的 CGI 脚本

最简单的 CGI 脚本类似于清单 15-4 。

#!/usr/bin/env python

print(’Content-type: text/plain’)
print() # Prints an empty line, to end the headers

print(’Hello, world!’)

Listing 15-4.A Simple CGI Script

如果你把它保存在一个名为simple1.cgi的文件中,并通过你的网络服务器打开它,你应该会看到一个只包含“Hello,world!”以纯文本格式。为了能够通过 web 服务器打开此文件,您必须将它放在 web 服务器可以访问的地方。在典型的 UNIX 环境中,将它放在主目录中名为public_html的目录下,您就可以用 URL http://localhost/∼username/simple1.cgi打开它(用您的用户名代替username)。有关详细信息,请咨询您的 ISP 或系统管理员。如果你使用的是cgi-bin目录,你也可以称之为类似于simple1.py的东西。

如您所见,程序写入标准输出的所有内容(例如,用print)最终都会出现在结果网页中——至少是几乎所有内容。事实上,您首先打印的是 HTTP 头,它是关于页面的信息行。这里我唯一关心的头球是Content-type。正如您所看到的,短语Content-type后面是一个冒号、一个空格和类型名text/plain。这表明该页面是纯文本的。为了表示 HTML,这一行应该如下所示:

print(’Content-type: text/html’)

打印完所有标题后,会打印一个空行,表示文档本身即将开始。如您所见,在这种情况下,文档只是字符串’Hello, world!’

使用 cgitb 调试

有时,编程错误会使您的程序因未捕获的异常而终止,并出现堆栈跟踪。当通过 CGI 运行程序时,这很可能会导致 web 服务器发出无用的错误消息,甚至可能只是一个黑色页面。如果您可以访问服务器日志(例如,如果您正在使用http.server),您可能会从那里获得一些信息。为了帮助你调试 CGI 脚本,标准模块包含了一个有用的模块,叫做cgitb(用于 CGI 回溯)。通过导入它并调用它的enable函数,你可以得到一个非常有用的网页,上面有关于哪里出错的信息。清单 15-5 给出了一个如何使用cgitb模块的例子。

#!/usr/bin/env python

import cgitb; cgitb.enable()

print(’Content-type: text/html\n’)

print(1/0)

print(’Hello, world!’)

Listing 15-5.A CGI Script That Invokes a Traceback (faulty.cgi)

在浏览器中(通过 web 服务器)访问该脚本的结果如图 15-1 所示。

A326949_3_En_15_Fig1_HTML.jpg

图 15-1。

A CGI traceback from the cgitb module

请注意,在开发完程序后,您可能想要关闭cgitb功能,因为回溯页面不是为程序的临时用户准备的。1

使用 cgi 模块

到目前为止,这些程序只产生了输出;他们没有使用任何形式的输入。输入作为键值对或字段从 HTML 表单(在下一节中描述)提供给 CGI 脚本。您可以使用来自cgi模块的FieldStorage类在 CGI 脚本中检索这些字段。当您创建您的FieldStorage实例(您应该只创建一个)时,它从请求中获取输入变量(或字段),并通过类似字典的接口将它们呈现给您的程序。可以通过普通的键查找来访问FieldStorage的值,但是由于一些技术上的问题(与文件上传有关,我们在这里不讨论),所以FieldStorage的元素并不是您真正想要的值。例如,如果您知道请求包含一个名为name的值,您不能简单地这样做:

form = cgi.FieldStorage()
name = form[’name’]

您需要这样做:

form = cgi.FieldStorage()
name = form[’name’].value

获取值的一个稍微简单的方法是getvalue方法,它类似于字典方法get,除了它返回项目的value属性的值。这里有一个例子:

form = cgi.FieldStorage()
name = form.getvalue(’name’, ’Unknown’)

在前面的例子中,我提供了一个默认值(’Unknown’)。如果不提供,默认为None。如果字段未填写,则使用默认值。

清单 15-6 包含了一个使用cgi.FieldStorage的简单例子。

#!/usr/bin/env python

import cgi
form = cgi.FieldStorage()

name = form.getvalue(’name’, ’world’)

print(’Content-type: text/plain\n’)

print(’Hello, {}!’.format(name))

Listing 15-6.A CGI Script That Retrieves a Single Value from a FieldStorage (simple2.cgi)

Invoking CGI Scripts Without Forms

CGI 脚本的输入通常来自已经提交的 web 表单,但是也可以直接用参数调用 CGI 程序。要做到这一点,可以在脚本的 URL 后面添加一个问号,然后添加由&符号分隔的键值对。例如,如果清单 15-6 中脚本的 URL 是 http://www.example.com/simple2.cgi ,您可以用 URL http://www.example.com/simple2.cgi?name=Gumby&age=42name=Gumby and age=42调用它。如果您尝试这样做,您应该会收到消息“你好,Gumby!”而不是“你好,世界!”来自你的 CGI 脚本。(注意没有使用age参数。)您可以使用urllib.parse模块的urlencode方法来创建这种 URL 查询:

>>> urlencode({’name’: ’Gumby’, ’age’: ’42’})
’age=42&name=Gumby’

你可以在自己的程序中使用这个策略,和urllib一起,创建一个可以和 CGI 脚本交互的屏幕抓取程序。然而,如果您正在编写这样一个装置的两端(即服务器端和客户端),您很可能会更好地使用某种形式的 web 服务(如本章“Web 服务:正确抓取”一节中所述)。

简单的形式

现在您有了处理用户请求的工具;是时候创建一个用户可以提交的表单了。该表单可以是一个单独的页面,但我将把它放在同一个脚本中。

要了解更多关于编写 HTML 表单(或一般的 HTML)的知识,您可能需要一本关于 HTML 的好书(您当地的书店可能有几本)。你也可以在网上找到大量关于这个主题的信息。和往常一样,如果您发现某个页面看起来像是您想要做的事情的一个很好的示例,您可以在浏览器中检查它的源代码,方法是从其中一个菜单中选择“查看源代码”或类似的内容(取决于您使用的浏览器)。

Note

从 CGI 脚本中获取信息有两种主要方法:GET 方法和 POST 方法。就本章的目的而言,两者之间的区别并不重要。基本上,GET 是为了检索东西,并在 URL 中编码它的查询;POST 可以用于任何类型的查询,但是对查询的编码稍有不同。

让我们回到我们的剧本。在清单 15-7 中可以找到一个扩展版本。

#!/usr/bin/env python

import cgi
form = cgi.FieldStorage()

name = form.getvalue(’name’, ’world’)

print("""Content-type: text/html

<html>
  <head>
    <title>Greeting Page</title>
  </head>
  <body>
    <h1>Hello, {}!</h1>

    <form action=’simple3.cgi’>
    Change name <input type=’text’ name=’name’ />
    <input type=’submit’ />
    </form>
  </body>
</html>
""".format(name))

Listing 15-7.A Greeting Script with an HTML Form (simple3.cgi)

在这个脚本的开始,CGI 参数name被检索,和以前一样,默认为’world’。如果您只是在浏览器中打开脚本,而没有提交任何内容,则使用默认设置。

然后打印一个简单的 HTML 页面,包含name作为标题的一部分。此外,该页面包含一个 HTML 表单,其action属性被设置为脚本本身的名称(simple3.cgi)。这意味着,如果表单被提交,您将返回到相同的脚本。表单中唯一的输入元素是一个名为name的文本字段。因此,如果你提交一个新名字的字段,标题应该会改变,因为name参数现在有了一个值。

图 15-2 显示了通过 web 服务器访问清单 15-7 中的脚本的结果。

A326949_3_En_15_Fig2_HTML.jpg

图 15-2。

The result of executing the CGI script in Listing 15-7

使用 Web 框架

大多数人不会直接为任何严肃的 web 应用编写 CGI 脚本;相反,他们使用一个 web 框架,为你做了很多繁重的工作。有很多这样的框架可用,我将在后面提到其中的一些—但是现在,让我们坚持使用一个非常简单但是非常有用的框架,叫做 Flask ( http://flask.pocoo.org )。使用pip很容易安装。

$ pip install flask

假设你写了一个计算 2 的幂的令人兴奋的函数。

def powers(n=10):
    return ’, ’.join(str(2**i) for i in range(n))

现在你想把这个杰作公之于众!要用 Flask 做到这一点,首先用适当的名称实例化Flask类,并告诉它哪个 URL 路径对应于您的函数。

from flask import Flask
app = Flask(__name__)

@app.route(’/’)
def powers(n=10):
    return ’, ’.join(str(2**i) for i in range(n))

如果您的脚本名为powers.py,您可以让 Flask 如下运行它(假设是 UNIX 风格的 shell):

$ export FLASK_APP=powers.py
$ flask run
 * Serving Flask app "powers"
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

最后两行是 Flask 的输出。如果在浏览器中输入 URL,应该会看到从powers返回的字符串。您还可以为您的函数提供一个更具体的路径。例如,如果您使用route(’/powers’)而不是route(’/’),该功能将在http://127.0.0.1:5000/powers可用。然后,您可以设置多个函数,每个函数都有自己的 URL。

你甚至可以为你的函数提供参数。您使用尖括号来指定参数,因此您可能会使用’/powers/<n>’,例如。您在斜杠后指定的任何内容都将作为名为n的关键字参数提供。它将是一个字符串,在我们的例子中,我们需要一个整数。我们可以通过使用route(’/powers/<int:n>’)来添加这个转换。然后,重启 Flask 后,如果访问 URL http://127.0.0.1:5000/powers/3,应该会得到输出1, 2, 4

Flask 还有很多其他特性,它的文档可读性很强。如果您想尝试简单的服务器端 web 应用开发,我推荐您看一看。

其他 Web 应用框架

有许多其他的 web 框架可供使用,有大有小。有些是相当模糊的,而另一些有专门的定期会议。表 15-2 中列出了一些流行的;要获得更全面的列表,您应该查阅 Python 网页( https://wiki.python.org/moin/WebFrameworks )。

表 15-2。

Python Web Application Frameworks

| 名字 | 网站 | | --- | --- | | Django | [`https://djangoproject.com`](https://djangoproject.com) | | 涡轮齿轮 | [`http://turbogears.org`](http://turbogears.org) | | web2py | [`http://web2py.com`](http://web2py.com) | | 神交 | [`https://pypi.python.org/pypi/grok`](https://pypi.python.org/pypi/grok) | | Zope2 | [`https://pypi.python.org/pypi/Zope2`](https://pypi.python.org/pypi/Zope2) | | 金字塔 | [`https://trypyramid.com`](https://trypyramid.com) |

Web 服务:抓取正确

Web 服务有点像计算机友好的网页。它们基于使程序能够通过网络交换信息的标准和协议,通常一个程序(客户机或服务请求者)请求一些信息或服务,另一个程序(服务器或服务提供者)提供这些信息或服务。是的,这是显而易见的东西,它似乎也非常类似于在第十四章中讨论的网络编程,但是有区别。

Web 服务通常在相当高的抽象层次上工作。他们使用 HTTP(“Web 的协议”)作为底层协议。除此之外,它们使用更多面向内容的协议,比如某种 XML 格式来编码请求和响应。这意味着 web 服务器可以是 web 服务的平台。正如这一部分的标题所示,这是网络抓取的另一个层次。您可以将 web 服务看作是为计算机化的客户端设计的动态网页,而不是供人使用的。

web 服务的标准在捕捉各种复杂性方面走得很远,但是你也可以用绝对的简单来完成很多事情。在这一节中,我只对这个主题做了一个简单的介绍,并提供了一些在哪里可以找到您可能需要的工具和信息的提示。

Note

由于有许多实现 web 服务的方法,包括大量的协议,并且每个 web 服务系统可能提供几个服务,所以有时有必要以一种客户机可以自动解释的方式来描述一个服务,也就是说一个元服务。这种描述的标准是 Web 服务描述语言(WSDL)。WSDL 是一种 XML 格式,它描述了诸如哪些方法可以通过服务使用,以及它们的参数和返回值。除了实际的服务协议(如 SOAP)之外,许多(如果不是大多数)web 服务工具包还将包括对 WSDL 的支持。

RSS 和朋友

RSS 代表 Rich Site Summary、RDF Site Summary 或 Really Simple Syndication(取决于版本号),其最简单的形式是用 XML 列出新闻条目的格式。让 RSS 文档(或提要)比简单的静态文档更像一种服务的是,它们应该定期(或不定期)更新。它们甚至可以被动态地计算,表示例如对博客等的最新添加。一种更新的格式是 Atom。有关 RSS 及其相关资源描述框架(RDF)的信息,请参见 http://www.w3.org/RDF 。原子的规格见 http://tools.ietf.org/html/rfc4287

有很多 RSS 阅读器,它们通常也可以处理其他格式,比如 Atom。因为 RSS 格式非常容易处理,所以开发人员不断为它开发新的应用。例如,一些浏览器(比如 Mozilla Firefox)会让你为一个 RSS 订阅源添加书签,然后给你一个动态书签子菜单,把单个新闻条目作为菜单项。RSS 也是播客的主干;播客本质上是一个列出声音文件的 RSS 提要。

问题是,如果您想编写一个处理来自几个站点的提要的客户端程序,您必须准备好解析几种不同的格式,甚至可能需要解析提要的各个条目中的 HTML 片段。尽管您可以使用BeautifulSoup(或者它的一个面向 XML 的版本)来解决这个问题,但是使用 Mark Pilgrim 的通用提要解析器( https://pypi.python.org/pypi/feedparser )可能是一个更好的主意,它可以处理几种提要格式(包括 RSS 和 Atom,以及一些扩展),并支持某种程度的内容清理。Pilgrim 还写过一篇有用的文章,“不惜一切代价解析 RSS”(http://xml.com/pub/a/2003/01/22/dive-into-xml.html),以防你想自己处理一些清理工作。

使用 XML-RPC 的远程过程调用

在 RSS 简单的下载和解析机制之外,还有远程过程调用。远程过程调用是基本网络交互的抽象。你的客户程序要求服务器程序执行一些计算并返回结果,但是这都被伪装成一个简单的过程(或者函数或者方法)调用。在客户端代码中,看起来像是调用了一个普通的方法,但是调用它的对象实际上完全驻留在不同的机器上。这种过程调用最简单的机制可能是 XML-RPC,它通过 HTTP 和 XML 实现网络通信。因为该协议与语言无关,所以用一种语言编写的客户机程序很容易调用用另一种语言编写的服务器程序上的函数。

Tip

试着在网上搜索一下,为 Python 找到许多其他的 RPC 选项。

Python 标准库包括对客户端和服务器端 XML-RPC 编程的支持。有关使用 XML-RPC 的示例,请参见第二十七章和第二十八章。

RPC and REST

尽管这两种机制非常不同,但是远程过程调用可以与网络编程中所谓的代表性状态传输风格(通常称为 REST)相比较。基于 REST(或 RESTful)的程序也允许客户端以编程方式访问服务器,但是服务器程序被认为没有任何隐藏状态。返回的数据由给定的 URL 唯一确定(或者,在 HTTP POST 的情况下,由客户端提供的附加数据)。

更多关于 REST 的信息可以在网上找到。例如,你可以从维基百科上的文章开始,在 http://en.wikipedia.org/wiki/Representational_State_Transfer 。在 RESTful 编程中经常使用的一个简单而优雅的协议是 JavaScript Object Notation,或 JSON ( http://www.json.org ),它允许您以纯文本格式表示复杂的对象。您可以在json标准库模块中找到对 JSON 的支持。

肥皂

SOAP 2 也是一种交换消息的协议,以 XML 和 HTTP 为底层技术。像 XML-RPC 一样,SOAP 支持远程过程调用,但是 SOAP 规范比 XML-RPC 的规范复杂得多。SOAP 是异步的,支持关于路由的元请求,并且有一个复杂的类型系统(与 XML-RPC 简单的固定类型集相反)。

Python 没有单一的标准 SOAP 工具包。你可能要考虑扭曲( http://twistedmatrix.com )、ZSI ( http://pywebsvcs.sf.net )或者索比( http://soapy.sf.net )。有关 SOAP 格式的更多信息,请参见 http://www.w3.org/TR/soap

快速总结

以下是本章所涵盖主题的摘要:

  • 屏幕抓取:这是自动下载网页并从中提取信息的做法。Tidy 程序及其库版本是在使用 HTML 解析器之前修复不良 HTML 的有用工具。另一个选择是使用漂亮的汤,它对杂乱的输入非常宽容。
  • CGI:公共网关接口是一种创建动态网页的方法,它通过让一个 web 服务器运行并与你的程序通信来显示结果。cgicgitb模块对于编写 CGI 脚本很有用。CGI 脚本通常从 HTML 表单中调用。
  • Flask:一个简单的 web 框架,可以让你将代码发布为 web 应用,而不用太担心 web 部分的事情。
  • Web 应用框架:对于用 Python 开发大型复杂的 web 应用,web 应用框架几乎是必不可少的。对于更简单的项目,Flask 是一个不错的选择。对于较大的项目,您可能想考虑像 Django 或 TurboGears 这样的东西。
  • Web 服务:Web 服务对于程序就像(动态)网页对于人一样。您可能会将它们视为在更高的抽象层次上进行网络编程的一种方式。常见的 web 服务标准有 RSS(及其相关的 RDF 和 Atom)、XML-RPC 和 SOAP。

本章的新功能

| 功能 | 描述 | | --- | --- | | `cgitb.enable()` | 在 CGI 脚本中启用回溯 |

现在怎么办?

我相信你已经通过运行程序测试了你写的程序。在下一章,你将学习如何真正地测试它们——彻底地、有条不紊地,甚至可能是着迷地(如果你幸运的话)。

Footnotes 1

另一种方法是关闭显示器,将错误记录到文件中。有关详细信息,请参阅 Python 库参考。

2

虽然这个名称曾经代表简单对象访问协议,但现在已经不是了。现在只是肥皂。

十六、测试,123

你怎么知道你的程序有效呢?你能一直依靠自己写出毫无瑕疵的代码吗?无意冒犯,我想这不太可能。当然,大多数时候用 Python 写正确的代码是很容易的,但是你的代码可能会有错误。

对于程序员来说,调试是生活的一部分,是编程工艺不可或缺的一部分。然而,开始调试的唯一方法是运行您的程序。正确仅仅运行你的程序可能还不够。例如,如果你写了一个以某种方式处理文件的程序,你将需要一些文件来运行它。或者,如果您已经编写了一个带有数学函数的实用程序库,您将需要为这些函数提供参数,以便运行您的代码。

程序员一直在做这种事情。在编译语言中,循环类似于“编辑、编译、运行”,周而复始。在某些情况下,甚至让程序编译都可能是一个问题,所以程序员只需在编辑和编译之间切换。在 Python 中,没有编译步骤——只需编辑和运行。运行你的程序是测试的全部。

在这一章中,我将讨论测试的基础。我给你一些关于如何让测试成为你的编程习惯之一的笔记,并向你展示一些编写测试的有用工具。除了标准库的测试和分析工具,我还向您展示了如何使用代码分析器 PyChecker 和 PyLint。

有关编程实践和理念的更多信息,请参见第十九章。在那里,我还提到了日志记录,这与测试有些关系。

先测试,后编码

为了计划变更和灵活性(如果您的代码甚至在您自己的开发过程结束时仍然存在,这是至关重要的),为程序的各个部分设置测试(所谓的单元测试)是很重要的。这也是设计应用的一个非常实用的部分。与直观的“一点点编码,一点点测试”实践不同,极限编程人群引入了非常有用但有点违反直觉的格言“一点点测试,一点点编码”

换句话说,先测试,后编码。这也被称为测试驱动编程。虽然这种方法一开始可能不熟悉,但它有很多优点,而且随着时间的推移,你会越来越喜欢它。最终,一旦你使用了测试驱动编程一段时间,在没有测试的情况下编写代码可能看起来真的很落后。

精确的需求规格

当开发一个软件时,你必须首先知道软件应该解决什么问题——它应该满足什么目标。你可以通过写一个需求规格说明,一个描述程序必须满足的需求的文档(或者只是一些快速注释)来阐明你的程序目标。然后很容易在以后的某个时间检查需求是否确实被满足。但是许多程序员不喜欢写报告,通常更喜欢让他们的计算机尽可能多地做他们的工作。这里有一个好消息:您可以用 Python 指定需求,并让解释器检查它们是否得到满足!

Note

有许多类型的需求,包括客户满意度这样的模糊概念。在这一节中,我将重点放在功能需求上——也就是说,程序的功能需要什么。

这个想法是从编写一个测试程序开始,然后编写一个通过测试的程序。测试程序是你的需求规格,帮助你在开发程序时坚持那些需求。

我们举一个简单的例子。假设您想编写一个模块,该模块具有一个计算给定高度和宽度的矩形面积的函数。在你开始编码之前,你写一个单元测试,用一些你知道答案的例子。您的测试程序可能看起来如清单 16-1 所示。

from area import rect_area
height = 3
width = 4
correct_answer = 12
answer = rect_area(height, width)
if answer == correct_answer:
    print('Test passed ')
else:
    print('Test failed ')
Listing 16-1.A Simple Test Program

在这个例子中,我对高度 3 和宽度 4 调用函数rect_area(我还没写)并将答案与正确答案进行比较,正确答案是 12。1

如果您随后不小心如下实现了rect_area(在文件area.py中)并试图运行测试程序,您将会得到一个错误消息。

def rect_area(height, width):
    return height * height # This is wrong ...

然后,您可以检查代码,看看哪里出错了,并用height * width替换返回的表达式。

在你写代码之前写一个测试不仅仅是为了找到 bug——也是为了看看你的代码是否工作。这有点像古老的禅宗公案:“如果没有人听到,森林中倒下的一棵树会发出声音吗?”嗯,当然有(抱歉,禅僧),但是声音对你或者其他人没有任何影响。对于您的代码,问题是,“在您测试它之前,它实际上做什么了吗?”撇开哲学不谈,采取这样一种态度是有用的,即在你对一个特性进行测试之前,它并不真正存在(或者不是一个真正的特性)。然后你就可以清楚地证明它就在那里,并且正在做它应该做的事情。这不仅在最初开发程序时有用,而且在以后扩展和维护代码时也有用。

变革规划

除了在您编写程序时提供大量帮助之外,自动化测试还可以帮助您避免在引入变更时积累错误,这在您的程序变大时尤为重要。正如在第十九章中所讨论的,你应该准备好改变你的代码,而不是疯狂地抓住现有的不放,但是改变是有危险的。当您更改代码的某个部分时,您经常会引入一两个无法预见的错误。如果你已经很好地设计了你的程序(通过适当的抽象和封装),一个改变的影响应该是局部的,并且只影响一小部分代码。这意味着如果您发现了错误,调试会更容易。

Code Coverage

覆盖率的概念是测试知识的一个重要部分。当您运行您的测试时,您可能不会运行您代码的所有部分,即使那将是理想的情况。(实际上,最理想的情况是使用每一个可能的输入,遍历程序的每一个可能的状态,但是这真的不会发生。)一个好的测试套件的目标之一是获得良好的覆盖率,确保这一点的一种方法是使用覆盖率工具,它测量测试期间实际运行的代码的百分比。在撰写本文时,还没有真正标准化的 Python 覆盖工具,但是在网上搜索类似“test coverage python”的东西应该会出现一些选项。一个选项是 Python 发行版附带的程序trace.py。您可以在命令行上将它作为一个程序运行(可能使用-m开关,省去了您查找文件的麻烦),或者您可以将它作为一个模块导入。关于如何使用它的帮助,你可以用--help开关运行程序,或者导入模块并在解释器中执行help(trace)

有时,您可能会被广泛测试所有内容的要求压垮。别担心——你不必测试输入和状态变量的数百种组合,至少一开始不用。测试驱动编程最重要的部分是,你实际上在编码时反复运行你的方法(或函数或脚本),以获得关于你做得如何的持续反馈。如果您想增加对代码正确性(以及覆盖率)的信心,您总是可以在以后添加更多的测试。

关键是,如果你手头没有一套完整的测试,你甚至可能直到后来才发现你引入了一个错误,那时你不再知道错误是如何引入的。如果没有一套好的测试,就很难准确找出问题所在。你不能逆来顺受,除非你看到他们来了。获得良好测试覆盖率的一个方法是遵循测试驱动编程的原则。如果在编写函数之前确保已经编写了测试,就可以确定每个函数都经过了测试。

测试的 1-2-3(和 4)

在我们进入编写测试的本质之前,这里有一个测试驱动开发过程的分解(或者至少是它的一个版本):

  1. 想出你想要的新功能。可能记录它,然后为它编写一个测试。
  2. 为该特性编写一些框架代码,这样您的程序运行时不会出现任何语法错误或类似错误,但您的测试仍然会失败。看到你的测试失败是很重要的,所以你肯定它确实会失败。如果测试有问题,并且无论如何它总是成功(这已经发生在我身上很多次了),你就没有真正测试任何东西。这一点值得重复:在你试图让测试成功之前,先看到它失败。
  3. 为你的骨架写伪代码,只是为了安抚测试。这不需要精确地实现功能;它只需要通过测试。这样,在开发的时候,你可以让你所有的测试都通过(除了第一次运行测试的时候,记得吗?),即使在最初实现功能时也是如此。
  4. 重写(或重构)代码,使它真正做它应该做的事情,同时确保你的测试一直成功。

当你离开时,你应该保持你的代码处于一个健康的状态——不要留下任何失败的测试(或者,就此而言,在你的伪代码还在的情况下成功)。他们是这么说的。我发现我有时会留下一个失败的测试,这是我目前工作的点,作为我自己的一种“待办事项”或“继续这里”。但是,如果你和其他人一起开发,这是非常不好的形式。您不应该将失败的代码签入公共代码库中。

测试工具

你可能认为编写大量的测试来确保程序的每个细节都正常工作听起来像是一件苦差事。好吧,我有好消息告诉你:标准库中有帮助(不是一直都有吗?).有两个出色的模块可以自动完成测试过程。

  • 通用测试框架
  • 一个更简单的模块,设计用于检查文档,但也非常适合编写单元测试

我们先来看看doctest,这是一个很好的起点。

doctest(测试)

在本书中,我使用了直接来自交互式解释器的例子。我发现这是一种展示事物如何工作的有效方式,当你有这样一个例子时,你很容易自己去测试它。事实上,交互式解释器会话是放入 docstrings 的一种有用的文档形式。例如,假设我编写了一个求数字平方的函数,并在它的 docstring 中添加了一个例子。

def square(x):
    '''
    Squares a number and returns the result.

    >>> square(2)
    4
    >>> square(3)
    9
    '''
    return x * x

如您所见,我也在 docstring 中包含了一些文本。这和测试有什么关系?假设square函数定义在模块my_math(即一个名为my_math.py的文件)中。然后,您可以在底部添加以下代码:

if name =='__main__':
    import doctest, my_math
    doctest.testmod(my_math)

这不是很多,是吗?您只需导入doctestmy_math模块本身,然后从doctest运行testmod(对于“测试模块”)函数。这是做什么的?让我们试试。

$ python my_math.py
$

似乎什么都没发生,但这是件好事。doctest.testmod函数读取一个模块的所有文档字符串,并从交互式解释器中找出任何看起来像例子的文本。然后它检查这个例子是否代表现实。

Note

如果我在这里编写一个真正的函数,我将(或者应该,根据我之前制定的规则)首先编写 docstring,用 doctest 运行脚本以查看测试是否失败,添加一个虚拟版本(例如使用if语句来处理 docstring 中的特定输入)以使测试成功,然后开始进行正确的实现。另一方面,如果您打算进行全面的“先测试,后编码”编程,那么unittest框架(稍后讨论)可能更适合您的需求。

为了获得更多的输入,您可以给脚本添加-v(表示“详细”)开关。

$ python my_math.py -v

该命令将产生以下输出:

Running my_math.__doc__
0 of 0 examples failed in my_math.__doc__
Running my_math.square.__doc__
Trying: square(2)
Expecting: 4
Ok

Trying: square(3)
Expecting: 9
ok
0 of 2 examples failed in my_math.square.__doc__
1 items had no tests:
     test
1 items passed all tests:
2 tests in my_math.square
2 tests in 2 items.
2 passed and 0 failed.
Test passed.

如你所见,幕后发生了很多事情。testmod函数检查模块 docstring(如您所见,它不包含任何测试)和函数 docstring(它包含两个测试,两个测试都成功)。

有了测试,您就可以安全地更改代码了。假设您想使用 Python 取幂运算符而不是简单乘法,并使用x ** 2而不是x * x。你编辑了代码,但是不小心忘记输入数字 2,以x ** x结束。尝试一下,然后运行脚本来测试代码。会发生什么?这是您得到的输出:

*****************************************************************
Failure in example: square(3)
from line #5 of my_math.square
Expected: 9
Got: 27
*****************************************************************
1 items had failures:
    1 of 2 in my_math.square
***Test Failed***
1 failures.

所以错误被发现了,你得到了一个非常清晰的错误描述。现在解决这个问题应该不难。

Caution

不要盲目相信自己的测试,一定要测试足够多的案例。正如你所看到的,使用square(2)的测试没有捕捉到错误,因为对于x == 2x ** 2x ** x是同一个东西!

要了解关于doctest模块的更多信息,您应该再次查阅库参考。

单元测试

虽然doctest非常容易使用,但是unittest(基于流行的测试框架 JUnit,for Java)更加灵活和强大。unittest可能比doctest有更陡峭的学习曲线,但是我建议你看一看这个模块,因为它允许你以更结构化的方式编写非常大和全面的测试集。

我将在这里给你一个简单的介绍。包含了一些您在大多数测试中可能不需要的特性。

Tip

标准库中单元测试工具的两个有趣的替代品是pytest ( pytest.org)和nose ( nose.readthedocs.io)。

同样,让我们看一个简单的例子。您将编写一个名为my_math的模块,其中包含一个名为product的计算乘积的函数。那么你从哪里开始呢?当然是通过测试(在一个名为test_my_math.py的文件中),使用来自unittest模块的TestCase类(参见清单 16-2 )。

import unittest, my_math

class ProductTestCase(unittest.TestCase):

    def test_integers(self):
        for x in range(-10, 10):
            for y in range(-10, 10):
                p = my_math.product(x, y)
                self.assertEqual(p, x * y, 'Integer multiplication failed')

    def test_floats(self):
        for x in range(-10, 10):
            for y in range(-10, 10):
                x = x / 10
                y = y / 10
                p = my_math.product(x, y)
                self.assertEqual(p, x * y, 'Float multiplication failed')

if __name__ == '__main__': unittest.main()

Listing 16-2.A Simple Test Using the unittest Framework

函数unittest.main负责为您运行测试。它将实例化TestCase的所有子类,并运行名称以test开头的所有方法。

Tip

如果您定义了名为setUptearDown的方法,它们将在每个测试方法之前和之后执行。您可以使用这些方法为所有的测试提供公共的初始化和清理代码,即所谓的测试夹具。

当然,运行这个测试脚本只会给出一个关于模块my_math不存在的异常。像assertEqual这样的方法检查一个条件,以确定给定的测试是成功还是失败。TestCase类还有许多其他类似的方法,比如assertTrueassertIsNotNoneassertAlmostEqual

unittest模块区分错误和失败,前者引发异常,后者由调用failUnless等导致。下一步是编写框架代码,这样我们就不会出错,只会失败。这仅仅意味着创建一个名为my_math的模块(即一个名为my_math.py的文件),包含以下内容:

def product(x, y):
    pass

全是填充物,没意思。如果您现在运行测试,您应该会得到两条FAIL消息,如下所示:

FF
======================================================================
FAIL: test_floats (__main__.ProductTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_my_math.py", line 17, in testFloats
    self.assertEqual(p, x * y, 'Float multiplication failed')
AssertionError: Float multiplication failed
======================================================================
FAIL: test_integers (__main__.ProductTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_my_math.py", line 9, in testIntegers
     self.assertEqual(p, x * y, 'Integer multiplication failed')
AssertionError: Integer multiplication failed

----------------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (failures=2)

这都在意料之中,所以不要太担心。现在,至少,您知道测试确实与代码相关联——代码是错误的,测试失败了。太好了。

下一步是让它发挥作用。在这种情况下,当然没什么大不了的:

def product(x, y):
    return x * y

现在输出简单如下:

..
----------------------------------------------------------------------
Ran 2 tests in 0.015s
OK

顶部的两个点是测试。如果您仔细观察失败版本的混杂输出,您也会在顶部看到两个字符:两个F表示两次失败。

只是为了好玩,改变product函数,使其对于特定的参数 7 和 9 失效。

def product(x, y):
    if x == 7 and y == 9:
        return 'An insidious bug has surfaced!'
    else:
        return x * y

如果您再次运行测试脚本,您应该会得到一个失败。

.F
======================================================================
FAIL: test_integers (__main__.ProductTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_my_math.py", line 9, in testIntegers
   self.assertEqual(p, x * y, 'Integer multiplication failed')
AssertionError: Integer multiplication failed
----------------------------------------------------------------------
Ran 2 tests in 0.005s

FAILED (failures=1)

Tip

对于面向对象代码的更高级测试,请查看模块unittest.mock

超越单元测试

测试显然是重要的,对于任何有点复杂的项目来说,它们是绝对重要的。即使你不想为单元测试的结构化套件而烦恼,你也必须有某种方法来运行你的程序,看看它是否有效。在您进行任何大量的编码之前拥有这种能力可以为您以后节省大量的工作(和痛苦)。

还有其他方法来预测你的程序,在这里我将向你展示几个工具来做这件事:源代码检查和分析。源代码检查是寻找代码中常见错误或问题的一种方式(有点像编译器对静态类型语言所做的,但远不止于此)。剖析是一种发现你的程序到底有多快的方法。我按照这个顺序来讨论这些话题,以此来尊重这条古老的好规则:“让它工作,让它更好,让它更快。”单元测试帮助它工作;源代码检查可以帮助它变得更好;最后,剖析有助于加快速度。

用 PyChecker 和 PyLint 检查源代码

在相当长的一段时间里,PyChecker ( pychecker.sf.net)是检查 Python 源代码的唯一工具,用来寻找错误,比如提供不能用于给定函数的参数等等。(好吧,在标准库中有tabnanny,但是它并不那么强大,因为它只是检查你的缩进。)随后出现了 PyLint ( pylint.org ),它支持 PyChecker 的大部分特性以及更多特性(比如你的变量名是否符合给定的命名约定,你是否遵守自己的编码标准,等等)。

安装工具很简单。它们都可以从几个包管理器系统(比如 Debian APT 和 Gentoo Portage)获得,也可以从它们各自的网站直接下载。您可以使用 Distutils 和标准命令进行安装。

python setup.py install

也可以使用pip安装 PyLint。

一旦完成,这些工具应该可以作为命令行脚本(分别用于 PyChecker 和 PyLint 的pycheckerpylint)和 Python 模块(具有相同的名称)使用。

Note

在 Windows 中,这两个工具使用批处理文件pychecker.batpylint.bat作为命令行工具。您可能需要将这些添加到 PATH 环境变量中,以便在命令行中使用pycheckerpylint命令。

要使用 PyChecker 检查文件,您可以使用文件名作为参数运行脚本,如下所示:

pychecker file1.py file2.py ...

使用 PyLint,您可以使用模块(或包)名称:

pylint module

您可以通过使用-h命令行开关运行这两个工具来获得更多信息。当您运行这些命令中的任何一个时,您可能会得到相当多的输出(很可能来自pylint的输出比来自pychecker的输出多)。这两个工具都可以根据您想要获得(或抑制)的警告进行配置;有关更多信息,请参见各自的文档。

在离开检查器之前,让我们看看如何将它们与单元测试结合起来。毕竟,让它们(或者只是其中的一个)作为测试在您的测试套件中自动运行,并且在没有任何问题的情况下静静地成功,这将是非常令人愉快的。那么你实际上可以有一个测试套件,不仅测试功能,也测试代码质量。

PyChecker 和 PyLint 都可以作为模块导入(分别是pychecker.checkerpylint.lint),但是它们并没有真正被设计成以编程方式使用。当你导入pychecker.checker时,它会检查后面的代码(包括导入的模块),将警告打印到标准输出。pylint.lint模块有一个名为Run的未记录的函数,它在pylint脚本本身中使用。这也打印出警告,而不是以某种方式返回它们。与其纠结于这些问题,我建议使用 PyChecker 和 PyLint 的本来用途:作为命令行工具。而 Python 中使用命令行工具的方式就是subprocess模块。清单 16-3 是早期测试脚本的一个例子,现在有两个代码检查测试。

import unittest, my_math
from subprocess import Popen, PIPE

class ProductTestCase(unittest.TestCase):

    # Insert previous tests here

    def test_with_PyChecker(self):
        cmd = 'pychecker', '-Q', my_math.__file__.rstrip('c')
        pychecker = Popen(cmd, stdout=PIPE, stderr=PIPE)
        self.assertEqual(pychecker.stdout.read(), '')

    def test_with_PyLint(self):
        cmd = 'pylint', '-rn', 'my_math'
        pylint = Popen(cmd, stdout=PIPE, stderr=PIPE)
        self.assertEqual(pylint.stdout.read(), '')

if __name__ == '__main__': unittest.main()

Listing 16-3.Calling External Checkers Using the subprocess Module

我给了 checker 程序一些命令行开关,以避免干扰测试的无关输出。对于pychecker,我已经提供了-Q(静音)开关。对于pylint,我提供了-rn(其中n代表“否”)来关闭报告,这意味着它将只显示警告和错误。

pylint命令直接使用提供的模块名运行,因此非常简单。为了让pychecker正常工作,我们需要获得一个文件名。为了实现这一点,我使用了my_math模块的__file__属性,rstrip去除了可能在文件名末尾找到的任何c(因为该模块实际上可能来自于.pyc文件)。

为了安抚 PyLint(而不是将它配置为对短变量名、缺少的修订和文档字符串之类的事情闭口不谈),我稍微重写了my_math模块。

"""
A simple math module.
"""
__revision__ = '0.1'

def product(factor1, factor2):
    'The product of two numbers'
    return factor1 * factor2

如果您现在运行测试,您应该不会得到任何错误。试着摆弄一下代码,看看是否能让任何检查器在功能测试仍然工作的时候报告错误。(请随意删除 PyChecker 或 PyLint——一个可能就足够了。)比如试着把参数重新命名回xy,PyLint 要抱怨变量名短。或者在return语句后添加print('Hello, world!'),两个检查者都会合理地抱怨(可能给出不同的抱怨理由)。

The Limits of Automatic Checking: Will it Ever End?

虽然像 PyChecker 或 PyLint 这样的自动检查器所能发现的东西令人惊讶,但是它们的能力是有限的。虽然他们能发现的错误和问题的广度令人印象深刻,但他们不知道你的程序最终要做什么;因此,总是需要定制的单元测试。但是除了这个明显的障碍,自动检查器还有其他限制。如果你喜欢稍微有点奇怪的理论,你可能会对计算理论世界的一个结果感兴趣,这个结果被称为停止定理。让我们考虑一个假设的检验程序,我们可以这样运行:

halts.py myprog.py data.txt

正如您可能猜到的,当对输入data.txt运行时,检查器应该检查myprog.py的行为。我们只想检查一件事:无限循环(或任何等效的东西)。换句话说,程序halts.py应该决定myprog.pydata.txt上运行时是否会停止。鉴于现有的 checker 程序可以分析代码,并确定各种变量必须是哪种类型才能工作,检测像无限循环这样简单的事情似乎轻而易举,对吗?抱歉,但是没有,至少一般情况下没有。

不要相信我的话,道理其实很简单。假设我们有一个正在工作的暂停检查器,并假设(为了简单起见)它是作为 Python 模块编写的。现在,让我们假设我们编写了下面这个小小的阴险程序,名为trouble.py

import halts, sys
name = sys.argv[1]
if halts.check(name, name):
    while True: pass

它使用halts模块的功能来检查一个作为第一个命令行参数给出的程序,如果把它自己作为输入来提供,它是否会停止。它可以这样运行,例如:

trouble.py myprog.py

这将决定如果以myprog.py(即其自身)作为输入,那么myprog.py是否会停止。如果确定它会停止运行,trouble.py将进入无限循环。否则,它将简单地结束(即,暂停)。

现在考虑以下场景:

halts.py trouble.py trouble.py

我们正在检查trouble.py是否会以trouble.py(也就是它自己)作为输入而停止。本身并不那么令人费解。但是结果会是什么呢?如果halts.py说“是”——也就是说trouble.py trouble.py将停止——那么trouble.py trouble.py被定义为不停止。如果我们得到一个“不”,我们就会遇到同样的(逆向)问题。无论哪种方式,halts.py都注定会出错,而且没有办法修复。我们以假设检查器实际工作开始这个故事,现在我们达到了一个矛盾,这意味着我们的假设是错误的。

当然,这并不意味着我们不能检测任何类型的无限循环。例如,看到一个没有breakraisereturnwhile True将是一个强有力的线索。只是一般情况下检测不出来。可悲的是,许多其他类似的属性在一般情况下也无法自动分析。因此,即使有 PyChecker 和 PyLint 这样漂亮的工具,我们也需要依靠根植于我们对程序特殊环境的了解的手工调试。或许,我们应该尽量避免故意编写像trouble.py这样复杂的程序。

压型

现在你已经让你的代码工作了,并且可能比最初的版本更好,是时候让它更快了。那么,同样,它可能不会。正如 Donald Knuth 引用 C. A. R. Hoare 的话说:“过早优化是编程中所有罪恶(或者至少是大部分罪恶)的根源。”如果你真的不需要,不要担心聪明的优化技巧。如果程序足够快,干净、简单、可理解的代码的价值可能比稍快的程序高得多。毕竟,再过几个月,更快的硬件可能就会出现。

但是如果您确实需要优化您的程序,因为它对于您的需求来说根本不够快,您绝对应该在做任何事情之前对它进行概要分析。这是因为很难猜测瓶颈在哪里,除非你的程序非常简单。如果你不知道是什么让你的程序变慢,很可能你优化的是错误的东西。

标准库包括一个名为profile的漂亮的剖析器模块,以及一个更快的 C 版本,名为cProfile。使用分析器很简单。只需用一个字符串参数调用它的run方法。

>>> import cProfile
>>> from my_math import product
>>> cProfile.run('product(1, 2)')

这将为您提供一份打印输出,其中包含各种函数和方法被调用的次数以及在各种函数中花费的时间。如果您提供一个文件名,例如,'my_math.profile',作为要运行的第二个参数,results将被保存到一个文件中。然后,您可以使用pstats模块来检查配置文件。

>>> import pstats
>>> p = pstats.Stats('my_math.profile')

使用这个Stats对象,您可以通过编程来检查结果。(有关 API 的详细信息,请参考标准库文档。)

Tip

标准库还包含一个名为timeit的模块,这是一种计时 Python 代码小片段的简单方法。timeit模块对于详细的概要分析来说并不真正有用,但是当您只想计算一段代码执行需要多少时间时,它会是一个很好的工具。自己尝试这样做经常会导致测量不准确(除非你知道自己在做什么)。使用timeit通常是更好的选择。

现在,如果你真的担心你的程序的速度,你可以添加一个单元测试来分析你的程序并强制执行某些约束(比如如果程序花费超过一秒钟就失败)。这可能是一件有趣的事情,但我不推荐这样做。过分的剖析很容易将你的注意力从真正重要的事情上转移开,比如干净、可理解的代码。如果程序真的很慢,你无论如何都会注意到,因为你的测试将永远无法完成。

快速总结

以下是本章涵盖的主要主题:

  • 测试驱动编程:基本上,测试驱动编程意味着先测试,后编码。测试让您充满信心地重写代码,使您的开发和维护更加灵活。
  • doctest 和 unittest 模块:如果您想用 Python 进行单元测试,这些是必不可少的工具。doctest模块被设计用来检查文档串中的例子,但是也可以很容易地用来设计测试套件。为了让您的套件更加灵活和结构化,unittest框架非常有用。
  • PyChecker 和 PyLint:这两个工具读取源代码并指出潜在的(和实际的)问题。他们检查从短变量名到不可及的代码片段的一切。通过一点编码,你可以使它们(或其中之一)成为你的测试套件的一部分,以确保你所有的重写和重构都符合你的编码标准。
  • 剖析:如果你真的关心速度,并且想要优化你的程序(只有在绝对必要的情况下才这么做),你应该首先剖析它。使用profilecProfile模块找到代码中的瓶颈。

本章的新功能

| 功能 | 描述 | | --- | --- | | `doctest.testmod(module)` | 检查文档字符串示例。(需要更多的参数。) | | `unittest.main()` | 在当前模块中运行单元测试。 | | `profile.run(stmt[, filename])` | 执行并分析`statement`。可选地,将结果保存到`filename`。 |

什么现在?

现在,您已经看到了使用 Python 语言和标准库可以做的各种事情。您已经看到了如何探测和调整您的代码,直到它尖叫起来(如果您认真对待剖析,不顾我的警告)。如果你仍然没有得到你需要的动力,是时候打开盖子,用一些低级工具调整引擎了。

Footnotes 1

当然,像这样只测试一种情况不会给你对代码正确性的信心。一个真正的测试程序可能会更彻底。

2

3.看看大卫·哈雷尔的《计算机有限公司:它们真的不能做什么》(牛津大学出版社,2000 年)中关于这个主题的许多有趣的材料。

十七、扩展 Python

你可以用 Python 实现任何东西,真的;这是一种强大的语言,但有时它会变得有点太慢。例如,如果您正在编写某种形式的核反应的科学模拟,或者您正在为下一部《星球大战》电影渲染图形,用 Python 编写高性能代码可能不是一个好的选择。Python 应该易于使用,并有助于加快开发速度。就效率而言,这种灵活性需要付出高昂的代价。对于大多数常见的编程任务来说,它当然足够快了,但如果您需要真正的速度,C、C++、Java 或 Julia 等语言通常可以超过它几个数量级。

两全其美

现在,我不想鼓励你们中的速度狂开始专门用 c 开发,虽然这可能会加速程序本身,但它肯定会减慢你的编程速度。所以你需要考虑什么是最重要的:快速完成程序或者最终(在遥远的将来)得到一个运行非常非常快的程序。如果 Python 足够快,所涉及的额外痛苦将使使用 C 之类的低级语言成为毫无意义的选择(除非你有其他需求,比如在没有 Python 空间的嵌入式设备上运行,或者类似的东西)。

这一章处理你确实需要额外速度的情况。那么最好的解决方案可能不是完全转向 C 语言(或者其他一些低级或中级语言);相反,我推荐下面的方法,这种方法已经在很多工业级速度狂热者那里奏效了(以这样或那样的形式):

  1. 用 Python 开发一个原型。(参见第十九章了解一些原型制作材料。)
  2. 剖析你的程序并确定瓶颈。(参见第十六章了解一些测试材料。)
  3. 将瓶颈重写为 C(或 C++、C#、Java、Fortran 等)扩展。

由此产生的架构——带有一个或多个 C 组件的 Python 框架——是一个非常强大的架构,因为它结合了两个世界的精华。这是为每项工作选择合适工具的问题。它为您提供了用高级语言(Python)开发复杂系统的好处,并且允许您用低级语言(C)开发较小的(可能更简单的)速度关键组件。

Note

使用 c 还有其他原因。例如,如果你想编写与一个陌生硬件接口的低级代码,你真的没有什么选择。

如果您甚至在开始之前就对系统的瓶颈有所了解,那么您可以(并且可能应该)设计您的原型,以便替换关键部分是容易的。我想我不妨以提示的形式来陈述这一点:

Tip

封装潜在的瓶颈。

您可能会发现,您不需要用 C 扩展来替换瓶颈(也许您突然得到了一台更快的计算机),但至少选项是存在的。

还有一种情况也是扩展的常见用例:遗留代码。您可能希望使用一些只存在于 C 中的代码,然后您可以“包装”这些代码(编写一个小型 C 库,为您提供一个合适的接口)并从您的包装器创建一个 Python 扩展库。

在接下来的几节中,我将为您提供一些扩展 Python 的经典 C 实现的起点,要么自己编写所有代码,要么使用名为 SWIG 的工具,以及扩展另外两个实现:Jython 和 IronPython。您还会发现一些关于访问外部代码的其他选项的提示。请继续阅读。。。

The Other Way Around

在这一章中,我主要关注用编译语言编写 Python 程序的扩展。但是反过来说——用编译语言编写一个程序,并嵌入一个 Python 解释器进行小的脚本编写和扩展——也有它的用处。在这种情况下,嵌入 Python 时您追求的不是速度,而是灵活性。在许多方面,这是用于编写编译后的扩展的相同的“两全其美”的论点;只是重心转移了而已。

嵌入方法在许多现实世界的系统中使用。例如,许多计算机游戏(几乎总是用编译语言编写,代码库主要是为了最大速度而开发的)使用 Python 等动态语言来描述高级行为(如游戏中角色的“智能”),而主代码引擎则负责图形等。

正文中引用的文档(针对 CPython、Jython 和 IronPython)也讨论了嵌入选项,以防您想走这条路。

如果你想使用快速的高级语言 Julia ( http://julialang.org )但是仍然想访问现有的 Python 库,你可以使用PyCall.jl库( https://github.com/stevengj/PyCall.jl )。

真正简单的方法:Jython 和 IronPython

如果你刚好在运行 Jython ( http://jython.org )或者 IronPython ( http://ironpython.net ),那么用原生模块扩展 Python 是相当容易的。原因是 Jython 和 IronPython 让你可以直接访问底层语言的模块和类(Java for Jython,C#和其他)。NET languages for IronPython),所以不需要遵守某些特定的 API(扩展 CPython 时必须遵守)。您只需实现您需要的功能,就像变魔术一样,它将在 Python 中工作。例如,您可以在 Jython 中直接访问 Java 标准库,在 IronPython 中直接访问 C#标准库。

清单 17-1 显示了一个简单的 Java 类。

public class JythonTest {

   public void greeting() {
      System.out.println("Hello, world!");
   }

}

Listing 17-1.A Simple Java Class (JythonTest.java)

你可以用一些 Java 编译器来编译这个,比如javac

$ javac JythonTest.java

Tip

如果您正在使用 Java,您也可以使用命令jythonc将您的 Python 类编译成 Java 类,然后这些 Java 类可以被导入到您的 Java 程序中。

编译完类后,启动 Jython(并将.class文件放在当前目录或 Java CLASSPATH中的某个位置)。

$ CLASSPATH=JythonTest.class jython

然后,您可以直接导入该类。

>>> import JythonTest
>>> test = JythonTest()
>>> test.greeting()
Hello, world!

看到了吗?这没什么。

Jython Property Magic

在与 Java 类交互时,Jython 有几个绝妙的技巧。最明显有用的一点是,它允许您通过普通的属性访问来访问所谓的 JavaBean 属性。在 Java 中,使用访问器方法来读取或修改它们。这意味着如果 Java 实例foo有一个名为setBar的方法,你可以简单地用foo.bar = baz代替foo.setBar(baz)。类似地,如果实例有一个名为getBarisBar(用于布尔属性)的方法,您可以使用foo.bar来访问该值。使用 Jython 文档中的一个例子,而不是这个:

b = awt.Button()
b.setEnabled(False)

你可以用这个:

b = awt.Button()
b.enabled = False

事实上,所有属性都可以通过构造函数中的关键字参数来设置。所以你可以,事实上,简单地写下这个:

b = awt.Button(enabled=False)

这适用于多个参数的元组,甚至适用于 Java 习惯用法(如事件侦听器)的函数参数。

def exit(event):
    java.lang.System.exit(0)
b = awt.Button("Close Me!", actionPerformed=exit)

在 Java 中,您需要用适当的actionPerformed方法实现一个单独的类,然后使用b.addActionListener添加它。

清单 17-2 展示了一个类似的 C#类。

using System;
namespace FePyTest {
  public class IronPythonTest {

    public void greeting() {
      Console.WriteLine("Hello, world!");
    }

  }
}

Listing 17-2.A Simple C# Class (IronPythonTest.cs)

用你选择的编译器编译它。对于微软来说。NET 中,命令如下:

csc.exe /t:library IronPythonTest.cs

在 IronPython 中使用它的一种方法是将类编译成动态链接库(DLL 请参阅 C#安装的文档以获取详细信息)并根据需要更新相关的环境变量(如PATH)。那么您应该能够像下面这样使用它(使用 IronPython 交互式解释器):

>>> import clr
>>> clr.AddReferenceToFile("IronPythonTest.dll")
>>> import FePyTest
>>> f = FePyTest.IronPythonTest()

>>> f.greeting()

有关这些 Python 实现的更多细节,请访问 Jython 网站( http://jython.org )和 IronPython 网站( http://ironpython.net )。

编写 C 扩展

这才是最重要的,真的。扩展 Python 通常意味着扩展 CPython,这是 Python 的标准版本,用编程语言 c 实现。

Tip

有关基本介绍和一些背景材料,请参见维基百科上关于 C、 http://en.wikipedia.org/wiki/C_programming_language 的文章。要了解更多信息,请查阅艾弗·霍顿的书《从 C 开始:从新手到专业人士》,第五版(2013 年出版)。真正权威的信息来源是 Brian Kernighan 和 Dennis Ritchie 的空前经典,他们是语言的发明者:C 编程语言,第二版(Prentice-Hall,1988)。

C 不像 Java 或 C#那样动态,如果你只提供你编译的 C 代码,Python 也不容易自己解决问题。因此,在为 Python 编写 C 扩展时,需要遵循严格的 API。稍后,我将在“自己动手开发”一节中讨论这个 API 不过,有几个项目试图简化编写 C 扩展的过程,其中一个比较著名的项目是 SWIG,我将在下一节讨论它。(参见侧栏“其他方法”,了解一些……其他方法。)

Other Approaches

如果您使用的是 CPython,有很多工具可以帮助您加速程序,或者是通过生成和使用 C 库,或者是通过实际加速 Python 代码。以下是一些选项的概述:

  • Cython ( http://cython.org ):这其实是一个 Python 的编译器!它还提供了扩展的 Cython 语言,基于 Greg Ewing 的旧 Pyrex 项目,允许您添加类型声明,并使用类似 Python 的语法定义 C 类型。结果是非常高效的,并且它与 C 扩展模块(包括 Numpy)很好地交互。
  • PyPy ( http://pypy.org ):这是一个雄心勃勃的、前瞻性的 Python 实现——用 Python 实现。虽然这听起来可能非常慢,但实际上,通过非常高级的代码分析和编译,它通常会优于 CPython。根据该网站的说法,“有传言说秘密目标是比 C 更快,这是胡说八道,不是吗?”PyPy 的核心是 RPython,这是 Python 的一种受限方言。RPython 适用于自动类型推断等,允许翻译成静态语言或本机代码,或者翻译成其他动态语言(如 JavaScript)。
  • Weave(http://scipy.org):SciPy 发行版的一部分,但也可以单独获得,Weave 是一个工具,用于将 C 或 C++代码直接包含在 Python 代码中(作为字符串),并无缝编译和执行代码。例如,如果你想快速计算某些数学表达式,那么这可能是一种方法。Weave 还可以使用数字数组来加速表达式(见下一条)。
  • NumPy ( http://numpy.org ): NumPy 让您可以访问数字数组,这对于分析多种形式的数字数据(从股票价值到天文图像)非常有用。一个优点是简单的接口,这减轻了显式指定许多低级操作的需要。然而,主要的优势是速度。对数值数组中的每个元素执行许多常见操作比用列表和for循环执行类似的操作要快得多,因为隐式循环是直接在 c 中实现的。
  • ctypes(https://docs.python.org/library/ctypes.html):ctypes 模块最初是 Thomas Heller 的一个独立项目,但它现在是标准库的一部分。它采用了一种非常直接的方法——它只是让您导入现有的(共享的)C 库。虽然有一些限制,但这可能是访问 C 代码最简单的方式之一。不需要包装器或特殊的 API。您只需导入库并使用它。
  • 子流程( https://docs.python.org/3/library/subprocess.html ):好吧,这个有点不一样。可以在标准库中找到子流程模块,以及具有类似功能的旧模块和函数。它允许 Python 运行外部程序,并通过命令行参数和标准输入、输出和错误流与它们通信。如果您的速度关键型代码可以在几个长时间运行的批处理作业中完成大部分工作,那么启动程序并与之通信的时间将会很少。在这种情况下,简单地将 C 代码放在一个完全独立的程序中,并作为一个子进程运行,这可能是最干净的解决方案。
  • PyCXX ( http://cxx.sourceforge.net ):以前称为 CXX,或 CXX/Objects,这是一套用于编写 Python 扩展的 C++工具。例如,它包括对引用计数的大量支持,以减少出错的机会。
  • SIP ( http://www.riverbankcomputing.co.uk/software/sip ): SIP(一语双关 SWIG?)最初是作为开发 GUI 包 PyQt 的工具创建的,由代码生成器和 Python 模块组成。它以类似 SWIG 的方式使用规范文件。
  • 助推。python(http://www.boost.org/libs/python/doc”):Boost。Python 旨在实现 Python 和 C之间的无缝互操作性,可以在引用计数和在 C中操作 Python 对象等问题上给你很大的帮助。使用它的一个主要方法是以类似 Python 的风格编写 C代码(通过 Boost 实现)。Python 的宏),然后使用您最喜欢的 C编译器将它直接编译成 Python 扩展。作为 SWIG 的一个相当不同但非常可靠的替代品,这肯定值得一看。

痛饮…痛饮

SWIG ( http://www.swig.org ),是简单包装器和接口生成器的缩写,是一个支持多种语言的工具。一方面,它让你用 C 或 C写你的扩展代码;另一方面,它自动包装这些内容,以便您可以在几种高级语言中使用它们,如 Tcl、Python、Perl、Ruby 和 Java。这意味着,如果您决定将系统的某些部分编写为 C 扩展,而不是直接用 Python 实现,那么 C 扩展库也可以(使用 SWIG)用于许多其他语言。如果您希望用不同语言编写的几个子系统一起工作,这可能非常有用;您的 C(或 C)扩展可以成为合作的中心。

安装 SWIG 遵循与安装其他 Python 工具相同的模式:

  • 你可以从网站 http://www.swig.org 获取 SWIG。
  • 许多 UNIX/Linux 发行版都带有 SWIG。很多包管理器会让你直接安装。
  • 有一个用于 Windows 的二进制安装程序。
  • 自己编译源代码也只是简单地调用configuremake install

如果您在安装 SWIG 时遇到问题,您应该可以在网站上找到有用的信息。

它是做什么的?

如果您有一些 C 代码,使用 SWIG 是一个简单的过程。

  1. 为你的代码写一个接口文件。这与 C 头文件非常相似(对于简单的情况,您可以直接使用您的头文件)。
  2. 在接口文件上运行 SWIG,以便自动生成更多的 C 代码(包装代码)。
  3. 将原始 C 代码与生成的包装代码一起编译,以生成共享库。

在下文中,我将从一些 C 代码开始讨论每一个步骤。

我更喜欢圆周率

回文(如本节的标题)是一个倒着读的句子,如果你忽略空格和标点符号之类的话。假设你想识别巨大的回文,不考虑空格和朋友。(也许你需要它来分析蛋白质序列或其他东西。)当然,字符串必须非常大,这对于纯 Python 程序来说是个问题,但是假设字符串非常大,并且您需要进行大量的检查。您决定编写一段 C 代码来处理它(或者您可能找到一些完成的代码——如前所述,在 Python 中使用现有的 C 代码是 SWIG 的主要用途之一)。清单 17-3 显示了一个可能的实现。

#include <string.h>

int is_palindrome(char *text) {
    int i, n=strlen(text);
    for (i = 0; I <= n/2; ++i) {
        if (text[i] != text[n-i-1]) return 0;
    }
    return 1;
}

Listing 17-3.A Simple C Function for Detecting a Palindrome (palindrome.c)

仅供参考,清单 17-4 中显示了一个等价的纯 Python 函数。

def is_palindrome(text):
    n = len(text)
    for i in range(len(text) // 2):
        if text[i] != text[n-i-1]:
            return False
    return True
Listing 17-4.Detecting Palindromes in Python

稍后您将看到如何编译和使用 C 代码。

接口文件

假设您将清单 17-3 中的代码放在一个名为palindrome .c的文件中,现在您应该将一个接口描述放在一个名为palindrome.i的文件中。在很多情况下,如果你定义了一个头文件(也就是palindrome.h),SWIG 也许能够从中获得它所需要的信息。所以如果你有一个头文件,请随意尝试使用它。显式编写接口文件的原因之一是,您可以调整 SWIG 实际包装代码的方式;最重要的调整是排除事物。例如,如果您正在包装一个巨大的 C 库,也许您只想将几个函数导出到 Python。在这种情况下,您只需将想要导出的函数放在接口文件中。

在接口文件中,您只需声明想要导出的所有函数(和变量),就像在头文件中一样。此外,在顶部有一个部分(由%{%}分隔),您可以在其中指定包含的头文件(比如我们的例子中的string.h),甚至在那之前还有一个%module声明,给出了模块的名称。(其中一些是可选的,您可以使用接口文件做更多的事情;有关更多信息,请参见 SWIG 文档。)清单 17-5 显示了这个接口文件。

%module palindrome

%{
#include <string.h>
%}

extern int is_palindrome(char *text);

Listing 17-5.Interface to the Palindrome Library (palindrome.i)

跑步痛饮

运行 SWIG 可能是这个过程中最简单的部分。尽管有许多命令行开关可用(试着运行swig -help获得选项列表),唯一需要的是python选项,以确保 SWIG 包装了您的 C 代码,以便您可以在 Python 中使用它。您可能会发现另一个有用的选项是-c++,如果您正在包装一个 C++库,您可以使用它。您可以使用接口文件(或者,如果您愿意,也可以使用头文件)运行 SWIG,如下所示:

$ swig -python palindrome.i

在此之后,您应该有两个新文件:一个名为palindrome_wrap.c,另一个名为palindrome.py

编译、链接和使用

编译可能是最棘手的部分(至少我是这样认为的)。为了正确地编译,您需要知道在哪里保存您的 Python 发行版的源代码(或者,至少,名为pyconfig.hPython.h的头文件;您可能会分别在 Python 安装的根目录和Include子目录中找到它们。您还需要找出正确的开关,用您选择的 C 编译器将您的代码编译到一个共享库中。如果您在寻找参数和开关的正确组合方面有困难,请看下一节“穿过编译器魔法森林的捷径”

这里是一个使用cc编译器的 Solaris 示例,假设$PYTHON_HOME指向 Python 安装的根目录:

$ cc -c palindrome.c
$ cc -I$PYTHON_HOME -I$PYTHON_HOME/Include -c palindrome_wrap.c
$ cc -G palindrome.o palindrome_wrap.o -o _palindrome.so

下面是在 Linux 中使用gcc编译器的顺序:

$ gcc -c palindrome.c
$ gcc -I$PYTHON_HOME -I$PYTHON_HOME/Include -c palindrome_wrap.c
$ gcc -shared palindrome.o palindrome_wrap.o -o _palindrome.so

可能是所有需要的包含文件都在一个地方找到了,比如/usr/include/python3.5(根据需要更新版本号)。在这种情况下,以下方法应该可以解决问题:

$ gcc -c palindrome.c
$ gcc -I/usr/include/python3.5 -c palindrome_wrap.c
$ gcc -shared palindrome.o palindrome_wrap.o -o _palindrome.so

在 Windows 中(再次假设您在命令行上使用gcc,您可以使用下面的命令作为最后一个命令,来创建共享库:

$ gcc -shared palindrome.o palindrome_wrap.o C:/Python25/libs/libpython25.a -o_palindrome.dll

在 macOS 中,您可以做如下事情(如果您使用官方 Python 安装,那么PYTHON_HOME就是/Library/Frameworks/Python.framework/Versions/Current):

$ gcc -dynamic -I$PYTHON_HOME/include/python3.5 -c palindrome.c
$ gcc -dynamic -I$PYTHON_HOME/include/python3.5 -c palindrome_wrap.c
$ gcc -dynamiclib palindrome_wrap.o palindrome.o -o _palindrome.so -Wl, -undefined, dynamic_lookup

Note

如果在 Solaris 上使用gcc,将标志-fPIC添加到前两个命令行中(就在命令gcc之后)。否则,当您试图在最后一个命令中链接文件时,编译器会变得非常混乱。此外,如果您使用包管理器(在许多 Linux 平台中很常见),您可能需要安装一个单独的包(称为类似于python-dev的东西)来获得编译您的扩展所需的头文件。

在这些黑暗魔法咒语之后,你应该会得到一个非常有用的叫做_palindrome.so的文件。这是您的共享库,可以直接导入到 Python 中(如果它放在您的PYTHONPATH中的某个地方,比如在当前目录中):

>>> import _palindrome
>>> dir(_palindrome)
['__doc__', '__file__', '__name__', 'is_palindrome']
>>> _palindrome.is_palindrome('ipreferpi')
1
>>> _palindrome.is_palindrome('notlob')
0

在老版本的 SWIG 中,这就是全部内容。然而,SWIG 的最新版本也用 Python 生成了一些包装代码(文件palindrome.py,还记得吗?).这个包装器代码导入了_palindrome模块,并负责一些检查。如果你想跳过这一步,你可以删除palindrome.py文件并将你的库直接链接到一个名为palindrome.so的文件中。

使用包装器代码和使用共享库一样有效。

>>> import palindrome
>>> from palindrome import is_palindrome
>>> if is_palindrome('abba'):
...     print('Wow -- that never occurred to me ...')
...
Wow -- that never occurred to me ...

穿越编译器魔法森林的捷径

如果你觉得编译过程有点神秘,你并不孤单。如果您自动化编译(比如说,使用 makefile),用户将需要通过指定 Python 的安装位置、使用编译器的特定选项以及使用哪个编译器来配置设置。使用 Setuptools 可以很好地避免这种情况。事实上,它直接支持 SWIG,所以您甚至不需要手动运行它。您只需编写代码和接口文件,并运行您的安装脚本。关于这个魔术的更多信息,请参见第十八章中的“编译扩展”一节。

自己黑进去

SWIG 在幕后施展了相当多的魔法,但并不是所有的都是绝对必要的。如果你想接近金属,在处理器上咬牙切齿,可以这么说,你当然可以自己写你的包装器代码,或者干脆写你的 C 代码,让它直接用 Python C API。

Python C API 有自己的手册,Python/C API 参考手册( https://docs.python.org/3/c-api )。更温和的介绍可以在标准库手册的相关章节中找到( https://docs.python.org/3/extending )。在这里,我会试着更温和(更简短)。如果您对我遗漏的内容(相当多)感到好奇,您应该看看官方文档。

引用计数

如果您以前没有使用过它,引用计数可能是您在本节中遇到的最陌生的概念之一,尽管它并没有那么复杂。在 Python 中,内存使用是自动处理的——您只需创建对象,当您不再使用它们时,它们就会消失。在 C 语言中,情况并非如此。您必须显式地释放不再使用的对象(或者说,内存块)。如果你不这样做,你的程序可能会占用越来越多的内存,这就是所谓的内存泄漏。

在编写 Python 扩展时,您可以使用 Python 在“幕后”使用的管理内存的工具,其中之一就是引用计数。其思想是,只要代码的某些部分引用了一个对象(在 C 语言中就是指向它的指针),就不应该释放它。然而,一旦对一个对象的引用数量达到零,这个数量就不能再增加了——没有代码可以创建对它的新引用,它只是在内存中“自由浮动”。此时,解除分配它是安全的。引用计数自动化了这一过程。您遵循一组规则,在这些规则中,您在各种情况下(通过 Python API 的一部分)增加或减少对象的引用计数,如果计数变为零,对象将被自动释放。这意味着没有一段代码单独负责管理一个对象。你可以创建一个对象,从一个函数返回它,然后忘记它,安全地知道当不再需要它时它会消失。

您使用两个宏Py_INCREFPy_DECREF,分别增加和减少一个对象的引用计数。您可以在 Python 文档中找到关于如何使用它们的详细信息,但这里是它的要点:

  • 您不能拥有一个对象,但可以拥有对它的引用。对象的引用计数是对该对象拥有的引用数。
  • 如果您拥有一个引用,当您不再需要该引用时,您有责任调用Py_DECREF
  • 如果你临时借用一个引用,当你使用完这个对象时,你不应该调用Py_DECREF;那是车主的责任。

Caution

在拥有者已经处置它之后,你当然不应该使用一个被借用的引用。有关保持安全的更多建议,请参见文档中的“薄冰”部分。

  • 您可以通过调用Py_INCREF将借用的引用转换为拥有的引用。这将创建一个新的拥有的引用;原始所有者仍然拥有原始引用。
  • 当您以参数的形式接收一个对象时,您可以决定是希望转移其引用的所有权(例如,如果您要将它存储在某个地方),还是仅仅希望借用它。这应该清楚地记录下来。如果您的函数是从 Python 中调用的,那么简单地借用是安全的——对象将在函数调用期间一直存在。然而,如果你的函数是从 C 中调用的,这是不能保证的,你可能想创建一个拥有的引用,然后在完成后释放它。

希望当我们过一会儿来看一个具体的例子时,这一切会看起来更清楚。

More Garbage Collection

引用计数是垃圾收集的一种形式,垃圾一词指的是对程序不再有用的对象。Python 还使用更复杂的算法来检测循环垃圾;也就是说,对象只相互引用(因此引用计数不为零),但没有其他对象引用它们。

您可以通过 gc 模块在 Python 程序中访问 Python 垃圾收集器。你可以在 Python 库参考( https://docs.python.org/3/library/gc.html )中找到更多关于它的信息。

扩展的框架

编写 Python C 扩展需要相当多的千篇一律的代码,这就是诸如 SWIG 和 Cython 这样的工具如此之好的原因。自动化千篇一律的代码是一条出路。不过,手工操作会是一次很好的学习经历。实际上,在如何构建代码方面,你确实有相当大的回旋余地。我会告诉你一个可行的方法。

首先要记住的是,必须首先包含Python.h头文件,在其他标准头文件之前。这是因为在某些平台上,它可能会执行一些应该由其他头使用的重新定义。因此,为了简单起见,只需这样放置:

#include <Python.h>

作为代码的第一行。

你的函数可以叫任何你想叫的名字。它应该是static,返回一个指向PyObject类型对象的指针(一个拥有的引用),并接受两个参数,两个参数都指向PyObject。这些对象通常被称为selfargs(其中self是自身对象,或者NULL,而args是一组参数)。换句话说,该函数应该如下所示:

static PyObject *somename(PyObject *self, PyObject *args) {
    PyObject *result;
    /* Do something here, including allocating result. */

    Py_INCREF(result); /* Only if needed! */
    return result;
}

self参数实际上只在绑定方法中使用。在其他函数中,它只是一个NULL指针。

注意,可能不需要调用Py_INCREF。如果对象是在函数中创建的(例如,使用像Py_BuildValue这样的实用函数),函数将已经拥有对它的引用,并且可以简单地返回它。然而,如果您希望从函数中返回None,您应该使用现有的对象Py_None。然而,在这种情况下,该函数不拥有对Py_None的引用,因此应该在返回之前调用Py_INCREF(Py_None)

args参数包含函数的所有参数(除了self参数,如果有的话)。为了提取对象,使用函数PyArg_ParseTuple(用于位置参数)和PyArg_ParseTupleAndKeywords(用于位置和关键字参数)。这里我将坚持立场论点。

函数PyArg_ParseTuple具有以下签名:

int PyArg_ParseTuple(PyObject *args, char *format, ...);

格式字符串描述了您期望的参数,然后您提供了您希望在末尾填充的变量的地址。返回值是一个布尔值。如果是真的,一切都很顺利;否则,就有错误。如果有错误,引发异常的适当准备工作已经完成(您可以在文档中了解更多),您需要做的就是返回NULL来启动进程。因此,如果您不需要任何参数(空格式字符串),下面是处理参数的一种有用方法:

if (!PyArg_ParseTuple(args, "")) {
     return NULL;
}

如果代码执行到该语句之外,您知道您有参数(在本例中,没有参数)。格式字符串可以看起来像是字符串的"s",整数的"i",Python 对象的"o",可能的组合有两个整数和一个字符串的"iis"。还有更多格式字符串代码。如何编写格式字符串的完整参考可以在 Python/C API 参考手册( https://docs.python.org/3/c-api/arg.html )中找到。

Note

您也可以在扩展模块中创建自己的内置类型和类。这并不太难,真的,但仍然是一个相当复杂的主题。如果您主要需要将一些瓶颈代码分解到 C 中,那么使用函数可能就足以满足您的大部分需求。如果您想学习如何创建类型和类,Python 文档是一个很好的信息来源。

一旦你有了你的函数,仍然需要一些额外的包装来使你的 C 代码作为一个模块。但是一旦我们有了一个真实的例子,让我们回到这个话题,好吗?

回文,详细描述 1 供您欣赏

事不宜迟,我给你清单 17-6 中手工编码的 Python C API 版本的palindrome模块(添加了一些有趣的新东西)。

#include <Python.h>

static PyObject *is_palindrome(PyObject *self, PyObject *args) {
    int i, n;
    const char *text;
    int result;
    /* "s" means a single string: */
    if (!PyArg_ParseTuple(args, "s", &text)) {
        return NULL;
    }
    /* The old code, more or less: */
    n=strlen(text);
    result = 1;
    for (i = 0; i <= n/2; ++i) {
        if (text[i] != text[n-i-1]) {
            result = 0;
            break;
        }
    }
    /* "i" means a single integer: */
    return Py_BuildValue("i", result);
}

/* A listing of our methods/functions: */
static PyMethodDef PalindromeMethods[] = {

    /* name, function, argument type, docstring */
    {"is_palindrome", is_palindrome, METH_VARARGS, "Detect palindromes"},
    /* An end-of-listing sentinel: */
    {NULL, NULL, 0, NULL}

};

static struct PyModuleDef palindrome =
{
    PyModuleDef_HEAD_INIT,
    "palindrome", /* module name */
    "",           /* docstring */
    -1,           /* signals state kept in global variables */
    PalindromeMethods
};

/* An initialization function for the module: */
PyMODINIT_FUNC PyInit_palindrome(void)
{
    return PyModule_Create(&palindrome); 

}

Listing 17-6.Palindrome Checking Again (palindrome2.c)

清单 17-6 中添加的大部分内容完全是样板文件。在你看到palindrome的地方,你可以插入你的模块的名字。在你看到is_palindrome的地方,插入你的函数名。如果你有更多的函数,只需在PyMethodDef数组中列出它们。不过,有一点值得注意:初始化函数的名字必须是initmodule,其中module是你的模块的名字;否则 Python 是找不到的。

所以,我们来编译吧!除了现在只需要处理一个文件之外,您可以像 SWIG 一节中描述的那样来做。下面是一个使用gcc的例子(记得在 Solaris 中添加-fPIC):

$ gcc -I$PYTHON_HOME -I$PYTHON_HOME/Include -shared palindrome2.c -o palindrome.so

同样,您应该有一个名为palindrome.so的文件供您使用。把它放在您的PYTHONPATH中的某个地方(比如当前目录),然后我们开始:

>>> from palindrome import is_palindrome
>>> is_palindrome('foobar')
0
>>> is_palindrome('deified')
1

就这样。现在去玩吧。(但是要小心;还记得这本书引言中引用的瓦尔迪·雷文斯的话吗?)

快速总结

扩展 Python 是一个巨大的课题。本章提供的一瞥包括以下内容:

  • 扩展理念:Python 扩展主要有两个用途:使用现有(遗留)代码或加速瓶颈。如果你正在从头开始写你自己的代码,试着用 Python 做原型,找到瓶颈,如果需要的话把它们作为扩展。预先封装潜在的瓶颈可能是有用的。
  • Jython 和 IronPython:扩展 Python 的这些实现非常容易。您只需在底层实现中将您的扩展实现为一个库(Java for Jython 和 C#或其他)。IronPython 的. NET 语言),代码可以直接从 Python 中使用。
  • 扩展方法:有很多工具可以用来扩展或加速你的代码。您可以找到一些工具,使将 C 代码合并到 Python 程序中变得更加容易,加快数字数组操作等常见操作的速度,并加快 Python 本身的速度。这类工具包括 SWIG、Cython、Weave、NumPy、ctypes 和 subprocess。
  • SWIG: SWIG 是一个为你的 C 库自动生成包装代码的工具。包装器代码负责 Python C API,因此您不必处理它。SWIG 是最简单也是最流行的 Python 扩展方式之一。
  • 使用 Python/C API:您可以自己编写 C 代码,这些代码可以作为共享库直接导入 Python。为此,您必须遵守 Python/C API。对于每个函数,您需要注意的事情包括引用计数、提取参数和构建返回值。让一个 C 库像一个模块一样工作也需要一定量的代码,包括列出模块中的函数,创建一个模块初始化函数。

本章的新功能

| 功能 | 描述 | | --- | --- | | `Py_INCREF(obj)` | 增加参考计数`obj` | | `Py_DECREF(obj)` | 减少参考计数`obj` | | `PyArg_ParseTuple(args, fmt, ...)` | 提取位置参数 | | `PyArg_ParseTupleAndKeywords(args, kws, fmt, kwlist)` | 提取位置参数和关键字参数 | | `PyBuildValue(fmt, value)` | 从 C 值构建一个`PyObject` |

什么现在?

现在你应该有一些非常酷的程序或者至少一些非常酷的程序想法。一旦你有了想与世界分享的东西(你确实想与世界分享你的代码,不是吗?),下一章可以是你的下一步。

Footnotes 1

也就是说,酒石酸盐已被删除。好吧,所以这个词与代码完全无关(与果汁更相关),但至少它是一个回文。

十八、打包您的程序

一旦您的程序准备好发布,您可能希望在发布它之前对它进行适当的打包。如果它只包含一个.py文件,这可能不是什么大问题。然而,如果你面对的是非程序员用户,即使在正确的位置放置一个简单的 Python 库或者摆弄一下PYTHONPATH也可能超出他们想要处理的范围。用户通常想简单地双击一个安装程序,按照一些安装向导,然后让您的程序准备运行。

最近,Python 程序员也已经习惯了类似的便利,尽管使用了稍微低级一些的接口。用于分发 Python 包的 Setuptools 工具包和旧的 Distutils 使得用 Python 编写安装脚本变得很容易。您可以使用这些脚本来构建用于分发的归档文件,程序员(用户)可以使用这些文件来编译和安装您的库。

在这一章中,我主要关注 Setuptools,因为它是每一个 Python 程序员工具箱中必不可少的工具。Setuptools 实际上超越了 Python 库的基于脚本的安装。编译扩展也相当方便,有了扩展py2exe and py2app,你甚至可以构建独立的 Windows 和 macOS 可执行程序。

设置工具基础

您可以在 Python 打包用户指南(packaging.python.org)和 Setuptools 网站( http://setuptools.readthedocs.io )上找到大量相关文档。您可以通过编写清单 18-1 中所示的简单脚本来使用 Setuptools 做各种有用的事情。(如果你还没有安装工具,你可以使用pip来安装。)

from setuptools import setup

setup(name='Hello',
      version='1.0',
      description='A simple example',
      author='Magnus Lie Hetland',
      py_modules=['hello'])

Listing 18-1.Simple Setuptools Setup Script (setup.py)

您实际上不必在setup函数中提供所有这些信息(您实际上根本不需要提供任何参数),并且您当然可以提供更多信息(例如author_emailurl)。名称应该是不言自明的。将清单 18-1 中的脚本保存为setup.py(这是 Distutils 安装脚本的通用约定),并确保在同一个目录中有一个名为hello.py的简单模块。

Caution

运行安装脚本时,它会在当前目录下创建新的文件和子目录,因此您应该在一个新的目录下进行试验,以避免旧文件被覆盖。

现在让我们看看如何使用这个简单的脚本。按如下方式执行:

python setup.py

您应该得到如下所示的输出:

usage: setup.py [global_opts] cmd1 [cmd1_opts] [cmd2 [cmd2_opts] ...]
   or: setup.py --help [cmd1 cmd2 ...]
   or: setup.py --help-commands
   or: setup.py cmd --help

error: no commands supplied

如您所见,您可以使用--help--help-commands开关获得更多信息。尝试发出build命令,看看 Setuptools 的运行情况。

python setup.py build

您现在应该会看到如下所示的输出:

running build
running build_py
creating build
creating build/lib
copying hello.py -> build/lib

Setuptools 创建了一个名为build的目录,子目录名为lib,并在build/lib中放置了一个hello.py的副本。build子目录是一种工作区,Setuptools 在这里组装一个包(例如,编译扩展库)。安装时你并不真的需要运行build命令,因为如果需要,当你运行install命令时,它会自动运行。

Note

在这个例子中,install命令将把hello.py模块复制到您的PYTHONPATH中某个特定于系统的目录中。这应该不会造成风险,但是如果您不想让您的系统变得混乱,您可能希望在之后删除它。记下放置的具体位置,如setup.py输出。你也可以使用-n开关来做一次演习。在撰写本文时,还没有标准的uninstall命令(尽管您可以在网上找到定制的卸载实现),所以您需要手动卸载该模块。

说到这个。。。让我们试着安装模块。

python setup.py install

现在,您应该会看到相当多的输出,以类似如下的内容结束:

Installed /path/to/python3.5/site-packages/Hello-1.0-py3.5.egg
Processing dependencies for Hello==1.0
Finished processing dependencies for Hello==1.0 byte-compiling

Note

如果您运行的 Python 版本不是您自己安装的,并且没有适当的权限,您可能不被允许安装所示的模块,因为您没有对正确目录的写权限。

这是用于安装 Python 模块、包和扩展的标准机制。您需要做的只是提供一个小的设置脚本。如您所见,作为安装过程的一部分,Setuptools 构建了一个egg,一个自包含的捆绑 Python 包。

示例脚本只使用 Setuptools 指令py_modules。如果您想安装整个包,您可以以同样的方式使用指令packages(只需列出包名)。您还可以设置许多其他选项(其中一些将在本章后面的“编译扩展”一节中介绍)。这些选项允许您指定要安装的内容和安装位置。您的配置可以用于多种用途。下一节将向您展示如何将您指定要安装的模块包装成归档文件,以备分发。

包装东西

一旦你写了一个让用户安装你的模块的setup.py脚本,你就可以用它来构建一个归档文件。您还可以构建一个 Windows installer、一个 RPM 包、egg发行版或wheel发行版,等等。(最终,轮子将取代鸡蛋。)我将带您浏览一下.tar.gz示例,您应该很容易从文档中找到其他格式。

您可以使用sdist(用于“源代码分发”)命令构建一个源代码归档文件。

python setup.py sdist

如果您运行这个,您可能会得到相当多的输出,包括一些警告。我收到的警告包括关于丢失author_email选项、丢失README文件和丢失 URL 的投诉。您可以放心地忽略所有这些(尽管您可以随意在您的setup.py脚本中添加一个author_email选项,类似于author选项,并在当前目录中添加一个README.txt文本文件)。

在警告之后,您应该会看到如下输出:

creating Hello-1.0/Hello.egg-info
making hard links in Hello-1.0...
hard linking hello.py -> Hello-1.0
hard linking setup.py -> Hello-1.0
hard linking Hello.egg-info/PKG-INFO -> Hello-1.0/Hello.egg-info
hard linking Hello.egg-info/SOURCES.txt -> Hello-1.0/Hello.egg-info
hard linking Hello.egg-info/dependency_links.txt -> Hello-1.0/Hello.egg-info
hard linking Hello.egg-info/top_level.txt -> Hello-1.0/Hello.egg-info
Writing Hello-1.0/setup.cfg
Creating tar archive
removing 'Hello-1.0' (and everything under it)

现在,除了build子目录,您应该还有一个名为dist的子目录。在里面,你会发现一个名为Hello-1.0.tar.gzgzip编辑的tar档案。这可以分发给其他人,他们可以解包并使用附带的setup.py脚本进行安装。如果您不想要一个.tar.gz文件,可以使用其他几种发行版格式,您可以通过命令行开关--formats来设置它们。(正如复数名称所示,您可以提供多种格式,用逗号分隔,以便一次性创建更多的归档文件。)将--help-formats开关切换到sdist,您将获得可用格式的列表。

编译扩展

在第十七章中,你看到了如何为 Python 编写扩展。您可能同意编译这些扩展有时有点麻烦。幸运的是,您也可以为此使用 Distutils。你可能想参考第十七章来获得程序palindrome的源代码(在清单 17-6 中)。假设您在当前(空)目录中有源文件palindrome2.c,下面的setup.py脚本可以用来编译(和安装)它:

from setuptools import setup, Extension

setup(name='palindrome',
      version='1.0',
      ext_modules = [
          Extension('palindrome', ['palindrome2.c'])
      ])

如果用这个setup.py脚本运行install命令,那么palindrome扩展模块应该会在安装之前自动编译。正如您所看到的,您没有指定模块名称的列表,而是给了ext_modules参数一个Extension实例的列表。构造函数接受一个名称和相关文件的列表;例如,您可以在这里指定头文件(.h)。

如果您更愿意就地编译扩展(对于大多数 UNIX 系统,在当前目录中生成一个名为palindrome.so的文件),您可以使用下面的命令:

python setup.py build_ext --inplace

现在我们开始真正有趣的部分。如果你安装了 SWIG(见第十七章),你可以让 Setuptools 直接使用它!

看看清单 17-3 中原始palindrome.c(没有所有包装代码)的源代码。当然比包装版简单多了。能够将它直接编译成 Python 扩展,让 Distutils 为您使用 SWIG,会非常方便。这真的非常简单——你只需将接口(.i)文件的名称(见清单 17-5)添加到Extension实例的文件列表中。

from setuptools import setup, Extension

setup(name='palindrome',
      version='1.0',
      ext_modules = [
          Extension('_palindrome', ['palindrome.c',
                                    'palindrome.i'])
      ])

如果您使用与之前相同的命令(build_ext,可能使用--inplace开关)运行这个脚本,那么您应该会再次得到一个.so文件(或者类似的文件),但是这次您不需要自己编写所有的包装器代码。请注意,我已经将扩展命名为_palindrome,因为 SWIG 将创建一个名为palindrom.py的包装器,以此名称导入一个 C 库。

用 py2exe 创建可执行程序

Setuptools 的py2exe扩展(可通过pip获得)允许你构建可执行的 Windows 程序(.exe文件),如果你不想让你的用户负担必须单独安装一个 Python 解释器,这可能是有用的。py2exe包可以用来创建带有 GUI 的可执行文件(如第十二章所述)。让我们在这里用一个非常简单的例子:

print('Hello, world!')
input('Press <enter>')

同样,从只包含这个名为hello.py的文件的空目录开始,创建一个setup.py文件,如下所示:

from distutils.core import setup
import py2exe

setup(console=['hello.py'])

您可以像这样运行这个脚本:

python setup.py py2exe

这将在dist子目录中创建一个控制台应用(名为hello.exe)和几个其他文件。您可以从命令行运行它,也可以双击它。

有关py2exe如何工作以及如何以更高级的方式使用它的更多信息,请访问py2exe网站( http://www.py2exe.org )。如果你使用的是 macOS,你可能想看看py2app ( http://pythonhosted.org/py2app ),它为那个平台提供了类似的功能。

Registering Your Package with PYPI

如果您希望其他人能够使用pip安装您的包,您必须用 Python 包索引 PyPI 注册它。标准库文档详细描述了这是如何工作的,但本质上您使用以下命令:

python setup.py register

此时,您将看到一个菜单,允许您登录或注册为新用户。一旦您的包被注册,您就可以使用upload命令将它上传到 PyPI。例如,

python setup.py sdist upload

将上传源分布。

快速总结

最后,您现在知道了如何用花哨的 GUI 安装程序创建闪亮、专业外观的软件——或者如何自动生成那些珍贵的.tar.gz文件。下面是对所涵盖的具体概念的总结:

  • setup tools:setup tools 工具包允许您编写安装程序脚本,通常称为setup.py。使用这些脚本,您可以安装模块、软件包和扩展。
  • Setuptools 命令:你可以用几个命令运行你的setup.py脚本,比如buildbuild_extinstallsdistbdist
  • 编译扩展:您可以使用 Setuptools 自动编译您的 C 扩展,Setuptools 会自动定位您的 Python 安装并确定使用哪个编译器。你甚至可以让它自动运行 SWIG。
  • 可执行二进制文件:Setuptools 的py2exe扩展可用于从 Python 程序创建可执行的 Windows 二进制文件。除了几个额外的文件(可以通过安装程序方便地安装),这些.exe文件可以在不单独安装 Python 解释器的情况下运行。py2app扩展为 macOS 提供了类似的功能。

本章的新功能

| 功能 | 描述 | | --- | --- | | `setuptools.setup(...)` | 在您的`setup.py`脚本中配置带有关键字参数的 Setuptools |

什么现在?

这就是技术方面的东西——算是吧。在下一章,你会学到一些编程方法和哲学,然后是项目。好好享受!

十九、有趣的编程

此时,您应该对 Python 的工作原理有了比开始时更清晰的了解。可以说,现在橡胶上路了,在接下来的十章中,你将把你的新技能投入到工作中。每章包含一个自己动手的项目,有很大的实验空间,同时给你必要的工具来实现一个解决方案。

在这一章,我给你一些用 Python 编程的通用指南。

为什么好玩?

我认为 Python 的优势之一是它让编程变得有趣——至少对我来说是这样。当你玩得开心的时候,更容易富有成效;Python 的一个有趣之处是它让你变得非常有效率。这是一个积极的反馈循环,你在生活中得到的太少了。

戏谑编程是我发明的一个不太极端的极端编程版本,简称 XP。我喜欢 XP 运动的许多想法,但一直懒得完全遵守它们的原则。相反,我选择了一些东西,并将它们与我认为是用 Python 开发程序的一种自然方式结合起来。

编程的柔术

你可能听说过柔术?这是一种日本武术,像它的后代柔道和合气道一样, 2 专注于反应的灵活性,或“弯曲而不是打破”不要试图把你预先计划好的动作强加给对手,你要随波逐流,用对手的动作来对付他。这样(理论上)你就可以打败比你更大、更卑鄙、更强的对手。

这如何应用于编程?关键是音节“ju”,它可以(非常粗略地)翻译为灵活性。当你在编程中遇到麻烦时(你总是会遇到),不要僵硬地坚持你最初的设计和想法,要灵活。逆来顺受。准备好改变和适应。不要把不可预见的事件视为令人沮丧的干扰;将它们视为创造性探索新选择和可能性的刺激起点。

关键是,当你坐下来计划你的程序应该如何时,你对那个特定的程序没有任何实际的经验。你怎么能这样?毕竟现在还不存在。通过致力于实现,您会逐渐学到新的东西,这些东西在您进行最初的设计时可能是有用的。不要忽视你在这个过程中获得的经验,你应该用它们来重新设计(或重构)你的软件。我并不是说你应该在不知道你要去哪里的情况下就开始动手,而是说你应该为改变做好准备,并接受你最初的设计需要修改。就像老作家说的:“写作就是改写。”

这种灵活性的实践有许多方面;在这里,我将谈到其中的两个:

  • 原型:Python 的一个好处是可以快速编写程序。写一个原型程序是了解你的问题的一个很好的方法。
  • 配置:灵活性有多种形式。配置的目的是为了让您和您的用户更容易地更改程序的某些部分。

第三个方面,自动化测试,如果你想能够容易地改变你的程序,是绝对必要的。有了测试,你就可以确定你的程序在引入修改后仍然工作。原型和配置将在下面的章节中讨论。有关测试的信息,请参见第十六章。

样机研究

总的来说,如果你想知道 Python 中的一些东西是如何工作的,那就试试吧。你不需要做大量的预处理,比如编译或者链接,这在其他语言中是必须的。你可以直接运行你的代码。不仅如此,你还可以在交互式解释器中一点一点地运行它,直到你完全理解它的行为。

这种探索不仅仅涉及语言特性和内置函数。当然,能够准确地找出iter函数是如何工作的是有用的,但是更重要的是能够轻松地创建您将要编写的程序的原型,只是为了看看它是如何工作的。

Note

在这个上下文中,单词 prototype 意味着一个试验性的实现,一个实现最终程序的主要功能的模型,但是可能需要在以后的某个阶段完全重写,或者不重写。通常,最初的原型可以变成一个工作程序。

在您对程序的结构进行了一些思考之后(比如您需要哪些类和函数),我建议实现一个简单的版本,可能功能非常有限。你会很快注意到,当你有一个正在运行的程序时,这个过程变得简单多了。你可以添加功能,改变你不喜欢的东西,等等。你可以真正看到它是如何工作的,而不只是想想或者在纸上画图表。

您可以在任何编程语言中使用原型,但是 Python 的优势在于编写一个模型是一项非常小的投资,所以您不一定要使用它。如果你发现你的设计没有想象中的聪明,你可以简单地扔掉你的原型,从头开始。这个过程可能需要几个小时或一两天。举例来说,如果你用 C++编程,可能需要做更多的工作来启动和运行一些东西,放弃它将是一个重大的决定。通过提交一个版本,你失去了灵活性;你会被早期的决定所束缚,根据你实际执行的经验,这些决定可能是错误的。

在本章后面的项目中,我一直使用原型而不是预先的详细分析和设计。每个项目都分为两个实现。第一个是摸索性实验,在这个实验中,我拼凑了一个解决问题(或者可能只是问题的一部分)的程序,以便了解一个好的解决方案所需的组件和要求。最大的教训可能是看到程序运行中的所有缺陷。通过建立在这个新发现的知识上,我采取了另一个,希望是更明智的,打击它。当然,你可以随意修改代码,甚至第三次从头开始。通常,从零开始并不像你想象的那样需要太多时间。如果你已经考虑过程序的实用性,打字不会花太长时间。

The Case Against Rewriting

虽然我在这里提倡使用原型,但是在任何时候都有理由对从零开始重新启动项目持谨慎态度,尤其是如果您已经在原型上投入了一些时间和精力。出于几个原因,将原型重构并修改成一个更具功能性的系统可能更好。

一个常见的问题是“第二系统综合症”这是试图让第二个版本变得如此聪明或完美的趋势,以至于它永远不会完成。

“持续重写综合症”在小说写作中非常普遍,它倾向于不断修改你的程序,也许是一次又一次地从头开始。在某些时候,保持足够好的状态可能是最好的策略——只要得到有用的东西。

然后是“代码疲劳”。你厌倦了你的代码。当你使用它很长时间后,你会觉得它又丑又笨。可悲的是,它看起来粗糙和笨拙的原因之一是,它已经适应了一系列特殊情况,并合并了几种形式的错误处理等。无论如何,这些都是您需要在新版本中重新引入的特性,并且它们可能已经花费了您相当多的精力(不仅仅是以调试的形式)来实现。

换句话说,如果你认为你的原型可以变成一个可行的系统,无论如何,继续研究它,而不是重新开始。在接下来的项目章节中,我将开发清晰地分为两个版本:原型和最终程序。这部分是为了清晰,部分是为了突出通过编写一个软件的第一个版本可以获得的经验和洞察力。在现实世界中,我很可能从原型开始,朝着最终系统的方向“重构自己”。

想了解更多关于从头开始的恐惧,看看乔尔·斯波尔斯基的文章《你不该做的事,第一部分》(见他的网站, http://joelonsoftware.com )。斯波尔斯基认为,从头重写代码是任何软件公司都会犯的最严重的战略错误。

配置

在这一节中,我将回到非常重要的抽象原则。在第 6 和 7 章中,我向你展示了如何通过将代码放入函数和方法中并在类中隐藏更大的结构来提取代码。让我们来看看在程序中引入抽象的另一种更简单的方式:从代码中提取符号常量。

提取常数

我所说的常量是指内置的文字值,比如数字、字符串和列表。您可以将它们收集在全局变量中,而不是在程序中重复编写它们。我知道我已经警告过你了,但是全局变量的问题主要发生在你开始改变它们的时候,因为很难跟踪你的代码的哪个部分负责哪个改变。然而,我不去管这些变量,而是把它们当作常量来使用(因此有了符号常量这个术语)。为了表示一个变量将被视为一个符号常量,您可以使用一个特殊的命名约定,在变量名中只使用大写字母,并用下划线分隔单词。

我们来看一个例子。在一个计算圆的面积和周长的程序中,每次需要π值时,你可以一直写 3.14。但是,如果您稍后想要一个更精确的值,比如 3.14159,该怎么办呢?您需要搜索整个代码并用新值替换旧值。这并不难,在大多数优秀的文本编辑器中,这可以自动完成。然而,如果你从值 3 开始呢?您是否希望以后用 3.14159 替换所有出现的数字 3?几乎没有。一个更好的处理方法是用行PI = 3.14开始程序,然后用名字PI代替数字本身。这样,您可以简单地修改这一行,在以后的某个时间获得更精确的值。只要记住这一点:每当你写一个常数(比如数字 42 或字符串“Hello,world!”)多次,请考虑将其放入全局变量中。

Note

实际上,π的值是在数学模块中找到的,名为math.pi:

>>> from math import pi
>>> pi
3.1415926535897931

这对你来说似乎是显而易见的。但是所有这些的真正意义在下一节,我将讨论配置文件。

配置文件

为了自己的利益提取常量是一回事,但是有些常量甚至可以暴露给用户。例如,如果他们不喜欢你的 GUI 程序的背景颜色,也许你应该让他们使用另一种颜色。或者,也许您可以让用户决定当他们启动您激动人心的街机游戏或您刚刚实现的新 web 浏览器的默认起始页时,他们希望收到什么样的问候消息。

您可以将这些配置变量放在一个单独的文件中,而不是放在一个模块的顶部。最简单的方法是用一个单独的模块进行配置。例如,如果在模块文件config.py中设置了PI,您可以(在主程序中)执行以下操作:

from config import PI

然后,如果用户想要不同的值给PI,她可以简单地编辑config.py,而不必费力地通过你的代码。

Caution

使用配置文件是有代价的。一方面,配置是有用的,但是为整个项目使用一个集中的、共享的变量存储库会使它变得不那么模块化,更加单一。确保你没有破坏抽象(比如封装)。

另一种可能是使用标准库模块configparser,这将允许您使用合理的标准格式来配置文件。它允许两种标准的 Python 赋值语法,例如:

greeting = 'Hello, world!'

(尽管这会在字符串中给你两个无关的引号)和许多程序中使用的另一种配置格式:

greeting: Hello, world!

您必须使用像[files][colors]这样的头文件将配置文件分成几个部分。名称可以是任何东西,但是需要用括号括起来。清单 19-1 显示了一个示例配置文件,清单 19-2 显示了一个使用它的程序。有关configparser模块特性的更多信息,请参考库文档。

[numbers]

pi: 3.1415926535897931

[messages]

greeting: Welcome to the area calculation program!
question: Please enter the radius:
result_message: The area is

Listing 19-1.A Simple Configuration File

from configparser import ConfigParser

CONFIGFILE = "area.ini"

config = ConfigParser()
# Read the configuration file:
config.read(CONFIGFILE)

# Print out an initial greeting;
# 'messages' is the section to look in:
print(config['messages'].get('greeting'))

# Read in the radius, using a question from the config file:
radius = float(input(config['messages'].get('question') + ' '))

# Print a result message from the config file;
# end with a space to stay on same line:
print(config['messages'].get('result_message'), end=' ')

# getfloat() converts the config value to a float:
print(config['numbers'].getfloat('pi') * radius**2)

Listing 19-2.A Program Using ConfigParser

在接下来的项目中,我不会详细讨论配置,但是我建议你考虑让你的程序可配置。这样,用户就可以根据自己的喜好来调整程序,从而使使用程序变得更加愉快。毕竟,使用软件的主要挫折之一是你不能让它按照你想要的方式运行。

Levels of Configuration

可配置性是 UNIX 编程传统中不可或缺的一部分。在他的优秀著作《UNIX 编程的艺术》( Addison-Wesley,2003)的第十章中,Eric S. Raymond 描述了配置或控制信息的以下三个来源,这些信息(如果包括的话)可能应该按照以下顺序查阅 3 以便后面的来源覆盖前面的来源:

  • 配置文件:请参阅本章中的“配置文件”一节。
  • 环境变量:可以使用字典os.environ获取这些变量。
  • 命令行传递给程序的开关和参数:对于处理命令行参数,可以直接使用sys.argv。如果你想处理开关(选项),你应该检查一下argparse模块,正如第十章中提到的。

记录

与测试有点关系(在第十六章中讨论),并且在疯狂地修改程序内部时非常有用,日志当然可以帮助你发现问题和错误。日志记录基本上是在程序运行时收集有关程序的数据,这样您就可以在以后检查它(或者在数据积累时检查它)。一种非常简单的日志形式可以用print语句来完成。只要在你的程序的开头加上这样一句话:

log = open('logfile.txt', 'w')

然后,您可以将有关程序状态的任何有趣信息放入该文件,如下所示:

print('Downloading file from URL', url, file=log)
text = urllib.urlopen(url).read()
print'File successfully downloaded', file=log)

如果你的程序在下载过程中崩溃了,这种方法就不好用了。如果您为每个log语句打开和关闭您的文件(或者,至少在写入后刷新文件)会更安全。然后,如果你的程序崩溃了,你可以看到日志文件的最后一行写着“从 URL 下载文件”,你就知道下载没有成功。

实际上,应该使用标准库中的logging模块。基本用法非常简单,如清单 19-3 中的程序所示。

import logging

logging.basicConfig(level=logging.INFO, filename='mylog.log')

logging.info('Starting program')

logging.info('Trying to divide 1 by 0')

print(1 / 0)

logging.info('The division succeeded')

logging.info('Ending program')

Listing 19-3.A Program Using the logging Module

运行该程序会产生以下日志文件(名为mylog.log):

INFO:root:Starting program
INFO:root:Trying to divide 1 by 0

如您所见,在尝试将 1 除以 0 之后,没有记录任何内容,因为这个错误实际上会杀死程序。因为这是一个如此简单的错误,所以您可以通过程序崩溃时打印的异常回溯来判断出问题所在。最难追踪的错误类型是不会停止你的程序,而只是让它行为异常的那种。检查详细的日志文件可能有助于您了解发生了什么。

这个例子中的日志文件不是很详细,但是通过正确地配置logging模块,您可以设置您想要的日志工作方式。这里有几个例子:

  • 不同类型的日志条目(信息、调试信息、警告、自定义类型等)。默认情况下,只允许警告通过(这就是为什么我在清单 19-3 中将级别显式设置为logging.INFO)。
  • 只记录与程序的某些部分相关的项目。
  • 记录时间、日期等信息。
  • 记录到不同的位置,例如套接字。
  • 配置记录器以过滤掉部分或大部分日志记录,这样您就可以在任何时候只获得您需要的内容,而无需重写程序。

模块相当复杂,文档中有很多东西需要学习。

如果你不想被打扰

“这一切都很好,”你可能会想,“但是我不可能花那么多精力去写一个简单的小程序。配置、测试、记录——听起来真的很无聊。”

嗯,那很好。简单的程序可能不需要它。即使你正在做一个更大的项目,在开始的时候你可能真的不需要所有这些。我认为最起码你有一些测试程序的方法(如第十六章所讨论的),即使它不是基于自动单元测试。例如,如果你正在编写一个自动为你煮咖啡的程序,你应该准备一个咖啡壶,看看它是否工作。

在接下来的项目章节中,我不会编写完整的测试套件、复杂的日志记录设施等等。我向您展示一些简单的测试案例来演示程序的工作原理,仅此而已。如果你发现一个项目的核心思想很有趣,你应该更进一步——尝试增强和扩展它。在这个过程中,你应该考虑你在本章读到的问题。也许配置机制是个好主意?或者更广泛的测试套件?这取决于你。

如果你想了解更多

如果你想了解更多关于编程的艺术、技巧和哲学的信息,这里有一些更深入讨论这些内容的书籍:

  • 《实用程序员》,作者:安德鲁·亨特和戴维·托马斯
  • 《重构》,肯特·贝克等人著(艾迪森-韦斯利,1999 年)
  • 设计模式,由“四人帮”埃里希·伽马、理查德·赫尔姆、拉尔夫·约翰逊、约翰·弗利塞德斯(Addison-Wesley,1994)提出
  • 《测试驱动的开发:以实例为例》,作者肯特·贝克
  • 《UNIX 编程的艺术》,作者 Eric S. Raymond (Addison-Wesley,2003)4
  • 《算法导论》,第二版,托马斯·h·科尔曼等著(麻省理工学院出版社,2001 年)
  • 《计算机编程的艺术》,第 1-3 卷,作者唐纳德·克努特(爱迪生韦斯利公司,1998 年)
  • 《计算机编程的概念、技术和模型》,Peter Van Roy 和 Seif Haridi 著(麻省理工学院出版社,2004 年)

即使你没有读完每本书的每一页(我知道我没有),只要浏览其中的几页就能给你带来相当多的洞察力。

快速总结

在这一章中,我描述了一些用 Python 编程的一般原则和技术,方便地集中在标题“有趣的编程”下以下是亮点:

  • 灵活性:在设计和编程时,你应该以灵活性为目标。不要坚持你最初的想法,你应该愿意——甚至准备好——随着你对手头问题的深入了解,修改和改变你程序的每一个方面。
  • 原型:学习一个问题和可能的实现的一个重要技术是写一个简单版本的程序来看看它是如何工作的。在 Python 中,这是如此简单,以至于你可以在用许多其他语言编写一个版本的时间内编写几个原型。不过,如果没有必要的话,你应该警惕重写代码——重构通常是更好的解决方案。
  • 配置:从你的程序中提取常量,使得在以后的某个时候修改它们变得更加容易。将它们放在一个配置文件中,可以让你的用户按照他们的意愿配置程序。使用环境变量和命令行选项可以使您的程序更加可配置。
  • 日志记录:日志记录对于发现程序中的问题非常有用——或者仅仅是监视它的普通行为。您可以使用print语句自己实现简单的日志记录,但是最安全的做法是使用标准库中的logging模块。

什么现在?

的确,现在怎么办?现在是冒险真正开始编程的时候了。项目时间到了。所有十个项目章节都有类似的结构,包括以下部分:

  • “有什么问题?”:在本节中,概述了项目的主要目标,包括一些背景信息。
  • “有用的工具”:在这里,我描述了可能对项目有用的模块、类、函数等等。
  • “准备工作”:本节涵盖开始编程前的任何必要准备工作。这可能包括为测试实现建立必要的框架。
  • “第一次实现”:这是第一次尝试——一次尝试性的实现,以了解问题的更多信息。
  • “第二次实现”:在第一次实现之后,你大概会对事物有更好的理解,这将使你能够创建一个新的改进版本。
  • “进一步探索”:最后,我给出进一步实验和探索的指针。让我们从第一个项目开始,这是创建一个自动用 HTML 标记文件的程序。

Footnotes 1

极限编程是一种软件开发方法,可以说已经被程序员使用了很多年,但它是由 Kent Beck 首先命名和记录的。更多信息请参见 http://www.extremeprogramming.org

2

或者,就此而言,它的中国亲戚,如太极拳或八卦掌。

3

实际上,全局配置文件和系统设置的环境变量在这些之前。更多细节见书。

4

也可以在 Raymond 的网站上在线获得。

二十、项目 1:即时标记

在这个项目中,您将看到如何使用 Python 出色的文本处理功能,包括使用正则表达式将纯文本文件转换成用 HTML 或 XML 等语言标记的文件。如果你想在一个需要标记内容的系统中使用不懂这些语言的人写的文本,你需要这样的技能。

不会说流利的 XML?不要担心这个——如果你对 HTML 只是一知半解,你在这一章会做得很好。如果你需要 HTML 入门,你可以在网上找到大量的教程。有关 XML 使用的示例,请参见第二十二章。

让我们从实现一个简单的原型开始,它完成基本的处理,然后扩展这个程序,使标记系统更加灵活。

有什么问题?

您想给纯文本文件添加一些格式。假设您从一个不喜欢用 HTML 编写的人那里得到了一个文件,您需要将该文档用作网页。您希望程序自动添加所有必要的标签,而不是手动添加。

Note

近年来,这种“纯文本标记”实际上已经变得相当普遍,可能主要是因为具有纯文本界面的 wiki 和博客软件的激增。有关更多信息,请参见本章末尾的“进一步探索”一节。

你的任务基本上就是对各种文本元素进行分类,比如标题和强调的文字,然后清晰地标注出来。在这里解决的具体问题中,您将 HTML 标记添加到文本中,这样得到的文档就可以在 web 浏览器中显示并用作网页。然而,一旦构建了基本引擎,就没有理由不能添加其他类型的标记(比如各种形式的 XML 或者 LATEX 代码)。在分析一个文本文件之后,您甚至可以执行其他任务,比如提取所有的标题来制作一个目录。

Note

LATEX 是另一种标记系统(基于 TEX 排版程序),用于创建各种类型的技术文档。我在这里提到它只是作为你的程序的其他用途的一个例子。如果您想了解更多,您可以访问 TEX 用户组网站 http://www.tug.org

给你的文本可能包含一些线索(比如被标记为*like this*的强调文本),但是你可能需要一些独创性来让你的程序猜测文档是如何构造的。

在开始写你的原型之前,让我们定义一些目标。

  • 不应该要求输入包含人工代码或标签。
  • 您应该能够处理不同的块,如标题、段落和列表项,以及行内文本,如强调文本或 URL。
  • 尽管这个实现处理的是 HTML,但是它应该很容易扩展到其他标记语言。

在你的程序的第一个版本中,你可能无法完全达到这些目标,但这就是原型的意义所在。你写原型是为了在你最初的想法中寻找缺陷,并学习如何写一个程序来解决你的问题。

Tip

如果可以的话,逐步修改你的原始程序可能是个好主意,而不是从头开始。为了清楚起见,我在这里给你程序的两个完全不同的版本。

有用的工具

考虑一下写这个程序可能需要什么工具。

  • 你当然需要读写文件(见第十一章),或者至少从标准输入(sys.stdin)中读取并用print输出。
  • 你可能需要迭代输入的行(见第十一章)。
  • 你需要一些字符串方法(见第三章)。
  • 也许你会使用一两个发电机(见第九章)。
  • 你可能需要re模块(见第十章)。

如果这些概念中的任何一个对你来说是陌生的,你也许应该花一点时间来刷新你的记忆。

准备

在你开始编码之前,你需要一些方法来评估你的进展;你需要一个测试套件。在这个项目中,一个简单的测试就足够了:一个测试文档(纯文本)。清单 20-1 包含您想要自动标记的示例文本。

Welcome to World Wide Spam, Inc.

These are the corporate web pages of *World Wide Spam*, Inc. We hope
you find your stay enjoyable, and that you will sample many of our
products.

A short history of the company

World Wide Spam was started in the summer of 2000\. The business
concept was to ride the dot-com wave and to make money both through
bulk email and by selling canned meat online.

After receiving several complaints from customers who weren't
satisfied by their bulk email, World Wide Spam altered their profile,
and focused 100% on canned goods. Today, they rank as the world's
13,892nd online supplier of SPAM.

Destinations

From this page you may visit several of our interesting web pages:

  - What is SPAM? (http://wwspam.fu/whatisspam)

  - How do they make it? (http://wwspam.fu/howtomakeit)

  - Why should I eat it? (http://wwspam.fu/whyeatit)

How to get in touch with us

You can get in touch with us in *many* ways: By phone (555-1234), by
email (wwspam@wwspam.fu) or by visiting our customer feedback page
(http://wwspam.fu/feedback).

Listing 20-1.A Sample Plain-Text Document (test_input.txt)

要测试您的实现,只需使用这个文档作为输入并在 web 浏览器中查看结果,或者直接检查添加的标签。

Note

拥有自动化测试套件通常比手动检查测试结果更好。(你有没有看到任何自动化测试的方法?)

首次实施

你需要做的第一件事是将文本分成段落。从清单 20-1 中可以明显看出,段落由一个或多个空行分隔。一个比段落更好的词可能是 block,因为这个名字也适用于标题和列表项。

查找文本块

找到这些块的一个简单方法是收集你遇到的所有行,直到你找到一个空行,然后返回你到目前为止收集的行。那会是一个街区。然后,你可以从头再来。不需要费心收集空行,也不会返回空块(遇到不止一个空行的地方)。此外,您应该确保文件的最后一行是空的;否则你不知道最后一块什么时候完成。(当然,还有其他方法可以发现。)

清单 20-2 展示了这种方法的实现。

def lines(file):
    for line in file: yield line
    yield '\n'

def blocks(file):
    block = []
    for line in lines(file):
        if line.strip():
            block.append(line)
        elif block:
            yield ''.join(block).strip()
            block = []

Listing 20-2.A Text Block Generator
(util.py)

生成器只是一个小工具,它在文件末尾添加一个空行。blocks生成器实现了所描述的方法。当产生一个块时,它的行被连接起来,产生的字符串被剥离,得到一个表示该块的单个字符串,两端多余的空格(如列表缩进或换行符)被删除。(如果你不喜欢这种找段落的方式,我相信你可以想出其他几种方法。看看你能发明多少可能会很有趣。)我已经将代码放在了文件util.py中,这意味着您可以稍后在您的程序中导入实用程序生成器。

添加一些标记

使用清单 20-2 中的基本功能,您可以创建一个简单的标记脚本。该程序的基本步骤如下:

  1. 打印一些开始标记。
  2. 对于每个块,打印包含在段落标记中的块。
  3. 打印一些结束标记。

这不是很难,但也不是非常有用。假设您没有将第一个块包含在段落标记中,而是将它包含在顶部标题标记(h1)中。此外,您可以用强调文本(使用em标签)替换星号中的任何文本。至少这样更有用一点。给定blocks函数并使用re.sub,代码非常简单。见清单 20-3 。

import sys, re
from util import *

print('<html><head><title>...</title><body>')

title = True
for block in blocks(sys.stdin):
    block = re.sub(r'\*(.+?)\*', r'<em>\1</em>', block)
    if title:
        print('<h1>')
        print(block)
        print('</h1>')
        title = False
    else:
        print('<p>')
        print(block)
        print('</p>')

print('</body></html>')

Listing 20-3.A Simple Markup Program (simple_markup.py)

该程序可以对样本输入执行如下:

$ python simple_markup.py < test_input.txt > test_output.html

文件test_output.html将包含生成的 HTML 代码。图 20-1 展示了这些 HTML 代码在网络浏览器中的样子。

A326949_3_En_20_Fig1_HTML.jpg

图 20-1。

The first attempt at generating a web page

虽然不是很令人印象深刻,但这个原型确实执行了一些重要的任务。它将文本分成可以单独处理的块,并依次对每个块应用过滤器(由对re.sub的调用组成)。这似乎是一个在你的期末项目中使用的好方法。

如果你试图扩展这个原型会发生什么?您可能会在for循环中添加检查,以查看该块是标题、列表项还是其他内容。你可以添加更多的正则表达式。它可能会很快变得一团糟。更重要的是,很难让它输出除 HTML 之外的任何内容;这个项目的目标之一是使添加其他输出格式变得容易。让我们假设您想要重构您的程序,并以稍微不同的方式构建它。

第二次实施

那么,你从第一次实现中学到了什么?为了使它更具可扩展性,你需要使你的程序更加模块化(将功能分成独立的组件)。实现模块化的一种方法是通过面向对象的设计(见第七章)。随着程序复杂性的增加,您需要找到一些抽象来使程序更易于管理。让我们首先列出一些可能的组件。

  • 解析器:添加一个读取文本并管理其他类的对象。
  • 规则:您可以为每种类型的块制定一条规则。该规则应该能够检测适用的块类型,并对其进行适当的格式化。
  • 过滤器:使用过滤器包装一些正则表达式来处理行内元素。
  • 处理程序:解析器使用处理程序来生成输出。每个处理程序可以产生不同种类的标记。

虽然这不是一个非常详细的设计,但至少它给了你一些关于如何将你的代码分成更小的部分,并使每个部分易于管理的想法。

经理人

让我们从处理程序开始。处理程序负责生成结果标记文本,但它从解析器接收详细的指令。假设它对每种块类型都有一对方法:一个用于开始块,一个用于结束块。例如,它可能有方法start_paragraphend_paragraph来处理段落块。对于 HTML,这些可以按如下方式实现:

class HTMLRenderer:
    def start_paragraph(self):
        print('<p>')
    def end_paragraph(self):
        print('</p>')

当然,对于其他块类型,您需要类似的方法。(关于HTMLRenderer类的完整代码,请参见本章后面的清单 20-4 。)这个好像够灵活了。如果您想要一些其他类型的标记,您只需用 start 和 end 方法的其他实现创建另一个处理程序(或呈现器)。

Note

选择术语 handler(例如,与 renderer 相对)来表示它处理由解析器生成的方法调用(另请参见下一节“Handler 超类”)。它不需要像HTMLRenderer那样用某种标记语言呈现文本。一个类似的处理机制被用在名为 SAX 的 XML 解析模式中,这将在第二十二章中解释。

你是怎么处理正则表达式的?您可能还记得,re.sub函数可以将一个函数作为它的第二个参数(替换)。这个函数用match对象调用,它的返回值被插入到文本中。这非常符合前面讨论的处理程序原理——您只需让处理程序实现替换方法。例如,强调可以这样处理:

def sub_emphasis(self, match):
    return '<em>{}</em>'.format(match.group(1))

如果你不明白group方法是做什么的,也许你应该再看看re模块,在第十章中有描述。

除了startendsub方法之外,我们将有一个名为feed的方法,我们用它向处理程序提供实际的文本。在简单的 HTML 呈现器中,让我们像这样实现它:

def feed(self, data):
    print(data)

处理程序超类

出于灵活性的考虑,让我们添加一个Handler类,它将是处理程序的超类,负责一些管理细节。不需要用它们的全名(例如,start_paragraph)来调用这些方法,有时将块类型作为字符串来处理(例如,'paragraph')并提供给处理程序是很有用的。您可以通过添加一些名为start(type)end(type)sub(type)的通用方法来做到这一点。此外,您可以让startendsub检查相应的方法(如start('paragraph')start_paragraph)是否真正实现,如果没有找到,则不做任何事情。下面是这个Handler类的一个实现。(这段代码取自后面显示的模块handlers,在清单 20-4 中。)

class Handler:
    def callback(self, prefix, name, *args):
        method = getattr(self, prefix + name, None)
        if callable(method): return method(*args)
    def start(self, name):
        self.callback('start_', name)
    def end(self, name):
        self.callback('end_', name)
    def sub(self, name):
        def substitution(match):
            result = self.callback('sub_', name, match)
            if result is None: match.group(0)
            return result
        return substitution

这段代码中的几件事情需要一些解释。

  • callback方法负责找到正确的方法(如start_paragraph),给定一个前缀(如'start_')和一个名称(如'paragraph')。它使用None作为默认值的getattr来执行其任务。如果从getattr返回的对象是可调用的,那么它将被提供的任何附加参数调用。例如,调用handler.callback('start_', 'paragraph')会不带参数地调用方法handler.start_paragraph,假设它存在。
  • startend方法只是用各自的前缀start_end_调用callback的助手方法。
  • sub方法有点不同。它不直接调用callback而是返回一个新函数,这个新函数在re.sub中被用作替换函数(这就是为什么它把一个匹配对象作为它唯一的参数)。

让我们考虑一个例子。比方说HTMLRendererHandler的一个子类,它实现了上一节描述的方法sub_emphasis(参见清单 20-4 中handlers.py的实际代码)。假设您在变量处理程序中有一个HTMLRenderer实例。

>>> from handlers import HTMLRenderer
>>> handler = HTMLRenderer()

那么handler.sub('emphasis')会做什么?

>>> handler.sub('emphasis')
<function substitution at 0x168cf8>

它返回一个函数(substitution),当你调用它时,这个函数基本上会调用handler.sub_emphasis方法。这意味着您可以在re.sub语句中使用该函数:

>>> import re
>>> re.sub(r'\*(.+?)\*', handler.sub('emphasis'), 'This *is* a test')
'This <em>is</em> a test'

神奇!(正则表达式匹配用星号括起来的文本,我稍后将讨论这一点。)但为什么要走这么远呢?为什么不像简单版那样直接用r'<em>\1</em>'?因为这样一来,您将致力于使用em标记,但是您希望处理程序能够决定使用哪个标记。例如,如果您的处理程序是一个(假设的)LaTeXRenderer,您可能会得到完全不同的结果。

>> re.sub(r'\*(.+?)\*', handler.sub('emphasis'), 'This *is* a test')
'This \\emph{is} a test'

标记已经更改,但代码没有更改。

我们也有一个备份,以防没有替代实施。callback方法试图找到一个合适的sub_something方法,但是如果没有找到,它就返回None。因为你的函数是一个re.sub替换函数,你不希望它返回None。相反,如果您没有找到替换方法,您只需返回原始匹配,不做任何修改。如果回调返回Nonesubstitution(在sub里面)反而返回原来匹配的文本(match.group(0))。

规则

既然您已经使处理程序变得非常可扩展和灵活,那么是时候转向解析(解释原始文本)了。不要像在简单的标记程序中那样,用各种条件和动作来做一个大的if语句,让我们把规则变成一个独立的对象。

规则由主程序(解析器)使用,主程序必须确定哪些规则适用于给定的块,然后让每个规则执行转换块所需的操作。换句话说,规则必须能够做到以下几点:

  • 识别适用的块(条件)。
  • 变换块(动作)。

所以每个规则对象必须有两个方法:conditionaction

condition方法只需要一个参数:有问题的块。它应该返回一个布尔值,表明该规则是否适用于给定的块。

Tip

对于复杂的规则解析,您可能还想让 rule 对象访问一些状态变量,这样它就能更多地了解到目前为止发生了什么,或者哪些其他规则已经应用或还没有应用。

action方法也需要块作为参数,但是为了能够影响输出,它还必须能够访问 handler 对象。

在许多情况下,可能只有一个规则适用;也就是说,如果您发现使用了标题规则(表明该块是标题),您不应该尝试使用段落规则。一个简单的实现是让解析器一个接一个地尝试规则,一旦其中一个规则被触发,就停止块的处理。这通常没问题,但是正如您将看到的,有时一个规则可能不排除其他规则的执行。因此,我们向 action 方法添加了另一项功能:它返回一个布尔值,指示当前块的规则处理是否应该停止。(你也可以为此使用一个异常,类似于迭代器的StopIteration机制。)

标题规则的伪代码可能如下:

class HeadlineRule:
    def condition(self, block):
        if the block fits the definition of a headline, return True;
        otherwise, return False.
    def action(self, block, handler):
        call methods such as handler.start('headline'), handler.feed(block) and
        handler.end('headline').
        because we don't want to attempt to use any other rules,
        return True, which will end the rule processing for this block.

规则超类

尽管您的规则并不一定需要一个公共的超类,但是它们中的几个可能共享相同的一般操作——用适当的类型字符串参数调用处理程序的startfeedend方法,然后返回True(以停止规则处理)。假设所有的子类都有一个名为type的属性,该属性以字符串的形式包含这个类型名,您可以实现您的超类,如下面的代码所示。(Rule类位于rules模块中;完整的代码显示在清单 20-5 中。)

class Rule:
    def action(self, block, handler):
        handler.start(self.type)
        handler.feed(block)
        handler.end(self.type)
        return True

condition方法是每个子类的责任。Rule类及其子类放在rules模块中。

过滤

您不需要为您的过滤器创建单独的类。给定您的Handler类的sub方法,每个过滤器可以由一个正则表达式和一个名称来表示(例如emphasisurl)。在下一节中,当我向您展示如何处理解析器时,您将会看到这一点。

解析器

我们来到了应用的核心:类Parser。它使用一个处理程序、一组规则和过滤器将一个纯文本文件转换成一个带标记的文件——在这个特定的例子中,是一个 HTML 文件。它需要哪些方法?它需要一个构造函数来设置,一个方法来添加规则,一个方法来添加过滤器,一个方法来解析给定的文件。

下面是Parser类的代码(来自清单 20-6 ,在本章的后面,它详述了markup.py):

class Parser:
    """
    A Parser reads a text file, applying rules and controlling a
    handler.
    """
    def __init__ (self, handler):
        self.handler = handler
        self.rules = []
        self.filters = []
    def addRule(self, rule):
        self.rules.append(rule)
    def addFilter(self, pattern, name):
        def filter(block, handler):
            return re.sub(pattern, handler.sub(name), block)
        self.filters.append(filter)
    def parse(self, file):
        self.handler.start('document')
        for block in blocks(file):
            for filter in self.filters:
                block = filter(block, self.handler)
            for rule in self.rules:
                if rule.condition(block):
                    last = rule.action(block, self.handler)
                    if last: break
        self.handler.end('document')

虽然这门课有很多内容需要消化,但大部分并不复杂。构造函数只是将提供的处理程序分配给一个实例变量(属性),然后初始化两个列表:一个是规则列表,一个是过滤器列表。addRule方法将规则添加到规则列表中。然而,addFilter方法做了更多的工作。像addRule一样,它将一个过滤器添加到过滤器列表中,但在此之前,它会创建那个过滤器。过滤器是一个简单的函数,它使用适当的正则表达式(模式)应用re.sub,并使用来自处理程序的替换,通过handler.sub(name)访问。

方法parse虽然看起来有点复杂,但可能是最容易实现的方法,因为它只是做了你一直计划要做的事情。它通过调用处理程序上的start('document')开始,通过调用end('document')结束。在这些调用之间,它遍历文本文件中的所有块。对于每个块,它应用过滤器和规则。应用一个过滤器只是调用带有块和处理程序作为参数的filter函数,并将块变量重新绑定到结果,如下所示:

block = filter(block, self.handler)

这使得每个过滤器都能够完成它的工作,用标记文本替换部分文本(比如用<em>this</em>替换*this*)。

规则循环中有更多的逻辑。对于每个规则,都有一个if语句,通过调用rule.condition(block)来检查规则是否适用。如果规则适用,则调用rule.action,将块和处理程序作为参数。记住,action方法返回一个布尔值,指示是否完成对这个块的规则应用。通过将变量last设置为 action 的返回值,然后有条件地退出for循环来完成规则应用。

if last: break

Note

您可以将这两条语句合并成一条,去掉last变量。

if rule.action(block, self.handler): break

是否这样做在很大程度上是一个品味问题。移除临时变量会使代码更简单,但保留它会清楚地标记返回值。

构建规则和过滤器

现在您已经拥有了所有需要的工具,但是您还没有创建任何特定的规则或过滤器。到目前为止,您编写的大部分代码背后的动机是让规则和过滤器像处理程序一样灵活。您可以编写几个独立的规则和过滤器,并通过addRuleaddFilter方法将它们添加到您的解析器中,确保在您的处理程序中实现适当的方法。

复杂的规则集使得处理复杂的文档成为可能。然而,现在让我们保持简单。让我们为标题创建一个规则,为其他标题创建一个规则,为列表项创建一个规则。因为列表项应该被视为一个列表,所以您将创建一个单独的列表规则来处理整个列表。最后,您可以为段落创建一个默认规则,该规则涵盖了前面的规则没有涉及的所有块。

我们可以用如下的非正式术语来说明这些规则:

  • 标题是仅由一行组成的块,其长度最多为 70 个字符。如果块以冒号结尾,则不是标题。
  • 标题是文档中的第一个块,前提是它是一个标题。
  • 列表项是以连字符(-)开头的块。
  • 列表开始于非列表项的块和后面的列表项之间,结束于列表项和后面的非列表项的块之间。

这些规则遵循我对文本文档结构的一些直觉。你对此的看法(以及你的文本文档)可能不同。此外,这些规则也有弱点(例如,如果文档以列表项结尾会发生什么?).请随意改进它们。这些规则的完整源代码如清单 20-5 ( rules.py,其中也包含了基本的Rule类)所示。让我们从标题规则开始:

class HeadingRule(Rule):
    """
    A heading is a single line that is at most 70 characters and
    that doesn't end with a colon.
    """
    type = 'heading'
    def condition(self, block):
        return not '\n' in block and len(block) <= 70 and not block[-1] == ':'

属性类型已经被设置为字符串'heading',它被从Rule继承的action方法使用。该条件只是检查该块不包含换行符(\n),其长度最多为 70,并且最后一个字符不是冒号。

标题规则类似,但只对第一个块有效一次。此后,它忽略所有块,因为它的属性first已经被设置为False

class TitleRule(HeadingRule):
    """
    The title is the first block in the document, provided that it is
    a heading.
    """
    type = 'title'
    first = True

    def condition(self, block):
        if not self.first: return False
        self.first = False
        return HeadingRule.condition(self, block)

列表项规则条件是上述规范的直接实现。

class ListItemRule(Rule):
    """
    A list item is a paragraph that begins with a hyphen. As part of
    the formatting, the hyphen is removed.
    """
    type = 'listitem'
    def condition(self, block):
        return block[0] == '-'
    def action(self, block, handler):
        handler.start(self.type)
        handler.feed(block[1:].strip())
        handler.end(self.type)
        return True

它的动作是在Rule中发现的动作的重新实现。唯一的区别是它删除了块中的第一个字符(连字符),并去掉了剩余文本中多余的空白。标记提供了自己的“列表项目符号”,所以不再需要连字符。

到目前为止,所有的规则操作都返回了True。列表规则不会触发,因为当您在非列表项目后遇到列表项目或在列表项目后遇到非列表项目时,会触发该规则。因为它实际上并不标记这些块,而只是指示一个列表(一组列表项)的开始和结束,所以您不希望停止规则处理—所以它返回False

class ListRule(ListItemRule):
    """
    A list begins between a block that is not a list item and a
    subsequent list item. It ends after the last consecutive list
    item.
    """
    type = 'list'
    inside = False
    def condition(self, block):
        return True
    def action(self, block, handler):
        if not self.inside and ListItemRule.condition(self, block):
            handler.start(self.type)
            self.inside = True
        elif self.inside and not ListItemRule.condition(self, block):
            handler.end(self.type)
            self.inside = False
        return False

列表规则可能需要一些进一步的解释。它的条件总是真的,因为你想检查所有的块。在行动方法中,你有两个可能导致行动的选择。

  • 如果属性inside(表示解析器当前是否在列表中)为假(最初是这样),并且列表项规则的条件为真,那么您就已经进入了一个列表。调用处理程序的适当的start方法,并将inside属性设置为True
  • 反之,如果inside为真,列表项规则条件为假,则刚刚离开了一个列表。调用处理程序的适当结束方法,并将inside属性设置为False

在这个处理之后,函数返回False让规则处理继续。(这当然意味着规则的顺序很关键。)

最后的规则是ParagraphRule。其条件始终为真,因为这是“默认”规则。它被添加为规则列表的最后一个元素,处理任何其他规则都无法处理的所有块。

class ParagraphRule(Rule):
    """
    A paragraph is simply a block that isn't covered by any of the
    other rules.
    """
    type = 'paragraph'
    def condition(self, block):
        return True

过滤器只是正则表达式。让我们添加三个过滤器:一个用于强调,一个用于 URL,一个用于电子邮件地址。让我们使用以下三个正则表达式:

r'\*(.+?)\*'
r'(http://[\.a-zA-Z/]+)'
r'([\.a-zA-Z]+@[\.a-zA-Z]+[a-zA-Z]+)'

第一个模式(强调)匹配一个星号,后跟一个或多个任意字符(尽可能少地匹配,因此是问号),再跟另一个星号。第二种模式(URL)匹配字符串'http://'(在这里,您可以添加更多的协议),后跟一个或多个点、字母或斜线字符。(这种模式不会匹配所有合法的 URL,请随意改进。)最后,email 模式匹配一个字母和点的序列,后面跟着一个 at 符号(@),再后面是更多的字母和点,最后是一个字母的序列,确保你不是以点结尾。(还是那句话,可以随意改进这个。)

把这一切放在一起

您现在只需要创建一个Parser对象,并添加相关的规则和过滤器。让我们通过创建一个Parser的子类来实现,这个子类在其构造函数中进行初始化。然后让我们用它来解析sys.stdin。最终程序如清单 20-4 至 20-6 所示。(这些清单取决于清单 20-2 中的实用程序代码。)最终的程序可以像原型一样运行。

$ python markup.py < test_input.txt > test_output.html

class Handler:
    """
    An object that handles method calls from the Parser.

    The Parser will call the start() and end() methods at the
    beginning of each block, with the proper block name as a
    parameter. The sub() method will be used in regular expression
    substitution. When called with a name such as 'emphasis', it will
    return a proper substitution function.
    """
    def callback(self, prefix, name, *args):
        method = getattr(self, prefix + name, None)
        if callable(method): return method(*args)
    def start(self, name):
        self.callback('start_', name)
    def end(self, name):
        self.callback('end_', name)
    def sub(self, name):
        def substitution(match):
            result = self.callback('sub_', name, match)
            if result is None: match.group(0)
            return result
        return substitution

class HTMLRenderer(Handler):
    """
    A specific handler used for rendering HTML.

    The methods in HTMLRenderer are accessed from the superclass
    Handler's start(), end(), and sub() methods. They implement basic
    markup as used in HTML documents.
    """
    def start_document(self):
        print('<html><head><title>...</title></head><body>')
    def end_document(self):
        print('</body></html>')
    def start_paragraph(self):
        print('<p>')
    def end_paragraph(self):
        print('</p>')
    def start_heading(self):
        print('<h2>')

    def end_heading(self):
        print('</h2>')
    def start_list(self):
        print('<ul>')
    def end_list(self):
        print('</ul>')
    def start_listitem(self):
        print('<li>')
    def end_listitem(self):
        print('</li>')
    def start_title(self):
        print('<h1>')
    def end_title(self):
        print('</h1>')
    def sub_emphasis(self, match):
        return '<em>{}</em>'.format(match.group(1))
    def sub_url(self, match):
        return '<a href="{}">{}</a>'.format(match.group(1), match.group(1))
    def sub_mail(self, match):
        return '<a href="mailto:{}">{}</a>'.format(match.group(1), match.group(1))
    def feed(self, data):
        print(data)

Listing 20-4.The Handlers (handlers.py)

class Rule:
    """
    Base class for all rules.
    """

    def action(self, block, handler):
        handler.start(self.type)
        handler.feed(block)
        handler.end(self.type)
        return True

class HeadingRule(Rule):
    """
    A heading is a single line that is at most 70 characters and
    that doesn't end with a colon.
    """
    type = 'heading'
    def condition(self, block):
        return not '\n' in block and len(block) <= 70 and not block[-1] == ':'

class TitleRule(HeadingRule):
    """
    The title is the first block in the document, provided that
    it is a heading.
    """
    type = 'title'
    first = True

    def condition(self, block):
        if not self.first: return False
        self.first = False
        return HeadingRule.condition(self, block)

class ListItemRule(Rule):
    """
    A list item is a paragraph that begins with a hyphen. As part of the
    formatting, the hyphen is removed.
    """
    type = 'listitem'
    def condition(self, block):
        return block[0] == '-'
    def action(self, block, handler):
        handler.start(self.type)
        handler.feed(block[1:].strip())
        handler.end(self.type)
        return True

class ListRule(ListItemRule):
    """
    A list begins between a block that is not a list item and a
    subsequent list item. It ends after the last consecutive list item.
    """
    type = 'list'
    inside = False
    def condition(self, block):
        return True
    def action(self, block, handler):
        if not self.inside and ListItemRule.condition(self, block):
            handler.start(self.type)
            self.inside = True
        elif self.inside and not ListItemRule.condition(self, block):
            handler.end(self.type)
            self.inside = False
        return False

class ParagraphRule(Rule):
    """
    A paragraph is simply a block that isn't covered by any of the other rules.
    """
    type = 'paragraph'
    def condition(self, block):
        return True

Listing 20-5.The Rules (rules.py)

import sys, re
from handlers import *
from util import *
from rules import *

class Parser:
    """
    A Parser reads a text file, applying rules and controlling a handler.
    """
    def __init__(self, handler):
        self.handler = handler
        self.rules = []
        self.filters = []
    def addRule(self, rule):
        self.rules.append(rule)
    def addFilter(self, pattern, name):
        def filter(block, handler):
            return re.sub(pattern, handler.sub(name), block)
        self.filters.append(filter)

    def parse(self, file):
        self.handler.start('document')
        for block in blocks(file):
            for filter in self.filters:
                block = filter(block, self.handler)
                for rule in self.rules:
                    if rule.condition(block):
                        last = rule.action(block,
                               self.handler)
                        if last: break
        self.handler.end('document')

class BasicTextParser(Parser):
    """
    A specific Parser that adds rules and filters in its constructor.
    """
    def __init__(self, handler):
        Parser.__init__(self, handler)
        self.addRule(ListRule())
        self.addRule(ListItemRule())
        self.addRule(TitleRule())
        self.addRule(HeadingRule())
        self.addRule(ParagraphRule())

        self.addFilter(r'\*(.+?)\*', 'emphasis')
        self.addFilter(r'(http://[\.a-zA-Z/]+)', 'url')
        self.addFilter(r'([\.a-zA-Z]+@[\.a-zA-Z]+[a-zA-Z]+)', 'mail')

handler = HTMLRenderer()
parser = BasicTextParser(handler)

parser.parse(sys.stdin)

Listing 20-6.The Main Program (markup.py)

您可以在图 20-2 中的示例文本上看到程序运行的结果。

A326949_3_En_20_Fig2_HTML.jpg

图 20-2。

The second attempt at generating a web page

第二个实现显然比第一个版本更加复杂和广泛。增加的复杂性是值得的,因为最终的程序更加灵活和可扩展。使它适应新的输入和输出格式仅仅是一个子类化和初始化现有类的问题,而不是像在第一个原型中那样从头重写一切。

进一步探索

这个程序可能有几个扩展。以下是一些可能性:

  • 添加对表格的支持。找到所有对齐的左字边框,并将块拆分成列。
  • 添加对将所有大写单词解释为强调的支持。(要正确地做到这一点,你需要考虑缩写词、标点符号、名称和其他大写单词。)
  • 添加对 LATEX 输出的支持。
  • 编写一个处理程序,做一些除了标记以外的事情。也许可以编写一个以某种方式分析文档的处理程序。
  • 创建一个脚本,自动将目录中的所有文本文件转换为 HTML 文件。
  • 查看一些现有的纯文本格式,如 Markdown、reStructuredText 或维基百科中使用的格式。

什么现在?

唷!在这个费力(但希望有用)的项目之后,是时候用一些更轻的材料了。在下一章,我们将基于从互联网上自动下载的数据创建一些图形。小菜一碟。

二十一、项目 2:绘制漂亮的图片

在这个项目中,您将学习如何用 Python 创建图形。更具体地说,您将创建一个带有图形的 PDF 文件,帮助您可视化从文本文件中读取的数据。虽然您可以从常规的电子表格中获得这样的功能,但是 Python 提供了更强大的功能,当您使用第二个实现并自动从互联网上下载数据时,您将会看到这一点。

在前一章,我们看了 HTML 和 XML——这是另一个缩写,我猜你可能很熟悉:PDF,便携文档格式的缩写。PDF 是 Adobe 创建的一种格式,可以用图形和文本表示任何类型的文档。PDF 文件实际上是不可编辑的(比如说,Microsoft Word 文件可能是可编辑的),但大多数平台都有免费的阅读器软件,无论您使用哪种阅读器或在哪个平台上,PDF 文件看起来都应该是一样的(与 HTML 不同,HTML 可能没有正确的字体,您通常必须将图片作为单独的文件发送,等等)。

有什么问题?

Python 非常适合分析数据。利用它的文件处理和字符串处理功能,从数据文件创建某种形式的报告可能比在普通的电子表格中创建类似的东西更容易,特别是当您想要做的事情需要一些复杂的编程逻辑时。

你已经看到(在第三章)如何使用字符串格式得到漂亮的输出——例如,如果你想打印列中的数字。然而,有时纯文本是不够的。(就像他们说的,一张图胜过千言万语。)在这个项目中,您将学习 ReportLab 包的基础知识,它使您能够像以前创建纯文本一样轻松地创建 PDF 格式(和其他一些格式)的图形和文档。

当你在这个项目中玩概念的时候,我鼓励你找到一些你感兴趣的应用。我选择使用关于太阳黑子的数据(来自美国国家海洋和大气管理局的太空天气预测中心),并根据这些数据创建一个线图。

该程序应能够执行以下操作:

  • 从互联网下载数据文件
  • 解析数据文件并提取感兴趣的部分
  • 基于数据创建 PDF 图形

与前一个项目一样,第一个原型可能无法完全满足这些目标。

有用的工具

这个项目中的关键工具是图形生成包。相当多的这样的软件包是可用的;我之所以选择 ReportLab,是因为它易于使用,并且具有丰富的 PDF 图形和文档生成功能。如果你想超越基础,你可能还想考虑 PYX 图形包( http://pyx.sf.net ),它真的很强大,并且支持基于 TEX 的排版。

要获取 ReportLab 软件包,请访问位于 http://www.reportlab.org 的官方网站。在那里您可以找到软件、文档和示例。您可以从网站下载或通过pip安装该库。完成后,您应该可以导入reportlab模块,如下所示:

>>> import reportlab
>>>

Note

虽然我向您展示了一些 ReportLab 功能在这个项目中是如何工作的,但是还有更多的功能可用。要了解更多信息,我建议您从 ReportLab 网站上获取手册。它们可读性很强,而且比这一章可能包含的内容要广泛得多。

准备

在你开始编程之前,你需要一些数据来测试你的程序。我(相当随意地)选择了使用关于太阳黑子的数据,这些数据可以从太空天气预测中心的网站上获得( http://www.swpc.noaa.gov )。你可以在 ftp://ftp.swpc.noaa.gov/pub/weekly/Predict.txt 找到我在例子中使用的数据。

这个数据文件每周更新,包含有关太阳黑子和射电流量的信息。(别问我那是什么意思。)一旦有了这个文件,就可以开始处理这个问题了。

下面是文件的一部分,让您了解数据的样子:

#         Predicted Sunspot Number And Radio Flux Values
#                     With Expected Ranges
#
#         -----Sunspot Number------  ----10.7 cm Radio Flux----
# YR MO   PREDICTED    HIGH    LOW   PREDICTED    HIGH    LOW
#--------------------------------------------------------------
2016 03        30.9    31.9    29.9       96.9    97.9    95.9
2016 04        30.5    32.5    28.5       96.1    97.1    95.1
2016 05        30.4    33.4    27.4       94.9    96.9    92.9
2016 06        30.3    35.3    25.3       93.2    96.2    90.2
2016 07        30.2    35.2    25.2       91.6    95.6    87.6
2016 08        30.0    36.0    24.0       90.3    94.3    86.3
2016 09        29.8    36.8    22.8       89.5    94.5    84.5
2016 10        30.0    37.0    23.0       88.9    94.9    82.9
2016 11        30.1    38.1    22.1       88.1    95.1    81.1
2016 12        30.5    39.5    21.5       87.8    95.8    79.8

首次实施

在第一个实现中,让我们将数据作为元组列表放入源代码中。这样,就很容易接近了。这里有一个你可以如何做的例子:

data = [
    # Year Month Predicted High Low
    (2016, 03, 30.9, 31.9, 29.9),
    (2016, 04, 30.5, 32.5, 28.5),
    # Add more data here
    ]

这样一来,让我们看看如何将数据转化为图形。

使用 ReportLab 绘图

ReportLab 由许多部分组成,使您能够以多种方式创建输出。生成 pdf 最基本的模块是pdfgen。它包含一个Canvas类,带有几个用于绘图的底层方法。例如,要在名为cCanvas上画线,可以调用c.line方法。

我们将使用更高级的图形框架(在包reportlab.graphics及其子模块中),这将使我们能够创建各种形状对象,并将它们添加到一个Drawing对象中,您可以稍后以 PDF 格式输出到一个文件中。

清单 21-1 展示了一个绘制字符串“Hello,world!”在一个 100 × 100 点的 PDF 图形中间。(你可以在图 21-1 中看到结果。)基本结构如下:创建一个给定大小的绘图,创建具有某些属性的图形元素(在本例中是一个String对象),然后将这些元素添加到绘图中。最后,绘图被渲染为 PDF 格式并保存到文件中。

A326949_3_En_21_Fig1_HTML.jpg

图 21-1。

A simple ReportLab figure

from reportlab.graphics.shapes import Drawing, String
from reportlab.graphics import renderPDF

d = Drawing(100, 100)
s = String(50, 50, 'Hello, world!', textAnchor='middle')

d.add(s)

renderPDF.drawToFile(d, 'hello.pdf', 'A simple PDF file')

Listing 21-1.A Simple ReportLab Program
(hello_report.py)

renderPDF.drawToFile的调用将您的 PDF 文件保存到当前目录中的一个名为hello.pdf的文件中。

String构造函数的主要参数是它的 x 和 y 坐标及其文本。此外,您可以提供各种属性(如字体大小、颜色等)。在这种情况下,我提供了一个textAnchor,它是字符串中应该放在坐标给定点上的部分。

构建一些折线

要创建太阳黑子数据的折线图,你需要创建一些线条。事实上,您需要创建几条链接的线。ReportLab 对此有一个特殊的类:PolyLine

创建一个PolyLine,用一个坐标列表作为它的第一个参数。这个列表的形式是[(x0, y0), (x1, y1), ...],每对 x 和 y 坐标在PolyLine上形成一个点。简单的PolyLine见图 21-2 。

A326949_3_En_21_Fig2_HTML.jpg

图 21-2。

PolyLine([(0, 0), (10, 0), (10, 10), (0, 10)])

要制作折线图,必须为数据集中的每一列创建一条折线。这些折线中的每个点将由一个时间(由年和月构成)和一个值(太阳黑子的数量,取自相关列)组成。要获得其中一列(值),列表理解可能很有用。

pred = [row[2] for row in data]

这里,pred(对于“预测的”)将是数据的第三列中的所有值的列表。您可以对其他列使用类似的策略。(每一行的时间都需要根据年和月来计算,例如,年+月/12。)

一旦有了值和时间戳,就可以将折线添加到绘图中,如下所示:

drawing.add(PolyLine(list(zip(times, pred)), strokeColor=colors.blue))

当然,没有必要设置描边颜色,但是这样可以更容易区分线条。(注意如何使用zip将时间和值组合成一个元组列表。)

编写原型

现在你已经有了编写程序第一个版本所需要的东西。源代码如清单 21-2 所示。

from reportlab.lib import colors
from reportlab.graphics.shapes import *
from reportlab.graphics import renderPDF

data = [
#    Year  Month   Predicted    High   Low
    (2007, 8,      113.2,       114.2, 112.2),
    (2007, 9,      112.8,       115.8, 109.8),
    (2007, 10,     111.0,       116.0, 106.0),
    (2007, 11,     109.8,       116.8, 102.8),
    (2007, 12,     107.3,       115.3,  99.3),
    (2008, 1,      105.2,       114.2,  96.2),
    (2008, 2,      104.1,       114.1,  94.1),
    (2008, 3,      99.9,        110.9,  88.9),
    (2008, 4,      94.8,        106.8,  82.8),
    (2008, 5,      91.2,        104.2,  78.2),
    ]

drawing = Drawing(200, 150)

pred = [row[2]-40 for row in data]
high = [row[3]-40 for row in data]
low = [row[4]-40 for row in data]
times = [200*((row[0] + row[1]/12.0) - 2007)-110 for row in data]

drawing.add(PolyLine(list(zip(times, pred)), strokeColor=colors.blue))
drawing.add(PolyLine(list(zip(times, high)), strokeColor=colors.red))
drawing.add(PolyLine(list(zip(times, low)), strokeColor=colors.green))

drawing.add(String(65, 115, 'Sunspots', fontSize=18, fillColor=colors.red))
renderPDF.drawToFile(drawing, 'report1.pdf', 'Sunspots')

Listing 21-2.The First Prototype for the Sunspot Graph Program (sunspots_proto.py)

如您所见,我已经调整了值和时间戳以获得正确的定位。结果图如图 21-3 所示。

A326949_3_En_21_Fig3_HTML.jpg

图 21-3。

A simple sunspot graph

虽然制作了一个可以工作的程序是令人高兴的,但显然还有改进的余地。

第二次实施

那么,我们从原型中学到了什么?我们已经弄清楚了如何用 ReportLab 画图的基础。我们还看到了如何提取数据,以便很好地绘制图表。然而,该计划也有一些弱点。为了正确定位,我必须对值和时间戳添加一些特别的修改。并且程序实际上并不从任何地方获取数据(或者,更确切地说,它从程序本身内部的列表中“获取”数据,而不是从外部来源读取数据)。

与项目 1 不同(在第二十章),第二个实现不会比第一个大得多或复杂得多。这将是一个渐进的改进,使用 ReportLab 的一些更合适的功能,并从互联网上获取数据。

获取数据

正如你在第十四章看到的,你可以用标准模块urllib通过互联网获取文件。它的函数urlopen的工作方式与open非常相似,但是它使用 URL 而不是文件名作为参数。当你打开文件并阅读其内容后,你需要过滤掉你不需要的内容。该文件包含空行(仅由空格组成)和以一些特殊字符(#:)开头的行。程序应该忽略这些。(请参阅本章前面“准备工作”一节中的示例文件片段。)

假设 URL 保存在一个名为URL的变量中,并且变量COMMENT_CHARS已经被设置为字符串'#:',您可以得到一个行列表(如我们的原始程序所示),如下所示:

data = []
for line in urlopen(URL).readlines():
    line = line.decode()
    if not line.isspace() and not line[0] in COMMENT_CHARS:
        data.append([float(n) for n in line.split()])

前面的代码将包括数据列表中的所有列,尽管您对与无线电通量有关的列并不特别感兴趣。但是,当您提取真正需要的列时,这些列将被过滤掉(就像您在原始程序中所做的那样)。

Note

如果您正在使用自己的数据源(或者如果在您阅读本文时,黑子文件的数据格式已经改变),那么您当然需要相应地修改这段代码。

使用 LinePlot 类

如果你认为获取数据出奇的简单,画一个更漂亮的线图也不是什么挑战。在这种情况下,最好浏览一下文档(在这种情况下,是 ReportLab 文档),看看是否已经存在可以满足您需要的功能,这样您就不需要自己实现它了。幸运的是,有这样一个东西:来自模块reportlab.graphics.charts.lineplotsLinePlot类。当然,你可以从这个开始,但是本着快速原型的精神,你只是利用手边的东西来看看你能做什么。现在是时候更进一步了。

在没有任何参数的情况下实例化了LinePlot,然后在将它添加到Drawing之前设置它的属性。您需要设置的主要属性有xyheightwidthdata。前四个应该是不言自明的;后者只是一个点列表,每个点列表都是一个元组列表,就像您在PolyLine中使用的那样。

最后,让我们设置每条线的笔画颜色。最终代码如清单 21-3 所示。结果图(当然,不同的输入数据看起来会有些不同)如图 21-4 所示。

A326949_3_En_21_Fig4_HTML.jpg

图 21-4。

The final sunspot graph

from urllib.request import urlopen
from reportlab.graphics.shapes import *
from reportlab.graphics.charts.lineplots import LinePlot
from reportlab.graphics.charts.textlabels import Label
from reportlab.graphics import renderPDF

URL = 'ftp://ftp.swpc.noaa.gov/pub/weekly/Predict.txt'
COMMENT_CHARS = '#:'

drawing = Drawing(400, 200)
data = []
for line in urlopen(URL).readlines():
    line = line.decode()
    if not line.isspace() and line[0] not in COMMENT_CHARS:
        data.append([float(n) for n in line.split()])

pred = [row[2] for row in data]
high = [row[3] for row in data]
low = [row[4] for row in data]
times = [row[0] + row[1]/12.0 for row in data]

lp = LinePlot()
lp.x = 50
lp.y = 50
lp.height = 125

lp.width = 300
lp.data = [list(zip(times, pred)),

           list(zip(times, high)),
           list(zip(times, low))]
lp.lines[0].strokeColor = colors.blue
lp.lines[1].strokeColor = colors.red
lp.lines[2].strokeColor = colors.green

drawing.add(lp)

drawing.add(String(250, 150, 'Sunspots',
            fontSize=14, fillColor=colors.red))

renderPDF.drawToFile(drawing, 'report2.pdf', 'Sunspots')

Listing 21-3.The Final Sunspot Program (sunspots.py)

进一步探索

Python 提供了许多图形和绘图包。ReportLab 的一个很好的替代品是 PYX,我在本章前面提到过。使用 ReportLab 或 PYX(或其他一些包),您可以尝试将自动生成的图形合并到文档中(也许还会生成其中的一部分)。你可以使用第二十章中的一些技巧来给文本添加标记。如果你想创建一个 PDF 文档,那么 Platypus,ReportLab 的一部分,是有用的。(您也可以将 PDF 图形与一些排版系统(如 LATEX)集成在一起。)如果你想创建网页,也可以使用 Python 创建 pixmap 图形(如 GIF 或 PNG)——只需在网上搜索相关主题。

如果您的主要目标是绘制数据(这正是我们在这个项目中所做的),您有许多 ReportLab 和 PYX 的替代方案。一个很好的选择是 Matplotlib/pylab ( http://matplotlib.org ),但是还有很多其他类似的包。

什么现在?

在第一个项目中,您学习了如何通过创建一个可扩展的解析器将标记添加到纯文本文件中。在下一个项目中,您将学习如何使用 Python 标准库中已经存在的解析器机制来分析标记文本(XML 格式)。该项目的目标是使用一个 XML 文件来指定一个完整的网站,然后由您的程序自动生成(带有文件、目录、添加的页眉和页脚)。您在下一个项目中学到的技术将普遍适用于 XML 解析,鉴于 XML 的普遍性,这不会有什么坏处。

二十二、项目 3:适合所有场合的 XML

我在项目 1 中简单提到了 XML。现在是时候更详细地检查它了。在这个项目中,您将看到如何使用 XML 来表示多种数据,以及如何使用 Simple API for XML(SAX)来处理 XML 文件。这个项目的目标是从一个描述各种网页和目录的 XML 文件生成一个完整的网站。

在本章中,我假设你知道什么是 XML 以及如何编写它。如果你知道一些 HTML,你已经熟悉的基础。XML 并不是真正的特定语言(比如 HTML);它更像是定义一类语言的一套规则。基本上,您仍然可以像在 HTML 中一样编写标记,但是在 XML 中,您可以自己发明标记名。这种特定的标记名集合及其结构关系可以在文档类型定义或 XML 模式中描述——我在这里不讨论这些。

关于什么是 XML 的简明描述,请参见万维网联盟(W3C)的“XML 十要点”( https://www.w3.org/XML/1999/XML-in-10-points-19990327 )。更详细的教程可以在 W3Schools 网站上找到( http://www.w3schools.com/xml )。关于 SAX 的更多信息,请参见 SAX 官方网站( http://www.saxproject.org )。

有什么问题?

在这个项目中,您要解决的一般问题是解析(读取和处理)XML 文件。因为您可以使用 XML 来表示几乎任何东西,并且在解析数据时可以对数据做任何想做的事情,所以应用是无限的(正如本章的标题所示)。本章要解决的具体问题是从一个 XML 文件生成一个完整的网站,该文件包含网站的结构和每个页面的基本内容。

在您着手这个项目之前,我建议您花一些时间阅读一些关于 XML 的知识,并了解它的应用。这可能会让你更好地理解什么时候它可能是一种有用的文件格式,什么时候它可能是多余的。(毕竟,当你只需要纯文本文件的时候,它们就可以了。)

Anything, You Say?

您可能会怀疑用 XML 到底能表示什么。好吧,让我给你举几个它的用法的例子:

XML 的现有应用示例可以在 XML 封面( http://xml.coverpages.org/xml.html#applications )中找到。

让我们来定义项目的具体目标。

  • 整个网站应该用一个 XML 文件来描述,这个文件应该包含关于各个网页和目录的信息。
  • 该计划应根据需要创建目录和网页。
  • 应该很容易改变整个网站的总体设计,并用新设计重新生成所有页面。

这最后一点可能足以让这一切变得值得,但还有其他好处。通过将所有内容放在一个 XML 文件中,您可以轻松地编写其他程序,使用相同的 XML 处理技术提取各种信息,如目录、自定义搜索引擎的索引等。即使您的网站不使用它,您也可以用它来创建基于 HTML 的幻灯片(或者,通过使用上一章讨论的 ReportLab,您甚至可以创建 PDF 幻灯片)。

有用的工具

Python 有一些内置的 XML 支持,但是如果您使用的是旧版本,您可能需要自己安装一些额外的支持。在这个项目中,您需要一个正常工作的 SAX 解析器。要查看您是否有可用的 SAX 解析器,请尝试执行以下命令:

>>> from xml.sax import make_parser
>>> parser = make_parser()

当您这样做时,很可能不会引发任何异常。在这种情况下,您已经准备好了,可以继续“准备”部分。

Tip

现在有很多针对 Python 的 XML 工具。“标准”PyXML 框架的一个非常有趣的替代方案是 Fredrik Lundh 的 ElementTree(和 C 实现 cElementTree),它也包含在 Python 标准库的最新版本中,在包xml.etree中。如果你有更老的 Python 版本,可以从 http://effbot.org/zone 获得 ElementTree。它非常强大且易于使用,如果您真的想在 Python 中使用 XML 的话,很值得一看。

如果您确实得到了一个异常,您必须安装 PyXMLweb 搜索应该会为您指出正确的方向(除非您的 Python 很古老,尽管它应该自带 XML 支持)。

准备

在编写处理 XML 文件的程序之前,必须设计 XML 格式。你需要什么标签,它们应该有什么属性,哪些标签应该放在哪里?为了找到答案,让我们首先考虑你希望你的格式描述什么。

主要的概念是网站、目录、页面、名称、标题和内容。

  • 您不会存储关于网站本身的任何信息,所以网站只是包含所有文件和目录的顶级元素。
  • 目录主要是文件和其他目录的容器。
  • 页面是单个网页。
  • 目录和网页都需要名字。这些将被用作目录名和文件名,因为它们将出现在文件系统和相应的 URL 中。
  • 每个网页都应该有一个标题(与其文件名不同)。
  • 每个网页也会有一些内容。我们将使用普通的 XHTML 来表示这里的内容。这样,我们可以简单地将它传递到最终的网页,让浏览器来解释它。

简而言之,您的文档将由一个单独的website元素组成,包含几个directorypage元素,每个目录元素可选地包含更多的页面和目录。directorypage元素将有一个名为name的属性,其中将包含它们的名称。另外,page标签有一个title属性。元素包含 XHTML 代码(属于 XHTML body标签中的类型)。清单 22-1 中显示了一个样本文件。

<website>
  <page name="index" title="Home Page">
    <h1>Welcome to My Home Page</h1>

    <p>Hi, there. My name is Mr. Gumby, and this is my home page.
    Here are some of my interests:</p>

    <ul>
      <li><a href="interests/shouting.html">Shouting</a></li>
      <li><a href="interests/sleeping.html">Sleeping</a></li>
      <li><a href="interests/eating.html">Eating</a></li>
    </ul>
  </page>
  <directory name="interests">
    <page name="shouting" title="Shouting">
      <h1>Mr. Gumby's Shouting Page</h1>

      <p>...</p>
    </page>
    <page name="sleeping" title="Sleeping">
      <h1>Mr. Gumby's Sleeping Page</h1>

      <p>...</p>
    </page>
    <page name="eating" title="Eating">

      <h1>Mr. Gumby's Eating Page</h1>

      <p>...</p>
    </page>
  </directory>
</website>

Listing 22-1.A Simple Web Site Represented

As an XML File (website.xml)

首次实施

此时,我们还没有看到 XML 解析是如何工作的。我们在这里使用的方法(称为 SAX)包括编写一组事件处理程序(就像在 GUI 编程中一样),然后让现有的 XML 解析器在读取 XML 文档时调用这些处理程序。

What about DOM?

Python(以及其他编程语言)中有两种处理 XML 的常用方法:SAX 和文档对象模型(DOM)。SAX 解析器通读 XML 文件,告诉您它看到了什么(文本、标记和属性),一次只存储文档的一小部分。这使得 SAX 简单、快速并且节省内存,这也是我在本章中选择使用它的原因。DOM 采用另一种方法:它构造一个数据结构(文档树),表示整个文档。这种方法速度较慢,需要更多的内存,但如果您想操作文档的结构,这种方法会很有用。

创建简单的内容处理程序

使用 SAX 进行解析时,有几种事件类型可用,但是让我们将自己限制为三种:元素的开始(开始标记的出现)、元素的结束(结束标记的出现)和纯文本(字符)。为了解析 XML 文件,让我们使用来自xml.sax模块的parse函数。这个函数负责读取文件和生成事件,但是在生成这些事件时,它需要调用一些事件处理程序。这些事件处理程序将作为内容处理程序对象的方法来实现。您将从xml.sax.handler继承ContentHandler类,因为它实现了所有必要的事件处理程序(作为无效的虚拟操作),并且您可以只覆盖您需要的那些。

让我们从一个最小的 XML 解析器开始(假设您的 XML 文件叫做website.xml)。

from xml.sax.handler import ContentHandler
from xml.sax import parse

class TestHandler(ContentHandler): pass
parse('website.xml', TestHandler())

如果你执行这个程序,看起来什么也没发生,但是你也不会得到任何错误信息。在幕后,XML 文件被解析,默认的事件处理程序被调用,但是因为它们不做任何事情,所以您看不到任何输出。

让我们尝试一个简单的扩展。将以下方法添加到TestHandler类中:

def startElement(self, name, attrs):
    print(name, attrs.keys())

这将覆盖默认的startElement事件处理程序。参数是相关的标记名及其属性(保存在一个类似字典的对象中)。如果您再次运行该程序(使用清单 22-1 中的website.xml,您会看到以下输出:

website []
page [u'name', u'title']
h1 []
p []
ul []
li []
a [u'href']
li []
a [u'href']
li []
a [u'href']
directory [u'name']
page [u'name', u'title']
h1 []
p []
page [u'name', u'title']
h1 []
p []
page [u'name', u'title']
h1 []
p []

这是如何工作的应该很清楚了。除了startElement,我们还将使用endElement(它只接受一个标签名作为参数)和characters(它接受一个字符串作为参数)。

下面是一个使用所有这三种方法构建网站文件标题列表的例子:

from xml.sax.handler import ContentHandler 

from xml.sax import parse

class HeadlineHandler(ContentHandler):

    in_headline = False

    def __init__(self, headlines):
        super().__init__()
        self.headlines = headlines
        self.data = []

    def startElement(self, name, attrs):
        if name == 'h1':
            self.in_headline = True

    def endElement(self, name):
        if name == 'h1':
            text = ''.join(self.data)
            self.data = []
            self.headlines.append(text)
            self.in_headline = False

    def characters(self, string):
        if self.in_headline:
            self.data.append(string)

headlines = []
parse('website.xml', HeadlineHandler(headlines))

print('The following <h1> elements were found:')
for h in headlines:
    print(h)

注意,HeadlineHandler跟踪它当前是否正在解析一对h1标签中的文本。这是通过当startElement找到一个h1标签时将self.in_headline设置为True以及当endElement找到一个h1标签时将self.in_headline设置为False来实现的。当解析器找到一些文本时,会自动调用characters方法。只要解析器在两个h1标签之间(self.in_headlineTrue),那么characters就会把字符串(可能只是标签之间文本的一部分)追加到self.data中,也就是一个字符串列表。连接这些文本片段,将它们附加到self.headlines(作为单个字符串),并将self.data重置为空列表的任务也落到了endElement身上。这种通用方法(使用布尔变量来指示您当前是否在给定标记类型的“内部”)在 SAX 编程中非常常见。

运行这个程序(同样,使用清单 22-1 中的website.xml文件),您会得到以下输出:

The following <h1> elements were found:
Welcome to My Home Page
Mr. Gumby's Shouting Page
Mr. Gumby's Sleeping Page
Mr. Gumby's Eating Page

创建 HTML 页面

现在,您已经准备好制作原型了。现在,让我们忽略目录,专注于创建 HTML 页面。您需要创建一个稍加修饰的事件处理程序来完成以下任务:

  • 在每个page元素的开头,用给定的名称打开一个新文件,并向其中写入一个合适的 HTML 标题,包括给定的标题
  • 在每个page元素的末尾,向文件写入一个合适的 HTML 页脚,并关闭它
  • page元素内部,遍历所有标签和字符而不修改它们(按原样写入文件)
  • 当不在page元素中时,忽略所有标签(如websitedirectory

大部分都很简单(至少如果你知道一点 HTML 文档是如何构造的)。然而,有两个问题可能并不十分明显。

  • 您不能简单地“传递”标签(将它们直接写到您正在构建的 HTML 文件中),因为您只能获得它们的名称(可能还有一些属性)。您必须自己重建标签(用尖括号等等)。
  • SAX 本身无法知道您当前是否在一个page元素的“内部”。

你必须自己跟踪这类事情(就像你在HeadlineHandler例子中所做的那样)。对于这个项目,您只对是否传递标签和字符感兴趣,所以您将使用一个名为passthrough的布尔变量,它将在您进入和离开页面时更新。

简单程序的代码见清单 22-2 。

from xml.sax.handler import ContentHandler
from xml.sax import parse

class PageMaker(ContentHandler):

    passthrough = False

    def startElement(self, name, attrs):
        if name == 'page':
            self.passthrough = True
            self.out = open(attrs['name'] + '.html', 'w')
            self.out.write('<html><head>\n')
            self.out.write('<title>{}</title>\n'.format(attrs['title']))
            self.out.write('</head><body>\n')
        elif self.passthrough:
            self.out.write('<' + name)
            for key, val in attrs.items():
                self.out.write(' {}="{}"'.format(key, val))
            self.out.write('>')

    def endElement(self, name):

        if name == 'page':
            self.passthrough = False
            self.out.write('\n</body></html>\n')
            self.out.close()
        elif self.passthrough:
            self.out.write('</{}>'.format(name))

    def characters(self, chars):
        if self.passthrough: self.out.write(chars)

parse('website.xml', PageMaker())

Listing 22-2.A Simple Page Maker Script

(pagemaker.py)

您应该在您希望文件出现的目录中执行此操作。请注意,即使两个页面位于两个不同的目录元素中,它们最终也会位于同一个真实目录中。(这将在我们的第二次实现中解决。)

同样,使用清单 22-1 中的文件website.xml,可以得到四个 HTML 文件。名为index.html的文件包含以下内容:

<html><head>
<title>Home Page</title>
</head><body>

<h1>Welcome to My Home Page</h1>

<p>Hi, there. My name is Mr. Gumby, and this is my home page. Here are some of my interests:</p>

<ul>
   <li><a href="interests/shouting.html">Shouting</a></li>
   <li><a href="interests/sleeping.html">Sleeping</a></li>
   <li><a href="interests/eating.html">Eating</a></li>
</ul>

</body></html>

图 22-1 显示了该页面在浏览器中的外观。

A326949_3_En_22_Fig1_HTML.jpg

图 22-1。

A generated web page

查看代码,两个主要的弱点应该是显而易见的。

  • 它使用if语句来处理各种事件类型。如果您需要处理许多这样的事件类型,您的if语句将变得很大并且不可读。
  • HTML 代码是硬连线的。应该很容易更换。

这两个弱点将在第二个实现中解决。

第二次实施

因为 SAX 机制是如此低级和基本,所以您可能会发现编写一个混合类来处理一些管理细节非常有用,比如收集字符数据、管理布尔状态变量(比如passthrough)或者将事件分派给自己的定制事件处理程序。在这个项目中,状态和数据处理非常简单,所以让我们把重点放在处理程序调度上。

调度员混合班

不需要在标准的通用事件处理程序(如startElement)中编写大量的if语句,最好只编写自己的特定语句(如startPage)并自动调用它们。您可以在一个 mix-in 类中实现该功能,然后将 mix-in 和ContentHandler一起子类化。

Note

正如第七章中提到的,mix-in 是一个功能有限的类,它意味着与其他一些更重要的类一起被子类化。

您希望您的程序具有以下功能:

  • 当用一个名字如'foo'调用startElement时,它应该试图找到一个名为startFoo的事件处理程序,并用给定的属性调用它。
  • 同样,如果用'foo'调用endElement,它应该尝试调用endFoo
  • 如果在这些方法中没有找到给定的处理程序,将调用一个名为defaultStart(或defaultEnd)的方法(如果存在的话)。如果默认的处理程序也不存在,就不应该做任何事情。

此外,应该注意参数。定制处理程序(例如,startFoo)不需要标签名作为参数,而定制默认处理程序(例如,defaultStart)需要。此外,只有启动处理程序需要这些属性。

迷茫?让我们从编写类中最简单的部分开始。

class Dispatcher:

    # ...

    def startElement(self, name, attrs):
        self.dispatch('start', name, attrs)
    def endElement(self, name):
        self.dispatch('end', name)

这里实现了基本的事件处理程序,它们简单地调用一个名为dispatch的方法,该方法负责寻找合适的处理程序,构造参数元组,然后用这些参数调用处理程序。下面是dispatch方法的代码:

def dispatch(self, prefix, name, attrs=None):
    mname = prefix + name.capitalize()
    dname = 'default' + prefix.capitalize()
    method = getattr(self, mname, None)
    if callable(method): args = ()
    else:
        method = getattr(self, dname, None)
        args = name,
    if prefix == 'start': args += attrs,
    if callable(method): method(*args)

下面是发生的情况:

  1. 根据一个前缀(或者是'start'或者是'end')和一个标签名(例如'page',构造处理程序的方法名(例如'startPage')。
  2. 使用相同的前缀,构造默认处理程序的名称(例如,'defaultStart')。
  3. 尝试用getattr获取处理程序,使用None作为缺省值。
  4. 如果结果是可调用的,则为args分配一个空元组。
  5. 否则,尝试使用getattr获取默认处理程序,再次使用None作为默认值。另外,将args设置为只包含标签名的元组(因为默认处理程序需要它)。
  6. 如果您正在处理一个开始处理程序,将属性添加到参数元组(args)。
  7. 如果您的处理程序是可调用的(也就是说,它或者是可行的特定处理程序,或者是可行的默认处理程序),请使用正确的参数调用它。

明白了吗?这基本上意味着您现在可以像这样编写内容处理程序:

class TestHandler(Dispatcher, ContentHandler):
    def startPage(self, attrs):
        print('Beginning page', attrs['name'])
    def endPage(self):
        print('Ending page')

因为 dispatcher mix-in 负责大部分的管道工作,所以内容处理程序相当简单,可读性很强。(当然,我们稍后会添加更多功能。)

分解出页眉、页脚和默认处理

这一节比上一节容易得多。我们将创建单独的方法来编写页眉和页脚,而不是直接在事件处理程序中调用self.out.write。这样,我们可以很容易地通过子类化事件处理程序来覆盖这些方法。让我们让默认的页眉和页脚变得非常简单。

def writeHeader(self, title):
    self.out.write("<html>\n  <head>\n    <title>")
    self.out.write(title)
    self.out.write("</title>\n  </head>\n  <body>\n")

def writeFooter(self):
    self.out.write("\n  </body>\n</html>\n")

XHTML 内容的处理也与原始处理程序联系得过于紧密。XHTML 现在将由defaultStartdefaultEnd来处理。

def defaultStart(self, name, attrs):
    if self.passthrough:
        self.out.write('<' + name)
        for key, val in attrs.items():
            self.out.write(' {}="{}"'.format(key, val))
        self.out.write('>')

def defaultEnd(self, name):
    if self.passthrough:
         self.out.write('</{}>'.format(name))

这和以前一样,只是我把代码移到了不同的方法中(这通常是件好事)。现在,到了拼图的最后一块。

支持目录

要创建必要的目录,您需要函数os.makedirs,它将所有必要的目录放在给定的路径中。例如,os.makedirs('foo/bar/baz')在当前目录中创建目录foo,然后在foo中创建bar,最后在bar中创建baz。如果foo已经存在,则只创建barbaz,同样,如果bar也存在,则只创建baz。但是,如果baz也存在,通常会引发异常。为了避免这种情况,我们提供了关键字参数exist_ok=True。另一个有用的函数是os.path.join,它用正确的分隔符连接几个路径(例如,UNIX 中的/等等)。

在处理过程中,始终将当前目录路径保存为目录名列表,由变量directory引用。当您输入一个目录时,附加它的名称;你离开的时候,把名字去掉。假设directory设置正确,您可以定义一个函数来确保当前目录存在。

def ensureDirectory(self):
    path = os.path.join(*self.directory)
    os.makedirs(path, exist_ok=True)

请注意,在将目录列表提供给os.path.join时,我是如何在目录列表上使用参数拼接的(带有星号运算符,*)。

我们网站的基目录(例如,public_html)可以作为构造函数的一个参数,它看起来像这样:

def __init__(self, directory):
    self.directory = [directory]
    self.ensureDirectory()

事件处理程序

最后,我们来到了事件处理程序。您需要四个:两个用于处理目录,两个用于页面。目录处理程序简单地使用了directory列表和ensureDirectory方法。

def startDirectory(self, attrs):
    self.directory.append(attrs['name'])
    self.ensureDirectory()

def endDirectory(self):
    self.directory.pop()

页面处理程序使用writeHeaderwriteFooter方法。此外,他们还设置了passthrough变量(通过 XHTML),而且——也许是最重要的——他们打开和关闭与页面相关的文件:

def startPage(self, attrs):
    filename = os.path.join(*self.directory + [attrs['name'] + '.html'])
    self.out = open(filename, 'w')
    self.writeHeader(attrs['title'])
    self.passthrough = True

def endPage(self):
    self.passthrough = False
    self.writeFooter()
    self.out.close()

startPage的第一行可能看起来有点吓人,但它与ensureDirectory的第一行或多或少是一样的,除了您添加了文件名(并给它加了一个.html后缀)。

该程序的完整源代码如清单 22-3 所示。

from xml.sax.handler import ContentHandler
from xml.sax import parse
import os

class Dispatcher:

    def dispatch(self, prefix, name, attrs=None):
        mname = prefix + name.capitalize()
        dname = 'default' + prefix.capitalize()
        method = getattr(self, mname, None)
        if callable(method): args = ()
        else:
            method = getattr(self, dname, None)
            args = name,
        if prefix == 'start': args += attrs,
        if callable(method): method(*args)

    def startElement(self, name, attrs):
        self.dispatch('start', name, attrs)

    def endElement(self, name):
        self.dispatch('end', name)

class WebsiteConstructor(Dispatcher, ContentHandler):

    passthrough = False

    def __init__(self, directory):
        self.directory = [directory] 

        self.ensureDirectory()

    def ensureDirectory(self):
        path = os.path.join(*self.directory)
        os.makedirs(path, exist_ok=True)

    def characters(self, chars):
        if self.passthrough: self.out.write(chars)

    def defaultStart(self, name, attrs):
        if self.passthrough:
            self.out.write('<' + name)
            for key, val in attrs.items():
                self.out.write(' {}="{}"'.format(key, val))
            self.out.write('>')

    def defaultEnd(self, name):
        if self.passthrough:
            self.out.write('</{}>'.format(name))

    def startDirectory(self, attrs):
        self.directory.append(attrs['name'])
        self.ensureDirectory()

    def endDirectory(self):
        self.directory.pop()

    def startPage(self, attrs):
        filename = os.path.join(*self.directory + [attrs['name'] + '.html'])
        self.out = open(filename, 'w')
        self.writeHeader(attrs['title'])
        self.passthrough = True

    def endPage(self):
        self.passthrough = False
        self.writeFooter()
        self.out.close()

    def writeHeader(self, title):
        self.out.write('<html>\n  <head>\n    <title>')
        self.out.write(title)
        self.out.write('</title>\n  </head>\n  <body>\n')

    def writeFooter(self):
        self.out.write('\n  </body>\n</html>\n')

parse('website.xml', WebsiteConstructor('public_html'))

Listing 22-3.The Web Site Constructor (website.py)

清单 22-3 生成以下文件和目录:

  • public_html/
  • public_html/index. html
  • public_html/interests/
  • public_html/interests/shouting.html
  • public_html/interests/sleeping.html
  • public_html/interests/eating.html

进一步探索

现在你有了基本程序。你能用它做什么?以下是一些建议:

  • 创建一个新的ContentHandler,用于为网站生成目录或菜单(带链接)。
  • 在网页上添加导航工具,告诉用户他们在哪里(在哪个目录下)。
  • 创建一个WebsiteConstructor的子类,覆盖writeHeaderwriteFooter,以提供定制的设计。
  • 创建另一个从 XML 文件构建单个 web 页面的ContentHandler
  • 创建一个以某种方式总结你的网站的ContentHandler,例如,在 RSS 中。
  • 查看其他转换 XML 的工具,尤其是 XML 转换(XSLT)。
  • 使用诸如 ReportLab 的 Platypus ( http://www.reportlab.org )之类的工具,基于 XML 文件创建一个或多个 PDF 文档。
  • 使通过网络界面编辑 XML 文件成为可能(参见第二十五章)。

什么现在?

在涉足 XML 解析领域之后,让我们再做一些网络编程。在下一章中,我们将创建一个程序,它可以从各种网络资源中收集新闻条目,并为您生成定制的新闻报告。

二十三、项目 4:在新闻中

互联网上充斥着各种形式的新闻来源,包括报纸、视频频道、博客和播客等等。其中一些还提供服务,如 RSS 或 Atom 提要,让您使用相对简单的代码检索最新的新闻,而不必解析它们的网页。在这个项目中,我们将探索一种先于网络的机制:网络新闻传输协议(NNTP)。我们将从一个没有任何抽象形式(没有函数,没有类)的简单原型到一个添加了一些重要抽象的通用系统。我们将使用nntplib库,它允许您与 NNTP 服务器交互,但是添加其他协议和机制应该很简单。

NNTP 是一种标准的网络协议,用于管理在所谓的新闻组讨论组上发布的消息。NNTP 服务器形成了一个全球网络,集中管理这些新闻组,通过 NNTP 客户端(也称为新闻阅读器),你可以张贴和阅读消息。NNTP 服务器的主要网络叫做新闻组,建立于 1980 年(尽管 NNTP 协议直到 1985 年才被使用)。与当前的网络趋势相比,这是相当“老派”的,但大多数互联网(在某种程度上)是基于这样的老派技术,和它可能不会伤害周围玩一些低级的东西。此外,你也可以用自己的新闻采集模块来代替本章中的 NNTP 内容(也许使用脸书或 Twitter 等社交网站的 web API)。

有什么问题?

您在本项目中编写的程序将是一个信息收集代理,一个可以收集信息(更具体地说,新闻)并为您编写报告的程序。考虑到您已经遇到的网络功能,这似乎不是很困难——事实上也不是。但是在这个项目中,你超越了简单的“用urllib下载文件”的方法。你使用了另一个比urllib更难使用的网络库,即nntplib。此外,您还可以重构程序,以允许多种类型的新闻源和各种类型的目的地,在前端和后端之间进行明确的分离,主引擎位于中间。

最终计划的主要目标如下:

  • 这个程序应该能够从许多不同的来源收集新闻。
  • 添加新的新闻来源(甚至是新种类的来源)应该很容易。
  • 这个程序应该能够以多种不同的格式将编辑好的新闻报道发送到不同的目的地。
  • 添加新的目的地(甚至新类型的目的地)应该很容易。

有用的工具

对于这个项目,你不需要安装单独的软件。然而,您确实需要一些标准的库模块,包括一个您以前没有见过的模块,nntplib,它处理 NNTP 服务器。我们不解释该模块的所有细节,而是通过一些原型来检查它。

准备

为了能够使用nntplib,你需要能够访问 NNTP 服务器。如果您不确定是否需要,您可以向您的 ISP 或系统管理员询问详细信息。在本章的代码示例中,我使用了新闻组comp.lang.python.announce,所以你应该确保你的新闻(NNTP)服务器有这个组,或者你应该找到你想使用的其他组。如果您无法访问 NNTP 服务器,任何人都可以使用几个开放的服务器。在网上快速搜索“免费的 nntp 服务器”会给你一些可供选择的服务器。(nntplib官方文档中的代码示例使用news.gmane.org。)假设你的新闻服务器是news.foo.bar(这不是真实的服务器名,不会起作用),你可以这样测试你的 NNTP 服务器:

>>> from nntplib import NNTP
>>> server = NNTP('news.foo.bar')
>>> server.group('comp.lang.python.announce')[0]

Note

要连接到某些服务器,您可能需要提供额外的身份验证参数。有关 NNTP 构造函数可选参数的详细信息,请参考 Python 库参考( https://docs.python.org/library/nntplib.html )。

最后一行的结果应该是以'211'(基本意思是服务器有你要的组)或者'411'(意思是服务器没有组)开头的字符串。它可能看起来像这样:

'211 51 1876 1926 comp.lang.python.announce'

如果返回的字符串以'411'开头,您应该使用新闻阅读器来查找您可能想要使用的另一个组。(您也可能会得到一个带有等效错误消息的异常。)如果出现异常,可能是您弄错了服务器名。另一种可能是在创建服务器对象和调用group方法之间“超时”——服务器可能只允许您在很短的时间内(比如 10 秒)保持连接。如果您很难快速输入,只需将代码放入脚本并执行它(添加一个print)或将服务器对象创建和方法调用放在同一行(用分号分隔)。

首次实施

本着原型的精神,让我们直接解决这个问题。您要做的第一件事是从 NNTP 服务器上的新闻组下载最新消息。为了简单起见,只需将结果打印到标准输出(用print)。在查看实现的细节之前,您可能想要浏览本节后面的清单 23-1 中的源代码,甚至可能执行程序来看看它是如何工作的。程序逻辑并不复杂——挑战主要在于使用nntplib。我们将使用NNTP类的一个对象,正如您在上一节中看到的,这个类是用 NNTP 服务器的名称实例化的。您需要在这个实例上调用三个方法。

  • group,它选择一个给定的新闻组作为当前新闻组,并返回关于它的一些信息,包括最后一条消息的编号
  • over,为您提供由编号指定的一组消息的概述信息
  • body,返回给定消息的正文

使用与前面相同的虚构服务器名,我们可以进行如下设置:

servername = 'news.foo.bar'
group = 'comp.lang.python.announce'
server = NNTP(servername)
howmany = 10

howmany变量表示我们想要检索多少篇文章。然后我们可以选择我们的组。

resp, count, first, last, name = server.group(group)

返回值是一般的服务器响应、组中消息的估计数量、第一个和最后一个消息编号以及组的名称。我们主要对last感兴趣,我们将使用它来构建我们感兴趣的文章编号的区间,从start = last - howmany + 1开始,以last结束。我们将这一对数字提供给over方法,该方法为我们提供了一系列消息的(id, overview)对。我们从概述中提取主题,并使用 ID 从服务器获取消息正文。

消息正文的行以字节形式返回。如果我们使用默认的 UTF-8 来解码它们,如果我们猜错了,我们可能会得到一些非法的字节序列。理想情况下,我们应该提取编码信息,但为了简单起见,我们只使用 Latin-1 编码,它适用于普通 ASCII,不会抱怨非 ASCII 字节。打印完所有文章,我们调用server.quit(),就这样。在像bash这样的 UNIX shell 中,您可以像这样运行这个程序:

$ python newsagent1.py | less

使用less有助于一次阅读一篇文章。如果你没有这样的分页程序可用,你可以重写程序的print部分,将结果文本存储在一个文件中,这也是你在第二个实现中要做的(参见第十一章了解更多关于文件处理的信息)。清单 23-1 显示了简单的新闻收集代理的源代码。

from nntplib import NNTP

servername = 'news.foo.bar'
group = 'comp.lang.python.announce'
server = NNTP(servername)
howmany = 10

resp, count, first, last, name = server.group(group)

start = last - howmany + 1

resp, overviews = server.over((start, last))

for id, over in overviews:
    subject = over['subject']
    resp, info = server.body(id)
    print(subject)
    print('-' * len(subject))
    for line in info.lines:
        print(line.decode('latin1'))

    print()

server.quit()

Listing 23-1.A Simple News-Gathering Agent

(newsagent1.py)

第二次实施

第一个实现是可行的,但是相当不灵活,因为它只允许您从新闻组讨论组中检索新闻。在第二个实现中,您可以通过稍微重构代码来解决这个问题。您可以通过创建一些类和方法来表示代码的各个部分,从而添加结构和抽象。一旦你这样做了,一些部分可能会被其他类替换,这比你替换原始程序中的部分代码要容易得多。

同样,在深入了解第二个实现的细节之前,您可能想浏览(或者执行)本章后面的清单 23-2 中的代码。

Note

在清单 23-2 中的代码运行之前,您需要将clpa_server变量设置为可用的 NNTP 服务器。

那么,需要上什么课呢?我们先简单回顾一下问题描述中的重要名词,如第七章所建议的:信息、代理、新闻、报道、网络、新闻来源、目的地、前端、后端、主引擎。这个名词列表暗示了以下主要类别(或类别种类):NewsAgentNewsItemSourceDestination

各种来源将构成前端,目的地将构成后端,新闻代理位于中间。

这其中最简单的就是NewsItem。它只表示一段数据,由标题和正文(一段简短的文本)组成,可以按如下方式实现:

class NewsItem:

    def __init__(self, title, body):

        self.title = title
        self.body = body

要确切地了解新闻源和新闻目的地需要什么,从编写代理本身开始可能是一个好主意。代理必须维护两个列表:一个是源列表,一个是目的列表。可以通过方法addSourceaddDestination添加源和目的地。

class NewsAgent:

    def __init__(self):
        self.sources = []
        self.destinations = []

    def addSource(self, source):
        self.sources.append(source)

    def addDestination(self, dest):
        self.destinations.append(dest)

现在唯一缺少的是一种将新闻从源分发到目的地的方法。在分发过程中,每个目的地都必须有一个返回其所有新闻项的方法,每个源都需要一个接收正在分发的所有新闻项的方法。我们称这些方法为getItemsreceiveItems。为了灵活起见,让我们只要求getItems返回一个任意的NewsItems迭代器。然而,为了使目的地更容易实现,让我们假设receiveItems可以用一个序列参数调用(例如,可以迭代多次,在列出新闻条目之前制作一个目录)。在这被决定之后,NewsAgentdistribute方法简单地变成如下:

def distribute(self):
    items = []
    for source in self.sources:
        items.extend(source.getItems())
    for dest in self.destinations:
        dest.receiveItems(items)

这将遍历所有来源,构建一个新闻条目列表。然后,它遍历所有目的地,并为每个目的地提供完整的新闻条目列表。

现在,你只需要几个来源和目的地。要开始测试,您可以简单地创建一个目的地,就像第一个原型中的打印一样。

class PlainDestination:

    def receiveItems(self, items):
        for item in items:
            print(item.title)
            print('-' * len(item.title))
            print(item.body)

格式是相同的;不同之处在于您封装了格式。它现在是几个可选目的地之一,而不是程序的硬编码部分。在本章后面的清单 23-2 中可以看到一个稍微复杂一点的目的地(HTMLDestination,它产生 HTML)。它基于PlainDestination的方法,增加了一些特性。

  • 它产生的文本是 HTML。
  • 它将文本写入特定文件,而不是标准输出。
  • 除了主项目列表之外,它还创建了一个目录。

就这样,真的。目录是使用链接到网页各部分的超链接创建的。我们将通过使用形式为<a href="#nn">...</a>(其中nn是某个数字)的链接来实现这一点,这将导致带有封闭锚标记<a name="nn">...</a>的标题(其中nn应该与目录中的数字相同)。目录和主要新闻条目列表构建在两个不同的for循环中。你可以在图 23-1 中看到一个样本结果(使用即将到来的NNTPSource)。

A326949_3_En_23_Fig1_HTML.jpg

图 23-1。

An automatically generated news page

在考虑设计时,我考虑使用一个泛型超类来表示新闻源,一个表示新闻目的地。事实证明,源和目的地并不真正共享任何行为,所以使用公共超类没有意义。只要他们正确地实现了必要的方法(getItemsreceiveItems),NewsAgent就会很高兴。(这是一个使用协议的例子,如第九章所述,而不是要求一个特定的、公共的超类。)

当创建一个NNTPSource时,大部分代码可以从原始原型中截取。正如您将在清单 23-2 中看到的,与原始版本的主要区别如下:

  • 代码被封装在getItems方法中。servernamegroup变量现在是构造函数的参数。另外,howmany变量已经变成了这个类的构造函数参数。
  • 我添加了一个对decode_header的调用,它处理标题字段(如 subject)中使用的一些特殊编码。
  • 不是直接打印每个新闻条目,而是生成一个NewsItem对象(使getItems成为一个生成器)。

为了展示设计的灵活性,让我们添加另一个新闻源—一个可以从网页中提取新闻条目的新闻源(使用正则表达式;更多信息见第十章。SimpleWebSource(参见清单 23-2 )将一个 URL 和两个正则表达式(一个表示标题,一个表示主体)作为其构造函数参数。在getItems中,它使用正则表达式方法findall来查找所有出现的内容(标题和正文)并使用zip来组合这些内容。然后,它遍历(title, body)对列表,为每个对生成一个NewsItem。如您所见,添加新类型的源(或目的地,就此而言)并不困难。

为了让代码发挥作用,让我们实例化一个代理、一些源和一些目的地。在函数runDefaultSetup(模块作为程序运行时调用)中,实例化了几个这样的对象。

  • 路透社网站的一个SimpleWebSource,它使用两个简单的正则表达式来提取它需要的信息

Note

Reuters 页面上的 HTML 布局可能会改变,在这种情况下,您需要重写正则表达式。当然,这也适用于你使用其他页面的情况。只需查看 HTML 源代码,并尝试找到适用的模式。

  • 一个NNTPSource代表comp.lang.python,其中howmany设置为 10,所以它的工作方式就像第一个原型一样
  • 一个PlainDestination,打印所有收集的新闻
  • 一个HTMLDestination,它生成一个名为news.html的新闻页面

当所有这些对象都被创建并添加到NewsAgent时,就调用distribute方法。您可以像这样运行程序:

$ python newsagent2.py

产生的news.html页面如图 23-2 所示。第二个实现的完整源代码可以在清单 23-2 中找到。

A326949_3_En_23_Fig2_HTML.jpg

图 23-2。

A news page with more than one source

from nntplib import NNTP, decode_header
from urllib.request import urlopen
import textwrap
import re

class NewsAgent:
    """
    An object that can distribute news items from news sources to news
    destinations.
    """

    def __init__(self):
        self.sources = []
        self.destinations = []

    def add_source(self, source):
        self.sources.append(source)

    def addDestination(self, dest):
        self.destinations.append(dest)

    def distribute(self):
        """
        Retrieve all news items from all sources, and Distribute them to all
        destinations.
        """
        items = []
        for source in self.sources:
            items.extend(source.get_items())
        for dest in self.destinations:
            dest.receive_items(items)

class NewsItem:

    """
    A simple news item consisting of a title and body text.
    """
    def __init__(self, title, body):
        self.title = title
        self.body = body

class NNTPSource:
    """
    A news source that retrieves news items from an NNTP group.
    """
    def __init__(self, servername, group, howmany):
        self.servername = servername
        self.group = group
        self.howmany = howmany

    def get_items(self):
        server = NNTP(self.servername)
        resp, count, first, last, name = server.group(self.group)
        start = last - self.howmany + 1
        resp, overviews = server.over((start, last))
        for id, over in overviews:
            title = decode_header(over['subject'])
            resp, info = server.body(id)
            body = '\n'.join(line.decode('latin')
                             for line in info.lines) + '\n\n'
            yield NewsItem(title, body)
        server.quit()

class SimpleWebSource:
    """
    A news source that extracts news items from a web page using regular
    expressions.
    """
    def __init__(self, url, title_pattern, body_pattern, encoding='utf8'):
        self.url = url
        self.title_pattern = re.compile(title_pattern)
        self.body_pattern = re.compile(body_pattern)
        self.encoding = encoding

    def get_items(self):
        text = urlopen(self.url).read().decode(self.encoding)
        titles = self.title_pattern.findall(text)
        bodies = self.body_pattern.findall(text)
        for title, body in zip(titles, bodies):
            yield NewsItem(title, textwrap.fill(body) + '\n')

class PlainDestination:

    """
    A news destination that formats all its news items as plain text.
    """
    def receive_items(self, items):
        for item in items:
            print(item.title)
            print('-' * len(item.title))
            print(item.body)

class HTMLDestination:
    """
    A news destination that formats all its news items as HTML.
    """
    def __init__(self, filename):
        self.filename = filename

    def receive_items(self, items):

        out = open(self.filename, 'w')
        print("""
        <html>
          <head>
            <title>Today's News</title>
          </head>
          <body>
          <h1>Today's News</h1>
        """, file=out)

        print('<ul>', file=out)
        id = 0
        for item in items:
            id += 1
            print('  <li><a href="#{}">{}</a></li>'
                    .format(id, item.title), file=out)
        print('</ul>', file=out)

        id = 0
        for item in items:
            id += 1
            print('<h2><a name="{}">{}</a></h2>'
                    .format(id, item.title), file=out)
            print('<pre>{}</pre>'.format(item.body), file=out)

        print("""
          </body>
        </html>
        """, file=out)

def runDefaultSetup():
    """
    A default setup of sources and destination. Modify to taste.
    """
    agent = NewsAgent()

    # A SimpleWebSource that retrieves news from Reuters:
    reuters_url = 'http://www.reuters.com/news/world'
    reuters_title = r'<h2><a href="[^"]*"\s*>(.*?)</a>'
    reuters_body = r'</h2><p>(.*?)</p>'
    reuters = SimpleWebSource(reuters_url, reuters_title, reuters_body)

    agent.add_source(reuters)

    # An NNTPSource that retrieves news from comp.lang.python.announce:
    clpa_server = 'news.foo.bar' # Insert real server name
    clpa_server = 'news.ntnu.no'
    clpa_group = 'comp.lang.python.announce'
    clpa_howmany = 10
    clpa = NNTPSource(clpa_server, clpa_group, clpa_howmany)

    agent.add_source(clpa)

    # Add plain-text destination and an HTML destination:
    agent.addDestination(PlainDestination())
    agent.addDestination(HTMLDestination('news.html'))

    # Distribute the news items:
    agent.distribute()

if __name__ == '__main__': runDefaultSetup()

Listing 23-2.A More Flexible News-Gathering Agent (newsagent2.py

)

进一步探索

由于其可扩展的本质,这个项目需要进一步的探索。以下是一些想法:

  • 使用第十五章中讨论的屏幕抓取技术,创建一个更加雄心勃勃的WebSource
  • 创建一个解析 RSS 的RSSSource,这也在第十五章中简要讨论过。
  • 改进HTMLDestination的布局。
  • 创建一个页面监视器,如果某个给定的 web 页面在您上次检查后发生了变化,它将为您提供一条新闻。(只需在发生变化时下载一份副本,然后进行比较。看看比较文件的标准库模块filecmp。)
  • 创建新闻脚本的 CGI 版本(参见第十五章)。
  • 创建一个EmailDestination,它会向您发送一封包含新闻条目的电子邮件。(见标准库模块smtplib发送邮件。)
  • 添加命令行开关来决定您需要的新闻格式。(参见标准库模块argparse了解一些技术。)
  • 给目的地提供关于新闻来源的信息,以允许更好的布局。
  • 尝试对你的新闻条目进行分类(也许可以通过搜索关键词)。
  • 创建一个XMLDestination,它产生适用于项目 3 中站点构建者的 XML 文件(第二十二章)。瞧,你有一个新闻网站。

什么现在?

我们已经做了大量的文件创建和文件处理(包括下载所需的文件),尽管这对很多事情来说非常有用,但它不是非常具有交互性。在下一个项目中,我们将创建一个聊天服务器,您可以在这里与您的朋友在线聊天。您甚至可以扩展它来创建您自己的虚拟(文本)环境。

Footnotes 1

例如,你知道在 http://groups.google.com 的讨论组,如sci.math``rec.arts.sf.written其实是幕后的新闻组小组吗?

二十四、项目 5:虚拟茶会

在这个项目中,我们将做一些严肃的网络编程。我们将编写一个聊天服务器——一个程序,让几个人通过互联网连接起来,彼此实时聊天。在 Python 中有很多方法可以创建这样的野兽。一个简单而自然的方法可能是使用 Twisted 框架(在第十四章中讨论过),例如,LineReceiver类占据中心位置。在这一章中,我将坚持使用标准库的异步网络模块。

值得注意的是,在撰写本文时,Python 似乎正处于这方面的空白期。虽然asyncoreasynchat模块的文档带有一个注释,说明它们只是为了向后兼容而被包含,并且未来的开发应该使用asyncio模块,但是asyncio的文档说明它只是在临时的基础上被包含,并且将来可能会被删除。我会走更保守的路线,用asyncoreasynchat。如果你愿意,你可以尝试一些在第十四章中讨论的替代方法(比如分叉或者线程),或者甚至使用asyncio重写项目。

有什么问题?

我们准备写一个相对底层的服务器,用于在线聊天。虽然这种功能可以通过大量的社交媒体和消息服务获得,但编写自己的代码对于了解更多的网络编程是有用的。假设我们有以下需求:

  • 服务器应该能够接收来自不同用户的多个连接。
  • 它应该让用户并行操作。
  • 它应该能够解释命令,如saylogout
  • 服务器应该易于扩展。

需要特殊工具的两件事是网络连接和程序的异步特性。

有用的工具

这个项目中唯一需要的新工具是标准库中的asyncore模块及其相关的asynchat。我将描述这些是如何工作的基础。您可以在 Python 库参考中找到关于它们的更多详细信息。正如在第十四章中所讨论的,网络程序的基本组件是套接字。通过导入socket模块并使用那里的函数可以直接创建套接字。那么你需要asyncore做什么?

asyncore框架使您能够同时处理几个连接的用户。想象一个场景,你没有特殊的工具来处理这个问题。当您启动服务器时,它会等待用户连接。当一个用户被连接时,它开始从该用户读取数据,并通过套接字提供结果。但是如果另一个用户已经连接了,会发生什么呢?第二个要连接的用户必须等到第一个完成。在某些情况下,这样做很好,但是当你编写一个聊天服务器时,关键是可以连接不止一个用户——否则用户之间如何聊天?

asyncore框架基于一种底层机制(来自select模块的select函数,如第十四章所述),它允许服务器以渐进的方式为所有连接的用户提供服务。不是先从一个用户读取所有可用数据,然后再继续下一个用户,而是只读取一些数据。此外,服务器只从有数据要读取的套接字读取数据。如此循环往复,一次又一次。以类似的方式处理书写。你可以自己使用模块socketselect来实现,但是asyncoreasynchat为你提供了一个非常有用的框架来处理细节。(有关实现并行用户连接的替代方法,请参见第十四章中的“多重连接”一节。)

准备

你首先需要的是一台联网的电脑(比如互联网);否则,其他人将无法连接到您的聊天服务器。(从您自己的机器连接到聊天服务器是可能的,但是从长远来看,这可能不太有趣。)为了能够连接,用户必须知道您机器的地址(机器名,如foo.bar.baz.com或 IP 地址)。此外,用户必须知道您的服务器使用的端口号。您可以在您的程序中设置这一点;在本章的代码中,我使用了(相当随意的)端口号 5005。

Note

如第十四章所述,某些端口号受到限制,需要管理员权限。一般来说,大于 1023 的数字是可以的。

为了测试你的服务器,你需要一个客户端——交互用户端的程序。这类事情的一个简单程序是telnet(它基本上允许您连接到任何套接字服务器)。在 UNIX 中,您可能可以在命令行上使用这个程序。

$ telnet some.host.name 5005

前面的命令连接到端口 5005 上的机器some.host.name。要连接到运行telnet命令的同一台机器,只需使用机器名localhost。(你可能想通过-e开关提供一个转义字符,以确保你可以轻松退出telnet。详见telnet文件。)

在 Windows 中,您可以使用具有telnet功能的终端仿真器,例如 PuTTY(软件和更多信息可在 http://www.chiark.greenend.org.uk/~sgtatham/putty 获得)。然而,如果你正在安装新的软件,你还不如买一个专门为聊天定制的客户端程序。MUD(或 MUSH 或 MOO 或其他一些相关的缩写)客户端 1 非常适合这类事情。一个选项是 TinyFugue(软件和更多信息可在 http://tinyfugue.sf.net 获得)。它主要是为在 UNIX 中使用而设计的。(一些客户端也可用于 Windows 只要在网上搜索“泥浆客户”或类似的东西。)这一切都有点“老派”,但这只是魅力的一部分。

首次实施

让我们把事情分解一下。我们需要创建两个主要的类:一个代表聊天服务器,一个代表每个聊天会话(连接的用户)。

ChatServer 类

为了创建基本的ChatServer,你从asyncore中继承了dispatcher类。dispatcher基本上只是一个 socket 对象,但是有一些额外的事件处理特性,您马上就会用到。参见清单 24-1 中的一个基本的聊天服务器程序(它做的很少)。

from asyncore import dispatcher
import asyncore

class ChatServer(dispatcher): pass

s = ChatServer()
asyncore.loop()

Listing 24-1.A Minimal Server Program

如果你运行这个程序,什么也不会发生。为了让服务器做任何有趣的事情,您应该调用它的create_socket方法来创建一个套接字,调用它的bindlisten方法来将套接字绑定到一个特定的端口号,并告诉它监听传入的连接。(毕竟,这就是服务器的作用。)此外,您将覆盖handle_accept事件处理方法,以便在服务器接受客户端连接时实际执行一些操作。结果程序如清单 24-2 所示。

from asyncore import dispatcher
import socket, asyncore

class ChatServer(dispatcher):

    def handle_accept(self):
        conn, addr = self.accept()
        print('Connection attempt from', addr[0])

s = ChatServer()
s.create_socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(('', 5005))
s.listen(5)
asyncore.loop()

Listing 24-2.A Server That Accepts Connections

handle_accept方法调用self.accept,让客户端连接。这将返回一个连接(特定于该客户机的套接字)和一个地址(关于哪台机器正在连接的信息)。handle_accept方法没有对这个连接做任何有用的事情,只是打印出一个连接尝试。addr[0]是客户端的 IP 地址。

服务器初始化使用两个参数调用create_socket,这两个参数指定了您想要的套接字类型。您可以使用不同的类型,但这里显示的是您通常需要的类型。对bind方法的调用只是将服务器绑定到一个特定的地址(主机名和端口)。主机名为空(一个空字符串,本质上意味着 localhost,或者更专业地说,“这台机器上的所有接口”),端口号为 5005。对listen的调用告诉服务器监听连接;它还指定了五个连接的积压。对asyncore.loop的最后一次调用像以前一样启动服务器的监听循环。

这个服务器实际上是工作的。尝试运行它,然后用您的客户端连接到它。客户端应该立即断开连接,服务器应该打印出以下内容:

Connection attempt from 127.0.0.1

如果您不从与服务器相同的机器连接,IP 地址将会不同。要停止服务器,只需使用键盘中断:UNIX 中的 Ctrl+C 或 Windows 中的 Ctrl+Break。

使用键盘中断关闭服务器会导致堆栈跟踪。为了避免这种情况,您可以将loop包装在一个try / except语句中。通过一些其他的清理,基本服务器最终如清单 24-3 所示。

from asyncore import dispatcher
import socket, asyncore

PORT = 5005

class ChatServer(dispatcher):

    def __init__(self, port):
        dispatcher.__init__(self)
        self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
        self.set_reuse_addr()
        self.bind(('', port))
        self.listen(5)

    def handle_accept(self):
        conn, addr = self.accept()
        print('Connection attempt from', addr[0])

if __name__ == '__main__':
    s = ChatServer(PORT)
    try: asyncore.loop()
    except KeyboardInterrupt: pass

Listing 24-3.The Basic Server with Some Cleanups

添加的对set_reuse_addr的调用允许您重用同一个地址(特别是端口号),即使服务器没有正确关闭。(如果没有这个调用,您可能需要等待一段时间才能再次启动服务器,或者在服务器每次崩溃时更改端口号,因为您的程序可能无法正确地通知您的操作系统它已经完成了端口。)

聊天会话类

基本的ChatServer不是很有用。应该为每个连接创建一个新的dispatcher对象,而不是忽略连接尝试。但是,这些对象的行为与用作主服务器的对象不同。他们不会在端口上侦听传入的连接;他们已经连接到客户端。他们的主要任务是收集来自客户端的数据(文本)并对其做出响应。您可以通过子类化dispatcher并覆盖各种方法来自己实现这个功能,但是,幸运的是,有一个模块已经完成了大部分工作:asynchat

尽管名字如此,asynchat并不是专门为我们正在开发的流(连续)聊天应用而设计的。(名称中的chat指的是“聊天式”或命令响应协议。)关于async_chat类(可以在asynchat模块中找到)的好处是,它隐藏了最基本的套接字读写操作,这可能有点难以做到。让它工作所需要的就是覆盖两个方法:collect_incoming_datafound_terminator。前者在每次从套接字读取一点文本时调用,后者在读取终止符时调用。终止符(在这种情况下)只是一个换行符。(作为初始化的一部分,您需要通过调用set_terminator来告诉async_chat对象这一点。)

清单 24-4 显示了一个更新的程序,现在有了一个ChatSession类。

from asyncore import dispatcher
from asynchat import async_chat
import socket, asyncore

PORT = 5005

class ChatSession(async_chat):

    def __init__(self, sock):
        async_chat. init (self, sock)
        self.set_terminator("\r\n")
        self.data = []

    def collect_incoming_data(self, data):
        self.data.append(data)

    def found_terminator(self):
        line = ''.join(self.data)
        self.data = []
        # Do something with the line...
        print(line)

class ChatServer(dispatcher):

    def __init__(self, port): dispatcher. init (self)
        self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
        self.set_reuse_addr()
        self.bind(('', port))
        self.listen(5)
        self.sessions = []

    def handle_accept(self):
        conn, addr = self.accept()
        self.sessions.append(ChatSession(conn))

if __name__ == '__main__':
    s = ChatServer(PORT)
    try: asyncore.loop()
    except KeyboardInterrupt: print()

Listing 24-4.Server Program with ChatSession Class

在这个新版本中,有几样东西一文不值。

  • 使用set_terminator方法将行终止符设置为"\r\n",这是网络协议中常用的行终止符。
  • ChatSession对象将目前已经读取的数据保存为一个名为data的字符串列表。当读取更多数据时,collect_incoming_data被自动调用,它只是将数据添加到列表中。使用一列字符串,然后将它们连接起来(用join string 方法)是一种常见的习惯用法(从历史上看,这比递增地添加字符串更有效)。请随意使用带字符串的+=
  • 当找到一个终止符时,调用found_terminator方法。当前实现通过连接当前数据项创建一行,并将self.data重置为空列表。但是,因为您还没有对该行做任何有用的事情,所以它只是被打印出来。
  • ChatServer保存会话列表。
  • ChatServerhandle_accept方法现在创建一个新的ChatSession对象,并将其添加到会话列表中。

尝试运行服务器并同时连接两个(或更多)客户端。您在客户机中键入的每一行都应该打印在运行服务器的终端上。这意味着服务器现在能够同时处理几个连接。现在所缺少的是让客户看到其他人在说什么的能力!

把它放在一起

在原型被认为是一个功能齐全(虽然简单)的聊天服务器之前,还缺少一个主要的功能:用户所说的话(他们键入的每一行)应该被广播给其他人。该功能可以通过服务器中的一个简单的for循环来实现,该循环遍历会话列表并将该行写入每个会话。要将数据写入一个async_chat对象,您可以使用push方法。

这种广播行为还增加了另一个问题:当客户端断开连接时,您必须确保从列表中删除连接。您可以通过覆盖事件处理方法handle_close来做到这一点。第一个原型的最终版本可以在清单 24-5 中看到。

from asyncore import dispatcher
from asynchat import async_chat
import socket, asyncore

PORT = 5005
NAME = 'TestChat'

class ChatSession(async_chat):
    """
    A class that takes care of a connection between the server and a single user.
    """
    def __init__(self, server, sock):
        # Standard setup tasks:
        async_chat. init (self, sock)
        self.server = server
        self.set_terminator("\r\n")
        self.data = []
        # Greet the user:
        self.push('Welcome to %s\r\n' % self.server.name)

    def collect_incoming_data(self, data):
        self.data.append(data)

    def found_terminator(self):
        """
        If a terminator is found, that means that a full
        line has been read. Broadcast it to everyone.
        """
        line = ''.join(self.data)
        self.data = []
        self.server.broadcast(line)

    def handle_close(self):
        async_chat.handle_close(self)
        self.server.disconnect(self)

class ChatServer(dispatcher):
    """
    A class that receives connections and spawns individual
    sessions. It also handles broadcasts to these sessions.
    """
    def __init__(self, port, name):
        # Standard setup tasks dispatcher. init (self)
        self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
        self.set_reuse_addr()
        self.bind(('', port))
        self.listen(5)
        self.name = name
        self.sessions = []

    def disconnect(self, session):
        self.sessions.remove(session)

    def broadcast(self, line):
        for session in self.sessions:
            session.push(line + '\r\n')

    def handle_accept(self):
        conn, addr = self.accept()
        self.sessions.append(ChatSession(self, conn))

if __name__ == '__main__':
    s = ChatServer(PORT, NAME)
    try: asyncore.loop()
    except KeyboardInterrupt: print()

Listing 24-5.A Simple Chat Server

(simple_chat.py)

第二次实施

第一个原型可能是一个全功能的聊天服务器,但是它的功能非常有限。最明显的限制是你不能辨别谁在说什么。此外,它不解释原始规范要求的命令(如saylogout)。因此,您需要添加对身份(每个用户一个唯一的名称)和命令解释的支持,并且您必须使每个会话的行为取决于它所处的状态(刚刚连接、登录等等)——所有这些都以一种易于扩展的方式进行。

基本命令解释

我将向您展示如何在标准库中的cmd模块的Cmd类上建模命令解释。(不幸的是,您不能直接使用这个类,因为它只能与sys.stdinsys.stdout一起使用,并且您正在处理几个流。)你需要的是一个可以处理单行文本(由用户键入)的函数或方法。它应该将第一个单词(命令)分离出来,并基于它调用一个适当的方法。例如,这一行:

say Hello, world!

可能会导致以下调用:

do_say('Hello, world!')

可能将会话本身作为一个附加参数(这样do_say就会知道是谁在说话)。

下面是一个简单的实现,增加了一个方法来表示命令未知:

class CommandHandler:
    """
    Simple command handler similar to cmd.Cmd from the standard library.
    """

    def unknown(self, session, cmd):
        session.push('Unknown command: {}s\r\n'.format(cmd))

    def handle(self, session, line):
        if not line.strip(): return
        parts = line.split(' ', 1)
        cmd = parts[0]
        try: line = parts[1].strip()
        except IndexError: line = ''
        meth = getattr(self, 'do_' + cmd, None)
        try:
            meth(session, line)
        except TypeError:
            self.unknown(session, cmd)

在这个类中getattr的使用类似于第二十章中的标记项目。基本的命令处理已经完成,您需要定义一些实际的命令。哪些命令可用(以及它们做什么)应该取决于会话的当前状态。你如何代表那个州?

空间

每个状态都可以由一个自定义命令处理程序来表示。这很容易与聊天室(或 MUD 中的位置)的标准概念相结合。每个房间都是一个CommandHandler,有自己专门的命令。此外,它应该跟踪哪些用户(会话)当前在其中。以下是适用于所有房间的通用超类:

class EndSession(Exception): pass

class Room(CommandHandler):
    """
    A generic environment which may contain one or more users (sessions).
    It takes care of basic command handling and broadcasting.
    """

    def __init__(self, server):
        self.server = server
        self.sessions = []

    def add(self, session):
        self.sessions.append(session)

    def remove(self, session):
        self.sessions.remove(session)

    def broadcast(self, line):
        for session in self.sessions:
            session.push(line)

    def do_logout(self, session, line):
        raise EndSession

除了基本的addremove方法之外,一个broadcast方法简单地在房间中的所有用户(会话)上调用push。还定义了一个命令— logout(以do_logout方法的形式)。它会引发一个异常(EndSession),该异常会在更高级别的处理中处理(在found_terminator)。

登录和注销室

除了表示普通的聊天室(这个项目只包含一个这样的聊天室)之外,Room子类还可以表示其他状态,这确实是我们的初衷。例如,当一个用户连接到服务器时,他被放入一个专用的LoginRoom(其中没有其他用户)。当用户进入时,LoginRoom打印一条欢迎消息(在add方法中)。它还覆盖了unknown方法来告诉用户登录;它唯一响应的命令是login命令,该命令检查名称是否可接受(不是空字符串,也没有被其他用户使用)。

LogoutRoom就简单多了。它唯一的工作是从服务器上删除用户名(服务器上有一个名为users的字典,用于存储会话)。如果这个名字不存在(因为用户从未登录过),那么产生的KeyError将被忽略。

有关这两个类的源代码,请参见本章后面的清单 24-6 。

Note

即使服务器的users字典保存了对所有会话的引用,也不会从中检索到任何会话。users字典仅用于跟踪哪些名称在使用中。然而,我决定让每个用户名引用相应的会话,而不是使用任意的值(比如True)。尽管没有立即使用它,但它在程序的某个较新版本中可能是有用的(例如,如果一个用户想私下给另一个用户发送消息)。另一种方法是简单地保存一组或一个会话列表。

主聊天室

主聊天室也覆盖了addremove方法。在add中,它广播一条关于正在进入的用户的消息,并将该用户的名字添加到服务器中的users字典中。remove方法广播关于用户离开的消息。

除了这些方法,ChatRoom类还实现了三个命令。

  • say命令(由do_say实现)广播一行,前缀是说话的用户的名字。
  • look命令(由do_look执行)告诉用户哪些用户当前在房间里。
  • who命令(由do_who执行)告诉用户哪些用户当前已经登录。在这个简单的服务器中,lookwho是等效的,但是如果您将其扩展为包含多个房间,它们的功能将会不同。

有关源代码,请参见本章后面的清单 24-6 。

新服务器

我已经描述了大部分的功能。对ChatSessionChatServer的主要补充如下:

  • ChatSession有一个叫enter的方法,用来进入一个新房间。
  • ChatSession构造函数使用LoginRoom
  • handle_close方法使用LogoutRoom
  • ChatServer构造函数将字典users和名为main_roomChatRoom添加到它的属性中。

还要注意handle_accept不再将新的ChatSession添加到会话列表中,因为会话现在由房间管理。

Note

一般来说,如果你只是简单地实例化一个对象,像handle_accept中的ChatSession,而没有给它绑定一个名字或者把它添加到一个容器中,那么它就会丢失,可能会被垃圾回收(也就是说它会彻底消失)。因为所有的调度器都是由asyncore(而async_chatdispatcher的子类)处理(引用)的,所以这在这里不是问题。

聊天服务器的最终版本如清单 24-6 所示。为了方便起见,我在表 24-1 中列出了可用的命令。

表 24-1。

Commands Available in the Chat Server

| 命令 | 有售 | 描述 | | --- | --- | --- | | `login name` | 登录室 | 用于登录到服务器 | | `logout` | 所有房间 | 用于从服务器注销 | | `say statement` | 聊天室 | 曾经说过的话 | | `look` | 聊天室 | 用来找出谁在同一个房间 | | `who` | 聊天室 | 用于找出登录到服务器的用户 |
from asyncore import dispatcher
from asynchat import async_chat
import socket, asyncore

PORT = 5005
NAME = 'TestChat'

class EndSession(Exception): pass

class CommandHandler:

    """
    Simple command handler similar to cmd.Cmd from the standard library.
    """

    def unknown(self, session, cmd):
        'Respond to an unknown command'
        session.push('Unknown command: {}s\r\n'.format(cmd))

    def handle(self, session, line):
        'Handle a received line from a given session'
        if not line.strip(): return
        # Split off the command:
        parts = line.split(' ', 1)
        cmd = parts[0]
        try: line = parts[1].strip()
        except IndexError: line = ''
        # Try to find a handler:
        meth = getattr(self, 'do_' + cmd, None)
        try:
            # Assume it's callable:
            meth(session, line)
        except TypeError:
            # If it isn't, respond to the unknown command:
            self.unknown(session, cmd)

class Room(CommandHandler):
    """
    A generic environment that may contain one or more users (sessions).
    It takes care of basic command handling and broadcasting.
    """

    def __init__(self, server):
        self.server = server
        self.sessions = []

    def add(self, session):
        'A session (user) has entered the room'
        self.sessions.append(session)

    def remove(self, session):
        'A session (user) has left the room'
        self.sessions.remove(session)

    def broadcast(self, line):
        'Send a line to all sessions in the room'
        for session in self.sessions:
            session.push(line)

    def do_logout(self, session, line):
        'Respond to the logout command'
        raise EndSession

class LoginRoom(Room):

    """
    A room meant for a single person who has just connected.
    """

    def add(self, session):
        Room.add(self, session)
        # When a user enters, greet him/her:
        self.broadcast('Welcome to {}\r\n'.format(self.server.name))

    def unknown(self, session, cmd):
        # All unknown commands (anything except login or logout)
        # results in a prodding:
        session.push('Please log in\nUse "login <nick>"\r\n')

    def do_login(self, session, line):
        name = line.strip()
        # Make sure the user has entered a name:
        if not name:
            session.push('Please enter a name\r\n')
        # Make sure that the name isn't in use:
        elif name in self.server.users:
            session.push('The name "{}" is taken.\r\n'.format(name))
            session.push('Please try again.\r\n')
        else:
            # The name is OK, so it is stored in the session, and
            # the user is moved into the main room. session.name = name
            session.enter(self.server.main_room)

class ChatRoom(Room):
    """
    A room meant for multiple users who can chat with the others in the room.
    """

    def add(self, session):
        # Notify everyone that a new user has entered:
        self.broadcast(session.name + ' has entered the room.\r\n')
        self.server.users[session.name] = session
        super().add(session)

    def remove(self, session):
        Room.remove(self, session)
        # Notify everyone that a user has left:
        self.broadcast(session.name + ' has left the room.\r\n')

    def do_say(self, session, line):
        self.broadcast(session.name + ': ' + line + '\r\n')

    def do_look(self, session, line):
        'Handles the look command, used to see who is in a room'
        session.push('The following are in this room:\r\n')
        for other in self.sessions:
            session.push(other.name + '\r\n')

    def do_who(self, session, line):
        'Handles the who command, used to see who is logged in'
        session.push('The following are logged in:\r\n')
        for name in self.server.users:
            session.push(name + '\r\n')

class LogoutRoom(Room):

    """
    A simple room for a single user. Its sole purpose is to remove the
    user's name from the server.
    """

    def add(self, session):
        # When a session (user) enters the LogoutRoom it is deleted
        try: del self.server.users[session.name]
        except KeyError: pass

class ChatSession(async_chat):
    """
    A single session, which takes care of the communication with a single user.
    """

    def __init__(self, server, sock):
        super().__init__(sock)
        self.server = server
        self.set_terminator("\r\n")
        self.data = []
        self.name = None
        # All sessions begin in a separate LoginRoom:
        self.enter(LoginRoom(server))

    def enter(self, room):
        # Remove self from current room and add self to
        # next room...
        try: cur = self.room
        except AttributeError: pass
        else: cur.remove(self)
        self.room = room
        room.add(self)

    def collect_incoming_data(self, data):
        self.data.append(data)

    def found_terminator(self):
        line = ''.join(self.data)
        self.data = []
        try: self.room.handle(self, line)
        except EndSession: self.handle_close()

    def handle_close(self):
        async_chat.handle_close(self)
        self.enter(LogoutRoom(self.server))

class ChatServer(dispatcher):
    """
    A chat server with a single room.

    """

    def __init__(self, port, name):

        super().__init__()
        self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
        self.set_reuse_addr()
        self.bind(('', port))
        self.listen(5)
        self.name = name
        self.users = {}
        self.main_room = ChatRoom(self)

    def handle_accept(self):
        conn, addr = self.accept()
        ChatSession(self, conn)

if __name__ == '__main__':
    s = ChatServer(PORT, NAME)
    try: asyncore.loop()
    except KeyboardInterrupt: print()

Listing 24-6.A Slightly More Complicated Chat Server (chatserver.py

)

聊天会话的示例如图 24-1 所示。该示例中的服务器是用以下命令启动的:

A326949_3_En_24_Fig1_HTML.jpg

图 24-1。

A sample chat session

python chatserver.py

用户dilbert使用以下命令连接到服务器:

telnet localhost 5005

进一步探索

您可以做很多事情来扩展和增强本章中介绍的基本服务器。

  • 你可以创建一个有多个聊天室的版本,你可以扩展命令集,让它以你想要的任何方式运行。
  • 你可能想让程序只识别某些命令(如loginlogout),而将所有其他输入的文本视为普通聊天,从而避免使用say命令。
  • 您可以在所有命令前添加一个特殊字符(例如,一个斜杠,给出像/login/logout这样的命令),并将所有不以指定字符开头的内容视为一般聊天。
  • 您可能想创建自己的 GUI 客户端,但这比看起来要复杂一些。GUI 工具包有一个事件循环,与服务器的通信可能需要另一个事件循环。为了让它们合作,你可能需要使用线程。(关于如何在不同线程不直接访问彼此数据的简单情况下实现这一点的示例,请参见第二十八章。)

什么现在?

现在你有了自己的聊天服务器。在下一个项目中,我们将处理不同类型的网络编程:CGI,许多 web 应用的底层机制(如第十五章所讨论的)。这项技术在下一个项目中的具体应用是远程编辑,它使几个用户能够协作开发同一个文档。你甚至可以用它来远程编辑你自己的网页。

Footnotes 1

MUD 代表多用户地下城/域/维度。MUSH 代表多用户共享幻觉。MOO 的意思是 MUD,面向对象。

二十五、项目 6:CGI 远程编辑

本章的项目使用 CGI,在第十五章会有更详细的讨论。具体应用是远程编辑——通过网络在另一台机器上编辑文档。这在协作系统(群件)中很有用,例如,几个人可能在处理同一个文档。它也可以用于更新您的网页。

有什么问题?

您在一台机器上存储了一个文档,并希望能够通过 Web 从另一台机器上编辑它。这使您能够拥有由几个协作作者编辑的共享文档。您不需要使用 FTP 或类似的文件传输技术,也不需要担心同步多个副本。要编辑这个文件,你只需要一个网络浏览器。

Note

这种远程编辑是维基的核心机制之一(例如, http://en.wikipedia.org/wiki/Wiki )。

具体而言,系统应满足以下要求:

  • 它应该能够将文档显示为正常的网页。
  • 它应该能够在 web 表单的文本区域显示文档。
  • 您应该能够保存表单中的文本。
  • 程序应该用密码保护文档。
  • 该程序应该易于扩展,以支持编辑多个文档。正如您将看到的,使用标准的 Python 库模块cgi和一些普通的 Python 编码,所有这些都很容易做到。然而,该应用中使用的技术可以用于创建所有 Python 程序的 web 接口,因此非常有用。

有用的工具

正如在第十五章中所讨论的,编写 CGI 程序的主要工具是cgi模块,以及用于调试的cgitb模块。更多信息参见第十五章。

准备

在第十五章的“使用 CGI 的动态网页”一节中详细描述了通过网络访问 CGI 脚本的步骤只要按照这些步骤去做,你应该会没事的。

首次实施

第一个实现基于清单 15-7 所示的问候脚本的基本结构(第十五章)。第一个原型所需要的只是一些文件处理。

为了使脚本有用,它必须在调用之间存储编辑过的文本。此外,表单应该比问候脚本(第十五章中的清单 15-7 中的simple3.cgi)中的要大一点,文本字段应该变成一个文本区域。你也应该使用POST CGI 方法,而不是默认的GET方法。(如果您正在提交大量数据,通常应该使用POST。)

程序的一般逻辑如下:

  1. 以数据文件的当前值为默认值获取 CGI 参数text
  2. 将文本保存到数据文件中。
  3. 打印出表格,文本在textarea中。

为了允许脚本写入您的数据文件,您必须首先创建这样一个文件(例如,simple_edit.dat)。它可以是空的,也可以包含初始文档(一个纯文本文件,可能包含某种形式的标记,如 XML 或 HTML)。然后你必须设置权限,使它是通用可写的,如第十五章所述。结果代码如清单 25-1 所示。

#!/usr/bin/env python

import cgi
form = cgi.FieldStorage()

text = form.getvalue('text', open('simple_edit.dat').read())
f = open('simple_edit.dat', 'w')
f.write(text)
f.close()

print("""Content-type: text/html

<html>
  <head>
     <title>A Simple Editor</title>
  </head>

  <body>
     <form action='simple_edit.cgi' method='POST'>
     <textarea rows='10' cols='20' name='text'>{}</textarea><br />
     <input type='submit' />
     </form>
  </body>
</html>
""".format(text))

Listing 25-1.A Simple Web Editor

(simple_edit.cgi)

当通过 web 服务器访问时,CGI 脚本检查名为text的输入值。如果提交了这样的值,文本将被写入文件simple_edit.dat。默认值是文件的当前内容。最后显示一个网页(包含编辑和提交文本的字段),如图 25-1 所示。

A326949_3_En_25_Fig1_HTML.jpg

图 25-1。

The simple_edit.cgi script in action

第二次实施

现在你有了第一辆上路的原型车,还缺什么?系统应该能够编辑多个文件,并且应该使用密码保护。(因为文档可以通过直接在浏览器中打开来查看,所以您不会太注意系统的查看部分。)

与第一个原型的主要区别在于,你将它分成两个独立的 CGI 脚本(一个用于你的系统应该能够执行的每个“动作”)。新原型的文件如下:

  • 一个普通的网页,带有一个可以输入文件名的表单。它还有一个打开按钮,可以触发edit.cgi
  • edit.cgi:在文本区域显示给定文件的脚本。它有一个用于输入密码的文本字段和一个触发save.cgi的保存按钮。
  • save.cgi:将接收到的文本保存到给定文件并显示简单消息(例如,“文件已保存”)的脚本。这个脚本还应该负责密码检查。

让我们逐一解决这些问题。

创建文件名表单

index.html是一个 HTML 文件,包含用于输入文件名的表单。

<html>
  <head>
    <title>File Editor</title>
  </head>
  <body>
    <form action='edit.cgi' method='POST'>
      <b>File name:</b><br />
      <input type='text' name='filename' />
      <input type='submit' value='Open' />
  </body>
</html>

注意文本字段是如何命名的filename。这确保了它的内容将作为 CGI 参数filename提供给edit.cgi脚本(这是form标签的action属性)。如果您在浏览器中打开该文件,在文本字段中输入文件名,然后单击 open,将会运行edit.cgi脚本。

编写编辑器脚本

edit.cgi脚本显示的页面应该包括一个包含您正在编辑的文件的当前文本的文本区域和一个用于输入密码的文本字段。唯一需要的输入是文件名,脚本从index.html中的表单接收文件名。但是,请注意,可以直接打开edit.cgi脚本,而无需在index.html中提交表单。在这种情况下,你不能保证cgi.FieldStoragefilename字段被设置。所以你需要添加一个检查来确保有一个文件名。如果有,文件将从包含可编辑文件的目录中打开。我们把目录叫做data。(当然,您必须创建这个目录。)

Caution

请注意,通过提供包含路径元素的文件名,例如..(两点),就有可能访问这个目录之外的文件。为了确保被访问的文件在给定的目录中,您应该执行一些额外的检查,例如列出目录中的所有文件(例如使用glob模块)并检查所提供的文件名是否是候选文件之一(确保您使用完整的绝对路径名)。另一种方法见第二十七章中的“验证文件名”一节。

然后,代码变成类似清单 25-2 的东西。

#!/usr/bin/env python

print('Content-type: text/html\n')

from os.path import join, abspath
import cgi, sys

BASE_DIR = abspath('data')

form = cgi.FieldStorage()
filename = form.getvalue('filename')
if not filename:
    print('Please enter a file name')
    sys.exit()
text = open(join(BASE_DIR, filename)).read()

print("""
<html>
    <head>
        <title>Editing...</title>
    </head>
    <body>
        <form action='save.cgi' method='POST'>
            <b>File:</b> {}<br />
            <input type='hidden' value='{}' name='filename' />
            <b>Password:</b><br />
            <input name='password' type='password' /><br />
            <b>Text:</b><br />
            <textarea name='text' cols='40' rows='20'>{}</textarea><br />
            <input type='submit' value='Save' />
        </form>
    </body>
</html>
""".format(filename, filename, text))

Listing 25-2.The Editor Script (edit.cgi)

注意,abspath函数已经被用来获取data目录的绝对路径。还要注意,文件名已经存储在一个hidden表单元素中,因此它将被传递给下一个脚本(save.cgi),而不会给用户任何修改的机会。(当然,你无法保证这一点,因为用户可能会编写自己的表单,将它们放在另一台机器上,并让这些表单用自定义值调用你的 CGI 脚本。)

对于密码处理,示例代码使用类型为password的输入元素,而不是text,这意味着输入的字符将显示为星号。

Note

这个脚本基于一个假设,即给定的文件名指的是一个现有的文件。请随意扩展它,以便它也可以处理其他情况。

编写保存脚本

执行保存的脚本是这个简单系统的最后一个组件。它接收文件名、密码和一些文本。它检查密码是否正确,如果正确,程序将文本存储在具有给定文件名的文件中。(该文件应正确设置其权限;参见第十五章中关于设置文件权限的讨论。)

只是为了好玩,我们将在密码处理中使用sha模块。安全哈希算法(SHA)是一种从输入字符串中提取看似随机的实际上无意义的数据字符串(摘要)的方法。该算法背后的思想是,构造一个具有给定摘要的字符串几乎是不可能的,所以如果你知道(例如)一个密码的摘要,你就没有(容易的)方法来重建密码或发明一个将再现摘要的密码。这意味着您可以安全地将提供的密码摘要与存储的(正确密码的)摘要进行比较,而不是比较密码本身。通过使用这种方法,您不需要将密码本身存储在源代码中,阅读代码的人也不会知道密码实际上是什么。

Caution

我说过,这个“安全”功能主要是为了好玩。除非您使用 SSL 或类似技术的安全连接(这超出了本项目的范围),否则仍然有可能获得通过网络提交的密码。此外,这里使用的 SHA1 算法不再被认为是特别安全的。

下面是一个如何使用sha的例子:

>>> from hashlib import sha1
>>> sha1(b'foobar').hexdigest()
'8843d7f92416211de9ebb963ff4ce28125932878'
>>> sha1(b'foobaz').hexdigest()
'21eb6533733a5e4763acacd1d45a60c2e0e404e1'

正如你所看到的,密码的一个小变化会给你一个完全不同的摘要。你可以在清单 25-3 中看到save.cgi的代码。

#!/usr/bin/env python

print('Content-type: text/html\n')

from os.path import join, abspath
from hashlib import sha1
import cgi, sys

BASE_DIR = abspath('data')

form = cgi.FieldStorage()

text = form.getvalue('text')
filename = form.getvalue('filename')
password = form.getvalue('password')

if not (filename and text and password):
     print('Invalid parameters.')
     sys.exit()

if sha1(password.encode()).hexdigest() != '8843d7f92416211de9ebb963ff4ce28125932878':
     print('Invalid password')
     sys.exit()

f = open(join(BASE_DIR,filename), 'w')
f.write(text)
f.close()

print('The file has been saved.')

Listing 25-3.The Saving Script (save.cgi)

运行编辑器

按照以下步骤使用编辑器:

  1. Open the page index.html in a web browser. Be sure to open it through a web server (by using a URL of the form http://www.someserver.com/index.html ) and not as a local file. The result is shown in Figure 25-2.

    A326949_3_En_25_Fig2_HTML.jpg

    图 25-2。

    The opening page of the CGI editor

  2. Enter a file name of a file that your CGI editor is permitted to modify and then click Open. Your browser should then contain the output of the edit.cgi script, as shown in Figure 25-3.

    A326949_3_En_25_Fig3_HTML.jpg

    图 25-3。

    The editing page of the CGI editor

  3. 根据个人喜好编辑文件,输入密码(您自己设置的密码,或者示例中使用的密码foobar),然后单击 Save。然后,您的浏览器应该包含save.cgi脚本的输出,这只是一条消息“文件已保存”

  4. 如果要验证文件是否已被修改,请重复打开文件的过程(步骤 1 和 2)。

进一步探索

使用本项目中展示的技术,您可以开发各种 web 系统。对现有系统的一些可能的补充如下:

  • 添加版本控制。保存已编辑文件的旧副本,以便您可以“撤销”您的更改。
  • 添加对用户名的支持,这样您就知道谁更改了什么。
  • 添加文件锁定(例如,使用fcntl模块),这样两个用户就不能同时编辑文件。
  • 添加一个自动向文件添加标记的view.cgi脚本(就像第二十章中的那个)。
  • 通过更彻底地检查脚本的输入并添加更多用户友好的错误消息,使脚本更加健壮。
  • 避免打印类似“文件已保存”的确认消息您可以添加一些更有用的输出,或者将用户重定向到另一个页面/脚本。重定向可以用Location头来完成,它的工作方式类似于Content-type。只需在输出的头部分添加Location:,后跟一个空格和一个 URL(在第一个空行之前)。

除了扩展这个 CGI 系统的功能之外,你可能想要为 Python 测试一些更复杂的 web 环境(如第十五章所讨论的)。

什么现在?

现在你已经尝试过编写 CGI 脚本了。在下一个项目中,您将使用 SQL 数据库进行存储。有了这种强大的组合,您将实现一个全功能的基于 web 的公告板。

二十六、项目 7:你自己的公告板

许多种类的软件使你能够在互联网上与其他人交流。你已经看到了一些(例如,第二十三章的新闻组组和第二十四章的聊天服务器)。在本章中,您将实现另一个这样的系统:一个基于 web 的论坛。虽然功能与复杂的社交媒体平台相去甚远,但它确实实现了评论系统所需的基本功能。

有什么问题?

在本项目中,您将创建一个通过 Web 发布和回复消息的简单系统。作为一个论坛,这本身就很有用。本章中开发的系统非常简单,但是基本的功能是有的,它应该能够处理大量的帖子。

然而,本章涵盖的材料除了开发独立的论坛之外还有其他用途。例如,它可以用来实现一个更通用的协作系统,或者一个问题跟踪系统,一个具有评论功能的博客,或者一些完全不同的东西。CGI(或类似的技术)和一个可靠的数据库(在这里是一个 SQL 数据库)的结合是非常强大和通用的。

Tip

尽管编写自己的代码既有趣又有教育意义,但在许多情况下,搜索现有的解决方案更划算。在论坛之类的情况下,您可能会发现相当多的开发良好的系统是免费提供的。此外,大多数 web 应用框架(在第十五章中讨论)都内置了对这种功能的支持。

具体来说,最终系统应满足以下要求:

  • 它应该显示所有当前消息的主题。
  • 它应该支持消息线程(在他们回复的消息下显示缩进的回复)。
  • 您应该能够查看现有的消息。
  • 您应该能够回复现有的消息。

除了这些功能需求之外,如果系统相当稳定,能够处理大量消息,并且避免两个用户同时写入同一个文件这样的问题,那就更好了。通过使用某种类型的数据库服务器,而不是自己编写文件处理代码,可以实现所需的健壮性。

有用的工具

除了第十五章中的 CGI 内容,你还需要一个 SQL 数据库,如第十三章中所讨论的。您可以使用独立的数据库 SQLite(在该章中使用),也可以使用其他系统,例如以下两个优秀的免费数据库中的任何一个:

在这一章中,我使用 PostgreSQL 作为例子,但是代码只需稍加编辑就可以用于大多数 SQL 数据库(包括 MySQL 或 SQLite)。

在继续之前,您应该确保能够访问 SQL 数据库服务器(或独立的 SQL 数据库,如 SQLite ),并查看其文档以了解如何管理它。

除了数据库服务器本身,您还需要一个能够与服务器交互的 Python 模块(并对您隐藏细节)。大多数这样的模块支持 Python DB API,这将在第十三章中详细讨论。在这一章中,我使用了psycopg ( http://initd.org ),PostgreSQL 的一个健壮前端。如果你正在使用 MySQL,那么MySQLdb模块( http://sourceforge.net/projects/mysql-python )是一个不错的选择。

安装完数据库模块后,您应该能够导入它(例如,用import psycopgimport MySQLdb)而不会引发任何异常。

准备

在您的程序可以开始使用您的数据库之前,您必须实际创建数据库。这是使用 SQL 完成的(参见第十三章中的一些指针)。

数据库结构与问题有着密切的联系,一旦创建了数据库并用数据(消息)填充了数据库,就很难改变它。让我们保持简单。

您将只有一个表,其中每条消息占一行。每条消息都有一个唯一的 ID(一个整数)、一个主题、一个发件人(或发帖人)和一些文本(正文)。

此外,因为您希望能够分层显示消息(线程),所以每条消息都应该存储一个对它所回复的消息的引用。产生的CREATE TABLE SQL 命令如清单 26-1 所示。

CREATE TABLE messages (
    id          SERIAL PRIMARY KEY,
    subject     TEXT NOT NULL,
    sender      TEXT NOT NULL,
    reply_to    INTEGER REFERENCES messages,
    text        TEXT NOT NULL
);

Listing 26-1.Creating the Database in PostgreSQL

注意,这个命令使用了一些 PostgreSQL 特有的特性(SERIAL),这确保了每条消息自动接收一个惟一的 ID;TEXT数据类型;以及REFERENCES,它确保reply_to包含有效的消息 id)。清单 26-2 显示了一个更加 MySQL 友好的版本。

CREATE TABLE messages (
    id          INT NOT NULL AUTO_INCREMENT,
    subject     VARCHAR(100) NOT NULL,
    sender      VARCHAR(15) NOT NULL,
    reply_to    INT,
    text        MEDIUMTEXT NOT NULL, PRIMARY KEY(id)
);
Listing 26-2.Creating the Database in MySQL

最后,对于那些使用 SQLite 的人来说,清单 26-3 中有一个模式。

create table messages (
    id          integer primary key autoincrement,
    subject     text not null,
    sender      text not null,
    reply_to    int,
    text        text not null
);
Listing 26-3.Creating the Database in SQLite

我将这些代码片段保持简单(SQL 专家肯定会找到改进它们的方法),因为这一章的重点毕竟是 Python 代码。SQL 语句创建一个包含以下五个字段(列)的新表:

  • id:用于识别单个消息。数据库管理器会自动为每条消息接收一个惟一的 ID,因此您不必担心如何从 Python 代码中分配这些 ID。
  • subject:包含消息主题的字符串。
  • sender:包含发件人姓名或电子邮件地址等内容的字符串。
  • reply_to:如果该报文是对另一报文的回复,该字段包含另一报文的id。(否则,该字段将不包含任何内容。)
  • text:包含消息正文的字符串。

当您创建了这个数据库并对其设置了权限,从而允许您的 web 服务器读取其内容并插入新行时,您就可以开始编写 CGI 代码了。

首次实施

在这个项目中,第一个原型非常有限。这是一个使用数据库功能的脚本,因此您可以感受一下它是如何工作的。一旦确定了这一点,编写其他必要的脚本就不会很难了。从很多方面来说,这只是对第十三章所涵盖内容的一个简短提醒。

代码的 CGI 部分与第二十五章非常相似。如果你还没有读过那一章,你可能想看一看。你还应该确保阅读第十五章中的“CGI 安全风险”一节。

Caution

在本章的 CGI 脚本中,我已经导入并启用了cgitb模块。这对于发现代码中的缺陷非常有用,但是您可能应该在部署软件之前删除对cgitb.enable的调用——您可能不希望普通用户面临完整的cgitb回溯。

首先,您需要知道 Python DB API 是如何工作的。如果你还没有读过第十三章,你现在至少应该浏览一下。如果您想直接点击,这里是核心功能(用您的数据库模块的名称替换db—例如,psycopgMySQLdb):

  • conn = db.connect('user=foo password=bar dbname=baz'):以用户foo的身份用密码bar连接到名为baz的数据库,并将返回的连接对象分配给conn。(注意connect的参数是一个字符串。)

Caution

在这个项目中,我假设您有一台运行数据库和 web 服务器的专用机器。应该只允许给定用户(foo)从该机器进行连接,以避免不必要的访问。因此没有必要使用密码,但是您的数据库可能需要您设置一个密码。如果你想公开这个论坛,你应该确保你学习了更多关于适当的安全措施,因为这个示例项目是不安全的!

  • curs = conn.cursor():从连接对象中获取光标对象。游标用于实际执行 SQL 语句并获取结果。
  • conn.commit():提交自上次提交以来由 SQL 语句引起的更改。
  • conn.close():关闭连接。
  • curs.execute(sql_string):执行一条 SQL 语句。
  • curs.fetchone():获取一个结果行作为一个序列——例如,一个元组。
  • curs.dictfetchone():获取一个结果行作为字典。(这不是标准的一部分,因此并非在所有模块中都可用。)
  • curs.fetchall():获取所有结果行,作为一个序列序列—例如,一个元组列表。
  • curs.dictfetchall():获取所有结果行作为字典序列(例如,一个列表)。(这不是标准的一部分,因此并非在所有模块中都可用。)

下面是一个简单的测试(假设psycopg)—检索数据库中的所有消息(该数据库目前为空,因此您不会得到任何消息):

>>> import psycopg2
>>> conn = psycopg2.connect('user=foo password=bar dbname=baz')
>>> curs = conn.cursor()
>>> curs.execute('SELECT * FROM messages')
>>> curs.fetchall()
[]

因为您还没有实现 web 界面,所以如果您想测试数据库,您必须手动输入消息。你可以通过一个管理工具(比如 MySQL 的mysql或者 PostgreSQL 的psql)来实现,或者你可以在数据库模块中使用 Python 解释器。

下面是一段有用的代码,您可以将其用于测试目的:

#!/usr/bin/env python
# addmessage.py
import psycopg2
conn = psycopg2.connect('user=foo password=bar dbname=baz)
curs = conn.cursor()

reply_to = input('Reply to: ')
subject = input('Subject: ')
sender = input('Sender: ')
text = input('Text: ')

if reply_to:
    query = """
    INSERT INTO messages(reply_to, sender, subject, text)
    VALUES({}, '{}', '{}', '{}')""".format(reply_to, sender, subject, text)
else:
    query = """
     INSERT INTO messages(sender, subject, text)
     VALUES('{}', '{}', '{}')""".format(sender, subject, text)

curs.execute(query)
conn.commit()

注意这段代码有点粗糙。它不会为您跟踪 id(您必须确保您作为reply_to输入的内容是有效的 ID,如果有的话),并且它不能正确处理包含单引号的文本(这可能会有问题,因为单引号在 SQL 中被用作字符串分隔符)。当然,这些问题将在最终系统中处理。

尝试添加一些消息,并在交互式 Python 提示符下检查数据库。如果一切正常,那么是时候编写一个访问数据库的 CGI 脚本了。

既然你已经弄清楚了处理数据库的代码,并且可以从第二十五章中抓取一些现成的 CGI 代码,那么编写一个查看消息主题的脚本(论坛“主页”的简单版本)应该不会太难。您必须进行标准的 CGI 设置(在本例中,主要是打印Content-type字符串),进行标准的数据库设置(获得一个连接和一个游标),执行一个简单的 SQL select命令来获得所有的消息,然后用curs.fetchallcurs.dictfetchall检索结果行。

清单 26-4 展示了一个完成这些事情的脚本。清单中唯一真正新的东西是格式化代码,它用于获得线程化的外观,回复显示在它们所回复的消息的下方和右侧。

它基本上是这样工作的:

  1. 对于每条消息,获取reply_to字段。如果是None(不是回复),将该消息添加到顶级消息列表中。否则,将该消息附加到保存在children[parent_id]的儿童列表中。
  2. 对于每个顶级消息,调用formatformat函数打印消息的主题。同样,如果消息有孩子,它打开一个blockquote元素(HTML),为每个孩子调用format(递归),并结束blockquote元素。

如果你在你的网络浏览器中打开这个脚本(参见第十五章关于如何运行 CGI 脚本的信息),你应该会看到你添加的所有消息(或者它们的主题)的线程视图。

要了解公告板的样子,请参见本章后面的图 26-1 。

A326949_3_En_26_Fig1_HTML.jpg

图 26-1。

The main page Note

如果你正在使用 SQLite,你不能使用dictfetchall,如清单 26-4 所示。行rows = curs.dictfetchall()可以替换为以下代码片段:

names = [d[0] for d in curs.description]
rows = [dict(zip(names, row)) for row in curs.fetchall()]

#!/usr/bin/python

print('Content-type: text/html\n')

import cgitb; cgitb.enable()

import psycopg2
conn = psycopg2.connect('user=foo password=bar dbname=baz')
curs = conn.cursor()

print("""
<html>
  <head>
    <title>The FooBar Bulletin Board</title>
  </head>
  <body>
    <h1>The FooBar Bulletin Board</h1>
    """)

curs.execute('SELECT * FROM messages')
rows = curs.dictfetchall()

toplevel = []
children = {}

for row in rows:
    parent_id = row['reply_to']
    if parent_id is None:
        toplevel.append(row)
    else:
        children.setdefault(parent_id, []).append(row)
    def format(row):
        print(row['subject'])
        try: kids = children[row['id']]
        except KeyError: pass
        else:
            print('<blockquote>')
            for kid in kids:
                format(kid)
            print('</blockquote>')

    print('<p>')

    for row in toplevel:
        format(row)

    print("""
        </p>
    </body>
    </html>
    """)

Listing 26-4.The Main Bulletin Board (simple_main.cgi

)

Note

如果由于某种原因,你不能让程序工作,那可能是你没有正确设置你的数据库。请查阅数据库文档,了解让给定用户连接和修改数据库需要做些什么。例如,您可能需要明确列出连接机器的 IP 地址。

第二次实施

第一个实现非常有限,因为它甚至不允许用户发布消息。在这一节中,我们将扩展第一个原型中的简单系统,它包含最终版本的基本结构。将添加一些措施来检查所提供的参数(例如检查reply_to是否真的是一个数字,以及是否真的提供了所需的参数),但是您应该注意,使这样的系统健壮且用户友好是一项艰巨的任务。如果你打算使用这个系统(或者,我希望,是你自己的一个改进版本),你应该准备在这些问题上做一些工作。

但是在你考虑提高稳定性之前,你需要一些有用的东西,对吗?那么,你从哪里开始呢?你如何构建系统?

构造 web 程序的一个简单方法(使用 CGI 等技术)是让用户执行的每个动作都有一个脚本。在这个系统中,这意味着以下脚本:

  • main.cgi:显示所有消息(线索)的主题,并带有文章本身的链接。
  • 显示一篇文章,并包含一个可以让你回复的链接。
  • edit.cgi:以可编辑的形式显示一篇文章(带有文本字段和文本区域,就像第二十五章一样)。它的提交按钮链接到保存脚本。
  • save.cgi:接收关于文章的信息(从edit.cgi),并通过在数据库表中插入新行来保存它。

让我们分别处理这些。

编写主脚本

main.cgi脚本与第一个原型中的simple_main.cgi脚本非常相似。主要区别是增加了链接。每个主题将是一个给定消息的链接(到view.cgi),在页面底部,您将添加一个允许用户发布新消息的链接(到edit.cgi的链接)。

看看清单 26-5 中的代码。包含每篇文章链接的行(format函数的一部分)如下所示:

print('<p><a href="view.cgi?id={id}i">{subject}</a></p>'.format(row))

基本上,它创建了一个到view.cgi?id=someid的链接,其中someid是给定行的id。这个语法(问号和key=val)只是向 CGI 脚本传递参数的一种方式。这意味着如果用户点击这个链接,他们将被带到正确设置了id参数的view.cgi。“发布消息”链接只是一个到edit.cgi的链接。

#!/usr/bin/python

print('Content-type: text/html\n')

import cgitb; cgitb.enable()

import psycopg2
conn = psycopg2.connect('user=foo password=bar dbname=baz')
curs = conn.cursor()

print("""
<html>
  <head>
     <title>The FooBar Bulletin Board</title>
  </head>
     <body>
       <h1>The FooBar Bulletin Board</h1>
       """)

curs.execute('SELECT * FROM messages')
rows = curs.dictfetchall()

toplevel = []
children = {}

for row in rows:
    parent_id = row['reply_to']
    if parent_id is None:
        toplevel.append(row)
    else:
        children.setdefault(parent_id, []).append(row)

def format(row):
    print('<p><a href="view.cgi?id={id}i">{subject}</a></p>'.format(row))
    try: kids = children[row['id']]
    except KeyError: pass
    else:
        print('<blockquote>')
        for kid in kids:
            format(kid)
        print('</blockquote>')
    print('<p>')

for row in toplevel:

    format(row)

print("""
     </p>
     <hr />
     <p><a href="edit.cgi">Post message</a></p>
  </body>
</html>
""")

Listing 26-5.The Main Bulletin Board (main.cgi)

所以,我们来看看view.cgi是如何处理id参数的。

编写视图脚本

view.cgi脚本使用提供的 CGI 参数id从数据库中检索一条消息。然后,它用结果值格式化一个简单的 HTML 页面。这个页面还包含一个返回到主页(main.cgi)的链接,更有趣的是,还包含一个到edit.cgi的链接,但是这次将reply_to参数设置为id,以确保新消息是对当前消息的回复。view.cgi的代码见清单 26-6 。

#!/usr/bin/python

print('Content-type: text/html\n')

import cgitb; cgitb.enable()

import psycopg2
conn = psycopg2.connect('user=foo password=bar dbname=baz')
curs = conn.cursor()

import cgi, sys
form = cgi.FieldStorage()
id = form.getvalue('id')

print("""
<html>
  <head>
    <title>View Message</title>
  </head>
  <body>
    <h1>View Message</h1>
    """)

try: id = int(id)
except:
     print('Invalid message ID')
     sys.exit()

curs.execute('SELECT * FROM messages WHERE id = %s', (format(id),))
rows = curs.dictfetchall()

if not rows:
     print('Unknown message ID')

     sys.exit()

row = rows[0]

print("""
     <p><b>Subject:</b> {subject}<br />
     <b>Sender:</b> {sender}<br />
     <pre>{text}</pre>
     </p>
     <hr />
     <a href='main.cgi'>Back to the main page</a>
     | <a href="edit.cgi?reply_to={id}">Reply</a>
  </body>
</html>
""".format(row))

Listing 26-6.The Message Viewer (view.cgi)

使用 SQL 包本身的拼接机制避免了我们前面的单引号问题——并使代码更安全。

Caution

您应该避免将不受信任的文本直接插入到要用作 SQL 查询的字符串中,因为这样的代码容易受到所谓的 SQL 注入攻击。相反,使用 Python DB API 占位符机制,并为curs.execute提供一个额外的参数元组。更多信息请参见,例如, http://bobby-tables.com

编写编辑脚本

edit.cgi脚本实际上执行双重功能:它用于编辑新消息,也用于编辑回复。区别并不是很大:如果在 CGI 请求中提供了一个reply_to,它会保存在编辑表单中的一个隐藏输入中。隐藏输入用于在 web 表单中临时存储信息。它们不会像文本区域等那样显示给用户,但是它们的值仍然会传递给 CGI 脚本,也就是表单的动作。

此外,默认情况下主题被设置为"Re: parentsubject"(除非主题已经以"Re:"开头——您不想继续添加它们)。下面是处理这些细节的代码片段:

subject = ''
if reply_to is not None:
    print('<input type="hidden" name="reply_to" value="{}"/>'.format(reply_to))
    curs.execute('SELECT subject FROM messages WHERE id = %s', (reply_to,))
    subject = curs.fetchone()[0]
    if not subject.startswith('Re: '):
        subject = 'Re: ' + subject

这样,生成表单的脚本可以将信息传递给最终将处理相同表单的脚本。

清单 26-7 显示了edit.cgi脚本的源代码。

#!/usr/bin/python

print('Content-type: text/html\n')

import cgitb; cgitb.enable()

import psycopg2
conn = psycopg2.connect('user=foo password=bar dbname=baz')
curs = conn.cursor()

import cgi, sys
form = cgi.FieldStorage()
reply_to = form.getvalue('reply_to')

print("""
<html>
  <head>
    <title>Compose Message</title>
  </head>
  <body>
    <h1>Compose Message</h1>

    <form action='save.cgi' method='POST'>
    """)

subject = ''
if reply_to is not None:
    print('<input type="hidden" name="reply_to" value="{}"/>'.format(reply_to))
    curs.execute('SELECT subject FROM messages WHERE id = %s', (format(reply_to),))
    subject = curs.fetchone()[0]
    if not subject.startswith('Re: '):
        subject = 'Re: ' + subject

print("""
     <b>Subject:</b><br />
     <input type='text' size='40' name='subject' value='{}' /><br />
     <b>Sender:</b><br />
     <input type='text' size='40' name='sender' /><br />
     <b>Message:</b><br />
     <textarea name='text' cols='40' rows='20'></textarea><br />
     <input type='submit' value='Save'/>
     </form>
     <hr />
     <a href='main.cgi'>Back to the main page</a>'
  </body>
</html>
""".format(subject))

Listing 26-7.The Message Editor (edit.cgi)

编写保存脚本

现在让我们进入最后的剧本。save.cgi脚本将接收关于消息的信息(来自edit.cgi生成的表单),并将它存储在数据库中。这意味着使用 SQL INSERT命令,因为数据库已经被修改,所以必须调用conn.commit,这样当脚本终止时更改不会丢失。

清单 26-8 显示了save.cgi脚本的源代码。

#!/usr/bin/python

print('Content-type: text/html\n')

import cgitb; cgitb.enable()

import psycopg2
conn = psycopg2.connect('user=foo password=bar dbname=baz')
curs = conn.cursor()

import cgi, sys
form = cgi.FieldStorage()

sender = form.getvalue('sender')
subject = form.getvalue('subject')
text = form.getvalue('text')
reply_to = form.getvalue('reply_to')

if not (sender and subject and text):
    print('Please supply sender, subject, and text')
    sys.exit()

if reply_to is not None:
    query = ("""
    INSERT INTO messages(reply_to, sender, subject, text)
    VALUES(%s, '%s', '%s', '%s')""", (int(reply_to), sender, subject, text))
else:
    query = ("""
    INSERT INTO messages(sender, subject, text)
    VALUES('%s', '%s', '%s')""", (sender, subject, text))

curs.execute(*query)
conn.commit()

print("""
<html>

<head>
  <title>Message Saved</title>
</head>
<body>
  <h1>Message Saved</h1>
  <hr />
  <a href='main.cgi'>Back to the main page</a>
</body>
</html>s
""")

Listing 26-8.The Save Script (save.cgi)

尝试一下

要测试该系统,首先打开main.cgi。在那里,单击发布消息链接。那应该带你去edit.cgi。在所有字段中输入一些值,然后单击保存链接。

这将把您带到save.cgi,它将显示消息“消息已保存”点击返回主页面链接返回main.cgi。列表现在应该包括您的新消息。

要查看您的邮件,只需点击其主题。您应该使用正确的 ID 前往view.cgi。从那里,试着点击回复链接,这将再次把你带到edit.cgi,但是这次设置了reply_to(在一个隐藏的输入标签中)和一个默认的主题。再次输入一些文本,单击 Save,然后返回主页。它现在应该显示您的回复,显示在原始主题下。(如果没有显示,请尝试重新加载页面。)

主页面如图 26-1 ,消息查看器如图 26-2 ,消息编辑器如图 26-3 。

A326949_3_En_26_Fig3_HTML.jpg

图 26-3。

The message composer

A326949_3_En_26_Fig2_HTML.jpg

图 26-2。

The message viewer

进一步探索

既然您已经有能力开发具有可靠和高效存储的庞大而强大的 web 应用,那么您就有很多事情可以投入进去了。

  • 为你最喜欢的 Monty Python 草图数据库做一个 web 前端怎么样?
  • 如果你对改进本章的系统感兴趣,你应该考虑抽象。创建一个具有打印标准页眉和打印标准页脚功能的实用模块如何?这样,您就不需要在每个脚本中编写相同的 HTML 内容。此外,添加一个具有某种密码处理功能的用户数据库或者抽象出用于创建连接的代码可能会很有用。
  • 如果您想要一个不需要专用服务器的存储解决方案,您可以使用 SQLite(在第十三章中使用),或者一些非 SQL 解决方案,如 MongoDB ( https://mongodb.com ),甚至更技术性的文件格式,如 HDF5 ( http://h5py.org )。

什么现在?

如果你认为编写自己的论坛软件很酷,那么编写自己的点对点文件共享程序怎么样,比如 BitTorrent?在下一个项目中,这正是你要做的。好消息是,由于远程过程调用的奇迹,这将比您迄今为止所做的大多数网络编程都要容易。

二十七、项目 8:使用 XML-RPC 共享文件

本章的项目是一个简单的文件共享应用。你可能对文件共享的概念很熟悉,比如著名的 Napster(不再以其原始形式下载)、Gnutella(参见 http://www.gnutellaforums.com 关于可用客户端的讨论)、BitTorrent(可从 http://www.bittorrent.com 获得)以及许多其他应用。我们将要写的东西在很多方面都与这些相似,尽管要简单得多。

我们将使用的主要技术是 XML-RPC。如第十五章所述,这是一个远程调用程序(函数)的协议,可能通过网络。如果你愿意,你可以很容易地使用普通套接字编程(可能采用第 14 和 24 章中描述的一些技术)来实现这个项目的功能。这甚至可能给你带来更好的性能,因为 XML-RPC 协议确实有一定的开销。然而,XML-RPC 非常易于使用,很可能会大大简化您的代码。

有什么问题?

我们想创建一个点对点的文件共享程序。文件共享基本上意味着在不同机器上运行的程序之间交换文件(从文本文件到声音或视频剪辑的一切)。对等是一个术语,描述计算机程序之间的一种交互,这种交互与常见的客户端-服务器交互(客户端可以连接到服务器,但服务器不能连接到客户端)有所不同。在对等交互中,任何对等体都可以连接到任何其他对等体。在这样的(虚拟)对等网络中,没有中央权威(如客户端/服务器架构中的服务器所代表的),这使得网络更加健壮。除非你把大部分同行都关了,否则不会崩溃。

构建对等系统涉及许多问题。在诸如老式 Gnutella 的系统中,一个对等体可以向它的所有邻居(它知道的其他对等体)传播查询,并且它们可以随后进一步传播该查询。任何响应查询的对等点都可以通过对等点链向最初的对等点发送回复。同事们单独工作,并行工作。最近的系统,比如 BitTorrent,使用了更巧妙的技术,比如要求你上传文件才能被允许下载文件。为了简化事情,这个项目的系统将依次联系每个邻居,等待其响应后再继续。这不如 Gnutella 的并行方法高效,但对于您的目的来说已经足够好了。

大多数对等系统都有巧妙的方式来组织它们的结构——也就是说,哪些对等体“挨着”哪些对等体——以及这种结构如何随着对等体的连接和断开而随时间演变。我们将在这个项目中保持非常简单,但事情有待改进。

以下是文件共享程序必须满足的要求:

  • 每个节点必须跟踪一组已知的节点,它可以向这些节点寻求帮助。一个节点必须能够将自己介绍给另一个节点(从而包含在这个集合中)。
  • 必须能够向节点请求文件(通过提供文件名)。如果节点有问题的文件,它应该返回它;否则,它应该依次向它的每个邻居请求同一个文件(而它们可能依次向它们的邻居请求)。如果其中一个节点拥有该文件,则返回该文件。
  • 为了避免循环(A 问 B,B 反过来问 A)和避免过长的邻居问邻居链(A 问 B 问 C)。。询问 Z),当查询一个节点时,必须能够提供一个历史。这个历史只是到目前为止哪些节点参与了查询的列表。通过不询问历史中已经存在的节点,可以避免循环,通过限制历史的长度,可以避免过长的查询链。
  • 必须有某种方法连接到一个节点,并将自己标识为可信方。通过这样做,您应该能够访问不受信任方(例如对等网络中的其他节点)无法访问的功能。该功能可以包括要求节点从网络中的其他对等体下载并存储文件(通过查询)。
  • 您必须有一些用户界面,让您连接到一个节点(作为受信任的一方)并使它下载文件。它应该很容易扩展和替换这个接口。

所有这些可能看起来有点陡峭,但是正如你将看到的,实现它并不困难。你可能会发现,一旦你有了这些,添加功能也不会那么困难。

Caution

正如文档中指出的,Python XML-RPC 模块对于恶意构造的数据是不安全的。尽管该项目将“可信”节点与“不可信”节点分开,但这不应被视为任何类型的安全保证。在使用系统时,您应该避免连接到不信任的节点。

有用的工具

在这个项目中,我们将使用相当多的标准库模块。

我们将使用的主要模块是xmlrpc.clientxmlrpc.serverxmlrpc.client的用法非常简单。您只需创建一个带有服务器 URL 的ServerProxy对象,就可以立即访问远程过程。使用xmlrpc.server有点复杂,你会在本章的项目中学习到。

对于文件共享程序的接口,我们将使用第二十四章中的朋友cmd。为了获得一些(非常有限的)并行性,我们将使用threading模块,为了提取 URL 的组成部分,我们将使用urllib.parse模块。这些模块将在本章后面解释。

你可能想复习的其他模块有randomstringtimeos.path。更多细节见第十章以及 Python 库参考。

准备

这个项目中使用的库不需要太多准备。如果您有一个相当新的 Python 版本,那么所有必需的库都应该是现成可用的。

你不一定要连接到网络上才能使用这个项目中的软件,但这会让事情变得更有趣。如果您可以访问两台(或多台)连接在一起的独立机器(例如,两台机器都连接到互联网),您可以在每台机器上运行软件,并让它们相互通信(尽管您可能需要对正在运行的任何防火墙规则进行更改)。出于测试目的,也可以在同一台机器上运行多个文件共享节点。

首次实施

在编写第一个Node类的原型(系统中的单个节点或对等点)之前,您需要了解一点来自xmlrpc.serverSimpleXMLRPCServer类是如何工作的。它用一个形式为(servername, port)的元组来实例化。服务器名称是运行服务器的机器的名称。(您可以在这里使用一个空字符串来表示 localhost,即您实际执行程序的机器。)端口号可以是您有权访问的任何端口,通常为 1024 及以上。

在实例化服务器之后,可以用register_instance方法注册一个实现其“远程方法”的实例。或者,您可以用register_function方法注册单个函数。当您准备好运行服务器时(这样它可以响应来自外部的请求),您调用它的方法serve_forever。你可以很容易地尝试这一点。启动两个交互式 Python 解释器。在第一个对话框中,输入以下代码:

>>> from xmlrpc.server import SimpleXMLRPCServer
>>> s = SimpleXMLRPCServer(("", 4242)) # Localhost at port 4242
>>> def twice(x): # Example function
...    return x * 2
...
>>> s.register_function(twice) # Add functionality to the server
>>> s.serve_forever() # Start the server

执行完最后一条语句后,解释器应该看起来像是“挂起”了。实际上,它在等待 RPC 请求。要发出这样的请求,请切换到另一个解释器并执行以下命令:

>>> from xmlrpc.client import ServerProxy # ... or simply Server, if you prefer
>>> s = ServerProxy('http://localhost:4242') # Localhost again...
>>> s.twice(2)
4

令人印象深刻,是吧?特别是考虑到客户端部分(使用xmlrpclib)可以在不同的机器上运行。(在这种情况下,您需要使用服务器的实际名称,而不是简单的 localhost。)如您所见,要访问由服务器实现的远程过程,所需要的就是用正确的 URL 实例化一个ServerProxy。这真的再简单不过了。

实现简单节点

既然我们已经讨论了 XML-RPC 的技术细节,现在是开始编码的时候了。(第一个原型的完整源代码可以在本节末尾的清单 27-1 中找到。)

为了找到从哪里开始,回顾一下本章前面的需求可能是个好主意。我们主要对两件事感兴趣:我们的Node必须持有什么信息(属性),以及它必须能够执行什么动作(方法)?

Node必须至少具有以下属性:

  • 目录名,因此它知道在哪里找到/存储它的文件。
  • 一个“秘密”(或密码),其他人可以使用它来标识自己(作为可信方)。
  • 一组已知的对等点(URL)。
  • 一个 URL,它可以被添加到查询历史或者可能被提供给其他Nodes。(本项目不会实现后者。)

Node构造函数将简单地设置这四个属性。此外,我们需要一个查询Node的方法,一个让它获取和存储文件的方法,以及一个向它引入另一个Node的方法。让我们称这些方法为queryfetchhello。以下是该类的草图,以伪代码形式编写:

class Node:

    def __init__(self, url, dirname, secret):
        self.url = url
        self.dirname = dirname
        self.secret = secret
        self.known = set()

    def query(self, query):
        Look for a file (possibly asking neighbors), and return it as a string

    def fetch(self, query, secret):
        If the secret is correct, perform a regular query and store
        the file. In other words, make the Node find the file and download it.

    def hello(self, other):
        Add the other Node to the known peers

假设已知 URL 的集合叫做knownhello方法非常简单。它只是将other添加到self.known,其中other是唯一的参数(一个 URL)。但是,XML-RPC 要求所有方法都返回值;None不被接受。因此,让我们定义两个表示成功或失败的结果“代码”。

OK = 1
FAIL = 2

那么hello方法可以实现如下:

def hello(self, other):
    self.known.add(other)
    return OK

NodeSimpleXMLRPCServer注册时,就有可能从“外部”调用这个方法。

queryfetch方法有点复杂。让我们从fetch开始,因为它是两者中比较简单的一个。它必须有两个参数:查询和“秘密”,这是必需的,这样您的Node就不会被任何人随意操纵。注意,调用fetch会导致Node下载一个文件。因此,对这个方法的访问应该比query更受限制,因为后者只是传递文件。

如果提供的密码不等于self.secret(启动时提供的密码),fetch简单地返回FAIL。否则,它调用query来获取对应于给定查询的文件(一个文件名)。但是query回报什么呢?当您调用query时,您希望知道查询是否成功,如果成功,您希望返回相关文件的内容。所以,我们把query的返回值定义为 pair (tuple) code, data,其中code不是OK就是FAIL,而data是存储在字符串中的抢手文件(如果code等于OK),否则为任意值(例如空字符串)。

fetch中,检索代码和数据。如果代码是FAIL,那么fetch也简单地返回FAIL。否则,它会打开一个新文件(以写模式),该文件的名称与查询的名称相同,并且位于目录self.dirname中(您使用os.path.join来连接这两个文件)。数据写入文件,文件关闭,返回OK。有关相对简单的实现,请参见本节后面的清单 27-1 。

现在,把注意力转向query。它接收一个查询作为参数,但也应该接受一个历史记录(其中包含不应被查询的 URL,因为它们已经在等待对同一查询的响应)。因为这个历史在第一次调用query时是空的,所以可以使用一个空列表作为默认值。

如果你看一下清单 27-1 中的代码,你会发现它通过创建两个名为_handle_broadcast的实用方法抽象出了query的部分行为。注意,它们的名字以下划线开头,这意味着不能通过 XML-RPC 访问它们。(这是SimpleXMLRPCServer行为的一部分,不是 XML-RPC 本身的一部分。)这很有用,因为这些方法并不意味着向外部提供单独的功能,而是用来构建代码的。

现在,让我们假设_handle负责查询的内部处理(检查文件是否存在于这个特定的Node,获取数据,等等)并且它返回一个代码和一些数据,就像query本身应该做的那样。从清单中可以看到,如果code == OK,那么code, data会立即返回——文件被找到。但是,如果从_handle返回的codeFAIL该怎么办?然后它必须向所有其他已知的Node求助。该过程的第一步是将self.url添加到history

Note

在更新历史记录时,既没有使用+=操作符,也没有使用append列表方法,因为这两种方法都在适当的位置修改列表,并且您不希望修改默认值本身。

如果新的history太长,query返回FAIL(以及一个空字符串)。最大长度被任意设置为 6,并保持在全局常量MAX_HISTORY_LENGTH中。

Why Is Max_History_Length Set to 6?

这个想法是网络中的任何一个对等点都应该能够在最多六步内到达另一个对等点。当然,这取决于网络的结构(哪个同伴认识哪个),但受到“六度分离”假设的支持,该假设适用于人们和他们认识的人。关于这一假设的描述,参见例如维基百科关于六度分离的文章( http://en.wikipedia.org/wiki/Six_degrees_of_separation )。

在你的程序中使用这个数字可能不太科学,但至少看起来是个不错的猜测。另一方面,在一个有许多节点的大型网络中,程序的顺序性质可能会导致大值MAX_HISTORY_LENGTH的糟糕性能,因此如果速度变慢,您可能希望减少它。

如果history不太长,下一步是将查询广播给所有已知的对等点,这是用_broadcast方法完成的。_broadcast方法并不复杂(参见清单 27-1 )。它遍历self.known的一个副本。如果在history中发现一个peer,循环继续到下一个对等点(使用continue语句)。否则,构造一个ServerProxy,并在其上调用query方法。如果查询成功,其返回值将被用作来自_broadcast的返回值。由于网络问题、错误的 URL 或者对等点不支持query方法,可能会出现异常。如果出现这样的异常,对等体的 URL 将从self.known中删除(在包含查询的try语句的except子句中)。最后,如果控制到达了函数的末尾(还没有返回任何东西),那么将返回FAIL和一个空字符串。

Note

您不应该简单地迭代self.known,因为集合可能会在迭代过程中被修改。使用副本更安全。

_start方法创建一个SimpleXMLRPCServer(使用一个小的实用函数get_port,它从一个 URL 中提取端口号),将logRequests设置为 false(您不想保存日志)。然后它用register_instance注册self并调用服务器的serve_forever方法。

最后,模块的main方法从命令行提取一个 URL、一个目录、一个秘密(密码);创建一个Node;并调用它的_start方法。

有关原型的完整代码,请参见清单 27-1 。

from xmlrpc.client import ServerProxy
from os.path import join, isfile
from xmlrpc.server import SimpleXMLRPCServer
from urllib.parse import urlparse
import sys

MAX_HISTORY_LENGTH = 6

OK = 1
FAIL = 2
EMPTY = ''

def get_port(url):
    'Extracts the port from a URL'
    name = urlparse(url)[1]
    parts = name.split(':')
    return int(parts[-1])

class Node:
    """
    A node in a peer-to-peer network.
    """
    def __init__(self, url, dirname, secret):
        self.url = url
        self.dirname = dirname
        self.secret = secret
        self.known = set()

    def query(self, query, history=[]):
        """
        Performs a query for a file, possibly asking other known Nodes for
        help. Returns the file as a string.
        """
        code, data = self._handle(query)
        if code == OK:
            return code, data
        else:
            history = history + [self.url]
            if len(history) >= MAX_HISTORY_LENGTH:
                return FAIL, EMPTY
            return self._broadcast(query, history)

    def hello(self, other):
        """
        Used to introduce the Node to other Nodes.
        """
        self.known.add(other)
        return OK

    def fetch(self, query, secret):
        """
        Used to make the Node find a file and download it.
        """
        if secret != self.secret: return FAIL
        code, data = self.query(query)
        if code == OK:
            f = open(join(self.dirname, query), 'w')

            f.write(data)
            f.close()
            return OK

        else:
            return FAIL

    def _start(self):
        """
        Used internally to start the XML-RPC server.
        """
        s = SimpleXMLRPCServer(("", get_port(self.url)), logRequests=False)
        s.register_instance(self)
        s.serve_forever()

    def _handle(self, query):
        """
        Used internally to handle queries.
        """
        dir = self.dirname
        name = join(dir, query)
        if not isfile(name): return FAIL, EMPTY
        return OK, open(name).read()

    def _broadcast(self, query, history):
        """
        Used internally to broadcast a query to all known Nodes.
        """
        for other in self.known.copy():
            if other in history: continue
            try:
                s = ServerProxy(other)
                code, data = s.query(query, history)
                if code == OK:
                    return code, data
            except:
                self.known.remove(other)
        return FAIL, EMPTY

def main():
    url, directory, secret = sys.argv[1:]
    n = Node(url, directory, secret)
    n._start()

if __name__ == '__main__': main()

Listing 27-1.A Simple Node Implementation (simple_node.py)

现在让我们来看一个简单的例子,看看这个程序是如何使用的。

尝试第一个实现

确保您打开了几个终端(Terminal.app、xterm、DOS window 或等效程序)。假设您想要运行两个对等体(都在同一台机器上)。为他们每个人创建一个目录,比如files1files2。将一个文件(例如,test.txt)放入files2目录。然后,在一个终端中,运行以下命令:

python simple_node.py http://localhost:4242 files1 secret1

在实际的应用中,您会使用完整的机器名而不是localhost,并且您可能会使用比secret1更神秘的密码。

这是你的第一个同伴。现在创建另一个。在不同的终端中,运行以下命令:

python simple_node.py http://localhost:4243 files2 secret2

如您所见,这个对等体提供来自不同目录的文件,使用另一个端口号(4243),并拥有另一个秘密。如果您遵循了这些说明,您应该有两个对等体在运行(每个都在单独的终端窗口中)。让我们启动一个交互式 Python 解释器,并尝试连接到其中一个。

>>> from xmlrpc.client import *
>>> mypeer = ServerProxy('http://localhost:4242') # The first peer
>>> code, data = mypeer.query('test.txt')
>>> code
2

如您所见,第一个对等点在被要求提供文件test.txt时失败了。(返回码2代表失败,还记得吗?)让我们用第二个对等体尝试同样的事情。

>>> otherpeer = ServerProxy('http://localhost:4243') # The second peer
>>> code, data = otherpeer.query('test.txt')
>>> code
1

这一次,查询成功了,因为在第二个对等体的文件目录中找到了文件test.txt。如果您的测试文件没有包含太多的文本,您可以显示data变量的内容,以确保文件的内容已经被正确地传输。

>>> data
'This is a test\n'

目前为止,一切顺利。把第一个同行介绍给第二个怎么样?

>>> mypeer.hello('http://localhost:4243') # Introducing mypeer to otherpeer

现在第一个对等体知道了第二个对等体的 URL,因此可以向它寻求帮助。让我们再次尝试查询第一个对等体。这一次,查询应该会成功。

>>> mypeer.query('test.txt')
[1, 'This is a test\n']

答对了。

现在只剩下一件事需要测试:你能让第一个节点真正从第二个节点下载并存储文件吗?

>>> mypeer.fetch('test.txt', 'secret1')
1

嗯,返回值(1)表示成功。如果你在files1目录中查找,你应该看到文件test.txt奇迹般地出现了。随意启动几个对等机(如果你愿意,可以在不同的机器上)并互相介绍。当你厌倦了游戏,继续下一个实现。

第二次实施

第一个实现有很多缺陷和不足。我不会一一提到(本章末尾的“进一步探索”一节讨论了一些可能的改进),但这里有一些更重要的改进:

  • 如果您试图停止一个Node然后重新启动它,您可能会得到一些关于端口已经被使用的错误消息。
  • 你可能想要一个比交互式 Python 解释器中的xmlrpc.client更加用户友好的界面。
  • 返回代码不方便。如果找不到文件,更自然和 Pythonic 化的解决方案是使用自定义异常。
  • Node不检查它返回的文件是否在文件目录中。通过使用像'../somesecretfile.txt'这样的路径,一个狡猾的黑魔法可能会非法访问你的任何其他文件。

第一个问题很容易解决。您只需将SimpleXMLRPCServerallow_reuse_address属性设置为 true。

SimpleXMLRPCServer.allow_reuse_address = 1

如果不想直接修改这个类,可以创建自己的子类。其他的变化稍微复杂一些,将在下面的章节中讨论。源代码在本章后面的清单 27-2 和 27-3 中显示。(在继续阅读之前,您可能想快速浏览一下这些列表。)

创建客户端界面

客户端接口使用来自cmd模块的Cmd类。关于如何工作的详细信息,请参见第二十四章或 Python 库参考。简单地说,你子类化Cmd来创建一个命令行接口,并为你希望它能够处理的每个命令foo实现一个名为do_foo的方法。该方法将接收命令行的其余部分作为其唯一的参数(作为字符串)。例如,如果您在命令行界面中键入以下内容:

say hello

调用方法do_say时,将字符串'hello'作为唯一的参数。Cmd子类的提示由prompt属性决定。

在你的界面中执行的命令只有fetch(下载文件)和exit(退出程序)。fetch命令简单地调用服务器的fetch方法,如果找不到文件,就打印一条错误消息。exit命令打印一个空行(仅出于美观原因)并调用sys.exit。(EOF命令对应于“文件结束”,当用户在 UNIX 中按 Ctrl+D 时会出现这种情况。)

但是在构造函数中发生了什么呢?嗯,您希望每个客户端都与其自己的对等体相关联。您可以简单地创建一个Node对象并调用它的_start方法,但是在_start方法返回之前,您的Client不能做任何事情,这使得Client完全无用。为了解决这个问题,在一个单独的线程中启动了Node。通常情况下,使用线程会涉及到许多安全措施以及与锁等的同步。然而,因为一个Client只通过 XML-RPC 与它的Node交互,所以你不需要这些。要在一个单独的线程中运行_start方法,你只需要把下面的代码放到你的程序中某个合适的地方:

from threading import Thread
n = Node(url, dirname, self.secret)
t = Thread(target=n._start)
t.start()

Caution

重写这个项目的代码时要小心。当你的Client开始直接与Node对象交互时,或者反之亦然,你可能很容易因为线程而遇到麻烦。在这样做之前,请确保您完全了解线程。

为了确保在开始用 XML-RPC 连接服务器之前,服务器已经完全启动,您将让它先启动,然后用time.sleep等待一会儿。

之后,您将遍历 URL 文件中的所有行,并使用hello方法向它们介绍您的服务器。

你真的不想为想出一个聪明的秘密密码而烦恼。相反,你可以使用实用函数random_string(在清单 27-3 中,这将在本章后面介绍),它生成一个在ClientNode之间共享的随机秘密字符串。

引发异常

不是返回一个指示成功或失败的代码,而是假设成功,并在失败时引发一个异常。在 XML-RPC 中,异常(或错误)由数字标识。对于这个项目,我(任意地)选择了数字 100 和 200 分别表示普通失败(未处理的请求)和请求拒绝(拒绝访问)。

UNHANDLED     = 100
ACCESS_DENIED = 200

class UnhandledQuery(Fault): 

    """
    An exception that represents an unhandled query.
    """
    def __init__(self, message="Couldn't handle the query"):
        super().__init__(UNHANDLED, message)

class AccessDenied(Fault):
    """
    An exception that is raised if a user tries to access a resource for
    which he or she is not authorized.
    """
    def __init__(self, message="Access denied"):
        super().__init__(ACCESS_DENIED, message)

例外是xmlrpc.client.Fault的子类。当它们在服务器中被引发时,它们被传递给具有相同faultCode的客户机。如果一个普通的异常(比如IOException)在服务器中被引发,那么Fault类的一个实例仍然被创建,所以你不能简单地在这里使用任意的异常。

从源代码中可以看出,逻辑基本上还是一样的,但是程序现在使用了异常,而不是使用if语句来检查返回的代码。(因为只能使用Fault对象,所以需要检查faultCodes。当然,如果您没有使用 XML-RPC,您会使用不同的异常类。)

验证文件名

最后要处理的问题是检查给定的文件名是否在给定的目录中。有几种方法可以做到这一点,但是为了保持平台独立(例如,它们可以在 Windows、UNIX 和 macOS 中工作),您应该使用模块os.path

这里采用的简单方法是从目录名和文件名创建一个绝对路径(例如,'/foo/bar/../baz'被转换为'/foo/baz'),目录名与一个空文件名连接(使用os.path.join)以确保它以一个文件分隔符结束(例如'/',然后我们检查绝对文件名是否以绝对目录名开始。如果是,文件实际上在目录中。

第二个实现的完整源代码如清单 27-2 和 27-3 所示。

from xmlrpc.client import ServerProxy, Fault
from os.path import join, abspath, isfile
from xmlrpc.server import SimpleXMLRPCServer
from urllib.parse import urlparse
import sys

SimpleXMLRPCServer.allow_reuse_address = 1

MAX_HISTORY_LENGTH = 6

UNHANDLED     = 100
ACCESS_DENIED = 200

class UnhandledQuery(Fault): 

    """
    An exception that represents an unhandled query.
    """
    def __init__(self, message="Couldn't handle the query"):
        super().__init__(UNHANDLED, message)

class AccessDenied(Fault):
    """
    An exception that is raised if a user tries to access a
    resource for which he or she is not authorized.
    """
    def __init__(self, message="Access denied"):
        super().__init__(ACCESS_DENIED, message)

def inside(dir, name):
    """
    Checks whether a given file name lies within a given directory.
    """
    dir = abspath(dir)
    name = abspath(name)
    return name.startswith(join(dir, ''))

def get_port(url):
    """
    Extracts the port number from a URL.
    """
    name = urlparse(url)[1]
    parts = name.split(':')
    return int(parts[-1])

class Node:
    """
    A node in a peer-to-peer network.
    """
    def __init__(self, url, dirname, secret):
        self.url = url
        self.dirname = dirname
        self.secret = secret
        self.known = set()

    def query(self, query, history=[]):
        """
        Performs a query for a file, possibly asking other known Nodes for
        help. Returns the file as a string.
        """
        try:
            return self._handle(query)
        except UnhandledQuery:
            history = history + [self.url]
            if len(history) >= MAX_HISTORY_LENGTH: raise
            return self._broadcast(query, history)

    def hello(self, other):
        """
        Used to introduce the Node to other Nodes. 

        """
        self.known.add(other)
        return 0

    def fetch(self, query, secret):
        """
        Used to make the Node find a file and download it.
        """
        if secret != self.secret: raise AccessDenied
        result = self.query(query)
        f = open(join(self.dirname, query), 'w')
        f.write(result)
        f.close()
        return 0

    def _start(self):
        """
        Used internally to start the XML-RPC server.
        """
        s = SimpleXMLRPCServer(("", get_port(self.url)), logRequests=False)
        s.register_instance(self)
        s.serve_forever()

    def _handle(self, query):
        """
        Used internally to handle queries.
        """
        dir = self.dirname
        name = join(dir, query)
        if not isfile(name): raise UnhandledQuery
        if not inside(dir, name): raise AccessDenied
        return open(name).read()

    def _broadcast(self, query, history):
        """
        Used internally to broadcast a query to all known Nodes.
        """
        for other in self.known.copy():
            if other in history: continue
            try:
                s = ServerProxy(other)
                return s.query(query, history)
            except Fault as f:
                if f.faultCode == UNHANDLED: pass
                else: self.known.remove(other)
            except:
                self.known.remove(other)
        raise UnhandledQuery

def main():
    url, directory, secret = sys.argv[1:]
    n = Node(url, directory, secret)
    n._start()

if __name__ == '__main__': main()

Listing 27-2.A New Node Implementation (server.py)

from xmlrpc.client import ServerProxy, Fault
from cmd import Cmd
from random import choice
from string import ascii_lowercase
from server import Node, UNHANDLED
from threading import Thread
from time import sleep
import sys

HEAD_START = 0.1 # Seconds
SECRET_LENGTH = 100

def random_string(length):
    """
    Returns a random string of letters with the given length.
    """
    chars = []
    letters = ascii_lowercase[:26]

    while length > 0:
        length -= 1
        chars.append(choice(letters))
    return ''.join(chars)

class Client(Cmd):
    """
    A simple text-based interface to the Node class.
    """

    prompt = '> '

    def __init__(self, url, dirname, urlfile):
        """
        Sets the url, dirname, and urlfile, and starts the Node
        Server in a separate thread.
        """
        Cmd.__init__(self)
        self.secret = random_string(SECRET_LENGTH)
        n = Node(url, dirname, self.secret)
        t = Thread(target=n._start) 

        t.setDaemon(1)
        t.start()
        # Give the server a head start:
        sleep(HEAD_START)
        self.server = ServerProxy(url)
        for line in open(urlfile):
            line = line.strip()
            self.server.hello(line)

    def do_fetch(self, arg):
        "Call the fetch method of the Server."
        try:
            self.server.fetch(arg, self.secret)
        except Fault as f:
            if f.faultCode != UNHANDLED: raise
            print("Couldn't find the file", arg)

    def do_exit(self, arg):
        "Exit the program."
        print()
        sys.exit()

    do_EOF = do_exit # End-Of-File is synonymous with 'exit'

def main():
    urlfile, directory, url = sys.argv[1:]
    client = Client(url, directory, urlfile)
    client.cmdloop()

if __name__ == '__main__': main()

Listing 27-3.A Node Controller Interface (client.py)

尝试第二个实现

让我们看看程序是如何使用的。像这样开始:

python client.py urls.txt directory http://servername.com:4242

文件urls.txt应该每行包含一个 URL——您知道的所有其他对等点的 URL。作为第二个参数给出的目录应该包含您想要共享的文件(并且将是下载新文件的位置)。最后一个参数是对等体的 URL。当您运行此命令时,您应该会得到如下提示:

>

尝试获取不存在的文件:

> fetch fooo
Couldn't find the file fooo

通过启动几个相互了解的节点(要么在同一台机器上使用不同的端口,要么在不同的机器上)(只需将所有的 URL 放在 URL 文件中),您可以像使用第一个原型一样尝试这些节点。当你对此感到厌烦时,继续下一部分。

进一步探索

你或许可以想出几种方法来改进和扩展本章所描述的系统。以下是一些想法:

  • 添加缓存。如果您的节点通过调用query来转发文件,为什么不同时存储文件呢?这样,下次有人要求相同的文件时,您可以更快地做出响应。您也许可以设置缓存的最大大小,删除旧文件,等等。
  • 使用线程或异步服务器(有点困难)。这样,您可以向其他几个节点寻求帮助,而不必等待它们的回复,它们稍后可以通过调用一个reply方法给你回复。
  • 允许更高级的查询,如查询文本文件的内容。
  • 更广泛地使用hello方法。当你发现一个新的同行(通过调用hello),为什么不把它介绍给你认识的所有同行呢?也许你可以想出更聪明的方法来发现新的同伴?
  • 仔细阅读分布式系统的表述性状态转移(REST)哲学。REST 是 XML-RPC 等 web 服务技术的替代技术。(例如参见 http://en.wikipedia.org/wiki/REST )。)
  • 使用xmlrpc.client.Binary包装文件,使非文本文件的传输更加安全。
  • 读取SimpleXMLRPCServer代码。查看libxmlrpc中的DocXMLRPCServer类和 multicall 扩展。

什么现在?

现在你已经有了一个点对点的文件共享系统,如何让它更加用户友好呢?在下一章中,您将学习如何添加一个 GUI 作为当前基于cmd的界面的替代。

二十八、项目 9:文件共享 II——现在有了 GUI!

这是一个相对较短的项目,因为你需要的大部分功能已经写好了——在第二十七章。在这一章中,你会看到在现有的 Python 程序中添加 GUI 是多么容易。

有什么问题?

在这个项目中,我们将用一个 GUI 客户端来扩展在第二十七章中开发的文件共享系统。这将使程序更容易使用,这意味着更多的人可能会选择使用它(当然,多个用户共享文件是该程序的全部意义)。这个项目的第二个目标是展示一个具有足够模块化设计的程序可以非常容易扩展(这是使用面向对象编程的一个理由)。

GUI 客户端应满足以下要求:

  • 它应该允许您输入一个文件名并提交给服务器的fetch方法。
  • 它应该列出服务器文件目录中当前可用的文件。

就这样。因为系统的大部分已经可以工作了,所以 GUI 部分是一个相对简单的扩展。

有用的工具

除了第二十七章中使用的工具之外,您还需要 Tkinter 工具包,它与大多数 Python 安装捆绑在一起。有关 Tkinter 的更多信息,请参见第十二章。如果您想使用另一个 GUI 工具包,请随意。本章中的例子将告诉你如何用你喜欢的工具构建你自己的实现。

准备

在你开始这个项目之前,你应该准备好项目 8(从第章到第二十七章)并且安装一个可用的 GUI 工具包,如前一节所述。除此之外,这个项目不需要任何重要的准备工作。

首次实施

如果您想看一眼第一个实现的完整源代码,您可以在本节后面的清单 28-1 中找到它。许多功能与前一章中的项目非常相似。客户端提供一个接口(fetch方法),用户可以通过该接口访问服务器的功能。让我们回顾一下代码中特定于 GUI 的部分。

第二十七章中的客户端是cmd.Cmd的子类;本章中描述的Client包含tkinter.Frame的子类。虽然你不需要子类化tkinter.Frame(你可以创建一个完全独立的Client类),但这是组织你代码的自然方式。与 GUI 相关的设置放在一个单独的方法中,称为create_widgets,在构造函数中调用。它为文件名创建一个条目,并创建一个获取给定文件的按钮,按钮的动作设置为方法fetch_handler。该事件处理程序与第二十七章中的处理程序do_fetch非常相似。它从self.input(文本字段)中检索查询。然后它在一个try / except语句中调用self.server.fetch

第一个实现的源代码如清单 28-1 所示。

from xmlrpc.client import ServerProxy, Fault
from server import Node, UNHANDLED
from client import random_string
from threading import Thread
from time import sleep
from os import listdir
import sys
import tkinter as tk

HEAD_START = 0.1 # Seconds
SECRET_LENGTH = 100

class Client(tk.Frame):

    def __init__(self, master, url, dirname, urlfile):
        super().__init__(master)
        self.node_setup(url, dirname, urlfile)
        self.pack()
        self.create_widgets()

    def node_setup(self, url, dirname, urlfile):
        self.secret = random_string(SECRET_LENGTH)
        n = Node(url, dirname, self.secret)
        t = Thread(target=n._start)
        t.setDaemon(1)
        t.start()
        # Give the server a head start:
        sleep(HEAD_START)
        self.server = ServerProxy(url)
        for line in open(urlfile):
            line = line.strip()
            self.server.hello(line)

    def create_widgets(self):

        self.input = input = tk.Entry(self)
        input.pack(side='left')

        self.submit = submit = tk.Button(self)
        submit['text'] = "Fetch"
        submit['command'] = self.fetch_handler
        submit.pack()

    def fetch_handler(self):
        query = self.input.get()
        try:
            self.server.fetch(query, self.secret)
        except Fault as f:
            if f.faultCode != UNHANDLED: raise
            print("Couldn't find the file", query)

def main():
    urlfile, directory, url = sys.argv[1:]

    root = tk.Tk()
    root.title("File Sharing Client")

    client = Client(root, url, directory, urlfile)
    client.mainloop()

if __name__ == "__main__": main()

Listing 28-1.A Simple GUI Client (simple_guiclient.py)

除了前面解释的相对简单的代码,GUI 客户端的工作方式就像第二十七章中基于文本的客户端一样。您也可以用同样的方式运行它。要运行这个程序,您需要一个 URL 文件、一个要共享的文件目录和一个您的Node的 URL。下面是一个运行示例:

$ python simple_guiclient.py urlfile.txt files/ http://localhost:8000

注意,文件urlfile.txt必须包含一些其他Node的 URL,程序才能使用。您可以在同一台机器上(使用不同的端口号)启动几个程序进行测试,也可以在不同的机器上运行它们。图 28-1 显示了客户端的图形用户界面。

A326949_3_En_28_Fig1_HTML.jpg

图 28-1。

The simple GUI client

这个实现是可行的,但是它只完成了部分工作。它还应该列出服务器文件目录中可用的文件。为此,必须扩展服务器(Node)本身。

第二次实施

第一个原型非常简单。它做了文件共享系统的工作,但对用户不太友好。如果用户可以看到他们有哪些可用的文件(无论是在程序启动时位于文件目录中,还是随后从另一个Node下载),这将非常有帮助。第二个实现将解决这个文件列表问题。完整的源代码可以在清单 28-2 中找到。

要从Node获取列表,必须添加一个方法。你可以用密码来保护它,就像你对fetch所做的那样,但是公开它可能是有用的,并且它不代表任何真正的安全风险。扩展一个对象真的很容易:你可以通过子类化来实现。您只需用一个额外的方法list构造一个名为ListableNodeNode的子类,该方法使用方法os.listdir,返回一个目录中所有文件的列表。

class ListableNode(Node):

    def list(self):
        return listdir(self.dirname)

为了访问这个服务器方法,方法update_list被添加到客户端。

def update_list(self):
    self.files.Set(self.server.list())

属性self.files指的是一个列表框,它已经被添加到create_widgets方法中。在创建列表框时,在create_widgets中调用update_list方法,并且每次调用fetch_handler时再次调用(因为调用fetch_handler可能会改变文件列表)。

from xmlrpc.client import ServerProxy, Fault
from server import Node, UNHANDLED
from client import random_string
from threading import Thread
from time import sleep
from os import listdir
import sys
import tkinter as tk

HEAD_START = 0.1 # Seconds
SECRET_LENGTH = 100

class ListableNode(Node):

    def list(self):
        return listdir(self.dirname)

class Client(tk.Frame):

    def __init__(self, master, url, dirname, urlfile):
        super().__init__(master)
        self.node_setup(url, dirname, urlfile)
        self.pack()
        self.create_widgets()

    def node_setup(self, url, dirname, urlfile):
        self.secret = random_string(SECRET_LENGTH)
        n = ListableNode(url, dirname, self.secret)
        t = Thread(target=n._start)
        t.setDaemon(1)
        t.start()
        # Give the server a head start:
        sleep(HEAD_START)
        self.server = ServerProxy(url)
        for line in open(urlfile):
            line = line.strip()
            self.server.hello(line)

    def create_widgets(self):
        self.input = input = tk.Entry(self)
        input.pack(side='left')

        self.submit = submit = tk.Button(self)
        submit['text'] = "Fetch"
        submit['command'] = self.fetch_handler
        submit.pack()

        self.files = files = tk.Listbox()
        files.pack(side='bottom', expand=True, fill=tk.BOTH)
        self.update_list()

    def fetch_handler(self):
        query = self.input.get()
        try:
            self.server.fetch(query, self.secret)
            self.update_list()
        except Fault as f:
            if f.faultCode != UNHANDLED: raise
            print("Couldn't find the file", query)

    def update_list(self):
        self.files.delete(0, tk.END)
        self.files.insert(tk.END, self.server.list())

def main():
    urlfile, directory, url = sys.argv[1:]

    root = tk.Tk()
    root.title("File Sharing Client")

    client = Client(root, url, directory, urlfile)
    client.mainloop()

if __name__ == '__main__': main()

Listing 28-2.The Finished GUI Client (guiclient.py)

就这样。现在,您有了一个支持 GUI 的对等文件共享程序,可以使用以下命令运行该程序:

$ python guiclient.py urlfile.txt files/ http://localhost:8000

图 28-2 显示了完成的 GUI 客户端。

A326949_3_En_28_Fig2_HTML.jpg

图 28-2。

The finished GUI client

当然,有很多方法可以扩展这个程序。对于一些想法,见下一节。除此之外,就让你的想象力天马行空吧。

进一步探索

第二十七章给出了一些扩展文件共享系统的想法。这里还有一些:

  • 让用户选择所需的文件,而不是键入其名称。
  • 添加一个状态栏,显示“正在下载”或“找不到文件 foo.txt”等消息
  • 想办法让Node们分享他们的“朋友”例如,当一个Node被介绍给另一个Node时,他们每个人都可以把另一个介绍给它已经认识的Node。此外,在一个Node关闭之前,它可能会告诉它当前所有的邻居它知道的所有Node
  • 向 GUI 添加一个已知的Node列表。可以添加新的 URL 并将其保存在 URL 文件中。

什么现在?

您已经编写了一个成熟的支持 GUI 的对等文件共享系统。虽然这听起来很有挑战性,但其实并不难,不是吗?现在是时候面对最后也是最大的挑战了:编写你自己的街机游戏。

二十九、项目 10:自己动手的街机游戏

欢迎来到期末专题。既然您已经体验了 Python 众多功能中的几个,是时候大干一场了。在这一章中,你将学习如何使用 Pygame,一个可以让你用 Python 编写完整的全屏街机游戏的扩展。虽然很容易使用,但 Pygame 非常强大,由几个组件组成,这些组件在 Pygame 文档中有详细描述(可在 Pygame 网站上找到, http://pygame.org )。这个项目向您介绍了一些主要的 Pygame 概念,但是因为这一章只是作为一个起点,所以我跳过了几个有趣的特性,比如声音和视频处理。一旦熟悉了基础知识,我建议您自己研究一下其他特性。你可能还想看看 Will McGugan 和 Harrison Kinsley 的《Python 游戏开发入门》(Apress,2015)或 Paul Craven 的《用 Python 和 Pygame 编程街机游戏》(Apress,2016)。

有什么问题?

那么,如何编写一个电脑游戏呢?基本的设计过程类似于你编写其他程序时使用的过程,但是在你可以开发一个对象模型之前,你需要设计游戏本身。它的特点、背景和目标是什么?

在这里,我将保持事情相当简单,以免混淆基本 Pygame 概念的演示。如果你喜欢的话,可以随意创建一个更复杂的游戏。

我们要创建的游戏是基于著名的巨蟒剧团的小品《对新鲜水果的自卫》在这个小品中,一名军士长(约翰·克立斯饰)正在指导他的士兵如何自卫,以对付挥舞着新鲜水果的袭击者,这些水果包括石榴、糖水芒果、青果和香蕉。防御技术包括使用枪,释放老虎,并在攻击者身上放下 16 吨重的重物。在这个游戏中,我们将扭转局面——玩家控制着一只香蕉,它拼命试图在自卫过程中幸存下来,避免 16 吨重的重物从上面落下。我想一个合适的游戏名字可能是 Squish。

Note

如果你想在阅读本章的时候尝试自己的游戏,请随意。如果您只是想改变游戏的外观和感觉,只需替换图形(一些 GIF 或 PNG 图像)和一些描述性文本。

这个项目的具体目标围绕着游戏设计。游戏应该按照设计的那样运行(香蕉应该是可移动的,16 吨的重量应该从上面落下)。此外,代码应该是模块化的,易于扩展(一如既往)。一个有用的需求可能是游戏状态(比如游戏介绍、各种游戏关卡和“游戏结束”状态)应该是设计的一部分,并且新的状态应该易于添加。

有用的工具

这个项目中唯一需要的新工具是 Pygame,可以从 Pygame 网站( http://pygame.org )下载。要让 Pygame 在 UNIX 中运行,您可能需要安装一些额外的软件,但是这些都记录在 Pygame 安装说明中(也可以从 Pygame 网站上获得)。与大多数 Python 包一样,最简单的选择可能是使用pip简单地安装 Pygame。

Pygame 发行版包含几个模块,其中大部分在这个项目中是不需要的。以下部分描述了您确实需要的模块。(这里只讨论您需要的特定函数或类。)除了下面几节中描述的函数之外,所使用的各种对象(如曲面、组和精灵)还有几个有用的方法,我将在实现一节中讨论这些方法。

皮尤游戏

pygame模块自动导入所有其他 Pygame 模块,所以如果你把import pygame放在程序的顶部,你可以自动访问其他模块,比如pygame.displaypygame.font

pygame模块包含了Surface函数,它返回一个新的表面对象。表面对象只是给定大小的空白图像,可用于绘图和位图传送。blit(调用一个表面对象的blit方法)仅仅意味着将一个表面的内容转移到另一个表面。(blit 这个词来源于技术术语 block transfer,缩写为 BLT。)

功能是任何 Pygame 游戏的核心。它必须在游戏进入主事件循环之前被调用。该功能自动初始化所有其他模块(如fontimage)。

当您想要捕捉特定于 Pygame 的错误时,您需要使用error类。

pygame.locals

pygame.locals模块包含了您可能想在自己的模块范围内使用的名字(变量)。它包含事件类型、按键、视频模式等的名称。它被设计成当你导入所有东西(from pygame.locals import *)时可以安全使用,尽管如果你知道你需要什么,你可能想要更具体一些(例如,from pygame.locals import FULLSCREEN)。

pygame .显示器

pygame.display模块包含处理 Pygame 显示的函数,这些函数可以包含在普通窗口中,也可以占据整个屏幕。在这个项目中,您需要以下功能:

  • flip:更新显示。通常,当您修改当前屏幕时,可以分两步完成。首先,对从get_surface函数返回的表面对象执行所有必要的修改,然后调用pygame.display.flip来更新显示以反映您的更改。
  • update:当您只想更新屏幕的一部分时,代替flip。它可以与从RenderUpdates类的draw方法返回的矩形列表(在即将到来的pygame.sprite模块的讨论中描述)一起用作它的唯一参数。
  • set_mode:设置显示尺寸和显示类型。有几种可能的变化,但是这里您将限制自己使用FULLSCREEN版本和默认的“在窗口中显示”版本。
  • set_caption:设置 Pygame 程序的标题。当你在一个窗口(相对于全屏)中运行游戏时,set_caption功能非常有用,因为标题被用作窗口标题。
  • get_surface:在调用pygame.display.flippygame.display.blit之前,返回一个你可以在上面绘制图形的表面对象。这个项目中唯一用于绘图的表面方法是blit,它将一个表面对象中的图形转移到另一个给定位置的表面对象上。(此外,Group对象的draw方法将用于在显示面上绘制Sprite对象。)

皮游戏字体

pygame.font模块包含了Font函数。字体对象用于表示不同的字体。它们可以用来将文本呈现为图像,然后在 Pygame 中用作普通图形。

皮格,雪碧

pygame.sprite模块包含两个非常重要的类:SpriteGroup

Sprite类是所有可见游戏对象的基类——在这个项目中,是香蕉和 16 吨重的物体。为了实现你自己的游戏对象,你子类化Sprite,覆盖它的构造函数来设置它的imagerect属性(它们决定了Sprite的外观和放置位置),并覆盖它的update方法,每当 sprite 可能需要更新时就会调用这个方法。

Group类(及其子类)的实例被用作Sprite的容器。一般来说,使用组是一件好事。在简单的游戏中(比如在这个项目中),只需创建一个名为spritesallsprites或类似的群组,并将你所有的Sprite加入其中。当您调用Group对象的update方法时,您所有Sprite对象的update方法将被自动调用。此外,Group对象的clear方法用于擦除它包含的所有Sprite对象(使用回调进行擦除),而draw方法可用于绘制所有的Sprite对象。

在这个项目中,您将使用GroupRenderUpdates子类,它的draw方法返回一个受影响的矩形列表。然后,这些可以被传递到pygame.display.update以仅更新显示器中需要更新的部分。这可能会大大提高游戏的性能。

pygame 鼠标

在 Squish 中,您将使用pygame.mouse模块做两件事:隐藏鼠标光标和获取鼠标位置。你用pygame.mouse.set_visible(False)隐藏鼠标,用pygame.mouse.get_pos()得到位置。

pygame.event

pygame.event模块跟踪各种事件,比如鼠标点击、鼠标运动、按键被按下或释放等等。要获得最近事件的列表,使用函数pygame.event.get

Note

如果仅仅依靠pygame.mouse.get_pos返回的鼠标位置等状态信息,就不需要使用pygame.event.get。然而,你需要保持 Pygame 的更新(“同步”),这可以通过定期调用函数pygame.event.pump来实现。

游戏,图像

pygame.image模块用于处理以 GIF、PNG、JPEG 和其他几种文件格式存储的图像。在这个项目中,您只需要load函数,它读取一个图像文件并创建一个包含该图像的表面对象。

准备

现在你已经知道了一些不同的 Pygame 模块是做什么的,是时候开始破解第一个原型游戏了。但是,在启动原型并运行之前,您需要做一些准备工作。首先你要确定你安装了 Pygame,包括imagefont模块。(您可能希望在交互式 Python 解释器中导入这两者,以确保它们可用。)

你还需要几张图片。如果你想坚持这一章提出的游戏主题,你需要一张描绘 16 吨重的图片和一张描绘香蕉的图片,这两张图片都显示在图 29-1 中。它们的确切大小并不重要,但您可能希望它们保持在 100 × 100 到 200 × 200 像素的范围内。这两个图像应该以通用的图像文件格式提供,比如 GIF、PNG 或 JPEG。

A326949_3_En_29_Fig1_HTML.jpg

图 29-1。

The weight and banana graphics used in my version of the game Note

你可能还需要一个单独的启动画面,第一个欢迎游戏用户的画面。在这个项目中,我也简单地使用了重量符号。

首次实施

当您使用 Pygame 这样的新工具时,保持第一个原型尽可能简单并专注于学习新工具的基础知识,而不是程序本身的复杂性,通常会有所回报。让我们将 Squish 的第一个版本限制为一个 16 吨重的重物从上面落下的动画。为此所需的步骤如下:

  1. 使用pygame.initpygame.display.set_modepygame.mouse.set_visible初始化 Pygame。用pygame.display.get_surface得到屏幕表面。用纯白填充屏幕表面(用fill方法)并调用pygame.display.flip显示这一变化。
  2. 加载重量图像。
  3. 使用图像创建一个自定义Weight类的实例(一个Sprite的子类)。将该对象添加到一个名为spritesRenderUpdates组中。(这在处理多个精灵的时候会特别有用。)
  4. pygame.event.get获取所有最近的事件。依次检查所有事件。如果发现类型为QUIT的事件,或者如果发现代表退出键(K_ESCAPE)的类型为KEYDOWN的事件,退出程序。(事件类型和键保存在事件对象的属性typekey中。可以从pygame.locals模块导入QUITKEYDOWNK_ESCAPE等常量。)
  5. 调用sprites组的clearupdate方法。clear方法使用回调来清除所有精灵(在本例中是权重),update方法调用Weight实例的update方法。(后一种方法必须自己实现。)
  6. 以屏幕表面为参数调用sprites.draw,在当前位置绘制Weight sprite。(每次调用update时,这个位置都会改变。)
  7. 用从sprites.draw返回的矩形列表调用pygame.display.update,只在正确的地方更新显示。(如果你不需要这个性能,你可以在这里使用pygame.display.flip来更新整个显示。)
  8. 重复步骤 4 到 7。

参见清单 29-1 中实现这些步骤的代码。如果用户退出游戏,例如关闭窗口,就会发生QUIT事件。

import sys, pygame
from pygame.locals import *
from random import randrange

class Weight(pygame.sprite.Sprite):

    def __init__(self, speed):
        pygame.sprite.Sprite.__init__(self)
        self.speed = speed
        # image and rect used when drawing sprite:
        self.image = weight_image
        self.rect = self.image.get_rect()
        self.reset()

    def reset(self):
        """
        Move the weight to a random position at the top of the screen.
        """
        self.rect.top = -self.rect.height
        self.rect.centerx = randrange(screen_size[0])

    def update(self):
        """
        Update the weight for display in the next frame.
        """
        self.rect.top += self.speed

        if self.rect.top > screen_size[1]:
            self.reset()

# Initialize things
pygame.init()
screen_size = 800, 600
pygame.display.set_mode(screen_size, FULLSCREEN)
pygame.mouse.set_visible(0)

# Load the weight image
weight_image = pygame.image.load('weight.png')
weight_image = weight_image.convert() # ... to match the display

# You might want a different speed, of courase
speed = 5

# Create a sprite group and add a Weight
sprites = pygame.sprite.RenderUpdates()
sprites.add(Weight(speed))

# Get the screen surface and fill it
screen = pygame.display.get_surface()
bg = (255, 255, 255) # White
screen.fill(bg)
pygame.display.flip()

# Used to erase the sprites:
def clear_callback(surf, rect):
    surf.fill(bg, rect)

while True:
    # Check for quit events:
    for event in pygame.event.get():
        if event.type == QUIT:
            sys.exit()
        if event.type == KEYDOWN and event.key == K_ESCAPE:
            sys.exit()
    # Erase previous positions:
    sprites.clear(screen, clear_callback)
    # Update all sprites:
    sprites.update()
    # Draw all sprites:
    updates = sprites.draw(screen)
    # Update the necessary parts of the display:
    pygame.display.update(updates)

Listing 29-1.A Simple “Falling Weights” Animation (weights.py)

您可以使用以下命令运行该程序:

$ python weights.py

在执行这个命令时,应该确保weights.pyweight.png(权重图像)都在当前目录中。图 29-2 显示了结果的截图。

A326949_3_En_29_Fig2_HTML.jpg

图 29-2。

A simple animation of falling weights

大部分代码应该是不言自明的。然而,有几点需要解释一下:

  • 所有的 sprite 对象都应该有两个名为imagerect的属性。前者应该包含一个 surface 对象(一个图像),后者应该包含一个 rectangle 对象(只需用self.image.get_rect()初始化即可)。这两个属性将在绘制精灵时使用。通过修改self.rect,可以四处移动精灵。
  • 表面对象有一个叫做convert的方法,可以用来创建一个不同颜色模型的副本。您不需要担心细节,但是使用没有任何参数的convert会创建一个为当前显示定制的表面,并且显示它会尽可能快。
  • 颜色通过 RGB 三元组(红绿蓝,每个值为 0-255)来指定,因此元组(255, 255, 255)表示白色。

通过为矩形的属性(topbottomleftrighttoplefttoprightbottomleftbottomrightsizewidthheightcentercenterxcenterymidleftmidrightmidtopmidbottom)赋值,或者调用inflatemove等方法,可以修改矩形(例如本例中的self.rect)。(这些都在 http://pygame.org/docs/ref/rect.html 的 Pygame 文档中有描述。)

既然 Pygame 的技术细节已经到位,是时候扩展和重构游戏逻辑了。

第二次实施

在这一节中,我没有一步一步地向您介绍设计和实现,而是向源代码添加了大量的注释和文档字符串,如清单 29-2 到 29-4 所示。您可以检查源(“使用源”,还记得吗?)来看看它是如何工作的,但这里有一个要点的简短纲要(和一些不太直观的细节):

  • 游戏由五个文件组成:config.py,包含各种配置变量;objects.py,包含游戏对象的实现;squish.py,包含主Game类和各种游戏状态类;还有weight.pngbanana.png,游戏中用到的两个形象。
  • rectangle 方法clamp确保一个矩形被放置在另一个矩形内,如果必要的话移动它。这用于确保香蕉不会移出屏幕。
  • rectangle 方法inflate在水平和垂直方向上按给定的像素数调整矩形的大小。这用于缩小香蕉边界,以便在记录点击(或“挤压”)之前,允许香蕉和权重之间有一些重叠。
  • 游戏本身由一个游戏对象和各种游戏状态组成。游戏对象一次只有一个状态,状态负责处理事件并在屏幕上显示自己。一个状态也可以告诉游戏切换到另一个状态。(Level状态可以例如告诉游戏切换到GameOver状态。)

就这样。您可以通过执行squish.py文件来运行游戏,如下所示:

$ python squish.py

您应该确保其他文件在同一目录中。在 Windows 中,您可以简单地双击squish.py文件。

# Configuration file for Squish
# -----------------------------

# Feel free to modify the configuration variables below to taste.
# If the game is too fast or too slow, try to modify the speed
# variables.

# Change these to use other images in the game:
banana_image = 'banana.png'
weight_image = 'weight.png'
splash_image = 'weight.png'

# Change these to affect the general appearance:
screen_size = 800, 600
background_color = 255, 255, 255
margin = 30
full_screen = 1
font_size = 48

# These affect the behavior of the game:
drop_speed = 1
banana_speed = 10
speed_increase = 1
weights_per_level = 10
banana_pad_top = 40
banana_pad_side = 20

Listing 29-2.The Squish Configuration File (config.py)

import pygame, config, os
from random import randrange

"This module contains the game objects of the Squish game."

class SquishSprite(pygame.sprite.Sprite):

    """
    Generic superclass for all sprites in Squish. The constructor
    takes care of loading an image, setting up the sprite rect, and
    the area within which it is allowed to move. That area is governed
    by the screen size and the margin.
    """

    def __init__(self, image):
        super().__init__()
        self.image = pygame.image.load(image).convert()
        self.rect = self.image.get_rect()
        screen = pygame.display.get_surface()
        shrink = -config.margin * 2
        self.area = screen.get_rect().inflate(shrink, shrink)

class Weight(SquishSprite):

    """
    A falling weight. It uses the SquishSprite constructor to set up
    its weight image, and will fall with a speed given as a parameter
    to its constructor.
    """

    def __init__(self, speed):
        super().__init__(config.weight_image)
        self.speed = speed
        self.reset()

    def reset(self):
        """
        Move the weight to the top of the screen (just out of sight)
        and place it at a random horizontal position.
        """
        x = randrange(self.area.left, self.area.right)
        self.rect.midbottom = x, 0

    def update(self):
        """
        Move the weight vertically (downwards) a distance
        corresponding to its speed. Also set the landed attribute
        according to whether it has reached the bottom of the screen.
        """
        self.rect.top += self.speed
        self.landed = self.rect.top >= self.area.bottom

class Banana(SquishSprite):

    """
    A desperate banana. It uses the SquishSprite constructor to set up
    its banana image, and will stay near the bottom of the screen,
    with its horizontal position governed by the current mouse
    position (within certain limits).
    """

    def __init__(self):
        super().__init__(config.banana_image)
        self.rect.bottom = self.area.bottom
        # These paddings represent parts of the image where there is
        # no banana. If a weight moves into these areas, it doesn't
        # constitute a hit (or, rather, a squish):
        self.pad_top = config.banana_pad_top
        self.pad_side = config.banana_pad_side

    def update(self):
        """
        Set the Banana's center x-coordinate to the current mouse
        x-coordinate, and then use the rect method clamp to ensure
        that the Banana stays within its allowed range of motion.
        """
        self.rect.centerx = pygame.mouse.get_pos()[0]
        self.rect = self.rect.clamp(self.area)

    def touches(self, other):

        """
        Determines whether the banana touches another sprite (e.g., a
        Weight). Instead of just using the rect method colliderect, a
        new rectangle is first calculated (using the rect method
        inflate with the side and top paddings) that does not include
        the 'empty' areas on the top and sides of the banana.
        """
        # Deflate the bounds with the proper padding:
        bounds = self.rect.inflate(-self.pad_side, -self.pad_top)
        # Move the bounds so they are placed at the bottom of the Banana:
        bounds.bottom = self.rect.bottom
        # Check whether the bounds intersect with the other object's rect:
        return bounds.colliderect(other.rect)

Listing 29-3.The Squish Game Objects

(objects.py)

import os, sys, pygame
from pygame.locals import *
import objects, config

"This module contains the main game logic of the Squish game."

class State:

    """
    A generic game state class that can handle events and display
    itself on a given surface.
    """

    def handle(self, event):
        """
        Default event handling only deals with quitting.
        """
        if event.type == QUIT:
            sys.exit()
        if event.type == KEYDOWN and event.key == K_ESCAPE:
            sys.exit()

    def first_display(self, screen):
        """
        Used to display the State for the first time. Fills the screen
        with the background color.
        """
        screen.fill(config.background_color)
        # Remember to call flip, to make the changes visible:
        pygame.display.flip()

    def display(self, screen):
        """
        Used to display the State after it has already been displayed
        once. The default behavior is to do nothing.
        """
        pass

class Level(State):
    """
    A game level. Takes care of counting how many weights have been
    dropped, moving the sprites around, and other tasks relating to
    game logic.
    """

    def __init__(self, number=1):
        self.number = number
        # How many weights remain to dodge in this level?
        self.remaining = config.weights_per_level

        speed = config.drop_speed
        # One speed_increase added for each level above 1:
        speed += (self.number-1) * config.speed_increase
        # Create the weight and banana:
        self.weight = objects.Weight(speed)
        self.banana = objects.Banana()
        both = self.weight, self.banana # This could contain more sprites...
        self.sprites = pygame.sprite.RenderUpdates(both)

    def update(self, game):
        "Updates the game state from the previous frame."
        # Update all sprites:
        self.sprites.update()
        # If the banana touches the weight, tell the game to switch to
        # a GameOver state:
        if self.banana.touches(self.weight):
            game.next_state = GameOver()
        # Otherwise, if the weight has landed, reset it. If all the
        # weights of this level have been dodged, tell the game to
        # switch to a LevelCleared state:
        elif self.weight.landed:
            self.weight.reset()
            self.remaining -= 1
            if self.remaining == 0:
                game.next_state = LevelCleared(self.number)

    def display(self, screen):

        """
        Displays the state after the first display (which simply wipes
        the screen). As opposed to firstDisplay, this method uses
        pygame.display.update with a list of rectangles that need to
        be updated, supplied from self.sprites.draw.
        """
        screen.fill(config.background_color)
        updates = self.sprites.draw(screen)
        pygame.display.update(updates)

class Paused(State):
    """
    A simple, paused game state, which may be broken out of by pressing
    either a keyboard key or the mouse button.
    """

    finished = 0 # Has the user ended the pause?
    image = None # Set this to a file name if you want an image
    text = ''    # Set this to some informative text

    def handle(self, event):
        """
        Handles events by delegating to State (which handles quitting
        in general) and by reacting to key presses and mouse
        clicks. If a key is pressed or the mouse is clicked,
        self.finished is set to true.
        """
        State.handle(self, event)
        if event.type in [MOUSEBUTTONDOWN, KEYDOWN]:
            self.finished = 1

    def update(self, game):
        """
        Update the level. If a key has been pressed or the mouse has
        been clicked (i.e., self.finished is true), tell the game to
        move to the state represented by self.next_state() (should be
        implemented by subclasses).
        """
        if self.finished:
            game.next_state = self.next_state()

    def first_display(self, screen):
        """
        The first time the Paused state is displayed, draw the image
        (if any) and render the text.
        """
        # First, clear the screen by filling it with the background color:
        screen.fill(config.background_color)

        # Create a Font object with the default appearance, and specified size:
        font = pygame.font.Font(None, config.font_size)

        # Get the lines of text in self.text, ignoring empty lines at
        # the top or bottom:
        lines = self.text.strip().splitlines()

        # Calculate the height of the text (using font.get_linesize()
        # to get the height of each line of text):
        height = len(lines) * font.get_linesize()

        # Calculate the placement of the text (centered on the screen):
        center, top = screen.get_rect().center
        top -= height // 2

        # If there is an image to display...
        if self.image:
            # load it:
            image = pygame.image.load(self.image).convert()
            # get its rect:
            r = image.get_rect()
            # move the text down by half the image height:
            top += r.height // 2
            # place the image 20 pixels above the text:
            r.midbottom = center, top - 20
            # blit the image to the screen:
            screen.blit(image, r)

        antialias = 1   # Smooth the text
        black = 0, 0, 0 # Render it as black

        # Render all the lines, starting at the calculated top, and
        # move down font.get_linesize() pixels for each line:
        for line in lines:
            text = font.render(line.strip(), antialias, black)
            r = text.get_rect()
            r.midtop = center, top
            screen.blit(text, r)
            top += font.get_linesize()

        # Display all the changes:
        pygame.display.flip()

class Info(Paused):

    """
    A simple paused state that displays some information about the
    game. It is followed by a Level state (the first level).
    """

    next_state = Level
    text = '''
    In this game you are a banana,
    trying to survive a course in
    self-defense against fruit, where the
    participants will "defend" themselves
    against you with a 16 ton weight.'''

class StartUp(Paused):

    """
    A paused state that displays a splash image and a welcome
    message. It is followed by an Info state.
    """

    next_state = Info
    image = config.splash_image
    text = '''
    Welcome to Squish,
    the game of Fruit Self-Defense'''

class LevelCleared(Paused):
    """
    A paused state that informs the user that he or she has cleared a
    given level. It is followed by the next level state.
    """

    def __init__(self, number):
        self.number = number
        self.text = '''Level {} cleared
        Click to start next level'''.format(self.number)

    def next_state(self):
        return Level(self.number + 1)

class GameOver(Paused):

    """
    A state that informs the user that he or she has lost the
    game. It is followed by the first level.
    """

    next_state = Level
    text = '''
    Game Over
    Click to Restart, Esc to Quit'''

class Game:

    """
    A game object that takes care of the main event loop, including
    changing between the different game states.
    """

    def __init__(self, *args):
        # Get the directory where the game and the images are located:
        path = os.path.abspath(args[0])
        dir = os.path.split(path)[0]
        # Move to that directory (so that the image files may be
        # opened later on):
        os.chdir(dir)
        # Start with no state:
        self.state = None
        # Move to StartUp in the first event loop iteration:
        self.next_state = StartUp()

    def run(self):
        """
        This method sets things in motion. It performs some vital
        initialization tasks, and enters the main event loop.
        """
        pygame.init() # This is needed to initialize all the pygame modules

        # Decide whether to display the game in a window or to use the
        # full screen:
        flag = 0                  # Default (window) mode

        if config.full_screen:
            flag = FULLSCREEN     # Full screen mode
        screen_size = config.screen_size
        screen = pygame.display.set_mode(screen_size, flag)

        pygame.display.set_caption('Fruit Self Defense')
        pygame.mouse.set_visible(False)

        # The main loop:
        while True:
            # (1) If nextState has been changed, move to the new state, and
            #     display it (for the first time):
            if self.state != self.next_state:
                self.state = self.next_state
                self.state.first_display(screen)
            # (2) Delegate the event handling to the current state:
            for event in pygame.event.get():
                self.state.handle(event)
            # (3) Update the current state:
            self.state.update(self)
            # (4) Display the current state:
            self.state.display(screen)

if __name__ == '__main__':
    game = Game(*sys.argv)

    game.run()

Listing 29-4.The Main Game Module (squish.py)

游戏的部分截图如图 29-3 到 29-6 所示。

A326949_3_En_29_Fig6_HTML.jpg

图 29-6。

The “game over” screen

A326949_3_En_29_Fig5_HTML.jpg

图 29-5。

The “level cleared” screen

A326949_3_En_29_Fig4_HTML.jpg

图 29-4。

A banana about to be squished

A326949_3_En_29_Fig3_HTML.jpg

图 29-3。

The Squish opening screen

进一步探索

以下是一些改进游戏的方法:

  • 给它加上声音。
  • 记录分数。例如,每躲开一个重量可以值 16 点。留个高分档案怎么样?或者甚至是一个在线高分服务器(使用asyncore或 XML-RPC,分别在第二十四章和第二十七章中讨论)?
  • 让更多的物体同时落下。
  • 颠倒一下逻辑:让玩家尝试被击中,而不是躲避,就像彼得·古德(Peter Goode)的老 Memotech 游戏《抓蛋者》(Egg Catcher)一样,这是 Squish 的主要灵感来源。
  • 给玩家不止一条“命”
  • 创建游戏的独立可执行文件。(详见第十八章。)

关于 Pygame 编程的一个更复杂(也非常有趣)的例子,可以看看 Pygame 维护者 Pete Shinners 的 SolarWolf 游戏( http://www.pygame.org/ shredwheat/solarwolf)。你可以在 Pygame 网站上找到大量的信息和其他几款游戏。如果玩 Pygame 让你迷上了游戏开发,你可能会想去看看像 http://www.gamedev.nethttp://gamedev.stackexchange.com 这样的网站。网络搜索应该会给你很多类似的网站。

什么现在?

嗯,就是这样。你已经完成了最后一个项目。如果你评估你已经完成的事情(假设你已经跟踪了所有的项目),你应该会对自己留下深刻的印象。所展示主题的广度让您领略了 Python 编程世界中等待您的各种可能性。我希望到目前为止您已经享受了这次旅行,并祝您在作为 Python 程序员的继续旅程中好运。

posted @ 2024-08-09 17:41  绝不原创的飞龙  阅读(6)  评论(0编辑  收藏  举报