一、简介
谦逊的一课
我 16 岁时开始写我的第一个电脑游戏。这是一个益智游戏,你必须移动砖块并让它们消失。几个朋友加入了这个项目,很快游戏就有了详细的概念、图形、关卡,甚至还有一个企鹅吉祥物(Linux 吉祥物还没有被发明出来)。只有程序需要被编写。那时,我正在 C64 计算机上用汇编语言编写代码。起初,编程很容易。在《少年无畏》中,我相信编程意味着在编写代码时投入足够多的精力,直到成功为止。我已经写了许多这样的小程序,一切都运行良好。但是很快,图形和游戏机制的编程变得比我想象的更困难。我花了几天时间试图对工作进行小的改进,但几乎没有进展。我的编程工作流程是
- 打开电脑
- 加载编译器
- 加载程序
- 写几行代码
- 运行程序
- 通常,程序会崩溃并关闭操作系统
- 关闭计算机并返回到步骤 1
实际上,我花在代码上的时间不超过 10%。发展速度减慢并很快完全停止是不足为奇的。目前,该项目已经晚了 23 年。我花了很长时间才弄明白发生了什么事。任何导师(一个有经验的程序员或我自己的老版本)都会坚持寻找从第 5 步到第 4 步的捷径。这种快捷方式在当时以一个插在电脑背面的盒子的形式存在。找到这些捷径不是一件小事。然而,他们让一切变得不同。我们将这些捷径称为最佳实践。
Python 中的最佳实践案例
23 年后的今天,我们有了 Python,一种让很多事情变得更简单的语言。当程序出错时,我们不必重启计算机。我们有像 Pygame 这样的库,可以帮助我们用很少的代码创建更好更快的图形。我们还需要编写 Python 代码的最佳实践吗?虽然你会从这本书的标题中预料到我的观点,但我想从一个简单的例子开始,解释为什么仍然值得考虑 Python 中的最佳实践。假设我们想使用 Python 和 Pygame 为自己的小游戏创建图形。作为概念验证,我们将加载两个图像,一个带有关卡,一个带有玩家人物,并将它们组合成一个图像(图 1-1 )。Pygame 库中的函数完成了大部分工作。要合并这些图像并保存到一个新文件中,只需要五行代码:
图 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 )。
图 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 )。
图 1-3。
Sketch of the MazeRun game—the Python example we will use throughout this book
我之所以确信 MazeRun 是一本关于最佳实践的书的绝佳范例,有许多原因:
- 这个游戏很容易理解。你们大多数人可能都玩过吃点游戏。
- 我们可以将游戏实现为一个简短的 Python 程序,不需要数千行代码。
- 编写游戏很快变得复杂。我们需要组织数据、图形、用户界面、性能、并发事件等等。
- 细节很重要。小故障会妨碍游戏的可玩性,甚至使游戏无法玩。
- 编写电脑游戏是软件开发人员的共同特点。即使你还没有写过任何游戏,你也很可能玩过一些。当然,你对这个主题有足够的了解来评估一个游戏是否有效。对于我喜欢的其他主题(例如,“模拟 RNA 三维结构的算法”),情况可能不是这样。
- 您可以使用代码示例来创建自己的游戏。
- 二维水平易于创建插图,可以放在一本书里(我也想创建一个关于在时空连续体中旅行的游戏,但四维图像太难打印)。
我们将逐步编写 MazeRun,一次几行 Python 代码。因此,游戏将逐章改进。不过,在我们准备开始之前,我们需要在下一部分准备一些技术上的东西。
这本书怎么用?
为了充分利用本书中的解释和代码示例,我强烈建议您自己下载并执行代码。为此,您需要注意四件事:
- 安装 Python 3
- 安装 Pygame 库
- 安装文本编辑器
- 下载源代码示例
所有章节都假设你正在一个 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 几乎没有疑问。所以这种缺陷比较容易调试。
图 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 字典中,以便我们可以轻松地访问它们,例如使用字符作为关键字:
图 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 中的典型异常。在本章中,我们将研究三种简单的异常调试策略:
- 读取错误位置的代码
- 理解错误信息
- 捕捉异常
在这样做的同时,我们将有希望从总体上了解缺陷的本质。
阅读代码
一般来说,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
的位置。
图 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 相关。
图 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
字典中提取值’ # '。但是翻了一遍之后,我找不到了。我到处都找遍了,但它不在那里。你确定你没把条目放在别的地方吗?真的很抱歉,希望下次能做得更好。 - 永远属于你,蟒蛇
追溯
回溯包含代码中发生异常的准确信息。它包含以下内容:
- 执行的代码的副本。有时我们会立即发现这里的缺陷。这次不会。
- 发生错误时执行的行号。缺陷一定是在代码行本身或者在之前执行的代码行中。
- 导致错误的函数调用。你可以像阅读事件链一样阅读我们的回溯:“模块调用函数 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 FileNotFoundError
是IOError
的子类。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
,并且总是捕捉特定的异常类型。
图 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 中,通常有许多可能导致相同错误的缺陷,这种情况对于TypeError
、ValueError
、AttributeError
和IndexError
来说非常常见。另一个棘手的情况是,如果我们将错误的数据输入到像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
获得。
图 2-6。
Successfully composed tiles
最佳实践
- 导致错误的错误代码被称为缺陷。
- 一些异常有精确的错误信息,可以通过检查代码来修复。
- 在错误消息指示的位置发现了一些缺陷。
- 一些缺陷远离错误信息中给出的位置。
- 错误消息由错误类型、描述和追溯组成。
- 演绎是一种确定错误根本原因的策略。
- 用
try.. except
捕捉异常是一种处理特定情况和错误类型的策略。 - 总是将
except
与特定的异常类型一起使用。 - 千万不要在
pass
里面使用except
。 - 错误和缺陷是明显的。
三、Python 中的语义错误
你试过关机再开机吗?—IT 人群,电话答录机上的帮助台消息
为了让迷宫游戏变得有趣,我们需要迷宫。玩家角色将在这些迷宫中移动,吃点,并被怪物追逐。在这一章中,我们将编写一个程序来产生这样的迷宫。当实现那个程序时,我们将到达一个程序几乎工作的点。您可能对这种现象很熟悉:您已经处理了第一次测试运行中出现的所有异常。然而,程序仍然没有做它应该做的事情。可能还是满满的语义错误。
当程序运行时没有引发异常,但给出了与预期不同的结果时,就会发生语义错误。潜在的缺陷通常比导致错误消息的缺陷更难消除。Python 异常提供的信息可能不太好,但至少给了你一个起点。由于语义错误,我们经常从较少的信息开始。尽管如此,语义错误的原因有时非常简单:错别字、省略的行、语句顺序错误等等(另见图 3-1 )。
图 3-1。
Semantic errors can be hard to spot at first glance but have severe consequences
在这一章中,我们将研究 Python 中的语义错误。为此,我们将故意在代码中引入缺陷,看看会发生什么。通过这样做,我们将试图理解是什么导致了语义错误,以及它们是如何表现的。任何列出所有可能的语义错误的尝试都是徒劳的。不存在像例外那样的分类。相反,我们将从重复出现的缺陷中抽象出来,看看我们能在这个过程中学到什么。
比较预期产出和实际产出
在前一章中,我们很容易判断一个程序是否产生错误:要么有错误信息,要么没有。Python 解释器告诉我们有一个错误。语义错误的情况不太明显。我们察觉到了一个错误,因为程序做了一些与我们预期不同的事情,但是 Python 认为一切正常。问题可能首先从我们的期望开始:它们可能不够清晰。有时候,期望是显而易见的:如果我们写一个程序来增加两个数字,并输入1
和1
,我们期望看到结果是2
。没有多少讨论的余地。让我们考虑一个更具挑战性的例子。
我们生成迷宫的程序到底要做什么?它将生成一个由墙砖(#)和用圆点填充的走廊组成的矩形网格。)构成迷宫。现在你可能已经有了迷宫应该是什么样子的清晰图像。根据这个描述,一个软件开发团队可能会想出如图 3-2 所示的迷宫。
图 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.图 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 = ""
...
我们得到的不是预期的输出,而是
#####
当我们在循环中初始化变量时发生了什么?变量被多次初始化,在循环的每一轮中初始化一次。初始化意味着我们覆盖先前包含的任何内容。因此,我们看到的五个散列实际上是网格的最后一行。多重初始化是一个非常常见的语义错误,它通常与for
或while
循环一起出现。
意外分配
如果我们不小心创建了一个变量赋值,就会出现类似的语义错误。假设我们用=替换+=操作符中的一个。让我们首先考虑+=的第二种情况:
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
)要难得多。参数被命名为xsize
和ysize
的一个原因是,它们与函数中使用的其他变量有很大的心理距离(参见史蒂夫·麦康奈尔所著的《代码全集》(第二版)。,微软出版社,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()
中的任何一个索引,就像前面的例子一样。这不可避免地破坏了甜甜圈的形状。我们已经知道这个缺陷,并且可以用类似的方法推导出来。
第二种缺陷是元组中的x
和y
可以交换,这样我们将得到(y, x+1)
而不是(x, y+1)
。然而,当我们引入这个缺陷时,我们看不到任何影响。甜甜圈还是完整的。但是让我们试着计算不同位置的邻居,例如3, 2
:
neighbors = get_neighbors(3, 2)
我们得到了一艘罗慕伦战舰的中微子信号,而不是甜甜圈:
#####
##...
##.#.
##.#.
##.##
这是非常非常坏的消息!通过用一个x
替换一个y,
,我们引入了一个缺陷,这个缺陷有时会导致错误,有时不会。当然,我们检查的代码示例本身设计得很糟糕:当我们将一个正方形放置在另一个正方形的中心时,我们不应该对插入x
或y
无关紧要感到太惊讶。然而,我们需要记住这个错误的性质:我们将会看到更多的缺陷,这些缺陷有时会传播成错误,有时不会。为了排除错误,先验地识别程序的所有可能的参数组合是非常困难的。
此外,代码本身也容易出现这种错误。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-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
算法并不完美。如果您自己尝试该算法,您可能会注意到它偶尔会产生无法访问的区域。这是另一种设计弱点,因为它不规则地发生。对于我们的目的来说,这个过程已经足够好了,但是如果你想开发一个更健壮的迷宫生成器,试试吧。这也是一个很好的调试练习。
布尔表达式中的缺陷
if
和while
都包含布尔表达式。在这两种情况下,表达式中的缺陷会改变接下来执行的命令。例如,在比较相邻点的数量时,我们可能会意外地使用大于号(>)而不是小于号(<):
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 中,我们可以看到错误传播中的事件链。代码中的缺陷会导致部分数据出错,或者调用错误的函数。该程序将继续运行。然而,缺陷引入的错误会在程序中传播。根据缺陷的不同,它最终会扩散或完全消失。当且仅当缺陷导致的东西到达输出时,我们才有机会将其视为语义错误。错误传播也会导致异常。在这种情况下,事件链不需要到达输出。
图 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 )。猜测策略失败的主要问题是,我们很少(如果有的话)获得关于缺陷的新信息。
图 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 ):
- 观察:我们从观察我们想要改变的程序行为开始。
- 假设:我们表达一个想法,一个假设,为什么观察到的行为会发生。
- 预测:基于我们的假设,我们做出一个可测试的预测,如果假设是正确的,我们的程序还会做什么。
- 测试:我们通过将程序暴露在实验条件下并观察结果来检验我们的预测。
- 总结:最后,我们根据结果接受或拒绝我们的假设。除非我们找到了缺陷的原因,否则我们会回到第二步,进一步完善我们的假设——或者提出一个全新的假设。
科学方法把一个猜测问题变成了一个演绎问题。严格遵循,科学方法很容易跟踪甚至复杂的缺陷。它优于猜测,还因为它导致更干净的解决方案和更容易维护的代码。有四种技术值得了解,它们将帮助我们在实践中有效地将科学方法应用于调试。这些是
- 复制缺陷
- 自动化缺陷
- 隔离缺陷
- 获得帮助
我们将在跟踪事件循环中的 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 个分区,我们可以将缺陷的来源从数千行代码缩小到一个功能或更少。使用二分搜索法策略的唯一先决条件是缺陷必须在过程中相对容易识别。在事件循环的例子中,这并不容易,因为我们在跟踪一些不存在的东西。在某种程度上,二分搜索法和剥离搜索策略是互补的。
图 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.
图 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 窗口需要被选中(活动)。我们已经成功地找到了缺陷的根源并消除了它。
图 4-4。
Working event loop. The Pygame window, although empty, is essential to make the program work. Reality Check
这个例子有多现实?有人真的会漏掉一个在模块文档的第一段写得很清楚的重要命令吗?第一,这是一个发生在我身上的真实 bug。有那么一会儿,我骄傲地认为在用 Pygame 写了半打小游戏后,我不再需要文档了。第二,我相信同样的事情也会发生在其他人身上。第三,每个程序员关于库和工具的知识都是有限的。有时我们很快意识到我们正在接近知识的边界,但有时我们还是选择走得更远一点。当面临是读材料还是写代码的决定时,我们中的许多人更喜欢写代码。我想这就是我们最初成为程序员的原因。
请注意这个错误主要是基于一个错误的假设。当编写有缺陷的版本时,假设是:我们不需要创建一个从键盘读取的窗口。这个假设被证明是错误的。我们最终意识到 Pygame 库需要自己的窗口来读取键盘事件。吸取教训!调试时,在身边放一个记事本是个好主意。对我们正在考虑的假设做笔记有助于我们在遵循它们的过程中保持专注。此外,在艰难的调试过程中,划掉我们已经拒绝的假设可能是我们在一段时间内得到的唯一满足(见图 4-5 )。在我看来,用来做笔记的纸远远胜过电子记事本。我甚至在桌子上放了一本剪贴簿,记录我在写这本书时故意引入的错误。
图 4-5。
Notepad with hypotheses tested on the event loop
清理
即使程序现在处于工作状态,我们还没有完成。我们仍然需要清理我们的建筑工地。这意味着删除我们在过程中引入的任何注释或额外的行,以及我们创建的附加文件。我们可能会决定保留我们的测试脚本,并将它放在我们之前编写的其他测试代码旁边。之后,我们需要再次检查事件循环是否还在工作。在一个更复杂的项目中,清理工作涉及许多其他事情。我们需要重新组织我们的代码来使缺陷的修复干净地适合现有的代码吗?我们需要重写代码来保持一致的风格吗?我们需要编写额外的自动测试吗?该修复是否会影响软件的其他组件?我们需要多长时间在最终产品中加入修正?我们需要更新任何文档吗?我们需要通知团队成员甚至客户吗?等等。
打扫卫生听起来像是一项无聊的任务,但绝对不能推迟。认真处理这些和类似的问题是保持我们项目健康发展的关键。持续忽视清理会直接导致一种令人讨厌的现象,称为技术债务,这是对正在慢慢恶化的软件的官方术语。在我们完成清理之后,我们终于可以运行程序并识别出我们将用于游戏控制的箭头键的代码(见图 4-6 )。
图 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 )。
图 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
对象本身包含四个值:左上角的x
和y
坐标以及矩形的宽度和高度。
从最少的投入开始
使用 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
)用于左起第二个图块,依此类推。因此,需要检查参数xs
和ys
。一个精确的假设是:xs 和 ys 是错误的图块索引。在任何情况下,我们都希望索引是(0,0)。我们可以用一条打印语句来检验这个假设:
print("xs={} \t ys={}".format(xs, ys))
这会产生以下输出:
xs=32 ys=32
这与预期的 0,0 相差甚远。因此,我们接受这个假设。原来我们把图像的大小xs
和循环索引x
搞混了。对这两个变量使用相似的名称可能不是一个好主意。我们需要使用循环索引x
和y
来计算矩形:
rect = get_tile_rect(x, y)
我们不仅观察正确的矩形:
(<rect(0, 0, 32, 32)>, <rect(0, 0, 32, 32)>)
我们还可以在生成的图像上看到单块墙砖(参见图 5-2 )。我们已经修复了另一个缺陷。耶!
图 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 )。我们可以通过交换两个参数来修复缺陷:
图 5-3。
Full diagnosis of the defect with rectangle coordinates
map_img.blit(img, get_tile_rect(x, y), tiles[char])
运行该程序可以正确绘制两块墙砖。这变得更加令人兴奋:我们现在可以再次切换到完整的迷宫,重新运行程序,保持我们的手指交叉…并最终看到完整的迷宫的所有美丽(见图 5-4 )!
图 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
随着程序的发展,对诊断输出和命令行选项的处理可以进一步扩展。标准模块logging
和argparse
为相同的目的提供了更健壮的实现。
完全码
下面给出了完整的工作代码,包括导入和可选的诊断语句:
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 没有多大帮助。有许多缺陷,打印变量值不是很有用。
图 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
( grid
,direction
),以及一个离开(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 中的自省函数开始我们的旅程。在这个过程中,我们会在程序中遇到名称空间、文档字符串和类型。
图 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)
该命令创建一个对象(包含–1
和0
的元组),并将其绑定到名称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__
是当前名称空间的名称。exit
和quit
是终止 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
导入到它的名称空间中的对象(例如Rect
和debug_print)
)。此外,我们看到 Python 内部使用的一些以下划线开头的名字。我们可以在任何 Python 对象上使用dir
,而不仅仅是在模块上。例如,我们可以检查TILE POSITIONS
对象的属性(一个 Python 列表):
In [15]: dir(draw_maze.TILE_POSITIONS)
结果,我们看到了一个很长的条目列表,在列表的最后,我们找到了您可能很熟悉的列表方法,比如pop
和sort
。
当自省 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
是导航这个名称空间网络的强大工具。
图 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
,但是它返回一个布尔值)。一个典型的例子是,我们从配置文件中读取模块或函数的列表,并在程序中动态地访问它们。getattr
和hasattr
有时在调试过程中很有用,但大多数时候我们会在一个动态添加模块和功能的程序中发现它们(例如,Django 经常这么做)。
IPython 中 dir 的替代方案
dir()
不是我们列出 Python 对象各部分的唯一选项。在 IPython 中,我们可以快速浏览名称空间,因为变量、函数和模块的名称是通过按下Tab.
自动完成的。我们还可以使用通配符(*)
搜索名称空间,例如通过键入
In [17]: ?dra*
您应该会看到draw_maze
出现在结果中。如果您不介意将名称空间视为 Python 列表,这种方法可能比dir
更有效。
由dir
产生的信息帮助我们找到要导入的内容。除了我们自己的函数,我们还需要标准库中的pygame.image
和两个模块random
和sys
。完整的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
将一个模块或它的一些组件添加到一个名称空间。 for
和with
语句创建类似=的新变量。- 理解创造了临时变量,一旦理解结束,这些变量就消失了。
专门寻找修改名称空间的命令有助于识别导致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, row
、x,
和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
这里,a
和b
是不同的对象,因为修改一个列表不会影响另一个列表。对于字典、集合以及有趣的元组也是如此。对于不可变的基本类型,如字符串和整数,is
和==
的结果是相同的。
检查实例和子类
使用isinstance
,我们可以检查给定对象是否是给定类的实例:
isinstance("abc", str)
True
使用issubclass
,我们可以检查一个给定的类是否是另一个类的后代:
issubclass(str, object)
True
这是因为每个 Python 对象都是object
的后代。对于调试使用复杂类层次结构的代码来说,isinstance
和issubclass
都是必不可少的。
内省的实际应用
为了成功调试 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 )。我建议在运行时谨慎使用自省,并且只有在您彻底理解名称空间和自省函数的情况下。
图 6-3。
Introspection functions
简而言之,自省是一种工具,它给了我们一个问题的详细答案,在给定的时刻,程序中存在什么对象,它们的属性是什么?自省允许我们分析程序的状态,而不是代码执行的动态。后者是自省的主要限制,因为仅仅依靠自省,我们将永远看不到名称空间或对象先前的状态,也看不到它后来在程序中是如何变化的。我们将在下一章中克服这个限制。除了这个限制,所有自省函数都应该在任何 Python 程序员的工具箱中占有一席之地。
最佳实践
- 自省是一套精确的诊断工具,用于检查 Python 对象。
- IPython 是使用自省函数和神奇函数的绝佳环境。
- 每个 Python 对象都有一个包含属性的名称空间。
dir()
列出名称空间的内容。- 名称空间是 Python 的核心特性。名称空间可以包含任何内容;因此,保持名称空间组织良好是很重要的。
help()
显示 Python 函数、类或模块的文档字符串。type()
显示 Python 对象的类型。- 其他自省函数有助于更精确地分析类型、类和属性。
- 在调试期间,自省允许您诊断程序的状态。