Python-速成课第三版-全-
Python 速成课第三版(全)
原文:
zh.annas-archive.org/md5/f5755848eb7beb838fd7c01e5a0a11de译者:飞龙
序言

每个程序员都有一段关于他们如何编写第一个程序的故事。我从小开始编程,当时我的父亲在数字设备公司(Digital Equipment Corporation)工作,这是一家现代计算机时代的先锋公司。我在我们地下室里,父亲组装的一台套件电脑上写了我的第一个程序。这台电脑仅由一个裸露的主板和一个没有外壳的键盘组成,显示器是一个裸露的阴极射线管。我的第一个程序是一个简单的猜数字游戏,内容大致如下:
I'm thinking of a number! Try to guess the number I'm thinking of: **25**
Too low! Guess again: **50**
Too high! Guess again: **42**
That's it! Would you like to play again? (yes/no) **no**
Thanks for playing!
我永远不会忘记,当看到家人玩我创建的并且按预期运作的游戏时,我感到的满足感。
那次早期的经历对我产生了深远的影响。通过构建一个有目的并解决问题的东西,真的会让人感到满足。我现在写的软件解决的需求比我童年时期的作品要重要得多,但创造一个有效程序带给我的满足感仍然大体相同。
这本书适合谁?
本书的目标是让你尽快掌握 Python,从而能够编写出有效的程序——游戏、数据可视化和 web 应用,同时建立一个将伴随你终生的编程基础。《Python 快速入门》适合任何年龄、从未编程过 Python 或从未编程过的人。本书适合那些希望快速学习编程基础的人,以便能够专注于有趣的项目,也适合那些通过解决有意义的问题来测试自己理解新概念的人。《Python 快速入门》同样适合各个层级的教师,他们希望为学生提供一个基于项目的编程入门。如果你正在上大学并希望获得比课本更友好的 Python 入门,本书也可以让你的课程变得更加轻松。如果你正在寻找转行的机会,《Python 快速入门》也能帮助你顺利过渡到一个更具满足感的职业轨道。这本书已帮助了许多目标各异的读者,取得了很好的效果。
你可以期待学到什么?
本书的目的是让你成为一名优秀的程序员,特别是成为一名优秀的 Python 程序员。你将高效地学习并养成良好的编程习惯,同时在编程基础概念上打下坚实的基础。在完成《Python 快速入门》之后,你应该能够开始学习更高级的 Python 技巧,且你的下一个编程语言也会变得更容易掌握。
本书的第一部分,你将学习编写 Python 程序所需的基本编程概念。这些概念与几乎所有编程语言的入门内容相同。你将学习不同类型的数据以及如何在程序中存储数据。你将构建数据集合,如列表和字典,并以高效的方式遍历这些集合。你将学习如何使用while循环和if语句来测试某些条件,以便在条件为真时运行特定代码段,条件不为真时运行其他代码段——这种技巧帮助你自动化许多过程。
你将学习如何接受用户输入,使你的程序变得交互性强,并保持程序在用户希望的时间内运行。你将探索如何编写函数,使程序的某些部分可以重复使用,这样你只需要编写执行某些操作的代码块一次,然后根据需要多次使用这些代码。你接着将这个概念扩展到更复杂的行为,使用类来让相对简单的程序响应各种情况。你将学习如何编写能够优雅处理常见错误的程序。完成这些基本概念的学习后,你将使用所学内容编写越来越复杂的程序。最后,你将迈出成为中级程序员的第一步,学习如何为代码编写测试,以便在进一步开发程序时不必担心引入错误。第一部分的所有信息将为你承担更大、更复杂的项目做好准备。
第二部分,你将把第一部分所学的内容应用到三个项目中。你可以按照任何顺序做其中的一个或多个项目,选择最适合你的方式。在第一个项目中,第 12 至 14 章,你将创建一个类似太空侵略者的射击游戏,名为外星入侵,其中包括几个逐渐增加难度的游戏关卡。完成这个项目后,你应该能够顺利开发自己的 2D 游戏。即使你不打算成为一名游戏程序员,完成这个项目也是一个很好的方式,可以将第一部分学到的大部分知识结合起来。
第二个项目,在第 15 至 17 章中,将带你进入数据可视化的领域。数据科学家使用各种可视化技术来帮助理解大量信息。你将使用通过代码生成的数据集、从在线来源下载的数据集以及你的程序自动下载的数据集。完成这个项目后,你将能够编写程序,筛选大量数据集,并对多种不同类型的信息进行可视化表示。
在第三个项目中,在第 18 至 20 章,你将构建一个名为 Learning Log 的小型 web 应用程序。这个项目允许你对自己学习的特定主题信息进行有序记录。你将能够为不同的主题保留单独的日志,并允许其他人创建账户并开始自己的日志。你还将学习如何部署你的项目,让任何人都能从世界任何地方在线访问它。
在线资源
No Starch Press 在nostarch.com/python-crash-course-3rd-edition上提供了更多关于本书的信息。
我还在ehmatthes.github.io/pcc_3e上维护了一套丰富的补充资源。这些资源包括以下内容:
设置说明 在线的设置说明与书中的内容相同,但它们包括了可以点击的活跃链接,帮助你完成不同步骤。如果你遇到设置问题,请参考此资源。
更新 Python 和所有语言一样,正在不断发展。我会维护一套详细的更新记录,如果某些内容无法正常工作,请查看此处以了解指令是否有所更改。
练习题解答 你应该花大量时间独立尝试“自己动手”部分的练习。然而,如果你遇到困难无法进展,绝大多数练习的解答可以在网上找到。
备忘单 完整的可下载备忘单,便于快速查阅主要概念,也可以在网上找到。
为什么选择 Python?
每年,我都会考虑是否继续使用 Python,或者转向其他语言,也许是编程领域中更新的语言。但出于多种原因,我仍然专注于 Python。Python 是一种极其高效的语言:你的程序可以用比许多其他语言更少的代码行完成更多的工作。Python 的语法也有助于你编写“简洁”的代码。与其他语言相比,你的代码将更易于阅读、调试、扩展和构建。
人们使用 Python 来做很多事情:制作游戏、构建 web 应用程序、解决商业问题、以及在各种有趣的公司开发内部工具。Python 在科学领域的应用也非常广泛,无论是学术研究还是应用工作。
我继续使用 Python 的最重要原因之一是 Python 社区,它包括一个非常多样且欢迎每个人的群体。社区对程序员来说至关重要,因为编程不是一项孤立的工作。即使是最有经验的程序员,大多数时候也需要向已经解决类似问题的人寻求建议。拥有一个紧密联系且互相支持的社区,对于帮助你解决问题至关重要,Python 社区完全支持那些把 Python 作为第一门编程语言学习的人,或是那些拥有其他语言背景转向 Python 的人。
Python 是一门很棒的语言,快来开始吧!
第一部分:基础知识
本书的第一部分将教授你编写 Python 程序所需的基本概念。这些概念在所有编程语言中都是通用的,因此它们将在你作为程序员的一生中始终有用。
在第一章中,你将安装 Python,并运行你的第一个程序,它会将Hello world!消息打印到屏幕上。
在第二章中,你将学习如何将信息赋值给变量,并处理文本和数值数据。
第三章和第四章介绍了列表。列表可以将你想要的所有信息存储在一个地方,使你能够高效地处理这些数据。你将能够在几行代码中处理数百、数千甚至数百万个值。
在第五章中,你将使用if语句编写代码,当某些条件为真时以一种方式响应,当这些条件不为真时则以另一种方式响应。
第六章展示了如何使用 Python 的字典,它允许你在不同的信息之间建立联系。像列表一样,字典可以存储你需要的所有信息。
在第七章中,你将学习如何接受用户输入,使你的程序变得互动。你还将学习while循环,它会在某些条件为真时反复执行代码块。
在第八章中,你将编写函数,函数是执行特定任务的命名代码块,可以在需要时运行。
第九章介绍了类,它允许你模拟现实世界中的对象。你将编写代码来表示狗、猫、人、车、火箭等等。
第十章展示了如何处理文件和错误,以防止程序意外崩溃。你将学会在程序关闭前保存数据,并在程序再次运行时读取数据。你将了解 Python 的异常,它们让你能够预见错误并使程序优雅地处理这些错误。
在第十一章中,你将学习如何为代码编写测试,以确保程序按预期工作。因此,你可以在不担心引入新错误的情况下扩展程序。编写测试是帮助你从初学者过渡到中级程序员的第一项技能。
第一章:入门

在这一章中,你将运行你的第一个 Python 程序,hello_world.py。首先,你需要检查计算机上是否安装了最新版本的 Python;如果没有,你将安装它。你还将安装一个文本编辑器来处理你的 Python 程序。文本编辑器会识别 Python 代码,并在你编写时突出显示代码部分,使你更容易理解代码的结构。
设置你的编程环境
Python 在不同的操作系统上略有不同,因此你需要记住一些注意事项。在接下来的章节中,我们将确保 Python 在你的系统上正确设置。
Python 版本
每种编程语言都会随着新想法和新技术的出现而不断发展,Python 的开发者们也不断让这门语言变得更加多功能和强大。截至目前,本书的内容应该可以在 Python 3.9 或更高版本上运行,但目前最新版本是 Python 3.11。在本节中,我们将检查你的系统是否已经安装了 Python,以及是否需要安装更新的版本。附录 A 还包含了在各大操作系统上安装最新版本 Python 的详细信息。
运行 Python 代码片段
你可以在终端窗口中运行 Python 的解释器,允许你尝试 Python 代码的片段,而不必保存并运行整个程序。
在本书中,你将看到如下所示的代码片段:
>>> **print("Hello Python interpreter!")**
Hello Python interpreter!
三个尖括号(>>>)提示符,我们称之为Python 提示符,表示你应该使用终端窗口。粗体文本是你应该输入并通过按下 ENTER 键执行的代码。本书中的大部分示例是小型、独立的程序,你将在文本编辑器中运行它们,而不是通过终端,因为你将大部分代码编写在文本编辑器中。但有时,基本概念会通过一系列在 Python 终端会话中运行的代码片段来展示,以便更高效地演示特定概念。当你在代码列表中看到三个尖括号时,你正在查看来自终端会话的代码和输出。稍后我们将尝试在你的系统中运行解释器进行编码。
我们还将使用文本编辑器创建一个简单的程序,名为Hello World!,它已经成为学习编程的必备程序。在编程界有一个长期的传统,认为将Hello world!消息输出到屏幕作为新语言的第一个程序,会带来好运。如此简单的程序有着非常实际的用途。如果它在你的系统上正确运行,那么你编写的任何 Python 程序也应该能够正常运行。
关于 VS Code 编辑器
VS Code 是一个功能强大、专业级的文本编辑器,免费且适合初学者。VS Code 非常适合简单和复杂的项目,因此如果你在学习 Python 的过程中熟悉使用它,你可以继续使用它来处理更大更复杂的项目。VS Code 可以安装在所有现代操作系统上,并且支持包括 Python 在内的大多数编程语言。
附录 B 提供了关于其他文本编辑器的信息。如果你对其他选项感兴趣,可以在此时浏览该附录。如果你想快速开始编程,可以使用 VS Code 来启动。然后,在你积累了一些编程经验后,你可以考虑使用其他编辑器。在这一章中,我将带你完成在你的操作系统上安装 VS Code 的过程。
不同操作系统上的 Python
Python 是一种跨平台编程语言,这意味着它可以在所有主流操作系统上运行。你编写的任何 Python 程序应该可以在任何安装了 Python 的现代计算机上运行。然而,在不同操作系统上设置 Python 的方法略有不同。
在这一部分中,你将学习如何在你的系统上设置 Python。你将首先检查是否安装了最新版本的 Python,如果没有安装,则进行安装。然后,你将安装 VS Code。这是每个操作系统中唯一不同的两步。
在接下来的章节中,你将运行 hello_world.py 并解决任何无法正常工作的部分。我将为每个操作系统带你完成这个过程,这样你就能拥有一个可靠的 Python 编程环境。
Windows 上的 Python
Windows 通常不会预装 Python,因此你可能需要先安装它,然后再安装 VS Code。
安装 Python
首先,检查你的系统是否已安装 Python。通过在开始菜单中输入 command 并点击 命令提示符 应用来打开命令窗口。在终端窗口中,输入小写的 python。如果你看到 Python 提示符(>>>),则表示 Python 已安装在你的系统中。如果你看到错误信息,告诉你 python 不是一个已识别的命令,或者如果 Microsoft Store 被打开,那么 Python 就没有安装。如果 Microsoft Store 打开,关闭它;最好从官方渠道下载安装程序,而不是使用 Microsoft 版本。
如果你的系统上没有安装 Python,或者你看到的版本早于 Python 3.9,那么你需要下载 Windows 版本的 Python 安装程序。访问 python.org,将鼠标悬停在 Downloads 链接上。你应该看到一个按钮,点击该按钮即可自动下载适合你系统的最新版本的 Python 安装程序。下载完成后,运行安装程序。确保你选择了 Add Python to PATH 选项,这将使配置系统更加方便。图 1-1 显示了该选项已被选中。

图 1-1:确保你勾选了标有 Add Python to PATH 的复选框。
在终端会话中运行 Python
打开一个新的命令窗口并输入小写的 python。你应该看到 Python 提示符(>>>),这意味着 Windows 找到了你刚安装的 Python 版本。
C:\> **python**
Python 3.`x`.`x` (main, Jun . . . , 13:29:14) [MSC v.1932 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>>
在你的 Python 会话中输入以下命令:
>>> **print("Hello Python interpreter!")**
Hello Python interpreter!
>>>
你应该看到输出 Hello Python interpreter!。每当你想运行一段 Python 代码时,只需打开命令窗口并启动一个 Python 终端会话。要关闭终端会话,按 CTRL-Z 然后按 ENTER,或者输入命令 exit()。
安装 VS Code
你可以在 code.visualstudio.com 下载 VS Code 安装程序。点击 Download for Windows 按钮并运行安装程序。跳过接下来的 macOS 和 Linux 部分,按照第 9 页的“运行 Hello World 程序”步骤操作。
macOS 上的 Python
在最新版本的 macOS 上,Python 默认没有安装,因此如果你还没有安装,你需要进行安装。在本节中,你将安装最新版本的 Python,然后安装 VS Code 并确保其正确配置。
检查是否安装了 Python 3
通过访问 应用程序▶实用工具▶终端 来打开终端窗口。你也可以按 ⌘-空格键,输入 terminal,然后按回车键。要检查是否安装了足够新的 Python 版本,输入 python3。你很可能会看到一条关于安装 命令行开发工具 的消息。最好在安装 Python 后再安装这些工具,所以如果出现此消息,请取消弹出的窗口。
如果输出显示你安装了 Python 3.9 或更高版本,可以跳过下一节,直接进入“在终端会话中运行 Python”。如果你看到任何早于 Python 3.9 的版本,请按照下一节的说明安装最新版本。
请注意,在 macOS 上,每当你在本书中看到 python 命令时,你需要使用 python3 命令来确保你正在使用 Python 3。大多数 macOS 系统中,python 命令要么指向一个过时的 Python 版本,只应由内部系统工具使用,要么指向空白并产生错误信息。
安装最新版本的 Python
你可以在 python.org 找到适用于你的系统的 Python 安装程序。将鼠标悬停在 Download 链接上,你应该会看到一个下载最新版本 Python 的按钮。点击该按钮,应该会自动开始下载适用于你系统的正确安装程序。文件下载后,运行该安装程序。
安装程序运行后,应该会出现一个 Finder 窗口。双击 Install Certificates.command 文件。运行此文件将帮助你更轻松地安装你在现实项目中需要的附加库,包括本书后半部分的项目。
在终端会话中运行 Python
你现在可以通过打开一个新的终端窗口并输入 python3 来尝试运行 Python 代码片段:
$ **python3**
Python 3.`x`.`x` (v3.11.0:eb0004c271, Jun . . . , 10:03:01)
[Clang 13.0.0 (clang-1300.0.29.30)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>
该命令会启动 Python 终端会话。你应该会看到 Python 提示符(>>>),这意味着 macOS 已经找到了你刚刚安装的 Python 版本。
在终端会话中输入以下命令:
>>> **print("Hello Python interpreter!")**
Hello Python interpreter!
>>>
你应该会看到消息 Hello Python interpreter!,它应该会直接在当前终端窗口中打印出来。你可以通过按 CTRL-D 或输入命令 exit() 来关闭 Python 解释器。
安装 VS Code
要安装 VS Code 编辑器,你需要在 code.visualstudio.com 下载安装程序。点击下载按钮,然后打开Finder窗口,进入下载文件夹。将Visual Studio Code安装程序拖到应用程序文件夹,然后双击安装程序运行。
跳过接下来的关于 Linux 上 Python 的部分,按照第 9 页“运行 Hello World 程序”中的步骤进行操作。
Linux 上的 Python
Linux 系统专为编程设计,因此大多数 Linux 计算机上已经安装了 Python。编写和维护 Linux 的人期望你在某个时候自己进行编程,并鼓励你这样做。因此,开始编程时几乎没有需要安装的内容,只有少量的设置需要更改。
检查你的 Python 版本
通过运行系统上的终端应用程序打开终端窗口(在 Ubuntu 中,你可以按 CTRL-ALT-T)。要查找安装的 Python 版本,输入小写的python3。当 Python 安装完成时,这个命令会启动 Python 解释器。你应该能看到显示已安装 Python 版本的输出。你还应该看到一个 Python 提示符(>>>),在这里你可以开始输入 Python 命令:
$ **python3**
Python 3.10.4 (main, Apr . . . , 09:04:19) [GCC 11.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>
这个输出表示 Python 3.10.4 是当前安装在这台计算机上的默认 Python 版本。当你看到这个输出时,按 CTRL-D 或输入exit()以退出 Python 提示符并返回终端提示符。每当你在本书中看到python命令时,应该输入python3代替。
你需要 Python 3.9 或更高版本才能运行本书中的代码。如果你系统上安装的 Python 版本低于 3.9,或者如果你想更新到当前可用的最新版本,请参考附录 A 中的说明。
在终端会话中运行 Python
你可以通过打开终端并输入python3来尝试运行 Python 代码片段,正如你在检查版本时所做的那样。再次执行此操作,当 Python 正在运行时,在终端会话中输入以下行:
>>> **print("Hello Python interpreter!")**
Hello Python interpreter!
>>>
消息应该直接显示在当前的终端窗口中。记住,你可以通过按 CTRL-D 或输入命令exit()来关闭 Python 解释器。
安装 VS Code
在 Ubuntu Linux 上,你可以通过 Ubuntu 软件中心安装 VS Code。点击菜单中的 Ubuntu 软件图标,然后搜索vscode。点击名为Visual Studio Code的应用(有时也叫code),然后点击安装。安装完成后,搜索你的系统,找到VS Code,然后启动该应用。
运行 Hello World 程序
在安装了最新版本的 Python 和 VS Code 后,你几乎准备好在文本编辑器中运行你的第一个 Python 程序了。但在此之前,你需要为 VS Code 安装 Python 扩展。
为 VS Code 安装 Python 扩展
VS Code 支持多种编程语言;作为 Python 开发者,想要最大限度地发挥它的作用,你需要安装 Python 扩展。这个扩展提供了编写、编辑和运行 Python 程序的支持。
要安装 Python 扩展,点击 VS Code 应用程序左下角的齿轮图标。在出现的菜单中,点击 扩展。在搜索框中输入 python,然后点击 Python 扩展。(如果你看到多个名为 Python 的扩展,请选择由 Microsoft 提供的那个。)点击 安装,并安装任何你的系统需要的附加工具来完成安装。如果看到提示需要安装 Python,而你已经安装了,可以忽略此消息。
运行 hello_world.py
在编写第一个程序之前,在桌面上创建一个名为 python_work 的文件夹来存放你的项目。最好在文件和文件夹名称中使用小写字母和下划线代替空格,因为 Python 使用这种命名惯例。你可以在桌面以外的地方创建此文件夹,但如果直接将 python_work 文件夹保存在桌面上,之后的一些步骤会更容易跟随。
打开 VS Code,如果 Get Started 标签页还未关闭,请关闭它。通过点击 文件▶新建文件,或者按 CTRL-N(在 macOS 上为 ⌘-N)来创建一个新文件。将文件保存为 hello_world.py,保存在你的 python_work 文件夹中。扩展名 .py 告诉 VS Code 你的文件是用 Python 编写的,并且告诉它如何运行程序并以有帮助的方式高亮显示文本。
保存文件后,在编辑器中输入以下内容:
hello_world.py
print("Hello Python world!")
要运行程序,选择 运行▶无调试运行,或者按 CTRL-F5。VS Code 窗口底部应该出现一个终端屏幕,显示你程序的输出:
Hello Python world!
你可能会看到一些额外的输出,显示用于运行程序的 Python 解释器。如果你想简化显示的信息,只看到程序的输出,请参阅附录 B。附录 B 中也提供了关于如何更高效使用 VS Code 的一些有用建议。
如果你没有看到这个输出,可能程序出了问题。检查你输入的每个字符。你是否不小心把 print 大小写写错了?你是否忘记了一个或两个引号或括号?编程语言要求非常具体的语法,如果你没有遵循,程序就会出错。如果你无法运行程序,请参阅下一节中的建议。
故障排除
如果无法运行 hello_world.py,这里有一些可以尝试的解决办法,它们也是解决任何编程问题的常见方法:
-
当程序包含重大错误时,Python 会显示一个traceback(回溯),这是一个错误报告。Python 会浏览文件并尝试识别问题。检查回溯,它可能会给你一个线索,指示是什么问题导致程序无法运行。
-
离开电脑,休息一下,然后再试一次。记住,编程中语法非常重要,因此像引号不匹配或括号不匹配这样的简单问题就可能导致程序无法正常运行。重新阅读本章相关部分,检查你的代码,试着找到问题所在。
-
从头开始。你可能不需要卸载任何软件,但删除你的hello_world.py文件并从头创建它可能更有意义。
-
请别人按照本章中的步骤在你的电脑或其他电脑上操作,仔细观察他们的操作。你可能会错过一个小步骤,而别人恰好会发现。
-
请参阅附录 A 中的额外安装说明;附录中包含的一些细节可能会帮助你解决问题。
-
找一个懂 Python 的人,向他们求助帮助你完成设置。如果你问问周围的人,你可能会意外地发现有认识的人会使用 Python。
-
本章中的设置说明也可以通过本书的伴随网站访问:
ehmatthes.github.io/pcc_3e。在线版本的这些说明可能会更有效,因为你可以直接剪切粘贴代码并点击链接获取所需资源。 -
在线寻求帮助。附录 C 提供了许多资源,例如论坛和在线聊天网站,你可以在这些地方向已经解决过你当前问题的人寻求解决方案。
永远不要担心打扰经验丰富的程序员。每个程序员都曾经卡在某个点上,而且大多数程序员都很乐意帮助你正确设置系统。只要你能清楚地说明你在做什么,已经尝试了哪些方法,得到了什么结果,那么很有可能有人能够帮助你。正如在导言中提到的,Python 社区对初学者非常友好和欢迎。
Python 应该能在任何现代计算机上良好运行。最初的设置问题可能令人沮丧,但完全值得解决。一旦你成功运行hello_world.py,你就可以开始学习 Python,并且你的编程工作将变得更加有趣和令人满足。
从终端运行 Python 程序
你将大部分程序直接在文本编辑器中运行。但是,有时从终端运行程序更有用。例如,你可能想运行一个现有的程序,而无需打开它进行编辑。
只要你知道如何访问存储程序文件的目录,你可以在任何安装了 Python 的系统上进行此操作。为了尝试这个,确保你已将hello_world.py文件保存在桌面上的python_work文件夹中。
在 Windows 上
你可以使用终端命令cd,即切换目录,在命令窗口中浏览你的文件系统。命令dir,即目录,会显示当前目录下所有存在的文件。
打开一个新的终端窗口,输入以下命令来运行hello_world.py:
C:\> **cd Desktop\python_work**
C:\Desktop\python_work> **dir**
hello_world.py
C:\Desktop\python_work> **python hello_world.py**
Hello Python world!
首先,使用cd命令导航到python_work文件夹,该文件夹位于Desktop文件夹中。接下来,使用dir命令确认hello_world.py在该文件夹中。然后,使用命令python hello_world.py来运行该文件。
大多数程序可以直接从编辑器运行。然而,随着工作变得更加复杂,你可能会希望从终端运行一些程序。
在 macOS 和 Linux 上
在终端会话中运行 Python 程序在 Linux 和 macOS 上是相同的。你可以使用终端命令cd,即切换目录,在终端会话中浏览你的文件系统。命令ls,即列出,会显示当前目录下所有非隐藏的文件。
打开一个新的终端窗口,输入以下命令来运行hello_world.py:
~$ **cd Desktop/python_work/**
~/Desktop/python_work$ **ls**
hello_world.py
~/Desktop/python_work$ **python3 hello_world.py**
Hello Python world!
首先,使用cd命令导航到python_work文件夹,该文件夹位于Desktop文件夹中。接下来,使用ls命令确认hello_world.py在该文件夹中。然后,使用命令python3 hello_world.py来运行该文件。
大多数程序可以直接从编辑器运行。但随着工作变得更加复杂,你可能会希望从终端运行一些程序。
小结
在本章中,你了解了 Python 的基本知识,如果你的系统尚未安装 Python,你也安装了它。你还安装了一个文本编辑器,以便更轻松地编写 Python 代码。你在终端会话中运行了 Python 代码片段,并运行了第一个程序hello_world.py。你可能还学到了一些故障排除的知识。
在下一章中,你将学习可以在 Python 程序中使用的不同数据类型,并且你也将开始使用变量。
第二章:变量和简单数据类型

在本章中,你将学习到可以在 Python 程序中使用的各种数据类型。你还将学习如何使用变量来表示程序中的数据。
当你运行hello_world.py时,会发生什么
让我们仔细看看,当你运行hello_world.py时,Python 到底做了什么。事实证明,即使是运行一个简单的程序,Python 也要做相当多的工作:
hello_world.py
print("Hello Python world!")
当你运行这段代码时,你应该会看到以下输出:
Hello Python world!
当你运行文件hello_world.py时,文件名以.py结尾,表示该文件是一个 Python 程序。然后,编辑器通过Python 解释器运行该文件,解释器逐行读取程序并确定每个单词的含义。例如,当解释器看到print后跟括号时,它会将括号内的内容打印到屏幕上。
在编写程序时,编辑器会以不同的方式突出显示程序的不同部分。例如,它会识别 print() 是一个函数的名称,并将该词以一种颜色显示。它会识别 "Hello Python world!" 不是 Python 代码,并将该短语以不同的颜色显示。此功能称为 语法高亮,当你开始编写自己的程序时非常有用。
变量
让我们尝试在 hello_world.py 中使用一个变量。为文件开头添加一行,并修改第二行:
hello_world.py
message = "Hello Python world!"
print(message)
运行这个程序看看会发生什么。你应该看到与之前相同的输出:
Hello Python world!
我们已经添加了一个名为 message 的变量。每个变量都与一个 值 相关联,这个值就是与该变量相关的信息。在此情况下,值为 "Hello Python world!" 文本。
添加变量会稍微增加 Python 解释器的工作量。当它处理第一行时,会将变量 message 与 "Hello Python world!" 文本关联。当它处理到第二行时,会将与 message 相关联的值打印到屏幕上。
让我们通过修改 hello_world.py 来扩展这个程序,使其打印第二条消息。为 hello_world.py 添加一个空行,然后再添加两行新代码:
message = "Hello Python world!"
print(message)
message = "Hello Python Crash Course world!"
print(message)
现在,当你运行 hello_world.py 时,你应该会看到两行输出:
Hello Python world!
Hello Python Crash Course world!
你可以随时更改程序中变量的值,Python 会始终跟踪其当前值。
命名和使用变量
在使用 Python 变量时,你需要遵守一些规则和准则。违反其中某些规则会导致错误;其他准则则有助于你编写更易于阅读和理解的代码。编写变量时请务必记住以下规则:
-
变量名只能包含字母、数字和下划线。它们可以以字母或下划线开头,但不能以数字开头。例如,你可以将变量命名为
message_1,但不能命名为1_message。 -
变量名中不允许有空格,但可以使用下划线分隔变量名中的单词。例如,
greeting_message可行,但greeting message会导致错误。 -
避免使用 Python 的关键字和函数名作为变量名。例如,不能使用
print作为变量名;Python 已经将其保留为特定的程序用途。(请参阅第 466 页的“Python 关键字和内置函数”) -
变量名应该简洁且具有描述性。例如,
name比n更好,student_name比s_n更好,name_length比length_of_persons_name更好。 -
在使用小写字母 l 和大写字母 O 时要小心,因为它们可能会与数字 1 和 0 混淆。
学习如何创建好的变量名可能需要一些练习,尤其是当你的程序变得越来越有趣和复杂时。随着你写更多的程序,并开始阅读其他人的代码,你会越来越擅长为变量命名并赋予它们有意义的名字。
避免在使用变量时出现名称错误
每个程序员都会犯错误,大多数程序员每天都会犯错误。虽然优秀的程序员也可能会产生错误,但他们知道如何高效地应对这些错误。让我们看看你可能会在早期犯的一个错误,并学习如何修复它。
我们将编写一些故意生成错误的代码。输入以下代码,其中包含拼写错误的单词mesage,该单词用粗体显示:
message = "Hello Python Crash Course reader!"
print(**mesage**)
当程序出现错误时,Python 解释器会尽力帮助你找出问题所在。如果程序无法成功运行,解释器会提供回溯信息。回溯是记录解释器在执行代码时遇到问题的位置。以下是 Python 提供的回溯示例,当你不小心拼写错了变量名时:
Traceback (most recent call last):
❶ File "hello_world.py", line 2, in <module>
❷ print(mesage)
^^^^^^
❸ NameError: name 'mesage' is not defined. Did you mean: 'message'?
输出报告了文件 hello_world.py 第 2 行发生了错误 ❶。解释器显示了这一行 ❷,帮助我们快速找到错误,并告诉我们它发现了什么类型的错误 ❸。在这个例子中,它发现了一个 名称错误,并报告说正在打印的变量 mesage 尚未定义。Python 无法识别提供的变量名。名称错误通常意味着我们要么忘记在使用变量之前设置它的值,要么在输入变量名时拼写错误。如果 Python 找到一个与它无法识别的变量名相似的名字,它会询问这是否是你想使用的名称。
在这个例子中,我们在第二行的变量名 message 中漏掉了字母 s。Python 解释器不会检查你的代码拼写,但它会确保变量名拼写一致。例如,当我们在定义变量的那一行错误拼写 message 时,看看会发生什么:
mesage = "Hello Python Crash Course reader!"
print(mesage)
在这种情况下,程序成功运行!
Hello Python Crash Course reader!
变量名称匹配,因此 Python 没有问题。编程语言是严格的,但它们忽略了拼写的好坏。因此,当你在创建变量名并编写代码时,你不需要考虑英语的拼写和语法规则。
许多编程错误是程序中单行的简单字符错误。如果你发现自己花了很长时间寻找这些错误,知道你并不孤单。许多经验丰富且才华横溢的程序员也会花几个小时寻找这些微小的错误。尽量笑对它,继续前进,知道在你的编程生涯中,这种事情会经常发生。
变量是标签
变量通常被描述为可以存储值的盒子。这个概念在你第一次使用变量时可能会有所帮助,但它并不准确地描述了变量在 Python 中是如何表示的。更好的方式是将变量视为你可以分配给值的标签。你也可以说,变量引用了某个特定的值。
这种区分在你最初的程序中可能并不重要,但早点学习总比晚学要好。到某个时候,你可能会遇到变量的意外行为,而准确理解变量的工作原理将帮助你找出代码中的问题。
字符串
由于大多数程序会定义和收集某种数据,然后对其执行有用的操作,因此分类不同类型的数据很有帮助。我们将要查看的第一个数据类型是字符串。字符串乍看之下很简单,但你可以以多种方式使用它们。
字符串 是一系列字符。Python 中任何引号内的内容都被视为字符串,你可以像这样使用单引号或双引号包围字符串:
"This is a string."
'This is also a string.'
这种灵活性允许你在字符串中使用引号和撇号:
'I told my friend, "Python is my favorite language!"'
"The language 'Python' is named after Monty Python, not the snake."
"One of Python's strengths is its diverse and supportive community."
让我们探索一下使用字符串的一些方法。
使用方法更改字符串的大小写
你可以对字符串执行的最简单操作之一是更改字符串中单词的大小写。看看下面的代码,尝试理解发生了什么:
name.py
name = "ada lovelace"
print(name.title())
将此文件保存为 name.py 然后运行,你应该会看到如下输出:
Ada Lovelace
在这个例子中,变量 name 引用了小写字符串 "ada lovelace"。方法 title() 出现在 print() 调用中的变量后面。方法 是 Python 可以对一块数据执行的操作。name 后的点号(.)告诉 Python 执行 title() 方法对变量 name 进行操作。每个方法后面都有一对括号,因为方法通常需要额外的信息来完成工作。这些信息在括号内提供。title() 方法不需要额外的信息,因此其括号是空的。
title() 方法将每个单词的首字母改为大写,这对于将名字视作信息非常有用。比如,你可能希望你的程序能够识别输入值 Ada、ADA 和 ada 为相同的名字,并将它们都显示为 Ada。
还有一些其他有用的方法可以处理大小写。例如,你可以将字符串转换为全大写或全小写字母,像这样:
name = "Ada Lovelace"
print(name.upper())
print(name.lower())
这将显示以下内容:
ADA LOVELACE
ada lovelace
lower() 方法在存储数据时特别有用。你通常不希望完全信任用户提供的大小写,因此你会将字符串转换为小写再存储。然后当你想显示信息时,你会使用最适合每个字符串的大小写形式。
在字符串中使用变量
在某些情况下,你可能想在字符串中使用变量的值。例如,你可能希望分别使用两个变量来表示名字和姓氏,然后将这些值组合在一起显示某人的全名:
full_name.py
first_name = "ada"
last_name = "lovelace"
❶ full_name = f"{first_name} {last_name}"
print(full_name)
要将变量的值插入到字符串中,需在开头的引号前面立即加上字母f ❶。将大括号包围你想在字符串中使用的变量名称。Python 会在显示字符串时,将每个变量替换为它的值。
这些字符串被称为f-strings。f代表format,因为 Python 通过将大括号内的变量名替换为其值来格式化字符串。前面代码的输出是:
ada lovelace
你可以用 f-strings 做很多事情。例如,你可以使用 f-strings 通过与变量相关联的信息来组成完整的消息,如下所示:
first_name = "ada"
last_name = "lovelace"
full_name = f"{first_name} {last_name}"
❶ print(f"Hello, {full_name.title()}!")
全名被用在一个句子中来问候用户 ❶,而title()方法将名称转换为标题格式。此代码返回一个简单而格式化良好的问候语:
Hello, Ada Lovelace!
你也可以使用 f-strings 来组合一条消息,然后将整个消息赋值给一个变量:
first_name = "ada"
last_name = "lovelace"
full_name = f"{first_name} {last_name}"
❶ message = f"Hello, {full_name.title()}!"
❷ print(message)
这段代码同样显示消息Hello, Ada Lovelace!,但通过将消息赋值给变量 ❶,我们使得最后的print()调用变得更加简洁 ❷。
使用制表符或换行符向字符串添加空白
在编程中,空白字符指的是任何非打印字符,如空格、制表符和行尾符号。你可以使用空白字符来组织输出,使其更易于用户阅读。
要在文本中添加制表符,使用字符组合\t:
>>> **print("Python")**
Python
>>> **print("\tPython")**
Python
要在字符串中添加换行符,使用字符组合\n:
>>> **print("Languages:\nPython\nC\nJavaScript")**
Languages:
Python
C
JavaScript
你还可以在一个字符串中结合制表符和换行符。字符串"\n\t"告诉 Python 跳到新的一行,并在新的一行开始时插入一个制表符。下面的示例展示了如何用单行字符串生成四行输出:
>>> **print("Languages:\n\tPython\n\tC\n\tJavaScript")**
Languages:
Python
C
JavaScript
换行符和制表符将在接下来的两章中非常有用,当你开始用少量代码生成许多行输出时。
去除空白字符
额外的空白字符在你的程序中可能会让人困惑。对于程序员来说,'python'和'python '看起来几乎是一样的。但对于程序来说,它们是两个不同的字符串。Python 会检测到'python '中的额外空格,并将其视为重要,除非你告诉它不这么做。
思考空白字符非常重要,因为你通常需要比较两个字符串来判断它们是否相同。例如,一个重要的情况可能是检查人们登录网站时的用户名。额外的空白字符在很多简单情况下也会让人困惑。幸运的是,Python 让你可以轻松地去除用户输入数据中的额外空白。
Python 可以查找字符串左右两侧的额外空白字符。为了确保字符串右侧没有空白字符,可以使用rstrip()方法:
❶ >>> **favorite_language = 'python '**
❷ >>> **favorite_language**
'python '
❸ >>> **favorite_language.rstrip()**
'python'
❹ >>> **favorite_language**
'python '
与 favorite_language 变量 ❶ 关联的值包含了字符串末尾的额外空白字符。当你在终端会话中请求这个值时,你可以看到末尾有一个空格 ❷。当 rstrip() 方法作用于变量 favorite_language ❸ 时,这个额外的空格会被移除。然而,这只是暂时移除。如果你再次请求 favorite_language 的值,字符串会和输入时一样,包括多余的空白字符 ❹。
要永久移除字符串中的空白字符,你需要将去除空白后的值与变量名关联:
>>> **favorite_language = 'python '**
❶ >>> **favorite_language = favorite_language.rstrip()**
>>> **favorite_language**
'python'
要去除字符串中的空白字符,你可以先去除右侧的空白字符,然后将这个新值与原始变量关联 ❶。在编程中,经常会修改变量的值。这就是如何在程序执行过程中或者响应用户输入时更新变量的值。
你也可以使用 lstrip() 方法去除字符串左侧的空白字符,或者使用 strip() 一次性去除两侧的空白字符:
❶ >>> **favorite_language = ' python '**
❷ >>> **favorite_language.rstrip()**
' python'
❸ >>> **favorite_language.lstrip()**
'python '
❹ >>> **favorite_language.strip()**
'python'
在这个示例中,我们从一个字符串开始,该字符串的开头和结尾有空白字符 ❶。接着,我们移除右侧的额外空白字符 ❷、左侧的空白字符 ❸,以及两侧的空白字符 ❹。通过尝试这些去除空白字符的函数,你可以熟悉如何处理字符串。在现实中,这些去除空白的函数通常用于清理用户输入的数据,确保它们在程序中存储之前是干净的。
去除前缀
在处理字符串时,另一个常见的任务是去除前缀。以带有常见前缀 https:// 的 URL 为例。我们希望去除这个前缀,这样就可以专注于用户需要在地址栏中输入的 URL 部分。以下是如何做到这一点:
>>> nostarch_url = 'https://nostarch.com'
>>> nostarch_url.removeprefix('https://')
'nostarch.com'
输入变量名,后跟一个点,然后是方法 removeprefix()。在括号内,输入你想从原始字符串中移除的前缀。
与去除空白字符的方法类似,removeprefix() 不会改变原始字符串。如果你希望保留去掉前缀后的新值,可以将其重新赋值给原变量,或者赋值给一个新变量:
>>> simple_url = nostarch_url.removeprefix('https://')
当你在地址栏中看到一个 URL,且 https:// 部分没有显示时,浏览器可能在后台使用了类似 removeprefix() 的方法。
避免字符串中的语法错误
你可能会经常看到的一种错误是语法错误。语法错误 发生在 Python 无法将程序的某一部分识别为有效的 Python 代码时。例如,如果你在单引号中使用了撇号,程序就会报错。这是因为 Python 会把第一个单引号和撇号之间的内容当作字符串处理,然后试图将剩余的文本解释为 Python 代码,导致错误。
下面是如何正确使用单引号和双引号。将这个程序保存为 apostrophe.py 然后运行它:
apostrophe.py
message = "One of Python's strengths is its diverse community."
print(message)
单引号出现在一对双引号内部,所以 Python 解释器能够正确地读取字符串:
One of Python's strengths is its diverse community.
然而,如果你使用单引号,Python 就无法识别字符串应该在哪里结束:
message = 'One of Python's strengths is its diverse community.'
print(message)
你将看到以下输出:
File "apostrophe.py", line 1
message = 'One of Python's strengths is its diverse community.'
❶ ^
SyntaxError: unterminated string literal (detected at line 1)
在输出中,你可以看到错误发生在最后一个单引号之后 ❶。这个语法错误表示解释器无法将代码中的某些内容识别为有效的 Python 代码,并且它认为问题可能出在未正确引用的字符串。错误的来源有很多种,我会在出现时指出一些常见的错误。当你学习编写正确的 Python 代码时,可能会经常遇到语法错误。语法错误也是最不具体的一种错误,因此它们可能很难识别和修正。如果你在解决特别棘手的错误时卡住了,可以参考附录 C 中的建议。
数字
在编程中,数字经常用于游戏中的得分、数据可视化、存储 Web 应用中的信息等。Python 根据数字的使用方式以几种不同的方式处理数字。我们首先来看 Python 如何处理整数,因为它们是最简单的。
整数
你可以在 Python 中对整数进行加法(+)、减法(-)、乘法(*)和除法(/)。
>>> **2 + 3**
5
>>> **3 - 2**
1
>>> **2 * 3**
6
>>> **3 / 2**
1.5
在终端会话中,Python 会直接返回运算结果。Python 使用两个乘号来表示指数:
>>> **3 ** 2**
9
>>> **3 ** 3**
27
>>> **10 ** 6**
1000000
Python 也支持运算顺序,因此你可以在一个表达式中使用多个运算符。你还可以使用括号来修改运算顺序,这样 Python 就能按照你指定的顺序计算表达式。例如:
>>> **2 + 3*4**
14
>>> **(2 + 3) * 4**
20
这些示例中的空格对 Python 计算表达式没有影响;它们只是帮助你在阅读代码时更快速地识别优先级较高的运算。
浮点数
Python 将任何带有小数点的数字称为 浮点数。这个术语在大多数编程语言中都使用,它指的是小数点可以出现在数字的任何位置。每种编程语言都必须精心设计,以正确地处理小数数字,使得数字无论小数点出现在什么位置都能正确表现。
大部分情况下,你可以使用浮点数而不需要担心它们的表现。只需输入你想要使用的数字,Python 很可能会按你预期的方式进行计算:
>>> **0.1 + 0.1**
0.2
>>> **0.2 + 0.2**
0.4
>>> **2 * 0.1**
0.2
>>> **2 * 0.2**
0.4
然而,请注意,有时你会得到一个任意的小数位数作为答案:
>>> **0.2 + 0.1**
0.30000000000000004
>>> **3 * 0.1**
0.30000000000000004
这种情况在所有语言中都会发生,通常不需要担心。Python 尝试以尽可能精确的方式表示结果,这有时很困难,因为计算机需要内部表示数字。现在先忽略额外的小数位;当你在第二部分的项目中需要处理这些额外的位数时,你将学习如何应对。
整数和浮点数
当你将两个数字相除时,即使它们是整数并且结果是一个整数,你仍然会得到一个浮点数:
>>> **4/2**
2.0
如果你在其他操作中混合使用整数和浮点数,你也会得到一个浮点数:
>>> **1 + 2.0**
3.0
>>> **2 * 3.0**
6.0
>>> **3.0 ** 2**
9.0
Python 在任何涉及浮点数的操作中默认会使用浮点数,即使输出结果是一个整数。
数字中的下划线
当你编写长数字时,可以使用下划线将数字分组,使大数字更易读:
>>> **universe_age = 14_000_000_000**
当你打印一个使用下划线定义的数字时,Python 只会打印出数字本身:
>>> **print(universe_age)**
14000000000
Python 在存储这些类型的值时会忽略下划线。即使你没有将数字分为三位一组,值也不会受到影响。对 Python 来说,1000 和 1_000,以及 10_00 都是一样的。这一特性适用于整数和浮点数。
多重赋值
你可以使用一行代码为多个变量赋值。这有助于缩短你的程序并使其更易读;你通常在初始化一组数字时使用这种技巧。
例如,下面是如何将变量 x、y 和 z 初始化为零:
>>> **x, y, z = 0, 0, 0**
你需要使用逗号分隔变量名,并且对值也要做同样的处理,Python 会将每个值分配给相应的变量。只要值的数量与变量的数量相匹配,Python 会正确地进行匹配。
常量
常量 是一个在程序生命周期内其值保持不变的变量。Python 没有内建的常量类型,但 Python 程序员通过使用全大写字母来表示一个变量应视为常量且永远不应改变:
MAX_CONNECTIONS = 5000
当你想在代码中将一个变量视为常量时,可以将该变量的名称写成全大写字母。
注释
注释是大多数编程语言中非常有用的功能。到目前为止,你在程序中编写的所有内容都是 Python 代码。随着程序变得越来越长且复杂,你应该在程序中添加注释,描述你解决问题的整体思路。注释 允许你在程序中以口语化的语言编写说明。
如何编写注释?
在 Python 中,井号(#)表示注释。井号后面的所有内容都将被 Python 解释器忽略。例如:
comment.py
# Say hello to everyone.
print("Hello Python people!")
Python 会忽略第一行并执行第二行。
Hello Python people!
你应该写什么样的注释?
编写注释的主要原因是解释你的代码应该做什么,以及你如何使其工作。当你正在进行项目时,你会明白所有部分是如何配合工作的。但当你在一段时间后回到项目时,你很可能会忘记一些细节。你可以总是花时间去研究代码,弄明白各个部分应该如何工作,但编写好的注释可以通过清晰地总结你的整体思路,节省你时间。
如果你想成为一名专业程序员或与其他程序员合作,你应该编写有意义的评论。如今,大多数软件都是以协作的方式编写的,无论是由一家公司的员工团队,还是由一群人在一个开源项目中合作。技术熟练的程序员期望在代码中看到评论,因此最好从现在开始为你的程序添加描述性的评论。编写清晰、简洁的评论是你作为新程序员可以培养的最有益的习惯之一。
当你决定是否写评论时,问问自己是否在考虑了几种方法之后,才想出了一个合理的解决方案;如果是这样,那就写一个关于你解决方案的评论。比起回头为一段评论稀少的程序写评论,稍后删除多余的评论要容易得多。从现在开始,我将在本书的示例中使用评论,帮助解释代码部分。
Python 之禅
经验丰富的 Python 程序员会鼓励你避免复杂性,尽可能追求简单。Python 社区的哲学体现在 Tim Peters 的《Python 之禅》中。你可以通过在解释器中输入 import this 来查看这组简短的编写优秀 Python 代码的原则。我不会在这里完整地重现《Python 之禅》,但我会分享其中几行,帮助你理解它们为什么对你作为一个初学者非常重要。
>>> **import this**
The Zen of Python, by Tim Peters
Beautiful is better than ugly.
Python 程序员认同代码可以是美丽且优雅的这个概念。在编程中,人们是用来解决问题的。程序员一直尊重那些设计良好、高效甚至美丽的解决方案。当你对 Python 了解得更多,并用它写更多代码时,有一天可能会有人站在你肩膀上,说:“哇,那是一些美丽的代码!”
Simple is better than complex.
如果你在简单和复杂的解决方案之间有选择,而且两者都有效,那就选择简单的解决方案。你的代码将更容易维护,并且以后你和其他人也能更轻松地在这段代码上进行扩展。
Complex is better than complicated.
现实生活是混乱的,有时候简单的解决方案是无法实现的。在这种情况下,使用最简单的有效解决方案。
Readability counts.
即使你的代码很复杂,也要尽量使它易于阅读。当你在一个涉及复杂编码的项目中工作时,专注于为这些代码编写具有信息量的评论。
There should be one-- and preferably only one --obvious way to do it.
如果两个 Python 程序员被要求解决同一个问题,他们应该会提出相当兼容的解决方案。这并不是说编程中没有创造性的空间。相反,编程中有很多创造性空间!然而,编程的许多部分是利用小而常见的方法来解决较大、更具创意项目中的简单问题。你程序中的基础部分应该能让其他 Python 程序员理解。
Now is better than never.
你可以花费余生学习 Python 和编程的所有细节,但那样你将永远无法完成任何项目。不要试图编写完美的代码;编写能工作的代码,然后决定是改进该项目的代码,还是继续做新的项目。
当你继续进入下一章并开始深入探讨更复杂的话题时,试着保持这个简单明了的哲学理念。经验丰富的程序员会更尊重你的代码,并乐意为你提供反馈,并与你一起合作进行有趣的项目。
总结
在本章中,你学习了如何使用变量。你学会了使用描述性变量名,并在出现名称错误和语法错误时进行解决。你了解了什么是字符串,并学习了如何使用小写字母、大写字母和标题大小写显示字符串。你开始使用空格来整理输出,使其整洁,并学习了如何从字符串中删除不需要的元素。你开始使用整数和浮动小数,学习了处理数字数据的一些方法。你还学会了编写解释性注释,使你的代码更容易被你和他人阅读。最后,你阅读了保持代码尽可能简单的哲学理念,无论何时可能。
在第三章中,你将学习如何在数据结构中存储信息集合,这些数据结构被称为列表。你还将学习如何遍历列表,操作列表中的任何信息。
第三章:介绍列表

在本章及下一章中,你将学习什么是列表,以及如何开始处理列表中的元素。列表允许你将一组信息存储在一个地方,无论你有几个项目还是数百万个项目。列表是 Python 中最强大的功能之一,易于新程序员使用,并且它连接了编程中的许多重要概念。
什么是列表?
列表是按特定顺序排列的项目集合。你可以创建一个包含字母表的字母、从 0 到 9 的数字,或你家里所有人的名字的列表。你可以将任何想要的东西放入列表中,且列表中的项目不需要以任何特定方式相关。因为列表通常包含多个元素,所以最好将列表的名称设置为复数形式,比如 letters、digits 或 names。
在 Python 中,方括号([])表示一个列表,列表中的单个元素用逗号分隔。以下是一个简单的包含几种自行车的列表示例:
bicycles.py
bicycles = ['trek', 'cannondale', 'redline', 'specialized']
print(bicycles)
如果你要求 Python 打印一个列表,Python 会返回它对该列表的表示,包括方括号:
['trek', 'cannondale', 'redline', 'specialized']
因为这不是你希望用户看到的输出,让我们学习如何访问列表中的单个项目。
访问列表中的元素
列表是有序的集合,因此你可以通过告诉 Python 项的位置或索引来访问列表中的任何元素。要访问列表中的元素,写出列表名称,然后用方括号括起该项的索引。
例如,让我们从列表bicycles中提取第一辆自行车:
bicycles = ['trek', 'cannondale', 'redline', 'specialized']
print(bicycles[0])
当我们从列表中请求单个元素时,Python 只返回该元素,而不带方括号:
trek
这是你希望用户看到的结果:干净、整齐格式化的输出。
你还可以对列表中的任何元素使用第二章中的字符串方法。例如,你可以使用title()方法将元素'trek'格式化为更整洁的外观:
bicycles = ['trek', 'cannondale', 'redline', 'specialized']
print(bicycles[0].title())
这个例子产生的输出与前一个例子相同,不同之处在于'Trek'被大写了。
索引位置从 0 开始,而不是从 1 开始
Python 认为列表中的第一个项目位于位置 0,而不是位置 1。这在大多数编程语言中都是如此,原因与列表操作在较低层次上如何实现有关。如果你遇到意外结果,问问自己是否犯了一个简单但常见的错位错误。
列表中的第二项的索引是 1。使用这种计数系统,你可以通过从元素在列表中的位置减去 1 来获取任何你想要的元素。例如,要访问列表中的第四项,你请求索引为 3 的元素。
以下请求的是索引1和索引3处的自行车:
bicycles = ['trek', 'cannondale', 'redline', 'specialized']
print(bicycles[1])
print(bicycles[3])
这段代码返回列表中的第二和第四辆自行车:
cannondale
specialized
Python 有一种特殊的语法来访问列表中的最后一个元素。如果你请求索引-1的项,Python 总是返回列表中的最后一个元素:
bicycles = ['trek', 'cannondale', 'redline', 'specialized']
print(bicycles[-1])
这段代码返回值'specialized'。这种语法非常有用,因为你经常希望在不知道列表长度的情况下访问列表中的最后一个元素。这个约定也适用于其他负索引值。索引-2返回列表倒数第二项,索引-3返回倒数第三项,依此类推。
从列表中使用单个值
你可以像使用任何其他变量一样使用列表中的单个值。例如,你可以使用 f-string 根据列表中的一个值创建消息。
让我们试着从列表中提取第一辆自行车,并使用该值组成一条消息:
bicycles = ['trek', 'cannondale', 'redline', 'specialized']
message = f"My first bicycle was a {bicycles[0].title()}."
print(message)
我们使用bicycles[0]中的值构建一句话,并将其赋值给变量message。输出是关于列表中第一辆自行车的简单句子:
My first bicycle was a Trek.
修改、添加和删除元素
你创建的大多数列表都是动态的,这意味着你将构建一个列表,并在程序运行过程中添加和删除元素。例如,你可能会创建一个游戏,玩家需要把外星人从天上击落。你可以将初始的外星人集合存储在一个列表中,每次击落一个外星人时,就从列表中移除它。每当一个新的外星人出现在屏幕上时,你就将它添加到列表中。你的外星人列表将在游戏过程中不断增加和减少长度。
修改列表中的元素
修改元素的语法与访问列表中元素的语法相似。要更改某个元素,使用列表的名称,后跟你想更改的元素的索引,然后提供你希望该项具有的新值。
例如,假设我们有一个摩托车列表,列表中的第一项是'honda'。在列表创建后,我们可以更改第一项的值:
motorcycles.py
motorcycles = ['honda', 'yamaha', 'suzuki']
print(motorcycles)
motorcycles[0] = 'ducati'
print(motorcycles)
这里我们定义了列表motorcycles,并将'honda'作为第一项。然后我们将第一项的值更改为'ducati'。输出显示,第一项已被更改,而其余的列表保持不变:
['honda', 'yamaha', 'suzuki']
['ducati', 'yamaha', 'suzuki']
你可以更改列表中任何项的值,而不仅仅是第一项。
向列表添加元素
你可能会出于许多原因向列表添加新元素。例如,你可能想要在游戏中让新的外星人出现,向可视化中添加新数据,或者向你构建的网站中添加新的注册用户。Python 提供了几种向现有列表添加新数据的方法。
将元素附加到列表的末尾
向列表添加新元素的最简单方法是将该项附加到列表中。当你将一个元素附加到列表时,新的元素会被添加到列表的末尾。使用我们在前面示例中的同一个列表,我们将新元素'ducati'添加到列表的末尾:
motorcycles = ['honda', 'yamaha', 'suzuki']
print(motorcycles)
motorcycles.append('ducati')
print(motorcycles)
在这里,append()方法将'ducati'添加到列表的末尾,而不会影响列表中的其他元素:
['honda', 'yamaha', 'suzuki']
['honda', 'yamaha', 'suzuki', 'ducati']
append()方法使动态构建列表变得非常简单。例如,你可以从一个空列表开始,然后使用一系列append()调用将项添加到列表中。使用一个空列表,我们将元素'honda'、'yamaha'和'suzuki'添加到列表中:
motorcycles = []
motorcycles.append('honda')
motorcycles.append('yamaha')
motorcycles.append('suzuki')
print(motorcycles)
结果列表与前面的示例中的列表完全相同:
['honda', 'yamaha', 'suzuki']
以这种方式构建列表非常常见,因为通常在程序运行之前你无法知道用户想要存储的数据。为了让用户掌控,首先定义一个空列表来保存用户的值。然后将每个新提供的值附加到你刚创建的列表中。
向列表中插入元素
你可以通过使用insert()方法将新元素添加到列表中的任何位置。你通过指定新元素的索引和值来实现这一点:
motorcycles = ['honda', 'yamaha', 'suzuki']
motorcycles.insert(0, 'ducati')
print(motorcycles)
在这个例子中,我们将值'ducati'插入到列表的开头。insert()方法在位置0处开辟一个空间,并将值'ducati'存储在该位置:
['ducati', 'honda', 'yamaha', 'suzuki']
这个操作会将列表中的其他每个值向右移动一个位置。
从列表中移除元素
通常,你可能希望从列表中移除一个元素或一组元素。例如,当玩家击落一个外星人时,你通常会希望将其从活动外星人列表中移除。或者,当用户决定取消他们在你创建的 Web 应用程序中的账户时,你会希望将该用户从活动用户列表中移除。你可以根据元素在列表中的位置或其值来移除元素。
使用 del 语句移除元素
如果你知道想从列表中移除的元素的位置,可以使用del语句:
motorcycles = ['honda', 'yamaha', 'suzuki']
print(motorcycles)
del motorcycles[0]
print(motorcycles)
在这里,我们使用del语句从摩托车列表中移除第一个项目'honda':
['honda', 'yamaha', 'suzuki']
['yamaha', 'suzuki']
如果你知道元素的索引,可以使用del语句从列表中的任何位置移除元素。例如,下面是如何从列表中移除第二个元素'yamaha':
motorcycles = ['honda', 'yamaha', 'suzuki']
print(motorcycles)
del motorcycles[1]
print(motorcycles)
第二辆摩托车已从列表中删除:
['honda', 'yamaha', 'suzuki']
['honda', 'suzuki']
在这两个例子中,使用del语句后,你将无法再访问被移除的列表元素。
使用 pop()方法移除元素
有时你可能希望在从列表中移除元素后使用该元素的值。例如,你可能想获取刚被击落的外星人的x和y位置,然后在该位置绘制爆炸效果。在一个 Web 应用程序中,你可能希望将一个用户从活动成员列表中移除,然后将该用户添加到非活动成员列表中。
pop()方法移除列表中的最后一个元素,但它允许你在移除后继续使用该元素。术语pop来源于将列表看作一个堆叠的元素,从堆叠顶部弹出一个元素。在这个类比中,堆叠的顶部对应于列表的末尾。
让我们从摩托车列表中弹出一辆摩托车:
❶ motorcycles = ['honda', 'yamaha', 'suzuki']
print(motorcycles)
❷ popped_motorcycle = motorcycles.pop()
❸ print(motorcycles)
❹ print(popped_motorcycle)
我们首先定义并打印列表motorcycles ❶。然后我们从列表中弹出一个值,并将该值赋给变量popped_motorcycle ❷。我们打印列表 ❸ 来展示某个值已从列表中移除。接着我们打印弹出的值 ❹ 来证明我们仍然可以访问被移除的值。
输出显示值'suzuki'已从列表末尾移除,并且现在被赋值给变量popped_motorcycle:
['honda', 'yamaha', 'suzuki']
['honda', 'yamaha']
suzuki
这个pop()方法有什么用呢?假设摩托车列表是按我们拥有它们的时间顺序存储的。如果是这种情况,我们可以使用pop()方法打印一条关于我们最后购买的摩托车的语句:
motorcycles = ['honda', 'yamaha', 'suzuki']
last_owned = motorcycles.pop()
print(f"The last motorcycle I owned was a {last_owned.title()}.")
输出是关于我们最近拥有的摩托车的一句简单话语:
The last motorcycle I owned was a Suzuki.
从列表中的任何位置弹出元素
你可以使用pop()方法通过在括号中指定要删除的项的索引,来从列表中的任何位置删除一个项:
motorcycles = ['honda', 'yamaha', 'suzuki']
first_owned = motorcycles.pop(0)
print(f"The first motorcycle I owned was a {first_owned.title()}.")
我们首先通过pop()删除列表中的第一辆摩托车,然后打印关于这辆摩托车的信息。输出是描述我曾拥有的第一辆摩托车的简单句子:
The first motorcycle I owned was a Honda.
记住,每次使用pop()时,你处理的项将不再存储在列表中。
如果你不确定是使用del语句还是pop()方法,以下是一个简单的判断方法:当你想从列表中删除一个项并且不再使用该项时,使用del语句;如果你在删除项的同时还希望使用该项,使用pop()方法。
按值删除项
有时你并不知道要从列表中删除的值的位置。如果你只知道要删除的项的值,可以使用remove()方法。
例如,假设我们想从摩托车列表中删除值'ducati':
motorcycles = ['honda', 'yamaha', 'suzuki', 'ducati']
print(motorcycles)
motorcycles.remove('ducati')
print(motorcycles)
这里remove()方法告诉 Python 找到'ducati'在列表中出现的位置,并删除该元素:
['honda', 'yamaha', 'suzuki', 'ducati']
['honda', 'yamaha', 'suzuki']
你也可以使用remove()方法来处理从列表中删除的值。让我们删除值'ducati'并打印删除该项的原因:
❶ motorcycles = ['honda', 'yamaha', 'suzuki', 'ducati']
print(motorcycles)
❷ too_expensive = 'ducati'
❸ motorcycles.remove(too_expensive)
print(motorcycles)
❹ print(f"\nA {too_expensive.title()} is too expensive for me.")
在定义列表❶之后,我们将值'ducati'赋给一个名为too_expensive的变量❷。然后,我们使用这个变量告诉 Python 要从列表中删除哪个值❸。值'ducati'已经从列表中删除❹,但仍可以通过变量too_expensive访问,从而允许我们打印一条关于为什么从摩托车列表中删除'ducati'的声明:
['honda', 'yamaha', 'suzuki', 'ducati']
['honda', 'yamaha', 'suzuki']
A Ducati is too expensive for me.
组织列表
通常,列表会以不可预测的顺序创建,因为你无法总是控制用户提供数据的顺序。虽然在大多数情况下这是不可避免的,但你经常会希望以特定的顺序展示信息。有时你想保留列表的原始顺序,其他时候你可能想改变原始顺序。Python 根据不同情况提供了多种方法来组织列表。
使用sort()方法永久排序列表
Python 的sort()方法使得排序列表变得相对简单。假设我们有一份汽车列表,并且想要将列表的顺序改为按字母顺序排列。为了简化任务,假设列表中的所有值都是小写字母:
cars.py
cars = ['bmw', 'audi', 'toyota', 'subaru']
cars.sort()
print(cars)
sort()方法会永久改变列表的顺序。现在这些汽车已经按字母顺序排列,我们将无法恢复到原来的顺序:
['audi', 'bmw', 'subaru', 'toyota']
你也可以通过向sort()方法传递参数reverse=True,按逆字母顺序对列表进行排序。以下示例将汽车列表按逆字母顺序排序:
cars = ['bmw', 'audi', 'toyota', 'subaru']
cars.sort(reverse=True)
print(cars)
再次强调,列表的顺序已经永久改变:
['toyota', 'subaru', 'bmw', 'audi']
使用sorted()函数临时排序列表
如果你希望保持列表的原始顺序,但以排序的顺序显示列表,可以使用sorted()函数。sorted()函数允许你以特定的顺序显示列表,但不会影响列表的实际顺序。
让我们在汽车列表上尝试这个函数。
cars = ['bmw', 'audi', 'toyota', 'subaru']
❶ print("Here is the original list:")
print(cars)
❷ print("\nHere is the sorted list:")
print(sorted(cars))
❸ print("\nHere is the original list again:")
print(cars)
我们首先按原始顺序打印列表❶,然后按字母顺序打印❷。在列表按照新顺序显示后,我们展示该列表仍然以原始顺序存储❸:
Here is the original list:
['bmw', 'audi', 'toyota', 'subaru']
Here is the sorted list:
['audi', 'bmw', 'subaru', 'toyota']
❶ Here is the original list again:
['bmw', 'audi', 'toyota', 'subaru']
请注意,在使用sorted()函数之后,列表仍然保留原始顺序❶。如果你希望列表按反字母顺序显示,sorted()函数还可以接受reverse=True参数。
以相反的顺序打印列表
要反转列表的原始顺序,你可以使用reverse()方法。如果我们最初按照拥有这些车的时间顺序存储了这些车的列表,我们可以很容易地将列表按时间倒序排列:
cars = ['bmw', 'audi', 'toyota', 'subaru']
print(cars)
cars.reverse()
print(cars)
请注意,reverse()不会按照字母顺序倒排;它只是反转列表的顺序:
['bmw', 'audi', 'toyota', 'subaru']
['subaru', 'toyota', 'audi', 'bmw']
reverse()方法会永久改变列表的顺序,但你可以通过再次对同一个列表调用reverse()来恢复原来的顺序。
查找列表的长度
你可以通过使用len()函数快速找到列表的长度。这个示例中的列表有四个元素,所以它的长度是4:
>>> **cars = ['bmw', 'audi', 'toyota', 'subaru']**
>>> **len(cars)**
4
你会发现,len()在你需要确定游戏中仍需击落的外星人数量、确定你需要管理的可视化数据量或计算网站上注册用户数量等任务时非常有用。
在处理列表时避免索引错误
在你第一次使用列表时,有一种常见的错误类型。假设你有一个包含三个元素的列表,而你请求第四个元素:
motorcycles.py
motorcycles = ['honda', 'yamaha', 'suzuki']
print(motorcycles[3])
这个例子会导致一个索引错误:
Traceback (most recent call last):
File "motorcycles.py", line 2, in <module>
print(motorcycles[3])
~~~~~~~~~~~^^^
IndexError: list index out of range
Python 试图给你索引为 3 的元素。但当它查找列表时,motorcycles中没有索引为 3 的元素。由于列表索引的下标从 0 开始,这种错误是常见的。人们通常认为第三个元素是索引 3 的元素,因为他们习惯从 1 开始计数。但在 Python 中,第三个元素的索引是 2,因为索引从 0 开始。
索引错误意味着 Python 无法找到你请求的索引位置的元素。如果你的程序中发生了索引错误,试着调整你请求的索引值,然后再次运行程序看看结果是否正确。
请记住,每当你想访问列表中的最后一个元素时,你应该使用索引-1。即使你的列表自上次访问以来发生了变化,这个方法也始终有效:
motorcycles = ['honda', 'yamaha', 'suzuki']
print(motorcycles[-1])
索引-1总是返回列表中的最后一个元素,在这个例子中是值suzuki:
suzuki
这种方法唯一会引发错误的情况是,当你请求一个空列表中的最后一个元素时:
motorcycles = []
print(motorcycles[-1])
motorcycles 中没有项目,因此 Python 返回另一个索引错误:
Traceback (most recent call last):
File "motorcyles.py", line 3, in <module>
print(motorcycles[-1])
~~~~~~~~~~~^^^^
IndexError: list index out of range
如果发生索引错误,并且你无法弄清楚如何解决它,可以尝试打印你的列表或仅打印列表的长度。你的列表可能与你想象的非常不同,尤其是当它由程序动态管理时。查看实际的列表或列表中项目的确切数量,可以帮助你理清这些逻辑错误。
总结
在本章中,你学习了什么是列表以及如何处理列表中的各个项目。你学习了如何定义列表,如何添加和移除元素。你还学习了如何对列表进行永久和临时排序,以便展示。你还学习了如何查找列表的长度,以及如何在处理列表时避免索引错误。
在第四章中,你将学习如何更高效地处理列表中的项目。通过仅使用几行代码遍历列表中的每一项,你将能够高效地工作,即使列表包含数千或数百万个项目。
第四章:处理列表

在第三章中,你学习了如何创建一个简单的列表,并学习了如何处理列表中的单个元素。在本章中,你将学习如何使用仅几行代码遍历整个列表,无论列表有多长。循环允许你对列表中的每一项执行相同的操作或一组操作。因此,你将能够高效地处理任何长度的列表,包括那些包含数千或甚至数百万个项目的列表。
遍历整个列表
你常常需要遍历列表中的所有条目,对每一项执行相同的任务。例如,在游戏中,你可能想要将屏幕上的每个元素移动相同的距离。在一个数字列表中,你可能想对每个元素执行相同的统计操作。或者,你可能想在一个网站上显示每篇文章的标题。当你想对列表中的每一项执行相同的操作时,可以使用 Python 的 for 循环。
假设我们有一个魔术师名字的列表,并且我们想打印出列表中的每个名字。我们可以通过单独取出列表中的每个名字来做到这一点,但这种方法可能会导致几个问题。首先,对于一个很长的名字列表,重复这样的操作会很繁琐。其次,每次列表长度发生变化时,我们必须修改代码。使用 for 循环可以避免这两个问题,因为 Python 会内部处理这些问题。
让我们使用 for 循环打印出魔术师名单中的每个名字:
magicians.py
magicians = ['alice', 'david', 'carolina']
for magician in magicians:
print(magician)
我们从定义一个列表开始,就像我们在第三章中做的那样。然后我们定义一个for循环。这一行告诉 Python 从列表magicians中取出一个名字,并将其与变量magician关联。接下来,我们告诉 Python 打印刚刚分配给magician的名字。Python 接着重复这两行代码,每个列表中的名字执行一次。你可以将这段代码理解为“对于magicians列表中的每个魔术师,打印该魔术师的名字。”输出将是列表中每个名字的简单打印:
alice
david
carolina
更深入地了解循环
循环很重要,因为它是计算机自动化重复任务的最常见方式之一。例如,在像我们在magicians.py中使用的简单循环中,Python 首先读取循环的第一行:
for magician in magicians:
这一行告诉 Python 从列表magicians中提取第一个值,并将其与变量magician关联。这个第一个值是'alice'。然后,Python 继续读取下一行:
print(magician)
Python 再次打印当前magician的值,仍然是'alice'。由于列表中还有更多值,Python 返回到循环的第一行:
for magician in magicians:
Python 检索列表中的下一个名字'david',并将该值与变量magician关联。然后,Python 执行这一行:
print(magician)
Python 再次打印当前magician的值,这次是'david'。Python 再次执行整个循环,使用列表中的最后一个值'carolina'。由于列表中没有更多的值,Python 转到程序中的下一行。在这种情况下,for循环后面没有更多的代码,所以程序结束。
当你第一次使用循环时,请记住,这些步骤会针对列表中的每个项目重复执行一次,不管列表中有多少项。如果列表中有一百万个项目,Python 将这些步骤重复执行一百万次——通常执行得非常快。
另外,在编写你自己的for循环时,记住你可以为与列表中每个值关联的临时变量选择任何你想要的名称。然而,选择一个有意义的名称来表示列表中的单个项是很有帮助的。例如,以下是为猫的列表、狗的列表和一般物品列表编写for循环的一个好方法:
for cat in cats:
for dog in dogs:
for item in list_of_items:
这些命名约定可以帮助你跟踪在for循环中每个项所执行的操作。使用单数和复数名称可以帮助你识别代码的某一部分是在处理列表中的单个元素还是整个列表。
在for循环中做更多的工作
你可以对for循环中的每个项目做几乎任何事情。让我们在之前的例子基础上,向每个魔术师打印一条消息,告诉他们他们表演了一个很棒的魔术:
magicians.py
magicians = ['alice', 'david', 'carolina']
for magician in magicians:
print(f"{magician.title()}, that was a great trick!")
这段代码唯一的区别在于我们为每个魔术师构建消息,消息从该魔术师的名字开始。在第一次执行循环时,magician的值是'alice',所以 Python 会用名字'Alice'开始第一条消息。第二次执行时,消息会以'David'开头,第三次执行时,消息会以'Carolina'开头。
输出显示了列表中每位魔术师的个性化消息:
Alice, that was a great trick!
David, that was a great trick!
Carolina, that was a great trick!
你也可以在for循环中写任意多行代码。在for magician in magicians这一行之后的每一行缩进代码都被认为是在循环内,并且每一行会为列表中的每个值执行一次。因此,你可以在每个列表项上执行任意多的操作。
让我们为消息添加第二行,告诉每位魔术师我们期待他们的下一个魔术:
magicians = ['alice', 'david', 'carolina']
for magician in magicians:
print(f"{magician.title()}, that was a great trick!")
print(f"I can't wait to see your next trick, {magician.title()}.\n")
由于我们已经缩进了两次print()的调用,因此每一行会为列表中的每个魔术师执行一次。第二次print()调用中的换行符("\n")会在每次循环后插入一个空行。这就创建了一组整齐分组的消息,每个人都有一条:
Alice, that was a great trick!
I can't wait to see your next trick, Alice.
David, that was a great trick!
I can't wait to see your next trick, David.
Carolina, that was a great trick!
I can't wait to see your next trick, Carolina.
你可以在for循环中使用任意多的行。实际上,你会发现,在使用for循环时,通常需要对列表中的每个项目执行多种不同的操作。
在for循环后做一些事情
一旦for循环执行完毕会发生什么?通常,你会希望总结输出块,或者继续执行程序中必须完成的其他工作。
在for循环后面任何没有缩进的代码行都只会执行一次,而不会重复执行。我们来为魔术师们写一条感谢信息,感谢他们为大家呈现了一场精彩的表演。为了在打印完所有个人消息后显示这条群体消息,我们将感谢信息写在for循环之后,且不缩进:
magicians = ['alice', 'david', 'carolina']
for magician in magicians:
print(f"{magician.title()}, that was a great trick!")
print(f"I can't wait to see your next trick, {magician.title()}.\n")
print("Thank you, everyone. That was a great magic show!")
前两次调用print()会为列表中的每个魔术师重复一次,正如你之前所看到的。然而,由于最后一行没有缩进,它只会执行一次:
Alice, that was a great trick!
I can't wait to see your next trick, Alice.
David, that was a great trick!
I can't wait to see your next trick, David.
Carolina, that was a great trick!
I can't wait to see your next trick, Carolina.
Thank you, everyone. That was a great magic show!
当你使用for循环处理数据时,你会发现这是总结对整个数据集执行的操作的好方法。例如,你可能会用for循环来初始化一个游戏,遍历角色列表并在屏幕上显示每个角色。然后你可能会在循环后写一些额外的代码,在所有角色都显示在屏幕后显示一个现在开始游戏按钮。
避免缩进错误
Python 使用缩进来确定某一行或一组行与程序其余部分的关系。在之前的示例中,打印每个魔术师消息的那些行属于 for 循环,因为它们是缩进的。Python 使用缩进使得代码非常容易阅读。基本上,它通过空白符强制你编写格式整洁且具有清晰视觉结构的代码。在更长的 Python 程序中,你会注意到有些代码块在不同的缩进级别上。这些缩进级别帮助你对整个程序的组织结构有一个大致的了解。
当你开始编写依赖于正确缩进的代码时,你需要注意一些常见的缩进错误。例如,人们有时会缩进不需要缩进的代码行,或者忘记缩进需要缩进的代码行。现在看到这些错误的示例会帮助你在将来避免它们,并在自己的程序中出现时修正它们。
让我们来看看一些更常见的缩进错误。
忘记缩进
在循环中的 for 语句后总是需要缩进。如果你忘记了,Python 会提醒你:
magicians.py
magicians = ['alice', 'david', 'carolina']
for magician in magicians:
❶ print(magician)
调用 print() ❶ 应该缩进,但它没有缩进。当 Python 期望一个缩进块却没有找到时,它会告诉你它在哪一行出现了问题:
File "magicians.py", line 3
print(magician)
^
IndentationError: expected an indented block after 'for' statement on line 2
通常,你可以通过在 for 语句后立即缩进该行或多行来解决这种缩进错误。
忘记缩进额外的行
有时你的循环会在没有错误的情况下运行,但却没有产生预期的结果。这种情况通常发生在你试图在循环中执行多个任务,却忘记缩进其中的一些行时。
例如,当我们忘记缩进循环中的第二行(这行代码告诉每个魔术师我们期待他们的下一个魔术时),就会发生以下情况:
magicians = ['alice', 'david', 'carolina']
for magician in magicians:
print(f"{magician.title()}, that was a great trick!")
❶ print(f"I can't wait to see your next trick, {magician.title()}.\n")
第二次调用 print() ❶ 应该缩进,但由于 Python 在 for 语句后发现至少有一行缩进的代码,它并不会报错。因此,第一个 print() 调用会针对列表中的每个名字执行一次,因为它是缩进的。第二个 print() 调用没有缩进,所以它只会在循环结束后执行一次。由于与 magician 关联的最终值是 'carolina',因此只有她收到了“期待下一场魔术”的消息:
Alice, that was a great trick!
David, that was a great trick!
Carolina, that was a great trick!
I can't wait to see your next trick, Carolina.
这是一个逻辑错误。语法上是有效的 Python 代码,但由于逻辑上出现问题,代码并没有产生预期的结果。如果你期望看到某个操作对列表中的每个项都执行一次,而它只执行了一次,请检查是否需要缩进某一行或一组行。
不必要的缩进
如果你不小心缩进了一个不需要缩进的行,Python 会提示你关于意外缩进的错误:
hello_world.py
message = "Hello Python world!"
print(message)
我们不需要缩进 print() 调用,因为它不是循环的一部分;因此,Python 会报告这个错误:
File "hello_world.py", line 2
print(message)
^
IndentationError: unexpected indent
通过只有在有特定理由时才进行缩进,你可以避免意外的缩进错误。在你此时编写的程序中,唯一需要缩进的行是你希望在每次 for 循环中的每个项目上重复执行的操作。
循环后不必要的缩进
如果你不小心缩进了应当在循环结束后运行的代码,这段代码将在列表中的每一项上都执行一次。有时这会导致 Python 报告错误,但通常会导致逻辑错误。
例如,看看当我们不小心缩进了感谢魔术师们集体表现出色的那一行时会发生什么:
magicians.py
magicians = ['alice', 'david', 'carolina']
for magician in magicians:
print(f"{magician.title()}, that was a great trick!")
print(f"I can't wait to see your next trick, {magician.title()}.\n")
❶ print("Thank you everyone, that was a great magic show!")
由于最后一行❶有缩进,它会为列表中的每一个人都执行一次:
Alice, that was a great trick!
I can't wait to see your next trick, Alice.
Thank you everyone, that was a great magic show!
David, that was a great trick!
I can't wait to see your next trick, David.
Thank you everyone, that was a great magic show!
Carolina, that was a great trick!
I can't wait to see your next trick, Carolina.
Thank you everyone, that was a great magic show!
这是另一个逻辑错误,类似于第 54 页中“忘记缩进额外的行”中的错误。由于 Python 不知道你想通过代码实现什么,它会运行所有符合语法规则的代码。如果某个操作应当仅执行一次,却被多次重复执行,你可能需要取消该操作的缩进。
忘记冒号
for语句末尾的冒号告诉 Python 将下一行解释为循环的开始。
magicians = ['alice', 'david', 'carolina']
❶ for magician in magicians
print(magician)
如果你不小心忘记了冒号❶,你会得到语法错误,因为 Python 不知道你到底想做什么:
File "magicians.py", line 2
for magician in magicians
^
SyntaxError: expected ':'
Python 不知道你是仅仅忘记了冒号,还是打算写额外的代码来设置一个更复杂的循环。如果解释器能够识别出一个可能的修复,它会给出建议,比如在行尾加上冒号,就像这里的 expected ':' 响应一样。有些错误有简单、显而易见的修复,这要归功于 Python 错误追踪中提供的建议。也有一些错误,即使最终的修复仅涉及一个字符,也更难解决。当一个小修复花费很长时间才找到时,不必感到沮丧;你绝对不是唯一有这种经历的人。
创建数字列表
存储一组数字有很多理由。例如,你可能需要跟踪游戏中每个角色的位置,也许还想记录玩家的最高分。在数据可视化中,你几乎总是会处理一组数字,比如温度、距离、人口大小,或者经纬度值等各种类型的数字集合。
列表非常适合存储数字集合,Python 提供了多种工具来帮助你高效地处理数字列表。一旦你理解了如何有效地使用这些工具,即使你的列表包含数百万个项目,代码也能顺利运行。
使用 range() 函数
Python 的 range() 函数使得生成一系列数字变得简单。例如,你可以使用 range() 函数像这样打印一系列数字:
first_numbers.py
for value in range(1, 5):
print(value)
尽管这段代码看起来应该打印从 1 到 5 的数字,但它没有打印数字 5:
1
2
3
4
在这个例子中,range()只打印 1 到 4 之间的数字。这是编程语言中常见的“偏差一”行为的另一个例子。range()函数会让 Python 从你提供的第一个值开始计数,并在达到你提供的第二个值时停止。因为它会在第二个值时停止,输出中永远不会包含结束值,而在这个例子中,结束值本应是 5。
要打印从 1 到 5 的数字,你可以使用range(1, 6):
for value in range(1, 6):
print(value)
这次输出从 1 开始,到 5 结束:
1
2
3
4
5
如果你在使用range()时,输出与预期不符,尝试调整结束值加 1。
你也可以只传递一个参数给range(),它会从 0 开始生成数字序列。例如,range(6)将返回从 0 到 5 的数字。
使用range()创建一个数字列表
如果你想制作一个数字列表,你可以使用list()函数将range()的结果直接转换为列表。当你将list()包裹在对range()函数的调用周围时,输出将是一个数字列表。
在前面章节的例子中,我们仅仅是打印了一系列数字。我们可以使用list()将这组数字转换为列表:
numbers = list(range(1, 6))
print(numbers)
这是结果:
[1, 2, 3, 4, 5]
我们还可以使用range()函数告诉 Python 跳过给定范围内的数字。如果你传递第三个参数给range(),Python 会使用该值作为生成数字时的步长。
例如,下面是如何列出 1 到 10 之间的偶数:
even_numbers.py
even_numbers = list(range(2, 11, 2))
print(even_numbers)
在这个例子中,range()函数从值 2 开始,然后将 2 加到这个值上。它会重复加 2,直到达到或超过结束值 11,并产生如下结果:
[2, 4, 6, 8, 10]
你可以使用range()函数创建几乎任何你想要的数字集。例如,考虑如何制作一个包含前 10 个平方数的列表(即从 1 到 10 的每个整数的平方)。在 Python 中,两个星号(**)表示指数运算。下面是如何将前 10 个平方数放入列表的示例:
square_numbers.py
squares = []
for value in range(1, 11):
❶ square = value ** 2
❷ squares.append(square)
print(squares)
我们首先创建一个空列表squares。然后,告诉 Python 使用range()函数循环遍历 1 到 10 之间的每个值。在循环内部,当前值被平方并赋值给变量square ❶。每次新的square值都会被追加到squares列表中 ❷。最后,当循环结束时,平方数的列表会被打印出来:
[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
为了使这段代码更简洁,可以省略临时变量square,并将每个新值直接添加到列表中:
squares = []
for value in range(1,11):
squares.append(value**2)
print(squares)
这一行完成了与前面列表中for循环内部相同的工作。循环中的每个值都会被平方,然后立即追加到平方数列表中。
在制作更复杂的列表时,你可以使用这两种方法中的任何一种。有时使用临时变量可以让你的代码更易读;有时则可能使代码变得冗长。首先关注编写自己清楚理解的代码,并确保它能按预期执行。然后,在复查代码时,寻找更高效的方法。
使用数字列表进行简单统计
在处理数字列表时,几个 Python 函数非常有用。例如,你可以轻松地找到数字列表中的最小值、最大值和总和:
>>> **digits = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]**
>>> **min(digits)**
0
>>> **max(digits)**
9
>>> **sum(digits)**
45
列表推导式
前面描述的生成 squares 列表的方法需要使用三到四行代码。一个 列表推导式 可以让你仅用一行代码生成相同的列表。列表推导式将 for 循环和新元素的创建合并为一行,并自动附加每个新元素。列表推导式通常不会首先介绍给初学者,但我在这里包含它们,因为一旦你开始查看其他人的代码,你很可能会看到它们。
以下示例构建了与之前相同的平方数列表,但使用了列表推导式:
squares.py
squares = [value**2 for value in range(1, 11)]
print(squares)
要使用这种语法,首先为列表取一个描述性名称,例如 squares。接着,打开一对方括号,定义你想存储到新列表中的表达式。在这个例子中,表达式是 value**2,它将值提升到二次方。然后,写一个 for 循环来生成你想要传入表达式的数字,最后关闭方括号。这个例子中的 for 循环是 for value in range(1, 11),它将值 1 到 10 传入表达式 value**2。注意,在 for 语句末尾不使用冒号。
结果是你之前看到的相同的平方数列表:
[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
编写你自己的列表推导式需要练习,但一旦你熟悉了创建普通列表,你会发现它们非常值得。当你编写三到四行代码来生成列表,并开始觉得重复时,考虑编写你自己的列表推导式。
操作列表的一部分
在第三章,你学习了如何访问列表中的单个元素,而在本章,你正在学习如何遍历列表中的所有元素。你还可以操作列表中的特定项集合,这在 Python 中称为 切片。
切片列表
要制作切片,你需要指定你想操作的第一个和最后一个元素的索引。与 range() 函数一样,Python 会在你指定的第二个索引之前停止。所以,要输出列表中的前三个元素,你需要请求索引 0 到 3,这将返回元素 0、1 和 2。
以下示例涉及一个团队的玩家列表:
players.py
players = ['charles', 'martina', 'michael', 'florence', 'eli']
print(players[0:3])
这段代码打印了列表的一个切片。输出保留了列表的结构,并包括了列表中的前三个玩家:
['charles', 'martina', 'michael']
你可以生成列表的任何子集。例如,如果你想要列表中的第二、第三和第四个项目,你可以从索引1开始切片,结束于索引4:
players = ['charles', 'martina', 'michael', 'florence', 'eli']
print(players[1:4])
这次切片从'martina'开始,直到'florence'结束:
['martina', 'michael', 'florence']
如果你在切片中省略第一个索引,Python 会自动从列表的开头开始切片:
players = ['charles', 'martina', 'michael', 'florence', 'eli']
print(players[:4])
没有起始索引时,Python 会从列表的开头开始:
['charles', 'martina', 'michael', 'florence']
如果你想要包括列表末尾的切片,类似的语法也适用。例如,如果你想要从第三个项目到最后一个项目的所有项,可以从索引2开始,省略第二个索引:
players = ['charles', 'martina', 'michael', 'florence', 'eli']
print(players[2:])
Python 会返回从第三个项目到列表末尾的所有项目:
['michael', 'florence', 'eli']
这种语法允许你从列表的任何位置输出所有元素到列表的末尾,无论列表的长度如何。记住,负索引返回距离列表末尾一定距离的元素;因此,你可以从列表的末尾输出任何切片。例如,如果我们想要输出名册中的最后三位玩家,我们可以使用切片players[-3:]:
players = ['charles', 'martina', 'michael', 'florence', 'eli']
print(players[-3:])
这会打印出最后三位玩家的名字,并且会随着玩家列表的变化继续有效。
遍历切片
如果你想要遍历列表中某个子集的元素,可以在for循环中使用切片。在下一个例子中,我们遍历前三个玩家,并将他们的名字作为简单名册的一部分打印出来:
players = ['charles', 'martina', 'michael', 'florence', 'eli']
print("Here are the first three players on my team:")
❶ for player in players[:3]:
print(player.title())
Python 并不是遍历整个玩家列表,而是只遍历前三个名字 ❶:
Here are the first three players on my team:
Charles
Martina
Michael
切片在许多情况下非常有用。例如,当你创建游戏时,每次玩家完成游戏后,你可以将该玩家的最终得分添加到列表中。然后,你可以通过将列表按降序排序,并提取一个包含前三个得分的切片,来获取玩家的前三个得分。当你处理数据时,你可以使用切片将数据分成特定大小的块进行处理。或者,当你构建一个 Web 应用程序时,你可以使用切片在一系列页面中显示信息,每个页面显示适量的信息。
复制列表
通常,你会想从一个现有的列表开始,并根据第一个列表创建一个全新的列表。让我们来探索如何复制一个列表,并看看在什么情况下复制列表是有用的。
要复制一个列表,你可以通过省略第一个索引和第二个索引([:])来创建一个包含原始列表所有元素的切片。这告诉 Python 从第一个项目开始切片,到最后一个项目结束,从而生成整个列表的副本。
例如,假设我们有一个关于我们最喜欢的食物的列表,并想创建一个包含朋友喜欢的食物的单独列表。这个朋友喜欢我们列表中的所有食物,因此我们可以通过复制我们的列表来创建他们的列表:
foods.py
my_foods = ['pizza', 'falafel', 'carrot cake']
❶ friend_foods = my_foods[:]
print("My favorite foods are:")
print(my_foods)
print("\nMy friend's favorite foods are:")
print(friend_foods)
首先,我们创建一个包含我们喜欢的食物的列表,叫做 my_foods。然后,我们创建一个新列表,叫做 friend_foods。我们通过请求 my_foods 的切片而不指定任何索引❶,并将复制品赋值给 friend_foods。当我们打印每个列表时,我们会看到它们都包含相同的食物:
My favorite foods are:
['pizza', 'falafel', 'carrot cake']
My friend's favorite foods are:
['pizza', 'falafel', 'carrot cake']
为了证明我们实际上有两个独立的列表,我们将向每个列表中添加新的食物,并展示每个列表是如何追踪相应人物的最爱食物的:
my_foods = ['pizza', 'falafel', 'carrot cake']
❶ friend_foods = my_foods[:]
❷ my_foods.append('cannoli')
❸ friend_foods.append('ice cream')
print("My favorite foods are:")
print(my_foods)
print("\nMy friend's favorite foods are:")
print(friend_foods)
我们将原始的 my_foods 中的项目复制到新列表 friend_foods 中,就像在之前的示例中一样❶。接下来,我们向每个列表中添加新食物:我们将 'cannoli' 添加到 my_foods ❷,将 'ice cream' 添加到 friend_foods ❸。然后我们打印这两个列表,看看这些食物是否出现在相应的列表中:
My favorite foods are:
['pizza', 'falafel', 'carrot cake', 'cannoli']
My friend's favorite foods are:
['pizza', 'falafel', 'carrot cake', 'ice cream']
输出结果显示,'cannoli' 现在出现在我们的最爱食物列表中,但 'ice cream' 没有。我们可以看到,'ice cream' 现在出现在朋友的列表中,但 'cannoli' 没有。如果我们只是将 friend_foods 设置为 my_foods,我们就不会得到两个独立的列表。例如,下面是当你尝试在没有使用切片的情况下复制列表时发生的情况:
my_foods = ['pizza', 'falafel', 'carrot cake']
# This doesn't work:
friend_foods = my_foods
my_foods.append('cannoli')
friend_foods.append('ice cream')
print("My favorite foods are:")
print(my_foods)
print("\nMy friend's favorite foods are:")
print(friend_foods)
我们不是将 my_foods 的副本赋给 friend_foods,而是将 friend_foods 设置为 my_foods。这种语法实际上告诉 Python,将新的变量 friend_foods 与已经与 my_foods 关联的列表关联起来,因此现在两个变量都指向同一个列表。因此,当我们将 'cannoli' 添加到 my_foods 时,它也会出现在 friend_foods 中。同样,'ice cream' 会出现在两个列表中,即使它似乎只被添加到了 friend_foods。
输出结果显示,两个列表现在是相同的,这不是我们想要的:
My favorite foods are:
['pizza', 'falafel', 'carrot cake', 'cannoli', 'ice cream']
My friend's favorite foods are:
['pizza', 'falafel', 'carrot cake', 'cannoli', 'ice cream']
元组
列表适用于存储在程序运行期间可能会发生变化的项目集合。当你在网站上处理用户列表或游戏中的角色列表时,能够修改列表尤其重要。然而,有时你希望创建一个不能改变的项目列表。元组可以让你做到这一点。Python 将不能更改的值称为不可变,而不可变的列表称为元组。
定义元组
元组看起来就像列表,只不过使用圆括号而不是方括号。一旦定义了元组,你可以通过使用每个项的索引来访问个别元素,就像访问列表一样。
例如,如果我们有一个矩形,它的大小应该始终保持不变,我们可以通过将其尺寸放入元组中来确保其大小不会改变:
dimensions.py
dimensions = (200, 50)
print(dimensions[0])
print(dimensions[1])
我们定义了元组 dimensions,使用圆括号而不是方括号。然后,我们使用与访问列表元素相同的语法,逐个打印元组中的每个元素:
200
50
让我们看看如果尝试改变元组 dimensions 中的某个项目会发生什么:
dimensions = (200, 50)
dimensions[0] = 250
这段代码试图更改第一个维度的值,但 Python 返回了类型错误。因为我们试图修改元组的值,而元组是不可更改的对象类型,Python 告诉我们不能为元组中的项赋予新值:
Traceback (most recent call last):
File "dimensions.py", line 2, in <module>
dimensions[0] = 250
TypeError: 'tuple' object does not support item assignment
这是有益的,因为我们希望在代码行尝试更改矩形的维度时,Python 能引发错误。
遍历元组中的所有值
你可以像遍历列表一样,使用for循环遍历元组中的所有值:
dimensions = (200, 50)
for dimension in dimensions:
print(dimension)
Python 返回元组中的所有元素,就像它对待列表一样:
200
50
重写元组
虽然你不能修改元组,但你可以为表示元组的变量赋予新值。例如,如果我们想改变这个矩形的维度,我们可以重新定义整个元组:
dimensions = (200, 50)
print("Original dimensions:")
for dimension in dimensions:
print(dimension)
dimensions = (400, 100)
print("\nModified dimensions:")
for dimension in dimensions:
print(dimension)
前四行定义了原始元组并打印了初始维度。然后,我们将一个新元组与变量dimensions关联,并打印新值。这次 Python 不会引发错误,因为重新赋值变量是有效的:
Original dimensions:
200
50
Modified dimensions:
400
100
与列表相比,元组是简单的数据结构。当你想存储一组在程序生命周期中不应更改的值时,使用元组。
代码风格
现在你正在编写更长的程序,学习如何一致地编写代码风格是一个好主意。花时间让你的代码尽可能易读。编写易读的代码有助于你跟踪程序的运行,并且也能帮助别人理解你的代码。
Python 程序员已达成共识,遵循一系列的代码风格规范,以确保每个人的代码结构大致相同。一旦你学会了编写干净的 Python 代码,你应该能够理解任何其他人写的 Python 代码,只要他们遵循相同的规范。如果你希望将来成为一名专业程序员,你应该尽早开始遵循这些规范,养成良好的编程习惯。
风格指南
当有人想对 Python 语言做出更改时,他们会写一份Python 增强提案(PEP)。最早的 PEP 之一是PEP 8,它指导 Python 程序员如何规范代码风格。PEP 8 内容相当长,但其中很多内容涉及到比你当前看到的更复杂的编码结构。
Python 的风格指南写作时考虑到代码比编写的更常被阅读。你编写代码一次后,就会在开始调试时反复阅读它。当你向程序添加新功能时,你会花更多时间阅读代码。当你与其他程序员分享代码时,他们也会阅读你的代码。
在写代码时,如果面临选择更容易写的代码和更容易读的代码之间的抉择,Python 程序员几乎总是会鼓励你写易读的代码。以下规范将帮助你从一开始就编写清晰的代码。
缩进
PEP 8 推荐每个缩进级别使用四个空格。使用四个空格可以提高可读性,同时在每一行留出空间以支持多个缩进级别。
在文字处理文档中,人们通常使用制表符而非空格来进行缩进。这对文字处理文档非常有效,但当制表符与空格混合时,Python 解释器会感到困惑。每个文本编辑器都提供一个设置,允许你使用 TAB 键,但会将每个制表符转换为一定数量的空格。你应该使用 TAB 键,但也要确保编辑器设置为将制表符转换为空格,而不是直接插入制表符。
在文件中混合使用制表符和空格可能会导致很难诊断的问题。如果你怀疑文件中混用了制表符和空格,大多数编辑器允许你将文件中的所有制表符转换为空格。
行长度
许多 Python 程序员建议每行字符数不应超过 80 个。这个指导方针最初源于大多数计算机只能在终端窗口的单行中显示 79 个字符。现在,人们可以在屏幕上显示更长的行,但仍然有其他原因支持遵循 79 字符的标准行长度。
专业程序员通常会在同一屏幕上打开多个文件,使用标准的行长度使他们能够同时查看屏幕上并排显示的两个或三个文件中的完整行。PEP 8 还建议将所有注释的每行字符数限制为 72 个,因为一些为大型项目生成自动文档的工具会在每行注释的开头添加格式字符。
PEP 8 关于行长度的指导方针并非一成不变,某些团队偏好 99 字符的限制。作为初学者,你不必过于担心代码行的长度,但要注意,协作开发的人员几乎总是遵循 PEP 8 的规范。大多数编辑器允许你设置一个视觉提示,通常是在屏幕上显示一条垂直线,帮助你了解行长度的限制。
空白行
为了在视觉上分组程序的各个部分,使用空白行。你应该使用空白行来组织文件,但不要过度使用。通过遵循本书中提供的示例,你应该能够找到合适的平衡。例如,如果你有五行代码用于构建一个列表,接着再有三行代码操作该列表,那么在这两部分之间插入一个空白行是合适的。然而,你不应在这两部分之间插入三到四个空白行。
空白行不会影响代码的运行,但会影响代码的可读性。Python 解释器使用水平缩进来解释代码的含义,但它忽略垂直空白。
其他样式指南
PEP 8 提供了许多额外的样式建议,但大多数指导原则适用于比你目前编写的更复杂的程序。当你学习更复杂的 Python 结构时,我会分享 PEP 8 指南中相关的部分。
总结
在本章中,你学会了如何高效地处理列表中的元素。你学会了如何使用 for 循环遍历列表,Python 如何使用缩进来结构化程序,以及如何避免一些常见的缩进错误。你学会了创建简单的数字列表,以及你可以对数字列表执行的一些操作。你学会了如何切片列表以处理子集项,以及如何通过切片正确地复制列表。你还学习了元组,它提供了一定的保护,防止一组值被改变,以及如何使你越来越复杂的代码具有可读性。
在第五章中,你将学习通过使用 if 语句来对不同的条件做出适当的反应。你将学会将相对复杂的条件测试组合起来,以准确应对你所寻找的特定情况或信息。你还将学习在循环遍历列表时使用 if 语句,以对列表中的特定元素执行特定操作。
第五章:if 语句

编程通常涉及检查一组条件,并根据这些条件决定采取什么行动。Python 的 if 语句允许你检查程序的当前状态,并对该状态做出适当的响应。
在本章中,你将学习如何编写条件测试,以检查你感兴趣的任何条件。你将学会编写简单的 if 语句,还将学习如何创建更复杂的 if 语句系列,以识别出你需要的确切条件。然后,你将把这个概念应用到列表中,这样你就能够编写一个 for 循环,以一种方式处理列表中的大多数项,但以另一种方式处理具有特定值的某些项。
一个简单的例子
以下示例展示了 if 测试如何让你正确地响应特殊情况。假设你有一个汽车名称的列表,你想打印出每辆车的名字。汽车名字是专有名词,因此大多数车的名字应该以标题格式打印。然而,'bmw' 的值应该以全大写形式打印。以下代码会遍历汽车名称列表,查找值为 'bmw' 的项。每当遇到 'bmw' 时,它就会以大写形式打印,而不是标题格式:
cars.py
cars = ['audi', 'bmw', 'subaru', 'toyota']
for car in cars:
❶ if car == 'bmw':
print(car.upper())
else:
print(car.title())
这个示例中的循环首先检查 car 的当前值是否为 'bmw' ❶。如果是,它将值以大写形式打印。如果 car 的值不是 'bmw',它将以标题格式打印:
Audi
BMW
Subaru
Toyota
这个示例结合了你将在本章中学习的多个概念。让我们先看看你可以用来检查程序中条件的各种测试。
条件测试
每个if语句的核心是一个可以被评估为True或False的表达式,这被称为条件测试。Python 使用True和False的值来决定是否执行if语句中的代码。如果条件测试评估为True,Python 就会执行if语句后的代码。如果测试评估为False,Python 则会忽略if语句后的代码。
检查相等性
大多数条件测试将一个变量的当前值与一个特定的目标值进行比较。最简单的条件测试检查一个变量的值是否等于目标值:
>>> **car = 'bmw'**
>>> **car == 'bmw'**
True
第一行使用单等号将car的值设置为'bmw',你已经看到了很多次。接下来的一行使用双等号(==)检查car的值是否为'bmw'。这个相等运算符如果左右两边的值相同,则返回True,如果不同,则返回False。在这个例子中,两个值是匹配的,所以 Python 返回True。
当car的值不是'bmw'时,这个测试返回False:
>>> **car = 'audi'**
>>> **car == 'bmw'**
False
一个等号实际上是一个赋值语句;你可以把这里的第一行代码读作“将car的值设置为'audi'。”另一方面,双等号是在问一个问题:“car的值是否等于'bmw'?”大多数编程语言都以这种方式使用等号。
检查相等性时忽略大小写
在 Python 中,检查相等性是区分大小写的。例如,两个大小写不同的值不会被认为是相等的:
>>> **car = 'Audi'**
>>> **car == 'audi'**
False
如果大小写很重要,这种行为是有利的。但是,如果大小写不重要,而你只想测试变量的值,可以在进行比较之前将变量的值转换为小写:
>>> **car = 'Audi'**
>>> **car.lower() == 'audi'**
True
这个测试会返回True,无论'Audi'的格式如何,因为现在测试是大小写不敏感的。lower()方法不会改变最初存储在car中的值,因此你可以在不影响原始变量的情况下进行这种比较:
>>> **car = 'Audi'**
>>> **car.lower() == 'audi'**
True
>>> **car**
'Audi'
我们首先将大写字符串'Audi'赋值给变量car。然后,我们将car的值转换为小写,并将小写值与字符串'audi'进行比较。两个字符串匹配,所以 Python 返回True。我们可以看到,存储在car中的值没有受到lower()方法的影响。
网站会像这样强制执行用户输入数据的某些规则。例如,一个网站可能会使用类似的条件测试来确保每个用户都有一个真正唯一的用户名,而不仅仅是另一个人用户名的大小写变化。当有人提交一个新用户名时,这个新用户名会被转换为小写,并与所有现有用户名的小写版本进行比较。在这个检查过程中,如果'John'的任何变体(如'john')已经被使用,新的用户名将被拒绝。
检查不相等性
当你想确定两个值是否不相等时,可以使用不等运算符(!=)。我们再用一个if语句来演示如何使用不等运算符。我们将把请求的披萨配料存储在一个变量中,然后如果这个人没有点凤尾鱼,就打印一条消息:
toppings.py
requested_topping = 'mushrooms'
if requested_topping != 'anchovies':
print("Hold the anchovies!")
这段代码将requested_topping的值与'anchovies'进行比较。如果这两个值不匹配,Python 返回True并执行if语句后的代码。如果两个值匹配,Python 返回False,并且不会执行if语句后的代码。
由于requested_topping的值不是'anchovies',因此执行了print()函数:
Hold the anchovies!
你编写的大多数条件表达式都会测试相等性,但有时你会发现测试不等式更高效。
数值比较
测试数值是相当简单的。例如,下面的代码检查一个人是否已经 18 岁:
>>> **age = 18**
>>> **age == 18**
True
你也可以测试两个数字是否不相等。例如,下面的代码如果给定的答案不正确,就会打印一条消息:
magic_number.py
answer = 17
if answer != 42:
print("That is not the correct answer. Please try again!")
条件测试通过,因为answer(17)的值不等于42。由于测试通过,缩进的代码块被执行:
That is not the correct answer. Please try again!
你还可以在条件语句中包含各种数学比较,例如小于、小于或等于、大于、大于或等于:
>>> **age = 19**
>>> **age < 21**
True
>>> **age <= 21**
True
>>> **age > 21**
False
>>> **age >= 21**
False
每个数学比较都可以作为if语句的一部分,这有助于你检测感兴趣的具体条件。
检查多个条件
你可能希望同时检查多个条件。例如,有时你可能需要两个条件都为True才能采取某个动作。其他时候,你可能只需要一个条件为True就满足了。and和or关键字可以在这些情况下帮到你。
使用and检查多个条件
要检查两个条件是否同时都为True,可以使用and关键字将两个条件测试结合起来;如果每个测试都通过,整体表达式就为True。如果任何一个测试失败,或者两个测试都失败,表达式就为False。
例如,你可以使用以下测试来检查两个人是否都超过 21 岁:
>>> **age_0 = 22**
>>> **age_1 = 18**
❶ >>> **age_0 >= 21 and age_1 >= 21**
False
❷ >>> **age_1 = 22**
>>> **age_0 >= 21 and age_1 >= 21**
True
首先,我们定义两个年龄,age_0和age_1。然后我们检查这两个年龄是否都大于或等于 21 岁❶。左边的测试通过了,但右边的测试失败了,因此整个条件表达式的结果为False。然后我们将age_1改为 22❷。age_1的值现在大于 21,因此两个单独的测试都通过了,导致整个条件表达式的结果为True。
为了提高可读性,你可以在单独的测试条件周围使用括号,但这不是必需的。如果使用括号,测试将像这样:
(age_0 >= 21) and (age_1 >= 21)
使用or检查多个条件
关键字 or 允许你检查多个条件,但只要其中一个或两个测试通过,它就会通过。or 表达式只有在两个测试都失败时才会失败。
让我们再次考虑两个年龄,但这次我们只关注是否有一个人超过 21 岁:
>>> **age_0 = 22**
>>> **age_1 = 18**
❶ >>> **age_0 >= 21 or age_1 >= 21**
True
❷ >>> **age_0 = 18**
>>> **age_0 >= 21 or age_1 >= 21**
False
我们再次开始使用两个年龄变量。由于对 age_0 ❶ 的测试通过,整个表达式评估为 True。然后我们将 age_0 降到 18。在最后的测试 ❷ 中,两个测试都失败,整个表达式评估为 False。
检查值是否在列表中
有时,在执行某个操作之前,检查列表是否包含某个值很重要。例如,在完成某人注册之前,你可能想检查一个新的用户名是否已经存在于当前的用户名列表中。在一个制图项目中,你可能想检查一个提交的地点是否已经存在于已知地点列表中。
要检查某个特定值是否已经在列表中,可以使用关键字 in。让我们考虑一些你可能为披萨店编写的代码。我们将创建一个顾客请求的披萨配料列表,然后检查某些配料是否在该列表中。
>>> **requested_toppings = ['mushrooms', 'onions', 'pineapple']**
>>> **'mushrooms' in requested_toppings**
True
>>> **'pepperoni' in requested_toppings**
False
关键字 in 告诉 Python 检查 'mushrooms' 和 'pepperoni' 是否存在于列表 requested_toppings 中。这种技术非常强大,因为你可以创建一个包含基本值的列表,然后轻松检查你正在测试的值是否与列表中的某个值匹配。
检查值是否不在列表中
有时,了解某个值是否不存在于列表中也很重要。在这种情况下,你可以使用关键字 not。例如,考虑一个在论坛中被禁止评论的用户列表。你可以在允许用户提交评论之前,检查该用户是否已被禁止:
banned_users.py
banned_users = ['andrew', 'carolina', 'david']
user = 'marie'
if user not in banned_users:
print(f"{user.title()}, you can post a response if you wish.")
这里的 if 语句很清晰。如果 user 的值不在 banned_users 列表中,Python 返回 True 并执行缩进的语句。
用户 'marie' 不在 banned_users 列表中,因此她会看到一条邀请她发布回应的消息:
Marie, you can post a response if you wish.
布尔表达式
当你进一步学习编程时,你会在某个时刻听到 布尔表达式 这个术语。布尔表达式就是条件测试的另一种说法。布尔值 只有 True 或 False,就像条件表达式在评估后的值一样。
布尔值常用于跟踪某些条件,例如游戏是否正在运行,或用户是否可以编辑网站上的某些内容:
game_active = True
can_edit = False
布尔值提供了一种高效的方式来跟踪程序的状态或程序中某个重要条件的状态。
if 语句
当你理解了条件测试后,你可以开始编写 if 语句。存在几种不同类型的 if 语句,你选择使用哪种类型取决于你需要测试的条件数量。在关于条件测试的讨论中,你已经看到了几个 if 语句的例子,现在让我们更深入地探讨这个话题。
简单的 if 语句
最简单的 if 语句类型有一个测试和一个操作:
if `conditional_test`:
`do something`
你可以在第一行中放置任何条件测试,并且在测试后面的缩进块中放置几乎任何操作。如果条件测试的结果为 True,Python 将执行 if 语句后的代码。如果测试结果为 False,Python 会忽略 if 语句后的代码。
假设我们有一个表示某人年龄的变量,我们想知道这个人是否足够大,可以投票。以下代码测试该人是否可以投票:
voting.py
age = 19
if age >= 18:
print("You are old enough to vote!")
Python 检查 age 的值是否大于或等于 18。它是成立的,因此 Python 执行缩进的 print() 调用:
You are old enough to vote!
缩进在 if 语句中的作用与在 for 循环中相同。if 语句后的所有缩进行将会在测试通过时执行,如果测试不通过,则整个缩进块会被忽略。
你可以在 if 语句后的块中放置任意多行代码。如果该人足够大,可以投票,我们可以添加另一行输出,询问该人是否已经注册投票:
age = 19
if age >= 18:
print("You are old enough to vote!")
print("Have you registered to vote yet?")
条件测试通过,且两个 print() 调用都已缩进,因此两行都被打印出来:
You are old enough to vote!
Have you registered to vote yet?
如果 age 的值小于 18,则此程序将不会产生任何输出。
if-else 语句
通常,你希望在条件测试通过时执行某个操作,在所有其他情况下执行不同的操作。Python 的 if-else 语法使得这一点成为可能。if-else 块类似于简单的 if 语句,但 else 语句允许你定义在条件测试失败时执行的操作或操作集。
如果一个人足够大可以投票,我们将显示与之前相同的消息,但这次我们还会为那些不够大不能投票的人添加一条消息:
age = 17
❶ if age >= 18:
print("You are old enough to vote!")
print("Have you registered to vote yet?")
❷ else:
print("Sorry, you are too young to vote.")
print("Please register to vote as soon as you turn 18!")
如果条件测试 ❶ 通过,则执行第一个缩进的 print() 调用块。如果测试结果为 False,则执行 else 块 ❷。因为这次 age 小于 18,条件测试失败,执行 else 块中的代码:
Sorry, you are too young to vote.
Please register to vote as soon as you turn 18!
这段代码有效,因为它只有两种可能的情况:一个人要么足够大可以投票,要么不够大不能投票。if-else 结构在你希望 Python 总是执行两种可能操作之一的情况下工作得很好。在像这样的简单 if-else 链中,总会执行两种操作中的一种。
if-elif-else 链
通常,你需要测试两个以上的可能情况,而要评估这些情况,你可以使用 Python 的if-elif-else语法。Python 只会在if-elif-else链中执行一个代码块。它按顺序运行每个条件测试,直到某个测试通过。当某个测试通过时,紧跟着该测试的代码会被执行,然后 Python 会跳过其余的测试。
许多现实世界的情况涉及两个以上的可能条件。例如,考虑一个根据不同年龄段收取不同费用的游乐园:
-
任何未满 4 岁的人可以免费入场。
-
任何 4 岁至 18 岁之间的人入场费用为$25。
-
任何 18 岁或以上的人入场费用为$40。
我们如何使用if语句来确定一个人的入场费用?以下代码测试一个人的年龄段,然后打印入场价格消息:
amusement_park.py
age = 12
❶ if age < 4:
print("Your admission cost is $0.")
❷ elif age < 18:
print("Your admission cost is $25.")
❸ else:
print("Your admission cost is $40.")
if测试❶检查一个人是否未满 4 岁。当测试通过时,会打印相应的消息,Python 跳过剩余的测试。elif行❷实际上是另一个if测试,只有在前一个测试失败时才会执行。在链中的这一点,我们知道这个人至少 4 岁,因为第一个测试失败了。如果这个人未满 18 岁,会打印相应的消息,Python 跳过else块。如果if和elif测试都失败,Python 会执行else块中的代码❸。
在这个例子中,if测试❶的结果为False,因此它的代码块没有被执行。然而,elif测试的结果为True(12 小于 18),因此它的代码被执行。输出是一个句子,通知用户入场费用:
Your admission cost is $25.
任何大于 17 岁的年龄都会导致前两个测试失败。在这些情况下,else块会被执行,入场费用为$40。
与其在if-elif-else块中打印入场价格,不如在if-elif-else链中仅设置价格,然后在链评估后执行一个单独的print()调用,这样更简洁:
age = 12
if age < 4:
price = 0
elif age < 18:
price = 25
else:
price = 40
print(f"Your admission cost is ${price}.")
缩进的行根据人的年龄设置price的值,就像前一个示例一样。在if-elif-else链设置了价格后,一个独立的未缩进的print()调用使用这个值来显示一条消息,报告这个人的入场价格。
这段代码产生与前一个示例相同的输出,但if-elif-else链的作用更窄。它不是用来确定价格并显示消息,而是简单地确定入场价格。除了更高效外,修改后的代码也比原始方法更容易修改。如果要更改输出消息的文本,只需要修改一个print()调用,而不是三个单独的print()调用。
使用多个elif块
你可以在代码中使用任意多个elif块。例如,如果游乐园实施了老年人折扣,你可以在代码中添加一个条件测试来确定某人是否符合老年人折扣的条件。假设 65 岁或以上的人只需支付常规入场费的一半,即 20 美元:
age = 12
if age < 4:
price = 0
elif age < 18:
price = 25
elif age < 65:
price = 40
else:
price = 20
print(f"Your admission cost is ${price}.")
这段代码的大部分内容保持不变。第二个elif块现在会检查确保一个人的年龄小于 65 岁,然后才会将其分配为 40 美元的全额入场费。请注意,else块中分配的值需要改为 20 美元,因为只有年龄在 65 岁及以上的人才会进入这个块。
忽略else块
Python 并不要求在if-elif链的末尾必须有else块。有时,else块是有用的。其他时候,使用额外的elif语句来捕获感兴趣的特定条件会更清晰:
age = 12
if age < 4:
price = 0
elif age < 18:
price = 25
elif age < 65:
price = 40
elif age >= 65:
price = 20
print(f"Your admission cost is ${price}.")
最后的elif块在一个人 65 岁或以上时分配 20 美元的价格,这比一般的else块更清晰。通过这个变化,每一块代码必须通过一个特定的测试才能执行。
else块是一个兜底语句。它匹配任何没有被特定if或elif测试匹配的条件,这有时可能包括无效或甚至恶意的数据。如果你有一个具体的最终条件要测试,可以考虑使用最后一个elif块,并省略else块。这样,你会更有信心代码只会在正确的条件下运行。
测试多个条件
if-elif-else链非常强大,但仅在你只需要通过一个测试时才适合使用。一旦 Python 找到一个通过的测试,它就会跳过剩下的测试。这种行为是有益的,因为它高效,并且允许你仅测试一个特定的条件。
然而,有时检查所有感兴趣的条件是很重要的。在这种情况下,你应该使用一系列简单的if语句,而不使用elif或else块。当多个条件可能为True时,这种技术很有意义,并且你希望对每个为True的条件做出响应。
让我们重新考虑披萨店的例子。如果有人请求一个双配料的披萨,你需要确保披萨上有两个配料:
toppings.py
requested_toppings = ['mushrooms', 'extra cheese']
if 'mushrooms' in requested_toppings:
print("Adding mushrooms.")
❶ if 'pepperoni' in requested_toppings:
print("Adding pepperoni.")
if 'extra cheese' in requested_toppings:
print("Adding extra cheese.")
print("\nFinished making your pizza!")
我们从一个包含请求的配料的列表开始。第一个if语句检查这个人是否请求了蘑菇。如果是这样,则打印一条消息确认这个配料。辣香肠的测试❶是另一个简单的if语句,而不是elif或else语句,因此无论前面的测试是否通过,这个测试都会被执行。最后一个if语句检查是否请求了额外的奶酪,无论前两个测试的结果如何。这三个独立的测试每次运行这个程序时都会执行。
因为在这个例子中每个条件都会被评估,所以蘑菇和额外的奶酪都会被添加到披萨上:
Adding mushrooms.
Adding extra cheese.
Finished making your pizza!
如果我们使用if-elif-else块,这段代码将无法正常工作,因为代码在只有一个测试通过后就会停止运行。下面是它的样子:
requested_toppings = ['mushrooms', 'extra cheese']
if 'mushrooms' in requested_toppings:
print("Adding mushrooms.")
elif 'pepperoni' in requested_toppings:
print("Adding pepperoni.")
elif 'extra cheese' in requested_toppings:
print("Adding extra cheese.")
print("\nFinished making your pizza!")
对'mushrooms'的测试是第一个通过的测试,所以蘑菇被添加到比萨上。然而,值'extra cheese'和'pepperoni'永远不会被检查,因为在if-elif-else链中,Python 不会运行通过的第一个测试后的任何其他测试。顾客的第一个配料会被添加,但所有其他配料都会被遗漏:
Adding mushrooms.
Finished making your pizza!
总结来说,如果你希望只有一个代码块运行,使用if-elif-else链。如果需要多个代码块运行,使用一系列独立的if语句。
在列表中使用if语句
当你将列表和if语句结合起来时,可以做一些有趣的工作。你可以观察需要与列表中其他值不同对待的特殊值。你可以有效地管理变化的条件,比如餐厅在一个班次中的某些物品的可用性。你还可以开始证明你的代码在所有可能的情况下都能按预期工作。
检查特殊项
本章开始时展示了一个简单的例子,演示了如何处理像'bmw'这样的特殊值,特别是它需要以不同于其他列表值的格式打印。现在你已经对条件测试和if语句有了基本的理解,我们来仔细看看如何在列表中检查特殊值并适当地处理这些值。
让我们继续以比萨店的例子。每当在制作比萨时添加配料,比萨店会显示一条消息。这个操作的代码可以通过列出顾客请求的配料,并使用循环在每次配料添加到比萨上时宣布它,来高效地编写:
toppings.py
requested_toppings = ['mushrooms', 'green peppers', 'extra cheese']
for requested_topping in requested_toppings:
print(f"Adding {requested_topping}.")
print("\nFinished making your pizza!")
输出是直观的,因为这段代码只是一个简单的for循环:
Adding mushrooms.
Adding green peppers.
Adding extra cheese.
Finished making your pizza!
那如果比萨店没有绿椒了怎么办?for循环中的if语句可以适当地处理这种情况:
requested_toppings = ['mushrooms', 'green peppers', 'extra cheese']
for requested_topping in requested_toppings:
if requested_topping == 'green peppers':
print("Sorry, we are out of green peppers right now.")
else:
print(f"Adding {requested_topping}.")
print("\nFinished making your pizza!")
这次,在将配料添加到比萨之前,我们检查每个请求的项。if语句检查顾客是否请求了绿椒。如果是,我们会显示一条消息告知他们为什么不能要绿椒。else块确保所有其他配料都将添加到比萨上。
输出显示每个请求的配料都得到了适当的处理。
Adding mushrooms.
Sorry, we are out of green peppers right now.
Adding extra cheese.
Finished making your pizza!
检查列表是否为空
到目前为止,我们对每个使用过的列表做了一个简单的假设:我们假设每个列表至少有一个项目。很快,我们将允许用户提供存储在列表中的信息,因此每次运行循环时,我们不能假设列表中有任何项目。在这种情况下,在运行for循环之前检查列表是否为空是很有用的。
作为示例,让我们在制作披萨之前检查请求的配料列表是否为空。如果列表为空,我们将提示用户并确认他们是否想要一份普通披萨。如果列表不为空,我们将像之前的示例一样制作披萨:
requested_toppings = []
if requested_toppings:
for requested_topping in requested_toppings:
print(f"Adding {requested_topping}.")
print("\nFinished making your pizza!")
else:
print("Are you sure you want a plain pizza?")
这次我们从一个空的请求配料列表开始。我们没有直接进入 for 循环,而是先做一个快速检查。当列表的名称在 if 语句中使用时,如果列表包含至少一个项,Python 会返回 True;如果是空列表,则返回 False。如果 requested_toppings 通过条件测试,我们就运行与前一个示例中相同的 for 循环。如果条件测试失败,我们会打印一条消息,询问顾客是否真的想要没有配料的普通披萨。
在这种情况下,列表为空,因此输出会询问用户是否真的想要一份普通披萨:
Are you sure you want a plain pizza?
如果列表不为空,输出将显示每个请求的配料被添加到披萨上。
使用多个列表
人们对配料的请求几乎无所不包,特别是涉及披萨配料时。如果顾客真的想要在披萨上放薯条呢?你可以使用列表和 if 语句,确保在执行操作之前输入是合理的。
在制作披萨之前,让我们留意一些不寻常的配料请求。以下示例定义了两个列表。第一个是披萨店提供的配料列表,第二个是用户请求的配料列表。这一次,requested_toppings 中的每个项在添加到披萨之前都会与可用配料列表进行检查:
available_toppings = ['mushrooms', 'olives', 'green peppers',
'pepperoni', 'pineapple', 'extra cheese']
❶ requested_toppings = ['mushrooms', 'french fries', 'extra cheese']
for requested_topping in requested_toppings:
❷ if requested_topping in available_toppings:
print(f"Adding {requested_topping}.")
❸ else:
print(f"Sorry, we don't have {requested_topping}.")
print("\nFinished making your pizza!")
首先,我们定义了这家披萨店提供的配料列表。请注意,如果披萨店提供稳定的配料选择,这个可以是一个元组。然后,我们创建了一个客户请求的配料列表。在这个示例中,有一个不寻常的配料请求:'french fries' ❶。接下来,我们遍历请求的配料列表。在循环内,我们检查每个请求的配料是否实际上在可用配料列表中 ❷。如果有,我们就将该配料添加到披萨上。如果请求的配料不在可用配料列表中,else 块将执行 ❸。else 块会打印一条消息,告诉用户哪些配料不可用。
这段代码语法产生了干净且富有信息的输出:
Adding mushrooms.
Sorry, we don't have french fries.
Adding extra cheese.
Finished making your pizza!
仅凭几行代码,我们就有效地处理了一个现实世界中的情况!
给你的 if 语句添加样式
在本章的每个示例中,你都看到了良好的样式习惯。PEP 8 对条件测试样式的唯一建议是,在比较运算符(如 ==、>= 和 <=)周围使用单个空格。例如:
if age < 4:
比下面这种写法更好:
if age<4:
这样的空格不会影响 Python 解释代码的方式;它只是让你的代码更容易阅读,无论是你自己还是其他人。
总结
在本章中,你学习了如何编写条件测试,这些测试总是返回True或False。你学习了如何编写简单的if语句、if-else链式结构以及if-elif-else链式结构。你开始使用这些结构来识别你需要测试的特定条件,并知道在你的程序中何时满足这些条件。你学会了如何在列表中处理某些项目与其他项目不同,同时继续利用for循环的高效性。你还重新回顾了 Python 的编码风格建议,以确保你日益复杂的程序仍然相对容易阅读和理解。
在第六章中,你将了解 Python 的字典。字典类似于列表,但它允许你将信息片段连接在一起。你将学习如何构建字典、遍历字典,并将它们与列表和if语句结合使用。学习字典将使你能够建模更广泛的现实世界情况。
第六章:字典

在本章中,你将学习如何使用 Python 的字典,它们允许你将相关信息连接在一起。你将学习如何访问存储在字典中的信息,并如何修改这些信息。由于字典可以存储几乎无限量的信息,我将向你展示如何遍历字典中的数据。此外,你将学习如何将字典嵌套在列表中,将列表嵌套在字典中,甚至将字典嵌套在其他字典中。
理解字典可以让你更准确地建模各种现实世界中的对象。你可以创建一个字典来表示一个人,并存储关于这个人的所有信息。你可以存储他们的名字、年龄、地点、职业以及任何你能描述的关于这个人的其他方面。你将能够存储任何两种可以配对的信息,比如单词及其意思、人的名字和他们的最爱数字、山脉及其海拔等等。
一个简单的字典
考虑一个包含外星人的游戏,外星人可以具有不同的颜色和点数。这个简单的字典存储了一个特定外星人的信息:
alien.py
alien_0 = {'color': 'green', 'points': 5}
print(alien_0['color'])
print(alien_0['points'])
字典alien_0存储了外星人的颜色和点数。最后两行访问并显示了这些信息,如下所示:
green
5
与大多数新的编程概念一样,使用字典需要练习。一旦你使用字典一段时间后,你会发现它们在建模现实世界情况方面是多么高效。
使用字典
在 Python 中,字典是一种由键-值对组成的集合。每个键与一个值相连,你可以使用键来访问与该键关联的值。一个键的值可以是一个数字、一个字符串、一个列表,甚至是另一个字典。实际上,你可以使用 Python 中可以创建的任何对象作为字典中的值。
在 Python 中,字典是用大括号{}包裹的,里面包含一系列键值对,如之前的示例所示:
alien_0 = {'color': 'green', 'points': 5}
键值对是一组相互关联的值。当你提供一个键时,Python 会返回与该键相关联的值。每个键通过冒号与其值连接,单独的键值对通过逗号分隔。你可以在字典中存储任意数量的键值对。
最简单的字典只有一个键值对,如下所示,这是修改后的alien_0字典版本:
alien_0 = {'color': 'green'}
这个字典存储了关于alien_0的一条信息:外星人的颜色。字符串'color'是字典中的一个键,而与之关联的值是'green'。
访问字典中的值
要获取与键相关联的值,给出字典的名称,然后将键放在一对方括号内,如下所示:
alien.py
alien_0 = {'color': 'green'}
print(alien_0['color'])
这将返回字典alien_0中与键'color'关联的值:
green
你可以在字典中拥有无限数量的键值对。例如,这里是原始的alien_0字典,包含两个键值对:
alien_0 = {'color': 'green', 'points': 5}
现在你可以访问alien_0的颜色或积分值。如果玩家击败了这个外星人,你可以使用如下代码查找他们应该获得多少积分:
alien_0 = {'color': 'green', 'points': 5}
new_points = alien_0['points']
print(f"You just earned {new_points} points!")
一旦字典被定义,我们就从字典中提取与键'points'相关联的值。然后将这个值赋给变量new_points。最后一行打印出玩家刚刚获得的积分:
You just earned 5 points!
如果你每次击败外星人时都运行这段代码,外星人的积分值将会被检索出来。
添加新键值对
字典是动态结构,你可以随时向字典添加新的键值对。要添加新的键值对,你只需给出字典的名称,后跟新键的方括号,并指定新值。
让我们向alien_0字典添加两个新的信息:外星人的x坐标和y坐标,这将帮助我们在屏幕上的特定位置显示外星人。我们将外星人放置在屏幕的左边缘,距离顶部 25 像素。由于屏幕坐标通常从屏幕的左上角开始,我们通过将x坐标设置为 0 来把外星人放在屏幕的左边缘,并通过将y坐标设置为正 25 来把它放置在距离顶部 25 像素的位置,如下所示:
alien.py
alien_0 = {'color': 'green', 'points': 5}
print(alien_0)
alien_0['x_position'] = 0
alien_0['y_position'] = 25
print(alien_0)
我们首先定义与之前相同的字典,然后打印这个字典,展示其信息快照。接下来,我们向字典中添加一个新的键值对:键'x_position'和值0。我们对键'y_position'做同样的操作。当我们打印修改后的字典时,我们看到这两个新增的键值对:
{'color': 'green', 'points': 5}
{'color': 'green', 'points': 5, 'x_position': 0, 'y_position': 25}
字典的最终版本包含四个键值对。原来的两个指定了颜色和得分值,再加上两个指定了外星人位置的键值对。
字典会保留定义时的顺序。当你打印字典或遍历其元素时,你会看到元素按添加到字典的顺序出现。
从一个空字典开始
有时,开始时使用一个空字典,然后再逐个添加新的项是方便的,甚至是必要的。要开始填充一个空字典,定义一个空的大括号字典,然后将每个键值对单独添加在每一行。例如,以下是如何使用这种方法构建alien_0字典:
alien.py
alien_0 = {}
alien_0['color'] = 'green'
alien_0['points'] = 5
print(alien_0)
我们首先定义一个空的alien_0字典,然后向其中添加颜色和得分值。结果是我们在之前示例中使用的字典:
{'color': 'green', 'points': 5}
通常,在存储用户提供的数据时,或者在编写生成大量键值对的代码时,你会使用空字典。
修改字典中的值
要修改字典中的值,给出字典的名称并用方括号括住键,然后指定你希望与该键关联的新值。例如,考虑一个外星人,在游戏进程中从绿色变为黄色:
alien.py
alien_0 = {'color': 'green'}
print(f"The alien is {alien_0['color']}.")
alien_0['color'] = 'yellow'
print(f"The alien is now {alien_0['color']}.")
我们首先定义一个仅包含外星人颜色的alien_0字典;然后我们将与'color'键关联的值更改为'yellow'。输出显示外星人确实从绿色变成了黄色:
The alien is green.
The alien is now yellow.
对于一个更有趣的例子,让我们跟踪一个可以以不同速度移动的外星人。我们将存储一个表示外星人当前速度的值,然后用它来决定外星人应该向右移动多远:
alien_0 = {'x_position': 0, 'y_position': 25, 'speed': 'medium'}
print(f"Original position: {alien_0['x_position']}")
# Move the alien to the right.
# Determine how far to move the alien based on its current speed.
❶ if alien_0['speed'] == 'slow':
x_increment = 1
elif alien_0['speed'] == 'medium':
x_increment = 2
else:
# This must be a fast alien.
x_increment = 3
# The new position is the old position plus the increment.
❷ alien_0['x_position'] = alien_0['x_position'] + x_increment
print(f"New position: {alien_0['x_position']}")
我们首先定义一个外星人,初始的x位置和y位置为'medium'速度。为了简化,我们省略了颜色和得分值,但如果你包括这些键值对,示例仍然适用。我们还打印了x_position的原始值,看看外星人向右移动了多少。
一个if-elif-else链决定外星人应该向右移动多远,并将这个值赋给变量x_increment ❶。如果外星人的速度是'slow',它向右移动一个单位;如果速度是'medium',它向右移动两个单位;如果是'fast',它向右移动三个单位。计算出增量后,它会加到x_position ❷的值上,并将结果存储在字典的x_position中。
由于这是一个中等速度的外星人,它的位置向右移动了两个单位:
Original x-position: 0
New x-position: 2
这个技巧非常酷:通过改变外星人字典中的一个值,你可以改变外星人的整体行为。例如,要将这个中等速度的外星人变成一个快速的外星人,你需要添加这一行:
alien_0['speed'] = 'fast'
if-elif-else 代码块将在下一次代码运行时将更大的值分配给 x_increment。
删除键值对
当你不再需要字典中存储的信息时,可以使用 del 语句完全删除一个键值对。del 只需要字典的名称和你想要删除的键。
例如,假设我们从 alien_0 字典中删除键 'points',以及它的值:
alien.py
alien_0 = {'color': 'green', 'points': 5}
print(alien_0)
❶ del alien_0['points']
print(alien_0)
del 语句❶告诉 Python 删除字典 alien_0 中的键 'points',并移除与该键关联的值。输出显示,键 'points' 和其值 5 被从字典中删除,但字典中的其他部分没有受到影响:
{'color': 'green', 'points': 5}
{'color': 'green'}
相似对象的字典
前一个示例涉及存储关于一个对象(游戏中的外星人)的不同信息。你还可以使用字典存储关于许多对象的一种信息。例如,假设你想对一些人进行调查,询问他们最喜欢的编程语言是什么。字典非常适合存储这种简单调查的结果,如下所示:
favorite_languages.py
favorite_languages = {
'jen': 'python',
'sarah': 'c',
'edward': 'rust',
'phil': 'python',
}
如你所见,我们将一个较大的字典分成了几行。每个键都是参与调查的人的名字,每个值是他们选择的语言。当你知道定义字典时需要多行时,按 ENTER 键后,输入一个新的左大括号。接着将下一行缩进一个级别(四个空格),然后写入第一个键值对,后面跟着一个逗号。从此以后,每次按 ENTER 键时,你的文本编辑器应自动缩进所有后续的键值对,以匹配第一个键值对。
一旦你完成了字典的定义,在最后一个键值对后添加一个闭括号,并在新的一行上进行缩进,使其与字典中的键对齐。将逗号添加到最后一个键值对后也是一个好习惯,这样你就可以在下一行准备添加新的键值对。
为了使用这个字典,给定一个参与调查的人的名字,你可以轻松查找他们最喜欢的编程语言:
favorite_languages.py
favorite_languages = {
'jen': 'python',
'sarah': 'c',
'edward': 'rust',
'phil': 'python',
}
❶ language = favorite_languages['sarah'].title()
print(f"Sarah's favorite language is {language}.")
要查看 Sarah 选择了哪种语言,我们请求该值:
favorite_languages['sarah']
我们使用这种语法从字典❶中提取 Sarah 最喜欢的编程语言,并将其赋值给变量 language。创建一个新的变量使得 print() 调用更加简洁。输出显示了 Sarah 最喜欢的编程语言:
Sarah's favorite language is C.
你可以使用相同的语法与字典中的任何单个对象。
使用 get() 访问值
使用方括号中的键从字典中检索你感兴趣的值可能会导致一个潜在的问题:如果你请求的键不存在,你将得到一个错误。
让我们看看当你请求一个没有设置点数值的外星人的点数值时会发生什么:
alien_no_points.py
alien_0 = {'color': 'green', 'speed': 'slow'}
print(alien_0['points'])
这会导致一个追溯错误,显示一个 KeyError:
Traceback (most recent call last):
File "alien_no_points.py", line 2, in <module>
print(alien_0['points'])
~~~~~~~^^^^^^^^^^
KeyError: 'points'
你将在第十章中了解如何处理类似的错误。针对字典,你可以使用get()方法来设置一个默认值,当请求的键不存在时,返回该默认值。
get()方法需要一个键作为第一个参数。作为第二个可选参数,你可以传递一个当键不存在时返回的默认值:
alien_0 = {'color': 'green', 'speed': 'slow'}
point_value = alien_0.get('points', 'No point value assigned.')
print(point_value)
如果字典中存在键'points',你将得到对应的值。如果不存在,则会返回默认值。在这种情况下,points不存在,我们得到的是一个干净的消息,而不是错误:
No point value assigned.
如果你请求的键可能不存在,考虑使用get()方法,而不是方括号表示法。
遍历字典
一个 Python 字典可以包含少量的键值对,也可以包含数百万个键值对。由于字典可以存储大量数据,Python 允许你遍历字典。字典可以用多种方式存储信息,因此也有多种方式可以遍历它们。你可以遍历字典的所有键值对、遍历它的键,或者遍历它的值。
遍历所有键值对
在我们探索不同的遍历方法之前,让我们考虑一个新的字典,用来存储网站上某个用户的信息。以下字典将存储一个人的用户名、名字和姓氏:
user.py
user_0 = {
'username': 'efermi',
'first': 'enrico',
'last': 'fermi',
}
基于你在本章中学到的知识,你可以访问user_0的任何信息。但是,如果你想查看该用户字典中存储的所有内容怎么办?你可以使用for循环遍历字典:
user_0 = {
'username': 'efermi',
'first': 'enrico',
'last': 'fermi',
}
for key, value in user_0.items():
print(f"\nKey: {key}")
print(f"Value: {value}")
要为字典编写for循环,你需要为每一对键值对创建两个变量名,用于存储键和值。你可以为这两个变量选择任何你喜欢的名字。如果你使用缩写作为变量名,代码依然能够正常工作,例如:
for k, v in user_0.items()
for语句的第二部分包括字典的名称,后跟items()方法,该方法返回键值对的序列。然后,for循环将这些对分配给提供的两个变量。在前面的示例中,我们使用这些变量打印每个key,后跟相应的value。第一个print()调用中的"\n"确保在输出的每个键值对之前插入一个空行:
Key: username
Value: efermi
Key: first
Value: enrico
Key: last
Value: fermi
遍历所有键值对在字典中尤其有效,比如第 96 页的favorite_languages.py示例,它存储了许多不同键的相同类型的信息。如果你遍历favorite_languages字典,你将得到字典中每个人的名字和他们最喜欢的编程语言。因为键总是指向一个人的名字,值总是指向一种语言,所以我们在循环中会使用变量name和language,而不是key和value。这样可以让你更容易理解循环内发生的事情:
favorite_languages.py
favorite_languages = {
'jen': 'python',
'sarah': 'c',
'edward': 'rust',
'phil': 'python',
}
for name, language in favorite_languages.items():
print(f"{name.title()}'s favorite language is {language.title()}.")
这段代码告诉 Python 遍历字典中的每个键值对。它在处理每个键值对时,将键分配给变量name,将值分配给变量language。这些描述性的变量名使得你更容易理解print()语句在做什么。
现在,只需几行代码,我们就可以显示民意调查的所有信息:
Jen's favorite language is Python.
Sarah's favorite language is C.
Edward's favorite language is Rust.
Phil's favorite language is Python.
如果我们的字典存储了对一千人甚至一百万人的调查结果,这种类型的循环同样有效。
遍历字典中的所有键
当你不需要处理字典中的所有值时,keys()方法非常有用。让我们遍历favorite_languages字典,并打印出所有参与调查的人的名字:
favorite_languages = {
'jen': 'python',
'sarah': 'c',
'edward': 'rust',
'phil': 'python',
}
for name in favorite_languages.keys():
print(name.title())
这个for循环告诉 Python 从字典favorite_languages中提取所有的键,并一次性将它们分配给变量name。输出结果显示了所有参与调查的人的名字:
Jen
Sarah
Edward
Phil
遍历键实际上是默认的行为,因此,如果你写成:
for name in favorite_languages:
而不是:
for name in favorite_languages.keys():
如果你觉得这样能让代码更易读,可以显式使用keys()方法,或者如果你愿意,也可以省略它。
你可以在循环内通过当前的键来访问任何你关心的键值对的值。让我们打印一些朋友选择的编程语言的消息。我们将像之前那样遍历字典中的名字,但当名字与我们的朋友之一匹配时,我们将显示关于他们最喜欢的语言的消息:
favorite_languages = {
*--snip--*
}
friends = ['phil', 'sarah']
for name in favorite_languages.keys():
print(f"Hi {name.title()}.")
❶ if name in friends:
❷ language = favorite_languages[name].title()
print(f"\t{name.title()}, I see you love {language}!")
首先,我们列出一些朋友的名字,准备给他们打印一条消息。在循环中,我们打印每个人的名字。然后,我们检查当前正在处理的name是否在friends列表中❶。如果在,我们使用字典的名字和当前name的值作为键来确定这个人最喜欢的语言❷。接着,我们打印一条特别的问候语,包含他们选择的编程语言。
每个人的名字都会被打印出来,但我们的朋友们会收到一条特别的消息:
Hi Jen.
Hi Sarah.
Sarah, I see you love C!
Hi Edward.
Hi Phil.
Phil, I see you love Python!
你还可以使用keys()方法来检查某个人是否参与了调查。这次,让我们来看看 Erin 是否参与了调查:
favorite_languages = {
*--snip--*
}
if 'erin' not in favorite_languages.keys():
print("Erin, please take our poll!")
keys() 方法不仅用于循环:它实际上返回所有键的序列,而 if 语句仅检查 'erin' 是否在这个序列中。因为她不在,所以打印出一条信息邀请她参与调查:
Erin, please take our poll!
按特定顺序遍历字典的键
遍历字典时,返回的项与它们插入字典的顺序相同。不过,有时你可能希望以不同的顺序遍历字典。
一种方法是在 for 循环中按返回的顺序对键进行排序。你可以使用 sorted() 函数来按顺序获取键的副本:
favorite_languages = {
'jen': 'python',
'sarah': 'c',
'edward': 'rust',
'phil': 'python',
}
for name in sorted(favorite_languages.keys()):
print(f"{name.title()}, thank you for taking the poll.")
这个 for 语句与其他 for 语句类似,唯一的区别是我们在 dictionary.keys() 方法周围包裹了 sorted() 函数。这告诉 Python 获取字典中的所有键,并在开始循环之前将其排序。输出结果显示所有参与调查的人,名字按顺序排列:
Edward, thank you for taking the poll.
Jen, thank you for taking the poll.
Phil, thank you for taking the poll.
Sarah, thank you for taking the poll.
遍历字典中的所有值
如果你主要对字典中包含的值感兴趣,可以使用 values() 方法返回一个值的序列,而不包含任何键。例如,假设我们只想要一个所有语言的列表,而不包含选择每种语言的人的名字:
favorite_languages = {
'jen': 'python',
'sarah': 'c',
'edward': 'rust',
'phil': 'python',
}
print("The following languages have been mentioned:")
for language in favorite_languages.values():
print(language.title())
这里的 for 语句从字典中提取每个值,并将其分配给变量 language。当这些值被打印时,我们得到一个所有选择语言的列表:
The following languages have been mentioned:
Python
C
Rust
Python
这种方法从字典中提取所有值,而不检查是否有重复项。对于较少的值数量,这可能没问题,但如果是调查中有大量回应者时,结果会是一个非常重复的列表。为了查看每种语言而不重复,我们可以使用集合。集合 是一个其中每个项必须唯一的集合:
favorite_languages = {
*--snip--*
}
print("The following languages have been mentioned:")
for language in set(favorite_languages.values()):
print(language.title())
当你将 set() 包裹在包含重复项的值集合周围时,Python 会识别集合中唯一的项,并根据这些项构建一个集合。这里我们使用 set() 从 favorite_languages.values() 中提取唯一语言。
结果是一个没有重复项的语言列表,这些语言是参与调查的人提到的:
The following languages have been mentioned:
Python
C
Rust
随着你继续学习 Python,你会经常发现语言内置的某个特性可以帮助你精确地处理数据。
嵌套
有时候你可能需要将多个字典存储在一个列表中,或者将一个列表作为字典的值。这被称为 嵌套。你可以在列表中嵌套字典,在字典中嵌套项的列表,甚至将字典嵌套在另一个字典中。嵌套是一个强大的特性,接下来的示例将演示这一点。
字典的列表
alien_0字典包含有关一个外星人的各种信息,但它没有空间存储第二个外星人的信息,更不用说一整屏外星人了。你如何管理一支外星人舰队呢?一种方法是创建一个外星人列表,其中每个外星人都是一个包含该外星人信息的字典。例如,以下代码构建了一个包含三个外星人的列表:
aliens.py
alien_0 = {'color': 'green', 'points': 5}
alien_1 = {'color': 'yellow', 'points': 10}
alien_2 = {'color': 'red', 'points': 15}
❶ aliens = [alien_0, alien_1, alien_2]
for alien in aliens:
print(alien)
我们首先创建三个字典,每个字典代表一个不同的外星人。我们将这些字典存储在一个名为aliens的列表中❶。最后,我们遍历列表并打印出每个外星人:
{'color': 'green', 'points': 5}
{'color': 'yellow', 'points': 10}
{'color': 'red', 'points': 15}
一个更现实的例子可能包含超过三个外星人,并且代码会自动生成每个外星人。在下面的示例中,我们使用range()来创建一个 30 个外星人的舰队:
# Make an empty list for storing aliens.
aliens = []
# Make 30 green aliens.
❶ for alien_number in range(30):
❷ new_alien = {'color': 'green', 'points': 5, 'speed': 'slow'}
❸ aliens.append(new_alien)
# Show the first 5 aliens.
❹ for alien in aliens[:5]:
print(alien)
print("...")
# Show how many aliens have been created.
print(f"Total number of aliens: {len(aliens)}")
这个示例从一个空的列表开始,用来保存将要创建的所有外星人。range()函数❶返回一系列数字,它只是告诉 Python 我们希望循环重复多少次。每次循环运行时,我们创建一个新的外星人❷,然后将每个新的外星人追加到列表aliens中❸。我们使用切片打印前五个外星人❹,最后,我们打印列表的长度,以证明我们已经生成了完整的 30 个外星人的舰队:
{'color': 'green', 'points': 5, 'speed': 'slow'}
{'color': 'green', 'points': 5, 'speed': 'slow'}
{'color': 'green', 'points': 5, 'speed': 'slow'}
{'color': 'green', 'points': 5, 'speed': 'slow'}
{'color': 'green', 'points': 5, 'speed': 'slow'}
...
Total number of aliens: 30
这些外星人都有相同的特征,但 Python 将每个外星人视为一个独立的对象,这使得我们可以单独修改每个外星人。
你如何处理这样一组外星人呢?假设游戏的某个环节中,外星人随着游戏的进展改变颜色并且移动得更快。当是时候改变颜色时,我们可以使用for循环和if语句来改变外星人的颜色。例如,为了将前三个外星人改为黄色的、中等速度的外星人,每个外星人值 10 分,我们可以这样做:
# Make an empty list for storing aliens.
aliens = []
# Make 30 green aliens.
for alien_number in range (30):
new_alien = {'color': 'green', 'points': 5, 'speed': 'slow'}
aliens.append(new_alien)
for alien in aliens[:3]:
if alien['color'] == 'green':
alien['color'] = 'yellow'
alien['speed'] = 'medium'
alien['points'] = 10
# Show the first 5 aliens.
for alien in aliens[:5]:
print(alien)
print("...")
因为我们想要修改前三个外星人,所以我们遍历一个只包含前三个外星人的切片。现在所有外星人都是绿色的,但并不总是这样,因此我们写一个if语句来确保只修改绿色外星人。如果外星人是绿色的,我们将其颜色改为'yellow',速度改为'medium',分值改为10,如下所示的输出:
{'color': 'yellow', 'points': 10, 'speed': 'medium'}
{'color': 'yellow', 'points': 10, 'speed': 'medium'}
{'color': 'yellow', 'points': 10, 'speed': 'medium'}
{'color': 'green', 'points': 5, 'speed': 'slow'}
{'color': 'green', 'points': 5, 'speed': 'slow'}
...
你可以通过添加一个elif块来扩展这个循环,将黄色的外星人变成红色的、快速移动的外星人,每个外星人的分数为 15 分。省略完整程序的情况下,这个循环看起来像这样:
for alien in aliens[0:3]:
if alien['color'] == 'green':
alien['color'] = 'yellow'
alien['speed'] = 'medium'
alien['points'] = 10
elif alien['color'] == 'yellow':
alien['color'] = 'red'
alien['speed'] = 'fast'
alien['points'] = 15
当每个字典包含关于一个对象的多种信息时,将多个字典存储在列表中是很常见的做法。例如,你可以为网站上的每个用户创建一个字典,就像我们在第 99 页的user.py中所做的那样,并将这些字典存储在一个名为users的列表中。列表中的所有字典应该具有相同的结构,这样你就可以遍历列表,并以相同的方式处理每个字典对象。
字典中的列表
有时,将列表放入字典中比将字典放入列表中更为有用。例如,考虑一下如何描述一个人正在点的披萨。如果你只使用列表,实际上你只能存储披萨的配料列表。使用字典时,配料列表可以仅仅是你描述的披萨的一个方面。
在以下示例中,每个披萨都存储了两种信息:一种是饼皮类型,另一种是配料列表。配料列表是与键 'toppings' 关联的值。为了使用列表中的项目,我们只需给出字典的名称和键 'toppings',就像访问字典中的任何值一样。返回的不是单个值,而是一个配料列表:
pizza.py
# Store information about a pizza being ordered.
pizza = {
'crust': 'thick',
'toppings': ['mushrooms', 'extra cheese'],
}
# Summarize the order.
❶ print(f"You ordered a {pizza['crust']}-crust pizza "
"with the following toppings:")
❷ for topping in pizza['toppings']:
print(f"\t{topping}")
我们从一个字典开始,字典中包含已点披萨的信息。字典中的一个键是 'crust',其对应的值是字符串 'thick'。下一个键 'toppings' 的值是一个存储所有请求配料的列表。在制作披萨之前,我们先总结一下订单❶。当你需要在 print() 调用中拆分一行时,选择一个适当的位置拆分打印的行,并以引号结束该行。接下来缩进新的一行,添加开引号,并继续字符串。Python 会自动将括号内的所有字符串组合在一起。为了打印配料,我们写一个 for 循环❷。要访问配料列表,我们使用键 'toppings',然后 Python 会从字典中获取配料列表。
以下输出总结了我们计划制作的披萨:
You ordered a thick-crust pizza with the following toppings:
mushrooms
extra cheese
你可以随时将列表嵌套在字典中,只要你希望将多个值与字典中的单个键相关联。在之前的最喜欢编程语言的示例中,如果我们将每个人的回答存储在一个列表中,那么人们可以选择多个最喜欢的语言。当我们遍历字典时,与每个人相关联的值将是一个语言列表,而不是单一的语言。在字典的 for 循环内部,我们使用另一个 for 循环来遍历与每个人相关联的语言列表:
favorite_languages.py
favorite_languages = {
'jen': ['python', 'rust'],
'sarah': ['c'],
'edward': ['rust', 'go'],
'phil': ['python', 'haskell'],
}
❶ for name, languages in favorite_languages.items():
print(f"\n{name.title()}'s favorite languages are:")
❷ for language in languages:
print(f"\t{language.title()}")
现在,favorite_languages 中每个名字所对应的值都是一个列表。注意,有些人只有一个最喜欢的语言,而有些人有多个最喜欢的语言。当我们遍历字典❶时,我们使用变量名 languages 来存储字典中的每个值,因为我们知道每个值将是一个列表。在主字典循环内部,我们再用一个 for 循环❷来遍历每个人的最喜欢语言列表。现在,每个人都可以列出他们喜欢的多个编程语言:
Jen's favorite languages are:
Python
Rust
Sarah's favorite languages are:
C
Edward's favorite languages are:
Rust
Go
Phil's favorite languages are:
Python
Haskell
为了进一步优化这个程序,你可以在字典的for循环开始时加入一个if语句,检查每个人是否有多个喜欢的编程语言,方法是检查len(languages)的值。如果某人有多个喜欢的语言,输出保持不变。如果某人只有一个喜欢的语言,你可以修改措辞以反映这一点。例如,你可以说:“Sarah 最喜欢的编程语言是 C。”
字典中的字典
你可以将一个字典嵌套在另一个字典中,但这样做时你的代码可能会迅速变得复杂。例如,如果你为一个网站拥有多个用户,每个用户都有一个唯一的用户名,你可以使用用户名作为字典中的键。然后,你可以使用字典作为与用户名相关联的值来存储每个用户的信息。在下面的代码中,我们存储了每个用户的三项信息:名字、姓氏和位置。我们将通过遍历用户名和与每个用户名相关联的字典来访问这些信息:
many_users.py
users = {
'aeinstein': {
'first': 'albert',
'last': 'einstein',
'location': 'princeton',
},
'mcurie': {
'first': 'marie',
'last': 'curie',
'location': 'paris',
},
}
❶ for username, user_info in users.items():
❷ print(f"\nUsername: {username}")
❸ full_name = f"{user_info['first']} {user_info['last']}"
location = user_info['location']
❹ print(f"\tFull name: {full_name.title()}")
print(f"\tLocation: {location.title()}")
我们首先定义一个名为users的字典,它包含两个键:分别对应用户名'aeinstein'和'mcurie'。与每个键关联的值是一个字典,包含每个用户的名字、姓氏和位置。接着,我们遍历users字典 ❶。Python 将每个键赋值给变量username,与每个用户名相关联的字典则赋值给变量user_info。进入主字典循环后,我们打印出用户名 ❷。
接下来,我们开始访问内层字典 ❸。包含用户信息的字典user_info有三个键:'first'、'last'和'location'。我们使用每个键来生成每个人整齐格式化的全名和位置,然后打印出我们对每个用户的总结 ❹:
Username: aeinstein
Full name: Albert Einstein
Location: Princeton
Username: mcurie
Full name: Marie Curie
Location: Paris
请注意,每个用户字典的结构是相同的。虽然 Python 没有强制要求这一点,但这种结构使得嵌套字典更容易操作。如果每个用户字典的键不同,for循环中的代码将更加复杂。
总结
在本章中,你学习了如何定义字典以及如何操作字典中存储的信息。你学习了如何访问和修改字典中的单个元素,以及如何遍历字典中的所有信息。你学会了如何遍历字典的键值对、键和值。你还学会了如何将多个字典嵌套在列表中,将列表嵌套在字典中,和将字典嵌套在字典中。
在下一章,你将学习while循环以及如何接受使用你程序的人的输入。这将是一个令人兴奋的章节,因为你将学习如何让你的程序变得互动:它们将能够响应用户输入。
第七章:用户输入与 while 循环

大多数程序是为了解决最终用户的问题而编写的。为此,你通常需要从用户那里获取一些信息。例如,假设有人想知道自己是否足够老,可以投票。如果你编写一个程序来回答这个问题,你需要知道用户的年龄,才能给出答案。程序将要求用户输入或提供他们的年龄;一旦程序获得了这个输入,它就可以将其与投票年龄进行比较,以判断用户是否足够老,然后报告结果。
在这一章中,你将学习如何接受用户输入,以便你的程序可以使用这些输入。当程序需要一个名字时,你可以提示用户输入名字。当程序需要一系列名字时,你也可以提示用户输入多个名字。为此,你将使用input()函数。
你还将学习如何让程序持续运行,直到用户希望它停止,这样他们可以输入所需的所有信息;然后,你的程序可以利用这些信息进行处理。你将使用 Python 的while循环来让程序在特定条件成立时持续运行。
通过能够处理用户输入以及控制程序运行时间的能力,你将能够编写完全互动的程序。
input()函数的工作原理
input()函数会暂停程序并等待用户输入文本。一旦 Python 接收到用户的输入,它会将输入赋值给一个变量,以便你方便地使用它。
例如,下面的程序会要求用户输入一些文本,然后将该消息显示给用户:
parrot.py
message = input("Tell me something, and I will repeat it back to you: ")
print(message)
input()函数接受一个参数:提示信息,用于告诉用户需要输入什么类型的信息。在这个例子中,当 Python 运行第一行时,用户会看到提示信息Tell me something, and I will repeat it back to you:。程序在等待用户输入响应,用户按下回车键后,程序继续执行。响应被赋值给变量message,然后print(message)会将输入内容显示给用户:
Tell me something, and I will repeat it back to you: **Hello everyone!**
Hello everyone!
编写清晰的提示信息
每次使用input()函数时,你都应该包含一个清晰、易于理解的提示,告诉用户你需要什么类型的信息。任何能告诉用户需要输入什么内容的语句都可以。例如:
greeter.py
name = input("Please enter your name: ")
print(f"\nHello, {name}!")
在提示信息末尾添加一个空格(在前面的示例中冒号后面)来将提示与用户的响应分开,并且清晰地告诉用户应该在何处输入他们的文本。例如:
Please enter your name: **Eric**
Hello, Eric!
有时候你可能需要编写一个超过一行的提示信息。例如,你可能想告诉用户你为何要求提供某些输入。你可以将提示信息赋值给一个变量,并将该变量传递给input()函数。这使得你能够在多行中构建提示信息,然后编写一个简洁的input()语句。
greeter.py
prompt = "If you share your name, we can personalize the messages you see."
prompt += "\nWhat is your first name? "
name = input(prompt)
print(f"\nHello, {name}!")
这个例子展示了构建多行字符串的一种方式。第一行将消息的第一部分赋值给变量 prompt。在第二行,运算符 += 将赋值给 prompt 的字符串与新的字符串连接起来。
现在提示符跨越了两行,再次在问号后留有空格以增强清晰度:
If you share your name, we can personalize the messages you see.
What is your first name? **Eric**
Hello, Eric!
使用 int() 接受数值输入
当你使用 input() 函数时,Python 将用户输入的所有内容都视为字符串。考虑以下的解释器会话,它询问用户的年龄:
>>> **age = input("How old are you? ")**
How old are you? **21**
>>> **age**
'21'
用户输入数字 21,但是当我们请求 Python 获取 age 的值时,它返回了 '21',即输入的数值的字符串表示。我们知道 Python 将输入解释为字符串,因为该数字现在被引号括起来。如果你只是想打印输入内容,这样没问题。但如果你尝试将输入作为数字使用,你将遇到错误:
>>> **age = input("How old are you? ")**
How old are you? **21**
❶ >>> **age >= 18**
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
❷ TypeError: '>=' not supported between instances of 'str' and 'int'
当你尝试使用输入进行数值比较 ❶ 时,Python 会产生错误,因为它不能将字符串与整数进行比较:分配给 age 的字符串 '21' 不能与数值 18 ❷ 进行比较。
我们可以通过使用 int() 函数来解决这个问题,它将输入的字符串转换为数值。这使得比较能够成功执行:
>>> **age = input("How old are you? ")**
How old are you? **21**
❶ >>> **age = int(age)**
>>> **age >= 18**
True
在这个例子中,当我们在提示符下输入 21 时,Python 将该数字解释为字符串,但该值随后通过 int() ❶ 被转换为数值表示。现在,Python 可以执行条件测试:它将 age(现在表示数值 21)与 18 进行比较,判断 age 是否大于或等于 18。这个测试的结果是 True。
如何在实际程序中使用 int() 函数?考虑一个程序,判断人们是否足够高,能够坐过山车:
rollercoaster.py
height = input("How tall are you, in inches? ")
height = int(height)
if height >= 48:
print("\nYou're tall enough to ride!")
else:
print("\nYou'll be able to ride when you're a little older.")
该程序可以将 height 与 48 进行比较,因为 height = int(height) 在进行比较之前将输入值转换为数值表示。如果输入的数字大于或等于 48,我们会告诉用户他们的身高足够高:
How tall are you, in inches? **71**
You're tall enough to ride!
当你使用数值输入进行计算和比较时,请务必先将输入值转换为数值表示。
取余运算符
处理数值信息时,一个有用的工具是 取余运算符(%),它将一个数字除以另一个数字并返回余数:
>>> **4 % 3**
1
>>> **5 % 3**
2
>>> **6 % 3**
0
>>> **7 % 3**
1
取余运算符不会告诉你一个数字能被另一个数字整除多少次;它只告诉你余数是多少。
当一个数字能被另一个数字整除时,余数为 0,因此取余运算符总是返回 0。你可以利用这一点来判断一个数字是偶数还是奇数:
even_or_odd.py
number = input("Enter a number, and I'll tell you if it's even or odd: ")
number = int(number)
if number % 2 == 0:
print(f"\nThe number {number} is even.")
else:
print(f"\nThe number {number} is odd.")
偶数总是可以被二整除,因此如果一个数字与 2 的取余为零(在这里,if number % 2 == 0),则该数字是偶数。否则,它是奇数。
Enter a number, and I'll tell you if it's even or odd: **42**
The number 42 is even.
引入 while 循环
for循环会遍历一个集合中的每个项,并为集合中的每个项执行一次代码块。相比之下,while循环则是只要某个条件为真,就继续运行。
while循环的实际运行
你可以使用while循环来通过一系列数字进行计数。例如,以下while循环从 1 计数到 5:
counting.py
current_number = 1
while current_number <= 5:
print(current_number)
current_number += 1
在第一行中,我们通过将current_number赋值为 1 来开始计数。然后,while循环被设置为在current_number的值小于或等于 5 时持续运行。循环中的代码打印current_number的值,然后使用current_number += 1将该值加 1。(+=运算符是current_number = current_number + 1的简写。)
只要current_number <= 5的条件为真,Python 就会重复执行循环。因为 1 小于 5,所以 Python 打印1,然后加 1,使当前数字变为2。因为 2 小于 5,所以 Python 打印2,再次加 1,使当前数字变为3,依此类推。当current_number的值大于 5 时,循环停止,程序结束:
1
2
3
4
5
你每天使用的程序很可能包含while循环。例如,一个游戏需要使用while循环来保持运行,直到你想停止为止,这样它就能在你要求退出时停止运行。如果程序在我们要求退出之前就停止运行,或者在我们想退出后仍然运行,那就不好玩了,所以while循环非常有用。
让用户选择何时退出
我们可以通过将大部分程序放入while循环中,让parrot.py程序运行直到用户想退出为止。我们将定义一个退出值,然后只要用户没有输入退出值,就继续运行程序:
parrot.py
prompt = "\nTell me something, and I will repeat it back to you:"
prompt += "\nEnter 'quit' to end the program. "
message = ""
while message != 'quit':
message = input(prompt)
print(message)
我们首先定义一个提示,告诉用户他们的两个选项:输入一条信息或输入退出值(在此案例中为'quit')。然后,我们设置一个变量message来跟踪用户输入的值。我们将message定义为空字符串"",这样 Python 在第一次进入while语句时会有东西可以检查。当程序第一次运行并且 Python 到达while语句时,它需要将message的值与'quit'进行比较,但此时用户尚未输入任何内容。如果 Python 没有东西可以比较,它将无法继续执行程序。为了解决这个问题,我们确保给message一个初始值。虽然它只是一个空字符串,但它对 Python 是有意义的,并且可以让 Python 进行比较,从而使while循环得以正常工作。这个while循环会在message的值不是'quit'时继续运行。
在第一次循环时,message只是一个空字符串,因此 Python 进入循环。在message = input(prompt)时,Python 显示提示并等待用户输入。当用户输入的内容被赋值给message并打印后,Python 会重新评估while语句中的条件。只要用户没有输入'quit',提示就会再次显示,Python 会等待更多输入。当用户最终输入'quit'时,Python 会停止执行while循环,程序结束:
Tell me something, and I will repeat it back to you:
Enter 'quit' to end the program. **Hello everyone!**
Hello everyone!
Tell me something, and I will repeat it back to you:
Enter 'quit' to end the program. **Hello again.**
Hello again.
Tell me something, and I will repeat it back to you:
Enter 'quit' to end the program. **quit**
quit
这个程序运行良好,唯一的问题是它像打印一个实际的消息一样打印了单词'quit'。一个简单的if测试就能解决这个问题:
prompt = "\nTell me something, and I will repeat it back to you:"
prompt += "\nEnter 'quit' to end the program. "
message = ""
while message != 'quit':
message = input(prompt)
if message != 'quit':
print(message)
现在程序在显示消息之前进行快速检查,只有在消息与退出值不匹配时才会打印该消息:
Tell me something, and I will repeat it back to you:
Enter 'quit' to end the program. **Hello everyone!**
Hello everyone!
Tell me something, and I will repeat it back to you:
Enter 'quit' to end the program. **Hello again.**
Hello again.
Tell me something, and I will repeat it back to you:
Enter 'quit' to end the program. **quit**
使用标志
在前面的例子中,我们让程序在给定条件为真时执行某些任务。但如果是更复杂的程序,其中许多不同的事件可能导致程序停止运行呢?
例如,在游戏中,几个不同的事件可能会结束游戏。当玩家的舰船耗尽、时间用完,或者他们应该保护的城市全部被摧毁时,游戏应该结束。只要发生了其中任何一个事件,游戏就应结束。如果有很多可能的事件可能导致程序停止,尝试在一个while语句中测试所有这些条件会变得复杂且困难。
对于一个应在多个条件都为真时才运行的程序,你可以定义一个变量来决定程序是否继续运行。这个变量被称为标志,它充当程序的信号。我们可以编写程序,使其在标志被设置为True时运行,并在多个事件之一将标志的值设置为False时停止运行。结果,我们的while语句只需要检查一个条件:标志是否当前为True。然后,所有其他的测试(检查是否发生了某个事件应该将标志设置为False)可以在程序的其余部分整齐地组织。
让我们在前一节的parrot.py程序中添加一个标志。这个标志,我们称之为active(当然你也可以用其他名称),将监控程序是否应该继续运行:
prompt = "\nTell me something, and I will repeat it back to you:"
prompt += "\nEnter 'quit' to end the program. "
active = True
❶ while active:
message = input(prompt)
if message == 'quit':
active = False
else:
print(message)
我们将变量active设置为True,以便程序在活动状态下启动。这样,while语句就变得更简洁,因为在while语句本身没有进行任何比较;逻辑被转移到程序的其他部分。只要active变量保持True,循环就会继续运行❶。
在while循环中的if语句里,我们在用户输入后检查message的值。如果用户输入'quit',我们将active设置为False,然后while循环停止。如果用户输入除'quit'以外的任何内容,我们将他们的输入打印为消息。
这个程序与之前的示例有相同的输出,后者是将条件测试直接放在while语句中。但现在,由于我们有一个标志来指示整体程序是否处于活动状态,因此可以很容易地添加更多的测试(例如elif语句),用于处理应使active变为False的事件。这在复杂的程序中很有用,比如游戏,在这些程序中可能有许多事件应该导致程序停止运行。当这些事件中的任何一个导致活动标志变为False时,主游戏循环将退出,可以显示游戏结束消息,并且玩家可以选择重新开始游戏。
使用 break 退出循环
要立即退出while循环而不运行循环中剩余的任何代码(无论条件测试的结果如何),请使用break语句。break语句控制程序的流程;你可以使用它来控制哪些代码行被执行,哪些不被执行,从而让程序仅在你希望的时刻执行你想要的代码。
例如,考虑一个询问用户曾经去过哪些地方的程序。我们可以通过在用户输入'quit'值时调用break来停止此程序中的while循环:
cities.py
prompt = "\nPlease enter the name of a city you have visited:"
prompt += "\n(Enter 'quit' when you are finished.) "
❶ while True:
city = input(prompt)
if city == 'quit':
break
else:
print(f"I'd love to go to {city.title()}!")
一个以while True ❶开头的循环将永远运行,除非遇到break语句。这个程序中的循环会不断询问用户输入他们去过的城市名,直到他们输入'quit'。当他们输入'quit'时,break语句会执行,导致 Python 退出循环:
Please enter the name of a city you have visited:
(Enter 'quit' when you are finished.) **New York**
I'd love to go to New York!
Please enter the name of a city you have visited:
(Enter 'quit' when you are finished.) **San Francisco**
I'd love to go to San Francisco!
Please enter the name of a city you have visited:
(Enter 'quit' when you are finished.) **quit**
在循环中使用 continue
与其完全跳出循环而不执行其余的代码,不如使用continue语句根据条件测试的结果返回到循环的开头。例如,考虑一个从 1 到 10 计数但仅打印该范围内奇数的循环:
counting.py
current_number = 0
while current_number < 10:
❶ current_number += 1
if current_number % 2 == 0:
continue
print(current_number)
首先,我们将current_number设置为 0。因为它小于 10,Python 进入了while循环。进入循环后,我们将计数器增加 1 ❶,因此current_number变为 1。接着,if语句检查current_number与 2 的模。如果模为 0(这意味着current_number可以被 2 整除),continue语句告诉 Python 忽略循环的其余部分并返回到开头。如果当前数字不能被 2 整除,循环的其余部分将继续执行,Python 打印当前的数字:
1
3
5
7
9
避免无限循环
每个while循环都需要一种停止运行的方法,以免它继续无限运行。例如,这个计数循环应该从 1 计数到 5:
counting.py
x = 1
while x <= 5:
print(x)
x += 1
然而,如果不小心省略了x += 1这一行,循环将永远运行:
# This loop runs forever!
x = 1
while x <= 5:
print(x)
现在,x的值将从1开始,但永远不会改变。因此,条件测试x <= 5将始终返回True,while循环将永远运行,打印一系列的 1,如下所示:
1
1
1
1
`--snip--`
每个程序员偶尔都会不小心写出无限while循环,尤其是当程序的循环有微妙的退出条件时。如果程序陷入了无限循环,可以按 CTRL-C 或者直接关闭显示程序输出的终端窗口。
为了避免写出无限循环,测试每个while循环,确保循环在你预期的时刻停止。如果你希望程序在用户输入某个特定值时结束,运行程序并输入该值。如果程序没有结束,仔细检查程序是如何处理应导致循环退出的值的。确保程序中至少有一部分可以使循环的条件变为False,或使其到达break语句。
使用while循环与列表和字典
到目前为止,我们只处理一次一个用户的信息。我们接收用户的输入,然后打印该输入或对此的响应。在下次执行while循环时,我们会接收另一个输入值并对此做出回应。但为了跟踪多个用户和信息,我们需要在while循环中使用列表和字典。
for循环适合遍历列表,但你不应该在for循环中修改列表,因为 Python 在跟踪列表中的项时会遇到困难。要在遍历列表时修改它,应该使用while循环。使用while循环与列表和字典结合,可以收集、存储和组织大量输入,以供后续检查和报告。
将项目从一个列表移动到另一个列表
假设有一个新注册但未验证的网站用户列表。在验证这些用户后,我们如何将他们移动到一个单独的已确认用户列表中呢?一种方法是使用while循环从未确认用户列表中提取用户进行验证,然后将他们添加到已确认用户的单独列表中。以下是该代码的示例:
confirmed_users.py
# Start with users that need to be verified,
# and an empty list to hold confirmed users.
❶ unconfirmed_users = ['alice', 'brian', 'candace']
confirmed_users = []
# Verify each user until there are no more unconfirmed users.
# Move each verified user into the list of confirmed users.
❷ while unconfirmed_users:
❸ current_user = unconfirmed_users.pop()
print(f"Verifying user: {current_user.title()}")
❹ confirmed_users.append(current_user)
# Display all confirmed users.
print("\nThe following users have been confirmed:")
for confirmed_user in confirmed_users:
print(confirmed_user.title())
我们从一个包含未确认用户的列表❶(Alice、Brian 和 Candace)开始,并有一个空列表来存放已确认的用户。只要unconfirmed_users列表不为空❷,while循环就会继续执行。在这个循环中,pop()方法会一次从unconfirmed_users列表的末尾移除一个未验证的用户❸。因为 Candace 是unconfirmed_users列表中的最后一个,所以她的名字会首先被移除,赋值给current_user,然后添加到confirmed_users列表中❹。接下来是 Brian,然后是 Alice。
我们通过打印确认消息来模拟确认每个用户,然后将他们添加到已确认用户的列表中。当未确认用户列表缩小时,已确认用户列表会增加。当未确认用户列表为空时,循环停止,并打印已确认用户列表:
Verifying user: Candace
Verifying user: Brian
Verifying user: Alice
The following users have been confirmed:
Candace
Brian
Alice
从列表中移除特定值的所有实例
在第三章中,我们使用了remove()函数从列表中移除特定的值。remove()函数之所以有效,是因为我们关心的值在列表中只出现一次。但是,如果你想从列表中移除某个值的所有实例呢?
假设你有一个包含多个'cat'值的宠物列表。要移除该值的所有实例,你可以运行一个while循环,直到'cat'不再出现在列表中,如下所示:
pets.py
pets = ['dog', 'cat', 'dog', 'goldfish', 'cat', 'rabbit', 'cat']
print(pets)
while 'cat' in pets:
pets.remove('cat')
print(pets)
我们从一个包含多个'cat'实例的列表开始。打印列表后,Python 进入while循环,因为它至少发现一次'cat'出现在列表中。一旦进入循环,Python 会移除第一个'cat'实例,返回到while行,然后当它发现'cat'仍然在列表中时再次进入循环。它会移除每一个'cat'实例,直到列表中不再有该值,此时 Python 退出循环并再次打印列表:
['dog', 'cat', 'dog', 'goldfish', 'cat', 'rabbit', 'cat']
['dog', 'dog', 'goldfish', 'rabbit']
使用用户输入填充字典
你可以在每次通过while循环时提示用户输入所需的内容。让我们制作一个投票程序,其中每次通过循环时都提示参与者输入名字和回答。我们将把收集的数据存储在字典中,因为我们希望将每个回答与特定的用户关联:
mountain_poll.py
responses = {}
# Set a flag to indicate that polling is active.
polling_active = True
while polling_active:
# Prompt for the person's name and response.
❶ name = input("\nWhat is your name? ")
response = input("Which mountain would you like to climb someday? ")
# Store the response in the dictionary.
❷ responses[name] = response
# Find out if anyone else is going to take the poll.
❸ repeat = input("Would you like to let another person respond? (yes/ no) ")
if repeat == 'no':
polling_active = False
# Polling is complete. Show the results.
print("\n--- Poll Results ---")
❹ for name, response in responses.items():
print(f"{name} would like to climb {response}.")
程序首先定义一个空字典(responses),并设置一个标志(polling_active)来指示投票处于激活状态。只要polling_active为True,Python 就会执行while循环中的代码。
在循环中,用户被提示输入他们的名字和想要攀登的山峰❶。这些信息会存储在responses字典中❷,然后用户会被询问是否继续进行投票❸。如果他们输入yes,程序会再次进入while循环。如果他们输入no,polling_active标志被设置为False,while循环停止运行,最后的代码块❹会显示投票结果。
如果你运行这个程序并输入一些示例响应,你应该看到类似这样的输出:
What is your name? **Eric**
Which mountain would you like to climb someday? **Denali**
Would you like to let another person respond? (yes/ no) **yes**
What is your name? **Lynn**
Which mountain would you like to climb someday? **Devil's Thumb**
Would you like to let another person respond? (yes/ no) **no**
--- Poll Results ---
Eric would like to climb Denali.
Lynn would like to climb Devil's Thumb.
摘要
在这一章中,你学习了如何使用input()让用户在程序中提供自己的信息。你学会了处理文本和数字输入,并且掌握了如何使用while循环让程序根据用户的需求运行。你还看到几种控制while循环流程的方法,诸如设置active标志、使用break语句以及使用continue语句。你学习了如何使用while循环将项目从一个列表移动到另一个列表,并且如何从列表中移除某个值的所有实例。你还学到了如何在字典中使用while循环。
在第八章中,你将学习函数。函数允许你将程序分解成小块,每一块都执行一个特定的任务。你可以根据需要调用一个函数任意次数,也可以将函数存储在单独的文件中。通过使用函数,你将能够编写更高效的代码,这些代码更容易排除故障和维护,并且可以在多个不同的程序中重复使用。
第八章:函数

在本章中,你将学习编写函数,它们是用于执行特定任务的命名代码块。当你想执行某个已经在函数中定义的特定任务时,你需要调用该函数。如果你需要在程序中多次执行该任务,你不需要一次又一次地编写相同的代码;只需调用专门处理该任务的函数,调用会告诉 Python 执行函数内的代码。你会发现,使用函数会使得你的程序更容易编写、阅读、测试和修复。
在本章中,你还将学习多种将信息传递给函数的方式。你将学习如何编写一些主要用于显示信息的函数,以及一些用于处理数据并返回一个或多个值的函数。最后,你将学习如何将函数存储在单独的文件中,这些文件被称为模块,以帮助组织你的主程序文件。
定义一个函数
这是一个简单的名为greet_user()的函数,用于打印问候语:
greeter.py
def greet_user():
"""Display a simple greeting."""
print("Hello!")
greet_user()
这个示例展示了函数最简单的结构。第一行使用关键字def来通知 Python 你正在定义一个函数。这是函数定义,它告诉 Python 函数的名称以及(如果适用)该函数执行任务所需的信息。括号中包含这些信息。在这个例子中,函数的名称是greet_user(),它不需要任何信息来完成工作,因此它的括号是空的。(尽管如此,括号仍然是必须的。)最后,定义以冒号结尾。
紧跟在def greet_user():之后的所有缩进行构成了函数的主体。第二行的文本是一个注释,称为文档字符串,它描述了该函数的功能。当 Python 为程序中的函数生成文档时,它会查找函数定义后的字符串。这些字符串通常用三重引号括起来,这样你就可以写多行文本。
print("Hello!")这一行是该函数主体中唯一的实际代码,因此greet_user()只有一个任务:print("Hello!")。
当你想使用这个函数时,必须调用它。函数调用告诉 Python 执行函数中的代码。要调用一个函数,你需要写出函数的名字,后面跟上括号内的必要信息。因为这里不需要额外的信息,所以调用我们的函数只需输入greet_user()。如预期,它会打印Hello!:
Hello!
向函数传递信息
如果你稍微修改greet_user()函数,它可以通过名字问候用户。为了让函数实现这一功能,你需要在函数定义的def greet_user()的括号内输入username。通过在这里添加username,你允许函数接受你指定的任何username值。现在,每次调用该函数时,它都期望你提供一个username值。当你调用greet_user()时,你可以在括号内传递一个名字,比如'jesse':
def greet_user(username):
"""Display a simple greeting."""
print(f"Hello, {username.title()}!")
greet_user('jesse')
输入greet_user('jesse')调用greet_user()并为函数提供执行print()调用所需的信息。函数接受你传递的名字,并显示该名字的问候语:
Hello, Jesse!
同样地,输入greet_user('sarah')会调用greet_user(),并将sarah传递给它,然后打印Hello, Sarah!。你可以多次调用greet_user()并传递任何你想要的名字,每次都会产生可预测的输出。
参数和参数值
在前面的greet_user()函数中,我们定义了greet_user()需要一个username变量的值。当我们调用该函数并提供信息(一个人的名字)时,它打印出正确的问候语。
在greet_user()的定义中,变量username是参数的一个例子,它是函数完成任务所需的信息。在greet_user('jesse')中,值'jesse'是一个参数值,它是从函数调用传递给函数的信息。我们调用函数时,将我们希望函数处理的值放入括号内。在这个例子中,参数值'jesse'被传递给函数greet_user(),并赋值给参数username。
传递参数
由于一个函数定义可以有多个参数,函数调用可能需要多个参数。你可以通过多种方式向函数传递参数。你可以使用位置参数,它们需要按参数定义时的顺序传递;关键字参数,每个参数由变量名和值组成;以及值的列表和字典。我们将逐一看看这些方法。
位置参数
当你调用一个函数时,Python 必须将函数调用中的每个参数与函数定义中的一个参数进行匹配。实现这一点最简单的方法是根据提供参数的顺序进行匹配。通过这种方式匹配的值被称为位置参数。
为了理解它是如何工作的,考虑一个展示宠物信息的函数。该函数告诉我们每只宠物是什么种类的动物以及宠物的名字,如下所示:
pets.py
❶ def describe_pet(animal_type, pet_name):
"""Display information about a pet."""
print(f"\nI have a {animal_type}.")
print(f"My {animal_type}'s name is {pet_name.title()}.")
❷ describe_pet('hamster', 'harry')
定义表明这个函数需要一个动物的种类和动物的名字❶。当我们调用describe_pet()时,我们需要按顺序提供动物种类和名字。例如,在函数调用中,参数'hamster'被分配给animal_type,参数'harry'被分配给pet_name❷。在函数体内,这两个参数被用来显示正在描述的宠物信息。
输出描述了一只名叫 Harry 的仓鼠:
I have a hamster.
My hamster's name is Harry.
多次函数调用
你可以根据需要多次调用一个函数。描述第二个不同的宠物只需要再调用一次describe_pet():
def describe_pet(animal_type, pet_name):
"""Display information about a pet."""
print(f"\nI have a {animal_type}.")
print(f"My {animal_type}'s name is {pet_name.title()}.")
describe_pet('hamster', 'harry')
describe_pet('dog', 'willie')
在第二次函数调用中,我们将describe_pet()的参数设置为'dog'和'willie'。和之前使用的参数组一样,Python 将'dog'与参数animal_type匹配,将'willie'与参数pet_name匹配。和之前一样,函数执行其功能,但这次它打印出一只名叫 Willie 的狗的值。现在我们有一只名叫 Harry 的仓鼠和一只名叫 Willie 的狗:
I have a hamster.
My hamster's name is Harry.
I have a dog.
My dog's name is Willie.
多次调用一个函数是一个非常高效的工作方式。描述宠物的代码在函数中只需要写一次。然后,每当你想描述一只新宠物时,只需要用新宠物的信息再次调用该函数。即使描述宠物的代码扩展到 10 行,你依然可以通过调用函数一次,用一行代码描述一只新宠物。
位置参数中的顺序很重要
如果你在函数调用中混淆了位置参数的顺序,可能会得到意想不到的结果:
def describe_pet(animal_type, pet_name):
"""Display information about a pet."""
print(f"\nI have a {animal_type}.")
print(f"My {animal_type}'s name is {pet_name.title()}.")
describe_pet('harry', 'hamster')
在这个函数调用中,我们将名字列在前面,动物种类列在后面。因为这次'harry'被列在前面,所以这个值被分配给animal_type。同样,'hamster'被分配给pet_name。现在我们得到了一只名叫“Harry”的仓鼠:
I have a harry.
My harry's name is Hamster.
如果你得到像这样的奇怪结果,请检查确保函数调用中的参数顺序与函数定义中的参数顺序一致。
关键字参数
一个关键字参数是你传递给函数的一个名称-值对。你在参数中直接关联名称和值,因此当你将参数传递给函数时,不会产生混淆(你不会把一只名为“哈利”的仓鼠变成狗)。关键字参数让你不必担心在函数调用中正确地排列参数的顺序,同时它们清晰地指明了每个值在函数调用中的角色。
让我们使用关键字参数重写pets.py来调用describe_pet():
def describe_pet(animal_type, pet_name):
"""Display information about a pet."""
print(f"\nI have a {animal_type}.")
print(f"My {animal_type}'s name is {pet_name.title()}.")
describe_pet(animal_type='hamster', pet_name='harry')
describe_pet()函数本身没有改变。但是当我们调用该函数时,我们显式告诉 Python 每个参数应与哪个实参匹配。当 Python 读取函数调用时,它知道将实参'hamster'分配给参数animal_type,并将实参'harry'分配给pet_name。输出结果正确地显示我们有一只名叫 Harry 的仓鼠。
关键字参数的顺序不重要,因为 Python 知道每个值应该放在哪个位置。以下两个函数调用是等效的:
describe_pet(animal_type='hamster', pet_name='harry')
describe_pet(pet_name='harry', animal_type='hamster')
默认值
编写函数时,你可以为每个参数定义默认值。如果在函数调用中为某个参数提供了实参,Python 会使用该实参的值。如果没有提供,Python 会使用该参数的默认值。因此,当你为参数定义了默认值时,可以省略通常需要在函数调用中编写的相应实参。使用默认值可以简化你的函数调用,并明确函数通常的使用方式。
例如,如果你注意到大多数调用describe_pet()的地方都是用来描述狗的,你可以将animal_type的默认值设置为'dog'。现在,任何调用describe_pet()来描述狗的人都可以省略这一信息:
def describe_pet(pet_name, animal_type='dog'):
"""Display information about a pet."""
print(f"\nI have a {animal_type}.")
print(f"My {animal_type}'s name is {pet_name.title()}.")
describe_pet(pet_name='willie')
我们更改了describe_pet()的定义,为animal_type添加了默认值'dog'。现在,当函数调用时没有指定animal_type,Python 会使用'dog'作为该参数的值:
I have a dog.
My dog's name is Willie.
请注意,函数定义中参数的顺序必须更改。由于默认值使得不需要将动物类型作为实参,因此函数调用中唯一需要提供的实参就是宠物的名字。Python 仍然将其解释为位置参数,因此如果函数仅使用宠物的名字进行调用,该实参将与函数定义中列出的第一个参数匹配。这就是第一个参数需要是pet_name的原因。
现在使用此函数的最简单方法是只在函数调用中提供一只狗的名字:
describe_pet('willie')
这个函数调用的输出与之前的示例相同。唯一提供的实参是'willie',因此它与函数定义中的第一个参数pet_name匹配。因为没有为animal_type提供实参,Python 会使用默认值'dog'。
要描述一只不是狗的动物,你可以使用如下的函数调用:
describe_pet(pet_name='harry', animal_type='hamster')
因为提供了animal_type的显式实参,Python 会忽略该参数的默认值。
等效的函数调用
因为位置参数、关键字参数和默认值可以一起使用,所以你通常会有几种等效的方式来调用一个函数。考虑以下为describe_pet()提供一个默认值的定义:
def describe_pet(pet_name, animal_type='dog'):
根据这个定义,pet_name始终需要提供参数,这个值可以通过位置参数或关键字参数的形式提供。如果描述的动物不是狗,那么调用时必须包括animal_type的参数,这个参数也可以通过位置参数或关键字参数的形式指定。
以下所有调用都会适用于这个函数:
# A dog named Willie.
describe_pet('willie')
describe_pet(pet_name='willie')
# A hamster named Harry.
describe_pet('harry', 'hamster')
describe_pet(pet_name='harry', animal_type='hamster')
describe_pet(animal_type='hamster', pet_name='harry')
每个函数调用的输出都将与之前的示例相同。
你使用哪种调用风格并不重要。只要你的函数调用能产生你想要的输出,就使用你最容易理解的风格。
避免参数错误
当你开始使用函数时,如果遇到关于不匹配参数的错误,不要感到惊讶。不匹配参数的错误发生在你提供的参数数量比函数所需的少或多。例如,如果我们尝试调用describe_pet()而不提供任何参数,会发生以下情况:
def describe_pet(animal_type, pet_name):
"""Display information about a pet."""
print(f"\nI have a {animal_type}.")
print(f"My {animal_type}'s name is {pet_name.title()}.")
describe_pet()
Python 识别出函数调用中缺少了一些信息,错误追踪告诉我们:
Traceback (most recent call last):
❶ File "pets.py", line 6, in <module>
❷ describe_pet()
^^^^^^^^^^^^^^
❸ TypeError: describe_pet() missing 2 required positional arguments:
'animal_type' and 'pet_name'
错误追踪首先告诉我们问题所在的位置 ❶,让我们回过头来看,发现问题出现在函数调用中。接下来,错误的函数调用被列出 ❷。最后,错误追踪告诉我们调用缺少两个参数,并报告缺少的参数名称 ❸。如果这个函数在一个独立的文件中,我们可能不需要打开文件并阅读函数代码,就能正确地重写调用。
Python 很有帮助,它会读取函数的代码并告诉我们需要提供的参数名称。这也是给变量和函数命名时使用描述性名称的另一个原因。如果这样做,Python 的错误信息将更有用,帮助你或其他使用你代码的人解决问题。
如果你提供了太多参数,你应该会得到类似的错误追踪信息,帮助你正确匹配函数调用与函数定义。
返回值
一个函数并不总是需要直接显示它的输出。相反,它可以处理一些数据,然后返回一个值或一组值。函数返回的值称为返回值。return语句将函数内部的值取出,并发送回调用该函数的行。返回值使得你可以将程序中许多繁琐的工作移到函数中,从而简化程序的主体部分。
返回一个简单的值
让我们看一个接受名字和姓氏并返回格式化全名的函数:
formatted_name.py
def get_formatted_name(first_name, last_name):
"""Return a full name, neatly formatted."""
❶ full_name = f"{first_name} {last_name}"
❷ return full_name.title()
❸ musician = get_formatted_name('jimi', 'hendrix')
print(musician)
get_formatted_name()的定义接受两个参数:名字和姓氏。该函数将这两个名字组合起来,在它们之间添加一个空格,并将结果赋值给full_name ❶。full_name的值被转换为标题大小写,然后返回给调用行 ❷。
当你调用一个返回值的函数时,需要提供一个变量来接收返回的值。在这种情况下,返回的值被赋值给变量musician ❸。输出显示了一个整齐格式化的名字,由一个人的名字部分组成:
Jimi Hendrix
这可能看起来像是为了得到一个格式化好的名字而做了很多工作,但我们也可以直接写成:
print("Jimi Hendrix")
然而,当你考虑在一个需要分别存储许多名和姓的大型程序时,像get_formatted_name()这样的函数就变得非常有用。你可以分别存储名和姓,然后每当你想要显示全名时调用这个函数。
使参数变为可选
有时将一个参数设为可选是有意义的,这样使用该函数的人可以根据需要提供额外的信息。你可以使用默认值来使参数成为可选项。
例如,假设我们想要扩展get_formatted_name()以处理中间名。首次尝试包括中间名可能如下所示:
def get_formatted_name(first_name, middle_name, last_name):
"""Return a full name, neatly formatted."""
full_name = f"{first_name} {middle_name} {last_name}"
return full_name.title()
musician = get_formatted_name('john', 'lee', 'hooker')
print(musician)
这个函数在提供名、中间名和姓时能正常工作。该函数接受名字的三个部分,然后将它们组合成一个字符串。函数会在适当的位置添加空格,并将全名转换为标题大小写:
John Lee Hooker
但是中间名并非总是需要的,这个函数的写法如果你只传入名和姓时将无法正常工作。为了使中间名成为可选项,我们可以给middle_name参数一个空的默认值,并且只有在用户提供值时才处理该参数。为了让get_formatted_name()在没有中间名的情况下工作,我们将middle_name的默认值设为空字符串,并将其移动到参数列表的最后:
def get_formatted_name(first_name, last_name, middle_name=''):
"""Return a full name, neatly formatted."""
❶ if middle_name:
full_name = f"{first_name} {middle_name} {last_name}"
❷ else:
full_name = f"{first_name} {last_name}"
return full_name.title()
musician = get_formatted_name('jimi', 'hendrix')
print(musician)
❸ musician = get_formatted_name('john', 'hooker', 'lee')
print(musician)
在这个示例中,名字由三个可能的部分组成。因为总是有姓和名,所以这些参数在函数定义中列出。中间名是可选的,因此它在定义中列出最后,并且其默认值为空字符串。
在函数体内,我们检查是否提供了中间名。Python 将非空字符串解释为True,因此条件测试if middle_name如果函数调用中有中间名参数,结果会评估为True ❶。如果提供了中间名,首先、其次和最后的名字将被组合成一个全名。这个名字然后被转换为标题大小写,并返回到函数调用行,在那里它被赋值给变量musician并打印。如果没有提供中间名,空字符串将使if测试失败,else块将执行 ❷。全名将只由名和姓组成,格式化后的名字返回到调用行,在那里它被赋值给musician并打印。
用名字和姓氏调用这个函数非常简单。然而,如果我们使用中间名,就必须确保中间名是最后一个传入的参数,这样 Python 才能正确匹配位置参数 ❸。
这个修改后的版本适用于只有名字和姓氏的人,也适用于有中间名的人:
Jimi Hendrix
John Lee Hooker
可选值使得函数能够处理广泛的使用场景,同时保持函数调用尽可能简洁。
返回字典
函数可以返回任何你需要的值,包括更复杂的数据结构,如列表和字典。例如,以下函数接受姓名的各个部分,并返回一个表示人的字典:
person.py
def build_person(first_name, last_name):
"""Return a dictionary of information about a person."""
❶ person = {'first': first_name, 'last': last_name}
❷ return person
musician = build_person('jimi', 'hendrix')
❸ print(musician)
函数build_person()接受名字和姓氏,并将这些值放入一个字典中 ❶。first_name的值被存储在键'first'下,last_name的值被存储在键'last'下。然后,表示这个人的整个字典被返回 ❷。返回值被打印 ❸,原始的两条文本信息现在被存储在字典中:
{'first': 'jimi', 'last': 'hendrix'}
这个函数接受简单的文本信息,并将其放入更有意义的数据结构中,这样你就可以在打印信息之外,对其进行更多操作。字符串'jimi'和'hendrix'现在被标记为名字和姓氏。你可以轻松地扩展这个函数,使其接受可选的值,比如中间名、年龄、职业或你想要存储的任何其他个人信息。例如,以下的更改允许你同时存储一个人的年龄:
def build_person(first_name, last_name, age=None):
"""Return a dictionary of information about a person."""
person = {'first': first_name, 'last': last_name}
if age:
person['age'] = age
return person
musician = build_person('jimi', 'hendrix', age=27)
print(musician)
我们在函数定义中添加了一个新的可选参数age,并为该参数赋予特殊值None,用于表示一个变量没有具体的值。你可以把None看作占位符值。在条件测试中,None会被评估为False。如果函数调用中包含age的值,那么该值将被存储在字典中。这个函数始终存储一个人的名字,但也可以修改以存储关于此人的任何其他信息。
使用带while循环的函数
你可以使用所有迄今为止学过的 Python 结构与函数。例如,让我们使用get_formatted_name()函数与while循环结合,来更正式地问候用户。这里是第一次尝试使用用户的名字和姓氏来问候他们:
greeter.py
def get_formatted_name(first_name, last_name):
"""Return a full name, neatly formatted."""
full_name = f"{first_name} {last_name}"
return full_name.title()
# This is an infinite loop!
while True:
❶ print("\nPlease tell me your name:")
f_name = input("First name: ")
l_name = input("Last name: ")
formatted_name = get_formatted_name(f_name, l_name)
print(f"\nHello, {formatted_name}!")
对于这个例子,我们使用了一个简单版本的get_formatted_name(),它不涉及中间名。while循环会要求用户输入他们的名字,我们分别提示用户输入他们的名字和姓氏 ❶。
但是这个while循环有一个问题:我们没有定义退出条件。当你请求一系列输入时,应该在哪里设置退出条件呢?我们希望用户尽可能容易地退出,因此每个提示都应提供一种退出方式。break语句为在任意提示中退出循环提供了一种简便的方法:
def get_formatted_name(first_name, last_name):
"""Return a full name, neatly formatted."""
full_name = f"{first_name} {last_name}"
return full_name.title()
while True:
print("\nPlease tell me your name:")
print("(enter 'q' at any time to quit)")
f_name = input("First name: ")
if f_name == 'q':
break
l_name = input("Last name: ")
if l_name == 'q':
break
formatted_name = get_formatted_name(f_name, l_name)
print(f"\nHello, {formatted_name}!")
我们添加了一条消息,告知用户如何退出,然后如果用户在任意提示中输入退出值,我们就跳出循环。现在,程序将继续向用户问候,直到有人输入q作为名字:
Please tell me your name:
(enter 'q' at any time to quit)
First name: **eric**
Last name: **matthes**
Hello, Eric Matthes!
Please tell me your name:
(enter 'q' at any time to quit)
First name: **q**
传递一个列表
无论是名字、数字还是更复杂的对象(例如字典),将一个列表传递给函数通常是非常有用的。当你将列表传递给函数时,函数可以直接访问列表的内容。让我们通过使用函数来提高处理列表的效率。
假设我们有一个用户列表,并希望向每个用户打印问候语。以下示例将一个名字列表传递给一个名为greet_users()的函数,该函数会逐一向列表中的每个人问好:
greet_users.py
def greet_users(names):
"""Print a simple greeting to each user in the list."""
for name in names:
msg = f"Hello, {name.title()}!"
print(msg)
usernames = ['hannah', 'ty', 'margot']
greet_users(usernames)
我们定义了greet_users(),使其期望接收一个名字列表,并将其分配给参数names。该函数会遍历它收到的列表,并向每个用户打印问候语。在函数外部,我们定义了一个用户列表,然后在函数调用中将列表usernames传递给greet_users():
Hello, Hannah!
Hello, Ty!
Hello, Margot!
这是我们想要的输出。每个用户都会看到个性化的问候语,且你可以在任何时候调用该函数来问候特定的用户群体。
在函数中修改一个列表
当你将一个列表传递给函数时,函数可以修改这个列表。在函数体内对列表所做的任何更改都是永久性的,这样即使你处理大量数据,也能高效工作。
考虑一个公司,该公司创建用户提交的设计的 3D 打印模型。需要打印的设计存储在一个列表中,打印完成后,它们会被移动到另一个单独的列表。以下代码在不使用函数的情况下实现这一功能:
printing_models.py
# Start with some designs that need to be printed.
unprinted_designs = ['phone case', 'robot pendant', 'dodecahedron']
completed_models = []
# Simulate printing each design, until none are left.
# Move each design to completed_models after printing.
while unprinted_designs:
current_design = unprinted_designs.pop()
print(f"Printing model: {current_design}")
completed_models.append(current_design)
# Display all completed models.
print("\nThe following models have been printed:")
for completed_model in completed_models:
print(completed_model)
这个程序以一个需要打印的设计列表开始,并有一个空列表completed_models,每个设计在打印完成后会被移到该列表中。只要unprinted_designs列表中还有设计,while循环就会通过从列表末尾移除一个设计、将其存储到current_design中,并显示当前设计正在打印的消息来模拟打印每个设计。然后,它会将该设计添加到已完成模型列表中。当循环完成后,将显示打印过的设计列表:
Printing model: dodecahedron
Printing model: robot pendant
Printing model: phone case
The following models have been printed:
dodecahedron
robot pendant
phone case
我们可以通过编写两个函数来重新组织这段代码,每个函数执行一个特定的任务。大部分代码不会改变;我们只是更仔细地进行结构化。第一个函数将处理打印设计,第二个函数将总结已打印的设计:
❶ def print_models(unprinted_designs, completed_models):
"""
Simulate printing each design, until none are left.
Move each design to completed_models after printing.
"""
while unprinted_designs:
current_design = unprinted_designs.pop()
print(f"Printing model: {current_design}")
completed_models.append(current_design)
❷ def show_completed_models(completed_models):
"""Show all the models that were printed."""
print("\nThe following models have been printed:")
for completed_model in completed_models:
print(completed_model)
unprinted_designs = ['phone case', 'robot pendant', 'dodecahedron']
completed_models = []
print_models(unprinted_designs, completed_models)
show_completed_models(completed_models)
我们定义了print_models()函数,它有两个参数:一个是需要打印的设计列表,另一个是已完成模型列表❶。给定这两个列表,该函数通过清空未打印设计的列表并填充已完成模型的列表来模拟打印每个设计。然后我们定义了show_completed_models()函数,它有一个参数:已完成模型的列表❷。根据这个列表,show_completed_models()会显示每个已打印模型的名称。
这个程序的输出与没有函数的版本相同,但代码更有条理。做大部分工作的代码已被移到两个单独的函数中,这使得程序的主体部分更容易理解。看看程序主体,注意你能多么轻松地跟随发生的事情:
unprinted_designs = ['phone case', 'robot pendant', 'dodecahedron']
completed_models = []
print_models(unprinted_designs, completed_models)
show_completed_models(completed_models)
我们建立了一个未打印设计的列表和一个空的已完成模型列表。然后,因为我们已经定义了两个函数,我们所需要做的就是调用它们并传递正确的参数。我们调用print_models()并传递它所需要的两个列表;正如预期的那样,print_models()模拟打印这些设计。然后我们调用show_completed_models()并传递已完成模型的列表,以便它能报告已打印的模型。描述性的函数名称让其他人即使没有注释也能阅读这段代码并理解它。
这个程序比没有函数的版本更容易扩展和维护。如果我们以后需要打印更多设计,只需再次调用print_models()。如果我们意识到打印代码需要修改,我们只需修改一次代码,这些修改就会在函数被调用的所有地方生效。这种方法比在程序中的多个地方分别更新代码更高效。
py`This example also demonstrates the idea that every function should have one specific job. The first function prints each design, and the second displays the completed models. This is more beneficial than using one function to do both jobs. If you’re writing a function and notice the function is doing too many different tasks, try to split the code into two functions. Remember that you can always call a function from another function, which can be helpful when splitting a complex task into a series of steps. ### Preventing a Function from Modifying a List Sometimes you’ll want to prevent a function from modifying a list. For example, say that you start with a list of unprinted designs and write a function to move them to a list of completed models, as in the previous example. You may decide that even though you’ve printed all the designs, you want to keep the original list of unprinted designs for your records. But because you moved all the design names out of `unprinted_designs`, the list is now empty, and the empty list is the only version you have; the original is gone. In this case, you can address this issue by passing the function a copy of the list, not the original. Any changes the function makes to the list will affect only the copy, leaving the original list intact. You can send a copy of a list to a function like this: function_name(list_name[:]) py The slice notation `[:]` makes a copy of the list to send to the function. If we didn’t want to empty the list of unprinted designs in *printing_models.py*, we could call `print_models()` like this: print_models(unprinted_designs[:], completed_models) py The function `print_models()` can do its work because it still receives the names of all unprinted designs. But this time it uses a copy of the original unprinted designs list, not the actual `unprinted_designs` list. The list `completed_models` will fill up with the names of printed models like it did before, but the original list of unprinted designs will be unaffected by the function. Even though you can preserve the contents of a list by passing a copy of it to your functions, you should pass the original list to functions unless you have a specific reason to pass a copy. It’s more efficient for a function to work with an existing list, because this avoids using the time and memory needed to make a separate copy. This is especially true when working with large lists. ## Passing an Arbitrary Number of Arguments Sometimes you won’t know ahead of time how many arguments a function needs to accept. Fortunately, Python allows a function to collect an arbitrary number of arguments from the calling statement. For example, consider a function that builds a pizza. It needs to accept a number of toppings, but you can’t know ahead of time how many toppings a person will want. The function in the following example has one parameter, `*toppings`, but this parameter collects as many arguments as the calling line provides: **pizza.py** def make_pizza(toppings): """打印请求的配料列表。""" print(toppings) make_pizza('pepperoni') make_pizza('mushrooms', 'green peppers', 'extra cheese') py The asterisk in the parameter name `*toppings` tells Python to make a tuple called `toppings`, containing all the values this function receives. The `print()` call in the function body produces output showing that Python can handle a function call with one value and a call with three values. It treats the different calls similarly. Note that Python packs the arguments into a tuple, even if the function receives only one value: ('pepperoni',) ('mushrooms', 'green peppers', 'extra cheese') py Now we can replace the `print()` call with a loop that runs through the list of toppings and describes the pizza being ordered: def make_pizza(toppings): """总结一下我们要做披萨。""" print("\nMaking a pizza with the following toppings:") for topping in toppings: print(f"- {topping}") make_pizza('pepperoni') make_pizza('mushrooms', 'green peppers', 'extra cheese') py The function responds appropriately, whether it receives one value or three values: Making a pizza with the following toppings: - pepperoni Making a pizza with the following toppings: - mushrooms - green peppers - extra cheese py This syntax works no matter how many arguments the function receives. ### Mixing Positional and Arbitrary Arguments If you want a function to accept several different kinds of arguments, the parameter that accepts an arbitrary number of arguments must be placed last in the function definition. Python matches positional and keyword arguments first and then collects any remaining arguments in the final parameter. For example, if the function needs to take in a size for the pizza, that parameter must come before the parameter `*toppings`: def make_pizza(size, toppings): """总结一下我们要做披萨。""" print(f"\nMaking a {size}-inch pizza with the following toppings:") for topping in toppings: print(f"- {topping}") make_pizza(16, 'pepperoni') make_pizza(12, 'mushrooms', 'green peppers', 'extra cheese') py In the function definition, Python assigns the first value it receives to the parameter `size`. All other values that come after are stored in the tuple `toppings`. The function calls include an argument for the size first, followed by as many toppings as needed. Now each pizza has a size and a number of toppings, and each piece of information is printed in the proper place, showing size first and toppings after: Making a 16-inch pizza with the following toppings: - pepperoni Making a 12-inch pizza with the following toppings: - mushrooms - green peppers - extra cheese py ### Using Arbitrary Keyword Arguments Sometimes you’ll want to accept an arbitrary number of arguments, but you won’t know ahead of time what kind of information will be passed to the function. In this case, you can write functions that accept as many key-value pairs as the calling statement provides. One example involves building user profiles: you know you’ll get information about a user, but you’re not sure what kind of information you’ll receive. The function `build_profile()` in the following example always takes in a first and last name, but it accepts an arbitrary number of keyword arguments as well: **user_profile.py** def build_profile(first, last, user_info): """创建一个包含用户所有信息的字典。""" ❶ user_info['first_name'] = first user_info['last_name'] = last return user_info user_profile = build_profile('albert', 'einstein', location='princeton', field='physics') print(user_profile) py The definition of `build_profile()` expects a first and last name, and then it allows the user to pass in as many name-value pairs as they want. The double asterisks before the parameter `**user_info` cause Python to create a dictionary called `user_info` containing all the extra name-value pairs the function receives. Within the function, you can access the key-value pairs in `user_info` just as you would for any dictionary. In the body of `build_profile()`, we add the first and last names to the `user_info` dictionary because we’ll always receive these two pieces of information from the user ❶, and they haven’t been placed into the dictionary yet. Then we return the `user_info` dictionary to the function call line. We call `build_profile()`, passing it the first name `'albert'`, the last name `'einstein'`, and the two key-value pairs `location='princeton'` and `field='physics'`. We assign the returned `profile` to `user_profile` and print `user_profile`: {'location': 'princeton', 'field': 'physics', 'first_name': 'albert', 'last_name': 'einstein'} py The returned dictionary contains the user’s first and last names and, in this case, the location and field of study as well. The function will work no matter how many additional key-value pairs are provided in the function call. You can mix positional, keyword, and arbitrary values in many different ways when writing your own functions. It’s useful to know that all these argument types exist because you’ll see them often when you start reading other people’s code. It takes practice to use the different types correctly and to know when to use each type. For now, remember to use the simplest approach that gets the job done. As you progress, you’ll learn to use the most efficient approach each time. ## Storing Your Functions in Modules One advantage of functions is the way they separate blocks of code from your main program. When you use descriptive names for your functions, your programs become much easier to follow. You can go a step further by storing your functions in a separate file called a *module* and then *importing* that module into your main program. An `import` statement tells Python to make the code in a module available in the currently running program file. Storing your functions in a separate file allows you to hide the details of your program’s code and focus on its higher-level logic. It also allows you to reuse functions in many different programs. When you store your functions in separate files, you can share those files with other programmers without having to share your entire program. Knowing how to import functions also allows you to use libraries of functions that other programmers have written. There are several ways to import a module, and I’ll show you each of these briefly. ### Importing an Entire Module To start importing functions, we first need to create a module. A *module* is a file ending in *.py* that contains the code you want to import into your program. Let’s make a module that contains the function `make_pizza()`. To make this module, we’ll remove everything from the file *pizza.py* except the function `make_pizza()`: **pizza.py** def make_pizza(size, *toppings): """总结一下我们要做披萨。""" print(f"\nMaking a {size}-inch pizza with the following toppings:") for topping in toppings: print(f"- {topping}") py Now we’ll make a separate file called *making_pizzas.py* in the same directory as *pizza.py*. This file imports the module we just created and then makes two calls to `make_pizza()`: **making_pizzas.py** import pizza ❶ pizza.make_pizza(16, 'pepperoni') pizza.make_pizza(12, 'mushrooms', 'green peppers', 'extra cheese') py When Python reads this file, the line `import pizza` tells Python to open the file *pizza.py* and copy all the functions from it into this program. You don’t actually see code being copied between files because Python copies the code behind the scenes, just before the program runs. All you need to know is that any function defined in *pizza.py* will now be available in *making_pizzas.py*. To call a function from an imported module, enter the name of the module you imported, `pizza`, followed by the name of the function, `make_pizza()`, separated by a dot ❶. This code produces the same output as the original program that didn’t import a module: Making a 16-inch pizza with the following toppings: - pepperoni Making a 12-inch pizza with the following toppings: - mushrooms - green peppers - extra cheese py This first approach to importing, in which you simply write `import` followed by the name of the module, makes every function from the module available in your program. If you use this kind of `import` statement to import an entire module named *module_name.py*, each function in the module is available through the following syntax: module_name.function_name() py ### Importing Specific Functions You can also import a specific function from a module. Here’s the general syntax for this approach: from module_name import function_name py You can import as many functions as you want from a module by separating each function’s name with a comma: from module_name import function_0, function_1, function_2 py The *making_pizzas.py* example would look like this if we want to import just the function we’re going to use: from pizza import make_pizza make_pizza(16, 'pepperoni') make_pizza(12, 'mushrooms', 'green peppers', 'extra cheese') py With this syntax, you don’t need to use the dot notation when you call a function. Because we’ve explicitly imported the function `make_pizza()` in the `import` statement, we can call it by name when we use the function. ### Using as to Give a Function an Alias If the name of a function you’re importing might conflict with an existing name in your program, or if the function name is long, you can use a short, unique *alias*—an alternate name similar to a nickname for the function. You’ll give the function this special nickname when you import the function. Here we give the function `make_pizza()` an alias, `mp()`, by importing `make_pizza as mp`. The `as` keyword renames a function using the alias you provide: from pizza import make_pizza as mp mp(16, 'pepperoni') mp(12, 'mushrooms', 'green peppers', 'extra cheese') py The `import` statement shown here renames the function `make_pizza()` to `mp()` in this program. Anytime we want to call `make_pizza()` we can simply write `mp()` instead, and Python will run the code in `make_pizza()` while avoiding any confusion with another `make_pizza()` function you might have written in this program file. The general syntax for providing an alias is: from module_name import function_name as fn py ### Using as to Give a Module an Alias You can also provide an alias for a module name. Giving a module a short alias, like `p` for `pizza`, allows you to call the module’s functions more quickly. Calling `p.make_pizza()` is more concise than calling `pizza.make_pizza()`: import pizza as p p.make_pizza(16, 'pepperoni') p.make_pizza(12, 'mushrooms', 'green peppers', 'extra cheese') py The module `pizza` is given the alias `p` in the `import` statement, but all of the module’s functions retain their original names. Calling the functions by writing `p.make_pizza()` is not only more concise than `pizza.make_pizza()`, but it also redirects your attention from the module name and allows you to focus on the descriptive names of its functions. These function names, which clearly tell you what each function does, are more important to the readability of your code than using the full module name. The general syntax for this approach is: import module_name as mn py ### Importing All Functions in a Module You can tell Python to import every function in a module by using the asterisk (`*`) operator: from pizza import * make_pizza(16, 'pepperoni') make_pizza(12, 'mushrooms', 'green peppers', 'extra cheese') py The asterisk in the `import` statement tells Python to copy every function from the module `pizza` into this program file. Because every function is imported, you can call each function by name without using the dot notation. However, it’s best not to use this approach when you’re working with larger modules that you didn’t write: if the module has a function name that matches an existing name in your project, you can get unexpected results. Python may see several functions or variables with the same name, and instead of importing all the functions separately, it will overwrite the functions. The best approach is to import the function or functions you want, or import the entire module and use the dot notation. This leads to clear code that’s easy to read and understand. I include this section so you’ll recognize `import` statements like the following when you see them in other people’s code: from module_name import * py ## Styling Functions You need to keep a few details in mind when you’re styling functions. Functions should have descriptive names, and these names should use lowercase letters and underscores. Descriptive names help you and others understand what your code is trying to do. Module names should use these conventions as well. Every function should have a comment that explains concisely what the function does. This comment should appear immediately after the function definition and use the docstring format. In a well-documented function, other programmers can use the function by reading only the description in the docstring. They should be able to trust that the code works as described, and as long as they know the name of the function, the arguments it needs, and the kind of value it returns, they should be able to use it in their programs. If you specify a default value for a parameter, no spaces should be used on either side of the equal sign: def function_name(parameter_0, parameter_1='default value') py The same convention should be used for keyword arguments in function calls: function_name(value_0, parameter_1='value') py PEP 8 ([`www.python.org/dev/peps/pep-0008`](https://www.python.org/dev/peps/pep-0008)) recommends that you limit lines of code to 79 characters so every line is visible in a reasonably sized editor window. If a set of parameters causes a function’s definition to be longer than 79 characters, press ENTER after the opening parenthesis on the definition line. On the next line, press the TAB key twice to separate the list of arguments from the body of the function, which will only be indented one level. Most editors automatically line up any additional lines of arguments to match the indentation you have established on the first line: def function_name( parameter_0, parameter_1, parameter_2, parameter_3, parameter_4, parameter_5): function body... py If your program or module has more than one function, you can separate each by two blank lines to make it easier to see where one function ends and the next one begins. All `import` statements should be written at the beginning of a file. The only exception is if you use comments at the beginning of your file to describe the overall program. ## Summary In this chapter, you learned how to write functions and to pass arguments so that your functions have access to the information they need to do their work. You learned how to use positional and keyword arguments, and also how to accept an arbitrary number of arguments. You saw functions that display output and functions that return values. You learned how to use functions with lists, dictionaries, `if` statements, and `while` loops. You also saw how to store your functions in separate files called *modules*, so your program files will be simpler and easier to understand. Finally, you learned to style your functions so your programs will continue to be well-structured and as easy as possible for you and others to read. One of your goals as a programmer should be to write simple code that does what you want it to, and functions help you do this. They allow you to write blocks of code and leave them alone once you know they work. When you know a function does its job correctly, you can trust that it will continue to work and move on to your next coding task. Functions allow you to write code once and then reuse that code as many times as you want. When you need to run the code in a function, all you need to do is write a one-line call and the function does its job. When you need to modify a function’s behavior, you only have to modify one block of code, and your change takes effect everywhere you’ve made a call to that function. Using functions makes your programs easier to read, and good function names summarize what each part of a program does. Reading a series of function calls gives you a much quicker sense of what a program does than reading a long series of code blocks. Functions also make your code easier to test and debug. When the bulk of your program’s work is done by a set of functions, each of which has a specific job, it’s much easier to test and maintain the code you’ve written. You can write a separate program that calls each function and tests whether each function works in all the situations it may encounter. When you do this, you can be confident that your functions will work properly each time you call them. In Chapter 9, you’ll learn to write classes. *Classes* combine functions and data into one neat package that can be used in flexible and efficient ways. ```py# 9 Classes
*Object-oriented programming (OOP) is one of the most effective approaches to writing software. In object-oriented programming, you write classes that represent real-world things and situations, and you create objects based on these classes. When you write a class, you define the general behavior that a whole category of objects can have. When you create individual objects from the class, each object is automatically equipped with the general behavior; you can then give each object whatever unique traits you desire. You’ll be amazed how well real-world situations can be modeled with object-oriented programming. Making an object from a class is called instantiation, and you work with instances of a class. In this chapter you’ll write classes and create instances of those classes. You’ll specify the kind of information that can be stored in instances, and you’ll define actions that can be taken with these instances. You’ll also write classes that extend the functionality of existing classes, so similar classes can share common functionality, and you can do more with less code. You’ll store your classes in modules and import classes written by other programmers into your own program files. Learning about object-oriented programming will help you see the world as a programmer does. It’ll help you understand your code—not just what’s happening line by line, but also the bigger concepts behind it. Knowing the logic behind classes will train you to think logically, so you can write programs that effectively address almost any problem you encounter. Classes also make life easier for you and the other programmers you’ll work with as you take on increasingly complex challenges. When you and other programmers write code based on the same kind of logic, you’ll be able to understand each other’s work. Your programs will make sense to the people you work with, allowing everyone to accomplish more. ## Creating and Using a Class You can model almost anything using classes. Let’s start by writing a simple class, Dog, that represents a dog—not one dog in particular, but any dog. What do we know about most pet dogs? Well, they all have a name and an age. We also know that most dogs sit and roll over. Those two pieces of information (name and age) and those two behaviors (sit and roll over) will go in our Dog class because they’re common to most dogs. This class will tell Python how to make an object representing a dog. After our class is written, we’ll use it to make individual instances, each of which represents one specific dog. ### Creating the Dog Class Each instance created from the Dog class will store a name and an age, and we’ll give each dog the ability to sit() and roll_over(): dog.py ❶ class Dog: """一个简单的模拟狗的尝试。""" ❷ def __init__(self, name, age): """初始化姓名和年龄属性。""" ❸ self.name = name self.age = age ❹ def sit(self): """模拟狗根据命令坐下。""" print(f"{self.name} is now sitting.") def roll_over(self): """模拟根据命令翻滚。""" print(f"{self.name} rolled over!")py There’s a lot to notice here, but don’t worry. You’ll see this structure throughout this chapter and have lots of time to get used to it. We first define a class called Dog ❶. By convention, capitalized names refer to classes in Python. There are no parentheses in the class definition because we’re creating this class from scratch. We then write a docstring describing what this class does. ### The init() Method A function that’s part of a class is a method. Everything you learned about functions applies to methods as well; the only practical difference for now is the way we’ll call methods. The __init__() method ❷ is a special method that Python runs automatically whenever we create a new instance based on the Dog class. This method has two leading underscores and two trailing underscores, a convention that helps prevent Python’s default method names from conflicting with your method names. Make sure to use two underscores on each side of __init__(). If you use just one on each side, the method won’t be called automatically when you use your class, which can result in errors that are difficult to identify. We define the __init__() method to have three parameters: self, name, and age. The self parameter is required in the method definition, and it must come first, before the other parameters. It must be included in the definition because when Python calls this method later (to create an instance of Dog), the method call will automatically pass the self argument. Every method call associated with an instance automatically passes self, which is a reference to the instance itself; it gives the individual instance access to the attributes and methods in the class. When we make an instance of Dog, Python will call the __init__() method from the Dog class. We’ll pass Dog() a name and an age as arguments; self is passed automatically, so we don’t need to pass it. Whenever we want to make an instance from the Dog class, we’ll provide values for only the last two parameters, name and age. The two variables defined in the body of the __init__() method each have the prefix self ❸. Any variable prefixed with self is available to every method in the class, and we’ll also be able to access these variables through any instance created from the class. The line self.name = name takes the value associated with the parameter name and assigns it to the variable name, which is then attached to the instance being created. The same process happens with self.age = age. Variables that are accessible through instances like this are called attributes. The Dog class has two other methods defined: sit() and roll_over() ❹. Because these methods don’t need additional information to run, we just define them to have one parameter, self. The instances we create later will have access to these methods. In other words, they’ll be able to sit and roll over. For now, sit() and roll_over() don’t do much. They simply print a message saying the dog is sitting or rolling over. But the concept can be extended to realistic situations: if this class were part of a computer game, these methods would contain code to make an animated dog sit and roll over. If this class was written to control a robot, these methods would direct movements that cause a robotic dog to sit and roll over. ### Making an Instance from a Class Think of a class as a set of instructions for how to make an instance. The Dog class is a set of instructions that tells Python how to make individual instances representing specific dogs. Let’s make an instance representing a specific dog: class Dog: *--snip--* ❶ my_dog = Dog('Willie', 6) ❷ print(f"My dog's name is {my_dog.name}.") ❸ print(f"My dog is {my_dog.age} years old.")py The Dog class we’re using here is the one we just wrote in the previous example. Here, we tell Python to create a dog whose name is 'Willie' and whose age is 6 ❶. When Python reads this line, it calls the __init__() method in Dog with the arguments 'Willie' and 6. The __init__() method creates an instance representing this particular dog and sets the name and age attributes using the values we provided. Python then returns an instance representing this dog. We assign that instance to the variable my_dog. The naming convention is helpful here; we can usually assume that a capitalized name like Dog refers to a class, and a lowercase name like my_dog refers to a single instance created from a class. #### Accessing Attributes To access the attributes of an instance, you use dot notation. We access the value of my_dog’s attribute name ❷ by writing: my_dog.namepy Dot notation is used often in Python. This syntax demonstrates how Python finds an attribute’s value. Here, Python looks at the instance my_dog and then finds the attribute name associated with my_dog. This is the same attribute referred to as self.name in the class Dog. We use the same approach to work with the attribute age ❸. The output is a summary of what we know about my_dog: My dog's name is Willie. My dog is 6 years old.py #### Calling Methods After we create an instance from the class Dog, we can use dot notation to call any method defined in Dog. Let’s make our dog sit and roll over: class Dog: *--snip--* my_dog = Dog('Willie', 6) my_dog.sit() my_dog.roll_over()py To call a method, give the name of the instance (in this case, my_dog) and the method you want to call, separated by a dot. When Python reads my_dog.sit(), it looks for the method sit() in the class Dog and runs that code. Python interprets the line my_dog.roll_over() in the same way. Now Willie does what we tell him to: Willie is now sitting. Willie rolled over!py This syntax is quite useful. When attributes and methods have been given appropriately descriptive names like name, age, sit(), and roll_over(), we can easily infer what a block of code, even one we’ve never seen before, is supposed to do. #### Creating Multiple Instances You can create as many instances from a class as you need. Let’s create a second dog called your_dog: class Dog: *--snip--* my_dog = Dog('Willie', 6) your_dog = Dog('Lucy', 3) print(f"My dog's name is {my_dog.name}.") print(f"My dog is {my_dog.age} years old.") my_dog.sit() print(f"\nYour dog's name is {your_dog.name}.") print(f"Your dog is {your_dog.age} years old.") your_dog.sit()py In this example we create a dog named Willie and a dog named Lucy. Each dog is a separate instance with its own set of attributes, capable of the same set of actions: My dog's name is Willie. My dog is 6 years old. Willie is now sitting. Your dog's name is Lucy. Your dog is 3 years old. Lucy is now sitting.py Even if we used the same name and age for the second dog, Python would still create a separate instance from the Dog class. You can make as many instances from one class as you need, as long as you give each instance a unique variable name or it occupies a unique spot in a list or dictionary. ## Working with Classes and Instances You can use classes to represent many real-world situations. Once you write a class, you’ll spend most of your time working with instances created from that class. One of the first tasks you’ll want to do is modify the attributes associated with a particular instance. You can modify the attributes of an instance directly or write methods that update attributes in specific ways. ### The Car Class Let’s write a new class representing a car. Our class will store information about the kind of car we’re working with, and it will have a method that summarizes this information: car.py class Car: """一个简单的表示汽车的尝试。""" ❶ def __init__(self, make, model, year): """初始化属性以描述汽车。""" self.make = make self.model = model self.year = year ❷ def get_descriptive_name(self): """返回一个格式整洁的描述性名称。""" long_name = f"{self.year} {self.make} {self.model}" return long_name.title() ❸ my_new_car = Car('audi', 'a4', 2024) print(my_new_car.get_descriptive_name())py In the Car class, we define the __init__() method with the self parameter first ❶, just like we did with the Dog class. We also give it three other parameters: make, model, and year. The __init__() method takes in these parameters and assigns them to the attributes that will be associated with instances made from this class. When we make a new Car instance, we’ll need to specify a make, model, and year for our instance. We define a method called get_descriptive_name() ❷ that puts a car’s year, make, and model into one string neatly describing the car. This will spare us from having to print each attribute’s value individually. To work with the attribute values in this method, we use self.make, self.model, and self.year. Outside of the class, we make an instance from the Car class and assign it to the variable my_new_car ❸. Then we call get_descriptive_name() to show what kind of car we have: 2024 Audi A4py To make the class more interesting, let’s add an attribute that changes over time. We’ll add an attribute that stores the car’s overall mileage. ### Setting a Default Value for an Attribute When an instance is created, attributes can be defined without being passed in as parameters. These attributes can be defined in the __init__() method, where they are assigned a default value. Let’s add an attribute called odometer_reading that always starts with a value of 0. We’ll also add a method read_odometer() that helps us read each car’s odometer: class Car: def __init__(self, make, model, year): """初始化属性以描述汽车。""" self.make = make self.model = model self.year = year ❶ self.odometer_reading = 0 def get_descriptive_name(self): *--snip--* ❷ def read_odometer(self): """打印一条显示汽车里程的语句。""" print(f"This car has {self.odometer_reading} miles on it.") my_new_car = Car('audi', 'a4', 2024) print(my_new_car.get_descriptive_name()) my_new_car.read_odometer()py This time, when Python calls the __init__() method to create a new instance, it stores the make, model, and year values as attributes, like it did in the previous example. Then Python creates a new attribute called odometer_reading and sets its initial value to 0 ❶. We also have a new method called read_odometer() ❷ that makes it easy to read a car’s mileage. Our car starts with a mileage of 0: 2024 Audi A4 This car has 0 miles on it.py Not many cars are sold with exactly 0 miles on the odometer, so we need a way to change the value of this attribute. ### Modifying Attribute Values You can change an attribute’s value in three ways: you can change the value directly through an instance, set the value through a method, or increment the value (add a certain amount to it) through a method. Let’s look at each of these approaches. #### Modifying an Attribute’s Value Directly The simplest way to modify the value of an attribute is to access the attribute directly through an instance. Here we set the odometer reading to 23 directly: class Car: *--snip--* my_new_car = Car('audi', 'a4', 2024) print(my_new_car.get_descriptive_name()) my_new_car.odometer_reading = 23 my_new_car.read_odometer()py We use dot notation to access the car’s odometer_reading attribute, and set its value directly. This line tells Python to take the instance my_new_car, find the attribute odometer_reading associated with it, and set the value of that attribute to 23: 2024 Audi A4 This car has 23 miles on it.py Sometimes you’ll want to access attributes directly like this, but other times you’ll want to write a method that updates the value for you. #### Modifying an Attribute’s Value Through a Method It can be helpful to have methods that update certain attributes for you. Instead of accessing the attribute directly, you pass the new value to a method that handles the updating internally. Here’s an example showing a method called update_odometer(): class Car: *--snip--* def update_odometer(self, mileage): """将里程表读数设置为给定值。""" self.odometer_reading = mileage my_new_car = Car('audi', 'a4', 2024) print(my_new_car.get_descriptive_name()) ❶ my_new_car.update_odometer(23) my_new_car.read_odometer()py The only modification to Car is the addition of update_odometer(). This method takes in a mileage value and assigns it to self.odometer_reading. Using the my_new_car instance, we call update_odometer() with 23 as an argument ❶. This sets the odometer reading to 23, and read_odometer() prints the reading: 2024 Audi A4 This car has 23 miles on it.py We can extend the method update_odometer() to do additional work every time the odometer reading is modified. Let’s add a little logic to make sure no one tries to roll back the odometer reading: class Car: *--snip--* def update_odometer(self, mileage): """ 将里程表读数设置为给定值。 如果尝试倒转里程表,则拒绝更改。 """ ❶ if mileage >= self.odometer_reading: self.odometer_reading = mileage else: ❷ print("You can't roll back an odometer!")py Now update_odometer() checks that the new reading makes sense before modifying the attribute. If the value provided for mileage is greater than or equal to the existing mileage, self.odometer_reading, you can update the odometer reading to the new mileage ❶. If the new mileage is less than the existing mileage, you’ll get a warning that you can’t roll back an odometer ❷. #### Incrementing an Attribute’s Value Through a Method Sometimes you’ll want to increment an attribute’s value by a certain amount, rather than set an entirely new value. Say we buy a used car and put 100 miles on it between the time we buy it and the time we register it. Here’s a method that allows us to pass this incremental amount and add that value to the odometer reading: class Car: *--snip--* def update_odometer(self, mileage): *--snip--* def increment_odometer(self, miles): """将给定的量添加到里程表读数。""" self.odometer_reading += miles ❶ my_used_car = Car('subaru', 'outback', 2019) print(my_used_car.get_descriptive_name()) ❷ my_used_car.update_odometer(23_500) my_used_car.read_odometer() my_used_car.increment_odometer(100) my_used_car.read_odometer()py The new method increment_odometer() takes in a number of miles, and adds this value to self.odometer_reading. First, we create a used car, my_used_car ❶. We set its odometer to 23,500 by calling update_odometer() and passing it 23_500 ❷. Finally, we call increment_odometer() and pass it 100 to add the 100 miles that we drove between buying the car and registering it: 2019 Subaru Outback This car has 23500 miles on it. This car has 23600 miles on it.py You can modify this method to reject negative increments so no one uses this function to roll back an odometer as well. ## Inheritance You don’t always have to start from scratch when writing a class. If the class you’re writing is a specialized version of another class you wrote, you can use inheritance. When one class inherits from another, it takes on the attributes and methods of the first class. The original class is called the parent class, and the new class is the child class. The child class can inherit any or all of the attributes and methods of its parent class, but it’s also free to define new attributes and methods of its own. ### The init() Method for a Child Class When you’re writing a new class based on an existing class, you’ll often want to call the __init__() method from the parent class. This will initialize any attributes that were defined in the parent __init__() method and make them available in the child class. As an example, let’s model an electric car. An electric car is just a specific kind of car, so we can base our new ElectricCar class on the Car class we wrote earlier. Then we’ll only have to write code for the attributes and behaviors specific to electric cars. Let’s start by making a simple version of the ElectricCar class, which does everything the Car class does: electric_car.py ❶ class Car: """一个简单的表示汽车的尝试。""" def __init__(self, make, model, year): """初始化属性以描述汽车。""" self.make = make self.model = model self.year = year self.odometer_reading = 0 def get_descriptive_name(self): """返回一个格式整洁的描述性名称。""" long_name = f"{self.year} {self.make} {self.model}" return long_name.title() def read_odometer(self): """打印一条显示汽车里程的语句。""" print(f"This car has {self.odometer_reading} miles on it.") def update_odometer(self, mileage): """将里程表读数设置为给定值。""" if mileage >= self.odometer_reading: self.odometer_reading = mileage else: print("You can't roll back an odometer!") def increment_odometer(self, miles): """将给定的量添加到里程表读数。""" self.odometer_reading += miles ❷ class ElectricCar(Car): """表示汽车的各个方面,特别是电动汽车。""" ❸ def __init__(self, make, model, year): """初始化父类的属性。""" ❹ super().__init__(make, model, year) ❺ my_leaf = ElectricCar('nissan', 'leaf', 2024) print(my_leaf.get_descriptive_name())py We start with Car ❶. When you create a child class, the parent class must be part of the current file and must appear before the child class in the file. We then define the child class, ElectricCar ❷. The name of the parent class must be included in parentheses in the definition of a child class. The __init__() method takes in the information required to make a Car instance ❸. The super() function ❹ is a special function that allows you to call a method from the parent class. This line tells Python to call the __init__() method from Car, which gives an ElectricCar instance all the attributes defined in that method. The name super comes from a convention of calling the parent class a superclass and the child class a subclass. We test whether inheritance is working properly by trying to create an electric car with the same kind of information we’d provide when making a regular car. We make an instance of the ElectricCar class and assign it to my_leaf ❺. This line calls the __init__() method defined in ElectricCar, which in turn tells Python to call the __init__() method defined in the parent class Car. We provide the arguments 'nissan', 'leaf', and 2024. Aside from __init__(), there are no attributes or methods yet that are particular to an electric car. At this point we’re just making sure the electric car has the appropriate Car behaviors: 2024 Nissan Leafpy The ElectricCar instance works just like an instance of Car, so now we can begin defining attributes and methods specific to electric cars. ### Defining Attributes and Methods for the Child Class Once you have a child class that inherits from a parent class, you can add any new attributes and methods necessary to differentiate the child class from the parent class. Let’s add an attribute that’s specific to electric cars (a battery, for example) and a method to report on this attribute. We’ll store the battery size and write a method that prints a description of the battery: class Car: *--snip--* class ElectricCar(Car): """表示汽车的各个方面,特别是电动汽车。""" def __init__(self, make, model, year): """ 初始化父类的属性。 然后初始化电动汽车特有的属性。 """ super().__init__(make, model, year) ❶ self.battery_size = 40 ❷ def describe_battery(self): """打印一条描述电池大小的语句。""" print(f"This car has a {self.battery_size}-kWh battery.") my_leaf = ElectricCar('nissan', 'leaf', 2024) print(my_leaf.get_descriptive_name()) my_leaf.describe_battery()py We add a new attribute self.battery_size and set its initial value to 40 ❶. This attribute will be associated with all instances created from the ElectricCar class but won’t be associated with any instances of Car. We also add a method called describe_battery() that prints information about the battery ❷. When we call this method, we get a description that is clearly specific to an electric car: 2024 Nissan Leaf This car has a 40-kWh battery.py There’s no limit to how much you can specialize the ElectricCar class. You can add as many attributes and methods as you need to model an electric car to whatever degree of accuracy you need. An attribute or method that could belong to any car, rather than one that’s specific to an electric car, should be added to the Car class instead of the ElectricCar class. Then anyone who uses the Car class will have that functionality available as well, and the ElectricCar class will only contain code for the information and behavior specific to electric vehicles. ### Overriding Methods from the Parent Class You can override any method from the parent class that doesn’t fit what you’re trying to model with the child class. To do this, you define a method in the child class with the same name as the method you want to override in the parent class. Python will disregard the parent class method and only pay attention to the method you define in the child class. Say the class Car had a method called fill_gas_tank(). This method is meaningless for an all-electric vehicle, so you might want to override this method. Here’s one way to do that: class ElectricCar(Car): *--snip--* def fill_gas_tank(self): """电动汽车没有油箱。""" print("This car doesn't have a gas tank!")py Now if someone tries to call fill_gas_tank() with an electric car, Python will ignore the method fill_gas_tank() in Car and run this code instead. When you use inheritance, you can make your child classes retain what you need and override anything you don’t need from the parent class. ### Instances as Attributes When modeling something from the real world in code, you may find that you’re adding more and more detail to a class. You’ll find that you have a growing list of attributes and methods and that your files are becoming lengthy. In these situations, you might recognize that part of one class can be written as a separate class. You can break your large class into smaller classes that work together; this approach is called composition. For example, if we continue adding detail to the ElectricCar class, we might notice that we’re adding many attributes and methods specific to the car’s battery. When we see this happening, we can stop and move those attributes and methods to a separate class called Battery. Then we can use a Battery instance as an attribute in the ElectricCar class: class Car: *--snip--* class Battery: """一个简单的电动汽车电池模型。""" ❶ def __init__(self, battery_size=40): """初始化电池的属性。""" self.battery_size = battery_size ❷ def describe_battery(self): """打印一条描述电池大小的语句。""" print(f"This car has a {self.battery_size}-kWh battery.") class ElectricCar(Car): """表示汽车的各个方面,特别是电动汽车。""" def __init__(self, make, model, year): """ 初始化父类的属性。 然后初始化电动汽车特有的属性。 """ super().__init__(make, model, year) ❸ self.battery = Battery() my_leaf = ElectricCar('nissan', 'leaf', 2024) print(my_leaf.get_descriptive_name()) my_leaf.battery.describe_battery()py We define a new class called Battery that doesn’t inherit from any other class. The __init__() method ❶ has one parameter, battery_size, in addition to self. This is an optional parameter that sets the battery’s size to 40 if no value is provided. The method describe_battery() has been moved to this class as well ❷. In the ElectricCar class, we now add an attribute called self.battery ❸. This line tells Python to create a new instance of Battery (with a default size of 40, because we’re not specifying a value) and assign that instance to the attribute self.battery. This will happen every time the __init__() method is called; any ElectricCar instance will now have a Battery instance created automatically. We create an electric car and assign it to the variable my_leaf. When we want to describe the battery, we need to work through the car’s battery attribute: my_leaf.battery.describe_battery()py This line tells Python to look at the instance my_leaf, find its battery attribute, and call the method describe_battery() that’s associated with the Battery instance assigned to the attribute. The output is identical to what we saw previously: 2024 Nissan Leaf This car has a 40-kWh battery.py This looks like a lot of extra work, but now we can describe the battery in as much detail as we want without cluttering the ElectricCar class. Let’s add another method to Battery that reports the range of the car based on the battery size: class Car: *--snip--* class Battery: *--snip--* def get_range(self): """打印一条关于此电池提供的续航里程的语句。""" if self.battery_size == 40: range = 150 elif self.battery_size == 65: range = 225 print(f"This car can go about {range} miles on a full charge.") class ElectricCar(Car): *--snip--* my_leaf = ElectricCar('nissan', 'leaf', 2024) print(my_leaf.get_descriptive_name()) my_leaf.battery.describe_battery() ❶ my_leaf.battery.get_range()py The new method get_range() performs some simple analysis. If the battery’s capacity is 40 kWh, get_range() sets the range to 150 miles, and if the capacity is 65 kWh, it sets the range to 225 miles. It then reports this value. When we want to use this method, we again have to call it through the car’s battery attribute ❶. The output tells us the range of the car based on its battery size: 2024 Nissan Leaf This car has a 40-kWh battery. This car can go about 150 miles on a full charge.py ### Modeling Real-World Objects As you begin to model more complicated things like electric cars, you’ll wrestle with interesting questions. Is the range of an electric car a property of the battery or of the car? If we’re only describing one car, it’s probably fine to maintain the association of the method get_range() with the Battery class. But if we’re describing a manufacturer’s entire line of cars, we probably want to move get_range() to the ElectricCar class. The get_range() method would still check the battery size before determining the range, but it would report a range specific to the kind of car it’s associated with. Alternatively, we could maintain the association of the get_range() method with the battery but pass it a parameter such as car_model. The get_range() method would then report a range based on the battery size and car model. This brings you to an interesting point in your growth as a programmer. When you wrestle with questions like these, you’re thinking at a higher logical level rather than a syntax-focused level. You’re thinking not about Python, but about how to represent the real world in code. When you reach this point, you’ll realize there are often no right or wrong approaches to modeling real-world situations. Some approaches are more efficient than others, but it takes practice to find the most efficient representations. If your code is working as you want it to, you’re doing well! Don’t be discouraged if you find you’re ripping apart your classes and rewriting them several times using different approaches. In the quest to write accurate, efficient code, everyone goes through this process. ## Importing Classes As you add more functionality to your classes, your files can get long, even when you use inheritance and composition properly. In keeping with the overall philosophy of Python, you’ll want to keep your files as uncluttered as possible. To help, Python lets you store classes in modules and then import the classes you need into your main program. ### Importing a Single Class Let’s create a module containing just the Car class. This brings up a subtle naming issue: we already have a file named car.py in this chapter, but this module should be named car.py because it contains code representing a car. We’ll resolve this naming issue by storing the Car class in a module named car.py, replacing the car.py file we were previously using. From now on, any program that uses this module will need a more specific filename, such as my_car.py. Here’s car.py with just the code from the class Car: car.py ❶ """一个可用于表示汽车的类。""" class Car: """一个简单的表示汽车的尝试。""" def __init__(self, make, model, year): """初始化属性以描述汽车。""" self.make = make self.model = model self.year = year self.odometer_reading = 0 def get_descriptive_name(self): """返回一个格式整洁的描述性名称。""" long_name = f"{self.year} {self.make} {self.model}" return long_name.title() def read_odometer(self): """打印一条显示汽车里程的语句。""" print(f"This car has {self.odometer_reading} miles on it.") def update_odometer(self, mileage): """ 将里程表读数设置为给定值。 如果尝试倒转里程表,则拒绝更改。 """ if mileage >= self.odometer_reading: self.odometer_reading = mileage else: print("You can't roll back an odometer!") def increment_odometer(self, miles): """将给定的量添加到里程表读数。""" self.odometer_reading += milespy We include a module-level docstring that briefly describes the contents of this module ❶. You should write a docstring for each module you create. Now we make a separate file called my_car.py. This file will import the Car class and then create an instance from that class: my_car.py ❶ from car import Car my_new_car = Car('audi', 'a4', 2024) print(my_new_car.get_descriptive_name()) my_new_car.odometer_reading = 23 my_new_car.read_odometer()py The import statement ❶ tells Python to open the car module and import the class Car. Now we can use the Car class as if it were defined in this file. The output is the same as we saw earlier: 2024 Audi A4 This car has 23 miles on it.py Importing classes is an effective way to program. Picture how long this program file would be if the entire Car class were included. When you instead move the class to a module and import the module, you still get all the same functionality, but you keep your main program file clean and easy to read. You also store most of the logic in separate files; once your classes work as you want them to, you can leave those files alone and focus on the higher-level logic of your main program. ### Storing Multiple Classes in a Module You can store as many classes as you need in a single module, although each class in a module should be related somehow. The classes Battery and ElectricCar both help represent cars, so let’s add them to the module car.py. car.py """一组用于表示汽油和电动汽车的类。""" class Car: *--snip--* class Battery: """一个简单的电动汽车电池模型。""" def __init__(self, battery_size=40): """初始化电池的属性。""" self.battery_size = battery_size def describe_battery(self): """打印一条描述电池大小的语句。""" print(f"This car has a {self.battery_size}-kWh battery.") def get_range(self): """打印一条关于此电池提供的续航里程的语句。""" if self.battery_size == 40: range = 150 elif self.battery_size == 65: range = 225 print(f"This car can go about {range} miles on a full charge.") class ElectricCar(Car): """模拟汽车的各个方面,特别是电动汽车。""" def __init__(self, make, model, year): """ 初始化父类的属性。 然后初始化电动汽车特有的属性。 """ super().__init__(make, model, year) self.battery = Battery()py Now we can make a new file called my_electric_car.py, import the ElectricCar class, and make an electric car: my_electric_car.py from car import ElectricCar my_leaf = ElectricCar('nissan', 'leaf', 2024) print(my_leaf.get_descriptive_name()) my_leaf.battery.describe_battery() my_leaf.battery.get_range()py This has the same output we saw earlier, even though most of the logic is hidden away in a module: 2024 Nissan Leaf This car has a 40-kWh battery. This car can go about 150 miles on a full charge.py ### Importing Multiple Classes from a Module You can import as many classes as you need into a program file. If we want to make a regular car and an electric car in the same file, we need to import both classes, Car and ElectricCar: my_cars.py ❶ from car import Car, ElectricCar ❷ my_mustang = Car('ford', 'mustang', 2024) print(my_mustang.get_descriptive_name()) ❸ my_leaf = ElectricCar('nissan', 'leaf', 2024) print(my_leaf.get_descriptive_name())py You import multiple classes from a module by separating each class with a comma ❶. Once you’ve imported the necessary classes, you’re free to make as many instances of each class as you need. In this example we make a gas-powered Ford Mustang ❷ and then an electric Nissan Leaf ❸: 2024 Ford Mustang 2024 Nissan Leafpy ### Importing an Entire Module You can also import an entire module and then access the classes you need using dot notation. This approach is simple and results in code that is easy to read. Because every call that creates an instance of a class includes the module name, you won’t have naming conflicts with any names used in the current file. Here’s what it looks like to import the entire car module and then create a regular car and an electric car: my_cars.py ❶ import car ❷ my_mustang = car.Car('ford', 'mustang', 2024) print(my_mustang.get_descriptive_name()) ❸ my_leaf = car.ElectricCar('nissan', 'leaf', 2024) print(my_leaf.get_descriptive_name())py First we import the entire car module ❶. We then access the classes we need through the module_name.ClassName syntax. We again create a Ford Mustang ❷, and a Nissan Leaf ❸. ### Importing All Classes from a Module You can import every class from a module using the following syntax: from `module_name` import *py This method is not recommended for two reasons. First, it’s helpful to be able to read the import statements at the top of a file and get a clear sense of which classes a program uses. With this approach it’s unclear which classes you’re using from the module. This approach can also lead to confusion with names in the file. If you accidentally import a class with the same name as something else in your program file, you can create errors that are hard to diagnose. I show this here because even though it’s not a recommended approach, you’re likely to see it in other people’s code at some point. If you need to import many classes from a module, you’re better off importing the entire module and using the module_name.ClassName syntax. You won’t see all the classes used at the top of the file, but you’ll see clearly where the module is used in the program. You’ll also avoid the potential naming conflicts that can arise when you import every class in a module. ### Importing a Module into a Module Sometimes you’ll want to spread out your classes over several modules to keep any one file from growing too large and avoid storing unrelated classes in the same module. When you store your classes in several modules, you may find that a class in one module depends on a class in another module. When this happens, you can import the required class into the first module. For example, let’s store the Car class in one module and the ElectricCar and Battery classes in a separate module. We’ll make a new module called electric_car.py—replacing the electric_car.py file we created earlier—and copy just the Battery and ElectricCar classes into this file: electric_car.py """一组可用于表示电动汽车的类。""" from car import Car class Battery: *--snip--* class ElectricCar(Car): *--snip--*py The class ElectricCar needs access to its parent class Car, so we import Car directly into the module. If we forget this line, Python will raise an error when we try to import the electric_car module. We also need to update the Car module so it contains only the Car class: car.py """一个可用于表示汽车的类。""" class Car: *--snip--*py Now we can import from each module separately and create whatever kind of car we need: my_cars.py from car import Car from electric_car import ElectricCar my_mustang = Car('ford', 'mustang', 2024) print(my_mustang.get_descriptive_name()) my_leaf = ElectricCar('nissan', 'leaf', 2024) print(my_leaf.get_descriptive_name())py We import Car from its module, and ElectricCar from its module. We then create one regular car and one electric car. Both cars are created correctly: 2024 Ford Mustang 2024 Nissan Leafpy ### Using Aliases As you saw in Chapter 8, aliases can be quite helpful when using modules to organize your projects’ code. You can use aliases when importing classes as well. As an example, consider a program where you want to make a bunch of electric cars. It might get tedious to type (and read) ElectricCar over and over again. You can give ElectricCar an alias in the import statement: from electric_car import ElectricCar as ECpy Now you can use this alias whenever you want to make an electric car: my_leaf = EC('nissan', 'leaf', 2024)py You can also give a module an alias. Here’s how to import the entire electric_car module using an alias: import electric_car as ecpy Now you can use this module alias with the full class name: my_leaf = ec.ElectricCar('nissan', 'leaf', 2024)py ### Finding Your Own Workflow As you can see, Python gives you many options for how to structure code in a large project. It’s important to know all these possibilities so you can determine the best ways to organize your projects as well as understand other people’s projects. When you’re starting out, keep your code structure simple. Try doing everything in one file and moving your classes to separate modules once everything is working. If you like how modules and files interact, try storing your classes in modules when you start a project. Find an approach that lets you write code that works, and go from there. ## The Python Standard Library The Python standard library is a set of modules included with every Python installation. Now that you have a basic understanding of how functions and classes work, you can start to use modules like these that other programmers have written. You can use any function or class in the standard library by including a simple import statement at the top of your file. Let’s look at one module, random, which can be useful in modeling many real-world situations. One interesting function from the random module is randint(). This function takes two integer arguments and returns a randomly selected integer between (and including) those numbers. Here’s how to generate a random number between 1 and 6: >>> **from random import randint** >>> **randint(1, 6)** 3py Another useful function is choice(). This function takes in a list or tuple and returns a randomly chosen element: >>> **from random import choice** >>> **players = ['charles', 'martina', 'michael', 'florence', 'eli']** >>> **first_up = choice(players)** >>> **first_up** 'florence'py The random module shouldn’t be used when building security-related applications, but it works well for many fun and interesting projects. ## Styling Classes A few styling issues related to classes are worth clarifying, especially as your programs become more complicated. Class names should be written in CamelCase. To do this, capitalize the first letter of each word in the name, and don’t use underscores. Instance and module names should be written in lowercase, with underscores between words. Every class should have a docstring immediately following the class definition. The docstring should be a brief description of what the class does, and you should follow the same formatting conventions you used for writing docstrings in functions. Each module should also have a docstring describing what the classes in a module can be used for. You can use blank lines to organize code, but don’t use them excessively. Within a class you can use one blank line between methods, and within a module you can use two blank lines to separate classes. If you need to import a module from the standard library and a module that you wrote, place the import statement for the standard library module first. Then add a blank line and the import statement for the module you wrote. In programs with multiple import statements, this convention makes it easier to see where the different modules used in the program come from. ## Summary In this chapter, you learned how to write your own classes. You learned how to store information in a class using attributes and how to write methods that give your classes the behavior they need. You learned to write __init__() methods that create instances from your classes with exactly the attributes you want. You saw how to modify the attributes of an instance directly and through methods. You learned that inheritance can simplify the creation of classes that are related to each other, and you learned to use instances of one class as attributes in another class to keep each class simple. You saw how storing classes in modules and importing classes you need into the files where they’ll be used can keep your projects organized. You started learning about the Python standard library, and you saw an example based on the random module. Finally, you learned to style your classes using Python conventions. In Chapter 10, you’ll learn to work with files so you can save the work you’ve done in a program and the work you’ve allowed users to do. You’ll also learn about exceptions, a special Python class designed to help you respond to errors when they arise. # 10 Files and Exceptions
Now that you’ve mastered the basic skills you need to write organized programs that are easy to use, it’s time to think about making your programs even more relevant and usable. In this chapter, you’ll learn to work with files so your programs can quickly analyze lots of data. You’ll learn to handle errors so your programs don’t crash when they encounter unexpected situations. You’ll learn about exceptions, which are special objects Python creates to manage errors that arise while a program is running. You’ll also learn about the json module, which allows you to save user data so it isn’t lost when your program stops running. Learning to work with files and save data will make your programs easier for people to use. Users will be able to choose what data to enter and when to enter it. People will be able to run your program, do some work, and then close the program and pick up where they left off. Learning to handle exceptions will help you deal with situations in which files don’t exist and deal with other problems that can cause your programs to crash. This will make your programs more robust when they encounter bad data, whether it comes from innocent mistakes or from malicious attempts to break your programs. With the skills you’ll learn in this chapter, you’ll make your programs more applicable, usable, and stable. ## Reading from a File An incredible amount of data is available in text files. Text files can contain weather data, traffic data, socioeconomic data, literary works, and more. Reading from a file is particularly useful in data analysis applications, but it’s also applicable to any situation in which you want to analyze or modify information stored in a file. For example, you can write a program that reads in the contents of a text file and rewrites the file with formatting that allows a browser to display it. When you want to work with the information in a text file, the first step is to read the file into memory. You can then work through all of the file’s contents at once or work through the contents line by line. ### Reading the Contents of a File To begin, we need a file with a few lines of text in it. Let’s start with a file that contains pi to 30 decimal places, with 10 decimal places per line: pi_digits.txt 3.1415926535 8979323846 2643383279py To try the following examples yourself, you can enter these lines in an editor and save the file as pi_digits.txt, or you can download the file from the book’s resources through ehmatthes.github.io/pcc_3e. Save the file in the same directory where you’ll store this chapter’s programs. Here’s a program that opens this file, reads it, and prints the contents of the file to the screen: file_reader.py from pathlib import Path ❶ path = Path('pi_digits.txt') ❷ contents = path.read_text() print(contents)py To work with the contents of a file, we need to tell Python the path to the file. A path is the exact location of a file or folder on a system. Python provides a module called pathlib that makes it easier to work with files and directories, no matter which operating system you or your program’s users are working with. A module that provides specific functionality like this is often called a library, hence the name pathlib. We start by importing the Path class from pathlib. There’s a lot you can do with a Path object that points to a file. For example, you can check that the file exists before working with it, read the file’s contents, or write new data to the file. Here, we build a Path object representing the file pi_digits.txt, which we assign to the variable path ❶. Since this file is saved in the same directory as the .py file we’re writing, the filename is all that Path needs to access the file. Once we have a Path object representing pi_digits.txt, we use the read_text() method to read the entire contents of the file ❷. The contents of the file are returned as a single string, which we assign to the variable contents. When we print the value of contents, we see the entire contents of the text file: 3.1415926535 8979323846 2643383279py The only difference between this output and the original file is the extra blank line at the end of the output. The blank line appears because read_text() returns an empty string when it reaches the end of the file; this empty string shows up as a blank line. We can remove the extra blank line by using rstrip() on the contents string: from pathlib import Path path = Path('pi_digits.txt') contents = path.read_text() contents = contents.rstrip() print(contents)py Recall from Chapter 2 that Python’s rstrip() method removes, or strips, any whitespace characters from the right side of a string. Now the output matches the contents of the original file exactly: 3.1415926535 8979323846 2643383279py We can strip the trailing newline character when we read the contents of the file, by applying the rstrip() method immediately after calling read_text(): contents = path.read_text().rstrip()py This line tells Python to call the read_text() method on the file we’re working with. Then it applies the rstrip() method to the string that read_text() returns. The cleaned-up string is then assigned to the variable contents. This approach is called method chaining, and you’ll see it used often in programming. ### Relative and Absolute File Paths When you pass a simple filename like pi_digits.txt to Path, Python looks in the directory where the file that’s currently being executed (that is, your .py program file) is stored. Sometimes, depending on how you organize your work, the file you want to open won’t be in the same directory as your program file. For example, you might store your program files in a folder called python_work; inside python_work, you might have another folder called text_files to distinguish your program files from the text files they’re manipulating. Even though text_files is in python_work, just passing Path the name of a file in text_files won’t work, because Python will only look in python_work and stop there; it won’t go on and look in text_files. To get Python to open files from a directory other than the one where your program file is stored, you need to provide the correct path. There are two main ways to specify paths in programming. A relative file path tells Python to look for a given location relative to the directory where the currently running program file is stored. Since text_files is inside python_work, we need to build a path that starts with the directory text_files, and ends with the filename. Here’s how to build this path: path = Path('text_files/`filename`.txt')py You can also tell Python exactly where the file is on your computer, regardless of where the program that’s being executed is stored. This is called an absolute file path. You can use an absolute path if a relative path doesn’t work. For instance, if you’ve put text_files in some folder other than python_work, then just passing Path the path 'text_files/``filename``.txt' won’t work because Python will only look for that location inside python_work. You’ll need to write out an absolute path to clarify where you want Python to look. Absolute paths are usually longer than relative paths, because they start at your system’s root folder: path = Path('/home/eric/data_files/text_files/`filename`.txt')py Using absolute paths, you can read files from any location on your system. For now it’s easiest to store files in the same directory as your program files, or in a folder such as text_files within the directory that stores your program files. ### Accessing a File’s Lines When you’re working with a file, you’ll often want to examine each line of the file. You might be looking for certain information in the file, or you might want to modify the text in the file in some way. For example, you might want to read through a file of weather data and work with any line that includes the word sunny in the description of that day’s weather. In a news report, you might look for any line with the tag <headline> and rewrite that line with a specific kind of formatting. You can use the splitlines() method to turn a long string into a set of lines, and then use a for loop to examine each line from a file, one at a time: file_reader.py from pathlib import Path path = Path('pi_digits.txt') ❶ contents = path.read_text() ❷ lines = contents.splitlines() for line in lines: print(line)py We start out by reading the entire contents of the file, as we did earlier ❶. If you’re planning to work with the individual lines in a file, you don’t need to strip any whitespace when reading the file. The splitlines() method returns a list of all lines in the file, and we assign this list to the variable lines ❷. We then loop over these lines and print each one: 3.1415926535 8979323846 2643383279py Since we haven’t modified any of the lines, the output matches the original text file exactly. ### Working with a File’s Contents After you’ve read the contents of a file into memory, you can do whatever you want with that data, so let’s briefly explore the digits of pi. First, we’ll attempt to build a single string containing all the digits in the file with no whitespace in it: pi_string.py from pathlib import Path path = Path('pi_digits.txt') contents = path.read_text() lines = contents.splitlines() pi_string = '' ❶ for line in lines: pi_string += line print(pi_string) print(len(pi_string))py We start by reading the file and storing each line of digits in a list, just as we did in the previous example. We then create a variable, pi_string, to hold the digits of pi. We write a loop that adds each line of digits to pi_string ❶. We print this string, and also show how long the string is: 3.1415926535 8979323846 2643383279 36py The variable pi_string contains the whitespace that was on the left side of the digits in each line, but we can get rid of that by using lstrip() on each line: *--snip--* for line in lines: pi_string += line.lstrip() print(pi_string) print(len(pi_string))py Now we have a string containing pi to 30 decimal places. The string is 32 characters long because it also includes the leading 3 and a decimal point: 3.141592653589793238462643383279 32py ### Large Files: One Million Digits So far, we’ve focused on analyzing a text file that contains only three lines, but the code in these examples would work just as well on much larger files. If we start with a text file that contains pi to 1,000,000 decimal places, instead of just 30, we can create a single string containing all these digits. We don’t need to change our program at all, except to pass it a different file. We’ll also print just the first 50 decimal places, so we don’t have to watch a million digits scroll by in the terminal: pi_string.py from pathlib import Path path = Path('pi_million_digits.txt') contents = path.read_text() lines = contents.splitlines() pi_string = '' for line in lines: pi_string += line.lstrip() print(f"{pi_string[:52]}...") print(len(pi_string))py The output shows that we do indeed have a string containing pi to 1,000,000 decimal places: 3.14159265358979323846264338327950288419716939937510... 1000002py Python has no inherent limit to how much data you can work with; you can work with as much data as your system’s memory can handle. ### Is Your Birthday Contained in Pi? I’ve always been curious to know if my birthday appears anywhere in the digits of pi. Let’s use the program we just wrote to find out if someone’s birthday appears anywhere in the first million digits of pi. We can do this by expressing each birthday as a string of digits and seeing if that string appears anywhere in pi_string: pi_birthday.py *--snip--* for line in lines: pi_string += line.strip() birthday = input("Enter your birthday, in the form mmddyy: ") if birthday in pi_string: print("Your birthday appears in the first million digits of pi!") else: print("Your birthday does not appear in the first million digits of pi.")py We first prompt for the user’s birthday, and then check if that string is in pi_string. Let’s try it: Enter your birthdate, in the form mmddyy: **120372** Your birthday appears in the first million digits of pi!py My birthday does appear in the digits of pi! Once you’ve read from a file, you can analyze its contents in just about any way you can imagine. ## Writing to a File One of the simplest ways to save data is to write it to a file. When you write text to a file, the output will still be available after you close the terminal containing your program’s output. You can examine output after a program finishes running, and you can share the output files with others as well. You can also write programs that read the text back into memory and work with it again later. ### Writing a Single Line Once you have a path defined, you can write to a file using the write_text() method. To see how this works, let’s write a simple message and store it in a file instead of printing it to the screen: write_message.py from pathlib import Path path = Path('programming.txt') path.write_text("I love programming.")py The write_text() method takes a single argument: the string that you want to write to the file. This program has no terminal output, but if you open the file programming.txt, you’ll see one line: programming.txt I love programming.py This file behaves like any other file on your computer. You can open it, write new text in it, copy from it, paste to it, and so forth. ### Writing Multiple Lines The write_text() method does a few things behind the scenes. If the file that path points to doesn’t exist, it creates that file. Also, after writing the string to the file, it makes sure the file is closed properly. Files that aren’t closed properly can lead to missing or corrupted data. To write more than one line to a file, you need to build a string containing the entire contents of the file, and then call write_text() with that string. Let’s write several lines to the programming.txt file: from pathlib import Path contents = "I love programming.\n" contents += "I love creating new games.\n" contents += "I also love working with data.\n" path = Path('programming.txt') path.write_text(contents)py We define a variable called contents that will hold the entire contents of the file. On the next line, we use the += operator to add to this string. You can do this as many times as you need, to build strings of any length. In this case we include newline characters at the end of each line, to make sure each statement appears on its own line. If you run this and then open programming.txt, you’ll see each of these lines in the text file: I love programming. I love creating new games. I also love working with data.py You can also use spaces, tab characters, and blank lines to format your output, just as you’ve been doing with terminal-based output. There’s no limit to the length of your strings, and this is how many computer-generated documents are created. ## Exceptions Python uses special objects called exceptions to manage errors that arise during a program’s execution. Whenever an error occurs that makes Python unsure of what to do next, it creates an exception object. If you write code that handles the exception, the program will continue running. If you don’t handle the exception, the program will halt and show a traceback, which includes a report of the exception that was raised. Exceptions are handled with try-except blocks. A try-except block asks Python to do something, but it also tells Python what to do if an exception is raised. When you use try-except blocks, your programs will continue running even if things start to go wrong. Instead of tracebacks, which can be confusing for users to read, users will see friendly error messages that you’ve written. ### Handling the ZeroDivisionError Exception Let’s look at a simple error that causes Python to raise an exception. You probably know that it’s impossible to divide a number by zero, but let’s ask Python to do it anyway: division_calculator.py print(5/0)py Python can’t do this, so we get a traceback: Traceback (most recent call last): File "division_calculator.py", line 1, in <module> print(5/0) ~^~ ❶ ZeroDivisionError: division by zeropy The error reported in the traceback, ZeroDivisionError, is an exception object ❶. Python creates this kind of object in response to a situation where it can’t do what we ask it to. When this happens, Python stops the program and tells us the kind of exception that was raised. We can use this information to modify our program. We’ll tell Python what to do when this kind of exception occurs; that way, if it happens again, we’ll be prepared. ### Using try-except Blocks When you think an error may occur, you can write a try-except block to handle the exception that might be raised. You tell Python to try running some code, and you tell it what to do if the code results in a particular kind of exception. Here’s what a try-except block for handling the ZeroDivisionError exception looks like: try: print(5/0) except ZeroDivisionError: print("You can't divide by zero!")py We put print(5/0), the line that caused the error, inside a try block. If the code in a try block works, Python skips over the except block. If the code in the try block causes an error, Python looks for an except block whose error matches the one that was raised, and runs the code in that block. In this example, the code in the try block produces a ZeroDivisionError, so Python looks for an except block telling it how to respond. Python then runs the code in that block, and the user sees a friendly error message instead of a traceback: You can't divide by zero!py If more code followed the try-except block, the program would continue running because we told Python how to handle the error. Let’s look at an example where catching an error can allow a program to continue running. ### Using Exceptions to Prevent Crashes Handling errors correctly is especially important when the program has more work to do after the error occurs. This happens often in programs that prompt users for input. If the program responds to invalid input appropriately, it can prompt for more valid input instead of crashing. Let’s create a simple calculator that does only division: division_calculator.py print("Give me two numbers, and I'll divide them.") print("Enter 'q' to quit.") while True: ❶ first_number = input("\nFirst number: ") if first_number == 'q': break ❷ second_number = input("Second number: ") if second_number == 'q': break ❸ answer = int(first_number) / int(second_number) print(answer)py This program prompts the user to input a first_number ❶ and, if the user does not enter q to quit, a second_number ❷. We then divide these two numbers to get an answer ❸. This program does nothing to handle errors, so asking it to divide by zero causes it to crash: Give me two numbers, and I'll divide them. Enter 'q' to quit. First number: **5** Second number: **0** Traceback (most recent call last): File "division_calculator.py", line 11, in <module> answer = int(first_number) / int(second_number) ~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~ ZeroDivisionError: division by zeropy It’s bad that the program crashed, but it’s also not a good idea to let users see tracebacks. Nontechnical users will be confused by them, and in a malicious setting, attackers will learn more than you want them to. For example, they’ll know the name of your program file, and they’ll see a part of your code that isn’t working properly. A skilled attacker can sometimes use this information to determine which kind of attacks to use against your code. ### The else Block We can make this program more error resistant by wrapping the line that might produce errors in a try-except block. The error occurs on the line that performs the division, so that’s where we’ll put the try-except block. This example also includes an else block. Any code that depends on the try block executing successfully goes in the else block: *--snip--* while True: *--snip--* if second_number == 'q': break ❶ try: answer = int(first_number) / int(second_number) ❷ except ZeroDivisionError: print("You can't divide by 0!") ❸ else: print(answer)py We ask Python to try to complete the division operation in a try block ❶, which includes only the code that might cause an error. Any code that depends on the try block succeeding is added to the else block. In this case, if the division operation is successful, we use the else block to print the result ❸. The except block tells Python how to respond when a ZeroDivisionError arises ❷. If the try block doesn’t succeed because of a division-by-zero error, we print a friendly message telling the user how to avoid this kind of error. The program continues to run, and the user never sees a traceback: Give me two numbers, and I'll divide them. Enter 'q' to quit. First number: **5** Second number: **0** You can't divide by 0! First number: **5** Second number: **2** 2.5 First number: **q**py The only code that should go in a try block is code that might cause an exception to be raised. Sometimes you’ll have additional code that should run only if the try block was successful; this code goes in the else block. The except block tells Python what to do in case a certain exception arises when it tries to run the code in the try block. By anticipating likely sources of errors, you can write robust programs that continue to run even when they encounter invalid data and missing resources. Your code will be resistant to innocent user mistakes and malicious attacks. ### Handling the FileNotFoundError Exception One common issue when working with files is handling missing files. The file you’re looking for might be in a different location, the filename might be misspelled, or the file might not exist at all. You can handle all of these situations with a try-except block. Let’s try to read a file that doesn’t exist. The following program tries to read in the contents of Alice in Wonderland, but I haven’t saved the file alice.txt in the same directory as alice.py: alice.py from pathlib import Path path = Path('alice.txt') contents = path.read_text(encoding='utf-8')py Note that we’re using read_text() in a slightly different way here than what you saw earlier. The encoding argument is needed when your system’s default encoding doesn’t match the encoding of the file that’s being read. This is most likely to happen when reading from a file that wasn’t created on your system. Python can’t read from a missing file, so it raises an exception: Traceback (most recent call last): ❶ File "alice.py", line 4, in <module> ❷ contents = path.read_text(encoding='utf-8') ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/.../pathlib.py", line 1056, in read_text with self.open(mode='r', encoding=encoding, errors=errors) as f: ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/.../pathlib.py", line 1042, in open return io.open(self, mode, buffering, encoding, errors, newline) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ❸ FileNotFoundError: [Errno 2] No such file or directory: 'alice.txt'py This is a longer traceback than the ones we’ve seen previously, so let’s look at how you can make sense of more complex tracebacks. It’s often best to start at the very end of the traceback. On the last line, we can see that a FileNotFoundError exception was raised ❸. This is important because it tells us what kind of exception to use in the except block that we’ll write. Looking back near the beginning of the traceback ❶, we can see that the error occurred at line 4 in the file alice.py. The next line shows the line of code that caused the error ❷. The rest of the traceback shows some code from the libraries that are involved in opening and reading from files. You don’t usually need to read through or understand all of these lines in a traceback. To handle the error that’s being raised, the try block will begin with the line that was identified as problematic in the traceback. In our example, this is the line that contains read_text(): from pathlib import Path path = Path('alice.txt') try: contents = path.read_text(encoding='utf-8') ❶ except FileNotFoundError: print(f"Sorry, the file {path} does not exist.")py In this example, the code in the try block produces a FileNotFoundError, so we write an except block that matches that error ❶. Python then runs the code in that block when the file can’t be found, and the result is a friendly error message instead of a traceback: Sorry, the file alice.txt does not exist.py The program has nothing more to do if the file doesn’t exist, so this is all the output we see. Let’s build on this example and see how exception handling can help when you’re working with more than one file. ### Analyzing Text You can analyze text files containing entire books. Many classic works of literature are available as simple text files because they are in the public domain. The texts used in this section come from Project Gutenberg (gutenberg.org). Project Gutenberg maintains a collection of literary works that are available in the public domain, and it’s a great resource if you’re interested in working with literary texts in your programming projects. Let’s pull in the text of Alice in Wonderland and try to count the number of words in the text. To do this, we’ll use the string method split(), which by default splits a string wherever it finds any whitespace: from pathlib import Path path = Path('alice.txt') try: contents = path.read_text(encoding='utf-8') except FileNotFoundError: print(f"Sorry, the file {path} does not exist.") else: # Count the approximate number of words in the file: ❶ words = contents.split() ❷ num_words = len(words) print(f"The file {path} has about {num_words} words.")py I moved the file alice.txt to the correct directory, so the try block will work this time. We take the string contents, which now contains the entire text of Alice in Wonderland as one long string, and use split() to produce a list of all the words in the book ❶. Using len() on this list ❷ gives us a good approximation of the number of words in the original text. Lastly, we print a statement that reports how many words were found in the file. This code is placed in the else block because it only works if the code in the try block was executed successfully. The output tells us how many words are in alice.txt: The file alice.txt has about 29594 words.py The count is a little high because extra information is provided by the publisher in the text file used here, but it’s a good approximation of the length of Alice in Wonderland. ### Working with Multiple Files Let’s add more books to analyze, but before we do, let’s move the bulk of this program to a function called count_words(). This will make it easier to run the analysis for multiple books: word_count.py from pathlib import Path def count_words(path): ❶ """计算文件中单词的大概数量。""" try: contents = path.read_text(encoding='utf-8') except FileNotFoundError: print(f"Sorry, the file {path} does not exist.") else: # Count the approximate number of words in the file: words = contents.split() num_words = len(words) print(f"The file {path} has about {num_words} words.") path = Path('alice.txt') count_words(path)py Most of this code is unchanged. It’s only been indented, and moved into the body of count_words(). It’s a good habit to keep comments up to date when you’re modifying a program, so the comment has also been changed to a docstring and reworded slightly ❶. Now we can write a short loop to count the words in any text we want to analyze. We do this by storing the names of the files we want to analyze in a list, and then we call count_words() for each file in the list. We’ll try to count the words for Alice in Wonderland, Siddhartha, Moby Dick, and Little Women, which are all available in the public domain. I’ve intentionally left siddhartha.txt out of the directory containing word_count.py, so we can see how well our program handles a missing file: from pathlib import Path def count_words(filename): *--snip--* filenames = ['alice.txt', 'siddhartha.txt', 'moby_dick.txt', 'little_women.txt'] for filename in filenames: ❶ path = Path(filename) count_words(path)py The names of the files are stored as simple strings. Each string is then converted to a Path object ❶, before the call to count_words(). The missing siddhartha.txt file has no effect on the rest of the program’s execution: The file alice.txt has about 29594 words. Sorry, the file siddhartha.txt does not exist. The file moby_dick.txt has about 215864 words. The file little_women.txt has about 189142 words.py Using the try-except block in this example provides two significant advantages. We prevent our users from seeing a traceback, and we let the program continue analyzing the texts it’s able to find. If we don’t catch the FileNotFoundError that siddhartha.txt raises, the user would see a full traceback, and the program would stop running after trying to analyze Siddhartha. It would never analyze Moby Dick or Little Women. ### Failing Silently In the previous example, we informed our users that one of the files was unavailable. But you don’t need to report every exception you catch. Sometimes, you’ll want the program to fail silently when an exception occurs and continue on as if nothing happened. To make a program fail silently, you write a try block as usual, but you explicitly tell Python to do nothing in the except block. Python has a pass statement that tells it to do nothing in a block: def count_words(path): """计算文件中单词的大概数量。""" try: *--snip--* except FileNotFoundError: pass else: *--snip--*py The only difference between this listing and the previous one is the pass statement in the except block. Now when a FileNotFoundError is raised, the code in the except block runs, but nothing happens. No traceback is produced, and there’s no output in response to the error that was raised. Users see the word counts for each file that exists, but they don’t see any indication that a file wasn’t found: The file alice.txt has about 29594 words. The file moby_dick.txt has about 215864 words. The file little_women.txt has about 189142 words.py The pass statement also acts as a placeholder. It’s a reminder that you’re choosing to do nothing at a specific point in your program’s execution and that you might want to do something there later. For example, in this program we might decide to write any missing filenames to a file called missing_files.txt. Our users wouldn’t see this file, but we’d be able to read the file and deal with any missing texts. ### Deciding Which Errors to Report How do you know when to report an error to your users and when to let your program fail silently? If users know which texts are supposed to be analyzed, they might appreciate a message informing them why some texts were not analyzed. If users expect to see some results but don’t know which books are supposed to be analyzed, they might not need to know that some texts were unavailable. Giving users information they aren’t looking for can decrease the usability of your program. Python’s error-handling structures give you fine-grained control over how much to share with users when things go wrong; it’s up to you to decide how much information to share. Well-written, properly tested code is not very prone to internal errors, such as syntax or logical errors. But every time your program depends on something external such as user input, the existence of a file, or the availability of a network connection, there is a possibility of an exception being raised. A little experience will help you know where to include exception-handling blocks in your program and how much to report to users about errors that arise. ## Storing Data Many of your programs will ask users to input certain kinds of information. You might allow users to store preferences in a game or provide data for a visualization. Whatever the focus of your program is, you’ll store the information users provide in data structures such as lists and dictionaries. When users close a program, you’ll almost always want to save the information they entered. A simple way to do this involves storing your data using the json module. The json module allows you to convert simple Python data structures into JSON-formatted strings, and then load the data from that file the next time the program runs. You can also use json to share data between different Python programs. Even better, the JSON data format is not specific to Python, so you can share data you store in the JSON format with people who work in many other programming languages. It’s a useful and portable format, and it’s easy to learn. ### Using json.dumps() and json.loads() Let’s write a short program that stores a set of numbers and another program that reads these numbers back into memory. The first program will use json.dumps() to store the set of numbers, and the second program will use json.loads(). The json.dumps() function takes one argument: a piece of data that should be converted to the JSON format. The function returns a string, which we can then write to a data file: number_writer.py from pathlib import Path import json numbers = [2, 3, 5, 7, 11, 13] ❶ path = Path('numbers.json') ❷ contents = json.dumps(numbers) path.write_text(contents)py We first import the json module, and then create a list of numbers to work with. Then we choose a filename in which to store the list of numbers ❶. It’s customary to use the file extension .json to indicate that the data in the file is stored in the JSON format. Next, we use the json.dumps() ❷ function to generate a string containing the JSON representation of the data we’re working with. Once we have this string, we write it to the file using the same write_text() method we used earlier. This program has no output, but let’s open the file numbers.json and look at it. The data is stored in a format that looks just like Python: [2, 3, 5, 7, 11, 13]py Now we’ll write a separate program that uses json.loads() to read the list back into memory: number_reader.py from pathlib import Path import json ❶ path = Path('numbers.json') ❷ contents = path.read_text() ❸ numbers = json.loads(contents) print(numbers)py We make sure to read from the same file we wrote to ❶. Since the data file is just a text file with specific formatting, we can read it with the read_text() method ❷. We then pass the contents of the file to json.loads() ❸. This function takes in a JSON-formatted string and returns a Python object (in this case, a list), which we assign to numbers. Finally, we print the recovered list of numbers and see that it’s the same list created in number_writer.py: [2, 3, 5, 7, 11, 13]py This is a simple way to share data between two programs. ### Saving and Reading User-Generated Data Saving data with json is useful when you’re working with user-generated data, because if you don’t store your user’s information somehow, you’ll lose it when the program stops running. Let’s look at an example where we prompt the user for their name the first time they run a program and then remember their name when they run the program again. Let’s start by storing the user’s name: remember_me.py from pathlib import Path import json ❶ username = input("What is your name? ") ❷ path = Path('username.json') contents = json.dumps(username) path.write_text(contents) ❸ print(f"We'll remember you when you come back, {username}!")py We first prompt for a username to store ❶. Next, we write the data we just collected to a file called username.json ❷. Then we print a message informing the user that we’ve stored their information ❸: What is your name? **Eric** We'll remember you when you come back, Eric!py Now let’s write a new program that greets a user whose name has already been stored: greet_user.py from pathlib import Path import json ❶ path = Path('username.json') contents = path.read_text() ❷ username = json.loads(contents) print(f"Welcome back, {username}!")py We read the contents of the data file ❶ and then use json.loads() to assign the recovered data to the variable username ❷. Since we’ve recovered the username, we can welcome the user back with a personalized greeting: Welcome back, Eric!py We need to combine these two programs into one file. When someone runs remember_me.py, we want to retrieve their username from memory if possible; if not, we’ll prompt for a username and store it in username.json for next time. We could write a try-except block here to respond appropriately if username.json doesn’t exist, but instead we’ll use a handy method from the pathlib module: remember_me.py from pathlib import Path import json path = Path('username.json') ❶ if path.exists(): contents = path.read_text() username = json.loads(contents) print(f"Welcome back, {username}!") ❷ else: username = input("What is your name? ") contents = json.dumps(username) path.write_text(contents) print(f"We'll remember you when you come back, {username}!")py There are many helpful methods you can use with Path objects. The exists() method returns True if a file or folder exists and False if it doesn’t. Here we use path.exists() to find out if a username has already been stored ❶. If username.json exists, we load the username and print a personalized greeting to the user. If the file username.json doesn’t exist ❷, we prompt for a username and store the value that the user enters. We also print the familiar message that we’ll remember them when they come back. Whichever block executes, the result is a username and an appropriate greeting. If this is the first time the program runs, this is the output: What is your name? **Eric** We'll remember you when you come back, Eric!py Otherwise: Welcome back, Eric!py This is the output you see if the program was already run at least once. Even though the data in this section is just a single string, the program would work just as well with any data that can be converted to a JSON-formatted string. ### Refactoring Often, you’ll come to a point where your code will work, but you’ll recognize that you could improve the code by breaking it up into a series of functions that have specific jobs. This process is called refactoring. Refactoring makes your code cleaner, easier to understand, and easier to extend. We can refactor remember_me.py by moving the bulk of its logic into one or more functions. The focus of remember_me.py is on greeting the user, so let’s move all of our existing code into a function called greet_user(): remember_me.py from pathlib import Path import json def greet_user(): ❶ """通过姓名问候用户。""" path = Path('username.json') if path.exists(): contents = path.read_text() username = json.loads(contents) print(f"Welcome back, {username}!") else: username = input("What is your name? ") contents = json.dumps(username) path.write_text(contents) print(f"We'll remember you when you come back, {username}!") greet_user()py Because we’re using a function now, we rewrite the comments as a docstring that reflects how the program currently works ❶. This file is a little cleaner, but the function greet_user() is doing more than just greeting the user—it’s also retrieving a stored username if one exists and prompting for a new username if one doesn’t. Let’s refactor greet_user() so it’s not doing so many different tasks. We’ll start by moving the code for retrieving a stored username to a separate function: from pathlib import Path import json def get_stored_username(path): ❶ """如果可用,获取存储的用户名。""" if path.exists(): contents = path.read_text() username = json.loads(contents) return username else: ❷ return None def greet_user(): """通过姓名问候用户。""" path = Path('username.json') username = get_stored_username(path) ❸ if username: print(f"Welcome back, {username}!") else: username = input("What is your name? ") contents = json.dumps(username) path.write_text(contents) print(f"We'll remember you when you come back, {username}!") greet_user()py The new function get_stored_username() ❶ has a clear purpose, as stated in the docstring. This function retrieves a stored username and returns the username if it finds one. If the path that’s passed to get_stored_username() doesn’t exist, the function returns None ❷. This is good practice: a function should either return the value you’re expecting, or it should return None. This allows us to perform a simple test with the return value of the function. We print a welcome back message to the user if the attempt to retrieve a username is successful ❸, and if it isn’t, we prompt for a new username. We should factor one more block of code out of greet_user(). If the username doesn’t exist, we should move the code that prompts for a new username to a function dedicated to that purpose: from pathlib import Path import json def get_stored_username(path): """如果可用,获取存储的用户名。""" *--snip--* def get_new_username(path): """提示输入新用户名。""" username = input("What is your name? ") contents = json.dumps(username) path.write_text(contents) return username def greet_user(): """通过姓名问候用户。""" path = Path('username.json') ❶ username = get_stored_username(path) if username: print(f"Welcome back, {username}!") else: ❷ username = get_new_username(path) print(f"We'll remember you when you come back, {username}!") greet_user()py Each function in this final version of remember_me.py has a single, clear purpose. We call greet_user(), and that function prints an appropriate message: it either welcomes back an existing user or greets a new user. It does this by calling get_stored_username() ❶, which is responsible only for retrieving a stored username if one exists. Finally, if necessary, greet_user() calls get_new_username()❷, which is responsible only for getting a new username and storing it. This compartmentalization of work is an essential part of writing clear code that will be easy to maintain and extend. ## Summary In this chapter, you learned how to work with files. You learned to read the entire contents of a file, and then work through the contents one line at a time if you need to. You learned to write as much text as you want to a file. You also read about exceptions and how to handle the exceptions you’re likely to see in your programs. Finally, you learned how to store Python data structures so you can save information your users provide, preventing them from having to start over each time they run a program. In Chapter 11, you’ll learn efficient ways to test your code. This will help you trust that the code you develop is correct, and it will help you identify bugs that are introduced as you continue to build on the programs you’ve written. # 11 Testing Your Code
When you write a function or a class, you can also write tests for that code. Testing proves that your code works as it’s supposed to in response to all the kinds of input it’s designed to receive. When you write tests, you can be confident that your code will work correctly as more people begin to use your programs. You’ll also be able to test new code as you add it, to make sure your changes don’t break your program’s existing behavior. Every programmer makes mistakes, so every programmer must test their code often, to catch problems before users encounter them. In this chapter, you’ll learn to test your code using pytest. The pytest library is a collection of tools that will help you write your first tests quickly and simply, while supporting your tests as they grow in complexity along with your projects. Python doesn’t include pytest by default, so you’ll learn to install external libraries. Knowing how to install external libraries will make a wide variety of well-designed code available to you. These libraries will expand the kinds of projects you can work on immensely. You’ll learn to build a series of tests and check that each set of inputs results in the output you want. You’ll see what a passing test looks like and what a failing test looks like, and you’ll learn how a failing test can help you improve your code. You’ll learn to test functions and classes, and you’ll start to understand how many tests to write for a project. ## Installing pytest with pip While Python includes a lot of functionality in the standard library, Python developers also depend heavily on third-party packages. A third-party package is a library that’s developed outside the core Python language. Some popular third-party libraries are eventually adopted into the standard library, and end up being included in most Python installations from that point forward. This happens most often with libraries that are unlikely to change much once they’ve had their initial bugs worked out. These kinds of libraries can evolve at the same pace as the overall language. Many packages, however, are kept out of the standard library so they can be developed on a timeline independent of the language itself. These packages tend to be updated more frequently than they would be if they were tied to Python’s development schedule. This is true of pytest and most of the libraries we’ll use in the second half of this book. You shouldn’t blindly trust every third-party package, but you also shouldn’t be put off by the fact that a lot of important functionality is implemented through such packages. ### Updating pip Python includes a tool called pip that’s used to install third-party packages. Because pip helps install packages from external resources, it’s updated often to address potential security issues. So, we’ll start by updating pip. Open a new terminal window and issue the following command: $ **python -m pip install --upgrade pip** ❶ Requirement already satisfied: pip in /.../python3.11/site-packages (22.0.4) `--snip--` ❷ Successfully installed pip-22.1.2py The first part of this command, python -m pip, tells Python to run the module pip. The second part, install --upgrade, tells pip to update a package that’s already been installed. The last part, pip, specifies which third-party package should be updated. The output shows that my current version of pip, version 22.0.4 ❶, was replaced by the latest version at the time of this writing, 22.1.2 ❷. You can use this command to update any third-party package installed on your system: $ **python -m pip install --upgrade** `package_name`py ### Installing pytest Now that pip is up to date, we can install pytest: $ **python -m pip install --user pytest** Collecting pytest `--snip--` Successfully installed attrs-21.4.0 iniconfig-1.1.1 ...pytest-7.`x`.`x`py We’re still using the core command pip install, without the --upgrade flag this time. Instead, we’re using the --user flag, which tells Python to install this package for the current user only. The output shows that the latest version of pytest was successfully installed, along with a number of other packages that pytest depends on. You can use this command to install many third-party packages: $ **python -m pip install --user** `package_name`py ## Testing a Function To learn about testing, we need code to test. Here’s a simple function that takes in a first and last name, and returns a neatly formatted full name: name_function.py def get_formatted_name(first, last): """生成一个格式整洁的全名。""" full_name = f"{first} {last}" return full_name.title()py The function get_formatted_name() combines the first and last name with a space in between to complete a full name, and then capitalizes and returns the full name. To check that get_formatted_name() works, let’s make a program that uses this function. The program names.py lets users enter a first and last name, and see a neatly formatted full name: names.py from name_function import get_formatted_name print("Enter 'q' at any time to quit.") while True: first = input("\nPlease give me a first name: ") if first == 'q': break last = input("Please give me a last name: ") if last == 'q': break formatted_name = get_formatted_name(first, last) print(f"\tNeatly formatted name: {formatted_name}.")py This program imports get_formatted_name() from name_function.py. The user can enter a series of first and last names and see the formatted full names that are generated: Enter 'q' at any time to quit. Please give me a first name: **janis** Please give me a last name: **joplin** Neatly formatted name: Janis Joplin. Please give me a first name: **bob** Please give me a last name: **dylan** Neatly formatted name: Bob Dylan. Please give me a first name: **q**py We can see that the names generated here are correct. But say we want to modify get_formatted_name() so it can also handle middle names. As we do so, we want to make sure we don’t break the way the function handles names that have only a first and last name. We could test our code by running names.py and entering a name like Janis Joplin every time we modify get_formatted_name(), but that would become tedious. Fortunately, pytest provides an efficient way to automate the testing of a function’s output. If we automate the testing of get_formatted_name(), we can always be confident that the function will work when given the kinds of names we’ve written tests for. ### Unit Tests and Test Cases There is a wide variety of approaches to testing software. One of the simplest kinds of test is a unit test. A unit test verifies that one specific aspect of a function’s behavior is correct. A test case is a collection of unit tests that together prove that a function behaves as it’s supposed to, within the full range of situations you expect it to handle. A good test case considers all the possible kinds of input a function could receive and includes tests to represent each of these situations. A test case with full coverage includes a full range of unit tests covering all the possible ways you can use a function. Achieving full coverage on a large project can be daunting. It’s often good enough to write tests for your code’s critical behaviors and then aim for full coverage only if the project starts to see widespread use. ### A Passing Test With pytest, writing your first unit test is pretty straightforward. We’ll write a single test function. The test function will call the function we’re testing, and we’ll make an assertion about the value that’s returned. If our assertion is correct, the test will pass; if the assertion is incorrect, the test will fail. Here’s the first test of the function get_formatted_name(): test_name_function.py from name_function import get_formatted_name ❶ def test_first_last_name(): """像“Janis Joplin”这样的名字有效吗?""" ❷ formatted_name = get_formatted_name('janis', 'joplin') ❸ assert formatted_name == 'Janis Joplin'py Before we run the test, let’s take a closer look at this function. The name of a test file is important; it must start with test_. When we ask pytest to run the tests we’ve written, it will look for any file that begins with test_, and run all of the tests it finds in that file. In the test file, we first import the function that we want to test: get_formatted_name(). Then we define a test function: in this case, test_first_last_name() ❶. This is a longer function name than we’ve been using, for a good reason. First, test functions need to start with the word test, followed by an underscore. Any function that starts with test_ will be discovered by pytest, and will be run as part of the testing process. Also, test names should be longer and more descriptive than a typical function name. You’ll never call the function yourself; pytest will find the function and run it for you. Test function names should be long enough that if you see the function name in a test report, you’ll have a good sense of what behavior was being tested. Next, we call the function we’re testing ❷. Here we call get_formatted_name() with the arguments 'janis' and 'joplin', just like we used when we ran names.py. We assign the return value of this function to formatted_name. Finally, we make an assertion ❸. An assertion is a claim about a condition. Here we’re claiming that the value of formatted_name should be 'Janis Joplin'. ### Running a Test If you run the file test_name_function.py directly, you won’t get any output because we never called the test function. Instead, we’ll have pytest run the test file for us. To do this, open a terminal window and navigate to the folder that contains the test file. If you’re using VS Code, you can open the folder containing the test file and use the terminal that’s embedded in the editor window. In the terminal window, enter the command pytest. Here’s what you should see: $ **pytest** ========================= test session starts ========================= ❶ platform darwin -- Python 3.`x`.`x`, pytest-7.`x`.`x`, pluggy-1.`x`.`x` ❷ rootdir: /.../python_work/chapter_11 ❸ collected 1 item ❹ test_name_function.py . [100%] ========================== 1 passed in 0.00s ==========================py Let’s try to make sense of this output. First of all, we see some information about the system the test is running on ❶. I’m testing this on a macOS system, so you may see some different output here. Most importantly, we can see which versions of Python, pytest, and other packages are being used to run the test. Next, we see the directory where the test is being run from ❷: in my case, python_work/chapter_11. We can see that pytest found one test to run ❸, and we can see the test file that’s being run ❹. The single dot after the name of the file tells us that a single test passed, and the 100% makes it clear that all of the tests have been run. A large project can have hundreds or thousands of tests, and the dots and percentage-complete indicator can be helpful in monitoring the overall progress of the test run. The last line tells us that one test passed, and it took less than 0.01 seconds to run the test. This output indicates that the function get_formatted_name() will always work for names that have a first and last name, unless we modify the function. When we modify get_formatted_name(), we can run this test again. If the test passes, we know the function will still work for names like Janis Joplin. ### A Failing Test What does a failing test look like? Let’s modify get_formatted_name() so it can handle middle names, but let’s do so in a way that breaks the function for names with just a first and last name, like Janis Joplin. Here’s a new version of get_formatted_name() that requires a middle name argument: name_function.py def get_formatted_name(first, middle, last): """生成一个格式整洁的全名。""" full_name = f"{first} {middle} {last}" return full_name.title()py This version should work for people with middle names, but when we test it, we see that we’ve broken the function for people with just a first and last name. This time, running pytest gives the following output: ``` $ pytest ========================= test session starts ========================= --snip-- ❶ test_name_function.py F [100%] ❷ ============================== FAILURES =============================== ❸ ________________________ test_first_last_name
第九章:类

面向对象编程(OOP)是编写软件最有效的方法之一。在面向对象编程中,你编写代表现实世界事物和情境的类,并基于这些类创建对象。当你编写一个类时,你定义了一个整个类别的对象可以拥有的通用行为。
当你从类中创建单独的对象时,每个对象都会自动配备通用的行为;然后你可以为每个对象添加你想要的独特特性。你会惊讶于面向对象编程如何能够很好地模拟现实世界的情况。
从类中创建对象被称为实例化,你将与类的实例进行工作。在这一章中,你将编写类并创建这些类的实例。你将指定可以存储在实例中的信息类型,并定义可以对这些实例进行的操作。你还将编写扩展现有类功能的类,这样相似的类可以共享通用功能,让你用更少的代码做更多的事情。你将把你的类存储在模块中,并将其他程序员编写的类导入到你自己的程序文件中。
学习面向对象编程将帮助你以程序员的视角来看待世界。它不仅能帮助你理解代码——不仅仅是逐行发生了什么,还能帮助你理解背后的更大概念。了解类的逻辑会训练你进行逻辑思考,从而让你能够编写有效解决几乎所有遇到的问题的程序。
类还使你和其他程序员在面对越来越复杂的挑战时,工作变得更加轻松。当你和其他程序员基于相同的逻辑编写代码时,你们能够相互理解对方的工作。你的程序将对与你合作的人有意义,从而使每个人能够做得更多。
创建和使用类
你可以使用类来建模几乎任何事物。让我们从编写一个简单的Dog类开始,它代表一只狗——不是特定的某只狗,而是任何一只狗。我们对大多数宠物狗了解什么呢?嗯,它们都有一个名字和年龄。我们还知道大多数狗都会坐下并打滚。这两条信息(名字和年龄)和这两种行为(坐下和打滚)将被放入我们的Dog类中,因为它们是大多数狗的共性。这个类将告诉 Python 如何创建一个代表狗的对象。在我们写完这个类后,我们将使用它来创建单独的实例,每个实例代表一只特定的狗。
创建狗类
从Dog类创建的每个实例将存储一个name和一个age,我们还将为每只狗提供sit()和roll_over()的能力:
dog.py
❶ class Dog:
"""A simple attempt to model a dog."""
❷ def __init__(self, name, age):
"""Initialize name and age attributes."""
❸ self.name = name
self.age = age
❹ def sit(self):
"""Simulate a dog sitting in response to a command."""
print(f"{self.name} is now sitting.")
def roll_over(self):
"""Simulate rolling over in response to a command."""
print(f"{self.name} rolled over!")
这里有很多需要注意的地方,但不用担心。你将在本章中看到这种结构,并有充足的时间来适应它。我们首先定义了一个名为Dog的类❶。按照惯例,首字母大写的名称表示类。在类定义中没有括号,因为我们是从头开始创建这个类。接着,我们编写了一个文档字符串,描述这个类的功能。
init() 方法
属于类的一部分的函数是方法。你学到的关于函数的所有知识同样适用于方法;目前唯一的实际区别是我们调用方法的方式。__init__()方法❷是一个特殊的方法,Python 会在我们基于Dog类创建新实例时自动运行这个方法。这个方法的前后各有两个下划线,这是一个约定,用来防止 Python 默认的方法名称与你的方法名称发生冲突。确保在__init__()两边使用两个下划线。如果每边只使用一个,下划线方法就不会在使用类时自动调用,这可能会导致难以发现的错误。
我们定义了__init__()方法,包含三个参数:self、name和age。self参数在方法定义中是必需的,并且必须排在其他参数之前。它必须包含在定义中,因为当 Python 稍后调用这个方法时(以创建Dog的实例),方法调用会自动传入self参数。每个与实例相关的调用都会自动传递self,它是对实例本身的引用;它让单个实例能够访问类中的属性和方法。当我们创建Dog的实例时,Python 将调用Dog类的__init__()方法。我们将Dog()的名称和年龄作为参数传递;self会自动传递,所以我们不需要传递它。每当我们想从Dog类创建一个实例时,我们只需要为最后两个参数name和age提供值。
在__init__()方法的主体中定义的两个变量都有前缀self❸。任何以self为前缀的变量在类的每个方法中都可以使用,并且我们还可以通过从该类创建的任何实例来访问这些变量。self.name = name这一行将与参数name相关联的值赋给变量name,然后将其附加到正在创建的实例上。self.age = age也做了相同的处理。通过实例可以访问的变量被称为属性。
Dog 类定义了另外两个方法:sit() 和 roll_over() ❹。因为这些方法在运行时不需要额外的信息,我们只需定义它们有一个参数,即 self。我们稍后创建的实例将能够访问这些方法。换句话说,它们将能够坐下和打滚。现在,sit() 和 roll_over() 并不会做太多事情。它们仅仅打印一条信息,说明狗狗正在坐下或打滚。但这个概念可以扩展到实际情况:如果这个类是某个电脑游戏的一部分,这些方法将包含代码来让动画中的狗狗坐下或打滚。如果这个类是为了控制机器人编写的,这些方法将指挥机器狗坐下和打滚。
从类中创建实例
可以将类看作一套说明,告诉我们如何创建一个实例。Dog 类就是一套说明,它告诉 Python 如何创建代表特定狗狗的实例。
让我们创建一个代表特定狗狗的实例:
class Dog:
*--snip--*
❶ my_dog = Dog('Willie', 6)
❷ print(f"My dog's name is {my_dog.name}.")
❸ print(f"My dog is {my_dog.age} years old.")
我们这里使用的 Dog 类是我们在前一个示例中编写的类。在这里,我们告诉 Python 创建一只名叫 'Willie'、年龄为 6 ❶ 的狗。当 Python 读取这一行时,它会调用 Dog 中的 __init__() 方法,传入参数 'Willie' 和 6。__init__() 方法创建一个代表这只特定狗狗的实例,并使用我们提供的值设置 name 和 age 属性。然后,Python 返回一个代表这只狗的实例,我们将这个实例赋给变量 my_dog。命名约定在这里很有帮助;我们通常可以假设像 Dog 这样的首字母大写的名称指的是一个类,而像 my_dog 这样的全小写名称指的是从类创建的单个实例。
访问属性
要访问实例的属性,使用点符号。我们通过以下方式访问 my_dog 的 name 属性 ❷ 的值:
my_dog.name
点符号在 Python 中使用频繁。这种语法展示了 Python 如何找到一个属性的值。这里,Python 查看实例 my_dog,然后找到与 my_dog 关联的 name 属性。这与在 Dog 类中提到的 self.name 属性相同。我们用相同的方法来处理 age 属性 ❸。
输出是我们关于 my_dog 的总结:
My dog's name is Willie.
My dog is 6 years old.
调用方法
在我们从 Dog 类创建一个实例之后,可以使用点符号调用 Dog 中定义的任何方法。让我们让狗狗坐下和打滚:
class Dog:
*--snip--*
my_dog = Dog('Willie', 6)
my_dog.sit()
my_dog.roll_over()
要调用一个方法,给出实例的名称(在本例中是 my_dog)和你想调用的方法,中间用点(.)隔开。当 Python 读取 my_dog.sit() 时,它会在 Dog 类中查找 sit() 方法并运行该代码。Python 以相同的方式解释 my_dog.roll_over() 这一行。
现在,Willie 按照我们的指示行动:
Willie is now sitting.
Willie rolled over!
这种语法非常有用。当属性和方法像name、age、sit()和roll_over()这样被赋予恰当的描述性名称时,我们可以轻松地推断出即使是我们从未见过的一段代码也应该做什么。
创建多个实例
你可以根据需要从类中创建任意多个实例。让我们创建第二只狗,叫做your_dog:
class Dog:
*--snip--*
my_dog = Dog('Willie', 6)
your_dog = Dog('Lucy', 3)
print(f"My dog's name is {my_dog.name}.")
print(f"My dog is {my_dog.age} years old.")
my_dog.sit()
print(f"\nYour dog's name is {your_dog.name}.")
print(f"Your dog is {your_dog.age} years old.")
your_dog.sit()
在这个例子中,我们创建了一只名叫 Willie 的狗和一只名叫 Lucy 的狗。每只狗都是一个独立的实例,拥有自己的一组属性,能够执行相同的一组操作:
My dog's name is Willie.
My dog is 6 years old.
Willie is now sitting.
Your dog's name is Lucy.
Your dog is 3 years old.
Lucy is now sitting.
即使我们为第二只狗使用相同的名字和年龄,Python 仍然会从Dog类中创建一个单独的实例。你可以根据需要从一个类创建任意多的实例,只要为每个实例赋予一个唯一的变量名,或者它占据列表或字典中的一个唯一位置。
使用类和实例
你可以使用类来表示许多现实世界的情况。一旦你编写了一个类,你将大部分时间都花在与从该类创建的实例进行交互上。你首先想要做的任务之一就是修改与特定实例相关的属性。你可以直接修改实例的属性,或者编写方法以特定方式更新属性。
汽车类
让我们编写一个表示汽车的新类。我们的类将存储我们正在使用的汽车类型信息,并且它将有一个方法来总结这些信息:
car.py
class Car:
"""A simple attempt to represent a car."""
❶ def __init__(self, make, model, year):
"""Initialize attributes to describe a car."""
self.make = make
self.model = model
self.year = year
❷ def get_descriptive_name(self):
"""Return a neatly formatted descriptive name."""
long_name = f"{self.year} {self.make} {self.model}"
return long_name.title()
❸ my_new_car = Car('audi', 'a4', 2024)
print(my_new_car.get_descriptive_name())
在Car类中,我们首先定义__init__()方法,并将self参数放在最前面❶,就像我们在Dog类中所做的那样。我们还为它提供了三个其他参数:make、model和year。__init__()方法接收这些参数并将它们分配给将与从此类创建的实例相关联的属性。当我们创建一个新的Car实例时,我们需要为我们的实例指定品牌、型号和年份。
我们定义了一个名为get_descriptive_name()的方法❷,它将一辆车的year、make和model组合成一个简洁的字符串来描述汽车。这将节省我们逐个打印每个属性值的麻烦。为了在这个方法中操作属性值,我们使用self.make、self.model和self.year。在类外部,我们从Car类创建一个实例,并将其赋值给变量my_new_car❸。然后我们调用get_descriptive_name()方法来显示我们拥有的是什么样的汽车:
2024 Audi A4
为了让类更有趣,让我们添加一个随时间变化的属性。我们将添加一个属性,用来存储汽车的总里程。
为属性设置默认值
当实例被创建时,属性可以在不作为参数传递的情况下定义。这些属性可以在__init__()方法中定义,并为其分配默认值。
让我们添加一个名为odometer_reading的属性,它的初始值始终为 0。我们还将添加一个方法read_odometer(),帮助我们读取每辆车的里程表:
class Car:
def __init__(self, make, model, year):
"""Initialize attributes to describe a car."""
self.make = make
self.model = model
self.year = year
❶ self.odometer_reading = 0
def get_descriptive_name(self):
*--snip--*
❷ def read_odometer(self):
"""Print a statement showing the car's mileage."""
print(f"This car has {self.odometer_reading} miles on it.")
my_new_car = Car('audi', 'a4', 2024)
print(my_new_car.get_descriptive_name())
my_new_car.read_odometer()
这次,当 Python 调用__init__()方法创建新实例时,它将像前一个示例中那样存储制造商、型号和年份作为属性。然后,Python 创建一个名为odometer_reading的新属性,并将其初始值设置为 0❶。我们还创建了一个新方法read_odometer()❷,它使读取汽车的里程数变得非常容易。
我们的车的初始里程为 0:
2024 Audi A4
This car has 0 miles on it.
很少有汽车的里程表上显示完全为 0 的里程,因此我们需要一种方法来更改这个属性的值。
修改属性值
你可以通过三种方式修改属性值:可以通过实例直接修改值,或者通过方法设置值,或者通过方法增量修改值(即增加一定的数值)。让我们来看看这三种方法。
直接修改属性值
修改属性值的最简单方法是通过实例直接访问该属性。在这里,我们直接将里程表读数设置为 23:
class Car:
*--snip--*
my_new_car = Car('audi', 'a4', 2024)
print(my_new_car.get_descriptive_name())
my_new_car.odometer_reading = 23
my_new_car.read_odometer()
我们使用点符号访问汽车的odometer_reading属性,并直接设置它的值。这行代码告诉 Python,获取实例my_new_car,找到与其相关的odometer_reading属性,并将该属性的值设置为 23:
2024 Audi A4
This car has 23 miles on it.
有时,你可能希望直接访问属性值,但也有时你会希望编写一个方法来更新该值。
通过方法修改属性值
有时,方法可以帮助你更新某些属性。你不需要直接访问属性,而是将新值传递给方法,由方法内部处理更新。
这是一个示例,展示了一个名为update_odometer()的方法:
class Car:
*--snip--*
def update_odometer(self, mileage):
"""Set the odometer reading to the given value."""
self.odometer_reading = mileage
my_new_car = Car('audi', 'a4', 2024)
print(my_new_car.get_descriptive_name())
❶ my_new_car.update_odometer(23)
my_new_car.read_odometer()
对Car类的唯一修改是添加了update_odometer()方法。这个方法接受一个里程值并将其赋值给self.odometer_reading。通过my_new_car实例,我们调用update_odometer()并传入23作为参数❶。这将把里程表读数设置为 23,接着read_odometer()打印出该读数:
2024 Audi A4
This car has 23 miles on it.
我们可以扩展update_odometer()方法,使其每次修改里程表读数时执行额外的操作。让我们添加一些逻辑,确保没有人试图回滚里程表读数:
class Car:
*--snip--*
def update_odometer(self, mileage):
"""
Set the odometer reading to the given value.
Reject the change if it attempts to roll the odometer back.
"""
❶ if mileage >= self.odometer_reading:
self.odometer_reading = mileage
else:
❷ print("You can't roll back an odometer!")
现在update_odometer()会在修改属性之前检查新读数是否合理。如果提供的mileage值大于或等于现有的里程数self.odometer_reading,则可以将里程表读数更新为新的里程数❶。如果新里程数小于现有里程数,系统会发出警告,提示不能回滚里程表❷。
通过方法增加属性值
有时,你可能希望按一定的增量来修改属性值,而不是设置一个全新的值。假设我们购买了一辆二手车,并在购车和注册之间行驶了 100 英里。这里有一个方法,可以让我们传入这个增量并将其添加到里程表读数中:
class Car:
*--snip--*
def update_odometer(self, mileage):
*--snip--*
def increment_odometer(self, miles):
"""Add the given amount to the odometer reading."""
self.odometer_reading += miles
❶ my_used_car = Car('subaru', 'outback', 2019)
print(my_used_car.get_descriptive_name())
❷ my_used_car.update_odometer(23_500)
my_used_car.read_odometer()
my_used_car.increment_odometer(100)
my_used_car.read_odometer()
新的方法 increment_odometer() 接受一个里程数,并将其添加到 self.odometer_reading。首先,我们创建一个二手车 my_used_car ❶。我们通过调用 update_odometer() 并传入 23_500 ❷,将其里程表设置为 23,500。最后,我们调用 increment_odometer() 并传入 100,以便将我们从购车到注册这段时间行驶的 100 英里加到里程表上:
2019 Subaru Outback
This car has 23500 miles on it.
This car has 23600 miles on it.
你可以修改此方法,以拒绝负数增量,防止有人使用此功能回调里程表。
继承
编写类时,你不一定要从头开始。如果你编写的类是你之前写的另一个类的特化版本,你可以使用 继承。当一个类 继承 另一个类时,它会继承第一个类的属性和方法。原始类称为 父类,新类称为 子类。子类可以继承父类的部分或全部属性和方法,但它也可以自由地定义自己新的属性和方法。
子类的 __init__() 方法
当你基于现有类编写新类时,通常会希望调用父类的 __init__() 方法。这将初始化父类 __init__() 方法中定义的任何属性,并使它们在子类中可用。
举个例子,我们来建模一辆电动汽车。电动汽车只是一种特殊类型的汽车,因此我们可以将新的 ElectricCar 类基于我们之前编写的 Car 类。这样,我们只需要为电动汽车特有的属性和行为编写代码。
让我们先做一个简单版的 ElectricCar 类,它完成 Car 类所做的所有工作:
electric_car.py
❶ class Car:
"""A simple attempt to represent a car."""
def __init__(self, make, model, year):
"""Initialize attributes to describe a car.""
self.make = make
self.model = model
self.year = year
self.odometer_reading = 0
def get_descriptive_name(self):
"""Return a neatly formatted descriptive name."""
long_name = f"{self.year} {self.make} {self.model}"
return long_name.title()
def read_odometer(self):
"""Print a statement showing the car's mileage."""
print(f"This car has {self.odometer_reading} miles on it.")
def update_odometer(self, mileage):
"""Set the odometer reading to the given value."""
if mileage >= self.odometer_reading:
self.odometer_reading = mileage
else:
print("You can't roll back an odometer!")
def increment_odometer(self, miles):
"""Add the given amount to the odometer reading."""
self.odometer_reading += miles
❷ class ElectricCar(Car):
"""Represent aspects of a car, specific to electric vehicles."""
❸ def __init__(self, make, model, year):
"""Initialize attributes of the parent class."""
❹ super().__init__(make, model, year)
❺ my_leaf = ElectricCar('nissan', 'leaf', 2024)
print(my_leaf.get_descriptive_name())
我们从 Car ❶ 开始。当你创建一个子类时,父类必须位于当前文件中,并且必须出现在子类之前。然后,我们定义了子类 ElectricCar ❷。在定义子类时,父类的名称必须包含在括号内。__init__() 方法接收创建 Car 实例所需的信息 ❸。
super() 函数❹是一个特殊的函数,允许你调用父类的方法。这一行代码告诉 Python 调用 Car 类中的 __init__() 方法,从而为 ElectricCar 实例赋予该方法中定义的所有属性。super 这个名字来源于一种约定,称父类为 超类,子类为 子类。
我们通过尝试使用与制作普通汽车时相同的信息创建一辆电动汽车来测试继承是否正常工作。我们创建了一个 ElectricCar 类的实例并将其赋值给 my_leaf ❺。这一行代码调用了 ElectricCar 中定义的 __init__() 方法,进而告诉 Python 调用父类 Car 中定义的 __init__() 方法。我们提供了参数 'nissan'、'leaf' 和 2024。
除了__init__()之外,尚未有任何特定于电动汽车的属性或方法。此时我们只是确保电动汽车具有适当的Car类行为:
2024 Nissan Leaf
ElectricCar实例的行为和Car实例一样,因此现在我们可以开始定义特定于电动汽车的属性和方法。
为子类定义属性和方法
一旦你有了一个从父类继承的子类,你可以添加任何必要的新属性和方法,以便将子类与父类区分开来。
让我们添加一个特定于电动汽车的属性(例如电池)以及一个报告此属性的方法。我们将存储电池大小并编写一个打印电池描述的方法:
class Car:
*--snip--*
class ElectricCar(Car):
"""Represent aspects of a car, specific to electric vehicles."""
def __init__(self, make, model, year):
"""
Initialize attributes of the parent class.
Then initialize attributes specific to an electric car.
"""
super().__init__(make, model, year)
❶ self.battery_size = 40
❷ def describe_battery(self):
"""Print a statement describing the battery size."""
print(f"This car has a {self.battery_size}-kWh battery.")
my_leaf = ElectricCar('nissan', 'leaf', 2024)
print(my_leaf.get_descriptive_name())
my_leaf.describe_battery()
我们添加了一个新的属性self.battery_size,并将其初始值设置为40 ❶。这个属性将与从ElectricCar类创建的所有实例相关联,但不会与任何Car实例相关联。我们还添加了一个名为describe_battery()的方法,用于打印电池的信息 ❷。当我们调用这个方法时,会得到一个明确针对电动汽车的描述:
2024 Nissan Leaf
This car has a 40-kWh battery.
ElectricCar类的专业化没有限制。你可以根据需要添加任意数量的属性和方法,以达到你需要的电动汽车建模精度。任何可以属于所有汽车的属性或方法,而不是特定于电动汽车的,应该添加到Car类中,而不是ElectricCar类中。这样,使用Car类的任何人都可以获得这些功能,而ElectricCar类将仅包含与电动汽车特定信息和行为相关的代码。
重写父类的方法
你可以重写父类中不适合你在子类中建模的方法。为了做到这一点,你需要在子类中定义一个与父类中要重写的方法同名的方法。Python 会忽略父类的方法,只关注你在子类中定义的方法。
假设Car类有一个名为fill_gas_tank()的方法。这个方法对于全电动汽车来说没有意义,所以你可能想要重写这个方法。下面是实现这一点的一种方式:
class ElectricCar(Car):
*--snip--*
def fill_gas_tank(self):
"""Electric cars don't have gas tanks."""
print("This car doesn't have a gas tank!")
现在,如果有人尝试对电动汽车调用fill_gas_tank(),Python 会忽略Car类中的fill_gas_tank()方法,而运行这段代码。当你使用继承时,你可以让子类保留你需要的内容,并重写你不需要的父类方法。
实例作为属性
在用代码建模现实世界中的事物时,你可能会发现自己在不断向一个类中添加更多的细节。你会发现属性和方法的列表越来越长,文件也变得越来越冗长。在这种情况下,你可能会意识到某个类的部分内容可以作为单独的类来编写。你可以将一个大的类拆分为多个相互协作的小类,这种方法叫做组合。
例如,如果我们继续向ElectricCar类添加细节,可能会注意到我们正在添加许多与汽车电池相关的属性和方法。当我们看到这种情况时,可以停下来,将这些属性和方法移动到一个名为Battery的单独类中。然后我们可以在ElectricCar类中使用Battery实例作为属性:
class Car:
*--snip--*
class Battery:
"""A simple attempt to model a battery for an electric car."""
❶ def __init__(self, battery_size=40):
"""Initialize the battery's attributes."""
self.battery_size = battery_size
❷ def describe_battery(self):
"""Print a statement describing the battery size."""
print(f"This car has a {self.battery_size}-kWh battery.")
class ElectricCar(Car):
"""Represent aspects of a car, specific to electric vehicles."""
def __init__(self, make, model, year):
"""
Initialize attributes of the parent class.
Then initialize attributes specific to an electric car.
"""
super().__init__(make, model, year)
❸ self.battery = Battery()
my_leaf = ElectricCar('nissan', 'leaf', 2024)
print(my_leaf.get_descriptive_name())
my_leaf.battery.describe_battery()
我们定义了一个名为Battery的新类,它没有继承任何其他类。__init__()方法❶除了self之外,还有一个参数battery_size。这是一个可选参数,如果没有提供值,则将电池大小设为 40。describe_battery()方法也已移到这个类中❷。
在ElectricCar类中,我们现在添加了一个名为self.battery的属性❸。这一行代码告诉 Python 创建一个新的Battery实例(默认大小为 40,因为我们没有指定值),并将该实例分配给属性self.battery。每次调用__init__()方法时,这个操作都会发生;任何ElectricCar实例现在都会自动创建一个Battery实例。
我们创建一辆电动汽车,并将其分配给变量my_leaf。当我们想描述电池时,我们需要通过汽车的battery属性来操作:
my_leaf.battery.describe_battery()
这一行代码告诉 Python 查看实例my_leaf,找到其battery属性,并调用与分配给该属性的Battery实例相关联的describe_battery()方法。
输出与我们之前看到的完全相同:
2024 Nissan Leaf
This car has a 40-kWh battery.
这看起来是多余的工作,但现在我们可以详细描述电池,而不必让ElectricCar类变得臃肿。让我们向Battery中添加另一个方法,用于报告基于电池大小的汽车续航:
class Car:
*--snip--*
class Battery:
*--snip--*
def get_range(self):
"""Print a statement about the range this battery provides."""
if self.battery_size == 40:
range = 150
elif self.battery_size == 65:
range = 225
print(f"This car can go about {range} miles on a full charge.")
class ElectricCar(Car):
*--snip--*
my_leaf = ElectricCar('nissan', 'leaf', 2024)
print(my_leaf.get_descriptive_name())
my_leaf.battery.describe_battery()
❶ my_leaf.battery.get_range()
新方法get_range()执行一些简单的分析。如果电池的容量是 40 kWh,get_range()将续航设定为 150 英里;如果容量是 65 kWh,则设定为 225 英里。然后,它会报告这个值。当我们想使用这个方法时,我们需要通过汽车的battery属性再次调用它❶。
输出告诉我们基于电池大小的汽车续航:
2024 Nissan Leaf
This car has a 40-kWh battery.
This car can go about 150 miles on a full charge.
真实世界对象建模
当你开始建模更复杂的事物,比如电动汽车时,你会遇到有趣的问题。电动汽车的续航是电池的属性,还是汽车的属性?如果我们只描述一辆车,可能保持get_range()方法与Battery类的关联是可以的。但如果我们在描述一家制造商的整个汽车系列,我们可能希望将get_range()方法移到ElectricCar类中。get_range()方法仍然会在确定续航之前检查电池大小,但它会报告一个特定于所关联汽车类型的续航。或者,我们可以保持get_range()方法与电池的关联,但传递一个像car_model这样的参数给它。get_range()方法然后会根据电池大小和汽车模型报告续航。
这带你进入作为程序员成长的一个有趣点。当你与这些问题作斗争时,你是在以更高的逻辑层次思考,而不是专注于语法层面。你思考的不是 Python,而是如何用代码表示现实世界。当你达到这个层次时,你会意识到,建模现实世界情境时,通常没有对错之分。一些方法比其他方法更高效,但要找到最有效的表示方法需要实践。如果你的代码按照你希望的方式运行,那么你做得很好!如果你发现自己不断拆解类并用不同的方法重写它们,不要灰心。每个人在写出准确、高效的代码的过程中都会经历这一过程。
导入类
当你为类添加更多功能时,即使在正确使用继承和组合的情况下,文件也可能变得很长。为了符合 Python 的整体哲学,你会希望保持文件尽可能简洁。为了帮助实现这一点,Python 允许你将类存储在模块中,然后将需要的类导入到主程序中。
导入单个类
让我们创建一个只包含Car类的模块。这引出了一个微妙的命名问题:在这一章中,我们已经有一个名为car.py的文件,但这个模块应该命名为car.py,因为它包含了代表汽车的代码。我们将通过将Car类存储在名为car.py的模块中来解决这个命名问题,取代我们之前使用的car.py文件。从现在起,任何使用这个模块的程序都需要一个更具体的文件名,比如my_car.py。以下是仅包含Car类代码的car.py:
car.py
❶ """A class that can be used to represent a car."""
class Car:
"""A simple attempt to represent a car."""
def __init__(self, make, model, year):
"""Initialize attributes to describe a car."""
self.make = make
self.model = model
self.year = year
self.odometer_reading = 0
def get_descriptive_name(self):
"""Return a neatly formatted descriptive name."""
long_name = f"{self.year} {self.make} {self.model}"
return long_name.title()
def read_odometer(self):
"""Print a statement showing the car's mileage."""
print(f"This car has {self.odometer_reading} miles on it.")
def update_odometer(self, mileage):
"""
Set the odometer reading to the given value.
Reject the change if it attempts to roll the odometer back.
"""
if mileage >= self.odometer_reading:
self.odometer_reading = mileage
else:
print("You can't roll back an odometer!")
def increment_odometer(self, miles):
"""Add the given amount to the odometer reading."""
self.odometer_reading += miles
我们在模块级别包含一个文档字符串,简要描述该模块的内容 ❶。你应该为你创建的每个模块编写文档字符串。
现在我们创建一个单独的文件,名为my_car.py。这个文件将导入Car类,然后从这个类创建一个实例:
my_car.py
❶ from car import Car
my_new_car = Car('audi', 'a4', 2024)
print(my_new_car.get_descriptive_name())
my_new_car.odometer_reading = 23
my_new_car.read_odometer()
import语句❶告诉 Python 打开car模块并导入Car类。现在我们可以像在此文件中定义该类一样使用Car类。输出与我们之前看到的相同:
2024 Audi A4
This car has 23 miles on it.
导入类是一种有效的编程方式。想象一下,如果将整个Car类包含在程序文件中,这个文件会有多长。相反,如果将类移动到模块中并导入该模块,你仍然可以获得相同的功能,但能保持主程序文件清晰易读。你还将大部分逻辑存储在单独的文件中;一旦你的类按照预期工作,你可以将这些文件保留不动,专注于主程序的更高层逻辑。
在一个模块中存储多个类
你可以在一个模块中存储尽可能多的类,尽管每个类应该以某种方式相关。Battery类和ElectricCar类都帮助表示汽车,因此我们将它们添加到模块car.py中。
car.py
"""A set of classes used to represent gas and electric cars."""
class Car:
*--snip--*
class Battery:
"""A simple attempt to model a battery for an electric car."""
def __init__(self, battery_size=40):
"""Initialize the battery's attributes."""
self.battery_size = battery_size
def describe_battery(self):
"""Print a statement describing the battery size."""
print(f"This car has a {self.battery_size}-kWh battery.")
def get_range(self):
"""Print a statement about the range this battery provides."""
if self.battery_size == 40:
range = 150
elif self.battery_size == 65:
range = 225
print(f"This car can go about {range} miles on a full charge.")
class ElectricCar(Car):
"""Models aspects of a car, specific to electric vehicles."""
def __init__(self, make, model, year):
"""
Initialize attributes of the parent class.
Then initialize attributes specific to an electric car.
"""
super().__init__(make, model, year)
self.battery = Battery()
现在我们可以创建一个新文件,命名为my_electric_car.py,导入ElectricCar类并制造一辆电动汽车:
my_electric_car.py
from car import ElectricCar
my_leaf = ElectricCar('nissan', 'leaf', 2024)
print(my_leaf.get_descriptive_name())
my_leaf.battery.describe_battery()
my_leaf.battery.get_range()
即使大部分逻辑被隐藏在模块中,这仍然输出我们之前看到的结果:
2024 Nissan Leaf
This car has a 40-kWh battery.
This car can go about 150 miles on a full charge.
从模块中导入多个类
你可以将所需的多个类导入到程序文件中。如果我们想在同一个文件中制作一辆常规汽车和一辆电动汽车,我们需要导入Car类和ElectricCar类:
my_cars.py
❶ from car import Car, ElectricCar
❷ my_mustang = Car('ford', 'mustang', 2024)
print(my_mustang.get_descriptive_name())
❸ my_leaf = ElectricCar('nissan', 'leaf', 2024)
print(my_leaf.get_descriptive_name())
你可以通过用逗号❶分隔每个类来从模块中导入多个类。一旦导入了必要的类,你可以根据需要创建每个类的任意数量的实例。
在这个示例中,我们创建了一辆汽油驱动的福特野马❷,然后是电动的日产聆风❸:
2024 Ford Mustang
2024 Nissan Leaf
导入整个模块
你也可以导入整个模块,然后使用点符号访问所需的类。这种方法简单,代码也容易阅读。因为每次创建类实例的调用都包括模块名称,所以你不会与当前文件中使用的任何名称发生命名冲突。
下面是导入整个car模块并创建一辆常规汽车和一辆电动汽车的代码:
my_cars.py
❶ import car
❷ my_mustang = car.Car('ford', 'mustang', 2024)
print(my_mustang.get_descriptive_name())
❸ my_leaf = car.ElectricCar('nissan', 'leaf', 2024)
print(my_leaf.get_descriptive_name())
首先,我们导入整个car模块❶。然后,我们通过module_name.ClassName语法访问所需的类。我们再次创建一辆福特野马❷和一辆日产聆风❸。
从模块中导入所有类
你可以使用以下语法导入模块中的每个类:
from `module_name` import *
这种方法不推荐使用,原因有两个。首先,能够阅读文件顶部的import语句并清楚了解程序使用了哪些类是很有帮助的。采用这种方法时,模块中你使用了哪些类并不清晰。这个方法也可能导致文件中的命名混乱。如果你不小心导入了一个与程序文件中其他内容同名的类,可能会产生难以诊断的错误。我之所以在这里展示这种方法,是因为尽管它并不推荐,但你可能会在其他人的代码中看到它。
如果你需要从一个模块导入多个类,最好导入整个模块,并使用module_name.ClassName语法。虽然你在文件顶部看不到所有类,但你会清楚地看到模块在程序中的使用位置。而且,你也能避免导入模块中每个类时可能出现的命名冲突。
将模块导入到模块中
有时候,你可能希望将类分散到多个模块中,以防某个文件过大,同时避免将不相关的类存储在同一个模块中。当你将类存储在多个模块中时,可能会发现一个模块中的类依赖于另一个模块中的类。在这种情况下,你可以将所需的类导入到第一个模块中。
例如,我们将Car类存储在一个模块中,将ElectricCar和Battery类存储在另一个模块中。我们将创建一个新模块,叫做electric_car.py——替换我们之前创建的electric_car.py文件——并将Battery和ElectricCar类仅复制到这个文件中:
electric_car.py
"""A set of classes that can be used to represent electric cars."""
from car import Car
class Battery:
*--snip--*
class ElectricCar(Car):
*--snip--*
ElectricCar类需要访问它的父类Car,因此我们直接将Car导入模块。如果我们忘记了这行代码,当尝试导入electric_car模块时,Python 会抛出错误。我们还需要更新Car模块,使其只包含Car类:
car.py
"""A class that can be used to represent a car."""
class Car:
*--snip--*
现在我们可以分别从每个模块导入,并创建任何我们需要的汽车:
my_cars.py
from car import Car
from electric_car import ElectricCar
my_mustang = Car('ford', 'mustang', 2024)
print(my_mustang.get_descriptive_name())
my_leaf = ElectricCar('nissan', 'leaf', 2024)
print(my_leaf.get_descriptive_name())
我们从它的模块导入Car,并从它的模块导入ElectricCar。然后我们创建一辆普通汽车和一辆电动汽车。两辆车都创建得正确无误:
2024 Ford Mustang
2024 Nissan Leaf
使用别名
正如你在第八章看到的,使用模块时,别名非常有帮助,能帮助你组织项目代码。你也可以在导入类时使用别名。
举个例子,假设你有一个程序,你需要制造一堆电动汽车。反复输入(和读取)ElectricCar可能会变得很繁琐。你可以在导入语句中为ElectricCar起个别名:
from electric_car import ElectricCar as EC
现在每当你想制造电动汽车时,你可以使用这个别名:
my_leaf = EC('nissan', 'leaf', 2024)
你还可以为模块指定别名。以下是如何使用别名导入整个electric_car模块的方法:
import electric_car as ec
现在你可以通过模块别名来使用完整的类名:
my_leaf = ec.ElectricCar('nissan', 'leaf', 2024)
寻找自己的工作流程
正如你所看到的,Python 为大型项目提供了多种代码结构选项。了解这些选项非常重要,这样你就能确定最佳的项目组织方式,同时也能理解他人的项目。
当你刚开始编写代码时,保持代码结构简单。尝试将所有内容写在一个文件中,等一切正常后,再将类移到单独的模块中。如果你喜欢模块和文件之间的交互,试着在开始一个项目时将类存储在模块中。找到一种能让你编写有效代码的方法,并从那里出发。
Python 标准库
Python 标准库 是随每个 Python 安装一起提供的一组模块。现在你已经基本理解了函数和类是如何工作的,你可以开始使用其他程序员编写的类似模块。你可以通过在文件顶部添加简单的import语句来使用标准库中的任何函数或类。我们来看一个模块,random,它在模拟许多现实世界的情况时非常有用。
来自random模块的一个有趣函数是randint()。该函数接受两个整数参数,并返回这两个数字之间(包括这两个数字)的随机整数。
下面是如何生成 1 到 6 之间的随机数:
>>> **from random import randint**
>>> **randint(1, 6)**
3
另一个有用的函数是choice()。这个函数接受一个列表或元组,并返回一个随机选择的元素:
>>> **from random import choice**
>>> **players = ['charles', 'martina', 'michael', 'florence', 'eli']**
>>> **first_up = choice(players)**
>>> **first_up**
'florence'
random模块不应在构建安全相关的应用程序时使用,但它在许多有趣的项目中表现良好。
样式类
关于类的几个样式问题值得澄清,特别是当你的程序变得更加复杂时。
类名应使用驼峰命名法(CamelCase)。为此,将每个单词的首字母大写,且不要使用下划线。实例和模块名应使用小写字母,并在单词之间使用下划线。
每个类在类定义之后应有一个文档字符串。文档字符串应简短描述类的功能,你应该遵循编写函数文档字符串时使用的相同格式约定。每个模块也应有一个文档字符串,描述模块中的类的用途。
你可以使用空行来组织代码,但不要过度使用。在一个类内部,你可以在方法之间使用一行空行,而在模块中,你可以使用两行空行来分隔类。
如果你需要导入标准库中的模块和你自己编写的模块,应先导入标准库模块的语句。然后添加一个空行,再导入你自己编写模块的语句。在有多个导入语句的程序中,这种约定能更容易地看到程序中使用的不同模块的来源。
概述
在本章中,你学习了如何编写自己的类。你学习了如何使用属性在类中存储信息,以及如何编写方法来赋予类所需的行为。你学习了如何编写__init__()方法,通过该方法可以根据你需要的属性创建类的实例。你了解了如何直接通过实例修改属性,也学会了通过方法来修改属性。你学会了继承可以简化创建相关类的过程,并且学会了如何使用一个类的实例作为另一个类的属性,从而保持每个类的简洁性。
你了解了如何将类存储在模块中,并将所需的类导入到使用它们的文件中,这样可以保持项目的组织性。你开始学习 Python 标准库,并且通过random模块的示例看到了它的应用。最后,你学会了如何使用 Python 的约定来给类添加风格。
在第十章中,你将学习如何处理文件,这样你就可以保存你在程序中完成的工作以及你允许用户执行的操作。你还将了解异常,这是一种特殊的 Python 类,旨在帮助你在错误发生时做出响应。
第十章:文件与异常

现在你已经掌握了编写结构化、易用程序的基本技能,是时候考虑如何使你的程序更具相关性和实用性了。在本章中,你将学习如何处理文件,这样你的程序就能快速分析大量数据。
你将学会如何处理错误,以确保当程序遇到意外情况时不会崩溃。你将了解异常,它是 Python 创建的特殊对象,用来管理程序运行时发生的错误。你还将学习 json 模块,它可以帮助你保存用户数据,以防程序停止运行时数据丢失。
学会处理文件和保存数据将使你的程序更易于使用。用户将能够选择何时输入哪些数据。人们可以运行你的程序,完成一些工作,然后关闭程序,并从上次离开的地方继续。学习如何处理异常将帮助你处理文件不存在的情况,并应对其他可能导致程序崩溃的问题。这将使你的程序在遇到坏数据时更具稳健性,无论数据是来自无心的错误,还是恶意企图破坏程序。通过本章你将学到的技能,你将使你的程序更具适用性、可用性和稳定性。
从文件中读取
大量的数据可以通过文本文件获取。文本文件可以包含天气数据、交通数据、社会经济数据、文学作品等内容。从文件中读取数据在数据分析应用中尤为重要,但它同样适用于任何需要分析或修改存储在文件中的信息的情况。例如,你可以编写一个程序,读取文本文件的内容,并重新编写文件,添加格式以便浏览器能够显示。
当你想处理文本文件中的信息时,第一步是将文件读取到内存中。然后,你可以一次性处理整个文件的内容,或者逐行处理内容。
读取文件内容
首先,我们需要一个包含几行文本的文件。让我们从一个包含 pi 到 30 位小数的文件开始,每行包含 10 位小数:
pi_digits.txt
3.1415926535
8979323846
2643383279
要亲自尝试以下示例,你可以在编辑器中输入这些行并将文件保存为 pi_digits.txt,或者你可以通过 ehmatthes.github.io/pcc_3e 下载该文件。将文件保存在与你存储本章程序相同的目录中。
下面是一个程序,它打开文件,读取文件内容,并将文件内容打印到屏幕上:
file_reader.py
from pathlib import Path
❶ path = Path('pi_digits.txt')
❷ contents = path.read_text()
print(contents)
为了处理文件内容,我们需要告诉 Python 文件的路径。路径是系统中一个文件或文件夹的确切位置。Python 提供了一个叫 pathlib 的模块,使得无论你或你的程序用户使用的是哪个操作系统,都能更轻松地处理文件和目录。像这样提供特定功能的模块通常被称为 库,因此命名为 pathlib。
我们首先从 pathlib 导入 Path 类。使用 Path 对象指向文件后,你可以做很多事情。例如,在操作文件之前,你可以检查文件是否存在,读取文件内容,或者向文件写入新数据。在这里,我们构建了一个表示文件 pi_digits.txt 的 Path 对象,并将其赋值给变量 path ❶。因为这个文件保存在与我们编写的 .py 文件相同的目录中,所以 Path 只需要文件名就能访问该文件。
一旦我们有了一个表示 pi_digits.txt 的 Path 对象,我们就可以使用 read_text() 方法读取文件的全部内容 ❷。文件的内容会作为一个字符串返回,我们将其赋值给变量 contents。当我们打印 contents 的值时,看到的就是文件的全部内容:
3.1415926535
8979323846
2643383279
这段输出与原始文件的唯一区别是输出末尾多了一行空白行。空白行出现是因为 read_text() 在到达文件末尾时返回了一个空字符串;这个空字符串显示为一行空白。
我们可以通过对 contents 字符串使用 rstrip() 来去除多余的空白行:
from pathlib import Path
path = Path('pi_digits.txt')
contents = path.read_text()
contents = contents.rstrip()
print(contents)
回想一下第二章,Python 的 rstrip() 方法会移除字符串右侧的所有空白字符。现在输出的内容与原始文件完全一致:
3.1415926535
8979323846
2643383279
我们可以通过在调用 read_text() 后立即应用 rstrip() 方法来去掉读取文件内容时的末尾换行符:
contents = path.read_text().rstrip()
这一行代码告诉 Python 调用我们正在处理的文件的 read_text() 方法。然后它将 read_text() 返回的字符串应用 rstrip() 方法。清理后的字符串随后被赋值给变量 contents。这种方法称为 方法链,在编程中你会经常看到它。
相对路径和绝对路径
当你将一个简单的文件名,比如 pi_digits.txt,传递给 Path 时,Python 会在当前执行的文件所在的目录中查找该文件(也就是你的 .py 程序文件)。
有时,取决于你如何组织工作,你想要打开的文件可能不在与你的程序文件相同的目录中。例如,你可能将程序文件存储在名为python_work的文件夹中;在python_work文件夹内,你可能会有另一个名为text_files的文件夹,用以区分程序文件和它们所操作的文本文件。即使text_files在python_work中,直接传递文件名给Path也无法正常工作,因为 Python 只会在python_work中查找,并且在那停止;它不会继续查找text_files中的文件。为了让 Python 从与你的程序文件存储位置不同的目录打开文件,你需要提供正确的路径。
编程中指定路径有两种主要方法。相对文件路径告诉 Python 从当前正在运行的程序文件所在目录的位置查找给定位置。由于text_files位于python_work内,我们需要构建一个从text_files文件夹开始,并以文件名结束的路径。以下是如何构建这个路径的示例:
path = Path('text_files/`filename`.txt')
你也可以告诉 Python 文件在你电脑上的确切位置,无论正在执行的程序存储在哪里。这称为绝对文件路径。如果相对路径不起作用,你可以使用绝对路径。例如,如果你把text_files放在了与python_work不同的文件夹中,那么直接传递路径Path 'text_files/``filename``.txt' 是行不通的,因为 Python 只会在python_work中查找该位置。你需要写出绝对路径来明确指定 Python 应该查找的位置。
绝对路径通常比相对路径长,因为它们是从系统的根文件夹开始的:
path = Path('/home/eric/data_files/text_files/`filename`.txt')
使用绝对路径,你可以从系统上的任何位置读取文件。现在,最简单的做法是将文件存储在与你的程序文件相同的目录中,或存储在一个名为text_files的文件夹内,该文件夹位于存储程序文件的目录中。
访问文件的行
当你处理文件时,你通常需要检查文件的每一行。你可能在文件中查找特定的信息,或者你可能想以某种方式修改文件中的文本。例如,你可能希望阅读一份天气数据文件,并处理包含sunny(晴天)字样的所有描述该日天气的行。在新闻报道中,你可能需要查找任何带有标签<headline>的行,并以特定格式重新写入该行。
你可以使用splitlines()方法将一长串字符串转换为一组行,然后使用for循环逐行检查文件中的内容:
file_reader.py
from pathlib import Path
path = Path('pi_digits.txt')
❶ contents = path.read_text()
❷ lines = contents.splitlines()
for line in lines:
print(line)
我们首先读取文件的全部内容,就像之前那样 ❶。如果你打算处理文件中的单独行,在读取文件时不需要去除任何空白字符。splitlines()方法会返回文件中所有行的列表,我们将这个列表赋值给变量lines ❷。然后我们遍历这些行并打印每一行:
3.1415926535
8979323846
2643383279
由于我们没有修改任何行,输出与原始文本文件完全一致。
处理文件内容
在你将文件的内容读取到内存中后,你可以对这些数据做任何你想做的事情,所以让我们简要地探索一下π的数字。首先,我们将尝试构建一个不包含任何空白字符的字符串,包含文件中的所有数字:
pi_string.py
from pathlib import Path
path = Path('pi_digits.txt')
contents = path.read_text()
lines = contents.splitlines()
pi_string = ''
❶ for line in lines:
pi_string += line
print(pi_string)
print(len(pi_string))
我们首先读取文件并将每行数字存储在一个列表中,就像我们在前面的示例中所做的那样。然后我们创建一个变量pi_string,用来存储π的数字。我们写一个循环,将每一行数字添加到pi_string中 ❶。然后我们打印这个字符串,并显示字符串的长度:
3.1415926535 8979323846 2643383279
36
变量pi_string包含了每行数字左侧的空白字符,但我们可以通过对每一行使用lstrip()来去除这些空白字符:
*--snip--*
for line in lines:
pi_string += line.lstrip()
print(pi_string)
print(len(pi_string))
现在我们有一个包含π到 30 位小数的字符串。这个字符串长度为 32 个字符,因为它还包括了前导的3和小数点:
3.141592653589793238462643383279
32
大文件:一百万位
到目前为止,我们专注于分析一个只包含三行的文本文件,但这些示例中的代码同样适用于更大的文件。如果我们从一个包含π到 1,000,000 位的小数的文本文件开始,而不是仅仅 30 位,我们可以创建一个包含所有这些数字的字符串。我们不需要更改程序,只需传入一个不同的文件。我们还会只打印前 50 位小数,这样就不需要在终端上看一百万个数字滚动过去:
pi_string.py
from pathlib import Path
path = Path('pi_million_digits.txt')
contents = path.read_text()
lines = contents.splitlines()
pi_string = ''
for line in lines:
pi_string += line.lstrip()
print(f"{pi_string[:52]}...")
print(len(pi_string))
输出显示,我们确实得到了一个包含π到 1,000,000 位小数的字符串:
3.14159265358979323846264338327950288419716939937510...
1000002
Python 没有固有的限制来限制你可以处理的数据量;你可以处理尽可能多的数据,只要你的系统内存能承受。
你的生日出现在π中吗?
我一直很好奇我的生日是否出现在π的数字中。让我们用刚刚写的程序来查找一下,看看某人的生日是否出现在π的前一百万位数字中。我们可以通过将每个生日表示为一个数字字符串,然后查看该字符串是否出现在pi_string中来实现:
pi_birthday.py
*--snip--*
for line in lines:
pi_string += line.strip()
birthday = input("Enter your birthday, in the form mmddyy: ")
if birthday in pi_string:
print("Your birthday appears in the first million digits of pi!")
else:
print("Your birthday does not appear in the first million digits of pi.")
我们首先提示用户输入生日,然后检查该字符串是否在pi_string中。让我们试试:
Enter your birthdate, in the form mmddyy: **120372**
Your birthday appears in the first million digits of pi!
我的生日确实出现在π的数字中!一旦你从文件中读取数据,就可以以任何你能想象的方式分析其内容。
写入文件
保存数据的最简单方法之一就是将其写入文件。当你将文本写入文件时,输出在你关闭包含程序输出的终端后仍然可用。你可以在程序运行完毕后检查输出,也可以将输出文件分享给他人。你还可以编写读取文本并重新加载到内存中的程序,以便以后继续使用。
写入单行
一旦定义了路径,你就可以使用 write_text() 方法向文件写入内容。为了演示这一过程,让我们写一个简单的消息并将其存储到文件中,而不是直接输出到屏幕:
write_message.py
from pathlib import Path
path = Path('programming.txt')
path.write_text("I love programming.")
write_text() 方法接受一个参数:你想写入文件的字符串。这个程序没有终端输出,但如果你打开 programming.txt 文件,你会看到一行内容:
programming.txt
I love programming.
这个文件的行为就像你电脑上的任何其他文件。你可以打开它,写入新文本,复制其中的内容,粘贴到其他地方,等等。
写入多行
write_text() 方法在后台做了几件事。如果 path 指向的文件不存在,它会创建该文件。而且,在将字符串写入文件后,它会确保文件被正确关闭。没有正确关闭的文件可能会导致数据丢失或损坏。
要向文件写入多行,你需要构建一个包含文件所有内容的字符串,然后使用该字符串调用 write_text()。让我们写几行内容到 programming.txt 文件中:
from pathlib import Path
contents = "I love programming.\n"
contents += "I love creating new games.\n"
contents += "I also love working with data.\n"
path = Path('programming.txt')
path.write_text(contents)
我们定义了一个名为 contents 的变量,用来保存文件的全部内容。在下一行,我们使用 += 运算符将新的内容添加到这个字符串中。你可以根据需要多次执行此操作,构建任意长度的字符串。在这个例子中,我们在每一行的末尾添加了换行符,以确保每条语句都出现在单独的一行上。
如果你运行这个程序并打开 programming.txt 文件,你会看到每一行内容都出现在文本文件中:
I love programming.
I love creating new games.
I also love working with data.
你还可以使用空格、制表符和空白行来格式化输出,就像你在终端输出中所做的那样。字符串的长度没有限制,这也是许多计算机生成文档的创建方式。
异常
Python 使用名为 exceptions(异常)的特殊对象来处理程序执行过程中发生的错误。当发生一个错误时,导致 Python 不确定下一步该做什么,它会创建一个异常对象。如果你编写了处理异常的代码,程序将继续运行。如果你没有处理异常,程序会停止并显示 traceback(追踪信息),其中包含已引发的异常报告。
异常通过try-except块进行处理。一个try-except块要求 Python 执行某些操作,但它也告诉 Python 如果发生异常时该怎么做。当你使用try-except块时,即使出现问题,程序也会继续运行。用户将看到你编写的友好错误信息,而不是可能让用户困惑的回溯信息。
处理ZeroDivisionError异常
让我们看一个简单的错误,导致 Python 引发异常。你可能知道,除以零是不可能的,但我们还是要求 Python 这么做:
division_calculator.py
print(5/0)
Python 无法执行此操作,因此我们会得到一个回溯信息:
Traceback (most recent call last):
File "division_calculator.py", line 1, in <module>
print(5/0)
~^~
❶ ZeroDivisionError: division by zero
回溯中报告的错误,ZeroDivisionError,是一个异常对象 ❶。Python 会在无法执行我们要求的操作时创建这种对象。当发生这种情况时,Python 会停止程序并告诉我们引发的异常类型。我们可以使用这些信息来修改我们的程序。我们将告诉 Python 在这种异常发生时该做什么,这样如果它再次发生,我们就能做好准备。
使用try-except块
当你认为可能发生错误时,可以编写一个try-except块来处理可能引发的异常。你告诉 Python 尝试运行某些代码,并告诉它在代码引发特定类型的异常时应该怎么做。
下面是一个用于处理ZeroDivisionError异常的try-except块的示例:
try:
print(5/0)
except ZeroDivisionError:
print("You can't divide by zero!")
我们将导致错误的print(5/0)语句放在try块中。如果try块中的代码执行成功,Python 会跳过except块。如果try块中的代码发生错误,Python 会查找与引发的错误匹配的except块,并执行该块中的代码。
在这个例子中,try块中的代码引发了一个ZeroDivisionError,因此 Python 会查找一个except块,告诉它如何响应。然后 Python 会执行该块中的代码,用户将看到一个友好的错误信息,而不是回溯信息:
You can't divide by zero!
如果try-except块后面还有更多的代码,程序将继续运行,因为我们告诉 Python 如何处理错误。让我们看一个示例,捕获错误可以让程序继续运行。
使用异常防止崩溃
正确处理错误在程序发生错误后仍有更多工作要做时尤为重要。尤其是在提示用户输入的程序中,这种情况经常发生。如果程序能够适当响应无效输入,它可以提示用户输入更多有效的内容,而不是崩溃。
让我们创建一个简单的仅进行除法的计算器:
division_calculator.py
print("Give me two numbers, and I'll divide them.")
print("Enter 'q' to quit.")
while True:
❶ first_number = input("\nFirst number: ")
if first_number == 'q':
break
❷ second_number = input("Second number: ")
if second_number == 'q':
break
❸ answer = int(first_number) / int(second_number)
print(answer)
这个程序提示用户输入一个first_number ❶,如果用户没有输入q来退出,则输入一个second_number ❷。然后我们将这两个数字相除得到answer ❸。这个程序没有做任何错误处理,因此如果要求它进行除以零的操作,它会崩溃:
Give me two numbers, and I'll divide them.
Enter 'q' to quit.
First number: **5**
Second number: **0**
Traceback (most recent call last):
File "division_calculator.py", line 11, in <module>
answer = int(first_number) / int(second_number)
~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~
ZeroDivisionError: division by zero
程序崩溃虽然不好,但让用户看到追踪信息也不是一个好主意。非技术用户会被这些信息弄得很困惑,而在恶意环境中,攻击者会通过这些信息学到你不希望他们知道的内容。例如,他们会知道你的程序文件名,还会看到某个无法正常工作的代码部分。一个有经验的攻击者有时可以利用这些信息来确定攻击代码的方式。
else 块
我们可以通过将可能产生错误的行包装在try-except块中,使程序更具抗错能力。错误发生在执行除法的那一行,所以我们将在那里放置try-except块。这个例子还包括了一个else块。任何依赖于try块成功执行的代码都会放入else块中:
*--snip--*
while True:
*--snip--*
if second_number == 'q':
break
❶ try:
answer = int(first_number) / int(second_number)
❷ except ZeroDivisionError:
print("You can't divide by 0!")
❸ else:
print(answer)
我们要求 Python 在try块中尝试完成除法操作❶,该块只包含可能引发错误的代码。任何依赖于try块成功的代码都放入else块。在这种情况下,如果除法操作成功,我们使用else块来打印结果❸。
except块告诉 Python 在遇到ZeroDivisionError时如何响应❷。如果try块因为除零错误而未能成功执行,我们会打印一条友好的消息,告诉用户如何避免这种错误。程序会继续运行,用户也看不到追踪信息:
Give me two numbers, and I'll divide them.
Enter 'q' to quit.
First number: **5**
Second number: **0**
You can't divide by 0!
First number: **5**
Second number: **2**
2.5
First number: **q**
只有可能引发异常的代码应该放入try块中。有时你还会有一些额外的代码,只有在try块成功执行后才应该运行,这些代码会放在else块中。except块告诉 Python 在try块运行代码时遇到特定异常时应该怎么做。
通过预测可能出现的错误来源,你可以编写出更加健壮的程序,即使遇到无效数据或缺失资源时也能继续运行。你的代码将能抵抗无心的用户错误和恶意攻击。
处理 FileNotFoundError 异常
在处理文件时,一个常见的问题是如何处理丢失的文件。你要找的文件可能在不同的位置,文件名可能拼写错误,或者文件根本不存在。你可以通过try-except块来处理所有这些情况。
让我们尝试读取一个不存在的文件。以下程序尝试读取爱丽丝梦游仙境的内容,但我没有将文件alice.txt保存在与alice.py相同的目录下:
alice.py
from pathlib import Path
path = Path('alice.txt')
contents = path.read_text(encoding='utf-8')
请注意,我们在这里使用read_text()的方式与之前所见的稍有不同。当你的系统默认编码与正在读取的文件编码不匹配时,需要使用encoding参数。这种情况最常见于读取来自非本地系统创建的文件时。
Python 无法从丢失的文件中读取数据,因此会引发异常:
Traceback (most recent call last):
❶ File "alice.py", line 4, in <module>
❷ contents = path.read_text(encoding='utf-8')
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/.../pathlib.py", line 1056, in read_text
with self.open(mode='r', encoding=encoding, errors=errors) as f:
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/.../pathlib.py", line 1042, in open
return io.open(self, mode, buffering, encoding, errors, newline)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
❸ FileNotFoundError: [Errno 2] No such file or directory: 'alice.txt'
这是比我们之前见过的回溯信息更长的回溯,所以让我们来看看如何理解更复杂的回溯。通常最好从回溯的最后一行开始查看。在最后一行,我们可以看到引发了FileNotFoundError异常 ❸。这很重要,因为它告诉我们在except块中使用哪种类型的异常。
回溯信息的开头附近 ❶,我们可以看到错误发生在文件alice.py的第 4 行。下一行显示了导致错误的代码 ❷。其余的回溯信息展示了涉及打开和读取文件的库中的一些代码。通常,你不需要逐行阅读或理解回溯中的所有这些内容。
为了处理引发的错误,try块将从回溯中标识为问题行的那一行开始。在我们的示例中,这一行包含了read_text():
from pathlib import Path
path = Path('alice.txt')
try:
contents = path.read_text(encoding='utf-8')
❶ except FileNotFoundError:
print(f"Sorry, the file {path} does not exist.")
在这个示例中,try块中的代码会产生FileNotFoundError,因此我们编写一个匹配该错误的except块 ❶。当找不到文件时,Python 会运行该块中的代码,结果是显示一个友好的错误信息,而不是回溯信息:
Sorry, the file alice.txt does not exist.
如果文件不存在,程序没有其他操作要做,因此我们只会看到这些输出。让我们基于这个例子继续,看看异常处理如何帮助你处理多个文件时的问题。
分析文本
你可以分析包含整本书的文本文件。许多经典文学作品作为简单的文本文件可供使用,因为它们属于公有领域。本节使用的文本来自古腾堡计划(gutenberg.org)。古腾堡计划维护着一系列公有领域的文学作品,如果你有兴趣在编程项目中使用文学文本,这是一个很好的资源。
让我们导入爱丽丝梦游仙境的文本并尝试统计其中的单词数。为此,我们将使用字符串方法split(),它默认在任何空白字符处将字符串拆分:
from pathlib import Path
path = Path('alice.txt')
try:
contents = path.read_text(encoding='utf-8')
except FileNotFoundError:
print(f"Sorry, the file {path} does not exist.")
else:
# Count the approximate number of words in the file:
❶ words = contents.split()
❷ num_words = len(words)
print(f"The file {path} has about {num_words} words.")
我将文件alice.txt移动到了正确的目录,因此这次try块会正常工作。我们获取包含爱丽丝梦游仙境全文的字符串contents,并使用split()方法将其转换为一本书中所有单词的列表 ❶。使用len()函数计算该列表的长度 ❷,可以很好地估算出原始文本中的单词数。最后,我们打印一条报告,显示文件中找到的单词数。此代码放在else块中,因为只有在try块中的代码成功执行时,它才会执行。
输出告诉我们alice.txt中有多少个单词:
The file alice.txt has about 29594 words.
计数有点偏高,因为出版商在此处使用的文本文件中提供了额外的信息,但它大致上可以反映出爱丽丝梦游仙境的字数。
处理多个文件
让我们添加更多的书籍来分析,但在此之前,先将程序的大部分内容移动到一个名为count_words()的函数中。这将使我们能够更轻松地对多本书进行分析:
word_count.py
from pathlib import Path
def count_words(path):
❶ """Count the approximate number of words in a file."""
try:
contents = path.read_text(encoding='utf-8')
except FileNotFoundError:
print(f"Sorry, the file {path} does not exist.")
else:
# Count the approximate number of words in the file:
words = contents.split()
num_words = len(words)
print(f"The file {path} has about {num_words} words.")
path = Path('alice.txt')
count_words(path)
这段代码大部分没有变化。它只是被缩进并移到了count_words()的主体内。修改程序时保持注释更新是一个好习惯,因此注释也已经被更改为文档字符串,并稍微重新措辞了 ❶。
现在我们可以编写一个简短的循环,统计我们想要分析的任何文本中的单词。我们通过将要分析的文件名存储在一个列表中来实现,然后对列表中的每个文件调用count_words()。我们将尝试统计Alice in Wonderland(《爱丽丝梦游仙境》)、Siddhartha(《悉达多》)、Moby Dick(《白鲸》)和Little Women(《小妇人》)的字数,这些书籍都可以在公共领域找到。我故意将siddhartha.txt文件从包含word_count.py的目录中遗漏,以便我们可以看到程序如何处理缺失的文件:
from pathlib import Path
def count_words(filename):
*--snip--*
filenames = ['alice.txt', 'siddhartha.txt', 'moby_dick.txt',
'little_women.txt']
for filename in filenames:
❶ path = Path(filename)
count_words(path)
文件名作为简单的字符串存储。每个字符串在调用count_words()之前都会被转换为一个Path对象 ❶。缺失的siddhartha.txt文件不会影响程序的其余部分执行:
The file alice.txt has about 29594 words.
Sorry, the file siddhartha.txt does not exist.
The file moby_dick.txt has about 215864 words.
The file little_women.txt has about 189142 words.
在这个例子中使用try-except块提供了两个显著的优势。我们防止用户看到回溯信息,并且让程序继续分析它能够找到的文本。如果我们没有捕获到siddhartha.txt引发的FileNotFoundError,用户将看到完整的回溯信息,程序会在尝试分析Siddhartha后停止运行。它将永远不会分析Moby Dick或Little Women。
安静地失败
在之前的示例中,我们告知用户某个文件不可用。但并不是所有捕获到的异常都需要报告。有时你希望程序在发生异常时默默地失败,并继续执行,就好像什么都没发生一样。要让程序安静地失败,你照常编写try块,但在except块中明确告诉 Python 不做任何事。Python 有一个pass语句,它会告诉 Python 在该块中什么也不做:
def count_words(path):
"""Count the approximate number of words in a file."""
try:
*--snip--*
except FileNotFoundError:
pass
else:
*--snip--*
这份列表和之前的唯一不同之处在于except块中的pass语句。现在,当抛出FileNotFoundError时,except块中的代码会运行,但什么也不会发生。不会生成回溯信息,用户也不会看到针对抛出的错误的任何输出。用户只能看到每个文件的字数,但不会看到文件未找到的任何提示:
The file alice.txt has about 29594 words.
The file moby_dick.txt has about 215864 words.
The file little_women.txt has about 189142 words.
pass语句也充当占位符。它提醒你在程序执行的特定位置选择不做任何操作,并且可能稍后在该位置做些事情。例如,在这个程序中,我们可能决定将任何缺失的文件名写入名为missing_files.txt的文件。我们的用户看不到这个文件,但我们可以读取这个文件并处理任何缺失的文本。
决定报告哪些错误
你如何知道何时向用户报告错误,以及何时让程序悄无声息地失败?如果用户知道哪些文本应该被分析,他们可能会希望看到一条消息,告诉他们为什么某些文本未被分析。如果用户期望看到一些结果,但不知道哪些书籍应该被分析,他们可能不需要知道某些文本不可用。向用户提供他们不需要的信息可能会降低程序的可用性。Python 的错误处理结构让你能精细地控制在出现问题时与用户共享多少信息;决定共享多少信息由你来决定。
编写良好、经过充分测试的代码不容易出现内部错误,如语法或逻辑错误。但是每当你的程序依赖于外部因素,比如用户输入、文件的存在或网络连接的可用性时,都有可能引发异常。通过一些经验,你将能知道在程序中应该在哪里加入异常处理块,以及在出现错误时应该向用户报告多少信息。
存储数据
你的许多程序都会要求用户输入某些类型的信息。你可能会允许用户在游戏中存储偏好设置,或为可视化提供数据。无论你的程序关注的是什么,你都会将用户提供的信息存储在列表和字典等数据结构中。当用户关闭程序时,你几乎总是希望保存他们输入的信息。一种简单的实现方法是使用json模块来存储数据。
json模块允许你将简单的 Python 数据结构转换为 JSON 格式的字符串,然后在程序下次运行时从该文件中加载数据。你还可以使用json在不同的 Python 程序之间共享数据。更好的是,JSON 数据格式并不限于 Python,因此你可以将存储在 JSON 格式中的数据与使用其他编程语言的人员共享。这是一个有用且便于移植的格式,而且容易学习。
使用 json.dumps()和 json.loads()
让我们编写一个简单的程序,存储一组数字,再编写另一个程序将这些数字读取回内存。第一个程序将使用json.dumps()来存储这组数字,第二个程序将使用json.loads()。
json.dumps()函数接受一个参数:一个应该转换为 JSON 格式的数据。该函数返回一个字符串,我们可以将其写入数据文件:
number_writer.py
from pathlib import Path
import json
numbers = [2, 3, 5, 7, 11, 13]
❶ path = Path('numbers.json')
❷ contents = json.dumps(numbers)
path.write_text(contents)
我们首先导入json模块,然后创建一个数字列表来处理。接着我们选择一个文件名来存储数字列表❶。通常使用文件扩展名.json来表示文件中的数据采用 JSON 格式。然后,我们使用json.dumps()❷函数生成一个包含数据 JSON 表示形式的字符串。得到这个字符串后,我们使用之前使用的write_text()方法将其写入文件。
该程序没有输出,但让我们打开文件numbers.json并查看它。数据以类似 Python 的格式存储:
[2, 3, 5, 7, 11, 13]
现在我们将编写一个单独的程序,使用json.loads()将列表读回内存:
number_reader.py
from pathlib import Path
import json
❶ path = Path('numbers.json')
❷ contents = path.read_text()
❸ numbers = json.loads(contents)
print(numbers)
我们确保从我们写入的同一个文件中读取❶。由于数据文件只是一个具有特定格式的文本文件,我们可以使用read_text()方法读取它❷。然后我们将文件内容传递给json.loads()❸。该函数接收一个 JSON 格式的字符串并返回一个 Python 对象(在此情况下是一个列表),我们将其赋值给numbers。最后,我们打印恢复的数字列表,看到它与number_writer.py中创建的列表相同:
[2, 3, 5, 7, 11, 13]
这是在两个程序之间共享数据的一种简单方法。
保存和读取用户生成的数据
使用json保存数据在处理用户生成的数据时非常有用,因为如果你不以某种方式存储用户的信息,当程序停止运行时,你将丢失这些数据。让我们看一个例子,我们首次运行程序时提示用户输入名字,然后下次运行时记住他们的名字。
让我们从存储用户的名字开始:
remember_me.py
from pathlib import Path
import json
❶ username = input("What is your name? ")
❷ path = Path('username.json')
contents = json.dumps(username)
path.write_text(contents)
❸ print(f"We'll remember you when you come back, {username}!")
我们首先提示用户输入用户名以进行存储❶。接下来,我们将刚刚收集的数据写入一个名为username.json的文件❷。然后我们打印一条消息,告知用户我们已经存储了他们的信息❸:
What is your name? **Eric**
We'll remember you when you come back, Eric!
现在让我们编写一个新程序,向已存储姓名的用户打招呼:
greet_user.py
from pathlib import Path
import json
❶ path = Path('username.json')
contents = path.read_text()
❷ username = json.loads(contents)
print(f"Welcome back, {username}!")
我们读取数据文件的内容❶,然后使用json.loads()将恢复的数据赋值给变量username❷。由于我们已经恢复了用户名,我们可以用个性化的问候语欢迎用户回来:
Welcome back, Eric!
我们需要将这两个程序合并为一个文件。当有人运行remember_me.py时,我们希望尽可能从内存中检索他们的用户名;如果没有找到,我们将提示输入用户名并将其存储在username.json中,以便下次使用。我们可以在这里写一个try-except块来处理username.json不存在的情况,但我们将使用pathlib模块中的一个方便方法:
remember_me.py
from pathlib import Path
import json
path = Path('username.json')
❶ if path.exists():
contents = path.read_text()
username = json.loads(contents)
print(f"Welcome back, {username}!")
❷ else:
username = input("What is your name? ")
contents = json.dumps(username)
path.write_text(contents)
print(f"We'll remember you when you come back, {username}!")
你可以使用Path对象的许多有用方法。exists()方法会在文件或文件夹存在时返回True,如果不存在则返回False。这里我们使用path.exists()来检查用户名是否已经存储❶。如果username.json存在,我们加载用户名并打印个性化的问候语。
如果文件username.json不存在❷,我们会提示用户输入用户名并存储用户输入的值。我们还会打印一个熟悉的消息,告诉用户我们会记住他们,当他们回来时。
无论执行哪个代码块,结果都会是一个用户名和相应的问候语。如果这是程序第一次运行,输出将是:
What is your name? **Eric**
We'll remember you when you come back, Eric!
否则:
Welcome back, Eric!
这是你在程序至少运行过一次后看到的输出。即使这一部分的数据只是一个字符串,程序也同样适用于任何可以转换为 JSON 格式字符串的数据。
重构
通常,你会遇到一种情况,代码可以正常运行,但你会意识到可以通过将代码拆分成一系列具有特定功能的函数来改进代码。这个过程叫做重构。重构使你的代码更简洁、更易于理解,也更容易扩展。
我们可以通过将大部分逻辑移动到一个或多个函数中来重构remember_me.py。remember_me.py的重点是问候用户,所以让我们把现有的所有代码移到一个叫做greet_user()的函数中:
remember_me.py
from pathlib import Path
import json
def greet_user():
❶ """Greet the user by name."""
path = Path('username.json')
if path.exists():
contents = path.read_text()
username = json.loads(contents)
print(f"Welcome back, {username}!")
else:
username = input("What is your name? ")
contents = json.dumps(username)
path.write_text(contents)
print(f"We'll remember you when you come back, {username}!")
greet_user()
因为我们现在使用了函数,所以我们将注释重写为反映程序当前工作方式的文档字符串❶。这个文件变得稍微更简洁了一些,但函数greet_user()做的不仅仅是问候用户——它还会检索已存储的用户名(如果存在),如果没有,则会提示输入新用户名。
让我们重构greet_user(),使它不再做这么多不同的任务。我们将首先把检索已存储用户名的代码移到一个单独的函数中:
from pathlib import Path
import json
def get_stored_username(path):
❶ """Get stored username if available."""
if path.exists():
contents = path.read_text()
username = json.loads(contents)
return username
else:
❷ return None
def greet_user():
"""Greet the user by name."""
path = Path('username.json')
username = get_stored_username(path)
❸ if username:
print(f"Welcome back, {username}!")
else:
username = input("What is your name? ")
contents = json.dumps(username)
path.write_text(contents)
print(f"We'll remember you when you come back, {username}!")
greet_user()
新函数get_stored_username()❶有一个明确的目的,正如文档字符串所述。这个函数检索存储的用户名,并返回用户名(如果找到)。如果传递给get_stored_username()的路径不存在,函数返回None❷。这是一个好习惯:函数应该返回你期望的值,或者返回None。这使我们可以用函数的返回值进行简单的测试。如果成功检索到用户名,我们会向用户打印欢迎回来的消息❸;如果失败,我们会提示输入新用户名。
我们应该再从greet_user()中提取出一个代码块。如果用户名不存在,我们应该把提示输入新用户名的代码移到一个专门的函数中:
from pathlib import Path
import json
def get_stored_username(path):
"""Get stored username if available."""
*--snip--*
def get_new_username(path):
"""Prompt for a new username."""
username = input("What is your name? ")
contents = json.dumps(username)
path.write_text(contents)
return username
def greet_user():
"""Greet the user by name."""
path = Path('username.json')
❶ username = get_stored_username(path)
if username:
print(f"Welcome back, {username}!")
else:
❷ username = get_new_username(path)
print(f"We'll remember you when you come back, {username}!")
greet_user()
在最终版本的remember_me.py中,每个函数都有一个单一且明确的目的。我们调用greet_user(),该函数会打印出合适的消息:它要么欢迎回来的老用户,要么向新用户问好。它通过调用get_stored_username()❶来完成这一任务,get_stored_username()只负责获取已存储的用户名(如果存在)。最后,如果有必要,greet_user()会调用get_new_username()❷,它仅负责获取新的用户名并将其存储。这样的工作分工是编写清晰代码的重要组成部分,能够使代码易于维护和扩展。
总结
在这一章中,你学会了如何处理文件。你学会了读取文件的全部内容,并且如果需要的话,可以逐行处理文件内容。你学会了向文件中写入任意文本。你还了解了异常处理以及如何处理程序中可能遇到的异常。最后,你学会了如何存储 Python 数据结构,以便保存用户提供的信息,避免用户每次运行程序时都要重新开始。
在第十一章,你将学习高效的代码测试方法。这将帮助你确保所编写的代码是正确的,并且帮助你识别在继续构建程序时可能引入的错误。
第十一章:测试你的代码

当你编写函数或类时,你还可以为这些代码编写测试。测试可以证明你的代码在响应它设计要接收的所有输入时能够按预期工作。当你编写测试时,你可以确信,随着越来越多的人开始使用你的程序,你的代码会正确运行。你还可以在添加新代码时进行测试,确保你的修改不会破坏程序原有的行为。每个程序员都会犯错,因此每个程序员都必须经常测试他们的代码,以便在用户遇到问题之前发现并解决问题。
在本章中,你将学习如何使用pytest来测试你的代码。pytest库是一套工具集,可以帮助你快速而简单地编写你的第一个测试,并在你的项目复杂性增加时,支持你的测试。Python 默认不包括pytest,所以你将学习如何安装外部库。知道如何安装外部库将使你可以使用各种设计良好的代码。这些库将极大扩展你可以从事的项目类型。
你将学习构建一系列测试,并检查每一组输入是否产生你想要的输出。你将看到通过的测试是什么样的,失败的测试是什么样的,你还将学习如何利用失败的测试来改进代码。你将学会测试函数和类,并开始理解为一个项目编写多少测试。
使用 pip 安装 pytest
尽管 Python 在标准库中包含了很多功能,但 Python 开发者也非常依赖第三方包。第三方包是指在核心 Python 语言之外开发的库。一些流行的第三方库最终会被纳入标准库,并且从那时起将包含在大多数 Python 安装中。这种情况通常发生在那些一旦修复了初始错误就不太可能发生重大变化的库上。这类库可以与整体语言的发展速度保持一致。
然而,许多软件包被排除在标准库之外,以便它们可以在独立于语言本身的时间表上进行开发。这些软件包的更新频率通常比如果它们与 Python 的开发进度挂钩时要高。这对于pytest以及我们将在本书后半部分使用的大多数库来说都是如此。你不应该盲目相信每一个第三方包,但也不应该因为很多重要功能是通过这些包来实现的而感到排斥。
更新 pip
Python 包含了一个叫做 pip 的工具,用于安装第三方包。由于 pip 帮助从外部资源安装包,它会经常更新,以解决潜在的安全问题。因此,我们首先会更新 pip。
打开一个新的终端窗口,并执行以下命令:
$ **python -m pip install --upgrade pip**
❶ Requirement already satisfied: pip in /.../python3.11/site-packages (22.0.4)
`--snip--`
❷ Successfully installed pip-22.1.2
这个命令的第一部分 python -m pip 告诉 Python 运行 pip 模块。第二部分 install --upgrade 告诉 pip 更新已经安装的包。最后一部分 pip 指定了要更新的第三方包。输出显示我的当前 pip 版本是 22.0.4 ❶,被替换为写作时的最新版本 22.1.2 ❷。
你可以使用这个命令来更新系统上安装的任何第三方包:
$ **python -m pip install --upgrade** `package_name`
安装 pytest
既然 pip 已经是最新版本,我们可以安装 pytest:
$ **python -m pip install --user pytest**
Collecting pytest
`--snip--`
Successfully installed attrs-21.4.0 iniconfig-1.1.1 ...pytest-7.`x`.`x`
我们仍然使用核心命令 pip install,这次没有 --upgrade 标志。相反,我们使用了 --user 标志,它告诉 Python 只为当前用户安装这个包。输出显示 pytest 的最新版本已成功安装,并且安装了 pytest 依赖的其他一些包。
你可以使用这个命令来安装许多第三方包:
$ **python -m pip install --user** `package_name`
测试一个函数
要了解测试,我们需要一些代码来进行测试。这里有一个简单的函数,它接受名字和姓氏,并返回格式化的全名:
name_function.py
def get_formatted_name(first, last):
"""Generate a neatly formatted full name."""
full_name = f"{first} {last}"
return full_name.title()
get_formatted_name() 函数将名字和姓氏结合,中间用空格隔开,完成全名的拼接,然后将全名首字母大写并返回。为了检查 get_formatted_name() 是否正常工作,让我们编写一个使用此函数的程序。程序 names.py 让用户输入名字和姓氏,并显示格式化的全名:
names.py
from name_function import get_formatted_name
print("Enter 'q' at any time to quit.")
while True:
first = input("\nPlease give me a first name: ")
if first == 'q':
break
last = input("Please give me a last name: ")
if last == 'q':
break
formatted_name = get_formatted_name(first, last)
print(f"\tNeatly formatted name: {formatted_name}.")
这个程序从 name_function.py 导入 get_formatted_name()。用户可以输入一系列名字和姓氏,并查看生成的格式化全名:
Enter 'q' at any time to quit.
Please give me a first name: **janis**
Please give me a last name: **joplin**
Neatly formatted name: Janis Joplin.
Please give me a first name: **bob**
Please give me a last name: **dylan**
Neatly formatted name: Bob Dylan.
Please give me a first name: **q**
我们可以看到这里生成的名字是正确的。但是假设我们想修改 get_formatted_name() 以便它也能处理中间名。在我们做这项修改时,我们希望确保不会破坏函数对只有名字和姓氏的处理方式。我们可以通过运行 names.py 并每次修改 get_formatted_name() 时输入一个名字,如 Janis Joplin,来测试我们的代码,但这样会变得很繁琐。幸运的是,pytest 提供了一种有效的方法来自动化测试函数的输出。如果我们自动化测试 get_formatted_name(),我们就可以始终确保在输入我们已编写测试的名字时,函数能够正常工作。
单元测试和测试用例
软件测试有许多种方法。最简单的测试之一就是单元测试。单元测试 验证函数行为的某一特定方面是否正确。测试用例 是一组单元测试,它们共同证明函数在你期望的所有情境下的行为是正确的。
一个好的测试用例会考虑函数可能接收到的所有输入类型,并包括能够代表这些情况的测试。一个完整覆盖的测试用例包括对所有可能使用方式的单元测试。对于大型项目而言,达到完全覆盖可能是一个巨大的挑战。通常,只需编写针对代码关键行为的测试,只有当项目开始广泛使用时,才需要追求完全覆盖。
通过测试
使用pytest,编写你的第一个单元测试是非常直接的。我们将编写一个简单的测试函数。测试函数将调用我们要测试的函数,然后我们会对返回的值进行断言。如果断言正确,测试将通过;如果断言错误,测试将失败。
这是get_formatted_name()函数的第一个测试:
test_name_function.py
from name_function import get_formatted_name
❶ def test_first_last_name():
"""Do names like 'Janis Joplin' work?"""
❷ formatted_name = get_formatted_name('janis', 'joplin')
❸ assert formatted_name == 'Janis Joplin'
在我们运行测试之前,让我们仔细看看这个函数。测试文件的名称很重要;它必须以test_开头。当我们请求pytest运行我们编写的测试时,它会查找任何以test_开头的文件,并运行该文件中的所有测试。
在测试文件中,我们首先导入我们要测试的函数:get_formatted_name()。然后我们定义一个测试函数:在这种情况下是test_first_last_name() ❶。这个函数名比我们之前使用的要长,原因是好的。首先,测试函数需要以test开头,后跟一个下划线。任何以test_开头的函数都将被pytest发现,并在测试过程中运行。
此外,测试名称应该比常规函数名更长且更具描述性。你不会自己调用这个函数;pytest会找到并为你运行它。测试函数名应该足够长,以便当你在测试报告中看到函数名时,能够清楚地了解测试的行为。
接下来,我们调用我们正在测试的函数 ❷。在这里,我们用'janis'和'joplin'作为参数调用get_formatted_name(),就像我们运行names.py时使用的那样。我们将此函数的返回值赋给formatted_name。
最后,我们进行一个断言 ❸。断言是对某个条件的声明。在这里,我们声明formatted_name的值应该是'Janis Joplin'。
运行测试
如果你直接运行文件test_name_function.py,不会得到任何输出,因为我们从未调用测试函数。相反,我们将让pytest为我们运行测试文件。
为此,打开一个终端窗口并导航到包含测试文件的文件夹。如果你使用的是 VS Code,你可以打开包含测试文件的文件夹,并使用编辑器窗口内嵌的终端。在终端窗口中,输入命令pytest。你应该会看到如下内容:
$ **pytest**
========================= test session starts =========================
❶ platform darwin -- Python 3.`x`.`x`, pytest-7.`x`.`x`, pluggy-1.`x`.`x`
❷ rootdir: /.../python_work/chapter_11
❸ collected 1 item
❹ test_name_function.py . [100%]
========================== 1 passed in 0.00s ==========================
让我们试着理解这个输出。首先,我们看到一些关于测试运行系统的信息❶。我是在 macOS 系统上进行测试的,所以你可能会看到不同的输出。最重要的是,我们可以看到用于运行测试的 Python、pytest和其他包的版本。
接下来,我们看到测试运行的目录❷:在我的情况下是python_work/chapter_11。我们可以看到pytest找到了一个测试来运行❸,并且可以看到正在运行的测试文件❹。文件名后面的单个点告诉我们有一个测试通过了,而100%则清楚地表示所有测试都已运行。一个大项目可能有数百个或数千个测试,点和完成百分比指示器可以帮助监控测试运行的整体进度。
最后一行告诉我们有一个测试通过了,并且运行测试的时间不到 0.01 秒。
这个输出表示,get_formatted_name()函数对于包含名字和姓氏的姓名始终有效,除非我们修改该函数。当我们修改get_formatted_name()时,可以再次运行这个测试。如果测试通过,我们就知道该函数仍然适用于像 Janis Joplin 这样的名字。
一个失败的测试
失败的测试是什么样子的?让我们修改get_formatted_name(),使其能够处理中间名,但我们将以一种破坏只包含名字和姓氏的人的功能的方式进行修改,就像 Janis Joplin 那样。
这是一个新的get_formatted_name()版本,它需要一个中间名参数:
name_function.py
def get_formatted_name(first, middle, last):
"""Generate a neatly formatted full name."""
full_name = f"{first} {middle} {last}"
return full_name.title()
这个版本应该适用于有中间名的人,但是当我们测试时,发现它已经破坏了只包含名字和姓氏的人的功能。
这次,运行pytest会给出以下输出:
$ **pytest**
========================= test session starts =========================
`--snip--`
❶ test_name_function.py F [100%]
❷ ============================== FAILURES ===============================
❸ ________________________ test_first_last_name _________________________
def test_first_last_name():
"""Do names like 'Janis Joplin' work?"""
❹ > formatted_name = get_formatted_name('janis', 'joplin')
❺ E TypeError: get_formatted_name() missing 1 required positional
argument: 'last'
test_name_function.py:5: TypeError
======================= short test summary info =======================
FAILED test_name_function.py::test_first_last_name - TypeError:
get_formatted_name() missing 1 required positional argument: 'last'
========================== 1 failed in 0.04s ==========================
这里有很多信息,因为当测试失败时,你可能需要知道很多东西。输出中的第一个重点是一个单独的F❶,它告诉我们有一个测试失败。接着,我们看到一个专注于FAILURES❷的部分,因为失败的测试通常是测试运行中最重要的关注点。接下来,我们看到test_first_last_name()是失败的测试函数❸。一个尖括号❹指示了导致测试失败的代码行。下一行的E❺显示了导致失败的实际错误:由于缺少必需的位置参数last,出现了TypeError。最重要的信息在最后的简短总结中得到了重复,因此当你运行多个测试时,可以快速了解哪些测试失败以及原因。
响应失败的测试
当测试失败时,你该怎么办?假设你正在检查正确的条件,测试通过意味着函数行为正确,而测试失败意味着你写的新代码中有错误。所以,当测试失败时,不要改变测试。如果你这么做,测试可能会通过,但任何像测试一样调用你的函数的代码会突然停止工作。相反,应该修复导致测试失败的代码。检查你刚刚对函数所做的修改,弄清楚这些修改是如何破坏预期行为的。
在这种情况下,get_formatted_name() 以前只需要两个参数:一个名和一个姓。现在它需要一个名、一个中间名和一个姓。增加了这个必需的中间名参数后,打破了 get_formatted_name() 原来的行为。这里的最佳选择是让中间名变成可选项。一旦我们这么做,像 Janis Joplin 这样的姓名测试应该能重新通过,我们也应该能够接受中间名。让我们修改 get_formatted_name(),使中间名成为可选项,然后再次运行测试用例。如果通过了,我们就可以继续确保该函数能够正确处理中间名。
为了让中间名成为可选项,我们将参数 middle 移到函数定义的参数列表末尾,并赋予它一个空的默认值。我们还添加了一个 if 语句,根据是否提供中间名来正确构建全名:
name_function.py
def get_formatted_name(first, last, middle=''):
"""Generate a neatly formatted full name."""
if middle:
full_name = f"{first} {middle} {last}"
else:
full_name = f"{first} {last}"
return full_name.title()
在这个新版本的 get_formatted_name() 中,中间名是可选的。如果传入了中间名,返回的全名将包含名、中间名和姓。否则,全名将仅包含名和姓。现在该函数应该能同时适用于两种类型的姓名。为了验证函数是否仍然适用于像 Janis Joplin 这样的姓名,让我们再次运行测试:
$ **pytest**
========================= test session starts =========================
`--snip--`
test_name_function.py . [100%]
========================== 1 passed in 0.00s ==========================
测试现在通过了。这是理想的情况;意味着该函数又能处理像 Janis Joplin 这样的姓名,而不需要我们手动测试函数。修复我们的函数更容易,因为失败的测试帮助我们确定了新代码如何破坏了现有的行为。
添加新测试
现在我们知道 get_formatted_name() 又能处理简单姓名了,让我们为包含中间名的人编写第二个测试。我们通过在文件 test_name_function.py 中添加另一个测试函数来做到这一点:
test_name_function.py
from name_function import get_formatted_name
def test_first_last_name():
*--snip--*
def test_first_last_middle_name():
"""Do names like 'Wolfgang Amadeus Mozart' work?"""
❶ formatted_name = get_formatted_name(
'wolfgang', 'mozart', 'amadeus')
❷ assert formatted_name == 'Wolfgang Amadeus Mozart'
我们将这个新函数命名为 test_first_last_middle_name()。函数名必须以 test_ 开头,这样我们运行 pytest 时它就会自动运行。我们为函数命名是为了明确指出我们正在测试 get_formatted_name() 的哪个行为。因此,如果测试失败,我们会立刻知道是哪些类型的姓名受到了影响。
为了测试该函数,我们调用 get_formatted_name(),并传入一个名、一个姓和一个中间名 ❶,然后我们进行断言 ❷,验证返回的全名是否与我们预期的全名(名、中间名和姓)匹配。当我们再次运行 pytest 时,两个测试都通过了:
$ pytest
========================= test session starts =========================
`--snip--`
collected 2 items
❶ test_name_function.py .. [100%]
========================== 2 passed in 0.01s ==========================
两个点❶表示两个测试通过,这也从输出的最后一行可以看出。这很棒!我们现在知道这个函数对像 Janis Joplin 这样的名字依然有效,并且可以放心它同样适用于像 Wolfgang Amadeus Mozart 这样的名字。
测试一个类
本章的第一部分中,你为单个函数编写了测试。现在你将为一个类编写测试。你将在自己的许多程序中使用类,因此能够证明你的类正常工作是非常有帮助的。如果你为正在处理的类编写了通过的测试,你可以放心地做出改进而不会意外地破坏当前的行为。
各种断言
到目前为止,你只见过一种类型的断言:声明一个字符串具有特定的值。在编写测试时,你可以做出任何可以表示为条件语句的声明。如果条件按预期为 True,则你关于程序某部分行为的假设将得到确认;你可以确信没有错误。如果你假设的条件是 True,实际上为 False,则测试将失败,你会知道存在需要解决的问题。表 11-1 显示了你可以在初始测试中包含的一些最有用的断言类型。
表 11-1:测试中常用的断言语句
| 断言 | 声明 |
|---|---|
assert a == b |
断言两个值相等。 |
assert a != b |
断言两个值不相等。 |
assert a |
断言 a 的值为 True。 |
assert not a |
断言 a 的值为 False。 |
assert element in list |
断言一个元素在列表中。 |
assert element not in list |
断言一个元素不在列表中。 |
这些只是一些例子;任何可以表示为条件语句的内容都可以包含在测试中。
一个用于测试的类
测试一个类与测试一个函数类似,因为大部分工作都涉及测试类中方法的行为。然而,仍然存在一些差异,因此让我们编写一个类来进行测试。考虑一个帮助进行匿名调查的类:
survey.py
class AnonymousSurvey:
"""Collect anonymous answers to a survey question."""
❶ def __init__(self, question):
"""Store a question, and prepare to store responses."""
self.question = question
self.responses = []
❷ def show_question(self):
"""Show the survey question."""
print(self.question)
❸ def store_response(self, new_response):
"""Store a single response to the survey."""
self.responses.append(new_response)
❹ def show_results(self):
"""Show all the responses that have been given."""
print("Survey results:")
for response in self.responses:
print(f"- {response}")
这个类从你提供的调查问题❶开始,并包括一个空列表用于存储回应。这个类有方法来打印调查问题❷、将新的回应添加到回应列表❸,并打印存储在列表中的所有回应❹。要创建该类的实例,你只需要提供一个问题。一旦你有了表示特定调查的实例,你可以使用 show_question() 显示调查问题,使用 store_response() 存储回应,并使用 show_results() 显示结果。
为了展示 AnonymousSurvey 类的工作原理,让我们编写一个使用该类的程序:
language_survey.py
from survey import AnonymousSurvey
# Define a question, and make a survey.
question = "What language did you first learn to speak?"
language_survey = AnonymousSurvey(question)
# Show the question, and store responses to the question.
language_survey.show_question()
print("Enter 'q' at any time to quit.\n")
while True:
response = input("Language: ")
if response == 'q':
break
language_survey.store_response(response)
# Show the survey results.
print("\nThank you to everyone who participated in the survey!")
language_survey.show_results()
这个程序定义了一个问题("你第一次学会说的是什么语言?"),并创建了一个包含该问题的AnonymousSurvey对象。程序调用show_question()显示问题,然后提示输入回答。每个回答都会在收到时保存。当所有回答输入完成后(用户输入q退出),show_results()将打印调查结果:
What language did you first learn to speak?
Enter 'q' at any time to quit.
Language: **English**
Language: **Spanish**
Language: **English**
Language: **Mandarin**
Language: **q**
Thank you to everyone who participated in the survey!
Survey results:
- English
- Spanish
- English
- Mandarin
这个类适用于一个简单的匿名调查,但假设我们想要改进AnonymousSurvey及其所在的模块survey。我们可以允许每个用户输入多个回答,编写一个方法仅列出唯一回答并报告每个回答出现的次数,甚至可以编写另一个类来管理非匿名调查。
实现这样的更改可能会影响当前AnonymousSurvey类的行为。例如,在尝试允许每个用户输入多个回答时,我们可能会不小心改变单个回答的处理方式。为了确保我们在开发该模块时不会破坏现有行为,我们可以为该类编写测试。
测试AnonymousSurvey类
让我们编写一个测试,验证AnonymousSurvey行为的一个方面。我们将编写一个测试来验证单个回答是否能正确存储在调查中:
test_survey.py
from survey import AnonymousSurvey
❶ def test_store_single_response():
"""Test that a single response is stored properly."""
question = "What language did you first learn to speak?"
❷ language_survey = AnonymousSurvey(question)
language_survey.store_response('English')
❸ assert 'English' in language_survey.responses
我们首先导入要测试的类AnonymousSurvey。第一个测试函数将验证当我们存储一个调查问题的回答时,该回答是否会出现在调查的回答列表中。这个函数的一个好描述性名称是test_store_single_response() ❶。如果这个测试失败,我们可以从测试总结中的函数名称了解到存储单个回答时出现了问题。
要测试一个类的行为,我们需要创建该类的实例。我们创建一个名为language_survey的实例 ❷,并赋予问题"你第一次学会说的是什么语言?"。我们使用store_response()方法存储一个回答English。然后,我们通过断言English是否在language_survey.responses列表中来验证该回答是否已正确存储 ❸。
默认情况下,运行命令pytest且不带任何参数时,会运行pytest在当前目录中发现的所有测试。要集中测试某个文件中的测试,可以传入要运行的测试文件名。这里我们将仅运行我们为AnonymousSurvey编写的一个测试:
$ **pytest test_survey.py**
========================= test session starts =========================
`--snip--`
test_survey.py . [100%]
========================== 1 passed in 0.01s ==========================
这是一个很好的开始,但一个调查只有生成多个回答时才有意义。让我们验证是否可以正确存储三个回答。为此,我们向TestAnonymousSurvey添加另一个方法:
from survey import AnonymousSurvey
def test_store_single_response():
*--snip--*
def test_store_three_responses():
"""Test that three individual responses are stored properly."""
question = "What language did you first learn to speak?"
language_survey = AnonymousSurvey(question)
❶ responses = ['English', 'Spanish', 'Mandarin']
for response in responses:
language_survey.store_response(response)
❷ for response in responses:
assert response in language_survey.responses
我们将新函数命名为test_store_three_responses()。我们像在test_store_single_response()中一样创建一个调查对象。我们定义了一个包含三个不同回答的列表❶,然后对每个回答调用store_response()。一旦回答被存储,我们再写一个循环,并断言每个回答现在都在language_survey.responses中❷。
当我们再次运行测试文件时,两个测试(一个是单个回答,另一个是三个回答)都通过了:
$ **pytest test_survey.py**
========================= test session starts =========================
`--snip--`
test_survey.py .. [100%]
========================== 2 passed in 0.01s ==========================
这完美地工作。然而,这些测试有些重复,所以我们将使用pytest的另一个特性来使它们更高效。
使用 Fixtures
在test_survey.py中,我们在每个测试函数中都创建了一个新的AnonymousSurvey实例。这在我们处理的短小示例中没问题,但在一个有数十或数百个测试的实际项目中,这将成为一个问题。
在测试中,fixture帮助设置测试环境。通常,这意味着创建一个由多个测试使用的资源。我们通过写一个带有@pytest.fixture装饰器的函数来在pytest中创建一个 fixture。装饰器是一个指令,位于函数定义之前;Python 在运行函数之前应用这个指令,以改变函数代码的行为。如果这听起来很复杂,不用担心;你可以先使用第三方包的装饰器,而不必先学会自己写装饰器。
让我们使用一个 fixture 来创建一个可以在test_survey.py中的两个测试函数中使用的单一调查实例:
import pytest
from survey import AnonymousSurvey
❶ @pytest.fixture
❷ def language_survey():
"""A survey that will be available to all test functions."""
question = "What language did you first learn to speak?"
language_survey = AnonymousSurvey(question)
return language_survey
❸ def test_store_single_response(language_survey):
"""Test that a single response is stored properly."""
❹ language_survey.store_response('English')
assert 'English' in language_survey.responses
❺ def test_store_three_responses(language_survey):
"""Test that three individual responses are stored properly."""
responses = ['English', 'Spanish', 'Mandarin']
for response in responses:
❻ language_survey.store_response(response)
for response in responses:
assert response in language_survey.responses
现在我们需要导入pytest,因为我们使用了一个在pytest中定义的装饰器。我们将@pytest.fixture装饰器❶应用到新的函数language_survey()❷上。这个函数构建了一个AnonymousSurvey对象,并返回新的调查。
请注意,两个测试函数的定义发生了变化❸❺;每个测试函数现在都有一个名为language_survey的参数。当测试函数中的参数与带有@pytest.fixture装饰器的函数名称匹配时,fixture 会自动运行,并将返回值传递给测试函数。在这个示例中,language_survey()函数为test_store_single_response()和test_store_three_responses()提供了一个language_survey实例。
两个测试函数中没有新增代码,但请注意,每个函数中有两行代码被移除❹❻:定义问题的那一行和创建AnonymousSurvey对象的那一行。
当我们再次运行测试文件时,两个测试仍然通过。这些测试在尝试扩展AnonymousSurvey以处理每个人的多个回答时尤其有用。在修改代码以接受多个回答后,您可以运行这些测试,确保没有影响存储单个回答或一系列个体回答的功能。
上述结构看起来几乎肯定会很复杂;它包含了一些你到目前为止见过的最抽象的代码。你不需要立刻使用 fixtures;与其完全不写测试,不如写一些有很多重复代码的测试。只要记住,当你写了足够多的测试,重复的代码开始成为障碍时,有一种成熟的方法可以处理这些重复。而且像这种简单的例子中的 fixture 并不会使代码更简洁或更容易理解。但在有许多测试的项目中,或者在需要多行代码来构建多个测试所用资源的情况下,fixtures 能显著提升你的测试代码质量。
当你想编写一个 fixture 时,写一个生成多个测试函数所用资源的函数。在新函数上添加@pytest.fixture装饰器,并将该函数的名称作为参数添加到每个使用该资源的测试函数中。从那时起,你的测试会更简洁,更容易编写和维护。
总结
在本章中,你学会了使用 pytest 模块中的工具为函数和类编写测试。你学会了编写测试函数来验证你的函数和类应该表现出的特定行为。你还了解了如何使用 fixtures 高效地创建可以在测试文件中多个测试函数中使用的资源。
测试是一个重要的主题,但许多新手程序员并未接触过。你不需要为所有简单的项目编写测试,尤其是作为新手程序员时。但一旦你开始从事涉及重大开发工作的项目,你应该测试函数和类的关键行为。这样你会更有信心,知道对项目进行的新工作不会破坏现有功能,并且这将让你有自由改进代码。如果你不小心破坏了现有功能,你会立刻发现,这样你仍然能轻松修复问题。响应一个失败的测试远比响应一个来自不满用户的 bug 报告要容易得多。
如果你在项目中加入一些初始测试,其他程序员会更尊重你的项目。他们会更愿意实验你的代码,并且更愿意与你合作。如果你想为其他程序员正在进行的项目做贡献,你需要展示你的代码通过了现有的测试,并且通常需要为你引入的任何新行为编写测试。
玩转测试,熟悉测试代码的编写过程。为你的函数和类的关键行为编写测试,但在早期项目中,除非有特定的原因,不要追求完全覆盖。
第二部分:项目
恭喜!您现在已经掌握了足够的 Python 知识,可以开始构建互动且有意义的项目。创建自己的项目将帮助您学习新技能,并巩固您在第一部分中学到的概念。
第二部分包含三种项目,您可以选择按照任何顺序进行这些项目中的任何一个或全部。以下是每个项目的简要描述,帮助您决定先深入哪一个。
外星入侵:用 Python 制作游戏
在外星入侵项目(第十二章,第十三章,和第十四章)中,您将使用 Pygame 包开发一个 2D 游戏。游戏的目标是射击屏幕上方掉落的外星舰队,随着关卡的增加,速度和难度也会增加。在项目结束时,您将掌握技能,能够使用 Pygame 开发自己的 2D 游戏。
数据可视化
数据可视化项目从第十五章开始,在这一章中,您将学习如何生成数据,并使用 Matplotlib 和 Plotly 创建一系列功能性且美观的数据可视化图表。第十六章教您如何从在线源获取数据,并将其输入到可视化工具包中,生成天气数据的图表和全球地震活动的地图。最后,第十七章将展示如何编写程序自动下载并可视化数据。学习制作可视化图表将使您探索数据科学领域,而数据科学是当前编程领域需求最高的领域之一。
Web 应用
在 Web 应用项目中(第十八章,第十九章,和第二十章),您将使用 Django 包创建一个简单的 Web 应用,允许用户记录他们正在学习的不同主题。用户将创建一个带有用户名和密码的账户,输入一个主题,然后记录他们的学习内容。您还将把您的应用部署到远程服务器,这样世界上任何地方的人都可以访问它。
完成此项目后,您将能够开始构建自己的简单 Web 应用,并且您已准备好深入研究关于使用 Django 构建应用的更多资源。
第十二章:一艘发射子弹的飞船

让我们来制作一个名为外星人入侵的游戏!我们将使用 Pygame,一个强大而有趣的 Python 模块集合,能够处理图形、动画,甚至音效,让你更容易构建复杂的游戏。借助 Pygame 来处理诸如绘制图像到屏幕等任务,你可以专注于游戏动态的更高级逻辑。
在本章中,你将设置 Pygame,并创建一艘能够响应玩家输入左右移动并发射子弹的火箭飞船。在接下来的两章中,你将创建一支外星舰队进行摧毁,然后继续通过设置飞船数量限制和添加记分板来完善游戏。
在构建这个游戏的过程中,你还将学习如何管理跨多个文件的大型项目。我们将重构大量代码并管理文件内容,以组织项目并使代码高效。
制作游戏是学习语言的理想方式,它既有趣又富有挑战性。玩自己写的游戏是一种极大的满足感,编写一个简单的游戏将让你学到很多关于专业人士如何开发游戏的知识。在本章过程中,输入并运行代码,找出每个代码块如何为整体游戏玩法做出贡献。尝试不同的值和设置,更好地理解如何优化游戏中的交互。
项目规划
当你在构建一个大型项目时,重要的是在开始编写代码之前先准备一个计划。你的计划将帮助你保持专注,并使你更有可能完成项目。
让我们写一个关于游戏玩法的一般描述。尽管以下描述没有涵盖外星人入侵的每个细节,但它提供了一个清晰的思路,帮助你开始构建游戏:
在外星人入侵中,玩家控制一艘出现在屏幕底部中央的火箭飞船。玩家可以使用箭头键控制飞船左右移动,使用空格键发射子弹。当游戏开始时,一支外星舰队充满了天空,并向屏幕的两侧和下方移动。玩家射击并摧毁外星人。如果玩家摧毁了所有外星人,新的舰队会出现,且它的移动速度比前一支更快。如果任何外星人撞到玩家的飞船或到达屏幕底部,玩家会失去一艘飞船。如果玩家失去了三艘飞船,游戏结束。
在开发的第一阶段,我们将制作一艘能够在玩家按下箭头键时左右移动,并在玩家按下空格键时发射子弹的飞船。在设置好这一行为后,我们可以创建外星人并优化游戏玩法。
安装 Pygame
在开始编写代码之前,先安装 Pygame。我们将以与第十一章安装 pytest 相同的方式进行安装:使用 pip。如果你跳过了第十一章或需要复习 pip,请参阅第 210 页的“使用 pip 安装 pytest”。
要安装 Pygame,在终端提示符下输入以下命令:
$ **python -m pip install --user pygame**
如果你使用除python之外的命令来运行程序或启动终端会话,例如python3,请确保使用该命令来代替。
启动游戏项目
我们将通过创建一个空的 Pygame 窗口来开始构建游戏。稍后,我们将在该窗口上绘制游戏元素,例如飞船和外星人。我们还将使游戏响应用户输入,设置背景颜色,并加载飞船图像。
创建 Pygame 窗口并响应用户输入
我们将通过创建一个类来表示游戏,从而制作一个空的 Pygame 窗口。在文本编辑器中,创建一个新文件并将其保存为alien_invasion.py;然后输入以下内容:
alien_invasion.py
import sys
import pygame
class AlienInvasion:
"""Overall class to manage game assets and behavior."""
def __init__(self):
"""Initialize the game, and create game resources."""
❶ pygame.init()
❷ self.screen = pygame.display.set_mode((1200, 800))
pygame.display.set_caption("Alien Invasion")
def run_game(self):
"""Start the main loop for the game."""
❸ while True:
# Watch for keyboard and mouse events.
❹ for event in pygame.event.get():
❺ if event.type == pygame.QUIT:
sys.exit()
# Make the most recently drawn screen visible.
❻ pygame.display.flip()
if __name__ == '__main__':
# Make a game instance, and run the game.
ai = AlienInvasion()
ai.run_game()
首先,我们导入sys和pygame模块。pygame模块包含了制作游戏所需的功能。我们将使用sys模块中的工具来在玩家退出时退出游戏。
Alien Invasion从一个名为AlienInvasion的类开始。在__init__()方法中,pygame.init()函数初始化 Pygame 正常工作所需的背景设置❶。然后,我们调用pygame.display.set_mode()来创建一个显示窗口❷,在该窗口上我们将绘制所有游戏的图形元素。参数(1200, 800)是一个元组,定义了游戏窗口的尺寸,宽度为 1,200 像素,高度为 800 像素。(你可以根据显示器的大小调整这些值。)我们将这个显示窗口分配给属性self.screen,这样它将在类中的所有方法中可用。
我们分配给self.screen的对象称为表面。在 Pygame 中,表面是屏幕上的一部分,用于显示游戏元素。游戏中的每个元素,例如外星人或飞船,都是一个独立的表面。display.set_mode()返回的表面表示整个游戏窗口。当我们激活游戏的动画循环时,这个表面会在每次通过循环时重新绘制,因此可以更新任何由用户输入触发的变化。
游戏由run_game()方法控制。这个方法包含一个while循环❸,它会持续运行。while循环中包含一个事件循环和管理屏幕更新的代码。事件是用户在游戏过程中执行的操作,例如按下某个键或移动鼠标。为了使我们的程序响应事件,我们编写一个事件循环来监听事件,并根据发生的事件类型执行相应的任务。嵌套在while循环内的for循环就是一个事件循环。
要访问 Pygame 检测到的事件,我们将使用pygame.event.get()函数。该函数返回自上次调用该函数以来发生的事件列表。任何键盘或鼠标事件都会导致该for循环运行。在循环内部,我们将编写一系列if语句来检测并响应特定事件。例如,当玩家点击游戏窗口的关闭按钮时,会检测到pygame.QUIT事件,并调用sys.exit()来退出游戏❺。
调用pygame.display.flip() ❻ 告诉 Pygame 显示最近绘制的屏幕。在这种情况下,它会在每次通过while循环时绘制一个空白屏幕,抹去旧屏幕,只显示新屏幕。当我们移动游戏元素时,pygame.display.flip()会持续更新显示,以显示游戏元素的新位置并隐藏旧位置,创造出流畅运动的错觉。
在文件末尾,我们创建一个游戏实例,然后调用run_game()。我们将run_game()放在一个if语句块中,只有在文件被直接调用时才会运行。当你运行这个alien_invasion.py文件时,应该能看到一个空白的 Pygame 窗口。
控制帧率
理想情况下,游戏应该在所有系统上以相同的速度,或称为帧率运行。控制一个可以在多个系统上运行的游戏的帧率是一个复杂的问题,但 Pygame 提供了一种相对简单的方法来实现这一目标。我们将创建一个时钟,并确保时钟在每次主循环中滴答一次。任何时候如果循环处理速度超过我们定义的帧率,Pygame 将计算正确的暂停时间,以确保游戏以一致的速度运行。
我们将在__init__()方法中定义时钟:
alien_invasion.py
def __init__(self):
"""Initialize the game, and create game resources."""
pygame.init()
self.clock = pygame.time.Clock()
*--snip--*
在初始化pygame后,我们创建一个Clock类的实例,来自pygame.time模块。然后我们将在run_game()的while循环末尾让时钟滴答作响:
def run_game(self):
"""Start the main loop for the game."""
while True:
*--snip--*
pygame.display.flip()
self.clock.tick(60)
tick()方法接受一个参数:游戏的帧率。在这里,我使用的是 60 的值,这样 Pygame 将尽力确保循环每秒运行 60 次。
设置背景颜色
Pygame 默认会创建一个黑色屏幕,但那太无聊了。让我们设置一个不同的背景颜色。我们将在__init__()方法的末尾进行设置。
alien_invasion.py
def __init__(self):
*--snip--*
pygame.display.set_caption("Alien Invasion")
# Set the background color.
❶ self.bg_color = (230, 230, 230)
def run_game(self):
*--snip--*
for event in pygame.event.get():
if event.type == pygame.QUIT:
sys.exit()
# Redraw the screen during each pass through the loop.
❷ self.screen.fill(self.bg_color)
# Make the most recently drawn screen visible.
pygame.display.flip()
self.clock.tick(60)
Pygame 中的颜色是通过 RGB 颜色指定的:由红色、绿色和蓝色混合而成。每个颜色值的范围是从 0 到 255。颜色值(255, 0, 0)是红色,(0, 255, 0)是绿色,(0, 0, 255)是蓝色。你可以混合不同的 RGB 值来创建多达 1600 万种颜色。颜色值(230, 230, 230)混合了相同数量的红色、绿色和蓝色,产生一种浅灰色背景颜色。我们将这种颜色赋值给self.bg_color ❶。
我们使用fill()方法 ❷ 填充背景颜色,该方法作用于一个表面,并且只接受一个参数:颜色。
创建一个设置类
每当我们向游戏中引入新功能时,通常也会创建一些新的设置。我们不再在代码中到处添加设置,而是编写一个名为settings的模块,其中包含一个叫做Settings的类来存储所有这些值。这个方法让我们每次需要访问单个设置时,只需操作一个settings对象。这也使得在项目发展过程中,修改游戏的外观和行为变得更加容易。为了修改游戏,我们将在接下来的settings.py中更改相关值,而不是在整个项目中搜索不同的设置。
在alien_invasion文件夹中创建一个名为settings.py的新文件,并添加这个初始的Settings类:
settings.py
class Settings:
"""A class to store all settings for Alien Invasion."""
def __init__(self):
"""Initialize the game's settings."""
# Screen settings
self.screen_width = 1200
self.screen_height = 800
self.bg_color = (230, 230, 230)
为了在项目中创建一个Settings实例,并使用它来访问我们的设置,我们需要按如下方式修改alien_invasion.py:
alien_invasion.py
*--snip--*
import pygame
from settings import Settings
class AlienInvasion:
"""Overall class to manage game assets and behavior."""
def __init__(self):
"""Initialize the game, and create game resources."""
pygame.init()
self.clock = pygame.time.Clock()
❶ self.settings = Settings()
❷ self.screen = pygame.display.set_mode(
(self.settings.screen_width, self.settings.screen_height))
pygame.display.set_caption("Alien Invasion")
def run_game(self):
*--snip--*
# Redraw the screen during each pass through the loop.
❸ self.screen.fill(self.settings.bg_color)
# Make the most recently drawn screen visible.
pygame.display.flip()
self.clock.tick(60)
*--snip--*
我们将Settings导入到主程序文件中。然后我们创建一个Settings实例,并将其赋值给self.settings ❶,在调用pygame.init()之后。当我们创建屏幕 ❷时,我们使用self.settings中的screen_width和screen_height属性,然后在填充屏幕时也使用self.settings来访问背景颜色 ❸。
当你现在运行alien_invasion.py时,你不会看到任何变化,因为我们所做的只是将已经在其他地方使用的设置移动过来。现在我们已经准备好开始向屏幕添加新元素了。
添加飞船图像
让我们将飞船添加到游戏中。为了在屏幕上绘制玩家的飞船,我们将加载一张图像,然后使用 Pygame 的blit()方法来绘制这张图像。
在选择游戏的艺术作品时,一定要注意授权。最安全且最便宜的起步方式是使用可以自由使用和修改的授权图形,可以从像opengameart.org这样的网站找到。
你几乎可以在游戏中使用任何类型的图像文件,但使用位图(.bmp)文件是最简单的,因为 Pygame 默认加载位图。虽然你可以配置 Pygame 使用其他文件类型,但某些文件类型依赖于必须安装在你计算机上的特定图像库。你会发现大多数图像都是.jpg或.png格式,但你可以使用像 Photoshop、GIMP 和 Paint 这样的工具将它们转换为位图。
特别注意所选图像的背景颜色。尽量找到一个背景透明或背景是实色的文件,你可以使用图像编辑器将其替换为任何背景颜色。如果图像的背景颜色与你游戏的背景颜色相匹配,游戏的效果最好。或者,你也可以将游戏的背景颜色与图像的背景颜色匹配。
对于外星入侵,你可以使用文件ship.bmp(图 12-1),该文件可以在本书的资源中找到:ehmatthes.github.io/pcc_3e。该文件的背景颜色与我们在此项目中使用的设置相匹配。在你的主alien_invasion项目文件夹内创建一个名为images的文件夹,并将ship.bmp文件保存在该文件夹中。

图 12-1:外星入侵的飞船
创建 Ship 类
选择完飞船图像后,我们需要将其显示在屏幕上。为了使用我们的飞船,我们将创建一个新的ship模块,其中包含Ship类。这个类将管理玩家飞船的大部分行为:
ship.py
import pygame
class Ship:
"""A class to manage the ship."""
def __init__(self, ai_game):
"""Initialize the ship and set its starting position."""
❶ self.screen = ai_game.screen
❷ self.screen_rect = ai_game.screen.get_rect()
# Load the ship image and get its rect.
❸ self.image = pygame.image.load('images/ship.bmp')
self.rect = self.image.get_rect()
# Start each new ship at the bottom center of the screen.
❹ self.rect.midbottom = self.screen_rect.midbottom
❺ def blitme(self):
"""Draw the ship at its current location."""
self.screen.blit(self.image, self.rect)
Pygame 非常高效,因为它让你将所有游戏元素都当作矩形(rects)来处理,即使它们的形状并不完全是矩形。将元素当作矩形处理非常高效,因为矩形是简单的几何形状。例如,当 Pygame 需要判断两个游戏元素是否发生碰撞时,如果将每个对象视为矩形,它就能更快地完成这项任务。这种方法通常足够有效,以至于游戏中的玩家不会注意到我们并没有使用每个游戏元素的确切形状。在这个类中,我们将飞船和屏幕都当作矩形来处理。
在定义类之前,我们导入了pygame模块。Ship类的__init__()方法接收两个参数:self引用和当前AlienInvasion类实例的引用。这样,Ship类就可以访问AlienInvasion中定义的所有游戏资源。然后,我们将屏幕赋值给Ship的一个属性❶,这样在该类中的所有方法中都能方便地访问它。我们通过get_rect()方法访问屏幕的rect属性,并将其赋值给self.screen_rect❷。这样做允许我们将飞船放置在屏幕上的正确位置。
为了加载图像,我们调用pygame.image.load()❸,并给它传入飞船图像的路径。此函数返回一个代表飞船的表面,我们将其赋值给self.image。图像加载完成后,我们调用get_rect()来访问飞船表面的rect属性,以便以后用来放置飞船。
当你使用rect对象时,可以使用矩形的顶部、底部、左边和右边的* x 和 y 坐标,以及中心坐标来定位对象。你可以设置这些值中的任何一个来确定rect的位置。当你居中一个游戏元素时,可以使用rect的center、centerx或centery属性。当你在屏幕边缘工作时,可以使用top、bottom、left或right属性。还有一些属性是这些属性的组合,比如midbottom、midtop、midleft和midright。当你调整rect的水平或垂直位置时,可以直接使用x和y属性,这些属性是矩形左上角的 x 和 y *坐标。这些属性可以避免你进行游戏开发者以前需要手动计算的操作,且你会频繁使用它们。
我们将把飞船定位在屏幕的底部中央。为此,需要将self.rect.midbottom的值与屏幕rect的midbottom属性对齐❹。Pygame 使用这些rect属性来定位飞船图像,使其水平居中并与屏幕底部对齐。
最后,我们定义了blitme()方法❺,该方法将图像绘制到由self.rect指定的位置的屏幕上。
将飞船绘制到屏幕上
现在让我们更新alien_invasion.py,使其创建一个飞船并调用飞船的blitme()方法:
alien_invasion.py
*--snip--*
from settings import Settings
from ship import Ship
class AlienInvasion:
"""Overall class to manage game assets and behavior."""
def __init__(self):
*--snip--*
pygame.display.set_caption("Alien Invasion")
❶ self.ship = Ship(self)
def run_game(self):
*--snip--*
# Redraw the screen during each pass through the loop.
self.screen.fill(self.settings.bg_color)
❷ self.ship.blitme()
# Make the most recently drawn screen visible.
pygame.display.flip()
self.clock.tick(60)
*--snip--*
我们导入了Ship,并在屏幕创建之后实例化了Ship❶。调用Ship()时需要传递一个参数:AlienInvasion的实例。这里的self参数指的是当前的AlienInvasion实例。这个参数使得Ship可以访问游戏的资源,例如screen对象。我们将这个Ship实例赋值给self.ship。
在填充背景之后,我们通过调用ship.blitme()来绘制飞船,这样飞船就会显示在背景之上❷。
现在运行alien_invasion.py时,你应该能看到一个空的游戏屏幕,火箭飞船位于屏幕的底部中央,如图 12-2 所示。

图 12-2:Alien Invasion游戏中,飞船位于屏幕底部中央
重构:_check_events()和 _update_screen()方法
在大型项目中,你通常会在添加更多代码之前重构已经写好的代码。重构简化了你已编写的代码结构,使得后续的扩展更加容易。在这一部分中,我们将把变得冗长的run_game()方法分解成两个辅助方法。辅助方法是在类内执行工作的,但不打算被类外的代码使用的。在 Python 中,单个前导下划线表示这是一个辅助方法。
_check_events()方法
我们将管理事件的代码移到一个名为_check_events()的单独方法中。这将简化run_game()并隔离事件管理循环。隔离事件循环使得你可以将事件管理与游戏的其他部分(如更新屏幕)分开处理。
下面是带有新_check_events()方法的AlienInvasion类,这只影响run_game()中的代码:
alien_invasion.py
def run_game(self):
"""Start the main loop for the game."""
while True:
❶ self._check_events()
# Redraw the screen during each pass through the loop.
*--snip--*
❷ def _check_events(self):
"""Respond to keypresses and mouse events."""
for event in pygame.event.get():
if event.type == pygame.QUIT:
sys.exit()
我们创建了一个新的_check_events()方法❷,并将检查玩家是否点击关闭窗口的代码移入这个新方法中。
要从类内部调用方法,使用点符号和变量self以及方法名❶。我们在run_game()中的while循环内调用该方法。
_update_screen() 方法
为了进一步简化run_game(),我们将更新屏幕的代码移到一个名为_update_screen()的单独方法中:
alien_invasion.py
def run_game(self):
"""Start the main loop for the game."""
while True:
self._check_events()
self._update_screen()
self.clock.tick(60)
def _check_events(self):
*--snip--*
def _update_screen(self):
"""Update images on the screen, and flip to the new screen."""
self.screen.fill(self.settings.bg_color)
self.ship.blitme()
pygame.display.flip()
我们将绘制背景、飞船和翻转屏幕的代码移到了_update_screen()中。现在run_game()中主循环的主体部分简单多了。很容易看出,在每次循环中,我们都在寻找新事件、更新屏幕并更新时间。
如果你已经开发了多个游戏,你可能会从将代码拆分成像这样的不同方法开始。但如果你从未尝试过这样的项目,你可能一开始并不知道如何构建你的代码。这个方法为你提供了一个现实的开发过程的思路:你开始时尽可能简单地编写代码,随着项目的复杂化再进行重构。
现在我们已经重构了代码,使其更容易扩展,我们可以开始处理游戏的动态部分了!
驾驶飞船
接下来,我们将赋予玩家控制飞船左右移动的能力。我们将编写响应玩家按下右箭头或左箭头的代码。我们首先关注向右移动的实现,然后将相同的原则应用到控制向左移动上。当我们添加这段代码时,你将学习如何控制屏幕上图像的移动并响应用户输入。
响应按键输入
每当玩家按下某个键时,Pygame 会将该按键记录为一个事件。每个事件都会被pygame.event.get()方法捕获。我们需要在_check_events()方法中指定游戏要检查哪些类型的事件。每个按键都会被记录为KEYDOWN事件。
当 Pygame 检测到KEYDOWN事件时,我们需要检查按下的键是否是触发某个动作的键。例如,如果玩家按下右箭头键,我们希望增加飞船的rect.x值,使飞船向右移动:
alien_invasion.py
def _check_events(self):
"""Respond to keypresses and mouse events."""
for event in pygame.event.get():
if event.type == pygame.QUIT:
sys.exit()
❶ elif event.type == pygame.KEYDOWN:
❷ if event.key == pygame.K_RIGHT:
# Move the ship to the right.
❸ self.ship.rect.x += 1
在_check_events()内部,我们在事件循环中添加了一个elif块,以响应 Pygame 检测到的KEYDOWN事件 ❶。我们检查按下的键event.key是否是右箭头键 ❷。右箭头键由pygame.K_RIGHT表示。如果按下了右箭头键,我们通过增加self.ship.rect.x的值 1 来将船向右移动 ❸。
现在,当你运行alien_invasion.py时,每次按下右箭头键,船只应该向右移动一个像素。虽然这是一个开始,但这并不是一种高效的控制船只的方式。我们可以通过允许连续移动来改善这种控制方式。
允许连续移动
当玩家按住右箭头键时,我们希望船持续向右移动,直到玩家松开按键。我们将让游戏检测pygame.KEYUP事件,这样我们就能知道什么时候松开右箭头键;然后,我们将使用KEYDOWN和KEYUP事件结合一个名为moving_right的标志来实现连续运动。
当moving_right标志为False时,船将停止不动。当玩家按下右箭头键时,我们将该标志设置为True,当玩家松开键时,我们将该标志再次设置为False。
Ship类控制船的所有属性,所以我们将为其添加一个名为moving_right的属性,以及一个update()方法来检查moving_right标志的状态。如果该标志被设置为True,update()方法将改变船的位置。我们将在每次循环中调用此方法一次,以更新船的位置。
以下是对Ship的修改:
ship.py
class Ship:
"""A class to manage the ship."""
def __init__(self, ai_game):
*--snip--*
# Start each new ship at the bottom center of the screen.
self.rect.midbottom = self.screen_rect.midbottom
# Movement flag; start with a ship that's not moving.
❶ self.moving_right = False
❷ def update(self):
"""Update the ship's position based on the movement flag."""
if self.moving_right:
self.rect.x += 1
def blitme(self):
*--snip--*
我们在__init__()方法中添加了一个self.moving_right属性,并将其初始值设置为False ❶。然后我们添加了update()方法,如果标志为True,它会将船向右移动 ❷。update()方法将在类外部调用,因此它不被视为辅助方法。
现在我们需要修改_check_events(),使得在按下右箭头键时将moving_right设置为True,在松开按键时将其设置为False:
alien_invasion.py
def _check_events(self):
"""Respond to keypresses and mouse events."""
for event in pygame.event.get():
*--snip--*
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_RIGHT:
❶ self.ship.moving_right = True
❷ elif event.type == pygame.KEYUP:
if event.key == pygame.K_RIGHT:
self.ship.moving_right = False
在这里,我们修改了游戏对玩家按下右箭头键的响应方式:我们不直接改变船的位置,而是将moving_right设置为True ❶。然后我们添加了一个新的elif块,用于响应KEYUP事件 ❷。当玩家松开右箭头键(K_RIGHT)时,我们将moving_right设置为False。
接下来,我们修改run_game()中的while循环,使其在每次循环时调用船的update()方法:
alien_invasion.py
def run_game(self):
"""Start the main loop for the game."""
while True:
self._check_events()
self.ship.update()
self._update_screen()
self.clock.tick(60)
船的位置将在检查键盘事件并更新屏幕之前进行更新。这使得船的位置可以根据玩家输入进行更新,并确保更新后的位置会用于绘制船只到屏幕上。
当你运行alien_invasion.py并按住右箭头键时,船应该会持续向右移动,直到你松开按键。
实现左右移动
既然飞船现在可以持续向右移动,添加向左移动就变得很简单了。我们再次修改 Ship 类和 _check_events() 方法。以下是对 __init__() 和 update() 在 Ship 中的相关修改:
ship.py
def __init__(self, ai_game):
*--snip--*
# Movement flags; start with a ship that's not moving.
self.moving_right = False
self.moving_left = False
def update(self):
"""Update the ship's position based on movement flags."""
if self.moving_right:
self.rect.x += 1
if self.moving_left:
self.rect.x -= 1
在 __init__() 中,我们添加了一个 self.moving_left 标志。在 update() 中,我们使用两个独立的 if 块,而不是 elif,这样可以让飞船的 rect.x 值在按住两个箭头键时先增加然后减少。这样可以使飞船保持静止。如果我们对左移使用 elif,右箭头键将始终具有优先权。使用两个 if 块可以使飞船在玩家可能会短暂按住两个键时改变方向时更精确地移动。
我们需要在 _check_events() 中做两项新增操作:
alien_invasion.py
def _check_events(self):
"""Respond to keypresses and mouse events."""
for event in pygame.event.get():
*--snip--*
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_RIGHT:
self.ship.moving_right = True
elif event.key == pygame.K_LEFT:
self.ship.moving_left = True
elif event.type == pygame.KEYUP:
if event.key == pygame.K_RIGHT:
self.ship.moving_right = False
elif event.key == pygame.K_LEFT:
self.ship.moving_left = False
如果 K_LEFT 键发生 KEYDOWN 事件,我们将 moving_left 设置为 True。如果 K_LEFT 键发生 KEYUP 事件,我们将 moving_left 设置为 False。由于每个事件只与一个按键相关联,所以这里可以使用 elif 块。如果玩家同时按下两个按键,将会检测到两个独立的事件。
当你现在运行 alien_invasion.py 时,应该能够让飞船持续向左和向右移动。如果同时按住两个方向键,飞船应该会停止移动。
接下来,我们将进一步优化飞船的移动。让我们调整飞船的速度,并限制飞船的移动范围,以防止它消失在屏幕的边缘。
调整飞船的速度
目前,飞船每次通过 while 循环时移动一个像素,但我们可以通过向 Settings 类中添加 ship_speed 属性来更精细地控制飞船的速度。我们将使用这个属性来确定飞船在每次循环中应移动的距离。以下是 settings.py 中的新属性:
settings.py
class Settings:
"""A class to store all settings for Alien Invasion."""
def __init__(self):
*--snip--*
# Ship settings
self.ship_speed = 1.5
我们将 ship_speed 的初始值设置为 1.5。现在飞船移动时,它的位置在每次循环中调整 1.5 像素(而不是 1 像素)。
我们使用浮动数值来设置速度,以便在稍后增加游戏节奏时更精细地控制飞船的速度。然而,rect 属性如 x 只存储整数值,因此我们需要对 Ship 做一些修改:
ship.py
class Ship:
"""A class to manage the ship."""
def __init__(self, ai_game):
"""Initialize the ship and set its starting position."""
self.screen = ai_game.screen
❶ self.settings = ai_game.settings
*--snip--*
# Start each new ship at the bottom center of the screen.
self.rect.midbottom = self.screen_rect.midbottom
# Store a float for the ship's exact horizontal position.
❷ self.x = float(self.rect.x)
# Movement flags; start with a ship that's not moving.
self.moving_right = False
self.moving_left = False
def update(self):
"""Update the ship's position based on movement flags."""
# Update the ship's x value, not the rect.
if self.moving_right:
❸ self.x += self.settings.ship_speed
if self.moving_left:
self.x -= self.settings.ship_speed
# Update rect object from self.x.
❹ self.rect.x = self.x
def blitme(self):
*--snip--*
我们为 Ship 创建了一个 settings 属性,这样就可以在 update() ❶ 中使用它。因为我们是通过像素的分数来调整飞船的位置,所以我们需要将位置赋值给一个可以存储浮动值的变量。你可以使用浮动数值来设置 rect 的属性,但 rect 只会保留该值的整数部分。为了精确追踪飞船的位置,我们定义了一个新的 self.x ❷。我们使用 float() 函数将 self.rect.x 的值转换为浮动数值,并将该值赋给 self.x。
现在,当我们在update()方法中改变飞船的位置时,self.x的值会根据settings.ship_speed中存储的数值进行调整 ❸。在更新了self.x之后,我们使用这个新值来更新self.rect.x,控制飞船的位置 ❹。只有self.x的整数部分会被赋给self.rect.x,但这对于显示飞船来说已经足够了。
现在我们可以改变ship_speed的值,任何大于 1 的值都会让飞船加速。这将有助于让飞船快速响应,打败外星人,并且随着玩家在游戏中的进展,我们可以改变游戏的节奏。
限制飞船的活动范围
此时,如果你长时间按住箭头键,飞船会消失在屏幕的两边。让我们修正这个问题,让飞船在到达屏幕边缘时停止移动。我们通过修改Ship中的update()方法来实现这个目标:
ship.py
def update(self):
"""Update the ship's position based on movement flags."""
# Update the ship's x value, not the rect.
❶ if self.moving_right and self.rect.right < self.screen_rect.right:
self.x += self.settings.ship_speed
❷ if self.moving_left and self.rect.left > 0:
self.x -= self.settings.ship_speed
# Update rect object from self.x.
self.rect.x = self.x
这段代码在修改self.x的值之前会检查飞船的位置。代码self.rect.right返回的是飞船rect右边缘的* x *坐标。如果这个值小于self.screen_rect.right返回的值,那么飞船还没有到达屏幕的右边缘 ❶。左边缘的情况也是一样:如果rect左边的值大于 0,那么飞船还没有到达屏幕的左边缘 ❷。这确保了在调整self.x的值之前,飞船位于这些边界内。
现在运行alien_invasion.py时,飞船应该会在屏幕的两边停止移动。这真的很酷,我们所做的仅仅是添加了一个if语句中的条件测试,但感觉飞船在屏幕的两边像撞上了墙或者力场一样!
重构_check_events()
随着我们不断开发游戏,_check_events()方法会变得越来越长,所以让我们把_check_events()分解成两个独立的方法:一个处理KEYDOWN事件,另一个处理KEYUP事件:
alien_invasion.py
def _check_events(self):
"""Respond to keypresses and mouse events."""
for event in pygame.event.get():
if event.type == pygame.QUIT:
sys.exit()
elif event.type == pygame.KEYDOWN:
self._check_keydown_events(event)
elif event.type == pygame.KEYUP:
self._check_keyup_events(event)
def _check_keydown_events(self, event):
"""Respond to keypresses."""
if event.key == pygame.K_RIGHT:
self.ship.moving_right = True
elif event.key == pygame.K_LEFT:
self.ship.moving_left = True
def _check_keyup_events(self, event):
"""Respond to key releases."""
if event.key == pygame.K_RIGHT:
self.ship.moving_right = False
elif event.key == pygame.K_LEFT:
self.ship.moving_left = False
我们创建了两个新的辅助方法:_check_keydown_events()和_check_keyup_events()。每个方法都需要一个self参数和一个event参数。这两个方法的代码体是从_check_events()中复制过来的,我们用调用新方法的代码替换了旧的代码。现在,_check_events()方法变得更加简洁,这种清晰的代码结构将使得进一步开发对玩家输入的响应更加容易。
按下 Q 退出
现在我们已经高效地响应了按键输入,我们可以添加另一种退出游戏的方式。每次测试新功能时,点击游戏窗口顶部的 X 来结束游戏会变得很麻烦,所以我们将添加一个快捷键,当玩家按下 Q 时结束游戏:
alien_invasion.py
def _check_keydown_events(self, event):
*--snip--*
elif event.key == pygame.K_LEFT:
self.ship.moving_left = True
elif event.key == pygame.K_q:
sys.exit()
在_check_keydown_events()方法中,我们添加了一个新的代码块,当玩家按下 Q 时结束游戏。现在,在测试时,你可以按下 Q 来关闭游戏,而不需要使用光标关闭窗口。
在全屏模式下运行游戏
Pygame 有一个全屏模式,你可能会比在常规窗口中运行游戏更喜欢它。有些游戏在全屏模式下看起来更好,在某些系统上,游戏在全屏模式下可能整体表现得更好。
要以全屏模式运行游戏,请在 __init__() 方法中进行以下更改:
alien_invasion.py
def __init__(self):
"""Initialize the game, and create game resources."""
pygame.init()
self.settings = Settings()
❶ self.screen = pygame.display.set_mode((0, 0), pygame.FULLSCREEN)
❷ self.settings.screen_width = self.screen.get_rect().width
self.settings.screen_height = self.screen.get_rect().height
pygame.display.set_caption("Alien Invasion")
在创建屏幕表面时,我们传递了 (0, 0) 的大小和 pygame.FULLSCREEN 参数 ❶。这告诉 Pygame 计算一个将填充屏幕的窗口大小。因为我们无法提前知道屏幕的宽度和高度,所以在屏幕创建后我们更新这些设置 ❷。我们使用屏幕 rect 的 width 和 height 属性来更新 settings 对象。
如果你喜欢游戏在全屏模式下的外观或表现,可以保持这些设置。如果你更喜欢游戏在自己的窗口中运行,可以恢复到原始方法,在该方法中我们为游戏设置了一个特定的屏幕尺寸。
快速回顾
在下一部分,我们将增加开火的功能,这涉及添加一个名为 bullet.py 的新文件,并对我们已经使用的一些文件进行一些修改。目前,我们有三个文件包含多个类和方法。为了更清楚地了解项目的组织方式,在添加更多功能之前,让我们回顾一下这些文件。
alien_invasion.py
主要文件 alien_invasion.py 包含 AlienInvasion 类。此类创建了许多在整个游戏中使用的重要属性:设置被分配给 settings,主显示表面被分配给 screen,并且在此文件中也创建了一个 ship 实例。游戏的主循环,一个 while 循环,也存储在这个模块中。while 循环调用 _check_events()、ship.update() 和 _update_screen() 方法。它还在每次通过循环时更新时钟。
_check_events() 方法检测相关事件,如按键和释放,并通过 _check_keydown_events() 和 _check_keyup_events() 方法处理这些事件。目前,这些方法管理船只的移动。AlienInvasion 类还包含 _update_screen(),该方法在每次通过主循环时重绘屏幕。
alien_invasion.py 文件是你运行游戏时唯一需要的文件。其他文件 settings.py 和 ship.py 包含的代码会被导入到这个文件中。
settings.py
settings.py 文件包含 Settings 类。此类只有一个 __init__() 方法,用于初始化控制游戏外观和船只速度的属性。
ship.py
ship.py 文件包含 Ship 类。Ship 类具有 __init__() 方法,一个用于管理船只位置的 update() 方法,以及一个用于将船只绘制到屏幕上的 blitme() 方法。船只的图像存储在 ship.bmp 文件中,该文件位于 images 文件夹内。
开火
现在让我们添加射击子弹的功能。当玩家按下空格键时,我们将编写代码发射子弹,子弹以小矩形的形式表示。子弹将一直向上移动,直到从屏幕顶部消失。
添加子弹设置
在__init__()方法的末尾,我们将更新settings.py,以包含我们为新的Bullet类所需的值:
settings.py
def __init__(self):
*--snip--*
# Bullet settings
self.bullet_speed = 2.0
self.bullet_width = 3
self.bullet_height = 15
self.bullet_color = (60, 60, 60)
这些设置创建了深灰色的子弹,宽度为3像素,高度为15像素。子弹的移动速度将略快于飞船。
创建子弹类
现在创建一个bullet.py文件来存储我们的Bullet类。以下是bullet.py的第一部分:
bullet.py
import pygame
from pygame.sprite import Sprite
class Bullet(Sprite):
"""A class to manage bullets fired from the ship."""
def __init__(self, ai_game):
"""Create a bullet object at the ship's current position."""
super().__init__()
self.screen = ai_game.screen
self.settings = ai_game.settings
self.color = self.settings.bullet_color
# Create a bullet rect at (0, 0) and then set correct position.
❶ self.rect = pygame.Rect(0, 0, self.settings.bullet_width,
self.settings.bullet_height)
❷ self.rect.midtop = ai_game.ship.rect.midtop
# Store the bullet's position as a float.
❸ self.y = float(self.rect.y)
Bullet类继承自Sprite,我们从pygame.sprite模块导入它。使用精灵时,您可以将游戏中相关的元素分组,并同时对所有分组的元素进行操作。为了创建一个子弹实例,__init__()需要当前的AlienInvasion实例,并且我们调用super()以正确继承自Sprite。我们还为屏幕和设置对象以及子弹的颜色设置了属性。
py`Next we create the bullet’s `rect` attribute ❶. The bullet isn’t based on an image, so we have to build a `rect` from scratch using the `pygame.Rect()` class. This class requires the *x*- and *y*-coordinates of the top-left corner of the `rect`, and the width and height of the `rect`. We initialize the `rect` at (0, 0), but we’ll move it to the correct location in the next line, because the bullet’s position depends on the ship’s position. We get the width and height of the bullet from the values stored in `self.settings`. We set the bullet’s `midtop` attribute to match the ship’s `midtop` attribute ❷. This will make the bullet emerge from the top of the ship, making it look like the bullet is fired from the ship. We use a float for the bullet’s *y*-coordinate so we can make fine adjustments to the bullet’s speed ❸. Here’s the second part of *bullet.py*, `update()` and `draw_bullet()`: **bullet.py** def update(self): """将子弹向屏幕上移.""" # 更新子弹的准确位置。 ❶ self.y -= self.settings.bullet_speed # 更新矩形的位置。 ❷ self.rect.y = self.y def draw_bullet(self): """将子弹绘制到屏幕上.""" ❸ pygame.draw.rect(self.screen, self.color, self.rect) py The `update()` method manages the bullet’s position. When a bullet is fired, it moves up the screen, which corresponds to a decreasing *y*-coordinate value. To update the position, we subtract the amount stored in `settings.bullet_speed` from `self.y` ❶. We then use the value of `self.y` to set the value of `self.rect.y` ❷. The `bullet_speed` setting allows us to increase the speed of the bullets as the game progresses or as needed to refine the game’s behavior. Once a bullet is fired, we never change the value of its *x*-coordinate, so it will travel vertically in a straight line even if the ship moves. When we want to draw a bullet, we call `draw_bullet()`. The `draw.rect()` function fills the part of the screen defined by the bullet’s `rect` with the color stored in `self.color` ❸. ### Storing Bullets in a Group Now that we have a `Bullet` class and the necessary settings defined, we can write code to fire a bullet each time the player presses the spacebar. We’ll create a group in `AlienInvasion` to store all the active bullets so we can manage the bullets that have already been fired. This group will be an instance of the `pygame.sprite.Group` class, which behaves like a list with some extra functionality that’s helpful when building games. We’ll use this group to draw bullets to the screen on each pass through the main loop and to update each bullet’s position. First, we’ll import the new `Bullet` class: **alien_invasion.py** --snip-- from ship import Ship from bullet import Bullet py Next we’ll create the group that holds the bullets in `__init__()`: **alien_invasion.py** def init(self): --snip-- self.ship = Ship(self) self.bullets = pygame.sprite.Group() py Then we need to update the position of the bullets on each pass through the `while` loop: **alien_invasion.py** def run_game(self): """启动游戏的主循环.""" while True: self._check_events() self.ship.update() self.bullets.update() self._update_screen() self.clock.tick(60) py When you call `update()` on a group, the group automatically calls `update()` for each sprite in the group. The line `self.bullets.update()` calls `bullet.update()` for each bullet we place in the group `bullets`. ### Firing Bullets In `AlienInvasion`, we need to modify `_check_keydown_events()` to fire a bullet when the player presses the spacebar. We don’t need to change `_check_keyup_events()` because nothing happens when the spacebar is released. We also need to modify `_update_screen()` to make sure each bullet is drawn to the screen before we call `flip()`. There will be a bit of work to do when we fire a bullet, so let’s write a new method, `_fire_bullet()`, to handle this work: **alien_invasion.py** def _check_keydown_events(self, event): --snip-- elif event.key == pygame.K_q: sys.exit() ❶ elif event.key == pygame.K_SPACE: self._fire_bullet() def _check_keyup_events(self, event): --snip-- def _fire_bullet(self): """创建一个新的子弹并将其添加到子弹组中.""" ❷ new_bullet = Bullet(self) ❸ self.bullets.add(new_bullet) def _update_screen(self): """更新屏幕上的图像,并切换到新屏幕.""" self.screen.fill(self.settings.bg_color) ❹ for bullet in self.bullets.sprites(): bullet.draw_bullet() self.ship.blitme() pygame.display.flip() --snip-- py We call `_fire_bullet()` when the spacebar is pressed ❶. In `_fire_bullet()`, we make an instance of `Bullet` and call it `new_bullet` ❷. We then add it to the group `bullets` using the `add()` method ❸. The `add()` method is similar to `append()`, but it’s written specifically for Pygame groups. The `bullets.sprites()` method returns a list of all sprites in the group `bullets`. To draw all fired bullets to the screen, we loop through the sprites in `bullets` and call `draw_bullet()` on each one ❹. We place this loop before the line that draws the ship, so the bullets don’t start out on top of the ship. When you run *alien_invasion.py* now, you should be able to move the ship right and left and fire as many bullets as you want. The bullets travel up the screen and disappear when they reach the top, as shown in Figure 12-3. You can alter the size, color, and speed of the bullets in *settings.py*.  Figure 12-3: The ship after firing a series of bullets ### Deleting Old Bullets At the moment, the bullets disappear when they reach the top, but only because Pygame can’t draw them above the top of the screen. The bullets actually continue to exist; their *y*-coordinate values just grow increasingly negative. This is a problem because they continue to consume memory and processing power. We need to get rid of these old bullets, or the game will slow down from doing so much unnecessary work. To do this, we need to detect when the `bottom` value of a bullet’s `rect` has a value of 0, which indicates the bullet has passed off the top of the screen: **alien_invasion.py** def run_game(self): """启动游戏的主循环.""" while True: self._check_events() self.ship.update() self.bullets.update() # 去除已经消失的子弹。 ❶ for bullet in self.bullets.copy(): ❷ if bullet.rect.bottom <= 0: ❸ self.bullets.remove(bullet) ❹ print(len(self.bullets)) self._update_screen() self.clock.tick(60) py When you use a `for` loop with a list (or a group in Pygame), Python expects that the list will stay the same length as long as the loop is running. That means you can’t remove items from a list or group within a `for` loop, so we have to loop over a copy of the group. We use the `copy()` method to set up the `for` loop ❶, which leaves us free to modify the original `bullets` group inside the loop. We check each bullet to see whether it has disappeared off the top of the screen ❷. If it has, we remove it from `bullets` ❸. We insert a `print()` call to show how many bullets currently exist in the game and verify they’re being deleted when they reach the top of the screen ❹. If this code works correctly, we can watch the terminal output while firing bullets and see that the number of bullets decreases to zero after each series of bullets has cleared the top of the screen. After you run the game and verify that bullets are being deleted properly, remove the `print()` call. If you leave it in, the game will slow down significantly because it takes more time to write output to the terminal than it does to draw graphics to the game window. ### Limiting the Number of Bullets Many shooting games limit the number of bullets a player can have on the screen at one time; doing so encourages players to shoot accurately. We’ll do the same in *Alien Invasion*. First, store the number of bullets allowed in *settings.py*: **settings.py** # 子弹设置 --snip-- self.bullet_color = (60, 60, 60) self.bullets_allowed = 3 py This limits the player to three bullets at a time. We’ll use this setting in `AlienInvasion` to check how many bullets exist before creating a new bullet in `_fire_bullet()`: **alien_invasion.py** def _fire_bullet(self): """创建一个新的子弹并将其添加到子弹组中.""" if len(self.bullets) < self.settings.bullets_allowed: new_bullet = Bullet(self) self.bullets.add(new_bullet) py When the player presses the spacebar, we check the length of `bullets`. If `len(self.bullets)` is less than three, we create a new bullet. But if three bullets are already active, nothing happens when the spacebar is pressed. When you run the game now, you should only be able to fire bullets in groups of three. ### Creating the _update_bullets() Method We want to keep the `AlienInvasion` class reasonably well organized, so now that we’ve written and checked the bullet management code, we can move it to a separate method. We’ll create a new method called `_update_bullets()` and add it just before `_update_screen()`: **alien_invasion.py** def _update_bullets(self): """更新子弹位置并去除旧的子弹.""" # 更新子弹位置。 self.bullets.update() # 去除已经消失的子弹。 for bullet in self.bullets.copy(): if bullet.rect.bottom <= 0: self.bullets.remove(bullet) py The code for `_update_bullets()` is cut and pasted from `run_game()`; all we’ve done here is clarify the comments. The `while` loop in `run_game()` looks simple again: **alien_invasion.py** while True: self._check_events() self.ship.update() self._update_bullets() self._update_screen() self.clock.tick(60) py Now our main loop contains only minimal code, so we can quickly read the method names and understand what’s happening in the game. The main loop checks for player input, and then updates the position of the ship and any bullets that have been fired. We then use the updated positions to draw a new screen and tick the clock at the end of each pass through the loop. Run *alien_invasion.py* one more time, and make sure you can still fire bullets without errors. ## Summary In this chapter, you learned to make a plan for a game and learned the basic structure of a game written in Pygame. You learned to set a background color and store settings in a separate class where you can adjust them more easily. You saw how to draw an image to the screen and give the player control over the movement of game elements. You created elements that move on their own, like bullets flying up a screen, and you deleted objects that are no longer needed. You also learned to refactor code in a project on a regular basis to facilitate ongoing development. In Chapter 13, we’ll add aliens to *Alien Invasion*. By the end of the chapter, you’ll be able to shoot down aliens, hopefully before they reach your ship!
第十三章:外星人!

在本章中,我们将向外星人入侵游戏中添加外星人。我们将在屏幕的顶部附近添加一个外星人,然后生成一整队外星人。我们将使这支舰队向横向和纵向移动,并且在外星人被子弹击中时将其移除。最后,我们将限制玩家可使用的飞船数量,并在玩家用完所有飞船时结束游戏。
在本章的过程中,你将学到更多关于 Pygame 的知识,以及如何管理一个大型项目。你还将学到如何检测游戏对象之间的碰撞,比如子弹和外星人。检测碰撞帮助你定义游戏中元素之间的互动。例如,你可以把角色限制在迷宫的墙壁内,或者在两个角色之间传递一个球。我们将继续遵循一个偶尔会重新审视的计划,以保持我们编写代码时的专注。
在我们开始编写新代码,向屏幕添加外星人舰队之前,让我们先看看项目并更新我们的计划。
回顾项目
当你在大型项目中开始一个新的开发阶段时,重新审视你的计划并明确你想要通过编写的代码实现什么目标总是一个好主意。在本章中,我们将进行以下操作:
-
在屏幕的左上角添加一个外星人,并确保周围有适当的间距。
-
填满屏幕上方的区域,尽可能水平地排列多个外星人。然后我们将继续创建额外的外星人行,直到形成一支完整的舰队。
-
让舰队横向和纵向移动,直到整个舰队被击落、外星人撞到飞船,或外星人到达地面。如果整个舰队被击落,我们将创建一个新的舰队。如果外星人撞到飞船或地面,我们将摧毁飞船并创建一个新的舰队。
-
限制玩家可以使用的飞船数量,并在玩家用尽所有飞船时结束游戏。
我们将在实现功能时完善这个计划,但这个计划已经足够具体,足以开始编写代码。
当你开始为项目中的新功能编写代码时,你也应该回顾现有的代码。因为每一个新阶段通常会使项目变得更加复杂,所以最好清理任何杂乱或低效的代码。我们在进行时已经进行了重构,因此目前没有任何需要重构的代码。
创建第一个外星人
在屏幕上放置一个外星人就像放置一艘飞船一样。每个外星人的行为由一个名为Alien的类控制,我们将像Ship类一样构建它。为了简化,我们将继续使用位图图像。你可以为外星人选择自己的图像,或者使用在图 13-1 中显示的图像,该图像在本书的资源中可找到,网址为ehmatthes.github.io/pcc_3e。这张图像有一个灰色背景,和屏幕的背景颜色匹配。确保你将选择的图像文件保存在images文件夹中。

图 13-1:我们将用来建立舰队的外星人
创建外星人类
现在我们将编写Alien类,并将其保存为alien.py:
alien.py
import pygame
from pygame.sprite import Sprite
class Alien(Sprite):
"""A class to represent a single alien in the fleet."""
def __init__(self, ai_game):
"""Initialize the alien and set its starting position."""
super().__init__()
self.screen = ai_game.screen
# Load the alien image and set its rect attribute.
self.image = pygame.image.load('images/alien.bmp')
self.rect = self.image.get_rect()
# Start each new alien near the top left of the screen.
❶ self.rect.x = self.rect.width
self.rect.y = self.rect.height
# Store the alien's exact horizontal position.
❷ self.x = float(self.rect.x)
这个类的大部分内容与Ship类相似,除了外星人在屏幕上的位置。我们最初将每个外星人放置在屏幕的左上角附近;在它的左侧添加一个与外星人宽度相等的空隙,在它的上方添加一个与外星人高度相等的空隙 ❶,这样更容易查看。我们主要关注外星人的水平速度,因此我们将精确跟踪每个外星人的水平位置 ❷。
这个Alien类不需要绘制它到屏幕上的方法;相反,我们将使用 Pygame 的组方法,它会自动将组中的所有元素绘制到屏幕上。
创建外星人实例
我们想创建一个Alien实例,这样就可以在屏幕上看到第一个外星人。由于这属于我们的设置工作,我们会在AlienInvasion中的__init__()方法末尾添加这个实例的代码。最终,我们将创建一个完整的外星舰队,这将是相当繁重的工作,所以我们将创建一个新的辅助方法,命名为_create_fleet()。
类中的方法顺序并不重要,只要它们的位置保持一致。我将把_create_fleet()放在_update_screen()方法之前,但在AlienInvasion类中的任何地方都可以。首先,我们将导入Alien类。
以下是alien_invasion.py的更新import语句:
alien_invasion.py
*--snip--*
from bullet import Bullet
from alien import Alien
这是更新后的__init__()方法:
alien_invasion.py
def __init__(self):
*--snip--*
self.ship = Ship(self)
self.bullets = pygame.sprite.Group()
self.aliens = pygame.sprite.Group()
self._create_fleet()
我们创建一个组来存放外星舰队,并调用我们即将编写的_create_fleet()方法。
这是新的_create_fleet()方法:
alien_invasion.py
def _create_fleet(self):
"""Create the fleet of aliens."""
# Make an alien.
alien = Alien(self)
self.aliens.add(alien)
在这个方法中,我们创建了一个Alien实例,然后将其添加到存放舰队的组中。外星人将被放置在屏幕的默认左上角区域。
为了让外星人出现,我们需要在_update_screen()中调用该组的draw()方法:
alien_invasion.py
def _update_screen(self):
*--snip--*
self.ship.blitme()
self.aliens.draw(self.screen)
pygame.display.flip()
当你在一个组上调用draw()时,Pygame 会在由该组的rect属性定义的位置绘制组内的每个元素。draw()方法需要一个参数:一个表面,在该表面上绘制组中的元素。图 13-2 展示了屏幕上出现的第一个外星人。

图 13-2:第一个外星人出现。
现在第一个外星人正确出现了,我们将编写代码来绘制整个舰队。
建立外星舰队
为了绘制一支舰队,我们需要弄清楚如何在屏幕的上半部分填充外星人,而不让游戏窗口变得过于拥挤。实现这个目标有多种方法。我们将通过在屏幕顶部从左到右添加外星人,直到没有空间再添加新的外星人为止。然后,只要我们有足够的垂直空间再添加一行外星人,我们就会重复这个过程。
创建一行外星人
现在我们准备生成完整的一排外星人。为了生成完整的一排,首先我们需要制作一个单独的外星人,以便获取外星人的宽度。我们将一个外星人放在屏幕的左侧,然后继续添加外星人,直到没有足够的空间为止:
alien_invasion.py
def _create_fleet(self):
"""Create the fleet of aliens."""
# Create an alien and keep adding aliens until there's no room left.
# Spacing between aliens is one alien width.
alien = Alien(self)
alien_width = alien.rect.width
❶ current_x = alien_width
❷ while current_x < (self.settings.screen_width - 2 * alien_width):
❸ new_alien = Alien(self)
❹ new_alien.x = current_x
new_alien.rect.x = current_x
self.aliens.add(new_alien)
❺ current_x += 2 * alien_width
我们从第一个创建的外星人中获取外星人的宽度,然后定义一个变量current_x ❶。它表示我们打算放置下一个外星人时的水平位置。我们最初将其设置为一个外星人的宽度,以便将舰队中的第一个外星人与屏幕的左边缘偏移。
接下来,我们开始while循环 ❷;我们将继续添加外星人,只要有足够的空间放置一个外星人。为了确定是否还有空间放置另一个外星人,我们将current_x与某个最大值进行比较。定义这个循环的第一次尝试可能看起来像这样:
while current_x < self.settings.screen_width:
这看起来可能有效,但它会将最后一个外星人放置在屏幕的最右边。因此,我们在屏幕右侧添加了一些边距。只要屏幕右边至少有两个外星人宽度的空间,我们就会进入循环并向舰队中添加另一个外星人。
每当有足够的水平空间继续循环时,我们需要做两件事:在正确的位置创建一个外星人,并定义这一排中下一个外星人的水平位置。我们创建一个外星人并将其分配给new_alien ❸。然后我们将精确的水平位置设置为current_x的当前值 ❹。我们还将外星人的rect位置设置为这个相同的 x 值,并将新外星人添加到self.aliens组中。
最后,我们增加current_x的值 ❺。我们将水平位置增加两个外星人宽度,以便跳过刚刚添加的外星人,并且在外星人之间留出一些空隙。Python 会在while循环开始时重新评估条件,并决定是否还有足够的空间放置另一个外星人。当没有空间时,循环结束,我们应该已经有了一整排外星人。
当你现在运行 Alien Invasion 时,你应该能看到第一排外星人出现,正如图 13-3 所示。

图 13-3:第一排外星人
重构 _create_fleet()
如果到目前为止我们编写的代码足够用来创建一个舰队,我们可能会保持_create_fleet()不变。但我们还有更多工作要做,所以我们稍微清理一下这个方法。我们将添加一个新的辅助方法_create_alien(),并在_create_fleet()中调用它:
alien_invasion.py
def _create_fleet(self):
*--snip--*
while current_x < (self.settings.screen_width - 2 * alien_width):
self._create_alien(current_x)
current_x += 2 * alien_width
❶ def _create_alien(self, x_position):
"""Create an alien and place it in the row."""
new_alien = Alien(self)
new_alien.x = x_position
new_alien.rect.x = x_position
self.aliens.add(new_alien)
方法_create_alien()除了self外,还需要一个参数:指定外星人放置位置的 x 值 ❶。_create_alien()方法中的代码与_create_fleet()中的代码相同,只是我们将参数名x_position替换了current_x。这种重构使得添加新的一排并创建整个舰队变得更加简单。
添加新的一排
为了完成舰队的创建,我们将不断添加更多的行,直到没有空间为止。我们将使用嵌套循环——在当前的while循环外再包裹一个循环。内层循环通过关注外星人的* x 坐标,将外星人横向放置在一行中。外层循环通过关注 y *坐标,将外星人纵向放置。我们将在接近屏幕底部时停止添加行,留出足够的空间给飞船并留有一些空间来开始向外星人开火。
这是如何在_create_fleet()中嵌套两个while循环的:
def _create_fleet(self):
"""Create the fleet of aliens."""
# Create an alien and keep adding aliens until there's no room left.
# Spacing between aliens is one alien width and one alien height.
alien = Alien(self)
❶ alien_width, alien_height = alien.rect.size
❷ current_x, current_y = alien_width, alien_height
❸ while current_y < (self.settings.screen_height - 3 * alien_height):
while current_x < (self.settings.screen_width - 2 * alien_width):
❹ self._create_alien(current_x, current_y)
current_x += 2 * alien_width
❺ # Finished a row; reset x value, and increment y value.
current_x = alien_width
current_y += 2 * alien_height
我们需要知道外星人的高度,以便放置行,所以我们通过外星人rect的size属性来获取外星人的宽度和高度 ❶。一个rect的size属性是一个包含其宽度和高度的元组。
接下来,我们为舰队中第一个外星人的放置设置初始的* x 和 y 坐标 ❷。我们将其放置在离左边一外星人宽度的位置,并向下放置一个外星人高度的距离。然后我们定义一个while循环来控制有多少行外星人被放置到屏幕上 ❸。只要下一行的 y *坐标小于屏幕高度减去三倍外星人高度,我们就会继续添加行。(如果这没有留下足够的空间,我们可以稍后调整。)
我们调用_create_alien()并传递* y 坐标以及其 x *位置 ❹。稍后我们会修改_create_alien()方法。
注意最后两行代码的缩进 ❺。它们位于外层while循环内,但在内层while循环外。这段代码在内层循环完成后运行;每添加一行后它就执行一次。每添加一行后,我们重置current_x的值,以便下一行的第一个外星人放置在与上一行的第一个外星人相同的位置。然后我们将current_y的当前值加上两个外星人高度,这样下一行就会放得更低。缩进在这里非常重要;如果在本节末尾运行alien_invasion.py时看不到正确的舰队,请检查这些嵌套循环中所有行的缩进。
我们需要修改_create_alien()方法来正确设置外星人的垂直位置:
def _create_alien(self, x_position, y_position):
"""Create an alien and place it in the fleet."""
new_alien = Alien(self)
new_alien.x = x_position
new_alien.rect.x = x_position
new_alien.rect.y = y_position
self.aliens.add(new_alien)
我们修改方法的定义,使其接受新外星人的* y *坐标,并在方法体内设置rect的垂直位置。
现在运行游戏时,你应该能看到一个完整的外星人舰队,如图 13-4 所示。

图 13-4:完整的舰队出现了。
在下一节中,我们将让舰队开始移动!
让舰队移动
现在,让我们让外星人舰队向右移动,直到碰到屏幕边缘,然后向下移动一定的距离并朝相反方向移动。我们将继续这个移动,直到所有外星人被击败、其中一个与飞船碰撞,或者某个外星人到达屏幕底部。我们先从让舰队向右移动开始。
让外星人向右移动
为了移动外星人,我们将在alien.py中使用一个update()方法,这个方法将会对外星人群体中的每个外星人进行调用。首先,添加一个设置来控制每个外星人的速度:
settings.py
def __init__(self):
*--snip--*
# Alien settings
self.alien_speed = 1.0
然后使用此设置来实现alien.py中的update()方法:
alien.py
def __init__(self, ai_game):
"""Initialize the alien and set its starting position."""
super().__init__()
self.screen = ai_game.screen
self.settings = ai_game.settings
*--snip--*
def update(self):
"""Move the alien to the right."""
❶ self.x += self.settings.alien_speed
❷ self.rect.x = self.x
我们在__init__()中创建了一个settings参数,这样我们就可以在update()中访问外星人的速度。每次更新外星人位置时,我们都将其向右移动,移动的距离由alien_speed存储。我们通过self.x属性跟踪外星人确切的位置,self.x可以存储浮动值❶。然后,我们使用self.x的值来更新外星人rect的位置❷。
在主while循环中,我们已经有了更新飞船和子弹位置的调用。现在我们将添加一个调用来更新每个外星人的位置:
alien_invasion.py
while True:
self._check_events()
self.ship.update()
self._update_bullets()
self._update_aliens()
self._update_screen()
self.clock.tick(60)
我们即将编写一些代码来管理舰队的移动,因此我们创建了一个名为_update_aliens()的新方法。在子弹更新之后,我们会更新外星人的位置,因为我们很快就会检查是否有子弹击中外星人。
你将此方法放置在模块中的位置并不关键。但为了保持代码的组织性,我会将它放在_update_bullets()方法之后,以便与while循环中的方法调用顺序一致。以下是_update_aliens()的第一个版本:
alien_invasion.py
def _update_aliens(self):
"""Update the positions of all aliens in the fleet."""
self.aliens.update()
我们在aliens组上使用update()方法,这将调用每个外星人的update()方法。现在运行Alien Invasion时,你应该会看到舰队向右移动并消失在屏幕的一侧。
创建舰队方向的设置
现在,我们将创建设置,使得当舰队撞到屏幕右边缘时,舰队会向下和向左移动。以下是实现这种行为的方法:
settings.py
# Alien settings
self.alien_speed = 1.0
self.fleet_drop_speed = 10
# fleet_direction of 1 represents right; -1 represents left.
self.fleet_direction = 1
设置fleet_drop_speed控制每次外星人到达任何一侧时,舰队下移的速度。将这个速度与外星人的水平速度分开是有帮助的,这样你就可以独立调整这两个速度。
为了实现fleet_direction设置,我们可以使用类似'left'或'right'的文本值,但那样我们最终会得到测试舰队方向的if-elif语句。相反,因为我们只有两个方向要处理,我们使用值1和-1,每次舰队改变方向时交换它们。(使用数字也更有意义,因为向右移动涉及到增加每个外星人的x坐标值,而向左移动则是从每个外星人的x坐标值中减去。)
检查外星人是否撞到边缘
我们需要一个方法来检查外星人是否位于任一边缘,同时我们需要修改update()方法,以允许每个外星人向适当的方向移动。这段代码是Alien类的一部分:
alien.py
def check_edges(self):
"""Return True if alien is at edge of screen."""
screen_rect = self.screen.get_rect()
❶ return (self.rect.right >= screen_rect.right) or (self.rect.left <= 0)
def update(self):
"""Move the alien right or left."""
❷ self.x += self.settings.alien_speed * self.settings.fleet_direction
self.rect.x = self.x
我们可以对任何外星人调用新的check_edges()方法,查看它是否位于左侧或右侧边缘。如果外星人的rect的right属性大于或等于屏幕rect的right属性,则它位于右边缘。如果它的left值小于或等于 0 ❶,则它位于左边缘。我们没有把这个条件判断放在if语句块中,而是直接把判断放在了return语句中。这个方法会在外星人位于右边缘或左边缘时返回True,否则返回False。
我们修改了update()方法,允许外星人向左或向右移动,方法是将外星人的速度乘以fleet_direction的值 ❷。如果fleet_direction为 1,外星人的当前位置会加上alien_speed的值,外星人向右移动;如果fleet_direction为−1,值会从外星人的位置中减去,外星人向左移动。
舰队下降并改变方向
当外星人到达屏幕边缘时,整个舰队需要下降并改变方向。因此,我们需要在AlienInvasion中添加一些代码,因为我们将在这里检查是否有外星人处于左侧或右侧边缘。我们将通过编写_check_fleet_edges()和_change_fleet_direction()方法来实现这一点,然后修改_update_aliens()。我会把这些新方法放在_create_alien()之后,但方法在类中的位置并不重要。
alien_invasion.py
def _check_fleet_edges(self):
"""Respond appropriately if any aliens have reached an edge."""
❶ for alien in self.aliens.sprites():
if alien.check_edges():
❷ self._change_fleet_direction()
break
def _change_fleet_direction(self):
"""Drop the entire fleet and change the fleet's direction."""
for alien in self.aliens.sprites():
❸ alien.rect.y += self.settings.fleet_drop_speed
self.settings.fleet_direction *= -1
在_check_fleet_edges()中,我们遍历整个舰队,并对每个外星人调用check_edges() ❶。如果check_edges()返回True,我们就知道有外星人处于边缘,整个舰队需要改变方向;因此,我们调用_change_fleet_direction()并跳出循环 ❷。在_change_fleet_direction()中,我们遍历所有外星人并使用fleet_drop_speed设置让它们下降 ❸;然后我们将fleet_direction的值乘以−1 来改变方向。改变舰队方向的那一行代码不是for循环的一部分。我们希望改变每个外星人的垂直位置,但只希望改变舰队的方向一次。
以下是对_update_aliens()的修改:
alien_invasion.py
def _update_aliens(self):
"""Check if the fleet is at an edge, then update positions."""
self._check_fleet_edges()
self.aliens.update()
我们通过在更新每个外星人位置之前调用_check_fleet_edges()方法来修改了这个方法。
当你现在运行游戏时,舰队应该会在屏幕的边缘之间来回移动,每次碰到边缘时就会下降。现在我们可以开始射击外星人,并观察任何碰到飞船或到达屏幕底部的外星人。
射击外星人
我们已经建造了我们的飞船和一支外星人舰队,但当子弹击中外星人时,它们会直接穿透过去,因为我们没有检测碰撞。在游戏编程中,碰撞发生在游戏元素重叠时。为了让子弹击落外星人,我们将使用sprite.groupcollide()函数来检查两个组成员之间的碰撞。
检测子弹碰撞
我们希望在子弹击中外星人时立即得知,这样我们就能在外星人被击中时立即让它消失。为此,我们将在更新所有子弹的位置后,立即检查碰撞。
sprite.groupcollide()函数将一个组中每个元素的rect与另一个组中每个元素的rect进行比较。在这种情况下,它将每颗子弹的rect与每个外星人的rect进行比较,并返回一个包含发生碰撞的子弹和外星人的字典。字典中的每个键将是一个子弹,而对应的值将是被击中的外星人。(我们将在第十四章实现评分系统时使用这个字典。)
将以下代码添加到_update_bullets()的末尾,以检查子弹与外星人之间的碰撞:
alien_invasion.py
def _update_bullets(self):
"""Update position of bullets and get rid of old bullets."""
*--snip--*
# Check for any bullets that have hit aliens.
# If so, get rid of the bullet and the alien.
collisions = pygame.sprite.groupcollide(
self.bullets, self.aliens, True, True)
我们添加的新代码比较了self.bullets中所有子弹和self.aliens中所有外星人的位置,并识别出任何重叠的部分。每当子弹和外星人的rect重叠时,groupcollide()会将一个键值对添加到它返回的字典中。两个True参数告诉 Pygame 删除已经碰撞的子弹和外星人。(要制作一颗可以穿越屏幕顶部并摧毁其路径上所有外星人的高能子弹,你可以将第一个布尔参数设置为False,并将第二个布尔参数保持为True。被击中的外星人会消失,但所有子弹会一直保持活动状态,直到它们消失在屏幕顶部。)
当你现在运行外星人入侵时,击中的外星人应该会消失。图 13-5 展示了一个部分被击落的舰队。

图 13-5:我们可以击落外星人!
为测试制作更大的子弹
你可以通过运行游戏来测试外星人入侵的许多功能,但某些功能在游戏的正常版本中测试起来很繁琐。例如,测试代码是否正确响应空舰队时,必须多次击落屏幕上的每个外星人,这是一项繁重的工作。
要测试特定功能,你可以更改某些游戏设置以集中测试特定区域。例如,你可以缩小屏幕,使需要击落的外星人更少,或增加子弹速度并一次性给予自己许多子弹。
我测试外星人入侵时最喜欢的更改是使用非常宽的子弹,即使它们击中外星人后仍保持活动状态(见图 13-6)。试试将bullet_width设置为 300,甚至 3,000,看看你能多快击落舰队!

图 13-6:超强子弹使游戏中的某些方面更容易进行测试。
这样的更改将帮助你更高效地测试游戏,并可能激发为玩家提供额外能力的创意。只需记得在测试完某个功能后,将设置恢复到正常状态。
重新填充舰队
外星入侵的一个关键特点是外星人是无情的:每当舰队被摧毁时,新的舰队应当出现。
为了在舰队被摧毁后让新的外星舰队出现,我们首先检查aliens组是否为空。如果为空,我们会调用_create_fleet()。我们将在_update_bullets()的最后进行此检查,因为那是个别外星人被摧毁的地方。
alien_invasion.py
def _update_bullets(self):
*--snip--*
❶ if not self.aliens:
# Destroy existing bullets and create new fleet.
❷ self.bullets.empty()
self._create_fleet()
我们检查aliens组是否为空 ❶。一个空的组会被评估为False,所以这是一种简单的方法来检查组是否为空。如果为空,我们通过使用empty()方法清除所有现有的子弹,该方法会从组中移除所有剩余的精灵 ❷。我们还会调用_create_fleet(),重新填充屏幕上的外星人。
现在,一旦你摧毁当前舰队,新的舰队就会出现。
加速子弹
如果你尝试过在当前状态下向外星人开火,你可能会发现子弹的速度不适合游戏玩法。它们可能有些太慢,或者有些太快。此时,你可以修改设置,使游戏玩法更有趣。请记住,游戏会逐渐加速,所以开始时不要让游戏速度过快。
我们通过调整settings.py中bullet_speed的值来修改子弹的速度。在我的系统上,我将bullet_speed的值调整为 2.5,这样子弹会稍微快一点:
settings.py
# Bullet settings
self.bullet_speed = 2.5
self.bullet_width = 3
*--snip--*
这个设置的最佳值取决于你对游戏的经验,因此需要找到适合你的值。你还可以调整其他设置。
重构 _update_bullets()
让我们重构_update_bullets(),让它不再做这么多不同的任务。我们将处理子弹和外星人碰撞的代码移到一个单独的方法中:
alien_invasion.py
def _update_bullets(self):
*--snip--*
# Get rid of bullets that have disappeared.
for bullet in self.bullets.copy():
if bullet.rect.bottom <= 0:
self.bullets.remove(bullet)
self._check_bullet_alien_collisions()
def _check_bullet_alien_collisions(self):
"""Respond to bullet-alien collisions."""
# Remove any bullets and aliens that have collided.
collisions = pygame.sprite.groupcollide(
self.bullets, self.aliens, True, True)
if not self.aliens:
# Destroy existing bullets and create new fleet.
self.bullets.empty()
self._create_fleet()
我们创建了一个新方法_check_bullet_alien_collisions(),用来检查子弹和外星人之间的碰撞,并在整个舰队被摧毁时作出适当反应。这样做避免了_update_bullets()变得过长,并简化了后续的开发。
结束游戏
玩一个无法失败的游戏有什么乐趣和挑战呢?如果玩家没有足够快地击落舰队,我们会让外星人摧毁飞船,当它们接触到飞船时。同时,我们会限制玩家可使用的飞船数量,当外星人到达屏幕底部时,飞船会被摧毁。游戏将在玩家用完所有飞船后结束。
检测外星飞船碰撞
我们将从检查外星人和飞船之间的碰撞开始,以便在外星人撞击飞船时做出适当反应。我们会在更新每个外星人的位置后立即检查外星人-飞船碰撞,在AlienInvasion中执行:
alien_invasion.py
def _update_aliens(self):
*--snip--*
self.aliens.update()
# Look for alien-ship collisions.
❶ if pygame.sprite.spritecollideany(self.ship, self.aliens):
❷ print("Ship hit!!!")
spritecollideany()函数接受两个参数:一个精灵和一个组。该函数查找组中任何与精灵发生碰撞的成员,并在找到一个发生碰撞的成员后立即停止遍历组。在这里,它遍历aliens组,并返回第一个与ship发生碰撞的外星人。
如果没有发生碰撞,spritecollideany()将返回None,if语句块不会执行❶。如果它找到了与飞船发生碰撞的外星人,它将返回该外星人,if语句块将执行:打印Ship hit!!!❷。当外星人撞击飞船时,我们需要执行一些任务:删除所有剩余的外星人和子弹,重新定位飞船,并创建一个新的舰队。在编写代码之前,我们想确保我们检测外星人-飞船碰撞的方法是正确的。通过编写一个print()调用是确保我们正确检测碰撞的一种简单方法。
现在,当你运行Alien Invasion时,每当外星人撞到飞船时,终端中应该会显示消息Ship hit!!!。在测试此功能时,将fleet_drop_speed设置为更高的值,例如 50 或 100,这样外星人会更快地到达飞船。
响应外星人-飞船碰撞
现在我们需要弄清楚当外星人和飞船发生碰撞时到底会发生什么。我们将通过追踪游戏的统计数据来计算飞船被击中的次数,而不是销毁ship实例并创建一个新的。追踪统计数据对于得分也很有用。
让我们编写一个新的类GameStats来跟踪游戏统计数据,并将其保存为game_stats.py:
game_stats.py
class GameStats:
"""Track statistics for Alien Invasion."""
def __init__(self, ai_game):
"""Initialize statistics."""
self.settings = ai_game.settings
❶ self.reset_stats()
def reset_stats(self):
"""Initialize statistics that can change during the game."""
self.ships_left = self.settings.ship_limit
我们将在Alien Invasion运行的整个过程中创建一个GameStats实例,但每次玩家开始新游戏时,我们需要重置一些统计数据。为此,我们将在reset_stats()方法中初始化大部分统计数据,而不是直接在__init__()中初始化。我们将在__init__()中调用此方法,以便在首次创建GameStats实例时正确设置统计数据❶。但我们也可以在玩家开始新游戏时随时调用reset_stats()。目前我们只有一个统计数据,ships_left,它的值会在整个游戏过程中发生变化。
玩家起始的飞船数量应该保存在settings.py中,作为ship_limit:
settings.py
# Ship settings
self.ship_speed = 1.5
self.ship_limit = 3
我们还需要在alien_invasion.py中做一些修改,以便创建一个GameStats实例。首先,我们将更新文件顶部的import语句:
alien_invasion.py
import sys
from time import sleep
import pygame
from settings import Settings
from game_stats import GameStats
from ship import Ship
*--snip--*
我们从 Python 标准库的time模块导入sleep()函数,这样当飞船被击中时,我们可以暂停游戏片刻。我们还导入了GameStats。
我们将在__init__()中创建一个GameStats实例:
alien_invasion.py
def __init__(self):
*--snip--*
self.screen = pygame.display.set_mode(
(self.settings.screen_width, self.settings.screen_height))
pygame.display.set_caption("Alien Invasion")
# Create an instance to store game statistics.
self.stats = GameStats(self)
self.ship = Ship(self)
*--snip--*
我们在创建游戏窗口后,但在定义其他游戏元素(如飞船)之前创建实例。
当外星人撞击飞船时,我们会将剩余飞船数量减去 1,销毁所有现有的外星人和子弹,创建一个新的舰队,并将飞船重新定位到屏幕中央。我们还会暂停游戏片刻,以便玩家能够注意到碰撞,并在新舰队出现之前重新集结。
让我们把大部分代码放在一个名为_ship_hit()的新方法中。当外星人撞击飞船时,我们会从_update_aliens()中调用这个方法:
alien_invasion.py
def _ship_hit(self):
"""Respond to the ship being hit by an alien."""
# Decrement ships_left.
❶ self.stats.ships_left -= 1
# Get rid of any remaining bullets and aliens.
❷ self.bullets.empty()
self.aliens.empty()
# Create a new fleet and center the ship.
❸ self._create_fleet()
self.ship.center_ship()
# Pause.
❹ sleep(0.5)
新方法_ship_hit()协调外星人撞击飞船时的响应。在_ship_hit()中,剩余飞船的数量减少 1❶,然后我们清空bullets和aliens两个组❷。
接下来,我们创建一个新的舰队并将飞船置于屏幕中央❸。(稍后我们会在Ship类中添加center_ship()方法。)然后,我们在更新所有游戏元素后但在任何更改绘制到屏幕之前添加一个暂停,以便玩家可以看到他们的飞船被撞击❹。sleep()调用会暂停程序执行半秒钟,足够让玩家看到外星人撞击飞船。当sleep()函数结束时,代码执行会转到_update_screen()方法,该方法将新的舰队绘制到屏幕上。
在_update_aliens()中,当外星人撞击飞船时,我们将print()调用替换为对_ship_hit()的调用:
alien_invasion.py
def _update_aliens(self):
*--snip--*
if pygame.sprite.spritecollideany(self.ship, self.aliens):
self._ship_hit()
这是新的center_ship()方法,它属于ship.py:
ship.py
def center_ship(self):
"""Center the ship on the screen."""
self.rect.midbottom = self.screen_rect.midbottom
self.x = float(self.rect.x)
我们以和__init__()中相同的方式将飞船居中。居中后,我们重置self.x属性,这样就可以跟踪飞船的准确位置。
运行游戏,射击几个外星人,让一个外星人撞击飞船。游戏应该暂停,并且一个新的舰队应该出现,飞船重新居中出现在屏幕底部。
到达屏幕底部的外星人
如果外星人到达屏幕底部,游戏会像外星人撞击飞船时那样做出响应。为了检查何时发生这种情况,请在alien_invasion.py中添加一个新方法:
alien_invasion.py
def _check_aliens_bottom(self):
"""Check if any aliens have reached the bottom of the screen."""
for alien in self.aliens.sprites():
❶ if alien.rect.bottom >= self.settings.screen_height:
# Treat this the same as if the ship got hit.
self._ship_hit()
break
方法_check_aliens_bottom()检查是否有外星人到达屏幕底部。当外星人的rect.bottom值大于或等于屏幕高度时,外星人就到达底部❶。如果外星人到达底部,我们调用_ship_hit()。如果一个外星人撞到底部,就不需要检查其他外星人,因此在调用_ship_hit()后,我们会跳出循环。
我们将在_update_aliens()中调用这个方法:
alien_invasion.py
def _update_aliens(self):
*--snip--*
# Look for alien-ship collisions.
if pygame.sprite.spritecollideany(self.ship, self.aliens):
self._ship_hit()
# Look for aliens hitting the bottom of the screen.
self._check_aliens_bottom()
在更新所有外星人位置并检查外星人与飞船的碰撞后,我们调用_check_aliens_bottom()。现在每次飞船被外星人撞击或外星人到达屏幕底部时,都会出现一个新的舰队。
游戏结束!
外星人入侵现在感觉更完整了,但游戏永远不会结束。ships_left的值会越来越负。让我们添加一个game_active标志,这样当玩家用尽船只时,我们就可以结束游戏。我们将在AlienInvasion类的__init__()方法末尾设置此标志:
alien_invasion.py
def __init__(self):
*--snip--*
# Start Alien Invasion in an active state.
self.game_active = True
现在我们向_ship_hit()中添加代码,当玩家用尽所有船只时,将game_active设置为False:
alien_invasion.py
def _ship_hit(self):
"""Respond to ship being hit by alien."""
if self.stats.ships_left > 0:
# Decrement ships_left.
self.stats.ships_left -= 1
*--snip--*
# Pause.
sleep(0.5)
else:
self.game_active = False
_ship_hit()的大部分内容保持不变。我们将所有现有代码移动到了一个if语句块中,用于检查玩家是否至少剩下一个船只。如果有,我们创建一个新的舰队,暂停并继续。如果玩家没有剩余的船只,我们将game_active设置为False。
确定游戏中哪些部分应该运行
我们需要确定游戏中应该始终运行的部分以及仅在游戏处于活动状态时才运行的部分:
alien_invasion.py
def run_game(self):
"""Start the main loop for the game."""
while True:
self._check_events()
if self.game_active:
self.ship.update()
self._update_bullets()
self._update_aliens()
self._update_screen()
self.clock.tick(60)
在主循环中,我们始终需要调用_check_events(),即使游戏处于非活动状态。例如,我们仍然需要知道用户是否按下 Q 键退出游戏,或者是否点击了关闭窗口的按钮。我们还需要继续更新屏幕,以便在等待玩家选择是否开始新游戏时,我们可以对屏幕进行更改。其余的函数调用只有在游戏处于活动状态时才需要执行,因为当游戏不活动时,我们不需要更新游戏元素的位置。
现在,当你玩外星人入侵时,当你用完所有船只后,游戏应该暂停。
总结
在本章中,你学会了如何通过创建外星人舰队将大量相同的元素添加到游戏中。你使用了嵌套循环创建了一个元素网格,并通过调用每个元素的update()方法使大量游戏元素移动。你学会了控制屏幕上物体的方向,并响应特定情况,例如舰队到达屏幕边缘时。你还检测并响应了子弹击中外星人和外星人撞击船只的碰撞。你还学会了如何跟踪游戏中的统计数据,并使用game_active标志来判断游戏是否结束。
在本项目的下一章,也是最后一章,我们将添加一个“播放”按钮,让玩家可以选择何时开始他们的第一局游戏,并在游戏结束时选择是否重新开始。每次玩家击败整个舰队时,我们将加速游戏,并且我们会添加一个计分系统。最终的结果将是一个完全可玩的游戏!
第十四章:计分

在本章中,我们将完成构建外星人入侵游戏。我们将添加一个播放按钮,让玩家可以随时开始游戏并在游戏结束后重新开始。我们还将修改游戏,使得玩家升到新的一关时,游戏速度会加快,并且实现一个计分系统。到本章结束时,你将掌握足够的知识来编写随着玩家进展而难度递增,并且包含完整计分系统的游戏。
添加播放按钮
在这一部分,我们将添加一个播放按钮,它会在游戏开始前显示,并在游戏结束后重新显示,以便玩家可以再次玩游戏。
现在,游戏在运行alien_invasion.py时会立即开始。让我们先让游戏处于非活动状态,然后提示玩家点击播放按钮开始游戏。为此,修改AlienInvasion的__init__()方法:
alien_invasion.py
def __init__(self):
"""Initialize the game, and create game resources."""
pygame.init()
*--snip--*
# Start Alien Invasion in an inactive state.
self.game_active = False
现在,游戏应该在非活动状态下启动,玩家在我们创建播放按钮之前无法启动游戏。
创建按钮类
由于 Pygame 没有内置的按钮制作方法,我们将编写一个Button类来创建一个带标签的填充矩形。你可以使用这段代码来制作任何游戏中的按钮。以下是Button类的第一部分;将其保存为button.py:
button.py
import pygame.font
class Button:
"""A class to build buttons for the game."""
❶ def __init__(self, ai_game, msg):
"""Initialize button attributes."""
self.screen = ai_game.screen
self.screen_rect = self.screen.get_rect()
# Set the dimensions and properties of the button.
❷ self.width, self.height = 200, 50
self.button_color = (0, 135, 0)
self.text_color = (255, 255, 255)
❸ self.font = pygame.font.SysFont(None, 48)
# Build the button's rect object and center it.
❹ self.rect = pygame.Rect(0, 0, self.width, self.height)
self.rect.center = self.screen_rect.center
# The button message needs to be prepped only once.
❺ self._prep_msg(msg)
首先,我们导入pygame.font模块,它允许 Pygame 将文本渲染到屏幕上。__init__()方法接受self、ai_game对象和msg(包含按钮文本)作为参数 ❶。我们设置按钮的尺寸 ❷,将button_color设置为深绿色来着色按钮的rect对象,并将text_color设置为白色以渲染文本。
接下来,我们为渲染文本准备一个font属性 ❸。None参数告诉 Pygame 使用默认字体,而48指定了文本的大小。为了将按钮居中显示在屏幕上,我们为按钮创建一个rect ❹,并将其center属性设置为与屏幕中心对齐。
Pygame 通过将你想要显示的字符串渲染为图像来处理文本。最后,我们调用_prep_msg()来处理此渲染 ❺。
以下是_prep_msg()的代码:
button.py
def _prep_msg(self, msg):
"""Turn msg into a rendered image and center text on the button."""
❶ self.msg_image = self.font.render(msg, True, self.text_color,
self.button_color)
❷ self.msg_image_rect = self.msg_image.get_rect()
self.msg_image_rect.center = self.rect.center
_prep_msg()方法需要一个self参数和要作为图像渲染的文本(msg)。对font.render()的调用将msg中存储的文本转化为图像,然后我们将其存储在self.msg_image中 ❶。font.render()方法还接受一个布尔值来开启或关闭抗锯齿效果(抗锯齿使文本的边缘更加平滑)。其余的参数是指定的字体颜色和背景颜色。我们将抗锯齿设置为True,并将文本背景设置为与按钮相同的颜色。(如果不包括背景颜色,Pygame 会尝试用透明背景渲染字体。)
我们通过创建一个来自图像的rect,并将其center属性设置为与按钮的中心对齐,从而将文本图像居中于按钮 ❷。
最后,我们创建了一个draw_button()方法,我们可以调用它在屏幕上显示按钮:
button.py
def draw_button(self):
"""Draw blank button and then draw message."""
self.screen.fill(self.button_color, self.rect)
self.screen.blit(self.msg_image, self.msg_image_rect)
我们调用screen.fill()绘制按钮的矩形部分。然后,我们调用screen.blit()将文本图像绘制到屏幕上,传递给它一个图像和与该图像相关的rect对象。这完成了Button类的创建。
将按钮绘制到屏幕上
我们将在AlienInvasion中使用Button类创建 Play 按钮。首先,我们将更新import语句:
alien_invasion.py
*--snip--*
from game_stats import GameStats
from button import Button
因为我们只需要一个 Play 按钮,所以我们将在AlienInvasion的__init__()方法中创建该按钮。我们可以将这段代码放在__init__()的末尾:
alien_invasion.py
def __init__(self):
*--snip--*
self.game_active = False
# Make the Play button.
self.play_button = Button(self, "Play")
这段代码创建了一个Button实例,标签为Play,但它没有将按钮绘制到屏幕上。为此,我们将在_update_screen()中调用按钮的draw_button()方法:
alien_invasion.py
def _update_screen(self):
*--snip--*
self.aliens.draw(self.screen)
# Draw the play button if the game is inactive.
if not self.game_active:
self.play_button.draw_button()
pygame.display.flip()
为了让“Play”按钮在屏幕上的其他所有元素之上可见,我们在绘制完所有其他元素后,但在切换到新屏幕之前绘制它。我们将其包含在if语句块中,因此按钮只会在游戏处于非活动状态时显示。
现在,当你运行Alien Invasion时,你应该会看到一个 Play 按钮出现在屏幕中央,如图 14-1 所示。

图 14-1:当游戏处于非活动状态时,出现一个 Play 按钮。
启动游戏
为了在玩家点击 Play 时开始新游戏,我们需要在_check_events()的末尾添加以下elif语句块,以监控按钮上的鼠标事件:
alien_invasion.py
def _check_events(self):
"""Respond to keypresses and mouse events."""
for event in pygame.event.get():
if event.type == pygame.QUIT:
*--snip--*
❶ elif event.type == pygame.MOUSEBUTTONDOWN:
❷ mouse_pos = pygame.mouse.get_pos()
❸ self._check_play_button(mouse_pos)
Pygame 会在玩家点击屏幕上的任意位置时检测到MOUSEBUTTONDOWN事件❶,但我们希望游戏只响应鼠标点击 Play 按钮的事件。为此,我们使用pygame.mouse.get_pos(),它返回一个包含鼠标光标的x和y坐标的元组,当鼠标按钮被点击时会返回这些值❷。我们将这些值传递给新的方法_check_play_button()❸。
这是_check_play_button(),我选择将它放在_check_events()之后:
alien_invasion.py
def _check_play_button(self, mouse_pos):
"""Start a new game when the player clicks Play."""
❶ if self.play_button.rect.collidepoint(mouse_pos):
self.game_active = True
我们使用rect方法collidepoint()来检查鼠标点击的位置是否与 Play 按钮的rect定义的区域重叠❶。如果重叠,我们将game_active设置为True,游戏开始!
此时,你应该能够启动并进行完整的游戏。当游戏结束时,game_active的值应变为False,并且 Play 按钮应重新出现。
重置游戏
我们刚刚编写的 Play 按钮代码在玩家第一次点击 Play 时有效。但在第一次游戏结束后它不再有效,因为导致游戏结束的条件尚未被重置。
为了在每次玩家点击 Play 时重置游戏,我们需要重置游戏统计信息,清除旧的外星人和子弹,重新建立新的舰队,并重新定位飞船,代码如下所示:
alien_invasion.py
def _check_play_button(self, mouse_pos):
"""Start a new game when the player clicks Play."""
if self.play_button.rect.collidepoint(mouse_pos):
# Reset the game statistics.
❶ self.stats.reset_stats()
self.game_active = True
# Get rid of any remaining bullets and aliens.
❷ self.bullets.empty()
self.aliens.empty()
# Create a new fleet and center the ship.
❸ self._create_fleet()
self.ship.center_ship()
我们重置了游戏统计信息❶,这会给玩家三个新船只。然后我们将game_active设置为True,这样当这个函数中的代码运行完毕后,游戏就会开始。我们清空了aliens和bullets两个组❷,然后创建一个新的外星舰队并将飞船居中❸。
现在,每次点击 Play 时,游戏都会正确重置,允许你玩任意次数!
禁用 Play 按钮
Play 按钮存在一个问题,即即使 Play 按钮不可见,屏幕上的按钮区域仍然会响应点击。如果在游戏开始后不小心点击了 Play 按钮区域,游戏会重新开始!
为了解决这个问题,设置游戏只在game_active为False时开始:
alien_invasion.py
def _check_play_button(self, mouse_pos):
"""Start a new game when the player clicks Play."""
❶ button_clicked = self.play_button.rect.collidepoint(mouse_pos)
❷ if button_clicked and not self.game_active:
# Reset the game statistics.
self.stats.reset_stats()
*--snip--*
标志button_clicked存储True或False值❶,只有在点击 Play 且游戏当前不活跃的情况下,游戏才会重新开始❷。要测试这个行为,启动一个新游戏并反复点击 Play 按钮应该所在的位置。如果一切按预期工作,点击 Play 按钮区域应该不会对游戏玩法产生任何影响。
隐藏鼠标光标
我们希望在游戏未激活时光标可见,但一旦游戏开始,光标就会妨碍操作。为了解决这个问题,我们将在游戏变为活跃状态时将光标设为不可见。我们可以在_check_play_button()函数的if块结束时实现这一点:
alien_invasion.py
def _check_play_button(self, mouse_pos):
"""Start a new game when the player clicks Play."""
button_clicked = self.play_button.rect.collidepoint(mouse_pos)
if button_clicked and not self.game_active:
*--snip--*
# Hide the mouse cursor.
pygame.mouse.set_visible(False)
将False传递给set_visible()会告诉 Pygame 在鼠标悬停在游戏窗口时隐藏光标。
游戏结束后,我们会让光标重新出现,这样玩家就可以点击 Play 重新开始新游戏。以下是实现这一点的代码:
alien_invasion.py
def _ship_hit(self):
"""Respond to ship being hit by alien."""
if self.stats.ships_left > 0:
*--snip--*
else:
self.game_active = False
pygame.mouse.set_visible(True)
当游戏变为未激活时,我们会再次让光标可见,这发生在_ship_hit()中。注意这些细节会让你的游戏看起来更专业,也能让玩家专注于游戏,而不是去琢磨界面。
升级关卡
在我们当前的游戏中,一旦玩家击败整个外星舰队,玩家就会进入新一关,但游戏的难度并不会改变。让我们稍微增加一点挑战性,增加游戏速度,使每当玩家清除屏幕时,游戏难度增加。
修改速度设置
我们将首先重新组织Settings类,将游戏设置分为静态和动态设置。我们还会确保任何在游戏中变化的设置会在重新开始新游戏时重置。以下是settings.py中的__init__()方法:
settings.py
def __init__(self):
"""Initialize the game's static settings."""
# Screen settings
self.screen_width = 1200
self.screen_height = 800
self.bg_color = (230, 230, 230)
# Ship settings
self.ship_limit = 3
# Bullet settings
self.bullet_width = 3
self.bullet_height = 15
self.bullet_color = 60, 60, 60
self.bullets_allowed = 3
# Alien settings
self.fleet_drop_speed = 10
# How quickly the game speeds up
❶ self.speedup_scale = 1.1
❷ self.initialize_dynamic_settings()
我们继续在__init__()方法中初始化那些保持不变的设置。我们添加了一个speedup_scale设置 ❶ 来控制游戏加速的速度:值为 2 时,每次玩家达到新关卡,游戏速度会翻倍;值为 1 时,速度保持不变。像1.1这样的值应该足够增加游戏难度,但又不至于让游戏变得不可能完成。最后,我们调用initialize_dynamic_settings()方法来初始化那些在游戏过程中需要变化的属性 ❷。
下面是initialize_dynamic_settings()的代码:
settings.py
def initialize_dynamic_settings(self):
"""Initialize settings that change throughout the game."""
self.ship_speed = 1.5
self.bullet_speed = 2.5
self.alien_speed = 1.0
# fleet_direction of 1 represents right; -1 represents left.
self.fleet_direction = 1
这个方法设置了飞船、子弹和外星人速度的初始值。随着玩家在游戏中的进展,我们将提高这些速度,并在每次玩家开始新游戏时重置它们。我们在这个方法中包含了fleet_direction,确保外星人在新游戏开始时总是向右移动。我们不需要增加fleet_drop_speed的值,因为当外星人横向移动速度加快时,它们也会更快地向下移动。
为了在玩家达到新关卡时提高飞船、子弹和外星人的速度,我们将编写一个新方法叫做increase_speed():
settings.py
def increase_speed(self):
"""Increase speed settings."""
self.ship_speed *= self.speedup_scale
self.bullet_speed *= self.speedup_scale
self.alien_speed *= self.speedup_scale
为了增加这些游戏元素的速度,我们将每个速度设置乘以speedup_scale的值。
当舰队中的最后一个外星人被击败时,我们在_check_bullet_alien_collisions()中调用increase_speed()来加速游戏:
alien_invasion.py
def _check_bullet_alien_collisions(self):
*--snip--*
if not self.aliens:
# Destroy existing bullets and create new fleet.
self.bullets.empty()
self._create_fleet()
self.settings.increase_speed()
只需改变飞船速度ship_speed、外星人速度alien_speed和子弹速度bullet_speed的值,就足以加速整个游戏!
重置速度
现在,我们需要每次玩家开始新游戏时将任何改变的设置恢复到初始值;否则,每个新游戏将从前一个游戏的加速设置开始:
alien_invasion.py
def _check_play_button(self, mouse_pos):
"""Start a new game when the player clicks Play."""
button_clicked = self.play_button.rect.collidepoint(mouse_pos)
if button_clicked and not self.game_active:
# Reset the game settings.
self.settings.initialize_dynamic_settings()
*--snip--*
现在,玩Alien Invasion应该更有趣和更具挑战性了。每次你清除屏幕时,游戏应该加速并变得稍微更难。如果游戏变得太难,可以降低settings.speedup_scale的值。如果游戏不够有挑战性,可以稍微提高这个值。通过在合理的时间内逐步增加难度,找到一个平衡点。前几个屏幕应该很容易,接下来的几个应该有挑战性但可以完成,而之后的屏幕应该几乎不可完成。
得分
我们来实现一个得分系统,实时追踪游戏分数,并显示最高分、关卡和剩余飞船数量。
得分是游戏的统计数据,因此我们将向GameStats添加一个score属性:
game_stats.py
class GameStats:
*--snip--*
def reset_stats(self):
"""Initialize statistics that can change during the game."""
self.ships_left = self.ai_settings.ship_limit
self.score = 0
为了每次开始新游戏时重置得分,我们将在reset_stats()中初始化score,而不是在__init__()中。
显示得分
为了在屏幕上显示分数,我们首先创建一个新的类Scoreboard。现在,这个类只会显示当前的分数。最终,我们将用它来报告最高分、等级和剩余的飞船数量。以下是该类的第一部分;将其保存为scoreboard.py:
scoreboard.py
import pygame.font
class Scoreboard:
"""A class to report scoring information."""
❶ def __init__(self, ai_game):
"""Initialize scorekeeping attributes."""
self.screen = ai_game.screen
self.screen_rect = self.screen.get_rect()
self.settings = ai_game.settings
self.stats = ai_game.stats
# Font settings for scoring information.
❷ self.text_color = (30, 30, 30)
❸ self.font = pygame.font.SysFont(None, 48)
# Prepare the initial score image.
❹ self.prep_score()
因为Scoreboard会在屏幕上写入文本,我们首先导入pygame.font模块。接下来,我们为__init__()方法提供ai_game参数,这样它就可以访问settings、screen和stats对象,这些是用来报告我们正在跟踪的数值❶。然后,我们设置文本颜色❷,并实例化一个字体对象❸。
为了将显示的文本转化为图像,我们调用prep_score()❹,我们在这里定义它:
scoreboard.py
def prep_score(self):
"""Turn the score into a rendered image."""
❶ score_str = str(self.stats.score)
❷ self.score_image = self.font.render(score_str, True,
self.text_color, self.settings.bg_color)
# Display the score at the top right of the screen.
❸ self.score_rect = self.score_image.get_rect()
❹ self.score_rect.right = self.screen_rect.right - 20
❺ self.score_rect.top = 20
在prep_score()中,我们将数值stats.score转换为字符串❶,然后将这个字符串传递给render(),它会创建图像❷。为了在屏幕上清晰地显示分数,我们将屏幕的背景颜色和文字颜色传递给render()。
我们将把分数放置在屏幕的右上角,并随着分数增加和数字宽度的增加,分数向左扩展。为了确保分数总是与屏幕的右侧对齐,我们创建一个名为score_rect的rect❸,并将它的右边缘设置为离屏幕右边缘 20 个像素❹。然后我们将其上边缘设置为距离屏幕顶部 20 个像素❺。
然后我们创建一个show_score()方法来显示渲染后的分数图像:
scoreboard.py
def show_score(self):
"""Draw score to the screen."""
self.screen.blit(self.score_image, self.score_rect)
这个方法将分数图像绘制到屏幕上,位置由score_rect指定。
创建记分板
为了显示分数,我们将在AlienInvasion中创建一个Scoreboard实例。首先,让我们更新import语句:
alien_invasion.py
*--snip--*
from game_stats import GameStats
from scoreboard import Scoreboard
*--snip--*
接下来,我们在__init__()中创建一个Scoreboard实例:
alien_invasion.py
def __init__(self):
*--snip--*
pygame.display.set_caption("Alien Invasion")
# Create an instance to store game statistics,
# and create a scoreboard.
self.stats = GameStats(self)
self.sb = Scoreboard(self)
*--snip--*
然后我们在_update_screen()中绘制记分板:
alien_invasion.py
def _update_screen(self):
*--snip--*
self.aliens.draw(self.screen)
# Draw the score information.
self.sb.show_score()
# Draw the play button if the game is inactive.
*--snip--*
我们在绘制“Play”按钮之前调用show_score()。
当你现在运行外星人入侵时,屏幕右上角应该会出现一个 0。(此时,我们只是想确保分数出现在正确的位置,之后再继续开发得分系统。)图 14-2 显示了游戏开始前分数的样子。
接下来,我们将为每个外星人分配分值!

图 14-2:分数出现在屏幕的右上角。
当外星人被击落时更新分数
为了在屏幕上实时显示分数,我们每当外星人被击中时更新stats.score的值,然后调用prep_score()来更新分数图像。但首先,让我们确定每次击落外星人时玩家能获得多少分:
settings.py
def initialize_dynamic_settings(self):
*--snip--*
# Scoring settings
self.alien_points = 50
随着游戏的进行,我们将增加每个外星人的分数值。为了确保每次新游戏开始时分数值都会重置,我们在initialize_dynamic_settings()中设置该值。
每次击落外星人时,让我们在_check_bullet_alien_collisions()中更新分数:
alien_invasion.py
def _check_bullet_alien_collisions(self):
"""Respond to bullet-alien collisions."""
# Remove any bullets and aliens that have collided.
collisions = pygame.sprite.groupcollide(
self.bullets, self.aliens, True, True)
if collisions:
self.stats.score += self.settings.alien_points
self.sb.prep_score()
*--snip--*
当子弹击中外星人时,Pygame 会返回一个collisions字典。我们检查这个字典是否存在,如果存在,就将外星人的分值添加到总分中。然后我们调用prep_score()来更新记分板的分数。
现在,当你玩Alien Invasion时,你应该能够快速累积分数!
重置分数
现在,我们只是在外星人被击中后才准备新的分数,这对大多数游戏场景有效。但在开始新游戏时,我们仍然会看到上一个游戏的分数,直到第一个外星人被击中。
我们可以通过在开始新游戏时准备分数来解决这个问题:
alien_invasion.py
def _check_play_button(self, mouse_pos):
*--snip--*
if button_clicked and not self.game_active:
*--snip--*
# Reset the game statistics.
self.stats.reset_stats()
self.sb.prep_score()
*--snip--*
我们在重置游戏统计数据后调用prep_score()来开始新游戏。这个方法将用 0 分初始化记分板。
确保为每次击中都得分
按照当前代码写法,我们可能会漏掉一些外星人的得分。例如,如果两颗子弹在同一轮循环中同时击中外星人,或者如果我们制作了一个超宽子弹来击中多个外星人,玩家只会为击中的一个外星人得分。为了修复这个问题,让我们改进子弹与外星人碰撞的检测方式。
在_check_bullet_alien_collisions()中,任何与外星人碰撞的子弹都会成为collisions字典中的一个键。每颗子弹相关的值是它所击中的外星人列表。我们遍历collisions字典中的值,确保为每个被击中的外星人得分:
alien_invasion.py
def _check_bullet_alien_collisions(self):
*--snip--*
if collisions:
for aliens in collisions.values():
self.stats.score += self.settings.alien_points * len(aliens)
self.sb.prep_score()
*--snip--*
如果collisions字典已定义,我们将遍历字典中的所有值。记住,每个值是一个被单颗子弹击中的外星人列表。我们将每个外星人的分值乘以每个列表中的外星人数量,并将这个值加到当前分数中。为了测试这个,先将子弹宽度改为 300 像素,验证你是否能为每个用超宽子弹击中的外星人得分;然后再把子弹宽度恢复到正常值。
增加分值
因为每次玩家达到新关卡时,游戏变得更加困难,所以后面的关卡中的外星人应该值更多的分。为了实现这个功能,我们将在游戏速度增加时添加代码来提高分值:
settings.py
class Settings:
"""A class to store all settings for Alien Invasion."""
def __init__(self):
*--snip--*
# How quickly the game speeds up
self.speedup_scale = 1.1
# How quickly the alien point values increase
❶ self.score_scale = 1.5
self.initialize_dynamic_settings()
def initialize_dynamic_settings(self):
*--snip--*
def increase_speed(self):
"""Increase speed settings and alien point values."""
self.ship_speed *= self.speedup_scale
self.bullet_speed *= self.speedup_scale
self.alien_speed *= self.speedup_scale
❷ self.alien_points = int(self.alien_points * self.score_scale)
我们定义了一个分数增长的比率,称为score_scale ❶。小幅度的速度增加(1.1)能迅速增加游戏的挑战性。但为了看到得分差异更明显,我们需要通过更大的数值(1.5)来改变外星人的分值。现在,当我们提高游戏的速度时,每击中一个外星人的分值也会随之增加 ❷。我们使用int()函数将分值增加为整数。
要查看每个外星人的分值,可以在Settings中的increase_speed()方法中添加print()调用:
settings.py
def increase_speed(self):
*--snip--*
self.alien_points = int(self.alien_points * self.score_scale)
print(self.alien_points)
每次达到新关卡时,新的分值应该出现在终端中。
四舍五入分数
大多数街机风格的射击游戏都会以 10 的倍数报告分数,因此我们也会按此方式报告我们的分数。此外,我们会格式化分数,以便在大数值中包含逗号分隔符。我们将在Scoreboard中进行此更改:
scoreboard.py
def prep_score(self):
"""Turn the score into a rendered image."""
rounded_score = round(self.stats.score, -1)
score_str = f"{rounded_score:,}"
self.score_image = self.font.render(score_str, True,
self.text_color, self.settings.bg_color)
*--snip--*
round()函数通常会根据作为第二个参数传递的小数位数来四舍五入浮点数。然而,当你传递负数作为第二个参数时,round()会将数值四舍五入到最接近的 10、100、1,000 等。此代码告诉 Python 将stats.score的值四舍五入到最接近的 10,并将其赋值给rounded_score。
然后我们在 f-string 中使用格式说明符来表示分数。格式说明符是一种特殊的字符序列,用于修改变量值的呈现方式。在这种情况下,序列:,告诉 Python 在数值的适当位置插入逗号。这会将1000000格式化为1,000,000。
现在,当你运行游戏时,你应该能看到一个格式整齐、四舍五入的分数,即使你获得了大量分数,正如在图 14-3 中所示。

图 14-3:带有逗号分隔符的四舍五入分数
最高分
每个玩家都希望打破游戏的最高分,因此我们将跟踪并报告最高分,以便给玩家设定一个目标。我们将在GameStats中存储最高分:
game_stats.py
def __init__(self, ai_game):
*--snip--*
# High score should never be reset.
self.high_score = 0
因为最高分永远不应重置,所以我们在__init__()方法中初始化high_score,而不是在reset_stats()方法中。
接下来,我们将修改Scoreboard来显示最高分。让我们从__init__()方法开始:
scoreboard.py
def __init__(self, ai_game):
*--snip--*
# Prepare the initial score images.
self.prep_score()
❶ self.prep_high_score()
最高分将与分数分开显示,因此我们需要一个新的方法prep_high_score()来准备最高分图像❶。
这是prep_high_score()方法:
scoreboard.py
def prep_high_score(self):
"""Turn the high score into a rendered image."""
❶ high_score = round(self.stats.high_score, -1)
high_score_str = f"{high_score:,}"
❷ self.high_score_image = self.font.render(high_score_str, True,
self.text_color, self.settings.bg_color)
# Center the high score at the top of the screen.
self.high_score_rect = self.high_score_image.get_rect()
❸ self.high_score_rect.centerx = self.screen_rect.centerx
❹ self.high_score_rect.top = self.score_rect.top
我们将最高分四舍五入到最接近的 10,并用逗号格式化❶。然后我们从最高分生成一张图像❷,将最高分的rect水平居中❸,并将其top属性设置为与分数图像的顶部对齐❹。
show_score()方法现在会在屏幕的右上角绘制当前分数,并在屏幕的顶部中央绘制最高分:
scoreboard.py
def show_score(self):
"""Draw score to the screen."""
self.screen.blit(self.score_image, self.score_rect)
self.screen.blit(self.high_score_image, self.high_score_rect)
为了检查最高分,我们将在Scoreboard中编写一个新的方法check_high_score():
scoreboard.py
def check_high_score(self):
"""Check to see if there's a new high score."""
if self.stats.score > self.stats.high_score:
self.stats.high_score = self.stats.score
self.prep_high_score()
方法check_high_score()将当前分数与最高分进行比较。如果当前分数更高,我们会更新high_score的值,并调用prep_high_score()来更新最高分的图像。
每次击中外星人后更新分数时,我们需要调用check_high_score():
alien_invasion.py
def _check_bullet_alien_collisions(self):
*--snip--*
if collisions:
for aliens in collisions.values():
self.stats.score += self.settings.alien_points * len(aliens)
self.sb.prep_score()
self.sb.check_high_score()
*--snip--*
当collisions字典存在时,我们会调用check_high_score(),并在更新所有已击中外星人的分数后执行此操作。
第一次玩Alien Invasion时,你的分数将是高分,因此它会显示为当前分数和高分。但是当你开始第二局游戏时,你的高分应该显示在中间,当前分数则显示在右侧,如图 14-4 所示。

图 14-4:高分显示在屏幕的顶部中间。
显示关卡
为了在游戏中显示玩家的关卡,我们首先需要在GameStats中添加一个属性来表示当前的关卡。为了在每场新游戏开始时重置关卡,我们在reset_stats()中初始化它:
game_stats.py
def reset_stats(self):
"""Initialize statistics that can change during the game."""
self.ships_left = self.settings.ship_limit
self.score = 0
self.level = 1
为了让Scoreboard显示当前的关卡,我们从__init__()中调用一个新方法prep_level():
scoreboard.py
def __init__(self, ai_game):
*--snip--*
self.prep_high_score()
self.prep_level()
这是prep_level():
scoreboard.py
def prep_level(self):
"""Turn the level into a rendered image."""
level_str = str(self.stats.level)
❶ self.level_image = self.font.render(level_str, True,
self.text_color, self.settings.bg_color)
# Position the level below the score.
self.level_rect = self.level_image.get_rect()
❷ self.level_rect.right = self.score_rect.right
❸ self.level_rect.top = self.score_rect.bottom + 10
prep_level()方法根据stats.level中存储的值创建一张图像 ❶,并将图像的right属性设置为与分数的right属性相匹配 ❷。然后,它将top属性设置为比分数图像底部低 10 个像素,以便分数和关卡之间留出空间 ❸。
我们还需要更新show_score():
scoreboard.py
def show_score(self):
"""Draw scores and level to the screen."""
self.screen.blit(self.score_image, self.score_rect)
self.screen.blit(self.high_score_image, self.high_score_rect)
self.screen.blit(self.level_image, self.level_rect)
这行新代码将关卡图像绘制到屏幕上。
我们将在_check_bullet_alien_collisions()中递增stats.level并更新关卡图像:
alien_invasion.py
def _check_bullet_alien_collisions(self):
*--snip--*
if not self.aliens:
# Destroy existing bullets and create new fleet.
self.bullets.empty()
self._create_fleet()
self.settings.increase_speed()
# Increase level.
self.stats.level += 1
self.sb.prep_level()
如果一队飞船被摧毁,我们会增加stats.level的值,并调用prep_level()来确保新关卡正确显示。
为了确保在新游戏开始时关卡图像正确更新,我们还需要在玩家点击“开始”按钮时调用prep_level():
alien_invasion.py
def _check_play_button(self, mouse_pos):
*--snip--*
if button_clicked and not self.game_active:
*--snip--*
self.sb.prep_score()
self.sb.prep_level()
*--snip--*
我们在调用prep_score()之后立即调用prep_level()。
现在你将看到你完成了多少个关卡,如图 14-5 所示。

图 14-5:当前关卡显示在当前得分下方。
显示飞船数量
最后,让我们显示玩家剩余的飞船数量,不过这次我们使用图形。为此,我们将在屏幕的左上角绘制飞船来表示剩余的飞船数量,就像许多经典街机游戏一样。
首先,我们需要让Ship继承自Sprite,这样我们就可以创建一组飞船:
ship.py
import pygame
from pygame.sprite import Sprite
❶ class Ship(Sprite):
"""A class to manage the ship."""
def __init__(self, ai_game):
"""Initialize the ship and set its starting position."""
❷ super().__init__()
*--snip--*
在这里,我们导入Sprite,确保Ship继承自Sprite ❶,并在__init__()的开始调用super() ❷。
接下来,我们需要修改Scoreboard以创建一组可以显示的飞船。这里是Scoreboard的import语句:
scoreboard.py
import pygame.font
from pygame.sprite import Group
from ship import Ship
因为我们要创建一组飞船,所以我们导入了Group和Ship类。
这是__init__():
scoreboard.py
def __init__(self, ai_game):
"""Initialize scorekeeping attributes."""
self.ai_game = ai_game
self.screen = ai_game.screen
*--snip--*
self.prep_level()
self.prep_ships()
我们将游戏实例分配给一个属性,因为我们需要它来创建一些飞船。我们在调用prep_level()后调用prep_ships()。
这是prep_ships():
scoreboard.py
def prep_ships(self):
"""Show how many ships are left."""
❶ self.ships = Group()
❷ for ship_number in range(self.stats.ships_left):
ship = Ship(self.ai_game)
❸ ship.rect.x = 10 + ship_number * ship.rect.width
❹ ship.rect.y = 10
❺ self.ships.add(ship)
prep_ships()方法创建了一个空的组,self.ships,用来存放飞船实例 ❶。为了填充这个组,循环会为玩家剩余的每艘飞船运行一次 ❷。在循环内部,我们创建一艘新的飞船,并设置每艘飞船的* x 坐标值,使得飞船彼此相邻,且组内的左侧有 10 像素的间距 ❸。我们设置 y *坐标值为屏幕顶部下方 10 像素,使得飞船显示在屏幕的左上角 ❹。然后我们将每艘新的飞船添加到ships组中 ❺。
现在我们需要将飞船绘制到屏幕上:
scoreboard.py
def show_score(self):
"""Draw scores, level, and ships to the screen."""
self.screen.blit(self.score_image, self.score_rect)
self.screen.blit(self.high_score_image, self.high_score_rect)
self.screen.blit(self.level_image, self.level_rect)
self.ships.draw(self.screen)
为了在屏幕上显示飞船,我们在组上调用draw(),然后 Pygame 会绘制每一艘飞船。
为了显示玩家初始拥有多少艘飞船,我们在新游戏开始时调用prep_ships()。我们在AlienInvasion的_check_play_button()中执行此操作:
alien_invasion.py
def _check_play_button(self, mouse_pos):
*--snip--*
if button_clicked and not self.game_active:
*--snip--*
self.sb.prep_level()
self.sb.prep_ships()
*--snip--*
当飞船被击中时,我们也会调用prep_ships(),以更新飞船图像的显示,当玩家失去一艘飞船时:
alien_invasion.py
def _ship_hit(self):
"""Respond to ship being hit by alien."""
if self.stats.ships_left > 0:
# Decrement ships_left, and update scoreboard.
self.stats.ships_left -= 1
self.sb.prep_ships()
`--snip--`
我们在减少ships_left的值后调用prep_ships(),以便每次飞船被摧毁时正确显示剩余飞船的数量。
图 14-6 展示了完整的得分系统,剩余的飞船显示在屏幕的左上角。

图 14-6:Alien Invasion的完整得分系统
小结
在本章中,你学习了如何实现一个“开始游戏”按钮来启动新游戏。你还学会了如何检测鼠标事件并在活动游戏中隐藏光标。你可以运用所学,创建其他按钮,例如帮助按钮来显示如何玩游戏的说明。你还学会了如何在游戏进程中调整游戏速度,实施渐进式得分系统,并以文本和非文本方式显示信息。
第十五章:生成数据

数据可视化 是利用视觉表示来探索和展示数据集中的模式。它与 数据分析 密切相关,数据分析使用代码来探索数据集中的模式和联系。一个数据集可以是一个简单的数字列表,可以用一行代码表示,或者它也可以是包含多种信息的数太字节数据。
创建有效的数据可视化不仅仅是让信息看起来好看。当数据集的表示形式简单且具有视觉吸引力时,它的含义会变得对观众更为清晰。人们会在你的数据集中看到以前从未意识到的模式和意义。
幸运的是,你不需要超级计算机就能可视化复杂的数据。Python 非常高效,使用一台笔记本电脑,你就可以快速探索包含数百万个数据点的数据集。这些数据点不一定是数字;利用你在本书第一部分学到的基础知识,你也可以分析非数字数据。
人们使用 Python 进行基因学、气候研究、政治与经济分析等数据密集型工作。数据科学家们为 Python 编写了令人印象深刻的可视化和分析工具,其中许多工具也可以供你使用。最流行的工具之一是 Matplotlib,一个数学绘图库。在本章中,我们将使用 Matplotlib 制作简单的图表,如折线图和散点图。接下来,我们将基于随机游走的概念创建一个更有趣的数据集——这是通过一系列随机决策生成的可视化。
我们还将使用一个名为 Plotly 的包,它可以创建在数字设备上效果良好的可视化,来分析掷骰子的结果。Plotly 生成的可视化会自动调整大小,以适应各种显示设备。这些可视化还可以包括一些交互功能,例如当用户将鼠标悬停在可视化的不同部分时,突出显示数据集的某些方面。学习使用 Matplotlib 和 Plotly 将帮助你开始可视化你最感兴趣的数据类型。
安装 Matplotlib
要使用 Matplotlib 进行初步的可视化,你需要像在第十一章中安装 pytest(见“使用 pip 安装 pytest”,第 210 页)一样,使用 pip 安装它。
要安装 Matplotlib,在终端提示符下输入以下命令:
$ **python -m pip install --user matplotlib**
如果你使用的命令不是 python 来运行程序或启动终端会话,比如 python3,那么你的命令应该像这样:
$ **python3 -m pip install --user matplotlib**
要查看你可以使用 Matplotlib 制作的可视化类型,访问 Matplotlib 官网 matplotlib.org,然后点击 Plot types。当你点击画廊中的某个可视化时,你将看到生成该图表所使用的代码。
绘制简单的折线图
让我们使用 Matplotlib 绘制一个简单的线性图,然后对其进行自定义,创建一个更具信息量的数据可视化图表。我们将使用平方数序列 1、4、9、16 和 25 作为图表的数据。
要制作一个简单的线性图,只需指定你要使用的数字,让 Matplotlib 来完成剩下的工作:
mpl_squares.py
import matplotlib.pyplot as plt
squares = [1, 4, 9, 16, 25]
❶ fig, ax = plt.subplots()
ax.plot(squares)
plt.show()
我们首先导入 pyplot 模块,并使用别名 plt,这样就不需要重复输入 pyplot 了。(你会在在线示例中经常看到这种约定,所以我们也在这里使用它。)pyplot 模块包含许多函数,帮助生成图表和图形。
我们创建一个名为 squares 的列表来保存我们要绘制的数据。接着,我们遵循 Matplotlib 中的另一个常见约定,调用 subplots() 函数 ❶。这个函数可以在同一图形中生成一个或多个子图。变量 fig 代表整个图形,即生成的所有子图集合。变量 ax 代表图形中的一个单独子图;这是我们大多数时候用来定义和自定义单个图表的变量。
然后,我们使用plot()方法,它会尝试以有意义的方式绘制给定的数据。函数plt.show()打开 Matplotlib 的查看器并显示图表,如图 15-1 所示。查看器允许你缩放和浏览图表,并且你可以通过点击磁盘图标保存你喜欢的任何图表图像。

图 15-1:在 Matplotlib 中,你可以制作的最简单的图表之一
更改标签类型和线条粗细
尽管图 15-1 中的图表显示数字在增加,但标签类型太小,线条也有些细,难以轻松阅读。幸运的是,Matplotlib 允许你调整可视化的每一个特性。
我们将使用一些可用的自定义选项来提高图表的可读性。让我们从添加标题和标注坐标轴开始:
mpl_squares.py
import matplotlib.pyplot as plt
squares = [1, 4, 9, 16, 25]
fig, ax = plt.subplots()
❶ ax.plot(squares, linewidth=3)
# Set chart title and label axes.
❷ ax.set_title("Square Numbers", fontsize=24)
❸ ax.set_xlabel("Value", fontsize=14)
ax.set_ylabel("Square of Value", fontsize=14)
# Set size of tick labels.
❹ ax.tick_params(labelsize=14)
plt.show()
linewidth 参数控制 plot() 生成的线条的粗细 ❶。一旦图表生成,仍然有许多方法可以修改图表,直到它被展示出来。set_title() 方法为整个图表设置标题 ❷。fontsize 参数(在代码中反复出现)控制图表中各个元素的文字大小。
set_xlabel() 和 set_ylabel() 方法允许你为每个坐标轴设置标题 ❸,而 tick_params() 方法用于设置刻度线样式 ❹。这里,tick_params() 将两个坐标轴上的刻度标签字体大小设置为 14。
如图 15-2 所示,生成的图表更容易阅读了。标签字体更大,线条图更粗了。通常,值得尝试调整这些值,看看在最终图表中哪些效果最好。

图 15-2:现在图表更容易阅读了。
修正图表
现在我们能更清晰地阅读图表,发现数据并没有正确绘制。注意在图表的末尾,4.0 的平方被错误地显示为 25!让我们来修正这个问题。
当你给plot()传递一个数字序列时,它会假设第一个数据点对应的x值为 0,但我们的第一个点对应的x值是 1。我们可以通过给plot()提供用于计算平方的输入和输出值来覆盖默认行为:
mpl_squares.py
import matplotlib.pyplot as plt
input_values = [1, 2, 3, 4, 5]
squares = [1, 4, 9, 16, 25]
fig, ax = plt.subplots()
ax.plot(input_values, squares, linewidth=3)
# Set chart title and label axes.
*--snip--*
现在,plot()不需要假设输出的数字是如何生成的。生成的图表,如图 15-3 所示,是正确的。

图 15-3:数据现在已经正确绘制。
你可以在调用plot()时指定多个参数,并在生成图表后使用多种方法来定制图表。我们将在本章中继续探索这些定制方法,并与更有趣的数据集一起工作。
使用内置样式
Matplotlib 有许多预定义的样式可供使用。这些样式包含各种默认设置,涵盖背景颜色、网格线、线宽、字体、字号等。它们可以让你的可视化效果更具吸引力,而无需太多的自定义。要查看所有可用样式的完整列表,请在终端会话中运行以下命令:
>>> **import matplotlib.pyplot as plt**
>>> **plt.style.available**
['Solarize_Light2', '_classic_test_patch', '_mpl-gallery',
`--snip--`
要使用这些样式中的任何一个,只需在调用subplots()之前添加一行代码:
mpl_squares.py
import matplotlib.pyplot as plt
input_values = [1, 2, 3, 4, 5]
squares = [1, 4, 9, 16, 25]
plt.style.use('seaborn')
fig, ax = plt.subplots()
*--snip--*
这段代码生成了如图 15-4 所示的图表。提供了多种样式;可以尝试这些样式,找到你喜欢的样式。

图 15-4:内置的 seaborn 样式
使用scatter()绘制和样式化单个点
有时候,根据某些特征绘制和样式化单个点是很有用的。例如,你可以用一种颜色绘制较小的值,另一种颜色绘制较大的值。你还可以用一组样式选项绘制大型数据集,然后通过重新绘制个别点并使用不同的选项来强调它们。
要绘制一个单独的点,可以将该点的单个x和y值传递给scatter():
scatter_squares.py
import matplotlib.pyplot as plt
plt.style.use('seaborn')
fig, ax = plt.subplots()
ax.scatter(2, 4)
plt.show()
让我们为输出添加样式,使其更有趣。我们将添加标题,标记坐标轴,并确保所有文本都足够大以便阅读:
import matplotlib.pyplot as plt
plt.style.use('seaborn')
fig, ax = plt.subplots()
❶ ax.scatter(2, 4, s=200)
# Set chart title and label axes.
ax.set_title("Square Numbers", fontsize=24)
ax.set_xlabel("Value", fontsize=14)
ax.set_ylabel("Square of Value", fontsize=14)
# Set size of tick labels.
ax.tick_params(labelsize=14)
plt.show()
我们调用scatter()并使用s参数设置绘制图表时使用的点的大小❶。现在运行scatter_squares.py,你应该能看到图表中心有一个单独的点,如图 15-5 所示。

图 15-5:绘制一个单独的点
使用scatter()绘制一系列点
要绘制一系列点,我们可以将独立的x和y值列表传递给scatter(),像这样:
scatter_squares.py
import matplotlib.pyplot as plt
x_values = [1, 2, 3, 4, 5]
y_values = [1, 4, 9, 16, 25]
plt.style.use('seaborn')
fig, ax = plt.subplots()
ax.scatter(x_values, y_values, s=100)
# Set chart title and label axes.
*--snip--*
x_values列表包含待平方的数字,而y_values包含每个数字的平方。当这些列表传递给scatter()时,Matplotlib 会在绘制每个点时从每个列表中读取一个值。待绘制的点为(1,1),(2,4),(3,9),(4,16)和(5,25);图 15-6 显示了结果。

图 15-6:具有多个点的散点图
自动计算数据
手动编写列表可能效率低下,尤其是当我们有很多点时。与其逐个写出每个值,不如使用循环来进行计算。
以下是使用 1,000 个点时的效果:
scatter_squares.py
import matplotlib.pyplot as plt
❶ x_values = range(1, 1001)
y_values = [x**2 for x in x_values]
plt.style.use('seaborn')
fig, ax = plt.subplots()
❷ ax.scatter(x_values, y_values, s=10)
# Set chart title and label axes.
*--snip--*
# Set the range for each axis.
❸ ax.axis([0, 1100, 0, 1_100_000])
plt.show()
我们从一个包含 1 到 1,000 的数字范围的x值开始❶。接下来,列表推导通过循环遍历x值(for x in x_values),将每个数字平方(x**2),并将结果赋值给y_values。然后,我们将输入和输出列表传递给scatter()❷。由于这是一个大数据集,我们使用较小的点大小。
在显示图表之前,我们使用axis()方法指定每个轴的范围❸。axis()方法需要四个值:x轴和y轴的最小值和最大值。在这里,我们将x轴范围设置为 0 到 1,100,y轴范围设置为 0 到 1,100,000。图 15-7 显示了结果。

图 15-7:Python 绘制 1,000 个点和绘制 5 个点一样容易。
自定义刻度标签
当轴上的数字足够大时,Matplotlib 默认使用科学计数法表示刻度标签。这通常是一个好事,因为用常规表示法表示大数字会占用可视化中大量不必要的空间。
几乎每个图表元素都可以自定义,因此如果你更喜欢常规表示法,可以告诉 Matplotlib 继续使用普通表示法:
*--snip--*
# Set the range for each axis.
ax.axis([0, 1100, 0, 1_100_000])
ax.ticklabel_format(style='plain')
plt.show()
ticklabel_format()方法允许你覆盖任何图表的默认刻度标签样式。
定义自定义颜色
要更改点的颜色,将color参数传递给scatter(),并使用引号括起颜色名称,如下所示:
ax.scatter(x_values, y_values, color='red', s=10)
你还可以使用 RGB 颜色模型定义自定义颜色。要定义颜色,将color参数传递一个包含三个浮动值的元组(分别代表红色、绿色和蓝色),这些值的范围是 0 到 1。例如,以下代码行创建一个包含浅绿色点的图:
ax.scatter(x_values, y_values, color=(0, 0.8, 0), s=10)
靠近 0 的值会产生较暗的颜色,而靠近 1 的值会产生较浅的颜色。
使用色彩图
色彩图是一个渐变的颜色序列,从起始颜色到结束颜色。在可视化中,色彩图用于强调数据中的模式。例如,你可以将低值设为浅色,将高值设为深色。使用色彩图可以确保可视化中的所有点在一个精心设计的颜色尺度上平滑、准确地变化。
pyplot 模块包含一组内置的颜色映射。要使用这些颜色映射之一,你需要指定 pyplot 如何根据数据集中的每个点的 y 值来分配颜色。以下是如何根据每个点的 y 值来为其分配颜色:
scatter_squares.py
*--snip--*
plt.style.use('seaborn')
fig, ax = plt.subplots()
ax.scatter(x_values, y_values, c=y_values, cmap=plt.cm.Blues, s=10)
# Set chart title and label axes.
*--snip--*
c 参数类似于 color,但它用于将一系列值与颜色映射关联。我们将 y 值列表传递给 c,然后通过 cmap 参数告诉 pyplot 使用哪种颜色映射。此代码将较低 y 值的点颜色设置为浅蓝色,将较高 y 值的点颜色设置为深蓝色。图 15-8 显示了结果图。

图 15-8:使用 Blues 颜色映射的图像
自动保存你的图像
如果你想将图像保存到文件而不是在 Matplotlib 查看器中显示它,可以使用 plt.savefig() 替代 plt.show():
plt.savefig('squares_plot.png', bbox_inches='tight')
第一个参数是图像文件的文件名,图像将保存在与 scatter_squares.py 相同的目录中。第二个参数用于修剪图像周围的额外空白。如果你希望保留图像周围的空白,可以省略此参数。你也可以使用 Path 对象调用 savefig(),并将输出文件保存到系统中的任何位置。
随机漫步
在本节中,我们将使用 Python 生成随机漫步的数据,并使用 Matplotlib 创建该数据的视觉表示。随机漫步 是由一系列简单决策决定的路径,每个决策完全依赖于随机性。你可以把随机漫步想象成一只迷失的蚂蚁,如果它每走一步都是随机选择方向的话,它走的路径就是随机漫步。
随机漫步在自然界、物理学、生物学、化学和经济学中有许多实际应用。例如,漂浮在水滴上的花粉粒子因不断受到水分子推动而在水面上移动。水滴中的分子运动是随机的,因此花粉粒子在水面上留下的轨迹就是一个随机漫步。我们接下来编写的代码将模拟许多现实世界的情况。
创建 RandomWalk 类
为了创建一个随机漫步,我们将创建一个 RandomWalk 类,该类将做出关于漫步方向的随机决定。这个类需要三个属性:一个变量来跟踪漫步中的点数,两个列表用来存储漫步中每个点的 x 和 y 坐标。
我们只需要为 RandomWalk 类编写两个方法:__init__() 方法和 fill_walk() 方法,后者将计算漫步中的各个点。我们先从 __init__() 方法开始:
random_walk.py
❶ from random import choice
class RandomWalk:
"""A class to generate random walks."""
❷ def __init__(self, num_points=5000):
"""Initialize attributes of a walk."""
self.num_points = num_points
# All walks start at (0, 0).
❸ self.x_values = [0]
self.y_values = [0]
为了做出随机决策,我们将在一个列表中存储可能的移动,并使用choice()函数(来自random模块)来决定每次走一步时的移动方向❶。我们将默认的步数设置为5000,这个值足够大,可以生成一些有趣的图案,同时又足够小,可以快速生成游走❷。然后我们创建两个列表来保存* x 和 y *的值,并将每次游走的起点设为(0, 0)❸。
选择方向
我们将使用fill_walk()方法来确定游走中的完整点序列。将此方法添加到random_walk.py中:
random_walk.py
def fill_walk(self):
"""Calculate all the points in the walk."""
# Keep taking steps until the walk reaches the desired length.
❶ while len(self.x_values) < self.num_points:
# Decide which direction to go, and how far to go.
❷ x_direction = choice([1, -1])
x_distance = choice([0, 1, 2, 3, 4])
❸ x_step = x_direction * x_distance
y_direction = choice([1, -1])
y_distance = choice([0, 1, 2, 3, 4])
❹ y_step = y_direction * y_distance
# Reject moves that go nowhere.
❺ if x_step == 0 and y_step == 0:
continue
# Calculate the new position.
❻ x = self.x_values[-1] + x_step
y = self.y_values[-1] + y_step
self.x_values.append(x)
self.y_values.append(y)
我们首先设置一个循环,直到游走填充了正确数量的点❶。fill_walk()的主要部分告诉 Python 如何模拟四个随机决策:游走是向右还是向左?在该方向上将移动多远?游走是向上还是向下?在该方向上将移动多远?
我们使用choice([1, -1])来为x_direction选择一个值,返回值为 1 表示向右移动,返回值为−1 表示向左移动❷。接着,choice([0, 1, 2, 3, 4])会随机选择一个距离来决定在该方向上移动的步数。我们将该值赋给x_distance。包括 0 的选择允许步伐仅在一个轴上移动。
我们通过将运动方向与选定的距离相乘来确定每一步在* x 和 y *方向上的步长❸❹。x_step为正值表示向右移动,负值表示向左移动,0 表示垂直移动。y_step为正值表示向上移动,负值表示向下移动,0 表示水平移动。如果x_step和y_step的值都为 0,则表示游走没有前进;在这种情况下,我们会继续循环❺。
为了获得下一个游走的* x 值,我们将x_step中的值加到x_values中最后存储的值❻, y *值同理。获得新的点的坐标后,我们将它们附加到x_values和y_values中。
绘制随机游走
以下是绘制游走中所有点的代码:
rw_visual.py
import matplotlib.pyplot as plt
from random_walk import RandomWalk
# Make a random walk.
❶ rw = RandomWalk()
rw.fill_walk()
# Plot the points in the walk.
plt.style.use('classic')
fig, ax = plt.subplots()
❷ ax.scatter(rw.x_values, rw.y_values, s=15)
❸ ax.set_aspect('equal')
plt.show()
我们首先导入pyplot和RandomWalk。然后我们创建一个随机游走并将其赋值给rw❶,确保调用fill_walk()。为了可视化游走,我们将游走的* x 和 y *值传递给scatter()并选择合适的点大小❷。默认情况下,Matplotlib 会独立缩放每个坐标轴。但这种方式会使大多数游走在水平方向或垂直方向上拉伸。这里我们使用set_aspect()方法来指定两个坐标轴之间的刻度间距应保持相等❸。
图 15-9 展示了生成的包含 5,000 个点的图表。本节中的图像省略了 Matplotlib 的查看器,但当你运行rw_visual.py时,你仍然会看到它。

图 15-9:一个包含 5,000 个点的随机游走
生成多个随机游走
每次随机漫步都是不同的,探索生成的各种模式非常有趣。使用前面的代码生成多个漫步而不需要多次运行程序的一种方式是将其包装在while循环中,如下所示:
rw_visual.py
import matplotlib.pyplot as plt
from random_walk import RandomWalk
# Keep making new walks, as long as the program is active.
while True:
# Make a random walk.
*--snip--*
plt.show()
keep_running = input("Make another walk? (y/n): ")
if keep_running == 'n':
break
这段代码生成一个随机漫步,将其显示在 Matplotlib 的查看器中,并暂停等待查看器关闭。当你关闭查看器时,系统会询问你是否要生成另一个漫步。如果你生成几个漫步,你应该会看到一些漫步保持在起点附近,一些漫步主要朝一个方向走,某些漫步有细长的部分连接着多个点,还有很多其他种类的漫步。当你想结束程序时,按 N 键。
漫步的样式
在这一节中,我们将自定义我们的图表,突出显示每次随机漫步的关键特征,并淡化干扰元素。为此,我们确定需要强调的特征,如漫步的起点、终点以及所走的路径。接下来,我们确定需要淡化的特征,如刻度标记和标签。最终结果应该是一个简单的视觉表示,清晰地传达每次随机漫步的路径。
给点着色
我们将使用颜色图来展示漫步中各点的顺序,并去除每个点的黑色轮廓,使点的颜色更加清晰。为了根据点在漫步中的位置为其着色,我们将包含每个点位置的列表传递给c参数。由于这些点是按顺序绘制的,这个列表只包含从 0 到 4,999 的数字:
rw_visual.py
*--snip--*
while True:
# Make a random walk.
rw = RandomWalk()
rw.fill_walk()
# Plot the points in the walk.
plt.style.use('classic')
fig, ax = plt.subplots()
❶ point_numbers = range(rw.num_points)
ax.scatter(rw.x_values, rw.y_values, c=point_numbers, cmap=plt.cm.Blues,
edgecolors='none', s=15)
ax.set_aspect('equal')
plt.show()
*--snip--*
我们使用range()生成一个与漫步点数相等的数字列表❶。我们将这个列表赋值给point_numbers,并将其用于设置每个点的颜色。我们将point_numbers传递给c参数,使用Blues颜色图,然后传递edgecolors='none'来去除每个点周围的黑色轮廓。结果是一个从浅蓝到深蓝变化的图表,准确地显示了漫步从起点到终点的过程。如图 15-10 所示。

图 15-10: 使用Blues颜色图着色的随机漫步
绘制起点和终点
除了通过颜色来标识每个点在漫步中的位置外,显示每次漫步的起点和终点也是很有用的。为此,我们可以在主要序列绘制完成后,单独绘制第一个和最后一个点。我们将使终点的点更大,并使用不同的颜色使其更加突出:
rw_visual.py
*--snip--*
while True:
*--snip--*
ax.scatter(rw.x_values, rw.y_values, c=point_numbers, cmap=plt.cm.Blues,
edgecolors='none', s=15)
ax.set_aspect('equal')
# Emphasize the first and last points.
ax.scatter(0, 0, c='green', edgecolors='none', s=100)
ax.scatter(rw.x_values[-1], rw.y_values[-1], c='red', edgecolors='none',
s=100)
plt.show()
*--snip--*
为了显示起点,我们将点(0, 0)以绿色和比其他点更大的尺寸(s=100)绘制。为了标记终点,我们也将最后的 x 和 y 值以红色绘制,尺寸为 100。确保将这段代码插入到plt.show()调用之前,以便起点和终点能够绘制在所有其他点的上面。
当你运行这段代码时,你应该能够准确找到每次漫步的开始和结束位置。如果这些端点不够显眼,可以调整它们的颜色和大小,直到它们清晰可见。
清理坐标轴
让我们移除此图中的坐标轴,以避免它们分散每次漫步的注意力。以下是隐藏坐标轴的方法:
rw_visual.py
*--snip--*
while True:
*--snip--*
ax.scatter(rw.x_values[-1], rw.y_values[-1], c='red', edgecolors='none',
s=100)
# Remove the axes.
ax.get_xaxis().set_visible(False)
ax.get_yaxis().set_visible(False)
plt.show()
*--snip--*
为了修改坐标轴,我们使用 ax.get_xaxis() 和 ax.get_yaxis() 方法来获取每个坐标轴,然后通过链式调用 set_visible() 方法使每个坐标轴变得不可见。随着你继续进行可视化工作,你将经常看到这种方法链的使用,用来定制可视化的不同方面。
现在运行 rw_visual.py;你应该能看到一系列没有坐标轴的图表。
添加绘图点
让我们增加点的数量,以便为我们提供更多的数据来处理。为此,我们在创建 RandomWalk 实例时增加 num_points 的值,并在绘制图表时调整每个点的大小:
rw_visual.py
*--snip--*
while True:
# Make a random walk.
rw = RandomWalk(50_000)
rw.fill_walk()
# Plot the points in the walk.
plt.style.use('classic')
fig, ax = plt.subplots()
point_numbers = range(rw.num_points)
ax.scatter(rw.x_values, rw.y_values, c=point_numbers, cmap=plt.cm.Blues,
edgecolors='none', s=1)
*--snip--*
这个例子创建了一个包含 50,000 个点的随机漫步,并且将每个点的大小设置为 s=1。最终的随机漫步呈现出云雾状,如 图 15-11 所示。我们已经从一个简单的散点图中创造出了一幅艺术作品!
尝试调整此代码,看看你可以在系统开始显著变慢或图表失去视觉吸引力之前,增加多少步数。

图 15-11:50,000 个点的随机漫步
调整大小以填充屏幕
如果可视化能够很好地适应屏幕,它在传达数据模式时会更加有效。为了使绘图窗口更好地适应屏幕,你可以调整 Matplotlib 输出的大小。这是在 subplots() 调用中完成的:
fig, ax = plt.subplots(figsize=(15, 9))
在创建图表时,你可以将 subplots() 的 figsize 参数传递进去,这样可以设置图形的大小。figsize 参数接受一个元组,告诉 Matplotlib 绘图窗口的尺寸(单位为英寸)。
Matplotlib 假设你的屏幕分辨率为每英寸 100 像素;如果这段代码没有给出准确的图表大小,请根据需要调整数字。或者,如果你知道系统的分辨率,可以使用 dpi 参数将分辨率传递给 subplots():
fig, ax = plt.subplots(figsize=(10, 6), dpi=128)
这应该有助于更有效地利用屏幕上可用的空间。
使用 Plotly 掷骰子
在本节中,我们将使用 Plotly 来制作交互式可视化。Plotly 特别适用于在浏览器中显示的可视化,因为这些可视化会自动缩放以适应查看者的屏幕。它们也是交互式的;当用户将鼠标悬停在屏幕上的某些元素上时,这些元素的信息会被高亮显示。我们将使用 Plotly Express 来构建最初的可视化,Plotly Express 是 Plotly 的一个子集,专注于以尽可能少的代码生成图表。一旦确认我们的图表正确无误,我们将像使用 Matplotlib 一样自定义输出。
在这个项目中,我们将分析投掷骰子的结果。当你投掷一个普通的六面骰时,每个数字从 1 到 6 出现的概率是相等的。然而,当你使用两个骰子时,某些数字比其他数字更可能出现。我们将尝试通过生成一个代表投掷骰子的 数据集来确定哪些数字最有可能出现。然后我们将绘制大量投掷的结果,以确定哪些结果比其他结果更有可能。
这项工作有助于建模涉及骰子的游戏,但其核心思想也适用于任何形式的机会游戏,如扑克牌游戏。它还与许多现实世界中的情况相关,这些情况中的随机性扮演着重要角色。
安装 Plotly
使用 pip 安装 Plotly,就像安装 Matplotlib 一样:
$ **python -m pip install --user plotly**
$ **python -m pip install --user pandas**
Plotly Express 依赖于 pandas,这是一个高效处理数据的库,因此我们也需要安装它。如果在安装 Matplotlib 时使用了 python3 或其他版本,确保在这里使用相同的命令。
要查看 Plotly 能实现哪些可视化,请访问 plotly.com/python 上的图表类型画廊。每个示例都包括源代码,您可以查看 Plotly 如何生成这些可视化。
创建 Die 类
我们将创建以下 Die 类来模拟投掷一个骰子:
die.py
from random import randint
class Die:
"""A class representing a single die."""
❶ def __init__(self, num_sides=6):
"""Assume a six-sided die."""
self.num_sides = num_sides
def roll(self):
""""Return a random value between 1 and number of sides."""
❷ return randint(1, self.num_sides)
__init__() 方法接受一个可选参数 ❶。在 Die 类中,当创建骰子的实例时,如果没有传入参数,骰子的面数默认为六个。如果传入了参数,则该值将设置骰子的面数。(骰子的命名取决于面数:六面骰为 D6,八面骰为 D8,依此类推。)
roll() 方法使用 randint() 函数返回一个介于 1 和面数之间的随机数 ❷。此函数可以返回起始值(1)、结束值(num_sides)或介于两者之间的任何整数。
投掷骰子
在创建基于 Die 类的可视化之前,让我们先投掷一个 D6,打印结果,并检查这些结果是否合理:
die_visual.py
from die import Die
# Create a D6.
❶ die = Die()
# Make some rolls, and store results in a list.
results = []
❷ for roll_num in range(100):
result = die.roll()
results.append(result)
print(results)
我们创建一个 Die 类的实例,默认有六个面 ❶。然后我们投掷骰子 100 次 ❷,并将每次投掷的结果存储在 results 列表中。以下是一个结果示例:
[4, 6, 5, 6, 1, 5, 6, 3, 5, 3, 5, 3, 2, 2, 1, 3, 1, 5, 3, 6, 3, 6, 5, 4, 1, 1, 4, 2, 3, 6, 4, 2, 6, 4, 1, 3, 2, 5, 6, 3, 6, 2, 1, 1, 3, 4, 1, 4, 3, 5, 1, 4, 5, 5, 2, 3, 3, 1, 2, 3, 5, 6, 2, 5, 6, 1, 3, 2, 1, 1, 1, 6, 5, 5, 2, 2, 6, 4, 1, 4, 5, 1, 1, 1, 4, 5, 3, 3, 1, 3, 5, 4, 5, 6, 5, 4, 1, 5, 1, 2]
快速扫描这些结果表明 Die 类似乎在正常工作。我们看到了 1 和 6,因此我们知道返回的最小值和最大值是正确的,而且因为没有看到 0 或 7,我们知道所有结果都在正确的范围内。我们还看到了从 1 到 6 的每个数字,这表示所有可能的结果都有出现。接下来,我们将确定每个数字出现的具体次数。
分析结果
我们将通过统计每个数字出现的次数来分析投掷一个 D6 骰子的结果:
die_visual.py
*--snip--*
# Make some rolls, and store results in a list.
results = []
❶ for roll_num in range(1000):
result = die.roll()
results.append(result)
# Analyze the results.
frequencies = []
❷ poss_results = range(1, die.num_sides+1)
for value in poss_results:
❸ frequency = results.count(value)
❹ frequencies.append(frequency)
print(frequencies)
因为我们不再打印结果,所以我们可以将模拟掷骰的次数增加到1000 ❶。为了分析这些掷骰结果,我们创建了一个空列表frequencies来存储每个结果出现的次数。接着,我们生成所有可能的结果;在这个例子中,这些结果是从1到骰子面数的所有数字 ❷。我们遍历这些可能的值,统计每个数字在results中出现的次数 ❸,然后将此值添加到frequencies中 ❹。我们在生成可视化之前先打印出这个列表:
[155, 167, 168, 170, 159, 181]
这些结果看起来是合理的:我们看到六个频率,每个频率对应掷 D6 骰子时的一个可能数字。我们还看到没有任何一个频率显著高于其他频率。现在让我们将这些结果可视化。
创建直方图
现在我们已经拥有所需的数据,我们可以仅通过几行代码使用 Plotly Express 生成可视化图表:
die_visual.py
import plotly.express as px
from die import Die
*--snip--*
for value in poss_results:
frequency = results.count(value)
frequencies.append(frequency)
# Visualize the results.
fig = px.bar(x=poss_results, y=frequencies)
fig.show()
我们首先导入plotly.express模块,使用传统别名px。然后,我们使用px.bar()函数创建条形图。在该函数的最简单使用中,我们只需要传入一组x-值和一组y-值。这里的x-值是掷单个骰子可能的结果,而y-值是每个可能结果的频率。
最后一行调用了fig.show(),这告诉 Plotly 渲染结果图表为 HTML 文件,并在新浏览器标签页中打开该文件。结果如图 15-12 所示。
这只是一个非常简单的图表,当然也不完整。但这正是 Plotly Express 的设计初衷;你写几行代码,查看图表,确保它准确地表示了你的数据。如果你喜欢看到的效果,可以继续自定义图表的标签和样式。如果你想探索其他可能的图表类型,你可以在没有花费额外时间进行自定义的情况下尝试。现在就可以通过将px.bar()更改为px.scatter()或px.line()等来试试。你可以在plotly.com/python/plotly-express找到所有可用图表类型的完整列表。
该图表是动态且交互式的。如果你调整浏览器窗口的大小,图表会自动调整大小以适应可用空间。如果你将鼠标悬停在任何一个柱状条上,你将看到一个弹出窗口,突出显示与该柱状条相关的特定数据。

图 15-12:Plotly Express 生成的初始图表
自定义图表
现在我们知道图表类型正确,数据也被准确地呈现出来,我们可以专注于为图表添加适当的标签和样式。
使用 Plotly 自定义图表的第一种方式是在初始生成图表时使用一些可选参数,这里是px.bar()。以下是如何添加一个总体标题和每个轴的标签:
die_visual.py
*--snip--*
# Visualize the results.
❶ title = "Results of Rolling One D6 1,000 Times"
❷ labels = {'x': 'Result', 'y': 'Frequency of Result'}
fig = px.bar(x=poss_results, y=frequencies, title=title, labels=labels)
fig.show()
我们首先定义了我们想要的标题,这里赋值给 title ❶。为了定义轴标签,我们写了一个字典 ❷。字典中的键是我们想要自定义的标签,值是我们想使用的自定义标签。这里,我们为 x 轴赋予了 Result 标签,为 y 轴赋予了 Frequency of Result 标签。调用 px.bar() 时,现在包括了可选参数 title 和 labels。
现在,当绘图生成时,它包括了适当的标题和每个轴的标签,如图 15-13 所示。

图 15-13:使用 Plotly 创建的简单柱状图
掷两个骰子
掷两个骰子会得到更大的数字,并且结果的分布也不同。让我们修改代码,创建两个 D6 骰子,模拟我们掷一对骰子的方式。每次掷骰子时,我们将两个数字(每个骰子上的一个数字)相加,并将和存储在 results 中。将 die_visual.py 保存为 dice_visual.py,并进行如下更改:
dice_visual.py
import plotly.express as px
from die import Die
# Create two D6 dice.
die_1 = Die()
die_2 = Die()
# Make some rolls, and store results in a list.
results = []
for roll_num in range(1000):
❶ result = die_1.roll() + die_2.roll()
results.append(result)
# Analyze the results.
frequencies = []
❷ max_result = die_1.num_sides + die_2.num_sides
❸ poss_results = range(2, max_result+1)
for value in poss_results:
frequency = results.count(value)
frequencies.append(frequency)
# Visualize the results.
title = "Results of Rolling Two D6 Dice 1,000 Times"
labels = {'x': 'Result', 'y': 'Frequency of Result'}
fig = px.bar(x=poss_results, y=frequencies, title=title, labels=labels)
fig.show()
在创建了两个 Die 实例后,我们掷骰子并计算每次掷骰子的和 ❶。最小的可能结果(2)是每个骰子上的最小数字之和。最大的可能结果(12)是每个骰子上的最大数字之和,我们将其赋值给 max_result ❷。变量 max_result 使得生成 poss_results 的代码更容易阅读 ❸。我们本可以写 range(2, 13),但这只适用于两个 D6 骰子。在模拟现实世界情况时,最好编写能轻松模拟多种情况的代码。这段代码允许我们模拟掷一对具有任意面数的骰子。
运行此代码后,您应该会看到一个类似于图 15-14 的图表。

图 15-14:模拟掷两个六面骰子 1,000 次的结果
该图展示了您掷一对 D6 骰子时,可能得到的结果的大致分布。正如您所见,掷出 2 或 12 的可能性最小,而掷出 7 的可能性最大。之所以这样,是因为掷出 7 有六种方式:1 和 6,2 和 5,3 和 4,4 和 3,5 和 2,6 和 1。
进一步自定义
我们刚刚生成的图表有一个问题需要解决。由于现在有 11 根柱子,x 轴的默认布局设置导致一些柱子没有标签。虽然默认设置适用于大多数可视化,但这个图表如果将所有柱子标注上,会看起来更好。
Plotly 有一个 update_layout() 方法,可以在图形创建后对其进行各种更新。以下是告诉 Plotly 给每个柱子添加标签的方法:
dice_visual.py
*--snip--*
fig = px.bar(x=poss_results, y=frequencies, title=title, labels=labels)
# Further customize chart.
fig.update_layout(xaxis_dtick=1)
fig.show()
update_layout()方法作用于fig对象,该对象表示整个图表。在这里,我们使用了xaxis_dtick参数,该参数指定了x轴上刻度线之间的间距。我们将间距设置为1,这样每个条形图上都会有标签。当你再次运行dice_visual.py时,你应该能看到每个条形图上都有标签。
掷不同大小的骰子
让我们创建一个六面骰和一个十面骰,看看当我们掷它们 50,000 次时会发生什么:
dice_visual_d6d10.py
import plotly.express as px
from die import Die
# Create a D6 and a D10.
die_1 = Die()
❶ die_2 = Die(10)
# Make some rolls, and store results in a list.
results = []
for roll_num in range(50_000):
result = die_1.roll() + die_2.roll()
results.append(result)
# Analyze the results.
*--snip--*
# Visualize the results.
❷ title = "Results of Rolling a D6 and a D10 50,000 Times"
labels = {'x': 'Result', 'y': 'Frequency of Result'}
*--snip--*
为了制作 D10,我们在创建第二个Die实例时传递参数10 ❶,并将第一个循环更改为模拟 50,000 次掷骰,而不是 1,000 次。我们还更改了图表的标题 ❷。
图 15-15 显示了结果图表。与只有一个最可能结果的情况不同,这里有五个这样的结果。这是因为掷出最小值(1 和 1)和最大值(6 和 10)的方法只有一种,但较小的骰子限制了生成中间数字的方式。掷出 7、8、9、10 或 11 有六种方式,这些是最常见的结果,你有相同的概率掷出其中的任何一个。

图 15-15:掷一个六面骰和一个十面骰 50,000 次的结果
我们使用 Plotly 模拟骰子掷出的能力,给了我们相当大的自由度来探索这一现象。在短短几分钟内,你可以使用各种骰子模拟大量的掷骰结果。
保存图形
当你得到一个喜欢的图形时,你可以通过浏览器将图表保存为 HTML 文件。但你也可以通过编程方式实现这一点。要将图表保存为 HTML 文件,只需将对fig.show()的调用替换为对fig.write_html()的调用:
fig.write_html('dice_visual_d6d10.xhtml')
write_html()方法需要一个参数:要写入的文件名。如果你只提供文件名,文件将保存在与.py文件相同的目录中。你也可以通过Path对象调用write_html(),并将输出文件保存到系统中的任何位置。
总结
在本章中,你学会了生成数据集并创建数据的可视化。你使用 Matplotlib 创建了简单的图表,并使用散点图探索了随机游走。你还使用 Plotly 创建了一个直方图,并用它来探索掷不同大小骰子的结果。
使用代码生成自己的数据集是一种有趣且强大的方式,可以模拟和探索各种现实世界的情况。在接下来的数据可视化项目中,保持警觉,看看你是否能找到可以通过代码模拟的情景。观察新闻媒体中的可视化内容,看看你能否识别出那些是使用类似于你在这些项目中学习的方式生成的。
在第十六章中,你将从在线源下载数据,并继续使用 Matplotlib 和 Plotly 探索这些数据。
第十六章:下载数据

在本章中,你将从在线来源下载数据集并创建该数据的可视化效果。你可以在线找到各种各样的数据,其中许多数据尚未得到彻底研究。能够分析这些数据将帮助你发现其他人尚未发现的模式和联系。
我们将访问并可视化存储在两种常见数据格式中的数据:CSV 和 JSON。我们将使用 Python 的 csv 模块处理存储在 CSV 格式中的天气数据,并分析两个不同地点的高低温度变化。然后,我们将使用 Matplotlib 基于下载的数据生成图表,显示两个不同环境中温度的变化:阿拉斯加的锡特卡和加利福尼亚的死亡谷。之后,在本章的后续部分,我们将使用 json 模块访问存储在 GeoJSON 格式中的地震数据,并使用 Plotly 绘制一张世界地图,显示最近地震的地点和震级。
在本章结束时,你将能够处理各种格式的数据集,并深入理解如何构建复杂的可视化。能够访问和可视化在线数据是处理各种现实世界数据集的基础。
CSV 文件格式
一种简单的文本文件数据存储方式是将数据写成由逗号分隔的一系列值,这就是所谓的 逗号分隔值。生成的文件即为 CSV 文件。例如,这里有一段以 CSV 格式存储的天气数据:
"USW00025333","SITKA AIRPORT, AK US","2021-01-01",,"44","40"
这是来自 2021 年 1 月 1 日,位于阿拉斯加锡特卡的天气数据摘录。它包括当天的最高温度和最低温度,以及当天的其他若干测量值。CSV 文件对人类来说可能阅读起来很繁琐,但程序可以快速、准确地处理并提取其中的信息。
我们将从一小部分以 CSV 格式记录的锡特卡天气数据开始;这些数据可以在本书的资源中找到,网址为 ehmatthes.github.io/pcc_3e。在你保存本章程序的文件夹中创建一个名为 weather_data 的文件夹,并将文件 sitka_weather_07-2021_simple.csv 复制到这个新文件夹中。(下载本书资源后,你将获得该项目所需的所有文件。)
解析 CSV 文件标题
Python 标准库中的 csv 模块解析 CSV 文件中的行,允许我们快速提取感兴趣的值。让我们从检查文件的第一行开始,这一行包含数据的标题。标题告诉我们数据包含的是什么类型的信息:
sitka_highs.py
from pathlib import Path
import csv
❶ path = Path('weather_data/sitka_weather_07-2021_simple.csv')
lines = path.read_text().splitlines()
❷ reader = csv.reader(lines)
❸ header_row = next(reader)
print(header_row)
我们首先导入 Path 和 csv 模块。然后我们创建一个 Path 对象,它指向 weather_data 文件夹,并指向我们想要处理的具体天气数据文件 ❶。我们读取文件,并链式调用 splitlines() 方法,以获取文件中所有行的列表,并将其赋值给 lines。
接下来,我们构建一个reader对象 ❷。这个对象可以用来解析文件中的每一行。要创建一个reader对象,调用函数csv.reader()并将 CSV 文件的行列表传递给它。
当给定一个reader对象时,next()函数返回文件中的下一行,从文件的开头开始。在这里,我们只调用了一次next(),因此我们得到了文件的第一行,其中包含文件头 ❸。我们将返回的数据赋值给header_row。如你所见,header_row包含了有意义的、与天气相关的标题,告诉我们每行数据所包含的信息:
['STATION', 'NAME', 'DATE', 'TAVG', 'TMAX', 'TMIN']
reader对象处理文件中的第一行逗号分隔值,并将每个值存储为列表中的一项。标题STATION代表记录此数据的气象站的代码。该标题的位置告诉我们,每行的第一个值将是气象站的代码。NAME标题表明每行的第二个值是进行记录的气象站的名称。其余标题指定每次读取中记录了哪些类型的信息。现在我们最关心的数据是日期(DATE)、最高温度(TMAX)和最低温度(TMIN)。这是一个简单的数据集,仅包含与温度相关的数据。当你下载自己的天气数据时,你可以选择包括与风速、风向和降水数据等相关的其他测量项。
打印标题及其位置
为了更容易理解文件头数据,让我们打印出每个标题及其在列表中的位置:
sitka_highs.py
*--snip--*
reader = csv.reader(lines)
header_row = next(reader)
for index, column_header in enumerate(header_row):
print(index, column_header)
enumerate()函数在遍历列表时返回每个项的索引和对应的值。(请注意,我们已删除了print(header_row)这一行,改用更详细的版本。)
下面是显示每个标题索引的输出:
0 STATION
1 NAME
2 DATE
3 TAVG
4 TMAX
5 TMIN
我们可以看到,日期及其对应的最高温度存储在第 2 列和第 4 列。为了探索这些数据,我们将处理sitka_weather_07-2021_simple.csv中的每一行数据,并提取索引为 2 和 4 的值。
提取和读取数据
现在我们知道了需要哪些数据列,让我们读取其中的一些数据。首先,我们将读取每天的最高温度:
sitka_highs.py
*--snip--*
reader = csv.reader(lines)
header_row = next(reader)
# Extract high temperatures.
❶ highs = []
❷ for row in reader:
❸ high = int(row[4])
highs.append(high)
print(highs)
我们创建一个名为highs的空列表 ❶,然后遍历文件中的其余行 ❷。reader对象从它在 CSV 文件中的当前位置继续,并自动返回当前行后的每一行。因为我们已经读取了标题行,循环将从第二行开始,实际数据也从这一行开始。在每次循环中,我们提取索引为 4 的数据显示,该索引对应标题TMAX,并将其赋值给变量high ❸。我们使用int()函数将存储为字符串的数据转换为数值格式,以便后续使用。然后我们将该值追加到highs列表中。
以下是当前存储在highs中的数据:
[61, 60, 66, 60, 65, 59, 58, 58, 57, 60, 60, 60, 57, 58, 60, 61, 63, 63, 70, 64, 59, 63, 61, 58, 59, 64, 62, 70, 70, 73, 66]
我们已经提取了每个日期的最高温度,并将每个值存储在列表中。现在,让我们创建一个关于这些数据的可视化图表。
在温度图表中绘制数据
为了可视化我们拥有的温度数据,我们首先使用 Matplotlib 绘制一个简单的每日最高温度图,如下所示:
sitka_highs.py
from pathlib import Path
import csv
import matplotlib.pyplot as plt
path = Path('weather_data/sitka_weather_07-2021_simple.csv')
lines = path.read_text().splitlines()
*--snip--*
# Plot the high temperatures.
plt.style.use('seaborn')
fig, ax = plt.subplots()
❶ ax.plot(highs, color='red')
# Format plot.
❷ ax.set_title("Daily High Temperatures, July 2021", fontsize=24)
❸ ax.set_xlabel('', fontsize=16)
ax.set_ylabel("Temperature (F)", fontsize=16)
ax.tick_params(labelsize=16)
plt.show()
我们将最高温度列表传递给plot(),并传递color='red'来将点绘制为红色❶。(我们将用红色绘制最高温度,蓝色绘制最低温度。)然后,我们指定一些其他的格式细节,比如标题、字体大小和标签❷,就像我们在第十五章中做的那样。由于我们还没有添加日期,所以我们不会标注X轴,但ax.set_xlabel()确实修改了字体大小,使默认标签更具可读性❸。图 16-1 显示了生成的图表:一张展示 2021 年 7 月阿拉斯加锡特卡每日最高温度的简单折线图。

图 16-1:显示 2021 年 7 月阿拉斯加锡特卡每日最高温度的折线图
datetime 模块
让我们向图表中添加日期,使其更加实用。天气数据文件中的第一行日期位于文件的第二行:
"USW00025333","SITKA AIRPORT, AK US","2021-07-01",,"61","53"
数据将作为字符串读取,因此我们需要一种方法将字符串"2021-07-01"转换为表示该日期的对象。我们可以使用datetime模块中的strptime()方法来构造一个表示 2021 年 7 月 1 日的对象。让我们看一下strptime()在终端会话中的工作方式:
>>> **from datetime import datetime**
>>> **first_date = datetime.strptime('2021-07-01', '%Y-%m-%d')**
>>> **print(first_date)**
2021-07-01 00:00:00
我们首先从datetime模块导入datetime类。然后,我们调用strptime()方法,并将包含我们要处理的日期的字符串作为第一个参数。第二个参数告诉 Python 日期的格式。在这个例子中,'%Y-'告诉 Python 在第一个破折号之前寻找四位数的年份;'%m-'表示第二个破折号之前是两位数的月份;'%d'表示字符串的最后部分是日期,范围从 1 到 31。
strptime()方法可以接受多种参数来决定如何解析日期。表 16-1 展示了这些参数的一部分。
表 16-1:datetime模块中的日期和时间格式化参数
| 参数 | 含义 |
|---|---|
%A |
星期几的名称,例如星期一 |
%B |
月份名称,例如一月 |
%m |
月份,数字表示(01 到 12) |
%d |
月份中的日期(01 到 31) |
%Y |
四位数的年份,例如 2019 |
%y |
两位数的年份,例如 19 |
%H |
小时(24 小时制,00 到 23) |
%I |
小时(12 小时制,01 到 12) |
%p |
AM 或 PM |
%M |
分钟(00 到 59) |
%S |
秒(00 到 61) |
绘制日期
我们可以通过提取每日最高温度的日期,并将这些日期用于X轴,来改善我们的图表:
sitka_highs.py
from pathlib import Path
import csv
from datetime import datetime
import matplotlib.pyplot as plt
path = Path('weather_data/sitka_weather_07-2021_simple.csv')
lines = path.read_text().splitlines()
reader = csv.reader(lines)
header_row = next(reader)
# Extract dates and high temperatures.
❶ dates, highs = [], []
for row in reader:
❷ current_date = datetime.strptime(row[2], '%Y-%m-%d')
high = int(row[4])
dates.append(current_date)
highs.append(high)
# Plot the high temperatures.
plt.style.use('seaborn')
fig, ax = plt.subplots()
❸ ax.plot(dates, highs, color='red')
# Format plot.
ax.set_title("Daily High Temperatures, July 2021", fontsize=24)
ax.set_xlabel('', fontsize=16)
❹ fig.autofmt_xdate()
ax.set_ylabel("Temperature (F)", fontsize=16)
ax.tick_params(labelsize=16)
plt.show()
我们创建了两个空列表,用来存储文件中的日期和高温数据 ❶。然后,我们将包含日期信息的数据(row[2])转换为 datetime 对象 ❷,并将其附加到 dates 中。接着,我们将日期和高温数据传递给 plot() ❸。调用 fig.autofmt_xdate() ❹ 会将日期标签以对角线方式绘制,以避免它们重叠。图 16-2 显示了改进后的图表。

图 16-2:图表更有意义,因为它在 x 轴上有了日期。
绘制更长时间跨度的数据
图表设置好之后,让我们加入更多数据,以获得对 Sitka 天气的更全面了解。将包含 Sitka 一整年天气数据的文件 sitka_weather_2021_simple.csv 复制到你存储本章程序数据的文件夹中。
现在我们可以生成全年天气的数据图表:
sitka_highs.py
*--snip--*
path = Path('weather_data/sitka_weather_2021_simple.csv')
lines = path.read_text().splitlines()
*--snip--*
# Format plot.
ax.set_title("Daily High Temperatures, 2021", fontsize=24)
ax.set_xlabel('', fontsize=16)
*--snip--*
我们修改文件名以使用新的数据文件 sitka_weather_2021_simple.csv,并更新图表的标题以反映其内容的变化。图 16-3 显示了生成的图表。

图 16-3:一年的数据
绘制第二组数据
我们可以通过包括低温数据使我们的图表更有用。我们需要从数据文件中提取低温数据,然后将其添加到图表中,如下所示:
sitka_highs_lows.py
*--snip--*
reader = csv.reader(lines)
header_row = next(reader)
# Extract dates, and high and low temperatures.
❶ dates, highs, lows = [], [], []
for row in reader:
current_date = datetime.strptime(row[2], '%Y-%m-%d')
high = int(row[4])
❷ low = int(row[5])
dates.append(current_date)
highs.append(high)
lows.append(low)
# Plot the high and low temperatures.
plt.style.use('seaborn')
fig, ax = plt.subplots()
ax.plot(dates, highs, color='red')
❸ ax.plot(dates, lows, color='blue')
# Format plot.
❹ ax.set_title("Daily High and Low Temperatures, 2021", fontsize=24)
*--snip--*
我们添加了空列表 lows 用来存放低温数据 ❶,然后从每行的第六个位置(row[5])提取并存储每个日期的低温数据 ❷。我们为低温数据添加了 plot() 调用,并将这些值设置为蓝色 ❸。最后,我们更新了标题 ❹。图 16-4 显示了生成的图表。

图 16-4:同一图表上的两组数据
在图表中填充阴影区域
在添加了两组数据后,我们现在可以查看每天的温度范围。让我们通过使用阴影来显示每天的最高温度和最低温度之间的范围,为图表画上完美的句号。为此,我们将使用 fill_between() 方法,它需要传入一系列 x 值和两组 y 值,并在两组 y 值之间填充区域:
sitka_highs_lows.py
*--snip--*
# Plot the high and low temperatures.
plt.style.use('seaborn')
fig, ax = plt.subplots()
❶ ax.plot(dates, highs, color='red', alpha=0.5)
ax.plot(dates, lows, color='blue', alpha=0.5)
❷ ax.fill_between(dates, highs, lows, facecolor='blue', alpha=0.1)
*--snip--*
alpha 参数控制颜色的透明度 ❶。alpha 值为 0 时完全透明,值为 1(默认)时完全不透明。通过将 alpha 设置为 0.5,我们让红色和蓝色的绘图线条看起来更轻。
我们将fill_between()应用于列表dates作为 x 值,然后是两组 y 值数据系列 highs 和 lows ❷。facecolor 参数决定了阴影区域的颜色;我们将其alpha值设置为 0.1,这样填充区域就可以连接两组数据系列,而不会干扰它们所代表的信息。图 16-5 显示了带有阴影区域的图表,其中高温和低温之间有一个填充区域。

图 16-5:两个数据集之间的区域已被阴影填充。
阴影有助于使两个数据集之间的范围立即显现。
错误检查
我们应该能够使用任何位置的数据运行 sitka_highs_lows.py 代码。但有些气象站收集的数据与其他站点不同,有些站点偶尔出现故障,未能收集到应收集的数据。缺失的数据可能会导致异常,进而导致程序崩溃,除非我们适当处理这些异常。
例如,让我们看看当我们尝试为加利福尼亚州的死亡谷生成温度图表时会发生什么。将文件death_valley_2021_simple.csv复制到你存储本章程序数据的文件夹中。
首先,让我们运行代码以查看此数据文件中包含的标题:
death_valley_highs_lows.py
from pathlib import Path
import csv
path = Path('weather_data/death_valley_2021_simple.csv')
lines = path.read_text().splitlines()
reader = csv.reader(lines)
header_row = next(reader)
for index, column_header in enumerate(header_row):
print(index, column_header)
以下是输出结果:
0 STATION
1 NAME
2 DATE
3 TMAX
4 TMIN
5 TOBS
日期位于相同的位置,索引为 2。但最高和最低气温位于索引 3 和 4,因此我们需要更改代码中的索引以反映这些新位置。此站点不包括当天的平均温度,而是包括TOBS,即特定观察时间的读数。
更改 sitka_highs_lows.py,使用我们刚刚注意到的索引生成死亡谷的图表,并查看发生了什么:
death_valley_highs_lows.py
*--snip--*
path = Path('weather_data/death_valley_2021_simple.csv')
lines = path.read_text().splitlines()
*--snip--*
# Extract dates, and high and low temperatures.
dates, highs, lows = [], [], []
for row in reader:
current_date = datetime.strptime(row[2], '%Y-%m-%d')
high = int(row[3])
low = int(row[4])
dates.append(current_date)
*--snip--*
我们更新程序以从死亡谷数据文件中读取数据,并将索引更改为对应于该文件中 TMAX 和 TMIN 的位置。
当我们运行程序时,我们得到一个错误:
Traceback (most recent call last):
File "death_valley_highs_lows.py", line 17, in <module>
high = int(row[3])
❶ ValueError: invalid literal for int() with base 10: ''
错误跟踪信息告诉我们,Python 无法处理某个日期的最高温度,因为它无法将空字符串('')转换为整数 ❶。我们不会逐一查找缺失的数据,而是直接处理缺失数据的情况。
我们将在从 CSV 文件读取值时运行错误检查代码,以处理可能出现的异常。以下是如何做到这一点:
death_valley_highs_lows.py
*--snip--*
for row in reader:
current_date = datetime.strptime(row[2], '%Y-%m-%d')
❶ try:
high = int(row[3])
low = int(row[4])
except ValueError:
❷ print(f"Missing data for {current_date}")
❸ else:
dates.append(current_date)
highs.append(high)
lows.append(low)
# Plot the high and low temperatures.
*--snip--*
# Format plot.
❹ title = "Daily High and Low Temperatures, 2021\nDeath Valley, CA"
ax.set_title(title, fontsize=20)
ax.set_xlabel('', fontsize=16)
*--snip--*
每次我们检查一行时,我们尝试提取日期以及最高和最低气温 ❶。如果缺少任何数据,Python 会抛出一个ValueError,我们通过打印包含缺失数据日期的错误信息来处理它 ❷。在打印错误信息后,循环将继续处理下一行数据。如果没有出现错误并且所有日期的数据都成功获取,则else块将执行,数据会被追加到相应的列表中 ❸。由于我们要绘制新的位置的信息,因此我们更新标题以在图表中包含位置,并使用较小的字体大小以适应较长的标题 ❹。
当你现在运行death_valley_highs_lows.py时,你会看到只有一个日期缺少数据:
Missing data for 2021-05-04 00:00:00
由于错误得到了适当处理,我们的代码能够生成图表,并跳过缺失的数据。图 16-6 显示了生成的图表。
比较这张图和西特卡图表,我们可以看到死亡谷总体上比东南阿拉斯加温暖,正如我们所预期的那样。此外,每天的温度范围在沙漠地区更大。阴影区域的高度清晰地表明了这一点。

图 16-6:死亡谷的每日最高和最低温度
你处理的许多数据集可能会有缺失、不正确格式化或不正确的数据。你可以使用本书前半部分介绍的工具来处理这些情况。在这里,我们使用了一个try-except-else块来处理缺失的数据。有时你会使用continue跳过一些数据,或者使用remove()或del在提取数据后删除一些数据。只要结果是有意义且准确的可视化,可以使用任何有效的方法。
下载你自己的数据
要下载你自己的天气数据,请按照以下步骤操作:
-
访问 NOAA 气候数据在线网站
www.ncdc.noaa.gov/cdo-web。在“通过数据发现”部分,点击搜索工具。在“选择数据集”框中,选择每日总结。 -
选择日期范围,在“搜索”部分,选择邮政编码。输入你感兴趣的邮政编码并点击搜索。
-
在下一页,你将看到一个地图和关于你关注区域的一些信息。在位置名称下,点击查看详细信息,或者点击地图然后点击查看详细信息。
-
向下滚动并点击站点列表,查看此区域可用的气象站。点击一个站点名称,然后点击添加到购物车。尽管该站点使用购物车图标,但这些数据是免费的。在右上角,点击购物车。
-
在“选择输出格式”中,选择自定义 GHCN-Daily CSV。确保日期范围正确,然后点击继续。
-
在下一页,你可以选择你想要的数据类型。你可以下载一种数据(例如,专注于空气温度),或者下载该站点提供的所有数据。做出选择后,点击继续。
-
在最后一页,你会看到你订单的总结。输入你的电子邮件地址并点击提交订单。你会收到一封确认邮件,确认你的订单已被接收,并且几分钟后,你应该会收到另一封包含下载数据链接的邮件。
你下载的数据应与我们在本节中处理的数据结构相同。它可能会有与本节中不同的表头,但如果你按照我们在这里使用的相同步骤操作,你应该能够生成你感兴趣的数据的可视化图表。
映射全球数据集:GeoJSON 格式
在本节中,你将下载一个数据集,表示过去一个月内全球发生的所有地震。然后,你将制作一张地图,显示这些地震的位置以及每次地震的严重程度。由于数据以 GeoJSON 格式存储,我们将使用json模块来处理它。使用 Plotly 的scatter_geo()图表,你将创建清晰显示地震全球分布的可视化图。
下载地震数据
在你保存本章程序的文件夹内,创建一个名为eq_data的文件夹。将文件eq_1_day_m1.geojson复制到这个新文件夹中。地震按其里氏震级进行分类。此文件包含过去 24 小时内(截至撰写时)所有震级 M1 或更大的地震数据。这些数据来自美国地质调查局的地震数据源,网址为earthquake.usgs.gov/earthquakes/feed。
检查 GeoJSON 数据
当你打开eq_1_day_m1.geojson时,你会看到它非常密集且难以阅读:
{"type":"FeatureCollection","metadata":{"generated":1649052296000,...
{"type":"Feature","properties":{"mag":1.6,"place":"63 km SE of Ped...
{"type":"Feature","properties":{"mag":2.2,"place":"27 km SSE of Ca...
{"type":"Feature","properties":{"mag":3.7,"place":"102 km SSE of S...
{"type":"Feature","properties":{"mag":2.92000008,"place":"49 km SE...
{"type":"Feature","properties":{"mag":1.4,"place":"44 km NE of Sus...
`--snip--`
该文件的格式更适合机器处理而非人类阅读。但我们可以看到文件中包含了一些字典,以及我们感兴趣的信息,如地震的震级和位置。
json模块提供了多种工具,用于探索和处理 JSON 数据。其中一些工具将帮助我们重新格式化文件,以便在编程处理之前更容易查看原始数据。
让我们从加载数据并以更易读的格式显示它开始。由于这是一个长数据文件,因此我们将数据重写到一个新文件中,而不是直接打印它。然后我们可以打开该文件,方便地浏览数据:
eq_explore_data.py
from pathlib import Path
import json
# Read data as a string and convert to a Python object.
path = Path('eq_data/eq_data_1_day_m1.geojson')
contents = path.read_text()
❶ all_eq_data = json.loads(contents)
# Create a more readable version of the data file.
❷ path = Path('eq_data/readable_eq_data.geojson')
❸ readable_contents = json.dumps(all_eq_data, indent=4)
path.write_text(readable_contents)
我们将数据文件作为字符串读取,并使用json.loads()将文件的字符串表示转换为 Python 对象❶。这与我们在第十章中使用的方法相同。在这种情况下,整个数据集被转换为一个字典,我们将其赋值给all_eq_data。然后,我们定义一个新的path,在其中以更易读的格式写入相同的数据❷。你在第十章中看到的json.dumps()函数可以接受一个可选的indent参数❸,该参数告诉它在数据结构中嵌套元素的缩进量。
当你查看你的eq_data目录并打开文件readable_eq_data.json时,你将看到以下是你所看到的第一部分内容:
readable_eq_data.json
{
"type": "FeatureCollection",
❶ "metadata": {
"generated": 1649052296000,
"url": "https://earthquake.usgs.gov/earthquakes/.../1.0_day.geojson",
"title": "USGS Magnitude 1.0+ Earthquakes, Past Day",
"status": 200,
"api": "1.10.3",
"count": 160
},
❷ "features": [
*--snip--*
文件的第一部分包括一个键为"metadata"的部分❶。这告诉我们数据文件生成的时间以及我们可以在哪里找到在线数据。它还为我们提供了一个人类可读的标题和文件中包含的地震数量。在这个 24 小时的时间段内,共记录了160次地震。
这个 GeoJSON 文件具有对基于位置的数据非常有用的结构。信息存储在与键"features"相关的列表中 ❷。由于该文件包含地震数据,因此数据以列表的形式存储,其中列表中的每一项对应一个地震。这个结构可能看起来有些混乱,但它非常强大。它允许地质学家将他们需要的所有信息存储在每个地震的字典中,然后将所有这些字典放入一个大的列表中。
让我们看一下表示单个地震的字典:
readable_eq_data.json
*--snip--*
{
"type": "Feature",
❶ "properties": {
"mag": 1.6,
`--snip--`
❷ "title": "M 1.6 - 27 km NNW of Susitna, Alaska"
},
❸ "geometry": {
"type": "Point",
"coordinates": [
❹ -150.7585,
❺ 61.7591,
56.3
]
},
"id": "ak0224bju1jx"
},
键"properties"包含了每个地震的许多信息 ❶。我们主要关心的是每个地震的震级,与键"mag"相关。我们还对每个事件的"title"感兴趣,它提供了震级和位置的简洁总结 ❷。
键"geometry"帮助我们理解地震发生的地点 ❸。我们需要这些信息来绘制每个事件的地图。我们可以在与键"coordinates"相关的列表中找到每个地震的经度 ❹ 和纬度 ❺。
该文件的嵌套层级比我们写的代码要多得多,所以如果看起来很混乱,别担心:Python 会处理大部分复杂性。我们将一次只处理一个或两个嵌套层级。我们将从提取在 24 小时内记录的每个地震的字典开始。
创建包含所有地震的列表
首先,我们将创建一个列表,其中包含发生的每个地震的所有信息。
eq_explore_data.py
from pathlib import Path
import json
# Read data as a string and convert to a Python object.
path = Path('eq_data/eq_data_1_day_m1.geojson')
contents = path.read_text()
all_eq_data = json.loads(contents)
# Examine all earthquakes in the dataset.
all_eq_dicts = all_eq_data['features']
print(len(all_eq_dicts))
我们获取与键'features'相关的数据,并将其赋值给all_eq_dicts。我们知道该文件包含 160 个地震记录,输出结果验证了我们已捕获文件中的所有地震:
160
注意这段代码是多么简洁。格式整齐的文件readable_eq_data.json有超过 6000 行。但只需几行代码,我们就能读取所有数据并将其存储在 Python 列表中。接下来,我们将从每个地震中提取震级。
提取震级
我们可以遍历包含每个地震数据的列表,并提取我们需要的任何信息。让我们提取每个地震的震级:
eq_explore_data.py
*--snip--*
all_eq_dicts = all_eq_data['features']
❶ mags = []
for eq_dict in all_eq_dicts:
❷ mag = eq_dict['properties']['mag']
mags.append(mag)
print(mags[:10])
我们创建一个空列表来存储震级,然后遍历列表all_eq_dicts ❶。在这个循环中,每个地震由字典eq_dict表示。每个地震的震级存储在该字典的'properties'部分,键为'mag' ❷。我们将每个震级存储在变量mag中,然后将其添加到列表mags中。
我们打印前10个震级,这样我们可以看看是否获取了正确的数据:
[1.6, 1.6, 2.2, 3.7, 2.92000008, 1.4, 4.6, 4.5, 1.9, 1.8]
接下来,我们将提取每个地震的位置信息,然后我们可以绘制地震地图。
提取位置信息
每个地震的位置数据存储在键"geometry"下。几何字典内部有一个"coordinates"键,列表中的前两个值分别是经度和纬度。以下是我们如何提取这些数据:
eq_explore_data.py
*--snip--*
all_eq_dicts = all_eq_data['features']
mags, lons, lats = [], [], []
for eq_dict in all_eq_dicts:
mag = eq_dict['properties']['mag']
❶ lon = eq_dict['geometry']['coordinates'][0]
lat = eq_dict['geometry']['coordinates'][1]
mags.append(mag)
lons.append(lon)
lats.append(lat)
print(mags[:10])
print(lons[:5])
print(lats[:5])
我们为经度和纬度创建了空列表。代码eq_dict['geometry']访问表示地震几何元素的字典❶。第二个键'coordinates'提取与'coordinates'相关联的值列表。最后,0索引请求获取坐标列表中的第一个值,这对应于地震的经度。
当我们打印前5个经度和纬度时,输出显示我们正在提取正确的数据:
[1.6, 1.6, 2.2, 3.7, 2.92000008, 1.4, 4.6, 4.5, 1.9, 1.8]
[-150.7585, -153.4716, -148.7531, -159.6267, -155.248336791992]
[61.7591, 59.3152, 63.1633, 54.5612, 18.7551670074463]
有了这些数据,我们可以继续绘制每次地震的位置。
构建世界地图
利用我们目前提取的信息,我们可以构建一张简单的世界地图。虽然它现在看起来还不美观,但我们希望在关注样式和展示问题之前,确保信息正确显示。以下是初始地图:
eq_world_map.py
from pathlib import Path
import json
import plotly.express as px
*--snip--*
for eq_dict in all_eq_dicts:
*--snip--*
title = 'Global Earthquakes'
❶ fig = px.scatter_geo(lat=lats, lon=lons, title=title)
fig.show()
我们导入plotly.express并给它取别名px,就像在第十五章中一样。scatter_geo()函数❶允许你在地图上叠加地理数据的散点图。在这种图表类型的最简单用法中,你只需要提供纬度列表和经度列表。我们将列表lats传递给lat参数,将lons传递给lon参数。
当你运行此文件时,你应该会看到一张类似于图 16-7 的地图。这再次展示了 Plotly Express 库的强大功能;仅用三行代码,我们就能得到一张全球地震活动的地图。

图 16-7:显示过去 24 小时所有地震发生地点的简单地图
现在我们知道数据集中的信息已经正确地绘制在地图上,我们可以做一些修改,让地图更有意义且更易于阅读。
表示震级
地震活动的地图应该显示每次地震的震级。现在我们知道数据已经正确绘制,可以加入更多数据。
*--snip--*
# Read data as a string and convert to a Python object.
path = Path('eq_data/eq_data_30_day_m1.geojson')
contents = path.read_text()
*--snip--*
title = 'Global Earthquakes'
fig = px.scatter_geo(lat=lats, lon=lons, size=mags, title=title)
fig.show()
我们加载文件eq_data_30_day_m1.geojson,以包含过去 30 天的地震活动数据。我们还在px.scatter_geo()调用中使用了size参数,它指定了地图上点的大小。我们将列表mags传递给size,这样震级较大的地震将在地图上显示为较大的点。
结果地图如图 16-8 所示。地震通常发生在构造板块边界附近,包含的更长时间段的地震活动揭示了这些边界的确切位置。

图 16-8:地图现在显示了过去 30 天内所有地震的震级。
这张地图更好一些,但仍然很难从中挑出哪些点代表最重要的地震。我们可以通过使用颜色来表示震级,进一步改善这一点。
自定义标记颜色
我们可以使用 Plotly 的颜色刻度来根据相应地震的严重程度自定义每个标记的颜色。我们还将为底图使用不同的投影。
eq_world_map.py
*--snip--*
fig = px.scatter_geo(lat=lats, lon=lons, size=mags, title=title,
❶ color=mags,
❷ color_continuous_scale='Viridis',
❸ labels={'color':'Magnitude'},
❹ projection='natural earth',
)
fig.show()
这里所有显著的变化都发生在px.scatter_geo()函数调用中。color参数告诉 Plotly 应使用哪些值来确定每个标记在颜色刻度上的位置❶。我们使用mags列表来确定每个点的颜色,就像我们使用size参数时一样。
color_continuous_scale参数告诉 Plotly 使用哪个颜色刻度❷。Viridis是一种颜色刻度,从深蓝到亮黄,适用于这个数据集。默认情况下,地图右侧的颜色刻度标记为color;这并不代表颜色实际的含义。labels参数,如第十五章所示,接受一个字典作为值❸。我们只需要在这个图表上设置一个自定义标签,确保颜色刻度标记为Magnitude,而不是color。
我们添加了一个额外的参数来修改地震绘制的底图。projection参数接受多种常见的地图投影❹。这里我们使用了'natural earth'投影,它将地图的两端做了圆形处理。另外,注意这个最后一个参数后面的逗号。当一个函数调用有一个跨多行的长参数列表时,通常的做法是在最后一个参数后加上逗号,这样你就可以随时在下一行添加新的参数。
现在运行程序时,你会看到一张看起来更漂亮的地图。在图 16-9 中,颜色刻度显示了每个地震的严重程度;最严重的地震以浅黄色的点突出显示,与许多较暗的点形成对比。你还可以看出哪些区域的地震活动更为显著。

图 16-9:在 30 天内的地震数据中,颜色和大小用于表示每次地震的震级。
其他颜色刻度
你可以从多种其他颜色刻度中选择。要查看可用的颜色刻度,可以在 Python 终端会话中输入以下两行:
>>> **import plotly.express as px**
>>> **px.colors.named_colorscales()**
['aggrnyl', 'agsunset', 'blackbody', ..., 'mygbm']
欢迎尝试在地震地图中,或者在任何数据集上使用这些颜色刻度,尤其是在连续变化的颜色可以帮助显示数据模式的情况下。
添加悬浮文本
为了完成这张地图,我们将添加一些信息文本,当你悬停在代表地震的标记上时会显示。除了显示经纬度(默认显示外),我们还将显示震级,并提供一个大致位置的描述。
为了进行此更改,我们需要从文件中提取更多数据:
eq_world_map.py
*--snip--*
❶ mags, lons, lats, eq_titles = [], [], [], []
mag = eq_dict['properties']['mag']
lon = eq_dict['geometry']['coordinates'][0]
lat = eq_dict['geometry']['coordinates'][1]
❷ eq_title = eq_dict['properties']['title']
mags.append(mag)
lons.append(lon)
lats.append(lat)
eq_titles.append(eq_title)
title = 'Global Earthquakes'
fig = px.scatter_geo(lat=lats, lon=lons, size=mags, title=title,
*--snip--*
projection='natural earth',
❸ hover_name=eq_titles,
)
fig.show()
我们首先创建一个名为eq_titles的列表,用于存储每次地震的标题❶。数据中的'title'部分包含了每次地震的震级和位置的描述性名称,以及它的经纬度。我们提取这些信息并将其赋值给变量eq_title❷,然后将其追加到eq_titles列表中。
在px.scatter_geo()调用中,我们将eq_titles传递给hover_name参数❸。Plotly 现在会将每次地震的标题信息添加到每个数据点的鼠标悬停文本中。当你运行这个程序时,你应该能够将鼠标悬停在任何标记上,看到描述地震发生位置的文本,并查看其确切震级。这个信息的示例显示在图 16-10 中。

图 16-10:现在,鼠标悬停时的文本会包含每次地震的总结信息。
这非常令人印象深刻!不到 30 行代码,我们就创建了一个既美观又有意义的全球地震活动地图,同时还展示了地球的地质结构。Plotly 提供了多种方式来定制你可视化内容的外观和行为。利用 Plotly 的众多选项,你可以制作出准确显示你所需信息的图表和地图。
总结
在本章中,你学习了如何处理实际数据集。你处理了 CSV 和 GeoJSON 文件,并提取了你想要关注的数据。通过使用历史天气数据,你更深入地了解了如何使用 Matplotlib,包括如何使用datetime模块以及如何在一个图表上绘制多个数据系列。你在 Plotly 中将地理数据绘制到世界地图上,并学习了如何自定义地图的样式。
随着你在处理 CSV 和 JSON 文件方面的经验积累,你将能够处理几乎任何你想分析的数据。你可以下载大多数在线数据集,并以这两种格式之一或两者兼有的方式获取。通过处理这些格式,你也将能够更轻松地学习如何处理其他数据格式。
在下一章中,你将编写程序自动从在线来源收集数据,然后创建这些数据的可视化。如果你想把编程当作爱好,这些技能非常有趣;如果你有意从事编程职业,它们则是至关重要的技能。
第十七章:使用 API

在这一章中,你将学习如何编写一个自包含的程序,基于它获取的数据生成可视化图表。你的程序将使用应用程序编程接口(API)来自动请求网站上的特定信息,然后使用这些信息生成可视化图表。因为这样的程序总是使用最新的数据来生成可视化图表,即使这些数据可能会快速变化,生成的图表也会始终保持最新。
使用 API
API 是网站的一部分,用于与程序交互。这些程序使用非常特定的 URL 请求某些信息。这样的请求被称为API 调用。请求的数据将以易于处理的格式返回,如 JSON 或 CSV。大多数使用外部数据源的应用程序,例如集成社交媒体网站的应用程序,都依赖于 API 调用。
Git 和 GitHub
我们将基于 GitHub (github.com) 上的信息创建可视化图表,这个网站允许程序员共同协作编码项目。我们将使用 GitHub 的 API 请求网站上 Python 项目的信息,然后使用 Plotly 生成这些项目的相对受欢迎程度的交互式可视化图表。
GitHub 的名字来源于 Git,一种分布式版本控制系统。Git 帮助人们管理项目工作,防止一个人的更改与其他人的更改冲突。当你在一个项目中实现新功能时,Git 会跟踪你对每个文件所做的更改。当你的新代码正常工作时,你提交所做的更改,Git 会记录项目的新状态。如果你犯了错误并想恢复更改,你可以轻松返回到任何之前正常工作的状态。(了解更多关于使用 Git 的版本控制,参见附录 D。)GitHub 上的项目存储在仓库中,仓库包含与项目相关的所有内容:代码、协作者信息、任何问题或错误报告等。
当 GitHub 用户喜欢一个项目时,他们可以通过“加星”来表示支持,并跟踪可能想要使用的项目。在这一章中,我们将编写一个程序,自动下载 GitHub 上最受欢迎 Python 项目的信息,并创建这些项目的有用可视化图表。
使用 API 调用请求数据
GitHub 的 API 允许你通过 API 调用请求各种信息。要查看一个 API 调用的样式,输入以下内容到浏览器的地址栏并按下 ENTER:
**https://api.github.com/search/repositories?q=language:python+sort:stars**
这个调用返回当前托管在 GitHub 上的 Python 项目数量,以及最受欢迎的 Python 仓库的相关信息。让我们来检查一下这个调用。第一部分,https://api.github.com/,将请求定向到 GitHub 处理 API 调用的部分。接下来的部分,search/repositories,指示 API 在 GitHub 上的所有仓库中进行搜索。
repositories 后面的问号表示我们即将传递一个参数。q 代表 查询,等号(=)让我们开始指定查询(q=)。通过使用 language:python,我们表示只想获取主语言为 Python 的仓库信息。最后部分,+sort:stars,按照项目的星标数量对项目进行排序。
以下代码片段显示了响应的前几行:
{
❶ "total_count": 8961993,
❷ "incomplete_results": true,
❸ "items": [
{
"id": 54346799,
"node_id": "MDEwOlJlcG9zaXRvcnk1NDM0Njc5OQ==",
"name": "public-apis",
"full_name": "public-apis/public-apis",
`--snip--`
从响应中可以看到,这个 URL 并非主要是供人类输入的,因为它的格式是为了让程序处理的。到目前为止,GitHub 找到了不到九百万个 Python 项目 ❶。"incomplete_results" 的值为 true,这告诉我们 GitHub 没有完全处理查询 ❷。GitHub 限制了每个查询的运行时间,以保持 API 对所有用户的响应能力。在这种情况下,它找到了最受欢迎的一些 Python 仓库,但没有足够的时间找到所有的;稍后我们会修复这个问题。返回的 "items" 被显示在接下来的列表中,其中包含了 GitHub 上最受欢迎的 Python 项目的详细信息 ❸。
安装 Requests
Requests 包允许 Python 程序轻松地从网站请求信息并查看响应。使用 pip 安装 Requests:
$ **python -m pip install --user requests**
如果你使用的命令不是 python 来运行程序或启动终端会话,比如 python3,你的命令将会像这样:
$ **python3 -m pip install --user requests**
处理 API 响应
现在我们将编写一个程序来自动发起 API 调用并处理结果:
python_repos.py
import requests
# Make an API call and check the response.
❶ url = "https://api.github.com/search/repositories"
url += "?q=language:python+sort:stars+stars:>10000"
❷ headers = {"Accept": "application/vnd.github.v3+json"}
❸ r = requests.get(url, headers=headers)
❹ print(f"Status code: {r.status_code}")
# Convert the response object to a dictionary.
❺ response_dict = r.json()
# Process results.
print(response_dict.keys())
我们首先导入 requests 模块。然后,我们将 API 调用的 URL 分配给 url 变量 ❶。这是一个很长的 URL,所以我们将其拆分成两行。第一行是 URL 的主体部分,第二行是查询字符串。我们在原查询字符串中添加了一个条件:stars:>10000,这表示 GitHub 只搜索那些星标超过 10,000 的 Python 仓库。这应该能让 GitHub 返回一个完整、一致的结果集。
GitHub 当前使用的是第三版 API,因此我们定义了 API 调用的头部,明确要求使用这个版本的 API,并以 JSON 格式返回结果 ❷。然后,我们使用 requests 来发起 API 调用 ❸。我们调用 get() 方法,并传入我们定义的 URL 和头部,将响应对象分配给变量 r。
响应对象有一个名为status_code的属性,它告诉我们请求是否成功。(状态码 200 表示响应成功。)我们打印status_code的值,以确保请求成功完成 ❹。我们请求 API 以 JSON 格式返回信息,因此我们使用json()方法将信息转换为 Python 字典 ❺。我们将结果字典赋值给response_dict。
最后,我们打印response_dict中的键并查看以下输出:
Status code: 200
dict_keys(['total_count', 'incomplete_results', 'items'])
因为状态码是200,我们知道请求成功。响应字典只包含三个键:'total_count'、'incomplete_results'和'items'。让我们来看一下响应字典的内容。
处理响应字典
通过将 API 调用返回的信息表示为字典,我们可以处理存储在其中的数据。让我们生成一些输出,来总结这些信息。这是确保我们收到了预期信息并开始检查我们感兴趣的信息的好方法:
python_repos.py
import requests
# Make an API call and store the response.
*--snip--*
# Convert the response object to a dictionary.
response_dict = r.json()
❶ print(f"Total repositories: {response_dict['total_count']}")
print(f"Complete results: {not response_dict['incomplete_results']}")
# Explore information about the repositories.
❷ repo_dicts = response_dict['items']
print(f"Repositories returned: {len(repo_dicts)}")
# Examine the first repository.
❸ repo_dict = repo_dicts[0]
❹ print(f"\nKeys: {len(repo_dict)}")
❺ for key in sorted(repo_dict.keys()):
print(key)
我们通过打印与'total_count'关联的值开始探索响应字典,这个值表示该 API 调用返回的 Python 仓库的总数 ❶。我们还使用与'incomplete_results'关联的值,这样我们就能知道 GitHub 是否完全处理了查询。我们不是直接打印这个值,而是打印它的相反值:True值表示我们收到了完整的结果集。
'items'对应的值是一个列表,列表中包含多个字典,每个字典包含一个独立的 Python 仓库的数据。我们将这个字典列表赋值给repo_dicts ❷。然后我们打印repo_dicts的长度,以查看我们拥有多少个仓库信息。
为了更仔细地查看每个仓库返回的信息,我们从repo_dicts中取出第一个项目并赋值给repo_dict ❸。然后我们打印字典中的键的数量,以查看我们拥有多少信息 ❹。最后,我们打印所有字典的键,以查看包含了哪些类型的信息 ❺。
结果让我们更清楚地了解了实际的数据:
Status code: 200
❶ Total repositories: 248
❷ Complete results: True
Repositories returned: 30
❸ Keys: 78
allow_forking
archive_url
archived
`--snip--`
url
visiblity
watchers
watchers_count
在写这篇文章时,只有248个 Python 仓库拥有超过 10,000 颗星 ❶。我们可以看到 GitHub 成功地处理了 API 请求 ❷。在这个响应中,GitHub 返回了符合我们查询条件的前30个仓库的信息。如果我们想要更多仓库信息,可以请求更多的数据页。
GitHub 的 API 返回了关于每个仓库的大量信息:repo_dict中有78个键 ❸。当你查看这些键时,你会大致了解你可以从一个项目中提取什么类型的信息。(通过 API 能获取哪些信息,唯一的方式就是阅读文档或者通过代码查看信息,就像我们现在所做的那样。)
让我们提取一些repo_dict中的键的值:
python_repos.py
*--snip--*
# Examine the first repository.
repo_dict = repo_dicts[0]
print("\nSelected information about first repository:")
❶ print(f"Name: {repo_dict['name']}")
❷ print(f"Owner: {repo_dict['owner']['login']}")
❸ print(f"Stars: {repo_dict['stargazers_count']}")
print(f"Repository: {repo_dict['html_url']}")
❹ print(f"Created: {repo_dict['created_at']}")
❺ print(f"Updated: {repo_dict['updated_at']}")
print(f"Description: {repo_dict['description']}")
在这里,我们打印第一个仓库字典中多个键的值。我们从项目名称开始❶。整个字典表示项目的所有者,所以我们使用键owner来访问表示所有者的字典,然后使用键login来获取所有者的登录名❷。接下来,我们打印项目获得的星标数❸和该项目 GitHub 仓库的 URL。然后,我们展示它的创建时间❹和最后更新时间❺。最后,我们打印仓库的描述。
输出应类似于以下内容:
Status code: 200
Total repositories: 248
Complete results: True
Repositories returned: 30
Selected information about first repository:
Name: public-apis
Owner: public-apis
Stars: 191493
Repository: https://github.com/public-apis/public-apis
Created: 2016-03-20T23:49:42Z
Updated: 2022-05-12T06:37:11Z
Description: A collective list of free APIs
我们可以看到,截至目前,GitHub 上星标最多的 Python 项目是public-apis。它的所有者是一个同名的组织,已获得近 20 万 GitHub 用户的星标。我们可以看到项目仓库的 URL、创建日期是 2016 年 3 月,并且它最近有更新。此外,描述中告诉我们,public-apis 包含了一份程序员可能感兴趣的免费 API 列表。
汇总顶级仓库
当我们为这些数据制作可视化时,我们希望包含多个仓库。让我们写一个循环来打印每个 API 调用返回的仓库的选定信息,这样我们就能将它们全部包含在可视化中:
python_repos.py
*--snip--*
# Explore information about the repositories.
repo_dicts = response_dict['items']
print(f"Repositories returned: {len(repo_dicts)}")
❶ print("\nSelected information about each repository:")
❷ for repo_dict in repo_dicts:
print(f"\nName: {repo_dict['name']}")
print(f"Owner: {repo_dict['owner']['login']}")
print(f"Stars: {repo_dict['stargazers_count']}")
print(f"Repository: {repo_dict['html_url']}")
print(f"Description: {repo_dict['description']}")
我们首先打印一条介绍性消息❶。然后我们遍历repo_dicts中的所有字典❷。在循环内部,我们打印每个项目的名称、所有者、星标数量、GitHub 上的 URL 以及项目描述:
Status code: 200
Total repositories: 248
Complete results: True
Repositories returned: 30
Selected information about each repository:
Name: public-apis
Owner: public-apis
Stars: 191494
Repository: https://github.com/public-apis/public-apis
Description: A collective list of free APIs
Name: system-design-primer
Owner: donnemartin
Stars: 179952
Repository: https://github.com/donnemartin/system-design-primer
Description: Learn how to design large-scale systems. Prep for the system
design interview. Includes Anki flashcards.
`--snip--`
Name: PayloadsAllTheThings
Owner: swisskyrepo
Stars: 37227
Repository: https://github.com/swisskyrepo/PayloadsAllTheThings
Description: A list of useful payloads and bypass for Web Application Security
and Pentest/CTF
一些有趣的项目出现在这些结果中,可能值得看一看。但不要在这里花太多时间,因为我们即将创建一个可视化,使得这些结果更易于阅读。
监控 API 请求限制
大多数 API 都有请求限制,这意味着你在一定时间内可以发出的请求数量是有限制的。要查看是否接近 GitHub 的限制,请在浏览器中输入api.github.com/rate_limit。你应该会看到类似以下的响应:
{
"resources": {
`--snip--`
❶ "search": {
❷ "limit": 10,
❸ "remaining": 9,
❹ "reset": 1652338832,
"used": 1,
"resource": "search"
},
`--snip--`
我们关心的信息是搜索 API 的请求限制❶。我们看到限制是每分钟 10 次请求❷,并且当前分钟剩余 9 次请求❸。与键"reset"相关的值表示我们的配额将在何时重置,时间是Unix 或 epoch 时间(自 1970 年 1 月 1 日午夜以来的秒数)❹。如果达到配额限制,你会收到一条简短的响应,告知你已达到 API 限制。如果你达到了限制,只需等待配额重置。
使用 Plotly 可视化仓库
让我们使用收集到的数据制作一个可视化图,展示 GitHub 上 Python 项目的相对受欢迎程度。我们将制作一个交互式条形图:每个条形的高度表示该项目获得的星标数量,你可以点击条形的标签进入该项目在 GitHub 上的主页。
将我们正在编写的程序保存为python_repos_visual.py,然后修改它,使其如下所示:
python_repos_visual.py
import requests
import plotly.express as px
# Make an API call and check the response.
url = "https://api.github.com/search/repositories"
url += "?q=language:python+sort:stars+stars:>10000"
headers = {"Accept": "application/vnd.github.v3+json"}
r = requests.get(url, headers=headers)
❶ print(f"Status code: {r.status_code}")
# Process overall results.
response_dict = r.json()
❷ print(f"Complete results: {not response_dict['incomplete_results']}")
# Process repository information.
repo_dicts = response_dict['items']
❸ repo_names, stars = [], []
for repo_dict in repo_dicts:
repo_names.append(repo_dict['name'])
stars.append(repo_dict['stargazers_count'])
# Make visualization.
❹ fig = px.bar(x=repo_names, y=stars)
fig.show()
我们导入 Plotly Express,然后像之前一样进行 API 调用。我们继续打印 API 调用响应的状态,以便在出现问题时知道❶。当我们处理总体结果时,继续打印确认信息,确保我们获得了完整的结果集❷。我们删除了其他的print()调用,因为我们不再处于探索阶段;我们知道我们已经得到了所需的数据。
然后,我们创建两个空列表❸来存储将要包含在初始图表中的数据。我们需要每个项目的名称来标记条形图(repo_names),以及每个项目的星标数量来确定条形图的高度(stars)。在循环中,我们将每个项目的名称和星标数量追加到这两个列表中。
我们用两行代码❹制作初步的可视化。这与 Plotly Express 的理念一致:在美化图表之前,你应该尽快看到你的可视化效果。在这里,我们使用px.bar()函数来创建条形图。我们将repo_names列表作为x参数,stars作为y参数。
图 17-1 展示了最终的图表。我们可以看到,前几个项目比其他项目受欢迎得多,但它们都是 Python 生态系统中非常重要的项目。

图 17-1:GitHub 上最受星标的 Python 项目
样式调整图表
一旦确认图表中的信息正确,Plotly 支持多种方式来调整和定制图表样式。我们将在最初的px.bar()调用中做一些修改,然后在图表创建后对fig对象进行进一步调整。
我们将通过为每个坐标轴添加标题和标签来开始为图表进行样式调整:
python_repos_visual.py
*--snip--*
# Make visualization.
title = "Most-Starred Python Projects on GitHub"
labels = {'x': 'Repository', 'y': 'Stars'}
fig = px.bar(x=repo_names, y=stars, title=title, labels=labels)
❶ fig.update_layout(title_font_size=28, xaxis_title_font_size=20,
yaxis_title_font_size=20)
fig.show()
我们首先添加一个标题和每个坐标轴的标签,正如我们在第十五章和第十六章中所做的那样。然后,我们使用fig.update_layout()方法来修改图表的特定元素❶。Plotly 使用一种约定,图表元素的各个方面通过下划线连接。当你熟悉 Plotly 的文档时,你会开始发现不同图表元素的命名和修改存在一致的模式。在这里,我们将标题的字体大小设置为28,每个坐标轴标题的字体大小设置为20。结果如图 17-2 所示。

图 17-2:已经为主图表和每个坐标轴添加了标题。
添加自定义工具提示
在 Plotly 中,你可以将光标悬停在单个柱状条上,以显示该柱状条所表示的信息。这通常被称为工具提示,在本例中,它目前显示项目的星标数。让我们创建一个自定义工具提示,显示每个项目的描述以及项目的所有者。
我们需要提取一些额外的数据来生成工具提示:
python_repos_visual.py
*--snip--*
# Process repository information.
repo_dicts = response_dict['items']
❶ repo_names, stars, hover_texts = [], [], []
for repo_dict in repo_dicts:
repo_names.append(repo_dict['name'])
stars.append(repo_dict['stargazers_count'])
# Build hover texts.
❷ owner = repo_dict['owner']['login']
description = repo_dict['description']
❸ hover_text = f"{owner}<br />{description}"
hover_texts.append(hover_text)
# Make visualization.
title = "Most-Starred Python Projects on GitHub"
labels = {'x': 'Repository', 'y': 'Stars'}
❹ fig = px.bar(x=repo_names, y=stars, title=title, labels=labels,
hover_name=hover_texts)
fig.update_layout(title_font_size=28, xaxis_title_font_size=20,
yaxis_title_font_size=20)
fig.show()
我们首先定义一个新的空列表hover_texts,用于存储我们希望为每个项目显示的文本❶。在处理数据的循环中,我们提取每个项目的所有者和描述❷。Plotly 允许在文本元素中使用 HTML 代码,因此我们生成一个包含换行符(<br />)的标签字符串,将项目所有者的用户名与描述分隔开❸。然后,我们将此标签附加到列表hover_texts中。
在px.bar()调用中,我们添加了hover_name参数,并将hover_texts传递给它❹。这与我们自定义全球地震活动图中每个点标签的做法相同。当 Plotly 创建每个柱状条时,它会从此列表中提取标签,并且只有当观众将光标悬停在柱状条上时才会显示这些标签。图 17-3 展示了其中一个自定义工具提示。

图 17-3:悬停在柱状图上会显示项目的所有者和描述。
添加可点击链接
由于 Plotly 允许在文本元素中使用 HTML,我们可以轻松地在图表中添加链接。我们可以使用x-轴标签作为方式,让观众访问任何项目在 GitHub 上的主页。我们需要从数据中提取 URLs,并在生成x-轴标签时使用它们:
python_repos_visual.py
*--snip--*
# Process repository information.
repo_dicts = response_dict['items']
❶ repo_links, stars, hover_texts = [], [], []
for repo_dict in repo_dicts:
# Turn repo names into active links.
repo_name = repo_dict['name']
❷ repo_url = repo_dict['html_url']
❸ repo_link = f"<a href='{repo_url}'>{repo_name}</a>"
repo_links.append(repo_link)
stars.append(repo_dict['stargazers_count'])
*--snip--*
# Make visualization.
title = "Most-Starred Python Projects on GitHub"
labels = {'x': 'Repository', 'y': 'Stars'}
fig = px.bar(x=repo_links, y=stars, title=title, labels=labels,
hover_name=hover_texts)
fig.update_layout(title_font_size=28, xaxis_title_font_size=20,
yaxis_title_font_size=20)
fig.show()
我们将创建的列表名称从repo_names更新为repo_links,以更准确地传达我们为图表整理的信息类型❶。然后,我们从repo_dict中提取项目的 URL,并将其赋值给临时变量repo_url❷。接下来,我们生成指向项目的链接❸。我们使用 HTML 锚标签,它的形式为<a href='URL'>链接文本</a>,来生成链接。然后,我们将这个链接附加到repo_links中。
当我们调用px.bar()时,我们使用repo_links作为图表中的x值。结果看起来和之前一样,但现在观众可以点击图表底部的任何项目名称,访问该项目在 GitHub 上的主页。现在我们有了一个互动的、富有信息的可视化,展示了通过 API 检索的数据!
自定义标记颜色
一旦图表创建完成,几乎图表的任何方面都可以通过更新方法进行自定义。我们之前使用过update_layout()方法。另一个方法update_traces()可以用来定制图表上呈现的数据。
让我们将柱状图的颜色改为更深的蓝色,并添加一些透明度:
*--snip--*
fig.update_layout(title_font_size=28, xaxis_title_font_size=20,
yaxis_title_font_size=20)
fig.update_traces(marker_color='SteelBlue', marker_opacity=0.6)
fig.show()
在 Plotly 中,trace 指的是图表上的一组数据。update_traces() 方法可以接受多种不同的参数;任何以 marker_ 开头的参数都会影响图表上的标记。这里,我们将每个标记的颜色设置为 'SteelBlue';任何命名的 CSS 颜色都可以在这里使用。我们还将每个标记的透明度设置为 0.6。透明度为 1.0 时,标记将完全不透明;透明度为 0 时,标记将完全透明。
更多关于 Plotly 和 GitHub API 的内容
Plotly 的文档内容丰富且组织良好;然而,可能很难知道从哪里开始阅读。一个不错的起点是阅读文章《Python 中的 Plotly Express》,地址是:plotly.com/python/plotly-express。这篇文章概述了你可以使用 Plotly Express 制作的所有图表,并且你可以找到关于每种单独图表类型的更长文章的链接。
如果你想更好地理解如何自定义 Plotly 图表,文章《Python 中的 Plotly Express 图表样式》将进一步扩展你在第 15 到 17 章中看到的内容。你可以在这里找到这篇文章:plotly.com/python/styling-plotly-express。
关于 GitHub API 的更多信息,请参考它的文档:docs.github.com/en/rest。在这里,你将了解如何从 GitHub 拉取各种各样的信息。要扩展你在这个项目中看到的内容,可以在侧边栏中查找参考文献的搜索部分。如果你有 GitHub 账户,你也可以使用自己的数据以及其他用户的公开数据进行操作。
Hacker News API
为了探索如何在其他网站上使用 API 调用,我们来快速看一下 Hacker News(news.ycombinator.com)。在 Hacker News 上,人们分享关于编程和技术的文章,并就这些文章进行热烈讨论。Hacker News 的 API 提供了对网站上所有提交和评论的数据访问,你可以在无需注册 API 密钥的情况下使用这个 API。
以下调用返回了截至目前的当前热门文章的信息:
https://hacker-news.firebaseio.com/v0/item/31353677.json
当你在浏览器中输入这个 URL 时,你会看到页面上的文本被大括号括起来,这意味着它是一个字典。但是,如果没有更好的格式化,响应是很难查看的。让我们像第十六章的地震项目中那样,通过json.dumps()方法运行这个 URL,这样我们就能探索返回的关于文章的信息:
hn_article.py
import requests
import json
# Make an API call, and store the response.
url = "https://hacker-news.firebaseio.com/v0/item/31353677.json"
r = requests.get(url)
print(f"Status code: {r.status_code}")
# Explore the structure of the data.
response_dict = r.json()
response_string = json.dumps(response_dict, indent=4)
❶ print(response_string)
这个程序中的所有内容应该都很熟悉,因为我们在前两章中已经使用过它。这里的主要区别是,我们可以打印格式化后的响应字符串❶,而不是将其写入文件,因为输出并不特别长。
输出是关于 ID 为31353677的文章的字典信息:
{
"by": "sohkamyung",
❶ "descendants": 302,
"id": 31353677,
❷ "kids": [
31354987,
31354235,
`--snip--`
],
"score": 785,
"time": 1652361401,
❸ "title": "Astronomers reveal first image of the black hole
at the heart of our galaxy",
"type": "story",
❹ "url": "https://public.nrao.edu/news/.../"
}
这个字典包含了我们可以使用的多个键。键"descendants"告诉我们文章收到的评论数❶。键"kids"提供了所有直接响应该提交的评论 ID❷。这些评论可能也会有自己的评论,因此一个提交的descendants通常会多于kids。我们可以看到正在讨论的文章的标题❸,以及该文章的 URL❹。
以下 URL 返回了一个简单的列表,列出了当前 Hacker News 上最热门文章的所有 ID:
https://hacker-news.firebaseio.com/v0/topstories.json
我们可以使用此调用来查找当前首页上的文章,并生成一系列类似于我们刚才查看的 API 调用。通过这种方法,我们可以打印出当前 Hacker News 首页所有文章的摘要:
hn_submissions.py
from operator import itemgetter
import requests
# Make an API call and check the response.
❶ url = "https://hacker-news.firebaseio.com/v0/topstories.json"
r = requests.get(url)
print(f"Status code: {r.status_code}")
# Process information about each submission.
❷ submission_ids = r.json()
❸ submission_dicts = []
for submission_id in submission_ids[:5]:
# Make a new API call for each submission.
❹ url = f"https://hacker-news.firebaseio.com/v0/item/{submission_id}.json"
r = requests.get(url)
print(f"id: {submission_id}\tstatus: {r.status_code}")
response_dict = r.json()
# Build a dictionary for each article.
❺ submission_dict = {
'title': response_dict['title'],
'hn_link': f"https://news.ycombinator.com/item?id={submission_id}",
'comments': response_dict['descendants'],
}
❻ submission_dicts.append(submission_dict)
❼ submission_dicts = sorted(submission_dicts, key=itemgetter('comments'),
reverse=True)
❽ for submission_dict in submission_dicts:
print(f"\nTitle: {submission_dict['title']}")
print(f"Discussion link: {submission_dict['hn_link']}")
print(f"Comments: {submission_dict['comments']}")
首先,我们进行一次 API 调用,并打印响应的状态❶。此 API 调用会返回一个列表,包含调用时 Hacker News 上最多 500 篇最受欢迎文章的 ID。然后,我们将响应对象转换为一个 Python 列表❷,并将其赋值给submission_ids。我们将使用这些 ID 来构建一个包含当前提交信息的字典集合。
我们设置了一个空的列表,名为submission_dicts,用于存储这些字典❸。然后,我们循环遍历前 30 个提交的 ID。我们为每个提交生成一个包含当前submission_id值的 URL,进行新的 API 调用❹。我们打印每个请求的状态及其 ID,以便查看请求是否成功。
接下来,我们为当前正在处理的提交创建一个字典❺。我们存储该提交的标题、讨论页面的链接以及该文章目前收到的评论数。然后,我们将每个submission_dict追加到submission_dicts列表中❻。
Hacker News 上的每个提交都会根据多个因素进行排名,包括投票次数、收到的评论数和提交的时间等。我们希望根据评论数对字典列表进行排序。为此,我们使用了一个名为itemgetter()的函数❼,它来自operator模块。我们将这个函数的键'comments'传递给它,函数会从列表中的每个字典中提取该键的值。然后,sorted()函数使用这个值来排序列表。我们按降序排序,以将评论数最多的文章排在最前面。
一旦列表排序完成,我们遍历列表❽并打印出每个顶级提交的三条信息:标题、讨论页面的链接以及该提交目前的评论数:
Status code: 200
id: 31390506 status: 200
id: 31389893 status: 200
id: 31390742 status: 200
`--snip--`
Title: Fly.io: The reclaimer of Heroku's magic
Discussion link: https://news.ycombinator.com/item?id=31390506
Comments: 134
Title: The weird Hewlett Packard FreeDOS option
Discussion link: https://news.ycombinator.com/item?id=31389893
Comments: 64
Title: Modern JavaScript Tutorial
Discussion link: https://news.ycombinator.com/item?id=31390742
Comments: 20
`--snip--`
你可以使用类似的过程通过任何 API 访问和分析信息。通过这些数据,你可以创建一个可视化,显示哪些提交激发了最活跃的近期讨论。这也是为像 Hacker News 这样的站点提供定制化阅读体验的基础。要了解更多关于如何通过 Hacker News API 访问的信息,请访问文档页面 github.com/HackerNews/API。
总结
在本章中,你学习了如何使用 API 编写自包含程序,自动收集所需的数据,并利用这些数据创建可视化。你使用了 GitHub API 探索了 GitHub 上最受欢迎的 Python 项目,并且简要了解了 Hacker News API。你学会了如何使用 Requests 包自动发起 API 请求,并处理该请求的结果。我们还介绍了一些 Plotly 设置,用于进一步自定义你生成的图表外观。
在下一章,你将使用 Django 构建一个 Web 应用程序作为最终项目。
第十八章:开始使用 Django

随着互联网的发展,网站和移动应用之间的界限逐渐模糊。网站和应用都帮助用户以多种方式与数据互动。幸运的是,你可以使用 Django 来构建一个既能提供动态网站,又能支持一套移动应用的项目。Django 是 Python 最受欢迎的 web 框架,它是一个为构建互动式 web 应用而设计的一套工具。在本章中,你将学习如何使用 Django 构建一个名为 Learning Log 的项目,这是一个在线日记系统,可以帮助你记录你所学到的不同主题的信息。
我们将为这个项目编写一个规范,然后定义该应用将使用的数据模型。我们将使用 Django 的管理系统输入一些初始数据,然后编写视图和模板,以便 Django 能构建网站的页面。
Django 可以响应页面请求,并使得读取和写入数据库、更好地管理用户等变得更加简便。在第十九章和第二十章中,你将完善 Learning Log 项目,并将其部署到在线服务器,这样你(以及全世界的其他人)就可以使用它。
设置项目
在开始一个像 web 应用这样重要的项目时,首先需要在规范(spec)中描述项目的目标。一旦你有了明确的目标,就可以开始识别可管理的任务来实现这些目标。
在本节中,我们将为 Learning Log 编写一个规范,并开始着手项目的第一阶段。这将包括设置虚拟环境并构建 Django 项目的初步部分。
编写规范
完整的规范详细说明了项目目标,描述了项目的功能,并讨论了其外观和用户界面。像任何好的项目或商业计划一样,规范应帮助你保持专注,确保项目按计划推进。我们在这里不会编写完整的项目规范,但会列出一些清晰的目标,以确保开发过程有条不紊。以下是我们将使用的规范:
我们将编写一个名为 Learning Log 的 web 应用,它允许用户记录他们感兴趣的主题,并在学习每个主题时做日记记录。Learning Log 的主页将描述该网站,并邀请用户注册或登录。一旦登录,用户可以创建新主题、添加新条目,并阅读和编辑现有条目。
当你研究一个新主题时,保持学习日志可以帮助你跟踪新信息以及已经找到的信息。尤其在学习技术类内容时,这一点尤为重要。一个好的应用程序,像我们将要创建的这个应用,可以帮助你提高这一过程的效率。
创建虚拟环境
为了使用 Django,我们首先设置一个虚拟环境。虚拟环境 是系统中可以安装包并将其与其他 Python 包隔离的地方。将一个项目的库与其他项目分开是有益的,并且在第二十章将 Learning Log 部署到服务器时是必要的。
为你的项目创建一个新的目录,命名为 learning_log,在终端中切换到该目录,并输入以下代码来创建虚拟环境:
learning_log$ **python -m venv ll_env**
learning_log$
在这里,我们运行 venv 虚拟环境模块,并使用它来创建名为 ll_env 的环境(请注意,这个名称以两个小写字母 L 开头,而不是两个数字 1)。如果你在运行程序或安装包时使用像 python3 这样的命令,请确保在这里也使用相同的命令。
激活虚拟环境
现在我们需要激活虚拟环境,使用以下命令:
learning_log$ **source ll_env/bin/activate**
(ll_env)learning_log$
该命令运行 ll_env/bin/ 中的 activate 脚本。当虚拟环境激活时,你会看到环境名称显示在括号中。这表示你可以向环境中安装新的包并使用已经安装的包。在 ll_env 中安装的包在环境不活跃时无法使用。
要停止使用虚拟环境,输入 deactivate:
(ll_env)learning_log$ **deactivate**
learning_log$
当你关闭运行虚拟环境的终端时,环境也会变得不活跃。
安装 Django
在虚拟环境激活后,输入以下命令来更新 pip 并安装 Django:
(ll_env)learning_log$ **pip install --upgrade pip**
(ll_env)learning_log$ **pip install django**
Collecting django
`--snip--`
Installing collected packages: sqlparse, asgiref, django
Successfully installed asgiref-3.5.2 django-4.1 sqlparse-0.4.2
(ll_env)learning_log$
由于 pip 会从多个来源下载资源,因此它会比较频繁地升级。每次创建新虚拟环境时,最好都升级 pip。
我们现在在虚拟环境中工作,所以安装 Django 的命令在所有系统中都是相同的。无需使用更长的命令,如 python -m pip install package_name,也不需要包括 --user 标志。请记住,Django 只有在 ll_env 环境激活时才可用。
在 Django 中创建项目
在不离开激活的虚拟环境(记得在终端提示符中查找括号里的 ll_env)的情况下,输入以下命令来创建一个新项目:
❶ (ll_env)learning_log$ **django-admin startproject ll_project .**
❷ (ll_env)learning_log$ **ls**
ll_env ll_project manage.py
❸ (ll_env)learning_log$ **ls ll_project**
__init__.py asgi.py settings.py urls.py wsgi.py
startproject 命令❶ 告诉 Django 创建一个名为 ll_project 的新项目。命令末尾的点(.)会创建一个新的项目,并生成一个目录结构,这样在我们完成开发后,可以方便地将应用部署到服务器上。
运行 ls 命令(Windows 上使用 dir)❷ 可以看到 Django 创建了一个名为 ll_project 的新目录。它还创建了一个 manage.py 文件,这是一个简短的程序,用来接收命令并将其传递给 Django 的相关部分。我们将使用这些命令来管理任务,如操作数据库和运行服务器。
ll_project目录包含四个文件 ❸;其中最重要的是settings.py、urls.py和wsgi.py。settings.py文件控制 Django 如何与您的系统交互并管理您的项目。在项目发展过程中,我们会修改其中的一些设置并添加我们自己的设置。urls.py文件告诉 Django 在响应浏览器请求时构建哪些页面。wsgi.py文件帮助 Django 提供它所创建的文件。该文件名是“Web 服务器网关接口”的缩写。
创建数据库
Django 将项目的大部分信息存储在数据库中,因此接下来我们需要创建一个 Django 可以使用的数据库。输入以下命令(仍然在活动环境中):
(ll_env)learning_log$ **python manage.py migrate**
❶ Operations to perform:
Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
Applying contenttypes.0001_initial... OK
Applying auth.0001_initial... OK
`--snip--`
Applying sessions.0001_initial... OK
❷ (ll_env)learning_log$ **ls**
db.sqlite3 ll_env ll_project manage.py
每当我们修改数据库时,我们称之为迁移数据库。首次执行migrate命令会告诉 Django 确保数据库与项目的当前状态相匹配。第一次在使用 SQLite 的新项目中运行此命令时,Django 会为我们创建一个新的数据库。在这里,Django 报告它将准备数据库以存储处理管理和认证任务所需的信息 ❶。
运行ls命令显示 Django 创建了另一个名为db.sqlite3的文件 ❷。SQLite是一个基于单个文件运行的数据库;它非常适合编写简单应用,因为您不需要过多关注数据库管理。
查看项目
让我们确保 Django 已正确设置项目。输入runserver命令查看当前状态下的项目:
(ll_env)learning_log$ **python manage.py runserver**
Watching for file changes with StatReloader
Performing system checks...
❶ System check identified no issues (0 silenced).
May 19, 2022 - 21:52:35
❷ Django version 4.1, using settings 'll_project.settings'
❸ Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
Django 应该启动一个名为开发服务器的服务器,这样您就可以在系统上查看项目,看看它的运行效果。当您通过在浏览器中输入 URL 来请求页面时,Django 服务器会响应该请求,构建相应的页面并将其发送到浏览器。
Django 首先检查确保项目已正确设置 ❶;然后报告正在使用的 Django 版本以及正在使用的设置文件的名称 ❷。最后,它报告项目正在服务的 URL ❸。URL http://127.0.0.1:8000/ 表示该项目正在监听计算机上端口 8000 的请求,这个地址被称为本地主机(localhost)。localhost指的是一个仅处理您系统上的请求的服务器;它不允许其他人查看您正在开发的页面。
打开一个网页浏览器并输入 URL http://localhost:8000/,或者如果第一个无法使用,可以输入 http://127.0.0.1:8000/。您应该会看到类似于图 18-1 的内容:Django 创建的页面,用来告诉您到目前为止一切正常。暂时保持服务器运行,但当您想停止服务器时,可以在发出runserver命令的终端中按 CTRL-C。

图 18-1:到目前为止一切正常。
启动一个应用
Django 项目 被组织为一组独立的 应用,这些应用共同工作,使项目作为一个整体运作。现在,我们将创建一个应用来完成我们项目的大部分工作。在第十九章中,我们会添加另一个应用来管理用户账户。
你应该保持之前打开的终端窗口中的开发服务器正在运行。打开一个新的终端窗口(或标签页),并导航到包含 manage.py 的目录。激活虚拟环境,然后运行 startapp 命令:
learning_log$ **source ll_env/bin/activate**
(ll_env)learning_log$ **python manage.py startapp learning_logs**
❶ (ll_env)learning_log$ **ls**
db.sqlite3 learning_logs ll_env ll_project manage.py
❷ (ll_env)learning_log$ **ls learning_logs/**
__init__.py admin.py apps.py migrations models.py tests.py views.py
命令 startapp appname 告诉 Django 创建构建应用所需的基础设施。当你现在查看项目目录时,你会看到一个名为 learning_logs ❶ 的新文件夹。使用 ls 命令查看 Django 创建了什么 ❷。最重要的文件是 models.py、admin.py 和 views.py。我们将使用 models.py 来定义我们想要在应用中管理的数据。稍后我们会再看看 admin.py 和 views.py。
定义模型
让我们思考一下我们的数据。每个用户需要在他们的学习日志中创建多个话题。每个条目都会与一个话题关联,并且这些条目将以文本的形式显示。我们还需要存储每个条目的时间戳,以便向用户展示他们何时创建了每个条目。
打开文件 models.py 并查看其现有内容:
models.py
from django.db import models
# Create your models here.
一个名为 models 的模块正在被导入,我们被邀请创建我们自己的模型。模型 告诉 Django 如何处理将存储在应用中的数据。模型是一个类;它有属性和方法,就像我们讨论过的每个类一样。这里是用户将要存储的主题模型:
from django.db import models
class Topic(models.Model):
"""A topic the user is learning about."""
❶ text = models.CharField(max_length=200)
❷ date_added = models.DateTimeField(auto_now_add=True)
❸ def __str__(self):
"""Return a string representation of the model."""
return self.text
我们创建了一个名为 Topic 的类,它继承自 Model —— 这是 Django 中定义模型基本功能的父类。我们在 Topic 类中添加了两个属性:text 和 date_added。
text 属性是一个 CharField,它由字符或文本组成 ❶。当你想存储少量文本时,比如名称、标题或城市时,使用 CharField。在定义 CharField 属性时,我们需要告诉 Django 在数据库中为它预留多少空间。这里我们给它设置了 max_length 为 200 个字符,这应该足够容纳大多数话题名称。
date_added 属性是一个 DateTimeField,用于记录日期和时间 ❷。我们传递参数 auto_now_add=True,告诉 Django 每当用户创建一个新话题时,自动将此属性设置为当前日期和时间。
告诉 Django 你希望它如何表示模型的一个实例是个好主意。如果一个模型有 __str__() 方法,每当 Django 需要生成与该模型实例相关的输出时,它就会调用该方法。这里我们编写了一个 __str__() 方法,它返回分配给 text 属性的值 ❸。
要查看你可以在模型中使用的不同字段类型,请参考“模型字段参考”页面:docs.djangoproject.com/en/4.1/ref/models/fields。你现在可能不需要所有信息,但在你开发自己的 Django 项目时,这些信息将非常有用。
激活模型
要使用我们的模型,我们必须告诉 Django 将我们的应用包含在整体项目中。打开 settings.py(在 ll_project 目录下);你会看到一个部分,告诉 Django 哪些应用已安装在项目中:
settings.py
`--snip--`
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
]
`--snip--`
通过修改 INSTALLED_APPS 来将我们的应用添加到此列表,使其看起来像这样:
*--snip--*
INSTALLED_APPS = [
# My apps.
'learning_logs',
# Default django apps.
'django.contrib.admin',
*--snip--*
]
*--snip--*
在一个项目中将应用分组有助于在项目增长并包含更多应用时进行追踪。这里我们开始一个名为 My apps 的部分,目前只包含 'learning_logs'。将自己的应用放在默认应用之前很重要,以防你需要用自定义行为覆盖默认应用的某些行为。
接下来,我们需要告诉 Django 修改数据库,以便它可以存储与模型 Topic 相关的信息。在终端中运行以下命令:
(ll_env)learning_log$ **python manage.py makemigrations learning_logs**
Migrations for 'learning_logs':
learning_logs/migrations/0001_initial.py
- Create model Topic
(ll_env)learning_log$
命令 makemigrations 告诉 Django 找出如何修改数据库,以便它可以存储与我们定义的任何新模型相关的数据。这里的输出显示 Django 创建了一个名为 0001_initial.py 的迁移文件。这个迁移文件将在数据库中为模型 Topic 创建一个表。
现在我们将应用这个迁移,并让 Django 为我们修改数据库:
(ll_env)learning_log$ **python manage.py migrate**
Operations to perform:
Apply all migrations: admin, auth, contenttypes, learning_logs, sessions
Running migrations:
Applying learning_logs.0001_initial... OK
这个命令的输出大部分与第一次执行 migrate 命令时的输出相同。我们需要检查输出的最后一行,Django 会在这里确认 learning_logs 的迁移是否成功 OK。
每当我们想修改 Learning Log 管理的数据时,我们将遵循以下三个步骤:修改 models.py,在 learning_logs 上调用 makemigrations,然后告诉 Django 执行 migrate 操作。
Django 管理站点
Django 通过其管理站点使得与模型的交互变得简单。Django 的 管理站点 仅供网站管理员使用;不适用于普通用户。在本节中,我们将设置管理站点,并通过 Topic 模型使用它添加一些主题。
设置超级用户
Django 允许你创建一个 超级用户,一个拥有网站所有权限的用户。用户的 权限 控制他们可以执行的操作。最严格的权限设置允许用户仅能读取网站上的公共信息。注册用户通常有权限读取自己的私人数据以及一些仅限成员查看的信息。为了有效地管理一个项目,网站所有者通常需要访问网站上存储的所有信息。一个好的管理员会小心处理用户的敏感信息,因为用户对他们访问的应用程序寄予了很大信任。
要在 Django 中创建超级用户,请输入以下命令并按照提示进行操作:
(ll_env)learning_log$ **python manage.py createsuperuser**
❶ Username (leave blank to use 'eric'): **ll_admin**
❷ Email address:
❸ Password:
Password (again):
Superuser created successfully.
(ll_env)learning_log$
当你执行 createsuperuser 命令时,Django 会提示你输入超级用户的用户名 ❶。这里我使用的是 ll_admin,但你可以输入任何你想要的用户名。你可以输入一个电子邮件地址,也可以将该字段留空 ❷。你需要输入密码两次 ❸。
在管理员网站注册模型
Django 自动将一些模型包含在管理员网站中,例如 User 和 Group,但是我们创建的模型需要手动添加。
当我们启动 learning_logs 应用时,Django 会在与 models.py 相同的目录中创建一个 admin.py 文件。打开 admin.py 文件:
admin.py
from django.contrib import admin
# Register your models here.
要将 Topic 注册到管理员网站,请输入以下内容:
from django.contrib import admin
from .models import Topic
admin.site.register(Topic)
这段代码首先导入我们要注册的模型 Topic。models 前面的点告诉 Django 在与 admin.py 相同的目录中查找 models.py 文件。admin.site.register() 代码告诉 Django 通过管理员网站管理我们的模型。
现在使用超级用户账户访问管理员网站。访问 http://localhost:8000/admin/,并输入你刚刚创建的超级用户的用户名和密码。你应该会看到一个类似 图 18-2 的页面。这个页面允许你添加新用户和组,并修改现有的用户和组。你也可以操作与我们刚刚定义的 Topic 模型相关的数据。

图 18-2:包含 Topic 的管理员网站
添加主题
现在 Topic 已经在管理员网站注册,接下来让我们添加第一个主题。点击 Topics 进入主题页面,页面大部分为空,因为我们还没有需要管理的主题。点击 Add Topic,会出现一个添加新主题的表单。在第一个框中输入 Chess,然后点击 Save。你将被带回主题管理员页面,你会看到刚刚创建的主题。
让我们创建第二个主题,以便有更多的数据进行操作。再次点击 Add Topic,输入 Rock Climbing。点击 Save,你会再次被带回主主题页面。现在,你会看到 Chess 和 Rock Climbing 列出在页面上。
定义 Entry 模型
为了让用户记录他们在棋类和攀岩方面的学习内容,我们需要定义一个模型,用于记录用户可以在学习日志中做的条目。每个条目都需要与一个特定的主题相关联。这种关系被称为 多对一关系,意味着多个条目可以关联到一个主题。
下面是 Entry 模型的代码。将其放入你的 models.py 文件中:
models.py
from django.db import models
class Topic(models.Model):
*--snip--*
❶ class Entry(models.Model):
"""Something specific learned about a topic."""
❷ topic = models.ForeignKey(Topic, on_delete=models.CASCADE)
❸ text = models.TextField()
date_added = models.DateTimeField(auto_now_add=True)
❹ class Meta:
verbose_name_plural = 'entries'
def __str__(self):
"""Return a simple string representing the entry."""
❺ return f"{self.text[:50]}..."
Entry 类继承自 Django 的基础 Model 类,就像 Topic 类一样 ❶。第一个属性 topic 是一个 ForeignKey 实例 ❷。外键 是一个数据库术语;它是指向数据库中另一个记录的引用。这段代码将每个条目与特定主题关联起来。每个主题在创建时都会分配一个键或 ID。当 Django 需要在两条数据之间建立连接时,它会使用与每条数据相关联的键。稍后我们将使用这些连接来检索与某个特定主题相关的所有条目。on_delete=models.CASCADE 参数告诉 Django,当删除一个主题时,与该主题关联的所有条目也应该被删除。这被称为 级联删除。
接下来是一个名为 text 的属性,它是 TextField 的一个实例 ❸。这种字段不需要大小限制,因为我们不希望限制单个条目的大小。date_added 属性允许我们按创建顺序展示条目,并为每个条目旁边添加一个时间戳。
Meta 类嵌套在 Entry 类中 ❹。Meta 类包含管理模型的额外信息;在这里,它允许我们设置一个特殊的属性,告诉 Django 在需要引用多个条目时使用 Entries。如果没有这个,Django 会将多个条目称为 Entrys。
__str__() 方法告诉 Django 在引用单个条目时显示哪些信息。因为一个条目可能包含大量文本,所以 __str__() 返回 text 的前 50 个字符 ❺。我们还添加了省略号,以明确表示我们并非总是显示完整的条目。
迁移条目模型
因为我们添加了一个新模型,所以我们需要再次迁移数据库。这个过程将变得非常熟悉:你修改 models.py,运行命令 python manage.py makemigrations app_name,然后运行命令 python manage.py migrate。
迁移数据库并通过输入以下命令来检查输出:
(ll_env)learning_log$ **python manage.py makemigrations learning_logs**
Migrations for 'learning_logs':
❶ learning_logs/migrations/0002_entry.py
- Create model Entry
(ll_env)learning_log$ **python manage.py migrate**
Operations to perform:
`--snip--`
❷ Applying learning_logs.0002_entry... OK
生成了一个新的迁移文件 0002_entry.py,它告诉 Django 如何修改数据库以存储与模型 Entry 相关的信息 ❶。当我们执行 migrate 命令时,Django 会应用这个迁移,并且一切正常工作 ❷。
在管理员网站注册条目
我们还需要注册 Entry 模型。现在 admin.py 应该是这样的:
admin.py
from django.contrib import admin
from .models import Topic, Entry
admin.site.register(Topic)
admin.site.register(Entry)
返回到 http://localhost/admin/,你应该看到在 Learning_Logs 下列出条目。点击添加链接来添加条目,或者点击条目,然后选择添加条目。你应该看到一个下拉列表,供你选择你要创建条目的主题,并且有一个文本框用来添加条目。选择下拉列表中的国际象棋,然后添加一个条目。这是我添加的第一个条目:
开局是游戏的第一部分,大约是前十步左右。在开局阶段,做三件事是个好主意——调动主教和骑士,尽量控制棋盘的中心,并为国王城堡。
当然,这些只是指导原则。了解何时遵循这些原则以及何时忽视这些建议非常重要。
当你点击保存时,页面会带你回到条目的主管理页面。在这里,你会看到使用text[:50]作为每个条目的字符串表示的好处;如果只显示条目的前部分而不是每个条目的完整内容,管理界面处理多个条目会更容易。
为 Chess 创建第二个条目,并为 Rock Climbing 创建一个条目,以便我们有一些初始数据。以下是 Chess 的第二个条目:
在游戏的开局阶段,重要的是要把你的主教和骑士调出来。这些棋子强大且灵活,足以在游戏的开始阶段发挥重要作用。
这是 Rock Climbing 的第一个条目:
攀岩中最重要的概念之一是尽可能将重心放在脚上。有人误以为攀岩者可以整天用手臂挂着。实际上,优秀的攀岩者已经练习过在可能的情况下将重心放在脚上。
这三个条目将为我们提供一些内容,帮助我们继续开发 Learning Log。
Django Shell
现在我们已经输入了一些数据,可以通过交互式终端会话以编程方式检查它。这个交互式环境叫做 Django 的shell,它是一个非常适合测试和排查项目问题的环境。以下是一个交互式 shell 会话的示例:
(ll_env)learning_log$ **python manage.py shell**
❶ >>> **from learning_logs.models import Topic**
>>> **Topic.objects.all()**
<QuerySet [<Topic: Chess>, <Topic: Rock Climbing>]>
在激活的虚拟环境中运行python manage.py shell命令,可以启动一个 Python 解释器,您可以用它来探索存储在项目数据库中的数据。在这里,我们从learning_logs.models模块导入Topic模型❶。然后,我们使用方法Topic.objects.all()来获取所有Topic模型的实例;返回的列表称为queryset。
我们可以像遍历列表一样遍历一个 queryset。以下是您如何查看分配给每个主题对象的 ID:
>>> **topics = Topic.objects.all()**
>>> **for topic in topics:**
... **print(topic.id, topic)**
...
1 Chess
2 Rock Climbing
我们将 queryset 分配给topics,然后打印每个主题的id属性以及每个主题的字符串表示。我们可以看到Chess的 ID 是1,Rock Climbing的 ID 是2。
如果您知道某个特定对象的 ID,可以使用方法Topic.objects.get()来检索该对象,并检查该对象的任何属性。让我们来看一下Chess的text和date_added值:
>>> **t = Topic.objects.get(id=1)**
>>> **t.text**
'Chess'
>>> **t.date_added**
datetime.datetime(2022, 5, 20, 3, 33, 36, 928759,
tzinfo=datetime.timezone.utc)
我们还可以查看与某个特定主题相关的条目。之前,我们为Entry模型定义了topic属性。这个属性是一个ForeignKey,它在每个条目和一个主题之间建立了连接。Django 可以利用这个连接获取与某个特定主题相关的每个条目,像这样:
❶ >>> **t.entry_set.all()**
<QuerySet [<Entry: The opening is the first part of the game, roughly...>, <Entry:
In the opening phase of the game, it's important t...>]>
要通过外键关系获取数据,你需要使用相关模型的小写名称,后跟下划线和单词set ❶。例如,假设你有 Pizza 和 Topping 两个模型,且 Topping 通过外键与 Pizza 相关。如果你的对象叫做 my_pizza,代表一个披萨,你可以使用代码 my_pizza.topping_set.all() 获取该披萨的所有配料。
当我们开始编写用户可以请求的页面代码时,我们将使用这个语法。shell 对于确保你的代码能正确获取你想要的数据非常有用。如果你的代码在 shell 中按预期工作,那么它也应该在项目中的文件中正确运行。如果你的代码产生错误或没有按预期获取数据,在简单的 shell 环境中调试代码要比在生成网页的文件中调试要容易得多。我们不会经常提到 shell,但你应该继续使用它来练习 Django 语法,以便访问项目中存储的数据。
每次修改模型后,你需要重新启动 shell 才能看到这些更改的效果。要退出 shell 会话,请按 CTRL-D;在 Windows 上,按 CTRL-Z 然后按 ENTER。
制作页面:Learning Log 主页
使用 Django 制作网页分为三个阶段:定义 URL、编写视图和编写模板。你可以按任何顺序进行这些操作,但在这个项目中,我们将始终从定义 URL 模式开始。URL 模式描述了 URL 的布局方式。它还告诉 Django 在匹配浏览器请求与网站 URL 时要查找什么,以便知道返回哪个页面。
每个 URL 都映射到一个特定的视图。视图函数获取并处理该页面所需的数据。视图函数通常会使用一个模板来渲染页面,模板包含页面的整体结构。为了了解这一过程,我们将制作 Learning Log 的主页。我们将定义主页的 URL,编写它的视图函数,并创建一个简单的模板。
因为我们只想确保 Learning Log 按预期工作,所以我们现在将制作一个简单的页面。完成后,功能完整的 Web 应用更容易进行样式化;而一个看起来不错但运行不良的应用毫无意义。目前,主页只会显示一个标题和简短的描述。
映射 URL
用户通过在浏览器中输入 URL 并点击链接来请求页面,因此我们需要决定需要哪些 URL。首先是主页的 URL:它是用户访问项目时使用的基础 URL。目前,基础 URL http://localhost:8000/ 会返回默认的 Django 网站,这告诉我们项目已经正确设置。我们将通过将基础 URL 映射到 Learning Log 的主页来改变这一点。
在主 ll_project 文件夹中,打开 urls.py 文件。你应该会看到以下代码:
ll_project/urls.py
❶ from django.contrib import admin
from django.urls import path
❷ urlpatterns = [
❸ path('admin/', admin.site.urls),
]
前两行导入了admin模块和一个用于构建 URL 路径的函数❶。文件的主体定义了urlpatterns变量❷。在这个urls.py文件中,它为整个项目定义了 URLs,urlpatterns变量包含了项目中各个应用的 URL 集合。列表中包括了模块admin.site.urls,它定义了所有可以从管理站点请求的 URLs❸。
我们需要包含learning_logs的 URLs,因此添加以下内容:
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('learning_logs.urls')),
]
我们已经导入了include()函数,并且还添加了一行来包含模块learning_logs.urls。
默认的urls.py文件在ll_project文件夹中;现在我们需要在learning_logs文件夹中创建一个第二个urls.py文件。创建一个新的 Python 文件,将其保存为urls.py在learning_logs文件夹中,并在其中输入以下代码:
learning_logs/urls.py
❶ """Defines URL patterns for learning_logs."""
❷ from django.urls import path
❸ from . import views
❹ app_name = 'learning_logs'
❺ urlpatterns = [
# Home page
❻ path('', views.index, name='index'),
]
为了明确我们正在处理哪个urls.py文件,我们在文件开头添加了文档字符串❶。然后我们导入了path函数,这是将 URLs 映射到视图时所需要的❷。我们还导入了views模块❸;点号告诉 Python 从与当前urls.py模块相同的目录导入views.py模块。变量app_name帮助 Django 区分这个urls.py文件与项目中其他应用中同名的文件❹。此模块中的urlpatterns变量是一个可以从learning_logs应用请求的单独页面的列表❺。
实际的 URL 模式是对path()函数的调用,该函数接受三个参数❻。第一个参数是一个字符串,帮助 Django 正确地路由当前请求。Django 接收到请求的 URL 后,会尝试将请求路由到一个视图。它通过搜索我们定义的所有 URL 模式,找到一个与当前请求匹配的模式来实现这一点。Django 忽略项目的基本 URL(http://localhost:8000/),所以空字符串('')与基本 URL 匹配。任何其他 URL 都无法匹配此模式,如果请求的 URL 没有匹配任何现有的 URL 模式,Django 将返回一个错误页面。
path()中的第二个参数❻指定了要调用的函数,即views.py中的函数。当请求的 URL 与我们定义的模式匹配时,Django 会调用views.py中的index()函数。(我们将在下一节编写这个视图函数。)第三个参数为这个 URL 模式提供了名称index,这样我们就可以在项目中的其他文件中更方便地引用它。每当我们想提供一个指向主页的链接时,我们会使用这个名称,而不是写出完整的 URL。
编写视图
视图函数接收来自请求的信息,准备生成页面所需的数据,然后将数据发送回浏览器。它通常通过使用一个模板来定义页面的外观,来完成这一过程。
在运行命令python manage.py startapp时,learning_logs中的views.py文件是自动生成的。目前,views.py中的内容如下:
views.py
from django.shortcuts import render
# Create your views here.
目前,这个文件只导入了render()函数,该函数根据视图提供的数据渲染响应。打开views.py并添加以下代码来创建主页:
from django.shortcuts import render
def index(request):
"""The home page for Learning Log."""
return render(request, 'learning_logs/index.xhtml')
当一个 URL 请求与我们刚刚定义的模式匹配时,Django 会在views.py文件中寻找一个名为index()的函数。然后,Django 将request对象传递给这个视图函数。在这种情况下,我们不需要处理页面的数据,所以函数中唯一的代码就是调用render()。这里的render()函数传递了两个参数:原始的request对象和一个可以用来构建页面的模板。让我们编写这个模板。
编写模板
模板定义了页面的外观,而 Django 则在每次请求页面时填充相关数据。模板允许你访问视图提供的任何数据。由于我们为主页提供的视图没有数据,因此这个模板相对简单。
在learning_logs文件夹内,创建一个名为templates的新文件夹。在templates文件夹内,再创建一个名为learning_logs的文件夹。这可能看起来有些冗余(我们在一个名为learning_logs的文件夹中再放一个名为templates的文件夹,里面再放一个名为learning_logs的文件夹),但这样可以建立一个 Django 可以明确理解的结构,即使在一个包含多个独立应用的大型项目中也是如此。在内部的learning_logs文件夹中,创建一个名为index.xhtml的新文件。该文件的路径将是ll_project/learning_logs/templates/learning_logs/index.xhtml。在该文件中输入以下代码:
index.xhtml
<p>Learning Log</p>
<p>Learning Log helps you keep track of your learning, for any topic you're
interested in.</p>
这是一个非常简单的文件。如果你不熟悉 HTML,<p></p>标签表示段落。<p>标签用于打开一个段落,</p>标签用于关闭一个段落。我们有两个段落:第一个作为标题,第二个描述用户可以在 Learning Log 中做什么。
现在,当你请求项目的基础 URL,http://localhost:8000/时,你应该能看到我们刚刚创建的页面,而不是默认的 Django 页面。Django 会获取请求的 URL,并将其与模式''进行匹配;然后,Django 将调用函数views.index(),该函数将使用index.xhtml中包含的模板来渲染页面。图 18-3 显示了生成的页面。

图 18-3:Learning Log 的主页
虽然创建一个页面看起来像是一个复杂的过程,但 URL、视图和模板之间的这种分离方式其实非常有效。它让你可以分别思考项目的每个方面。在更大的项目中,它使得参与项目的人可以专注于自己最擅长的领域。例如,数据库专家可以专注于模型,程序员可以专注于视图代码,前端专家则可以专注于模板。
创建附加页面
既然我们已经建立了构建页面的常规流程,接下来我们可以开始构建学习日志项目。我们将构建两个显示数据的页面:一个列出所有主题的页面和一个显示特定主题下所有条目的页面。对于每个页面,我们将指定一个 URL 模式,编写一个视图函数,并编写一个模板。但在这之前,我们将创建一个所有模板都可以继承的基础模板。
模板继承
在构建网站时,一些元素需要在每个页面上重复。与其在每个页面中直接编写这些元素,不如编写一个包含重复元素的基础模板,然后让每个页面继承自该基础模板。这种方法让你可以专注于开发每个页面的独特部分,并使得更改项目的整体外观和感觉变得更加容易。
父模板
我们将在与index.xhtml相同的目录下创建一个名为base.xhtml的模板。这个文件将包含所有页面共享的元素;其他所有模板都将继承自base.xhtml。目前我们只希望在每个页面上重复的元素是顶部的标题。因为我们将在每个页面中包含这个模板,所以让我们把标题做成指向主页的链接:
base.xhtml
<p>
❶ <a href="{% url 'learning_logs:index' %}">Learning Log</a>
</p>
❷ {% block content %}{% endblock content %}
这个文件的第一部分创建了一个包含项目名称的段落,该名称还充当主页链接。为了生成链接,我们使用一个模板标签,它由大括号和百分号表示({% %})。模板标签生成将在页面上显示的信息。这里显示的模板标签{% url 'learning_logs:index' %}生成一个与learning_logs/urls.py中定义的 URL 模式匹配的 URL,模式的名称为'index' ❶。在这个例子中,learning_logs是命名空间,而index是该命名空间中独一无二的 URL 模式。命名空间来自我们在learning_logs/urls.py文件中赋值给app_name的值。
在一个简单的 HTML 页面中,链接被锚点 标签 <a> 包围:
<a href="`link_url`">`link text`</a>
让模板标签为我们生成 URL 可以大大简化更新链接的过程。我们只需要在urls.py中更改 URL 模式,Django 会在下次请求页面时自动插入更新后的 URL。我们项目中的每个页面都将继承自base.xhtml,因此从现在起,每个页面都会有一个指向主页的链接。
在最后一行,我们插入了一对block标签 ❷。这个名为content的块是一个占位符;子模板将定义放入content块中的信息类型。
子模板不必定义父模板中的每个块,因此你可以在父模板中保留尽可能多的块空间;子模板只使用它需要的部分。
子模板
现在我们需要重写index.xhtml,使其继承自base.xhtml。请将以下代码添加到index.xhtml:
index.xhtml
❶ {% extends 'learning_logs/base.xhtml' %}
❷ {% block content %}
<p>Learning Log helps you keep track of your learning, for any topic you're
interested in.</p>
❸ {% endblock content %}
如果你将其与原始的index.xhtml进行对比,你会看到我们将学习日志的标题替换为了继承父模板的代码❶。子模板的第一行必须有{% extends %}标签,以告诉 Django 从哪个父模板继承。base.xhtml文件是learning_logs的一部分,因此我们在父模板的路径中包含了learning_logs。这一行引入了base.xhtml模板中的所有内容,并允许index.xhtml定义在content块中预留的空间中放置的内容。
我们通过插入一个名为content的{% block %}标签❷来定义内容块。所有没有从父模板继承的内容都放在content块内。这里,就是描述学习日志项目的段落。我们通过使用{% endblock content %}标签❸来表示内容定义的结束。{% endblock %}标签不需要指定名称,但如果模板中包含多个块,知道具体哪个块结束会很有帮助。
你可以开始看到模板继承的好处:在子模板中,我们只需要包含该页面独特的内容。这不仅简化了每个模板的编写,还使得修改站点变得更加容易。要修改多个页面共享的元素,只需要修改父模板。然后,你的更改将应用于所有继承自该模板的页面。在一个包含数十或数百个页面的项目中,这种结构可以大大提高更新站点的效率和速度。
在一个大型项目中,通常会有一个名为base.xhtml的父模板,涵盖整个站点,以及每个主要部分的父模板。所有的部分模板都继承自base.xhtml,站点中的每个页面都继承自一个部分模板。这样,你可以轻松地修改站点的整体外观、任何部分的外观,或者任何单独页面的外观。这种配置提供了一种非常高效的工作方式,并鼓励你随着时间的推移稳步更新你的项目。
主题页面
现在我们有了高效的页面构建方法,可以专注于接下来的两个页面:一般主题页面和显示单个主题条目的页面。主题页面将展示用户创建的所有主题,这是第一个涉及数据处理的页面。
主题 URL 模式
首先,我们定义主题页面的 URL。通常会选择一个简单的 URL 片段,反映页面上呈现的信息类型。我们将使用topics这个词,因此 URL http://localhost:8000/topics/ 将返回该页面。以下是我们如何修改learning_logs/urls.py:
learning_logs/urls.py
"""Defines URL patterns for learning_logs."""
*--snip--*
urlpatterns = [
# Home page
path('', views.index, name='index'),
# Page that shows all topics.
path('topics/', views.topics, name='topics'),
]
新的 URL 模式是单词 topics,后跟一个斜杠。当 Django 检查请求的 URL 时,这个模式将匹配任何以基本 URL 后跟 topics 的 URL。你可以选择在末尾添加或省略斜杠,但在 topics 后面不能有其他内容,否则模式将无法匹配。任何与此模式匹配的 URL 请求将被传递给 views.py 中的 topics() 函数。
主题视图
topics() 函数需要从数据库中获取一些数据,并将其发送到模板。将以下内容添加到 views.py:
views.py
from django.shortcuts import render
❶ from .models import Topic
def index(request):
*--snip--*
❷ def topics(request):
"""Show all topics."""
❸ topics = Topic.objects.order_by('date_added')
❹ context = {'topics': topics}
❺ return render(request, 'learning_logs/topics.xhtml', context)
我们首先导入与我们需要的数据相关的模型 ❶。topics() 函数需要一个参数:Django 从服务器接收到的 request 对象 ❷。我们通过请求 Topic 对象并按 date_added 属性排序来查询数据库 ❸。我们将结果查询集分配给 topics。
然后我们定义一个上下文,将其传送到模板 ❹。上下文 是一个字典,其中键是我们在模板中用于访问所需数据的名称,值是我们需要传递给模板的数据。在这种情况下,有一对键值对,包含我们将在页面上显示的主题集合。在构建使用数据的页面时,我们调用 render(),传入 request 对象、我们想要使用的模板和 context 字典 ❺。
主题模板
主题页面的模板接收 context 字典,因此模板可以使用 topics() 提供的数据。请在与 index.xhtml 相同的目录中创建一个名为 topics.xhtml 的文件。以下是我们如何在模板中显示主题:
topics.xhtml
{% extends 'learning_logs/base.xhtml' %}
{% block content %}
<p>Topics</p>
❶ <ul>
❷ {% for topic in topics %}
❸ <li>{{ topic.text }}</li>
❹ {% empty %}
<li>No topics have been added yet.</li>
❺ {% endfor %}
❻ </ul>
{% endblock content %}
我们使用 {% extends %} 标签从 base.xhtml 继承,就像在主页中做的那样,然后打开一个 content 块。该页面的主体包含已输入主题的项目符号列表。在标准 HTML 中,项目符号列表被称为 无序列表,并通过 <ul></ul> 标签表示。打开标签 <ul> 开始了主题的项目符号列表 ❶。
接下来,我们使用一个等效于 for 循环的模板标签,它遍历 context 字典中的 topics 列表 ❷。模板中使用的代码在一些重要方面与 Python 不同。Python 使用缩进来指示 for 语句中的哪些行属于循环。在模板中,每个 for 循环都需要一个明确的 {% endfor %} 标签,指示循环的结束。因此,在模板中,你会看到类似这样的循环:
{% for `item` in `list` %}
`do something with each item`
{% endfor %}
在循环内部,我们希望将每个话题转化为项目符号列表中的一个项目。要在模板中打印变量,请将变量名包裹在双大括号中。大括号不会出现在页面上,它们只是告诉 Django 我们正在使用模板变量。因此,代码{{ topic.text }} ❸将在循环的每次迭代中被当前话题的text属性的值所替代。HTML 标签<li></li>表示一个列表项。任何位于这两个标签之间、在<ul></ul>标签对中的内容,都将作为项目符号项目显示在列表中。
我们还使用了{% empty %}模板标签 ❹,它告诉 Django 在列表中没有项目时该怎么办。在这种情况下,我们会打印一条消息,告知用户尚未添加任何话题。最后两行关闭for循环 ❺,然后关闭项目符号列表 ❻。
现在我们需要修改基础模板,以便包含指向话题页面的链接。将以下代码添加到base.xhtml中:
base.xhtml
<p>
❶ <a href="{% url 'learning_logs:index' %}">Learning Log</a> -
❷ <a href="{% url 'learning_logs:topics' %}">Topics</a>
</p>
{% block content %}{% endblock content %}
我们在主页链接后添加一个破折号 ❶,然后再次使用{% url %}模板标签添加指向话题页面的链接 ❷。这一行告诉 Django 生成一个与learning_logs/urls.py中名为'topics'的 URL 模式匹配的链接。
现在,当您在浏览器中刷新主页时,您会看到一个话题链接。当您点击该链接时,您将看到一个类似于图 18-4 的页面。

图 18-4:话题页面
单个话题页面
接下来,我们需要创建一个页面,专注于单个话题,显示该话题的名称以及所有相关条目。我们将定义一个新的 URL 模式,编写一个视图,并创建一个模板。我们还将修改话题页面,使项目符号列表中的每个项目都链接到其对应的话题页面。
话题 URL 模式
话题页面的 URL 模式与之前的 URL 模式略有不同,因为它将使用话题的id属性来指示请求的具体话题。例如,如果用户想查看象棋话题的详细页面(其中id为 1),则 URL 将是http://localhost:8000/topics/1/。以下是匹配此 URL 的模式,您应将其放置在learning_logs/urls.py中:
learning_logs/urls.py
*--snip--*
urlpatterns = [
*--snip--*
# Detail page for a single topic.
path('topics/<int:topic_id>/', views.topic, name='topic'),
]
让我们分析这个 URL 模式中的字符串'topics/<int:topic_id>/'。字符串的第一部分告诉 Django 在基础 URL 后查找包含单词topics的 URL。字符串的第二部分/<int:topic_id>/,匹配两个斜杠之间的整数,并将整数值分配给名为topic_id的参数。
当 Django 找到与此模式匹配的 URL 时,它会调用视图函数topic(),并将分配给topic_id的值作为参数传递。我们将在函数中使用topic_id的值来获取正确的话题。
话题视图
topic()函数需要从数据库中获取话题及其所有相关条目,类似于我们之前在 Django shell 中所做的:
views.py
`--snip--`
❶ def topic(request, topic_id):
"""Show a single topic and all its entries."""
❷ topic = Topic.objects.get(id=topic_id)
❸ entries = topic.entry_set.order_by('-date_added')
❹ context = {'topic': topic, 'entries': entries}
❺ return render(request, 'learning_logs/topic.xhtml', context)
这是第一个需要除了request对象外的其他参数的视图函数。该函数接受由表达式 /<int:topic_id>/ 捕获的值,并将其分配给 topic_id ❶。然后我们使用 get() 来检索主题,就像我们在 Django shell 中做的那样 ❷。接下来,我们获取与此主题相关的所有条目,并按date_added ❸ 排序。date_added 前的负号将结果按逆序排列,从而使最新的条目首先显示。我们将主题和条目存储在 context 字典 ❹ 中,并使用 render() 函数,将 request 对象、topic.xhtml 模板和 context 字典传递给它 ❺。
主题模板
模板需要显示主题的名称和条目。如果该主题尚未创建任何条目,我们还需要通知用户。
topic.xhtml
{% extends 'learning_logs/base.xhtml' %}
{% block content %}
❶ <p>Topic: {{ topic.text }}</p>
<p>Entries:</p>
❷ <ul>
❸ {% for entry in entries %}
<li>
❹ <p>{{ entry.date_added|date:'M d, Y H:i' }}</p>
❺ <p>{{ entry.text|linebreaks }}</p>
</li>
❻ {% empty %}
<li>There are no entries for this topic yet.</li>
{% endfor %}
</ul>
{% endblock content %}
我们扩展了 base.xhtml,正如我们将在项目中的所有页面中所做的那样。接下来,我们显示请求的主题的 text 属性 ❶。变量 topic 可用,因为它已包含在 context 字典中。然后我们开始一个项目符号列表 ❷,显示每个条目,并像之前处理主题时那样循环遍历它们 ❸。
每个项目列出两条信息:时间戳和每个条目的完整文本。对于时间戳 ❹,我们显示属性date_added的值。在 Django 模板中,竖线符号 (|) 代表模板的过滤器—一种在渲染过程中修改模板变量值的函数。过滤器date:'M d, Y H:i'以 2022 年 1 月 1 日 23:00 的格式显示时间戳。下一行显示当前条目的text属性值。过滤器linebreaks ❺ 确保长文本条目在浏览器中显示时会包含换行符,而不是一大段连续的文本。我们再次使用 {% empty %} 模板标签 ❻ 来打印一条消息,告知用户尚未创建任何条目。
来自主题页面的链接
在我们浏览器中查看主题页面之前,我们需要修改主题模板,以便每个主题都链接到相应的页面。以下是你需要在topics.xhtml中做的修改:
topics.xhtml
*--snip--*
{% for topic in topics %}
<li>
<a href="{% url 'learning_logs:topic' topic.id %}">
{{ topic.text }}</a></li>
</li>
{% empty %}
*--snip--*
我们使用 URL 模板标签来生成适当的链接,基于learning_logs中名为'topic'的 URL 模式。该 URL 模式需要一个topic_id参数,因此我们将属性topic.id添加到 URL 模板标签中。现在,主题列表中的每个主题都链接到相应的主题页面,例如 http://localhost:8000/topics/1/。
当你刷新主题页面并点击一个主题时,你应该会看到像图 18-5 那样的页面。

图 18-5:单一主题的详细页面,展示该主题的所有条目
总结
在本章中,你学习了如何使用 Django 框架开始构建一个简单的 web 应用程序。你了解了一个简要的项目规范,安装了 Django 到虚拟环境中,设置了一个项目,并检查了项目是否正确设置。你设置了一个应用并定义了模型,以表示应用的数据。你了解了数据库以及 Django 如何在你对模型进行更改后帮助你迁移数据库。你为管理站点创建了超级用户,并使用管理站点输入了一些初始数据。
你还探索了 Django shell,它允许你在终端会话中操作项目的数据。你学习了如何定义 URL、创建视图函数以及编写模板来制作你站点的页面。你还使用了模板继承来简化单个模板的结构,并在项目发展过程中使修改站点变得更加容易。
在第十九章中,你将创建直观且用户友好的页面,允许用户添加新的主题和条目,并编辑现有条目,而无需通过管理站点。你还将添加用户注册系统,允许用户创建账户并制作自己的学习日志。这是 web 应用的核心——创建一种任何数量的用户都可以互动的功能。
第十九章:用户账户

Web 应用程序的核心是让全球任何用户都能够在你的应用中注册账户并开始使用。在本章中,你将构建表单,让用户能够添加自己的主题和条目,并编辑现有条目。你还将学习 Django 如何防范常见的基于表单的页面攻击,这样你就不必花太多时间考虑如何确保应用的安全。
你还将实现一个用户身份验证系统。你将构建一个注册页面,让用户创建账户,然后将某些页面的访问权限限制为仅登录用户可见。接着,你将修改一些视图函数,让用户只能看到自己的数据。你将学习如何保持用户数据的安全性。
允许用户输入数据
在我们构建用于创建账户的身份验证系统之前,我们将首先添加一些允许用户输入自己数据的页面。我们将赋予用户添加新主题、添加新条目以及编辑其先前条目的能力。
目前,只有超级用户才能通过管理站点输入数据。我们不希望用户与管理站点进行交互,因此我们将使用 Django 的表单构建工具来构建允许用户输入数据的页面。
添加新主题
首先,让我们允许用户添加一个新主题。添加基于表单的页面与添加我们已经构建的页面方式类似:我们定义一个 URL,编写视图函数,然后编写模板。唯一显著的区别是增加了一个名为forms.py的新模块,它将包含表单。
主题 ModelForm
任何让用户输入并提交信息的网页都涉及一个 HTML 元素,称为表单。当用户输入信息时,我们需要验证所提供的信息是否是正确的数据类型,且不包含恶意内容,例如设计用来干扰服务器的代码。然后,我们需要处理并将有效的信息保存到数据库的适当位置。Django 自动化了这项工作。
在 Django 中构建表单的最简单方法是使用 ModelForm,它利用我们在第十八章中定义的模型信息自动构建表单。在 forms.py 文件中编写你的第一个表单,该文件应与 models.py 位于同一目录:
forms.py
from django import forms
from .models import Topic
❶ class TopicForm(forms.ModelForm):
class Meta:
❷ model = Topic
❸ fields = ['text']
❹ labels = {'text': ''}
我们首先导入 forms 模块和我们将要使用的模型 Topic。然后,我们定义一个名为 TopicForm 的类,它继承自 forms.ModelForm ❶。
ModelForm 的最简单版本由一个嵌套的 Meta 类构成,该类告诉 Django 基于哪个模型构建表单,并指定应包含哪些字段。这里,我们指定表单应基于 Topic 模型 ❷,并且只包含 text 字段 ❸。标签字典中的空字符串告诉 Django 不为 text 字段生成标签 ❹。
新主题的 URL
新页面的 URL 应该简短且富有描述性。当用户想要添加新主题时,我们将引导他们访问 http://localhost:8000/new_topic/。这是 new_topic 页面的网址模式;将其添加到 learning_logs/urls.py 中:
learning_logs/urls.py
*--snip--*
urlpatterns = [
*--snip--*
# Page for adding a new topic.
path('new_topic/', views.new_topic, name='new_topic'),
]
这个 URL 模式会将请求发送到视图函数 new_topic(),我们将在接下来编写它。
new_topic() 视图函数
new_topic() 函数需要处理两种不同的情况:初次请求 new_topic 页面时,应该显示一个空白表单;以及处理任何在表单中提交的数据。在表单数据处理完后,需要将用户重定向回 topics 页面:
views.py
from django.shortcuts import render, redirect
from .models import Topic
from .forms import TopicForm
*--snip--*
def new_topic(request):
"""Add a new topic."""
❶ if request.method != 'POST':
# No data submitted; create a blank form.
❷ form = TopicForm()
else:
# POST data submitted; process data.
❸ form = TopicForm(data=request.POST)
❹ if form.is_valid():
❺ form.save()
❻ return redirect('learning_logs:topics')
# Display a blank or invalid form.
❼ context = {'form': form}
return render(request, 'learning_logs/new_topic.xhtml', context)
我们导入了 redirect 函数,用于在用户提交主题后将他们重定向回 topics 页面。我们还导入了刚刚写的表单 TopicForm。
GET 和 POST 请求
构建应用时,您将使用的两种主要请求类型是 GET 和 POST。您使用 GET 请求来访问只从服务器读取数据的页面。通常,在用户需要通过表单提交信息时,您会使用 POST 请求。我们将为处理所有表单指定 POST 方法。(虽然存在一些其他类型的请求,但在这个项目中我们不会使用它们。)
new_topic() 函数接收 request 对象作为参数。当用户最初请求这个页面时,他们的浏览器会发送一个 GET 请求。当用户填写并提交表单后,他们的浏览器会发送一个 POST 请求。根据请求,我们可以判断用户是请求一个空白表单(GET)还是要求我们处理已填写的表单(POST)。
我们使用 if 语句来判断请求方法是 GET 还是 POST ❶。如果请求方法不是 POST,那么请求可能是 GET,因此我们需要返回一个空白表单。(如果是其他类型的请求,返回空白表单也是安全的。)我们创建一个 TopicForm 的实例 ❷,将其赋值给变量 form,并通过 context 字典将表单传递给模板 ❼。因为在实例化 TopicForm 时没有传递任何参数,Django 会创建一个空白表单,用户可以填写。
如果请求方法是 POST,else 块将运行并处理表单中提交的数据。我们创建一个 TopicForm 的实例 ❸,并将用户输入的数据传递给它,这些数据存储在 request.POST 中。返回的 form 对象包含用户提交的信息。
我们不能在数据库中保存提交的信息,直到我们检查它是否有效 ❹。is_valid() 方法检查所有必填字段是否已填写(表单中的所有字段默认都是必填的),并且检查输入的数据是否符合预期的字段类型——例如,text 的长度是否小于 200 个字符,如我们在第十八章的 models.py 中指定的那样。这个自动验证为我们节省了很多工作。如果一切有效,我们可以调用 save() ❺,它将表单中的数据写入数据库。
一旦我们保存了数据,就可以离开这个页面。redirect() 函数接受一个视图的名称,并将用户重定向到与该视图关联的页面。在这里,我们使用 redirect() 将用户的浏览器重定向到 topics 页面 ❻,在该页面上用户应该能看到他们刚刚输入的话题。
context 变量在视图函数的末尾定义,页面是使用模板 new_topic.xhtml 渲染的,我们接下来会创建这个模板。这段代码放在任何 if 块之外;无论是创建了一个空白表单,还是提交的表单被判断为无效时,这段代码都会运行。无效表单会包含一些默认的错误信息,帮助用户提交有效的数据。
new_topic 模板
现在我们将创建一个新模板 new_topic.xhtml,以展示我们刚刚创建的表单:
new_topic.xhtml
{% extends "learning_logs/base.xhtml" %}
{% block content %}
<p>Add a new topic:</p>
❶ <form action="{% url 'learning_logs:new_topic' %}" method='post'>
❷ {% csrf_token %}
❸ {{ form.as_div }}
❹ <button name="submit">Add topic</button>
</form>
{% endblock content %}
这个模板继承了base.xhtml,因此它与学习日志中其他页面具有相同的基本结构。我们使用 <form></form> 标签来定义一个 HTML 表单 ❶。action 参数告诉浏览器将表单提交的数据发送到哪里;在这种情况下,我们将数据发送回视图函数 new_topic()。method 参数告诉浏览器将数据作为 POST 请求提交。
Django 使用模板标签 {% csrf_token %} ❷ 来防止攻击者利用表单获取未经授权的服务器访问权限。(这种攻击被称为 跨站请求伪造。)接下来,我们展示表单;在这里你可以看到 Django 如何简化某些任务,例如显示表单。我们只需包含模板变量 {{ form.as_div }},Django 就会自动创建所有必要的字段来展示表单 ❸。as_div 修饰符告诉 Django 将所有表单元素渲染为 HTML <div></div> 元素;这是一种简单而整洁的方式来显示表单。
Django 并不会为表单创建提交按钮,因此我们在关闭表单之前定义一个按钮 ❹。
链接到新话题页面
接下来,我们在 topics 页面中包含一个指向 new_topic 页面链接:
topics.xhtml
{% extends "learning_logs/base.xhtml" %}
{% block content %}
<p>Topics</p>
<ul>
*--snip--*
</ul>
<a href="{% url 'learning_logs:new_topic' %}">Add a new topic</a>
{% endblock content %}
将链接放置在现有话题列表后面。图 19-1 显示了最终的表单;尝试使用表单添加一些你自己的新话题。

图 19-1:添加新话题的页面
添加新条目
现在用户可以添加新主题了,他们也希望添加新的条目。我们将再次定义一个 URL,编写视图函数和模板,并链接到页面。但首先,我们将向 forms.py 添加另一个类。
Entry ModelForm
我们需要创建一个与 Entry 模型关联的表单,但这一次,我们需要比 TopicForm 更多的自定义:
forms.py
from django import forms
from .models import Topic, Entry
class TopicForm(forms.ModelForm):
*--snip--*
class EntryForm(forms.ModelForm):
class Meta:
model = Entry
fields = ['text']
❶ labels = {'text': ''}
❷ widgets = {'text': forms.Textarea(attrs={'cols': 80})}
我们更新了 import 语句,包含了 Entry 和 Topic。我们创建了一个新的类 EntryForm,它继承自 forms.ModelForm。EntryForm 类有一个嵌套的 Meta 类,列出了它所基于的模型以及表单中要包含的字段。我们再次为字段 'text' 设置一个空的标签 ❶。
对于 EntryForm,我们包括了 widgets 属性 ❷。一个 widget 是一个 HTML 表单元素,如单行文本框、多行文本区域或下拉列表。通过包含 widgets 属性,你可以覆盖 Django 默认的 widget 选择。在这里,我们告诉 Django 使用一个 forms.Textarea 元素,宽度为 80 列,而不是默认的 40 列。这为用户提供了足够的空间来写下有意义的条目。
new_entry URL
新条目必须与特定主题关联,因此我们需要在添加新条目的 URL 中包含 topic_id 参数。以下是你需要添加到 learning_logs/urls.py 的 URL:
learning_logs/urls.py
*--snip--*
urlpatterns = [
*--snip--*
# Page for adding a new entry.
path('new_entry/<int:topic_id>/', views.new_entry, name='new_entry'),
]
这个 URL 模式匹配任何形如 http://localhost:8000/new_entry/id/ 的 URL,其中 id 是与主题 ID 匹配的数字。代码 <int:topic_id> 捕获一个数字值并将其赋值给变量 topic_id。当请求匹配这个模式的 URL 时,Django 会将请求和主题的 ID 发送到 new_entry() 视图函数。
new_entry() 视图函数
new_entry 的视图函数与添加新主题的函数非常相似。将以下代码添加到你的 views.py 文件中:
views.py
from django.shortcuts import render, redirect
from .models import Topic
from .forms import TopicForm, EntryForm
*--snip--*
def new_entry(request, topic_id):
"""Add a new entry for a particular topic."""
❶ topic = Topic.objects.get(id=topic_id)
❷ if request.method != 'POST':
# No data submitted; create a blank form.
❸ form = EntryForm()
else:
# POST data submitted; process data.
❹ form = EntryForm(data=request.POST)
if form.is_valid():
❺ new_entry = form.save(commit=False)
❻ new_entry.topic = topic
new_entry.save()
❼ return redirect('learning_logs:topic', topic_id=topic_id)
# Display a blank or invalid form.
context = {'topic': topic, 'form': form}
return render(request, 'learning_logs/new_entry.xhtml', context)
我们更新了 import 语句,包含了我们刚刚创建的 EntryForm。new_entry() 的定义有一个 topic_id 参数,用于存储它从 URL 中接收到的值。我们需要主题来渲染页面并处理表单数据,因此我们使用 topic_id 来获取正确的主题对象 ❶。
接下来,我们检查请求方法是 POST 还是 GET ❷。如果是 GET 请求,if 块将会执行,我们创建一个空的 EntryForm 实例 ❸。
如果请求方法是 POST,我们通过实例化 EntryForm 并用 request 对象中的 POST 数据填充它来处理数据❹。然后我们检查表单是否有效。如果有效,我们需要在保存条目到数据库之前设置条目对象的 topic 属性。当我们调用 save() 时,包含 commit=False 参数❺,告诉 Django 创建一个新的条目对象并将其分配给 new_entry,但暂时不保存到数据库。我们将 new_entry 的 topic 属性设置为函数开始时从数据库中获取的主题❻。然后我们再次调用 save(),不带参数,将条目与正确关联的主题一起保存到数据库。
redirect() 调用需要两个参数:我们希望重定向的视图名称和该视图函数所需的参数❼。在这里,我们将重定向到 topic(),它需要 topic_id 参数。这个视图随后会渲染用户创建条目的主题页面,用户应该能在条目列表中看到他们的新条目。
在函数的末尾,我们创建一个 context 字典,并使用 new_entry.xhtml 模板渲染页面。此代码会在空白表单或提交后发现无效的表单时执行。
new_entry 模板
正如以下代码所示,new_entry 的模板类似于 new_topic 的模板:
new_entry.xhtml
{% extends "learning_logs/base.xhtml" %}
{% block content %}
❶ <p><a href="{% url 'learning_logs:topic' topic.id %}">{{ topic }}</a></p>
<p>Add a new entry:</p>
❷ <form action="{% url 'learning_logs:new_entry' topic.id %}" method='post'>
{% csrf_token %}
{{ form.as_div }}
<button name='submit'>Add entry</button>
</form>
{% endblock content %}
我们在页面顶部显示主题❶,以便用户可以看到他们正在为哪个主题添加条目。该主题还充当返回该主题主页面的链接。
表单的 action 参数在 URL 中包含了 topic.id 的值,以便视图函数能够将新条目与正确的主题关联❷。除此之外,这个模板与 new_topic.xhtml 看起来几乎相同。
链接到 new_entry 页面
接下来,我们需要在每个主题页面的主题模板中包括一个指向 new_entry 页面的链接:
topic.xhtml
{% extends "learning_logs/base.xhtml" %}
{% block content %}
<p>Topic: {{ topic }}</p>
<p>Entries:</p>
<p>
<a href="{% url 'learning_logs:new_entry' topic.id %}">Add new entry</a>
</p>
<ul>
*--snip--*
</ul>
{% endblock content %}
我们将添加条目的链接放在显示条目之前,因为添加新条目将是此页面上最常见的操作。图 19-2 显示了 new_entry 页面。现在,用户可以为每个主题添加新的条目,且每个主题可以添加任意数量的条目。通过向你创建的一些主题添加条目,尝试一下 new_entry 页面。

图 19-2:new_entry 页面
编辑条目
现在我们将创建一个页面,用户可以在其中编辑他们添加的条目。
edit_entry URL
页面 URL 需要传递要编辑的条目的 ID。这里是 learning_logs/urls.py:
urls.py
*--snip--*
urlpatterns = [
*--snip--*
# Page for editing an entry.
path('edit_entry/<int:entry_id>/', views.edit_entry, name='edit_entry'),
]
这个 URL 模式匹配像 http://localhost:8000/edit_entry/id/ 这样的 URL。在这里,id 的值被分配给 entry_id 参数。Django 会将与此格式匹配的请求发送到视图函数 edit_entry()。
edit_entry() 视图函数
当edit_entry页面收到 GET 请求时,edit_entry()函数将返回一个用于编辑条目的表单。当页面收到包含修改后条目文本的 POST 请求时,它会将修改后的文本保存到数据库中:
views.py
from django.shortcuts import render, redirect
from .models import Topic, Entry
from .forms import TopicForm, EntryForm
*--snip--*
def edit_entry(request, entry_id):
"""Edit an existing entry."""
❶ entry = Entry.objects.get(id=entry_id)
topic = entry.topic
if request.method != 'POST':
# Initial request; pre-fill form with the current entry.
❷ form = EntryForm(instance=entry)
else:
# POST data submitted; process data.
❸ form = EntryForm(instance=entry, data=request.POST)
if form.is_valid():
❹ form.save()
❺ return redirect('learning_logs:topic', topic_id=topic.id)
context = {'entry': entry, 'topic': topic, 'form': form}
return render(request, 'learning_logs/edit_entry.xhtml', context)
我们首先导入Entry模型。然后我们获取用户想要编辑的条目对象❶,以及与该条目相关联的主题。在if块中,这部分代码会在 GET 请求时执行,我们用instance=entry参数实例化EntryForm❷。该参数告诉 Django 创建表单,并预填充来自现有条目对象的信息。用户将看到他们现有的数据,并可以编辑这些数据。
在处理 POST 请求时,我们传递instance=entry和data=request.POST参数❸。这些参数告诉 Django 根据与现有条目对象关联的信息创建一个表单实例,并用request.POST中的相关数据更新它。然后,我们检查表单是否有效;如果有效,我们调用save()方法且不传递任何参数,因为条目已经与正确的主题关联❹。接着,我们重定向到topic页面,用户应该能看到他们编辑过的条目的更新版本❺。
如果我们展示的是用于编辑条目的初始表单,或者提交的表单无效,我们将创建context字典,并使用edit_entry.xhtml模板来渲染页面。
edit_entry 模板
接下来,我们创建一个edit_entry.xhtml模板,它类似于new_entry.xhtml:
edit_entry.xhtml
{% extends "learning_logs/base.xhtml" %}
{% block content %}
<p><a href="{% url 'learning_logs:topic' topic.id %}">{{ topic }}</a></p>
<p>Edit entry:</p>
❶ <form action="{% url 'learning_logs:edit_entry' entry.id %}" method='post'>
{% csrf_token %}
{{ form.as_div }}
❷ <button name="submit">Save changes</button>
</form>
{% endblock content %}
action参数将表单发送回edit_entry()函数进行处理❶。我们在{% url %}标签中包含entry.id作为参数,以便视图函数可以修改正确的条目对象。我们将提交按钮标记为Save changes,提醒用户他们正在保存编辑内容,而不是创建新条目❷。
链接到 edit_entry 页面
现在我们需要为每个条目在主题页面上添加指向edit_entry页面的链接:
topic.xhtml
*--snip--*
{% for entry in entries %}
<li>
<p>{{ entry.date_added|date:'M d, Y H:i' }}</p>
<p>{{ entry.text|linebreaks }}</p>
<p>
<a href="{% url 'learning_logs:edit_entry' entry.id %}">
Edit entry</a></p>
</li>
*--snip--*
我们在每个条目的日期和文本展示后,添加编辑链接。我们使用{% url %}模板标签来确定edit_entry的命名 URL 模式的 URL,并结合当前条目在循环中的 ID 属性(entry.id)。链接文本Edit entry将出现在页面上每个条目之后。图 19-3 展示了带有这些链接的主题页面样式。

图 19-3:每个条目现在都有一个编辑该条目的链接。
Learning Log 现在已经具备了大部分所需功能。用户可以添加主题和条目,并且可以浏览他们想要的任何一组条目。在下一节中,我们将实现一个用户注册系统,以便任何人都可以在 Learning Log 上注册账户并创建自己的主题和条目。
设置用户账户
在本节中,我们将设置一个用户注册和授权系统,以便用户可以注册帐户、登录和登出。我们将创建一个新应用来包含与用户相关的所有功能。我们将尽可能使用 Django 提供的默认用户认证系统来完成大部分工作。同时,我们还会稍微修改 Topic 模型,使每个主题都属于某个用户。
账户应用
我们将通过使用 startapp 命令创建一个名为 accounts 的新应用:
(ll_env)learning_log$ **python manage.py startapp accounts**
(ll_env)learning_log$ **ls**
❶ accounts db.sqlite3 learning_logs ll_env ll_project manage.py
(ll_env)learning_log$ **ls accounts**
❷ __init__.py admin.py apps.py migrations models.py tests.py views.py
默认的认证系统是围绕用户帐户的概念构建的,因此使用 accounts 这个名称使得与默认系统的集成更加容易。这里显示的 startapp 命令会创建一个名为 accounts ❶ 的新目录,其结构与 learning_logs 应用 ❷ 完全相同。
将账户添加到 settings.py
我们需要将新应用添加到 settings.py 的 INSTALLED_APPS 中,如下所示:
settings.py
*--snip--*
INSTALLED_APPS = [
# My apps
'learning_logs',
'accounts',
# Default django apps.
*--snip--*
]
*--snip--*
现在,Django 会将 accounts 应用包含到整个项目中。
包含来自 accounts 的 URL
接下来,我们需要修改根目录下的 urls.py,以便它包含我们为 accounts 应用编写的 URL:
ll_project/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('accounts/', include('accounts.urls')),
path('', include('learning_logs.urls')),
]
我们添加一行代码以包含来自 accounts 的 urls.py 文件。这一行代码将匹配任何以 accounts 开头的 URL,如 http://localhost:8000/accounts/login/。
登录页面
我们将首先实现一个登录页面。我们将使用 Django 提供的默认 login 视图,因此该应用的 URL 模式看起来稍微不同。在 ll_project/accounts/ 目录下创建一个新的 urls.py 文件,并向其中添加以下内容:
accounts/urls.py
"""Defines URL patterns for accounts."""
from django.urls import path, include
app_name = 'accounts'
urlpatterns = [
# Include default auth urls.
path('', include('django.contrib.auth.urls')),
]
我们导入 path 函数,然后导入 include 函数,这样我们可以包含一些 Django 已定义的默认认证 URL。这些默认的 URL 包括命名的 URL 模式,如 'login' 和 'logout'。我们将变量 app_name 设置为 'accounts',以便 Django 可以区分这些 URL 和属于其他应用的 URL。即使是 Django 提供的默认 URL,当包含在 accounts 应用的 urls.py 文件中时,也将通过 accounts 命名空间进行访问。
登录页面的模式匹配网址 http://localhost:8000/accounts/login/。当 Django 读取这个 URL 时,accounts 会告诉 Django 去查看 accounts/urls.py,而 login 则会告诉它将请求发送到 Django 默认的 login 视图。
登录模板
当用户请求登录页面时,Django 会使用默认的视图函数,但我们仍然需要为该页面提供一个模板。默认的认证视图会在一个名为 registration 的文件夹中查找模板,因此我们需要创建这个文件夹。在 ll_project/accounts/ 目录下,创建一个名为 templates 的目录;在其中,再创建一个名为 registration 的目录。以下是 login.xhtml 模板,应该保存在 ll_project/accounts/templates/registration 中:
login.xhtml
{% extends 'learning_logs/base.xhtml' %}
{% block content %}
❶ {% if form.errors %}
<p>Your username and password didn't match. Please try again.</p>
{% endif %}
❷ <form action="{% url 'accounts:login' %}" method='post'>
{% csrf_token %}
❸ {{ form.as_div }}
❹ <button name="submit">Log in</button>
</form>
{% endblock content %}
这个模板继承自 base.xhtml,确保登录页面与网站的其他部分保持相同的外观和感觉。请注意,一个应用中的模板可以继承自另一个应用中的模板。
如果表单的 errors 属性已设置,我们会显示一条错误信息 ❶,报告用户名和密码组合与数据库中存储的内容不匹配。
我们希望登录视图处理表单,因此我们将 action 参数设置为登录页面的 URL ❷。登录视图会将一个 form 对象传递给模板,接下来由我们来显示表单 ❸ 并添加提交按钮 ❹。
LOGIN_REDIRECT_URL 设置
一旦用户成功登录,Django 需要知道将该用户重定向到哪里。我们在设置文件中控制这一点。
将以下代码添加到 settings.py 的末尾:
settings.py
*--snip--*
# My settings.
LOGIN_REDIRECT_URL = 'learning_logs:index'
使用 settings.py 中的所有默认设置时,标记出我们添加新设置的部分会很有帮助。我们要添加的第一个新设置是 LOGIN_REDIRECT_URL,它告诉 Django 在成功登录后应该重定向到哪个 URL。
链接到登录页面
我们来将登录链接添加到 base.xhtml 中,以便它出现在每个页面上。我们不希望在用户已经登录时显示该链接,因此我们将它嵌套在 {% if %} 标签内:
base.xhtml
<p>
<a href="{% url 'learning_logs:index' %}">Learning Log</a> -
<a href="{% url 'learning_logs:topics' %}">Topics</a> -
❶ {% if user.is_authenticated %}
❷ Hello, {{ user.username }}.
{% else %}
❸ <a href="{% url 'accounts:login' %}">Log in</a>
{% endif %}
</p>
{% block content %}{% endblock content %}
在 Django 的认证系统中,每个模板都有一个可用的 user 对象,该对象始终具有 is_authenticated 属性:当用户登录时,属性值为 True,否则为 False。这个属性可以让你为已认证用户和未认证用户显示不同的消息。
在这里,我们显示了当前已登录用户的问候信息 ❶。已认证的用户会有一个额外的 username 属性,我们利用这个属性来个性化问候并提醒用户他们已经登录 ❷。对于未认证的用户,我们会显示一个指向登录页面的链接 ❸。
使用登录页面
我们已经设置好了用户账户,现在让我们登录看看页面是否正常工作。访问 http://localhost:8000/admin/。如果你仍然以管理员身份登录,可以在页眉中找到一个 logout 链接并点击它。
当你注销后,访问 http://localhost:8000/accounts/login/。你应该看到一个类似于 图 19-4 所示的登录页面。输入你之前设置的用户名和密码,应该会跳转回主页。主页的页眉应该会显示一个带有用户名的个性化问候。

图 19-4:登录页面
注销
现在我们需要提供一个方法,让用户可以注销。注销请求应该以 POST 请求的形式提交,因此我们将向 base.xhtml 添加一个小的注销表单。当用户点击注销按钮时,他们会看到一个确认已注销的页面。
将登出表单添加到 base.xhtml
我们将注销表单添加到 base.xhtml,这样它在每个页面上都可用。我们将其包含在另一个 if 语句块中,这样只有已登录的用户才能看到它:
base.xhtml
*--snip--*
{% block content %}{% endblock content %}
{% if user.is_authenticated %}
❶ <hr />
❷ <form action="{% url 'accounts:logout' %}" method='post'>
{% csrf_token %}
<button name='submit'>Log out</button>
</form>
{% endif %}
登出的默认 URL 模式是 'accounts/logout/'。但是,请求必须作为 POST 请求发送;否则,攻击者可以轻易发起强制注销请求。为了让注销请求使用 POST 方法,我们定义了一个简单的表单。
我们将表单放在页面底部,位于水平线元素 (<hr />) ❶ 下面。这是一种简单的方式,确保注销按钮始终处于页面中任何其他内容下方的统一位置。表单本身的 action 参数是注销 URL,'post' 是请求方法 ❷。Django 中的每个表单都需要包含 {% csrf_token %},即使是像这样的简单表单。这个表单除了提交按钮外是空的。
LOGOUT_REDIRECT_URL 设置
当用户点击注销按钮时,Django 需要知道将用户发送到哪里。我们在 settings.py 中控制这一行为:
settings.py
*--snip--*
# My settings.
LOGIN_REDIRECT_URL = 'learning_logs:index'
LOGOUT_REDIRECT_URL = 'learning_logs:index'
LOGOUT_REDIRECT_URL 设置指示 Django 在用户注销后将其重定向到主页。这是确认用户已注销的简单方法,因为注销后他们不应再看到自己的用户名。
注册页面
接下来,我们将构建一个页面,让新用户可以注册。我们将使用 Django 的默认 UserCreationForm,但会编写我们自己的视图函数和模板。
注册 URL
以下代码提供了注册页面的 URL 模式,应当放在 accounts/urls.py 中:
accounts/urls.py
"""Defines URL patterns for accounts."""
from django.urls import path, include
from . import views
app_name = accounts
urlpatterns = [
# Include default auth urls.
path('', include('django.contrib.auth.urls')),
# Registration page.
path('register/', views.register, name='register'),
]
我们从 accounts 导入 views 模块,这是因为我们正在为注册页面编写自己的视图。注册页面的 URL 模式对应于 http://localhost:8000/accounts/register/ 并将请求发送到我们即将编写的 register() 函数。
register() 视图函数
register() 视图函数需要在首次请求注册页面时显示一个空白的注册表单,然后在表单提交后处理已完成的注册表单。当注册成功时,该函数还需要将新用户登录。将以下代码添加到 accounts/views.py 中:
accounts/views.py
from django.shortcuts import render, redirect
from django.contrib.auth import login
from django.contrib.auth.forms import UserCreationForm
def register(request):
"""Register a new user."""
if request.method != 'POST':
# Display blank registration form.
❶ form = UserCreationForm()
else:
# Process completed form.
❷ form = UserCreationForm(data=request.POST)
❸ if form.is_valid():
❹ new_user = form.save()
# Log the user in and then redirect to home page.
❺ login(request, new_user)
❻ return redirect('learning_logs:index')
# Display a blank or invalid form.
context = {'form': form}
return render(request, 'registration/register.xhtml', context)
我们导入 render() 和 redirect() 函数,然后导入 login() 函数,以便在用户的注册信息正确时将其登录。我们还导入了默认的 UserCreationForm。在 register() 函数中,我们检查是否是对 POST 请求做出响应。如果不是,我们就用没有初始数据的 UserCreationForm 实例化 ❶。
如果我们对 POST 请求做出响应,我们就基于提交的数据实例化 UserCreationForm ❷。然后我们检查数据是否有效 ❸——在这种情况下,检查用户名是否符合要求,密码是否匹配,并且用户没有在提交中尝试恶意操作。
如果提交的数据有效,我们调用表单的save()方法,将用户名和密码的哈希值保存到数据库中❹。save()方法返回新创建的用户对象,我们将其赋值给new_user。当用户信息保存后,我们通过调用login()函数并传入request和new_user对象❺来登录用户,这会为新用户创建一个有效的会话。最后,我们将用户重定向到主页❻,在页眉中显示个性化的问候,告诉他们注册成功。
在函数的末尾,我们渲染页面,该页面可能是一个空白表单,或者是一个提交但无效的表单。
注册模板
现在创建一个注册页面的模板,它将类似于登录页面。请确保将其保存在与login.xhtml相同的目录中:
register.xhtml
{% extends "learning_logs/base.xhtml" %}
{% block content %}
<form action="{% url 'accounts:register' %}" method='post'>
{% csrf_token %}
{{ form.as_div }}
<button name="submit">Register</button>
</form>
{% endblock content %}
这应该与我们一直在编写的其他基于表单的模板类似。我们再次使用as_div方法,这样 Django 会适当地显示表单中的所有字段,包括如果表单填写不正确时的错误信息。
链接到注册页面
接下来,我们将添加代码,向任何未登录的用户显示注册页面链接:
base.xhtml
*--snip--*
{% if user.is_authenticated %}
Hello, {{ user.username }}.
{% else %}
<a href="{% url 'accounts:register' %}">Register</a> -
<a href="{% url 'accounts:login' %}">Log in</a>
{% endif %}
*--snip--*
现在,已登录的用户会看到个性化的问候语和登出按钮。未登录的用户则会看到注册链接和登录链接。通过创建多个不同用户名的用户账户来试用注册页面。
在下一部分,我们将限制一些页面的访问,使其仅对注册用户可用,并确保每个主题都属于特定的用户。
允许用户拥有自己的数据
用户应该能够在他们的学习日志中输入私人数据,因此我们将创建一个系统来识别哪些数据属于哪个用户。然后,我们将限制某些页面的访问,确保用户只能操作自己的数据。
我们将修改Topic模型,使每个主题都属于一个特定用户。这也将处理条目,因为每个条目都属于特定的主题。我们将从限制某些页面的访问开始。
使用@login_required 限制访问
Django 使得通过@login_required装饰器限制某些页面的访问变得非常容易。回想一下第十一章中的内容,装饰器是一个放置在函数定义之前的指令,它修改函数的行为。我们来看一个示例。
限制访问主题页面
每个主题将由一个用户拥有,因此只有注册用户才能请求访问该主题页面。请将以下代码添加到learning_logs/views.py中:
learning_logs/views.py
from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required
from .models import Topic, Entry
*--snip--*
@login_required
def topics(request):
"""Show all topics."""
*--snip--*
我们首先导入login_required()函数。我们通过在topics()视图函数前加上@符号来将login_required()作为装饰器应用到topics()视图函数。因此,Python 会在运行topics()中的代码之前先运行login_required()中的代码。
login_required()中的代码检查用户是否已登录,只有在用户登录后,Django 才会执行topics()中的代码。如果用户未登录,他们将被重定向到登录页面。
为了使这个重定向生效,我们需要修改settings.py,以便 Django 知道在哪里找到登录页面。在settings.py的末尾添加以下内容:
settings.py
*--snip--*
# My settings.
LOGIN_REDIRECT_URL = 'learning_logs:index'
LOGOUT_REDIRECT_URL = 'learning_logs:index'
LOGIN_URL = 'accounts:login'
现在,当未经过身份验证的用户请求受@login_required装饰器保护的页面时,Django 将把用户发送到settings.py中定义的LOGIN_URL URL。
你可以通过注销任何用户帐户并访问主页来测试此设置。点击Topics链接,这应该会将你重定向到登录页面。然后登录到任何一个账户,再次从主页点击Topics链接。你应该能够访问主题页面。
限制在学习日志中的访问
Django 使得限制页面访问变得简单,但你需要决定哪些页面需要保护。最好先考虑哪些页面需要公开访问,然后限制项目中的所有其他页面。你可以轻松纠正过度限制的访问,这比让敏感页面没有限制更安全。
在 Learning Log 中,我们将保持主页和注册页面不受限制。我们将限制对其他页面的访问。
这是带有@login_required装饰器的learning_logs/views.py,除了index()之外,其他每个视图都应用了该装饰器:
learning_logs/views.py
*--snip--*
@login_required
def topics(request):
*--snip--*
@login_required
def topic(request, topic_id):
*--snip--*
@login_required
def new_topic(request):
*--snip--*
@login_required
def new_entry(request, topic_id):
*--snip--*
@login_required
def edit_entry(request, entry_id):
*--snip--*
尝试在登出状态下访问这些页面;你应该会被重定向回登录页面。你还无法点击像new_topic这样的页面链接。但如果你输入 URL http://localhost:8000/new_topic/,你会被重定向到登录页面。你应该限制任何与私人用户数据相关并且是公开访问的 URL。
将数据与特定用户关联
接下来,我们需要将数据与提交的用户关联。我们只需要将层次结构中最高级的数据与用户关联,低级数据会随之而来。在 Learning Log 中,主题是应用中最高级的数据,所有条目都与一个主题相关。只要每个主题属于一个特定的用户,我们就可以追溯到数据库中每个条目的所有者。
我们将通过向Topic模型添加一个外键关系来关联用户。接着,我们需要迁移数据库。最后,我们将修改一些视图,使它们只显示当前登录用户相关的数据。
修改主题模型
对models.py的修改仅仅是两行代码:
models.py
from django.db import models
from django.contrib.auth.models import User
class Topic(models.Model):
"""A topic the user is learning about."""
Text = models.CharField(max_length=200)
date_added = models.DateTimeField(auto_now_add=True)
owner = models.ForeignKey(User, on_delete=models.CASCADE)
def __str__(self):
"""Return a string representing the topic."""
Return self.text
class Entry(models.Model):
*--snip--*
我们从django.contrib.auth导入User模型。然后,我们在Topic中添加一个owner字段,这样就建立了与User模型的外键关系。如果用户被删除,所有与该用户相关的主题也将被删除。
识别现有用户
当我们迁移数据库时,Django 将修改数据库,以便能够存储每个话题与用户之间的关联。为了进行迁移,Django 需要知道与每个现有话题关联的用户。最简单的方法是先将所有现有话题分配给一个用户——例如,超级用户。但首先,我们需要知道该用户的 ID。
让我们看看目前为止创建的所有用户的 ID。在 Django shell 会话中输入以下命令:
(ll_env)learning_log$ **python manage.py shell**
❶ >>> **from django.contrib.auth.models import User**
❷ >>> **User.objects.all()**
<QuerySet [<User: ll_admin>, <User: eric>, <User: willie>]>
❸ >>> **for user in User.objects.all():**
... **print(user.username, user.id)**
...
ll_admin 1
eric 2
willie 3
>>>
我们首先将User模型导入到 shell 会话中❶。然后查看到目前为止创建的所有用户❷。输出显示了我版本中的三个用户:ll_admin、eric和willie。
接下来,我们循环遍历用户列表,打印每个用户的用户名和 ID❸。当 Django 询问要将现有话题与哪个用户关联时,我们将使用这些 ID 值之一。
迁移数据库
现在我们知道了用户的 ID,可以开始迁移数据库。当我们进行迁移时,Python 会要求我们暂时将Topic模型与某个特定的用户关联,或者在我们的models.py文件中添加默认值来告诉它该如何操作。选择选项1:
❶ (ll_env)learning_log$ **python manage.py makemigrations learning_logs**
❷ It is impossible to add a non-nullable field 'owner' to topic without
specifying a default. This is because...
❸ Please select a fix:
1) Provide a one-off default now (will be set on all existing rows with a
null value for this column)
2) Quit and manually define a default value in models.py.
❹ Select an option: **1**
❺ Please enter the default value now, as valid Python
The datetime and django.utils.timezone modules are available...
Type 'exit' to exit this prompt
❻ >>> **1**
Migrations for 'learning_logs':
learning_logs/migrations/0003_topic_owner.py
- Add field owner to topic
(ll_env)learning_log$
我们首先执行makemigrations命令❶。在输出中,Django 表示我们正在尝试向现有模型(topic)中添加一个必需的(非空)字段,但未指定默认值❷。Django 提供了两个选项:我们可以立即提供默认值,或者可以退出并在models.py中添加默认值❸。在这里,我选择了第一个选项❹。然后,Django 会要求我们输入默认值❺。
为了将所有现有话题与原始管理员用户ll_admin关联,我输入了用户 ID 1 ❻。你可以使用任何你创建的用户的 ID,它不必是超级用户。Django 然后使用该值迁移数据库,并生成迁移文件0003_topic_owner.py,该文件将owner字段添加到Topic模型中。
现在我们可以执行迁移。在一个活动的虚拟环境中输入以下命令:
(ll_env)learning_log$ **python manage.py migrate**
Operations to perform:
Apply all migrations: admin, auth, contenttypes, learning_logs, sessions
Running migrations:
❶ Applying learning_logs.0003_topic_owner... OK
(ll_env)learning_log$
Django 应用新迁移,结果是OK ❶。
我们可以通过在 shell 会话中执行以下命令来验证迁移是否按预期工作:
>>> **from learning_logs.models import Topic**
>>> **for topic in Topic.objects.all():**
... **print(topic, topic.owner)**
...
Chess ll_admin
Rock Climbing ll_admin
>>>
我们从learning_logs.models导入Topic,然后循环遍历所有现有话题,打印每个话题以及它所属的用户。你可以看到每个话题现在都属于用户ll_admin。(如果运行这段代码时遇到错误,请尝试退出 shell 并重新启动一个新的 shell。)
限制话题访问权限到适当的用户
目前,如果你登录了系统,你将能够看到所有话题,无论你以哪个用户身份登录。我们将通过只显示属于该用户的话题来改变这一点。
在views.py中的topics()函数中进行以下更改:
learning_logs/views.py
*--snip--*
@login_required
def topics(request):
"""Show all topics."""
topics = Topic.objects.filter(owner=request.user).order_by('date_added')
context = {'topics': topics}
return render(request, 'learning_logs/topics.xhtml', context)
*--snip--*
当用户登录时,request对象会设置request.user属性,其中包含用户信息。查询Topic.objects.filter(owner=request.user)会告诉 Django 只检索数据库中owner属性与当前用户匹配的Topic对象。由于我们没有改变主题的显示方式,所以不需要更改主题页面的模板。
要检查这是否有效,首先以你已将所有现有主题关联的用户身份登录,然后访问主题页面。你应该能看到所有的主题。接下来注销,并以另一个用户身份重新登录。你应该看到消息“尚未添加任何主题”。
保护用户的主题
我们还没有限制访问主题页面,因此任何注册用户都可以尝试多个 URL(如http://localhost:8000/topics/1/),并检索到恰好匹配的主题页面。
亲自尝试一下。当以拥有所有主题的用户身份登录时,复制一个主题的 URL 或记下 URL 中的 ID,然后注销并以另一个用户身份重新登录。输入该主题的 URL。即使你以不同的用户身份登录,你仍然应该能够读取该条目。
我们现在通过在topic()视图函数中执行检查,来解决这个问题,在检索请求的条目之前进行检查:
learning_logs/views.py
from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required
❶ from django.http import Http404
*--snip--*
@login_required
def topic(request, topic_id):
"""Show a single topic and all its entries."""
topic = Topic.objects.get(id=topic_id)
# Make sure the topic belongs to the current user.
❷ if topic.owner != request.user:
raise Http404
entries = topic.entry_set.order_by('-date_added')
context = {'topic': topic, 'entries': entries}
return render(request, 'learning_logs/topic.xhtml', context)
*--snip--*
404 响应是标准的错误响应,当请求的资源在服务器上不存在时返回。在这里,我们导入了Http404异常❶,如果用户请求一个他们没有权限访问的主题,我们将抛出此异常。在接收到主题请求后,我们在渲染页面之前,确保主题的拥有者与当前登录的用户匹配。如果请求的主题的拥有者与当前用户不相同,我们会抛出Http404异常❷,然后 Django 返回一个 404 错误页面。
现在,如果你尝试查看其他用户的主题条目,你会看到 Django 的“页面未找到”消息。在第二十章,我们将配置项目,使用户看到一个合适的错误页面,而不是调试页面。
保护edit_entry页面
edit_entry页面的 URL 形式为http://localhost:8000/edit_entry/entry_id/,其中 entry_id 是一个数字。我们来保护这个页面,以便没有人能通过 URL 访问他人的条目:
learning_logs/views.py
*--snip--*
@login_required
def edit_entry(request, entry_id):
"""Edit an existing entry."""
entry = Entry.objects.get(id=entry_id)
topic = entry.topic
if topic.owner != request.user:
raise Http404
if request.method != 'POST':
*--snip--*
我们检索与该条目关联的主题。然后我们检查该主题的拥有者是否与当前登录的用户匹配;如果不匹配,我们会抛出一个Http404异常。
将新主题与当前用户关联
当前,添加新主题的页面无法正常工作,因为它没有将新主题与任何特定用户关联。如果你尝试添加新主题,你会看到 IntegrityError 消息,伴随 NOT NULL constraint failed: learning_logs_topic.owner_id。Django 表示你无法创建一个新主题,除非为主题的 owner 字段指定一个值。
这个问题有一个直接的解决办法,因为我们可以通过request对象访问当前用户。添加以下代码,将新主题与当前用户关联起来:
learning_logs/views.py
*--snip--*
@login_required
def new_topic(request):
*--snip--*
else:
# POST data submitted; process data.
form = TopicForm(data=request.POST)
if form.is_valid():
❶ new_topic = form.save(commit=False)
❷ new_topic.owner = request.user
❸ new_topic.save()
return redirect('learning_logs:topics')
# Display a blank or invalid form.
context = {'form': form}
return render(request, 'learning_logs/new_topic.xhtml', context)
*--snip--*
当我们第一次调用form.save()时,我们传递了commit=False参数,因为在将新主题保存到数据库之前,我们需要修改它 ❶。然后,我们将新主题的owner属性设置为当前用户 ❷。最后,我们在刚刚定义的主题实例上调用save() ❸。现在,主题拥有了所有必需的数据,并将成功保存。
你应该能够为不同的用户添加尽可能多的新主题,每个用户只能访问属于他们自己的数据,无论他们是在查看数据、输入新数据,还是修改旧数据。
小结
在本章中,你学会了如何使用表单让用户添加新主题和条目,并编辑现有条目。然后,你学会了如何实现用户账户。你让现有用户能够登录和退出,并使用 Django 的默认UserCreationForm来让人们创建新账户。
在构建一个简单的用户身份验证和注册系统之后,你使用@login_required装饰器限制了登录用户访问某些页面。然后,你通过外键关系将数据分配给特定的用户。你还学会了在迁移时,如何在迁移需要你指定某些默认数据时进行数据库迁移。
最后,你学会了通过修改视图函数来确保用户只能看到属于他们的数据。你使用filter()方法检索了适当的数据,并将请求数据的所有者与当前登录用户进行了比较。
可能并不总是显而易见你应该提供哪些数据,哪些数据需要保护,但这个技能会通过实践逐渐掌握。本章中我们为了保护用户数据所做的决策也说明了在构建项目时与他人合作的好处:让别人检查你的项目可以提高你发现漏洞的可能性。
你现在已经在本地机器上运行了一个功能完整的项目。在最后一章中,你将对 Learning Log 进行样式美化,使其更具视觉吸引力,并将项目部署到服务器上,这样任何有互联网访问的人都可以注册并创建账户。
第二十章:样式和部署应用程序

Learning Log 现在已经完全可用,但它没有样式,仅在本地计算机上运行。在这一章中,你将以简单但专业的方式为项目添加样式,然后将其部署到实时服务器,使世界上任何人都可以注册并使用它。
对于样式,我们将使用Bootstrap库,这是一组用于为 Web 应用程序添加样式的工具,使其在所有现代设备上看起来都很专业,从小型手机到大型桌面显示器。为此,我们将使用 django-bootstrap5 应用程序,它还将帮助你练习使用其他 Django 开发者创建的应用程序。
我们将使用Platform.sh部署 Learning Log,这是一个允许你将项目推送到其服务器的站点,从而使其对任何有互联网连接的人可用。我们还将开始使用一种名为 Git 的版本控制系统来跟踪项目的变更。
当你完成 Learning Log 后,你将能够开发简单的 Web 应用程序,赋予它们专业的外观与感觉,并将其部署到实时服务器上。你还将能够在提升技能的过程中使用更高级的学习资源。
为 Learning Log 添加样式
我们故意推迟了样式设置,直到现在才开始,以便首先关注 Learning Log 的功能。这是一种不错的开发方式,因为一个应用程序只有在其功能正常时才有用。一旦应用程序能够正常工作,它的外观就变得至关重要,这样用户才会想使用它。
在这一部分,我们将安装 django-bootstrap5 应用程序并将其添加到项目中。然后,我们将使用它为项目中的各个页面添加样式,以便所有页面具有一致的外观和感觉。
django-bootstrap5 应用程序
我们将使用 django-bootstrap5 将 Bootstrap 集成到我们的项目中。这个应用程序会下载所需的 Bootstrap 文件,将其放置在项目中的适当位置,并在项目的模板中提供样式指令。
要安装 django-bootstrap5,请在活动的虚拟环境中执行以下命令:
(ll_env)learning_log$ **pip install django-bootstrap5**
`--snip--`
Successfully installed beautifulsoup4-4.11.1 django-bootstrap5-21.3
soupsieve-2.3.2.post1
接下来,我们需要将 django-bootstrap5 添加到INSTALLED_APPS中的settings.py:
settings.py
*--snip--*
INSTALLED_APPS = [
# My apps.
'learning_logs',
'accounts',
# Third party apps.
'django_bootstrap5',
# Default django apps.
'django.contrib.admin',
*--snip--*
为由其他开发者创建的应用程序启动一个新的部分,命名为 Third party apps,并将 'django_bootstrap5' 添加到该部分。确保将此部分放在 My apps 之后,但在包含 Django 默认应用程序的部分之前。
使用 Bootstrap 为 Learning Log 添加样式
Bootstrap 是一个庞大的样式工具集。它还提供了若干模板,可以应用到你的项目中,从而创建整体样式。使用这些模板比单独使用个别样式工具要简单得多。要查看 Bootstrap 提供的模板,请访问 getbootstrap.com 并点击 Examples。我们将使用Navbar static模板,它提供了一个简单的顶部导航栏和一个页面内容的容器。
图 20-1 展示了应用 Bootstrap 模板并稍微修改index.xhtml后,主页的样子。

图 20-1:使用 Bootstrap 的学习日志主页
修改 base.xhtml
我们需要使用 Bootstrap 模板重写base.xhtml。我们将分段开发新的base.xhtml。这是一个较大的文件,你可以从ehmatthes.github.io/pcc_3e的在线资源中复制该文件。如果你复制了该文件,仍然应该阅读以下部分,以了解所做的更改。
定义 HTML 头部
我们对base.xhtml所做的第一个修改是定义文件中的 HTML 头部。我们还将为在模板中使用 Bootstrap 添加一些要求,并为页面设置标题。删除base.xhtml中的所有内容,并用以下代码替换它:
base.xhtml
❶ <!doctype html>
❷ <html lang="en">
❸ <head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
❹ <title>Learning Log</title>
❺ {% load django_bootstrap5 %}
{% bootstrap_css %}
{% bootstrap_javascript %}
</head>
我们首先将此文件声明为一个用英语编写的 HTML 文档 ❶。HTML 文件分为两个主要部分:头部和主体。文件的头部以一个<head>标签开始 ❸。HTML 文件的头部不包含页面内容;它只是告诉浏览器如何正确显示页面所需的信息。我们为页面包含一个<title>元素,该元素将在浏览器的标题栏中显示每当“学习日志”页面被打开时 ❹。
在关闭头部部分之前,我们加载 django-bootstrap5 中可用的模板标签集 ❺。模板标签{% bootstrap_css %}是 django-bootstrap5 的一个自定义标签,它加载实现 Bootstrap 样式所需的所有 CSS 文件。接下来的标签启用页面上可能使用的所有交互行为,例如可折叠的导航栏。最后一行是关闭的</head>标签。
所有 Bootstrap 样式选项现在可以在任何继承自base.xhtml的模板中使用。如果你想在模板中使用 django-bootstrap5 的自定义模板标签,则每个模板都需要包含{% load django_bootstrap5 %}标签。
定义导航栏
定义页面顶部导航栏的代码相当长,因为它必须在狭窄的手机屏幕和宽大的桌面显示器上都能很好地工作。我们将分段处理导航栏。
这是导航栏的第一部分:
base.xhtml
*--snip--*
</head>
<body>
❶ <nav class="navbar navbar-expand-md navbar-light bg-light mb-4 border">
<div class="container-fluid">
❷ <a class="navbar-brand" href="{% url 'learning_logs:index' %}">
Learning Log</a>
❸ <button class="navbar-toggler" type="button" data-bs-toggle="collapse"
data-bs-target="#navbarCollapse" aria-controls="navbarCollapse"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
❹ <div class="collapse navbar-collapse" id="navbarCollapse">
❺ <ul class="navbar-nav me-auto mb-2 mb-md-0">
❻ <li class="nav-item">
❼ <a class="nav-link" href="{% url 'learning_logs:topics' %}">
Topics</a></li>
</ul> <!-- End of links on left side of navbar -->
</div> <!-- Closes collapsible parts of navbar -->
</div> <!-- Closes navbar's container -->
</nav> <!-- End of navbar -->
❽ {% block content %}{% endblock content %}
</body>
</html>
第一个新元素是开启的 <body> 标签。HTML 文件的 body 部分包含用户将在页面上看到的内容。接下来是一个 <nav> 元素,它开启了页面顶部导航栏的代码❶。这个元素中包含的所有内容都按照由选择器 navbar、navbar-expand-md 以及你在这里看到的其他选择器定义的 Bootstrap 样式规则进行样式设置。选择器 决定了哪些页面元素应用某个样式规则。navbar-light 和 bg-light 选择器为导航栏设置了浅色主题背景。mb 在 mb-4 中是 margin-bottom(下外边距)的缩写;该选择器确保在导航栏与页面其他部分之间出现一点空间。border 选择器为浅色背景提供了一条细边框,使其与页面其他部分略有区分。
下一行的 <div> 标签打开了一个可调整大小的容器,用于容纳整个导航栏。div 是 division 的缩写;你通过将网页分为几个部分来构建页面,并定义适用于该部分的样式和行为规则。任何在 <div> 开启标签中定义的样式或行为规则会影响从开启标签到其对应闭合标签 </div> 之间的所有内容。
接下来我们将项目名称 Learning Log 设置为导航栏中的第一个元素❷。它还将作为首页的链接,就像在我们之前两章构建的简洁版项目中那样。navbar-brand 选择器为此链接设置样式,使其从其他链接中脱颖而出,并帮助为网站增加品牌元素。
Bootstrap 模板定义了一个按钮,当浏览器窗口太窄,无法水平显示整个导航栏时,按钮会显示出来❸。当用户点击该按钮时,导航元素会以下拉列表的形式显示。collapse 参考标记会在用户缩小浏览器窗口或网站在小屏设备上显示时,使导航栏折叠。
接下来,我们开启导航栏的新部分(<div>)❹。这是导航栏的可折叠部分,取决于浏览器窗口的大小。
Bootstrap 将导航元素定义为无序列表中的项目❺,并设置样式规则使其看起来不像列表。你需要在导航栏中包含的每个链接或元素都可以作为无序列表中的一项❻。这里,列表中唯一的项目是指向主题页面的链接❼。注意链接末尾的闭合 </li> 标签;每个开启标签都需要对应的闭合标签。
这里显示的其余行关闭了所有已打开的标签。在 HTML 中,注释的写法如下:
<!-- This is an HTML comment. -->
关闭标签通常没有注释,但如果你是 HTML 新手,给一些关闭标签加上注释是非常有帮助的。一个遗漏的标签或一个多余的标签可能会影响整个页面的布局。我们也包括了content块❽以及关闭的</body>和</html>标签。
我们还没有完成导航栏,但现在我们已经有了一个完整的 HTML 文档。如果runserver当前正在运行,停止当前的服务器并重新启动它。访问项目的主页,你应该能看到一个导航栏,里面包含了图 20-1 中显示的一些元素。现在,让我们将其余的元素添加到导航栏中。
添加用户账户链接
我们仍然需要添加与用户账户相关的链接。我们将首先添加所有与账户相关的链接,除了注销表单。
对base.xhtml进行以下更改:
base.xhtml
*--snip--*
</ul> <!-- End of links on left side of navbar -->
<!-- Account-related links -->
❶ <ul class="navbar-nav ms-auto mb-2 mb-md-0">
❷ {% if user.is_authenticated %}
<li class="nav-item">
❸ <span class="navbar-text me-2">Hello, {{ user.username }}.
</span></li>
❹ {% else %}
<li class="nav-item">
<a class="nav-link" href="{% url 'accounts:register' %}">
Register</a></li>
<li class="nav-item">
<a class="nav-link" href="{% url 'accounts:login' %}">
Log in</a></li>
{% endif %}
</ul> <!-- End of account-related links -->
</div> <!-- Closes collapsible parts of navbar -->
*--snip--*
我们通过使用另一个开口的<ul>标签❶开始一组新的链接。你可以根据需要在页面上拥有多个链接组。选择器ms-auto是margin-start-automatic的缩写:这个选择器会检查导航栏中的其他元素,并计算出一个左(开始)边距,将这一组链接推到浏览器窗口的右侧。
if块与我们之前用来根据用户是否登录显示适当信息的条件块相同❷。这个块现在稍微长了一些,因为在条件标签内部有一些样式规则。对已认证用户的问候被包裹在一个<span>元素中❸。span 元素用于样式化长行文本或页面元素的部分,而div元素则在页面中创建自己的分区。虽然许多页面有深层嵌套的div元素,但在这里我们使用span元素来样式化导航栏中的信息文本:在这种情况下,是已登录用户的姓名。
在else块中,针对未认证用户,我们包含了注册新账户和登录的链接❹。这些链接应该与指向主题页面的链接样式相同。
如果你想在导航栏中添加更多的链接,你可以在我们已经定义的<ul>组中添加另一个<li>项,使用像你在这里看到的样式指令。
现在让我们将注销表单添加到导航栏中。
将注销表单添加到导航栏
当我们第一次编写注销表单时,我们将其添加到了base.xhtml的底部。现在,让我们把它放到一个更合适的位置——导航栏中:
base.xhtml
*--snip--*
</ul> <!-- End of account-related links -->
{% if user.is_authenticated %}
<form action="{% url 'accounts:logout' %}" method='post'>
{% csrf_token %}
❶ <button name='submit' class='btn btn-outline-secondary btn-sm'>
Log out</button>
</form>
{% endif %}
</div> <!-- Closes collapsible parts of navbar -->
*--snip--*
注销表单应放在与账户相关链接的后面,但要放在导航栏的可折叠部分中。表单中唯一的变化是<button>元素中加入了一些 Bootstrap 样式类,这些类将 Bootstrap 的样式应用到注销按钮上❶。
重新加载主页,你应该能够使用你创建的任何账户进行登录和登出。
我们仍然需要在 base.xhtml 中添加一些内容。我们需要定义两个区块,供各个页面使用,以便放置与这些页面特定内容相关的内容。
定义页面的主体部分
base.xhtml 的其余部分包含了页面的主体部分:
base.xhtml
*--snip--*
</nav> <!-- End of navbar -->
❶ <main class="container">
❷ <div class="pb-2 mb-2 border-bottom">
{% block page_header %}{% endblock page_header %}
</div>
❸ <div>
{% block content %}{% endblock content %}
</div>
</main>
</body>
</html>
我们首先打开一个 <main> 标签 ❶。main 元素用于页面主体中最重要的部分。在这里,我们为它分配了 Bootstrap 选择器 container,这是一个简单的方式来将页面元素进行分组。我们将在这个容器中放置两个 div 元素。
第一个 div 元素包含一个 page_header 区块 ❷。我们将使用这个区块为大多数页面命名。为了让这个部分从页面的其他部分中突出,我们在标题下方添加了一些填充。填充是指元素内容与其边框之间的空间。选择器 pb-2 是一个 Bootstrap 指令,它在被样式化元素的底部提供适量的填充。边距是指元素的边框与页面上其他元素之间的空间。选择器 mb-2 在这个 div 的底部提供适量的边距。我们希望在这个区块的底部加上边框,因此我们使用选择器 border-bottom,它在 page_header 区块的底部提供一个细边框。
然后我们再定义一个 div 元素,包含 content 区块 ❸。我们没有为这个区块应用任何特定的样式,因此可以根据页面的需求来样式化任何页面的内容。base.xhtml 文件的结尾部分包含了 main、body 和 html 元素的闭合标签。
当你在浏览器中加载 Learning Log 的主页时,你应该看到一个专业外观的导航栏,和 图 20-1 中显示的相匹配。尝试调整窗口的宽度,使其变得非常窄;此时,导航栏应该会被一个按钮替代。点击这个按钮,所有链接应该会以下拉列表的形式出现。
使用 Jumbotron 样式化主页
为了更新主页,我们将使用一个叫做 jumbotron 的 Bootstrap 元素,一个从页面其他部分突出的较大框。通常它用于主页,放置项目的简短描述以及一个呼吁行动的按钮,邀请观众参与进来。
这是修改后的 index.xhtml 文件:
index.xhtml
{% extends "learning_logs/base.xhtml" %}
❶ {% block page_header %}
❷ <div class="p-3 mb-4 bg-light border rounded-3">
<div class="container-fluid py-4">
❸ <h1 class="display-3">Track your learning.</h1>
❹ <p class="lead">Make your own Learning Log, and keep a list of the
topics you're learning about. Whenever you learn something new
about a topic, make an entry summarizing what you've learned.</p>
❺ <a class="btn btn-primary btn-lg mt-1"
href="{% url 'accounts:register' %}">Register »</a>
</div>
</div>
{% endblock page_header %}
我们首先告诉 Django,我们即将定义 page_header 区块的内容 ❶。Jumbotron 是通过一对 div 元素实现的,并对它们应用了一些样式指令 ❷。外部 div 具有填充和边距设置、浅色背景以及圆角。内部 div 是一个容器,它会随着窗口大小的变化而改变,并且也有一些填充。py-4 选择器为 div 元素的上下添加了填充。你可以随意调整这些设置中的数字,看看主页的变化。
jumbotron 内部有三个元素。第一个是一个简短的信息,Track your learning,它让新访客了解 Learning Log 的功能 ❸。<h1> 元素是一个一级标题,display-3 选择器为这个标题添加了更瘦更高的外观。我们还包括了一条更长的信息,提供有关用户可以如何使用学习日志的更多信息 ❹。这段文字格式化为 lead 段落,旨在与普通段落区分开来。
我们没有仅仅使用文本链接,而是创建了一个按钮,邀请用户在 Learning Log 上注册帐户 ❺。这是与标题中相同的链接,但按钮在页面上更为突出,向查看者展示了他们需要做什么才能开始使用这个项目。这里看到的选择器将其样式化为一个大型按钮,表示一个行动号召。代码 » 是一个 HTML 实体,看起来像两个右尖括号(>>)结合在一起。最后,我们提供了关闭的 div 标签并结束了 page_header 块。由于该文件中只有两个 div 元素,标记关闭的 div 标签并没有太大帮助。我们不会再往这个页面添加内容,因此不需要在这个模板中定义 content 块。
主页现在看起来像 图 20-1。这比项目的未样式化版本有了显著的改进!
样式化登录页面
我们已经优化了登录页面的整体外观,但登录表单本身还没有任何样式。让我们通过修改 login.xhtml,让表单的样式与页面的其余部分保持一致:
login.xhtml
{% extends 'learning_logs/base.xhtml' %}
❶ {% load django_bootstrap5 %}
❷ {% block page_header %}
<h2>Log in to your account.</h2>
{% endblock page_header %}
{% block content %}
<form action="{% url 'accounts:login' %}" method='post'>
{% csrf_token %}
❸ {% bootstrap_form form %}
❹ {% bootstrap_button button_type="submit" content="Log in" %}
</form>
{% endblock content %}
我们首先将 bootstrap5 模板标签加载到这个模板中 ❶。然后我们定义了 page_header 块,告诉用户这个页面的用途 ❷。注意,我们已经从模板中移除了 {% if form.errors %} 块;django-bootstrap5 会自动处理表单错误。
为了显示表单,我们使用了模板标签 {% bootstrap_form %} ❸;这替代了我们在第十九章中使用的 {{ form.as_div }} 元素。{% bootstrap_form %} 模板标签会在渲染表单时,将 Bootstrap 样式规则插入到表单的各个元素中。为了生成提交按钮,我们使用了 {% bootstrap_button %} 标签,并通过参数指定它为提交按钮,标签为 Log in ❹。
图 20-2 显示了现在的登录表单。页面变得更加简洁,样式一致且目的明确。尝试使用错误的用户名或密码登录;你会看到,即使是错误信息的样式也与整个网站的样式一致,并且能够很好地融入其中。

图 20-2:用 Bootstrap 样式化的登录页面
样式化话题页面
让我们确保查看信息的页面也得到了适当的样式化,从话题页面开始:
topics.xhtml
{% extends 'learning_logs/base.xhtml' %}
{% block page_header %}
❶ <h1>Topics</h1>
{% endblock page_header %}
{% block content %}
❷ <ul class="list-group border-bottom pb-2 mb-4">
{% for topic in topics %}
❸ <li class="list-group-item border-0">
<a href="{% url 'learning_logs:topic' topic.id %}">
{{ topic.text }}</a>
</li>
{% empty %}
❹ <li class="list-group-item border-0">No topics have been added yet.</li>
{% endfor %}
</ul>
<a href="{% url 'learning_logs:new_topic' %}">Add a new topic</a>
{% endblock content %}
我们不需要 {% load bootstrap5 %} 标签,因为在这个文件中我们没有使用任何自定义的 bootstrap5 模板标签。我们将标题 Topics 移动到 page_header 块,并将其改为 <h1> 元素,而不是简单的段落 ❶。
该页面的主要内容是一个话题列表,因此我们使用 Bootstrap 的列表组组件来渲染页面。此组件为整体列表以及每个列表项应用了一组简单的样式指令。当我们打开 <ul> 标签时,我们首先包含 list-group 类,以便将默认的样式指令应用到列表上 ❷。我们通过在列表的底部添加一个边框、在列表下方添加一点内边距(pb-2),并在底部边框下方添加一个边距(mb-4)来进一步定制列表。
列表中的每个项都需要 list-group-item 类,我们通过去掉单独项的边框来定制默认样式 ❸。当列表为空时显示的消息也需要这些相同的类 ❹。
当你现在访问话题页面时,应该能看到一个与主页样式匹配的页面。
为话题页面上的条目添加样式
在话题页面,我们将使用 Bootstrap 的卡片组件,使每个条目更加突出。一个卡片是一个可以嵌套的 div 集合,具有灵活的预定义样式,完美适用于展示话题的条目:
topic.xhtml
{% extends 'learning_logs/base.xhtml' %}
❶ {% block page_header %}
<h1>{{ topic.text }}</h1>
{% endblock page_header %}
{% block content %}
<p>
<a href="{% url 'learning_logs:new_entry' topic.id %}">Add new entry</a>
</p>
{% for entry in entries %}
❷ <div class="card mb-3">
<!-- Card header with timestamp and edit link -->
❸ <h4 class="card-header">
{{ entry.date_added|date:'M d, Y H:i' }}
❹ <small><a href="{% url 'learning_logs:edit_entry' entry.id %}">
edit entry</a></small>
</h4>
<!-- Card body with entry text -->
❺ <div class="card-body">{{ entry.text|linebreaks }}</div>
</div>
{% empty %}
❻ <p>There are no entries for this topic yet.</p>
{% endfor %}
{% endblock content %}
我们首先将话题放置在 page_header 块中 ❶。然后我们删除了之前在此模板中使用的无序列表结构。我们不再将每个条目作为列表项,而是打开一个带有 card 选择器的 div 元素 ❷。这个卡片有两个嵌套元素:一个用来显示时间戳和编辑条目的链接,另一个用来显示条目的正文。card 选择器负责为这个 div 提供大部分样式;我们通过为每个卡片的底部添加一个小的边距(mb-3)来定制卡片。
卡片中的第一个元素是一个头部,它是一个带有 card-header 选择器的 <h4> 元素 ❸。这个头部包含了条目创建的日期和一个编辑条目的链接。围绕 edit_entry 链接的 <small> 标签使其看起来比时间戳略小 ❹。第二个元素是一个带有 card-body 选择器的 div ❺,它将条目的文本放入卡片的一个简单框中。请注意,包含页面信息的 Django 代码没有变化;只是影响页面外观的元素发生了变化。由于我们不再使用无序列表,因此我们用简单的段落标签替换了空列表消息周围的列表项标签 ❻。
图 20-3 显示了带有新外观的话题页面。学习日志的功能没有变化,但看起来明显更专业,且更能吸引用户。
如果你想为项目使用不同的 Bootstrap 模板,可以按照本章中到目前为止的过程进行操作。将你想使用的模板复制到base.xhtml中,并修改包含实际内容的元素,使模板显示你项目的信息。然后使用 Bootstrap 的单独样式工具来为每个页面的内容进行样式设置。

图 20-3:具有 Bootstrap 样式的主题页面
部署 Learning Log
现在我们有了一个专业外观的项目,让我们将其部署到实时服务器,这样任何有互联网连接的人都可以使用它。我们将使用 Platform.sh,这是一个基于网络的平台,可以让你管理 Web 应用程序的部署。我们将把 Learning Log 部署到 Platform.sh 上并运行。
创建 Platform.sh 账户
要创建一个账户,请访问 platform.sh 并点击 免费试用 按钮。Platform.sh 提供免费套餐,截至本文写作时,无需信用卡即可注册。试用期允许你以最小的资源部署应用程序,这使得你可以在正式使用付费托管计划前,先在实际部署环境中测试你的项目。
安装 Platform.sh CLI
要在 Platform.sh 上部署和管理项目,你需要使用命令行界面(CLI)中的工具。要安装最新版本的 CLI,请访问 docs.platform.sh/development/cli.xhtml,并根据你的操作系统遵循相应的安装指南。
在大多数系统上,你可以通过在终端中运行以下命令来安装 CLI:
$ curl -fsS https://platform.sh/cli/installer | php
该命令运行完成后,你需要打开一个新的终端窗口,才能使用 CLI。
安装 platformshconfig
你还需要安装一个额外的软件包,platformshconfig。这个软件包帮助检测项目是运行在本地系统还是 Platform.sh 服务器上。在一个激活的虚拟环境中,执行以下命令:
(ll_env)learning_log$ **pip install platformshconfig**
我们将使用这个软件包来修改项目在实时服务器上运行时的设置。
创建 requirements.txt 文件
远程服务器需要知道 Learning Log 依赖哪些软件包,因此我们将使用 pip 来生成一个列出这些软件包的文件。同样,在一个已激活的虚拟环境中,执行以下命令:
(ll_env)learning_log$ **pip freeze > requirements.txt**
freeze 命令告诉 pip 将当前项目中所有已安装软件包的名称写入 requirements.txt 文件。打开此文件以查看项目中已安装的软件包及其版本号:
requirements.txt
asgiref==3.5.2
beautifulsoup4==4.11.1
Django==4.1
django-bootstrap5==21.3
platformshconfig==2.4.0
soupsieve==2.3.2.post1
sqlparse==0.4.2
Learning Log 已经依赖于七个不同软件包的特定版本,因此它需要一个匹配的环境才能在远程服务器上正常运行。(我们手动安装了其中三个软件包,剩下的四个软件包作为这些包的依赖项自动安装。)
当我们部署 Learning Log 时,Platform.sh 将安装requirements.txt中列出的所有软件包,从而创建一个与我们本地使用的相同软件包环境。因此,我们可以确信,部署后的项目将像在本地系统中一样正常运行。采用这种管理项目的方式,在你开始构建和维护多个项目时至关重要。
附加部署要求
生产服务器需要两个额外的软件包。这些软件包用于在生产环境中服务项目,在这种环境中,许多用户可能同时发出请求。
在保存requirements.txt的相同目录下,创建一个新文件,命名为requirements_remote.txt。将以下两个软件包添加到该文件中:
requirements_remote.txt
# Requirements for live project.
gunicorn
psycopg2
gunicorn软件包会响应传入的请求,这取代了我们在本地使用的开发服务器。psycopg2软件包是必需的,它允许 Django 管理 Platform.sh 使用的 Postgres 数据库。Postgres是一种开源数据库,非常适合生产环境中的应用。
添加配置文件
每个托管平台都需要一些配置才能使项目在其服务器上正确运行。在这一节中,我们将添加三个配置文件:
.platform.app.yaml 这是项目的主要配置文件。它告诉 Platform.sh 我们正在尝试部署什么类型的项目以及我们项目需要什么类型的资源,同时包含了在服务器上构建项目的命令。
.platform/routes.yaml 该文件定义了我们项目的路由。当 Platform.sh 接收到请求时,正是这个配置帮助将这些请求导向我们的特定项目。
.platform/services.yaml 该文件定义了我们项目所需的任何额外服务。
这些都是 YAML(YAML Ain’t Markup Language)文件。YAML是一种为编写配置文件而设计的语言,旨在让人类和计算机都能轻松读取。你可以手动编写或修改典型的 YAML 文件,计算机也能无歧义地读取和解释该文件。
YAML 文件非常适合部署配置,因为它们让你能够很好地控制部署过程中的行为。
显示隐藏文件
大多数操作系统都会隐藏以点(.)开头的文件和文件夹,例如.platform。当你打开文件浏览器时,默认情况下是看不到这些文件和文件夹的。但是,作为程序员,你需要查看它们。根据操作系统的不同,下面是查看隐藏文件的方法:
-
在 Windows 上,打开 Windows 资源管理器,然后打开一个文件夹,例如桌面。点击查看标签页,确保文件扩展名和隐藏的项目已勾选。
-
在 macOS 上,你可以在任何 Finder 窗口按下⌘-SHIFT-.(点号)来查看隐藏的文件和文件夹。
-
在 Linux 系统(如 Ubuntu)上,您可以在任何文件浏览器中按 CTRL-H 来显示隐藏的文件和文件夹。要使此设置永久生效,请打开文件浏览器(如 Nautilus),点击选项卡(三条线所示),然后勾选 显示隐藏的文件 复选框。
.platform.app.yaml 配置文件
第一个配置文件最长,因为它控制整个部署过程。我们将分部分展示,您可以通过手动输入到文本编辑器中,或者从在线资源 ehmatthes.github.io/pcc_3e 下载副本。
这是 .platform.app.yaml 的第一部分,应保存在与 manage.py 同一目录中:
.platform.app.yaml
❶ name: "ll_project"
type: "python:3.10"
❷ relationships:
database: "db:postgresql"
# The configuration of the app when it's exposed to the web.
❸ web:
upstream:
socket_family: unix
commands:
❹ start: "gunicorn -w 4 -b unix:$SOCKET ll_project.wsgi:application"
❺ locations:
"/":
passthru: true
"/static":
root: "static"
expires: 1h
allow: true
# The size of the persistent disk of the application (in MB).
❻ disk: 512
保存此文件时,请确保文件名开头包含点(.)。如果省略点,Platform.sh 将无法找到该文件,您的项目将无法部署。
您目前不需要理解 .platform.app.yaml 中的所有内容;我将重点讲解配置中最重要的部分。该文件首先指定了项目的 name,我们将其命名为 'll_project',以保持与启动项目时使用的名称一致 ❶。我们还需要指定所使用的 Python 版本(本文编写时为 3.10)。您可以在 docs.platform.sh/languages/python.xhtml 查看支持的版本列表。
接下来是一个标记为 relationships 的部分,定义了项目所需的其他服务 ❷。这里唯一的关系是与 Postgres 数据库的连接。接下来是 web 部分 ❸。commands:start 部分告诉 Platform.sh 使用哪种进程来处理传入请求。我们在这里指定使用 gunicorn 来处理请求 ❹。该命令替代了我们在本地使用的 python manage.py runserver 命令。
locations 部分告诉 Platform.sh 将传入请求发送到哪里 ❺。大部分请求应传递给 gunicorn;我们的 urls.py 文件将告诉 gunicorn 如何处理这些请求。静态文件的请求将单独处理,并每小时刷新一次。最后一行显示我们请求在 Platform.sh 的服务器上分配 512MB 的磁盘空间 ❻。
.platform.app.yaml的其余部分如下所示:
*--snip--*
disk: 512
# Set a local read/write mount for logs.
❶ mounts:
"logs":
source: local
source_path: logs
# The hooks executed at various points in the lifecycle of the application.
❷ hooks:
build: |
❸ pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements_remote.txt
mkdir logs
❹ python manage.py collectstatic
rm -rf logs
❺ deploy: |
python manage.py migrate
mounts 部分 ❶ 让我们定义可以在项目运行时读写数据的目录。此部分为已部署的项目定义了一个 logs/ 目录。
hooks部分 ❷ 定义了在部署过程中不同阶段执行的操作。在 build 部分,我们安装了所有在实际环境中提供项目所需的包 ❸。我们还运行 collectstatic ❹,它将项目所需的所有静态文件收集到一个地方,以便高效地提供服务。
最后,在 deploy 部分 ❺,我们指定每次部署项目时都应运行迁移操作。在一个简单的项目中,如果没有变化,这个操作不会产生任何效果。
另外两个配置文件要短得多;让我们现在写它们。
routes.yaml 配置文件
route 是请求在服务器处理时所经过的路径。当 Platform.sh 接收到请求时,它需要知道将请求发送到哪里。
创建一个名为 .platform 的新文件夹,放在与 manage.py 相同的目录下。确保文件夹名称前有一个点。在该文件夹内,创建一个名为 routes.yaml 的文件,并输入以下内容:
.platform/routes.yaml
# Each route describes how an incoming URL will be processed by Platform.sh.
"https://{default}/":
type: upstream
upstream: "ll_project:http"
"https://www.{default}/":
type: redirect
to: "https://{default}/"
这个文件确保像 https://project_url.com 和 www.project_url.com 这样的请求都会被路由到相同的位置。
services.yaml 配置文件
最后的这个配置文件指定了我们项目运行所需的服务。将此文件保存在 .platform/ 目录中,和 routes.yaml 一起:
.platform/routes.yaml
# Each service listed will be deployed in its own container as part of your
# Platform.sh project.
db:
type: postgresql:12
disk: 1024
这个文件定义了一个服务,一个 Postgres 数据库。
为 Platform.sh 修改 settings.py
现在我们需要在 settings.py 的末尾添加一个部分,修改一些 Platform.sh 环境的设置。将以下代码添加到 settings.py 的最后:
settings.py
*--snip--*
# Platform.sh settings.
❶ from platformshconfig import Config
config = Config()
❷ if config.is_valid_platform():
❸ ALLOWED_HOSTS.append('.platformsh.site')
❹ if config.appDir:
STATIC_ROOT = Path(config.appDir) / 'static'
❺ if config.projectEntropy:
SECRET_KEY = config.projectEntropy
if not config.in_build():
❻ db_settings = config.credentials('database')
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': db_settings['path'],
'USER': db_settings['username'],
'PASSWORD': db_settings['password'],
'HOST': db_settings['host'],
'PORT': db_settings['port'],
},
}
我们通常将 import 语句放在模块的开头,但在这种情况下,将所有远程特定的设置保存在一个部分中更为方便。在这里,我们从 platformshconfig 导入 Config ❶,它有助于确定远程服务器上的设置。我们只有在方法 config.is_valid_platform() 返回 True ❷ 时才修改设置,这表明这些设置正在使用 Platform.sh 服务器。
我们修改 ALLOWED_HOSTS 以允许项目通过以 .platformsh.site 结尾的主机提供服务 ❸。所有部署到免费层的项目将使用此主机。如果设置正在加载到已部署应用的目录中 ❹,我们设置 STATIC_ROOT 以确保静态文件正确提供。我们还在远程服务器上设置了一个更安全的 SECRET_KEY ❺。
最后,我们配置生产数据库 ❻。只有在构建过程完成并且项目开始提供服务时,这才会被设置。这里的所有内容都是必要的,以便让 Django 与 Platform.sh 为项目设置的 Postgres 服务器进行通信。
使用 Git 跟踪项目的文件
正如第十七章所讨论的,Git 是一个版本控制程序,它允许你在每次成功实现一个新功能时拍摄项目代码的快照。如果出现问题,你可以轻松回到上一个工作正常的快照;例如,如果你在开发新功能时不小心引入了一个 bug。每个快照被称为一个 commit。
使用 Git,你可以尝试实现新功能,而不用担心破坏项目。当你将项目部署到生产服务器时,需要确保部署的是一个可用的项目版本。想了解更多关于 Git 和版本控制的信息,请参阅附录 D。
安装 Git
Git 可能已经安装在你的系统中。要检查这一点,打开一个新的终端窗口并执行git --version命令:
(ll_env)learning_log$ **git --version**
git version 2.30.1 (Apple Git-130)
如果你收到提示信息表明 Git 没有安装,请参见附录 D 中的安装说明。
配置 Git
Git 会跟踪谁对项目进行了更改,即使只有一个人正在进行项目开发。为了做到这一点,Git 需要知道你的用户名和电子邮件。你必须提供用户名,但可以为你的练习项目随意设置一个电子邮件:
(ll_env)learning_log$ **git config --global user.name "eric"**
(ll_env)learning_log$ **git config --global user.email "eric@example.com"**
如果你忘记了这一步,Git 会在你第一次提交时提示你输入这些信息。
忽略文件
我们不需要 Git 跟踪项目中的每个文件,因此我们会告诉它忽略一些文件。在包含manage.py的文件夹中创建一个名为.gitignore的文件。注意,这个文件名以点(.)开头,并且没有文件扩展名。下面是放入.gitignore中的代码:
.gitignore
ll_env/
__pycache__/
*.sqlite3
我们告诉 Git 忽略整个ll_env目录,因为我们可以随时自动重新创建它。我们还不跟踪包含.pyc文件的pycache目录,.pyc文件是执行.py文件时自动生成的。我们不跟踪本地数据库的更改,因为这是一种不好的习惯:如果你在服务器上使用 SQLite,可能会不小心将生产环境数据库覆盖为本地测试数据库,尤其是在将项目推送到服务器时。*.sqlite3中的星号告诉 Git 忽略所有以.sqlite3结尾的文件。
提交项目
我们需要为 Learning Log 初始化一个 Git 仓库,将所有必要的文件添加到仓库中,并提交项目的初始状态。以下是操作步骤:
❶ (ll_env)learning_log$ **git init**
Initialized empty Git repository in /Users/eric/.../learning_log/.git/
❷ (ll_env)learning_log$ **git add .**
❸ (ll_env)learning_log$ **git commit -am "Ready for deployment to Platform.sh."**
[main (root-commit) c7ffaad] Ready for deployment to Platform.sh.
42 files changed, 879 insertions(+)
create mode 100644 .gitignore
create mode 100644 .platform.app.yaml
`--snip--`
create mode 100644 requirements_remote.txt
❹ (ll_env)learning_log$ **git status**
On branch main
nothing to commit, working tree clean
(ll_env)learning_log$
我们执行git init命令在包含 Learning Log 的目录中初始化一个空的 Git 仓库❶。接着,我们使用git add .命令,将所有未被忽略的文件添加到仓库中❷。(别忘了点。)接下来,我们执行git commit -am "``commit message``"命令:-a标志告诉 Git 将所有已更改的文件包含在此次提交中,-m标志则告诉 Git 记录一条日志信息❸。
执行git status命令❹表明我们当前位于main分支,并且工作树是干净的。这是每次将项目推送到远程服务器时希望看到的状态。
在 Platform.sh 上创建项目
到目前为止,Learning Log 项目仍然运行在我们的本地系统上,并且已经配置为在远程服务器上正确运行。我们将使用 Platform.sh CLI 在服务器上创建一个新项目,然后将我们的项目推送到远程服务器。
确保你处于终端的learning_log/目录下,并执行以下命令:
(ll_env)learning_log$ **platform login**
Opened URL: http://127.0.0.1:5000
Please use the browser to log in.
`--snip--`
❶ Do you want to create an SSH configuration file automatically? [Y/n] **Y**
该命令会打开一个浏览器标签,供你登录。一旦登录,你可以关闭浏览器标签并返回终端。如果系统提示你创建一个 SSH 配置文件❶,请输入Y,这样你以后就可以连接到远程服务器了。
现在我们将创建一个项目。由于输出信息较多,我们将分部分查看创建过程。首先输入create命令:
(ll_env)learning_log$ **platform create**
* Project title (--title)
Default: Untitled Project
❶ > **ll_project**
* Region (--region)
The region where the project will be hosted
`--snip--`
[us-3.platform.sh] Moses Lake, United States (AZURE) [514 gC02eq/kWh]
❷ > **us-3.platform.sh**
* Plan (--plan)
Default: development
Enter a number to choose:
[0] development
`--snip--`
❸ > **0**
* Environments (--environments)
The number of environments
Default: 3
❹ > **3**
* Storage (--storage)
The amount of storage per environment, in GiB
Default: 5
❺ > **5**
第一个提示要求为项目命名❶,所以我们使用了名称ll_project。下一个提示询问我们希望服务器在哪个区域❷。选择离你最近的服务器;对我来说是us-3.platform.sh。对于其余的提示,你可以接受默认设置:最低开发计划的服务器❸,为项目设置三个环境❹,以及为整个项目提供 5GB 的存储空间❺。
还有三个提示需要回应:
Default branch (--default-branch)
The default Git branch name for the project (the production environment)
Default: main
❶ > **main**
Git repository detected: /Users/eric/.../learning_log
❷ Set the new project ll_project as the remote for this repository? [Y/n] **Y**
The estimated monthly cost of this project is: $10 USD
❸ Are you sure you want to continue? [Y/n] **Y**
The Platform.sh Bot is activating your project
▀▄ ▄▀
█▄█▀███▀█▄█
▀█████████▀
▄▀ ▀▄
The project is now ready!
一个 Git 仓库可以有多个分支;Platform.sh 在询问我们是否将main设置为项目的默认分支❶。接着它会询问是否将本地项目的仓库连接到远程仓库❷。最后,我们被告知,如果我们在免费试用期结束后继续运行该项目,每月大约需要支付 10 美元❸。如果你还没有输入信用卡信息,应该不必担心这笔费用。Platform.sh 会在你超过免费试用的限制而没有添加信用卡时暂停你的项目。
推送到 Platform.sh
在查看项目的在线版本之前,最后一步是将我们的代码推送到远程服务器。为此,输入以下命令:
(ll_env)learning_log$ **platform push**
❶ Are you sure you want to push to the main (production) branch? [Y/n] **Y**
`--snip--`
The authenticity of host 'git.us-3.platform.sh (...)' can't be established.
RSA key fingerprint is SHA256:Tvn...7PM
❷ Are you sure you want to continue connecting (yes/no/[fingerprint])? **Y**
Pushing HEAD to the existing environment main
`--snip--`
To git.us-3.platform.sh:3pp3mqcexhlvy.git
* [new branch] HEAD -> main
当你输入platform push命令时,会要求你再次确认是否推送项目❶。如果这是你第一次连接到该网站,你可能还会看到关于 Platform.sh 真实性的提示❷。对于这些提示,你可以输入Y,然后会看到一大段输出。刚开始可能会觉得这些输出很混乱,但如果出现任何问题,这些输出在排查问题时非常有用。如果你粗略浏览这些输出,你可以看到 Platform.sh 安装了必要的软件包、收集静态文件、应用迁移以及为项目设置了 URL。
查看在线项目
推送完成后,你可以打开项目:
(ll_env)learning_log$ **platform url**
Enter a number to open a URL
[0] https://main-bvxea6i-wmye2fx7wwqgu.us-3.platformsh.site/
`--snip--`
> **0**
platform url命令列出了与已部署项目相关的 URL;你将看到几个有效的 URL 供选择。选择其中一个,你的项目应该会在新浏览器标签中打开!这将看起来和我们之前本地运行的项目一样,但你可以将这个 URL 分享给世界上的任何人,他们也可以访问并使用你的项目。
完善 Platform.sh 部署
现在我们通过创建一个超级用户来完善部署,就像我们本地做的那样。我们还将通过将DEBUG设置为False来增强项目的安全性,这样错误信息就不会向用户展示任何他们可以用来攻击服务器的额外信息。
在 Platform.sh 上创建超级用户
实时项目的数据库已经设置好了,但它完全为空。我们之前创建的所有用户仅存在于本地项目版本中。
要在实时版本的项目中创建超级用户,我们将启动一个 SSH(安全套接字外壳)会话,在其中运行远程服务器上的管理命令:
(ll_env)learning_log$ **platform environment:ssh**
___ _ _ __ _
| _ \ |__ _| |_ / _|___ _ _ _ __ __| |_
| _/ / _` | _| _/ _ \ '_| ' \ _(_-< ' \
|_| |_\__,_|\__|_| \___/_| |_|_|_(_)__/_||_|
Welcome to Platform.sh.
❶ web@ll_project.0:~$ **ls**
accounts learning_logs ll_project logs manage.py requirements.txt
requirements_remote.txt static
❷ web@ll_project.0:~$ **python manage.py createsuperuser**
❸ Username (leave blank to use 'web'): **ll_admin_live**
Email address:
Password:
Password (again):
Superuser created successfully.
❹ web@ll_project.0:~$ **exit**
logout
Connection to ssh.us-3.platform.sh closed.
❺ (ll_env)learning_log$
当您第一次运行platform environment:ssh命令时,可能会出现一个关于此主机真实性的提示。如果您看到此消息,请输入Y,您应该会登录到远程终端会话。
运行ssh命令后,您的终端行为就像是在远程服务器上的终端一样。注意,您的提示符已经改变,表明您正处于与名为ll_project的项目相关联的web会话中❶。如果您执行ls命令,您将看到已推送到 Platform.sh 服务器的文件。
执行我们在第十八章❷中使用的相同createsuperuser命令。这一次,我输入了一个与本地使用的用户名不同的管理员用户名ll_admin_live❸。当您完成远程终端会话后,输入exit命令❹。您的提示符将显示您正在本地系统中工作❺。
现在,您可以在实时应用的 URL 末尾添加/admin/并登录到管理员站点。如果其他人已经开始使用您的项目,请注意,您将能够访问他们的所有数据!请认真对待这项责任,用户将继续信任您处理他们的数据。
保护实时项目
当前我们项目部署方式中存在一个显著的安全问题:在settings.py中设置了DEBUG = True,这会在发生错误时提供调试信息。Django 的错误页面在开发项目时会为您提供重要的调试信息;然而,如果在实时服务器上启用它们,它们会向攻击者泄露过多的信息。
为了查看问题的严重性,请访问您已部署项目的主页。登录到一个用户账户,并在主页 URL 末尾添加/topics/999/。假设您没有创建成千上万的主题,您应该会看到一个页面,显示信息DoesNotExist at /topics/999/。如果您向下滚动,您会看到关于项目和服务器的许多信息。您不希望用户看到这些信息,而且肯定不希望攻击者能看到这些信息。
我们可以通过在只适用于已部署项目版本的settings.py文件中设置DEBUG = False,防止这些信息显示在实时网站上。这样,您仍然可以在本地查看调试信息,这些信息在本地有用,但它不会显示在实时网站上。
在文本编辑器中打开settings.py,并在修改 Platform.sh 设置的部分添加一行代码:
settings.py
*--snip--*
if config.is_valid_platform():
ALLOWED_HOSTS.append('.platformsh.site')
DEBUG = False
*--snip--*
所有配置部署版本项目的工作都得到了回报。当我们想要调整项目的在线版本时,只需修改之前设置的相关配置部分。
提交并推送更改
现在我们需要提交对settings.py所做的更改,并将这些更改推送到 Platform.sh。以下是显示此过程第一部分的终端会话:
❶ (ll_env)learning_log$ **git commit -am "Set DEBUG False on live site."**
[main d2ad0f7] Set DEBUG False on live site.
1 file changed, 1 insertion(+)
❷ (ll_env)learning_log$ **git status**
On branch main
nothing to commit, working tree clean
(ll_env)learning_log$
我们发出git commit命令并附上简短而描述性的提交信息❶。记住,-am标志确保 Git 提交所有已更改的文件,并记录日志消息。Git 识别到一个文件已被更改并将此更改提交到仓库。
运行git status命令显示我们正在处理仓库的main分支,并且现在没有新的更改需要提交❷。在推送到远程服务器之前检查状态非常重要。如果你看到的状态不干净,那么某些更改尚未提交,这些更改将不会推送到服务器。你可以尝试重新发出commit命令;如果你不确定如何解决问题,可以阅读附录 D 以更好地了解如何使用 Git。
现在让我们将更新后的仓库推送到 Platform.sh:
(ll_env)learning_log$ **platform push**
Are you sure you want to push to the main (production) branch? [Y/n] **Y**
Pushing HEAD to the existing environment main
`--snip--`
To git.us-3.platform.sh:wmye2fx7wwqgu.git
fce0206..d2ad0f7 HEAD -> main
(ll_env)learning_log$
Platform.sh 识别到仓库已更新,并重新构建项目以确保所有更改都已被考虑。它不会重新构建数据库,因此我们没有丢失任何数据。
为了确保此更改生效,请再次访问/topics/999/ URL。你应该只看到Server Error (500)的消息,并且没有任何项目的敏感信息。
创建自定义错误页面
在第十九章中,我们配置了 Learning Log,以便当用户请求一个不属于他们的主题或条目时返回 404 错误。现在你也看到了 500 服务器错误。404 错误通常意味着你的 Django 代码是正确的,但请求的对象不存在。500 错误通常意味着你写的代码有问题,例如views.py中的某个函数错误。Django 目前在这两种情况下都会返回相同的通用错误页面,但我们可以编写自己的 404 和 500 错误页面模板,使其与 Learning Log 的整体外观相匹配。这些模板应放在根模板目录中。
创建自定义模板
在learning_log文件夹中,创建一个名为templates的新文件夹。然后创建一个名为404.xhtml的新文件;该文件的路径应为learning_log/templates/404.xhtml。以下是该文件的代码:
404.xhtml
{% extends "learning_logs/base.xhtml" %}
{% block page_header %}
<h2>The item you requested is not available. (404)</h2>
{% endblock page_header %}
这个简单的模板提供了通用的 404 错误页面信息,但样式与网站的其余部分保持一致。
使用以下代码再创建一个名为500.xhtml的文件:
500.xhtml
{% extends "learning_logs/base.xhtml" %}
{% block page_header %}
<h2>There has been an internal error. (500)</h2>
{% endblock page_header %}
这些新文件需要对settings.py做一些轻微的修改。
settings.py
*--snip--*
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
*--snip--*
},
]
*--snip--*
这个更改告诉 Django 在根模板目录中查找错误页面模板和其他未关联到特定应用的模板。
将更改推送到 Platform.sh
现在,我们需要提交刚才做出的更改并将其推送到 Platform.sh:
❶ (ll_env)learning_log$ **git add .**
❷ (ll_env)learning_log$ **git commit -am "Added custom 404 and 500 error pages."**
3 files changed, 11 insertions(+), 1 deletion(-)
create mode 100644 templates/404.xhtml
create mode 100644 templates/500.xhtml
❸ (ll_env)learning_log$ **platform push**
`--snip--`
To git.us-3.platform.sh:wmye2fx7wwqgu.git
d2ad0f7..9f042ef HEAD -> main
(ll_env)learning_log$
我们首先执行 git add . 命令 ❶,因为我们在项目中创建了一些新文件。然后我们提交更改 ❷ 并将更新后的项目推送到 Platform.sh ❸。
现在,当错误页面出现时,它应该与网站的其他部分保持一致的样式,使得在出现错误时用户体验更加顺畅。
持续开发
在你将 Learning Log 初次推送到在线服务器后,可能还想进一步开发它,或者你可能想开发自己的项目进行部署。在此过程中,更新项目有一个相对一致的流程。
首先,你需要对本地项目做出必要的更改。如果你的更改涉及到新的文件,使用命令 git add . 将这些文件添加到 Git 仓库中(确保命令末尾有点号)。任何需要数据库迁移的更改都需要使用这个命令,因为每次迁移都会生成一个新的迁移文件。
其次,使用 git commit -am "``commit message``" 将更改提交到你的仓库。然后,使用命令 platform push 将更改推送到 Platform.sh。访问你的在线项目,确保你期望看到的更改已经生效。
在这个过程中很容易犯错,所以当出现问题时不要感到惊讶。如果代码不起作用,回顾一下你做过的操作,试着找出错误。如果你找不到错误,或者不知道如何撤销操作,可以参考附录 C 中关于寻求帮助的建议。不要害怕寻求帮助:其他人也是通过问你现在可能会问的问题来学会构建项目的,因此有人一定会愿意帮助你。解决每个出现的问题有助于你稳步提升技能,直到你能构建有意义、可靠的项目,并解答他人的问题。
删除 Platform.sh 上的项目
多次使用同一个项目或一系列小项目来运行部署过程是很好的练习,这有助于你熟悉部署过程。但你需要知道如何删除已经部署的项目。Platform.sh 还限制了你可以免费托管的项目数量,别让练习项目占满了你的账户。
你可以通过 CLI 删除项目:
(ll_env)learning_log$ **platform project:delete**
系统会要求你确认是否要执行这个破坏性操作。根据提示回应,项目就会被删除。
命令 platform create 还为本地 Git 仓库提供了一个指向 Platform.sh 服务器上远程仓库的引用。你也可以通过命令行删除这个远程仓库:
(ll_env)learning_log$ **git remote**
platform
(ll_env)learning_log$ **git remote remove platform**
命令 git remote 列出了当前仓库所有关联的远程 URL 名称。命令 git remote remove``remote_name 会将这些远程 URL 从本地仓库中删除。
你也可以通过登录 Platform.sh 网站并访问你的仪表盘 console.platform.sh 来删除项目的资源。该页面列出了所有你的活跃项目。点击项目框中的三个点,然后点击 Edit Plan。这是项目的定价页面;点击页面底部的 Delete Project 按钮,你将看到一个确认页面,按照提示进行删除。即使你使用 CLI 删除了项目,熟悉任何托管服务提供商的仪表盘也是一个好主意。
总结
在本章中,你学习了如何使用 Bootstrap 库和 django-bootstrap5 应用为你的项目赋予简单而专业的外观。借助 Bootstrap,你选择的样式将在几乎所有访问你项目的设备上保持一致。
你学习了 Bootstrap 的模板,并使用了 Navbar static 模板为学习日志(Learning Log)创建了一个简洁的外观。你使用了一个 jumbotron 让主页的信息更加突出,并学会了如何为网站的所有页面保持一致的样式。
在项目的最后部分,你学习了如何将一个项目部署到远程服务器,这样任何人都可以访问它。你创建了一个 Platform.sh 账户并安装了一些帮助管理部署过程的工具。你使用 Git 将工作中的项目提交到仓库,并将仓库推送到 Platform.sh 的远程服务器。最后,你学习了如何通过在生产服务器上设置 DEBUG = False 来开始保护你的应用程序。你还创建了自定义的错误页面,这样不可避免的错误也能看起来处理得当。
现在你已经完成了学习日志,你可以开始构建自己的项目。可以从简单的项目开始,并确保项目在添加复杂性之前正常工作。享受你持续的学习过程,并祝你的项目好运!
附录:A
安装与故障排除

有许多版本的 Python 可供选择,并且每个操作系统上都有多种安装方式。如果第一章中的方法不起作用,或者你想安装与当前安装版本不同的 Python 版本,本附录中的说明可以帮助你。
Windows 上的 Python
第一章中的说明向你展示了如何使用python.org的官方安装程序来安装 Python。如果你在使用安装程序后无法运行 Python,本节中的故障排除说明将帮助你让 Python 正常运行。
使用 py 代替 python
如果你运行了最近的 Python 安装程序,然后在终端中输入 python 命令,你应该会看到终端会话的 Python 提示符(>>>)。如果 Windows 无法识别 python 命令,它会打开微软商店,因为它认为 Python 未安装,或者你会看到类似“未找到 Python”的消息。如果微软商店打开了,请关闭它;与其使用微软维护的版本,还是更好地使用来自python.org的官方 Python 安装程序。
最简单的解决方法是尝试使用py命令,而无需对系统进行任何更改。这是一个 Windows 工具,可以找到系统上安装的最新 Python 版本并运行该解释器。如果此命令有效且你希望使用它,只需在本书中看到python或python3命令时,使用py即可。
重新运行安装程序
python命令无法正常工作最常见的原因是人们在运行安装程序时忘记选择“Add Python to PATH”选项;这是一个容易犯的错误。PATH变量是一个系统设置,告诉 Python 去哪里查找常用的程序。在这种情况下,Windows 不知道如何找到 Python 解释器。
这种情况下最简单的解决方法是重新运行安装程序。如果从python.org可以获取更新的安装程序,请下载新版本并运行,确保勾选Add Python to PATH选项。
如果你已经有了最新的安装程序,请再次运行并选择Modify选项。你将看到一个可选功能的列表;在这个界面上保持默认选项。然后点击Next并勾选Add Python to Environment Variables框。最后点击Install。安装程序会识别到 Python 已经安装,并将 Python 解释器的位置添加到 PATH 变量中。确保关闭所有打开的终端,因为它们仍然会使用旧的 PATH 变量。打开一个新的终端窗口并再次输入 python 命令;你应该会看到 Python 提示符(>>>)。
macOS 上的 Python
第一章中的安装说明使用了python.org的官方 Python 安装程序。该官方安装程序多年来一直运行良好,但有一些情况可能会让你偏离正轨。如果有什么不按预期工作,本节将帮助你解决问题。
意外安装了 Apple 版本的 Python
如果你运行python3命令时,系统中尚未安装 Python,你很可能会看到需要安装命令行开发者工具的消息。此时,最好的做法是关闭显示该消息的弹窗,下载 Python 安装程序,来自python.org,并运行安装程序。
如果你选择在此时安装命令行开发者工具,macOS 会与开发者工具一起安装 Apple 的 Python 版本。唯一的问题是,Apple 的 Python 版本通常会比最新的官方版本稍微滞后。然而,你仍然可以从python.org下载并运行官方安装程序,python3就会指向更新的版本。不要担心开发者工具已安装;其中有一些有用的工具,包括附录 D 中讨论的 Git 版本控制系统。
旧版 macOS 上的 Python 2
在旧版 macOS(Monterey 之前的版本,macOS 12)上,默认安装的是过时的 Python 2。在这些系统中,python命令会指向过时的系统解释器。如果你使用的是安装了 Python 2 的 macOS 版本,请确保使用python3命令,这样你就能始终使用你安装的 Python 版本。
Linux 上的 Python
Python 默认已经包含在几乎所有 Linux 系统中。然而,如果你系统上的默认版本早于 Python 3.9,建议安装最新版本。你也可以安装最新版本,以便获得最新的功能,比如 Python 改进后的错误信息。以下的说明适用于大多数基于 apt 的系统。
使用默认的 Python 安装
如果你想使用python3所指向的 Python 版本,请确保安装以下三个附加包:
$ **sudo apt install python3-dev python3-pip python3-venv**
这些包包括一些对开发人员有用的工具,以及让你安装第三方包的工具,比如本书项目部分使用的包。
安装最新版本的 Python
我们将使用一个叫做deadsnakes的包,它可以方便地安装多个版本的 Python。输入以下命令:
$ **sudo add-apt-repository ppa:deadsnakes/ppa**
$ **sudo apt update**
$ **sudo apt install python3.11**
这些命令将会把 Python 3.11 安装到你的系统中。
输入以下命令,启动一个运行 Python 3.11 的终端会话:
$ **python3.11**
>>>
在本书中,凡是出现python命令的地方,请使用python3.11代替。你也需要在从终端运行程序时使用这个命令。
你需要再安装两个包,才能充分利用你的 Python 安装:
$ **sudo apt install python3.11-dev python3.11-venv**
这些包包括在安装和运行第三方包时所需的模块,例如书籍下半部分项目中使用的那些包。
检查你正在使用的 Python 版本
如果你在运行 Python 或安装额外包时遇到任何问题,知道自己正在使用哪个版本的 Python 会很有帮助。你可能安装了多个版本的 Python,且不清楚当前使用的是哪个版本。
在终端中执行以下命令:
$ **python --version**
Python 3.11.0
这告诉你当前命令python所指向的确切版本。较短的命令python -V也会给出相同的输出。
Python 关键字和内置函数
Python 自带了一套关键字和内置函数。了解这些非常重要,因为在 Python 中命名时,你的名称不能与这些关键字相同,也不应与函数名相同,否则你将覆盖这些函数。
在本节中,我们将列出 Python 的关键字和内置函数名称,以便你知道哪些名称需要避免使用。
Python 关键字
以下每个关键字都有特定的含义,如果你尝试将它们用作变量名,会出现错误。
False await else import pass
None break except in raise
True class finally is return
and continue for lambda try
as def from nonlocal while
assert del global not with
async elif if or yield
Python 内置函数
如果你使用以下这些现成的内置函数作为变量名,你不会遇到错误,但会覆盖该函数的行为:
abs() hash() slice()
aiter() help() sorted()
all() hex() staticmethod()
any() id() str()
anext() input() sum()
ascii() int() super()
bin() isinstance() tuple()
bool() issubclass() type()
breakpoint() iter() vars()
bytearray() len() zip()
bytes() list() __import__()
callable() locals()
chr() map()
classmethod() max()
compile() memoryview()
complex() min()
delattr() next()
dict() object()
dir() oct()
divmod() open()
enumerate() ord()
eval() pow()
exec() print()
filter() property()
float() range()
format() repr()
frozenset() reversed()
getattr() round()
globals() set()
hasattr() setattr()
附录:B
文本编辑器和 IDE

程序员花费大量时间编写、阅读和编辑代码,使用文本编辑器或 IDE(集成开发环境)来提高工作的效率是至关重要的。一个好的编辑器能够完成简单的任务,例如突出显示代码结构,让你在工作时及时发现常见的错误。但它不会做得过多,以至于分散你思考的注意力。编辑器还具有自动缩进、标记合适行长以及常见操作的快捷键等有用功能。
IDE 是一种文本编辑器,包含许多其他工具,如交互式调试器和代码自省。IDE 在你输入代码时会分析你的代码,并试图了解你正在构建的项目。例如,当你开始输入函数名时,IDE 可能会显示该函数接受的所有参数。这种行为在一切正常且你理解自己看到的内容时非常有帮助。但作为初学者,它也可能让人感到不知所措,并且当你不确定为什么代码在 IDE 中无法正常工作时,排除故障可能会变得很困难。
如今,文本编辑器和 IDE 之间的界限已经模糊。大多数流行的编辑器都具有曾经只有 IDE 才有的功能。同样,大多数 IDE 可以配置成运行在一个较轻的模式下,减少干扰,帮助你专注工作,但当你需要时,也能让你使用更高级的功能。
如果你已经安装了一个你喜欢的编辑器或 IDE,并且它已经配置好与系统上安装的最新版本 Python 一起使用,那么我建议你继续使用你已经熟悉的工具。探索不同的编辑器可以很有趣,但这也是避免学习新语言的一种方式。
如果你还没有安装编辑器或 IDE,我推荐 VS Code,原因有很多:
-
它是免费的,并且以开源许可证发布。
-
它可以在所有主要操作系统上安装。
-
它对初学者友好,但也足够强大,许多专业程序员将其作为主要编辑器使用。
-
它可以查找你已安装的 Python 版本,通常无需配置即可运行你的第一个程序。
-
它有一个集成终端,因此你的输出会显示在与代码相同的窗口中。
-
有一个 Python 扩展,它使得编辑器在编写和维护 Python 代码时非常高效。
-
它高度可定制,因此你可以根据自己的工作方式来调整它,以便更好地使用代码。
在本附录中,你将学习如何开始配置 VS Code,以使其更适合你的工作。你还将学习一些快捷键,帮助你提高工作效率。成为一个快速打字员在编程中并不像许多人认为的那么重要,但了解你的编辑器并知道如何高效使用它是非常有帮助的。
尽管如此,VS Code 并不适合所有人。如果由于某些原因它在你的系统上运行不良,或者在工作时让你分心,还有许多其他编辑器你可能会觉得更合适。本附录包含了对一些其他编辑器和集成开发环境(IDE)的简要描述,你可以考虑一下。
高效使用 VS Code
在第一章中,你已经安装了 VS Code 并添加了 Python 扩展。本节将向你展示一些你可以进行的进一步配置,以及提高工作效率的快捷键。
配置 VS Code
有几种方法可以更改 VS Code 的默认配置设置。一些更改可以通过界面进行,而另一些则需要修改配置文件。这些更改有时会对你在 VS Code 中的所有操作生效,而其他一些则仅会影响包含配置文件的文件夹中的文件。
例如,如果你在 python_work 文件夹中有一个配置文件,那么这些设置只会影响该文件夹(及其子文件夹)中的文件。这是一个很好的特性,因为它意味着你可以为项目设置特定的配置,覆盖全局设置。
使用制表符和空格
如果你的代码中混合使用了制表符和空格,这可能会导致程序出现难以诊断的问题。当你在安装了 Python 扩展的 .py 文件中工作时,VS Code 会配置为每次按下 TAB 键时插入四个空格。如果你只写自己的代码,并且安装了 Python 扩展,你可能永远不会遇到制表符和空格的问题。
然而,你的 VS Code 安装可能没有正确配置。而且,在某些时候,你可能会遇到一个只有制表符或制表符与空格混合使用的文件。如果你怀疑有关于制表符和空格的问题,请查看 VS Code 窗口底部的状态栏,点击 空格 或 制表符大小。下拉菜单将出现,允许你在使用制表符和使用空格之间切换。你还可以更改默认缩进级别,并将文件中的所有缩进转换为制表符或空格。
如果你正在查看一些代码,不确定缩进是由制表符还是空格组成,可以高亮显示几行代码。这会使不可见的空白字符变得可见。每个空格将显示为一个点,每个制表符将显示为一个箭头。
更改颜色主题
默认情况下,VS Code 使用暗色主题。如果你想更改它,请点击 文件(在 macOS 上是 Code 菜单),然后点击 首选项,选择 颜色主题。会出现一个下拉列表,允许你选择一个适合你的主题。
设置行长度指示器
大多数编辑器允许你设置一个视觉提示,通常是垂直线,来显示行的结束位置。在 Python 社区中,约定是将每行限制为 79 个字符或更少。
要设置此功能,请点击代码,然后点击首选项,接着选择设置。在弹出的对话框中,输入rulers。你将看到一个名为“Editor: Rulers”的设置;点击标记为在 settings.json 中编辑的链接。在弹出的文件中,向 editor.rulers 设置中添加以下内容:
settings.json
"editor.rulers": [
80,
]
这将在编辑窗口的 80 个字符位置添加一条垂直线。你可以设置多个垂直线;例如,如果你想在 120 个字符的位置添加一条线,你的设置值将是[80, 120]。如果你没有看到垂直线,请确保你已保存设置文件;在某些系统上,可能需要退出并重新打开 VS Code 才能使更改生效。
简化输出
默认情况下,VS Code 会在嵌入式终端窗口中显示你的程序输出。该输出包含用于运行文件的命令。对于许多情况,这是理想的,但当你刚开始学习 Python 时,可能会比你想要的更分散注意力。
为了简化输出,关闭所有在 VS Code 中打开的标签页,然后退出 VS Code。重新启动 VS Code,并打开包含你正在处理的 Python 文件的文件夹;这可以是保存hello_world.py的python_work文件夹。
点击运行/调试图标(像一个带小虫子的三角形),然后点击创建一个 launch.json 文件。在弹出的提示中选择 Python 选项。在打开的launch.json文件中,进行以下更改:
launch.json
{
*--snip--*
"configurations": [
{
*--snip--*
"console": "internalConsole",
"justMyCode": true
}
]
}
在这里,我们将console设置从integratedTerminal更改为internalConsole。保存设置文件后,打开一个.py文件,例如hello_world.py,然后按 CTRL-F5 运行它。在 VS Code 的输出窗格中,如果没有选中,请点击调试控制台。你应该只看到程序的输出,并且每次运行程序时输出都会刷新。
进一步探索自定义设置
你可以通过多种方式自定义 VS Code,以帮助你更高效地工作。要开始探索可用的自定义选项,请点击代码,然后点击首选项,接着选择设置。你将看到一个名为“常用”的列表;点击任何子标题,查看一些常见的方式,修改你安装的 VS Code。花些时间查看是否有任何设置可以让 VS Code 更好地为你工作,但不要在配置编辑器时迷失,耽误了学习如何使用 Python!
VS Code 快捷键
所有编辑器和集成开发环境(IDE)都提供了高效的方式来完成每个人在编写和维护代码时需要做的常见任务。例如,你可以轻松缩进单行代码或整块代码;你也可以同样轻松地将代码块上下移动。
这里有太多快捷键无法一一描述。本节将分享一些在编写第一个 Python 文件时,你可能会发现有用的快捷键。如果你使用与 VS Code 不同的编辑器,确保学习如何在你选择的编辑器中高效地完成这些相同的任务。
缩进和取消缩进代码块
要缩进整个代码块,选中它并按 CTRL-],或 macOS 上的 ⌘-]。要取消缩进一个代码块,选中它并按 CTRL-[,或 macOS 上的 ⌘-[。
注释掉代码块
要暂时禁用一块代码,你可以选中该代码块并注释掉它,这样 Python 就会忽略它。选中你想忽略的代码部分并按 CTRL-/,或 macOS 上的 ⌘-/。所选行将会被注释掉,并且以 # 号标记缩进到与代码行相同的层级,以表示这些不是普通的注释。当你想取消注释代码块时,选中代码块并重新执行相同的命令。
上下移动行
随着程序变得越来越复杂,你可能会发现需要在文件中上下移动代码块。为此,选中你想要移动的代码并按 ALT-上箭头,或 macOS 上的 Option-上箭头。按相同的键组合并配合下箭头,可以将代码块向下移动。
如果你只想上下移动一行,可以点击该行的任意位置;不需要高亮整个行就能移动它。
隐藏文件资源管理器
VS Code 中集成的文件资源管理器非常方便。然而,当你编写代码时,它可能会让你分心,并且在较小的屏幕上可能占用宝贵的空间。命令 CTRL-B 或 macOS 上的 ⌘-B 可以切换文件资源管理器面板的可见性。
查找其他快捷键
在编辑环境中高效工作需要练习,但同样也需要有意识。当你学习编写代码时,试着注意自己重复做的事情。你在编辑器中执行的任何操作很可能都有快捷键;如果你通过点击菜单项来执行编辑任务,寻找这些操作的快捷键。如果你经常在键盘和鼠标之间切换,找一些可以减少频繁拿起鼠标的导航快捷键。
你可以通过点击 Code 然后选择 Preferences,再选择 Keyboard Shortcuts 来查看 VS Code 中所有的键盘快捷键。你可以使用搜索框来查找特定的快捷键,或者可以滚动浏览列表,找到能帮助你提高工作效率的快捷键。
记住,专注于你正在编写的代码比花太多时间在你使用的工具上更为重要。
其他文本编辑器和 IDE
你会听到并看到一些人使用其他文本编辑器。大多数编辑器可以像你自定义 VS Code 一样进行配置,以帮助你完成任务。这里有一些你可能会听到的文本编辑器。
IDLE
IDLE 是随 Python 附带的文本编辑器。与其他更现代的编辑器相比,它的操作稍显不直观。然而,你会在其他针对初学者的教程中看到它的引用,所以你可能想试试看。
Geany
Geany 是一个简单的文本编辑器,它将在一个单独的终端窗口中显示所有输出,帮助你习惯使用终端。Geany 拥有非常简洁的界面,但它足够强大,许多经验丰富的程序员仍然在使用它。
如果你觉得 VS Code 太分散注意力、功能过多,可以考虑改用 Geany。
Sublime Text
Sublime Text 是另一个极简主义的编辑器,如果你觉得 VS Code 功能太多,太复杂,可以考虑使用 Sublime Text。Sublime Text 拥有非常简洁的界面,并且以即使在处理非常大的文件时也能运行良好而闻名。它是一个不会干扰你、让你专注于编写代码的编辑器。
Sublime Text 提供无限期的免费试用,但它并不是免费的,也不是开源的。如果你决定喜欢它并且能够负担得起购买完整版许可,你应该这么做。购买是一次性费用;并不是软件订阅。
Emacs 和 Vim
Emacs 和 Vim 是许多有经验的程序员偏好的两款编辑器,因为它们的设计可以让你在不离开键盘的情况下使用它们。这使得编写、阅读和修改代码非常高效,一旦你学会了如何使用这些编辑器。也正因为如此,这两个编辑器的学习曲线都比较陡峭。Vim 已经预装在大多数 Linux 和 macOS 机器上,Emacs 和 Vim 都可以完全在终端中运行。因此,它们经常被用来通过远程终端会话在服务器上编写代码。
程序员们通常会推荐你尝试一下它,但许多熟练的程序员忘记了新手程序员已经在努力学习的内容。了解这些编辑器是好的,但在你能在一个更具用户友好的编辑器中舒适地处理代码之前,你应该暂时避免使用它们,专注于学习编程,而不是学习如何使用编辑器。
PyCharm
PyCharm 是 Python 程序员中流行的集成开发环境(IDE),因为它是专门为 Python 设计的。完整版需要付费订阅,但也有一个免费的 PyCharm Community Edition 版本可供使用,许多开发者觉得它非常实用。
如果你尝试使用 PyCharm,请注意,默认情况下,它为你的每个项目设置了一个隔离的环境。这通常是好事,但如果你不了解它为你做了什么,可能会导致意外的行为。
Jupyter Notebooks
Jupyter Notebook 是一种不同于传统文本编辑器或集成开发环境(IDE)的工具,它是一个主要由代码块和文本块组成的 Web 应用。文本块以 Markdown 渲染,因此你可以在文本块中使用简单的格式化。
Jupyter Notebook 是为了支持 Python 在科学应用中的使用而开发的,但它们后来扩展到在各种场景中都非常有用。与其在 .py 文件中仅仅写注释,你还可以在代码段之间写清晰的文本,并使用简单的格式化,例如标题、项目符号列表和超链接。每个代码块都可以独立运行,允许你测试程序中的小部分,或者一次性运行所有代码块。每个代码块都有自己的输出区域,你可以根据需要切换输出区域的显示与否。
Jupyter Notebook 有时会让人困惑,因为不同单元格之间的交互。如果你在一个单元格中定义了一个函数,那么这个函数在其他单元格中也可以使用。这大多数情况下是有益的,但在较长的笔记本中,或者当你没有完全理解 Notebook 环境是如何工作的时,它可能会让人感到困惑。
如果你从事任何科学或数据分析相关的 Python 工作,你几乎肯定会在某个时候接触到 Jupyter Notebook。
附录:C
寻求帮助

每个人在学习编程时都会遇到卡壳的情况。因此,作为程序员,最重要的技能之一就是如何高效地解决问题。这个附录概述了几种在编程遇到困惑时帮助你重新开始的方法。
第一步骤
当你遇到困难时,第一步应该是评估自己的情况。在向其他人寻求帮助之前,清晰地回答以下三个问题:
-
你在尝试做什么?
-
你到目前为止尝试了什么?
-
你得到了什么结果?
让你的回答尽可能具体。对于第一个问题,像“我正在尝试在我的新 Windows 笔记本电脑上安装最新版本的 Python”这样的明确陈述已经足够详细,能够帮助 Python 社区的其他人提供帮助。而像“我正在尝试安装 Python”这样的说法,则无法提供足够的信息,其他人也无法提供太多帮助。
你对第二个问题的回答应该提供足够的细节,这样就不会建议你重复已经尝试过的步骤:“我去了python.org/downloads并点击了适合我系统的下载按钮。然后我运行了安装程序”比“我去了 Python 网站下载了一些东西”更有帮助。
对于第三个问题,了解你收到的具体错误信息非常有帮助,这样你就可以利用这些信息在网上搜索解决方案,或者在寻求帮助时提供这些信息。
有时,仅仅在向他人寻求帮助之前回答这三个问题,就能让你发现自己遗漏的东西,并帮助你摆脱困境,而无需进一步寻求帮助。程序员甚至给这个过程起了个名字:橡皮鸭调试。这个想法是,如果你清楚地向一只橡皮鸭(或任何无生命的物体)解释你的情况,并向它提出一个具体的问题,你通常能够自己找到答案。有些编程团队甚至会保留一只真正的橡皮鸭,以鼓励大家“与鸭子交谈”。
再试一次
有时候,回到一开始再试一次就能解决很多问题。比如,你正在尝试根据本书中的示例编写一个 for 循环。你可能只是错过了一些简单的东西,比如 for 语句末尾的冒号。再走一遍步骤可能会帮助你避免重复相同的错误。
休息一下
如果你已经在同一个问题上工作了一段时间,休息一下是你可以尝试的最佳策略之一。当我们长时间专注于同一任务时,大脑会开始只关注一种解决方案。我们失去了对我们所做假设的关注,而休息有助于我们从新的角度看待问题。这不一定需要很长时间的休息,只需要让你摆脱当前的思维模式。如果你已经坐了很长时间,可以做些身体活动:短暂散步、到外面走一走,或者喝杯水或吃点零食。
如果你感到沮丧,可能值得把工作放一放,休息一下。良好的睡眠几乎总能让问题变得更易于解决。
请参考本书的资源
本书的在线资源,可以通过 ehmatthes.github.io/pcc_3e 访问,包含了许多有助于设置系统和逐章操作的部分。如果你还没有查看过,请看一下这些资源,看看是否有对你情况有帮助的内容。
在线搜索
很有可能,别人已经遇到过你现在所遇到的问题,并在网上写下了解决方法。良好的搜索技能和具体的问题会帮助你找到现有的资源来解决你面临的问题。例如,如果你在新 Windows 系统上安装最新版 Python 时遇到困难,搜索 install python windows 并将结果限定在过去一年内,可能会引导你找到一个清晰的答案。
搜索确切的错误信息也能非常有帮助。例如,如果你在新 Windows 系统的终端中运行 Python 程序时遇到以下错误:
> **python** **hello****_world.py**
Python was not found; run without arguments to install from the Microsoft
Store...
搜索完整的短语,“Python was not found; run without arguments to install from the Microsoft Store”,可能会得到一些有用的建议。
当你开始搜索与编程相关的主题时,几个网站会反复出现。我将简要描述这些网站,让你了解它们可能有多么有帮助。
Stack Overflow
Stack Overflow (stackoverflow.com) 是程序员最受欢迎的问答网站之一,通常会出现在 Python 相关搜索的结果第一页。会员们在遇到困难时发布问题,其他会员则尝试提供有帮助的回答。用户可以投票支持他们认为最有用的回答,因此最好的回答通常是你最先能找到的。
许多基础的 Python 问题在 Stack Overflow 上都有非常清晰的答案,因为社区随着时间的推移已经将其完善。用户也被鼓励发布更新,因此回答通常保持相对的时效性。截止本文写作时,几乎有两百万个与 Python 相关的问题在 Stack Overflow 上得到了回答。
在你在 Stack Overflow 上发布问题之前,有一个期望需要了解。问题应该是你面临的情况的最简短示例。如果你发布 5 到 20 行代码,能够重现你遇到的错误,并且如果你在本附录第 477 页中提到的“第一步”中回答了相关问题,通常会有人帮助你。如果你分享一个包含多个大型文件的项目链接,人们就很不可能提供帮助。这里有一个很好的指南,介绍了如何写出一个好的问题:stackoverflow.com/help/how-to-ask。这个指南中的建议适用于任何程序员社区求助时。
官方 Python 文档
官方 Python 文档(docs.python.org)对于初学者来说可能有些难以掌握,因为它的目的更多是记录语言本身,而不是提供详细的解释。官方文档中的示例通常可以正常工作,但您可能无法理解其中的所有内容。不过,它仍然是一个在搜索中遇到时可以参考的好资源,并且随着您对 Python 理解的加深,它将变得更加有用。
官方库文档
如果您使用的是某个特定的库,例如 Pygame、Matplotlib 或 Django,相关的官方文档链接通常会出现在搜索结果中。例如,docs.djangoproject.com 在使用 Django 时非常有帮助。如果您计划使用这些库,熟悉它们的官方文档是一个不错的主意。
r/learnpython
Reddit 由多个名为 subreddit 的子论坛组成。r/learnpython 子论坛(reddit.com/r/learnpython)非常活跃且充满支持。您可以阅读他人的问题并发布自己的问题。通常您会得到多个不同的观点,这对于深入理解您正在研究的主题非常有帮助。
博客文章
许多程序员会维护博客并分享他们正在使用的语言部分的文章。您应该查看博客文章上的日期,以判断其信息是否适用于您正在使用的 Python 版本。
Discord
Discord 是一个在线聊天平台,拥有一个 Python 社区,您可以在这里寻求帮助并参与 Python 相关的讨论。
要查看它,请访问 pythondiscord.com 并点击右上角的 Discord 链接。如果您已经有 Discord 账户,可以使用现有账户登录。如果没有账户,请输入用户名并按照提示完成注册。
如果这是您第一次访问 Python Discord,您需要先接受社区规则才能完全参与。一旦完成,您可以加入任何您感兴趣的频道。如果您需要帮助,请确保在 Python 帮助频道中发帖。
Slack
Slack 是另一个在线聊天平台。它通常用于公司内部通讯,但也有许多公共小组您可以加入。如果您想查看 Python Slack 小组,可以从 pyslackers.com 开始。点击页面顶部的 Slack 链接,然后输入您的电子邮件地址以获取邀请。
一旦进入 Python 开发者工作区,您将看到一个频道列表。点击 Channels,然后选择您感兴趣的话题。您可能希望从 #help 和 #django 频道开始。
附录:D
使用 Git 进行版本控制

版本控制软件允许你在项目处于工作状态时拍摄快照。当你对项目进行更改时——例如,添加一个新功能——如果项目当前的状态无法正常工作,你可以回到先前的工作状态。
使用版本控制软件使你可以自由地进行改进和犯错,而不必担心破坏项目。这个功能在大型项目中尤为重要,但在较小的项目中也同样有帮助,甚至当你只是在处理单个文件中的程序时。
在本附录中,你将学习如何安装 Git,并在当前项目中使用它进行版本控制。Git 是目前最流行的版本控制软件。它的许多高级工具帮助团队在大型项目中协作,但其最基本的功能也非常适合单独开发者使用。Git 通过跟踪项目中每个文件的更改来实现版本控制;如果你犯了错误,只需返回到先前保存的状态。
安装 Git
Git 可在所有操作系统上运行,但每个系统的安装方法有所不同。以下各节提供了针对每个操作系统的具体安装说明。
Git 在一些系统中默认包含,并且常常与其他你可能已经安装的软件包一起捆绑。安装 Git 之前,先检查一下系统中是否已经安装了它。打开一个新的终端窗口并运行命令 git --version。如果输出显示了特定的版本号,则表示 Git 已经安装。如果看到提示你安装或更新 Git 的信息,请按照屏幕上的指示操作。
如果你没有看到屏幕上的任何提示,并且使用的是 Windows 或 macOS,可以从 git-scm.com 下载一个安装程序。如果你是使用 apt 兼容系统的 Linux 用户,可以通过命令 sudo apt install git 安装 Git。
配置 Git
Git 跟踪谁对项目进行了更改,即使只有一个人在项目中工作。为此,Git 需要知道你的用户名和电子邮件地址。你必须提供一个用户名,但可以随意编造一个假的电子邮件地址:
$ **git config --global user.name "**`username`**"**
$ **git config --global user.email "**`username@example.com`**"**
如果你忘记了这一步,Git 会在你第一次提交时提示你提供这些信息。
最好为每个项目设置主分支的默认名称。一个好的名称是 main:
$ **git config --global init.defaultBranch main**
这个配置意味着,你使用 Git 管理的每个新项目将以一个名为 main 的单一分支提交开始。
创建一个项目
让我们创建一个项目来进行操作。在你的系统上创建一个名为 git_practice 的文件夹。在该文件夹内,创建一个简单的 Python 程序:
hello_git.py
print("Hello Git world!")
我们将使用这个程序来探索 Git 的基本功能。
忽略文件
扩展名为.pyc的文件是从.py文件自动生成的,所以我们不需要 Git 来跟踪它们。这些文件被存储在一个名为pycache的目录中。为了告诉 Git 忽略这个目录,我们创建一个名为.gitignore的特殊文件——文件名以点号开头且没有扩展名,并将以下行添加到文件中:
.gitignore
__pycache__/
这个文件告诉 Git 忽略任何位于pycache目录中的文件。使用.gitignore文件可以保持项目整洁,更容易进行操作。
你可能需要修改文件浏览器的设置,以便显示隐藏文件(文件名以点号开头)。在 Windows 资源管理器中,勾选“查看”菜单中的隐藏的项目框。在 macOS 中,按⌘-SHIFT-.(点号)。在 Linux 中,查找名为“显示隐藏文件”的设置。
初始化仓库
现在你有一个包含 Python 文件和.gitignore文件的目录,你可以初始化一个 Git 仓库。打开终端,进入git_practice文件夹,并运行以下命令:
git_practice$ **git init**
Initialized empty Git repository in git_practice/.git/
git_practice$
输出显示 Git 已经在git_practice中初始化了一个空的仓库。一个仓库是 Git 正在积极跟踪的程序文件集合。Git 用来管理仓库的所有文件都位于一个隐藏的目录.git中,你不需要直接与这个目录交互。只要不要删除这个目录,否则你将丢失项目的历史记录。
检查状态
在做任何操作之前,让我们先查看一下项目的状态:
git_practice$ **git status**
❶ On branch main
No commits yet
❷ Untracked files:
(use "git add <file>..." to include in what will be committed)
.gitignore
hello_git.py
❸ nothing added to commit but untracked files present (use "git add" to track)
git_practice$
在 Git 中,一个分支是你正在工作的项目版本;在这里你可以看到我们在一个名为main的分支上❶。每次检查项目的状态时,它应该显示你在main分支上。接着你可以看到我们即将进行初始提交。一个提交是项目在某一特定时刻的快照。
Git 告知我们项目中有未被跟踪的文件❷,因为我们还没有告诉它要跟踪哪些文件。接着我们被告知,当前提交没有任何内容,但存在一些未跟踪的文件,我们可能想要将它们添加到仓库中❸。
添加文件到仓库
让我们将这两个文件添加到仓库中,再次检查状态:
❶ git_practice$ **git add .**
❷ git_practice$ **git status**
On branch main
No commits yet
Changes to be committed:
(use "git rm --cached <file>..." to unstage)
❸ new file: .gitignore
new file: hello_git.py
git_practice$
命令git add .将所有项目中尚未被跟踪的文件添加到仓库中❶,只要它们没有被列在.gitignore文件中。它不会提交这些文件;只是告诉 Git 开始关注它们。当我们现在检查项目的状态时,可以看到 Git 已经识别出一些需要提交的更改❷。标签新文件表示这些文件是新添加到仓库中的❸。
提交更改
让我们进行第一次提交:
❶ git_practice$ **git commit -m "Started project."**
❷ [main (root-commit) cea13dd] Started project.
❸ 2 files changed, 5 insertions(+)
create mode 100644 .gitignore
create mode 100644 hello_git.py
❹ git_practice$ **git status**
On branch main
nothing to commit, working tree clean
git_practice$
我们使用命令git commit -m "``message``"❶来对项目进行快照。-m标志告诉 Git 记录后续的消息(Started project.)到项目日志中。输出显示我们在main分支上❷,并且有两个文件发生了变化❸。
当我们现在检查状态时,可以看到我们位于 main 分支,并且工作树是干净的 ❹。这是你每次提交项目工作状态时应该看到的信息。如果你看到不同的信息,仔细阅读它;很可能是你在提交前忘记添加某个文件。
检查日志
Git 会记录所有对项目所做的提交日志。让我们查看日志:
git_practice$ **git log**
commit cea13ddc51b885d05a410201a54faf20e0d2e246 (HEAD -> main)
Author: eric <eric@example.com>
Date: Mon Jun 6 19:37:26 2022 -0800
Started project.
git_practice$
每次你进行提交时,Git 会生成一个独特的 40 字符的参考 ID。它记录谁进行了提交、何时进行的提交以及记录的消息。你并不总是需要所有这些信息,因此 Git 提供了一个选项来打印更简洁的日志条目:
git_practice$ **git log --pretty=oneline**
cea13ddc51b885d05a410201a54faf20e0d2e246 (HEAD -> main) Started project.
git_practice$
--pretty=oneline 标志提供了两条最重要的信息:提交的参考 ID 和为该提交记录的消息。
第二次提交
要看到版本控制的真正强大之处,我们需要对项目进行更改并提交该更改。在这里,我们将只是再给 hello_git.py 添加一行:
hello_git.py
print("Hello Git world!")
print("Hello everyone.")
当我们检查项目的状态时,我们会看到 Git 已经注意到文件发生了变化:
git_practice$ **git status**
❶ On branch main
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
❷ modified: hello_git.py
❸ no changes added to commit (use "git add" and/or "git commit -a")
git_practice$
我们看到当前工作的分支 ❶、被修改的文件名 ❷,以及没有提交的更改 ❸。让我们提交这个更改,然后再次检查状态:
❶ git_practice$ **git commit -am "Extended greeting."**
[main 945fa13] Extended greeting.
1 file changed, 1 insertion(+), 1 deletion(-)
❷ git_practice$ **git status**
On branch main
nothing to commit, working tree clean
❸ git_practice$ **git log --pretty=oneline**
945fa13af128a266d0114eebb7a3276f7d58ecd2 (HEAD -> main) Extended greeting.
cea13ddc51b885d05a410201a54faf20e0d2e246 Started project.
git_practice$
我们进行一次新的提交,在使用 git commit 命令时传入 -am 标志 ❶。-a 标志告诉 Git 将所有修改过的文件添加到当前的提交中。(如果你在两次提交之间创建了新文件,请重新发出 git add . 命令,将新文件包含进来。)-m 标志告诉 Git 在日志中记录这个提交的消息。
当我们检查项目的状态时,我们看到我们再次拥有一个干净的工作树 ❷。最后,我们在日志中看到这两个提交 ❸。
放弃更改
现在让我们来看一下如何放弃更改并回到上一个工作状态。首先,给 hello_git.py 添加一行:
hello_git.py
print("Hello Git world!")
print("Hello everyone.")
print("Oh no, I broke the project!")
保存并运行这个文件。
我们检查状态时发现 Git 已经注意到这个更改:
git_practice$ **git status**
On branch main
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
❶ modified: hello_git.py
no changes added to commit (use "git add" and/or "git commit -a")
git_practice$
Git 发现我们修改了 hello_git.py ❶,如果需要,我们可以提交这个更改。但是这次,我们不会提交这个更改,而是回到上次提交的状态,那个时候我们知道项目是正常工作的。我们不会对 hello_git.py 做任何操作:既不删除这行,也不使用文本编辑器的撤销功能。相反,在你的终端会话中输入以下命令:
git_practice$ **git restore .**
git_practice$ **git status**
On branch main
nothing to commit, working tree clean
git_practice$
命令 git restore filename 允许你放弃自上次提交以来某个特定文件的所有更改。命令 git restore . 放弃自上次提交以来在所有文件中做的所有更改;此操作将项目恢复到最后一次提交的状态。
当你回到文本编辑器时,你会看到 hello_git.py 已经恢复成了这个样子:
print("Hello Git world!")
print("Hello everyone.")
尽管在这个简单项目中回到之前的状态看起来似乎很不起眼,但如果我们在一个有数十个修改文件的大项目中工作,自上次提交以来所有变动的文件都会被恢复。这个功能非常有用:你可以在实现新特性时进行尽可能多的更改,如果它们不起作用,你可以丢弃这些更改而不影响项目。你不需要记住那些更改并手动撤销,Git 会为你做这一切。
检出之前的提交
你可以通过使用checkout命令并利用参考 ID 的前六个字符,重新访问日志中的任何提交。在检出并查看一个较早的提交后,你可以返回到最新的提交,或者放弃最近的工作,从较早的提交开始继续开发:
git_practice$ **git log --pretty=oneline**
945fa13af128a266d0114eebb7a3276f7d58ecd2 (HEAD -> main) Extended greeting.
cea13ddc51b885d05a410201a54faf20e0d2e246 Started project.
git_practice$ **git checkout cea13d**
Note: switching to 'cea13d'.
❶ You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by switching back to a branch.
If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -c with the switch command. Example:
git switch -c <new-branch-name>
❷ Or undo this operation with:
git switch -
Turn off this advice by setting config variable advice.detachedHead to false
HEAD is now at cea13d Started project.
git_practice$
当你检出一个之前的提交时,你将离开主分支,进入 Git 所说的detached HEAD状态❶。HEAD是项目当前已提交的状态;你之所以detached,是因为你已经离开了一个命名的分支(此处为main)。
要返回到main分支,你可以按照建议❷撤销之前的操作:
git_practice$ **git switch -**
Previous HEAD position was cea13d Started project.
Switched to branch 'main'
git_practice$
此命令将你带回main分支。除非你想使用 Git 的更高级功能,否则在检出一个之前的提交后,最好不要对你的项目进行任何更改。不过,如果你是唯一一个在项目上工作的人,并且想要放弃所有最近的提交,回到之前的状态,你可以将项目重置到一个之前的提交。工作在main分支时,输入以下命令:
❶ git_practice$ **git status**
On branch main
nothing to commit, working directory clean
❷ git_practice$ **git log --pretty=oneline**
945fa13af128a266d0114eebb7a3276f7d58ecd2 (HEAD -> main) Extended greeting.
cea13ddc51b885d05a410201a54faf20e0d2e246 Started project.
❸ git_practice$ **git reset --hard cea13d**
HEAD is now at cea13dd Started project.
❹ git_practice$ **git status**
On branch main
nothing to commit, working directory clean
❺ git_practice$ **git log --pretty=oneline**
cea13ddc51b885d05a410201a54faf20e0d2e246 (HEAD -> main) Started project.
git_practice$
我们首先检查状态,确保我们在main分支❶。当我们查看日志时,看到两个提交❷。然后,我们使用git reset --hard命令,并输入想要永久回退的提交的参考 ID 前六个字符❸。我们再次检查状态,看到我们处于main分支且没有需要提交的内容❹。当我们再次查看日志时,发现我们已经回到想要重新开始的提交❺。
删除仓库
有时候你可能会弄乱仓库的历史,甚至不知道如何恢复。如果发生这种情况,首先可以考虑按照附录 C 中讨论的方法寻求帮助。如果你无法修复它,而且是在做一个独立项目,你可以继续使用文件,但通过删除.git目录来摆脱项目的历史。这不会影响任何文件的当前状态,但会删除所有提交,因此你将无法检出项目的其他状态。
要执行此操作,你可以打开文件浏览器并删除.git仓库,或者通过命令行删除它。之后,你需要从一个新的仓库开始,重新开始跟踪更改。下面是在终端会话中执行此操作的过程:
❶ git_practice$ **git status**
On branch main
nothing to commit, working directory clean
❷ git_practice$ **rm -rf .git/**
❸ git_practice$ **git status**
fatal: Not a git repository (or any of the parent directories): .git
❹ git_practice$ **git init**
Initialized empty Git repository in git_practice/.git/
❺ git_practice$ **git status**
On branch main
No commits yet
Untracked files:
(use "git add <file>..." to include in what will be committed)
.gitignore
hello_git.py
nothing added to commit but untracked files present (use "git add" to track)
❻ git_practice$ **git add .**
git_practice$ **git commit -m "Starting over."**
[main (root-commit) 14ed9db] Starting over.
2 files changed, 5 insertions(+)
create mode 100644 .gitignore
create mode 100644 hello_git.py
❼ git_practice$ **git status**
On branch main
nothing to commit, working tree clean
git_practice$
我们首先检查状态,发现工作目录是干净的 ❶。然后我们使用命令rm -rf .git/删除.git目录(在 Windows 上使用del .git) ❷。删除.git文件夹后检查状态,系统提示这不是一个 Git 仓库 ❸。Git 用来跟踪仓库的所有信息都存储在.git文件夹中,因此删除它会删除整个仓库。
接着,我们可以使用git init来初始化一个新的仓库 ❹。检查状态显示我们回到了初始阶段,等待第一次提交 ❺。我们添加文件并进行第一次提交 ❻。现在检查状态时显示我们处于新的main分支,且没有需要提交的内容 ❼。
使用版本控制需要一些练习,但一旦开始使用,你就再也不想没有它了。
附录:E
故障排除部署

部署应用程序在成功时是非常令人满意的,尤其是如果你以前从未做过。但是,在部署过程中可能会出现许多障碍,不幸的是,这些问题中的一些可能难以识别和解决。本附录将帮助你理解现代部署方法,并在出现问题时提供具体的故障排除方法。
如果本附录中的额外信息不足以帮助你顺利完成部署过程,请查看ehmatthes.github.io/pcc_3e上的在线资源;那里更新的内容几乎肯定会帮助你成功完成部署。
理解部署
当你试图故障排除特定的部署尝试时,清楚地理解一个典型的部署过程是很有帮助的。部署是指将一个在本地系统上正常工作的项目复制到远程服务器的过程,以便它可以响应来自互联网上任何用户的请求。远程环境与典型的本地系统在多个重要方面有所不同:它可能不是你正在使用的相同操作系统(OS),而且它很可能是单一物理服务器上的多个虚拟服务器之一。
当你部署一个项目,或将其推送到远程服务器时,需要执行以下步骤:
-
在数据中心的物理机器上创建一个虚拟服务器。
-
建立本地系统和远程服务器之间的连接。
-
将项目的代码复制到远程服务器。
-
确定项目的所有依赖项,并将它们安装在远程服务器上。
-
设置数据库并运行现有的迁移。
-
将静态文件(CSS、JavaScript 文件和媒体文件)复制到可以高效提供服务的位置。
-
启动一个服务器来处理传入的请求。
-
一旦项目准备好处理请求,开始将传入的请求路由到该项目。
当你考虑到部署过程中涉及的所有步骤时,部署失败并不令人意外。幸运的是,一旦你理解了部署应该发生的过程,你就更有可能找出出错的地方。如果你能识别出问题所在,或许能够找到一个解决方法,让下一次的部署尝试成功。
你可以在一种操作系统上进行本地开发,然后将其推送到运行不同操作系统的服务器上。了解你推送到的系统类型很重要,因为这可以指导你的一些故障排除工作。在本文写作时,Platform.sh 上的基础远程服务器运行 Debian Linux;大多数远程服务器都是基于 Linux 的系统。
基础故障排除
一些故障排除步骤是特定于每个操作系统的,但我们稍后会讨论这些内容。首先,让我们考虑在故障排除部署时每个人都应该尝试的步骤。
你最好的资源就是在尝试推送时生成的输出。这个输出可能看起来令人畏惧;如果你是应用部署的新手,它可能看起来非常技术化,而且通常会有很多内容。好消息是,你不需要理解输出中的所有内容。你在浏览日志输出时应该有两个目标:识别任何成功的部署步骤,并识别任何失败的步骤。如果你能做到这一点,你也许能够找出需要在项目或部署过程中更改的地方,从而使下一次推送成功。
按照屏幕上的建议操作
有时候,你推送的平台会生成一条消息,给出如何解决问题的明确建议。例如,如果你在初始化 Git 仓库之前创建了一个 Platform.sh 项目,然后尝试推送该项目,你将看到以下消息:
$ **platform push**
❶ Enter a number to choose a project:
[0] ll_project (votohz445ljyg)
> **0**
❷ [RootNotFoundException]
Project root not found. This can only be run from inside a project directory.
❸ To set the project for this Git repository, run:
platform project:set-remote [id]
我们正在尝试推送一个项目,但本地项目尚未与远程项目关联。所以,Platform.sh CLI 会询问我们想要推送到哪个远程项目❶。我们输入0,选择唯一列出的项目。但接下来,我们看到一个RootNotFoundException ❷。发生这种情况是因为 Platform.sh 在检查本地项目时会查找 .git 目录,以便弄清楚如何将本地项目与远程项目连接起来。在这种情况下,由于创建远程项目时没有 .git 目录,因此该连接从未建立。CLI 提供了一个修复建议❸;它告诉我们可以使用 project:set-remote 命令指定应与此本地项目关联的远程项目。
让我们尝试这个建议:
$ **platform project:set-remote votohz445ljyg**
Setting the remote project for this repository to: ll_project (votohz445ljyg)
The remote project for this repository is
now set to: ll_project (votohz445ljyg)
在之前的输出中,CLI 显示了该远程项目的 ID,votohz4451jyg。所以我们运行了建议的命令,使用这个 ID,CLI 成功地将本地项目与远程项目连接起来。
现在,让我们再次尝试推送项目:
$ **platform push**
Are you sure you want to push to the main (production) branch? [Y/n] **y**
Pushing HEAD to the existing environment main
`--snip--`
这是一次成功的推送;按照屏幕上的建议操作有效。
你应该小心运行你不完全理解的命令。然而,如果你有充分的理由相信某个命令的危害很小,并且你信任推荐的来源,那么尝试使用工具提供的建议可能是合理的。
阅读日志输出
如前所述,运行类似 platform push 的命令时,日志输出既可以提供信息,也可能让人感到畏惧。请阅读以下日志输出片段,这是另一次使用 platform push 的尝试,看看你能否找出问题所在:
`--snip--`
Collecting soupsieve==2.3.2.post1
Using cached soupsieve-2.3.2.post1-py3-none-any.whl (37 kB)
Collecting sqlparse==0.4.2
Using cached sqlparse-0.4.2-py3-none-any.whl (42 kB)
Installing collected packages: platformshconfig, sqlparse,...
Successfully installed Django-4.1 asgiref-3.5.2 beautifulsoup4-4.11.1...
W: ERROR: Could not find a version that satisfies the requirement gunicorrn
W: ERROR: No matching distribution found for gunicorrn
130 static files copied to '/app/static'.
Executing pre-flight checks...
`--snip--`
当部署尝试失败时,一个好的策略是查看日志输出,看看是否能发现任何类似警告或错误的信息。警告是相当常见的;它们通常是关于项目依赖项即将发生变化的消息,帮助开发人员在问题导致实际故障之前解决问题。
成功的推送可能会有警告,但不应包含任何错误。在这种情况下,Platform.sh 无法找到安装要求 gunicorrn 的方法。这是 requirements_remote.txt 文件中的一个拼写错误,原本应该包含 gunicorn(少一个 r)。在日志输出中,通常不容易直接找到根本问题,尤其是当问题导致一系列级联错误和警告时。就像在本地系统上查看回溯信息一样,最好仔细查看列出的前几个错误和最后几个错误。中间的大多数错误往往是内部包在抱怨某些东西出了问题,并将错误信息传递给其他内部包。我们通常可以解决的实际错误,通常是列出的第一个或最后一个错误。
有时候,你可以很容易发现错误,但有时候,你根本不明白输出的含义。这绝对值得一试,利用日志输出成功诊断错误是一种非常满足的感觉。当你花更多时间查看日志输出时,你会变得更擅长识别对你最有意义的信息。
操作系统特定的故障排除
你可以在任何你喜欢的操作系统上进行开发,并推送到任何你喜欢的主机上。推送项目的工具已经足够成熟,它们会根据需要修改你的项目,以确保能够在远程系统上正确运行。然而,仍然可能会出现一些特定操作系统的问题。
在 Platform.sh 的部署过程中,最可能遇到的困难之一是安装 CLI。下面是安装命令:
$ **curl -fsS https://platform.sh/cli/installer | php**
该命令以 curl 开头,curl 是一个让你在终端中通过 URL 请求远程资源的工具。这里,它用于从 Platform.sh 服务器下载 CLI 安装程序。命令中的 -fsS 部分是一个标志集,用来修改 curl 的运行方式。f 标志告诉 curl 抑制大多数错误消息,这样 CLI 安装程序可以处理这些错误,而不是把所有错误报告给你。s 标志告诉 curl 静默运行;它允许 CLI 安装程序决定在终端中显示什么信息。S 标志告诉 curl 如果整体命令失败,则显示错误消息。命令结尾的 | php 告诉系统使用 PHP 解释器运行下载的安装程序文件,因为 Platform.sh 的 CLI 是用 PHP 编写的。
这意味着你的系统需要安装 curl 和 PHP 才能安装 Platform.sh CLI。要使用该 CLI,你还需要 Git 和能够运行 Bash 命令的终端。Bash 是一种大多数服务器环境中都可以使用的语言。大多数现代系统都有足够的空间安装这些工具。
以下章节将帮助你解决适用于你操作系统的相关需求。如果你尚未安装 Git,请参考附录 D 中第 484 页的 Git 安装说明,然后查看适用于你操作系统的部分。
从 Windows 部署
近年来,Windows 在程序员中重新获得了广泛的关注。Windows 集成了许多其他操作系统的不同元素,提供了多种方式让用户进行本地开发工作并与远程系统交互。
从 Windows 部署的最大困难之一是,核心的 Windows 操作系统与 Linux 基础的远程服务器所使用的系统不同。一个基础的 Windows 系统拥有与基础 Linux 系统不同的工具和语言,因此,要从 Windows 执行部署工作,你需要选择如何将基于 Linux 的工具集集成到本地环境中。
Windows Subsystem for Linux
一种流行的方法是使用 Windows Subsystem for Linux (WSL),它是一个允许 Linux 在 Windows 上直接运行的环境。如果你已经设置好 WSL,那么在 Windows 上使用 Platform.sh CLI 就和在 Linux 上一样简单。CLI 并不会知道它是在 Windows 上运行,它只会看到你正在使用的 Linux 环境。
设置 WSL 是一个两步过程:首先安装 WSL,然后选择一个 Linux 发行版并将其安装到 WSL 环境中。设置 WSL 环境的过程较为复杂,无法在这里详细描述;如果你对这种方法感兴趣并且尚未设置,可以查看 docs.microsoft.com/en-us/windows/wsl/about 上的文档。一旦你设置好了 WSL,就可以按照本附录中 Linux 部分的说明继续部署工作。
Git Bash
另一种构建本地环境的方法是使用 Git Bash,它是一个与 Bash 兼容的终端环境,但可以在 Windows 上运行。当你从 git-scm.com 使用安装程序时,Git Bash 会和 Git 一起安装。这个方法是可行的,但没有 WSL 那么简化。在这种方法中,你需要在一些步骤中使用 Windows 终端,而在其他步骤中则使用 Git Bash 终端。
首先,你需要安装 PHP。你可以通过 XAMPP 来完成这一点,它是一个将 PHP 和其他一些开发者工具捆绑在一起的套件。访问 apachefriends.org,点击按钮下载适用于 Windows 的 XAMPP。打开安装程序并运行它;如果看到有关用户帐户控制(UAC)限制的警告,点击 OK。接受所有安装程序的默认设置。
当安装程序运行完成后,你需要将 PHP 添加到系统的路径中;这将告诉 Windows 在你想运行 PHP 时该去哪里查找。在开始菜单中输入path,然后点击编辑系统环境变量;点击标记为环境变量的按钮。你应该看到变量Path被高亮显示;在此面板下点击编辑。点击新建,将一个新的路径添加到当前的路径列表中。假设你在运行 XAMPP 安装程序时保持了默认设置,在出现的框中添加C:\xampp\php,然后点击确定。完成后,关闭所有仍然打开的系统对话框。
在处理好这些需求之后,你可以安装 Platform.sh CLI。你需要使用具有管理员权限的 Windows 终端;在开始菜单中输入command,然后在命令提示符应用下,点击以管理员身份运行。在弹出的终端中,输入以下命令:
> **curl -fsS https://platform.sh/cli/installer | php**
这将安装 Platform.sh CLI,正如前面所描述的那样。
最后,你将在 Git Bash 中进行操作。要打开 Git Bash 终端,进入开始菜单并搜索git bash。点击出现的Git Bash 应用程序,你应该会看到一个终端窗口打开。在这个终端中,你可以使用传统的基于 Linux 的命令,如ls,以及基于 Windows 的命令,如dir。为了确保安装成功,执行platform list命令。你应该看到 Platform.sh CLI 中所有命令的列表。从此以后,所有的部署工作都将在 Git Bash 终端窗口内使用 Platform.sh CLI 完成。
从 macOS 部署
macOS 操作系统并不是基于 Linux,但它们都遵循相似的原则开发。实际上,这意味着你在 macOS 上使用的许多命令和工作流程也可以在远程服务器环境中使用。你可能需要安装一些面向开发者的资源,才能在本地的 macOS 环境中使用这些工具。如果在工作中任何时候出现提示要求安装命令行开发工具,点击安装以批准安装。
安装 Platform.sh CLI 时最可能遇到的困难是确保 PHP 已安装。如果你看到提示php命令未找到,你需要安装 PHP。安装 PHP 的一种最简单方法是使用Homebrew包管理器,它简化了程序员所依赖的各种软件包的安装。如果你还没有安装 Homebrew,可以访问brew.sh并按照指示安装。
一旦 Homebrew 安装完成,使用以下命令来安装 PHP:
$ **brew install php**
这可能需要一段时间才能完成,但完成后,你应该能够成功安装 Platform.sh CLI。
从 Linux 部署
因为大多数服务器环境都是基于 Linux 的,所以你应该很容易安装和使用 Platform.sh CLI。如果你尝试在新安装的 Ubuntu 系统上安装 CLI,它会告诉你需要哪些软件包:
$ **curl -fsS https://platform.sh/cli/installer | php**
Command 'curl' not found, but can be installed with:
sudo apt install curl
Command 'php' not found, but can be installed with:
sudo apt install php-cli
实际输出会提供更多关于其他一些可用软件包的信息,并包含一些版本信息。以下命令将安装 curl 和 PHP:
$ **sudo apt install curl php-cli**
运行此命令后,Platform.sh CLI 安装命令应该会成功运行。由于你的本地环境与大多数基于 Linux 的托管环境非常相似,因此你在终端中学习的许多内容也将适用于远程环境。
其他部署方法
如果 Platform.sh 不适合你,或者你想尝试不同的方法,市面上有许多托管平台可以选择。有些平台的工作方式与第二十章中描述的过程类似,而有些则采用与本附录开头描述的步骤完全不同的方法:
-
Platform.sh 允许你使用浏览器来执行我们使用 CLI 进行的步骤。如果你更喜欢基于浏览器的界面,而不是终端工作流,可能会更喜欢这种方法。
-
还有许多其他托管服务提供商,提供 CLI 和基于浏览器的方法。有些提供商在其浏览器内提供终端,因此你无需在系统上安装任何东西。
-
一些提供商允许你将项目推送到像 GitHub 这样的远程代码托管站点,然后将你的 GitHub 仓库连接到托管站点。主机会从 GitHub 拉取代码,而不是要求你将代码从本地系统直接推送到主机。Platform.sh 也支持这种工作流程。
-
一些提供商提供一系列服务供你选择,以便组建适合你项目的基础设施。这通常需要你对部署过程有更深入的了解,以及远程服务器为服务项目所需的内容。这些主机包括 Amazon Web Services(AWS)和微软的 Azure 平台。在这些平台上,追踪费用可能更困难,因为每项服务都可能独立产生费用。
-
很多人将他们的项目托管在虚拟私人服务器(VPS)上。在这种方法中,你租用一个虚拟服务器,它就像一台远程计算机,你可以登录到服务器,安装运行项目所需的软件,复制代码,设置正确的连接,并允许服务器开始接收请求。
新的托管平台和方法定期出现;找到一个对你有吸引力的,并投入时间学习该提供商的部署流程。保持你的项目足够长的时间,直到你了解什么方法适合你的提供商,什么方法不适合。没有任何托管平台是完美的;你需要持续评估你目前使用的提供商是否足够适合你的使用场景。
我将对选择部署平台和整体部署方法给出最后的警告。有些人会热衷于引导你选择过于复杂的部署方法和服务,这些方法和服务旨在让你的项目具有高度可靠性,并能够同时为数百万用户提供服务。许多程序员花费大量时间、金钱和精力来构建复杂的部署策略,结果却发现几乎没有人使用他们的项目。大多数 Django 项目可以在一个小型托管计划上设置,并调整到每分钟处理成千上万的请求。如果你的项目的流量低于这个水平,在投资那些适用于全球最大网站的基础设施之前,先花时间配置一个能在最小平台上顺利运行的部署。
部署有时极具挑战性,但当你的实时项目运作顺利时,也同样令人满足。享受这个挑战,遇到困难时,及时寻求帮助。


浙公网安备 33010602011771号