Python-操作指南-全-
Python 操作指南(全)
原文:Python How-To
译者:飞龙
前言
前言
我们可能是人类历史上最幸运的一代。我们不再处于新石器时代或工业时代;我们已经进入了信息时代。先进的信息技术,尤其是计算机和网络,已经改变了人类生活。我们可以在不到半天的时间内从家乡飞往数千英里外的另一个地方。如果我们愿意,我们可以用智能手机预约医生,并通过视频通话参加预约。我们可以从在线商店订购几乎任何东西,几天或甚至几小时内就能收到。
过去几十年中,这些变革伴随着大量数据的积累。处理和分析这些数据的工作促进了新跨学科领域——数据科学的产生。作为一名行为科学家,我花了很多时间处理数据,所以你可以说我正在将数据科学应用于行为研究。然而,处理如此大量数据需要的不只是纸和笔。相反,我一直在用一种出色的编程语言:Python 编写代码来清理数据并运行统计模型。
作为一名自学成才的程序员,我知道掌握 Python 或其他任何编程语言并不容易——不是因为学习所有技术(以及知道何时使用它们)需要很长时间,而是因为可用的学习资源太多,例如在线课程、教程视频、博客文章,当然还有书籍。你如何选择最适合你的资源呢?
当我开始学习 Python 时,我也有同样的疑问。多年来,我尝试了各种资源,我发现最好的学习资源是书籍,因为书籍有结构化的内容,可以让你深入语言。在学习过程中,你可以根据自己的节奏来学习。无论何时需要,你都可以放慢速度来消化难点。此外,如果出现任何问题,你可以快速查阅书架上的书籍。
市面上大多数 Python 书籍都是为初学者编写的(详细介绍了语言的基本功能)或高级用户(涵盖了更少通用化的专业技术)。毫无疑问,其中一些书籍非常出色。然而,从学习曲线的角度来看,我觉得缺少了一本书:一本针对 Python 学习者晚期初学者和早期中级水平的书。这些阶段至关重要,因为学习者正在形成正确的编码习惯,并找出特定上下文适当的 Pythonic 技术。从内容角度来看,我认为缺少的书应该解决大多数读者都能与他们工作相关联的通用编程问题,无论他们用 Python 做什么:网页开发或数据科学。换句话说,更多的读者可以从这样的书中受益,因为它将提供通用的、领域无关的知识。
我写这本书是为了填补初学者和高级书籍之间的差距。我希望你在阅读后能感觉到你学到了一些东西。
致谢
适合阅读这本书的人
我还想感谢 Manning 团队:出版人 Marjan Bace,他领导了优秀的编辑和生产团队;副出版人 Michael Stephens,他邀请我写这本书;资深开发编辑 Marina Michaels,她负责协调和编辑;René van den Berg,他负责技术编辑;Walter Alexander 和 Ignacio Torres,他们提供了代码审查;Aleksandar Dragosavljević,他负责组织同行评审;以及生产团队,他们为格式化这本书付出了辛勤努力。
最后,感谢那些提供了宝贵反馈的审稿人:Alexei Znamensky,Alexey Vyskubov,Ariel Andres,Brent Boylan,Chris Kolosiwsky,Christopher Kardell,Christopher Villanueva,Claudiu Schiller,Clifford Thurber,Dirk Gomez,Ganesh Swaminathan,Georgios Doumas,Gerald Mack,Gregory Grimes,Igor Dudchenko,Iyabo Sindiku,James Matlock,Jeffrey M. Smith,Josh McAdams,Keerthi Shetty,Larry Cai,Louis Aloia,Marcus Geselle,Mary Anne Thygesen,Mike Baran,Ninoslav Cerkez,Oliver Korten,Piergiorgio Faraglia,Radhakrishna M.V.,Rajinder Yadav,Raymond Cheung,Robert Wenner,Shankar Swamy,Sriram Macharla,Giri S. Swaminathan,Steven Herrera,以及 Vitosh K. Doynov。他们的建议帮助使这本书变得更好。
关于这本书
在这本书中,我从专业无关的角度专注于教授 Python 的必要技术。尽管有各种各样的 Python 包适用于不同的专业,如数据科学和 Web 开发,但这些包都是建立在 Python 的核心特性之上的。无论你使用什么特定领域的 Python 包来完成工作,你都必须对基本技术有很好的理解,例如选择合适的数据模型和编写结构良好的函数和类。这些技术使你能够舒适地使用特定领域的包。
我想感谢我的导师,德克萨斯大学 MD 安德森癌症中心的 Paul Cinciripini 博士和 Jason Robinson 博士,他们在追求将 Python 作为我们分析工作的语言的过程中支持了我。这项努力最终导致了这本书的诞生。
如果你已经自学了一段时间并使用 Python,但感觉你的 Python 知识结构不完整,我认为你是一个晚期初学者或早期中级用户。这本书非常适合你,因为你需要以结构化的方式巩固和综合你的 Python 知识。在这本书中,我确定了每一章的几个主题,以解决你可能在工作中遇到的一些常见问题。我对这些主题的覆盖不仅教你如何解决特定问题,而且将内容置于更广泛的背景下,展示当你在一个项目中工作时,为什么以及如何关注这个主题。这样,你不是学习个别技术来完成单独的任务;你是在完成一个项目的同时学习这些技术。
本书组织结构:路线图
这本书由六个部分组成,如下面的图所示。在第一部分(第 2-5 章),你学习内置数据模型,例如字符串、列表和字典。这些数据模型是任何项目的基石。在第二部分(第 6 和 7 章),你学习定义函数的最佳实践。函数对于任何项目都是至关重要的,因为它们负责操作数据以生成所需的输出。在第三部分(第 8 和 9 章),你学习定义自定义类的技术。我们不是使用内置类,而是定义自定义类来更好地模拟我们项目中的数据。在第四部分(第 10 和 11 章),你学习使用对象和操作计算机上的文件的基础知识。在第五部分(第 12 和 13 章),你学习各种保护程序的技术,包括日志记录、异常处理和测试。在第六部分(第十四章),你综合所学知识来制作一个网络应用程序——这是一个作为其他所有章节教学框架的项目。

为了跟上教学进度,我建议你在学习这本书时使用计算机,这将帮助你更快地熟悉 Python 语法和技术。我已经在 GitHub 上上传了所有代码,你可以在github.com/ycui1/python_how_to找到我的公共仓库。然而,在这本书中,每当我向你展示一些代码时,我都会提供必要的解释和输出,所以即使你在阅读这本书时没有计算机也完全可以。
如果你确实打算使用计算机,你的计算机操作系统无关紧要。Windows、macOS 和 Linux 都可以,因为 Python 是一种跨平台编程语言。(有关 Python 安装说明,请参阅附录 A 在线内容。)因为我专注于最近 Python 版本中已经稳定的必要技术,所以你的计算机运行的是 Python 3.8 或更早版本并不重要,但为了最大限度地利用本书,我建议你安装 Python 3.10 或更高版本。
关于附录
本书在线版本包含五个附录。附录 A,使用 IDLE 中的 REPL 学习 Python,展示了如何交互式地编写 Python 代码。附录 B,使用 pip 管理 Python 包,展示了如何管理 Python 包。附录 C,使用 Jupyter Notebook:一个基于网页的交互式 Python 编辑器,展示了如何使用 Jupyter Notebook。附录 D,将版本控制集成到你的项目中,展示了版本控制在你代码库中的重要性。附录 E,准备你的包以供公开分发,展示了如何发布你的包。
关于代码
本书包含许多源代码示例,无论是编号列表还是与普通文本内联。在这两种情况下,源代码都使用固定宽度字体格式化,如这样,以将其与普通文本区分开来。有时,代码也会加粗以突出章节中先前步骤的变化,例如当新功能添加到现有代码行时。
在许多情况下,原始源代码已被重新格式化;我已添加换行和重新整理缩进来适应书籍中的可用页面空间。在极少数情况下,列表中包括行续行标记(➥)。此外,当代码在文本中描述时,源代码中的注释已从列表中删除。代码注释伴随许多列表,突出显示重要概念。
你可以从本书的 liveBook(在线)版本中获取可执行的代码片段,网址为 livebook.manning.com/book/python-how-to。书中示例的完整代码可以从 Manning 网站 www.manning.com/books/python-how-to 和 GitHub github.com/ycui1/python_how_to 下载。
liveBook 讨论论坛
购买 Python How-To 包括免费访问 liveBook,Manning 的在线阅读平台。使用 liveBook 的独家讨论功能,你可以对整本书或特定章节或段落附加评论。为自己做笔记、提问和回答技术问题,以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问 livebook.manning.com/book/python-how-to/discussion。你可以在 livebook.manning.com/discussion 了解更多关于 Manning 论坛和行为准则的信息。
Manning 对我们读者的承诺是提供一个场所,在那里个人读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向作者提出一些挑战性的问题,以免他们的兴趣转移!只要这本书有售,论坛和先前讨论的存档将可在出版社的网站上访问。
其他在线资源
您可以在docs.python.org/3找到官方文档,包括教程和参考。作者 Yong Cui 博士经常在 Medium(medium.com/@yongcui01)上撰写关于 Python 和相关数据科学主题的博客。
关于作者
Yong Cui 博士是一位在生物医学领域工作了超过 15 年的科学家。他的研究专注于开发使用 Swift 和 Kotlin 的移动健康应用程序,用于行为干预。作为他最喜欢的语言,Python 是他进行数据分析、机器学习和研究工具开发的首选语言。在业余时间,他喜欢撰写关于各种技术主题的博客文章,包括移动开发、Python 编程和人工智能。
关于封面插图
《Python How-To》封面上的插图标题为“Paysanne des environs de Soleure”,或“Solothurn 地区的农妇”,取自 Jacques Grasset de Saint-Sauveur 的作品集,该作品集于 1788 年出版。每一幅插图都绘制得非常精细,并手工着色。
在那些日子里,仅凭人们的服饰就能轻易识别他们居住的地方以及他们的职业或社会地位。Manning 通过基于几个世纪前丰富多样的地域文化的书封面来庆祝计算机行业的创新精神和主动性,这些文化通过如这一系列图片的图片被重新带回生活。
1 制定实用学习策略
本章涵盖内容
-
实用主义意味着什么
-
Python 能做什么
-
当您应该考虑使用其他语言时
-
您可以从这本书中学到什么
Python 是一种令人惊叹的编程语言。它的开源、通用、平台无关的特性使它拥有庞大的开发者社区,以及一个令人难以置信的生态系统,包括成千上万的免费库,用于 Web 开发、机器学习(ML)、数据科学和其他许多领域。我希望我们共享这个信念:知道如何用 Python 编码是很好的,但知道如何编写真正高效、安全和可维护的应用程序会给您带来巨大的优势。本书将帮助您从 Python 初学者成长为自信的程序员。
在 Python 生态系统中,我们使用特定领域的 Python 工具,如 Web 框架和机器学习库,来完成我们在工作中的各种任务。这些工具的有效使用并不简单,因为它需要相当熟悉基本的 Python 技能,例如处理文本、处理结构化数据、创建控制流和处理文件。Python 程序员可以编写不同的解决方案来解决相同的任务。在这些解决方案中,通常有一个比其他方案更好,因为它可能更简洁、更易读或更高效,我们将其统称为 Pythonic:这是一种所有 Python 程序员都努力获得的惯用编码风格。本书是关于如何编写 Pythonic 代码来解决编程任务的。
Python 发展得如此之好,特性如此之多,以至于试图从这本书中学到关于它的所有知识是不可能的,也是不明智的。相反,我将采取实用主义的方法来定义我在本书中将要教授的内容:您在项目中最可能使用的核心技能。同样重要的是,我会经常提到如何考虑可读性和可维护性来使用这些技能,这样您就可以形成良好的编码习惯,我相信您和您的团队成员会非常感激。
注意:您将在本书中看到类似这样的提示。其中许多都是关于可读性和可维护性的技巧。不要错过它们!
1.1 以成为实用程序员为目标
我们编写代码是为了达到目的,例如构建网站、训练机器学习模型或分析数据。无论我们的目的是什么,我们都希望是实用的;我们编写代码是为了解决实际问题。因此,在我们从零开始学习编码或在我们职业生涯中途提升编码技能之前,我们应该清楚我们的意图。但即使在这个阶段您不确定您想用 Python 实现什么,好消息是 Python 的核心特性是通用的知识。在您掌握了核心特性之后,您可以将它们应用到任何特定领域的 Python 工具中。
旨在成为一个实用主义程序员意味着你应该专注于最有用的技术。掌握这些技能只是你旅程的第一个里程碑;然而,编码的长期游戏是编写既有效又可维护的代码。
1.1.1 专注于编写可读的 Python 代码
作为一名开发者,我对可读性着迷。编写代码就像说一门现实世界的语言。当我们说一门语言时,难道我们不想别人理解我们吗?如果你的答案是肯定的,你可能会同意我的观点,我们希望别人也能理解我们的代码。我们的代码的读者是否具备理解我们代码所必需的技术专长,这不在我们的控制范围内。我们能控制的是我们如何编写代码——我们如何使代码可读。考虑一些简单的问题:
-
你的变量命名是否恰当,以表明它们的含义? 如果代码中充满了名为 var0、temp_var 或 x 的变量,没有人会欣赏你的代码。
-
你的函数是否有适当的签名来指示它们的功能? 如果看到名为 do_data(data)或 run_step1()的函数,人们会感到困惑。
-
你是否在文件之间一致地组织代码? 人们期望同一类型的不同文件使用相似的布局。例如,你会不会在文件顶部放置导入语句?
-
你的项目文件夹是否按照特定的文件结构存储在期望的文件夹中? 当你的项目范围扩大时,你应该为相关文件创建单独的文件夹。
这些示例问题与可读性相关。我们不仅仅偶尔提出这些问题;相反,我们在整个项目中不断提出这类可读性问题。原因很简单:良好的实践造就完美。作为一名受过神经科学训练的人,我确切地知道大脑在行为学习方面的运作方式。通过通过这些自我检查问题来练习可读性,我们正在训练我们大脑的神经回路。从长远来看,你的大脑将训练出知道哪些行为构成编码中的良好实践,你将无需思考就能写出可读且可维护的代码。
1.1.2 在编写任何代码之前就考虑可维护性
在罕见的情况下,我们编写一次性的代码。当我们编写脚本时,我们几乎总是成功地让自己相信我们永远不会再次使用这个脚本;因此,我们不在乎创建好的变量名,合理地布局代码,或者重构函数和数据模型,更不用说确保我们不留任何注释(或过时的注释)。但是,有多少次我们发现自己下周或甚至第二天不得不再次使用相同的脚本?这很可能发生在我们大多数人身上。
前一段描述了一个小规模的可维护性问题。在这种情况下,它只影响你在短时间内的工作效率。然而,如果你在一个团队环境中工作,个别贡献者引入的问题会累积成大规模的可维护性问题。团队成员未能遵循相同的变量、函数和文件命名规则。无数被注释掉的代码事件仍然存在。过时的注释无处不在。
为了解决自己在项目后期可能出现的可维护性问题,你在学习编码时应该培养良好的心态。以下是一些你可能需要考虑的问题,以帮助你长期培养良好的“可维护性”心态:
-
你的代码是否没有过时的注释和被注释掉的代码? 如果答案是“否”,请更新或删除它们!这些情况甚至比没有任何注释的情况更糟糕,因为它们可能提供相互矛盾的信息。
-
代码中是否存在大量的重复? 如果答案是“是”,那么重构可能是必要的。在编码中的一个经验法则是DRY(不要重复自己)。通过删除重复的部分,你将处理一个单一的共享部分,它比重复部分的更改更不容易出错。
-
你是否使用版本控制工具,如 Git? 如果答案是“否”,请查看你的集成开发环境(IDE)的扩展或插件。对于 Python,常见的 IDE 包括 PyCharm 和 Visual Studio Code。许多 IDE 已经集成了版本控制工具,这使得版本管理变得容易得多。
成为一名务实的 Python 程序员需要这种可维护性训练。毕竟,几乎所有 Python 工具都是开源的,并且正在快速发展。因此,可维护性应该是任何可行项目的基石。在本书中,只要适用,我们将探讨如何在日常 Python 编码中实施可维护性实践。请记住,可读性是持续可维护性的关键。当你专注于编写可读的代码时,你的代码库的可维护性会相应地提高。
1.2 Python 擅长做什么或与其他语言一样擅长
Python 的日益流行归功于语言本身的特性。尽管这些特性中没有一个是 Python 独有的,但当它们有机地结合在一起时,Python 注定会成长为一个广泛使用的语言。以下列表总结了 Python 的关键特性:
-
跨平台——Python 在常见的平台上运行,如 Windows、Linux 和 MacOS。因此,Python 代码是可移植的。你自己在平台上编写的任何代码都可以在其他计算机上运行,不受平台差异的限制。
-
表达性和可读性——Python 的语法比许多其他语言的语法更简单。这种表达性和可读性的编码风格被 Python 程序员广泛采用。你会发现,写得好的 Python 代码读起来很愉快,就像好的散文一样。
-
快速原型设计—由于其简单的语法,Python 代码通常比其他语言编写的代码更简洁。因此,在 Python 中比在其他语言中产生一个功能原型所需的工作更少。
-
独立使用—当你将 Python 安装到你的计算机上时,它会在“开箱即用”后立即准备好使用。基本的 Python 安装包包含了你进行任何常规编码工作所需的所有基本库。
-
开源、免费且可扩展—尽管 Python 可以独立运行,但你也可以编写和使用自己的包。如果你需要其他已发布的包,你可以通过一行命令安装它们,无需担心许可证或订阅费用。
这些关键特性吸引了众多程序员,形成了一个庞大的开发者社区。Python 的开源特性允许感兴趣的用户为这门语言及其生态系统做出贡献。表 1.1 总结了某些显著的领域及其相应的 Python 工具。这个列表并不全面,鼓励你探索自己感兴趣的专业领域的 Python 工具。
表 1.1 领域特定 Python 工具概述
| 领域 | 工具 | 突出特点 |
|---|---|---|
| Web 开发 | Flask | 一个微型的 Web 框架;适合构建轻量级 Web 应用;对第三方功能具有灵活的可扩展性 |
| Web 开发 | Django | 一个完整的 Web 框架;适合构建数据库驱动的 Web 应用;作为企业解决方案具有高度可扩展性 |
| FastAPI | 一个用于构建应用程序编程接口(API)的 Web 框架;数据验证和数据转换;自动生成 API Web 界面 | |
| Streamlit | 一个用于轻松构建数据相关应用的 Web 框架;在数据科学家和机器学习工程师中很受欢迎 | |
| 数据科学 | NumPy | 专门用于处理大型、多维数组;高计算效率;是许多其他库的组成部分 |
| pandas | 一个用于处理类似电子表格的二维数据的通用包;全面的数据操作 | |
| statsmodels | 一个流行的统计包,包括线性回归、相关性、贝叶斯建模和生存分析等 | |
| Matplotlib | 一个面向对象的范式,用于绘制直方图、散点图、饼图和其他常见图形,具有各种可定制的设置 | |
| Seaborn | 一个易于使用的可视化库,用于绘制吸引人的图形;基于 Matplotlib 的高级 API | |
| 机器学习 | Scikit-learn | 建立机器学习模型的各种预处理工具;常见机器学习算法的实现 |
| TensorFlow | 一个具有高级和低级 API 的框架;Tensorboard 可视化工具;适合构建复杂的神经网络 | |
| Keras | 构建神经网络的高级 API;易于使用;适合构建低性能模型 | |
| PyTorch | 用于构建神经网络框架;比 TensorFlow 更直观的代码风格;适合构建复杂的神经网络 | |
| FastAI | 在 PyTorch 之上构建神经网络的高级 API;易于使用 |
框架、库、包和模块
当我们讨论工具时,我们使用几个密切相关的术语,包括 框架、库、包 和 模块。不同的语言可能使用其中一些术语,并且含义略有不同。在这里,我讨论了大多数 Python 程序员接受的这些术语的含义。
框架 范围最广。框架提供了一套完整的、旨在以高级别执行特定任务的功能,例如 Web 开发。
图书馆 是框架的构建块,由包组成。图书馆提供功能,用户无需担心底层包。
包 提供特定的功能。更具体地说,包将模块捆绑在一起,每个模块由一个文件中的紧密相关的数据结构和函数集组成,例如 .py 文件。
1.3 Python 无法做到或做得不好的事情
任何事物都有其极限,Python 也是如此。有许多事情 Python 做不到,或者至少与替代工具相比做得不好。尽管有些人试图将 Python 推向其他用途,但在当前阶段,我们应该了解它在两个重要领域的限制:
-
移动应用程序——在这个移动时代,我们都有智能手机,并在生活的几乎每个方面使用应用程序,如银行、在线购物、健康、通信,当然还有游戏。不幸的是,尽管有 Kivy 和 BeeWare 这样的尝试,但仍然没有伟大的 Python 框架用于开发智能手机应用程序。如果你从事移动开发,你应该考虑成熟的替代方案,如 Swift 用于 iOS 应用程序和 Kotlin 用于 Android 应用程序。作为一名实用主义者程序员,你应该选择一种能够带来最佳用户体验的语言。
-
低级开发——当涉及到开发直接与硬件交互的软件时,Python 不是最佳选择。由于 Python 的解释性质,整体执行速度不足以开发低级软件,如需要即时响应的设备驱动程序。如果你对开发低级软件感兴趣,你应该考虑更好的硬件接口的替代语言。例如,C 和 C++ 是开发设备驱动程序的好选择。
1.4 本书你将学到什么
我们已经简要讨论了成为一名实用主义程序员的意义。现在让我们谈谈你如何达到这个目标。当你编写程序时,你不可避免地会遇到新的编程挑战。在这本书中,我们确定了你在最可能遇到的任务中需要的编程技术。
1.4.1 专注于领域无关的知识
所有事物都以某种方式直接或间接地相互联系,Python 知识也是如此。为了将这次讨论置于一个上下文中,请考虑图 1.1。我们可以将 Python 特性和其应用视为三个相关的实体。

图 1.1 领域无关和领域特定 Python 知识之间的关系。领域无关的知识包括基础和高级 Python 特性,它们密切相关。它们共同构成了不同内容领域中特定领域知识的基石。
对于我们大多数人来说,学习 Python 的目标是将其应用于解决我们工作领域中的问题,这需要领域特定的 Python 知识,例如 Web 开发和数据分析。作为完成您工作的先决条件,您的知识库应包括基本的 Python 特性——更具体地说,领域无关的 Python 知识。即使您的职位角色发生变化或发展,您也可以将基本的 Python 知识应用于您的新职位。
在这本书中,你将专注于获取与领域无关的 Python 知识。为了便于学习过程,我们可以将领域无关的 Python 知识操作性地定义为两个构建组件:基础和高级。
对于基础知识,我们应该了解常见的数据结构和它们的操作。我们还需要知道如何评估条件以构建 if...else... 语句。当我们执行重复性工作的时候,我们可以利用 for 和 while 循环。为了重用代码块,我们可以将它们重构为函数和类。掌握这些基础知识就足以编写有用的 Python 代码以执行您的工作任务。如果您掌握了大部分基础知识,您就准备好学习高级技能了。
高级技能使您能够编写更高效、更利用 Python 多样化特性的代码。让我们通过一个简单的例子来感受 Python 的多功能性。当我们使用 for 循环迭代列表对象时,我们通常需要显示每个项目旁边的位置,例如
prime_numbers = [2, 3, 5]
# desired output:
Prime Number #1: 2
Prime Number #2: 3
Prime Number #3: 5
如果我们只使用基本功能,我们可能会得到以下解决方案。在解决方案中,我们创建了一个范围对象,允许检索基于 0 的索引以产生位置信息。对于输出,我们使用字符串连接:
for num_i in range(len(prime_numbers)):
num_pos = num_i + 1
num = prime_numbers[num_i]
print("Prime Number #" + str(num_pos) + ": " + str(num))
然而,在阅读这本书之后,您将成为一个更有经验的 Python 用户,并且应该能够产生以下更干净、更 Pythonic 的解决方案:
for num_pos, num in enumerate(prime_numbers, start=1):
print(f"Prime Number #{num_pos}: {num}")
上述解决方案涉及三种技术:元组解包以获取 num_pos 和 num(第 4.4 节)、创建 enumerate 对象(第 5.3 节)以及使用 f-strings 格式化输出(第 2.1 节)。我不会在这里扩展这些技术的讨论,因为它们将在各自的章节中介绍。尽管如此,这个例子只是简单地展示了这本书的主题——如何使用各种技术来产生 Pythonic 解决方案。
除了这些技术,你还将学习和应用高级函数概念,例如装饰器和闭包。当你定义类时,你会知道如何使它们协同工作以最小化代码并减少潜在的错误。当你的程序完成时,你会知道如何记录和测试你的代码,使其准备好投入生产。
这本书完全是关于综合领域无关的 Python 知识。你不仅将学习实用的先进特性,而且在适用的情况下,还将学习基本的 Python 特性和基本的计算机编程概念。这里的关键术语是综合,如 1.4.2 节所述。
1.4.2 通过综合解决问题
初学者常遇到的一个常见困境是,他们似乎知道各种技术,但不知道如何以及何时使用它们来解决问题。对于本书中讨论的每个技术,我们将向你展示它是如何独立工作的,我们还将向你展示它是如何与其他技术相匹配的。我们希望你能开始看到所有不同的部分是如何组合成无限多种新程序的。
作为学习和综合各种技术的基本注意事项,你应该预期学习编码不是一条线性的路径。毕竟,Python 的技术特性是紧密相关的。虽然你将专注于学习中级和高级 Python 技术,但它们不能完全与基础知识分离。相反,你会发现我经常对基本技术发表评论,或者故意重复我已经覆盖过的技术。
1.4.3 在特定环境中学习技能
如我们之前提到的,这本书专注于学习建立在领域无关 Python 知识之上的技能。领域无关意味着你可以将本书中涵盖的技能应用到任何你想使用 Python 的领域。然而,没有例子几乎不可能学到任何东西。我们将通过使用一个持续的项目来展示本书中的大多数技术,以提供一个一致的环境,在这个环境中我们可以讨论特定的技能。如果你熟悉某个特定技能,你可以跳到该部分的讨论部分,在那里我将讨论所涵盖技能的一些关键方面。
提前提醒一下,通用项目是一个任务管理型网络应用。在这个应用中,你可以管理任务,包括添加、编辑和删除任务——所有这些都将使用纯 Python 实现,例如数据模型、函数、类以及你可以想到的任何其他应用可能拥有的东西。继续前进,重要的是要注意,本书的目标不是得到一个完美、闪亮的应用程序。相反,你希望通过创建这个网络应用的过程学习所有必要的 Python 技术,以便将你的领域无关知识应用到自己的工作中。
摘要
-
构建一个务实的学习策略对你来说至关重要。通过专注于学习 Python 的领域无关特性,你将为任何与 Python 相关的职位做好准备。
-
Python 是一种通用、开源的编程语言,它培养了一个庞大的开发者社区,这些开发者创建并共享 Python 包。
-
Python 在许多领域都具有竞争力,包括 Web 开发、数据科学和机器学习。每个领域都有特定的 Python 框架和包可供使用。
-
Python 有其局限性。如果你考虑开发移动应用或低级设备驱动程序,你应该使用 Swift、Kotlin、Java、C、C++、Rust 或任何其他适用的语言。
-
我在领域无关的 Python 知识和领域相关的 Python 知识之间做出了区分。这本书专注于教授领域无关的 Python 知识。
-
学习编程并非一条直线。尽管在这本书中你会学到高级特性,但我经常会提到基础内容。此外,你还会遇到一些难题,这将形成一个向上的螺旋式学习路径。
-
学习 Python 或任何编程语言的基本方法是将单个技术技能综合起来形成一个全面的技能集。通过综合过程,你将以务实的方式学习语言,了解哪些方法适用于你正在解决的问题。
第一部分 使用内置数据模型
我们构建应用程序来解决我们日常生活中的问题。人们建立在线购物网站,这样我们就可以在线订购衣服和书籍。他们建立人力资源软件,这样公司就可以管理员工。他们还建立文本处理软件,这样我们就可以编辑文档。从应用程序开发的角度来看,无论我们的应用程序解决什么问题,我们都必须提取和处理有关问题的信息。在编程中,为了在我们的应用程序中模拟各种类型的信息,例如产品描述和员工,我们必须使用适当的数据结构。这些数据结构为我们提供了在应用程序中以标准化的方式表示现实生活实体的方法,使得能够实现特定的规则、组织和实现来满足我们的业务需求。在本部分中,我们主要关注使用内置数据模型,包括字符串、列表、元组、字典和集合。此外,你还将学习到各种类型的数据结构共有的技术,例如类似序列的数据和可迭代对象。
2 处理和格式化字符串
本章涵盖了
-
使用 f 字符串进行表达式插值和格式化
-
将字符串转换为其他适用的数据类型
-
连接和拆分字符串
-
使用正则表达式进行高级字符串处理
文本信息几乎是每个应用程序最重要的数据形式。文本数据以及数值数据都可以保存为文本文件,读取它们需要我们处理字符串。例如,在购物网站上,我们使用文本来提供产品描述。机器学习正在兴起,你可能听说过一个机器学习专业:自然语言处理,它从文本中提取信息。由于字符串的通用使用,在这些场景中准备数据时,文本处理是不可避免的步骤。以我们的任务管理应用程序为背景,我们需要将任务的属性转换为文本数据,以便我们可以在我们的 Web 应用程序的前端展示它们。当我们从应用程序的前端获取数据输入时,我们必须将这些字符串转换为适当的类型,例如整数,以便进一步处理。在许多现实生活中的案例中,我们需要正确处理和格式化字符串。在本章中,我们解决了一些常见的文本处理问题。
2.1 我该如何使用 f 字符串进行字符串插值和格式化?
在 Python 中,你可以以多种方式格式化文本字符串。一种新兴的方法是使用 f 字符串,它允许你在字符串字面量中嵌入表达式。虽然你可以使用其他字符串格式化方法,但 f 字符串提供了一个更易读的解决方案;因此,当你准备字符串作为输出时,你应该使用 f 字符串作为首选方法。
事实小贴士:F 字符串是在 Python 3.6 中引入的。f 和 F(表示格式化)都可以作为 f 字符串的前缀。一个字符串字面量是一系列被单引号或双引号包围的字符。
当你使用字符串作为输出时,你经常需要处理非字符串数据,例如整数和浮点数。假设我们的任务管理应用程序有从现有变量创建字符串输出的需求:
# existing variables
name = "Homework"
urgency = 5
# desired output:
Name: Homework; Urgency Level: 5
在本节中,你将学习如何使用 f 字符串来插值非字符串数据并以所需的格式呈现字符串。正如你将发现的,f 字符串是格式化从现有字符串和其他类型变量中获取的字符串的更易读的解决方案。
2.1.1 在 f 字符串之前格式化字符串
str 类通过其实例处理文本数据,我们将其称为字符串变量。除了字符串变量之外,文本信息通常涉及整数和浮点数等数据类型。理论上,我们可以将非字符串数据转换为字符串并将它们连接起来以创建所需的文本输出,如以下列表所示。
列表 2.1 使用字符串连接创建字符串输出
task = "Name: " + name + "; Urgency Level: " + str(urgency)
print(task)
# output: Name: Homework; Urgency Level: 5
在创建任务变量的代码中存在两个潜在问题。首先,它看起来很繁琐,读起来不流畅,因为我们正在处理多个字符串,每个字符串都被引号包围。其次,我们必须在将紧急程度从 int 转换为 str 之后,才能将其与其他字符串连接,这进一步复杂了字符串连接操作。
旧字符串格式化技术
在 f-string 被引入之前,有两种其他解决方案可用。第一种解决方案是经典的 C 风格,涉及 % 符号,另一种使用格式方法。您将在下面的代码片段中找到这些解决方案:
task1 = "Name: %s; Urgency Level: %d" % (name, urgency) ❶
task2 = "Name: {}; Urgency Level: {}".format(name, urgency)
❶ % 符号将字符串字面量和元组对象分开。
C 风格的方法在字符串字面量中使用 % 符号来表示一个变量将被格式化,其后跟随 % 符号和相应变量的元组。格式方法方法有类似的用法。它不使用字面量中的 % 符号,而是使用花括号作为字符串插值的标记,相应的变量列在格式方法中。
值得注意的是,这两种方法在 Python 中仍然得到支持,但它们已经过时,您很少需要使用它们。因此,在这里我不展开讨论。重要的是要知道,它们所做的工作可以用 f-string 来完成——这是一种更易读的字符串插值和格式化方法,我们将在 2.1.2 节中探讨。
CONCEPT 通常,方法是在类内部定义的函数。在这里,format 是在 str 类中定义的函数,我们调用这些方法在 str 实例对象上。
2.1.2 使用 f-string 插值变量
格式化字符串通常涉及将字符串字面量和不同类型的变量(如整数和字符串)组合在一起。当我们把变量整合到 f-string 中时,我们可以将这些变量进行插值,自动转换为所需的字符串。在本节中,您将看到使用 f-string 进行各种插值,涉及常见数据类型的示例。让我们首先看看如何使用 f-string 创建如列表 2.1 所示的输出:
task_f = f"Name: {name}; Urgency Level: {urgency}"
assert task == task_f == "Name: Homework; Urgency Level: 5"
在这个例子中,我们通过使用 f-string 方法创建 task_f 变量。最重要的是,我们使用花括号来包围用于插值的变量。由于 f-string 集成了字符串插值,它们也被称为 插值字符串字面量**.
CONCEPT “字符串插值”这个术语并非 Python 独有,因为大多数常见的现代语言(如 JavaScript、Swift 和 C#)都有这个特性。一般来说,它比字符串连接和替代字符串格式化方法更简洁、更易读。
断言语句
assert 是 Python 中的一个关键字,用于创建断言语句,它评估提供的条件。当条件为 True 时,程序继续执行。当条件为 False 时,执行停止,程序引发 AssertionError。
作为本书的惯例,我使用断言语句来显示比较中涉及的变量的等价性。作为一个特殊情况,当评估的变量是布尔值时,技术上更倾向于使用 assert true_var and assert not false_var。然而,为了明确显示变量的布尔值,我选择使用 assert true_var == True 和 assert false_var == False。
我们已经看到 f-字符串可以插值字符串和整数变量。那么其他类型,如列表和元组呢?这些类型由 f-字符串支持,如以下代码片段所示:
tasks = ["homework", "laundry"]
assert f"Tasks: {tasks}" == "Tasks: ['homework', 'laundry']" ❶
task_hwk = ("Homework", "Complete physics work")
assert f"Task: {task_hwk}" == "Task: ('Homework', 'Complete physics work')"❷
task = {"name": "Laundry", "urgency": 3}
assert f"Task: {task}" == "Task: {'name': 'Laundry', 'urgency': 3}" ❸
❶ 插值一个列表对象
❷ 插值一个元组对象
❸ 插值一个字典对象
PEEK F-字符串也支持自定义类实例。当我们学习在第八章创建自己的自定义类时,我们将重新审视字符串插值如何与自定义实例一起工作(第 8.4 节)。
2.1.3 使用 f-字符串插值表达式
我们已经看到 f-字符串如何插值变量。作为一种更通用的用法,f-字符串还可以插值表达式,从而消除了创建中间变量的需要。例如,您可以通过访问字典对象中的项来创建字符串输出,或者使用函数调用的结果。在这些常见场景中,您可以将这些表达式插入到 f-字符串中,如下面的代码片段所示:
tasks = ["homework", "laundry", "grocery shopping"]
assert f"First Task: {tasks[0]}" == 'First Task: homework' ❶
task_name = "grocery shopping"
assert f"Task Name: {task_name.title()}" == 'Task Name: Grocery Shopping' ❷
number = 5
assert f"Square: {number*number}" == 'Square: 25' ❸
❶ 访问列表中的项
❷ 调用一个函数
❸ 直接计算
这些表达式被括在花括号内,允许 f-字符串直接计算它们以产生所需的字符串输出:{tasks[0]} -> “作业”;{task_name .title()} -> “购物清单”;{number*number} -> 25。
作为一种关键编程概念,我们经常遇到术语表达式。一些初学者可能会将这个术语与相关概念语句混淆。表达式通常是一行代码(它可以扩展到多行,例如三引号字符串),计算为一个值或对象,例如字符串或自定义类实例。根据这个定义,我们可以轻松地弄清楚变量是一种表达式。
相比之下,语句不会创建任何值或对象,语句的目的就是完成一个动作。例如,我们使用 assert 创建断言语句,以确保在继续之前某件事是有效的。我们并不是试图产生一个 True 或 False 布尔值;我们是在检查或断言一个条件。图 2.1 说明了表达式和语句之间的区别。

图 2.1 表达式和语句之间的区别。表达式表示某物,并计算为一个值或对象,而语句执行特定操作,不能计算为一个值。
虽然 f-字符串可以原生地插值表达式,但我们应谨慎使用这项技能,因为 f-字符串中的任何复杂表达式都会损害代码的可读性。以下是一个使用复杂表达式的 f-字符串误用的例子:
summary_text = f"Your Average Score: {sum([95, 98, 97, 96, 97, 93]) /
➥ len([95, 98, 97, 96, 97, 93])}."
检查代码可读性的一个经验法则是确定读者消化你的代码需要多少时间。在前面的代码中,读者可能需要数十秒才能知道你想要实现什么。作为直接对比,考虑以下重构版本:
scores = [95, 98, 97, 96, 97, 93]
total_score = sum(scores)
subject_count = len(scores)
average_score = total_score / subject_count
summary_text = f"Your Average Score: {average_score}."
这个版本有几个需要注意的地方。首先,我们使用列表对象来存储分数,以消除数据的重复。其次,我们使用单独的步骤,每个步骤代表一个更简单的计算。第三,提高可读性的关键是每个步骤使用一个合理的名称来指示计算结果。在没有任何注释的情况下,你的代码易于阅读;一切都很清楚。
可读性 创建具有合理名称的必要中间变量,以清楚地指示每个操作步骤。对于这些简单的操作,你甚至不需要写任何注释,因为合理的名称表明了每个操作的目的。
2.1.4 将说明符应用于格式化 f-string
正确格式化文本数据,如对齐,是传达所需信息的关键。由于它们被设计用来处理字符串格式化,f-string 允许我们设置一个格式说明符(以冒号开头)来对花括号中的表达式应用额外的格式配置(图 2.2)。在本节中,你将学习如何应用这些说明符来格式化 f-string。

图 2.2 f-string 的组成部分。表达式是第一部分,是必需的。表达式首先被评估,然后创建相应的字符串。第二部分,即格式说明符,是可选的。
作为可选组件,格式说明符定义了表达式插入字符串应该如何格式化。f-string 可以接受不同类型的格式说明符。接下来,我们将探讨一些最有用的格式说明符,从文本对齐开始。
使用文本对齐创建视觉结构
提高沟通效率的一种方法是用结构化的组织,这在呈现文本数据时也是正确的。如图 2.3 所示,由于结构更组织化,列对齐,场景 B 比场景 A 提供了更清晰的信息。

图 2.3 与默认的左对齐(场景 A)相比,当文本以有组织的结构呈现时(场景 B),提高了清晰度
f-string 中的文本对齐涉及三个字符:<, >, 和 ^,分别对文本进行左对齐、右对齐和居中对齐。如果你对哪个是哪个感到困惑,记住要关注箭头的尖端;例如,如果它在左侧,文本就是左对齐的。
要将文本对齐指定为格式说明符,我们使用语法 f”{expr:x<n}”,其中 expr 表示插值表达式,x 表示填充字符(省略时默认为空格)用于对齐,<表示左对齐,n 是一个整数,表示字符串扩展的宽度。应用此语法,下一列表中的代码展示了如何创建两个正确对齐的记录,以提高清晰度。
列出 2.2:在 f-strings 中应用格式说明符
task_ids = [1, 2, 3]
task_names = ['Do homework', 'Laundry', 'Pay bills']
task_urgencies = [5, 3, 4]
for i in range(3):
print(f'{task_ids[i]:¹²}{task_names[i]:¹²}{task_urgencies[i]:¹²}') ❶
# Output the following lines:
1 Do homework 5
2 Laundry 3
3 Pay bills 4
❶ 将格式说明符应用于表达式
应该引起你注意的是,你应该为所有表达式应用相同的格式说明符,这代表重复。当你看到代码中的重复时,你很可能会违反 DRY(不要重复自己)原则,这是一个重构的信号。
DRY 原则和重构
我们可以将许多原则应用到我们的编码中。其中一个著名的是 DRY 原则。当你的程序包含重复的代码时,你很可能会将其重构以去除这些重复。一些 IDE,如 PyCharm,包括自动检测重复的功能,你应该利用这些功能来改进你的程序。
当我说“重构”时,我的意思是采取步骤更新现有代码以改进其设计、结构和可维护性。重构的目的不是向你的程序添加功能;相反,它意味着在不改变其外部行为的情况下重构现有代码。在适用的情况下,你将在本书中看到重构的示例。
在列出 2.2 中,如果我们有一个新的文本对齐要求,我们必须在三个地方更新代码,这既不方便又容易出错。因此,重构的目标是有一个机制来使用变量作为格式说明符。列出 2.3 显示了一个可能的解决方案,它提取了重复的部分:格式说明符。将重构进一步推进,我们定义了一个函数来接受格式说明符作为参数,允许我们尝试不同的格式说明符。为了提高可读性,我们为任务的详细信息创建了单独的变量。
列出 2.3:接受任何格式说明符的重构函数
def create_formatted_records(fmt):
for i in range(3):
task_id = task_ids[i]
name = task_names[i]
urgency = task_urgencies[i]
print(f'{task_id:{fmt}}{name:{fmt}}{urgency:{fmt}}')
在列出 2.3 时需要注意的一个重要事项是,格式说明符 fmt 被括号包围,嵌套在外部括号内。Python 知道如何将{fmt}替换为正确的格式说明符。让我们尝试使用不同的格式说明符来运行这个函数:
>>> create_formatted_records('¹⁵')
1 Do homework 5
2 Laundry 3
3 Pay bills 4
>>> create_formatted_records('¹⁸')
1 Do homework 5
2 Laundry 3
3 Pay bills 4
如你所见,重构后的代码允许我们设置任何格式说明符,这种灵活性突出了重构的好处。当我们使用格式说明符进行文本对齐时,文本形成不同的列,创建视觉边界来分隔不同的信息块。
可维护性 我们不断发现重构代码的机会,通常是在“局部”层面。局部优化可能看起来微不足道,但这些小的改进会累积起来,并决定整个项目的整体可维护性。
我们一直使用空格作为填充进行对齐;我们也可以使用其他字符作为填充。我们选择的字符取决于它们是否使信息突出。表 2.1 显示了使用不同填充和对齐的一些示例。
表 2.1 F-string 格式说明符用于文本对齐
| F-string | 输出 | 描述 |
|---|---|---|
| f"{task:*>10}"¹ | "**homework" | 右对齐,* 作为填充 |
| f"{task:*<10}" | "homework**" | 左对齐,* 作为填充 |
| f"{task:*¹⁰}" | "homework" | 居中对齐,* 作为填充 |
| f"{task:¹⁰}" | " homework " | 居中对齐,空格作为填充 |
(¹ 我们将任务定义为字符串变量:task = "homework"。)
格式化数字
数字是信息的重要来源,我们经常将其包含在文本材料中。存在多种数值形式,例如大整数、浮点数和百分比。在本节中,你将学习如何使用 f 字符串通过适当的格式说明符来表示数值,以提高其可读性。
有无限多的质数。通过快速进行 Google 搜索,我们可以找到大于 10 亿的最小质数是 1000000007。为了显示这个大整数,使用数字之间的分隔符是个好主意,常见的方法是每三位数字使用逗号分隔。要在 f 字符串中的整数上应用分隔符,格式说明符是 xd,其中 x 是分隔符,d 是整数的具体格式说明符:
large_prime_number = 1000000007
print(f"Use commas: {large_prime_number:,d}")
# output: Use commas: 1,000,000,007
浮点数,或一般的小数,几乎可以在任何科学或工程报告中找到。正如你可能预期的,f 字符串有格式说明符,允许我们以可读的方式格式化小数。考虑以下示例:
decimal_number = 1.23456
print(f"Two digits: {decimal_number:.2f}")
# output: Two digits: 1.23
print(f"Four digits: {decimal_number:.4f}")
# output: Four digits: 1.2346
与整数中的 d 一样,我们使用 f 作为十进制值的格式说明符。虽然 f 格式说明符可以单独使用,但它更常用于指定我们想要在小数符号后保留多少位数字:.2 保留两位数字,.4 保留四位数字,依此类推。
类似于使用 f 表示十进制,我们可以使用 e 作为科学记数法的格式说明符。考虑以下该功能的示例:
sci_number = 0.00000000412733
print(f"Sci notation: {sci_number:e}")
# output: Sci notation: 4.1227330e-09
print(f"Sci notation: {sci_number:.2e}")
# output: Sci notation: 4.13e-09
数值的一个常见形式是百分比,百分比的格式说明符是百分号(%)。正如我们使用 e 和 f 说明符一样,我们可以单独使用 % 说明符或与精度说明符结合使用,例如 .2 表示两位精度:
pct_number = 0.179323
print(f"Percentage: {pct_number:%}")
# output: Percentage: 17.932300%
print(f"Percentage two digits: {pct_number:.2%}")
# output: Percentage two digits: 17.93%
除了这些格式说明符之外,f 字符串还支持其他说明符。表 2.2 显示了在处理数字时可以应用于 f 字符串的常见说明符。
表 2.2 使用 f 字符串格式化数字的常见格式说明符
| 数值类型 | F-string | 输出 | 描述 |
|---|---|---|---|
| int | f"{number:b}" | "1111" | 二进制格式,使用基数 2 |
| f"{number:c}" | "\x0f" | 整数的 Unicode 表示 | |
| f"{number:d}" | "15" | 十进制格式,使用基数 10 | |
| f"{number:o}" | "17" | 八进制格式,使用基数 8 | |
| f"{number:x}" | "f" | 十六进制格式,使用 16 为基数 | |
| float | f"{point:.2e}" | "1.23e+00" | 科学计数法 |
| f"{point:.2f}" | "1.23" | 保留两位小数的定点表示法 | |
| f"{point:.2g}" | "1.23" | 通用格式,自动应用 e 或 f | |
| f"{point:.2%}" | "123.45%" | 百分比,保留两位小数² |
(² 我们将数字定义为整数变量(number = 15)和浮点变量(point = 1.2345)。请注意,浮点格式说明符中的.2 部分是可选的。当你使用.3 时,你将有三位小数精度。)
2.1.5 讨论
虽然直接通过 f-string 插值表达式可以使代码更简洁,但避免在 f-string 中使用复杂的表达式,这可能会使读者困惑。相反,当表达式复杂时,创建具有合理名称的中间变量。
Python 仍然支持传统的 C 风格和基于格式的接近方法,但你实际上没有必要学习它们(尽管你可能在旧代码中看到它们)。每次你需要创建字符串输出时,请使用 f-string。不要忘记对齐文本和格式化数值以提高文本输出的清晰度。
2.1.6 挑战
James 在一家批发公司的 IT 部门工作,正在准备价格标签模板。假设产品的数据被保存为一个字典对象:{"name": "Vacuum", "price": 130.675}。如果期望的输出是Vacuum: {130.68},James 应该如何编写 f-string?请注意,价格需要保留两位小数,并且输出包括花括号,这些花括号恰好是 f-string 中的字符串插值字符。
提示:花括号在 f-string 中是特殊字符。当字符串字面量包含特殊字符时,你需要以这种方式转义它们,使它们不再被评估为特殊字符。要转义花括号,你使用额外的花括号:{{表示{,}}表示}。
2.2 如何将字符串转换为检索表示的数据?
虽然表面上字符串是文本数据,但字符串实际表示的数据可以是整数、字典和其他数据类型。例如,内置的input函数是收集 Python 控制台用户输入的最基本方式:
>>> age = input("Please enter your age: ")
Please enter your age: 35
>>> type(age) ❶
<class 'str'>
❶ 检查变量的类型
如前述代码片段所示,用户的输入被当作字符串。假设我们想要检查用户的年龄是否超过 18 岁。我们认为我们可以运行以下代码:
>>> age > 18
# ERROR: TypeError: '>' not supported between instances of 'str' and 'int'
不幸的是,比较没有成功,因为年龄是一个字符串,你不能将字符串与整数进行比较。这个例子突出了将字符串转换为整数的必要性。更广泛地说,许多其他场景需要我们将字符串转换为列表、字典和其他适用的数据类型。这种转换对于后续数据处理至关重要。在本节中,你将学习如何检查字符串表示的数据类型以及将字符串转换为所需数据类型的正确方法。
2.2.1 检查字符串是否表示字母数字值
在 Python 中,字符串可以是你可以用键盘输入的任何内容。一个常见的需求是检查字符串是否只包含字母数字字符。在本节中,你将学习检查字符串字符性质的各种方法。
假设任务管理应用程序要求用户设置用户名,该用户名必须是字母数字的。我们可以通过使用 isalnum 方法实现此功能,该方法检查字符串是否只包含 a-z、A-Z 和 0-9。以下是一些示例:
bad_username0 = "123!@#"
assert bad_username0.isalnum() == False
bad_username1 = "abc..."
assert bad_username1.isalnum() == False
good_username = "1a2b3c"
assert good_username.isalnum() == True
假设当用户创建任务时,我们要求名称只包含字母。为此功能,我们可以使用 isalpha 方法,它返回 True 或 False。正如你可能已经注意到的,所有这些 is-方法都返回布尔值:
assert "Homework".isalpha() == True
assert "Homework123".isalpha() == False
以类似的方式,你可以使用 isnumeric 方法来检查字符串中的所有字符是否都是数值字符:
assert "123".isnumeric() == True
assert "a123".isnumeric() == False
在这里,我想讨论一下在使用 isnumeric 方法检查字符串是否表示数值时的一些需要注意的问题:
-
表示浮点数的字符串将不会通过 isnumeric 检查。合理地预期,包含有效数值的字符串在这个方法调用上应该返回 True。不幸的是,情况并非如此:
assert "3.5".isnumeric() == False -
表示负整数的字符串将不会通过isnumeric检查。这在很多人看来可能也违背了直觉,例如在这个例子中:
assert "-2".isnumeric() == False -
空字符串在 isnumeric 中被评估为 False *。将空字符串评估为非数值可能是期望的行为。当我们处理字符串到数字的转换时,我们应该理解这种行为。
为了避免这些意外,请记住,只有当非空字符串中的所有字符都是数值字符时,字符串才会通过 isnumeric 方法产生 True 值。请注意,数值字符不包括小数符号或负号。因此,isnumeric 方法将浮点数和负数评估为 False。
isnumeric、isdigit 和 isdecimal 之间的区别
与 isnumeric 方法相关,isdigit 和 isdecimal 方法通常用于检查字符串是否只包含数字或小数字符。这些名称似乎意味着相同的意思,并且在大多数情况下它们产生相同的布尔值,例如"123"。但一些细微差别使它们在某些字符串上产生不同的值,尤其是在数值字符串不是阿拉伯数字时。
根据定义,这三个方法在检查数值的严格性方面有以下关系:isdecimal < isdigit < isnumeric。当你对这些方法感到困惑时,最好的选择是使用 isnumeric,这是最全面的。
除了讨论过的用于检查字符串数值性质的 is-方法之外,作为一个复习,Python 字符串还有其他 is-方法,它们执行其他检查任务,例如 islower 和 isupper。尽管我在这本书中没有涵盖这些其他 is-方法,但你应该熟悉它们。
趣闻 在这些方法中,isidentifier 是一个有趣的方法,因为它测试一个字符串是否是一个有效的标识符,可以用来命名变量、函数或对象。
2.2.2 将字符串转换为数字
在上一节中,你学习了如何检查一个字符串是否表示一个正整数。但似乎没有简单的方法来判断一个字符串是否表示一个数值,尤其是当它是浮点数或负数时。将字符串转换为数字很重要,因为我们不能对字符串进行任何数值计算,例如比较年龄与 18 岁。因此,在许多情况下,我们必须推导出字符串所表示的数值,以便进行后续处理。在本节中,你将学习如何将字符串转换为数字——这个过程被称为类型转换。
概念 在编程中,将数据类型转换为另一种数据类型的过程,例如将字符串转换为整数,被称为 类型转换。
对于数值,有两种常见的数据类型:float 和 int。从字符串创建这些实例的语法是 float("string") 和 int("string")。Python 会评估字符串对象,将它们转换为适当的 float 或 int 对象——如果可能的话。
如果你期望一个字符串是 float 类型,你可以将其发送给内置的 float 构造函数。在以下示例中,所有转换后的数字都是 float 类型,即使字符串表示的是整数:
>>> float("3.25")
3.25
>>> float("-2") ❶
-2.0
❶ 即使字符串看起来像整数,也会创建一个 float 对象。
概念 一个 构造函数 指的是一种特殊的函数,它创建一个类的实例对象。有关这个主题的更多信息,请参阅第八章。在这里,我们使用 float 和 int 构造函数分别创建 float 和 int 类型的对象。
如果你期望一个字符串是 int 类型,你可以使用内置的 int 构造函数:
>>> int("-5")
-5
>>> int("123")
123
注意,当这些字符串具有所需的数值时,这些类型转换操作会成功。但是,当它们不具备所需数值时,这些转换会导致错误,这会导致你的整个程序停止,如下面的代码片段所示:
>>> float("3.5a")
# ERROR: ValueError: could not convert string to float: '3.5a'
>>> int("one")
# ERROR: ValueError: invalid literal for int() with base 10: 'one'
为了防止你的程序因这个错误而终止,使用 try...except... 语句来处理异常是非常重要的。虽然在这里我不展开讨论,但下一个列表显示了这种用法。我将在第十二章中讨论这个特性(第 12.3 节)。
列表 2.4 从字符串转换数字
def cast_number(number_str):
try:
casted_number = float(number_str)
except ValueError:
print(f"Couldn't cast {repr(number_str)} to a number") ❶
else:
print(f"Casting {repr(number_str)} to {casted_number}")
# Use the above function in a console
>>> cast_number("1.5")
Casting '1.5' to 1.5
>>> cast_number("2.3a")
Couldn't cast '2.3a' to a number
❶ 使用 repr 函数以引号格式显示字符串
2.2.3 将字符串评估为获取其表示的数据
除了数值之外,我们的应用程序通常还有表示其他数据类型的文本数据,例如列表和元组。例如,在一个网络应用程序中,数据通常以文本形式输入,如 "[1, 2, 3]",它表示一个列表对象。由于数据类型是 str,你不能对这个文本数据应用任何列表方法——也就是说,你只能在列表对象上调用列表方法。在这种情况下,需要进行数据转换。在本节中,你将探索如何从字符串中获取除了数字之外的其他底层数据。
在上一节中,你学习了如何使用 float 和 int 构造函数将字符串转换为数值。然而,使用带有字符串对象的构造函数的方法并不总是有效。考虑以下代码片段中的三个常见数据类型——列表、元组和字典,它们在以下代码片段中由字符串表示:
numbers_list_str = "[1, 2]"
numbers_tuple_str = "(1, 2)"
numbers_dict_str = "{1:'one', 2: 'two'}"
当我们尝试直接将这些字符串发送到相应的构造函数时,会出现意外的结果:
>>> list(numbers_list_str) ❶
['[', '1', ',', ' ', '2', ']']
>>> tuple(numbers_tuple_str) ❶
('(', '1', ',', ' ', '2', ')')
>>> dict(numbers_dict_str)
# ERROR: ValueError: dictionary update sequence element #0 has length 1; 2 is
➥ required
❶ 列表和元组可以从字符串实例化。
虽然列表和元组构造函数确实通过将字符串作为可迭代对象处理来创建列表和元组对象,但创建的对象不会是你从这些字符串中期望提取的数据。具体来说,字符串是包含字符的可迭代对象。当你将一个字符串包含在列表构造函数中时,它的字符成为创建的列表对象的项。对元组构造函数执行相同的操作。
可迭代(Iterables)是能够逐个渲染项目的对象。字符串、列表和元组是常见的可迭代对象。关于可迭代的进一步讨论,请参阅第五章。
为了解决这种不可预测的行为,使用内置的 eval 函数,它将字符串作为你在控制台中输入的字符串处理,并返回评估结果:
assert eval(numbers_list_str) == [1, 2]
assert eval(numbers_tuple_str) == (1, 2)
assert eval(numbers_dict_str) == {1: 'one', 2: 'two'}
通过评估这些字符串,我们可以检索这些字符串所代表的数据。这种转换很有用,因为我们经常使用文本作为数据交换格式。使用 eval 的好处是,提供的文本的评估结果保证是你从在控制台中运行相同文本作为代码所期望的结果。
谨慎使用 eval 和 exec
你可能希望限制 eval 的使用范围,仅限于可信的数据源,因为 eval 会将字符串评估为代码的一部分。以下代码片段显示了这样一个问题。不正确代码的评估会导致 SyntaxError,这可能会使你的程序崩溃:
>>> eval("[1, 2")
...(omitted lines)
SyntaxError: unexpected EOF while parsing
另一个内置的 exec 函数与 eval 类似。exec 函数可以运行一个字符串,就像这个字符串是程序的一部分。exec 与 eval 之间最显著的区别是,eval 评估并返回一个表达式,而 exec 可以接受表达式和语句,如 if...else...,但不返回任何内容。尽管这两个函数都可以为你的应用程序提供动态性,但使用不当时,它们可能会危害你的应用程序甚至你的计算机。例如,你可以将字符串 "os.system('rm -rf *') " 发送到 exec 函数,这将删除你计算机上的所有文件夹和文件。
因此,当你的应用程序需要使用 eval 和 exec 动态处理字符串作为代码时,你应该谨慎行事。作为 eval 的替代方案,你可以查看标准库中的 ast 模块,它具有 literal_eval 函数,可以安全地评估字符串。
如果你的应用程序关心数据源的合法性,我建议你自己解析字符串。例如,如果你需要从一个字符串中获取整数列表对象,你可以移除方括号并拆分字符串以重新创建适用的列表对象。以下是一个简单的示例供你参考。请注意,代码片段涉及一些技术,如字符串拆分和列表推导,我将在后面的章节(2.3 和 5.2)中介绍:
list_str = "[1, 2, 3, 4]"
stripped_str = list_str.strip("[]")
number_list = [int(x) for x in stripped_str.split(",")]
print(number_list)
# output: [1, 2, 3, 4]
可维护性 使用 eval 而不验证字符串对象的一致性可能会导致错误,甚至灾难性的后果。每次你需要使用此方法时都要小心。
2.2.4 讨论
当我们使用浮点数或整数构造函数来获取字符串表示的实际数值时,请考虑使用 try...except...,因为成功的转换并不总是保证的,如果转换失败,并且没有处理异常,程序会崩溃。当你使用 eval 来获取底层数据时,你应该小心,因为如果你使用不受信任的来源,它可能会给程序带来危险。因此,当数据安全是一个关注点时,你应该考虑自己解析数据或使用更安全的工具,例如 ast 模块。如果你处理自己的数据,例如处理数据的脚本,你只需使用 eval 来获取底层数据。
2.2.5 挑战
在本节的开始,你学习了你可以使用 input 函数来收集用户的输入。玛丽是一位小学教师,她想要为她的学生编写一个简单的玩具程序。假设她想要询问学生今天的摄氏度温度,使用 Python 控制台。她如何编写程序以满足以下要求?x 代表用户输入的值:
-
当温度小于 10 度时,输出你输入了 x 度。天气寒冷!
-
当温度在 10 到 25 度之间时,输出你输入了 x 度。天气凉爽!
-
当温度大于 25 度时,输出你输入了 x 度。天气炎热!
-
x 值应保留一位小数精度。例如,如果用户输入 15.75,则应显示为 15.8。
提示 输入的字符串输入需要在与其他数字比较之前转换为浮点数。要创建字符串输出,请使用 f-strings。不要忘记格式说明符!
2.3 我该如何连接和拆分字符串?
字符串不一定是你想要的形式。在某些情况下,单个字符串代表相关信息的离散部分,我们需要将它们连接起来形成一个单独的字符串。假设用户输入了多个字符串,每个字符串代表他们喜欢的某种水果。我们可能将字符串连接起来创建一个单独的字符串来显示用户的喜好,如下所示:
# initial input
fruit0 = "apple"
fruit1 = "banana"
fruit2 = "orange"
# desired output
liked_fruits = "apple, banana, orange"
在其他时候,我们需要拆分字符串以创建多个字符串。假设用户输入了他们访问过的所有国家作为一个单独的字符串。我们希望有一个这些国家的列表,如下所示:
# initial input
visited_countries = "United States, China, France, Canada"
# desired output
countries = ["United States", "China", "France", "Canada"]
这两个场景是你在实际项目中可能会遇到的基本字符串处理任务的合理示例。在本节中,我们通过使用现实生活中的例子来探索连接和拆分字符串的关键功能。
2.3.1 使用空白字符连接字符串
当你连接多个字符串时,可以使用显式的连接运算符:加号(+)符号,正如你在列表 2.1 中看到的。当你有多个字符串字面量时,如果它们之间由空白字符(如空格、制表符和换行符)分隔,你可以将它们连接起来。在本节中,你将看到如何将用空白字符分隔的字符串连接起来。
假设我们有多组配置来设置我们应用程序的显示样式。我们将每个配置作为一个字符串字面量分开,这些单独的配置设置会自动连接起来:
style_settings = "font-size=large, " "font=Arial, " "color=black, "
➥ "align=center"
print(style_settings)
# output: font-size=large, font=Arial, color=black, align=center
自动连接只能在字符串字面量之间发生,你不能用这个技术处理字符串变量或字符串字面量和变量的混合。F 字符串也支持自动连接。当你通过将不同的字符串字面量拆分成单独的代码行以提高清晰度来构建一个长的 f 字符串时,这个特性非常有用:
settings = {"font_size": "large", "font": "Arial", "color":
➥ "black", "align": "center"}
styles = f"font-size={settings['font_size']}, " \
f"font={settings['font']}, " \
f"color={settings['color']}, " \
f"align={settings['align']}" ❶
❶ 使用反斜杠作为行续接字符
可读性 当字符串很长时,考虑将其拆分成多行,每行代表一个有意义的子字符串。这些子字符串在它们之间用空白字符分隔时可以自动连接。
2.3.2 使用任何分隔符连接字符串
使用空格分隔的字符串连接可能会有些令人困惑,因为字符串字面量之间的边界(空格)并不容易让我们直观地看到单个字符串。此外,它只能在字符串字面量之间发生,这是一个额外的限制。作为一个一般情况,使用任何分隔符连接字符串是理想的。在本节中,你将学习如何使用任何适用的分隔符连接字符串。
尽管如此,考虑样式设置示例。我们可以使用join方法将这些单独的字符串连接起来:
style_settings = ["font-size=large", "font=Arial", "color=black",
➥ "align=center"]
merged_style = ", ".join(style_settings)
print(merged_style)
# output: font-size=large, font=Arial, color=black, align=center
join方法接受一个字符串列表作为其参数。列表中的项将按照我们调用方法时使用的分隔符字符串依次连接。虽然我们在这里使用了一个列表对象,但更广泛地说,它可以是任何可迭代对象,例如元组或集合。
str.join或list.join
实话实说,当我刚开始使用 Python 时,方法调用separator.join(the_list)让我有些困惑,因为在日常生活中,我习惯于说我想用特定的分隔符连接这些项。按照这个逻辑,你可能会期望列表对象出现在指定符之前。实际上,在另一种常见的语言 JavaScript 中,数组(类似于 Python 中的列表)有一个join方法,它可以从其项创建一个分隔符字符串。应用这个逻辑,你可能会期望 Python 列表对象有join`方法。
不幸的是,情况并非如此。相反,Python 的字符串有 join 方法。因此,似乎期望的实现与实际实现之间存在不匹配。后来,我发现记住正确的方法调用签名的最佳方式是这样的:我想使用特定的分隔符来连接列表对象中的每个项目。
当你更多地了解 Python 时,你会发现 Python 将 join 作为字符串方法的设计非常出色。不仅列表中的项目可以用分隔符连接;我们还可以使用 join 与元组、集合、字典、map 对象以及任何其他可迭代对象一起使用。如果 Python 将 join 作为列表方法来实现,以提供其他可迭代对象的相同功能,Python 将不得不为每种可迭代类型实现 join,这违反了 DRY 原则!
与直接连接相比,join 更易于阅读,因为贡献的字符串是单独的项目;因此,我们很容易知道要连接什么。更重要的是,join 有一个额外的优势:我们可以动态地操作列表对象中的项目。
假设我们想在任务管理应用程序中有一个字符串来列出我们希望在本周完成的任务。首先,我们有以下任务。我们可以将这些字符串连接起来生成一个字符串,作为显示在桌面上的备注:
tasks = ["Homework", "Grocery", "Laundry", "Museum Trip", "Buy Furniture"]
note = ", ".join(tasks)
print("Remaining Tasks:", note)
# output: Remaining Tasks: Homework, Grocery, Laundry, Museum Trip, Buy
➥ Furniture
经过一些艰苦的工作,一些任务已经完成,所以我们要移除这些任务:
tasks.remove("Buy Furniture")
tasks.remove("Homework")
移除这些任务后,我们仍然可以使用 join 方法来创建所需的字符串:
print("Remaining Tasks: ", ", ".join(tasks))
# output: Remaining Tasks: Grocery, Laundry, Museum Trip
这个例子展示了具有动态变化字符串列表的使用案例。当我们有额外的任务时,我们可以将这些任务添加到列表对象中,并使用 join 方法重新生成所需的字符串以创建更新的字符串。
2.3.3 将字符串分割成字符串列表
我们经常使用文本文件来保存和传输数据。例如,我们可以将表格数据保存到文本文件中,其中每一行代表一条记录。当我们读取文本文件时,每一行是一个包含多个子字符串的单个字符串,每个子字符串代表记录的一个值。为了处理数据,我们需要使用分割字符串来提取这些值,以获得单独的子字符串。本节涵盖了与字符串分割相关的话题。
假设我们有一个名为"task_data.txt"的文本文件,其中存储了一些任务。每一行代表一个任务的详细信息,包括任务 ID 号、名称和紧急程度,如下面的代码片段所示。因为你在第十一章将要学习如何从文件中读取数据,所以假设你已经读取了文本数据,并使用三引号将其保存为多行字符串:
task_data = """1001,Homework,5
1002,Laundry,3
1003,Grocery,4"""
逸事:你可以使用单引号或双引号来创建一个展开多行的三引号字符串。F-字符串也支持三引号的多行 f-string。
为了处理这个字符串,我们可以使用 split 方法,它可以定位指定的分隔符并相应地分离字符串。下面的列表显示了可能的解决方案。
列表 2.5 通过拆分字符串处理文本数据
processed_tasks = []
for data_line in task_data.split("\n"):
processed_task = data_line.split(",") ❶
processed_tasks.append(processed_task)
print(processed_tasks)
# output the following line:
[['1001', 'Homework', '5'], ['1002', 'Laundry', '3'], ['1003', 'Grocery', '4']]
❶ 拆分每一行的文本
split 方法的局限性在于它只允许我们指定一个分隔符,当字符串使用不同的分隔符分隔时,这可能会成为一个问题。假设我们有一个文本文件,它混合了逗号和下划线作为分隔符的使用。为了简单起见,单词之间只有一个分隔符。为了演示目的,考虑一行数据:messy_data = "process,messy_data_mixed,separators"。
当我们处理未清理的原始数据时,这个问题很可能会在现实生活中出现。当我们遇到这个问题时,我们必须考虑一种程序化的方法来解决它,因为很可能会发现文本文件中有大量的记录。显然,在这些记录上使用 split 方法是不行的,因为我们只能设置一种类型的分隔符。因此,我们必须考虑替代方案:
-
依次使用分隔符:
-
我们使用逗号拆分字符串以创建一个列表。
-
我们检查列表中的项是否包含任何下划线。如果没有,则该项就绪。如果有,我们使用下划线进行第二次拆分:
separated_words0 = [] for word in messy_data.split(","): if word.find("_") < 0: ❶ separated_words0.append(word) else: separated_words0.extend(word.split("_")) ❷❶ 当没有找到匹配项时,结果将是 -1。
❷ 扩展方法将拆分字符串的所有项附加到列表中。
-
-
合并分隔符。
因为我们知道只有两种可能的分隔符,所以我们可以将一个分隔符转换为另一个分隔符,这允许我们只需调用一次 split 方法即可完成所需的操作:
consolidated = messy_data.replace(",", "_") ❶ separated_words1 = consolidated.split("_")❶ 使用 replace 方法替换子字符串
这两种解决方案都很直接。如果你熟悉字符串和列表的基本操作,那么在性能不是问题的情况下,它们是完美的解决方案,因为它们需要多次遍历来检查分隔符,尤其是在你必须处理多个分隔符的情况下。在这种情况下,操作在计算方面更加昂贵。
是否有更高效的解决方案?答案是肯定的。正则表达式被设计用来处理这种更复杂的模式匹配和搜索,正如我在第 2.4 节和第 2.5 节中讨论的那样。
概念 正则表达式,通常简称为 regex 或 regexp,是由字符序列定义的特定搜索模式。
2.3.4 讨论
选择字符串连接、f-string 或 join 应该根据具体情况评估。关键是使你的代码可读。当你有少量字符串要连接时,你可以使用连接运算符来连接它们。当你有更多字符串时,你应该首先考虑使用 f-string 来将相关字符串组合在一起。join 方法在将这些字符串保存为可迭代对象时连接单个字符串特别有用。
除了 split,字符串还有一个方法:rsplit,它具有与 split 类似的功能。唯一的区别是你可以设置一个最大项数,通过 maxsplit 参数从拆分中创建。第 2.3.5 节将进一步探讨 split 和 rsplit。
2.3.5 挑战
split 和 rsplit 方法有以下调用签名。两种方法都接受一个参数来指定分隔符,另一个参数来指定创建的最大项目数。你能写几个字符串来使它们的行为相同和不同吗?
str.split(separator, maxsplit)
str.rsplit(separator, maxsplit)
提示:两种方法通常行为相同。当最大分割次数小于分割项数时,你会看到差异。
2.4 正则表达式的关键要素是什么?
Python 的 str 类包含一些有用的方法,例如 find 和 rfind,用于搜索子字符串。然而,许多场景超出了这些基本方法所能解决的问题,尤其是在复杂模式匹配方面。在这些情况下,我们应该考虑使用正则表达式。在前一节中,我提到你可以使用正则表达式来分割包含多种分隔符的字符串——这是一个纯 str 方法难以解决的问题。下面是使用正则表达式解决问题的示例:
import re
regex = re.compile(r"[,_]") ❶
separated_words2 = regex.split(messy_data)
❶ 编译所需的正则表达式
从性能角度来看,我们只需遍历字符串一次即可完成分割。当存在更多分隔符时,正则表达式的性能比其他两种解决方案(第 2.3.3 节)要好得多,后者需要多次遍历字符串。由于其灵活性和性能,正则表达式方法是在进行高级字符串处理中不可替代的技术。在本节中,我使用字符串搜索作为教学主题来解释正则表达式的机制。
知识点 正则表达式被认为是独立实体,尽管在语法方面存在一些差异,但所有常见的编程语言都支持正则表达式。然而,正则表达式是相似的,你可以将不同的编程语言视为拥有它们自己的正则表达式方言。
2.4.1 在 Python 中使用正则表达式
要学习正则表达式,你将从了解大局开始:相关的模块及其核心语法。本节提供了 Python 中正则表达式的 10,000 英尺概览。
Python 的标准库中包含 re 模块,它提供了与正则表达式相关的功能。使用此模块有两种方式。第一种方法与 Python 的面向对象编程(OOP)方面相关。将 OOP 模式应用于正则表达式(图 2.4),我们通过关注模式对象来执行操作。在这种方法中,我们首先通过编译所需的字符串模式来创建一个模式对象。接下来,我们使用这个模式对象来搜索与模式匹配的实例。
概念 OOP 代表 面向对象编程,这是一种以数据和对象为中心的编程设计模型,而不是以函数和过程为中心。

图 2.4 应用通用 OOP 在模式匹配中。在通用 OOP 方法中,我们首先确定适合任务的正确类。在这种情况下,我们使用 re 模块中的 Pattern 类。第二步是创建实例对象。在 OOP 范式下,一个对象由属性组成,可以通过点符号访问,以及方法,可以通过括号调用。第三步是使用创建的模式对象,例如通过访问其属性或调用其方法。
以下代码片段展示了如何将 OOP 范式应用于使用正则表达式进行模式搜索:
import re
regex = re.compile("do") ❶
regex.pattern ❷
regex.search("do homework") ❸
regex.findall("don't do that") ❸
❶ 创建模式
❷ 访问属性
❸ 使用方法
另一种风格采用功能方法。我们不是在模块中创建模式对象,而是直接调用函数。在函数调用中,我们指定模式以及模式要测试的字符串:
import re
re.search("pattern", "the string to be searched")
re.findall("pattern", "the string to be searched")
在幕后,当我们调用 re.search 时,Python 会为我们创建模式对象并调用该模式上的搜索方法。因此,使用模块调用这些函数是使用正则表达式的便捷方式。然而,你应该意识到一个差异:当你使用 compile 函数创建模式对象时,编译后的模式以某种方式被缓存,这使得多次使用模式更有效率,因为不需要再次编译模式。
概念 缓存 或 缓存机制 是在编程(以及一般计算)中用来存储相关数据的一种机制,以便数据可以更快地服务于未来的请求。
与之相反,功能方法会在运行时动态创建模式,因此它没有缓存模式的效率提升优势。因此,如果你只使用一次模式,你不需要担心这两种方法之间的差异。
2.4.2 使用原始字符串创建模式
正则表达式强大功能的关键表现是模式匹配广泛可能性的简洁性。要创建一个模式,我们通常需要使用原始字符串,例如带有前缀 r 的字符串字面量,如 r"pattern"。在本节中,你将看到为什么需要使用原始字符串来构建正则表达式模式。
在正则表达式中,我们使用 \d 来匹配任何数字,使用 \w 来表示 Unicode 单词字符。这些都是正则表达式中的特殊字符的例子,我们使用反斜杠作为前缀来表示这些字符具有超出其表面意义的特殊含义。值得注意的是,Python 字符串也使用反斜杠来表示特殊字符,例如 \t 表示制表符,\n 表示换行符,\ 表示反斜杠本身。
当这些巧合结合在一起时,我们最终会使用看起来很奇怪的图案。假设我们想在字符串中搜索\task。值得注意的是,\t 在这里是一个字面量;它实际上意味着一个反斜杠和一个字母 t,而不是制表符字符。我们必须使用\task,这样 Python 才能搜索\task。事情变得更加复杂,当我们创建这样的模式时,两个反斜杠都必须转义,这导致四个反斜杠(\\task)来在字符串中搜索\task。听起来很混乱吗?请查看以下代码:
task_pattern = re.compile("\\\\task")
texts = ["\task", "\\task", "\\\task", "\\\\task"]
for text in texts:
print(f"Match {text!r}: {task_pattern.match(text)}")
# output the following lines:
Match '\task': None
Match '\\task': <re.Match object; span=(0, 5), match='\\task'>
Match '\\\task': None
Match '\\\\task': None
由于 match 在字符串的开始处搜索,我们的模式只能匹配"\task"。这种行为是预期的;两个连续的反斜杠被解释为一个字面量反斜杠,这使得字符串实际上变成了"\task",匹配我们想要搜索的模式。
显然,使用如此多的反斜杠会让人困惑。为了解决这个问题,我们应该以这种方式使用原始字符串表示法,使得 Python 不处理任何反斜杠。就像 f-string 表示法一样,我们使用 r 而不是 f 作为前缀,将常规字符串字面量转换为原始字符串。将原始字符串应用于模式,我们得到以下解决方案:
task_pattern_r = re.compile(r"\\task")
texts = ["\task", "\\task", "\\\task", "\\\\task"]
for text in texts:
print(f"Match {text!r}: {task_pattern_r.match(text)}")
# output the following lines:
Match '\task': None
Match '\\task': <re.Match object; span=(0, 5), match='\\task'>
Match '\\\task': None
Match '\\\\task': None
如你所见,原始字符串定义了一个比常规字符串字面量更干净的模式,我们不得不使用四个连续的反斜杠。正如你所想象的那样,当你构建更复杂的模式时,你需要更多的反斜杠来表示特殊字符。没有原始字符串,你的模式将看起来像拼图。因此,始终使用原始字符串来创建正则表达式模式是一个好习惯。
可读性使用原始字符串构建模式消除了转义特殊字符反斜杠的需要,这使得用户更容易阅读。
2.4.3 理解搜索模式的基本要素
正则表达式的语法让大多数程序员感到困惑。如第 2.4 节开头所述,正则表达式构成了一种具有自己独特语法的独立语言。好消息是 Python 在一般情况下采用了正则表达式的语法。在本节中,我将介绍模式的基本组成部分。
边界锚点
当你处理字符串时,你可能想知道一个字符串是否以特定的模式开始或结束。这些用例与字符串的边界有关,我们称它们为边界锚点,包括字符串的开始和结束,如下面的代码所示:
^hi starts with hi
task$ ends with task
^hi task$ starts and ends with "hi task", and thus exact matching
^符号表示模式关注字符串的开始,而$符号表示模式关注字符串的结束。以下代码片段展示了这些锚点的示例:
re.search(r"^hi", "hi Python")
# output: <re.Match object; span=(0, 2), match='hi'>
re.search(r"task$", "do the task")
# output: <re.Match object; span=(7, 11), match='task'>
re.search(r"^hi task$", "hi task")
# output: <re.Match object; span=(0, 7), match='hi task'>
re.search(r"^hi task$", "hi Python task")
# output: None (omitted output in an interactive console)
你可能知道,在 str 类中存在 startswith 和 endswith 方法,它们在简单情况下工作。但是,当你有更复杂的需求时,例如搜索以一个或多个 h 后跟 i 开头的字符串,使用 startswith 是不可能的,因为你必须考虑 hi、hhi、hhhi 等等。在这种情况下,正则表达式变得非常有用。
可维护性 虽然正则表达式功能强大,但始终查看是否有一个更简单的解决方案是一个好主意,例如 startswith 或 endswith。这些解决方案更直接,且错误更少。
量词
在上一节中,我提出了搜索可变数量字符的问题,这需要创建一个考虑数量的模式。正则表达式通过支持量词类别来解决此问题。这个类别包括几个特殊字符:
hi? h followed by zero or one i
hi* h followed by zero or more i
hi+ h followed by one or more i
hi{3} h followed by iii
hi{1,3} h followed by i, ii, or iii
hi{2,} h followed by 2 or more i
如你所见,有四个通用的量词:? 表示 0 或 1,* 表示 0 或更多,+ 表示 1 或更多,{} 表示范围。需要注意的是:使用 ?, *, 和 + 的模式进行字符串搜索是贪婪的,这意味着模式尽可能匹配最长的序列。为了修改这种默认行为,我们可以在这些量词后附加后缀 ?:
test_string = "h hi hii hiii hiiii"
test_patterns = [r"hi?", r"hi*", r"hi+", r"hi{3}", r"hi{2,3}", r"hi{2,}",
r"hi??", r"hi*?", r"hi+?", r"hi{2,}?"]
for pattern in test_patterns:
print(f"{pattern: <9}--> {re.findall(pattern, test_string)}")
# output the following lines:
hi? ---> ['h', 'hi', 'hi', 'hi', 'hi']
hi* ---> ['h', 'hi', 'hii', 'hiii', 'hiiii']
hi+ ---> ['hi', 'hii', 'hiii', 'hiiii']
hi{3} ---> ['hiii', 'hiii']
hi{2,3} ---> ['hii', 'hiii', 'hiii']
hi{2,} ---> ['hii', 'hiii', 'hiiii']
hi?? ---> ['h', 'h', 'h', 'h', 'h']
hi*? ---> ['h', 'h', 'h', 'h', 'h']
hi+? ---> ['hi', 'hi', 'hi', 'hi']
hi{2,}? ---> ['hii', 'hii', 'hii']
这些搜索结果应该符合你的预期。在这些结果中,最后几个模式涉及使用 ? 后缀,这使得模式匹配满足模式的最短序列,而不是最长序列。
字符类和集合
正则表达式的灵活性源于使用少量字符表示多个字符可能性的简单性。当我在 2.4.2 节中介绍原始字符串时,我提到你可以使用 \d 来表示任何数字。你可以使用正则表达式指定许多其他字符集。在这里,我专注于最常见的一些:
\d any decimal digit
\D any character that is not a decimal digit
\s any whitespace, including space, \t, \n, \r, \f, \v
\S any character that isn't a whitespace
\w any word character, means alphanumeric plus underscores
\W any character that is not a word character
. any character except a newline
[] a set of defined characters
使用 [] 定义字符集时,你应该注意以下几点:
-
你可以包括单个字符。[abcxyz] 将匹配这六个字符中的任何一个,而 [0z] 将匹配 "0" 和 "z"。
-
你可以包括字符范围。[a-z] 将匹配 "a" 和 "z" 之间的任何字符,而 [A-Z] 将匹配 "A" 和 "Z" 之间的任何字符。
-
你甚至可以组合不同的字符范围。[a-dw-z] 将匹配 "a" 和 "d" 以及 "w" 和 "z" 之间的任何字符。
记忆每个字符集的作用的最佳方式是研究具体的示例,如下面的代码片段所示:
test_text = "#1$2m_ M\t"
patterns = ["\d", "\D", "\s", "\S", "\w", "\W", ".", "[lmn]"]
for pattern in patterns:
print(f"{pattern: <9}---> {re.findall(pattern, test_text)}")
# output the following lines:
\d ---> ['1', '2']
\D ---> ['#', '$', 'm', '_', ' ', 'M', '\t']
\s ---> [' ', '\t']
\S ---> ['#', '1', '$', '2', 'm', '_', 'M']
\w ---> ['1', '2', 'm', '_', 'M']
\W ---> ['#', '$', ' ', '\t']
. ---> ['#', '1', '$', '2', 'm', '_', ' ', 'M', '\t']
[lmn] ---> ['m']
识别的匹配形成几个互补对。\d 定位所有数字,例如,而 \D 定位所有非数字。认识到这些字符类进行相反匹配有助于你记住它们。掌握正则表达式的关键是实践!
逻辑运算符
与其他编程语言一样,正则表达式通过定义模式来进行逻辑操作。这些操作是最常见的:
a|b a or b
(abc) abc as a group
[^a] any character other than a
使用一对括号来表示必须存在的确切字符组,并使用插入符号来通过否定特定字符来创建字符集。例如,如果你想找到任何不是 s 的字符,你可以使用[^s]。以下是一些参考示例:
re.findall(r"a|b", "a c d d b ab")
# output: ['a', 'b', 'a', 'b']
re.findall(r"a|b", "c d d b")
# output: ['b']
re.findall(r"(abc)", "ab bc abc ac")
# output: ['abc']
re.findall(r"(abc)", "ab bc ac")
# output: []
re.findall(r"[^a]", "abcde")
# output: ['b', 'c', 'd', 'e']
2.4.4 解构匹配项
当你学会了构建合适的模式后,一个明显的任务就是找到所有匹配项,就像你在使用 findall 方法(第 2.4.3 节)时做的那样。当涉及到的文本较短且我们能够轻松地找出匹配项的位置时,findall 方法可能最为有用。在实际项目中,我们很可能会处理大量的文本,所以仅仅展示匹配项并不帮助。相反,我们想知道匹配项在哪里以及是什么。这项任务正是 Match 对象所关注的。本节将展示如何处理匹配项。
创建 Match 对象
match 和 search 方法通常用于模式搜索。match 和 search 之间的主要区别在于它们寻找匹配项的位置。match 方法关注字符串开头是否存在匹配项;search 方法扫描字符串直到找到匹配项(如果存在)。尽管存在这种差异,但两种方法在找到匹配项时都返回一个 Match 对象。为了学习 Match 对象,关注一个调用 search 方法的示例:
match = re.search(r"(\w\d)+", "xyza2b1c3dd")
print(match)
# output: <re.Match object; span=(3, 9), match='a2b1c3'>
Match 对象的关键信息是其匹配的字符串和范围。我们可以通过它们各自的方法:group、span、start 和 end 来检索它们,如下一列表所示。
列表 2.6 Match 对象的方 法
print("matched:", match.group())
# output: matched: a2b1c3
print("span:", match.span())
# output: span: (3, 9)
print(f"start: {match.start()} & end: {match.end()}")
# output: start: 3 & end: 9
当我们使用正则表达式时,我们仅在识别到匹配项时执行特定操作。为了使我们的工作更简单,Match 对象在条件语句中始终评估为 True。以下是一种通用风格:
match = re.match("pattern", "string to match")
if match:
print("do something with the matched")
else:
print("found no matches")
可读性 当你使用正则表达式结合 if...else...时,可以直接在 if 子句中包含一个 Match 对象,因为 Match 对象评估为 True。
处理多个组
可能让你感到困惑的一件事是,为什么这些信息是通过调用方法而不是属性来检索的:match.span()与 match.span。如果你想知道为什么,恭喜你;你正在培养良好的面向对象原则感。我同意你的观点,从面向对象的角度来看,你认为数据应该是属性是正确的。但你是通过使用方法调用来实现这个功能的,因为模式搜索可能会导致多个组。如果你仔细观察列表 2.6,你会注意到你使用 group 方法来检索匹配的字符串。你在想匹配项何时会有多个组吗?通过一个例子来找出答案:
match = re.match(r"(\w+), (\w+)", "Homework, urgent; today")
print(match)
# output: <re.Match object; span=(0, 16), match='Homework, urgent'>
match.groups()
# output: ('Homework', 'urgent')
match.group(0)
# output: 'Homework, urgent'
match.group(1)
# output: 'Homework'
match.group(2)
# output: 'urgent'
这种模式涉及两组(括号内),每组都搜索一个或多个由逗号和空格分隔的单词字符。如前所述,匹配是贪婪的,因为可能的最长序列是“Homework, urgent”。识别出的匹配创建的单独组对应于模式的组。
默认情况下,组 0 是整个匹配。后续的组基于模式的组进行匹配。由于模式可以匹配多个组,最好使用方法来检索每个组的信息,而不是使用无法接受参数的属性。相同的分组也适用于 span:
match.span(0)
# output: (0, 16)
match.span(1)
# output: (0, 8)
match.span(2)
# output: (10, 16)
2.4.5 了解常用方法
为了在我们的项目中有效地使用正则表达式,我们必须知道我们可以使用哪些功能。表 2.3 总结了关键方法;每个方法都附有示例以供说明。
表 2.3 常见正则表达式方法
| 方法 | 代码示例 | 匹配/返回值 |
|---|---|---|
| search: 如果在字符串的任何位置找到匹配项,则返回 Match。 | re.search(r"\d+", "ab12xy") | '12' |
| re.search(r"\d+", "abxy") | None | |
| match: 仅当在字符串的开始处找到匹配项时返回 Match。 | re.match(r"\d+", "ab12xy") | None |
| re.match(r"\d+", "12abxy") | '12' | |
| findall: 返回与模式匹配的字符串列表。当模式有多个组时,项是一个元组。 | re.findall(r"h[ie]\w", "hi hey hello") | ['hey', 'hel'] |
| re.findall(r"(h|H)(i|e)", "Hey hello") | [('H', 'e'), ('h', 'e')] | |
| finditer: 返回一个迭代器³,它产生 Match 对象。 | re.finditer(r"(h|H)(i|e)", "hi Hey hello") | An iterator |
| split: 通过模式分割字符串。 | re.split(r"\d+", 'a1b2c3d4e') | ['a', 'b', 'c', 'd', 'e'] |
| sub: 通过替换匹配项创建字符串。 | re.sub(r"\D", "-", '123,456_789') | '123-456-789' |
(³An iterator is an object that can be iterated, such as in a for loop. I cover iterators in chapter 5.)
对于表 2.3 中的方法,我想强调它们使用的关键点:
-
search 和 match 都识别单个 Match 对象。最大的区别是 match 锚定在字符串的开始处,而 search 则扫描字符串,中间的匹配也是有效的。
-
当你尝试定位所有匹配项时,findall 方法返回所有匹配项,但不提供它们的位置信息。因此,更常见的是使用 finditer。该方法返回一个迭代器,它产生每个 Match 对象,该对象包含有关匹配的更多描述性信息(例如位置)。
-
The split method splits the string by all the matched patterns. Optionally, you can specify the maximum number of splits that you want.
-
子方法(sub method)的名称意味着“替换”,你使用此方法用指定的替换内容替换任何已识别的模式。在高级用法中,你可以指定一个函数而不是字符串字面量,该函数接受一个匹配对象作为其参数以产生所需的替换。
2.4.6 讨论
使用正则表达式的关键步骤是(1)创建模式,(2)查找匹配项,以及(3)处理匹配项。这些步骤应建立在对你文本处理工作确切需求清晰理解的基础上。从更高层次考虑模式。你需要边界锚点、量词还是字符集?然后深入到这些类别的语法。准备好你的模式可能不会按预期工作。你必须通过用你的文本子集评估匹配项来测试你的模式。几乎总是有一些边缘情况会让你感到惊讶。在将任何内容部署到生产之前,确保模式考虑到了罕见情况。
2.4.7 挑战
杰瑞是一名研究生。他的一个项目要求他从文本中提取数据。假设文本数据是 "abc_,abc__,abc,,_abc,_abc",其中 abc 代表所需的数据值。也就是说,数据值由一个或多个分隔符分隔。他如何使用正则表达式提取数据值?
提示 当你需要创建一个涉及可变字符数的模式时,考虑使用模式量词。
2.5 我如何使用正则表达式处理文本?
正则表达式不是最容易掌握的主题,因为我们正在创建一个可以匹配多种可能性的通用模式。在大多数情况下,模式看起来相当抽象,因此对许多初学者来说很令人困惑。因此,如果你现在对概念感到困惑,请不要感到沮丧;掌握正则表达式需要时间。当你掌握了它们,你会发现它们在处理文本数据方面非常强大。
以我们的任务管理应用为例,假设我们最初有如下所示的文本。该文本是从数据库崩溃中恢复的数据,包含多个有效的任务记录,但不幸的是,随机文本遍布整个数据。
列表 2.7 待处理文本数据
text_data = """101, Homework; Complete physics and math
some random nonsense
102, Laundry; Wash all the clothes today
54, random; record
103, Museum; All about Egypt
1234, random; record
Another random record""" ❶
❶ 多行字符串的三重引号
我们的工作是从文本数据中提取所有有效的记录,排除无效记录。假设有数千行文本,手动处理数据是不现实的。我们需要使用一种通用的模式搜索方法来完成这项工作,这正是正则表达式设计来做的。在本节中,我将概述解决此问题的关键步骤。
2.5.1 创建工作模式以查找匹配项
列表 2.7 中显示的字符串突出了我们在处理文本时遇到的一个常见任务:清理数据。通常,所需数据与不需要的数据混合在一起。因此,我们希望实现一个程序性解决方案,利用正则表达式,仅保留所需数据。在本节中,你将学习第一步:创建模式。
在仔细检查原始数据后,你注意到有效的记录包含三个贡献组:以三位数字形式出现的任务 ID,任务的标题,以及任务的描述。前两组由逗号分隔,后两组由分号分隔。基于这些信息,你可能构建以下模式,并对每个组件进行详细分析:
r"(\d{3}), (\w+); (.+)"
(\d{3}): a group of 3 digits
, : string literals, a comma and a space
(\w+): a group of one or more word characters
; : string literals, a semicolon and a space
(.+): a group of one or more characters
将此模式应用于文本数据,你可以快速查看结果。在这个阶段,不要担心处理匹配项,因为你想要确保模式按预期工作。在测试和修改模式多次并达到期望的模式之前,你可以运行以下代码:
regex = re.compile(r"(\d{3}), (\w+); (.+)")
for line in text_data.split("\n"): ❶
match = regex.match(line) ❷
if match:
print(f"{'Matched:':<12}{match.group()}") ❸
else:
print(f"{'No Match:':<12}{line}")
# output the following lines:
Matched: 101, Homework; Complete physics and math
No Match: some random nonsense
Matched: 102, Laundry; Wash all the clothes today
No Match: 54, random; record
Matched: 103, Museum; All about Egypt
No Match: 1234, random; record
No Match: Another random record
❶ 将数据行拆分以提取每行
❷ 使用匹配方法在字符串开头搜索模式
❸ 使用 group 方法显示匹配的字符串
如第 2.4.4 节所述,Match 对象的一个重要特性是它评估为 True,这允许我们仅在通过 match 方法创建时才在 Match 对象上工作。从打印输出中,你可以看到你从匹配对象中获得了有效记录。相比之下,在不匹配的情况下,这些记录确实是无效的。
2.5.2 从匹配中提取所需数据
因为模式按预期工作,现在是时候提取数据并准备进一步处理了。具体来说,你希望将每个记录(ID、标题和描述)保存为元组对象,而元组对象形成一个列表对象。
值得注意的是,当你构建你的模式时,你包括了三个独立的组,分别对应任务的每个数据字段。这些组允许你访问每个组的单个匹配项。下一个列表显示了组是如何工作的。
列表 2.8 从单个组中提取数据
regex = re.compile(r"(\d{3}), (\w+); (.+)")
tasks = []
for line in text_data.split("\n"):
match = regex.match(line)
if match:
task = (match.group(1), match.group(2), match.group(3)) ❶
tasks.append(task)
print(tasks)
# output the following line
[('101', 'Homework', 'Complete physics and math'),
➥ ('102', 'Laundry', 'Wash all the clothes today'),
➥ ('103', 'Museum', 'All about Egypt')]
❶ 从多个组创建元组
如列表 2.8 所示,我们使用 group 方法并按顺序访问识别的三个组:组 1 用于 ID,组 2 用于标题,组 3 用于描述。作为一个相关的注释,当我们省略 group 方法中的数字参数时,我们将检索跨越组的整个匹配(见第 2.4.4 节)。
在我们的例子中,模式中有三个组。当我们的记录变得更加复杂时,我们可能需要处理更多的组。使用整数按顺序跟踪这些组可能会出错;很容易多计数一个,这可能导致意外的行为。
是否有更好的解决方案?这个问题引出了第 2.5.3 节中的讨论。
2.5.3 使用命名组进行文本处理
通常情况下,文本提供的语义信息比数字要多。如果引用组的整数可能会令人困惑,我们是否有使用文本进行组引用的选项?幸运的是,Python 支持这一功能,称为命名组。本质上,这个功能允许你以某种方式给组命名,这样你就可以使用该名称来引用组以进行后续处理。
要命名一个组,你使用语法(?P<group_name>pattern),其中将模式组命名为 group_name。该名称应该是一个有效的 Python 标识符,因为你必须能够通过调用名称来检索它。现在你可以使用命名组技术来更新列表 2.8 中的代码,如下一个列表所示。
列表 2.9 使用命名组提取数据
regex = re.compile(r"(?P<task_id>\d{3}), (?P<task_title>\w+); (?P<task_desc>.+)")
tasks = []
for line in text_data.split("\n"):
match = regex.match(line)
if match:
task = (match.group('task_id'), match.group('task_title'),
➥ match.group('task_desc'))
tasks.append(task)
在代码片段中,我们命名了三个组 task_id、task_title 和 task_desc,这清楚地指出了每个组的数据。以后,我们不再将整数传递给组方法,而是可以直接传递组名。与列表 2.8 中的实现相比,列表 2.9 中使用命名组提高了代码的可读性;更重要的是,它减少了引用错误组的可能性,尤其是如果模式包含更多的组。
可维护性:始终使用合理的标识符来命名变量或任何对象。这种方法不仅提高了可读性,而且由于你知道通过查看名称你正在处理什么数据,因此可以减少可能的错误。
虽然我们使用 group 方法从识别出的组中检索单个项,但命名组为我们提供了另一种检索识别数据的选项:groupdict 方法。对于第一个识别出的匹配项,我们可能有以下数据:
>>> match.groupdict()
{'task_id': '101', 'task_title': 'Homework', 'task_desc':
➥ 'Complete physics and math'}
如果你更喜欢使用这个字典对象进行数据处理,从代码可读性的角度来看,这也是一个好的选择。
2.5.4 讨论
使用正则表达式的第一步是了解我们想要实现什么业务需求,并据此创建一个模式。你不应该对第一次就使模式正确感到着迷。你必须用文本测试你的模式,找到正确的模式需要多次来回努力(图 2.5)。

图 2.5 使用正则表达式处理文本的一般过程
当你使用通过模式识别出的更多组时,我建议你使用命名组,因为通过命名这些组,你清楚地告诉读者每个组包含什么数据。以后,由于它们的名称合理,引用组将更容易。
2.5.5 挑战
当我们处理文本数据以提取记录时,我们将文本分割成单独的行。假设每一行确实有一个有效的记录或没有记录,你能找到一个处理所有文本而不将数据分割成多行的模式吗?
提示:每一行都以换行符(\n)结束。将这个字符整合到你的模式中。
摘要
-
f-string 是一种简洁的方式来插值变量和表达式。
-
对 f-string 应用适当的文本对齐可以通过创建不同数据片段的视觉边界,使信息更加清晰。
-
f-string 在格式化数字方面也很擅长,例如科学记数法和小数精度。
-
Python 字符串有 isalnum、isnumeric 以及许多其他的 is- 方法。你可以使用它们来确定字符串的性质。
-
所有 Python 数据,如整数和列表,都可以具有字符串的外观(例如,当数据通过互联网传输且全部由字符串组成时)。我们通过评估它们将这些字符串转换为它们的原生数据类型,因此我们可以使用特定数据类型的方法。
-
当我们需要连接几个字符串时,使用连接符号是可以的。然而,当我们处理多个字符串时,最好使用 join 方法。
-
split 方法用于分割字符串,它是一个有用的数据处理工具,也是处理表格文本文件的基础。尽管有内置模块,如 csv,但了解这些基础知识对于编写适合自己工作的脚本至关重要。
-
使用正则表达式的关键是构建一个满足你需求的模式。当我们构建模式时,我们需要从更高的层次开始思考。相关的问题可以包括这些:我需要多个组吗?边界锚点、字符集或量词如何?
-
命名组使得在使用正则表达式处理复杂的文本数据时,更容易引用特定的信息。
3 使用内置数据容器
本章涵盖了
-
选择列表而不是元组,反之亦然
-
对由复杂数据类型组成的列表进行排序
-
使用命名元组作为数据容器模型
-
访问字典的数据
-
理解可哈希性和其对字典和集合的影响
-
将集合运算应用于操作非集合数据
作为一种通用编程语言,Python 为不同的目的提供了一系列内置数据类型,包括集合类型。这些数据类型的集合作为容器,用于存储整数、字符串、自定义类的实例以及所有其他类型的对象。在每一个项目中,我们同时处理多个对象,这些场景通常需要数据容器来处理这些对象。每种现代语言都有数据容器作为其核心数据模型,突显了数据容器作为任何编程项目构建块的重要性。正如你在第十四章中构建任务管理应用时将看到的,我们将使用数据容器来完成各种工作,例如使用列表来存储任务类的自定义实例(第八章)。在本章中,我们将讨论最常见的一些内置数据容器,包括列表、元组、字典和集合。请注意,本章的目的不是提供与这些数据模型相关的所有功能的详尽审查。相反,我们将专注于对我们项目最重要的基本主题。
概念数据容器,如列表和元组,是包含其他对象的对象。相比之下,字符串和整数不是数据容器,因为它们不包含其他对象。
3.1 我该如何在列表和元组之间进行选择?
由于列表和元组作为数据容器具有相似性,我们经常将它们一起讨论。两者都可以以有序的方式存储对象,并且可以通过索引访问这些对象。在许多情况下,我们可以互换使用它们。但有些情况下可能需要我们选择其中之一。假设你需要一个数据容器来存储银行账户中的交易记录。你应该使用列表还是元组?作为另一个例子,如果你需要显示交易信息,例如金额和日期,你应该使用列表还是元组?
有许多类似的情况,其中两种选择似乎都是可行的,但最终我们选择了其中之一。在本节中,我们将讨论指导我们在列表和元组之间进行选择的关键区分因素。
3.1.1 使用元组实现不可变性和使用列表实现可变性
列表和元组之间的一大区别是可变性。列表是可变的,我们可以修改列表对象的数据:我们可以在列表的末尾添加新项,在中间插入项,更改项,以及删除项。为了支持这种可变性,Python 在列表类中提供了一系列方法,如 append、extend 和 remove,你应该熟悉它们。图 3.1 显示了这些方法。

图 3.1 列表作为可变对象的基本操作
逸事:列表的 remove 方法仅删除第一个匹配的项。当你尝试删除列表中不存在的项时,你会遇到 ValueError。
与列表相比,元组是不可变的;我们无法修改元组对象的 数据。为了支持这种不可变特性并防止不必要的混淆,Python 没有任何修改元组对象的方法。修改元组的项在语法上是可能的,但这种操作会导致异常:调用不存在的方法会导致 AttributeError,重新分配元组的项会导致 TypeError,如以下列表所示。
列表 3.1 元组对象的不可变性
integers_tuple = (1, 2, 3)
integers_tuple.append(4) ❶
# ERROR: AttributeError: 'tuple' object has no attribute 'append'
integers_tuple[0] = 'zero' ❷
# ERROR: TypeError: 'tuple' object does not support item assignment
❶ 尝试在元组对象上使用不存在的方法
❷ 尝试为元组的项分配新值
由于可变性的差异,当你期望更新数据时,你应该使用列表而不是元组。对于任务管理应用,我们使用列表来存储任务,因为我们添加新任务或删除旧任务。当你不更改存储的数据时,你应该使用元组,因为它们的不可变性。对于任务应用,我们可以使用元组来存储任务的元数据,如创建时间和用户,因为它们是固定的。尽管我们可以在元组的位置使用列表,但我们出于几个原因更喜欢在这些情况下使用元组:
-
它防止了数据发生任何意外变化。 尝试更改元组的数据将导致 AttributeError 或 TypeError(列表 3.1)。
-
它清楚地表明了我们的意图,即相关数据应保持不变。 我们使用(creation_time, user)来存储任务信息,而不是[creation_time, user],以表明这两个值是固定的。
-
元组比列表更节省内存。 当列表和元组包含相同的数据时,列表的大小比元组大。列表更大的内存成本来自于支持可变性的额外开销。因此,在需要许多实例的情况下,我们更喜欢使用元组,因为它们的内存效率更高。
逸事:你可以通过调用 sizeof 来检查对象的内存使用情况。
3.1.2 使用元组实现异质性和使用列表实现同质性
我们可以在列表和元组中存储任何数据类型。当项是不同类型时,或者当项具有相同的类型但具有不同的信息时,我们说它们在语义上是异质的。考虑一个现实生活中的对象——比如说,一个盒子。与盒子相关的信息可以包括大小、材料和颜色。这些信息是异质的,因为它们代表了盒子特性的不同方面。当项是同一类型的时候——或者更严格地说,当数据指的是同一种信息时——我们说它们是同质的。例如,当你搬家时,你可能使用多个盒子。这些盒子是同质的,因为它们代表了同一种对象。
列表和元组可以存储异质和同质数据。这个事实是否意味着我们对列表和元组没有偏好?当然,主要决定因素是数据对可变性的要求,如第 3.1.1 节中讨论的那样。但当可变性不是主要关注点时,你应该使用数据的同质性来指导你的选择。
让我们考虑一个更具体的例子,在任务应用中。在第 3.1.1 节中,我提到,从数据可变性角度来看,使用元组(创建时间,用户)来引用任务的元数据是首选的,因为它由不同的信息组成:任务是在何时创建的以及谁创建了任务。你可能会听到人们说元组是结构性的,因为每个项都携带独立的信息,这些信息有助于元组对象。因此,元组是存储语义异质数据的首选数据结构。
相比之下,列表中存储的数据在语义上是同质的。在任务应用中,任务属于相同的语义类别;因此,我们应该使用列表来存储任务。默认情况下,我们可以根据创建时间按升序存储任务。因此,如图 3.2 所示,列表被视为一个线性数据结构,它包含同质项。

图 3.2 列表项的同质性和元组项的异质性。列表通常用于存储同种类型的数据,称为同质数据。在图中,我们使用列表来存储多个任务。相比之下,元组通常用于存储具有不同意义的数据,称为异质数据。如图所示,我们使用元组来存储任务的元数据,这些是固定且独特的信息。
3.1.3 讨论
从可读性的角度来看,使用元组来存储数据给读者一个清晰的信号,表明数据不会改变。从可维护性的角度来看,我们更喜欢使用元组,以避免在预期保持不变的相关数据中发生任何意外的更改。
我应该指出,元组的不可变性并不能阻止你更改其项的数据。如果一个元组包含列表,例如 numbers = ([1, 2], [1, 2]),那么更改内部列表(如向第一个列表添加一个项目 numbers[0].append(3))是有效的。这个操作是有效的,因为尽管我们改变了内部对象的内容,但对该对象的引用保持不变。正如你将在第十章中看到的,我们将区分对象及其引用。
3.1.4 挑战
Zoe 在一家地理领域的软件公司工作。她正在构建一个基于位置的应用程序,我们知道一个地方有一个名称、描述和坐标(纬度和经度)。对于用户访问的一系列地点,她应该使用列表还是元组来存储它们?对于每个地点,她需要一个数据模型来存储其坐标。她应该选择列表还是元组来存储纬度和经度?
提示 考虑存储的数据是否可变和/或同质,以帮助你做出决定。
3.2 如何使用自定义函数对复杂数据的列表进行排序?
列表是序列数据(见第四章),其顺序由插入顺序决定。由于支持可变性,我们经常将列表重新排列成除了初始插入顺序之外的其他顺序。假设我们的项目有一个列表对象,它包含给定一天的任务,如下所示。
列表 3.2 由多个字典对象组成的列表
tasks = [
{'title': 'Laundry', 'desc': 'Wash clothes', 'urgency': 3},
{'title': 'Homework', 'desc': 'Physics + Math', 'urgency': 5},
{'title': 'Museum', 'desc': 'Egyptian things', 'urgency': 2}
]
假设我们按照任务的创建时间或紧急程度来显示任务。你会发现,如果我们对这个字典列表进行排序,我们会遇到一个 TypeError,因为 Python 不知道如何比较字典:
tasks.sort()
# ERROR: TypeError: '<' not supported between instances of 'dict' and 'dict'
在本节中,你将学习如何排序列表,特别是那些由复杂数据(如与整数和字符串相比的字典对象)组成的列表,并具有自定义要求。
3.2.1 使用默认顺序排序列表
由于排序列表是一个常见任务,Python 有一个内置的排序方法:sort 方法。下面的列表显示了使用 sort 的一些简单示例。
列表 3.3 使用 sort 方法排序列表
numbers = [12, 4, 1, 3, 7, 5, 9, 8]
numbers.sort() ❶
print(numbers)
# output: [1, 3, 4, 5, 7, 8, 9, 12]
names = ['Danny', 'Aaron', 'Zack', 'Jennifer', 'Mike', 'David']
names.sort(reverse=True) ❷
print(names)
# output: ['Zack', 'Mike', 'Jennifer', 'David', 'Danny', 'Aaron']
mixed = [3, 1, 2, 'John', ['c', 'd'], ['a', 'b']]
mixed.sort()
# ERROR: TypeError: '<' not supported between instances of 'str' and 'int'
❶ 在原地排序数字
❷ 在原地排序字符串,但要求顺序反转
注意,排序操作是在原地进行的,这意味着排序改变了原始列表的顺序,而不是创建一个新的列表。与这个原地特性相关的是,sort 返回 None。因此,在交互式 Python 控制台中,运行 numbers.sort()后你不会看到任何输出,因为 None 在控制台输出时自动省略。还有一点要注意的是,默认排序顺序是升序。如果你将 reverse 参数指定为 True,你将得到一个降序的列表。
概念 当我们说某个对象发生了原地变化时,意味着这个过程修改了对象本身。sort 方法就是修改列表对象本身。
看起来 Python 不能对包含不同数据类型的列表进行排序。在列表 3.3 中,当列表包含整数、字符串和列表时,我们遇到了 TypeError,因为默认情况下,Python 不知道如何比较不同类型的对象。有没有办法让 Python 比较这些对象?第 3.2.2 节讨论了答案。
3.2.2 使用内置函数作为排序键
除了 reverse 之外,sort 方法还有一个 key 参数。正如其名所示,此参数为排序问题提供了一个键。具体来说,你应该使用一个函数来设置 key,该函数从列表中的每个项目生成一个值。这些派生值用于比较,派生顺序决定了列表项的顺序。
知识点 不仅 sort 方法有 key 参数。一些其他函数,如 max 和 min,也有 key 参数。在这里学到的知识可以应用于这些函数。
如第 3.2.1 节末所述,Python 不知道如何比较整数、字符串和列表。值得注意的是,Python 确实知道如何比较字符串。因此,对不同类型数据进行排序的策略是将它们通过设置键参数转换为字符串:
mixed = [3, 1, 2, 'John', ['c', 'd'], ['a', 'b']]
mixed.sort(key=str)
print(mixed)
# output: [1, 2, 3, 'John', ['a', 'b'], ['c', 'd']]
在代码中,我们使用 str 函数(严格来说是一个类构造函数;见第 10.5 节)作为键参数,它将每个项目转换为字符串。Python 将这些字符串作为代理进行排序,['3', '1', '2', 'John', "['c', 'd']", "['a', 'b']"],产生['1', '2', '3', 'John', "['a', 'b']", "['c', 'd']"]。值得注意的是,每个转换后的字符串都与它的原始对象相关联,Python 以原始项目渲染排序后的列表。
3.2.3 使用自定义函数满足更复杂的排序需求
第 3.2.2 节讨论了如何使用键对各种类型对象的列表进行排序,但示例过于简单,在现实项目中没有实际用途。在第 3.2 列表中,我们的任务管理应用有一个由字典对象组成的列表对象。在本节中,您将看到如何对这类列表对象进行排序。
虽然我们可以将 str 设置为键来使这些字典对象可比较,但排序后的列表并不是我们想要的;对象不是按其紧急程度排序的。为了满足这一需求,我们可以创建一个自定义函数并将其设置为键参数,如下一列表所示。
列表 3.4 通过设置键对任务进行排序
def using_urgency_level(task):
return task['urgency']
tasks.sort(key=using_urgency_level, reverse=True)
print(tasks)
# output the following lines (re-arranged for readability):
[{'title': 'Homework', 'desc': 'Physics + Math', 'urgency': 5},
{'title': 'Laundry', 'desc': 'Wash clothes', 'urgency': 3},
{'title': 'Museum', 'desc': 'Egyptian things', 'urgency': 2}]
列表中的每个项目都通过使用 _urgency_level 函数发送到函数。重要的是要注意,这个键函数必须恰好接受一个参数,它对应于列表对象的每个项目. 此函数根据任务紧急程度提取任务紧急级别。图 3.3 直观地显示了排序过程。
PEEK 我们可以使用 lambda 函数设置键,这是一个通过使用 lambda 关键字创建的匿名函数。为了使用 lambda 函数获得相同的排序结果,我们可以使用 tasks.sort(key=lambda x: x['urgency'], reverse=True)。我们将在第 7.1 节中讨论 lambda 函数。

图 3.3 使用键函数的排序过程。键函数将列表中的每个项目转换为相应的值。生成的值将用作排序列表的中间项目。排序后,原始项目将按照中间项目创建的顺序呈现。
3.2.4 讨论
sort 方法仅适用于列表,因为它列表的一个实例方法。当我们对其他容器数据类型进行排序,如元组、集合和字典时,我们可以使用 sorted,它可以接受任何可迭代对象并返回一个排序后的列表。您也可以为 sorted 指定一个自定义排序函数。记住,要设置的键参数的函数必须恰好接受一个参数。当键参数的函数执行小操作时,我们应该考虑使用 lambda 函数(见第 7.1 节)。
3.2.5 挑战
在本章中,你学习了如何根据任务的紧急程度对任务进行排序,如列表 3.4 所示。你能想出一个解决方案来根据任务描述的长度对任务进行排序吗?描述越长,任务的排名越高。
提示:自定义排序需要在排序函数中设置 key 参数。内置函数 len 可以检查字符串的长度。
3.3 如何使用命名元组构建轻量级数据模型?
任何项目的核心都是数据。如果你正在构建一个社交网络应用,用户及其连接就是数据。如果你正在构建一个电子商务网站,商品和客户信息就是数据。如果你正在构建一个机器学习模型,特征和目标是数据。对于我们的任务管理应用,我们需要有一个机制来处理和操作与任务相关的数据。
如果你来自面向对象编程(OOP)的背景,你的直观反应可能是创建自定义类来管理数据。但是编写一个类是一个非平凡的任务。(你将在第八章中学习创建类的最佳实践。)随着我们应用程序的复杂性不断增加,我们可能需要多个类来处理数据流的各个方面。对于更简单的数据模型,命名元组可以是一个完美的解决方案,特别是当我们的主要关注点是拥有一个易于使用且内存开销小的轻量级数据模型时。
3.3.1 理解替代数据模型
在我们使用命名元组构建数据模型之前,了解我们的选项是至关重要的。在本节中,我们将探讨至少四种其他管理数据的方法:列表、元组、字典和自定义类。
为了创建一个上下文,假设在我们的应用程序中,每个任务都有以下需要管理的信息:标题、描述和紧急程度。接下来的列表显示了使用列表、元组和字典的数据模型的样子。
列表 3.5 使用内置数据模型进行数据管理
task_list = ['Laundry', 'Wash clothes', 3] ❶
task_tuple = ('Laundry', 'Wash clothes', 3) ❷
task_dict = {'title': 'Laundry', 'desc': 'Wash clothes', 'urgency': 3} ❸
❶ 使用列表
❷ 使用元组
❸ 使用字典
如列表 3.5 所示,这些信息作为单独的项目存储在列表和元组中,作为键值对存储在字典中。除了使用内置类,我们还可以创建一个自定义类来存储数据。你可以在以下代码片段中找到一个自定义类的框架(如果你不熟悉定义自定义类,不用担心;它将在第八章中介绍):
class Task:
def __init__(self, title, desc, urgency):
self.title = title
self.desc = desc
self.urgency = urgency
task_class = Task('Laundry', 'Wash clothes', 3)
尽管在某种情况下每种方法都是可行的,但各种缺点使它们对我们的业务需求(一个轻量级的数据模型)不太理想。
列表是可变的,这使得它们容易受到有意和无意的更改。第 3.1 节也讨论了,我们通常使用列表来存储同质数据。使用列表来存储异质数据不是一个好主意。尽管元组是不可变的,我们不必担心数据更改,但要检索如标题这样的属性,我们必须使用解包技术(第 4.4 节)或索引(第 4.2 节)。这两种技术都不简单。
列表和元组没有关于它们所持数据的元信息。一个不熟悉应用程序的同事在审查你的代码时,对数据模型将没有任何线索。与列表和元组相比,字典提供了元信息,因为键说明了数据是什么。但是,要检索这些属性,我们必须使用相应的键(如 task_dict['title'])。如果我们拼错了键或遗漏了引号,我们将遇到 KeyError 或 SyntaxError。
列表、元组和字典是通用类型,它们对数据模型的特定信息一无所知。因此,现代集成开发环境(如 PyCharm 和 Visual Studio Code)不会为这些数据结构提供有用的自动完成提示,这降低了你的编码效率。我们可以通过创建自定义类来克服这一缺点。当创建一个任务实例后,在输入实例和一个点之后,你的 IDE 会自动提示可用的属性(如标题和描述),从而加快编码速度。
概念 一个集成开发环境(IDE)提供全面的功能,例如自动完成提示和实时代码分析,以促进软件开发。
实现自定义类的解决方案可能会有一些复杂性:
-
创建一个自定义类需要相当多的样板代码,而对于像数据持有者这样的简单数据模型,实现整个自定义类是过度的。
-
内存成本不容忽视,尤其是如果你必须处理大量的实例。
如第 3.3.2 节所述,自定义类的每个实例比命名元组的实例消耗更多的内存。当我们的项目发展时,我们希望我们的数据模型能做更多的事情;我们将把轻量级的数据模型移动到一个完全装备的自定义类(第八章)。
3.3.2 创建命名元组以存储数据
如其名所示,命名元组是一种元组。命名元组之所以特殊,是因为它们所持有的项与名称相关联。与通过索引访问项的常规元组不同,命名元组支持点表示法,就像访问自定义类实例的属性一样访问项。我们可以在以下示例中观察到这些功能:
from collections import namedtuple
Task = namedtuple('Task', 'title desc urgency') ❶
task_nt = Task('Laundry', 'Wash clothes', 3) ❷
assert task_nt.title == 'Laundry' ❸
assert task_nt.desc == 'Wash clothes' ❸
❶ 创建命名元组类
❷ 创建命名元组的实例
❸ 访问实例的属性
注意关于命名元组技术的一些重要事项:
-
namedtuple 的实例具有使用点符号访问其属性的优势。这不仅因为自动完成提示而使编码更快,而且由于清晰的访问模式,也更易于阅读。
-
namedtuple 是 collections 模块中的一个工厂函数。因为它是一个工厂函数,所以调用它返回一个新的类或新的实例对象。在这种情况下,我们得到了 Task 类。
可读性 遵循 Python 中类命名的约定,使用大驼峰形式:ClassName。当你有多个单词时,每个单词的首字母应大写,如 TaskUser。
- 在 namedtuple 函数中,我们指定了类的名称及其属性。值得注意的是,数据模型的属性可以是单个字符串(使用空格或逗号作为分隔符)或列表对象(见图 3.4):
Task = namedtuple('Task', 'title, desc, urgency')
Task = namedtuple('Task', ['title', 'desc', 'urgency'])

图 3.4 创建 namedtuple。类名应遵循大驼峰命名规则,属性应通过单个字符串或字符串列表指定。
可读性 在 namedtuple 函数中使用单个字符串(包含空格或逗号)指定属性。代码更容易输入和阅读。
现在你已经了解了 namedtuple,你可以使用 Task 类来处理我们应用程序中使用的数据。为了简单起见,假设我们的数据源是从特定的应用程序编程接口(API)接收到的字符串对象:
task_data = '''Laundry,Wash clothes,3
Homework,Physics + Math,5
Museum,Epyptian things,2'''
概念 一个 API 定义了一组构建和集成不同组件的方法,包括软件和硬件。常见的一种 API 指的是应用程序可以调用来从另一个源检索数据的各种定义好的函数。
要将文本数据转换为 Task 实例对象,以下是一个可能的解决方案:
for task_text in task_data.split('\n'): ❶
title, desc, urgency = task_text.split(',') ❷
task_nt = Task(title, desc, int(urgency))
print(f"--> {task_nt}")
# output the following lines
--> Task(title='Laundry', desc='Wash clothes', urgency=3)
--> Task(title='Homework', desc='Physics + Math', urgency=5)
--> Task(title='Museum', desc='Epyptian things', urgency=2)
❶ 将文本数据分割成多行
❷ 使用逗号分割文本数据
此解决方案使用了一些你迄今为止学到的技术,包括字符串分割和 f-strings,并展示了如何将小事积累起来使某物工作。为了更进一步,我们可以利用 namedtuple 类方法 _make,它将可迭代对象(由 split 创建的列表是一个可迭代对象;我们将在第五章详细讨论可迭代对象)映射到 namedtuple。以下是更新后的解决方案:
for task_text in task_data.split('\n'):
task_nt = Task._make(task_text.split(','))
PEEK 你将在第 8.2 节中学习关于类方法的内容。
与具有通过 dict 实现的每个实例字典表示的定制类不同,namedtuple 没有底层字典表示,这使得 namedtuple 成为一个轻量级的数据模型,内存成本可忽略不计。当需要创建数千个实例时,namedtuple 可以节省大量的内存。
鼓励好奇的读者探索 Python 的官方网站(docs.python.org/3/library/collections.html),以了解命名元组的其他功能,例如通过替换字段值从现有命名元组创建新的命名元组以及检查字段的默认值。
3.3.3 讨论
与内置类型(如列表、元组和字典)和自定义类相比,如果您的业务关注的是主要用于只读访问要求的数据模型,则命名元组是一个更合适、更轻量级的数据模型。例如,流行的数据科学 Python 库 pandas 允许您将其 DataFrame 数据模型的每一行作为命名元组访问。
知识点大多数数据科学家在日常数据处理工作中使用 pandas。该库的关键数据结构 DataFrame 以电子表格的形式表示数据。
因为命名元组代表了一种新类型,你应该使用一个描述性的名称,首字母大写,就像其他自定义类一样。同时,使命名元组类明显。将创建命名元组类的代码放在模块的顶部是一个好主意。毕竟,代码只有一行,你不想让它被埋没。
可维护性将创建命名元组类的代码放在一个显眼的位置,例如在模块的顶部。代码只有一行,但它是重要的:它创建了一个新类。
3.3.4 挑战
对于任务管理应用,假设我们需要通过将紧急程度级别设置为 4 来更新名为 Task 的命名元组(title='Laundry', desc='Wash clothes', urgency=3)。你能直接更改级别吗?如果不能,如何更改它?
提示:命名元组是一个元组对象,因此它是不可变的,不允许直接更改其存储的数据。
3.4 如何访问字典的键、值和项?
最常用的内置数据类型包括 int、float、bool、str、list、tuple、set 和 dict。前四种类型是原始类型,因为它们是其他数据类型的基础。其他四种类型是数据容器(图 3.5)。dict 与 list、tuple 和 set 的不同之处在于它包含键值对而不是单个对象。通过存储键值对,字典可以持有两类信息。

图 3.5 Python 中的常见数据模型,包括原始类型和数据容器
假设我们有一个以下字典来存储任务应用中某些任务的紧急程度。这个字典对象包含两组信息,即作为键的标题和作为值的紧急程度级别:
urgencies = {"Laundry": 3, "Homework": 5, "Museum": 2}
当我们将字典包含在我们的项目中时,我们经常需要访问它们存储的数据:键、值和键值对。在本节中,我们将探讨访问这些数据的不同方法。因为我们经常在我们的项目中使用字典,了解如何访问字典的数据对于使用这种强大的数据类型至关重要。
3.4.1 直接使用动态视图对象(keys、values 和 items)
除了提供访问字典中单个键值对的方法,例如 urgency ["洗衣"],Python 还提供了三种基本方法来检索字典存储的所有数据:keys、values 和 items,分别用于访问键、值和键值对。让我们观察它们的基本用法:
urgencies = {"Laundry": 3, "Homework": 5, "Museum": 2}
urgen_keys = urgencies.keys()
urgen_values = urgencies.values()
urgen_items = urgencies.items()
print(urgen_keys, urgen_values, urgen_items, sep="\n")
# output the following lines:
dict_keys(['Laundry', 'Homework', 'Museum'])
dict_values([3, 5, 2])
dict_items([('Laundry', 3), ('Homework', 5), ('Museum', 2)])
许多人会犯的一个假设是,从这些方法(keys、values 和 items)创建的对象是列表对象。然而,它们不是。它们分别是 dict_keys、dict_values 和 dict_items。这些数据类型最特别的地方在于它们都是 动态视图对象。如果你熟悉数据库术语,你应该听说过 视图,它指的是从数据库中的数据动态计算或汇总的虚拟结果。
知识拓展 视图 是数据库中存储查询的结果。当相关数据更新时,视图也会更新。
就像数据库中的视图一样,字典视图对象是动态的,会随着字典对象的更改自动更新。也就是说,每次你修改存储在字典对象中的键值对时,这些视图对象都会更新。观察这个效果:
urgencies["Grocery Shopping"] = 4
print(urgen_keys)
# output: dict_keys(['Laundry', 'Homework', 'Museum', 'Grocery])
print(urgen_values)
# output: dict_values([3, 5, 2, 4])
print(urgen_items)
# output: dict_items([('Laundry', 3), ('Homework', 5), ('Museum', 2),
➥ ('Grocery, 4)])
这种动态特性在我们访问字典的数据时提供了极大的便利,因为数据与字典对象完美同步。相比之下,以下不利用视图对象的示例是反模式:
urgencies = {"Laundry": 3, "Homework": 5, "Museum": 2}
urgen_keys_list = list(urgencies.keys())
print(urgen_keys_list)
# output: ['Laundry', 'Homework', 'Museum']
urgencies["Grocery"] = 4
print(urgen_keys_list)
# output: ['Laundry', 'Homework', 'Museum']
我们创建一个键列表。在我们更新字典后,列表保持不变,并且不与字典对象同步。因此,当你使用列表来跟踪字典的键而不是使用 dict_keys 视图对象时,可能会遇到意外的错误,例如尝试访问已删除的项目。
可维护性始终使用视图对象来访问字典的数据,因为这些视图对象是动态的;当字典的数据更新时,它们会自动更新。
3.4.2 小心处理 KeyError 异常
在 3.4.1 节中,我们讨论了访问字典中所有键和/或值的三个方法。然而,大多数时候,我们需要使用 下标表示法 来访问单个值,即用一对方括号括住键:
assert urgencies["Laundry"] == 3
assert urgencies["Homework"] == 5
知识点下标表示法是访问集合数据类型中数据的一种常见方式。对于字典对象,使用下标表示法意味着使用方括号括住的键来访问相应的值。
这种方法的优点是它的直接性。如果你在其他语言中使用过字典,你应该熟悉这种方法。因此,当你访问字典项时,使用这个特性是很自然的。但如果你不小心处理键,可能会发生意外的错误。以下代码片段展示了这样的问题:
urgencies["Homeworks"]
# ERROR: KeyError: 'Homeworks'
当你尝试访问字典中不存在的键时,你会遇到 KeyError 异常。当异常被抛出时,除非它被 try...except...语句(第 12.3 节)处理,否则你的程序会崩溃。我们当然不希望我们的程序崩溃,所以我们应该通过使用替代方法来避免这种错误。
3.4.3 首先进行卫生检查以避免 KeyError:非 Python 风格的方法
因为我们知道 KeyError 异常仅在键不在字典对象中时才会发生,所以我们可以在检索值之前检查键的存在,如下例所示:
if "Homework" in urgencies: ❶
urgency = urgencies["Homework"]
else:
urgency = "N/A"
❶ 检查键是否在字典中
这个解决方案帮助我们避免了 KeyError 异常,但与此同时,它既繁琐又不符合 Python 风格,因为 Python 风格的代码应该是简洁的。现在,我们只访问一个项。你能想象访问多个项吗?我们不得不重复这段代码,导致代码库中的代码重复。代码重复应该让你想起 DRY(不要重复自己)原则;我们应该重构我们的代码以消除不必要的重复。考虑以下代码:
def retrieve_urgency(task_title):
if task_title in urgencies:
urgency = urgencies[task_title]
else:
urgency = "N/A"
return urgency
通过重构代码,我们可以检索任务的紧急程度,而无需再担心 KeyError 异常:
retrieve_urgency("Homework")
# output: 5
retrieve_urgency("Homeworks")
# output: 'N/A'
retrieve_urgency 函数对于检索任务的紧急程度很有用,但它被硬编码,包括字典对象(urgencies)和特定的语义(urgency)。如果我们访问另一个字典的数据,我们必须定义一个类似的函数来避免 KeyError。
我们拥有的字典对象越多,我们需要创建的函数就越多。你是否在这里看到了更高层次的重用?我们的 Python 先驱们已经考虑了这个问题,并创建了一个内置函数:get 方法,在第 3.4.4 节中讨论。
3.4.4 使用 get 方法访问字典项
因为这是一个字典方法,所以我们可以通过指定键和不存在时的默认值来在任何字典对象上调用 get 方法。当省略默认参数时,Python 使用 None 作为默认值。以下代码片段展示了几个示例:
urgencies.get("Homework")
# output: 5
urgencies.get("Homeworks", "N/A")
# output: 'N/A'
urgencies.get("Homeworks")
# output: None (None is automatically hidden in an interactive console)
get 方法的优势在于当键不在字典中时不会抛出 KeyError。更重要的是,它允许你设置一个合适的默认值作为回退值。你可以在从字典检索值时随时使用 get,但我更喜欢下标符号,我认为它更易读。
然而,在某些情况下,使用get方法比使用下标表示法更可取。一个这样的场景是在函数定义中需要处理可变数量的关键字参数(kwargs)。我们将在第 6.4 节中介绍使用kwargs。目前,你只需要知道 kwargs 是在函数中使用的字典对象,并且这些参数通常是可选的。假设你正在为 Python 社区构建一个 Python 包,并且这个包具有以下函数:
def calculate_something(arg0, arg1, **kwargs):
kwarg0 = kwargs.get("kwarg0", 0)
kwarg1 = kwargs.get("kwarg1", "normal")
kwarg2 = kwargs.get("kwarg2", [])
kwarg3 = kwargs.get("kwarg3", "text")
# ... and so on
# possible invocations:
calculate_something(arg0, arg1)
calculate_something(arg0, arg1, kwarg0=5)
calculate_something(arg0, arg1, kwarg0=5, kwarg3="text")
在这个例子中,calculate_something除了两个位置参数外还接受多个关键字参数。为了简洁,你可能不希望列出所有可选关键字参数,因为它们的默认值几乎总是被使用;因此,你可以在函数头中将它们包装到kwargs字典中。在函数体中,你会注意到我们多次使用get,这允许我们在调用函数时设置缺失键的默认值,并在get方法中包含这些适当的默认值。
3.4.5 监视setdefault方法的副作用
当人们谈论get方法的替代方案时,有些人可能会提到setdefault方法。这种方法与get方法类似,因为它也接受两个参数:键和一个作为后备的默认值。观察一些setdefault的使用情况:
urgencies = {"Laundry": 3, "Homework": 5, "Museum": 2}
urgencies.setdefault("Homework")
# output: 5
urgencies.setdefault("Homeworks", 0)
# output: 0
urgencies.setdefault("Grocery")
# output: None (None is automatically hidden in an interactive console)
这段代码片段展示了setdefault和get之间的相似性。但使setdefault与get不同之处在于,当你调用setdefault时,如果键不在字典中,则会发生一个额外的操作(dict[key] = default_value):
print(urgencies)
# output: {'Laundry': 3, 'Homework': 5, 'Museum': 2, 'Homeworks': 0,
➥ 'Grocery': None}
我们之前使用setdefault方法时使用了键“Homework”、“Homeworks”和“Grocery”。由于后两个键最初不在字典中,因此内部发生了以下操作:
urgencies["Homeworks"] = 0
urgencies["Grocery"] = None
由于这种副作用,我不建议使用setdefault方法。这个名字令人困惑——通常,我们不会期望通过调用涉及设置值的函数来返回值——并且涉及一个许多人可能不知道的隐式操作(设置指定的默认值或如果键不存在则返回 None)。
可维护性:避免使用setdefault方法,因为它可能会以意想不到的方式设置缺失键的值。使用更明确的方法,例如get方法。
3.4.6 讨论
字典视图对象是一种出色的设计,它能够动态跟踪字典的键、值和键值对。作为可迭代对象,如果想要迭代字典对象的数据,可以在 for 循环(第 5.3 节)中使用它们。
在访问键的值时,不必总是使用get方法。如果你习惯于使用下标表示法,请随意使用。有时,在你的代码库中使用下标表示法是个好主意,因为你希望任何问题在开发期间都能暴露出来,而引发错误是识别任何问题的基本机制。如果你拼写了一个键,使用get方法可能会通过提供后备值来隐藏KeyError异常。
3.4.7 挑战
内置的 id 函数检查对象的内存地址。运行 id("Hello")返回"Hello"对象的地址。你能使用 id 函数跟踪字典视图对象(如 dict_keys)的变化吗?你期望视图对象的数据会随着字典对象更新而变化。你应该期望视图对象的内存地址保持不变。
提示 一个对象在其生命周期内具有相同的内存地址。尽管对象的数据可以改变,但内存地址应该保持不变。
3.5 我在什么情况下使用字典和集合而不是列表和元组?
我们已经广泛讨论了两种数据容器:元组和列表。Python 对可以存储在其中的数据类型没有限制,这种灵活性使它们在任何项目中都成为有吸引力的数据模型。3.4 节提到,字典是有用的,因为它存储键值对,但集合呢?此外,你可能知道,并非所有数据类型都可以存储在字典和集合中,如下面的列表所示。
列表 3.6 字典和集合对象创建失败
failed_dict = {[0, 2]: "even"}
# ERROR: TypeError: unhashable type: 'list'
failed_set = {{"a": 0}}
# ERROR: TypeError: unhashable type: 'dict'
当对象不可哈希时,它们不能作为字典键或集合项使用。乍一看,这个事实似乎是一个缺陷,损害了这两个数据结构的有用性。但这个设计有很好的理由。在本节中,我们将探讨哈希限制如何有利于这两个数据结构的数据检索,以及我们应该何时使用它。我们还将研究可哈希与不可哈希的概念。
3.5.1 利用常量查找效率
字典存储键值对,这种存储模式允许我们通过访问键来检索数据。此外,字典有一个显著的优势:在检索特定项目时具有优越的查找效率。因为集合与字典有相同的底层存储机制(哈希表;见 3.5.2 节),它们具有相同的特性——高效的项查找。在本节中,我们将探讨何时更倾向于使用字典或集合而不是列表和元组。
假设我们的应用程序需要大量的项目检索或查找。从理论角度来看,我们可以使用列表或集合来存储数据。我们可以运行一个简单的实验来比较使用 timeit 和 random 模块从每个对象中检索随机项的速度,如下面的列表所示。
列表 3.7 列表和集合之间数据检索速度的比较
from timeit import timeit
for count in [10, 100, 1000, 10000, 100000]:
setup_str = f"""from random import randint; n = {count};
➥ numbers_set = set(range(n));
➥ numbers_list = list(range(n))""" ❶
stmt_set = "randint(0, n-1) in numbers_set" ❷
stmt_list = "randint(0, n-1) in numbers_list" ❸
t_set = timeit(stmt_set, setup=setup_str, number=10000) ❹
t_list = timeit(stmt_list, setup=setup_str, number=10000) ❹
print(f"{count: >6}: {t_set:e} vs. {t_list:e}")
❶ 设置定时测试的字符串
❷ 检查集合对象成员资格的字符串
❸ 检查列表对象成员资格的字符串
❹ 查找平均执行时间
知识点 作为标准 Python 库的一部分,timeit 模块允许我们检查我们操作的性能,而 random 模块提供了创建随机数的功能。这些内置工具的可用性是 Python 在日常工作工具方面全面性的另一个体现。
在列表 3.7 中,我们使用 for 循环遍历列表和集合对象具有不同数量项的多个条件。运行代码后,您将看到以下输出:
10: 1.108225e-02 vs. 9.955332e-03
100: 9.514037e-03 vs. 1.533820e-02
1000: 1.051638e-02 vs. 7.346468e-02
10000: 1.034654e-02 vs. 6.189157e-01
100000: 1.086105e-02 vs. 6.290399e+00 ❶
❶ 预期由于不同计算机而看到不同的结果。
可读性 我们使用了 f-strings 来格式化字符串输出。具体来说,我们应用了文本对齐格式说明符来创建一个视觉结构,以增强可读性。
随着集合中项目数量的增加,查找时间保持在同一量级,这代表常数时间,称为 O(1) 时间复杂度。也就是说,无论集合增长得多大,项目查找所需的时间大致相同。相比之下,查找时间的大小随着列表大小的线性增长而增加。与使用哈希表通过哈希值索引对象的集合(第 3.5.2 节)不同,列表需要遍历来检查是否包含某个项目,这种遍历的时间直接取决于列表项的数量。这种时间复杂度的对比突出了在业务需求是项目查找时使用集合而不是列表的好处。
此示例使用集合对象作为测试主题,以观察我们如何实现 O(1) 时间复杂度。对于 dict 对象,效率相同,因为底层存储机制是相同的:使用哈希表。dict 对象中的每个键和集合对象中的每个项目都有一个相应的哈希值。但哈希是什么意思?第 3.5.2 节讨论了该主题。
算法的时间复杂度
在计算机科学中,算法可以被理解为解决问题的定义性指令,例如排序一个列表或从一个序列中检索一个项目。并非所有算法的解决问题速度都相同。为了量化性能,我们使用时间复杂度来描述运行算法所需的时间量。为了表示时间复杂度,我们使用所谓的 Big O 符号,其中我们使用一对括号来包含涉及项数量的函数,通常表示为 n。例如,O(n) 表示算法所需的时间与涉及项的数量线性相关;O(n²) 表示所需时间与项的数量成平方关系;而 O(1) 表示时间是常数,不依赖于涉及项的数量。以下图表提供了一个关于时间复杂性的简要概述。

不同量级的时间复杂度曲线。变量 n 代表计算中涉及的项数。
3.5.2 理解可哈希和哈希
当你创建字典或集合时,你不想遇到 TypeError 异常(列表 3.6)。这个异常是因为我们试图使用不可哈希的对象作为字典键或集合项。正如你所想象的那样,不可哈希的相反是可哈希,并且似乎只有可哈希的对象才能与字典和集合一起使用。但可哈希是什么意思呢?在本节中,你将了解可哈希和不可哈希对象。
概念 当你的 Python 程序遇到错误时,我们说它抛出了异常。其他编程语言可能使用throw来表示错误或异常。
可哈希不是一个孤立的概念。你可能听说过相关的术语,如哈希值、哈希、哈希表和 hashmap。在本质上,可哈希对象使用相同的根本程序:哈希**。 图 3.6 展示了使用字典键作为示例的哈希的一般过程。我们开始于原始数据值:四个字符串。哈希函数,通常被称为*哈希器**,通过使用特定的算法进行一系列计算,并输出原始数据值的哈希值(称为哈希)。

图 3.6 使用字典键作为示例的哈希过程。哈希函数(哈希器)对字典的键进行哈希处理,生成整数形式的哈希值。这些哈希值与字典中的每个键唯一关联。期望不同的哈希器产生不同的哈希值。
注意哈希过程的一些关键点:
-
哈希函数应该足够计算稳健,为不同的对象产生不同的哈希值。 在罕见的情况下,哈希函数可以为不同的对象产生相同的哈希值——这种现象被称为哈希冲突,必须按照指定的协议处理。
-
哈希函数应该足够一致,相同的对象总是具有相同的哈希值。 当你在应用程序中设置密码时,密码会被哈希器哈希并存储在数据库中。当你再次尝试登录时,输入的密码字符串会被哈希并与存储的哈希值进行比较。在这两种情况下,相同的密码应该产生相同的哈希值。
-
对于更复杂的哈希器,哈希是单向交通。 设计上(例如使用随机数),根据哈希值反向计算原始数据几乎是不可能的。这种不可逆性在网络安全方面是必需的。即使黑客得到了密码的哈希值,他们也无法从哈希值中推断出密码(至少不是轻易地)。
Python 实现了哈希器,可以为它的对象生成哈希值。具体来说,我们可以通过使用内置的 hash 函数来检索对象的哈希值。以下代码展示了几个示例:
hash("Hello World!") ❶
# output: 9222343606437197585
hash(100)
# output: 100
hash([1, 2, 3])
# ERROR: TypeError: unhashable type: 'list'
❶ 预期不同的值,因为一些哈希器依赖于操作系统。
并非每个对象都能通过哈希函数生成哈希值。字符串和整数是可哈希的,但列表是不可哈希的。你可能想知道为什么列表是不可哈希的,或者更广泛地说,为什么字典和集合也是不可哈希的。原因很简单:这些不可哈希的数据类型是可变的。按照设计,哈希函数基于对象的内容生成哈希值。
可变数据的内容在创建后可以改变。如果我们神奇地将列表变为可哈希的,当我们用更改后的内容更新列表时,我们期望得到不同的哈希值。但哈希函数应该始终为相同的对象生成相同的哈希值,在这种情况下,我们期望列表对象的哈希值保持不变。显然,列表内容的变化导致哈希值的变化,与对相同列表对象期望的哈希值一致性是不相容的(图 3.7)。

图 3.7 可变对象哈希过程的不可调和性。如果列表是可哈希的,一方面,你期望列表无论其内容如何都能产生相同的哈希值,就像相同的对象一样。另一方面,在列表更新后,不同的内容应该产生不同的哈希值。这两种情况是不可调和的。
相比之下,对于整数、字符串和元组等不可变数据,内容在创建后保持不变。内容的这种一致性是应用哈希函数到任何对象的关键。因此,所有不可变数据类型都是可哈希的。
你可能会想知道是否有更直接的方法来确定对象的哈希性,而不使用哈希函数。列表 3.8 展示了解决方案。除了使用 Hashable 之外,所有内容都应该很简单。为了简单起见,你可以将 Hashable 视为一个类,每个可哈希对象都是这个类的实例。
列表 3.8 检查对象的哈希性
from collections.abc import Hashable
def check_hashability():
items = [{"a": 1}, [1], {1}, 1, 1.2, "test", (1, 2), True, None] ❶
for item in items:
print(f"{str(type(item)): <18} | {isinstance(item, Hashable)}") ❷
print(f"{'Data Type': <18} {'Hashable'}")
check_hashability()
# output the following lines:
Data Type Hashable
<class 'dict'> | False
<class 'list'> | False
<class 'set'> | False
<class 'int'> | True
<class 'float'> | True
<class 'str'> | True
<class 'tuple'> | True
<class 'bool'> | True
<class 'NoneType'> | True
❶ 创建了一个包含不同类型对象的列表
❷ isinstance 函数用于类型检查,并返回一个布尔值。
知识点:abc 子模块定义了一系列抽象基类(ABC)。它允许你检查一个类是否提供了特定的接口,例如可哈希性。用通俗的话说,它帮助你检查一个对象是否可以执行某些特定操作,例如进行哈希。
与我们之前的讨论一致,可变数据——包括字典、列表和集合——是不可哈希的。相比之下,所有其他不可变数据类型都是可哈希的。对于内置数据类型,不可变性实际上等同于可哈希性。表 3.1 提供了一个有组织的视图,展示了根据可变性和可哈希性对常见数据类型的分类。
表 3.1 根据可哈希性对常见数据类型的分类
| 可变性 | 可哈希性 | 数据类型 | 是否可以作为字典键或集合元素 |
|---|---|---|---|
| 可变 | 不可哈希 | dict, list, set | 否 |
| 不可变 | 可哈希 | int, float, str, tuple, bool, NoneType | 是 |
在 3.1 节中,我们看到了元组对象的不可变性,我们无法给元组对象中的项赋另一个值。在表 3.1 中,请注意,字符串在 Python 中也是不可变的。这表明在字符串中更改字符或子字符串是不可能的。以下代码显示了字符串的不可变性:
text = "Hello, World."
text[-1] = "!"
# ERROR: TypeError: 'str' object does not support item assignment
如果需要替换子字符串,不要忘记字符串的 replace 方法,它创建一个新的字符串,如下面的代码所示:
text.replace(".", "!")
# output: 'Hello, World!'
知识点 我们知道我们可以使用 id 函数来检查对象的内存地址,这些地址应该在不同对象之间不同。你可以通过替换来比较字符串及其对应项。
3.5.3 讨论
可哈希是关键编程概念。在底层,Python 使用哈希表作为字典和集合的存储机制。使用哈希表的最显著好处是数据检索具有 O(1)的性能,这使得它成为当你想要快速查找项时的理想数据模型。我们经常使用集合对象来存储涉及成员资格的数据,例如。
3.5.4 挑战
Jennifer 正在学习 Python,因为她正在追求数据科学职业。她了解到由于底层哈希表实现,字典对象不能有重复的键。假设她创建了一个字典对象:numbers = {1: "one", 1.0: "one point one"}。你期望 numbers 有哪些值?
提示 当你故意传递重复的键时,在构建字典对象时,后者的值会覆盖前者的值。
3.6 我如何使用集合操作来检查列表之间的关系?
列表是存储同质数据的常用数据结构。有时,我们会有多个列表来存储相似的项目,并需要确定列表对象之间的关系。假设我们使用一个 API 来检索一个由投资分析公司推荐的股票列表。每个客户的当前股票也以列表对象的形式保存。为了简化,我们以下列数据开始:
good_stocks = ["AAPL", "GOOG", "AMZN", "NVDA"]
client0 = ["GOOG", "AMZN"]
client1 = ["AMZN", "SNAP"]
应用程序的一个特定功能是检查一个客户的股票是否全部包含在推荐列表中。你知道如何解决这个问题吗?你可以使用一些列表方法来解决这个问题。然而,与它们的数学对应物一样,Python 中的集合对象有一系列方便的方法来检查集合对象之间的关系。在本节中,我们将探索集合类的独特操作,并了解如何使用这些操作来解决与列表关系相关的问题。
3.6.1 检查一个列表是否包含另一个列表的所有项
实现前面的功能本质上需要我们解决以下问题:我们如何检查一个列表对象是否包含另一个列表对象的全部项目?在本节中,你将学习如何使用集合操作来实现这个功能。如果不使用集合操作,初学者可能会考虑一个涉及列表对象迭代的解决方案。为了实现这个常规功能,我们创建了一个可以按需多次调用的函数,如下所示。
列表 3.9 检查一个列表是否包含另一个列表的全部元素
def all_contained_in_recommended(recommended, personal):
print(f"Is {personal} contained in {recommended}?")
for stock in personal:
if stock not in recommended: ❶
return False
return True
❶ “not in”检查一个项目是否不包含在集合中。
可维护性 总是考虑在需要为许多类似用例提供通用解决方案时创建函数。当你需要修改功能时,你只需要更改这个单一函数,而不是执行相同工作的单独重复函数。
列表 3.9 中函数的逻辑是,如果我们能找到任何股票不在推荐列表中的情况,我们就说客户的列表不完全包含在推荐列表中。使用这个逻辑,我们遍历客户的列表项。当发现任何股票不在推荐列表中时,我们通过返回 False 退出函数;否则,在遍历完整个列表后返回 True。使用这个函数,我们可以测试几个案例:
print(all_contained_in_recommended(good_stocks, client0))
# output the following lines:
Is ['GOOG', 'AMZN'] contained in ['AAPL', 'GOOG', 'AMZN', 'NVDA']?
True
print(all_contained_in_recommended(good_stocks, client1))
# output the following lines:
Is ['AMZN', 'SNAP'] contained in ['AAPL', 'GOOG', 'AMZN', 'NVDA']?
False
这两种用例都按预期工作。但更好的解决方案不需要创建函数。编码的一个重要原则是不要重复造轮子。如果我们可以使用现有的解决方案,我们应该直接使用它。因此,更好的解决方案利用了与集合相关的操作:
good_stocks_set = set(good_stocks) ❶
contained0 = good_stocks_set.issuperset(client0) ❷
print(f"Is {client0} contained in {good_stocks}? {contained0}")
# output: Is ['GOOG', 'AMZN'] contained in
➥ ['AAPL', 'GOOG', 'AMZN', 'NVDA']? True
contained1 = good_stocks_set.issuperset(client1)
print(f"Is {client1} contained in {good_stocks}? {contained1}")
# output: Is ['AMZN', 'SNAP'] contained in
➥ ['AAPL', 'GOOG', 'AMZN', 'NVDA']? False
❶ 创建一个集合对象
❷ 使用 issuperset 方法
要使用 issuperset 方法,我们将列表对象 good_stocks 转换为集合对象 good_stocks_set。我们在 good_stocks_set 上调用 issuperset,并将列表对象 client0 或 client1 作为参数传递。正如预期的那样,我们得到了所需的结果。从理论上讲,我们可以使用 issubset 方法来实现这个功能,但这需要为每个客户的列表创建集合对象,这是不必要的重复。因此,当你共享一个可能是超集的集合对象时,issuperset 比 issubset 更好。在我们的情况下,这是推荐的股票集合。
如你所见,使用 issuperset 的解决方案比使用自定义函数的解决方案更简洁。更重要的是,当我们使用内置函数而不是自定义函数时,我们的程序更不容易出错。
可维护性 编写函数来解决问题是很好的。使用现有函数,例如内置函数,则更好!
3.6.2 检查一个列表是否包含另一个列表的任何元素
关于列表之间关系的一个常见场景是,一个列表是否包含另一个列表的任何元素。本节将解决这个问题。
为了便于讨论,让我们继续使用股票推荐的例子。假设我们想要检查一个客户的股票列表是否包含任何推荐的股票。如第 3.6.1 节所示,这种功能由迭代技术提供(见以下列表)。
列表 3.10 检查一个列表是否包含另一个列表中的任何项
def contained_any_in_recommended(recommended, personal):
print(f"Does {personal} contain any in {recommended}?")
for stock in personal:
if stock in recommended:
return True
return False
列表 3.10 中函数的逻辑与列表 3.9 中的逻辑相反。如果我们能在客户的列表中找到推荐列表中的任何项,则满足我们的标准,函数返回 True;否则,没有匹配的记录,函数返回 False。以下代码片段显示了两个用例:
print(contained_any_in_recommended(good_stocks, client0))
# output the following lines:
Does ['GOOG', 'AMZN'] contain any in ['AAPL', 'GOOG', 'AMZN', 'NVDA']?
True
print(contained_any_in_recommended(good_stocks, client1))
# output the following lines:
Does ['AMZN', 'SNAP'] contain any in ['AAPL', 'GOOG', 'AMZN', 'NVDA']?
True
列表是否包含另一个列表中的任何项的问题本质上是一个问题,即它们之间是否存在任何重叠。不幸的是,没有内置的方法来检查两个列表对象之间的关系。然而,对于集合对象,存在这样的方法。一个关键的集合操作是在两个集合对象之间创建交集,这正是我们所需要的。以下是一个解决方案:
good_stocks_set & set(client0)
# output: {'AMZN', 'GOOG'}
bool(good_stocks_set & set(client0))
# output: True
good_stocks_set & set(client1)
# output: {'AMZN'}
bool(good_stocks_set & set(client1))
# output: True
使用交集运算符&,我们可以方便地检索两个集合对象之间的交集。如果我们想要得到布尔输出,我们可以使用内置的 bool 函数,它评估任何非空集合数据,例如这里的集合,将其评估为 True。
知识点 bool 函数是 bool 构造函数,它通过评估括号内的项来创建一个 bool 对象。如果集合对象至少包含一个项,则它们将被评估为 True。
除了在两个集合对象之间使用&运算符之外,还可以使用交集方法执行交集操作。与 issuperset 一样,使交集方便的是它可以接受任何可迭代对象,这样我们就可以直接将列表对象 client0 和 client1 发送到方法中,而无需先将它们转换为集合对象。以下代码片段中观察这一特性:
good_stocks_set.intersection(client0)
# output: {'AMZN', 'GOOG'}
good_stocks_set.intersection(client1)
# output: {'AMZN'}
在第 3.6.1 节和第 3.6.2 节中,我们使用了集合操作来检查列表对象之间的常见关系。现在让我们回顾一下 Python 中集合对象的更一般操作,特别是检查集合之间的关系。
3.6.3 处理多个集合对象
如第 3.5 节所述,集合对象最适合用于需要检查成员资格的场景,因为这种操作的复杂度为 O(1)。除了成员资格测试之外,当你拥有多个相关的集合对象时,你可能需要在它们之间执行操作。在本节中,我们将探讨处理多个集合对象的操作。最常见的四种集合操作是:并集、交集、对称差集和差集(图 3.8)。

图 3.8 Python 中的集合操作。展示了四个常见的集合操作及其相应的数学符号、维恩图和 Python 中的操作。并集包含来自 A 和 B 的成员。交集包含 A 和 B 共有的成员。对称差集包含一个集合的成员但不包含两个集合的成员。差集包含一个集合的成员但不包含两个集合的成员。
所有四个操作都有相应的特殊操作符,这简化了语法。以下代码片段展示了这些操作。正如你所见,这些操作在尝试选择符合特定标准(如属于两个集合的交集或任一集合的并集)的成员时非常有用:
tasks_a = {"Homework", "Laundry", "Grocery"}
tasks_b = {"Laundry", "Gaming"}
tasks_a | tasks_b ❶
# output: {'Laundry', 'Gaming', 'Homework', 'Grocery'}
tasks_a & tasks_b ❷
# output: {'Laundry'}
tasks_a ^ tasks_b ❸
# output: {'Homework', 'Grocery', 'Gaming'}
tasks_a - tasks_b ❹
# output: {'Homework', 'Grocery'}
❶ 使用 | 进行并集操作
❷ 使用 & 进行交集操作
❸ 使用 ^ 进行对称差集操作
❹ 使用 - 进行差集操作
知识点 你可能会得到不同的结果顺序。集合对象中存储的项是无序的,因为它们使用哈希表,并且不关心项的顺序。
除了这些从其他集合创建集合的操作之外,还有 issubset 和 issuperset 方法,它们用于检查两个集合之间的关系。issubset 检查调用方法是否是另一个集合的子集(更一般地说,它可以是对任何可迭代的),而 issuperset 检查相反的情况。以下是一些简单的例子:
small_set = {1, 2}
large_set = {1, 2, 3, 4}
assert small_set.issubset(large_set) == True
assert small_set.issuperset(large_set) == False
assert large_set.issubset(small_set) == False
assert large_set.issuperset(small_set) == True
我们已经看到了四个与集合相关的操作(并集、交集、对称差集和差集)、它们对应的方法以及各自的操作符。有趣的是,issuperset 和 issubset 方法有对应的操作符。这些方法和操作符总结在表 3.2 中。
表 3.2 集合操作符及其对应的方法
| 集合操作 | 操作符 | 方法 |
|---|---|---|
| 并集 | | | union |
| 交集 | & | intersection |
| 对称差集 | ^ | symmetric_difference |
| 差集 | - | difference |
| 检查一个集合是否是另一个集合的超集 | >= | issuperset |
| 检查一个集合是否是另一个集合的子集 | <= | issubset |
| 检查一个集合是否是另一个集合的严格超集 | > | N/A 但可以通过组合 issuperset 和 != 实现 |
| 检查一个集合是否是另一个集合的严格子集 | < | N/A 但可以通过组合 issubset 和 != 实现 |
虽然操作符可以使你的代码更简洁,但它们只能与集合对象一起使用。相比之下,所有这些方法都可以接受可迭代对象作为它们的参数;因此,它们更灵活。当你处理不是集合对象的可迭代对象时,你应该考虑直接使用这些方法,这样可以消除首先将它们转换为集合对象的需求。
可读性 在执行集合操作时,优先使用相关的方法;它们不仅更灵活(因为它们接受任何可迭代对象),而且更易于理解(因为它们的名称)。
3.6.4 讨论
集合对象是存储唯一成员的首选数据模型,Python 提供了一系列操作来操作多个集合对象并检查它们之间的关系。由于列表没有检查列表之间关系的原生方法,我们可以方便地将列表转换为集合,以推导出初始列表对象的关系。
3.6.5 挑战
当我们在两个集合之间执行并集操作时,这个操作生成一个包含来自任一集合的所有成员的集合。因此,这个操作类似于 OR 操作。你知道如果你在两个集合之间使用关键字 or 会发生什么吗?你期望这个操作的{1, 2, 3}或{4, 5, 6}的结果是什么?以类似的方式,有些人可能将交集操作比作 AND 操作。你能猜出操作{1, 2, 3}和{4, 5, 6}的结果吗?
提示 这些评估也被称为*短路评估**。或操作在第一个对象具有布尔值 True 时评估为第一个对象;否则,它评估为第二个对象。对于与操作,如果第一个对象具有布尔值 False,则它评估为第一个对象;否则,它评估为第二个对象。
摘要
-
列表是可变的数据类型,允许我们添加、插入、更新和删除项,而元组是不可变的,这意味着你无法在创建后修改它们。
-
除了它们在可变性的差异之外,列表和元组在包含数据的同质性方面也有所不同。我们使用列表来存储语义上同质的项,这些项形成一个线性有序序列。我们使用元组来存储语义上不同的项,这些项形成一个结构序列。
-
默认排序只能使用数值或字典序对列表进行排序,这相当有限。因此,我们需要了解如何使用自定义函数作为键参数来指定排序要求。
-
当你需要一个简单的数据容器时,你应该考虑使用命名元组,这允许你通过一行代码创建一个类。命名元组有几个优点,包括内存效率和点符号用于属性检索。
-
当我们访问字典的所有键、值或键值对时,我们更喜欢使用字典视图对象,因为它们将与底层字典对象同步自动更新。
-
在 Python 中,只有可哈希的对象才能成为字典键和集合项。常见的可哈希数据类型包括 int、float、str、tuple、bool 和 NoneType。
-
基于底层的哈希实现,使用字典和集合进行项查找是高效的,时间复杂度为 O(1)。
-
get 方法检索字典的项而不触发 KeyError 异常。当你与其他人创建的字典一起工作时,这是首选方法。
-
如果你使用你创建的字典,你可能想使用下标(dict[key]),这允许键的任何拼写错误自行暴露。
-
集合是一种专门用于处理具有唯一值成员的数据结构。集合对象之间存在多种操作,例如并集和差集。如果你需要检查其他非集合数据类型(如列表)之间的关系,可以利用这些操作。
4 处理序列数据
本章涵盖了
-
使用切片对象来检索和操作子序列
-
结合使用正负索引进行项目检索
-
在序列中查找项目
-
解包序列
-
考虑除列表之外的数据模型
在第三章中,你学习了如何使用列表和元组来存储数据。列表和元组的一个共同特征是它们持有的项目具有特定的顺序。这两个数据结构是更通用数据类型序列的例子。Python 还有其他序列数据类型,如字符串和字节。这些序列数据模型是我们项目中使用的必要数据结构。原因很简单:我们使用数据来模拟现实生活,现实生活充满了有序的对象/事件,例如排队、书面语言和门牌号码,仅举几例。因此,有效地处理序列数据是编程项目中的普遍需求,无论我们的业务专业是什么。
从 Python 的实现角度来看,这些序列数据结构有许多共同特征,在这里一起讨论它们是值得的。你希望一石二鸟,你会发现你可能认为只适用于特定数据模型(例如解包元组对象)的技能可以应用于所有序列数据模型。作为一个相关的说明,尽管我在本章的示例中主要使用列表或字符串,但不要错误地认为这些技术仅适用于列表或字符串。
4.1 我如何使用切片对象检索和操作子序列?
当我们拥有序列数据时,我们可能对获取序列的特定子集感兴趣,我们将其称为子序列。内置的数据类型包括常见的序列数据模型 str、list 和 tuple:
# str is a sequence of characters:
text = "Hello, World!"
# list is a mutable sequence of any kinds of objects
fruits = ["apple", "orange", "banana", "strawberry"]
# tuple is an immutable sequence of any kinds of objects
vowels = ("a", "e", "i", "o", "u")
当我们从列表对象检索子序列时,我们可以使用切片。切片的最简单形式是 list[start:end],在起始和结束索引之间(不包括结束索引处的项目)的项目将被检索:
assert fruits[1:3] == ["orange", "banana"]
在本节中,我将超越基本形式的切片列表[start:end],讨论切片的更多高级特性,你将学习如何使用这些特性来检索和操作子序列。
4.1.1 充分利用切片的完整功能
除了指定起始和结束索引外,切片有多种排列组合,给我们提供了不同的方式来检索子序列。我们将在这里讨论最显著的一些:
-
忽略起始或结束索引
-
不要滥用超出范围的切片索引的容错性
-
将步长应用于切片
忽略起始或结束索引
默认情况下,起始索引为零,因此如果你想检索前n个元素,Python 的方式是省略起始索引并使用 list[:end]。默认情况下,结束索引是列表的长度,并且切片选择不包括结束索引,所以如果你想检索列表的最后n个元素,你使用 list[start:]。正如你所看到的,忽略起始或结束索引可以删除不必要的代码并提高可读性:
assert fruits[:3] == ["apple", "orange", "banana"]
assert fruits[1:] == ["orange", "banana", "strawberry"]
如果你同时忽略起始和结束索引怎么办?你可能已经猜到了正确答案:list[:]检索所有元素,这是原始列表的副本。(第 10.3 节更详细地讨论了复制对象。)以下代码片段显示了[:]检索列表对象的所有元素:
assert fruits[:] == ["apple", "orange", "banana", "strawberry"]
可读性 当你忽略起始或结束索引(如果可能的话),代码更易于阅读。
不要滥用超出范围切片索引的容忍
切片的一个特点是容忍超出范围的索引,因为 Python 使用最大允许的范围来限制切片。序列中的每个元素都有一个索引来表示其位置。当你使用一个与序列中没有任何元素匹配的索引时,你会遇到 IndexError 异常,指出所使用的索引超出范围:
fruits[5]
# ERROR: IndexError: list index out of range
显著的是,Python 容忍切片中使用的超出范围的索引,例如使用与序列中任何元素都不对应的索引。考虑以下示例来观察此功能:
numbers = [0, 1, 2, 3, 4, 5]
numbers[:20] ❶
# output: [0, 1, 2, 3, 4, 5]
numbers[-10000:2] ❷
# output: [0, 1, 2]
❶ 使用大于最后一个元素的索引
❷ 使用小于第一个元素的索引
虽然切片对超出范围索引的容忍似乎给了我们检索元素的灵活性,但我不建议使用此功能,因为它会混淆读者。他们可能会想知道代码中是否有拼写错误或程序员忘记更新索引。无论如何,你的代码都会失去其清晰性。
可维护性 当你使用超出范围的索引时,你只是在混淆自己或你的队友。
将步长应用于切片
我们可以将步长应用于切片以检索等间隔的元素。切片注解接受一个可选的步长参数:list[start🔚stride],它从起始位置取每n个元素,直到达到结束。当我们使用步长(或一些用户称之为步)时,我们仍然可以省略起始和结束索引,Python 会为我们提供适用的边界。以下是一些常见用法(如图 4.1 所示):
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9]
assert numbers[2:5:2] == [3, 5] ❶
assert numbers[::3] == [1, 4, 7] ❷
assert numbers[::-1] == [9, 8, 7, 6, 5, 4, 3, 2, 1] ❸
❶ 当步长为 2 时,每隔一个元素被保留。
❷ 当步长为 3 时,每隔两个元素被保留。
❸ 当步长为-1 时,切片从右侧开始并朝左侧移动。
使用正步长很简单。值得注意的是,切片还支持负步长,这对许多人来说可能会令人困惑。许多人看到的一个 Python 技巧是使用 list[::-1]反转列表,如前例所示,但许多人不明白为什么。原因是当步长为负时,切片从右侧开始并向左侧移动。因此,步长-1 意味着我们正在连续检索左侧的项目。因为我们没有指定起始和结束索引,所以整个列表从右侧到左侧被切片;因此,它被反转了。图 4.1 展示了正负步长之间的对比。

图 4.1 使用正负步长进行列表切片。当步长为正时,切片从左侧开始。当步长为负时,切片从右侧开始。
虽然切片支持负步长,但我不建议使用此功能,因为它会降低可读性。如果您想从左到右获取子序列,可以使用 reverse 方法就地反转列表(调用 reverse 会改变原始列表),然后以从左到右的方向执行任何切片操作。这种方法需要额外的代码行,但它使读者更容易理解切片操作。
可维护性:在使用切片时,除了-1 之外,避免使用负步长。它们不直观,可能会造成很大的困惑。
4.1.2 不要混淆切片和 range
在底层,检索子序列涉及创建切片对象。也就是说,切片列表 list[start:stop:end]等价于 list[slice(start, stop, step)]。但另一个类 range 具有相同的调用签名:range(start, stop, step)。这种相似性可能会让一些初学者感到困惑。在本节中,我将澄清这一点。
slice 和 range 相似,因为它们的构造函数接受起始值、结束值和步长,创建三个属性:start、stop 和 step:
slice_obj = slice(1, 10, 2)
range_obj = range(1, 10, 2)
slice_obj.start, slice_obj.stop, slice_obj.step
# output: (1, 10, 2)
range_obj.start, range_obj.stop, range_obj.step
# output: (1, 10, 2)
这些相似之处可能会令人困惑。然而,切片和 range 在两个方面有所不同,这使得它们不能互换。首先,range 对象是可迭代的,而切片对象不是。这意味着我们可以使用 range 对象创建列表或将其用于 for 循环,而我们不能在这些操作中使用切片对象。以下代码片段展示了示例:
list(range(10))
# output: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
list(slice(10))
# ERROR:TypeError: 'slice' object is not iterable
其次,我们可以使用切片对象来检索列表或其他序列数据中的项目。在下面的示例中,我们使用切片对象获取奇数,但使用 range 对象进行相同的操作是不允许的:
numbers = list(range(10))
odd_slice = slice(1, 10, 2)
numbers[odd_slice]
# output: [1, 3, 5, 7, 9]
odd_range = range(1, 10, 2)
numbers[odd_range]
# ERROR:TypeError: list indices must be integers or slices, not range
图 4.2 展示了切片对象和 range 对象之间的差异;它还展示了它们在构造函数和属性上的相似之处。

图 4.2 切片对象和 range 对象之间的相似之处和不同之处。它们具有相似的构造函数模式和属性。range 对象是可迭代的,而切片对象不是。这意味着我们可以使用 range 对象创建列表或将其用于 for 循环,而我们不能在这些操作中使用切片对象。以下代码片段展示了示例:
4.1.3 使用命名切片对象处理序列数据
大多数时候,我们使用基于下标的切片来检索项:list[start:stop]。当序列中的数据简单时,这种方法有效。然而,当序列有更复杂的数据时,我们应该使用具有合理名称的切片对象来提高代码的可读性。
假设我们正在处理为我们任务管理应用生成的外部源文本数据。由于一些格式设置,文本数据看起来像这样(文本中的数字是字符索引):
tasks = """
0....5..............20..........................48......
1001 Laundry Wash all clothes 3
1002 Museum Visit Go to the Egypt exhibit 4
1003 Do Homework Physics and math 5
1004 Go to Gym Work out for 1 hour 2
"""
在文本中,我们注意到相同的数据字段在每一行中垂直对齐。使用切片对象是一种最佳实践,你可以在下一个列表中找到一个可能的实现。
列表 4.1 在数据处理中使用命名切片
task_id = slice(5)
task_title = slice(5, 20)
task_desc = slice(20, 48)
task_urgency = slice(48, 49)
task_lines = tasks.split("\n")[2:-1]
tasks = []
for line in task_lines:
task = (line[task_id].strip(), line[task_title].strip(),
➥ line[task_desc].strip(), line[task_urgency].strip()) ❶
tasks.append(task)
print(tasks)
# output the following lines (re-formatted for clarity):
[('1001', 'Laundry', 'Wash all clothes', '3'),
('1002', 'Museum Visit', 'Go to the Egypt exhibit', '4'),
('1003', 'Do Homework', 'Physics and math', '5'),
('1004', 'Go to Gym', 'Work out for 1 hour', '2')]
❶ 使用条带法去除尾随空格
为了分离任务 ID、标题、描述和紧急程度,我们创建了四个切片对象,分别提取每个对应的子字符串。技术上,我们可以直接对字符串应用切片,例如line[:5]用于标题。然而,这些切片对象的名称清楚地表明了每个切片获取的数据。更重要的是,从可维护性的角度来看,当我们使用命名切片时,如果文本文件中有格式更改,例如数据字段之间的额外空格,通过更新索引以反映新的格式要求,修改切片对象会更简单。
可维护性 命名切片易于阅读,并清楚地表明它们代表什么数据。
4.1.4 使用切片操作操作列表项
在 4.1.1 到 4.1.3 节中,你学习了如何使用切片检索子序列。这些操作对所有序列数据模型都可用,包括可变的,如列表和 bytearray,以及不可变的,如元组和字符串(表 4.1)。可变序列数据模型支持另一组操作,我们称之为切片手术。在本节中,你将学习如何操作可变序列中的项。
表 4.1 可变序列数据模型作为可变性的函数
| 可变性 | 数据类型 | 允许切片手术 |
|---|---|---|
| 可变 | list, bytearray | 是 |
| 不可变 | str, tuple, range, bytes | 否 |
以列表为例,切片手术意味着我们可以使用切片对象操作列表的子序列。我们可以通过切片手术对子序列执行几个操作,包括替换、扩展、收缩和删除。要替换子序列,我们将相同数量的项目分配给检索到的子序列:
numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8]
numbers[:3] = [10, 11, 12]
numbers
# output: [10, 11, 12, 3, 4, 5, 6, 7, 8]
要扩展子序列,我们将较长的子序列分配给原始子序列:
numbers[3:] = [13, 14, 15, 16, 17, 18, 19, 20]
numbers
# output: [10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
要收缩子序列,我们将较短的子序列分配给原始子序列:
numbers[:5] = [0, 1]
numbers
# output: [0, 1, 15, 16, 17, 18, 19, 20]
值得注意的是,子序列不必是连续的。即使在切片中有步长,我们仍然可以执行替换。如下例所示,因为步长是 2,所以我们正在更新列表中的每个第二个项目,使用提供的项目:
numbers[::2] = [0, 0, 0, 0]
numbers
# output: [0, 1, 0, 16, 0, 18, 0, 20]
当需要时,你可以使用 del 语句删除子序列。或者,你可以将空列表赋值给子序列,这样相应的项目也会被删除:
numbers = [0, 1, 0, 16, 0, 18, 0, 20]
del numbers[:4]
print(numbers)
# output: [0, 18, 0, 20]
numbers[-2:] = []
print(numbers)
# output: [0, 18]
4.1.5 讨论
当你在切片中使用负步长时,切片会从右向左处理序列。因为人们通常更熟悉从左到右的顺序,所以负步长可能会令人困惑,你应该谨慎使用。
当你以一致格式处理一系列序列时,例如在处理文本数据(第 4.1.3 节)的示例中,你应该使用命名切片对象,因为每个名称都清楚地指明了对应子序列的数据,这可以提高你代码的可读性。
4.1.6 挑战
杰森正在学习 Python,以便分析有关旅游的新闻,这是他机器学习兴趣的一部分。在他的工作中,他处理各种序列数据。他想尝试使用不同序列数据类型(如字符串和元组)进行切片。你能帮助他找到这些序列类型生成的子序列的数据类型吗?请注意,如表 4.1 所示,范围也是一种序列类型。请尝试子序列范围。
提示:生成的子序列在类型上应该类似于“父”序列。
4.2 我如何使用正负索引来检索项目?
序列对象的一个共同特征是存储的数据遵循线性顺序,每个数据点对应一个特定的索引,因此我们可以使用索引从序列中检索数据。在大多数编程语言中,索引从左侧开始计数。因为我们知道列表是代表性的序列数据模型,所以在本节中我们将使用以下列表对象作为示例。具体来说,这个列表对象存储了过去一年书店的月收入:
revenue_by_month = [95, 100, 80, 93, 92, 110, 102, 88, 96, 98, 115, 120]
假设我们想检索 11 月的记录。我们该如何做?在本节中,你将学习如何使用正负索引来解决从序列中检索数据的问题。正如你将看到的,Python 支持从右向左计数的索引(负索引),你将学习何时使用正索引或负索引。
4.2.1 正向索引从列表的起始位置开始
在 4.1 节中,您已经看到切片使用正索引来创建子序列。从一般的角度来看,就像在大多数其他语言中一样,我们根据它们的索引检索单个项目,从左边的 0 开始。在本节中,我们将回顾如何使用正索引。我知道你们大多数人熟悉这项技术,所以我会简要地讨论。使用正索引,要访问一月份的收入和第二个季度的收入,我们可以这样做:
revenue_jan = revenue_by_month[0] ❶
revenue_season2 = revenue_by_month[3:6] ❷
❶ 第一个项目的索引为 0。
❷ 使用索引 3、4 和 5 检索第四、第五和第六个项目。
如果我们检索列表末尾的项目,我们应该怎么做?例如,我们可能想检索十一月的收入和第四季度的收入。我们的第一个反应可能是以下内容:
revenue_nov = revenue_by_month[10] ❶
revenue_season4 = revenue_by_month[9:] ❷
❶ 十一月收入的索引为 10。
❷ 从索引 9 开始检索直到最后一个项目。
使用正索引和切片,我们必须分别数到 10 和 9,才能检索到所需的项目。当然,如果列表包含几十个项目,计数索引是可行的。但是,当有更多项目时,从开始计数以获取正确的索引可能会出错。难道没有更好的方法吗?下一节将讨论其中之一:负索引。
4.2.2 负索引从列表的末尾开始
幸运的是,Python 支持负索引。我们可以从左到右计数,也可以从右到左计数。在本节中,我们将看到当我们检索序列末尾附近的项时,负索引如何提高可读性。
在常规方式中,正索引的索引从第一个项目的 0 开始,到列表长度的减 1 结束。使用负索引,我们用-1 表示最后一个项目,-2 表示倒数第二个项目,-3 表示倒数第三个项目,依此类推。因此,第一个项目的负索引为-len(list)。负索引是一种非常巧妙的设计,因为它更直观。在日常生活中,我们通常从 1 开始计数,通过使用负号进行调整。图 4.3 显示了列表的正索引和负索引。

图 4.3 列中的正索引和负索引。正索引从左侧开始计数,初始计数为 0,而负索引从右侧开始计数,初始计数为-1。
让我们将负索引的特性应用于书店十一月收入和第四季度收入的检索:
revenue_nov_neg = revenue_by_month[-2] ❶
assert revenue_nov == revenue_nov_neg
revenue_season4_neg = revenue_by_month[-3:] ❷
assert revenue_season4 == revenue_season4_neg
❶ 十一月有一个索引为-2。
❷ 第四季度包括列表的最后三个项目。
如此例所示,我们得到了与使用正索引相同的结果。但负索引方法有三个优点:
-
节省时间。 我们只需要从列表末尾计数几个项目。
-
很简单。 我们从右边开始计数,第 n 个项有一个负索引为-n。我们不需要对以 0 开始的正索引进行心理调整。我们只需取反这个数字:2 -> -2。
-
很明显。 我们让代码的读者清楚地知道我们正在检索列表末尾的项,这是最重要的优势。
因此,每次你想检索接近序列末尾的项时,使用负索引总是一个好主意。
可读性 当你试图检索序列末尾附近的任何项时,很容易找到负索引。
4.2.3 根据需要组合正索引和负索引
正索引和负索引不是相互排斥的。对于序列中的每个项,正索引和负索引在它们的值不同的情况下是等效的,它们都指向相同的项,这使得我们可以在适用的情况下结合这两种索引。
假设你想要检索列表中间的项。你可以通过使用正索引和负索引来定义切片:
revenue_middle = revenue_by_month[1:-1] ❶
print(revenue_middle)
# output: [100, 80, 93, 92, 110, 102, 88, 96, 98, 115]
❶ 访问 2 月到 11 月的收入记录
4.2.4 讨论
当你检索序列末尾的项时,你应该使用负索引。本节可能对一些读者来说有些枯燥,但我决定包括它,因为序列数据模型在许多项目中都有应用。养成使用负索引来表示序列中最后一个或最后几个项的好习惯是至关重要的。这不仅使找到最后一个项的索引变得更容易,而且向读者清楚地表明代码关注的是序列末尾的项。像往常一样,可读性在任何代码库中都是关键。
4.2.5 挑战
杰弗里是一名中学生,他参加了学校的机器人队。他最近学习了序列的正索引。他知道可以使用列表的长度来计算接近末尾的项的正索引,并且他想编写一些代码来检索 11 月的收入;这段代码涉及到计算列表的长度。你能帮他吗?
提示 记住,正索引从 0 开始。因此,最后一个项的正索引比序列的长度少 1。
4.3 我如何在序列中查找项?
在 4.1 和 4.2 节中,你学习了序列数据类型的共有特征,如切片和索引。当我们有一个序列时,我们想知道特定项在序列中的位置。例如,在一个由任务组成的列表对象中,我们可能想知道是否有任何任务涉及完成调查。作为另一个例子,我们可能想知道任务的文本描述中是否包含术语作业。更普遍地说,在序列中查找项是一个常见任务,本节讨论了几个解决这一需求的方法。
4.3.1 检查项的存在
在序列中查找项的第一步是检查项的存在。本节讨论了这一主题。
许多编程语言,如 JavaScript,通过实现一个命名方法来检查项在序列中的存在:list.contains(item),list.includes(item) 或类似的方法。然而,Python 采用不同的方法来解决这个问题,使用 in 关键字。一般语法是 item in sequence,它返回一个布尔值以指示项是否存在于序列中。以下是一些示例:
assert (8 in [1, 2, 3, 4, 5]) == False ❶
assert ('cool' in 'Python is cool') == True
assert (404 in (404, 'Page Not Found')) == True ❷
❶ 需要括号。否则,等号 == 将首先被评估。
❷ == True 可以省略。我在这里包含它是为了清晰起见。
当你只对序列中特定项的存在感兴趣时,序列中的项功能很有用。但在需要知道项的确切索引的情况下,二进制的 True 或 False 就不够了。你可能需要使用搜索项的索引作为锚点并检索从锚点开始的子序列,例如。在这种情况下,你需要使用下一节中讨论的索引方法。
4.3.2 使用索引方法定位项
序列数据的另一个共同特点是支持索引方法,它返回项在序列中的索引。在本节中,你将学习如何使用索引方法来定位特定项。
以下代码片段展示了使用不同类型的序列数据的几个示例。正如你所见,所有序列数据都有索引方法:
[1, 2, 3, 4, 5].index(4)
# output: 3
(404, 'Page Not Found').index('Page Not Found')
# output: 1
'Python is cool'.index('cool')
# output: 10
默认情况下,索引使用基于 0 的正索引。当检查的项确实在序列中时,一切按预期工作,我们找到了项的索引。
注意:当序列中有重复项时,索引方法返回第一个匹配项的索引。
索引方法的一个许多人未能充分理解的缺点是,有时项并不包含在序列中。以下是一个例子:
[1, 2, 3, 4, 5].index(8)
# ERROR: ValueError: 8 is not in list
当引发异常时——在这个例子中,是 ValueError 异常——如果这个异常没有被处理,你的程序会崩溃。虽然你将在第十二章学习异常处理,但这里先快速看一下使用 try...except... 语句的解决方案:
def process_item_try(item):
try:
item_index = the_list.index(item)
except ValueError:
# do something when the item isn't present
# do something with the item_index
你可以用不同的方式编写这个代码片段,在找到索引之前先进行存在性检查,如下所示:
def process_item_check_first(item):
if item in the_list:
item_index = the_list.index(item)
# do something with the item_index
else:
# do something when the item isn't present
表面上,这两种方法做的是相同的工作,但我更喜欢第一种,因为它比另一种更高效。当我们使用索引方法时,Python 需要遍历序列以将每个项与它进行比较以找到匹配项,这是一个耗时的操作。同样,当我们检查项是否包含在序列中时,Python 也需要遍历序列。因此,当你使用 process_item_check_first 方法时,时间消耗预计会加倍,因为涉及到两次遍历,而 process_item_try 方法只涉及一次。因此,当序列较短时,两种方法都行得通,但当序列较长时,你应该使用第一种方法。
EAFP vs. LBYL
Python 中的一个广受尊重的原则是 EAFP(请求宽恕比请求许可更容易)。在这种模式中,你使用 try...except...,假设事情应该会按预期工作。如果出现问题,我们将相应地处理错误(宽恕)。
相比之下,另一个原则被称为 LBYL(先检查后跳)。这种模式在其他编程语言中更为普遍,例如 C 语言。在这种模式中,你首先检查适用条件,可能使用 if 语句(检查),只有在条件有效时才执行操作(跳过)。
4.3.3 在字符串中查找子字符串
作为序列数据类型,字符串支持 index 方法,正如你在第 4.3.2 节中看到的。此外,我们解决了与 index 方法相关的潜在 ValueError 异常。然而,与其他序列类型相比,字符串是特殊的,因为它们有两个额外的项查找方法:find 和 rfind。
这两种方法都返回搜索子字符串的索引。它们比索引方法更好的地方在于,当子字符串在字符串中未找到时,它们返回-1 而不是引发 ValueError 异常。因此,我建议你在搜索任何子字符串时使用 find 或 rfind,如下例所示:
def find_string(substr):
str_index = the_str.find(substr)
if str_index >= 0:
# do something with the str_index
else:
# do something when the substr isn’t present
请注意,find 方法仅适用于字符串。你不能用它与其他序列数据类型一起使用,尽管我认为在非字符串序列模型中实现此功能没有技术困难。
知识点 你可以使用 find 仅与字符串一起使用,而不能与其他序列数据类型一起使用。
4.3.4 在列表中查找自定义类的实例
当我们的项目规模扩大时,我们将使用自定义类作为我们的数据模型。在我们的项目中,我们使用列表来存储自定义类的多个实例。我们很可能会想知道特定实例是否存在于列表中。在本节中,你将学习如何定位自定义类的实例。
假设在我们的任务管理应用程序中,我们使用列表对象来存储一天的任务。以下列表是我们的起点。为了简单起见,Task 类有最小实现。为了提供概念证明,列表对象包含四个实例。
列表 4.2 创建自定义类对象的列表
class Task:
def __init__(self, title, urgency):
self.title = title
self.urgency = urgency
tasks = [
Task("Laundry", 3),
Task("Museum", 4),
Task("Homework", 5),
Task("Ticket", 2)
]
在我们的应用程序中,界面显示了这些任务的列表。我们应用程序的一个可能特性是突出显示与过滤标准匹配的任务行,例如紧急程度为 5 的任务行。为了实现此功能,我们需要知道具有所需紧急程度的任务的索引。正如你可能意识到的,我们不能使用 index 方法,因为我们事先不知道具有所需紧急程度的任务。因此,我们必须考虑不同的方法。因为我们感兴趣的是获取具有所需紧急程度的任务,所以我们可以遍历整个列表以找到潜在的匹配项。以下代码片段显示了可行的解决方案:
needed_urgency = 5
needed_task_index = None
for task_i in range(len(tasks)): ❶
task = tasks[task_i]
if task.urgency == needed_urgency:
needed_task_index = task_i
break ❷
print(f"Task Index: {needed_task_index}")
# output: Task Index: 2
❶ 请参阅第 4.3.6 节以获取一种替代技术。
❷ 使用 break 退出 for 循环
我们使用 for 循环遍历列表,将每个实例的紧急属性与所需级别进行比较。当找到任务时,我们使用 break 语句(见第 5.4.1 节)退出 for 循环并完成搜索。有了确定的索引,我们可以通过突出显示任务对应的行来更新我们应用程序的界面。
概念:break 语句立即退出当前循环。
4.3.5 讨论
在序列上调用索引只返回第一个匹配项的索引,因此请注意序列可能包含其他匹配项。由于索引方法如果项目不在序列中会引发 ValueError 异常,我们可以使用 try...except...语句(第 12.3 节)来处理异常。虽然我们可以检查特定项目的存在,但这种 LBYL 方法需要遍历序列两次,导致额外的开销。因此,使用 EAFP 方法以获得更好的性能是个好主意。
可维护性:尽可能采用 EAFP 模式,因为它通常比 LBYL 模式更高效。
4.3.6 挑战
在定位自定义类对象的示例中,具有所需紧急级别的任务索引为 2,即对象 Task("Homework", 5)。如果你运行代码:tasks.index(Task("Homework", 5)),你会得到结果索引 2 吗?
提示:尽管一些对象看起来具有相同的数据,但它们是不同的对象,具有不同的内存地址。你可以使用 id 函数来解释这些发现。
4.4 如何解包一个序列?超越元组解包
由于元组是不可变的数据容器,我们使用它们来保存多个对象,而不打算更改其内容。为了从元组对象中单独或连续地检索项目,我们已经学会了使用索引和切片(第 4.2 节和第 4.3 节):
task = (1001, "Laundry", 5)
task_id = task[0]
task_title = task[1]
task_urgency = task[-1]
在这个例子中,我们使用了三个单独的赋值来创建三个变量,每个变量对应于元组 task 的一个项目。如果元组对象有更多项目,我们需要进行更多赋值,这可能是一项繁琐的工作,会使我们的代码看起来忙碌且难以阅读。有没有更好的方法来使用相应的变量访问多个项目?
答案是解包技术。当它应用于元组时,它最著名的名称是元组解包技术。基本思想是我们将创建元组来保存数据的过程概念化为打包信息的过程。不出所料,相反的过程——检索项目——被称为解包。在本节中,你将学习这个重要的技术,主要关注元组对象。然而,请注意,解包不仅限于元组;它也适用于任何可迭代对象,包括序列数据类型。
4.4.1 使用一对一对应解包短序列
当我们处理包含少量项目并需要使用所有项目的元组时,我们使用一对一解包,其中每个项目都分配给匹配的变量:
task = (1001, "Laundry", 5)
task_id, task_title, task_urgency = task
print(task_id, task_title, task_urgency)
# output: 1001 Laundry 5
user_data = ("python_user", 35, "male")
username, age, gender = user_data
print(username, age, gender)
# output: python_user 35 male
使用这种一对一解包技术,我们使用一行代码创建了多个变量,这些变量对应于元组对象中的每个项。请注意,在前面的示例中,元组是首先创建的,模拟了我们项目其他部分创建元组对象的真实情况。
与一对一解包紧密相关的是多重赋值技术,其中我们通过共享单个赋值运算符(等号)来创建多个变量:
x0, y0 = (90, 20)
(x1, y1) = 90, 20
(x2, y2) = (90, 20)
assert x0 == x1 == x2 == 90
assert y0 == y1 == y2 == 20
前面的代码片段展示了多种多重赋值的形式。尽管它们的表面看起来不同,但它们执行的是相同的工作。在右侧,我们创建元组对象,在左侧,我们以相同数量的变量传递,这样项就可以一对一地解包。在这些赋值中,一个值得注意的特点是创建和解包元组时括号是可选的。以下代码片段显示了在字符串示例中补充前面的缺失排列:
x3, y3 = 90, 20
assert x3 == 90
assert y3 == 20
可读性:只有在变量紧密相关时才使用多重赋值。当变量服务于不同目的时,优先使用单独的代码行进行赋值。
4.4.2 使用带星号的表达式检索连续项
在前面的章节中,我们使用一对一解包技术检索了多个项,这对于包含少量项的元组效果很好。当元组包含更多项时,我们可能希望将一些项作为单独的变量检索,而将一些连续项作为一个变量检索。本节将向您展示如何做到这一点。
假设我们正在举办一场体操比赛,每位运动员由八位裁判评分。为了计算运动员的最终得分,我们去掉最低分和最高分,然后计算剩余六个分数的平均值。为了数据记录的目的,我们保存每位运动员的得分记录:最低分、中间分、最高分和最终得分。为了简化示例,假设得分已经按从低到高的顺序排序。当然,我们可以使用索引来生成这些得分记录,如下所示:
player_scores = [6.1, 6.5, 6.8, 7.1, 7.3, 7.6, 8.2, 8.9]
lowest0 = player_scores[0]
middles0 = player_scores[1:-1]
highest0 = player_scores[-1]
final0 = sum(middles0) / len(middles0)
我们不使用本节后面讨论的解包技术,而是使用多行代码逐个创建这些变量。这种方法不是从序列数据中创建多个变量的最 Pythonic 方式。不幸的是,如果我们尝试通过应用一对一解包技术的语法来解决问题,我们会遇到一个问题:
lowest1, middles1, highest1 = player_scores
# ERROR:ValueError: too many values to unpack (expected 3)
错误信息很明确:要解包的值太多。让我们更仔细地看看。在左侧,我们有三个变量,所以 Python 期望从元组中解包三个项。但是元组对象包含八个项,这导致了不匹配。我们如何解决这个问题?一个带星号的表达式就派上用场了:
lowest2, *middles2, highest2 = player_scores ❶
final2 = sum(middles2) / len(middles2)
assert lowest0 == lowest2 == player_scores[0]
assert middles0 == middles2 == player_scores[1:-1]
assert highest0 == highest2 == player_scores[-1]
❶ 使用带星号的表达式
你应该注意带星号表达式的几个特点:
-
带星号的表示法使用星号作为变量 (var_name) 的前缀。所有未由其他变量表示的项目都将被该变量捕获。在这种情况下,第一个和最后一个项目分别与 lowest2 和 highest2 相关联。中间的六个项目被 middles2 捕获。因此,一些 Python 用户将带星号的表示法称为 捕获所有项目的星号**。
-
带星号的表示法会产生一个捕获项目的列表 对象,无论原始序列的数据类型如何。我们可以通过以下代码片段观察此效果。不要犯错误,认为变量 b 是一个包含所有中间字符的 str 对象:
a, *b, c = "abcdefg" assert b == ['b', 'c', 'd', 'e', 'f'] -
列表对象中捕获的项目数量可以是零。 如果所有项目都使用正确的变量数量解包,留下零个项目来计算,则带星号的表示法将生成一个空列表。观察以下效果:
first_score, *scores, last_score = [9.1, 8.9] assert scores == [] -
*一个赋值只能使用一个带星号的表示法。尝试使用两个带星号的表示法是语法错误。原因很简单:带星号的表示法旨在捕获所有未计算的项目,因此当使用两个带星号的表示法时,无法确定哪个应该捕获哪些项目:
score0, *scores0, *scores1, score1 = [9.1, 8.8, 9.2, 7.7, 8.4] # ERROR:SyntaxError: multiple starred expressions in assignment
4.4.3 使用下划线表示不需要的项目以消除干扰
我们已经讨论了如何解包元组或列表以访问单个或连续的项目。在任何解包中,我们必须提供与序列中的项目相对应的正确数量的变量(如果需要,可以使用带星号的表示法)。但我们并不总是使用解包的项目。在这种情况下,我们应该在解包中使用下划线。
在我们的任务管理应用程序中,假设我们有一个返回包含四个项目的元组对象的 API,这四个项目是:任务的 ID、任务的标题、任务的描述和任务的状态。作为提醒,任务的 ID 在我们的应用程序中唯一标识一个任务。我们可以定义一个函数来更新我们的数据库中的数据,如下面的代码所示:
def update_status(t_id, t_status): ❶
# use task_id to locate the task in the database and update its status
pass
task = (1001, "Laundry", "Wash clothes", "completed") ❷
task_id, task_title, task_desc, task_status = task ❸
update_status(task_id, task_status)
❶ 数据库更新的实用函数
❷ API 返回一个包含四个项目的元组。
❸ 完全解包元组对象
在前面的代码片段中,我们以这种方式解包了元组对象,使得所有项目都与相应的变量相关联。通过进行一对一的解包,我们向读者传达了一个重要的含义:我们将使用每个解包的项目。然而,正如代码所示,我们只需要处理任务的 ID 和状态。
因此,完全解包,包括分配我们不需要的变量,是一个分散注意力的信号。为了消除这种干扰,我们应该使用下划线来表示这些不需要的项目,如下所示:
task_id, _, _, task_status = task
使用下划线的想法是,如果我们不需要一些变量,我们就不分配它们有意义的名称。以下功能与在解包中使用下划线相关联:
-
你可以使用尽可能多的下划线。 在我们的例子中,元组对象有四个项。由于我们只对两个项感兴趣,我们使用两个下划线加上 task_id 和 task_status 来解包这些项。
-
下划线是有效的变量名。 除了解包之外,使用下划线是 Python 用户中的一种约定,用来表示不需要的变量。尽管我们发出了我们不需要这些变量的信号,但如果我们选择这样做,我们仍然可以引用它们。在我们的例子中,_ 变量保存了任务描述,因为之前的任务标题(第一个 _)的赋值被覆盖了。
-
你可以在星号表达式中组合星号和下划线。 以下代码片段展示了示例:
task = (1001, "Laundry", "Wash clothes", "completed")
task_id, *_, task_status = task ❶
❶ 星号和下划线在星号表达式中的组合
可读性 在序列解包中,用下划线表示不需要的项,这表示我们不应该费心去使用这些项。
4.4.4 讨论
解包是检索序列中单个或连续项的最可读方式。我们应该彻底理解解包的各种技术。值得注意的是,我主要使用元组来展示解包是如何工作的,但你也可以将相同的解包技术应用于任何可迭代对象。当你学习到第五章关于可迭代对象的内容时,你可以尝试使用解包技术来处理任何可迭代对象。
4.4.5 挑战
丹尼正在做一个项目,在这个项目中,他使用解包技术从列表对象中提取数据。这些数据特殊之处在于他的项目中的列表对象有两层,例如[1, (2, 3), 4]。他是如何使用一行代码来解包这两层,提取这四个数字作为四个变量的?
提示 你可以使用括号在解包过程中创建层级。
4.5 我应该在什么情况下考虑使用列表和元组之外的数据模型?
在各种序列数据类型中,它们功能的多样性无疑使得列表和元组在许多常见情况下成为令人满意的数据容器。然而,当你转向特定项目时,你会发现列表和元组变得不那么理想。因此,你应该对其他可能适用于某些用例的数据结构持开放态度。在本节中,我将回顾一些常见场景和推荐的替代方案。
4.5.1 在关注成员资格时使用集合
我们经常需要检查数据容器是否包含正在检查的特定项,这种功能被称为成员资格检查。 对于列表和元组,我们已经了解到我们可以使用 _in_the_list 中的任何项来检查成员资格,或者使用索引方法作为确定列表是否包含特定项的间接方式。请注意,使用索引是不太可取的,因为当项不在列表中时,会引发 ValueError 异常。
虽然列表支持成员资格测试,但如果您的应用程序关注成员资格,您应该考虑使用集合。在第 3.5 节中详细说明,Python 要求集合中的所有项目都是唯一的,因为底层,集合是通过哈希表实现的,这提供了常数项查找时间的重要优势,称为 O(1)时间复杂度。相比之下,成员资格测试的查找时间是线性的,与列表的长度成正比,因为 Python 需要遍历序列以找到潜在的匹配项。列表中的项目越多,遍历的成本就越高。因此,当您的应用程序关注成员资格测试时,您应该使用集合。
问题 您还记得哈希表实现作为集合的存储机制吗?请参阅第 3.5 节。
4.5.2 如果您关心先进先出(FIFO)顺序,则使用双端队列
在某些应用中,我们希望我们的数据具有 先进先出(FIFO)功能。FIFO 强调首先添加到序列中的项目(先进)首先从序列中移除(先出)。在本节中,我们将看到当涉及 FIFO 时,一个更好的模型。
假设我们正在为企业构建一个在线客户聊天系统。在营业时间内,客户登记,我们使用列表来跟踪登记顺序。将首先登记的客户与客户支持同事连接起来是合理的,这代表了应用程序中的 FIFO 需求。一个可能的解决方案使用列表,如下所示。
列表 4.3 使用列表创建客户队列系统
clients = list()
def check_in(client):
clients.append(client) ❶
print(f"in: New client {client} joined the queue.")
def connect_to_associate(associate):
if clients: ❷
client_to_connect = clients.pop(0) ❸
print(f"out: Remove {client_to_connect}, connecting to
➥ {associate}.")
else:
print("No more clients are waiting.")
❶ 将新项目添加到列表的末尾
❷ 检查列表中是否有项目。当列表为空时,pop 会导致 IndexError。
❸ 从列表中移除第一个项目
在代码片段中,check_in 函数将新客户添加到等待队列的末尾,该队列是一个名为 clients 的列表对象。当有同事有空闲时,我们将队列中的第一个客户与同事连接起来。为了检索第一个客户,我们使用列表对象的 pop 方法。此方法不仅返回列表中的第一个项目,而且还移除它。
从列表对象的开头移除项目是重要的。在底层,Python 将列表中的每个项目都移动以调整内存中第一个项目的空位,这是一个具有 O(n)时间复杂度的昂贵操作。鉴于其相当大的复杂性,我们应该考虑一个替代方案:使用双端队列。
知识点 Deque 发音为“deck”,而不是“dee-queue”。
双端队列数据类型是一个双端队列。由于其双端特性,它支持从两端进行插入和移除,这使得它成为实现需要 FIFO(先进先出)的客户端聊天管理系统的一个完美数据类型。如前所述,在列表对象上调用 pop 方法在时间和内存方面都是一个昂贵的操作。相比之下,由于双端队列的两端都是开放的,从双端队列中移除最左边的元素是一个计算上微不足道的操作。图 4.4 展示了列表和双端队列之间的对比。

图 4.4 从列表中移除第一个元素与从双端队列中移除第一个元素的比较。从列表中移除最左边的元素需要移动所有剩余的元素,这使得它成为一个 O(n)的操作,而从不移除双端队列中最左边的元素则不需要对剩余元素进行任何操作,这使得它成为一个 O(1)的操作。
让我们对列表和双端队列在这个操作上进行直接比较。考虑以下用于从等待队列中移除第一个元素的简化设置。请注意,接下来的列表中包含了 lambda 函数的使用(第七章)。
列表 4.4 比较双端队列和列表的性能
from collections import deque ❶
from timeit import timeit ❷
def time_fifo_testing(n):
integer_l = list(range(n))
integer_d = deque(range(n))
t_l = timeit(lambda : integer_l.pop(0), number=n)
t_d = timeit(lambda : integer_d.popleft(), number=n) ❸
return f"{n: >9} list: {t_l:.6e} | deque: {t_d:.6e}"
numbers = (100, 1000, 10000, 100000)
for number in numbers:
print(time_fifo_testing(number))
# output something like the following lines:
100 list: 6.470000e-05 | deque: 3.790000e-05
1000 list: 7.637000e-04 | deque: 3.435000e-04
10000 list: 1.805050e-02 | deque: 2.134700e-03
100000 list: 1.641030e+00 | deque: 1.336000e-02
❶ 双端队列数据类型在标准库的 collections 模块中可用。
❷ timeit 函数计算表达式的平均执行时间。
❸ popleft 方法从双端队列的起始位置移除第一个元素。
在这个简单的例子中,使用双端队列而不是列表的性能提升是显著的,对于 10 万个元素来说,提升了两个数量级。对于企业应用来说,这种在单一方面的改进对于提升整体用户体验可能是至关重要的。需要注意的是,使用双端队列数据类型并不涉及任何复杂的实现。那么,为何不享受这种性能提升,而无需付出除了使用内置数据类型之外的任何代价呢?接下来的列表展示了使用双端队列的修改后的实现。
列表 4.5 使用列表创建客户端队列系统
from collections import deque
clients = deque()
def check_in(client):
clients.append(client)
print(f"in: New client {client} joined the queue.")
def connect_to_associate(associate):
if clients:
client_to_connect = clients.popleft()
print(f"out: Remove {client_to_connect}, connecting to
➥ {associate}.")
else:
print("No more clients are waiting.")
4.5.3 使用 NumPy 和 Pandas 处理多维数据
到目前为止,我们一直关注线性序列数据结构,如列表、元组和字符串。然而,在现实生活中,数据可以具有多维形状,例如图像和视频。例如,图像可以用数学上的三层(红色、绿色和蓝色)二维像素面板来表示。尝试使用基本数据模型来表示高维数据可能会是一场噩梦。幸运的是,Python 的开源特性促进了众多第三方库和包的开发,用于处理多维大规模数据集。因此,我们不应该使用列表,而应该考虑使用专为计算密集型工作设计的替代方案。
如果你需要处理大量数值数据,例如,你应该考虑使用 NumPy 数组,这是 NumPy 包中实现的核心数据类型。重要的是要注意,该包中提供了许多相关操作,例如重塑、转换和各种算术运算。
如果你需要处理类似电子表格的数据,并且数据类型混合(如字符串、日期和数字),你应该考虑使用 pandas DataFrame,这是 pandas 包中实现的核心数据类型之一。如果你进行机器学习,你需要使用张量,这是 TensorFlow 和 PyTorch 等主要机器学习框架中最重要的数据类型。如果你的应用程序处理大量多维数据,尤其是以数值形式,你应该利用这些第三方库,它们具有专门的数据类型和相关方法,以简化你的工作。
4.5.4 讨论
列表和元组是有用的有序项存储序列数据类型。现在,然而,我们知道基本替代数据模型。当然,这里涵盖的数据模型并不是一个详尽的列表。相反,我想传达的是,你应该对数据模型的选择持开放态度。决策必须由具体的业务需求驱动。
可维护性 总是选择适合不同目的的数据模型。使用不适当的数据模型可以使你的项目难以维护。
选择数据模型的基本原则是,你应该采取需求驱动的方法来选择最适合你应用程序特定组件的最佳数据模型。换句话说,你的应用程序应该包含尽可能多的不同数据模型,每个数据模型的选择都是为了解决特定的需求。图 4.5 提供了数据模型选择需求驱动方法的概述。

图 4.5 你选择的数据模型取决于你应用程序组件的具体需求。
4.5.5 挑战
Emma 是一位初学者数据科学家,她开始使用 Python 来处理她的项目。她明白她可以使用列表来存储一维数据,例如数字列表。但她的项目涉及嵌套在其他列表对象中的列表,以存储类似于四行三列电子表格的二维数据:
numbers = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]]
如果她想要将每个元素乘以 3,她应该怎么做?你可能注意到这项工作很繁琐。你能帮她想到另一个更合适的数据模型吗?
提示 NumPy 中的数组类型专门用于执行多维数值数据的操作。
摘要
-
你可以使用切片来检索子序列。使用切片时,你可以指定起始、结束和步长。请注意,切片支持多种调用方法,包括省略起始和结束索引。
-
我们使用切片从序列数据中创建子序列,而使用指定范围和步长的迭代则使用范围。
-
序列数据包括可变和不可变类型。我们可以通过切片操作来操纵可变类型,例如列表和字节序列,以替换、扩展、缩减和删除子序列。
-
在一个序列中,每个项目都有一个索引来指示其位置。正索引从左侧开始,以 1 为增量向右移动,起始值为 0;负索引从右侧开始,以-1 为增量向左移动,起始值为-1。
-
为了提高代码的可读性,我们应该养成在引用序列开头项目时使用正索引,在引用序列末尾项目时使用负索引的习惯。
-
我们需要了解检查序列中项目存在性的不同方法,并理解使用索引方法的局限性。对于字符串,我们应该使用 find 或 rfind 方法来定位子字符串。对于自定义实例,我们应该使用迭代来检查每个项目以查找可能的匹配项。
-
元组解包是从元组对象中提取项目的一个显著特性。这项技术适用于所有序列数据类型和其他类型的可迭代对象。但我们应该熟悉不同的解包方法,包括使用下划线和星号表达式。
-
列表并不是万能的解决方案。我们应该探索更适合解决特定业务需求的其他数据结构,例如用于多维数值计算的 NumPy 数组。
5 可迭代对象和迭代
本章涵盖
-
理解可迭代对象和迭代器
-
使用可迭代对象创建常见数据容器
-
使用列表、字典和集合推导式进行实例化
-
提高 for 循环迭代
-
在 for 和 while 循环中使用 continue、break 和 else
前几章多次提到了可迭代对象,我们知道列表、元组以及许多其他内置数据类型都是可迭代的。但我们还没有明确地定义可迭代对象的概念。我们说这些数据类型是可迭代的,但并没有讨论为什么。在本章中,你将了解到它们是如何构成可迭代的。更重要的是,我们将探讨如何通过使用构造器和推导式从其他可迭代对象中创建最常见的数据模型,例如列表和字典。
对于 Python 或其他任何编程语言来说,执行重复性工作的一个基本机制是 for 循环迭代(或 while 循环,其中 for 循环更为常见)。在每次迭代中,相同的操作可以应用于可迭代对象的每个项目。我们可以通过应用内置函数,如 enumerate 和 zip,以及使用可选语句,包括 break 和 continue,来有多种方法提高 for 循环的性能。在本章中,你将了解这些主题。
5.1 我如何使用可迭代对象创建常见的数据容器?
可迭代对象不应该对你来说是陌生人。第二章回顾了处理字符串的基本技术,字符串是由字符组成的可迭代对象。第三章讨论了几个内置数据容器,包括列表、元组、集合和字典,所有这些都是由单个项目(或键值对)组成的可迭代对象。第四章考察了序列数据类型之间的共有方法,所有序列数据类型都是可迭代的。正如你所看到的,可迭代对象在 Python 中很普遍。
事实上,可迭代对象是许多内置数据结构构建的重要基础类型。考虑以下场景。在任务管理应用中,你有两个独立的数据来源,一个是任务 ID 号,另一个是任务标题。你需要创建一个包含 ID-标题对的字典对象:
id_numbers = [101, 102, 103]
titles = ["Laundry", "Homework", "Soccer"]
desired_output = {101: "Laundry", 102: "Homework", 103: "Soccer"}
为了创建所需输出,初学者可能会想到使用 for 循环:
desired_output = {}
for item_i in range(len(id_numbers)):
desired_output[id_numbers[item_i]] = titles[item_i]
一个看似更高级的解决方案涉及字典推导式(第 5.2 节)和 zip 函数的使用:
desired_output = {key: value for key, value in zip(id_numbers, titles)}
然而,这些解决方案并不是最好的,因为它们没有利用到字典以及许多内置数据容器可以直接接受可迭代对象进行实例化的这一事实。本节首先回顾了什么是可迭代对象,然后继续讨论一个关键技术:通过使用可迭代对象来实例化常见内置数据容器。
实例、实例化、构造函数和构建
在面向对象编程(OOP)语言中,包括 Python,基本的数据模型是类,包括内置类如列表、字典和元组,以及我们在自己的项目中创建的自定义类。当我们创建属于类的对象,例如一个字典对象num_dict = dict(one=1, two=2)时,我们说我们创建了一个类的实例;因此,num_dict是dict类的一个实例。相关地,创建实例的过程被称为实例化。相同的实例化概念也适用于自定义类。
在实例化过程中,我们使用dict函数来创建字典对象,这种创建类实例的函数被称为构造函数。正如你可能看到或知道的,对于自定义类,构造函数是你定义的__init__函数。因为我们在实例化时使用构造函数,所以我们也可以将实例化称为构造。
注意:第八章更详细地介绍了实例化。
5.1.1 了解可迭代对象和迭代器
可迭代对象的使用不是一个孤立的主题;一个相关的关键概念是迭代器。迭代器是一种特殊的数据类型,我们可以通过称为迭代的过程从中检索其每个元素。可迭代对象和迭代器之间的关键联系是,在我们可以对它们执行任何迭代相关操作之前,所有可迭代对象都被转换为迭代器。
在底层,有两个函数在为我们做这件事:iter和next。图 5.1 显示了可迭代对象和迭代器如何通过三个步骤一起进行迭代:
-
使用
iter函数从一个可迭代对象中创建迭代器。迭代器被设计用来迭代可迭代对象的元素。 -
使用
next函数来渲染元素。在迭代器上调用next会检索下一个元素(如果有的话)。 -
使用
StopIteration异常来停止迭代。当没有更多元素可用时,调用next会导致StopIteration异常。

图 5.1 使用迭代器的迭代工作流程。迭代器通过使用iter函数从可迭代对象中创建。迭代器使用next函数来检索下一个项目(如果有的话)。当迭代器耗尽其项目时,会引发StopIteration异常。
为了说明迭代过程,考虑一个常见的可迭代对象,即列表对象,我们通过使用iter函数创建迭代器:
tasks = ["task0", "task1", "task2"]
tasks_iterator = iter(tasks)
tasks_iterator
# output: <list_iterator object at 0x000001F232ACEE50> ❶
❶ 在你的计算机上,内存地址将不同。
我们从一个列表对象tasks开始,通过调用iter函数创建一个迭代器list_iterator。我们可以使用next函数逐个检索迭代器的项目:
next(tasks_iterator)
# output: 'task0'
next(tasks_iterator)
# output: 'task1'
next(tasks_iterator)
# output: 'task2'
next(tasks_iterator)
# ERROR: StopIteration
正如你所见,每次我们在迭代器上调用next时,我们都会检索下一个项目,直到我们耗尽迭代器的项目并遇到StopIteration异常。
本节关于使用 iter 和 next 的讨论提供了迭代工作原理的机制概述。在我们的代码中,我们很少需要自己创建迭代器。相反,Python 在幕后为我们做了繁重的工作。以 for 循环为例,这是使用可迭代对象和迭代器最常见的形式:
for task in tasks:
print(task)
# output the following lines:
task0
task1
task2
我们在 for 循环中直接使用列表任务,无需担心创建迭代器,因为 Python 会自动处理。更重要的是,当列表迭代器耗尽时,for 循环会安全退出,因为异常已经被我们处理了。
5.1.2 检查可迭代性
为了更好地在我们的代码中使用可迭代对象,了解我们已覆盖的 str、list、tuple、dict 和 set 之外的数据类型是可迭代的至关重要。在本节中,你将了解如何确定特定对象是否是可迭代的。
从实际的角度来看,任何可以在 for 循环中使用的数据类型都是可迭代的。那么,确定一个对象的可迭代性的正式方法是什么呢?你可能从上一节推断出,如果一个对象可以通过 iter 函数转换为迭代器,那么它就是可迭代的。以下代码片段展示了对象(一个 int 对象与一个 list 对象)在可迭代性方面的不同行为:
iter(5)
# ERROR: TypeError: 'int' object is not iterable
iter([1, 2, 3])
# output: <list_iterator object at 0x000001F232A44700>
概念 可迭代性是指一个对象具有可迭代的特性,即它可以被转换为迭代器进行迭代。
除了检查对象的可迭代性之外,我们还应该了解除了 str、list、tuple、dict 和 set 之外,哪些常见数据类型是可迭代的。使用 iter 来确定可迭代性,我们可以得出下一列表中所示解决方案。第十二章将更详细地讨论 try...except...的工作方式。
列表 5.1 检查对象是否是可迭代的
def is_iterable(obj):
try:
_ = iter(obj) ❶
except TypeError:
print(type(obj), "is not an iterable")
else:
print(type(obj), "is an iterable") ❷
is_iterable(5)
# output: <class 'int'> is not an iterable
is_iterable([1, 2, 3])
# output: <class 'list'> is an iterable
❶ 使用下划线表示我们不使用返回结果
❷ 当没有 TypeError 异常时,else 子句会执行。
在列表 5.1 中,为了测试一个对象是否是可迭代的,我们尝试直接使用对象调用 iter 函数。当调用此函数成功时,对象是可迭代的;当调用失败时,对象不是可迭代的。使用 is_iterable 函数,我们可以对一系列内置对象进行测试,以确定哪些数据类型是可迭代的。表 5.1 显示了常见的内置可迭代对象。
表 5.1 常见内置可迭代对象及代码示例
| 数据类型 | 代码示例 | 迭代器类型 |
|---|---|---|
| str | "Hello" | str_iterator |
| list | [1, 2, 3] | list_iterator |
| tuple | (1, 2, 3) | tuple_iterator |
| dict | dict_keyiterator¹ | |
| set | set_iterator | |
| range | range(3) | range_iterator |
| map | map(int, ["1", "2"]) | map |
| zip | zip([1, 2], [2, 3]) | zip |
| filter | filter(bool, [1, None]) | filter |
| enumerate | enumerate([1, 2, 3]) | enumerator |
| reversed | reversed("Hello") | reversed |
(¹ 当迭代字典时,默认是迭代其键。以下两个操作是等效的:for key in dict 和 for key in dict.keys()。你可以迭代字典对象的值和项。有关更多信息,请参阅第 5.3.7 节。)
在表 5.1 中,你会注意到一些我还没有介绍的数据类型,例如 map 和 zip。第 5.1.3 节讨论了这些可迭代类型中的一些。
5.1.3 使用可迭代对象创建内置数据容器
在第二章中,我们学习了集合数据类型,包括列表、集合、元组和字典,也称为数据容器。在简单场景中,当涉及少量元素时,我们可以使用各自的字面量形式来创建数据。
如列表 5.2 所示,我们创建了一些数据容器,而没有使用它们的构造函数。相反,我们使用其特殊的语法要求来指定数据,例如列表对象使用方括号,集合对象使用花括号。这种实例化方法被称为使用字面量创建实例。
列表 5.2 使用字面量进行实例化
list_obj = [1, 2, 3]
tuple_obj = (404, "Connection Error")
dict_obj = {"one": 1, "two": 2}
set_obj = {1, 2, 3}
然而,当我们需要创建具有许多元素的容器数据时,使用字面量就不那么方便了。值得注意的是,这些集合数据类型都有自己的构造函数,使用相应的类名,并且它们可以接受可迭代对象来创建新的集合对象。以下列表显示了如何实现。
列表 5.3 使用可迭代对象进行实例化
integers_list = list(range(10)) ❶
assert integers_list == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
integers_tuple = tuple(integers_list) ❷
assert integers_tuple == (0, 1, 2, 3, 4, 5, 6, 7, 8, 9)
dict_items = [("zero", 0), ("one", 1), ("two", 2)]
integers_dict = dict(dict_items) ❸
assert integers_dict == {'zero': 0, 'one': 1, 'two': 2}
even_numbers = (-2, 4, 0, 2, 4, 2)
unique_evens = set(even_numbers) ❹
assert unique_evens == {0, 2, 4, -2}
❶ 调用列表构造函数
❷ 调用元组构造函数
❸ 调用字典构造函数
❹ 调用集合构造函数
如列表 5.3 所示,列表、元组、字典和集合构造函数可以接受一个可迭代对象来创建相应的对象。从可迭代对象创建对象的技术在现实生活中的项目中经常使用,当我们处理许多种类的可迭代对象且涉及的数据相关时。因此,我们经常利用这一特性从现有的可迭代对象中创建新的数据。
问题 字符串 是字符的可迭代对象。假设我们有一个 str 对象:letters = "ABCDE"。从 letters 中创建字符列表 ["A", "B", "C", "D", "E"] 的最佳方法是什么?
假设我们的项目有一个字符串列表对象,每个字符串代表一个浮点数:numbers_str = ["1.23", "4.56", "7.89"]。为了进行下一步的计算,我们需要将字符串转换为浮点数。我们可以通过使用 map 来实现这种转换,它将一个函数应用于可迭代对象的每个项目,并创建一个 map 迭代器:
numbers_str = ["1.23", "4.56", "7.89"]
numbers_float = list(map(float, numbers_str))
assert numbers_float == [1.23, 4.56, 7.89]
在前面的代码示例中,map 函数将内置的 float 函数(确切地说,是 float 构造函数)应用于每个字符串,然后列表构造函数使用创建的 map 迭代器来创建一个浮点数列表对象。
瞧,map 函数是一个高阶函数,它接受一个函数作为参数。更多内容请参阅第 7.2 节。
与其他数据容器相比,dict 类型的构造器是特殊的,因为它要求可迭代对象中的每个元素都由两个元素组成,键和值以配对的方式存在。除了使用包含两个元素的元组列表外,从现有的可迭代对象创建 dict 对象的一种常见方法是通过使用 zip 函数将两个可迭代对象连接起来。这种情况与我之前提出的是一样的:如何从两个列表对象创建 dict 对象。以下是解决方案:
zipped_tasks = dict(zip(id_numbers, titles))
assert zipped_tasks == {101: "Laundry", 102: "Homework", 103: "Soccer"}
这个操作的魔力在于 zip 函数将 id_numbers 和 titles 并排连接,形成一个 zip 迭代器,该迭代器生成由每个可迭代对象中的一个项目组成的元素。图 5.2 显示了 zip 函数的工作方式。

图 5.2 zip 函数从多个可迭代对象创建迭代器。在示例中,我们使用了两个可迭代对象。zip 函数将每个可迭代对象在相应位置的项连接起来。请注意,在 zip 函数中使用的可迭代对象的顺序很重要,因为创建的元组按照可迭代对象的顺序存储项。
在图 5.2 中,示例使用两个可迭代对象来创建迭代器:一个生成两个元素元组的 zip 对象。这两个元素的元组是 dict 构造器所需要的,第一个元素成为键,第二个元素成为相应的值。在实际项目中,你通常会使用 zip 函数来创建 dict 对象。
这个 zip 和那个 zip
zip 函数将两个或多个可迭代对象连接起来,每个可迭代对象向 zip 迭代器的元素贡献一个项目。大多数时候,你在 zip 函数中使用两个可迭代对象,这模仿了你现实生活中的夹克拉链的动作。因此,如果你对 zip 函数的作用感到困惑,想想你的夹克拉链是如何工作的:将两排牙齿连接起来,这些牙齿交替排列以形成一对。
你可能知道 zipping 是一个文件压缩概念。在 Python 中,zipfile 模块提供了与 zip 和 unzip 文件相关的功能。
5.1.4 讨论
除了 Python 的标准库之外,可迭代对象在第三方库中也被大量使用。例如,NumPy 中的 ndarray 和 pandas 库中的 Series 可以接受可迭代对象进行实例化。如果你的工作涉及数据科学,你会发现将数据在不同类型的可迭代对象之间转换非常方便。
5.1.5 挑战
作为一名有抱负的金融分析师,Ava 正在学习 Python 以用于她的工作。她对连接多个可迭代对象的 zip 函数很着迷,她想知道 zip 如何与多个可迭代对象一起工作。你能帮她编写一些代码来尝试 zip 三个可迭代对象吗?通常,可迭代对象中的项目数量是不同的。你能找出如果你使用 zip 来连接不同数量的可迭代对象会发生什么吗?
提示:两个可迭代对象在 zip 之后形成两个元素的元组。当一个可迭代对象比其他可迭代对象短时,当它的元素首先被用完时,较短的可迭代对象就没有东西可以贡献了。
5.2 列表、字典和集合推导式是什么?
如果你问一个中级 Python 程序员哪个特性是最 Pythonic 的,你可能会得到答案列表推导式,这是一种创建列表对象的简洁方法。以下代码片段展示了列表推导式看起来是什么样子:
numbers = [1, 2, 3, 4]
squares = [x * x for x in numbers]
assert squares == [1, 4, 9, 16]
如你所见,列表推导式看起来不像字面量,因为它没有直接列出项,但它也不像构造函数方法,因为它没有调用 list。列表推导式是 Python 的一个常用特性。Pythonic意味着它简洁且易于阅读(当然,前提是你了解这项技术)。除了列表推导式之外,还有字典和集合推导式可用于创建字典和集合对象。在下一节中,你将了解这些推导技术以及你应该避免的一些陷阱。
5.2.1 使用列表推导式从可迭代对象创建列表
我们使用不同类型的可迭代对象来存储各种数据。在项目中,我们通常需要将这些数据转换为列表对象。在本节中,你将学习如何使用列表推导式将可迭代对象转换为列表对象。假设在我们的任务管理应用程序中,我们有一个 Task 类实例对象的列表,如下所示。
列表 5.4 创建自定义类实例的列表
from collections import namedtuple
Task = namedtuple("Task", "title, description, urgency") ❶
tasks = [
Task("Homework", "Physics and math", 5),
Task("Laundry", "Wash clothes", 3),
Task("Museum", "Egypt exhibit", 4)
]
❶ 使用命名元组的自定义类
提示:命名元组是一种轻量级的数据模型,用于存储数据并支持点表示法。有关更多详细信息,请参阅第 3.3 节。
在我们的应用程序中,我们需要一个列表对象来获取这些任务的全部标题。一个不了解列表推导式的初学者可能会提出以下解决方案:
task_titles = []
for task in tasks:
task_titles.append(task.title)
assert task_titles == ['Homework', 'Laundry', 'Museum']
我们使用 for 循环遍历 tasks 中的项并检索它们的 title 属性,然后将它们追加到 task_titles 列表对象中。这个解决方案是可行的,但不是最有效或最 Pythonic 的方法。更好的方法是使用列表推导式:[expression for item in iterable],其中 expression 是对可迭代对象中每个项进行特定操作的表达式。表达式被评估后成为创建的列表中的项。以下代码片段展示了如何使用列表推导式提取任务的标题:
titles = [task.title for task in tasks]
assert titles == ['Homework', 'Laundry', 'Museum']
如此例所示,通过使用列表推导式,我们创建了一个包含所需数据的列表对象。该示例突出了使用列表推导式最显著的优点:简洁性。你不需要使用 for 循环,操作可以放在一行代码中。尽管一些初学者可能会觉得这项技术令人困惑,但当你对 Python 更加熟练时,你会发现列表推导式不仅简洁,而且易于阅读。
列表推导式或 map
我们使用列表推导式从一个现有的可迭代对象创建一个列表对象。值得注意的是,我们可以通过使用列表构造函数和 map 函数一起使用来创建相同的列表对象。例如,要获取标题列表,我们可以使用以下替代方案:
def get_title(task):
return task.title
titles = list(map(get_titles, tasks))
如第 7.1 节所述,我们还可以使用 lambda 函数来消除创建 get_title 函数的需要:titles = list(map(lambda x: x.title, tasks))。
如您所见,使用列表和映射创建列表对象通常比列表推导式更冗长;因此,它通常可读性较差。我建议您使用列表推导式而不是映射方法。尽管如此,有些人更喜欢映射方法,因为它代表了一种称为 函数式编程 的编码风格。这种风格侧重于编写和使用函数,而不是像面向对象语言那样关注对象。
5.2.2 使用字典推导式从可迭代对象创建字典
dict 是 Python 中另一种关键的数据容器类型。与列表对象一样,我们可以通过使用推导式创建字典对象:字典推导式。在本节中,我将快速介绍字典推导式,因为它与列表推导式在语法上只有细微的差别。原理相同,提供了一种从现有可迭代对象创建字典对象的简洁方法。
由于字典由键值对组成,字典推导式包括两个由冒号分隔的表达式,如 {expr_key: expr_value for item in iterable},其中 expr_key 计算为键,expr_value 计算为相应的值。另一个语法上的区别是,在字典推导式中使用花括号,而在列表推导式中使用方括号。
使用与我们的起点相同的列表对象任务,假设我们的应用程序需要一个字典对象,其中标题是键,描述是值。以下代码展示了我们如何通过使用循环和字典推导式来满足这一需求,以提供易于阅读的逐项比较:
title_dict0 = {}
for task in tasks:
title_dict0[task.title] = task.description
title_dict1 = {task.title: task.description for task in tasks}
assert title_dict0 == title_dict1
与非 Pythonic 的 for 方法相比,字典推导式更加简洁。对于经验丰富的 Python 用户来说,它也更容易阅读,因为通过阅读它,您可以知道标题成为键,描述成为值。这种清晰度是推导式作为 Python 中创建数据容器简洁技术的另一个优点。
5.2.3 使用集合推导式从可迭代对象创建集合
在第 3.5 节中,我们了解到当关注成员测试时,集合对象是完美的数据模型。因此,我们经常需要将其他可迭代对象转换为集合对象。我们可以通过集合推导式实现这种转换,{expression for item in iterable},其中表达式计算为集合的项。在本节中,您将了解集合推导式。
提示:由于底层实现使用哈希表,集合对象中的项查找需要恒定的时间,这种现象称为 O(1) 时间复杂度。
集合推导式使用花括号而不是方括号。在所有三种推导技术中,你可能注意到所使用的符号与它们的字面形式相同:[]用于列表,{:}用于字典,{}用于集合。因此,如果你对推导的符号感到困惑,想想它们的字面形式。
以下代码片段展示了与 for 循环方法相比,集合推导式创建集合对象时的简洁性。我们使用 task.title 来获取每个任务的标题,这些标题将添加到创建的集合对象中:
title_set0 = set() ❶
for task in tasks:
title_set0.add(task.title)
title_set1 = {task.title for task in tasks}
assert title_set0 == title_set1 == {'Homework', 'Laundry', 'Museum'}
❶ 创建一个空集合需要一个集合构造函数,因为空集合没有字面形式。
需要注意的一件事是,类似于集合构造函数(例如:set([1, 1, 2, 2, 3, 3]) = {1, 2, 3}),集合推导式会自动为你删除重复项,因为集合对象只存储唯一项,这是由于底层哈希实现的结果。也就是说,具有相同值(因此具有相同的哈希值;记住哈希函数的一致性)的对象在集合对象中只能有一个副本,如下例所示:
numbers = [-3, -2, -1, 0, 1, 2, 3]
squares = {x*x for x in numbers}
assert squares == {0, 9, 4, 1} ❶
❶ 集合对象中的项是无序的。
5.2.4 应用过滤条件
当我们遍历可迭代对象时,有时在执行操作之前需要评估项是否满足特定标准。在本节中,你将看到如何将过滤条件应用于推导技术。
假设对于 tasks 列表,我们只想生成紧急程度大于 3 的任务的标题列表。在这种情况下,我们应该通过使用 if 语句来过滤可迭代对象。一个对列表推导式一无所知的初学者可以使用常规的 for 循环来得出以下解决方案:
filtered_titles0 = []
for task in tasks:
if task.urgency > 3:
filtered_titles0.append(task.title)
assert filtered_titles0 == ['Homework', 'Museum']
在 for 循环中,我们检查每个迭代的任务紧急程度,并且只有当任务通过测试时才将其添加到列表中。但 Python 风格的解决方案是将 if 语句集成到列表推导式中:[expression for item in iterable if condition]。具体来说,我们将 if 语句添加到 iterable 之后以过滤适用元素:
filtered_titles1 = [task.title for task in tasks if task.urgency > 3]
assert filtered_titles0 == filtered_titles1
虽然相关的代码在这里没有展示,但 if 语句也可以在创建字典和集合对象时用于过滤不需要的项。如果你感兴趣,可以尝试这个功能。
5.2.5 使用嵌套 for 循环
当我们遇到嵌套数据时,我们可能需要从嵌套结构的每一层中提取所有元素。列表对象 tasks 代表一层数据,例如,每个元素是另一层数据,因为每个任务都有自己的存储数据。在本节中,你将学习如何使用嵌套 for 循环来提取嵌套数据的内部项。
我们将从非 Python 风格的方法开始,作为直接比较。当你使用 for 循环进行迭代时,你可能知道你可以在另一个 for 循环中嵌入一个 for 循环,如下所示:
flattened_items0 = []
for task in tasks:
for item in task:
flattened_items0.append(item)
assert flattened_items0 == ['Homework', 'Physics and math', 5,
➥ 'Laundry', 'Wash clothes', 3, 'Museum', 'Egypt exhibit', 4]
这种嵌套 for 循环的操作是有效的,因为 tasks 是一个包含 Task 实例的列表,每个 Task 实例都是一个命名元组——也是一种可迭代对象。列表推导式也支持相同的操作。也就是说,你可以在列表推导式中使用嵌套 for 循环。观察这个特性:
flattened_items1 = [item for task in tasks for item in task]
assert flattened_items0 == flattened_items1
在这段代码中,第一个 for 循环从列表对象 tasks 中提取每个任务,第二个 for 循环提取任务对象的每个项。这种语法可能会让一些初学者感到困惑,因为有两个 for 循环。我的建议是,他们应该像处理常规嵌套 for 循环一样阅读代码。第一个 for 指的是外层循环,第二个 for 指的是内层循环:[expression for iterable in iterables for item in iterable]。
从理论上讲,你可以有任意多的嵌套 for 循环。然而,从可读性的角度来看,我不建议使用超过两层 for 循环的推导式,正如我之前讨论的那样。
可读性 不要使用超过两层 for 循环。具有三层或更多 for 循环的列表推导式很难阅读。
5.2.6 讨论部分
第 5.2 节讨论了如何使用列表、字典和集合推导式作为创建列表、字典和集合对象的简洁方法。图 5.3 总结了这些技术。
你应该清楚何时应该使用推导式。也就是说,当你从一个可迭代对象开始,并希望创建列表、字典或集合类的实例时,这种情况可能是使用推导式的最佳时机。你知道我为什么说“可能”吗?有一些例外。

图 5.3 列表、字典和集合推导式的一般形式。每个推导式都涉及迭代一个可迭代对象,使用不同的语法进行推导,并创建相应的实例对象。
首先,如果你不需要操作可迭代对象中的项,你应该考虑直接使用它们的构造函数。你从一个列表对象开始,numbers = [1, 1, 2, 3],并希望创建一个集合对象,例如。虽然这样做并没有错,但你不应该使用集合推导式:{x for x in numbers}。相反,你应该使用集合构造函数,因为它直接接受一个可迭代对象并创建一个集合对象:set(numbers)。
其次,当推导式需要复杂的表达式或深层嵌套的循环时,使用传统的 for 循环方法会更好。假设你有以下推导式:
styles = ['long-sleeve', 'v-neck']
colors = ['white', 'black']
sizes = ['L', 'S']
options = [' '.join([style, color, size]) for style in styles
➥ for color in colors for size in sizes]
你不能说这段代码不可读,但你应该尽力使你的代码对大多数读者来说都是可读的。这里有一个替代方案:
options = []
for style in styles:
for color in colors:
for size in sizes:
option = ' '.join([style, color, size])
options.append(option)
与前面的解决方案相比,这个解决方案需要更多的代码行,但它清楚地展示了多层 for 循环,这使得代码更容易阅读和理解。
5.2.7 挑战
卢卡斯正在学习 Python,以便在物理学领域进行研究生研究。他已经意识到列表、字典和集合使用方括号和花括号。他想知道表达式(表达式 for 项 in 可迭代对象)能做什么。因为这个表达式使用了括号,而括号用于创建元组,所以这种方法是元组推导吗?尝试运行它,并告诉卢卡斯他将得到什么。
提示:如果这个过程是元组推导,我早就已经覆盖了。你可以使用 type 函数检查对象的性质。第 7.4 节涵盖了创建的对象。
5.3 我该如何使用内置函数改进 for 循环迭代?
在我们的项目中,大多数数据都期望以有组织的形式呈现。例如,在一个讨论论坛中,我们需要将帖子以标题在左侧、作者在右侧的形式排列。为了以清晰格式打印收据,我们需要逐个列出项目及其相应的价格。正如你所想象的,可以说每个项目都使用结构化信息,存储这种信息的普遍需求证明了 Python 中实现具有不同特性的各种可迭代对象的必要性。
对于结构化信息——帖子、有序项或项目中任何适用的数据——大多数情况下,数据是同质的,我们通常应用相同的操作。当你尝试将相同的操作应用于可迭代对象时,最好使用 for 循环,其形式如下(你应该熟悉它):
for item in iterable:
# the same operation goes here
了解这种基本形式是解决迭代相关问题的良好开端。但 Python 还有更多特性可以使 for 循环工作得更好。在本节中,你将学习 Python 对这些适用用例的实现。我会先展示一个非 Python 风格的解决方案作为起点,然后我会探索 Python 风格的解决方案。最后,我将简要解释这些函数和技术。
5.3.1 使用 enumerate 枚举项
许多可迭代对象是序列数据,例如列表和元组。每个项都有一个相应的索引——它在序列数据中的位置。我们经常想使用项的位置信息以及项本身的数据。在本节中,我解决这个需求,这被称为*枚举**。
假设我们的任务管理应用程序有一个 Task 类的实例对象列表。为了简单起见,Task 类是通过使用命名元组实现的,如下所示。
列表 5.5 创建自定义类实例的列表
from collections import namedtuple
Task = namedtuple("Task", "title description urgency")
tasks = [
Task("Homework", "Physics and math", 5),
Task("Laundry", "Wash clothes", 3),
Task("Museum", "Egypt exhibit", 4)
]
用例是我们想以编号列表的形式显示这些任务:
Task 1: task1_title task1_description task1_urgency
Task 2: task2_title task2_description task2_urgency
Task 3: task3_title task3_description task3_urgency
如果你考虑一个解决方案,你可能会注意到唯一缺失的信息是每个任务的计数器——即任务在任务列表中的索引。因此,你可能会有以下解决方案:
for task_i in range(len(tasks)):
task = tasks[task_i]
task_counter = task_i + 1
print(f"Task {task_counter}: {task.title:<10}
➥ {task.description:<18} {task.urgency}")
# output the following lines:
Task 1: Homework Physics and math 5
Task 2: Laundry Wash clothes 3
Task 3: Museum Egypt exhibit 4
可读性 在 f-string(在第 2.1.4 节中介绍),我们应用格式说明符,例如代码中使用的文本对齐,来格式化插值字符串。这种结构对齐提供了更好的字符串输出可读性。
该解决方案使用任务的长度创建一个范围对象。请注意,当你只向范围构造函数发送一个参数(len(tasks),即 3)时,它被解析为停止参数;因此,范围对象包含索引 0、1 和 2。
虽然这个解决方案适用于该用例,但一个更 Pythonic 的解决方案利用 enumerate 函数,它检索项目并为每个项目生成一个计数器:
for task_i, task in enumerate(tasks, start=1):
print(f"Task {task_i}: {task.title:<10}
➥ {task.description:<18} {task.urgency}")
enumerate 函数接受一个可迭代对象并创建一个 enumerate 类型的迭代器(表 5.1)。这个迭代器每次渲染一个元组对象:(item_counter,item),即项目的计数器和来自原始可迭代对象的项目。默认情况下,计数器与每个项目的索引匹配,因此第一个项目的计数器为 0。值得注意的是,enumerate 函数接受一个可选参数 start,允许你设置第一个项目的数字。在我们的例子中,我们希望从 1 开始计数,因此在 enumerate 函数中设置 start=1。
提醒 我们还使用了元组解包(第 4.4 节)。enumerate 迭代器中的每个项目都是一个元组对象。一对一解包创建了两个变量,task_i 和 task,以同时访问计数器和项目。
5.3.2 使用 reversed 反转项目
在本节中,我们以相同的可迭代对象开始:5.3.1 节中的列表对象 tasks。这次,我们希望在保持原始数据用于其他目的的同时,以相反的顺序显示任务。当你看到这个需求时,你可能想到从最后一个到第一个获取项目。这种想法可能会让你想到以下解决方案:
for task_i in range(len(tasks)):
task = tasks[-(task_i + 1)]
print(f"Task: {task}")
# output the following lines:
Task: Task(title='Museum', description='Egypt exhibit', urgency=4)
Task: Task(title='Laundry', description='Wash clothes', urgency=3)
Task: Task(title='Homework', description='Physics and math', urgency=5)
这个解决方案通过使用任务的长度创建一个范围对象。这个解决方案的一个特别之处在于使用负索引(第 4.2 节)以原始列表对象的相反顺序检索项目。因为负索引从最后一个项目的-1 开始,我们必须在取反索引之前将 task_i 加 1。正如你可以告诉的,从正索引中找出所需的负索引并不简单。在这个用例中,一个 Pythonic 的解决方案利用 reversed 函数,如下所示:
for task in reversed(tasks):
print(f"Task: {task}")
reversed 函数接受一个序列数据对象并返回一个反转对象。值得注意的是,反转对象是一个迭代器,它以原始列表对象的相反顺序渲染项目。与非 Pythonic 解决方案相比,该解决方案不需要处理任何索引。相反,我们使用 task 直接访问由 reversed 迭代器渲染的项目。这种无需任何索引转换的直接访问既干净又易于阅读。
5.3.3 使用 zip 对齐可迭代对象
当我们有多组迭代器来保存相同对象的单独信息时,我们希望执行需要所有迭代器信息的操作。在这种情况下,我们需要以某种方式连接这些迭代器。在本节中,您将了解如何使用 zip 函数连接迭代器。
这个用例的描述可能有些令人困惑。我将通过提供一个具体的例子来详细说明。除了任务列表对象之外,我们的应用程序还有两个列表对象——日期(任务到期时),以及位置(任务应该执行的地方):
dates = ["May 5, 2022", "May 9, 2022", "May 11, 2022"]
locations = ["School", "Home", "Downtown"]
我们希望向用户展示以下信息:每个任务的标题、其到期日期以及任务的位置。当您看到这个需求时,您可能会认为这些迭代器包含相同项的不同方面。您可能会观察到,这些迭代器之间的一个一致元素是给定索引处的信息与同一任务相关。因此,您可能会想出以下解决方案:
for task_i in range(len(tasks)):
task = tasks[task_i]
date = dates[task_i]
location = locations[task_i]
print(f"{task.title}: by {date} at {location}")
# output the following lines:
Homework: by May 5, 2022 at School
Laundry: by May 9, 2022 at Home
Museum: by May 11, 2022 at Downtown
因为我们知道索引是一致的元素,允许我们在这些迭代器中引用相同的任务,所以我们创建一个范围对象来获取索引。然而,如果您还记得,第 5.1.3 节讨论了如何使用 zip 来在创建字典对象时连接两个迭代器。正如那里提到的,zip 对象是一个迭代器,它渲染由对齐迭代器聚合的元组对象。以下是一个使用 zip 函数的解决方案:
for task, date, location in zip(tasks, dates, locations):
print(f"{task.title}: by {date} at {location}")
zip 函数接受多个迭代器(在我们的例子中,是三个),并将它们并排排列。作为一个迭代器,从这个函数调用创建的 zip 对象会渲染一个元组对象,该对象由迭代器提供的三个项组成。值得注意的是,与 enumerate 对象一样,您可以使用一对一的元组解包来同时创建任务、日期和位置,这显著提高了您代码的简洁性和可读性。(您会习惯这个功能,并发现它相当易于阅读。)
也可能相关的迭代器会有不同数量的项。默认情况下,zip 函数在最少项的迭代器耗尽后停止压缩。但如果你想让压缩与最多项的迭代器匹配,你可能想使用 zip_longest 函数,该函数在 itertools 模块中可用(见以下侧边栏)。
压缩不同数量项的迭代器
为了展示 zip 的工作原理,我仅使用了相同长度的迭代器。如果迭代器有不同的数量项会发生什么?
默认情况下,zip函数在包含最少元素的迭代对象耗尽时停止打包。例如,如果你运行zip(range(3), range(4)),你只会得到三个元组对象。有时,我们希望确保迭代对象具有相同数量的元素。为了强制这种一致性,Python 3.10 引入了可选参数strict,当设置为True时,指定迭代对象具有相同数量的元素。请注意,strict默认设置为False,因此使用旧版本zip函数的先前用法仍然有效。在不影响使用旧版本创建的代码的情况下发布新的软件版本被称为向后兼容性。
对于某些用例,我们希望将zip操作进行到包含最多元素的迭代对象耗尽为止。在这些情况下,我们应该考虑使用zip_longest函数,该函数存在于标准 Python 库中的itertools模块中。以下代码片段展示了它的用法。正如你所看到的,当较短的迭代对象耗尽时,Python 使用None作为填充项与较长的迭代对象的剩余元素进行打包:
>>> from itertools import zip_longest
>>> list(zip_longest(range(3), range(4), range(5)))
[(0, 0, 0), (1, 1, 1), (2, 2, 2), (None, 3, 3), (None, None, 4)]
5.3.4 使用链式连接多个可迭代对象
在zip函数中,在将各自的元素进行打包之前,可迭代对象是并排对齐的。但可能存在多个你想要以这种方式连接的可迭代对象,以便你可以按顺序检索它们的元素。也就是说,你希望连续使用这些可迭代对象而不是同时使用。在本节中,你将探索这个特性,它被称为*可迭代对象的链式连接**。假设除了tasks列表对象外,你还有一个保存你刚刚完成的任务的列表对象:
completed_tasks = [
Task("Toaster", "Clean the toaster", 2),
Task("Camera", "Export photos", 4),
Task("Floor", "Mop the floor", 3)
]
用例是你想显示所有已完成和待完成的任务标题。当你看到这个用例时,你可能会创建一个列表对象来连接tasks和completed_tasks,从而得到以下解决方案:
all_tasks = tasks + completed_tasks
for task in all_tasks:
print(task.title)
# output the following lines:
Homework
Laundry
Museum
Toaster
Camera
Floor
这个解决方案是可行的,但它涉及到创建一个中间列表对象。尽管当列表对象不大时通常不会出现这个问题,但如果必须处理多个大型列表对象,内存使用可能会引起关注。因此,一个更 Pythonic 的解决方案涉及使用chain函数:
from itertools import chain
for task in chain(tasks, completed_tasks):
print(task.title)
与zip_longest函数类似,chain函数也存在于itertools模块中。chain函数接受多个可迭代对象以创建一个迭代器,该迭代器聚合这些迭代对象的所有元素。因此,zip和chain函数都可以接受多个可迭代对象并以不同的方式将它们连接起来。图 5.4 显示了它们之间的差异。

图 5.4 zip和chain函数都接受多个可迭代对象。zip函数在每个索引处将迭代对象并排连接,而chain函数则按顺序连接迭代对象。zip迭代器生成包含来自每个迭代对象的元素的元组对象。chain迭代器按顺序生成来自每个迭代对象的元素。该图使用两个迭代对象作为示例,并且这两个函数可以接受超过两个迭代对象。
换句话说,多个可迭代对象的迭代是通过链式迭代器处理的,这不会增加非 Pythonic 解决方案中创建中间列表对象所强加的内存开销。
5.3.5 使用 filter 过滤可迭代对象
可迭代对象由多个项组成。然而,在某些情况下,我们只想处理满足我们需求的项的子集。在本节中,你将学习如何使用 filter 函数过滤可迭代对象。
假设我们想要显示紧急程度等级大于 3 的任务的详细信息。正如在第 5.2.4 节中的理解技术所示,我们可以在 for 循环中应用过滤条件:
for task in tasks:
if task.urgency > 3:
print(task)
# output the following lines:
Task(title='Homework', description='Physics and math', urgency=5)
Task(title='Museum', description='Egypt exhibit', urgency=4)
我应该说的是,这个解决方案是完美的,如果你想出了它,我会很高兴。但稍微好一点的方法(无论是否是 Pythonic,由你决定;参见第 5.3.6 节),是使用 filter 函数:
for task in filter(lambda x: x.urgency > 3, tasks):
print(task)
filter 函数接受一个应用于可迭代对象项的函数。每个项都会被该函数评估:如果为 True,则保留该项,如果为 False,则排除该项。在我们的例子中,我们使用了一个 lambda 函数,其中 x 指的是可迭代对象中的一个项。尽管我们在第 3.2 节讨论排序列表时看到了 lambda 函数,但它们在第 7.1 节中进行了详细讨论。现在,你可以将 lambda 视为一个返回表达式的值的常规函数——在我们的例子中,就是任务的紧急程度是否大于 3。
5.3.6 讨论部分
当你使用 reversed 时,你会创建一个具有与可迭代对象相同项但顺序相反的迭代器。你不应该将 reversed 与原地反转列表对象的 reverse 方法混淆。原地更改意味着此方法会更改原始列表对象并返回 None。因此,以下代码将无法运行!同样的区别也适用于 sorted 和 sort。前者创建一个排序后的列表对象,并且与 for 循环兼容。后者返回 None,与 for 循环不兼容:
tasks = ["task1", "task2", "task3"]
for task in tasks.reverse():
pass
从 Python 3.10 开始,zip 函数有一个可选的严格参数。将严格设置为 True 要求可迭代对象的长度必须相同;否则,zip 函数会在最短的可迭代对象耗尽时停止。正如你在第 6.1 节中看到的,将默认值设置给参数允许用户在函数调用时省略该参数。最显著的影响是,在旧代码库中,任何对 zip 函数的调用,例如 zip(list0, list1),即使你更新了 Python 到版本 3.10 仍然有效。该函数将被解释为 zip(list0, list1, strict=False),这不需要可迭代对象具有相同数量的元素,就像 Python 3.10 之前的旧 zip 函数一样。这种出色的设计支持向后兼容性。
可维护性 当你向现有代码库引入新功能时,最好有向后兼容性,这样你就不需要回去修复使用旧功能的代码。
对于更高级的迭代工具,请查看 itertools 模块,它提供了各种迭代相关功能,你可以探索——不仅仅是 zip_ longest 和 chain。例如,range 对象是一个可迭代对象,它返回整数而不是小数。值得注意的是,itertools 有一个 count 函数,它创建一个迭代器来生成均匀间隔的值,包括小数。
对于 filter 函数,有些人更喜欢将其视为 Pythonic 实现。但我觉得使用 filter 函数并没有比使用 if 语句有显著改进。对我来说,使用 if 语句更明确,因为它使关键逻辑操作(条件评估)作为一个单独的代码行突出显示。是否使用 filter 或 if 语句取决于你。
5.3.7 挑战
在 3.4 节中,你学习了可以使用 keys()、values()和 items()来访问字典的键、值或键值对。你知道它们是否都是可迭代的吗?如果你需要迭代键值对,最好的方法是什么?
提示:items 函数返回键值对作为元组对象,你可以使用元组解包从每个元组对象中检索键和值。
5.4 在 for 和 while 循环中使用可选语句
到目前为止,我已经讨论了如何使用 for 循环通过遍历可迭代对象来完成重复工作。除了 for 循环之外,我们经常使用另一个重要的控制流——while 循环,来执行重复工作。如果你不熟悉 while 循环,请看下面的示例。本质上,你在 while 关键字之后指定一个条件,并在每次迭代中评估该条件。当条件为 True 时,执行主体中的代码;在示例中,当 n 为 1 和 2 时执行。当条件为 False 时,退出 while 循环;在示例中,while 循环完成后 n 变为 3:
n = 1
while n < 3:
print(f"n's value: {n}")
n += 1
print(f"n's value after while loop: {n}")
# output the following lines:
n's value: 1
n's value: 2
n's value after while loop: 3
这些控制流在迭代过程中执行主体中的代码。但你不总是希望对所有元素完成迭代。假设我们有一周要完成的任务列表,如下一列表所示,我们想要优先处理紧急任务,因此我们需要找到第一个紧急级别为 5 的任务。
列表 5.6 通过创建列表查找紧急任务
from collections import namedtuple
Task = namedtuple("Task", "title, description, urgency") ❶
tasks = [
Task("Toaster", "Clean the toaster", 2),
Task("Camera", "Export photos", 4),
Task("Homework", "Physics and math", 5),
Task("Floor", "Mop the floor", 3),
Task("Internet", "Upgrade plan", 5),
Task("Laundry", "Wash clothes", 3),
Task("Museum", "Egypt exhibit", 4),
Task("Utility", "Pay bills", 5)
]
❶ Task 是通过使用命名元组创建的类。
如果我们尝试用 for 循环来满足这个需求,我们可能会得到以下解决方案:
first_urgent_task0 = None
counter, task in enumerate(tasks, 1):
print(f"---checking task {counter}: {task.title}")
if (task.urgency == 5) and (first_urgent_task0 is None): ❶
first_urgent_task0 = task
print(f"***first urgent task: {first_urgent_task0}")
# output the following lines:
---checking task 1: Toaster
---checking task 2: Camera
---checking task 3: Homework
---checking task 4: Floor
---checking task 5: Internet
---checking task 6: Laundry
---checking task 7: Museum
---checking task 8: Utility
***first urgent task: Task("Homework", "Physics and math", 5)
❶ 当任务紧急且 first_urgent_task0 未设置时设置值
提醒:enumerate 函数为可迭代对象创建一个计数器。
正如您所看到的,for 循环在完成所需工作之前会遍历整个列表对象。如果您查看列表,您会注意到您正在寻找的任务位于开头;如果您在 for 循环中必须等待迭代完成,直到列表对象耗尽,那么这将非常低效。为什么不在找到所需任务后退出 for 循环呢?幸运的是,您可以使用两个可选语句:break 和 continue 来更改默认的迭代行为。除了这两个语句之外,Python 还有一个独特功能,允许您在 for 和 while 循环中使用 else 语句。
在本节中,我回顾了这些语句的工作原理。更重要的是,我使用实际示例向您展示这些语句如何提高您 for 和 while 循环的可读性和效率。
5.4.1 使用 break 语句退出循环
在前面的用例中,我们需要在遍历整个可迭代对象之前退出 for 循环。我们通过 break 语句实现这一功能,它停止迭代并立即使执行退出循环。在本节中,您将学习如何使用 break 语句。首先,快速回顾一个简单的示例,从技术角度建立对 break 如何工作的基本理解:
for number in range(5):
print(f"Number: {number}")
if number == 2:
print("Breaking at 2")
break
# output the following lines:
Number: 0
Number: 1
Number: 2
Breaking at 2
您可以看到,当数字为 2 时,for 循环停止运行,这反映了 break 的作用,立即退出 for 循环。如果您想知道,break 语句在 while 循环中也是以相同的方式工作的:
number = 0
while number < 100:
if number == 2:
print("Breaking at 2")
break
else:
number += 1
print(f"Number: {number}")
# output the following lines:
Number: 1
Number: 2
Breaking at 2
将这两个示例结合起来,您应该观察到一般的用法模式:我们在 if 语句中放置 break 语句以检查特定条件。在迭代过程中,条件的评估可能会改变,当它评估为 True 时,break 语句以这种方式执行,使得循环立即终止。我已经在各种场合使用过 for 循环。为了给您不同的体验,图 5.5 描述了 break 在 while 循环中的工作原理。

图 5.5 在 while 循环中 break 语句的工作原理。while 循环头部的条件在每次迭代中都会被评估。当条件为 True 时,执行将移动到 while 循环的主体。在某个点上,我们放置一个 if 语句,在其中使用 break。当这个条件评估为 True 时,break 语句执行并结束 while 循环。如果条件为 False,它将回到 while 循环的头部并再次评估条件,以确定 while 循环是结束还是继续。
现在您已经了解了 break 的工作原理,下一步是解决之前介绍的实际用例。因为您只需要找到第一个紧急任务,检索所有紧急任务需要更长的时间,因为整个列表都在迭代。一个更好的解决方案是使用 break 语句,如下一个列表所示。
列表 5.7 通过使用 break 查找紧急任务
first_urgent_task1 = None ❶
for task in tasks:
if task.urgency == 5:
first_urgent_task1 = task
break
assert first_urgent_task0 == first_urgent_task1
❶ 设置初始值
如列表 5.7 所示,for 循环遍历任务并检查每个任务的紧急程度。当它找到一个紧急任务时,迭代立即结束,因为我们已经获得了所需的信息;任何额外的操作都是浪费时间。
可维护性 你想要给 first_urgent_task1 赋予一个初始值,我将它设置为 None。如果你不设置初始值,first_urgent_task1 被设置的唯一地方是在 if 语句的体内。很可能没有紧急任务,在这种情况下,first_urgent_task1 从未设置。尝试访问从未设置的变量会导致你的应用程序崩溃。
5.4.2 使用 continue 语句跳过迭代
当我们与可迭代对象一起工作时,我们可能只需要对满足特定标准的某些元素应用操作。你已经了解到你可以过滤可迭代对象(第 5.3.5 节)。但你也可以跳过不符合标准的元素的操作,这可以使代码更易读。在本节中,你将学习如何使用 continue 跳过特定元素的迭代。
与 break 一样,continue 语句通过跳过当前迭代并移动到下一个迭代来改变默认的迭代行为。以下是一个简单的 for 循环,展示了 continue 的作用:
for number in range(5):
if number < 3:
continue
print(f"Number: {number}")
# output the following lines:
Number: 3
Number: 4
对于前三次迭代,即 0、1 和 2,if 条件评估为 True;continue 语句执行,代码移动到下一个迭代,我们没有得到任何打印输出。直到数字变为 3,continue 语句才不执行,迭代继续到 print 函数调用。图 5.6 展示了 continue 的一般工作方式。

图 5.6 如何在 for 循环中使用 continue 语句。当迭代器生成一个项目时,for 循环体内的代码执行。在循环体中,if 语句中的条件被评估。当条件为 True 时,continue 语句执行并跳到下一个迭代。当条件为 False 时,for 循环体的执行移动到其他操作,直到执行移动到下一个迭代。当迭代器耗尽其项目并且 for 循环结束时,迭代停止。
考虑一个更实际的例子。假设我们需要对那些紧急任务应用一系列功能。如果不使用 continue 语句,我们可能会有以下实现来演示。请注意,下一列表中的代码无法运行,因为我们没有定义 do_something 方法。
列表 5.8 当满足条件时对项目应用多个操作
for task in tasks:
if task.urgency > 4:
result0 = task.do_something0()
result1 = task.do_something1()
if (result0 >= 0) and (result1 == "Hello"):
task.do_something2()
task.do_something3()
task.do_something4()
在这个例子中,我们只对紧急任务应用函数。换句话说,我们不需要对紧急程度等于或低于 4 的任务应用任何函数。因此,在这种情况下,你可以考虑使用 continue 作为替代实现,如下列表所示。
列表 5.9 当满足条件时跳过迭代
for task in tasks:
if task.urgency <= 4:
continue
result0 = task.do_something0()
result1 = task.do_something1()
if (result0 < 0) or (result1 != "Hello"):
continue
task.do_something2()
task.do_something3()
task.do_something4()
如果你比较列表 5.8 和 5.9 中的实现,你会发现主要区别在于两个地方使用了相反的评估条件。你可能想知道这两个实现有什么不同。从性能角度来看,它们没有区别,但可读性可能不同。当你需要对满足特定标准的项目执行一系列操作时,通常使用与 continue 语句(图 5.7)一起使用的互补评估条件会更易于阅读。

图 5.7 使用 continue 语句减少缩进层。如果没有 continue,for 循环需要两层缩进。相比之下,当使用相反的评估条件时,相同的 for 循环只需要一层缩进。
如图 5.7 所示,使用 continue 语句,我们可以减少所需的缩进级别。因此,我们的代码可读性更好,因为我们去除了深层嵌套的代码。比较列表 5.7 和 5.8。
5.4.3 在 for 和 while 循环中使用 else 语句
我们知道我们可以将 else 语句与 if 语句一起使用。本质上,if...else...语句通过检查条件来创建一个逻辑分支。当条件评估为真时,if 语句内的操作执行;否则,else 语句内的操作执行。值得注意的是,这两个语句的操作是互斥的,意味着只能运行其中一个。
在大多数编程语言中,else 语句仅存在于 if...else...语句中。Python 在这方面很特别;它允许我们在 for 和 while 循环中使用 else 语句。请注意,将 else 语句添加到 for 或 while 循环中并不是常见的做法,这可能会让许多 Python 程序员,尤其是初学者感到困惑。尽管你可能会谨慎地在 for 或 while 循环中使用 else 语句,但了解并使用这些特性在期望的使用场景中是有帮助的。在本节中,你将探索这些使用场景。
在 for 循环中使用 else
当你将 else 语句添加到 for 循环中时,它形成以下结构:
for item in iterable:
# some operations
else:
# some other operations
与 if...else...语句中 if 和 else 执行之间的互斥性不同,else 语句不会形成与 for 循环部分(或迭代部分)相反的分支。执行规则是 else 语句在迭代完成后只运行一次,但如果迭代因 break 语句而终止,则跳过 else 语句。下一列表中的代码显示了这一规则。
列表 5.10 for...else 语句的工作原理
def show_for_else_rule(breaking_number):
for number in range(2):
print(f"Iteration: {number}")
if number == breaking_number:
print(f"Break: {number}; Skip the else statement")
break
else:
print("Running the else statement")
print("Outside the for...else...")
show_for_else_rule(1)
# output the following lines
Iteration: 0
Iteration: 1
Break: 1; Skip the else statement
Outside the for...else...
show_for_else_rule(3)
# output the following lines
Iteration: 0
Iteration: 1
Running the else statement
Outside the for...else...
如您所见,决定是否跳过 else 语句的因素是 break 语句是否执行。简而言之:执行 break -> 跳过 else,没有执行 break -> 执行 else。因此,如果迭代不涉及 break 语句,则不需要添加 else 语句,因为它无论如何都会执行。换句话说,for...else...语句的有效用例之一是你需要在迭代部分包含一个 break 语句。
考虑一个实际用例。假设我们有一个任务列表,我们想要定位具有所需紧急水平的第一个任务。我们可能有一个使用 for...else...语句的解决方案,如下面的列表所示。
列表 5.11 for...else 语句的实际示例
def locate_task(urgency_level):
for task in tasks:
if task.urgency == urgency_level:
working_task = task
break
else:
working_task = None
print(f"Working Task: {working_task}")
locate_task(1)
# output: Working Task: None
locate_task(4)
# output: Working Task: Task(title='Camera',
➥ description='Export photos', urgency=4)
在列表 5.11 中,我们看到当迭代找到一个具有所需紧急水平的任务时,它会以跳过 else 语句的方式退出循环。然而,当所有迭代完成而没有触发 break 语句时,例如当所需的紧急水平为 1 时,else 语句被执行,并且我们得到任务为 None 的打印输出。
在 while 循环中使用 else
当您将 else 语句添加到 while 循环中时,它形成以下结构:
while the_condition:
# some operations
else:
# some other operations
与 for...else...语句一样,while...else...语句具有相同的执行规则:执行 break -> 跳过 else,没有执行 break -> 执行 else。图 5.8 显示了 for 和 while 循环的规则。

图 5.8 for 和 while 循环中 else 的工作方式。在迭代循环中,如果执行了 break 语句,则迭代立即结束,else 语句被跳过。如果没有执行 break 语句而正常结束循环,则执行 else 语句。
作为实际示例,假设我们希望在每次会话中完成一系列任务的同时休息。为了使我们的工作在会话中有效,我们设置一个休息阈值,即已完成任务的总体紧急水平的总和。下面的列表显示了使用 while...else...语句的可能实现。
列表 5.12 while...else 语句的实际示例
def complete_tasks_with_break(resting_threshold):
completed_urgency_levels = 0
while tasks: ❶
if completed_urgency_levels > resting_threshold:
print("Coffee break now!")
break
next_task = tasks.pop() ❷
print(f"Completed: {next_task}")
completed_urgency_levels += next_task.urgency
else:
print("Party! Completed all the tasks.")
tasks = [
Task("Toaster", "Clean the toaster", 2),
Task("Camera", "Export photos", 4),
Task("Homework", "Physics and math", 5),
Task("Floor", "Mop the floor", 3),
Task("Internet", "Upgrade plan", 5)
]
complete_tasks_with_break(7)
# output the following lines:
Completed: Task(title='Internet', description='Upgrade plan', urgency=5)
Completed: Task(title='Floor', description='Mop the floor', urgency=3)
Coffee break now!
complete_tasks_with_break(6)
# output the following lines:
Completed: Task(title='Homework', description='Physics and math', urgency=5)
Completed: Task(title='Camera', description='Export photos', urgency=4)
Coffee break now!
complete_tasks_with_break(5)
# output the following lines:
Completed: Task(title='Toaster', description='Clean the toaster', urgency=2)
Party! Completed all the tasks.
❶ 如果一个列表非空,则其评估结果为 True。
❷ pop 从列表对象中移除并返回最后一个元素。
可读性 当您检查数据容器或序列对象(如 str、list 或 dict)的空状态时,您最好使用对象本身,例如 if tasks 和 while tasks。在这些情况下,如果 tasks 有任何项,它将被评估为 True。相比之下,一种非 Pythonic 或可读性较差的方法是检查这些对象的长度,例如 if len(tasks) > 0。
complete_tasks_with_break 函数的前两次调用涉及运行 break 语句,以便跳过 else 语句。相比之下,第三次调用在未运行 break 语句的情况下完成迭代,因此 else 语句被执行。
5.4.4 讨论
你应该清楚何时使用 for 循环和何时使用 while 循环进行迭代。当你一开始就有可迭代对象,且迭代次数取决于可迭代对象可以渲染的项目数量时,使用 for 循环。相比之下,当你不确定将运行多少次迭代时,使用 while 循环,因为 while 循环会持续检查特定标准以确定何时结束。
避免在 for 循环和 while 循环中使用 else 语句,因为这种做法对大多数人来说都不熟悉,因此会令许多程序员感到困惑。我不建议在你的代码库中使用这个特性。我仅展示了这个技术,以防你看到其他程序员使用它。
可维护性 避免在 for 循环和 while 循环中使用 else 语句,这可能会造成混淆。
5.4.5 挑战
列表 5.7 将 first_urgent_task1 变量的初始值设置为 None。正如所述,设置此初始值很重要,因为如果你处理的是一组不同的任务,无法保证一定能找到紧急任务。假设你没有设置初始值,并使用一个不包含任何紧急任务的待办事项列表。看看当你尝试访问 first_urgent_task1 变量时会发生什么。
提示 如果一个变量没有被赋值,Python 没有办法确定你所说的那个变量是什么意思。
摘要
-
可迭代对象可以通过 iter 函数转换为迭代器。迭代器是数据对象,可以通过使用 next 函数逐个渲染它们的元素。
-
常见的数据容器,如列表、字典、集合和元组,可以通过各自的构造函数接受可迭代对象来创建它们各自的实例对象。因此,每当你有任何类型的可迭代对象时,如果你需要从现有可迭代对象创建数据容器,首先考虑使用这些构造函数。
-
列表、字典和集合推导式是创建列表、字典和集合对象的简洁方式,分别对应。它们消除了使用常规 for 循环进行实例化的需要。然而,如果你不操作这些项,你很可能可以直接使用构造函数进行实例化。
-
我们使用 for 循环在可迭代对象上执行迭代,它们是应用相同操作到存储在可迭代对象中的项目组的基本方式。为了使 for 循环更有效,你需要记住操作现有可迭代对象的高级方法,例如 enumerate、reversed、zip、chain 和 filter。在这些函数中,chain 是 itertools 模块的一部分,该模块具有额外的迭代高级操作。
-
for 循环和 while 循环都可以包含三个可选语句:break、continue 和 else。break 立即退出循环,continue 跳过当前迭代,而 else 在迭代循环中没有 break 语句时执行。你需要知道这些语句的正确用法。
第二部分 定义函数
在第一部分,我们学习了如何使用内置数据模型来表示应用中的现实问题。然而,将现实问题转换为适当的数据模型,这只是构建我们应用的第一步。这些数据就像原材料,我们必须使用适当的设备来处理这些原材料,遵循特定的协议来制造所需的产品。在我们的应用中,函数充当设备,函数的算法定义了协议。正如你可以想象的那样,如果我们没有必要的设备和协议(函数及其实现细节),我们就无法处理任何原材料(数据)。在本部分,你将学习编写函数的各种技术——这是任何应用数据流背后的驱动力。
6 定义用户友好函数
本章涵盖
-
为函数设置适当的默认参数
-
设置和使用函数的返回值
-
将类型提示应用于参数和返回值
-
定义具有可变数量位置参数和关键字参数的函数
-
为函数创建适当的文档字符串
在前几章中,你已经看到了几个函数的例子。总的来说,无论我们的应用是什么,我们都会定义一系列函数来执行各种操作,例如进行计算和格式化字符串。当你在一个团队环境中工作时,你经常需要定义允许团队成员重用你代码的函数。当你发布一个 Python 包时,该包应包括为用户定义的良好函数,就像标准 Python 库提供的内置函数一样。因此,定义用户友好函数是一项基本技能;即使你独自工作,你也不想函数难以使用。
当我说用户友好函数时,我指的是易于理解、具有适当的参数类型提示且便于调用的函数,可能使用默认参数。对于那些自解释的函数,用户可以找到所需的帮助信息,通常以文档字符串的形式提供。
在本章中,你将学习构建用户友好函数背后的关键技术。当我们第十四章构建自己的任务管理应用时,你会看到所有这些技术的应用,突出函数在任何一个项目中的重要性。
6.1 如何设置默认参数以简化函数调用?
根据具体要求,函数可以接受零个或多个参数。对于函数来说,调用参数较少的函数更容易;理想情况下,一个函数如果不需要任何参数,调用起来最简单。当一个函数有多个参数时,我们可以通过设置默认参数来减少函数调用所需的参数数量。
在函数中设置默认参数的最大优势是便利性。当默认参数正好是我们需要的时,我们不需要设置参数。此外,函数需要灵活性,这样我们仍然可以通过设置适用的参数来覆盖默认值。在本节中,你将学习如何设置默认参数。
6.1.1 使用默认参数调用函数
在函数中设置默认参数是一种常见的简化函数调用的技术,在标准 Python 库中很普遍。在本节中,让我们快速看一下一些用例,以获得使用默认参数调用函数的便利性的第一手经验。
尽管在前几章中我们没有明确讨论默认参数,但我们已经多次利用了这一特性。例如,在第 3.2 节中,我们讨论了如何在列表对象上使用排序方法,如下面的代码片段所示:
numbers = [4, 5, 7, 2]
numbers.sort()
assert numbers == [2, 4, 5, 7]
当我们想要按降序对数字列表进行排序时,我们通过设置 reverse 参数来调用 sort 方法:
numbers.sort(reverse=True)
assert numbers == [7, 5, 4, 2]
让我们检查一下 sort 方法的头部:sort(*, key=None, reverse=False)。你会注意到参数 key 和 reverse 有默认值:None 和 False。这些参数的默认值通常被称为 默认参数。
事实趣闻:sort 方法中的星号指示所有跟在星号后面的参数都应该使用它们的参数名来设置,例如 numbers.sort(reverse=True)。相比之下,numbers.sort(True) 是一个无效的调用。这种技术被称为 *设置关键字参数**。有关更多信息,请参阅第 6.4.1 节。
当 Python 的核心开发者定义 sort 方法时,他们知道当我们对一个列表对象进行排序时,在大多数情况下我们使用字典序或数值序,并且我们希望项目按升序排列,因此他们为 key 和 reverse 参数提供了 None 和 False 作为默认参数。当我们对一个列表对象使用 sort 时,我们通常使用 the_list.sort(),这被解释为 the_list.sort(key=None, reverse=False),因为函数定义中设置了默认参数。
6.1.2 定义具有默认参数的函数
具有默认参数的函数不仅易于调用,而且灵活,支持多种使用场景。在本节中,你将学习定义具有默认参数的函数的一般过程。
当我们最初定义一个函数时,它通常通过接受一个或多个参数来执行一个特定的目的。假设在我们的任务管理应用程序中,当用户完成任务时,我们更新任务的状态。我们可以有一个以下函数:complete_task。请注意,这个函数应该被定义为实例方法(第 8.2 节)。在这里,我将其定义在 Task 类外部,以便于方便地调用它:
class Task: ❶
def __init__(self, title, description, urgency):
self.title = title
self.description = description
self.urgency = urgency
def complete_task(task):
task.status = "completed"
print(f"{task.title}'s status: completed")
task = Task("Homework", "Physics and math", 5)
complete_task(task)
# output: Homework's status: completed
❶ 定义一个自定义类
窥视:在这里,我们使用自定义类而不是基于命名元组的模型。自定义类使我们能够改变实例对象的属性,这是我们不能使用命名元组模型(第 3.3 节)做到的。定义自定义类的内容在第八章中介绍。
当用户完成任务后,我们将其状态更新为“已完成”,这是该函数执行的一项操作。后来,我们意识到我们可能希望用户为任务添加一个完成备注——也就是说,我们需要扩展函数的功能。有了这个新增的功能,我们的函数已经演变为以下版本:
def complete_task(task, note):
task.status = "completed"
task.note = note
print(f"{task.title}'s status: completed; note: {note}")
更新此函数后,我们对我们的决定感到满意,但我们认识到两个问题。首先,我们需要更新我们调用 complete_task(task) 的旧代码,因为它缺少一个参数。其次,在大多数其他地方,我们需要更新状态,而不必担心设置任何备注,如下所示:
# Use case 1
complete_task(task1, "")
# Use case 2
complete_task(task2, "")
# Use case 3
complete_task(task3, "")
如你所见,我们正在使用一个模式来使用函数,将空字符串作为笔记,这可能会让你想起 DRY(不要重复自己)原则:当你重复某事时,很可能你应该重构你的代码。在这种情况下,我们主要设置笔记为空字符串,这是在函数定义中设置默认参数的完美用法,处理大多数用例的自动参数设置:
def complete_task(task, note=""):
task.status = "completed"
task.note = note
print(f"{task.title}'s status: completed; note: {note}")
使用更新后的功能,当我们不需要设置笔记时,我们可以简单地运行
complete_task(task)
除了省略参数的便利性之外,最重要的是更新函数不会破坏使用相同任务参数调用相同函数的旧代码。由于更新后的函数定义中的默认参数,你旧代码中的这个函数调用 complete_task(task) 会自动解释为 complete_task(task, "")。
可维护性 当你更新你的函数时,最好保持相同的调用签名,这样现有的代码仍然可以在没有任何更新的情况下工作。
为了提供一个系统性的概述,图 6.1 展示了通过使用默认参数将具有单一功能的函数演变到具有多个功能的函数的一般过程。在图中,我们定义了两个角色:应用程序编程接口(API)开发者,定义函数的人,以及 API 消费者,使用该函数构建应用程序的人。当然,根据团队的大小,这些角色可以由不同的人处理。然而,在较小的项目中,同一个人很可能同时扮演这两个角色。

图 6.1 创建具有默认参数的函数的一般过程示例。当 API 开发者从消费者那里收到反馈时,他们会添加所需的参数,以便消费者可以设置笔记。后来,消费者意识到将空字符串设置为笔记很繁琐,并要求开发者更改 API。开发者使用默认参数功能更新 API,消除了在空字符串是期望的参数时设置笔记参数的需要。
从消费者的角度来看,使用默认参数调用函数允许他们省略设置参数,这些参数将自动默认为预设值。从开发者的角度来看,当你简化定义函数的调用时,由于参数数量的减少,消费者犯错的几率更小。因此,你在两个方面提高了消费者的体验:
-
你正在为现有函数提供额外的功能。 函数具有更多的灵活性,具有多个功能。
-
你正在确保使用旧调用签名的现有代码仍然有效。 缺少的参数将使用默认值进行解释。
6.1.3 避免为可变参数设置默认参数的陷阱
在 6.1.2 节中,你学习了设置默认参数的理由以及使用默认参数的函数的演变过程。我们的例子涉及设置字符串类型的默认参数。如第三章所述,字符串是不可变的。另一类数据模型是可变的,如列表和字典。在本节中,你将学习为可变参数设置正确的默认参数。
正确的术语是:参数还是参数?
术语参数和参数似乎可以互换使用,以指代函数中使用的变量。然而,这里存在一个细微的区别。当我们定义函数时,我们将函数头部中指定的变量称为参数。当我们调用函数时,我们将使用的变量称为参数。换句话说,参数是函数定义中使用的变量。相比之下,参数是函数调用中使用的变量。
假设当我们完成任务时,我们可以选择性地将任务添加到我们跟踪的任务组中。我们可能有一个以下的工作版本作为起点:
def complete_task(task, grouped_tasks=[]):
task.status = "completed"
grouped_tasks.append(task.title) ❶
return grouped_tasks
❶ 仅使用标题以简化。
我们将一个空列表对象设置为 grouped_tasks 参数的默认参数。我们的意图是,如果我们通过省略 grouped_tasks 参数来调用此函数,将创建一个空列表对象。你可以在下一个列表中观察到结果。
列表 6.1 使用具有可变默认参数的函数
task0 = Task("Homework", "Physics and math", 5)
task1 = Task("Fishing", "Fishing at the lake", 3)
work_tasks = complete_task(task0)
play_tasks = complete_task(task1)
print("Work Tasks:", work_tasks)
print("Play Tasks:", play_tasks)
# output the following lines:
Work Tasks: ['Homework', 'Fishing']
Play Tasks: ['Homework', 'Fishing']
如列表 6.1 所示,对于每个调用 complete_task 函数且省略 grouped_tasks 的情况,我们希望有一个新的列表对象来保存完成的任务。然而,令人惊讶的是,这两个列表对象具有相同的项,尽管我们预计 work_tasks 和 play_tasks 分别应该是['Homework']和['Fishing']。如果你仔细观察这两个列表对象,你会发现它们是同一个对象:
assert work_tasks == play_tasks
assert work_tasks is play_tasks ❶
❶ is 比较两个变量是否引用同一个对象。
这种现象的潜在原因是 Python 在定义函数时对其进行评估。评估有一个副作用:任何可变默认参数都是在评估期间创建的,并成为函数的一部分。在我们的例子中,当函数被评估时创建了一个列表对象。现在,这个特定的列表对象在每次调用函数且未提供 grouped_tasks 参数时用作 grouped_tasks 参数,如下一个列表中的代码所示。
列表 6.2 使用函数中定义的相同可变对象
def append_task(task, tasks=[]):
tasks.append(task)
print(f"Tasks: {tasks}; id: {id(tasks)}") ❶
append_task.__defaults__ ❷
# output: ([],)
id(append_task.__defaults__[0])
# output: 4356663616
append_task("Homework")
# output: Tasks: ['Homework']; id: 4356663616
append_task("Laundry")
# output: Tasks: ['Homework', 'Laundry']; id: 4356663616
append_task.__defaults__
# output: (['Homework', 'Laundry'],)
❶ 一个 id 函数返回内存地址,该地址唯一地标识一个对象。
❷ defaults 检索与函数关联的默认对象。
在列表 6.2 中,我们使用内置的 id 函数来检查对象的内存地址。当我们处理相同的对象时,id 函数返回相同的内存地址。正如你所见,当我们调用函数而不指定 tasks 参数时,我们得到的是从函数定义中创建的相同对象。
CPython 和 id 函数
当你编写 Python 代码时,代码会在你的电脑(机器)上执行。值得注意的是,Python 代码本身并不直接与你的机器通信。相反,代码必须在执行之前被编译成字节码。Python 代码的编译有不同的实现方式。其中,最普遍的是 CPython,它是 Python 的原始实现,也是你可以从 Python 官方网站下载的实现。其他实现,如 Jython,将 Python 代码编译成 Java 字节码。
在 CPython 中,id 函数返回对象在该时刻的内存地址。因此,如果你在不同的代码中或在不同的机器上运行 id 函数,你应该预期内存地址会有所不同。相关地,其他 Python 实现可能为 id 函数使用不同的标识符。
如果我们不能将[]或 list()用作列表参数的默认值,我们可以使用什么?这难道意味着我们不能为可变参数设置默认值吗?答案是否定的。常见的做法是将 None 用作可变参数的默认参数。下一个列表显示了期望的模式。
列表 6.3 使用 None 作为可变参数的默认值
def complete_task(task, grouped_tasks=None):
task.status = "completed"
if grouped_tasks is None: ❶
grouped_tasks = []
grouped_tasks.append(task.title)
return grouped_tasks
complete_task.__defaults__
# output: (None,)
❶ 当我们比较一个对象与 None 时,使用 is 而不是==。
如你所见,该函数的默认参数是 None。在函数体中,我们检查 grouped_tasks 参数是否为 None,如果是,则创建一个新的列表对象。每次我们调用此函数而省略 grouped_tasks 参数时,该函数都会为我们创建一个新的列表对象,这正是我们期望的行为。
可维护性 当你在函数中为可变参数设置默认值时,将其设置为 None。
6.1.4 讨论
在函数定义中设置默认参数是 Python 标准库中广泛使用的一种模式。除了 sort 方法外,许多内置函数,如 sorted 和 print,也包括默认参数。有了默认参数,这些函数很容易调用;如果我们设置不同的参数,它们也保持了灵活性。你应该注意可变参数和不可变参数之间的区别。当你为可变参数设置错误的默认值时,你可能会在你的代码库中引入错误。
6.1.5 挑战
科里在大学里教授 Python 编程。他希望向他的学生展示默认参数是在函数定义时而不是在函数调用时被评估的。你能帮他想到另一种支持这一论点的办法吗?
提示 创建一个时间戳来检查函数定义和调用期间发生了什么。以下代码允许你检索时间戳:
from datetime import datetime
timestamp = datetime.today()
6.2 如何在函数调用中设置和使用返回值?
我们定义函数来执行特定的操作。要使用这些函数,我们需要通过传递适当的参数来调用它们,这些参数是函数的输入。当函数完成其操作后,它返回一个值,这是函数的输出。到目前为止,你应该知道函数在你应用程序中的重要性;因此,不仅需要处理输入的技能(如设置默认参数,在 6.1 节中介绍),还需要处理输出的技能。在本节中,我们将专注于研究如何设置返回值以及如何使用它。
6.2.1 隐式或显式地返回值
在我们的例子中,我们使用了许多内置和自定义函数。有些函数返回一个值;而有些函数看起来没有返回值。在本节中,我将展示每个 Python 函数都返回一个值,尽管有时是隐式的。
内置的 sum 函数计算可迭代对象的求和值。不出所料,返回值是可迭代对象中项的总和:
numbers = list(range(5))
sum_numbers = sum(numbers)
print(f"Sum of {numbers} is {sum_numbers}")
# output: Sum of [0, 1, 2, 3, 4] is 10
在 3.2 节中,我们学习了如何使用 sort 方法对列表对象中的项进行排序。值得注意的是,sort 方法是在原地排序列表对象,这意味着 sort 会改变原始列表对象。相关地,如果你检查 sort 的返回值,你会发现它是 None:
primes = [5, 7, 2, 3, 11]
sort_return_value = primes.sort()
print(f"Return value of sort: {sort_return_value}")
# output: Return value of sort: None
通过这两个例子,我们应该意识到每个函数都返回一个值,并且我们应该清楚函数返回了什么:None 还是其他值。不要假设函数返回了什么,因为当你尝试链式调用方法时,你可能会犯愚蠢的错误。以下有问题的代码试图对 primes 列表进行排序,并将 13 添加到末尾:
primes.sort().append(13)
问题 你知道为什么这段代码无法运行吗?检查 sort 返回什么。
6.2.2 定义返回零个、一个或多个值的函数
要理解函数如何返回值,最好的方法是定义函数,以便你对它们的行为有细粒度的控制。一般来说,根据函数返回值的数量,有三种情况:零个、一个和多个。
返回零值
严格来说,我们不能定义不返回值的函数。正如 6.2.1 节中讨论的,每个函数都有一个返回值,无论是隐式还是显式。当我们定义一个不返回任何内容的函数时,它仍然会被评估为返回 None。考虑以下示例:
def append_task(task, grouped_tasks):
grouped_tasks.append(task)
appended_no_return = append_task("Homework", [])
print(f"Appended: {appended_no_return}")
# output: Appended: None
如我们所见,函数定义中没有返回语句。但当我们检查返回值时,appended_no_return 的值是 None。这个结果与 6.2.1 节中的讨论一致。图 6.2 展示了定义不显式返回变量的函数的一般模式。

图 6.2 函数隐式返回。当一个函数没有返回语句时,它等同于返回 None 的函数。
问题 当函数有一个裸的返回语句时,它的返回值是什么?
返回一个值
返回值是函数最常见的形式。你应该知道,函数被定义为执行特定的操作。通常,我们期望从操作中有一个输出值,因为它消除了关于函数目的的歧义。因此,在大多数情况下,你应该让你的函数只返回一个值。
是时候快速回顾一下将函数的返回值分配给变量的过程了——这是调用函数最常见的形式。考虑以下场景:
def say_hello(person):
hello = f"Hello, {person}!"
return hello
greeting = say_hello("Rocky")
这个代码片段展示了常见的用法:调用函数 say_hello 并将它的返回值分配给变量 greeting。你知道幕后发生了什么吗?如果你知道,你可以跳到下一节;否则,请参阅图 6.3。

图 6.3 从函数调用创建变量的过程。当你定义一个函数时,该函数被保存到命名空间中。当你调用函数时,它会查找命名空间以定位函数,并使用提供的参数调用函数。当函数调用完成时,返回的值被发送回并分配给变量。当赋值完成时,新变量被加载到相同的命名空间中,以便以后可以查找使用。
概念 命名空间 是一组定义的变量集合,你可以从中查找和使用。你可以将其视为一个字典对象:标识符,如函数的名称,是键,它们对应的对象是值。第 10.2 节详细讨论了命名空间。
当你从函数调用中创建一个变量时,你正在使用一个赋值语句。赋值语句评估右侧的表达式;在我们的例子中,它是调用函数,该函数从当前命名空间中查找。在完成函数中定义的操作后,执行返回值并将其分配给 greeting 变量。
返回多个值
当你的函数执行复杂的操作时,这些操作可能会生成两个或更多的对象,并且你需要所有这些对象来进行后续处理。在这种情况下,你应该考虑将这些对象全部作为函数的输出返回。
如你可能所知,科学家报告实验中所有测量的平均值和标准差是标准的。假设你正在定义一个函数来帮助科学家完成这项工作。接下来的列表显示了可能的解决方案。
列表 6.4 从函数中返回多个值
from statistics import mean, stdev
def generate_stats(measures):
measure_mean = mean(measures)
measure_std = stdev(measures)
return measure_mean, measure_std
generate_stats 函数同时返回平均值和标准差,这简化了你的代码库。一种非 Pythonic 的方法可能会使用两个单独的函数,如果每个函数只返回一个值:
def calculate_mean(measures):
measure_mean = mean(measures)
return measure_mean
def calculate_std(measures):
measure_std = stdev(measures)
return measure_std
显然,你并不总是想返回多个值。在列表 6.4 中,measure_mean 和 measure_std 值密切相关,它们构成了这些实验度量的统计报告;因此,这个列表是返回函数中两个值的有效例子。
与此相反,当你试图返回两个不相关的值时,你的函数可能由服务于不同目的的混合操作组成。以下代码片段是一个定义不良的函数的例子:
def process_data(measures):
formatted_measures = [f"{x} mg/L" for x in measures]
measure_mean = mean(measures)
return formatted_measures, measure_mean
如你所见,在 process_data 函数中,返回的值不相关。因此,当其他人使用这个函数时,他们很难弄清楚这个函数调用会返回什么,因为这个函数服务于两个不同的目的:格式化度量值和计算度量值的平均值。一个更可读的方法是为每个目的定义单独的函数。更重要的是,这些函数应该以清楚地反映它们目的的方式命名:
def format_measures(measures):
formatted_measures = [f"{x} mg/L" for x in measures]
return formatted_measures
def calculate_mean(measures):
measure_mean = mean(measures)
return measure_mean
可维护性函数应服务于单一目的。当你认为通过合并服务于不同目的的函数来“重构”或“节省”代码行时,你正在使代码更难使用和阅读,这可能会让你和你的队友感到困惑。
6.2.3 使用函数调用返回的多个值
当一个函数返回 None 或任何其他单一值时,使用返回值是直接的。但有些情况下,一个函数可以返回多个值。在本节中,我们将讨论如何使用函数调用返回的多个值。
虽然我一直说我们可以定义一个返回多个值的函数,但实际上,在任何函数中我们只能返回一个对象。检查我们定义在列表 6.4 中的 generate_stats 函数的使用情况:
measures = [5.6, 7.0, 5.7, 5.8, 4.3, 5.2]
measures_stats = generate_stats(measures)
print(type(measures_stats), measures_stats) ❶
# output: <class 'tuple'> (5.6, 0.8786353054595518)
❶ 类型函数检查对象的数据类型。
从调用 generate_stats 返回的值是一个元组对象,尽管在函数定义中看起来我们返回了两个值。这两个值被打包到一个单一的元组对象中。换句话说,严格来说,当我们似乎在函数定义中返回多个值时,我们返回的是一个单一变量,它是一个包含这些值的元组对象。请注意,正如关于元组解包(第 4.4 节)所讨论的,创建元组对象时括号是可选的。
你可以将元组解包技术应用于使用函数返回的多个值,这是一种简洁、Pythonic 的方式来访问返回的元组对象的各个单独项,如下一列表所示。
列表 6.5 解包返回的元组对象
m_mean, m_std = generate_stats(measures)
print(f"Mean: {m_mean}; SD: {m_std}")
# output: Mean: 5.6; SD: 0.8786353054595518
问题 如果你想只使用从 generate_stats 函数调用中返回的均值,你应该怎么做?
6.2.4 讨论
你的函数应该只服务于单一目的,因此只返回一个值是首选的输出形式。虽然你可以从函数中返回尽可能多的值,但返回过多的值并不是一个好主意,因为这会让函数的用户难以弄清楚每个值代表什么。作为一个经验法则,最好让你的函数只返回一个值。在某些情况下,使用两个到四个值是可以的,但使用五个或更多可能意味着你的函数有问题,比如服务于多个目的。
6.2.5 挑战
Zoe 继续工作在她的以位置为中心的应用程序(第 3.1.4 节)。她定义了多个函数,这些函数返回一个地点的纬度和经度:
def locate_me():
# look up the user's current location
return latitude0, longitude0
def locate_home():
# look up the user's home location
return latitude1, longitude1
def locate_work():
# look up the user's work location
return latitude2, longitude2
当你看到返回值的模式重复时,你会意识到她应该重构她的代码。你能给她什么建议来使这些函数返回一个值?
提示 命名元组(第 3.3 节)是一种轻量级的数据模型,你可以用它来存储数据。
6.3 如何使用类型提示编写可理解的函数?
当我们定义函数时,Python 不需要我们指定参数和返回值的类型。在大多数情况下,我们的函数只接受特定的数据类型。考虑以下列表 6.4 中的函数:
def generate_stats(measures):
pass
如果用户不知道他们需要使用什么类型的数据,他们可能会这样调用函数:
generate_stats({"measure0": 7.9, "measure1": 6.8, "measure2": 7.0})
这个函数调用不起作用,因为该函数假设参数 measures 是一个列表或元组对象。因此,为了减少其他人使用我们的函数不正确的情况,我们应该考虑在我们的函数定义中使用类型提示。适当的类型提示告诉用户我们的函数需要哪些类型的参数以及我们的函数返回什么值,使我们的函数更易于理解。在下一节中,你将学习如何编写具有类型提示的用户友好函数。
6.3.1 为变量提供类型提示
在第 1-5 章中,你学习了关于常见数据模型如 str、list、tuple 和 dict 的内容。当我们定义特定类型的变量时,我们创建它而不必担心指定数据类型。但我们可以指明变量的数据类型,这是为函数应用类型提示的基础。在本节中,我们将回顾为变量提供类型提示的基本技能。以下是一个创建 int 变量简单示例:
number = 1
print(type(number))
# output: <class 'int'>
知识点 The built-in type function allows us to inspect an object’s type.
如预期的那样,变量 number 的数据类型为 int。如果我们决定将这个变量赋值为不同的值,例如一个字符串,我们可以这样做:
number = "one"
print(type(number))
# output: <class 'str'>
在代码片段中,我们将一个字符串字面量赋值给变量 number,使其数据类型变为 str。换句话说,我们正在使用同一个变量 number,但通过简单的重新赋值,其数据类型已从 int 转换为 str。使用编程术语,我们说 Python 是动态类型化的——变量的类型可以在创建后改变。
相比之下,一些其他编程语言不允许你在定义变量后更改其类型;这些语言是静态类型的。 Swift,推荐用于开发 iPhone 应用和其他苹果相关系统的语言,是一种静态类型语言。在 Swift 中,我们不能将字符串值重新分配给最初定义为整数的变量。当一个变量具有特定类型时,你不能使用不同类型的值进行重新分配,如下一个列表所示。
列表 6.6 Swift 中静态类型的一个示例
var number = 1
number = "one"
error: cannot assign value of type 'String' to type 'Int'
尽管 Python 是一种动态类型语言,但我们可以在 Python 中为创建的变量提供类型提示。这个功能被称为类型提示,它是在 Python 3.6 中添加的。要提供类型提示,你在变量名后使用分号,然后指定变量的类型。以下是一些示例:
number: int = 3
name: str = "John"
primes: list = [1, 2, 3]
重要的是要知道类型提示并不会使 Python 成为静态类型语言,并且它不会强制变量的类型。(如果你想知道使用类型提示的目的,请参阅下一节。)你仍然可以将不同类型的值分配给使用类型提示创建的变量,并且可以无问题地运行以下两行代码:
numbers: tuple = (1, 2, 3)
numbers = [1, 2, 3]
6.3.2 在函数定义中使用类型提示
在 6.3.1 节中,你学习了如何为单个变量提供类型提示。在本节中,我们将把这个技术应用到函数定义中,以了解使用类型提示定义函数的好处。
在函数定义中使用类型提示与用它来创建变量没有区别,除了一个方面:为返回值提供提示。我们将通过下一个列表中的示例(6.4 列表中定义的 generate_stats 函数的修改版本)来了解函数类型提示是如何工作的。
列表 6.7 在函数中使用类型提示
from statistics import mean, stdev
def generate_stats(measures: list) -> tuple:
measure_mean = mean(measures)
measure_std = stdev(measures)
return measure_mean, measure_std
向函数参数添加类型提示与创建变量相同,并且这两种用法都采用 param: data_type 的形式。向返回值添加类型提示不同,因为在函数头部,我们没有为返回值提供一个明确的变量。相反,我们使用-> data_type 来表示返回值的类型。在函数定义中使用类型提示有两个主要原因:
-
类型提示清楚地告诉用户函数需要哪些参数以及它返回什么。 例如,如果你调用 help(generate_stats),你将能够看到函数的签名并正确使用它:
>>> help(generate_stats) Help on function generatate_stats in module __main__: generate_stats(measures: list) -> tuple -
类型提示通过允许你在编码时检查正确的类型来提高编码效率。 如果你使用控制台或纯文本编辑器,这个优势可能并不明显,因为主要的 Python 集成开发环境(IDEs)要么原生提供,要么通过安装插件(也称为扩展)提供实时代码分析。
假设你定义了一个接受整数的函数,并且通过使用类型提示来指定这个要求。图 6.4 显示了代码分析如何产生有意义的弹出菜单,这有助于你确保代码质量。

图 6.4 在 Python 编辑器 PyCharm 中显示了具有类型提示的函数的弹出菜单。当你调用函数时,弹出菜单会显示参数及其相应的类型。当你用错误的类型调用函数时,弹出菜单会显示不兼容的类型。
6.3.3 将高级类型提示技能应用于函数定义
在 6.3.2 节中,你学习了如何在函数定义中使用类型提示的语法。然而,在几种情况下,你会发现基本用法是不够的。在本节中,你将了解类型提示的一些高级用法:
-
具有默认值的参数
-
自定义类
-
容器对象
-
多种数据类型
使用具有默认值的参数
我已经介绍了如何在函数定义中为参数设置默认值。当这个特性与类型提示结合使用时,我们只需要知道序列的顺序:类型提示首先,然后是默认值。以下代码片段显示了一个示例:
def calculate_product(a: int, b: int, multiplier: int = 1) -> int:
c = a * b * multiplier
return c
参数乘数对于 int 类型具有默认值 1。请注意,在指定参数的默认值和类型时使用的空格是必要的,因为它们有助于提高代码的可读性。具体来说,应该在类型和等号前后留有空格。
可读性:在许多地方,空格和空行是必要的,通过为不同的组件创建视觉分隔符来提高代码的可读性。
与自定义类一起工作
当我们的项目增长时,我们引入新的类来管理数据。这些类是新的类型,我们可以像使用内置数据类型 int、tuple 和 dict 一样使用它们。以下列表显示了如何通过使用类型提示将自定义类包含在函数定义中。
列表 6.8 在自定义类中使用类型提示
from collections import namedtuple
Task = namedtuple("Task", "title description urgency")
class User:
pass ❶
def assign_task(pending_task: Task, user: User):
pass
❶ 使用 pass 语句作为占位符
事实:pass 语句用于满足代码的语法要求。作为一个占位符,pass 语句不执行任何操作。在类定义的主体中,我们需要编写代码来实现类。然而,在这种情况下,我们可以使用 pass 来验证类定义。
如列表 6.8 所示,我们定义了两个类:Task(使用命名元组技术)和 User(使用典型的类定义)。当这些类被定义后,我们可以立即使用它们。Python 知道这些类是类型,并且它们可以被用来指示函数定义中参数的类型。
与容器对象一起工作
我们了解到,一些内置数据类型,如列表和元组,是容器,因为它们可以包含其他对象。当涉及到这些容器的类型提示时,您可能会注意到,为容器本身提供类型并不总是足够有意义的。假设我们有一个用于完成多个任务的函数,如下所示。
列表 6.9 使用容器类型的类型提示
def complete_tasks(tasks: list):
for task in tasks:
pass
函数定义显示 tasks 参数是一个列表对象,但它没有指定列表中包含的对象类型。因此,人们可能会使用包含 str 对象的列表或包含 Task 对象的列表:
complete_tasks(["Laundry", "Museum"])
complete_tasks([Task("Laundry", "Wash clothes", 5),
➥ Task("Museum", "Egyptian exhibit", 4)])
当您向函数添加特定操作时,您可以使 str 或 Tasks 对象兼容,但为 tasks 参数提供具体性更符合用户友好性。它是 str 对象的列表还是 Task 对象的列表?下一列表显示了函数的修改版本。
列表 6.10 使用特定内容类型的容器类型提示
def complete_tasks_hinted(tasks: list[Task]):
for task in tasks:
pass
事实:Python 近期版本中的类型提示功能正在演变。如果您不使用 Python 的最新版本,某些功能可能不可用。
除了列表之外,您还可以使用一对括号跟在列表后面来包含包含对象的预期数据类型。在我们的例子中,我们期望列表对象包含 Task 对象,而不是 str 对象。通过这种改变,您会注意到,当您使用不兼容的数据类型的列表对象时,IDE 会给出警告,如图 6.5 所示。

图 6.5 当容器包含不兼容数据类型的对象时显示警告。截图来自 Python IDE PyCharm。由于 IDE 的实时代码分析,在您指定与类型提示不符的参数后,IDE 会显示警告弹出菜单。
除了列表之外,最常见的容器数据类型是 dict、tuple 和 set。表 6.1 总结了各自包含对象的类型提示。
表 6.1 常见内置容器对象的类型提示
| 容器类型 | 代码示例 | 说明 |
|---|---|---|
| list | list[str] | 包含 str 对象的列表 |
| list[int] | 包含 int 对象的列表 | |
| tuple | tuple[float, int] | 包含 float 对象和 int 对象的元组 |
| tuple[float, ...] | 包含多个 float 对象的元组 | |
| dict | dict[int, str] | 使用 int 对象作为键和 str 对象作为值的字典 |
| dict[int, list[int]] | 使用 int 对象作为键和 int 对象列表作为值的字典 | |
| set | set[int] | int 对象的集合 |
| set[str] | 包含 str 对象的集合 |
接受多种数据类型
函数可以接受特定参数的不同数据类型。在列表 6.6 中,generate_stats 函数的 measures 参数是一个数字列表。但如果我们使用数字元组,这个函数的工作方式也将相同。在这种情况下,我们应该使用类型提示来指示参数可以是多种类型,如下一列表所示。
列表 6.11 指定多个类型
from statistics import mean, stdev
def generate_stats(measures: list[float] | tuple[float, ...])
➥ -> tuple[float, float]:
measure_mean = mean(measures)
measure_std = stdev(measures)
return measure_mean, measure_std
要为参数指定多个类型,我们使用竖线 | 来分隔类型。值得注意的是,如果你有超过两种类型,你可以使用多个竖线:
para0: int | float | str | list
6.3.4 讨论
Python 在早期并不支持类型注解,但逐渐配备了类型注解功能。Python 标准库的一个主要补充是用于高级类型注解的 typing 模块。你在这章中学到的知识将使你准备好学习 typing 模块中的任何新内容。为了给你一个先发优势,以下代码展示了如何使用 typing 模块使类型注解更清晰,因为它包括更高级别的类型信息(如 Sequence,它可以捕获任何序列数据类型):
from statistics import mean, stdev
from typing import Sequence
def generate_stats(measures: Sequence[float]) -> tuple[float, float]:
measure_mean = mean(measures)
measure_std = stdev(measures)
return measure_mean, measure_std
6.3.5 挑战
安德鲁正在构建一个用于处理财务数据的 Python 包。他在包中使用类型注解来简化用户的使用。当函数的参数是一个 int 列表或一个 str 列表时,他该如何编写类型注解?
提示:竖线表示 或,它不必位于类型注解之间。换句话说,它可以在类型注解中使用,例如 set[int | str]。
6.4 如何使用 *args 和 **kwargs 增加函数的灵活性?
当我们定义函数时,我们希望它们解决特定问题。为了调用这些函数,我们发送适用的参数,以便它们可以执行所需的操作。到目前为止,我们定义的所有函数都接受预设数量的参数,但有时所需的用例需要超过预设数量的参数。考虑内置的 print 函数的头部:
print(*objects, sep=' ', end='\n', file=sys.stdout, flush=False)
表面上看,print 函数似乎接受五个参数,其中最后四个有默认值。然而,你可能已经注意到,我们可以通过使用 print 来打印任意数量的对象,正如下一个列表所示。
列表 6.12 使用内置的 print 函数
word = "Hello"
numbers = [1, 2, 3]
prime_number = 11
print(word, numbers, prime_number)
# outprint: Hello [1, 2, 3] 11
print 可以接受多个对象的原因是在对象参数之前使用了 *,这意味着一个可变数量的(零个或更多)位置参数。这种参数指定技术通常表示为 *args。使用 *args 使得 print 函数足够灵活,可以接受任意数量的对象。值得注意的是,还有另一种与指定可变数量的 关键字 参数密切相关的技术,表示为 **kwargs。在下一节中,你将学习如何使用 *args 和 **kwargs 定义具有良好灵活性的函数。此外,我们还将介绍有关参数类别的关键概念。
6.4.1 了解位置参数和关键字参数
你可能已经注意到,当我们调用函数时,在括号中,我们有时直接使用参数,有时使用在指定参数之前的前缀标识符。我们对这两种类型的参数有不同的术语。
当参数有相关标识符时,它们是关键字参数,这些标识符在函数体中用于引用这些参数。当参数没有相关标识符时,它们是位置参数。换句话说,Python 根据函数定义中的顺序,根据参数的位置处理这些参数。为了理解关键字参数和位置参数的区别,考虑一个简单的函数:
def multiply_numbers(a, b):
return a * b
对于一个典型的函数,如 multiply_numbers,我们可以将参数设置为位置参数或关键字参数。图 6.6 展示了调用此函数时使用两个参数的几种方法。

图 6.6 展示了在函数调用中使用位置参数和关键字参数。如果参数前面有标识符,它们是关键字参数。如果参数没有标识符,它们是位置参数。
从图 6.6 中展示的各种示例中,我们可以总结出以下关于使用位置参数和关键字参数的关键点:
-
当你使用位置参数时,这些参数的顺序很重要。参数将与函数头部的原始参数匹配。
-
当你使用关键字参数时,这些参数的顺序不重要。参数将根据提供的关键字/标识符使用。
-
当你同时使用位置参数和关键字参数时,必须将位置参数放在任何关键字参数之前。否则,你会引发语法错误。
现在你已经了解了位置参数和关键字参数的区别,我们准备讨论定义可变数量的位置参数和关键字参数。
仅位置参数和仅关键字参数
图 6.6 显示,在调用函数时,参数可以设置为位置参数或关键字参数。也就是说,当你用参数调用函数时,Python 会遵循特定的顺序来确定参数与函数定义中的对应关系。如果参数是关键字参数,Python 将它们与定义中的相应参数匹配。如果参数是位置参数,Python 将根据它们的位置处理它们。一般来说,我们不限制参数(无论是位置参数还是关键字参数)的设置方式。
有两种更高级的方式来指定参数的设置方式:仅位置参数只能按位置设置,而仅关键字参数只能用标识符设置。如果你还记得,sort 方法有如下头部:sort(, key=None, reverse=False)。星号表示所有其后的参数都应该仅作为仅关键字参数设置。
通过强化仅关键字参数,你迫使读者使用关键字参数,这样他们就能确切知道正在设置哪些参数。如果你想某些参数只能作为关键字参数设置,可以使用这个特性。
对于仅限位置参数,看看 sum 函数:sum(iterable, /, start=0)。/ 指定它之前的参数应仅作为位置参数设置。这个特性可能很有用,但在你的代码中,你很少需要设置只能作为位置参数使用的参数。
6.4.2 接受可变数量的位置参数
在 print 函数(列表 6.12)中,*objects 允许我们打印任意数量的对象,这提高了其灵活性。在本节中,你将学习如何定义一个接受可变数量位置参数的函数。
为了便于讨论,我将从一个简单的函数开始,其目的是将任意数量的对象转换为它们对应的字符串表示。显然,我们不知道会有多少对象被发送到函数调用中。因此,我们希望这个函数具有灵活性,就像 print 函数一样。下面的代码片段显示了该函数:
def stringify(*items):
print(f"got {items} in {type(items)}")
return [str(item) for item in items]
使用 *args 作为元组
在函数的头部,我们使用 items 来表示该函数可以接受任意数量的位置参数。本质上,你使用星号 () 符号来在参数名前。既然我们知道这个函数头,用户可以用任意数量的位置参数调用它,那么下一个问题是如何在函数体中使用这些位置参数。
因为我们在代码中包含了一行打印参数的代码,print(f"got {items} in {type(items)}"), 我们可以调用 stringify 来检查 items 的内容:
>>> stringify(1, "two", None)
got (1, 'two', None) in <class 'tuple'>
['1', 'two', 'None'] ❶
❶ 函数的返回值将在控制台打印。
从输出中,我们知道所有位置参数都被打包到一个名为 items 的元组对象中。因此,我们可以对 items 应用任何元组相关技术。在示例中,我们使用列表推导技术迭代 items 对象。
将 *args 作为最后一个位置参数
当你期望用户调用一个接受除了 *args 之外的其他指定位置参数的函数时,你应该将 *args 放在末尾。考虑对 stringify 函数的修改版本:
def stringify_a(item0, *items):
print(item0, items)
当我们调用 stringify_a 时,Python 会知道相应地解析位置参数。第一个参数传递给 item0,其余参数传递给 items:
>>> stringify_a(0)
0; ()
>>> stringify_a(0, 1)
0; (1,)
显然,stringify_a 函数是有效的。现在看看一个无效的修改:
def stringify_b(*items, item0):
print(item0, items)
当我们用位置参数调用 stringify_b 时,Python 无法确定哪个参数对应哪个参数。items 表示任意数量的位置参数,Python 也不知道在哪里停止,就像这个例子一样:
stringify_b(0, 1)
# ERROR: TypeError: stringify_b() missing 1 required keyword-only argument:
➥ 'item0'
当我们只用位置参数调用 stringify_b 时,我们会遇到 TypeError,错误信息告诉我们我们缺少关键字参数 item0。因此,如果我们把 items 设置为关键字参数,我们就可以使用 stringify_b:
>>> stringify_b(0, item0=1)
1 (0,)
虽然函数调用是有效的,但我们的初衷是定义一个只能用位置参数调用的函数。基于这个假设,我们应该记住将*args 放在位置参数列表的末尾。
6.4.3 接受任意数量的关键字参数
在 6.4.2 节中,我们学习了如何创建一个可以接受任意数量位置参数的函数。作为对比,我们可以定义一个可以接受任意数量关键字参数的函数。按照惯例,我们使用kwargs来表示关键字参数的可变性。在本节中,你将了解kwargs。
为了便于讨论,我将从一个涉及kwargs的简单函数开始。以该函数为例,以下是使用kwargs的关键点:
def create_report(name, **grades):
print(f"got {grades} in {type(grades)}")
report_items = [f"***** Report Begin for {name} *****"]
for subject, grade in grades.items():
report_items.append(f"### {subject}: {grade}")
report_items.append(f"***** Report End for {name} *****")
print("\n".join(report_items))
将kwargs作为字典使用
我们知道,可变数量的位置参数被打包成一个元组对象。以类似的方式,可变数量的关键字参数被打包成一个单一的对象:字典。让我们通过调用 create_report 函数来验证这一点:
create_report("John", math=100, phys=98, bio=95)
# output the following lines:
got {'math': 100, 'phys': 98, 'bio': 95} in <class 'dict'>
***** Report Begin for John *****
### math: 100
### phys: 98
### bio: 95
***** Report End for John *****
从打印输出中,你可以轻松地看到这些关键字参数形成了一个字典对象。使用这个字典对象,我们可以使用相关的字典方法。在这个例子中,我们通过使用 items 迭代所有键值对。
将kwargs作为最后一个参数
当你在函数中使用kwargs时,你应该记住语法规则,即kwargs应该放在所有其他参数之后。与此规则相关,位置参数应该放在所有关键字参数之前。图 6.7 显示了这些参数的一般顺序。

图 6.7 函数定义中位置参数和关键字参数的放置顺序。一般来说,位置参数应该始终在关键字参数之前。*args 应该是最后一个位置参数,**kwargs 应该是最后一个关键字参数。
6.4.4 讨论
虽然使用args 和kwargs有助于提高定义的函数的灵活性,但对于函数的用户来说,它对适用参数的说明不够明确。因此,我们不应该滥用这个特性。只有在你无法知道函数期望接受多少位置参数或关键字参数时,才应考虑使用args 和kwargs。一般来说,在函数定义中使用显式命名的位置参数和关键字参数是首选的,因为这些参数名称清楚地表明了参数的预期行为。
6.4.5 挑战
让我们继续讲述关于柯里(Cory)的故事,他在大学里教授 Python 编程。学生们知道,一个带有kwargs的函数可以接受任意数量的关键字参数,如下面的例子所示:
def example(**kwargs):
pass
为了测试学生对调用函数的知识,他创建了一个调用前面示例函数的方法列表:
example(a=1, b=2)
example(1, 2)
example(2a=1, 2b=2)
example()
如果你是一名学生,你会知道哪些技术是有效的,哪些不是吗?是什么使得一些调用无效?
提示 关键字参数使用标识符。Python 对标识符有特定的规则。例如,它们不能以数字开头。
6.5 如何为函数编写合适的文档字符串?
当我们遇到一个新函数时,通常会查阅其文档以了解如何使用它。例如,你可以使用内置的 isinstance 函数来检查一个对象是否属于特定类型。但你不知道如何调用这个函数。除了在网上查找信息外,还有没有其他方法来获取相关信息?答案是肯定的——借助内置的 help 函数,如下所示。
列表 6.13 使用 help 获取文档字符串
>>> help(isinstance)
Help on built-in function isinstance in module builtins:
isinstance(obj, class_or_tuple, /)
Return whether an object is an instance of a class or
➥ of a subclass thereof.
A tuple, as in ``isinstance(x, (A, B, ...))``, may be given as the
➥ target to check against. This is equivalent to
➥ ``isinstance(x, A) or isinstance(x, B) or ...`` etc.
如列表 6.13 所示,我们使用 help 函数检索 isinstance 函数的文档字符串。尽管这种技术不太为人所知,但你也可以通过访问其特殊属性 doc 来检索一个函数的文档字符串:
>>> print(isinstance.__doc__)
Return whether an object is an instance of a class or
➥ of a subclass thereof.
A tuple, as in ``isinstance(x, (A, B, ...))``, may be given as the
➥ target to check against. This is equivalent to
➥ ``isinstance(x, A) or isinstance(x, B) or ...`` etc.
如果你不知道,Python 使用docstrings来指代函数、类或模块的文档,以解释这些事物的功能。在我们的例子中,我们正在查看 isinstance 函数的文档字符串,它提供了如何使用 isinstance 的具体说明。更重要的是,你可以通过在 Python 控制台中简单地调用 help 来方便地访问文档字符串,而不依赖于任何外部资源。在本节中,你将学习如何为函数编写合适的文档字符串。
概念 docstring 是一个字符串,它以这种方式记录模块、类、函数或方法,使用户知道如何正确使用它们。
6.5.1 检查函数文档字符串的基本结构
函数的文档字符串位于函数头部下方的一个多行字符串。按照惯例,我们使用三引号来包围字符串。只要它们匹配,你可以使用双引号或单引号来形成三引号。在本节中,我们将回顾函数文档字符串的基本结构。
对于这个多行字符串,作为最佳实践,需要包含三个关键元素:函数的摘要、参数和返回值。如果你的函数可以抛出一个或多个异常,你希望指定它们,作为第四个元素。图 6.8 显示了函数文档字符串的构建元素。

图 6.8 Google 风格的函数文档字符串。需要包含三个元素:摘要、参数和返回值。如果函数抛出任何异常,也需要指定。
值得注意的是,Python 程序员在文档字符串风格上还没有达成共识。图 6.8 中显示的文档字符串被称为 Google 风格,因为它是由 Google 官方推荐的。不同的 Python 用户和 IDE 已经采用了多种风格。作为最常用的 Python IDE 之一,PyCharm 将所谓的reStructuredText (reST)风格作为文档字符串的默认选项;图 6.9 显示了示例。

图 6.9 PyCharm 使用的 reST 风格的函数文档字符串。关键元素与其他风格的文档字符串相同:摘要、参数、返回值和异常(如有)。
虽然 Python 程序员通常在函数文档字符串应包含哪些元素上达成一致,但每个程序员都有权选择一个首选风格或遵循公司的约定。在本节中,我们将坚持使用 reST 风格。在下一节中,我们将讨论定义每个元素的适当方法。
可维护性 在项目中坚持特定的文档字符串风格很重要。文档的一致性对于可读性和可维护性都是必不可少的。
6.5.2 将函数的动作指定为摘要
函数文档字符串的第一个元素是函数的摘要。摘要应该是简洁的,如果可能的话,只占用一行。它提供了函数执行动作的高级描述。
例如,在列表 6.13 中,我们看到了内置的 isinstance 函数的文档字符串。其摘要清楚地指出了函数的动作:返回一个对象是否是某个类或其子类的实例。我们在创建自己的摘要时应该使用相同的理念。值得注意的是,对于一些简单的函数,可能只需要一行作为文档字符串。在这种情况下,摘要构成了整个文档字符串。以下简单的函数代表了这种情况:
def doubler(a):
"""Return the number multiplied by 2"""
return a * 2
6.5.3 记录参数和返回值
在为函数提供摘要之后,创建函数文档字符串的下一步是记录函数使用的每个参数。在 reST 风格中,每个参数以:param开头,不同的参数作为单独的行列出。对于每个参数,我们需要提供以下信息:
-
参数名称—它应该与函数头中使用的完全一致。
-
参数类型—你期望参数是什么类型的数据?请指定它。
-
描述—根据参数的直观性,提供有用的描述以帮助用户理解该参数是什么,或者如果其目的不明确,为什么需要它。
-
默认值(可选)—如果参数有一个默认值,请指定它。值得注意的是,如果你选择特定值作为默认值的原因不明确,你需要提供简要的说明。
你可以在以下列表中看到这些指南的实际应用。
列表 6.14 简单函数的文档字符串示例
def quotient(dividend, divisor, taking_int=False):
"""
Calculate the product of two numbers with a base factor.
:param dividend: int | float, the dividend in the division
:param divisor: int | float, the divisor in the division
:param taking_int: bool, whether only taking the integer part of
➥ the quotient; default: False, which calculates the
➥ precise quotient of the two numbers
:return: float | int, the quotient of the dividend and divisor
"""
result = dividend / divisor
if taking_int:
result = int(result)
return result
列表 6.14 中的示例提供了三个参数所需的文档字符串,包括参数名称、类型和说明。此外,由于 taking_int 有一个默认值,它也在文档字符串中提到。当一个参数的文档字符串超过一行时,请记住在第二行和后续行中插入一些缩进,以便清楚地划分不同的参数。
从可读性的角度来看,我们为函数本身(quotient)和所有参数(被除数、除数和 taking_int)使用有意义的名称。在使用函数定义时,使用有意义的名称是关键,因为这些名称可以提供关于函数的直观信息。如果它们命名得当,用户可能甚至不需要检查文档字符串就能理解函数。
可读性 为了最佳的可读性,一切都应该有意义的命名。使用长名称是可以的,因为自动补全是常见 IDE 的一个功能。在你写下前几个字母之后,你可以选择所需的名字。
换句话说,你在定义函数时的目标是使其易于用户理解和使用,最大限度地减少他们必须参考函数的文档字符串的可能性。请记住,文档字符串应该是你函数的备用信息源。
对于函数的返回值,文档字符串使用 :return 来指示返回值的类型和说明。说明应该是简洁且易于理解的。
6.5.4 指定可能抛出的任何异常
当你的函数可能抛出任何异常时,你应该在文档字符串中指定它们,这样当用户阅读文档字符串时,他们就会知道可能遇到的异常,并可以避免或处理它们。
让我们考虑 quotient 函数,它包括除法操作被除数 / 除数。我们知道如果除数为 0,除法是未定义的,我们可以看到如果我们尝试将一个数字除以 0 会发生什么:
1 / 0
# ERROR: ZeroDivisionError: division by zero
因此,我们应该在文档字符串中指定这样的异常,如以下列表所示。
列表 6.15 在文档字符串中指定可能的异常
def quotient(dividend, divisor, taking_int=False):
"""
Calculate the product of two numbers with a base factor.
:param dividend: int | float, the dividend in the division
:param divisor: int | float, the divisor in the division
:param taking_int: bool, whether only taking the integer part of
➥ the quotient; default: False, which calculates the
➥ precise quotient of the two numbers
:return: float | int, the quotient of the dividend and divisor
:raises: ZeroDivisionError, when the divisor is 0
"""
if divisor == 0:
raise ZeroDivisionError("division by zero") ❶
result = dividend / divisor
if taking_int:
result = int(result)
return result
❶ 明确抛出 ZeroDivisionError 异常
在列表 6.15 中,我们明确检查除数是否为 0,并在其为 0 时抛出 ZeroDivisionError 异常。请注意,即使我们没有明确抛出这个异常,当我们调用类似 quotient(1, 0) 这样的操作时,Python 也会在适用的情况下抛出 ZeroDivisionError 异常。在这里,我明确抛出这个异常,因为我想要展示一个由函数抛出的异常应该如何在文档字符串中进行记录。
相关地,当我们创建自己的 Python 模块时,我们经常需要自己定义自定义异常,以便在创建的函数中明确抛出这些自定义异常。我在第 12.5 节中介绍了自定义异常。
6.5.5 讨论
创建函数的文档字符串有不同的风格。关键是始终如一地坚持特定的风格。如果你在一个团队中工作,使用团队商定的风格。如果你只为自己的函数/模块编写代码,采用你最习惯的风格。请记住,代码的一致性是任何项目持续可维护性的关键。
6.5.6 挑战
杰瑞过去通常使用 reST 风格为其文档字符串,如列表 6.15 所示。他现在加入了一家使用 Google 风格对所有文档进行编写的公司。作为一个最佳实践,如果他使用 Google 风格重写列表 6.15 中的文档字符串,那么文档字符串会是什么样子?
提示图 6.8 展示了一个使用 Google 风格的文档字符串。
摘要
-
你应该考虑为大多数调用中值相同的参数设置默认值。当使用默认值时,用户不再需要设置它们,这使得使用较少参数的函数调用更容易阅读。
-
当你为可变参数设置默认值时,例如列表,不要使用构造函数 list(),因为函数是在定义时进行评估的,包括默认参数。使用构造函数会导致不同的函数调用操作相同的可变对象,并产生不期望的副作用。为了避免这种陷阱,你应该使用 None 作为可变参数的默认值。
-
每个 Python 函数都有一个返回值——要么是显式返回的值,要么是隐式返回的 None。
-
一个函数可以返回多个值,这些值形成一个单一的元组对象。你可以在函数调用之后使用元组解包技术来检索单个项。这样,读者可以更清楚地了解你将如何使用返回值。
-
虽然 Python 是一种动态类型语言,但我们可以使用类型提示来为函数的参数和返回值提供有用的类型信息。当你将类型提示纳入函数定义中时,你会使你的函数更易于阅读,使用户更容易理解你的函数。更重要的是,现代 IDE 可以利用函数的类型提示,并在使用不兼容类型的对象作为参数时提供实时警告。
-
当我们调用函数时,我们通常会传递所需的参数。当参数使用标识符时,它们被称为关键字参数。相比之下,没有标识符且基于其位置解析的参数是位置参数。位置参数应始终放在关键字参数之前。
-
大多数情况下,最好定义一个固定数量的位置参数和关键字参数。然而,在某些情况下,有必要定义接受可变数量位置参数和/或关键字参数的函数,这些参数分别表示为 *args 和 **kwargs。
-
如果你的函数要公开使用,你需要提供文档,称为文档字符串。函数的文档字符串应包括函数的摘要、所有参数、返回值以及可能的异常(如果有)。
-
开发者会使用不同的风格来编写文档字符串。当你为你的函数编写文档字符串时,务必采用一种特定的文档字符串风格,并保持一致地使用。当你一致地应用文档字符串,这将使你更容易开发和维护你的代码(你只需要熟悉一种风格),同时也会让读者更容易理解。
7 超越基础的函数使用
本章涵盖
-
使用 lambda 函数处理小任务
-
使用高阶函数
-
创建和使用装饰器
-
使用生成器获取数据
-
创建部分函数
你可能已经意识到,在每一个项目中,你花费在开发上的最大时间都用于编写函数。在第六章中,我们专注于编写和使用函数的基础知识。在覆盖了这些主题之后,你将能够编写用户友好的函数来满足你的工作需求。Python 知道函数在任何一个项目中的重要作用;因此,它有你可以利用的先进功能,以使函数更好地服务于你的工作。
在本章中,你将了解更高级的函数主题。你会发现相关概念可能听起来很高级,但实用技术并不难应用到你的日常编码工作中。
7.1 我如何使用 lambda 函数处理小任务?
当我们定义函数时,我们使用 def 关键字,然后给函数命名,这个名称作为函数的标识符。尽管这个术语并不常见,但我们可以将这些函数称为命名函数,因为它们有相关的标识符。
相比之下,在 Python 中,你可以定义另一种类型的函数,而不需要指定名称。这些函数被称为匿名函数**. 更正式地说,这些函数被称为lambda 函数. 当我们讨论使用自定义函数的高级排序(第 3.2 节)时,我们使用了一个将 lambda 函数设置为排序方法中的键参数的例子:
tasks.sort(key=lambda x: x['urgency'], reverse=True)
在本节中,你将了解使用 lambda 函数所需的一切:组成部分和最佳实践。
知识点 将匿名函数称为lambda 函数或表达式不仅存在于 Python 中,还存在于许多其他语言中,如 Java。这个名字来源于数学中的 lambda 演算。
7.1.1 创建 lambda 函数
你可能已经看到了一些 lambda 函数的例子,但还没有正式学习如何创建它们。首先,让我们回顾一下构成 lambda 函数的关键元素。
创建 lambda 函数不需要使用 def 关键字和提供标识符,就像我们为常规函数所做的那样。相反,我们使用 lambda 关键字来表示我们正在创建一个 lambda 函数。图 7.1 显示了 lambda 函数的组成部分。

图 7.1 创建由三个部分组成的 lambda 函数:lambda 关键字、参数和表达式
如图 7.1 所示,在 lambda 关键字之后,我们提供参数和一个使用参数产生值的单个表达式。别忘了你需要在参数后添加一个冒号。请注意,在 lambda 函数中,你可以使用零个参数。当 lambda 函数没有参数时,在指定表达式之前仍然需要冒号。
概念:关键字是 Python 为执行预定义操作而保留的特殊单词,例如 def 用于创建函数,class 用于创建类,以及 lambda 用于创建 lambda 函数。
与常规函数不同,常规函数可能返回一个对象,而 lambda 函数不返回任何内容。当它们返回时,你会得到一个语法错误:
lambda x: return x * 2
# ERROR: SyntaxError: invalid syntax
SyntaxError 是预期的,因为 lambda 使用表达式而不是语句,而 return x * 2 是一种语句。
提示:表达式计算出一个单一值或对象,而语句执行一个特定的动作,但不计算为任何对象。
现在我们知道了如何创建一个 lambda 函数,是时候尝试一下了:
doubler = lambda x: x * 2
这个 lambda 函数将一个数字乘以 2。为了演示目的,我们将 lambda 函数赋值给变量 doubler,这使我们能够更详细地检查 lambda 函数。然而,如你将在下一节中看到的,将 lambda 函数赋值给变量并不是一个好的做法。当你检查 lambda 函数的类型时,你会发现它确实是一种函数:
print(type(doubler))
# output: <class 'function'>
Lambda 函数在本质上就是函数,因此我们可以像常规函数一样调用它们。当你调用一个 lambda 函数时,你发送所需的参数,就像你通常使用常规函数一样:
>>> doubler(5)
10
>>> doubler(8)
16
7.1.2 使用 lambda 函数执行一次性的小任务
在 7.1.1 节中,我提到你不应该将 lambda 函数赋值给变量。主要原因是一个 lambda 函数应该执行一个小任务,并且只使用一次。在本节中,我将讨论我所说的“小任务”是什么意思。
你可能会想知道小任务有什么样的用例。如果你回忆一下,你在 3.2.1 节中学习了如何使用自定义函数(列表 3.3)进行更复杂的排序。为了快速参考,代码将在下一个列表中展示。
列表 7.1 使用自定义函数排序列表
tasks = [
{'title': 'Laundry', 'desc': 'Wash clothes', 'urgency': 3},
{'title': 'Homework', 'desc': 'Physics + Math', 'urgency': 5},
{'title': 'Museum', 'desc': 'Egyptian things', 'urgency': 2}
]
def using_urgency_level(task):
return task['urgency']
tasks.sort(key=using_urgency_level, reverse=True)
我们定义 using_urgency_level 函数,并将其设置为 sort 方法调用中的 key 参数。值得注意的是,这个 using_urgency_level 函数执行一个小任务来获取字典对象的价值。此外,这个函数只作为 sort 方法中的 key 参数使用一次。通过将单次使用的 lambda 函数部分作为 key 参数调用 sort,你不会创建额外的“噪音”(显式定义的函数),使你的代码更简洁。因此,这是一个使用 lambda 函数的完美场景:
tasks.sort(key=lambda x: x['urgency'], reverse=True)
这个 lambda 函数接受一个参数,代表列表对象中的每个字典对象,就像在 using_urgency_level 函数中一样。
提示:调用常规函数和 lambda 函数都是表达式,接受输入并生成输出。
7.1.3 使用 lambda 函数时避免陷阱
在学习 lambda 函数之后,你可能会认为它们是各种原因下的酷炫高级特性。名字——lambda!——很酷。lambda 函数简洁——一行代码。此外,许多 Python 初学者对 lambda 函数了解不深,他们认为如果使用这个高级特性,他们就不再是初学者了。如果你有任何这些想法,那么你可能会遇到以下陷阱之一。
将 lambda 赋值给变量
我已经提到过几次,我们不会将 lambda 函数赋值给变量。我们的理由(在上一个部分中暗示)是,我们只使用一次 lambda 函数。然而,从可读性的角度来看,将 lambda 函数赋值给变量似乎是一个好的实践,这样我们可以合理地命名变量,并让读者更多地了解 lambda 函数。考虑以下示例:
using_urgency_level = lambda x: x['urgency']
tasks.sort(key=using_urgency_level, reverse=True)
在这个例子中,我们使用 using_urgency_level 来指代 lambda 函数,并且它确实给我们提供了一些关于排序算法的信息。然而,避免将 lambda 函数赋值给变量的更重要原因是,如果函数出错,调试会更困难,正如下面的列表所示。
列表 7.2:使用 lambda 函数时的 KeyError
using_urgency_level0 = lambda x: x['urgency0']
tasks.sort(key=using_urgency_level0, reverse=True)
# ERROR:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 1, in <lambda>
KeyError: 'urgency0'
对于直接比较,将相同的错误(使用错误的键访问值)应用于命名函数。以下列表显示了会发生什么。
列表 7.3:使用命名函数时的 KeyError
def using_urgency_level1(task):
return task['urgency1']
tasks.sort(key=using_urgency_level1, reverse=True)
# ERROR:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 2, in using_urgency_level1
KeyError: 'urgency1'
在列表 7.2 和 7.3 之间,我突出显示了最显著的区别,尽管两个代码片段都显示了相同的 KeyError。当我们使用命名函数时,错误信息清楚地显示了问题所在:在 using_urgency_level1 函数中。相比之下,当我们使用使用错误键的 lambda 函数时,错误信息只告诉我们
可维护性:不要将 lambda 函数赋值给变量;如果出现问题,代码将难以调试。
使用更好的替代方案
我们理解 lambda 函数的目的是执行一个小任务。一个常见的使用场景是将 lambda 函数设置为函数的键参数,例如 sort、sorted 和 max。然而,在某些情况下,存在更好的替代方案。
假设我们有一个数字列表,我们想要创建一个新的列表对象,该对象根据这些数字的绝对值进行排序。你可能会有以下解决方案:
integers = [-4, 3, 7, 0, -6]
sorted(integers, key=lambda x: abs(x))
# output: [0, 3, -4, -6, 7]
在 lambda 函数中,我们使用内置的 abs 函数,该函数计算项的绝对值。一个更 Pythonic 的解决方案是直接将 abs 函数用作键参数:
sorted(integers, key=abs)
对于另一个例子,假设我们有一个包含元组的列表,每个元组记录一个学生在数学、科学和艺术中的分数,我们想要找出哪个元组对象的总分最高。考虑以下解决方案:
scores = [(93, 95, 94), (92, 95, 96), (94, 97, 91), (95, 97, 99)]
max(scores, key=lambda x: x[0] + x[1] + x[2])
# output: (95, 97, 99)
在这个 lambda 函数中,我们使用索引来检索三个分数中的每一个,并将它们相加得到总分。但我们知道内置的 sum 函数可以接受任何可迭代对象来生成其项的总和。因此,我们应该直接利用 sum 函数。作为旁注,你可以调用 max(scores)来得到相同的结果。在这里,我包括 key=sum 来明确说明如何选择最大项:
max(scores, key=sum)
可读性:优先使用内置函数或适用的替代方案,这些通常更简洁,而不是创建 lambda 函数。
7.1.4 讨论
Lambda 函数应该只执行一个小任务,用于一次性使用,例如在 sorted、max 和 min 等内置函数中作为 key 参数。值得注意的是,lambda 函数在第三方库中得到了广泛的应用,例如 pandas,一个流行的数据科学库。例如,在 pandas 中,我们可以使用 apply 函数从现有的 DataFrame 创建新的数据。apply 函数接受一个 key 参数,它指定了如何从现有数据创建新数据。因此,lambda 函数是一种通用的技术,你可以用它来指定数据提取或转换中的小任务。
7.1.5 挑战
高中生琳达正在学习 Python 来批量处理她的图片和视频文件。她知道 Python 函数有一个特殊的属性叫做 name。她尝试访问 lambda 函数和命名函数的这个属性。你认为这些值应该是什么?
提示:回到 7.2 和 7.3 列表中,看看错误信息对命名函数和 lambda 函数说了什么。
7.2 函数作为对象的意义是什么?
我们知道 Python 在核心上是面向对象编程(OOP)语言。从一般的角度来看,当我们谈论对象时,我们通常是指一个代表特定数据的实体。在前五章中,我们关注了与数据模型相关的各种主题,如 str、list、tuple、dict 和 set。这些类及其各自的实例是对象的例子。作为对象的一个重要含义是,我们可以通过将其发送到函数来操作代表的数据。以下代码片段显示我们可以使用 int 和 str 实例对象在函数中:
def add_three(number):
return number + 3
add_three(7) ❶
def greeting_message(person):
return f"Hello, {person}!"
greeting_message("Zoe") ❷
❶ 在函数中使用 int 对象
❷ 在函数中使用 str 对象
特别地,在上一节中,我们提到我们可以将一个命名函数或 lambda 函数传递给 sort 方法:
tasks.sort(key=lambda x: x['urgency'], reverse=True)
能够将函数作为参数设置似乎意味着 lambda 函数,或者函数一般,代表了一些数据,就像其他数据模型如 int 和 str 一样。如果你再进一步思考,可能会想知道函数是否也是对象。确实,有句话是“在 Python 中,一切都是对象”:Python 也将函数视为对象。在本节中,我将介绍函数作为对象的最重要含义,并展示一些实际的应用案例。
7.2.1 在数据容器中存储函数
我们知道基本数据模型可以交织在一起,创造出巨大的可能性。特别是,我们可以使用数据容器来存储几乎任何类型的数据模型。你可以有一个包含 int、str、dict 和 set 的列表。在一个字典中,你可以将 int、str、list 和 dict 作为其值存储。在本节中,你将了解函数作为对象的第一个含义:与其他数据模型一起使用函数。具体来说,我们将看到我们如何利用在数据容器中存储函数的优势。
假设我们有一个应用程序编程接口(API),允许用户发送一个数字列表并指定所需的数据操作。为了简单起见,让我们说操作是计算平均值、最小值或最大值。API 函数看起来像这样:
def get_mean(data):
return "mean of the data"
def get_min(data):
return "min of the data"
def get_max(data):
return "max of the data"
def process_data(data, action):
if action == "mean":
processed = get_mean(data)
elif action == "min":
processed = get_min(data)
elif action == "max":
processed = get_max(data)
else:
processed = "error in action"
return processed
在这个代码片段中,get_mean、get_min 和 get_max 代表执行相应计算的函数。正如你可能注意到的,process_data 的主体相当繁琐。相反,如果我们将函数作为字典对象中的值保存,我们将有一个更好的解决方案,如下面的列表所示。
列表 7.4 在字典对象中保存函数
actions = {"mean": get_mean, "min": get_min, "max": get_max}
def fallback_action(data): ❶
return "error in action"
def process_data(data, action):
calculation = actions.get(action, fallback_action)
processed = calculation(data)
return processed
❶ 当未使用定义的任何操作时,将调用后备函数。
在列表 7.4 中,我们有 actions 字典,它保存了所有需要的操作。当用户指定一个操作时,我们可以查找字典对象以定位所需的函数。通过这样做,我们消除了使用 if...elif...else... 语句的多个分支。如果你有更多操作,通过在字典对象中保存函数,你可以显著提高可读性。
如果你有复杂的 if...elif...else... 语句结构,可读性代码的可读性较低。尽可能考虑其他替代方案。
7.2.2 将函数作为参数传递给高阶函数
使用函数作为对象时的第二个含义是,当我们调用其他函数时,我们可以将函数作为参数使用。当函数可以接受其他函数作为输入(参数)或返回一个函数作为输出时,我们称这些函数为 高阶函数——在函数之上的函数。在本节中,我们将重点关注一个著名的高阶函数 map,以说明如何将一个函数作为数据(参数)传递给另一个函数。
高阶函数
高阶函数接受函数作为参数或返回函数作为输出,如下面的图所示。请注意,如果一个函数接受一个或多个函数作为参数,它就是一个高阶函数,如果一个函数返回一个函数作为其输出,它也是一个高阶函数。如果一个函数同时做这两件事,它肯定是一个高阶函数。

高阶函数使用函数作为参数和/或返回函数。
一阶函数是高阶函数的对立面。值得注意的是,高阶函数的概念在许多现代语言中都很普遍,如 JavaScript、Kotlin 和 Swift。如果你需要使用其他语言,了解这个概念会对你有所帮助。
在第 5.1 节中,我简要提到了 map 函数,其调用签名是 map(func, iterable),其中 func 是一个函数对象,通常称为映射函数。map 函数创建一个 map 迭代器,我已经向你展示了如何从 map 迭代器构建列表对象,如下所示:
numbers_str = ["1.23", "4.56", "7.89"]
numbers = list(map(float, numbers_str))
assert numbers == [1.23, 4.56, 7.89]
知识点:map 函数可以接受多个可迭代对象。当有多个可迭代对象时,每个可迭代对象的项目将根据可迭代对象的顺序发送到映射函数。map 函数最常见的使用案例是处理一个可迭代对象,所以如果你在 map 中使用多个可迭代对象,对于一些初学者来说可能会有些困惑。谨慎使用此功能。
值得注意的是,从编写 Python 代码的角度来看,如果你试图从 map 迭代器创建列表对象,最好使用列表推导式技术:
numbers_list = [float(x) for x in numbers_str]
使用高阶函数,然而,代表了函数式编程风格(正式称为函数式编程),与在 Python 中更为普遍的面向对象编程风格相对。在函数式编程风格中,我们专注于应用和组合函数。相比之下,在面向对象编程风格中,我们专注于与各种对象一起工作。由于列表推导式和生成器表达式(第 7.4 节),你可以用这两种技术替换大多数与 map 相关的用法,这些技术被认为更符合 Python 风格。因为 map 对象可以是一个迭代器,一个有效的用例是在涉及的操作复杂时,在 for 循环中实现它:
for number in map(float, numbers_str):
# operation 1
# operation 2
# operation 3
# operation 4
...
在这个例子中,for 循环包含了多个不适合放入列表推导式中的操作。在这种情况下,你应该利用 map 迭代器,它逐个渲染其项目,而无需你构建列表对象。
7.2.3 使用函数作为返回值
在前一节中,我们关注了如何通过将它们作为参数传递给高阶函数(如 map)来使用函数作为对象。在本节中,我们将关注使用函数作为对象的第三个含义。具体来说,我将向你展示如何创建一个返回函数的高阶函数。
我们使用 def 来表示我们正在创建一个函数。你可能不知道我们可以在另一个函数内部嵌入函数的定义,遵循以下一般格式:
def outside(x):
def inside(y):
pass
pass
提示:我们使用 pass 语句来满足期望语句的语法要求。
假设我们想要创建一个高阶函数。使用这个新函数,我们可以创建增加预定义数字的递增函数。应用前面的语法,我们可以得出以下列表中的解决方案。
列表 7.5 创建一个返回函数的函数
def increment_maker(number):
def increment(num0):
return num0 + number
return increment
可读性 在内部函数和外部函数的返回语句之间添加一个空行以提高可读性。作为一般规则,空格和空行是不同逻辑组件之间的自然分隔符。
如列表 7.5 所示,外部函数,称为外部函数,接受一个数字参数。在 increment_maker 函数内部,我们定义了一个内部函数:增量函数,它接受另一个数字(num0 参数)。与返回 None 或某种形式数据的首次函数不同,高阶函数 increment_maker 返回增量函数作为其输出。现在我们可以看到这个高阶函数是多么有用,因为它允许我们创建一系列的增量函数,如下一个列表所示。
列表 7.6 通过调用高阶函数创建函数
increment_one = increment_maker(1)
increment_three = increment_maker(3)
increment_five = increment_maker(5)
increment_ten = increment_maker(10)
increment_one(99), increment_three(88), increment_five(80),
➥ increment_ten(100)
# output: (100, 91, 85, 110)
如列表 7.6 所示,我们可以通过指定所需的增量值方便地创建多个函数。当我们调用这些函数时,我们会得到预期的结果。
7.2.4 讨论
作为面向对象的语言,Python 允许我们通过将函数视为常规对象来使用它们,从而提供了额外的灵活性。您可能会想知道列表 7.5 和 7.6 中所示的示例是否过于简单而不实用,我完全同意。在这里,我使用这个简单的示例来提供一个概念证明。在 7.3 节中,我将讨论使用装饰器,这是一种基于创建高阶函数的实用技术。
7.2.5 挑战
在列表 7.4 中,我们将函数保存在字典对象中。除了这些函数之外,您是否理解创建 fallback_action 函数的理由?相关地,为什么我们使用 get 方法而不是下标表示法?
提示 您永远无法预测用户将如何调用您定义的函数。您如何处理 process_data([1, 2, 3], "maxx") 这样的可能调用?
7.3 我该如何使用装饰器检查函数的性能?
函数是任何应用程序的基本组成部分。您应用程序的性能,尤其是其响应性,在很大程度上取决于您的函数处理数据的速度有多快。因此,在开发过程中,我们通常希望记录函数的运行速度。使用一种简单的方法,我们可能会创建如下列表中所示解决方案。
列表 7.7 记录函数的性能
import random
import time
def example_func0():
print("--- example_func0 starts")
start_t = time.time()
random_delay = random.randint(1, 5) * 0.1 ❶
time.sleep(random_delay) ❶
end_t = time.time()
print(f"*** example_func0 ends; used time: {end_t - start_t:.2f} s")
def example_func1():
print("--- example_func1 starts")
start_t = time.time()
random_delay = random.randint(6, 10) * 0.1 ❷
time.sleep(random_delay) ❷
end_t = time.time()
print(f"*** example_func1 ends; used time: {end_t - start_t:.2f} s")
❶ 添加一个随机延迟(0.1-0.5 秒)来模拟实际操作
❷ 添加一个随机延迟(0.6-1 秒)来模拟实际操作
在列表 7.7 中,我们计算函数开始运行和结束时的时间差,以便我们知道它需要多长时间。当这个函数被调用时,我们可以观察其性能:
example_func0()
# output the following lines:
--- example_func0 starts
*** example_func0 ends; used time: 0.20 s
example_func1()
# output the following lines:
--- example_func1 starts
*** example_func1 ends; used time: 0.70 s
可读性 如果您预计会有许多具有相似单词的输出行,那么有一个有规律的缩进前缀是个好主意。这些前缀作为独特的视觉提示。
在你的应用程序中,你不会只有一个或两个需要观察的函数。很可能你需要监控数十个或数百个函数的性能。将 7.7 列表(粗体突出显示)中的相关代码行添加到所有这些函数中可能会很繁琐。如果你还记得 DRY(不要重复自己)原则,如果有显著的重复,我们几乎可以肯定需要重构我们的代码。在本节中,我将向你展示如何使用装饰器来解决这类问题:将共享操作应用于多个函数。
7.3.1 装饰函数以显示其性能
我已经提到过装饰器几次,但你可能不知道这个术语的含义。"装饰器"是提供额外功能给被装饰函数的函数。需要注意的是,装饰器不会改变被装饰函数的工作方式;因此,我们称这个过程为“装饰”。在本节中,我们将构建一个装饰器来跟踪函数的性能。
在介绍机制之前,我会先展示一些代码,然后再解释事情是如何工作的。现在,你可以快速浏览一下 logging_time 函数,并从下一列表中的@logging_time 行开始阅读代码。
列表 7.8 使用性能日志装饰器
import random
import time
def logging_time(func):
def logger(*args, **kwargs):
print(f"--- {func.__name__} starts")
start_t = time.time()
value_returned = func(*args, **kwargs)
end_t = time.time()
print(f"*** {func.__name__} ends; used time: {end_t - start_t:.2f} s")
return value_returned
return logger
@logging_time
def example_func2():
random_delay = random.randint(3, 5) * 0.1
time.sleep(random_delay)
example_func2()
# output the following two lines:
--- example_func2 starts
*** example_func2 ends; used time: 0.40 s
如列表 7.8 所示,当我们调用 example_func2 函数时,我们得到显示其性能的输出。然而,example_func2 的主体中并没有代码做这样的事情。那么是什么让 example_func2 输出其性能数据呢?
在 example_func2 上方使用@logging_time 产生的魔法效果。这种特殊语法与装饰有关;这意味着下面定义的函数将被装饰器函数 logging_time 装饰。我们可以将这个装饰器函数应用于我们喜欢的任何函数,就像这个例子一样:
@logging_time
def example_func3():
pass
@logging_time
def example_func4():
pass
@logging_time
def example_func5():
pass
可维护性装饰器提取出可以被多个函数使用的共享实用功能。你只需要维护装饰器函数,而不是所有被装饰的函数。
我们已经看到,我们可以将装饰器函数应用于多个函数以执行共享功能。但我们还没有讨论构成装饰器的要素,这是下一节的主题。
7.3.2 解构装饰器函数
在 7.2 节中,我提到装饰器是一种高阶函数。如列表 7.8 所示,logging_time 函数是一个装饰器——一种闭包形式。(有关更多信息,请参阅以下侧边栏。)通过这个例子,我们将在本节中通过识别其关键元素来解构装饰器。
装饰器背后的:闭包
装饰器是一种闭包形式。从广义上讲,闭包在许多现代语言中代表了一种高级编程概念,包括 Kotlin、Swift 和当然还有 Python。闭包是一个在外部函数中创建并返回的内部函数。此外,它要求内部函数使用外部函数作用域中的变量(或变量),这种技术称为非局部变量绑定****。
正如你所注意到的,涉及了几个新的术语,包括作用域和非局部变量绑定。要完全解释这个概念可能需要整个章节,甚至更多。尽管如此,这个主题是一个重要的主题,可以帮助你理解相关的技术,特别是装饰器。因此,我提供了一个图来展示闭包的基本组成部分。请注意,你可以使用闭包的应用,例如装饰器,而不必完全理解闭包,所以如果你觉得这个概念不太明白,不必担心。

返回函数作为输出的高阶函数
在这个图中,请注意三个要点:在外部函数的主体中,我们创建了一个内部函数;内部函数使用属于外部函数的参数;外部函数将其返回的内部函数作为其输出。
当我们通过调用外部函数创建一个函数时,我们正在创建一个闭包。如果你检查闭包,你会发现它确实是外部函数中创建的内部函数,你也可以调用这个闭包:
>>> closure = outer(100)
>>> closure
<function outer.<locals>.inner at 0x7f89a812d5a0>
>>> closure()
105
检查闭包的更深入的方法还有很多。例如,我们可以检查闭包绑定了哪些变量:
>>> closure.__closure__[0].cell_contents
100
>>> closure.__closure__[1].cell_contents
5
基本结构:生成闭包的函数
如果我们省略 logging_time 函数的实现细节,我们可以得到以下基本结构:
def logging_time_backbone(func):
def logger(*args, **kwargs):
# covering the body's details later
pass
return logger
如果你记得,这个结构代表了一个高阶函数,它接受一个函数作为输入,并返回一个函数作为输出。本质上,装饰器处理一个函数,我们称这个过程为装饰。但是,幕后发生了什么?为了说明底层机制,我将首先展示这个代码片段:
def before_deco():
pass
after_deco = logging_time(before_deco)
after_deco()
# output the following lines:
--- before_deco starts:
*** before_deco ends; used time: 0.00 s
观察到调用 after_deco 函数会产生与之前使用@logging_time 装饰的其他函数相同的性能相关输出,这很有趣。如果你退一步,你会看到 after_deco 函数是通过调用装饰器函数 logging_time 并将 before_deco 函数传递给它来创建的。因此,正如你可能已经猜到的,装饰是一个通过将现有函数发送到装饰器来创建闭包的过程。图 7.2 展示了这个过程。

图 7.2 应用装饰器是创建装饰器函数的闭包的过程。装饰器函数是一个高阶函数,它接受一个函数(待装饰的函数)并返回一个函数(装饰后的函数,一个闭包)。请注意,我们可以在赋值语句中使用相同的变量名。Python 解释器会首先评估右侧,并将评估后的值赋给左侧。因为名称相同,旧变量的值会被新值替换。
内部函数中的 *args 和 **kwargs
在 6.4 节中,你学习了 *args 和 **kwargs 的概念,并看到了如何使用它们分别允许用户传递任意数量的位置参数和关键字参数。在内部函数中使用 *args 和 **kwargs 的理由是相同的:你希望装饰器与所有函数兼容,无论它们的调用签名如何。
为了说明使用 *args 和 **kwargs 的必要性,考虑一个不使用它们的装饰器,看看我们可能会遇到的问题。为了简单起见,装饰器 monitor 在函数被调用时报告:
def monitor(func):
def monitored():
print(f"*** {func.__name__} is called")
func()
return monitored
如果我们为不带任何参数的函数使用这个装饰器,一切都会顺利:
@monitor
def example0():
pass
example0()
# output: *** example0 is called
然而,如果我们为接受一个或多个参数的函数使用这个装饰器,我们将会遇到一个 TypeError:
@monitor
def example1(param0):
pass
example1("a string")
# ERROR: TypeError: monitor.<locals>.monitored() takes 0 positional
➥ arguments but 1 was given
错误信息告诉我们问题所在。在装饰器函数 monitor 的第四行中,我们通过使用 func() 调用装饰后的函数,但没有指定任何参数!但是装饰后的 example1 函数期望一个位置参数。正如你可以想象的那样,这种不兼容性显著限制了装饰器的使用范围。因此,为了最大限度地提高装饰器的灵活性,在内部函数中包含 *args 和 **kwargs 是至关重要的,因为创建的内部函数将是装饰后的函数,使用 *args 和 **kwargs 使得内部函数与任何调用签名兼容。
可维护性 在装饰器的内部函数中使用 *args 和 **kwargs 可以提供最大的灵活性给装饰器。
内部函数中的返回语句
6.2 节提到,每个 Python 函数要么隐式返回 None,要么显式返回一个返回值。因此,当我们定义内部函数时,我们不应该忘记添加返回语句。具体来说,返回值应该是通过调用装饰后的函数获得的值。
在相关的问题上,注意返回语句的位置。正如你所知,任何位于返回语句下面的代码都无法执行,因为返回意味着当前执行已完成,我们将控制权交回给调用者,执行是从那里开始的。因此,当我们想在调用装饰函数后应用操作时,我们使用一个临时变量来存储返回值。在额外的操作之后,我们返回这个变量。这正是我们在列表 7.8 中的 logging_time 函数所做的事情。图 7.3 展示了对比。

图 7.3 将返回语句放置在内部函数的末尾。首先,我们不应该忘记添加返回语句。否则,我们正在改变装饰函数的行为,因为预期的返回值在内部函数中消失了。其次,我们应该将返回值放置在内部函数的末尾,而不是中间的某个位置。
7.3.3 包装以传递装饰函数的元数据
到目前为止,我已经介绍了装饰器的核心特性和如何创建一个 logging_time 装饰器来通过装饰监控任何函数的性能。但是,装饰过程可能会使装饰函数丢失其元数据,例如其文档字符串。在本节中,我们将看到如何保留装饰函数的元数据。在你跳入解决方案之前,检查以下代码在装饰后可能出现的潜在问题:
def say_hi(person):
"""Greet someone"""
print(f"Hi, {person}")
@logging_time
def say_hello(person):
"""Greet someone"""
print(f"Hello, {person}")
print(say_hi.__doc__, say_hi.__name__, sep="; ")
# output: Greet someone; say_hi
print(say_hello.__doc__, say_hello.__name__, sep="; ")
# output: None; logger
如此代码所示,未经装饰,我们通过访问其 doc 属性检索了 say_hi 函数的文档字符串。相比之下,经过装饰后,我们丢失了 say_hello 的文档字符串。以类似的方式,装饰改变了函数的名称(可以通过 name 属性访问)。这些函数属性,包括 doc 和 name(称为其元数据),会受到装饰过程的影响。为什么?在继续之前,给自己几秒钟时间思考一下。
提示 装饰器将原始函数转换为闭包,这是一个由装饰器创建的内部函数。
当我们定义一个没有装饰器的函数时,标识符(函数名)代表定义的函数及其相关操作。相比之下,当我们用装饰器定义一个函数时,装饰函数不仅仅是一个函数,它看起来是这样的。相反,内部函数是由装饰器函数创建并返回的,这被称为 *闭包**。因此,访问 say_hello 的 doc 属性等同于访问 logging_time 的内部函数,logger 的 doc 属性。为了证明这一点,我们可以通过向内部函数添加一些文档字符串来进行实验:
def logging_time_doc(func):
def logger(*args, **kwargs):
"""Log the time"""
print(f"--- {func.__name__} starts")
start_t = time.time()
value_returned = func(*args, **kwargs)
end_t = time.time()
print(f"*** {func.__name__} ends; used time:
➥ {end_t - start_t:.2f} s")
return value_returned
return logger
@logging_time_doc
def example_doc():
"""Example function"""
pass
print(example_doc.__doc__)
# output: Log the time
输出支持我们的预测,因为的确是装饰器内部函数的文档字符串。如果我们使用这个装饰器为多个函数,所有被装饰的函数都将具有相同的文档字符串和与内部函数匹配的名称!我们不能这样做事。幸运的是,Python 提供了一个解决方案:我们可以使用 functools 模块中的 wraps 装饰器,它负责为被装饰的函数保持正确的元数据。观察下一个列表中的这个效果。
列表 7.9 包装被装饰的函数
import functools
def logging_time_wraps(func):
@functools.wraps(func)
def logger(*args, **kwargs):
"""Log the time"""
print(f"--- {func.__name__} starts")
start_t = time.time()
value_returned = func(*args, **kwargs)
end_t = time.time()
print(f"*** {func.__name__} ends; used time:
➥ {end_t - start_t:.2f} s")
return value_returned
return logger
@logging_time_wraps
def example_wraps():
"""Example function"""
pass
print(example_wraps.__doc__, example_wraps.__name__, sep="; ")
# output: Example function; example_wraps
我们使用 wraps 装饰器(列表 7.9 中的粗体)来装饰内部函数 logger。值得注意的是,这个装饰器与您所学的不同;它除了装饰 logger 函数外,还接受被装饰的函数(func)作为参数。换句话说,wraps 装饰器使用 func 和 logger 作为其参数。这个特性是有效的,因为装饰器本质上是一等函数,并且它们可以接受尽可能多的函数作为参数来使用。更普遍地说,这个特性——接受参数的装饰器——更为高级,通常我们不需要使用它。但我在本节结束时确实想挑战一下您!
可维护性 不要忘记使用 wraps 装饰器来保持被装饰函数的元数据,特别是它的文档字符串和名称。
7.3.4 讨论
本节的主题可能代表了我迄今为止所覆盖的最难的主题之一。尽管如此,在学习完材料后,你应该感到自豪;我们克服了一些复杂的概念,并创建了一个有用的日志装饰器。你应该知道闭包的构成以及为什么装饰器是闭包技术的应用。在最佳实践方面,当你定义装饰器时,使用 wraps 装饰器来传递被装饰函数的元数据是很重要的。
7.3.5 挑战
Mike 是一位使用 Python 作为工作语言的网页开发者。他的工作需要他定义一些可以接受参数的装饰器。作为一个最佳实践,你能帮助他编写一个装饰器函数——比如说,命名为 logging_time_app 的装饰器函数——接受一个参数吗?这个装饰器执行与 logging_time 装饰器相同的任务。参数是一个字符串,用来表示应用程序的名称,它作为 print 函数中所有输出字符串的前缀。当我们使用装饰器时,我们希望达到以下效果:
@logging_time_app("Task Tracker")
def example_app():
pass
example_app()
# output the following lines:
Task Tracker --- example_app starts
Task Tracker *** example_app ends; used time: 0.00 s
提示 1 当在 @decorator(param) 中使用参数时,我们首先用 param 调用高阶函数装饰器,然后它返回另一个装饰器,可能称为 true_decorator。接下来,true_decorator 被应用于待装饰的函数,就像我们使用了 @true_decorator 一样。
提示 2 当两个高阶函数都是装饰器时,不要害怕在另一个高阶函数内部创建一个高阶函数!
7.4 我该如何使用生成器函数作为内存高效的数据提供者?
任何应用程序的核心是数据。随着数据科学和机器学习的出现,许多用户已经使用 Python 处理了大量的数据——数 GB 或更多。当你处理这种规模的数据时,将所有数据加载到内存中可能需要几分钟甚至几个小时。当涉及多个数据处理步骤时,每个步骤都可能需要很长时间,如果任何步骤出错,代码就难以调试。除了处理过程中的延长等待时间外,可能最大的限制是某些计算机没有足够的内存来处理如此多的数据。
为了说明,考虑一个涉及大量数据的一个简单示例。(请注意,我本可以使用更大的数字,但这个例子可能不容易在普通计算机上运行,所以我使用了适度的较大数字。)假设我们需要计算从 1 到 1,000,000 生成的完全平方数的和。使用典型方法,我们创建一个列表对象来保存这些数字,然后计算它们的和:
upper_limit = 1_000_000
squares_list = [x*x for x in range(1, upper_limit + 1)] ❶
sum_list = sum(squares_list)
❶ 停止索引没有被使用。将其更正为 1。
问题 你能编写一个由 logging_time 装饰的函数来查看运行此求和操作的时间成本吗?
如果你运行代码,你会注意到获取结果需要相当长的时间。并且请注意,该对象消耗了相当大的内存:
print(squares_list.__sizeof__())
# output: 8448712 ❶
❶ 由于不同的存储机制,不同的计算机可能会产生不同的结果。
在本节中,你将学习如何使用生成器函数以内存高效的方式提供所需的数据。
7.4.1 创建一个生成器以产生完全平方数
作为一种特殊的迭代器,生成器是由生成器函数创建的。因为生成器是一个迭代器,它可以逐个渲染其项目。生成器之所以特殊,是因为它不存储其项目,而是在需要时检索和渲染其项目。这一特性意味着它是一个内存高效的渲染迭代器。在本节中,我们将重点关注生成器。
首先,让我们用新技术解决这个问题:使用生成器来计算完全平方数的和。下一列表中的代码展示了解决方案。
列表 7.10 创建一个生成器以计算完全平方数的和
def perfect_squares(limit):
n = 1
while n <= limit:
yield n * n
n += 1
squares_gen = perfect_squares(upper_limit)
sum_gen = sum(squares_gen)
assert sum_gen == sum_list == 333333833333500000
perfect_squares 函数是一个生成器函数。通过使用 upper_limit 调用此函数,我们创建了一个名为 squares_gen 的生成器。这个生成器渲染完全平方数:1²、2²、3²、4²、... 直到 1,000,000²。正如预期的那样,从生成器获得的这些完全平方数的和与从列表对象 squares_list 获得的结果相同。
这个生成器之所以能工作,是因为它位于生成器函数的主体中。最显著的特征是yield关键字,这是生成器函数的标志。每当操作执行到yield行时,它就会提供n * n这个项目。生成器最酷的地方在于它能够记住下一个应该产生的项目。图 7.4 展示了生成器是如何工作的。

图 7.4 创建和使用生成器的流程。调用生成器函数创建一个生成器。当我们使用生成器时,它通过 while 循环遍历其适用的项目。每次遇到yield关键字时,它都会通过在幕后调用next来产生一个项目,如图中的灰色框所示。当条件(n <= limit)不再满足时,while 循环结束,就没有更多机会遇到yield关键字,因此迭代结束。
如图 7.4 所示,生成器在核心上是一个迭代器,因此使用生成器涉及到调用next函数。每次调用squares_gen.next()都会重新启动生成器的执行,从上次停止的地方开始:最后一个yield执行之后的行。由于yield语句是 while 循环的一部分,循环会持续运行,并且每个循环都会遇到一次yield项。当循环结束时,所有项目都会被产生,生成器耗尽,迭代完成。
提示:当你手动在迭代器上调用next时,你会遇到StopIteration异常。
作为一个重要的概念,yield与return不同,return会终止当前执行并将控制权交回调用者。相比之下,yield会暂停当前执行并将控制权暂时交回调用者。当请求时,它继续执行。这种情况就像在双车道路上开车。当需要时,你可以让其他车辆先行,让行后,你就可以回到原来的车道。
7.4.2 使用生成器提高内存效率
在前面的章节中,你学习了如何从一个生成器函数创建生成器。但我们为什么要费心使用生成器呢?在本节中,我们将找到答案。
生成器最重要的特性是当需要时才会渲染一个项目。与这个特性相关的一个计算机编程概念叫做*惰性求值**,其中特定的操作或变量只有在需要时才会被评估。在生成器的术语中,它们最初不会创建所有项目。相反,生成器只有在被调用时才会创建下一个项目。
惰性求值
惰性求值以各种形式存在于不同的编程语言中,例如 Kotlin 和 Swift。一个对象可能有一个包含大量数据的属性,但这个属性并不是必需的。当我们创建这样的对象时,在可以使用对象之前,准备这个属性可能需要很长时间。相反,我们可以使这个属性“惰性”评估,这意味着我们将没有那个属性来创建对象。第一次调用时,我们将准备这个属性。
由于它们在请求时才生成项目,生成器是内存高效的。相比之下,我们已经看到,从 1 到 1 百万的完全平方数的列表对象消耗了超过 8 MB(从 8,448,712 字节转换而来)。现在,是时候观察一个生成器,它能够生成相同数量的数据,需要多少内存了:
squares_gen = perfect_squares(upper_limit)
print(squares_gen.__sizeof__())
# output: 88 ❶
❶ 你的电脑可能显示不同的值。
生成器的大小仅为 88 字节,大约是列表对象的 0.001%。它之所以如此小,是因为它只需要知道它的当前状态;当它需要下一个项目时,它可以从当前状态开始并创建下一个项目。相比之下,列表对象在使用项目之前需要预先加载所有项目。
7.4.3 在适用的情况下使用生成器表达式
我们已经看到了生成器是多么有用。但在使用它之前,我们需要创建一个生成器函数,这可能会很繁琐。在本节中,你将学习一种创建生成器的替代方法:使用称为生成器表达式的过程中的表达式。
当你在 5.2 节中学习到列表推导时,我提到没有元组推导,否则可以使用以下语法:(expression for x in iterable)。实际上,这个表达式是生成器表达式的语法。现在让我们将 perfect_squares 函数重写为生成器表达式:
>>> squares_gen_exp = (x * x for x in range(1, upper_limit))
>>> squares_gen_exp
<generator object <genexpr> at 0x7f89a8111f50>
生成器表达式不是在生成器函数中使用 yield 关键字,而是直接使用表达式来表示应该渲染的数据。从语法角度来看,你必须注意使用括号;否则,如果你不小心使用了方括号,你将产生一个列表。为了表明生成器是一个迭代器,你可以使用 next 函数逐个检索生成器中的项目:
>>> next(squares_gen_exp)
1
>>> next(squares_gen_exp)
4
>>> next(squares_gen_exp)
9
让我们计算生成器表达式的总和:
>>> sum_gen_exp = sum(squares_gen_exp)
>>> sum_gen_exp
333332833333499986
它正在工作!但是等等——为什么与之前我们计算出的总和相比,这个总和少了 14?
问题:在使用 squares_gen_exp 之前我们做了什么?
如我之前所述,生成器通过记住其状态来懒加载其项。第一次 next 调用检索 1,第二次 next 调用检索 4,第三次 next 调用检索 9。当我们调用 sum(squares_gen_exp) 时,生成器仍然记住其状态,因此它开始渲染下一个项,即 16。正如你应该注意到的,求和结果的差异是由于无法使用前三个已经被手动调用 next 三次消耗掉的项。
从语法角度来看,我们可以直接使用生成器表达式调用 sum 函数,这消除了创建中间变量的需要。当生成器很简单时,这是首选的方法:
>>> sum(x*x for x in range(4))
14
请注意,在这个表达式中,我们省略了生成器表达式的括号,因为如果它被用于另一对括号内,则是可选的。
7.4.4 讨论
在底层,生成器的实现涉及使用 yield 关键字。除了生成器之外,一种高级技术,协程,也使用 yield,这些协程被称为基于生成器的协程。然而,这些协程正在从 Python 中逐步淘汰,你可能在仅使用较旧版本的 Python 的遗留项目中看到这种技术。所以,如果你不太了解基于生成器的协程,请不要担心。
7.4.5 挑战
詹姆斯在数学系为本科生教授入门级 Python 编程。为了使用一个熟悉的概念,他想到了斐波那契数列——一个数列,其值是前两个数的和,如 0, 1, 1, 2, 3, 5, 8, 13。他挑战他的学生编写一个具有上限的生成器函数,以生成一个生成器,直到达到指定的限制时渲染斐波那契数。
提示:你可以自己定义前两个数字,然后通过使用定义值[n+2] = value[n] + value[n+1]来构建公式。
7.5 我如何创建部分函数以简化常规函数调用?
函数并不是与应用程序的其他组件隔离的。相反,它们通过接收输入并返回处理后的输出与其他实体进行交互。为了增加函数的灵活性,我们经常在函数中定义多个参数,以便它可以处理不同形式的输入,从而为不同的场景推导出所需的结果。
假设你使用 Python 进行数据科学工作。你有一个以下函数来使用指定的数据集进行统计分析:
def run_stats_model(dataset, model, output_path):
# process the dataset
# apply the model
# save the stats to the output path
calculated_stats = 123 ❶
return calculated_stats
❶ 使代码运行的标称值
这个函数如此重要且通用,以至于你在多个项目中都会使用它。在你的每个项目中,你使用相同的模型并将输出到不同的数据集上的相同文件夹。以下代码片段可能会揭示你在项目之间可能正在做的事情:
# Project A
run_stats_model(dataset_a1, "model_a", "project_a/stats/")
run_stats_model(dataset_a2, "model_a", "project_a/stats/")
run_stats_model(dataset_a3, "model_a", "project_a/stats/")
run_stats_model(dataset_a4, "model_a", "project_a/stats/")
如你所意识到的那样,这里有一个重复模式,因为相同的参数被用于多个函数调用。你的第一个反应可能是将默认参数应用到 run_stats_model 函数上。然而,这个解决方案并不理想,因为你可能仍然需要为其他项目指定这些参数:
# Project B
run_stats_model(dataset_b1, "model_b", "project_b/stats/")
run_stats_model(dataset_b2, "model_b", "project_b/stats/")
run_stats_model(dataset_b3, "model_b", "project_b/stats/")
run_stats_model(dataset_b4, "model_b", "project_b/stats/")
# Project C
run_stats_model(dataset_c1, "model_c", "project_c/stats/")
run_stats_model(dataset_c2, "model_c", "project_c/stats/")
run_stats_model(dataset_c3, "model_c", "project_c/stats/")
run_stats_model(dataset_c4, "model_c", "project_c/stats/")
在下一节中,你将了解一种名为部分函数的新技术,我们将看到如何使用部分函数在项目内部共享参数时简化函数调用。
7.5.1 “本地化”共享函数以简化函数调用
对于这个业务需求,我们在每个项目中使用相同的模型和输出路径来调用 run_stats_model 函数。由于 run_stats_model 在多个项目中共享,因此在每个项目中使用此函数是局部的。因此,我们可以将这种需求操作化为一个本地化问题。本节讨论了一个使用我们现有知识的工作解决方案。
由于每个项目使用相同的模型和输出路径,我们可以为每个项目创建共享函数的变体版本。例如,在 Project A 文件顶部,我们可能创建一个如下所示的函数:
def run_stats_model_a(dataset):
model_stats = run_stats_model(dataset, "model_a", "project_a/stats/")
return model_stats
可读性 尽管我可以写 return run_stats_models(dataset, "model_a", "project_a/stats/"), 我仍然想使用一个中间变量来表示函数调用的确切返回值。一般来说,返回一个明确定义的变量而不是直接从另一个函数调用返回东西是一个好主意。
run_stats_model_a 函数相当直接。它提供了一个便利的函数调用,该调用被 run_stats_models 函数包装。使用这个局部函数,所有对 run_stats_models 的原始调用都变成了以下形式:
# Project A
run_stats_model_a(dataset_a1)
run_stats_model_a(dataset_a2)
run_stats_model_a(dataset_a3)
run_stats_model_a(dataset_a4)
7.5.2 创建局部函数以定位函数
前一节定义了一个常规函数来定位共享函数。它是有效的。但它重写了轮子,因为 Python 已经为我们实现了这样的功能。更 Pythonic 的解决方案是使用 partial 函数来定位共享函数:
from functools import partial
run_stats_model_a = partial(run_stats_model, model="model_a", output_path="project_a/stats/")
run_stats_model_a("dataset_a")
# output: 123
partial 函数存在于 functools 模块中,该模块是 Python 标准库中包含一系列高级函数相关工具的模块。在 partial 函数中,我们指定共享函数以及我们想要设置的任何附加参数——在这种情况下,特定于项目的模型和输出路径。
提醒:我们之前使用 wraps 来在装饰期间保持函数的元数据。wraps 函数也在 functools 模块中。
创建的函数 run_stats_model_a 被称为部分函数。当我们调用它时,我们不再需要指定共享参数,这些参数已经被处理好了。使用 partial 函数技术,我们可以为每个项目创建单独的部分函数,并且它们可以显著简化调用签名,使代码更易读。
7.5.3 讨论
整个这一节(7.5)内容简短。我通过一个简单的例子向你展示一个有用的技巧:偏函数。当你积累代码库时,你会发现你经常需要在多个位置使用一些函数。在这种情况下,你可以从现有函数创建偏函数。这些偏函数在某个位置冻结共享参数,你可以省略这些参数以提高代码的清晰度。
7.5.4 挑战
偏函数是从其他函数创建的。你如何找出偏函数是从哪个函数创建的?
提示:与普通函数相比,偏函数有额外的属性。你可以通过调用 dir(partial_function_created)来检查其属性。检查列表以查看哪个属性是相关的。
摘要
-
Lambda 函数旨在执行一次性的小任务,这意味着你不会将 lambda 函数赋值给变量。
-
虽然 lambda 函数很方便,但不要重新发明轮子。在适用的情况下,使用内置函数来完成相同的工作,而不需要定义 lambda 函数,例如使用内置的 int 而不是 lambda x: int(x)。
-
函数在 Python 中是一等公民,因为它们是其他对象。你可以对对象执行的所有操作也可以应用于函数。
-
高阶函数接受函数作为输入和/或返回函数作为输出。一些值得注意的内置高阶函数包括 sorted、map 和 filter。
-
使用装饰器,我们可以将额外的功能应用于其他函数,而无需更改被装饰函数的原始功能。
-
虽然本章没有正式介绍,但闭包是一个重要的编程概念。它们是高阶函数创建并返回的内联函数,并且它们还绑定高阶函数定义的变量。装饰器是闭包技术的一种应用。
-
我们可以从生成器函数创建生成器,它使用 yield 关键字产生一个项目并暂时放弃控制。当再次调用时,它会记住其状态,并通过渲染下一个适用的项目或完成迭代来继续执行。
-
与其他迭代器相比,生成器更节省内存,因为它们不需要在开始迭代之前加载所有元素,而传统的迭代器(如列表和元组)必须在迭代之前加载所有元素。
-
我们使用偏函数来冻结共享函数的一些参数,以便我们有一个专门为项目服务的本地化版本的功能。偏函数消除了指定冻结参数的需要,这使得你的代码更简洁。
第三部分 定义类
内置数据结构是最通用的数据类型,无论我们正在构建什么类型的应用程序,我们都可以使用它们。尽管这些数据类型很普遍,但它们的通用性质并不允许我们为这些对象定义定制的数据和操作。因此,我们几乎总是必须定义自己的类。在这些类中,我们定义了各种属性,为我们提供了存储定制数据的隔间,以及一系列执行定制操作的方法。随着我们应用程序的日益复杂,我们定义了多个类,并需要确保这些类能够协调一致地共同工作。正如你可以想象的那样,定义表现良好的类以服务于应用程序是一项具有挑战性的任务。在本部分,你将学习定义自定义类的必要技术。
8 定义用户友好的类
本章涵盖了
-
定义初始化方法
-
创建实例、静态和类方法
-
将封装应用于类
-
创建适当的字符串表示
-
定义超类和子类
任何应用程序的核心是数据。尽管内置数据类型对于管理数据很有用,但你可能会发现它们有限,因为它们只具有设计用来处理最通用功能(包括命名元组(第 3.3 节))的属性和方法。你可能已经注意到,你没有有用的方法来操作带有命名元组的任务。但是,任务管理应用程序(就像所有通用应用程序一样)解决特定的业务需求,这需要能够处理这些需求的数据模型。因此,自定义类是你应用程序中不可或缺的元素。通过为类定义适当的属性,你可以更好地捕捉到应用程序中所需的数据。通过定义适当的方法,你可以更好地处理应用程序中的数据。
在本章中,我专注于如何为你的类定义属性和不同种类的的方法,主要使用任务管理应用程序中的 Task 类作为讨论相关主题的一部分。定义一个好的自定义类的目标是使其用户友好——不仅在其属性和方法方面(应该提供什么)方面健壮,而且在实现其功能时的可维护性方面(如何组织)。
8.1 我该如何定义一个类的初始化方法?
当我们使用内置类,如 list 和 dict 时,我们可以使用它们的构造函数来创建这些类的实例对象(或实例)。创建实例的过程被称为*实例化**:你创建了实例对象。在底层,创建实例对象涉及调用 init 方法,如下一列表所示。
列表 8.1 创建一个没有有意义初始化的 Task 类
class Task:
def __init__(self):
print("Creating an instance of Task class")
task = Task()
# output: Creating an instance of Task class
正如你所见,我们调用构造函数 Task()来创建一个实例,这会触发调用 init 方法。如果你想知道这个方法(init)的名字意味着什么,它代表的是初始化**,为实例对象设置初始状态。因此,这个方法是在自定义类中几乎总是定义的最基本的方法。在本节中,你将学习定义初始化方法:init 的最佳实践。
8.1.1 揭秘 self:init 中的第一个参数
在列表 8.1 中,尽管我们没有为 init 方法提供任何实现,但该方法仍然有一个参数:self。更广泛地说,如果你曾经阅读过别人的代码,你应该看到他们的 init 方法也使用 self 作为其第一个参数。如果你想知道 self 是什么,本节通过回答四个问题来揭开它的神秘面纱:
-
self 代表什么?
-
为什么我们不需要为 self 传递参数?
-
Self 是一个关键字吗?
-
我们是否必须使用 self 作为参数名?
Self:实例对象
第一个问题是什么 self 代表。当你在一个类中定义方法时,大多数情况下,这些方法是为了操作实例对象,例如__init__,它为新实例对象设置初始属性。因此,我们需要一种方便的方式来引用实例对象。如果你恰好了解其他面向对象编程(OOP)语言,你知道这些语言可能使用 this、that、self 或 it 来引用实例对象。Python 在方法定义中使用 self 来引用实例对象。为了证明 self 指向新创建的实例对象,我们可以使用内置的 id 函数,它唯一地标识内存中的对象,如下所示:
class Task:
def __init__(self):
print(f"Memory address (self): {id(self)}")
task = Task()
# output: Memory address (self): 140702458470768
task_address = f"Memory address (task): {id(task)}"
print(task_address)
# output: Memory address (task): 140702458470768 ❶
❶ 在您的计算机上可能会得到不同的值,并且每次运行都可能得到新的值。
打印输出显示自我和任务的内存地址是相同的,这意味着它们是同一个对象——新创建的 Task 类实例对象。
提示:id 函数检查对象的内存地址。因为每个对象都有一个唯一的内存地址,当对象具有相同的内存地址时,它们就是同一个对象。
隐式设置 self
当我们通过调用构造函数Task()创建实例对象时,我们不使用任何参数。但是底层的__init__方法确实需要一个参数:self。你如何解释这种明显的冲突?原因是 self 参数被 Python 隐式设置。正如你将看到的,Python 通过调用__new__创建实例对象,并将其作为 self 参数传递给__init__。为了理解 self 参数的隐式设置,观察以下代码片段:
class Task:
def __init__(self):
print(f"__init__ gets called, creating object at {id(self)}")
def __new__(cls):
new_task = object.__new__(cls)
print(f"__new__ gets called, creating object at {id(new_task)}")
return new_task
task = Task()
# output the following lines:
__new__ gets called, creating object at 140702458469952
__init__ gets called, creating object at 140702458469952
在此代码中,我们调用构造函数Task()。请注意,构造过程涉及底层连续自动调用两个特殊方法:__new__和__init__。__new__方法创建并返回(粗体)新的实例对象,而__init__方法不返回任何内容。这种返回值差异的原因是在调用__new__之后,你需要引用你刚刚创建的实例对象。因此,如果__new__方法不返回那个新实例对象,你就无法访问和使用它。相比之下,__init__方法接受 self 作为参数;它指向新的实例并在原地操作实例。
为了模拟实例构造是一个两步过程,它调用__new__和__init__,我们可以手动调用这两个方法。请注意,这种模拟是为了演示底层机制,在代码库中很少使用:
task = Task.__new__(Task)
# output: __new__ gets called, creating object at 140702458476192
Task.__init__(task)
# output: __init__ gets called, creating object at 140702458476192
首先,我们使用__new__方法创建实例对象:task。然后我们可以在__init__方法中将 task 设置为 self 参数。正如你可以从内存地址中看出的,我们正在操作同一个实例对象。图 8.1 总结了这个过程。

图 8.1 后台详细的实例化过程。当你通过调用构造函数创建实例对象时,实例对象首先通过 __new__ 方法创建。当它被创建时,它被发送到 __init__ 方法以完成初始化,在那里设置实例的属性。
由于构造函数调用与两步实例化的等价性,你可以认为直接使用构造函数是两步过程的语法糖。此外,使用构造函数进行实例化更简洁、更易读。
self 不是一个关键字
在 Python 中,我们使用 def 来表示我们正在创建一个函数,使用 for 来表示我们正在运行一个循环。def 和 for 是 Python 中的关键字,意味着它们被语言保留用于特殊操作。由于我们在 Python 中使用 self 来引用实例,这看起来像是一个特殊操作,这可能会让一些人认为 self 是一个关键字。然而,正如你将看到的,self 并不是一个关键字。关键字的一个规则是,你不能将关键字用作变量名,如下面的例子所示:
def = 5
# ERROR: SyntaxError: invalid syntax
class = 7
# ERROR: SyntaxError: invalid syntax
self = 9
# Works!
我们不能给 def 或 class 赋值,但我们可以给 self 赋值,这清楚地表明 self 与其他关键字有本质的不同。实际上,检查一个词是否是保留关键字的一个更正式的方法是利用关键字模块,它提供了一个方便的 iskeyword 函数:
import keyword
words_to_check = ["def", "class", "self", "lambda"]
for word in words_to_check:
print(f"Is {word:⁸} a keyword? {keyword.iskeyword(word)}")
# output the following lines:
Is def a keyword? True
Is class a keyword? True
Is self a keyword? False
Is lambda a keyword? True
如前述代码片段所示,def、class 和 lambda 被识别为关键字,而 self 则不是关键字。
知识点 你可以通过在关键字模块中调用 kwlist 函数来获取整个关键字列表。
倾向于使用 self 作为参数名
我们知道 self 在 __init__ 中指的是实例对象,并且它不是一个关键字。我们可能已经看到 __init__ 中的第一个参数总是 self;因此,我们可能认为它必须命名为 self。然而,我们没有义务将 self 作为参数名。我们可以使用任何合法的变量名(但不能是关键字)。下面的代码片段显示了在 __init__ 中使用此变量名而不是 self 的用法:
class Task:
def __init__(this):
print("An instance is created with this instead of self.")
task = Task()
# output: An instance is created with this instead of self.
如你所见,当我们使用这种方式时,我们仍然可以无任何问题地创建 Task 类的实例对象。从语法角度来看,我们没有义务在 __init__ 中使用 self。但我们应该使用 self;在 __init__ 中使用 self 是一种约定,每个 Python 程序员都应该遵守这个约定。
可读性 遵循常见的约定,例如在 __init__ 中使用 self。当你遵循约定时,其他人阅读你的代码会更容易,因为他们确切地知道你的意思。
8.1.2 在 __init__ 中设置合适的参数
在我展示的例子中,我没有在 __init__ 方法中包含除 self 之外的任何参数。本节展示了我们应该考虑在 __init__ 方法中使用哪些参数。
init 方法旨在完成新实例对象的初始化过程,特别是将基本属性设置到实例中。第 3.3 节中关于命名元组的讨论提到,Task 类应该为每个任务处理三个属性:标题、描述和紧急程度。以下代码片段显示了使用命名元组创建的数据模型:
from collections import namedtuple
Task = namedtuple("Task", "title desc urgency")
task = Task("Laundry", "Wash clothes", 3)
print(task)
# output: Task(title='Laundry', desc='Wash clothes', urgency=3)
正如你所看到的,使用基于命名元组的模型,我们通过指定所有三个属性来创建实例对象。因此,当我们创建一个除了命名元组之外的定制类时,我们应该有相同的机制,允许用户设置这些属性,将必要的参数添加到 init 方法中:
class Task:
def __init__(self, title, desc, urgency):
self.title = title
self.desc = desc
self.urgency = urgency
通过接受参数,init 可以执行额外的初始化过程:从参数中为实例设置初始属性。需要注意的是,这些参数应该与实例对象的属性相关。在 init 方法的主体中,我们使用参数设置实例的属性。使用这个更新的 init 方法,我们可以通过提供参数来创建实例对象:
task = Task("Laundry", "Wash clothes", 3)
当实例被创建时,它已经设置了所有需要的属性。要检查新实例的属性,你可以检查实例的特殊属性 dict。正如你所看到的,新实例 task 具有这些属性,它们被存储为一个字典对象:
print(task.__dict__)
# output: {'title': 'Laundry', 'desc': 'Wash clothes', 'urgency': 3}
对于 Task 类,这个特定的例子适用于任务管理应用程序,但你的项目使用不同的定制类来满足你的数据建模需求。因此,问题是当你构建自己的定制类时,在 init 方法中构建参数时你应该考虑哪些因素。一般来说,我建议以下经验法则:
-
识别所需的参数. 当你创建一个实例时,你希望新的实例具有所有已设置并准备使用的属性。因此,你需要识别出设置实例属性所需的参数。
-
优先考虑关键字参数. 你的自定义类可能需要十个初始属性,这些属性需要为新实例对象设置。然而,有些属性比其他属性更重要。你希望在较不重要的属性之前列出更重要的属性。
-
使用关键字作为位置参数. 这个要求更多的是一种风格约定,而不是规则。你希望用户能够将重要的参数设置为位置参数,因为在不指定关键字参数的情况下调用构造函数比使用关键字参数更简洁。
-
限制位置参数的数量. 这个点与前面的点相关。虽然我们更喜欢在 init 方法中使用位置参数,但当位置参数太多时,读者可能不知道哪个是哪个。因此,作为一个经验法则,我建议不要使用超过四个位置参数。你可以将额外的参数设置为关键字参数(第 6.4.1 节)。
-
设置适用的默认值。 在本质上,init 是一个函数。因此,为了使调用此函数更容易,你希望为大多数用户不费心更改的参数设置默认值。在十个初始属性中,大多数情况下可能七个是相同的;因此,你可以为这七个属性设置默认值。
8.1.3 在 init 中指定所有属性
在第 8.1.2 节中,我们讨论了在 init 方法中设置参数。通过这些参数,我们在 init 方法的主体中为实例对象设置相应的属性。实例对象可以拥有的属性比从 init 的参数创建的属性更多。尽管你可以在类的任何地方设置实例的属性,但最佳实践是在 init 方法的主体中指定实例对象的全部属性。本节讨论了这种实践。
首先,考虑下一个列表,其中实例的属性在多个地方初始化。请注意,我不推荐这种模式,因为它不清楚实例可以有哪些属性。
列表 8.2 在 init 之外设置属性
class Task:
def __init__(self, title, desc, urgency):
self.title = title
self.desc = desc
self.urgency = urgency
def complete(self):
self.status = "completed"
def add_tag(self, tag):
if not self.tags:
self.tags = []
self.tags.append(tag)
查看以 self 为第一个参数的方法被称为 实例方法,这些方法旨在由类的实例对象调用。我们将在第 8.2 节中讨论它们。
在列表 8.2 中,除了标题、描述和紧急程度属性外,我们在 complete 和 add_tag 方法中分别设置了状态和标签属性。你不希望在整个类中(除了在 init 方法内部)初始化实例属性的模式,原因有两个:
-
当你尝试访问这些属性时,除非你已经调用了这些两个方法来相应地设置这些属性,否则你会遇到 AttributeError。换句话说,如果你在未调用相关方法的情况下意外地访问这些属性,你的应用程序将会崩溃:
task = Task("Laundry", "Wash clothes", 3) print(task.status) # ERROR: AttributeError: 'Task' object has no attribute 'status' task.complete() print(task.status) # output: completed -
用户很难知道类的一个实例对象可以有哪些属性。尤其是当你的应用程序很复杂时,你的类可能有很多功能。如果你在这些方法中设置属性,用户在试图找出实例对象的属性时会有一个噩梦。
由于这两个原因,我们应该在 init 中指定所有属性,即使某些属性将通过特定的方法调用进行更新。在这些情况下,这些属性应该有一个合理的初始值。下一个列表显示了期望的模式。
列表 8.3 在 init 中设置所有属性
class Task:
def __init__(self, title, desc, urgency):
self.title = title
self.desc = desc
self.urgency = urgency
self.status = "created"
self.tags = []
def complete(self):
self.status = "completed"
def add_tag(self, tag):
self.tags.append(tag)
使用更新的模式,在你创建一个实例对象之后,它已经正确分配了所有属性,我们可以通过访问 dict 特殊属性来检查它们:
task = Task("Laundry", "Wash clothes", 3)
print(task.__dict__)
# output: {'title': 'Laundry', 'desc': 'Wash clothes',
➥ 'urgency': 3, 'status': 'created', 'tags': []}
可维护性 通过将所有属性放在 init 中,你让你的队友清楚地知道类的一个实例对象可以有哪些属性。当你访问任何属性时,它总是有一个值,所以不会引发 AttributeError。
现在,你可以直接访问状态和标签属性,而无需首先调用 complete 和 add_tag 方法。更重要的是,读者可以扫描 init 方法来了解实例的可用属性,而不是在各种方法中寻找隐藏的属性(列表 8.2)。图 8.2 展示了两种模式的对比。

图 8.2 展示了两个模式之间的对比,这两个模式在指定实例对象属性的位置上有所不同。在不推荐的模式中,你在各种地方初始化属性。在推荐的模式中,你只在 init 方法中初始化属性,这样读者就可以清楚地知道实例对象有哪些属性。
8.1.4 在 init 方法外定义类属性
初始化方法应该通过在实例级别上定义其属性来为实例对象提供初始化。值得注意的是,所有实例对象可以共享属性。在这种情况下,你不应该将它们包括为实例属性,而应该考虑类属性。本节讨论了这一特性。
概念 类属性 是那些属于类的(作为一个对象)属性,并且类的所有实例对象通过类共享这些属性。
为了简化,假设每个任务都有一个创建任务的属性用户。理论上,你可以通过以下 init 方法将用户作为一个实例属性:
def __init__(self, title, desc, urgency, user):
self.title = title
self.desc = desc
self.urgency = urgency
self.user = user
因为用户是一个实例属性,你预期你的应用程序需要更多的内存,因为你需要为每个实例保存用户数据。但重要的是要知道,在应用程序中,用户登录后,将只有一个用户会创建所有任务。因此,所有实例都应该共享属性用户。为了帮助减少为每个实例保存用户数据的内存成本,你应该在这种情况下创建一个类属性:
class Task:
user = "the logged in user"
def __init__(self, title, desc, urgency):
pass
根据数据模型,你可能需要为你的类定义额外的类属性。定义类属性是节省内存的重要技术,因为实例通过引用内存中相同的底层对象来共享相同的属性。从可读性的角度来看,了解你将类属性放在类定义头部下方和 init 方法上方是至关重要的。
可读性 所有类属性都应该是明确和清晰的。将它们放在类定义头部下方。
8.1.5 讨论
您几乎总是在自定义类中实现 __init__ 方法。__init__ 方法应包含实例对象的全部属性,这样读者就不必猜测实例具有哪些属性。此外,将 __init__ 方法放在类体中的其他方法之前。为什么?从可读性的角度来看,我们想知道一个类可以持有什么数据;实例的属性代表类持有的数据。定义一个合适的 __init__ 方法是在自定义类中首先想要做的工作。
8.1.6 挑战
莉亚正在开发任务管理应用以学习 Python 编程。她建议在实例化时允许用户指定标签。因此,她需要在 __init__ 方法中添加标签作为参数(见列表 8.3)。在大多数情况下,她期望用户将空列表设置给标签参数。在这种情况下,她应该为标签设置什么默认值?
提示 在其核心,__init__ 是一个函数。你可能还记得从第 6.1 节中,我们应该在函数中为可变参数设置一个默认值。
8.2 我何时定义实例、静态和类方法?
在我们为实例对象设置了适当的属性之后,是时候为类提供功能了。在列表 8.3 中,该类有两个函数:complete 和 add_tag。这些函数被称为 实例方法。除了实例方法之外,您还可以定义静态和类方法。这些方法适用于不同的用例。本节探讨了需要定义实例、静态或类方法的场景。
8.2.1 为操作单个实例定义实例方法
实例方法旨在在类的实例对象上调用。因此,当您想更改单个实例对象的数据或运行依赖于单个实例对象数据的操作时,例如属性或其他实例方法,您需要定义实例方法。
提醒 从语法上讲,您可以为 self 参数使用不同的参数名,但使用 self 作为名称是一种约定。
实例方法的标志是您将其第一个参数设置为 self。如第 8.1.1 节中广泛讨论的那样,self 指的是 __init__ 方法中的实例对象,这对于所有实例方法都是正确的。在列表 8.4 中,我们通过简单修改列表 8.3 中的 Task 类的 complete 方法来验证实例方法中的 self 参数也指向实例对象。请注意,为了节省空间,我没有包括 Task 类的其他实现细节,例如 __init__。
列表 8.4 创建和使用实例方法
class Task:
def __init__(self, title, desc, urgency):
self.title = title
self.desc = desc
self.urgency = urgency
self._status = "created"
def complete(self):
print(f"Memory Address (self): {id(self)}")
self.status = "completed"
task = Task("Laundry", "Wash clothes", 3)
task.complete()
# output: Memory Address (self): 140508514865536
task_id = f"Memory Address (task): {id(task)}"
print(task_id)
# output: Memory Address (task): 140508514865536
如你所见,在完整方法中,self 与任务实例具有相同的内存地址,这表明 self 确实是我们调用方法的实例对象。在底层,实例方法是通过类调用方法并以实例作为参数来调用的,如图 8.3 所示。

图 8.3 调用实例方法的基本操作。当你使用实例对象来调用实例方法时,它被处理为使用类来调用方法,其中实例对象作为参数。最后,函数的操作应用于调用实例方法的实例对象。
实例方法的单一目的是操作特定的实例对象。也就是说,你总是采用以下调用模式来使用实例方法:instance.instance_method(arg0, arg1, arg2)。
在实例方法的主体中,操作应该是关于操作我们调用的实例对象。因此,如果你发现该方法不操作实例或不依赖于实例相关数据,那么很可能该方法一开始就不应该作为实例方法实现。相反,你可能需要将其实现为静态方法。
8.2.2 定义用于实用功能的静态方法
当你实现不针对任何特定实例的实用相关函数时,你需要定义一个静态方法。本节讨论了如何定义静态方法。
与使用 self 作为其第一个参数的实例方法不同,静态方法不使用 self,因为它旨在独立于任何实例对象,并且没有必要引用特定的实例。为了定义静态方法,我们在类的主体中使用 staticmethod 装饰器为函数。考虑列表 8.5 中的示例。
提醒装饰器在不改变其原始功能的情况下为装饰的函数添加额外的功能。
列表 8.5 创建静态方法
from datetime import datetime
class Task:
@staticmethod
def get_timestamp():
now = datetime.now()
timestamp = now.strftime("%b %d %Y, %H:%M")
return timestamp
在列表 8.5 中,get_timestamp 是使用 @staticmethod 装饰器定义的静态方法。在这个静态方法中,我们创建了一个格式化的时间戳字符串,我们可以在需要向用户显示确切时间时使用它。要调用此方法,我们使用以下模式:CustomClass.static_method(arg0, arg1, arg2)。我们可以尝试使用 get_timestamp 静态方法来尝试这个模式:
refresh_time = f"Data Refreshed: {Task.get_timestamp()}"
print(refresh_time)
# output: Data Refreshed: Mar 04 2022, 15:43
如你所见,我们通过调用 Task.get_timestamp() 使用静态方法,它以所需格式检索当前时间戳。这种操作代表了一种通用实用需求;正如你可以想象的那样,有多个场景需要显示时间戳。提供实用功能是静态方法的主要目的。也就是说,当你需要定义与任何实例对象无关的实用相关方法时,你应该使用 @staticmethod 装饰器来创建静态方法。当你阅读他人的自定义类并注意到任何使用 @staticmethod 的情况时,你知道它是一个静态方法,因为 staticmethod 装饰器是静态方法的标志。
8.2.3 定义用于访问类级属性的类方法
在 8.2.2 节中,你学习了如何定义不需要访问单个实例对象的实用方法静态方法。可能有些方法需要访问类的属性。在这种情况下,你需要定义一个类方法。
类方法的第一个显著特征是,你使用 cls 作为它的第一个参数。就像实例方法中的 self 一样,cls 不是一个关键字,你可以给这个参数其他适用的名称,但按照惯例,我们将其命名为 cls,并且每个 Python 程序员都应该遵守这个惯例。
可读性 你在类方法中将第一个参数命名为 cls。当其他程序员看到 cls 时,他们会知道它指的是类。
静态方法的实现需要使用 @staticmethod 装饰器。类方法也使用 @classmethod 装饰器——这是类方法的第二个显著特征。这个方法被称为 类方法,因为它需要访问类的属性或方法。考虑一个例子。假设在我们的任务管理应用程序中,我们以字典对象的形式获取数据,该对象存储了任务的详细信息:
task_dict = {"title": "Laundry", "desc": "Wash clothes", "urgency": 3}
从这个字典对象构建 Task 类的实例对象,我们可能需要执行以下操作:
task = Task(task_dict["title"], task_dict["desc"], task_dict["urgency"])
但因为我们可能经常获取这种字典数据并创建相应的 Task 实例,我们应该提供一个更方便的方式来满足这种需求。幸运的是,类方法是一个很好的解决方案,如下面的列表所示。
列表 8.6 创建类方法
class Task:
def __init__(self, title, desc, urgency):
self.title = title
self.desc = desc
self.urgency = urgency
self._status = "created"
@classmethod
def task_from_dict(cls, task_dict):
title = task_dict["title"]
desc = task_dict["desc"]
urgency = task_dict["urgency"]
task_obj = cls(title, desc, urgency)
return task_obj
如列表 8.6 所示,我们使用 @classmethod 定义了一个名为 task_from_dict 的类方法。在这个方法的主体中,因为 cls 代表我们正在工作的类(Task),我们可以直接使用类的构造函数——cls(title, desc, urgency)—来创建实例对象。通过这个类方法,我们可以方便地从字典对象创建 Task 实例对象:
task = Task.task_from_dict(task_dict)
print(task.__dict__)
# output: {'title': 'Laundry', 'desc': 'Wash clothes',
➥ 'urgency': 3, 'status': 'created', 'tags': []}
从一般的角度来看,类方法主要用作 *工厂方法**,这意味着这类方法用于从特定形式的数据创建实例对象。第 4.5 节提到,DataFrame 是 pandas 库中类似于电子表格的数据模型。它有几个类方法——from_dict 和 from_records,你可以使用这些方法来构建 DataFrame 类的实例对象。
8.2.4 讨论部分
在三种方法中,实例方法和类方法是最直接的。静态方法稍微复杂一些。因为它们旨在提供实用功能,所以通常可以在类外部定义它们;毕竟,它们不需要操作任何实例或类。一般来说,我建议如果你定义的静态方法比类应该处理的功能更通用,就将静态方法放在类外部。以数据处理库 pandas 为例,核心数据模型是 Series 和 DataFrame 类。一个实用函数 to_datetime 将数据转换为日期对象。这个函数解决了一个更普遍的需求;因此,它不是在 Series 或 DataFrame 中作为静态方法实现的。
8.2.5 挑战
当利亚继续在任务管理应用上工作时,她意识到她需要从一个元组对象中创建一个 Task 类的实例:("Laundry", "Wash clothes", 3)。她应该在类中实现哪种方法来满足这个需求?
提示:我们在列表 8.6 中实现了一个从字典对象创建实例对象的方法。
8.3 我该如何对类应用更细粒度的访问控制?
在一个自定义类中,你可能定义了数十个方法。其中一些方法是供你(类的开发者)内部使用的,而其他方法则是供其他使用你的类的开发者使用的。考虑以下场景。在 Task 类中,另一个方法为 complete 方法格式化笔记:
class Task:
def __init__(self, title, desc, urgency):
self.title = title
self.desc = desc
self.urgency = urgency
self._status = "created"
self.note = ""
def complete(self, note = ""):
self.status = "completed"
self.note = self.format_note(note)
def format_note(self, note):
formatted_note = note.title()
return formatted_note
当用户调用 complete 方法时,这个方法通过调用 format_note 方法将一个格式化的笔记设置到 note 属性中。值得注意的是,用户也可以直接调用 format_note。这种行为并不是期望的行为,因为面向对象编程的一个基本原则是封装:你只暴露用户需要访问的属性和方法,而不暴露更多。封装的隐含意义是,你为类应用更细粒度的访问控制。在本节中,我们将讨论一些关键的访问控制技术。
概念封装指的是在面向对象编程语言中广泛采用的一种编码原则,其中你将数据和方法捆绑到一个类中,并只允许访问对用户相关的数据部分。
公共的、受保护的和私有的
在典型的面向对象编程语言中,为了限制对特定属性或方法的访问,许多语言使用 protected 或 private 作为关键字。protected 和 private 的反义词是 public,意味着属性和方法对类内外所有用户都是可用的。受保护的意味着属性和方法对类及其子类可用,但不在类外部。私有的意味着属性和方法仅对类本身可用,对它的子类或类外部不可用。由于它们对内部访问的限制,私有的和受保护的也被称为非公共的。
8.3.1 通过使用下划线作为前缀创建受保护的方法
在其核心,Python 是一种面向对象编程(OOP)语言。然而,与其他使用私有和/或受保护进行访问控制的 OOP 语言不同,Python 没有正式的机制来限制对任何属性或方法的访问。换句话说,类中的所有内容都是公开的,Python 没有受保护或私有的关键字。创建访问控制机制的惯例是使用下划线作为属性或方法的前缀。单下划线前缀表示受保护,双下划线前缀表示私有(如第 8.3.2 节所述)。在本节中,你将学习如何定义受保护方法。值得注意的是,相同的机制也适用于创建受保护和私有属性。
当我在第 3.3 节中谈到命名元组时,我提到创建命名元组数据模型允许我们利用集成开发环境(IDE)的自动完成提示,在输入对象后的点号后填充可用的属性。然而,如果填充的列表包括你不会使用的函数,这种方法可能不方便。作为用户,你不会自己调用 format_note 方法;因此,自动完成建议不显示 format_note(如图 8.4 所示)。

图 8.4 为实例对象提供的不同自动完成提示。如果自动完成提示包括用户不需要使用的函数,则不太理想——在这种情况下,是 format_note 方法。
显然,通过在自动完成提示列表中隐藏你不需要的函数,你可以提高编码效率。但是,IDE 如何知道要隐藏哪些函数呢?秘诀在于使用下划线作为方法名称的前缀,这表示它是一个受保护的方法。我们可以将方法命名为 _format_note。下划线前缀的意义有两重:
-
此方法不打算在课堂外使用,因此在你在课堂外工作时,自动完成提示中不会显示,如图 8.4 的右侧面板所示。
-
当你在类内工作时,此方法作为自动完成提示的一部分仍然可用,如图 8.5 所示。

图 8.5 类中受保护方法的可用性。在你输入点号后,实例对象可用的属性和方法会显示在列表中,并且列表中包括受保护方法。
这两个含义与封装原则一致。你限制外部用户访问他们不需要的功能,同时保持对需要这些功能的用户可用相同的功能。
8.3.2 使用双下划线作为前缀创建私有方法
在 8.3.1 节中,你学习了如何定义受保护的方法来限制用户对不想让他们看到的方法的公共访问。除了使用受保护的方法外,你还可以定义私有方法,这可以达到相同的封装效果。在本节中,你将学习如何定义私有方法。更重要的是,你将了解为什么有时定义私有方法而不是受保护的方法是一个好主意。
你已经了解到定义私有方法需要使用两个下划线作为前缀。让我们继续以format_note方法为例。为了使方法私有,将名称更改为__format_note。通过这个名称更改,方法的访问权限将始终限制在类的内部(如图 8.6 所示)。

图 8.6 内部访问但外部无法访问私有方法。__format_note 方法以两个下划线开头,这意味着它是私有的。私有方法仅可在类内部使用。
受保护方法和私有方法在类内部的可访问性方面相似。然而,如 8.3.1 节开头所述,Python 中并没有严格意义上的非公共方法。如果你想访问受保护的方法,你可以,尽管许多 IDE 会显示警告,如图 8.7 所示。

图 8.7 在类外部调用受保护的方法在技术上是被允许的,但出于这种意外行为,会出现警告,因为受保护的方法并不打算用于外部使用。
当有人试图在类外部访问私有方法时会发生什么?会发生一件看似奇怪的事情。如下面的代码片段所示,不存在这样的方法或属性:
task.__format_note("a note")
# ERROR: AttributeError: 'Task' object has no attribute '__format_note'.
这种在类外部对__format_note的“不可访问性”标志着私有方法和受保护方法之间的一项重大区别,因为它似乎比像_format_note这样的受保护方法更私密。因此,如果你想对非公共方法有更严格的访问限制,你应该使用双下划线作为前缀来创建私有方法,而不是使用单下划线来创建受保护的方法。
可维护性 由于受保护方法和私有方法之间的公共访问规则不同,如果你想有更严格的访问限制,应使用私有方法。
我说过 Python 没有真正的非公共方法,这就是为什么我在本节前面将“不可访问性”用引号括起来的原因。但是,问题是如果你需要如何访问私有方法。你可能想操作其他人开发的包内的某些代码,例如。如下面的代码片段所示,你可以通过调用_Task__format_note("a note")来访问私有方法。
task._Task__format_note("a note")
# output: 'A Note'
这种技术称为*名称混淆**,它将私有方法转换为具有不同名称的方法,允许在类外调用私有方法。具体来说,名称混淆遵循以下规则:__private_method -> _ClassName__private_method。因此,__format_note 变为 _Task__format_note,我们可以在 Task 类外调用这个私有方法。
概念 名称混淆 是通过使用 _ClassName 作为前缀将私有方法名称转换为不同名称的过程。然后,私有方法可以在类外访问。
除了具有不同的公共访问规则外,受保护和私有方法在定义它们的子类中访问它们时也有不同的规则。我将在第 8.5 节中回顾这个主题。
8.3.3 使用属性装饰器创建只读属性
实现自定义类的一个主要原因是你可以定义你需要的尽可能多的属性,这样自定义类作为一个统一的实体,可以通过定义良好的属性和方法捆绑所有相关数据。值得注意的是,自定义类是可变的,这意味着你可以更改实例对象的属性。但是你可能不希望用户更改某些属性。在这种情况下,你应该考虑另一种访问控制技术:只读属性。用户可以读取这些属性,但不能更改它们。在本节中,你将学习如何定义只读属性。
对于 Task 类,考虑 status 属性。目前,用户可以自由更改实例的 status 属性:
print(task.status)
# output: created
task.status = "completed"
print(task.status)
# output: completed
为了封装的目的,我们不允许用户自由设置状态属性。例如,要将任务的状态更新为完成,他们应该调用完整方法。所以问题是如何防止用户手动设置状态。解决方案是利用属性装饰器。下一个列表显示了技术。
列表 8.7 使用属性装饰器
class Task:
def __init__(self, title, desc, urgency):
self.title = title
self.desc = desc
self.urgency = urgency
self._status = "created"
@property
def status(self):
return self._status
def complete(self):
self._status = "completed"
在列表 8.7 中,我们只保留与定义只读属性技术相关的代码。在代码中,我们应该注意三个重要的事情:
-
实例有一个受保护的属性 _status。
-
我们定义了一个实例方法 status,它被属性装饰器装饰。
-
在完整方法中,我们更新 _status 属性。
我们知道当我们对一个对象调用方法时,我们使用调用操作符——方法名称后面的括号。但是属性装饰器使得方法可以像属性一样访问。为了简单起见,你可以将带有属性装饰器的方法称为属性,并且不需要使用调用操作符来访问属性:
task = Task("Laundry", "Wash clothes", 3)
print(task.status)
# output: created
值得注意的是,属性代表一个只读属性。你可以像前面的代码片段中那样读取它。然而,你不能设置它,这正是你想要的:防止用户直接设置状态,如下面的列表所示。
列表 8.8 只读属性
>>> task.status = "completed"
# ERROR: AttributeError: can't set attribute 'status'
可维护性 创建只读属性可以防止用户更改特定的属性,从而保持数据稳定性。
在更一般的情况下,当你定义一个只读属性时,通常需要创建一个受保护的属性来内部处理相应的数据。例如,status 就是一个只读属性,我们使用 _status 来在类内部处理与状态相关的数据。
问题 为什么我们想要使用受保护的属性而不是私有属性?从子类访问的角度考虑它们之间的区别。
8.3.4 使用属性设置器验证数据完整性
在 8.3.3 节中,我们介绍了属性装饰器,我们使用它为 Task 类创建了只读属性 status。只读属性的含义是我们不能为其设置值。然而,这种行为并不总是我们想要的。有时,我们希望有一种机制来为属性设置值。本节讨论的设置属性的有用场景之一是验证数据完整性。
概念 在像 Java 这样的传统面向对象语言中,有两个概念与属性相关:获取器和设置器。获取器 是允许你检索属性值的函数,而 设置器 是通过它设置属性值的函数。属性装饰器创建了一个获取器,在接下来的段落中,我们将创建一个设置器。
假设我们允许用户直接设置 status 属性。然而,值必须是有效的。考虑一个任务的状态可以是创建、开始、完成或暂停。我们如何确保设置的值是其中之一?这种属性的数据验证可以通过属性设置器技术来最好地解决,如下一列表所示。
列表 8.9 为属性创建设置器
class Task:
# __init__ stays the same
@property
def status(self):
return self._status
@status.setter
def status(self, value):
allowed_values = ["created", "started", "completed", "suspended"]
if value in allowed_values:
self._status = value
print(f"task status set to {value}")
else:
print(f"invalid status: {value}") ❶
❶ 最佳实践是抛出异常(见第 12.4 节)。
在列表 8.9 中,在创建 status 属性之后,我们通过使用 @status.setter 创建了这个属性的设置器,它采用了通用的形式 @property_name.setter。这个设置器是一个实例方法,它接受一个值参数,代表我们想要分配给属性的值。在设置器的主体中,我们验证该值是否是四种可能性之一。有了这个设置器,我们就能设置 status 属性:
task = Task("Laundry", "Wash clothes", 3)
task.status = "completed"
# output: task status set to completed
task.status = "random"
# output: invalid status: random
如你所见,我们可以直接将状态设置为“完成”。更重要的是,当我们尝试设置一个无效值时,我们会收到这个错误的提示。尽管我们可以创建获取器和设置器来将属性转换为属性,但我们不想这样做,因为这会使类变得复杂。除非你出于只读或数据验证等理由实现属性,否则你应该直接访问和设置属性,而不是通过属性。这种直接访问和操作的模式使 Python 与其他面向对象的语言区分开来,通常使 Python 代码更加简洁。
8.3.5 讨论
定义私有和受保护的方是实现类封装的基本技术;它有助于最小化类的公共属性。当用户与类一起工作时,他们会收到这些公共属性的自动完成提示,使他们的工作更加高效。不要试图通过创建 setter 和 getter 来封装一切,就像其他一些面向对象语言所做的那样;这种做法不符合 Python 风格。在大多数情况下,你应该使用直接访问和设置属性,而不是使用属性,因为前者更直接,且需要更少的实现代码。
8.3.6 挑战
假设紧急属性应该有一个介于 1 和 5 之间的整数值。你能将其转换为具有 setter 的属性吗?setter 允许你检查值。
提示:您可以使用一个受保护的属性,例如_urgency,作为紧急数据的内部表示,并创建一个名为urgency的属性。
8.4 如何自定义类的字符串表示?
在第 8.1 节中,我们学习了初始化方法__init__。这种方法的名字被两对双下划线包围,被称为*特殊方法**。特殊方法执行特殊操作,例如__init__,当使用构造函数创建实例对象时会被调用。值得注意的是,当我们在一个类中实现特殊方法时,可以说我们正在重写这个方法,因为所有 Python 类都是对象类的子类,而对象类实现了这些特殊方法。
概念:在面向对象编程语言中,重写意味着子类为其父类中定义的方法提供了不同的实现。
在本节中,我将向你展示另外两个特殊方法:__str__和__repr__,它们为类提供了定制的字符串表示。
8.4.1 重写__str__以显示实例的有意义信息
在许多地方,我们需要检查我们正在处理的实例对象。一个常见的方法是print函数,它显示了对象的字符串表示。使用这种方法,我们可以看到Task类的实例看起来像什么:
print(task)
# output: <__main__.Task object at 0x7f9f280d6800>
这些信息包括实例的类及其内存地址,但没有其他信息。换句话说,我们看不到关于实例的任何更有意义的信息,例如其属性。在本节中,我们将看到如何使用print函数显示实例的更多有意义信息。
当你使用print与自定义类实例一起使用时,被调用的特殊方法是__str__,它定义了实例的字符串表示。为了提供除了前面代码片段中显示的默认字符串表示之外的定制字符串表示,我们可以在我们的Task类中重写__str__,如下一个列表所示。
列表 8.10 在类中重写__str__
class Task:
def __init__(self, title, desc, urgency):
self.title = title
self.desc = desc
self.urgency = urgency
def __str__(self):
return f"{self.title}: {self.desc}, urgency level {self.urgency}"
当你在类中重写__str__时,你应该注意三件事:
-
它是一个实例方法,因为它旨在为实例对象提供一个字符串表示。
-
它应该返回一个
str对象作为其返回值。 -
返回的字符串应该为实例提供描述性信息。在我们的例子中,我们希望显示实例的关键属性,包括标题、描述和紧急程度。
在重写 __str__ 方法之后,我们可以通过 print 函数看到我们观察到的结果:
task = Task("Laundry", "Wash clothes", 3)
print(task)
# output: Laundry: Wash clothes, urgency level 3
除了 print 之外,我们还经常使用 f-string 准备字符串输出以供数据显示。当你将实例对象包含在大括号中时,实例的插值会调用底层的 __str__ 方法。观察以下行为:
planned_task = f"Next Task - {task}"
print(planned_task)
# output: Next Task - Laundry: Wash clothes, urgency level 3
如果你想要显式调用实例上的 __str__ 方法,首选的方法是 str(instance),尽管我们也可以直接调用 Class.__str__(instance):
str(task)
# output: Laundry: Wash clothes, urgency level 3
8.4.2 重写 __repr__ 以提供实例化信息
许多人喜欢在交互式 Python 控制台中使用 Python,尤其是在学习 Python 时,因为控制台提供了代码的实时输出。在控制台中,如果你输入一个 str 变量,你会看到它的字符串值:
>>> planned_task
'Next Task - Laundry: Wash clothes, urgency level 3'
如果你尝试对任务实例这样做,你会看到类似以下的内容:
>>> task
<__main__.Task object at 0x7f9f280d6f80>
我们已经实现了 __str__ 方法,它不会改变在交互式控制台中显示的实例信息。在本节中,我们将看到如何更改控制台显示的字符串表示。
当交互式控制台显示实例的字符串表示时,调用的特殊方法是 __repr__。首先,我将向你展示如何在类中实现 __repr__(参见列表 8.11),并解释需要注意的关键事项:
-
它是一个实例方法,因为它基于特定实例提供字符串表示信息。
-
它返回一个字符串值。
-
字符串应该提供有关实例化的信息。具体来说,如果其他用户将字符串作为代码输入,它应该创建一个具有与当前实例对象相同属性的实例对象。
列表 8.11 在类中重写 __repr__
class Task:
def __init__(self, title, desc, urgency):
self.title = title
self.desc = desc
self.urgency = urgency
def __str__(self):
return f"{self.title}: {self.desc}, urgency level {self.urgency}"
def __repr__(self):
return f"Task({self.title!r}, {self.desc!r}, {self.urgency})" ❶
❶ !r 请求使用 __repr__ 方法进行字符串插值。
在实现 __repr__ 之后,我们可以在交互式 Python 控制台中检查 Task 类的实例:
>>> task = Task("Laundry", "Wash clothes", 3)
>>> task
Task('Laundry', 'Wash clothes', 3)
要在实例上调用 __repr__,你应该使用 repr(instance) 而不是 Class.__repr__(instance):
repr(task)
# output: Task('Laundry', 'Wash clothes', 3)
8.4.3 理解 __str__ 和 __repr__ 之间的区别
在 8.4.1 和 8.4.2 节中,你学习了 __str__ 和 __repr__,这两个方法都是为了为自定义类的实例提供字符串表示。本节将讨论它们之间的区别。
不同的目的
第一个区别,也是最大的区别,是这两个方法服务于不同的目的。__repr__ 提供的字符串旨在用于调试和开发,因此它是为开发者准备的。具体来说,开发者应该能够从字符串中直接构造实例。如 2.2 节中提到的,我们可以使用内置函数 eval 来评估字符串字面量以推导出底层对象。我们在这里也可以做同样的事情:
task = Task("Laundry", "Wash clothes", 3)
task_repr = repr(task)
task_repr_eval = eval(task_repr)
print(type(task_repr_eval))
# output: <class '__main__.Task'>
print(task_repr_eval)
# output: Laundry: Wash clothes, urgency level 3
相比之下,__str__ 提供的字符串旨在显示描述性信息,并且是为代码的常规用户设计的。因此,这个字符串比 __repr__ 提供的字符串不那么正式,后者显示了实例化信息。
不同用法
尽管这两种方法都为类提供了字符串表示形式,但 __str__ 是既支持 print 函数又支持 f-string 中插值的底层方法。相比之下,__repr__ 是当你尝试在交互式控制台中检查实例时应该使用的方法。
在列表 8.11 中,你可能注意到我们在 self.title 的插值中添加了 !r。!r 被称为 *转换标志**,它要求对象被插值的字符串调用 __repr__ 而不是 __str__ 来创建字符串表示形式。默认情况下,插值自定义类的实例使用由 __str__ 创建的字符串。为了覆盖此默认行为,你使用实例后的转换标志:f"{instance!r}"。相关地,实例的默认转换标志是 !s,它使用由 __str__ 创建的字符串。换句话说,表达式 f"{instance}" 和 f"{instance!s}" 是等效的。
你可能想知道为什么我们需要为标题和描述使用 !r 标志,而不是紧急情况。原因是标题和描述都是 str 对象。它们从 __str__ 生成的字符串没有引号。因此,如果我们使用它们的默认插值,__repr__ 的字符串不能用来构造实例对象,如下所示:
class Task:
def __init__(self, title, desc, urgency):
self.title = title
self.desc = desc
self.urgency = urgency
def __str__(self):
return f"{self.title}: {self.desc}, urgency level {self.urgency}"
def __repr__(self):
return f"Task({self.title}, {self.desc}, {self.urgency})"
task = Task("Laundry", "Wash clothes", 3)
print(repr(task))
# output: Task(Laundry, Wash clothes, 3)
在修订后的类中,我们省略了标题和描述的 !r 转换标志。从打印输出中,我们可以看到洗衣和洗衣服不再有引号。正如你所预期的,我们不能从这个字符串构造一个任务实例:
eval(repr(task))
# ERROR: SyntaxError: invalid syntax. Perhaps you forgot a comma?
相比之下,__repr__ 的字符串表示形式确实有引号,因为字符串字面量需要引号,例如 "Laundry" 与 Laundry。前者是一个有效的 str 对象,但后者不是。(它将被视为名为 Laundry 的变量,但不能使用,因为我们从未定义名为 Laundry 的变量。)
8.4.4 讨论
__repr__ 方法的根本目的是以明确的方式解释对象是什么。因为从 repr 方法生成的字符串(注意调用 repr 会调用类中的 __repr__ 方法)应该代表我们可以用来重建类似对象的文本,所以由 repr 生成的任何字符串都应该有引号以使其成为有效的 Python 字符串字面量。如果你使用 f-string,不要忘记使用 !r 转换标志。我建议你为自定义类实现 __str__ 和 __repr__ 方法。如果你只想实现一个方法,则覆盖 __repr__,因为当 __str__ 没有实现时,Python 会使用 __repr__。
8.4.5 挑战
对于 Task 类,我们在 repr 方法中返回 f"Task({self.title!r}, {self.desc!r}, {self.urgency})",在 f-string 中硬编码了类名 Task。一个通用的编程原则是我们尽量减少硬编码的数据。你知道我们如何以编程方式检索类名吗?
提示 一个实例有一个特殊的属性 class 来标识其类,一个类有一个特殊的属性 name 来检索类的名称。
8.5 为什么以及如何创建超类和子类?
在面向对象编程(OOP)中,继承是一个基本概念,它通常指的是创建一个子类,该子类可以重用父类的实现,或其部分实现。同时,你可以对子类应用定制的实现,这使得子类在解决特定问题方面比父类做得更好。子类也被称为子类,父类也被称为超类。
知识点 子类和超类是相对的。一个子类是其自身子类的超类。
创建子类是一个比我们之前讨论的许多其他主题更高级的话题。正如你将在本节中发现的那样,管理具有多个子类的超类比管理不同且无关的类要复杂得多。因此,一个经验法则是,在决定实现子类之前,你需要证明使用子类的合理性。在本节中,我们将回顾构成良好理由的内容,并检查实现子类的技术细节。
8.5.1 确定子类的使用场景
当你的项目范围扩大时,你需要定义更多的类来处理增加的数据。在这个阶段,所有类之间没有继承关系。然而,你注意到一些类在功能上相似;存在一定程度的代码重复。如果你回想起 DRY(不要重复自己)原则,你可能会意识到是时候重构这些类了。一个基本的方法是创建子类以减少类之间的重叠实现。在本节中,我们将了解何时使用子类。
自顶向下(超类到子类)还是自底向上(子类到超类)?
当你在项目中尝试实现子类时,可能会出现两种常见的场景。在第一种场景中,你从一个类作为数据模型开始,然后意识到你需要从这个类创建子类以形成更具体的数据模型。在第二种场景中,你从多个类作为独立的数据模型开始,然后意识到这些类之间有相当多的功能相似。在这种情况下,你可以创建一个超类,当前类可以从该超类继承。
这两种情况都可能出现在项目中。在本节中,我们将重点关注第二种情况:自下而上的情况。根据我的经验,一个项目通常从一个扁平的数据模型结构开始——每个模型有多个类。当你实现这些类时,你会认识到它们之间的相似性,这使得创建一个超类成为必要。
假设我们的任务管理应用程序支持用户注册。存在两种类型的用户:主管和下属。当我们开始开发我们的应用程序时,我们创建了两个独立的类,分别是 Supervisor 和 Subordinate,分别用于管理主管和下属的数据。图 8.8 提供了这两个类的属性和方法的可视概述。

图 8.8 Supervisor 和 Subordinate 类的相似性和差异。这两个类中有一些属性和方法是相同的;其他属性和方法是不同的。
如您所见,这两个类很相似,共享大多数属性和方法。在这种情况下,你应该考虑创建一个处理共享功能性的超类。为了处理每种类型的独特功能,你可以从超类继承以创建两个子类。图 8.9 提供了继承结构的可视概述。

图 8.9 创建处理共享属性和方法的超类。在子类中,你实现特定的属性和方法。你还应该注意,默认情况下,子类从超类继承所有非私有属性和方法。
如图 8.9 所示,当我们创建一个超类时,我们将所有共享的属性和方法从子类移动到超类。在子类中,你实现特定的属性和方法。这些说明可能听起来过于抽象。让我们在下一节中看看更多的实现代码。
8.5.2 自动继承超类的属性和方法
之前,我提到类之间的功能重叠是创建超类的基础,这有助于减少代码重复。在本节中,你将了解为什么我们需要更少的代码来实现继承。
为了了解超类和子类是如何一起工作的,让我们继续使用员工-主管的例子。请首先阅读下一列表中的代码。我们在 Supervisor 类中没有实现自定义的 init;我将其任务留给了 8.5.6 节。
列表 8.12 超类和子类的基本结构
class Employee:
def __init__(self, name, employee_id):
self.name = name
self.employee_id = employee_id
def login(self):
print(f"An employee {self.name} just logged in.")
def logout(self):
print(f"An employee {self.name} just logged out.")
class Supervisor(Employee):
pass
当你定义一个子类时,你指定超类在类名后面的括号中。在这里,超类是 Employee,所以我们将其放在 Supervisor 之后。值得注意的是,子类 Supervisor 自动从其超类 Employee 继承一切,包括其初始化和其他方法。我们可以在以下代码片段中观察到这一特性:
supervisor = Supervisor("John", "1001")
print(supervisor.name)
# output: John
supervisor.login()
# output: An employee John just logged in.
如你所见,我们通过调用 Supervisor("John", "1001")创建了一个实例。Supervisor 类的主体只使用了 pass 语句。Supervisor 支持实例化,但创建的实例对象具有属性和方法,因为 Supervisor 类从 Employee 类继承而来。
从一般的角度来看,当你的子类与超类具有相同的属性和方法时,你不需要在子类中提供任何实现,因为子类会自动从超类获得所有属性和方法。
8.5.3 覆盖超类的方法以提供定制行为
在 8.5.2 节中,你了解到子类会自动从超类继承所有属性和方法。然而,有时你可能想要为子类提供定制的功能。在本节中,你将学习如何覆盖超类的方法,为子类提供特定的实现。
完全覆盖方法
你可以完全覆盖超类的方法。与一些需要使用覆盖关键字的重写某些面向对象语言不同,Python 允许你使用与超类不同的实现来定义相同的方法。让我们以登录方法为例:
class Supervisor(Employee):
def login(self):
print(f"A supervisor {self.name} just logged in.")
在子类中更新了登录方法后,我们可以看到 Supervisor 类的实例将调用子类的登录方法,而不是超类的登录方法:
supervisor = Supervisor("John", "1001")
supervisor.login()
# output: A supervisor John just logged in.
我们没有为注销方法提供定制实现。正如您所预料的,如果我们对实例调用注销,将触发 Employee 类的注销实现。Python 是如何确定应该使用哪个实现的呢?答案涉及一个重要的概念:方法解析顺序(MRO),它决定了在层次类结构中特定方法实现的顺序。
概念:MRO 决定了在继承类结构中实例的方法或属性是如何被评估的。
由于 Python 支持多重继承——一个类可以继承多个类——多重继承中的 MRO 更为复杂。在这里,让我们关注最常见的情况:只有一个超类的子类。图 8.10 说明了 MRO 是如何工作的。请注意,当你定义一个没有显式超类的类时,Python 使用 object 类作为其超类——在 Employee 的情况下,object 的一个子类。

图 8.10 层次类结构中的 MRO。当你对一个实例调用方法时,Python 首先检查其类中的方法。如果方法被解析,则应用该实现。如果没有解析,则向上移动到其超类。如果仍然没有,则向上移动到对象超类,尝试解析该方法。如果方法仍然没有解析,则引发 AttributeError 异常。如果类继承结构有更多层级,则每个层级都会被检查。
当你在实例上调用一个方法时,实例对象通过其类有一个已建立的 MRO(方法解析顺序),你可以使用 mro 方法来检查:
Supervisor.mro()
# output the following line:
[<class '__main__.Supervisor'>, <class '__main__.Employee'>,
➥ <class 'object'>]
正如你所见,解析顺序是 Supervisor -> Employee -> object。也就是说,按照这个顺序,如果在任何类中找到了方法的实现,它就会被解析和评估。如果检查了所有类而没有解析到方法,则会引发 AttributeError 异常。
部分重写方法
你并不总是希望对超类中的方法有不同的实现。相反,你希望保留超类的实现,并在其基础上应用额外的定制。在这种情况下,我们是在部分重写一个方法。
这次,考虑注销方法。除了超类的实现之外,我们希望应用一个特定于管理员的定制行为——为了简单起见,显示“Additional logout actions for a supervisor”的消息。以下代码片段展示了我们应该如何实现这个行为:
class Supervisor(Employee):
def logout(self):
super().logout()
print("Additional logout actions for a supervisor")
最重要的是要注意,我们使用 super() 作为对超类的引用来创建一个超类的代理对象。从概念上讲,你可以将 super() 视为一个超类的临时实例对象,允许我们在该对象上调用超类的 logout 方法。有了这个部分重写的 logout 方法,你期望什么输出?以下就是结果:
supervisor = Supervisor("John", "1001")
supervisor.logout()
# output the following lines:
An employee John just logged out.
Additional logout actions for a supervisor
从输出中,我们可以看到,在管理员实例上调用 logout 不仅通过 super().logout() 调用了 Employee 类的 logout 方法,还调用了 Supervisor 的 logout 方法中的额外定制实现。
8.5.4 创建超类的非公共方法
在 8.3 节中,我们介绍了两个非公共属性/方法:受保护的和私有的。除了它们的命名差异(一个下划线前缀与两个下划线前缀)之外,我们还提到它们在子类中的可访问性不同。在本节中,我们将观察这种差异,并从类继承的角度看何时创建受保护的或私有方法。
首先,假设我们的超类 Employee 有以下实现。除了初始化方法外,我们定义了一个受保护的成员方法 _request_vacation 和一个私有方法 __transfer_group:
class Employee:
def __init__(self, name, employee_id):
self.name = name
self.employee_id = employee_id
def _request_vacation(self):
print("Send a vacation request to the employee's supervisor.")
def __transfer_group(self):
print("Transfer the employee to a different group.")
我们准备好创建一个继承自 Employee 的子类 Supervisor。为了说明在子类中受保护和私有在可访问性方面的差异,让我们尝试在 Supervisor 中访问这些非公共方法:
class Supervisor(Employee):
def do_something(self):
self._request_vacation()
self.__transfer_group()
在这个子类中,我们定义了一个实例方法 do_something,在其中我们调用了 _request_vacation 和 __transfer_group。当你调用 do_something 时,你期望会发生什么?给自己一些时间思考。记住,子类继承了受保护的成员方法。如果你准备好了,这里就是答案:
supervisor = Supervisor("John", "1001")
supervisor.do_something()
# output the following lines:
Send a vacation request to the employee's supervisor.
# ERROR: AttributeError: 'Supervisor' object has no attribute
➥ '_Supervisor__transfer_group'
如你所见,_request_vacation 被成功调用,这是预期的。但是 __transfer_group 无法被调用,因为使用双下划线作为前缀触发了名称改写。因此,不要尝试调用 __transfer_group,Python 会尝试调用 _Supervisor__transfer_group,这是一个在 Supervisor 中未定义的方法!
考虑到子类中不同的可访问性,你应该根据以下原则定义非公开方法:如果你期望子类应该能够访问非公开方法,你应该定义受保护的方法,子类可以继承这些方法。如果你期望子类不应该访问非公开方法,你应该定义私有方法。
8.5.5 讨论
在面向对象的世界中,创建分层类结构是一种基本技术,并且对于构建干净、可维护的代码库来说是一项关键技能。超类负责处理在其子类之间共享的属性和方法。如果你在类似类中定义相同的方法,而不是在多个位置处理方法,你需要只在超类中维护这些方法。
你应该意识到创建分层类结构是有代价的。因为子类依赖于超类的行为,这种相互关系或紧密耦合可能会使代码库的更新变得棘手或困难。当你想要向子类添加内容时,你可能还需要更新其超类。因此,在你的项目中,最好使用更扁平的数据模型。然而,如果你注意到类之间存在重叠的功能,则不妨实现超类和子类。
8.5.6 挑战
在 8.1 节中,我们学习了如何在自定义类中实现 init 方法。如果子类与超类有相同的实现,你根本不需要重写 init。但是,如果你需要定制的初始化,就像 Supervisor 的情况一样,你想要重写 init。你如何在 Supervisor 类中重写 init?
提示重写 init 与其他方法的重写没有区别。你使用 super()创建一个代理对象来使用超类的构造函数。
概述
-
你的类应该将 init 作为第一个方法,并初始化实例的所有属性,即使某些属性的值为 None。
-
初始化方法 init 是一个实例方法,它使用 self 作为其第一个参数。你应该了解幕后的事情——如何通过调用构造函数来创建实例。
-
当所有实例共享相同的属性值时,你应该将它们定义为类属性,这有助于节省内存。
-
通常情况下,你可以在类中定义三种方法:实例方法(注意第一个参数是 self),静态方法(使用@staticmethod 装饰器),以及类方法(使用@classmethod 装饰器)。你应该了解这些方法之间的区别以及何时使用哪种方法。
-
当你定义一个类时,考虑最小化用户需要访问的属性和方法。通过“隐藏”它们,例如通过定义受保护的和私有方法,你帮助用户提高他们的编码效率,因为他们不需要在自动完成提示列表中烦恼这些非公开方法。
-
属性装饰器允许你创建一个只读属性,这有助于你通过不允许数据更改来创建数据完整性。如果你想允许用户更改属性,你可以为属性创建一个设置器,这也是你验证数据完整性的机会。
-
当你定义一个类时,你希望重写
__str__和__repr__,以便为用户和开发者提供适当的字符串表示。 -
创建一个分层类结构有助于你在数据模型之间存在相似性时管理你的数据。共享的数据可以放入超类中,这使得开发和维护代码库变得更加容易。
-
在创建分层类结构之前三思而后行,因为你可能通过处理超类和子类而使你的数据模型过于复杂。
9 超越基础使用类
本章涵盖
-
创建枚举
-
消除自定义类的样板代码
-
处理 JSON 数据
-
创建懒属性
-
重构繁琐的类
Python 在其核心是一个面向对象的语言。面向对象语言的标志是使用对象来保存数据和提供功能,这通常需要你实现定义良好的自定义类。在第八章中,你学习了定义类的必要技术。但还有很多其他技术可以帮助我们定义更健壮的自定义类,从而使我们能够构建一个具有良好定义的数据模型的更易于维护的代码库。
自定义类通常需要实现几个特殊方法,例如包括 init 和 repr。随着你编写更多的代码,你可能会发现编写这些方法很繁琐,因为它们可能是样板代码。你知道你可以使用 dataclass 装饰器来移除样板代码吗?
在本章中,你将学习高级技术。其中一些技术,如创建枚举,有特定的用例(例如,当你需要枚举时,比如在我们的任务管理应用程序中的任务状态)。其他技术更为基础,如重构繁琐的类和创建懒属性,无论你制作什么应用程序,你都会发现这些技术很有用。请特别注意这些与项目无关的技术。
9.1 我该如何创建枚举?
在我们的应用程序中,一些数据自然地属于同一概念伞下的同一概念。考虑四个方向——北、东、南和西,它们都属于方向类别。当我们在我们应用程序中表示这些数据时,最简单的方法是使用字符串:“north”、“east”、“south”和“west”。然而,当我们编写一个期望方向的函数时,即使我们提供了类型提示,用户也可能不清楚他们应该提供什么数据,如下例所示:
def move_to(direction: str, distance: float):
if direction in {"north", "south", "east", "west"}:
message = f"Go to the {direction} for {distance} miles"
else:
message = "Wrong input for direction"
print(message)
因为字符串缺乏固有的语义,当用户调用此函数时,他们没有任何线索关于他们应该提供什么,可能会使用一个与函数不兼容的语义上有意义的字符串:
move_to("North", 2)
# output: Wrong input for direction
如你所预期,如果我们能提供更多关于方向参数的具体类型信息,用户就会清楚地知道他们应该输入什么。此外,当你定义一个具有离散成员的类型时,例如星期和季节,枚举就有一个完美的用例。本节将探讨这个特性。
9.1.1 避免为枚举使用常规类
有些人关于实现枚举的第一想法可能是使用一个常规的自定义类。然而,正如本节所讨论的,如果你使用常规类来实现枚举,可能会遇到一些缺点。首先,让我们看看使用自定义类的一个可能的实现样子:
class Direction:
NORTH = 0
SOUTH = 1
EAST = 2
WEST = 3
从风格的角度来看,有两点是值得注意的:
-
因为这四个方向是常量,所以通常使用全部大写字母。
-
在大多数编程语言中,枚举使用整数值作为枚举成员的值。
除了这两点风格说明之外,此实现通过在 Direction 类中定义类属性进行了一些“黑客式”操作。你可以通过访问这些类属性来使用这些“枚举”(正如你将在 9.1.3 节中看到的,它们并不是真正的枚举):
print(Direction.NORTH)
# output: 0
print(Direction.SOUTH)
# output: 1
你可能注意到一些缺点。首先,这些成员的类型不是 Direction,这阻止了你在函数中使用 Direction 时使用这些成员(如图 9.1 所示)。

图 9.1 当类属性用作枚举时类型不兼容。你可以使用自定义类作为参数的类型提示,但不能在函数调用中使用成员。
成员 Direction.North 的值为 0,这是一个整数,而不是 Direction 类的实例。当你使用枚举时,你应该期待每个成员都是枚举类的实例。
另一个缺点是,你不能迭代类来遍历每个成员,因为“成员”是类属性;它们不是一个统一的实体,也不能代表枚举概念。相比之下,真正的枚举类应该支持迭代每个成员。这些缺点削弱了常规类作为枚举的目的,这是一个非 Pythonic 的实现。正如下一节所揭示的,我们将使用 enum 模块来解决这些缺点。
9.1.2 创建枚举类
你在 8.5 节中学习了关于子类的内容。创建枚举类是创建 enum 模块中内置 Enum 类的子类的过程。在本节中,你将学习如何实现一个表示方向的枚举类。下面的列表显示了代码。
列表 9.1 实现枚举类
from enum import Enum
class Direction(Enum):
NORTH = 0
SOUTH = 1
EAST = 2
WEST = 3
与自定义类实现相比,真正的枚举类是 Enum 类的子类。通过继承 Enum,枚举类将看似类属性转换为离散成员。在类体内部,我们指定成员及其关联的值。值得注意的是,我们还可以将枚举类创建为一行代码:
class DirectionOneLiner(Enum):
NORTH = 0; SOUTH = 1; EAST = 2; WEST = 3
尽管你可以通过在单行上使用分号分隔成员来在枚举类中声明成员,但我建议使用前者风格——为每个成员定义一行——因为它有更好的可读性。
可读性 枚举类中的每个成员应占一行,这样更容易看到成员是什么,以及计算成员的数量。
在许多用例中,你不需要关心成员的原始值。在我们的示例中,我们一直在使用增量的小整数,但你也可以使用任何整数:
class DirectionRandomInt(Enum):
NORTH = 100
SOUTH = 200
EAST = 300
WEST = 400
此外,Python 并不限制成员原始值所使用的数据类型。你还可以使用字符串代替整数,如下例所示:
class DirectionString(Enum):
NORTH = "N"
SOUTH = "S"
EAST = "E"
WEST = "W"
9.1.3 使用枚举
在我们定义枚举类之后,是时候探索如何从类中使用枚举了。本节涵盖了这一主题。
检查枚举成员的类型
枚举的第一个用途是检查枚举成员的类型。从 9.1.1 节中,我们知道当我们使用常规类时,使用类属性的枚举没有类的类型。在真正的枚举类中,一切工作方式都不同,如下例所示:
north = Direction.NORTH
print("north type:", type(north))
# output: north type: <enum 'Direction'>
print("north check instance of Direction:", isinstance(north, Direction))
# output: north check instance of Direction: True
如您所见,枚举类的“属性”类型与类类型相同:北变量具有 Direction 类的类型。也就是说,每个成员代表类的一个预定义实例。
使用枚举成员的属性
由于成员本质上都是枚举类的实例,所以每个成员都有实例属性并不令人惊讶。在这些属性中,最重要的是名称和值,它们是枚举成员的名称及其关联的值:
print("north name:", north.name)
# output: north name: NORTH
print("north value:", north.value)
# output: north value: 0
成员的值在多种用例中很有用。假设我们收到一个应用程序编程接口(API)响应,其中整数表示用户应前往的方向。以下代码片段展示了这一场景:
direction_value = 2
direction = Direction(direction_value)
print("Direction to go:", direction)
# output: Direction to go: Direction.EAST
如您所见,我们通过向构造函数提供适用值来构造枚举成员。因为 EAST 在 Direction 类中的值为 2,所以使用 2 调用构造函数创建 EAST 方向。如果您尝试创建一个具有未定义值的成员,您会遇到异常:
unknown_direction = Direction(8)
# ERROR: ValueError: 8 is not a valid Direction
迭代所有枚举成员
我们定义枚举的主要原因是为了将相关概念以枚举类成员的形式分组。当用户想要找出这些成员是什么时,他们可以迭代枚举类——这是常规类所不具备的功能。本节展示了如何迭代枚举类的成员。
枚举类 Direction,作为 Enum 的子类,按设计是一个由其成员组成的可迭代对象。因此,我们可以对 Direction 类使用迭代技术,如下所示:
all_directions = list(Direction)
print(all_directions)
# output: [<Direction.NORTH: 0>, <Direction.SOUTH: 1>,
➥ <Direction.EAST: 2>, <Direction.WEST: 3>]
此代码展示了如何创建一个包含所有方向的列表对象。如 5.1 节所述,我们通过使用列表构造函数和可迭代对象:Direction 类来创建列表。因为 Direction 是可迭代的,所以您也可以在 for 循环中使用它:
for direction in Direction:
pass
9.1.4 为枚举类定义方法
在本质上,枚举类仍然是一个 Python 自定义类,因此我们可以定义适用方法来为类添加更多灵活的功能。我们已经学习了如何创建枚举,并且知道枚举类是一个可迭代的对象。我们现在准备好更新 move_to 函数,如下代码片段所示:
def move_to(direction: Direction, distance: float):
if direction in Direction:
message = f"Go to the {direction} for {distance} miles"
else:
message = "Wrong input for direction"
print(message)
需要注意的一个重要事项是,我们在 Direction 中使用 direction 来确定提供的方向参数是否合适。当我们调用此函数时,我们得到期望的类型提示。然而,输出看起来并不完美:
move_to(Direction.NORTH, 3)
# output: Go to the Direction.NORTH for 3 miles
输出并不友好,因为显示的方向是 "Direction.NORTH" 而不是你预期的 north。为了解决这个问题,我们可以定义一个自定义实例方法来为成员显示适当的人类可读输出,如下一列表所示。
列表 9.2 添加自定义方法
class Direction(Enum):
NORTH = 0
SOUTH = 1
EAST = 2
WEST = 3
def __str__(self):
return self.name.lower()
def move_to(direction: Direction, distance: float):
if direction in Direction:
message = f"Go to the {direction} for {distance} miles"
else:
message = "Wrong input for direction"
print(message)
move_to(Direction.NORTH, 3)
# output: Go to the north for 3 miles
在列表 9.2 中,有两件重要的事情值得关注:
-
我们在 Direction 类中重写了 str 方法。如第 8.4 节所述,str 决定了实例的字符串表示。
-
在消息的 f-string 中,大括号包围着 direction,它在幕后调用 str 方法。从打印输出中,你可以看到我们得到了方向参数的人类可读输出。
列表 9.2 中的代码片段显示,你可以覆盖枚举类中的特殊方法。你还可以根据需要定义其他方法。例如,你可以在 Direction 类中将 move_to 函数定义为实例方法;我将把这个任务留给你在第 9.1.6 节中挑战。
9.1.5 讨论
枚举是在你有一些相关概念属于同一类别时最常用的技术。要使用枚举,通过在 enum 模块中从 Enum 类派生创建一个枚举类。当你需要向枚举类添加自定义行为时,你可以像处理常规类一样定义方法。
9.1.6 挑战
Zoe 正在构建一个基于位置的应用程序,其中她定义了一个 Direction 类,如前几节所示。在列表 9.2 中,move_to 函数是在 Direction 类外部定义的,但她认为将此函数作为实例方法更合理。你能帮她完成转换吗?
提示:将 move_to 函数放置在 Direction 类体内部。对于实例方法,别忘了第一个参数是 self,它指的是实例对象。
9.2 我如何使用数据类来消除样板代码?
数据是任何编程项目的核心元素。所有程序都有一个数据的位置。在第 3.3 节中,你学习了如何通过使用命名元组创建轻量级的数据模型。然而,由于它们的不可变性,命名元组最好用作数据持有者。如果你想实现数据的可变性和在数据操作中的更大灵活性,你需要创建一个自定义类,如第八章所讨论的。在自定义类中,最佳实践包括实现特殊方法,如 init 和 repr:
class CustomData:
def __init__(self, attr0, attr1, attr2):
self.attr0 = attr0
self.attr1 = attr1
self.attr2 = attr2
def __repr__(self):
return f"CustomData({self.attr0}, {self.attr1}, {self.attr2})"
在 init 方法中,我们将所有参数分配给实例的每个属性,而在 repr 方法中,我们创建一个 f-string,它模仿字符串字面量以进行实例化。这些方法的代码是样板代码,这意味着一切都遵循预定义的模板。如果你定义了许多其他类,你将几乎为这些方法做同样的事情。为什么我们不能消除这种样板代码?在本节中,我们将发现如何使用数据类创建一个没有所有样板代码的类。
概念 在编程中,样板代码 指的是在需要高度相似(或相同)代码的地方未做任何重大修改的代码。样板代码是一种重复的模式,尽管它处于更高的层次。
9.2.1 使用 dataclass 装饰器创建数据类
第 7.3 节介绍了装饰器,它们在不修改原始函数性能的情况下为装饰的函数提供额外的功能。然而,装饰器不仅可以装饰函数,当它们被正确定义时,也可以装饰类。其中一种特殊的装饰器是 dataclass,它通过装饰类来处理样板代码,正如本节所讨论的。
dataclass 装饰器在 dataclasses 模块中可用。在讨论如何使用这个装饰器之前,请检查下一列表中的代码,该代码创建了一个用于模拟餐厅账单管理的数据类。
列表 9.3 创建数据类
from dataclasses import dataclass
@dataclass
class Bill:
table_number: int
meal_amount: float
served_by: str
tip_amount: float
在列表 9.3 中观察以下三点:
-
我们导入 dataclass 装饰器 从* dataclasses 模块,该模块是标准 Python 库的一部分。 如果你从官方 Python 网站安装 Python,dataclasses 模块应该已经存在于你的计算机上。
-
与使用函数装饰器类似,你将装饰器放置在类的头部上方,形式为 @dataclass。
-
在类的主体中,你指定具有相应类型的属性。 注意,指定类型对于数据类是必需的。
在本节的开始,我提到我们可以使用数据类来消除一些样板代码,包括 init 和 repr。换句话说,dataclass 装饰器已经处理了样板代码:
bill0 = Bill(5, 60.5, "John", 10)
bill_output = f"Today's bill: {bill0}"
print(bill_output)
# output: Today's bill: Bill(table_number=5, meal_amount=60.5,
➥ served_by='John', tip_amount=10)
如你所见,我们创建了一个 Bill 类的实例对象,尽管在类中从未显式定义 init 方法。以类似的方式,在不实现 repr 方法的情况下,我们得到了实例的正确字符串表示形式,它模仿了实例化时的字符串。
9.2.2 为字段设置默认值
在初始化方法中为某些属性设置默认值可以使代码更简洁并节省用户的时间。数据类支持为属性设置默认值。在本节中,你将学习在数据类中设置默认值的规则。
在深入技术细节之前,我需要澄清一个关键概念。在自定义类中,在头部下方,我们列出类属性。在数据类中,dataclass 装饰器将这些属性转换为实例属性,这些属性被称为字段**。我提到这些字段需要类型注解。为什么?从机制上讲,dataclass 装饰器利用类的注解*来定位字段:
print(Bill.__annotations__)
# output: {'table_number': <class 'int'>, 'meal_amount':
➥ <class 'float'>, 'served_by': <class 'str'>,
➥ 'tip_amount': <class 'float'>}
如你所见,我们通过访问 annotations 特殊属性来检索类的所有字段。相反,如果你没有注解某些属性,它们就不能成为 annotations 属性的一部分,这阻止了 dataclass 装饰器定位这些字段。因此,dataclass 装饰器无法正确构建数据类。图 9.2 总结了创建数据类的底层过程。

图 9.2 使用 dataclass 装饰器创建数据类的底层工作流程。dataclass 装饰器利用字段的类型注解来创建样板代码,包括 init 和 repr。
在图 9.2 中,使用注解字段,dataclass 装饰器创建了相应的 init 方法。当你为字段设置默认值时,它们也成为 init 方法的一部分。为字段设置默认值涉及到使用第六章中描述的语法:你指定默认值在类型注解之后,如下列所示。
列表 9.4 为字段设置默认值
@dataclass
class Bill:
table_number: int
meal_amount: float
served_by: str
tip_amount: float = 0
因为你在 tip_amount 字段中指定了默认值,当你创建 Bill 类的实例对象时,你可以省略这个字段,它将被默认值填充:
bill1 = Bill(5, 60.5, "John")
print(bill1)
# output: Bill(table_number=5, meal_amount=60.5,
➥ served_by='John', tip_amount=0)
在第 6.1 节中,我讨论了为函数设置默认参数时,强调带有默认值的参数不能在无默认值的参数之前。同样的规则也适用于数据类。当你设置一个带有默认值且在无默认值字段之前的字段时,你会遇到 TypeError。如果你使用 PyCharm 等集成开发环境(IDE),当你这样做时,会显示警告(图 9.3)。

图 9.3 在数据类定义中将带有默认值的字段放置在无默认值字段之前时的警告
9.2.3 使数据类不可变
与不可变的命名元组相比,数据类的字段可以针对每个实例进行修改;因此,数据类是可变的。然而,根据具体的使用情况,数据的可变性可能不是所希望的。在本节中,你将学习如何使数据类不可变。
数据类装饰器不仅可以单独使用,不带任何参数,形式为@dataclass,还可以接受额外的参数以提供自定义的装饰行为。一些值得注意的参数包括 init 和 repr,它们默认设置为 True,这意味着我们要求数据类装饰器实现 init 和 repr。在其他参数中,有一个与可变性相关:frozen。当你想让你的数据类不可变时,你应该将 frozen 设置为 True。以下代码片段显示了用法:
@dataclass(frozen=True)
class ImmutableBill:
meal_amount: float
served_by: str
immutable_bill = ImmutableBill(50, "John")
immutable_bill.served_by = "David"
# ERROR: dataclasses.FrozenInstanceError: cannot assign
➥ to field 'served_by'
如你所见,对于数据类 ImmutableBill,实例创建后,我们不能再更新其字段。这种不可变性可以保护你免受意外数据更改——这是你可以从定义为不可变的命名元组中获得的特性。
可维护性 如果你不希望你的数据类更改其数据,考虑将它们的字段冻结以防止意外更改。
9.2.4 创建现有数据类的子类
在本质上,数据类与其他常规自定义类具有相同的可扩展性。如 8.5 节所述,我们可以创建一个类层次结构。在数据类的术语中,我们也可以创建一个子类。但数据类装饰器的几个方面使得数据类的子类化与常规类的子类化(未使用数据类装饰器定义)不同,如本节所述。
继承超类的字段
我们知道在数据类中,其属性成为数据字段。当你创建现有数据类的子类时,超类的所有字段自动成为子类字段的一部分:
@dataclass
class BaseBill:
meal_amount: float
@dataclass
class TippedBill(BaseBill):
tip_amount: float
问题:你能尝试对冻结的数据类进行子类化吗?
如此例所示,我们创建了 TippedBill 类作为 BaseBill 的子类。这两个类都应该使用数据类装饰器来使它们成为数据类。子类 TippedBill 的构造函数包括超类的字段和它自己的字段:
tipped_bill = TippedBill(60, 10)
print(tipped_bill)
# output: TippedBill(meal_amount=60, tip_amount=10)
当你创建子类的实例时,请记住,超类的字段先于子类的字段,顺序很重要!
避免为超类使用默认值
我们已经看到,数据类的子类使用其超类和自己的所有字段,遵循超类 -> 子类的顺序。然而,在 9.2.2 节中,你了解到具有默认值的字段必须放在没有默认值的字段之后。这一要求有一个重要的含义:如果超类有具有默认值的字段,你必须为每个子类的字段指定默认值。否则,你的代码将无法工作,如下例所示:
@dataclass
class BaseBill:
meal_amount: float = 50
@dataclass
class TippedBill(BaseBill):
tip_amount: float
# ERROR: TypeError: non-default argument 'tip_amount'
➥ follows default argument
因此,在大多数情况下,你可能想避免为超类设置默认值,这样你将会有更多的灵活性来实现你的子类。如果你为超类设置了默认值,你也必须为子类指定默认值:
@dataclass
class BaseBill:
meal_amount: float = 50
@dataclass
class TippedBill(BaseBill):
tip_amount: float = 0
9.2.5 讨论
使用 dataclass 装饰器,你可以轻松地将普通类转换为数据类,这有助于消除你否则必须编写的许多样板代码。与轻量级的数据模型命名元组相比,我们使用数据类,因为它们是可变的数据模型,并且因为它们通过定义自定义功能支持可扩展性,就像常规自定义类一样。如果需要,我们可以冻结属性以防止不希望的数据更改——这是命名元组也具有的优势。
9.2.6 挑战
布拉德利在一家网站公司的分析团队工作。他在项目中使用数据类。他知道当他为一个函数中的可变参数设置默认值时(第 6.1 节),惯例是使用 None 作为默认值。但他不确定对于如列表这样的可变数据类字段,他应该使用什么值。你能想出他应该设置什么默认值吗?
提示:dataclass 模块有一个名为 field 的函数,该函数旨在为可变字段设置默认值。
9.3 如何准备和处理 JSON 数据?
当你的应用程序与外部实体(如其他网站)交互时,应该有一个数据交换机制。你可能需要从另一个服务器下载数据,例如,通常以 API 的形式。JavaScript 对象表示法(JSON)是不同系统之间数据交换中最受欢迎的格式之一。假设我们的任务管理应用程序使用一个 API 从服务器获取以下 JSON 数据,它类似于 Python 中的字典对象:
{
"title": "Laundry",
"desc": "Wash clothes",
"urgency": 3
}
对于另一个 API,我们可能得到以下数据,它类似于 Python 中的由两个字典对象组成的列表对象。请注意,我已经通过使用适当的缩进来格式化字符串,使其更容易阅读:
[
{
"title": "Laundry",
"desc": "Wash clothes",
"urgency": 3
},
{
"title": "Homework",
"desc": "Physics + Math",
"urgency": 5
}
]
当你以字符串形式接收这些数据时,为了进一步操作数据,你希望将其转换为适当的类(第八章讨论)。更普遍地说,JSON 的出色可读性和其类似对象的架构使其成为任何你可能会工作的应用程序中的通用数据格式。在本节中,你将了解在 Python 中处理 JSON 数据的基本技术。
9.3.1 理解 JSON 的数据结构
在你学习处理 JSON 数据之前,你需要了解 JSON 数据的结构及其与 Python 数据类型的关系。本节致力于介绍 JSON 数据。如果你对这个主题很熟悉,请随时跳到下一节。
JSON 数据以键值对的形式组织在成对的花括号中,例如 {"title": "Laundry", "desc": "Wash clothes", "urgency": 3}。JSON 对象要求它们的键只能是字符串,这一要求允许不同系统之间的标准通信。显示的值包括字符串和整数,但 JSON 支持其他数据类型,包括布尔值、数组(类似于 Python 中的列表)和对象,如表 9.1 所总结。
表 9.1 JSON 数据类型
| 数据类型 | 数据内容 |
|---|---|
| String | 双引号包围的字符串字面量 |
| Number | 数字字面量,包括整数和小数 |
| Boolean | 布尔值,true 或 false(全部小写) |
| Array | 包围在方括号中的支持数据类型列表 |
| Object | 花括号包围的键值对 |
| Null | 表示任何有效数据类型空值的特殊值(null) |
我们知道我们可以使用单引号或双引号来表示 Python 字符串。但是 JSON 字符串必须仅用双引号包围。单引号的不当使用会创建无效的 JSON 数据,这些数据无法被常见的 JSON 解析器处理。
注意:在 JSON 中,只能使用双引号来包围字符串。
重要的是要知道 JSON 支持嵌套数据结构。一个 JSON 对象可以包含另一个 JSON 对象,例如。数组可以是任何支持的数据类型的列表,包括对象。以下是一些示例:
embedded object: {"one": {"one": 1}, "two": {"two": 2}}
array of strings: ["one", "two", "three"]
JSON 中混合不同数据类型的灵活性使我们能够以键值对的形式构建具有清晰结构信息复杂的数据。
9.3.2 在 JSON 和 Python 之间映射数据类型
当你使用 Python 开发应用程序,并且你的应用程序通过 JSON 与其他系统交互时,你必须知道如何在不同数据类型之间进行转换。从高层次来看,转换涉及不同的 JSON 数据类型如何映射到相应的 Python 数据类型。
由于 JSON 和 Python 都用于通用目的,因此 JSON 数据类型有对应的原生 Python 数据结构也就不足为奇了。图 9.4 展示了这些转换。大多数转换都很直接。但是 Python 没有与 JSON 对象中的数字相对应的原生数据类型,这些数字不区分整数和浮点数,统称为数字。相比之下,当 JSON 中的数字是整数或实数时,Python 使用 int 和 float 来表示。

图 9.4 JSON 和 Python 之间的数据转换以及支持示例。请注意,由于这两种语言使用不同的术语,这些类型在 JSON 和 Python 中的名称不同,例如 String 与 str。
9.3.3 反序列化 JSON 字符串
当我们将 JSON 数据读取到其他编程语言的数据结构中,如 Python,我们进行 解码 或 反序列化 JSON 数据。阅读和解码过程的更正式术语是 反序列化。在本节中,你将学习如何将 JSON 数据读取到 Python 中。
我提到,网络服务通常使用 JSON 对象作为 API 响应,并且这些响应以文本形式表示,以促进系统间数据交换。考虑以下以 Python 字符串对象表示的响应:
tasks_json = """
[
{
"title": "Laundry",
"desc": "Wash clothes",
"urgency": 3
},
{
"title": "Homework",
"desc": "Physics + Math",
"urgency": 5
}
]
""" ❶
❶ 使用三引号表示多行字符串
标准 Python 库包含 json 模块,该模块专门用于反序列化 JSON 数据。为了读取这个 JSON 字符串,我们使用 loads 方法。如下面的代码片段所示,我们获得一个由两个格式良好的字典对象组成的列表对象,这些对象代表最初保存在 JSON 数组中的两个 JSON 对象:
import json
tasks_read = json.loads(tasks_json)
print(tasks_read)
# output: [{'title': 'Laundry', 'desc': 'Wash clothes', 'urgency': 3},
➥ {'title': 'Homework', 'desc': 'Physics + Math', 'urgency': 5}]
如果数据以字典的形式存在,我们无法利用第九章中讨论的 Task 类中定义的功能。因此,我们需要将这些字典对象转换为 Task 类的实例。这种转换突出了类方法的完美用例,如下面的列表所示。
列表 9.5 将字典对象转换为自定义类的实例
from dataclasses import dataclass
@dataclass
class Task:
title: str
desc: str
urgency: int
@classmethod
def task_from_dict(cls, task_dict):
return cls(**task_dict)
tasks = [Task.task_from_dict(x) for x in tasks_read]
print(tasks)
# output: [Task(title='Laundry', desc='Wash clothes', urgency=3),
➥ Task(title='Homework', desc='Physics + Math', urgency=5)]
在列表 9.5 中,我们成功地将字典对象列表转换为 Task 实例对象列表,正如我们计划的那样。值得注意的是,我们使用了迄今为止学到的几种技术。正如第一章(第 1.4 节)中提到的,我们在过程中尝试综合各种技术。以下是关键要点:
-
我们在 Task 类上使用 dataclass 装饰器(第 9.2 节),这样我们就不必实现 init 和 repr 的样板代码。
-
类方法(第 8.2.3 节)task_from_dict 中的 cls 参数指的是 Task 类。
-
我们知道kwargs指的是可变数量的关键字参数(第 6.4 节),并且被打包为一个字典对象。相反,为了访问键值对,运算符将字典对象转换为关键字参数,构造函数使用这些参数来创建 Task 类的新实例。
我们已经看到了如何将 JSON 数组转换为 Python 中的列表对象。loads 方法非常灵活。该方法不仅可以将 JSON 数组转换为列表,还可以解析除对象以外的任何 JSON 数据类型。以下是一些示例:
json.loads("2.2")
# output: 2.2
json.loads('"A string"')
# output: 'A string'
json.loads('false') ❶
# output: False
json.loads('null') is None ❷
# output: True
❶ 布尔值
❷ JSON null 转换为 Python None
这些字符串代表 JSON 数据,包括浮点数、字符串、布尔值和 Null,并且它们都通过 loads 方法进行转换,无需任何定制。所有的转换都是自动完成的,这突出了 Python 作为通用语言的力量。
9.3.4 将 Python 数据序列化为 JSON 格式
当你处理来自外部实体的 JSON 数据时,你正在构建一个传入通信路由。同时,你可能需要构建一个传出路由,以便你的应用程序可以向外部世界发送适用的信息。
如图 9.5 所示,反序列化 JSON 数据的相反过程是从其他数据创建 JSON 数据,这个过程称为序列化。因此,当我们把 Python 数据转换为 JSON 数据时,我们序列化 Python 对象为 JSON 数据。本节讨论 JSON 序列化。

图 9.5 JSON 与 Python 之间的数据转换。当你将 JSON 转换为 Python 时,这个过程是反序列化;当你将 Python 转换为 JSON 时,这个过程是序列化。
与 loads 方法一样,json 模块有 dumps 方法来处理 JSON 数据序列化。对于基本内置数据类型,转换是直接的:
builtin_data = ['text', False, {"0": None, 1: [1.0, 2.0]}]
builtin_json = repr(json.dumps(builtin_data)) ❶
print(builtin_json)
# output: '["text", false, {"0": null, "1": [1.0, 2.0]}]'
❶ 要显示字符串的引号,请使用 repr。
在这个例子中,请注意,dumps 方法创建了一个包含不同类型 JSON 数据的 JSON 数组。最重要的观察结果是,尽管原始列表对象使用的是原生 Python 数据结构,但生成的 JSON 字符串具有相应的 JSON 数据结构。注意以下转换:
-
单引号('text')包围的字符串现在使用双引号("text")。
-
Python 的 bool 对象 False 变为 false。
-
对象 None 变为 null。
-
因为只有字符串可以是 JSON 键,所以数字 1 自动转换为它的字符串对应物,"1"。
如果您尝试序列化自定义类(如 Task)的实例对象会发生什么?以下是结果:
json.dumps(tasks[0])
# ERROR: TypeError: Object of type Task is not JSON serializable
如您所见,我们无法序列化自定义类实例。主要原因是一个自定义类实例可能包含许多属性和其他元数据,如果没有适当的指令,Python 就不知道应该序列化哪些数据。因此,为了使自定义类可序列化,我们必须提供序列化指令。以下是一个可能的解决方案(请注意,存在其他解决方案):
dumped_task = json.dumps(tasks[0], default=lambda x: x.__dict__)
print(dumped_task)
# output: {"title": "Laundry", "desc": "Wash clothes", "urgency": 3}
我们对 dumps 函数调用所做的最重要的更改是使用默认参数。此参数指示编码器(进行编码或序列化的底层对象)在无法序列化对象时应使用哪个对象(作为后备)。在这种情况下,因为我们知道编码器无法序列化 Task 类实例对象,所以我们指示编码器使用其字典表示形式。编码器知道如何转换内置的 dict 类。
在转换过程中,我们经常使用两个其他功能。首先,为了以更可读的格式创建 JSON 对象,我们可以设置 indent 参数以获得适当的缩进:
task_dict = {"title": "Laundry", "desc": "Wash clothes", "urgency": 3}
print(json.dumps(task_dict, indent=2))
# output the following lines:
{
"title": "Laundry",
"desc": "Wash clothes",
"urgency": 3
}
每个级别都进行了适当的缩进,以指示 JSON 对象及其键值对的相对结构。
可读性 使用适当的缩进来提高 JSON 数据的可读性。可读性在创建 JSON 字符串时尤其相关。
另一个有用的功能是设置 sort_keys 参数。因为我们将其设置为 True,所以创建的 JSON 字符串的键按字母顺序排序,这使得我们更容易查找信息,尤其是对于多个项目。观察以下功能:
user_info = {"name": "John", "age": 35, "city": "San Francisco",
➥ "home": "123 Main St.", "zip_code": 12345, "sex": "Male"}
print(json.dumps(user_info, indent=2, sort_keys=True))
# output the following lines:
{
"age": 35,
"city": "San Francisco",
"home": "123 Main St.",
"name": "John",
"sex": "Male",
"zip_code": 12345
}
9.3.5 讨论
JSON 可能是不同系统之间数据交换中最流行的数据格式。你应该知道如何使用原生 Python 对象来反序列化和序列化 JSON 数据。需要注意的是,Python 中自定义类的实例默认情况下不是 JSON 可序列化的,因此你应该指定自定义编码行为。除了处理 JSON 字符串外,json 模块还提供了 dump 和 load 方法来直接处理 JSON 文件。这些方法的调用签名几乎与 dumps 和 loads 的调用签名相同。
9.3.6 挑战
卢卡斯正在构建一个社交媒体网络应用作为他的暑期实习项目。在他的应用中,他使用命名元组作为数据模型。假设项目有以下命名元组类:
from collections import namedtuple
User = namedtuple("User", "first_name last_name age")
user = User("John", "Smith", "39")
如果他尝试序列化用户对象会发生什么?
提示:元组对象是 JSON 可序列化的,在序列化后成为 JSON 数组。
9.4 如何创建延迟属性以提高性能?
延迟 评估 是一种通用的编程实现范式,它将评估操作推迟到需要执行时。通常,当操作昂贵,需要大量处理时间或内存时,延迟评估是首选的评估模式。例如,生成器(第 7.4 节)是延迟评估的应用,它延迟检索和产生下一个项。延迟评估也是自定义类中的一个相关主题。具体来说,你可以为实例对象定义延迟属性以节省时间或内存。在本节中,你将了解如何定义延迟属性。
9.4.1 确定使用场景
让我们先确定一个合适的使用场景。假设我们的任务管理应用是一个社交媒体应用,用户可以关注其他用户。一个功能是查看用户的关注者。在应用中,我们可以通过点击用户的缩略图进一步查看用户的详细配置文件。考虑以下列表中的实现。
列表 9.6 创建 User 类
class User:
def __init__(self, username):
self.username = username
self.profile_data = self._get_profile_data()
print(f"### User {username} created")
def _get_profile_data(self):
# get the data from the server and load it into memory
print("*** Run the expensive operation")
fetched_data = " Extensive data, including thumbnail,
➥ followers, etc."
return fetched_data
def get_followers(username):
# get the followers from the server for the user
usernames_fetched = ["John", "Aaron", "Zack"]
followers = [User(username) for username in usernames_fetched]
return followers
我们定义 User 类来管理用户相关数据,get_followers 函数用于获取用户的关注者。当我们调用这个函数时,我们观察到以下输出:
followers = get_followers("Ashley")
# output the following lines:
*** Run the expensive operation
### User John created
*** Run the expensive operation
### User Aaron created
*** Run the expensive operation
### User Zack created
如你所见,当我们获取用户的关注者时,我们为每个用户创建多个实例对象。这个过程需要昂贵的操作来获取配置文件数据,因为应用程序必须连接到远程服务器下载数据并将其加载到内存中。然而,配置文件数据并不需要,因为我们只需要显示关注者的用户名,除非用户点击关注者;然后关注者的配置文件数据才变得相关。预先为所有用户加载数据是一个不必要的操作,因此我们应该考虑使用延迟评估技术来避免繁重的操作。接下来的几节将探讨实现延迟属性的两个方法。
9.4.2 覆盖 _getattr 特殊方法以实现延迟属性
在自定义类中,我们可以覆盖除 __str__ 和 __repr__ 之外的特殊方法来定义自定义行为(第 8.3 节)。其中一种方法,__getattr__,与检索实例属性相关。在本节中,我们将看到如何通过覆盖 __getattr__ 来实现懒属性。
对于自定义类,实例对象的属性保存在一个字典对象中,可以通过特殊属性 __dict__ 访问。这个字典对象使用属性名作为键,属性值作为相应的值。当你使用点符号访问实例对象的属性时,如果字典对象包含该属性,它将返回该值。如果字典对象不包含该属性,特殊方法 __getattr__ 将作为后备机制被调用,并尝试为请求的属性提供一个值。图 9.6 描述了与访问 __dict__ 和 __getattr__ 相关的解决实例属性顺序。
注意:属性解析顺序比图 9.6 所示的更复杂。实例的属性也可以使用类的属性作为后备,例如。图 9.6 是一个简化版本,适用于常见场景。

图 9.6 展示了解决实例对象属性顺序。Python 首先检查实例对象的字典对象是否包含该属性。如果字典对象不包含该属性,Python 会检查是否可以通过调用 __getattr__ 特殊方法返回一个值。
现在我们已经了解了 __dict__ 和 __getattr__ 如何协同工作以提供实例对象的所需属性,我们准备查看覆盖 __getattr__ 以实现懒属性的具体实现,如下所示。
列表 9.7 在类中覆盖 __getattr__
class User:
def __init__(self, username):
self.username = username
print(f"### User {username} created")
def __getattr__(self, item):
print(f"__getattr__ called for {item}")
if item == "profile_data":
profile_data = self._get_profile_data()
setattr(self, item, profile_data)
return profile_data
def _get_profile_data(self):
# get the data from the server and load it into memory
print("*** Run the expensive operation")
fetched_data = "Extensive data, including thumbnail,
➥ followers, etc."
return fetched_data
与列表 9.6 相比,列表 9.7 有两个显著的变化:
-
*
__init__方法移除了设置 profile_data 属性**。这种移除是必要的,因为如果设置了,即使设置为 None,profile_data 属性及其值也会存储在对象的__dict__属性中。特殊方法__getattr__无法被调用,这违背了使用__getattr__实现懒属性的目的。 -
*在
__getattr__方法中,我们指定当访问 profile_data 属性时,我们将运行昂贵的操作以获取用户的配置数据。重要的是要注意,我们还使用setattr设置了获取的数据;当再次访问 profile_data 属性时,它将立即可用。
这些更改后,我们期望以下行为:
-
行动 1—当创建用户时,没有配置文件数据,防止了前期进行昂贵的操作。
-
行动 2—当我们访问属性时,可以触发昂贵的操作以提供所需的属性。
-
行动 3——当我们第二次访问属性时,没有必要再次运行昂贵的操作。
让我们看看我们的预期是否得到满足:
followers = get_followers("Ashley") ❶
# output the following lines:
### User John created
### User Aaron created
### User Zack created
follower0 = followers[0]
follower0.profile_data ❷
# output the following lines:
__getattr__ called for profile_data
*** Run the expensive operation
'Extensive data, including thumbnail, followers, etc.'
follower0.profile_data ❸
'Extensive data, including thumbnail, followers, etc.'
❶ 行动 1
❷ 行动 2
❸ 行动 3
对于行动 1,当我们获取一个用户的关注者时,创建的 User 实例对象只包含用户名,这节省了内存!对于行动 2,当我们第一次访问 profile_data 时,会运行昂贵的操作来获取数据。对于行动 3,当我们第二次访问 profile_data 时,我们获取数据而不触发昂贵的操作,这节省了时间!
9.4.3 将属性作为延迟属性实现
在 8.3 节中,你学习了如何使用属性装饰器创建只读属性,作为一种更精细的访问控制方法。因为属性装饰器允许我们“拦截”属性是如何被访问的,我们可以用它来实现本节中讨论的延迟属性功能。请注意,属性并不是严格意义上的属性,但在支持点符号方面,属性和属性是相似的。
到现在为止,你应该熟悉使用属性装饰器。你可以直接跳到下一个列表,看看如何创建涉及 @property 的延迟属性。
列表 9.8 创建延迟属性装饰器
class User:
def __init__(self, username):
self.username = username
self._profile_data = None
print(f"### User {username} created")
@property
def profile_data(self):
if self._profile_data is None:
print("_profile_data is None")
self._profile_data = self._get_profile_data()
else:
print("_profile_data is set")
return self._profile_data
def _get_profile_data(self):
# get the data from the server and load it into memory
print("*** Run the expensive operation")
fetched_data = "Extensive data, including thumbnail,
➥ followers, etc."
return fetched_data
与列表 9.6 相比,列表 9.8 有两个显著的变化:
-
在 init 方法中,我们将 profile_data 属性设置为 None。profile 数据是 profile_data 属性的内部管理对应物;将其设置为 None 相比于在实例化期间获取数据可以节省内存。
-
我们将 profile_data 实现为一个属性。在这个方法中,我们检查 profile_data 是否已设置,并且只有在 profile 数据未设置时才运行昂贵的操作。如果已设置,我们返回该值。
如 9.4.2 节所述,我们期望从 9.8 列表中实现的 User 类中获取相同的三种行为:
followers = get_followers("Ashley")
# output the following lines:
### User John created
### User Aaron created
### User Zack created
follower0 = followers[0]
follower0.profile_data
# output the following lines:
_profile_data is None
*** Run the expensive operation
'Extensive data, including thumbnail, followers, etc.'
follower0.profile_data
# output the following lines:
_profile_data is set
'Extensive data, including thumbnail, followers, etc.'
与我们预期的行为一致,当用户被创建时,他们的配置文件数据不会被加载。相反,当请求用户的配置文件数据时,会执行昂贵的操作,这正是延迟评估的核心——直到必须执行时才进行评估,从而节省时间(不运行耗时操作)和内存(不使用任何内存来存储大量数据)。
9.4.4 讨论
你可以通过覆盖 getattr 或实现一个属性为自定义类提供延迟评估的属性。我建议使用属性方法;它更直接,并且所有实现都是明确的。相比之下,覆盖 getattr 需要了解 Python 实例对象的属性解析顺序是如何工作的。
9.4.5 挑战
Tim 正在更新他公司发布的 Python 包。包中的一个 API 访问对象的属性,例如 user.initials。随着最近的更新,他需要对这个属性有更精细的控制。他如何创建一个属性来维护 API?
提示:属性和属性都支持点符号。你可以在更新的代码库中将先前定义的属性转换为属性。
9.5 我该如何定义具有不同关注点的类?
随着你项目的开发,你会发现你必须处理更多的数据。假设你从一个类开始来管理数据。为了适应不断增长的数据量,如果你的类坚持使用单一类,它可能会变得笨拙。这个问题的潜在原因之一是类可能具有混合的关注点;一个类模型了不同类型的数据,这可能会使你的项目难以维护。
想象一下图 9.7 中显示的两个场景。在第一个场景中,一个大框(你的类)包含两种类型的对象(数据)。在第二个场景中,你有两个较小的框(两个独立的类),每个框只包含一种类型的对象。你可以判断哪种场景更适合管理对象。

图 9.7 当对象由其自身类型在单独的框中处理,而不是存储混合对象的较大框时,组织得更好
在本节中,我将向你展示如何定义具有不同关注点的类,这是重构项目的重要形式。这个主题对于提高项目的长期可维护性很重要,因为移动多个较轻的框比移动一个巨大的重框更容易。你会发现,当每个类专注于一个目的时,维护和更新数据模型是可管理的。
9.5.1 分析一个类
在理想的项目中,我们有一个经验丰富的领导者,可以为我们的项目设计完美的数据结构:我们的项目有多个小的类,每个类都针对特定的数据模型。然而,假设你被分配到更新和维护你公司的一个遗留项目。你会发现基本的数据模型是一个巨大的单一类,这使得这个项目几乎无法更新。在本节中,你将看到这种笨拙的类可能的样子以及如何分析它。
假设这个项目涉及一个学区使用的用于管理数据的程序。一个关键类是 Student,它存储所有与学生相关的数据。这个类的结构如下所示。请注意,为了简单起见,我只展示了 Student 类的一部分。
列表 9.9 具有混合目的的类
class Student:
def __init__(self, first_name, last_name, student_id):
self.first_name = first_name
self.last_name = last_name
self.student_id = student_id
self.account_number = self.get_account_number()
self.balance = self.get_balance()
age, gender, race = self.get_demographics()
self.age = age
self.gender = gender
self.race = race
def get_account_number(self):
# query database to locate the account number using student_id
account_number = 123456
return account_number
def get_balance(self):
# query database to get the balance for the account number
balance = 100.00
return balance
def get_demographics(self):
# query database to get the demographics using student_id
birthday = "08/14/2010"
age = self.calculated_age(birthday)
gender = "Female"
race = "Black"
return age, gender, race
@staticmethod
def calculated_age(birthday):
# get today's date and calculate the difference from birthday
age = 12
return age
在你对现有类进行任何操作之前,生成一个图表来检查其组件是个好主意。虽然你可以用不同的方式创建这样的图表,但关键是高层次的视图。为此,使用统一建模语言(UML)图(图 9.8)。

图 9.8 学生类的 UML 图(版本 0)。在图中,我们列出了类的所有属性和方法。
概念 UML 是可视化系统设计的一种标准方式,显示了系统的组件及其连接。
在 UML 图的版本 0 中,你不会做出判断,只是列出 Student 类的结构组件。为了帮助查看数据,你列出方法的名称而不包含任何实现细节。在获得类的结构信息后,下一步是检查其功能组件(图 9.9)。

图 9.9 Student 类的 UML 图(版本 1)。在这个图中,我们根据功能将方法分组。
在 UML 图(版本 1)中,共同实现相同功能的方法被分组在一起。这里,我们有两个功能组件:一个处理学生的午餐账户,另一个处理学生的人口统计信息。此外,每个功能组件都有相关的属性。
9.5.2 创建额外的类以隔离关注点
图 9.9 展示了 Student 类的一部分。在实际项目中,这个类可能包含许多其他功能,例如午餐账户和人口统计信息可能包括其他方法。例如,管理午餐账户的功能可能有许多额外的操作,如挂失卡片和合并多个账户。实现这些操作会使 Student 类变得复杂。正如本节所讨论的,我们应该创建具有单独关注点的额外类。
当我们分析 Student 类时,我们识别出两个主要的功能组件:午餐账户和人口统计信息,它们代表了与 Student 类不同的关注点。因此,这两个功能组件可以形成它们自己的类。在我们编写任何代码之前,我们可以继续完善我们的 UML 图(图 9.10),其更新版本反映了我们应用程序的额外结构组件。

图 9.10 将处理 Account 和 Demographics 的功能隔离成单独的类(UML 图版本 2)。请注意,我列出了每个类可能存在的其他属性和方法。
更新的 UML 图展示了两个额外的类:Account 和 Demographics。Account 类具有管理学生午餐账户的属性和方法,而 Demographics 类具有处理学生人口统计信息的属性和方法。
9.5.3 连接相关类
当我们创建 Account 和 Demographics 类时,这个过程是单向的;我们从现有的 Student 类中提取信息。这两个类仍然是独立的,并且还没有与 Student 类协同工作。在本节中,我们将连接它们,以便它们以整洁的方式协同工作。
使用属性连接数据
你可能已经注意到,Account 和 Demographics 类都有 student_id 属性。由于学生识别号码的唯一性,可以通过唯一的 student_id 连接特定学生的所有数据。为了在 Student 和 Account/Demographics 之间建立双向交通,Student 类的实例对象应该能够通过 student_id 访问账户和人口统计信息。接下来的列表展示了如何连接实例对象。
列表 9.10 分离类以具有不同的关注点
class Account:
def __init__(self, student_id):
# query the database to get additional information using student_id
self.account_number = 123456
self.balance = 100
class Demographics:
def __init__(self, student_id):
# query the database to get additional information using student_id
self.age = 12
self.gender = "Female"
self.race = "Black"
class Student:
def __init__(self, first_name, last_name, student_id):
self.first_name = first_name
self.last_name = last_name
self.student_id = student_id
self.account = Account(self.student_id)
self.demographics = Demographics(self.student_id)
在列表 9.10 中,我们定义了 Account 和 Demographics 类,仅实现了初始化方法。值得注意的是,我们通过添加两个属性:account 和 demographics,分别对应 Account 和 Demographics 类的实例对象,来更新 Student 类的初始化方法。这样做,我们连接了这三个类。现在我们可以检查 Student 类实例的属性:
student = Student("John", "Smith", "987654")
print(student.account.__dict__)
# output: {'account_number': 123456, 'balance': 100}
print(student.demographics.__dict__)
# output: {'age': 12, 'gender': 'Female', 'race': 'Black'}
如您所见,实例 student 具有正确的账户和人口统计信息,因为它具有 Account 和 Demographics 的实例作为其属性。请注意,我们可以将 student_id 作为 Account 和 Demographics 类实例对象的属性保存。然而,我们不必这样做,因为 Student 的实例对象具有账户和人口统计属性;连接已经建立。
连接方法
在这三个类之间连接数据是直接的。有趣的部分是连接方法。
创建额外类别的目的不仅仅是让它们持有特定的属性。更重要的是使用这些类来提供专门的函数。具体来说,我们的计划是将所有账户管理的实现移动到 Account 类,将所有人口统计的实现移动到 Demographics 类。下面的列表展示了 Account 和 Demographics 类的更新版本。
列表 9.11 更新的 Account 和 Demographics 类
class Account:
def __init__(self, student_id):
self.student_id = student_id
# query the database to get additional information using student_id
self.account_number = self.get_account_number_from_db()
self.balance = self.get_balance_from_db()
def get_account_number_from_db(self):
# query database to locate the account number using student_id
account_number = 123456
return account_number
def get_balance_from_db(self):
# query database to get the balance for the account number
balance = 100.00
return balance
class Demographics:
def __init__(self, student_id):
self.student_id = student_id
# query the database to get additional information
age, gender, race = self.get_demographics_from_db()
self.age = age
self.gender = gender
self.race = race
def get_demographics_from_db(self):
# query database to get the demographics using student_id
birthday = "08/14/2010"
age = self.calculated_age(birthday)
gender = "Female"
race = "Black"
return age, gender, race
@staticmethod
def calculated_age(birthday):
# get today's date and calculate the difference from birthday
age = 12
return age
问题:如果数据库操作成本高昂,例如在云端托管数据库,它们可以实施为延迟属性。你能回忆起如何做吗?请参阅第 9.4 节。
在我们的应用程序中,每当我们要显示学生的账户信息时,我们可以直接利用 Account 类。我们可以通过运行以下代码来显示学生的余额:
balance_output = f"Balance: {student.account.balance}"
print(balance_output)
# output: Balance: 100.0
以类似的方式,我们可以通过运行以下代码来展示学生的人口统计信息:
demo = student.demographics
demo_output = f"Age: {demo.age}; Gender: {demo.gender}; Race: {demo.race}"
print(demo_output)
# output: Age: 12; Gender: Female; Race: Black
注意,一些用户可能更喜欢使用来自较少类的函数,因此他们可能会在 Student 类中创建一些方法:
class Student:
def __init__(self, first_name, last_name, student_id):
self.first_name = first_name
self.last_name = last_name
self.student_id = student_id
self.account = Account(self.student_id)
self.demographics = Demographics(self.student_id)
def get_account_balance(self):
return self.account.balance
def get_demographics(self):
demo = self.demographics
return demo.age, demo.gender, demo.race
我们可以通过调用 Student 实例上的 get_account_balance 和 get_demographics 方法来获取账户余额和人口统计信息。然而,我不推荐这种模式。它使得 Student 和 Account/Demographics 类之间的联系过于紧密——这是一个被称为 紧密耦合 的问题。当你更新 Account 类时,你可能也必须更新 Student 类,因为它的功能(获取账户余额)依赖于 Account。
可维护性:不要在相关类之间引入紧密耦合。为了最佳的可维护性,类应该处于松散耦合状态。
9.5.4 讨论
在开始你的项目之前,使用 UML 图来规划数据管理所需的基本类是一个好习惯。然而,不要期望这项工作是一次性的。随着项目的进展,你可能会意识到某些类变得越来越复杂。在整个项目开发过程中不时地思考你的数据模型是一个很好的习惯。单一的目标是使类保持瘦小和松散连接——也就是说,相关的类可以一起工作,但不要过度依赖彼此,这样在紧密耦合的设计中重构会变得困难。
9.5.5 挑战
在本节代码片段中,我故意将类中的所有方法都设置为公开。然而,如第 8.3 节所述,将用户不需要访问的方法设置为非公开是一个最佳实践。作为一个挑战,你能将列表 9.11 中的适用方法设置为非公开吗?
提示:如果你想定义一个非公开方法,请使用下划线作为方法名的前缀。
摘要
-
当你需要将相关概念分组时,可以通过继承 Enum 类来创建枚举类。
-
枚举类使得遍历可能值和成员检查变得方便。
-
使用 dataclass 装饰器创建类以避免样板代码,例如实现
__init__和__repr__。当你使用这个装饰器时,请记住使用类型注解来创建适用的字段。 -
JSON 数据是不同系统间通用的数据交换格式。我们可以使用 json 模块将 JSON 转换为原生 Python 数据结构(JSON 反序列化)以及相反的过程(JSON 序列化)。
-
自定义类的实例通常不是 JSON 序列化的。你应该为类的 JSON 序列化提供特定的编码指令。
-
你可以使用
__getattr__来实现延迟属性,但必须理解__getattr__是当属性不包含在对象的__dict__属性中时的一个后备机制。 -
实现属性允许你更精细地控制特定的属性。在延迟属性的情况下,你可以将 None 设置为内部管理的对应属性。当请求属性时,你可以设置对应属性。
-
课堂应该保持单一目的。当你的类在范围上增长,你意识到它具有多种目的时,你应该重构你的类以创建不同的类,每个类都针对特定的需求。
-
使用 UML 图来分析类的结构,这让你能够从高层次上清晰地理解类。
第四部分:操作对象和文件
Python 是一种设计为面向对象的编程语言。它的模块、包、内置数据类型,以及函数和自定义类及其实例,都是对象。因此,对象的一般特性是每个 Python 用户都应该非常了解的必要主题。在本部分,我们专注于 Python 中对象使用的基础知识。
除了对象之外,这部分还涵盖了读取和处理文件的内容,这些是最常见的存储机制。作为一种通用语言,Python 使我们能够做以下事情:
-
读取存储在文件中的数据,无论是纯文本还是以逗号分隔的数据
-
将数据写入文件
-
移动、删除和复制文件
-
获取文件的元数据,例如修改时间
10 对象的基础知识
本章涵盖
-
检查对象
-
展示一个对象的生命周期
-
复制一个对象
-
解决变量:LEGB 规则
-
理解对象的调用性
在 Python 中,对象无处不在,因为 Python 是一种设计上就是面向对象编程(OOP)的语言。我们在应用程序中不断与对象打交道。因此,了解使用对象的基本知识非常重要,尤其是自定义类的实例对象,因为它们是应用程序中最普遍的数据模型。例如,在一个函数中,我们期望用户可能发送不同类型的数据,我们可以通过相应地处理适用的数据类型来增加这种灵活性。作为另一个例子,当我们有一个工作副本需要更新,同时保持原始对象完整以便在需要撤销更新时使用时,复制一个对象是必要的。在本章中,我将介绍对象的基础知识。当然,本章并不旨在详尽无遗,因为 Python 中的一切都是对象,我无法涵盖对象使用的所有方面。另外,要注意的是,一些部分针对特定问题(例如第 10.4 节是关于在不同作用域中更改变量),但我会通过解决特定问题来涵盖更一般的话题(例如变量的查找顺序)。
10.1 我如何检查一个对象类型以改进代码的灵活性?
我们总是与各种对象打交道,例如函数、类和实例。让我们以自定义函数为例。我们的大部分编码工作都涉及编写函数:定义输入、执行操作和提供输出。函数的输入通常具有特定的类型要求;相应地,用户必须使用一种特定类型的数据来调用函数。考虑以下函数,它根据我们在任务管理应用程序中的任务紧急程度来过滤任务列表(任务参数):
def filter_tasks(tasks, by_urgency):
pass
我们的第一反应可能是 by_urgency 参数应该是一个整数,例如 4 和 5 作为可能的参数。因此,函数可能有以下实现:
def filter_tasks(tasks, by_urgency):
filtered = [x for x in tasks if x.urgency == by_urgency]
return filtered
提醒:使用此功能,您需要创建任务类(第八章)并创建一些实例作为任务参数。
在函数体中,我们使用列表推导来选择与 by_urgency 参数提供的紧急程度匹配的任务。然而,完全有可能有一个允许用户使用这种方式过滤具有多个紧急程度的任务的特性:filter_tasks([4, 5])。为此特性,函数应该有以下的实现:
def filter_tasks(tasks, by_urgency):
filtered = [x for x in tasks if x.urgency in by_urgency]
return filtered
而不是比较整数值,现在我们使用列表中的项来检查任务的紧急程度是否在提供的紧急程度值中。
为了适应这两种情况,我们应该有一个机制来检查 by_urgency 参数并相应地过滤任务。这种检查对象类型的方式是对象 自省 的一个例子——检查对象以找出其特征,例如类型、属性和方法。在本节中,我们将回顾对象自省的关键技术及其使用场景,主要关注提高代码的灵活性。以 filter_tasks 函数作为我们的工作对象,我们将编写一个可以接受不同类型输入的单个函数。
概念 自省 是在程序执行过程中检查对象的类型或属性,如属性的行为。
10.1.1 使用 type 检查对象类型
在 10.1 节的代码示例中,为了在 filter_tasks 函数中处理整数或列表作为参数的灵活性,我们需要检查参数的类型。在本节中,我们将看到我们可以使用哪些内置函数来检查对象类型。
你可能首先想到的函数是 type。对一个对象调用 type 返回其类型,你已经在几个地方看到过这种用法。以下代码片段展示了几个例子,作为快速复习:
print(type(4))
# output: <class 'int'>
print(type([4, 5]))
# output: <class 'list'>
如预期,数字 4 的类型是 int,而 [4, 5] 的类型是 list。我们知道如何获取一个对象类型的信息,所以接下来要问的问题是,我们如何将对象的类型与期望的类型进行比较。如果你过度思考比较的问题,可能得不到答案,答案是:将对象的类型与类进行比较:
assert (type(4) is int)
assert (type([4, 5]) is list)
问题:当你比较两个对象时,== 和 is 是否相同?
基于这些比较,我们现在可以更新 filter_tasks 函数以处理两种调用场景,如下一列表所示。请注意,我们通过假设 by_urgency 参数只有两种可能性:整数和列表来简化条件。
列表 10.1 比较对象类型与类
def filter_tasks(tasks, by_urgency):
if type(by_urgency) is list:
filtered = [x for x in tasks if x.urgency in by_urgency]
else:
filtered = [x for x in tasks if x.urgency == by_urgency]
return filtered
如此列表所示,当 by_urgency 是一个列表时,我们检查列表中是否存在紧迫性,而当 by_urgency 是一个整数时,我们比较每个任务的紧迫级别与数字。
10.1.2 使用 isinstance 检查对象类型
另一个有用的自省函数是 isinstance,它检查一个对象是否是特定类的实例。正如你将在本节中看到的,isinstance 与 type 执行类似的工作,但它是检查对象类型的首选方法。
当你学习为函数创建适当的文档字符串(第 6.5 节)时,你使用了 isinstance 函数的帮助,但我没有扩展其用法的讨论。现在是我们正式学习我们可以使用 isinstance 做些什么的时候了:
assert isinstance(4, int)
assert isinstance([4, 5], list)
第一个参数是对象,第二个参数是特定的类。实际上,第二个参数也可以是类的元组,这允许你灵活地检查对象与多个类。观察这个特性:
passed_arg0 = [4, 5]
passed_arg1 = (4, 5)
assert isinstance(passed_arg0, (list, tuple))
assert isinstance(passed_arg1, (list, tuple))
如果你的函数接受列表或元组,例如,你可以在单个 isinstance 调用中组合测试,如前面的代码片段所示。请注意,这些类之间的关系相当于一个“或”评估:
assert isinstance([4, 5], list) or isinstance([4, 5], tuple)
使用 isinstance 函数,我们可以将 filter_tasks 函数更新为可以处理 by_urgency 作为整数或列表,如下所示。
列表 10.2 使用 isinstance 检查对象类型
def filter_tasks(tasks, by_urgency):
if isinstance(by_urgency, list):
filtered = [x for x in tasks if x.urgency in by_urgency]
else:
filtered = [x for x in tasks if x.urgency == by_urgency]
return filtered
当你比较列表 10.1 和 10.2 时,你可能注意到 type 和 isinstance 都确定对象是否为特定类型。但它们并不相同。
当我们使用 type 来确定对象的类型时,我们正在进行一对一的比较:对象的类型与指定的类型。相比之下,isinstance 更灵活,它是一对多的比较;它不仅检查类,还检查其超类。也就是说,isinstance 考虑了类继承,而 type 则没有。听起来很复杂?这里有一个通用示例:
class User:
pass
class Supervisor(User):
pass
supervisor = Supervisor()
comparisons = [
type(supervisor) is User,
type(supervisor) is Supervisor,
isinstance(supervisor, User),
isinstance(supervisor, Supervisor)
]
print(comparisons)
# output: [False, True, True, True]
从第一次和第二次比较中,你可以看出,当你使用 type 时,获得类型信息是针对直接类特定的:Supervisor。相比之下,尽管 supervisor 是 Supervisor 类的实例,而不是 User 类,但 isinstance 也使用了 Supervisor 是 User 子类的信息,即使你检查实例与超类 User,它也会返回 True。
这种灵活性很重要,即使我们的函数使用 isinstance 检查特定类型,例如 User,如果我们通过发送 Supervisor(一个名为 user 的参数)的实例来调用该函数,它仍然有效,因为这样的实例通过了 isinstance(user, User) 检查。
可维护性 为了提高类型检查的稳健性,你应该在检查对象类型时使用 isinstance,因为这个函数不仅考虑对象的直接类,还考虑类的子类。
10.1.3 以通用方式检查对象类型
在列表 10.1 和 10.2 中,我们假设传递的 by_urgency 参数是整数或列表。但如果另一个用户尝试以 filter_tasks(tasks, (4, 5)) 的方式调用 filter_tasks 函数,那么这就不太友好。也就是说,用户不是使用列表,而是使用元组对象来调用函数。正如你所看到的,为了使我们的函数更加灵活,仅检查参数类型与特定类型是相当受限的。在本节中,我们将看到如何更通用地获取对象类型信息。
我们知道在检查对象类型时,isinstance 优于 type。此外,我们可以在 isinstance 中指定多个类。因此,下一个列表显示了在 filter_tasks 函数中检查 by_urgency 对多个类的有效解决方案。
列表 10.3 使用 isinstance 检查对象类型与多个类
def filter_tasks(tasks, by_urgency):
if isinstance(by_urgency, (list, tuple)):
filtered = [x for x in tasks if x.urgency in by_urgency]
else:
filtered = [x for x in tasks if x.urgency == by_urgency]
return filtered
如您所预期的那样,更新后的 filter_tasks 函数可以处理 list 和 tuple 作为 by_urgency 参数。但用户也可能希望用 set 对象调用此函数:filter_tasks(tasks, {4, 5})。当前的实现无法处理此调用。理论上,我们可以将 set 添加到 isinstance 函数调用中。问题是,还有许多其他类似列表的数据类型,例如 pandas 库中的 Series,也可以用于 by_urgency。因此,考虑到您还可以定义自定义类,不可能逐一列出所有这些类型。我们应该有一种机制来通用地检查对象的类型。
在标准库中,collections.abc 模块定义了几个 抽象基类(其中 abc 的名称由此而来),可以用来测试一个特定类是否具有属性或方法,这在编程中被称为 接口。
概念 在面向对象编程(OOP)中,接口 代表实体(如类或包)定义的属性、函数、方法、类以及其他适用组件,开发者可以使用它们。
与当前主题相关的是 Collection 抽象类,它需要三个关键的特殊方法:contains(检查项目是否存在:item in obj),iter(可转换为迭代器:iter(obj)),以及 len(检查项目数量:len(obj))。list、tuple、set 以及许多其他类型的数据容器,包括 Series,都实现了这些方法,并且它们都是 Collection 的具体(而非抽象)类。因此,我们可以将 filter_tasks 函数更新为更通用的形式,以检查 by_urgency 参数的类型,如下所示。
列表 10.4 检查对象类型与抽象类
from collections.abc import Collection
def filter_tasks(tasks, by_urgency):
if isinstance(by_urgency, Collection):
filtered = [x for x in tasks if x.urgency in by_urgency]
else:
filtered = [x for x in tasks if x.urgency == by_urgency]
return filtered
通过使用抽象 Collection 类,我们可以适应所有类似集合的数据类型,而无需识别用户可能发送的类的多样性,这有助于提高我们代码的灵活性。
如您从这些部分中看到的那样,我们通过使用 type 和 isinstance 检查参数类型,逐渐提高了我们函数的灵活性,使用一种类型、多个确定类型以及通用类型。图 10.1 提供了这些用法的视觉总结。

图 10.1 使用 type 和 isinstance 检查对象的类型信息。cls、cls0 和 cls1 指的是特定的类,而 abs_cls 指的是一个可能代表无限多个使用该接口的类的抽象类。
10.1.4 讨论
检查对象的类型是对象自省的一个基本方面。有太多其他自省技术无法全面涵盖。作为开发者,当您使用一个您不熟悉的库时,您不必在网上查找信息,而是可以运行 dir(obj),这将返回对象的所有可用属性和方法。
collections.abc 模块还有许多其他的抽象基类。其中一个抽象类是 Sequence,list 是 Sequence 的具体类。另一个抽象类是 Iterable,它定义了 iter 接口。
10.1.5 挑战
在列表 5.1 中,我们定义了以下函数来检查一个对象是否是可迭代的:
def is_iterable(obj):
try:
_ = iter(obj)
except TypeError:
print(type(obj), "is not an iterable")
else:
print(type(obj), "is an iterable")
我们提到 Iterable 是 collections.abc 模块中的一个抽象类。你能利用 Iterable 类重写 is_iterable 函数吗?
提示:如果一个对象是可迭代的,其类必须实现了 iter 并具有 Iterable 类的相应接口。
10.2 实例对象的生存周期是什么?
当项目规模扩大时,你定义自己的自定义类。当你学习如何实现自定义类(第八章和第九章)时,你会遇到与自定义类实例创建相关的各种术语。理解这些实例的生存周期是一项基本技能,它使你,Python 开发者,能够正确地操作这些实例。
在本节中,我将通过具体示例回顾实例对象的关键事件。在这个过程中,你会看到描述基本编程概念的术语,这些术语是你与开发者有效沟通所必需的。其中一些术语在第八章中有介绍;我将在这里简要回顾它们,并将讨论置于对象的生存周期背景中。
10.2.1 实例化对象
实例对象的生存周期始于其创建,称为 *实例化**。本节回顾了实例化过程。
提醒:实例化 是创建特定类实例对象的过程。
对于一些内置数据类型,如 str 和 list,我们可以使用字面量来创建实例,例如 "Hello, World!" 用于 str 实例,[1, 2, 3] 用于 list 实例。除了用于创建内置数据类型的这些字面量之外,更普遍的情况是调用类的构造函数。考虑以下 Task 类(并注意我正在保持其实现尽可能简单,以便我可以专注于向您展示最相关的内容):
class Task:
def __new__(cls, *args):
new_task = object.__new__(cls)
print(f"__new__ is called, creating an instance at {id(new_task)}")
return new_task
def __init__(self, title):
self.title = title
print(f"__init__ is called, initializing an instance
➥ at {id(self)}")
在 Task 类中,除了 init 方法外,我们还实现了 new 方法。请注意,我们通常不会实现 new,因为在这个方法中我们不需要担心太多。在这里,在 new 和 init 中,我们添加了两个 print 函数调用,这样我们就可以看到每个函数何时被调用。更重要的是,打印的消息将告诉我们实例的内存地址(使用 id 函数),这样我们就可以为了跟踪目的知道对象的身份。有了这个类,让我们看看当我们创建实例对象时会发生什么:
task = Task("Laundry")
# output the following lines:
__new__ is called, creating an instance at 140557771534976 ❶
__init__ is called, initializing an instance at 140557771534976
print("task memory address:", id(task))
# output: task memory address: 140557771534976
❶ 在你的计算机上期望不同的内存地址。
当我们调用 Task 构造函数时,首先调用 new 方法,创建实例而不分配任何属性;在这个阶段,它是一个全新的对象,正如方法名所示。这一步的目的是在内存中为对象分配一个特定的槽位。这也是为什么我们可以获得实例的内存地址。
下一步是调用 init 方法,其中新创建的实例完成属性分配以完成初始化过程。正如相同的内存地址所示,我们在 new、init 和创建的任务变量中始终处理同一个对象。将这些观察结果综合起来,图 10.2 展示了实例化过程。

图 10.2 自定义类的实例化过程。在调用自定义类的构造函数之后,幕后,new 和 init 方法依次被调用,其中 new 创建新对象,init 完成初始化过程。最终,构建结果导致实例对象的创建。
10.2.2 在适用命名空间中保持活跃
你通过调用类构造函数来创建一个实例。接下来,你使用创建的实例。本节介绍了命名空间概念。你会发现创建的实例在适用命名空间中保持活跃,允许其被使用。
我们通过运行 task = Task("Laundry") 创建了 Task 类的实例对象,其中变量 task 代表实例对象。在代码的后续部分,我们可能想要检索任务的标题属性,如下所示:
title_output = f"Title: {task.title}"
当我们编写这一行代码时,我们隐含地假设任务变量指向我们定义的变量:Task 类的实例。然而,当 Python 尝试运行这一行代码时,它并不知道我们的假设;相反,它需要一个机制来定位任务变量,以便它可以创建 f-string。查找变量的机制涉及 命名空间,它跟踪已定义的变量。
概念作为一个字典,命名空间 跟踪其空间内已定义的变量。当你使用变量时,命名空间可以帮助定位变量的信息。
假设 Task 类在同一个 Python 文件中定义,并且创建了 task 实例,这形成了一个模块。在这个模块中,我们有一个 全局命名空间 跟踪所有变量,我们可以通过调用 globals 函数来检查这些变量:
print(globals())
# output the following data:
{'__name__': '__main__', '__doc__': None, '__package__': None,
➥ '__loader__': <class '_frozen_importlib.BuiltinImporter'>,
➥ '__spec__': None, '__annotations__': {}, '__builtins__':
➥ <module 'builtins' (built-in)>, 'Task': <class '__main__.Task'>,
➥ 'task': <__main__.Task object at 0x7fd6280af280>}
你可以将命名空间想象成字典,其中活动变量是键,相应的值(对象)是值。前面的例子突出了两个变量:Task 类和 task 实例。在我们定义类并创建实例之后,这两个对象都进入命名空间,并且无论何时我们使用这些变量,都可以找到它们。作为一个快速参考,以下身份比较显示'Task'和'task'的值确实是类和实例对象:
assert Task is globals()["Task"]
assert task is globals()["task"]
在创建实例之后,我们可以使用它,因为它可以通过查找已注册创建实例的全局命名空间来解析。
10.2.3 跟踪引用计数
当一个对象在命名空间中处于活动状态时,Python 会跟踪有多少其他对象持有对该对象的引用,以进行内存管理。这个重要的事件在幕后发生,许多现代面向对象的语言都有类似的功能。在本节中,我们将讨论跟踪引用计数的机制。
计算机有固定数量的内存。当我们的应用程序运行时,我们创建对象,这些对象会消耗内存。我们添加的对象越多,我们的应用程序使用的内存就越多。如果我们继续创建对象,我们的计算机可能会耗尽内存,导致我们的应用程序崩溃,甚至可能冻结计算机。因此,我们的应用程序应该有一种机制,在我们不再使用对象时从内存中删除对象。"引用计数"就是这样一种机制。
理解对象和变量之间的区别
要理解引用计数是如何工作的,我们首先需要理解对象和变量之间的区别。当我们运行 task = Task("Laundry")时,会发生两件不同的事情:
-
创建一个实例对象,创建实际对象及其相关数据存储在内存中。
-
对象通过变量 task 进行引用,使用标签来引用内存中底层的对象。
值得注意的是,对象和标签之间的关系可能会改变。在 Python 这样的动态类型语言中,我们可以将不同的对象赋给相同的标签;与标签关联的对象仍然存在于内存中,但现在标签引用了新的对象(图 10.3)。

图 10.3 对象和变量之间的关系。在赋值语句中,内存中创建了一个 Task 类的实例对象,并且这个对象与 task 变量相关联。后来,我们将 str 对象赋值给 task 变量。这种重新赋值破坏了 task 与实际 Task("Laundry")对象之间的先前关联,并创建了 task 与 str 对象之间新的关联。
如图 10.3 所示,我们通过将 task 赋值给 Task 类的实例来创建一个名为 task 的变量,这样变量 task 就引用了 Task 实例对象。当我们将相同的变量 task 赋值给另一个 str 对象时,task 不再引用 Task 实例对象;相反,它引用了 str 对象。
增加和减少引用计数
现在我们理解了对象和变量之间的区别,并且我们知道变量在内存中代表对底层对象的引用。这种对对象的引用从初始赋值语句开始计为 1。本节展示了我们如何改变引用计数。
在我们尝试改变一个对象的引用计数之前,我们应该找到一种方法来跟踪引用计数。在 Python 中,我们可以使用 sys 模块中的 getrefcount 函数:
import sys
task = Task("Laundry")
assert sys.getrefcount(task) == 2
上述示例中有两个对 Task 实例对象的引用。等等,不应该只有一个引用——赋值中的 task 变量吗?这是一个很棒的问题。答案是,在 getrefcount 函数调用中使用变量会创建对对象的另一个引用,使得当前的引用计数为 2。更普遍地说,在函数中使用变量会增加底层对象的引用计数。
我们知道如何跟踪一个对象的引用计数,并且我们可以进行一些实验来操纵对象的计数。为了增加这个计数,一个常见的方法是将变量包含在数据容器中,例如字典或列表对象:
work = {"to_do": task}
assert sys.getrefcount(task) == 3
tasks = [task]
assert sys.getrefcount(task) == 4
在这两种情况下,使用 task 在字典和列表对象中都会增加引用计数 1。我们已经看到了引用计数的增加,现在是时候看看我们如何可以减少计数了。常见的方法是使用 del 语句:
del tasks
assert sys.getrefcount(task) == 3
在移除任务后,我们移除对实例对象的引用;因此,引用计数减少 1。我们也可以删除工作来减少引用计数 1,但总是做同样的事情会显得很无聊。而不是删除字典对象,我们可以通过用不同的值替换 task 来操作工作对象,在这种情况下,我们也移除了对 Task 实例的引用:
work["to_do"] = "nothing"
assert sys.getrefcount(task) == 2
你可以看到 Python 是如何响应和即时地跟踪引用计数的。但引用计数最终会怎样呢?让我们继续探索实例对象的生命周期。
10.2.4 对象的销毁
10.2.3 节讨论了 Python 如何跟踪引用计数。关键是当一个对象的引用计数达到零时,Python 会销毁该对象,以便它占用的内存可以被系统释放以供使用。在本节中,我们将更详细地探讨销毁过程。
与构建过程一样,销毁过程通常通过 Python 中的自动引用计数来处理。为了更深入地了解销毁过程,我们可以覆盖 del,与对象销毁相关的特殊方法,如下面的列表所示。
列表 10.5 在类中覆盖 del
class Task:
def __init__(self, title):
print(f"__init__ is called, initializing an instance
➥ at {id(self)}")
self.title = title
def __del__(self):
print(f"__del__ is called, destructing an instance at {id(self)}")
使用这个更新的 Task 类,让我们编写一些代码来回顾初始化和全局命名空间过程:
task = Task("Homework")
# output: __init__ is called, initializing an instance at 140557504542416
assert "task" in globals()
为了手动将引用计数设置为 0 以触发销毁过程,我们可以使用 del 语句:
del task
# output: __del__ is called, destructing an instance at 140557504542416
assert "task" not in globals()
如您所见,对 task 调用 del 会调用 del 特殊方法。通过交叉检查内存地址,我们确实移除了我们创建的相同实例。值得注意的是,在销毁后,“task”也从命名空间中移除,我们不能再访问 task 变量。如果您坚持尝试,您将看到错误:
title_output = f"Title: {task.title}"
# ERROR: NameError: name 'task' is not defined. Did you mean: 'Task'?
10.2.5 讨论
本节讨论了对象生命周期中的主要事件,以自定义类的实例对象为例。将所有关键点综合起来,图 10.4 展示了对象生命周期的整体图景。

图 10.4 对象生命周期中的关键事件。对象从构造开始,在适用的命名空间中变得活跃。在其使用过程中,Python 跟踪其引用计数。当没有对象引用时,Python 销毁它,使其占用的内存再次可用。
与 Python 一起工作的好处是这些事件在很大程度上是自动的;Python 在幕后做繁重的工作。除非您正在构建内存密集型应用程序,否则您不需要担心这些底层事件。尽管如此,这些概念是面向对象编程的基础,如果您也在学习另一种面向对象编程语言,这些知识可以加速您的学习过程。
我还没有提到的一个关键模块是 gc,其名称代表 *垃圾回收**。此模块具有处理内存管理的先进算法,同时与引用计数机制一起工作。当发生循环引用时,引用计数无法销毁对象,例如。这种问题场景发生在两个或更多对象相互引用,并且它们的引用计数永远不会达到 0。感兴趣的读者可以探索 gc 模块,以了解这类问题(循环引用)是如何处理的。
10.2.6 挑战
作为一名 Python 初学者,詹姆斯特别感兴趣的是自定义类实例的引用计数是如何工作的。他有一个问题。假设他创建了一个实例变量,例如 task = Task("Homework"),并且他知道底层对象的引用计数为 1——task 变量。在函数中使用 task 变量是否会增加其引用计数?请编写一些代码来告诉他会发生什么。
提示:您可以在函数中包含 getrefcount 来检查参数的引用计数。
10.3 我如何复制一个对象?
当我们与一个对象一起工作时,我们可以修改其属性,但我们也可能想要保留其原始属性,以防我们需要取消修改。这种需求在许多应用中很常见。在我们的任务管理应用中,一个功能允许用户编辑现有的任务。在用户进行一些更改后,他们可以保存更新或取消编辑。在这个用例中,我们创建原始任务的副本,以便我们有新的副本来跟踪更新,并将原始副本作为备份。在本节中,你将学习正确复制对象的方法。
10.3.1 创建(浅)拷贝
在 Python 中,copy 模块为对象提供了与复制相关的功能。本节展示了如何进行复制。更确切地说,它讨论了创建浅拷贝而不是深拷贝;第 10.3.2 节区分了这两个过程。
假设我们已经为我们的应用创建了一个名为 Task 的以下类。为了简单起见,该类只实现了 init 和 repr:
class Task:
def __init__(self, title, desc):
self.title = title
self.desc = desc
def __repr__(self):
return f"Task({self.title!r}, {self.desc!r})"
def save_data(self):
# update the database
pass
在应用中,用户可以查看任务列表,并且如果他们想的话可以编辑特定的任务。例如,他们可能想要编辑以下实例的 Task:
task = Task("Homework", "Math and physics")
如果用户对编辑满意,则更新后的任务将被保存,如果用户取消编辑,则原始任务中的所有内容都将保持不变。因为 Task 类的实例有一个字典表示,创建副本的一个简单解决方案可能使用字典对象作为原始实例的“非正式”副本:
task_dict = task.__dict__
task_dict_copied = task_dict.copy()
print(task_dict_copied)
# output: {'title': 'Homework', 'desc': 'Math and physics'}
如此例所示,我们使用 dict 获取字典表示。对于这个字典对象,我们可以使用它的实例方法 copy 来创建一个副本。当用户编辑任务时,我们使用字典对象来跟踪更改。然而,这个解决方案有一个复杂因素:在字典对象更新后,我们必须将其还原为 Task 的实例,以便我们可以使用 Task 类实现的其他功能。否则,我们无法对字典对象做很多事情,因为我们无法访问与任务相关的功能,如 save_data。
我们可以直接使用 copy 模块中可用的功能来复制实例的字典表示,而不是复制其字典表示。以下代码片段显示了一个更好的解决方案,它创建了一个真正的实例副本:
from copy import copy
task_copied = copy(task)
print(task_copied)
# output: Task('Homework', 'Math and physics')
事实趣闻:函数 copy 与模块 copy 具有相同的名称。这个例子并不是唯一一个函数与它的模块具有相同名称的情况。例如,datetime 模块有一个名为 datetime 的函数,所以你有时会看到 from datetime import datetime。
我们从 copy 模块中导入 copy 函数,并将实例 task 发送到 copy 函数。打印输出显示,复制的变量 task_copied 与 task 具有相同的数据,并确认它是原始任务的副本。使用这个复制的任务,在用户进行编辑后,我们运行 task_copied.save_data()来更新我们的数据库。
10.3.2 注意浅拷贝的潜在问题
在 10.3.1 节的开始,我提到有两种复制方式:浅复制和深复制。复制函数正在创建一个浅复制。但浅复制和深复制是什么?在本节中,我将展示这些类型复制的区别,并讨论可能由浅复制引起的问题。
对于我们的任务管理应用程序,假设我们可以为每个任务有标签。为了满足这个需求,我们的 Task 类可能看起来像这样:
class Task:
def __init__(self, title, desc, tags = None):
self.title = title
self.desc = desc
self.tags = [] if tags is None else tags ❶
def __repr__(self):
return f"Task({self.title!r}, {self.desc!r}, {self.tags})"
def save_data(self):
pass
❶ 三元赋值
概念 A 三元表达式 根据逻辑条件进行评估,其格式为 value_when_true if condition else value_when_false。当你使用三元表达式来赋值时,这个过程被称为三元赋值。
使用这个更新的类,让我们创建一个实例,并在下一个列表中使用复制函数来制作一个副本。
列表 10.6 创建现有任务的副本
task = Task("Homework", "Math and physics", ["school", "urgent"])
task_copied = copy(task)
print(task_copied)
# output: Task('Homework', 'Math and physics', ['school', 'urgent'])
在应用程序中,用户开始更新任务。具体来说,用户给任务添加了另一个标签:
task_copied.tags.append("red")
print(task_copied)
# output: Task('Homework', 'Math and physics', ['school', 'urgent', 'red'])
如你所见,我们能够更新复制任务的标签。但用户决定取消这次编辑。在这种情况下,我们仍然使用原始任务的数据。因为我们没有修改原始任务,它的数据应该保持不变:
print(task)
# output: Task('Homework', 'Math and physics', ['school', 'urgent', 'red'])
我们确信原始任务有标签:['school', 'urgent'],但它为什么改变了?具体来说,它被更改为与复制任务中的列表对象匹配。这种情况不太可能是巧合,你应该怀疑。看起来 task 和 task_copied 对于标签有相同的列表对象。这个假设很容易测试:
assert task.tags is task_copied.tags
assert id(task.tags) == id(task_copied.tags)
使用 is 或 == 检查相等性
当我在 Python 中比较两个对象时,你可能注意到有时我使用 is,而有时我使用 。is 比较两个对象是否是同一个对象,因此它也被称为 身份测试。相比之下, 比较两个对象是否有相同的值。因为它们用于不同的比较(身份与值),所以它们应该以不同的方式使用。例如,在比较对象与 None 的常见用例中,你应该使用 is,尽管你可能见过人们使用 ==。None 是一个单例对象,这意味着只有一个对象在应用程序中持有 None。每次你使用 None 时,它都是从内存中访问的相同对象。因此,与 None 对象的比较应该使用 is,因为这种比较应该是一个身份测试。相同的身份测试旨在用于比较 task.tags 和 task_copied.tags。
另一方面,如果我们想比较两个列表对象的内存地址,我们应该使用 ==。每次我们调用对象的 id 函数时,它都会创建一个 int 对象来表示对象的内存地址。因此,调用 id 两次会创建两个不同的 int 对象,而我们只比较这两个 int 对象是否有相同的值。
如前例所示,两种等价比较(身份和内存地址)支持我们的假设,即 task_copied 的 tags 列表对象与 task 的相同。为什么会这样呢?这种意外的列表对象共享突出了浅拷贝和深拷贝之间的区别。在浅拷贝中,我们只复制最外层数据容器。在副本之间,我们共享包含的可变对象,如 tags 的列表对象。相比之下,在深拷贝中,我们不仅复制最外层容器,还递归地复制内部对象。两种类型的拷贝都让包含的不可变对象(如字符串和元组)保持不变,因为它们无论如何都无法操作这些对象。图 10.5 显示了深拷贝和浅拷贝之间的区别。

图 10.5 浅拷贝和深拷贝的区别。在浅拷贝中,最外层数据容器(或任何非容器对象,如字符串)及其不可变包含对象被复制,但不包括内部可变对象,如列表。相比之下,在深拷贝中,最外层容器及其所有内部对象都有独立的副本。灰色框表示内存中的对象。
在图 10.5 中,我们使用一个包含字符串"hello"和列表[3, 4, 5]的列表对象。当我们进行浅拷贝时,我们只复制最外层的列表对象。内部列表对象[3, 4, 5]和不可变的字符串对象"hello"在浅拷贝及其原始列表之间是共享的。相比之下,当我们进行深拷贝时,最外层容器及其可变项,即内部列表对象,为每个对象分别复制。
由于两种类型的副本处理内部可变对象的方式不同,如果你只进行浅拷贝,你可能会意外地覆盖原始对象中的数据。因此,如果你想创建两个独立的对象的真正副本,你应该创建一个深拷贝,如下一节所示。
10.3.3 创建深拷贝
既然我们已经知道了浅拷贝和深拷贝之间的区别,我们可以回顾一下我们应用程序的任务编辑功能。对于这个功能,我们希望原始任务和复制的任务是独立的,不共享任何内部可变对象——在我们的例子中是 tags 属性——这样我们就可以自由地更新可变属性 tags,而不会影响原始任务。基于浅拷贝和深拷贝之间的区别,这个功能要求我们创建一个深拷贝。
除了 copy 函数外,copy 模块还有一个 deepcopy 函数。该函数专门设计用来创建对象的深拷贝:
from copy import deepcopy
task = Task("Homework", "Math and physics", ["school", "urgent"])
task_deepcopied = deepcopy(task)
print(task_deepcopied)
# output: Task('Homework', 'Math and physics', ['school', 'urgent'])
在此代码中,我们使用 deepcopy 函数创建原始任务的副本。在这个阶段,我们不应该期望浅拷贝和深拷贝之间有差异,因为我们还没有操作内部可变对象。接下来,是时候看看深拷贝的有用性了:
task_deepcopied.tags.append("red")
print(task_deepcopied)
# output: Task('Homework', 'Math and physics', ['school', 'urgent', 'red'])
print(task)
# output: Task('Homework', 'Math and physics', ['school', 'urgent'])
在这个代码片段中,我们更新了深拷贝任务标签属性的数据库。值得注意的是,这个更改存在于 task_deepcopied 中,而不在 task 中——这是预期的行为,因为深拷贝会为每个内部对象创建一个独立的副本,包括可变列表对象 tags。
10.3.4 讨论
浅拷贝和深拷贝在复制内部可变对象时的行为不同,通常以数据容器形式存在,如列表、字典和集合。浅拷贝不会为这些内部数据容器创建副本,如果您不关心共享的内部对象,这可以节省内存。相比之下,当您期望创建具有独立数据的副本时,例如在编辑任务并希望保留其原始数据时,您应该使用深拷贝。
10.3.5 挑战
在示例中,我们使用 copy 模块中的 copy 和 deepcopy 函数。调用这些函数分别创建浅拷贝和深拷贝。值得注意的是,您可以在自定义类中覆盖两个特殊方法 copy 和 deepcopy,当您使用 copy 和 deepcopy 函数时,这些方法将被触发。在覆盖 copy 的情况下,假设我们更改了复制的任务的标题:“作业”->“复制的:作业”。我们还希望副本具有独特的 tags 属性副本,使其类似于深拷贝。你能实现这个功能吗?
提示:复制实例应该是实例特定的,所以 copy 应该是一个实例方法。在方法体中,你应该返回一个带有更新任务标题和新的 tags 列表对象的新实例。
10.4 如何访问和更改不同作用域中的变量?
第 10.2 节介绍了命名空间的概念。当我们在一个 Python 模块(一个.py 文件)中定义一个类,例如 Task 时,该类被注册在全局命名空间中,其形式为一个字典:标识符是键,相应的对象是值。假设在我们的任务管理应用程序中,我们有一个名为 task.py 的模块。这个文件包含了下一列表中显示的代码。
列表 10.7 尝试更改全局变量
db_filename = "N/A"
def set_database(db_name):
db_filename = db_name
set_database("tasks.sqlite")
print(db_filename)
# output: "N/A"
在列表 10.7 中,我们有变量 db_filename,它是我们任务管理应用程序的文件路径。通过调用 set_database,我们将 db_name 设置为 db_filename。然而,在打印输出中,db_filename 的值为“N/A”。这个结果是不预期的,因为我们认为我们已经更改了它。发生了什么?
在本节中,我将向您展示如何在这种情况下访问和更改变量。更普遍地说,这类问题涉及在不同作用域中操作变量,特别强调涉及两个关键字:global 和 nonlocal 的情况。通过这些示例,您将学习如何访问通过应用 LEGB 规则解析的变量。
10.4.1 访问任何变量:名称查找的 LEGB 规则
作用域和命名空间密切相关。作用域形成了命名空间的边界,而命名空间构成了作用域的内容。以 Python 模块为例,图 10.6 展示了命名空间和作用域之间的关系。

图 10.6 全局命名空间和全局作用域之间的关系。在一个模块中,全局命名空间以字典的形式跟踪所有变量,以及函数和类。全局命名空间位于全局作用域中,它定义了边界。
如图 10.6 所示,命名空间跟踪所有对象,每个对象在模块中都有自己的标识符。因此,我们可以将命名空间视为一个容器,其内部空间填充着不同的对象。作用域是容器的整个封装结构,定义了模块的边界。
从 Python 的角度解释代码时,当 Python 遇到一个变量,它试图解析该变量,这意味着它需要找到变量引用的对象。第 10.2.2 节提到,Python 在关联的作用域的命名空间中查找变量。存在不同级别的作用域,称为 LEGB 规则。
概念 LEGB 规则 指定了 Python 中解析变量的顺序,从局部(L),到封装(E),全局(G),再到内置(B)。
缩写词LEGB代表按规模递增的顺序的局部、封装、全局和内置作用域。一个模块形成一个全局作用域。在全局作用域之上,内置作用域包含所有内置函数和类的命名空间。在模块中,你可以定义一个类或一个函数,每个都形成一个局部作用域。
知识点听起来将模块的作用域称为全局可能有些奇怪。但如果你回想起模块内的函数创建了一个局部作用域,那么当作用域大于局部作用域时,将其称为全局作用域也就不足为奇了。这种逻辑可能有助于你记住这种区别。
但封装作用域又是如何呢?在我第 7.3 节介绍装饰器时,我在另一个函数内部嵌套了一个函数。对于内部函数,外部函数的局部作用域被称为封装作用域。图 10.7 展示了通过查找特定作用域来解析变量/函数(通常称为名称)的方式。

图 10.7 变量解析的示例。像 int 和 print 这样的函数是内置函数,它们通过查找内置作用域来解析。变量 number 和函数 outer_fun 在全局作用域中解析。变量 x 在 inner_fun 中使用,它在封装作用域中解析。number_str 和 x_str 在局部作用域中解析。
LEGB 规则按照顺序应用于变量解析。如图 10.8 所示,对于变量(或一般名称,或作为标识符的名称,它可以指代函数、列表甚至类),Python 首先搜索其局部作用域。如果名称被解析,则使用相应的值。如果没有,Python 继续搜索封装作用域。如果名称被解析,则使用该值——然后按顺序搜索全局和内置作用域。如果在 Python 检查了所有这些作用域之后,名称仍然无法解析,则会引发 NameError。

图 10.8 解析变量的通用过程:LEGB 规则。当 Python 遇到变量时,它会通过按顺序查找局部、封装(如果适用)、全局和内置作用域来尝试解析它。如果变量被解析,Python 使用该值;否则,它会引发 NameError。
10.4.2 在局部作用域中更改全局变量
在本节的开始,我提出了一个问题,即我们未能通过调用 set_database 函数来更改变量 db_filename。在 10.4.1 节中,您了解到 db_filename 代表一个全局变量,而 set_database 函数形成一个局部作用域。因此,这个问题被概括为在局部作用域中更改全局变量,这是本节的主题。
在我向您展示解决方案之前,请关注列表 10.7 中的代码的一部分。请注意,我正在调用 print 函数来向您展示函数局部作用域中可用的内容:
db_filename = "N/A"
def set_database(db_name):
db_filename = db_name
print(list(locals()))
对于第一个赋值语句(db_filename = "N/A"),我们在全局作用域中创建了一个名为 db_filename 的变量。然后,在接下来的几行中定义了 set_database 函数。如果我们检查全局命名空间,我们期望它包含 db_filename 和 set_database:
print(list(globals()))
# output: ['__name__', '__doc__', '__package__', '__loader__', '__spec__',
➥ '__annotations__', '__builtins__', 'db_filename', 'set_database']
在 set_database 函数的主体中,需要我们特别注意的代码是 db_filename = db_name,其目的是更新全局变量 db_filename。但列表 10.7 中的打印输出显示,它不起作用。
在我们找到解释之前,让我们观察另一件事。您可能已经注意到,我还包括了一行额外的代码:print(list(locals())),它生成了 set_database 函数局部作用域中注册的对象。当我们调用这个函数时,我们应该能够观察到局部命名空间的内容:
set_database("tasks.sqlite")
# output: ['db_name', 'db_filename']
set_database 函数的局部命名空间有两个变量:db_name 和 db_filename。当 Python 执行 db_filename = db_name 这一行代码时,LEGB 规则如何分别解决 db_filename 和 db_name?
变量 db_name 只存在于局部作用域中,并且它被解析为我们用于函数调用的参数。对于 db_filename,局部和全局作用域都有一个具有该名称的变量,但根据 LEGB 规则,局部作用域中的那个被使用。由于局部作用域中的那个没有注册的值,Python 将此行代码解释为创建新变量的赋值语句,而不是更新现有的全局变量。
现在我们知道了发生了什么,理解解决方案就更容易了:使用全局关键字来表示一个特定的变量是全局的而不是局部的,如下面的列表所示。
列表 10.8 成功改变全局变量
db_filename = "N/A"
def set_database(db_name):
global db_filename
db_filename = db_name
print(list(locals()))
set_database("tasks.sqlite")
# output: ['db_name']
print(db_filename)
# output: tasks.sqlite
在 set_database 函数的主体中,在赋值之前,我们表示 db_filename 是全局的,这样局部作用域就不会再次注册这个名称。接下来,我们执行赋值。Python 知道它正在更新全局作用域中的 db_filename。我们可以通过打印 db_filename 来观察更新的值(tasks.sqlite),它不再有初始值 "N/A"。
请注意,你只有在尝试在局部作用域中更改全局变量时才使用 global 关键字。如果你使用全局变量而不进行任何赋值或更新,你不需要使用 global,因为它将通过访问全局作用域来解决。
10.4.3 改变封闭变量
在第 10.4.2 节中,你学习了如何在局部作用域中使用 global 来改变全局变量。另一个关键字 nonlocal 用于改变局部作用域中的封闭变量。nonlocal 的使用频率低于 global,因为全局作用域无处不在,但封闭作用域仅存在于有嵌套函数的函数中。因此,我将简要介绍在本节中改变封闭变量的方法。为了帮助解释这个特性,我将使用以下列表中的简单代码示例。
列表 10.9 改变非局部变量
def change_text(using_nonlocal: bool):
text = "N/A"
def inner_fun0():
text = "No nonlocal"
def inner_fun1():
nonlocal text
text = "Using nonlocal"
inner_fun1() if using_nonlocal else inner_fun0()
return text
change_text(using_nonlocal=False)
# output: 'N/A'
change_text(using_nonlocal=True)
# output: 'Using nonlocal'
在 change_text 函数中,我们定义了一个局部变量 text。这两个内部函数形成它们自己的局部作用域;对于它们来说,change_text 函数的作用域是封闭作用域。这两个函数在是否通过使用 nonlocal 关键字将 text 声明为非局部变量方面有所不同。当你使用 nonlocal 关键字时,你是在告诉 Python 使用封闭作用域中的变量 text。
从打印输出中,我们可以看到调用内部函数 inner_fun1 成功地改变了非局部变量 text。然而,调用 inner_fun0 对非局部变量 text 没有影响,因为 Python 将 text = "No nonlocal" 解释为常规的赋值语句,而不是更新非局部变量。
10.4.4 讨论
第 10.4 节涵盖了 Python 如何通过遵循 LEGB 顺序(Local -> Enclosing -> Global -> Built-in)来解析变量、函数和类。当你编写涉及多个作用域的代码时,请记住哪些作用域预期解析特定的变量。由于 LEGB 顺序的复杂性,如果你需要在局部作用域中更新全局变量,请记住使用 global 关键字。不要犯愚蠢的错误,以为可以通过调用函数来更新,就像我们在列表 10.7 中尝试的那样。
10.4.5 挑战
约翰有 Swift 的编程背景,Swift 是用于创建 macOS 和 iOS 应用的语言。在 Swift 中,if...else... 语句可以形成一个独立于全局作用域的作用域。他如何在 Python 中找出 if...else... 语句是否有其局部作用域?
提示:创建一个全局变量,并在 if...else... 语句中尝试更改它。如果存在局部作用域,如果你不使用 global 关键字,你将无法更改其值。
10.5 什么是可调用性,它意味着什么?
作为一种面向对象的语言,Python 将其构建块(如包、模块、类、函数和数据)组织为不同类型的对象。因此,理解对象的特点对于编写更好的 Python 代码至关重要。在第 3.1 节中,当我们讨论在列表和元组之间进行选择时,我们讨论了可哈希性和可变性,分别指的是对象被哈希和修改的能力。
除了可哈希性和可变性之外,对象的一个关键特性是 可调用性——一个对象是否可以被调用。与大多数现代语言一样,我们在 Python 中通过使用一对括号(调用运算符)来调用对象。因此,如果一个对象可以用调用运算符使用,我们就说它是可调用的;如果一个对象不能用调用运算符使用,它就不是可调用的。实际上,Python 有一个内置函数,callable,可以检查对象的可调用性。我们知道我们可以调用一个函数,并且我们应该期望它是可调用的,如下所示:
def doubler(x):
return 2 * x
assert callable(doubler)
可调用性的概念似乎很简单,但可调用性是 Python 中几个关键特性的底层机制。本节回顾了对象可调用性的重要实际含义。
10.5.1 区分类和函数
我们可以调用一个类,例如 Task("Homework", "Math and physics"),来创建 Task 类的实例对象。我们也可以调用一个函数,例如 print("Hello, World!"),来执行一个定义的操作。因此,类和函数都是可调用的,相同的可调用性可能会使得区分类和函数变得困难。你可能经常听到人们说 Python 有很多有用的内置函数,比如 list、range 和 sum,但并非所有这些都是函数。可调用的第一个含义涉及到类和函数之间的细微差别。
“可调用”这个概念意味着一个可以被调用的对象。当一个函数期望一个可调用对象,例如 sorted 函数的 key 参数时,你可以传递一个函数或一个类。如果你有一个实现了 call 的自定义类,你也可以使用该类的实例作为可调用对象!
这些“函数”中的许多并不是函数。相反,它们是类,例如 bool、int 和 dict,与 callable 和 hash 这样的函数不同。它们之所以难以区分的主要原因是它们共享的可调用性,但从语义角度来看,这种差异是明显的。当我们调用这些类时,我们得到的是该类的实例对象,例如调用 bool 得到一个 bool 对象,调用 dict 返回一个 dict 对象。
逸事:这些内置类的名称都是小写,这与自定义类的驼峰命名法相反。将这些内置类型命名为小写是出于历史原因:在 Python 的早期版本中就是这样命名的。
相比之下,真实函数并不直接与任何底层类相关联。因此,通过调用这些函数,我们不会得到同名实例对象。例如,我们不会期望通过调用 sum 得到一个 sum 对象,或者通过调用 hash 得到一个 hash 对象。相反,我们可以通过调用 range 得到一个 range 对象,或者通过调用 slice 得到一个切片对象。
10.5.2 重新审视高阶函数映射
Python 函数式编程的一个表现是*高阶函数**:接受其他函数作为参数或返回函数作为输出的函数。第 7.2 节介绍了一个高阶函数 map,但它真的是一个函数吗?你的直觉可能会告诉你它是。然而,直觉可能会出错。我们将在本节中重新审视 map。
检查一个对象的最简单方法是用 print 函数调用它。我们期望一个自定义或内置函数是一个函数:
def do_something():
pass
print(do_something)
# output: <function do_something at 0x7fe8180f30a0>
print(sum)
# output: <built-in function sum>
如果 map 确实是一个函数,我们应该期望一个打印的消息告诉我们它是一个内置函数,例如 sum。让我们看看这是否是事实:
print(map)
# output: <class 'map'>
与你想象的可能不同,map 不是一个函数。相反,它是一个类:map 类。与 map 是一个类的事实一致,调用 map 创建了一个 map 对象,就像内置类 list 和 dict 一样:
print(map(int, ["1", "2.0", "3"]))
# output: <map object at 0x7fe8180df700>
认为 map 是一个函数的误解可能源于这样的假设,即类通常使用非函数对象进行实例构造。然而,不要忘记,Python 的所有函数都是对象。因此,map 类在构造时接受函数作为参数是特殊的。
10.5.3 使用可调用对象作为键参数
几个 Python 函数包括一个名为 key 的参数,当函数执行排序(如 sorted)或比较(如 max)时使用。在第 3.2 节中,列表的 sort 方法使用了一个函数作为 key;我们可能有一个假设,即我们只能为 key 参数使用函数。但正如本节所讨论的,任何可调用对象都可以作为 key 参数。
使用类而不是函数作为 sorted 的键参数的最简单场景是使用内置的 str 类。假设我们想要对一个扑克牌列表进行排序。如果没有设置键参数,由于无法比较整数和字符串,排序将失败:
cards = [10, 1, "J", "A"]
print(sorted(cards))
# ERROR: TypeError: '<' not supported between instances of 'str' and 'int'
print(sorted(cards, key=str))
# output: [1, 10, 'A', 'J']
因为使用了字符串作为键,所以可以进行排序,但顺序不正确:A 应该大于 J。让我们通过创建一个名为 PokerOrder 的类来解决这个问题,如下一列表所示。
列表 10.10 创建用于排序扑克牌的自定义类
class PokerOrder(int):
def __new__(cls, x):
numbers_mapping = {'J': 11, 'Q': 12, 'K': 13, 'A': 14}
casted_number = numbers_mapping.get(x, x)
return super().__new__(PokerOrder, casted_number)
提示:当我们尝试从一个字典对象中检索值时,如果键不存在,get 方法可以包含一个回退值。
在 PokerOrder 类中,我们重写了 __new__ 方法,这样我们就可以在构造 PokerOrder 的实例时(PokerOrder 是 int 的子类)修改默认行为。值得注意的是,如第 8.1 节所述,super() 创建了一个代理对象,它引用了超类 int,该超类期望接收一个数字(在我们的实现中是 casted_number)来构造一个实例。具体来说,如果牌在 2 到 10 之间,我们使用这个数字。如果牌是 J、Q、K 或 A,我们将其转换为相应的整数,这样类就可以将非数字牌映射到正确的数值。现在让我们对它们进行排序:
print(sorted(cards, key=PokerOrder))
# output: [1, 10, 'J', 'A']
10.5.4 将装饰器作为类创建
在第 7.3 节中,你学习了如何创建装饰器,装饰器是高阶函数,它修改被装饰的函数而不影响被装饰函数的预期操作。幕后,装饰过程将待装饰的函数发送到装饰器。也就是说,装饰过程本质上调用了一个高阶函数。因为类也是可调用的,这个特性允许我们以自定义类的形式创建装饰器,如本节所示。为了刷新你的记忆,以下代码片段显示了如何创建一个可以记录函数执行时间的装饰器:
import time
def logging_time(func):
def logger(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
print(f"Calling {func.__name__}: {time.time() - start:.5f}")
return result
return logger
请注意,我只为装饰器使用了最基本元素。如果你对装饰器不熟悉,请参考第 7.3 节以获取创建装饰器的最佳实践。要将此函数转换为类,请记住类构造函数期望接收一个函数作为其参数。我们可能有以下解决方案:
import time
class TimeLogger:
def __init__(self, func):
def logger(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
print(f"Calling {func.__name__}: {time.time() - start:.5f}")
return result
self._logger = logger
def __call__(self, *args, **kwargs):
return self._logger(*args, **kwargs)
注意这个代码片段中的两点:
-
受保护的属性
_logger用于内部存储创建的内部函数,正如我们所知,装饰过程正在创建一个闭包,这是一个内部函数。 -
我们重写了 call 特殊方法,当尝试调用类的实例时会被调用。也就是说,当我们调用装饰过的函数时,我们应该调用闭包,即 _logger 属性。请注意,通过在自定义类中实现 call,我们使类的实例可调用。因此,如图 10.9 所示,我们应该知道除了函数和类之外,实现了 call 的类的实例对象也是可调用的,就像 TimeLogger 类的情况一样。

图 10.9 三种可调用对象及其调用后的预期结果。你调用函数以获取它们的返回值。你调用类以获取实例对象。你调用一个可调用实例以获取 call 方法的返回结果。
使用这个类,我们可以用相同的语法来装饰一个函数:
@TimeLogger
def calculate_sum(n):
return sum(range(n))
result = calculate_sum(100_000)
# output: Calling calculate_sum: 0.00181
然而,请注意,装饰过的函数不再是一个函数。相反,它变成了 TimeLogger 类的一个实例对象:
print(calculate_sum)
# output: <__main__.TimeLogger object at 0x7fe8180de710>
默认情况下,我们不能调用一个实例对象。例如,我们不能写 1, 2, 3 或 "Hello, World!"()。为了使这个实例对象表现得像一个函数,我们重写了 call 特殊方法,它返回 _logger 属性——一个函数,因此是可调用的。换句话说,我们将实例对象的调用操作传递给它的函数属性 (_logger),使这个实例对象可调用。
10.5.5 讨论
本节重点介绍 Python 对象的可调用性——它们被调用操作符 () 调用的能力。本质上,类和函数都是可调用的,这创造了大量的交叉可能性,例如作为键参数和使用自定义类创建装饰器。特别是在后一种情况下,你可以实现接受参数的复杂装饰器。使用类使得提供这种灵活性变得更容易,因为你可以向实例对象添加其他属性。
10.5.6 挑战
Ruby 在她的项目中创建 TimeLogger 类作为装饰器来记录函数的性能。如第 7.3 节所述,实现装饰器的一个最佳实践是使用 functools 模块中的 wraps 装饰器。她应该如何在 TimeLogger 类中使用 wraps?
提示:在我们定义内部函数之前,我们包装了装饰过的函数。
摘要
-
我们可以使用内置的 type 函数来检查对象的类型。在程序运行时获取类型信息使我们能够编写灵活的代码。
-
isinstance 函数可以检查一个对象是否是某个类或类元组的实例。isinstance 比 type 更灵活,因为它如果检查的类有一个超类,也会给出有效结果。
-
collections.abc 模块允许我们检查对象的通用类型,以便对实现相同接口的多个类应用相同的操作。
-
类的实例对象会经历以下过程:实例化 -> 在命名空间中激活 -> 跟踪引用计数(与其在命名空间中的激活同时发生)-> 销毁。
-
当你复制一个对象时,默认的复制行为是仅复制最外层数据容器,这被称为浅拷贝。
-
当你需要复制包含的可变对象的独立副本时,你应该创建一个深拷贝,这样你就可以在不影响原始对象的情况下操作内部的可变对象。
-
内置的 copy 模块旨在以标准方式复制对象。但如果你想要为你的类定义自定义的复制行为,你可以覆盖 copy 和 deepcopy 方法。
-
当 Python 需要解析一个变量或名称时,它使用 LEGB 规则(局部 -> 封闭 -> 全局 -> 内置)来找到使用名称的值。
-
当你想要在局部作用域中更改一个变量,且该变量最初定义的位置不同时,你需要使用全局或非局部关键字,前者用于全局定义的变量,后者用于封装作用域中的变量,它仅存在于嵌套函数中。
-
类和函数都可以原生调用。尽管它们具有相同的可调用性,但你仍需要了解类和函数之间的区别,尤其是在内置函数方面。由于可调用性的共享,你可以在某些常见场景中使用类和函数,例如将它们用作键参数或创建装饰器。
11 处理文件
本章涵盖
-
文件的读写
-
处理表格数据文件
-
将数据保存为文件
-
管理计算机上的文件
-
访问文件元数据
文件对于任何计算机系统或应用程序都是至关重要的。我们使用文件来存储数据。我们通过使用文件与我们的队友共享数据。当我们从他人那里获取文件时,我们需要打开文件,读取其内容,处理数据,并将一些数据写入另一个文件或追加到同一文件中。这些操作与文件的内容有关。我们的应用程序可以使用数百种不同的 Python 对象,并且某些对象需要进行大量的计算或其他处理步骤,因此能够将这些对象保存为文件是非常理想的。当我们需要再次使用这些对象时,我们可以从文件中加载它们,这可以节省大量的处理时间。
文件在任何计算机系统中无处不在,我们的工作可能包括许多种文件操作,例如将文件移动到目的地、提取特定类型的文件,以及找出我们上周修改过的文件。充分了解以编程方式执行这些操作的知识使我们能够完成我们无法轻松手动完成的任务,并跟踪我们对文件所做的任何更改。在本章中,我们将涵盖与文件相关的重要主题——不仅从内容的角度进行读写,还包括常见的文件操作,如移动和复制文件。
11.1 如何使用上下文管理器读取和写入文件?
我们的项目可能涉及各种文件类型,例如表格数据、媒体和纯文本文件。当我们处理这些文件时,第一步是读取它们以处理包含的数据。虽然我们可以使用特殊软件来操作文件,但我们的项目通常需要我们以编程方式处理文件,尤其是在处理大量文件时。为了利用 pandas 等 Python 工具处理表格数据,我们也必须以编程方式读取文件。正如你可以想象的那样,以编程方式处理文件是通用数据处理的基本操作。在本节中,你将学习如何在 Python 中读取和写入文件。由于文本数据是最常见的格式,我们将使用文本文件作为示例。然而,这些一般技术也适用于其他文件格式,例如存储字节数据的二进制文件。
11.1.1 打开和关闭文件:上下文管理器
最基本的文件处理操作是打开和关闭文件。在本节中,我们将了解如何以两种方式打开和关闭文件:使用基本方法和使用上下文管理器(我们将在后面讨论)。
假设我们使用文本文件来存储我们的任务管理应用程序的数据。首先,我们可以创建一个名为 tasks.txt 的文本文件,其中包含以下数据:
1001,Homework,5
1002,Laundry,3
1003,Grocery,4
数据的每一行代表一个任务的信息:ID 号、标题和紧急程度。为了简单起见,我们有三行数据。我们可以使用内置的 open 函数打开此文件:
text_file = open("tasks.txt")
print(text_file)
# output: <_io.TextIOWrapper name='tasks.txt' mode='r' encoding='UTF-8'>
我们通过使用 print 函数检查 text_file 对象,并获取四条信息:
-
此对象是 _io.TextIOWrapper 类的实例,该类创建了一个提供对文件中底层文本数据的更高级别访问的缓冲文本流。这种对象也被称为 流 或 *文件对象**。
-
名称告诉你文件的名称。
-
模式指示文件是如何被读取的。'r' 表示读取模式,在这种模式下,我们只能读取文件。在读取模式下,你不能执行非读取操作,例如将数据写入文件。
-
编码指示文本文件是如何编码的。在大多数情况下,你不需要担心它,因为大多数数据都是使用 UTF-8 编码的(如果你听说过 ASCII 编码,它也具有与 ASCII 编码的向后兼容性),这是 Unicode 系统中最常见的编码形式。
使用创建的文件对象,我们可以读取数据。我们有不同的方式来读取数据(第 11.1.2 节),但最直接的方法是下一列表中显示的 read 方法。
列表 11.1 将数据作为字符串读取
text_data = text_file.read()
print(type(text_data)) ❶
# output: <class 'str'>
print(text_data)
# output the following lines:
1001,Homework,5
1002,Laundry,3
1003,Grocery,4
❶ 使用类型检查
读取方法将文件中的所有文本数据作为一个字符串读取,我们可以打印出这个字符串以确保它确实与文件中的文本匹配。我们可以对此字符串应用额外的处理步骤,例如将每一行分割以提取底层数据(第 2.3 节)。当我们完成处理时,我们可以使用 close 方法关闭文件。关闭文件后,我们可以通过访问 closed 属性来验证状态,它应该是 True:
text_file.close()
assert text_file.closed
当你完成文件操作后,你应该始终关闭文件。因为文件是计算机中的共享资源,如果你忘记关闭它们,你使用文件对象所做的任何更改可能会在实际文件中丢失。关闭文件后,所有对文件的更新都将保存,当其他进程访问文件时,它们将获得最新的数据。
为了防止我们因忘记关闭文件而丢失数据,我们可以使用 上下文管理 技术:with 语句,这是 Python 读取文件的方式,如下一列表所示。
列表 11.2 使用 with 打开文件
with open("tasks.txt") as file: ❶
print(f"file object: {file}")
data = file.read()
print(data)
# output the following lines:
file object: <_io.TextIOWrapper name='tasks.txt' mode='r' encoding='UTF-8'>
1001,Homework,5
1002,Laundry,3
1003,Grocery,4
❶ 使用 with 语句
使用 with 语句的语法是 with open("filepath") as file,这是头部,它创建文件对象并将其分配给变量 file。然后我们创建缩进来表示定义适用操作的主体。正如你在列表 11.2 中看到的,我们获得了与列表 11.1 相同的输出。使用 with 语句的最大优点是我们不再需要显式关闭文件。当 with 语句完成后,文件会自动关闭:
assert file.closed
文件自动关闭是由于使用了 with 语句,这被称为上下文管理技术。上下文管理器在 with 语句的头部建立与适用资源对象的连接,在主体中,你操作这个对象。当你完成主体并退出 with 语句时,上下文管理器会自动关闭与资源的连接。对于一个文件,管理器会释放文件对象。图 11.1 展示了上下文管理器是如何工作的,使用文件对象作为具体示例。

图 11.1 以文件管理为例的上下文管理器流程。with 语句由头部和主体组成。头部连接资源,主体使用资源。当你退出 with 语句时,上下文管理器会释放资源。
11.1.2 以不同方式从文件中读取数据
列表 11.1 显示了 read 方法,它获取整个文本数据。当文本文件很大时,加载所有数据可能需要相当长的时间,有时,计算机可能没有足够的内存来存储这么多数据。因此,我们必须根据具体用例使用其他方式来读取数据,正如我们将在本节中讨论的那样。
将行作为生成器读取
在 7.4 节中,你学习了生成器,它们是内存高效的数据提供者,因为它们在请求时逐个产生项目。文件对象代表数据流,我们可以将文件对象当作生成器使用,一次产生一行数据。
最常见的将文件作为生成器处理的方式是使用 for 循环逐行读取,这样我们就可以处理每一行数据,如下一个列表所示。为了给文件读取增添一些趣味,我包括了一些代码,将每一行转换为 Task 类的实例。
列表 11.3 以生成器读取文件
from collections import namedtuple
Task = namedtuple("Task", "task_id title urgency") ❶
with open("tasks.txt") as file:
for line in file:
stripped_line = line.strip() ❷
task_id, title, urgency = stripped_line.split(",") ❸
task = Task(task_id, title, urgency)
print(f"{stripped_line}: {task}")
# output the following lines:
1001,Homework,5: Task(task_id='1001', title='Homework', urgency='5')
1002,Laundry,3: Task(task_id='1002', title='Laundry', urgency='3')
1003,Grocery,4: Task(task_id='1003', title='Grocery', urgency='4')
❶ 创建一个命名元组类
❷ 移除尾随的换行符
❸ 使用逗号分割字符串
如你所见,我们在 for 循环中将文件用作迭代器,它产生每一行。请注意,每一行都结束于一个“不可见”的换行符,你应该使用 strip 来移除它。我们使用 split 元素来创建 Task 类的实例。
问题:如果你不删除换行符会发生什么?
将行读取以形成列表
如果文件数据不是太多,我们可以使用 readlines 方法读取行以形成一个列表对象。因为列表对象是可变的,这将更容易更改数据并保存以供其他用途。
假设我们想要从文本文件 tasks.txt 中提取所有数据作为一个列表对象,并且我们想要给每一行添加行号。这是期望的输出:
desired_output = [
'#1: 1001,Homework,5',
'#2: 1002,Laundry,3',
'#3: 1003,Grocery,4'
]
因为预期的输出是一个列表对象,我们可以利用 readlines 创建一个列表对象,这使我们能够由于其可变性来操作数据,如下一个列表所示。
列表 11.4 以列表形式读取行
with open("tasks.txt") as file:
lines = file.readlines()
updated_lines = [f"#{row}: {line.strip()}" for row, line
➥ in enumerate(lines, start=1)] ❶
assert desired_output == updated_lines
❶ enumerate 创建一个计数器。
我们使用 enumerate 函数(第 5.3.1 节)为迭代创建一个计数器,除了项目之外。使用列表推导(第 5.2.1 节),我们创建了一个列表对象 updated_lines,它与期望的列表对象 desired_output 相匹配。
读取单行
在更罕见的情况下,我们可能只想读取单行。例如,我们可能只想读取文件的标题以找到 CSV 文件的列。(有关处理 CSV 文件的更多信息,请参阅第 11.2 节。)虽然我们可以读取所有行并检索第一项,但读取这么多数据可能会很耗时。相反,我们可以使用 readline 方法读取单行文本,这比读取所有行要节省时间。
值得注意的是,我们可以多次使用 readline。文件对象跟踪每次读取结束的位置(就像生成器一样,文件对象知道项目在顺序中的位置),下一次我们调用 readline 时,它将从上次离开的地方继续读取,如下面的列表所示。
列表 11.5 读取单行
with open("tasks.txt") as file:
print(file.readline())
print(file.readline())
print(file.readline(5))
print(file.readline(8))
print(file.readline())
# output the following lines:
1001,Homework,5 ❶
1002,Laundry,3
1003,
Grocery,
4
❶ 由于换行符而打印以下空行
在列表 11.5 中注意以下三点:
-
readline 可选地接受一个大小参数,该参数读取该行中的字符数。例如,file.readline(5)读取 1003,而 file.readline(8)读取 Grocery,.
-
通过多次调用 readline 来获取单独的行。
-
行以换行符结束。当我们调用 readline 时,它会读取整行,包括换行符;因此,在打印消息中会有空行。
注意:与 readline 类似,read 和 readlines 都可以接受大小参数,该参数指定从文件中读取多少个字符。
11.1.3 以不同方式将数据写入文件
我们从文件中读取数据以处理存储的数据。当我们完成编辑或从另一个来源准备数据后,我们需要将数据写入文件以进行长期保存。本节以写入数据的使用场景为例进行描述。
将字符串数据写入新文件
在许多情况下,我们的数据已经准备好,并希望将其保存到新文件中。假设我们有以下数据:
data = """1001,Homework,5
1002,Laundry,3
1003,Grocery,4"""
要将此数据写入新文件,我们可以通过使用 with 语句创建一个文件对象。我们不是提前创建一个空文件,而是使用带有新文件路径的 open 函数,如以下列表所示,在指定路径创建新文件。
列表 11.6 写入新文件
with open("tasks_new.txt", "w") as file: ❶
print("File:", file)
result = file.write(data)
print("Writing result:", result)
# output the following lines:
File: <_io.TextIOWrapper name='tasks_new.txt' mode='w' encoding='UTF-8'>
Writing result: 45
❶ 指定写入模式
在 open 函数中,除了文件路径外,我们指定该文件对象的模式为"w",这意味着它是写入模式,而不是默认的读取模式。从打印消息中我们可以看到文件对象确实具有'w'模式。要将字符串数据写入新文件,我们调用 write 方法。调用此方法返回已写入的字符数——在我们的例子中是 45。
对于写入操作,需要为文件对象指定"w"模式。如果你使用默认的读取模式打开文件,你将无法写入任何数据,如下例所示:
with open("tasks_new.txt") as file: ❶
print("File:", file)
result = file.write(data)
print("Writing result:", result)
# ERROR: io.UnsupportedOperation: not writable
❶ 默认是读取模式。
写入新文件中的行列表
我们已经看到,我们可以以行的形式将数据作为列表对象从文件中读取。不出所料,我们也可以将行列表写入文件。涉及的方法是 writelines。正如你在写入字符串数据时所做的,你需要以写入模式打开一个文件,如下一列表所示。
列表 11.7 向文件写入列表
list_data = [
'1001,Homework,5',
'1002,Laundry,3',
'1003,Grocery,4'
]
with open("tasks_list_write.txt", "w") as file:
file.writelines(list_data) ❶
❶ writelines 返回 None。
如果你打开 tasks_list_write.txt 文件,你会注意到数据可能看起来不正确:
with open("tasks_list_write.txt") as file:
print(file.read())
# output: 1001,Homework,51002,Laundry,31003,Grocery,4
这种行为是预期的。writelines 按顺序写入数据,数据中的任何项都没有换行符,你不应该期望文件有多个行。因此,如果你想创建一个包含多行的文件,你需要给你的数据添加换行符,我将把这个任务留作挑战(第 11.1.5 节)。
到目前为止,我们已经看到了如何通过使用各种方法,包括 read、write、readline、readlines 和 writelines,以不同的方式读取和写入数据。为了帮助区分它们,图 11.2 展示了这些操作。

图 11.2 文件的关键读取和写入函数。当你有数据时,你可以使用 write 和 writelines 将数据写入文件。当你读取文件时,你可以通过调用 read、readline 和 readlines 来获取文本数据。这些函数有不同的用法。
如你可能注意到的(或者好奇的),读取和写入之间的操作几乎是对称的;唯一的例外是左侧没有 writeline。然而,不需要它。当你想要写入一行时,使用 write 方法。
将字符串数据追加到现有文件中
当你有新数据时,你想要将数据追加到现有文件中。假设你创建了一个新任务,其数据如下:
new_task = "1004,Museum,3"
你想要将此数据写入 tasks.txt 文件的末尾。而不是启用写入模式,你应该使用追加模式,如下一列表所示。
列表 11.8 向现有文件追加数据
with open("tasks.txt", "a") as file:
file.write(f"\n{new_task}") ❶
❶ 添加换行符
在 open 函数中指定"a"以追加模式打开文件;write 方法将数据添加到文件末尾。需要注意的是,new_task 前面有一个换行符(\n),这样你就可以将数据作为新行添加,而不是添加到文件的最后一行。
追加模式的底层机制是,当我们读取或写入时,我们使用光标来确定操作的位置。我已经提到,文件对象代表数据流,光标设置流中的位置。表 11.1 提供了有关模式和它们的光标位置的更多信息。
表 11.1 文件模式
| 模式¹ | read | write | create | truncate | 光标位置 |
|---|---|---|---|---|---|
| 模式¹ | read | write | create | truncate | 光标位置 |
| w | * | * | * | 开始 | |
| a | * | * | 结束 | ||
| r+ | * | * | 开始 | ||
| w+ | * | * | * | * | 开始 |
| a+ | * | * | * | 结束 | |
| x | * | 开始 |
(¹读取:读取数据;写入:写入新数据;创建:创建新文件;截断:调整文件大小;光标位置:操作开始时。)
当我们为文件设置模式"a"时,光标位于文件末尾,因此新添加的文本被附加到末尾。对于最常用的"r"和"w"模式,光标位于开始处,因此相应的读取和写入操作从开始处开始。
11.1.4 讨论
在读写文件时使用 with 语句是 Python 的方式。with 语句设计得更多的是用于上下文管理,而不是用于处理文件。更广泛地说,当我们处理共享资源,如数据库连接时,我们会使用上下文管理器。正如第 14.3 节所讨论的,当你与 SQLite 数据库一起工作时,你可以通过上下文管理下的连接执行数据库操作,如下所示:
import sqlite3
with con = sqlite3.connect("database.sqlite"):
# do your operations here
pass
11.1.5 挑战
在 Leo 作为电气工程师的日常工作中,他经常需要使用 Python 将数据写入文件。有一天,他尝试将列表对象写入一个新文件,就像我们在列表 11.7 中所做的那样。但他发现文件只有一行,而不是多行,每行代表列表对象的一个项。他如何更改列表对象,以便文件有多个行?
提示:你可以将换行符添加到每个项目。
11.2 我如何处理表格数据文件?
许多人使用 Microsoft Excel 来处理数据文件,这些数据被称为电子表格。更普遍地,电子表格被称为表格数据,它包括行和列。公司的销售数据可以保存为表格数据。学校可以记录考试成绩为表格数据。研究项目收集的数据可以存储为表格数据。正如你所看到的,表格数据具有通用性,因此处理表格数据是数据处理的基本技能。从一般的角度来看,你可以将表格数据转换为 CSV(逗号分隔值)文件,以促进不同系统之间的数据交换。本节重点介绍处理 CSV 文件——表格数据文件的代表性格式。
11.2.1 使用 csv reader 读取 CSV 文件
像往常一样,我们通过读取数据开始我们的数据处理工作。对于前端应用,我们需要在显示数据之前读取数据。假设我们的任务管理应用使用 CSV 文件来存储任务相关数据:文件 tasks.txt(第 11.1 节)。为了在我们的应用中显示这些任务,我们需要知道如何读取 CSV 文件,正如我们将在本节中讨论的。
虽然我在 11.1 节中没有指定,但 tasks.txt 文件是一个 CSV 文件。因此,我们已经学会了如何读取 CSV 文件。但是,我们必须自己拆分字符串来获取存储的数据,这在处理 CSV 文件时是一个常见的操作。不出所料,Python 的标准库为此提供了一个内置的解决方案:csv 模块,它允许我们直接使用 csv_reader 读取数据,如下面的列表所示。
列表 11.9 使用 csv 模块读取 CSV 文件
import csv ❶
with open("tasks.txt", newline="") as file:
csv_reader = csv.reader(file)
for row in csv_reader:
print(row)
# output the following lines: ❷
['1001', 'Homework', '5']
['1002', 'Laundry', '3']
['1003', 'Grocery', '4']
❶ 导入模块
❷ 如果你在 11.1 节中添加数据,你可能会看到另一行。
事实小贴士:官方 Python 文档建议将换行符指定为 "",以确保跨平台一致性。更多信息请参阅 docs.python.org/3/library/csv.html。
如列表 11.9 所示,我们通过调用带有文件对象的 reader 函数来创建 csv_reader。创建的 csv_reader 是一个迭代器,因此我们可以使用 for 循环遍历读取器。每个项目是一个由逗号分隔的值组成的列表对象——这与我们在列表 11.5 中获得的结果相同。但我们并没有重新发明轮子;我们使用了内置的 csv 模块!
提示:不要重新发明轮子。始终使用可用的解决方案,尤其是标准库提供的解决方案。
值得注意的是,我们知道列表构造函数可以接受一个可迭代对象来创建一个列表对象。因此,我们可以调用列表构造函数来检索所有行作为列表对象:
with open("tasks.txt", newline="") as file:
csv_reader = csv.reader(file)
tasks_rows = list(csv_reader)
print(tasks_rows)
# output the following line:
[['1001', 'Homework', '5'], ['1002', 'Laundry', '3'],
➥ ['1003', 'Grocery', '4']]
11.2.2 读取带有标题的 CSV 文件
在 tasks.txt 文件中,我们只有三个数据字段:ID 编号、标题和紧急程度。当你的文件有很多字段时,很难知道哪个字段保存了什么数据。因此,为了防止任何歧义,许多 CSV 文件使用标题来标记每个字段。在本节中,你将学习如何使用带有标题的 CSV 文件。假设我们向 tasks.txt 文件添加字段名,该文件包含以下数据:
task_id,title,urgency
1001,Homework,5
1002,Laundry,3
1003,Grocery,4
如你所见,第一行定义了映射到后续行中每个值的三个字段。当你有一个带有标题的 CSV 文件时,最佳做法是将每一行读取为字典对象,其中标题的字段名成为键,如下面的列表所示。
列表 11.10 使用 csv_reader 读取带有标题的 CSV 文件
with open("tasks.txt", newline="") as file:
csv_reader = csv.reader(file)
fields = next(csv_reader) ❶
print("Field:", fields)
for row in csv_reader:
task_dict = dict(zip(fields, row)) ❷
print(task_dict)
# output the following lines:
Field: ['task_id', 'title', 'urgency']
{'task_id': '1001', 'title': 'Homework', 'urgency': '5'}
{'task_id': '1002', 'title': 'Laundry', 'urgency': '3'}
{'task_id': '1003', 'title': 'Grocery', 'urgency': '4'}
❶ 获取下一个项目
❷ 创建一个字典对象
作为对之前介绍的一些技术的复习,以下是列表 11.10 的要点:
-
因为 csv_reader 是一个迭代器(第 5.1 节),我们可以调用 next 函数来获取第一行数据。
-
当我们消费迭代器的第一个项目时,迭代会继续到第二个项目。在 for 循环中,csv_reader 从第二行开始产生项目。
-
字典构造函数接受一个可迭代对象,其中每个元素有两个项目。我们使用 zip 函数通过连接字段和行来创建一个 zip 对象。输出显示我们获得了三个与 CSV 文件中的数据相对应的字典对象。
如你所注意到的,单独读取第一行并构建所需数据并不直观。但是,带标题的 CSV 文件如此常见,必须存在一个更简单的解决方案。确实,csv 模块提供了一个额外的 reader——DictReader,它专门针对这个需求,如下所示。
列表 11.11 使用 DictReader 读取带标题的 CSV 文件
with open("tasks.txt", newline="") as file:
csv_reader = csv.DictReader(file)
for row in csv_reader:
print(row)
# output the following lines:
{'task_id': '1001', 'title': 'Homework', 'urgency': '5'}
{'task_id': '1002', 'title': 'Laundry', 'urgency': '3'}
{'task_id': '1003', 'title': 'Grocery', 'urgency': '4'}
而不是调用 reader 函数,我们调用 DictReader 构造函数来创建一个 DictReader 对象,该对象以第一行作为键。正如你所看到的,列表 11.11 中的解决方案比列表 11.10 中的解决方案要简洁得多,这突出了如果你使用正确的技术,Python 代码的简洁性。作为学习过程中的一个旁注,如果你在日常工作中遇到常见问题,那么 Python 可能已经有了针对这些问题的解决方案,你只需要找到它们!根据我的经验,你可以在 Google 上用“Python + 你手头的任务”这个短语开始搜索。例如,如果你想用 Python 读取 PDF 文件,你可以搜索“Python read pdf files”。通常,搜索结果的前几页应该足以你找到潜在解决方案。
问题:由于 csv_reader 是一个迭代器,你如何检索所有数据作为一个由这些字典对象组成的列表?
11.2.3 向 CSV 文件写入数据
在我们处理完数据后,是时候将数据保存回 CSV 文件了。reader 和 DictReader 有对应的 writer:writer 和 DictWriter。正如你所想象的,writer 写入一个列表对象,而 DictWriter 写入一个字典对象。本节将展示如何进行。因为 writer 很简单,所以本节很短。
假设我们想要将行 1004,Museum,3 添加到 CSV 文件中。使用 writer,我们需要将这个字符串转换为列表对象:
new_task = "1004,Museum,3"
with open("tasks.txt", "a", newline="") as file:
file.write("\n")
csv_writer = csv.writer(file)
csv_writer.writerow(new_task.split(",")) ❶
❶ 使用 split 创建一个用于写入的列表
就像向常规文本文件写入数据一样,如果我们知道数据的最后一行不以换行符结束,我们应该添加一个换行符:file.write("\n")。
提示:文件模式应该是“a”——追加模式。如果你使用“w”,所有现有数据将被删除。
有时,数据以字典对象的形式进行处理。假设我们想要将以下数据保存到一个新的 CSV 文件中:
tasks = [
{'task_id': '1001', 'title': 'Homework', 'urgency': '5'},
{'task_id': '1002', 'title': 'Laundry', 'urgency': '3'},
{'task_id': '1003', 'title': 'Grocery', 'urgency': '4'}
]
数据是一个由多个字典对象组成的列表。在这种情况下,我们应该使用 DictWriter,如下所示。
列表 11.12 使用 DictWriter 向 CSV 写入数据
fields = ['task_id', 'title', 'urgency']
with open("tasks_dict.txt", "w", newline="") as file:
csv_writer = csv.DictWriter(file, fieldnames=fields)
csv_writer.writeheader() ❶
csv_writer.writerows(tasks) ❷
❶ 写入标题
❷ 写入多行
列表 11.12 中有三件事值得注意:
-
当我们创建 DictWriter 的实例时,我们需要通过设置 fieldnames 参数来指定字段名。
-
我们调用 writeheader 方法来写入标题。
-
因为我们有一个列表对象作为 dict 对象,我们可以通过调用 writerows 方法来写入整个数据集,而不是调用 writerow 方法,后者只写入一行。
到目前为止,我已经介绍了如何使用 CSV 文件读取和写入数据。你可能已经意识到,读取和写入数据涉及对称操作:读取器与写入器,以及 DictReader 与 DictWriter。图 11.3 提供了这些操作的视觉总结。如果你使用列表,你应该选择读取器和写入器。如果你使用字典,你应该选择 DictReader 和 DictWriter。另一个需要考虑的因素是 CSV 文件是否使用标题;如果使用,DictReader 和 DictWriter 的操作会更简单。

图 11.3 展示了使用 csv 模块的读取和写入操作。读取操作涉及读取器和 DictReader,前者将每一行读取为列表,后者将每一行读取为字典。写入操作涉及写入器和 DictWriter,前者将列表写入一行,后者将字典写入一行。
11.2.4 讨论
表格数据可以转换为 CSV 格式。使用内置的 csv 模块,我们可以方便地处理 CSV 数据,包括读取和写入数据。我们需要熟悉这些双向操作。值得注意的是,如果我们需要对 CSV 数据进行数值运算,我们需要探索第三方库,如 pandas,以获取高级处理功能。这些包可以通过简单的函数调用读取 CSV 文件。例如,我们可以调用 pandas.read_csv("filepath.csv")来从 CSV 文件创建一个 DataFrame(表格数据模型),这样我们就可以使用这个 DataFrame 进行各种操作。
11.2.5 挑战
利奥使用 CSV 文件存储他电气工程工作中的一些实验结果。在一个项目中,他使用 DictWriter 调用 writerows 方法来写入一个由多个 dict 对象组成的列表对象,如列表 11.12 所示。他如何使用这个方法与常规 CSV 写入器一起写入多个列表对象?
提示:你需要将你的数据组织成一个列表对象,其中每个项目代表一行数据:
tasks = [
['1001', 'Homework', '5'],
['1002', 'Laundry', '3'],
['1003', 'Grocery', '4']
]
11.3 如何使用序列化将数据保存为文件?
在我们程序的执行过程中,我们的代码会生成数百个对象。当数据科学家准备数据时,他们会执行多个处理步骤并创建大量数据。一些数据很大——几百兆字节甚至几吉字节——重新运行代码生成数据可能需要很长时间。将数据永久存储在计算机上的文件形式会更好。
在 11.2 节中,我们学习了如何将表格数据写入文件。但我们的数据可以以其他形式存在,例如 dict、list、tuple,以及类和函数。因此,我们应该有一个更通用的机制来保存数据。在本节中,你将了解序列化,它允许我们保存各种形式的 Python 数据。
11.3.1 使用序列化对象进行数据保存
术语 pickle 和 pickling 来自于使用醋、盐水或类似溶液保存食物的过程。在 Python 中,pickling 指的是将对象转换为二进制格式以进行数据保存的过程。当正常程序停止时,数据可能会丢失,这可能是我们不希望的。有些数据需要处理过多的时间,我们希望保存数据以便以后方便地检索。在本节中,我们将了解如何使用内置的 pickle 模块来保存数据。
概念 Pickling 是从现有对象创建二进制格式以进行数据保存的过程。
我们可以在 Python 中几乎保存任何对象。假设我们使用不同的数据形式在我们的任务管理应用程序中存储任务信息。让我们看看我们如何可以 pickling 这些对象:
import pickle ❶
task_tuple = (1001, "Homework", 5)
task_dict = {'task_id': '1002', 'title': 'Laundry', 'urgency': 3}
with open("task_tuple_saved.pickle", "wb") as file:
pickle.dump(task_tuple, file)
with open("task_dict_saved.pickle", "wb") as file:
pickle.dump(task_dict, file)
❶ 导入模块
在这个代码片段中,我们创建了一个元组和一个字典对象以进行 pickling。我想强调两个关键点。
-
dump 函数 将数据保存到文件中。当我们与文件一起工作时,我们使用 open 函数创建一个文件对象,以便我们与相关文件建立连接。
-
当我们打开文件时,我们应该使用 "wb" 模式。这种模式意味着我们正在进行写入操作,并且文件应该是二进制格式。相比之下,当我们处理文本文件时,我们不需要担心指定模式,因为我们使用默认模式:"t" 代表 "text"。
运行代码后,你的当前目录中有两个新文件:task_tuple_saved .pickle 和 task_dict_saved.pickle。如果你想要用文本编辑器打开它们,你将看不到任何有意义的内容。同样,当你尝试用文本编辑器打开一个图片时,你会看到一些可读内容与无意义的文本混合,这是因为二进制格式。你如何使用 pickle 文件中保存的数据?下一节将解释。
11.3.2 通过 unpickling 恢复数据
我们通过一个称为 pickling 的过程将对象保存为文件以进行保存。稍后,当我们再次需要数据时,我们从 pickle 文件中检索数据——与 pickling 相反的过程,称为 unpickling。在本节中,你将学习如何通过 unpickling 来恢复数据。
当我们讨论 JavaScript 对象表示法 (JSON) 数据序列化(第 9.3 节)时,我们使用了 dump 来创建 JSON 文件,它与 pickling 的 dump 函数具有相同的调用签名。当我们读取 JSON 文件时,我们使用了 load 函数。正如你所期望的,unpickling 也使用 load:
with open("task_tuple_saved.pickle", "rb") as file:
task_tuple_loaded = pickle.load(file)
with open("task_dict_saved.pickle", "rb") as file:
task_dict_loaded = pickle.load(file)
Unpickling 需要我们以读取模式打开文件。记住,pickle 文件是二进制的,因此我们需要使用 "rb" 作为打开模式。与返回 None 的 dump 函数不同,我们期望通过在文件对象上调用 load 函数来获取数据。因此,我们将返回值赋给一个变量。
为了检查数据保存的保真度,我们可以将 unpickled 数据与原始对象进行比较。恢复的对象与原始对象相等。这种保真度很重要,因为我们确信在 pickling 后可以重新创建原始数据:
assert task_tuple == task_tuple_loaded
assert task_dict == task_dict_loaded
我们是否也可以序列化自定义类的实例对象?答案是肯定的。考虑以下示例:
class Task:
def __init__(self, title, urgency):
self.title = title
self.urgency = urgency
task = Task("Laundry", 3)
with open("task_class_saved.pickle", "wb") as file:
pickle.dump(task, file)
with open("task_class_saved.pickle", "rb") as file:
task_class_loaded = pickle.load(file)
assert task.__dict__ == task_class_loaded.__dict__
assert task is not task_class_loaded
在此代码片段中,我们序列化和反序列化 Task 类的实例对象,原始对象和序列化/反序列化对象具有相同的属性,这通过它们字典表示形式的比较得到揭示。值得注意的是,它们不是同一个对象,如通过身份比较(is not)所示。
虽然我们可以序列化自定义类的实例,但我们应特别注意它们。原因是对于内置类,当你反序列化这些对象时,Python 知道如何反序列化它们,因为它们的类型是已知的。相比之下,如果你在反序列化时没有定义它们,Python 可能不知道你的自定义类。也就是说,如果你反序列化一个实例对象(在我们的例子中,是 Task 类的实例),当命名空间中没有 Task 类时,你会遇到错误。考虑以下示例:
del Task ❶
with open("task_class_saved.pickle", "rb") as file:
task_class_loaded = pickle.load(file)
# ERROR: AttributeError: Can't get attribute 'Task' on <module '__main__'
➥ (built-in)>
❶ 从全局命名空间中删除 Task
为了模拟在实例的类未定义时反序列化实例的情况,我们通过运行 del Task 来从全局命名空间中删除 Task 类。之后,我们无法获取自定义实例,因为它找不到 Task 类来进行实例化。
可维护性 当你反序列化自定义类的实例时,请确保你在相应的命名空间中已定义了该类。
当你学习关于 JSON 数据转换时,你了解到 dump 和 load 用于操作 JSON 文件,而 dumps 和 loads 用于处理 JSON 字符串。序列化有对应的同名函数:dump 和 load 用于 pickle 文件,而 dumps 和 loads 用于二进制形式的 pickle 字符串(称为字节;参见列表 11.13),如图 11.4 所示。

图 11.4 以字符串和文件形式展示的序列化和反序列化。在序列化中,你调用 dumps 来创建二进制字符串,调用 dump 来创建二进制文件。在反序列化中,你调用 loads 从二进制字符串创建对象,调用 load 从二进制文件创建对象。
之前示例主要关注 pickle 文件,你将在下一节看到一些与 pickle 相关的二进制字符串的示例。
11.3.3 序列化的利弊权衡
我们已经看到了序列化和反序列化在数据保存方面的作用。了解序列化的优缺点很重要。本节回顾了序列化最重要的方面,这将帮助我们确定序列化是否是我们项目中数据保存的正确选择。
与大多数对象兼容
作为另一种常见的存储和数据交换机制,JSON 与内置数据类型兼容,但除非我们提供特定的 JSON 序列化指令,例如通过在调用 dump 或 dumps 时设置默认参数(第 9.3 节),否则它不适用于自定义类。此外,JSON 无法原生处理所有对象,例如函数。相比之下,序列化与许多更多类型的对象直接兼容。为了了解序列化的灵活性,请观察下一个列表中保存简单函数的示例。
列表 11.13 序列化函数到字节
def doubler(x):
return x * 2
doubler_pickle = pickle.dumps(doubler)
print(doubler_pickle)
# output: b'\x80\x04\x95\x18\x00\x00\x00\x00\x00\x00\x00\x8c\x08
➥ __main__\x94\x8c\x07doubler\x94\x93\x94.'
如此代码所示,我们将函数 doubler 序列化为字节数据,它看起来像字符串,但以 b 开头表示它是一个字节对象。我们可以反序列化这个字节对象来重建函数,它应该与 doubler 执行相同的任务:
doubler_loaded = pickle.loads(doubler_pickle)
assert doubler_loaded(5) == doubler(5)
我们已经看到,序列化与自定义类兼容,无需任何特定指令(参见列表 11.14),这与需要特殊指令进行编码实例的 JSON 序列化不同(第 9.3 节)。但是,序列化并不是与 Python 中的每个对象都兼容。例如,我们不能序列化一个模块:
import os
os_dumped = pickle.dumps(os)
# ERROR: TypeError: cannot pickle 'module' object
此外,我们也不能将文件对象和数据库连接进行序列化,因为它们以动态方式使用资源,而序列化无法处理这种动态性。除了这些限制,序列化与大多数类型的对象兼容,作为一种灵活的数据保存机制。
数据安全
当我们处理任何数据时,我们可能首先没有考虑到的因素是数据安全。当我们获取文件时,我们应该考虑它们是否安全。同样的原则也适用于序列化文件;我们应该对序列化数据的安全性保持谨慎。
由于序列化允许我们保存几乎任何对象,黑客有机会在对象中嵌入恶意代码。在第 11.3.1 节和 11.3.2 节中,我们已经看到了序列化如何与内置数据类型(如元组和字典)一起工作。对于这些内置数据类型,你无法做太多。然而,如果有人创建了一个自定义类,他们可以定义自定义行为,这些行为可以黑客化序列化系统。考虑以下示例:
import os
class MaliciousTask:
def __init__(self, title, urgency):
self.title = title
self.urgency = urgency
def __reduce__(self):
print("__reduce__ is called")
return os.system, ('touch hacking.txt',) ❶
❶ 创建一个单元素元组
在这个代码片段中,有人定义了一个名为 MaliciousTask 的类。这个类实现了与序列化过程相关的特殊方法 reduce。如果运行返回值,会在你的计算机上创建 hacking.txt 文件。这个文件是空的,但它可以被编程包含会损害你的计算机系统的恶意代码!
如果你没有注意到这个恶意源代码,并试图反序列化这个类的实例,你的计算机可能会因为调用 reduce 时添加的文件而变得脆弱。下一个列表显示了这种效果。
列表 11.14 序列化自定义类的实例
malicious_task = MaliciousTask("Set fire", 5)
with open("test_malicious.pickle", "wb") as file:
pickle.dump(malicious_task, file)
# output: __reduce__ is called
注意,我包括了输出"reduce 被调用",以显示 reduce 在 pickle 过程中是涉及的。创建可能恶意文件的命令是 pickle 文件的一部分。当你反 pickle 这类文件时,会出现以下问题:
with open("test_malicious.pickle", "rb") as file:
pickle.load(file)
反 pickle 文件后,如果你检查你的目录,你会看到文件 hacking.txt 偷偷出现!真正的恶意代码不会留下如此明显的痕迹。因此,当你尝试 pickle 和 unpickle 对象时,你应该小心。一般来说,只 pickle 来自可信来源的对象,例如内置的,你自己创建的类,或者信誉良好的第三方包。
存储大小和速度
另一个优点是,与基于文本的存储(如 CSV 格式)相比,pickle 的存储空间更小,读写速度更快。我多次提到,pandas 是 Python 数据科学中最常用的包之一。它的核心数据模型被称为 DataFrame,它是一种表格数据结构。您可以将 DataFrame 对象保存为 CSV 文件或 pickle 文件。一般来说,使用 pickle 文件读写数据比使用 CSV 文件快得多,并且对于存储相同数量的数据,pickle 文件通常比 CSV 文件小。
11.3.4 讨论
Pickle 是一种方便的存储机制,与大多数 Python 对象兼容,包括自定义类。当然,使用 pickle 有优点也有缺点。一般来说,如果你从事与数据相关的项目,pickle 可以是一个很好的选择,因为它比 CSV 文件提供了更快的读写速度。作为提醒,请小心处理来自不可信来源的 pickle 数据的安全漏洞。
11.3.5 挑战
作为一家医院的网络安全分析师,Roger 评估了 Python 中 pickle 技术的安全性。他尝试将 MaliciousTask 类的实例(如列表 11.14 中所示)进行 pickle,向当前工作目录添加一个文件(hacking.txt)。他如何修改这个类,使其在 pickle 过程中删除 hacking.txt 文件?
提示:我们使用 touch hacking.txt 命令创建了这个文件。我们可以使用 rm hacking.txt 命令删除这个文件。别忘了放置这个命令的位置。
11.4 如何管理计算机上的文件?
无论你在做什么项目,你不可避免地会处理文件。毕竟,文件是存储组织化信息的最灵活的容器。在前面的章节中,你学习了从文件中读取数据以及将数据写入文件。但你还没有学习如何完全操作文件(不关心内容,而是文件本身),以及如何操作目录,例如通过移动和复制文件。
考虑以下使用场景。假设你正在进行一项科学实验,其中每个参与者完成一个反应时间测试。这个测试包括多个试验,测试运行后,软件会生成几个文件。因为我们用多个受试者进行实验,数据目录中有以下文件:
subject_123.config
subject_123.dat
subject_123.txt
subject_124.config
subject_124.dat
subject_124.txt
subject_125.config
subject_125.dat
subject_125.txt
我们关注特定类型的文件。具体来说,当我们完成数据收集后,我们如何提取仅包含这些数据文件(.dat)并将它们移动到新目录中?我们还想删除文本文件(.txt),因为我们不需要它们。
在本节中,我们将解决这些需求并介绍常见的文件处理技术。请注意,Python 是一种通用语言,在文件处理方面,可能会有多种解决方案,涉及不同的库,如 os 和 pathlib。我将专注于可通用的解决方案。
11.4.1 创建目录和文件
为了跟随整个章节,你将首先学习如何创建一个新的目录和一系列模拟文件。当你处理文件路径或目录路径时,如果你一直在使用 os 模块,我建议你使用 pathlib 模块;这是一个更紧凑的模块,专门用于处理路径。使用 pathlib,你可以轻松地创建一个新目录:
from pathlib import Path
data_folder = Path("data")
data_folder.mkdir() ❶
❶ 创建目录
pathlib 的核心数据模型是 Path,这是一个用于路径相关操作的类。例如,要使用 Path 对象创建目录,可以调用 mkdir 方法,这将在你的当前目录中创建数据文件夹。你可以通过调用 exists 来程序化地检查其存在性:
assert data_folder.exists()
当你准备好文件夹后,你可以创建一些模拟文件,你将在本节后面的内容中使用这些文件进行操作:
subject_ids = [123, 124, 125]
extensions = ["config", "dat", "txt"]
for subject_id in subject_ids:
for extension in extensions:
filename = f"subject_{subject_id}.{extension}"
filepath = data_folder / filename ❶
with open(filepath, "w") as file:
file.write(f"It's the file {filename}.")
❶ 创建文件路径
目前,你应该知道如何通过在 with 语句中使用 open 函数创建一个包含数据的文件(第 11.1 节)。需要注意的是,你通过使用操作目录 _path / 文件名来构造文件路径。你可能知道 Windows 和 macOS 使用不同的符号(反斜杠与正斜杠)来分隔目录中的层级:data\subject_123.dat 与 data/subject_123.dat。当你使用目录 _path / 文件名创建文件路径时,这个操作是操作系统无关的,这意味着相同的代码可以在这些平台上的任何一个上运行。如果你随意创建路径——比如说,data\subject_123.dat——你的代码可能在不同的系统上无法运行。这种跨平台兼容性是使用 pathlib 而不是 os 模块(其中你可能需要使用原始字符串作为路径)的另一个优点,因为 os 模块是平台相关的。
11.4.2 获取特定类型文件的列表
下一步是检索目录中的所有.dat 文件,以便我们可以对这些文件进行评分数据处理。为了检索特定类型的所有文件,我们在目录路径上调用 glob 方法,并指定一个文件名模式。所有匹配此模式的文件都可以找到,如下一个列表所示。
列表 11.15 检索相同类型的文件
data_folder = Path("data")
data_files = data_folder.glob("*.dat")
print("Data files:", data_files) ❶
for data_file in data_files:
print(f"Processing file: {data_file}")
# applicable data processing steps here
# output the following lines:
Data files: <generator object Path.glob at 0x100b5c040> ❷
Processing file: data/subject_124.dat
Processing file: data/subject_125.dat
Processing file: data/subject_123.dat
❶ 创建生成器对象
❷ 在你的计算机上预期不同的内存地址。
我们指定模式为*.dat,定位具有.dat 扩展名的文件。值得注意的是,匹配此模式的文件列表形成一个生成器,我们可以在 for 循环中使用它。从打印消息中,我们看到我们确实获得了所有.dat 文件。一个潜在的缺点是列表没有排序,这可能会使查看哪些文件已被处理变得困难。作为一个改进,我们可以对生成器进行排序以更好地组织文件:
data_files = data_folder.glob("*.dat") ❶
for data_file in sorted(data_files): ❷
print(f"Processing file: {data_file}")
# applicable data processing steps here
# output the following lines:
Processing file: data/subject_123.dat
Processing file: data/subject_124.dat
Processing file: data/subject_125.dat
❶ 重新创建生成器
❷ 对生成器进行排序以创建列表
提醒生成器是可消耗的。当你耗尽生成器中的项目时,你必须重新创建生成器,以便它产生其项目。
11.4.3 将文件移动到不同的文件夹
为了在科学实验中组织我们项目的数据,我们可以将参与者的数据放在他们自己的文件夹中。例如,对于 ID 号为 123 的参与者,我们希望所有他们的数据都位于 subject_123 文件夹中。在本节中,你将了解如何移动文件以满足这一需求。
当我们移动文件时,我们的想法是“重命名”文件的路径。也就是说,如果你将文件 data/subject_123.dat 重命名为 subjects/subject_123/subject_123.dat,它将从 data 文件夹移动到 subject_123 文件夹。利用这个知识,我们可以在列表 11.16 中找到解决方案。请注意,我们使用了 mkdir 方法,它允许我们在某些中间级别不存在的情况下创建多级目录。在下一个列表中,我们将 mkdir 调用中的 parents 参数设置为 True;它根据需要创建路径中缺失的任何中间级别。
列表 11.16 将文件移动到目标文件夹
subject_ids = [123, 124, 125]
data_folder = Path("data")
for subject_id in subject_ids:
subject_folder = Path(f"subjects/subject_{subject_id}")
subject_folder.mkdir(parents=True, exist_ok=True) ❶
for subject_file in data_folder.glob(f"*{subject_id}*"):
filename = subject_file.name ❷
target_path = subject_folder / filename ❸
_ = subject_file.rename(target_path)
print(f"Moving {filename} to {target_path}")
# output the following lines:
Moving subject_123.config to subjects/subject_123/subject_123.config
Moving subject_123.dat to subjects/subject_123/subject_123.dat
Moving subject_123.txt to subjects/subject_123/subject_123.txt
Moving subject_124.config to subjects/subject_124/subject_124.config
Moving subject_124.dat to subjects/subject_124/subject_124.dat
Moving subject_124.txt to subjects/subject_124/subject_124.txt
Moving subject_125.dat to subjects/subject_125/subject_125.dat
Moving subject_125.config to subjects/subject_125/subject_125.config
Moving subject_125.txt to subjects/subject_125/subject_125.txt
❶ 创建主题文件夹
❷ 获取文件名
❸ 构建目标路径
运行此代码后,我们应该看到当前目录中有一个新的文件夹 subjects,其中包含每个主题的三个文件夹。移动文件通常需要四个步骤(图 11.5):确定你要移动的文件,检索文件名,构建新的文件名,并使用新的文件名重命名文件。

图 11.5 移动文件的一般过程。本质上,你将文件从原始路径重命名为目标路径。
11.4.4 将文件复制到不同的文件夹
复制文件使我们能够保留原始文件并拥有第二个副本。假设我们不是将文件从 data 文件夹移动到 subjects 文件夹,而是复制数据。(您需要重新创建初始数据文件才能继续。)在这里,我介绍了 shutil 模块,它提供了一个高级应用程序编程接口(API)来操作文件。
此模块具有 copy 方法,其调用签名是 copy(src, dst),其中 src 表示源文件,dst 表示目标路径。使用此方法,我们可以将文件复制到每个受试者的文件夹中,如下一列表所示。
列表 11.17 复制文件到目标文件夹
import shutil
shutil.rmtree("subjects") ❶
subject_ids = [123, 124, 125]
data_folder = Path("data")
for subject_id in subject_ids:
subject_folder = Path(f"subjects/subject_{subject_id}")
subject_folder.mkdir(parents=True, exist_ok=True)
for subject_file in data_folder.glob(f"*{subject_id}*"):
filename = subject_file.name
target_path = subject_folder / filename
_ = shutil.copy(subject_file, target_path) ❷
print(f"Copying {filename} to {target_path}")
# output the following lines:
Copying subject_123.config to subjects/subject_123/subject_123.config
Copying subject_123.dat to subjects/subject_123/subject_123.dat
Copying subject_123.txt to subjects/subject_123/subject_123.txt
Copying subject_124.config to subjects/subject_124/subject_124.config
Copying subject_124.dat to subjects/subject_124/subject_124.dat
Copying subject_124.txt to subjects/subject_124/subject_124.txt
Copying subject_125.dat to subjects/subject_125/subject_125.dat
Copying subject_125.config to subjects/subject_125/subject_125.config
Copying subject_125.txt to subjects/subject_125/subject_125.txt
❶ 删除文件夹及其内容
❷ 当您不使用函数的返回值时,请使用下划线。
如列表 11.17 所示,我们使用 rmtree 函数删除文件夹及其内容,因为 rmtree 不关心目录的空旷。相比之下,如果我们使用 Path.rmdir 删除一个非空目录,我们可能会遇到问题。观察这个特性:
Path("subjects").rmdir()
# ERROR: OSError: [Errno 66] Directory not empty: 'subjects'
在列表 11.16 中,我们移动了文件。复制文件涉及相同的程序:识别文件,获取文件名,构建目标路径,并使用 shutil 模块的 copy 函数。
11.4.5 删除特定类型的文件
在 11.4 节的开头,我提到一个业务需求是删除 data 文件夹中的.txt 文件——特别是可能包含受试者隐私数据的单个数据文件——并且我们必须出于安全考虑删除原始文件。从一般的角度来看,我们需要删除特定类型的文件,正如我们将在本节中讨论的那样。
Path 类提供了 unlink 方法来删除文件。要使用此功能,我们需要获取 Path 对象的实例并在它们上调用 unlink:
data_folder = Path("data")
for file in data_folder.glob("*.txt"):
before = file.exists()
file.unlink()
after = file.exists()
print(f"Deleting {file}, existing? {before} -> {after}")
# output the following lines:
Deleting data/subject_123.txt, existing? True -> False
Deleting data/subject_124.txt, existing? True -> False
Deleting data/subject_125.txt, existing? True -> False
为了证明删除是有效的,我们在删除前后检查文件的存在。如您所见,每个文件在删除前都存在,在删除后就不见了。
11.4.6 讨论
当我们操作文件时,我们可以手动执行操作,但我们可能会失去对文件所做操作的跟踪。虽然我们可以写下每个操作,但记录所有操作既繁琐又麻烦。因此,为了使文件操作更具可重复性和可追踪性,我们应该编写代码来操作文件。
11.4.7 挑战
Cassi 使用 Python 管理她的电脑上的文件。她学到的一个教训是,当她将文件复制到不同的文件夹时,她不应该覆盖任何文件。也就是说,目标文件夹可能已经包含了她之前移动的相同文件。此外,这些文件可能已经被处理并包含新数据。她如何更新列表 11.17 中的代码,以便只有当这些文件在目标文件夹中不存在时才复制文件?
提示:您可以在 Path 实例对象上调用 exists 来确定文件是否存在。
11.5 如何检索文件元数据?
在 11.4 节中,你学习了如何在计算机上操作文件。对于移动和复制操作,我们通过访问 Path 对象的 name 属性来检索文件名。除了文件名外,文件还有可能在特定用例中很重要的元数据。我们需要检索文件的目录来构建另一个路径以访问同一目录中的另一个文件,例如。
假设我们继续处理 11.4 节中的实验数据。在数据文件夹中,我们需要处理那些数据文件(.dat)。但我们必须为每个受试者获取额外的配置文件(.config)。我们可以使用 glob 获取 .dat 文件的列表。但我们如何轻松地找到每个受试者的对应 .config 文件?本节将解答此问题以及其他与访问文件元数据相关的操作。
11.5.1 检索文件名相关信息
当我说“文件名相关信息”时,我指的是目录、文件名和文件扩展名。这些信息是 Path 类的属性。让我们通过一些代码示例来了解它们。
对于这个问题,我们从数据文件开始:subjects/subject_123/subject_123.dat。我们如何检索 subjects/subject_123/subject_123.config?这两个文件具有相同的目录和文件名,但具有不同的文件扩展名。观察这些特征,我们可以提出以下列表中所示的解决方案。
列表 11.18 检索文件名信息
from pathlib import Path
subjects_folder = Path("subjects")
for dat_path in subjects_folder.glob("**/*.dat"): ❶
subject_dir = dat_path.parent ❷
filename = dat_path.stem ❸
config_path = subject_dir / f"{filename}.config"
print(f"{subject_dir} & {filename} -> {config_path}")
dat_exists = dat_path.exists()
config_exists = config_path.exists()
with open(dat_path) as dat_file, open(config_path) as config_file: ❹
print(f"Process {filename}: dat? {dat_exists}, config?
➥ {config_exists}\n")
# process the subject's data
# output the following lines:
subjects/subject_125 & subject_125 -> subjects/subject_125/subject_125.config
Process subject_125: dat? True, config? True
subjects/subject_124 & subject_124 -> subjects/subject_124/subject_124.config
Process subject_124: dat? True, config? True
subjects/subject_123 & subject_123 -> subjects/subject_123/subject_123.config
Process subject_123: dat? True, config? True
❶ 检索所有数据文件
❷ 检索文件目录
❸ 检索文件名
❹ 打开两个文件
在列表 11.18 中,从打印信息中,我们看到我们通过访问 .dat 和 .config 文件来处理每个受试者的数据。以下四点值得关注:
-
因为 subjects_folder 中有文件夹,当你尝试访问这些子目录中的文件时,模式涉及 /,这意味着文件位于子目录中。
-
对于每个 Path 实例,我们可以访问其 parent 属性,它返回路径的目录。
-
对于每个 Path 实例,我们可以访问其 stem 属性,它返回路径中不带扩展名的文件名。
-
在 with 语句中,我们可以同时打开两个文件,创建两个可以同时处理的文件对象。
您可以通过访问 name(列表 11.17)来检索整个文件名,包括扩展名,也可以通过访问 suffix 来仅检索扩展名,如下所示(请注意,扩展名包括点符号):
dat_path = Path("subjects/subject/subject_123.dat")
assert dat_path.suffix == ".dat"
图 11.6 展示了哪些属性对应于文件名数据。

图 11.6 使用 Path 类的实例检索文件名相关数据。您可以访问其父目录(目录)、名称(包括扩展名的文件名)、主体(不带扩展名的文件名)和后缀(文件扩展名)。
11.5.2 检索文件的大小和时间信息
当你在电脑上使用文件资源管理器应用时,你可以看到除了名称之外的一些列,例如文件大小和文件最后更新的时间。这些元数据在特定场景中可能很有用。本节讨论了这些场景中的几个。
对于实验数据,如果数据记录正确,每个受试者的数据文件通常具有稳定的大小。因此,在不打开文件检查内容的情况下,我们可以检查文件的大小,以快速确定数据完整性,然后再应用任何处理程序。下面列出的函数解决了这个需求。
列表 11.19 创建一个筛选文件大小的函数
def process_data_using_size_cutoff(min_size, max_size):
data_folder = Path("data")
for dat_path in data_folder.glob("*.dat"):
filename = dat_path.name
size = dat_path.stat().st_size ❶
if min_size < size < max_size: ❷
print(f"{filename}, Good; {size}, within
➥ [{min_size}, {max_size}]")
else:
print(f"{filename}, Bad; {size}, outside
➥ [{min_size}, {max_size}]")
❶ 获取文件大小
❷ 连接比较
在这个代码片段中,我们调用 stat() 来获取文件的状态相关数据,其中 st_size 是以字节为单位的大小信息。使用这个函数,我们可以测试几个截止值的变体,以确定数据完整性:
process_data_using_size_cutoff(20, 40)
# output the following lines:
subject_124.dat, Good; 30, within [20, 40]
subject_125.dat, Good; 30, within [20, 40]
subject_123.dat, Good; 30, within [20, 40]
process_data_using_size_cutoff(40, 60)
# output the following lines:
subject_124.dat, Bad; 30, outside [40, 60]
subject_125.dat, Bad; 30, outside [40, 60]
subject_123.dat, Bad; 30, outside [40, 60]
如您所见,当我们要求范围在 20-40 之间时,所有文件都是好的,因为它们的大小都是 30。如果我们定义大小窗口为 40-60,所有文件都是坏的。
有时,我们根据文件的内容修改时间来筛选文件。要检索时间相关的元数据,我们可以在 Path 实例上调用 stat 方法:
import time
subject_dat_path = Path("data/subject_123.dat")
modified_time = subject_dat_path.stat().st_mtime ❶
readable_time = time.ctime(modified_time) ❷
print(f"Modification time: {modified_time} -> {readable_time}")
# output: Modification time: 1652123144.9999998 -> Mon May 9 14:05:44 2022❸
❶ 内容修改时间
❷ 转换为可读时间
❷ 预期不同的值。
在这个代码中,我们访问属性 st_mtime,这是文件内容修改的时间(不是文件名更改或其他元数据)。这个值代表自纪元以来的秒数:1970 年 1 月 1 日 00:00:00(UTC)。我们可以使用 time 模块中的 ctime 函数将这个值转换为可读的时间戳。
11.5.3 讨论
本节重点介绍了文件的目录、文件名、扩展名、大小和时间相关的元数据。请注意,然而,文件的元数据包含许多其他信息,例如文件的权限模式,尽管你的项目可能只需要本节中涵盖的元数据。当你考虑访问文件的元数据时,你应该知道你可以在 Path 类的实例上调用 stat 方法。
11.5.4 挑战
阿尔伯特是一名化学专业的硕士研究生。他喜欢使用 Python 以编程方式管理他的电脑。他如何编写一个函数来选择过去 24 小时内修改过的目录文件?
提示:使用 time 模块,你可以调用 time 来获取自纪元以来的秒数。你可以将文件的内容修改时间与这个值进行比较以进行 24 小时调整。记住,你需要计算 24 小时内的秒数。
概述
-
当你对文件执行读写操作时,使用 with 语句,它会自动关闭文件,使用上下文管理器。
-
默认打开模式是 "r"(读取)。执行任何写入操作都需要你使用 "w"(写入)或 "a"(追加),后者将数据追加到文件的末尾。
-
内置的 csv 模块专门用于读取和写入 CSV 数据。尽管这个主题不是本书的重点,如果你需要进行数值计算和数据处理,考虑使用第三方库,如 pandas。
-
当 CSV 文件有标题时,优先使用 csv.DictReader,它处理标题,而不是其他常见的数据读取器 csv.reader。
-
作为 csv.reader 和 csv.DictReader 的对应物,csv.writer 和 csv.DictWriter 用于创建 CSV 文件。后者在处理标题方面做得更好。
-
Pickling 是一种将 Python 对象存储为二进制数据的内置机制。与 JSON 相比,Pickling 更灵活,因为它支持更多的数据类型,包括函数。
-
在 Pickling 的数据安全性方面要谨慎。不要从可能不可信的来源中 Pickling 或 Unpickling 任何数据。
-
与使用 CSV 文件作为表格数据的存储机制不同,你可以使用 Pickling 来减小数据大小并提高读写速度。
-
内置的 pathlib 模块为它的 Path 类提供了各种方法和属性。你应该熟悉使用 pathlib 来执行文件管理,例如创建目录和移动文件。
-
一个文件不仅仅包含其内容,还包含其名称、目录、修改时间以及其他可能包含所需信息的元数据。你应该知道如何通过 Path 类检索这些数据。
第五部分:保护代码库
作为程序员,我们应该对我们的代码负责。承担责任意味着通过使其功能化,尽可能减少错误(最好是完全没有错误)来确保代码的质量。我们可以通过四种不同的方式来提高代码质量:
-
我们可以在程序执行过程中记录重要事件,这样就可以知道发生了什么,并在出现任何问题时快速提供解决方案。
-
我们可以将异常处理集成到我们的程序中,因为正确处理可能的异常可以防止程序崩溃。
-
我们应该在开发阶段调试我们的程序——这是移除错误的最佳时机,因为我们对代码的记忆最为新鲜。
-
我们应该彻底测试我们的程序,确保在产品交付之前每个部分都能正常工作。
在这部分,你将学习这四种编写健壮和可靠程序的方法。
12 日志记录和异常处理
本章涵盖
-
将日志记录到文件
-
正确格式化日志
-
处理异常
-
抛出异常
当我们将应用程序移入生产环境时,我们暂时“失去”了对产品的控制;我们必须依赖产品本身的行为。如果我们开发阶段非常小心,我们可能非常幸运地拥有一个完美的产品,没有任何错误。然而,这种情况几乎从未发生过。因此,我们应该知道,各种问题,如我们的 Web 应用程序的不寻常流量,都可能发生。如果出现任何问题,我们不要惊慌;我们开始解决问题的过程。
有时,我们没有机会与报告问题的用户交谈,即使我们有,他们提供的信息也可能相当有限,这并不能帮助我们识别潜在的问题。幸运的是,因为我们预计我们的产品可能会出现问题,我们的应用程序记录了用户的活动和相关应用程序事件,这使得我们能够研究事情可能出错的地方。这些日志记录在通过持续监控其性能使我们的产品平稳运行方面发挥着至关重要的作用。由于日志记录非常有用,我们应该在开发期间将其集成到我们的应用程序中。同时,由于用户输入,我们应该预料到会发生特定的异常。例如,有人试图获取一个除以零的结果,这会导致 ZeroDivisionError 异常;我们应该正确处理这个异常,以便应用程序可以继续运行。在本章中,我们研究日志记录和异常处理。
12.1 我如何使用日志记录来监控我的程序?
软件开发中最令人沮丧的事情可能是调试一个无法重现的问题。如果你足够幸运,你可能会有一些不太懂技术的最终用户的各种轶事描述。然而,这些描述可能没有意义,因为表面上相同的问题可能有多个根本原因。因此,常识告诉我们,在将应用程序交给最终用户之前,你应该正确设置日志记录来监控应用程序的性能。当用户在应用程序的特定模块中遇到任何问题时,你可以提取相关的日志信息,解决问题应该会花费更少的时间。本节介绍了 Python 中日志记录的基本功能。
12.1.1 创建 Logger 对象以记录应用程序事件
在 Python 中,一切皆对象,因此我们使用对象来记录应用程序事件并不令人惊讶。具体来说,Logger 对象为我们执行日志记录。在本节中,你将了解创建 Logger 对象的最佳实践。
在标准的 Python 库中,logging 模块提供了日志记录功能。此模块包含 Logger 类,该类的构造函数接受一个名称来创建一个实例对象:
import logging
logger_not_good = logging.Logger("task_app")
这段代码片段创建了一个 Logger 对象。但你可能想知道为什么我称这个日志记录器为 logger_not_good?在解释之前,先看看创建 Logger 对象的正确方法:
logger_good = logging.getLogger("task_app")
在这里,我们通过提供日志记录器的名称来调用 getLogger 函数。我们应该使用 getLogger 而不是调用构造函数的原因是我们想要一个共享的 Logger 类实例来处理日志记录。更具体地说,在一个应用程序或模块中,我们可能想在多个地方检索日志记录器。如果我们使用构造函数,最终会得到多个不同的日志记录器,就像这个例子中所示:
logger0 = logging.Logger("task_app")
logger1 = logging.Logger("task_app")
logger2 = logging.Logger("task_app")
assert logger0 is not logger1
assert logger1 is not logger2
assert logger0 is not logger2 ❶
❶ 你可以通过使用 AND 操作将这些比较组合成一个单独的比较。
你必须分别配置这些日志记录器(我在第 12.2 节中讨论了配置),确保它们有相同的配置,以便它们可以正常工作。没有理由你应该为同一个模块使用多个日志记录器;只有一个日志记录器应该完成这项工作。正如这个例子所示,使用 getLogger 确保我们总是检索到相同的日志记录器:
logger0_good = logging.getLogger("task_app")
logger1_good = logging.getLogger("task_app")
logger2_good = logging.getLogger("task_app")
assert logger0_good is logger1_good is logger2_good
使用 is 比较可以知道,无论你调用多少次 getLogger,日志记录器都是相同的。当它是同一个日志记录器时,你可以配置一次,在整个应用程序执行的生命周期中,它将以相同的方式表现。
作为最佳实践,如果你在应用程序中的每个模块创建模块级别的日志记录器,我建议你通过运行 logging.getLogger (__name__) 来创建日志记录器。__name__ 是一个用于模块名的特殊属性。例如,当你将模块命名为 taskier.py 时,该模块的 __name__ 属性是 taskier。
可维护性 总是使用 getLogger 来获取你的模块或应用程序的相同日志记录器。对于模块级别的日志记录器,最好使用 getLogger (__name__) 来获取日志记录器。
12.1.2 使用文件存储应用程序事件
在所有前面的章节中,我几乎总是使用 print 函数来显示特定代码片段执行期间的重要消息。假设我们想在任务管理应用程序中创建一个用户创建任务的日志。下面的列表显示了代码的简化版本。
列表 12.1 使用 print 创建日志
class Task:
def __init__(self, title):
self.title = title
def remove_from_db(self):
# operations to remove the task from the database
task_removed = True
return task_removed
task = Task("Laundry")
if task.remove_from_db():
print(f"removed the task {task.title} from the database")
我们可以在任务成功移除后打印一条消息。但这种方法只能在活跃的编码阶段工作,因为打印的消息会显示在 Python 控制台中。当你提交应用程序进行生产时,几乎不可能以连续的方式监控打印消息。因此,一个可持续的方法是使用永久介质存储应用程序事件:文件。在本节中,我将向你展示如何将事件发送到文件。
注意 当你在文件中存储事件时,你可以多次检查这些事件;因此,你的方法是可以持续的。相比之下,如果你使用 print 函数,事件将被发送到控制台,当控制台关闭时,你会丢失记录的信息。
我们可以将负责记录所有内容的记录器视为一个整体。因此,要记录文件中的事件,我们必须向记录器提供特定的配置,这通过设置 处理器 来实现。日志模块包含一个名为 FileHandler 的类;我们可以使用这个类来指定记录器应保存事件的文件,如下一列表所示。
列表 12.2 向记录器添加文件处理器
logger = logging.getLogger(__name__)
file_handler = logging.FileHandler("taskier.log") ❶
logger.addHandler(file_handler) ❷
❶ 指定文件处理器
❷ 将处理器添加到记录器
如列表 12.2 所示,我们指定所有记录都应发送到 taskier.log 文件,并通过调用 addHandler 方法将文件与记录器关联起来。值得注意的是,运行此代码后,您应该看到当前目录中存在 taskier.log 文件。现在记录器知道记录的保存位置,我们准备检查下一列表中日志的工作方式。
列表 12.3 将记录写入日志文件
task = Task("Laundry")
if task.remove_from_db():
logger.warning(f"removed the task {task.title} from the database")
在此代码片段中,我们通过调用 logger.warning 来写入警告记录。如果我们打开 taskier.log 文件,我们应该能够看到这条记录。
PEEK 每条日志消息都是一个日志记录,它是 LogRecord 类的实例。第 12.2.3 节讨论了日志记录的格式化。
如果您更喜欢程序化的方式来查看记录,请运行以下代码。您知道如何读取文本文件(第 11.1 节),对吧?请注意,我编写了一个函数来检查文件内容,因为我们将在以后多次检查日志文件,因此有一个这样的函数很有帮助:
def check_log_content(filename):
with open(filename) as file:
return file.read()
log_records = check_log_content("taskier.log")
print(log_records)
# output: removed the task Laundry from the database
提示:使用 with 语句打开文件,以便它可以自动关闭文件。
如您所见,我们读取了整个文件,内容与我们预期相符:一条关于从数据库中删除任务的记录。
12.1.3 向记录器添加多个处理器
在第 12.1.2 节中,我们看到了如何向记录器添加文件处理器,以便将日志记录发送到文件。记录器可以有多个处理器,我们将在本节中讨论这一点。
除了文件处理器之外,日志模块还提供了流处理器,可以在交互式控制台中记录记录。在软件开发过程中,我们可以使用文件来保存日志记录以供以后参考,但在此同时,我们可以向记录器添加一个流处理器,以便我们可以在控制台中查看记录,从而实现实时反馈,如下所示。这样,我们就不需要打开或读取日志来检索记录。
列表 12.4 使用记录器与流处理器
stream_handler = logging.StreamHandler()
logger.addHandler(stream_handler)
logger.warning("Just a random warning event.")
# output the following: Just a random warning event.
我们调用 StreamHandler 构造函数来创建一个流处理器并将其添加到记录器中。当我们向记录器发送警告日志记录时,这条消息会在控制台中打印出来。同时,我们可以检查相同的记录器是否也记录了我们之前添加的文件处理器中的消息:
log_records = check_log_content("taskier.log")
print(log_records)
# output the following lines:
removed the task Laundry from the database
Just a random warning event.
如您所见,日志文件记录了与流处理器相同的事件。请注意,日志文件中记录了我们之前输入的内容。
对于日志记录器,你可以设置多个文件处理器和流处理器。实际上,你可以为日志记录器设置多个文件处理器。假设你想要为备份目的创建两个重复的日志文件。你可以为每个日志文件设置两个文件处理器。此外,你可以为处理器设置不同的级别(如第 12.2.2 节所述),从而更精细地控制处理器,以捕获哪些类型的日志记录。
在大多数情况下,我们只需要使用流处理器和文件处理器。但在某些特定用例中,其他几种处理器可能很有用。尽管我不会详细讨论它们,因为它们不常使用,但了解它们的存在是好的(见 mng.bz/E0pD)。
如图 12.1 所示,我们可以将不同类型的处理器附加到日志记录器上。我已经介绍了流处理器和文件处理器。一些值得注意的处理器包括 SMTP 处理器,可以将日志记录作为电子邮件发送;HTTP 处理器,可以通过 HTTP GET 或 POST 请求将日志记录发送到 Web 服务器;以及队列处理器,可以将日志记录发送到队列,例如在另一个线程中的队列。

图 12.1 常见处理器可以附加到日志记录器上。当我们创建日志记录器时,我们可以实例化各种处理器并将它们附加到日志记录器上。这些处理器有其各自预定的用途。
12.1.4 讨论
我们应该使用文件来记录重要的应用程序事件,这样我们就可以定位到解决任何出现问题的必要信息。在开发阶段,将流处理器设置到日志记录器中会有所帮助,这样你就可以实时在控制台上查看日志记录。
12.1.5 挑战
约翰最近开始将日志记录集成到他的项目中。他知道他可以通过调用 logging.getLogger(name) 来检索模块使用的日志记录器。他运行了列表 12.2 中的代码,为日志记录器添加了一个文件处理器。如果他多次运行代码,日志记录器会有多个文件处理器,尽管这些文件处理器都指向同一个文件。当他记录任何事件时,文件会有重复的记录。他应该如何更新列表 12.2 中的代码,以便只添加一次文件处理器?如果他确实为日志记录器设置了多个处理器,他应该如何移除它们?
提示 1 日志记录器有一个名为 hasHandlers 的方法,你可以使用它来检查日志记录器是否有处理器。如果日志记录器没有任何处理器,你可以添加一个处理器。
提示 2 你可以将日志记录器的处理器保存为列表对象,并且你可以清空列表,这样处理器就会从日志记录器中移除。
12.2 我应该如何正确保存日志记录?
根据应用程序的大小,在一段较长的时间内,日志文件可能会积累许多记录,数量可能达到数千或数百万。检查这些记录以找到所需信息可能真的非常痛苦。为了演示目的,我在 12.1 节中使用了简单的消息作为日志记录。然而,对于一个任务管理应用程序,你可能会看到一些这样的记录:
-- app is starting
-- created a new task Laundry
-- removed the task from the database
-- successfully changed the tags for the task
-- updated the task's status to completed
-- FAILED to change the task's status!!!
如你所见,由于记录的格式化(前导两个短横线)最少,很难找到报告问题的潜在记录。幸运的是,我们可以对日志记录进行分类和格式化,以包含更多信息,使我们的调试体验不那么痛苦。在本节中,我将向你展示如何通过关注使用不同的日志级别来正确保存日志记录,并展示如何对日志记录进行格式化以提高可读性。
12.2.1 使用级别对应用程序事件进行分类
软件中并非所有问题都具有相同的优先级。有些问题需要立即修复,而有些则可以稍后处理。我们可以将相同的逻辑应用到我们的日志系统中。通过使用不同的日志级别,我们可以突出显示问题的紧迫性/重要性。在 12.3 节中,我们调用 logger.warning 来写入一个警告级别的记录。正如本节所讨论的,警告级别之上还有多个级别,你将学习如何文件处理程序和日志与级别一起工作。
在 Python 的日志模块中,我们有五个级别(DEBUG、INFO、WARNING、ERROR 和 CRITICAL)以及一个基本级别(NOTSET),其数值为 0,通常不使用。每个级别都有一个数值,数值越高,问题越严重。图 12.2 显示了这些级别以及关于每个级别应捕获哪些记录的一般指南。

图 12.2 不同用途的五个日志级别。有五个日志级别——DEBUG、INFO、WARNING、ERROR 和 CRITICAL,它们的严重性依次增加。
这五个级别在日志模块中被定义为整数常量;它们的数值从 10 到 50,以 10 为增量。如图 12.1 所示,这些级别旨在用于不同的目的,当你使用这些级别时,应遵守这些指南。但我还没有讨论如何使用这些级别。
这些级别的第一个用途是设置记录器的级别。除了文件处理程序属性外,记录器还有一个重要的属性,称为级别。当我们设置记录器的特定级别,如 INFO 时,所有 INFO 级别或更严重(意味着 WARNING、ERROR 和 CRITICAL)的日志记录都将被记录器捕获。让我们看看它是如何工作的:
logger = logging.getLogger(__name__)
logger.setLevel(logging.WARNING)
print(logger.level, logging._levelToName[logger.level]) ❶
# output: 30 WARNING
❶ 获取级别的名称
在这个代码片段中,我们将记录器设置为 WARNING 级别,当我们检查记录器的级别时,它确实是 WARNING。当记录器设置为 WARNING 级别时,我们期望只有警告、错误和关键消息会被记录器捕获。我们可以在下面的列表中观察到这种效果。
列表 12.5 在不同级别记录记录
def logging_messages_all_levels():
logger.critical("--Critical message")
logger.error("--Error message")
logger.warning("--Warning message")
logger.info("--Info message")
logger.debug("--Debug message")
logging_messages_all_levels()
log_records = check_log_content("taskier.log")
print(log_records)
# output the following lines:
removed the task Laundry from the database
Just a random warning event.
--Critical message
--Error message
--Warning message
如列表 12.5 所示,我们发送了五条消息,每条消息对应五个级别。从打印输出中,你可以看到 INFO 和 DEBUG 消息没有被记录在日志文件中,因为记录器被设置为 WARNING 级别。
如你可能已经注意到的,我们使用 logger.critical 发送关键消息,logger.error 发送错误消息,等等。了解这些方法很重要,因为我们可以创建不同级别的记录。级别设置直接决定了记录器将如何捕获记录。文件处理器也可以接受级别设置,如下一节所述。
12.2.2 设置处理器级别
级别的另一种用途是设置处理器的级别。当我们设置记录器的级别时,该级别适用于记录器级别,这并不总是期望的。记录器可以有多个处理器,我们可能需要将这些处理器应用于不同的级别,以便它们可以在指定的级别保存记录。本节讨论了这种用法。
让我们以文件处理器为例。假设我们的任务管理应用程序有两个日志文件,一个记录 WARNING 级别及以上的记录,另一个只记录 CRITICAL 记录。下一个列表显示了可能的实现。
列表 12.6 为单个文件处理器设置级别
logger.setLevel(logging.DEBUG) ❶
handler_warning = logging.FileHandler("taskier_warning.log")
handler_warning.setLevel(logging.WARNING) ❷
logger.addHandler(handler_warning)
handler_critical = logging.FileHandler("taskier_critical.log")
handler_critical.setLevel(logging.CRITICAL) ❸
logger.addHandler(handler_critical)
logging_messages_all_levels()
warning_log_records = check_log_content("taskier_warning.log")
print(warning_log_records)
# output the following lines:
--Critical message
--Error message
--Warning message
critical_log_records = check_log_content("taskier_critical.log")
print(critical_log_records)
# output the following line:
--Critical message
❶ 将记录器的级别设置为 DEBUG
❷ 在 WARNING 级别添加处理器
❸ 在 CRITICAL 级别添加处理器
如列表 12.6 所示,我们首先将记录器的级别设置为 DEBUG,这允许记录器捕获 DEBUG 级别或以上的任何消息。为了展示我们如何在处理器级别自定义级别,我在记录器中添加了两个文件处理器,一个在 WARNING 级别,另一个在 CRITICAL 级别。
在我们以所有级别记录多个消息之后,我们发现每个文件都捕获了其指定级别的记录。taskier_critical.log 文件只有一个 CRITICAL 记录,而 taskier_warning.log 文件有 WARNING、ERROR 和 CRITICAL 消息。
12.2.3 为处理器设置格式
在上一节中,你学习了如何初始化一个记录器,并使用文件处理器和所需的记录级别来配置记录器。另一个重要的配置是格式化记录。没有适当的格式化,很难定位问题。格式化记录记录的目标是突出每个记录中的关键信息,例如事件的时间和消息的级别。
尽管我们本可以继续配置一个文件处理器来进行格式化,但我们必须读取日志文件以检索日志记录,这对于教程目的来说有些不便。因此,我们将使用流处理器。流处理器将日志记录输出到交互式控制台,这使得查看结果更加容易(见以下列表)。
列表 12.7 为流处理器格式化日志记录
import logging
logger = logging.getLogger(__name__) ❶
logger.setLevel(logging.DEBUG) ❶
logger.handlers = [] ❷
formatter = logging.Formatter("%(asctime)s [%(levelname)s] -
➥ %(name)s - %(message)s") ❸
stream_handler = logging.StreamHandler()
stream_handler.setLevel(logging.DEBUG)
stream_handler.setFormatter(formatter) ❹
logger.addHandler(stream_handler)
def log_some_records():
logger.info("App is starting")
logger.error("Failed to save the task to the db")
logger.info("Created a task by the user")
logger.critical("Can't update the status of the task")
log_some_records()
# output the following lines:
2022-05-18 10:45:00,900 [INFO] - __main__ - App is starting
2022-05-18 10:45:00,907 [ERROR] - __main__ - Failed to save the
➥ task to the db
2022-05-18 10:45:00,912 [INFO] - __main__ - Created a task by the user
2022-05-18 10:45:00,917 [CRITICAL] - __main__ - Can't update the
➥ status of the task
❶ 获取记录器并设置级别
❷ 移除之前设置的处理器
❸ 创建一个格式化器
❹ 使用格式化器配置处理器
如列表 12.7 所示,logging 模块有一个 Formatter 类,我们可以用它来创建一个用于格式化的实例。请注意,格式化器使用%样式而不是 f-strings(第 2.1 节),这是根据类的要求。本质上,格式化器应包括事件记录的时间、记录的级别和消息。还包括模块的名称也很有用——在我们的例子中是 main 模块,因为我们是在交互式控制台中运行的。
从打印记录中可以看出,日志的可读性得到了显著提高。由于记录中包含了级别,我们更容易关注记录,例如 ERROR 和 CRITICAL。同时,我们还有事件的日期和时间戳,我们可以使用它们将事件与我们的应用程序之外的相关事件相关联。例如,如果我们看到午夜有很多错误,那是因为服务器在那个时间正在维护吗?
可读性 总是格式化日志记录,以便更容易定位相关问题。
12.2.4 讨论
到目前为止,你应该已经很好地理解了 Python 中日志记录的工作方式。图 12.3 展示了日志记录的一般工作流程。

图 12.3 日志记录的一般过程。第一步是通过调用 getLogger 获取记录器。然后(可选)我们可以设置记录器的级别。要记录文件中的记录,我们应该向记录器添加一个文件处理器。我们可以调用相应的方法来记录特定级别的消息。
我们应该清楚五个级别的含义,并按预期的方式使用它们。例如,如果某些功能对于软件的正常执行至关重要,那么当它们出错时,你应该将它们记录为 CRITICAL。因为记录器只能记录等于或高于设置级别的消息,如果我们想有更全面的日志记录,那么将记录器的级别设置为 INFO 或 DEBUG 非常重要,这样就可以捕获更多记录。
12.2.5 挑战
约翰是一个新项目中的事件日志记录员。他已经意识到他可以同时设置记录器和处理器的级别。假设记录器的级别是 WARNING,而处理器的级别是 DEBUG。如果他调用 logger.info("It's an info message.")会发生什么?处理器会捕获这个记录吗?
提示 在记录器将消息发送到处理器之前,会检查消息是否与记录器的级别匹配。
12.3 我该如何处理异常?
在第 2.2 节中讨论如何将字符串转换为获取其底层数据时,你了解到一些字符串代表数字(例如 "1" 和 "2"),并且我们可以使用这些字符串调用 int 构造函数来获取这些整数值。假设我们的任务管理应用有一个处理字符串数据的函数,它代表存储在文本文件中的任务的一行数据。为了简单起见,假设一个任务只有标题和紧急程度作为其属性:
from collections import namedtuple
Task = namedtuple("Task", ["title", "urgency"]) ❶
task_text0 = "Laundry,3"
def process_task_string0(text):
title, urgency_str = text.split(",") ❷
urgency = int(urgency_str)
task = Task(title, urgency)
return task
processed_task0 = process_task_string0(task_text0)
assert processed_task0 == Task(title='Laundry', urgency=3)
❶ 创建一个命名元组类
❷ 解包创建的列表对象
在这个代码片段中,我们定义了 process_task_string0 来处理文本数据并创建 Task 类的实例。一切看起来都很正常。但如果文本被损坏,比如 Laundry,3#,会发生什么呢?让我们试试看:
task_text1 = "Laudry,3#"
processed_task1 = process_task_string0(task_text1)
# ERROR: ValueError: invalid literal for int() with base 10: '3#'
我们不能通过调用 int("3#") 将 3# 转换为有效的整数,这会导致 ValueError 异常。
在许多情况下,我们不能假设事情会像我们预期的那样进行,尤其是在处理需要特定输入才能工作的代码块时。例如,int 构造函数需要一个整数或表示整数值的字符串。在这种情况下,我们应该在开发阶段处理潜在的 ValueError 异常,以防止错误在运行时停止我们的应用程序。本节讨论了 Python 中异常处理的关键方面。
12.3.1 使用 try...except... 处理异常
当发生 ValueError 等异常时,你的应用程序会停止运行(除非本节讨论的异常被处理)。这种现象——当软件突然停止执行时——通常被称为 崩溃。软件可以通过不同的方式崩溃,其中一些是软件本身无法控制的,例如当计算机内存不足时。当我们预期运行一段代码可能会引发特定的异常时,例如,我们应该通过正确处理异常来考虑这种可能性,以防止应用程序崩溃。在本节中,我们将看到异常处理的基本代码块。
异常 或 错误 是所有编程语言中的一个通用概念。在 Python 中处理异常的标准方式是使用 try...except... 块。许多其他语言使用 try...catch... 块。图 12.4 显示了 try...except... 语句的一般工作流程。

图 12.4 try...except 语句的工作流程。我们将可能引发异常的代码包含在 try 子句中。当引发此类异常时,except 子句将被执行,执行将移动到语句外的代码。如果执行 try 子句中的代码时没有引发异常,Python 将跳过 except 子句。
如图 12.4 所示,Python 试图执行 try 子句中的代码。如果一切顺利,它将跳过 except 子句并继续执行 try...except 语句之外的代码。如果发生异常,except 子句将被执行,并且 try 子句中引发异常之后的任何代码也将被跳过。下一个列表显示了 try...except...是如何工作的示例。
列表 12.8 在函数中使用 try...except
def process_task_string1(text):
title, urgency_str = text.split(",")
try:
urgency = int(urgency_str)
except:
print("Couldn't cast the number")
return None
task = Task(title, urgency)
return task
注意:不要使用裸 except 语句。参见 12.3.2 节。
在列表 12.8 中,process_task_string1 函数包含了 try...except...语句。具体来说,在 try 子句中,我们包含了可能引发异常的代码——在这种情况下,将 urgency_str 转换为整数。作为一个重要的注意事项,我们不希望 try 子句中包含大量代码,因为这会使我们难以知道哪些代码可能导致异常。
可读性:try 子句应仅包含可能引发异常的代码。
为了简单和演示目的,except 子句中包含了调用 print 函数。重要的是要知道,只有当捕获到异常时,except 子句才会被执行。我们可以在以下代码片段中观察到这种效果:
processed_task1 = process_task_string1(task_text1)
# output: Couldn't cast the number
assert processed_task1 is None
我们在 except 子句中返回 None,并且可以通过比较 processed_task1 与 None 来验证它。如果 try 子句的执行没有引发任何异常,except 子句将被跳过,并且 try...except...语句之外的代码将继续执行:
processed_task0 = process_task_string1(task_text0)
assert processed_task0 == Task(title='Laundry', urgency=3)
问题:你认为在这个例子中,使用自定义类对象而不是像 Task 这样的命名元组类进行比较是否可行?
如你所见,当 task_text0 包含构建 Task 类实例所需的数据时,一切都会像 process_task_string0 函数中那样正常工作,就像 try...except...语句在 process_task_string1 中不存在一样!
12.3.2 在 except 子句中指定异常
在列表 12.8 中,except 子句通过关键字本身使用裸 except。然而,我不推荐这种用法。相反,except 子句允许我们指定在子句中处理的异常。正如本节所述,我们应该明确指出要捕获的异常。
指定异常是必要的;否则,裸 except 子句将捕获所有异常,甚至是你不期望的异常。假设我们有一个待处理的任务,在将紧急程度转换为级别后需要更新:
def process_task_string2(text):
title, urgency_str = text.split(",")
try:
urgency = int(urgency_str)
pending_task.urgency = urgency
except:
print("Couldn't cast the number")
return None
task = Task(title, urgency)
return task
注意:我们通常尽量减少 try 子句中的代码。我为了教学目的添加了一行会导致异常的额外代码,以说明我们可能需要处理多个异常。
前面的 try 子句中有一行额外的代码:pending_task.urgency = urgency。你可能已经意识到,这段代码会导致 NameError 异常,因为我们从未定义过具有此名称的变量,它也不在任何命名空间中可用。在以下代码片段中观察这一效果:
pending_task.urgency = 3
# ERROR: NameError: name 'pending_task' is not defined
因此,当我们调用 process_task_string2 时,我们可能会有 ValueError 和 NameError 异常,裸 except 将无差别地处理这两个异常:
process_task_string2("Laundry,3")
# output: Couldn't cast the number
我们应该预期 task_text0 能够无任何问题地被处理,并且我们应该得到一个转换后的 urgency 级别为 3。但是打印信息表明数字无法转换,这表明转换过程中可能存在问题。
为了避免歧义,永远不要使用裸 except;相反,要明确指定异常。在这种情况下,我们已经知道 ValueError 是可能的;因此,我们在 except 关键字后指定这个异常。如果 try 子句运行,并且引发了 ValueError 异常,这个子句将被执行,如下面的列表所示。
列表 12.9 指定异常
def process_task_string3(text):
title, urgency_str = text.split(",")
try:
urgency = int(urgency_str)
pending_task.urgency = urgency
except ValueError:
print("Couldn't cast the number")
return None
task = Task(title, urgency)
return task
使用更新的函数,只有在捕获到 ValueError 异常时,代码才会显示打印信息:
process_task_string3("Laundry,3#")
# output: Couldn't cast the number
因为 int 构造函数无法将"3#"转换为整数,所以 ValueError 异常被按预期处理。注意,当我们用预期产生正确的 Task 实例的字符串调用此函数时,我们仍然应该看到 NameError,因为我们没有代码来处理它:
process_task_string3("Laudry,3")
# ERROR: NameError: name 'pending_task' is not defined
12.3.3 处理多个异常
我们知道代码是线性执行的,在执行 int(urgency_str)类型转换操作后,执行继续到 pending_task.urgency = urgency,这应该引发一个 NameError 异常。到目前为止,这个异常没有被处理。我们可以在 try...except...语句中处理多个异常。
我们有两种处理多个异常的方法。当异常不相关时,我们应该使用多个 except 子句,每个 except 处理一种不同的异常,如下面的列表所示。
列表 12.10 使用多个 except 子句
def process_task_string4(text):
title, urgency_str = text.split(",")
try:
urgency = int(urgency_str)
pending_task.urgency = urgency
except ValueError:
print("Couldn't cast the number")
return None
except NameError:
print("You're referencing an undefined name")
return None
task = Task(title, urgency)
return task
如列表 12.10 所示,我们通过添加一个额外的 except 子句来更新函数,该子句处理潜在的 NameError 异常。
注意:我们的代码包含这些看似“愚蠢”的错误是为了演示目的。其中一些错误与代码本身的质量有关,这些错误应该通过更改代码而不是处理异常来修复。
通过这次更新,我们可以验证这个异常是否被处理,如下面的打印信息所示:
process_task_string4("Laundry,3")
# output: You're referencing an undefined name
维护性:对于不相关的异常,使用单独的 except 子句。如果异常在语义上是相关的,可以使用单个 except 子句将它们分组。然而,如果你愿意,仍然可以单独处理这些异常。
除了使用多个 except 子句外,你还可以在单个 except 子句中指定多个异常来处理多个异常。下面的列表显示了示例。
列表 12.11 except 子句中的多个异常
def process_task_string5(text):
title, urgency_str = text.split(",")
try:
urgency = int(urgency_str)
pending_task.urgency = urgency
except (ValueError, NameError):
print("Couldn't process the task string")
return None
task = Task(title, urgency)
return task
在这个例子中,我们将两个异常作为一个元组对象列在单个 except 子句中。这样,如果捕获到任一异常,相同的 except 子句将被执行:
process_task_string5("Laundry,3") ❶
# output: Couldn't process the task string
process_task_string5("Laundry,3#") ❷
# output: Couldn't process the task string
❶ 预期 NameError 异常。
❷ 预期 ValueError 异常。
我们尝试了两种不同的字符串,使用 "Laundry,3" 会引发 NameError 异常,而使用 "Laundry,3#" 会引发 ValueError 异常。请注意,当捕获到异常时,执行会跳转到 except 子句。在后一种情况下,当运行 int(urgency_str) 引发 ValueError 时,我们不会期望出现 NameError。
12.3.4 显示异常的更多信息
except 子句在捕获到指定的异常时处理该异常。在我之前使用的代码示例中,我已经打印出消息作为异常的反馈。但是,这些消息缺乏有关异常的详细信息,我可以向用户提供更具体的信息。
要获取捕获到的异常的更多信息,我们可以使用 except SpecificException as var_name 语法将异常分配给一个变量。我们可以更新我们的函数以利用此功能,如下一列表所示。
列表 12.12 从异常创建变量
def process_task_string6(text):
title, urgency_str = text.split(",")
try:
urgency = int(urgency_str)
except ValueError as ex:
print(f"Couldn't cast the number. Description: {ex}")
return None
task = Task(title, urgency)
return task
如列表 12.12 所示,我们将捕获到的 ValueError 异常分配给 ex,以便我们可以在子句中使用这个变量。为了简单起见,我们将只打印出 ValueError 异常:
process_task_string6("Laundry,3#")
# output the following line:
Couldn't cast the number. Description: invalid literal
➥ for int() with base 10: '3#'
从消息中,我们知道转换失败是因为 "3#" 不能转换为整数。请注意,我调用 print 函数是为了教学目的,显示异常的详细描述。对于前端应用程序,例如任务管理应用程序,我们可以显示一个 WARNING 消息来通知用户这个错误,他们可以相应地纠正它。
12.3.5 讨论
正确处理异常是提高用户应用体验的关键。我们不能忽视异常的后果;如果未正确处理,它们将使您的应用程序崩溃。因此,在应用程序的开发阶段,我们应该对可能出错代码保持谨慎。不要担心在代码中使用 try...except... 语句。尽管它们可能看起来会延长代码,但它们使应用程序更加健壮;即使发生异常,它们仍然可以运行,因为它们得到了适当的处理。
12.3.6 挑战
Bob 是一位经验丰富的程序员,他在代码中遵循最佳实践。他明白,当他编写 try...except... 语句时,他应该明确他正在处理的精确异常。存在许多种类的异常。他在开发阶段如何找出适用于特定用例的适当异常呢?例如,在列表 12.9 中,他如何知道需要处理可能的 ValueError 异常?
提示:除了在官方 Python 文档中查找有关异常的信息外,您还可以运行可能存在问题的代码以查看您正在获取哪些异常;然后您可以相应地处理它们。
12.4 如何在异常处理中使用 else 和 finally 子句?
在 Python 中处理异常的最基本形式是使用 try...except... 语句。这个语句由一个 try 子句和至少一个 except 子句组成。以下示例是列表 12.12 的一部分:
try:
urgency = int(urgency_str)
except ValueError as ex:
print(f"Couldn't cast the number. Description: {ex}")
return None
task = Task(title, urgency)
我们知道代码 task = Task(title, urgency) 在 try...except... 语句之后运行。值得注意的是,except 子句包含一个返回语句(return None)。如果我没有包含它,由于在 except 子句中没有定义 urgency,运行 task = Task(title, urgency) 将会引发 UnboundLocalError 异常。但我们知道,代码 task = Task(title, urgency) 仅在 try 子句中的代码没有引发异常时才相关。有没有更好的方法来明确我们只想在没有异常的情况下运行某些代码?这个问题引出了下一节的主题:向 try...except... 语句添加 else 子句。12.4.2 节讨论了 finally 子句,这是完整 try...except... 语句中的另一个可选组件。
12.4.1 使用 else 继续在 try 子句中的代码逻辑
在 12.3 节中,我提到最小化 try 子句的长度是至关重要的,只包含可能引发异常的代码。当 try 子句完成执行后,Python 将运行 try...except... 语句之后的代码。然而,语句之后的代码只有在 try 子句中的代码没有引发任何异常时才有意义。为了实现这个功能,我们应该在 try 和 except 子句之上使用 else 子句。
在 try...except... 语句中,try 关键字意味着我们将尝试执行可能引发异常的代码,而 except 关键字意味着我们将处理我们捕获到的异常。那么术语 else 呢?这个名字可能听起来有些令人困惑。(还有什么 else?)为了理解它,我们必须承认整个 try...except...else... 语句的目的是处理异常。更具体地说,一个目标就是捕获这样的异常。因此,如果我们能够捕获异常,我们将处理它;否则,我们将继续执行。else 子句负责“否则”部分的工作。下面的列表显示了示例。
列表 12.13 向 try...except 语句添加 else 子句
def process_task_string7(text):
title, urgency_str = text.split(",")
try:
urgency = int(urgency_str)
except ValueError as ex:
print(f"Couldn't cast the number. Description: {ex}")
return None ❶
else:
task = Task(title, urgency)
return task
❶ 你可以省略这个可选的返回 None 语句。
如列表 12.13 所示,我们在 except 子句之后包含一个 else 子句。在 else 子句中,我们使用标题和紧急程度创建 Task 类(在 12.3 节开头定义)的实例对象。如果我们没有 ValueError 异常,我们应该期望获得一个实例对象:
processed_task7 = process_task_string7("Laundry,3")
assert processed_task7 == Task("Laundry", 3)
如此代码片段所示,我们获得了 Task 类的实例类,这表明 else 子句中的代码执行成功。当引发 ValueError 异常时会发生什么?观察结果:
processed_task = process_task_string7("Laundry,3#")
# output the following line:
Couldn't cast the number. Description: invalid literal for
➥ int() with base 10: '3#'
print(processed_task)
# output: None
首先要注意的是,异常处理子句执行是因为捕获到的ValueError异常。另一件要注意的事情是,调用process_task_string7函数的返回值是None,这表明当异常处理子句运行并返回None时,else 子句中的代码并没有执行。
12.4.2 使用 finally 子句清理异常处理
正如你在 12.4.1 节中看到的,except 和 else 子句中只有一个会运行。如果 try 子句抛出异常,except 子句(处理异常)会运行;如果 try 子句没有抛出异常,else 子句会运行。然而,有时我们有一些无论异常状态如何都希望运行的代码。例如,在处理任务字符串的函数中,我们可能希望通知用户处理已完成,无论是否成功。这正是 finally 子句所能完成的任务,正如我们将在本节中看到的那样。图 12.5 提供了异常处理中四个可能子句的图形概述。

图 12.5 完整的 try...except...else...finally...语句中的四个子句。try 子句包括可能抛出异常的代码。except 子句包括处理可能异常的代码。else 子句仅在未抛出异常的情况下运行。finally 子句在 except 子句或 else 子句之后运行。
如其名称所示,finally 子句应该放在 try...except...语句的末尾(图 12.5)。如果你使用 else 子句,finally 子句应该跟在其后;否则,它跟在 except 子句后面。无论异常抛出的状态如何,finally 子句中的代码都会执行。下面的列表展示了 finally 是如何通过继续处理存储任务数据的字符串的例子来工作的。
列表 12.14 在 try...except 语句中使用 finally 子句
def process_task_string8(text):
title, urgency_str = text.split(",")
try:
urgency = int(urgency_str)
except ValueError as ex:
print(f"Couldn't cast the number. Description: {ex}")
return None
else:
task = Task(title, urgency)
return task
finally:
print(f"Done processing text: {text}")
在列表 12.14 中,我们将 finally 子句添加到 try...except...语句中。为了简单起见,我们打印出一个消息,显示处理已完成。这个 finally 子句应该在是否抛出ValueError异常的情况下都运行:
task_no_exception = process_task_string8("Laundry,3")
# output the following line:
Done processing text: Laundry,3
task_exception = process_task_string8("Laundry,3#")
# output the following lines:
Couldn't cast the number. Description: invalid literal for int()
➥ with base 10: '3#'
Done processing text: Laundry,3#
在对process_task_string8函数的两次调用中,我们可以看到 finally 子句通过打印 f-string 消息来执行。你可能想知道使用 finally 子句的目的是什么。如果它无论异常状态如何都会运行,为什么我们不将其放在 try...except...语句之外?因为我们知道代码通常按线性方式执行,通过将其放在语句之外,我们保证它将跟随 except 或 else 子句。
如你所注意到的,我使用了“通常”这个词,因为 finally 子句有一个不典型的规则。如果 try 子句遇到 break、continue 或 return 语句,finally 子句会在执行 break、continue 或 return 语句之前运行。这个规则是必要的,以确保 finally 子句中的代码执行,因为在典型场景中,这些语句会结束当前执行并跳过剩余的代码。我们可以在以下示例中观察到这种效果:
def process_task_string9(text):
title, urgency_str = text.split(",")
try:
urgency = int(urgency_str)
task = Task(title, urgency)
return task
except ValueError as ex:
print(f"Couldn't cast the number. Description: {ex}")
return None
finally:
print(f"Done processing text: {text}")
task = process_task_string9("Laundry,3")
# output: Done processing text: Laundry,3
assert task == Task("Laundry", 3)
如代码片段所示,我们在 try 子句中包含了一个返回语句。与其它场景不同,返回语句会立即结束函数的执行。在这里,我们看到在 finally 子句中调用了 print 函数,这支持了我们之前的观点,即 finally 子句无论异常状态如何都会执行,即使 try 或 except 子句中包含返回语句。因为 finally 子句无论是否抛出异常都会执行,所以我们经常在处理共享资源,如文件和网络连接时使用 finally 子句。我们希望在 try 子句中完成所需操作(或抛出异常)的情况下,都能在 finally 子句中释放这些资源。
12.4.3 讨论
在异常处理功能的四个子句中,你应该始终使用 try 和 except,因为它们构成了处理异常的基础。try 子句“尝试”执行代码,因为它可能会抛出异常,而 except 子句则捕获并处理这些异常。尽管 else 和 finally 子句是可选的,但它们有自己的用例,你应该了解。
12.4.4 挑战
我们知道,在 finally 子句存在的情况下,如果 try 子句中包含返回语句,它仍然会在 try 子句中的返回语句之前运行 finally 子句中的代码。以下示例中调用 process_ task_challenge 函数的返回值是什么?
def process_task_challenge(text):
title, urgency_str = text.split(",")
try:
urgency = int(urgency_str)
task = Task(title, urgency)
return task
except ValueError as ex:
print(f"Couldn't cast the number. Description: {ex}")
return None
finally:
print(f"Done processing text: {text}")
return "finally"
processed = process_task_challenge("Laundry,3")
print(processed)
提示:因为 finally 子句中的代码在 try 子句中的返回语句之前运行,所以它会立即结束函数,因为 finally 子句本身包含一个返回语句。try 子句中的返回语句将被跳过。
12.5 如何使用自定义异常类抛出有信息的异常?
当我们学习用 Python 编程时,会犯各种各样的错误。一些错误是由于语法错误造成的,比如在 if...else...语句中缺少冒号。当我们对所有语法有了基本理解后,可能会遇到其他错误,这些错误主要与从语义或逻辑角度正确使用特定功能有关。正如在第 12.3 节和第 12.4 节中广泛使用的那样,ValueError 就是这样的错误。作为另一个例子,当我们尝试将一个数除以零时,我们会遇到 ZeroDivisionError:
int("3#")
# ERROR: ValueError: invalid literal for int() with base 10: '3#'
1 / 0
# ERROR: ZeroDivisionError: division by zero
在这两种情况下,错误消息不仅告诉我们具体的异常名称,还提供了错误描述,这有助于我们找出我们做错了什么。当我们为其他开发者创建库或包时,向用户显示适当的错误消息非常重要,这样他们才知道如何调试代码或处理异常。在本节中,你将学习如何使用自定义异常类引发信息丰富的异常。
12.5.1 使用自定义消息引发异常
到目前为止,我们已经看到了 Python 评估我们的代码时引发的异常。然而,我们还没有学习如何自己引发异常。在本节中,我将展示我们如何引发异常以及如何为异常提供自定义消息。
概念 当我们在代码中“产生”异常以指示某些问题时,我们说我们 引发 异常。一些其他语言使用 throw 来实现这个目的。
我一直在使用 raise 来表示某些代码产生了异常。不出所料,raise 是 Python 中引发异常的关键字。当我们运行以下代码到控制台时,我们也应该看到 traceback:
>>> raise ValueError
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError
>>> raise ZeroDivisionError
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ZeroDivisionError
我们通过使用格式 raise ExceptionClass 来引发异常。ValueError 和 ZeroDivisionError 是两个异常类。严格来说,当我们引发异常时,我们是在引发异常类的实例对象;因此,此示例的格式是 raise ExceptionClass() 的语法糖,其中 ExceptionClass() 创建了一个类的实例对象。
概念 在编程中,语法糖 指的是简单但执行与更复杂对应操作相同的用法。
同样,当我们处理异常时,我们正在处理异常类的实例。观察以下示例的效果:
try:
1 / 0
except ZeroDivisionError as ex:
print(f"Type: {type(ex)}")
print(f"Is an instance of ZeroDivisionError?
➥ {isinstance(ex, ZeroDivisionError)}")
# output the following lines:
Type: <class 'ZeroDivisionError'>
Is an instance of ZeroDivisionError? True
如此例所示,我们知道 1 / 0 会引发 ZeroDivisionError 异常,并在 except 子句中处理它。从打印的消息中,我们知道引发的异常确实是 ZeroDivisionError 类的实例对象。
运行 raise ValueError 似乎没有用。如果你还记得,当我们调用 int("3#") 时,错误消息明确告诉我们这个异常的原因:ValueError: invalid literal for int() with base 10: ' 3#'。要向异常提供自定义消息,我们使用格式 raise ExceptionClass("custom message")。以下是一些示例:
raise ValueError("Please use the correct parameter.")
# ERROR: ValueError: Please use the correct parameter.
code_used = "3#"
raise ValueError(f"You used a wrong parameter: {code_used!r}") ❶
# ERROR: ValueError: You used a wrong parameter: '3#'
❶ 使用 repr !r 转换来生成引号内的字符串
当我们向异常类构造函数提供自定义消息时,引发的异常会附带该消息,通知用户异常的详细信息。请注意,此消息应简短;我们不希望用一大段可能只会使他们困惑的描述来压倒用户。
可读性 当你向异常类提供自定义消息时,要简洁。
12.5.2 优先使用内置异常类
在我们讨论早期章节中的数据模型时,你在学习自定义类(第八章和第九章)之前,学习了内置数据类型,如 str(第二章)、list 和 tuple(第三章)。这种顺序的原因是内置数据类型是表示数据的最基本形式,所有 Python 程序员都很好地理解它们。我们可以将同样的哲学应用到异常上。当我们需要抛出异常时,我们更喜欢使用内置异常类型。
我们知道,异常是通过从异常类创建实例对象来引发的。因此,要使用内置异常类,我们需要了解最常见的那些。不要害怕不知道它们;每个学习编码的人都会犯各种错误,从而引发异常。你将逐渐学会你的代码中哪种异常与哪种错误相关联。图 12.6 提供了常见异常的概述。

图 12.6 常见的内置异常类。BaseException 类是所有其他异常类的超类。我们与之交互的大多数异常类都是 Exception 类的子类。
BaseException 是所有内置异常的基类,包括如 KeyboardInterrupt 和 SystemExit 这样的系统退出异常。一般来说,我们不应该从这个类继承来定义我们自己的自定义异常类;相反,我们应该使用 Exception 类(见 12.5.3 节)来避免捕获系统退出异常。我们遇到的一些常见异常类,如 ValueError 和 NameError,是 Exception 类的直接或间接子类。
虽然定义自定义异常类并不困难,但当我们想到抛出异常时,我们应该首先考虑内置异常类,因为它们更被普通开发者所熟知。考虑一个简单的例子:
class Task:
def __init__(self, title):
self.title = title
在这个代码片段中,我们定义了一个具有标题属性的任务类,该属性是一个字符串。目前,我们没有强制用户使用字符串对象来实例化任务类。如果我们确实想强制这一要求,我们可以在代码中包含类型检查,并在提供的参数不是字符串对象时抛出异常,如下一个列表所示。
列表 12.15 在构造函数中抛出异常的类创建
class Task:
def __init__(self, title):
if isinstance(title, str):
self.title = title
else:
raise TypeError("Please instantiate the Task
➥ using string as its title")
task = Task(100)
# ERROR: TypeError: Please instantiate the Task using string as its title
通过使用列表 12.15 中的内置 TypeError,我们使用户更容易理解他们为参数使用了错误的数据类型。
可读性:在抛出异常时,优先使用内置异常类,因为它们对用户来说更熟悉。
12.5.3 定义自定义异常类
当你创建自己的 Python 包时,如果内置的异常类不能满足你的需求,定义自定义异常类是很常见的。在本节中,我将向你展示定义自定义异常类的最佳实践。
如在第 12.5.2 节简要提到的,我们的自定义异常类应该继承自 Exception 类。对于自定义包,最佳实践是为您的包创建一个基本异常类,然后通过继承基本类创建额外的异常类。为您的包创建基本异常允许用户在需要时处理所有包的异常。
如果您需要定义自己的自定义异常类,请为您的包创建一个基本异常类,这些类应继承自基本类。假设对于任务管理应用,我们正在将其构建为一个其他开发者可以使用的包,以便他们可以使用 Task 类作为数据模型,通过使用不同的前端库来构建另一个应用。对于这个可能被命名为 taskier 的包,我们可以定义一个名为 TaskierError 的基本异常类:
class TaskierError(Exception):
pass
在这个特定于包的基本异常类中,我们不需要有任何实现细节。我们可以简单地使用 pass 语句来满足语法要求。(类体不能为空。)
对于任务包,我们可以定义更具体的异常类。例如,我们可以允许用户从他们的电脑上传 CSV 文件以从多个任务中检索数据。以下列表定义了一个要求文件具有.csv 扩展名的异常。
列表 12.16 定义自定义异常类
class FileExtensionError(TaskierError):
def __init__(self, file_path):
super().__init__()
self.file_path = file_path
def __str__(self):
return f"The file ({self.file_path}) doesn't appear to be a
➥ CSV file."
# In another part of our package
from pathlib import Path
def upload_file(file_path):
path = Path(file_path)
if path.suffix.lower() != ".csv":
raise FileExtensionError(file_path)
else:
print(f"Processing the file at {file_path}")
注意列表 12.16 中的两个重要事项:
-
自定义异常类可以接受额外的实例化参数。 这里,我们包括 file_path 参数(注意创建异常的消息是可选的),因为我们想向读者展示指定路径上的文件不是正确的格式。
-
我们重写了 str 方法。 如您从第 8.4 节回忆起来,这个方法在我们打印实例对象时被调用。
在我们包的另一个部分,我们使用这个异常类。如前述代码所示,upload_file 函数检查文件的扩展名(第 11.5 节)并在扩展名不正确时引发异常。
当其他开发者使用我们的包时,他们可能会构建一个控件小部件,允许用户上传文件。他们的应用可能具有以下功能:
def custom_upload_file(file_path):
try:
upload_file(file_path)
except FileExtensionError as ex:
print(ex)
else:
print("Custom upload file is done.")
custom_upload_file("tasks.csv") #A Calling the function with a CSV file
# output the following lines:
Processing the file at tasks.csv
Custom upload file is done.
custom_upload_file("tasks.docx") #B Calling the function with a docx file
# output: The file at tasks.docx doesn't appear to be a CSV file.
在这个例子中,我们使用两种不同类型的文件调用自定义函数:CSV 文件和 Microsoft Word 文档文件。如您所见,当我们不使用正确的文件时,except 子句会捕获 FileExtensionError 并打印我们在 str 类中实现的错误消息。
如果需要,我们可以在我们的包中定义额外的自定义异常类。例如,我们可以定义一个名为 FileFormatError 的异常类,用于文件不包含所需数据时使用。作为另一个例子,我们可以定义一个名为 InputArgumentError 的异常类,用于开发者在关键函数中使用错误的参数时使用。这两个类都应该继承自 TaskierError。图 12.7 显示了自定义包中异常类的层次结构。

图 12.7 自定义包中自定义异常类的层次结构。我们通过继承 Exception 类创建一个特定于包的基本异常类。从这个基类出发,我们可以定义多个异常类,以引发特定的异常。
12.5.4 讨论
虽然你可以定义自定义异常类来引发信息丰富的异常,但尽可能使用内置异常类。然而,如果你正在创建自定义包或库,你可能发现创建自己的自定义异常类来生成更具体的错误消息更有意义,从而帮助包的用户(开发者)调试问题。值得注意的是,你应该首先定义一个特定于包的基本异常类。这些自定义异常类的行为类似于常规的自定义类,如果需要,你可以覆盖特殊方法,如 str。
12.5.5 挑战
在列表 12.15 中,Task 类可以在其构造函数中引发 TypeError 异常。你能编写一些代码,通过使用 try...except...else...finally...语句来处理这个异常吗?
提示:你应该在 try 子句中调用构造函数,并处理可能出现的 TypeError 异常。
摘要
-
最佳实践是调用 getLogger 来检索你模块的记录器,这保证了你获得的是同一个记录器而不是创建多个。
-
为了长期存储目的,通常会将文件处理器附加到记录器上,以便将日志记录保存到文件中。
-
在开发阶段,显示日志在控制台中有助于调试。你还可以将流处理器添加到记录器中。
-
为了更好地跟踪日志记录的严重性,你应该将这些记录按不同级别进行分类:DEBUG、INFO、WARNING、ERROR 和 CRITICAL。
-
你可以通过设置适当的日志级别来设置记录器和处理器,以便它们跟踪所需级别的记录。
-
为了提高可读性,格式化日志记录始终是一个好主意。关键信息包括时间戳、严重级别、适用模块和消息。
-
try...except...语句是 Python 中处理异常的基本格式。try 子句应仅包含可能引发异常的代码。你应该在 except 子句中明确指出你正在处理的异常。
-
虽然您可以在单个 except 子句中将多个异常作为一个元组对象捆绑起来,但我建议您使用多个 except 子句而不是一个 except 子句——除非这些异常确实非常相关。
-
当 try 子句没有引发异常时,else 子句会执行。finally 子句可以用来清理异常处理;无论 try 子句中是否引发了异常,它都会执行。
-
您可以通过使用内置的异常类来引发异常,并向这些异常提供自定义消息以使其更具信息性。
-
当您定义自定义异常类时,请记住您应该继承自 Exception 类,而不是 BaseException 类。
-
如果您的包中包含自定义异常类,最佳实践是从其中定义额外的自定义异常子类的包特定基异常类。
13 调试与测试
本章涵盖
-
阅读跟踪信息
-
交互式调试你的应用程序
-
测试函数
-
测试一个类
从零开始完成一个编程项目到生产就像建造一所房子。在你铺设地基、搭建框架和墙壁、完成屋顶并安装门窗之后,你会觉得房子的大部分已经完成。但是当你进行内部装修,如地板、灯光、家具和壁橱时,你会意识到它还远未完成。
你已经努力工作了三个月,感觉你已经完成了项目的 90%。然而,在你将其推入生产之前,你必须通过严格的调试和测试来确保其性能。如果最后估计的 10%再花费你三个月的时间——这与你最初 90%所需的时间相同——这并不会让我感到惊讶。调试和测试阶段类似于房屋的内部装修——它是如此重要,以至于你的应用程序没有它是无法运行的——而且你也不想听到客户在发布日后的抱怨。因此,让我们在应用程序仍然在我们手中的时候解决调试和测试任务。在本章中,你将学习可以应用于对应用程序进行严格最终润色的基本技术:调试和测试。
13.1 我如何通过跟踪信息发现问题?
当我们的代码由于异常而无法运行时,Python 不仅告诉我们关于异常的信息,还提供了有关异常发生位置的其他信息。假设我们在定义 Task 类时拼写了一个方法调用。当我们创建 Task 类的实例对象并调用实例方法 update_urgency 时,我们会遇到 AttributeError 异常。尝试在控制台中运行下一列表中的代码。
列表 13.1 运行某些代码时显示跟踪信息
class Task:
def __init__(self, title, urgency):
self.title = title
self.urgency = urgency
def _update_db(self):
# update the record in the database
print("update the database")
def update_urgency(self, urgency):
self.urgency = urgency
self.update_db() ❶
task = Task("Laundry", 3)
task.update_urgency(4) ❷
# output the following error:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 10, in update_urgency
AttributeError: 'Task' object has no attribute 'update_db'
❶ 不计算空行,行号为 10。
❷ 引发异常的代码行
注意:当你将代码提交到控制台时,空行会被删除,所以你会看到跟踪信息中的行号与文件中的行号不匹配。因为我们经常在控制台和文件中运行代码,所以在本节中,我将向你展示两种模式下的跟踪信息。
在大多数涉及异常的前置代码片段中,我只展示了异常的最后一行。在这里,我将展示异常的整个输出信息。除了异常行之外,输出还包括涉及的方法名称和行号等信息,所有这些都可以帮助我们定位错误问题。输出中的这些信息被称为跟踪信息。使用跟踪信息来定位问题是调试我们代码的第一步。在本节中,你将学习如何阅读跟踪信息以及如何使用它们来定位我们代码中的问题。
13.1.1 理解如何生成跟踪信息
回溯是对异常抛出过程的详细描述。在第十二章中,我们学习了如何读取回溯的最后一行,它包含异常的类型和描述。在这里,让我们回顾一下回溯是如何生成的,因为它是我们正确读取回溯并收集异常信息的基础。
在我们应用程序运行期间,事件会持续发生,例如创建实例、访问它们的属性和调用它们的方法。当某些事情没有按预期工作的时候,我们的应用程序可能会遇到异常并停止执行。尽管在列表 13.1 中,如 task.update_urgency(4) 这样的特定代码行似乎是我们应用程序终止的直接原因,但这行代码可能并不是要责备的对象;异常可能是由其他地方的底层操作引起的。因此,在没有回溯的情况下,我们必须了解一般的执行过程,以便知道异常是如何被抛出的。
让我们以列表 13.1 中的代码为例。图 13.1 是执行步骤的简单示意图。

图 13.1 列表 13.1 中代码的执行过程。第一步是定义 Task 类。第二步是创建类的实例。第三步是调用 update_urgency 方法。第四步是使用类中的方法定义。
列表 13.1 中的代码包含四个主要步骤:
-
定义 Task 类
-
创建 Task 的实例
-
调用 update_urgency 方法
-
使用类中 update_urgency 方法的定义
如列表 13.1 中注释所示,task.update_urgency(4) 导致异常,并不是因为调用该方法本身是错误的。在底层,方法定义中存在错误。正如你在列表 13.1 中可能注意到的,update_urgency 错误地调用了 update_db 而不是 _update_db,正如它应该做的。
这四个步骤代表了一个程序运行时执行序列的快照,这涉及到数千个连续的操作。从一般的角度来看,我们可以构建一个操作树(图 13.2)。每个框代表一个不同的操作。这样的操作可以被称为“调用”,这与回溯标题中的术语相对应:Traceback (most recent call last)。这些操作形成了“调用栈”,它跟踪应用程序执行的进展。
概念 一个 调用栈 跟踪从当前调用到完成执行所需的底层操作的执行顺序。这些连续的操作形成了调用栈。

图 13.2 构建回溯的示意图。回溯从最终导致异常的代码行开始,并跟随涉及的操作,直到到达直接抛出异常的代码行。
回溯建立在调用栈之上。它们从最终导致异常的代码行的调用开始,并记录该代码行调用的操作(或调用)。如果该操作没有引发异常,回溯将继续记录下一个操作,直到它们定位到引发异常的代码。图 13.2 显示了回溯的示意图。
13.1.2 在控制台中运行代码时的回溯分析
在 13.1.1 节中,我们检查了回溯是如何在幕后生成的。现在我们准备找出构成在控制台中运行代码生成的回溯的元素。
让我们继续查看列表 13.1 中显示的回溯信息。图 13.3 展示了在控制台中运行代码生成的回溯的基本元素。

图 13.3 突出了在控制台中生成的回溯的关键元素。每一行代表一个不同的操作,如图 13.2 所示。对于每一行,关键元素包括操作的源文件、行号和有问题的代码。最后一行显示了异常。
回溯中的每一行代表一个操作或调用。第一行是导致异常的代码行:task.update_urgency(4)。让我们仔细看看第二行,以检查关键元素。因为我们是在控制台中运行列表 13.1 中的代码,所以涉及操作的来源是
13.1.3 在运行脚本时的回溯分析
在 13.1.2 节中,我们专注于分析在控制台中运行代码创建的回溯。从更一般的角度来看,我们通常通过使用命令行工具将代码作为脚本运行。在本节中,我们将看到回溯中更有趣的内容。
为了保持一致性,将列表 13.1 中的代码保存到一个名为 task_test.py 的脚本文件中。注意代码片段末尾的一个变化:
class Task:
def __init__(self, title, urgency):
self.title = title
self.urgency = urgency
def _update_db(self):
# update the record in the database
print("update the database")
def update_urgency(self, urgency):
self.urgency = urgency
self.update_db()
if __name__ == "__main__":
task = Task("Laundry", 3)
task.update_urgency(4)
如您所见,我们不再像在 13.1 列表中那样直接创建实例并调用方法,而是现在将相关的代码包含在一个条件语句中,该语句仅在特殊属性 __name__ 等于 "__main__" 时运行。包含此语句是一种最佳实践,它允许您将文件作为脚本和模块运行。当您将文件作为脚本运行时,特殊属性 __name__ 的值为 "__main__",因此该语句评估为 True 并运行包含的操作。与此同时,当您将文件作为模块导入时,模块的名称是文件名,它不是 "__main__",因此您无法意外地运行包含的代码。在接下来的部分中,我们将在脚本文件中包含 if 语句。
可维护性 在大多数情况下,当您的 Python 文件旨在同时作为脚本和模块执行时,如果您只想让这些操作作为脚本运行,则应将这些操作包含在 if 语句中(if __name__ == "__main__": # operations)。如果不这样做,当文件作为模块导入时,这些操作将被执行。
您可以在您的命令行工具中运行以下命令(列表 13.2),例如,如果您使用的是 Mac 电脑,则使用 Terminal 应用;如果您使用的是 Windows 电脑,则使用 cmd 工具。请注意,如果您不使用脚本的完整路径,则需要导航到当前目录。
列表 13.2 运行生成跟踪回溯的 Python 脚本
$ python3 task_test.py ❶❷
Traceback (most recent call last):
File "/full_path/task_test.py", line 17, in <module>
task.update_urgency(4)
File "/full_path/task_test.py", line 12, in update_urgency
self.update_db()
AttributeError: 'Task' object has no attribute 'update_db'. Did you mean:
➥ '_update_db'?
❶ $ 表示命令行提示符。
❷ 我使用 python3,因为 macOS 默认使用 Python 2 版本。
与在控制台中执行代码生成的跟踪回溯相比,运行脚本生成的跟踪回溯包含更多信息。如列表 13.2 中所示,跟踪回溯还显示了该调用的确切操作。例如,在 update_urgency 方法中,代码 self.update_db() 引发了 AttributeError 异常。在控制台中运行代码和作为脚本文件运行时的跟踪回溯之间的差异,是因为 Python 在这两种运行模式下创建的调用堆栈不同。当代码在控制台中运行时,调用堆栈仅跟踪行,而当脚本执行时,它跟踪具体的操作。
13.1.4 聚焦于跟踪回溯中的最后一个调用
我们已经看到了一些由在 Python 控制台运行代码或从命令行执行脚本生成的跟踪回溯。您可能已经注意到了在跟踪回溯中查找问题的位置,本节将正式讨论这个话题。
按照设计,跟踪回溯以从上到下的线性方式显示调用堆栈。也就是说,最后的调用显示在底部,这直接导致了抛出的异常。因此,为了解决问题,我们应该关注最后的调用。在我们使用的例子中,AttributeError 异常告诉我们问题:AttributeError: 'Task' 对象没有属性 'update_db'。当我们以脚本的形式运行文件时生成的跟踪回溯(列表 13.2),错误信息甚至建议:“你是指:'_update_db'?”请注意,这些附加信息可能在早期的 Python 版本中不可用。
逸事:显示“你是指”异常消息是 Python 中的一个新功能。根据你的 Python 版本和使用的 Python 编辑器,你可能看不到它。
这个建议正是我们应该采取的。我们前往更新紧急性方法的定义,如跟踪回溯的最后调用所示(使用行号快速定位代码),并将 update_db 替换为 _update_db。注意使用下划线前缀的差异。在做出这个更改后,我们可以再次运行脚本:
$ python3 task_test.py
# output: update the database
如预期的那样,我们没有看到 AttributeError 异常。现在脚本运行正常。
13.1.5 讨论
在本节中,我通过一个简单的例子来展示跟踪回溯的结构以及如何阅读它来修复我们代码中的简单问题。一般来说,最后的调用与可能需要我们修复的问题相关。然而,当你的项目使用多个依赖项时,你很可能会看到更复杂的跟踪回溯。我敢打赌,你会发现跟踪回溯中的最后调用不是你的代码!当这种情况发生时,你必须通过追踪到更早的调用来向上阅读跟踪回溯,在那里你会找到你编写的代码。这个调用更有可能是你想要解决的问题的根源。
13.1.6 挑战
乔是一个初级软件开发人员。作为他工作的一部分,他被分配去调试公司开发的提高工作效率软件的问题。作为他学习经验的一部分,他正在玩跟踪回溯。在列表 13.1 中,跟踪回溯包括两个调用。为了找点乐子,他如何通过添加和使用一些更多的方法来更新 Task 类,从而生成包含两个以上调用的跟踪回溯?
提示:你可以添加一个或两个方法,其中一个包含会引发异常的代码错误。将这些方法用于其他方法中,以创建多个顺序调用。
13.2 如何交互式地调试我的程序?
在开发阶段识别错误总是一个好主意,这样你就不必在产品交付后处理客户的投诉。你可能喜欢在每一部分(几乎)完成后都调试程序。但我建议你逐步调试你的应用程序,这样可以最大限度地减少错误的可能性。虽然你可以通过异常检查回溯来解决问题,但这并不总是足够让你仔细检查每个涉及的运算,因为异常会立即崩溃你的应用程序。
另一种重要的调试技术是 交互式调试器,它允许你在应用程序运行时实时检查它。在本节中,你将了解内置调试器的关键特性。图 13.4 展示了交互式调试程序的一般方面。我将在本节中介绍这些方面。

图 13.4 Python 中程序调试的一般方面。对于一个程序,我们在调试的地方添加断点。当执行遇到断点时,它会激活交互式调试器。然后我们可以执行各种调试任务,例如逐行运行代码。
如第六章和第七章所述,我们知道函数对于应用程序至关重要。它们也构成了自定义类(第八章)的大部分内容。编写无错误的函数是任何程序员的重大目标,因此本节使用函数作为示例来展示交互式调试过程。
13.2.1 使用断点激活调试器
在大多数情况下,我们不需要花费太多时间就能找到有问题的位置,因为当我们的应用程序由于异常而崩溃时,生成的回溯信息可以告诉我们异常的位置。当我们知道问题位置后,我们可以通过添加 断点 来激活调试器,开始我们的干预。
概念 断点 是你请求应用程序停止执行以进行调试的点。
作为标准 Python 库的一部分,pdb 模块提供了通过交互式调试器进行调试的基本功能。要激活此调试器,你可以调用其 set_trace 函数:
def create_task():
import pdb; pdb.set_trace() ❶
create_task()
# output the following lines:
--Return--
> <stdin>(2)create_task()->None
(Pdb)
❶ 添加断点
在 create_task 函数中,你导入 pdb 模块并调用 set_trace 来插入断点。(请注意,你本可以将导入语句移到函数外部;将导入语句放在 set_trace 之前只是一个惯例。)当你调用此函数时,你会注意到调试器被激活;你的 Python 控制台提示符从默认的 >>> 变成了 (Pdb),这表明 Python 已进入调试模式。
虽然你可以通过调用 import pdb; pdb.set_trace() 来激活调试器,但我在这里展示它是为了让你理解这一行代码的含义。你可能已经在一些旧项目中见过这种用法。然而,一种更干净的方法是使用 Python 3.7 中添加的功能。你可以直接调用内置的 breakpoint 函数,如下所示(如果你打开了调试器,你可以通过按 q 来终止它):
def create_task():
breakpoint()
create_task()
从输出中,你应该能看到断点函数通过激活调试器实现了相同的效果;它是一个底层调用 set_trace 的便利函数。值得注意的是,调试模式是交互式的,并且提供了许多选项来帮助你调试函数,正如下一节所讨论的。
13.2.2 逐行运行代码
当我们执行一个操作,比如函数调用时,操作通过执行其整个主体瞬间发生。如果成功,我们得到返回值(或隐式地得到 None)。如果失败,我们可能会得到一个异常或我们预料之外的价值。在两种情况下,操作的速度太快,我们无法确切知道函数中发生了什么。如果我们能够逐行运行代码,我们就可以更好地理解操作中的每一步,从而提高我们解决可能出现的错误的机会。在本节中,我将向你展示如何逐行运行代码。同样重要的是,你将看到一些调试器的关键选项。
假设在我们的任务管理应用程序中,我们获得了包含任务信息的文本数据,并且我们想要将此数据转换为 Task 类的实例对象。为了教程的目的,让我们在其中一个函数中添加一个断点,并将代码保存在一个名为 task_debug.py 的脚本文件中,如下所示。虽然你在控制台中提交代码时可以进行调试,但实际项目更像是运行脚本,所以我们将在这里使用脚本进行调试。
列表 13.3 创建包含断点的函数(task_debug.py)
from collections import namedtuple
Task = namedtuple("Task", "title urgency") ❶
def obtain_text_data(want_bad):
text = "Laundry,3#" if want_bad else "Laundry,3"
return text
def create_task(inject_bug: bool):
breakpoint() ❷
task_text = obtain_text_data(inject_bug) ❸
title, urgency_text = task_text.split(",")
urgency = int(urgency_text)
task = Task(title, urgency)
return task
if __name__ == "__main__":
create_task(inject_bug=False)
❶ 创建一个命名元组类
❷ 添加断点
❸ 这是第 10 行。
create_task 函数通过调用 obtain_text_data 并处理文本数据来创建任务。为了让我们能够模拟函数调用失败的情况,我们有一个布尔参数,在需要时引入错误。有了这个设置,我们可以继续调试脚本,而不期望出现错误(inject_bug=False)。启动命令行工具并导航到当前目录,然后运行以下命令来执行脚本:
$ python3 task_debug.py
> /full_path/task_debug.py(10)create_task()
-> task_text = obtain_text_data(inject_bug)
(Pdb)
你应该能看到我们处于调试模式,提示符为 (Pdb)。数字(10)告诉我们行号,并且当前执行在 create_task 函数中停止。它还显示了将要执行的下一行,即调用 obtain_text_data 函数。
要执行此行,我们可以按 n,代表 下一个。你会看到我们完成了当前行的运行,显示了下一行代码:
> /full_path/task_debug.py(11)create_task()
-> title, urgency_text = task_text.split(",")
(Pdb)
如果我们要执行下一行,可以按 Return 键(在 Mac 上)或 Enter 键(在 Windows 计算机上),这将重复上一个命令:n。执行移动到下一行:
> /full_path/task_debug.py(12)create_task()
-> urgency = int(urgency_text)
(Pdb)
如您所预期,如果我们继续按 Enter 或 Return 键,整个脚本将无任何问题地完成。但这并不有趣,对吧?让我们看看一些其他调试选项。
有时,您可能想查看其他行以获得函数的更大视图。为此,您可以按 l 键(小写 L),因为 l 代表列表命令:
(Pdb) l
7
8 def create_task(inject_bug: bool):
9 breakpoint()
10 task_text = obtain_text_data(inject_bug)
11 title, urgency_text = task_text.split(",")
12 -> urgency = int(urgency_text)
13 task = Task(title, urgency)
14 return task
15
16 if __name__ == "__main__":
17 create_task(inject_bug=False)
(Pdb)
这种信息以两种方式有帮助:它显示了围绕当前行的所有行,行号清晰标注;并且使用箭头指示当前行。
13.2.3 步进到另一个函数
在 13.2.2 节中的调试中,第一行代码调用另一个函数:task_text = obtain_text_data(inject_bug)。您可能会注意到我们立即获得了返回值。尽管这里不是这种情况,被调用的函数可能会出错,我们可能想放大被调用的函数以查看其操作。我们可以通过按 q 键退出当前的调试会话,然后在命令行工具中再次运行脚本:
$ python3 task_debug.py
> /full_path/task_debug.py(10)create_task()
-> task_text = obtain_text_data(inject_bug)
(Pdb)
而不是按 n 键执行下一行,我们想按 s 键,它代表步进;我们要求执行下一个步骤。在这种情况下,下一个步骤是调用 obtain_text_data 函数:
(Pdb) s
--Call--
> /full_path/task_debug.py(4)obtain_text_data()
-> def obtain_text_data(want_bad):
如您所见,我们放大了函数调用,而不是直接获取其返回值。如果我们继续按 s 或 Return 键,我们将查看整个函数:
Pdb) s
> /full_path/task_debug.py(5)obtain_text_data()
-> text = "Laundry,3#" if want_bad else "Laundry,3"
(Pdb) s
> /full_path/task_debug.py(6)obtain_text_data()
-> return text
(Pdb) s
--Return--
> /full_path/task_debug.py(6)obtain_text_data()->'Laundry,3'
-> return text
最后一个操作显示了调用函数的返回值:'Laundry,3'。如果我们继续按 s 键,我们将回到原始函数,create_task:
(Pdb) s
> /full_path/task_debug.py(11)create_task()
-> title, urgency_text = task_text.split(",")
您可能会注意到命令 n(下一个)和 s(步进)很相似,因为这两个命令在大多数情况下都可以执行下一行。区别在于步进允许您进入另一个函数调用,正如您所看到的。图 13.5 显示了 n 和 s 之间的区别。

图 13.5 调试中 next 和 step 命令的区别。next 命令执行整个行;step 命令试图执行下一行,但在下一个可能的机会停止。在示例中,step 调用另一个函数。
在图 13.5 中,尽管步进命令试图执行下一行,但它会在下一个可能的机会停止。在这种情况下,这个机会是调用 obtain_text_data 函数。
13.2.4 检查相关变量
我们可以看到正在执行的内容,但我们还没有采取任何主动措施。有时,函数调用可能无法正常工作,因为它没有正确的参数。即使参数可能是正确的类型,但很可能值是不兼容的,因此我们想要检查函数内部的变量值。在本节中,我们将学习如何在函数中检查变量。我们可以将脚本(task_debug.py)的最后一行更改为 create_task(inject_bug=True),然后可以从命令行运行脚本:
> /full_path/task_debug.py(10)create_task()
-> task_text = obtain_text_data(inject_bug)
(Pdb) n
> /full_path/task_debug.py(11)create_task()
-> title, urgency_text = task_text.split(",")
(Pdb) n
> /full_path/task_debug.py(12)create_task()
-> urgency = int(urgency_text)
假设我们知道下一行将引发 AttributeError 异常。我们可以检查相关的变量以查看此异常的潜在原因:
(Pdb) p urgency_text
'3#'
如前述代码片段所示,我们可以使用命令 p 检索变量的值。如果我们想显示多个变量,我们可以按顺序列出它们,用逗号作为分隔符:
(Pdb) p urgency_text, task_text
('3#', 'Laundry,3#')
列出我们想要检查的所有变量可能会很繁琐。我们可以利用允许我们在调试器中直接调用函数的功能。在这里,我们可以调用 locals 函数,它显示局部命名空间(第 10.4 节):
(Pdb) locals()
{'inject_bug': True, 'task_text': 'Laundry,3#', 'title':
➥ 'Laundry', 'urgency_text': '3#'}
我们可以观察函数局部作用域中的所有变量,从而全面了解函数的状态。
13.2.5 讨论
追踪回溯(第 13.1 节)在您的应用程序停止执行后提供了一个快照,并且导致异常的所有事情都是瞬间发生的。这种静态信息不会给您提供逐个检查每个操作的机会;一切发生得太快了。相比之下,本节中涵盖的调试器是按需的。您决定应用程序何时可以继续执行下一行,这给您时间仔细研究每一行以识别错误的可能原因。更重要的是,调试器是交互式的,您可以选择除了 n、l、s 和 p 之外的其他选项。您可以在官方 Python 网站上了解更多关于交互式调试器的信息:docs.python.org/3/library/pdb.html。
13.2.6 挑战
戴伦是 Python 的积极学习者,他想知道几乎所有技术的细节。当他学习调试时,他想知道在函数调用期间局部命名空间中发生了什么。对于 13.2.4 中讨论的示例,他不想在运行几行代码后调用 locals 来检索局部作用域中的变量,而是希望在启动调试器后调用 locals。您预计变量列表在函数调用过程中会如何变化?
提示:命名空间是动态的。在执行创建新变量后,它会在命名空间中注册。
13.3 我如何自动测试我的函数?
在完成你程序的函数性和通过回溯或交互式调试移除明显的错误之后,你感觉你的应用程序几乎可以交付了。但你还想做一件事:彻底测试你的程序。测试是一个广泛的概念,可以以多种方式体现。当你从你的应用程序中移除任何错误时,你就是在进行测试。当你调用一些函数以确保它们在你的应用程序中按预期工作,你也是在测试。然而,这些例子是手动测试。
虽然在处理较小项目时手动测试是可以接受的,但如果你的项目范围很大,可能会非常累;每次你修改代码,你可能都必须遍历每个相关的功能,以确保它不会因为更改而损坏。正如你可以想象的那样,手动测试可能是一个耗时的因素,会延迟你的进度。幸运的是,你可以为你的应用程序开发自动测试。具体来说,你可以编写测试代码来测试应用程序的代码库。每次你修改代码库时,你都可以运行测试代码,这样可以节省大量时间。在本节中,我将向你展示一些实现自动测试的重要技术,特别关注函数。
可维护性测试是确保你的代码库可维护性的重要工具。第 13.3 节和第 13.4 节仅提供了介绍性信息。如果你的工作分配主要是关于测试,你应该查看有关测试的教育材料,例如 Roy Osherove 的《单元测试的艺术:C#示例》(Manning,2019 年)。
13.3.1 理解测试函数的基础
我们知道函数对我们应用程序至关重要。如果我们能确保每个函数都按预期工作,我们的应用程序就会很强大。本节展示了测试函数的关键要素。
让我们从简单的函数开始,当我们有更复杂的函数要测试时,我们可以在其基础上构建。假设我们的任务管理应用程序有一个以下函数,用于从字符串创建一个任务,作为 Task 类的实例对象。我们将该函数保存在 task_func.py 文件中,以便我们可以在测试中使用它,如下所示。
列表 13.4 定义要测试的函数(task_func.py)
class Task: ❶
def __init__(self, title, urgency):
self.title = title
self.urgency = urgency
def create_task(text):
title, urgency_text = text.split(",")
urgency = int(urgency_text)
task = Task(title, urgency)
return task
❶ 创建一个自定义类
对于我们项目中的特定功能(尽管我们可以使用不同的实现细节),我们通常期望对于给定的输入,函数应该返回确定的输出。无论我们如何更改 create_task 的实现细节,例如,我们应该期望以下内容为真:
assert create_task("Laundry,3").__dict__ == Task("Laundry", 3).__dict__
在这里,我们使用 assert 语句来验证我们函数的确定性。在这种情况下,我们期望这两个实例的字典表示相同。请注意,自定义类的实例默认情况下并不相等,但它们的字典表示可以作为相等性的代理进行比较。从一般的角度来看,这种特定输入产生特定输出的确定性是测试函数的基础。图 13.6 阐述了测试函数是如何工作的。

图 13.6 测试函数的一般过程。在测试函数中,我们使用特定的输入来调用函数,并将产生的输出与预期输出进行比较。
13.3.2 为测试函数创建 TestCase 子类
现在我们知道了测试函数的基础,我们就可以利用 unittest 模块(Python 标准库的一部分)来实现自动测试。此模块为我们提供了自动测试程序的重要功能。具体来说,模块的 TestCase 类允许我们测试我们的函数,如下面的列表所示。
列表 13.5 使用 TestCase 测试函数(test_task_func.py)
from task_func import Task, create_task ❶
import unittest ❷
class TestTaskCreation(unittest.TestCase): ❸
def test_create_task(self):
task_text = "Laundry,3"
created_task = create_task(task_text) ❹
self.assertEqual(created_task.__dict__,
➥ Task("Laundry", 3).__dict__)
if __name__ == "__main__":
unittest.main()
❶ 从脚本文件中导入类和函数
❷ 导入模块
❸ 继承 TestCase 类
❹ 调用要测试的函数
注意 如果你在导入类和函数时遇到问题,你可能需要在你的 Python 集成开发环境(IDE)中打开该章节的文件夹。
在列表 13.5 中,我们通过继承 TestCase 类创建了 TestTaskCreation 类。以 Test 开头命名我们的测试类是一种约定。在类的主体中,我们定义了一个实例方法,该方法被指定用于测试 create_task 函数。使用 test_ 前缀命名这个方法很重要,这样当我们运行测试时,Python 就知道应该调用这个方法。图 13.7 展示了测试类与我们要测试的函数之间的关系。
可读性 命名测试类时,以 Test 开头,并跟随着你的类要测试的具体功能。它的方法应该以 test_ 前缀命名,这样 Python 才会在测试期间运行这些方法。

图 13.7 创建一个测试类来测试一组函数。测试函数应该以 test_ 作为前缀,后跟它要测试的函数名称。类应该以 Test 作为前缀,并且是 TestCase 的子类。
test_create_task 方法使用特定的输入调用待测试的函数(create_task),并将返回值与预期输出进行比较。比较是通过调用 assertEqual 完成的,该函数断言两个 Task 类的实例在值上相等。如果这个断言为真,我们就有信心我们的函数按预期工作。在最后一行,我们调用 unittest.main(),这将运行 TestTaskCreation 类中定义的所有测试。有了这个设置,我们就准备好在命令行工具中测试我们的函数了:
$ python3 test_task_func.py
# output the following lines:
.
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK
目前,我们有一个测试用例的单元:test_create_task。但我们可以定义多个测试用例。
概念一个测试用例是测试的一个单独单元,它检查在提供特定输入集时产生的特定响应。
假设我们还有一个从字典对象创建 Task 类实例的函数。我们将此函数添加到 task_func.py 文件中,如下所示:
def create_task_from_dict(task_data):
title = task_data["title"]
urgency = task_data["urgency"]
task = Task(title, urgency)
return task
这个函数应该是直接的:它从字典对象中检索所需值并创建实例对象。我们可以更新我们的测试类来测试这个函数,如以下列表所示。
列表 13.6 测试多个函数(test_task_func.py)
from task_func import Task, create_task, create_task_from_dict
import unittest
class TestTaskCreation(unittest.TestCase):
def test_create_task(self):
task_text = "Laundry,3"
created_task = create_task(task_text)
self.assertEqual(created_task.__dict__,
➥ Task("Laundry", 3).__dict__)
def test_create_task_from_dict(self):
task_data = {"title": "Laundry", "urgency": 3}
created_task = create_task_from_dict(task_data)
self.assertEqual(created_task.__dict__,
➥ Task("Laundry", 3).__dict__)
if __name__ == "__main__":
unittest.main()
与 test_create_task 类似,我们定义了一个以 test_ 开头的方法。在这个新增的方法中,我们确保函数能够与我们所使用的特殊情况兼容。我们可以再次运行测试:
$ python3 test_task_func.py
# output the following lines:
..
----------------------------------------------------------------------
Ran 2 tests in 0.000s
OK
如您所见,我们在测试类中定义了两个方法,因此 Python 为我们运行了两个测试,并且两个都通过了。顺便说一下,您可能注意到第一行中的两个点;点的数量表示运行了测试的数量。
13.3.3 设置测试
我们已经看到我们的测试类可以一起测试两个函数。值得注意的是,这两个函数有共同点:它们都创建 Task 类的实例。当我们测试它们时,我们也创建一个 Task 类的实例,以便我们可以进行比较。如果您还记得(第 2.1.4 节),重复是可能需要重构的信号。在本节中,我们设置了测试,这可以提取测试函数中的共同点。
可维护性始终关注可能的重构机会,例如代码重复。重构可以提高代码库的可维护性。
TestClass 有一个 setUp 方法,我们可以重写它。这个方法在运行任何测试之前被调用,因此我们可以利用这个机会执行测试方法共享的操作。(请注意,这些操作取决于我们为测试设置的数据。)请参见下一列表中的示例。
列表 13.7 重写 setUp 方法(test_task_func.py)
from task_func import Task, create_task, create_task_from_dict
import unittest
class TestTaskCreation(unittest.TestCase):
def setUp(self):
task_to_compare = Task("Laundry", 3)
self.task_dict = task_to_compare.__dict__
def test_create_task(self):
task_text = "Laundry,3"
created_task = create_task(task_text)
self.assertEqual(created_task.__dict__, self.task_dict)
def test_create_task_from_dict(self):
task_data = {"title": "Laundry", "urgency": 3}
created_task = create_task_from_dict(task_data)
self.assertEqual(created_task.__dict__, self.task_dict)
if __name__ == "__main__":
unittest.main()
如列表 13.7 所示,我们通过添加一个属性来更新类。具体来说,我们正在定义 task_dict,它包含我们的测试方法将用于相等性比较的字典对象。在测试方法中,我们可以直接引用实例属性 task_dict;我们不需要为比较创建重复的实例对象。如果我们再次运行测试脚本文件,我们将看到相同的结果。
知识点:你可能已经注意到,unittest 模块中的方法使用小驼峰命名约定(例如 setUp 和 assertEqual),而不是蛇形命名法(例如 set_up 和 assert_equal)。这些方法的命名是为了保留历史原因;它们是从基于 Java 的工具中改编而来的,这些工具使用驼峰命名法。
13.3.4 讨论
在测试类的方 法中,我们只使用 assertEqual 来测试期望输出和生成输出之间的相等性。但还有其他方便的方法来断言生成的输出满足期望输出的要求。例如,assertIn(a, b) 检查 a 是否在 b 中,而 assertTrue(a) 检查 a 是否为 True。这些方法使用起来很简单,你应该熟悉它们。你可以在 unittest 模块的官方文档中找到这些方法(docs.python.org/3/library/unittest.html)。
13.3.5 挑战
亚伦正在为天气预报构建软件,并且他正在学习在他的项目中运行一些单元测试。当他跟随本节内容,其中我们定义了两个函数并使用 TestTaskCreation 类测试它们时,他被要求编写另一个函数及其相应的测试方法。假设该函数从一个元组对象 ("Laundry", 3) 创建 Task 类的实例。你能提供一个解决方案吗?
提示:你可能把这个函数命名为 create_task_from_tuple,在其中你可以使用元组解包(第 4.4 节)来获取标题和紧急程度以进行实例化。
13.4 如何自动测试一个类?
尽管函数对我们应用程序至关重要,但自定义类是我们应用程序的基石,因为它们是将必要的数据和功能捆绑成一个连贯实体的数据模型。通常,我们不需要担心测试自定义类的属性,因为这些属性应该以简单的方式定义。因此,测试一个类主要关于测试其方法,正如本节所讨论的。
13.4.1 为测试一个类创建 TestCase 子类
方法是函数,它们被称为 方法 是因为它们是在类内部定义的。因此,测试一个类的方 法归结为测试这些函数,这在第 13.3 节中得到了广泛的讨论。正如你将在本节中看到的那样,我们仍然会为测试一个类创建一个 TestCase 子类。示例使用类方法,但相同的测试原则也适用于实例和静态方法。
在 13.3 节中,我们处理了两个函数:create_task 和 create_task_from_dict。正如你可能意识到的,我们可以将它们转换为自定义方法。因为这两个方法使用构造函数来创建 Task 类的实例,所以它们是类方法的完美用例,如下一个列表所示。
列表 13.8 创建用于测试的类(task_class.py)
class Task:
def __init__(self, title, urgency):
self.title = title
self.urgency = urgency
@classmethod
def task_from_text(cls, text_data):
title, urgency_text = text_data.split(",")
urgency = int(urgency_text)
task = cls(title, urgency)
return task
@classmethod
def task_from_dict(cls, task_data):
title = task_data["title"]
urgency = task_data["urgency"]
task = cls(title, urgency)
return task
在列表 13.8 中,Task 类有 task_from_text 和 task_from_dict 类方法,它们分别从 create_task 和 create_task_from_dict 函数转换而来。
提示:类方法使用 cls 作为其第一个参数,它指的是类。参见 8.2 节。
为了测试这个类,我们将创建一个名为 TestTask 的类,它是 TestCase 类的子类,在其中我们定义了两个方法,分别对应两个类方法。将代码保存在下一个列表中,文件名为 test_task_class.py。
列表 13.9 创建用于测试类的类(test_task_class.py)
from task_class import Task
import unittest
class TestTask(unittest.TestCase):
def setUp(self): ❶
task_to_compare = Task("Laundry", 3)
self.task_dict = task_to_compare.__dict__
def test_create_task_from_text(self):
task_text = "Laundry,3"
created_task = Task.task_from_text(task_text)
self.assertEqual(created_task.__dict__, self.task_dict)
def test_create_task_from_dict(self):
task_data = {"title": "Laundry", "urgency": 3}
created_task = Task.task_from_dict(task_data)
self.assertEqual(created_task.__dict__, self.task_dict)
if __name__ == "__main__":
unittest.main()
❶ 设置测试
正如我们在 TestCreationTask 类中所做的那样,我们在 TestTask 类中定义了以 test_ 开头名称的测试方法,这样当我们运行脚本时,所有这些测试方法都会自动运行。观察以下代码片段的效果:
$ python3 test_task_class.py
..
----------------------------------------------------------------------
Ran 2 tests in 0.000s
OK
如预期,运行了两个测试,并且它们都没有问题。
13.4.2 对测试失败的反应
测试的目的是确保我们正在测试的单元按预期工作。正如你可以想象的那样,所有测试的成功从未得到保证。当某些测试失败时,我们需要知道如何对这些失败做出反应。考虑向列表 13.8 中的 Task 类添加以下函数:
def formatted_display(self):
displayed_text = f"{self.title} ({self.urgency})"
return displayed_text
这个实例方法为任务创建一个格式化的显示。为了测试这个实例方法,我们可以在 TestTask 类中添加以下测试方法(列表 13.9):
def test_formatted_display(self):
task = Task("Laundry", 3)
displayed_text = task.formatted_display()
self.assertEqual(displayed_text, "Laundry(3)")
正如你可能注意到的,为了模拟测试失败,我在 assertEqual 调用中故意省略了任务标题和紧急程度之间的空格。如果我们运行测试,我们应该期望失败:
$ python3 test_task_class.py
..F
======================================================================
FAIL: test_formatted_display (__main__.TestTask)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/full_path/test_task_class.py", line 22, in test_formatted_display
self.assertEqual(displayed_text, "Laundry(3)")
AssertionError: 'Laundry (3)' != 'Laundry(3)'
- Laundry (3)
? -
+ Laundry(3)
----------------------------------------------------------------------
Ran 3 tests in 0.001s
FAILED (failures=1)
我们没有看到代表三个成功测试的三个点,而是看到了 ..F。F 表示测试失败,失败的详细描述告诉我们测试失败的原因:由于这两个字符串之间的 AssertionError。这个错误消息应该给我们足够的信息来解决该问题。我们可以在字符串 'Laundry(3)' 中添加一个空格,使比较相等。
13.4.3 讨论
测试应该是软件开发中的一个基本步骤,以确保产品的质量。在开发过程中,你应该专注于在尽可能小的范围内消除错误。也就是说,每当完成一个功能(即使是微小的功能)时,你应该进行一些手动测试。你不应该想“我现在先进行开发,不做任何手动测试。”在处理问题时,这样做更容易。尽管自动测试可能很强大,但在解决任何出现的问题之前,你可能需要刷新你的记忆。
13.4.4 挑战
在我们的测试类中,一个失败的测试不一定是一个 AssertionError。也可能我们的代码本身存在问题。你能更新 formatted_display 方法,使其抛出异常,并在测试期间查看发生了什么吗?
提示:引发异常的最简单方法是手动进行,例如使用 raise TypeError。
摘要
-
跟踪信息提供了显示异常如何被抛出的详细信息。这些详细信息代表了一系列操作或调用。
-
当您尝试从跟踪信息中解决问题时,应该关注跟踪信息中异常被抛出的最后一个调用。
-
为了仔细检查某些代码的执行情况,您可以设置断点,这会激活调试器。pdb 模块专门设计用于交互式调试。
-
使用交互式调试器,您可以逐行移动执行线(使用 n 命令),这样我们就可以知道哪个行是问题的来源。
-
当您想要进入另一个操作,例如调用一个函数时,应该使用 s 命令而不是 n 命令,s 命令会立即执行整行。
-
unittest 模块提供了自动测试的功能。它包含 TestCase 类,您可以通过创建子类来定义自己的测试用例。
-
在创建测试方法时,您应该遵守命名规则。它应该以 test_ 开头,类应该以 Test 开头。
-
测试函数的基础是函数预期操作的确定性。当您提供一些定义好的输入时,函数应该生成输出,没有任何歧义。
-
在大多数情况下,您可以使用 assertEqual 来评估测试结果。您还可以在 TestCase 类中使用其他方法。
-
测试一个类实际上是在测试其方法,您可以将用于测试函数的相同技术应用到测试方法上。
第六部分:构建一个 Web 应用
评估棋手技能水平最好的方式是让他们与另一名棋手进行一场真正的比赛,而不是询问他们能玩多少种开局。要玩一场真正的比赛,棋手必须了解开局、中局和残局。
对于程序员来说,完成一个项目就像下棋:你必须具备全面的知识,包括(但不限于)选择合适的数据模型、编写良好的函数和定义结构良好的类。在这一部分,我们完成了在前五部分中提到的任务管理应用。我们不仅回顾了所学到的技术,还将在实际项目的背景下使用这些技术。完成一个项目总是很有趣,并且能带来成就感。你不这么认为吗?
14 完成真实项目
本章节涵盖了
-
设置虚拟环境
-
构建数据模型
-
与本地数据库一起工作
-
构建一个 Web 应用
第二章至第十二章专注于单个技术,并对相关技术进行了大量交叉引用。例如,当我介绍内置数据类型(第二章至第五章)时,我们创建了函数来执行一些重复工作。当我讨论函数(第六章和第七章)和类(第八章和第九章)时,我们使用了内置数据类型。从任务管理应用的环境中的例子,你已经看到这些技术相互依赖以解决实际问题。在学习的意义上,解决这些孤立的问题很有趣。然而,学习这些单个技术的最终目的是将它们共同使用,以从头到尾完成一个真实的项目。
在本章中,我们将从头开始完成任务管理应用项目(1.4.3 节),创建虚拟环境(14.1 节),定义适当的数据模型(14.2 节),使用后端数据库(14.3 节),实现前端应用(14.4 节),并发布我们的包以供分发(附录 E 在线)。作为一个重要的注意事项,尽管我们将学习一些新技术,例如使用本地数据库,但我们将会专注于综合我们在第二章至第十二章中学到的技术。
14.1 我如何为我的项目使用虚拟环境?
如第一章(1.2 节)所述,我们在项目中有很多选择使用开源 Python 包。我们可以使用 Python 的包安装器 pip(参见附录 B 在线)安装第三方包,这是一个命令行工具,允许你通过一行命令安装和卸载 Python 包。
默认情况下,这些包是在系统级别安装的,这意味着所有你的项目都必须共享这些包。然而,不同的项目可能需要不同版本的包,如果共享的系统级包与项目所需版本不同,那么解决这些冲突并不容易。在本节中,我将向你展示如何通过使用虚拟环境来解决这个困境。
14.1.1 理解虚拟环境的原因
虚拟环境解决了不同项目需要多个版本的包的问题。什么是虚拟环境,它能做什么?本节将回答这些问题。
首先,我将详细说明包冲突问题。当你只有一个项目时,在包的使用方面没有问题。通常,你可能同时处理多个项目——这种情况可能会引入包管理问题。在一个项目中,你使用包 A 的版本 1.0;在另一个项目中,你需要包 A 的版本 1.5,因此你将包升级到版本 1.5。你可能已经陷入了两难。当你回到第一个项目时,你的代码可能会出错,因为很可能包 A 在版本 1.5 中删除了一些功能。
你当然可以将版本降级到 1.0 以便处理第一个项目,但当你想要处理第二个项目时,你必须再次运行升级。我不认为你想进行大量的来回降级和升级。
最佳解决方案是使用虚拟环境。虚拟环境 是隔离的工作目录,在其中你可以为项目安装所需的包。因为每个项目都有自己的虚拟环境,你可以在它们各自的工作目录中安装不同的包(或不同版本的包)。此外,在高级虚拟环境管理工具如 conda 中,你可以为每个虚拟环境拥有一个独特的 Python 版本,以及不同的包,这为你提供了更大的灵活性来管理不同项目的环境,如图 14.1 所示。
概念 A 虚拟环境 是一个目录树,其中包含 Python 和第三方依赖,这些依赖与计算机上的安装(包括 Python 和第三方依赖)是隔离的。

图 14.1 为每个项目创建多个虚拟环境。在每个虚拟环境中,你可以拥有一个独特的 Python 版本和一组不同的第三方依赖。
图 14.1 展示了具有虚拟环境的三个项目。在虚拟环境中,你使用所需的 Python 版本和相应的第三方依赖。通过使用独立的虚拟环境,你不需要担心不同项目需要冲突的包版本,因为每个项目使用它自己的依赖。
可维护性 为每个项目创建一个独立的虚拟环境,以防止项目之间出现冲突的依赖。
14.1.2 为每个项目创建虚拟环境
14.1.1 节中描述的两难困境的根本原因是你因为是在系统级别安装的包,所以你在计算机上共享了这些包。如果你能为每个项目分别安装包会怎样?这正是虚拟环境的工作方式。
作为标准 Python 库的一部分,venv 模块提供了虚拟环境管理的核心功能。一些第三方工具,如 conda 和 virtualenv,可以管理 Python 中的虚拟环境。尽管它们的功能略有不同,但基本原理与内置的 venv 模块提供的类似。因此,我将使用 venv 模块来展示核心技术。
要创建一个虚拟环境,你需要打开一个命令行工具,例如 Mac 的 Terminal 或 Windows 的 cmd 工具。对于你的应用程序项目,你创建一个 taskier_app 目录,我在本章中会一直提到这个目录。导航到 taskier_app 目录(使用 cd 命令更改目录),并运行以下命令:
$ python3 -m venv taskier-env
如果你使用 Windows,你可能需要使用 python 而不是 python3,我之所以使用 python3 是因为我使用的是 Mac。该命令创建了一个名为 taskier-env 的虚拟环境,因为你正在使用这个环境来构建你的任务管理应用程序 taskier。你应该为与项目相关的环境命名,这样当你有多个环境时,你会知道哪个环境是为哪个项目准备的。也就是说,每个项目都将拥有自己的正确命名的虚拟环境,用于依赖管理,并且项目之间不会有依赖冲突。
可维护性:为项目服务的虚拟环境命名。
你会注意到目录中出现了一个名为 taskier-env 的文件夹。这个文件夹包含了创建虚拟环境所需的所有文件夹和文件。如果你好奇,bin 文件夹(仅限 macOS;在 Windows 上,你会看到一个名为 Scripts 或类似名称的文件夹)包含了环境所需的基本工具,包括指向 Python 解释器的链接、pip(第 14.1.3 节)和激活脚本(第 14.1.3 节)。
14.1.3 在虚拟环境中安装包
你明白虚拟环境是项目隔离的工作目录,并且安装此项目所需的任何包都不会影响其他项目,这是安全的。在本节中,我将向你展示如何在虚拟环境中安装包。
首先,为项目创建一个名为 taskier-env 的虚拟环境。要使用此环境,请运行以下命令:
# for Mac:
$ source taskier-env/bin/activate
# for Windows:
> taskier-env\Scripts\activate.bat
注意:如果命令在你的命令行工具中不起作用,请参阅官方 Python 网站的此页面以获取进一步说明:docs.python.org/3/library/venv.html。
该命令 激活 虚拟环境,允许你在虚拟环境中安装包。你会看到命令行有一个虚拟环境名称作为前缀(taskier-env),这表示环境已激活并准备好安装包。
最常见的 Python 软件包安装工具是 pip;您可以在附录 B 在线找到有关如何使用 pip 的详细说明。简而言之,您将为任务管理应用程序安装 streamlit 库,该库将为项目提供构建前端作为网络应用程序的工具。我选择这个库是因为使用它很容易构建网络应用程序,这使您能够专注于内容而不是网络元素布局。此命令安装 streamlit(在本书编写时为版本 1.10.0):
$ pip install streamlit==1.10.0
为了最佳的可重复性,我建议您安装相同的版本。然而,您的网络应用程序仍然可以使用最新的 streamlit 版本运行。
14.1.4 在 Visual Studio Code 中使用虚拟环境
对于这个项目,你将使用 Visual Studio Code (VSC) 作为你的编码工具,因为它是一个具有强大扩展能力的开源集成开发环境 (IDE)。(有关安装说明,请参阅附录 A 在线内容。)在本节中,我将向你展示如何在 VSC 中使用虚拟环境。
在 VSC 中打开项目目录(taskier_app);按 Cmd+Shift+P(Mac)或 Ctrl+Shift+P(Windows)以显示命令菜单;并输入 Python: Select Interpreter,这将显示可用的虚拟环境列表。您应该能够在列表中看到虚拟环境 taskier-env。选择 'taskier-env': venv 选项(图 14.2)。
注意:您需要通过选择 VSC 中的文件 > 打开文件夹来打开项目目录(taskier_app)。否则,您可能看不到列表中的环境。

图 14.2 在正确的虚拟环境中选择合适的解释器。请注意,您可能看不到计算机上的其他选项;此图显示了我在计算机上可用的虚拟环境的完整列表。
为了验证您确实正在使用此环境,在父目录(taskier_app)中创建一个文件(例如,test_env.py)。当您打开此文件时,您应该在 VSC 窗口的底部看到状态栏,如图 14.3 所示。

图 14.3 显示在 VSC 中运行 Python 的状态栏,包括 Python 版本、虚拟环境和环境的创建工具(venv)。
请注意,尽管您的项目将使用 Python 3.10.4 完成,但它应该与早期版本(Python 3.8 及以后版本)兼容,因为我所涵盖的技术是 Python 的稳定核心功能。
14.1.5 讨论
venv 模块提供了创建虚拟环境的基本功能,并且使用方便,因为它包含在标准 Python 库中。然而,该模块有一个缺点:默认情况下,它使用系统范围内的 Python。如果你想为你的项目使用特定的 Python 版本,你应该使用其他虚拟环境管理工具,例如 conda。使用 conda,你可以享受到与 venv 相同的安装环境特定包的所有好处。此外,你可以在虚拟环境中拥有独立的 Python 安装,这为你提供了在项目配置方面(就 Python 版本和第三方包而言)更大的灵活性。
使用 conda 进行虚拟环境管理
要为你的项目拥有一个独立的 Python 解释器,你可以使用 conda 来管理虚拟环境。你可以在其官方网站上找到安装说明:conda.io。安装 conda 后,你可以使用它在你喜欢的命令行工具中创建虚拟环境。
对于你的项目,你拥有 Python 3.10.4 和 streamlit 1.10.0 的依赖。你可以使用以下命令来创建所需的虚拟环境:
conda create -n taskier-env python=3.10.4 streamlit=1.10.0
(请注意,如果你在用 venv 创建虚拟环境后运行以下代码,你可能会看到两个具有相同名称但不同文件路径的虚拟环境。)运行此命令后,你可以通过运行 conda activate taskier-env 来激活此环境,然后在这个虚拟环境中工作。要在 VSC 中设置环境,请显示 Python 解释器列表,并选择 taskier-env 环境中的那个。
14.1.6 挑战
杰瑞在一家房地产公司担任数据科学家。他知道为他的项目拥有独立的虚拟环境是个好主意。作为一个练习,他如何创建一个名为 python-env 的虚拟环境?在这个环境中,他需要安装 pandas 库。安装后,他还想配置 VSC 使用这个环境。
提示:遵循本节中涵盖的说明。
14.2 如何为我的项目构建数据模型?
任何应用程序的核心都是数据,尽管数据有多种形式,如文本和图像。无论数据的形式如何,当我们构建应用程序时,我们通常定义自定义类来表示数据作为属性。我们通过自定义类内的函数或方法准备和处理数据。这些数据和相关的操作统称为应用程序的 数据模型。在本节中,我们将回顾我们在任务管理应用程序中使用的数据模型。
14.2.1 识别业务需求
数据模型应该服务于我们项目的业务需求。为了正确构建数据模型,我们首先必须确定任务管理应用的功能。该应用是一个演示项目,所以我会包括足够的功能作为骨干来展示 Python 的基本技术。请注意,我不想使应用过于复杂,这会使我们难以专注于学习这些基本技术。
在我们的应用中,用户可以创建一个新的任务,查看任务列表,编辑任务,以及删除任务。如果用户能够根据特定标准对任务进行排序和筛选,那将非常有帮助。图 14.4 总结了这些功能。

图 14.4 任务管理应用的关键功能。在应用中,用户可以创建新的任务;查看、排序和筛选任务;查看任务的详细信息;以及删除任务。
如图 14.4 所示,每个任务都有一些属性:标题(title)、描述(desc)、紧急程度(urgency)和状态(status)。当你构建具有更多功能的真实应用时,你需要以这种方式设计应用的用户界面(前端),以便确定你是否拥有所有需要的功能以及这些功能如何交互。对于我们的任务管理应用,我会保持界面简单,专注于编码部分而不是应用界面设计。
查看我们将构建一个网络应用作为我们的应用前端。由于 streamlit 框架帮助我们布局网络应用的元素(如文本显示和输入框),我们将使用它作为我们的工具。
14.2.2 创建辅助类和函数
在分析任务类(第 14.2.3 节)的代码之前,我想在本节中介绍所需的辅助类和函数。我们将创建一个名为 taskier.py 的文件来存储任务类。在这个文件的头部,我们正在导入必要的依赖项如下(并且请注意,当讨论相关代码时,我会介绍这些模块的使用):
import csv
import re
import sqlite3
from enum import IntEnum, Enum
from pathlib import Path
from random import choice
from string import ascii_lowercase
一个任务有三个可能的状态:创建、进行中和完成。我们将使用枚举来表示这些状态:
class TaskStatus(IntEnum):
CREATED = 0
ONGOING = 1
COMPLETED = 2
@classmethod
def formatted_options(cls):
return [x.name.title() for x in cls]
在第 9.1 节中,我们通过子类化 Enum 类学习了枚举。在这里,我们子类化 IntEnum 类,它类似于 Enum 类,但有一个额外的优点:我们可以对状态进行排序,因为它们的原始值是整数。在这个枚举类中,我们定义了一个类方法(第 8.2 节),它创建了一个字符串列表,用于在我们的网络应用中(第 14.4 节)使用。
在第 11.2 节中,我们学习了如何使用 csv 模块处理表格数据。为了展示相关技术,我将使用 CSV 文件作为数据源,尽管 CSV 文件通常不是首选的数据库;作为正式的数据库选择,我将在第 14.3 节中展示如何使用 SQLite。为了在 Web 应用中包含这两种选项,我们可以使用枚举类:
class TaskierDBOption(Enum):
DB_CSV = "tasks.csv"
DB_SQLITE = "tasks.sqlite"
app_db = TaskierDBOption.DB_CSV.value
我们创建了一个全局变量 app_db 来跟踪数据库选项。现在我们将其默认设置为 CSV 文件选项。在 Web 应用程序中,为了演示目的,我们让用户选择数据库选项,并使用下一列表中的函数来更新数据库选择。
列表 14.1 设置应用程序的数据库选项
def set_db_option(option):
global app_db
app_db = option
db_path = Path(option)
if not db_path.exists(): ❶
Task.load_seed_data()
elif app_db == TaskierDBOption.DB_SQLITE.value:
Task.con = sqlite3.connect(app_db)
❶ 检查路径是否存在
由于我们正在更改全局作用域中的变量,我们需要在更改它之前使用 global 关键字(第 10.4 节)。如果文件不存在于该路径,我们将创建数据文件并加载一些用于演示的种子数据,使用 Task 的 load_seed_data 方法(第 14.2.3 节)。尽管我将在第 14.3 节中更多地讨论 SQLite 数据库,但列表 14.1 包含一行代码(Task.con = sqlite3.connect(app_db)),当数据库选项为 SQLite 时,它将创建到数据库的连接。
从异常处理的角度来看,我们将创建自己的异常类,允许我们引发自定义异常。如第 12.5 节所述,我们的异常类是 Exception 类的子类:
class TaskierError(Exception):
pass
由于我们可以在使用此类时提供自定义错误消息,因此我们不需要实现任何方法;我们将使用 pass 语句来满足语法要求。请注意,如果我们想提供更具体的异常,我们可以从 TaskierError 类创建子类。
14.2.3 创建 Task 类以满足这些需求
我们已经确定了应用程序的核心功能,并准备好实现 Task 类以满足我们的业务需求。在本节中,我们将构建 Task 类。为了便于教学,我将直接分析代码,并侧重于单个方法。
创建和保存任务
在我们的应用程序中,每个任务都被建模为 Task 类的一个实例。我们创建实例对象来建模任务。在本节中,我将向您展示创建并保存实例对象到文件的代码。
初始化方法允许我们为实例对象定义自定义属性(第 8.1 节)。我们重写 init 方法来配置实例化。在定义中,我们为每个参数使用类型提示(第 6.3 节)。我们还通过使用 Google 风格(第 6.5.1 节)为该方法提供文档字符串:
class Task:
def __init__(self, task_id: str, title: str, desc: str, urgency:
➥ int, status=TaskStatus.CREATED, completion_note=""):
"""Initialize the instance object of the Task class
Args:
task_id (str): The randomly generated string as the identifier
title (str): The title
desc (str): The description
urgency (int): The urgency level, 1 - 5
status (_type_, optional): The status. Defaults to
➥ TaskStatus.CREATED.
completion_note (str, optional): The note when a task is
➥ completed. Defaults to "".
"""
self.task_id = task_id
self.title = title
self.desc = desc
self.urgency = urgency
self.status = TaskStatus(status)
self.completion_note = completion_note
我们使用表单来收集标题、描述和紧急程度,然后使用这些信息来创建 Task 类的实例。正如您在以下代码片段中可以看到的,task_from_form_entry 是一个类方法,因为我们不需要访问或操作每个实例的数据。相反,此方法访问类的构造函数:
@classmethod
def task_from_form_entry(cls, title: str, desc: str, urgency: int):
"""Create a task from the form's entry
Args:
title (str): The task's title
desc (str): The task's description
urgency (int): The task's urgency level (1 - 5)
Returns:
Task: an instance of the Task class
"""
task_id = cls.random_string()
task = cls(task_id, title, desc, urgency)
return task
注意,我本可以指定类方法的返回类型为 Self,它指的是类,但它在 Python 3.11 之前不可用。为了与早期 Python 版本兼容,我省略了返回类型的类型提示。
在这个类方法中,我们调用 random_string 方法来获取一个随机字符串作为新任务的 ID 号。因为随机字符串的生成可以是其他目的的实用函数,所以我们将其实现为静态方法,因为它不使用类或实例相关的属性:
@staticmethod
def random_string(length=8):
"""Create a random ASCII string using the specified length
Args:
length (int, optional): The desired length for the random
➥ string. Defaults to 8.
Returns:
str: The random string
"""
return "".join(choice(ascii_lowercase) for _ in range(length))
在这种方法中,我们使用小写 ASCII 字符集(从 string 模块导入)作为我们的源,使用 random 模块中的 choice 函数随机选择八个字符,并使用 join 方法(第 2.3 节)将这些字符连接起来。当我们创建实例后,我们需要将其保存到数据库中,可以使用 save_to_db 方法,如下所示。
列表 14.2 将记录保存到数据库
def save_to_db(self):
"""Save the record to the database
"""
if app_db == TaskierDBOption.DB_CSV.value:
with open(app_db, "a", newline="") as file:
csv_writer = csv.writer(file)
db_record = self._formatted_db_record()
csv_writer.writerow(db_record)
else:
# operations when the database is the SQLite3
pass
def _formatted_db_record(self):
db_record = (self.task_id, self.title, self.desc, self.urgency,
➥ self.status.value, self.completion_note)
return db_record
我们使用 with 语句(第 11.1 节)以追加模式打开 CSV 文件。使用 CSV writer,我们可以将一行数据写入 CSV 文件。正如你可能注意到的,我们调用受保护的方法 _formatted_db_record 来获取我们打算写入文件的记录。下划线前缀表示该方法是非公开的(第 8.3.1 节)。
从数据源读取任务
当我们在数据库中有多个任务时,是时候读取和显示任务了。为了从数据库中加载任务,我们创建 load_tasks 方法,如下一列表所示。
列表 14.3 从数据库加载任务
@classmethod
def load_tasks(cls, statuses: list[TaskStatus]=None, urgencies:
➥ list[int]=None, content: str=""):
"""Load tasks matching specific criteria
Args:
statuses (list[TaskStatus], optional): Filter tasks with
➥ the specified statuses.
Defaults to None, meaning no requirements on statuses
urgencies (list[int], optional): Filter tasks with the
➥ specified urgencies.
Defaults to None, meaning no requirements on urgencies
content (str, optional): Filter tasks with the specified
➥ content (title, desc, or note).
Defaults to "".
Returns:
list[Task]: The list of tasks that match the criteria
"""
tasks = list()
if app_db == TaskierDBOption.DB_CSV.value:
with open(app_db, newline="") as file:
reader = csv.reader(file)
for row in reader:
task_id, title, desc, urgency_str, status_str, note = row
urgency = int(urgency_str)
status = TaskStatus(int(status_str))
if statuses and (status not in statuses):
continue
if urgencies and (urgency not in urgencies):
continue
if content and all([note.find(content) < 0,
➥ desc.find(content) < 0, title.find(content) < 0]): ❶
continue
task = cls(task_id, title, desc, urgency, status, note)
tasks.append(task)
else:
# using the SQLite as the data source
pass
return tasks
❶ 使用 find 搜索子字符串
注意:类型提示使用 list[TaskStatus]在 Python 3.9 及以后版本中可用。如果你遇到与此使用相关的异常,很可能是你正在使用较旧的 Python 版本。
在列表 14.3 中,我想强调以下技术:
-
从文件创建的 CSV reader 可以用作生成器(第 11.2 节),其中每个项目代表一行数据。
-
我们使用元组解包(第 4.4 节)依次获取六个数据元素。这些元素都是字符串形式。
-
我们通过使用 int 和 TaskStatus 构造函数分别获取所需的 urgency 和 status 属性。请注意,我们本可以使用 try...except...语句来获取数据,但我们确信数据完整性,因此转换应该可以工作。当我们处理外部数据时,我们应该使用异常处理技术。
-
当我们搜索子字符串时,我们更喜欢使用 find,因为它不会引发异常,与 index 方法(第 4.3.2 节)不同。
-
与子字符串搜索相关,内置的 all 函数如果列表中的所有项都被评估为 True,则返回 True。整个行意味着如果函数调用指定了 content 参数,并且我们在 note、desc 或 title 中找不到任何匹配项,我们将通过触发 continue 语句跳过当前行。
-
因为我们的应用程序允许用户选择符合特定标准(包括状态、紧急程度和内容,即标题、描述和完成备注)的任务,我们希望定义一个 load_tasks 方法,它可以加载不仅所有任务,还可以是任务的一个子集。如果 statuses 参数不为 None,并且当前行的状态不在 statuses 中,我们可以通过调用 continue 语句跳过当前行。同样的逻辑适用于 urgencies 和 content 参数。
更新数据源中的任务
当用户对任务进行更改时,我们需要更新数据库中的记录。为此,我们使用 update_in_db 方法,如下所示。
列表 14.4 在数据库中更新记录
def update_in_db(self):
"""Update the record in the database
"""
if app_db == TaskierDBOption.DB_CSV.value:
updated_record = f"{','.join(map(str,
➥ self._formatted_db_record()))}\n"
with open(app_db, "r+") as file:
saved_records = file.read()
pattern = re.compile(rf"{self.task_id}.+?\n") ❶
if re.search(pattern, saved_records):
updated_records = re.sub(pattern,
➥ updated_record, saved_records)
file.seek(0)
file.truncate()
file.write(updated_records)
else:
raise TaskierError("The task appears to be
➥ removed already!")
else:
# using the SQLite as the data source
pass
❶ 编译正则表达式模式
在这个方法中,我想向你展示正则表达式的实用性。本质上,我们从 CSV 文件中读取所有文本数据。模式是搜索以任务 ID 数字开头并以换行符结尾的字符串。替换是我们通过调用 _formatted_db_record 方法获得的更新记录。请注意,因为我们正在将文本数据写入文件,所以我们需要使用 map 函数(第 7.2.2 节)将格式化记录的数据转换为字符串。
从性能的角度来看,我们可以直接替换更新记录,而无需搜索其存在。但由于我们应用程序的设计(第 14.4.3 节),用户可能正在尝试更新一个已经被删除的任务。为了满足这一需求,当记录不存在时,我们抛出一个异常。
尽管我们没有机会在 11.1 节中讨论 seek 和 truncate 方法,但它们很容易理解。本质上,我们调用 seek(0)将文件流的指针移动到开始位置,并调用 truncate 来删除所有文本数据。当文件为空时,我们可以将 updated_records 写入文件。
从数据源中删除任务
如果用户想要删除一个任务,他们可以这样做。我们可以定义 delete_from_db 方法来满足这一需求,如下所示。
列表 14.5 从数据库中删除记录
def delete_from_db(self):
"""Delete the record from the database
"""
if app_db == TaskierDBOption.DB_CSV.value:
with open(app_db, "r+") as file:
lines = file.readlines()
for line in lines:
if line.startswith(self.task_id):
lines.remove(line)
break
file.seek(0)
file.truncate()
file.writelines(lines)
else:
# using the SQLite as the data source
pass
在这个方法中,我们调用 readlines(第 11.1 节)以列表对象的形式获取文本数据。我们使用这个方法是因为列表对象是可变的(第 3.1 节),允许我们删除一个任务。对于每一行,我们检查它是否以任务 ID 数字开头,当我们找到它时,我们调用 break 语句立即退出 for 循环。在 lines 对象更新后,我们可以通过调用 writelines 方法将其写回文件。
我们定义了一个名为 load_seed_data 的方法,用于加载一些任务,以便应用程序可以显示一些数据。在这个方法中,我们创建了三个任务,并通过调用 save_to_db 方法将它们保存到数据库中:
@classmethod
def load_seed_data(cls):
"""Load seeding data for the web app
"""
task0 = cls.task_from_form_entry("Laundry", "Wash clothes", 3)
task1 = cls.task_from_form_entry("Homework", "Math and physics", 5)
task2 = cls.task_from_form_entry("Museum", "Egypt things", 4)
for task in [task0, task1, task2]:
task.save_to_db()
最后但同样重要的是,我们定义了字符串表示方法 str 和 repr(第 8.4 节):
def __str__(self) -> str:
stars = "\u2605" * self.urgency
return f"{self.title} ({self.desc}) {stars}"
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.task_id!r},
➥ {self.title!r}, {self.desc!r}, {self.urgency},
➥ {self.status}, {self.completion_note!r})"
备注 _str_ 用于信息目的,而 _repr_ 用于编码开发目的,如果您想知道这两种方法之间的区别。
14.2.4 讨论
我们的数据模型应该满足我们的业务需求。在实现我们的数据模型之前,确定应用程序的功能非常重要。尽管我展示了代码的最终版本,但到达这个版本花费了我相当多的时间和多次代码迭代。当您在处理任何项目时,请对自己有耐心。
PEEK 任务类为我们将在 14.4 节中构建的 Web 应用程序提供服务。
14.2.5 挑战
当凯西在学习这本书时,她编写了所有代码来学习这本书涵盖的所有主题。当她处理任务类时,她认为用户可能会尝试删除已从数据库中删除的任务。她如何更新 delete_from_db 方法,使其在记录不存在时引发异常?
提示 您可以在执行所需操作之前检查记录是否已被定位。
14.3 如何将 SQLite 用作我的应用程序数据库?
数据库为您的应用程序存储数据。根据您应用程序的性质,例如数据量和处理需求,您有多种数据库选择——例如 Microsoft SQL、Oracle、MySQL 和 PostgreSQL。这些选项通常用于企业级应用程序,设置基础设施和维护其性能需要时间和资源。与这些企业数据库解决方案不同,SQLite 是一种轻量级数据库,几乎不需要在您的计算机上进行设置,因为它直接使用您的计算机磁盘作为存储机制。在本节中,我将向您展示如何使用 SQLite 作为我们应用程序的数据库。
14.3.1 创建数据库
创建 SQLite 数据库几乎是瞬时的,只需要调用几个函数。具体来说,我们将使用内置的 sqlite3 模块,它位于标准 Python 库中。此模块提供了创建和操作 SQLite 数据库所需的所有应用程序编程接口 (API)。我们将从创建数据库开始。
因为数据库被任务类的所有实例共享,所以我们将数据库连接定义为类属性。通过这个连接,我们将执行所有数据库相关操作,如数据查询和更新。我们不直接在物理级别上工作在数据库上,因为我们希望其他进程在必要时可以使用数据库。因此,我们建立连接,就像创建文件对象一样工作,而不是直接操作文件:
class Task:
con: sqlite3.Connection
要创建数据库,我们定义 create_sqlite_database 方法:
@classmethod
def create_sqlite_database(cls):
"""Create the SQLite database
"""
with sqlite3.connect(TaskierDBOption.DB_SQLITE.value) as con:
cls.con = con ❶
cursor = con.cursor()
cursor.execute("CREATE TABLE task (task_id text, title text,
➥ desc text, urgency integer, status integer, completion_note text);")
❶ 将其保存为类变量
在此方法中,我们执行两个操作:
-
通过调用 connect 函数,我们正在建立与指定路径数据库的连接。值得注意的是,如果路径上不存在数据库,此函数调用也会创建数据库。我们使用 with 语句,它创建一个上下文管理器来自动提交执行。
-
我们正在向数据库添加一个新的表, task *。请注意,此代码仅在不存在数据库时运行。命令是 CREATE TABLE table_name (field0_name field0_type, field1_name field1_type, ...)。你可能还会注意到,我们创建了一个游标来运行该语句——这是 SQLite 和 SQL 数据库中的一般标准操作。
我们打算在用户设置数据库选项时调用这个 create_sqlite_database 方法,因此我们需要更新列表 14.1 中的 set_db_option 函数,如下所示:
def set_db_option(option):
global app_db
app_db = option
db_path = Path(option)
if not db_path.exists():
if app_db == TaskierDBOption.DB_SQLITE.value:
Task.create_sqlite_database()
Task.load_seed_data()
elif app_db == TaskierDBOption.DB_SQLITE.value:
Task.con = sqlite3.connect(app_db)
我将添加的代码加粗了,这是一个简单的调用 create_sqlite_database 方法,当数据库不存在时。作为对 elif 部分的补充说明,当 SQLite 数据库存在且数据库选择为 SQLite 时,我们建立与数据库的连接。在我们跳入代码以使用 SQLite 数据库执行数据操作之前,快速看一下图 14.5,它描述了最常见的操作。

图 14.5 使用 SQLite 数据库的常见操作:查询、插入、更新和删除。查询从数据库中检索记录,插入将新记录保存到数据库中,更新更新现有记录,删除删除现有记录。
如图 14.5 所示,当我们使用 SQLite 数据库(或任何数据库)时,我们执行四个常见的操作:查询(从数据库中检索记录)、插入(将新记录保存到数据库中)、更新(更新现有记录)和删除(从数据库中删除记录)。以下各节将分别介绍这四个操作。
14.3.2 从数据库中检索记录
为了在我们的应用程序中显示数据,我们需要从数据库中检索记录。我们看到了如何使用 csv 模块从 CSV 文件中读取数据(第 14.2.3 节)。这里,我将向您展示如何使用 SQLite 数据库检索数据。
我们已经定义了 load_tasks 方法(列表 14.3)来获取任务数据。现在我们将更新此方法,使其能够与 SQLite 数据库一起工作(列表 14.6)。请注意,我只展示了与从 SQLite 数据库读取数据相关的代码,省略了使用 CSV 文件的代码。
列表 14.6 从 SQLite 数据库加载数据
@classmethod
def load_tasks(cls, statuses: list[TaskStatus]=None,
➥ urgencies: list[int]=None, content: str=""):
"""The docstring as before
"""
tasks = list()
if app_db == TaskierDBOption.DB_CSV.value:
# csv-related code from listing 14.3
pass
else:
with cls.con as con:
if statuses is None:
statuses = tuple(map(int, TaskStatus))
else:
statuses = tuple(statuses) * 2
if urgencies is None:
urgencies = tuple(range(1, 6))
else:
urgencies = tuple(urgencies) * 2
sql_stmt = f"SELECT * FROM task WHERE status in {statuses}
➥ and urgency in {urgencies}"
if content:
sql_stmt += f" and ((completion_note LIKE '%{content}%')
➥ or (desc LIKE '%{content}%') or (title LIKE
➥ '%{content}%'))"
cursor = con.cursor()
cursor.execute(sql_stmt)
tasks_tuple = cursor.fetchall()
tasks = [Task(*x) for x in tasks_tuple]
return tasks
注意以下关于此代码的要点:
-
因为我想创建一个 SQL 语句来处理两种情况——所有任务(不使用过滤条件)和任务子集(使用过滤条件)——所以我通过运行 statuses = tuple(map(int, TaskStatus))列出当 statuses 参数为 None 时的所有状态。
-
相似的逻辑也适用于 urgencies 参数。当用户想要检索所有任务时,我们要求记录的紧急程度字段在 1-5 的范围内,这是紧急程度可能的范围。
-
需要理解的一个复杂部分是,当状态和紧急程度不为 None 时,我使用 tuple(statuses) * 2 和 tuple(urgencies) * 2。我这样做是为了满足 SQL 语句语法要求,当用户只选择一个状态或紧急程度的项目时。具体来说,如果用户从输入中指定一个紧急程度级别,例如 2,那么我们将有一个只有一个元素的元组对象 (2,)。直接使用这个元组对象在 sql_stmt 中是无效的,因此我们复制元组对象中的项目,将 (2,) 改为 (2,2),这是一个有效的 SQL 语句。
-
LIKE 操作是 SQL 语法,用于获取与指定子串匹配的记录。只有当内容参数被设置时,我们才更新 sql_stmt。内容部分如果字符串包含任何字符,则评估为 True。
-
fetchall 函数根据执行的 SQL 语句检索所有记录作为一个列表对象。每条记录以 (task_id, title, desc, urgency, status, completion_note) 的形式作为元组对象返回。使用列表推导,我们将这些元组对象转换为 Task 实例对象。
-
在将元组对象转换为实例的过程中,我们使用星号操作,该操作解包元组对象并将项目发送到构造函数。
14.3.3 将记录保存到数据库
当我们创建了记录后,我们需要将它们保存到数据库中。我们可以逐个保存记录,也可以一次性保存所有记录。在本节中,我将展示这两种技术。
列表 14.2 定义了 CSV 文件作为数据源的 save_to_db 方法。我们将更新此方法以使其与 SQLite 数据库兼容(列表 14.7)。
列表 14.7 保存记录到 SQLite 数据库
def save_to_db(self):
"""Save the record to the database
"""
if app_db == TaskierDBOption.DB_CSV.value:
# operations when the database is the CSV file
pass
else:
with self.con as con:
cursor = con.cursor()
sql_stmt = f"INSERT INTO task VALUES (?, ?, ?, ?, ?, ?);"
cursor.execute(sql_stmt, self._formatted_db_record())
将记录保存到 SQLite 数据库的语法是 INSERT INTO table VALUES (?, ?, ...)。问号代表占位符,占位符的数量(在我们的例子中是六个)应与记录中的项目数量相匹配,这些项目是通过调用 _formatted_db_record 获得的。请注意,您可以在不使用占位符的情况下执行语句,就像我们在列表 14.6 中所做的那样。如果您使用占位符,您将这些值指定为 execute 函数调用中的第二个参数。
另一点需要注意的是,我们调用 self.con 来获取数据库的连接。尽管我们将 con 定义为类属性,但当我们访问实例的 con 属性时,它使用类属性作为后备。
如果我们想在单个 SQL 语句中保存多条记录,我们应该怎么做?这个功能是支持的。我们不是调用 execute,而是调用 executemany 函数。在函数调用中,第二个参数是记录列表。虽然我们不会在 Task 类(实例方法 save_to_db 对于演示目的已经足够)中实现它,但下一个列表显示了如何将多条记录保存到 SQLite 数据库中。
列表 14.8 将多条记录保存到 SQLite 数据库
task0 = Task.task_from_form_entry("Laundry", "Wash clothes", 3)
task1 = Task.task_from_form_entry("Homework", "Math and physics", 5)
task2 = Task.task_from_form_entry("Museum", "Egypt things", 4)
with Task.con as con:
cursor = con.cursor()
tasks = [task0, task1, task2]
formatted_records = [task._formatted_db_record() for task in tasks]
sql_stmt = f"INSERT INTO task VALUES (?, ?, ?, ?, ?, ?);"
cursor.executemany(sql_stmt, formatted_records)
14.3.4 在数据库中更新记录
我们的任务管理应用允许用户编辑任务。编辑任务后,我们需要更新数据库中的记录。本节展示了如何在 SQLite 数据库中更新记录。
update_in_db 方法负责更新记录。以下代码更新了该方法,以包含 SQLite 数据库部分的代码:
def update_in_db(self):
"""Update the record in the database
"""
if app_db == TaskierDBOption.DB_CSV.value:
# operations when the database is the CSV file
pass
else:
with self.con as con:
cursor = con.cursor()
count_sql = f"SELECT COUNT(*) FROM task WHERE
➥ task_id = {self.task_id!r}"
row_count = cursor.execute(count_sql).fetchone()[0] ❶
if row_count > 0:
sql_stmt = f"UPDATE task SET task_id = ?, title = ?,
➥ desc = ?, urgency = ?, status = ?, completion_note = ?
➥ WHERE task_id = {self.task_id!r}"
cursor.execute(sql_stmt, self._formatted_db_record())
else:
raise TaskierError("The task appears to be
➥ removed already!")
❶ 计算现有记录数
注意,我们首先检查与任务 ID 号匹配的记录数,这个数字应该是 1——因此,大于 0。如果记录已被删除,我们将抛出一个异常来指示这一点,就像我们在 14.4 列表中实现此方法时使用 CSV 文件作为数据源时所做的那样。
在 SQLite 数据库中更新记录的语法是 UPDATE table SET field0_name = ?, field1_name = ?, ... WHERE condition。在这个语法中,我们不应该省略 WHERE 子句,它用于过滤记录;如果我们省略了它,我们可能会意外地更新所有记录。再次强调,我们仍在使用占位符为 execute 函数调用。在子句中,我们使用 !r 作为转换指定 task_id,它将任务 ID 以单引号 ('example_id') 的形式输出,而不是 example_id。
14.3.5 从数据库中删除记录
我们的任务管理应用允许用户删除任务。当任务被删除时,我们需要从数据库中删除记录。在本节中,我将展示如何解决这个问题。
delete_from_db 方法负责删除一条记录。以下代码更新了该方法,以包含 SQLite 数据库部分的代码:
def delete_from_db(self):
"""Delete the record from the database
"""
if app_db == TaskierDBOption.DB_CSV.value:
# operations when the database is the CSV file
pass
else:
with self.con as con:
cursor = con.cursor()
cursor.execute(f"DELETE FROM task WHERE task_id =
➥ {self.task_id!r}")
在 SQLite 数据库中删除记录的语法是 DELETE FROM table WHERE condition。需要注意的是,我们仍然使用 !r 为任务的 ID 号创建一个单引号内的字符串。
14.3.6 讨论
由于 SQLite 是一个配置简单的轻量级数据库,我们可以在原型化我们的应用程序时使用它。当我们将应用程序迁移到生产环境时,我们可以通过使用更大的数据库(如 Oracle 和 MySQL)来升级它。尽管我专注于文本和整数作为数据类型,这满足了我们的业务需求,但 SQLite 有局限性。一方面,它不支持所有数据类型,例如日期和布尔值。作为解决方案,我们可以使用格式为 MMDDYYHHMMSS 的字符串,这是自参考日期以来的秒数,以及用于 false 和 true 的整数 0 和 1。
14.3.7 挑战
我们已经看到,我们可以使用 CSV 文件和 SQLite 作为我们的数据库选项。你能编写一个装饰器来记录调用方法所需的时间吗?你可以通过使用 CSV 文件或 SQLite 数据库进行数据相关操作来比较哪个更快。
提示第 7.3 节讨论了创建装饰器。
14.4 如何构建网络应用程序作为前端?
网络应用程序是许多编程项目的热门选择。它们最重要的好处是跨平台兼容性。它们可以在任何网络浏览器上运行,这意味着你可以在任何电脑、任何智能手机,甚至任何支持网络浏览器的电视上访问该应用程序。此外,由于网络应用程序在浏览器上运行,因此客户端无需安装和配置,所有网络应用程序的功能都作为网页元素加载。
如你所知,网络应用程序为任何业务提供了最有吸引力的出口。在本节中,我将展示如何使用 streamlit,一个用于网络开发的第三方 Python 框架,来构建网络应用程序。请注意,这个框架提供了一系列功能,我不会提供使用此框架的全面教程。相反,我将专注于以网络应用程序的形式实现我们的任务管理应用程序的功能。
14.4.1 理解 streamlit 的基本功能
要使用 streamlit 创建网络应用程序,你应该对这个框架有一个很好的理解。在本节中,我将介绍这个框架的基本知识。
在我们的虚拟环境中(taskier-env;第 14.1 节)安装 streamlit 之后,除了在我们的 Python 文件中使用该框架之外,streamlit 的安装还包括使用基于命令行的功能——也就是说,我们可以使用命令行工具作为接口来调用与操作使用 streamlit 构建的网络应用程序相关的操作。最重要的命令是 streamlit run taskier_app.py。正如其名称所示,此命令使用 taskier_app.py 作为源文件,在默认网络浏览器中启动一个网络应用程序。
Streamlit 的第一个基本功能是将 Python 脚本文件转换为网络应用程序。这就是为什么 streamlit 是 Python 开发者流行的网络框架选择的主要原因。如果你知道 Python,你可以使用 streamlit 来构建网络应用程序。
Streamlit 的另一个基本功能是自动布局网页元素,如按钮和文本输入框。这个框架提供了一些常见的网页元素(小部件)。图 14.6 展示了框架中实现的可用小部件。请注意,这些小部件可能在 streamlit 框架的最新版本中有所变化。

图 14.6 streamlit 框架中的可用小部件。六个类别是可点击的(按钮)、单选或复选、数值数据、文本数据、多媒体和日期和时间。
我不会讨论如何使用小部件,因为它们的使用很简单;此外,您可以在 streamlit.io/ 找到说明。我将在 14.4.2 节中展示一些截图。您会看到,当您在脚本中使用这些小部件时,您需要指定小部件类型以及必要的配置,例如按钮上显示的文本,将元素布局的繁重工作留给框架。
Streamlit 框架的另一个显著特点是,当输入有任何变化时(例如,用户选择了单选小部件的选项),整个脚本将线性地(从上到下)重新加载。这个特性是这个框架执行模型的核心。一些初学者可能会感到沮丧,因为他们的使用 Web 应用的经验告诉他们,当点击单选小部件的选项时,页面不会自动重新加载。尽管在某些用例中这可能是一个缺点,但我们有一个解决方案来解决这个问题:会话状态(第 14.4.3 节)。
14.4.2 理解应用程序的界面
在我向您展示创建 Web 应用程序的代码之前,您需要看到应用程序的外观。本节展示了应用程序的界面。
第一页显示任务列表(图 14.7)。左侧是一个侧边栏,包括菜单选项,如显示任务和选择数据库选项。右侧是主要内容区域。在这种情况下,内容是任务列表。您可以使用侧边栏选择如何排序和过滤任务列表。为了清晰起见,我们仅在显示任务列表时显示排序/过滤菜单。

图 14.7 显示任务列表的界面。主界面包括一个侧边栏,显示菜单信息。主要内容区域显示任务列表。
对于列表中的每个任务,您都可以点击“查看详情”按钮来显示任务的详细信息(图 14.8)。在左侧,我们添加了一些小部件,允许用户删除任务。在右侧,我们在主要内容区域显示任务的详细信息,其中包括一个“更新任务”按钮,用于将更新后的任务保存到数据库中。

图 14.8 显示任务详细信息的界面。在侧边栏中,我们显示一些小部件,允许用户删除任务。在主要内容区域,我们显示任务的详细信息。
如果用户在侧边栏中点击“新建任务”按钮,他们将被引导到一个表单,在那里他们可以创建任务(图 14.9)。在主要内容区域,我们显示一个表单,收集创建新任务所需的数据。用户点击“保存任务”将记录保存到数据库中。

图 14.9 创建新任务的界面。输入数据后,用户可以点击“保存任务”按钮将其保存到数据库中。
14.4.3 使用会话状态跟踪用户活动
为了便于讨论 streamlit,我将用户通过网页浏览器访问网络应用称为一个“会话”,通常在现代浏览器中以标签页的形式出现。当标签页处于活动状态且未刷新时,我们可以使用会话状态来跟踪用户的活动,这些活动以键值对的形式存储。本节展示了我们需要为我们的应用跟踪哪些数据。
我们将创建 taskier_app.py 文件作为我们网络应用的脚本,本节中讨论的所有代码都将放入此文件,除非另有说明。在文件顶部,我们导入依赖项。当这些依赖项在代码的上下文中变得相关时,我们将讨论这些依赖项;现在,我们将专注于 streamlit。按照惯例,我们通常使用 st 作为 streamlit 的别名,这使得引用框架更加容易。我们调用 st.session_state 来检索会话数据,例如:
import copy
import streamlit as st
from taskier import Task, TaskierDBOption, set_db_option,
➥ TaskStatus, TaskierError
from taskier_app_helper import TaskierMenuOption, TaskierFilterKey
session = st.session_state
sidebar = st.sidebar
status_options = TaskStatus.formatted_options()
menu_key = "selected_menu_option"
working_task_key = "working_task"
sorting_params_key = "sorting_params"
sorting_orders = ["Ascending", "Descending"]
sorting_keys = {"Title": "title", "Description": "desc", "Urgency":
➥ "urgency", "Status": "status", "Note": "completion_note"}
除了依赖项之外,此代码还包括我们在应用中经常引用的变量,所有这些变量都与设置侧边栏或会话状态有关。
在会话中,我们想要跟踪的第一个项目是选定的菜单选项。我们希望显示三个主要页面(第 14.4.2 节):任务列表、任务详情以及创建新任务的表单。由于会话状态以键值对的形式存储数据,对于这个项目,我们将键命名为 selected_menu_option,它保存了这三个菜单选项中的一个,这些选项在 taskier_app_helper.py 文件中实现为一个枚举类:
from enum import Enum
class TaskierMenuOption(Enum):
SHOW_TASKS = "Show Tasks"
NEW_TASK = "New Task"
SHOW_TASK_DETAIL = "Show Task Detail"
class TaskierFilterKey(Enum):
SORTING_KEY = "sorting_key"
SORTING_ORDER = "sorting_order"
SELECTED_STATUSES = "selected_statuses"
SELECTED_URGENCIES = "selected_urgencies"
SELECTED_CONTENT = "selected_content"
你可能会注意到我们在辅助文件中定义了 TaskierFilterKey 类。这个类与我们正在会话状态中跟踪的第二个项目相关:用户如何选择对任务列表进行排序和过滤。例如,用户只能查看紧急程度为 3 的任务。这些排序和过滤参数通过会话状态中的 key sorting_params 以字典对象的形式保存。
注意:我们可以使用两个字典对象分别跟踪排序和过滤参数。但许多网络应用,包括我们的应用,都有相同的过滤和排序用户界面。对我们来说,使用一个字典对象来跟踪由单个用户界面生成的这些参数会更简洁。除非我明确说明,否则我将排序和过滤参数互换使用。
当用户想要查看任务详情时,我们需要跟踪用户正在查看哪个任务。在会话状态中,我们使用 working_task 键来存储这个任务,它是一个 Task 类的实例。由于我们需要在会话中的多个函数中更新多个键值对,因此在 taskier_app.py 文件中定义一个用于此任务的函数是个好主意:
def update_session_tracking(key, value):
session[key] = value
我们可以使用 update_session_tracking 函数来更新对应键的值。值得注意的是,每当用户输入发生变化时,streamlit 都会从头到尾运行整个脚本。因此,我们只想在会话没有这些键时将键设置为它们的初始值。如果这些键已经设置,我们不想覆盖它们现有的值,这些值用于跟踪用户的活动。以下代码片段显示了如何设置初始会话状态:
def init_session():
if menu_key not in session:
update_session_tracking(menu_key,
➥ TaskierMenuOption.SHOW_TASKS.value)
update_session_tracking(working_task_key, None)
update_session_tracking(sorting_params_key, {x.value: None for x
➥ in TaskierFilterKey})
由于我们使用 streamlit 将文件作为脚本运行,因此将 if name == "main" 放在文件末尾是一个好习惯,以防我们想将此文件作为模块使用,如以下列表所示。
列表 14.9 调用创建网络应用程序的函数
if __name__ == "__main__":
init_session() ❶
setup_sidebar()
if session[menu_key] == TaskierMenuOption.SHOW_TASKS.value:
show_tasks()
elif session[menu_key] == TaskierMenuOption.NEW_TASK.value:
show_new_task_entry()
elif session[menu_key] == TaskierMenuOption.SHOW_TASK_DETAIL.value:
show_task_detail()
else:
st.write("No matching menu")
❶ 启动会话
如列表 14.9 所注解,我们调用 init_session 函数,该函数设置可以跟踪用户活动的会话状态。我们接下来调用的函数是 setup_sidebar,这在 14.4.4 节中讨论过。
14.4.4 设置侧边栏
我们通常使用侧边栏来显示菜单或可选配置设置。在本节中,我将展示如何为我们的应用程序设置侧边栏。我们通过调用 setup_sidebar 函数来配置侧边栏,如下一列表所示。
列表 14.10 设置侧边栏
def setup_sidebar():
sidebar.button("Show Tasks", on_click=update_session_tracking,
➥ args=(menu_key, TaskierMenuOption.SHOW_TASKS.value)) ❶
sidebar.button("New Task", on_click=update_session_tracking,
➥ args=(menu_key, TaskierMenuOption.NEW_TASK.value))
selected_db = sidebar.radio("Choose Database Option", [x.value for x
➥ in TaskierDBOption]) ❷
set_db_option(selected_db)
sidebar.button("Load Data to Database", on_click=Task.load_seed_data)
sidebar.markdown("___") ❸
if session[menu_key] == TaskierMenuOption.SHOW_TASKS.value:
setup_filters()
elif session[menu_key] == TaskierMenuOption.SHOW_TASK_DETAIL.value:
setup_deletion()
❶ 添加一个按钮
❷ 添加一个单选按钮
❸ 添加一个分隔符
概念 Markdown 是一种轻量级标记语言,用于创建格式化文本。在这些示例中,我们使用三个下划线 ___, 它们转换为一个分隔小部件,用于在部分之间形成视觉分隔。
列表 14.10 是我们第一次向我们的网络应用程序添加小部件。一般来说,我们按照以下语法添加小部件:st.widget_name(widget_label, value_or_options, key=widget_id, on_click=on_click_if_applicable, args=args_if_any)。对于侧边栏,我们可以使用 sidebar.widget_name。以按钮和单选按钮小部件为例,图 14.10 展示了相关代码的结构。

图 14.10 解构在 streamlit 中添加按钮和单选按钮的代码。调用 st.button 在网页上添加一个按钮,并返回一个布尔值,表示按钮的点击状态。调用 st.radio 在网页上添加一个单选按钮,并返回一个整数,表示所选选项的索引。每个函数都包括额外的参数,用于配置小部件。
当我们添加一个小部件,如单选按钮(图 14.10)时,我们可以选择使用函数调用的返回值。例如,st.radio 添加单选按钮,当用户选择一个选项时,我们可以从该函数调用中获取索引。在我们的情况下,我们使用这个索引通过调用 set_db_option 函数(列表 14.1)来知道哪个数据库选项被选中。当数据库选项被选中时,我们将在幕后配置数据库,例如通过创建 SQLite 数据库并添加任务表。与此小部件相关,为了帮助您从学习角度与该应用程序交互,我添加了一个“将数据加载到数据库”按钮,以便向数据库添加更多数据。
当用户选择显示任务时,我们通过调用 setup_filters 函数来显示排序和过滤选项。如果您想知道是否需要将此函数设为私有(我们正在为开发者编写脚本,而不是为其他用户),不使用下划线前缀来命名函数是可以的,否则这会降低可读性:
def setup_filters():
filter_params = session[sorting_params_key]
with sidebar.expander("Sort and Filter", expanded=True):
filter_params[TaskierFilterKey.SORTING_KEY.value] =
➥ st.selectbox("Sorted by", sorting_keys)
filter_params[TaskierFilterKey.SORTING_ORDER.value] =
➥ st.radio("Sorting order", sorting_orders)
filter_params[TaskierFilterKey.SELECTED_STATUSES.value] =
➥ st.multiselect("Show tasks with status (defaults to all)",
➥ options=status_options)
filter_params[TaskierFilterKey.SELECTED_URGENCIES.value] =
➥ st.multiselect("Show tasks with urgency level (defaults to all)",
➥ options=range(1, 6))
filter_params[TaskierFilterKey.SELECTED_CONTENT.value] =
➥ st.text_input("Show tasks with the content (defaults to all)")
由于排序和过滤参数属于同一概念类别,我使用了一个名为“Sort and Filter”的展开小部件。在展开小部件中,我们定义了五个小部件:一个选择框用于选择用于排序的任务属性(标题、描述、紧急程度、状态或完成备注);一个单选按钮用于确定排序顺序(降序或升序);一个多选框用于指定选定的状态;另一个多选框用于指定选定的紧急程度级别;以及一个文本输入用于过滤具有指定内容的任务。图 14.11 显示了如何通过指定这些参数来选择任务的一个子集。

图 14.11 使用“Sort and Filter”小部件选择任务的一个子集。用户指定排序和过滤参数后,根据这些标准检索任务,并在主要内容区域显示。
当用户在主要内容区域查看任务的详细信息时,我们通过调用 setup_deletion 函数在侧边栏中显示删除选项:
def setup_deletion():
task = session[working_task_key]
text_title = sidebar.text_input("Enter task title to delete",
➥ key="existing_delete")
submitted = sidebar.button("Delete Task")
if submitted:
if text_title == task.title:
task.delete_from_db()
sidebar.success("Your task has been deleted.")
else:
sidebar.error("You must enter the exact text for the
➥ title to delete.")
在此函数中,我们通过访问会话的 working_task 键来检索任务。为了防止用户意外删除任务,我们要求他们在从数据库中删除任务之前输入任务的标题。新功能调用成功和错误函数,这些函数对于提供对用户执行的操作的实时正面和负面反馈非常有用(图 14.12)。

图 14.12 网页应用程序中的成功和错误反馈。我们调用 st.success 来提供正面反馈,调用 st.error 来提供负面反馈。
14.4.5 显示任务
在任务管理应用中,显示用户可以工作的可用任务列表是有用的。因此,显示任务的页面非常重要。本节展示了如何使用 streamlit 实现此功能。在列表 14.9 中,我们调用了 show_tasks 函数来配置显示任务的 Web 元素。下一个列表展示了 show_tasks 函数的实现。
列表 14.11 在 Web 应用中显示任务
def show_tasks():
filter_params = session[sorting_params_key]
if filter_params[TaskierFilterKey.SORTING_KEY.value] is not None:
reading_params = get_reading_params(filter_params)
tasks = Task.load_tasks(**reading_params)
sorting_key = sorting_keys[filter_params[
➥ TaskierFilterKey.SORTING_KEY.value]]
should_reverse = filter_params[
➥ TaskierFilterKey.SORTING_ORDER.value] == sorting_orders[1]
tasks.sort(key=lambda x: getattr(x, sorting_key),
➥ reverse=should_reverse)
else:
tasks = Task.load_tasks() ❶
for task in tasks:
col1, col2 = st.columns([3, 1]) ❷
col1.write(str(task))
col2.button("View Detail", key=task.task_id,
➥ on_click=wants_task_detail, args=(task,))
st.write(f"Status: {task.status.name.title()}")
st.markdown("___") ❸
❶ 获取数据
❷ 创建两列作为网格以实现更清晰的显示
❸ 显示数据
此代码有两部分。第一部分检索数据,包括使用和不使用排序和过滤参数的情况,第二部分通过使用小部件显示数据。
列表 14.11 的第一部分涉及两个步骤:
-
通过调用 get_reading_params 函数从用户输入中获取过滤参数。 我们将在本节的后面讨论这个函数。
-
根据提供的排序参数对任务进行排序。 由于我们使用列表(第 3.1 节),一个可变对象来存储任务,我们可以通过使用排序方法(第 3.2 节)对任务进行排序。因为排序键可能会改变,例如从标题到降序,如果我们需要为每个键参数创建不同的 lambda 函数,比如 lambda x: x.title 来按标题排序和 lambda x: x.urgency 来按紧急程度排序,那么这可能会变得繁琐。因此,我们采用了一种通用方法来动态检索相应的属性:lambda x: getattr(x, sorting_key)。
列表 14.11 的第二部分使用适用的小部件来显示任务。在这里,我使用了一个新的小部件,称为 columns,这是一个用于组织目的的不可见小部件。具体来说,调用 st.columns([3, 1])创建了两列,宽度比为 3:1,这个调用的返回值是一个表示这两列的元组。通过元组解包,我们获得了它们的引用,分别命名为 col1 和 col2,我们可以向这些列添加小部件。其中一个小部件是查看详情按钮,当它被点击时,我们将在主要内容区域显示任务的详情,如第 14.4.6 节所述。以下是 get_reading_params 函数的工作方式:
def get_reading_params(filter_params):
reading_params = dict.fromkeys(["statuses", "urgencies", "content"])
if selected_statuses := filter_params[
➥ TaskierFilterKey.SELECTED_STATUSES.value]:
reading_params["statuses"] = [status_options.index(x) for x
➥ in selected_statuses]
if selected_urgencies := filter_params[
➥ TaskierFilterKey.SELECTED_URGENCIES.value]:
reading_params["urgencies"] = selected_urgencies
if selected_content := filter_params[
➥ TaskierFilterKey.SELECTED_CONTENT.value]:
reading_params["content"] = selected_content
return reading_params
如图 14.11 所示,用户可以配置三个过滤参数:状态、紧急程度和内容。在这个代码片段中,除了我们之前没有见过的赋值表达式之外,一切都应该很简单。这种技术使用:=符号(昵称“海象操作符”),它在 Python 3.8 中被引入。例如,代码 selected_statuses := filter_params[TaskierFilterKey.SELECTED_STATUSES.value]意味着我们正在尝试检索 filter_params 字典中 selected_statuses 键的值,并将其分配给名为 selected_statuses 的变量。如果这个值不是 None,我们将运行 if 语句内的代码。通常,赋值是一个语句,所以我们不能在需要子句为表达式的 if 语句中使用它。正如你所看到的,赋值表达式做两件事:分配一个值并评估它。
提醒:表达式求值得到一个对象,而语句执行一个动作而不返回值。请参阅第 2.1.3 节,以详细了解表达式和语句之间的差异。
14.4.6 显示任务详情
任务列表为每个任务提供整体信息。我们可以显示有关任务的更详细信息。本节展示了如何满足这一需求。
对于查看详情按钮,我们使用 wants_task_detail 函数设置 on_click 参数,并使用(args 参数)(task,)。如果用户点击此按钮,我们将调用 wants_task_detail(task):
def wants_task_detail(task: Task):
update_session_tracking(working_task_key, task)
update_session_tracking(menu_key,
➥ TaskierMenuOption.SHOW_TASK_DETAIL.value)
这个函数调用做了两件事:
-
它将关联到查看详情按钮的任务设置为当前工作任务。
-
它更改选定的菜单以显示任务的详情。通过更改菜单,当 Web 应用程序重新加载时,我们通过调用 show_task_detail 函数来显示任务详情页面,如下一列表所示。
列表 14.12 显示任务详情
def show_task_detail():
task = session[working_task_key]
form = st.form("existing_task_form", clear_on_submit=False)
form.title("Task Detail")
task.title = form.text_input("The title", value=task.title,
➥ key="existing_task_title")
task.desc = form.text_input("The description", value=task.desc,
➥ key="existing_task_desc")
task.urgency = form.slider("The urgency level", min_value=1,
➥ max_value=5, value=task.urgency)
status = form.selectbox("The status", index=task.status,
➥ options=status_options, key="existing_task_status")
task.status = TaskStatus(status_options.index(status))
task.completion_note = form.text_input("The completion note",
➥ value=task.completion_note, key="existing_task_note")
submitted = form.form_submit_button("Update Task")
if submitted:
try:
task.update_in_db()
except TaskierError:
form.error("Couldn't update the task as it's maybe
➥ deleted already.")
else:
session[working_task_key] = task
form.success("Your Task Was Updated!")
在列表 14.12 中注意以下三点:
-
我们正在使用表单小部件来组合单个小部件,例如滑块和文本输入。表单小部件可以记住其包含小部件的用户输入,这样当网页重新加载时,它会显示用户的输入。
-
当我们完成更新后,我们调用 form_submit_button,它将提交按钮添加到表单中,并使用返回值,当按钮被点击时返回 True。
-
当我们提交这个表单以更新数据库中的记录时,我们使用 try...except...else...语句(第 12.3 和 12.4 节)。我们在这里使用异常处理,因为用户可能已经通过侧边栏上的删除选项删除了任务,或者可能已经使用另一个标签页删除了任务。
请注意,在实际的 Web 应用程序中,你可能不希望以这种方式设计你的界面。如果用户已删除项目,你应该引导他们到一个不显示已删除项目的页面。我提供这个示例纯粹是为了演示目的,以展示如何在项目中使用异常处理。
14.4.7 创建新任务
在任务管理应用中,我们允许用户创建一个新任务。本节展示了如何在我们的 Web 应用中实现此功能。为此功能,我们定义了 show_new_task_entry 函数,如下面的列表所示。
列表 14.13 在 Web 应用中创建新任务
def show_new_task_entry():
with st.form("new_task_form", clear_on_submit=True):
st.title("New Task")
title = st.text_input("The title", key="new_task_title")
desc = st.text_input("The description", key="new_task_desc")
urgency = st.slider("The urgency level", min_value=1, max_value=5)
submitted = st.form_submit_button("Save Task")
if submitted:
task = Task.task_from_form_entry(title, desc, urgency)
task.save_to_db()
st.success("Your Task Was Saved!")
正如我们在任务详情页中所做的那样,我们使用表单小部件进行新任务输入。与列表 14.12 的不同之处在于,我们使用 with 语句为表单创建上下文管理器(第 11.1 节)。在 with 语句中,当我们调用 st.text_input 创建文本输入框时,由于上下文管理器,streamlit 知道该框应放置在表单内。相比之下,在列表 14.12 中没有使用上下文管理器时,我们明确调用 form.text_input 将文本输入框添加到表单中。这两种方法——使用和不使用上下文管理器——都是可接受的。
14.4.8 组织你的项目
我们已经看到了我们如何单独实现我们的功能。从可维护性的角度来看,组织你的项目以便团队成员更容易阅读和定位相关功能是至关重要的。在本节中,我将展示使用 streamlit 开发 Web 应用的最佳实践来组织你的项目。因为最终产品是一个 Web 应用,所以我首先关注负责创建 Web 应用的脚本文件 taskier_app.py,它负责创建 Web 应用。
通常,此脚本由三个组件组成:依赖项、全局变量和配置界面的函数。对于我们的 Web 应用,脚本使用 Task 类作为其核心数据模型。尽管脚本文件是我们应用中使用 Task 类的唯一地方,但我们不希望将类放在脚本文件中,原因如下:
-
我们使脚本文件难以阅读以了解 Web 应用是如何构建的,因为 Task 类在代码中占据了相当大的空间,而且它并没有对 Web 应用界面做出贡献。
-
使用此类进行其他目的,例如构建桌面应用,将非常不便。因此,使用单独的文件来实现我们的数据模型至关重要。
当我们在应用中使用数据模型时,我们将其作为依赖项导入。对于脚本文件,我们将依赖项放置在文件顶部,如下面的代码片段所示。依赖项不仅服务于脚本中的代码,还提供了代码读者(如队友)想要了解的重要信息,例如脚本使用了哪些库和包:
import streamlit as st
from taskier import Task, TaskierDBOption, set_db_option,
➥ TaskStatus, TaskierError
from taskier_app_helper import TaskierMenuOption, TaskierFilterKey
如您可能注意到的,我们将 TaskierMenuOption 和 TaskierFilterKey 类保存在不同的文件(taskier_app_helper.py)中,以便 taskier_app.py 文件只包含构建 Web 界面的代码。
在明确依赖项的组织结构后,我们可以分析脚本文件组件的组织结构。图 14.13 提供了图形分析。

图 14.13 taskier_app.py 文件的组织。该文件有三个组件:依赖项、全局变量和界面配置。
对于配置界面的代码,我已经根据其预期用途组织了函数。相关函数被分组在一起。会话跟踪的代码位于顶部,因为它是跟踪用户活动的驱动力。中间是配置主要内容区域的函数。最后是设置侧边栏的函数。
14.4.9 运行应用程序
我们已经完成了代码并很好地组织了它。现在是时候运行应用程序并尝试一下了。(请注意,当您进行应用程序开发时,您应该在浏览器中运行应用程序,以便您可以看到代码的实时性能。)要运行应用程序,请在命令行工具中输入 streamlit 命令:
$ streamlit run taskier_app.py
确保在导航到保存 taskier_app.py 文件的目录后运行命令之后;否则,您需要指定脚本的完整路径。您应该在默认浏览器中看到一个新标签页,其中运行着我们的应用程序。
14.4.10 讨论
熟悉像 streamlit 这样的框架需要一些时间。本节不是关于使用此框架的技术细节。相反,通过构建这个网络应用程序,包括其界面及其支持的数据模型,您可以看到书中涵盖的技术是如何贡献于实际项目的。在本节的末尾,我向您展示了如何组织项目。尽管这个应用程序是一个玩具项目,但以可读性和可维护的方式组织代码仍然很重要。
注意:streamlit 框架背后的公司允许您在 GitHub 上公开托管应用程序代码时免费发布您的网络应用程序。您可以在share.streamlit.io/找到有关共享应用程序的信息。
14.4.11 挑战
我们定义的一个全局变量是 sorting_keys,它是一个字典对象:{"标题": "title", "描述": "desc", "紧急性": "urgency", "状态": "status", "备注": "completion_note"}。我们在创建 selectbox 小部件时使用此对象:st.selectbox("Sorted by", sorting_keys)。在这个调用中,我们使用字典对象作为小部件的选项。为什么我们可以使用字典对象而不是列表对象,例如 list(sorting_keys.keys())?
提示:我们可以将任何可迭代对象发送到 selectbox 作为选项。字典对象是可迭代的,默认情况下使用其键作为迭代器的元素。
摘要
-
您应该为每个项目创建一个虚拟环境,形成一个隔离的环境来管理项目的依赖项,避免项目之间的依赖项要求。
-
venv 模块是管理虚拟环境的内置解决方案。
-
一些第三方工具,如 conda,允许您为每个虚拟环境拥有一个独立的 Python 解释器,如果您的项目使用不同的 Python 版本,这可以提供更多的灵活性。
-
数据模型应该满足你项目的业务需求。因此,在编写实现数据模型的代码之前,你应该确定你的需求。
-
你的代码文件应该是可读的。对于一个类,你应该为每个你定义的方法编写文档字符串。
-
SQLite 是一个轻量级数据库,无需预先配置。你可以在所有主流操作系统上创建 SQLite 数据库,包括智能手机等便携式设备的操作系统。
-
与 CSV 文件相比,SQLite 数据库是一个更正式的数据库选择。我使用 CSV 文件作为教程的数据源,但对于真实项目,你应该始终考虑使用正式的数据库。
-
Web 应用是展示你项目的绝佳选择,因为它们是平台无关的。Python 支持多个 Web 框架,包括 streamlit,所有 Python 开发者都可以使用这些框架轻松构建 Web 应用。
-
尽管你为这本书创建的项目很小,但你应该组织你的文件及其内部代码。这对于提高可读性和可维护性至关重要。
附录 A.使用 IDLE 中的 REPL 学习 Python
A.1 在你的计算机上安装 Python
在你可以在你的计算机上执行任何 Python 代码之前,你必须安装 Python。本节展示了如何在你的计算机上安装并运行 Python。如果你已经在你的计算机上安装了 Python,请跳过本节。
获取 Python 最直接的方式是从官方网站下载(www.python.org/downloads/)。尽管 Python 有多种版本,但始终使用最新版本是个好主意,因为它拥有最新的应用程序编程接口(API)。对于这本书,所有代码都是使用版本 3.10 测试的。在大多数情况下,即使你使用的是 Python 的新版本,书中的代码也应该能运行;这本书涵盖了核心的 Python 特性,预计它们将是稳定的。
在你下载与你的计算机操作系统匹配的 Python 版本后,你可以按照提示在你的计算机上安装 Python。当你运行安装程序时,你应该看到类似于图 A.1(我在运行 macOS 笔记本电脑上安装 Python 时拍摄的截图)的窗口。
注意:如果你的项目需要不同的 Python 版本,你需要创建单独的虚拟环境,以便每个项目都有自己的 Python 解释器,而不是共享系统级别的 Python。第 14.1 节讨论了虚拟环境的创建。

图 A.1 运行 Python 安装程序。这个截图是在运行 macOS 的计算机上拍摄的,即使你使用 Windows 或 Linux,你也应该看到类似这样的窗口。
A.2 使用 IDLE 进行 REPL
在图 A.1 中,安装程序对话框显示安装还包括集成开发和学习环境(IDLE)。当你运行 IDLE 时,默认情况下你应该看到 IDLE Shell;它提供了一个交互式的 Python 控制台,其中 REPL 发挥作用。REPL 是学习 Python 的最佳方式。在本节中,我简要回顾了如何使用 IDLE 进行 REPL。
要使用 REPL,你首先输入一些代码;Python 会读取并评估这些代码,然后打印出任何相关的输出。你可以重复这个过程以促进你的学习过程,形成一个循环(图 A.2)。

图 A.2 REPL 过程。你编写代码,Python 读取并评估。如果代码有任何相关的输出,Python 会打印出这些输出。
在控制台中,尝试输入以下代码行:
>>> print("Hello, World!")
Hello, World!
>>> sum(range(10))
45
你会看到,每次你在键盘上按下 Enter 或 Return 键后输入一行代码,Python 都会立即读取、评估并打印结果。请注意,>>>符号是提示符,之后你输入你的代码。没有提示符的行是输出。
除了单行代码外,你还可以输入涉及缩进的多个代码行,例如 for 循环。在你输入第一行后,Python 知道你还没有完成输入,你将移动到下一行。值得注意的是,IDLE 会为你创建适当的缩进。正如你所知,Python 使用缩进来表示不同级别代码之间的逻辑。在这里,在 for 循环的头部之后,它会自动创建一个四格缩进来表示下一个代码块是 for 循环的主体。图 A.3 显示了在 IDLE 中输入多行代码时的显示效果。

图 A.3 在 IDLE 中运行多行代码。多行代码将被一起评估。
当你完成多行代码后,你需要有一个额外的空行来告诉 Python 代码已经准备好评估。另一个需要注意的事项是,在 for 循环体内部,提示符变为...,这表示前一个头部的延续。
使用 REPL 作为学习方法的最大的优势是 Python 可以即时执行你的代码并给出反馈,这让你可以在不创建任何文件的情况下探索 Python 的各种特性。例如,你可以在控制台中输入以下代码来快速学习列表推导(第 5.2 节)是如何工作的:
>>> numbers = [-3, -2, -1, 0, 1, 2, 3]
>>> [x*x for x in numbers]
[9, 4, 1, 0, 1, 4, 9]
>>> {x: x*x for x in numbers}
{-3: 9, -2: 4, -1: 1, 0: 0, 1: 1, 2: 4, 3: 9}
>>> {x*x for x in numbers}
{0, 9, 4, 1} ❶
❶ 集合是无序的。你可能看到不同的结果。
作为另一个例子,你可以通过在 sorted 函数中包含 lambda 函数(第 7.1 节)来了解高级排序(第 3.2 节)是如何工作的。你按照数字的平方值进行排序,如下所示:
>>> sorted(numbers, key=lambda x: x*x)
[0, -1, 1, -2, 2, -3, 3]
IDLE 中另一个有用的特性是自动提示,这有助于你更高效地编码。例如,在控制台中输入 sorted 后,你会看到这个函数的帮助信息(图 A.4)。

图 A.4 IDLE 中的自动提示帮助信息
作为另一个例子,在你输入 n 并按下 Tab 键后,你会看到与 n 相关的变量列表(图 A.5)。

图 A.5 在按下 Tab 键后出现的与 n 相关的变量/函数列表
A.3 在终端中使用 REPL
除了使用 IDLE,如果你在 macOS 电脑上工作,你还可以在终端程序中学习 Python。如果你在 Windows 上工作,你可以使用任何命令行工具,包括 Windows 附带的 cmd.exe。在这里,我使用 macOS 的终端来展示如何使用 REPL,但你也可以在 Windows 上做同样的事情。
在 Mac 电脑上,通过 Spotlight 搜索框(Command+Space)输入 terminal,如图 A.6 所示,这是启动终端应用的最简单方法。如果你使用 Windows,可以按 Win+R 打开运行对话框,并输入 cmd 来打开命令行工具。

图 A.6 在 Mac 上通过 Spotlight 搜索启动终端应用
在终端或命令行应用程序中,输入 python 并按 Return 或 Enter。请注意,Mac 电脑可能已通过系统安装了 Python 2 版本,在这种情况下,您应该通过输入 python3 而不是 python 来使用 Python 3。应该会启动一个交互式 Python 控制台,如图 A.7 所示。

图 A.7 在终端中启动交互式 Python 控制台
在控制台中,您可以执行我在 A.2 节中展示的相同操作。现在尝试计算前 10 个整数(0-9)的和:
>>> sum(range(10))
45
如果您完成了 REPL 并想退出 Python 控制台,请输入 quit() 以结束会话。
A.4 在 Visual Studio Code 中使用 REPL
由微软开发的 Visual Studio Code (VSC) 是一个适用于通用目的的开源集成开发环境 (IDE)。通过安装适当的扩展,VSC 支持使用各种编程语言进行编码项目,例如 C、C#、Java 和 Python。在这里,我将向您展示如何设置 VSC 以进行 Python 开发。
您可以在 code.visualstudio.com/download 下载与您的计算机兼容的 VSC 版本。下载后,按照提示完成安装,您应该能够运行 VSC。为了在 Python 中获得最佳的编码体验,您想要通过 VSC 的扩展市场(图 A.8)安装 Python 扩展。Python 扩展提供了一系列功能,包括自动完成提示、语法高亮、Jupyter Notebook(附录 C 在线)和代码格式化,所有这些都可以提高您的工作效率。

图 A.8 在 VSC 中安装 Python 扩展。您在扩展市场中找到 Python 扩展,并通过单击安装按钮来安装扩展。
安装 VSC 和推荐扩展后,是时候用它来玩 REPL 了。(在 VSC 中进行 REPL 比在 IDLE 和终端中更容易。)为了跟踪您所编写的代码,在您选择的目录中创建一个文件,例如 repl_vsc.py。为了简单起见,假设这个文件有几行代码:
# the repl_vsc.py file
numbers = [2, 3, 5, 7]
numbers_sum = sum(numbers)
print(numbers_sum)
我最喜欢 VSC 的地方是您可以选择要运行的代码,然后按 Shift+Return(在 Windows 中为 Shift+Enter)将其提交到 Python 控制台,如图 A.9 所示。

图 A.9 使用 VSC 中的快捷键提交代码到 Python 控制台
您可以立即看到结果。如果您需要更改代码,请编辑文件中的代码并再次提交。
附录 B. 使用 pip 管理 Python 包
B.1 在您的计算机上安装 pip
Python 的流行主要归功于其庞大的各种领域工具集,包括数据科学、机器学习、Web 开发等。当我提到“工具”时,我指的是模块、包、库和框架,这些我在第一章中进行了区分。要使用这些工具,您必须将它们下载到您的计算机上。安装这些工具最广泛采用的方式是 pip——官方的 Python 包管理工具。
为了集中管理 Python 包,Python 基金会创建了 Python 包索引(PyPI;pypi.org/),这是一个 Python 软件的仓库。需要注意的是,PyPI 中的包与您作为多个模块容器创建的包是不同的概念。相反,它是一个泛指,指的是要一起安装的软件包。要使用 PyPI 安装包,您需要在您的计算机上安装 pip。本节将向您展示如何进行操作。
在您尝试在您的计算机上安装 pip 之前,您可以检查它是否已经安装,因为它可能在安装 Python 的过程中已经安装:
macOS:
python3 -m pip --version
Windows:
py -m pip --version
如果您的计算机有 pip,运行该命令应该会显示其版本。如果该行无法运行,您可以运行以下行从互联网上获取 pip:
macOS:
python3 -m ensurepip --default-pip
Windows:
py -m ensurepip --default-pip
请注意,根据您的操作系统,如果前面的代码对您不起作用,您可能需要在网上搜索以找到正确的命令。安装完成后,您可以运行命令来检查其版本。如果您仍然遇到问题,请参阅 Python 的网站以获取额外的说明(mng.bz/aPaY)。
B.2 安装和更新单个包
当您在您的计算机上安装了 pip,您可以使用它来安装您能想到的几乎每一个 Python 包,因为 PyPI 是 Python 软件的中央仓库,大多数 Python 开发者都在这里公开分享他们的项目。本节将展示如何安装和更新包。
提醒 在您继续之前,请确保您想要为安装包创建虚拟环境(第 14.1 节)。
在本书中,我多次提到了 pandas 库,这是一个流行的数据科学 Python 包。您可以通过以下代码在您的计算机或虚拟环境中安装 pandas,如果您选择这样做的话:
pip install pandas
此命令将在您的计算机上安装 pandas 的最新版本。pandas 有多个版本,因此您的某些项目可能需要特定版本。要安装特定版本,您可以如下指定:
pip install pandas==1.4.0
您应该能够看到安装的进度,这通常需要几分钟。为了快速检查已安装的包,运行命令 pip show pandas,您将看到如下类似的内容:
Name: pandas
Version: 1.4.0
Summary: Powerful data structures for data analysis, time series,
➥ and statistics
Home-page: https://pandas.pydata.org
Author: The Pandas Development Team
Author-email: pandas-dev@python.org
License: BSD-3-Clause
Location: the_location_on_your_computer
Requires: numpy, pytz, python-dateutil
Required-by: streamlit, altair ❶
❶ 需要 pandas 的包
为了利用最新包的功能,你可能想使用以下命令更新你的包:
pip install pandas --upgrade
B.3 安装多个包
逐个安装多个包可能不方便,所以使用一行命令安装多个包会很好。在本节中,我将展示如何安装多个包。
假设你想要安装 pandas 和 seaborn,这两个是许多数据科学家使用的包(后者用于数据可视化)。在命令行中,你可以输入以下代码:
pip install pandas seaborn
此命令安装了包及其相应的依赖项。如果你想安装更多包,你可以列出所有包。对于每个包,你可以指定版本如下:
pip install pandas==1.4.0 seaborn==0.11.2
如你所猜,安装更多包仍然可能不方便。假设你的队友正在做一个依赖于数十个包的项目,而你想要重现他们的工作。更复杂的是,这些包需要特定的版本。你能在命令行中输入以下文本吗?
pip install altair==4.2.0 anyio==3.5.0 appnope==0.1.2
➥ argon2-cffi==21.3.0 argon2-cffi-bindings==21.2.0
➥ asttokens==2.0.5 attrs==21.4.0 Babel==2.10.1
➥ backcall==0.2.0 beautifulsoup4==4.10.0 bleach==4.1.0
➥ blinker==1.4 cachetools==5.0.0 certifi==2020.6.20
➥ cffi==1.15.0 charset-normalizer==2.0.12 click==8.0.4
➥ cycler==0.11.0
(请注意,此代码是我项目依赖的一部分;完整的列表要长得多。)使用 pip 工具是更好的方法。运行以下命令:
pip freeze > requirements.txt
此命令在当前目录中创建一个名为 requirements.txt 的文本文件。如果你打开此文件,你会看到它列出了所有依赖项,如下(请注意,由于空间考虑,我只展示了文件的一部分):
altair==4.2.0
anyio==3.5.0
appnope==0.1.2
argon2-cffi==21.3.0
argon2-cffi-bindings==21.2.0
asttokens==2.0.5
attrs==21.4.0
Babel==2.10.1
backcall==0.2.0
beautifulsoup4==4.10.0
bleach==4.1.0
blinker==1.4
cachetools==5.0.0
certifi==2020.6.20
cffi==1.15.0
charset-normalizer==2.0.12
click==8.0.4
cycler==0.11.0
可维护性 它是一个惯例,将文件命名为 requirements.txt。
如果你从队友那里得到了这样的需求文件,你可以运行以下代码,它会安装文件中指定的所有依赖项:
pip install -r requirements.txt
更重要的是,安装的包使用相同的版本,因此你可以重现你队友的项目。命令 -r 表示你想要使用一个指定依赖项的文本文件——在这个例子中,是 requirements.txt 文件。
B.4 卸载包
由于包是必要的代码文件,它们不占用太多内存,因此你很少需要卸载包来腾出空间。但了解你有选项删除一个或多个包是很好的。要删除特定的包,使用以下命令:
pip uninstall pandas
在卸载过程中,你会被询问是否继续卸载。如果你确定想要在不被提示的情况下卸载包,可以添加 -y 命令:
pip uninstall -y pandas
在 B.3 节中,你学习了如何使用 requirements.txt 安装多个包。你也可以用这个文件进行卸载。也就是说,你指定要删除的所有依赖项在文件中,并运行以下命令:
pip uninstall -r requirements.txt
在卸载时,你会被提示确认是否删除它们。以类似的方式,如果你想跳过确认问题,可以添加 -y 命令:
pip uninstall -y -r requirements.txt
提示 确保你确实想要使用 -y 命令跳过确认来删除包。
附录 C. 使用 Jupyter Notebook:一个基于网络的交互式 Python 编辑器
C.1 Jupyter Notebook 背后的基本思想
您可能在使用 Read Evaluate Print Loop (REPL) 以促进您的 Python 学习过程中有一些亲身体验,可能使用的是与 Python 安装一起提供的 Python 编辑器 IDLE。然而,您还有其他几种编写 Python 代码的选择,包括 Jupyter Notebook,这是一个适合所有水平 Python 开发者的流行选项。在本节中,我将简要介绍 Jupyter Notebook 的工作原理,并讨论基于 Jupyter Notebook 的主要 Python 编辑器的变体。
Jupyter Notebook 是一个基于网络的交互式计算环境,它是作为 Jupyter 项目的一部分开发的。名称 Jupyter 来自于 Julia(另一种用于科学计算的编程语言)、Python 和 R 的组合,它们是科学和工程计算的主流编程语言。由于它是一个基于网络的应用程序,Jupyter Notebook 是跨平台的;它可以通过像 Chrome 或 Edge 这样的网络应用程序运行,而不会受到您计算机操作系统的任何限制。因此,Jupyter Notebook 在运行平台方面具有灵活性。
当您使用 IDLE 或命令行工具进行 REPL 时,您会受到几个因素的制约:
-
保存您的代码的方式并不明显。
-
输出仅限于文本,没有简单的方法来显示任何非文本输出,例如图像。
-
重新使用或修改之前输入的代码不方便。
REPL 同样令人着迷,因为您可以对代码获得实时反馈。因此,为了解决之前提到的问题同时保持 REPL 的即时反馈,Jupyter Notebook 使用单元格。每个单元格代表一个独立的空间,您可以输入尽可能多的代码。当您准备好时,您可以要求 Jupyter Notebook 仅评估当前单元格;结果显示在单元格下方,提供与 IDLE 中的 REPL 相同的实时体验。更重要的是,由于 Jupyter Notebook 使用基于网络的框架,它原生支持显示文本以外的内容;输出可以是图像甚至是视频。因此,Jupyter Notebook 提供即时结果并支持多媒体。
在您的笔记本中,您可以使用尽可能多的单元格,并且您可以根据项目的大小使用一个笔记本或多个笔记本文件。您还可以通过将其转换为 HTML 页面、演示文稿和其他格式来共享您的笔记本。因此,Jupyter Notebook 非常适合团队合作。
C.2 安装 Jupyter Notebook 及其变体
在您开始使用 Jupyter Notebook 之前,最明显的第一步是在您的计算机上安装它。您还可以探索一些变体。本节描述了这些安装。由于您可以在互联网上找到大量关于安装每个变体的信息,因此我将保持本节简短。
另一个需要注意的重要事项是,尽管你可以在全局范围内安装这些包,使其适用于整个计算机,但最佳实践是在单独的虚拟环境中创建并工作(参见第 14.1 节)。在你电脑上安装 pip 后,你可以在命令行中运行以下代码:
pip install notebook
安装 Jupyter Notebook 后,运行以下代码以启动它:
jupyter notebook
运行此代码将在默认的网页浏览器中打开一个网页,你将看到类似于图 C.1 的内容,这表明你已成功安装了 Jupyter Notebook。网页显示了当前目录的文件树。

图 C.1 启动 Jupyter Notebook 时的登录网页
更好的变体是 JupyterLab,这是由 Project Jupyter 开发的新一代 Jupyter Notebook。与 Jupyter Notebook 相比,JupyterLab 为编辑和组织你的代码及其输出提供了更一致的用户体验。
你可以使用 pip 工具在你的电脑上安装 JupyterLab:
pip install jupyterlab
要启动 JupyterLab,请在命令行中运行以下代码:
jupyter-lab
将出现类似于图 C.2 的网页。

图 C.2 JupyterLab 网页的菜单选项和文件目录
Jupyter Notebook 的另一种变体是 Google Colab,这是一个免费的在线程序(提供付费升级选项),可在research.google.com/colaboratory/找到。与前面的两种选项相比,Google Colab 无需配置,你几乎可以立即开始使用 Notebook。
除了这三个选项之外,许多集成开发环境(IDEs)支持运行 Notebook,包括 Visual Studio Code(VSC)和 PyCharm。尝试它们所有,看看你最喜欢哪一个。当我需要编辑笔记本时,我喜欢使用 JupyterLab 和 VSC,许多需要不断以各种形式评估代码及其输出的数据科学家也是如此,例如文本、数据表和图形。
C.3 Jupyter Notebook 的基本操作
在本节中,我使用 JupyterLab 展示了一些你可以在笔记本中执行的一些常见操作。请注意,我并不打算提供所有功能的详尽列表。这里涵盖的操作是开始使用笔记本时最基本和最重要的。
在创建一个空白的笔记本后,通过点击+按钮添加一个单元格。在单元格中,输入任何你想要的代码,例如 print("Hello, World!")。完成代码后,不要点击运行按钮(三角形),而是按键盘快捷键 Shift+Return(Mac)或 Shift+Enter(Windows)来运行代码并创建一个新的单元格。字符串将出现在单元格下方。如果你想在运行单元格而不创建新单元格的情况下运行,请按快捷键 Command+Return(Mac)或 Ctrl+Enter(Windows)。
当你有多个单元格时,你可以通过点击每个单元格的边缘来选择它们并运行所有单元格。或者,你可以将光标放在单元格中,选择运行 > 运行所有选中单元格上方单元格。你还可以运行笔记本中的所有单元格。图 C.3 显示了单元格的运行选项。

图 C.3 JupyterLab 中的运行菜单
另外两个有用的快捷键是按 a 和 b,分别在前一个和后一个单元格中插入单元格。当你有多个单元格并且想在中间插入一个时,选择顶部单元格然后按 b 键在下面插入单元格。要删除单元格,选择单元格然后按 dd 键。默认情况下,新插入的单元格被称为代码单元格,在其中你预计会输入 Python 代码。
另一个重要的单元格类别是 Markdown 单元格。在 Markdown 单元格中,你可以编写 Markdown,它可以将文本转换为 HTML 元素。你可以在mng.bz/gRGn找到关于 Markdown 的更详细说明。
Markdown 单元格之所以重要,是因为它们是你可以输入工作标题和笔记的地方。始终用适当的标题组织你的笔记本是个好主意,因为这些标题可以帮助你搜索和定位信息。如果其他用户阅读你的笔记本,这些 Markdown 单元格中的笔记可以提高可读性。
附录 D. 将版本控制集成到你的项目中
D.1 使用版本控制简短的理由
当你在 Microsoft Word 或其他文档编辑软件中编写内容时,你可能使用撤销操作,例如通过删除错误输入,或者撤销删除或样式设置。在大多数情况下,你可以撤销操作的次数是有限的。有时,你可能发现即使撤销了所有操作,你仍然无法恢复你想要的文档版本。你有过这样的经历吗?
在你的编码项目中也可能发生同样的事情。你永远不知道何时需要回到早期版本。因此,将 版本控制(VC)集成到你的项目中非常重要。VC 跟踪你的项目版本,在你需要时允许你恢复到之前的版本。在本节中,我将向你展示 VC 的关键概念,它不仅仅是恢复早期版本。
D.2 项目的典型工作流程
在我展示 VC 的工作原理之前,我会快速回顾一下项目的典型工作流程。请注意,我并不是在谈论书中的挑战或像 Microsoft Word 那样巨大的项目。假设你正在与另外两名开发者合作进行一个项目,你们每个人都被分配去工作一个不同的组件。图 D.1 展示了三个组件的进度。

图 D.1 项目中各个组件的进度
然而,图 D.1 在两个方面是不完整的:组件之间没有连接,这在实际项目中不太可能发生,而且组件没有形成一个实体(项目,它可以作为中心,允许每个组件访问其他组件)。考虑到这些因素,图 D.2 展示了项目进度的更现实图。

图 D.2 各个组件与主要项目之间的互连。每个组件都有自己的进度线,在特定的里程碑时,这些组件被组合到主要项目中。
如图 D.2 所示,每个开发者可以独立工作在自己的组件上以满足各自的里程碑。同时,所有三名开发者时不时地聚在一起,将他们的独立组件组合起来并更新主要项目。
D.3 使用版本控制系统促进协作工作
如你所想象,当开发者们聚在一起讨论如何组合独立组件时,可能会有很多麻烦。首先,他们需要找到一个所有人都能够出席的时间。另一个问题是,一些开发者可能落后于进度,他们的组件还没有准备好。为了促进协作工作,大多数项目需要一个版本控制系统(VCS)。
VCS(版本控制系统)有几种变体,全面涵盖它们是不切实际的。如果你曾经编写过任何代码,你可能已经听说过 Git 或 GitHub,后者是一个在线 VCS 服务提供商。这种 VCS 被称为分布式 VCS,其中你的队友共享一个主仓库(源代码库),每个人都可以在自己的本地计算机上拥有自己的仓库。你在自己的仓库上工作,作为你计算机上的源代码库。当你取得良好的进展时,你可以通过称为提交的过程将进展提交到本地仓库。当你认为可以更新在线仓库——所有团队成员共享的主仓库时,你可以将其推送到主仓库。图 D.3 展示了分布式 VCS。

图 D.3 分布式 VCS 的工作流程。共享仓库托管在云端。每个团队成员都有自己的本地个人仓库。你使用工作副本,当你取得一些进展时,你可以将其提交到个人仓库,并在必要时将其推送到在线仓库。从另一个方向来看,你可以从共享仓库中拉取最新版本并更新工作副本。
如图 D.3 所示,除了将更新从你的工作副本直接指向个人本地仓库,然后到共享在线仓库的提交和推送操作外,你还可以看到另一个操作方向:拉取和更新。也就是说,你可以从服务器获取最新仓库并更新你的工作副本。
你可以从分布式 VCS 的工作流程中衍生出几个显著的好处:
-
每个开发者都在自己的副本上工作,因此其他人不会干扰你的工作;你有你的独立性。
-
你还在共享在线仓库,因此你可以访问他人的工作。
-
每个开发者的工作都可以同步。你可能提交和推送,而其他人可能拉取和更新。你和你的队友总是拥有最新的仓库。
-
这些仓库由数据库备份。你可以查看变更的历史记录并跟踪版本,这允许你轻松地恢复以前的版本。
-
由于变更被跟踪,当多个人做出冲突性变更时,你会被提示解决冲突,使代码库保持一致。
-
如果服务器失败,导致仓库丢失,你可以从分布式副本中几乎无损地恢复它,从而提高代码库的整体灾难恢复能力。
D.4 突出显示关键术语
如我之前所述,分布式 VCS 最显著的表现形式是 Git。像这样的理论讨论有助于你理解大局,但要掌握 Git 相关操作需要大量的实践。作为一个入门,我将按字母顺序列出一些关键术语,你可以将此列表作为你 Git 知识构建的基石:
-
分支 —分支是仓库的特定版本。你可以为仓库拥有多个分支。请记住要合理命名分支。
-
克隆 —你创建一个仓库的副本,这样你就可以在你的电脑上工作在复制的版本上。
-
Fork —你在服务器上创建一个仓库的副本,例如在 GitHub 上。
-
主分支 —你创建一个仓库,这将导致一个默认分支。我们称这个默认分支为主分支。主分支也是仓库的主要分支。
-
合并 —当你合并时,你合并分支,以便你可以从这些分支获取更新,并在必要时删除它们。
-
源 —源是创建你的仓库的在线仓库。
-
拉取 —你拉取以从在线仓库获取数据到你的工作仓库。
-
推送 —你推送以将本地仓库上传到在线仓库。
-
变基 —你变基以将一系列提交组合到一个新的基础提交。
-
远程 —远程是我在谈论的在线仓库。因为它通常托管在云服务器上,所以它离你的电脑是远程的。
-
仓库 —仓库是一个综合概念,指的是文件的集合以及它们的元数据,包括变更的历史。
-
回滚 —当你回滚一些提交时,你正在回到一个更早的版本。
附录 E. 准备您的包以供公开分发
E.1 选择 PyPI 作为分发目标
Python 流行的一个主要原因是它拥有庞大的工具库,用于各种专业领域,包括数据科学和机器学习。这些工具中的大多数都是由常规 Python 开发者贡献的。最常见的方式是将代码打包成包并上传到 Python 包索引(PyPI;pypi.org)。上传后,世界上任何使用 pip 工具(附录 B 在线)的 Python 用户都可以轻松下载您的包。在本附录中,我将向您展示如何使用我们构建的任务管理应用程序发布包(第十四章)。
E.2 准备文件
在我们可以发布我们的包之前,我们应该以所需的格式组织目录。使用我们的任务管理应用程序,我们应该有一个如下所示的目录:
taskier_app/
├──howtotaskier/
│ ├──__init__.py
│ ├──taskier_app_helper.py
│ ├──taskier_app.py
│ ├──taskier.py
├──README.md
├──setup.py
README.md 文件包含有关包的信息,例如安装和使用说明。.md 扩展名代表 Markdown 语言,可以将纯文本转换为格式化文本。为了简单起见,假设 README.md 文件具有以下内容:
**We can use the taskier app to manage our daily tasks.**
## License
MIT License
在我们发布包之后,您会看到网页已经格式化了文本(图 E.1)。

图 E.1 将 Markdown 转换为格式化内容
您可能会注意到我们包括了有关包的许可信息,以便用户了解条款。在大多数情况下,您使用其中一个许可来开源您的包,使用户能够自由使用该包。
setup.py 文件包含设置包以供发布的代码。请注意,有不同方式为发布准备您的包。一种常见的方式是使用内置的 setuptools 模块。我们在 setup.py 文件中有以下代码:
import setuptools
with open("README.md") as file:
read_me_description = file.read()
setuptools.setup(
name="unique_package_name",
version="0.1",
author="Your Name",
author_email="your_email_address",
description="This is a task management app.",
long_description=read_me_description,
long_description_content_type="text/markdown",
url="https://github.com/ycui1/python_how_to",
packages=setuptools.find_packages(),
install_requires = ["streamlit"],
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
],
python_requires='>=3.10',
)
在文件中,我们调用设置方法。参数应该是直截了当的。以下列表突出显示了一些参数:
-
名称参数是您的包在 PyPI 上的唯一标识符。将您的 PyPI 用户名附加到包名是一种约定,以确保唯一性。
-
long_description 参数通常使用 README.md 文件的内容设置。为了正确格式化内容,我们在 long_description_content_type 参数中指定内容使用 markdown 语言。
-
setuptools.find_packages() 调用检索我们将要发布的目录中的包。正如我将在本列表的最后一项中讨论的那样,init.py 文件将文件夹标记为 Python 包。
-
由于我们的应用程序依赖于 streamlit,我们在 install_requires 参数中指定了它。
-
Python 正在不断发展,因此我们通过设置 python_requires 参数来确保我们的包具有正确的 Python 版本。
-
我们在 howtotaskier 文件夹中创建了一个名为 init.py 的空文件。此文件将常规目录转换为 Python 包。
E.3 注册 TestPyPI 账户
官方 PyPI 指的是通过pypi.org网站管理的包。值得注意的是,该网站的测试实例允许您在不干扰官方索引的情况下测试您的包发布。为了练习,您将在这个测试实例上工作。
您需要通过访问test.pypi.org/account/register/来为测试实例(与pypi.org上的账户分开)注册一个账户。当您拥有 TestPyPI 账户后,我建议您创建一个应用程序编程接口(API)令牌,您可以使用它来上传您的包。这个过程比输入您的用户名和密码来上传更安全。
要设置 API 令牌,请转到账户设置,并在 API 令牌部分点击添加 API 令牌。您可以指定令牌的作用域:对所有项目或特定项目。在创建令牌后,将其保存到您可以稍后检索的地方非常重要;出于安全原因,您无法重新创建或恢复令牌。
E.4 安装、存档和上传工具
为了发布我们的包,我们需要三个工具:setuptools、wheel 和 twine。您已经看到了我们如何使用 setuptools,这是一个内置模块,无需额外安装。对于这些工具,在 taskier-env 环境(第 14.1 节)中,在命令行工具中运行以下命令:
python3 -m pip install --upgrade setuptools wheel twine
此命令安装或更新必要的工具(如果您已经安装了这些工具)。第 E.5 节展示了如何使用这些工具。
E.5 分发您的包
包分发的第一步是存档包。使用命令行工具,导航到项目的根目录(在本例中是 taskier_app 文件夹)。然后运行以下命令:
python3 setup.py sdist bdist_wheel
此命令使用 wheel 工具与 setuptools 一起存档包,使其准备好上传到 PyPI。运行命令后,您将看到创建了额外的文件,您应该看到以下类似的内容:
taskier_app/
├──build/
│ ├── a bunch of files
├──dist/
│ ├── howtotaskier-ycui1-0.1.tar.gz
│ ├── howtotaskier-ycui1-0.1-py3-none-any.whl
├──howtotaskier/
│ ├──__init__.py
│ ├──taskier_app_helper.py
│ ├──taskier_app.py
│ ├──taskier.py
├──howtotaskier.egg-info/
│ ├── a bunch of files
├──README.md
├──setup.py
这些新目录和文件是存档您的包所需的文档。特别是,dist 文件夹中的两个文件是您将要上传以供公开分发的文件。
包分发的第二步是上传包。要上传,使用 twine 工具运行以下命令:
python3 -m twine upload --repository testpypi dist/*
此命令将 dist 文件夹中的归档包文件上传到 PyPI 网站的测试实例。你将被提示输入用户名和密码。如 E.3 节所述,最好使用 API 令牌。为此,输入 token 作为用户名。密码方面,从你保存令牌的地方复制并粘贴你的令牌。出于安全考虑,令牌或密码是隐藏的。然后按电脑上的 Return(Mac)或 Enter(Windows)键完成上传。如果一切顺利,命令行工具应该会告诉你包已上传,你可以在 test.pypi.org/project/howtotaskier/ 查看项目。
E.6 测试你的包
现在包已上传,你知道它对全世界都是可见的!你可以尝试使用 pip 工具(附录 B 在线)如下安装包:
pip install -i https://test.pypi.org/simple/ howtotaskier
提醒:你可以为测试创建一个单独的环境,这样就不会弄乱当前项目。
安装后,请在 Python 控制台中尝试以下代码:
from howtotaskier.taskier import Task
task = Task.task_from_form_entry("Laundry", "Wash clothes", 3)
print(task)
# output: Laundry (Wash clothes) ***
如所示,你能够使用包中的 Task 类!
附录 F.挑战的解决方案
第一章
没有挑战开始使用 Python。你 赢了!
第二章
第 2.1 节
我们从以下字典对象开始:
product = {"name": "Vacuum", "price": 130.675}
以下是为产生所需输出提供的解决方案:
product_tag = f"{product['name']}: {{{product['price']:.2f}}}"
assert product_tag == "Vacuum: {130.68}"
通常,我们使用花括号来插值变量,因此要使它们表示花括号符号本身而不是插值,你需要使用 {{ 来表示花括号符号本身。因此,{{{var_name} 被解释为一个左花括号加上来自 var_name 的插值字符串。
第 2.2 节
当我们使用 input 函数收集用户输入时,我们得到的是字符串。当我们期望数值时,我们需要将它们转换为相应的数值。我们可以有以下代码:
x = input("What's today's temperature in your area?")
x_num = float(x) ❶
if x_num < 10:
x_output = f"You entered {x_num:.1f} degrees. It's cold!" ❷
elif 10 <= x_num < 25:
x_output = f"You entered {x_num:.1f} degrees. It's cool!"
else:
x_output = f"You entered {x_num:.1f} degrees. It's hot!"
print(x_output)
❶ 将字符串转换为浮点数
❷ .1f 是浮点格式说明符。
如果你多次查看 x_output 的创建方式,你可能注意到一个重复的模式:唯一的不同之处在于描述天气的形容词。因此,更好的解决方案是
x = input("What's today's temperature in your area?")
x_num = float(x)
if x_num < 10:
x_whether = "cold"
elif 10 <= x_num < 25:
x_whether = "cool"
else:
x_whether = "hot"
x_output = f"You entered {x_num:.1f} degrees. It's {x_whether}!"
print(x_output)
第 2.3 节
参数 maxsplit 指定当你使用 split 或 rsplit 时最大分割次数。当你忽略此参数时,两种方法都将使用分隔符的所有出现。或者,如果你设置此参数大于出现次数,你期望两种方法的结果相同,如下所示:
fruits = "apple,orange,pineapple,cherry,watermelon"
assert fruits.split(",") == fruits.split(",", 10) ==
➥ fruits.rsplit(",") == fruits.rsplit(",", 10) ==
➥ ['apple', 'orange', 'pineapple', 'cherry', 'watermelon']
然而,如果你使用一个小于最大可用分割次数的数字,你期望 split 和 rsplit 产生不同的结果:
assert fruits.split(",", 3) == ['apple', 'orange', 'pineapple',
➥ 'cherry,watermelon']
assert fruits.rsplit(",", 3) == ['apple,orange', 'pineapple',
➥ 'cherry', 'watermelon']
第 2.4 节
假设你想要拆分以下字符串:
data_to_split = "abc_,abc__,abc,,__abc_,_abc"
如你所见,分隔符是 _ 和 , 的可变混合。为了拆分此类字符串数据,我们可以使用以下模式:[,_]+,这意味着字符串中可以有多个匹配的 _ 或 , 出现。应用此模式,我们可以创建所需的拆分:
import re
pattern = r"[,_]+" ❶
splitted = re.split(pattern, data_to_split)
print(splitted)
# output: ['abc', 'abc', 'abc', 'abc', 'abc']
❶ 使用原始字符串进行模式创建
第 2.5 节
当我们处理多行文本时,我们可以使用 \n 来标识行的结束。因此,为了在不拆分行的情况下提取所需的记录,我们可以尝试以下模式,指定记录以换行符结束:
text_data = """101, Homework; Complete physics and math
some random nonsense
102, Laundry; Wash all the clothes today
54, random; record
103, Museum; All about Egypt
1234, random; record
Another random record"""
import re
pattern = r"(\d{3}), (\w+); (.+)\n"
splitted = re.findall(pattern, text_data)
print(splitted)
# output: [('101', 'Homework', 'Complete physics and math'), ('102',
➥ 'Laundry', 'Wash all the clothes today'), ('103', 'Museum',
➥ 'All about Egypt'), ('234', 'random', 'record')]
似乎一切正常,但有一个例外:我们还包含了错误的记录 ('234', 'random', 'record')。如果我们将此记录与我们的模式进行比较,匹配它并不令人惊讶,因为我们对三位标识符之前的内容没有任何限制。以下是一个更精确的构建模式的方法:
pattern = r"(?<!\d)(\d{3}), (\w+); (.+)\n"
splitted = re.findall(pattern, text_data)
print(splitted)
# output: [('101', 'Homework', 'Complete physics and math'), ('102',
➥ 'Laundry', 'Wash all the clothes today'), ('103', 'Museum',
➥ 'All about Egypt')]
部分模式 (?<!\d) 被称为 *负向后视断言**,这意味着它只匹配没有数字在其前面的三位数字文本。请注意,此示例展示了正则表达式的高级用法。你可以在官方 Python 网站上找到更多信息:docs.python.org/3/library/re.html。
第三章
第 3.1 节
当您需要保存一系列位置,例如一个人的旅行历史时,您希望使用列表作为数据模型,因为您预计用户可能会更改他们访问过的地方(例如添加新的地方)。
一个位置有一个特定的坐标,您不希望它改变。因此,您希望使用元组来保存坐标数据:
(latitude, longitude)
第 3.2 节
下面是我们想要根据描述长度进行排序的列表:
tasks = [
{'title': 'Laundry', 'desc': 'Wash clothes', 'urgency': 3},
{'title': 'Homework', 'desc': 'Physics + Math', 'urgency': 5},
{'title': 'Museum', 'desc': 'Egyptian things', 'urgency': 2}
]
我们知道我们需要将一个函数设置到关键字参数中,并且该函数应该按照以下方式计算任务描述的长度:
def using_by_desc_len(task):
return len(task["desc"])
tasks.sort(key=using_by_desc_len, reverse=True)
print(tasks)
# output: [{'title': 'Museum', 'desc': 'Egyptian things', 'urgency':
➥ 2}, {'title': 'Homework', 'desc': 'Physics + Math', 'urgency':
➥ 5}, {'title': 'Laundry', 'desc': 'Wash clothes', 'urgency': 3}]
我们定义了一个名为 using_by_desc_len 的函数,该函数返回任务的描述长度。作为提醒,此函数将作为键参数,它必须接受恰好一个参数。由于挑战要求如果描述更长,则任务具有更高的排名,因此必须将 reverse 参数设置为 True。如果您已经了解 lambda 函数(第 7.1 节),则可以使用以下代码进行排序:
tasks.sort(key=lambda x:len(x["desc"]), reverse=True)
第 3.3 节
由于命名元组是一个元组对象,我们不能更改它,因为它是不可变的。如果我们坚持这样做,我们将遇到 AttributeError:
from collections import namedtuple
Task = namedtuple("Task", "title desc urgency")
task = Task(title='Laundry', desc='Wash clothes', urgency=3)
task.urgency = 4
# ERROR: AttributeError: can't set attribute
但命名元组提供了一种名为 _replace 方法的解决方案:
task._replace(urgency=4)
# output: Task(title='Laundry', desc='Wash clothes', urgency=4)
请注意,此方法创建了一个新的元组对象,它具有更改后的值,而不是对原始对象进行原地更改。
第 3.4 节
假设我们有一个名为 numbers 的字典对象:
numbers = {"one": 1, "two": 2, "three": 3}
numbers_key = numbers.keys()
id_key = id(numbers_key)
print(id_key)
# output 140660045849520 ❶
❶ 在您的计算机上预期不同的值。
在此代码片段中,我们通过使用 keys 方法获取键,该方法是一个字典视图对象。内置的 id 函数可以获取此视图对象的内存地址。我们将通过添加新的键值对来更改字典对象:
numbers["four"] = 4
在此更改之后,我们可以看到 keys 在 numbers_key 对象中自动更新,并且内存地址保持不变,因为更新操作是针对同一个对象的:
print(numbers_key)
# output: dict_keys(['one', 'two', 'three', 'four'])
print(id_key)
# output: 140660045849520
第 3.5 节
字典对象中的键必须是可哈希的,因为哈希值将被底层哈希表用作存储机制。当您有具有相同哈希值的键时,将应用最近查看规则:稍后设置的键的值成为该键的值。在我们的例子中,整数 1 和浮点数 1.0 具有相同的哈希值:
assert hash(1) == hash(1.0) == 1
因此,根据最近查看规则,我们应该期望与 1.0 关联的值成为键的值:
numbers = {1: "one", 1.0: "one point one"}
print(numbers)
# output: {1: 'one point one'}
第 3.6 节
如提示所示,这些评估被称为*短路评估**。当 Python 尝试评估 expr_a 或 expr_b 时,如果它发现第一个表达式为 True,则使用第一个对象;否则,它使用第二个表达式。一些例子支持这个规则:
assert ({1, 2, 3} or {4, 5, 6}) == {1, 2, 3}
assert (False or []) == []
assert ("Hello" or "World") == "Hello"
当 Python 尝试评估 expr_a 和 expr_b 时,如果它发现第一个表达式为 False,则使用第一个对象;否则,它使用第二个表达式。一些例子支持这个规则:
assert ({1, 2, 3} and {4, 5, 6}) == {4, 5, 6}
assert (False and []) == False
assert ("Hello" and "World") == "World"
这个规则可能比或操作更难记住。这里有一个提示:因为它们是 and 操作的短路评估,所以只有当两者都为 True 时才被评估为 True。因此,如果 Python 发现第一个表达式为 False,则执行评估;结果必须是 False。因此,Python 使用第一个表达式。
第四章
第 4.1 节
当你从一个切片创建子序列时,它应该与原始序列完全相同。以下是一些示例:
num_list = [1, 2, 3, 4]
num_tuple = (1, 2, 3, 4)
num_str = "1234"
print(num_list[:2])
# output: [1, 2]
print(num_tuple[:2])
# output: (1, 2)
print(num_str[:2])
# output: 12
你可以使用 range 对象进行相同的切片操作。正如你所期望的,子序列也是一个 range 对象:
num_range = range(1, 5)
print(num_range[:2])
# output: range(1, 3)
第 4.2 节
我们需要获取 11 月的销售额。以下是整年的数据:
revenue_by_month = [95, 100, 80, 93, 92, 110, 102, 88, 96, 98, 115, 120]
如同该节所述,我们可以使用 revenue_by_month[-2]通过负索引来获取这个数据点。如果我们想使用正索引,我们可以通过计算长度来获取它:
assert revenue_by_month[-2] ==
➥ revenue_by_month[len(revenue_by_month) - 2]
第 4.3 节
如果你运行以下代码片段,你会遇到 ValueError:
class Task:
def __init__(self, title, urgency):
self.title = title
self.urgency = urgency
tasks = [
Task("Laundry", 3),
Task("Museum", 4),
Task("Homework", 5),
Task("Ticket", 2)
]
task_to_search = Task("Homework", 5)
tasks.index(task_to_search)
# ERROR: ValueError: <__main__.Task object at 0x7fee281be3e0>
➥ is not in list
这种错误的原因是,尽管 task_to_search 看起来在 tasks 列表的第三项具有相同的属性,但自定义类的实例对象默认是不可比较的。内置数据,如字符串,是可比较的,因此你可以使用索引方法来定位项目。为了使比较工作,你必须重写 eq 特殊方法:
class Task:
def __init__(self, title, urgency):
self.title = title
self.urgency = urgency
def __eq__(self, __o: object):
return self.__dict__ == __o.__dict__
tasks = [
Task("Laundry", 3),
Task("Museum", 4),
Task("Homework", 5),
Task("Ticket", 2)
]
task_to_search = Task("Homework", 5)
print(tasks.index(task_to_search))
# output: 2
请注意,你将在第八章学习如何定义自定义类。
第 4.4 节
当你解包一个包含嵌套结构的列表对象时,你可以像它们独立存在一样解包内部结构。以下代码展示了如何操作:
data_to_unpack = [1, (2, 3), 4]
a, (b, c), d = data_to_unpack
print(a, b, c, d)
# output: 1, 2, 3, 4
第 4.5 节
如果你直接将嵌套列表对象乘以 3,你将重复元素三次:
numbers = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]]
print(numbers * 3)
# output: [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12], [1, 2, 3],
➥ [4, 5, 6], [7, 8, 9], [10, 11, 12], [1, 2, 3], [4, 5, 6], [7, 8, 9],
➥ [10, 11, 12]]
然而,所需的输出是将每个元素乘以 3。对于这种类型的数据,你必须使用 for 循环:
numbers_multiplied = []
for number_list in numbers:
embedded_list = []
for number in number_list:
number_multiplied = number * 3
embedded_list.append(number_multiplied)
numbers_multiplied.append(embedded_list)
print(numbers_multiplied)
# output: [[3, 6, 9], [12, 15, 18], [21, 24, 27], [30, 33, 36]]
嵌套的 for 循环不易阅读。一个更好的解决方案是使用列表推导,如第 5.2 节所述:
numbers_multiplied2 = [x*3 for number_list in numbers for x in number_list]
assert numbers_multiplied == numbers_multiplied2
显然,如果你的应用程序涉及大量的数值计算,那么 NumPy 库中的数组等数据结构(参见附录 B 中的包安装说明)是更好的选择。你可以通过以下方式使用 NumPy 库找到更简洁的解决方案:
import numpy as np
numbers_array = np.array(numbers)
print(numbers_array * 3)
# output the following lines:
[[ 3 6 9]
[12 15 18]
[21 24 27]
[30 33 36]]
如此代码片段所示,与 NumPy 数组相乘的操作类似于你通常用数字进行的其他代数运算。这种做法不是更方便吗?
第五章
第 5.1 节
要连接三个或更多可迭代对象,我们按顺序列出它们。zip 迭代器的每个项目都包含来自每个可迭代对象的一个成员,形成一个元组对象,如下例所示:
numbers_int = [1, 2, 3]
numbers_word = ("one", "two", "three")
letters = "abc"
for item in zip(numbers_int, numbers_word, letters):
print(item)
# output the following lines:
(1, 'one', 'a')
(2, 'two', 'b')
(3, 'three', 'c')
由 zip 操作形成的元素数量取决于元素最少的可迭代对象。以下示例提供了一个说明:
numbers_fewer = [1, 2]
numbers_more = [3, 4, 5, 6]
for item in zip(numbers_fewer, numbers_more):
print(item)
# output the following lines:
(1, 3)
(2, 4)
一个可迭代对象 numbers_fewer 有两个元素,而另一个 numbers_more 有四个元素。当我们 zip 它们时,我们有两个配对,与 numbers_fewer 的数量相匹配。
第 5.2 节
尝试使用(expression for item in iterable)运行代码。以下是一个示例:
numbers = [1, 2, 3]
numbers_gen = (x*x for x in numbers)
print(type(numbers_gen))
# output: <class 'generator'>
如此代码片段所示,表达式 (x*x for x in numbers) 创建了一个生成器,这是一种内存高效的迭代器(见第 7.4 节)。显然,它不是一个元组对象,Python 中也没有元组推导的概念。
第 5.3 节
假设我们有一个以下的字典对象:
numbers = {"one": 1, "two": 2}
我们可以遍历这个字典对象的键:
for key in numbers.keys():
print(key)
# output the following lines:
one
two
我们可以遍历这个字典对象的值:
for value in numbers.values():
print(value)
# output the following lines:
1
2
我们可以遍历键值对:
for key, value in numbers.items():
print(f"{key}: {value}")
# output the following lines:
one: 1
two: 2
在前面的代码中,这些项作为键和值形成元组对象,我们可以解包这个元组。值得注意的是,这里有一个语法糖。当我们遍历键时,我们可以直接使用字典对象本身,如下所示:
for key in numbers:
print(key)
# output the following lines:
one
two
第 5.4 节
供您参考,您需要搜索的任务列表如下
from collections import namedtuple
Task = namedtuple("Task", "title, description, urgency")
tasks = [
Task("Toaster", "Clean the toaster", 2),
Task("Camera", "Export photos", 4),
Task("Homework", "Physics and math", 5),
Task("Floor", "Mop the floor", 3),
Task("Internet", "Upgrade plan", 5),
Task("Laundry", "Wash clothes", 3),
Task("Museum", "Egypt exhibit", 4),
Task("Utility", "Pay bills", 5)
]
当您尝试使用 break 语句查找紧急任务时,您可以这样做(如列表 5.7 所示):
first_urgent_task1 = None
for task in tasks:
if task.urgency == 5:
first_urgent_task1 = task
break
print(first_urgent_task1)
# output: Task(title='Homework', description='Physics and math', urgency=5)
挑战在于询问如果我们没有为 first_urgent_task1 设置初始值会发生什么。因为可能我们可能不会遇到任何紧急任务,所以 first_urgent_task1 永远不会被设置,使其无法使用。考虑以下修改以查看潜在的问题:
for task in tasks:
if task.urgency > 5:
first_urgent_task2 = task
break
print(first_urgent_task2)
# ERROR: NameError: name 'first_urgent_task2' is not defined.
如此代码片段所示,如果任务的紧急程度大于 5,我们要求任务必须是紧急的。在这种情况下,似乎没有任务满足这一标准,因此 first_urgen_task2 永远不会被设置。当我们尝试打印它时,我们会遇到 NameError(见第 10.4 节)。
第六章
第 6.1 节
我们可以将时间戳作为默认参数嵌入。这个时间戳反映了定义时的时间,而不是调用时的时间:
from datetime import datetime
from time import sleep
def set_start_time(time=datetime.today()):
print(f"Time: {time}")
for _ in range(3):
set_start_time()
sleep(1.0)
# output the following lines:
Time: 2022-04-25 20:22:06.337848
Time: 2022-04-25 20:22:06.337848
Time: 2022-04-25 20:22:06.337848
如您所见,我们多次调用函数,以为我们可以得到不同的时间戳。但每个时间戳都是相同的,显示了函数创建的时间。
第 6.2 节
返回值具有相同的结构,纬度和经度,我们可以创建一个命名元组来捕获这两个值。以下是一个可能的重构版本:
from collections import namedtuple
Coordinate = namedtuple("Coordinate", ["latitude", "longitude"])
def locate_me():
# look up the user's current location
return coordinate0
def locate_home():
# look up the user's home location
return coordinate1
def locate_work():
# look up the user's work location
return coordinate2
现在我们可以为这些函数中的每一个只返回一个元组对象,而不是返回两个值。
第 6.3 节
以下函数可以接受一个作为 int 或 str 列表参数,并提供类型提示:
def run_computation(numbers: list[int | str]):
pass
在这个例子中,我们使用类型提示:list[int | str],这意味着列表对象可以包含整数或字符串。
第 6.4 节
调用 example(a=1, b=2) 是有效的,因为我们使用了两个关键字参数。调用 example(1, 2) 是无效的,因为我们使用了位置参数,但函数接受关键字参数。调用 example(2a=1, 2b=2) 是无效的,因为这些标识符是无效的(它们不能以数字开头)。调用 example() 是有效的,因为它使用了零个关键字参数。**kwargs 表示可变数量的关键字参数,包括零个关键字参数。
第 6.5 节
我们可以使用以下 Google 风格的文档字符串:
def quotient(dividend, divisor, taking_int=False):
"""
Calculate the product of two numbers with a base factor.
Args:
dividend: int | float, the dividend in the division
divisor: int | float, the divisor in the division
taking_int: bool, whether only taking the integer part of
➥ the quotient;
default: False, which calculates the precise quotient of the
➥ two numbers
Returns:
float | int, the quotient of the dividend and divisor
Raises:
ZeroDivisionError, when the divisor is 0
"""
if divisor == 0:
raise ZeroDivisionError("division by zero")
result = dividend / divisor
if taking_int:
result = int(result)
return result
第七章
第 7.1 节
所有 lambda 函数都有一个名为 <lambda> 的名称,这是它们的官方名称,这也是为什么 lambda 函数被称为匿名函数。相比之下,一个常规定义的函数有一个与函数头部中定义的标识符相匹配的名称:
add_five = lambda x: x + 5
print(add_five.__name__)
# output: <lambda>
def add_ten(x):
return x + 10
print(add_ten.__name__)
# output: add_ten
7.2 节
如提示所述,用户可能会使用不匹配任何指定条件的参数。我们应该为这种不期望的调用做好准备。通过使用 get,当指定的操作不在 actions 字典对象中时,我们可以使用 fallback_action。
7.3 节
如提示所示,我们需要添加另一层函数来处理参数。这是解决方案:
import functools
import time
def logging_time_app(app_name):
def decorator(func):
@functools.wraps(func)
def logger(*args, **kwargs):
"""Log the time"""
print(f"{app_name} --- {func.__name__} starts")
start_t = time.time()
value_returned = func(*args, **kwargs)
end_t = time.time()
print(f"{app_name} *** {func.__name__} ends; used time:
➥ {end_t - start_t:.2f} s")
return value_returned
return logger
return decorator
@logging_time_app("Task Tracker")
def example_app():
pass
example_app()
# output the following lines:
Task Tracker --- example_app starts
Task Tracker *** example_app ends; used time: 0.00 s
最外层的函数 logging_time_app 是装饰器,它将应用程序名称作为其参数。在这个函数内部,我们定义了我们通常使用的典型装饰器,并且这个装饰器接受我们将要装饰的实际函数。
7.4 节
根据提示,我们可以编写以下生成器函数,它生成斐波那契数列中的数字:
def fibonacci(n):
a, b = 0, 1
while a < n:
yield a
a, b = b, a + b
由于斐波那契数列是通过将两个连续的数字相加来创建下一个数字,因此我们用其前两个数字初始化序列,并相应地创建后续的数字。我们可以通过创建一个列表对象来尝试这个函数:
below_fiften = fibonacci(15)
numbers = list(below_fiften)
print(numbers)
# output: [0, 1, 1, 2, 3, 5, 8, 13]
这个列表表示了直到 13 的斐波那契数列。
7.5 节
假设我们有一个名为 run_stats_model 的函数和一个名为 run_stats_model_a 的部分函数:
from functools import partial
def run_stats_model(dataset, model, output_path):
calculated_stats = 123
return calculated_stats
run_stats_model_a = partial(run_stats_model, model="model_a",
➥ output_path="project_a/stats/")
从 run_stats_model 创建部分函数。使用提示,我们可以查看这个部分函数的属性:
print(dir(run_stats_model_a))
# output: ['__call__', '__class__', '__class_getitem__', '__delattr__',
➥ '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__',
➥ '__getattribute__', '__gt__', '__hash__', '__init__',
➥ '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__',
➥ '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__',
➥ '__setstate__', '__sizeof__', '__str__', '__subclasshook__',
➥ '__vectorcalloffset__', 'args', 'func', 'keywords']
如您所见,该函数有一个名为 func 的属性,这可能是告诉我们哪个函数是源函数的属性:
print(run_stats_model_a.func)
# output: <function run_stats_model at 0x7fedf82c30a0>
的确,它是 run_stats_model 函数。您还可以尝试找出 args 和 keywords 属性是什么。
8 章节内容
8.1 节
在 6.1 节中,我说过我们应该使用 None 作为可变参数的默认值。我们也应该在 __init__ 方法中做同样的事情:
class Task:
def __init__(self, title, desc, urgency, tags=None):
self.title = title
self.desc = desc
self.urgency = urgency
if tags is None:
self.tags = []
else:
self.tags = tags
我们还可以尝试三元表达式 var = value_true if condition else value_false。因此,我们可以这样更新前面的代码:
class Task:
def __init__(self, title, desc, urgency, tags=None):
self.title = title
self.desc = desc
self.urgency = urgency
self.tags = [] if tags is None else tags
8.2 节
由于我们从一个元组对象创建实例对象,我们需要访问类的构造函数。因此,我们需要定义一个类方法来访问类的数据:
class Task:
def __init__(self, title, desc, urgency):
self.title = title
self.desc = desc
self.urgency = urgency
@classmethod
def task_from_tuple(cls, data):
title, desc, urgency = data
return cls(title, desc, urgency)
8.3 节
按照列表 8.9 中所示的示例,我们可以将相同的方法应用到紧急情况:
class Task:
def __init__(self, title, desc, urgency):
self.title = title
self.desc = desc
self._urgency = urgency
@property
def urgency(self):
return self._urgency
@urgency.setter
def urgency(self, value):
if value in range(1, 6):
self._urgency = value
else:
raise ValueError("Can't set a value outside of 1 - 5")
详细说明请见列表 8.9。
8.4 节
我们可以不用硬编码类名,而是使用其特殊属性以编程方式检索此信息:
class Task:
def __init__(self, title, desc, urgency):
self.title = title
self.desc = desc
self.urgency = urgency
def __repr__(self):
return f"{self.__class__.__name__}({self.title!r}, {self.desc!r},
➥ {self.urgency})"
特殊属性 __class__ 获取实例对象的类,该类具有 __name__ 特殊属性,可以获取其类名。
8.5 节
以下代码展示了如何在子类中重写初始化方法:
class Employee:
def __init__(self, name, employee_id):
self.name = name
self.employee_id = employee_id
class Supervisor:
def __init__(self, name, employee_id, subordinates):
super().__init__(name, employee_id)
self.subordinates = subordinates
在 Supervisor 类的 __init__ 方法中,我们使用 super() 创建其超类 Employee 的代理对象,这样我们就可以通过发送 name 和 employee_id 来使用其 __init__ 方法。
9 章节内容
9.1 节
因为 move_to 与特定实例相关,我们可以将其转换为 Direction 类的实例方法:
from enum import Enum
class Direction(Enum):
NORTH = 0
SOUTH = 1
EAST = 2
WEST = 3
def __str__(self):
return self.name.lower()
def move_to(self, distance: float):
if self in self.__class__:
message = f"Go to the {self} for {distance} miles"
else:
message = "Wrong input for direction"
print(message)
如此代码片段所示,我们将 move_to 方法的第一个参数重命名为 self,它指向实例对象。在函数体内,我们可以使用 self.class 来获取对 Direction 类的引用。
第 9.2 节
当我们创建数据类时,如果我们为字段设置默认值,我们可以使用 dataclasses 模块的 field 函数,该函数处理可变字段的默认值设置。以下代码显示了如何实现此功能:
from dataclasses import dataclass, field
@dataclass
class Bill:
table_number: int
meal_amount: float
served_by: str
tip_amount: float
dishes: field(default_factory=list)
在此代码中,dishes 字段是可变的,我们可以将 default_factory 参数指定为 list,以便创建一个空列表对象。
第 9.3 节
如提示所述,元组对象是可序列化的,我们可以直接将它们转换为 JavaScript 对象表示法(JSON)字符串,如下所示:
import json
from collections import namedtuple
User = namedtuple("User", "first_name last_name age")
user = User("John", "Smith", "39")
print(json.dumps(user))
# output: ["John", "Smith", "39"]
第 9.4 节
假设你构建了一个客户管理应用,使用以下 Client 数据模型:
class ClientV0:
def __init__(self, first_name, last_name, middle_initial='-'):
self.first_name = first_name
self.last_name = last_name
self.middle_initial = middle_initial
self.initials = first_name[0] + middle_initial + last_name[0]
一切都应该很简单。当你获取实例对象的缩写时,它使用你最初设置的值。但是,应用程序有一个允许用户更改其名称的功能,因此他们的缩写也可能在更新。为了使缩写即时计算,我们可以将属性 initials 转换为函数,如下所示:
class ClientV1:
def __init__(self, first_name, last_name, middle_initial='-'):
self.first_name = first_name
self.last_name = last_name
self.middle_initial = middle_initial
def initials(self):
return self.first_name[0] + self.middle_initial + self.last_name[0]
这种方法可行——但它可能会破坏你的代码。之前,你使用 client.initials 来访问客户的缩写;现在你必须使用 client.initials()。为了避免使用调用操作符,你可以应用属性装饰器:
class ClientV2:
def __init__(self, first_name, last_name, middle_initial='-'):
self.first_name = first_name
self.last_name = last_name
self.middle_initial = middle_initial
@property
def initials(self):
return self.first_name[0] + self.middle_initial + self.last_name[0]
这样,你只需使用 client.initials 来保持你的应用程序编程接口(API)的一致性,但通过调用此属性的函数来提供即时计算。因此,使用装饰器可以帮助你避免 API 中断更改。即使实现已经变成属性而不是属性,你也能保持 API 的一致性。
第 9.5 节
因为所有这些方法都可以是非公开的,所以我通过使用下划线前缀将它们转换为受保护的方法:
class Account:
def __init__(self, student_id):
self.student_id = student_id
# query the database to get additional information using student_id
self.account_number = self._get_account_number_from_db()
self.balance = self._get_balance_from_db()
def _get_account_number_from_db(self):
# query database to locate the account number using student_id
account_number = 123456
return account_number
def _get_balance_from_db(self):
# query database to get the balance for the account number
balance = 100.00
return balance
class Demographics:
def __init__(self, student_id):
self.student_id = student_id
# query the database to get additional information
age, gender, race = self._get_demographics_from_db()
self.age = age
self.gender = gender
self.race = race
def _get_demographics_from_db(self):
# query database to get the demographics using student_id
birthday = "08/14/2010"
age = self._calculated_age(birthday)
gender = "Female"
race = "Black"
return age, gender, race
@staticmethod
def _calculated_age(birthday):
# get today's date and calculate the difference from birthday
age = 12
return age
第十章
第 10.1 节
如提示所述,collections.abc 模块有 Iterable 类,可迭代对象通常应该实现了所需的 iter 方法。因此,我们可以使用 isinstance 函数在这个类上检查一个对象是否是可迭代的:
from collections.abc import Iterable
def is_iterable(obj):
if isinstance(obj, Iterable):
outcome = "is an iterable"
else:
outcome = "is not an iterable"
print(type(obj), outcome)
使用这个更新的函数,我们可以检查一些常见的数据类型:
is_iterable([1, 2, 3])
# output: <class 'list'> is an iterable
is_iterable((404, "Data"))
# output: <class 'tuple'> is an iterable
is_iterable("abc")
# output: <class 'str'> is an iterable
is_iterable(456)
# output: <class 'int'> is not an iterable
第 10.2 节
为了测试在函数中使用变量如何改变引用计数,我们可以编写一个简单的函数:
import sys
class Task:
def __init__(self, title):
self.title = title
task = Task("Homework")
def get_detail(obj):
print(sys.getrefcount(obj))
如果我们用 task 变量调用 get_detail,引用计数变为
get_detail(task)
# output: 4
为什么是 4?第一次计数是任务变量本身。当你调用 get_detail 时,你发送 task,使计数变为 2。函数 get_detail 接收 task,使计数变为 2。在函数体内,调用 sys.getrefcount 添加另一个计数,使计数变为 4。
第 10.3 节
根据挑战中指定的要求,我们可以将我们的 Task 类更新为以下版本:
class Task:
def __init__(self, title, desc, tags = None):
self.title = title
self.desc = desc
self.tags = [] if tags is None else tags
def __copy__(self):
new_title = f"Copied: {self.title}"
new_desc = self.desc
new_tags = self.tags.copy()
new_task = self.__class__(new_title, new_desc, new_tags)
return new_task
In the copy method, we create a new title and a new tags list for the copied object. We can check whether the copy method works as intended by using this code:
from copy import copy
task = Task("Homework", "Math and physics", ["school", "urgent"])
new_task = copy(task)
print(new_task.__dict__)
# output: {'title': 'Copied: Homework', 'desc': 'Math and physics',
➥ 'tags': ['school', 'urgent']}
To double-check whether the tags attributes of these two objects are indeed different, we can try changing one list:
task.tags.append("red")
print(task.tags)
# output: ['school', 'urgent', 'red']
print(new_task.tags)
# output: ['school', 'urgent']
Everything works as expected: task.tags and new_task.tags are two distinct list objects.
第 10.4 节
In Python, the if...else... statement doesn’t form its own scope, unlike classes and functions. As there is no scope, you can change a global variable without using the global keyword, as shown in this example:
import random
weather = "sunny"
if random.randint(1, 100) % 2:
weather = "cloudy"
else:
weather = "rainy"
print(weather)
# output: cloudy ❶
❶ You may get a different result because of the randomness.
As shown in this code snippet, we change the weather variable without the global keyword, indicating that the if...else... statement doesn’t form a scope, making weather fall outside it.
第 10.5 节
When you define a decorator as a class, to keep the metadata for a decorated function, you know that you need to wrap the function. But unlike a decorator function, in which you use the wraps decorator, a class-based decorator uses the method update_wrapper, which helps keep the metadata:
import time
import functools
class TimeLogger:
def __init__(self, func):
functools.update_wrapper(self, func)
def logger(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
print(f"Calling {func.__name__}: {time.time() - start:.5f}")
return result
self._logger = logger
def __call__(self, *args, **kwargs):
return self._logger(*args, **kwargs)
@TimeLogger
def calculate_sum(n):
return sum(range(n))
print(calculate_sum.__name__)
# output: calculate_sum
Using update_wrapper is like using the wraps decorator. You update the wrapper in the init method of the TimeLogger class. Notably, the wraps decorator is syntactic sugar, as it’s invoking the update_wrapper under the hood.
第十一章
第 11.1 节
我们需要给每个项目添加一个换行符。使用列表推导式,我们可以通过 list_data 创建一个新的列表对象:
list_data = [
'1001,Homework,5',
'1002,Laundry,3',
'1003,Grocery,4'
]
updated_list_data = [f"{x}\n" for x in list_data]
With the updated list, we can use the writelines function to produce the desired file. We can double-check whether the writing is successful by reading the data:
with open("tasks_list_write.txt", "w") as file:
file.writelines(updated_list_data)
with open("tasks_list_write.txt") as file:
print(file.read())
# output the following lines:
1001,Homework,5
1002,Laundry,3
1003,Grocery,4
第 11.2 节
The writerows works with a list object, so we can embed each row’s data (list object) within an outer list object, as suggested by the hint:
tasks = [
['1001', 'Homework', '5'],
['1002', 'Laundry', '3'],
['1003', 'Grocery', '4']
]
Then we can run the following code to write this list:
import csv
with open("tasks_writer.txt", "w", newline="") as file:
csv_writer = csv.writer(file)
csv_writer.writerows(tasks)
如果我们打开文件 tasks_writer.txt,我们应该看到数据被正确输入。
第 11.3 节
We override the reduce method within the MaliciousTask class as follows:
import os
class MaliciousTask:
def __init__(self, title, urgency):
self.title = title
self.urgency = urgency
def __reduce__(self):
print("__reduce__ is called")
return os.system, ('rm hacking.txt',)
Specifically, we use ('rm hacking.txt',) instead of ('touch hacking.txt'). The command rm means that we’ll delete the specified file. After updating the class, we can run the code in listing 11.14 to see the effect.
第 11.4 节
We can call the exists method on an instance of the Path class to check a file’s existence. Thus, we can update listing 11.17 to the following version:
from pathlib import Path
import shutil
shutil.rmtree("subjects") ❶
subject_ids = [123, 124, 125]
data_folder = Path("data")
for subject_id in subject_ids:
subject_folder = Path(f"subjects/subject_{subject_id}")
subject_folder.mkdir(parents=True, exist_ok=True)
for subject_file in data_folder.glob(f"*{subject_id}*"):
filename = subject_file.name
target_path = subject_folder / filename
if not target_path.exists():
_ = shutil.copy(subject_file, target_path)
print(f"Copying {filename} to {target_path}")
else:
print(f"{filename} already exists at {target_path}")
❶ 移除现有的文件夹
As highlighted in this code, we copy the files only if the file at the target path doesn’t exist, preventing us from overwriting already-processed files.
第 11.5 节
我们知道可以通过访问文件的 st_mtime 属性来找到文件的修改时间。因此,我们可以创建以下函数来返回过去 24 小时内修改的文件:
from pathlib import Path
import time
def select_recent_files_24h(directory):
dir_path = Path(directory)
current_time = time.time()
time_cutoff = current_time - 24 * 3600
good_files = []
for file_path in dir_path.glob("*"):
file_time = file_path.stat().st_mtime
if time_cutoff <= file_time <= current_time:
good_files.append(file_path)
return good_files
模式"*"允许我们遍历目录中的所有文件。我们指定文件的修改时间必须在过去的 24 小时内。如果一个文件满足这个要求,我们就将其添加到 good_files 列表中,作为这个函数的最终输出。
第十二章
第 12.1 节
我们可以调用记录器的 hasHandlers 方法来检查在添加处理器之前记录器是否有任何处理器:
import logging
logger = logging.getLogger(__name__)
if not logger.hasHandlers():
file_handler = logging.FileHandler("taskier.log")
logger.addHandler(file_handler)
为了清除处理器,我们可以操作记录器的 handlers 属性,它是一个列表对象:
print(logger.handlers)
# output: [<FileHandler /directory/taskier.log (NOTSET)>]
logger.handlers.clear() ❶
print(logger.handlers)
# output: []
❶ 移除所有处理器
第 12.2 节
为了演示会发生什么,我使用了一个流处理器,以便消息可以在控制台中打印:
import logging
logger = logging.getLogger(__name__)
logger.handlers = []
logger.setLevel(logging.WARNING)
stream_handler = logging.StreamHandler()
stream_handler.setLevel(logging.DEBUG)
logger.addHandler(stream_handler)
logger.info("It's an info message.")
# output: None (hide automatically in the console)
logger.warning("It's a warning message.")
# output: It's a warning message.
如果你在这个控制台中运行此代码,你会看到只显示了警告消息;INFO 级别的日志消息低于记录器的级别,因此它不会被发送到处理器。相比之下,WARNING 级别的消息符合记录器的级别要求,并被转发到处理器。
第 12.3 节
如提示所示,你可以在控制台中运行可能有问题代码并查看发生了什么。以下是一个示例:
>>> urgency = int("3#")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: invalid literal for int() with base 10: '3#'
你会看到你遇到了 ValueError 异常。回退并在这个 try...except...语句中添加这个异常:
try:
urgency = int(urgency_str)
except ValueError:
# the operation when ValueError happens
第 12.4 节
如果你运行挑战中的代码,你会看到你的控制台有如下输出:
Done processing text: Laundry,3
finally
你看不到任务被返回,因为在 try 子句的返回语句执行之前,finally 子句中的返回语句已经执行了。
第 12.5 节
为了让你能够多次尝试代码,我定义了一个函数,可以根据不同的输入创建任务:
def create_task(task_title):
try:
print(f"Trying to process {task_title}")
task = Task(task_title)
except TypeError as e:
print(f"Couldn't create the task, error: {e}")
else:
print(f"Created task: {task}")
finally:
print(f"Done processing {task_title}")
这个函数使用了异常处理中的所有四个子句。尝试调用这个函数:
>>> create_task(100)
Trying to process 100
Couldn't create the task, error: Please instantiate the Task using
➥ string as its title
Done processing 100
>>> create_task("Laundry")
Trying to process Laundry
Created task: <__main__.Task object at 0x1043e7b80>
Done processing Laundry
当你使用非 str 对象时,你会看到 try、except 和 finally 子句被执行。当你使用 str 对象时,你会看到 try、else 和 finally 子句被执行。
第十三章
第 13.1 节
有不同的方法使回溯更加复杂。以下是一个可能的解决方案:
class Task:
def __init__(self, title, urgency):
self.title = title
self.urgency = urgency
def _report(self):
print("report")
report = "Urgency: " + self.urgency
def _send_report(self):
print("send report")
self._report()
def _update_db(self):
# update the record in the database
print("update the database")
self._send_report()
def update_urgency(self, urgency):
self.urgency = urgency
self._update_db()
task = Task("Laundry", 3)
task.update_urgency(4)
# output the following lines:
update the database
send report
report
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 17, in update_urgency
File "<stdin>", line 14, in _update_db
File "<stdin>", line 10, in _send_report
File "<stdin>", line 7, in _report
TypeError: can only concatenate str (not "int") to str
在类中,我们使用多种方法相互调用,导致回溯有多个调用。
第 13.2 节
命名空间动态跟踪变量。调用内置的 locals 函数可以揭示在特定时刻本地命名空间中可用的内容。以下代码片段是变化的快照:
$ python3 task_debug.py
> /fullpath/task_debug.py(10)create_task()
-> task_text = obtain_text_data(inject_bug)
(Pdb) locals()
{'inject_bug': False}
(Pdb) n
> /fullpath/task_debug.py(11)create_task()
-> title, urgency_text = task_text.split(",")
(Pdb) locals()
{'inject_bug': False, 'task_text': 'Laundry,3'}
(Pdb)
第 13.3 节
我们可以定义以下函数,从元组对象创建 Task 类的实例:
def create_task_from_tuple(task_tuple):
title, urgency = task_tuple
task = Task(title, urgency)
return task
我们可以在 create_task_from_tuple 函数的测试类中定义以下测试函数:
import unittest
class TestTaskCreation(unittest.TestCase):
def setUp(self):
task_to_compare = Task("Laundry", 3)
self.task_dict = task_to_compare.__dict__
def test_create_task_from_tuple(self):
task_tuple = ("Laundry", 3)
created_task = create_task_from_tuple(task_tuple)
self.assertEqual(created_task.__dict__, self.task_dict)
第 13.4 节
你可以更新方法使其显式地抛出异常。你需要按照以下方式更改 test_class.py 文件中的 Task 类:
class Task:
def __init__(self, title, urgency):
self.title = title
self.urgency = urgency
def formatted_display(self):
displayed_text = f"{self.title} ({self.urgency})"
raise TypeError("This is a TypeError")
# the next return statement will be skipped due to raising
➥ an exception
return displayed_text
当你再次运行 test_task_class.py 时,你将在命令行工具中看到以下输出,显示我们遇到了由于代码中的 TypeError 而导致的错误。请注意,输出显示..E 而不是..F,因为它是一个错误而不是测试失败:
$ python3 test_task_class.py
..E
======================================================================
ERROR: test_formatted_display (__main__.TestTask)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/fullpath/test_task_class.py", line 21, in test_formatted_display
displayed_text = task.formatted_display()
File "/fullpath/task_class.py", line 22, in formatted_display
raise TypeError("This is a TypeError")
TypeError: This is a TypeError
----------------------------------------------------------------------
Ran 3 tests in 0.001s
FAILED (errors=1)
第十四章
第 14.1 节
如果您的电脑是 Mac,请使用终端;如果您的电脑在 Windows 下运行,请使用命令行工具。导航到所需的目录,然后运行以下命令以创建虚拟环境:
$ python3 -m venv python-env
创建虚拟环境后,您需要运行以下命令来激活它:
# for Mac:
$ source taskier-env/bin/activate
# for Windows:
> taskier-env\Scripts\activate.bat
要安装 pandas 库,请运行以下命令:
$ pip install pandas
要在 Visual Studio Code 中使用此虚拟环境,请参阅第 14.1.4 节以获取详细说明。
第 14.2 节
我们可以使用布尔标志来指示记录是否找到:
def delete_from_db(self):
"""Delete the record from the database
"""
if app_db == TaskierDBOption.DB_CSV.value:
with open(app_db, "r+") as file:
lines = file.readlines()
found_record = False
for line in lines:
if line.startswith(self.task_id):
found_record = True
lines.remove(line)
break
if not found_record:
raise Exception("Record not found error.")
else:
file.seek(0)
file.truncate()
file.writelines(lines)
如此代码片段所示,我们为标志设置了一个初始的 False 值。如果我们找到记录,我们将其设置为 True。当布尔值为 False 时,我们可以引发异常。
第 14.3 节
第七章介绍了如何创建一个时间记录装饰器。以下是一个可能的实现,摘自列表 7.9:
import functools
import time
def logging_time_wraps(func):
@functools.wraps(func)
def logger(*args, **kwargs):
"""Log the time"""
print(f"--- {func.__name__} starts")
start_t = time.time()
value_returned = func(*args, **kwargs)
end_t = time.time()
print(f"*** {func.__name__} ends; used time: {end_t -
➥ start_t:.10f} s")
return value_returned
return logger
您可以使用这个装饰器来装饰类中的方法。为了展示一个概念证明,我装饰了 load_tasks 方法:
@classmethod
@logging_time_wraps
def load_tasks(cls, statuses: list[TaskStatus]=None,
➥ urgencies: list[int]=None, content: str=""):
虽然我并不打算进行正式的比较,但看起来 SQLite 3 数据库在数据读取速度方面优于 CSV 文件。请注意,我们处理的是少量数据,因此这两个来源之间的差异似乎微不足道:
# Using the CSV file as the data source
*** load_tasks ends; used time: 0.0008411407 s
--- load_tasks starts
*** load_tasks ends; used time: 0.0005502701 s
--- load_tasks starts
*** load_tasks ends; used time: 0.0004429817 s
--- load_tasks starts
*** load_tasks ends; used time: 0.0002791882 s
--- load_tasks starts
*** load_tasks ends; used time: 0.0003058910 s
--- load_tasks starts
*** load_tasks ends; used time: 0.0005359650 s
--- load_tasks starts
*** load_tasks ends; used time: 0.0002870560 s
--- load_tasks starts
*** load_tasks ends; used time: 0.0004091263 s
--- load_tasks starts
*** load_tasks ends; used time: 0.0004007816 s
--- load_tasks starts
*** load_tasks ends; used time: 0.0002658367 s
# Using the SQLite as the data source
--- load_tasks starts
*** load_tasks ends; used time: 0.0003259182 s
--- load_tasks starts
*** load_tasks ends; used time: 0.0002837181 s
--- load_tasks starts
*** load_tasks ends; used time: 0.0004198551 s
--- load_tasks starts
*** load_tasks ends; used time: 0.0002789497 s
--- load_tasks starts
*** load_tasks ends; used time: 0.0003492832 s
--- load_tasks starts
*** load_tasks ends; used time: 0.0003030300 s
--- load_tasks starts
*** load_tasks ends; used time: 0.0004410744 s
--- load_tasks starts
*** load_tasks ends; used time: 0.0003309250 s
--- load_tasks starts
*** load_tasks ends; used time: 0.0003337860 s
--- load_tasks starts
*** load_tasks ends; used time: 0.0002810955 s
第 14.4 节
我们可以在 streamlit 的 selectbox 小部件中使用任何可迭代对象作为选项。当我们使用字典对象作为可迭代对象时,使用 dict 和 dict.keys()是相同的,如下例所示:
numbers = {0: "zero", 1: "one", 2: "two"}
assert list(numbers) == list(numbers.keys())


浙公网安备 33010602011771号