你好-Python-全-
你好 Python(全)
原文:Hello! Python
译者:飞龙
第一章. 为什么选择 Python?
本章涵盖
-
计算机和程序是什么,以及为什么你想编写一个程序
-
Python 是什么以及为什么它如此出色
-
安装 Python
如果你拿起这本书,你很可能是在尝试学习如何编程。恭喜你!试图学习编程的人不多,但这是你可以自学的一些最有意思和最有回报的主题之一。编程是新的读写能力;如果你不确定如何编写一个简单的程序,无论是批处理文件、邮件过滤器还是电子表格中的公式,那么与那些会的人相比,你处于不利地位。编程也是一种杠杆。通过编程,你可以将你的想法变成现实。

我第一次开始编程是在大约 10 岁的时候,使用的是 Commodore 64。那时候,可用的预编程软件并不多,除非你把游戏或简单的文字处理软件算在内。像 Commodore 这样的电脑内置了 BASIC,编程变得更容易接近——你不需要学习很多就能快速得到结果。
从那时起,计算机已经偏离了那个早期的理想。现在你必须费尽周折去安装某些东西,以便你的电脑能够编程。但一旦你掌握了方法,你就可以创建各种奇妙的程序,这些程序会为你做枯燥的工作,提供信息,并娱乐你。尤其是最后这部分——编程很有趣,每个人都应该尝试一下。
你会注意到书中穿插的卡通。我使用这些来给你提供有关章节中正在发生的事情的背景信息,或者覆盖一些常见问题,同时还能有点乐趣。虽然角色来自《用户友好》,但文本和笑话都是我自己的——所以如果你不喜欢它们,你知道该怪谁了。
让我们从学习编程的基础开始。
学习编程
因为这本书是关于编程的,所以在我们深入学习第二章的细节之前,给你一个概述是有意义的。什么是编程?它是如何工作的?编程的定义很简单。

定义
编程就是告诉电脑做什么。
但是,像大多数定义一样,这是一个极端的简化。就像下棋一样,学习编程的初始规则很容易;但将它们组合在一起以产生有用的效果并掌握它们则要困难得多。如今,编程触及了人类努力的几乎所有领域——如果你想在电脑上创建有意义的东西,那么在某种程度上进行编程是非常困难的——它同样关乎设计、想法和个人表达,而不仅仅是数字和计算。
告诉电脑做什么
让我们分解我们定义的不同部分,并逐一审视它们。为了理解我们的定义,我们需要知道什么是计算机;我们所说的“告诉”它做什么是什么意思;以及“做什么”具体包括什么。
计算机是什么
计算机是一个快速的计算器,可以根据你的指令做出简单的决策。计算机指令很简单,通常包括像加法运算和比较这样的任务。但指令集可以组合起来创建能够做复杂事情的大型程序,比如写文档、玩游戏、平衡你的账户和控制核反应堆。
计算机看起来很聪明,但实际上它们很愚蠢、单一思维,并且缺乏常识。毕竟,它们只是机器;它们会完全按照你(或 Python 的开发者)告诉它们去做——无论后果如何。考虑一个删除整个硬盘的命令。大多数人都会认为这有点过于激进,他们可能会在继续之前检查一下这是否是你想要的。但计算机会直接继续并销毁你所有的数据,不会问任何问题。
注意
计算机的好处是它们会完全按照你告诉它们的那样做。计算机的坏处也是它们会完全按照你告诉它们的那样做。
如果你正在使用(或编写)的程序正在做奇怪的事情或者无缘无故地崩溃,这并不是针对个人的——它只是遵循它被给予的指令。
告知
当使用 Python 时,你通常会通过在文本文件中输入程序代码并告诉 Python 程序运行它来指导它;你将在本章后面了解如何做这件事。你输入的指令可以是复杂的或简单的,并且涵盖了广泛的任务——加法运算、打开其他文件、在屏幕上放置东西等等。一个简单的 Python 程序看起来像这样:
number = "42"
print "Guess my number..."
guess = raw_input(">")
if guess == number:
print "Yes! that's it!"
else:
print "No - it's", number
raw_input("hit enter to continue")
不要过于担心试图理解这个程序;这个例子只是旨在为你提供一些背景信息。
要做什么
这就是乐趣的开始。大多数现代计算机都是“图灵完备”的,这意味着它们可以做任何事情;任何你能想到的事情,计算机都能做。至少在理论上——它可能比你最初预期的要花更长的时间或更复杂,或者如果你想要以某种方式交互,可能需要特殊的硬件,但如果计算机能够访问足够的数据,并且你正确地编程了它,那么就没有什么是不可能的。以下是一些计算机被用于的任务:
-
控制载人航天器和无人航天器、探测器,并在其他星球上引导机器人,包括火星探测车“勇气号”和“机遇号”。
-
通过计算机网络的互联网和万维网传输数据!在线,你可以在几秒钟内从世界各地传输或接收信息。
-
构建机器人,从工业机器人臂到 Roomba 吸尘器,再到能够爬楼梯或模仿人类情感的逼真人类机器人。
-
模拟现实世界的过程,如重力、光和天气。这包括科学模型,但也包括大多数游戏。

你可能没有将机器人探测器发送到另一个星球所需的硬件,但至少在原则上,你仍然可以运行相同的程序。令人难以置信的是,驱动 Spirit 和 Opportunity 的计算机,例如,比坐在你桌上的、放在你腿上的,甚至在你口袋里的(你的手机)上的计算机都要弱得多。
编程由想法组成
专注于计算机编程的具体方面——指令、加法、网络、硬件等——很容易,但编程的核心是关于想法:具体来说,就是成功地捕捉这些想法,以便其他人可以使用它们。自早期人类开始使用尖锐的棍棒以来,通过发现新事物、酷事物来帮助他人就已经发生,编程也不例外。计算机自发明以来就帮助发展了许多新想法,包括互联网、电子表格、交互式游戏和桌面出版。

很遗憾,我无法帮助你产生新的想法,但我可以向你展示一些其他人提出的思想,作为你开发自己想法的灵感。
编程是设计
我们在这本书中将要涵盖的大多数编程方面都涉及设计。设计通常被描述为对特定问题的常见解决方案。例如,建筑是建筑及其所占空间的设计。它解决了建筑中的一些常见问题,例如人们如何进出和移动,如何占用空间,如何让人们喜欢在建筑中,如何合理使用材料,等等。
什么使设计变得良好——以及什么使一个设计比另一个设计更好——是它是否有效地解决了你的问题。这意味着设计永远不会完成;总有其他、可能更好的方法来解决问题。始终质疑你所设计的内容。解决方案准确吗?或者它只是解决了你问题的一部分?你的设计有多容易构建?如果它在某些方面提高了 10%,但实施起来要困难两倍,那么你可能选择更简单的方案。
如果编程是想法的设计,那么它解决了哪些问题?你可能会遇到的一些问题包括以下内容:
-
你的想法还不够成熟——还有一些细节需要完善。
-
大多数想法都很复杂,一旦开始写下来,就会涉及很多细节。
-
你的想法需要清晰易懂,以便其他人可以使用、理解并在此基础上进行构建。
程序需要做的关键事情是以尽可能清晰和简单的方式表达你的想法。在计算机语言的发展中,管理复杂性是一个常见的主题。即使是在编写简单的程序时,也很容易陷入细节而失去目标。当需要修改程序时,你可能会误解程序的原意,引入错误或不一致。一个好的编程语言将具有帮助你在不同细节级别工作的功能,允许你在必要时移动到更详细(或更少详细)的级别。
另一个重要因素是,当用特定的语言编写程序时,你的程序有多灵活。探索性编程在开发想法时是一个有用的工具,在这本书中我们将进行很多这样的编程——但如果你的编程语言没有强大的管理复杂性的工具或隐藏细节的功能,那么它们就很难更改,很多好处就会丧失。
现在你对编程有了基本的了解,是时候检查这本书选择的语言——Python 了。
什么让 Python 如此出色?

在这本书中,你将学习 Python,这并非巧合,恰好也是我最喜欢的编程语言。由于许多原因,它非常适合刚开始编程的新手。
Python 易于使用
如果你将 Python 与其他编程语言进行比较,你首先会注意到它很容易阅读。Python 的语法旨在尽可能清晰。以下是一些使 Python 特别用户友好的特性:
-
它避免了使用大括号{}、美元符号$、斜杠/和反斜杠\等标点符号。
-
Python 使用空白字符来缩进程序控制行,而不是使用括号。
-
鼓励程序员使他们的程序清晰易读。
-
Python 支持多种不同的程序结构方式,因此你可以为任务选择最佳的一种。
Python 的开发者试图“正确”地做事,通过尽可能使编程变得简单易懂。有几个案例,在核心开发者弄清楚如何最好地展示某个特性之前,特性被推迟(甚至完全取消)。Python 甚至有自己的关于程序应该如何看起来和表现的理念。一旦安装了 Python,尝试输入“import this”(在章节的后面部分)。
Python 是一种真实语言
虽然 Python 是一种易于使用的语言,但它也是一种“真实”的语言。通常,语言有两种风味:一种是带有辅助轮的简单语言,用于教授人们如何编程;另一种是功能更强大的语言,让你能够完成实际工作。当你学习如何编程时,你有两种选择:
-
直接跳入一门真正的语言,但要做好准备,直到你弄清楚这门难的语言之前,你可能会感到困惑。
-
从一门入门语言开始,但当你需要它没有的功能时,准备好放弃你所做的一切工作。
Python 跳过了缺点,并成功结合了这些方法的最佳方面。它易于使用和学习,但随着你的编程技能增长,你将能够继续使用 Python,因为它速度快且功能丰富。最好的是,直接学习如何以正确的方式做事通常比遵循所有需要学习如何“正确”编程的步骤要容易得多。
Python 包含“电池”
Python 包含大量库,还有更多你可以下载和安装的。库是其他程序员编写的程序代码,你可以轻松重用。它们让你能够读取文件、处理数据、通过互联网连接到其他计算机、提供网页、生成随机数,以及进行几乎所有其他基本活动。Python 是以下情况的好选择:
-
网络开发
-
网络编程
-
图形界面
-
脚本操作系统任务
-
游戏
-
数据处理
-
商业应用

通常,当编写程序的时候,大部分困难的部分已经为你准备好了,你所要做的就是将几个库组合起来,以便能够完成你需要的功能。你将在第三章 chapter 3 中了解更多关于 Python 库及其使用方法。
Python 拥有一个庞大的社区
Python 是一种流行的语言,拥有一个庞大、友好的社区,乐于帮助新 Python 开发者。在主要邮件列表上总是欢迎提问,还专门设立了一个邮件列表,专门帮助新开发者。互联网上还有大量的介绍和教程,以及大量的示例代码。
小贴士
“好艺术家借鉴,伟大的艺术家偷窃。”由于 Python 开发者社区的大小,无论你编写什么类型的程序,都有很多程序可以借鉴、借用和偷窃。一旦你有一些 Python 经验,阅读其他人的程序是学习更多知识的极好方式。
拥有一个庞大社区的其他优势之一是 Python 获得了大量的积极开发,因此错误被迅速修复,新功能定期添加。Python 不断改进。
现在你已经了解了编程以及为什么 Python 是一个好的选择,让我们在你的计算机上安装 Python,这样你就可以运行自己的程序了。如果你正在运行 Linux,请跳过一节。如果你正在运行 Mac,请跳过两节。
在 Windows 上设置 Python
在接下来的几节中,我们将逐步介绍安装过程,创建一个简单的程序以确保 Python 在你的系统上运行,并教你运行程序的基本步骤。现在确保 Python 正确运行将为你节省很多后续的挫败感。
安装 Python
我们将使用 Python 2 的最新版本,因为我们将在本书中使用的大多数库还不支持 Python 3。在撰写本文时,Python 2.6 是标准版本,但 Python 2.7 应该在您阅读本文时可用。要安装 Python,我们需要从 Python 网站下载一个程序并运行它。该程序包括 Python、其库以及运行 Python 程序所需的一切。
第一步是访问 python.org/ 并点击“下载”。这将带您到一个列出 Python 可以安装的所有操作系统的页面。点击 Windows 版本,并将其保存到您的桌面。
图 1.1. Python.org 的下载页面

下载完成后,双击程序图标以打开和运行它。您可能会看到一个类似于 图 1.2 的屏幕。点击“运行”以运行 Python 安装程序。
图 1.2. 您确定要运行来自互联网的奇怪程序吗?是的!

现在您将获得一系列安装 Python 的选项。通常,默认选项(已经为您选择的选项)就足够好了,除非您的计算机磁盘空间不足,需要安装到不同的分区。如果您对每个步骤的选项都感到满意,请点击“下一步”以进入下一屏幕。
图 1.3. 为所有用户安装 Python

图 1.4. 选择 Python 的安装位置。

图 1.5. 选择您想要安装的 Python 版本。

图 1.6. 安装 Python

最终阶段可能需要一些时间,具体取决于您计算机的速度,但一旦您看到 图 1.7,您就完成了。
图 1.7. 欢呼!Python 已经安装了!


恭喜!您已成功安装 Python!
在 Windows 上运行 Python 程序
现在您已经在系统上安装了 Python,让我们创建一个简单的程序。这将让您知道 Python 已经正确安装,并展示如何创建和运行程序。
Python 程序通常写入文本文件,然后由 Python 解释器运行。首先,您将使用记事本创建您的文件(但如果您已经有了一个喜欢的文本编辑器,您也可以使用它)。避免使用 Microsoft Word 或 Wordpad 创建程序——它们会插入额外的格式化字符,Python 无法理解。记事本位于您的开始菜单的“程序 > 附件”部分。
图 1.8. 这就是记事本所在的位置。

在打开的记事本窗口中,输入以下代码。现在不必太在意它具体做什么——目前您只想测试 Python 并确保您能够运行程序。输入以下内容:
print "Hello World!"
raw_input("hit enter to continue")
图 1.9. Python 的测试程序

图 1.10. 将您的测试程序保存到桌面。


当您完成时,将其保存到桌面上的 hello_world.py。末尾的 .py 很重要——这是 Windows 知道它是一个 Python 程序的方式。
如果您查看桌面,应该能看到带有蓝色和黄色 Python 图标的程序。双击文档图标,程序应该会运行。

图 1.11. 通过双击运行您的脚本。

恭喜!Python 已正确安装在您的计算机上!继续阅读以了解如何从命令行运行 Python——当事情出错时,它可以是重要的故障排除工具。如果您没有看到输出,请不要担心——“故障排除”部分有一些常见问题和它们的解决方案。
从命令行运行 Python 程序
您也可以从命令行运行 Python 程序。当您有一个主要处理文本输入和输出的程序,或者作为操作系统脚本运行,或者需要大量输入时,这通常更容易;使用命令行选项编程可能比自定义设置窗口更容易。
注意
有许多不同的方式来访问和运行程序。通过 GUI 双击是一种方式;命令行是另一种。您将在本书的学习过程中了解几种。
当您有一个有错误的程序时,从命令行运行也更容易,因为您会看到一个错误消息,而不是没有窗口或窗口立即关闭。
Windows 命令行程序可在 Windows 开始菜单下的“程序文件”>“附件”中找到。
如果您运行那个程序,您应该会看到一个带有一些白色文本的黑色窗口。输入 cd Desktop 以切换到桌面目录,然后输入 python hello_world.py 以打开 Python 并告诉它运行您之前创建的脚本文件。
图 1.12. Windows 命令行所在位置

当您这样做时,两种情况中的一种会发生:要么您的程序会运行,这样您就完成了;要么您会看到一个错误消息,说找不到 Python 程序。如果发生这种情况,不要慌张——您只需要告诉 Windows Python 的位置在哪里。
图 1.13. Windows 不知道 Python 在哪里!

您需要更改 Windows 的路径设置。路径 是 Windows 查找您请求运行的程序的一组位置。首先,右键单击“我的电脑”图标,然后点击“属性”。
图 1.14. 查看您的计算机属性

图 1.15. 编辑您的系统属性

然后选择“高级”选项卡,并点击底部的“环境变量”按钮。您应该会看到一个类似于图右侧的环境变量列表。
在下半部分,查找名为“Path”的行,并双击它。在出现的编辑框中,您需要在行尾添加 ;c:\python26 并点击“确定”。
图 1.16. 打开 PATH 变量

注意
路径是 Windows 用来查找文件的方式。你的电脑上的每个单独文件都有一个路径。你将在第三章中学习更多关于路径以及如何使用它们的内容。
图 1.17. 将 Python 添加到 PATH 变量

完成这些操作后,点击你打开的所有窗口中的“确定”按钮,直到你回到桌面。打开另一个命令提示符窗口(旧的窗口仍然保留旧的路径设置),再次输入python hello_world.py。你应该能看到你程序的输出。

恭喜!你现在可以开始编程了。不过,你可能首先想阅读“故障排除”部分,以找到更好的程序来编辑你的 Python 程序。
接下来,我们将回顾如何在 Linux 机器上安装 Python。
Linux
使用 Linux 与 Python 的结合比较难以精确描述,因为市面上有大量的 Linux 发行版,它们在操作上略有不同。我选择使用 Gnome 和 Ubuntu 作为示例;其他 Linux 发行版也类似。
Linux 下的安装
根据你运行的发行版,在 Linux 上安装 Python 通常不是必需的。大多数发行版默认都会安装某个版本的 Python,尽管它可能已经落后了几次修订。你可以使用python -V来查看你安装的是哪个版本。
在 Linux 下主要有两种安装方法:你可以使用软件包或者从源代码编译。
软件包管理器使用简单,并能为你处理大部分依赖和编译问题。在 Debian 的 apt-get 系统中,你可以输入类似sudo apt-get install python的命令,自动安装最新版本的 Python。你也可以使用apt-cache search python来查找其他可用的软件包,因为通常还有其他一些你可能会想安装的软件包(如 python-dev 或 python-docs)。
从源代码编译也是一个选项,但它超出了本书的范围。这可能是一个复杂的过程,如果你想要使用 Python 的所有功能,你需要安装几个其他库(如 gnu-readlines 和 OpenSSL)。通常情况下,通过软件包安装会更简单,但如果你想要了解如何编译 Python,可以访问www.python.org/download/source/获取更多信息。

Linux 图形用户界面
通常,Linux 用户会更习惯于命令行,我们将在下一节中介绍,但你也可以从 GUI(如 Gnome)中运行 Python 程序——尽管这比 Windows 版本要复杂一些。将以下程序输入到文本编辑器中并保存:
#!/usr/bin/python
print "hello world!"
ignored = raw_input("Hit enter to continue")
你还需要编辑文件的权限,将其设置为可执行,这样你就可以直接运行它,如图 1.19 所示。

完成这些后,您可以双击程序文件,在终端中点击运行来运行您的程序。

图 1.20. 选择对您的程序做什么

当您看到 图 1.21 中的窗口时,您就完成了。虽然这是从 GUI 运行 Python 程序的最简单方法,但还有其他运行脚本的方法,不需要选择是否运行或显示程序。在 Gnome 中,您可以设置程序启动器。权限窗口如图所示。
图 1.21. 在 Ubuntu Linux 的终端窗口中运行的测试程序

请记住,对于基于终端的程序,如您的测试脚本,您需要在终端窗口中运行它,通过以下类似命令执行:

图 1.22. 在启动器中设置命令

gnome-terminal -e '/usr/bin/python /home/anthony/Desktop/hello_world.py'
虽然这些示例是针对 Gnome 的,但其他发行版和窗口管理器也有类似选项。
Linux 命令行
许多 Linux 程序都是从命令行运行的,Python 也不例外。您需要能够打开一个终端窗口。如果您使用 Gnome,那么这可以在应用程序 > 附件菜单下找到。
一旦打开终端窗口,您将看到一个命令提示符。要执行您的脚本,请输入
python path/to/your/script
如果您已将脚本保存在桌面上,这可以简化为
python ~/Desktop/hello_world.py
如果您想让您的脚本看起来更像系统命令,您可以在文件末尾省略 .py,将其保存到您的路径上的某个位置(大多数系统支持 ~/bin 文件夹),并使用类似 chmod 755 path/to/script.py 的命令使其可执行。只要您将 #!/usr/bin/python 行作为文件的第一行,您就应该能够从任何地方输入脚本名称并运行它。
现在已经涵盖了 Windows 和 Linux 用户,让我们看看如何在 Mac 上安装 Python。
Macintosh
在 Mac 上使用 Python 与在 Linux 下运行几乎一样,唯一的区别是图形部分。Mac OS 10.5 预装了 Python 2.5,而 Snow Leopard (Mac OS 10.6) 预装了 Python 2.6。这两个版本都应该能与您在 Hello Python 中使用的代码兼容。
如果您需要安装 Python 的较新版本,您也可以从 Python 网站下载,并通过标准的 .dmg 图像文件安装——但需要注意一些细节以确保正常运行。
更新 shell 配置文件
您首先需要做的是告诉 Mac OS X 使用新的 Python 版本,如果您是从终端运行程序。否则,它将继续使用内置版本。幸运的是,Python 包含一个脚本可以为您设置。如果您导航到应用程序中的 Python 文件夹并运行名为 Update Shell Profile 的应用程序,未来的 shell 窗口应该使用正确的版本。
设置默认应用程序
第二步是设置双击 Python 程序时它们会做什么。默认情况下,它们将在 Python 附带的编辑器 IDLE 中打开;但我想让它们运行 Python 程序,这样它们的行为更像是一个真正的应用程序。如果你右键单击(或控制单击)一个.py Python 文件,你应该会看到这个弹出菜单。
图 1.23. 正确设置新的 Python 路径

图 1.24. 设置 Python 文件的默认操作

这让你可以选择这次运行 Python 脚本要使用的程序;但如果你选择其他,你可以选择每次运行哪个程序。
在应用程序目录中的 Python 文件夹内选择 Python 启动器,选择始终以打开方式检查框,然后点击打开。现在,每次你双击一个.py 脚本时,它都会运行而不是在 IDLE 中打开。如果你想测试命令行是否正常工作,你可以打开终端应用程序并尝试在 Linux 部分中所有之前的命令。
现在你已经在你选择的操作系统上安装了 Python,是时候找出任何小问题(hiccups)了。

故障排除
如果你运行 Python 程序时没有看到窗口,可能有一些问题。当你学习编程时,你可能会遇到很多这样的错误。一个很好的信息来源是在你尝试运行程序时进行网络搜索,查找你得到的确切错误信息或症状。如果你遇到困难,不要害怕寻求帮助(例如,在 Python 邮件列表之一上)。以下是一些更常见的问题。
图 1.25. 设置 Python 启动器为默认应用程序

语法错误
如果你输入程序时犯了错误,你可能会看到窗口闪烁。请再次确认你已正确输入所有内容,然后重新运行你的程序。如果它仍然不起作用,尝试从命令行运行它;这将告诉你 Python 正在做什么以及是否有任何错误。
不正确的文件扩展名(Windows)
如果你没有在文档中看到蓝色和黄色的图标,这意味着 Windows 没有识别出它是一个 Python 程序。请再次确认你的文件以.py 结尾。如果这不起作用,可能 Python 没有正确安装;尝试卸载并重新安装它。
Python 在 Linux 系统中的安装位置不同
在 Linux 下,你在程序开头放置的#!行告诉 shell 使用哪个程序来运行你的脚本。如果该程序不存在,那么你的命令行程序将失败,如下面的错误信息所示:
bash: ./hello_world.py: /usr/local/bin/python: bad interpreter:
No such file or directory
要解决这个问题,你需要找出 Python 的安装位置并更新这一行。最简单的方法是在命令行中输入which python,它应该会响应 Python 的当前位置。另一个选项是使用#!/usr/bin/env python,这将使用env程序来查找 Python 而不是直接引用它。
最后,让我们看看文本编辑器和 IDE 如何使编程更容易。
文本编辑器和 IDE
要创建你的程序,你需要使用文本编辑器来编辑 Python 读取的文件。像 Microsoft Word 和 Wordpad 这样的程序是糟糕的选择,因为它们使用更复杂的格式,无法与 Python(或其他编程语言)一起使用。相反,你将想要使用直接编辑文本且不支持如粗体文本或页面格式等格式的程序。
如果你使用的是 Windows PC,你始终可以使用记事本,Linux 和 Mac OS X 也有类似的应用程序;但它们非常基础,不会帮助你捕捉许多常见的编程错误,例如正确缩进代码或字符串中未关闭引号。
更好的选择是使用 Python 一起提供的 IDLE 编辑器,或者下载稍后列出的其中之一,这些编辑器专门为编程设计。编程编辑器通常具有使编程更容易的额外功能:
-
它们会自动缩进你的代码。
-
他们可以通过为不同的指令着色来使你的程序更容易阅读。
-
它们可以运行你的程序,并把你带回错误发生的确切行,使编写程序更快。
Python 的网站上提供了可用于 Python 编辑的编辑器长列表,网址为 wiki.python.org/moin/PythonEditors。其中一些更常用的包括以下内容:
-
IDLE,它是与 Python 一起安装的。
-
Emacs 和 Vim 被许多开发者使用,功能强大,但它们的学习曲线相当陡峭。Cream 是 Vim 的一个变种,具有更正常的键绑定。
-
Notepad++ 是一款针对 Windows 的具有许多功能的特定编辑器。
一些编辑器也是集成开发环境(IDE)。IDE 提供了超出文本编辑的额外服务,以节省你在编程时的时间。通常,它们会为你提供访问 Python 解释器、某种形式的自动完成以及更高级的代码导航(例如,直接跳转到程序中错误发生的源代码),以及交互式调试工具,这样你可以在程序运行时逐步运行代码并查看变量。Python wiki 上还列出了 Python IDE 的列表,网址为 wiki.python.org/moin/IntegratedDevelopmentEnvironments。你可能想要考虑的一些 IDE 包括以下内容:
-
IDLE 是一个简单的 IDE——它包含 Python 解释器,以及弹出式完成功能,并直接带你到错误处。
-
Wing IDE 是一款集成了单元测试、源代码浏览和自动完成的商业集成开发环境(IDE)。Wingware 为从事开源项目开发的开发者提供免费许可证。
-
PyDev 是 Eclipse 的开源插件。
-
SPE 也是开源的,提供了一系列功能,包括一个代码检查器,它可以测试常见的编程错误并评估你代码的质量。
-
Komodo 有多种形式,包括一个名为 OpenKomodo 的开源编辑器。
最终,你使用 IDE 还是编辑器,以及你使用哪个,往往是基于个人偏好和项目范围的决定。当你开始构建更大的编程项目时,学习一个功能更强大的编辑器或 IDE 的投资将得到回报。最好的建议是尝试多个编辑器,看看你更喜欢哪一个。

摘要
在本章中,我们介绍了你需要了解的基本知识,以便开始用 Python 编程。你了解了一些高级细节:什么是编程,编程的哲学,以及程序员通常面临的问题类型;还了解了一些低级细节,例如如何安装和运行 Python,如何创建程序,以及如何从图形用户界面和命令行运行它们。
当你编程时,学习如何处理可能出现的错误是最重要的长期技能之一。当这种情况发生时,追踪它们回到源头并修复问题的根本原因可能需要一些坚持和侦探工作,因此了解你可用到的资源是很重要的。你将在后面的章节中学习如何在程序中处理错误。
你在本章中学到的所有内容——尤其是如何运行一个 Python 程序——都将有助于你在接下来的章节中,我们将探讨 Python 的基本语句,并使用它们编写一个名为“寻找 Wumpus”的游戏。
第二章:Hunt the Wumpus
本章涵盖
-
编写你的第一个真实程序
-
程序的工作原理
-
一些组织程序的方法
现在你已经安装并设置了 Python,并且知道如何输入和运行测试程序,让我们开始编写一个真实的程序。我将首先解释 Python 的一些基本功能,然后你将创建一个名为 Hunt the Wumpus 的简单基于文本的冒险游戏。
随着你对本章的深入,你将为你的游戏添加功能,在初始版本的基础上构建。这就是大多数程序员(包括作者)学习编程的方式:学习足够多的语言知识来编写一个简单的程序,然后从那里开始构建。为了做到这一点,你需要更多的知识——但你只需要学习一点点更多,就能对你的程序进行小的添加。重复添加小功能的过程几次,你将拥有一个你无法一次性创建的程序。在这个过程中,你将学到很多关于编程语言的知识。
在本章中,你将亲身体验编程的早期阶段,编写你自己的 Hunt the Wumpus 版本。基于文本的界面非常适合你的第一个程序,因为你只需要知道两个简单的语句来处理所有的输入和输出。由于所有的输入都将是以字符串形式,你的程序逻辑简单直接,你不需要学习很多就能开始变得高效。
Hunt the Wumpus 的简要历史
《Hunt the Wumpus》是一款由格雷戈里·约伯(Gregory Yob)在 1976 年编写的大受欢迎的早期电脑游戏。它让你扮演一位勇敢的探险家,深入一个洞穴网络,寻找只有“wumpus”这个名称的毛茸茸、臭气熏天的神秘生物。玩家面临了许多危险,包括蝙蝠、无底洞,当然还有 wumpus。由于原始游戏附带源代码,它允许用户创建具有不同洞穴和危险的 Hunt the Wumpus 版本。最终,Wumpus 的重新诠释导致了第一人称冒险游戏整个流派的发展,例如《冒险》和《Zork》。
到本章结束时,你将知道如何向你的完全功能版的 Hunt the Wumpus 添加功能,你甚至可以对其进行调整以创建你自己的版本。

在我们进入洞穴冒险之前,让我们弄清楚基础知识。
什么是程序?
正如你在第一章中学到的,一个程序由告诉电脑如何做某事的语句组成。程序可以执行简单的任务,例如在屏幕上打印字符串,并且可以组合起来执行复杂任务,如平衡账户或编辑文档。
程序
一系列指令,通常称为语句,告诉你的电脑如何执行某些操作。
程序的基本机制很简单:Python 从第一行开始,执行它所说的,然后移动到下一行并执行它所说的,依此类推。例如,输入这个简单的 Python 程序:
print "Hello world!"
print "This is the next line of my program"
代码将以下文本输出到屏幕上:
Hello world!
This is the next line of my program
Python 可以做很多不同类型的事情。因此,你可以尽快开始你的程序,本章将为你简要介绍你可以用来告诉 Python 要做什么的语句。我们不会深入细节,但你将学会你需要知道的一切,以便你能跟上进度。

有很多内容需要吸收,所以如果你一开始不理解所有内容,不要过于担心。你可以将编程比作画画;你将开始用轻铅笔草图,然后开始正式工作。一开始有些部分可能会模糊不清,但在尝试理解细节之前,对整体有一个感觉是很重要的。
你可能还想在电脑上阅读这一章,这样你可以尝试不同的语句来查看哪些有效,并尝试你自己的想法。
我们首先来调查你刚才尝试的 print 语句。
向屏幕写入
print 语句用于告诉玩家游戏中的情况,例如玩家在哪个洞穴或附近是否有独角兽。你已经在“Hello World”程序中看到了 print 语句,但它还可以做更多的事情。你不仅限于打印文字;Python 中的几乎所有东西都可以打印出来:
print "Hello world!"
print 42
print 3.141592
你可以通过在它们之间放置逗号来同时打印出很多东西,如下所示:
print "Hello", "world!"
print "The answer to life, the universe and everything is", 42
print 3.141592, "is pi!"
但打印语句不会使游戏变得互动。让我们看看你如何添加选项。
使用变量记住事情
Python 也需要一种方式来了解发生了什么。例如,在“寻找独角兽”游戏中,Python 需要知道独角兽藏在哪个洞穴里,这样它就会知道玩家何时找到了独角兽。在编程中,我们称这种记忆为 数据,它使用一种称为 变量 的对象类型来存储。变量有名称,这样可以在程序中稍后引用它们。

要告诉 Python 设置一个变量,你为变量选择一个名称,然后使用等号告诉 Python 变量应该是什么。变量可以是字母、数字、单词或句子,以及我们稍后将要介绍的其他一些东西。以下是设置变量的方法:
variable = 42
x = 123.2
abc_123 = "A string!"
实际上,你的程序可能会变得相当复杂,所以选择一个能告诉你变量含义或如何使用的名称会很有帮助。在“寻找独角兽”程序中,你会使用这样的变量名:
player_name = "Bob"
wumpus_location = 2
注意
对你的变量名有一些限制;它们不能以数字开头,不能包含空格,也不能与 Python 用于其自身目的的一些名称冲突。在实践中,如果你使用有意义的名称,你不会遇到这些限制。
表 2.1 为你提供了你将在你的“猎杀独眼巨人”程序中使用的变量类型的概述。
表 2.1. 在“猎杀独眼巨人”中使用的变量类型
| 类型 | 概述 |
|---|---|
| Numbers | 包括 3 或 527 这样的整数,或 2.0 或 3.14159 这样的浮点数。Python 不会在它们之间切换,所以某些情况下你需要小心;例如,3 / 2 的结果是 1 而不是 1.5。3.0 / 2 将给出正确答案。 |
| Strings | 字符序列,包括a–z、数字和标点符号。它们可以用来存储单词和句子。Python 有几种不同的字符串表示方法:你可以使用单引号或双引号— 'foo' 或 "foo"—以及可以跨越多行的特殊版本的三引号。 |
| Lists | 其他变量的集合,可以包括其他列表。列表以方括号开始和结束,内部项用逗号分隔:["foo", "bar", 1, 2, [3, 4, 5]]。 |
既然变量已经工作,你该如何让玩家参与进来?
询问玩家该做什么
程序还需要一种方式来询问玩家在特定情况下该做什么。对于“猎杀独眼巨人”,你将使用raw_input命令。当 Python 运行该命令时,它会提示玩家输入一些内容,然后输入的内容可以存储在一个变量中:
player_input = raw_input(">")
接下来,你需要弄清楚如何处理用户输入。
做出决定
如果编程就只有这些,那会相当无聊。所有有趣的事情都发生在玩家在游戏中必须做出选择的时候。他们会选择洞穴 2 还是洞穴 8?独眼巨人是否藏在那里?玩家会被吃掉吗?为了告诉 Python 在特定情况下你想让它发生什么,你使用if语句,它需要一个条件,例如两个变量相等或一个变量等于另一个值,以及如果条件满足要执行的操作:
if x == y:
print "x is equal to y!"
if a_variable > 2:
print "The variable is greater than 2"
if player_name == "Bob":
print "Hello Bob!"

你还可以使用else命令,它告诉 Python 如果条件不匹配时要做什么,如下所示:
if player_name == "Bob":
print "Hello Bob!"
else:
print "Hey! You're not Bob!"
为了让 Python 能够区分if语句的正文和你的程序的其他部分,属于它的行需要缩进。如果你在另一个if语句中放置一个if语句——通常称为嵌套——那么你需要再次缩进,总共八格空格。通常,你会在每个缩进级别使用四个空格。
一些常见条件列于表 2.2 中。
表 2.2. 常见条件
| 条件 | 概述 |
|---|---|
| name == "bob" | 如果变量 name 存储的是字符串“bob”,则为 True。Python 使用两个等号来区分它和赋值:name = "bob"意味着完全不同的事情。 |
| name != "bob" | 如果变量 name 存储的不是字符串“bob”,则为 True。!=通常读作“不等于”。 |
| a > 0 | 如果变量 a 存储的数字大于 0,则为 True。 |
| 0 <= a <= 10 | 如果 a 是介于 0 和 10 之间的数字(包括 0 和 10),则为 True。 |
| "ab" in "abcde" | 你也可以通过使用 in 来判断一个字符串是否是另一个字符串的一部分。 |
| not "bob" in "ab" "bob" not in "ab" | Python 还具有 not 和 not in 命令,它们可以反转表达式的意义。 |
现在你已经掌握了决策语句,让我们看看你可以做些什么来使程序继续运行。
循环
计算机的一个伟大之处不在于它们能做什么,而在于它们能一遍又一遍地做同样的事情而不会感到无聊。一大堆要加的数字?没问题。数百行文件?也是一样。程序只需要知道它将要重复什么,以及何时应该停止。在 Hunt the Wumpus 程序中,你将使用一个称为 while 循环 的结构,它会在指定的条件为真时循环,以及一个 break 语句,它允许你控制何时停止。以下是一个示例:
while True:
print "What word am I thinking of?"
answer = raw_input(">")
if answer == "cheese":
print "You guessed it!"
break
else:
print "No, not that word..."

我们几乎完成了对 Python 基本特性的游览;我们最后一个是函数。
函数
Wumpus 程序中还有一些称为 函数 的语句。它们通常告诉你有关你的程序、玩家或变量的有用信息,它们看起来像这样:
range(1,21)
len(cave_numbers)

通常,函数会通过 返回 一个值来告诉你一些事情,你可以将这个值存储在另一个变量中或直接使用:
cave_numbers = range(1,21)
print "You can see", len(cave_numbers), "caves
现在我们已经介绍了一些基础知识,让我们看看你如何使用它们来构建一个简单的程序。这个程序并不做原始的 Hunt the Wumpus 程序所做的一切,但为了现在,我们想要搭建一些东西来看看它如何组合在一起。
增量编程
在本章的后续部分,你将通过添加功能或改进现有功能来构建这个程序,并在过程中进行整理。大多数程序员通常是这样工作的:开始简单,然后逐步构建。你可以从 www.manning.com/hellopython 下载这个程序,但我建议你跟随阅读并输入它。这将帮助你更容易地记住单个语句,但你也将在编写更大的程序时养成一个关键习惯——从一个小的程序开始,然后逐步扩展。
表 2.3 列出了本章中你将学习的基本特性。
表 2.3. Python 基本特性
| 特性 | 概述 |
|---|---|
| 语句 | 通常是一行程序(但也可以更多)告诉 Python 做某事。 |
| 变量 | 用于引用信息,以便程序以后可以使用。Python 可以引用许多不同类型的信息。 |
| if-then-else | 这是告诉 Python 做出决定的方式。一个 if 语句至少包含一个条件,例如 x == 2 或 some_function() < 42,以及当该表达式为真时 Python 要执行的操作。您还可以包含一个 else 子句,它告诉 Python 当表达式为假时该做什么。 |
| 循环 | 用于重复某些语句多次。它们可以是基于条件(如 if 语句)的 while 循环,或者是对列表中的每个元素运行一次的 for 循环。在循环内部,你可以使用 continue 语句,它将跳转到循环的下一个迭代,或者使用 break 语句,它将完全退出循环。 |
| 函数 | 一系列可以运行以将值返回到程序中其他部分的语句。如果需要,它们可以接受输入,或者它们可以读取(有时写入)程序中的其他变量。 |
| 缩进 | 因为你可以将函数、循环和 if 语句嵌套在一起,Python 在行的开头使用空白(通常每级四个空格)来告诉哪些语句属于哪里。 |
| 注释 | 当 Python 在行的开头遇到#字符时,它将忽略该行并且不会运行它。此外,如果#字符不在字符串内部,它将忽略该行剩余部分。注释用于解释程序的部分,无论是为了其他程序员还是为了几周后的自己——当你已经忘记了大部分你当时所做的事情的细节。在书中你不会看到太多注释,因为我们为代码列表使用编号注释。 |
你在本节中学到了很多,但在下一节中,你将把所学知识付诸实践,并编写你的第一个程序。
你的第一个程序
现在你已经了解了 Python 的基础知识,让我们来看看程序。仅仅通过阅读关于单个特性的描述,很难看到程序是如何工作的,因为在运行中的程序中,它们都相互依赖。在本节中,我们将探索 Hunt the Wumpus 的第一个版本并解决出现的第一问题。

注意
实验对于培养对 Python 如何工作以及所有部分如何相互配合的直觉至关重要。没有它,你将陷入复制粘贴他人的程序,而且当你遇到错误时,将无法修复。
Hunt the Wumpus 的第一个版本
如果你一开始不理解接下来的列表,请不要担心。了解一个程序如何工作的一个好方法是实验它——更改几个语句,再次运行它,看看有什么不同。或者,将几个语句复制到另一个文件中,这样你就可以单独运行它们。
列表 2.1. Hunt the Wumpus 的第一个版本


让我们从程序的“设置”部分开始
。你在程序中存储了一个数字列表,每个数字代表一个洞穴。不必太担心第一行——你将在第三章中了解更多关于 import 语句的信息。choice 函数将随机返回一个洞穴,你使用它将独眼巨人和玩家放置在起始位置。注意你使用的循环,用于判断玩家和独眼巨人是否在同一位置——如果玩家立刻被吃掉,游戏将不再有趣!
开场白
告诉玩家游戏是如何工作的。你使用 len() 函数来告诉洞穴的数量。这很有用,因为你可能想在以后改变洞穴的数量,使用这样的函数意味着你只需要在一个地方更改定义洞穴列表时的事情。

您的主游戏循环
是游戏开始的地方。当玩游戏时,程序会向玩家提供玩家可以看到的详细信息,要求玩家输入一个洞穴,检查玩家是否被吃掉,然后从头开始。while 循环会在其条件为真时循环,所以 while True: 表示“不断循环而不停止”(你将在下一分钟处理停止的部分)。
第一个 if 语句
告诉玩家玩家的位置,如果独眼巨人只有一个房间之遥,则打印警告信息(“我闻到独眼巨人的气味了!”)。注意你如何使用 player_location 和 wumpus_location 变量。因为它们是数字,你可以对它们进行加法和减法操作。如果玩家在洞穴 3,而独眼巨人位于洞穴 4,那么 player_location == wumpus_location - 1 条件将为真,Python 将显示该消息。
然后你询问玩家想要去哪个洞穴
。你做一些检查以确保玩家输入了正确的输入类型。它必须是一个数字,并且必须是洞穴之一。注意,输入将是一个字符串,而不是数字,所以你必须使用 int() 函数将其转换。如果它不符合你的需求,你将向玩家显示一条消息。
如果输入与洞穴编号匹配,将触发这个 else 子句
。它将使用新值更新 player_location 变量,然后检查玩家的位置是否与独眼巨人的位置相同。如果是 ... “啊!你被独眼巨人吃掉了!”一旦玩家被吃掉,游戏应该停止,所以你使用 break 命令来停止你的主循环。Python 没有更多的语句要执行,因此游戏结束。

调试
如果你已经按照列表 2.1 的内容输入并运行了,你会注意到它并不完全按计划工作。实际上,它根本无法运行。确切的结果将取决于你的计算机操作系统以及你如何运行你的 Python 程序,但你应该会看到以下列表中显示的内容。如果你没有,尝试通过在命令行中输入 python wumpus-1.py 来运行你的程序。
列表 2.2. BANG!你的程序爆炸了
Welcome to Hunt the Wumpus!
You can see
Traceback (most recent call last):
File "wumpus-1.py", line 10, in ?
print "You can see", len(caves), "caves"
NameError: name 'caves' is not defined

发生的事情是程序中有一个 bug。在 列表 2.1 中有一个 Python 无法运行的语句。它不会猜测你的意图,而是会停止并拒绝继续运行,直到你修复它。
幸运的是,这个问题很容易解决:Python 会告诉你出错的行号和触发的错误类型,并提供对问题的粗略描述。在这种情况下,是第 10 行,错误是 NameError: name 'caves' is not defined。哎呀——程序试图访问变量 caves 而不是 cave_numbers。如果你将第 10 行修改为
print "You can see", len(cave_numbers), "caves"
然后,程序应该可以运行。
恭喜——你的第一个真正的 Python 程序!接下来,让我们看看你还能做些什么来改进“抓捕独角兽”。
实验你的程序
通过实验程序是大多数程序员学习如何处理新的编程问题和找到解决方案的最常见方式。你也可以通过实验你的新程序来看看你还能让它做什么。你是输入的人,所以独角兽程序是你的。你可以让它做任何你想让它做的事情。如果你觉得勇敢,尝试以下想法。
更多(或少)的洞穴
你可能会觉得有 20 个洞穴太多——或者太少。幸运的是,现在这是你的程序,所以你可以修改定义 cave_numbers 的行,使其更小或更大。问题:如果你只有一个洞穴会发生什么?
更好的独角兽
你还没有在游戏中加入弓箭,所以玩家只能漫无目的地在洞穴中徘徊,直到玩家撞到独角兽并被吃掉。这不是一个很有趣的游戏。如果你改变玩家找到独角兽的行,使其读取:
*print "You got hugged by a wumpus!"*
哎呀,多可爱的独角兽!(作者和出版社声明,如果你选择这个选项,对于干洗衣服以去除独角兽的味道,他们不承担任何责任。)
多个独角兽
独角兽在洞穴里一定非常孤单。给它一个朋友怎么样?这有点棘手;但你已经有了现有的独角兽代码可以从中工作。添加一个 wumpus_friend_location 变量,并检查你检查第一个 wumpus_location 的任何地方,如下所示。
列表 2.3. 为独角兽添加一个朋友
wumpus_location = choice(cave_numbers)
wumpus_friend_location = choice(cave_numbers)
player_location = choice(cave_numbers)
while (player_location == wumpus_location or
player_location == wumpus_friend_location):
player_location = choice(cave_numbers)
...
if (player_location == wumpus_location - 1 or
player_location == wumpus_location + 1):
print "I smell a wumpus!"
if (player_location == wumpus_friend_location - 1 or
player_location == wumpus_friend_location + 1):
print "I smell an even stinkier wumpus!"
...
if player_location == wumpus_location:
print "Aargh! You got eaten by a wumpus!"
break
if player_location == wumpus_friend_location:
print "Aargh! You got eaten by the wumpus' friend!"
break
现在游戏更有趣了!
你还可以做更多的事情来改进“抓捕独角兽”游戏,从洞穴结构开始。

制作洞穴
你可能首先注意到的关于列表 2.1 的是,“迷宫洞穴”并不是一个迷宫。它更像是一个走廊,洞穴整齐地排列成一行,一个接一个。很容易找出 Wumpus 的位置——按顺序进入下一个洞穴,直到你闻到它的气味。因为确定 Wumpus 的位置是游戏的一个关键部分,所以这是首先要解决的问题。在解决这个问题时,你会对 Python 的列表和for循环有更多的了解。
列表
假设你想写一个程序来帮助你购物。你需要的第一件事是跟踪你想要购买的东西。Python 有一个内置机制可以做到这一点,称为列表。你可以像使用任何其他变量一样创建和使用它:
shopping_list = ['Milk', 'Bread', 'Cheese', 'Bow and Arrow']
如果你想知道你的购物清单上有什么,你可以将其打印出来,或者你可以使用一个索引来找出特定位置的内容。列表将按照你定义的顺序保留一切。唯一的缺点是数组的索引从 0 开始,而不是 1:
>>> print shopping_list
['Milk', 'Bread', 'Cheese', 'Bow and Arrow']
>>> print shopping_list[0]
Milk
如果你需要,一个巧妙的技巧是,索引-1 获取数组中的最后一个项目:
>>> print shopping_list[-1]
Bow and Arrow
你还可以检查特定的事物是否在你的列表中:
if 'Milk' in shopping_list:
print "Oh good, you remembered the milk!"
列表的另一个酷特点是它们可以满足许多用途。你不仅限于字符串或数字——你可以放入任何东西,包括其他列表。如果你有两个商店的列表(比如,超市和 Wumpus ‘R’ Us(“满足你所有 Wumpus 狩猎需求!”),你可以将它们存储在自己的列表中,然后将这些列表存储在一个大列表中:
>>> supermarket_list = ['Milk', 'Bread',
'Cheese']
>>> wumpus_r_us_list = ['Bow and Arrow',
'Lantern', 'Wumpus B Gone']
>>> my_shopping_lists = [supermarket_list,
wumpus_r_us_list]

你也可以将事物放入列表中,然后再取出来。如果你忘记将绳子列入你的列表,那很容易解决:
>>> wumpus_r_us_list.append('Rope')
>>> print wumpus_r_us_list
['Bow and Arrow', 'Lantern', 'Wumpus B Gone', 'Rope']
你想捕捉 Wumpus 而不是将其吓跑,所以“Wumpus B Gone”可能不是一个好主意:
>>> wumpus_r_us_list.remove('Wumpus B Gone')
>>> print wumpus_r_us_list
['Bow and Arrow', 'Lantern', 'Rope']
如果你需要,你也可以通过给出两个用冒号分隔的值来从列表中删除部分内容。这被称为切片列表。Python 将返回另一个列表,从第一个索引开始,直到但不包括第二个索引。记住列表索引从零开始:
first_three = wumpus_r_us_list[0:3]
如果你提供一个负值,那么 Python 将从后向前测量:
last_three = wumpus_r_us_list[-3:]
注意,最后一个例子省略了最后一个索引。如果你在切片中省略一个值,Python 将使用列表的起始或结束位置。这两个切片与前面的两个完全相同:
first_three = wumpus_r_us_list[:3]
last_three = wumpus_r_us_list[1:]
最后,一旦你从列表中取出所有东西,你将得到一个空列表,它由两个方括号本身表示:[]。
注意
Python 与其他程序(如 C)之间有一个区别,那就是 Python 的变量在经典意义上并不是变量。大部分情况下,它们表现得像变量,但它们更像是一个标签或内存中对象的指针。当你发出像a = []这样的命令时,Python 创建一个新的列表对象,并使a变量指向它。如果你然后发出像b = a这样的命令,b将指向同一个列表对象,并且通过a进行的任何操作也会似乎发生在b上。
现在你已经了解了列表,让我们来处理for循环。
for循环
一旦你将所有物品列成清单,一个常见的使用清单的方法是对其中的每一项进行某种操作。最简单的方法是使用一种称为for循环的循环类型。for循环通过重复某些语句来遍历列表中的每一项,并将该项分配给一个变量,这样你就可以对它进行操作:
print "Wumpus hunting checklist:"
for each_item in wumpus_r_us_list:
print each_item
if each_item == "Lantern":
print "Don't forget to light your lantern"
print "once you're down there."
除了变量之外,for循环与while循环非常相似。你在列表 2.1 中的while循环中使用的break语句在for循环中也会起作用。
注意
这是在编程中常见的一种模式——获取一堆东西,并对你的每一堆东西做点什么。
编码你的洞穴
在“寻找独角兽”游戏中,每个洞穴只应该连接到少量其他洞穴。例如,洞穴 1 可能只有通往洞穴 5、7 和 12 的隧道,然后洞穴 5 有通往 10、14 和 17 的隧道。这限制了玩家一次可以访问的洞穴数量,而通过洞穴系统导航以尝试找到独角兽成为游戏的核心挑战。

在你第一次编写“寻找独角兽”的游戏版本时,你已经使用了一个洞穴编号列表来告诉 Python 独角兽和玩家的位置。在你的新版本中,你将使用类似类型的列表,但进行了修改,以便它能告诉你从特定位置可以访问哪些洞穴。对于每个洞穴,你需要一个其他洞穴的列表,因此你追求的是一个列表的列表。在 Python 中,它看起来是这样的:
caves = [ [2, 3, 7],
[5, 6, 12],
...
]
这表明洞穴 0(别忘了列表的索引从 0 开始)链接到洞穴 2、3 和 7;洞穴 1 链接到洞穴 5、6 和 12;以此类推。因为洞穴是随机生成的,所以你的数字会不同,但整体结构将是相同的。洞穴的编号与列表中的索引相同,这样 Python 可以轻松地找到出口。让我们用以下列表替换列表 2.1 的第一部分,以便它设置你的新改进的洞穴系统。
列表 2.4。设置你的洞穴
from random import choice
cave_numbers = range(0,20)
caves = []
for i in cave_numbers:
caves.append([])
for i in cave_numbers:
for j in range(3):
passage_to = choice(cave_numbers)
caves[i].append(passage_to)
print caves
你仍然使用range函数来生成洞穴列表,但你已经改变了范围,使其从 0 开始而不是 1,以匹配列表的索引。然后你为应该拥有的每个洞穴创建一个空列表。在这个阶段,它是一个未连接的洞穴列表。
对于你列表中每个未连接的洞穴,你随机选择三个其他洞穴并将它们附加到这个洞穴的隧道列表中。为了使事情更简单,你在第一个循环内部使用另一个 for 循环,这样如果你以后需要更改隧道的数量,你只需要将数字 3 改成你想要的任何数字。
当你选择一个要连接的洞穴时,你使用一个 临时变量 来存储它。这样做的主要优点是你可以使用一个有意义的名称来使代码更容易阅读,因为你知道这个变量做什么。请注意,你可以通过写入 caves[i].append(choice(cave_numbers)) 来合并这两行(直接使用 choice(cave_numbers) 函数),但这会使代码更难阅读。
为了检查程序是否正常工作,你打印出洞穴列表。这通常被称为 调试字符串,因为当你试图调试程序时,这是一个方便的技术。一旦程序正常运行,你可以删除这一行,因为玩家不应该提前知道洞穴。

现在,当你运行你的程序时,它应该会打印出一个洞穴列表,如下所示:
[[8, 7, 14], [1, 18, 4], [4, 8, 15], [6, 6, 0],
[5, 3, 6], [15, 9, 10], [2, 13, 5], [17, 18, 3],
[4, 8, 15], [18, 17, 2], [1, 9, 15], [11, 4, 16],
[16, 10, 6], [2, 10, 5], [13, 4, 6], [8, 14, 11],
[16, 4, 10], [3, 12, 17], [18, 18, 0], [2, 8, 5]]
这正是你预期的。在这个例子中,洞穴 0 连接到洞穴 8、7 和 14;洞穴 1 连接到洞穴 1、18 和 4;以此类推。现在你有了这个列表,你只需要将程序的其余部分更改为使用它。列表 2.1 的第 4 和 5 节应替换为以下列表。
列表 2.5. 修改程序以使用新的洞穴系统

你只使用洞穴列表来找出玩家可以进入的下一个洞穴,所以代码的更改
是相当直接的。你不需要检查玩家的输入是否在洞穴编号列表中,而是检查你所在的特定洞穴列表。
在你用来设置洞穴的代码中有一个错误。你可能不相信我,尤其是如果你已经玩了几局游戏,但确实有。让我们回到调试模式。
修复一个更微妙的错误
使错误难以发现的是,代码运行正常,但有时游戏根本无法获胜。在本节中,我们将探讨为什么游戏可能无法获胜以及如何修复它。
注意
这些是最难追踪的 bug——你的程序不会崩溃或输出任何明显的错误,但它确实有误。
我们首先将检查洞穴是如何连接的。
问题
诀窍是所有洞穴隧道都是随机生成的,因此它们可以以任何可能的方式连接。让我们考虑一个更容易的情况,一个小型的洞穴系统。假设隧道恰好像 图 2.1 中的那样连接。
图 2.1. 这不是一个很有趣的游戏。

玩家永远无法捕捉到野猪。
在有很多洞穴的情况下,玩家被困在地图孤立角落的可能性会降低;但理想情况下,您希望程序尽可能无懈可击,使其不可能,而不是不太可能。
解决方案
您需要对地图生成进行两项更改以解决问题。第一是使隧道双向。如果您可以从洞穴 1 前往洞穴 2,那么您应该能够从洞穴 2 返回洞穴 1。

第二点是确保每个洞穴都相互连接,并且没有孤立的洞穴(或洞穴网络)。这被称为连通结构。这样,无论您如何连接其余的通道,您都可以确信玩家可以到达每个洞穴,因为玩家可以沿着来时的路返回并选择另一条通道。如果玩家忘记了他们来的路,他们仍然可能会迷路,但这不是您的错。
现在,您如何使用 Python 连接隧道?
编码连通洞穴
连接洞穴很简单——当您创建一个单向隧道时,您需要沿着来时的路再创建一个单向隧道。每次您说 caves[a].append[b],您也说要 caves[b].append[a]。程序看起来像以下列表。
列表 2.6. 创建一个连通的洞穴网络

首先,创建一个您尚未访问的洞穴列表,并访问洞穴 0!。您循环直到 unvisited_caves 为空!;也就是说,没有未访问的洞穴了。您选择一个与其他洞穴有少于三个隧道的洞穴!。如果您将一个洞穴连接到 10 个其他洞穴,游戏将太难,因为这将很难或不可能确定哪个隧道通向怪物。
是您正在建造洞穴的地方。您选择一个未访问的洞穴,在旧洞穴中放入通往新洞穴的隧道,然后从新洞穴返回到旧洞穴。这样您就可以确信玩家可以找到返回的路。在 图 2.2 中,您正在将洞穴 3 添加到您的结构中——它将连接到洞穴 0、1 或 2 中的任何一个。
图 2.2. 将洞穴 3 添加到您的网络中


一旦您完成了洞穴,您就可以将它从未访问列表移动到已访问列表!。步骤
,
,和
会重复进行,直到您没有洞穴(未访问洞穴 == [])。您的洞穴结构将开始看起来像 图 2.3。
图 2.3. 这要好多了!

进度报告行
是可选的,但如果您包含它们,您将能够在构建过程中看到您的洞穴,因为每次 Python 通过循环时,它都会打印出当前的洞穴结构。它看起来也比打印洞穴更美观。
现在所有的洞穴都已经连接起来,剩下的工作需要添加一些单向隧道
。这与之前的示例完全相同,只是你每个洞穴中已经有了至少一个隧道。为了不添加超过三个隧道,你将你的 for 循环改为 while 循环。
你的洞穴问题解决了,让我们看看函数如何提高你代码的可读性。
用函数清理你的代码!
如果你一直跟着示例(你应该这么做!)你会发现你的程序变得越来越长。这是一个相对简短的示例,即便如此,理解程序中发生的事情变得越来越困难。如果你想要把你的程序复制给朋友使用,他们可能很难弄清楚所有部分的功能。
注意
记得我们在第一章 chapter 1 中讨论过如何隐藏复杂性吗?函数是 Python 隐藏程序复杂部分的关键方法之一。
是时候进行春季大扫除了,你将通过设计使用一些函数的程序来完成这项工作。到目前为止,你已经使用了一些函数;它们是你的代码中的 choice(), len(), raw_input() 部分——所以你对它们的工作方式有一个大致的了解。你(目前)不知道它们真正是什么或者如何创建自己的。
函数基础
函数是一种使程序的一部分自我包含的方法,通常被称为 封装。这是将程序分解成易于理解部分的重要方法。一个好的经验法则是每个函数“应该只做一件事,并且做好它。”你的函数之间应该尽可能少有重叠。这类似于汽车发动机的各个部分是如何工作的;如果风扇皮带断了,你应该更换风扇皮带——同时更换轮胎或火花塞就没有太多意义了。

在你的程序中使用函数有几个优点:
-
你只需要编写程序的一部分,然后你可以在任何需要的地方使用它。以后,如果你不喜欢程序的工作方式或者发现了一个错误,你只需要在一个地方更改你的代码。
-
与你可以选择好的变量名来告诉你程序中正在发生什么一样,你也可以选择好的函数名来描述函数的功能。
-
你现在的代码难以理解的一个原因是它全部是一个大块,很难判断各部分开始和结束的地方。如果它被分解成更小的部分,比如设置洞穴的部分、挖隧道的部分、移动玩家的部分等等,你只需要阅读(和理解)程序的一小部分,而不是一大块。
函数是 Python 中封装的主要单元之一。即使是我们在第六章中介绍的高级结构,如类,也是由函数组成的。Python 还有一种称为 first-class functions 的功能,这意味着你可以将函数分配给变量并将它们传递给其他函数。你将在第七章中了解更多关于如何使用这种函数的信息。
函数有输入和输出,你已经看到了——当你使用函数时,你发送一些数据给它,然后得到一些更多的数据作为答案。一些函数会自己执行某些操作,但其他函数在执行一些计算后会返回一个值。以下是一个简单的函数,它将两个数字相加:
def add_two_numbers(a, b):
""" This function adds two numbers """
return a + b
让我们看看函数声明的第一行。它以保留字 def 开头,后面跟着你的函数名,然后是函数期望在括号内接收的参数。当你稍后在程序中调用函数时,你指定这些参数是什么——它们可以是明确的值或变量。
第二行被称为 docstring,这是在结合良好的变量和函数名时使你的程序更容易阅读的另一种有用方式。它应该是函数的简短描述以及它做什么——任何可能需要知道以正确使用函数的内容。你还使用了 Python 字符串的特殊版本,即三引号,这样你就可以在需要时将 docstring 扩展到多行。
第三行是函数执行其工作的地方。在这种情况下很容易——将 a 和 b 相加。return 语句告诉 Python 函数已经完成,并将 a + b 的结果发送给调用者。
变量作用域
Python 对函数施加了一些限制,以便它们只能影响程序的一小部分,通常是函数本身。在函数内部设置的变量大多数被称为 局部变量,你将无法在函数外部使用它们:
def create_a():
a = 42
create_a()
print a

当你尝试运行这个程序时,你会得到类似于这样的错误:
Traceback (most recent call last):
File "<stdin>", line 5, in test.py
NameError: name 'a' is not defined
发生了什么?你不是在 create_a() 函数内设置了 a 变量吗?实际上,它只是在函数内部被创建。你可以把它想象成“属于”create_a。一旦 Python 完成对变量的处理,它就会被丢弃——在这种情况下,一旦函数退出。
此外,你还将无法更改大多数在函数外部定义的变量。相反,当你创建一个变量时,你将创建一个新的变量。以下代码将无法工作:
a = 42
def add_to_a(b):
a = a + b
add_to_a(42)
除非你明确告知,否则 Python 假设 a 变量应该在 add_one_to_a 函数内。尝试访问函数内部的变量会产生类似于这样的错误:
Traceback (most recent call last):
File "<stdin>", line 1, in ?
File "<stdin>", line 2, in add_to_a
UnboundLocalError: local variable 'a' referenced before assignment
要记住的经验法则是,在函数中使用的变量和在其他程序部分使用的变量是不同的。在函数内部,你应该只使用传递给它的参数变量,一旦回到程序的主要部分,你应该只使用从函数返回的变量。
但是,像大多数经验法则一样,也有例外。在你的程序中,当你修改洞穴列表时,你正在制造一个例外。在 Python 中,洞穴列表和洞穴网络是一种特殊的变量类型,称为对象,而在幕后,你正在向这些对象发送消息而不是直接修改它们。你将在第六章中了解更多关于它是如何工作的信息。但,现在,将列表视为不能修改外部变量的规则的一个特殊例外。
共享状态
当函数(或对象)在某个单一副本上工作时,这被称为共享状态。你可以通过让函数在洞穴列表上工作来使用共享状态,但通常,在你的程序中拥有共享状态是一个不好的事情。如果你在某个函数中有一个错误,Python 可能会损坏你的数据(可能截断它,或者用奇怪的东西替换它)。你不会注意到这一点,直到程序的一个完全不同的部分尝试读取这些混乱的数据并显示奇怪的结果。当这种情况发生时,你的程序将变得非常难以修复,这取决于访问共享状态的函数数量。
注意
共享数据是一把双刃剑。你需要有一些,但它也是错误的一个来源——尤其是如果很多函数共享数据的话。
在第六章中,你将学习如何通过使用另一种称为类的 Python 结构来限制可以访问共享状态的函数数量。然而,现在,你必须小心;你只会在设置时修改你的洞穴,一旦你在玩游戏,你就不会去动它们。
数据和数据处理
大多数程序可以被视为一个信息或数据的集合,同时也包含有关如何与该数据交互的规则。Hunt the Wumpus 程序也不例外。你有一个洞穴结构、Wumpus 和玩家的位置、修改这些数据的函数,以及一个主程序,它使用这些函数将它们全部结合起来。
以这种方式设计你的程序会使它们更容易编写和调试,并且比将所有东西都扔进一个大程序或函数中,给你更多的代码重用机会。
如果你有一个适合你程序所需一切并能轻松检索所需数据的数据结构,那么在编写程序时,这通常是战斗的一半。
现在你已经知道了函数是什么以及为什么你想使用它们,让我们继续看看如何将你的 Wumpus 游戏分解成单独的函数。
修复 Wumpus
在原则上,将程序封装到函数中并不太难:寻找符合以下某些标准的程序部分,并尝试将它们拉出来形成函数,其中它们
-
执行一项特定的事情(自包含)
-
被重复多次
-
难以理解
当考虑“抓捕怪物”游戏时,你应该能够看到它有三个主要部分。你将首先从最简单的函数开始,然后使用它们来构建程序的其余部分。
与洞穴互动
在处理与洞穴相关的任务时,你经常执行几个简单的操作:
-
从一个洞穴到另一个洞穴创建隧道。
-
标记洞穴为已访问。
-
随机选择一个洞穴,最好是适合挖隧道的洞穴。
为了使你在处理洞穴列表时生活更轻松,你可以创建所谓的便利函数。这些函数执行一系列(可能复杂的)操作,但在你使用程序中的函数时隐藏了这种复杂性。好处是,你可以在主程序中一步完成这些操作,一旦创建了函数,你就不必担心细节。这使得你的程序更容易理解,并有助于减少程序中的错误。下一列表介绍了一些便利函数,你可以使用它们使“抓捕怪物”游戏更清晰易懂。
列表 2.7. 添加便利函数

创建隧道和访问洞穴都是明显的函数候选者
。使用错误的变量来引用洞穴很容易出错,而使用像 create_tunnel(cave1, cave2) 这样的代码可以使你的程序更容易阅读。
在 choose_cave 函数
中,你可以隐藏更多细节。当你选择一个洞穴时,你通常只对少于三个通道的洞穴感兴趣。将这个检查添加到函数中将从你的主程序中删除大量重复代码。注意,choose_cave 接受洞穴列表作为输入,因此你可以用它从已访问或未访问的洞穴列表中选择一个洞穴。
不仅“最终”版本的代码可以包含便利函数。你还可以创建便利函数来帮助你编程。如果你想稍后调试你的代码,一个打印所有洞穴的函数
会很有用。
接下来,让我们关注如何创建你的洞穴。
创建洞穴
我们已经讨论了程序使用的数据。一个很好的经验法则是创建执行特定操作于数据或告知你关于数据的函数,然后只使用这些函数来“交流”数据。在编程术语中,这通常被称为接口。有了接口的引导,你犯错误或对数据的含义感到困惑的可能性会小得多。在某种程度上,你已经开始了这个过程。
在“寻宝怪”游戏中,创建洞穴时,你需要执行三个任务,这些任务非常适合作为函数:
-
设置洞穴列表。
-
确保所有洞穴都相互连接。
-
确保每个洞穴有三个隧道。
在 列表 2.8 中,三个函数正是这样做的。这些函数是程序的核心,因此尝试正确实现它们将是有益的。虽然没有硬性规则,但以下是一些表明你的程序编写良好的迹象:
-
它易于阅读和理解。
-
它易于查找和修复错误。
-
当你添加新功能时,你只需要更改程序中有限的部分。
-
在修改程序时,你可以重用一些函数。
然而,最终“正确”的含义将因程序而异,这取决于设计和该设计试图实现的目标。
列表 2.8. 洞穴创建函数

创建洞穴列表
与之前的列表没有太大变化,但仍然是一个好主意,将定义良好的代码部分放入它们自己的函数中以提高可读性。
所有的艰苦工作,包括连接洞穴和挖掘隧道,都在 link_caves
中完成。你注意到你之前定义的便利函数如何进一步整理事情了吗?即使你不知道这个函数在做什么,猜测起来也会很容易。
使用 finish_caves,你还没有创建一个便利函数
。这是唯一一段创建单向隧道的代码,所以它的好处比其他情况要有限一些。在这种情况下是否创建函数可能取决于你是否计划稍后添加更多功能。这样的决定可能是一种风格问题,所以选择对你来说感觉最好的选项。如果你需要重复某些代码,你总是可以稍后更改它。
最后,让我们看看如何将功能引入与玩家互动的“寻宝怪”游戏中。
与玩家互动
在运行程序时,你通常会执行两个任务来找出玩家接下来想要做什么:
-
向玩家说明他们所在的位置。
-
从玩家那里获取一些输入。
由于程序的外观可能会因使用者的反馈或添加新功能而大幅改变,因此通常有道理将界面与程序的其他部分分开,并通过定义良好的机制与玩家互动。下面的列表定义了两个函数,你将在用户界面中用于这两个任务。
列表 2.9. 玩家互动函数

这里就是我所提到的机制。无论玩家输入什么,这个函数都会返回一个特殊值 None(Python 的 null 版本),如果输入不正确,或者返回玩家想要进入的洞穴编号。你可以在程序的主要部分轻松检查这一点。
程序的其余部分
一旦你有了所有这些函数,你的程序中剩下的不是函数的部分就不多了。但这是好事,你很快就会看到。
列表 2.10 展示了更新后的“捕猎怪物”游戏的最终版本。从玩家的角度来看,它表现得与 列表 2.6 中的程序完全一样,但结构已经完全改变。你现在所有的任务都存储在函数中,主程序使用这些函数来完成游戏中的所有操作——显示当前洞穴、获取输入、移动玩家等等。
列表 2.10. 重构后的“捕猎怪物”游戏
from random import choice
...function definitions...
cave_numbers = range(0,20)
unvisited_caves = range(0,20)
visited_caves = []
caves = setup_caves(cave_numbers)
visit_cave(0)
print_caves()
link_caves()
print_caves()
finish_caves()
wumpus_location = choice(cave_numbers)
player_location = choice(cave_numbers)
while player_location == wumpus_location:
player_location = choice(cave_numbers)
while True:
print_location(player_location)
new_location = get_next_location()
if new_location isn't None:
player_location = new_location
if player_location == wumpus_location:
print "Aargh! You got eaten by a wumpus!"
break
注意现在程序的主要部分是多么简短且易于理解。它只有 20 行,而且因为你选择了有用的函数名,即使你不了解 Python,你也能大概猜出它做什么。这就是你应该追求的理想。清晰、易于理解的代码在阅读和修改它时能节省你大量时间。
简化
你已经看到了随着程序的进行,你如何精炼和简化了程序,包括在必要时返回并完全更改部分内容。如果你可以简化你的代码,通常没有不这样做的原因。程序越简单,编写、理解、调试和修改就越容易。精炼过程通常是本章迄今为止你看到的那种方式:
-
为变量和函数使用有意义的名称。
-
使用空白来分隔程序的不同部分。
-
将值存储在中间变量中。
-
将函数拆分,使它们擅长做一件事。
-
限制函数使用的共享状态的数量,并清楚地说明共享状态是什么。
完美不是当你没有东西可以添加时,而是当你没有东西可以移除时。
安托万·德·圣埃克苏佩里
洞穴 ... 检查完毕。怪物 ... 检查完毕。在洞穴中四处奔跑 ... 检查完毕。赢得游戏的办法 ... 嗯。看来没有赢得游戏的方法。最好做些什么来改变这种情况。
弓和箭
在传统的“捕猎怪物”游戏中,你有一把弓和一支箭,当你认为你知道怪物在哪一个洞穴时,你可以选择将箭射入那个洞穴。如果你猜错了,那就太糟糕了!

注意
游戏设计的黄金法则之一是玩家必须能够享受你的游戏。没有弓和箭,你仍然可以探索并享受乐趣,但射箭是了解你的洞穴系统探索和理解是否正确的方法。
现在应该很容易看出如何添加这种功能,因为它在风格上与 get_next_location() 函数相似。你将添加总共三个更多的函数:
-
询问玩家是想移动还是射击。
-
查找移动的位置。
-
查找射箭的位置。
你还将修改get_next_location()函数成为一个通用函数ask_for_cave()。它已经是这样的了,你可以在你的移动和射击函数中调用它。通过这种方式编写,你的两个输入函数将会更短,这有助于保持你的程序可管理。如果你以后添加了需要询问洞穴的新功能,那么你将已经有一个有用的函数可以调用,这使得编程更加容易和快速。
列表 2.11. 添加箭头


你不需要对你的早期get_next_location函数做太多修改;你只需要更改名称以使其意图更清晰,并对程序如何请求输入进行一些外观上的修改(
)。你不需要进行大量修改的事实通常是一个好兆头,表明函数设计得当。如果你必须对函数进行重大修改,这可能是一个迹象,表明原始函数试图一次做太多事情。

函数get_action()(
)与ask_for_cave()函数类似,但有效的输入不同。嗯……也许有可能创建一个更清晰的函数,一个这两个函数都可以调用的函数。在第六章([kindle_split_014.html#ch06])中,你将了解到一种很好的实现方法。
不仅输入可以变成自己的函数。游戏中的动作也可以是函数!(
)。也许“动作”这个词用得太强了——注意动作函数并不做任何事情(也就是说,设置任何变量);它们只返回应该发生的事情,然后主程序根据函数告诉它做什么来采取行动。
你的程序的主要部分仍然像之前一样清晰(
),尽管你添加了一个主要的新功能。如果它变得复杂得多,这通常是一个迹象,表明你可能需要为程序的一些部分创建一个新函数,并简化你正在做的核心内容。
更多氛围
恭喜!你现在拥有了一个完全功能化的“寻找 Wumpus”程序,你可以反复玩它,并用它来给你的朋友留下深刻印象。嗯,差不多吧。它确实可以工作,但每个洞穴的数字并不具有氛围或令人印象深刻。这使得你的程序更容易思考,但它需要额外的润色。那么,改变程序,使其不再使用数字,而是为每个洞穴使用描述性的名称如何?
注意
核心游戏机制是使“寻找 Wumpus”有趣的原因,但像这样的最后润色细节是区分好游戏和伟大游戏的关键。
要做到这一点的一种方法是在你的程序中根据洞穴编号引用存储的洞穴名称列表。而不是显示原始的洞穴编号,显示cave_names[cave_number]。当你向玩家询问洞穴时,他们应该选择 1 到 3 之间的数字,洞穴名称跟在数字后面。你希望达到的效果类似于以下列表所示。
列表 2.12:捕猎独角兽的接口
Black pit
From here, you can see:
1 - Winding steps
2 - Old firepit
3 - Icy underground river
I smell a wumpus!
What do you do next?
m) move
a) fire an arrow
>
洞穴名称列表相对简单。你可以借用我的或者创建自己的。注意,在以下洞穴名称列表中,你可以在项目之间的逗号处将列表拆分成多行。这是为了让程序更容易阅读和修改。
列表 2.13:洞穴名称列表
cave_names = [
"Arched cavern",
"Twisty passages",
"Dripping cave",
"Dusty crawlspace",
"Underground lake",
"Black pit",
"Fallen cave",
"Shallow pool",
"Icy underground river",
"Sandy hollow",
"Old firepit",
"Tree root cave",
"Narrow ledge",
"Winding steps",
"Echoing chamber",
"Musty cave",
"Gloomy cave",
"Low ceilinged cave",
"Wumpus lair",
"Spooky Chasm",
]
你需要做的唯一其他更改是显示的内容以及你将接受的输入,如下面的列表所示。
列表 2.14:捕猎独角兽——现在增加了 40%的氛围!
这里是你打印当前洞穴和玩家可以看到的洞穴列表的地方 。它们都使用你的洞穴名称列表中的可打印洞穴名称,而不是数字。你使用一个
for循环来打印洞穴列表,用tunnel作为隧道列表的索引。你还给它加一,以得到 1、2 或 3,而不是 0、1 或 2 的索引,使其更加友好。
现在你知道了只有三个有效的选择,你可以直接检查这些 而不是需要用户输入洞穴编号。你还从结果中减去一,因为你需要 0、1 或 2 作为列表索引,而不是 1、2 或 3。
即使你在使用 1、2 和 3 作为选项,你仍然将洞穴编号作为索引返回。你所有的更改都包含在print_location和ask_for_cave函数中,并使用我们之前讨论过的接口,所以你的程序中不需要做任何其他更改 。
接下来是什么?
你不必停止使用列表中的程序。你可以添加许多功能,包括原始版本中的一些功能。你可以自由地发明自己的——现在这是你的程序,你可以让它做任何你喜欢的事情。
蝙蝠和陷阱
在原始的《捕猎独角兽》游戏中,还有其他危险:蝙蝠,它们会把玩家带到另一个洞穴,以及陷阱,它们的工作方式与独角兽类似(“我感觉到了一股冷风!”)。
让独角兽移动
一种独角兽变体让独角兽在玩家射箭未命中时移动到另一个随机洞穴——而不是让玩家输掉游戏。
不同的洞穴结构
原始的《捕猎独角兽》有一个静态的洞穴结构,其中洞穴是多面体的顶点。你不必一定要遵循这种格式,但尝试不同的洞穴结构可能会让游戏更有趣。例如,也许你不喜欢单向隧道;这应该很容易修复。此外,在当前版本中,洞穴可以通向自己。我恰好喜欢这种布局,但你可能不喜欢。能够编写自己的程序意味着你不必局限于我的设计选择;你可以自由地做出自己的选择。
摘要
本章涵盖了大量的内容。你不仅学习了 Python 的基础知识以及如何将这些知识组合起来编写程序,我们还探讨了设计程序的可能方式,并分析了为什么某些设计选择可能比其他选择更好。
开始编写程序的最佳方式是选择一个简单的东西,要么是完成你需要的一部分功能,要么是描述程序的核心;然后,从这里开始构建。在《猎捕怪物》游戏中,第一步是创建初始的游戏循环,选择一个洞穴并允许玩家移动到另一个洞穴。从那里,你能够开发一个合适的洞穴系统;在确保洞穴正确连接之后,你的程序变成了一个可以玩并且可以赢(或输)的完整游戏。
继续开发程序的最佳方式是在进行过程中不断优化它,通过将常用部分分解成函数,并尝试在程序的不同部分之间开发一个接口。因为很容易在低级细节中失去对整体结构的把握,例如向列表中添加项目或确保洞穴有三个隧道,因此你的接口通常会涉及隐藏不必要的细节或使程序的部分更容易操作。
第三章. 与世界交互
本章涵盖
-
有哪些库
-
如何使用库,包括 Python 的标准库
-
使用 Python 的
os和sys库的示例程序 -
Python 的字典数据类型
Python 的一个关键优势是其标准库。与 Python 一起安装的标准库是一个庞大的程序代码套件,涵盖了常见的任务,如查找和迭代文件、处理用户输入、从网络下载和解析页面,以及访问数据库。如果你充分利用标准库,你通常可以以比其他方式少得多的时间编写程序,打字更少,错误更少。
Guido 的时光机
标准库非常庞大,以至于在 Python 社区中有一个流行的笑话是,Guido(Python 的发明者)拥有一台时光机。当有人请求一个执行特定任务的模块时,Guido 就跳进他的时光机,回到 Python 的起点,然后——“噗!”——它就已经在那里了。
在第二章(kindle_split_010.html#ch02)中,你使用了 Python 随机模块中的 choice 函数从列表中选择某个元素,所以你已经使用了一个库。在本章中,我们将深入探讨如何使用库,有哪些其他库,以及如何使用 Python 的文档来了解特定的库。在这个过程中,你还将学会一些 Python 的其他缺失部分,比如如何读取文件,你还将发现 Python 的另一种数据类型——字典。
本章的程序解决了一个你可能之前遇到过的问题:你有两个相似的文件夹(可能一个是你的假日照片的备份),你想要知道这两个文件夹之间有哪些文件不同。你将从这个程序的不同角度来处理这个问题,而不是像在第二章(kindle_split_010.html#ch02)中那样,写大部分自己的代码。相反,你将使用 Python 将几个标准库粘合在一起来完成这项工作。
让我们从了解 Python 库开始。
“内置电池”:Python 的库
库通常用于什么?通常,它们针对单一目的,比如通过网络发送数据,写入 CSV 或 Excel 文件,显示图形,或处理用户输入。但库可以扩展以涵盖大量相关功能;没有硬性或固定的规则。
库
编写的程序代码,以便其他程序可以使用。
Python 库可以做 Python 能做的任何事情,甚至更多。在某些(罕见)情况下,比如密集的数值计算或图形处理,Python 可能太慢而无法完成你需要的工作;但你可以扩展 Python 来使用用 C 语言编写的库。
在本节中,你将了解 Python 的标准库,了解你可以添加哪些其他库,尝试它们,并掌握探索单个库的方法。
Python 的标准库
Python 安装时附带了许多库,这些库涵盖了编程时你需要处理的几乎所有常见任务。

如果你发现自己面临一个棘手的问题,阅读 Python 标准库中的模块以查看是否有东西覆盖了你需要做的,这是一个好习惯。Python 手册与标准 Windows 安装程序一起安装,在 Linux 下安装时通常会有文档包。如果你连接到互联网,最新版本也可在 docs.python.org 找到。能够使用一个好的库可以节省你数小时的编程时间,所以前期花上 5 或 10 分钟可以带来巨大的回报。
Python 的标准库足够大,以至于很难找到你需要的东西。另一种学习方法是逐个学习。Python 模块每周博客 (www.doughellmann.com/PyMOTW/) 涵盖了 Python 的标准库的大部分内容,是熟悉可用的内容的极好方式,因为它通常包含比标准 Python 文档更多的解释。
其他库
你不仅限于 Python 安装的库。很容易下载和安装额外的库来添加你需要的额外功能。大多数附加库都附带自己的安装程序或安装脚本;那些没有的通常可以复制到你的 Python 目录的库文件夹中。你将在后面的章节中了解到如何安装库,一旦安装了额外的库,它们的行为就像 Python 的内置库一样;你不需要知道任何特殊的语法。
使用库
一旦安装,使用库就很简单:只需在脚本顶部添加一行导入语句。有几种方法可以做到这一点,但这里介绍三种最常见的方法。
包含一切
你可以通过使用类似以下行的方式将库中的所有内容包含到脚本中
from os import *

这会将 os 模块中的所有内容直接读入你的脚本。如果你想使用 os 中的 access 函数,你可以直接使用,例如 access("myfile.txt")。这的优点是节省了一些输入,但缺点也很严重:
-
现在,你的脚本中有很多奇怪的函数。
-
更糟糕的是,如果你以这种方式包含多个模块,那么你面临的风险是后续模块中的函数会覆盖第一个模块中的函数——哎呀!
-
最后,记住特定函数来自哪个模块要困难得多,这使得你的程序难以维护。
幸运的是,有更好的方法来导入模块。
包含模块
处理事情的一个更好的方法是使用类似 import os 的行。这将导入 os 中的所有内容,但只通过 os 对象使其可用。现在,如果你想使用 access 函数,你需要这样使用它:os.access("myfile.txt")。这需要多打一些字,但你不会冒覆盖其他函数的风险。
只包含你需要的部分
如果你经常使用模块中的函数,你可能会发现你的代码难以阅读,尤其是如果模块有一个很长的名字。在这种情况下,还有一个第三种选择:你可以使用类似 from os import access 的行。这将直接导入,这样你就可以使用 access("myfile.txt") 而不需要模块名,但只包含 access 函数,而不是整个 os 模块。你仍然存在后来模块覆盖的风险,但由于你必须指定函数,而且函数较少,所以这种情况发生的可能性要小得多。
库中到底有什么呢?
库可以包含标准 Python 中包含的任何内容——变量、函数和类,以及当库被加载时应运行的 Python 代码。你没有任何限制;任何在 Python 中合法的内容都可以放入库中。当第一次使用库时,了解其中包含的内容和它做什么是有帮助的。有两种主要的方法可以找到这些信息。
小贴士
dir 和 help 不仅对库有用。你可以在所有 Python 对象上尝试它们,例如类和函数。它们甚至支持字符串和数字。
阅读详细手册
Python 提供了关于其使用、语法、标准库等各个方面的详细手册——几乎是你编写程序时可能需要参考的所有内容。它不涵盖所有可能的使用情况,但大多数标准库都在其中。如果你有互联网访问权限,你可以在 docs.python.org 上查看它,它通常也与 Python 一起安装。
探索
用于查找库包含内容的实用函数是 dir(). 你可以对其任何对象进行调用以了解它支持哪些方法,但它在库中特别有用。你可以将其与 doc 特殊变量结合使用,该变量设置为函数或方法定义的 doc-string,以快速了解库或类的方方法和它们的作用。这种组合非常有用,以至于有一个名为 help() 的快捷方式,它被定义为 Python 的内置函数之一。

对于详细信息,查看文档通常更好;但如果只是需要唤醒记忆,或者文档不完整或令人困惑,那么使用 dir(), doc, 和 help() 会更快。以下列表是一个查找有关 os 库信息的示例。
列表 3.1. 了解更多关于 os.path 库的信息


首先,你需要导入 os 模块
。你可以直接导入 os.path,但这是通常的做法,所以你以后会有更少的意外。接下来,你在 os.path 上调用 dir() 函数,以查看其中包含的内容
。该函数将返回一个包含函数和变量名称的大列表,包括一些内置的 Python 名称,如 doc 和 name。
因为你在 os.path 中可以看到一个 doc 变量,所以打印它并查看它包含的内容
。这是对 os.path 模块及其预期用途的一般描述。
如果你查看 os.path 中一个函数的 doc 变量
,它会显示几乎相同的内容——该函数预期要做的简短描述。
一旦你找到一个你认为可以满足你需求的函数,你可以尝试它来确保
。在这里,你正在对几个不同的文件和目录调用 os.path.isdir() 来查看它返回的内容。对于更复杂的库,你可能发现编写一个简短的程序比在命令行中输入所有内容更容易。
最后,help() 函数的输出
包含了与 doc 和 dir() 相同的所有信息,但打印得更加美观。它还会遍历整个对象,并返回所有变量和方法,而无需你亲自寻找。你可以按空格键或翻页键上下浏览输出,当你想返回到解释器时按 Q 键。
在实践中,在理解足够关于库以使其有用之前,你通常需要结合使用这些方法。快速浏览库文档,然后在命令行进行一些实验,并进一步阅读文档,一旦你理解了所有这些是如何结合在一起的,你将获得一些更细微的要点。此外,请记住,你不必一次性理解整个库,只要你能挑选出你需要的部分即可。
现在你已经了解了 Python 库的基础知识,让我们看看你可以用它们做什么。
另一种提问方式
在你开始组合你的程序之前,有一件事你需要知道。实际上,还有其他几件事,但你可以在路上学到它们。为了开始,你想要能够告诉计算机你想要比较哪些目录。如果这是一个正常的程序,你可能会有一个图形界面,你可以点击相关的目录。但那听起来很复杂,所以你将选择一个更简单的方式来编写:命令行界面。
使用命令行参数
命令行参数常用于系统级程序中。当你从命令行运行程序时,你可以在程序名称后输入额外的参数来指定它们。在这种情况下,你将输入你想比较的两个目录的名称;类似于这样:
python difference.py directory1 directory2

如果你的目录名中包含空格,你可以用引号将参数括起来;否则,你的操作系统会将其解释为两个不同的参数:
python difference.py "My Documents\directory1" "My Documents\directory2"
现在你有了你的参数,你打算如何使用它们?
使用 sys 模块
为了读取你输入的参数,你需要使用 Python 标准库中提供的 sys 模块。sys 处理所有与系统相关的功能,例如找出脚本正在运行的 Python 版本、有关脚本的信息、路径等等。你将使用 sys.argv,它是一个包含脚本名称和任何调用时传递的参数的数组。你的初始程序是 列表 3.2,它将是比较脚本的起点。
列表 3.2. 使用 sys 读取参数

首先,你需要检查脚本是否已经用足够的参数调用!。如果参数太少,那么你将向用户返回一个错误。注意,你正在使用 sys.argv[0] 来找出脚本的名称,以及使用 sys.exit 来提前结束程序。
因为现在你知道至少还有两个其他值,你可以将它们存储起来以供以后使用!。你可以直接使用 sys.argv,但这样你就有了一个很好的变量名,这使得程序更容易理解。
一旦设置了变量,你可以将它们打印出来! 以确保它们是你所期望的。你可以通过尝试“使用命令行参数”部分中的命令来测试它。脚本应该会回应你所指定的内容。
注意
文件对象是 Python 的重要组成部分。许多库使用类似文件的对象来访问其他事物,如网页、字符串以及其他程序返回的输出。
如果你对自己的结果满意,那么现在是时候开始构建下一节中的程序了。
读取和写入文件
在你的重复检查器中,接下来你需要做的是找到你的文件和目录,并打开它们以查看它们是否相同。Python 内置了对文件的处理支持,以及通过 os 模块提供的良好的跨平台文件和目录支持。你将在你的程序中使用这两个模块。
路径和目录(也就是,我的文件在哪里?)
在你打开文件之前,你需要知道它在哪。你想要找到目录中的所有文件并将它们打开,以及在该目录中的任何子目录中的文件,依此类推。如果你自己编写它,这相当棘手;幸运的是,os 模块有一个名为 os.walk() 的函数,它正好能完成你想要的功能。os.walk() 函数返回一个包含路径中所有目录和文件的列表。如果你将 列表 3.3 添加到 列表 3.2 的末尾,它将在你指定的目录上调用 os.walk()。
列表 3.3. 使用 os.walk()

你将对directory1和directory2都做同样的事情!。你可以为directory2重复你的代码,但如果你以后想修改它,你将不得不在两个地方进行修改。更糟糕的是,你可能会不小心只修改了一个,而没有修改另一个,或者修改的方式略有不同。更好的方法是使用for循环中的目录名,这样你就可以在循环中重用代码。

检查你的脚本输入是个好主意!。如果有问题,那么退出并给出一个合理的错误信息,让用户知道出了什么问题。
是遍历目录的部分。现在,你正在打印由os.walk()返回的原始输出,但很快你将对它做些处理。
我在我的电脑上设置了两个测试目录,里面有一些我找到的目录。你可能也这样做是个好主意,这样你可以测试你的程序,并知道你在取得进步。
如果你运行到目前为止的程序,你应该会看到以下类似的输出:
D:\code>python difference_engine_2_os.py . test1 test2
Comparing:
test1
test2
Directory test1
('C:\\test1', ['31123', 'My Music', 'My Pictures', 'test'], [])
('C:\\test1\\31123', [], [])
('C:\\test1\\My Music', [], ['Desktop.ini', 'Sample Music.lnk'])
('C:\\test1\\My Pictures', [], ['Sample Pictures.lnk'])
('C:\\test1\\test', [], ['foo1.py', 'foo1.pyc', 'foo2.py', 'foo2.pyc',
'os.walk.py', 'test.py'])
Directory test2
('C:\\test2', ['31123', 'My Music', 'My Pictures', 'test'], [])
('C:\\test2\\31123', [], [])
('C:\\test2\\My Music', [], ['Desktop.ini', 'Sample Music.lnk'])
('C:\\test2\\My Pictures', [], ['Sample Pictures.lnk'])
('C:\\test2\\test', [], ['foo1.py', 'foo1.pyc', 'foo2.py', 'foo2.pyc',
'os.walk.py', 'test.py'])
在 Python 字符串中,可以通过在另一个字符前使用反斜杠来创建一些特殊字符。例如,如果你想有一个制表符字符,你可以在你的字符串中放入\t。当 Python 打印它时,它将被替换为一个实际的制表符字符。不过,如果你确实需要使用反斜杠——就像你在这里做的那样——那么你需要使用两个连续的反斜杠。
每一行的输出都会给你路径中目录的名称,然后是那个目录内的目录列表,然后是文件列表……这很方便,而且绝对比编写你自己的版本要好。

路径
如果你想要使用一个文件或目录,你需要一个所谓的路径。路径是一个字符串,它给出了文件的精确位置,包括包含它的任何目录。例如,我电脑上 Python 的路径是 C:\python26\python.exe,当它作为一个 Python 字符串表达时,看起来像"C:\python26\python.exe"。
如果你想在上一列表的最后一行为 foo2.py 设置一个路径,你可以使用os.path.join('C:\test2\test', 'foo2.py'),以得到看起来像'C:\test2\test\foo2.py'的路径。当你开始组装你的程序时,你将看到更多细节。
小贴士
在使用路径时,需要注意的一点是,分隔符将根据你使用的平台而有所不同。Windows 使用反斜杠(\)字符,而 Linux 和 Macintosh 使用正斜杠(/)。为了确保你的程序在所有三个系统上都能工作,养成使用os.path.join()函数的习惯是个好主意,它接受一个字符串列表,并将它们与当前计算机上的路径分隔符连接起来。
一旦你找到了你的文件位置,下一步就是打开它。
文件,打开!
在 Python 中打开文件,你可以使用 file() 或 open() 内置函数。它们在幕后是完全相同的,所以使用哪一个都无关紧要。如果文件存在并且你可以打开它,你会得到一个文件对象,你可以使用 read() 或 readlines() 方法来读取它。read() 和 readlines() 之间的唯一区别是 readlines() 会将文件分割成字符串,而 read() 会返回一个大的字符串。此代码展示了如何打开文件并读取其内容:
read_file = file(os.path.join("c:\\test1\\test", "foo2.py"))
file_contents = list(read_file.readlines())
print "Read in", len(file_contents), "lines from foo2.py"
print "The first line reads:", file_contents[0]
首先,使用 os.path.join() 创建一个路径,然后使用它来打开该位置上的文件。你需要输入你电脑上存在的文本文件的路径。现在 read_file 将成为一个文件对象,因此你可以使用 readlines() 方法来读取文件的全部内容。你还将使用 list() 函数将文件内容转换为列表。你通常不会这样处理文件,但这有助于展示正在发生的事情。file_contents 现在是一个列表,因此你可以使用 len() 函数来查看它有多少行,并通过使用索引 0 来打印第一行。
虽然你不会在程序中使用它,但也可以将文本写入文件,以及从文件中读取。为此,你需要以写入模式而不是默认的只读模式打开文件,并使用文件对象的 write() 或 writelines() 函数。这里有一个快速示例:

你使用的是之前使用的相同的 file() 函数,但在这里你给它提供了一个额外的参数,字符串 "w", 来告诉 Python 你想要以写入模式打开它
。
一旦你得到了文件对象,你可以通过使用 .write() 方法并传入要写入的字符串作为参数来向它写入内容
。末尾的 "\n" 是一个特殊字符,表示换行;如果没有它,所有的输出都会在同一行上。你也可以一次性写入多行,通过将它们放入一个列表并使用 .writelines() 方法来实现
。
当你完成文件操作后,通常一个好的做法是关闭它
,尤其是如果你正在写入文件。文件有时可能会被缓冲,这意味着它们不会立即写入磁盘——如果你的电脑崩溃,它可能不会被保存。
这不是你可以对文件做的所有事情,但这足以开始。对于你的差分机,你不需要编写文件,但这将有助于未来的程序。现在,让我们将注意力转向你将在程序中添加的最后一个主要功能。
比较文件
我们几乎完成了,但还有一个最后的障碍。当你运行程序时,你需要知道你是否在其他目录中看到过特定的文件,以及如果有的话,它是否有相同的内容。你可以读取所有文件并逐行比较它们的内容,但如果你有一个包含大图像的大目录呢?这将占用大量存储空间,这意味着 Python 可能会运行得较慢。
注意
通常,考虑你的程序将运行得多快,或者它需要存储多少数据,尤其是如果你正在解决的问题没有明确的结束——也就是说,它可能在大量的数据上运行。
文件指纹
幸运的是,还有一个名为hashlib的库可以帮助你,它用于为特定数据生成哈希。哈希就像文件的指纹:从它提供的数据中,它会生成一个数字和字母列表,对于该数据几乎可以保证是唯一的。如果文件的一小部分发生变化,哈希将完全不同,你将能够检测到变化。最好的是,哈希相对较小,所以它们不会占用太多空间。下面的列表展示了如何为单个文件生成哈希的小脚本。
列表 3.4. 为文件生成哈希

在导入你的库之后,你从命令行读取一个文件名并打开它
。接下来,你在这里创建一个哈希对象
,它将处理所有的哈希生成。我正在使用 md5,但在hashlib中还有很多其他的选项。
一旦你有一个打开的文件和一个哈希对象,你可以通过update()方法
将文件的每一行输入到哈希中。
在你将所有行输入到哈希之后,你可以以hexdigest形式获取最终的哈希
。它只使用数字和字母a–f,所以它很容易在屏幕上显示或粘贴到电子邮件中。
测试脚本的一个简单方法是将它运行在自身上。在运行一次之后,尝试对脚本进行一些小的修改,比如在文件末尾添加一个额外的空白行。如果你再次运行脚本,输出应该是完全不同的。
在这里,我正在运行哈希生成脚本本身。对于相同的内容,它总是会生成相同的输出:
D:\test>python hash.py hash.py
df16fd6453cedecdea3dddca83d070d4
D:\test>python hash.py hash.py
df16fd6453cedecdea3dddca83d070d4
这些是在 hash.py 文件末尾添加一个空白行后的结果。这是一个微小的变化(大多数人不会注意到),但现在哈希已经完全不同了:
D:\test>hash.py hash.py
47eeac6e2f3e676933e88f096e457911
现在既然你的哈希已经工作,让我们看看你如何在程序中使用它们。
Mugshots:在字典中存储你的文件的指纹
现在你可以为任何给定的文件生成哈希,但你需要有一个地方来存放它。一个选项是将哈希放入一个列表中,但每次你想找到特定的文件时都要在列表中进行搜索,这会很慢,尤其是如果你有一个包含大量文件的目录。有一个更好的方法来做这件事,那就是使用 Python 的另一个主要数据类型:字典。

你可以把字典想象成一个数据包。你把数据放进去,给它一个名字,然后,稍后,当你想要数据时,你给出字典的名字,字典就会返回数据。在 Python 的术语中,这个名字被称为键,而数据是该键的值。让我们通过查看以下列表来了解如何使用字典。
列表 3.5. 如何使用字典

字典与列表相当相似,只是你使用花括号而不是方括号,并且用冒号分隔键和它们的值。
列表的其他相似之处在于,你可以将任何你喜欢的作为值包含在内!,包括列表、字典和其他对象。你不仅限于存储简单的类型,如字符串或数字,或一种类型的东西。唯一的限制是键:它只能是不可以修改的东西,如字符串或数字。

一旦你在字典中添加了值,想要取回它,可以使用字典的名称,后面跟着方括号中的键!。如果你完成了一个值,使用del后跟字典和要删除的键就可以轻松地移除它!。
字典是对象,因此它们有一些有用的方法!以及直接访问。keys() 返回字典中的所有键,values() 将返回其值,而 items() 返回键和值。通常,你会在 for 循环中使用它,如下所示:
*for key, value in test_dictionary.items(): ...*
在决定为字典使用哪些键和值时,最佳选项是使用对键唯一的值,以及你程序中需要的数据作为值。你可能需要在构建字典时以某种方式转换数据,但这通常会使你的代码更容易编写和理解。对于你的字典,你将使用文件的路径作为键,以及你生成的校验和作为值。
现在你已经了解了哈希和字典,让我们把你的程序组合起来。
整合所有内容
“量两次,切一次”是一个古老的谚语,通常情况下是正确的。在编程时,你总是有撤销键,但你无法撤销你用来编写最终丢弃的代码所花费的时间。
在开发程序时,制定一个如何进行的计划往往很有帮助。你的计划不必非常详细;但它可以帮助你预见潜在的障碍或问题点,从而避免它们。现在你认为你已经拥有了所有需要的部分,让我们从高层次上规划一下程序的整体设计。它应该类似于
-
读取并检查你想要比较的目录。
-
构建包含第一个目录中所有文件的字典。
-
对于第二个目录中的每个文件,将其与第一个字典中的相同文件进行比较。
这看起来相当直接。除了有这个整体结构外,考虑每个文件的四种不同可能性也有帮助,如下面的图所示。
图 3.1. 文件之间差异的四种可能性
| 情况 1 | 文件不存在于目录 2 中。 | 情况 2 | 文件存在,但在每个目录中都不相同。 |
|---|---|---|---|
| 情况 3 | 两个目录中的文件完全相同。 | 情况 4 | 文件存在于目录 2 中,但不在你的第一个目录中。 |
给定这种粗略的方法,应该有几个问题应该引起注意。首先,你一开始就构建所有校验和的计划可能最终并不好。如果文件不在第二个目录中,那么你将经历所有构建校验和的麻烦,但你永远不会使用它。对于小型文件和目录,这可能没有太大区别,但对于较大的文件(例如,数码相机的照片或 MP3 文件),额外的时间可能很重要。这个问题的解决方案是在你构建的字典中放入一个占位符,并且只有在你知道你有这两个文件时才生成校验和。
你不能使用列表吗?
如果你将占位符放入字典而不是校验和,你通常会从使用列表开始。在字典中查找值通常要快得多;对于大型列表,Python 需要逐个检查每个值,而字典只需要一次查找。另一个好理由是,如果你正在比较独立对象,字典比列表更灵活且更容易使用。
第二,如果一个文件在第一个目录中但不在第二个目录中会发生什么?根据我们刚才讨论的粗略计划,你只是在比较第二个目录和第一个目录,而不是反过来。如果你不在第二个目录中,你不会注意到文件。一个解决方案是在比较文件时从字典中删除文件。一旦你完成了比较,你就知道剩下的任何东西都是第二个目录中缺失的。
这样的规划可能需要时间,但通常在前期花点时间解决潜在问题会更快。当你改变主意时,什么更容易丢弃:五分钟的设计还是半小时的编码?列表 3.6 和 3.7 展示了基于更新计划的程序的最后两部分。你可以将它们与 列表 3.2 和 3.3 结合起来,得到一个可工作的程序。

列表 3.6. 差异程序实用函数


这是从 列表 3.5 中的程序,封装成一个函数。注意,第二行添加了一个文档字符串,这样就可以很容易地记住函数的作用。图片
因为你会为两个目录构建一个文件列表,所以有一个函数返回你需要的所有关于目录的信息是有意义的,这样你就可以每次重用它。你需要的是 root,即最低级别的目录(在命令行中输入的目录)以及相对于该根目录的所有文件列表,这样你可以轻松地比较两个目录。例如,C:\test\test_dir\file.txt 和 C:\test2\test_dir\file.txt 都应该分别输入到它们各自的字典中作为 \test_dir\file.txt。
因为 os.walk() 默认从目录的根目录开始,你只需要记住它返回的第一个目录
。你通过在进入 for 循环之前将 dir_root 设置为 None 来做到这一点。None 是 Python 中的一个特殊值,表示“未设置”或“值未知”。如果你需要定义一个变量但不知道它的值,你会使用它。在循环内部,如果 dir_root 是 None,你知道这是第一次进入循环,你必须设置它。你还设置了一个 dir_trim 变量,这样你就可以在之后轻松地修剪每个返回的目录的前一部分。
![f0089-01.jpg]
一旦你有了目录根,你可以从 os.walk() 返回的路径的前面切掉目录和路径分隔符的公共部分
。你通过使用字符串切片来完成这个操作,它将返回字符串的一个子串。它的工作方式与列表索引完全相同,因此它从 0 开始,可以到字符串的长度。
当你完成时,你需要返回目录列表和目录的根目录
,使用一种特殊的 Python 数据类型,称为 元组。元组与列表类似,但它们是不可变的——一旦创建后就不能更改。
现在你已经检查了输入并设置了程序的所有数据,你可以开始使用它们了。正如 第二章 中所描述的,当你简化“抓捕 Wumpus”时,执行任务的程序部分相当短,清晰且易于理解。所有棘手细节都隐藏在函数内部,正如你在下一个列表中可以看到的那样。
列表 3.7. 查找目录之间的差异
![03list07_alt.jpg]
要分配从你的函数返回的两个变量,你用逗号将它们分开
。你已经在使用 dictionary.items() 在 for 循环中看到过这个。
这是第一次比较
:如果文件不在目录 1 中,那么你警告用户。你可以像使用列表一样使用字典中的 in,Python 会在对象是字典键时返回 True。
如果文件在两个目录中都存在,那么你将为每个文件构建校验和并比较它们
。如果它们不同,你知道文件是不同的,你再次警告用户。如果校验和相同,那么你保持安静,因为你不想用屏幕和屏幕的输出压倒人们——他们想知道差异。
一旦你比较了第三部分中的文件,你就从字典中删除它们。任何剩下的你知道都不在目录 2 中,你告诉用户关于它们
。
你的程序似乎已经完成了,但你确定它正在正常工作吗?是时候测试它了。
![f0091-01.jpg]
测试你的程序
如果你还没有创建测试目录,现在可能是创建一些测试目录的好时机,这样你可以尝试你的脚本并确保它正在工作。当你开始处理具有实际后果的问题时,这尤其重要。例如,如果你正在备份一些家庭照片,而你的程序没有报告文件已更改(或不存在),你将不知道要备份它,如果硬盘崩溃,你可能会丢失它。或者它可能报告两个文件是相同的,而实际上它们是不同的。
你可以在已有的目录上测试你的脚本,但特定的测试目录是个好主意,主要是因为你可以练习你期望的所有功能。至少,我建议
-
至少添加两个目录层级,以确保正确处理路径
-
创建一个名称中至少包含一个空格的目录
-
使用文本和二进制文件(例如,图像)
-
设置你期望的所有情况(文件缺失、文件差异、文件相同)
通过考虑所有可能的情况,你可以在实际目录上运行程序之前捕捉到程序中的错误,避免遗漏某些内容,或者更糟糕的是,丢失重要数据。以下图显示了我在电脑上设置的初始测试目录(称为 test)。
图 3.2. 差分引擎的测试目录

这个测试目录并没有涵盖所有可能的失败情况,但它确实检查了大多数情况。下一步是将该目录(我将其称为 test2)复制,并对差分引擎进行一些修改,如图图 3.3 所示。我在文件中使用数字 1 到 4 来表示每种可能的情况,其中 1 和 4 是缺失的文件,2 是有些差异的文件,3 是两个目录中相同的文件。
图 3.3. test2,第一个测试目录的几乎相同副本

你可以看到运行脚本在这些目录上的输出:
D:\>python code\difference_engine.py test test2
Comparing:
test
test2
dir test root is test
dir test2 root is test2
test\test 2\test2.txt and test2\test 2\test2.txt differ!
image4.gif not found in directory 1
test 2\test4.txt not found in directory 1
test\image2.gif and test2\image2.gif differ!
test4.txt not found in directory 1
test\test2.txt and test2\test2.txt differ!
test1.txt not found in directory 2
test 2\test1.txt not found in directory 2
image1.gif not found in directory 2
这似乎正是你所期望的。在每种情况下,脚本都在进入测试 2 目录,并正在获取文件之间的差异——1 和 4 缺失,2 不同,3 没有报告,因为文件在两个目录中都是相同的。
现在你已经测试了你的脚本,让我们看看你可以做些什么来改进它。
改进你的脚本
你目前的脚本已经可以工作,但还可以进行一些改进。首先,它返回的结果顺序不对。第二目录中缺失的文件出现在最后。理想情况下,你希望它们出现在该目录其他条目旁边,这样更容易看出差异。
注意
这种策略看起来熟悉吗?这正是你在开发 Hunt the Wumpus 时所做的。你首先编写尽可能简单的程序,然后在此基础上添加你需要的功能。
按顺序排列结果
初始时可能难以看到如何对结果进行排序,但如果你回想一下第二章,你使用 Hunt the Wumpus 时采用的一个策略是将程序与其界面分离。在你的差异引擎中,你还没有做很多这样的事情——现在可能是一个开始的好时机。你的程序需要两个部分:一个部分执行工作并存储它生成的数据,另一个部分用于显示这些数据。下面的列表显示了如何生成结果并存储它们。
列表 3.8. 将生成的结果与显示分离

这是个技巧。而不是一得到结果就尝试显示,这意味着你试图将你的程序结构塞入你的显示结构中,你将结果存储在字典中以供稍后显示!
。
每次比较的结果都存储在 result 中!
,文件路径作为键,比较结果的描述作为值。
应该可以处理存储结果的问题;让我们看看你是如何显示它们的:

sorted() 是一个内置的 Python 函数,用于对项目组进行排序!
。你可以给它列表、字典键、值或项、字符串以及各种其他东西。在这种情况下,你正在使用它来按file_path对result.items()进行排序,这是result.items()的第一部分。
在循环体中,你使用 in 来检查字符串的内容!
。你想要知道这个路径是否是目录的一部分,在这种情况下,它将在其中包含 os.path.sep,你还想了解结果是否显示文件是相同的。

现在你已经显示了目录根下的所有内容,你可以继续显示子目录中的所有内容!
。你正在反转if语句的感念,以显示第一次没有显示的内容。
事后看来,这相对简单。遵循你在 Hunt the Wumpus 中建立的模式,将数据与其显示分离是一种强大的策略,可以使复杂的问题易于理解和编程。
比较目录
你的程序可能还需要处理的情况是空目录。目前它只查找文件,任何空目录都会被跳过。虽然对于你的初始用例(在备份之前检查缺失的图像)来说是不必要的,但它将来几乎肯定是有用的。一旦你添加了这个功能,你将能够发现目录中的任何变化,除了文件权限的变化——而且这需要出人意料地少的代码。下一个列表显示了我是如何做到这一点的。
列表 3.9. 比较目录

第一件事是在生成列表时包括目录路径以及文件
。要做到这一点,你需要使用 + 运算符将 dirs 和 files 列表连接起来。

如果你尝试打开一个目录来读取其内容,你会得到一个错误
;这是因为目录不像文件那样有内容。为了解决这个问题,稍微作弊一下是可以的。你修改了 md5 函数,并使用 os.path.isdir() 来找出它是否是一个目录。如果是,你返回一个虚拟值 '1'。目录的内容并不重要,因为文件将依次检查,你只关心目录是否存在(或不存在)。
一旦你做了这些更改,你就完成了。因为目录遵循与文件相同的数据结构,所以你不需要对你的程序的比较或显示部分做任何更改。你可能想要添加一些目录到你的测试目录中,以确保程序运行正常。
你已经改进了你的脚本,但这并不意味着你没有更多可以做的事情。
接下来该做什么?
现在的程序根据你的初始需求已经功能完善,但你也可以使用你迄今为止编写的代码来实现其他目的。以下是一些想法:
-
如果你确定不会有不同的文件,你可以扩展程序来创建一个来自多个来源的合并目录。给定多个目录,将它们的目录内容合并到第三个单独的位置。
-
一个相关的任务是在目录中找到所有相同的文件副本——你可能有几个旧的备份,并想知道是否在其中一个中不小心添加了额外的文件。
-
你可以创建一个变更监控器——一个脚本,它会通知你某个目录中的变更。一个脚本会检查一个目录并将结果存储在一个文件中。第二个脚本会查看那个文件和目录,并告诉你是否有任何输出已经改变。你的存储文件不需要很复杂——一个包含每个文件的路径和校验和的文本文件就足够了。
-
你也可以将你的
os.walk函数作为一个模板,来做一些除了检查文件内容之外的事情。一个检查目录大小的脚本可能很有用。你的操作系统可能会给你提供关于特定目录占用多少空间的信息,但如果你想要绘制随时间变化的用量图,或者按文件类型分解你的结果呢?一个脚本会更加灵活,你可以让它做你需要的一切。
你需要避免重造轮子的诱惑。如果已经有工具可以解决你的问题,通常最好使用它,或者如果可能的话,至少将其包含在你的脚本中。例如,你可能考虑编写一个程序来显示不同版本文件之间的变化以及它们是否不同——但这样的程序已经存在;它被称为 diff。它在 Linux 下广泛可用作为命令行程序,但 Windows 上也有可用,并且还有图形版本。
另一个编程技巧是知道何时停止。过度优化你的程序可能很有趣,但你总是可以选择开始你的下一个项目!
摘要
在本章中,你了解了一些与 Python 每个安装一起提供的标准库包,以及如何包含和使用它们以及如何了解不熟悉的包。你构建了一个通常相当复杂的应用程序,但由于你很好地使用了几个 Python 库,你不得不编写的代码量是最小的。
在下一章中,我们将探讨另一种组织程序的方法,以及函数的其他用途和一些其他可以帮助你编写更清晰、更简洁代码的 Python 技巧。本章的程序测试起来相当容易,但并非所有程序都会那么简单,因此我们还将探讨另一种测试程序的方法,以确保它们能够正常工作。
第四章. 组织起来
本章涵盖
-
如何更彻底地规划程序
-
使用单元测试测试程序
到目前为止,你一直在学习如何使用 Python,编程一直是“摸着石头过河”。《寻找巨怪》在规划方面几乎没有什么,也没有任何测试,尽管你在上一章中进行了测试,但你只是轻描淡写地进行了测试。现在,你将改变策略,专注于如何更彻底地规划和测试程序。你还将使用函数做一些更复杂的事情,并了解pickle和text-wrap,这两个 Python 标准库中的功能。
本章的主要变化是,你将开始学习如何自动测试程序。单元测试是一个相对较新的概念,它可以帮助将大量测试和调试程序的工作转移到计算机上。你还将通过使用测试驱动开发(先写测试再写程序)来颠覆开发实践。这听起来很奇怪,但看到单元测试如何使棘手的问题变得简单,以及先写测试如何有助于更好地塑造程序的设计,可能会令人耳目一新。
由于本章的主题是“组织”,你将要编写的程序是一个生产力应用程序,用于帮助管理待办事项列表。你将使其成为一个命令行应用程序,这样你可以专注于重要的部分,即确保待办事项列表功能正确。稍后,在第八章(chapter 8)中,我们将探讨如何扩展程序的核心并为其添加一个网络界面。
让我们先弄清楚你想要实现什么目标。
规划:指定你的程序
首先你需要做的是提前尝试弄清楚你的程序需要做什么,以及有什么是令人愉快的。这样,你将提前知道你试图做什么,并且你有时间思考最佳的问题解决方法。
你将采用自顶向下的方法进行设计,其中你将分解程序并描述每个部分。你还将想要考虑每个部分如何组合在一起,以及它们如何通信和存储数据——通常被称为程序的结构。如果你有足够的细节开始编程:太好了。如果没有,你可以通过将部分分解为其他部分来重复这个过程,直到每个部分都足够详细,你可以开始工作(或者,让你的客户签字)。不同的项目将需要不同级别的细节,这取决于它们是什么以及最终客户是谁。这个过程完成后的最终产品被称为规范或spec,它类似于建筑物的蓝图。

在待办事项列表程序的情况下,你可以使用流行的计算机行业缩写词CRUD来帮助指导你的规范。CRUD 并不是对你程序质量的评价;它代表创建、检索、更新和删除,这是你通常需要能够对数据进行的基本操作:
-
添加任务项(创建)。
-
查看您已创建的任务(检索)。
-
编辑任务中的信息(更新)。
-
从您的列表中删除任务(删除)。
程序还需要处理用户输入,除了保存当前任务以便以后可以访问,还需要搜索特定任务(或者至少显示符合某些标准(如“今天到期”)的任务列表)。
在架构方面,你将重用为“猎捕独角兽”程序准备的路由功能以及共享数据,但你将增强用户界面,以便你可以要求用户提供更详细的信息。
你怎么知道你的程序能工作?
在我们深入编码之前,让我们先简单谈谈单元测试以及如何更彻底地测试你的程序。正确测试听起来很无聊,但实际上正好相反。如果你不测试,你不可避免地会调试你的程序——如果你认为测试无聊,调试则更糟糕十倍。让我们看看自动测试如何帮助使编程更有趣。
手动测试——无聊!
在“猎捕独角兽”程序中,你完全没有自动测试。你对程序所做的任何更改都是手动验证的;每次你做出更改时,你都会运行程序并输入一些数据,如果一切看起来都很好,那么你可以假设你的更改是好的。这可能会带来一些缺点,正如你所看到的。你的程序可能看起来没问题,但存在你看不见的错误。
注意
为什么强调测试?简单的答案是创建程序比尝试调试它更有趣。彻底测试,尤其是使用自动测试,有助于在错误萌芽时将其扼杀,并将保持编程的乐趣!

另一个问题是有趣。这意味着,随着你进一步开发程序,你更有可能假设某些东西在正常工作,而实际上可能已经损坏,尤其是在你要求程序的老部分做新的事情时。一个“简单的修复”可能会严重损害你的程序。
功能测试
当你编写你的差分机时,你使用了一些简单的功能测试来确保程序能正确工作。你设置了两个目录,运行了程序,并检查了它是否输出了正确的结果;也就是说,你直接测试了它的功能。这比手动测试要好得多,因为它更容易、更快,而且你不太可能感到无聊,但缺点是它只能找到你程序中的错误。你仍然需要经历繁琐的调试过程,才能找出导致错误的原因。
另一个问题是你可能会改变程序的工作方式,那么您可能需要更改测试,这可能会涉及大量工作。在这种情况下,您可能会倾向于忽略测试并回到手动测试。
单元测试:让计算机来做
幸运的是,有一种更简单的方法来处理重复、无聊的任务:让计算机来做。在本章中您将使用的机制称为 单元测试。单元测试通过测试程序的小部分,或单元,来工作。就像您一直在分解程序以使其更容易编写一样,单元测试将程序分解成单元——例如函数——并确保它们在一系列输入下都能正常工作。单元测试还有助于隔离您正在测试的代码,这意味着在运行测试时发生的任何错误都可以快速追踪到单个函数并修复。
测试驱动开发
在开发程序时使用单元测试的关键方式是先编写测试。这看起来有些反直觉,但它迫使您关注代码的高级设计而不是细节。其工作方式是这样的:如果您想添加一个功能,您将编写一个测试并运行它。您还没有编写测试所需的代码,所以它会失败。然后您添加足够的代码以便测试通过并开始工作;然后您想到另一个测试,并重复这个过程。图 4.1 是一个方便的三步流程图,如果您迷路了可以遵循。
图 4.1. 测试驱动开发周期

在您的工作过程中,您将构建一系列测试,这将帮助您的程序保持正确的方向。您的单元测试还将作为程序应该如何工作的低级规范。它将回答诸如,Python 应该从这个函数中期望什么输入?当您给它一些它没有期望的输入,或者输入错误时,它应该做什么等问题?
编写程序
让我们从编写第一个测试开始。有一些库可以帮助您测试您的程序,但,目前,您将保持简单,并使用 Python 的内置 assert 语句。assert 采用以下格式:
assert something == something_else, "Message if assert is triggered!"

Python 将测试第一个条件,就像一个 if 语句一样,如果它为假,则将引发一个错误,并显示您在第二部分中指定的消息。您将通过一个尝试程序特定部分的函数来测试程序的每个部分,然后使用 assert 确保结果符合您的预期。
将以下列表中的程序输入到名为 test_todo.py 的文件中。此测试指定了程序中的一个函数应该如何表现。
列表 4.1. 您的第一个单元测试

这将是您将要编写的程序,将被命名为 todo.py
。现在请不要创建文件;您将在下一节中这样做。
第一个测试是一个简单的函数
。请注意,你应该遵循与程序中其他函数相同的规则来编写你的单元测试。如果它们令人困惑,那么当你的测试失败时,你将难以找到错误或修复问题。
todos 将是程序存储待办事项列表的地方
。请注意,你通过将其设置为空列表来覆盖当前的待办事项列表。这有助于你更快地编写测试——如果你有一个共享的待办事项列表,那么你必须在运行测试之前担心其他测试对它的操作。要注意的另一件事是,你正在引用模块的 todos 版本。如果你只使用一个局部 todos 变量,你将有两个版本:一个在模块中,另一个是你创建的,你可能会混淆这两个版本。
测试运行待办程序的一个小部分
,创建一个待办事项。你可能想在一个测试中做更多的事情,但你的测试越大,出错时追踪错误就越困难。
现在你使用 Python 的 assert 命令来测试 create_todo 是否正确执行
。它应该已经创建了一个带有正确细节的任务项并将其添加到你的待办事项列表中。
一旦你设置了测试,你就可以调用它来运行并测试程序
。你还在代码中添加了一个 print 语句,这样你知道测试何时成功运行。
注意
不仅你的测试应该是简单的。单元测试还迫使你使你的 code 简单。大型、笨拙的函数难以测试——并且,通过扩展,难以理解。
关于 列表 4.1 的主要注意事项是它简短且简单。单元测试不应该长、复杂且难以理解——如果它们是这样的,那么你的测试或代码可能存在问题。
使测试通过
你有一个单元测试,但它做什么?让我们运行它看看会发生什么:
Traceback (most recent call last):
File "D:/Documents and Settings/Anthony/.../test_todo.py",
line 2, in <module>
import todo
ImportError: No module named todo

哎呀,出了什么问题?嗯,没什么。这正是你预期的。因为你还没有编写程序,你的测试没有 todo 模块可以工作。从这里开始,你将向程序中添加一些内容来修复从单元测试中得到的错误,所以请继续在同一个目录下创建一个名为 todo.py 的文件,并再次运行测试:
Traceback (most recent call last):
File "D:/Documents and Settings/Anthony/.../test_todo.py",
line 18, in <module>
test_create_todo()
File "D:/Documents and Settings/Anthony/.../test_todo.py",
line 6, in test_create_todo
todo.create_todo(
AttributeError: 'module' object has no attribute 'create_todo'
另一个错误,但这次不同,它在第 6 行而不是第 2 行,所以你正在取得进步。你的测试现在抱怨找不到 create_todo 函数,所以让我们继续在 todo.py 中添加它。作为输入,它需要你的待办事项列表,以及标题、描述和级别,因为这是你在测试中指定的:
def create_todo(todos, title, description, level):
pass
这是一个简单的程序,它使用 Python 的 pass 语句什么都不做。它也没有通过你的测试,但你正在取得进步。你开始测试程序的功能,而不是检查函数是否存在:
Traceback (most recent call last):
File "D:/Documents and Settings/Anthony/.../test_todo.py",
line 18, in <module>
test_create_todo()
File "D:/Documents and Settings/Anthony/.../test_todo.py",
line 10, in test_create_todo
assert len(todo.todos) == 1, "Todo was not created!"
AssertionError: Todo was not created!
现在测试抱怨待办事项没有被添加到待办事项列表中。使测试通过所需的代码现在很明显,所以一次性修复所有问题:
def create_todo(todos, title, description, level):
todo = {
'title' : title,
'description' : description,
'level' : level,
}
todos.append(todo)
测试通过。当你运行测试针对此程序时,你应该在屏幕上看到ok - create_todo打印出来。太棒了——测试通过了,所以你知道函数正在正常工作。现在,让我们看看你将在程序中如何调用此函数。
组装你的程序
你将遵循与 Hunt the Wumpus 相同的策略:快速构建一个简单的可运行程序,然后在此基础上构建。你可以创建的最简单可用的程序将只能创建待办事项——但这应该足够了。为了达到这个目标,你必须考虑你想要如何输入待办事项,以及如何从输入到程序中相关函数的运行,然后如何将输出返回到屏幕。更重要的是,你想要考虑一个简单的方法来编写测试,以确保一切正常工作。

测试用户界面
单元测试的一个大问题在于它并不擅长测试用户界面。例如,没有 Python 命令可以让你在raw_input中输入信息。当涉及到测试图形界面,包括鼠标位置和弹出窗口时,事情变得更加困难。
解决方案是尽可能简化用户界面,使其易于测试。理想情况下,应该能够仅通过查看代码来确保代码的正确性。在你的待办事项列表应用程序中,你将使用以下代码片段来运行程序中的所有内容。请将其添加到文件的底部 todo.py 中。
列表 4.2. 你无法测试程序的一部分

首先,你使用程序用户输入的命令进行一些操作
。最初,你并不接受输入,这可能会显得有些反直觉,但它会允许你在程序首次运行时打印欢迎/帮助屏幕。"run_command"是程序的精髓,但它接受任何已输入的输入;这将使你在下一分钟测试时更容易。
一旦你用给定的输入运行了程序,告诉 Python 要求更多的输入
。
在run_command函数之外的一个命令是quit。你在这里检查任何以单词quit开头的命令
。如果你看到它,立即跳出while循环;这将结束程序。
当你将程序作为测试的一部分导入时,你不想运行*main_loop*函数,但如果你直接将其作为程序运行,你就想运行它!解决方案是这个*if*语句,这在 Python 程序中很常见。__name__是当前命名空间,或者你正在运行的模块的名称。如果程序直接运行,它将被称为__main__,你可以用*if*语句捕获它。通常,这个*if*块会放在程序的末尾,以确保它使用的所有函数都已定义。
注意
这种结构被称为事件循环;它告诉 Python 等待程序使用者(或网络等其他来源)的输入,并根据找到的内容采取行动。
你还需要一种方式来获取多行输入。如果你要添加一个新的待办事项,那么你需要询问它的标题、描述和级别。这也相当难以测试,除非采取极端措施,比如修改*raw_input*函数。以下函数也放在todo.py中,并提示程序使用者输入一个字段列表。
列表 4.3。程序中你无法测试的另一部分
def get_input(fields):
user_input = {}
for field in fields:
user_input[field] = raw_input(field + " > ")
return user_input
再次强调,这是程序的一个简单部分。我们尽量让它尽可能简单,这样你手动调试的量就会很少。如果测试无法检测到错误,那么错误应该很明显。
你如何处理你的输入?
现在你可以开始认真编写run_command脚本了。你首先想让程序做的事情是根据用户输入选择一个 Python 函数来运行,所以让我们先做这部分。这相当简单,但你需要使用一个新的 Python 技巧。你应该把下一部分放在test_todo.py中。所有的测试代码都将放在那个文件中,程序代码本身将放在todo.py中:
def test_get_function():
assert todo.get_function('new') == todo.create_todo
print "ok - get_function"
...
test_get_function()

你计划设置一个函数,告诉你给定命令应该运行什么。然后,当你用新命令调用它时,你期望它返回*create_todo*函数——不是函数的结果,而是函数本身。在 Python 中,你可以像分配字符串、数字、列表和字典一样分配函数到变量。你很快就会看到如何使用它。
现在你有了测试,请在*test_create_todo()*下面添加调用它的代码,并再次运行测试。你刚刚添加的新测试应该会失败。
这里有一些代码可以修复它,并为你提供扩展到包括其他函数的空间:
commands = {
'new' : create_todo,
}
def get_function(command_name):
*return commands[command_name]*
*commands*是一个包含所有命令的字典。键是命令的名称,值是将被调用的函数。
给定你想要运行的命令的名称,*get_function*将返回你需要调用的函数。
运行命令
这只是谜题的一部分。下一部分是如何将 get_input 函数的输入传递到你的最终函数中。好吧,你需要知道特定函数需要哪些字段,所以我们从这里开始:
def test_get_fields():
assert (todo.get_fields('new') ==
['title', 'description', 'level'])
print "ok - test_get_fields"
这相当简单,因为它与你用来查找命令函数的方法几乎相同。像以前一样在底部添加测试函数,运行你的测试,并确保你的新测试失败;然后你可以编写代码。我的版本在下面的列表中。
列表 4.4. 查找命令字段
commands = {
'new' : [create_todo, ['title', 'description', 'level']],
}
def get_function(command_name):
return commands[command_name][0]
def get_fields(command_name):
return commands[command_name][1]
注意到 commands 字典以及你用来查找函数的代码都已经发生了变化。将命令函数及其期望的字段放在同一个地方,这样更容易更改并且不会混淆,这是完全正常且完全可行的——只要你的测试仍然通过。

现在你已经创建并测试了这两个低级函数,你就可以尝试创建 run_command 函数了。这将完成程序的界面部分,然后你可以继续编写其他执行工作的代码。不过,你首先需要使用一些单元测试技术。
下面的列表是一个新的测试,确保你的 run 命令正常工作。
列表 4.5. 测试 run_command

理想情况下,当你进行单元测试时,你希望测试程序的某个特定方面。如果你有将多个函数的结果组合在一起的测试,并且其中一个函数失败了,你仍然需要调试程序。你只想测试 run_command,所以让我们创建一个虚拟测试程序
,它只返回其输入,而不是强迫测试使用(然后解释)create_todo 函数的结果。
你还需要测试的是数据是否正确地输入到命令函数中。但是,你如何在每次测试时都不强迫某人输入数据呢?解决方案是使用 Python 默认变量来模拟数据输入
。当程序正常运行时,它将通过 get_input 函数询问用户;但是如果你输入一个字典,它将使用该字典。
因为架构只是移动文本
,所以你的函数很容易测试——输入一些输入字典,并检查你是否得到了正确的输出。注意,我在这里以不同的方式拆分了行,通过使用反斜杠字符(\)而不是花括号。如果你选择使用这个方法,确保它是行上的最后一个字符;否则,它将不起作用。
让我们看看使测试通过的代码是什么样的。同样,一旦你编写了测试,代码相对简单——而且你通常不需要调试你已经编写的函数。我的版本如下所示。
列表 4.6. 编写 run_command

首先,你使用一个默认变量
。在大多数情况下,你将数据设置为 None,但在测试时,你可以将数据作为字典输入以模拟用户输入。
你使用 lower() 方法将命令转换为小写,然后你在你的字典
中查找它。如果你找不到它,那么你将返回一个错误。
当程序正常运行时,数据将是 None。当你看到这个时,你知道你需要从用户那里读取一些输入,并且你可以调用 get_fields 来了解应该询问
什么。
现在你已经知道要调用哪个函数以及传递什么数据,你可以继续操作并控制
。命令函数将执行其应有的操作,并将结果作为字符串返回,然后你将这个字符串返回给用户。你输入字典前面的 **** 看起来有点奇怪——它的作用是将字典参数作为关键字参数传递。这样,你可以在函数定义中看到特定函数期望的值,而不是有一个大值。
太好了——现在你有一个直接的方法来分配程序使用者的文本输入并将其传递给特定的函数。接下来的章节将讨论通过编写其他适合该框架的函数来扩展这个框架。

运行你的程序
到这个阶段,你可能已经注意到一些奇怪的事情;你实际上还没有运行程序来确保它工作。在之前的章节中,你一直在编写程序,运行它以确保它工作,然后再编写一些。但是,由于你一直在进行单元测试,所以你实际上一次都没有这样做——单元测试通过了,所以代码必须工作,对吧?
你可能对此有点怀疑,但你的程序在这个阶段已经相当功能化了,如果你想确保它工作,可以运行它。下面的列表显示了一个示例运行。
列表 4.7. 到目前为止的程序
D:\Documents and Settings\Anthony>python todo.py
? I don't know what that command is.
> test
abcd > qwer
ijkl > uiop
Command 'test' returned:
abcd: qwer
ijkl: uiop
> new
title > Test Todo
description > This is a test
level > Very Important
None
> quit
Exiting...
仍然有一些细节需要整理,但你已经可以从程序的用户界面创建待办事项,这意味着你的所有基础设施都在正常工作。你正在取得进展!
检查清单
到目前为止,你在程序上已经取得了良好的开端,并且程序的大部分核心功能已经工作。此外,你可以运行测试来确保程序 持续 工作。你的单元测试也有另一个好处;因为你只测试了程序的小部分,所以你的程序已经被分解成小的函数,没有必要整理或重构它。至少,目前不需要。

下一步该做什么?
你应用程序的下一个重要部分是显示待办事项列表中的内容;如果你不能在以后看到列表中的内容,添加到列表中就没有什么意义了。为了开始,你将编写一个测试函数,该函数将显示你所有的待办事项。然后,你将查看如何简化它以隐藏不那么重要的待办事项。完成这些后,我们将探讨如何保存你的列表并重新加载它们,这样你就不必在程序重新启动时重新输入一切。
下一个列表确保当你在你的程序中查看待办事项时,待办事项能够正确显示。
列表 4.8. 测试你的待办事项列表视图
![04list08.jpg]
首先,你设置一个待办事项列表
。因为你的待办事项列表处于已知状态,这将使测试更容易进行。在这里重用你的创建测试来设置待办事项列表很有诱惑力,但这是一个陷阱。尽管这可能节省一些代码,但你正在在测试之间创建依赖关系。以后,如果创建函数中存在错误,你将有两个(或更多)测试失败,错误追踪将变得更加困难。
你在待办事项列表
上运行视图函数,并返回结果。为了使生活更简单,你使用结果字符串的 split() 方法通过行结束符来拆分结果。我想象待办事项列表将看起来像以下这样:
Item Title Description Level
1 test todo This is a test Important
![f0116-01.jpg]
接下来,你测试你期望的单词是否存在于每一行
。第一行应该是列的标题,第二行应该有你期望的值。注意你在测试中指定每个值都是单独的——你可以为函数期望的确切结果生成一个字符串,但这又是一个陷阱。过于严格地指定结果会使测试变得脆弱,格式或列的顺序的任何微小变化都可能导致测试失败,而实际上它不应该失败。在实践中,你应该只测试重要的内容,并尽可能多地省略其他内容。
现在你已经知道你期望你的函数做什么,你可以继续编写它。Python 字符串有几种你可以用来格式化输出的方法;让我们看看你如何使用它们。以下列表中的 show_todos() 函数展示了其中一些方法的工作原理。
列表 4.9. 显示待办事项
def show_todos(todos):
output = ("Item Title "
"Description Level\n")
for index, todo in enumerate(todos):
line = str(index+1).ljust(8)
for key, length in [('title', 16),
('description', 24),
('level', 16)]:
line += str(todo[key]).ljust(length)
output += line + "\n"
return output
commands = {
'new' : [create_todo, ['title', 'description', 'level']],
'show' : [show_todos, []],
'test' : [test, ['abcd', 'ijkl']],
}
首先,你初始化输出为一个标题列表。随着你进行,你将逐行将每一行复制到输出的末尾。注意字符串是如何放在两行中并用括号括起来的?这使得在页面上阅读更容易。Python 会自动将字符串这样连接起来,所以当它被分配给output时,将只有一个大字符串。
![f0117-01.jpg]
接下来,你遍历每个待办事项并添加数字。我添加了一个索引,以便更容易地看到你有多少个待办事项。enumerate() 接受一个列表或可迭代对象,并返回列表中的下一个项及其索引——这对于这种情况很有用。
为了格式化结果,你开始一行打印待办事项的编号。为了使其余的列对齐,你将其转换为字符串,并使用 .ljust() 字符串方法将其扩展到八列。Python 字符串有其他许多类似的方法,例如 .rjust() 和 .center()。
接下来,你按列打印每个待办事项的部分。在这里,我稍微有点狡猾,把你要打印的键及其宽度拉到一个列表中,然后你遍历这个列表。这样,你可以从待办事项中提取每个值,并使其具有正确的宽度。
最后,别忘了将 show 命令添加到命令列表中,这样你就可以在运行程序时使用它。它不需要任何参数,所以它有一个空列表。
你添加的代码很简单,但如果你像为“Hunt the Wumpus”开发时那样使用“代码和错误修复”,你可能需要多次来回才能使代码工作。使用单元测试,你可以指定你的输出应该是什么,然后直接将其添加到你的程序中。
我非常忙,非常重要
你还想检查的另一件事是,你的视图函数是否按正确的顺序显示待办事项。理想情况下,重要的事情应该根据其重要性以不同的方式显示。你将重要事项放在顶部,不重要的事项放在底部。问题是,到目前为止,你一直将重要性级别作为文本字段,这可能会使你的列表难以排序。幸运的是,Python 中有处理这类问题的工具。

但我们可能有点超前了。首先,你需要一个测试来确保你的程序正确地排序了待办事项!以下列表中的测试应该能解决问题。
列表 4.10. 测试视图的顺序

这是你的待办事项样本列表
。它们是按逆序排列的(不重要到重要),以确保排序工作正常。

待办事项应该按重要到不重要的顺序排列
。你还应该用大写字母显示重要状态,这样它们会更突出。
这涵盖了你的预期。你是如何做到这一点的?在实践中,你仍然会以文本字符串的形式输入它们,那么你为什么不把所有重要的字段放在前面,所有标记为“不重要”的字段放在底部,其余的放在中间呢?
你通常会使用三个连续的 for 循环来做这件事,每个循环对应一个单独的情况——但我想向你展示一种更快的方法,一旦你习惯了,这种方法也更清晰。
列表推导式
列表推导式是 Python 中一个强大的内置工具,用于理解事物列表。它们是解决常见编程问题的通用解决方案:处理项目组。也许你想要获取列表中每个项目的总和,或者过滤掉那些不重要的,或者只包括那些开放时间过长的。列表推导式将让你做到所有这些。
当你想显示重要待办事项时,你试图请求的是类似以下这样的内容:“Python,请给我待办事项列表中标记为‘重要’的每个待办事项。”
你可以使用列表推导式来获取更多内容。以下列表展示了常见的列表推导式类型,以及它们能做什么的感觉。
列表 4.11. 列表推导式可以做的许多事情
![04list11_alt.jpg]
这是一个列表推导式,它给你你想要的东西 ![one.jpg]。Python 将遍历你的列表中的每个待办事项,并收集与你的 if 语句匹配的待办事项(即级别为“重要”)。你添加了一个 .lower() 调用,以便将级别转换为小写;important、Important 和 IMPORTANT 都将匹配。
列表推导式能做的不仅仅是这些。你还可以将函数应用于最终结果中的每个成员,以获得不同的列表 ![two.jpg]。在这里,你正在编写一个函数来将级别大写,然后对标记为“重要”的每个待办事项调用它。
如果你有一系列数字,你还可以对它们执行其他操作 ![three.jpg]。如果它们是对象,你可以调用该对象的所有方法,依此类推。你可以在列表推导式中对原始值执行的所有操作都可以在列表推导式中执行。
![f0120-01.jpg]
最后,你有一个使用两个数字列表生成坐标列表的列表推导式 ![four.jpg]。
考虑到所有这些,你的最终代码列表可能看起来像以下这样。
列表 4.12. 对待办事项列表进行排序的代码
![04list12_alt.jpg]
这里有三组列表推导式 ![one.jpg],分别对应待办事项的三个不同级别:“重要”、“不重要”和“其他所有事项。”你将重要的那些大写,以便使它们更加突出。
一旦你将待办事项列表分开,你可以使用 + 来将它们重新连接 ![two.jpg]。
如果你想查看运行测试时的输出结果,你可以在发送回之前在这里打印输出。你的程序将打印出函数返回的确切内容,因此你会看到最终用户会看到的内容 ![three.jpg]。或者,你也可以在你的测试中放置打印语句。如果你在失败的测试中遇到麻烦,打印出你正在处理的某些变量可以节省大量时间。
最后,当你再次运行测试时,你会注意到你之前的一个测试,test_show_todos,现在失败了。在这种情况下,你不必担心——你在真正考虑程序应该如何看起来之前就写了这个测试。只需将测试中的“Important”改为“IMPORTANT”,测试就应该通过了。
现在,你可以通过使用一些列表推导式,一个易于理解的有力工具,将待办事项排序到特定的顺序。通常,你会发现你可以用一个简单的函数和一个列表推导式来替换复杂的 for 循环。
哎呀,一个错误!
如果你查看 列表 4.13 中的输出,你会注意到列并没有完全正确显示。show_todos 测试看起来没问题,但第二个测试的所有字段都挤在一起——当一个条目太长时,它会将其他列推出去。
列表 4.13. 测试的输出
C:\Documents and Settings\Anthony>python test_todo.py
ok - create_todo
ok - get_function
ok - get_fields
ok - run_command
Item Title Description Level
1 test todo This is a test IMPORTANT
ok - show_todos
Item Title Description Level
1 test important todoThis is an important testIMPORTANT
2 test medium todoThis is a test Medium
3 test unimportant todoThis is an unimportant testUnimportant
ok - todo sort order
这看起来并不好。单元测试不是应该确保代码没有错误吗?不幸的是,并不完全是这样。你可以测试你所想到的事情,但如果还有你没有考虑到的事情,那么你的程序可能仍然存在错误。如果你在使用单元测试并且注意到程序中存在这样的错误,解决方案相对简单:写一个测试来覆盖你 确实 预期的行为,确保它失败,然后修复你的程序。
也可能你在某个测试中犯了一个错误。同样,单元测试是一个有用的工具,但不是完整的解决方案。仍然有可能测试错误的事情,或者单元测试中存在错误。在实践中,这比程序中存在错误的可能性要小得多,因为单元测试更容易跟踪。
尽管如此,问题仍然存在:当一行太长时,你希望程序做什么?如果你对这个问题没有明确的答案,那么编写测试就很难了!如果字符串太长,你可以将其截断到固定宽度——但你希望所有信息仍然可见。更好的方法是折行每个待办事项。
列表 4.14. 更好地显示你的待办事项
Item Title Description Level
1 test important This is an important IMPORTANT
todo test
2 test medium This is a test Medium
todo
3 test This is an unimportant Unimportant
unimportant test
todo
从视觉角度来看,这看起来要好得多。但你怎么能编写这样的程序呢?简短的回答是...完全是你迄今为止一直在使用的方式:先写一个测试!我想出了 test_todo_wrap_long_lines,你可以在下一列表中看到。
列表 4.15. 测试你的待办事项是否换行

首先,你设置了一个带有长行的待办事项,这些行应该被换行
。在这个例子中,我尽量让它看起来像真实的待办事项,以确保在需要跨多行换行以及只有一行时,换行都能正常工作。注意,我已经将描述拆分,使得行长小于 24 个字符,这是描述列的宽度。这有助于你在编写测试时看到你需要检查的内容。
然后你测试当查看待办事项时,正确的行是否出现!。对描述的测试会跨越几行,但这样你就能确保程序对较长的描述进行了适当的包装。
好吧,测试很简单;但我怀疑编写代码可能会有些困难。幸运的是,你到目前为止一直在彻底测试,所以如果你犯了错误,你的测试应该会捕捉到。
注意
如果代码太难编写,你会怎么办?在这种情况下,通常的答案是你在一次尝试做太多事情,你需要将问题分解成更小、更简单的部分。
问题在于你正在包装行,但它们在其他行内部,所以你不能依赖 Python 的内置打印机制。Python 确实有一个textwrap模块可用,它并不完全符合我们的期望,但这是一个开始。整体计划将是编写一个函数来生成每个待办事项的行。在这个函数内部,你可以使用textwrap模块将待办事项的每个部分(标题、描述等)拆分成行,然后以某种方式将它们编织成最终的输出。让我们试试。下面的列表展示了新的函数show_todo和你需要对show_todos进行的更改。
列表 4.16. 显示待办事项的函数


首先,你使用textwrap模块的wrap()函数将标题和描述包装到正确的字符数!。你还需要在脚本顶部添加import textwrap。
你首先使用索引和级别构建第一行,假设它们不会包装,再加上标题和描述的第一条包装行!。你使用的是+=运算符,它是output = output + ...的简写。(你还在每列之间添加两个空格,以便更容易阅读。)
如果标题或描述中还有任何行,你在这里打印它们,并为索引和重要性输入占位符!。你使用的是稍微不同的 range 版本,其中你指定了起始索引和结束索引。如果只有一行,max_len也将是 1,enumerate将为空,并且不会打印额外的行。另一个需要注意的问题是,在打印标题和描述中的每一行之前,你需要确保你还有东西可以打印;否则,Python 会因“list index out of range”错误而崩溃。你使用单个空格的乘法运算,这样就可以清楚地看到字符串的长度。
虽然这并不是严格必要的,但你将待办事项的排序分离到自己的函数中!。你可以这样做,因为你已经有了单元测试来捕获任何损坏,并且这使得程序看起来更美观。
新版本的 show_todos 同时调用了 show_todo 和 sort_todos,它更短,更容易理解
。这告诉你你正在朝着正确的方向前进;如果它更长更复杂,你就做错了。
你需要做的最后一件事是更新 test_todo_sort_order 测试用例,使其引用输出中的新行号。在那之后运行你的测试,它们都应该通过,你现在有了待办事项的更美观的视图。哇!下一个功能!
保存你的工作
你需要能够做的最后一件事是将待办事项列表保存到文件中。没有这个功能,使用程序的人将不得不重新输入所有的工作。嗯,他们可能不会这么做——他们可能会找到一个可以保存他们数据的程序。因为你自己也会使用这个程序,所以这不是一个选择。
要保存你的待办事项列表,你将使用一个名为 pickle 的 Python 模块,它被设计用于将 Python 对象写入文件。你可以序列化的对象类型有一些限制,但所有基本的 Python 类型,如字符串、列表和字典都受支持,所以它非常适合你的程序。使用 pickle 的优点是快速实现和易于测试,但它在纯文本编辑器中不可编辑。编写你自己的函数来读取和写入自定义格式是可能的,但这更难编程,并且很难完全正确。在这里,你将选择简单的方法,但如果你需要,你总是可以在以后阶段编写你自己的格式。
你如何测试你的保存功能?最简单的方法是使用所谓的 往返测试:创建一个待办事项列表并保存它,然后从同一文件重新加载它并与原始版本进行比较。如果它们相同,那么你的测试就通过了;但缺点是你在一次操作中同时测试了加载和保存功能。如果你的测试没有通过,那么很难判断是加载功能、保存功能(或两者)出了问题。解决这个问题的方法是创建一个 已知良好 的文件,从一个成功的保存中生成。但这意味着你已经正确地保存过了。
让我们选择第一个选项,看看结果如何。你将以相当直接的方式使用内置的 Python 模块,所以你不太可能遇到任何大问题。下一个列表是你的往返测试,test_save_todo_list。

列表 4.17. 测试你的应用程序是否正确保存

在这里,你正在创建你的待办事项列表
,方式和你在之前的测试中做的一样。唯一的区别是,你保留了一份副本,这样你就可以在重新加载待办事项列表后参考它。你还要确保你没有现有的待办事项列表;否则测试会失败或覆盖某个人的待办事项列表。
首先,你运行保存命令
。虽然你不能直接测试保存文件的内容,但你可以通过使用 os.listdir() 函数来测试文件是否已创建。'.' 是当前目录的简写。
接下来,你清空待办事项列表,然后调用 load_todo_list() 函数来重新加载它
。最后,你将有两个字典列表,它们应该完全相同。
运行你的测试,确保新的测试失败,然后你可以添加以下代码来创建你的保存文件并重新加载它。

列表 4.18. 加载和保存你的待办事项

pickle 需要一个打开的文件来工作,因此你首先以 "w" 模式打开你的保存文件
,你将其命名为 "todos.pickle",这意味着打开文件并覆盖其中已有的内容。
pickle 语法很简单——只需调用 pickle.dump() 函数
,并传入你想要序列化的对象以及你想要序列化到其中的文件。

接下来,你关闭文件
。你不必执行此步骤,因为一旦离开 save_todo_list() 函数,Python 将自动关闭文件,但养成这个习惯是个好习惯,并且有助于保持事物整洁。
因为你在加载时正在替换待办事项列表,所以你需要将其声明为全局变量
。这意味着你对 todos 变量所做的更改将在函数外部可见。
在你开始接受任何用户输入之前,你需要检查文件是否存在
。如果你尝试打开一个不存在的文件,那么 Python 将引发错误,程序将崩溃。
当你准备好从保存文件中加载数据时,应该以读取模式打开它
。然后,pickle.load() 方法将读取你之前保存的任务列表。完成之后,你需要关闭保存文件。你不需要返回对象,因为它是全局变量,并且你已经更新了它。
在你添加了加载和保存功能之后,唯一剩下的问题是你在哪里调用它们。你可以让用户显式地调用它们,但用户必须知道这些函数的存在并记得调用它们。一个更简单的方法是在程序开始时自动调用 load_todo_list(),然后在程序退出时保存。你可以通过在主循环的开始和结束时调用 load_todo_list() 和 save_todo_list() 来轻松实现这一点,如下面的列表所示。
列表 4.19. 自动加载和保存
def main_loop():
user_input = ""
load_todo_list()
while 1:
print run_command(user_input)
user_input = raw_input("> ")
if user_input.lower().startswith("quit"):
print "Exiting..."
break
save_todo_list()
在你开始接受任何用户输入之前,你首先查找现有的保存文件,如果存在,则从该文件中加载待办事项。
当用户发出退出命令时,你将退出循环,程序将自动保存其待办事项列表。如果你想更加谨慎,你可以在每个可能引起变化的函数的末尾调用 save_todo_list():create_todo(), edit_todo(), 和 delete_todo()。
你可以添加、查看和保存你的待办事项列表(对于那些记得章节第一部分 CRUD 中的 C 和 R 的你),并存储迄今为止输入的所有待办事项,因此你现在要完成所有绝对必要功能只需处理现有待办事项的编辑和删除。
编辑和删除
对于这个特定的应用,它们并不是那么必要,这就是为什么我们将其留到了最后,但如果没有删除或编辑功能,将会非常令人烦恼。
快速修复
首先,有一个问题你应该在开始之前解决。当你之前对待办事项进行排序时,你没有更新存储的列表,每次查看时都会重新排序你的列表。当用户想要告诉你他们想要编辑或删除哪个待办事项——比如说,使用索引号——你将不得不再次构建列表以知道他们指的是哪一个。如果待办事项列表已经排序,将会容易得多。让我们现在就做这件事。这意味着每次添加待办事项到待办事项列表时都要调用 sort_todos 函数。用户在编辑待办事项时可能会更改其重要性,所以你也需要在那时调用它,但不需要在删除时调用,因为那时它已经是有序的。

如你迄今为止所做的那样,先编写一个单元测试。
列表 4.20. 添加待办事项排序器

到现在为止,这应该很熟悉了。对于这次测试,你只需设置两个待办事项,顺序相反
。
是动作发生的地方。你创建一个重要的待办事项。按照目前的代码,这将只将重要的待办事项追加到底部。

现在检查所有待办事项是否按正确顺序排列
。重要的待办事项排在前面,不重要的排在底部,其余的排在中间。
现在运行你的测试,最新的一个应该会失败。是时候编写一些代码了!
列表 4.21. 新的 sort_todos

理想情况下,你希望能够在程序的任何地方调用 sort_todos(),但就目前的状态而言,这有点困难。前进的最简单方法是使 todos 成为全局变量
。请注意,一旦你这样做,你就不需要从 sort_todos() 中返回 todos。现在你可以从任何你想要的地方调用它。
现在 sort_todos() 更容易使用,你可以将其从 show_todos() 中移除,并将其放在 todos 顺序可能被更改的任何地方
。在下一节中,你也会在更改待办事项列表时调用它。
你会发现,一旦 show_todos() 无法再对待办事项进行排序,你将遇到测试失败,但这些问题很容易解决。在 test_todo_sort_order() 和 test_show_todos() 中,一旦你设置了待办事项列表,只需调用一次 todo.sort_todos(),以确保它们按正确的顺序排列并具有正确的格式。

这应该足以让你开始下一部分了。你所做的是确保待办事项列表始终按相同的顺序排序,无论是幕后还是显示在屏幕上。这是对程序存储数据方式的重大改变,但由于你有一套单元测试,你可以有信心进行这样的重大更改不会破坏程序中的任何内容。让我们继续前进,把最后几块拼图放好。
删除待办事项
现在你已经准备好开始从你的列表中删除待办事项了。执行此操作的代码相当直接,但由于你开始进行可能删除用户数据的破坏性函数,你需要提高单元测试的级别。到目前为止,你主要测试的是“快乐路径”,确保代码在正常使用中工作。同样重要的是要确保你的程序注意到错误或数据,并生成适当的错误消息。现在让我们看看如何在以下列表中测试这一点。
列表 4.22. 测试删除



对于删除测试,你设置了三个待办事项,并删除了中间的一个。这测试了你不会删除错误的待办事项,以及正确的一个被删除了
。你还在检查 delete_todos 是否返回一个合理的消息来告诉你它做了什么。
到目前为止,你能够跳过的一件事是检查用户输入。对于你的删除脚本,这不再可能,因为你可能会输入一个错误的数字或不是数字的东西。在这里,你检查所有可能的错误输入类型都会生成一个错误消息,并且不会删除任何待办事项
。
这涵盖了我想到的所有可能出错的情况,但重要的是要注意,测试失败是一个持续的过程。换句话说,失败测试不是最终的。特别是对于更复杂的函数,可能有不良的输入或数据会导致你没有考虑到的错误。当你发现这样的输入时,你应该将其视为程序中的错误。但修复很简单:为覆盖失败添加另一个单元测试或额外的测试用例,然后修复你的代码以便测试通过。
以下是我编写的代码,以使两个删除测试通过。
列表 4.23. 删除待办事项
def delete_todo(todos, which):
if not which.isdigit():
return ("'" + which +
"' needs to be the number of a todo!")
which = int(which)
if which < 1 or which > len(todos):
return ("'" + str(which) +
"' needs to be the number of a todo!")
del todos[which-1]
return "Deleted todo #" + str(which)
commands = {
...
'delete' : [delete_todo, ['which']],
这里是你进行检查的地方,以确保你提供的输入与你的待办事项列表中的待办事项匹配。它必须是一个数字,所以你首先使用 .isdigit() 方法来确保这一点。然后,你使用 int() 将其转换为数字,并检查它是否对应于待办事项列表中的条目。如果你的输入未能通过这些检查中的任何一个,你将不采取任何行动,只是返回一个信息性错误消息。
现在你可以删除待办事项了。注意,你正在通过从它减去一来将你给出的数字转换为列表索引。
使用程序的人可能想知道你做了什么,所以你在这里告诉他们
。你返回的任何字符串都将作为结果打印在屏幕上。

因为添加了一个新命令,所以你还需要将它添加到 commands 字典中。它只接受一个参数 'which'),这是你想要删除的待办事项的 ID。
这就是你确保删除待办事项正常工作的所有步骤。现在,所有测试都应该通过,你可以继续到下一节。
编辑待办事项
编辑待办事项也是相当直接的。因为从许多方面来看,编辑是删除和编辑之间的交叉,你可以将之前的单元测试和程序代码中的代码结合起来创建一个 edit_todo() 函数。原则上,我们在这个章节中已经涵盖了所有内容。

唯一的难点在于你遇到了 Python 的 raw_input() 函数的限制。因为你无法预先填充要输入到函数中的文本,所以你无法让它像你希望的那样轻松地编辑现有条目。不幸的是,你需要绕过这个限制。最简单的方法是创建一个空白条目不会覆盖现有字段;相反,对于任何你想要编辑的字段,你可能需要重新输入数据或者从输出中的较早部分剪切并粘贴它。这很烦人,但对此你无能为力。在 第八章 中,你将扩展你的待办事项列表,并使用 Django 为它提供一个网络界面,因此适当的编辑将不得不等到那时。
让我们继续编写一个测试,以覆盖你可以添加的功能。
列表 4.24. 测试待办事项编辑

这是一个应该编辑待办事项的函数调用
。你正在使用输入参数中的空字符串来模拟空白条目。
现在你测试待办事项是否有正确的字段
。那些为空的字段应该保持不变,而那些不为空的应该设置为正确的值。你还检查你仍然只有一个待办事项,并且得到正确的响应。
你还需要测试的是,编辑待办事项的优先级会导致它重新排序。如果一个待办事项突然变得重要,你希望它出现在列表的顶部,而不是仍然在中间。下面的列表显示了如何测试这一点。
列表 4.25. 编辑后测试排序顺序

首先,你设置两个中等优先级的待办事项
。你编辑最后一个待办事项,将其优先级设置为重要,但其他字段保持不变
。
现在第二个待办事项的重要性已经改变,它应该出现在列表的第一位而不是第二位
。
这就涵盖了你对编辑待办事项所期望的行为。让我们看看你如何在程序中实现它。
列表 4.26. 编辑待办事项的代码

你使用与在*delete_todo()*中相同的代码来检查用户输入
。你可能将其提取出来并使其成为一个函数,但由于你只在使用两个地方,是否这样做是一个选择。如果你添加了第三个使用此代码的函数,那么它应该肯定被分离。
现在你可以更新待办事项
。对于任何非空输入,你将覆盖待办事项的字段,使用已输入的内容。
最后一步是对待办事项进行排序(因为级别可能已经改变)并返回一条消息,告知用户发生了什么
。别忘了将*edit_todo()*函数添加到*commands*字典中,并带上它需要的参数。
你完成了!在章节开始时设定的所有基本功能都已实现,你现在拥有了一个可用的待办事项列表程序。
注意
“基本”的定义因人而异,但确保你的应用程序的核心功能到位,无疑将有助于完善其他基本部分的细节。
更好的是,你有一个涵盖应用程序所有主要功能的全面测试套件,所以如果你在以后进行任何更改,你可以轻松地检查以确保程序仍然正常工作。
接下来该做什么?
就像本书中的所有程序一样,待办事项列表程序现在属于你,你可以根据自身需求对其进行扩展和增强。尽管它可用,但还有一些事情可以显著改进它,添加这些功能将是一个有用的练习。以下是一些你可以添加的功能的想法。
帮助命令
如果你对于每个命令的作用感到困惑,一个帮助命令可能会使事情更清晰。为了使其更加方便,你可能想要将其绑定到多个命令,如*?*和*help*,并且如果程序不理解给出的命令,还可以将其添加到错误信息中。
撤销
当删除或编辑待办事项时,如果你犯了一个错误,就没有办法退出。你只是个人,尽量允许错误发生是有意义的,尤其是在删除待办事项时。一旦你删除了一个,就没有办法恢复它。
一种解决方法是在列表中标记已删除的待办事项而不是将其从列表中删除,并且在正常情况下不显示它们。如果需要,你可以使用另一个命令来显示已删除的待办事项(可能如*showdeleted*?)并恢复它们(*restore*)。
不同的界面
你可能会觉得这个界面要求你先点击提示,然后点击响应,然后提示,然后响应,如此循环,有点让人烦恼。界面设计得尽可能容易编程,但这并不意味着它尽可能容易使用。一个替代方案是允许用户在输入命令后添加参数。例如,你不必输入 delete
时间管理和估计
另一个有用的功能是记录你认为完成任务所需的时间估计,然后稍后标记项目为完成,并记录你在它们上的时间。最后,你可以生成一个报告,显示你花费了时间的地方,并发现你的初始估计有多准确。能够估计完成任务所需的时间可以是一项有用的技能,但只有通过练习并获得对你估计准确性的反馈,它才会得到改善。
学习其中一个单元测试框架
单元测试本身并不特别困难,这就是为什么你在本章中开发了你自己方法的原因。但有许多单元测试模块你可以使用,使用它们提供了两个主要优势。首先,它们可以帮助你将测试组织成测试套件和类,并从多个文件中自动运行所有测试,以及在每次测试前后运行设置和清理代码。其次,它们允许你测试比仅使用简单的断言语句更多的内容,并在出错时提供更详细的信息。你最初想要查看的三个单元测试模块是 unittest 和 doctest,它们都包含在 Python 中,以及 py.test,这是一个比 unittest 更轻量级的版本,可以从 pytest.org/ 获取。
摘要
在本章中,你了解了单元测试,亲自看到了如何使用它来编写程序,并开发了一套大量的单元测试,这样你就可以在扩展应用程序时不必担心如何避免在更改某些内容时破坏它。你还了解了一些程序方面(主要是用户输入)的测试比较困难,并发现了如何通过尽可能保持未测试代码部分的小巧和简单来解决这个问题。
你还了解到 Python 有第一类函数,这些函数可以被分配给变量,就像整数和字符串等更基本的数据类型一样,而且利用函数的一个好方法是将它们作为字典中的值。你将在第七章学习更多关于第一类函数的内容。你还使用了两个 Python 库,pickle 和 textwrap,并且还发现了如何使用列表推导来过滤待办事项列表,这是一种简单但强大的过滤和处理列表的方法。
我们最后讨论的是如何在开发过程中解决出现的问题。有时候,比如编辑待办事项,除了找到一个合理的解决方案之外,没有太多可以做的事情。在其他情况下——例如,当包装待办事项的文本时——一些耐心和坚持(以及一套合理的测试)可能会带来回报。
第五章. 以业务为导向的编程
本章涵盖
-
为现实世界编写程序
-
与现有系统交互
-
如何处理程序中的错误
在本章中,我们将探讨 Python 如何在现实世界中使用,以帮助您更好地更快地完成工作。作为一个示例项目,您将从互联网上的网页中提取一些股票数据,提取您感兴趣的数据,然后看看您如何可以使用这些数据,报告这些数据,并将这些报告发送给感兴趣的相关方。为了使您的生活更加轻松,所有这些都将编写得易于自动化。
许多程序员和系统管理员面临的一个关键任务是让许多不同的系统相互通信。你可能需要做以下事情:
-
从一个系统中读取一些数据
-
将其与第二个的结果进行比较
-
确保两者都合理(通常称为合理性检查)
-
将结果保存以供以后使用
-
用报告将你发现的内容或遇到的问题发送给相关人士
人们依赖于这些系统中的信息,所以你写的任何东西都必须是健壮的。你不能尝试一些东西并希望一切顺利。听起来令人畏惧吗?别担心——Python 本质上是一种实用的语言,它有许多特性和库模块,可以使得与真实世界及其所有怪癖的交互变得更加容易。
为什么自动化?
想要自动化的更多自私的理由是,一旦你设置了你的程序,你就不必再担心它了,这让你有更多时间去思考更重要和有趣的事情。
你将从构建你的报告程序开始,然后我们将探讨你可以采取哪些步骤来预测错误并使程序无懈可击。
让程序相互通信
你如何让程序相互通信?通常,程序将有一些数据输入和输出,因此集成两个程序通常是一个问题,即获取一个程序的输出,读取其数据,然后将这些数据以第二程序能理解的形式呈现。最终,你可以使用 Python 作为解释器将许多不同的程序连接在一起。你将构建的系统看起来就像图 5.1。
这种类型的程序通常被称为粘合代码,因为你正在将两个或更多程序粘合在一起形成一个系统。
图 5.1. Python 作为粘合语言,帮助其他程序“交谈”

CSV 来拯救!
如果你有一个通用的数据格式——一个所有相关程序都能说的数据“语言”,那么将程序粘合在一起的过程会容易得多。最接近数据交换通用语言的是简单的逗号分隔值(CSV)文件,它是一种由标题行和随后的行数组成的简单电子表格格式。行上的项目由逗号分隔,因此得名逗号分隔值。一些 CSV 文件将使用其他字符值,如制表符,来分隔它们的值,但原理是相同的。

使用 CSV 文件有许多优点。CSV 是一种简单直接格式,这在开发或调试你的系统时很重要。如果你遇到问题,你可以在文本编辑器中读取文件。大多数编程语言都将有一个库来读取和写入 CSV,许多程序也将 CSV 用作导入或导出格式,因此你可以重用为一个程序编写的所有例程。最后,它也很好地映射到大多数数据——你甚至可以将其视为一个 SQL 表——因此它在大多数情况下都很有用。

使用 CSV 的一个优点是,大多数电子表格程序,如 Excel,可以轻松导入它,然后你可以使用它们来生成图表或彩色编码的图表。不过,有一个重要的警告:许多电子表格程序在导入时会将数据转换为它们的内部格式,这意味着你的数据可能会无声地损坏。这尤其重要对于看起来像日期的任何内容,或者看起来像数字的字符串。例如,员工 ID 00073261 会被转换为数字 73,261。 wherever possible,最好使用 Excel 来查看数据,并认为它输出的任何数据都是受污染的。不要用它进行任何进一步的工作——只使用原始的 CSV 文件。
提示
如果你需要快速完成工作,通常在现有的系统上构建会更快。Python 允许你从一个系统将报告通过电子邮件发送到你的程序,以及一些来自网页的数据,并将其导入 CSV 文件或数据库。
其他格式
除了 CSV 之外,Python 还有库可以读取许多其他格式,所有这些格式都可以以某种方式用于数据交换。以下是一个快速列表,其中最常见的一些;许多其他格式要么在 Python 的标准库中可用,要么作为你可以安装的附加包。
Html
你可能没有意识到,但 HTML 是一种数据格式,大多数编程语言都有库允许你编写表现得像网络浏览器的程序,读取 HTML 并通过 HTTP、POST 或 GET 请求发送数据。Python 有几个库可用于下载和以这种方式解释网页,并发送数据。在下一节中,我们将探讨如何使用 Python 内置的urllib库下载网页,然后使用名为 Beautiful Soup 的附加模块从中提取股票价格。
Json, Yaml, and Microformats
如果你需要更结构化的数据,例如嵌套树或对象网络,那么 CSV 可能不是最佳选择。其他格式,如 JSON 和 YAML,更为通用。
Sqlite
如果你可能需要将数据存储升级到数据库,或者你需要快速访问数据,那么你可能想要考虑 SQLite,自 Python 2.6 版本起它包含在 Python 标准库中。它提供了你预期在 MySQL 或 PostgreSQL 等数据库中找到的 SQL 命令的子集,并将数据保存到本地文件。许多程序,如 Mozilla、Skype 和 iPhone,都使用 SQLite 作为数据存储格式。
邮箱(Mbox)
Python 也能够读取大多数常见的邮箱格式,如 mbox 和 maildir,并解析其中的邮件,包括提取附件和读取多部分 MIME 消息。通过电子邮件接收到的任何内容都可以被读取、解释并采取行动。Python 还可以通过附加库如 getmail 作为普通邮件阅读器,并通过 SMTP 发送邮件。

Xml
Python 支持读取、写入和解析 XML 文件,以及 XML 远程过程调用(XMLRPC)服务。Python 的最新版本,2.6 版本,包括 ElementTree,这是一个处理 XML 的简单而强大的库。
你需要与之交互的任何程序都会有自己的操作方式,因此了解可用于输出程序所需格式的库以及相反地读取它输出的格式是很重要的。幸运的是,Python 可以轻松处理各种格式。让我们继续前进,看看你将在本章中使用到的工具。
开始使用
你的第一个任务是查看你想要与之交互的程序导出的数据。在这种情况下,你希望与雅虎的股票跟踪网站交互,你可以通过finance.yahoo.com/q?s=GOOG访问它,并报告股票价格随时间的一些统计数据。该链接提供了谷歌的结果,但你可以自由选择不同的股票,如 IBM 或 AAPL(苹果)。你可能需要与一个完全不同的网站交互,但这里的一般原则仍然适用。在解析时,你将使用两个主要工具:Beautiful Soup,让 Python 读取 HTML,以及 Firebug,帮助你检查网站的 HTML 并确定你想要提取的元素。
安装 Beautiful Soup
Beautiful Soup 是一个设计得易于使用的 Python 库,但它也能处理各种 HTML 标记,包括“病态的坏”标记。通常,你不会有选择你想要抓取的页面的自由,所以选择一个对所提供的 HTML 不太挑剔的库,如 Beautiful Soup,是值得的。
Beautiful Soup 可以从 www.crummy.com/software/BeautifulSoup/ 获取。要安装它,请将文件下载到您的桌面并解压;然后,在命令提示符窗口中,使用 cd 命令进入该目录并运行 python setup.py install。如果您使用的是 Linux 或 Mac,您需要在命令前加上 sudo;如果您使用的是 Windows,您需要以管理员权限运行终端应用程序。Beautiful Soup 将会自动安装到 Python 的 site-packages 文件夹中,这样您就可以在任何地方使用它了。为了确保它已正确安装,打开 Python 命令提示符并输入 import BeautifulSoup。如果没有错误,您就可以开始了!
安装 Firefox 和 Firebug
您还需要另一个工具,那就是 Firefox,它比 Internet Explorer 更开放、更符合标准。当您查看网页代码时,这将很有帮助。您可以从 getfirefox.com/ 获取 Firefox。

Firebug 为 Firefox 网络浏览器提供了许多额外的开发功能。它对您的任务不是必需的,但它确实使与网页 HTML 的交互变得更加容易。您可以通过访问 Firefox 中的 getfirebug.com/ 并点击大型的“安装 Firebug”按钮来下载它。您可能需要更改设置以允许 Firefox 从该特定网站安装,但除此之外,一切应该都是自动的。当您完成操作并且 Firefox 重新启动后,您将在 Firefox 窗口的右下角看到一个小的虫子图标,当您右键单击页面的一些元素时,您将会有额外的选项。
检查页面
现在您已经安装了 Firebug,您可以查看您想在 Python 脚本中导出的元素。如果您在雅虎财经页面的一部分(如股票标题)上右键单击,并选择“检查元素”,窗口的下半部分应该会打开并显示与标题对应的 HTML。它看起来可能像这样
<div class="yfi_quote_summary">
<div class="hd">
[<div class="title">]
<h2>Google Inc.</h2>
<span>(NasdaqGS: GOOG)</span>
</div>
...
<div>
图 5.2 展示了在我的浏览器中的样子。
图 5.2. 使用 Firebug 检查元素

您可以使用类似的过程检查页面上的其他元素,以找出它们的 HTML 看起来是什么样子。如果您不确定 HTML 的哪些部分对应于页面的特定元素,您可以将鼠标悬停在 HTML 或您感兴趣的元素上。Firebug 将突出显示页面上的相关部分,正如您在 图 5.3 中可以看到的那样。
图 5.3. 使用 Firebug 并突出显示

现在您已经知道如何使用 Firebug 检查页面并找到您要查找的元素,从 HTML 中提取数据将会容易得多。

使用 Python 下载页面
你将开始使用 Python 的 urllib2 模块下载整个页面,如下面的列表所示。你将通过编写一个函数来实现,该函数将返回任何你命名的股票页面的 HTML 代码。这将是一个易于重用的函数,你可以直接将其粘贴到最终的脚本中。
列表 5.1. 下载网页

urllib 使用 opener 对象来读取网页。在这里,你创建了一个
并向它提供了两个处理器,这些处理器是处理来自网络服务器特定类型 HTTP 响应的对象。HTTPRedirectHandler 将自动跟随重定向,所以如果一个页面临时移动了,你不必担心编写代码来跟随它。HTTPHandler 将读取返回的任何网页。

不幸的是,一些网站喜欢阻止这样的自动化代理,所以为了安全起见,你在这里要小心行事,并设置发送给服务器的用户代理,以便你看起来像是一个完全不同的网络浏览器
。在这种情况下,你假装在 Windows XP 上运行 Internet Explorer 7。你可以通过在网络上搜索“用户代理字符串”来找到其他用户代理字符串。
现在你只需要调用打开器的 open() 方法并传入一个 URL
,就可以读取网页了。该方法返回一个类似文件的对象,它响应方式与打开的文件完全一样,因此你可以通过调用 readlines() 并将其响应拼接起来来获取网页的文本。
现在调用这个函数很容易
,并且所有复杂的 urllib 部分都被隐藏起来了。如果你运行这个脚本,它将在屏幕上打印出 finance.yahoo.com/q?s=GOOG 页面的全部内容。
注意
在 Python 3.0 中,urllib、urllib2、urlparse 和 robotparse 模块都被合并到了 urllib 中,并且进行了一些改进。你在这里使用的方法已经被移动到了 urllib.request 模块中,但除此之外,它们是相同的。
页面的全部内容对于你想要做的事情来说有点多。你只对包含股价的部分感兴趣。你需要将结果限制在页面中你感兴趣的部分。
切割出你需要的内容
让我们用 Beautiful Soup 来练习,只解析出你感兴趣的报价元素并打印出来。一旦你完成了这个操作,你就可以开始提取单个元素以生成最终的输出。
大多数时候,你可以通过在网页的 HTML 中寻找地标来简化你的解析。通常会有 ID 和 class 属性,你可以使用它们来定位特定的部分,然后从那里缩小搜索范围。在这种情况下,看起来有一个
列表 5.2. 查找报价部分

解析 HTML 时,你需要做的第一件事是创建一个 Beautiful Soup 对象
。这个对象会查看所有输入的 HTML,并提供许多方法供你检查、搜索和导航。
soup对象提供了一个find()方法,可以快速搜索 HTML。在这里,你正在寻找所有具有yfi_quote_summary类的
。find()命令返回第一个符合标准的元素,但作为另一个soup对象,因此如果你需要进一步搜索,可以继续使用。

作为快捷方式,如果你打印一个soup对象
,它将返回包含其 HTML 的字符串。在你的情况下,这正是你想要的——yfi_quote_summary
如果你运行这个脚本,它应该会打印出一个更短的 HTML 片段,这就是你正在寻找的报价部分。你应该能看到一些部分,如股票名称和价格,以及其他
添加额外信息
现在你有了更小的 HTML 部分,你可以进一步检查它并提取所需的特定部分。find()命令将返回另一个soup对象,因此你不必担心再次解析它——你可以在结果上调用find()方法来提取所需的数据。以下列表显示了一个使用多个find()调用从股票页面构建数据字典的函数。
列表 5.3. 提取股票数据


这看起来熟悉吗?这又是古老的分而治之策略:先写一个简单的能工作的东西,然后逐步改进,直到你得到所需的数据。
你可能会发现将你试图匹配的 HTML 作为注释很有帮助,就像
这样。这可以节省在编辑器窗口和网页浏览器之间切换来回提醒自己 HTML 看起来像什么。
接下来,你可以在报价摘要上运行一个简单的 find 命令来找到第一个h2元素
。一旦完成,你就可以从.contents属性中获取第一个元素,在这个例子中将是股票名称。.contents属性返回特定元素内的所有子元素,作为一个soup对象列表。
注意,在 HTML 中,你正在寻找的 ID 是以公司命名的。这并不是什么大问题,因为你可以传入股票代码并将其转换为小写
。你还在使用.string方法。如果你确定搜索结果中只有一个文本节点,你可以使用.string快捷方式,它将返回该节点作为文本。
如果你仔细查看这里的搜索结果和 Firebug 中的相应 HTML
,你可能会注意到它们是不同的。代码似乎忽略了浏览器中可以看到的额外 span。答案是,有时当 HTML 无效时,Firebug 会插入额外的元素以使 HTML 代码有效。但对于 Beautiful Soup 来说,这并不是问题,因为它将图像和文本作为两个元素返回。如果有疑问,你总是可以从浏览器本身查看页面的源代码并搜索元素的 ID 或类,以查看从服务器接收到的 HTML 的确切内容。

如果你需要更多的搜索灵活性,那么你可以使用 Beautiful Soup 的 find() 方法的一种方式是使用一个函数而不是一个字符串
。Beautiful Soup 会将属性名称传递给函数——如果函数返回 True,则该元素被包含。
在搜索时使用
中的函数很简单:只需使用函数
而不是字符串。在本节中,你还将链式调用 find()。第一个 find() 查找 ID 为 yfs_c10_goog 的元素,并返回另一个 Beautiful Soup 对象,然后你立即运行另一个 find() 命令。整个调用集包含在括号中,这样你可以将其展开到多行,使其更容易理解。
你可以继续这样做,直到从页面中提取出所有你需要的数据。注意,你的解析过程不要变得过于复杂。如果变得复杂,你可能需要考虑将 parse_stock_html 分解成函数,每个数据值一个函数,并在解析时遍历一个包含数据值名称和函数的字典:
parse_items = {'stock_name': parse_stock_name,
'ah_price': parse_ah_price, ... }
网络爬取的注意事项
虽然直接从网络读取数据是一个有用的工具,但它并非没有缺点。主要问题是网页经常变化,你的解析代码可能需要随之改变。你可以通过关注页面最不可能变化的元素来降低风险,例如 ID 或类变量,但你仍然受制于创建网页的人。如果可能的话,从长远来看,通常更好的做法是依靠官方渠道,例如使用发布的 API 来访问数据,而不是自己全部完成。稍后,我们将探讨处理脚本失败的战略以及如何减轻它们。
但首先,你需要给你的工具增加一些复杂性。
写入 CSV 文件
单个股票价格没有用处。为了对是否购买或出售提出建议,或者预测股票未来的走势,你需要一些股票价格及其变动的历史记录。这意味着你需要保存你刚刚读取的数据,以便将来再次使用。正如我们在“CSV 来帮忙!”一节中所述,最常见的数据格式是 CSV 文件。下面的代码将把你的结果字典保存到 CSV 文件中的一行。
列表 5.4. 编写 CSV 文件


在你开始创建 CSV 文件之前,你需要知道字典中的哪些键值对对应于 CSV 文件中的哪些标题
。将它们存储在字典中意味着你可以轻松地稍后访问它们。不过,字典不保证有特定的顺序,所以还有一个列表告诉你列应该以什么顺序出现。

当你写入文件时,你会根据你正在跟踪的股票给它命名,这样更容易找到,并且任何其他脚本都可以轻松访问它。你还需要知道你是否已经向此文件写入过,这样你就可以在必要时向它添加标题。os.access可以做到这一点,你需要知道它是否存在
。
csv.DictWriter是一个将字典写入 CSV 文件的类
。它需要两个参数来运行:以二进制模式打开的文件和一个列表,其中包含它们在 CSV 文件中应该出现的顺序。我还添加了一个extrasaction参数,它告诉DictWriter是否应该忽略字典中的额外值或引发异常。在这种情况下,你有一个额外的stock_name字段,你希望它不要在 CSV 文件中反复出现,所以你会忽略它。
一旦创建了DictWriter对象,使用它来写入一行就很简单
:给它提供一个字典来写入。但是,如果任何键缺失,则会引发错误。
你也对特定股票记录的检索时间感兴趣。在 Python 中,这是输出当前本地时间和日期的方法
。字符串中的%Y %H部分将被当前年份、小时等替换。你可以按任何顺序排列它们,只要保持%符号与其对应字符在一起。
现在你有一个 CSV 文件,每次运行你的脚本时都会更新。如果你多次运行它,你会在末尾看到额外的行被附加。通常,你会使用 cron(如果你使用 Linux 或 Mac)或 Windows Scheduler 或类似的工具来自动化这个脚本。对于一些脚本,你可以在这里停止,但如果结果很重要,你想要确保其他人知道这些结果。
接下来,让我们看看如何使用你的 CSV 文件创建电子邮件。
发送 CSV 文件
如果你需要用电子邮件做任何事情,通常情况下,email模块是开始的地方。它包含了解析电子邮件和提取其信息的类和函数,以及创建和编码电子邮件的工具,甚至包括包含 HTML、文本(如果收件人不能阅读 HTML)和附件的多部分电子邮件。通常,你会从一个简单的部分开始,逐步构建,但在创建电子邮件时,删除不需要的部分会更简单。
创建电子邮件很简单,但如果你对电子邮件的工作原理有一些背景知识,那就更有帮助了。让我们先看看这一点,然后你将看到如何在你的程序中将其付诸实践。
电子邮件结构
大多数电子邮件,除了最简单的纯文本电子邮件外,都是由容器组成的,其中包含部分内容。这些部分可以是文本、HTML 或任何可以用 MIME 类型描述的部分。当电子邮件发送时,电子邮件结构将被转换为纯文本格式,以便在到达目的地时重新组装。
通常情况下,至少会有两个部分:一个包含 HTML 格式的电子邮件,另一个包含文本版本——但理论上,一封电子邮件可以包含你需要的任意多个不同部分。我发现最有用,并且在各种电子邮件程序中显示效果最好的结构是图 5.4 中的结构。
图 5.4. HTML 电子邮件的结构


这种结构有两个容器。外部容器包含消息部分和任意数量的附件,内部容器包含你电子邮件的两个版本。如果你需要额外的附件,可以将它们附加到外部容器中。
创建电子邮件
现在你已经知道了如何构建 MIME 消息,让我们看看下面列表中相应的程序。这个函数将接受一个电子邮件地址和一个股票代码名称,例如“GOOG”,并构建一个准备发送的电子邮件。
列表 5.5. 创建 MIME 电子邮件


首先你需要做的是创建一个外部容器
,它将包含所有其他部分。这也是放置所有消息头——主题、收件人和发件人——的地方。
接下来是电子邮件的正文
,包括 HTML 和文本部分。通常,电子邮件程序会显示这个容器的最后一部分作为正文,如果无法处理,则会回退到其他部分,所以你应该把 HTML 放在最后。

现在,你可以创建MIMEText对象来保存电子邮件正文。它们将自动成为 text/something 类型,第二个参数告诉了那“something”是什么。一旦你有了这些对象,你可以调用attach方法将它们插入到内部容器中,然后将内部容器附加到外部容器上
。
你可以为 CSV 部分做与电子邮件正文相同的事情。一旦你读取了 CSV 文件,你创建一个 text/csv 部分并将其插入到外部部分
。唯一需要额外做的事情是添加一个Content-Disposition头来说明它是一个附件,并给它一个文件名。如果没有文件名,你将得到一个默认名称,如“Part 1.2”,这看起来既不友好也不专业。
如果你想要查看你创建的内容,使用你的外部消息对象的as_string()方法,它将打印出电子邮件的准确内容,就像它将被发送一样
。你将在下一节中编写一个send_message()函数,你将使用它通过 SMTP 服务器发送电子邮件。
这就是你需要对报告电子邮件所做的所有操作;它已经准备好可以发送了。如果你想要重用这个功能,你可以做很多事情来扩展它。第一个显而易见的做法是将主题、文本和 HTML 内容作为参数传递,而不是在正文中硬编码它们。另一个做法是能够传递多个附件,作为一个列表。对于这部分的一个重要功能是mimetypes.guess_type,它将根据附件的文件名给出一个 MIME 类型和一个编码(例如 zip、gzip 或 compress)。从那里,你可以创建正确的 MIME 对象,例如MIMEApplication或MIMEImage,并将其附加到电子邮件中。
顺便说一句,如果你正在附加图片,你可以通过使用一个cid: URL 从 HTML 正文中链接到它们,就像这样:。
发送电子邮件
你需要对你的电子邮件做的最后一件事就是发送它。这是发送电子邮件过程中最直接的部分,只需要一个发件人地址、一个收件人地址列表以及电子邮件本身。
列表 5.6. 发送电子邮件

首先,你创建一个 SMTP 对象,它将处理你的电子邮件发送
。如果你连接到你的互联网服务提供商的邮件服务器,这通常就足够了,通常像 mail.yourisp.com 或 smtp.yourisp.com 这样的东西——如果你不知道是什么,你可以从你的电子邮件程序的“发送电子邮件”部分获取它。一些 ISP 要求 SMTP 用户名和密码,你可以通过像s.login('user', 'password')这样的行来包含它们。
一旦你有了 SMTP 对象,你可以调用它的sendmail()方法来发送电子邮件
。你正在从消息中提取电子邮件地址以及电子邮件正文;这样,你就不需要将它们作为单独的参数指定,你的代码也更整洁。如果你需要,你可以多次调用sendmail()方法。
当你完成时,你可以关闭连接
。这可以节省 SMTP 服务器的一些负载,因为它将少一个要跟踪的连接——但是如果你有多个电子邮件要发送,最好重用连接。
要创建一个要发送的电子邮件,你使用你的mail_report()函数,并给它你的电子邮件地址和股票名称。然后你将它传递给send_message()函数来发送它
。如果你更喜欢周报,那么你可以运行一个单独的脚本,该脚本读取网页。如果你想要一次性给多个人发邮件,那么你仍然给mail_report()函数传递一个字符串——但是用逗号分隔电子邮件地址。
这就是你需要为你的脚本做的所有事情。它从网页上抓取数据,将其发布到 CSV 文件中,然后将报告通过电子邮件发送给可以利用这些信息的人。出人意料的是,大量的商业编程归结为大致相似的过程:收集一些数据,处理它,然后将结果发送或存储到某个地方,无论是给需要信息的人,还是给另一个程序。
其他电子邮件模块
虽然在这个脚本中你不需要它们,但如果你在处理电子邮件时需要更多灵活性,或者需要做一些这个脚本无法做到的事情,你可以使用其他与电子邮件相关的模块。除了这个脚本中的模块,我最常用的两个是邮箱模块和 getmail。

邮箱模块包含用于读取多种不同类型邮箱的类,包括最常用的两种,mbox和maildir,并提供了一种轻松遍历文件中每条消息的方法。解析mbox文件相对简单,但也有一些陷阱,使用库会更方便。除了编写电子邮件,email模块还提供了email.parser来从平面文本文件中读取标题行、正文和附件。它们一起提供了你处理电子邮件所需的一切。
Getmail 是一个由 Charles Cazabon 编写的附加模块,可以从pyropus.ca/software/getmail/获取。它处理 POP 和 IMAP4,包括通过 SSL,可以将消息保存到 mbox 或 maildir 存储,也可以将它们传递给另一个程序。它也很容易使用,并且只需要一个配置文件即可工作。
在 Python 的内置电子邮件模块和 getmail 之间,你应该能够处理几乎所有你遇到的电子邮件编程问题,无论你需要读取、下载、解析还是分析电子邮件。
一个简单的脚本——可能出什么问题?
在完成类似这样的项目时,你可以问自己一个有用的问题:“我完成了吗?”
现在尝试一下,看看你认为答案是什么。你完成了吗?你是否有信心每天运行这个脚本而不用担心它?它会不会出问题?如果你的公司 CEO 或董事依赖于你脚本的输出结果,你能否安心入睡?即使你的脚本不是至关重要的,你如何知道它在工作?你是否需要像照顾孩子一样照顾你的脚本,可能每隔几天检查一下结果,以确保一切正常?
有可能编写一个一开始就能工作的程序,但需要如此多的帮助才能运行,以至于你几乎可以认为你一开始就不应该编写它。为了保持你的理智,尝试分析你的程序,并尽可能多地找出潜在的故障点。
注意
这是最具挑战性的编程部分——任何事物都有可能以某种方式失败,你必须准备好应对潜在的后果。如果你曾经好奇为什么许多程序员和系统管理员看起来像谨慎的悲观主义者,现在你应该知道了。

这里有一些可能导致你的脚本崩溃的可能问题的列表。然后,在下一节中,我们将探讨如何解决这些问题。
没有互联网
显然,如果没有互联网连接,脚本将无法下载股票页面或发送电子邮件,对此你无能为力。但具体会发生什么?脚本会立即失败,还是会进行到一半时损坏你的数据?如果你无法连接一天,CSV 文件中应该出现什么内容?
无效数据
如果雅虎决定更改其网站的设计,你的脚本会发生什么?如果它期望在 HTML 中找到特定的 ID,而这个 ID 被移除了,那么你的脚本将会崩溃。或者,可能会有部分中断,你将看到空值或零值。或者如果服务器负载过高,你可能会看到超时错误或只收到半页内容。你的脚本如何处理这种情况?它会尝试解析错误页面并失败,还是会识别发生了什么?在最坏的情况下,它可能会得到看起来与你期望的数据相似的数据,你不会注意到它已经改变,你的数据将会被无声地损坏。
你未考虑到的数据
与无效数据相关的另一种故障模式是:有时你可能会得到一些看似有效但仅在你期望的范围内有效的数据。如果它处于某个特定范围内或目前未知,它的格式或呈现方式可能也会不同。这类值通常被称为边缘情况。它们并不经常发生,因此可能更难以预测,但它们仍然会对程序稳定性产生重大影响。处理边缘情况的最佳方式是尝试考虑你数据的整个范围,并将任何存疑的案例纳入你的测试套件中。
无法写入数据
当你在处理数据时,你假设你将能够写入 CSV 文件。这通常是情况,但在某些情况下,你可能无法做到:如果网站管理员设置了错误的权限,或者你的电脑空间不足。你可能想要考虑不时地轮换你的 CSV 文件:压缩旧文件,删除那些存在时间最长的文件(或下载并归档它们)。确切的时间表将取决于你服务器上的可用空间以及你程序的要求。
没有邮件服务器
当你尝试发送电子邮件时,也可能遇到问题。大多数情况下,电子邮件相当可靠,但邮件服务器可能宕机。如果是这种情况,你的脚本会发生什么?可能只是存储 CSV 文件中的行并在第二天晚上重新发送,或者你可能需要检查邮件服务器是否运行正常,如果不行,尝试其他路线。
你不必修复它们
这些绝不是你脚本可能出错的唯一原因,但它们是最可能发生的。根据你的脚本、其目的以及运行的环境,这些问题可能更多或更少。或者你可能根本不需要担心它们。但无论如何,你仍然需要考虑它们。
让我们继续前进,看看你可能会解决,或者至少减轻一些这些问题的方法。
如何处理脚本中断问题
有许多策略可以用来处理你在脚本中看到的弱点。你选择哪种取决于你脚本的性质和目的。首先,让我们考察两个影响你如何编写脚本以及你如何看待潜在失败和如何解决它们的因素。
沟通
当你为其他人构建软件时,沟通至关重要。了解你项目的整体目标、它如何影响业务的各个方面、失败的预期影响以及业务中的人们将如何使用你的最终产品是很重要的。尽管严格来说,一个不执行所需功能的程序不是错误,但它几乎可以视为没有编写。
在构建程序的过程中,保持人们知情也很重要,因为你要解决的问题可能在任何时刻发生变化。没有什么比完成一个程序后才发现它不再需要,而且几周的努力都白费了更糟糕的事情了。
失败容忍度
处理潜在错误的方法有很多种,它们都有不同的成本。你选择哪种取决于企业对失败的容忍度。

贵重案例
例如,如果企业使用你的脚本买卖价值数百万美元的股票,那么它对任何可能的失败都有很低的容忍度。你可能会在专用服务器或多个不同位置的服务器上托管脚本——与损失数百万美元的交易风险相比,额外几千美元只是小菜一碟。你还会想每月支付几百美元来访问专门为此目的提供的 API,而不是抓取网页,并拥有完整的功能和单元测试套件来捕捉任何错误。
便宜案例
如果相反,你将脚本用作更通用的商业智能应用,那么这里或那里的股票失败或需要一天时间通过系统可能并不是那么糟糕。成本是更大的问题,因此你会在服务器上运行你的脚本,同时还有其他几个应用程序。这让你面临额外的错误可能性,比如磁盘空间不足或某个应用程序使用了过多的 CPU,以至于其他什么也做不了——但与单独服务器的相对成本相比,任何此类错误的冲击都是微不足道的。
首先不要出错
虽然听起来很明显,但避免程序中错误的最简单方法就是一开始就不编写它们。很容易拼凑出一个看起来像能工作的脚本,但通常你会发现代码中隐藏着各种问题,等待着机会让你的程序崩溃。
首先,当你编写程序时,考虑所有可能的数据,包括奇怪的非典型情况。寻找边缘情况以及那些“不可能发生”的事情,并确保它们不会发生。如果你正在处理数字,当数字为零、负数或巨大时会发生什么?程序应该抛出错误吗?忽略那个特定的值?提前考虑这些问题比程序崩溃时你必须立即修复它们要容易得多。
一旦你知道你可以处理和不能处理哪些数据,你就可以将其包含在测试中。你的单元测试和功能测试可以验证当你向程序提供无效数据(当你期望数字时却得到“fruit”)或可能成为问题(零、负数或非常大)的数据时会发生什么。如果你在测试或程序上线时发现输入导致错误,你可以将其添加到测试套件中,以确保它不再发生。

早期且大声地失败
如果在程序的某个地方出现错误,通常最好的处理方式是程序立即停止并开始“尖叫”(通过电子邮件或打印到屏幕上)。这在开发期间或问题意外出现时尤其正确。面对错误继续前进是危险的,因为你可能会用无意义的结果覆盖重要数据。
在可能的情况下,检查你使用的库返回的数据和任何错误代码。如果你正在尝试从网络上加载数据,你可以检查响应代码:除了 200(成功)之外的其他任何代码都意味着某个地方出现了错误,你应该停止。如果你在解析返回的数据时遇到麻烦,可能你看到的是不同类型的页面,或者数据不是你预期的。在这种情况下,将错误记录下来并跳过处理也是一个好主意。别忘了在错误中包含相关数据,这样你就可以复制问题。
双保险
为了减轻任何错误的影响,如果事情出错,通常有多个后备方案会很有帮助。例如,你可能在不同的服务器上运行你的脚本的两个副本。如果一个脚本出现问题,比如网络不可用,另一个脚本可能仍然能够访问数据。
另一个技巧是在可能的情况下保存数据的中间副本。在你的脚本中,你可能想在分析之前保存从服务器下载的 HTML 文件。如果某些数据看起来很奇怪,或者你在解析时出现错误,你可以双重检查你的结果,看看出了什么问题。
压力和性能测试
当你的程序上线时,一个常见的问题是它在少量数据上运行良好,但在使用真实数据时失败或运行得太慢。确保你的程序能够处理上线时预期的数据量,并在可能的情况下使用真实数据进行测试。

尝试稍后再试
如果你的程序因为外部资源不可用而失败,你通常可以在放弃之前尝试多次。也许你试图加载的网站正在经历一些临时停机,几分钟后将恢复。如果你选择这条路,确保在查询之间等待一段时间,如果它们失败,则在它们之间等待更长的时间。如果你想重试五次,你可能等待 1 分钟,然后是 3、5、7 分钟,最后放弃。如果你需要通过电子邮件发送数据,队列可以简化你的错误处理。与其直接将电子邮件发送到服务器,不如将它们排队到磁盘上的目录。第二个进程读取保存的文件并尝试发送。如果邮件发送成功,那么你删除邮件文件或将它移动到另一个目录,如果失败,你让它准备好下次使用。下面的列表显示了如何将这种逻辑添加到你的股票跟踪脚本中。
列表 5.7. 将电子邮件排队到临时文件

首先,你检查邮件队列目录是否存在。如果不存在,那么你需要创建它
。
接下来,你使用 tempfile 模块来创建邮件文件
。通过这种方式而不是自己确定文件名,如果你同时运行多个脚本,你遇到命名冲突的可能性会小得多。
现在你有了文件,你可以在发送邮件时写入所有需要的信息:收件人、发件人和邮件正文本身
。
一旦邮件被排队到磁盘,你可以使用第二个进程来读取并发送它。下一个列表显示了第二个进程可能如何编写。
列表 5.8. 从邮件队列目录发送电子邮件

这个过程的部分与刚才你看到的相反。给定一个目录,你想要读取其中的所有文件
,然后对于每个文件,读取收件人和发件人行,然后是邮件正文。
smtplib服务器会对任何意味着无法发送邮件的情况生成错误,所以你尝试发送邮件
。如果成功,那么你知道邮件已经发送,你可以删除邮件文件并继续。

现在你不必担心邮件服务器因维护而宕机或无法通过网络访问时邮件丢失。所有邮件都将排队在mail_queue目录中,只有在发送后才会被删除。
尽管如此,仍然存在一些限制。最主要的一个是,程序遇到的第一个错误将会终止整个邮件发送过程。对于你的目的来说,这已经足够好了,因为如果一封邮件失败,其他邮件也很可能失败。但你希望你的程序尽可能健壮。例如,一个格式不正确的电子邮件地址可能会导致 SMTP 服务器拒绝你的连接请求,然后这封邮件会反复发送,阻塞其后所有等待的邮件。
注意
如果你的程序需要一段时间才能返回结果,或者是一个需要整夜运行的批处理程序,那么优雅地处理错误就尤为重要。如果你需要等待六个小时才能知道程序是否正常运行,那么可能需要一周或更长时间才能排除所有错误。详细的错误报告会有所帮助,但你也可以在数据集较小的情况下工作,直到你对程序的工作有信心。
一个错误不应该让你的整个程序停止运行,所以你需要的是异常:Python 中设计用来帮助你处理这种错误并在发生时优雅恢复的功能。
异常
当 Python 遇到它无法处理的问题时,会触发一个称为异常的错误。有各种各样的异常,如果你找不到合适的内置异常,你甚至可以定义自己的来适应特定类型的错误。然而,异常并不是最终的——你可以编写代码来捕获它们,解释它们的结果,并采取所需的任何行动。
为什么使用异常?
当使用得当,异常可以使你的程序更容易理解。你不需要进行太多的错误检查和处理,尤其是对于错误代码和返回结果,因为你通常可以假设任何出错的情况都会引发一个异常。没有错误处理代码意味着程序中执行任务的这部分会显得更加突出,更容易理解,因为它不会被检查返回代码所中断。
当你的程序“爆炸”时意味着什么
在我们深入了解如何使用异常之前,让我们看看一些示例,看看它们是如何工作的。当你在程序中遇到错误时,你会看到一个称为跟踪回溯的东西。这将给出整个函数“堆栈”,从触发错误的程序部分到中间函数,再到最初在程序核心中启动的函数。
让我们从上一节中编写的邮件发送者的 traceback 开始。它显示最近的错误在最后,所以你需要从后往前工作。
列表 5.9. 发送邮件时的 traceback
![f0173-01_alt.jpg]
你从列表的末尾开始,以异常的名称和错误发生的简要描述
。如果你无法找出程序中出了什么问题,通过在网络上搜索异常和描述通常可以给你一些线索。
最后执行的一行是异常抛出的地方
。不过,如果变量没有正确设置或函数被错误地调用,bug 可能出现在程序更早的部分。
是异常在
中抛出的文件、行号和函数名。请注意,这位于 Python 的标准库中,所以你可能还没有找到问题。当怀疑时,总是假设程序中存在一个错误,而不是 Python 的标准库。
traceback
将继续到调用你的原始函数的函数。注意你调用的函数名,self.connect,与 traceback 中的最后部分
中列出的函数名相同。
现在你已经进入代码,错误很明显。你忘记移除示例 ISP 邮件服务器并替换你自己的
。在 mail.yourisp.com 没有网络地址,因此原始错误是:“名称或服务未知。”通常,异常的根本原因可能在 traceback 中深达几层,这是一个追踪函数调用直到找到错误来源的过程。
Python 通过向上传播异常来处理异常:也就是说,它首先会在当前函数中寻找异常处理程序。如果找不到,它会在调用当前函数的函数中查找,然后是那个函数的父函数,依此类推,直到程序顶部。如果仍然没有处理程序,Python 将停止并打印出未处理的异常和堆栈跟踪。砰!
展示了从 列表 5.9 生成的堆栈跟踪,左侧是函数调用,右侧是 traceback 回溯。
图 5.5. 栈跟踪的示意图
![05fig05.jpg]
为了不同的参考点,这里还有一个来自 parse_stock_html 的 traceback。
列表 5.10. 解析 HTML 页面时的 traceback
![05list10_alt.jpg]
触发的异常是一个 IndexError,这意味着你尝试访问一个不存在的数组索引。类似 ['foo'][1] 这样的操作也会触发类似的异常,因为那个数组没有第二个元素。
如果你查看上面的那一行,你可以看到可能引起问题的原因。你正在运行 Beautiful Soup 中的查找,并尝试访问第二个元素!。显然,有一些数据,其 HTML 只有一个元素,解析代码没有处理它。
在这种情况下,错误是由于当股票价格没有变动时,上或下箭头图像没有显示,这改变了从 .contents() 返回的元素数量。以下图显示了两种 HTML 版本并排,以便你可以看到我的意思。
图 5.6。如果没有股票价格变动,HTML 会发生变化。

通过使用负索引,这个问题相对容易解决,例如:.contents[-1]。现在 Python 将访问列表的最后一个元素:如果有两个元素,则是第二个元素;如果只有一个元素,则是第一个元素。你可以相当确信在那个特定范围内不会有超过两个元素。
现在你应该有一些使用回溯来帮助你调试错误的提示。要记住的主要事情是仔细地反向工作,寻找潜在的错误或异常结果。如果这没有帮助,你可能需要进行更传统的检查,例如打印或断言语句。一旦追踪到错误,如果可能的话,你应该将其添加到你的测试套件中。
捕获错误
异常的主要原因是如果它们被抛出,就要捕捉它们并适当地处理它们。这是通过使用 try..except 块来完成的,通常称为 异常处理程序。在 try 部分运行的代码,如果抛出异常,Python 会查看 except 部分以确定如何处理错误。以下列表显示了如何在你的邮件发送程序中捕获一些常见的异常。
列表 5.11。一个带有异常处理的邮件发送程序

你从你想要包装的部分开始你的程序!,以捕获任何异常。它表现得非常像任何正常的缩进块,所以你可以包括 if 语句、循环、函数调用以及你需要的一切。
是处理单个异常的方法,这也是大多数异常处理的方式。处理程序的工作方式有点像函数,你给它需要处理的异常类型和一个变量来存储错误信息。当抛出异常时,你可以打印错误信息和你收到的错误信息。
你也可以通过将你期望的异常放入一个元组中,而不是单独一个,用一个处理程序来处理多种类型的异常!。
如果你需要捕获引发的每个异常,请使用 Exception,这是一个通用的异常对象
。通常认为使用这种通用处理程序是不好的做法,因为你可能最终会掩盖你希望传播的错误。如果可能,你应该处理特定的错误,但在某些情况下,你可能不知道需要处理哪些异常。
你还可以使用 else: 来包含一段代码,如果 try..except 块成功运行且未引发任何异常,则执行该段代码
。在这种情况下,你正在删除你刚刚发送的邮件。还有一个 finally: 选项,如果你有一些需要始终运行的内容,无论是否发生异常,都可以使用它。
这样的异常处理应该可以满足你的大部分需求,并允许你编写能够从错误条件中恢复的程序,或者至少优雅地失败,你可以在需要处理错误的地方使用它。具体在哪里取决于程序的性质和你要尝试捕获的错误。处理用户输入的程序可能有一个高级异常处理程序,它将整个程序包裹在一个 try..except 子句中。这样,无论用户输入什么,你都可以处理它并返回合理的错误消息。你还可以在程序的部分子句或抛出异常的特定模块周围使用错误处理程序。
但在服务器上处理异常并不能给你太多关于出错原因的信息。特别是在关键的生产系统中,知道错误发生的位置和文件非常有帮助。你希望能够看到跟踪回执,就像你在本地运行程序一样。幸运的是,有一个 Python 模块可以帮助你打印出详细的调试消息并找出出了什么问题:traceback 模块。
traceback 模块
traceback 模块为你提供了一系列处理异常、格式化和提取跟踪回调和错误消息的函数。其中两个关键函数是 print_exc() 和 format_exc(),分别打印跟踪回调和将跟踪回调作为字符串返回。你可以通过 sys 模块提取这些信息,通过 sys.exc_type、sys.exc_value 和 sys.exc_traceback,但使用 traceback 要简单得多。让我们扩展最后部分的错误处理,以便在出现未知错误时打印出漂亮的跟踪回执,如下所示。
列表 5.12. 使用 traceback 模块

让我们从捷径开始:对于通用处理程序,你不必指定 异常。如果你完全省略任何异常类型,它将以完全相同的方式执行
。
print_exc() 函数将打印格式化的跟踪回执
。如果你以交互式方式运行程序或使用类似 cron 的工具,它会通过电子邮件发送你运行的任何程序的输出,这很有用。
如果你需要将日志记录到文件中,那么你可以使用format_exc()函数来返回一个包含回溯信息的字符串
。除此之外,输出完全相同。
现在你已经拥有了处理程序中出现的任何错误所需的一切。你可以扩展本节中的代码来处理实践中遇到的大多数情况,如果没有,至少留下足够的数据,以便你能找出出了什么问题。
接下来是什么?
本章中的脚本都是自包含的,因此没有关于如何扩展它们的特定建议。相反,尝试将本章的教训(和代码)应用于自动化你经常在工作或家中做的事情。合适的候选者是你觉得无聊和乏味的事情,或者需要详细步骤且难以正确完成的事情。
如果你不能自动化整个过程,至少可以覆盖其中的一部分——例如,下载所需数据,使其集中在一个地方,或者从中央数据存储发送多封电子邮件。
通常,一个好的脚本可以在一个月内为你节省几个小时的工作时间,这样你就可以用所有新获得的空闲时间来编写另一个脚本。最终,你可能根本不需要做任何工作!
摘要
本章的前半部分涵盖了 Python 中一些基本但重要的库,你可以使用这些库连接到外部世界并完成实际工作。我们介绍了几项技术:
-
从网络下载 HTML
-
使用 Beautiful Soup 解析 HTML
-
将数据写入 CSV 文件
-
编写电子邮件,包括编写 HTML 电子邮件和附加文档
-
通过 SMTP 发送电子邮件
在本章的后半部分,我们退后一步,探讨了如何使你的程序更加可靠——毕竟,这是现实世界,其他人可能有很多钱依赖于你的程序。
我们首先探讨了如何识别程序中存在风险的区域。然后,我们考虑了是否应该修复这些问题,基于修复的成本以及任何潜在失败的影响。最后,我们介绍了一些简单的策略来提高程序的可靠性,并探讨了在程序失败时如何减少损害。
最后,我们介绍了异常和回溯,这是 Python 处理错误的方式,以及当你能够时,如何捕获异常、检查它们并解决问题。
在下一章中,我们将休息一下,编写你自己的冒险游戏,包括怪物、宝藏、危险和兴奋!
第六章. 类和面向对象编程
本章涵盖
-
关于类的更简单思考方式
-
如何使用类来设计你的程序
到目前为止,我们一直在浏览 Python 中组织程序的一种基本方式:类。类和面向对象编程通常被视为庞大而令人畏惧的事物,真正的程序员使用它们来编写程序,你可能认为你需要大量的理论知识才能正确使用它们。事实远非如此。在 Python 中,你可以轻松地进入类和面向对象编程。
在本章中,你将从第二章中编写的代码开始,生成 Hunt the Wumpus 的洞穴,然后看看使用类编写它有多容易。然后,你将在此基础上构建一个完整的冒险游戏,类似于 Adventure 或 Zork。在这个过程中,你将了解 Python 的类以及如何充分利用它们。
究竟什么是类?
如果你回想起第二章,你可能还记得你有一组处理玩家和 Wumpus 居住的洞穴的函数。有一个用于创建洞穴的函数,另一个用于将两个洞穴连接起来以创建隧道,一个用于确保所有洞穴都连接起来,等等。当你编写程序时,你完全通过函数来处理洞穴。创建类的一种方法就是识别这样的函数组,并正式确定它们之间的关系。
类包含数据
另一种思考类的方式是将类视为一个容器或包装器,它围绕着你程序中想要使用的数据。你可以将执行特定任务所需的所有数据分组,并提供处理这些数据的函数,尤其是如果数据复杂、难以处理或需要一致性时——例如,跟踪你在银行账户余额的程序。
它们是它们自己的类型
类类似于称为抽象数据类型的东西,它是一组数据以及可以对该数据进行的所有操作。你不必指定你可以在类内部对数据进行的所有可能的事情,只需指定对你特定情况有用的即可。然而,在设计类时,通常有助于考虑你可能想要使用它的所有可能的事情,并添加那些有意义的。
它们是如何工作的?
将类想象成一个大的橡皮图章。一旦你创建了你的橡皮图章,你就可以轻松地印出你喜欢的任何数量的图片。类的工作方式也是一样的。你通常不会直接与类工作:你而是与该类的实例一起工作,这些实例是通过使用原始类创建的。
程序中的类有一个优点——如果你需要稍微不同的图片,很容易创建原始类的副本,稍作修改,然后使用它。图 6.1 展示了本章中的类可能看起来像什么,如果它们是橡皮图章的话。
图 6.1. 怪物就像玩家一样,只是有角和皱眉的脸。

实例和类本身都可以有你可以调用的方法和可以访问的数据。在大多数情况下,这些将在你首次创建实例时设置,但 Python 还允许你在需要时即时更新它们,甚至可以重新绑定方法。
注意
面向对象编程包含大量的术语——其中大部分显然是为了混淆粗心的读者。你会听到人们提到类、对象、实例、方法、类方法、获取器、设置器等等。如果你不确定某人是什么意思,试着弄清楚他们是在谈论橡皮图章还是它在你的程序上留下的印记。
你的第一个类
在以下列表中的类应该看起来很熟悉,尽管有一些部分相当不同。它包含了你在第二章中编写的洞穴列表和方法,但已更新以便它们包含在类中。
列表 6.1. 存储洞穴的对象


你从 Python 创建类所使用的语法开始
。它与创建函数的语法类似,但按照惯例,类名以大写字母开头(毕竟类很重要)。括号中的object是这个类继承自的类——在这种情况下,Python 的通用object类,因为你没有继承任何东西。
大多数 Python 类都将有init方法,该方法负责在类首次创建时设置类的实例
。你注意到你在方法中获得了self参数了吗?这是为了让方法可以访问变量并共享状态。你在第二章中使用的所有列表都在这里,但以self为前缀,以便它们引用实例中的变量。
你用来设置洞穴的函数在这里,它们也接受了self处理
。除此之外,它们没有太多变化,这正是你所期望的,因为它们只是带有显式self的函数。
一旦你设置了你的类,你就创建它的一个实例,并调用它的print_caves()方法来测试它
。Python 运行类的init方法,然后它依次调用setup_caves()和link_caves(),创建你的洞穴网络,你可以从caves.print_caves()的结果中看到。

将所有功能放入类中,你从中获得了什么?主要的好处是所有洞穴的细节都包含在你创建的实例中。你现在可以同时创建额外的洞穴系统,而不用担心它们之间会相互冲突。从这里,你也可以扩展类——也许包括大气洞穴名称和其他我排除的功能——或者添加一个方法来扩展洞穴系统。
注意
类是你在程序中可以使用的另一种分而治之的机制。一旦创建了实例,你不需要考虑它为什么工作,只需要考虑你在代码中可以用它做什么。
尽管你创建了新的 Caves 类并且它工作得很好,但它仍然不是一个面向对象的设计。你只是将现有的功能设计推入了一个类中。如果你将来想要添加额外的功能——比如在洞穴中捡起宝藏、更多怪物或其他特性——它们将很难添加。这与在第二章中添加函数改变程序设计的方式非常相似,正确地使用类现在将改变你设计的重点。
面向对象设计
许多人更喜欢面向对象程序的一个原因是,对象往往很好地映射到你在现实世界中处理的事物,并且使你在开发程序时更容易思考它们如何交互。如果你在编写一个管理财务的程序,你可能会创建名为 Account、Expense、Income 和 Transaction 的类。如果你在编写一个控制工厂的程序,你可能有名为 Component、ConveyorBelt、Assembly(即多个组件组合在一起)和 AssemblyLine 的类。
让我们退一步,更深入地思考一下冒险游戏。它会有哪些内容?好吧,如果你采取传统的方法,玩家将是一个勇敢的冒险家,在一个充满怪物、天花板堆满宝藏、名声和荣耀的地下地牢或洞穴系统中寻找宝藏。以下图显示了你的游戏可能的样子,这种草图你可能用来向朋友解释。
你应该解决的基本特性是洞穴,而不是整个洞穴系统。列表和功能的机制可能让你误以为洞穴系统是重要的部分,但通过在正确的层面上思考:单个洞穴及其内部的内容,你可以得到一个更干净的设计。以下列表显示了洞穴应该如何编写。你应该将其放入名为 caves.py 的文件中;否则,本章后面的一些代码可能无法正常工作。
图 6.2. 你游戏的基本草图

列表 6.2. 更面向对象的设计


你从为新洞穴对象设置初始状态开始
。而不是为洞穴名称设置一个列表,为链接设置另一个列表,以及一个用于判断洞穴是否被访问过的列表,你将所有这些信息都存储在对象本身中。当你稍后构建洞穴列表时,你可以很容易地通过这些属性进行筛选。你添加一个self.here列表来存储可能存在于洞穴中的任何其他对象(例如玩家、怪物和宝藏),以及一个description字符串,当玩家进入洞穴时,这个字符串将描述洞穴。你现在可以忽略这两个新值。
因为你可以很容易地看出一个洞穴连接到了什么
,所以向另一个洞穴添加隧道很容易:将它们添加到隧道列表中,并将self(self是当前的Cave实例)添加到隧道列表中。注意,你也正在处理实例级别的洞穴,这使得你的程序更加清晰。
你最后要做的就是在你的类中添加一个repr方法
。内置到基类object中的那个有点难以阅读(它可能看起来像<main.Cave object at 0x00B38EF0>),这使得当你需要打印出洞穴时,程序输出看起来更美观。

现在你需要做的就是弄清楚如何连接洞穴。借用第二章中的洞穴名称列表,你可以将每个名称分配给一个新的洞穴实例,将该实例链接到现有的洞穴,并将其添加到caves列表
。唯一稍微有点棘手的地方是如何找到要链接的洞穴。但你可以通过检查列表推导式中每个洞穴隧道列表的长度来轻松地解决这个问题。此外,请注意,Python 不会限制你解决问题的方法——你可以在需要的地方自由使用函数、类和裸代码。
如果你不相信它有效,
打印出完整的洞穴列表。
主要需要注意的是列表 6.1 中有多少被替换了。Caves的原始版本有六个不同的方法相互调用;你用一个新的类和一个外部函数替换了它。正如你在第二章中学到的,更短、更简单的代码通常意味着你正在正确的道路上,面向对象的设计很好地映射到你的冒险游戏中。让我们继续前进,解决程序的下一段:处理玩家的输入。
玩家输入
大多数冒险游戏是通过在提示符中输入指令来玩的,比如 GO NORTH(向北走)、GET SWORD(拿剑)、KILL MONSTER(杀怪物)和 GET TREASURE(拿宝藏)。游戏随后会响应你的动作结果,以及你所在房间的描述和房间中的物品。你将采取相同的方法,并使用一些对象的属性来使你的程序易于扩展。记住,你还将想要使你的代码易于测试,所以你会将用户输入分离到一个单独的函数中。

第一步:将名词动词化
你将从尝试找到一种好方法将“动词名词”接口写入你的类结构开始。通常,因为一个对象将是一个名词,而该对象上的方法将是动词,所以像 GET SWORD 这样的命令应该尝试在当前房间中找到剑对象,并调用其 get 接口。这样设计意味着,而不是有一个知道游戏中所有可能操作的大 Player 类,你可以有更多、更小的类,这些类更容易理解(以及修改和扩展)。
以下列表包含应用程序核心的代码:玩家对象。它负责从玩家那里读取输入,以及找到正确的对象来解释命令。你将它放入一个名为 player.py 的文件中。
列表 6.3. 一个 Player 对象


你在 Player 类中最初需要的变量相当直接
。你将它们添加到一个位置,并告诉游戏他们正在玩游戏。
你在这里拆分命令,并确保动词和名词变量中始终有内容
。你使用 shlex.split() 来拆分你的命令,因为它比正常的拆分更好地处理引号。例如,如果玩家输入 GET “GOLD KEY”,那么 shlex.split() 将 GOLD KEY 读取为一个部分。你将动词之后的所有内容连接起来,并假设它是名词的一部分,所以 GET GOLD KEY 也会工作。
一旦你有了易于处理的命令格式,你尝试找到一个可以调用的方法
。如果方法查找器返回 None,这意味着没有方法来处理命令,你返回一个错误(如果你曾经玩过冒险游戏,这会非常熟悉)。
当你有了你的命令后,你尝试找到一个处理它的方法
。如果你有一个名词,你寻找一个与它匹配的对象——例如,GET SWORD 中的 SWORD——并查看它是否可以响应。getattr() 函数是这样做的好方法——它寻找一个在变量中设置的名称的类属性或方法。如果找不到,那么它将命令传递给位置或玩家(这将在下一节中帮助你提供更好的接口)。
如果玩家没有给出名词,那么它可能是一个更通用的命令,如 LOOK 或 QUIT,所以你在当前位置和 Player 对象中寻找它
。如果这两个都不起作用,那么你没有找到它,并且你“掉落”到方法的末尾。这意味着你返回 None,这会导致错误。
你为 Player 添加了两个基本命令
,即 LOOK 和 QUIT,这样你就可以感受到它们在完成的游戏中的工作方式。你还需要为 Cave 类添加一个空的动作列表;否则,如果 Player 实例无法处理一个命令,这将引发错误。
现在你有了玩家,你需要能够从玩家那里读取输入,并使用该输入来运行游戏。以下是一个示例框架。你创建一个简单的洞穴,将玩家放入其中,然后循环读取输入,直到玩家完成游戏。稍后,你可能会将其集成到Game对象中,但鉴于 Python 的灵活性,你可以在编写和测试其他类时将其保留为函数。
列表 6.4. 运行你的Player类

你的Player类需要一个位置才能正常工作,所以你在这里设置了一个测试洞穴,并将玩家放入其中
。一个简单的描述和名称就足够开始使用了。
一旦你这样做,你从玩家那里获取输入,并将其传递给process_input()方法,该方法将运行你的代码,并以字符串列表的形式返回结果
。然后你使用'\n'字符串的join()方法将它们逐行打印出来。当玩家发出退出命令时,player.playing变量将为 false,你将停止程序。

如果你现在运行冒险程序,你应该能够给出诸如 LOOK 和 QUIT 之类的命令。这很简单,但你将在下一节中看到如何扩展界面。
宝藏!
让我们从游戏中添加一些更令人兴奋的东西开始。首先,你可能希望能够在游戏初期就给玩家一些装备或宝藏,以吸引他们进入游戏并让他们参与其中。没有宝藏或剑的冒险不是真正的冒险,所以让我们先添加这些。不过,在你这样做之前,你需要更多地考虑你的设计。
方法应该放在哪里?
显然,你需要与你的物品进行交互,这意味着你至少希望能够执行诸如 GET SWORD、LOOK SWORD 和 DROP SWORD 之类的操作。按照你目前的方式做事,这意味着你需要在某个地方有一个方法来处理 GET、LOOK 和 DROP。
一个选择是将它们存储在Player类中——毕竟,是玩家在进行获取和丢弃操作。沿着这些思路思考是很有吸引力的,但在进行任何面向对象的编程时,你希望尽可能多地委派责任。例如,稍后你可能希望添加玩家无法捡起的对象,比如一个沉重的箱子或一座雕像。这没问题;添加一个检查以查看对象是否设置了不可移动标志。如果玩家有力量腰带,他们能捡起箱子吗?嗯,另一个检查。你可以看到这是怎么回事——等你完成游戏时,你可能在玩家的get()方法中可能有 5 个或 6 个(或 20 个)条件。
注意
当你刚开始时,类设计可能是一件棘手的事情。要记住的主要事情是经验很重要,所以你会随着实践而变得更好。此外,别忘了你可以尝试不同的设计,并选择最好的一个。
更好的方法是让对象自己判断它们是否可以被捡起。一个箱子“知道”它很重,并且在允许自己被捡起之前可以检查玩家是否拥有正确的物品在他们的背包中。这听起来很奇怪,但玩家对象不应该负责物体的重量或怪物如何战斗,将这类东西添加到玩家对象会使它过于复杂。让我们看看如何编写一些可以被查看的对象;然后,你将修改它们以便它们可以被捡起。
列表 6.5. 可以被查看的对象
class Item(object):
def __init__(self, name, description, location):
self.name = name
self.description = description
self.location = location
location.here.append(self)
actions = ['look']
def look(self, player, noun):
return [self.description]
物品需要知道的事情基本上与玩家和洞穴对象相同:它的名字和位置。你可以在创建物品实例时提供所有这些信息。
初始时,你的物品只响应一个命令:LOOK。当玩家发出带有物品名称作为名词的 LOOK ITEM 命令时,这个方法将被调用;它所做的只是返回物品的描述。
寻找宝藏
此外,你还需要修改洞穴的描述,让玩家知道特定位置有什么物品。在你做这件事的同时,你将遵循迄今为止设定的指导原则,将look()方法从玩家类中移除;从玩家类中删除该方法,并将其添加到洞穴类中。

列表 6.6. 修改look()命令

此方法的主要更改是列出位置中的所有物品并将它们放在描述下 ![one.jpg]。没有这个,玩家不会知道洞穴里有一把剑,除非他们偶然猜到可能存在。
如果玩家试图引用不存在的东西(例如 LOOK AARDVARK),此函数也将被调用。如果房间里没有土拨鼠,你需要返回一个错误 ![two.jpg]。

不要忘记更新洞穴对象可以处理的行为 ![three.jpg] 并从玩家类中移除look——否则,它将继续尝试处理 LOOK 命令。
列表 6.7. 更新你的设置
...
"A desolate, empty cave, "
"waiting for someone to fill it.")
import item
sword = item.Item("sword",
"A pointy sword.", empty_cave)
coin = item.Item("coin", "A shiny gold coin. "
"Your first piece of treasure!", empty_cave)
player = Player(empty_cave)
...
将物品添加到你的冒险中!设置它们的名称、描述和位置,物品对象将处理其余的事情。
如果你现在运行这个冒险游戏,你应该能够查看你的宝藏和一把闪亮的剑,但你无法触及它们。所以……诱人地……很近……
拿起宝藏
现在你需要的只是让对象能够被捡起。你需要进行两个更新:第一个是更新对象,给它们提供get()和drop()方法,第二个是更新玩家类,使其能够携带物品。下一个列表显示了如何将这些命令添加到游戏中。
列表 6.8. 可以被捡起的物品

get()方法本身很简单
。对象需要从当前位置的here数组中移除自己,并将其放入玩家的库存(你将在下一分钟创建这个列表)并设置其当前位置。完成这些后,你返回一条消息,让玩家知道。

如果你在没有这个检查的情况下运行之前的代码
,它将正常工作;但如果玩家再次尝试拾取物品,你的程序将会崩溃,因为如果物品不在列表中,你无法从列表中移除它。
drop()方法基本上与get()方法相同,只是方向相反
。就像之前的get()方法一样,在将物品移回你所在的位置之前,你需要检查该物品是否在玩家的库存中。
下一个列表更新了Player类,使其能够持有物品,添加了一些命令来告诉你你携带了什么,并输出错误信息。让我们在尝试查找命令处理程序时也查看库存。
列表 6.9. Player类的更新


首先,玩家需要能够携带物品,这需要一个库存
。最简单的选项是将一个列表添加到你的类中。当玩家拾取物品时,它们将被添加到这个列表中。

这些错误处理程序
与你为Cave类编写的错误处理程序类似。如果你尝试获取当前位置中没有的东西,那么这些方法将被调用以处理 GET 和 DROP 命令。
玩家应该能够记住他们迄今为止找到的东西,所以
是一个列出他们所携带一切的命令。
最后的更改是在查找处理程序时检查你的库存
。这样,玩家就可以查看或丢弃他们库存中的物品。
现在,你可以捡起剑和闪亮的硬币,也可以查看它们。你还可以将它们放回原处(尽管这不太像冒险)。手握你信任的剑和第一件宝藏,是时候深入洞穴了。
深入洞穴
一个冒险如果没有某种形式的探索就不算冒险。在大多数游戏中,你可以通过发出像 GO NORTH(向北走)或简写为 N 这样的命令来移动。当你旅行时,游戏将更新描述来告诉你你刚刚移动过的区域。你将以设置其他命令相同的方式设置你的移动命令,但也会添加一些快捷键,这样输入移动命令会更简单。

首先,让我们看看如何将方向本身添加到Cave类中。然后,你将创建允许玩家移动的命令。
列表 6.10. 向Cave类添加移动功能


变更的第一部分将有效方向列表添加到Cave类中![one.jpg],这样你知道可以旅行的所有方向。你也可以用这个来找到相反的方向(你很快就会需要它)。
![f0200-01.jpg]
当你创建每个洞穴时,你需要设置基本的数据结构,你将使用它来存储每个方向可以到达的洞穴![two.jpg]。目前,它们都是None,这意味着那个方向没有其他洞穴。
![three.jpg]是一个方便的方法,用于列出特定洞穴所有出口的方向。它很简单——一个通过self.tunnels的列表推导——但它使得当你可以访问cave.exits()时,你的代码更容易理解。不要被函数编写的顺序所迷惑——这个方法的方法代码是从look中提取出来的,因为那时它开始看起来很丑陋。
玩家会想知道他们可以从洞穴到洞穴走哪个方向,所以![four.jpg]列出了所有有效的出口。如果没有,你也让他们知道这一点。
你还需要一种方法来创建洞穴之间的隧道。单向链接很简单:你将洞穴放入self.tunnels中正确的方向。但你想让你的隧道是双向的,所以你在列表中查找相反的方向,并从目标洞穴添加一个链接到你自己![five.jpg]。
如果这个方法看起来有点奇怪,那是因为你添加了一些异常来捕获连接隧道时的错误情况![six.jpg]。raise()命令将创建类似于你迄今为止看到的错误:例如,当你输入错误时。在这种情况下,你实际上创建了自己的对象类型,就像整数或字符串一样,所以表现得像一个是更好的选择,抛出异常而不是打印错误或忽略不良输入。你将接近问题的源头崩溃,并给出一个清晰的错误消息,这使得调试你的程序变得容易得多。
小贴士
当你设计像Cave这样的类(它们实际上是库类)时,总是捕捉这类情况并尽可能抛出异常的好主意。这样,当你以后使用这个类时,当你犯错时,就会很明显。
现在你有一个可以存储方向和其他洞穴链接的Cave类,以及向玩家描述这些方向。现在让我们添加一些命令,让玩家可以在洞穴之间移动,如下所示。
列表 6.11. 在洞穴之间移动的命令
![ch06list11-0.jpg]
![ch06list11-1.jpg]
你添加的基本命令被称为 GO(例如,GO NORTH)。首先,你需要检查玩家输入的方向,确保它是有效的,并且那个方向有洞穴![one.jpg]。
一旦你确定它是有效的,你就可以继续移动玩家 ![two.jpg]。机制很简单:从当前洞穴中移除玩家,将玩家添加到新的洞穴中,并更新玩家的位置。你还把新洞穴的描述附加到命令的结果上,这样就可以让玩家生活更轻松,并减少他们键盘的磨损。
你还可以通过提供常用命令的快捷方式来使生活更轻松 ![three.jpg]。反复输入 GO NORTH 会变得很繁琐,所以你允许玩家使用 NORTH 或只是 N,以及其他四个基本方向的类似操作。幕后,这些命令调用原始的 go() 方法,所以它们的行为没有区别。
最后,你需要更新 Cave 类的有效动作列表 ![four.jpg]。同时,你也可以为 look() 命令添加一个快捷方式。
在那里,你就完成了。注意你如何将功能在玩家和他们所在的位置之间分割?这是良好面向对象设计的一个正常特性,其中对象有很好的分离责任。在这种情况下,洞穴 对象负责跟踪其出口和它们去往的地方,而 玩家 对象可以在其 go() 命令内部使用这些信息。如果以后其他东西可能需要使用方向,你不需要从 玩家 对象或你隐藏它的任何地方提取代码来使新的功能工作。
然而,玩家仍然需要某个地方可以移动,所以下一个列表扩展了之前的洞穴生成函数以提供帮助。
![f0203-01.jpg]
列表 6.12. 创建洞穴网络
![ch06list12-0.jpg]
![ch06list12-1.jpg]
你可以从另一个便利方法开始 ![one.jpg]。在一分钟内,你就会看到它是如何使用的,但这是用来告诉你一个洞穴是否可以连接到(或者不可以,如果四个方向都被占据了)。
![f0204-01.jpg]
这个函数 ![two.jpg] 是你之前在 列表 6.2 中编写的 create_caves() 函数的一个修改版。主要区别是它不仅选择了一个方向,还选择了一个洞穴,但除此之外,它是一个标准的连通洞穴结构。
你可以通过使用列表推导式中的 can_tunnel_to() 便利方法来选择下一个要连接的洞穴 ![three.jpg]。
你还需要选择一个空方向来将你的洞穴与之连接 ![four.jpg]。如果没有方向,选择函数将失败,但你并不太担心这种情况发生,因为 can_tunnel_to() 方法已经告诉你它至少有一个方向。
一旦你的洞穴有空余的位置,连接新洞穴就很容易了 ![five.jpg]。你还将你的新洞穴添加到列表中,这样其他新洞穴也可以连接到它。
最后,更新游戏设置(在 player.py 文件中)以使用新的洞穴系统
。不再只有一个空旷、荒凉的洞穴,你现在将所有东西都放入列表中的第一个洞穴,包括玩家。除此之外,其他都差不多。
现在当你运行 players.py 时,你应该能看到从起始位置的一些出口,以及正常的描述和物品。你可以拿起你的剑,四处走动,把它扔到另一个地方,然后再回来。
恭喜你,你已经创建了一个世界!请随意去探索它。当你回来时,你将添加更多部分。
这里到处都是怪物!
你现在有一个玩家,一些物品和宝藏可以收集。剩下要放入你的冒险中的只有突然、痛苦的死亡,也就是危险和刺激。你将在游戏中添加一些会在地图上移动并可能攻击玩家的怪物,如果它们在同一个洞穴里并且感觉不好,或者捡起任何散落的宝藏。玩家也可以攻击怪物,并掠夺它们的宝藏。
创建你的怪物
让我们稍微思考一下。怪物听起来是不是很熟悉?让我们画一个图表来帮助。
| 怪物 | 玩家 |
|---|---|
| 在地图上移动 | 在地图上移动 |
| 收集宝藏 | 收集宝藏 |
| 攻击玩家 | 攻击怪物 |
怪物和玩家似乎有很多共同之处。在一个基于函数的程序中,你会看到这一点并认识到你需要避免重复,但在面向对象的程序中如何做到这一点呢?答案是子类化 Player,通常称为 继承。

继承是一种说法,即“制作一个略有不同但做这些事情不同的类的副本。”在你的游戏中的怪物情况下,它们的行为几乎相同,但怪物不是由玩家告诉下一步该做什么,而是自己决定。怪物还需要有一个名字和描述,这样玩家就可以查看它们。这意味着它们的 init 和 get_input 函数需要不同,但你将保持 Player 类的大部分内容不变。下一个列表是新的 Monster 类的第一个草案。
列表 6.13. 向游戏中添加怪物

你需要做的第一件事是导入 player 模块
。一旦完成,你可以在创建类时使用 player.Player 而不是 object。现在,当 Python 尝试查找在 Monster 类中未直接定义的属性或方法时,它会在 player.Player 中查找。
当你初始化你的 怪物 类时,你还需要初始化父类以确保一切设置正确
。在这种情况下,需要设置的主要事情是怪物的位置。
这里你可以看到 player 和 monster 类之间的相似之处。你的怪物人工智能是一个不同的 get_input 版本,它生成一个命令而不是要求玩家提供命令
。一开始,你会保持简单,返回一个空字符串以什么都不做,或者返回一个随机方向来移动(充分利用洞穴类的 exits() 函数)。
玩家会想要尝试与怪物互动,因此你需要提供实现这一点的机制
。look() 从 Item 类复制而来,返回怪物的描述,而 get() 则给出一个有趣的错误信息。
现在你有一个能够以与玩家相同的方式与世界交互的 怪物 类,并且将看到所有相同的信息。这有几个重要的原因。让我们更仔细地看看这些原因以及它们如何与面向对象设计相结合。

一些面向对象设计的技巧
使用继承的第一个原因是,你可以依赖基类中存在的公共功能,这减少了程序运行时所需的“特殊处理”数量。你不需要为玩家和怪物分别编写两个独立的游戏循环,或者看起来像 if player then: ... else if monster then: ... 的程序代码。相反,你可以将怪物和玩家同等对待。
使用继承的第二个原因是,它使你的程序更容易扩展;这实际上建立了一个接口,怪物、玩家以及你可以想到的任何其他东西都可以与之交互。如果你需要向你的世界中添加第三种类型的演员,你只需要编写特定于该演员的部分。
最后一个原因是,使用继承大大减少了你需要编写的代码量,并使你的程序更容易理解,这总是好的,无论你的程序是否是面向对象的。
另一点需要注意的是,这并不是设计类的唯一方法。另一种可能更好的方法是创建一个第三类(可以称为 Mobile 或 Actor),其中包含玩家和怪物之间的所有公共功能,然后让玩家和怪物类都从该类继承。在面向对象设计中,这通常被称为 抽象类。你不应该创建 Actor 的实例;相反,你应该从它继承,添加缺少的部分,然后从你的子类创建实例。
注意
面向对象术语可能会令人困惑,但一旦你看过几个例子,你会发现它相当直观。只需将其与你熟悉的事物联系起来,比如本章中的 Cave、Player 和 Monster 类。
在这个例子中,指定抽象类的优势并不立即明显,因为你只有两个类。但是,如果你将来发现 Player 类需要的功能而怪物不应该访问,或者反之亦然,这是一个选项。
另一个设计点是,到目前为止,你更倾向于组合而非继承。继承通常被称为“是/有”关系:玩家是一个角色,怪物也是一个角色。另一方面,组合是一个“有/包含”关系:洞穴中有一个玩家,玩家有多个物品。组合通常会使你的对象耦合度更低——它们必须通过方法调用和检查彼此的值来交互——与继承不同,继承会自动将一个对象的方法插入到另一个对象中。大多数情况下,你将想要使用组合;但在适当的情况下,继承可以产生重大影响。图 6.3 展示了你游戏中组合与继承的区别。
图 6.3. 你游戏中的一些继承和组合

将所有内容整合在一起
现在你有了玩家和怪物类,你需要对游戏运行时处理玩家的方式做一些更改。你不再只想为玩家获取输入的特殊情况;你的怪物也有平等的权利!你也不想将迄今为止使用的所有函数都封装在一个类中,这样它们更容易正确交互。以下列表展示了一个正好做到这一点的类——你可以使用它来设置游戏、构建洞穴并收集输入,直到玩家完成。
列表 6.14. Game 类


这个 __init__ 函数包含了迄今为止从 Player 类中运行的所有设置代码,创建了物品、怪物和玩家。__init__ 是一个更合理的放置位置!
。
接下来,你询问游戏中的每个角色接下来要做什么!
。请注意,dir() 函数同样适用于你的类,以及 Python 的内置对象。将输入与处理分离,这样意味着一个角色不能基于其他角色即将要做的事情做出决定,这使得游戏对玩家和开发者来说都更容易理解。
一旦收集了所有输入,每个角色将依次行动!
。机制几乎相同:构建一个角色列表,然后遍历它们。如果你想做到公平,你可能需要对这个列表进行洗牌以确定谁先行动,但怪物并不关心公平性。
这是你的主游戏循环!
。它与之前的一样,只是调用了 do_input 和 do_update,玩家和怪物将它们的输出存储在一个结果列表中。你还创建了一个单独的事件列表,用于存储每回合发生的事情。
最后的益处是,你现在有一个干净整洁的main循环
。这里曾经有的所有复杂代码都被拆分成小块,并包含在方法中。

你最后需要做的是给Player类添加一个更新函数。当代码检查游戏世界中的所有对象时,它会看到玩家,以及从Player类派生出的所有对象,它们都需要更新:
Class Player(object):
...
def __init__(self, location):
self.name = "Player"
self.description = "The Player"
...
def update(self):
self.result = self.process_input(self.input)
这只是调用process_input函数,无论玩家的输入是什么,并将返回值(将是一个字符串列表)存储在self.result中。你还需要给Player类添加一个名字,因为Monster会在玩家尝试运行命令时调用process_input。
如果你现在运行你的程序,你应该能看到一个兽人在房间里和你在一起。按几次 Enter 键来模拟等待一段时间,兽人应该会离开去另一个房间。如果你四处寻找,你应该能找到兽人在洞穴里漫无目的地徘徊。不过,除非你希望你的游戏像欧洲艺术电影一样探索存在的无意义,否则你最好开始添加一些更有趣的游戏元素。
危险与刺激
你的游戏的最后一部分将是允许玩家和怪物互相攻击。在游戏中,竞争的元素是必不可少的,无论是战斗、速度、谁可以建造最大的城市、探索还是建造最好的房子。在这种情况下,你正在编写一个地下城冒险游戏,所以战斗几乎是必不可少的——任何玩过《龙与地下城》的人都会期待能够击中兽人。因为战斗将在玩家和怪物之间进行,所以你将从Player类开始,并添加一个攻击方法,如下所示。

列表 6.15。攻击其他对象


你首先添加一些你需要用于attack()方法的额外属性
。hit_points很明显,events用于存储玩家(或他们看到的)在回合中发生的事情,你已经添加了名字和描述,这样你可以轻松地处理战斗,无论目标是怪物还是玩家。

你将使用一个简单的战斗机制:计算一个命中数,掷骰子,如果掷出的数字小于或等于命中数,玩家或怪物就会被击中
。命中数通常是 2,但如果你有剑,那么它将是 4。记住,attack()命令将在被攻击的对象上调用,而不是攻击者。
接下来,你掷骰子看是否击中
——从 1 到 6 的随机选择。如果掷出的数字大于命中数,那么你就没击中。不过,在你离开之前,你需要告诉攻击者和被攻击者发生了什么。
如果你被击中,那么你将失去一个生命值。如果生命值减少到零或以下,那么你就死了 ![four.jpg]。如果玩家死亡,这将触发游戏的结束。无论如何,你都要报告,但在调用 die() 函数之前生成消息,因为那可能会修改它 ("你杀死了那个死去的兽人")。
如果你还没有死,那么情况与未命中相似:向攻击者和目标报告,然后继续前进 ![five.jpg]。
因为当怪物死亡时,可能需要进行大量的记录工作,所以你将其拉入自己的方法 ![six.jpg]。你标记自己为非玩家,取消任何未完成的命令,并将你的名字改为反映你新近死亡的状态。你还添加了一个理智检查,以确保你不能攻击自己。
这就是你使游戏中的战斗功能生效所需做的全部工作!因为类都被很好地封装了,所以不需要对 Cave、Item 和 Game 类进行任何更改。嗯,不完全是这样。如果你回想起上一章或运行代码,你会看到一个明显的问题:怪物不会反击。更糟糕的是,在你杀死它们之后,它们仍然四处跑动!这两个问题都可以通过简单地升级怪物的 AI 来解决,如下一列表所示。
列表 6.16. 更新你的怪物 AI
![06list16_alt.jpg]
死去的怪物不会说故事。如果它死了,那么它就不应该生成任何输入 ![one.jpg];如果没有这个部分,你会在杀死它之后在你的洞穴中看到一个不死不活的兽人四处跑动。
接下来,你可以让怪物对玩家进行报复 ![two.jpg]。如果怪物能在洞穴中看到玩家,那么你就发出攻击命令,就像玩家能看到怪物一样。来吧,玩家!如果怪物看不到他们,那么它就会回到洞穴中随机游荡。
现在你已经拥有了冒险游戏的所有元素:一个可以探索的房间网络,攻击玩家的怪物,以及收集物品和宝藏来帮助玩家完成他们的任务。凭借本章中的代码和你的想象力,你应该能够创建几乎任何你喜欢的冒险游戏。
接下来该做什么?
本章所介绍的课程和方法只是触及了你可以为你的游戏添加内容的表面。根据你偏好的游戏类型,你可以将你的开发引向任何方向。以下是一些关于如何扩展你迄今为止所编写的游戏的思路。
添加更多怪物和宝藏
目前,游戏中只有一个兽人和几样不同的物品。你可以添加更多类型的怪物和宝藏(或者更强大的武器,或者能以不同方式影响怪物的武器)。你还会想要一个玩家可以通过玩家上的得分方法访问的得分,当玩家退出或死亡时,也应该将其打印出来。
扩展战斗和物品
你可以扩展 Item 类或 Player.attack() 方法,添加可能有用的其他物品,例如盔甲或绳索,以及使用它们的东西。如果你添加了很多具有不同命中修正值的武器或盔甲,你可能想要考虑简化查找你添加或从命中骰或造成的伤害中减去的数量的方法。
添加更多冒险元素
一些冒险游戏更多地是关于探索具有氛围的位置,而不是杀怪物。你可以在设置阶段添加适当的描述,或者使用预先生成的洞穴系统而不是随机生成的,并添加特定的方法来处理特定事件——例如,一艘起航的船,或者你可以升起或降下的城堡吊桥。
尝试使用动词和名词
你可能想要尝试向 Item 类添加不同的方法,看看当你覆盖内置方法时你能做什么。例如,你可以在一个物品上添加移动方法 go(), north(), 等等,并制作一个直到玩家拥有正确的钥匙才能通过的门。使用需要正确魔法剑或秘密密码才能通过的静态怪物也是可能的。如果游戏找不到原始对象,还可以允许其他物品处理特定的命令。
探索类的更多高级特性
重要的是要注意,你还没有处理类的所有功能,只是你将在 95%的程序中处理的常见部分。还有其他高级类特性,如缺失方法和属性处理、属性和混入类,我们将在相关章节中介绍。如果你已经熟悉其他语言中的类,那么你可能想查阅 Python 的类文档,看看你还能用它们做什么。
概述
我们在本章中讨论了许多面向对象的主题和设计问题,并探讨了如何通过类来使你的程序更清晰、更容易理解。特别是,我们讨论了以下内容:
-
类如何封装数据和函数,并将它们初始化为实例,这样你就可以更容易地推理和理解它们,而不是单独的数据和函数
-
类如何交互,调用彼此的方法并查看数据以做出决策
-
类可以通过组合(实例可以包含其他实例)和继承(类可以被声明为另一个类的特定子类型)来组合使用
我们还没有涵盖 Python 类系统的所有特性——远未触及——但你现在已经牢固掌握了类的基本用法,更重要的是,如何在你自己的程序中使用它们来解决问题。在未来的章节中,你将更多地使用类及其更高级的特性。然而,在下一章中,我们将探讨另一个与函数紧密相关的 Python 特性:生成器。
第七章. 足够先进的技术...
本章涵盖
-
类的高级特性
-
生成器
-
函数式编程
在本章中,我们将探讨 Python 可以执行的一些更高级的任务。在 第一章 中,你了解到 Python 被称为多范式语言,这意味着它不会限制你只使用一种方式做事。有三种主要的编程风格:命令式、面向对象和函数式。Python 允许你使用所有这三种风格,甚至在必要时混合匹配它们。
我们已经在之前的章节中介绍了命令式和大部分面向对象编程,所以本章将主要关注函数式编程和 Python 面向对象编程的更高级部分。
面向对象
让我们先重新审视一下面向对象类应该如何组织,使用两种独立的方法:混入类(mixin classes)和 super() 方法。
混入类
有时候你不需要整个类就能完成某件事。也许你只需要添加日志记录,或者将你的类状态保存到磁盘的能力。在这些情况下,你可以将功能添加到基类,或者添加到每个需要它的类,但这可能会变得相当重复。有一种更简单的方法,称为 混入类。
想法是混入类只包含一小块独立的功能,通常是一两个方法或变量,这些不太可能与子类中的任何内容冲突。看看这个列表,它创建了一个 Loggable 类。
列表 7.1. 一个日志混入
class Loggable(object):
"""Mixin class to add logging."""
log_file_name = 'log.txt'
def log(self, log_line):
file(self.log_file_name).write(log_line)
class MyClass(Loggable):
"""A class that you've written."""
log_file_name = "myclass_log.txt"
def do_something(self):
self.log("I did something!')
混入类(mixin class)的定义方式与普通类完全相同。在这里,你添加一个用于文件名的类变量和一个将行写入该文件的方法。如果你想使用混入类,你所需要做的就是从它继承。
一旦你进入了子类,所有的混入方法(mixin’s methods)和变量(variables)都将可用,并且如果你需要的话,你可以覆盖它们。
使用这种简单的文件日志记录方式效果很好,但下面的列表展示了一个稍微复杂一些的版本,它使用了 Python 的内置日志模块。这个版本的优势在于,随着你的程序增长,你可以利用一些不同的日志方法——你可以将其发送到系统日志,或者当旧日志文件变得太大时自动滚动到新文件。
列表 7.2. 使用 Python 的日志模块


与其依赖类方法,不如从 __init__ 方法(
)正确实例化它们。这样,你可以处理任何额外的初始化工作,或者要求在创建时指定变量。

是在从 Python 的 logging 模块创建日志器时需要做的所有设置。首先,你创建一个日志器实例。然后,你可以向其中添加一个处理器,以指定日志条目的处理方式,以及向该处理器添加一个格式化器,以告诉它如何写出日志行。
你的混入类也需要方法,这样你就可以记录
。一个选项是使用一个通用的日志方法,你在调用它时提供严重性,但更干净的方法是使用日志器的 debug、info、warn、error 和 critical 等方法。
现在你已经在 Loggable 中使用了 init,你需要找到一种方法来调用它
。有两种方法。第一种是明确地通过使用其名称和方法直接调用每个父类,但传递 self。第二种是使用 Python 的 super() 方法,它会在下一个父类中查找方法。在这种情况下,它们做的是同一件事,但 super() 正确处理了你有共同祖父母类的情况。参见下一节,了解使用此方法时可能遇到的问题,以及本书代码包中的 super_test.py 中的示例代码。
完成所有这些后,你可以像在之前的版本中一样使用日志类
。请注意,你已暴露了日志器对象本身,因此如果你需要,可以直接调用其方法。
super() 和朋友
使用 super() 方法与 菱形继承(参见 图 7.1)可能会充满危险——主要原因是当你与常见的如 init 方法一起使用时,你不能保证调用的是哪个类的 init 方法。每个都会被调用,但它们的调用顺序可能是任意的。为了应对这些情况,在使用 super() 时,记住以下事项是有帮助的:
图 7.1. 菱形继承结构

-
使用 **kwargs,避免使用普通参数,并且始终将你接收到的所有参数传递给任何父方法。其他父方法可能没有与子类相同数量或类型的参数,尤其是在调用 init 时。
-
如果你的某个类使用了 super(),那么它们都应该使用。不一致意味着 init 方法可能不会被调用,或者可能被调用两次。
-
如果你能够设计程序以避免菱形继承——也就是说,没有父类共享祖父母,那么你不必 necessarily 使用 super()。
现在你对类应该如何组织以及在使用多重继承时需要注意什么有了更好的理解,让我们来看看你可以用类做的一些其他事情。
自定义类
当涉及到定义你的类中的方法如何工作以及哪些方法被调用时,Python 给你提供了大量的权力。你不仅能够访问 Python 的所有反射能力,还可以决定在运行时使用不同的方法——甚至可以使用不存在的方法。
当 Python 在类上查找属性或方法(例如,列表 7.2 中的 self.log_file_name 或 test.do_something())时,它将在名为 __dict__ 的字典中查找该值。__dict__ 存储类的所有用户定义值,并用于大多数查找,但可以在几个点上覆盖它。
Python 提供了多种方法来通过覆盖一些内置方法来自定义属性访问。你以与使用 __init__ 初始化类相同的方式这样做。
__getattr__
__getattr__ 用于在类或父类中找不到方法或属性时提供方法或属性。你可以使用它来捕获缺失的方法或围绕其他类或程序编写包装器。下面的列表展示了如何使用 __getattr__ 方法来覆盖 Python 查找缺失属性的方式。
列表 7.3. 使用 __getattr__
class TestGetAttr(object):
def __getattr__(self, name):
print "Attribute '%s' not found!" % name
return 42
test_class = TestGetAttr()
print test_class.something
test_class.something = 43
print test_class.some-
thing
__getattr__ 方法接收一个参数,即属性名称,并返回该值应该是什么。在这种情况下,你打印名称然后返回一个默认值,但你可以做任何事情——记录到文件、调用 API 或将责任转交给另一个类或函数。
现在你尝试访问类中不存在的值时,__getattr__ 将介入并返回你的默认值。
因为 __getattr__ 只在找不到属性时调用,所以首先设置属性意味着 __getattr__ 不会运行。
现在你已经可以获取你的属性了,让我们也学习如何设置它们。
__setattr__
__setattr__ 用于改变 Python 修改属性或方法的方式。你可以拦截对类的调用,记录它们,或者做你需要做的任何事情。下面的列表展示了捕获属性访问并将其重定向到不同的字典而不是将其插入到默认的 __dict__ 中的简单方法。
列表 7.4. 使用 __setattr__

是你设置 things 的地方,它将存储你将设置的 所有属性。使用 __setattr__ 时的一个陷阱是,你不能直接在类中设置某些东西,因为这会导致 __setattr__ 调用自身并循环,直到 Python 耗尽递归空间。你需要直接在类的 __dict__ 属性中设置值,就像你在这里做的那样。

一旦在 __dict__ 中设置了 things,你就可以正常读取它,因为当你访问 self.things 时不会调用 __getattr__。__setattr__ 接收一个名称和一个值,在这种情况下,你将值插入到 things 字典中
而不是类中。
这个版本的 __getattr__ 在 self.things 字典中查找你的值
。如果不在那里,你将引发一个 AttributeError 来模拟 Python 的正常处理。
你编写的类表现得就像一个正常类一样,除了你几乎完全控制其方法和属性的读取方式
。不过,如果你想覆盖一切,你将需要使用 getattribute。
getattribute
另一种方法是完全覆盖所有方法访问。如果你的类中存在 getattribute,它将为所有方法和属性访问被调用,对吧?
嗯,这在某种程度上是正确的。严格来说,甚至 getattribute 也不是覆盖一切。还有一些方法,如 len 和 init,是直接由 Python 访问的,不会被覆盖。但其他所有内容,包括 dict,都通过 getattribute 进行。这可以工作,但在实践中意味着你将很难访问任何属性。如果你尝试像 self.thing 这样的操作,最终你会陷入无限循环的 getattribute。
你该如何解决这个问题?如果你无法访问真实变量,那么 getattribute 将不会有多大用处。答案是使用 getattribute 的另一个版本:如果你没有覆盖它,你通常会使用的那个版本。获取一个全新的 getattribute 的最简单方法是通过基类 object,并将 self 作为实例传入。下面的列表显示了如何做到这一点。
列表 7.5. 使用 __getattribute__


Python 方法是函数,所以调用回 object 相对容易。你需要做的只是传递 self 作为实例,以及你想要
的属性的名称。我还更新了 init,这样你就可以传入值来设置内部 things 字典。
为了整理对对象的调用,你可以定义一个辅助函数来为你进行调用。
是使用它的 setattr 的一个版本。
除了你需要使用 object 来获取你正在编辑的字典之外,对 getattribute 的调用
与对 getattr 的调用非常相似;它接收一个名称并返回一个值,在过程中将 KeyError 转换为 AttributeError。
在经历所有这些之后,你的类就准备好使用了
。它遵循相同的用法模式,但现在你可以从普通检查中隐藏 things 字典(尽管如果你使用旧的 object.getattribute,它仍然可见)。
如果使用 getattribute 看起来像是一项繁重的工作,请不要担心。大多数时候,你不需要使用它。但许多第三方库除了使用 getattr 和 setattr 之外,还使用了它。如果你需要使用它们,它们可以节省大量工作,并使你的类接口更加 Pythonic 和易于使用。

属性
一个更具体的自定义属性的方法是使用 Python 的 property 函数。与 getattr 和 getattribute 在整个类中工作不同,property 允许你指定函数,通常称为 获取器 和 设置器,它们负责控制对属性或方法的访问。
属性解决了常见的编程问题:如何在不妨碍你的类的外部接口的情况下自定义属性访问。如果没有属性,通常的做法是为每个属性使用获取器和设置器,即使你不需要它们,也由于从属性访问切换到使用函数的困难。Python 允许你这样做,而无需更改使用你的类的所有内容。接下来的列表显示了如何创建一个只能设置为 0 到 31 之间的整数的整数属性。
列表 7.6. 使用属性

初始设置
与任何类的 init 函数类似。一些介绍直接设置隐藏变量;但我更喜欢这种方式,因为它意味着你不能将 x 设置为超出范围的值。
是你的获取器,它返回 _x 的值——尽管你可以将其转换为任何你喜欢的值,甚至可以返回 None。
是你的设置器,它首先检查确保值是一个 0 到 31 之间的整数。如果不是,那么你将引发一个 ValueError。
最后,你将 x 设置为类的属性
并传递获取器和设置器函数,get_x 和 set_x。请注意,你也可以定义一个只读属性,如果你只传递获取器。如果你尝试设置 x,你会得到 AttributeError: can't set attribute。
如果你不知道它是一个属性,你将无法通过使用类来识别。你定义的 x 的接口
与它是一个常规属性时的接口完全相同。
唯一的例外是你所包含的接口。如果你尝试将 test2.x 的值设置为一个超出范围的值,你会得到异常
。
在实际应用中,你将希望使用最适合你用例的方法。在某些情况下,例如日志记录、包装库和安全性检查,需要使用 getattribute 或 getattr,但如果你需要定制的只是几个特定的方法,那么属性通常是完成这项工作的最佳方式。

模拟其他类型
另一个常见的做法是编写类来模拟某些类型,例如列表或字典。如果你有一个应该与列表或数字类似行为的类,当类以完全相同的方式行为,在误用时支持相同的方法和抛出相同的异常时,这会有所帮助。
你可以定义一些方法,Python 在你以某种方式使用你的类时会使用这些方法。例如,如果你需要两个类的实例被视为相等,你可以定义一个 *__eq__* 方法,它接受两个对象,如果它们应该被视为相等,则返回 *True*。
下一个列表提供了一个示例:在这里,向之前的类添加了两个方法,以便你可以将它们相互比较。我已经将类重命名为 *LittleNumber*,以使其目的更清晰(你还需要在异常中重命名类名)。
列表 7.7. 扩展属性


这个方法
将值与提供的其他值进行比较。每当 Python 遇到 *a == b* 时,它将调用 *a.__eq__(b)* 来确定实际值应该是什么。
与 *__eq__* 类似,*__lt__*
将比较两个值,如果当前实例小于传入的实例,则返回 *True*。
__add__ 也是一个有用的方法,它应该返回将某个东西添加到类中的结果
。这种情况稍微复杂一些——如果你传入一个整数或另一个 *LittleNumber*,你应该返回一个新的 *LittleNumber*,但你需要捕获两种情况:值超出范围和有人传入不同的类型,例如字符串。如果你无法(或不愿意)处理特定情况,你可以返回 *NotImplemented*,Python 将引发一个 *TypeError*。同样,这里的一个更易于理解的错误消息将帮助你节省大量的调试时间。
信不信由你,这就是你需要让你的类表现得像整数
的所有内容。请注意,你不必实现所有像 *__gt__* 和 *__ne__* 这样的镜像函数,因为如果它们未定义,Python 将尝试它们的相反函数。这里的所有表达式都应该返回 *True*。
这里是一个表格,列出了一些你可能会想要覆盖的常见方法,如果你提供的是一个类似于内置类型的类。
表 7.1. 可能需要覆盖的常见方法
| 类型 | 方法 | 描述 |
|---|
| 大多数类型 | __eq__(self, other) __ne__(self, other)
__gt__(self, other)
__lt__(self, other)
__le__(self, other)
__ge__(self, other) | 测试相等和相对值,==, !=, >, <, <=, 和 >=。|
__str__(self) __repr__(self) |
返回类的可打印版本和类的可打印表示。 |
|---|
| 字典、列表或其他容器 | __getitem__(self, key) __setitem__(self, key, value)
__delitem__(self, key) | 获取、设置和删除条目。|
keys(self) |
返回键的列表(仅适用于字典)。 | |
|---|---|---|
__len__(self) |
返回条目数。 | |
__iter__(self) 和 iterkeys(self) |
如果你的对象很大,你可能想考虑使用这些方法来返回一个迭代器(有关详细信息,请参阅下一节)。 | |
__contains__(self, value) |
一个条目(列表或集合)的值,或一个键(字典)。 |
| 数字 | __add__(self, other) __sub__(self, other)
__mul__(self, other)
__floordiv__(self, other)
__pow__(self, other) | 返回加法、乘法、除法和幂运算的结果。|
__int__(self) __float__(self) |
将类的实例转换为整数或浮点数。 |
|---|
这些绝对不是你可以设置的唯一方法,但它们是最常见的,除非你在做某些异乎寻常的事情。让我们通过检查 Python 的生成器和迭代器来实际看看这些方法是如何在现实中使用的。
生成器和迭代器
生成器是 Python 在列表推导式之后最好的秘密之一,并且值得更详细地研究。它们旨在解决在函数调用之间存储状态的问题,但它们在需要处理大量数据的情况下也很有用,可能太大而无法轻松地放入内存中。
首先,我们将看看迭代器,Python 处理循环对象的常用方法。然后,我们将看看如何利用生成器快速处理日志文件中的大量数据。
迭代器
你在整本书中一直在使用迭代器,从第二章开始,因为每次你使用一个for循环或列表推导式时,迭代器都在幕后工作。你不需要知道迭代器是如何工作的,才能使用它们,但了解它们对于理解生成器的工作方式是有用的。
注意
迭代器是解决常见编程任务的一种常见方法:你有一堆东西——现在你如何对它们中的每一个都做些什么呢?诀窍在于,大多数 Python 集合,无论是列表、文件还是集合,都可以用作迭代器。
迭代器的接口很简单。它有一个.next()方法,你可以一次又一次地调用它来获取序列中的每个值。一旦所有的值都消失了,迭代器会引发一个StopIteration异常,这告诉 Python 停止循环。在实践中,使用迭代器看起来就像图 7.2 所示。
图 7.2. 迭代器协议:一旦运行了三次迭代,它就会停止。

当你要求 Python 遍历一个如列表这样的对象时,它首先做的事情是调用iter(object),这会调用那个对象的iter方法,并期望得到一个迭代器对象。除非你正在创建自己的自定义迭代器,否则你不需要直接使用iter调用。在这种情况下,你需要自己实现iter和next()方法。这个列表显示了如何使用iter函数从任何可迭代的 Python 对象创建迭代器。
列表 7.8. 使用迭代器的困难方式

你使用iter()函数为my_list创建一个迭代器对象![one.jpg]。如果你打印它,你可以看到它是一个新的对象类型——listiterator——而不是列表。当你调用迭代器的next()方法![two.jpg]时,它返回列表中的值:1、2 和 3。
一旦用完了值![three.jpg],迭代器将引发StopIteration异常来表示迭代器已结束。
![f0234-01.jpg]
迭代器协议很简单,但它是 Python 中循环和迭代机制的基本基础。让我们看看生成器是如何使用这个协议来使你的编程生活更轻松的。
生成器
生成器类似于迭代器。它们使用完全相同的.next()方法,但更容易创建和理解。生成器的定义方式与函数完全相同,只是它们使用yield语句而不是return语句。以下是一个简单的生成器示例,它从指定的值开始倒数到零:
![f0235-01.jpg]
让我们一步一步地来看。
生成器开始时就像函数一样,包括你给它们的参数![one.jpg]。我们还在这里包含了一个调试字符串,这样你就可以跟踪生成器是如何被调用的。
生成器需要某种方式来重复返回值,所以通常你会在主体中看到一个循环![two.jpg]。
yield语句![three.jpg]将停止你的函数并返回你给出的值。
最后,在每次调用之后,Python 将通过next()方法返回到生成器。你每次都从值中减去 1,直到它不再大于零,然后生成器将使用StopIteration异常结束。
然而,这只是一个谜题的一半——你仍然需要能够调用你的生成器。以下列表显示了你可以如何通过创建计数器并在for循环中使用它来做到这一点。你也可以直接使用一行如for x in counter(5)来调用它。
列表 7.9. 使用你的计数器生成器
![07list09.jpg]
首先,你创建计数器![one.jpg]。尽管它看起来像是一个函数,但它不会立即开始;相反,它返回一个生成器对象。
你可以在循环中使用生成器对象![two.jpg]。Python 将反复调用生成器,直到它用完值或生成器正常退出。
![three.jpg]是循环打印出来的内容。第一行是生成器的初始调试信息,其他行是它返回的结果。
你应该知道最后一个机制,它可以节省你很多设置生成器函数的时间。
生成器表达式
生成器表达式与列表推导式非常相似,但在幕后,它们使用生成器而不是构建整个列表。尝试在 Python 提示符中运行以下表达式:
foo = [x**2 for x in range(100000000)]
bar = (x**2 for x in range(100000000))
根据你的计算机,列表推导式要么需要很长时间才能返回,要么会引发 MemoryError。这是因为它正在创建一亿个结果并将它们插入到列表中。
与此相反,这个生成器将立即返回。它根本就没有创建任何结果——只有当你尝试迭代 bar 时才会这样做。如果你在 10 个结果后跳出循环,那么其他 999,999,990 个值就不需要计算了。
如果生成器和迭代器如此出色,为什么你还会使用列表或列表推导式呢?如果你想要对数据进行除了循环以外的任何操作,它们仍然是有用的。如果你想以随机顺序访问你的值——比如说第四个值,然后是第二个,然后是第十八个——那么你的生成器将帮不上忙,因为它们是线性地从第一个到第 1000 万个值访问的。同样,如果你需要向你的列表中添加额外的值或以某种方式修改它们,那么生成器将帮不上忙——你需要一个列表。
现在你已经了解了生成器的工作原理,让我们看看在你编写程序时它们可以在哪里发挥作用。
使用生成器
正如你在本章开头所学的,在读取大量数据会减慢程序速度或使其耗尽内存而崩溃的情况下,你可以使用生成器。
注意
如果你还没有意识到,Python 是一种非常实用的语言。每个特性都经过了一个严格的基于社区的审查过程,称为 Python 增强提案(PEP),因此每个特性都会有强大的用例。你可以在 www.python.org/dev/peps/pep-0001/ 上了解更多关于 PEP 的信息。

涉及大量数据的一个常见问题是文件处理。如果你有几百个日志文件,需要找出其中哪些包含特定的字符串,或者跨几个网站目录汇总数据,那么在合理的时间内理解所发生的事情可能会很困难。让我们看看一些简单的生成器,这些生成器可以在你遇到这类问题时使你的生活变得更轻松。
读取文件
Python 在处理文件的部分使用生成器的一个领域是 os 模块中的文件处理部分。os.walk 是一个很好的例子——它允许你递归地遍历目录,构建其中文件和子目录的列表,但由于它在遍历过程中构建列表,所以它既快又好。你已经在前面的 第三章 中遇到了 os.walk,当时你正在构建一个比较文件的程序。以下是一个典型的用法示例,它是一个读取目录并返回特定类型文件(在这种情况下,.log 文件)的程序。
列表 7.10. os.walk 重新审视

首先,你指定你的目录和想要搜索的文件类型
。os.walk 返回一个生成器,你可以用它来遍历目录
。它将给你目录的路径,以及它内部的任何子目录和文件。
你假设以 .log 结尾的任何东西都是日志文件
。根据你的具体情况,这样的假设可能或可能不成立,但因为你实际上将控制网络服务器,如果你需要,你可以添加 .log 部分。
当你在 代码列表 7.10 中运行程序时,它将输出类似以下内容。每个部分包含你正在迭代的目录,然后是其子目录和当前目录中的日志文件。
列表 7.11。os.walk 的输出
/var/log
['landscape', 'lighttpd', 'dist-upgrade', 'apparmor', ... ]
['wpa_supplicant.log', 'lpr.log', 'user.log', ... ]
------------------------------------------
/var/log/landscape
[]
['sysinfo.log']
------------------------------------------
/var/log/dist-upgrade
[]
['main.log', 'apt-term.log', 'xorg_fix_intrepid.log', ... ]
------------------------------------------
...
你可以使用一些生成器使你的代码更容易使用。例如,假设你正在监控你的网络服务器以查找错误,因此你想找出哪些日志文件中包含单词 error。你还想打印出该行本身,以便在出现错误时追踪发生了什么。这里有三个生成器可以帮助你做到这一点。
列表 7.12。遍历目录的生成器



这就是你在 代码列表 7.10 中看到的相同代码,但被封装在一个生成器函数中
。os.walk 的问题之一是,如果你给它一个不存在的目录或不是目录的东西,它不会引发异常,所以你在开始之前要捕获这两种情况。
现在你有了 log_files 生成器,你可以用它来构建更进一步的生成器。log_lines 逐个读取每个文件,并产生每个日志文件的连续行,以及文件的名称
。
这个生成器基于 log_lines 生成器,只返回包含单词 error 的行
。注意,你返回的是一个生成器推导式而不是使用 yield。这是做事情的一种替代方式,对于小型生成器或返回的值适合生成器推导式风格的情况来说,这可能是有意义的。
一旦你完成了创建生成器的所有艰苦工作
,调用它们就很容易了——给他们目录和文件类型,然后对每个结果做你需要的事情。
现在你可以在某个目录中找到所有日志文件中的错误行。返回包含 error 的行并不特别有用。如果你的错误不包含单词 error 呢?相反,它可能包含类似 进程 #3456 内存不足! 这样的内容。你希望在日志文件中检查各种条件,所以你需要一些更强大的东西。
掌握你的日志行
你可能希望对你的日志文件中的数据有更多的控制权,包括能够根据任何字段或字段的组合进行筛选。在实践中,这意味着你需要将日志文件中每一行的数据分割并解释这些比特。以下列表展示了从我在周围找到的一个旧的 Apache 访问日志中的一些示例。
列表 7.13. Apache 日志行
124.150.110.226 - - [26/Jun/2008:06:48:29 +0000] "GET / HTTP/1.1" 200 99
"-" "Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.8.1.14) Gecko/20080419
Ubuntu/8.04 (hardy) Firefox/2.0.0.14"
66.249.70.40 - - [26/Jun/2008:08:41:18 +0000] "GET /robots.txt HTTP/1.1"
404 148 "-" "Mozilla/5.0 (compatible; Googlebot/2.1;
+http://www.google.com/bot.html)"
65.55.211.90 - - [27/Jun/2008:23:33:52 +0000] "GET /robots.txt HTTP/1.1"
404 148 "-" "msnbot/1.1 (+http://search.msn.com/msnbot.htm)"
这些行是 Apache 的组合日志格式。大多数字段都是自解释的——IP 地址是发起请求的计算机,引用者是用于到达页面的页面(如果有)的 URL,HTTP 请求包含用户请求的路径,大小是作为请求结果传输的字节数,等等。

图 7.3. Apache 日志行

在列表 7.13 中,你应该能够看到三个单独的请求:一个在 Linux 下运行的 Firefox,一个来自 Google 的搜索蜘蛛,以及一个来自 Microsoft 的 MSN。正如你在第五章中学到的,用户代理字符串由客户端提供,因此不能完全信赖,但在大多数情况下它是准确的。HTTP 请求部分是发送给 Web 服务器的完整命令,因此它包括请求的类型(通常是 GET 或 POST)以及 HTTP 版本,以及请求的路径。
提取比特
这解释了各个字段的意义,但你如何获取它们呢?你可以根据引号或空格进行分割,但它们可能出现在奇怪的位置,从而让分割函数偏离轨道。例如,用户代理字符串和日期时间字符串中都有空格。用户代理字符串和 URL 中也可能包含引号,尽管它们应该是 URL 编码的。
小贴士
我的经验法则是当寻找简单事物时使用 Python 的字符串方法,如 endswith() 和 .split(),但我发现当你要匹配更复杂的模式,如 Apache 日志行时,它们可能会变得难以控制。
在这种情况下,通常最好一开始就使用强大的工具,而不是尝试用各种字符分割字段并确保它适用于所有情况。在这种情况下,最快的解决方案可能是使用一个称为 正则表达式 的解析工具,这对于读取此类单行文本并将它们分解成块非常有用。
正则表达式通过使用特殊的匹配字符来指定特定的字符类型,例如空格、数字、字母 a 到 z、A 到 Z 等等。正则表达式的详细描述超出了本书的范围,但表 7.2 中的便捷快速参考可以帮助你入门。
表 7.2. 正则表达式速查表
| 表达式 | 定义 |
|---|---|
| \ | 正则表达式使用反斜杠作为特殊字符。如果你需要匹配实际的反斜杠,则可以使用两个反斜杠一起,\。 |
| \w | 一个“单词”字符:a–z,A–Z,0–9,以及一些其他字符,例如下划线。 |
| \W | 一个非单词字符——\w 的反义词。 |
| \s | 一个空白字符,例如空格或制表符。 |
| \S | 一个非空白字符。 |
| \d | 一个数字字符,0–9。 |
| . | 任何字符。 |
| + | 将一个特殊字符扩展为匹配一次或多次。 \w+ 将匹配至少一个单词字符,但可能匹配 20 个。 |
| * | 与 + 类似,但匹配零个或多个而不是一个或多个。 |
| ? | 您可以在 * 或 + 通配符搜索之后使用它来使它们不那么“贪婪”。 .*? 将匹配尽可能少的字符,而不是尽可能多的字符。 |
| () | 您可以将一组字符放在括号中,然后使用匹配对象的 .groups() 方法稍后提取它们。 |
| [] | 您可以在方括号内放置字符以匹配它们。例如,[aeiou] 匹配元音。 |
| r'' | 前面带有 r 的字符串是一个 原始 字符串,Python 不会转义其内的任何反斜杠。例如,"line 1\nline 2" 通常会在多行中拆分,因为 Python 会将 \n 解释为换行,但 r"line 1\nline 2" 不会。 |
| match vs. search | 在正则表达式对象上使用了两种主要方法。match 将尝试从行的开头进行匹配,但 search 将查看整个字符串。通常,您会想使用 search,除非您知道您想要从开头进行匹配。 |
您将要使用的用于匹配 Apache 日志行的正则表达式字符串看起来像这样:
log_format = (r'(\S+) (\S+) (\S+) \[(.*?)\] '
r'"(\S+) (\S+) (\S+)" (\S+) (\S+) '
r'"(.+)" "(.+)"')
看起来很复杂,但如果分解并查看各个部分,其实并不难:
-
大多数字段都是由空格分隔的字母数字字符组,因此您可以使用 (\S+) 来匹配它们。它们被括号包围,这样您可以在匹配后访问字段。每个部分对应于 Apache 日志行中的一个字段。
-
日期和时间字段是唯一一个周围有方括号的字段,因此您也可以轻松地匹配它并使用通配符匹配提取所有内容,包括空格。请注意,您通过在它们前面放置反斜杠来转义 [ 和 ],这样正则表达式就会将它们视为普通字符。
-
引用者和用户代理也使用通配符进行匹配,因为它们可能包含引号或空格。
-
整个字符串被括号包围,这样您可以在多个字符串之间断开它,但 Python 仍然将它们视为单个字符串。

现在您已经大致了解了如何使用正则表达式匹配日志行中的字段,让我们看看如何编写 Python 函数和生成器来理解日志文件的整体范围。以下列表扩展了列表 7.7,以添加新的 Apache 相关函数和生成器。
列表 7.14. 解析 Apache 日志行


在我们深入研究函数之前,设置一些你将需要的正则表达式变量是个好主意
。apache_log_headers 是你在日志文件中看到的所有字段名称的列表,而 log_format 是我们之前查看的正则表达式字符串。你还把 log_format 编译成 log_regexp,这样在解析日志行时匹配会更快。
首先,你需要设置一个负责解析单行的函数
。在这里,你将编译的正则表达式对象与传递给你的行进行匹配,使用 match 方法。如果匹配成功,log_split 将是一个匹配对象,你可以调用 .groups() 方法来提取用括号匹配的部分。如果没有匹配,log_split 将是 None,这意味着你有一行可能是非法的。在这种情况下,你几乎无能为力,所以你会返回一个空字典。
如果你的函数将被广泛使用,你需要能够轻松访问日志行的不同部分。最简单的方法是将所有字段放入一个字典
,这样你就可以通过输入 line['user_agent'] 来访问用户代理字符串。一个快速的方法是使用 Python 内置的 zip 函数,它将字段与标题列表连接起来。它创建了一个元组序列(与在字典上调用 .items() 的结果相同),然后你可以使用 dict() 函数将其转换为字典。最后,你将一些结果转换为整数,以便稍后更容易处理。
现在你有了你的行解析函数,你可以添加一个生成器来对日志文件的每一行调用它
。
如果你关于行中的内容有更多信息,你可以在日志中搜索更多详细信息
。在这里,你正在计算请求的总大小,但仅限于成功的请求(状态码为 200)。你也可以做类似的事情,比如排除 Google、MSN 和 Yahoo 的蜘蛛,以获取“真实”的网页流量,查看有多少流量是由 Google 引用的,或者将单个 IP 地址加起来,以了解你有多少独特的访客。
当你在 列表 7.14 中运行程序时,你应该会看到一系列行及其解析表示,行尾的数字表示成功事务中传输的字节数。你的程序已经完成,如果你想要添加特定的功能,可以开始对其进行扩展。
函数式编程
随着你对编程越来越熟悉,你会发现某些功能比其他功能更容易出错。例如,如果你的程序使用了大量的共享状态或全局变量,那么你可能发现很多错误都集中在管理那个状态和追踪哪个 !$%@% 函数将所有值替换为 None。
尝试找到不涉及易出错特性、更清晰、更容易理解的设计程序的方法是有意义的。反过来,你将能够编写具有更多功能的更大程序,并且比以前更快地编写它们。
这些策略之一被称为函数式编程。它的主要标准是它使用没有副作用(它们的输出完全由它们的输入决定,并且它们不会修改函数之外的东西)的函数。如果一个函数是这样编写的,它使得推理和测试变得更容易,因为你只需要考虑函数本身,而不需要考虑其他任何东西。
函数式编程的另一个特点是函数本身就是对象——你可以将它们作为参数传递给其他函数并将它们存储在变量中。这可能看起来并不特别重要,但它使得实现许多其他情况下相当困难的功能成为可能。

副作用
副作用指的是函数在其控制范围之外做的事情或者与它返回的值无关的事情。修改全局变量或传入的变量,写入文件,以及向 URL 发布值都是副作用。函数也应该只依赖于传入的值,而不是函数之外的东西。
Map 和 filter
一旦你知道函数是安全的可以运行,并且不会做任何奇怪的事情,你就可以更频繁地使用它们——甚至是在你通常不会使用函数的情况下。
两个常见的例子是map和filter。map接受一个函数和一个可迭代对象,如列表或生成器,并返回一个包含该函数应用于可迭代对象中每个项的结果的列表。另一方面,filter接受一个可迭代函数,并只返回那些函数返回True的项。
在你的日志文件的情况下,你可能会有这样的代码:
*errors = map(extract_error, filter(is_error, log_file.readlines()))*
注意,extract_error从日志行中提取错误文本,而is_error告诉你该行是否是错误行。结果将是一个包含你的日志文件中错误消息的新列表,而原始列表将保持不变。
但是,在实践中,map和filter往往不如使用类似列表解析这样的方法使程序更易读:
errors = [extract_error(line) for line in log_file.readlines()
if is_error(line)]
函数式编程的更好用途是使用函数来改变其他函数和类的行为。一个很好的例子是使用装饰器来改变函数和方法的行为。
传递和返回函数
装饰器本质上是对其他函数的包装。它们接受一个函数作为参数,可能还有其他参数,并返回一个替代调用的函数。要使用装饰器,你将它的名字放在你要装饰的函数上方,前面有一个@符号和任何需要的参数,就像一个函数一样。
一个现实世界的例子是 Django 的user_passes_test函数,如下所示,它用于创建像login_required这样的装饰器。login_required检查用户是否已登录,如果是,则返回常规网页(Django 称它们为views),如果没有,则将其重定向到网站的登录页面。它相当复杂,但它使用了迄今为止描述的大多数函数式编程技术,以及一些其他技术。我认为你已经准备好处理它了,我们将一步一步地进行。
列表 7.15。Django 的user_passes_test装饰器


首先要注意的是,user_passes_test本身不是一个装饰器:它是一个返回你可以用作装饰器的函数的函数
。如果你需要几个类似函数,这是一个常见的技巧——传入不同的部分,让函数返回你可以使用的东西。

就是装饰器。记住,它只需要返回一个用于替代view_func的函数。
如果你打算编写几个装饰器,那么查看functools
,一个提供函数式编程相关类和函数的 Python 模块是值得的。wrap确保原始元信息,如文档字符串和函数名,在最终的装饰器中得以保留。注意,你也在你的函数中使用了args和kwargs*,所以请求的参数可以通过真实视图传递。
是测试的第一部分。如果test_func返回True,则用户已登录,装饰器将返回使用相同参数和关键字参数调用真实视图的结果。
如果他们没有登录
,那么你将返回一个重定向到登录页面的响应。注意,我已经剪掉了根据一些 Django 内部实现确定path的额外代码——但这不是理解装饰器工作原理所必需的。
接下来,你定义装饰器
。你用相关参数调用user_passes_test,然后得到一个可以替代真实视图的函数。你还会使用lambda,这是 Python 中的一个关键字,可以用来定义小型、单行的函数。如果你的函数比这更复杂,那么通常最好定义一个单独的函数,这样你可以给它命名并使其更清晰。
Python 将使用login_required返回的函数替代真实视图
,所以你的top_secret_view函数在返回任何来自你其中一个地堡的秘密文件之前,将首先检查用户是否已登录。你也可以包含参数,如果你想让装饰器有不同的行为:在这种情况下,通过重定向到/超级秘密/login 的单独登录系统。
在大多数编程中,重点是对象及其交互,但优秀的函数式程序仍有其位置。任何需要额外配置、有可提取的公共功能或需要包装某些内容(而不需要整个类的开销)的地方,你都可以使用函数式编程。
接下来该做什么?
从这里,你可以扩展你的日志解析脚本以捕获不同类型的流量。你可以根据类型(访客、登录用户、搜索引擎、机器人)、网站使用的部分或到达的时间段对日志条目进行分类。还有可能通过 IP 地址跟踪个人在使用你的网站时的行为,以了解人们如何使用你的网站或确定他们在寻找什么。
你也可以在其他类型的程序中使用 Python 的生成器。如果你是从网页而不是日志文件中读取信息,你仍然可以使用相同的策略来帮助你减少代码中的复杂性或所需的下载次数。任何需要减少读取的数据量或需要重复调用但仍然保持状态的程序,都可以从使用生成器中受益。
你还应该留意程序中可能从本章讨论的一些高级功能中受益的区域。秘密在于,当你使用它时,它应该通过在模块或函数中隐藏困难或重复的部分来使你的程序更容易理解。当你用 Django 编写应用程序时,你只需要在每个你想要保护的视图中包含@login_required——你不需要明确检查请求的用户或自己重定向到登录页面。
摘要
在本章中,你学习了更高级的 Python 特性,如生成器和装饰器,并了解了如何修改类的行为,使它们按照你的意愿行事。
你看到了如何改变查找类方法的方式,捕获缺失的方法,甚至用你自己的标准替换正常的方法查找。你看到了如何通过使用属性来透明地替换属性为函数,并通过定义特殊方法使你的类表现得像整数、列表或字典。
我们还探讨了如何使用生成器来组织程序中的数据,以及它们如何通过仅在需要时加载数据而不是一次性加载一大块数据来减少程序所需的内存。我们介绍了如何将生成器链接起来以帮助编写更复杂的程序,通过一个解析 Apache 日志文件信息的例子来说明。我们还探讨了在需要一种好方法来匹配或从某些文本中提取信息时使用正则表达式模块。
最后,我们讨论了函数式编程,你看到了 Python 如何通过map和filter支持它,此外还有可以分配给变量的函数。然后我们探讨了装饰器以及它们在实际中是如何通过定义和返回不同的函数来工作的。
我们已经涵盖了 Python 的大部分特性,所以从现在开始,我们将采取稍微不同的方法,来看看一些与 Python 一起使用的常见库。在下一章中,我们将研究 Django,这是 Python 使用的主要 Web 框架。
第八章。Django!
本章涵盖
-
在 Django 中编写 Web 应用程序
-
设计一个 Web 应用程序
-
一些常见的 Web 实践,例如仅通过 POST 进行编辑
在第三章中,我们探讨了构建一个简单的待办事项列表来帮助你跟踪你正在做什么。现在我们将扩展应用程序并通过 Web 浏览器使其可用,这样无论你在哪里(只要你有互联网连接,显然),你都可以看到你需要做什么。为了使你的生活更轻松,你将使用一个名为 Django 的 Web 框架。
你问什么是 Web 框架?当你为 Web 开发时,你需要跟踪很多细节。除了显示 HTML 和处理表单输入外,还有很多额外的部分:
-
处理 cookies、会话和登录
-
检测错误并显示它们
-
在数据库中存储数据
-
将页面设计从应用程序的其他部分分离出来(这样你的网页设计师就可以在不打扰你的情况下设计页面)
等等。使用像 Django 这样的框架,你可以使用代码来处理所有这些事情,并更快地构建你的 Web 应用程序。
使用 Django 编写基于 Web 的应用程序
Django 是主要的 Python Web 框架,拥有庞大的开发者群体。它不是唯一的 Python 框架,但它是使用最广泛且文档最完善的框架之一。它主要遵循模型-视图-控制器(MVC)编程风格,但有时会稍微调整这种结构。在 Django 中,有许多内置功能可以使开发 Web 应用程序变得更加容易。
表 8.1. Django 术语与 MVC 术语对照
| Django | MVC | 目的 |
|---|---|---|
| 模型 | 模型 | 存储数据 |
| 模板 | 视图 | 展示用户界面 |
| 视图 | 控制器 | 执行“操作” |
模型-视图-控制器
模型-视图-控制器(MVC)是一种设计方法,它将数据与其展示方式分离。模型存储数据并具有操作它的函数。视图向最终用户展示数据和用户界面。最后,控制器处理两者之间的所有事情。
MVC 经常被用作一个万能术语,但不同的人、应用程序和 Web 框架以不同的方式解释和使用它;很难给出一个在每种情况下都适用的定义。参见表 8.1,了解 Django 术语和“经典”模型-视图-控制器之间的区别。最重要的是,它将数据、数据的展示以及存在于两者之间的业务逻辑“胶水”分离出来。
安装 Django
Django 的安装很简单,因为你已经安装了 Python。从 www.djangoproject.com/download/ 下载最新版本,解压它,然后在安装目录中运行 python setup.py install。在 Linux 和 Mac OS X 上,你需要在其前面加上 sudo,而在 Windows 上你需要从具有管理员权限的命令行中运行它。安装过程完成后,在 Python 中输入 import django 以确保它正在工作。你不应该看到任何错误。

如果你遇到任何问题,或者如果你认为你可能需要安装一个更复杂的设置(如果你想要运行 PostgreSQL 或 MySQL 数据库服务器,或者使用单独的 Web 服务器,如 Apache 或 Nginx),更详细的安装说明可在 docs.djangoproject.com/en/dev/topics/install/ 找到。
设置 Django
现在你已经安装了 Django,你可以开始你的项目工作了。Django 会为你设置大多数事情;你只需要将你的代码插入到正确的位置并更改一些设置。在你的电脑上选择一个你想要存储和运行项目的目录,然后在命令行中输入 列表 8.1 中的代码。在 Linux 下应该可以按原样工作,但在 Windows 上你需要做两件额外的事情:
-
将 C:\Python26\Scripts 添加到你的 PATH 环境变量中,就像你在 第一章 中做的那样,然后重新启动你的终端以便 django-admin 能够工作。
-
在名为 PYTHONPATH 的第二个环境变量中包含你的项目路径。这个变量允许你为 Python 添加额外的路径,以便在导入模块时进行检查。Django 的设置作为 Python 模块导入,因此 Python 需要知道它们的位置。以下图示显示了我在我的电脑上如何编辑它们。
列表 8.1. Django 首次运行

一旦你做了必要的调整,你就可以开始了!
图 8.1. 设置 Django 系统路径

大多数设置和与你的服务器后续交互都是通过 django-admin
完成的,这是一个 Django 内置的脚本。

一旦你创建了你的项目,你可以四处看看 Django 创建了什么
。现在没有什么可看的,但随着你编写应用程序,你将在此基础上构建。
在此同时,启动 Django 并看看会发生什么。在 todos 文件夹内,输入 python manage.py runserver
。
当服务器启动时,它会告诉你它正在运行的 IP 地址和端口号
。如果你想在不同的 IP 地址或端口上运行,请在运行 manage.py 时指定它。例如,python manage.py runserver 0.0.0.0:80 将连接到你的电脑上的端口 80 上的每个接口,这样你就可以通过允许你的朋友连接到运行在你电脑上的新潮的 Web 服务器来给他们留下深刻印象。
Django 将持续运行,直到你手动停止它
。runserver 是开发服务器,它将自动检测文件的变化并在必要时重新启动,所以在大多数情况下你甚至不需要重新启动来刷新你的应用程序。
当请求到来时,Django 将打印日志行以便你可以看到它在做什么
;这可以用于调试。
如果你访问前一个输出中列出的 URL,http://127.0.0.1:8000/,你应该能看到类似于图 8.2 的内容。
图 8.2. Django 的起始屏幕

如果你仔细观察,你会发现 Django 甚至会告诉你下一步要采取的步骤;但现在你可以忽略数据库部分,安装一个应用程序。在 Django 的术语中,一个应用程序是一个执行特定任务的模块,例如管理待办事项或用户注册。一个项目可能包含多个应用程序,Django 将设置并协调它们。你可以让开发服务器继续运行——它将自动检测并重新导入你做出的大多数更改。
注意
Django 开发者鼓励你重用代码,他们实现这一目标的一种方式是将项目拆分为应用程序。理想情况下,你可以在任何一个项目中拥有多个应用程序,它们各自贡献自己的部分。你可能有一个应用程序用于存储待办事项,另一个用于处理电子邮件,第三个用于 PayPal 注册,等等。下次你使用 Django 创建网站时,你将能够重用其中的一些应用程序来帮助你构建它。
现在,你可以添加一个应用程序并创建你的第一个简单页面。这将帮助你确认一切正常,并让你第一次尝试创建 Django 应用程序。你需要编辑两个文件:urls.py 和 todo/view.py。下面的列表显示了我是如何设置我的 todo 应用程序以及我在 settings.py 和 url.py 文件中放入的内容。
列表 8.2. 第一步

第一步是使用 manage.py
创建你的应用程序。Django 将创建一个 todo 文件夹来存储所有应用程序特定的代码。
接下来,你创建你的视图。Django 有助于在正确的地方放置一个注释
。
当你创建一个页面以发送回请求者时,涉及了很多细节:设置 MIME 类型、状态码等等。Django 的 HttpResponse 处理所有这些
,并在一个地方存储所有可能的变量,如果你需要修改它们。
是 Django 可以调用的一个函数,用于显示网页。它接受一个 Request 对象,代表对网页的请求,并返回一个 Response 对象。目前你不需要做任何复杂的事情——只需返回一个“Hello world!”的响应。
urls.py 是你将一切联系在一起的地方
;你可以将其视为 Django 的交通控制器,确保每个请求都到达正确的位置。patterns 函数接受包含正则表达式和函数的多个元组。如果正则表达式匹配,Django 将调用相应的函数并显示它返回的内容。目前,你将使用 *.**,这将匹配所有内容。
提示
通过在某个地方保存一个目录,里面包含你设置新项目所需的所有内容——在这个例子中,是 Django 安装程序、你的初始“Hello world!”设置,以及你在进行过程中找到的所有有用的东西,这样可以大大节省时间。
如果你现在刷新你的页面,你应该看到不那么有帮助的“Hello world!”消息,类似于图 8.3。
图 8.3. “Hello world!”


这不是很多,但这是你的“Hello world!”现在你对 Django 及其工作方式有了更多了解,让我们开始着手你的待办事项列表!
编写你的应用程序
由于你对你的待办事项应用程序应该是什么样子有相当好的想法,你将从前端视图开始,并在需要时添加后端功能,或者创建一个简单的替代品。随着需求的出现,你将逐渐引入更多 Django 的功能。
注意
这种开发策略只是使用 Django 构建程序的一种方式。你可以使用其他方法,例如首先创建你将工作的模型,或者你需要来控制一切的业务逻辑。这完全取决于你的应用程序,技术风险在哪里,什么最有意义。
最简单的待办事项列表
这里是你的新待办事项列表!它很简单,但你已经可以用它来跟踪你的待办事项。你需要从你的视图中返回待办事项页面的 HTML,而不是简单的“Hello world!”
列表 8.3. 一个简单的待办事项列表
def hello_world(request):
return HttpResponse("""<html>
<head>
<title>My Todo list!</title>
</head>
<body>
<h1>Todos:</h1>
<p>Mow the lawn</p>
<p>Backup your PC</p>
<p>Buy some Milk</p>
</body>
</html>""")
如果你需要一个新的待办事项,将其添加到你的视图中。当你完成某事时,删除它。也许这并不是很有用,手动输入所有 HTML 也很烦人,但请记住,这就是 Django 对你请求的任何网页所做的事情。你需要一种更好的方法来生成你的 HTML 标记。
使用模板
Django 和大多数其他 Web 框架通过使用模板来解决此问题。你不需要一开始就输入所有的 HTML,你可以使用一种简单的编程语言,从你可以访问的变量和列表中生成它。下面的列表提供了一个简单的例子。

列表 8.4. 使用模板

这里是你的待办事项列表。目前还没有什么特别之处:只是记得割草、备份你的电脑,以及买一些牛奶
。
模板负责显示页面
,包括所有繁琐的 HTML 片段。你将在一分钟内看到模板的样子以及如何创建它。
上下文是一种将变量传递给模板的方式
。在这种情况下,你只对你的待办事项列表感兴趣,所以这就是模板需要了解的所有信息。
是所有工作完成的地方。你使用模板的 .render() 方法,并传入特定的上下文,创建一个 HttpResponse 对象,然后将其发送回去。
如果你直接运行这段代码,你会得到一个 TemplateNotFound 错误(如图 8.4 所示),因为你没有告诉 Django 关于 index.tmpl 模板的信息。
图 8.4. 我的模板在哪里?

你需要创建 index.tmpl 模板,无论是在 todos 应用程序内部还是在单独的模板目录中。以下是我版本的模板示例。
列表 8.5. 一个简单的模板


你的 Django 模板只是一个 HTML 页面
,但包含了一些特殊的命令来动态创建一些额外的 HTML。如果你熟悉 HTML,模板不会让你感到太陌生。
你的模板代码的第一部分是一个 if 语句
。模板中的 Python 代码被包裹在 {% %} 括号中用于程序代码,或者 {{ }} 括号中用于变量。变量来源于模板被应用程序渲染时传入的上下文。
你也可以在模板中使用 for 循环,来遍历一个列表或迭代器
。
正常的 Python 程序依赖于缩进来指示 for 循环或 if 语句的开始和结束位置,但在 HTML 模板中嵌入代码时这是不可能的。为了解决这个问题,你需要在关闭 if 语句或 for 循环时包含显式的结束标签
。
最后,你需要编辑 todos 项目中的 settings.py 文件,以便 Django 知道在哪里找到你的待办应用程序的模板。
列表 8.6. 编辑 settings.py

这里就是你的待办模板存储的地方
。Django 会搜索子文件夹,所以如果你发现应用程序目录变得杂乱,你可以在子目录如 todo/ templates/ 中存储你的模板。现在如果你在浏览器中刷新页面,你应该会看到一个格式良好的表格,列出你必须完成的任务。如果你想到了另一个任务,那么在待办字典中添加另一个条目,模板会处理其余部分。
但你仍然会遇到类似的问题:你从直接编辑 HTML 转变为直接编辑字典。虽然将表示和数据进行分离是一个改进,因为你现在能够将任务存储在数据库中。
使用模型
但在这样做之前,你需要启动一个数据库,告诉 Django 数据库的位置,并用你的初始数据填充它。这是一项相当多的工作——但 Django 会为你做大部分。你正在使用内置的 SQLite 数据库,这对于你的需求来说已经足够好了,但如果你正在编写一个更大的应用程序,你可能需要一个更强大的工业级数据库,比如 MySQL 或 PostgreSQL。

设置数据库
首先,你需要编辑你的 settings.py 文件,告诉 Django 使用哪个数据库文件。在你的首选编辑器中打开它,并将数据库行更改为如下所示:
DATABASE_ENGINE = 'sqlite3'
DATABASE_NAME = 'todo.db'
其余的数据库行你可以保持不变。还有一些其他与管理员电子邮件和时区有关的设置——如果你想要编辑它们,也可以——但它们不是立即必要的。一个例外是,如果你的系统在 Windows 下运行,你的时区需要设置为与系统相同的时区。有关更多详细信息,包括有效时区设置的链接,请参阅 docs.djangoproject.com/en/dev/ref/settings/#time-zone。
现在,输入
python manage.py syncdb
并且 Django 会为你设置数据库。在这个过程中,它还会要求你创建一个管理员用户(这是一个好主意)。如果你在这个时候没有设置管理员用户,你可以稍后通过运行 python manage.py createsuperuser 来完成。
创建模型
现在你已经准备好创建模型来存储你的数据了。因为你已经在 第四章 中创建了一个 todo 应用程序,你将在此基础上构建数据结构。你需要打开 todo 目录中的 models.py 文件,并输入类似于以下列表中的内容。
列表 8.7. 一个 todo 模型

所有数据库交互代码都存储在 Django 的 db 模块
中。为了以 Django 可以理解的方式声明你的 todos,你创建一个 Todo 类作为 models.Model 的子类。
数据库中的字段
与 Python 中的变量类似——它们存储你需要的数据。Django 有许多不同的字段类型,你甚至可以创建自己的。
使用数据库的一个重要部分是你可以限制输入到其中的值
。这样,你就不能将无意义的信息输入到你的应用程序中——Django 会捕获它并拒绝添加或编辑你的 todo。你已经将 importance_choices 放在模型外部,这样你就可以在其他上下文中访问它。
一旦你创建了你的模型,你需要让 Django 知道它的存在。你将更新 settings.py 来告诉 Django 关于 todo 应用程序的信息,然后同步你的数据库,这将告诉 Django 查找新的模型或字段并创建它们。下一个列表显示了我是如何做到这一点的。
列表 8.8. 将 todo 应用程序添加到你的项目中

是你需要添加的行。这些行将被 Django 转换为 import 行,所以为了安全起见,你通常想要包含项目名称和应用。
一旦你完成了这个操作,你就可以使用 manage.py 来创建你的表 ![two.jpg]。
如果你有一些数据库经验,并且想看看 Django 在幕后做了什么,下面的列表展示了如何使用 manage.py 的 sql 命令来检查待办事项模型。
列表 8.9. 显示你的模型的 SQL
anthony:~/todos$ python manage.py sql todo
BEGIN;
CREATE TABLE "todo_todo" (
"id" integer NOT NULL PRIMARY KEY,
"title" varchar(200) NOT NULL,
"description" text NOT NULL,
"importance" varchar(1) NOT NULL
)
;
COMMIT;
现在你已经有了一个数据库,你只需要添加一些数据以便你可以测试应用程序。Django 也有一个简单的方法来做这件事。
注意
当选择一个框架如 Django 时,需要注意的一个问题是它提供了多少节省时间的库。大多数 Web 框架都提供了像模型-视图-控制器和路由 URL 到视图这样的功能,所以像管理模块这样的额外功能将使你的生活更轻松。
Django 的管理模块
Django 的一个优势是其内置的管理系统,它将允许你查看和编辑数据,而无需自己编写大量的数据处理和检查。你只需要对 settings.py 和 urls.py 进行一些修改,并同步你的数据库,你就可以准备定义一个管理界面了。让我们先开启它
列表 8.10. 激活 Django 的管理系统
![08list10.jpg]
所有的管理功能都存储在 admin 应用中,它需要创建一些数据库表,所以你需要包含 django.contrib.admin 作为应用 ![one.jpg]。
autodiscover 函数会遍历你编写的管理接口,并自动生成 Django 需要的配置 ![two.jpg]。你还会发现,管理功能已经添加到了你的 urls.py 中——你只需要从相关行的开头移除注释字符。
![f0268-01.jpg]
习惯上使用 ^admin/(.)*,但如果你更喜欢保密,你可以添加任何你想要的路径 ![three.jpg]。任何匹配该路径的内容都会被发送到管理模块的根函数。
现在你只需要再次同步你的数据库,使用 python manage.py syncdb,Django 将创建它需要来运行的管理表和索引。然后访问 http://127.0.0.1:8000/admin/,你应该能看到服务器的登录页面。使用你之前输入的管理员用户名和密码,你应该能够访问管理页面。
你已经进来了,有一些看起来像网站的东西,但你在哪里编辑你的待办事项?首先,你需要使用 Django 管理界面注册你的模型,这样它就会知道包含该模型,显示哪些字段等等。
图 8.5. 登录 Django 的管理系统
![08fig05_alt.jpg]
添加管理界面
Django 在如何设计你的管理界面方面提供了很多灵活性,但到目前为止,我们将保持简单。以下列表提供了你能够在管理界面中看到待办事项所需的最小内容。所有的管理代码都存储在 todo/admin.py 中。内容不多——导入你的Todo模型和admin,并将Todo类注册为应该出现在管理界面中的内容。Django 将处理其余部分。
列表 8.11. 注册你的模型:admin.py
todo/admin.py:
from todos.todo.models import Todo
from django.contrib import admin
admin.site.register(Todo)
todo/models.py:
class Todo(object):
...
def __unicode__(self):
return self.title
你需要对你的模型进行的唯一更改是添加一个unicode方法。没有这个方法,Django 将不知道如何引用任何特定的待办事项,你将得到模型名称:Todo.你需要重新启动服务器,以便admin.autodiscover函数能够检测到你的管理更改。一旦这样做,你应该在界面中看到待办事项链接出现。如果你点击待办事项然后添加待办事项,你会看到类似于图 8.6 的内容。此外,请注意,如果你没有输入值,或者输入了不适合你模型的内容,Django 会注意到并给你一个错误。
图 8.6. 在 Django 管理界面编辑待办事项

你现在可以继续添加一些待办事项给自己,然后返回并查看待办事项页面。你会看到 Django 的管理页面为你提供了一个所有待办事项的表格,你可以点击并编辑任何你需要修改的。
如果你需要显示或隐藏某些字段,或者按特定字段排序,管理界面也很容易进行自定义。让我们添加一列来显示每个待办事项的重要性:
class TodoAdmin(admin.ModelAdmin):
list_display = ['title', 'importance']
search_fields = ['title', 'description']
admin.site.register(Todo, TodoAdmin)

通常,Django 会根据一些简单的默认值自动为你创建这个类。要创建你自己的自定义版本,你需要继承ModelAdmin并覆盖你想要更改的部分,例如在列表视图中显示哪些项目。
还有许多其他属性可以影响管理显示。例如,你可以通过添加这一行来实现对标题和描述的搜索。
现在,当你注册你的类时,你将包含自定义的管理类而不是让 Django 自己选择。
如果你现在重新启动你的服务器并查看待办事项列表,你会看到第二列。当你之前创建优先级列表时,你可能记得它使用了 A、B、C 和 D 这些值。秘密原因是你可以通过点击列顶来根据优先级对待办事项进行排序。
好的,我认为你的管理界面已经完成了。你通常不希望让每个人都访问管理界面,所以我们将查看如何在下一节中提供一个适合一般消费的前端。
利用你的数据
现在你有了可以工作的数据,你应该提供一个接口,以便其他人可以看到你在做什么。尽快从你的程序中得到有形的输出通常是一个好主意;这样,你可以看到需要做什么,人们可以尽早给你反馈和想法。
使用模型
让我们首先了解你如何访问数据库中的数据并利用它。你将更新你的前一个视图,使其使用数据库而不是你的字典。
列表 8.12. 修改你的视图

你的模型类是视图和数据库之间的主要接口,因此你需要导入它!
。
查找所有你的待办事项——目前还没有什么复杂的功能。Todo.objects.all() 将返回一个 QuerySet 对象,其中包含所有你的待办事项,你可以使用 .order_by 方法对其进行排序。还有其他 QuerySet 方法可以帮助你搜索模型——一个简短的列表在 表 8.2 中。
表 8.2. 一些常见的 Django QuerySet方法
| 方法 | 描述 |
|---|---|
| .all()[0] | 一个 QuerySet 对象只有在绝对需要时才会触发查询,因此你可以使用这些切片来过滤查询中的前几个结果。 |
| .filter(criteria) .exclude(criteria)
.get(criteria) | .filter(), .exclude(), 和 .get() 将根据你指定的关键字参数返回结果。 |
| .get(id__exact=14) | 关键字如下指定: |
|---|---|
| .filter(importance__lte='B') | <字段>__<匹配类型> |
| .exclude( title__contains=
| Django 将它们转换为相关的 SQL。你还可以将 QuerySets 链接起来,以进一步限制你返回的结果。 |
除了这些,你不需要对你的视图做任何更改!
——Django 的模板足够智能,当你给它一组对象而不是字典列表时,它能够调整。
然而,关于你的显示,你需要做一些更改。你的优先级是通过底层的字母来显示的,而不是人类可读的字母。幸运的是,这只是一个简单的模型和模板更改,所以让我们现在就做。
列表 8.13. 可读的优先级

第一步是更新你的模型,使其能够提供优先级的人类可读版本,而无需跳过太多环节。做到这一点的一个简单方法是将 importance_choices 元组转换为字典!
,然后使用 self.importance 来访问正确的选项。
现在,在模板中使用 text_importance 方法来显示待办事项的重要性!
。请注意,Django 的模板足够智能,无论你给它什么输入,都能做正确的事情。

这是您需要显示待办事项的基本功能。您可以通过扩展模板来使页面看起来更美观,例如根据重要程度对项目进行颜色编码等。for 和 if 元素以及您模型的方法应该足以创建大多数应用程序。
表 8.3 展示了一些可能有用的其他模板语法元素——但如果你需要更复杂的功能,通常更好的做法是在模型或控制器中而不是在模板中包含这些功能。
表 8.3. Django 模板语法速查表
| 语法 | 用法 |
|---|
| {% for variable in iterable %} <tr class="{% cycle 'row1' 'row2' %}"
{{ variable }}
{% endfor %} | 您已经看到了 for 循环的作用。不过,还有一个方便的额外功能,那就是 cycle——它将在每次循环遍历时在您提供的值之间切换。 |
| {% comment %} ...
{% endcomment %} | 如果您需要在模板中添加注释,这是您应该这样做的方式。注释标签及其之间的代码都不会出现在最终输出中。添加注释可能很有用,但通常您的模板应该足够简单,以至于您不需要它们。 |
| {% filter force_escape|lower %} HTML 转义的小写文本。
{% endfilter %}
{{ variable|urlencode}} | 您可以通过将过滤标签包裹在需要转义的文本周围,或者使用管道字符 | 和过滤名称来应用各种过滤器到模板的输出。有许多不同的过滤器可供使用,例如大写、小写和 urlencode,您甚至可以编写自己的过滤器! |
现在您有了一种从数据库获取数据到最终用户的方法——但是您需要能够再次获取数据。要做到这一点,您需要能够提交表单。
设置您的 URL
首先,您需要稍微考虑一下您的应用程序的布局方式。Django 容易支持的一种好方法是称为表示状态转移(Representational State Transfer),简称 REST。简而言之,这是一种您可以用它来表示网络上的资源及其上可以执行的操作的风格。
REST 对于典型基于数据的应用程序,如您的待办事项应用程序,效果很好。在这种情况下,您有许多待办事项,并且您希望能够查看、添加、编辑和删除每个单独的待办事项。一个典型的 REST 设计可能看起来像以下列表。
列表 8.14. 一个 RESTful URL 设计

如果您将待办事项列表视为一个资源,那么这是您应用程序的根级别
。添加待办事项不会链接到另一个待办事项,因此最好将 添加 作为您根资源的一种方法。
一旦您创建了一个待办事项,查看它意味着将它的 ID 附加到 URL 的末尾
。
通常,任何资源的默认方法都是查看它,但是当您需要编辑或删除待办事项时,您有时会想要附加方法
。然而,在这个应用程序中,您将只使用相同的 URL 来查看和编辑。
注意
使用 RESTful 接口的一个优点是它鼓励你一次只做一件事情。如果你还没有实现删除功能,这通常不是什么大问题——你仍然可以测试其他部分,因为它们是独立的。
现在你已经规划了应用程序应该如何工作,那么如何将其付诸实践呢?所有的 URL 处理都存储在 urls.py 中。正是在那里你可以指定哪些 URL 将工作以及哪些视图应该处理它们,还可以提取 ID。然而,为了更好地封装你的待办事项应用程序,你将在 todo 中创建自己的 urls.py,然后从标准的 urls.py 中包含它。
列表 8.15. 设置 URL 和视图

首先,你从根 urls.py 中包含一个单独的 urls.py 文件
。请注意,路径相对于项目根目录。你也可以删除 import todo.views 。

接下来,在 todo 中创建 urls.py 并在其中添加这两行
。第一行包含默认的 Django URL 处理函数,第二行导入视图。
你的模式定义
与你在根 urls.py 中使用的函数定义完全相同。这里的省时之处在于你可以包含函数的开始部分,而不是多次调用 todo.views.some_function 。
接下来,你为查看待办事项列表和添加待办事项定义 URL
。请注意,我已经将 hello_world 视图重命名为 todo_index ,这更符合逻辑。include 函数也会从前面剪掉 todos/ 部分,因此你匹配的是一个空 URL。
最后,
是单个待办事项的 URL。有一个用于查看,一个用于编辑,还有一个用于删除。请注意,URL 正则表达式定义了一个带有括号的组。它匹配的数字将被作为额外的参数传递给视图——当我们查看如何处理单个待办事项时,你会看到如何利用它。

注意,在 todo/urls.py 文件中,你没有指定任何视图的绝对路径,或者任何超出你责任范围的东西。这将在以后帮助你,尤其是如果你试图组合几个不同的应用程序或者需要在其他地方使用你的应用程序。
目前你需要对 URL 做的就这些。让我们继续编写一些可以处理用户输入的视图。
提交表单
你首先需要创建处理添加新待办事项所需的表单和视图。如果没有待办事项开始编写待办事项编辑表单就没有太多意义。你将把它添加到根页面——将其包含在那里是最有意义的。提交后,它将转到 http://localhost:8080/todos/add ,这将处理其余部分。下一个列表显示了如何将表单添加到模板中。
列表 8.16. 提交表单


不要忘记你希望你的待办事项应用程序是可移植的。使用硬编码的路径,如 /todos/add,很有诱惑力,但那样意味着每次你重用应用程序时都需要编辑你的模板。
这样你就不必反复添加 importance_choices 的单独实例——这可能会导致不同步——你包含来自你模型的版本
。选择将需要从模型传递到模板
。
现在,如果你刷新 /todos/ 页面,你应该在待办事项列表下方看到表单。
但如果你尝试提交表单,你会得到一个错误。你还没有编写你的处理器,所以尽管在理论上 Django 知道该怎么做,但它找不到这个函数。下一个列表显示了如何做到这一点。
图 8.7. 添加待办事项的表单
![08fig07.jpg]
列表 8.17. 处理添加待办事项的视图
![08fig17_alt.jpg]
处理 POST 请求与其他视图没有不同。定义一个函数,它接受一个请求参数
。
![f0279-01.jpg]
接下来,你创建一个新的 Todo 实例,基于通过 HTTP POST 传入的值
。你可以输入的内容没有限制,所以你直接输入参数。
一旦你创建了待办事项,其 .save() 方法就会将其写入数据库
。
你完成了,所以你使用 HttpResponse-Redirect 返回到索引页面
。这样你就不会用类似 /todos/ 的东西硬编码 URL,而是使用 reverse() 函数,它接受一个视图或视图的名称,并返回其 URL。
reverse() 函数不喜欢未实现的观点,所以你现在需要添加一些。因为它们都与特定的待办事项相关,所以你确保待办事项的 ID 被包含在内
。
这应该就足够了。如果你在表单中输入待办事项并提交,你应该看到它在列表中显示。恭喜!你现在有一个功能齐全的 Web 应用程序。你可以从数据库向用户显示数据,并接受输入,你的应用程序可以使用这些输入来添加数据。
Django 中的安全
如果你有一些之前的 Web 开发经验,你可能对之前的代码感到牙痒痒。通常,盲目接受使用你网站的人的输入是一个主要的安全漏洞,会导致 SQL 注入和 XSS(跨站脚本)攻击,但 Django 的数据库层和模板层会自动转义任何输入和显示的数据,除非你告诉它否则。
几乎任何 Web 框架,甚至是一个 CGI 应用程序,都会让你能够显示数据和接受请求。Django 的好处是你可以用少量简单、直接的代码来做这件事,并轻松地构建更高级的功能。
尽管如此,你仍然需要关注单个待办事项——编辑和删除与创建新事项一样重要。我们还将探讨一些其他方法,你可以使用 Django 的一些更高级功能使你的开发更加容易。
处理单个待办事项
使用 Web 框架的主要优势之一是许多简单的模板代码已经为你准备好了。在 Django 的情况下,最有用的两个部分是通用视图和模型表单:
-
通用视图是常见视图类型的实现,例如,在数据库中显示特定模型的项列表,以及与之相关的所有编辑、更新和删除操作。
-
模型表单是从你的模型直接构建的表单。因为它们知道你的数据结构和类型,它们可以自动处理表单数据的解析和清理,这使得从应用程序的用户那里获取信息变得容易。
你主要会使用通用视图来构建你的应用程序,但我们将简要介绍一些模型表单。因为你的模型和视图已经编写好了,你将从应用程序的 URL 开始,然后很快转向模板。
列表 8.18. 更新 urls.py

django.views.generic.create_update包含了你需要使用的两个视图
。create_update中还有其他视图,但只有这两个是你需要的。
现在你正在使用通用视图,视图的来源不同,你不能使用'todo.views'快捷方式。相反,你将直接输入函数
。

是一种更高级的链接到视图的方式,分成多行以便于跟踪。第一行是通常的 URL 正则表达式,第二行是视图函数。从第三行开始是传递给视图的参数字典,你也将它们逐行放置。唯一必需的是model,但你还在覆盖template_name(默认为 todo/todo_form.html)。最后一个参数post_save_ redirect告诉 Django 下一步去哪里。%(id)s在编辑的任何对象的上下文中被解释,所以如果你正在编辑 ID 为 1 的Todo模型,它将评估为/todos/1。
如果你阅读了
并想知道视图是如何知道编辑哪个对象的,这里的?P<object_id>部分就是它的工作方式
。如果你在 URL 中有一个这样的命名匹配,Django 会将其添加到传递给视图的参数字典中,因此你不需要明确指定它。我通常在 URL 中命名所有参数,因为如果不这样做,它们将被作为参数传入,并且可能顺序不正确。
最后, 是通用的 删除 函数。它与更新函数几乎相同,只是在你删除待办事项后,你将无法重新定向回它;因此,你使用 post_delete_redirect 参数跳回一个目录到索引页面。
在你的 URL 中,你需要做的是这些。现在你需要添加两个模板:一个用于更新,另一个用于确认删除。这些模板与之前的索引模板具有相同的 HTML,所以我省略了所有相同的内容。
列表 8.19. 待办事项编辑模板
通用编辑模板自动传递两个变量。第一个是对象 ,这是你正在编辑的对象——在本例中是你的待办事项。
视图处理显示和编辑,所以你不需要对表单的 action 属性做任何事情 ;你的输入将被传递到当前 URL。
你得到的第二个变量是 形式,这是 Django 的 ModelForm 对象之一,我们在本节开头提到过 。它包含与你的模型中定义的字段相匹配的字段,以及一个 label_tag 属性,因此你不需要在模板中重复字段名称。ModelForm 字段将自动输出正确的输入元素:标题为文本,重要性为下拉列表,描述为 textarea 元素。
如果你不想编辑待办事项,或者你已经完成了编辑,你可以使用此链接返回主索引页面 。todos/1 中的 1 不算作目录,所以你想回到当前目录,即 “.”。
让我们看看编辑表单的样子。目前,你需要手动输入待办事项的 URL。http://localhost:8080/todos/1 应该看起来像 图 8.8。
图 8.8. 你的编辑视图
如果你为待办事项输入了新值并点击保存,它们应该会被保存到数据库中。你可能想打开一个包含索引页面的单独窗口,并在保存时刷新,以进行双重检查。
最后但同样重要的是,你需要在完成待办事项后能够删除它。以下表单将允许你这样做。
列表 8.20. 删除模板
强制使用 POST 请求而不是 GET 请求来执行破坏性行为,例如编辑或删除待办事项,是个好主意 ;这样,如果有人意外浏览到该页面,或者 Google 试图索引你的网站,就不会造成任何损害。Django 遵循这种行为——在浏览器地址栏中输入 http://localhost:8000/todos/1/delete 是一个 GET 请求,所以 Django 会提示你通过 POST 确认你的操作。
你可能不想删除待办事项(也许你点击了错误的东西或输入错误),所以你提供了一个返回索引页面的方法 。/todos/1/delete 比 /todos/1 深一级,因为 /1/ 被视为一个目录,所以这个回链会向上跳一个目录。
现在,如果您访问 http://localhost:8000/todos/1/delete,您应该会收到提示以删除待办事项#1。如果您点击删除,您的待办事项应该从系统中删除。
最终润色
您几乎完成了添加、编辑和删除的工作——您需要做的最后一件事是将所有这些整合在一起,并使其易于点击以编辑和删除条目。当您在界面中做这些事情时,您还会改进一些其他事情。
列表 8.21。编辑索引页

您在列表中增加了两列!。第一列是用于删除待办事项的链接,第二列是描述的片段。

描述可能很长,会弄乱您漂亮的页面,因此您可以使用todo.short_description!代替。这是一个便利的方法!,它最多返回描述的第一行中的 80 个字符。
现在,您可以通过点击索引页来添加、编辑和删除待办事项。在开发应用时,润色并不是那么重要,但一旦开始使用,您就会欣赏您为使应用更易用所付出的额外努力。
接下来该做什么?
您的应用功能几乎已经完善,尽管首页的设计看起来像是“程序员 HTML”,可能需要一点修改。您还可以添加更多功能,使您的应用更易于使用或功能更强大。以下是一些想法:
-
根据重要性对待办事项进行颜色编码。
-
包含一些 JavaScript,通过点击表头对列进行排序。
-
允许将待办事项分配给一个组。您需要能够添加和删除组,并希望从待办事项模型创建一个外键链接到特定的组。
-
为待办事项分配可选的截止日期,并按这些日期进行排序。
我们还没有完成 Django。在第十一章[kindle_split_019.html#ch11]中,您将进一步扩展待办事项应用,允许您的朋友登录并创建他们自己的待办事项列表。我们还将探讨 Django 的一些更高级功能,例如内置单元测试,以及查看一些高级数据库操作。
摘要
现在,您可以使用 Django 在 Python 中创建自己的 Web 应用。我们已经在本章中涵盖了所有基础知识,包括为您的网站设计 URL,设置视图、模型和模板。
在编写 Web 应用时,我们也触及了几个设计问题,例如将设计和数据模型分开,限制破坏性编辑到 POST 请求,以及一些简单的设计策略。这些建议基于常见实践和经验;以这种方式“顺应潮流”可以为您节省大量时间和精力,避免与开发环境斗争。
我们将在第十一章回到待办事项应用,但在此期间,请休息一下并尝试编写一个桌面应用程序。在这种情况下,您将使用一个名为 Pyglet 的图形库来创建自己的街机游戏。
第九章. 使用 Pyglet 进行游戏
本章内容
-
在屏幕上显示图像和文本
-
使用事件循环和计时器
-
游戏设计和让你的游戏有趣
在本章中,你将使用名为 Pyglet 的库编写自己的街机游戏。Pyglet 自称为“跨平台的 Python 窗口和多媒体库”,但你将用它来实现其真正的目的——编写游戏!
如果你熟悉各种街机游戏,你的游戏将类似于 Spacewar!、Asteroids 和 Space Invaders 的结合——它将有一个宇宙飞船、要射击的邪恶外星人,以及一个要撞到的星球。为了让游戏更有趣,你将给星球一些重力,这样它就会逐渐吸引飞船。
但首先,你需要将 Pyglet 安装并配置在你的电脑上。
安装 Pyglet
你需要做的第一件事是下载并安装 Pyglet。Windows 安装程序和源代码可以从 www.pyglet.org/download.html 获取,Pyglet 作为软件包在几个 Linux 发行版中可用。在 Windows 下安装 Pyglet 很简单:下载安装程序,然后运行它。Mac 用户可以下载带有安装程序的 .dmg 图像,大多数 Linux 发行版都有软件包。下一张图显示了 Windows 安装程序正在运行。
注意
Pyglet 在底层使用 OpenGL,所以你需要一块支持 OpenGL 的显卡。这通常不是问题,除非你正在运行一台旧电脑——过去五年左右发布的几乎所有显卡都自动支持 OpenGL。
如果上述选项都不适合你,你可以始终下载源代码包并运行 python setup.py install,尽管如果你选择这条路,你还需要单独安装 AVbin。
图 9.1. 安装 Pyglet

让我们从简单的 Pyglet 程序开始,逐行分析:
import pyglet
window = pyglet.window.Window(fullscreen=True)
pyglet.app.run()
Pyglet 的所有子模块都存储在 pyglet 模块中。例如,你可以使用 pyglet.window 访问窗口模块。这可以节省你在程序顶部导入多个模块,并使你的代码更容易阅读。
Pyglet 的 Window 对象处理所有屏幕初始化和渲染。你通常需要在每个 Pyglet 应用程序中有一个。你正在将 fullscreen=True 作为参数传递,这样窗口就会占据整个屏幕。
Pyglet 是一个框架,所以在你设置好一切之后,你需要调用其主应用程序循环。
如果你输入这个程序并运行它,你应该会看到一个类似于 图 9.2 的屏幕。
图 9.2. 一个黑色屏幕


没错——一个大大的黑色屏幕。并不怎么引人注目,但这是你的黑色屏幕:你将在这张空白画布上创作你的杰作。作为额外的奖励,你知道 Pyglet 正在正常工作。要退出 Pyglet,请按 Escape 键。
接下来,我们将找出如何让那个黑色屏幕更加引人注目。
第一步
让我们开始吧!你首先想做的事情是在屏幕上显示一个图像。因为你正在编写一个太空游戏,让我们做一个大行星。我使用了一个从 NASA 网站下载的火星图像 www.nasa.gov/multimedia/imagegallery/,但如果你有艺术感,你也可以自己创建。接下来的列表将显示你的行星图像。
列表 9.1. 在屏幕上绘制


在你显示图像之前,你需要告诉 Pyglet 它们在哪里。为此,你将图像文件夹的路径追加到 Pyglet 的资源路径
,并要求它重新索引其资源。你还需要手动创建文件夹,并将你的行星图像保存在其中。

一旦你有了图像源,你所需要做的就是调用 pyglet.resource.image 函数,它将从你的资源目录中读取图像
。
默认情况下,图像有一个在左下角的锚点;你更希望它在中心。我创建了一个函数,可以为你做到这一点。因为你希望 x 和 y 坐标是整数,Python 的整数除法运算符 (//) 确保结果是整数。
Pyglet 能够直接在屏幕上绘制图像,但更快、更干净的方法是使用 Sprite 类
。精灵跟踪其位置和图像,并拥有自己的优化绘制程序,这使得你的程序运行更快。你将创建一个你行星的实例,并将其直接放置在屏幕中央。需要注意的是,你调用 super(Planet, self) 来获取你的精灵的父类——这样你就不必担心手动更新它。
提示
游戏是一个基于类的设计通常很有意义的地方,因为通常有许多实体具有相似的行为。
一旦你创建了精灵,你需要告诉 Pyglet 在每一帧绘制它。为此,你为窗口创建一个 on_draw 事件处理器
(我们将在下一节中更详细地介绍事件处理器)。你以后会做更多的事情,但现在你先清除屏幕并绘制行星。
图 9.3. 你的行星。非常适合你的宇宙飞船相撞!(图片由 NASA/JPL/Malin 空间科学系统提供)

你应该在屏幕中央看到一个漂亮的行星。
行星将成为你的宇宙飞船的障碍,但首先你需要一艘飞船。让我们接下来做这部分。在这个过程中,我们将介绍在编写游戏或任何基于事件的程序时的一些重要概念。

宇宙飞船驾驶 101
你的飞船遵循与行星几乎相同的流程,只有一个主要区别:它会在玩家按下按键时在屏幕上移动。如果你曾经玩过 Asteroids,你会熟悉你将使用的控制方法。上箭头会启动引擎,左右键会转向飞船。如果你想减速或后退,你需要完全转向飞船并朝相反方向启动引擎。
以下列表显示了你的 Ship 类的开始。你将通过本节的其余部分添加功能。我已经将这个类包含在与行星相同的文件中,但你可以自由地创建一个新文件并导入它。
列表 9.2. Ship 类

首先,你以与行星相同的方式加载你的飞船
的图像。你的 Ship 类看起来与 Planet 类似,但你有一些额外的信息
:.dx 和 .dy 是飞船在 x 和 y 方向上的速度,而 .rotation 是你向左或向右转的程度。你还加入了 .thrust 和 .rot_spd 来确定飞船应该加速和转向的速度。这些数字越高,飞船的速度就越快。
现在你可以创建你的飞船实例
。你在这里输入飞船的速度作为 dx 和 dy,但直到你在下一节开始更新飞船的位置之前,它不会有任何效果。
一旦你有了飞船,你可以在 on_draw 事件处理器中添加 ship.draw(),你的飞船就会出现在屏幕上
。
现在你可以看到你的飞船将开始的位置,以及它的外观。

图 9.4. 你的宇宙飞船

到目前为止,它与你在绘制的行星没有不同,但现在你已经设置了精灵,你可以开始让它做一些事情。
让事情发生
在大多数游戏中,你控制某个方面——比如主要角色——并且可以输入指令来告诉他们下一步该做什么。按左箭头向左移动;按右箭头向右移动。在本节中,你将看到游戏是如何实现这一点的。
Pyglet 使用基于事件的编程模型,这也是大多数交互式程序(如游戏和图形用户界面)的编写方式。你不需要在程序的特定部分检查或等待输入,而是注册函数,以便在发生有趣的事情时调用。Pyglet 将这些函数称为 事件处理器。如果你习惯于标准的命令式设计(“这样做,然后这样做...”),基于事件的架构可能会显得有些奇怪,但它是一种编写某些类型程序更干净的方法。接下来的列表介绍了两个事件处理器——一个用于按键按下时,另一个用于按键释放时。
列表 9.3. 处理事件

为了响应用户按键,Pyglet 定义了两个事件处理器,on_key_press 和 on_key_release
。它们与 on_draw 函数的定义方式类似,但它们有两个参数:按下的键,以及任何额外按下的键,例如 Shift 或 Ctrl。

符号参数是一个整数,但 Pyglet 定义了大量你可以使用的键,无需担心如何表示不可打印的键,例如左箭头键或 Esc 键
。要使用它们,请从 pyglet.window 导入 key。
如果按下箭头键,你需要对游戏状态进行一些更改。在这种情况下,它们直接对应于飞船,因此你将对飞船的状态进行更改,并让飞船在其 update 方法中处理这些更改
。
提示
事件是一个强大的技术,可以使你的程序更简单、更容易编写。另一种方法是编写一个大的循环,检查游戏中的所有内容。它必须尽可能快地运行,否则你的游戏将会很慢,无法玩。
一旦你完成了这些,按下箭头键将触发一个 on_key_press 事件并更新飞船的状态——但你不会在屏幕上看到任何变化。那是因为你没有告诉飞船如何对其状态的变化做出响应。为此,你需要编写一个 update 方法来根据其状态更改飞船的旋转。
列表 9.4. 更新飞船

当飞船首次创建时,它不会向左或向右转动,也不会启动引擎。你在这里设置飞船的状态
,这样你的更新函数就不会在以后抛出异常。
按照惯例,大多数 Pyglet 类将有一个在游戏引擎的每个“tick”上被调用的 update 方法
。这是你的精灵改变位置、在游戏中创建新对象以及更新其内部状态的地方。一个 update 方法接受一个参数,dt,它告诉你自上次调用更新以来过去了多少时间。
你现在开始得很简单,所以你现在只是左右旋转飞船
。如果你在转动,那么你通过将旋转速度乘以 dt 来更新 .rotation 属性(一个 Pyglet 内置的旋转精灵的属性)。
之后,你将拥有具有 update 方法的其他对象,因此将所有方法调用收集在一个地方是一个好主意
。

图 9.5. 转动飞船

最后,你将 Pyglet 的内置调度器设置为每秒调用你的主要更新方法 60 次
。这是 Pyglet 运行你的游戏的最大速度。如果它更慢,那么你将得到不同的 dt 值,但你的游戏仍然会运行。
现在你的反馈循环已经完成,你可以看到你所有辛勤工作的结果。如果你运行程序,你应该能够通过按左右箭头键来左右旋转你的飞船。
下一步是让船只移动。不过,为了正确地做到这一点,你需要了解如何指定方向和距离。
回到学校:牛顿第一定律(以及向量)
为了使你的船只保持一致的运动,你需要应用一点理论。你可能记得一些从学校、数学或物理课程中学到的内容。如果不记得,不要担心——我们将一步一步地进行。首先要知道的是,x 代表从左到右的值,y 代表从上到下的值,如图所示。
牛顿第一定律
如果你回想起你的物理课程,你可能会记得牛顿的第一定律。简而言之,它声明,“一个运动的物体将继续其运动,除非受到外部力的作用。”这意味着你的船只应该沿直线运动,除非你启动引擎。你已经有一个速度——这是你的 Ship 类的 .dx 和 .dy 属性。
图 9.6. x 和 y 坐标。x 代表从左到右的值,y 代表从上到下的值。

向量
你还需要的是一种将船只的角度和加速度转换为可以添加到船只 x 和 y 速度中的值的方法。每当你的船只引擎启动时,你需要像这样分解其角度,以计算出对 x 和 y 方向速度的影响。下一图中的方向意味着当船只引擎启动时,你需要将 2 添加到你的 x 速度,将 3 添加到你的 y 速度。
你需要几个数学模块来在 Python 中完成这项工作,但原理与 图 9.7 并无不同:找出加速度的 x 和 y 部分,并将这些添加到你的 x 和 y 速度中。在每次更新时,将你的速度添加到你的位置。

图 9.7. 船只的角度可以分解为 x 和 y 部分。

列表 9.5. 移动船只


你需要使用 Python 的 math 模块存储所有所需的三角函数,因此你首先需要导入它
。

事先稍微考虑一下,你可能还希望能够处理船只移动到屏幕边缘的情况。你将选择简单的方法,将游戏上下左右都包裹起来
。wrap 是一个执行此操作的函数——给定值和您希望它约束到的数量。
接下来,将你的角度分解为 x 和 y 部分
。注意,如果角度指向左或下,这些值可能是负数。此外,Pyglet 和 .math 模块使用不同的角度表示(度数与弧度),因此你需要一个函数将 Pyglet 的版本转换为 math 模块可以使用的版本。你还需要翻转你的旋转,以在 y 方向获得正确的值。
图 9.8. 现在,你可以驾驶你的宇宙飞船四处移动了。哔哔!哔哔!


一旦你有了两个分量,剩下的就相对简单了。你将每个部分乘以飞船的加速度和自上次更新以来经过的时间,然后将每个 1 加到你的速度上 ![five.jpg]。
最后一步是更新你在屏幕上的位置 ![six.jpg]。你还需要检查确保你不会因为基于窗口的高度和宽度包裹你的 x 和 y 位置而超出屏幕边缘。
最后,如果你的飞船在没有任何视觉反馈的情况下飞行,看起来有点奇怪,所以我创建了一个额外的图像,其中有一些火焰从后面喷出。当飞船的引擎开启时,你切换到这个图像 ![three.jpg]。
现在,你可以驾驶你的飞船在屏幕上移动,加速,然后转弯减速。Wheee!这很有趣,但最终没有太多可做的事情,机制也容易理解。你想要的可能是更复杂的东西,这样你就有更多机会与游戏进行不同类型的互动。
重力
你将通过让行星具有重力来增加游戏的功能,这样它就会吸引飞船。如果飞船与行星相撞,那么 BOOM!飞船就没了!在抵御外星人的同时试图避开行星,这将增加足够的难度,以保持玩家忙碌和娱乐。
计算重力
你如何添加这个功能呢?显然,你应该在 Planet 类中放置它。这样做是有意义的,因为行星是影响飞船的,如果你想让其他任何东西被行星的引力吸引,这不会太难。本质上,你是在给飞船添加另一个力,就像你启动引擎时做的那样。
图 9.9 展示了问题的样子。长线是从你的飞船到行星的矢量。你希望找到这个矢量,将其转换为力矢量,然后将其分解为 x 和 y,这样你就可以轻松地将其添加到飞船的速度中。我们先处理容易的部分:分解力矢量。
图 9.9. 重力对飞船施加力。
![09fig09.jpg]
列表 9.6. 行星更新
![9list06.jpg]
首先,你需要找出行星将对飞船施加多少重力。我们现在暂时跳过这部分;你需要知道的是,在一分钟内,你将创建一个方法来告诉你力的强度和方向 ![one.jpg]。除此之外,这与你在引擎启动时更新飞船的情况相同。
不要忘记在主 update 函数中包含 update 方法 ![two.jpg]。
现在你有一个很好的、定义明确的问题要解决:找到飞船的距离和角度。这是与之前你解决的问题相反的问题。当时,你有一个角度和距离,想要 x 和 y 部分;现在你有 x 和 y 部分,想要知道角度和距离。
![f0303-01.jpg]
列表 9.7. 计算重力
![09list07_alt.jpg]
首先,设置你的行星质量
。你的行星越重,它对船只的拉力就越大。这是你可以在游戏中调整以使其更容易或更难的一个元素。

你的方法的核心是找出船只距离有多远,以及它在哪个方向
。基于这些信息,你可以计算出你需要的一切。
接下来,你找出目标(船只)在 x 和 y 方向上的距离
。如果船只位于左侧或下方,距离可能会变成负数——这是正常的。
现在你已经找到了你需要的第一部分,即船只的距离
。这是通过勾股定理确定的:平方两个较小的边,然后取平方根。
角度计算稍微复杂一些
。在水平和垂直距离的情况下,你可以使用 math.acos 或 math.asin 来计算角度,但需要考虑完整的 360 度范围。math.acos 只对圆的第一半部分有效,所以如果角度在错误的一半,你需要通过从 2math.pi* 减去它来反射角度。图 9.10 更详细地展示了这一点:两个角度不同,尽管 x 距离和直接距离相同。
图 9.10. 两个不同的角度,相同的 x 位置和距离

一旦你有了距离和角度,你就可以返回这些向量
。我选择(距离,角度)作为向量的表示方式,以避免以后发生意外的混淆。
注意
如果这些数学看起来有点复杂,不要过于担心。你有一些易于使用的计算向量和力的方法,你可以在你的下一个游戏中重复使用。
现在你已经知道了船只的距离和方向,计算由于重力产生的力就很容易
。它与行星的质量成正比,与距离的平方成反比。船只越近,你施加的力就越大。图 9.11 展示了船只移动的时间流逝。
图 9.11. 你的船在行星轨道上


当你运行程序时,你应该看到船只受到重力的影响!它不会直线移动,而是会受到行星施加的力,并沿着优雅的曲线移动。如果你小心操作,甚至可以将你的船只送入行星轨道。
注意那颗行星!
对于碰撞检测,我们继续使用船只、行星和外星人的圆形。像图 9.12 中的圆形使代码更简单、更直接;但在以简单换取准确性的过程中,你可能会注意到一些本应只是擦肩而过的碰撞。使用 Pyglet 通过比较图像本身的重叠,可以得到像素级的精确度,但这超出了本章的范围。
图 9.12. 行星和船只的碰撞圆

实际上,你不需要绘制圆形——你可以比较飞船和行星之间的距离,然后将其与行星和飞船的半径进行比较。
列表 9.8. 碰撞到行星


你需要在你的对象上设置一些属性
。一个是告诉游戏飞船是否存活,其他的是行星和飞船的半径。为了简化生活,你会从它们图像的大小计算飞船和行星的半径。如果你稍后更改图像,你不需要更新对象的半径。

使用圆形来检测碰撞,你所需要做的就是比较飞船和行星之间的距离,以及它们半径的总和
。如果距离更短,那么圆形相交,你就发生了碰撞。
一旦你的宇宙飞船坠毁
,你将飞船标记为已损毁并重置玩家的位置。
船的 .reset() 方法将飞船放回起点
并将其速度设置为合理的值。你还在设置一个“生命计时器”,它决定了飞船重新启动的时间,给玩家几秒钟的时间来思考发生了什么。你可以使用这个计时器来设置飞船的起始位置,这样你就不需要在创建类时输入位置。
为了延迟飞船的返回,你检查在 .reset() 方法中设置的 life_timer 属性
。如果你已死且计时器大于零,那么你还有一些时间。如果它小于 0,那么你可以将飞船标记为存活,并再次重置其位置(因为重力仍然影响它),然后你就可以恢复正常。
你需要做的最后一件事是确保飞船在已损毁时不会被绘制
。一个简单的 if 语句就可以解决这个问题。
现在随着你的游戏开始成形,你可以看到游戏的一般形式。它有一定的状态,实际上是对行星和飞船等许多事物的模拟,你可以以某种方式影响这个模拟。经过一些思考、一点运气和一些实验,你的模拟将具有一些有趣的方面。
接下来,让我们给你的游戏增加一些刺激。
枪,枪,枪!
没有外星人射击的空间游戏是什么?即使是太空贸易游戏也有某种枪械,所以如果你没有,你会显得有些奇怪。考虑到你已经对角度和计时器做了很多工作,添加枪械很容易:让子弹以与飞船相同的速度和角度飞行,并以类似的方式更新它。你还需要跟踪子弹是否撞到任何东西,并在一定时间后将其从游戏中移除。
列表 9.9. 射击


使用 Pyglet 的 KeyStateHandler 类来管理按键更简单 ![one.jpg]。这个类跟踪哪些键被按下,并以字典语法使它们可用,所以你不需要在 Ship 类上额外的事件处理程序和状态。如果你按下左箭头键,那么 self[key.LEFT] 将被设置为 True。唯一需要记住的棘手部分是,飞船实例现在是一个键处理程序,所以你需要做 window .push_handlers(ship) 以让 Pyglet 知道要传递事件给它。

如果你只允许玩家在按下空格键时射击,他们每帧就会得到一颗子弹,或者每秒 60 发子弹!即使你的电脑足够快可以处理屏幕上的数百颗子弹,这也让游戏变得有点简单:这意味着你可以用子弹填满屏幕,直到外星无处可藏。你可以通过在飞船发射子弹时设置计时器来限制射击次数 ![two.jpg]。每次更新,你都会从计时器中减去 dt,直到它为 0,玩家可以再次射击。
射击很简单——你创建一个朝正确方向飞行的 Bullet 类实例 ![four.jpg]。你给子弹设置速度为 500,加上飞船的速度(否则当你快速移动或侧向射击时会出现奇怪的效果)。你将子弹实例存储在 ship.bullets 中,因为如果你没有在某个地方保留对它们的引用,Python 的垃圾回收器会删除它们,你就会 wonder 为什么你的子弹没有出现在屏幕上。
![five.jpg] 是你每次射击时使用的类。子弹更新很简单,因为它们不受重力影响,沿直线移动。
图 9.13。你的飞船在射击——准备迎接外星舰队


你不希望子弹永远悬挂在那里,所以它们有自己的计时器。一旦它们存在了 5 秒钟,你就从 ship.bullets 中删除它们,让 Python 处理剩下的部分 ![three.jpg]。你也会检查与行星的碰撞,就像你为飞船做的那样。
因为可能有这么多子弹,所以使用 Pyglet 的 Batch 类是有意义的,如果你有很多精灵要绘制,它会大大加快精灵渲染的速度。要使用 bullets 批次,你需要在创建子弹精灵时传递它,然后调用 bullets.draw() 来一次性绘制所有子弹 ![six.jpg]。你应该会看到类似下一张图的样子。
现在你可以飞遍银河系,做好事并摧毁外星渣滓。Hang on——你还没有可以射击的外星渣滓。让我们在下一部分修复这个问题。
恶劣的外星人
没有外星人来尝试,子弹有什么用?在本节中,你将添加一个外星飞船,它的唯一目的是摧毁邪恶的地球入侵者。为了简化问题,你假设外星人拥有不受重力影响的高级技术,并且他们可以随意进入和离开地球的大气层。你将稍微偷懒,不会担心所有那些向量和与地球的碰撞——只需关注外星人是否击中玩家。下一个列表提供了你将外星人放入游戏所需的所有代码。
列表 9.10. 一个随机的外星人


在这个代码段中,我做了这样一件事:将向量函数从类中提取出来,使它们更加独立
。我这里保留了它们,但最终,你可能希望将它们放入自己的模块或找到一个可以重用的向量库。

最终,外星人类与飞船类相似,除了它的update方法
,所以在这个部分不应该有任何大的惊喜。你拥有所有相同的概念——速度、加速度、x 和 y 位置的环绕、死亡以及倒计时后的重生。
你的外星人有一个简单的 AI——时不时地,它会向一个随机方向加速
。加速的频率和在init中设置的参数使得外星人的方向变化足够多,以至于射击它可以构成一定的挑战。
最后,我在测试游戏时注意到,外星人可以加速到荒谬的速度
,这使得射击变得困难。为了阻止它这样做,你需要检查 x 和 y 速度是否在外星人的最大速度范围内,如果不是,则降低它们。
注意
在这个游戏中,外星人是你绝对想要尝试的一个元素。粗略的规则是,外星人应该足够容易被玩家射击,但同时也足够困难,以构成挑战。如果没有达到适当的平衡,你的游戏将不会有趣。
你需要做的最后一件事是让外星人与其他屏幕上的对象交互:它应该被子弹杀死,当它撞到飞船时应该杀死玩家。你也许还想为玩家添加某种奖励系统,所以你会添加分数。每当玩家做错事,比如撞到星球或外星人,你会减去 100 分。如果玩家射击外星人,那么你会加上 100 分。
列表 9.11. 使外星人交互


你希望外星人成为玩家需要避免的额外危险,所以你检查飞船和外星人之间的距离
——就像你检查飞船和地球一样。是否希望外星人撞到玩家时消失,取决于你。
分数
是你让玩家知道他们根据游戏规则做了正确的事情的方式,所以你给他们射击外星人 100 分,如果他们撞到外星人或行星则减去 100 分。
子弹应该对外星人产生效果
。所以,对于每一颗子弹,你检查它到外星人的距离。如果它在外星人的半径内,那么你就击中了外星人!重置外星人的方式基本上与玩家相同——只是你在外星人存活时绘制它,并在外星人死亡和再次出现之间有一个短暂的延迟。
玩家需要在屏幕上看到他们的分数
,所以你在屏幕底部 10 像素处添加一个标签类。设置颜色看起来有点奇怪,因为你可能期望红色、绿色和蓝色三个数字。第四个是 alpha 值——255 是不透明的,0 是完全透明的——这对于淡入淡出文本很有用。
你应该能看到下一个图示,包括外星渣滓!
现在你有一个完整的太空外星人射击游戏,目标是尽可能多地得分,但不要撞到行星(我确信你能想到一个更吸引人的标题)。你可以把游戏发送给你的朋友,甚至可以互相竞争。

图 9.14. 外星渣滓,去死吧!

接下来该做什么?
你可以对游戏进行许多改进或更改,无论是为了完善现有的内容,扩展游戏玩法,还是将游戏转变为完全不同的东西。这里有一些想法。
扩展游戏玩法
这类游戏中通常会有许多其他元素。一个好主意可能是选择你最喜欢的太空射击游戏,看看你能添加多少它的功能。你可能想让外星人也能射击回来,调整它的 AI 让它变得更恶劣,或者添加更多外星人。随着外星波的每次到来添加额外的难度级别,以及限制玩家的生命值,这将是一个额外的功能。音效也有助于营造氛围。
改变游戏玩法
另一个选择是将游戏扩展到完全不同的方向——毕竟,也许你不是太空外星人破坏的大粉丝。如果你添加第二个玩家,并使你的射击受到重力的影响,你将拥有非常接近 1962 年为 PDP-1 编写的原始太空射击游戏 Spacewar!。
如果射击并不是你的强项,你可以添加额外的行星,将游戏转变为太空贸易游戏或 3D 版本的月球着陆器。有限的燃料和不同行星上的轻重不同的重力将增加游戏的挑战性,除了交易之外。
Pyglet 附带了一些示例,你可以使用这些示例来添加文本输入和其他功能。
重构
现在你已经理解了这个程序,有一些地方代码可以进行改进。例如,在对象及其位置更新方面存在相当多的重复——使它们从子类派生可以使你的代码更清晰且更容易扩展。
你也可以在你的对象内部使用外部矢量类,这样你就不必查看(或调试)所有那些几何代码。在开始之前了解矢量库背后的内容会有所帮助。
单元测试将有助于确保你在进行这些更改时程序能够正常工作。测试视觉和游戏玩法方面可能很困难,但你仍然可以通过手动放置飞船、子弹和外星人对象并检查它们是否重叠来检查碰撞是否被正确检测。其他游戏数据,如力和速度,也可以以相同的方式进行测试。
获取反馈
另一点需要记住的是,你正在编写一个游戏。你可以编写出最漂亮的代码,包含各种特性,但如果你的游戏不好玩,所有这些都将毫无意义。设计和发展游戏的一个好方法就是创建一个包含你认为会有趣元素的最小版本,并在少数人身上测试它,根据需要调整各个部分。

摘要
在本章中,你学习了如何编写自己的街机游戏。你使用了 Pyglet 图形类在屏幕上显示图像并移动它们。为了使你的对象移动得更加逼真,你使用了一些几何和物理建模来更新它们在屏幕上的位置。
你添加了几种类型的对象,并学习了如何使它们相互交互——你的飞船可以撞上行星并发射子弹;然后,最终,你添加了一个可以撞上飞船并被子弹射中的外星人。
在这个过程中,你学习了其他游戏元素,例如碰撞检测和安排在一段时间内执行的动作。
最后,我们讨论了一些游戏设计方面的内容:你的游戏需要有趣,并包含一些熟悉元素以吸引人们。获取他人的反馈很重要:对你来说有趣的东西可能对其他人来说并不有趣。
在下一章中,你将学习更多关于 Django 的知识,以及你如何可以让其他人使用的你编写的网络应用程序在互联网上可用。
第十章. Twisted 网络编程
本章涵盖
-
用 Python 编写网络程序
-
设计多人游戏(包括在朋友身上测试它们)
-
编写异步程序时可能遇到的问题
在本章中,我们将回顾你在 第六章 中编写的冒险游戏,并扩展它,以便你可以通过互联网登录并与其他人一起玩。通常这些游戏被称为 MUD,代表多人地下城。根据创建它们的人,MUD 可以从幻想的砍杀到科幻,玩家可以竞争或合作以获得宝藏、分数或名声。
为了让你快速入门,我们将使用一个名为 Twisted 的框架,它包含了许多不同网络协议和服务器的工作库。
安装 Twisted
第一步是安装 Twisted 并运行一个测试应用程序。Twisted 提供了 Windows 和 Macintosh 的安装程序,可以从 Twisted 主页 twistedmatrix.com/ 获取。某些版本的 MacOS 已经预装了 Twisted,在这种情况下,使用该版本会更简单。如果你使用 Linux,应该可以通过你的包管理器获取到相应的软件包。
当编译东西时,安装程序会弹出一个窗口,但一旦你在 图 10.1 中看到右侧的窗口,Twisted 就已安装!

图 10.1. 在 Windows 上安装 Twisted

你还需要一个 Telnet 应用程序。大多数操作系统都内置了一个,你也可以下载许多免费的版本。我通常使用一个名为 PuTTY 的 SSH 终端程序,它适用于 Windows。
你的第一个应用程序
你将从编写一个简单的聊天服务器开始。想法是人们可以通过一个名为 Telnet 的程序登录并互相发送消息。这比“Hello World!”要复杂一些,但你可以在本章后面扩展这个程序并在游戏中使用它。打开一个新文件,并将其保存为类似 chat_server.py 的名称。
让我们从应用程序的第一部分开始:聊天服务器的协议。在 Twisted 术语中,协议 指的是处理低级别细节的部分:打开连接、接收数据以及完成时关闭连接。你可以在 Twisted 中通过继承其现有的网络类来实现这一点。下面的列表显示了一个简单的聊天客户端,你将在本章后面的部分编写游戏时在此基础上构建。
列表 10.1. 简单聊天服务器协议


对于你的聊天服务器,你将使用 Twisted 的 StatefulTelnetProtocol
。它负责处理低级别的行解析代码,这意味着你可以在单个行的级别编写代码,而无需担心是否有一行完整的代码。
你通过覆盖内置的 connectionMade 方法 ![two.jpg]来自定义协议。这将对于每个连接在第一次建立时被调用。
你在这里负责一些家务管理——存储客户的 IP 地址,并通知所有已经连接的人新的连接 ![three.jpg]。你还会存储新的连接,以便将来可以发送广播消息。
Telnet 协议类提供了 lineReceived 方法 ![four.jpg],每当准备好供你使用的一整行时(即,当另一端的人按下回车键时)就会被调用。在你的聊天服务器中,你需要做的就是将输入的内容发送给所有连接到服务器的其他人。唯一需要小心的是移除任何换行符;否则,当你打印时,你的行会相互覆盖。
如果由于某种原因连接丢失——无论是客户端断开连接,还是你将其断开——将调用 connectionLost 方法,以便你可以整理一下 ![five.jpg]。在这种情况下,你实际上不需要做太多,只需将客户端从连接列表中移除,这样你就不会向他们发送更多消息。
为了使代码更容易理解,我创建了 msg_all 和 msg_me 方法,分别向所有人发送消息和仅向你发送消息 ![six.jpg]。msg_all 方法接受一个 sender 属性,你可以用它来告知人们消息的来源。
注意
Factory 是一个编程术语,指的是为你创建一个类的东西。这是隐藏库复杂性的另一种方式,让使用它的程序员不必担心。
这样就处理了你的程序应该如何表现的问题。现在,你是如何将其链接到 Twisted 的呢?你使用 Twisted 所称为的 Factory,它负责处理连接并为每个连接创建新的 ChatProtocol 实例。你可以将 Factory 想象成一个交换机操作员:当人们连接到你的服务器时,Factory 创建新的协议并将它们连接起来,类似于 图 10.2。
图 10.2. 工厂创建协议
![10fig02.jpg]
那么在 Twisted 中如何做到这一点呢?很简单!添加一个工厂类,如下一列表所示。
列表 10.2. 连接到你的协议
![10list02_alt.jpg]
![f0324-01.jpg]
Factory 是面向对象术语,指的是创建另一个类的实例的东西 ![one.jpg]。在这种情况下,它将创建 ChatProtocol 的实例。
ChatFactory 是存储所有 ChatProtocol 实例之间共享数据的自然位置。send-ToAll 方法负责向客户端列表中指定的每个客户端发送消息 ![two.jpg]。正如你在 代码列表 10.1 中看到的,客户端协议负责在它们连接或断开时更新此列表。
最后一步是让 Twisted 了解你的新协议和 Factory。你通过创建一个ChatFactory实例,使用listenTCP方法将其绑定到特定的端口,然后通过调用其主循环reactor.run()来启动 Twisted 来完成此操作
。在这里,你使用 4242 作为监听端口——你使用哪个端口并不重要,只要它大于 1024,以免干扰现有的网络应用程序。
如果你保存程序并运行它,你应该会看到消息“聊天服务器正在运行!”如果你通过 Telnet 在端口 4242(通常通过输入telnet localhost 4242)连接到你的计算机,那么你应该会看到类似图 10.3 的内容。
图 10.3. 你的聊天服务器正在运行。


虽然看起来不多,但你已经让 MUD 服务器的基本功能开始运行了。如果你想进一步探索聊天服务器,源代码中包含了一个功能更完整的版本,可在manning.com/HelloPython/找到。该版本添加了更改你的名字和查看谁还连接的命令,以及限制一些常见的错误行为并允许你移除行为不良的人。
MUD 的初步步骤
现在,你已准备好将你的冒险游戏连接到网络。你将基于聊天服务器,但不是仅仅向所有连接的人广播输入的内容,而是直接将其输入到冒险游戏中。在编程时,这是一种常见的方法——找到两个程序(或函数或库)分别完成你需要的一部分,然后将它们“粘合”在一起。
基本上,你将同时有多个玩家登录,他们都在尝试同时执行命令(例如“拿剑”)。这可能会给服务器带来问题,因为你将实时 Twisted 代码与你的冒险游戏的一步一步操作混合在一起。你将通过排队玩家命令并每秒更新一次游戏状态来解决大部分问题。
让我们开始吧。将你的冒险代码从第六章复制到一个新文件夹中,连同你刚刚创建的聊天服务器代码。你可能还想将 chat_server.py 重命名为类似 mud_server.py 的东西,以帮助保持事物清晰,并按下一列表中的方式重命名你的类和变量。
列表 10.3. 更新你的聊天协议

第一步是将Game和Player类导入到你的代码中
。我还更改了协议的名称,使其明显表明你正在尝试编写什么。

接下来,当有人首次连接到你的 MUD 时,你给出一个友好、亲切的起始信息
。
现在,您将开始进行真正的开发工作。但事实上并不难。我假设游戏将以某种方式跟踪其玩家,并在游戏玩家的列表中添加了一个新的玩家对象 。为了使您能够在游戏中与玩家交谈,我还将协议添加到玩家中。您将在下一分钟看到它是如何工作的。
您仍然需要处理玩家从服务器断开连接的情况 。但,再次强调,这是直截了当的:从游戏玩家的列表中移除他们,并删除他们。
一旦玩家连接,他们就会想要输入命令,比如“向北走”和“攻击兽人” 。首先,您需要对收到的输入进行清理(在测试中,我发现不同的 Telnet 程序可以发送不同的奇怪字符)。当它被缩减到只有可打印字符时,您假设玩家有一个等待执行的命令列表,并将这个命令推到最后。
您的协议已经完成,但工厂和其他部分呢?结果是,您不需要对工厂做太多——只需更改几行。
列表 10.4. 更新您的聊天工厂
您不需要做太多来更新您的工厂 ——更改其协议并重命名它。
您还需要一个 Game 对象,因此在这里创建它 。不过,您不想使用旧的 run 方法,因为它仍然以旧的方式处理事情。
设计要求您每秒运行一次游戏更新。因为您正在使用 Twisted 的事件循环(即 reactor.run() 部分),您需要使用 Twisted 的 task.LoopingCall 来调用游戏的更新方法 ,run_one_tick,您也将很快创建它。
至此,您可能需要做的网络代码就这些了。您对游戏代码的工作方式做了一些假设,但通常这比在 Game 和 Mud-Protocol 类之间跳来跳去并试图将其全部组合起来要容易。现在,您的协议已经编写完成,您还需要让 Game 和 Player 一起参与。
列表 10.5. 将您的游戏代码修改为与新接口兼容
冒险游戏的单人版本只有一个玩家,但您可能会有很多玩家,所以您将其改为列表 。您还为起始洞穴取了一个合理的名字。
是您从代码的网络部分调用的主循环。您应该能够从名称中了解它在做什么——为每个 Player 对象(包括怪物)获取输入,运行更新,然后将结果发送回去。
您已经有了获取输入和处理命令的方法,但您需要某种东西来发送每个玩家的行动结果 。为此,您将做出另一个假设:每个 Player 对象都知道如何将结果发送回玩家。
现在,你只剩下两个假设需要填写,它们都在 Player 类中。第一个是 Player 将会有一个待处理命令列表,第二个是它将有一种方式将任何命令或事件的结果发送回玩家。你需要做的另一件事是确保 Player 类从待处理命令列表中读取,而不是使用 raw_input。
列表 10.6. 修改 Player 代码



你首先创建你的待处理命令列表和需要发送回玩家的结果
。它们只是列表,当它们在使用时,将包含字符串列表。
你不能再使用 raw_input 了,所以你需要从 self.input_list 中读取你的下一个命令
。pop 会为你移除命令,这样你就不必担心稍后从列表中移除它。在空列表上调用 pop 会引发异常,所以你需要检查这种情况,并假设如果没有内容则命令为空。
要发送玩家行动的结果
,你使用在 mudserver.py 中设置的 self.connection 对象。请注意,即使玩家没有进行任何操作,其他玩家和怪物却在行动,所以你有两个独立的部分:一个用于你行动的结果,另一个用于事件。
在游戏的老版本中,当玩家死亡时,游戏结束。现在情况不再是这样,所以你需要优雅地处理玩家死亡的情况
。为此,你让玩家放下他们携带的任何物品,发送一条消息,并断开连接。如果你扩展你的游戏,你可能想让玩家保留他们的物品。或者,如果你觉得残忍,你可以允许其他玩家“从安东尼那里得到剑”。
怪物不会通过网络连接,也没有 self.connection 对象,所以 Player 类的默认 send_results 不会起作用。它们不需要知道它们行动的结果,所以你需要模拟它们的 send_results 版本并立即返回
。
之前的冒险游戏通过查看玩家的名字来判断是否攻击他们。现在你有多个玩家,他们可能都有不同的名字,你需要更加细致
。更好的方法是检查怪物正在查看的对象的类,使用 class 方法。这将返回类,你可以将其与 player.Player 进行比较。
注意
这之所以有效,是因为你的游戏与玩家之间只有一个通信点:玩家输入的命令和游戏返回的响应。
这应该就是你需要做的全部。现在,当你运行你的服务器并通过 Telnet 连接时,你会看到你熟悉的冒险游戏提示,你可以四处跑动在服务器上收集战利品和击败怪物。尽情享受冒险和荣耀吧。
嗯,差不多吧。虽然游戏可以运行,你也可以探索和做你需要做的所有事情,但在你的游戏可玩之前,还有一些事情需要处理。
使游戏更有趣
我将之前的代码发布到网上给一些朋友看,并从他们那里得到了反馈。他们提出了两个主要问题:怪物太难打败,玩家之间的互动不足。通常,在这种冒险游戏中,你将能够更改你的名字和描述,与其他玩家交谈,查看他们的描述,等等。

坏怪物!
一旦你第一次遇到兽人,战斗的问题就非常明显了。你的行动受限于你输入的内容——但怪物以计算机的速度反应。下一张图显示了我是怎么想的。
大多数 MUD 游戏所采用的解决方案被称为愤怒列表。而不是直接攻击事物,游戏会维护一个你愤怒的怪物和其他玩家的列表。如果你没有明确地做其他任何事情,而且你的愤怒列表中存在某个目标,那么你会攻击它。如果有人攻击你,那么它也会进入你的愤怒列表,这样你至少会进行象征性的防御。让我们看看如何在你的游戏中实现愤怒列表。
图 10.4. 坏怪物!不要打玩家!

列表 10.7. 愤怒列表



玩家和怪物都需要一种记住他们愤怒的对象的方法。你将把它做成一个列表
,因为你不期望它变得太大。
接下来,你将修改你的update方法。如果你的input属性为空,你知道玩家(或怪物)没有输入任何命令,并且如果需要,你可以继续攻击。你构建一个列表,列出所有你愤怒的且当前存在的目标,然后攻击其中一个
。
如果玩家或怪物死了,他们不应该继续攻击,所以你会清除他们的愤怒列表
。
玩家还需要一种停止攻击事物的办法(也许他们又成为了朋友)。停止命令将从玩家愤怒的列表中移除攻击者
。
你将要做的最后一件大事是让攻击命令修改攻击者和被攻击者的愤怒列表
。现在,当某物被攻击时,它会自动反击。注意你在攻击之前是如何构建你的结果的。这样,如果目标死亡,你不会看到“你攻击了死去的兽人。” do_attack 是从你的旧attack属性中来的机制,只是名字不同。
最后,最后的事情是向你的命令列表中添加停止命令
——否则你将无法使用它!
现在玩家应该有半成的机会对抗兽人。如果兽人现在打败了玩家,玩家至少会感到他们没有被游戏完全欺骗。如果你拿起剑,你会发现它非常有帮助,这正是你想要的。还有很多其他改进战斗系统的机会,但你需要处理一个更紧迫的问题。
返回聊天服务器
第二个问题是玩家不能互相互动。当涉及到多人游戏时,这通常是一个很大的吸引力——玩家会为了游戏而来,但会为了陪伴而留下。幸运的是,让你的游戏更具社交性是很容易做到的。你将在 Player 类中添加一些额外的命令。
列表 10.8. 社交游戏


如果玩家对游戏完全陌生,你需要至少给他们一半的想法,了解他们可以做什么。你将使“帮助”输出一些有用的说明
。我添加的完整帮助文本在 源代码 中。
另一个容易的胜利是让玩家通过更改他们的名字和描述来自定义他们的外观
。玩家现在可以不再是“玩家 #4”,而是“Grognir,兽人杀手”。
当然,如果其他玩家看不到描述,那么描述就没有什么用了
。

你还需要添加最重要的命令之一:一个 say 命令,这样你的玩家就可以互相交谈
。这个命令需要做的只是将你输入的内容发送到当前房间中的每个其他对象。这个简单的改变将允许玩家在人类层面上进行互动,这反过来又可以帮助他们继续回来。
你会遇到的一个问题是,随着新命令的出现,旧的 find_handler 方法有时会调用错误的东西。例如,player 和 location 都有一个 look 方法,哪个是正确的将取决于上下文。此外,你刚刚添加的一些命令只适用于玩家本身,你不应该寻找对象来应用它们。以下列表有一个更新的版本,它对应该查看哪些对象要明确得多。
列表 10.9. 更新 find_handler



让我们密切关注词汇选择。有些动词不适用于名词,或者它们隐含地适用于玩家
。
有几个特殊情况的命令
你无法用你当前的系统处理。你可以重写整个处理器,但更容易捕捉这些命令并将它们明确转换为你可以处理的东西。当然,如果转换变得超过几个,那么你将不得不重新思考事情;但现在这就可以了。
为了让你看到你看起来怎么样,你也会添加一个 self 对象
。“查看 self” 应该返回你呈现给其他人的描述。
是另一个改进,使新玩家更容易。当事情出错时,你将有一个错误信息,一个是你不理解的命令,另一个是你找不到玩家在寻找的东西时。
现在,你的玩家可以互相聊天,并赞美他们出色的线程。
最后,如果没有反社会的机会,社交游戏会是什么样子?大多数 MUDs 都有喊叫的选项,这和说话很像,只是连接的每个人都能听到你。
列表 10.10. 反社会游戏



现在,你需要依次访问每个洞穴——但似乎没有找到洞穴的方法。目前,你将假设你能够访问游戏的洞穴列表!。
这基本上和玩家互相交谈时的情况一样!将两者合并——例如,通过将代码推入位置类——留给读者作为练习。
现在,你需要通过在创建玩家或怪物时将其作为变量从游戏传递进来,给你的Player类访问游戏中的洞穴列表的权限!。然后,更新创建玩家或怪物实例的每个地方!,这样它现在就知道了game对象,并能告诉所有洞穴的位置。
好吧!你已经平滑掉了一些游戏的粗糙边缘。还有很多事情要做,但现在你不会为游戏编写任何新功能。相反,你将专注于使游戏的基础设施更加稳健,这样玩家就不会因为所有辛勤工作都消失而感到沮丧。
使你的生活更轻松
如果你只想为你的朋友编写游戏,你可能可以在这里停止;毕竟,他们可以连接并玩你的游戏。然而,目前还有一些问题会使你的生活比必要的更艰难。任何人都可以以任何人的身份登录,所以游戏并不特别安全;而且游戏不保存任何进度,所以每次你重新启动游戏服务器时,玩家都必须从头开始。
让我们解决这个问题。你需要在游戏中添加用户名和密码,以及一个允许新玩家注册的机制。一旦你知道谁登录了,你就可以不时地保存玩家的进度,以及他们在退出游戏时。但是,你还需要更多地了解 Twisted,因为你将深入挖掘其 Telnet 类的一个核心。不过,别担心;一旦你掌握了诀窍,它就很简单了。
探索不熟悉的代码
Twisted 是一个大型代码库,拥有大量的模块来帮助你网络化你的应用程序。这很好,因为它意味着你不需要编写自己的代码来处理应用程序中的网络,但它也引发了一个相关问题:在能够利用所有这些优秀代码之前,你必须至少对它们如何组合在一起有一个基本的了解。
理想情况下,像 Twisted 这样的库的文档应该是 100%最新的,并涵盖你需要做的所有事情,有一个优雅的、温和的介绍——但这并不总是如此。通常,你可能会找到一些接近的内容,但之后你需要通过一些猜测、实验和侦探工作来拼凑代码的工作方式。
听起来很难,但在实践中通常相当简单。关键是不要过于不知所措,并使用你所能利用的所有资源。以下是一些关于如何掌握大型代码库并在你的应用程序中使用它的想法。

查找一个示例
在网上搜索“twisted 教程”会给你提供一些起点,你还可以在搜索中加入“telnet”或“telnet 协议”。随着你对 Twisted 了解的深入,你会发现其他关键词或方法名,这些可以帮助你缩小搜索范围。你也可以从一个类似你需要的功能的示例开始,然后调整它,直到它完全满足你的需求。
扭曲的文档
在 Twisted 主站点的 Conch 部分提供了相当全面的文档,twistedmatrix.com/documents/,但它并没有涵盖你需要做的所有事情。有一些简单的 SSH 和 Telnet 服务器示例,你可以快速浏览以了解一切是如何组合在一起的。
Twisted API 文档
详细且自动生成的文档适用于整个 Twisted 代码库,你可以在twistedmatrix.com/documents/current/api/中查看。不要让包的数量让你却步——我们将专注于 Telnet 包:twistedmatrix.com/documents/current/api/twisted.conch.telnet.html。
Twisted 代码
你也可以直接阅读 Twisted 的大部分代码。Windows 版本的 Python 将它的库存储在 C:\Python26\Lib\site-packages\twisted;在 Linux 下,它可能位于类似/usr/lib/python2.6/dist-packages/twisted 的位置;在 Mac 下,通常在/Developer/SDKs/MacOSX10.6.sdk/System/Library/Frameworks/Python.framework/Versions/2.5/Extras/lib/python/twisted。所有的 Twisted 代码都存储在那里,你可以打开文件并阅读代码,以了解一个方法的确切功能。
反省
如果图书馆没有 API 文档,也并非全无希望。你仍然可以通过创建类的实例和使用 dir(), help(), 以及 method.doc 来了解它们的功能。如果你需要了解某个一次性方法,这通常比阅读代码或文档要简单。
实际上,这些资源中的任何一个都不会涵盖你编写程序时需要了解的所有细节,所以你将结合使用它们,在学习新部分或遇到问题时来回切换。
整合所有内容
让我们开始构建你的登录系统。快速浏览 Twisted 的 Telnet 模块后,看起来最佳的起点是 AuthenticatingTelnetProtocol 类。你将使用你的代码来实现它,然后让它注册新玩家,最后让游戏能够保存玩家数据。
首先,我查阅了 Twisted 文档和 AuthenticatingTelnetProtocol 的 API 参考。它似乎有些道理,但从方法和类中很难看出如何将它们全部联系起来。协议需要一个 Portal,而它又依赖于 Realm,Avatar 和 PasswordChecker。嗯,有点复杂。看起来是时候尝试找到一个类如何组合在一起的例子了。

你可以尝试几种不同的搜索: “twisted telnet”,“twisted telnet example”,等等,但我没有找到太多信息,直到我输入了一些代码中的术语。搜索“twisted telnet TelnetProtocol example”将我带到了 www.mail-archive.com/twisted-python@twistedmatrix.com/msg01490.html,如果你跟到底,你会得到一些示例代码,足以看到类是如何一起工作的。
基本思路是这样的:设置一个 Realm 类,以及一个进入它的 Portal。文档没有说明它是否是一个 magic portal,但应该可以。Portal 通过一个或多个密码 Checker,通过 TelnetTransport 控制对 Realm 的访问。当然,Authenticating-TelnetProtocol 只处理认证,所以一旦登录,你需要将控制权交给另一个协议,比如你的 MudProtocol。
理解了吗?不,我也没有。我不得不画一张图来弄清楚它是如何工作的,没有示例代码,我可能就会迷失方向。图 10.5 展示了我得出的结果。
图 10.5. Twisted 类结构

使用图表和示例代码,你可以启动一个简单的登录。以下列表显示了我是如何修改 mudserver.py 文件的。
列表 10.11. Mudserver.py


首先,你需要导入 Twisted 中你需要的所有部分
。虽然有很多,但你可以将其视为你不需要编写的代码。
Realm 是表示你的游戏登录的核心类
。你只需要重写一个方法:获取一个 Avatar。Avatar 是 MudProtocol 的实例,代表玩家的登录。请注意,你设置了玩家的名字,这样你就可以在 MudProtocol 中访问它,并将 state 设置为 "Command";否则,你将立即被注销。
注意
“你不必编写的代码”这部分很重要。人们很容易高估学习现有代码的难度,而低估编写经过良好测试的新代码的难度。
当你在弄清楚一切是如何工作的同时,将一些信息打印到屏幕上以尝试弄清楚每个对象的功能是完全正常的
。你可以使用你所学到的知识在网上搜索,或者通过代码来查找使用这些类的其他内容。
大部分的 MudProtocol 都没有改变,但你将需要知道玩家的用户名和密码,以便在稍后
开始将数据保存到文件时使用。Realm 已经为你提供了用户名,因此你可以使用它从 checker 中获取密码。你还需要更改的另一件事是 connectionLost 方法——如果你失去了与玩家的连接,你想要正确地清理。

现在我们已经进入了设置代码运行的章节。首先要做的是创建一个 Realm,然后将其与一个 Portal 和 Checker 相关联。一旦完成这些,你就可以将用户名和密码插入到你的 checker 中
。InMemory..DontUse 对于你的目的来说是可以的,尽管在理论上它是不安全的,你不应该使用它。还有一个基于文件的 checker,但它不支持将新用户保存回文件。
现在你正在使用 TelnetTransport 和你的 Realm 来控制事物,你不需要自定义 Factory,而且你也不再需要手动在工厂中跟踪客户端
。TelnetTransport 将使用 AuthenticatingTelnetProtocol 来处理用户名和密码,但一旦完成,它将转交给 Realm 来获取最终的协议。
最后一件事情是,Twisted 使用 Python 的日志功能。要查看它在做什么,你可以添加这一行
,这将把日志重定向到 sys.stdout——也就是说,打印到屏幕上。
所有这些给你带来了什么?好吧,如果你现在运行你的服务器并尝试连接到它,你应该会看到一个登录请求而不是密码,类似于图 10.6 中所示。图 10.6。如果你输入脚本中存在的用户名和密码,你应该可以连接到游戏。
图 10.6. 登录到你的游戏

然而,你还需要做更多的事情。记住,你想要允许玩家注册自己的用户名和密码。为此,你需要学习更多关于 Twisted 的知识。
编写你自己的状态机
在本节中,你将要创建一个类的子类,该类是你迄今为止一直在使用的 AuthenticatingTelnetProtocol。它是生成登录时 User-name: 和 Password: 提示的类。你想要的提示是询问玩家他们是否想要登录或注册新账户。如果是注册,它仍然会要求你输入用户名和密码,但会创建账户而不是检查它是否存在。

让我们先看看 AuthenticatingTelnetProtocol,看看它是如何实现的。你可以在你的计算机上找到 Telnet 模块,位置在 C:\Python26\Lib\site-packages\twisted\conch\telnet.py,或者如果你使用 Linux 或 MacOS X,可能在 /usr/lib/python2.6/ site-packages/twisted/conch/telnet.py。如果你打开该文件并滚动到最底部,你会找到你正在寻找的类;它也在 列表 10.12 中显示。
列表 10.12. Twisted 的 AuthenticatingTelnetProtocol 类


我们到目前为止所查看的所有 Telnet 类都是状态机——登录涉及多个步骤,下一个步骤取决于你获得的输入。你最初处于 "User" 状态,这意味着输入被馈送到 telnet_User 方法!。每个方法返回一个字符串,该字符串确定下一个状态。

有几种其他方法:connectionMade 和 connectionLost,但在这个情况下你不需要处理它们!。
第一行(在初始问候之后)发送到 telnet_User 并在实例中设置用户名!。transport.will() 调用告诉本地客户端服务器(即你)将负责回显用户输入的任何内容——但在这种情况下,是密码,所以你不需要。然后返回 "Password",所以下一行发送到 telnet_Password。
现在你有了密码,你可以将其与门户密码检查器中为该用户名存储的内容进行比较!。
Twisted 有一个称为 Deferred 的机制,有助于加快服务器速度!。密码检查器可能会查看磁盘上的文件,或者连接到不同的服务器以查看密码是否正确。如果它等待结果(通常称为 blocking),那么在磁盘或远程服务器响应之前,其他人将无法做任何事情。Deferred 对象是一种表示“当我们得到响应时,用这个函数处理它”然后继续其他任务的方式。有两种可能性:回调和错误回传。
如果检查器响应密码正确!,你可以继续进行登录的其余部分,这意味着存储一些值,将你的状态设置为 "Command",并切换你的协议到最后一个。
如果检查器告诉你密码或用户名错误
,那么你可以责备用户并切换回 "User" 状态。用户将需要再次输入用户名和密码——而且这次你会输入正确。
你如何子类化 AuthenticatingTelnetProtocol?答案是添加一些新状态,以便有一个注册分支以及正常的登录分支,类似于 图 10.7 中的流程图。
图 10.7. RegisteringTelnet-Protocol 中的状态

下一个列表添加了一个新的协议,包含三个额外的状态—"Welcome"、"NewUserName" 和 "New Password"—以及处理每个状态的方法。
列表 10.13. RegisteringTelnetProtocol


欢迎用户连接到服务器
与上一个例子几乎相同,只是有不同的值。你提示用户输入 R 进行注册或 L 进行登录。

因为你的上一个状态是 "Welcome",第一个方法是 telnet_Welcome。代码很简单:R 将状态设置为 "NewUser-Name",L 为 "User",其他任何操作都会将他们踢回 "Welcome"
。telnet_NewUserName 与 telnet_User 相同
。它提示的方式略有不同,并传递到不同的状态:"NewPassword"* 而不是 "Password"。
当然,你不能有两个甘道夫或康纳在服务器周围跑来跑去,所以你需要检查用户名是否已经在服务器上存在
。如果存在,将用户踢回 "Welcome"。选择一个更有创意的名字!
玩家已经通过了你设置的障碍后,你可能会想将玩家添加到服务器
。为了使玩家更容易,你还会自动登录玩家。
最后的部分并没有添加用户,只是假装添加了。
将会起作用。你依次调用你的检查器,并调用它们的 addUser 方法。注意,如果你使用基于文件的检查器,twisted.cred.FilePasswordDB,这不会起作用——至少不会永久起作用,因为它不会将玩家写回文件。
如果登录引发错误,你应该返回初始的 "Welcome" 状态
,而不是 "User",这样用户就可以在忘记用户名(或者由于某些原因你删除了它)时进行注册。
最后,你需要更新你工厂的协议,使其使用 Registering-TelnetProtocol 而不是旧的 AuthenticatingTelnetProtocol
。
太棒了!现在你不必为想要体验你酷炫新游戏的每个人输入用户名和密码。在实践中,这意味着你会得到更多的玩家,因为这将降低进入门槛,玩家不必等待你检查电子邮件。如果你感兴趣,下一步是包括一个密码重置或检索机制,这样玩家(如果他们在游戏中设置了电子邮件地址)就可以在忘记密码时收到他们的密码。
使你的世界永久化
现在,你还有一些更紧迫的问题:玩家可以注册和登录,但如果由于某种原因(比如,为了添加新功能)重新启动服务器,那么他们就会失去所有进度并需要重新注册!虽然你不必保存 一切,但你将只保存玩家及其物品,并从零开始重新启动所有怪物。这在大多数 MUD 中是常见做法,因此怪物、谜题和故事每晚都会重置。
注意
实现保存的另一个原因是,如果一切突然消失,这会打破玩家的信念。你希望玩家在某种程度上相信你创造的世界是真实的,而真实的世界不会在虚拟烟雾中消失。
列表 10.14. 加载玩家

你不需要存储每个玩家,因为你只对玩家的数据感兴趣——他们叫什么名字,他们看起来如何,他们携带哪些物品。你将把那些信息放入存储!
,这样你就可以随意调用它。
下一步,你需要想清楚如何命名用于加载玩家存储的代码!
。我认为如果你创建一个方法,应该没问题。
加载玩家存储的方法!
实际上非常简单。检查文件是否存在——如果存在,则打开它,并使用 Pickle 从其中加载 player_store。
很简单!当然,你还没有完成——这只是为了加载玩家存储。现在你需要确定存储中应该包含什么,并将其保存到文件中。
列表 10.15. 保存玩家

你会以典型的面向对象方式将每个玩家添加到玩家存储中——通过调用 player.save 来找出每个玩家应该存储什么!
。
一旦你刷新了存储,你就可以继续将其保存到磁盘上!
,以便下次启动游戏时使用。
player.save 方法需要做的只是创建一个包含所有玩家数据的字典,并返回它!
。
现在,你的 game.save 方法应该可以正常工作,你可以从中加载。最后一步是在适当的位置触发 game.save,并确保玩家在登录时携带所有数据。
列表 10.16. 更新服务器


加载玩家与保存它!
非常相似,只是方向相反。你不需要将状态倒入字典,而是从另一个状态更新状态。
而不是让玩家在退出时死亡,他们现在会保存自己并优雅地退出
。对于这个游戏,你只有一把剑和一个金币要与其他所有玩家共享,所以你会丢弃所有物品;但这不是冒险游戏的正常做法。
要保存一切
,你需要使用 Twisted 设置另一个周期性函数。
玩家可以在游戏中更改他们的密码,因此随着游戏的保存刷新服务器的密码列表是有意义的
。你会在调用 game.save() 之后立即这样做,这样你就知道 game.player_store 是尽可能新鲜的。

注意,这段代码中有一个错误:当玩家更改他们的名字时,旧名字不会被删除。你将想要更新 Player 中的名字更改代码以从门户和 player_store 中删除旧名字,或者禁用名字更改代码。禁止名字更改可能是最好的选择,因为它也阻止了不良行为。
一旦你的函数完成,你只需在启动时调用它,之后每分钟或大约如此调用一次
。我选择了 60 秒作为一个合理的时间框架,但你可能会发现更长或更短的时间间隔更适合你。实际上,这将在游戏保存时服务器的负载和失去玩家物品的风险之间进行权衡。
就这样。现在你有一个稳定的未来开发基础,你不必担心玩家无法登录,或必须回应所有想要登录的人。
接下来是什么?
你的 MUD 正在运行且功能完整,但你只是触及了你可以做到的表面。了解需要做什么的一种方法是将一些朋友邀请来玩——确保他们知道这是一个正在进行的项目——并征求他们的建议和错误修复。如果你没有对 MUD 感兴趣的朋友,以下是一些你可以尝试的想法:
-
杀死它(在另一个位置)后让兽人重生,或者添加不同的怪物。它们可能有不同的攻击方式,需要更多或更少的攻击才能杀死,并掉落不同类型的宝藏。
-
保存洞穴布局以及玩家信息将帮助玩家更强烈地将其视为一个实际的地方。此外,大多数 MUD 都允许你以“巫师”的身份登录并在游戏过程中扩展游戏,添加房间或怪物。
-
不同的物品、盔甲和武器可以为玩家探索或存钱购买新物品增添额外的兴趣。
![f0356-01.jpg]()
-
让玩家获得经验和等级,高级角色更难对付且更强大。不同的角色类别和统计数据(力量、智力、敏捷等)可以帮助玩家认同游戏并使其更加有趣。
-
有许多开源的 MUD 游戏可供选择,它们使用了几种不同的语言;下载它们并看看它们是如何工作的。大多数核心组件将是相似的,所以当你试图理解它们时,你会知道该寻找什么。
摘要
在本章中,你学习了如何将网络功能添加到游戏中,以及在网络环境中需要处理的问题。你从一个简单的聊天服务器开始,学习了 Twisted 的协议和服务器类,然后创建了一个类似的设置,以便你可以在 Telnet 上玩游戏。因为 Twisted 是异步的(同时做很多事情),你还需要学习如何使用 Twisted 的task.LoopingCall来实现你的游戏循环。
一旦你完成了这些,你就对你的游戏进行了测试,并发现了在新环境中游戏玩法的一些问题。为了修复这些问题,你添加了一些新功能,例如愤怒列表、与其他玩家交谈,以及更改玩家名称和描述的命令。
最后,你设置了一个系统,新玩家可以登录到你的系统,而无需你将他们添加到用户列表中。你对 Twisted 的细节了解得更多了一些,特别是它的 Telnet 实现,但也了解了它是如何与协议、服务器以及延迟(Twisted 的较低级功能之一)交互的。
第十一章。再次回顾 Django!
本章涵盖
-
添加认证
-
单元测试和功能测试应用程序
-
更新模型更改时的数据库
-
服务静态图像和 CSS 样式表
在 第八章 中,您使用 Django 构建了一个简单的待办事项列表,这允许您跟踪您需要完成的任务。虽然这对您很有用,但对其他人来说并不有帮助。在本章中,我们将探讨您需要采取的一些润色步骤,以使您的 Django 应用程序对其他人有用。让我们开始吧!
认证
从功能角度来看,您的应用程序几乎已经完成——您可以删除和更改任何待办事项,并且可以添加尽可能多的待办事项。问题是:如果那个人有权访问您的网络界面,那么任何人都可以这样做。如果那个人是恶意的,那么您的所有待办事项都可能被删除,或者您的重要待办事项可能会被篡改。
为了确保您的应用程序安全,您需要限制谁可以访问您的应用程序,并且您不应该能够篡改任何人的待办事项。在实践中,这意味着您将引入以下检查:
-
您需要登录到应用程序。
-
登录后,您应该只能看到您自己的待办事项。
-
无论何时您尝试添加、更改或删除待办事项,它都应该仅当它是您的待办事项时才有效。
一旦这三个约束条件就位,您应该能够抵御任何试图篡改他人待办事项的人。

登录
让我们从登录您的应用程序开始。Django 提供了一个名为 auth 的内置应用程序,以及中间件来处理会话和存储用户数据。这使得像您这样的基于用户的应用程序变得更加简单直接,而且它比从头开始创建自己的要更加健壮和安全。
列表 11.1。Django 认证和登录视图


您需要导入 Django 的认证功能所需的三个函数 ![two.jpg]。

此视图以几种不同的方式使用:作为登录表单的初始显示以及检查用户名和密码。为了防止出现 Key-Error 异常,您正在从 request.post 字典的 get 方法设置用户名和密码变量 ![three.jpg];这样,如果这些值在请求中没有设置,它们将默认为空白。您还将 error_msg 设置为空字符串。
如果您有用户名和密码,那么有人在尝试登录,您使用 Django 的 authenticate 函数 ![four.jpg] 来检查它们与您的用户列表。
如果用户名和密码检查无误,则 authenticate 将返回一个 User 对象。如果不正确,它将返回 None。这很容易测试,但您还需要查找用户已被停用的情况。如果这两个测试都通过,则可以使用 login 登录用户并重定向到待办事项索引 ![five.jpg]。
如果你没有被重定向到首页,你会通过这个部分,在这里你将重新显示登录页面 ![one.jpg]。这样你就可以在出错时重新填充表单,你将回传用户名和密码,以及你生成的任何错误信息。你还在使用 Django 的便利函数之一,render_to_response,它会在给定模板名称和变量字典时直接查找并渲染模板。
注销甚至更简单——使用请求调用 logout 函数,它将删除用户使用过的任何会话数据和 cookies ![six.jpg]。一旦这样做,你将重定向回登录页面。
太好了——现在你只需要一个模板来显示登录表单。这并不难做。以下列表显示了我的版本,我将它放在 todos/templates/todo_login.tmpl 中。
列表 11.2. 登录模板
![11list02.jpg]
如果你从视图中收到错误,你想显示它,所以 ![one.jpg] 是模板代码的一部分,它正是这样做的。你还添加了一个 error 类,它以红色显示错误。
![f0362-01.jpg]
除了将用户名和密码作为值包含在内,该表单是一个标准的用户名和密码登录 ![two.jpg]。你包含了输入的用户名和密码,所以如果出错,用户不需要重新输入所有内容。这样的小细节可以让你的应用程序看起来更专业。
最后但同样重要的是,你需要告诉 Django 关于视图的信息,以便它可以显示它们。以下是 urls.py 中的管道,用于将一切连接起来——登录和注销直接跳转到相关视图:
(r'^login$', views.todo_login),
(r'^logout$', views.todo_logout),
现在,如果你在浏览器中访问 http://localhost:8000/todos/login,你应该能够输入你的用户名和密码,然后重定向到首页。如果你输入的用户名或密码错误,它也应该给你一个漂亮的红色错误信息。
注意
如果你不想手动输入用户,有一个名为 django-registration 的 Django 应用程序可以让人们通过电子邮件添加自己的账户。
添加用户
你此时可能还在想如何添加新用户。这很简单——使用 Django 的管理界面 (http://localhost:8000/admin/) 创建一些。我不确定你朋友的名字是什么;我将我的朋友称为“布鲁斯”,以避免混淆。
在管理界面中点击“用户”,你应该会看到类似于 图 11.1 中的第一个屏幕。填写用户名和密码,你的用户将被创建。然后,编辑相关字段。如果你仔细查看权限列表,你会看到有添加、编辑和删除待办事项的权限。你在这个应用程序中不会使用它们,因为它们适用于每个待办事项,但如果你需要,它们是可用的。
图 11.1. 通过 Django 的管理界面添加用户
![11fig01_alt.jpg]
接下来是什么?现在你需要在应用程序中登录,让我们让待办事项更加安全。
只列出你自己的 todos
现在用户可以登录了,你可以开始修改应用程序的显示方式了。目前,你的索引页面仍然列出数据库中的每个 todo,但你希望它只显示由当前用户创建的 todos。再想想,你没有任何方法来确定哪些 todos 属于哪个用户。你最好在做一些花哨的事情之前先解决这个问题。
要添加一个指向 todo 所有者的链接,你需要在 Todo 表中添加一个外键。
class Todo(models.Model):
...
owner = models.ForeignKey(User)
不要忘记从 Django 导入 User 模型:
*from django.contrib.auth.models import User*
然而,问题在于你现在不能再使用 python manage.py syncdb 来更新你的数据库了。出于安全考虑,Django 只会添加新表,而不会篡改你现有的表。但你必须做些什么,因为如果你尝试使用 todo 模型,Django 会给你一个错误,就像图 11.2 所示。
图 11.2. 你的数据库坏了!

你的数据库和你的模型不同步了!此时你有两个选择:要么删除 todo.db 数据库文件并从头开始,要么安装 SQLite 并使用它来更新现有数据库。
修复你的数据库
你会选择第二个选项——虽然它有些困难,但一旦你开始处理有现有数据的应用程序,你将需要能够这样更新数据库。别担心,一旦你掌握了几个简单的命令,这并不太难。如果你还没有安装 sqlite3 程序,SQLite 可以从 www.sqlite.org 获取,你只需要将可执行文件放在操作系统可以找到的地方。下面的列表显示了我是如何更新我的数据库的。

列表 11.3. 向数据库后端添加字段


首先,你需要弄清楚要添加什么
。manage.py 不会为你做任何改变,但它仍然知道应该有什么。python manage.py sql todo 会告诉你使用数据库的确切 SQL 命令,这比试图在脑海中构建正确的 SQL 命令要好。
了解常见的 SQLite 命令是有好处的
。如果你需要绕过数据库,.help、.tables 和 .schema 是三个有用的命令。
接下来,你发出一个 alter table 命令,SQL 语法是从 manage.py 中借鉴的
。不幸的是,SQLite 不会接受它——你已经告诉它该字段不应该为 NULL,但你没有提供默认值,所以 SQLite 不会知道如何处理你现有 todos 的该字段。要修复 alter 命令,你可以要么删除 NOT NULL 子句,要么添加一个合理的默认值。
在这种情况下,你让默认情况是现有的 todos 归属于管理员用户(id=1)
。
小贴士
一个名为 South 的 Django 应用程序可以根据你的 models.py 文件自动更改你的数据库。它不会得到所有东西(例如字段重命名),但如果你的应用程序复杂,它可能是一个救星。
现在你已经添加了所有者列,并且 Django 已经知道了它,你的应用程序就更有用了,你可以使用所有者字段来做各种酷的事情。例如,Django 管理系统现在将允许你通过方便的下拉菜单更改待办事项的所有者,如图 11.3 所示 图。
图 11.3. 更改待办事项的所有者

Django 管理应用程序擅长读取你的模型并做出适当的选择来显示你的数据。它只需要定义正确的关系。
回到正轨...
你最初计划只显示登录用户的待办事项。现在这很容易做到——下面的列表展示了更新的 todo_index 和 add_todo 视图,这些视图将过滤你在登录时显示的待办事项列表。
列表 11.4. 只显示你的待办事项


如果有人没有登录,他们仍然可以手动输入索引 URL,或者将索引页面添加到书签并忘记登录。这将搞乱你的应用程序,所以如果他们是匿名用户,你会将他们重定向到登录页面
。
而不是返回所有待办事项,这个数据库查询
将返回那些用户与当前登录的用户相同的待办事项。
通常,一旦你找到了一个巧妙的新方法来做事情,回到过去清理你已经编写的代码是个好主意。这是你的旧模板调用代码来自 第八章,但现在它使用了新的 render_to_response 函数
。
剩下的唯一事情就是将所有者添加到你的所有新待办事项中
。简单得很!
现在你可以只查看你的待办事项,其他人无法看到你在忙什么。当你创建一个新的待办事项时,它会链接到你的用户 ID。你所需要做的就是编辑或删除你的待办事项时执行相同的检查。它们不会出现在待办事项列表中,但这不会阻止邪恶的人注意到你的待办事项是通过 ID 引用的,并看到他们输入不同 ID 时会发生什么。哎呀!刚刚删除了别人的待办事项!
覆盖所有基础
这就是 Django 简单性的体现。你的视图只是根据传入的请求提供某些东西的函数,你可以利用这种简单性来发挥优势。而不是重写整个视图——丢弃所有你的工作——你可以用一些自己的代码包装视图来检查请求,如果请求是正常的,然后将你的值传递给通用视图。下面的列表展示了如何做到这一点。
列表 11.5. 包装更新和删除视图



是通用视图,但你是在 views.py 而不是 urls.py 中导入它们的。
你可能会注意到我们在本章前面看到的占位符视图 ,但你正在扩展它。你只需要请求和你要编辑的待办事项的 ID。
get_object_or_404 是另一个 Django 快捷方式 。它尝试访问具有该 ID 的模型,如果无法访问,则触发 404 错误。
在你看到通用视图之前,你需要检查待办事项是否由试图编辑它的人拥有 。如果 todo.owner 的 ID 与请求中的 ID 不匹配,那么你会带着错误信息重定向到索引页面。
如果你到了这里,那么用户拥有待办事项,你可以将控制权传递给通用视图 。这里包含了之前 urls.py 中所有的相同参数,但它们被指定为函数参数,而不是字典中的键值对。
删除待办事项的步骤与更新待办事项的步骤完全相同 ,除了传递给通用视图的变量略有不同。
现在,你需要新的 URL 来指向你的新视图。以下列表显示了之前 urls.py 的一个更简洁版本。
列表 11.6. 新的 urls.py
现在你应该能够轻松地遵循这些更新的 URL 。 (?P<todo_id>\d+) 匹配一个 ID,并将其作为参数传递给视图。
你还应该在编辑 URL 中添加一个可选的正斜杠,以防有人手动添加。
更新你的界面
你需要做的最后一件事是更新你的索引页面,使其能够接受可选的错误参数。这很容易做到,而且你已经为登录表单做了这件事,所以你可以将其复制粘贴到相关部分。
列表 11.7. 索引页面上的错误信息
第一步是将请求中的任何错误信息传递给模板 。这里你使用的是一行代码,其方式与你在登录脚本中处理用户名和密码的方式类似。
现在,你可以更新模板以显示在 URL 中设置的红色错误信息 。
有了这些,你的应用程序就完成了!你可以查看、添加、编辑和删除待办事项。你的界面也仅限于你选择给予用户名和密码的人。此外,如果你回顾一下你编写的代码,你会注意到它被很好地分割——与显示相关的一切都在你的模板中,你的模型包含所有数据和数据格式化函数,而你的视图处理登录、数据提取和发生某些情况时的重定向。
测试!
在你的原始待办事项应用程序中,你通过单元测试来开发它,但到目前为止,你还没有看到任何测试代码。当你的项目很小的时候,你可以没有测试代码,但随着项目的增长,它将需要某种测试来保持其可控性。此外,Django 的测试基础设施很酷。让我们快速看一下你如何测试你的 Django 应用程序。
单元测试
你需要知道的第一件事是如何创建单元测试来测试你的模型。你也可以使用单元测试来测试其他独立且不依赖于任何其他基础设施的函数和类。以下列表展示了如何为你的应用程序创建单元测试。你应该将它放在应用程序文件夹中名为 tests.py 的文件中。
列表 11.8. 单元测试 (tests.py)

你正在使用 Django 的 TestCase 类来组织你的测试代码。它主要基于 xUnit 测试风格,尽管也可以使用 Django 的 doctests。你的 Todo 类需要链接到一个用户,所以你也在导入 Todo 类
。

xUnit 测试通过一个父类和其中多个 test_ 方法进行结构化
。父类为你提供了许多方便的方法来测试相等性、真值、抛出异常等。
xUnit 还有一个 setUp 方法的概念,它在每个测试方法之前被调用,用于设置常见的数据结构。在这里,你正在设置一个用户和一个 todo,你需要为测试
。还有一个相应的 tearDown,它在每个测试之后被调用。
是一个单元测试可能的样子。你正在测试 Todo 类的 short_description 方法,所以你设置了不同的测试 todo 描述,并确保使用 TestCase 的 assertEqual 方法返回正确的值。
你通常会对每个模型进行多个单元测试,以测试它们的所有功能,但视图怎么办?它们甚至更重要进行测试,因为它们通常定义了你的应用程序的大部分内容,并将所有内容连接在一起。
功能测试
对于 views.py 和 urls.py,你需要使用功能测试来确保一切正常工作。对于功能测试,Django 允许你使用 Client 类提交“假装”表单,该类模拟浏览器和整个请求/响应过程。以下列表展示了几个简单的功能测试。
列表 11.9. 功能测试


因为你在测试视图,所以导入
将与你在视图中设置的相同。
创建 Client 类的实例很简单
——它不需要任何参数。
一旦你有了 Client 类,你可以使用它的 .post 方法向你的应用程序发送数据
。.post 接受一个 URL 和一个 POST 参数的字典,并返回一个响应对象。注意,你在这里使用 reverse,就像在视图一样,保持你的单元测试独立于你存储代码的位置是很重要的。还有一个相应的 .get 方法,它不需要参数字典。
响应对象
包含了你刚刚运行的 POST 请求返回的所有内容,例如状态码、内容、头部、Cookies 等。这里你感兴趣的是两点:响应是一个重定向,并且重定向到索引页面(因为你已成功登录)。

你不希望在每次测试应用程序中的某个功能时都发送一个登录请求,因此 Django 为客户端提供了 login 方法
。它创建所有必要的 Cookies 和会话变量来模拟实际的登录。
如果你正在测试一个更正常的请求,并且需要测试它返回的内容,你可以通过 response.content 访问它——它是一个包含 HTML 的字符串,就像你在浏览器中查看页面的源代码一样
。
现在你有了测试,但这并不算什么——你需要能够运行它们并确保你的代码测试通过。美妙之处在于,找到你的测试代码和运行测试是自动完成的。
运行你的测试
Django 的 manage.py 包含一个 test 命令,它将收集你的测试代码并运行它,以及设置所有相关的数据库基础设施等。以下列表显示了针对 todo 应用程序的示例测试运行。
列表 11.10. 测试运行

如果你只想针对一个应用程序运行测试,那么在 test 命令后包含应用程序的名称
。否则,Django 将测试所有已安装的应用程序,包括可能不是你想要的如管理界面等应用程序。
对于每个测试运行,Django 将创建一个测试数据库并将其连接到该数据库而不是你的实时数据库
。这使得你的测试独立于你在应用程序中存储的数据——如果你需要,甚至可以针对实时服务器运行测试。
测试输出的结果与标准单元测试风格的测试运行结果非常相似
。每个测试都会得到一个点(通过),一个 E(错误),或者一个 F(失败)。一旦测试运行完成,你将得到一个报告,显示有多少失败,以及任何错误或失败的跟踪信息。
当测试完成后,Django 将删除测试数据库
。
现在你已经知道如何确保你的应用程序按计划运行,即使你需要将其拆分并完全重构。你可以确保你的发布版本没有已知的错误,并且在开发过程中你的代码不会回退——所有这些只需要一个健壮、健康、无压力的项目。
图片和样式
你需要做的最后一件事是配置服务图片和样式表。到目前为止,你一直是在 HTML 中硬编码样式表;但如果你稍后想进行更改,你需要编辑每个模板。Django 将图片、样式表、JavaScript 以及其他类似的东西称为 media。

首先,让我们看看一种直接从 Django 提供媒体的方法,然后是一种更健壮的方法,其中你的媒体由 Apache 或 Nginx 等服务器提供。
从 Django 提供媒体
首先,一个警告:这种方法仅适用于开发服务器。与用 C 语言编写的服务器相比,Django 用 Python 编写,在提供平面文件(如图片)时速度较慢。如果你的服务器上有任何显著的流量,它将无法处理负载。
小贴士
做一件事,做好这件事。Django 更适合返回包含数据库结果的 HTML,因此最好用它来做这件事,而用其他东西来提供图片。
话虽如此,让我们看看如何配置 Django 使用内置视图 django.views.static.serve 来提供静态媒体文件。你需要对 settings.py 和 urls.py 进行更改。
列表 11.11. 使用 Django 提供静态文件


首先,选择一个目录来存储你的媒体
。为了方便,我通常将其命名为“media”,并将其放在项目的根目录下。我包括了 Windows 和 Linux 的版本——请注意,第二行和第三行是一行,并且即使在 Windows 下,Django 也使用正斜杠。
你还想要选择一个 URL 来提供你的文件
。你可以选择任何你喜欢的 URL,但请注意,如果你选择 /media/,你将干扰管理应用程序的媒体设置。/site_media/ 是大多数 Django 应用程序的惯例。
要确保你的网站上线后不会使用 Django 来提供图片服务,你需要检查 settings.py 文件中 DEBUG 的值
。如果它设置为 True,那么你处于开发模式,应该是安全的。
最后,你设置 django.views.static.serve 视图
。它需要的两个变量是路径和文档根;除此之外,它可以自己处理。为了避免重复设置,你还会包含 settings.py 中的 MEDIA_ROOT。
一旦你做了这些更改,你可以重新启动服务器,并开始添加图片和样式表。图 11.4 展示了在我的开发服务器上显示的 Django 标志。
图 11.4. 使用 Django 提供图片

现在你可以通过引用 /site_media/ 来在你的应用程序模板中包含图片、CSS 文件和 JavaScript,如下所示:
<link rel="stylesheet" type="text/css"
href="/site_media/style.css">
<img src="/site_media/images/todo_logo.gif">
实际上为待办事项列表创建一个标志留作读者的练习!
从另一个服务器提供媒体
然而,提供媒体的一个更好的方法是使用专门设计来提供静态内容(如图片和样式表)的程序,并保留 Django 用于动态页面。这在大多数 Web 服务器上相对容易实现,并且有几种方法可以达到相同的效果。以下列表提供了一个示例,说明如何配置 Apache 使用 mod_python 来提供媒体和图片请求,但将其他页面请求传递给 Django。
列表 11.12。一个示例 Apache mod_python配置


基本上是一个标准的 Django-with-mod_python配置部分。你使用 Django 的mod_python处理程序,使用你的todos.settings作为设置模块,并开启调试——至少,在你设置和测试一切的时候。
对于/media,你希望 Apache 正常提供文件,这意味着它将回退到服务器的正常文档根目录,并显示你存储在/var/www/www.example.com/media 中的媒体
。
你可以使用Location-Match指令做类似的事情,所以像www.example.com/not_a_media_folder/logo.gif这样的 URL 将回退到存储在/var/www/www.example.com/not_a_media_folder/logo.gif 的图片
。
注意,你不仅限于使用这样的子文件夹——你可以使用完全独立的域名。例如,如果你有 www.exam-ple.com 提供 Django 页面,那么从 media.example.com 或 images.example.com 提供媒体也是可能的。如果你的应用程序模型支持这样做,按照这种方式安排你的 URL 可以节省大量的配置。
最后但同样重要的是
你需要做的最后一件事是编辑你的 settings.py 文件,找到DEBUG设置。将此设置为True,当出现问题时 Django 会提供详细的错误信息。在开发过程中这很有用,但错误信息落入错误之手可能会造成危险。一旦你将其设置为False,如果你的网站出现故障,Django 将返回标准的 500 错误,并保护你的应用程序内部安全。
接下来该做什么?
你的应用程序现在完全功能正常,你还可以将它安装在内部网络或互联网上的服务器上,并将账户分发给所有你的朋友。尽管如此,它仍然相当简陋,所以这里有一些扩展它的想法,使其更有用:
-
现在你可以使用单独的样式表了,你可以让你的应用程序看起来更美观,在你的主页上添加标志和图标,并将表单放入表格中。一点美化可以大大改变人们对你的应用程序的重视程度。
-
你的待办事项相当基础。关于添加一些额外数据到它们,比如截止日期呢?如果你记录了电子邮件地址,当他们的截止日期还有一周或一天时,系统也可以给人们发电子邮件。
-
当你拥有一些用户后,你可以考虑使应用程序更具协作性。也许你可以添加一个字段来标记待办事项为公开。其他人的公开待办事项将包含在你的列表中,你可以查看但不能编辑它们。系统中的用户列表以及人们正在做什么也可能很有用。
-
或者,添加注释到你的待办事项中可能很有用;你甚至可能允许其他人添加评论。
关于 Django 的高级方面有大量的信息可供参考。例如,Django 文档非常出色,并且可以从 Django 网站免费获取。
你还可以下载许多基于 Django 的应用程序,并阅读它们。这是一种学习如何构建你的项目以及关于如何使开发更加容易的新库的好方法。一个不错的起点是 Pinax 网站(pinaxproject.com/),它提供了一些可重用的模块,例如用户注册和分页(将大列表拆分为 10 或 20 页的小页面)。还有用于集成外部服务如 PayPal 或 Facebook 的模块,以及内容管理系统等完整的应用程序。
摘要
在本章中,你了解了一些与托管和维护 Django 相关的问题。你从向你的系统中添加用户和登录开始,并看到了当你更改模型时如何更新数据库。然后,你检查了到目前为止的系统,并确保了所有页面和表单的安全性,以便不同的用户(甚至外部攻击者)无法访问那些应该私有的待办事项列表。在这个过程中,你与视图有了更多的实际操作,并看到了如何包装 Django 的一些内置视图,以便在不从头实现视图的情况下添加额外功能。
你已经看到了如何向你的 Django 应用程序添加测试,以及 Django 如何通过提供一个类似网页浏览器的Client类来轻松添加功能测试。
最后,我们探讨了如何为你提供静态内容,例如图片和样式表——无论是通过 Django 内置的视图,还是通过像 Apache 这样的更高效机制。
这本书中你将学到的就是所有的 Python 编程。在下一章中,你将了解到为了进一步提高你的 Python 技能,你应该采取的下一步行动,以及如果你在旅途中遇到困难,你将了解一些求助的来源。
第十二章。接下来该做什么?
本章涵盖
-
进一步提高你的 Python 技能
-
与其他 Python 程序员建立联系
-
你可能觉得有用的其他 Python 库
如果你已经读到这本书的这一部分,你已经学习了多种不同风格的 Python 程序。我们从第二章(Hunt the Wumpus)中的简单程序开始,那时你编写了 Hunt the Wumpus。从那时起,我们涵盖了库、类、基于事件的程序以及与网络的交互。你可以把Hello! Python看作是一个品尝盘,让你在深入研究特定主题之前尝试不同的 Python 编程风格。
尽管在这本书中我们已经覆盖了很多内容,但我们只是触及了 Python 所能做到的一小部分。本章旨在作为你作为程序员发展下一阶段的跳板。
阅读更多代码
学习如何编写更好的程序的最佳方法之一是查看其他人如何编写他们的程序,并找出他们所做的是什么以及为什么这样做。阅读他人的代码有一定的艺术性——经验肯定有帮助。
我发现理解新代码的最佳方式是首先快速浏览其结构,以了解程序的设计(这样你就不会完全迷失方向),然后深入研究其编写的细节。为了更好地掌握设计,在阅读过程中提出问题。他们为什么这样拆分程序?为什么在这里使用字典而不是列表?有没有更好的方法来做这件事?如果需要做不同的事情,我该如何扩展它?有没有可以帮助的库?
请记住,互联网上并非所有程序代码都是生产质量的——有些可能是废弃的原型或概念验证,有些可能是针对 Python 旧版本的。提问也能帮助你避免这些陷阱。
这里有一些你可以找到代码来阅读的地方。
Python 标准库
Python 库本身的大部分内容是用 Python 编写的。通过深入研究它,你可以了解常见的 Python 特性和库是如何被编写者实现的。然而,请注意,一些库可能相对较旧,并使用已被弃用的技术。
Python 食谱
类似于code.activestate.com/recipes/langs/python和djangosnippets.org的网站提供了 Python 函数和模块,它们展示了特定的技术或解决了特定的(小)问题。当你确切知道需要做什么时,它们很有用——例如,检查一本书的 ISBN 代码或找出如何解决一个字谜,但它们对于初学者来说也更容易跟随。
开源项目
一旦你阅读了一些小的代码示例,你可能想查看更大的程序。许多开源项目可以在 SourceForge (sf.net) 和 Google Code 的项目托管 (code.google.com/hosting) 等网站上找到,这两个网站都允许你专门搜索基于 Python 的项目。像 ohloh.net 这样的网站也会提供关于项目年龄、开发者数量和代码行数的统计数据,这样你可以根据你的舒适度选择成熟的代码或较小的项目。
加入 Python 社区
另一个了解如何提高编程技能的好方法是与其他了解 Python 的人取得联系。询问他们正在做什么是一个了解可能性的好方法。
订阅一些邮件列表
如果你在网上搜索过但没有找到解决方案,或者如果你遇到了难题,一个好的提问地方是 Python 导师列表。(你可以在 mail.python.org/mailman/listinfo/tutor 订阅。)在你提问之前,别忘了搜索列表存档,否则你可能会问一个已经被问过一百次的问题。

此外,别忘了“将知识传递下去”——一旦你发现自己已经超过了导师列表,就留下来帮助其他人学习如何使用 Python。如果你不知道如何回答某个问题,不要担心;但如果你能提供帮助,就请加入。该网站由志愿者运营,他们非常乐意得到帮助。
也有一些邮件列表专注于你可能感兴趣的其他领域,例如 Django 和 Pyglet。通过邮件列表,你不仅能学到许多技巧,了解有用的库,还能阅读讨论,找出为什么某些事情要以某种方式编写,或者在遇到问题之前发现局限性。
找到当地的用户组
Python 线下聚会和用户组是找到附近活跃的 Python 程序员的一个极好方式。他们通常会有定期的会议或聚会,是获取建议和新想法的好来源。阅读关于 Python 能做什么的网站是一回事;而与人们面对面讨论他们的项目则是完全不同的事情。

帮助开源项目
如果你经常使用某个开源 Python 项目,你可能想考虑订阅开发者邮件列表,熟悉其代码,并贡献补丁。大多数开源项目都有一些你可以用来查找你认为可以修复的错误的跟踪器。即使验证一个错误的存在并调查可能的原因也是一个好的开始——你不必一次就修复所有问题。或者,添加一个小的功能,编写文档或教程,或者添加单元测试。
搔自己的痒处
一旦你掌握了 Python,继续学习的最佳方式就是通过实践。选择一个项目——可以是你感兴趣的项目,可以是让你感到烦恼并需要改进的项目,或者是一个可能有用的项目——然后开始尝试去开发它。别忘了从一个小的部分开始;否则,你可能会感到不知所措。
另一个选择是构建在这本书中的代码之上。我已经覆盖了很多内容,所以应该会有接近你想要编写的代码,或者你可以用作支架的代码。一旦你对你的项目有一个基本的概念以及要采取的方向,编写程序就会容易得多。
当你准备好将你的项目与世界分享时,有许多网站你可以用来发布你的代码、提供文档以及跟踪错误和功能请求。这些包括之前提到的 SourceForge 和 Google Code,以及 GitHub (github.com) 和 Bitbucket (bitbucket.org)。
查看更多 Python 库
随着你的程序规模的增长,你会发现你需要能够做更多的事情。也许你需要通过网络与其他程序通信,加载特定的数据格式,或者更快地运行程序。在这本书中,我们已经介绍了几个可以帮助你扩展功能的库。这里还有一些你可能错过的其他库。其中一些是 Python 自带的;而另一些则需要你下载并单独安装。
代码分析
如果你正在编写需要快速运行的代码,或者它的运行速度比你想象的要慢得多,Python 自带一个名为cProfile的剖析器,它可以告诉你程序各个部分运行所需的时间。你可以利用这些信息来重写运行缓慢的部分(或者缓存或预先生成它们),而不是猜测程序运行缓慢的原因。
日志记录
Python 还内置了对日志记录的支持——将状态报告写入文件,以便你可以了解你的程序正在做什么。你可以以不同的级别进行日志记录,如果你已经配置了程序的调试输出,则只生成一些行,并且可以将日志记录到几个不同的目的地,例如文件、打印到屏幕,或者如果你使用的是 Linux,则可以记录到 syslog。
子进程和多进程
有时候你可能需要同时运行多个进程——通常情况下,如果你是以系统程序在后台运行,或者如果你正在进行一些需要大量处理器的操作,需要利用你系统中的所有 CPU。子进程模块是运行独立进程的标准方式,如果你需要并行运行进程以获得额外的速度,可以使用多进程。

更好的解析
你在编写冒险游戏时已经使用了 shlex(以及一些自定义代码),但如果你需要一个功能更强大的解析器,还有其他解决方案。Pyparsing 很容易上手,并允许你定义更复杂的语法类型,而不仅仅是根据引号和空格进行分割;但根据你的经验和需求,还有许多其他类型的解析器可供选择。
PIL 和图像处理
如果你用 Python 进行任何类型的图像处理工作,Python 图像库(PIL)是必不可少的。有了它,你可以裁剪和调整图像大小,合并图像,通过互联网接受二进制图像数据,检查它,并将其保存到磁盘——甚至从头开始生成图像。
XML、ElementTree 和 JSON
对于 XML 和 XHTML 解析,ElementTree 几乎无人能敌。它在 Python 2.5 版本中被添加到标准库中,并提供了多种解析和检查 XML 数据的模型。Python 还拥有 xmlrpclib:如果你需要与其他使用 XML-RPC 的程序进行通信,这个库非常方便。
如果你觉得 XML 有点过于重量级,还有许多其他格式可供尝试。如果你在 JavaScript 启用的网站上处理数据,JSON 是理想的选择,但即使是在存储数据或程序间传输数据时,它也非常有用。

摘要
现在你不仅知道了如何用 Python 编程,还知道了在需要时如何寻找帮助或进一步灵感的资源。为了在掌握 Python 的道路上迈出下一步,你需要阅读代码,与其他程序员交流,寻找新的库和技术,最重要的是,进行实验并创建自己的程序。你甚至不需要被竹竿击中!




浙公网安备 33010602011771号