Python-最佳实践高级教程-全-

Python 最佳实践高级教程(全)

原文:Pro Python Best Practices

协议:CC BY-NC-SA 4.0

一、简介

谦逊的一课

我 16 岁时开始写我的第一个电脑游戏。这是一个益智游戏,你必须移动砖块并让它们消失。几个朋友加入了这个项目,很快游戏就有了详细的概念、图形、关卡,甚至还有一个企鹅吉祥物(Linux 吉祥物还没有被发明出来)。只有程序需要被编写。那时,我正在 C64 计算机上用汇编语言编写代码。起初,编程很容易。在《少年无畏》中,我相信编程意味着在编写代码时投入足够多的精力,直到成功为止。我已经写了许多这样的小程序,一切都运行良好。但是很快,图形和游戏机制的编程变得比我想象的更困难。我花了几天时间试图对工作进行小的改进,但几乎没有进展。我的编程工作流程是

  1. 打开电脑
  2. 加载编译器
  3. 加载程序
  4. 写几行代码
  5. 运行程序
  6. 通常,程序会崩溃并关闭操作系统
  7. 关闭计算机并返回到步骤 1

实际上,我花在代码上的时间不超过 10%。发展速度减慢并很快完全停止是不足为奇的。目前,该项目已经晚了 23 年。我花了很长时间才弄明白发生了什么事。任何导师(一个有经验的程序员或我自己的老版本)都会坚持寻找从第 5 步到第 4 步的捷径。这种快捷方式在当时以一个插在电脑背面的盒子的形式存在。找到这些捷径不是一件小事。然而,他们让一切变得不同。我们将这些捷径称为最佳实践。

Python 中的最佳实践案例

23 年后的今天,我们有了 Python,一种让很多事情变得更简单的语言。当程序出错时,我们不必重启计算机。我们有像 Pygame 这样的库,可以帮助我们用很少的代码创建更好更快的图形。我们还需要编写 Python 代码的最佳实践吗?虽然你会从这本书的标题中预料到我的观点,但我想从一个简单的例子开始,解释为什么仍然值得考虑 Python 中的最佳实践。假设我们想使用 Python 和 Pygame 为自己的小游戏创建图形。作为概念验证,我们将加载两个图像,一个带有关卡,一个带有玩家人物,并将它们组合成一个图像(图 1-1 )。Pygame 库中的函数完成了大部分工作。要合并这些图像并保存到一个新文件中,只需要五行代码:

A419627_1_En_1_Fig1_HTML.jpg

图 1-1。

Two images to be combined with Pygame

from pygame import image, Rect

maze = image.load(’maze.png’)
player = image.load(’player.png’)

maze.blit(player, Rect((32, 32, 64, 64)), Rect((0, 0, 32, 32)))
image.save(maze, ’merged.png’)

乍一看,这个程序非常简单。更重要的是,它工作正常,并产生一个合并的图像。程序太短了,不能失败…是吗?我花时间列举了这个五行程序可能失败的方式:

  • 程序中的一个错别字用SyntaxError终止了 Python
  • Pygame 没有安装,所以 Python 退出时出现一个 ImportError
  • 安装了不兼容的 Pygame 版本,因此 Python 因异常而终止
  • 其中一个图像文件不存在,所以 Python 用一个IOError退出
  • 输出图像没有写权限,所以 Python 以IOError结束
  • 其中一个图像文件已经损坏,因此 Python 终止并出现异常
  • 其中一个图像文件失真,因此输出图像也会失真
  • 该程序与 Python 3 不兼容,因此用户依赖于 Python 2
  • 生成图像太慢,所以游戏无法玩了
  • 图像透明度处理不正确,因此输出图像中会出现伪像
  • 给出了一个错误的输出文件名,因此一个重要的文件被覆盖
  • Pygame 包含恶意代码,因此系统安全受到威胁
  • 该程序是以未经许可的图片发布的,所以作者有知识产权律师在他们的脖子上
  • 这个程序不能在手机上运行,所以没人想用它
  • 没有文档,因此潜在用户无法安装或使用该程序

我相信你可以发现更多的潜在问题。即使在一个五行程序中,也可能有不止五个地方出错。我们可以肯定,在一个长的程序中,甚至会有更多的事情出错。当我们更仔细地查看问题列表时,我们会发现一些问题显然与代码本身有关(例如,错误的导入)。其他问题,如缺少文档或法律问题)与代码本身无关,但它们会产生严重的后果。

Conclusion

编程中的一些问题可以通过编程来解决。编程中的其他问题是编程解决不了的。作为程序员,两者我们都有责任。

无论问题是什么,我们,程序员和我们的用户都必须承担后果。我经常使用自己的五行 Python 程序来完成小任务(例如,合并 pdf 或缩小数码相机图像)。作为唯一的用户,我可以很容易地改变代码或完全重写程序。但是,如果我们想编写更大的程序,有更多用户的程序,并与其他程序员合作,我们需要防止我们的项目陷入停顿。我们需要防止同时遇到太多的问题。我们需要技术来保持我们的程序健康并且易于使用。本书的前提是向您介绍编写更好的 Python 程序的既定技术或最佳实践。

最佳实践的起源

我们如何创建编写良好的程序来解决或避免前面描述的问题?有几个思想流派在这个问题上投入了大量精力。在这里,我们将探究它们是否对我们有所帮助(见图 1-2 )。

A419627_1_En_1_Fig2_HTML.jpg

图 1-2。

Building a house as a metaphor for building software. Upper left: Hacking, love for the challenge of overcoming technical limitations. Upper right: Software engineering, a systematic approach found in large projects. Lower left: Agile, fast, iterative development. Note that the product is inhabited by the end user while development goes on. Lower right: Software craftsmanship, focusing on doing things right using a set of established tools.

根据理查德·斯托尔曼的说法,黑客对卓越和编程有着共同的爱好(黑客:电子时代的奇才,1985 年,电视纪录片)。他们喜欢创造性的挑战,以克服技术限制,实现以前认为不可能的事情。在当今技术驱动的社会,擅长黑客的人是不可或缺的。黑客是一项关键技能,我们需要擅长这项技能的人。毫无疑问,编程是一项有用的技能。Python 是一种很好的编程语言。

然而,我不认为黑客是一个伟大的编程方法。对此,我给出了三个理由。首先,黑客专注于新的、困难的或具有挑战性的问题。就其本质而言,相应的解决方案有一点天才和一点即兴创作的味道。这是一个很好的组合,如果你正在解决以前已经做过的前沿问题。但是如果只是想写普通的程序呢?对我们许多人来说,找到一个可行的解决方案就足够了,即使这个解决方案会很无聊。

第二,黑客有很强的优秀内涵。黑客通常被认为是一个精英,一个需要不言而喻的技能水平才能加入的群体。但是在编程中,有很多问题并不需要专家来解决。通常,一个普通的程序员就足够了,黑客可能会感到厌烦。从商业角度来说,成千上万的程序员走过的路比只有少数人选择或未知的路风险要小得多。

第三,不是每个人都想投身于黑客事业。除了详细了解计算机如何工作,我们许多人还有其他事情要做;我们有数据要理解,有网站要创建,有生意要经营,有家庭要照顾。在我看来,编程太重要了,不能把它留给一小群专注的专家。我写这本书的一个原因是,我想打破界限,让更多的人可以使用编程。在我看来,每个人都能编程,而且每个人都能做好。除了黑客还有其他编程方式。不过,我们可以从黑客文化中学到很多东西:它产生了非常有用的技术和工具,以及对卓越和编程的热爱。

软件工程

反对黑客,我们发现软件工程。软件工程关注的是构建软件的系统方法,通常是在公司环境中。软件工程不是关注个人,而是控制构建程序的整个过程:它的技术包括精确地找出要构建什么,设计一个具有明确定义的组件的程序,验证程序实际上是否正确工作,以及最后,一旦程序被使用就对其进行维护。我们也可以从软件工程中学到很多东西。例如,背景研究已经对类似前面列表中的问题所引起的工作进行了研究。根据你引用的哪项研究,我们发现软件总成本中只有三分之一是初始开发;剩下的就是保养了。在最初的三分之一中,只有 25%-50%用于编写代码;剩下的就是计划、调试、测试和维护。

软件工程方法的缺点是它对于大多数 Python 项目来说太大了。软件工程方法变得有用的典型项目的时间范围从半年到几年,有时甚至几十年。通常,我们选择 Python 是因为它有可能在几天、几小时、有时几分钟内取得结果。此外,软件工程项目通常涉及数十或数百人,数千到数百万行代码,以及覆盖数千页的文档。如果你的目标更适中,我们需要寻找一种更轻松的方法。如果您想了解更多的背景信息,那么伊恩·萨默维尔(Addison-Wesley,2000)的《软件工程》一书是一个很好的起点。

敏捷

软件工程是一种相当繁重的方法论的概念并不新鲜。当问及替代方案时,2017 年你经常会听到的答案是敏捷。敏捷是一种致力于改进软件开发过程的哲学。它的核心价值观是个人和互动、工作软件、客户协作和应对变化(另见 www.agilemanifesto.org/ )。敏捷(及其最普遍的表现形式,Scrum 和 XP)促进了程序的快速、渐进的开发和短迭代的工作(见图 1-2 )。以小的工作增量构建程序对全世界的程序员产生了巨大的积极影响。

了解敏捷是有用的,因为它为高效编程提供了哲学基础。它将编程中的人的因素放在第一排。本书中描述的许多工具都是根据敏捷原则和实践开发的。这给我们带来了敏捷方法的局限性:敏捷是一种哲学,而不是一套工具。敏捷告诉我们为什么我们可能希望以某种方式编程(快速创建工作软件并让客户满意),但它没有告诉我们在键盘前应该做什么。此外,敏捷框架在实践中通常很难实现。例如,Scrum 框架被限制在五到九个人,它需要组织的大量投入。此外,敏捷过程有时被采用主要是因为它们时髦的名字。在实践中,遵循明确定义的流程和盲目遵循规则手册之间只有一线之隔,需要经验和常识来找到正确的平衡。

软件工艺

成为天才黑客很有帮助。拥有一个设计良好的软件蓝图会有所帮助。拥有一个动态的面向客户的过程也会有所帮助。但事情的根本是我们在电脑前做的工作。承认这项工作是一门叫做软件工艺的学科的基础。软件工艺承认编程的很大一部分是由需要完成的简单任务组成的。要做好它,我们需要有合适的工具,我们需要有合适的技能,我们需要在实践中应用这两者。编程是一门手艺,就像石工、木工或糖果业一样,这意味着

  • 目标很重要。我们创造程序是为了达到目的。编程不是一门艺术;我们想写一些有用的程序。
  • 规划很重要。这是工作中有用且必要的部分(测量两次,切割一次)。
  • 工具很重要。我们需要保管好我们的工具,保持工作场所整洁。这门手艺帮助我们选择合适的工具。
  • 技能很重要。我们一直在努力改进我们的工艺。我们努力尽可能地编程,同时承认我们的技能并不完美。
  • 社区事务。有一个由志同道合的人组成的大社区,他们以这门手艺为荣。这个社区是学徒和师傅的天堂。
  • 大小并不重要。我们不把自己局限于某一类型的项目。无论我们写一个五行程序还是为一个大项目做贡献,这种技巧都是需要的。
  • 练习很重要。我们无法单独在白板或电子表格上解决编程问题。为了成功编程,我们需要亲自动手。

对于 Python 程序员来说,软件工艺是一个有用的比喻。因此,这本书是建立在软件工艺的理念之上的。我们将在本书中看到的最佳实践已经被我们之前的许多软件工匠尝试过、测试过,并且发现是有用的。

这本书是给谁的

你已经掌握了 Python 的基础,自己编写 Python 程序也有一段时间了。您可以安全地应用列表、字典和集合等数据结构,并且能够编写自己的函数。也许你已经开始使用 Python 类和由多个模块组成的程序编写面向对象的程序了。随着编程技能的提高,你的程序变得越来越大。你会发现越大的程序越难调试和测试,并且有崩溃的趋势。为了编写 100 到 10,000 行代码的可靠 Python 程序,解决这些问题的技术可能是有用的。你可能已经意识到编程不仅仅是写代码。知道所有的命令并不足以让程序工作。你可能也已经意识到 Python 的世界是巨大的。有太多的工具和库可以帮助你。然而,要找出哪一个值得一试却很难。写这本书是为了帮助你找到下一步该做什么。

这本书是给经常编程,但不是全职软件开发人员的人看的。你可能是一个生物学家,有几百万个 DNA 序列的数据要分析。你可以成为一名记者,使用 Python 从网络上自动获取信息。作为管理大型网络的系统管理员,您可以使用 Python 来提高工作效率。你可以开办编程课程,在那里你可以建立一个交互式网站。无论你做什么,你都是自己领域的专家,都有相关的问题需要解决。这本书旨在提高您的 Python 项目,而无需先学习计算机科学。您希望编写的 Python 代码不仅能以某种方式工作,而且写得很好。在这种情况下,这本关于调试、自动化测试和维护的最佳实践的书将帮助您进一步发展 Python 技能。

这本书是关于什么的

Python 不是一门新语言。它已经存在超过 25 年了。在此期间,出现了大量有助于编写更好的 Python 程序的技术和工具,新的工具也在不断开发。对于相对不熟悉 Python 的人来说,这些巨大的数字很容易让人不知所措。为了提供一些指导,本书将集中讨论三个问题:

  • 我们如何让我们的代码工作?
  • 我们如何检查我们的代码是否有效?
  • 我们如何确保我们的代码在未来能够工作?

我发现这三个问题对我自己的 Python 编程实践至关重要,我也看到许多新的 Python 程序员在这些领域苦苦挣扎。本书接下来的 16 章分为三个部分,每个部分都介绍了回答这三个问题之一的最佳实践。

第一部分:调试

作为程序员,我们的首要任务是让程序运行起来。第一个碍事的是虫子。在本书的第一部分,我们将研究 Python 中出现了哪些类型的错误,或者更准确地说,异常和语义错误,以及如何消除它们。我们将使用科学的方法,一个系统的思维过程,而不是胡乱猜测。调试的最佳实践包括像print这样的工具、产生诊断信息的自省以及用于逐行跟踪代码执行的交互式调试器。不管您编写的程序的类型和大小如何,这些调试的最佳实践都是有用的。

第二部分:自动化测试

一旦我们写了一个程序,我们怎么知道它能工作?当然,我们可以自己手动运行它,但是随着频繁的变化,手动测试变得容易出错和乏味。幸运的是,Python 中的测试很容易自动化。从简单的测试函数开始,我们将添加测试数据,在各种条件下测试我们的程序。我们将把程序的测试组装成一个测试套件。我们将着眼于测试的最佳实践:存在什么样的测试?它们在什么情况下有用?最后,我们将检查自动化测试的优点和缺点。自动化测试将帮助您检查程序中是否存在 bug,从而防止它们在您修复后再次出现。

第三部分:维护

写程序是一回事。让它继续工作是另一回事。维护软件是一个广泛的领域,Python 提供了大量优秀的支持工具。我们从版本控制的维护最佳实践开始,这被认为是任何专业程序员都必须具备的。我们将看到在一个保存良好的 Python 项目中,文件和文件夹是如何构造的。两章处理清理代码和将编程问题分解成更小的部分以使其更容易管理。接下来,我们仔细看看 Python 中的类型,以及我们有哪些选项可以使我们的数据类型更加可靠。在最后一章,我们将使用 Sphinx 工具编写文档。所有这些结合在一起创造了一个健康的生态系统,你的程序可以在其中茁壮成长。

更多好处

阅读这三个领域的最佳实践的好处是双重的:首先,您将学习工具和技术本身,以便您可以在日常编程实践中应用它们。第二,您将获得许多有经验的 Python 程序员认为重要的最佳实践的概述。了解他们有助于你理解其他开发人员的工作。它还将帮助你自己评估,哪些实际问题可以通过本书中没有涉及的其他技术来解决,以及它们是否对你有用。

马泽伦游戏

在我第一个游戏编程项目的痛苦经历多年后,编程变得容易多了。在一个多任务操作系统中,一个崩溃的程序并不需要我们重启所有的程序。我们有舒适的编程语言和强大的处理图形、输入设备和声音效果的库。在网络上,我们有一个无处不在、几乎无限的信息流。在过去的几年里,我利用这种情况,成功地用 Python 编写了许多小游戏(主要是为了我自己的乐趣)。在这本书里,我们要用 Python 写游戏 MazeRun。这个游戏的特色是一个人在迷宫中吃出一条路来。玩家在一个由方形瓷砖建成的景观中移动一个类似奶酪轮子的人物,吞食圆点并试图避开在迷宫中游荡的鬼魂。游戏的想法可以很快在一张餐巾纸上勾画出来(见图 1-3 )。

A419627_1_En_1_Fig3_HTML.jpg

图 1-3。

Sketch of the MazeRun game—the Python example we will use throughout this book

我之所以确信 MazeRun 是一本关于最佳实践的书的绝佳范例,有许多原因:

  1. 这个游戏很容易理解。你们大多数人可能都玩过吃点游戏。
  2. 我们可以将游戏实现为一个简短的 Python 程序,不需要数千行代码。
  3. 编写游戏很快变得复杂。我们需要组织数据、图形、用户界面、性能、并发事件等等。
  4. 细节很重要。小故障会妨碍游戏的可玩性,甚至使游戏无法玩。
  5. 编写电脑游戏是软件开发人员的共同特点。即使你还没有写过任何游戏,你也很可能玩过一些。当然,你对这个主题有足够的了解来评估一个游戏是否有效。对于我喜欢的其他主题(例如,“模拟 RNA 三维结构的算法”),情况可能不是这样。
  6. 您可以使用代码示例来创建自己的游戏。
  7. 二维水平易于创建插图,可以放在一本书里(我也想创建一个关于在时空连续体中旅行的游戏,但四维图像太难打印)。

我们将逐步编写 MazeRun,一次几行 Python 代码。因此,游戏将逐章改进。不过,在我们准备开始之前,我们需要在下一部分准备一些技术上的东西。

这本书怎么用?

为了充分利用本书中的解释和代码示例,我强烈建议您自己下载并执行代码。为此,您需要注意四件事:

  1. 安装 Python 3
  2. 安装 Pygame 库
  3. 安装文本编辑器
  4. 下载源代码示例

所有章节都假设你正在一个 Ubuntu Linux 系统上工作。除了少数例外,本书中的工具应该也能在 MacOS 和 Windows 上运行。但是,我没有测试过这些操作系统,也不会有任何针对这些操作系统的具体安装说明。在下文中,你会发现四个要点的详细提示。

安装 Python 3

要执行程序,需要 Python 3.5 或更高版本。在 Ubuntu 上,默认安装 Python 2。一些例子在早期的 Python 版本上会失败,我不会告诉你是哪一个。为了避免以后的挫折,我建议您安装一个最新的 Python 版本

sudo apt-get install python3

如果该方法由于任何原因失败,请从 www.python.org 下载并安装一个最新的 Python 解释器。我鼓励您安装 IPython shell 以及

sudo pip install ipython

IPython 将在许多方面让您的生活更加轻松。在许多章节中,我们将安装额外的 Python 库和一些额外的工具。使用 pip 和 apt-get 可以轻松地安装它们中的大多数。在相应的章节中给出了精确的说明。

安装 Pygame 库

因为马泽润游戏将建立在 Pygame 库( www.pygame.org )之上,所以你也需要安装它。安装 Pygame 没有其他 Python 库方便。您需要从 https://bitbucket.org/pygame/pygame/overview 下载适合您系统的文件,解压该文件,并从解压后的目录安装

python setup.py install

安装文本编辑器

要查看或编辑 Python 源代码,您需要一个带有 Python 语法高亮显示的文本编辑器。你可以使用你最喜欢的文本编辑器。例如,Sublime,PyCharm,Anaconda Spyder,Emacs,甚至 vim 都是完美的。如果你在 Windows 上工作,空闲编辑器或 Notepad++是 Python 的好编辑器。请不要使用 gedit 甚至记事本来查看 Python 代码,因为在两者中编辑源代码的能力都非常有限。

下载源代码示例

本书中使用的源代码在 Github 上完全可用。通常,每一章都有单独的文件,每一部分都有完整的版本。您可以从 GitHub 仓库下载源代码:

https://github.com/krother/maze_run

右侧有一个按钮,可以下载整个源代码作为.zip文件。使用git程序的更优雅的方法将在第十二章中介绍。这些文件包含一个主文件夹,maze_run,,其中包含整个游戏,以及几个章节文件夹中的个人chapters的例子。代码是在 MIT 许可的条件下发布的,这给了你很大的重用代码的自由。因此,它与大多数其他许可证兼容,对代码的私人或教育用途没有任何限制,包括再分发。请注意,无论是本书的作者还是出版商,都不会对您使用代码所做的任何事情承担任何责任。请看执照。TXT 文件,以获得准确的法律术语。

完成这些步骤后,我们就可以开始了。让我们深入了解 Python 的最佳实践!

二、Python 中的异常

有一次,我们不小心在标题中多加了一个零:“2000 辆坦克被毁。”当局非常生气。—1941 年我祖父在印刷厂工作时的回忆

只要一个程序包含一行程序代码,这一行就可能包含缺陷——这是迟早的事。如果缺陷是当我们的代码没有做预期的事情时,调试就是修复这些缺陷。这比听起来更复杂。调试意味着几件事:

  • 我们知道一个正确的程序应该做什么。
  • 我们知道我们的程序有缺陷。
  • 我们承认缺陷需要修复。
  • 我们知道如何修复它。

对于很多细微的 bug 来说,前三点都不是小事。然而,当 Python 程序中出现异常时,情况是相当明显的:我们希望异常消失。因此,在这一章中,我们将集中讨论最后一点:如何修复我们已知的缺陷。我们将在后面的章节中处理其他问题。

例外是我们知道的缺陷

很少有程序在第一次尝试时能顺利运行。通常,在事情开始工作之前,我们至少会看到一条错误消息。当我们在 Python 中看到错误消息或异常时,我们知道我们的代码有问题。重复使用第一章的比喻,如果我们的程序是一栋建筑,一个异常将意味着房子着火了(图 2-1 )。因为 Python 异常通常是因为缺陷而发生的,所以是否存在 bug 几乎没有疑问。所以这种缺陷比较容易调试。

A419627_1_En_2_Fig1_HTML.jpg

图 2-1。

If a program were a building, an Exception would be a fire. There is no reason to run away from an Exception. At least we know it’s there.

例如,我们将为 MazeRun 游戏准备图形。我们将使用 Pygame 从图形瓷砖构建一个图像。这些瓷砖有 32 × 32 像素大,我们可以将它们巧妙地组合起来,以构建关卡和移动的对象。所有图块都在一个图像文件中,如图 2-2 所示。我们需要读取图像文件,并将所有方形图块存储在 Python 字典中,以便我们可以轻松地访问它们,例如使用字符作为关键字:

A419627_1_En_2_Fig2_HTML.jpg

图 2-2。

Tiles we will use to create graphics for MazeRun. We want to create a dictionary, from which we can access tiles by single characters (e.g., represent the wall tile in the top-left corner with a #). The image itself is in the XPM format (tiles.xpm. The format allows Pygame to handle transparency easily, although other formats may work equally well.

tiles = {
    '#': wall_tile_object,
    ' ': floor_tile_object,
    '*': player_object,
}

在编写创建字典的代码时,我们将看到 Python 中的典型异常。在本章中,我们将研究三种简单的异常调试策略:

  1. 读取错误位置的代码
  2. 理解错误信息
  3. 捕捉异常

在这样做的同时,我们将有希望从总体上了解缺陷的本质。

阅读代码

一般来说,Python 中的异常分为两类:执行代码前引发的异常(SyntaxErrors)和执行代码时引发的异常(所有其他)。只有在 Python 没有找到任何SyntaxError的情况下,才开始逐行解释并执行代码。从那时起,可能会出现其他类型的异常。

句法误差

最容易修复的 Python 异常是SyntaxError及其子类型IndentationError。在这两种情况下,Python 都无法正确地解释或标记 Python 命令,因为它写得很糟糕。在执行任何代码之前完成标记化。因此,语法错误总是第一个出现的错误。出现SyntaxError的原因大多是不同种类的错别字:遗忘的字符、多余的字符、用错地方的特殊字符等等。让我们看一个例子。我们通过导入 Pygame 开始准备我们的牌组:

imprt pygame

我在编程的第一天就看到了一条恼人的消息,这条消息很可能在我的最后一天也会看到:

File "load_tiles.py", line 2
    imprt pygame
               ˆ
SyntaxError: invalid syntax

有一个拼错的import命令 Python 不懂。这可能也发生在你身上。在这种情况下,只需阅读错误消息中的代码,看看问题出在哪里。我们可以通过一直读取错误消息中指出的代码来确定缺陷吗?为了找到答案,我们需要查看更多的异常。第二个常见的SyntaxError是由缺少括号引起的。假设我们尝试在图像中定义一系列图块及其 x/y 索引:

TILE_POSITIONS = [
    ('#', 0, 0), # wall
    (' ', 0, 1), # floor
    ('.', 2, 0), # dot
    ('*', 3, 0), # player

这段代码在你输入 Python 的那一刻就爆炸了:

SyntaxError: unexpected EOF while parsing

在错误消息中,Python 并没有给我们多少关于右方括号丢失的线索。它到达文件的末尾并退出。但是,如果我们在文件中添加另一行,例如,以像素为单位的图块大小:

SIZE = 32

错误消息变为

  File "load_tiles.py", line 11
    SIZE = 32

SyntaxError: invalid syntax

请注意,回溯指示列表后的行,这与丢失的括号无关。只是碰巧挡了道。Python 程序员需要学会快速识别丢失括号的症状。此外,一个好的编辑会为我们计算括号,并在一个括号似乎丢失时委婉地指出。我们可以从一个SyntaxError来识别缺陷,但是描述往往不准确。缺少括号的一个更令人不安的方面是,如果我们忘记了开始括号,我们会得到一个完全不同类型的异常:

TILE_POSITIONS = ('#', 0, 0), # wall
                 (' ', 0, 1), # floor
                 ('.', 2, 0), # dot
                 ('*', 3, 0), # player
                ]

这一行失败于

IndentationError: unexpected indent

Python 不知道为什么第二行在这里缩进。注意,只有当第一个列表项从赋值行开始时,我们才会得到一个IndentationError;不然又是一个SyntaxError。这类缺陷非常常见,但通常是最容易修复的。

Conclusion

Python 代码中类似的缺陷会导致不同的错误消息。

除了缺少括号之外,IndentationError也是由于错误的缩进造成的。如果我们用冒号(:)表示一个新的代码块,但是忘记缩进,就会出现错误的缩进。如果我们使用的空格比前一行多了一个或少了一个,就会出现错误的缩进。如果我们在文件的某个地方使用制表符而不是空格,最糟糕的缩进情况就会发生,因为我们无法在视觉上区分它们。这可以通过使用为编写 Python 代码而设计的编辑器来避免。幸运的是,错误缩进的症状往往很明显(见图 2-3 ,我们可以通过检查错误消息中的行号来确定一个IndentationError的位置。

A419627_1_En_2_Fig3_HTML.jpg

图 2-3。

An IndentationError if programs were buildings

调试语法错误的最佳实践

通过仔细阅读错误消息中指出的代码行,通常可以修复SyntaxError或其子类型IndentationError。该策略有点类似于朱利叶斯·凯撒著名的“veni-vidi-vici”:我们首先转到代码中指示的行(veni),然后我们查看该位置的行(vidi)并修复问题(vici)。在实践中,许多异常可以用这种策略在很短的时间内解决。对SyntaxError最常见的修复如下:

  • 首先查看错误消息中指定的行。
  • 看它正上方的线。
  • 将有错误的代码块剪切并粘贴到一个单独的文件中。语法错误在剩下的内容中存在吗?(其他误差还可以。)
  • 检查命令if, for, def,class后是否缺少冒号。
  • 检查是否缺少括号。如果有一个好的编辑,很容易找到它们。
  • 检查不完整的引号,尤其是在多行字符串中。
  • 注释错误消息中指示的行。误差有变化吗?
  • 检查您的 Python 版本。(你是在 Python 3 中使用不带括号的print吗?)
  • 使用每当按 Tab 时插入四个空格的编辑器。
  • 确保您的代码符合 PEP8(参见第十四章)。

检查错误消息

在上一节中,我们使用了“veni-vidi-vici”策略来修复SyntaxErrors,实际上我们已经足够仔细地查看了错误消息中的行。这个策略对所有的 bug 都有效吗?鉴于我们前面还有五章关于调试的内容,可能不会。让我们看一个稍微复杂一点的例子。为了创建一个图像,我们将创建一个字典来查找矩形块进行复制。这些长方形是 pygame。矩形对象。我们在助手函数get_tile_rect()中创建矩形,在函数load_tiles()中创建图块字典。这是第一个实现:

from pygame import image, Rect, Surface

def get_tile_rect(x, y):
    """Converts tile indices to a pygame.Rect"""
    return Rect(x * SIZE, y * SIZE, SIZE, SIZE)

def load_tiles():
    """Returns a dictionary of tile rectangles"""
    tiles = {}
    for symbol, x, y in TILE_POSITIONS:
        tiles[x] = get_tile_rect(x, y)
    return tiles

现在,我们可以调用函数并尝试从字典中提取墙砖(缩写为' # '):

tiles = load_tiles()
r = get_tile_rect(0, 0)
wall = tiles['#']

然而,执行这段代码会导致一个KeyError:

Traceback (most recent call last):
  File "load_tiles.py", line 32, in <module>
    wall = tiles['#']
KeyError: '#'

无论我们如何仔细观察第 32 行,我们都没有发现从tiles请求一个' # '有什么问题。这就是我们的字典应该如何工作。而如果缺陷不在第 32 行,我们可以从逻辑上推断,它一定在别的地方。

Conclusion

错误信息中给出的位置不一定是缺陷的位置。

怎样才能找到缺陷?为了获得更多信息,我们将仔细查看错误消息。阅读 Python 产生的错误消息并不困难。Python 中的错误消息包含三条相关信息:错误类型、错误描述和回溯。让我们浏览一下:

错误类型

从技术上讲,错误消息意味着 Python 引发了一个Exception。错误类型指示引发了哪个异常类。所有异常都是Exception类的子类。在 Python 3.5 中,总共有 47 种不同的异常类型。您可以查看例外的完整列表

[x for x in dir(__builtins__) if 'Error' in x]

图 2-4 中的图表显示了这些类之间的层次关系。您可以看到许多错误类型都与输入/输出有关。同样耐人寻味的是,有四个独立的类别与 Unicode 相关。

A419627_1_En_2_Fig4_HTML.jpg

图 2-4。

Hierarchy of Python Exceptions. The figure shows the inheritance for 34 of the 47 Exceptions in Python 3.5. Fine-grained types, mostly subclasses of IOError , were left out for clarity.

从 Python 3 开始,Unicode 将字符拼写错误的可能性提高了几个数量级。对于有经验的 Python 程序员来说,了解可能的异常类型以及它们的含义是坚实的背景知识。在我们的例子中,KeyError是一个明确的暗示,表明我们试图在字典中查找不存在的东西。

错误描述

错误类型后面的文本向我们描述了到底是什么问题。这些描述有时非常准确,有时不准确。例如,当调用带有太多或太少参数的函数时,错误消息会给出准确的数字:

TypeError: get_tile_rect() takes 2 positional arguments but 3 were given

这同样适用于解包元组失败的情况。在其他情况下,Python 礼貌地告诉我们,它不知道哪里出了问题。大多数NameError s都属于这一类。用我们的KeyError,我们得到的唯一信息就是人物'#'。一个有经验的开发人员的内心声音会很快自动完成这个任务

  • 亲爱的用户:
  • 谢谢你最近的命令。我按照您的指示尝试从tiles字典中提取值' # '。但是翻了一遍之后,我找不到了。我到处都找遍了,但它不在那里。你确定你没把条目放在别的地方吗?真的很抱歉,希望下次能做得更好。
  • 永远属于你,蟒蛇

追溯

回溯包含代码中发生异常的准确信息。它包含以下内容:

  1. 执行的代码的副本。有时我们会立即发现这里的缺陷。这次不会。
  2. 发生错误时执行的行号。缺陷一定是在代码行本身或者在之前执行的代码行中。
  3. 导致错误的函数调用。你可以像阅读事件链一样阅读我们的回溯:“模块调用函数 X,函数 X 调用 Y,而 Y 又因异常而失败。”您可以在回溯的不同行中看到这两个事件。当阅读较长的回溯时,从底部开始阅读。这并不意味着错误的原因总是在底部。但通常它会给我们一个提示,告诉我们去哪里寻找问题。

扣除

为了追踪我们的KeyError,我们可以推断:如果键'#'不在字典中,那么这个键是否被写过呢?钥匙写在哪一行?那条线打通了吗?在代码中有几个地方,流入tiles字典的数据可能会被中断。检查load_tiles功能时,您可能会注意到分配了错误的键。执行该任务的命令是

tiles[x] = get_tile_rect(x, y)

虽然它应该是

tiles[symbol] = get_tile_rect(x, y)

阅读和理解错误信息有助于识别缺陷。即使缺陷更加复杂,错误信息通常会给我们一个寻找问题来源的起点。然而,我们注意到一些推论是必要的,因为异常出现在与有缺陷的行不同的行中。有许多可能导致相同症状的缺陷。有时我们需要检查几个位置。在一个简短的代码片段中,我们可以直观地应用演绎,并检查缺陷的多个候选位置。在第四章中,我们将看到一个更系统的方法。

捕捉异常

一旦导入和tiles字典开始工作,我们就可以尝试加载带有图块的图像:

from pygame import image, Rect

tile_file = open('tiless.xpm', 'rb')

该操作失败,并显示以下无关紧要的消息:

FileNotFoundError: [Errno 2] No such file or directory: 'tiless.xpm'

缺陷是一个拼写错误的文件名。A FileNotFoundErrorIOError的子类。FileNotFoundError的所有兄弟姐妹都是处理数据时非常常见的错误。在一些项目中,似乎我一半的错误是IOErrors。幸运的是,这是一个精确的错误消息,几乎没有解释的余地。这个错误可以通过仔细检查代码中的路径和文件名来修复。为了修复缺陷,我们需要找出文件的真实位置,然后重新检查代码中的拼写。有时我们会因为讨厌的细节而需要几次尝试:绝对和相对路径,缺少下划线、破折号,最后但同样重要的是,窗口上的反斜杠需要用 Python 字符串中的双反斜杠(\)来表示。导致IOError的缺陷几乎完全是一个错误的文件名。

很明显,我们不能防止每一个 Python 异常。我们还能做什么?一种可能性是对程序内部的异常做出反应。我们试图做一个手术,意识到它可能会失败。如果失败,Python 将引发一个异常。有了try.. except结构,我们可以做出特定的反应。捕捉异常非常有用的典型情况是用户输入文件名:

filename = input("Enter the file name: ")
try:
    tiles = load_tile_file(filename)
except IOError:
    print("File not found: {}".format(filename))

使用except语句,我们相应地对特定类型的异常做出反应。这种策略被称为 EAFP,“请求原谅比请求许可更容易。”“请求原谅”意味着对异常做出反应,“请求许可”意味着在试图打开文件之前检查文件是否存在。更容易,因为提前检查每一件可能出错的事情既不可能也不可取。Python 开发人员 Alex Martelli 指出,捕捉异常是对无效输入或配置设置做出反应并对用户隐藏异常的一个很好的策略。捕捉异常对于在终止程序之前保存重要数据也很有用。但是捕捉异常也受到了相当多的批评。Joel Spolsky 是软件开发领域的一位著名权威,他说:

  • 原因是我认为异常并不比“goto’s”更好,后者自 20 世纪 60 年代以来就被认为是有害的,因为它们创建了从代码的一个点到另一个点的突然跳转。

事实上,异常通过程序的路径在代码中是不可见的。通过观察 Python 函数,我们看不到它内部可能会引发什么异常,也看不到任何引发的异常会在哪里停止。因此,考虑所有可能的执行路径变得非常困难,这更容易引入额外的缺陷。此外,我们需要小心决定捕捉哪些异常。当然,try.. except的以下用法是个糟糕的主意:

try:
    call_some_functions()
except:
    pass

这种结构被称为尿布模式。它捕捉一切,但过一会儿你就不想往里面看了。它让异常消失了,但是反而产生了一个更糟糕的问题:异常被覆盖了,但是我们诊断正在发生什么的可能性也被覆盖了(比较图 2-5 )。最佳实践是只在明确定义的情况下使用try.. except,并且总是捕捉特定的异常类型。

A419627_1_En_2_Fig5_HTML.jpg

图 2-5。

If Exceptions were a fire in the building, this is what except: pass would look like. We extinguish the Exception for sure, but is that really what we want?

调试 IOErrors 的最佳实践

因为 IOErrors 很常见,而且对初学者来说非常烦人,所以在这里列举最常见的应对策略也无妨:

  • 在终端或文件浏览器中找到文件的确切位置。
  • 打印程序中使用的路径和文件名。和真的比较一下。
  • 查看当前工作目录(import os; print(os.getcwd ()))。
  • 用绝对路径替换相对路径。
  • 在 Unix 上:确保您拥有对相关文件的访问权限。
  • 使用os.path模块为您处理路径和目录。
  • 在你前进的道路上要小心反斜线!您需要用正斜杠(/)或双反斜杠(\)来替换它们,以获得正确的分隔符。

错误和缺陷

在本章中,我们已经看到了三种处理导致异常的缺陷的策略:通过查看代码来调试它,通过查看错误消息来调试它,以及捕捉异常。让我们总结一下我们的观察:有时错误消息直接指向缺陷(例如,a SyntaxError)。也有很多例子,错误消息足够精确,可以将缺陷缩小到几种可能性(例如,一个IOError)。其他的错误信息更加模糊,需要有经验才能明白是什么意思。你可能会发现本章给出的典型应对策略很有帮助。但是我们也看到了缺陷是如何远离错误信息中的位置的。在这种情况下,错误消息没有多大帮助。就像甲骨文一样,它提供了一个提示,但是仅仅通过查看错误消息是不可能定位缺陷的。在 Python 中,通常有许多可能导致相同错误的缺陷,这种情况对于TypeErrorValueErrorAttributeErrorIndexError来说非常常见。另一个棘手的情况是,如果我们将错误的数据输入到像pygame.Rect;这样的库函数中,结果我们将从库中得到一个异常,即使缺陷在我们自己的代码中。在这些情况下,我们需要考虑错误消息中的所有信息:代码中的位置、错误类型和回溯。在许多情况下,这些信息足以定位缺陷,这是一个很好的直观调试策略。

try.. except沉默异常怎么办?捕捉异常是处理超出我们控制的异常情况的一个很好的策略:例如,无效的输入数据或错误的文件名。但是异常处理不足以修复程序中已经存在的缺陷。一个程序不会因为假装一切都是正确的而工作得更好。但是try.. except向我们展示了我们可以管理程序抛出的错误,即使我们没有意识到潜在的缺陷。我们将在下一章进一步探讨的一个结论是,错误和缺陷是截然不同的。错误是我们观察到的东西,是出了问题的征兆。另一方面,缺陷隐藏在代码的某个地方。要修复代码,我们需要首先找到潜在的缺陷。找到缺陷就成了一个演绎问题。

缺陷从何而来?

为什么我们首先要引入缺陷?我们程序中出现缺陷的原因是多方面的。为了成功地调试,有必要知道缺陷来自哪里。以下是我如何制作大部分 Python bugs 的:

  • 首先,在实施过程中出现了错误。代码在我的脑海中是正确的,但是在到文本编辑器的路上出错了:一个丢失的冒号,一个拼错的变量,一个忘记的参数。或者我忘记了如何使用一个函数并添加错误的参数。这些缺陷中的大部分会在早期失效,通常会有例外。
  • 第二,糟糕的计划产生了更多微妙的缺陷。代码在我的头脑中已经是不正确的:我选择了一个不合适的方法,忘记了一个重要的细节,以至于我最终解决了一个与我最初打算的不同的问题。这种缺陷比较难识别。通常的结果是,我可以重新开始编写一段代码。测试是尽早发现糟糕计划的好策略。
  • 第三,糟糕的设计间接导致缺陷。每当我写了多余的代码,很少注意清理我的代码,或者没有记录我正在做的事情,后来对程序的修改更有可能产生不正确的程序。为了避免这样的问题,我们需要维护软件项目的最佳实践。
  • 最后,还有潜在的人为因素。当第一次使用一个语言特性或库时,当与其他程序员交流困难时,当匆忙或疲惫地编写程序时,缺陷就会蜂拥而至。除了前面提到的实践,对自己的能力保持谦虚的态度也很有帮助。不要盲目地相信你的代码,因为它会包含更多的缺陷。

Python 不是最容易调试的语言。Python 中的动态类型会导致非常普通的错误消息,需要进行解释。在其他语言中,编译器提供更精确的消息(和更多的错误)来帮助我们生成可执行代码。另一方面,Python 给了我们许多与代码近距离互动的可能性,近距离检查缺陷。这使我们能够一次解决一个问题,并在出现异常时将其消除。如果我们想让我们的程序正确运行,我们需要利用这一点作为优势。因为仅仅查看错误消息是不够的,我们将需要查看其他调试技术来调试甚至发现更具挑战性的缺陷。在转向系统调试的方法之前,我们将在下一章进一步研究缺陷的本质。

正确的代码

在我们开始下一章之前,有必要完成加载图块的代码。有了导入、瓷砖列表和调试好的load_tiles函数,我们可以添加几行代码来组成三个瓷砖的图像。以下是完整的代码:

from pygame import image, Rect, Surface

TILE_POSITIONS = [
    ('#', 0, 0), # wall
    (' ', 0, 1), # floor
    ('.', 2, 0), # dot
    ('*', 3, 0), # player
]

SIZE = 32

def get_tile_rect(x, y):
    """Converts tile indices to a pygame.Rect"""
    return Rect(x * SIZE, y * SIZE, SIZE, SIZE)

def load_tiles():
    """Returns a dictionary of tile rectangles"""
    tile_image = image.load('tiles.xpm')
    tiles = {}
    for symbol, x, y in TILE_POSITIONS:
        tiles[symbol] = get_tile_rect(x, y)
    return tile_image, tiles

if __name__ == '__main__':
    tile_img, tiles = load_tiles()
    m = Surface((96, 32))
    m.blit(tile_img, get_tile_rect(0, 0), tiles['#'])
    m.blit(tile_img, get_tile_rect(1, 0), tiles[' '])
    m.blit(tile_img, get_tile_rect(2, 0), tiles['*'])
    image.save(m, 'tile_combo.png')

代码产生了一个图像,我们可以把它作为概念的证明,我们现在可以用瓷砖组成更大的图形(见图 2-6 )。也许 tile 的例子已经鼓励你自己去尝试,这样你很快就会有很多机会去调试你自己的错误信息。代码存储在文件maze run/load tiles.py中,可从 https://github.com/krother/maze_run 获得。

A419627_1_En_2_Fig6_HTML.jpg

图 2-6。

Successfully composed tiles

最佳实践

  • 导致错误的错误代码被称为缺陷。
  • 一些异常有精确的错误信息,可以通过检查代码来修复。
  • 在错误消息指示的位置发现了一些缺陷。
  • 一些缺陷远离错误信息中给出的位置。
  • 错误消息由错误类型、描述和追溯组成。
  • 演绎是一种确定错误根本原因的策略。
  • try.. except捕捉异常是一种处理特定情况和错误类型的策略。
  • 总是将except与特定的异常类型一起使用。
  • 千万不要在pass里面使用except
  • 错误和缺陷是明显的。

三、Python 中的语义错误

你试过关机再开机吗?—IT 人群,电话答录机上的帮助台消息

为了让迷宫游戏变得有趣,我们需要迷宫。玩家角色将在这些迷宫中移动,吃点,并被怪物追逐。在这一章中,我们将编写一个程序来产生这样的迷宫。当实现那个程序时,我们将到达一个程序几乎工作的点。您可能对这种现象很熟悉:您已经处理了第一次测试运行中出现的所有异常。然而,程序仍然没有做它应该做的事情。可能还是满满的语义错误。

当程序运行时没有引发异常,但给出了与预期不同的结果时,就会发生语义错误。潜在的缺陷通常比导致错误消息的缺陷更难消除。Python 异常提供的信息可能不太好,但至少给了你一个起点。由于语义错误,我们经常从较少的信息开始。尽管如此,语义错误的原因有时非常简单:错别字、省略的行、语句顺序错误等等(另见图 3-1 )。

A419627_1_En_3_Fig1_HTML.jpg

图 3-1。

Semantic errors can be hard to spot at first glance but have severe consequences

在这一章中,我们将研究 Python 中的语义错误。为此,我们将故意在代码中引入缺陷,看看会发生什么。通过这样做,我们将试图理解是什么导致了语义错误,以及它们是如何表现的。任何列出所有可能的语义错误的尝试都是徒劳的。不存在像例外那样的分类。相反,我们将从重复出现的缺陷中抽象出来,看看我们能在这个过程中学到什么。

比较预期产出和实际产出

在前一章中,我们很容易判断一个程序是否产生错误:要么有错误信息,要么没有。Python 解释器告诉我们有一个错误。语义错误的情况不太明显。我们察觉到了一个错误,因为程序做了一些与我们预期不同的事情,但是 Python 认为一切正常。问题可能首先从我们的期望开始:它们可能不够清晰。有时候,期望是显而易见的:如果我们写一个程序来增加两个数字,并输入11,我们期望看到结果是2。没有多少讨论的余地。让我们考虑一个更具挑战性的例子。

我们生成迷宫的程序到底要做什么?它将生成一个由墙砖(#)和用圆点填充的走廊组成的矩形网格。)构成迷宫。现在你可能已经有了迷宫应该是什么样子的清晰图像。根据这个描述,一个软件开发团队可能会想出如图 3-2 所示的迷宫。

A419627_1_En_3_Fig2_HTML.jpg

图 3-2。

Four possible outputs of an imprecisely described maze generator

这些迷宫中哪些符合你的预期,哪些不符合?你以前都没想到的是什么?对于不同的程序员来说,对一个编程问题的相同描述会导致非常不同的结果,这是很常见的。可以理解的是,他们都会相信自己的解决方案是正确的。而且都是,因为前面的描述不严谨!

为了区分什么是语义错误,什么不是,我们需要精确地描述对于给定的输入,我们期望什么样的输出。计算机需要精确性,但我们也需要精确性,因为如果我们过于频繁地改变我们对程序应该做什么的看法,就不可能完成它。

让我们的期望更精确的一个简单方法是预先写下一个样本输入和预期输出。这可以在列出输入/输出值的表格中完成。让我们在程序中确定这两者:

  • 输入:两个整数值 x 和 y,指定网格块中迷宫的大小。

  • Output: A random maze consisting of a grid of x * y tiles. The maze is surrounded by a frame of wall tiles (#). It contains an interconnected network of connected corridors separated by walls and filled up with dots (.). No room should be wasted by filling it up with walls. A 12 x 7 maze could look like the one in Figure 3-3.

    A419627_1_En_3_Fig3_HTML.jpg

    图 3-3。

    Example output of a more precisely described maze. Although small, this is what we really need.

在开始编程之前,写下输入和预期输出通常是个好主意。有更复杂的方法来规划更大的软件,但是从一张纸上的简单表格开始就足够了。

Warning

可能会发生这样的情况,您的项目没有足够精确地被指定来写下输入和预期的输出。这是一个非常严重的障碍,因为很可能你的任何程序都将解决错误的问题。当这种情况发生在你身上时,你有两个选择:第一,立即停止编程,找出你到底应该做什么(更彻底的计划)。第二,用最少的努力找到一个最小的解决方案,把它暴露给现实,并准备好抛弃一切(原型)。Python 对两者都有好处。

缺点

识别输入和预期输出使我们能够通过比较预期和实际输出来识别语义错误。发现错误是寻找错误或缺陷原因的起点。寻找缺陷是调试的主要内容。通过演绎发现缺陷通常是困难的。为了了解缺陷和语义错误的本质,我们将从另一端着手处理这个问题:我们将在代码中引入典型的缺陷,看看会发生什么。Python 中有哪些典型的缺陷?我们观察到的相应误差是什么?缺陷是如何导致错误的?

变量赋值中的缺陷

我们从产生最终迷宫的函数开始考虑。作为输入,这个create_grid_string()函数使用一个 Python 集合,其中包含点的位置和网格的 x,y 大小。作为输出,它以字符串的形式返回迷宫。通过使用集合,我们可以确保每个位置只出现一次。当我们用一组样本点位置调用函数时:

dots = set(((1,1), (1,2), (1,3), (2,2), (3,1), (3,2), (3,3)))
create_grid_string(dots, 5, 5)

我们期望以下网格:

#####
#.#.#
#...#
#.#.#
#####

下面是create_grid_string()函数的一个可能实现:

def create_grid_string(dots, xsize, ysize):
    grid = ""
    for y in range(ysize):
        for x in range(xsize):
            grid += "." if (x, y) in dots else "#"
        grid += "\n"
    return grid

如您所见,函数中有三个变量赋值(第 2、5 和 6 行)。让我们考虑当这些变量赋值包含一个缺陷时会发生什么。

多重初始化

第一个变量赋值将grid设置为空字符串。有几种方法做错了。如果我们完全省略这一行,我们将得到一个UnboundLocalError,因为仅仅几行之后就有一个未定义的变量。但是我们也可以,例如,在for循环中的一行之后初始化grid:

for y in range(ysize):
    grid = ""
...

我们得到的不是预期的输出,而是

#####

当我们在循环中初始化变量时发生了什么?变量被多次初始化,在循环的每一轮中初始化一次。初始化意味着我们覆盖先前包含的任何内容。因此,我们看到的五个散列实际上是网格的最后一行。多重初始化是一个非常常见的语义错误,它通常与forwhile循环一起出现。

意外分配

如果我们不小心创建了一个变量赋值,就会出现类似的语义错误。假设我们用=替换+=操作符中的一个。让我们首先考虑+=的第二种情况:

grid = "\n"

最有可能的是,这一行是一个打字错误的结果。结果是我们的输出是一个换行符,结果我们看到一个空输出。这是一种比前一种更令人不安的错误,因为当程序员看到一个空的输出时,他们开始想象错误的可怕可能性。幸运的是,这一行的简短使得这个缺陷更容易被发现。

让我们考虑一下,如果我们也在三元表达式中用=替换+=会发生什么:

grid = "." if (x, y) in dots else "#"

同样,grid变量在循环的每次迭代中被重新初始化。这个缺陷的结果是输出字符串只包含一个散列(#)。这种缺陷的原因很可能是一个打字错误(程序员脑子里有+=但没有到达手指),或者是思考问题时的逻辑遗漏(程序员脑子里一开始就没有浮现+=这一点)。

根据经验,你是否容易发现这种缺陷有很大的不同。这就是为什么一些初学者更喜欢冗长的加法运算符:

grid = grid + "." if (x, y) in dots else "#"

直到他们更容易以同样的精度确定 a +=为止。

偶然比较

一个不太常见的缺陷是无意中引入了比较运算符:

grid == "\n"

由于==和+=的相似性,这个缺陷更难发现。这也很有可能是打字错误的结果。这一行令人讨厌的地方(也是我们在这里拒绝这个缺陷的原因)是它是一个有效的 Python 表达式,尽管它什么也不做。作为缺陷的结果,我们的输出将不包含任何换行符。

######.#.##...##.#.######

看到这个输出,我们需要分两步推断缺陷。首先,我们需要认识到缺少换行符。其次,我们需要检查为什么换行符会丢失。与之前的缺陷相比,这种两步推导在概念上是新的。幸运的是,一旦我们发现了由 rogue ==引起的缺陷,就很容易修复它。

表达式中的错误变量

每当我们使用一个变量时,都有可能不小心用错了。这种缺陷可能也很难推断。假设我们在下面的三元表达式中使用网格大小(xsize, ysize)代替网格位置(x, y):

grid += "." if (xsize, ysize) in dots else "#"

我们用网格大小而不是循环索引(x, y)在集合中搜索元组。因此,if条件永远不会计算为True,表达式永远不会返回“.”当我们执行这段代码时,我们看到一堵没有任何地砖的实心砖墙:

#####
#####
#####
#####
#####

网格大小和换行符是否正确;只有点不见了。推断这个语义错误的关键问题是:在哪一行没有正确引入点字符?

为了弄清楚发生了什么,我们需要在检查三元表达式时知道(xsize, ysize)是什么意思。否则这条线看起来很好。这个缺陷很难被发现。假设我们将函数的参数命名为 xs 和 ys。区分(xs, ys)和(x, y)比区分(xsize, ysize)要难得多。参数被命名为xsizeysize的一个原因是,它们与函数中使用的其他变量有很大的心理距离(参见史蒂夫·麦康奈尔所著的《代码全集》(第二版)。,微软出版社,2004 年])。

表达式中的交换文字

我们将在create_grid_string()函数中探测的最后一个缺陷是交换三元表达式中的两个字符串。我们写的不是正确的答案

grid += "#" if (x, y) in dots else "."

我们获得反相输出并不奇怪。这个输出看起来更像直升机着陆点,而不是迷宫:

.....
.#.#.
.###.
.#.#.
.....

涉及交换、交换和价值倒置的缺陷非常普遍。我们可以在三元表达式、条件块或简单的布尔运算符中得到类似的效果。请注意,这种缺陷很容易与其他缺陷重叠。在一个简单的场景中,我们也可以通过添加一个额外的not操作符来反转 if 条件:

grid += "#" if not (xsize, ysize) in dots else "."

之后输出再次正确。当我们有两个以上的值可以交换时,或者我们在代码中做出一个以上的决定时,这种缺陷就变成了一个真正的挑战。

我们在前面的变量赋值中发现的所有缺陷都会影响函数返回的数据。这同时也是我们节目的主要产出。从缺陷到输出的路径相当短。在一些缺陷中,缺陷可以被直接发现,但是在另一些缺陷中,需要一点推理。在所有情况下,我们通过将实际输出与预期输出进行匹配,直接观察到了错误。

索引中的缺陷

列表、元组、集合和字典的索引为索引错误之外的缺陷提供了大量的机会。这里,我们只看两个有代表性的例子。要创建一个迷宫,我们需要生成所有可能出现点的位置,除了网格的边界。当用这些位置划分出一个 5 × 5 的网格时,预期的输出是以下网格:

#####
#...#
#...#
#...#
#####

这可以使用列表理解来实现。我们定义相应的函数来生成位置:

def get_all_dot_positions(xsize, ysize):
    return [(x,y) for x in range(1, xsize-1) for y in range(1, ysize-1)]

我们使用结果列表作为create_grid_string()函数的输入,该函数生成正确的预期输出:

positions = get_all_dot_positions(5, 5) print(create_grid_string(positions, 5, 5))

创建错误的索引

有数不清的可能性,我们可以把列表的索引打乱一个位置。在我们的例子中,这归结为为range()函数设置正确的参数。我们可以将开始和结束索引都设置得过高或过低。表 3-1 包含四个缺陷和产生的网格。

表 3-1。

Wrong Start and End Indices for Range

| `range(0, xsize)` | `range(1, xsize)` | `range(0, xsize-1)` | `range(2, xsize+2)` | | `.....` | `#####` | `....#` | `#####` | | `.....` | `#....` | `....#` | `#####` | | `.....` | `#....` | `....#` | `##.##` | | `.....` | `#....` | `....#` | `#####` | | `.....` | `#....` | `#####` | `#####` |

Python 计算索引的方式与人类不同。这就是为什么指数通常是违反直觉的。当我们用数字索引创建或分割列表时,所有这四种可能性——甚至更多——都会出现。我们并不总是拥有数据的可视化表示。其实值得指出的是,我们这里观察到的误差就是create_grid_string()产生的网格。然而缺陷在get_all_dot_positions()中。

两者是如何联系的?当然,错误一定出在我们用来创建网格的变量positions中——集合包含了错误的索引。这意味着,错误首先是由range中的缺陷引起的,然后在程序中不可见地传播,最后导致输出错误。我们可以说错误已经通过我们的程序传播了。在我们的例子中,错误从get_all_dot_positions()函数传播到主程序,再从那里传播到create_grid_string()函数。

使用错误的指数

要创建一个路径相连的迷宫,我们需要识别网格中的相邻位置。在 2D 网格中,每个位置正好有八个相邻位置。对于一个给定的位置x, y,我们可以定义一个函数get_neighbors(),返回一个包含八个相邻位置的列表:

def get_neighbors(x, y):
    return [
        (x, y-1), (x, y+1), (x-1, y), (x+1, y),
        (x-1, y-1), (x+1, y-1), (x-1, y+1), (x+1, y+1)
        ]

这个函数返回一个可以被create_grid_string()使用的索引的list。我们可以使用该函数的输出来生成网格:

neighbors = get_neighbors(2, 2)
print(create_grid_string(neighbors, 5, 5))

位置2, 2的邻居的预期输出是一个类似甜甜圈的形状:

#####
#...#
#.#.#
#...#
#####

这个代码示例说服你了吗?在前面的代码中,有一些严重的设计缺陷。让我们考虑一下可能出错的地方:

首先,一个缺陷可能会增加或减少get_neighbors()中的任何一个索引,就像前面的例子一样。这不可避免地破坏了甜甜圈的形状。我们已经知道这个缺陷,并且可以用类似的方法推导出来。

第二种缺陷是元组中的xy可以交换,这样我们将得到(y, x+1)而不是(x, y+1)。然而,当我们引入这个缺陷时,我们看不到任何影响。甜甜圈还是完整的。但是让我们试着计算不同位置的邻居,例如3, 2:

neighbors = get_neighbors(3, 2)

我们得到了一艘罗慕伦战舰的中微子信号,而不是甜甜圈:

#####
##...
##.#.
##.#.
##.##

这是非常非常坏的消息!通过用一个x替换一个y,,我们引入了一个缺陷,这个缺陷有时会导致错误,有时不会。当然,我们检查的代码示例本身设计得很糟糕:当我们将一个正方形放置在另一个正方形的中心时,我们不应该对插入xy无关紧要感到太惊讶。然而,我们需要记住这个错误的性质:我们将会看到更多的缺陷,这些缺陷有时会传播成错误,有时不会。为了排除错误,先验地识别程序的所有可能的参数组合是非常困难的。

此外,代码本身也容易出现这种错误。get_neighbors()函数非常难读;列表元素的含义不清楚,很难看出是否所有的逗号、减号和括号都在正确的位置,以及列表元素的顺序是否正确。例如,以下版本的列表甚至没有包含一个正确的条目:

def get_neighbors(x, y):
    return [
        (x, -1), (y, x+1), (x-(1), y), (x+1), y,
        (x,(-1, y)), (x+1, y, 1), (x-1, y+1, x+1, y+1)
        ]

代码仍然会执行。当然,结果是一场灾难:

#####
#####
##.##
#####
##.##

这是设计缺陷的一个例子。即使代码工作正常,缺陷也很容易出现。因此,除了调试之外,我们还需要考虑如何使代码更加健壮(和可读),以便更容易发现缺陷。

控制流语句中的缺陷

如果我们写错了控制流语句会怎么样?在像if, for,while,这样的控制流语句中,我们会遇到各种有趣的缺陷。我们将在迷宫生成算法中研究它们。迷宫生成的简单算法如下:

  1. 创建网格中所有职位的列表。
  2. 从列表中随机选择一个位置。
  3. 如果该位置有多达四个相邻的点作为邻居,则将该位置标记为一个点。
  4. 否则,将该位置标记为墙。
  5. 重复步骤 1-4,直到位置列表为空。

值为 5 时,该算法将构建包含很少开放区域的平衡迷宫,并且所有走廊都相互连接。此外,它在外墙留下了一个圆形的路径,这对于逃离鬼魂来说是非常好的。

实现可能如下所示:

def generate_dot_positions(xsize, ysize):
    positions = get_all_dot_positions(xsize, ysize)
    dots = set()
    while positions != []:
        x, y = random.choice(positions)
        neighbors = get_neighbors(x, y)
        free = [nb in dots for nb in neighbors]
        if free.count(True) < 5:
            dots.add((x, y))
        positions.remove((x, y))
    return dots

Tip

算法并不完美。如果您自己尝试该算法,您可能会注意到它偶尔会产生无法访问的区域。这是另一种设计弱点,因为它不规则地发生。对于我们的目的来说,这个过程已经足够好了,但是如果你想开发一个更健壮的迷宫生成器,试试吧。这也是一个很好的调试练习。

布尔表达式中的缺陷

ifwhile都包含布尔表达式。在这两种情况下,表达式中的缺陷会改变接下来执行的命令。例如,在比较相邻点的数量时,我们可能会意外地使用大于号(>)而不是小于号(<):

if free.count(True) > 5:

布尔表达式永远不会计算为True。随着错误的传播,没有位置被标记为点,结果我们获得了一个只有墙的网格。

同样结局的一个缺陷是省略了free.count中的函数调用。不幸的是,对于许多编程学徒来说,下面是 Python 2.7 中的一个合法表达式:

if free.count < 5:

尽管前面的表达式在 Python 3.5 中引发了一个异常,但那里也存在类似的缺陷。例如

if "ABC".lower == 5:

在设计类似于while语句中的显式条件时,需要多加小心:

while positions != []:

这与前面语句的简化版本不同:

while positions:

两种表达都可以。然而,前者容易改变positions的类型(例如,到一个集合或字典)。通常,控制流语句中布尔表达式的各种缺陷会改变执行顺序,并可能创建不可达的代码。

压痕缺陷

在 Python 中,缩进改变了代码的含义。因此,我们可以通过错误的缩进产生语义错误。在while循环中发现了一个非常常见的情况。考虑我们将调用positions.remove的行分隔四个空格,这样它就与return语句在同一层:

    ...
    if free.count(True) < 5:
        dots.add((x, y))
positions.remove((x, y))
return dots

作为缺陷的结果,列表positions永远不会空运行,并且while循环永远不会结束。我们通过删除四个空格创建了一个死循环。这是一种新的语义错误,因为程序永远不会到达输出阶段。误差无限传播。这就引出了一系列有趣的理论问题(我们能证明程序真的包含缺陷吗?)和实际问题(我们需要等多久才能知道出了问题?).在本例中,我们通过按下Ctrl+C来终止这些问题和程序。

你也可以用for构建无限循环。如果我们遍历一个列表并在循环中追加到那个列表,就会发生这种情况。一个更常见的缺陷是一行被删除,即使它应该在一个for循环中,反之亦然。这样的for循环通常会结束,但会产生错误的结果。这种缺陷的影响通常更加微妙。

使用函数的缺陷

在本章的最后一节,我们将看看与函数相关的三个缺陷。对于高级程序员来说,这些都是微不足道的缺陷,但是我已经看到了如此多的初学者与它们作斗争(特别是如果他们只从微积分中知道函数的话),所以我觉得有必要简要地说明它们。为了完成我们的程序,我们编写了一个简短的函数create_maze,它使用到目前为止编写的代码来生成一个迷宫:

def create_maze(xsize, ysize):
    """Returns a xsize*ysize maze as a string"""
    dots = generate_dot_positions(xsize, ysize)
    maze = create_grid_string(dots, xsize, ysize)
    return maze

maze = create_maze(12, 7)
print(maze)

让我们看看如何破解这个代码!

省略函数调用

首先,我们可以写下函数的名称,但由于省略了括号而忘记调用它:

maze = create_maze

当使用不带参数的函数时,例如str.upper(),这种情况更容易发生。结果是,我们最终在结果变量中找到了函数本身。在maze中,我们以这样的方式结束:

<function create_maze at 0x7efdf7427598>

缺少返回语句

第二,返回语句return maze可能会丢失。由于这个缺陷,这个函数没有把它的结果传递给主程序。相反,它返回None,这也是我们在输出中看到的:

None

忘记一个return是一个不一定会产生异常的缺陷。通常,默认返回值None会让程序优雅地结束(例如,当您将结果解释为布尔值时)。结果,错误继续传播。

不存储返回值

第三,我们可以调用函数,但不对结果做任何事情(例如,将结果存储在变量中)。该调用如下所示:

create_maze(12, 7)

在这一点上,初学者通常会感到困惑。如果他们看不到输出,并且认为应该打印一些东西,那么第一次尝试可能是

create_maze(12, 7)
print(maze)

这当然引出了一个NameError。如果变量maze同时出现在函数和主程序中,问题会变得更糟。一旦函数终止,函数中的maze就会失效。这是一个很好的例子,说明为什么在不同的地方使用相同的变量名是一个坏主意。我认为处理这种缺陷的正确方法是亲自体验一个像这样的简单代码示例,看看让代码工作的不同方法。我希望在你自己的键盘上尝试了这些例子后,你会少一个需要处理的问题。

这个例子说明了初学者在熟悉函数时面临的三个常见问题。除此之外,前面几节中列举的所有问题也可能出现在函数中。特别是,缩进和名称空间缺陷更容易被发现。即使你写得很好,函数产生了正确的结果,更大的问题仍然是如何设计好函数。这确实是一个很好的问题,因此我们将把它留到第十五章。

错误传播

在梳理常见的语义错误时,我们做了一些观察。首先,语义错误和潜在的缺陷是明显的。它们可能在同一位置,也可能不在同一位置。这与我们在第二章中查看异常时发现的缺陷是一致的。

第二,缺陷在程序中传播。这是什么意思?在图 3-4 中,我们可以看到错误传播中的事件链。代码中的缺陷会导致部分数据出错,或者调用错误的函数。该程序将继续运行。然而,缺陷引入的错误会在程序中传播。根据缺陷的不同,它最终会扩散或完全消失。当且仅当缺陷导致的东西到达输出时,我们才有机会将其视为语义错误。错误传播也会导致异常。在这种情况下,事件链不需要到达输出。

A419627_1_En_3_Fig4_HTML.jpg

图 3-4。

Error propagation. A defect causes a chain of events that travels through the program execution. If the chain manifests in the output, we can observe the error. Note that multiple propagation paths can exist, including such that never lead to an observable error. The figure was inspired by the work of Andreas Zeller . Image covered by the CC-BY-SA License 4.0.

Andreas Zeller 对缺陷和错误传播进行了非常清晰和准确的描述,远远超出了本章的范围。你可以在 Udacity ( https://www.udacity.com/course/softwaredebugging–cs259上的软件调试课程中找到解释,在他的书《为什么程序失败:系统调试指南》(Dpunkt Verlag,2006)中也有描述。

当我们发现一个缺陷时,我们能得出什么结论?理想情况下,缺陷是简单地以错误的方式编写的单行代码。然后我们修改那行代码,程序就工作了。但是还有很多其他的可能性:

  • 我们必须编写更大部分的代码来修复缺陷。
  • 我们知道错在哪里,但是解决方案与程序编写的方式不兼容。我们需要首先重组代码。
  • 不同地方的几个缺陷重叠,我们只观察其中一个。修复一个缺陷只会把我们引向下一个错误。下一个。
  • 该缺陷是由两条线路引起的,这两条线路结合起来传播成一个错误。他们中的任何一个都可能被修复。
  • 两个缺陷相互部分补偿。修复其中一个似乎会让问题变得更糟。
  • 输入是我们之前没有想到的特例。我们需要决定这个特殊情况是否需要包含在这个项目中。

有许多不同种类的语义错误,从容易发现和容易修复的到可能需要几个小时或几天调试的真正的脑筋急转弯。还有一些真正不好的缺陷,指出了我们的算法方法、数据结构或程序架构中的一个主要弱点。最糟糕的是那些暴露出我们没有正确理解我们正在解决的问题的缺陷。在后一种情况下,我们可以想怎么编码就怎么编码,这一点帮助都没有。

缺陷离输出越远(在执行时间、处理的操作或代码行方面),就越难发现。我们大部分的调试工作旨在将图 3-4 中的空间分割成更小的块。我们可以通过尝试较小的输入来分割数据。这就是为什么我们从一个 5 × 5 的迷宫开始,而不是更大的。或者我们可以通过检查程序的较小部分来分割操作。决定先尝试什么的方法是下一章的主题。

最佳实践

  • 没有导致错误消息的缺陷被称为语义错误。
  • 为了首先确定是否存在语义错误,首先写下给定输入的预期输出会有所帮助。
  • 一些导致语义错误的缺陷可以通过查看输出,然后查看代码直接识别出来。
  • 一些缺陷需要演绎来找出是什么导致了语义错误。
  • 有些缺陷不会直接导致输出错误。它们在程序中传播,直到在输出中导致错误。
  • 除了缺陷之外,代码还可能包含设计缺陷,这使得代码更容易添加错误或更难发现现有的错误。

四、使用科学的方法调试

我有这方面的背景。我不认为任何事情是理所当然的。—匿名

在前两章中,我们已经看到了 Python 中一些经常出现的缺陷。这不仅有助于我们识别和修复类似的缺陷,我们还知道缺陷可能会在程序中传播,并且诊断可能不明显。现在是我们把注意力转向修复更困难的错误的时候了。我们如何修复一个从未见过的错误?在这一章中,我们将系统地分析 MazeRun 游戏控件中的一个 bug。

在任何游戏中,玩家都有一个机制来控制正在发生的事情。在我们的例子中,控件非常简单:玩家使用箭头键移动一个图形。因为许多准游戏程序员很久以前就有同样的想法,所以 Pygame 提供处理键盘和鼠标事件的基础设施也就不足为奇了。我们将使用该基础结构来编写一个事件循环。事件循环应持续检查新的关键事件。Pygame 使用函数pygame.event.pump()在内部准备事件,使用函数pygame.event.poll()检索事件。然后,事件循环将按下的键发送到执行动作(例如,移动图形)的自定义函数。事件循环的代码如下所示:

from pygame.locals import KEYDOWN
import pygame

def event_loop(handle_key, delay=10):
    """Processes events and updates callbacks."""
    while True:
        pygame.event.pump()
        event = pygame.event.poll()
        if event.type == KEYDOWN:
            handle_key(event.key)
        pygame.time.delay(delay)

if __name__ == '__main__':
    pygame.init()
    event_loop(print)

我们将print作为回调函数传递给event loop,这样可以直接看到玩家输入的按键。因为回调函数是作为参数传递的,所以稍后我们可以很容易地用不同的函数替换它。然而,当我们运行这段代码时,什么也没有发生。完全没有键盘输出。我们发现了另一个语义错误。在这一章中,我们将使用一种系统的方法,即科学的方法,来追踪潜在的缺陷。科学方法是编程中的一个关键的最佳实践,首先因为它提供了一个其他调试技术适用的概念框架,其次因为它完美地补充了我们将在本书后面看到的测试和维护的最佳实践。

运用科学方法

在第二章中,我们能够通过查看生成的错误消息来追踪错误的原因。在这些错误消息中,发生错误的行和缺陷的位置通常是不同的。然而,这些都是相对简单的缺陷。这种缺陷通常可以通过非系统的猜测来解决:您查看症状,查看您的代码,尝试一些可能的解决方案,并(有希望地)解决问题。

通过第三章中的语义错误,我们了解到缺陷会在程序中传播,最终导致错误。当错误传播的性质或缺陷本身变得更加复杂时(对必须修复缺陷的人来说是复杂的,而不是绝对复杂),猜测策略将彻底失败。在一个复杂的缺陷中,症状和潜在的缺陷通过一个长的因果链联系在一起。仅仅更努力地看代码和尝试更多的猜测会很快让程序员筋疲力尽(见图 4-1a )。猜测策略失败的主要问题是,我们很少(如果有的话)获得关于缺陷的新信息。

A419627_1_En_4_Fig1_HTML.jpg

图 4-1。

Suggestive comparison of a) trying to guess the source of the defect and b) systematically testing hypotheses with the scientific method

科学方法是解决未知问题的最佳实践。简而言之,我们不是只关注解决方案,而是通过收集确凿的证据,尝试首先确定缺陷的来源。它在许多方面都优于查看代码的直观想法。类似于你在科学教科书中发现的,科学方法由五个步骤组成(也见图 4-1b ):

  1. 观察:我们从观察我们想要改变的程序行为开始。
  2. 假设:我们表达一个想法,一个假设,为什么观察到的行为会发生。
  3. 预测:基于我们的假设,我们做出一个可测试的预测,如果假设是正确的,我们的程序还会做什么。
  4. 测试:我们通过将程序暴露在实验条件下并观察结果来检验我们的预测。
  5. 总结:最后,我们根据结果接受或拒绝我们的假设。除非我们找到了缺陷的原因,否则我们会回到第二步,进一步完善我们的假设——或者提出一个全新的假设。

科学方法把一个猜测问题变成了一个演绎问题。严格遵循,科学方法很容易跟踪甚至复杂的缺陷。它优于猜测,还因为它导致更干净的解决方案和更容易维护的代码。有四种技术值得了解,它们将帮助我们在实践中有效地将科学方法应用于调试。这些是

  • 复制缺陷
  • 自动化缺陷
  • 隔离缺陷
  • 获得帮助

我们将在跟踪事件循环中的 bug 时遇到这四个问题。

Tip

给自己设定一个快速解决问题的时间限制。有许多缺陷,你不需要应用科学的方法。如果您看到一个错误,并在几个简单的测试后知道发生了什么,您可能可以马上修复这个缺陷。根据经验,如果 10-15 分钟后你还没有发现缺陷,那么是时候转向系统化的方法了。

重现错误

我们首先观察到我们的事件循环根本不产生任何输出(如果您在控制台上看到一些奇怪的字符,这些字符来自 Unix,而不是来自我们的程序,因为print应该在每个键后产生一个换行符)。在制定任何假设之前,我们需要确保这是一个持久的问题,而不是一个临时或随机的条件。为了收集证据,我们至少需要第二次运行程序,并检查我们是否得到相同的观察结果(我们得到了)。

再现性是成功调试的先决条件。如果我们能复制一个缺陷,我们就能找到它。如果我们能找到它,我们就能修好它。如果我们不能重现错误,我们就是在追逐幽灵——一场永无止境的 bug 搜索。因此,重现错误是调试中最基本的最佳实践。在某些情况下,错误可能很难重现。自然,包含随机数的程序会产生不可预测的结果。例如,第三章中的迷宫生成器每次都会创建一个不同的迷宫。大约五分之一的迷宫包含不可接近的区域(例如,被墙壁包围的单个点)。如果我们想消除这些问题,那么多次重新运行程序并扫描输出会很麻烦。使用默认的随机数生成器,这很容易解决。为了使我们的生成器生成的迷宫可重现,我们需要用一个种子值初始化随机数生成器。尝试在调用和不调用random.seed的情况下运行以下程序几次:

import random
random.seed(0)
print(create_maze(12,   7))

为了更好地调试,使用种子值使程序的行为可预测是完全可以的。当一个系统变得更复杂时,误差通常变得更难重现。这是两台或多台计算机相互通信的常见情况。在 web 和服务器编程中,涉及到过多的设备和协议,导致失败的原因有很多。缺陷可能会因为 HTTP 超时而出现,但另一次不会。缺陷可能会出现在生产服务器上,但不会出现在测试服务器上。当 web 流量很大时,当可用内存很少时,当用户快速点击网页时,只有在星期三,等等,缺陷可能会出现。最严重的这类错误在被检测时会改变它们的行为,因此被称为海森伯格,借用了量子力学的名称。在找到导致 Heisenbug 的缺陷之前,期望从日志文件和监控工具中收集大量关于程序生态系统的信息(这可能非常简单)。

尽管事件循环在可再现性方面似乎没有任何问题,但我们不确定是否所有的键都受到了影响。为了使我们最初的观察更精确,我们制定了第一个假设:键盘上没有一个键产生输出。假设允许一个直接的预测:如果我们按下每个键一次,我们仍然看不到输出。这个假设的测试可以用一个手指来完成。事实上,我们仍然没有观察到来自print的任何输出,因此接受了这个假设。我们更精确、可重复的观察变成:键盘上没有一个键在回调函数中产生输出。

Tip

许多初学者面临的一个最常见的不可重现的错误是,他们在自己的计算机上维护同一个程序的两个版本,却不小心运行了错误的版本。当用两个不同的 Python 版本运行同一个程序时,也会发生同样的情况(如果您并行使用 Anaconda 或 Canopy 等 IDE 和手动安装的 Python,就有可能发生这种情况)。如果你在一个简单的程序中遇到一个不可重现的错误,首先检查你的 Python 文件(和你的 Python 解释器)的位置。

自动化错误

有时很难重现一个 bug,因为我们需要手动输入大量信息。严格来说,“大量信息”意味着“我们需要按下不止一个按钮来查看问题是否仍然存在。”尽早实现自动化可以节省大量后期调试时间。自动化也有助于再现性。例如,如果我们的事件循环只在我们以一定的速度打字时才响应,我们可能每次都会看到不同的结果。自动化消除了我们观察中潜在的不确定性来源。

让我们为事件循环制定第二个假设:我们程序中的事件处理通常是中断的。为了通过自动化测试这个假设,我们创建了一个人工键盘事件。如果假设是真的,我们预测我们仍然什么也看不见。在 Pygame 中,使用pygame.event.post()函数生成人工事件非常简单。我们在事件循环的开头插入以下代码:

eve = pygame.event.Event(KEYDOWN, key=42)
pygame.event.post(eve)

当我们重新运行程序时,我们观察到它打印

42
42
42
..

我们观察到我们的程序能够完美地处理我们的人工按键事件。只有物理键会被忽略。因此,我们拒绝这个假设。事实证明,自动化并不适合发现我们的缺陷,但它为我们提供了新的信息:其他一切似乎都正常工作。因此,我们将暂时坚持手动输入。关于自动化还有很多要说的(例如,我们可以创建一个自动测试功能,但是我们会把它保存到第八章)。

隔离缺陷

我们分析的代码越多,可能隐藏缺陷的地方就越多。调试中的一个关键任务是使调试的代码量尽可能少,或者隔离缺陷。我们可以将这本书的大部分内容视为隔离缺陷的不同技术,或者使隔离缺陷变得更容易。这里我们将坚持一个例子:事实上,按下的键没有到达我们的程序代码仍然可以用两种替代的方式来解释。我们可以把它们表述为可供选择的假设:

  • Pygame 安装不正确,因此 Pygame 和物理键盘无法通信。
  • 我们使用 Pygame 的方式是错误的,所以它不会产生一个关键事件。
  • Pygame 产生了事件,但是我们不能正确显示它。

让我们逐一检查这些假设。当面临多种选择时,最佳实践是首先检查较简单的选项。一方面,更简单的替代方案更容易测试,另一方面,它们通常更有可能。在三个可供选择的假设中,第一个是不太可能的(毕竟,我们的人工事件工作得很好,前两章中的例子也工作得很好)。然而,它很容易测试。要 100%确定 Pygame 安装正确,可以在同一个 Python 安装上执行另一个基于 Pygame 的游戏(我推荐 Bub-n-Bros,见 http://bub-n-bros.sourceforge.net/ ,虽然可能会分散注意力)。这是可行的,所以我们可以拒绝第一个假设,专注于剩下的两个假设。第二种选择似乎可行,但难以检验。第三种选择更容易测试。查看代码,我们看到if条件只检查标记为KEYDOWN的事件,并丢弃所有其他事件。可能我们正在寻找的键盘事件有不同的类型。我们可以制定另一个预测:如果我们打印所有事件,不管它们的类型,我们应该看到按下的键。

脱衣策略

为了测试我们的预测并查看所有没有条件的生成事件,我们需要简化代码。我们希望找到重现错误所需的最少的行。一种方法是在 Python shell 中执行代码。另一种是将函数复制到一个测试脚本中,并连续删除行(应该命名为test_event_loop.py)。更糟糕的方法是复制粘贴整个脚本,或者注释掉代码中的一半行。这两种方法都会很快搞乱我们的整个工作场所。

有了测试脚本,我们可以在多次迭代中删除行,试图找到一个错误消失或者只剩下几行的点。移除了if和回调函数后,最小化的事件循环如下所示:

import pygame

pygame.init()
while True:
    pygame.event.pump()
    event = pygame.event.poll()
    print(event)

生成的代码更短,更容易阅读。运行这段代码时,我们会看到无限的消息输出,所有消息都与

<Event(0-NoEvent {})>

我们做的任何事情(按键、点击、移动鼠标、对着你的摄像头做鬼脸)都不会改变这个信息。我们观察到,显然没有 Pygame 事件到达我们的代码,因此拒绝第三个替代假设。通过将我们的程序缩减到六行,其中三行是琐碎的(import, while,print语句),剩下的潜在故障点非常少。我们已经隔离了缺陷。目前唯一剩下的解释是我们使用 Pygame 的方式不对。

Tip

为以后保留这样短的测试脚本是值得的。通常,它们可以被开发成测试函数,我们将在后面的章节中看到。

二分搜索法战略

缩小缺陷位置的另一种策略是使用二分搜索法(见图 4-2 )。如果我们有很多可能隐藏缺陷的代码,这种技术是非常有用的。为了执行二分搜索法,我们将代码分成大小相似的两部分(例如,主要模块或功能)。然后我们检查缺陷在哪个部分传播。然后我们第二次分割那个部分,以此类推(见图 4-3 )。这是一个有效的隔离策略,因为在每次迭代中,剩余代码的大小减少了一半。有了 10 个分区,我们可以将缺陷的来源从数千行代码缩小到一个功能或更少。使用二分搜索法策略的唯一先决条件是缺陷必须在过程中相对容易识别。在事件循环的例子中,这并不容易,因为我们在跟踪一些不存在的东西。在某种程度上,二分搜索法和剥离搜索策略是互补的。

A419627_1_En_4_Fig3_HTML.jpg

图 4-3。

Binary search for isolating bugs: a) initially, the bug can be everywhere; b) code to examine after a first division; c) code remaining after a second division; d) isolated bug location after a third division.

A419627_1_En_4_Fig2_HTML.jpg

图 4-2。

Looking for defects using binary search if your program were a building

获得帮助

在调试过程的这一点上,我们可能会变得疲倦(至少我在这个问题上挣扎了 15 分钟)。似乎在我们的代码中找不到问题的解决方案。我们最近的结论(我们以错误的方式使用 Pygame)并没有告诉我们如何正确使用 Pygame。我们需要帮助。这是一个非常强大但经常被低估的调试策略。承认在问题上投入更多的时间/意志力/咖啡不会有帮助通常是建设性解决问题的关键。至少有五种方法可以获得帮助。这五个都是每个程序员应该记住的最佳实践,尤其是在压力下。

休息一下

有时候,我们看不到解决方案是因为我们累了。这很正常。一双新鲜的眼睛可能是你自己的。因此,通过散步、小睡、午餐、锻炼来暂时摆脱这个问题,可能会产生奇迹,让我们更接近解决方案。如果我们正在解决的问题感觉足够强烈,过夜也有帮助。我们的潜意识会继续为我们工作。我经历过很多次,一个下午看起来令人厌倦和难以应付的问题在新的一天到来时,在五分钟内消失了。

向别人解释这个问题

向同事或其他程序员解释这个问题会有很大帮助。通常,我们会获得以前没有想到的新鲜想法。解释这个问题不需要看代码。事实上,当我们被迫可以理解地制定我们的思路时,我们可能会在这个过程中自己发现新的方面。令人惊讶的是,如果我们向初级开发人员或非程序员解释这个问题,这种技术同样有效。我经常看到有人在向我解释一个 bug 时中途停下来,而他们自己已经意识到了解决方法。甚至有报道称,程序员会与一只鸭子或一只泰迪熊交谈,以寻找解决方案。就我个人而言,我更喜欢与人交谈,但如果你在隐居中编程,而人是一种奢侈品,那么与熊交谈听起来是一个合理的替代选择。

结对编程

结对编程意味着两个人坐在电脑前一起解决一个问题。我发现在两个人的团队中工作对调试特别有价值。如果有第二双眼睛,就很难忽略事物。此外,它通常有助于避免肮脏的修复。关于结对编程是否普遍比单独编程更有效,存在争议。我不会在这个话题上偏袒任何一方,但我确信,成对解决一个问题是调试代码的好方法。

代码审查

代码审查是让另一个人阅读我们的代码。通常,评论者会问一些天真的问题,指出我们以前没有想到的事情。即使代码审查不包括编辑或修改代码,我们也可以学到很多:一个熟练的审查者会指出实际的缺陷,但也会指出含糊不清的书面陈述,甚至更大的架构弱点。我们也可能在代码审查后找到更有效的方法来使用库或了解值得了解的新技术,或者只是决定更新文档以使代码更容易理解。评审期间的一个可能的活动是一行一行地检查代码。检查每一行的作用以及下一步执行哪一行。如果你一个人做这个,需要非常高的专注力,很累。如果你在两三个人的团队中做,这就变成了一种优越的调试技术。

正式的代码评审(确定会议时间并在会后编写协议)是构建必须满足最高质量标准的软件的既定技术。有一些研究证据表明,代码审查在发现缺陷方面甚至优于自动化测试(然而,相应的研究已经超过 10 年了,并且不包括 Python,所以它有点超出了我们的范围;有关详细信息,请参见 Ian Sommerville,软件工程第 9 版。,皮尔森,2011)。所有类型的代码评审都是为了揭示程序员在开发过程中制造的盲点。

阅读

有时候我们需要退一步,阅读背景资料。这意味着,如果我们正在实现一个算法,我们需要彻底理解它的理论。如果我们使用数据库,我们需要详细了解数据库架构和接口。如果我们正在调试数据记录,很好地理解数据是关于什么的会有所帮助。对于任何库,我们都需要了解它的基础知识。很多调试问题只要做足功课就能解决。阅读不会给我们带来快速的结果,尤其是如果我们现在想修复一个 bug 的话(在 Python 控制台中键入import this看看吉多·范·罗苏姆现在推荐什么)。但是从长远来看,阅读肯定是有回报的。

在我们的事件循环中,最可能的假设是我们以错误的方式使用了 Pygame。那么,什么是正确的方法呢?Pygame 文档是研究这个问题的好地方。当我们在 www.pygame.org/docs/ref/event.html 上查看pygame.event模块的文档时,我们会发现模块内的函数和类的列表。之后,第一段文字是:

Pygame handles all event messages through an event queue. The routines in this module help you manage the event queue. The input queue is heavily dependent on Pygame display module. If the monitor is not initialized and the video mode is not set, the event queue will not really work.

抓住你了!我们已经用pygame.init()初始化了显示屏,但是我们还没有设置视频模式。多读一点关于 www.pygame.org/docs/ref/display.html 上的pygame.display模块很快就引出了pygame.display.set mode()功能。我们可以将它纳入我们计划的主要部分:

pygame.init()
pygame.display.set_mode((640, 400))
event_loop(print)

令人惊奇的是,程序开始工作了。一个黑色背景的额外窗口出现(Pygame 显示),我们的键盘输入出现在控制台上(见图 4-4 )。请注意,Pygame 窗口需要被选中(活动)。我们已经成功地找到了缺陷的根源并消除了它。

A419627_1_En_4_Fig4_HTML.jpg

图 4-4。

Working event loop. The Pygame window, although empty, is essential to make the program work. Reality Check

这个例子有多现实?有人真的会漏掉一个在模块文档的第一段写得很清楚的重要命令吗?第一,这是一个发生在我身上的真实 bug。有那么一会儿,我骄傲地认为在用 Pygame 写了半打小游戏后,我不再需要文档了。第二,我相信同样的事情也会发生在其他人身上。第三,每个程序员关于库和工具的知识都是有限的。有时我们很快意识到我们正在接近知识的边界,但有时我们还是选择走得更远一点。当面临是读材料还是写代码的决定时,我们中的许多人更喜欢写代码。我想这就是我们最初成为程序员的原因。

请注意这个错误主要是基于一个错误的假设。当编写有缺陷的版本时,假设是:我们不需要创建一个从键盘读取的窗口。这个假设被证明是错误的。我们最终意识到 Pygame 库需要自己的窗口来读取键盘事件。吸取教训!调试时,在身边放一个记事本是个好主意。对我们正在考虑的假设做笔记有助于我们在遵循它们的过程中保持专注。此外,在艰难的调试过程中,划掉我们已经拒绝的假设可能是我们在一段时间内得到的唯一满足(见图 4-5 )。在我看来,用来做笔记的纸远远胜过电子记事本。我甚至在桌子上放了一本剪贴簿,记录我在写这本书时故意引入的错误。

A419627_1_En_4_Fig5_HTML.jpg

图 4-5。

Notepad with hypotheses tested on the event loop

清理

即使程序现在处于工作状态,我们还没有完成。我们仍然需要清理我们的建筑工地。这意味着删除我们在过程中引入的任何注释或额外的行,以及我们创建的附加文件。我们可能会决定保留我们的测试脚本,并将它放在我们之前编写的其他测试代码旁边。之后,我们需要再次检查事件循环是否还在工作。在一个更复杂的项目中,清理工作涉及许多其他事情。我们需要重新组织我们的代码来使缺陷的修复干净地适合现有的代码吗?我们需要重写代码来保持一致的风格吗?我们需要编写额外的自动测试吗?该修复是否会影响软件的其他组件?我们需要多长时间在最终产品中加入修正?我们需要更新任何文档吗?我们需要通知团队成员甚至客户吗?等等。

打扫卫生听起来像是一项无聊的任务,但绝对不能推迟。认真处理这些和类似的问题是保持我们项目健康发展的关键。持续忽视清理会直接导致一种令人讨厌的现象,称为技术债务,这是对正在慢慢恶化的软件的官方术语。在我们完成清理之后,我们终于可以运行程序并识别出我们将用于游戏控制的箭头键的代码(见图 4-6 )。

A419627_1_En_4_Fig6_HTML.jpg

图 4-6。

Arrow keys and their key codes produced by Pygame

科学方法和其他最佳实践

在调试过程中应用科学方法是调试程序的一般最佳实践。在应用该方法时,我们可能会使用多种调试工具,就像接下来三章中的工具:使用第 5 中的print,第 6 中的自省函数,以及第七章中的交互式调试器。一旦我们发现了一个缺陷,在修复它的时候,一些最佳实践补充了科学方法:在关于自动化测试的第二部分中,我们将使用技术来证明,一旦修复了,缺陷就不会再出现。在关于维护的第三部分中,我们将学习支持结构,它帮助我们将缺陷的修复与程序的其余部分干净地集成在一起。我们要吸取的教训是,调试不仅仅是查看代码。为了构建可靠的软件,我们需要应用系统化的方法。

最佳实践

  • 通过非系统猜测进行调试只对小缺陷有效。
  • 在科学的方法中,你制定关于缺陷的假设,做出预测,然后测试它们。
  • 根据观察到的测试结果,你接受或拒绝假设。
  • 反复完善假设,直到找到错误的根本原因。
  • 重现错误是成功调试的必要前提。
  • 自动重现缺陷有助于您更快地迭代。
  • 缺陷可以通过剥离代码或代码中的二分搜索法来隔离。
  • 当其他方法都不起作用时,寻求帮助是调试过程中很自然的一部分。
  • 向别人解释这个问题也有帮助。
  • 调试后的清理保持了较低的技术债务。

五、使用打印语句调试

我看到一片黑暗。—威尔·奥德哈姆,同样由约翰尼·卡什演出

在前三章中,我们已经看到了大量的程序错误和导致这些错误的缺陷。我们已经学习了错误传播和作为消除错误的一般方法的科学方法。我们如何将这些知识付诸实践?在这一章和接下来的章节中,我们将收集工具来诊断程序,以便发现缺陷。在这一章中,我们从一个非常简单但功能强大的诊断工具开始:print

我们将诊断的代码是绘制游戏中的图形。我们将把我们生成的迷宫绘制成屏幕上的图像。作为输入数据,我们将使用第三章中基于字符串的表示法。这意味着每当游戏中的一些东西改变时,我们将重新计算图像(对于一个快速的游戏,这不是一个很好的方法,但对现在来说足够了)。在这一章中,我们将编写一个函数,从X * Y个小方块中合成整个迷宫的图像。我们将从几个导入和一个表示随机迷宫的字符串开始:

from pygame import image, Rect, Surface
from load_tiles import load_tiles, get_tile_rect, SIZE
from generate_maze import create_maze

level = """

        ############
        #...#.##.#.#
        #.##.......#
        #....##.##.#
        #.#.##...#.#
        #......#...#
        ############"""

我们首先通过将字符串分成单独的行,将迷宫数据转换成一个列表。为了便于重用,我们将代码包装在一个函数中:

def  parse_grid(data):
     """Parses the string representation into a nested list"""

     return [list(row) for row  in data.strip().split("\n")]

在得到的嵌套列表中,我们可以通过瓦片的行和列索引(即level[y][x]))来寻址瓦片。接下来,我们实现绘制网格本身的函数。我们使用 Pygame 的Surface.blit方法将图像的一部分从一个矩形区域复制到另一个矩形区域:

def draw_grid(data, tile_img, tiles):
    """Returns an image of a tile-based grid"""

    xs = len(data[0]) * SIZE
    ys = len(data) * SIZE
    img = Surface((xs,  ys))
    for y, row in enumerate(data):
        for x, char in enumerate(row):
            img.blit(tile_img,  tiles[char],  get_tile_rect(xs, ys))
        return img

使用第二章中的load_tiles函数,我们可以尝试只用三行画一个关卡。为了清晰和更好的可测试性,我们将把图像写入一个文件:

If __name__== '__main__':
    tile_img, tiles = load_tiles()
    level = create_maze(12, 7)
    level = parse_grid(level)
    maze = draw_grid(level,  tile_img,  tiles)
    image.save(maze,  'maze.png')

当我们执行这段代码时,我们观察到程序没有错误地完成了。它还写入一个图像文件。但是当我们查看图像maze.png本身时,它只是显示 384 x 224 像素的黑暗(图 5-1 )。

A419627_1_En_5_Fig1_HTML.gif

图 5-1。

Output of the buggy function draw grid(). To find out why we see darkness, we need to generate diagnostic information.

程序完成了,但是没有我们预期的那样。我们刚刚发现了另一个语义错误。在本章中,我们将通过添加print语句来诊断我们的代码。对于很多 Python 程序员来说,print是他们的头号调试工具。print解决了调试语义错误的一个主要问题:缺乏信息。

印刷品是收集观察数据的一种非常直接、简单的工具。尽管如此,它也不是没有危险:很容易过度使用print并用诊断语句搞乱你的整个代码。为了避免这样的陷阱,我们将继续严格使用第四章中的科学方法。在这一章中,我们将陈述可以通过在代码中添加print语句来回答的假设。

诊断代码是否被执行

print 最简单的用法是检查一段代码是否已经执行。在我们的例子中,一个原因可能是没有到达for循环。因此,我们制定了以下假设:不执行 for 循环。为了测试这个假设,我们在第一个for之后添加了一个单独的print语句,它的作用是给我们一个生命的信号。我们预测,如果我们的假设为真,我们将看不到消息:

for y, row in enumerate(data):
    print("I'm stuck in Folsom prison.")
    for x, char in enumerate(row):
        ...

实际上,大多数程序员不会在他们的诊断语句中引用约翰尼·卡什的话。下面的内容写起来更快,也更容易在屏幕上看到:

print
("A" * 40)

你可能会在喉科医生那里听到这样的输出:

AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA

无论您在for之后使用哪个print,您都会在输出中看到该消息。print的声明给出了一个来自循环内部的生命迹象。执行for循环中的代码。因此,我们需要拒绝我们的假设。

打印变量的内容

然而,你可能会注意到一些奇怪的事情。我们期望迷宫的每一行都有一条打印线,但是我们只观察到一条。我们需要更多这方面的信息。让我们制定一个新的假设:只处理迷宫的第一行。我们预测y将取值0并且x从 0 到 11(迷宫的宽度)循环。我们将使用print来显示这些变量的值,并检验假设。这一次,我们在内部循环中插入一个print语句来观察它的工作情况:

for y, row in enumerate(data):
    for x, char in enumerate(row):
        print("x={} \t y={} \t {}".format(x,y,char))

这给了我们输出:

x=0     y=0     #
x=1     y=0     #
x=2     y=0     #
x=3     y=0     #
..
x=10     y=0     #
x=11     y=0     #

我们已经发现证据表明内部循环执行了 12 次,外部循环只执行了一次。因此我们接受这个假设。打印变量信息是一个非常强大的诊断工具,因为它易于使用。但是,您需要仔细选择要打印的信息。想象一下,我们将处理一个更大的迷宫(或者更一般地说,一个巨大的数据表)。输出会散落在许多屏幕页面上,我们仍然什么也看不到。要有效地使用print,必须很好地了解 Python 中的格式字符串和字符串方法。

漂亮打印数据结构

寻找只打印第一行的原因,我们可以进一步细化假设。我们应该检查的一点是迷宫数据本身是否有问题。一种可能的解释是迷宫中只有一排(其他的不知何故迷路了)。我们精炼的假设变成:迷宫里只有一排。我们可以通过在函数的开头打印data来测试迷宫的完整性。然而,print(data)的输出可能很长,难以阅读,因为换行符与列表的行不对应。字符串格式在这里没有多大帮助。更好的替代方法是使用 Python 标准库中的pprint模块:

def draw_grid(data, tile_img, tiles):
    """Returns an image of a tile-based grid"""

    from pprint import pprint
    pprint(data)
    ...

这给了我们一个很好的迷宫:

['############',
 '#...#.##.#.#',
 '#.##.......#',
 '#....##.##.#',
 '#.#.##...#.#',
 '#......#...#',
 '############']

漂亮打印适用于列表、字典、集合、元组以及由前者组成的任何数据。有几个选项,最重要的设置是要显示的嵌套数据结构的线宽和深度。

例如,我们可以用pprint (data, depth=1, width=500)强制将迷宫打印在一个超长的行上。pprint模块对于诊断和产生常规程序输出都很有用。

正如我们所看到的,迷宫数据是正确的,并且包含不止一行。我们再次拒绝这个假设。寻找替代的解释,我们迟早会得出一个类似于下面的假设:外部 for 循环退出得太早。通过剥离、追踪或简单地审查程序代码,我们迟早会发现看似无辜的那一行

        return img

该行与内部 for 循环对齐。这意味着,第一行一结束,函数就返回。将语句取消一个级别

    return img

让两个循环迭代正确的次数(前面的一个print语句将证实这一点)。至此,我们已经纠正了代码中的至少一个缺陷,并生成了大量的控制台输出。在继续之前,这是清理代码中打印语句的好时机。

简化输入数据

即使我们刚刚修复了一个缺陷,我们仍然在输出图像中看不到任何东西。显然还有更多缺陷需要修复。因为图像本身是以正确的尺寸创建和书写的,所以最可能的缺陷来源是线条

rect = get_tile_rect(xs, ys)
img.blit(tile_img, tiles[char], rect)

我们的后续假设是:带blit的线用错了坐标。在这一点上,严格使用科学方法可以避免我们迷失在细节中。简单地打印循环中所有图块的坐标很有诱惑力:

rect = Rect((xs, ys, SIZE, SIZE))
print(tiles[char], rect)
img.blit(tile_img, tiles[char], rect)

这会产生一长串 Pygame 矩形对象作为输出:

<rect(0, 0, 32, 32)> <rect(...)>
<rect(0, 0, 32, 32)> <rect(...)>
<rect(0, 0, 32, 32)> <rect(...)>
...

只要我们不知道预期的坐标,这些信息就几乎毫无用处。print产生的输出数量变得势不可挡。我们不知道先检查哪个矩形。手动计算每个矩形的预期输出也不是一个好主意。我们需要另一个调试策略:简化输入数据。

How exactly does blit work?

在测试我们的假设之前,我们需要预测预期的坐标。这次我已经从上一章的错误中吸取了教训,在写代码之前读了一点关于blit的内容。Pygame 方法blit将一个矩形区域从一个图像img复制到第二个图像map_img中的第二个矩形区域。该方法需要三个参数:复制的图像img、目标矩形map_img,和指定源图像哪一部分应该被复制的矩形。Rect对象本身包含四个值:左上角的xy坐标以及矩形的宽度和高度。

从最少的投入开始

使用 Pygame 时,获得正确的矩形坐标是一个反复出现的问题。我总是把它们弄乱。处理一个持续的问题(它已经持续了四页),我们将很高兴看到至少一个瓷砖被正确绘制,而不是空白图像。我们缩小了问题的规模,因为我们只需要一个由单块瓷砖组成的迷宫:

level = ['#']

当我们计算预期的坐标时,目标图像上的矩形应该在(0, 0, 32, 32)处,源矩形也应该在(0, 0, 32, 32)处,因为墙砖在tiles.png的左上角。使用最少的输入将前面的print语句产生的信息量减少到一行:

(<rect(0, 0, 32, 32)>, <rect(1024, 1024, 32, 32)>)

产生的图像仍然是黑色的,只是小得多。当我们将打印的坐标与我们的预期值进行比较时,发现第二个矩形rect是不正确的。我们有足够的证据接受这个假设。错误的坐标从何而来?当我们仔细检查第二章中的get_tile_rect函数时,我们看到它从图块索引计算矩形;例如,(0, 0)用于左上角的图块,(1, 0)用于左起第二个图块,依此类推。因此,需要检查参数xsys。一个精确的假设是:xs 和 ys 是错误的图块索引。在任何情况下,我们都希望索引是(0,0)。我们可以用一条打印语句来检验这个假设:

print("xs={} \t ys={}".format(xs, ys))

这会产生以下输出:

xs=32       ys=32

这与预期的 0,0 相差甚远。因此,我们接受这个假设。原来我们把图像的大小xs和循环索引x搞混了。对这两个变量使用相似的名称可能不是一个好主意。我们需要使用循环索引xy来计算矩形:

rect = get_tile_rect(x, y)

我们不仅观察正确的矩形:

(<rect(0, 0, 32, 32)>, <rect(0, 0, 32, 32)>)

我们还可以在生成的图像上看到单块墙砖(参见图 5-2 )。我们已经修复了另一个缺陷。耶!

A419627_1_En_5_Fig2_HTML.jpg

图 5-2。

Single wall tile

逐渐添加更多的输入数据

现在我们已经让程序处理最少的数据,我们可以再次尝试生成完整的迷宫。但是如果我们用原来的变量代替微观层次,重新运行程序,一切又会一片漆黑。我们又回到了起点!我们的假设仍然是:带有blit的线使用了错误的坐标。同样,我们需要更多的诊断数据。同样,开始检查大输出中的单个位置是非常诱人的(例如,使用条件短语):

# check bottom right tile
if x == 11 and y == 6:
    print(tiles[char], rect)

这是个馊主意!为什么将一个if条件和print结合起来是一个坏主意?首先,它通过添加代码块使我们的代码更难阅读。因为我们可能需要测试不止一个条件,有条件的prints往往会迅速扩散。第二,使用有条件的打印,我们只探查了数据的一小部分。这使得发现全局变得更加困难。偶尔我会使用条件print来限制输出的大小。然而,我更喜欢通过引入一个ZeroDivisionError来完全退出程序的策略(也是肮脏的):

print(tiles[char], rect)
1/0

虽然有点奇怪,但我更喜欢这个短语,而不是sys.exit(0)assert False,因为它写起来更快,而且显然是错误的。条件打印和过早退出都会在调试过程中破坏我们的程序。请将这些工具视为最后的手段。我们将在第七章中看到一种更优雅的探测单值的方式。

在用条件句探索一个巨大的迷宫之前,我们将检查一个几乎最小的输入。什么比单个瓷砖更复杂一点?两块瓷砖!

level = ['##']

作为预期输出,我们预测四个矩形:

(<rect(0, 0, 32, 32)>, <rect(0, 0, 32, 32)>)
(<rect(32, 0, 32, 32)>, <rect(0, 0, 32, 32)>)

我们观察到以下情况:

(<rect(0, 0, 32, 32)>, <rect(0, 0, 32, 32)>)
(<rect(0, 0, 32, 32)>, <rect(32, 0, 32, 32)>)

输出显示了一个奇怪的非墙面瓷砖,旁边还有一个空瓷砖。此外,控制台输出表明矩形的顺序是错误的。因此,我们接受假设并检查代码。原来参数到blit的顺序是错的。它是tiles[char], rect,而它应该是rect, tiles[char].,我们可以完全解释我们的观察结果:两个图块中的第一个被正确绘制,因为两个矩形是相同的。第二块瓷砖覆盖第一块瓷砖(1, 0)(参见图 5-3 )。我们可以通过交换两个参数来修复缺陷:

A419627_1_En_5_Fig3_HTML.jpg

图 5-3。

Full diagnosis of the defect with rectangle coordinates

map_img.blit(img, get_tile_rect(x, y), tiles[char])

运行该程序可以正确绘制两块墙砖。这变得更加令人兴奋:我们现在可以再次切换到完整的迷宫,重新运行程序,保持我们的手指交叉…并最终看到完整的迷宫的所有美丽(见图 5-4 )!

A419627_1_En_5_Fig4_HTML.jpg

图 5-4。

Generated maze image Tip

如果你觉得写自己的小游戏是一个很好的编程练习,但仍然觉得编程图形有点可怕,干脆不要图形了!计算机历史上许多成功的游戏完全基于 ASCII 字符构建的文本或图形。图形在这里主要是因为它们在书中比大量的字符图形看起来更好。

打开和关闭打印输出

在整个调试过程中,我们引入了几行代码来产生诊断信息。在我的副本中,我现在在一个 40 行的程序中有 12 行注释。为了正确清理一切,我们必须删除诊断代码。然而,如果我们知道可能需要再次诊断程序的这一部分,我们该怎么办呢?一遍又一遍地重写同样的print语句?来回编辑我们的代码会带来引入新错误的严重风险。难道没有可能打开和关闭print语句吗?

我们可以从定义调试常数开始:

DEBUG = True

然后我们可以使用DEBUG变量来决定是否应该打印信息。但是如前所述,在我们的代码中加入诊断条件prints并不是一个好主意。想象一下,我们代码中的每个打印语句都被夸大成这样:

if DEBUG:
    print(rect)

一个更好的替代方法是用一个debug_print函数代替 print,这个函数负责检查DEBUG变量并将参数传递给常规的print函数:

def debug_print(*args):
    if DEBUG:
        print(*args)

我们甚至可以将它与一个条件项结合起来:

def debug_print(*args, **kwargs):
    condition = kwargs.get('condition', True)
    if DEBUG and condition:

        print(*args)

debug_print(rect, tiles[char], condition=(x==0))

尽管如此,每当我们想要打开或关闭调试时,我们都必须在代码中编辑DEBUG变量。我们可以添加一个简单的命令行选项:

import sys
DEBUG = "-d" in sys.argv

现在我们可以在调试模式下用

python draw_maze.py -d

随着程序的发展,对诊断输出和命令行选项的处理可以进一步扩展。标准模块loggingargparse为相同的目的提供了更健壮的实现。

完全码

下面给出了完整的工作代码,包括导入和可选的诊断语句:

from pygame import image, Rect, Surface
from load_tiles import load_tiles, get_tile_rect,  SIZE
from generate_maze import create_maze
from util import debug_print

def  parse_grid(data):
     """Parses the string representation into a nested list"""

     return [list(row) for row in data.strip().split("\n")]

def  draw_grid(data,  tile_img,  tiles):
     """Returns an image of a tile-based grid"""

     debug_print("drawing level", data)
     xsize = len(data[0]) * SIZE ysize = len(data) * SIZE
     img = Surface((xsize,  ysize))
     for y, row in enumerate(data):
         for x, char in enumerate(row):
             rect = get_tile_rect(x,  y)
             img.blit(tile_img, rect, tiles[char])
     return img

if __name__ == ' main  ':
    tile_img, tiles = load_tiles()
    level = create_maze(12, 7)
    level = parse_grid(level)
    maze = draw_grid(level, tile_img, tiles)
    image.save(maze, 'maze.png')

使用打印报表的利弊

使用print来诊断我们的代码很容易。这是一种调试策略,即使是 Python 初学者也可以在一两节课之后应用。print允许我们观察工作中的程序,收集信息,并缩小 bug 的来源。这样可以发现很多缺陷。印刷与科学方法和第四章中描述的二分搜索法战略相得益彰。简化输入数据也是如此,这是一种通用的调试策略,并不局限于与print的结合。你可能会说,一旦我们发现第一个语义错误,或者甚至在我们开始编写任何代码之前,我们就应该转向最小化输入数据。这是一个伟大的想法;请加油吧!总的来说,print是一个强大的诊断工具。

然而,从工程的角度来看,将print语句添加到我们的代码中并不是一种优雅的调试方式。首先,我们让程序做一些它原本不想做的事情。从某种意义上来说,我们让代码变得更错误是为了修复它。想象在墙上打洞来检查建筑物是否着火(见图 5-5 )。第二,print语句让我们正在调试的代码变得更难读,输出变得更难读(当打印输出和程序的常规输出混在一起的时候)。第三,我们需要删除之后的每一个打印,这使得调试过程变得乏味并且更容易出错,特别是如果我们为了下一个 bug 而重新插入它们。最后,添加print语句对复杂的 bug 没有多大帮助。有许多缺陷,打印变量值不是很有用。

A419627_1_En_5_Fig5_HTML.jpg

图 5-5。

Debugging with print is a bit like shooting holes into a wall to see what is inside

然而,print是一个非常有效且广泛使用的诊断工具。结合像科学方法这样的系统方法,你可以很好地使用print从中小型程序中收集数据。对于更大的程序,您可能需要更复杂的日志记录或其他诊断基础设施。请注意print不是唯一的调试工具,干净地使用它需要一点训练。

最佳实践

  • print是 Python 中一个强大的诊断工具。
  • print让您观察给定的行是否已经执行。
  • print让您观察变量中的值。
  • 如果限制输入数据,输出更容易解释。
  • 从最小输入开始,然后逐渐增加输入大小。
  • 将条件语句视为最后的手段。
  • 一个DEBUG变量允许打开和关闭打印信息。
  • 使用print语句并不是一种非常简洁的编写程序的方式。应该谨慎使用,通常不太适合较大的程序。
  • 与科学方法或二分搜索法等严格的方法论结合得很好。

六、使用自省功能的调试

不充分的事实总是招致危险。——伦纳德·尼莫伊饰演《星际迷航》第一季第 24 集的斯波克

在前几章中,我们已经写了很多函数,现在可以开始组装了。当使用 Python 函数或模块时,我们经常会面临这样的问题:“我把函数放在哪里了..?",“那个函数返回什么?”,或者“这个模块里有什么?”这些问题都可以用自省来回答。Python 中的自省指的是一组强大的函数,允许您详细检查对象。所提供的详细信息使自检成为调试和编写代码的强大诊断工具。

在本章中,我们将使用几个内省函数来查看 Python 对象的内部。作为一个例子,我们将使玩家的形象四处移动。图形会被墙挡住,并在途中吃点。我们需要的只是一个使用二维迷宫的函数move ( griddirection),以及一个离开(LEFT, RIGHT, UP, DOWN)的方向。让我们将运动实现为随机行走(这样我们还不需要插入事件循环):

if __name__ == '__main__':
    tile_img, tiles = load_tiles()
    maze = create_maze(12, 7)
    maze = parse_grid(maze)
    maze[1][1] = '*'
    for i in range(100):
        direction = random.choice([LEFT, RIGHT, UP, DOWN])
        move(maze, direction)
    img = draw_grid(maze, tile_img, tiles)
    image.save(img, 'moved.png')

程序的结果应该是类似于图 6-1 中的路径。我们将一步一步地为move函数构建代码,而不是扔给你另一个有问题的程序。我们从使用 IPython shell 中的自省函数开始我们的旅程。在这个过程中,我们会在程序中遇到名称空间、文档字符串和类型。

A419627_1_En_6_Fig1_HTML.jpg

图 6-1。

Result of the random walk of the player figure. The arrow has been added to the figure for clarity.

IPython 中的探索性编码

您可能已经熟悉 IPython,这是改进的 Python shell。IPython 是探索性编码的一个很好的工具。探索性编码意味着在沙盒中尝试命令或者使用现有 Python 程序的一部分。它在编写和调试程序时很有用。有许多 IPython 函数支持探索性编码(例如,执行代码,使用运行 shell 命令的Tab ,自动完成,以及浏览名称空间)。因此,使用 IPython 而不是常规的 Python shell 是大多数有经验的 Python 开发人员推荐的最佳实践,无论是作为独立的控制台还是内置到开发环境中,如 Anaconda ( https://www.continuum.io/ )或 entthought Canopy(https://www.enthought.com/products/canopy/)。所有这三种类型的 IPython 都以相同的方式工作,这就是为什么我们将在这里对 IPython 命令进行一个简短的介绍。

我们首先在 IPython 中交互式地编写几行代码来定义一组运动向量(作为(x, y)元组):

In [1]: LEFT = (-1, 0)

In [2]: RIGHT = (1, 0)

In [3]: UP = (0, -1)

In [4]: DOWN  =  (0, 1)

这些命令会像在标准 Python 提示符(>>>)中一样立即执行。新的是,IPython 为我们提供了许多所谓的神奇功能,使我们的生活更加轻松。例如,我们可以通过编写%hist来获得到目前为止我们已经编写的所有命令的列表:

In [5]: %hist
LEFT = (-1, 0)
RIGHT = (1, 0)
UP  =  (0, -1)
DOWN = (0, 1)

%hist命令允许我们将探索性代码复制粘贴到常规的 Python 文件中。这是一个增量编写程序的好策略,每次我们找到一段工作代码时都会保存我们的进度。我们也可以颠倒这种方法。如果我们在 Python 脚本中已经有了这四行,我们可以在 IPython 中通过复制这些行并使用神奇的函数%paste插入它们来执行它们。通过这种方式,我们可以检查代码片段,而无需执行整个模块或使用复制的代码片段创建脚本。%paste功能优于常规的Ctrl+V,因为它更智能地处理缩进。

我们可以使用%paste通过执行小部分代码来检查我们的程序。拥有一个交互式的环境使得一个接一个地测试函数变得容易,当代码改变时替换它们,检查变量的内容。或者,我们可以使用神奇的函数%run <module name>.来执行一个 Python 模块,其结果与我们使用来自操作系统的python <program name>来执行程序是一样的。从 IPython 运行一个程序的好处是,程序创建的所有变量和函数都被保留下来,使得在程序完成后检查它们变得更加容易。

浏览文件和目录

我们可以在 IPython 内部直接使用 Unix 命令如ls, cd,和 pwd。这样我们就可以在不离开 Python 的情况下探索文件和目录(或者使用来自os模块的更详细的函数)。使用 shell 命令极大地方便了识别错误的文件名和路径。它还帮助我们找出可以导入哪些 Python 模块。这里,我们使用ls来列出前面章节中创建的 Python 文件。

In [6]: ls *.py
load_tiles.py
generate_maze.py
event_loop.py
draw_maze.py
util.py

从这个列表中,我们将需要从第二章导入模块load_tiles,从第三章导入模块generate_maze,从第五章导入模块draw_maze,以便在迷宫中执行移动并显示结果。在编写相应的import语句之前,我们需要找出需要从这些模块中导入哪些对象。

Hint

如果您定义了一个名为ls的 Python 变量,前面列出 Python 文件的命令将会失败。在这种情况下,您需要编写!ls来表明您指的是 Unix 命令。事实上,您可以通过在前面添加感叹号来运行任何 Unix 命令:

In [7]: !echo "Hello World"
Hello World

使用 IPython,我们有效地将两个交互环境合二为一:Python shell 和 Unix 终端。如果你还在学习这两者,那么专注于 IPython 可能比编程时切换终端(以及记住在哪个窗口写哪个命令)更容易混淆。

IPython 命令概述

像 Python 一样,IPython 是一个灵活的工具,可以用来试验 Python 代码、执行小程序和运行 shell 命令。它结合了 Python shell 和常规 Unix 终端的优点。这种组合也使 IPython 成为调试的强大基础。

IPython 的功能超出了前面的例子。表 6-1 给出了最重要的 IPython 命令的概述。在 Wes McKinney 的著作《Python for Data Analysis》(O ' Reilly,2013)和 http://ipython.readthedocs.io/en/stable/interactive/magics.html 中可以找到一个优秀的全面的 IPython 教程。如果你喜欢用 IPython 解决小问题或者记录你的努力,你也可以考虑 Jupyter 笔记本( http://jupyter.org /)。笔记本使用 IPython 从 web 浏览器执行 Python 代码,允许您使用格式化文本补充代码,并且可能包含动态生成的绘图、图像,甚至旋转的 3D 分子(参见 http://github.com/arose/nglview )。除了调试,IPython 无论有没有笔记本都广泛用于交互式数据分析。

表 6-1。

Useful IPython Commands That Supplement Introspection

| 命令 | 描述 | | --- | --- | | `?name` | 显示关于`name`的基本信息 | | `?nam*` | 列出以`nam`开头的所有对象 | | `Tab` | 自动完成 | | `pwd` | 打印工作目录(与 Unix 命令相同) | | `cd name` | 更改工作目录(与 Unix 命令相同) | | `ls` | 列出工作目录(与 Unix 命令相同) | | `%run name.py` | 执行 Python 模块 | | `%paste` | 执行剪贴板中的代码 | | `%debug name.py` | 在 Python 模块上运行调试器 | | `%reset` | 清除 IPython 会话的命名空间 | | `%env` | 列出环境变量 |

探索命名空间

我们现在已经知道有哪些模块需要导入。这些模块中定义了什么函数或变量?回想一下,在 Python 中,一切都是对象,我们可以将这个问题从模块推广到所有对象。在这一节中,我们将使用内省来查看 Python 对象的内部。找出给定对象内部的内容是调试过程中的一个常见问题。与其在 IPython 会话中浏览源代码或无休止地向上滚动,不如使用 Python 自己的自省功能。“对象内部是什么”这个问题与名称空间的概念密切相关。什么是名称空间?在 Python 中,对象被绑定到名称,例如,通过定义变量:

In [8]: LEFT = (-1, 0)

该命令创建一个对象(包含–10的元组),并将其绑定到名称LEFT。每当我们使用LEFT时,这个名称就被用作 Python 内部用来查找元组的字典的键。这个字典称为名称空间。因为 Python 根本不区分存储的对象的类型,所以不客气地说,名称空间是一大袋与对象相关的名称。

Python 程序中有许多名称空间。例如,如果我们在另一个模块中使用名称LEFT,那个模块不知道我们是如何在 IPython 会话中定义LEFT的,要么会以错误消息停止,要么会找到一个不同的对象(如果LEFT也被分配到那里)。我们也可以说这个模块有一个不同的名称空间。名称空间只是一个附加在 Python 对象上的名称包。每个模块、每个函数和每个 Python 对象都有自己的名称空间。IPython 也有自己的名称空间。名称空间的组件被称为属性。通过前面的赋值,LEFT成为了 IPython 名称空间的一个属性。让我们看看如何检查名称空间和属性。

用 dir()探索名称空间

我们如何查看名称空间内部并了解它包含哪些名称?使用dir函数可以很容易地探索名称空间。通过使用不带参数的dir,我们可以看到 Ipython 的主名称空间的内容:

In [9]: dir()
['DOWN', 'In', 'LEFT', 'Out', 'RIGHT', 'UP', '_', '__',
'___', '__builtin__', '__builtins__', '__doc__',
'__loader__', '__name__', '__package__', '__spec__', '_dh',
'_i', '_i1', '_i2', '_i3', '_i4', '_i5', '_i6', '_i7',
'_i8', '_ih', '_ii', '_iii', '_oh', '_sh', 'exit',
'get_ipython', 'quit']

如果您以前没有看过 Python,可能需要对它进行一些解释。dir()按字母顺序返回命名空间中对象的名称列表:首先是以大写字母开头的名称;接下来,以下划线开头的名字;最后,以小写字母开头的名字。让我们一项一项地检查一下:

  • LEFT, RIGHT, UP,DOWN是我们之前进一步定义的元组。
  • In是由 IPython 自动创建的。它包含了到目前为止我们在 IPython 中输入的所有命令的列表。
  • Out也是由 IPython 创建的。它是 IPython 发送到标准输出的所有输出的字典。
  • __builtin____builtins__是指具有标准 Python 函数的模块,如print。每当您启动 Python 时,它会自动导入。
  • __doc__是当前名称空间的文档字符串。
  • __name__是当前名称空间的名称。
  • exitquit是终止 IPython 的函数。
  • 剩下的是 IPython 使用的其他内部快捷键。

我们可以在 IPython 提示符下键入这些名称,看看相应的对象包含什么。让我们检查一下当我们导入自己的模块时,用dir获得的名称空间是如何变化的。

In [10]: import draw_maze

In [11]: import generate_maze

In [12]: import load_tiles

In [13]: dir()
['DOWN', 'In', 'LEFT', 'Out', 'RIGHT', 'UP', '_', '_9', '__',
'___', '__builtin__', '__builtins__', '__doc__',
'__loader__', '__name__', '__package__', '__spec__', '_dh',
'_i', '_i1', '_i2', '_i3', '_i4', '_i5', '_i6', '_i7',
'_i8', '_ih', '_ii', '_iii', '_oh', '_sh', 'draw_maze',
'exit', 'generate_maze', 'get_ipython', 'load_tiles', 'quit']

当我们再次调用dir()时,我们会看到与之前相同的名称,外加三个导入的模块。所有三个模块的名称都已成为名称空间的一部分。在调试期间,使用dir来找出在给定时刻定义了哪些模块、函数和变量是非常实用的。这样很容易发现一些错别字。从某种意义上说,使用dir就像打开汽车引擎盖看发动机。dir向我们展示了完整的零件列表。

探索对象的命名空间

使用内省,我们找到了自己的模块,导入了它们,并在 IPython 名称空间中看到了它们。尽管如此,我们不知道每个模块包含什么。我们可以在一个模块上使用dir,看看这些模块的名称空间,以及它们自己的部件列表:

In [14]: dir(draw_maze)

这导致:

['Rect', 'SIZE', 'TILE_POSITIONS', 'Surface', '__builtins__',
'__cached__', '__doc__', '__file__', '__loader__',
'__name__', '__package__', '__spec__', 'create_maze',
'debug_print', 'draw_grid', 'get_tile_rect', 'image',
'load_tiles', 'parse_grid']

现在我们看到了我们在draw_maze模块中定义的函数和变量。我们还看到了draw_maze导入到它的名称空间中的对象(例如Rectdebug_print))。此外,我们看到 Python 内部使用的一些以下划线开头的名字。我们可以在任何 Python 对象上使用dir,而不仅仅是在模块上。例如,我们可以检查TILE POSITIONS对象的属性(一个 Python 列表):

In [15]: dir(draw_maze.TILE_POSITIONS)

结果,我们看到了一个很长的条目列表,在列表的最后,我们找到了您可能很熟悉的列表方法,比如popsort

当自省 Python 对象时,dir的输出有时很难阅读,因为带下划线前缀的属性似乎无处不在。很多时候,我们可以忽略它们。我发现有三个以下划线开头的属性非常有用:

  • _file_–包含模块的物理位置。如果我们怀疑我们从错误的位置导入了一个模块,__file_包含了挽救这一天的信息。
  • __name__–帮助我们找出函数、类和模块的名称,如果我们一直在使用它们(例如,使用import ..作为或使用函数作为另一个函数的参数)。
  • __add____len__这样的神奇方法映射到 Python 中的操作符或标准函数。例如,如果我们看到名称空间中的__add__属性,我们就知道+操作符应该与该对象一起工作。如果我们在名称空间中看到__getitem__属性,我们知道可以使用方括号对其进行索引。记住所有这些神奇方法的名字并不容易。幸运的是,它们上面的 Python 引用非常准确。https://docs.python.org/3/reference/datamodel.html.

使用dir我们可以检查每个 Python 对象的名称空间。在调试会话中,这有助于我们发现是否导入了正确的模块,以及是否正确拼写了对象的名称及其属性。我们还看到 Python 名称空间是嵌套的:名称空间中的每个 Python 对象都有自己的名称空间。图 6-2 展示了名称空间中名称空间的一个例子。dir是导航这个名称空间网络的强大工具。

A419627_1_En_6_Fig2_HTML.jpg

图 6-2。

Namespaces in Python. The namespace of the IPython session contains names of Python objects we created directly, of modules we imported, and of IPython’s own objects. Each of these objects has a namespace of its own. Two of these namespaces are indicated, each depicted with a small fraction of the names contained.

探索 Python 程序中的属性

名称空间的内容也称为属性。只给了我们这些属性的名字作为一个字符串列表。如果我们想访问真实的对象,我们可以使用点(。)运算符。例如,我们用draw_maze.SIZE访问draw_maze模块的SIZE属性。在运行时,我们并不总是预先知道属性名。那么使用另外两个内省函数hasattr()getattr().会更方便,假设我们想在程序中访问对象draw_maze.SIZE。在draw_maze函数上使用getattr返回相应的 Python 对象:

In [16]: size = getattr(draw_maze, 'SIZE')

使用hasattr(x, name),我们可以检查给定的对象是否存在(像dir,但是它返回一个布尔值)。一个典型的例子是,我们从配置文件中读取模块或函数的列表,并在程序中动态地访问它们。getattrhasattr有时在调试过程中很有用,但大多数时候我们会在一个动态添加模块和功能的程序中发现它们(例如,Django 经常这么做)。

IPython 中 dir 的替代方案

dir()不是我们列出 Python 对象各部分的唯一选项。在 IPython 中,我们可以快速浏览名称空间,因为变量、函数和模块的名称是通过按下Tab.自动完成的。我们还可以使用通配符(*)搜索名称空间,例如通过键入

In [17]: ?dra*

您应该会看到draw_maze出现在结果中。如果您不介意将名称空间视为 Python 列表,这种方法可能比dir更有效。

dir产生的信息帮助我们找到要导入的内容。除了我们自己的函数,我们还需要标准库中的pygame.image和两个模块randomsys。完整的import街区是

from load_tiles import load_tiles
from generate_maze import create_maze
from draw_maze import draw_grid, parse_grid
from pygame import image
import random
import sys

名称空间机制

通过检查名称空间,我们可以了解很多关于 Python 如何工作的知识。在这一节中,我们看一下与名称空间 s 相关的三个特殊方面。

Python 为自己的函数使用名称空间

名称空间无处不在。甚至常规的 Python 函数也是以同样的方式组织的。一个很好的例子是__builtins__模块:

In [18]: dir(__builtins__)

我们看到一个很长的列表,其中包含了所有的标准 Python 函数(其中一些您可能想了解一下)。默认情况下,__builtins_模块中的每个函数都可以在每个名称空间中使用,就好像它是该名称空间的一部分一样。将标准函数分组到它们自己的模块中的一个很好的原因是没有参数的dir()的输出变得更短。

修改名称空间

名称空间在程序中是如何变化的?实际上只有几个 Python 命令可以直接改变名称空间。这些措施如下:

  • 带有=的变量赋值向命名空间添加新名称或替换现有名称。
  • 指令从名称空间中删除一个名字。
  • 带有def的函数定义,它将函数添加到名称空间。函数本身是一个 Python 对象,有自己的命名空间;当执行该函数时,会创建一个附加的本地名称空间或范围。
  • 一个class定义将类添加到名称空间中。创建类和该类的每个实例时,它们也有自己的命名空间。
  • every import将一个模块或它的一些组件添加到一个名称空间。
  • forwith语句创建类似=的新变量。
  • 理解创造了临时变量,一旦理解结束,这些变量就消失了。

专门寻找修改名称空间的命令有助于识别导致NameError的缺陷。让我们来看看在一个寻找迷宫中玩家位置的函数中,名称空间是如何变化的。让我们考虑下面这段代码(如果你觉得在 IPython 中一行一行地输入很繁琐,就用%paste)。

def get_player_pos(level, player_char='*'):
    """Returns a (x, y) tuple of player char on the level"""
    for y, row in enumerate(level):
        for x, char in enumerate(row):
            if char == player_char:
                return x, y

当我们进入函数后调用dir()时,会看到get_player_pos出现在命名空间中。然而,我们看不到函数内部定义的任何变量(level, y, row,等)。).当检查带有dir(get_player_pos)的函数的名称空间时,我们也看不到它们中的任何一个。这些变量仅在执行函数时动态创建。一旦我们调用get_player_pos并且函数中的代码被执行,参数level, player_char就被添加到名称空间中。当进入相应的for循环时,相应的变量y, rowx,char也会出现。当函数终止时,添加到函数内部命名空间的所有名称都将消失。原因是名称空间有一个本地范围。在下一节中,我们将看看局部作用域的一些有趣的效果。

命名空间和本地范围

拥有多个名称空间和作用域的一个实际后果是,两个变量不一定相同,即使它们有相同的名称。下面的例子让许多初学者陷入绝望:

def f():
    a = 2

a = 1
f()
print(a)

如果您正在自己发现名称空间,您可能会奇怪为什么这个程序的输出是 1。当然,有两个独立的名称空间在工作:一个用于函数,一个用于主程序。在其中执行上下文代码的命名空间也称为范围。两个名称空间都定义了变量a。如果 Python 在局部范围内找不到名字,它就开始向上一级查找(即,首先在函数内,然后在主程序中)。这被称为从局部向更大的全球范围转移。这解释了为什么下面的例子也可以工作:

a = 1

def f():
    print(a)

f()

这段代码的结果是 1。但是,当在局部范围内定义名称时,局部名称优先于全局名称:

a = 1

def f():
    a = 2
    print(a)

f()

这个例子的结果当然是 2。下面的例子以一个UnboundLocalError,结束,因为 Python 在解析函数时决定a属于本地名称空间:

a = 1

def f():
    print(a)
    a = 2

f()

希望这个例子说明了保持名字的清晰分离(例如,不要在函数内部和外部使用相同的名字)是一个最佳实践。

名称空间是 Python 的核心特性

名称空间是 Python 的一个核心特性。我们已经看到所有的 Python 对象都有一个名称空间。我们在这些名称空间中看到的所有东西也是一个 Python 对象,它也有自己的名称空间。简而言之,Python 由名称空间中的名称空间中的名称空间组成(见图 6-2 )。我们还看到,当程序运行时,名称空间经常改变。

这对调试有几个更深层次的重要影响。首先,函数、类的方法和包含数据的对象之间没有真正的区别。Python 并不阻止我们混合这些类别。第二,名称空间可以被创造性地修改和重组(函数装饰器就是一个很好的例子;元类是更糟糕的一种)。第三,Python 中没有严格的封装,这意味着程序的每个部分都可以修改其他部分的名称空间。虽然这很方便,但也意味着很难将事物严格分开;我们不能阻止其他代码修改特定的名称空间。封装的缺乏使得编写简短的 Python 程序变得更加容易,但是在编写大型软件时却容易出错。

因此,保持名称空间组织良好是必须的。组织名称空间取决于程序员,因为 Python 并不关心我们在名称空间中放了什么。这就是为什么命名约定、描述性变量和函数名在 Python 中比在其他语言中更重要。我们是否决定使用函数、类、模块、包或标准字典来组织名称空间取决于我们自己。从某种意义上说,所有这些语言特性都是对同一个抽象问题的解决方案:如何组织名称空间。知道了它们的优点和缺点,我们可以选择在给定的时刻哪个特性有助于我们最好地组织:如果我们想要组织代码单元,函数是组织我们的名称空间的一个好工具。如果我们想组织一些数据对象,一个简单的列表就足够了。如果我们两者都需要,上课可能是正确的选择。函数、类和模块使管理名称空间更加方便。在这一点上,Python 与其他编程语言在内部有很大的不同,尽管实际代码可能看起来相似。

使用自我记录的对象

当我们使用dir,时,我们可以看到有哪些部分(功能、模块、其他属性),但看不到它们是如何工作的。我们将使用文档字符串来找出答案,而不是阅读源代码。我们经常可以在本地找到更快的答案,而不是浏览互联网。

使用帮助()访问文档字符串

最佳实践是使用自省函数help()简单地检查一个 Python 对象。help()函数显示 Python 对象的文档字符串,如果我们想检查一个给定函数做什么,这非常有用:

In [19]: help(draw_maze.draw_grid)

此命令会显示一个单独的屏幕页面,其中包含帮助文本:

Help on function draw_grid in module draw_maze:

draw_grid(data, tile_img, tiles)
    Returns an image of a tile-based grid

按下q',我们可以再次离开帮助页面。使用help有助于获得使用函数或模块的快速提示(例如,我们可以很容易地用它检查参数的顺序)。为了获得更深入的理解,help不太合适,所以它作为我们编程时唯一的文档来源是不够的。但是help在支持我们对以前做过的事情的记忆方面做得不错。

Hint

有时候帮助显示的信息一点帮助都没有。如果您发现文档过于晦涩,或者(更糟糕的是)它是空的,请立即离开帮助屏幕,到其他地方看看。

帮助还列出了包的内容。有时dir不能很好地处理一个包(例如,如果__init__.py文件是空的)。在这种情况下,help()出手相救:

In [20]: import pygame
In [21]: help(pygame)

文档包含一个自动生成的部分,称为包内容,其中列出了包中的所有模块。在 P ygame的情况下,我们也可以看到带dir的内容;如果你想看一个dir没多大帮助的包,试试import xml

IPython 中的对象摘要

IPython 命令?带有对象名称的符号为我们提供了对象类型、内容和描述的概要(有点像函数type, print,help的组合):

In [3]: ?maze
Type:        list
String form: [['#', '#', '#', '#', '#', '#', '#'],
              ['#', '.', '.', '.', '.', '.', '#'],
              ['#', '.', '.', '.', ' <...> ', '.', '.', '.', '#'],
              ['#', '.', '.', '.', '.', 'x', '#'],
              ['#', '#', '#', '#', '#', '#', '#']]
Length:      7
Docstring:
list() -> new empty list
list(iterable) -> new list initialized from iterable's items

分析对象类型

有了四个方向向量(LEFT, RIGHT, UP, DOWN)和get_player_pos函数,我们就可以实现移动播放器的功能(或者用%paste复制到 IPython)。我们只需将运动向量添加到玩家的位置,并相应地修改地图:

def move(level, direction):
    """Handles moves on the level"""
    oldx, oldy = get_player_pos(level)
    newx = oldx + direction[0]
    newy = oldy + direction[1]
    if level[newy][newx] == 'x':
        sys.exit(0)
    if level[newy][newx] != '#':
        level[oldy][oldx] = ' '
        level[newy][newx] = '*'

在调用 move 之前,我们需要设置玩家的起始位置。让我们从迷宫的左上角开始,使用星号(*)作为玩家符号:

In [22]: maze = create_maze(12, 7)

In [23]: maze[1][1] = '*'

唉,我们得到另一个错误消息:

TypeError

Traceback

(most recent call last)
<ipython-input-5-b9a7a1b90faf> in <module>()
----> 1 maze[1][1] = "*"

TypeError: 'str' object does not support item assignment

我们可能需要仔细看看这个maze物体。我们将使用另一个内省功能type()来检查它,而不是有些粗糙的print:

In [24]: type(maze)
str

type函数返回对象类型(派生出maze的类)。它适用于任何内置或用户定义的类型。原来我们的迷宫是一个不可修改的单串。要修改字符串,我们需要将其转换为二维列表。我们已经在前面的draw_maze.parse_grid()函数中为其编写了代码:

In [25]: maze = draw_maze.parse_grid(maze)

迷宫的类型是一个列表,当然是可变的:

In [26]: type(maze)
list

解决了这个问题,我们终于可以将前面的命令组合成一个程序,沿着迷宫随机行走:

If __name__ == ' __main__':
    tile_img, tiles = load_tiles()
    maze = create_maze(12, 7)

    maze = parse_grid(maze)
    maze[1][1]  =  '*'
    for i in range(100):
        direction = random.choice([LEFT, RIGHT, UP, DOWN])
        move(maze, direction)
    img = draw_grid(maze, tile_img, tiles)
    image.save(img, 'moved.png')

检查对象标识

还有一些内省函数可以详细检查对象:有时检查两个对象是否真的相同,而不仅仅是包含相同的数据是很重要的。这可以用is操作符来完成,而==只比较内容。以下示例说明了两者的区别:

In [17]: a = [1, 2, 3]

In [18]: b = [1, 2, 3]

In [19]: a == b
Out[19]: True

In [20]: a is b
Out[20]: False

这里,ab是不同的对象,因为修改一个列表不会影响另一个列表。对于字典、集合以及有趣的元组也是如此。对于不可变的基本类型,如字符串和整数,is==的结果是相同的。

检查实例和子类

使用isinstance,我们可以检查给定对象是否是给定类的实例:

isinstance("abc", str)
True

使用issubclass,我们可以检查一个给定的类是否是另一个类的后代:

issubclass(str, object)
True

这是因为每个 Python 对象都是object的后代。对于调试使用复杂类层次结构的代码来说,isinstanceissubclass都是必不可少的。

内省的实际应用

为了成功调试 Python 程序,我们需要知道如何导航和检查程序中的名称空间。自省是分析 Python 程序中名称空间的强大工具。我们有一组分析函数,如dir, help,type,它们提供了关于任何给定 Python 对象的内容、文档和类型的丰富信息。你可以在表 6-2 中找到自检功能的总结。

表 6-2。

Introspection Functions in Python

| 功能 | 描述 | | --- | --- | | `l (list)` | 在下一个执行的代码周围列出几行代码 | | `dir()` | 返回当前命名空间中的名称列表 | | `dir(x)` | 列出 x 中名称空间的内容 | | `help(x)` | 显示了 Python 对象的文档字符串 | | `x is y` | 检查两个对象的标识(相对于==) | | `type(x)` | 返回对象的类型 | | `hasattr(x, s)` | 如果`x`的名称空间包含名称`s`,则返回`True` | | `getattr(x, s)` | 从名称空间`x`返回名为`s`的属性 | | `issubclass(x, y)` | 如果`x`是`y`的子类,则返回`True` | | `isinstance(x, y)` | 如果`x`是类`y`的实例,则返回`True` | | `callable(x)` | 如果可以调用`x`,则返回`True` | | `globals(x)` | 返回全局范围内的对象字典 | | `locals(x)` | 返回局部范围内(例如,在函数内部)的对象字典 |

结合 IPython 中的快捷方式(例如,用于检查文件名或用通配符列出名称),我们可以找到很多关于我们程序的信息。自省有用的情况包括

  • 在 IPython 中运行程序后检查对象

  • 识别重叠的名称空间

  • 探索图书馆

  • 试验代码片段

  • 使用print()从程序中输出关于名称空间或类型的信息

  • 在调试期间探索对象的类型

最常见的是,它被用在调试中,帮助我们找到甚至是简单的缺陷,比如错别字。

内省查找错别字

使用内省来分析名称空间的内容有时有助于识别拼写错误。请考虑以下说明:

In [16]: player_pos = 7
In [17]: playr_pos = player_pos + 1

代码中有一个容易被忽略的缺陷。然而,在运行dir之后,我们会立即发现有问题:

..
'player_pos',
'playr_pos',
..

深入研究名称空间会发现一个缺陷,否则这个缺陷可能会在代码中隐藏很长时间。

组合自省功能

为了展示自省函数的分析能力,我们将再看一个例子。在第二章中,我们用指令识别了所有 Python 错误

 [x for x in dir(__builtin__) if 'Error' in x]

该命令有效,但不精确。我们不能确定所有内置异常的名字中都有Error。更正确的方法是从__builtins__模块中找到基类Exception的后代的所有对象:

for name for name in dir(__builtins__):
    obj = getattr(__builtin__, name)
    if obj.__class__ == type \
       and issubclass(obj, Exception):
       print(obj)

我们首先遍历__builtins__模块中的所有对象。我们使用getattr通过名称检索对象。第一个条件检查对象是否是一个类(使用一个名为元类的属性,在本例中是type)。第二个条件检查对象是否是Exception.的子类

这不是你日常使用自省函数的方式。特别是,在我看来,元类属于《专业 Python:黑魔法》(如果要写的话)这本书。像大多数危险的功夫技巧一样,了解它们是有益的,但千万不要使用它们。然而,编写类似前面的表达式有助于我们更深入地理解 Python 的内部机制。

大程序和小程序中的自省

在编写大型和小型 Python 程序时,花时间在交互式环境中用自省来诊断 Python 对象是一种最佳实践。主要的区别在于,当编写小程序时,自省通常与探索性编码并行发生,一行一行地尝试并保留那些运行良好的代码,而在大程序中,自省更多地出现在旨在解决某个特定问题的调试会话中。自省意味着检查 Python 程序。有时你会发现程序内部使用的自省函数是其功能的一部分。虽然如果一个程序自我检查可能是有用的,但可能会感觉有点尴尬(见图 6-3 )。我建议在运行时谨慎使用自省,并且只有在您彻底理解名称空间和自省函数的情况下。

A419627_1_En_6_Fig3_HTML.jpg

图 6-3。

Introspection functions

简而言之,自省是一种工具,它给了我们一个问题的详细答案,在给定的时刻,程序中存在什么对象,它们的属性是什么?自省允许我们分析程序的状态,而不是代码执行的动态。后者是自省的主要限制,因为仅仅依靠自省,我们将永远看不到名称空间或对象先前的状态,也看不到它后来在程序中是如何变化的。我们将在下一章中克服这个限制。除了这个限制,所有自省函数都应该在任何 Python 程序员的工具箱中占有一席之地。

最佳实践

  • 自省是一套精确的诊断工具,用于检查 Python 对象。
  • IPython 是使用自省函数和神奇函数的绝佳环境。
  • 每个 Python 对象都有一个包含属性的名称空间。
  • dir()列出名称空间的内容。
  • 名称空间是 Python 的核心特性。名称空间可以包含任何内容;因此,保持名称空间组织良好是很重要的。
  • help()显示 Python 函数、类或模块的文档字符串。
  • type()显示 Python 对象的类型。
  • 其他自省函数有助于更精确地分析类型、类和属性。
  • 在调试期间,自省允许您诊断程序的状态。

七、使用交互式调试器

调试器就像对一个病人做全身扫描。—Zed Shaw,艰难地学习 Pythonwww。learnpythontheha rdway。org

我们现在已经准备好了游戏第一版运行的所有部分。我们只需要结合绘制地图、移动玩家、运行事件循环的功能。因此,我们将导入到目前为止编写的模块,添加一些代码将它们粘在一起,运行程序,并开始游戏!至少理论上是这样的。你认为组件在第一次尝试时会顺利合作吗?在这一章的开始,我不想让你失望,但这不太可能——至少在我的代码中不会。当然,有许多软件在第一次用于生产环境时必须工作(例如,控制飞机、医疗设备、选举等的程序)。).这种软件是使用不同的过程开发的,而不是“让我们从一个 bug 爬到下一个 bug”当编写游戏时,我们可以在问题出现时修复它们。

在第六章中,我们使用自省来检查程序的状态。在这一章中,我们将研究它的动力学,一个程序如何从一个状态转移到下一个状态。通过使用另一个调试工具,交互式调试器,我们将以慢动作观看程序,并选择与之交互。

在我们看到游戏运行之前,我们需要做一些准备。让我们从导入到目前为止我们创建的所有内容开始。因为我们已经有了相当数量的进口,所以我将它们按升序排列:

from load_tiles import load_tiles
from generate_maze import create_maze
from event_loop import event_loop
from draw_maze import draw_grid, parse_grid
from moves import move, LEFT, RIGHT, UP, DOWN
from pygame import Rect
import pygame

我们还需要初始化一个 Pygame 显示,以便处理事件和交互式绘制图形。因为我们稍后将在其他函数中需要显示,所以需要首先执行以下代码:

pygame.init()
pygame.display.set_mode((800,  600))
display = pygame.display.get_surface()

接下来,我们创建一个迷宫,并在对角添加一个玩家(*)和一个出口(x):

maze = parse_grid(create_maze(12, 7))
maze[1][1] = '*'
maze[5][10] = 'x'

最后,我们在游戏开始前显示地图(黑屏会让你的第一步很尴尬)。

tile_img, tiles = load_tiles()
img = draw_grid(maze, tile_img, tiles)
display.blit(img, Rect((0, 0, 384, 224)), Rect((0, 0, 384, 224)))
pygame.display.update()

这是执行代码的好时机。在五章关于 bug 的内容之后,你不应该对程序失败感到太惊讶:

Traceback (most recent call last):
  File "maze_run.py", line 22, in <module>
    img = draw_grid(maze, tile_img, tiles)
  File "/home/krother/projects/maze_run/maze_run/draw_maze.py", line 25, in draw_grid
    img.blit(tile_img, rect, tiles[char])
KeyError: 'x'

当把不同的代码组合成一个程序时,我们不能期望每件事都能马上顺利运行。与此同时,我们的程序变得越来越复杂(现在它应该包含 100 多行代码和至少 10 个函数)。无论潜在的缺陷是什么,我们都可以期望在不同的函数之间跳来跳去(就像我们的代码一样),以便识别缺陷。在这种情况下,科学方法仍然完全适用。但是为了对正在执行的程序进行精确的观察,我们需要一个额外的工具:交互式调试器。

交互式调试器 ipdb

ipdb这样的交互式调试器可以让我们一行一行地跟踪程序的执行,看着它以慢动作执行。在包括 Python 在内的大多数编程语言中,使用交互式调试器来跟踪缺陷是一种基本的最佳实践。交互式调试器允许我们做几件事情:

  • 检查变量的内容。
  • 使用自省功能。
  • 评估常规 Python 指令。
  • 在异常发生之前检查我们程序的状态。
  • 一条指令一条指令地执行我们的代码。我们需要按键来执行下一条指令。
  • 继续或终止我们程序的执行。
  • 设置断点,即代码中正常执行停止并进入交互调试模式的位置。

与我们在第 5 和第六章中用print和自省分析的程序状态相比,交互式调试器的优势在于分析程序执行的动态:一个状态如何转换到下一个状态,以及相应地,缺陷如何传播。在 Python 中,首选的交互式调试器是 ipdb。

安装 ipdb

ipdb 是标准 Python 调试器pdb的扩展版本。像 IPython 一样,它提供了语法高亮和用[TAB]完成名称。Python 异常的回溯提供了更多信息。在第六章第六章中描述的 IPython 中的神奇函数在ipdb中不起作用,但在 Python 的调试器pdb中也不起作用。

使用pip, ipdb安装起来很简单:

pip install ipdb

Note

如果你的安装因为某种原因失败了,你可以使用内置的调试器pdb.来完成本章中的大部分例子,只需用pdb替换ipdb即可。

启动调试器

在使用ipdb的时候,我们有三种可能的开始:程序执行的开始,结束,中间。

从命令行运行 ipdb

我们可以通过将ipdb指定为 Python 解释器的额外模块来开始调试我们的代码:

python -m ipdb maze_run.py

或者,我们可以使用-d选项从 IPython 内部执行程序:

%run -d maze_run.py

在这两种情况下,程序都是从调试器的第一行开始执行的。您应该会看到如下输出:

> /home/krother/Desktop/python_maze_game/maze_run/maze_run.py(1)<module>()

----> 1 from draw map import draw grid, parse grid

      2 from event_loop import event_loop
      3 from generate_maze import create_maze
ipdb>

箭头指示调试器接下来要执行的行(即,第一次导入尚未执行)。在代码摘录之后有一个提示ipdb>,我们可以在这里输入命令。idpb中最重要的命令是 q,它终止调试器。毕竟,maze_run.py的第一行离追溯的位置相当远,所以我们将尝试不同的方式来调用 ipdb。

从程序启动 ipdb

或者,我们可以在程序中的任意位置启动调试器。为此,我们需要在希望启动调试会话的位置插入以下代码片段。例如,我们可以在maze_run.py中引发异常的第 22 行之前调用调试器:

..
tile_img, tiles = load_tiles()

import ipdb; ipdb.set trace()

img = draw_grid(maze, tile_img, tiles)

..

Hint

这是极少数适合在同一行中放置两个 Python 命令的情况之一。“适当的”比如“在这个特殊的聚会上,多喝几杯酒是合适的。”你可能会得出结论,这仍然不是一个好主意。

当我们执行程序时,我们再次看到ipdb提示。这一次,代码中的位置是调用set_trace()的地方。现在我们可以检查变量。查看前面调用img.blit时的KeyError,我们假设tiles字典有问题。我们使用调试会话打印带有pp快捷键的tiles:

ipdb> pp tiles
{' ': <rect(0, 32, 32, 32)>,
'#': <rect(0, 0, 32, 32)>,
'*': <rect(96, 0, 32, 32)>,
'.': <rect(64, 0, 32, 32)>}

我们注意到我们忘记了将出口图块添加到字典中。我们的假设被证实了。

Hint

如果你的程序包含列表、字典或集合(哪个 Python 程序没有?)使用pprint是使屏幕输出更容易阅读的最佳实践。幸运的是,ipdb中的pp快捷方式省去了我们导入模块的麻烦。

我们可以尝试立即修复缺陷。ipdb接受任何 Python 命令,所以我们可以简单地添加缺少的条目。退出磁贴的左上角是位置(32, 32)。之后,我们使用c命令继续执行程序:

ipdb> tiles['x'] = Rect((32, 32, 32, 32))
ipdb> c

添加了缺少的条目后,程序运行得完美无缺。我们看到了水平,至少在窗口关闭前的短暂时刻。修补我们的程序不是一个非常干净或优雅的策略,但有时它是检查假设是否正确的最快方法。让我们从代码中删除对ipdb.set_trace()的调用,以便尝试另一种调试策略。

事后调试

对大多数犯罪现场调查员来说,麻烦在于他们来的太晚了。犯罪结束了,嫌疑人跑了,侦查员需要从各种线索中重构事件。不是这样的:在死后调试中,我们在异常发生的那一刻到达犯罪现场,仿佛时间凝固了。我们可以在程序崩溃前检查它。这是可能的,因为 Python 记住了许多关于上一个异常的信息(可通过sys.exc_info())获得)。当然,Python 解释器必须还在运行。幸运的是,当我们从交互提示符下启动程序时,IPython 会保留 Python 对象,这样我们就可以使用函数ipdb.pm():启动后期调试器

In [1]: %run maze_run.py

    .. Traceback

KeyError: 'x'

In [2]: import ipdb

In [3]: ipdb.pm()

ipdb>

关于这个命令的一个恼人的事情是,如果我们打错了ipdb,我们需要重新开始(Python 只记住一个异常,控制台上任何新创建的错误都会丢弃前一个)。幸运的是,IPython 提供了%debug魔法函数来代替前面的序列:

In [1]: %run maze_run.py

    .. Traceback

KeyError: 'x'

In [2]: %debug

ipdb>

像使用ipdb.set_trace()一样,我们可以检查变量的内容并修改它们。主要区别是不能继续执行(程序已经死了)。按下c终止调试器会话并返回 IPython 提示符。在程序的生命周期中,事后调试是步入调试器的一个令人兴奋的替代方法。

Postmortem Analysis

术语“事后分析”也被用来描述项目后的会议或回顾。这不是这里的意思。我想他们也在停尸房做死后分析,但这和我们在这里做的差远了。

异常时启动调试器

有些情况下,无论何时出现异常,我们都需要默认进入调试器。例如,我们有一个程序,我们需要不惜任何代价保持它的活力(一个生产 web 服务器)。或者我们有不想丢失的数据。或者我们正在现场测试我们的程序,并且很好奇会出现什么样的问题。在这种情况下,我们可以将整个程序包装在一个大的try块中,并在except块中开始ipdb.pm()。它的代码非常难看,不符合 Pythonic 语言,所以我不想在这里展示。在 Python 中实现这一点的正确方法是使用上下文管理器和with

from ipdb import launch_ipdb_on_exception

with launch_ipdb_on_exception():
    # your code

这个表达式平滑地与现有代码集成,易于阅读,我们可以选择程序的哪些部分ipdb应该被检查。启动交互式调试器的所有三种模式(程序开始、执行期间或异常终止后)都是最佳实践。像print一样,有一个假设有助于决定在给定的情况下使用三者中的哪一个,并且通常防止你在代码中到处搜索。

修复缺陷

当然,我们仍然需要在load_tiles.py中的程序中将额外的条目添加到字典中:

TILE_POSITIONS = [
    ('#', 0, 0), # wall
    ('˽', 0, 1), # floor

    ('x', 1, 1), # exit

    (", 2, 0), # dot

    ('*', 3, 0), # player
    ]

通过添加一行代码,我们的程序可以正确地设置图形。我们可以继续添加游戏机制。

调试器提示符下的命令

在我们查看调试器更复杂的功能之前,让我们总结一下到达ipdb提示符后的基本选项:

检查变量

我们可以在ipdb提示符下检查任何变量的内容。这可以通过键入它们的名字(就像在 Python shell 中一样)或使用p name来完成。用ipdb检查变量的主要优点是,我们可以检查程序状态,而不会因为调用print和内省函数而破坏代码。

Tip

单字符变量也有转义:大多数调试器命令都有一些程序员喜欢用作变量名的名称。如果您有这样的名称,您仍然可以通过用感叹号(!idspnonenote)对其进行转义来访问您的变量。):

ipdb> !p = 3
ipdb> p p

评估 Python 表达式

我们可以编写 Python 表达式来改变变量值(例如,检查我们是否通过手动纠正找到了 bug 的原因)。为此,只需输入一个 Python 命令,看看会发生什么。这样,我们可以测试关于什么可以立即修复缺陷的假设。后者不是一直都管用;在修复前面的KeyError时,我们有点幸运。

单步调试我们的代码

为了找到问题所在,我们可以用n在调试器中执行下一行。如果我们需要深入一个函数并从内部观察它,使用s而不是n。有了s,我们基本上可以在我们的代码中爬上整个调用栈(并返回)。用lll我们显示下一个执行的行和它周围的几行。一般来说,调试器命令l, n,s的组合允许我们有效地一步一步导航我们的程序。简而言之,在“示例 ipdb 会话”一节中,我们将查看一个交互式会话的详细示例。

重新开始

一旦我们看够了,我们可以简单地点击c并继续执行到最后(或调试器的下一次调用)。相比之下,当我们断定我们正在运行的程序无法再保存时,q命令会快速终止它,以便我们可以更改它并重试。表 7-1 列出了在ipdb提示符下可用的命令。

表 7-1。

Common Commands in ipdb

| 命令 | 描述 | | --- | --- | | `l (list)` | 在下一个执行的代码周围列出几行代码 | | `ll (long)` | 再列几行 | | `n (next)` | 执行下一行 | | `s (step)` | 执行下一行,如果它包含函数调用,跳到函数的第一行 | | `p (print)` | 显示变量的内容 | | `pp (pretty-print)` | 显示格式良好的复合类型 | | `c (continue)` | 不使用调试器继续执行 | | `R` | 继续到函数的结尾 | | `q (quit)` | 终止程序的执行 | | `u, d` | 在调用堆栈中上下导航(以便可以在那里查看代码) | | `b (break)` | 列出断点 | | `b ` | 添加断点 | | `b ` | 添加断点 | | `b , ` | 添加带条件的断点 | | `b , ` | 添加带条件的断点 | | `cl ` | 移除断点 | | `?` | 列出可用命令 | | `help ` | 显示调试器命令的帮助 | | `[ENTER]` | 重复上一个调试器命令 |

使用断点

断点告诉调试器在特定行停止执行并启动调试器。它们让我们的生活更加舒适,以至于我们不需要一直在代码中插入pdb.set_trace()。我们可以在一个特定函数的开始处设置一个断点(例如,在move()函数中)

ipdb> b moves.move

假设此时moves模块是已知的。或者,我们可以指定文件名和行号:

ipdb> b moves.py:24

为了利用断点,我们以调试模式启动程序(作为一个新的 ipdb 会话)。然后我们按一次c到达第一个断点并开始我们的分析。如果我们已经设置了多个断点,我们可以用c命令从一个断点跳到下一个断点。

断点有助于缩小缺陷的位置。一种可能的搜索策略是在几次迭代中圈出错误代码。在一切都正确的时候设置一个断点,在肯定出错的地方设置第二个断点。中途设置第三个断点,并检查故障是否已经出现在第三个断点处。根据结果,缩小一半或另一半的搜索范围。

查看和删除断点

只需按下b即可列出当前设置的所有断点。该列表包括编号、位置和断点被访问的次数:

ipdb> b
Num Type         Disp Enb   Where
1   breakpoint   keep yes   at ../maze_run/debugging/maze_run.py:35
    breakpoint already hit 2 times
2   breakpoint   keep yes   at  ../maze_run/debugging/moves.py:24
    breakpoint already hit 2 times

如果你想删除一个断点,你可以使用cl命令,使用与创建断点相同的参数。使用表 7-1 中的数字可以更容易地删除大多数时间断点:

cl 2

这将从列表中删除第二个断点。有时候我发现在 ipdb 中手动设置和删除断点有点笨拙。在 PyCharm 中,设置和删除它们需要单击程序代码左侧的栏(见下图 7-2 )。

条件断点

在调试复杂程序时,一个常见的问题是,当程序第一次到达某一行时,不会出现错误。假设在达到我们感兴趣的条件之前,我们有 99 次迭代。手动输入前 99 次会让我们在发现错误前睡着。在这种情况下,我们需要根据程序的状态停止程序的执行。我们可以自己给代码添加一个条件,作为一个if子句。例如,我们可以检查下一个瓷砖是否是一面墙:

if level[newy][newx] == '#':
    ipdb.set_trace()

然而,这不是一个优雅的解决方案。与条件打印一样,这往往会弄乱我们的代码。条件断点是一种更优雅的捷径。我们可以通过在ipdb中设置一个断点来达到同样的目的

b moves:27, level[newy][newx] == '#'

有趣的是,如果我们错误地输入了条件表达式,或者由于其他原因它不起作用,这都不是问题。当 Python 从条件中引发异常时,ipdb假设有理由仔细检查代码,并且断点仍然被触发。

配置 ipdb

我们可以在进入调试器时执行来自.pdbrc文件的命令。ipdb在我们启动 Python 的目录和我们的主目录中查找这个配置文件(后者适用于所有 Python 项目)。或者,当从 Python 命令行运行调试器时,我们可以用选项-c指定一个文件。一个.pdbrc配置文件可能如下所示:

from pprint import pprint
ll
print(dir()

)
b maze_run.handle_key

当进入调试器时,会发生四件事:首先,我们导入漂亮的打印函数pprint()来更容易地检查大型数据结构。其次,我们让ipdb产生代码中当前位置的一段较长的摘录。第三,我们显示当前名称空间的内容。第四,我们设置了一个断点,每当调用maze_run模块中的handle_key函数时就会触发该断点。

如您所见,配置文件可能包含普通 Python 命令和调试器命令的混合。配置的目的是使我们的调试更容易。例如,如果您正在调试一个大型程序,您可能会在那里列出您最喜欢的断点。如果正在调试包含关键数据的系统,可以在调试前自动保存数据。如果想跟踪调试的时间和地点,可以写一个带有时间戳的日志消息等等。大量干扰你的程序或者强烈依赖于所检查的问题类型的命令不应该进入你的.pdbrc。综上所述,一个好的配置可以让你的紧急着陆更加平稳。

ipdb 会话示例

使用调试器的主要好处是单步调试您的代码。也就是说,我们一个命令接一个命令地执行程序,并在执行过程中检查变量,直到我们(希望)发现缺陷发生的地方。我们可以用表 7-1 中的快捷方式控制代码的执行。在这里,我们将从头到尾经历一个ipdb会话。

添加游戏控制功能

在导入所有必要的模块并初始化游戏后,我们需要将箭头键插入我们的move函数。在第四章中,我们发现了事件循环中使用的箭头键的键码(整数 273-276)。我们可以将每个箭头键映射到字典中的一个运动向量:

DIRECTIONS = {
    276: LEFT,
    275: RIGHT,
    273: UP,
    274: DOWN
}

在游戏控制函数中,我们取一个键,得到移动向量,在迷宫中执行移动,然后画出所有的东西。我们通过将该函数作为回调传递到事件循环中来开始游戏:

def handle_key(key):
    """Handles key events in the game"""

    move(maze, DIRECTIONS.get(key))
    img = draw_grid(maze, tile_img, tiles)
    display.blit(img, Rect((0, 0, 384, 224)), Rect((0, 0, 384, 224)))
    pygame.display.update()

event_loop(handle_key)

当我们运行这个程序时,它起初看起来很好。当我们按下箭头键时,图形四处移动并吃掉点,走在出口瓷砖上终止程序。但是如果我们按了不同的键,程序会突然崩溃,并显示一条错误消息:

Traceback (most recent call last):
  File "maze_run.py", line 45, in <module>
    event_loop(handle_key)
  File "/home/krother/projects/maze_run/maze_run/event_loop.py", line 16, in event_loop
    handle_key(event.key)
  File "maze_run.py", line 39, in handle_key
    move(maze, direction)
  File "/home/krother/projects/maze_run/maze_run/moves.py", line 29, in move
    newx = oldx + direction[0]

TypeError

: 'NoneType' object is not subscriptable

单步执行代码

我们将从头开始单步执行代码。让我们从 IPython 启动调试器:

In [2]: %run -d maze_run.py

> /home/krother/Desktop/python_maze_game/maze_run/debugging/maze_run.py(1)<module>()

1---> 1 from draw map import draw grid, parse grid

      2 from event_loop import event_loop
      3 from generate_maze import create_maze
      4 from load_tiles import load_tiles
      5 from moves import move, LEFT, RIGHT, UP, DOWN

调试器让我们从第一行开始。我们现在可以按n来一行一行地浏览。

ipdb> n
> /home/krother/Desktop/python_maze_game/maze_run/debugging/maze_run.py(2)<module>()
1     1 from draw_map import draw_grid, parse_grid

----> 2 from event loop import event loop

      3 from generate_maze import create_maze
      4 from load_tiles import load_tiles
      5 from moves import move, LEFT, RIGHT, UP, DOWN

很快就会发现,这不是一个理想的前进方式。首先,浏览一长串与问题无关的导入和其他代码很无聊。第二,一旦我们到达那里,我们将不得不离开事件循环(你可以自由地尝试发生什么)。幸运的是,我们已经知道错误发生在哪里,所以我们将从回溯的顶层开始调试。为了到达代码中的点,我们在handle_key函数中设置了一个断点:

ipdb> b handle_key
Breakpoint 1 at /home/krother/projects/maze_run/maze_run/maze_run.py:35

对于断点的设置,我们不需要指定模块,因为我们已经在game模块的范围内了。现在,我们可以继续执行:

ipdb> c

您应该看到 Pygame 窗口弹出,调试器会话被挂起。现在,如果您将焦点切换到游戏窗口并按下箭头键,调试器将到达第一个断点。

> /home/krother/projects/maze_run/maze_run/maze_run.py(37)handle_key()
1    35 def handle_key(key):
     36     """Handles key events in the game"""

---> 37 move(maze, DIRECTIONS.get(key))

     38      img = draw_grid(maze, tile_img, tiles)
     39      display.blit(img, Rect((0, 0, 384, 224)), Rect((0, 0, 384, 224)))

我们可以将焦点切换回运行ipdb的终端。请注意,如何切换焦点很重要(如果您使用鼠标,Pygame 不会注意到,但如果您使用键(例如,Alt + Tab),它们会进入 Pygame 事件队列)。我们现在可以检查功能参数key的值:

ipdb> key
275

这是我们知道正在起作用的“安全”价值观之一。我们继续执行并返回游戏窗口:

ipdb> c

这一次,我们按空格键。同样,断点是在同一位置触发的。这一次,我们为key获得了一个不同的值:

..

---> 22 move(maze, DIRECTIONS.get(key))

..
ipdb> key
32

我们现在可以通过按下n来评估handle_key函数中的第一行。然而,我们从回溯中知道,调用move的那一行将会发生错误。我们现在可以用s命令进入函数内部:

ipdb> s
--Call--
> /home/krother/Desktop/python_maze_game/maze_run/debugging/moves.py(22)move()
     20
     21

---> 22 def move(level, direction):

     23      """Handles moves on the level"""

     24      oldx, oldy = get_player_pos(level)

每当在调试会话期间进入一个函数时,列出它的所有参数是一个好主意。一种方便的方法是自省函数locals():

ipdb> locals()
{'level': [['#', '#', '#', '#', '#', '#', '#', '#', '#', '#', '#', '#'], ['#', '*', '.', '.', '.', '.', '.', '.', '.', '.', '.', '#'],['#', '.', '.', '#', '.', '#', '.', '#', '.', '.', '.', '#'],['#', '.', '.', '.', '.', '#', '.', '.', '.', '.', '.', '#'], ['#', '#', '.', '#

如果您决定一个接一个地输入参数名,而不是使用locals(),那么directionNone值将变得更难识别。我们必须使用p命令来实际查看它:

ipdb> direction
ipdb> p direction
None

Hint

如果您想更详细地分析level,那么pp命令会给出更好的输出。

此时,您可能已经假设 None 值导致了异常。克制自己不立即修改代码是值得的。首先,我们已经确定了一个症状,而不是缺陷本身。第二,有多种方法可以修复它。因此,观察缺陷的整个传播过程是值得的。我们将再按几次n,直到出现异常。幸运的是,我们不用走很远:

ipdb> n
..
ipdb> n
..
ipdb> n
TypeError: 'NoneType' object is not subscriptable
> /home/krother/Desktop/python_maze_game/maze_run/debugging/moves.py(25)move()
     23      """Handles moves on the level"""

     24      oldx, oldy = get_player_pos(level)

---> 25 newx = oldx + direction[0]

     26 newy = oldy + direction[1]
     27 if level[newy][newx]
== 'x':

我们现在有了完整的了解:Python 不能对值None应用索引 0。此时,调试器会话对我们不再有用(值oldxoldy与该现象无关)。是时候退出会话了。

ipdb>  q

修复缺陷

显然,我们还没有告诉我们的程序当一个未知的键被按下时该做什么。有不同的方法来缓解这个问题。首先,我们可以给move()添加防御代码:

if direction == None:
    return

这使得函数比必要的更复杂。第二个想法是在访问DIRECTIONS字典时添加一个默认值:

DIRECTIONS.get(key, (0, 0))

这会用一个不移动任何东西的值调用move()。添加零向量(0, 0)作为默认值也不是一个好主意。从某种意义上来说,我们在强迫move()做一些该函数原本不应该做的事情。即使它有效,很可能这个修复会很快发展成一个比我们正在修复的更复杂的缺陷。

修复缺陷的最佳位置就是它第一次出现的地方。我们明确地关注游戏功能中缺失的关键情况:

direction = DIRECTIONS.get(key)
if direction:
    move(maze, direction)

虽然这引入了两行额外的代码,但它是最容易阅读和理解的。

成功了!

完成互动调试环节,修复缺陷后,游戏工作了!庆祝的时间到了。你找到图[中截图的代码 7-1 创建于 https://github.com/krother/maze_run 。在子目录buggy/中,您可以找到包含前六章中所有 bug 的代码版本。如果你在读这本书的时候写了你自己版本的游戏,你自己的 bug 当然就足够了。

A419627_1_En_7_Fig1_HTML.jpg

图 7-1。

Scene from the working game at the end of this chapter

现在的程序没有缺陷吗?

有一条经验法则是,程序中仍然存在的缺陷数量与我们已经发现的缺陷数量成正比。我们知道我们在第三章中发现的几个设计弱点。例如,程序有时会产生一个迷宫,里面有无法到达的位置。可能还有其他我们不知道的。这个程序能在所有运行 Python 的计算机上运行吗?老版本的 Python/Pygame 怎么样?我们不知道。因此,我们的问题的答案显然是否定的,仍然存在缺陷。但是我们不会为他们担心——这个项目现在已经足够好了。如果我们已经知道有问题,调试是有用的。在调试中,我们卷起袖子,弄脏手。

其他调试工具

Python 调试器 pdb

标准的 Python 调试器pdb有一个写得很清楚的文档值得查看: https://docs.python.org/3/library/pdb.html

PyCharm IDE

ipdb 的缺点是需要知道几个键盘命令。如果你喜欢使用像 PyCharm ( https://www.jetbrains.com/pycharm/ ),这样的 IDE,你可以做完全一样的事情,只是 PyCharm 有所有的按钮(见图 7-2 )。您可能会发现使用图形调试器单步调试程序更方便。PyCharm 调试器提供了一些值得一提的有用特性:

  • 您可以通过点击行号旁边的一个按钮来设置和删除断点(图 7-2 中的红点)。
  • 左下方的选项卡允许您切换到当前调用堆栈中其他框架的名称空间。
  • 调试器中底部中间的选项卡(变量)显示了当前名称空间的内容
  • 右下角的选项卡(Watches)允许您在单步执行代码时连续观察表达式的结果。

A419627_1_En_7_Fig2_HTML.jpg

图 7-2。

The interactive debugger in PyCharm provides the same functionality as ipdb through a graphical interface.

ipdbplugin

ipdbplugin是测试框架nose的插件。它允许你在运行自动测试时运行ipdb。在第三部分中,您将看到py.test框架的一个类似选项。https://pypi.python.org/pypi/ipdbplugin.

pudn

Andreas Kloeckner 是一个基于控制台的可视化调试器。它在控制台中创建了一个类似于 IDE 的屏幕布局,因此您可以获得比常规调试器更多的信息。pudb由键盘命令控制,可以让你非常快速地工作(参见 https://pypi.python.org/pypi/pudb ).

走过冬季情感论坛

wdb是一个网页调试器。它可以作为中间件插入到像 Django 或 Flask 这样的 Python web 服务器中,在 web 请求导致错误的情况下,它可以为您提供一个调试器。wdb能够在不同于 web 服务器的机器上运行调试器(参见 https://github.com/Kozea/wdb ).

django-调试-工具栏

Django web 服务器的一个插件,在浏览器页面上显示 web 请求的各种信息(参见 http://django-debug-toolbar.readthedocs.org )。

设定档

cProfile 是一个标准的 Python 模块,可以生成关于代码性能的详细报告。在cProfile的输出中,你可以看到每个函数被调用了多少次,Python 解释器在每个函数上花费了多少时间。如果你需要优化你的代码,它是在你的程序中找到瓶颈的一个很好的工具。见 https://docs.python.org/3/library/profile.html .

最佳实践

  • ipdb是一个 Python 的交互式调试器。
  • 您可以使用 prefer 或从 ipython 内部启动ipdb
  • 您可以在程序开始时、从任意一行或在异常发生后调用ipdb
  • 在调试器提示符下,您有一组命令来控制执行。
  • 您可以在调试器提示符下显示变量并执行普通的 Python 命令。
  • 您可以使用n执行单个指令,或者使用s单步执行功能。
  • 断点在给定位置启动调试器,而不编辑您的代码。
  • ipdb启动时,执行配置文件.pdbrc中的命令。
  • 修复缺陷有很多方法,有好的,也有坏的。
  • 即使你的程序看起来一切正常,里面可能还是有更多的缺陷。

八、编写自动化测试

质量工程师走进一家酒吧。点了杯啤酒。点了 0 瓶啤酒。订购 999999999 啤酒。订购一只蜥蜴。订单-1 瓶啤酒。订购 sfdeljknesv。——比尔·塞姆普夫(@塞姆普夫)在推特上

在本书的第二部分,我们将关注一种强大的编程技术:自动化测试。自动化测试被许多 Python 程序员视为基本的最佳实践。为什么会这样?在这一章中,我们将会看到一个关于什么是自动化测试以及它有什么好处的温和介绍。在关于调试的章节中,我们通过简单地执行程序来检查我们的程序是否工作。我们有时直观地将输出与我们自己的期望进行比较,有时使用准备好的输入/输出值表。然而,这种策略并不能很好地适应我们程序的规模:想象一下我们在游戏中加入了更多的元素(方块、关卡、谜题等的类型)。).每次我们添加一些东西,我们都需要从头到尾玩一遍并检查

  • 新功能是否有效。
  • 程序的其余部分是否仍然有效。

无论我们的游戏有多好,手动测试很快就会变成一项令人生畏的任务。在这里,自动化测试来拯救。测试是一项枯燥、重复的任务,因此我们可以(也应该)将其自动化。什么是自动化测试?基本上,在自动化测试中,我们编写一个程序来测试另一个程序。在某种意义上,我们已经这样做了:在第一章中,我们的每个模块都包含一个__main__块,我们用它来执行每个模块,看看它是否在工作。但是自动化测试有一些不同之处。术语自动化测试通常意味着

  • 测试代码给了我们一个明确的答案:测试通过或者失败。
  • 我们一次测试一小部分代码。
  • 我们使用专门的测试框架来简化测试代码。

在本书中,我们将使用测试框架 py.test 来编写自动化测试。自动化测试就像一个脚手架,随着代码的增长,它将代码保持在适当的位置。让我们考虑一个例子:我们想给我们的迷宫添加可移动的板条箱。有些走廊里有板条箱,我们可以推来推去,但不能拉。这意味着如果一个板条箱被扔在角落里,我们就再也拿不出来了。我们一次只能移动一个板条箱。在我们的示例中,我们必须移动板条箱一次才能到达出口(见图 8-1 )。

A419627_1_En_8_Fig1_HTML.jpg

图 8-1。

The four blue crates around the player shall be pushed. At least one needs to be moved in order to reach the exit.

安装 py.test

我们将使用 py.test 框架测试板条箱移动特性。首先,我们需要安装 py.test:

sudo pip install pytest

为了成功运行测试,py.test 需要能够从maze_run包中导入模块。为此,我们需要添加一个文件__init__.py来导入我们的模块:

from . import load_tiles
from . import moves
from . import draw_maze

我们还需要将PYTHONPATH变量设置为目录。您可以在 bash 控制台上使用

export PYTHONPATH=.

每次启动新的控制台会话时,我们都必须这样做。或者,我们可以在我们的.bashrc文件中添加变量

export  PYTHONPATH=$PYTHONPATH:/home/krother/projects/maze_run/maze_run

当然,你需要把路径调整到自己的maze_run目录。

编写测试函数

接下来,我们在一个名为test_crate.py的文件中创建测试本身。现在,我们将该文件放在与 MazeRun 代码相同的目录中。首先,我们从前面的章节中导入一些对象并创建一个迷宫,使用“o”作为板条箱的符号:

from draw_maze import parse_grid
from moves import move
from moves import LEFT, RIGHT, UP, DOWN

LEVEL = """
#######
#.....#
#..o..#
#.o*o.#
#..o..#
#.....#
#######"""

接下来,我们实现一个名为test_move_crate_right.py的小 Python 函数,它检查我们的代码是否正常工作(如果您在家尝试这样做,您可以选择不同的名称,只要函数名称以test_开头)。然后我们移动玩家一次:

def test_move_crate_right():
    maze = parse_grid(LEVEL)
    move(maze, RIGHT)
    assert maze[3][4] == '*'

最后两行中的assert语句检查玩家符号(' * ')是否向右移动了一个位置。如果我们用常规的 Python 解释器运行这个程序,什么都不会发生,因为在任何地方都不会调用test_move_crate_right函数。让我们看看 py.test 对函数做了什么。

运行测试

我们通过在 Unix 终端中键入来执行测试

py.test

并获得以下输出:

============================= test session starts ==============================
platform linux -- Python 3.4.0, pytest-2.9.2, py-1.4.31, pluggy-0.3.1
rootdir: /home/krother/projects/maze_run/maze_run, inifile:
collected 1 items

test_crate.py .

=========================== 1 passed in 0.06 seconds ===========================

发生了什么事?py.test 报告它收集了一个测试。它自动在我们的测试模块中找到了我们的测试函数(都由前缀test_标识)。然后它执行了test_crate.py中的测试。测试通过,意味着assert语句中的条件评估为True。我们的第一次测试成功了!

编写失败的测试

当然,检查玩家是否移动是不够的。我们需要检查板条箱是否也移动了。为了检查板条箱的新位置,我们向测试函数添加了第二个断言:

assert maze[3][5] == ’o’

当再次运行py.test命令时,它发现了一些要抱怨的东西:

===================================   FAILURES   ===================================
_____________________________________test_crate_____________________________________

     def test_move_crate_right():
         maze = parse_grid(LEVEL)
         move(maze, RIGHT)
         assert maze[3][4] == ’*’

>        assert  maze[3][5]  ==  ’o’

E        assert ’.’ == ’o’
E        - .
E        +  o

test_crate.py:19: AssertionError
=========================== 1 failed in 0.14 seconds ===========================

测试失败。在输出中,我们看到第 17 行的assert是失败的。如果条件失败,assert会引发一个AssertionError,老实说,这个异常本身并没有多大帮助。但是,py.test 劫持了异常,并明确告诉我们比较双方的值是什么。我们看到的不是一个箱子(' o '),而是一个点('.').显然,板条箱没有移动(相反,玩家吃了板条箱)。当然,板条箱没有移动,因为我们还没有实现移动板条箱。如果你想看的话,你可以从测试关卡开始游戏,然后自己啃几个箱子。

通过测试

要移动板条箱并通过测试,我们需要编辑moves.py中的move()功能。我们需要为“o”符号添加一个额外的if条件。让我们将板条箱向右移动一格:

def move(level, direction):
    """Handles moves on the level"""

    oldx, oldy = get_player_pos(level)
    newx = oldx + direction[0]
    newy = oldy + direction[1]
    if level[newy][newx] == ’x’:
        sys.exit(0)
    if level[newy][newx] == ’o’:
        level[newy][newx + 1] = ’o’

    if level[newy][newx] != ’#’:
        level[oldy][oldx] = ’˽’
        level[newy][newx] = ’*’

当我们重新运行测试时,它再次通过。

=========================== 1 passed in 0.06 seconds ===========================

通过与失败的测试

最后一个例子应该引起我们的关注,或者至少是思考。前面的代码只负责向右移动。代码充其量是不完整的。我们知道代码还不能工作,但是测试通过了。最后,我们必须注意自动化测试的一个基本事实:测试并不能证明代码是正确的。

那么,测试有什么好处呢?让我们考虑图 8-2 中的选择:如果代码是正确的,测试通过。如果代码是不正确的(错误的,错误的,不完整的,设计糟糕的,等等)。),两种测试结果都有可能。因此,如果我们观察到我们的测试通过了,这给了我们很少的信息。我们不能决定代码是否正确,因为测试可能不完整。反过来效果更好:每当我们观察到一个失败的测试,我们肯定知道有什么地方出错了。我们仍然不知道是代码错了,还是测试不完整,或者两者都有。但是我们有确凿的证据表明这种情况需要进一步的调查。简而言之:自动化测试证明了缺陷的存在,而不是不存在。知道通过测试并不意味着什么,使得自动化测试有点棘手。帮助我们编写有意义的测试的最佳实践:您可能已经注意到,在前面的章节中,我们甚至在编写移动板条箱的代码之前就已经运行了测试。首先编写一个失败的测试有助于我们证明我们编写的代码有所不同。如果测试从失败切换到通过,我们知道我们的代码比以前更加正确。我们将在第十一章中重温这个想法。

A419627_1_En_8_Fig2_HTML.jpg

图 8-2。

Possible outcomes when testing code. Testing does not prove that code is correct (with incorrect code, both outcomes are possible), but failing tests prove that something is wrong.

编写单独的测试函数

我们能做些什么来把我们的代码更坚定地推向正确的状态呢?答案非常简单:编写更多的测试。您可能会注意到,目前我们只实现了向右移动板条箱。从技术上讲,我们可以用与第一个方向相同的方式实现其他方向。我们将每个方向实现为一个单独的测试函数。四个函数名都以test_开头,这样 py.test 会自动发现它们。为了避免代码重复,我们将对move的调用和断言放在助手函数move_crate中:

def move_crate(direction, plr_pos, crate_pos):
    """Helper function for testing crate moves"""
    maze = parse_grid(LEVEL)
    move(maze, direction)
    assert maze[plr_pos[0]][plr_pos[1]] == ’*’
    assert maze[crate_pos[0]][crate_pos[1]] == ’o’

def test_move_crate_left():
    move_crate(LEFT, (3, 2), (3, 1))

def test_move_crate_right():
    move_crate(RIGHT, (3, 4), (3, 5))

def test_move_crate_up():
    move_crate(UP, (2, 3), (1, 3))

def test_move_crate_down():
    move_crate(DOWN, (4, 3), (5, 3))

使用助手函数来保持测试代码简短是一个最佳实践,因为通常要避免代码重复,测试代码也不例外。此外,自动化测试应该比被测试的代码简单(否则我们可能会调试两个复杂的程序,而不是一个)。但是为什么我们要定义四个单行函数,而不是将所有四个调用组合在一个函数中呢?在这个代码上再次调用py.test给了我们一个答案:

3 failed, 1 passed in 0.10 seconds

只有test_move_crate_right通过,因为在实现move()函数时,我们假设板条箱会一直向右移动,这当然是无稽之谈。py.test 给出的输出不仅告诉我们四个测试中有三个失败了,还精确地告诉我们是哪一个失败了。这给了我们比一个更大的测试函数更多的信息。显然,编写许多小测试是最佳实践。测试结果为我们提供了在move()功能中重写if条件处理箱的信息。更新的功能是

def move(level, direction):
    """Handles moves on the level"""

    oldx, oldy = get_player_pos(level)
    newx = oldx + direction[0]
    newy = oldy + direction[1]
    if level[newy][newx] == ’x’:
        sys.exit(0)
    if level[newy][newx] == ’o’:
        cratex = newx + direction[0]

        cratey = newy + direction[1]

        level[cratey][cratex]  =  ’o’

    if level[newy][newx] != ’#’:
        level[oldy][oldx] = ’˽’
        level[newy][newx] = ’*’

现在四项测试都通过了:

============================= test session starts ==============================
platform linux -- Python 3.4.0, pytest-2.9.2, py-1.4.31, pluggy-0.3.1
rootdir: /home/krother/projects/maze_run/part1_debugging, inifile:
collected 4 items

test_crate.py ....

=========================== 4 passed in 0.07 seconds ===========================

Note

许多程序员认为一个测试函数应该只包含一个断言。这通常是一个好的实践,但是在这一点上,它会不必要地膨胀我们的代码。在第九章中,我们将看到一种更优雅的方式来构建我们的测试并简化我们的代码。

断言提供了有用的输出

assert语句将计算 Python 中任何有效的布尔表达式。这允许我们测试许多不同的情况,其中一些应用于下面的二维列表maze:

def test_assert_examples():
    maze = parse_grid(LEVEL)
    assert len(maze) <= 7                            # comparison operator
    assert 1 < len(maze) < 10                        # range check
    assert maze[0][0] == ’#’ and maze[1][1] == ’.’   # logical operators
    assert maze[0].count(’#’) == 7                   # using methods

如前所述,py.test 将assert的输出从AssertionError变为更有意义的输出。这样做的一个实际方面是,我们可以在assert语句中比较列表、字典和其他复合类型。例如,我们使用仅由三块瓷砖组成的极简迷宫’*o#’编写了另一个测试:

def test_push_crate_to_wall():
    maze = parse_grid("*o#")
    move(maze, RIGHT)
    assert maze[0] == [’*’, ’o’, ’#’]

运行 py.test 时测试失败!显然,我们还没有注意到阻碍板条箱移动的墙壁。py.test 报告两个列表之间不匹配的位置(实际上,三个位置都不匹配,但第一个位置足以使测试失败):

E            assert [’ ’, ’*’, ’o’] == [’*’, ’o’, ’#’]
E              At index 0 diff: ’ ’ != ’*’

类似地,我们可以编写一个测试来确保板条箱也能阻止运动。这一次,我们比较了assert语句中的两个嵌套列表:

def test_push_crate_to_crate():
    maze = parse_grid("*oo")
    move(maze, RIGHT)
    assert maze == [[’*’, ’o’, ’o’]]

对于二维列表,我们还会得到关于不匹配项的精确报告:

E        assert [[’ ’, ’*’, ’o’]] == [[’*’, ’o’, ’o’]]
E          At index 0 diff: [’ ’, ’*’, ’o’] != [’*’, ’o’, ’o’]

在测试大型列表和类似的复合类型时,这种精确的输出是救命稻草。比较预期输出和实际输出可以在一个断言中完成,我们不需要手动比较列表来寻找不匹配。py.test 使用的 assert 语句非常通用。在测试失败的情况下,py.test 经常设法从表达式中创建一个有意义的输出(使用-v选项,它甚至变得更好)。因为我们可以在assert中使用任何类型的布尔表达式,所以我们可以构造实践中需要的大多数测试。

异常测试

有一种情况下assert语句不起作用:如果我们想测试是否出现了异常。让我们考虑一个例子:如果我们调用方向参数设置为None?move()函数会发生什么(我们在第七章中遇到了这种情况)。)假设我们想要创建一个TypeError,并且想要测试move是否会引发异常。测试没有异常产生是很容易的:

move(maze, None)
assert True

但是反过来就不行了。没有语法上正确的方法来编写一个单独的assert语句,用None调用move并在函数内部引发异常时通过。问题是,如果我们想让TypeError在一切正常的情况下发生,那么assert总是会因为那个异常而失败。我们可以用一个笨拙的方法来帮助自己:

try:
     move(maze, None)
except TypeError:
     assert True
else:
     assert False

现在,如果(且仅当)异常没有发生,测试失败。对于这种情况,py.test 提供了一种快捷方式,这是一种推荐的最佳实践。上下文管理器pytest.raises允许我们专门测试异常:

import pytest

def test_move_to_none():
    """direction=None generates an Exception"""

    maze = parse_grid(LEVEL)
    with pytest.raises(TypeError):
        move(maze, None)

为什么我们要针对异常进行测试?这样做有几个很好的理由:

  • 首先,我们不希望我们的代码表现出任何随机行为。如果代码失败,我们希望以我们之前指定的方式失败。pytest.raises函数是一个很好的方法。
  • 其次,它有助于我们控制错误——如果我们知道错误输入的行为方式,错误就不太可能在我们的代码中传播。
  • 第三,错误的来源变得更容易找到。
  • 第四,它帮助我们发现设计缺陷,那些我们以前根本没有想到的东西。

如果测试失败,我们会看到消息’Failed: DID  NOT  RAISE’。然而,它很好地通过了。

边境案件

到目前为止,我们编写的测试表现如何?在八个测试函数中,六个通过,两个失败。用py.test -v我们获得

============================= test session starts ==============================
platform linux -- Python 3.4.0, pytest-2.9.2, py-1.4.31, pluggy-0.3.1
rootdir: /home/krother/projects/maze_run/part1_debugging, inifile:
collected 8 items

test_crate.py::test_move_crate_left PASSED
test_crate.py::test_move_crate_right PASSED
test_crate.py::test_move_crate_up PASSED
test_crate.py::test_move_crate_down PASSED
test_crate.py::test_assert_examples PASSED
test_crate.py::test_push_crate_to_wall FAILED
test_crate.py::test_push_crate_to_crate FAILED
test_crate.py::test_move_to_none PASSED

==================================== FAILURES ==================================
________________________________test_push_crate_to_wall__________________________

    def test_push_crate_to_wall():
        maze = parse_grid("*o#")
        move(maze, RIGHT)
>       assert maze[0] == [’*’, ’o’, ’#’]
E       assert [’ ’, ’*’, ’o’] == [’*’, ’o’, ’#’]
E        At index 0 diff: ’ ’ != ’*’
E        Full  diff:
E        - [’ ’, ’*’, ’o’]
E        + [’*’, ’o’, ’#’]

test_crate.py:51: AssertionError
_______________________________test_push_crate_to_crate_____________________________

    def test_push_crate_to_crate():
        maze = parse_grid("*oo")
        move(maze, RIGHT)
>       assert maze == [[’*’, ’o’, ’o’]]
E       assert [[’ ’, ’*’, ’o’]] == [[’*’, ’o’, ’o’]]
E         At index 0 diff: [’ ’, ’*’, ’o’] != [’*’, ’o’, ’o’]
E         Full  diff:
E         - [[’ ’, ’*’, ’o’]]
E         + [[’*’, ’o’, ’o’]]

test_crate.py:57: AssertionError
=============================== 2 failed, 6 passed in 0.11 seconds =============

如果我们检查两个失败测试中的不匹配列表,我们会注意到板条箱很容易被移动。板条箱可以被推过任何东西,包括墙壁和其他板条箱。把它推得更远会使程序崩溃。我们忽略了几种可能出现的情况。这些被称为边界情况或边缘情况。一般来说,边界案例旨在用尽可能少的测试代码覆盖尽可能多的可能输入。好的边界案例涵盖了各种各样的输入。例如,浮点数的典型边界情况包括典型值和极值,如最大值和最小值、空的和错误的数据或错误的数据类型。在我们的案例中,我们没有明确涉及的边界案例包括

  • 把板条箱推到墙上。
  • 把一个板条箱推进另一个板条箱。
  • 把板条箱推进出口。

为了满足我们的边界情况,我们需要再次更新move()函数:

def move(level, direction):
    """Handles moves on the level"""
    oldx, oldy = get_player_pos(level)
    newx = oldx + direction[0]
    newy = oldy + direction[1]
    if level[newy][newx] == ’x’:
        sys.exit(0)
    if level[newy][newx] == ’o’:
        cratex = newx + direction[0]
        cratey = newy + direction[1]
        if level[cratey][cratex] in ’. ’:
            level[cratey][cratex] = ’o’

            level[newy][newx] = ’ ’

        elif level[cratey][cratex] == ’x’:

            raise NotImplementedError("Crate pushed to exit")

    if level[newy][newx] in ’. ’:

        level[oldy][oldx] = ’˽’
        level[newy][newx] = ’*’

最后一个添加使得我们所有的测试都通过了:

=========================== 7 passed in 0.08 seconds ===========================

我们也可以为其他方向测试相同的边界情况。但是由于我们正在测试的代码不是很长或者很复杂,我们决定我们的七个测试现在已经足够好地覆盖了可能的情况(也见图 8-3 )。

A419627_1_En_8_Fig3_HTML.jpg

图 8-3。

“I admit the chance of finding a seven-tentacled monster below the house was low, but I found it worth testing anyway.”

复杂的边境案件

在前面的例子中,边界情况相对容易识别。我们只是简单地考虑如果某块瓷砖挡住了板条箱会发生什么。通常,边界案例并不容易找到。原因是输入包含很多维度。为了探索这个想法,让我们进行一个思维实验:想象我们将增加使玩家变得超强的能量,这样它就可以移动多个板条箱(见图 8-4 )。假设每个玩家都有一个实力,用浮点数表示,一个玩家一次可以移动到strength crates 的平方根。

A419627_1_En_8_Fig4_HTML.jpg

图 8-4。

When we add the strength of the player as an extra dimension, the border cases become a lot more complex.

这一小小的增加引发了一系列关于板条箱到底是如何移动的问题:

  • 强度值需要多大的数值精度?
  • 实际移动的板条箱的最大数量是多少?
  • 最小数量是多少?有没有可能是玩家太弱了,什么都推不动?
  • 平方根的舍入是如何进行的(向上、向下、数学)?
  • 强度可以是负的吗(这里是虚数)?
  • 实力可以无限大吗?

如果我们想涵盖前面例子中的所有边界情况,我们必须考虑这六种情况中每一种的至少一个代表性强度值。更重要的是,假设我们想要确保加电功能在所有可能的情况下都能正常工作。然后,我们必须将每个有代表性的力量值与我们之前测试的每个游戏情况结合起来。结果将是几十个额外的测试。如果有更多的维度,比如运动方向、上电强度、可能挡路的瓷砖等等,那么通过测试详尽地涵盖所有情况就变得不可行了。幸运的是,推板条箱游戏在实践中并不复杂,但许多现实世界的应用程序却很复杂。令人讨厌的边界情况的范围从数学计算、读取输入文件到各种模式匹配。在我的不止一个编程项目中,维护一个干净的、有代表性的边界测试用例集合(并迫使程序理解它们)是投入工作的主要部分。一般的策略是挑选能够很好地代表我们输入的边界案例。如果我们把程序的可能输入想象成一个多维空间,通过选择边界情况,我们试图用尽可能少的测试和尽可能多的必要测试来定义那个空间的轮廓。

自动化测试的好处

到目前为止,我们已经编写了五个测试,我们有了一个自动化的过程,允许我们(或其他开发人员)检查移动的板条箱是否工作。在程序的未来开发过程中,测试可能失败的原因有几个:

  • 我们给游戏增加了更多的功能,却意外地破坏了箱子的功能。
  • 我们修复了一个缺陷,却意外地破坏了板条箱特征。
  • 我们重命名了move函数,这样我们的测试就不会再找到它了。
  • 我们改变编码墙砖和地砖的字符,这样我们的助手函数move_crate就可以检查无意义的内容。
  • 我们尝试在没有安装 P ygame的计算机上运行测试。

当然,我们简单的测试无法自动找出到底哪里出了问题。但是自动化测试的显著之处在于,在所有这些情况下,py.test 都会产生失败的测试,这告诉我们:“有些地方出错了,请进一步调查问题。”这难道不是件好事吗?

毫无疑问,编写自动化测试会预先产生额外的工作。这项工作有什么回报?还有三个好处值得一提。首先,自动化测试节省了时间。我们开始这一章的前提是手工测试是不可伸缩的。想象一下,每次修改代码时,您都必须手动检查所有的边界情况。你要么花大量时间手工测试,要么跳过一些测试,花更多时间在你的代码上。有了可重复的自动化测试,在运行几次测试之后,投入的努力就有了回报。第二,自动化测试促进了结构良好的代码。值得指出的是,我们可以在不初始化图形或事件循环的情况下完全测试move()函数。代码以易于独立测试的方式编写。如果我们要测试一个单一的大函数,高质量的测试会很复杂,如果不是不可能编写的话。第三,自动化测试是有益的。当你执行你的测试,看到失败的测试变成了通过,这给了程序员一种成就感。通常,这种积极的反馈会让你坚持下去,尤其是在咬穿一段困难的代码时。至少这是我和许多其他 Python 程序员反复遇到的情况。所有这三个好处对于自动化测试的最佳实践都是必不可少的。为了更深入地理解它们,当我们在接下来的章节中看到更多的自动化测试技术时,我们将需要重温它们。

Python 中的其他测试框架

py.test 的一些替代方法值得一提:

单元测试

unittest是 Python 安装的默认测试框架。因为unittest不需要任何安装,所以知道它是如何工作的就好。实际上,unittest提供了 py.test 的一小部分可能性,主要是将测试分组到类中。

鼻子

测试框架 nose 通过自动测试发现和一些其他方便的特性扩展了unittest。nose 和 py.test 是远亲,py.test 是两者中更强大的。我们将描述限制在一个与鼻子相关的链接上: http://nose.readthedocs.io

doctest(测试)

doctest 框架的工作方式完全不同。文档测试是写入 Python 文档字符串的 Python shell 会话。doctests 的伟大之处在于,它使得在文档中包含可测试的代码示例变得很容易。第十七章描述了如何将 doctests 包含在文档中。

编写 main

在一些模块中,您可以在__main__块中找到测试代码。如果一个程序不需要__main__块进行常规执行,我们不妨为模块中的函数编写一些测试。例如,我们可以测试移动的板条箱:

if __name__ == ’ __main__’:
    maze = parse_grid(LEVEL)
    move(maze, direction)
    if maze[3][5] == ’o’:
        print(’OK’)

在非常小的程序中,这是一个可以接受的策略,用来测试不直接执行的模块。一方面,我们会在一段时间内避免使用较重的基础设施。另一方面,随着项目的增长,这种测试很快变得很麻烦。举个例子,你不能用这种方式测试多个模块。一旦代码增长超过 100 行,就有必要切换到“真正的”测试框架。

最佳实践

  • 自动化测试由检查程序是否工作的代码组成。
  • py.test 是一个用 Python 编写自动化测试的框架。
  • 要导入被测模块,需要将它们导入到_init_.py.文件中
  • 断言决定测试是通过还是失败。
  • py.test 提供了断言失败原因的详细信息。
  • 功能pytest.raises允许明确测试故障。
  • 编写许多小的测试函数比一些大的测试能提供更多的信息。
  • 失败的测试提供了最有用的信息。测试证明缺陷的存在,而不是不存在。
  • 首先编写一个失败的测试,然后让它通过是一个更可靠的方法。
  • 测试应涵盖不同的边界情况。
  • 收集和维护边界案例的工作可能相当繁重。
  • 自动化测试节省了时间,促进了代码的可维护性,并且是有益的。

九、组织测试数据

质量工程师走进一家酒吧。在第一杯啤酒上桌之前,迅速点了第二杯啤酒。—@botticus

我们在前一章中的测试很容易编写。每个测试只需要很少的输入,所以测试函数最多只有三四行。但是如果我们想为更复杂的情况编写测试呢?我们需要仔细查看与我们的测试相关的数据。理想情况下,我们希望测试不同的数据,以覆盖尽可能多的边界情况(见图 9-1 )。同时,我们不能通过测试来覆盖程序的每一个可能的输入。如何实现数据驱动测试?在这一章中,我们将遇到有效地组织我们的测试和相应数据的最佳实践。我们将实现需要准备好的示例数据的测试,并从数据和测试中创建一系列测试,以隔离程序内的模块间依赖。我们将从测试在 MazeRun 移动板条箱的几个特定场景开始,我们还没有检查:

  • 把板条箱移到角落。
  • 来回移动板条箱。
  • 移动到不同路径上的相同位置。
  • 搬几个板条箱。

A419627_1_En_9_Fig1_HTML.jpg

图 9-1。

Test data is like collecting diverse objects and checking which of them burn

这种测试场景更加复杂,可能会发生在真实的游戏场景中。因此,它们给了我们一个机会去发现意想不到的错误,这些错误会通过简单的测试而不被注意到。我们的场景有一个共同点,它们由多个步骤组成,需要一些准备工作。

使用夹具

当针对多种情况编写测试时,测试数据通常会在几个测试中重复。在第八章中,我们编写了一个助手函数来准备迷宫并执行断言 s。这里,我们将使用 py.test 提供的快捷方式,它允许我们更灵活地重用我们的测试数据:fixtures。py.test 中的 fixture 是一个为一个或多个测试返回数据的函数。要创建一个 fixture,我们需要将@ pytest.fixture装饰器应用到函数中。测试前会自动调用 fixture。假设我们想要实现我们的第一个测试场景:将一个板条箱移动到一个角落。我们从导入几个模块开始,使用与上一章相同的级别(见图 9-2 )。然后我们实现 fixture 函数:

A419627_1_En_9_Fig2_HTML.jpg

图 9-2。

The level used for several scenario is provided by the level fixture

from .draw_maze import parse_grid
from .moves import move
from .moves import LEFT, RIGHT, UP, DOWN
import pytest

LEVEL = """#######
#.....#
#..o..#
#.o*o.#
#..o..#
#.....#
#######"""

@pytest.fixture
def level():
    """A level with four single crates"""
    return parse_grid(LEVEL)

夹具level由一条线组成。这不是一件坏事。创建我们的测试数据的过程可能会在以后变得容易(例如,如果我们决定以后使用类)。现在,我们可以通过简单地添加level作为附加参数,在测试函数中使用我们的 fixture。要将顶部的板条箱移到角落,我们需要将它向上推,绕过它,然后向左推三次:

def test_move_crate_to_corner(level):
    """Moves top crate to upper left corner"""
    for d in [UP, RIGHT, UP, LEFT, LEFT, LEFT]:
        move(level, d)
    assert level[1][1] == 'o'

当我们用上一章更新的 move 函数调用py.test时,测试通过。注意,我们不必关心告诉我们的测试函数level参数来自哪里。py.test 为我们做了这些!

Reminder

如果运行测试不起作用,在调用py.test.之前尝试export PYTHONPATH =.

只有当我们不止一次使用时,创建夹具才变得有用。对于我们的第二个场景,我们将来回移动一个板条箱。这是一个简单的健全性检查。好的测试通常包括一个循环或重复的操作,例如读一个文件,写一个文件,然后再读一次,以确保数据在过程中不会改变。来回移动板条箱也是为了同样的目的。即使移动一次板条箱工作正常,第二次也可能会卡住。这次我们将在测试中使用一个assert来比较两个列表

def test_move_crate_back_forth(level):
    """Sanity check: move the top crate twice"""

    for d in [LEFT, UP, RIGHT, UP, RIGHT, RIGHT, DOWN, LEFT, LEFT, LEFT]:
        move(level, d)
    assert level[2] == list('#o*   #')

两项测试都通过了。这里我们可能会注意到一个关于夹具的重要事实:两个测试都移动顶部的板条箱。更重要的是,我们的第一次测试把板条箱推到了一个它应该永远呆在那里的角落。尽管如此,我们的第二次测试通过了。这是可能的,因为每次测试前都会重新创建夹具。py.test 负责在每次测试前通过再次调用level将板条箱重置到其起始位置。

范围参数

有时创建夹具会花费很多时间(例如,读取一个巨大的文件、创建一个数据库、启动 web 服务等。).如果许多测试使用如此昂贵的夹具,测试执行将会变慢。如果数据没有改变,我们可以通过设置scope参数来指示 py.test 只为整个测试模块初始化一次夹具。我们将fixture装饰器替换为

@pytest.fixture(scope='module')

请注意,在我们的示例中,第二次执行的测试将会失败,因为夹具中的顶部板条箱已经在其他地方了。相互干扰的测试很难调试,这就是为什么我们将 scope 参数保留为默认值(函数级别)。

测试参数化

另一个典型的情况是,我们想要编写许多类似的测试。假设我们想要检查另一个测试场景:移动到不同路径上的相同位置。假设我们有以下路径通向level夹具中的位置(2, 2):

PATHS = [
    (UP, LEFT),
    (LEFT, UP),
    (RIGHT, UP, LEFT, LEFT),
    (DOWN, DOWN),  # wrong on purpose
]

为了避免代码重复,我们可以使用for循环来测试这些路径:

def test_paths():
    for path in PATHS:
        level = parse_grid(LEVEL)
        for direction in path:
            move(level, direction)
        assert level[2][2] == '*'

然而,这种方法有严重的缺点:

  • 我们不能使用我们的 fixture level,因为我们需要在测试中多次重新初始化它。
  • 只要一条路径失败,整个测试就会失败。我们不知道有多少条路径出现故障。
  • 如果一条路径失败,其余的路径将不会被执行。我们不会知道他们是否会失败。

一个更好的选择是从我们的数据中自动生成测试。在 py.test 中,这种策略被称为测试参数化。我们编写一个测试函数,并指示 py.test 用不同的参数调用这个测试函数。每个调用将导致一个单一的测试。为了使用测试参数化,我们应用了@ pytest.mark.parametrize装饰器:

@pytest.mark.parametrize('path', PATHS)
def test_paths(path, level):
    """Different paths lead to the same spot"""
    for direction in path:
        move(level, direction)
    assert level[2][2] == '*'

当我们运行测试时,我们会看到四个新的测试。PATHS中的每个条目产生一个单独的测试函数。py.test 给每个测试分配编号,这样我们就可以更容易地识别它们。事实上,第四个测试失败了,因为(DOWN, DOWN)不是左上角的有效路径。在输出中,我们看到test_paths[path3]正在失败(索引照常从 0 开始):

test_data.py .....F

================================ FAILURES =================================
_____________________________test_paths[path3]_____________________________

path = ((0, 1), (0, 1))
level = [['#', '#', '#', '#', '#', '#', ...], ['#', '.', '.', '.', '.', '.',
...], ['#', '.', '.', 'o', '.', '.', ...], ['#', '.', 'o', ' ', 'o', '.',
...], ['#', '.', '.', '*', '.', '.', ...], ['#', '.', '.', 'o', '.', '.',
...], ...]

    @pytest.mark.parametrize('path', PATHS)
    def test_paths(path, level):
        """Different paths lead to the same spot"""

        for direction in path:
            move(level, direction)
>       assert level[2][2] ==  '*'
E       assert '.' == '*'
E         - .
E         +  *

test_data.py:50:  AssertionError
=================== 1 failed, 5 passed in 0.09 seconds ====================

如果我们预期一个测试会失败,我们可以在定义PATHS:时使用pytest.mark.xfail函数将测试标记为“预期失败”

PATHS = [
    (UP, LEFT),
    (LEFT, UP),
    (RIGHT, UP, LEFT, LEFT),
    pytest.mark.xfail((DOWN, DOWN))
]

py.test 将在输出中用'x'表示该测试。最佳实践是将xfail作为一个占位符来标记需要在以后修复的测试。

多参数

通过测试参数化,我们可以用一个装饰器为一个测试函数提供多个参数。这种模式通常用于向测试提供输入数据和相应的预期输出。这里我们只举一个简短的例子。我们的输入应该是一个运动路径,我们的预期输出是玩家的最终 x/y 位置:

PATH_PLAYERPOS = [
    ((LEFT,), 2, 3),
    ((LEFT, RIGHT), 3, 3),
    ((RIGHT, RIGHT), 4, 3),
]

@pytest.mark.parametrize('path, expected_x, expected_y', PATH_PLAYERPOS)
def test_move_player(level, path, expected_x, expected_y):
    """Player position changes correctly"""

    for direction in path:
        move(level, direction)
    assert level[expected_y][expected_x] == '*'

'@pytest.mark.parametrize'装饰器中,参数名以字符串的形式给出。py.test 自动将它们分配给我们的测试函数的参数,这给了我们三个额外的通过测试。

参数化夹具

测试参数化是使测试代码更短更简单的好策略。参数化夹具是将@pytest.mark.parametrize装饰器应用于测试的一种替代方法。例如,我们可以检查关卡中的点是否有任何不同。如果我们定义一个相同的水平,但没有点,我们会期望相同的结果。要创建级别,字符串替换就足够了:

LEVEL_NO_DOTS = LEVEL.replace('.', ' ')

我们不想复制到目前为止编写的所有测试函数,而是想告诉 py.test 将所有东西运行两次:一次在关卡中有圆点,一次没有。创建参数化夹具类似于测试参数化。这一次,我们需要将测试数据作为params参数添加到@pytest.fixture装饰器中,并在request.params.中使用它。我们通过替换level夹具来实现这一点:

@pytest.fixture(params=[LEVEL, LEVEL_NO_DOTS])
def level(request):
    """A level with four single crates"""
    return parse_grid(request.param)

使用level夹具的每个测试都在两个级别中的每一个级别上执行一次。因此,我们有效地测试了两倍多的情况!通过编写四个测试函数,我们已经涵盖了 16 种不同的情况。这应该使我们的板条箱功能充分测试。

Hint

测试夹具和测试参数化在 py.test 中配合得非常好。我们可以在单个测试函数中结合多个参数化夹具和测试参数化。这样,您可以用很少的代码轻松地生成数百个测试,因为 py.test 会在生成测试时遍历所有的参数组合。在使用参数化测试时,要注意执行时间!

嘲弄的

在这一节中,我们将关注一个更具挑战性的测试环境:我们如何测试连接到其他组件的程序的一部分?如果这些组件在测试中做了我们不希望发生的事情怎么办?假设我们想测试一个向打印模块发送文档的函数。这是否意味着每次运行测试时,我们都必须打开打印机,插入纸张,并观察打印的页面?更糟糕的是,如果外部组件正在等待用户输入呢?幸运的是,Python 为这种场景提供了一个优雅的解决方案:模仿。

模拟是在我们的程序中替代真实对象的假对象。Mock 模拟被替换对象的行为,但实际上不做任何事情。使用 Mock,我们可以将正在测试的组件与其依赖项隔离开来,并检查 Mock 是否被调用。由于 Python 中的动态类型系统,模仿几乎可以插入到任何地方。模块unittest .mock给了我们一个创建模拟的舒适界面。

假设我们想在 MazeRun 中测试图形。详细测试图形(捕获图像并分析其内容)会导致惊人的开销。如果我们假设 Pygame 工作正常的话,大部分甚至是不必要的。我们需要知道的是是否已经对 Pygame 函数进行了相应的调用。下面我们通过两个unittest.mock的例子来说明它的用法。为了简单起见,我们想要测试draw,一个绘制单个图块的短函数:

from pygame import image, Rect
import pygame

pygame.init()
pygame.display.set_mode((80, 60))

def draw(surface):
    img = image.load('img/tiles.xpm')
    surface.blit(img, Rect((0, 0, 32, 32)), Rect((0, 0, 32, 32)))
    pygame.display.update()

首先,我们将测试 draw 函数是否真的在调用pygame.display.update来更新屏幕。为此,我们用一个 Mock 替换了pygame.display.update@mock.patch装潢师会处理这些:

from unittest import mock

@mock.patch('pygame.display.update')
def test_mocking(mock_update):
    display = pygame.display.get_surface()
    draw(display)
    assert mock_update.called is True
    assert mock_update.call_count == 1

装饰器自动将创建的模拟交给我们的测试函数。我们可以给它取任何我们喜欢的名字(这里,mock_update)。这两个assert语句使用了 Mock 的两个属性,都验证了我们的 Mock 函数已经被调用。

对于第二个测试,我们想看看是否在display对象上画了什么。为此,我们从MagicMock类中创建一个模拟对象来代替display:

def test_blit():
    mock_disp = mock.MagicMock(name='display')
    draw(mock_dist)
    assert mock_disp.blit.called is True

两项测试都通过了。MagicMock对象如何知道自己有一个blit方法?MagicMock类的一个实用属性是,它对所有事情都说“是”,并在需要时为自己创建新属性。这个属性使得模仿有点危险,因为我们可能会意外地创建通过测试。

def test_bad_mocks():

  mo = mock.MagicMock()
  assert mo.twenty_blue_dolphins()
  assert mo.foo.bar('spam')['eggs']
  assert mo.was_called()  # wrong method that passes
  assert mo.caled         # typo that passes!

通常,模仿是替换外部组件的最佳实践:网络操作、时钟、随机数、输入/输出流等等。我们可以模仿os.environ在测试期间创建假的环境变量。我们可以模仿time.sleep来减少延迟并加速我们的测试。我们可以模仿urllib来创建一个总是有效的网络连接。模块unittest.mock提供了许多有趣的选项,值得一读。详见 https://docs.python.org/3/library/unittest.mock.html

测试输出文件

在许多应用程序中,我们需要测试程序产生的输出文件是否正确。涉及文件的测试通常会带来至少四个挑战:

  • 我们需要先读取文件(作为字符串或使用支持库),然后才能解释其内容。
  • 通常有很多信息需要我们解析、构造、过滤等等。
  • 读取文件的测试可能在许多方面失败(错误的内容、错误的路径、缺少写权限)。因此,测试结果更难解释。
  • 如果我们的测试创建了一个文件,我们需要在之后清理它。否则,下一次测试可能会通过,因为文件仍然在那里,即使我们在此期间破解了代码。

在这些挑战中,最后一个是最危险的,因为它可能会掩盖我们程序中的缺陷。我们将首先处理这个问题。为了避免测试留下文件的问题,我们可以尝试用os.remove自己清理输出文件:

import os

def test_file_output():
    open('output.txt', 'w').write("Hello  World!")  # the code being tested
    assert os.path.exists('output.txt')
    os.remove('output.txt')

只要代码工作正常或因异常而终止,这就很好。该测试中的潜在缺陷发生在以下事件序列中:

  1. 被测试的代码创建文件,但是随后崩溃并出现异常。测试失败,但输出文件在那里。
  2. 在下一次测试运行之前,我们试图修复代码,但意外的是它什么也做不了。
  3. 在下一次测试运行中,代码什么也不做,但是测试仍然会找到该文件。测试通过。

更好(更安全)的替代方法是在测试前删除输出文件。但是接下来,我们会用输出文件来填充我们的磁盘。您可能已经猜到,对于这种情况,有一些很好的捷径。

测试后清理

py.test 提供了一种测试后清理的机制。功能teardown_functionteardown_module在测试(或模块中的所有测试)完成后执行。测试前运行的相应函数分别是setup_functionsetup_module。为了在测试完成后删除输出文件,我们需要编写

import os

def teardown_function(function):
    if os.path.exists('output.txt'):
        os.remove('output.txt')

def test_file_output():
    open('output.txt',  'w').write("Hello  World!")  # the code being tested
    assert os.path.exists('output.txt')

这种方法的主要优点是,即使测试因异常而终止,也会在每次测试后自动调用 teardown 函数。将清理代码从我们的测试中分离出来是一个最佳实践。

Hint

如果您对清理单个夹具感兴趣,请查看屈服夹具的 py.test 文档: http://doc.pytest.org/en/latest/fixture.html#fixture-finalization-executing-teardown-code

使用临时文件

另一种可能是使用临时文件。Python 标准库中的tempfile模块允许我们创建临时文件对象,这些对象随后会自动删除:

import tempfile

def test_file_output():
    with tempfile.TemporaryFile('w') as f:
        f.write("Hello World!")  # the code being tested

在这种情况下,我们的测试不需要 teardown 函数。TemporaryFile对象要求我们的代码接受文件对象作为输入。如果我们需要一个物理文件(例如文件名),在 py.test 中使用tmpdir fixture 是一个更好的主意:

def test_file_output_with_tempdir(tmpdir):
    tempf = tmpdir.join('output.txt')
    tempf.write("Hello  World!") # the code being tested
    content = tempf.read()
    assert content == "Hello World!"

tmpdir fixture 的一个有用的方面是测试结束后不会自动删除临时文件。在 Unix 上,它们存储在/tmp目录中,在那里它们不太可能干扰我们程序的其余部分。如果测试失败,将显示确切的路径。这使得tmpdir成为存储输出以供以后诊断的好地方。详情请查阅 py.test 文档: http://docs.pytest.org/en/latest/tmpdir.html

将输出文件与测试数据进行比较

当我们生成输出文件(计算结果、报告、日志文件)时,有许多细节需要测试。手动创建一个正确的样本文件,并将样本与实际输出进行比较,通常比解析文件和为每个细节编写测试更容易。Python 标准库提供了两个模块来帮助我们:

filecmp 模块

filecmp.cmp函数接受两个文件对象并返回TrueFalse。假设我们有一个文件'output.txt'包含测试结果,另一个文件'expected.txt'包含手工准备的样本,我们可以用

import filecmp

def test_compare_files:
    open('output.txt', 'w').write("Hello World!")  # the code being tested
    assert filecmp.cmp('output.txt', 'expected.txt')

这种方法的缺点是,如果文件不相等,我们将看不到它们有什么不同。为此,我们需要第二个模块。

difflib 模块

difflib模块逐行比较两个文件,并产生类似于 Unix diff命令的输出。函数difflib.ndiff接受两个字符串列表。difflib.ndiff函数的输出对于测试断言不是很有用,因为如果测试失败,py.test 不会显示完整的输出。但是我们可以扩展前面的测试来打印关于文件不匹配的信息:

def test_compare_files():
    open('output.txt', 'w').write("***Hello World***") # the code being tested
    lines_result = open('output.txt').readlines()
    lines_expected = open('expected.txt').readlines()
    print('\n'.join(difflib.ndiff(lines_result, lines_expected)))
    assert filecmp.cmp('output.txt', 'expected.txt')

如果测试失败,py.test 只显示标准输出(和差异)。这就是为什么这个例子包含了一些额外的星号。我们获得了单个字符的不匹配:

-------------------------- Captured  stdout  call ---------------------------
- ***Hello World***
? ---           ˆˆˆ

+  Hello  World!
?              ˆ

涉及大文件的测试的最佳实践

在测试大型数据文件时,发现差异并不是唯一的问题。此外,首先设计测试是具有挑战性的。我们应该测试什么?我们需要多少测试?对于一个大的输出文件,很明显我们不能测试所有的东西。在下面的列表中,您会发现一些涵盖多种情况的典型测试:

  1. 首先测试文件是否在那里。
  2. 测试文件是否不为空。
  3. 读取文件并检查它是否有正确的条目数。
  4. 将文件与样本文件进行匹配。
  5. 检查一些典型的细节,不是全部。
  6. 在将输出数据写入文件之前,对其进行测试。通常写作本身是琐碎的,或者可以委托给像csvjson这样的支持库。

生成随机测试数据

许多测试需要包含姓名、电子邮件地址或简单文本的样本数据记录。我们自己创造几个虚拟人很有趣,但创造一整本电话簿就不好玩了。faker 包帮助我们为许多常见和不太常见的字段生成随机数据。我们可以安装 faker

pip install faker

这个包为我们提供了一个Faker对象,它的方法随机生成数据:

In [1]: import faker
In [2]: f = faker.Faker()

In [3]: f.date()
Out[3]: '1979-12-17'

In [4]: f.name()
Out[4]: u'Sonji Moore'

In [5]: f.sentence()
Out[5]: u'Unde ea quidem asperiores voluptate eos adipisci sed.'

生成的数据涵盖了许多边界情况。例如,f.name()经常抛出 PhDs、royalty 和其他前缀和后缀,如果考虑得太晚,会让开发者头疼。faker 中有很多方法可以生成随机测试数据。值得看一下dir(f)的输出。

在哪里存储测试数据?

到目前为止,我们将大部分测试数据存储在带有测试函数的 Python 文件中。这是一个公认的最佳实践,即使在非常大的测试集中也能找到(例如,在 Python 标准库的测试中)。但是,有一些替代方案值得一提。在这里,我们将简要地看一下它们。

测试数据模块

将测试数据(例如,所有夹具)分组到单独的模块中。这种方法的优点是多个测试模块可以访问相同的数据。

测试数据目录

如果您的测试需要文件(输入好的/坏的/奇异的例子,样本输出),那么将它们分组在一个单独的目录中是值得的。这个集合可能会在一段时间后发展出自己的生命。如果您存储了您或您的用户发现的令人讨厌的例子,测试数据就会成为项目集体记忆中有价值的一部分。

测试数据库

当您的程序使用数据库时,您可能需要一个专用的测试数据库。实践中经常发现两种方法。首先,您可以在测试中创建数据库。每次开始测试时,fixture 都会创建一个新的数据库,用数据填充它,然后销毁数据库。这种方法的缺点是从头开始创建数据库通常很慢。哪怕延迟只有几秒钟,这都会拖累你的开发速度。其次,您可以维护一个专用的测试数据库。您永久地保留一个数据库用于测试。这种方法往往更快,并且它给你一个地方来存储你的测试例子。这种方法的缺点是维护数据一致性更加困难:总是存在代码意外更改数据并破坏测试数据库的风险。有一些策略可以降低风险(例如,只读访问或使用事务),但是一般来说,您需要关注如何管理数据库。

没有完美的解决方案,维护一个非常大的测试数据集本身就是一个有趣的项目。

最佳实践

  • Fixtures 是为测试函数准备数据的函数。
  • 参数化测试从数据条目列表中创建多个测试。
  • 模仿使用用unittest.mock模块生成的假对象。
  • 用模拟代替外部对象,测试代码的依赖性变得更简单。
  • py.test 中的清理函数在测试完成后被调用。
  • tmpfiletmpdir fixture 创建的临时文件是测试生成的输出文件的好地方。
  • filecmp 和 difflib 模块有助于比较文件。
  • faker 库创建随机数据用于测试。
  • 有了多方面的投入,就不可能详尽无遗地涵盖所有的边界情况。一个粗略的指导方针是写尽可能多的测试和尽可能少的测试。

十、编写测试套件

如果你不关心质量,你就无法满足任何其他要求。—杰拉尔德·m·温伯格

到目前为止,我们已经为我们的游戏创建了一套测试函数。我们可以从 Linux 命令行运行所有测试,只需输入'py.test'或'py.test filename.py'。如果我们正在测试一个更大的程序,测试的数量将很容易增长到数百个。对于参数化测试,甚至成千上万的测试都是常见的。我们如何组织如此大量的测试,并且仍然有效地使用它们呢?

为了更好地组织我们的测试,我们将在本章中创建一个测试套件。测试套件是伴随给定程序的一组完整的结构化测试。要为 MazeRun 编写一个测试套件,我们将使用我们已经编写的测试。我们将把我们的测试组织成模块、类和包。我们将重构测试代码,使其更加简洁。我们还将以不同的方式执行测试,这样我们就不需要每次都运行整个测试套件。最后,我们将计算测试覆盖率,以衡量我们的代码有多少已经被测试。一个好的测试套件是一个强大的诊断工具,可以识别程序中的各种问题。测试套件可以测试基本功能是否工作(单元测试),软件系统的组件是否一起工作(集成测试),或者用户要求的功能是否存在(验收测试)——下一章将详细介绍这三个方面。我们通过将每个测试放在一个单独的函数中来编写我们的第一个测试。我们有意将测试函数存储在单独的模块中。在最后两章之后,py.test 的输出如下所示:

=============================== test session starts ===============================
platform linux -- Python 3.4.0, pytest-2.9.2, py-1.4.31, pluggy-0.3.1
rootdir: /home/krother/projects/maze_run/tests, inifile:
plugins: cov-2.3.0
collected 33 items

test_crate.py ........
test_data.py .......x...x......
test_files.py ....
test_mock.py ...
====================== 31 passed, 2 xfailed in 0.30 seconds =======================

随着测试函数数量的增长,我们需要更加关注整体结构。为了改进结构,我们将清理或重构我们的测试套件。与普通代码类似,我们将把函数分组到合理的高阶结构中。我们要做的第一件事是改进测试模块的结构。然后我们将把我们的测试分组。

测试模块

通过许多测试,测试模块提供了一种构造测试套件的简单方法。我们可以将测试分成多个模块。从技术上讲,这很容易做到:我们只需将相应的测试复制到一个新的 Python 文件中。如果这些模块的名称像前面一样带有test,py.test 中的自动发现将相应地收集所有测试模块。通常,我们不需要显式地导入我们的测试模块。然而,在组织测试模块时,需要记住一些最佳实践:

  • 首先,测试模块的名字应该和被测试的模块一样。按照惯例,如果我们正在测试maze_run/moves.py模块,相应的测试模块应该是test_moves.py。这使得为一段给定的代码寻找测试代码变得更加容易,反之亦然。
  • 第二,助手函数和测试数据应该保存在不同的模块中。在我们的测试目录中有一个额外的非测试模块(例如util.py or fixtures.py),测试模块可以在那里导入它们,这有助于避免测试套件中的冗余。
  • 第三,所有的测试模块应该在同一个地方。

我们可以对模块的结构进行一些改进。重组我们的测试模块主要包括将相似的测试组合在一起,并给每个模块起一个描述性的名字。首先,我们将我们的 fixtures 移动到一个单独的模块中,fixtures.py,其他测试可以从这个模块中导入它们。夹具模块包含我们的参数化夹具level:

import pytest
from maze_run.draw_maze import parse_grid

LEVEL = """#######
#.....#
#..o..#
#.o*o.#
#..o..#
#.....#
#######"""

LEVEL_NO_DOTS = LEVEL.replace('.', ' ')

@pytest.fixture

(params=[LEVEL, LEVEL_NO_DOTS])
def level(request):
    """A level with four single crates"""
    return parse_grid(request.param)

接下来,我们从第章第八章和第章第九章中发现移动玩家形象和箱子的测试覆盖了非常相似的功能。我们把它们合并成一个模块,test_moves.py。我们将图形(带有嘲讽)和文件输出的测试留在原处,只将文件分别重命名为test_graphics.pytest_file_output.py。因此,我们有三个模块和一个助手模块。

Best Practice

在我们重新组织代码之后继续之前,有必要检查一下是否所有的测试都通过了。

测试等级 es

使用 py.test,创建一个测试类并不需要做很多事情。创建测试类的简单方法如下:

  1. 收集几个属于一起的测试函数。
  2. 将函数缩进一级。
  3. 在上面写一个class语句。
  4. 给类起一个以Test开头的名字,这样 py.test 就知道里面有测试。
  5. 添加一个 docstring 来描述测试类的好处。
  6. 给每个函数添加self参数。

让我们在模块test_moves.py中清理移动玩家和箱子的测试。当合并我们以前的代码时,该模块包含以下函数:

move_crate(direction, plr_pos, crate_pos)
test_move_crate_left()
test_move_crate_right()
test_move_crate_up()
test_move_crate_down()
test_assert_examples()
test_push_crate_to_wall()
test_push_crate_to_crate()
test_move_to_none()
test_move_crate_to_corner(level)
test_move_crate_back_forth(level)
test_paths(path, level)
test_move_player(level, path, expected_x, expected_y)

虽然您可能注意到代码本身可以改进,但是我们将首先把测试分组到类中。查看代码,可能会发现两个潜在的类。第一个,TestCrateMoves,用于与板条箱相关的一切,第二个,TestPlayerMoves,用于剩余的测试。功能test_assert_examples与模块中的任何功能无关。我们决定删除它,因为它在 MazeRun 没有任何用途。将self添加到每个方法后,模块的结果结构如下:

def move_crate(direction, plr_pos, crate_pos):

class TestCrateMoves:
    def test_move_crate_left(self)
    def test_move_crate_right(self)
    def test_move_crate_up(self)
    def test_move_crate_down(self)
    def test_push_crate_to_wall(self)
    def test_push_crate_to_crate(self)
    def test_move_crate_to_corner(self, level) def test_move_crate_back_forth(self, level)

class TestCrateMoves:
    def test_move_to_none(self)
    def test_paths(self, path, level)
    def test_move_player(self, level, path, expected_x, expected_y)

测试类的行为类似于普通的 Python 类。我们不需要写一个构造函数(_init_)或者实例化这个类:py.test 会为我们处理这些。用[NEWLINE]from fixtures import level, LEVEL[NEWLINE]使测试通过。

Hint

如果你熟悉标准 Python 库的测试框架unittest,你可能会期望我们创建一个unittest.TestCase的子类。当使用 py.test 时,您仍然可以做到这一点,并且来自unittest的所有断言方法都以同样的方式工作。这里我们将使用 py.test 断言实现所有测试(这与unittest风格的类不兼容)。生成的代码更短,更容易阅读。

重构测试函数

现在我们转向改进代码本身。我们将借此机会执行一些小的重构。我们要关注的第一个施工现场是四个测试test_move_crate_lefttest_move_crate_right, and so on。他们都使用LEVEL数据,而不是水平夹具。事实上,我们可以应用第九章中描述的测试参数化。作为奖励,我们也去掉了助手函数move_crate。我们不会改变其他四个测试的实现。代码与第八章中的相同。重构后的测试类如下所示:

from maze_run.draw_maze import parse_grid
from maze_run.moves import move
from maze_run.moves import LEFT, RIGHT, UP, DOWN
from fixtures import level
import pytest

CRATE_MOVES = [
    (LEFT,  (3, 2), (3, 1)),
    (RIGHT, (3, 4), (3, 5)),
    (UP,    (2, 3), (1, 3)),
    (DOWN,  (4, 3), (5, 3)),
]

class TestCrateMoves:

    @pytest.mark.parametrize

(’direction, plr_pos, crate_pos’, CRATE_MOVES)
    def test_move_crate(self, level, direction, plr_pos, crate_pos):
        """After move player and crate moved by one square"""
        print(direction, plr_pos, crate_pos)
        move(level, direction)
        assert level[plr_pos[0]][plr_pos[1]] == ’*’
        assert level[crate_pos[0]][crate_pos[1]] == ’o’

    def test_push_crate_to_wall(self):
        ...

    def test_push_crate_to_crate(self):
        ...

    def test_move_crate_to_corner(self, level):
        ...

    def test_move_crate_back_forth(self, level):
        ...

TestPlayerMoves类中,我们将合并测试函数 s test_pathstest_move_player。两者都使用测试参数化,代码大部分是冗余的。重构后的函数使用单个参数列表,这使得我们的测试类大大缩短了:

PATHS = [
    ((UP, LEFT), 2, 2),
    ((LEFT, UP), 2, 2),
    ((RIGHT, UP, LEFT, LEFT), 2, 2),
    pytest.mark.xfail(((DOWN, DOWN), 0, 0)),
    ((LEFT,), 2, 3),
    ((LEFT, RIGHT), 3, 3),

    ((RIGHT, RIGHT), 4, 3),
]

class TestPlayerMoves:

    def test_move_to_none(self, level):
        """direction=None generates an Exception"""

        with pytest.raises

(TypeError):
            move(level, None)

    @pytest.mark.parametrize

(’path, expected_x, expected_y’, PATHS)
    def test_move_player(self, level, path, expected_x, expected_y):
        """Player position changes correctly"""

        for direction in path:
            move(level, direction)
        assert level[expected_y][expected_x] == ’*’

在这个重构之后,我们再次验证所有的测试都通过了。他们有:

test_file_output.py ....
test_graphics.py ...
test_moves.py ...................x.......x...

====================== 35 passed, 2 xfailed in 0.44 seconds =======================

除了四次额外的测试,我们得到了什么?现在,模块test_moves只包含 7 个方法,而不是 13 个。夹具和参数化的使用导致了一个更短更简单的测试套件,如果其中一个测试失败,这将有利于将来的维护或修复缺陷。只要稍微注意一下将测试分组到类和模块中,通过查看文件名就可以很容易地找到相应的测试。在图 10-1 中,你可以看到我们已经创建的测试模块和类的概述。

A419627_1_En_10_Fig1_HTML.gif

图 10-1。

Example test suite. Test function s (left) are nested in a test class, which in turn is nested in a test module. A test suite usually contains many test modules.

测试类中的夹具

你可能会问自己为什么我们把level夹具移到一个单独的模块中(fixtures模块只包含一个夹具,我们只在test_moves)中使用它)。另一方面,这种结构为增长留下了空间:如果我们发明更多的固定装置或在其他地方使用它们,一个适当的结构已经到位。但是让我们简单地检查一个可能的选择:将 fixture 放在一个类中。

class TestCrateMoves:

    @pytest.fixture

(params=[LEVEL, LEVEL_NO_DOTS])
    def level(self, request):
        """A level with four single crates"""

        return parse_grid(request.param)

    ...

除了额外的self,与我们之前定义的 fixture 几乎没有语法上的区别。参数化也以同样的方式工作。当然,一个类中的 fixture 只在那个类中可用。这就是我们坚持使用fixtures模块的原因(两个类都使用level)。作为最后一个细节,您需要知道我们可以将scope参数设置为@pytest.fixture装饰器:

@pytest.fixture(scope="class", params=[LEVEL, LEVEL_NO_DOTS])

在这种情况下,将分别为该类创建一次夹具。将范围设置为class(或module)的动机是,一次构建夹具将节省时间,从而加快我们的测试。缺点是我们的测试可能会干扰。我们已经在第九章中看到了通过灯具干扰的例子。总之,这个选项需要小心使用。

测试如何找到被测试的代码?

当我们在第八章中编写我们的第一个测试时,我们简单地将test_crate.py模块放在与我们测试的模块相同的目录中。对于一个或两个测试模块来说,将测试和我们的代码放在一个文件夹中是没有坏处的。随着我们程序的增长,这变得很麻烦。因此,我们将把我们的测试模块和类放在主项目目录中一个单独的tests/文件夹中(也参见第十三章)。这将有助于我们清楚地将程序代码和测试分开。现在我们需要确保我们测试过的代码可以被正确导入。由于 Python 中的包导入机制,有一些最佳实践可以使测试和程序都是可执行的。我推荐以下步骤:

  1. 在主项目目录中创建一个test/文件夹,与包含我们想要测试的 Python 模块的maze_run文件夹平行。
  2. 确保maze_run文件夹中有一个__init__.py文件(可能是空的)。
  3. 将程序模块中的导入改为绝对导入(例如,from draw_maze import parse_grid变成了from maze_run.draw_maze import parse_grid))。在程序的所有模块中都这样做。
  4. 确保maze_run文件夹中没有文件maze_run.py。改名为__main__.py
  5. PYTHONPATH变量设置到主项目文件夹中。

如果游戏的 Python 模块在/home/krother/projects/maze_run/maze_run目录中,我们将在主目录的.bashrc文件中添加下面一行:

export PYHTONPATH=$PYTHONPATH:/home/krother/projects/maze_run

只要我们打开一个新的终端,PYTHONPATH就会更新。之后,我们可以在我们的测试模块中(或任何其他地方)编写:

from maze_run.moves import move

或者,我们可以告诉pip在可编辑模式下安装软件包。如果我们有一个setup.py文件,我们可以转到/home/krother/projects/maze_run目录并键入

pip install -e .

这将把到maze_run的路径添加到导入路径(sys.path)。第二种方法要求项目有一个setup.py文件。这两种导入测试代码的方法都是公认的最佳实践,尽管第二种方法与第十三章中详细讨论的 virtualenv 集成得更加无缝。

因此,程序内部和测试中的导入以同样的方式工作。现在我们可以从主项目文件夹中执行游戏

python maze_run/

我们的测试

cd tests/
py.test

请注意,test/文件夹或其子包中不需要__init__.py文件。同样,测试自动发现会处理一切。没有__init__.py文件也避免了在运行python setup.py install时安装测试(这在大多数时候是没有意义的)。

Hint

如果您以前没有处理过如此大规模的包导入,那么让路径和目录起作用可能会令人沮丧。让测试在前面章节的代码上运行的一个不太好的方法是给PYTHONPATH变量添加一个更深的文件夹级别:例如,/home/krother/projects/maze_run/maze_run

多重测试包

当测试数量进一步增长时,我们可以将我们的测试模块分组到多个测试包中。您应该使用哪些软件包?像模块一样,有一个简单的最佳实践,类似于测试模块的最佳实践:使用与被测试项目的包相同的结构来创建您的测试包。唯一的区别是名称需要有test作为前缀或后缀,这样 py.test 就可以自动找到其中的测试。结果,我们得到了两个平行的目录树:一个包含我们程序的包和模块,另一个,通常在一个名为tests/,的目录中,包含了整个测试套件,可能还有进一步的测试包和测试模块。假设我们想测试两个 Python 包:maze_run包含游戏,maze_run.graphics包含在屏幕上画图的所有代码。在我们的项目中,我们将有一个用于maze_runtest/目录和一个用于子包的test/test_graphics(见图 10-2 )。通过遵循这个命名约定,找到程序特定部分的测试变得很容易。

A419627_1_En_10_Fig2_HTML.gif

图 10-2。

Structure of test modules and packages. The code units under test (left) correspond with the names of the according tests (right).

测试自动发现

在这一点上,有必要花点时间思考一下 py.test 到底是如何识别测试的。py.test 中内置的测试自动发现首先检查当前目录的所有子目录,并将它们的名称与模式进行比较。每个目录

  • test_或开始
  • _test结尾

被进一步检查,包括子目录。在每个目录中,所有

  • test_开始,以.py结束,或者
  • _test.py结尾

被收集并作为测试模块导入。将模块作为测试模块导入与普通的导入有细微的不同,因为 py.test 会在这些模块中更改assert语句以导致测试通过或失败。在所有其他模块(特别是所有导入的非测试模块)中,assert语句正常工作。

测试自动发现不会在模块级别停止。在每个模块中,测试类和测试函数是使用非常相似的模式发现的:

  • 测试类从Test开始
  • 类内部的测试函数以test开始
  • 类外测试函数从test开始

测试自动发现将忽略其他一切,允许我们定义任何我们喜欢的帮助函数/类/模块,只要它们看起来不像测试。

执行测试套件

现在我们已经准备好运行我们的测试套件了。到目前为止,我们只是简单地键入py.test,让测试框架完成所有的工作。在这里,我们将探索几种替代方案。常规的py.test命令列出了测试模块,用“.”标记通过的测试,用“F”标记失败,用“x”标记预期失败,用“E”标记错误:

test_file_output.py ....
test_graphics.py ...
test_moves.py ...................x........x...

当运行一个更长的测试套件时,我们可以用py.test -q甚至py.test -q --tb=no.得到一个更简洁的输出,更容易得到正在发生的事情的概况。通常,结果是三种情况之一:

..........................x.......x...

所有测试都通过了。如果我们只看到点,这是最无聊的情况,我们的测试套件没有给我们任何额外的信息。看到这一点,我们可以马上继续编写更多的代码(或者更好,更多的测试)。

.......EEEE..EEEEEExEEE

大多数测试都因出错而失败。这些错误通常很容易发现。当我们看到这种输出时,原因要么是我们破坏了代码中的一些基本内容(在这种情况下,level fixture 被破坏)。另一个原因可能是配置问题(例如,配置文件放错了位置)。请注意,在执行任何测试之前,py.test 会捕获一个ImportError

...............FFFF.......x..F...x..F

一些测试通过,一些测试失败。这是最有趣的情况。一些失败的测试可能意味着有一个我们忘记修复或者我们甚至不知道的边界情况。无论如何,我们已经获得了新的信息来评估。现在值得详细研究 py.test 产生的输出,并找出导致测试失败的原因。在这种情况下,改变maze_run.moves.move函数中的单个字符导致了缺陷。我鼓励你自己添加微小的缺陷,看看结果如何变化。

py.test -q --tb=no

部分执行

如果我们的一些测试失败了,我们该怎么办?因为我们的测试是独立工作的,所以我们可以采取分而治之的方法:我们一个接一个地检查失败的测试,并逐个修复它们。幸运的是,在 py.test 中,有许多选项来执行我们的测试套件的各个部分。

执行测试模块和包

要运行单个模块,我们将其名称作为参数提供给 py.test:

py.test test_moves.py

通过提供测试包的名称,我们可以在一个目录中运行所有的测试模块:

py.test tests/

执行测试类

要从一个模块中选择一个单独的测试类,使用模式module::class添加它的名称,如下所示:

py.test test_moves.py::TestCrateMoves

执行单一测试

我们可以通过使用模式module::class::test指定已执行测试的名称来进一步缩小已执行测试的范围

py.test test_moves.py::TestCrateMoves::test_move_crate

通过关键字选择测试

选择相似名称的一个更简单的方法是用-k选项指定测试名称中出现的关键字。例如,要运行所有包含“推送”的测试,我们可以编写

py.test -k push

为了查看我们到底选择了哪些测试,我们添加了-v--tb=no选项:

py.test -k push -v --tb=no

这给了我们

test_moves.py::TestCrateMoves::test_push_crate_to_wall PASSED

test_moves.py::TestCrateMoves::test_push_crate_to_crate PASSED

检查故障

为了检查失败或错误的测试,我们可以使用所有的调试技术。一个方便的快捷方式是--pdb选项,一旦断言失败,它就将我们直接弹出到调试器中。然而,默认情况下使用这个选项是不可取的,因为对于每个失败的测试,我们将在调试器中结束一次。一个更好的实践是首先获得整个测试套件的概述,然后使用--pdb选项运行一个更小的子集。例如:

py.test -k push --pdb

重新运行测试

修复代码后,我们需要知道修复是否使失败的测试通过。我们还需要知道先前通过的测试是否仍然通过(或者我们是否把一切都搞砸了)。一旦我们修复了代码中的任何缺陷,我们将不得不再次运行测试类/模块/套件的剩余部分。py.test 的几个选项可以帮助我们进行这样的测试:

  • -x选项在第一次测试失败时停止测试执行。如果我们已经知道许多测试会以类似的方式失败,并且想要开始对其中一个进行更详细的诊断,这是很有帮助的。
  • 当我们已经修复了一些东西,并且希望看到对整个测试套件的影响时,--lf选项只重新运行在先前运行中失败的测试。
  • --ff(失败优先)选项首先执行失败的测试。如果我们发现没有改进,我们可以通过按下Ctrl+C来中止测试运行。

py.test 知道还有很多其他的选择。表 10-1 总结了一些常用选项。

表 10-1。

Frequently Used Options in py.test

| [计]选项 | 描述 | | --- | --- | | `-q` | 简明输出 | | `-s` | 禁用输出捕获 | | `-x` | 第一次失败时停止 | | `-k [keyword]` | 关键字作为子字符串出现在测试或类名中 | | `-v` | 明确列出所有测试 | | `-pdb` | 对失败的测试启动调试器 | | `-x` | 在第一次测试失败时停止测试执行 | | `-lf` | 从上一次运行重新运行失败的测试 | | `-ff` | 首先执行失败的测试,然后执行所有其他测试 | | `-tb=line` | 将回溯缩短到一行 | | `-tb=no` | 关闭追踪 |

计算测试覆盖率

当运行一个测试套件时,我们已经看到了执行了多少测试,以及有多少测试通过或失败。我们还不知道我们的测试执行了多少代码。这就是测试覆盖率的用武之地。在 Python 中,pytest-cov包允许我们检查测试了代码的哪些部分。我们首先使用pip安装pytest-cov用于覆盖率分析的 py.test 插件:

pip install pytest-cov

现在我们可以使用--cov选项运行我们的测试:

py.test --cov

我们可以通过给 pytest-cov 一个参数来显式地计算特定模块或包的覆盖率:

py.test --cov=../maze_run/

注意,前面的命令将运行整个测试套件,而不仅仅是test_crate.py。当然 py.test 的常规选项可以和 coverage 模块结合使用。在后台,覆盖率分析的结果存储在一个隐藏的文件.coverage,中,它的内容不打算被人们阅读。为了方便起见,我们获得了一个包含所有已经执行的 Python 文件的报告。maze_run目录的简要报告如下(完整路径略):

 -----------  coverage: platform linux, python 3.4.0-final-0  -----------
Name                            Stmts   Miss  Cover
--------------------------------------------------
../maze_run/__init__.py            3      0    100%
../maze_run/draw_maze.py          23      5     78%
../maze_run/event_loop.py         13      9     31%
../maze_run/generate_maze.py      37      8     78%
../maze_run/load_tiles.py         18      6     67%
../maze_run/maze_run.py           27      1     96%
../maze_run/moves.py              40     10     75%
../maze_run/util.py                8      2     75%
--------------------------------------------------
TOTAL                             169     41     76%

在这个表中,Stmts是 Python 语句的数量(不包括空行),Miss是没有被任何测试执行的行数,Cover是执行的行数的百分比。76%的总覆盖率已经给了我们一些有用的信息:我们现在有确凿的证据表明我们的测试套件执行了超过一半的代码。这意味着可能会有很多地方,甚至像a = 1/0这样的无意义代码也会不被注意地通过我们的测试套件。显然还有工作要做!

我们可以通过创建 HTML 报告来确定有问题的区域:

coverage html
firefox htmlcov/index.html

新创建的htmlcov/目录包含每个模块的 HTML 页面。在 HTML 页面上,突出显示已执行和未执行的部分(参见图 10-3 )。事实证明,我们的测试套件没有覆盖的大部分代码段是事件循环和每个模块的__main__块。虽然我们的测试主要针对移动板条箱,但事实证明他们几乎执行了整个程序!

A419627_1_En_10_Fig3_HTML.jpg

图 10-3。

HTML output of the coverage tool. The executed code parts are marked by the green bar to the left. The sections shaded in red were not executed. In this case, the module moves.py has been covered mostly by the tests, but we did test whether the exit (’x’) works and the __main__ block was never called.

如何解读一个程序的测试覆盖率?100%的测试覆盖率是什么意思?首先,它仅仅意味着每条指令至少被执行一次。100%的测试覆盖率并不意味着程序中的所有指令都能工作,更不意味着程序整体上是正确的。为什么会这样?有一个简单的解释:可能的执行路径太多了。假设我们的程序包含一个决策(例如一个if语句)。然后,程序中有两条独立的路径。每条路径都可能包含缺陷,因此需要单独测试。如果我们的程序包含一系列八个决策,那么已经有 256 个可能的路径。随着程序复杂性的增长,执行路径的数量也在无限增长。除非程序非常简单,否则我们的测试很可能无法覆盖程序中的所有路径。让我们考虑一个小小的、稍微有点做作的例子。假设我们有(错!)用于计算逻辑异或的代码:

def xor(a, b):
    if a is False and :
        if b is True:
            return True
    if b is False:
        return True

和一个测试函数:

def test_xor():
    assert xor(True, False)
    assert xor(False, True)

这个测试将会覆盖xor函数中 100%的代码。然而,即使是肤浅的解释也会发现,一半的可能输入产生了错误的结果(我们的测试未能覆盖相关的边界情况(True, True)和(False, False))。并且测试覆盖率没有给我们任何功能被破坏的暗示。这个限制是为什么测量测试覆盖率有点危险的原因之一。拥有 100%的测试覆盖率是非常令人迷惑的。这表明我们已经测试了所有的东西,而事实上我们只是确保了每一行都被至少一条路径覆盖。顺便说一下,在 Python 中计算逻辑异或的正确方法是

def xor(a, b):
    return bool(a) != bool(b)

在实践中,测量测试覆盖率仍然是有用的。当然,我们的测试覆盖率越高越好。然而,我们不需要直接瞄准 100%的覆盖率。如果一个程序经常改变,甚至会阻碍开发。通常,我们可以用相对较少的测试和一个小但有用的测试套件达到 50%的测试覆盖率。75%的测试覆盖率开始感觉稳定,90%是严格的,100%是针对那些发展不是很快但需要高度完整性的项目(例如,像 Python 本身这样的语言处理器)。分析测试覆盖的主要好处是,它允许我们识别有问题的区域,并更好地评估风险。

测试套件需要维护

测试套件是软件的一部分,就像完成工作的代码一样。因此,保持测试代码的整洁和保持程序代码本身的整洁一样重要。这意味着将大的函数或文件分割成小的,避免代码重复,并关注编码风格。记录为什么要编写某些测试是一个重要的最佳实践,它使得清理测试更加容易。测试套件像任何程序一样进化:它增长,它变化,并且它也需要不时地重构(重构)。

幸运的是,测试代码通常比常规代码更容易维护。测试函数往往已经很短了。根据经验,尽量保持测试代码非常简单。避免在同一个测试中出现多个断言:许多小测试比几个大测试要好。还要避免多重循环或if条件;在大多数情况下,这些可以移动到一个助手功能或参数化测试。请记住,如果在调试期间测试失败,错误可能出现在测试或代码中。我们不想同时调试两个程序(程序和测试);因此,维护一个非常容易理解的测试套件使得开发更加可靠。

最佳实践

  • 测试套件是给定程序的测试的结构化集合。
  • 测试功能可以分为测试模块、测试类和测试包。
  • 测试模块和包应该与它们测试的代码单元具有相同的名称。
  • 一个测试套件应该位于一个单独的文件夹tests/.
  • py.test 中的测试自动发现通过匹配名称来查找测试。
  • 最有趣的测试结果是只有一小部分测试失败。
  • py.test 提供了许多部分执行测试套件的选项。
  • 测试覆盖率是测试期间执行的代码行的百分比。
  • 测试覆盖率可以用pytest-cov包来计算。
  • 100%的测试覆盖率并不意味着我们已经测试了每一种可能性。
  • 自动化测试是需要清理和重构的程序代码。

十一、测试最佳实践

“没有测试的代码被设计破坏了。”—Jacob Kaplan-Moss,Django 核心开发人员

在过去的几章中,我们已经看到自动化测试是检验 Python 程序是否有效的一种强大的技术。py.test 框架是一个多功能的工具,可以为小代码单元编写测试,使用测试数据集,以及管理带有许多断言和测试函数的测试套件。显然,测试是每个高级 Python 程序员工具箱中非常有用的技术。然而,到目前为止,我们还没有看到全局:自动化测试在开发过程中的什么位置?

“没有测试的代码是被设计破坏的”这句话 Django web 服务器的核心开发人员 Jacob Kaplan-Moss 提出了一个大胆的主张。它说如果没有自动化测试,我们就无法写出正确的代码。我们能推翻这种说法,说“带测试的代码是正确的”吗?在这一章中,我们将研究这两种说法。我们将软件测试视为开发过程的一部分,并将我们的注意力转向如下问题:

  • 有哪些不同类型的测试?
  • 开发期间什么时候开始测试最好?
  • 自动化测试的好处是什么?
  • 在什么情况下测试很难写?
  • 一般来说,测试的局限性是什么?
  • 还有其他选择吗?
  • 什么区分好的和坏的测试?

我希望本章涵盖的测试的最佳实践将帮助你编写自动化测试,以最佳方式支持你的编程,这样你就能更快地生产出可工作的软件,并有效地维护它。

自动化测试的类型

首先,有几种重复的测试。与使用 py.test 来实现它们相比,它们在编写目的和使用范围上有更大的不同。本节介绍了许多专业开发人员在谈论自动化测试时使用的词汇。表 11-1 给出了不同类型测试的概述。

表 11-1。

Types of Automated Tests

| 试验 | 描述 | | --- | --- | | 单元测试 | 测试小的、孤立的代码单元。 | | 整合测试 | 测试两个或更多较大组件的协作。 | | 接收试验 | 从用户的角度测试功能。 | | 回归测试 | 重新运行测试以确保先前构建的功能仍然有效。 | | 特性试验 | 测试执行速度、内存使用或其他性能指标。 | | 负荷试验 | 测试高工作负载下的性能,尤其是 web 服务器。 | | 负荷试验 | 在不利条件下(组件故障、攻击等)测试功能。) |

单元测试

对单个函数、类或模块的测试称为单元测试。单元测试证明一段代码满足了它的基本需求。单元测试可能会执行非常详细的检查,并包含相当多的吹毛求疵。在编写单元测试时,我们通常希望涵盖许多边界情况,比如空输入、长输入、怪异输入等等。编写单元测试的最佳实践是 Tim Ottinger 和 Jeff Langr 引入的第一个缩写词。单元测试应该是

  • 快速—在几秒钟或更短时间内执行
  • 隔离—一次只测试一段代码
  • 可重复—可以重新运行,但结果相同
  • 自我验证——测试套件不需要额外的信息来评估测试
  • 适时——测试在代码之前编写(在“测试优先的方法”一节中有更多的介绍)

单元测试中其他常见的最佳实践是每个测试只使用一个assert(尽管这是一个经验法则),并使用模拟对象用简单的占位符代替复杂的组件,这样测试只依赖于被测试的代码单元。

集成测试

孤立地测试组件是不够的。还需要对组件之间的协作进行测试;它们被称为集成测试。通常,这意味着测试更大组件(如数据库、web 服务器或外部应用程序)之间的协作,而不是测试两个相邻 Python 模块中的两个类。集成测试还可能包括在不同版本的库或 Python 版本上测试软件。在 Python 中,有一些用于集成测试的工具,例如,Tox ( https://tox.readthedocs.io/ ),,它支持在一系列 Python 版本上运行测试套件。当编写集成测试时,需要使用实际的程序组件(没有模拟对象或其他占位符)。集成测试试图尽可能精确地再现软件的使用环境。

验收测试

第三种类型的测试关注用户的观点。验收测试检查一个程序的特性是否“像宣传的那样”工作典型的验收测试将程序作为一个整体运行,并检查输出的一些特性。它们模拟真实用户的行为(例如,从命令行执行程序或通过浏览器发送 HTTP 请求)。验收测试的最佳实践是,我们不需要测试每一种可以想到的情况。这就是单元测试的目的。我们更愿意确保我们的应用程序在假设所有组件都正确的情况下处理输入并交付期望的输出。

如何在实践中实现验收测试很大程度上取决于我们程序的用户界面类型。如果我们正在开发一个程序库,验收测试将看起来像我们以前见过的用 py.test 编写的测试,只是它们将测试我们想要向用户公开的接口部分。使用命令行应用程序,我们可以用os.system执行程序并重定向输出。命令行应用程序的原始测试可能如下所示:

def test_commandline_app():
    """Make sure a maze with at least one wall is produced"""

    cmd = "python create_maze.py > output.txt"

    maze = open('output.txt').read()
    assert '#' in maze

为图形界面编写验收测试更具挑战性。存在用于 web 接口验收测试的专用工具(例如 Selenium、Cucumber 和 Codecept.js),但是有时,合理的测试可以通过 web 框架中的常规测试来完成(使用 py.test)。在任何情况下,自动化验收测试都不能代替人工检查程序是否完成了它的工作。在任何情况下,程序员和用户之间的交流都是必要的,以找出软件做的事情是否首先是相关的(例如,它使他们的生活更容易,改善他们的业务,玩起来很有趣)。

回归测试

测试的一个重要应用叫做回归测试。回归测试仅仅意味着在程序改变后重新运行测试。这可能包括重新运行单元测试、集成测试、验收测试,或者以上所有的测试。回归测试强调在几种标准情况下重新运行测试是一种最佳实践:

  • 添加新功能后
  • 修复缺陷后
  • 重新组织代码(重构)后
  • 在将代码提交到存储库之前(参见第十二章
  • 从存储库中签出代码后

回归测试确保在我们编辑了代码之后,我们到目前为止创建的所有东西仍然工作。如果我们有一个快速测试集,在编程期间每隔几分钟重新运行测试是一个非常强大的技术。回归测试确保我们不会在关注程序其他部分的时候无意中破坏了某个特性。py.test 的命令行选项使我们在回归测试过程中的生活变得更加轻松(例如,只重新运行失败的测试会大大加快我们的工作)。您是否决定重新运行失败的测试、单元测试、集成测试,或者您在回归测试期间可以得到的所有测试,取决于代码的变化有多大,以及您离发布程序有多近。

性能测试

到目前为止,所有的测试都是测试一个程序的功能性:这个程序是工作还是不工作?但是其他一些指标也值得测试:程序够快吗?它是否在适当的时间对用户输入做出反应?程序是内存高效的还是消耗了过多的系统资源?所有这些都可以在术语性能测试中找到。在 Python 中,有一些很棒的工具用于手工性能测试。例如,我们可以使用 IPython 中的%timeit魔法方法来检查函数的性能:

In [1]: from generate_maze import create_maze
In [2]: create_maze(10, 10)
In [3]: %timeit create_maze(10, 10)
1000 loops, best of 3: 589 s per loop

%timeit函数智能地计算出它需要运行一段代码的频率,以确定可靠的平均执行时间。较慢的函数通常比快的函数需要的运行次数少。相反,如果我们尝试range,一百万次运行是必要的。此外,IPython 警告我们,结果波动很大:

In [4]: %timeit range(100)
The slowest run took 9.16 times longer than the fastest.
This could mean that an intermediate result is being cached.
1000000 loops, best of 3: 392 ns per loop

使用timeit优于使用外部程序测量执行时间。特别是,不要试图用 Unix 命令行tool time来测量单个 Python 函数的性能。通过这种方式,您也将测量大量的开销(例如,启动 Python 解释器),并且结果将是不精确的。通过导入timeit标准模块,我们可以测量常规 Python 程序中任何 Python 命令的执行时间。通过将timeit.timeit函数返回的数字与预期的最大时间进行比较,我们可以编写简单的性能测试:

def test_fast_maze_generation():
    """Maze generation is fast"""

    seconds = timeit.timeit("create_maze(10, 10)", number=1000)
    assert seconds <= 1.0

性能优化

我们如何利用计时信息来提高性能?模块允许我们更详细地检查程序的性能。例如,如果我们使用create maze()函数创建一个非常大的迷宫,这个函数会变得很慢。通过cProfile.run(),我们获得了一份关于这个项目具体在哪里花费时间的报告:

cProfile.run("create_maze(200, 200)")
         395494 function calls in 19.689 seconds

Ordered by: standard name

ncalls tottime percall cumtime percall filename:lineno(function)
     1   0.003   0.003  19.689  19.689 <string>:1(<module>)
     1   0.000   0.000   0.009   0.009 generate_maze.py:22(get_all_dot_pos)
     1   0.009   0.009   0.009   0.009 generate_maze.py:24(<listcomp>)
 39204   0.078   0.000   0.078   0.000 generate_maze.py:27(get_neighbors)
     1   0.262   0.262  19.670  19.670 generate_maze.py:35(generate_dot_pos)
 39204   0.126   0.000   0.126   0.000 generate_maze.py:42(<listcomp>)
     1   0.000   0.000  19.686  19.686 generate_maze.py:49(create_maze)
     1   0.017   0.017   0.017   0.017 generate_maze.py:9(create_grid_string)
 ...
 39204  18.914   0.000  18.914   0.000 {method 'remove' of 'list' objects

ncalls列告诉我们这个函数被调用了多少次。tottime是 Python 在这个函数中花费的总时间,后面是每次调用的时间(percall)。cumtime是累计时间,Python 在这个函数和从它调用的其他函数中花费的时间,后面是每个函数花费的时间。在输出中,我们看到函数get_neighbors被调用了 39204 次。我们可以试着让get_neighbors更快,但是它不会加速我们的程序(在那里花费的总时间只有 78 毫秒)。真正的瓶颈是list.remove,几乎占了整个执行时间。此时,值得看一下代码(完整的迷宫生成器在第三章中介绍):

def generate_dot_positions(xsize, ysize):
    """Creates positions of dots for a random maze"""

    positions = get_all_dot_positions(xsize, ysize)
    dots = set()
    while positions != []:
        x, y = random.choice(positions)
        neighbors = get_neighbors(x, y)
        free = [nb in dots for nb in neighbors]
        if free.count(True) < 5:
            dots.add((x, y))
        positions.remove((x, y))
    return dots

通过检查代码,我们看到每个位置只被使用了一次。使用慢速list.remove方法的原因是位置以随机顺序处理。为了让代码运行得更快,我们将列表混洗一次,然后在一个for循环中处理列表的每个元素。以下是删除两行并添加了两个突出显示的行后的更新实现:

def generate_dot_positions(xsize, ysize):
    """Creates positions of dots for a random maze"""

    positions = get_all_dot_positions(xsize, ysize)
    dots = set()
    random.shuffle(positions)

    for x, y in positions:

       neighbors = get_neighbors(x, y)
       free = [nb in dots for nb in neighbors]
       if free.count(True) < 5:
           dots.add((x, y))
   return dots

但是这种实现真的更快吗?再次运行%timeitcProfile,给我们一个答案:

In [13]: %timeit create_maze(200,200)
1 loop, best of 3: 279 ms per loop

通过更改几行代码,我们将功能加速了几乎两个数量级!使用cProfile对于测量性能和定位瓶颈至关重要。当优化代码时,单元测试是回归测试的一个有价值的工具,以验证我们的程序不仅快速而且正确。性能测试也可能包括许多其他情况。例如:

  • 负载测试:程序(例如,web 服务)如何处理大量的并发流量?
  • 压力测试:程序如何处理恶意条件(快速变化的条件、组件故障,甚至是试图使程序崩溃的外部攻击)?
  • 健壮性:执行时间是稳定的还是波动很大?

因为性能测试往往比单元测试更耗时,所以最好将它们与其他测试分开。一般来说,编写性能测试更具挑战性。幸运的是,很多程序根本不需要它们。

测试优先的方法

在第八章,我们写了一个测试,尽管我们还没有实现满足测试的代码。在编写程序之前编写测试是测试的一个关键的最佳实践。为什么首先编写测试是一个好主意?让我们首先考虑相反的方法,这是我们大多数人凭直觉想到的方法。我们先写一个程序。然后我们调试程序并修复所有缺陷。只有在那之后,我们编写测试,我们就完成了。然而,这种方法产生了一些严重的问题:

  • 测试给我们的额外信息很少,因为程序已经工作了:它们都默认通过。
  • 如果测试没有立竿见影的效果,那么编写测试就很无聊。如果没有必要,编写低质量的测试是很容易的。
  • 如果在测试过程中,我们发现有些事情可以做得更好,我们需要重复整个过程(编写代码,修复错误,测试,重复)。
  • 我们不知道什么时候我们写了足够多的测试。
  • 我们不知道我们什么时候能修复完错误。
  • 我们不知道什么时候写完程序。

特别是最后一点,除非我们对“完成”的含义有详细而准确的了解,否则会对这种方法产生相当大的怀疑。事实证明,当我们开始编程时,我们的知识往往不够准确,因为编程项目的目标是随着代码本身发展的。在很长一段时间里,软件首先被开发,然后被测试(使用手工和自动化测试)。随着 21 世纪初敏捷运动的开始,在实现满足测试的代码之前编写测试变得越来越流行。简而言之,测试优先方法遵循该程序(也见图 11-1 ):

  1. 写一个测试函数。
  2. 运行测试,确保失败。
  3. 写代码。
  4. 运行测试并确保它通过。
  5. 清理代码并运行回归测试。
  6. 重复这个过程,直到你完成。

A419627_1_En_11_Fig1_HTML.jpg

图 11-1。

The test-first approach. We write a failing test first, and then the code to make it pass. After that, Regression Tests check whether everything else is still working. The process is repeated until the program is complete.

测试优先的方法有几个优点:首先,看到测试失败证明了它的正确性。当我们编写一个测试时,我们默认不知道它是否正确。测试是程序代码,因此可能包含缺陷。相应代码尚不存在的测试可能会失败。如果测试通过了,我们就会知道这个测试包含了一个缺陷或者测试了一些已经存在的东西。这样,我们可以确保测试为我们提供了额外的信息。首先看到测试失败对于开发有用的测试是必不可少的。

第二,先写测试,促进写出好用的代码。当编写测试时,我们需要考虑如何使用代码。这更早地暴露了设计弱点。例如,如果我们发现即使编写测试也很复杂,那么界面可能需要重新设计。

第三,先写测试对程序员来说是激励。如果我们看到我们的测试从FAIL切换到PASS,这比“哦,天哪,我需要为所有代码编写测试吗?”的想法更有价值

测试优先的方法现在被认为是促进软件更高质量的最佳实践。它可以应用于各种情况。下面介绍其中的三种。

根据规范编写测试

如果我们有一个书面的规范——一个描述程序应该做的所有事情的文档——我们可以在写代码之前写很多测试。我们甚至可以编写一个完整的测试套件。之后,我们开始实现代码,并看到越来越多的测试通过。这种方法的主要优点是,首先编写测试有助于您为代码开发一个良好的接口,并且看到越来越多的测试通过会极大地激励开发人员。如果我们在一个已经建立了领域知识的领域中工作(也就是说,我们非常详细地理解了软件将要解决的问题),根据一个规范编写测试是可行的。在许多其他情况下(电脑游戏、创业公司和研究项目),要么我们没有开始的书面规范,要么规范很可能会改变。在这种情况下,根据完整的规范编写测试是浪费时间。更实用的方法是从规范中挑选一个组件,为该组件编写测试,然后实现它,挑选下一个,等等。这样我们就减少了浪费的精力。

针对缺陷编写测试

正如我们在第一章中所看到的,寻找和修复缺陷可能是有压力的、令人厌倦的和令人讨厌的。同一个缺陷修复两次就更糟糕了。测试优先的方法保护我们免受这种影响。针对缺陷进行测试的工作流程是对测试优先过程的一个微小的修改:

  1. 识别缺陷或程序失败。
  2. 针对缺陷或导致的失败编写一个测试。
  3. 确保测试失败。
  4. 修复缺陷。
  5. 再次运行测试,确保它通过。

每当我们在程序中发现缺陷时,我们可以使用相同的过程。失败的测试作为程序包含特定缺陷的证据。当我们调查缺陷如何在程序中传播时,我们可能会添加更多特定的测试(同样会失败)来更早地捕捉缺陷。一旦我们修复了缺陷,所有新编写的测试都应该通过。这样我们可以确保同样的缺陷不会再次发生。针对 bug 编写测试是使软件更加健壮的一个非常有用的策略。这也是使用测试优先方法最不武断的方式。您会发现大多数程序员都同意这是一个最佳实践。使用编写许多针对 bug 的自动化测试的一个小缺点是,产生的测试套件将是不系统的。然后,不时地重构测试是一个好主意。

测试驱动开发

测试驱动开发(TDD)是对测试第一思想最严格的解释。TDD 将测试优先的方法应用到整个开发过程中。在写任何代码之前,我们都要写一个测试。然后我们编写足够的代码使测试通过,编写下一个测试,等等。当我们在编写测试和编写代码之间交替时,我们构建程序。TDD 背后的想法是,它有助于构建程序,因为要编写一个测试,我们首先需要完全理解程序应该做什么。严格地应用,TDD 导致非常短的开发周期(在几分钟的范围内)。TDD 和本章前面描述的其他测试优先方法的主要区别在于,在 TDD 中,编写测试的人和编写代码的人必须是相同的。在前面描述的方法中,测试人员和开发人员可以是独立的个人,甚至是团队。关于使用 TDD 是否是一个好主意的观点是不同的。一方面,它防止编码太多。当所有测试都通过时,我们就完成了。它也促进了写代码前的思考。两者都是编程中公认的优点。另一方面,TDD 不容易做,需要经验和纪律。一个更大的缺点是,当从一个测试工作到下一个测试时,很难关注软件的整体架构。对 TDD 的一个批评是,它促进了难以维护的设计。Emily Bache ( https://archive.org/details/EuroPython_2014_5ZLqAvRe )在 EuroPython 2014 主题演讲中对 TDD 进行了平衡的讨论。无论如何,尝试 TDD 是一个很好的学习经历,我强烈推荐你尝试一下。

自动化测试的优势

我们已经看到,自动化测试可以应用于大小代码单元,从单个功能到整个软件包。我们已经看到了一些例子,在这些例子中,测试更加困难,但是可以编写。最后,我们已经看到,在编写代码之前编写测试是公认的最佳实践。另一方面,我们也看到了测试并不能证明一个程序是正确的。它只能证明缺陷的存在,而不能证明缺陷的不存在。图 11-2 给出了测试只能证明错误的基本原理。

A419627_1_En_11_Fig2_HTML.jpg

图 11-2。

Possible outcomes if code and tests are correct or wrong

到目前为止,我们还忽略了自动化测试的其他好处或限制吗?

测试节省时间

首先,使用测试比手工检查程序特性要快得多。但是花在编写自动化测试上的时间呢?理想情况下,在编写测试上投入的时间会很快得到回报。每当我们改变一个程序时,都有意外破坏之前正常工作的东西的风险。进行测试消除了手动发现和修复这些缺陷的时间。无论我们是添加新特性,修复缺陷,还是重组(重构)代码,都是如此。自动化测试的主要好处是我们永远不会担心同一个问题两次。因此,投入的努力应该在几轮开发后得到回报。

一些程序员跳过自动化测试,声称他们没有时间做这些。对于一个小程序(100 行或更少),这是毫无疑问的。大多数情况下,运行代码并手动检查会更快。超过 100 行,自动化测试开始变得更加有用。根据项目的不同(如果有一个 bug,会造成多大的损失),有测试和没有测试的程序都可以工作。在 1000 行以上,不编写自动化测试变得非常危险。一般来说,一个有经验的程序员在没有自动测试的情况下所能处理的程序规模比一个混合技能水平的团队要大。随着时间的推移,测试会加速开发,因为现有的特性可以快速地以自动化的方式进行测试。你的程序越大,你维护它的时间越长,自动化测试的好处就越大。

测试增加了精确度

在 Python 这样的动态类型语言中,测试是必要的。Python 在运行程序之前很少捕捉错误,而静态类型语言在编译期间会指出许多缺陷。在 Python 中,我们需要测试来达到相当的精确度。我们可以使用测试来精确地定义一个函数、类或模块应该做什么。通过编写测试,我们清楚地声明我们正在期待一个给定的行为,并且观察到的行为不是巧合。

测试使协作更容易

测试在许多方面促进了开发人员之间的合作。测试使得快速检查从其他程序员那里收到的代码是否工作变得容易。在开始修改别人的代码之前,这是一个必要的步骤。通过测试,我们可以在不咨询其他程序员的情况下进行这样的检查。当参与一个开源项目,加入一个现有的团队,或者接管一个原始开发人员不再可用的项目时,这一点尤其有价值。在相反的网站上,当你对代码的最新修改不能在别人的计算机上工作时,编写测试可以保护你的安全。询问“测试通过了吗?”将讨论引向一个建设性的、基于事实的方向,而不是仅仅根据假设进行指责。

自动化测试的局限性

测试需要可测试的代码

为了编写好的测试,代码本身的结构需要是可测试的。想象一下,我们将编写整个游戏(设置关卡、绘制图形、事件循环处理移动等)。)在单个单片函数中。这将导致几个严重的障碍:

  • 将我们的测试数据放入程序会非常复杂(例如,用我们自己的测试场景替换随机地图)。
  • 图形会一直显示,减慢测试速度。
  • 很难劫持键盘来将命令放入事件循环并在之后离开程序。

拥有一个单一的大程序会极大地膨胀我们的测试代码并减慢测试速度。实际上,将代码分割成易于测试的小函数和类已经完成了一半的工作。结构良好的代码和有用的测试之间的关系是相互的。好的测试使你的程序更容易结构化,而结构良好的代码更容易测试。

测试不适合快速发展的项目

如果一个编程项目变化非常快,测试可能就不值得了。例如,如果您做探索性的数据分析(例如,从文件和数据库中收集数据并生成大量图表),编写自动化测试通常是一种浪费。此外,在实现原型时,开发速度通常比正确性更有用。在这两种情况下,编写测试会不必要地减慢开发速度。然而,程序中的一些变化是正常的。如果程序中的所有东西都一直在变化,这可能表明程序不是结构良好的。在这种情况下,测试可以帮助稳定那些不会改变的部分:程序没有错误地完成,它产生一个输出文件,输出文件不是空的,等等。即使这样的测试看起来微不足道,它们也会覆盖大量的代码。

测试并不能证明正确性

不可能测试所有的东西。自动化测试必然是不完整的。即使有非常详细的边界案例,仍然有许多不确定性。正如第十章所说,100%的测试覆盖率并不意味着我们已经测试了所有的东西。像路径复杂性这样的问题——程序中的多条执行路径——创造了一个程序可以执行的比我们可以测试的更多的可能方式。即使在分支指令数量很少的程序中,可能路径的数量也超过了我们在实践中可以测试的情况的数量。即使我们覆盖了所有可能的路径,我们通常也不能覆盖所有可能的输入。最后,我们的测试总有可能没有准确描述我们希望程序被使用的所有可能的方式。有许多研究证实,一个程序包含未知的缺陷是相当正常的。不幸的是,带有自动化测试的 Python 程序也不例外。结论是,自动化测试并不能证明正确性。

难以测试的程序

随机数

根据定义,随机数是不可预测的。无论何时涉及到随机数,测试都需要涵盖所有可能的结果。使用随机数测试程序的最佳实践是使用random.seed()来控制种子值,这样我们就知道将创建什么样的随机数序列,并且您的测试结果至少是一致的。这里,为每个测试模块单独设置种子值是很重要的,以避免我们的测试相互干扰。

Hint

我了解到 Python random模块的实现在所有平台上都是一致的。使用相同的种子值,您应该在任何地方获得相同的结果。我也检查过,发现 Python 2.7 和 3.5 生成的随机数一致,但无论如何测试随机数的时候我都会很谨慎。

图形用户界面

如果你的程序有一个文本接口,那么为它编写测试是非常容易的。基本上,我们可以捕捉标准输出(通过sys.stdin或在 Unix 上重定向输出)并用我们的测试数据替换用户输入。有了图形界面,事情很快变得繁琐。基本的问题是,很难指导自动测试来检查我们在屏幕上看到的东西对人类是否有意义(作为一个极端的例子,浅灰色背景上的白色字体对自动测试可能是正确的,但对人类用户完全没有用)。此外,还可以模拟用户界面中特定位置的一系列鼠标点击。然而,GUI 元素的位置经常发生变化,导致测试的频繁变化。

一般来说,为动态网页编写测试要简单一些,因为 HTML、CSS 和 Python 的分离有助于将测试集中在功能上。HTML 和后端部分的测试策略包括不同的技术,如 Django 测试框架(与本书中的测试非常相似)和 Selenium(远程控制 web 浏览器的测试脚本)。对于 Django 开发人员来说,Harry j . w . PERC ival(O ' Reilly Media,2014)的《使用 Python 进行测试驱动开发》是一本关于这一主题的专家级书籍。

处理图形用户界面的最佳实践是将可视组件与其他组件完全分离,如下所示:首先,编写一个经过充分测试的库或命令行工具来完成这项工作。使用前几章介绍的技术测试它。其次,在它上面写图形部分。根据您的能力,编写基本的测试或者求助于图形部分的手工测试。无论如何,您都需要对图形界面进行一些手工测试。

复杂或大量输出

如果程序的输出非常复杂或者非常大,那么编写好的测试会很有挑战性。这种情况可能包括生成图像、音频、视频或大型文本文件。主要问题是,将程序的输出与样本文件进行比较效果不好。假设您在程序中引入了一个小的变化,导致在一个几 MB 大的文件中出现了一些额外的空间。快速重新检查测试数据会变得很昂贵。

在这种情况下,有两种策略可以解决问题:首先,对于许多文件格式,用 Python 编写的支持库已经存在。例如,如果您使用PILLOW库来生成图像,您可以使用相同的库来验证程序的输出。如果您有一个用于读写目标格式的库(并假设它工作正常),您就不必检查输出的每个细节。检查一些主要特征(例如,图像的数量、大小和一些其他关键属性)通常就足够了。如果这样的支持库不存在,编写一个可能是一个不错的投资。

其次,您可以为非常小的样本数据集编写详细的测试。如果您的常规数据集较大,单独测试一个小文件是不够的。最佳实践是额外测试一个更大的文件,但是将测试限制在几个关键指标上:输出是否非空?文件的大小和条目数量是否正确?图像的像素数量是否正确?音频信号的最小/最大振幅是多少?视频的平均颜色是多少?这样的度量标准覆盖了 80%的情况,甚至使得测试大的输出也是可管理的。

并发

测试并行发生的事情是我个人的噩梦。无论您测试的是并发进程、线程、greenlets 还是任何其他类型的并行处理,都没有关系;并发性很难测试。原因在于并发本身的性质:想象我们有一个玩家和一个幽灵在迷宫中移动(就像我们在第十五章中设计的那样)。如果我们将这个特性作为单线程来实现,我们就可以完全控制事件的顺序。我们可以编写普通的测试来检查 Pygame 事件队列中的内容。如果我们为玩家创建一个线程,为幽灵创建一个线程,我们就放弃了控制权。现在,如果我们编写一个旨在检查两个线程的测试,这个测试很容易干扰这些线程。例如,测试可能会稍微延迟一些事件。因此,作为测试的结果,bug 可能会消失。如果你熟悉海森堡的测不准原理,你可能会发现一个相似之处。因此,在测试时消失的 bug 被称为 Heisenbug。简而言之,在并发环境中,测试和被测系统相互干扰,使得测试更加困难。

如果您必须使用并发,我的建议是更深入地阅读这个主题。选择一个库(asyncio、gevent 或 Twisted)并彻底理解它。在文档中寻找关于测试的具体建议。很可能你将不得不记录大量信息。如果并发编程一定会成为您的日常业务,那么像 Go 和 Scala 这样具有强大并发支持的编程语言也值得一看。

自动化测试失败的情况

计算机科学家已经发现了一些非常糟糕的情况,在这些情况下,测试几乎没有意义。思考以下问题:

  • 你能测试一个程序是否结束(例如,是否有一个死循环)?
  • 你能测试一个程序在内存耗尽时的行为吗?
  • 你能在多少不同的环境(操作系统、Python 版本、库版本以及它们的组合)中测试你的程序?
  • 您能测试基础设施(网络连接、web 服务、助手脚本)吗?这在什么情况下有用?

自动化测试的替代方案

在一家生产安全关键软件的公司,我听到了下面的陈述:“我们通常不依赖自动化测试这样的不安全技术。”考虑到自动化测试只能证明 bug 的存在,我们需要承认这样一个事实,即自动化测试本身并不能使程序变得安全、可靠或正确。但是有什么选择呢?在这里,我们简要地看一下在软件工程师的工具箱中找到的几种验证正确性的方法。

样机研究

在经典的软件工程书籍《神话中的人月》(Addison-Wesley,1995)中,Fred Brooks 指出,人们需要准备扔掉自己的第一个实现。这种经验直接指向原型技术。在原型开发中,程序的第一个版本是作为一个概念证明来编写的,并且有一个明确的条件,就是以后不能使用它。工作原型暴露了以前未知的问题。通过创建一个具体的实现,许多没有人想到的概念或架构问题可以被识别出来。为了快速创建一个原型,测试不是必需的。在原型完成后,编写第二个设计得更彻底的程序,以避免原型的缺陷。在那里,自动化测试又有了用武之地。原型对我们来说是一种非常有用的技术,因为 Python 是一种编写原型的优秀语言。写一个原型很快,通常从头开始写一个新程序比试图纠正第一次尝试中引入的缺陷要快得多。

代码审查

第四章中已经介绍了代码评审作为一种识别缺陷的方法。除了调试之外,代码评审还有很多好处:评审通常有助于提高软件质量。评审有助于开发人员一起工作(如果评审是支持性的,而不是以相互指责告终)。评审促进团队的学习和知识的保留。个人觉得评论很好玩。如果你想让别人看看你的代码,请随时发送给我,我会看看。

检查表 s

清单也是维护软件质量的有用工具。清单只是一系列被逐一检查的步骤、问题或提醒。几年来,我一直在用一份清单来打包我的商务旅行行李箱。我单子上的一件东西是牙刷。当然,我会记得带我的牙刷,不是吗?但如果我早上 4 点起床,或者如果我不得不匆忙收拾行李离开,我就不那么确定了。当事情变得棘手时,我不想让我是否带着牙刷碰运气。清单确保了这一点。

在软件项目中,清单上的项目可能如下:

  • 您想要排除的典型错误,例如,“所有变量都已经初始化了吗?”
  • 软件发布期间的步骤,例如,“创建 zip 文件。上传到项目页面。”
  • 显式手动测试,例如,“zip 文件可以下载并解压缩吗?”

通过明确地检查事情,清单将人的因素排除在外。这有好的一面,也有不好的一面。不好的一面是,使用清单是重复的,一段时间后会变得无聊。这就是为什么清单更适合两个人一起工作(例如,在评审期间)。好的一面是清单简化了非常复杂的情况。因此,他们在紧张的条件下工作:做外科手术或操作飞机和直升机的人大量使用清单是有原因的。Atul Gawande 的《清单宣言》( Metropolitan Books,2010 年)提供了更多关于创建和使用清单的背景知识。

促进正确性的过程

编写正确的软件与底层开发过程有很大关系。例如,安全是优先考虑的过程需要在一开始就识别安全风险,并明确地解决这些风险。这包括非编程活动,如风险评估和应急计划。在组织层面上,像 CMMI 的 ISO9001 和 ITIL 的质量标准是常见的。他们的目标是在开发过程中控制、记录和提高质量。然而,这些是大团队和组织的方法。质量标准的知识对于一般的 Python 项目来说不是很有用,除非您的组织使用它们。更有用的方法是采用一些基本的改善实践,如 5 个为什么技术和看板。两者都注重改进,并有助于发现组织中的主导价值观。组织价值观(例如质量、客户服务、个人诚信)对代码质量有巨大的影响,通常是以不明显的方式。这就是为什么我认为对于一个对质量感兴趣的有经验的程序员来说,关于过程、领导力和团队动力的基础知识是不可或缺的。

结论

所有制造正确软件的方法都是有代价的。与编写未经测试的 Python 程序相比,验证代码或使用提高正确性的流程需要更长的时间。在一个科学 Python 编程项目中,我计算出每个团队成员平均每天编写大约 20 行 Python 代码(大量时间也花在测试、调试、重写代码、阅读和撰写科学文章、参加会议、教学、管理职责等方面。).这个数字与文献相符。对于安全关键环境中的软件开发,这个数字甚至可能下降到程序员每天 10 行代码。因此,编写完全验证的软件是非常昂贵的。在快速原型或启动环境中,一个开发人员可能很容易产生 100 多行代码。当然,快速发展的结果将更加难以维持。但是为了功能正确,一个程序只需要在几个月内回答一个业务假设,而在一个安全关键系统中,软件需要可靠地运行很长时间,通常是几年到几十年。

当思考正确性时,瑞士奶酪模型是有帮助的:想象一下,我们为提高正确性所采取的每一项措施都是一块奶酪。每个切片都有漏洞,因为每个度量都有其局限性。但是,如果我们将许多部分堆叠在一起,比如原型、代码评审,当然还有自动化测试,那么缺陷穿透整个堆栈的漏洞数量将会非常少。知道存在测试的替代方法是很好的,如果仅仅是为了让我们不再妄想自动化测试是编写正确代码的唯一最佳实践。Python 是一种允许快速开发和快速发展项目的语言。自动化测试是一种在不牺牲开发速度的情况下使软件项目更加健壮的技术。Python 和自动化测试非常契合。

最佳实践

  • 单元测试是对小代码单元的测试。最佳实践是编写快速、独立、可重复、自我验证和及时的单元测试。
  • 集成测试测试两个或多个组件是否能正确协作。
  • 验收测试测试用户级特性。
  • 回归测试是指在重构代码、修复缺陷或进行其他更改后重新运行测试。
  • timeitcProfile模块分析 Python 语句的执行时间,并允许编写性能测试。
  • 在编写使测试通过的代码之前编写失败的测试证明测试工作正常。
  • 在编写代码之前编写测试对于实现新功能或修复错误非常有用。
  • TDD 是对测试优先方法的严格解释,提倡编写测试和代码的周期非常短。
  • 自动化测试当然不是创建工作软件的唯一方法。还有很多其他的技术来验证和确认程序,比如手工测试、代码审查和清单。

十二、版本控制

根据大气中的污染成分来判断,我相信我们已经来到了二十世纪的下半叶。——斯波克,星际迷航 4:回家的旅程

有一次,我参与了一个研究项目,涉及一个数据库、一个公共 web 服务器和大量 Python 代码。有一天服务器的硬盘崩溃了,把所有东西都抹掉了。我们试图从分散在不同人电脑上的文件中恢复服务,但失败了。这个项目再也没有实现。我们将需要一个时间机器来恢复项目以前的状态。具有讽刺意味的是,当我一年后从事另一个网络数据库项目时,历史重演了。服务器上的磁盘又崩溃了。这一次,我们真的有了时间机器。我们可以像崩溃前一样拿走所有代码和数据。虽然我们不得不手动配置一些东西,但网站很快又运行了。现在你可能想知道我们如何恢复旧代码。或者,更准确地说:“你是如何给自己造出那个时间机器的?”我们的时光机叫版本控制(见图 12-1 )。在本章中,我们将了解什么是版本控制以及如何使用它。

A419627_1_En_12_Fig1_HTML.jpg

图 12-1。

Version Control is a bit like time traveling in your source code—temporal paradoxa included. To see a world without program bugs, you would have to travel back into the 19th century before Ada Lovelace wrote the very first computer program.

在前几章中,我们的程序有了很大的变化。我们应用了各种调试和编码策略,并相应地编辑了我们的代码。在尝试不同的方法时,我们可能会复制 Python 文件来保存新旧版本。一段时间后,我们会看到文件越积越多(例如,模块将游戏区域绘制为一个方格):

draw_grid.py
draw_grid2.py
draw_grid2_new_version.py
draw_grid3.py
draw_map.py
draw_map_buggy.py
draw_map_with_introspection.py

看起来我们的项目已经发展壮大了。几天不使用代码后,正确使用这些文件将会非常困难。出现类似下面的问题:最近修改的文件真的是您应该使用的文件吗?再问一下draw_mapdraw_grid有什么区别?删除所有其他的安全吗,或者它们仍然包含重要的代码片段吗?如果我们的代码包含分布在多个目录中的文件,情况会变得更糟。简而言之,结果是我们的计划有变成一团乱麻的危险。

版本控制是解决这个问题的方法。版本控制意味着跟踪你的程序代码和相关文件中的所有变化,这样随着时间的推移,你可以建立一个你的代码是如何开发的日志。版本控制使我们能够在以后恢复到任何以前的时间点。正因为如此,版本控制被认为是长期维护任何项目的最基本、最重要的最佳实践。即使是小型的单人项目,回报也很快。

在这一章中,我们将使用版本控制系统 git,来跟踪 MazeRun 代码中的变化,并把它放在一个公共源代码库中。

git 入门

git 是 Linux 的发明者 Linus Torvalds 写的。它是当今最常用和最先进的版本控制系统。几乎不用说,它是免费的。使用 git 可以做的事情包括:

  1. 创建存储库
  2. 将文件添加到存储库中
  3. 检查项目的历史
  4. 跳到更早的状态并返回
  5. 在公共存储库上发布代码
  6. 并行管理多个代码分支

在 Ubuntu Linux 上,可以用

sudo apt-get install git

创建存储库

git 中的项目被组织在存储库中。一般来说,存储库是一组版本控制在一起的文件。这是什么意思?让我们考虑一个具体的例子。假设您在一个名为maze_run的目录中拥有 MazeRun 游戏的所有文件(包括 Python 代码、图像,可能还有一些数据文件)。我们希望使用 git 来跟踪这些文件中的变化。首先,我们从命令行用git init命令创建一个新的存储库:

cd maze_run/
git init

您应该会看到这样的消息:

Initialized empty Git repository in /home/krother/projects/maze_run/

起初,似乎什么都没有改变。但是如果你更仔细地看(用ls -la),你会注意到一个叫做.git的隐藏文件夹。这是 git 存储随着时间的推移应用于文件的更改的地方。现在我们的项目可以开始发展自己的历史了。

将文件添加到存储库中

当我们初始化一个存储库时,git 会自动开始跟踪现有的文件吗?我们可以随时通过输入以下命令来检查 git 对当前目录的了解

git status

我们得到这样一条消息:

Untracked files:
   (use "git add <file>..." to include in what will be committed)

     draw_maze.py
     event_loop.py
     generate_maze.py
     load_tiles.py
     maze_run.py
     moves.py
     util.py
     images/

nothing added to commit

but untracked files present (use "git add" to track)

git 告诉我们的是,它看到了以前从未见过的文件,因此它们还不在存储库中。我们可以使用git add命令将这些文件和整个目录添加到存储库中:

git add *.py
git add images/*

通过再次使用git status,您可以检查 git 记录了哪些文件。添加适用于任何类型的文件(源代码、文本文件、图像、Word 文档)。然而,git 对于文本文件中的更改最有效。

为了告诉 git 我们已经完成了文件的添加,我们可以使用 commit 命令并附加一条消息:

git commit

-m "added first working version of MazeRun"

之后git status报告:

nothing to commit

, working directory clean

commit 命令将我们文件的内容复制到 git 的内部文件的.git目录中。我们现在已经完整地记录了这些变化。无论我们后来在 Python 代码中做了什么改变,只要我们不弄乱.git目录(这通常不是一个好主意,除非您真的知道自己在做什么),我们都可以恢复到以前的状态。

跟踪文件中的更改

版本控制的主要用途是跟踪文件中的变化。假设我们想要编辑第三章中介绍的随机迷宫生成器的描述。像以前一样,我们在我们喜欢的文本编辑器中打开文件(generate_maze.py),并进行修改。例如,我们可以替换斯巴达的评论

# Code for chapter 03 - Semantic Errors

更具说明性的 docstring 解释了该模块的功能及其局限性:

"""

Random Maze Generator

Creates X*Y grids consisting of walls (#) and floor tiles (.),

forming a network of connected corridors.

Warning: Sometimes, the algorithm will create enclosed spaces,
but it is good enough to experiment with debugging techniques.

This module was introduced in chapter 03 - Semantic Errors

"""

当我们保存文件时,更改不会自动添加到 git 存储库中。我们可以再次使用git status检查哪些文件已经更改:

> git status
On branch master
Changes not staged for commit

:
  (use "git add <file>..." to update what will be committed)

    modified: generate_maze.py

nothing added to commit but untracked files present (use "git add" to track)

您还可以查看git diff到底发生了什么变化。输出包含我们添加的每一行的前缀+和我们删除的每一行的前缀:

> git diff
index c0d5a33..2ea0321 100644
--- a/part1_debugging/generate_maze.py
+++ b/part1_debugging/generate_maze.py
@@ -1,5 +1,13 @@
+"""

+Random Maze Generator

-# Code for chapter 03 - Semantic Errors
+Creates X*Y grids consisting of walls (#) and floor tiles (.),
+so that the floor tiles are connected.
+
+Warning: Sometimes, the algorithm will create enclosed spaces.
+
+This module was introduced in chapter 03 - Semantic Errors
+"""

 import random

要将这些更改写入存储库,我们需要添加文件并再次提交更改:

git add generate_maze.py

git commit

-m "improved module docstring for maze generator"

作为一种捷径,您可以在一个步骤中完成添加和提交更改,只要文件generate_maze.py已经被添加到存储库中,以便 git 知道它:

git commit -a -m "improved module docstring for maze generator"

git commit -a -m命令将从您的存储库中添加最近更改过的所有文件。这是有用的,因为它节省了您逐个添加每个文件的努力。在更大的项目中,有时希望从一轮编辑中创建多个提交(例如,如果您正在写一本书,提交可以被标记为“第十二章的附加数字”、“第十五章的固定代码示例”等)。).git 为您提供了对单个提交内容的细粒度控制。作为一个最佳实践,我建议手动添加文件,而不是使用git commit -a -m,只是为了意识到你在添加什么。

每天提交至少在一个工作日结束时提交你所有的改变是一个好习惯。如果您正在与其他人协作或进行密集开发,提交可能会更频繁地发生。在高峰时段,我每隔几分钟就使用git commit

移动和删除文件

除了改变文件的内容之外,我们可能想要将文件移动到不同的地方或者将它完全扔掉。普通的 Unix 命令mvrm仍然工作,但是 git 会变得混乱,因为它期望的文件已经不在那里了。相反,我们需要明确地告诉 git 我们想要移动或删除一个文件。

git mv命令相当于移动文件的mv。它的工作方式与mv完全相同,只是在我们的存储库中添加了一个注释,说明该文件曾经在其他地方。完整的命令可能如下所示:

git mv foo.py examples/foo.py

类似地,git rm删除一个文件,但仍会记住存储库中以前的内容:

git rm bar.py

使用-f标志可以覆盖更改,但是我不推荐使用-f作为最佳实践。使用git status,我们可以检查 git 是否注意到了移动和删除的文件。在移动和删除文件之后,需要使用git commit将更改提交到存储库,就像我们之前编辑文件时所做的那样。

放弃更改

在编辑代码时,有时会发现一个想法行不通。或者说原来我们已经把自己纠结得太厉害了,不如扔掉所有改动,重新开始。或者我们只是不小心删除了代码。在文本编辑器中,按下Ctrl+A, Backspace, Ctrl+S很容易毁掉几个小时和几天的工作。在这些情况下,我们可以使用存储库来撤销我们的更改,并退回到一个已知的状态。假设我们弄乱了文件generate_maze.py(鉴于您已经将最近的变更提交到一个存储库中,在家里尝试是安全的)。

我们可以用git checkout命令恢复文件:

git checkout generate_maze.py

这将重新创建上次提交时的文件。我们也可以用一个命令恢复当前目录中的所有文件:

git checkout .

对于频繁的提交,git checkout就像一个项目范围的撤销功能。

浏览我们代码的历史

当在一段时间内跟踪我们代码中的变化时,我们会进行大量的提交。我们的知识库正在积累自己的历史。理想情况下,这段历史被静静地保存在.git目录中,我们再也不需要查看它了。然而,有一天我们可能会注意到,我们搞砸了一些以前有效的事情。在这种情况下,我们可以使用 git 作为我们的时间机器,回去看看代码以前是什么样子的(见图 12-1 )。

我们需要弄清楚的第一件事是进入时间机器的日期。命令git log给出了我们之前所做的所有提交的列表:

> git log
commit a11d542accc755a47533783b462c69992e218e73

Author: krother <krother@academis.eu>
Date:   Fri Jul 1 11:45:40 2016 +0200

    improved module docstring for maze generator

commit b865b483a7e042e02724464cc6bd944b23e2324e
Author: krother <krother@academis.eu>
Date:   Fri May 27 21:18:51 2016 +0200

    cleaned up scripts for part 1
...

每次提交都包含散列码、作者、时间戳和我们编写的消息(此时编写描述性的提交消息是值得的)。我们还可以限制关于单个文件的日志消息的输出:

> git log util.py
commit b9699d1e57292d33aa908a790013c93e15f24962
Author: krother <krother@academis.eu>
Date:   Sat May 21 06:29:25 2016 +0200

    reorganizd part1

为了获得更长时间的历史概况,使用--pretty=oneline选项将每个提交压缩到一行是很有用的:

git log --pretty=oneline generate_maze.py
a11d542accc755a47533783b462c69992e218e73 improved module docstring
b865b483a7e042e02724464cc6bd944b23e2324e cleaned up scripts for part 1
6c3f01bb1efbe1dfdf6479a60c96897505c1d7a4 cleaned up code for chapter 03
b9699d1e57292d33aa908a790013c93e15f24962 reorganized part1

Why do we need hash codes instead of simply numbering commits from 1 to X?

时间旅行的类比为我们提供了一个很好的解释:想象我们回到过去给更早的自己一个考试的结果。我们会用现在的两个版本创建另一个历史(一个考试失败,一个考试完美)。除了产生的科学问题之外,仅仅通过一个日期很难明确地提到两个交替的现实中的一个。

有了像 git 这样的时间旅行机器,我们有可能改变代码的历史。此外,您(或一个团队)可以并行创建多个历史。简而言之,线性历史与时间旅行不太合拍。git 避免由此导致的悖论的方法是将每次提交视为独立于时间线的唯一事件——在计算机科学中,哈希代码是识别此类数据项的首选工具。我还没有在现实生活中看到哈希码应用于时间旅行,但这个想法听起来并不太糟糕。

签出旧的提交

确定了感兴趣的提交后,我们可以跳回到项目的历史中。让我们假设我们想要检查标记为“重组 part1”的提交之后的代码是什么样子的。我们需要从git log的输出中复制的git checkout命令和相应的散列码b9699d1e572..。我们回到过去

git checkout b865b483a7e042e02724464cc6bd944b23e2324e

git 给我们一个警告消息(“分离的头”),基本上意味着时间旅行是潜在危险的,git 将放弃任何更改,除非我们明确声明不是这样。当我们抓住机会简单地回顾过去并在编辑器中打开generate_maze.py时,我们注意到我们最近的 docstring 已经消失了。模块的标题再次显示如下

# Code for chapter 03 - Semantic Errors

git 已经更改了存储库中的所有文件,使其类似于提交历史中的状态。

回溯到最近一次提交

使用git log,我们只能看到比跳转点更早的提交。也就是说,我们更新的提交是不可见的(我们看不到它的哈希代码)。我们会永远被困在过去吗?还好我们的时光机有一个go back”按钮。当我们看够了过去,我们可以回到现在

git checkout master

我们的 docstring 再次出现在磁盘上的generate_maze.py中(取决于您的编辑器处理其他程序正在编辑的文件的能力,您可能会从您的文本编辑器中获得或多或少的惊讶反应)。

在 GitHub 上发布代码

即使你不是用 git 开发代码,在某些时候不碰到 GitHub 几乎是不可能的。GitHub 是一个拥有数百万储存库的网站,包含从 Hello World 程序到整个操作系统的所有内容。GitHub 为开发者提供了一个共享代码和合作项目的市场(见图 12-2 )。使用 GitHub 有很多好处:

  • 您可以从多台计算机访问您的程序代码,而无需 SSH。
  • 在 GitHub 上发布代码是一个备份系统。
  • 您可以在 web 浏览器中浏览已发布存储库的源代码。
  • 其他程序员可以复制一个项目,改进它,并友好地请你重新整合他们的更改。
  • 自述文件会自动呈现并显示在项目页面上。
  • 每个项目都有一个问题跟踪器,它是一个轻量级的项目管理工具。
  • 您可以将附加服务插入 Github 存储库中。例如,可以自动执行测试或者发布文档。

A419627_1_En_12_Fig2_HTML.jpg

图 12-2。

GitHub is a platform to share source code and to collaborate with other developers. The screenshot shows an earlier version of the MazeRun repository .

在撰写本文时,GitHub 是这个星球上开源开发事实上的中心平台。然而,您也可以将它用于私有存储库(通过付费计划)。我见过的很多程序员都把某个人在 GitHub 上的存在看做是一种名片。GitHub 概要文件并不能提供对编程技能的非常准确的评估(例如,因为大多数商业项目并不公开可见),但是熟悉该平台可以被认为是大多数编程语言的最佳实践。幸运的是,使用 GitHub 你只需要记住三个命令:git push, git pull,git clone.

在 GitHub 上开始一个项目

在 GitHub 上开始自己的项目很容易。你进入 http://github.com ,为自己创建一个账户,找到按钮“创建一个新的存储库”。之后,你主要是按照屏幕上的指示输入项目名称和描述。最相关的决定是你是把新的资源库从 GitHub 复制到你的电脑还是从你的电脑复制到 GitHub。GitHub 将显示这两个选项的命令,以便您可以将它们复制粘贴到 Unix 终端中。您只需要这样做一次,所以我们不会详细讨论确切的命令。

哪个选项更好?这取决于你已经有了什么:如果你还没有在你的电脑上用git init创建一个存储库,用git clone把存储库从 GitHub 复制到你的电脑上就更简单了。如果您已经在本地使用了 git,即使是一两次提交,也最好使用页面上给出的命令将现有的存储库复制到 GitHub。目前,GitHub 针对这种情况(例如针对 MazeRun 存储库)提出的命令如下:

git remote  add  origin  https://github.com/krother/maze_run.git

git push -u origin master

使用 GitHub 作为单一贡献者

一旦我们在 GitHub 上建立了项目,并完成了第一步配置,事情就变得简单了。我们如何发布我们的代码?让我们假设我们是项目中唯一的工作人员。我们在本地编写代码,并用git commit将更改提交给本地存储库。每隔一段时间(例如,每天),我们会将我们的更改提交到 GitHub,这样其他人就可以阅读它,并且我们的代码是安全的,以防计算机的驱动器崩溃。提交后,我们需要输入的是

git push

git 会要求我们输入用户名和密码,我们就完成了。如果第一次出现问题,两个最常见的原因是 git 存储库设置不正确(在这种情况下,您可以安全地删除 GitHub 上的存储库,创建一个新的,然后重试)以及您的互联网连接通过代理服务器工作,默认情况下不允许连接(一些公司和公共场所会这样做)。在这种情况下,请咨询您的网络管理员。

这种工作流不仅仅对一个人的编程项目有用。如果我们通过 GitHub 发布教程或培训材料(例如,使用 http://gitbook.com )或通过 GitHub 页面( http://pages.github.com ).管理我们自己的网站,效果也很好

从事其他人开始的项目

最初,git 是为一起工作的开发团队而创建的。这个想法是两个或更多的开发人员可以并行处理相同的代码。他们交换代码中的更改,因此公共代码库的增长速度比他们必须等待另一个人完成要快得多。通过GitHub开始一个现有的项目很容易。基本上任何人都可以使用git clone来获得存储库的副本。例如,我们可以开始开发 MazeRun 游戏的本地变体:

git clone https://github.com/krother/maze_run.git

git clone命令创建 GitHub 项目的本地副本。它还复制了.git目录,因此项目的整个历史也被复制了。稍后,我们可以和git pull一起检查 GitHub 上的库是否有任何变化:

git pull

要使用git pull,我们需要首先提交我们自己的更改。

有多个参与者的项目

默认情况下,只有存储库的所有者可以更改其内容。但是如果我们想和某人合作一个项目,我们可以在 Github 项目的设置中添加多个贡献者。通过添加一个贡献者,我们允许这个人添加代码到项目中,改变它的设置,甚至删除整个项目。这些特权应该只给予你认识和信任的人。

对于多个参与者,典型的工作流如下:

  1. git clonegit pull更新我们的本地副本。
  2. 编辑代码。
  3. git commit.提交更改
  4. 再次运行git pull检查变化。
  5. 随后,用git push.发布所有内容

由两个人合并更改

当两个或更多的人编辑相同的代码时,一个大问题就要出现了。想象一下,你和其他人同时编辑代码。双方都提交代码(前面列表中的步骤 3)。第 4 步和第 5 步会发生什么?如果两个人处理不同的文件,git 会自动合并两次编辑。合并时,将使用每次提交的最新文件。如果同一个文件是由两个人编辑的,合并会更复杂。通常,git 会要求您手动合并文件。您将得到一个标记了冲突更改的文件。然后你有机会解决冲突,用git addgit commit贡献代码。

拉取请求

有许多高级选项可以促进与其他开发人员的协作:例如,您可以创建自己的已发布项目副本(称为 Fork)。您可以在不修改原始项目的情况下更改分叉。然而,如果您认为您的更改值得被合并回原始项目中,这可以通过一个拉请求来完成。假设您在 GitHub 上创建了自己的 MazeRun 分支,并在项目副本中为游戏添加了音效。如果我们认为这也是对我的代码的一个很好的补充,会发生什么呢:

  • 您在 GitHub 上创建了一个提交到我的存储库的 Pull 请求。
  • 我收到拉取请求,并检查将要发生的变化。
  • 我接受这些改变。
  • 代码被合并(假设没有合并冲突)。

一个好的拉请求需要相关人员之间的某种交流,否则收到拉请求的开发人员很容易被弄糊涂。Pull 请求类似于 merge,只是它完全发生在 GitHub 上。拉请求是对开源软件进行大小改进的一种常见方式。

分支发展

git 的一个关键特性是我们可以并行处理多件事情。为了将时间机器的等效物延伸得更远一点,想象我们有多个交替的现实(2009 年的《星际迷航》电影包含了一个这造成的实际问题的极好例子)。幸运的是,有了 git,这些交替的现实或分支并不那么难以管理。

默认情况下,有一个单独的分支,称为master。让我们假设我们想要为游戏开发一个新的实验特性(例如,音效)。我们可以使用命令为其创建一个专用分支,并将其命名为sound_effects:

git branch sound_effects

现在我们有两个代码分支,mastersound_effects,两者仍然是相同的。我们可以看到所有分支

git branch

我们看到一个分支列表,当前活动的分支标有星号(*):

* master
  sound_effects

为了处理新的分支,我们需要切换到它:

git checkout sound_effects

你可以用git branch验证我们现在真的在新的分支机构了。我们现在可以正常地编辑代码、添加文件和提交。这里,我们简单地创建一个占位符:

echo "print(‘BEEP’)" >> sound.py
git add sound.py
git commit -m "created file for sound effects"

新文件被添加到新分支的存储库中。让我们切换回master分支:

git checkout master

我们注意到新创建的文件sound.py不见了!我们的代码中现在有两个交替的现实。

合并分支

在某些时候,我们可能会决定音效已经足够成熟,可以包含在节目的主线中。我们可以将一个分支的更改应用到另一个分支。假设我们仍然在master分支中。我们可以使用git mergesound_effects分支的变更合并到master中:

git merge sound_effects

请注意,master分支在此期间可能会进一步发展。在这种情况下,git 将合并更改,并要求我们手动合并,以防它们重叠。这样,我们可以确保代码总是一致的。

使用 git 中的分支进行开发有几种做法。最重要的最佳实践是将稳定的代码保存在master分支中,并将新实现的特性保存在其他地方(例如,development分支)。一旦一个特性足够稳定,它就会被合并到master中。对于本书的代码库( http://github.com/krother/maze_run ),我为许多章节创建了单独的分支。这样,我可以保持代码的上下文清晰地分开。我定期将不同的分支合并在一起。这些分支及其关系给人留下了深刻的印象(见图 12-3 )。理解什么是分支以及如何使用它们需要一些思考。我建议不要在你的第一个 git 项目中使用分支。然而,它们是职业发展中的一项基本技术。

A419627_1_En_12_Fig3_HTML.jpg

图 12-3。

Graphical log of a git repository with multiple branches

正在配置 git

一般来说,一旦您熟悉了基本命令(即add, commit, status, push,pull),使用 git 可以很好地与大多数日常编程活动集成。有几个配置选项让使用 git 变得更加愉快。

忽略文件

在导入 Python 模块时,Python 3 会在__pycache__目录下创建字节码来加速执行。在 MazeRun 中,只要我们运行一次程序,__pycache__目录就会包含类似于generate_maze.cpython-34.pyc的文件。Python 会自动更新缓存的文件,所以我们可以安全地忽略它们。然而,git status会不断纠缠我们将__pycache__目录添加到我们的存储库中:

> git status
Untracked files:
   (use "git add <file>..." to include in what will be committed)

    ___pycache___/

有充分的理由不将__pycache__目录添加到我们的存储库中。首先,Python 自动管理它们,所以它们对其他人来说毫无用处。第二,它们会在我们每次运行代码时改变,产生许多不必要的消息。我们更希望 git 不要在我们每次输入git status时都提醒我们这些文件。我们通过在主项目目录中创建文件.gitignore来实现这两个目标。.gitignore文件包含 git 不会打扰我们的名称和模式。该文件可能包含以下行:

___pycache___/*

现在,git status将不再提及缓存目录。此外,当我们试图通过git addgit add *,添加它时,它会被拒绝。除了字节码文件,大多数项目包含其他不需要被版本控制跟踪的文件。例如,这些包括

  • 本地配置设置(例如,包含我们的主目录)
  • 日志文件
  • 数据库
  • 临时文件
  • 密码

当使用git statusgit diff时,前三种方法会不必要地扩大存储库,并产生大量背景噪音。最后一个是严重的安全风险(绝对不要向存储库添加密码)。最佳实践是将这些类型的文件作为模式添加到.gitignore中。典型的.gitignore file可能包含以下项目:

___pycache___/*
*.conf
*.log
*.db
*.tmp
password.txt

第十三章介绍的 pyscaffold 工具会自动为我们创建一个更详细的.gitignore文件。当多个人一起开发一个程序时,有时会发生他们来回修改一行代码的情况。这可能是各自机器上不同的目录名或导入语句。当我们观察到这种模式时,它暗示了一些配置需要在存储库之外进行管理(例如,在一个环境变量或.bashrc文件中)。每当你看到人们来回修改代码,这是停止编码并开始讨论的好时机。

全局设置

git 存储配置选项的第二个地方是我们主目录中的.gitconfig文件。在这里,我们可以定义我们的用户名和有用的快捷键。可以使用git config命令或在文本编辑器中编辑该文件。为了简单起见,我将坚持后者。我自己的.gitconfig文件是这样的:

[user]
    email = krother@academis.eu
    name = krother
[alias]
    ci = commit
    st = status
    l = log --pretty=oneline

[user]部分只包含我在 github 上的用户名,这样我就不必每次连接到公共存储库时都输入用户名。[alias]部分定义快捷方式:git ci类似于git commit , git st类似于git status,``git l以单行方式总结日志。

用法示例

如果您以前没有使用过版本控制,您可能会发现使用 git 非常有益。当我开始使用版本控制系统时,过了一会儿,我想知道没有它我怎么能写出任何程序。然而,根据项目的规模和贡献者的数量,您可以以许多不同的方式使用 git。

二十个字符:低流量的小项目

在小型项目中,您可以将版本控制视为您的私人时间机器。git 为代码、数据、文档或其他任何东西维护了一个清晰的历史。例如,我用 git 编写和维护许多课程材料。在 GitHub 上维护许多小的存储库并没有什么不好。我已经在 GitHub 上创建了 25 个存储库,在 Bitbucket 上又创建了 5 个。我的一个最小的存储库只包含三个提交、两个 Python 脚本和几个图像文件( http://github.com/krother/twenty_characters ).它的目的是保存一个项目,以防我不小心擦除我的备份驱动器。我也为其他人创建的知识库做出了贡献。在某些情况下,我的贡献只有一行。真的没有最小尺寸。

Python:一个每天都有提交的巨大项目

另一方面,共享存储库可能会变得非常大。例如,标准 Python 解释器 CPython 的存储库(镜像在 http://github.com/python/cpython ),上)包含 131 个贡献者的 93,000 多个提交。吉多·范·罗苏姆自己持有 10800 个提交,添加了超过一百万行代码,删除了 700000 行(见图 12-4 )。历史可以追溯到 1991 年。每天都会提交多次更改。

A419627_1_En_12_Fig4_HTML.jpg

图 12-4。

Contributions to the Python interpreter by a few core developers over time. The red/green numbers indicate the lines contributed/deleted. Taken from http://github.com/python/cpython .

grep:一个长期项目

其他存储库的流量较少。例如,grep的存储库,一个在文件中搜索文本的 Unix 命令行工具,已经存在很长时间了。GNU grep 库在 1999 年有 35,000 行代码,但历史可以追溯到 Mike Haerkal 在 1988 年发布的第一个版本,这是 Ken Thompson 在 1973 年对原始 grep 的重写。虽然grep通常被认为是一个非常稳定的工具,但一个开发团队每周都会修改一到两次几行代码。如果没有版本控制,这个程序不可能维持超过四十年!

如示例所示,似乎也没有最大大小。我们通常为每个项目找到一个存储库。我强烈反对在同一个存储库中放置多个项目。毕竟,仓库并不昂贵。

git 是一个非常强大的工具。到目前为止,我们只是触及了表面。表 12-1 给出了最常见的命令。要了解更多,我强烈推荐斯科特·沙孔和本·施特劳布的《Pro Git 》( a press,2009 ),这是一本专门介绍 Git 提供的可能性和工作流的书。

表 12-1。

git Commands

| 向存储库添加文件`git init` `git add ` `git commit -m "message"` `git commit –amend` | 在当前目录中创建新的 git 存储库跟踪给定文件中的更改将跟踪的更改写入存储库更改提交消息或将文件添加到上次提交中 | | 导航修订`git status` `git checkout .` `git diff ` `git rm ` `git log` `git log ` | 显示添加、修改和未跟踪的文件放弃本地更改显示自上次提交以来的更改从下次提交中删除文件列表提交消息列出给定文件的提交消息 | | 配置 git `git config` | 更改配置变量 | | 使用远程储存库`git clone` `git pull` `git push` | 创建本地存储库的本地副本更新本地副本将更改提交到远程存储库(例如 GitHub) | | 高级选项`git branch` `git branch ` `git checkout ` `git merge` `git blame ` `git cherry pick hash` `git rebase` `git bisect` | 显示分支创建新分支将本地拷贝切换到给定分支合并两个分支显示谁编辑了哪一行拷贝了旧提交的文件将两个分支重新排列成线性历史记录指导您完成提交历史记录中的二分搜索法 |

其他版本控制系统

有一些可供选择的版本控制系统值得一提:

水银的

一个初学者友好的分布式版本控制系统。它的特性比 git 略少,但是常规的工作流比 git 更容易使用。网站 http://hginit.com 是一个不错的起点。

颠覆(SVN)

早在 git 和 Mercurial 之前就存在的非分布式版本控制系统。现在没有理由在新项目中使用它。我听到的使用 SVN 的最有说服力的论据是一家生产安全关键软件的公司。他们希望 100%确定他们使用的是一种稳定的技术,这种技术在过去十年没有改变,在未来也不太可能改变。参见 http://subversion.apache.org/ .

并行版本软件(CVS)

这是一个更老的版本控制系统,在博物馆中赢得了一席之地。

Bitbucket

像 Github 这样的代码库,允许免费使用有限数量的公共和私有库。Bitbucket 兼容 git 和 Mercurial。参见 https://bitbucket.org/ .

一套合作式软件开发管理系统

一个致力于开源项目的代码库。十多年来(在 GitHub 广泛流行之前),Sourceforge 是开源软件的主要市场。参见 https://sourceforge.net/ .

最佳实践

  • git 是一个跟踪代码变化的版本控制系统。
  • 随着时间的推移,git 会记录代码的后续版本。
  • 存储库是存储在.git目录中的变更历史。
  • 要跟踪文件中的变更,您需要git add文件和git commit变更。
  • git log显示提交历史。每个提交都由一个哈希代码标识。
  • 通过git checkout,你可以回到之前的提交,回到现在。
  • 项目的历史不一定是线性的。可以存在多个并行分支。
  • GitHub 是一个发布和交换代码的平台。它也是一种简单的备份机制。
  • 当两个贡献者同时编辑不同的文件时,git 会自动合并更改。
  • 当两个参与者编辑相同的代码时,您需要手动合并更改。
  • .gitignore文件包含不会被跟踪的文件名模式。
  • 合理使用存储库没有最小和最大的大小。所有 Python 项目都受益于使用版本控制系统。

十三、设置 Python 项目

For proper home decoration, you need three things: first, clean up the construction site. Second, the right tools. Third, the beer in the refrigerator. -My dad

当我们从头开始编写一个小程序时,我们并不太担心组织文件。我们只是把所有的东西都收集在同一个地方,这很好。MazeRun 游戏从一些 Python 文件和一些图像开始。但是随着项目的发展,其他文件开始积累。我们已经看到了各种输入和输出文件、测试、测试数据、配置文件、文档,当然还有更多 Python 模块。如何合理组织这些文件?我们如何控制对外部 Python 模块的依赖?意识到软件只有一部分由代码组成,一般来说,组织文件和构建 Python 项目的最佳实践是什么?

坚实的项目结构有助于我们

  • 快速查找文件。
  • 无需太多配置即可应用标准工具(例如,用于测试和打包)。
  • 让其他程序员参与进来,不用解释太多。
  • 在多个项目之间轻松切换。
  • 防止项目相互干扰。

在本章中,我们将进行挖掘工作,使我们的项目进一步发展成为可能(见图 13-1 )。首先,我们将使用 pyscaffold 工具为文件和目录建立一个结构。把这个结构想象成一个有支撑墙的大沟,所有东西都可以放进去。其次,作为我们项目的基础,我们将使用 virtualenv 工具来管理已安装的 Python 模块。

A419627_1_En_13_Fig1_HTML.jpg

图 13-1。

If programs were buildings, setting up a project would look like an excavation. The project contains a few things that are not a part of the program directly: a versioning system, a test environment, documentation, and so on. But what if I have already set up my project?

与建筑相比,我们可以采用现有的方案来构建这里提出的结构。即使我们已经有了一个 git 存储库,也可以通过git mv快速重新排列文件以适应给定的结构。因此,不仅我们的程序代码,而且支持代码的所有其他部分都将有自己的位置。

使用 pyscaffold 创建项目结构

幸运的是,Python 项目的文件和目录结构有一个事实上的标准。这种结构在大大小小的项目中都有。坚持它算作一个最佳实践,因为这种结构在 Python 程序员中是众所周知的,并且与其他 Python 工具配合得很好;也就是说,它有助于运行自动化测试和创建软件版本。pyscaffold 工具根据这个标准为 Python 项目创建了一个标准结构。它会在新的项目文件夹中创建重要的目录和文件。当然,我们也可以用一些 Linux 命令来设置它的大部分。使用 pyscaffold 的优点是我们确保了多个项目的一致性,并且它创建了一个干净的脚本,使得我们的软件在整个生命周期中更容易测试、构建和发布。

When is pyscaffold not applicable?

并非所有 Python 项目都使用 pyscaffold 创建的目录结构。最值得注意的是,像 Django、web2py 和 Zope / Plone 这样的大型 Python web 框架都有自己的脚本来为新项目创建目录和配置文件。丹尼尔·格林菲尔德(Daniel Greenfield)和奥黛丽·罗伊(Audrey Roy)所著的《Django 的两勺》(两勺出版社,2015 年)一书是组织 Django 项目(或 Django 总体而言)的绝佳资源。

安装 pyscaffold

pyscaffold 可以通过pip无痛安装:

sudo pip install pyscaffold

除了 Python 包,pyscaffold 还需要安装 git。详见第十二章。为了用 pyscaffold 创建一个新项目,我们从用于 Python 开发的文件夹(例如,projects/)开始,运行 pyscaffold 附带的putup脚本:

cd projects/
putup maze_run

这里的maze_run是我们想要创建的 Python 项目和 Python 包的名称。我们观察到 pyscaffold 创建了一个带有 4 个子目录和 10 个文件的maze_run目录,尽管其中一些是隐藏的。键入ls -la会导致

.git/
docs/
maze_run/
tests/
.coveragerc
.gitattributes
.gitignore
AUTHORS.rst
LICENSE.txt
MANIFEST.in
README.rst

requirements.txt
setup.py
versioneer.py

在下文中,我们将更详细地查看所创建的目录和文件(以及其他一些文件)。

Python 项目中的典型目录

通常,在一个好的目录结构中,每个文件都有一个明显的位置。在典型的项目结构中,pyscaffold 创建四个目录和几个附加文件。

pyscaffold 创建的目录

Pyscaffold 创建了四个目录,其中包含了典型 Python 项目中需要的大部分内容。我们将逐一查看:

Python 包的主目录

这是我们程序的 Python 代码所在的目录。在我们的例子中,它是maze_run目录。目录是一个可导入的 Python 包;它已经包含了一个__init__.py文件。它还包含文件_version.py,该文件从git.中自动确定版本号,我们根本不需要编辑 version.py 文件。这个目录也是我们可以添加自己的 Python 模块和子包的地方。

测试/目录

这是存储自动化测试的地方。除了一个__init__.py文件,目录应该是空的。如果我们已经安装了py.test,我们可以用

python setup.py test

或者

py.test

文档/目录

这是保存文档的单独文件夹。文档工具 Sphinx 的初始文件已经存在。如果我们已经安装了 Sphinx(和 make 工具),我们可以用

cd docs
make html
firefox _build/html/index.html

你可以在第十七章找到关于斯芬克斯的详细介绍。

那个。git /目录

我们看到 pyscaffold 已经自动创建了一个新的git存储库,并添加了所有文件作为初始提交。隐藏文件夹.git包含了git库的内部内容。如果您已经有一个现有的git存储库,只需将所有目录和文件(除了.git文件夹)移动到您现有的存储库中,并在那里运行git add。没有 pyscaffold 在后台做的“隐藏魔法”。

不是由 pyscaffold 创建的目录

还有一些在 Python 项目中常见的其他目录,但不是由 pyscaffold 创建的。有些将由脚本创建;其他的我们需要自己创造:

媒体夹/目录

按照惯例,bin/目录包含打算从命令行执行的程序(Python 和非 Python)。使用安装 Python 包时

python setup.py install

将为所有用户安装bin/目录中的程序。在 Linux 上,这些通常会安装在/usr/bin/目录中。以这种方式安装脚本是一种方便的方法,可以使我们的 Python 工具在系统范围内可用。

目录 build/、dist/和 sdist/

一旦你开始创建你的程序版本,目录build/, dist/,和/或sdist就会出现。

那个。hg/目录

如果你在一个项目中看到一个.hg目录,你就知道这个项目中使用了版本控制系统 Mercurial。. git.hg可能出现在同一个目录中(在这种情况下,你需要小心一点,因为并行使用两个版本控制系统不是最好的主意)。

数据目录

我们还可能希望在项目中为输入和输出数据建立单独的目录。将数据与程序代码分开通常是明智的。我们是希望将数据目录放在我们的项目文件夹中,还是放在一个完全不同的地方,这取决于我们是否希望将数据添加到同一个git存储库中。如果没有,最好选择一个单独的位置,并使用环境变量来指定其位置。

文件

pyscaffold 创建的文件

读我. rst

在任何项目中,README文件都是最重要的文件。因为它在文件系统和公共的git存储库中是可见的,所以如果大多数开发人员想安装程序或者只是想知道项目是关于什么的,这是他们首先要读的东西。该文件应包含该程序的功能、安装和使用方法以及在哪里可以找到更多信息的简要概述。

拥有一个 ReStructuredText 格式的 README 文件(.rst)允许我们使用公共存储库 github 和 bitbucket 使用的标记语言来很好地格式化我们的描述。或者,您可以使用 Markdown 格式(带后缀.md)。两者在相应的 GitHub 页面上呈现得非常相似。

setup.py

setup.py文件是 Python 项目的核心部分。它包含了构建我们的程序、创建发布和运行测试的指令。我们可以配置setup.py将你的程序发布到 Python 包索引(pypi.python.org)或者在 Windows 上用 py2exe 创建可执行文件。有了 pyscaffold 创建的setup.py文件,许多事情都可以开箱即用。最常见的用途是构建您的程序。例如,下面的命令收集了在build/目录中运行maze_run Python 包所需的一切:

python setup.py build

我们还可以将该包与系统上安装的其他 Python 模块一起安装:

python setup.py install

AUTHORS.rst

这个文件包含一个简单的开发人员列表和他们的联系方式。

LICENSE.rst

LICENSE.rst文件是一份涵盖法律方面的文件。默认情况下,作为作者,您是软件的版权所有者,不管这个文件是否存在。但是如果你打算授予其他人使用、修改或重新发布你的软件或者用它赚钱的权利,许多微妙的法律问题就开始起作用了。除非你的同行是律师,否则他们会更喜欢花时间使用软件或改进软件,而不是在定制的许可证之间寻找法律漏洞。因此,最佳实践是使用一个标准软件许可证(麻省理工学院许可证、LGPL、GPL、Apache 许可证,这取决于您希望允许的使用类型)并将默认文本粘贴到该文件中。在 http://choosealicense.com/ .可以获得开源许可及其人类语言含义的概述

MANIFEST.in

MANIFEST.in文件包含文件名和文件名模式的列表。该列表用于标识要包含在构建和源代码发布中的文件。例如,我们将在这里找到来自maze_run包的 Python 文件,但不是测试。我们可以创建一个.tar.gz档案,通过调用setup.py来分发MANIFEST.in文件中指定的所有文件:

python setup.py sdist

versioneer.py

这个脚本方便了用git.更新版本号,它通常会很好地保护自己,所以我们根本不需要修改它。

requirements.txt

这个文件被pip用来跟踪所需的 Python 模块及其版本。我们将负责在virtualenv部分安装它们。

。覆盖范围 c

使用coverage.py通过自动化测试计算覆盖率的配置文件。我们不会在本书中触及它。

。gitattributes 和。被增加

git.的默认配置文件详见第十二章。

不是由 pyscaffold 创建的文件

其他一些不是由 pyscaffold 创建的文件值得一提,因为它们经常出现在 Python 项目中。概述见表 13-1 。

表 13-1。

Additional Files Frequently Found in Python Projects Not Created by pyscaffold

| 文件名 | 描述 | | --- | --- | | `CONTRIBUTING.md` | 对希望提交错误报告、错误修复或其他类型的改进的人的说明。 | | `Makefile` | 如果一个程序包含用 C 写的组件,`Makefile`就是相当于`setup.py`的脚本。 | | `fabfile.py` | 通过结构工具促进本地和远程服务器安装之间通信的脚本。 | | `manage.py` | 当您看到这个脚本时,您知道您在一个 Django 项目中。 | | `Dockerfile` | 虚拟化技术 Docker 已经获得了广泛的流行。`Dockerfile`包含创建封装服务或其他组件的 Docker 映像的指令。 | | `tox` `.ini` | 生成工具 tox 的配置文件。 | | `.travis.yml` | 持续集成工具 Travis 的配置文件。 |

设置我们程序的版本号

pyscaffold 建立的机制有助于管理我们项目的版本号。要设置版本号,使用git t ag命令。按照惯例,版本号总是以一个小的“v”开头:

git tag v0.3

由于 pyscaffold 创建的基础设施,我们的 Python 包将自动找到这个标记:

>>> import maze_run
>>> maze_run. __version__
’0.3’

使用 virtualenv 管理 Python 项目环境

建立了项目的目录和文件后,我们准备处理项目的第二个基本方面:Python 本身。在管理 Python 安装时,我们很快会遇到一些实际问题:

  • 我们将使用哪个 Python 版本?
  • 可以用 Python 3.3 和 Python 3.6 一样测试游戏吗?
  • 我们可以在没有超级用户权限的情况下安装额外的模块吗?
  • 怎样才能防止不同项目需要的 Python 库互相干扰?
  • 如何方便地设置PYTHONPATH变量和其他环境变量?

在开发软件时,所有这些情况都很常见。有时,我们需要一个 Python 库,它在一个特定的项目中有特定的版本,但在另一个项目中没有。有时,我们希望在同一台机器上安装稳定版本的同时进一步开发我们的程序。在任何情况下,我们都希望防止不同的项目相互干扰。在所有这些情况下,virtualenv 都会出手相救。

Virtualenv 管理多个项目,每个项目都有一组单独安装的 Python 库。它允许我们在项目之间快速切换,我们的整个 Python 环境也随之改变。同时,这是一种轻量级的方法,不会产生大量的管理开销。实际上,virtualenv 就像是我们项目及其 Python 安装周围的护城河。它确保我们项目中 Python 安装或库配置的错误不会破坏我们的整个系统,反之亦然(见图 13-2 )。

A419627_1_En_13_Fig2_HTML.jpg

图 13-2。

Virtualenv is like building a moat around the house. It prevents a fire from spreading—in both directions. Likewise, a virtual environment prevents that Python projects interfere with each other.

安装 virtualenv

为了方便地使用虚拟环境,需要两个 Python 包。两者都可以安装pip.第一个是virtualenv包本身:

sudo pip install virtualenv

第二个是virtualenvwrapper,它是一个工具集合,使创建虚拟环境和在它们之间切换变得更加容易:

sudo pip install virtualenvwrapper

我们还需要在˜/.bashrc文件中添加几行,让 virtualenv 知道在哪里可以找到它的配置:

export WORKON_HOME=$HOME/.virtualenvs
export PROJECT_HOME=$HOME/projects
source /usr/local/bin/virtualenvwrapper.sh

最后,我们需要一行额外的代码来使 Python3 成为 virtualenv 管理的项目的默认解释器:

export VIRTUALENV_PYTHON=/usr/bin/python3

将项目连接到 virtualenv

我们现在可以告诉 virtualenv 接管我们用 pyscaffold 创建的项目文件夹。首先,我们使用以下命令启动一个新的 virtualenv 项目:

mkvirtualenv maze_run

在 pyscaffold 项目中使用 virtualenv 不是先决条件;virtualenv 适用于任何类型的目录,甚至是空目录。我们可以明确指定 Python 版本:

mkvirtualenv maze_run -p /usr/bin/python3

我们得到以下消息:

Running virtualenv with interpreter /usr/bin/python3
Using base prefix ’/usr’
New python executable in maze_run/bin/python3
Also creating executable in maze_run/bin/python
Installing setuptools, pip.done.

幕后发生了什么?首先,virtualenv 在自己的文件夹˜/.virtualenvs/maze run .中创建一个新的子目录,在那里将存储库和配置脚本。在子文件夹˜/.virtualenvs/maze run/bin/中,你会找到 Python 和pip.的副本,它们将代替/usr/bin/python3或你的 Python 解释器。实际上,您现在有了一个独立的 Python 安装。一件好事是我们没有什么理由去挖掘˜/.virtualenvs文件夹;大多数时候,它能很好地管理自己。第二步,我们需要将虚拟 Python 环境连接到我们的项目文件夹:

cd maze_run/
setvirtualenvproject ˜/.virtualenvs/maze_run/ .

就这样,我们结束了!虚拟环境已经可以使用了。在图 13-3 中,你会发现项目目录和隐藏的 virtualenv 目录包含了什么。

A419627_1_En_13_Fig3_HTML.jpg

图 13-3。

Contents of a project directory and the according virtual environment directory. Most of the time, it is sufficient to edit only the files/directories on the left side.

使用 virtualenv 项目

虚拟 Python 环境的主要好处是我们可以在它们之间自由切换。几个简单的命令就可以打开或关闭虚拟环境。要开始处理maze_run项目,请键入

workon maze_run

我们看到项目名称(maze_run)出现在我们的提示中。例如:

(maze_run)˜/projects/maze_run:master$

现在,每当我们运行 Python 时,都会使用虚拟环境的 Python 安装。我们可以检查一下

>>> import sys
>>> sys.executable
’/home/krother/.virtualenvs/maze_run/bin/python’

我们已经成功地将我们的工作环境从系统的其余部分分离出来。即使我们替换或删除我们的主要 Python 安装,虚拟环境也将继续工作。

在 virtualenv 中安装软件包

在虚拟环境中使用 Python 解释器时,您可能会注意到之前安装在系统上的所有 Python 库都消失了。virtualenv改变模块的搜索路径,不再包含标准安装目录。起初这可能感觉有点麻烦,但这正是使用virtualenv.的目的,我们不需要关心系统范围内安装的库是否有正确的版本,或者是否损坏或有错误。他们实际上已经出局了。相反,我们的项目现在有了自己的库。如果我们的项目需要一个库,我们必须在虚拟环境中使用pippython setup.py install从头开始安装:

pip install pytest

这将安装py.test测试框架,就像第八章一样,这次是在˜/.virtualenvs/maze run/目录中。

No administrator privileges are required to install packages

这是使用virtualenv的一个很好的副作用,它不仅方便,而且避免了由于安装像root一样的潜在有害包而危及系统安全。

如果我们的程序需要安装特定版本的库,我们可以将它们写入requirements.txt文件。例如,我们用pip freeze获得py.test的版本,并可以将结果放在requirements.txt中。该文件将包含一个库:

pytest==2.9.2

以下命令安装项目的所有依赖项:

pip -r requirements.txt

Doesn’t installing libraries twice generate extra overhead?

简而言之,是的。如果我们创建十个虚拟环境,每个环境都需要同样的十个库,那么每个库将在我们的系统上安装十次。当然,这将使用十倍以上的磁盘空间。幸运的是,大多数 Python 库并没有那么大。基本上是用磁盘空间来换取单独修改每个安装的自由。

离开虚拟会话

当我们在虚拟环境中完成工作并想关闭它时,我们可以键入

deactivate

虚拟环境专用于单个终端会话。因此,我们可以同时处理许多项目,只要它们在不同的终端中打开。

用于管理虚拟环境的其他 shell 命令包括

lsvirtualenv
rmvirtualenv
cpvirtualenv

How to create many virtual environments for testing?

如果您想用 Python 解释器和库版本的多种组合来测试您的软件,您可以用 Tox 来自动化这个过程。Tox 从版本列表中自动创建虚拟环境,并为您指定的每个组合运行测试。 https://testrun.org/tox/latest/

配置 virtualenv 启动和停用

建立一个项目环境通常需要比创建几个目录和安装 Python 库更多的东西。这些可能包括

  • 设置PYTHONPATH变量
  • 设置 C 库的路径
  • 使用登录名和密码设置环境变量
  • 设置其他环境变量
  • 启动数据库服务器
  • 启动其他服务和守护程序
  • 启动虚拟机

还有,这些东西需要在我们工作结束后清理干净。幸运的是,virtualenv为特定于项目的配置的设置和清理提供了一个自然的地方。在˜/.virtualenvs/maze run/bin/目录中,有四个用于该目的的命令行脚本:每次激活虚拟环境时,preactivatepostactivate将会运行。每次您停用环境时,predeactivatepostdeactivate都会被执行。你可以在图 13-4 中找到事件的精确顺序。

A419627_1_En_13_Fig4_HTML.jpg

图 13-4。

Activation/deactivation sequence in virtualenv

我们无法在此详细讨论这些任务。相反,我们将看一两个例子。集成其他的并不那么困难,只要它们可以用命令行脚本来表达。

设置 PYTHONPATH 变量

到目前为止,在 virtualenv 的启动脚本中最常见的是设置PYTHONPATH变量。通过这样做,我们希望将maze_run包导入到我们的整个项目中。我们不能在系统范围内这样做(例如,在.bashrc文件中),因为那样我们的环境会再次开始互相干扰。为了设置变量,我们需要在postactivate脚本中导出它。脚本中相应的一行如下所示:

export PYTHONPATH
=/home/krother/projects/maze_run/

Python 3 非常注意已安装库的路径,所以大多数时候不需要在PYTHONPATH中包含任何其他内容。

当导出PYTHONPATH变量时,您可能还会看到下面的表达式:

export PYTHONPATH
=$PYTHONPATH:/home/krother/projects/maze_run/

它复制了PYTHONPATH变量的内容并附加了我们的项目目录。然而,当将这个命令与virtualenv!结合使用时,有一个令人讨厌的问题,如果我们现在激活和停用我们的虚拟环境几次,PYTHONPATH会变得越来越长。这样会很快产生不想要的副作用!

用 virtualenv 安装 Pygame

大多数时候,用pip在虚拟环境中安装 Python 库效果很好。一个明显的例外是 Pygame。要安装它,我们需要执行几个手动步骤。首先,系统级需要几个 C 库(主要是 SDL 媒体库和版本控制系统 Mercurial)。在 Ubuntu Linux 上,它们可以与

sudo apt-get install mercurial python3-dev python3-numpy libav-tools \
    libsdl-image1.2-dev libsdl-mixer1.2-dev libsdl-ttf2.0-dev libsmpeg-dev \
    libsdl1.2-dev libportmidi-dev libswscale-dev libavformat-dev libavcodec-dev

其次,我们从存储库中检索 Pygame 包的源代码并编译这些包:

hg clone https://bitbucket.org/pygame/pygame
cd pygame
python3 setup.py build

现在我们可以切换到我们的虚拟环境并在那里安装 Pygame:

workon maze_run
cd pygame/
python3 setup.py install --prefix="$HOME/.virtualenvs/maze_run"

如您所见,我们项目的隔离并不完美。因为我们必须安装系统范围的库,它们仍然可能干扰其他项目。此外,virtualenv 不考虑安全或安保方面。如果我们不小心编写了一个脚本,删除了所有其他项目中的所有文件(或者恶意攻击者故意这样做),virtualenv 不会采取任何措施来防止这种情况。virtualenv 创造的隔离让开发更方便,没别的。如果我们想要更强地隔离我们的项目,我们可以使用更重的虚拟化技术(例如,vagger、VirtualBox、Docker,或者甚至将我们的程序部署到 AWS 这样的基于云的服务)。但是让这些设备正常工作就是另一回事了。

最佳实践

  • 代码只是 Python 软件项目的一部分。
  • pyscaffold 工具创建了一个包括目录和文件的标准项目结构。
  • 标准目录用于程序代码、测试和文档。
  • 脚本是测试、构建和安装程序的入口点。
  • 可以使用git tag命令设置软件的版本号。
  • virtualenv 工具允许您管理多个 Python 环境。
  • 每个虚拟环境管理自己的一组已安装库。
  • 在 virtualenv 中安装库不需要超级用户权限。
  • 激活或停用项目时,可以执行自定义 shell 脚本。
  • 虚拟环境不提供任何安全防范措施。

十四、清理代码

“首先让它工作,然后让它正确,最后,让它快速。”—Stephen C. Johnson 和 Brian W. Kernighan 在《字节》杂志(1983 年 8 月)《系统编程的 C 语言和模型》中

当我们学习用一种新的语言编程时,我们一开始会编写非常小的程序。在 Python 中,小程序很小;像搜索文本文件、缩小数字图像或创建图表这样的任务可以用十行或更少的代码来完成。在程序员学习之初,每一行都是艰苦的工作:学习简单的事情,如向列表追加一个值或将一个字符串剪成两部分,似乎要花很长时间。每一行至少包含一个 bug。因此,我们在微小的步骤中取得进展。但是过了一段时间后,我们对这门语言的知识变得更加牢固了。几个小时、几天或几周前似乎无法实现的事情,突然间变得容易了。所以我们开始编写更雄心勃勃的程序。我们开始编写不再适合单个屏幕页面的代码。很快,我们的程序超过了 100 行。对于一个程序来说,这是一个有趣的大小,我们已经通过 MazeRun 游戏达到了这个大小。在本书的第一部分,我们开始从头开始开发 MazeRun 游戏。我们一个接一个地增加新功能。在这一章中,我们将增加一个新的特性。我们的编程风格需要如何适应一个不断增长的程序?我们可以永远一个接一个地添加小功能吗?还是我们必须考虑以前没有考虑到的方面?

有组织和无组织的代码

例如,我们将从文本文件中加载图块数据。在第二章中,我们将瓦片实现为元组列表。将这些信息放在一个文件中会使扩展游戏中的图形和测试我们的程序变得更容易。我们可以很容易地将图块及其索引存储在文本文件tiles.txt中:

REMARK x and y positions of tiles in the .xpm file
#   0   0
    0   1
o   1   0
x   1   1
.   2   0
*   3   0
g   5   0

要使用这些信息,我们需要读取文本文件,解析其内容,并收集所有瓷砖的边界矩形作为包含 Pygame 的字典。矩形对象。我们还需要忽略以REMARK开头的行。读取和解析都可以用一个小的 Python 程序来完成。由经验较少的 Python 程序员编写的代码的第一个工作版本可能如下所示:

tilefile = open('tiles.txt')
TILE_POSITIONS = {}
for text_str in tilefile.readlines():
    print([text_str])
    x = text_str[2]
    # print(data)
    # if 'REMARK' in data == True: # didn't work
    data = text_str
    if text_str.find('REMARK') == 0:

       text_str = text_str.strip()
       #print(line[7:]) # doesnt need to be printed
       continue
    else:
        import pygame
        y = int(text_str[4])
        r = pygame.rect.Rect(int(x)*32, int(y)*32, int(32), int(32))
        key = data.split()[0]
        TILE_POSITIONS[key] = r
        # print(TILE_POSITIONS[-1])
    continue
print(TILE_POSITIONS)

这个程序工作正常,但是看起来很乱。代码很难理解,主要是因为我们必须阅读并理解所有的代码行才能理解程序的功能。当然,这个程序包含了不必要的行,尽管我们可能不容易看出是哪一行。另一方面,对于熟练的 Python 程序员来说,解析文本文件并不是真正的挑战。他们可能会屈服于用尽可能少的行来解决任务的诱惑:

from pygame import Rect

mkrect = lambda x: (x[0], Rect(int(x[1])*32, int(x[2])*32, 32, 32))
tile_positions = dict(map(mkrect, [l.split('\t') for l in open('tiles.txt')\
    if l[0]!='R']))

print(tile_positions)

这个程序也能正常工作。虽然比较短,但也不容易理解。我们可以争论这两个程序中哪一个更丑陋:为了简洁,人们可能更喜欢第二个,或者第一个,因为它使用了不太复杂的语言特性,使得它(理论上)更容易被编程新手理解。我将把决定权留给你,因为这两个项目都有很多需要改进的地方!在本章中,我们将清理这些从文本文件中读取图块坐标的程序。

软件熵:无组织代码的原因

当程序增长时,它更容易变得无组织。保持我们的程序干净变得更加重要。但是为什么程序一开始就变得没有条理呢?当一个程序增长时,为什么我们需要组织更多的东西,做更多的清理工作?正如我们在上一节中看到的,解决同一个编程问题有多种可能性。显然,可能的实现数量随着规模的增加而增加。假设我们把一个四行程序分成函数。为了简单起见,让我们假设我们可以自由地移动函数之间的边界。我们可以创建四个函数,每个函数一行,或者我们可以有一个四行函数,两个两行函数,和三个其他的组合(见图 14-1 )。现在考虑将一个八行程序分成几个函数。有了两倍长的代码,我们可以创建八个单行函数。我们可以创建一个八行的函数。我们可以创建一到四个双线函数。我们可以创造介于两者之间的任何尺寸组合。可能性的数量比行数增长得快得多。如果我们不仅考虑函数,还考虑所有类型的程序结构(列表与字典、for循环与列表理解、类、模块等等)。),同一问题的可能实现的数量实际上是无限的。

A419627_1_En_14_Fig1_HTML.jpg

图 14-1。

Possibilities to structure a program into functions. Top row: a four-line program; all six possibilities are shown (four one-line functions, two two-line functions, etc.). Bottom row: an eight-line program; only five out of many possibilities are shown. With more rows, the number of possible structures grows exponentially.

这些可能的实现中哪一个是最好的?这取决于我们的程序做什么,使用的输入类型,我们使用什么库,以及用户的期望。我们将这简单地称为你的程序的上下文。现在有一个令人讨厌的事实:当一个程序成长时,环境会改变。当我们添加新的功能、新的输入类型、新的库时,环境会逐渐改变。当环境改变时,以前看起来是最佳解决方案的代码会变成次等的解决方案。不断变化的环境是难以找到理想实现的一个主要原因。

无组织代码的第二个原因是程序是由人类编写的。例如,编写有效的代码比编写整洁的代码更容易。程序通常在多个会话中编写。有一天我们开始在程序中添加一些东西,但是第二天忘记完成它。最后但同样重要的是,时间压力导致代码写得很快,而不是很干净。保持代码的整洁需要程序员高度的自律,这是一场与懒惰、健忘、匆忙和其他人类不完美表现的持续战斗。

由于两个原因,变化的环境和人类的不完美,随着时间的推移,它们变得无组织是所有程序的固有属性。这种代码无组织的现象被称为软件熵,借用了热力学第二定律的概念。用不那么光彩的话来说,法律规定:无序是自己生长的。不管是什么原因,都是我们要收拾残局。我们需要知道如何识别、清理或防止无组织的代码。

如何识别无组织代码?

有许多方法可以使代码变得杂乱无章。它们中的一些被赋予了奇特的名字,如代码气味 s 或 un pythonic 代码。这些术语有助于与其他 Python 程序员就编码实践展开对话。然而,当我们在寻找清晰的规则时,这些关键词就没什么用了。没有定义什么是 pythonic 代码,什么不是。在这里,我们将检查需要清理的代码的四个方面:可读性、结构缺陷、冗余和设计缺陷。

可读性

在一个更大的程序中,我们花在阅读上的时间比写代码还多。因此,代码的可理解性至关重要。我们可以问自己一个非常简单的问题:当我们阅读代码时,我们理解它吗?初学者和高级程序员编写的代码都或多或少具有可读性。很明显,像第一个例子中那样用print语句和注释行乱丢代码会降低可读性。在第二个例子中,玩代码高尔夫(用尽可能少的击键来解决问题)也没有帮助。可读性从小事开始。比较线条

if text_str.find('REMARK') == 0:

使用语义相同的行

if line.startswith('REMARK'):

第二种表达方式更接近英语,更明确,因此可读性更强。可读性体现在代码的许多方面:选择描述性变量名、代码格式、合理选择数据结构(想象一下只使用元组编写程序)、代码模块化和使用注释。当然,在阅读代码时,经验很重要。了解高级语言特性和一些库是游戏的一部分。但是很多时候,需要查看一段代码,并快速找出它的作用和存在的原因,而不是手动逐行跟踪代码。如果你发现不在脑子里执行代码很难回答这些问题,那么可读性就需要提高了。

结构性弱点

有两种非常常见的结构性弱点:第一种是缺乏结构。缺乏结构的一个典型标志是不包含任何结构的大块代码:包含 100 行或更多行的函数,没有任何函数的程序,等等。在 Python 中,不用任何函数写程序也是可以的。例如,我经常在不考虑函数的情况下编写简短的数据分析脚本。但是超过 100 行,它们很快就会变成一次性代码。在 100 行以上,结构化是必要的,程序越大,它就变得越重要。

第二个结构弱点是伪结构,代码看起来是结构化的,但实际上是另一种形式的整体块。一个简单的例子是多个for循环、if条件和其他代码块,如下例所示:

for line in tilefile:
    if not line.startswith('REMARK'):
        try:
            columns = line.split('\t')
            if len(columns) == 3:
                x = int(columns[1])
                y = int(columns[2])
                if 0 < x 100:
                    if 0 < y < 100:
                        r = Rect(...)
                        ...

根据经验,当您在任何 Python 程序中达到第四级缩进时(第 16 列之前的所有内容都是空白),有些事情很奇怪。通常代码可以通过重组来改进。特别是,如果有多个嵌套的for循环,性能可能会很快下降。

伪结构的一个不太明显的例子是代码被分割成多个功能,但是职责没有明确定义。考虑以下示例:

def get_number():
    a = input("enter first number")
    return a

def calculate(a):
    b = input("enter second number")
    result = float(a) + (b)
    print("the result of the addition is:", end="")
    return result

def output(r):
    print("{}".format(r))

num = get_number()
result = calculate(num)
output(result)

在本例中,输入和输出部分由calculate函数执行。功能之间的界限难以理解,代码变得更难管理。当代码变长时,这种结构上的弱点变得非常普遍。还有许多其他种类的结构性弱点。

裁员

冗余违背了编程的不重复原则(DRY)。通常,冗余来自复制粘贴代码片段。最明显的冗余形式是重复行。例如,在本章的第一个代码示例中,continue语句出现了两次:

...
if text_str.find('REMARK') == 0:
    ...
    continue
...
continue

在这种情况下,第一个continue是多余的,因为第二个将被执行。第二个continue也是多余的,因为循环无论如何都会终止。像这样的冗余行增加了程序的大小,损害了可读性。在这种情况下,我们可以简单地删除它们。另一种冗余是代码块以微小的变化重复出现。有时候会出现重复块,因为程序员使用Ctrl-C + Ctrl-V作为编程工具;有时它们会自己进化。在这两种情况下,重复的代码块可以通过将多余的代码行移到单独的函数或类中来删除。这种重组被称为重构,可以成为更大程序中的复杂操作。冗余的一种更微妙的形式是数据结构中的冗余。考虑到冗余出现在许多层次上,有时很难发现,冗余成为大型程序中缺陷的主要原因就不足为奇了。

设计弱点

程序设计是一个更难的方面。好的程序设计会产生健壮的程序,它能容忍不寻常的输入,有明确定义的可接受值范围,当输入错误时有明确的错误消息。一般来说,稳健的设计可以防止缺陷的蔓延。在 MazeRun 游戏中,至少有一个设计弱点:随机迷宫生成器有时会在迷宫中创建无法到达的点。目前这还不是问题,但将来可能会成为问题。

好设计的第二个方面是可扩展性:在不破坏现有功能的情况下,向程序中添加新内容的难易程度。为了实现一个简单的特性,需要修改的地方越少,设计的可扩展性就越强。好的设计预见到未来会发生什么样的变化。当你想评估一个设计时,问问你自己:你对改变代码感觉如何?如果有你不喜欢接触的区域,设计可能需要改进。

总而言之,难以阅读、无结构、冗余或包含设计缺陷的代码可能被认为是无组织的(见图 14-2 )。通常,这些症状会同时出现。您可以在加载图块坐标的两个代码片段中找到所有四种症状。现在想象在一个 1000 行的程序中出现类似的症状,这个程序跨越了许多屏幕页面——同样,这个问题会随着程序大小的增加而增加。但是抱怨糟糕的代码对我们没有帮助;我们需要思考如何改进它。因此,接下来我们将看看清理 Python 程序的一些最佳实践。

A419627_1_En_14_Fig2_HTML.jpg

图 14-2。

“I don’t get it. The kitchen got dirty all by itself.” Software becomes messy over time.

清理 Python 指令

我希望前面的例子已经使您相信清理代码是必要的。软件熵原理告诉我们,代码是自己变得无组织的,但不会自己清理(也见图 14-2 )。清理代码是一项日常编程任务。清理代码包括许多非常简单的任务。它们中的大多数都需要很少的 Python 知识,因此我们可以立即开始改进我们的初始实现。为了清理代码,从代码的工作版本开始会有所帮助。理想情况下,有自动测试告诉我们是否破坏了什么。让我们擦,擦,抛光,直到我们的代码发光!

将导入语句放在一起

要理解一个程序,有必要知道它还需要哪些模块来工作(它的依赖)。在 Python 中,依赖关系主要通过import语句来体现。因此,Python 程序的第一个功能单元应该是一个包含所有import语句的独立块。我们只是从程序中收集所有的导入语句,并将它们移动到文件的开头。这样,很容易一眼就看出代码需要哪些组件。只导入真正使用的 Python 对象是值得的。在我们的例子中,有一个单一的进口声明,我们只使用pygame.Rect。我们的进口商品变成了:

from pygame import Rect

我们用一个空行将导入与任何代码分开。

将常数放在一起

之后的导入部分是存放所有常量的好地方。常量是一个变量,它的值在程序执行过程中不会改变。典型的常量是输入和输出文件名、路径变量、列标签或计算中使用的比例因子。我们将简单地在导入块后的单独部分收集所有这些常量。在 Python 中,没有技术手段可以让一个变量成为常量;它们的值总是可以被覆盖。为了更容易区分常量和改变其值的变量,Python 常量按照惯例用UPPER_CASE字母书写。我们在图块坐标加载器中有两个常量。首先,我们需要计算矩形的像素大小。我们将把它放在一个常量SIZE中。其次,还有文件名"tiles.txt"。该文件名假定文件位于当前目录中。为了使程序可以在不同的地方使用,我们需要提供完整的路径。我们可以写

TILE_POSITION_FILE = '/home/krother/projects/maze_run/tiles.txt'

然而,这只能在我自己的计算机上运行,这使得代码非常不灵活。文件名的一个更好的替代方法是使用表达式os.path.dirname(__file__)来确定当前 Python 模块的位置。然后我们可以用os.path.join将我们的文件名添加到路径中。程序的完整导入和常量部分现在是:

import os
from pygame import Rect

CONFIG_PATH = os.path.split(__file__)[0]
TILE_POSITION_FILE = os.path.join(CONFIG_PATH, 'tiles.txt')
SIZE = 32

同样,我们用一两行空行将常量与其他代码块分开。在最初混乱的代码中,变量 TILE_POSITIONS 看起来像一个常量,但被程序修改了。我们将其更改为小写以备后用:

tile_positions = {}

随着程序的发展,许多常量会发生变化。在一个 1000 行以下的程序中,这样的改变通常很容易通过编辑代码来适应。但是,如果每次运行程序时常量的值都会发生变化,那么就应该将它们移动到输入文件中,使用 argparse 模块创建命令行选项,或者使用 configparser 模块读取配置文件。

删除不必要的行

在编程中,我们起初认为很重要的行,后来可能证明根本不重要。一个常见的直觉反应是认为“也许我以后会需要它们”,并留下不必要的代码。但是,程序不是仓库!不必要的代码需要被严格剔除。如果您有不想丢失的说明性代码示例,请将它们复制到一个单独的文件中,并为其创建一个单独的git commit。在我们的例子中,我们有许多不必要的行的例子:print语句、注释行和前面提到的多余的continue语句。我们可以简单地删除它们(总共七行)。该程序立即变得更具可读性!现在更容易注意到有一个冗余的变量赋值:

data = text_str

变量datatext_str是相同的。我们可以去掉额外的任务。我们也可能认识到,下面的if条件是一条死胡同:

if text_str.find('REMARK') == 0:
    text_str = text_str.strip()

修改后的变量text_str以后不再使用。因此,我们可以去掉这个代码块,用与if语句相反的语句替换下面的else。结果,我们的程序变得比以前清晰多了:

tile_positions = {}
for text_str in open(TILE_POSITION_FILE).readlines():
    x = text_str[2]
    if text_str.find('REMARK') != 0:
        y = int(text_str[4])
        r = Rect(int(x)*32, int(y)*32, int(32), int(32))
        key = text_str.split()[0]
        tile_positions[key] = r
print(tile_positions)

删除线路后,这是验证程序是否仍在运行的最佳时机。我们已经清理了代码中的许多问题,但是我们还没有完成。

选择有意义的变量名

精心选择的变量名对可读性有很大的影响。一般来说,包含英文单词的名字比首字母缩写更好。描述意义的英语单词比描述变量类型的单词更好。如果变量本身是短命的,那么非常短的变量名通常是好的(例如,我们例子中的xy)。我们可以通过用name,代替rect, key和用 row 代替text_str来提高可读性。表 14-1 包含一些好的和坏的变量名的例子。

表 14-1。

Examples of Bad and Good Variable Names

| 严重的 | 好的 | 说明 | | --- | --- | --- | | `xs` | `xsize` | `xs`太短 | | `str_list` | `column_labels` | `str_list`形容一种类型 | | `dat` | `book` | `dat`没有意义 | | `xy` | `position` | `xy`没有意义 | | `plrpos` | `player position` | 露骨的话 | | `line_from_text_file` | `line` | 引用太多 | | `l` | `?` | 史上最烂的变量名!根据字体的不同,它很容易被误认为是 1。 |

不时重新检查变量名是值得的。在您开发代码时,它们的含义会发生变化——一个不可理解的变量名是不好的,但一个误导性的变量名更糟糕。有了干净的变量名,缺陷通常变得更容易发现。

惯用的 Python 代码

有一些小的改进要做。我们可以使用 Python 习惯用法,即适用于许多情况的简短、精确的表达式。在此,我仅举两个简短的例子。首先,我们可以使条件表达式更具可读性,如前所述:

if not row.startswith('REMARK'):

其次,我们可以使用csv模块来挑选文件的列。这比自己解析文件更不容易出错。同样,使用with语句是一种普遍推荐的打开文件的方式(因为之后它们会自动关闭):

with open(filename) as f:
    for row in csv.reader(f, delimiter='\t'):

找到正确的习惯用法很难,关于哪种习惯用法是最好的看法也不尽相同。它需要知识和经验的结合。既没有 Python 习惯用法的完整目录,也没有何时使用它们的明确规则。最接近的是卢西亚诺·拉马尔霍的书《流畅的 Python》(O ' Reilly,2015)。

重构

到目前为止,我们的清理主要集中在个别线路上。在下一节中,我们将从整体上检查程序的结构。改进程序结构被称为重构。重构是维护大型程序的一个重要的最佳实践。整本书都在讨论重构技术,我们在这里只能对这个主题略知一二。如果你想知道存在哪种重构,网站 https://sourcemaking.com/refactoring 是一个很好的起点。

Hint

当大规模重构代码时,拥有一个好的测试套件是必不可少的。重构的目标总是让程序做和以前一样的事情。很容易(也很诱人)拆开一个程序,重新组装它,然后遗漏一个细节,导致程序后来工作起来不一样。

在基本的清理之后,我们将集中精力进一步改进代码的结构。通常,结构化意味着创建清晰分离的功能、类、模块和其他代码单元。

提取函数

可能最重要的重构是将代码分成精心选择的功能。用 Python 写函数有不同的原因。这里我们主要是将一段较长的代码分成较小的块。为了从现有代码中提取一个函数,我们需要编写一个函数定义,并为输出定义输入参数和返回语句。我们缩进中间的代码,添加一个函数调用和一个 docstring。例如,我们可以从代码中提取一个函数来创建Rect对象:

def get_rectangle(row):
    """Returns a pygame.Rect for a given tile"""

    x = int(row[1])
    y = int(row[2])
    return Rect(x*SIZE, y*SIZE, SIZE, SIZE)

起初,为仅仅三行代码创建一个单独的函数似乎有些多余。你可能会反对get_rectangle里面的代码太简单。但这正是重点!我们想要简单的代码。首先,当软件熵出现时,简单代码在更长时间内保持干净;例如,如果我们的函数需要覆盖一两个特殊的情况(并且增长),代码仍然是可读的。第二,简单的代码是其他人(同事、实习生、主管或我们的继任者)可以理解的。第三,简单代码在压力下更可靠:当程序员被最后期限、紧张的经理和天黑后的调试会议困扰时,简单代码是他们最好的朋友。我们从包含大部分剩余代码的第二个函数load_tile_positions中调用get_rectangle函数:

def load_tile_positions(filename):
    """Returns a dictionary of positions {name: (x, y), ..} parsed from the file"""

    tile_positions = {}
    with open(filename) as f:
        for row in csv.reader(f, delimiter='\t'):
            name = row[0]
            if not name.startswith('REMARK'):
                rect = get_rectangle(row)
                tile_positions[name] = rect
return tile_positions

当你想把自己的程序拆分成函数时,你首先需要确定一段连贯的代码,然后把它移到一个函数中。Python 程序中经常出现一些典型的函数:

  • 读取输入,如数据文件或网页
  • 解析数据(即,为分析准备数据)
  • 生成输出,例如写入数据文件、打印结果或可视化数据
  • 任何种类的计算
  • 辅助函数,从一个较大的函数中提取代码,使其变小

重构程序时,合理的函数大小是 5-20 行。如果函数将从多个地方被调用,它甚至可能更短。像模块一样,函数应该在函数定义后加上三重引号。文档字符串应该用人类语言描述函数在做什么(如果可能的话,避免使用 Python 术语)。

创建一个简单的命令行界面

把我们的代码划分成函数之后,是时候为程序创建一个顶层接口了。这个接口避免了代码被意外执行(例如,被一个import)。为了创建接口,我们将所有剩余的函数调用分组在程序的末尾,并将它们包装在一个单独的代码块中。按照惯例,它以一个奇怪的if语句开始:

if __name__ == '__main__':
    tile_positions = load_tile_positions(TILE_POSITION_FILE)
    print(tile_positions)

if表达式是 Python 的一个习惯用法,乍一看很奇怪(尤其是如果你见过其他编程语言的话)。用人类语言来表达就是:“如果这个文件是作为主 Python 程序启动的,那么执行下面的代码块。如果此文件是作为模块导入的,请不要执行任何操作。__main__块帮助我们避免代码的意外执行。我们现在可以从其他地方导入模块:

from load_tiles import load_tile_positions
tiles = load_tile_positions(my_filename)

在这种情况下,不会打印任何内容,因为导入时不会执行__main__块。__main__块的第二个用途是我们可以将load_tiles.py作为 Python 程序运行:

python3 load_tiles.py

现在我们看到了由print语句产生的输出,并且可以检查它是否符合我们的期望。在我们的程序中有一个__main__块作为一般的入口点。如果我们的模块不打算直接执行,我们可以使用__main__块进行简单的测试代码(本书第一部分的代码包含一些例子)。如果我们正在编写一个用作命令行工具的程序,使用argparse模块而不是sys.argv是一个最佳实践。在 Python 项目中,bin/目录是命令行前端的好地方。

将程序组织成模块

我们已经在前几章中创建了独立的模块。在本章中,我们学习了一个模块。因此,我们将简单地列出一些在开发您自己的模块时要记住的最佳实践:

  • 模块不应该变得太大。100-400 线的模块尺寸较好;多达 1000 行的模块是可以忍受的,但是我建议尽快将它们拆分。
  • 每个模块都应该有明确的目的。例如,加载数据、写入数据和进行计算都是独立的目的,因此有理由拥有自己的模块。此外,如果您的常量部分变得很大,可能值得将其放在一个单独的模块中。
  • 创建一个模块就像将一段代码移动到一个新文件中并在原始文件中添加import语句一样简单。
  • 不惜一切代价避免循环导入。每当你遇到像 A 需要 B,但是 B 需要 A 这样的关系,就值得考虑一个更好的结构。总是可以避免循环导入。你可以通过把 A 和 B 放在一起来避免这个问题,但是很可能这会在以后引起问题。
  • 在导入自己的模块时,写显式导入(避免import *)。
  • 在每个模块的顶部添加一个三重引用的 docstring。

将程序分解成独立的模块是构建程序最简单的方法之一。

清理后的代码

当我们完成这些清理步骤时,是时候验证程序是否仍然工作了。完全清理和重构的读取瓦片的程序是

"""

Load tile coordinates from a text file

"""

import csv
import os
from pygame import Rect

CONFIG_PATH = os.path.dirname(__file__)
TILE_POSITION_FILE = os.path.join(CONFIG_PATH, 'tiles.txt')
SIZE = 32

def get_rectangle(row):
    """Returns a pygame.Rect for a given tile"""

    x = int(row[1])
    y = int(row[2])
    rect = Rect(x*SIZE, y*SIZE, SIZE, SIZE)
    return rect

def load_tile_positions(filename):
    """Returns a dictionary of positions {name: (x, y), } from a text file"""

    tile_positions = {}
    with open(filename) as f:
        for row in csv.reader(f, delimiter='\t'):
            name = row[0]
            if not name.startswith('REMARK'):
                rect = get_rectangle(row)

                tile_positions[name] = rect
    return tile_positions

if __name__ == ' __main__':
    tile_positions = load_tile_positions(TILE_POSITION_FILE)
    print(tile_positions)

我们意识到这个程序并没有比我们第一次实现时变得更短。它甚至有点长。但是我们的实现有几个优点值得指出:

  • 很容易看出程序做了什么。
  • 代码的许多部分比以前更容易阅读。
  • 该模块可以被导入并用于定制用途(例如,加载不同的文件或多个文件)。
  • 我们可以独立使用这两个功能。这对于编写自动化测试非常有价值(在本书的第三部分)。
  • 调试程序时,一次最多读取 10 行就足够了。
  • 该程序以__main__模块的形式内置了自测功能。

综上所述,这个程序更加简洁,可读性更强。缺陷将很难隐藏在这个程序中。此外,大多数有经验的程序员会认为这段代码写得很好或很有 pythonic 风格。

PEP8 和 pylint

Python 有一个标准的编码风格指南,称为 pep 8(https://www.python.org/dev/peps/pep-0008).pep 8 标准对变量名、导入、文档字符串、函数长度、缩进等给出了明确的指导方针。遵守 PEP8 是最佳实践,因为它使我们的代码对其他人来说是可读的。这也有助于我们以一致的风格写作。幸运的是,我们不需要背诵完整的 PEP8 指南。pylint 工具帮助我们检查我们的代码是否符合 PEP8 标准。例如,我们将在使用 pylint 进行清理之前和之后检查我们的代码。首先,我们需要安装这个工具

pip install pylint

然后,我们可以用以下方法分析任何 Python 文件

pylint load_tiles.py

该程序生成几页控制台输出。对我们来说,有两个部分很有趣:警告消息和代码分数。

警告消息

在 pylint 输出的顶部,我们发现了一个包含警告消息的部分,该警告消息引用了 PEP8 违例。每个警告都包含该警告引用的行号。对于没有经验的 Python 开发人员编写的代码,我们得到

C: 1, 0: Missing module docstring (missing-docstring)
C: 7, 0: Invalid constant name "tilefile" (invalid-name)

而清理后的代码会导致

C: 18, 4: Invalid variable name "x" (invalid-name)
C: 19, 4: Invalid variable name "y" (invalid-name)
W: 25, 4: Redefining name 'tile_positions' from outer scope (line 36) (redefined-outer-name)
C: 26,27: Invalid variable name "f" (invalid-name)
C: 36, 4: Invalid constant name "tile_positions" (invalid-name)

所有这些警告都向我们指出了可以改进的地方。不鼓励使用包含一个字符的变量名,也不鼓励在函数内部和外部使用同名的变量。我们可以开始重命名我们的变量(使它们更长)和常量(大写字符)。然而,我们将暂时克制自己,滚动到输出的底部。

代码分数

在 pylint 输出的最后,我们发现我们的代码得分高达 10 分:

Global evaluation
-----------------
Your code has been rated at 7.73/10

与 pylint 一起工作有时非常有益。当我们开始修复 PEP8 问题时,我们可以重新运行 pylint 并看到我们的分数提高。这使得 PEP8 标准有点靠不住。您可能已经注意到,在清理我们的代码之后,我们比之前混乱的代码中有更多的 PEP8 警告。这告诉我们,pylint 产生的警告和分数并不能很好地代表代码中的较大变化。过分关注风格一致性会分散对更重要问题的注意力。最佳实践是使用 pylint 来符合 PEP8 风格指南,但不要试图将每个 Python 文件都推到 10.0 的 pylint 分数。通常 7.0 左右的分数就已经足够好了。忽略你不同意的警告信息是可以的。用你的理智。根据 Python 核心开发人员 Raymond Hettinger 的说法,“PEP8 是一个指南,而不是法律手册。”把 PEP8 想象成我们建筑上的一层油漆(见图 14-3 )。它改善了我们代码的外观,但是它不支持屋顶。

A419627_1_En_14_Fig3_HTML.jpg

图 14-3。

Adhering to the PEP8 coding standard is like a good layer of paint: it looks beautiful and protects your code from bad weather.

让它工作,让它正确,让它快

当编写适合一个屏幕页面的小程序时,如何准确地编写代码并不是什么大问题。我们最关心的是让程序运行起来。但是随着尺寸越来越大,可读性的缺乏会落到我们的脚上。我们需要组织我们的代码,或者使它正确。在我们的清理过程中,我们遵循了斯蒂芬·c·约翰逊和布赖恩·w·柯尼根制定的指导方针:“首先让它工作,然后让它正确,最后,让它快。”这一方针被归功于不同的人,包括肯特·贝克(也见 http://c2.com/cgi/wiki?MakeItWorkMakeItRightMakeItFast )。它当然适用于 100 行以上的 Python 程序。让我们仔细看看指南的三个部分。

使其工作

在这里,工作意味着程序无异常地完成,并且没有我们所知道的语义错误。在第一部分中,我们已经学习了许多使程序工作的调试技术。在第二部分中,我们使用自动化测试来更彻底地检测缺陷。

做正确的事

使它正确通常意味着组织你的代码。在这一章中,我们已经看到了使程序可读性更好、结构更好以及使执行逻辑透明的清理步骤。然而,这些清理策略仅仅是开始。随着我们程序的进一步发展,保持代码组织良好变得更加重要。除了组织功能和模块,设计类和它们之间的相互作用,构建 Python 包,开发一个包含系统所有组件的架构,这些都是可以期望找到大量重构的主题。不过,这些话题超出了本书的范围。

动作快点

当你的程序工作正常,结构良好,可读性强时,它的性能就值得一看了。通常在这个阶段,程序已经足够快了。否则,组织良好的代码至少更容易调优以获得更高的性能。有许多方法可以加速 Python 程序,从增加计算能力和编译 Python 到更快的编程语言,以及消除 Python 代码本身的瓶颈。性能优化不是本书的主题,但是在第十一章中你可以找到一个编写性能测试的例子。

组织良好的代码示例

从不到 100 行到超过 100 行的 Python 代码的转变很有趣。当一个程序超过 100 行时,有很多种可能性可以写出同样的程序。哪个是正确的?为了给你一些试探性的答案,我们来看看这个星球上一些最好的 Python 程序员写的程序的结构。表 14-2 中总结了知名程序员的 7 个 Python 项目的结构。我选择了较小的日常项目或宠物项目来进行比较,而不是他们的(大部分是巨大的)主要项目。这些项目是

表 14-2。

Metrics for Seven Python Projects of Between 100–1000 Lines. The packages, modules, functions, and classes were counted with Unix command-line tools. The comments, blank, and code lines were counted with the cloc tool.

| 项目 | 包装 | 模块 | 功能 | 班级 | 空白行 | 评论 | 代码行 | | --- | --- | --- | --- | --- | --- | --- | --- | | 衬衫 | Zero | Two | six | Zero | Fifty-nine | Fifty-five | Two hundred and twenty-seven | | 皮普西 | Zero | five | Forty | Two | One hundred and twenty-three | Twenty-two | Four hundred and eighty-six | | 爬行者 | Zero | three | Thirty | five | Ninety-one | Ninety | Five hundred and thirty-one | | 搬出去 | three | Twenty-three | Thirty-five | Twenty-five | One hundred and seventy | Thirty-four | Five hundred and ninety-nine | | python progress bar(python 进度列) | one | six | Sixty-one | Seventeen | Two hundred and twenty-three | Two hundred and thirty-one | Five hundred and sixty-seven | | 吉萨!吉萨 | one | Fourteen | Fifty-seven | five | Two hundred and thirty | Two hundred and forty-two | Six hundred and fourteen |

当比较表 14-2 中的项目时,我们看到所有项目都包含 10%–25%的空行和高达 25%的带注释的行。我们还看到代码的结构有很大的不同。shirts 项目本质上是一个用于数据分析的经过清理和注释的线性脚本,而 pipsi 和 python-progressbar 被分解成 40 多个更小的代码单元,可用于不同的目的。大多数作者使用类,但不是所有作者都使用类(例如,gizeh 更强调函数,而 move-out 使用从 Django 框架派生的类)。我们的结论是,即使在杰出的程序员中,显然也有不止一种正确的方法。

最佳实践

  • 有无限的可能性来实现相同的功能。
  • 软件熵是代码随着时间的推移变得无组织的现象。
  • 无组织的代码可读性差、结构化程度低、冗余或包含其他设计缺陷。
  • 清理代码是一项日常编程任务。
  • import语句放在 Python 模块的开头。
  • 将常数放在一起,它们的名字写在UPPER_CASE中。
  • 不必要的线需要严格去除。
  • 变量应该有有意义的名字。
  • 将程序重构为小而简单的函数会使它更容易理解。
  • 大型程序应该被分割成多达 400 行的模块。
  • 导入时不执行__main__块。
  • pylint 是一个检查是否符合 PEP8 编码标准的工具。
  • 遵守编程的中心原则:让它工作,让它漂亮,让它快。

十五、分解编程任务

程序员,就像诗人一样,只是稍微偏离了纯粹的思想。——弗雷德·布鲁克斯——神话中的人月

编程的一个关键部分是将一个问题分成更小的部分。这种问题分解是每个程序员的必备技能。不管你是想从头写一个程序,还是想给一个已存在的问题增加功能,你都需要先分解编程问题。问题分解,也称为需求分析,是一种被没有经验和有经验的程序员低估的技能。前者低估了它,因为他们正忙于掌握编程语言本身,后者因为他们认为这是微不足道的,他们已经弄清楚了(尽管他们往往是正确的)。因此,许多编程项目因为缺乏功能或者功能分解不良而遇到问题就不足为奇了。结果,产品是脆弱的,并且深受软件熵之苦,它们花费的时间比预期的要长,或者它们完全失败。个人和大型开发团队、使用传统软件工程方法的团队以及动态、敏捷的团队都报告过这样的问题。本章致力于分解编程任务,帮助你编写不容易犯这个错误的软件。

分解编程任务是困难的

但是为什么分解编程问题很难呢?让我们考虑一个典型的编程任务:我们想给我们的游戏添加一个幽灵。幽灵将随机穿过迷宫,试图吃掉玩家。怎么才能分解这个特征呢?直观地说,把一个问题分成更小的块,可以分层次地考虑(见图 15-1 )。我们首先把它分成两个更小的子问题。然后我们将每个子问题分解成更小的问题。最后,我们实现所有较小的部分,我们就完成了。

A419627_1_En_15_Fig1_HTML.jpg

图 15-1。

Hierarchical decomposition of a programming problem. First, the problem is divided into two separate subproblems, which are divided further. Unfortunately, this approach rarely works in practice.

这是一个不错的模型。不幸的是,除了最简单的程序之外,它并不适用于所有程序。原因是大多数程序中较小的组件是高度相互依赖的。大多数编程问题都是多维的。我们需要记住多个问题维度:

  • 编程任务本身——我们试图编程的到底是什么?
  • 数据——我们需要在程序中存储什么信息,它是如何构成的?
  • 控制流——程序执行任务的顺序是什么?
  • 技术——软件由哪些组件(python 式和非 python 式)组成?
  • 成本—哪些功能价格合理,哪些不合理?

所有这些维度(以及这里没有提到的其他维度)都相互依赖。多年来,已经写了很多关于分解的书。他们涉足非常多样的领域,如需求分析、通用建模语言(UML)和 Scrum。所有这些都是非常强大的技术。在一个(主要是公司)环境中,这些方法之一被应用到大型项目中,它们当然值得学习。但是对于普通的 Python 程序员来说,它们并没有太大的帮助。

Python 中的分解问题有什么特别之处吗?有:Python 的一个关键优势是开发速度快。不花任何时间在计划上并马上开始编程是非常诱人的。一个好的分解方法也需要很快,这样我们才不会危及 Python 带给我们的优势。这就是为什么本章描述了一个在几分钟或几小时内适用的轻量级分解过程。我们将详细分解重影特征。最后,我们将在本章开始实现它。

分解编程任务的过程

分解编程任务是困难的,因为我们必须在多个维度(功能、数据、控制流、技术、成本)做出设计决策。为了避免过早的设计决策,我们需要或多或少地并行处理这些维度。这是什么意思?我们对程序特性的最初看法就像一朵云(见图 15-2 )。我们对程序应该做什么有一个粗略的想法,但是功能太不精确而无法实现。在设计过程中,组件开始从云中出现并成形。我们不是在开始下一个维度之前完全固定一个维度,而是将每个维度与其他维度并行平衡,从而解决大的设计问题。最后,组件获得了足够的清晰度,实现是可行的。如果从一个特性到它的实现的路径是清晰的,我们可以说这个特性是完全分解的。

A419627_1_En_15_Fig2_HTML.jpg

图 15-2。

A program feature is like a blurry cloud in the beginning. Only by decomposing it will details of the functionality and program components become apparent. When we finally arrive at sharply defined, implementable components, the decomposition is complete.

在下文中,我们将使用七个步骤将重影特征分解成更小的部分:

  1. 写一个用户故事,对编程任务的简单描述
  2. 向描述中添加细节
  3. 检查非功能需求
  4. 识别可能的问题
  5. 决定解决主要问题的架构
  6. 识别程序组件
  7. 实施

这个过程反映了许多程序员在实现一个简单的功能时的直觉。系统地完成这七个步骤,我们也可以将同样的过程应用到更困难的任务中。在这个过程中,我们有意识地做出设计决策,在云中寻找可以用 Python 实现的组件。这是我们接下来要做的。

我们可以用这个过程来扩展现有的程序吗?没错。很少有软件是从零开始开发的。分解的过程在从零开始或扩展现有程序之间没有根本的区别。当然,当添加到现有软件中时,我们需要考虑我们已经拥有的(在第 3 步及以后),因为我们不应该在每次添加功能时颠倒我们的设计。

写一个用户故事

第一步是写下我们想要编程的内容。此描述必须足够短,以便能写在一张小纸上(A6 纸卡)。描述还必须足够简单,非程序员也能明白其中的价值。这样的描述也被称为用户故事。对于鬼魂,相应的用户故事可能是(也见图 15-3 ):作为一个玩家,我想要一个鬼魂,这样我就可以通过迷宫逃离它。这个用户故事的句子结构是标准的最佳实践。为了更清晰,许多开发人员已经同意使用该结构“作为(一种类型的用户),我想要(一些目标),以便(一些原因)。”这种用户故事的结构不仅记录了要实现什么样的程序特性,还记录了为什么要引入这个特性。又见 https://www.mountaingoatsoftware.com/agile/user-stories .我们用户故事的价值当然是有鬼的游戏比没鬼的有趣。尽管写下这个特性是一件简单的事情,但它在许多方面改进了程序员的工作:

  • 任务变得切实可行。无论使用什么项目管理方法,用户故事都很容易管理。我们可以对它们进行优先排序,估计或跟踪完成的时间,当然,也可以检查它们是否完成。在编写用户故事时,一个常见的最佳实践是将它们放在一个显眼的地方。令人惊讶的是,纸质卡片或纸板往往比电子系统更好。
  • 描述有助于我们集中注意力。在描述中包含程序用户将会看到或做的事情有助于我们开发一个比我们写“用 Python 实现一个 ghost 模块”更有用的程序
  • 这个描述忽略了技术细节。此时,决定如何实现该功能还为时过早。
  • 最重要的是,用户故事表明我们对功能的理解可能是不完整的。我们将需要进一步思考或沟通,以澄清我们想要实施的细节。因此,用户故事也被称为“交流的承诺”

A419627_1_En_15_Fig3_HTML.jpg

图 15-3。

User Story for adding a ghost to the game. A User Story is a short description of a program feature with an emphasis on the value the feature has for users.

每当你开始开发一个新的程序时,写下所有计划功能的用户故事是组织起来的第一步。

向描述中添加细节

从前面的简单描述中,我们已经可以看出,ghost 将大大改变我们的程序。以前,我们只需要对用户按下的键做出反应。现在,幽灵和玩家将同时移动:存在并发性。对于一个程序员来说,这是一个非常危险的时刻。当遇到“并发”这样的概念时,有趣的事情就发生了。经验不足但聪明的程序员举手说“等等!我不知道该怎么做。”更有经验、热情的程序员倾向于立即提出他们喜欢的解决方案(例如,“Python 线程是理想的解决方案”或“不惜一切代价避免使用 Python 线程”。GIL 会把你逼疯的!”).有经验的程序员之间的讨论可能会让其他人感到有点害怕。此外,现在决定技术解决方案可能还为时过早。无论你看到许多可能的解决方案还是根本没有,收集更多的信息都是值得的。

验收准则

处理新情况的一个建设性方法是首先收集问题的细节。很多时候,简单地记下要点列表是我们能做的最好的事情。ghost 功能的要点如下所示:

  • 玩家和幽灵必须平行移动。
  • 幽灵以规则的间隔移动(每平方不到一秒)。
  • 鬼魂朝一个方向直线移动,直到碰到一堵墙。然后它随机改变方向。
  • 鬼魂不吃小点。
  • 当幽灵和玩家在同一方格时,游戏终止。

根据项目方法的不同,这种特性的细节有时被称为验收标准或要求。在一个敏捷项目中,验收标准适用于另一张 A6 卡。在大型的传统软件项目中,需求有时会增长到数百或数千页的文档。后者绝对不是我们希望在一般的 Python 项目中发生的事情。对我们来说,将需求保存在一个小的文本文件中或者用户故事卡的背面就足够了。如果一个简单的要点列表还不够,我们可以添加文字描述和图表来更详细地描述这个特性。如果这样的信息已经存在(带有说明、文章、例子、会议记录的电子邮件),最好将这些信息放在靠近其他描述的地方。

用例描述

除非我们正在编写一个巨大的软件,否则我不会从编写一个关于我们将要实现的特性的冗长的文本描述开始:它们可能无论如何都会改变。比写文本更快的实用计划技术是写下一系列事件或用例描述。用例描述是以一定顺序发生的事件的列表。例如,我们可以像图 15-4 那样描述幽灵的运动。用例描述已经比用户故事更类似于 Python 代码。但是仍然,里面没有技术术语;我们仍然不需要决定幽灵的移动是由单个函数处理(也许),由十个不同的类处理(也许),还是用 COBOL 语言实现会更好(非常不可能)。

A419627_1_En_15_Fig4_HTML.jpg

图 15-4。

Use Case for moving the ghost. The movement feature has been broken down into a chronological order of steps that make it easier to reason about it. This Use Case description is not exhaustively accurate, nor does it suggest how to implement the feature. The description simply helps us to gradually shift to higher precision while postponing technical decisions.

图 15-4 中的用例包含一个玩家被吃掉时的分支(在 3a 中)。一个用例可以,但不是必须,包含分支。如果有许多不同的情况,通常挑选几个有代表性的就足够了。用例不一定要完整。它仍然需要适合幻灯片或一张纸。也不要担心是否有不止一个“正确”的序列。我们将在接下来的步骤中找到答案。用例描述是一种帮助分解困难用例的工具。大多数情况下,一系列要点将为实施添加足够的细节。

检查非功能需求

在前两步中,我们已经足够详细地描述了我们想要实现的功能。在这一步,我们将关注开发中更微妙的部分:非功能性需求。什么是非功能需求?简而言之,非功能需求是对项目的所有约束,与我们正在实现的功能没有直接关系。例如,这些包括技术、开发方法、平台、性能和其他技术参数,以及道德和法律问题。安全和保障也是非功能需求的一部分,但是正如第一章所述,我们不会在本书中讨论它们。MazeRun 游戏的一些非功能性需求如下:

  • 游戏因为书名(技术)需要用 Python 写。
  • 游戏需要有自动化测试(开发方法)。
  • 游戏需要可以安装在 Linux、Windows 和 Mac 上,以便许多开发者可以对它进行实验(平台)。
  • 源代码的长度需要少于 1000 行,以适合一本书(技术参数)。
  • 有 0 到 10 个幽灵以 0.1-1.0 秒的间隔移动(技术参数)。
  • 游戏需要适合六岁儿童(伦理)。
  • 该游戏需要在开源许可(合法)下在 GitHub 上发布。

为什么非功能需求很重要?想象一下,我们增加了一个非功能性的要求,除了前面的要求,MazeRun 应该可以在 Android 和 iOS 手机上运行,并且能够处理 10,000 个同时追逐玩家的幽灵(以确保即使是最活跃的手机用户也不会感到无聊)。结果将是一个完全不同的程序,我们将不得不使用不同于 Python 和 SDL 的技术来运行它。对非功能性需求的错误理解有可能使程序完全无用,因此跳过这一步是不可选择的。

在更深的层次上,非功能需求帮助我们确定我们正在编写什么样的程序。MazeRun 的第一个工作版本(没有第七章中的幽灵)可以开发成许多不同的游戏:一个具有高效人工智能的类似象棋的策略游戏(不在本书中),一个有许多花哨图形的快节奏射击游戏(也不在本书中),或者一个为了推理 Python 最佳实践而构建的类似小精灵的游戏(准确地说!).用几个技术参数或其他非功能性需求来表达明确的方向,会使以后的技术决策变得容易得多。

就像功能性描述一样,写下非功能性需求是个好主意。幸运的是,非功能性需求在项目中通常不会改变(但是,如果它们改变了,您需要非常小心)。

识别问题

有了功能性和非功能性的描述,我们就有希望清楚地了解 ghost 特性是什么,以及它需要适应什么样的边界条件。这是寻找潜在问题的好时机。还有什么看起来很难的呢?有没有与非功能需求相冲突的用户故事?这些描述中有互相矛盾的吗?有什么事情是听起来不可能的吗?在这一节中,我们休息一下,思考一下我们需要处理的问题。

Tip

休息一下是字面意思。在花了一些时间(10 分钟到几个小时之间)编写用户故事、用例描述和非功能需求之后,现在是后退一步,从一个新的角度来看待任务的好时机。新鲜空气确实有帮助!

对于 ghost 特性,有一个主要的挑战:并发性。玩家和幽灵必须同时移动。目前,我们还没有一个如何实施的计划。这里我们将详细分析并发问题,看看如何从不同的角度处理问题。一般来说,我们可以预料到至少来自四个不同方向的麻烦:不完整的信息、领域专业知识、更改现有代码和预测未来的变化。

不完全信息

当你试图自己应用这个过程时,你很可能会意识到你没有足够的信息来继续。对你的特征的描述包含了一些假设,或者仅仅是缺乏你的主管/客户真正需要的细节。这是完全正常的。面对不完全信息时,你有两种选择。要么创建一个(快速的)原型实现,看看它是否符合预期,要么尝试先获取更多信息。当询问更多信息时,你的主管/客户可能也不知道,因为他们在你分解之前从来没有想过这个问题。或者他们想出了一些一开始听起来不错的好主意,但一旦付诸实施,却发现毫无用处。因此,应对信息缺乏的一个好策略是循序渐进。

对于 ghost 特性,我们没有关于如何处理并发性的信息。另一方面,我们对这个特性有很多自由,因为它的主要目的是提供学习体验。任何工作鬼都可以,所以信息不足在这里不是问题。

领域专业知识

相反的问题是拥有过多的信息,知道我们正在解决的问题充满了例外和特殊情况。一个常见的症状是,我们反复推理某个特征的非常特殊的方面,比如“是的,但在 Anhk-Morpork 的税法中,如果雇员是一个幽灵,税率是 13%而不是 21%,因为它是一个非物质实体。”如果你不熟悉问题领域(在这种情况下是 Ankh-Morporkian 税法),这种特定领域的细节很快就会让人不知所措。这类问题的解决方法是先简化。找到一个简洁地代表问题域的模型,但是不要过于简化。在问题域的一个良好构建的模型中,在实现过程中可以包含特殊情况。关于问题领域的背景知识是关键。取决于你是否是领域专家,这种问题可以在一杯咖啡或去图书馆做大量背景研究时解决。幸运的是,我们每个人以前都玩过或至少看过电脑游戏。我们对游戏有足够的了解,可以理解前面提到的 MazeRun 中的并发问题。我们的理解有助于我们列举一些需要注意的情况:

  • 如果玩家在幽灵上移动会发生什么?这一点在前面的用例描述中没有明确涵盖!
  • 玩家对幽灵移动还是幽灵对玩家移动有区别吗?
  • 玩家和鬼魂理论上可以同时移动从而互换位置吗?
  • 移动的幽灵会让玩家移动的更慢吗?

收集这些问题将有助于我们评估我们的解决方案。

更改现有代码

向程序中添加新的特性可能很有挑战性,因为它们需要适应已经存在的代码。通常,需要重新组织现有代码,以便为新功能腾出空间。问题是如何做到这一点,同时又不造成一片混乱。简而言之,你需要记住在第十四章中介绍的软件熵的概念。考虑到并发性问题,当考虑 MazeRun 的现有代码时,我们需要更改哪些部分?当然,事件循环必须改变。到目前为止,第四章的实现什么都不做,除非玩家按下一个键。我们将不得不仔细看看这些代码。

预见未来的变化

软件熵的另一个方面是我们知道我们的程序将来会改变。我们现在做出的设计决策将会产生长期的影响。因此,我们不仅需要考虑程序的当前需求,还要预测程序在未来可能如何发展。为了创建一个稳定的设计,我们需要知道程序的哪些部分最不可能改变,哪些部分肯定会改变。这些部分需要分开。在所有可能的问题中,这是最困难的一个。要解决这个问题,领域专业知识、经验和运气的结合是必要的。回想过去几十年写作和玩电脑游戏的经历,有些事情很可能会改变

  • 额外的游戏元素(幽灵的种类,特殊的地砖)
  • 参数(重影速度、屏幕尺寸)
  • 图形和动画

每个玩游戏的人都立即意识到的一个方面是移动玩家时缺少动画。玩家人物只是从一个方块跳到下一个方块。让动作流畅确实是一个不错的改进。乍一看,这似乎是一个无害的功能,我们可以用几行代码实现为一个定格动画:

import time

for offset in range(32):
    draw_map_without_player()
    draw_player(100 + offset, 100)  # starting point (x=100, y=100)
    pygame.display.update()
    time.sleep(0.05) # seconds

但是等等!如果我们加上幽灵,不知何故,幽灵和玩家的流畅动作需要协调。又是并发问题。不用进一步考虑这个问题,我们可以预计并发问题在未来会变得更加重要。总而言之,我们在本节中收集了三个问题,都与并发性有关:

  1. 目前,我们还没有解决如何处理平行移动的球员和幽灵。
  2. 为了允许同时运动,我们需要改变事件循环。
  3. 在未来,将会有更多的事情同时发生。

这样一个可能的问题列表可能会很长。我们需要做的是对它们进行优先排序,然后专注于最糟糕的问题。在我们的例子中,并发性是最大的挑战。在本章的剩余部分,我们将重点解决这个问题。

决定架构

我们认为并发是最困难的问题。为了给游戏加个鬼,任何实现都需要妥善解决这个问题。现在,如果这听起来已经足够简单,我们可以跳过接下来的两节,开始编写代码。但是假设我们是第一次做这种事情,最好先考虑一下我们程序的架构。老实说,给电脑游戏添加一个幽灵,建筑这个词有点浮夸。术语软件架构也用于描述由数百台连接的服务器组成的事物。但是我更喜欢架构,而不是更温和的术语软件设计,因为设计经常与事物的外观不正确地联系在一起。我们真正感兴趣的是找到一个能帮助我们解决问题的程序结构。让我们看几个潜在的架构。

在图 15-5 中,我们发现了软件中使用的六种常见架构模式。其中四个是有用的。管道描述了一系列相互依赖的步骤。我们在执行计算、数据处理的程序中和 Unix 命令行上的工具中找到了管道结构。层模型有助于组织以两种方式相互通信的组件。它是构建 web 服务器的经典模型。反馈回路是各种调节过程的良好结构,例如,可以在监控工具和感觉装置中找到。最后,中介模型组织不直接相互对话的组件之间的通信。例如,一个绘图程序被构造成一个中介;画布是所有不同绘图工具交互的中心媒介。

A419627_1_En_15_Fig5_HTML.jpg

图 15-5。

Six frequent architectural pattern s in software. The four on the left are useful patterns; the two on the right are antipatterns that should be avoided.

该图还包含两个反模式,应该避免的结构。第一个是 Blob,它通过将所有内容放在一个组件中来避免分解。结果是一个巨大的混乱的非结构。第二个是意大利面条模型,它有许多组件,但是所有组件都可以自由地相互交流。结果又是一个混乱的非结构。

图中还没有包括其他基本的架构模式。没有太多,因为有有限数量的不同拓扑,既不是斑点也不是意大利面条。我们不能混合两种架构,因为结果将会是一个类似意大利面条的结构。但是,体系结构可以嵌套,也就是说,可以相互包含。例如,分层架构的第二层可以在内部包含一个管道。

我们如何使用这些信息来解决并发问题呢?我们需要让玩家和幽灵都可以并行移动,并负责中间的一些后台任务(例如,绘制迷宫)。让我们考虑图 15-6 中的两种可选结构。

  1. 一个简单的反馈回路。我们在一个循环中处理所有事情。首先我们移动玩家,接下来我们移动幽灵,最后我们绘制图形。
  2. 我们引入了一个中介,这个组件的唯一职责是决定该轮到谁做什么。播放器、ghost 和绘图部分通过相同的协议与中介进行通信。

A419627_1_En_15_Fig6_HTML.jpg

图 15-6。

Two alternatives for taking care of concurrency : a feedback loop architecture (left) and a mediator-based architecture (right)

可能有更多的可能性来解决这个问题,但是我们将坚持这两个。在我们的案例中,这两种架构中哪一种是最好的?反馈循环可能是更容易实现的。我们可以很快地从头开始写,而且会成功。不过,循环结构有几个明显的缺点。首先,它不容易扩展:每次我们添加一个新的游戏元素,我们都必须将它添加到循环中。第二,循环中最慢的一步会减慢所有其他步骤。在游戏中,增加一些元素很容易导致明显的延迟。我们可以通过最终跳过事件循环中的一个或多个步骤来避免这种情况,但这样架构就不再那么简单了。

另一方面,中介结构易于扩展。只要组件使用相同的协议与中介通信,插入多少个元素并不重要。中介只需要有某种队列或其他规则集来决定轮到哪个组件。后一种属性正是我们所需要的。我们知道玩家和鬼魂的移动会遵循非常不同的节奏。中介架构能够适应这种差异。这就是为什么我们将继续使用中介架构来实现。

识别程序组件

我们已经决定使用一个中介架构来实现 ghost。但到目前为止,我们还没有决定介体和它们周围的组件到底是什么。我们仍然需要决定是否使用函数,类,模块,或者其他的东西作为图的一部分。为了先解决高阶问题,我们推迟了这个(显然很重要的)决定。在这一节,最后的实施之前,是时候做出这样的决定了。一般来说,识别组件意味着跨越开头提到的编程问题的维度(功能、数据、控制流、技术)来划分界限。我们现在需要看到一个清晰的结构从最初的云中浮现出来,并且易于实现。

在 Python 中,决定是否在给定的地方使用函数、类或模块本身并不是一个非常困难的决定。因为所有这些都是 Python 对象,所以通常可以很容易地将一个对象换成另一个对象。例如,我们可以从实现一个函数开始,但是如果程序增长了,就用一个类来代替它。在其他语言中,这样的决定有点困难。这一步的目的是使上一步的架构变得生动,而不是创建一个函数、类和模块的详尽列表。我们将需要在实施过程中自由添加或更改一些细节。有一个我们的程序将包含的组件的概要就足够了。

我们的调解人需要哪些组件?同样,我们面临一个设计决策:我们将使用什么作为中介,以及它将如何与其他组件通信?同样,我们将考虑两个选项。首先,我们可以使用多线程。我们并行运行三个子进程,一个用于玩家,一个用于幽灵,一个用于绘图。三者共享相同的数据。在这种情况下,中介是 Python 内置的线程引擎。多线程是游戏中常见的一种中介模型。对于更复杂的并发模型(例如,asynciotwisted,gevent),有大量关于在 Python 和库中实现线程的文档。多线程模型很容易扩展。我们可以简单地为更多的幽灵添加更多的线程。另一方面,众所周知,线程很难调试,不仅仅是在 Python 中。此外,Python 中线程的性能优化并不容易(因为所谓的全局解释器锁或 GIL)。

第二,我们可以使用基于 Pygame 事件的交流。作为中介,我们将有一个事件循环,它收集事件并根据事件类型将它们分配给不同的功能。事件模型的好处是 Pygame 负责将事件排队,并且有预定义的事件类型。我们基本上需要使事件循环更加通用,这样我们就可以插入自定义事件(玩家和幽灵移动和绘图)。像多线程一样,调试事件队列并不容易,我也不敢对它的性能做任何假设。决定是使用线程还是事件队列比之前关于架构的决定要小得多,因为我们现在是在架构的约束下决定的。如果没有太多支持这个或那个模型的话,程序员的经验和偏好决定。就我个人而言,我更喜欢事件循环模型,主要是因为我以前做过,对我来说,线程调试起来更痛苦。但是我坚信这个游戏可以通过多种不同的方式成功实现。你可以自由尝试自己喜欢的方法。

这个问题解决后,我们可以打开文本编辑器,写一个程序大纲。首先,我们将对所有事情使用函数。为了让事件循环作为中介工作,我们将需要以下组件(见图 15-7 以获得概述):

A419627_1_En_15_Fig7_HTML.jpg

图 15-7。

A clear structure has emerged. We have decided to solve the problem of concurrency by using a mediator structure—the event loop. Other game components communicate with the mediator using pygame.event.Event objects, each of which is associated with a callback function. The decomposition is now ready for an implementation.

def move_player(event):
    """moves when keys are pressed"""
    pass

def move_ghost(event):
    """Ghost moves randomly"""
    pass

def update_graphics(event):
    """Re-draws the game window"""
    pass

def event_loop():
    """a mediator using the Pygame event queue"""
    pass

创建这样的框架结构是实现的一个很好的准备,不管你想实现小的还是大的组件。这里介绍的方法是相同的。实际上有无限多种可能的成分。为了避免迷失在细节中,表 15-1 列出了一些经常出现的错误。

表 15-1。

Some Frequently Occuring Components in Python programs

| 名字 | 目的 | Python 关键字或模块 | | --- | --- | --- | | 数据结构 | 将数据与代码分离 | 任何(正确) | | 班 | 模块化数据+代码 | `class` | | 命令行界面 | 解析命令行选项 | `argparse` | | 记录 | 将信息写入日志文件 | `logging` | | 配置 | 设置参数或从文件中读取参数 | `configparser` | | 文件输入输出 | 读取或写入数据文件 | 许多 | | 数据库接口 | 访问外部数据 | 模块,取决于数据库 | | c 扩展 | 加速计算 | 外部模块 | | HTML 模板 | 将代码与显示分开 | `jinja2 or django` |

有了这些,我们已经做了足够的练习。是时候将我们的计划工作转化为工作代码了。

实施

让我们开始实施的幽灵。中心组件将是事件循环。事件循环的主要职责是充当并发事件的中介。我们从上一节中的框架函数开始实现。事件循环将通过pygame.event.Event对象与图 15-7 中的其他功能进行通信。Pygame 有内置的整数USEREVENT用于定义自定义事件类型,这很方便。我们首先为幽灵移动定义事件,更新屏幕,并退出游戏(对于玩家移动,我们将使用已经存在的键盘事件KEYDOWN):

import pygame
from pygame.locals import USEREVENT, KEYDOWN

EXIT = USEREVENT
UPDATE_DISPLAY = USEREVENT + 1
MOVE_GHOST = USEREVENT + 2

接下来,我们为游戏中的主要事件创建虚拟程序(在接下来的两章中,我们将把它们发展成功能齐全的组件):

def move_player(event):
    """moves when keys are pressed"""
    print(’player moves’)

def move_ghost(event):
    """Ghost moves randomly"""
    print(’ghost moves’)

def update_graphics(event):
    """Re-draws the game window"""
    print(’graphics updated’)

我们希望以一种允许我们灵活地插入不同组件的方式来编写事件循环。为此,我们将事件类型与我们的函数相关联。在程序员的行话中,这样的函数通常被称为回调。Python 字典是存储事件类型和回调函数的理想结构:

callbacks = {
    KEYDOWN: move_player,
    MOVE_GHOST: move_ghost,
    UPDATE_DISPLAY: update_graphics,
}

但是所有的事件从何而来呢?对于键盘事件,这一点非常清楚:Pygame 自动生成这些事件,除非我们省略初始化显示(就像我们在第四章中所做的)。其余的事件需要显式生成。例如,我们可以将一个EXIT事件发送到 Pygame 的事件队列中

# not part of the final program
exit = pygame.event.Event(EXIT)
pygame.event.post(exit)

另一种可能性是启动一个每N毫秒产生一个事件的定时器。例如,用于每 0.3 秒移动重影的计时器可以从

pygame.time.set_timer(MOVE_GHOST, 300)

现在我们可以编写事件循环本身了。与我们之前的实现一样,它从队列中收集事件。但是它没有解释它们,而是将它们重定向到一个回调函数。唯一的例外是EXIT事件,它终止了循环:

def event_loop(callbacks, delay=10):
    """Processes events and updates callbacks."""
    running = True
    while running:
        pygame.event.pump()
        event = pygame.event.poll()
        action = callbacks.get(event.type)
        if action:
            action(event)
        pygame.time.delay(delay)
        if event.type == EXIT:
            running = False

最后,我们可以开始事件循环。我们需要设置几个定时器来定时移动幽灵和更新屏幕。我们还设置了一个定时器,在五秒钟后触发一个EXIT事件:

if __name__ == ’__main__’:
    pygame.init()
    pygame.display.set_mode((10, 10))
    pygame.time.set_timer(UPDATE_DISPLAY, 1000)
    pygame.time.set_timer(MOVE_GHOST, 300)
    pygame.time.set_timer(EXIT, 5000)
    event_loop(callbacks)

如果我们启动程序,按几个键,输出如下:

player moves
player moves
ghost moves
ghost moves
player moves
player moves
ghost moves
graphics updated
ghost moves
..

到目前为止,我们取得了什么成绩?我们创建了一个通用结构,比幽灵问题通用得多。我们可以对许多并发的游戏元素使用相同的机制。注意,事件循环并不关心我们连接到它的是什么。我们可以插入一个或两个玩家,一个到多个幽灵,或者其他我们以后可能会发明的东西。当然,我们可以插入的组件数量有一个实际上限。但是,我们还远没有到必须考虑优化我们的架构的地步。在接下来的章节中,我们将讨论如何实现 ghost 的其余部分。

七步计划过程是一个如何分解问题的例子。尽管拥有这样一个过程是一种最佳实践,但我很少完整地经历所有的步骤,有时步骤会有很大的不同。大多数时候,解决方案在配方中途指向自己(哔哔声“实现我!”).但是每当有一个有问题的编程任务要添加时,一个缓慢而系统的过程会给出最好的长期结果。关于幽灵的完整实现,参见 http://github.com/krother/maze_run .

其他规划工具

规划是编程中的一项基本技能。尽管这会分散我们编写代码的注意力,并且通过严格应用常识可以实现很多目标,但是当事情变得更加复杂时,了解一些规划工具可以节省您的时间。这不是一本关于软件项目管理的书,就不赘述了。然而,我有几个最喜欢的规划工具值得一提:

一页纸的项目计划

在没有正式计划阶段(有时甚至没有正式的项目经理)的项目中,编写一个最小计划通常足以让所有人达成一致。我一直使用的计划总结在一张 A4 纸上:

  • 这个项目是关于什么的?
  • 团队中有哪些人(以及如何联系他们)?
  • 你为什么要写这个程序?
  • 主要目标是什么?
  • 最重要的子目标是什么?
  • 有截止日期吗?
  • 有预算吗?

在一个小团队中,有这样一个小计划可以避免很多误解。

问题跟踪器

当你确定了编程任务或者把它们分解成更小的时候,你把它们放在哪里?一种可能性是使用问题跟踪器。问题跟踪器是一个管理编程任务、描述以及谁在负责它们的软件。使用它对防止遗忘有很大的帮助。流行的问题跟踪器有 JIRA、Trac 和 GitHub 上的问题系统。但是基本上任何项目管理系统都提供了跟踪问题的功能。

看板法

看板最初由丰田公司发明,是精益生产中管理库存的一种方法。这个概念被大卫·安德森应用于软件开发。看板流程限制了要同时处理的事情的数量,并将它们显示在一个清晰可见的板上。我的桌子上、浏览器上和厨房里都有看板——它们都工作得很好。看板是一种注重改进和完成工作的实用方法,可以很容易地与工作环境中的大多数现有实践相结合。

最佳实践

  • 分解编程任务类似于从云中慢慢浮现的形状,而不是一系列的突然切割。
  • 用户故事是对编程任务的简短、非技术性的描述,使其易于管理。
  • 细节可以作为项目符号、文本文档、图表和用例描述添加到用户故事中。
  • 非功能需求是描述程序使用环境的边界条件。尽早考虑它们可以大大降低程序变得难以维护或无用的风险。
  • 基于一个用户故事,用例描述和非功能需求,矛盾和其他问题可以被识别。
  • 当分解一个编程问题时,领域知识是必不可少的。
  • 问题的常见来源包括缺乏信息、信息过多、现有代码和未来的变化。
  • 选择解决主要问题的架构。
  • 确定程序组件(函数、类、模块等)后,创建一个框架程序。)来实现该架构。
  • 实现是完全分解后的最后一步。
  • 分解编程任务的七步过程应该被理解为一个指南。

十六、Python 中的静态类型

像死亡和税收这样确定无疑的事情,可以更坚定地相信。—丹尼尔·笛福,《魔鬼的政治史》

动态类型是 Python 最受称赞的特性之一。受到称赞是因为它允许快速开发。但是动态类型也有其黑暗的一面。动态类型化也意味着在程序的任何地方都不能确定接收到某种类型的对象。在这一章中,我们将研究为什么动态类型是一种风险,以及如何降低这种风险。作为一个例子,让我们考虑我们想要添加一个高分列表到我们的游戏中。一个简单的高分列表包含五个得分最高的球员的得分和他们的名字(见表 16-1 )。

表 16-1。

High Score List with Five Top-Scoring Players

| 运动员 | 得分 | | --- | --- | | 语言 | Five thousand five hundred | | 上下移动 | Four thousand four hundred | | 傻瓜 | Three thousand three hundred | | 爸爸 | Two thousand two hundred | | 她 | One thousand one hundred |

为简单起见,让我们将每个条目定义为一个namedtuple:

from collections import namedtuple

Highscore = namedtuple('Highscore', ['score', 'name'])

现在我们可以生成条目作为Highscore的实例:

entry = Highscore(5500, 'Ada')

在 Python 中,没有什么能阻止我们意外地交换顺序或参数:

entry = Highscore('Ada', 5500)

虽然这个命令明显是错误的,但是它被执行并通过,没有任何错误。它产生了一个Highscore的实例,缺陷传播开来,可能直到我们试图将分数与另一个玩家的分数进行比较(除非顺序也被交换)。如果我们运气不好,缺陷根本不会产生错误,并且会一直传播,直到它感染了我们数据的其他部分。主要原因是 Python 中允许的动态类型。使用早期的类型控制,缺陷不会传播。在这一点上,我们已经可以得出结论,动态类型化使得缺陷更容易传播。如果我们想因为动态类型而限制错误传播,我们必须自己添加它。

动态类型的缺点

动态类型的弱点在许多不同的情况下都有所体现。在这一章中,我们将更仔细地研究四种常见的与类型相关的问题:

功能签名

和前面的高分例子一样,Python 在给函数赋值参数时不考虑类型。调用与设计不同类型的函数很容易。因此,我们最终可能会让相同的功能做完全不同的事情,正如这个经典的例子所示:

>>> def add(a, b): return a + b
...
>>> add(7, 7)
14
>>> add('7', '7')
'77'

价值边界

通常,数据包含硬边界(最小值和最大值、列表大小、可能值等)。).Python 并没有阻止我们违反这些界限,结果是语法上正确的废话:

>>> year = -2016
>>> month = 13
>>> days = [day for day in range(1, 33)]
>>> weekday = "Cupcakeday"

(当然,在日期的情况下,使用datetime模块会发现一些这样的问题。但是我们仍然可以编写像date.day + 42这样的表达式,将一个值推到预期范围之外。)

类型的语义

相同的数据可能有不同的含义。假设我们用不同的单位存储长度:

>>> cm = 183.0
>>> inches = 72.05
>>> cm - inches 110.95

由于两个值都有类型float,Python 永远不会抱怨计算明显错误。同样的错误也会发生在货币而不是长度单位上。如果汇率彼此接近,这种缺陷可能很难发现。大多数人不会觉得一个财务计算有百分之几误差的程序有什么好玩的。

复合类型

在 Python 中,通常很难说“我想要一个除了整数之外不能包含任何其他内容的列表。”对于字典、集合、元组等等也是如此。因此,很容易将不兼容的类型混合在一起,就像前面的例子一样。

Python 中更强的类型化可能吗?

这四个问题的共同点是都和打字有关。它们是 Python 特有的问题。综上所述,尽管动态类型有很多优点,但它仍然是 Python 语言的软肋。让我们看看是否可以做些什么来防止这些问题,并使我们的代码输入更加严格。在理想情况下,类似于以下的构造是可能的:

>>> Highscore = typedtuple(['int score', 'str name'],)
>>> entry = Highscore(5500, 'Ada')

当我们不小心弄乱了类型时,Python 会立即抱怨:

>>> entry = Highscore('Charlie', 3300)

Traceback (most recent call last):
    Python expected type 'int' but got type 'str'
    in line ...
                Hey wait. This is not real Python code!

您可能会得出与前面虚构的解释器相同的结论:“但是 Python 不是一种静态类型的语言。Python 里没有静态类型。”我同意。这一章的标题有点让人迷惑。Python 不是一种静态类型的语言,这一点不太可能改变。相反,我们正在寻找变通办法,即在 Python 程序中加强类型控制的策略。幸运的是,有几种类型的控制策略。在这一章中,我们将考察几种这类控制策略的利弊,看看它们是否有助于我们排除最坏的错误。

断言

第一个想法可能是简单地显式检查变量的类型。例如,我们可以在给列表添加高分时创建一个断言:

from collections import namedtuple

Highscore = namedtuple('Highscore', ['score', 'name'])

highscores = []

def add(name, score):
    assert type(name) is str
    assert type(score) is int
    hs = Highscore(score, name)
    highscores.append(hs)

这种策略被称为防御性编程。防御性编程背后的思想是,我们“永远不要相信调用我们函数的代码。”这两个断言明确地陈述了函数对其参数的期望。如果违反了这些期望或前提条件,该函数将不会做任何事情。这样,我们可以在类型相关的缺陷传播之前捕捉到它:

add('Ada', 5500)
add('Bob', 4400)
add(3300, 'Charlie')

程序干净地退出,带有一个AssertionError:

Traceback (most recent call last):
  File "highscores_assert.py", line 19, in <module>
    add(3300, 'Charlie')
  File "highscores_assert.py", line 9, in add
    assert type(name) is str
AssertionError

使用断言,我们还可以检查数据上更复杂的条件。例如,我们可以确保高分列表中的元素不超过五个:

def add(name, score):
    assert type(name) is str
    assert type(score) is int
    hs = Highscore(score, name)
    highscores.append(hs)
    highscores = highscores[:5]
    assert len(scores) < 5

第二种断言也称为后置条件。我们故意引入另一个失败点来缩短错误传播。因为我们可以用任何有效的 Python 表达式和assert一起使用,所以我们基本上可以检查我们想要的任何东西。前置条件和后置条件(以及一般的防御性编程)的好处在于,它们会在早期以特定的方式失败。一个受欢迎的副作用是断言明确地阐明了需求,因此比文档更可靠。这个断言就像一个来自过去的幽灵,警告你“如果 score 不是一个整数,就会发生不好的事情!”

Hint

在某些语言中,前置条件和后置条件是强大的正式结构,可以在运行代码之前通过外部工具进行检查。在 Python 中并非如此。我们本质上是用一个错误替换另一个错误。

但是防御性编程也有一些严重的缺点。首先,断言膨胀了我们的程序。在add函数的六行代码中,有三行是断言。更多的代码意味着更多的空间来隐藏 bug。第二,一些断言引入了冗余。函数add的最后两条指令负责相同的事情,确保列表不会超过五条。第三,断言需要计算时间,并且会使代码变慢。如果我们要调用前面的函数一百万次,断言将花费我们大约一秒钟的时间。如果我们代码中的计时很重要,或者我们想在 Raspberry Pi 上运行它,断言会很快成为障碍。总而言之,防御性编程倾向于使代码更庞大,更难以快速更改。

然而,当处理一组复杂的依赖关系时,防御性编程可以合理地增强我们的代码。例如,在由许多步骤组成的计算管道中,中间的断言是有意义的。根据计算结果,我们可以确保数字确实是浮点数,三角形确实有三条边,并且开始和结束时的样本数是相同的。在一长串事件中,通过一个assert语句缩短错误传播可能会挽救我们的一天。也有人说,防御性编程可以用来防止老化的程序崩溃(一段时间)。但是我不会推荐防御性编程作为帮助控制一般类型的最佳实践。例如,在前面的高分示例代码中,这肯定是多余的。

NumPy

作为最突出的 Python 库之一,NumPy 在这里值得一提。顾名思义,NumPy 是作为一个库设计的,用来处理大型数组和数值矩阵。在内部,NumPy 将数据映射到用 c 编写的数据结构中,这样做的好处是 NumPy 数组的所有元素都具有相同的类型。用 NumPy 编写,我们的高分列表可能是这样的:

import numpy as np

scores = np.array([5500, 4400, 3300, 2200, 1100], dtype=np.int64)
players = np.array(['Ada', 'Bob', 'Charlie', 'Dad', 'Elle'], dtype=np.str)

严格保留数组的类型。我们可以检查,如果我们试图打破一个不匹配类型的数组。在需要数字的地方使用字符串会立即失败:

>>> scores[2] = "Charlie"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: invalid literal for long() with base 10: 'Charlie'

但是,如果我们将一个数字放入一个字符串数组,它会自动转换:

>>> players[3] = 100
>>> players
array(['Ada', 'Bob', 'Charlie', '100', 'Elle'], dtype='|S6')

这意味着我们实际上不能很好地输入控制字符串,因为每个 Python 对象都有一个字符串表示。在scores中插入一个浮点数也会导致转换。因此,这些类型不是在所有方向上都严格强制的。dtype参数为整个数组设置一个类型。对于 Python 中的许多基本数据类型,dtype参数有多种选项(不同精度的整数和浮点数等)。).更复杂的数据类型由dtype=np.object表示。这意味着 NumPy 不能对字典、集合甚至自定义类型施加任何类型控制。'object'型对我们的目的没有帮助。

另一个缺点是 NumPy 接口迫使我们将玩家的名字和分数保存在两个独立的数组中。在实践中,这限制了在 NumPy 中输入用于构建类型控制的数据结构的有用性——它根本不是为此而构建的(除非我们使用 NumPy . re array 来稍微缓解这种情况)。NumPy 在最初构建的方面要好得多:数字数组。NumPy 中数值计算的速度是出了名的,这也是大多数人使用该库的原因。

总之,NumPy 的类型系统为相同类型的对象(最好是数字)的列表式集合引入了一些严格性。使用错误的类型仍然会失败,但会更早。这很好,但是它并没有给我们一个足够强的理由仅仅为了类型控制而使用 NumPy 数组。

What about the pandas library?

pandas library 是一个构建在 NumPy 之上的豪华界面,用于帮助分析表格数据。pandas 推断数据列的类型类似于 NumPy。然而,类型检查更加宽松:您可以将所有内容分配给 pandas 中的一个列,而不管它是什么类型。如有必要,该列将自动更改其类型。这导致了讨厌的类型相关的错误;例如,读取一个用逗号(,)而不是点(.)将产生一列字符串而不是浮点数。

数据库 s

因为我们主要感兴趣的是控制数据的类型,所以我们可以将数据委托给外部工具(而不是像 C 扩展那样将所有事情都委托给外部工具)。有一个很大的工具家族就是为了这个目的而建立的:数据库。主要思想是:如果我们使用数据库来存储数据,我们可以强加一个刚性的结构,包括数据类型。因此,每次我们访问我们的数据时,我们都确信我们获得的数据正是我们想要的类型。这种方法的好处是 Python 可以与几乎所有可用的数据库引擎很好地协作。这里我们将使用 PostgreSQL 作为数据库引擎,使用 psycopg2 作为 Python 模块,创建数据库连接来实现高分列表。根据您的系统,建立数据库的过程可能有点复杂。在 Ubuntu 上,以下命令就足够了:

sudo apt-get install postgresql
sudo /etc/init.d/postgresql start
createdb highscores
sudo pip install psycopg2

如果您想跳过安装,而是想专注于代码,下面是如何将 highscore 表存储到数据库和从数据库中读取:

import psycopg2

DB_SETUP = "'
CREATE TABLE IF NOT EXISTS scores (
    player VARCHAR(25),
    score INTEGER);
    "'

db = psycopg2.connect(host="127.0.0.1",
                      user="krother",
                      dbname="highscores")
cur = db.cursor()

# create the database
cur.execute(DB_SETUP)

# fill the database with entries
insert = "INSERT INTO scores VALUES (%s,%s);"
cur.execute(insert, 'Ada', 5500)
cur.execute(insert, 'Bob', 4400)

# retrieve the top five entries in descending order
query = 'SELECT player, score FROM scores ORDER BY score DESC LIMIT 5;' cur.execute(query)
for result in cur.fetchall():
    print(result)

db.close()

执行代码会导致:

('Ada', 5500L)
('Bob', 4400L)

数字后的'L'表示这些是长整数。如果我们添加另一个带有交换参数的调用(3300, 'Charlie'),我们会得到想要的类型错误:

Traceback (most recent call last):
  File "highscores_postgres.py", line 20, in <module>
    cur.execute(insert, (3300, 'Charlie'))
psycopg2.DataError: invalid input syntax for integer: "Charlie"
LINE 1: INSERT INTO scores VALUES (3300,'Charlie');

用于其他 SQL 数据库的 Python 模块几乎以同样的方式工作。更方便的方法是使用对象关系映射器(ORM),这是一组创建 SQL 查询并将结果转换为 Python 对象的类。广泛使用的 ORM 是 SQLAlchemy 和 Django 中的数据模型。大多数 NoSQL 数据库(MongoDB、Redis 等)也有 Python 接口。),但是它们之间的差别太大了,因此无法在这里讨论它们的输入方法。

Warning

SQLite 数据库是随 Python 一起安装的全功能 SQL 数据库接口。然而,SQLite 是动态类型的,因此对类型控制没有任何帮助。

使用数据库有几个额外的好处:持久性(即使我们的程序崩溃,数据也是安全的)、互操作性(非 Python 程序员也可以使用它们)和可伸缩性(如果 PostgreSQL 数据库变得太小,我们可以用专用的数据库服务器(或 Oracle 集群)来代替它,而不用从根本上改变我们的代码)。当然,数据库也不是完美的解决方案。首先,与 SQL 数据库交互会在代码大小和速度方面产生大量开销。为了管理单个表,编写一个助手模块来构建和解释 SQL 查询是一个好主意,即使只是为了让 SQL 代码看不见。使用 ORM,代码会变得更短,但是在助手模块中创建数据模型仍然是一个好主意。其次,我们只能使用数据库提供的类型(例如,SQL 数据库中没有字典)。第三,我们仍然没有在程序中得到类型控制;我们只是将数据管理移出 it。

考虑到这些限制,数据库对类型控制问题有什么影响?简而言之,我们控制了违规最严重的数据类型:我们为我们的“业务数据”获得了一个清晰、持久、强类型的结构在我们的 Python 代码中,所有其他具有更多本地上下文的变量都像以前一样保持动态,数据库为类型控制问题提供了一个实用的局部解决方案。我们并没有非常接近“真正的”静态类型,但是它是有效的。

集成 C 代码

Python 非常擅长与用 C/C++编写的代码进行交互。通常,这样做是为了加速 Python 代码。然而,如果没有静态类型,C 代码是不可想象的,这就是为什么我们要仔细研究这个选项。用 Python 编写 C 扩展允许我们像调用 Python 函数一样调用 C 函数。C 头文件Python.h提供了将标准 Python 数据类型转换成 C 类型的函数。这意味着,我们在程序的 C 部分得到了完全的类型控制。另一个实现相同目的的方法是 scipy.weave 包,它允许直接在 Python 模块中以字符串形式执行 C 指令。

然而,在这两种情况下,我们负责在 Python-C 接口中捕捉所有类型的错误。这种类型检查可以在 C 端(使用Python.h)或 Python 端(作为断言,使用scipy.weave)完成。在 C 中使用错误的类型会带来整个程序因分段错误而崩溃的风险。一般来说,除了静态类型之外,C 代码给了我们灵活性和速度。然而,也有一些缺点:

  • 有一些开销:我们需要编写 Python-C 适配器,设置 C 编译器,并将所有东西集成到构建程序的任何东西中。
  • 在 Python 方面,没有什么变化。一切都一如既往地充满活力。
  • 编译 C 代码意味着我们编译的代码不再是独立于平台的。
  • 严格来说,使用 C 代码已经不是 Python 了。如果我们有充分的理由用静态类型语言编写代码,也许将整个程序转换成这种语言是值得考虑的(用更严格的实现替换 Python 原型是许多开发团队追求的最佳实践)。

当优化程序速度或使用外部 C 库时,类型控制是一个受欢迎的副作用。不过,我还没见过有人为了类型控制而编写 C 扩展。

西通

Cython 项目受到了很多关注(参见 http://docs.cython.org/en/latest/index.html ). Cython 是一个将 Python 源代码转换成 C 源代码的工具。然后,我们可以使用 C 编译器来构建一个可执行程序。可执行程序可以在没有 Python 解释器的情况下使用,并且通常比常规 Python 程序更快。除了极少数例外,Cython 解释 Python 代码的方式与标准 Python 解释器相同。我们感兴趣的是,我们可以添加 Cython 理解的类型注释。highscore 实现的 Cython 版本应该是

cdef struct Highscore:
    char *name
    int score

cdef Highscore scores[5]

scores[0] = Highscore('Ada', 5500)
scores[1] = Highscore('Bob', 4400)

for i in range():
    print(scores[i].name, scores[i].score)

该结构是一个类似于namedtuple的 C 数据结构,但是具有静态类型。

假设安装了 Cython,我们可以编译一个可执行文件并在 Unix shell 中运行它:

cython --embed highscores_cython.pyx
gcc -Os -I /usr/include/python3.4m -o highscores.out highscores_cython.c -lpython3.4m -lpthread
./highscores.out

这导致了

Bob                4400
Ada                5500

如果我们尝试在 Python 代码中使用无效的类型会发生什么?

scores[2] = Highscore(3300, 'Charlie')

重新编译程序时,第一步(用 C ython编译)会出现一堆错误:

highscores_cython.pyx:11:28: Only single-character string literals can be coerced into int

Error compiling Cython file:
------------------------------------------------------------
...

cdef Highscore scores[5]

scores[0] = Highscore('Ada', 5500)
scores[1] = Highscore('Bob', 4400)
scores[2] = Highscore(3300, 'Charlie')

------------------------------------------------------------
highscores_cython.pyx:11:22: Cannot assign type 'long' to 'char *'

这里我们终于有了真正的静态类型:在执行任何代码之前,类型被检查并生成错误消息!我们付出的代价是代码本身只是部分类似于一个 Python 程序(后半部分是,但前半部分看起来更像 C)。幸运的是,C 类型是可选的。我们可以通过 Cython 运行普通的 Python 代码,或者在一些关键的地方添加静态类型。这种方法的缺点是 Cython 注释代码的构建过程完全改变了。标准 Python 解释器不再理解 Cython 代码。此外,如果我们在 Cython 中导入 Python 模块,它们也需要编译。

使用 Cython 编译可执行程序并不是使用 Cython 最正统的方式。它的主要应用领域是生成库。与 C 扩展和 NumPy 一样,使用 Cython 的一个常见动机是速度。然而,通过额外的类型注释,使用 Cython 进行类型控制是一个可行的选择。如果您不担心额外的编译步骤(并且知道如何配置 C 编译器),Cython 将为您提供两个世界的最佳选择。

类型提示

从 Python 3.5 开始,可以注释变量和参数的类型。这些类型提示是该语言的一个快速发展的特性,这里给出的例子需要 Python 3.5 或更高版本。类型提示背后的主要思想是,我们将类型写入 Python 代码,以便其他程序员知道我们希望变量具有什么类型。注意,类型提示是提示,不是检查——目前,Python 解释器忽略它们。带类型注释的高分列表如下所示:

from typing import NamedTuple, List
import csv

Highscore = NamedTuple('Highscore', [('score', int), ('name', str)])

class HighscoreList:
    """A sorted list of top scores"""
    def __init__(self, places: int=10) -> None:
       self.scores = [] # type: List[Highscore]
       self.places = places

    def add(self, highscore: Highscore) -> None:
      self.scores.append(highscore)
      self.scores.sort()
      self.scores = self.scores[:self.places]

    def __repr__(self) -> str:
      return str(self.scores)
hs = HighscoreList()
hs.add(Highscore(5500, 'Ada'))
hs.add(Highscore(4400, 'Bob'))

在代码中,类型提示出现在几个地方:

  • 函数签名-函数参数具有类型(例如:整数的:int或高分对象的:Highscore)。返回值也有类型,例如,-> None-> str
  • 变量定义——第一次定义变量时,以# type:开头的注释指定了它的类型。
  • 复合类型——我们使用typing模块将scores定义为一列Highscore对象。
  • 预定义的类型——我们定义自己的NamedTuple,以及每个字段的类型。请注意,这是一个与常见的collections.namedtuple不同的元组。

typing提供了更加详细和灵活的方式来注释类型,例如,Any(类型无关紧要)、Union(从类型列表中选择一个),以及NewType(现有类型的不同克隆)。您可以在 PEP484 文档和typing模块的文档中找到完整的描述(参见 https://docs.python.org/3.5/library/typing.html#module-typing )。就其本身而言,类型提示作为文档是有帮助的:其他程序员知道我们的意思。这种方法的最大缺点是类型提示什么都不做。Python 解释器会忽略它们,由错误类型导致的运行时失败根本不会改变。

但是我们可以使用额外的工具来解释类型提示。目前,在这些工具中,我们找到了用于生成文档的 pydoc、在编写代码时警告类型冲突的 PyCharm IDE(见图 16-1 )和 mypy。在这里,我们将重点关注 mypy。

A419627_1_En_16_Fig1_HTML.jpg

图 16-1。

Type checking in PyCharm for function calls. The checker needs to be activated by setting the ‘Python – editor – Type checker’ preferences to ‘Error’. Also see https://www.jetbrains.com/help/pycharm/5.0/type-hinting-in-pycharm.html .

我是麦比

mypy 是一个静态类型检查器(参见 http://mypy-lang.org /)。它检查 Python 代码,并检查带注释的类型是否被一致地使用。如果没有,mypy 会在执行任何代码之前创建一条错误消息。mypy 可以与一起安装

> pip install mypy

接下来,我们通过键入mypy <filename.py>从命令行调用 mypy:

> mypy highscores.py

如果一切都一致,mypy 的输出是空的。让我们试着给 Python 脚本添加一个错误的类型。

add(3300, 'Charlie')

重新运行 mypy 会产生一条错误消息:

> mypy highscores.py
highscores.py, line 37: Argument 1 to "Highscore" has incompatible type "str";
  expected "int"
highscores.py, line 37: Argument 2 to "Highscore" has incompatible type "int";
  expected "str"

这最终是我们希望看到的应用严格类型的错误,我们不需要过多地扭曲我们的 Python 程序。耶!为了对第三方模块创建的对象应用类型检查,mypy 使用存根文件。这些包含 mypy 解释的.pyi文件中函数和变量的带类型注释的签名。Python 标准库和许多其他模块的 Stubfiles 可以在 https://github.com/python/typeshed/ 的 typeshed 项目中找到。

请注意,mypy 并没有找到每一个问题。例如,如果我们使用collections.namedtuple而不是typing.NamedTuple,元组中的类型将被忽略。像 pylint 一样,mypy 增加了额外的质量检查,我们可以打开和关闭它。因此,它可以集成到像 Jenkins 这样的持续集成平台中,这样每次代码添加到存储库时都会执行类型检查。目前,mypy 的 0.4.6 版本已经发布(2016 年 11 月)。该工具正在非常积极地开发中,可能会发生变化,就像 Python 中的类型提示一样。鉴于它们短暂的历史,我不会把类型提示和 mypy 的组合称为最佳实践,但它是一个非常有前途的候选,我希望将来能看到更多的 mypy。

使用哪种类型控制方法?

我们已经看到了六种可能的策略来改进 Python 中的类型控制。它们是否有助于我们限制程序中的类型,使大多数的TypeErrors和/或NameErrors永远不会出现?让我们回顾一下每种方法的优缺点。为此,让我们考虑一下我们在开始时提出的四个问题:

  • 函数签名:我们能强制一个函数被调用或者返回一个特定的类型吗?(9 + 9 vs. '9'+ '9')
  • 语义类型:我们可以定义多个不同名的类型吗?(保持厘米和英寸分开)
  • 价值边界:我们可以将价值限制在某个范围或选择中吗?(选手必须有正分)。
  • 类型组合:我们可以限制复杂类型的组合吗?(例如,高分列表中的所有项目必须是Highscore对象)。

表 16-2 包含这些问题的快速答案。让我们详细总结一下这六种策略。

表 16-2。

Strategies to Improve Type Control in Python

| 方法 | 断言 | NumPy | c 代码 | 数据库 | 西通 | 我是麦比 | | --- | --- | --- | --- | --- | --- | --- | | 功能签名 | 是 | 不 | 是 | 不 | 是 | 是 | | 价值边界 | 是 | 不 | 不 | 是 | 不 | 不 | | 类型语义 | 是 | 不 | 是 | 不 | 不 | 是 | | 复合类型 | 是 | 不 | 是 | 桌子 | 是 | 是 | | 失败时间 | 执行 | 执行 | 执行 | 执行 | 编辑 | 分析 | | 开销 | 高的 | 低的 | 高的 | 媒介 | 高的 | 低的 |

断言和防御性编程给 Python 代码本身增加了更严格的类型控制。断言允许我们引入任何类型的约束,提前失败,并带有定义好的错误消息。这听起来是一个完美的解决方案。实际上,维护断言的成本很高:它们增加了代码的大小,使程序不那么灵活,不那么 pythonic 化。

但是在长时间的计算或者跨越几十个模块的过程中,单个断言可以节省我们许多调试时间。节约使用,是一个有价值的工具。

NumPy 为我们提供了整数和浮点数的类型控制。NumPy 数字数组是一个数字数组,所以我们在实现计算时不必担心输入问题。对于其他类型,它对我们没有任何帮助。这是一个非常棒、非常有用的库,但是类型控制不是 NumPy 的初衷。

乍一看,为控件类型编写 C 扩展似乎是个好主意,但事实证明不值得这么麻烦。我们有很好的方法来控制 C 部分中的类型,但是 Python 部分完全不受影响。开销很高,因为我们需要编写防御性代码来避免 C 代码中的打字问题。如果你依赖于强类型语言的好处,也许 Python 不是解决这个问题的正确选择。但是,如果我们计划用 C 扩展 Python(例如,为了速度),知道打字更容易是件好事。

将数据存储在强类型数据库中通常是个好主意。我们将核心数据委托给一个专用的、严格类型化的引擎。类型控制只是使用数据库的众多好处之一。事实上,这是一个副作用,数据库仍然不能修复 Python 程序内部的类型问题。不管我们是使用 ORM 还是将 SQL 代码写入 Python 模块,数据库在构建、管理和与它交互时都会产生一些开销。然而,我们得到了我们想要的:对我们数据的更多控制。

Cython 是这里唯一一个将真正的静态类型引入 Python 代码的策略。它也是唯一拒绝执行错误类型代码的方法。这是有代价的:结果是一个有点奇怪的 Python-C 混合体,结合了两者的优点。一个好的方面是,我们可以决定程序的哪些部分需要静态类型化。最大的缺点是构建 Cython 程序非常不同。需要做一些工作来配置基于 Cython 的库和可执行文件的编译。你的生活越接近 C(或者你越愿意学习 C),Cython 就越有用。

我们最后的竞争对手,类型提示和 mypy 的结合提供了一个强大的类型系统,它是 Python 本身的一部分(至少在最新版本中)。类型提示文档代码,mypy 在最早的时候警告许多类型冲突。让 mypy 作为一个独立的工具来执行检查与许多软件开发团队使用的工具链集成得很好。使用 mypy 的一个好方法是让代码自动检查类型违规(例如,在提交到代码库之后)。Python 中的类型提示仍然是一个新特性,还不是一个标准过程,但我们可以期待在未来看到更多关于类型提示的内容。

综上所述,在某种程度上用 Python 控制类型是可能的。前面描述的所有方法都有副作用(好的和坏的)。最值得注意的是,除了 Cython 和 mypy 之外的所有控件类型都只能在运行时使用。使用任何类型的控制策略,我们的程序仍然会失败。但是它们会更早地失败,有更清晰的错误信息,这样缺陷可以更快地被发现。在一个增长到 1000 行甚至更多的程序中,这是一种使开发不那么痛苦的健壮性。

最佳实践

  • 动态类型是 Python 缺陷的主要来源。
  • 与类型相关的问题包括函数签名、值边界和语义以及复合类型。
  • 有几种策略可以改善 Python 程序中的类型控制。
  • 断言可以用于检查类型和其他限制,但是会增加相当大的开销。
  • 防御性编程是系统地使用断言来保护函数免受不一致参数的影响。
  • NumPy 数组有一个 set 类型,只对数字严格执行。
  • SQL 数据库有助于将核心数据从 Python 程序转移到严格控制类型的表中。
  • C 扩展使用静态类型,但是程序员负责在 Python-C 接口捕捉错误。
  • Cython 是一个从 Python 代码生成 C 代码的工具。Cython 使用类型注释来加速程序。
  • 类型提示注释 Python 代码,但不改变代码的执行方式。
  • mypy 是一个独立的工具,它在不执行代码的情况下检查类型提示的一致性。

十七、文件

“但是即使是单独工作的黑客,”无名师说,“也会和其他人合作,并且必须不断地和他们清楚地交流,以免他的工作变得混乱和丢失。”“你说的其他人?”神童问道。无名师说:“你们所有未来的自己。”—Eric S. Raymond,“Foo 大师和编程神童”

20 世纪 80 年代,当我父母翻新房子的木板时,我母亲在墙和新木板之间放了一张报纸。当我想知道妈妈在做什么时,她解释说:“这是我们给未来的信息。无论谁是下一个更换面板的人,都会找到报纸,看到我们是如何看待当今世界的。”也许几十年后,其他人会在镶板建成时瞥见我们的生活(例如,报纸被认为是历史的见证)。回顾过去是困难的。当然,这比展望未来容易。有一些信息:我们可以看到旧的,木制面板,在他们可能枯萎的状态。我们可以看到已经建成的东西,但看不到我们的精神状态、我们的希望、我们的理由、我们的意图。记录软件是相似的。

当我们编写软件时,文档是我们未来的信息。它有助于未来的开发人员理解我们是如何构建某个东西的,以及它是如何构建的。考虑如下陈述:“该程序包含 7 个类和 23 个函数。”在最好的情况下,这将是对您在代码中看到的内容的准确描述。更常见的是,随着程序的不断发展,这种文档在短时间内就会出错。相比之下,像“我们建造迷宫作为游戏的中心数据结构和它周围的一切”这样的陈述有助于你理解在代码中很难看到的想法。但是后一种说法缺乏技术细节:我们需要在描述想法和技术细节之间找到正确的平衡。在这一章中,我们将探索如何为 Python 项目编写有用的、平衡的文档。

我们为谁写文档?

一般来说,我们为三类人写文档:开发人员、其他团队成员和用户。理解这一点是很重要的,在未来的几个月里,作为开发人员,你是你之前写的文档的主要受益者。这就是我们在这里关注以开发人员为中心的文档的原因。在某种程度上,一个好的面向开发人员的文档也可以帮助项目中的其他参与者,例如加入项目的新团队成员或者报告开源项目中的 bug 的人。

这里我们将忽略面向非程序员最终用户的文档。写用户手册或者,例如,一个项目相关的文档当然很重要,但是这是一种完全不同的技术写作。从工程的角度来看,是技术文档挽救了这个项目。

sphinx:Python 的文档工具

很长一段时间以来,我们已经能够在我们的主项目目录中使用一个单独的README .md文件,加上一个用于法律事务的LICENSE.TXT文件,轻松地记录我们的程序。有很多项目的README文件都绰绰有余。但是随着程序的增长,它最终会达到单个文件不再足够的大小。以下是你的程序已经超出了README文件的一些症状:

  • README文件太长,浏览起来不舒服。
  • 该文件描述了许多只与某些人相关的特殊情况。
  • 有许多代码示例,很难使它们保持最新。
  • 您无法自动验证文档中的代码示例是否正确。

当我们发现前面的一个或多个适用时,是时候切换到更大的文档工具了。我们仍将保留README文件,即使只是为了指向更大的文档。在这一章中,我们将使用 Sphinx(Python 可用的标准文档工具)记录我们项目中的迷宫(generate maze模块)。许多大大小小的 Python 项目都是用 Sphinx 编写的,包括 Python 本身。

简而言之,Sphinx 结合了我们编写的文档文件和我们正在记录的 Python 源代码。然后 Sphinx 将这些文件编译成 HTML、PDF 和 EPUB 文档(见图 17-1 )。文档文件以 ReStructuredText ( .rst格式编写,可能包含自动生成的目录、超链接,甚至自动化测试。在这一章中,在回到什么是好的文档的问题之前,我们将浏览所有这些特性。

A419627_1_En_17_Fig1_HTML.jpg

图 17-1。

Workflow for creating documentation with Sphinx

建立狮身人面像

首先,我们需要安装斯芬克斯。这可以使用pip:来完成

pip   install  Sphinx

Sphinx 使用了许多其他 Python 包。最值得注意的是,它使用pygments库在文本文档中创建语法突出显示的源代码。

我们将在项目的docs/文件夹中添加文档。如果您使用了pyscaffold来设置您的项目,它应该已经在那里了。如果没有,只需创建一个空的docs/目录:

cd docs

接下来,我们运行sphinx-quickstart来初始化文档:

sphinx-quickstart

斯芬克斯问了你很多问题。在大多数情况下,默认值就可以了,但是我建议更改一些值。对于第一个问题,使用默认设置就可以了:

> Root path for the documentation [.]:
..
> Separate source and build directories (y/n) [n]:
..
> Name prefix for templates and static dir [_]:

对于项目名称、作者和版本号,您可以插入您喜欢的任何内容:

>  Project  name:
..
>  Author  name(s):
..
>  Project  version:

对于接下来的三个问题,默认值是正确的:

> Project release [1]:
> Source file suffix [.rst]:
>  Name  of your master  document  (without suffix) [index]:

现在我们已经到了有趣的部分。当程序询问以 epub 格式构建文档时,停止接受默认值。这是一个非常有用的东西,所以我们说“是”。

Sphinx can also add configuration for epub output:
> Do you want to use the epub builder (y/n) [n]: y

接下来是扩展。我强烈建议更改一些非常有用的默认值,比如autodoc, doctest, todo, ifconfig,viewcode

Please indicate if you want to use one of the following Sphinx extensions:
> autodoc: automatically insert docstrings from modules (y/n) [n]: y
> doctest: automatically test code snippets in doctest blocks (y/n) [n]: y
> intersphinx: link between Sphinx documentation of different projects (y/n) [n]:
> todo: write "todo" entries that can be shown or hidden on build (y/n) [n]: y
> coverage: checks for documentation coverage (y/n) [n]:
> pngmath: include math, rendered as PNG images (y/n) [n]:
> mathjax: include math, rendered in the browser by MathJax (y/n) [n]:
> ifconfig: conditional  inclusion  of  content  based  on  config  values  (y/n) [n]: y
> viewcode: include links to the source code of documented Python objects (y/n) [n]: y

最后,斯芬克斯询问剧本创作。您可以按两次 enter 键来完成配置。

> Create Makefile? (y/n) [y]:
> Create Windows command file? (y/n) [y]:

我们将在后面使用这些特性中的大部分。

Sphinx 创建的文件

Sphinx 在doc/文件夹中创建以下文件:

_build      conf.py     index.rst   Makefile    _static      _templates

文件index.rst是您的文档的主文件。_build/目录将包含编译后的文档。文件conf.py包含我们在sphinx-quickstart配置脚本中选择的设置,这样我们可以在以后编辑它们。我们现在不需要担心其他文件。

构建文档

为了创建文档,我们需要在系统上安装 make 工具和 LaTeX。在 Ubuntu 上,我们可以用

sudo apt-get install make
sudo apt-get install texlive-full

注意,LaTeX 包非常大(1.8 GB)!

构建 HTML 文档

我们现在可以通过键入以下命令将文档编译成 HTML

make html

当我们在网络浏览器中打开文件_build/html/index.html时,我们会看到一个默认页面。该页面还没有包含太多内容,因为我们还没有编写任何文档(您并不期望所有的文档都自己编写,对吗?).也参见图 17-2 。

A419627_1_En_17_Fig2_HTML.jpg

图 17-2。

Barebones HTML documentation generated by Sphinx

构建 PDF 文档

或者,我们可以编译 PDF 文档。这需要安装 LaTeX 和 pdflatex 包。

make latexpdf

PDF 可在_build/latex目录中获得。

Warning

在撰写本文时,Sphinx 报告说它无法找到文件iftex.sty。我通过手动下载文件并在 Sphinx 需要时多次粘贴文件的完整路径来解决这个问题。我认为这本书出版时这个问题会得到解决。

构建 EPUB 文档

您也可以构建 EPUB 电子书格式:

make epub

使用您最喜欢的电子书阅读器打开来自_build/epub的文档。

按下make [TAB]键,你会看到一个构建文档的其他选项列表。在内部,make 使用了sphinx-build程序。构建 HTML 文档的一种更冗长的方式是

sphinx-build -b html . _build/html

在哪里?是包含所有源文件的目录,而_build/html是编译文档被写入的目录。

编写文档

Sphinx 使用reStructuredText作为标记格式。一般来说,文档是以文本文件的形式编写的,后缀为.rst。记录generate maze模块的剩余文件tile grid.rst可以包含以下文本:

The Maze Generator
------------------

The module "maze_run.generate_maze" is responsible for generating mazes. It creates a **two-dimensional list** with a *fixed size* that can be used to create a graphical representation in Pygame.

迷宫由圆点代表的走廊和散列代表的墙壁组成。该算法在边界附近留下一个圆形路径。

一些特殊字符用于表示格式。例如,给一行加下划线是一个标题,而双撇号为代码、shell 命令等创建固定宽度的字体。同样,您可以制作斜体和粗体文本、图像等等。一些例子见表 17-1 。完整的休止符格式在 www.sphinx-doc.org/en/stable/rest.html 中解释。

表 17-1。

Markup in the ReStructuredText Format

| 功能 | 剩余标记 | | --- | --- | | 大胆的 | *文本* | | 意大利语族的 | **文本* * | | 固定宽度(代码) | “代码” | | 超链接 | `'link name` < [`http://academis.eu/`](http://academis.eu/) > | | 项目符号列表 | `* first bullet` | | 列举 | `1.  first item` | | 标题 1 | `underline with '--------', same length as text` | | 标题 2 | `underline with '========', same length as text` | | 标题 3 | `underline  with  '++++++++',  same  length  as  text` |

指令

指令是一种特殊的标记。指令是决定 Sphinx 如何生成文档的规则:Sphinx 的大多数有趣特性,如将文档链接在一起、编写 Python 代码和包含 Python 文档字符串,都需要使用指令。所有指令都以..并以::)结尾,因此,例如,在文档中包含图像的指令是

.. image:: example_maze.png

组织文档

中央的一个指示是toctree。它创建了一个树状目录。在 Sphinx 中,所有文档都是按照目录或 toctree 来组织的。

.. toctree::
    mazes.rst
    tile_grid.rst
    sprite.rst
    :maxdepth:  2

这里,文档 mazes.rst 和 tile grid.rst 已添加到目录中。指令:maxdepth : 2 告诉 Sphinx 不仅要包含这里列出的文档,还要递归地将 toctree 语句中列出的任何文档包含在各自的文档中。当编写 toctree 指令时,我们可以使用绝对和相对路径,并且在一个文档中有多个toctree语句。Sphinx 将自动创建整个文档中所有目录树的导航和索引,使用文件中的标题作为标题。

Warning

指令中两个点后面的空格是至关重要的!如果你忘记了,写下来了,例如,..toctree:::代替..toctree::,Sphinx 会把那一行当做普通文本,不会抱怨。

代码示例

编写软件文档时最重要的事情之一就是代码本身。要在.rst文件中包含 Python 代码,我们只需设置一个双冒号(::)并引入一个缩进段落:

Using the "generate_maze" module::
    >>> from maze_run.generate_maze import create_maze
    >>> maze = create_maze(14, 7)
    >>> print(maze)
    ##############
    #............#
    #.#.#..#.###.#
    #.#...##.#...#
    #...#....#..##
    #.#....#.....#
    ##############

Python 代码将被呈现为语法高亮显示的文本,无论它是否包含提示符号(> > >)。渲染由 pygments 库完成。这个选项足以编写一本包含许多代码示例的食谱(见图 17-3 )。

A419627_1_En_17_Fig3_HTML.jpg

图 17-3。

HTML output with source code rendered by Sphinx

从文档字符串生成文档

如果我们在设置 Sphinx 时安装了autodoc扩展,我们可以从 docstrings 为函数、类或整个模块生成文档。在autofunction指令中,我们可以引用程序中的一个函数:

.. autofunction:: maze_run.generate_maze.create_maze

在文档中,该函数的签名与文档一起出现。如果您还包含了 viewcode 扩展,那么您还会自动获得一个到源代码的链接。

Hint

您需要将PYTHONPATH变量设置为带有maze run包的目录,以便 Sphinx 可以导入它。你可能已经在前面的某一章中完成了(或者使用pip-e选项)。为了使模块可以导入,我偶尔会添加正确的导入路径到conf.py中的sys.path,如果默认情况下在PYTHONPATH变量中找不到的话。

也可以用automodule指令发现整个模块:

.. automodule:: maze_run.generate_maze
  :members:

Sphinx 收集所有具有 docstring 和模块级 docstring 的函数和类,并编译相应的文档。如果您没有看到由autodoc生成的函数描述,这并不意味着这样的函数不存在,只是它没有 docstring。

注意,即使导入失败,Sphinx 也会完成文档的构建。除了几个非常严重的错误,Sphinx 总是试图完成构建,而不是因错误而终止。在生成的文档中查找错误信息(参见图 17-4 )。

A419627_1_En_17_Fig4_HTML.jpg

图 17-4。

Error messages generated by Python appear in the documentation generated by Sphinx Warning

autofunctionautomodule都以与 Python 程序相同的方式导入模块。在函数体中执行的代码可能有副作用(例如,如果它创建或删除文件)。

文档测试

文档测试是将文档和自动化测试结合起来的有效方法。strongdoctests 由 Sphinx 文档中的 Python shell 会话组成。当执行测试时,Sphinx 执行这些会话中的命令,并将它们的输出与文档中的输出进行匹配。这样,我们可以有效地提供被证明是正确的文档。

要编写 doctest,我们需要在 Sphinx 中激活doctest扩展(如前面的初始配置中所述)。然后,我们将一个 Python shell 会话复制到一个.rst文件中,并将其放在doctest指令下。从一组走廊位置生成迷宫的 doctest 如下所示:

.. doctest::

     >>> from maze_run.generate_maze import create_grid_string
     >>> dots = [(1,1), (1, 2), (2,2), (2,3), (3,3)]
     >>> maze = create_grid_string(dots, 5, 5)

     >>> print(maze.strip())
     #####
     #.###
     #..##
     ##..#
     #####

要执行测试,请编写

make doctest

在输出中,我们获得了每个.rst文档通过和未通过测试的概述,以及最后的总结。每一行 Python 代码都被解释为一个单独的测试(因为每一行都可能单独失败):

Document: tile_grid
-------------------
1 items passed all tests:
   4 tests in default
4 tests in 1 items.
4 passed and 0 failed.
Test passed.

Doctest summary
===============
    4 tests
    0  failures  in  tests
    0 failures in setup code
    0 failures in cleanup code
build succeeded.

相反,如果我们写了一个失败的测试:

.. doctest::

   >>> 1 + 2
   2

在这种情况下,make doctest的输出包含一条故障消息,指示文档文件中的行:

**********************************************************************
File "tile_grid.rst", line 21, in default
Failed   example:
    1 + 2
Expected:
    2
Got:
    3
**********************************************************************

Are doctests a replacement for Unit Tests and other kinds of automated testing?

在某种程度上,我们可以用 doctests 代替自动化测试,就像在第八章中用 pytest 创建的那些测试一样。特别是,如果我们正在测试 Python 库的以用户为中心的一面——它的接口——文档测试是非常有价值的。然而,对于测试较小代码单元的详细功能来说,它们并不是那么好。当我们试图用 doctests 覆盖很多边界案例或者一系列测试场景时,代码量会迅速暴涨。此外,doctests 缺少许多选项,如测试参数化或有选择地运行测试。因此,最佳实践是使用 doctests 来创建经过测试的、人类可读的文档,而不是详尽的测试集。

配置 Sphinx

待办事项条目

我们可以将文档中的条目标记为待办事项。例如,我们可以使用todo指令在文档中标记计划的特性。以下两个功能尚未实现:

.. todo::
Adding caching to the function drawing mazes could accelerate graphics a lot.
.. todo::
Describe how to move an element of a maze.

创建待办事项列表

todolist指令向我们显示了到目前为止定义的所有 TODO 项目:

TODOs in the MazeRun project
++++++++++++++++++++++++++++
.. todolist::

使用todo扩展(启动 Sphinx 项目时可配置),我们可以在最终文档中打开和关闭 TODO 项目。conf.py中的配置变量用于切换待办事项(默认设置为True:

todo_include_todos  =   True

或者,我们可以使用命令行选项覆盖配置设置:

sphinx-build -b html -D todo_include_todos=1 . _build/html
sphinx-build -b html -D todo_include_todos=0 . _build/html

有条件建筑

类似地,我们可以添加自己的变量(例如,为不同的操作系统、处理器架构或简单的长版本和短版本编译文档)。让我们假设我们想要为开发人员构建一个长文档,为网站构建一个短文档作为开胃菜。

这需要打开ifconfig扩展(这应该在启动项目时发生,但是我们可以稍后在conf.py中添加它)。为了创建我们自己的交换机,我们需要在conf.py的末尾定义一个新的配置变量size。这可以用几行代码来完成:

size = "long"
def setup(app):
    app.add_config_value('size', ", 'env')

接下来,我们在.rst文件的ifconfig指令中添加可选文本。我们为短版本添加了一小段文本,为长版本添加了一个额外的文档链接。

.. ifconfig:: size == "short"
Magic Methods in MazeRun
++++++++++++++++++++++++
Some classes in MazeRun contain examples for reusable object-oriented design. It uses several magic methods and properties to make it easy to access the data.

.. ifconfig:: size == "long"

   .. include(’magic_methods.rst’)

ifconfig评估 Python 表达式size == "short"。如果计算结果为 True,将呈现段落中的额外文本。我们可以用以下代码来构建文档的长版本和短版本

sphinx-build -b html -D size=long . _build/long_html
sphinx-build -b html -D size=short . _build/short_html

为了并行使用多个配置,我们可以编辑Makefile来包含它们,或者创建配置文件conf.py的第二个副本,并在构建文档时使用-c选项在它们之间切换。

改变外观和感觉

为了设计您的文档样式或者给它一个公司的外观和感觉,我们可能想要更改 Sphinx 正在使用的模板。Sphinx 有几个内置主题。我们可以通过改变conf.py文件中的html theme变量来配置它们:

html_theme = 'classic'

可能的名字包括'alabaster', 'classic', 'sphinxdoc', 'sphinx rtd theme', 'scrolls', 'agogo', 'traditional', 'nature', 'haiku', 'pyramid','bizstyle'

对于更详细或定制的更改,我们可以在模板目录中创建自己的模板。在内部,Sphinx 使用 Jinja 作为模板引擎。这意味着我们可以用自己的模板替换部分默认模板,或者只编辑 CSS 文件来改变网站的外观。更多信息见 www.sphinx-doc.org/en/stable/templating.html

如何写出好的文档?

写好文档不同于写小说或技术书籍。读者带着许多不同的意图接近他们。有些读者想了解我们节目的基本内容,有些人在寻找非常详细的答案,还有一些人只是想快速查找一些东西。由于这些意图,好的文档需要易于访问、准确且可操作。让每个人一直快乐是非常困难的,甚至可能是不可取的。但是有一些典型的文本部分在许多项目中经常出现。

技术文档中的文本部分

我们将考虑以下典型的文本部分作为记录软件的最佳实践。

摘要

好的文档从回答为什么开始。我们为什么要使用这个软件?我们当初为什么要建造它?它解决的唯一问题是什么?好的总结需要简短。

先决条件和安装

软件的一个必要方面是让它工作。我们的文档需要涵盖这一点,即使它只包含一个pip install命令。在其他情况下,您可能需要包括分步说明。提及可用的和/或经过测试的平台也是值得的。如果我们认为程序应该在一个特定的平台上工作,但是我们还没有测试它,这也是一个写在安装部分的好东西。

入门指南

本节描述软件的预期主要用途。在许多情况下,这可以被理解为一个“Hello world”的例子。根据我们的程序,这个例子可能包括 Python 代码,一步一步的食谱或两者都有。如果我们的程序不止做一件事,我们可以在这一部分包括一个小的功能浏览。

食谱

食谱是一套说明我们程序使用的食谱,包括代码示例。例子必须完整和准确,这也是 doctests 的荣耀所在。菜谱风格的文档适用于基本和高级功能。

个案研究

一个比食谱更实际的方法是记录案例研究,我们在实践中如何实际使用软件的例子。这将有助于用户更好地感受软件的可行性和不可行性。

技术参考资料

技术参考通常是部分的列举:输入格式的描述、参数表和程序中的函数列表(可能是自动生成的)。这部分是给想查资料的读者看的。Sphinx 的默认搜索功能派上了用场。

设计资料

最高级的读者(那些想要使用我们的源代码的人)会对程序设计的细节感兴趣。在这里,为什么又变得重要了。我们为什么要这样构建程序?我们的主要设计考虑是什么?我们能提供我们软件的可视化概述吗?我们是否应该记录我们的程序设计很大程度上取决于软件的种类。在复杂的服务器架构中,这可能是文档中最重要的部分。

法律方面

文档应该说明谁是作者,指向一个许可,并包含一个法律免责声明(可以在LICENSE文件中)。如果你希望被联系,不要忘记你的电子邮件地址。

良好文档的示例

有许多 Python 项目都有很好的文档。这份文件的共同点是,它包含指导性的标题和简短的章节,并避免复杂的词汇。如果特定领域语言的使用不可避免,它们至少会被一致地使用。

一些记录良好的 Python 项目的例子有:

  • gizeh by zulko ( http://github.com/Zulko/gizeh ): gizeh 是一个基于 cairo 构建的用于创建矢量图形的库。文档可以放在一个网页上,但是这个库非常强大。这是一个文档不需要很长的例子。
  • 本书中使用的测试框架文档在记录基本特性和高级特性之间找到了恰当的平衡。在首页,有一个详细但结构清晰的目录。前两节介绍了基础知识,对于基本的使用已经足够了。其余部分(大约三分之二)描述了特殊的用例。通过关注具体的例子,作者避免解释每一个可能的特例。
  • sci kit-learn(http://scikit-learn.org):Python 中机器学习的头号库(Learning s ci kit-learn:Machine Learning in Python,Raúl Garreta 和 Guillermo Moncecchi,JMLR 12,第 2825–2830 页,Packt 出版社,2011 年。)必须平衡两个挑战:

首先,库本身是巨大的,其次,底层概念是复杂的。由此产生的文档非常庞大,其中有许多好东西可以找到。我想指出使用图像来说明不同的方法(如 http://scikit-learn.org/stable/auto_examples/classification/plot_classifier_comparison.html ).

Tip

您可以使用以下浏览器查看 Pygame 文档:

python  -m  pygame.docs

像编写软件一样,编写文档是一个迭代的过程。不要期望“一劳永逸地”清理文档理想的最佳实践是并行维护文档和代码。这并不意味着我们每次修改代码时都需要更新文档。目标是记录那些不经常改变的东西。对于经常变化的部分,我们的源代码中的注释或源代码本身是一个更好的地方。将 Sphinx 与 doctests 结合使用使我们的生活变得容易多了,因为我们可以检查文档的哪些部分是正确的。

其他文档工具

值得一提的是,还有许多其他工具可以帮助维护 Python 项目的文档。其中一些被认为是 Sphinx 的可能替代品,但大多数是对 Sphinx 提供的功能的补充:

MkDocs

与 Sphinx 相比,MkDocs 是一个轻量级的文档工具。它将 Markdown 格式的文档呈现为 HTML,并提供了许多模板。它具有预览功能;也就是说,我们可以在编写时看到渲染的文档。目前,MkDocs 没有像 Sphinx 那样直接包含 Python 文档字符串的任何功能。 www.mkdocs.org/

jupyter 笔记型电脑

Jupyter 笔记本提供了文本和可执行代码的独特组合。它们服务于记录工作的目的,特别是在快速发展的项目中。当我们为数据分析编写代码,并希望与结果、图表和描述我们想法的文本一起分享时,笔记本是一种被广泛接受的工具。http://jupyter.org/见。Jupyter 笔记本可以结合 reveal.js 将笔记本转换成幻灯片演示( http://lab.hakim.se/reveal-js/ ).

Gitbook

Gitbook 是一个以多种电子书格式(PDF、EPUB、MOBI 和 HTML)创建文档的程序。它也是一个门户网站,建立和托管由此产生的电子书。Gitbook 使用 Markdown 格式作为标记语言。对于教程和指南来说,Gitbook 是一个很好的选择,因为它与源代码的集成没有 Sphinx 那么紧密。在更新的版本中,Gitbook 支持多语言文档。 www.gitbook.io

阅读文件

read Docs 提供了用 Sphinx 和 MkDocs 构建的文档的免费托管。该服务允许使用 git web-hooks,这样一旦我们将更改推送到包含我们文档的 git 存储库,文档就会自动重建。 http://readthedocs.org/

pydoc

pydoc工具显示控制台中任何可导入模块的文档。例如,如果我们在带有maze run.py文件的目录中:

pydoc maze_run

pydoc还允许搜索文档、创建 HTML 文档以及启动本地 web 服务器浏览文档。pydoc默认随 Python 一起安装。详见pydoc  --help

表面抗原-5

由 Eric A. Meyer 建立的 S5 布局是用于幻灯片演示的 HTML5 模板的标准。它们允许以 Markdown 格式编写演示文稿,并将其编译成幻灯片。 http://meyerweb.com/eric/tools/s5/

色素细胞

pygments是 Sphinx 用来将 Python 代码呈现为语法突出显示的表示的 Python 库。pygments 可以为这个星球上的大多数编程语言创建 HTML 表示。如果我们想在自己的基于 Python 的网站上显示 Python 代码,pygments是首选工具。 http://pygments.org/

doctest(测试)

Python 标准库中的doctest模块可以独立于 Sphinx 使用。我们可以将文档测试直接写入我们的文档字符串,并使用

python  -m  doctest  my program.py

http://docs.python.org/3/library/doctest.html

PyPDF2

Python 库PyPDF2允许用几行 Python 代码拆分和合并 PDF 文档。我发现它对 pdf 的后处理非常有用(例如,添加封面)。http://github.com/mstamy2/PyPDF2见。

潘多克

使用 pandoc,您可以快速地将各种标记格式相互转换,并生成 Word 或 PDF 文档。控制各种不同的文件是非常方便的。 http://pandoc.org/

最佳实践

  1. Sphinx 是一个为 Python 项目构建文档的工具。
  2. Sphinx 将文档构建为 HTML、PDF 或 EPUB 文档。
  3. 它使用 ReStructuredText 格式作为标记语言。
  4. 指令链接多个.rst文档,包括图像,或者触发 Sphinx 的其他特殊功能。
  5. 文档中的 Python 代码通过 pygments 语法高亮显示。
  6. Python 函数和模块的文档可以从 docstrings 自动生成。
  7. 文档测试是写入文档的 shell 会话。它们可以由 Sphinx 执行,有效地验证您的文档是否正确。
  8. 使用配置变量可以打开和关闭文档的某些部分。
  9. 对于技术和非技术读者来说,好的文档包括为什么和如何使用一个软件。

第一部分:调试

第二部分:自动化测试

第三部分:维护

posted @ 2024-08-09 17:44  绝不原创的飞龙  阅读(21)  评论(0编辑  收藏  举报