流畅的-Python-第二版-GPT-重译--全-
流畅的 Python 第二版(GPT 重译)(全)
前言
计划是这样的:当有人使用你不理解的特性时,直接开枪打死他们。这比学习新东西要容易得多,不久之后,活下来的程序员只会用一个容易理解的、微小的 Python 0.9.6 子集来编写代码
。¹ Tim Peters,传奇的核心开发者,Python 之禅的作者
"Python 是一种易于学习、功能强大的编程语言。"这是官方 Python 3.10 教程的开篇词。这是真的,但有一个问题:因为这门语言易学易用,许多实践中的 Python 程序员只利用了它强大特性的一小部分。
有经验的程序员可能在几个小时内就开始编写有用的 Python 代码。当最初富有成效的几个小时变成几周和几个月时,许多开发人员会继续用之前学过的语言的强烈口音编写 Python 代码。即使 Python 是你的第一门语言,在学术界和入门书籍中,它通常被小心地避开语言特定的特性来呈现。
作为一名向有其他语言经验的程序员介绍 Python 的老师,我看到了这本书试图解决的另一个问题:我们只会错过我们知道的东西。来自另一种语言,任何人都可能猜测 Python 支持正则表达式,并在文档中查找。但是,如果你以前从未见过元组解包或描述符,你可能不会搜索它们,最终可能不会使用这些特性,只是因为它们是 Python 特有的。
本书不是 Python 的 A 到 Z 详尽参考。它强调 Python 独有的或在许多其他流行语言中找不到的语言特性。这也主要是一本关于核心语言及其一些库的书。我很少会谈论不在标准库中的包,尽管 Python 包索引现在列出了超过 60,000 个库,其中许多非常有用。
本书适合的读者
本书是为想要精通 Python 3 的在职 Python 程序员编写的。我在 Python 3.10 中测试了这些示例,大部分也在 Python 3.9 和 3.8 中测试过。如果某个示例需要 Python 3.10,会有明确标注。
如果你不确定自己是否有足够的 Python 知识来跟上,请复习官方 Python 教程的主题。除了一些新特性外,本书不会解释教程中涉及的主题。
本书不适合的读者
如果你刚开始学习 Python,这本书可能很难理解。不仅如此,如果你在 Python 学习之旅的早期阶段阅读它,可能会给你一种印象,认为每个 Python 脚本都应该利用特殊方法和元编程技巧。过早的抽象和过早的优化一样糟糕。
五合一的书
我建议每个人都阅读第一章,"Python 数据模型"。本书的核心读者在阅读完第一章后,应该不会有什么困难直接跳到本书的任何部分,但我经常假设你已经阅读了每个特定部分的前面章节。可以把第一部分到第五部分看作是书中之书。
我试图强调在讨论如何构建自己的东西之前先使用现有的东西。例如,在第一部分中,第二章涵盖了现成可用的序列类型,包括一些不太受关注的类型,如collections.deque。用户自定义序列直到第三部分才会讲到,在那里我们还会看到如何利用collections.abc中的抽象基类(ABC)。创建自己的 ABC 要更晚在第三部分中讨论,因为我认为在编写自己的 ABC 之前,熟悉使用现有的 ABC 很重要。
这种方法有几个优点。首先,知道什么是现成可用的,可以避免你重新发明轮子。我们使用现有的集合类比实现自己的集合类更频繁,并且我们可以通过推迟讨论如何创建新类,而将更多注意力放在可用工具的高级用法上。我们也更有可能从现有的 ABC 继承,而不是从头开始创建新的 ABC。最后,我认为在你看到这些抽象的实际应用之后,更容易理解它们。
这种策略的缺点是章节中散布着前向引用。我希望现在你知道我为什么选择这条路,这些引用会更容易容忍。
本书的组织方式
以下是本书各部分的主要主题:
第 I 部分,"数据结构"
第一章介绍了 Python 数据模型,并解释了为什么特殊方法(例如,__repr__)是所有类型的对象行为一致的关键。本书将更详细地介绍特殊方法。本部分的其余章节涵盖了集合类型的使用:序列、映射和集合,以及str与bytes的分离——这给 Python 3 用户带来了许多欢呼,而让迁移代码库的 Python 2 用户感到痛苦。还介绍了标准库中的高级类构建器:命名元组工厂和@dataclass装饰器。第二章、第三章和第五章中的部分介绍了 Python 3.10 中新增的模式匹配,分别讨论了序列模式、映射模式和类模式。第 I 部分的最后一章是关于对象的生命周期:引用、可变性和垃圾回收。
第 II 部分,"作为对象的函数"
在这里,我们讨论作为语言中一等对象的函数:这意味着什么,它如何影响一些流行的设计模式,以及如何通过利用闭包来实现函数装饰器。还涵盖了 Python 中可调用对象的一般概念、函数属性、内省、参数注解以及 Python 3 中新的nonlocal声明。第八章介绍了函数签名中类型提示的主要新主题。
第 III 部分,"类和协议"
现在的重点是"手动"构建类——而不是使用第五章中介绍的类构建器。与任何面向对象(OO)语言一样,Python 有其特定的功能集,这些功能可能存在也可能不存在于你和我学习基于类的编程的语言中。这些章节解释了如何构建自己的集合、抽象基类(ABC)和协议,以及如何处理多重继承,以及如何在有意义时实现运算符重载。第十五章继续介绍类型提示。
第 IV 部分,"控制流"
这一部分涵盖了超越传统的使用条件、循环和子程序的控制流的语言构造和库。我们从生成器开始,然后访问上下文管理器和协程,包括具有挑战性但功能强大的新 yield from 语法。第十八章包含一个重要的示例,在一个简单但功能齐全的语言解释器中使用模式匹配。第十九章,"Python 中的并发模型"是一个新章节,概述了 Python 中并发和并行处理的替代方案、它们的局限性以及软件架构如何允许 Python 在网络规模下运行。我重写了关于异步编程的章节,强调核心语言特性,例如 await、async dev、async for 和 async with,并展示了它们如何与 asyncio 和其他框架一起使用。
第五部分,"元编程"
这一部分从回顾用于构建具有动态创建属性以处理半结构化数据(如 JSON 数据集)的类的技术开始。接下来,我们介绍熟悉的属性机制,然后深入探讨 Python 中对象属性访问如何在较低级别使用描述符工作。解释了函数、方法和描述符之间的关系。在第五部分中,逐步实现字段验证库,揭示了微妙的问题,这些问题导致了最后一章中的高级工具:类装饰器和元类。
动手实践的方法
我们经常会使用交互式 Python 控制台来探索语言和库。我觉得强调这种学习工具的力量很重要,尤其是对那些有更多使用静态编译语言经验而没有提供读取-求值-打印循环(REPL)的读者而言。
标准 Python 测试包之一 doctest,通过模拟控制台会话并验证表达式是否得出所示的响应来工作。我用 doctest 检查了本书中的大部分代码,包括控制台列表。你不需要使用甚至了解 doctest 就可以跟随:doctests 的关键特性是它们看起来像是交互式 Python 控制台会话的记录,所以你可以轻松地自己尝试这些演示。
有时,我会在编写使其通过的代码之前,通过展示 doctest 来解释我们想要完成的任务。在考虑如何做之前牢固地确立要做什么,有助于集中我们的编码工作。先编写测试是测试驱动开发(TDD)的基础,我发现它在教学时也很有帮助。如果你不熟悉 doctest,请查看其文档和本书的示例代码仓库。
我还使用 pytest 为一些较大的示例编写了单元测试——我发现它比标准库中的 unittest 模块更易于使用且功能更强大。你会发现,通过在操作系统的命令行 shell 中键入 python3 -m doctest example_script.py 或 pytest,可以验证本书中大多数代码的正确性。示例代码仓库根目录下的 pytest.ini 配置确保 doctests 被 pytest 命令收集和执行。
皂盒:我的个人观点
从 1998 年开始,我一直在使用、教授和探讨 Python,我喜欢研究和比较编程语言、它们的设计以及背后的理论。在一些章节的末尾,我添加了"皂盒"侧边栏,其中包含我自己对 Python 和其他语言的看法。如果你不喜欢这样的讨论,请随意跳过。它们的内容完全是可选的。
配套网站:fluentpython.com
为了涵盖新特性(如类型提示、数据类和模式匹配),第二版的内容比第一版增加了近 30%。为了保持书本的便携性,我将一些内容移至 fluentpython.com。你会在几个章节中找到我在那里发表的文章的链接。配套网站上也有一些示例章节。完整文本可在 O'Reilly Learning 订阅服务的在线版本中获得。示例代码仓库在 GitHub 上。
本书中使用的约定
本书使用以下排版惯例:
Italic
表示新术语、URL、电子邮件地址、文件名和文件扩展名。
Constant width
用于程序清单,以及在段落内引用程序元素,如变量或函数名、数据库、数据类型、环境变量、语句和关键字。
请注意,当换行符出现在 constant_width 术语中时,不会添加连字符,因为它可能被误解为术语的一部分。
Constant width bold
显示用户应按字面意思键入的命令或其他文本。
Constant width italic
显示应由用户提供的值或由上下文确定的值替换的文本。
提示
此元素表示提示或建议。
注意
此元素表示一般注释。
警告
此元素表示警告或注意事项。
使用代码示例
书中出现的每个脚本和大多数代码片段都可在 GitHub 上的 Fluent Python 代码仓库中找到,网址为 https://fpy.li/code。
如果你有技术问题或使用代码示例的问题,请发送电子邮件至 bookquestions@oreilly.com。
这本书旨在帮助你完成工作。一般来说,如果本书提供了示例代码,你可以在程序和文档中使用它。除非你要复制大量代码,否则无需联系我们征得许可。例如,编写一个使用本书多个代码片段的程序不需要许可。出售或分发 O'Reilly 图书中的示例需要获得许可。通过引用本书和引用示例代码来回答问题不需要许可。将本书中大量示例代码合并到你的产品文档中确实需要许可。
我们感谢但通常不要求注明出处。出处通常包括标题、作者、出版商和 ISBN,例如:"Fluent Python,第 2 版,Luciano Ramalho 著(O'Reilly)。2022 Luciano Ramalho 版权所有,978-1-492-05635-5。"
如果你认为你对代码示例的使用超出了合理使用范围或上述许可范围,请随时通过 permissions@oreilly.com 与我们联系。
致谢
我没想到五年后更新一本 Python 书会是如此重大的任务,但事实如此。我挚爱的妻子 Marta Mello 总是在我需要她的时候出现。我亲爱的朋友 Leonardo Rochael 从最早的写作到最后的技术审核都一直帮助我,包括整合和复核其他技术审核人员、读者和编辑的反馈。说实话,如果没有你们的支持,Marta 和 Leo,我不知道自己是否能做到。非常感谢你们!
Jürgen Gmach、Caleb Hattingh、Jess Males、Leonardo Rochael 和 Miroslav Šedivý 是第二版的杰出技术审查团队。他们审阅了整本书。Bill Behrman、Bruce Eckel、Renato Oliveira 和 Rodrigo Bernardo Pimentel 审阅了特定章节。他们从不同角度提出的许多建议使本书变得更好。
在早期发布阶段,许多读者发送了更正或做出了其他贡献,包括:Guilherme Alves、Christiano Anderson、Konstantin Baikov、K. Alex Birch、Michael Boesl、Lucas Brunialti、Sergio Cortez、Gino Crecco、Chukwuerika Dike、Juan Esteras、Federico Fissore、Will Frey、Tim Gates、Alexander Hagerman、Chen Hanxiao、Sam Hyeong、Simon Ilincev、Parag Kalra、Tim King、David Kwast、Tina Lapine、Wanpeng Li、Guto Maia、Scott Martindale、Mark Meyer、Andy McFarland、Chad McIntire、Diego Rabatone Oliveira、Francesco Piccoli、Meredith Rawls、Michael Robinson、Federico Tula Rovaletti、Tushar Sadhwani、Arthur Constantino Scardua、Randal L. Schwartz、Avichai Sefati、Guannan Shen、William Simpson、Vivek Vashist、Jerry Zhang、Paul Zuradzki 以及其他不愿透露姓名的人,在我交稿后发送了更正,或者因为我没有记录他们的名字而被遗漏——抱歉。
在研究过程中,我在与 Michael Albert、Pablo Aguilar、Kaleb Barrett、David Beazley、J.S.O. Bueno、Bruce Eckel、Martin Fowler、Ivan Levkivskyi、Alex Martelli、Peter Norvig、Sebastian Rittau、Guido van Rossum、Carol Willing 和 Jelle Zijlstra 的互动中了解了类型、并发、模式匹配和元编程。
O'Reilly 编辑 Jeff Bleiel、Jill Leonard 和 Amelia Blevins 提出的建议在许多地方改善了本书的流畅度。Jeff Bleiel 和制作编辑 Danny Elfanbaum 在整个漫长的马拉松中都一直支持我。
他们每个人的见解和建议都让这本书变得更好、更准确。不可避免地,最终产品中仍然会有我自己制造的错误。我提前表示歉意。
最后,我要向我在 Thoughtworks 巴西的同事们表示衷心的感谢,尤其是我的赞助人 Alexey Bôas,他们一直以多种方式支持这个项目。
当然,每一个帮助我理解 Python 并编写第一版的人现在都应该得到双倍的感谢。没有成功的第一版就不会有第二版。
第一版致谢
Josef Hartwig 设计的包豪斯国际象棋是优秀设计的典范:美观、简洁、清晰。建筑师之子、字体设计大师之弟 Guido van Rossum 创造了一部语言设计的杰作。我喜欢教授 Python,因为它美观、简洁、清晰。
Alex Martelli 和 Anna Ravenscroft 是最早看到本书大纲并鼓励我将其提交给 O'Reilly 出版的人。他们的书教会了我地道的 Python,是技术写作在清晰、准确和深度方面的典范。Alex 在 Stack Overflow 上的 6,200 多个帖子是语言及其正确使用方面的见解源泉。
Martelli 和 Ravenscroft 也是本书的技术评审,还有 Lennart Regebro 和 Leonardo Rochael。这个杰出的技术评审团队中的每个人都有至少 15 年的 Python 经验,对与社区中其他开发人员密切联系的高影响力 Python 项目做出了许多贡献。他们一起给我发来了数百条修正、建议、问题和意见,为本书增添了巨大的价值。Victor Stinner 友好地审阅了第二十一章,将他作为 asyncio 维护者的专业知识带到了技术评审团队中。能在过去的几个月里与他们合作,我感到非常荣幸和愉快。
编辑 Meghan Blanchette 是一位杰出的导师,帮助我改进了本书的组织和流程,让我知道什么时候它变得无聊,并阻止我进一步拖延。Brian MacDonald 在 Meghan 不在时编辑了第二部分的章节。我很高兴与他们以及我在 O'Reilly 联系过的每个人合作,包括 Atlas 开发和支持团队(Atlas 是 O'Reilly 的图书出版平台,我很幸运能使用它来写这本书)。
Mario Domenech Goulart 从第一个早期版本开始就提供了大量详细的建议。我还收到了 Dave Pawson、Elias Dorneles、Leonardo Alexandre Ferreira Leite、Bruce Eckel、J.S. Bueno、Rafael Gonçalves、Alex Chiaranda、Guto Maia、Lucas Vido 和 Lucas Brunialti 的宝贵反馈。
多年来,许多人敦促我成为一名作家,但最有说服力的是 Rubens Prates、Aurelio Jargas、Rudá Moura 和 Rubens Altimari。Mauricio Bussab 为我打开了许多大门,包括我第一次真正尝试写书。Renzo Nuccitelli 一路支持这个写作项目,即使这意味着我们在 python.pro.br 的合作起步缓慢。
美妙的巴西 Python 社区知识渊博、慷慨大方、充满乐趣。Python Brasil 小组有数千人,我们的全国会议汇聚了数百人,但在我的 Pythonista 旅程中最具影响力的是 Leonardo Rochael、Adriano Petrich、Daniel Vainsencher、Rodrigo RBP Pimentel、Bruno Gola、Leonardo Santagada、Jean Ferri、Rodrigo Senra、 J.S. Bueno、David Kwast、Luiz Irber、Osvaldo Santana、Fernando Masanori、Henrique Bastos、Gustavo Niemayer、Pedro Werneck、Gustavo Barbieri、Lalo Martins、Danilo Bellini 和 Pedro Kroger。
Dorneles Tremea 是一位伟大的朋友(他慷慨地奉献时间和知识),一位了不起的黑客,也是巴西 Python 协会最鼓舞人心的领导者。他离开得太早了。
多年来,我的学生通过他们的提问、见解、反馈和创造性的问题解决方案教会了我很多东西。Érico Andrei 和 Simples Consultoria 让我第一次能够专注于当一名 Python 老师。
Martijn Faassen 是我的 Grok 导师,与我分享了关于 Python 和尼安德特人的宝贵见解。他以及 Paul Everitt、Chris McDonough、Tres Seaver、Jim Fulton、Shane Hathaway、Lennart Regebro、Alan Runyan、Alexander Limi、Martijn Pieters、Godefroid Chapelle 等来自 Zope、Plone 和 Pyramid 星球的人的工作对我的职业生涯起到了决定性作用。多亏了 Zope 和冲浪第一波网络浪潮,我能够从 1998 年开始以 Python 谋生。José Octavio Castro Neves 是我在巴西第一家以 Python 为中心的软件公司的合伙人。
在更广泛的 Python 社区中,我有太多的大师无法一一列举,但除了已经提到的那些,我还要感谢 Steve Holden、Raymond Hettinger、A.M. Kuchling、David Beazley、Fredrik Lundh、Doug Hellmann、Nick Coghlan、Mark Pilgrim、Martijn Pieters、Bruce Eckel、Michele Simionato、Wesley Chun、Brandon Craig Rhodes、Philip Guo、Daniel Greenfeld、Audrey Roy 和 Brett Slatkin,感谢他们教会我新的更好的 Python 教学方式。
这些页面大部分是在我的家庭办公室和两个实验室写的:CoffeeLab 和 Garoa Hacker Clube。CoffeeLab 是位于巴西圣保罗 Vila Madalena 的咖啡因极客总部。Garoa Hacker Clube 是一个向所有人开放的黑客空间:一个社区实验室,任何人都可以自由尝试新想法。
Garoa 社区提供了灵感、基础设施和宽松的环境。我想 Aleph 会喜欢这本书。
我的母亲 Maria Lucia 和父亲 Jairo 总是全力支持我。我希望他能在这里看到这本书,我很高兴能与她分享。
我的妻子 Marta Mello 忍受了 15 个月总是在工作的丈夫,但她仍然保持支持,并在我担心可能会退出这个马拉松项目的一些关键时刻给予我指导。
谢谢你们,感谢一切。
¹ 2002 年 12 月 23 日在 comp.lang.python Usenet 小组的留言:"Acrimony in c.l.p"。
第一部分:数据结构
第一章:Python 数据模型
Guido 在语言设计美学方面的感觉令人惊叹。我遇到过许多优秀的语言设计师,他们能构建理论上漂亮但无人会使用的语言,但 Guido 是为数不多的能够构建一门理论上略微欠缺但编写程序时充满乐趣的语言的人。
Jim Hugunin,Jython 的创建者,AspectJ 的联合创建者,以及.Net DLR¹的架构师
Python 最大的优点之一是其一致性。使用 Python 一段时间后,你能够开始对新接触到的特性做出有根据的、正确的猜测。
然而,如果你在学 Python 之前学过其他面向对象语言,你可能会觉得使用len(collection)而不是collection.len()很奇怪。这个明显的奇怪之处只是冰山一角,一旦正确理解,它就是我们称之为Pythonic的一切的关键。这个冰山被称为 Python 数据模型,它是我们用来使自己的对象与最符合语言习惯的特性很好地配合的 API。
你可以将数据模型视为对 Python 作为框架的描述。它规范了语言本身的构建块的接口,例如序列、函数、迭代器、协程、类、上下文管理器等。
使用框架时,我们会花费大量时间编写被框架调用的方法。在利用 Python 数据模型构建新类时也会发生同样的情况。Python 解释器调用特殊方法来执行基本的对象操作,通常由特殊语法触发。特殊方法名总是以双下划线开头和结尾。例如,语法obj[key]由__getitem__特殊方法支持。为了计算my_collection[key],解释器会调用my_collection.__getitem__(key)。
当我们希望对象支持并与基本语言结构交互时,我们会实现特殊方法,例如:
-
集合
-
属性访问
-
迭代(包括使用
async for进行的异步迭代) -
运算符重载
-
函数和方法调用
-
字符串表示和格式化
-
使用
await进行异步编程 -
对象的创建和销毁
-
使用
with或async with语句管理上下文
Magic 和 Dunder
"魔术方法"是特殊方法的俚语,但我们如何谈论像__getitem__这样的特定方法呢?我从作者和教师 Steve Holden 那里学会了说"dunder-getitem"。"Dunder"是"前后双下划线"的缩写。这就是为什么特殊方法也被称为dunder 方法。Python 语言参考的"词法分析"章节警告说,"在任何上下文中,任何不遵循明确记录的__*__名称的使用都可能在没有警告的情况下被破坏。"
本章的新内容
本章与第一版相比变化不大,因为它是对 Python 数据模型的介绍,而数据模型相当稳定。最重要的变化是:
-
支持异步编程和其他新特性的特殊方法,已添加到"特殊方法概述"的表格中。
-
图 1-2 展示了在"集合 API"中特殊方法的使用,包括 Python 3.6 中引入的
collections.abc.Collection抽象基类。
此外,在第二版中,我采用了 Python 3.6 引入的f-string语法,它比旧的字符串格式化表示法(str.format()方法和%运算符)更具可读性,通常也更方便。
提示
仍然使用 my_fmt.format() 的一个原因是,当 my_fmt 的定义必须在代码中与格式化操作需要发生的地方不同的位置时。例如,当 my_fmt 有多行并且最好在常量中定义时,或者当它必须来自配置文件或数据库时。这些都是真正的需求,但不会经常发生。
Python 风格的纸牌
示例 1-1 很简单,但它展示了仅实现两个特殊方法 __getitem__ 和 __len__ 的强大功能。
示例 1-1. 一副扑克牌序列
import collections
Card = collections.namedtuple('Card', ['rank', 'suit'])
class FrenchDeck:
ranks = [str(n) for n in range(2, 11)] + list('JQKA')
suits = 'spades diamonds clubs hearts'.split()
def __init__(self):
self._cards = [Card(rank, suit) for suit in self.suits
for rank in self.ranks]
def __len__(self):
return len(self._cards)
def __getitem__(self, position):
return self._cards[position]
首先要注意的是使用 collections.namedtuple 构造一个简单的类来表示单个牌。我们使用namedtuple 来构建只有属性而没有自定义方法的对象类,就像数据库记录一样。在示例中,我们使用它为牌组中的牌提供了一个很好的表示,如控制台会话所示:
>>> beer_card = Card('7', 'diamonds')
>>> beer_card
Card(rank='7', suit='diamonds')
但这个例子的重点是 FrenchDeck 类。它很短,但却很有冲击力。首先,像任何标准 Python 集合一样,牌组响应 len() 函数并返回其中的牌数:
>>> deck = FrenchDeck()
>>> len(deck)
52
读取牌组中的特定牌(例如第一张或最后一张)很容易,这要归功于 __getitem__ 方法:
>>> deck[0]
Card(rank='2', suit='spades')
>>> deck[-1]
Card(rank='A', suit='hearts')
我们应该创建一个方法来随机抽取一张牌吗?没有必要。Python 已经有一个从序列中获取随机项的函数:random.choice。我们可以在一个 deck 实例上使用它:
>>> from random import choice
>>> choice(deck)
Card(rank='3', suit='hearts')
>>> choice(deck)
Card(rank='K', suit='spades')
>>> choice(deck)
Card(rank='2', suit='clubs')
我们刚刚看到了利用 Python 数据模型使用特殊方法的两个优点:
-
你的类的用户不必记住标准操作的任意方法名称。("如何获得项目数?是
.size()、.length()还是什么?") -
从丰富的 Python 标准库中受益并避免重新发明轮子更容易,比如
random.choice函数。
但它变得更好了。
因为我们的 __getitem__ 将工作委托给 self._cards 的 [] 运算符,所以我们的牌组自动支持切片。以下是我们如何查看全新牌组中的前三张牌,然后从索引 12 开始每次跳过 13 张牌来选出四张 A:
>>> deck[:3]
[Card(rank='2', suit='spades'), Card(rank='3', suit='spades'),
Card(rank='4', suit='spades')]
>>> deck[12::13]
[Card(rank='A', suit='spades'), Card(rank='A', suit='diamonds'),
Card(rank='A', suit='clubs'), Card(rank='A', suit='hearts')]
只需实现 __getitem__ 特殊方法,我们的牌组也是可迭代的:
>>> for card in deck: # doctest: +ELLIPSIS
... print(card)
Card(rank='2', suit='spades')
Card(rank='3', suit='spades')
Card(rank='4', suit='spades')
...
我们还可以反向迭代牌组:
>>> for card in reversed(deck): # doctest: +ELLIPSIS
... print(card)
Card(rank='A', suit='hearts')
Card(rank='K', suit='hearts')
Card(rank='Q', suit='hearts')
...
doctest 中的省略号
只要有可能,我就会从 doctest 中提取本书中的 Python 控制台列表以确保准确性。当输出太长时,省略部分用省略号(...)标记,就像前面代码中的最后一行。在这种情况下,我使用 # doctest: +ELLIPSIS 指令来使 doctest 通过。如果你在交互式控制台中尝试这些示例,你可以完全忽略 doctest 注释。
迭代通常是隐式的。如果一个集合没有 __contains__ 方法,in 运算符会进行顺序扫描。恰好:in 适用于我们的 FrenchDeck 类,因为它是可迭代的。看看这个:
>>> Card('Q', 'hearts') in deck
True
>>> Card('7', 'beasts') in deck
False
那么排序呢?一个常见的牌的排名系统是先按点数(A 最高),然后按花色顺序:黑桃(最高)、红心、方块和梅花(最低)。这是一个函数,它根据该规则对牌进行排名,梅花 2 返回0,黑桃 A 返回51:
suit_values = dict(spades=3, hearts=2, diamonds=1, clubs=0)
def spades_high(card):
rank_value = FrenchDeck.ranks.index(card.rank)
return rank_value * len(suit_values) + suit_values[card.suit]
有了 spades_high,我们现在可以按点数递增的顺序列出我们的牌组:
>>> for card in sorted(deck, key=spades_high): # doctest: +ELLIPSIS
... print(card)
Card(rank='2', suit='clubs')
Card(rank='2', suit='diamonds')
Card(rank='2', suit='hearts')
... (46 cards omitted)
Card(rank='A', suit='diamonds')
Card(rank='A', suit='hearts')
Card(rank='A', suit='spades')
虽然 FrenchDeck 隐式继承自 object 类,但其大部分功能不是继承而来的,而是通过利用数据模型和组合来实现的。通过实现特殊方法 __len__ 和 __getitem__,我们的 FrenchDeck 表现得像一个标准的 Python 序列,允许它从核心语言特性(例如迭代和切片)和标准库中受益,如使用 random.choice、reversed 和 sorted 的示例所示。得益于组合,__len__ 和 __getitem__ 实现可以将所有工作委托给一个 list 对象 self._cards。
那么洗牌呢?
到目前为止,FrenchDeck无法被洗牌,因为它是不可变的:卡片及其位置不能被改变,除非违反封装并直接处理_cards属性。在第十三章中,我们将通过添加一行__setitem__方法来解决这个问题。
特殊方法的使用方式
关于特殊方法需要知道的第一件事是,它们是由 Python 解释器调用的,而不是由你调用的。你不会写my_object.__len__()。你写的是len(my_object),如果my_object是一个用户定义类的实例,那么 Python 会调用你实现的__len__方法。
但是当处理内置类型如list、str、bytearray,或者像 NumPy 数组这样的扩展类型时,解释器会采取一种快捷方式。用 C 语言编写的可变长度 Python 集合包括一个名为PyVarObject的结构体²,其中有一个ob_size字段,用于保存集合中的项数。因此,如果my_object是这些内置类型之一的实例,那么len(my_object)会直接获取ob_size字段的值,这比调用一个方法要快得多。
通常情况下,特殊方法的调用是隐式的。例如,语句for i in x:实际上会调用iter(x),如果x有__iter__()方法,则会调用它,否则会像FrenchDeck示例那样使用x.__getitem__()。
通常,你的代码不应该有太多直接调用特殊方法的地方。除非你在做大量的元编程,否则你应该更多地实现特殊方法,而不是显式地调用它们。唯一经常被用户代码直接调用的特殊方法是__init__,用于在你自己的__init__实现中调用超类的初始化方法。
如果你需要调用一个特殊方法,通常最好调用相关的内置函数(例如len、iter、str等)。这些内置函数会调用相应的特殊方法,但通常还提供其他服务,并且对于内置类型来说,比方法调用更快。例如,参见第十七章中的"与可调用对象一起使用 iter"。
在接下来的部分中,我们将看到特殊方法的一些最重要的用途:
-
模拟数值类型
-
对象的字符串表示
-
对象的布尔值
-
实现集合类
模拟数值类型
几个特殊方法允许用户对象响应诸如+之类的运算符。我们将在第十六章中更详细地介绍这一点,但这里我们的目标是通过另一个简单的例子来进一步说明特殊方法的使用。
我们将实现一个类来表示二维向量——即数学和物理中使用的欧几里得向量(参见图 1-1)。
小贴士
内置的complex类型可以用来表示二维向量,但我们的类可以扩展为表示n维向量。我们将在第十七章中实现这一点。

图 1-1. 二维向量加法示例;Vector(2, 4) + Vector(2, 1) 的结果是 Vector(4, 5)。
我们将通过编写一个模拟控制台会话来开始设计这个类的 API,稍后我们可以将其用作文档测试。下面的代码片段测试了图 1-1 中所示的向量加法:
>>> v1 = Vector(2, 4)
>>> v2 = Vector(2, 1)
>>> v1 + v2
Vector(4, 5)
请注意+运算符如何生成一个新的Vector,并以友好的格式显示在控制台上。
内置函数abs返回整数和浮点数的绝对值,以及complex数的模,所以为了保持一致,我们的 API 也使用abs来计算向量的模:
>>> v = Vector(3, 4)
>>> abs(v)
5.0
我们还可以实现*运算符来执行标量乘法(即,将一个向量乘以一个数来得到一个新的向量,其方向相同,但大小被乘以该数):
>>> v * 3
Vector(9, 12)
>>> abs(v * 3)
15.0
示例 1-2 是一个Vector类,通过使用特殊方法__repr__、__abs__、__add__和__mul__实现了刚才描述的操作。
示例 1-2. 一个简单的二维向量类
"""
vector2d.py: a simplistic class demonstrating some special methods
It is simplistic for didactic reasons. It lacks proper error handling,
especially in the ``__add__`` and ``__mul__`` methods.
This example is greatly expanded later in the book.
Addition::
>>> v1 = Vector(2, 4)
>>> v2 = Vector(2, 1)
>>> v1 + v2
Vector(4, 5)
Absolute value::
>>> v = Vector(3, 4)
>>> abs(v)
5.0
Scalar multiplication::
>>> v * 3
Vector(9, 12)
>>> abs(v * 3)
15.0
"""
import math
class Vector:
def __init__(self, x=0, y=0):
self.x = x
self.y = y
def __repr__(self):
return f'Vector({self.x!r}, {self.y!r})'
def __abs__(self):
return math.hypot(self.x, self.y)
def __bool__(self):
return bool(abs(self))
def __add__(self, other):
x = self.x + other.x
y = self.y + other.y
return Vector(x, y)
def __mul__(self, scalar):
return Vector(self.x * scalar, self.y * scalar)
除了熟悉的__init__之外,我们还实现了五个特殊方法。请注意,在类中或 doctests 所说明的类的典型用法中,没有一个方法是直接调用的。如前所述,Python 解释器是大多数特殊方法的唯一频繁调用者。
示例 1-2 实现了两个操作符:+和*,以展示__add__和__mul__的基本用法。在这两种情况下,方法都会创建并返回Vector的新实例,而不会修改任何一个操作数——self或other只是被读取。这是中缀操作符的预期行为:创建新对象而不接触其操作数。我将在第十六章中对此有更多说明。
警告
按照实现,示例 1-2 允许Vector乘以一个数,但不允许数乘以Vector,这违反了标量乘法的交换律。我们将在第十六章中用特殊方法__rmul__来解决这个问题。
在接下来的部分中,我们将讨论Vector中的其他特殊方法。
字符串表示
内置的repr函数会调用特殊方法__repr__来获取对象的字符串表示,以便检查。如果没有自定义__repr__,Python 控制台会显示Vector实例<Vector object at 0x10e100070>。
交互式控制台和调试器对计算结果调用repr,经典的%操作符格式化中的%r占位符以及f-strings中新的格式字符串语法使用的!r转换字段中的str.format方法也是如此。
请注意,我们__repr__中的f-string使用!r来获取要显示的属性的标准表示。这是个好习惯,因为它展示了Vector(1, 2)和Vector('1', '2')之间的关键区别——在这个例子中,后者不起作用,因为构造函数的参数应该是数字,而不是str。
__repr__返回的字符串应该是明确的,如果可能的话,应该与重新创建所表示对象所需的源代码相匹配。这就是为什么我们的Vector表示看起来像调用类的构造函数(例如Vector(3, 4))。
相比之下,内置的str()函数会调用__str__,print函数也会隐式地使用它。它应该返回一个适合向终端用户显示的字符串。
有时__repr__返回的相同字符串对用户友好,你不需要编写__str__,因为从object类继承的实现会调用__repr__作为后备。示例 5-2 是本书中有自定义__str__的几个示例之一。
提示
有其他语言toString方法使用经验的程序员倾向于实现__str__而不是__repr__。如果你在 Python 中只实现这两个特殊方法之一,选择__repr__。
"Python 中__str__和__repr__有什么区别?"是一个 Stack Overflow 的问题,Python 专家 Alex Martelli 和 Martijn Pieters 对此做出了精彩的贡献。
自定义类型的布尔值
尽管 Python 有bool类型,但它在布尔上下文中接受任何对象,例如控制if或while语句的表达式,或者作为and、or和not的操作数。为了确定一个值x是truthy还是falsy,Python 会应用bool(x),它返回True或False。
默认情况下,用户定义类的实例被视为真值,除非实现了__bool__或__len__。基本上,bool(x)调用x.__bool__()并使用结果。如果没有实现__bool__,Python 会尝试调用x.__len__(),如果返回零,bool返回False。否则bool返回True。
我们对__bool__的实现在概念上很简单:如果向量的大小为零,则返回False,否则返回True。我们使用bool(abs(self))将大小转换为布尔值,因为__bool__期望返回布尔值。在__bool__方法之外,很少需要显式调用bool(),因为任何对象都可以用在布尔上下文中。
注意特殊方法__bool__如何允许你的对象遵循Python 标准库文档的"内置类型"章节中定义的真值测试规则。
注意
Vector.__bool__的更快实现是:
def __bool__(self):
return bool(self.x or self.y)
这更难阅读,但避免了通过abs、__abs__、平方和平方根的旅程。需要显式转换为bool,因为__bool__必须返回布尔值,而or会原样返回任一操作数:如果x为真值,则x or y求值为x,否则结果为y,无论是什么。
Collection API
图 1-2 展示了该语言中基本集合类型的接口。图中所有的类都是抽象基类(ABC)。第十三章涵盖了 ABC 和collections.abc模块。本节的目标是全面概览 Python 最重要的集合接口,展示它们是如何由特殊方法构建而成的。

图 1-2. 包含基本集合类型的 UML 类图。斜体方法名是抽象的,因此必须由具体子类如list和dict实现。其余方法有具体实现,因此子类可以继承它们。
每个顶层 ABC 都有一个单独的特殊方法。Collection ABC(Python 3.6 新增)统一了每个集合应该实现的三个基本接口:
-
Iterable支持for、解包和其他形式的迭代 -
Sized支持内置函数len -
Container支持in运算符
Python 并不要求具体类实际继承任何这些 ABC。任何实现了__len__的类都满足Sized接口。
Collection的三个非常重要的特化是:
-
Sequence,形式化了list和str等内置类型的接口 -
Mapping,由dict、collections.defaultdict等实现。 -
Set,内置类型set和frozenset的接口
只有Sequence是Reversible的,因为序列支持任意顺序的内容,而映射和集合则不支持。
注意
从 Python 3.7 开始,dict类型正式"有序",但这只意味着保留了键的插入顺序。你不能随意重新排列dict中的键。
Set ABC 中的所有特殊方法都实现了中缀运算符。例如,a & b计算集合a和b的交集,在__and__特殊方法中实现。
接下来两章将详细介绍标准库序列、映射和集合。
现在让我们考虑 Python 数据模型中定义的主要特殊方法类别。
特殊方法概览
Python 语言参考的"数据模型"章节列出了 80 多个特殊方法名。其中一半以上实现了算术、位运算和比较运算符。关于可用内容的概览,请参见下表。
表 1-1 展示了特殊方法名,不包括用于实现中缀运算符或核心数学函数(如abs)的方法名。本书将涵盖其中大部分方法,包括最新增加的:异步特殊方法如 __anext__(Python 3.5 新增),以及类定制钩子 __init_subclass__(Python 3.6 新增)。
表 1-1. 特殊方法名(不包括运算符)
| 类别 | 方法名 |
|---|---|
| 字符串/字节表示 | __repr__ __str__ __format__ __bytes__ __fspath__ |
| 转换为数字 | __bool__ __complex__ __int__ __float__ __hash__ __index__ |
| 模拟集合 | __len__ __getitem__ __setitem__ __delitem__ __contains__ |
| 迭代 | __iter__ __aiter__ __next__ __anext__ __reversed__ |
| 可调用对象或协程执行 | __call__ __await__ |
| 上下文管理 | __enter__ __exit__ __aexit__ __aenter__ |
| 实例创建和销毁 | __new__ __init__ __del__ |
| 属性管理 | __getattr__ __getattribute__ __setattr__ __delattr__ __dir__ |
| 属性描述符 | __get__ __set__ __delete__ __set_name__ |
| 抽象基类 | __instancecheck__ __subclasscheck__ |
| 类元编程 | __prepare__ __init_subclass__ __class_getitem__ __mro_entries__ |
表 1-2 列出了中缀和数值运算符支持的特殊方法。其中最新的名称是 __matmul__、__rmatmul__ 和 __imatmul__,于 Python 3.5 新增,用于支持 @ 作为矩阵乘法的中缀运算符,我们将在第十六章看到。
表 1-2. 运算符的特殊方法名和符号
| 运算符类别 | 符号 | 方法名 |
|---|---|---|
| 一元数值运算 | - + abs() |
__neg__ __pos__ __abs__ |
| 富比较 | < <= == != > >= |
__lt__ __le__ __eq__ __ne__ __gt__ __ge__ |
| 算术运算 | + - * / // % @ divmod() round() ** pow() |
__add__ __sub__ __mul__ __truediv__ __floordiv__ __mod__ __matmul__ __divmod__ __round__ __pow__ |
| 反向算术运算 | (交换运算数的算术运算符) | __radd__ __rsub__ __rmul__ __rtruediv__ __rfloordiv__ __rmod__ __rmatmul__ __rdivmod__ __rpow__ |
| 增强赋值算术运算 | += -= *= /= //= %= @= **= |
__iadd__ __isub__ __imul__ __itruediv__ __ifloordiv__ __imod__ __imatmul__ __ipow__ |
| 位运算 | & | ^ << >> ~ |
__and__ __or__ __xor__ __lshift__ __rshift__ __invert__ |
| 反向位运算 | (交换运算数的位运算符) | __rand__ __ror__ __rxor__ __rlshift__ __rrshift__ |
| 增强赋值位运算 | &= |= ^= <<= >>= |
__iand__ __ior__ __ixor__ __ilshift__ __irshift__ |
注意
当第一个操作数的相应特殊方法无法使用时,Python 会在第二个操作数上调用反向运算符特殊方法。增强赋值是将中缀运算符与变量赋值组合的简写形式,例如 a += b。
第十六章详细解释了反向运算符和增强赋值。
为什么 len 不是一个方法
我在 2013 年向核心开发者 Raymond Hettinger 提出了这个问题,他回答的关键是引用了"Python 之禅"中的一句话:"实用性胜过纯粹性。"在"特殊方法的使用方式"中,我描述了当 x 是内置类型的实例时,len(x) 的运行速度非常快。对于 CPython 的内置对象,不调用任何方法:长度直接从 C 结构体中的一个字段读取。获取集合中的项数是一种常见操作,必须为 str、list、memoryview 等基本且多样的类型高效地工作。
换句话说,len 之所以不作为方法调用,是因为它作为 Python 数据模型的一部分,与 abs 一样得到特殊对待。但是,借助特殊方法 __len__,你也可以让 len 适用于你自己的自定义对象。这在内置对象的效率需求和语言的一致性之间取得了合理的平衡。正如"Python 之禅"所言:"特例不足以打破规则。"
注意
如果你认为 abs 和 len 是一元运算符,那么相比于在面向对象语言中期望的方法调用语法,你可能更倾向于原谅它们的函数外观和感觉。事实上,ABC 语言(Python 的直接祖先,开创了其许多特性)有一个相当于 len 的 # 运算符(你会写成 #s)。当用作中缀运算符时,写作 x#s,它会计算 x 在 s 中出现的次数,在 Python 中,对于任何序列 s,都可以用 s.count(x) 获得。
章节总结
通过实现特殊方法,你的对象可以表现得像内置类型一样,从而实现社区认为 Pythonic 的表达性编码风格。
Python 对象的一个基本要求是提供自身的可用字符串表示,一个用于调试和日志记录,另一个用于呈现给终端用户。这就是为什么数据模型中存在特殊方法 __repr__ 和 __str__ 的原因。
如 FrenchDeck 示例所展示的,模拟序列是特殊方法最常见的用途之一。例如,数据库库通常以类序列集合的形式返回查询结果。第二章的主题是充分利用现有的序列类型。第十二章将介绍如何实现自己的序列,届时我们将创建 Vector 类的多维扩展。
得益于运算符重载,Python 提供了丰富的数值类型选择,从内置类型到 decimal.Decimal、fractions.Fraction,都支持中缀算术运算符。NumPy 数据科学库支持对矩阵和张量使用中缀运算符。第十六章将通过增强 Vector 示例来演示如何实现运算符,包括反向运算符和增强赋值。
本书贯穿始终介绍了 Python 数据模型中大多数剩余特殊方法的使用和实现。
延伸阅读
"数据模型"一章,摘自Python 语言参考手册,是本章以及本书大部分内容的权威来源。
Alex Martelli、Anna Ravenscroft 和 Steve Holden 合著的Python in a Nutshell, 3rd ed.(O'Reilly 出版)对数据模型有极佳的阐述。除了实际的 CPython C 源代码外,他们对属性访问机制的描述是我所见过最权威的。Martelli 也是 Stack Overflow 上的高产贡献者,贴出了超过 6,200 个答案。可以在 Stack Overflow 上看到他的用户资料。
David Beazley 有两本书在 Python 3 的背景下详细介绍了数据模型:Python Essential Reference,第 4 版(Addison-Wesley 出版),以及与 Brian K. Jones 合著的Python Cookbook,第 3 版(O'Reilly 出版)。
Gregor Kiczales、Jim des Rivieres 和 Daniel G. Bobrow 合著的The Art of the Metaobject Protocol(MIT 出版社)解释了元对象协议的概念,Python 数据模型就是其中一个例子。
¹ "Jython 的故事",作为 Samuele Pedroni 和 Noel Rappin 合著的 Jython Essentials(O'Reilly 出版)的前言。
² C 结构体是一种带有命名字段的记录类型。
第二章:序列之阵
你可能已经注意到,提到的几个操作同样适用于文本、列表和表格。文本、列表和表格统称为 "序列"。[...]
FOR命令也可以通用地作用于序列。Leo Geurts、Lambert Meertens 和 Steven Pembertonm,ABC Programmer's Handbook¹
在创建 Python 之前,Guido 曾是 ABC 语言的贡献者——一个为初学者设计编程环境的 10 年研究项目。ABC 引入了许多我们现在认为 "Pythonic" 的想法:对不同类型序列的通用操作、内置元组和映射类型、缩进结构、无需变量声明的强类型等等。Python 如此用户友好并非偶然。
Python 从 ABC 继承了对序列的统一处理。字符串、列表、字节序列、数组、XML 元素和数据库结果共享一组丰富的通用操作,包括迭代、切片、排序和连接。
了解 Python 中可用的各种序列可以节省我们重复发明轮子的时间,它们的通用接口激励我们创建正确支持和利用现有和未来序列类型的 API。
本章大部分讨论适用于一般的序列,从熟悉的 list 到 Python 3 中新增的 str 和 bytes 类型。这里还涵盖了列表、元组、数组和队列的具体主题,但 Unicode 字符串和字节序列的详细信息出现在 第四章。此外,这里的想法是涵盖已准备好使用的序列类型。创建你自己的序列类型是 第十二章 的主题。
本章将主要涵盖以下主题:
-
列表推导式和生成器表达式基础
-
将元组用作记录与将元组用作不可变列表
-
序列解包和序列模式
-
从切片读取和向切片写入
-
专门的序列类型,如数组和队列
本章的更新内容
本章最重要的更新是 "使用序列进行模式匹配"。这是 Python 3.10 的新模式匹配特性在第二版中首次出现。
其他变化不是更新,而是对第一版的改进:
-
序列内部结构的新图和描述,对比容器和扁平序列
-
简要比较
list和tuple的性能和存储特性 -
包含可变元素的元组的注意事项,以及如何在需要时检测它们
我将命名元组的介绍移至 第五章 的 "经典命名元组",在那里它们与 typing.NamedTuple 和 @dataclass 进行了比较。
注意
为了给新内容腾出空间并将页数控制在合理范围内,第一版中的 "使用 Bisect 管理有序序列" 一节现在是 fluentpython.com 配套网站中的一篇文章。
内置序列概述
标准库提供了丰富的用 C 实现的序列类型选择:
容器序列
可以容纳不同类型的项目,包括嵌套容器。一些示例:list、tuple 和 collections.deque。
扁平序列
持有一种简单类型的项目。一些示例:str、bytes 和 array.array。
容器序列存储对其所包含的对象的引用,这些对象可以是任何类型,而扁平序列则在其自身的内存空间中存储其内容的值,而不是作为独立的 Python 对象。参见图 2-1。

图 2-1. 一个tuple和一个array的简化内存图,每个包含三个项目。灰色单元格表示每个 Python 对象的内存头——没有按比例绘制。tuple有一个对其项目的引用数组。每个项目都是一个单独的 Python 对象,可能包含对其他 Python 对象的引用,比如那个两个项目的列表。相比之下,Python array是一个单一的对象,包含一个 C 语言的三个 double 数组。
因此,扁平序列更紧凑,但它们仅限于保存字节、整数和浮点数等原始机器值。
注意
内存中的每个 Python 对象都有一个带有元数据的头部。最简单的 Python 对象float有一个值字段和两个元数据字段:
-
ob_refcnt:对象的引用计数 -
ob_type:指向对象类型的指针 -
ob_fval:一个 Cdouble,用于保存float的值
在 64 位 Python 构建中,这些字段中的每一个都占用 8 个字节。这就是为什么一个浮点数组比一个浮点元组更紧凑:数组是一个单一的对象,包含浮点数的原始值,而元组由多个对象组成——元组本身和其中包含的每个float对象。
对序列类型进行分组的另一种方式是按可变性:
可变序列
例如,list、bytearray、array.array和collections.deque。
不可变序列
例如,tuple、str和bytes。
图 2-2 有助于可视化可变序列如何继承不可变序列的所有方法,并实现几个额外的方法。内置的具体序列类型实际上并没有子类化Sequence和MutableSequence抽象基类(ABC),但它们是注册到这些 ABC 的虚拟子类——我们将在第十三章中看到。作为虚拟子类,tuple和list通过了这些测试:
>>> from collections import abc
>>> issubclass(tuple, abc.Sequence)
True
>>> issubclass(list, abc.MutableSequence)
True

图 2-2.collections.abc 中一些类的简化 UML 类图(超类在左侧;继承箭头从子类指向超类;斜体名称是抽象类和抽象方法)。
记住这些共同特征:可变与不可变;容器与扁平。它们有助于将你对一种序列类型的了解推广到其他类型。
最基本的序列类型是list:一个可变容器。我希望你非常熟悉列表,所以我们将直接进入列表推导式,这是一种构建列表的强大方式,但有时会因为语法一开始看起来不寻常而被低估。掌握列表推导式为生成器表达式打开了大门,生成器表达式除了其他用途外,还可以生成元素来填充任何类型的序列。这两者都是下一节的主题。
列表推导式和生成器表达式
构建序列的一个快速方法是使用列表推导式(如果目标是list)或生成器表达式(对于其他类型的序列)。如果你没有每天使用这些语法形式,我敢打赌你正在错失编写更易读且通常更快的代码的机会。
如果你怀疑我声称这些构造"更具可读性",请继续阅读。我会试着说服你。
提示
为了简洁起见,许多 Python 程序员将列表推导式称为listcomps,将生成器表达式称为genexps。我也会使用这些词。
列表推导式和可读性
这里有一个测试:你觉得示例 2-1 和示例 2-2 哪个更易读?
示例 2-1. 从字符串构建 Unicode 码点列表
>>> symbols = '$¢£¥€¤'
>>> codes = []
>>> for symbol in symbols:
... codes.append(ord(symbol))
...
>>> codes
[36, 162, 163, 165, 8364, 164]
示例 2-2. 使用列表推导式从字符串构建 Unicode 码点列表
>>> symbols = '$¢£¥€¤'
>>> codes = [ord(symbol) for symbol in symbols]
>>> codes
[36, 162, 163, 165, 8364, 164]
任何稍微了解 Python 的人都可以读懂示例 2-1。然而,在学习了列表推导式之后,我发现示例 2-2 更具可读性,因为它的意图很明确。
for循环可用于执行许多不同的事情:扫描序列以计数或选择项目、计算聚合(总和、平均值)或任何其他任务。示例 2-1 中的代码正在构建一个列表。相比之下,列表推导式更加明确。它的目标总是构建一个新列表。
当然,也可能滥用列表推导式来编写真正难以理解的代码。我见过 Python 代码,其中列表推导式仅用于重复代码块以产生副作用。如果你不对生成的列表做任何事情,就不应该使用该语法。此外,尽量保持简短。如果列表推导式跨越两行以上,最好将其拆开或重写为普通的for循环。运用你的最佳判断:对于 Python,就像对于英语一样,没有明确的清晰写作规则。
语法提示
在 Python 代码中,在[]、{}或()对之间的换行符会被忽略。因此,你可以构建多行列表、列表推导式、元组、字典等,而无需使用\换行转义符,如果不小心在其后键入空格,它将不起作用。此外,当这些分隔符对用于定义包含以逗号分隔的一系列项的字面量时,尾随逗号将被忽略。因此,例如,在编写多行列表字面量时,在最后一项后面加上逗号是很周到的,这会让下一个编码者更容易向该列表添加一个项目,并在阅读差异时减少噪音。
列表推导式通过过滤和转换项目从序列或任何其他可迭代类型构建列表。内置的filter和map可以组合起来做同样的事情,但可读性会受到影响,我们接下来会看到。
列表推导式与 map 和 filter 的对比
列表推导式可以完成map和filter函数所做的一切,而无需功能受限的 Python lambda的扭曲。考虑示例 2-3。
示例 2-3. 通过列表推导式和 map/filter 组合构建的相同列表
>>> symbols = '$¢£¥€¤'
>>> beyond_ascii = [ord(s) for s in symbols if ord(s) > 127]
>>> beyond_ascii
[162, 163, 165, 8364, 164]
>>> beyond_ascii = list(filter(lambda c: c > 127, map(ord, symbols)))
>>> beyond_ascii
[162, 163, 165, 8364, 164]
我曾经认为map和filter比等效的列表推导式更快,但 Alex Martelli 指出事实并非如此——至少在前面的示例中不是。Fluent Python代码仓库中的02-array-seq/listcomp_speed.py脚本是一个简单的速度测试,比较了列表推导式与filter/map。
在第七章中,我将对map和filter进行更多说明。现在我们来看看如何使用列表推导式计算笛卡尔积:一个包含由两个或多个列表中所有项构建的元组的列表。
笛卡尔积
列表推导式可以从两个或多个可迭代对象的笛卡尔积构建列表。构成笛卡尔积的项是由每个输入可迭代对象的项构成的元组。结果列表的长度等于输入可迭代对象的长度相乘。参见图 2-3。

图 2-3. 3 个牌面和 4 个花色的笛卡尔积是由 12 对组成的序列。
例如,假设你需要生成一个包含两种颜色和三种尺寸的 T 恤列表。示例 2-4 展示了如何使用列表推导式生成该列表。结果有六个项目。
示例 2-4. 使用列表推导式的笛卡尔积
>>> colors = ['black', 'white']
>>> sizes = ['S', 'M', 'L']
>>> tshirts = [(color, size) for color in colors for size in sizes] # ①
>>> tshirts
[('black', 'S'), ('black', 'M'), ('black', 'L'), ('white', 'S'),
('white', 'M'), ('white', 'L')] >>> for color in colors: # ②
... for size in sizes:
... print((color, size))
...
('black', 'S') ('black', 'M') ('black', 'L') ('white', 'S') ('white', 'M') ('white', 'L') >>> tshirts = (color, size) for size in sizes ![3
... for color in colors]
>>> tshirts
[('black', 'S'), ('white', 'S'), ('black', 'M'), ('white', 'M'),
('black', 'L'), ('white', 'L')]
①
这会生成一个按颜色再按大小排列的元组列表。
②
注意结果列表的排列方式,就好像for循环按照它们在列表推导式中出现的顺序嵌套一样。
③
要按大小再按颜色排列项目,只需重新排列for子句;在列表推导式中添加一个换行,可以更容易地看出结果的排序方式。
在示例 1-1(第一章)中,我使用以下表达式初始化一副由 4 种花色的 13 种牌面组成的 52 张牌的扑克牌,按花色和点数排序:
self._cards = [Card(rank, suit) for suit in self.suits
for rank in self.ranks]
列表推导式是一招鲜吃遍天:它们构建列表。要为其他序列类型生成数据,生成器表达式是不二之选。下一节将简要介绍在构建非列表序列的上下文中使用生成器表达式。
生成器表达式
要初始化元组、数组和其他类型的序列,你也可以从列表推导式开始,但生成器表达式可以节省内存,因为它使用迭代器协议一个接一个地产生项目,而不是构建一个完整的列表来馈送另一个构造函数。
生成器表达式使用与列表推导式相同的语法,但用括号括起来,而不是方括号。
示例 2-5 展示了使用生成器表达式构建元组和数组的基本用法。
示例 2-5. 从生成器表达式初始化元组和数组
>>> symbols = '$¢£¥€¤'
>>> tuple(ord(symbol) for symbol in symbols) # ①
(36, 162, 163, 165, 8364, 164) >>> import array
>>> array.array('I', (ord(symbol) for symbol in symbols)) # ②
array('I', [36, 162, 163, 165, 8364, 164])
①
如果生成器表达式是函数调用中的唯一参数,则不需要复制括号。
②
array 构造函数接受两个参数,因此生成器表达式周围的括号是必需的。array 构造函数的第一个参数定义了用于数组中数字的存储类型,我们将在"数组"中看到。
示例 2-6 使用笛卡尔积中的生成器表达式打印出三种尺寸两种颜色的 T 恤衫名册。与示例 2-4 相比,这里从未在内存中构建六个 T 恤衫的列表:生成器表达式每次产生一个项目来馈送 for 循环。如果笛卡尔积中使用的两个列表每个都有一千个项目,使用生成器表达式就可以节省构建一个包含一百万个项目的列表的成本,而这个列表只是用来馈送 for 循环。
示例 2-6. 生成器表达式中的笛卡尔积
>>> colors = ['black', 'white']
>>> sizes = ['S', 'M', 'L']
>>> for tshirt in (f'{c} {s}' for c in colors for s in sizes): # ①
... print(tshirt)
...
black S black M black L white S white M white L
①
生成器表达式一个接一个地产生项目;在此示例中,从未生成包含所有六种 T 恤衫变体的列表。
注意
第十七章详细解释了生成器的工作原理。这里的想法只是展示如何使用生成器表达式来初始化列表以外的序列,或生成不需要保存在内存中的输出。
现在我们继续讨论 Python 中另一个基本的序列类型:元组。
元组不仅仅是不可变的列表
一些介绍 Python 的入门文本将元组描述为"不可变的列表",但这并没有充分利用它们。元组具有双重功能:它们可以用作不可变列表,也可以用作没有字段名的记录。这种用法有时会被忽略,所以我们将从这里开始。
元组作为记录
元组保存记录:元组中的每一项保存一个字段的数据,项目的位置赋予了它含义。
如果将元组视为不可变列表,则根据上下文,项目的数量和顺序可能重要,也可能不重要。但是在将元组用作字段集合时,项目的数量通常是固定的,它们的顺序始终很重要。
示例 2-7 显示了用作记录的元组。请注意,在每个表达式中,对元组进行排序都会破坏信息,因为每个字段的含义由其在元组中的位置给出。
示例 2-7. 元组用作记录
>>> lax_coordinates = (33.9425, -118.408056) # ①
>>> city, year, pop, chg, area = ('Tokyo', 2003, 32_450, 0.66, 8014) # ②
>>> traveler_ids = ('USA', '31195855'), ('BRA', 'CE342567'), ![3
... ('ESP', 'XDA205856')]
>>> for passport in sorted(traveler_ids): # ④
... print('%s/%s' % passport) # ⑤
...
BRA/CE342567 ESP/XDA205856 USA/31195855 >>> for country, _ in traveler_ids: # ⑥
... print(country)
...
USA BRA ESP
①
洛杉矶国际机场的纬度和经度。
②
关于东京的数据:名称、年份、人口(千人)、人口变化(%)和面积(平方公里)。
③
形式为 (country_code, passport_number) 的元组列表。
④
当我们遍历列表时,passport绑定到每个元组。
⑤
%格式化运算符理解元组,并将每个项视为单独的字段。
⑥
for循环知道如何分别检索元组的项,这称为"解包"。这里我们对第二个项不感兴趣,所以将其赋值给虚拟变量_。
提示
通常,使用_作为虚拟变量只是一种约定。它只是一个奇怪但有效的变量名。但是,在match/case语句中,_是一个通配符,可以匹配任何值,但不会绑定到一个值。参见"使用序列进行模式匹配"。在 Python 控制台中,前一个命令的结果被赋值给_,除非结果是None。
我们通常认为记录是具有命名字段的数据结构。第五章介绍了两种创建具有命名字段的元组的方法。
但通常没有必要费力创建一个类来命名字段,尤其是如果你利用解包并避免使用索引访问字段。在示例 2-7 中,我们在一条语句中将('Tokyo', 2003, 32_450, 0.66, 8014)赋值给city, year, pop, chg, area。然后,%运算符将passport元组中的每一项分配给print参数中格式字符串的相应位置。这是元组解包的两个例子。
注意
术语元组解包被 Pythonista 广泛使用,但可迭代解包正在获得关注,如PEP 3132 — 扩展可迭代解包的标题所示。
"解包序列和可迭代对象"不仅详细介绍了元组的解包,还包括序列和可迭代对象的解包。
现在让我们将tuple类视为list类的不可变变体。
元组作为不可变列表
Python 解释器和标准库广泛使用元组作为不可变列表,你也应该这样做。这带来了两个主要好处:
清晰度
当你在代码中看到tuple时,你知道它的长度永远不会改变。
性能
与相同长度的list相比,tuple使用更少的内存,并允许 Python 进行一些优化。
但是,请注意tuple的不可变性仅适用于它所包含的引用。元组中的引用不能被删除或替换。但是,如果其中一个引用指向一个可变对象,并且该对象发生了变化,那么tuple的值就会改变。下面的代码片段通过创建两个最初相等的元组a和b来说明这一点。图 2-4 表示内存中b元组的初始布局。

图 2-4。元组本身的内容是不可变的,但这只意味着元组持有的引用将始终指向相同的对象。但是,如果其中一个引用对象是可变的(如列表),其内容可能会发生变化。
当b中的最后一项发生变化时,b和a变得不同:
>>> a = (10, 'alpha', [1, 2])
>>> b = (10, 'alpha', [1, 2])
>>> a == b
True
>>> b[-1].append(99)
>>> a == b
False
>>> b
(10, 'alpha', [1, 2, 99])
包含可变项的元组可能是 bug 的根源。正如我们将在"什么是可哈希的"中看到的,一个对象只有在其值不能改变时才是可哈希的。不可哈希的元组不能插入为dict键或set元素。
如果你想明确确定一个元组(或任何对象)是否具有固定值,可以使用内置的hash创建一个fixed函数,如下所示:
>>> def fixed(o):
... try:
... hash(o)
... except TypeError:
... return False
... return True
...
>>> tf = (10, 'alpha', (1, 2))
>>> tm = (10, 'alpha', [1, 2])
>>> fixed(tf)
True
>>> fixed(tm)
False
我们在"元组的相对不可变性"中进一步探讨了这个问题。
尽管有这个警告,元组仍然被广泛用作不可变列表。Python 核心开发者 Raymond Hettinger 在 StackOverflow 回答"在 Python 中元组比列表更高效吗?"时解释了元组提供的一些性能优势。总结一下,Hettinger 写道:
-
为了评估元组字面量,Python 编译器在一个操作中为元组常量生成字节码;但是对于列表字面量,生成的字节码将每个元素作为单独的常量推送到数据栈,然后构建列表。
-
给定元组
t,tuple(t)只是返回对同一个t的引用。没有必要复制。相比之下,给定列表l,list(l)构造函数必须创建l的新副本。 -
由于具有固定长度,
tuple实例分配它需要的确切内存空间。另一方面,list的实例分配时会留有余地,以分摊将来追加的成本。 -
元组中元素的引用存储在元组结构中的数组中,而列表在其他地方保存指向引用数组的指针。当列表增长超过当前分配的空间时,Python 需要重新分配引用数组以腾出空间,因此需要间接寻址。额外的间接寻址使 CPU 缓存效率降低。
比较元组和列表方法
当使用元组作为list的不可变变体时,了解它们的 API 有多相似是很好的。如表 2-1 所示,除了一个例外,tuple支持所有不涉及添加或删除元素的list方法——tuple缺少__reversed__方法。但是,这只是为了优化;reversed(my_tuple)可以在没有它的情况下工作。
表 2-1. 在list或tuple中找到的方法和属性(为简洁起见,省略了对象实现的方法)
| list | tuple | ||
|---|---|---|---|
s.__add__(s2) |
● | ● | s + s2—连接 |
s.__iadd__(s2) |
● | s += s2—原地连接 |
|
s.append(e) |
● | 在最后追加一个元素 | |
s.clear() |
● | 删除所有元素 | |
s.__contains__(e) |
● | ● | e in s |
s.copy() |
● | 列表的浅拷贝 | |
s.count(e) |
● | ● | 计算元素出现的次数 |
s.__delitem__(p) |
● | 移除位置p处的元素 |
|
s.extend(it) |
● | 从可迭代对象it追加元素 |
|
s.__getitem__(p) |
● | ● | s[p]—获取位置p处的元素 |
s.__getnewargs__() |
● | 支持使用pickle进行优化的序列化 |
|
s.index(e) |
● | ● | 查找e第一次出现的位置 |
s.insert(p, e) |
● | 在位置p的元素之前插入元素e |
|
s.__iter__() |
● | ● | 获取迭代器 |
s.__len__() |
● | ● | len(s)—元素的数量 |
s.__mul__(n) |
● | ● | s * n—重复连接 |
s.__imul__(n) |
● | s *= n—原地重复连接 |
|
s.__rmul__(n) |
● | ● | n * s—反向重复连接^(a) |
s.pop([p]) |
● | 移除并返回最后一个元素或位置p处的可选元素 |
|
s.remove(e) |
● | 按值移除元素e的第一次出现 |
|
s.reverse() |
● | 原地反转元素的顺序 | |
s.__reversed__() |
● | 获取从最后到第一个元素的迭代器 | |
s.__setitem__(p, e) |
● | s[p] = e—将e放在位置p,覆盖现有元素^(b) |
|
s.sort([key], [reverse]) |
● | 原地排序,可选关键字参数key和reverse |
|
| (a)反向运算符在第十六章中解释。(b)也用于覆盖子序列。参见"赋值给切片"。 |
现在让我们切换到 Python 编程中一个重要的主题:元组、列表和可迭代对象解包。
解包序列和可迭代对象
解包很重要,因为它避免了不必要的和容易出错的使用索引从序列中提取元素。此外,解包可以与任何可迭代对象作为数据源一起使用,包括不支持索引表示法([])的迭代器。唯一的要求是,可迭代对象在接收端为每个变量只产生一个项,除非你使用星号(*)来捕获多余的项,如"使用*捕获多余的项"中所解释的。
解包最明显的形式是并行赋值;也就是说,将可迭代对象中的项赋值给一个元组变量,如下例所示:
>>> lax_coordinates = (33.9425, -118.408056)
>>> latitude, longitude = lax_coordinates # unpacking
>>> latitude
33.9425
>>> longitude
-118.408056
解包的一个优雅应用是在不使用临时变量的情况下交换变量的值:
>>> b, a = a, b
解包的另一个例子是在调用函数时在参数前面加上*:
>>> divmod(20, 8)
(2, 4)
>>> t = (20, 8)
>>> divmod(*t)
(2, 4)
>>> quotient, remainder = divmod(*t)
>>> quotient, remainder
(2, 4)
前面的代码展示了解包的另一个用途:允许函数以一种对调用者很方便的方式返回多个值。另一个例子是,os.path.split()函数从文件系统路径构建一个元组(path, last_part):
>>> import os
>>> _, filename = os.path.split('/home/luciano/.ssh/id_rsa.pub')
>>> filename
'id_rsa.pub'
另一种在解包时只使用部分项的方式是使用*语法,我们马上就会看到。
使用*捕获多余的项
使用*args定义函数参数以捕获任意多余的参数是 Python 的一个经典特性。
在 Python 3 中,这个想法也被扩展到并行赋值:
>>> a, b, *rest = range(5)
>>> a, b, rest
(0, 1, [2, 3, 4])
>>> a, b, *rest = range(3)
>>> a, b, rest
(0, 1, [2])
>>> a, b, *rest = range(2)
>>> a, b, rest
(0, 1, [])
在并行赋值的上下文中,*前缀只能应用于一个变量,但它可以出现在任何位置:
>>> a, *body, c, d = range(5)
>>> a, body, c, d
(0, [1, 2], 3, 4)
>>> *head, b, c, d = range(5)
>>> head, b, c, d
([0, 1], 2, 3, 4)
在函数调用和序列字面量中使用*解包
PEP 448—Additional Unpacking Generalizations引入了更灵活的可迭代对象解包语法,在"What's New In Python 3.5"中总结得最好。
在函数调用中,我们可以多次使用*:
>>> def fun(a, b, c, d, *rest):
... return a, b, c, d, rest
...
>>> fun(*[1, 2], 3, *range(4, 7))
(1, 2, 3, 4, (5, 6))
在定义list、tuple或set字面量时也可以使用*,如"What's New In Python 3.5"中的这些例子所示:
>>> *range(4), 4
(0, 1, 2, 3, 4)
>>> [*range(4), 4]
[0, 1, 2, 3, 4]
>>> {*range(4), 4, *(5, 6, 7)}
{0, 1, 2, 3, 4, 5, 6, 7}
PEP 448 为**引入了类似的新语法,我们将在"Unpacking Mappings"中看到。
最后,元组解包的一个强大功能是它可以与嵌套结构一起使用。
嵌套解包
解包的目标可以使用嵌套,例如(a, b, (c, d))。如果值具有相同的嵌套结构,Python 会做正确的事情。示例 2-8 展示了嵌套解包的实际应用。
示例 2-8.解包嵌套元组以访问经度
metro_areas =
('Tokyo', 'JP', 36.933, (35.689722, 139.691667)), ![1
('Delhi NCR', 'IN', 21.935, (28.613889, 77.208889)),
('Mexico City', 'MX', 20.142, (19.433333, -99.133333)),
('New York-Newark', 'US', 20.104, (40.808611, -74.020386)),
('São Paulo', 'BR', 19.649, (-23.547778, -46.635833)),
]
def main():
print(f'{"":15} | {"latitude":>9} | {"longitude":>9}')
for name, _, _, (lat, lon) in metro_areas: # ②
if lon <= 0: # ③
print(f'{name:15} | {lat:9.4f} | {lon:9.4f}')
if __name__ == '__main__':
main()
①
每个元组都包含一个有四个字段的记录,最后一个字段是一对坐标。
②
通过将最后一个字段赋值给嵌套元组,我们解包了坐标。
③
lon <= 0:测试只选择西半球的城市。
示例 2-8 的输出是:
| latitude | longitude
Mexico City | 19.4333 | -99.1333
New York-Newark | 40.8086 | -74.0204
São Paulo | -23.5478 | -46.6358
解包赋值的目标也可以是一个列表,但好的用例很少见。这是我知道的唯一一个:如果你有一个数据库查询只返回一条记录(例如,SQL 代码中有一个LIMIT 1子句),那么你可以解包并同时确保只有一个结果,代码如下:
>>> [record] = query_returning_single_row()
如果记录只有一个字段,你可以直接获取它,像这样:
>>> [[field]] = query_returning_single_row_with_single_field()
这两种情况都可以用元组来写,但不要忘记单项元组必须用尾随逗号来写的语法怪癖。所以第一个目标应该是(record,),第二个应该是((field,),)。在这两种情况下,如果你忘记了逗号,你会得到一个无声的错误。³
现在让我们研究模式匹配,它支持更强大的序列解包方式。
序列模式匹配
Python 3.10 中最明显的新特性是PEP 634—Structural Pattern Matching: Specification中提议的带有match/case语句的模式匹配。
注意
Python 核心开发者 Carol Willing 在 "What's New In Python 3.10" 的 "Structural Pattern Matching" 部分中写了关于模式匹配的精彩介绍。你可能需要阅读那个快速概述。在本书中,我选择根据模式类型将模式匹配的内容分散在不同的章节中:"Pattern Matching with Mappings" 和 "Pattern Matching Class Instances"。一个扩展示例在 "Pattern Matching in lis.py: A Case Study" 中。
这是一个 match/case 处理序列的第一个例子。想象你正在设计一个机器人,它接受以单词和数字序列发送的命令,如 BEEPER 440 3。在分割成部分并解析数字后,你会得到一条像 ['BEEPER', 440, 3] 这样的消息。你可以使用如下方法来处理这样的消息:
示例 2-9. 一个虚构的 Robot 类的方法
def handle_command(self, message):
match message: # ①
case ['BEEPER', frequency, times]: # ②
self.beep(times, frequency)
case ['NECK', angle]: # ③
self.rotate_neck(angle)
case ['LED', ident, intensity]: # ④
self.leds[ident].set_brightness(ident, intensity)
case ['LED', ident, red, green, blue]: # ⑤
self.leds[ident].set_color(ident, red, green, blue)
case _: # ⑥
raise InvalidCommand(message)
①
match 关键字后面的表达式是主题。主题是 Python 将尝试与每个 case 子句中的模式匹配的数据。
②
这个模式匹配任何包含三个元素的序列主题。第一个元素必须是字符串 'BEEPER'。第二个和第三个元素可以是任何内容,它们将按顺序绑定到变量 frequency 和 times。
③
这将匹配任何包含两个元素的主题,第一个元素是 'NECK'。
④
这将匹配一个以 'LED' 开头的三个元素的主题。如果元素数量不匹配,Python 将继续执行下一个 case。
⑤
另一个以 'LED' 开头的序列模式,现在有五个元素,包括常量 'LED'。
⑥
这是默认的 case。它将匹配任何没有匹配前面模式的主题。_ 变量是特殊的,我们很快就会看到。
从表面上看,match/case 可能类似于 C 语言中的 switch/case 语句 —— 但那只是故事的一半。⁴ match 相对于 switch 的一个关键改进是解构 —— 一种更高级的解包形式。解构是 Python 词汇表中的一个新词,但在支持模式匹配的语言(如 Scala 和 Elixir)的文档中常用。
作为解构的第一个示例,示例 2-10 展示了用 match/case 重写的 示例 2-8 的一部分。
示例 2-10. 解构嵌套元组 —— 需要 Python ≥ 3.10
metro_areas = [
('Tokyo', 'JP', 36.933, (35.689722, 139.691667)),
('Delhi NCR', 'IN', 21.935, (28.613889, 77.208889)),
('Mexico City', 'MX', 20.142, (19.433333, -99.133333)),
('New York-Newark', 'US', 20.104, (40.808611, -74.020386)),
('São Paulo', 'BR', 19.649, (-23.547778, -46.635833)),
]
def main():
print(f'{"":15} | {"latitude":>9} | {"longitude":>9}')
for record in metro_areas:
match record: # ①
case [name, _, _, (lat, lon)] if lon <= 0: # ②
print(f'{name:15} | {lat:9.4f} | {lon:9.4f}')
①
这个 match 的主题是 record,即 metro_areas 中的每个元组。
②
case 子句有两个部分:一个模式和一个可选的带有 if 关键字的守卫。
通常,序列模式在以下情况下匹配主题:
-
主题是一个序列并且;
-
主题和模式具有相同数量的元素并且;
-
每个对应的元素都匹配,包括嵌套元素。
例如,示例 2-10 中的模式 [name, _, _, (lat, lon)] 匹配一个包含四个元素的序列,最后一个元素必须是一个包含两个元素的序列。
序列模式可以写成元组或列表,或者任何嵌套元组和列表的组合,但使用哪种语法并不重要:在序列模式中,方括号和括号的含义相同。我将模式写成带有嵌套 2 元组的列表,只是为了避免在 示例 2-10 中重复使用括号。
序列模式可以匹配collections.abc.Sequence的大多数实际或虚拟子类的实例,但str、bytes和bytearray除外。
警告
在match/case的上下文中,str、bytes和bytearray的实例不会被处理为序列。这些类型的match主题被视为"原子"值——就像整数 987 被视为一个值,而不是一个数字序列。将这三种类型视为序列可能会因意外匹配而导致错误。如果要将这些类型的对象视为序列主题,请在match子句中进行转换。例如,请参见以下内容中的tuple(phone):
match tuple(phone):
case ['1', *rest]: # North America and Caribbean
...
case ['2', *rest]: # Africa and some territories
...
case ['3' | '4', *rest]: # Europe
...
在标准库中,这些类型与序列模式兼容:
list memoryview array.array
tuple range collections.deque
与解包不同,模式不会解构非序列的可迭代对象(如迭代器)。
_符号在模式中很特殊:它匹配该位置的任何单个项,但永远不会绑定到匹配项的值。此外,_是唯一可以在模式中多次出现的变量。
你可以使用as关键字将模式的任何部分绑定到一个变量:
case [name, _, _, (lat, lon) as coord]:
给定主题['Shanghai', 'CN', 24.9, (31.1, 121.3)],前面的模式将匹配,并设置以下变量:
| 变量 | 设置值 |
|---|---|
name |
'Shanghai' |
lat |
31.1 |
lon |
121.3 |
coord |
(31.1, 121.3) |
我们可以通过添加类型信息来使模式更具体。例如,以下模式匹配与前面示例相同的嵌套序列结构,但第一项必须是str的实例,而 2 元组中的两个项都必须是float的实例:
case [str(name), _, _, (float(lat), float(lon))]:
提示
表达式str(name)和float(lat)看起来像构造函数调用,我们会用它们将name和lat转换为str和float。但在模式的上下文中,该语法执行运行时类型检查:前面的模式将匹配一个四项序列,其中第 0 项必须是str,第 3 项必须是一对浮点数。此外,第 0 项中的str将绑定到name变量,第 3 项中的浮点数将分别绑定到lat和lon。所以,尽管str(name)借用了构造函数调用的语法,但在模式的上下文中,语义完全不同。在"模式匹配类实例"中介绍了在模式中使用任意类。
另一方面,如果我们想匹配任何以str开头并以两个浮点数的嵌套序列结尾的主题序列,我们可以这样写:
case [str(name), *_, (float(lat), float(lon))]:
*_匹配任意数量的项,而不将它们绑定到变量。使用*extra而不是*_会将项绑定到extra作为一个包含 0 个或多个项的list。
可选的以if开头的保护子句只在模式匹配时求值,并且可以引用模式中绑定的变量,如示例 2-10 所示:
match record:
case [name, _, _, (lat, lon)] if lon <= 0:
print(f'{name:15} | {lat:9.4f} | {lon:9.4f}')
只有在模式匹配且保护表达式为真时,才会运行包含print语句的嵌套块。
提示
使用模式进行解构是如此富有表现力,以至于有时只有一个case的match就可以使代码更简单。Guido van Rossum 有一个case/match示例集合,其中有一个他标题为"一个非常深层的可迭代对象和类型匹配与提取"。
示例 2-10 并不比示例 2-8 有改进。它只是一个示例,用于对比做同一件事的两种方式。下一个示例展示了模式匹配如何有助于清晰、简洁和有效的代码。
解释器中的序列模式匹配
斯坦福大学的 Peter Norvig 编写了lis.py:一个 Lisp 编程语言的 Scheme 方言子集的解释器,用 132 行优美、易读的 Python 代码实现。我采用了 Norvig 的 MIT 许可源代码,并将其更新到 Python 3.10,以展示模式匹配。在本节中,我们将比较 Norvig 代码的一个关键部分(使用if/elif和解包)与使用match/case重写的版本。
lis.py的两个主要函数是parse和evaluate。⁵ 解析器接受 Scheme 的括号表达式并返回 Python 列表。这里有两个例子:
>>> parse('(gcd 18 45)')
['gcd', 18, 45]
>>> parse('''
... (define double
... (lambda (n)
... (* n 2)))
... ''')
['define', 'double', ['lambda', ['n'], ['*', 'n', 2]]]
求值器接受这样的列表并执行它们。第一个例子是用18和45作为参数调用gcd函数。求值时,它计算参数的最大公约数:9。第二个例子是定义一个名为double的函数,带有一个参数n。函数体是表达式(* n 2)。在 Scheme 中调用函数的结果是函数体中最后一个表达式的值。
我们这里重点关注序列的解构,所以我不会解释求值器的动作。想了解更多关于lis.py如何工作的信息,请参阅"lis.py 中的模式匹配:一个案例研究"。
示例 2-11 显示了 Norvig 的求值器,经过略微修改,仅显示序列模式。
示例 2-11. 不使用match/case匹配模式
def evaluate(exp: Expression, env: Environment) -> Any:
"Evaluate an expression in an environment."
if isinstance(exp, Symbol): # variable reference
return env[exp]
# ... lines omitted
elif exp[0] == 'quote': # (quote exp)
(_, x) = exp
return x
elif exp[0] == 'if': # (if test conseq alt)
(_, test, consequence, alternative) = exp
if evaluate(test, env):
return evaluate(consequence, env)
else:
return evaluate(alternative, env)
elif exp[0] == 'lambda': # (lambda (parm…) body…)
(_, parms, *body) = exp
return Procedure(parms, body, env)
elif exp[0] == 'define':
(_, name, value_exp) = exp
env[name] = evaluate(value_exp, env)
# ... more lines omitted
注意每个elif子句是如何检查列表的第一个元素,然后解包列表,忽略第一个元素的。广泛使用解包表明 Norvig 是模式匹配的粉丝,但他最初是为 Python 2 编写那段代码的(尽管它现在适用于任何 Python 3)。
使用 Python ≥ 3.10 中的match/case,我们可以重构evaluate,如示例 2-12 所示。
示例 2-12. 使用match/case进行模式匹配——需要 Python ≥ 3.10
def evaluate(exp: Expression, env: Environment) -> Any:
"Evaluate an expression in an environment."
match exp:
# ... lines omitted
case ['quote', x]: # ①
return x
case ['if', test, consequence, alternative]: # ②
if evaluate(test, env):
return evaluate(consequence, env)
else:
return evaluate(alternative, env)
case ['lambda', [*parms], *body] if body: # ③
return Procedure(parms, body, env)
case ['define', Symbol() as name, value_exp]: # ④
env[name] = evaluate(value_exp, env)
# ... more lines omitted
case _: # ⑤
raise SyntaxError(lispstr(exp))
①
匹配是否是以'quote'开头的两元素序列。
②
匹配是否是以'if'开头的四元素序列。
③
匹配是否是以'lambda'开头的三个或更多元素的序列。guard 确保body不为空。
④
匹配是否是以'define'开头的三元素序列,后面跟着一个Symbol的实例。
⑤
将所有的case语句写一个兜底是一个很好的实践。在这个例子中,如果exp不匹配任何模式,表达式就是有问题的,我会抛出SyntaxError。
如果没有兜底语句,当主体不匹配任何 case 时,整个match语句都不会执行任何操作——而这可能是一个静默的失败。
Norvig 故意避免在lis.py中进行错误检查,以保持代码易于理解。使用模式匹配,我们可以添加更多检查,同时保持可读性。例如,在'define'模式中,原始代码不确保name是Symbol的实例——这需要一个if块、一个isinstance调用和更多代码。示例 2-12 比示例 2-11 更简洁、更安全。
lambda 的替代模式
这是 Scheme 中lambda的语法,使用语法约定:后缀…表示元素可能出现零次或多次:
(lambda (parms…) body1 body2…)
lambda case 'lambda'的一个简单模式是:
case ['lambda', parms, *body] if body:
然而,这会匹配parms位置的任何值,包括这个无效主体中的第一个'x':
['lambda', 'x', ['*', 'x', 2]]
Scheme 中lambda关键字后面的嵌套列表包含函数的形式参数名称,即使它只有一个元素也必须是一个列表。如果函数不接受任何参数,它也可以是一个空列表——就像 Python 的random.random()。
在 示例 2-12 中,我使用嵌套序列模式使 'lambda' 模式更加安全:
case ['lambda', [*parms], *body] if body:
return Procedure(parms, body, env)
在序列模式中,* 在每个序列中只能出现一次。这里我们有两个序列:外部序列和内部序列。
在 parms 周围添加 [*] 字符使模式看起来更像它所处理的 Scheme 语法,并为我们提供了额外的结构检查。
函数定义的简写语法
Scheme 有一种替代的 define 语法,可以在不使用嵌套 lambda 的情况下创建命名函数。语法如下:
(define (name parm…) body1 body2…)
define 关键字后面跟着一个列表,其中包含新函数的 name 以及零个或多个参数名称。在该列表之后是函数体,其中包含一个或多个表达式。
将这两行添加到 match 中就可以完成实现:
case ['define', [Symbol() as name, *parms], *body] if body:
env[name] = Procedure(parms, body, env)
我会将该 case 放在 示例 2-12 中另一个 define case 之后。在这个示例中,define case 的顺序无关紧要,因为没有主体可以同时匹配这两个模式:在原始的 define case 中第二个元素必须是 Symbol,但在用于函数定义的 define 简写中,它必须是以 Symbol 开头的序列。
现在考虑一下,如果没有 示例 2-11 中模式匹配的帮助,为第二个 define 语法添加支持需要做多少工作。match 语句比类 C 语言中的 switch 做的事情要多得多。
模式匹配是声明式编程的一个例子:代码描述了你想要匹配的"什么",而不是"如何"匹配。代码的形状遵循数据的形状,如 表 2-2 所示。
表 2-2. 一些 Scheme 语法形式和用于处理它们的 case 模式
| Scheme 语法 | 序列模式 |
|---|---|
(quote exp) |
['quote', exp] |
(if test conseq alt) |
['if', test, conseq, alt] |
(lambda (parms…) body1 body2…) |
['lambda', [*parms], *body] if body |
(define name exp) |
['define', Symbol() as name, exp] |
(define (name parms…) body1 body2…) |
['define', [Symbol() as name, *parms], *body] if body |
我希望用模式匹配重构 Norvig 的 evaluate 能让你相信 match/case 可以使你的代码更具可读性和安全性。
注意
在"lis.py 中的模式匹配:案例研究"一节中,当我们回顾 evaluate 中完整的 match/case 示例时,我们将看到更多关于 lis.py 的内容。如果你想了解更多关于 Norvig 的 lis.py,请阅读他精彩的文章"(如何用 Python 编写一个 Lisp 解释器)"。
以上就是我们对序列解包、解构和模式匹配的首次介绍。我们将在后面的章节中介绍其他类型的模式。
每个 Python 程序员都知道可以使用 s[a:b] 语法对序列进行切片。我们现在来看一些关于切片的鲜为人知的事实。
切片
Python 中 list、tuple、str 以及所有序列类型的一个共同特性是支持切片操作,其功能比大多数人意识到的要强大得多。
在本节中,我们描述了这些高级切片形式的使用。它们在用户定义类中的实现将在 第十二章中介绍,这与我们在本书这一部分中介绍现成可用的类,并在 第 III 部分中创建新类的理念保持一致。
为什么切片和范围要排除最后一项
在 Python、C 语言以及许多其他语言中使用的基于 0 的索引,与 Python 中切片和范围排除最后一项的约定能够很好地配合。这个约定有一些方便的特性:
-
当只给出停止位置时,很容易看出切片或范围的长度:
range(3)和my_list[:3]都会产生三个项目。 -
当给出起始位置和停止位置时,计算切片或范围的长度很容易:只需计算
stop - start。 -
在任意索引
x处轻松将序列分为两部分,不重叠:只需获取my_list[:x]和my_list[x:]。例如:>>> l = [10, 20, 30, 40, 50, 60] >>> l[:2] # split at 2 [10, 20] >>> l[2:] [30, 40, 50, 60] >>> l[:3] # split at 3 [10, 20, 30] >>> l[3:] [40, 50, 60]
这种约定的最佳论据是由荷兰计算机科学家 Edsger W. Dijkstra 撰写的(请参阅“进一步阅读”中的最后一个参考文献)。
现在让我们仔细看看 Python 如何解释切片表示法。
切片对象
这并不是秘密,但值得重复一遍:s[a:b:c]可用于指定步长或步进c,导致生成的切片跳过项目。步长也可以是负数,返回相反顺序的项目。三个示例清楚地说明了这一点:
>>> s = 'bicycle'
>>> s[::3]
'bye'
>>> s[::-1]
'elcycib'
>>> s[::-2]
'eccb'
另一个示例在第一章中展示,当我们使用deck[12::13]来获取未洗牌牌组中的所有 A 时:
>>> deck[12::13]
[Card(rank='A', suit='spades'), Card(rank='A', suit='diamonds'),
Card(rank='A', suit='clubs'), Card(rank='A', suit='hearts')]
符号a:b:c仅在作为索引或下标运算符使用时在[]内有效,并产生一个切片对象:slice(a, b, c)。正如我们将在“切片工作原理”中看到的,为了评估表达式seq[start:stop:step],Python 调用seq.__getitem__(slice(start, stop, step))。即使您不是在实现自己的序列类型,了解切片对象也是有用的,因为它允许您为切片分配名称,就像电子表格允许命名单元格范围一样。
假设您需要解析像示例 2-13 中显示的发票那样的平面文件数据。您可以为它们命名,而不是在代码中填充硬编码切片。看看这如何使示例末尾的for循环变得更易读。
示例 2-13。来自平面文件发票的行项目
>>> invoice = """
... 0.....6.................................40........52...55........
... 1909 Pimoroni PiBrella $17.50 3 $52.50
... 1489 6mm Tactile Switch x20 $4.95 2 $9.90
... 1510 Panavise Jr. - PV-201 $28.00 1 $28.00
... 1601 PiTFT Mini Kit 320x240 $34.95 1 $34.95
... """
>>> SKU = slice(0, 6)
>>> DESCRIPTION = slice(6, 40)
>>> UNIT_PRICE = slice(40, 52)
>>> QUANTITY = slice(52, 55)
>>> ITEM_TOTAL = slice(55, None)
>>> line_items = invoice.split('\n')[2:]
>>> for item in line_items:
... print(item[UNIT_PRICE], item[DESCRIPTION])
...
$17.50 Pimoroni PiBrella
$4.95 6mm Tactile Switch x20
$28.00 Panavise Jr. - PV-201
$34.95 PiTFT Mini Kit 320x240
当我们讨论在“向量接收器#2:可切片序列”中创建自己的集合时,我们将回到slice对象。与此同时,从用户角度来看,切片包括额外的功能,如多维切片和省略号(...)表示法。继续阅读。
多维切片和省略号
[]运算符还可以接受用逗号分隔的多个索引或切片。处理[]运算符的__getitem__和__setitem__特殊方法简单地将a[i, j]中的索引作为元组接收。换句话说,为了评估a[i, j],Python 调用a.__getitem__((i, j))。
例如,在外部 NumPy 包中使用,可以使用语法a[i, j]获取二维numpy.ndarray的项目,并使用表达式a[m:n, k:l]获取二维切片。本章后面的示例 2-22 展示了此表示法的用法。
除了memoryview,Python 中的内置序列类型是一维的,因此它们仅支持一个索引或切片,而不是它们的元组。⁶
省略号——用三个完整的句号(...)而不是…(Unicode U+2026)编写——被 Python 解析器识别为一个标记。它是Ellipsis对象的别名,ellipsis类的单个实例。⁷因此,它可以作为参数传递给函数,并作为切片规范的一部分,如f(a, ..., z)或a[i:...]。NumPy 在对许多维度的数组进行切片时使用...作为快捷方式;例如,如果x是一个四维数组,则x[i, ...]是x[i, :, :, :,]的快捷方式。查看“NumPy 快速入门”以了解更多信息。
在撰写本文时,我不知道 Python 标准库中使用Ellipsis或多维索引和切片的用途。如果您发现了,请告诉我。这些语法特性存在是为了支持用户定义的类型和扩展,如 NumPy。
切片不仅有助于从序列中提取信息;它们还可以用于就地更改可变序列,即不需要从头开始重建它们。
分配到切片
可变序列可以通过在赋值语句的左侧使用切片表示法或作为del语句的目标来进行嫁接、切除和其他修改。接下来的几个示例展示了这种表示法的强大之处:
>>> l = list(range(10))
>>> l
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] >>> l[2:5] = [20, 30]
>>> l
[0, 1, 20, 30, 5, 6, 7, 8, 9] >>> del l[5:7]
>>> l
[0, 1, 20, 30, 5, 8, 9] >>> l[3::2] = [11, 22]
>>> l
[0, 1, 20, 11, 5, 22, 9] >>> l[2:5] = 100 # ①
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: can only assign an iterable
>>> l[2:5] = [100]
>>> l
[0, 1, 100, 22, 9]
①
当赋值的目标是一个切片时,右侧必须是一个可迭代对象,即使它只有一个项目。
每个程序员都知道连接序列是一种常见操作。Python 入门教程解释了如何使用+和*来实现这一目的,但它们的工作原理有一些微妙之处,我们接下来会详细介绍。
使用+和*处理序列
Python 程序员期望序列支持+和*。通常,+的两个操作数必须是相同的序列类型,并且它们都不会被修改,但作为连接结果会创建一个相同类型的新序列。
要连接同一序列的多个副本,可以将其乘以一个整数。同样,会创建一个新序列:
>>> l = [1, 2, 3]
>>> l * 5
[1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3]
>>> 5 * 'abcd'
'abcdabcdabcdabcdabcd'
+和*都会创建一个新对象,并且不会改变它们的操作数。
警告
当包含可变项的序列a尝试执行a * n时要小心,因为结果可能会让你感到惊讶。例如,尝试将一个列表的列表初始化为my_list = [[]] * 3将导致一个包含对同一内部列表的三个引用的列表,这可能不是你想要的。
下一节将介绍尝试使用*初始化列表的陷阱。
构建列表的列表
有时我们需要使用一定数量的嵌套列表来初始化一个列表,例如,将学生分配到团队列表中或表示游戏棋盘上的方块。最好的方法是使用列表推导式,就像示例 2-14 中那样。
示例 2-14. 一个包含三个长度为 3 的列表的列表可以表示一个井字棋棋盘
>>> board = [['_'] * 3 for i in range(3)] # ①
>>> board
[['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']] >>> board[1][2] = 'X' # ②
>>> board
[['_', '_', '_'], ['_', '_', 'X'], ['_', '_', '_']]
①
创建一个包含三个每个三个项目的列表的列表。检查结构。
②
在第 1 行第 2 列放置一个标记,然后查看结果。
一个诱人但错误的快捷方式是像示例 2-15 那样做。
示例 2-15. 一个包含对同一列表的三个引用的列表是无用的
>>> weird_board = [['_'] * 3] * 3 # ①
>>> weird_board
[['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']] >>> weird_board[1][2] = 'O' # ②
>>> weird_board
[['_', '_', 'O'], ['_', '_', 'O'], ['_', '_', 'O']]
①
外部列表由三个对同一内部列表的引用组成。当它保持不变时,一切似乎都正确。
②
在第 1 行第 2 列放置一个标记,揭示所有行都是指向同一对象的别名。
示例 2-15 的问题在于,本质上它的行为类似于以下代码:
row = ['_'] * 3
board = []
for i in range(3):
board.append(row) # ①
①
相同的row被三次附加到board上。
另一方面,来自示例 2-14 的列表推导式等同于以下代码:
>>> board = []
>>> for i in range(3):
... row = ['_'] * 3 # ①
... board.append(row)
...
>>> board
[['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']]
>>> board[2][0] = 'X'
>>> board # ②
[['_', '_', '_'], ['_', '_', '_'], ['X', '_', '_']]
①
每次迭代都会构建一个新的row并将其附加到board上。
②
只有第 2 行被更改,这是预期的结果。
提示
如果本节中的问题或解决方案对您来说不清楚,请放心。第六章旨在澄清引用和可变对象的机制和陷阱。
到目前为止,我们已经讨论了在序列中使用普通的+和*运算符,但还有+=和*=运算符,它们会根据目标序列的可变性产生非常不同的结果。接下来的部分将解释其工作原理。
序列的增强赋值
增强赋值运算符+=和*=的行为取决于第一个操作数。为了简化讨论,我们将首先关注增强加法(+=),但这些概念也适用于*=和其他增强赋值运算符。
使 += 生效的特殊方法是 __iadd__(代表“就地加法”)。
然而,如果未实现 __iadd__,Python 将退而求其次调用 __add__。考虑这个简单的表达式:
>>> a += b
如果 a 实现了 __iadd__,那么将会调用它。对于可变序列(例如 list、bytearray、array.array),a 将会就地更改(即效果类似于 a.extend(b))。然而,当 a 没有实现 __iadd__ 时,表达式 a += b 的效果与 a = a + b 相同:首先计算表达式 a + b,产生一个新对象,然后将其绑定到 a。换句话说,取决于是否有 __iadd__,绑定到 a 的对象的标识可能会改变或不会改变。
一般来说,对于可变序列,可以肯定会实现 __iadd__,并且 += 会就地发生。对于不可变序列,显然不可能发生这种情况。
我刚刚写的关于 += 的内容也适用于 *=,它是通过 __imul__ 实现的。__iadd__ 和 __imul__ 特殊方法在 第十六章 中有讨论。这里展示了对可变序列和不可变序列使用 *= 的演示:
>>> l = [1, 2, 3]
>>> id(l)
4311953800 # ①
>>> l *= 2
>>> l
[1, 2, 3, 1, 2, 3] >>> id(l)
4311953800 # ②
>>> t = (1, 2, 3)
>>> id(t)
4312681568 # ③
>>> t *= 2
>>> id(t)
4301348296 # ④
①
初始列表的 ID。
②
经过乘法运算后,列表仍然是同一个对象,只是附加了新项。
③
初始元组的 ID。
④
经过乘法运算后,创建了一个新的元组。
对不可变序列的重复连接是低效的,因为解释器不仅仅是附加新项,还必须复制整个目标序列,以创建一个新的序列,其中包含新附加的项。⁸
我们已经看到了 += 的常见用法。下一节将展示一个引人入胜的特殊情况,突显了在元组上“不可变”在实际中意味着什么。
A += 赋值谜题
尝试在不使用控制台的情况下回答:评估 示例 2-16 中的两个表达式的结果是什么?⁹
示例 2-16. 一个谜题
>>> t = (1, 2, [30, 40])
>>> t[2] += [50, 60]
接下来会发生什么?选择最佳答案:
-
t变成了(1, 2, [30, 40, 50, 60])。 -
引发
TypeError,消息为'tuple' object does not support item assignment。 -
无。
-
A 和 B 都是。
当我看到这个时,我非常确定答案是 B,但实际上是 D,“A 和 B 都是”!示例 2-17 是来自 Python 3.9 控制台的实际输出。¹⁰
示例 2-17. 意外结果:项目 t2 被更改 并且 引发异常
>>> t = (1, 2, [30, 40])
>>> t[2] += [50, 60]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
>>> t
(1, 2, [30, 40, 50, 60])
在线 Python Tutor 是一个很棒的在线工具,可以详细展示 Python 的工作原理。图 2-5 是两个截图的组合,显示了来自 示例 2-17 的元组 t 的初始状态和最终状态。

图 2-5. 元组赋值谜题的初始状态和最终状态(由在线 Python Tutor 生成的图表)。
当你查看 Python 为表达式 s[a] += b 生成的字节码时(示例 2-18),就会清楚这是如何发生的。
示例 2-18. 表达式 s[a] += b 的字节码
>>> dis.dis('s[a] += b')
1 0 LOAD_NAME 0 (s) 3 LOAD_NAME 1 (a) 6 DUP_TOP_TWO 7 BINARY_SUBSCR # ①
8 LOAD_NAME 2 (b) 11 INPLACE_ADD # ②
12 ROT_THREE 13 STORE_SUBSCR # ③
14 LOAD_CONST 0 (None) 17 RETURN_VALUE
①
将 s[a] 的值放在 TOS(栈顶)上。
②
执行 TOS += b。如果 TOS 指向一个可变对象(就像在 示例 2-17 中的列表),那么这将成功。
③
将 s[a] = TOS。如果 s 是不可变的(例如 示例 2-17 中的元组 t),则此操作失败。
这个例子是一个非常特殊的情况,在使用 Python 20 年中,我从未见过这种奇怪的行为实际上影响到任何人。
我从中得到了三个教训:
-
避免将可变项放入元组中。
-
增强赋值不是一个原子操作——我们刚刚看到它在完成部分工作后抛出异常。
-
检查 Python 字节码并不太困难,而且可以帮助我们了解底层发生了什么。
在见识了使用+和*进行连接的微妙之后,我们可以将话题转向另一个与序列相关的重要操作:排序。
list.sort与内置的sorted的比较
list.sort方法原地对列表进行排序,即不创建副本。它返回None以提醒我们它改变了接收者¹¹,并且没有创建新列表。这是一个重要的 Python API 约定:在原地更改对象的函数或方法应该返回None,以明确告诉调用者接收者已被更改,没有创建新对象。例如,random.shuffle(s)函数也表现出类似的行为,它原地对可变序列s进行洗牌,并返回None。
注意
返回None以表示原地更改的约定存在一个缺点:我们无法级联调用这些方法。相反,返回新对象的方法(例如,所有str方法)可以以流畅接口风格级联。请参阅维基百科的“流畅接口”条目以进一步描述这个主题。
相反,内置函数sorted创建一个新列表并返回它。它接受任何可迭代对象作为参数,包括不可变序列和生成器(参见第十七章)。无论给sorted的可迭代对象的类型是什么,它总是返回一个新创建的列表。
list.sort和sorted都接受两个可选的、仅限关键字的参数:
reverse
如果为True,则按降序(即,通过反转项目的比较)返回项目。默认值为False。
key
一个参数函数,将被应用于每个项目以生成其排序键。例如,当对字符串列表进行排序时,可以使用key=str.lower执行不区分大小写的排序,key=len将按字符长度对字符串进行排序。默认是恒等函数(即,比较项目本身)。
提示
您还可以在min()和max()内置函数以及标准库中的其他函数(例如itertools.groupby()和heapq.nlargest())中使用可选的关键字参数key。
这里有一些示例来澄清这些函数和关键字参数的使用。这些示例还演示了 Python 的排序算法是稳定的(即,它保留了相等比较的项目的相对顺序):¹²
>>> fruits = ['grape', 'raspberry', 'apple', 'banana']
>>> sorted(fruits)
['apple', 'banana', 'grape', 'raspberry'] # ①
>>> fruits
['grape', 'raspberry', 'apple', 'banana'] # ②
>>> sorted(fruits, reverse=True)
['raspberry', 'grape', 'banana', 'apple'] # ③
>>> sorted(fruits, key=len)
['grape', 'apple', 'banana', 'raspberry'] # ④
>>> sorted(fruits, key=len, reverse=True)
['raspberry', 'banana', 'grape', 'apple'] # ⑤
>>> fruits
['grape', 'raspberry', 'apple', 'banana'] # ⑥
>>> fruits.sort() # ⑦
>>> fruits
['apple', 'banana', 'grape', 'raspberry'] # ⑧
①
这将产生一个按字母顺序排序的新字符串列表。¹³
②
检查原始列表,我们看到它没有改变。
③
这是之前的“字母顺序”,但是反转了。
④
一个按长度排序的新字符串列表。由于排序算法是稳定的,“葡萄”和“苹果”,长度均为 5,按原始顺序排列。
⑤
这些是按长度降序排序的字符串。这不是前一个结果的反转,因为排序是稳定的,所以“葡萄”再次出现在“苹果”之前。
⑥
到目前为止,原始fruits列表的顺序没有改变。
⑦
这会原地对列表进行排序,并返回None(控制台省略了这一点)。
⑧
现在fruits已经排序。
警告
默认情况下,Python 按字符代码按字典顺序对字符串进行排序。这意味着 ASCII 大写字母将排在小写字母之前,非 ASCII 字符不太可能以合理的方式排序。“对 Unicode 文本进行排序”介绍了按人类期望的方式对文本进行排序的正确方法。
一旦您的序列被排序,它们可以被非常高效地搜索。Python 标准库的bisect模块中已经提供了二分搜索算法。该模块还包括bisect.insort函数,您可以使用它来确保您的排序序列保持排序。您可以在fluentpython.com伴随网站的“使用 Bisect 管理有序序列”文章中找到bisect模块的图解介绍。
到目前为止,在本章中所看到的大部分内容都适用于一般序列,而不仅仅是列表或元组。Python 程序员有时会过度使用list类型,因为它非常方便——我知道我曾经这样做过。例如,如果您正在处理大量数字列表,应考虑改用数组。本章的其余部分致力于列表和元组的替代方案。
当列表不是答案时
list类型灵活且易于使用,但根据具体要求,有更好的选择。例如,当需要处理数百万个浮点值时,array可以节省大量内存。另一方面,如果您不断地向列表的两端添加和删除项目,那么了解deque(双端队列)是一种更高效的 FIFO¹⁴数据结构是很有用的。
提示
如果您的代码经常检查集合中是否存在某个项目(例如,item in my_collection),请考虑使用set代替my_collection,特别是如果它包含大量项目。集合针对快速成员检查进行了优化。它们也是可迭代的,但它们不是序列,因为集合项的顺序是未指定的。我们将在第三章中介绍它们。
在本章的其余部分中,我们将讨论可以在许多情况下替代列表的可变序列类型,从数组开始。
数组
如果列表只包含数字,array.array是更高效的替代品。数组支持所有可变序列操作(包括.pop、.insert和.extend),以及用于快速加载和保存的附加方法,如.frombytes和.tofile。
Python 数组与 C 数组一样精简。如图 2-1 所示,float值的array不保存完整的float实例,而只保存代表其机器值的打包字节——类似于 C 语言中的double数组。创建array时,您提供一个类型码,一个用于确定数组中每个项目存储的基础 C 类型的字母。例如,b是 C 中称为signed char的类型码,一个范围从-128 到 127 的整数。如果创建一个array('b'),那么每个项目将存储在一个字节中,并解释为整数。对于大量数字序列,这可以节省大量内存。Python 不会让您放入与数组类型不匹配的任何数字。
示例 2-19 展示了创建、保存和加载一个包含 1000 万个浮点随机数的数组。
示例 2-19. 创建、保存和加载大量浮点数的数组
>>> from array import array # ①
>>> from random import random
>>> floats = array('d', (random() for i in range(10**7))) # ②
>>> floats[-1] # ③
0.07802343889111107 >>> fp = open('floats.bin', 'wb')
>>> floats.tofile(fp) # ④
>>> fp.close()
>>> floats2 = array('d') # ⑤
>>> fp = open('floats.bin', 'rb')
>>> floats2.fromfile(fp, 10**7) # ⑥
>>> fp.close()
>>> floats2[-1] # ⑦
0.07802343889111107 >>> floats2 == floats # ⑧
True
①
导入array类型。
②
从任何可迭代对象(在本例中是生成器表达式)创建双精度浮点数(类型码'd')的数组。
③
检查数组中的最后一个数字。
④
将数组保存到二进制文件。
⑤
创建一个空的双精度数组。
⑥
从二进制文件中读取 1000 万个数字。
⑦
检查数组中的最后一个数字。
⑧
验证数组内容是否匹配。
如您所见,array.tofile和array.fromfile非常易于使用。如果尝试示例,您会注意到它们也非常快速。一个快速实验显示,array.fromfile从使用array.tofile创建的二进制文件中加载 1000 万个双精度浮点数大约需要 0.1 秒。这几乎比从文本文件中读取数字快 60 倍,后者还涉及使用内置的float解析每一行。使用array.tofile保存的速度大约比在文本文件中每行写一个浮点数快七倍。此外,具有 1000 万个双精度浮点数的二进制文件的大小为 80000000 字节(每个双精度浮点数 8 字节,零开销),而相同数据的文本文件大小为 181515739 字节。
对于表示二进制数据的数字数组的特定情况,例如光栅图像,Python 中有bytes和bytearray类型,详见第四章。
我们通过表 2-3 总结了数组部分,比较了list和array.array的特性。
表 2-3。list或array中找到的方法和属性(为简洁起见,省略了已弃用的数组方法和对象也实现的方法)
| 列表 | 数组 | ||
|---|---|---|---|
s.__add__(s2) |
● | ● | s + s2—连接 |
s.__iadd__(s2) |
● | ● | s += s2—原地连接 |
s.append(e) |
● | ● | 在最后一个元素后追加一个元素 |
s.byteswap() |
● | 交换数组中所有项目的字节以进行字节顺序转换 | |
s.clear() |
● | 删除所有项目 | |
s.__contains__(e) |
● | ● | e in s |
s.copy() |
● | 列表的浅拷贝 | |
s.__copy__() |
● | 支持copy.copy |
|
s.count(e) |
● | ● | 计算元素的出现次数 |
s.__deepcopy__() |
● | 优化支持copy.deepcopy |
|
s.__delitem__(p) |
● | ● | 移除位置p处的项目 |
s.extend(it) |
● | ● | 从可迭代对象it中追加项目 |
s.frombytes(b) |
● | 从字节序列中解释为打包的机器值追加项目 | |
s.fromfile(f, n) |
● | 从解释为打包的机器值的二进制文件f追加n个项目 |
|
s.fromlist(l) |
● | 从列表追加项目;如果一个导致TypeError,则不追加任何项目 |
|
s.__getitem__(p) |
● | ● | s[p]—获取位置处的项目或切片 |
s.index(e) |
● | ● | 查找e的第一个出现位置 |
s.insert(p, e) |
● | ● | 在位置p的项目之前插入元素e |
s.itemsize |
● | 每个数组项的字节长度 | |
s.__iter__() |
● | ● | 获取迭代器 |
s.__len__() |
● | ● | len(s)—项目数 |
s.__mul__(n) |
● | ● | s * n—重复连接 |
s.__imul__(n) |
● | ● | s *= n—原地重复连接 |
s.__rmul__(n) |
● | ● | n * s—反向重复连接^(a) |
s.pop([p]) |
● | ● | 移除并返回位置p处的项目(默认为最后一个) |
s.remove(e) |
● | ● | 通过值删除元素e的第一个出现 |
s.reverse() |
● | ● | 原地反转项目的顺序 |
s.__reversed__() |
● | 获取从最后到第一个扫描项目的迭代器 | |
s.__setitem__(p, e) |
● | ● | s[p] = e—将e放在位置p,覆盖现有项目或切片 |
s.sort([key], [reverse]) |
● | 使用可选关键字参数key和reverse原地对项目进行排序 |
|
s.tobytes() |
● | 以bytes对象的形式返回打包的机器值 |
|
s.tofile(f) |
● | 将项目保存为打包的机器值到二进制文件f |
|
s.tolist() |
● | 以list中的数值对象形式返回项目 |
|
s.typecode |
● | 用于标识项目的 C 类型的单字符字符串 | |
| ^(a) 反向运算符在 第十六章 中有解释。 |
提示
截至 Python 3.10,array 类型没有像 list.sort() 那样的原地 sort 方法。如果需要对数组进行排序,请使用内置的 sorted 函数重新构建数组:
a = array.array(a.typecode, sorted(a))
要在向数组添加项目时保持已排序数组的排序,请使用 bisect.insort 函数。
如果您经常使用数组并且不了解 memoryview,那么您会错过很多。请看下一个主题。
内存视图
内置的 memoryview 类是一个共享内存序列类型,允许您处理数组的切片而无需复制字节。它受到 NumPy 库的启发(我们将在 “NumPy” 中讨论)。NumPy 的首席作者 Travis Oliphant 对于何时应该使用 memoryview 的问题的回答是这样的:“何时应该使用 memoryview?”
内存视图本质上是 Python 中的一个广义 NumPy 数组结构(不涉及数学)。它允许您在不复制字节的情况下在数据结构之间共享内存(例如 PIL 图像、SQLite 数据库、NumPy 数组等)。这对于大型数据集非常重要。
使用类似于 array 模块的符号,memoryview.cast 方法允许您更改多个字节的读取或写入方式,而无需移动位。memoryview.cast 总是返回另一个共享相同内存的 memoryview 对象。
示例 2-20 展示了如何在相同的 6 个字节数组上创建替代视图,以便将其视为 2×3 矩阵或 3×2 矩阵进行操作。
示例 2-20. 将 6 个字节的内存处理为 1×6、2×3 和 3×2 视图
>>> from array import array
>>> octets = array('B', range(6)) # ①
>>> m1 = memoryview(octets) # ②
>>> m1.tolist()
[0, 1, 2, 3, 4, 5] >>> m2 = m1.cast('B', [2, 3]) # ③
>>> m2.tolist()
[[0, 1, 2], [3, 4, 5]] >>> m3 = m1.cast('B', [3, 2]) # ④
>>> m3.tolist()
[[0, 1], [2, 3], [4, 5]] >>> m2[1,1] = 22 # ⑤
>>> m3[1,1] = 33 # ⑥
>>> octets # ⑦
array('B', [0, 1, 2, 33, 22, 5])
①
构建包含 6 个字节的数组(类型码为 'B')。
②
从该数组构建 memoryview,然后将其导出为列表。
③
从先前的 memoryview 创建新的 memoryview,但具有 2 行和 3 列。
④
另一个 memoryview,现在有 3 行和 2 列。
⑤
在 m2 的第 1 行、第 1 列覆盖字节为 22。
⑥
在 m3 的第 1 行、第 1 列覆盖字节为 33。
⑦
显示原始数组,证明内存在 octets、m1、m2 和 m3 之间共享。
memoryview 的强大之处也可以用来损坏。示例 2-21 展示了如何更改 16 位整数数组中一个项目的单个字节。
示例 2-21. 通过修改一个字节来更改 16 位整数数组项的值
>>> numbers = array.array('h', [-2, -1, 0, 1, 2])
>>> memv = memoryview(numbers) # ①
>>> len(memv)
5 >>> memv[0] # ②
-2 >>> memv_oct = memv.cast('B') # ③
>>> memv_oct.tolist() # ④
[254, 255, 255, 255, 0, 0, 1, 0, 2, 0] >>> memv_oct[5] = 4 # ⑤
>>> numbers
array('h', [-2, -1, 1024, 1, 2]) # ⑥
①
从包含 5 个 16 位有符号整数的数组(类型码为 'h')构建 memoryview。
②
memv 在数组中看到相同的 5 个项目。
③
通过将 memv 的元素转换为字节(类型码为 'B')来创建 memv_oct。
④
将 memv_oct 的元素导出为包含 10 个字节的列表,以供检查。
⑤
将值 4 分配给字节偏移 5。
⑥
注意 numbers 的变化:2 字节无符号整数的最高有效字节中的 4 是 1024。
注意
您将在 fluentpython.com 上找到使用 struct 包检查 memoryview 的示例:“使用 struct 解析二进制记录”。
同时,如果您在数组中进行高级数值处理,应该使用 NumPy 库。我们将立即简要介绍它们。
NumPy
在本书中,我强调了 Python 标准库中已经存在的内容,以便您能充分利用它。但是 NumPy 如此强大,值得一提。
对于高级的数组和矩阵操作,NumPy 是 Python 在科学计算应用中变得流行的原因。NumPy 实现了多维、同质数组和矩阵类型,不仅保存数字,还保存用户定义的记录,并提供高效的逐元素操作。
SciPy 是一个库,建立在 NumPy 之上,提供许多来自线性代数、数值微积分和统计学的科学计算算法。SciPy 快速可靠,因为它利用了来自Netlib Repository的广泛使用的 C 和 Fortran 代码库。换句话说,SciPy 为科学家提供了最佳的两种选择:交互式提示符和高级 Python API,以及在 C 和 Fortran 中优化的工业强度数值计算函数。
作为一个非常简短的 NumPy 演示,示例 2-22 展示了一些关于二维数组的基本操作。
示例 2-22。在numpy.ndarray中进行行和列的基本操作
>>> import numpy as np # ①
>>> a = np.arange(12) # ②
>>> a
array([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) >>> type(a)
<class 'numpy.ndarray'> >>> a.shape # ③
(12,) >>> a.shape = 3, 4 # ④
>>> a
array([[ 0, 1, 2, 3],
[ 4, 5, 6, 7], [ 8, 9, 10, 11]]) >>> a[2] # ⑤
array([ 8, 9, 10, 11]) >>> a[2, 1] # ⑥
9 >>> a[:, 1] # ⑦
array([1, 5, 9]) >>> a.transpose() # ⑧
array([[ 0, 4, 8],
[ 1, 5, 9], [ 2, 6, 10], [ 3, 7, 11]])
①
导入 NumPy,在安装后(不在 Python 标准库中)。按照惯例,将numpy导入为np。
②
构建并检查一个包含整数0到11的numpy.ndarray。
③
检查数组的维度:这是一个一维的,包含 12 个元素的数组。
④
改变数组的形状,增加一个维度,然后检查结果。
⑤
获取索引为2的行。
⑥
获取索引为2, 1的元素。
⑦
获取索引为1的列。
⑧
通过转置(交换列和行)创建一个新的数组。
NumPy 还支持用于加载、保存和操作numpy.ndarray的高级操作:
>>> import numpy
>>> floats = numpy.loadtxt('floats-10M-lines.txt') # ①
>>> floats[-3:] # ②
array([ 3016362.69195522, 535281.10514262, 4566560.44373946]) >>> floats *= .5 # ③
>>> floats[-3:]
array([ 1508181.34597761, 267640.55257131, 2283280.22186973]) >>> from time import perf_counter as pc # ④
>>> t0 = pc(); floats /= 3; pc() - t0 # ⑤
0.03690556302899495 >>> numpy.save('floats-10M', floats) # ⑥
>>> floats2 = numpy.load('floats-10M.npy', 'r+') # ⑦
>>> floats2 *= 6
>>> floats2[-3:] # ⑧
memmap([ 3016362.69195522, 535281.10514262, 4566560.44373946])
①
从文本文件中加载 1000 万个浮点数。
②
使用序列切片表示法检查最后三个数字。
③
将floats数组中的每个元素乘以.5,然后再次检查最后三个元素。
④
导入高分辨率性能测量计时器(自 Python 3.3 起可用)。
⑤
将每个元素除以3;对于 1000 万个浮点数,经过的时间不到 40 毫秒。
⑥
将数组保存为.npy二进制文件。
⑦
将数据作为内存映射文件加载到另一个数组中;这允许对数组的切片进行高效处理,即使它不能完全放入内存中。
⑧
将每个元素乘以6后,检查最后三个元素。
这只是一个开胃菜。
NumPy 和 SciPy 是强大的库,是其他出色工具的基础,比如 Pandas — 实现了可以容纳非数值数据的高效数组类型,并提供了许多不同格式的导入/导出功能,如 .csv、.xls、SQL dumps、HDF5 等 — 以及 scikit-learn,目前是最广泛使用的机器学习工具集。大多数 NumPy 和 SciPy 函数是用 C 或 C++ 实现的,并且可以利用所有 CPU 核心,因为它们释放了 Python 的 GIL(全局解释器锁)。Dask 项目支持在机器群集上并行处理 NumPy、Pandas 和 scikit-learn。这些包值得写一整本书来介绍。但这不是那本书。但是,没有至少简要介绍 NumPy 数组的 Python 序列概述是不完整的。
在查看了平面序列 — 标准数组和 NumPy 数组之后,我们现在转向一组完全不同的替代品,用于替代普通的 list:队列。
Deques 和其他队列
.append 和 .pop 方法使得 list 可以用作堆栈或队列(如果使用 .append 和 .pop(0),则获得 FIFO 行为)。但是,在列表头部(0 索引端)插入和删除是昂贵的,因为整个列表必须在内存中移动。
类 collections.deque 是一个线程安全的双端队列,旨在快速从两端插入和删除。如果需要保留“最近看到的项目”列表或类似内容,deque 也是一个不错的选择,因为 deque 可以是有界的 — 即,创建时具有固定的最大长度。如果有界 deque 已满,在添加新项目时,它会从相反端丢弃一个项目。示例 2-23 展示了在 deque 上执行的一些典型操作。
示例 2-23. 使用 deque
>>> from collections import deque
>>> dq = deque(range(10), maxlen=10) # ①
>>> dq
deque([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], maxlen=10) >>> dq.rotate(3) # ②
>>> dq
deque([7, 8, 9, 0, 1, 2, 3, 4, 5, 6], maxlen=10) >>> dq.rotate(-4)
>>> dq
deque([1, 2, 3, 4, 5, 6, 7, 8, 9, 0], maxlen=10) >>> dq.appendleft(-1) # ③
>>> dq
deque([-1, 1, 2, 3, 4, 5, 6, 7, 8, 9], maxlen=10) >>> dq.extend([11, 22, 33]) # ④
>>> dq
deque([3, 4, 5, 6, 7, 8, 9, 11, 22, 33], maxlen=10) >>> dq.extendleft([10, 20, 30, 40]) # ⑤
>>> dq
deque([40, 30, 20, 10, 3, 4, 5, 6, 7, 8], maxlen=10)
①
可选的 maxlen 参数设置了此 deque 实例中允许的最大项目数;这将设置一个只读的 maxlen 实例属性。
②
使用 n > 0 旋转会从右端获取项目并将其前置到左端;当 n < 0 时,项目从左端获取并附加到右端。
③
向已满的 deque(len(d) == d.maxlen)添加元素会丢弃另一端的项目;请注意下一行中的 0 被丢弃了。
④
向右侧添加三个项目会推出最左侧的 -1、1 和 2。
⑤
请注意,extendleft(iter) 的工作方式是将 iter 参数的每个连续项目附加到 deque 的左侧,因此项目的最终位置是反转的。
Table 2-4 比较了 list 和 deque 中特定的方法(删除了也出现在 object 中的方法)。
请注意,deque 实现了大多数 list 方法,并添加了一些特定于其设计的方法,如 popleft 和 rotate。但是存在隐藏成本:从 deque 中间删除项目不够快。它真正优化于从两端附加和弹出。
append 和 popleft 操作是原子性的,因此在多线程应用程序中,deque 可以安全地用作 FIFO 队列,无需使用锁。
表 2-4. 在 list 或 deque 中实现的方法(省略了那些也由 object 实现的方法)
| list | deque | ||
|---|---|---|---|
s.__add__(s2) |
● | s + s2—连接 |
|
s.__iadd__(s2) |
● | ● | s += s2—原地连接 |
s.append(e) |
● | ● | 向右侧附加一个元素(在最后之后) |
s.appendleft(e) |
● | 向左侧附加一个元素(在第一个之前) | |
s.clear() |
● | ● | 删除所有项目 |
s.__contains__(e) |
● | e in s |
|
s.copy() |
● | 列表的浅复制 | |
s.__copy__() |
● | 支持 copy.copy(浅复制) |
|
s.count(e) |
● | ● | 计算元素出现的次数 |
s.__delitem__(p) |
● | ● | 删除位置 p 处的项目 |
s.extend(i) |
● | ● | 将可迭代对象 i 中的项目添加到右侧 |
s.extendleft(i) |
● | 将可迭代对象 i 中的项目添加到左侧 |
|
s.__getitem__(p) |
● | ● | s[p]—获取位置处的项目或切片 |
s.index(e) |
● | 查找第一个出现的 e 的位置 |
|
s.insert(p, e) |
● | 在位置 p 的项目之前插入元素 e |
|
s.__iter__() |
● | ● | 获取迭代器 |
s.__len__() |
● | ● | len(s)—项目数量 |
s.__mul__(n) |
● | s * n—重复连接 |
|
s.__imul__(n) |
● | s *= n—原地重复连接 |
|
s.__rmul__(n) |
● | n * s—反向重复连接 |
|
s.pop() |
● | ● | 移除并返回最后一个项目 |
s.popleft() |
● | 移除并返回第一个项目 | |
s.remove(e) |
● | ● | 按值删除第一个出现的元素 e |
s.reverse() |
● | ● | 原地反转项目顺序 |
s.__reversed__() |
● | ● | 获取迭代器以从后向前扫描项目 |
s.rotate(n) |
● | 将 n 个项目从一端移动到另一端 |
|
s.__setitem__(p, e) |
● | ● | s[p] = e—将 e 放在位置 p,覆盖现有的项目或切片 |
s.sort([key], [reverse]) |
● | 使用可选关键字参数 key 和 reverse 原地对项目进行排序 |
|
^(a) 反向操作符在 第十六章 中有解释。^(b) a_list.pop(p) 允许从位置 p 处移除项目,但 deque 不支持该选项。 |
除了 deque,其他 Python 标准库包实现了队列:
queue
这提供了同步(即线程安全)的类 SimpleQueue、Queue、LifoQueue 和 PriorityQueue。这些可以用于线程之间的安全通信。除了 SimpleQueue 外,通过向构造函数提供大于 0 的 maxsize 参数,其他队列都可以被限制大小。然而,它们不会像 deque 那样丢弃项目以腾出空间。相反,当队列已满时,插入新项目会被阻塞—即等待直到其他线程通过从队列中取出项目来腾出空间,这对于限制活动线程数量很有用。
multiprocessing
实现了自己的无界 SimpleQueue 和有界 Queue,与 queue 包中的类非常相似,但设计用于进程间通信。提供了专门的 multiprocessing.JoinableQueue 用于任务管理。
asyncio
提供了受 queue 和 multiprocessing 模块中类启发的 Queue、LifoQueue、PriorityQueue 和 JoinableQueue,但适用于管理异步编程中的任务。
heapq
与前三个模块不同,heapq 不实现队列类,而是提供函数如 heappush 和 heappop,让您可以使用可变序列作为堆队列或优先队列。
这结束了我们对 list 类型的替代品以及对序列类型的一般探索—除了 str 和二进制序列的细节,它们有自己的章节(第四章)。
章节总结
精通标准库的序列类型是编写简洁、高效和惯用的 Python 代码的先决条件。
Python 序列通常被归类为可变或不可变,但考虑一个不同的维度也是有用的:扁平序列和容器序列。前者更紧凑、更快速、更易于使用,但仅限于存储数字、字符和字节等原子数据。容器序列更灵活,但当它们持有可变对象时可能会让您感到惊讶,因此您需要小心地在嵌套数据结构中正确使用它们。
不幸的是,Python 没有绝对可靠的不可变容器序列类型:即使“不可变”元组中包含可变项(如列表或用户定义对象),其值也可能被更改。
列表推导和生成器表达式是构建和初始化序列的强大表示法。如果您尚未熟悉它们,请花时间掌握它们的基本用法。这并不难,很快您就会上瘾。
在 Python 中,元组扮演两个角色:作为具有未命名字段的记录和作为不可变列表。当将元组用作不可变列表时,请记住,仅当其中所有项也是不可变时,元组值才被保证固定。在元组上调用 hash(t) 是一种快速断言其值固定的方法。如果 t 包含可变项,则会引发 TypeError。
当元组用作记录时,元组解包是提取元组字段的最安全、最可读的方式。除了元组外,* 在许多上下文中与列表和可迭代对象一起使用,并且在 Python 3.5 中出现了一些用例,其中包括 PEP 448—Additional Unpacking Generalizations。Python 3.10 引入了带有 match/case 的模式匹配,支持更强大的解包,称为解构。
序列切片是 Python 中一个受欢迎的语法特性,比许多人意识到的要更强大。多维切片和省略号(...)符号,如 NumPy 中使用的方式,也可能受到用户定义序列的支持。对切片赋值是编辑可变序列的一种非常表达性的方式。
如 seq * n 中的重复连接很方便,并且经过小心处理,可以用于初始化包含不可变项的列表列表。对于可变和不可变序列,使用 += 和 *= 的增强赋值行为不同。在后一种情况下,这些运算符必然构建新序列。但如果目标序列是可变的,则通常会就地更改它,但并非总是,这取决于序列的实现方式。
sort 方法和 sorted 内置函数易于使用且灵活,这要归功于可选的 key 参数:用于计算排序标准的函数。顺便说一句,key 也可以与 min 和 max 内置函数一起使用。
除了列表和元组外,Python 标准库还提供了 array.array。虽然 NumPy 和 SciPy 不是标准库的一部分,但如果您对大量数据进行任何类型的数值处理,学习这些库的一小部分甚至可以让您走得更远。
我们最后讨论了多才多艺且线程安全的 collections.deque,将其 API 与 list 在表 2-4 中进行了比较,并提到了标准库中的其他队列实现。
进一步阅读
第一章“数据结构”来自Python Cookbook,第 3 版(O’Reilly),作者是 David Beazley 和 Brian K. Jones,其中包含许多关于序列的技巧,包括“Recipe 1.11. 命名切片”,我从中学到了将切片赋值给变量以提高可读性的技巧,在我们的示例 2-13 中有所展示。
Python Cookbook 的第二版是为 Python 2.4 编写的,但其中的许多代码也适用于 Python 3,并且第五章和第六章中的许多技巧涉及序列。该书由 Alex Martelli、Anna Ravenscroft 和 David Ascher 编辑,其中包括数十位 Python 爱好者的贡献。第三版是从头开始重写的,更侧重于语言的语义,特别是 Python 3 中发生了什么变化,而旧版则更强调实用性(即如何将语言应用于实际问题)。尽管第二版的一些解决方案不再是最佳方法,但我真诚地认为值得同时拥有Python Cookbook 的两个版本。
官方 Python “排序 HOW TO” 中有几个关于使用 sorted 和 list.sort 的高级技巧示例。
PEP 3132—扩展可迭代解包是阅读关于在并行赋值的左侧使用*extra语法的新用法的权威来源。如果你想一窥 Python 的发展,“缺失*-解包泛化”是一个提出增强可迭代解包符号的 bug 跟踪器问题。PEP 448—额外解包泛化是从该问题的讨论中产生的。
正如我在“使用序列进行模式匹配”中提到的,Carol Willing 的“结构化模式匹配”部分在“Python 3.10 有什么新特性”中是对这一重要新功能的很好介绍,大约有 1400 字(当 Firefox 从 HTML 生成 PDF 时,这不到 5 页)。PEP 636—结构化模式匹配:教程也不错,但更长。同样的 PEP 636 包括“附录 A—快速介绍”。它比 Willing 的介绍短,因为它省略了关于为什么模式匹配对你有好处的高层考虑。如果你需要更多论据来说服自己或他人模式匹配对 Python 有好处,那么阅读 22 页的PEP 635—结构化模式匹配:动机和原理。
Eli Bendersky 的博客文章“使用缓冲区协议和 memoryviews 在 Python 中减少拷贝”包含了关于memoryview的简短教程。
市场上有许多涵盖 NumPy 的书籍,许多书名中并未提及“NumPy”。两个例子是 Jake VanderPlas 的开放获取书籍Python 数据科学手册,以及 Wes McKinney 的第二版Python 数据分析。
“NumPy 的全部内容都关乎向量化。”这是 Nicolas P. Rougier 的开放获取书籍从 Python 到 NumPy的开篇语句。向量化操作将数学函数应用于数组的所有元素,而无需在 Python 中编写显式循环。它们可以并行操作,使用现代 CPU 中的特殊向量指令,利用多个核心或委托给 GPU,具体取决于库。Rougier 的书中的第一个例子展示了通过将一个漂亮的 Python 类使用生成器方法重构为调用几个 NumPy 向量函数的精简函数后,速度提高了 500 倍。
要学习如何使用deque(以及其他集合),请参阅 Python 文档中“容器数据类型”中的示例和实用配方。
Python 惯例中排除范围和切片中的最后一项的最佳辩护是由 Edsger W. Dijkstra 亲自撰写的,标题为“为什么编号应该从零开始”的短备忘录。备忘录的主题是数学符号,但与 Python 相关,因为 Dijkstra 以严谨和幽默解释了为什么像 2, 3, …, 12 这样的序列应该始终表示为 2 ≤ i < 13。所有其他合理的惯例都被驳斥,以及让每个用户选择惯例的想法。标题指的是基于零的索引,但备忘录实际上是关于为什么'ABCDE'[1:3]意味着'BC'而不是'BCD',以及为什么写range(2, 13)来生成 2, 3, 4, …, 12 是完全合理的。顺便说一句,备忘录是一张手写的便条,但它非常漂亮且完全可读。Dijkstra 的笔迹非常清晰,以至于有人根据他的笔记创建了一个字体。
¹ Leo Geurts,Lambert Meertens 和 Steven Pemberton,ABC 程序员手册,第 8 页。 (Bosko Books)。
² 感谢读者 Tina Lapine 指出这一点。
³ 感谢技术审阅员 Leonardo Rochael 提供此示例。
⁴ 在我看来,一系列的if/elif/elif/.../else块是对switch/case的一个很好的替代。它不会受到一些语言设计者在几十年后仍然无谓地从 C 语言中复制的贯穿和悬空 else问题的困扰,这些问题已经被广泛认为是导致无数错误的原因。
⁵ 后者在 Norvig 的代码中被命名为eval;我将其重命名以避免与 Python 的eval内置函数混淆。
⁶ 在“内存视图”中,我们展示了特别构造的内存视图可以具有多个维度。
⁷ 不,我没有搞错:ellipsis类名确实全小写,而实例是一个名为Ellipsis的内置对象,就像bool是小写但其实例是True和False一样。
⁸ str是这个描述的一个例外。因为在实际代码库中,在循环中使用+=进行字符串构建是如此普遍,CPython 对这种用例进行了优化。str的实例在内存中分配了额外的空间,因此连接不需要每次都复制整个字符串。
⁹ 感谢 Leonardo Rochael 和 Cesar Kawakami 在 2013 年 PythonBrasil 大会上分享这个谜题。
¹⁰ 读者建议在示例中的操作可以用t[2].extend([50,60])来完成,而不会出错。我知道这一点,但我的目的是展示在这种情况下+=运算符的奇怪行为。
¹¹ 接收者是方法调用的目标,是方法体中绑定到self的对象。
¹² Python 的主要排序算法以其创造者 Tim Peters 命名为 Timsort。有关 Timsort 的一些趣闻,参见“讲台”。
¹³ 这个例子中的单词按字母顺序排序,因为它们完全由小写 ASCII 字符组成。请参见示例后的警告。
¹⁴ 先进先出——队列的默认行为。
第三章:字典和集合
Python 基本上是用大量语法糖包装的字典。
Lalo Martins,早期数字游牧民和 Pythonista
我们在所有的 Python 程序中都使用字典。即使不是直接在我们的代码中,也是间接的,因为dict类型是 Python 实现的基本部分。类和实例属性、模块命名空间和函数关键字参数是内存中由字典表示的核心 Python 构造。__builtins__.__dict__存储所有内置类型、对象和函数。
由于其关键作用,Python 字典经过高度优化,并持续改进。哈希表是 Python 高性能字典背后的引擎。
其他基于哈希表的内置类型是set和frozenset。这些提供比您在其他流行语言中遇到的集合更丰富的 API 和运算符。特别是,Python 集合实现了集合理论中的所有基本操作,如并集、交集、子集测试等。通过它们,我们可以以更声明性的方式表达算法,避免大量嵌套循环和条件语句。
以下是本章的简要概述:
-
用于构建和处理
dicts和映射的现代语法,包括增强的解包和模式匹配 -
映射类型的常见方法
-
丢失键的特殊处理
-
标准库中
dict的变体 -
set和frozenset类型 -
哈希表在集合和字典行为中的影响。
本章的新内容
这第二版中的大部分变化涵盖了与映射类型相关的新功能:
-
“现代字典语法”介绍了增强的解包语法以及合并映射的不同方式,包括自 Python 3.9 起由
dicts支持的|和|=运算符。 -
“使用映射进行模式匹配”演示了自 Python 3.10 起使用
match/case处理映射。 -
“collections.OrderedDict”现在专注于
dict和OrderedDict之间的细微但仍然相关的差异——考虑到自 Python 3.6 起dict保留键插入顺序。 -
由
dict.keys、dict.items和dict.values返回的视图对象的新部分:“字典视图”和“字典视图上的集合操作”。
dict和set的基础实现仍然依赖于哈希表,但dict代码有两个重要的优化,可以节省内存并保留键在dict中的插入顺序。“dict 工作原理的实际后果”和“集合工作原理的实际后果”总结了您需要了解的内容,以便很好地使用它们。
注意
在这第二版中增加了 200 多页后,我将可选部分“集合和字典的内部”移至fluentpython.com伴随网站。更新和扩展的18 页文章包括关于以下内容的解释和图表:
-
哈希表算法和数据结构,从在
set中的使用开始,这更容易理解。 -
保留
dict实例中键插入顺序的内存优化(自 Python 3.6 起)。 -
用于保存实例属性的字典的键共享布局——用户定义对象的
__dict__(自 Python 3.3 起实现的优化)。
现代字典语法
接下来的部分描述了用于构建、解包和处理映射的高级语法特性。其中一些特性在语言中并不新鲜,但对您可能是新的。其他需要 Python 3.9(如|运算符)或 Python 3.10(如match/case)的特性。让我们从其中一个最好且最古老的特性开始。
字典推导式
自 Python 2.7 起,列表推导和生成器表达式的语法已经适应了 dict 推导(以及我们即将讨论的 set 推导)。dictcomp(dict 推导)通过从任何可迭代对象中获取 key:value 对来构建一个 dict 实例。示例 3-1 展示了使用 dict 推导从相同的元组列表构建两个字典的用法。
示例 3-1. dict 推导示例
>>> dial_codes = ![1
... (880, 'Bangladesh'),
... (55, 'Brazil'),
... (86, 'China'),
... (91, 'India'),
... (62, 'Indonesia'),
... (81, 'Japan'),
... (234, 'Nigeria'),
... (92, 'Pakistan'),
... (7, 'Russia'),
... (1, 'United States'),
... ]
>>> country_dial = {country: code for code, country in dial_codes} # ②
>>> country_dial
{'Bangladesh': 880, 'Brazil': 55, 'China': 86, 'India': 91, 'Indonesia': 62, 'Japan': 81, 'Nigeria': 234, 'Pakistan': 92, 'Russia': 7, 'United States': 1} >>> {code: country.upper() # ③
... for country, code in sorted(country_dial.items())
... if code < 70}
{55: 'BRAZIL', 62: 'INDONESIA', 7: 'RUSSIA', 1: 'UNITED STATES'}
①
可以直接将类似 dial_codes 的键值对可迭代对象传递给 dict 构造函数,但是…
②
…在这里我们交换了键值对:country 是键,code 是值。
③
按名称对 country_dial 进行排序,再次反转键值对,将值大写,并使用 code < 70 过滤项。
如果你习惯于列表推导,那么字典推导是一个自然的下一步。如果你不熟悉,那么理解推导语法的传播意味着现在比以往任何时候都更有利可图。
解包映射
PEP 448—额外的解包泛化 自 Python 3.5 以来增强了对映射解包的支持。
首先,我们可以在函数调用中对多个参数应用 **。当键都是字符串且在所有参数中唯一时,这将起作用(因为禁止重复关键字参数):
>>> def dump(**kwargs):
... return kwargs
...
>>> dump(**{'x': 1}, y=2, **{'z': 3})
{'x': 1, 'y': 2, 'z': 3}
第二,** 可以在 dict 字面量内使用——也可以多次使用:
>>> {'a': 0, **{'x': 1}, 'y': 2, **{'z': 3, 'x': 4}}
{'a': 0, 'x': 4, 'y': 2, 'z': 3}
在这种情况下,允许重复的键。后续出现的键会覆盖先前的键—请参见示例中映射到 x 的值。
这种语法也可以用于合并映射,但还有其他方法。请继续阅读。
使用 | 合并映射
Python 3.9 支持使用 | 和 |= 来合并映射。这是有道理的,因为这些也是集合的并运算符。
| 运算符创建一个新的映射:
>>> d1 = {'a': 1, 'b': 3}
>>> d2 = {'a': 2, 'b': 4, 'c': 6}
>>> d1 | d2
{'a': 2, 'b': 4, 'c': 6}
通常,新映射的类型将与左操作数的类型相同—在示例中是 d1,但如果涉及用户定义的类型,则可以是第二个操作数的类型,根据我们在第十六章中探讨的运算符重载规则。
要就地更新现有映射,请使用 |=。继续前面的例子,d1 没有改变,但现在它被改变了:
>>> d1
{'a': 1, 'b': 3}
>>> d1 |= d2
>>> d1
{'a': 2, 'b': 4, 'c': 6}
提示
如果你需要维护能在 Python 3.8 或更早版本上运行的代码,PEP 584—为 dict 添加 Union 运算符 的 “动机” 部分提供了其他合并映射的方法的简要总结。
现在让我们看看模式匹配如何应用于映射。
使用映射进行模式匹配
match/case 语句支持作为映射对象的主题。映射的模式看起来像 dict 字面量,但它们可以匹配 collections.abc.Mapping 的任何实际或虚拟子类的实例。¹
在第二章中,我们只关注了序列模式,但不同类型的模式可以组合和嵌套。由于解构,模式匹配是处理结构化为嵌套映射和序列的记录的强大工具,我们经常需要从 JSON API 和具有半结构化模式的数据库(如 MongoDB、EdgeDB 或 PostgreSQL)中读取这些记录。示例 3-2 演示了这一点。get_creators 中的简单类型提示清楚地表明它接受一个 dict 并返回一个 list。
示例 3-2. creator.py:get_creators() 从媒体记录中提取创作者的名称
def get_creators(record: dict) -> list:
match record:
case {'type': 'book', 'api': 2, 'authors': [*names]}: # ①
return names
case {'type': 'book', 'api': 1, 'author': name}: # ②
return [name]
case {'type': 'book'}: # ③
raise ValueError(f"Invalid 'book' record: {record!r}")
case {'type': 'movie', 'director': name}: # ④
return [name]
case _: # ⑤
raise ValueError(f'Invalid record: {record!r}')
①
匹配任何具有 'type': 'book', 'api' :2 的映射,并且一个 'authors' 键映射到一个序列。将序列中的项作为新的 list 返回。
②
匹配任何具有 'type': 'book', 'api' :1 的映射,并且一个 'author' 键映射到任何对象。将对象放入一个 list 中返回。
③
具有'type': 'book'的任何其他映射都是无效的,引发ValueError。
④
匹配任何具有'type': 'movie'和将'director'键映射到单个对象的映射。返回list中的对象。
⑤
任何其他主题都是无效的,引发ValueError。
示例 3-2 展示了处理半结构化数据(如 JSON 记录)的一些有用实践:
-
包括描述记录类型的字段(例如,
'type': 'movie') -
包括标识模式版本的字段(例如,`'api': 2')以允许公共 API 的未来演变
-
有
case子句来处理特定类型(例如,'book')的无效记录,以及一个全捕捉
现在让我们看看get_creators如何处理一些具体的 doctests:
>>> b1 = dict(api=1, author='Douglas Hofstadter',
... type='book', title='Gödel, Escher, Bach')
>>> get_creators(b1)
['Douglas Hofstadter']
>>> from collections import OrderedDict
>>> b2 = OrderedDict(api=2, type='book',
... title='Python in a Nutshell',
... authors='Martelli Ravenscroft Holden'.split())
>>> get_creators(b2)
['Martelli', 'Ravenscroft', 'Holden']
>>> get_creators({'type': 'book', 'pages': 770})
Traceback (most recent call last):
...
ValueError: Invalid 'book' record: {'type': 'book', 'pages': 770}
>>> get_creators('Spam, spam, spam')
Traceback (most recent call last):
...
ValueError: Invalid record: 'Spam, spam, spam'
注意,模式中键的顺序无关紧要,即使主题是OrderedDict,如b2。
与序列模式相比,映射模式在部分匹配上成功。在 doctests 中,b1和b2主题包括一个在任何'book'模式中都不出现的'title'键,但它们匹配。
不需要使用**extra来匹配额外的键值对,但如果要将它们捕获为dict,可以使用**前缀一个变量。它必须是模式中的最后一个,并且**_是被禁止的,因为它是多余的。一个简单的例子:
>>> food = dict(category='ice cream', flavor='vanilla', cost=199)
>>> match food:
... case {'category': 'ice cream', **details}:
... print(f'Ice cream details: {details}')
...
Ice cream details: {'flavor': 'vanilla', 'cost': 199}
在“缺失键的自动处理”中,我们将研究defaultdict和其他映射,其中通过__getitem__(即,d[key])进行键查找成功,因为缺失项会动态创建。在模式匹配的上下文中,只有在主题已经具有match语句顶部所需键时,匹配才成功。
提示
不会触发缺失键的自动处理,因为模式匹配总是使用d.get(key, sentinel)方法——其中默认的sentinel是一个特殊的标记值,不能出现在用户数据中。
从语法和结构转向,让我们研究映射的 API。
映射类型的标准 API
collections.abc模块提供了描述dict和类似类型接口的Mapping和MutableMapping ABCs。参见图 3-1。
ABCs 的主要价值在于记录和规范映射的标准接口,并作为需要支持广义映射的代码中isinstance测试的标准:
>>> my_dict = {}
>>> isinstance(my_dict, abc.Mapping)
True
>>> isinstance(my_dict, abc.MutableMapping)
True
提示
使用 ABC 进行isinstance通常比检查函数参数是否为具体dict类型更好,因为这样可以使用替代映射类型。我们将在第十三章中详细讨论这个问题。

图 3-1。collections.abc中MutableMapping及其超类的简化 UML 类图(继承箭头从子类指向超类;斜体名称是抽象类和抽象方法)。
要实现自定义映射,最好扩展collections.UserDict,或通过组合包装dict,而不是继承这些 ABCs。collections.UserDict类和标准库中的所有具体映射类在其实现中封装了基本的dict,而dict又建立在哈希表上。因此,它们都共享一个限制,即键必须是可哈希的(值不需要是可哈希的,只有键需要是可哈希的)。如果需要复习,下一节会解释。
什么是可哈希的
这里是从Python 术语表中适应的可哈希定义的部分:
如果对象具有永远不会在其生命周期内更改的哈希码(它需要一个
__hash__()方法),并且可以与其他对象进行比较(它需要一个__eq__()方法),则该对象是可哈希的。比较相等的可哈希对象必须具有相同的哈希码。²
数值类型和扁平不可变类型str和bytes都是可哈希的。如果容器类型是不可变的,并且所有包含的对象也是可哈希的,则它们是可哈希的。frozenset始终是可哈希的,因为它包含的每个元素必须根据定义是可哈希的。仅当元组的所有项都是可哈希的时,元组才是可哈希的。参见元组tt、tl和tf:
>>> tt = (1, 2, (30, 40))
>>> hash(tt)
8027212646858338501
>>> tl = (1, 2, [30, 40])
>>> hash(tl)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
>>> tf = (1, 2, frozenset([30, 40]))
>>> hash(tf)
-4118419923444501110
对象的哈希码可能因 Python 版本、机器架构以及出于安全原因添加到哈希计算中的盐而有所不同。³ 正确实现的对象的哈希码仅在一个 Python 进程中保证是恒定的。
默认情况下,用户定义的类型是可哈希的,因为它们的哈希码是它们的id(),并且从object类继承的__eq__()方法只是简单地比较对象 ID。如果一个对象实现了一个考虑其内部状态的自定义__eq__(),那么只有当其__hash__()始终返回相同的哈希码时,它才是可哈希的。实际上,这要求__eq__()和__hash__()只考虑在对象生命周期中永远不会改变的实例属性。
现在让我们回顾 Python 中最常用的映射类型dict、defaultdict和OrderedDict的 API。
常见映射方法概述
映射的基本 API 非常丰富。表 3-1 显示了dict和两个流行变体:defaultdict和OrderedDict的方法,它们都定义在collections模块中。
表 3-1. 映射类型dict、collections.defaultdict和collections.OrderedDict的方法(为简洁起见省略了常见对象方法);可选参数用[…]括起来
| dict | defaultdict | OrderedDict | ||
|---|---|---|---|---|
d.clear() |
● | ● | ● | 移除所有项 |
d.__contains__(k) |
● | ● | ● | k in d |
d.copy() |
● | ● | ● | 浅拷贝 |
d.__copy__() |
● | 支持copy.copy(d) |
||
d.default_factory |
● | __missing__调用的可调用对象,用于设置缺失值^(a) |
||
d.__delitem__(k) |
● | ● | ● | del d[k]—删除键为k的项 |
d.fromkeys(it, [initial]) |
● | ● | ● | 从可迭代对象中的键创建新映射,可选初始值(默认为None) |
d.get(k, [default]) |
● | ● | ● | 获取键为k的项,如果不存在则返回default或None |
d.__getitem__(k) |
● | ● | ● | d[k]—获取键为k的项 |
d.items() |
● | ● | ● | 获取项的视图—(key, value)对 |
d.__iter__() |
● | ● | ● | 获取键的迭代器 |
d.keys() |
● | ● | ● | 获取键的视图 |
d.__len__() |
● | ● | ● | len(d)—项数 |
d.__missing__(k) |
● | 当__getitem__找不到键时调用 |
||
d.move_to_end(k, [last]) |
● | 将k移动到第一个或最后一个位置(默认情况下last为True) |
||
d.__or__(other) |
● | ● | ● | 支持d1 | d2创建新的dict合并d1和d2(Python ≥ 3.9) |
d.__ior__(other) |
● | ● | ● | 支持d1 |= d2更新d1与d2(Python ≥ 3.9) |
d.pop(k, [default]) |
● | ● | ● | 移除并返回键为k的值,如果不存在则返回default或None |
d.popitem() |
● | ● | ● | 移除并返回最后插入的项为(key, value) ^(b) |
d.__reversed__() |
● | ● | ● | 支持reverse(d)—返回从最后插入到第一个插入的键的迭代器 |
d.__ror__(other) |
● | ● | ● | 支持other | dd—反向联合运算符(Python ≥ 3.9)^(c) |
d.setdefault(k, [default]) |
● | ● | ● | 如果k在d中,则返回d[k];否则设置d[k] = default并返回 |
d.__setitem__(k, v) |
● | ● | ● | d[k] = v—在k处放置v |
d.update(m, [**kwargs]) |
● | ● | ● | 使用映射或(key, value)对的可迭代对象更新d |
d.values() |
● | ● | ● | 获取视图的值 |
^(a) default_factory 不是一个方法,而是在实例化defaultdict时由最终用户设置的可调用属性。^(b) OrderedDict.popitem(last=False) 移除第一个插入的项目(FIFO)。last关键字参数在 Python 3.10b3 中不支持dict或defaultdict。^(c) 反向运算符在第十六章中有解释。 |
d.update(m) 处理其第一个参数m的方式是鸭子类型的一个典型例子:它首先检查m是否有一个keys方法,如果有,就假定它是一个映射。否则,update()会回退到迭代m,假设其项是(key, value)对。大多数 Python 映射的构造函数在内部使用update()的逻辑,这意味着它们可以从其他映射或从产生(key, value)对的任何可迭代对象初始化。
一种微妙的映射方法是setdefault()。当我们需要就地更新项目的值时,它避免了冗余的键查找。下一节将展示如何使用它。
插入或更新可变值
符合 Python 的失败快速哲学,使用d[k]访问dict时,当k不是现有键时会引发错误。Python 程序员知道,当默认值比处理KeyError更方便时,d.get(k, default)是d[k]的替代方案。然而,当您检索可变值并希望更新它时,有一种更好的方法。
考虑编写一个脚本来索引文本,生成一个映射,其中每个键是一个单词,值是该单词出现的位置列表,如示例 3-3 所示。
示例 3-3. 示例 3-4 处理“Python 之禅”时的部分输出;每行显示一个单词和一对出现的编码为(行号,列号)的列表。
$ python3 index0.py zen.txt
a [(19, 48), (20, 53)]
Although [(11, 1), (16, 1), (18, 1)]
ambiguity [(14, 16)]
and [(15, 23)]
are [(21, 12)]
aren [(10, 15)]
at [(16, 38)]
bad [(19, 50)]
be [(15, 14), (16, 27), (20, 50)]
beats [(11, 23)]
Beautiful [(3, 1)]
better [(3, 14), (4, 13), (5, 11), (6, 12), (7, 9), (8, 11), (17, 8), (18, 25)]
...
示例 3-4 是一个次优脚本,用于展示dict.get不是处理缺失键的最佳方式的一个案例。我从亚历克斯·马特利的一个示例中进行了改编。⁴
示例 3-4. index0.py 使用dict.get从索引中获取并更新单词出现列表的脚本(更好的解决方案在示例 3-5 中)
"""Build an index mapping word -> list of occurrences"""
import re
import sys
WORD_RE = re.compile(r'\w+')
index = {}
with open(sys.argv[1], encoding='utf-8') as fp:
for line_no, line in enumerate(fp, 1):
for match in WORD_RE.finditer(line):
word = match.group()
column_no = match.start() + 1
location = (line_no, column_no)
# this is ugly; coded like this to make a point
occurrences = index.get(word, []) # ①
occurrences.append(location) # ②
index[word] = occurrences # ③
# display in alphabetical order
for word in sorted(index, key=str.upper): # ④
print(word, index[word])
①
获取word的出现列表,如果找不到则为[]。
②
将新位置附加到occurrences。
③
将更改后的occurrences放入index字典中;这需要通过index进行第二次搜索。
④
在sorted的key=参数中,我没有调用str.upper,只是传递了对该方法的引用,以便sorted函数可以使用它来对单词进行规范化排序。⁵
示例 3-4 中处理occurrences的三行可以用dict.setdefault替换为一行。示例 3-5 更接近亚历克斯·马特利的代码。
示例 3-5. index.py 使用dict.setdefault从索引中获取并更新单词出现列表的脚本,一行搞定;与示例 3-4 进行对比
"""Build an index mapping word -> list of occurrences"""
import re
import sys
WORD_RE = re.compile(r'\w+')
index = {}
with open(sys.argv[1], encoding='utf-8') as fp:
for line_no, line in enumerate(fp, 1):
for match in WORD_RE.finditer(line):
word = match.group()
column_no = match.start() + 1
location = (line_no, column_no)
index.setdefault(word, []).append(location) # ①
# display in alphabetical order
for word in sorted(index, key=str.upper):
print(word, index[word])
①
获取word的出现列表,如果找不到则将其设置为[];setdefault返回值,因此可以在不需要第二次搜索的情况下进行更新。
换句话说,这行的最终结果是…
my_dict.setdefault(key, []).append(new_value)
…等同于运行…
if key not in my_dict:
my_dict[key] = []
my_dict[key].append(new_value)
…除了后者的代码至少执行两次对key的搜索—如果找不到,则执行三次—而setdefault只需一次查找就可以完成所有操作。
一个相关问题是,在任何查找中处理缺失键(而不仅仅是在插入时)是下一节的主题。
缺失键的自动处理
有时,当搜索缺失的键时返回一些虚构的值是很方便的。有两种主要方法:一种是使用defaultdict而不是普通的dict。另一种是子类化dict或任何其他映射类型,并添加一个__missing__方法。接下来将介绍这两种解决方案。
defaultdict:另一种处理缺失键的方法
一个collections.defaultdict实例在使用d[k]语法搜索缺失键时按需创建具有默认值的项目。示例 3-6 使用defaultdict提供了另一个优雅的解决方案来完成来自示例 3-5 的单词索引任务。
它的工作原理是:在实例化defaultdict时,你提供一个可调用对象,每当__getitem__传递一个不存在的键参数时产生一个默认值。
例如,给定一个创建为dd = defaultdict(list)的defaultdict,如果'new-key'不在dd中,表达式dd['new-key']会执行以下步骤:
-
调用
list()来创建一个新列表。 -
使用
'new-key'作为键将列表插入dd。 -
返回对该列表的引用。
产生默认值的可调用对象保存在名为default_factory的实例属性中。
示例 3-6。index_default.py:使用defaultdict而不是setdefault方法
"""Build an index mapping word -> list of occurrences"""
import collections
import re
import sys
WORD_RE = re.compile(r'\w+')
index = collections.defaultdict(list) # ①
with open(sys.argv[1], encoding='utf-8') as fp:
for line_no, line in enumerate(fp, 1):
for match in WORD_RE.finditer(line):
word = match.group()
column_no = match.start() + 1
location = (line_no, column_no)
index[word].append(location) # ②
# display in alphabetical order
for word in sorted(index, key=str.upper):
print(word, index[word])
①
使用list构造函数创建一个defaultdict作为default_factory。
②
如果word最初不在index中,则调用default_factory来生成缺失值,这种情况下是一个空的list,然后将其分配给index[word]并返回,因此.append(location)操作总是成功的。
如果没有提供default_factory,则对于缺失的键会引发通常的KeyError。
警告
defaultdict的default_factory仅在为__getitem__调用提供默认值时才会被调用,而不会为其他方法调用。例如,如果dd是一个defaultdict,k是一个缺失的键,dd[k]将调用default_factory来创建一个默认值,但dd.get(k)仍然返回None,k in dd为False。
使defaultdict工作的机制是调用default_factory的__missing__特殊方法,这是我们接下来要讨论的一个特性。
__missing__方法
映射处理缺失键的基础是名为__missing__的方法。这个方法在基本的dict类中没有定义,但dict知道它:如果你子类化dict并提供一个__missing__方法,标准的dict.__getitem__将在找不到键时调用它,而不是引发KeyError。
假设你想要一个映射,其中键在查找时被转换为str。一个具体的用例是物联网设备库,其中一个具有通用 I/O 引脚(例如树莓派或 Arduino)的可编程板被表示为一个Board类,具有一个my_board.pins属性,它是物理引脚标识符到引脚软件对象的映射。物理引脚标识符可能只是一个数字或一个字符串,如"A0"或"P9_12"。为了一致性,希望board.pins中的所有键都是字符串,但也方便通过数字查找引脚,例如my_arduino.pin[13],这样初学者在想要闪烁他们的 Arduino 上的 13 号引脚时不会出错。示例 3-7 展示了这样一个映射如何工作。
示例 3-7。当搜索非字符串键时,StrKeyDict0在未找到时将其转换为str
Tests for item retrieval using `d[key]` notation::
>>> d = StrKeyDict0([('2', 'two'), ('4', 'four')])
>>> d['2']
'two'
>>> d[4]
'four'
>>> d[1]
Traceback (most recent call last):
...
KeyError: '1'
Tests for item retrieval using `d.get(key)` notation::
>>> d.get('2')
'two'
>>> d.get(4)
'four'
>>> d.get(1, 'N/A')
'N/A'
Tests for the `in` operator::
>>> 2 in d
True
>>> 1 in d
False
示例 3-8 实现了一个通过前面的 doctests 的StrKeyDict0类。
提示
创建用户定义的映射类型的更好方法是子类化collections.UserDict而不是dict(正如我们将在示例 3-9 中所做的那样)。这里我们子类化dict只是为了展示内置的dict.__getitem__方法支持__missing__。
示例 3-8。StrKeyDict0在查找时将非字符串键转换为str(请参见示例 3-7 中的测试)
class StrKeyDict0(dict): # ①
def __missing__(self, key):
if isinstance(key, str): # ②
raise KeyError(key)
return self[str(key)] # ③
def get(self, key, default=None):
try:
return self[key] # ④
except KeyError:
return default # ⑤
def __contains__(self, key):
return key in self.keys() or str(key) in self.keys() # ⑥
①
StrKeyDict0继承自dict。
②
检查key是否已经是str。如果是,并且它丢失了,那么引发KeyError。
③
从key构建str并查找它。
④
get方法通过使用self[key]符号委托给__getitem__;这给了我们的__missing__发挥作用的机会。
⑤
如果引发KeyError,则__missing__已经失败,因此我们返回default。
⑥
搜索未修改的键(实例可能包含非str键),然后搜索从键构建的str。
花点时间考虑一下为什么在__missing__实现中需要测试isinstance(key, str)。
没有这个测试,我们的__missing__方法对于任何键k——str或非str——都能正常工作,只要str(k)产生一个现有的键。但是如果str(k)不是一个现有的键,我们将会有一个无限递归。在__missing__的最后一行,self[str(key)]会调用__getitem__,传递那个str键,然后会再次调用__missing__。
在这个例子中,__contains__方法也是必需的,因为操作k in d会调用它,但从dict继承的方法不会回退到调用__missing__。在我们的__contains__实现中有一个微妙的细节:我们不是用通常的 Python 方式检查键——k in my_dict——因为str(key) in self会递归调用__contains__。我们通过在self.keys()中明确查找键来避免这种情况。
在 Python 3 中,像k in my_dict.keys()这样的搜索对于非常大的映射也是高效的,因为dict.keys()返回一个视图,类似于集合,正如我们将在“dict 视图上的集合操作”中看到的。然而,请记住,k in my_dict也能完成同样的工作,并且更快,因为它避免了查找属性以找到.keys方法。
我在示例 3-8 中的__contains__方法中有一个特定的原因使用self.keys()。检查未修改的键——key in self.keys()——对于正确性是必要的,因为StrKeyDict0不强制字典中的所有键都必须是str类型。我们这个简单示例的唯一目标是使搜索“更友好”,而不是强制类型。
警告
派生自标准库映射的用户定义类可能会或可能不会在它们的__getitem__、get或__contains__实现中使用__missing__作为回退,如下一节所述。
标准库中对__missing__的不一致使用
考虑以下情况,以及缺失键查找是如何受影响的:
dict子类
一个只实现__missing__而没有其他方法的dict子类。在这种情况下,__missing__只能在d[k]上调用,这将使用从dict继承的__getitem__。
collections.UserDict子类
同样,一个只实现__missing__而没有其他方法的UserDict子类。从UserDict继承的get方法调用__getitem__。这意味着__missing__可能被调用来处理d[k]和d.get(k)的查找。
具有最简单可能的__getitem__的abc.Mapping子类
一个实现了__missing__和所需抽象方法的最小的abc.Mapping子类,包括一个不调用__missing__的__getitem__实现。在这个类中,__missing__方法永远不会被触发。
具有调用__missing__的__getitem__的abc.Mapping子类
一个最小的abc.Mapping子类实现了__missing__和所需的抽象方法,包括调用__missing__的__getitem__的实现。在这个类中,对使用d[k]、d.get(k)和k in d进行的缺失键查找会触发__missing__方法。
在示例代码库中查看missing.py以演示这里描述的场景。
刚才描述的四种情况假设最小实现。如果你的子类实现了__getitem__、get和__contains__,那么你可以根据需要让这些方法使用__missing__或不使用。本节的重点是要表明,在子类化标准库映射时要小心使用__missing__,因为基类默认支持不同的行为。
不要忘记,setdefault和update的行为也受键查找影响。最后,根据你的__missing__的逻辑,你可能需要在__setitem__中实现特殊逻辑,以避免不一致或令人惊讶的行为。我们将在“Subclassing UserDict Instead of dict”中看到一个例子。
到目前为止,我们已经介绍了dict和defaultdict这两种映射类型,但标准库中还有其他映射实现,接下来我们将讨论它们。
dict 的变体
本节概述了标准库中包含的映射类型,除了已在“defaultdict: Another Take on Missing Keys”中介绍的defaultdict。
collections.OrderedDict
自从 Python 3.6 开始,内置的dict也保持了键的有序性,使用OrderedDict的最常见原因是编写与早期 Python 版本向后兼容的代码。话虽如此,Python 的文档列出了dict和OrderedDict之间的一些剩余差异,我在这里引用一下——只重新排列项目以便日常使用:
-
OrderedDict的相等操作检查匹配的顺序。 -
OrderedDict的popitem()方法具有不同的签名。它接受一个可选参数来指定要弹出的项目。 -
OrderedDict有一个move_to_end()方法,可以高效地将一个元素重新定位到末尾。 -
常规的
dict被设计为在映射操作方面非常出色。跟踪插入顺序是次要的。 -
OrderedDict被设计为在重新排序操作方面表现良好。空间效率、迭代速度和更新操作的性能是次要的。 -
从算法上讲,
OrderedDict比dict更擅长处理频繁的重新排序操作。这使得它适用于跟踪最近的访问(例如,在 LRU 缓存中)。
collections.ChainMap
ChainMap实例保存了一个可以作为一个整体搜索的映射列表。查找是按照构造函数调用中出现的顺序在每个输入映射上执行的,并且一旦在这些映射中的一个中找到键,查找就成功了。例如:
>>> d1 = dict(a=1, b=3)
>>> d2 = dict(a=2, b=4, c=6)
>>> from collections import ChainMap
>>> chain = ChainMap(d1, d2)
>>> chain['a']
1
>>> chain['c']
6
ChainMap实例不会复制输入映射,而是保留对它们的引用。对ChainMap的更新或插入只会影响第一个输入映射。继续上一个例子:
>>> chain['c'] = -1
>>> d1
{'a': 1, 'b': 3, 'c': -1}
>>> d2
{'a': 2, 'b': 4, 'c': 6}
ChainMap对于实现具有嵌套作用域的语言的解释器非常有用,其中每个映射表示一个作用域上下文,从最内部的封闭作用域到最外部作用域。collections文档中的“ChainMap objects”部分有几个ChainMap使用示例,包括这个受 Python 变量查找基本规则启发的代码片段:
import builtins
pylookup = ChainMap(locals(), globals(), vars(builtins))
示例 18-14 展示了一个用于实现 Scheme 编程语言子集解释器的ChainMap子类。
collections.Counter
一个为每个键保存整数计数的映射。更新现有键会增加其计数。这可用于计算可散列对象的实例数量或作为多重集(稍后在本节讨论)。Counter 实现了 + 和 - 运算符来组合计数,并提供其他有用的方法,如 most_common([n]),它返回一个按顺序排列的元组列表,其中包含 n 个最常见的项目及其计数;请参阅文档。这里是 Counter 用于计算单词中的字母:
>>> ct = collections.Counter('abracadabra')
>>> ct
Counter({'a': 5, 'b': 2, 'r': 2, 'c': 1, 'd': 1})
>>> ct.update('aaaaazzz')
>>> ct
Counter({'a': 10, 'z': 3, 'b': 2, 'r': 2, 'c': 1, 'd': 1})
>>> ct.most_common(3)
[('a', 10), ('z', 3), ('b', 2)]
请注意,'b' 和 'r' 键并列第三,但 ct.most_common(3) 只显示了三个计数。
要将 collections.Counter 用作多重集,假装每个键是集合中的一个元素,计数是该元素在集合中出现的次数。
shelve.Shelf
标准库中的 shelve 模块为字符串键到以 pickle 二进制格式序列化的 Python 对象的映射提供了持久存储。当你意识到 pickle 罐子存放在架子上时,shelve 这个奇怪的名字就有了意义。
shelve.open 模块级函数返回一个 shelve.Shelf 实例——一个简单的键-值 DBM 数据库,由 dbm 模块支持,具有以下特点:
-
shelve.Shelf是abc.MutableMapping的子类,因此它提供了我们期望的映射类型的基本方法。 -
此外,
shelve.Shelf提供了一些其他的 I/O 管理方法,如sync和close。 -
Shelf实例是一个上下文管理器,因此您可以使用with块来确保在使用后关闭它。 -
每当将新值分配给键时,键和值都会被保存。
-
键必须是字符串。
-
值必须是
pickle模块可以序列化的对象。
shelve、dbm 和 pickle 模块的文档提供了更多细节和一些注意事项。
警告
Python 的 pickle 在最简单的情况下很容易使用,但也有一些缺点。在采用涉及 pickle 的任何解决方案之前,请阅读 Ned Batchelder 的“Pickle 的九个缺陷”。在他的帖子中,Ned 提到了其他要考虑的序列化格式。
OrderedDict、ChainMap、Counter 和 Shelf 都可以直接使用,但也可以通过子类化进行自定义。相比之下,UserDict 只是作为一个可扩展的基类。
通过继承 UserDict 而不是 dict 来创建新的映射类型
最好通过扩展 collections.UserDict 来创建新的映射类型,而不是 dict。当我们尝试扩展我们的 StrKeyDict0(来自示例 3-8)以确保将任何添加到映射中的键存储为 str 时,我们意识到这一点。
更好地通过子类化 UserDict 而不是 dict 的主要原因是,内置类型有一些实现快捷方式,最终迫使我们覆盖我们可以从 UserDict 继承而不会出现问题的方法。⁷
请注意,UserDict 不继承自 dict,而是使用组合:它有一个内部的 dict 实例,称为 data,用于保存实际的项目。这避免了在编写特殊方法如 __setitem__ 时出现不必要的递归,并简化了 __contains__ 的编写,与示例 3-8 相比更加简单。
由于 UserDict 的存在,StrKeyDict(示例 3-9)比 StrKeyDict0(示例 3-8)更简洁,但它做得更多:它将所有键都存储为 str,避免了如果实例被构建或更新时包含非字符串键时可能出现的令人不快的情况。
示例 3-9. StrKeyDict 在插入、更新和查找时总是将非字符串键转换为 str。
import collections
class StrKeyDict(collections.UserDict): # ①
def __missing__(self, key): # ②
if isinstance(key, str):
raise KeyError(key)
return self[str(key)]
def __contains__(self, key):
return str(key) in self.data # ③
def __setitem__(self, key, item):
self.data[str(key)] = item # ④
①
StrKeyDict 扩展了 UserDict。
②
__missing__ 与示例 3-8 中的一样。
③
__contains__ 更简单:我们可以假定所有存储的键都是 str,并且可以在 self.data 上进行检查,而不是像在 StrKeyDict0 中那样调用 self.keys()。
④
__setitem__ 将任何 key 转换为 str。当我们可以委托给 self.data 属性时,这种方法更容易被覆盖。
因为 UserDict 扩展了 abc.MutableMapping,使得使 StrKeyDict 成为一个完整的映射的剩余方法都是从 UserDict、MutableMapping 或 Mapping 继承的。尽管后者是抽象基类(ABC),但它们有几个有用的具体方法。以下方法值得注意:
MutableMapping.update
这种强大的方法可以直接调用,但也被 __init__ 用于从其他映射、从 (key, value) 对的可迭代对象和关键字参数加载实例。因为它使用 self[key] = value 来添加项目,所以最终会调用我们的 __setitem__ 实现。
Mapping.get
在 StrKeyDict0(示例 3-8)中,我们不得不编写自己的 get 来返回与 __getitem__ 相同的结果,但在 示例 3-9 中,我们继承了 Mapping.get,它的实现与 StrKeyDict0.get 完全相同(请参阅 Python 源代码)。
提示
安托万·皮特鲁(Antoine Pitrou)撰写了 PEP 455—向 collections 添加一个键转换字典 和一个增强 collections 模块的补丁,其中包括一个 TransformDict,比 StrKeyDict 更通用,并保留提供的键,然后应用转换。PEP 455 在 2015 年 5 月被拒绝—请参阅雷蒙德·赫廷格的 拒绝消息。为了尝试 TransformDict,我从 issue18986 中提取了皮特鲁的补丁,制作成了一个独立的模块(03-dict-set/transformdict.py 在 Fluent Python 第二版代码库 中)。
我们知道有不可变的序列类型,但不可变的映射呢?在标准库中确实没有真正的不可变映射,但有一个替代品可用。接下来是。
不可变映射
标准库提供的映射类型都是可变的,但您可能需要防止用户意外更改映射。再次在硬件编程库中找到一个具体的用例,比如 Pingo,在 “缺失方法” 中提到:board.pins 映射表示设备上的物理 GPIO 引脚。因此,防止意外更新 board.pins 是有用的,因为硬件不能通过软件更改,所以映射的任何更改都会使其与设备的物理现实不一致。
types 模块提供了一个名为 MappingProxyType 的包装类,给定一个映射,它返回一个 mappingproxy 实例,这是原始映射的只读但动态代理。这意味着可以在 mappingproxy 中看到对原始映射的更新,但不能通过它进行更改。参见 示例 3-10 进行简要演示。
示例 3-10. MappingProxyType 从 dict 构建一个只读的 mappingproxy 实例。
>>> from types import MappingProxyType
>>> d = {1: 'A'}
>>> d_proxy = MappingProxyType(d)
>>> d_proxy
mappingproxy({1: 'A'}) >>> d_proxy[1] # ①
'A' >>> d_proxy[2] = 'x' # ②
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'mappingproxy' object does not support item assignment
>>> d[2] = 'B'
>>> d_proxy # ③
mappingproxy({1: 'A', 2: 'B'}) >>> d_proxy[2]
'B' >>>
①
d 中的项目可以通过 d_proxy 看到。
②
不能通过 d_proxy 进行更改。
③
d_proxy 是动态的:d 中的任何更改都会反映出来。
在硬件编程场景中,这个方法在实践中可以这样使用:具体的 Board 子类中的构造函数会用 pin 对象填充一个私有映射,并通过一个实现为 mappingproxy 的公共 .pins 属性将其暴露给 API 的客户端。这样,客户端就无法意外地添加、删除或更改 pin。
接下来,我们将介绍视图—它允许在 dict 上进行高性能操作,而无需不必要地复制数据。
字典视图
dict实例方法.keys()、.values()和.items()返回类dict_keys、dict_values和dict_items的实例,分别。这些字典视图是dict实现中使用的内部数据结构的只读投影。它们避免了等效 Python 2 方法的内存开销,这些方法返回了重复数据的列表,这些数据已经在目标dict中,它们还替换了返回迭代器的旧方法。
示例 3-11 展示了所有字典视图支持的一些基本操作。
示例 3-11。.values()方法返回字典中值的视图
>>> d = dict(a=10, b=20, c=30)
>>> values = d.values()
>>> values
dict_values([10, 20, 30]) # ①
>>> len(values) # ②
3 >>> list(values) # ③
[10, 20, 30] >>> reversed(values) # ④
<dict_reversevalueiterator object at 0x10e9e7310> >>> values[0] # ⑤
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'dict_values' object is not subscriptable
①
视图对象的repr显示其内容。
②
我们可以查询视图的len。
③
视图是可迭代的,因此很容易从中创建列表。
④
视图实现了__reversed__,返回一个自定义迭代器。
⑤
我们不能使用[]从视图中获取单个项目。
视图对象是动态代理。如果源dict被更新,您可以立即通过现有视图看到更改。继续自示例 3-11:
>>> d['z'] = 99
>>> d
{'a': 10, 'b': 20, 'c': 30, 'z': 99}
>>> values
dict_values([10, 20, 30, 99])
类dict_keys、dict_values和dict_items是内部的:它们不通过__builtins__或任何标准库模块可用,即使你获得了其中一个的引用,也不能在 Python 代码中从头开始创建视图:
>>> values_class = type({}.values())
>>> v = values_class()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: cannot create 'dict_values' instances
dict_values类是最简单的字典视图——它只实现了__len__、__iter__和__reversed__特殊方法。除了这些方法,dict_keys和dict_items实现了几个集合方法,几乎和frozenset类一样多。在我们讨论集合之后,我们将在“字典视图上的集合操作”中更多地谈到dict_keys和dict_items。
现在让我们看一些由dict在幕后实现的规则和提示。
dict工作方式的实际后果
Python 的dict的哈希表实现非常高效,但重要的是要了解这种设计的实际影响:
-
键必须是可散列的对象。它们必须实现适当的
__hash__和__eq__方法,如“什么是可散列”中所述。 -
通过键访问项目非常快速。一个
dict可能有数百万个键,但 Python 可以通过计算键的哈希码并推导出哈希表中的索引偏移量直接定位一个键,可能会有少量尝试来找到匹配的条目的开销。 -
键的顺序保留是 CPython 3.6 中
dict更紧凑的内存布局的副作用,在 3.7 中成为官方语言特性。 -
尽管其新的紧凑布局,字典不可避免地具有显着的内存开销。对于容器来说,最紧凑的内部数据结构将是一个指向项目的指针数组。⁸ 相比之下,哈希表需要存储更多的数据,而 Python 需要保持至少三分之一的哈希表行为空以保持高效。
-
为了节省内存,避免在
__init__方法之外创建实例属性。
最后一条关于实例属性的提示来自于 Python 的默认行为是将实例属性存储在一个特殊的__dict__属性中,这是一个附加到每个实例的dict。自从 Python 3.3 实现了PEP 412—Key-Sharing Dictionary以来,一个类的实例可以共享一个与类一起存储的公共哈希表。当__init__返回时,具有相同属性名称的每个新实例的__dict__都共享该公共哈希表。然后,每个实例的__dict__只能保存自己的属性值作为指针的简单数组。在__init__之后添加一个实例属性会强制 Python 为__dict__创建一个新的哈希表,用于该实例的__dict__(这是 Python 3.3 之前所有实例的默认行为)。根据 PEP 412,这种优化可以减少面向对象程序的内存使用量 10%至 20%。
紧凑布局和键共享优化的细节相当复杂。更多信息,请阅读fluentpython.com上的“集合和字典的内部”。
现在让我们深入研究集合。
集合理论
在 Python 中,集合并不新鲜,但仍然有些被低估。set类型及其不可变的姊妹frozenset首次出现在 Python 2.3 标准库中作为模块,并在 Python 2.6 中被提升为内置类型。
注意
在本书中,我使用“集合”一词来指代set和frozenset。当专门讨论set类型,我使用等宽字体:set。
集合是一组唯一对象。一个基本用例是去除重复项:
>>> l = ['spam', 'spam', 'eggs', 'spam', 'bacon', 'eggs']
>>> set(l)
{'eggs', 'spam', 'bacon'}
>>> list(set(l))
['eggs', 'spam', 'bacon']
提示
如果你想去除重复项但又保留每个项目的第一次出现的顺序,你现在可以使用一个普通的dict来实现,就像这样:
>>> dict.fromkeys(l).keys()
dict_keys(['spam', 'eggs', 'bacon'])
>>> list(dict.fromkeys(l).keys())
['spam', 'eggs', 'bacon']
集合元素必须是可散列的。set类型不可散列,因此你不能用嵌套的set实例构建一个set。但是frozenset是可散列的,所以你可以在set中包含frozenset元素。
除了强制唯一性外,集合类型还实现了许多集合操作作为中缀运算符,因此,给定两个集合a和b,a | b返回它们的并集,a & b计算交集,a - b表示差集,a ^ b表示对称差。巧妙地使用集合操作可以减少 Python 程序的行数和执行时间,同时使代码更易于阅读和理解——通过消除循环和条件逻辑。
例如,想象一下你有一个大型的电子邮件地址集合(haystack)和一个较小的地址集合(needles),你需要计算needles在haystack中出现的次数。由于集合交集(&运算符),你可以用一行代码实现这个功能(参见示例 3-12)。
示例 3-12. 计算在一个集合中针的出现次数,两者都是集合类型
found = len(needles & haystack)
没有交集运算符,你将不得不编写示例 3-13 来完成与示例 3-12 相同的任务。
示例 3-13. 计算在一个集合中针的出现次数(与示例 3-12 的结果相同)
found = 0
for n in needles:
if n in haystack:
found += 1
示例 3-12 比示例 3-13 运行速度稍快。另一方面,示例 3-13 适用于任何可迭代对象needles和haystack,而示例 3-12 要求两者都是集合。但是,如果你手头没有集合,你可以随时动态构建它们,就像示例 3-14 中所示。
示例 3-14. 计算在一个集合中针的出现次数;这些行适用于任何可迭代类型
found = len(set(needles) & set(haystack))
# another way:
found = len(set(needles).intersection(haystack))
当然,在构建示例 3-14 中的集合时会有额外的成本,但如果needles或haystack中的一个已经是一个集合,那么示例 3-14 中的替代方案可能比示例 3-13 更便宜。
任何前述示例中的一个都能在haystack中搜索 1,000 个元素,其中包含 10,000,000 个项目,大约需要 0.3 毫秒,即每个元素接近 0.3 微秒。
除了极快的成员测试(由底层哈希表支持),set 和 frozenset 内置类型提供了丰富的 API 来创建新集合或在set的情况下更改现有集合。我们将很快讨论这些操作,但首先让我们谈谈语法。
集合字面量
set字面量的语法—{1},{1, 2}等—看起来与数学符号一样,但有一个重要的例外:没有空set的字面表示,因此我们必须记得写set()。
语法怪癖
不要忘记,要创建一个空的set,应该使用没有参数的构造函数:set()。如果写{},你将创建一个空的dict—在 Python 3 中这一点没有改变。
在 Python 3 中,集合的标准字符串表示总是使用{…}符号,除了空集:
>>> s = {1}
>>> type(s)
<class 'set'>
>>> s
{1}
>>> s.pop()
1
>>> s
set()
字面set语法如{1, 2, 3}比调用构造函数(例如,set([1, 2, 3]))更快且更易读。后一种形式较慢,因为要评估它,Python 必须查找set名称以获取构造函数,然后构建一个列表,最后将其传递给构造函数。相比之下,要处理像{1, 2, 3}这样的字面量,Python 运行一个专门的BUILD_SET字节码。¹⁰
没有特殊的语法来表示frozenset字面量—它们必须通过调用构造函数创建。在 Python 3 中的标准字符串表示看起来像一个frozenset构造函数调用。请注意控制台会话中的输出:
>>> frozenset(range(10))
frozenset({0, 1, 2, 3, 4, 5, 6, 7, 8, 9})
谈到语法,列表推导的想法也被用来构建集合。
集合推导式
集合推导式(setcomps)在 Python 2.7 中添加,与我们在“dict 推导式”中看到的 dictcomps 一起。示例 3-15 展示了如何。
示例 3-15. 构建一个拉丁-1 字符集,其中 Unicode 名称中包含“SIGN”一词
>>> from unicodedata import name # ①
>>> {chr(i) for i in range(32, 256) if 'SIGN' in name(chr(i),'')} # ②
{'§', '=', '¢', '#', '¤', '<', '¥', 'µ', '×', '$', '¶', '£', '©', '°', '+', '÷', '±', '>', '¬', '®', '%'}
①
从unicodedata导入name函数以获取字符名称。
②
构建字符集,其中字符代码从 32 到 255,名称中包含 'SIGN' 一词。
输出的顺序会因为“什么是可哈希的”中提到的盐哈希而对每个 Python 进程进行更改。
语法问题在一边,现在让我们考虑集合的行为。
集合工作方式的实际后果
set 和 frozenset 类型都是使用哈希表实现的。这会产生以下影响:
-
集合元素必须是可哈希对象。它们必须实现适当的
__hash__和__eq__方法,如“什么是可哈希的”中所述。 -
成员测试非常高效。一个集合可能有数百万个元素,但可以通过计算其哈希码并推导出索引偏移量来直接定位一个元素,可能需要少量尝试来找到匹配的元素或耗尽搜索。
-
与低级数组指针相比,集合具有显着的内存开销—后者更紧凑但搜索超过少量元素时也更慢。
-
元素顺序取决于插入顺序,但并不是以有用或可靠的方式。如果两个元素不同但具有相同的哈希码,则它们的位置取决于哪个元素先添加。
-
向集合添加元素可能会改变现有元素的顺序。这是因为如果哈希表超过三分之二满,算法会变得不那么高效,因此 Python 可能需要在增长时移动和调整表格。当发生这种情况时,元素将被重新插入,它们的相对顺序可能会改变。
详细信息请参见“集合和字典的内部”在fluentpython.com。
现在让我们来看看集合提供的丰富操作。
集合操作
| | | s.difference(it, …) | s 和从可迭代对象 it 构建的所有集合的差集 |
| S ⊆ Z | s <= z | s.__le__(z) | s 是 z 集合的子集 |
费曼学习法的灵感源于理查德·费曼,这位物理学诺贝尔奖得主。

| | | | |
| 数学符号 | Python 运算符 | 方法 | 描述 |
s.intersection(it, …) |
s 和从可迭代对象 it 构建的所有集合的交集 |
||
|---|---|---|---|
s -= z |
s.__isub__(z) |
s 更新为 s 和 z 的差集 |
|
| S \ Z | s - z |
s.__sub__(z) |
s 和 z 的相对补集或差集 |
z ^ s |
s.__rxor__(z) |
反转 ^ 运算符 |
|
s.difference_update(it, …) |
s 更新为 s 和从可迭代对象 it 构建的所有集合的差集 |
||
s &= z |
s.__iand__(z) |
s 更新为 s 和 z 的交集 |
|
s.union(it, …) |
s 和从可迭代对象 it 构建的所有集合的并集 |
||
图 3-2 概述了可变和不可变集合上可用的方法。其中许多是重载运算符的特殊方法,如 & 和 >=。表 3-2 显示了在 Python 中具有对应运算符或方法的数学集合运算符。请注意,一些运算符和方法会对目标集合进行就地更改(例如 &=,difference_update 等)。这样的操作在数学集合的理想世界中毫无意义,并且在 frozenset 中未实现。 |
|||
s.update(it, …) |
s 更新为 s 和从可迭代对象 it 构建的所有集合的并集 |
||
z & s |
s.__rand__(z) |
反转 & 运算符 |
|
| S ∆ Z | s ^ z |
s.__xor__(z) |
对称差集(s & z 的补集) |
| 表 3-2. 数学集合操作:这些方法要么生成新集合,要么在原地更新目标集合(如果可变) | |||
| S ∩ Z = ∅ | s.isdisjoint(z) |
s 和 z 互不相交(没有共同元素) |
|
s.symmetric_difference(it) |
s & set(it) 的补集 |
||
| S ∩ Z | s & z |
s.__and__(z) |
s 和 z 的交集 |
使用费曼的技巧,你可以在短短20 min内深入理解知识点,而且记忆深刻,难以遗忘。 |
|||
s ^= z |
s.__ixor__(z) |
s 更新为 s 和 z 的对称差集 |
|
| S ∪ Z | s | z |
s.__or__(z) |
s 和 z 的并集 |
| e ∈ S | e in s |
s.__contains__(e) |
元素 e 是 s 的成员 |
s.intersection_update(it, …) |
s 更新为 s 和从可迭代对象 it 构建的所有集合的交集 |
||
| 数学符号 | Python 运算符 | 方法 | 描述 |
| 表 3-3. 返回布尔值的集合比较运算符和方法 | |||
s.symmetric_difference_update(it, …) |
s 更新为 s 和从可迭代对象 it 构建的所有集合的对称差 |
提示
| | | | |
| 表 3-3 列出了集合谓词:返回 True 或 False 的运算符和方法。 |
| --- | --- | --- | --- |
|---|---|---|---|
图 3-2. MutableSet 及其来自 collections.abc 的超类的简化 UML 类图(斜体名称为抽象类和抽象方法;为简洁起见省略了反转运算符方法) |
|||
z | s |
s.__ror__(z) |
反转 | 运算符 |
|
z - s |
s.__rsub__(z) |
反转 - 运算符 |
|
s |= z |
s.__ior__(z) |
s 更新为 s 和 z 的并集 |
|
s.issubset(it) |
s 是从可迭代对象 it 构建的集合的子集 |
||
| S ⊂ Z | s < z |
s.__lt__(z) |
s 是 z 集合的真子集 |
| S ⊇ Z | s >= z |
s.__ge__(z) |
s 是 z 集合的超集 |
s.issuperset(it) |
s 是从可迭代对象 it 构建的集合的超集 |
||
| S ⊃ Z | s > z |
s.__gt__(z) |
s 是 z 集合的真超集 |
除了从数学集合理论中派生的运算符和方法外,集合类型还实现了其他实用的方法,总结在表 3-4 中。
表 3-4. 额外的集合方法
| 集合 | 冻结集合 | ||
|---|---|---|---|
s.add(e) |
● | 向 s 添加元素 e |
|
s.clear() |
● | 移除 s 的所有元素 |
|
s.copy() |
● | ● | s 的浅复制 |
s.discard(e) |
● | 如果存在则从 s 中移除元素 e |
|
s.__iter__() |
● | ● | 获取 s 的迭代器 |
s.__len__() |
● | ● | len(s) |
s.pop() |
● | 从 s 中移除并返回一个元素,如果 s 为空则引发 KeyError |
|
s.remove(e) |
● | 从 s 中移除元素 e,如果 e 不在 s 中则引发 KeyError |
这完成了我们对集合特性的概述。如“字典视图”中承诺的,我们现在将看到两种字典视图类型的行为非常类似于 frozenset。
字典视图上的集合操作
表 3-5 显示了由 dict 方法 .keys() 和 .items() 返回的视图对象与 frozenset 非常相似。
表 3-5. frozenset、dict_keys 和 dict_items 实现的方法
| 冻结集合 | dict_keys | dict_items | 描述 | |
|---|---|---|---|---|
s.__and__(z) |
● | ● | ● | s & z(s 和 z 的交集) |
s.__rand__(z) |
● | ● | ● | 反转 & 运算符 |
s.__contains__() |
● | ● | ● | e in s |
s.copy() |
● | s 的浅复制 |
||
s.difference(it, …) |
● | s 和可迭代对象 it 等的差集 |
||
s.intersection(it, …) |
● | s 和可迭代对象 it 等的交集 |
||
s.isdisjoint(z) |
● | ● | ● | s 和 z 不相交(没有共同元素) |
s.issubset(it) |
● | s 是可迭代对象 it 的子集 |
||
s.issuperset(it) |
● | s 是可迭代对象 it 的超集 |
||
s.__iter__() |
● | ● | ● | 获取 s 的迭代器 |
s.__len__() |
● | ● | ● | len(s) |
s.__or__(z) |
● | ● | ● | s | z(s 和 z 的并集) |
s.__ror__() |
● | ● | ● | 反转 | 运算符 |
s.__reversed__() |
● | ● | 获取 s 的反向迭代器 |
|
s.__rsub__(z) |
● | ● | ● | 反转 - 运算符 |
s.__sub__(z) |
● | ● | ● | s - z(s 和 z 之间的差集) |
s.symmetric_difference(it) |
● | s & set(it) 的补集 |
||
s.union(it, …) |
● | s 和可迭代对象 it 等的并集 |
||
s.__xor__() |
● | ● | ● | s ^ z(s 和 z 的对称差集) |
s.__rxor__() |
● | ● | ● | 反转 ^ 运算符 |
特别地,dict_keys 和 dict_items 实现了支持强大的集合运算符 &(交集)、|(并集)、-(差集)和 ^(对称差集)的特殊方法。
例如,使用 & 很容易获得出现在两个字典中的键:
>>> d1 = dict(a=1, b=2, c=3, d=4)
>>> d2 = dict(b=20, d=40, e=50)
>>> d1.keys() & d2.keys()
{'b', 'd'}
请注意 & 的返回值是一个 set。更好的是:字典视图中的集合运算符与 set 实例兼容。看看这个:
>>> s = {'a', 'e', 'i'}
>>> d1.keys() & s
{'a'}
>>> d1.keys() | s
{'a', 'c', 'b', 'd', 'i', 'e'}
警告
一个 dict_items 视图仅在字典中的所有值都是可哈希的情况下才能作为集合使用。尝试在具有不可哈希值的 dict_items 视图上进行集合操作会引发 TypeError: unhashable type 'T',其中 T 是有问题值的类型。
另一方面,dict_keys 视图始终可以用作集合,因为每个键都是可哈希的—按定义。
使用视图和集合运算符将节省大量循环和条件语句,当检查代码中字典内容时,让 Python 在 C 中高效实现为您工作!
就这样,我们可以结束这一章了。
章节总结
字典是 Python 的基石。多年来,熟悉的 {k1: v1, k2: v2} 文字语法得到了增强,支持使用 **、模式匹配以及 dict 推导式。
除了基本的 dict,标准库还提供了方便、即用即用的专用映射,如 defaultdict、ChainMap 和 Counter,都定义在 collections 模块中。随着新的 dict 实现,OrderedDict 不再像以前那样有用,但应该保留在标准库中以保持向后兼容性,并具有 dict 没有的特定特性,例如在 == 比较中考虑键的顺序。collections 模块中还有 UserDict,一个易于使用的基类,用于创建自定义映射。
大多数映射中可用的两个强大方法是 setdefault 和 update。setdefault 方法可以更新持有可变值的项目,例如在 list 值的 dict 中,避免为相同键进行第二次搜索。update 方法允许从任何其他映射、提供 (key, value) 对的可迭代对象以及关键字参数进行批量插入或覆盖项目。映射构造函数也在内部使用 update,允许实例从映射、可迭代对象或关键字参数初始化。自 Python 3.9 起,我们还可以使用 |= 运算符更新映射,使用 | 运算符从两个映射的并集创建一个新映射。
映射 API 中一个巧妙的钩子是 __missing__ 方法,它允许你自定义当使用 d[k] 语法(调用 __getitem__)时找不到键时发生的情况。
collections.abc 模块提供了 Mapping 和 MutableMapping 抽象基类作为标准接口,对于运行时类型检查非常有用。types 模块中的 MappingProxyType 创建了一个不可变的外观,用于保护不希望意外更改的映射。还有用于 Set 和 MutableSet 的抽象基类。
字典视图是 Python 3 中的一个重要补充,消除了 Python 2 中 .keys()、.values() 和 .items() 方法造成的内存开销,这些方法构建了重复数据的列表,复制了目标 dict 实例中的数据。此外,dict_keys 和 dict_items 类支持 frozenset 的最有用的运算符和方法。
进一步阅读
在 Python 标准库文档中,“collections—Container datatypes” 包括了几种映射类型的示例和实用配方。模块 Lib/collections/init.py 的 Python 源代码是任何想要创建新映射类型或理解现有映射逻辑的人的绝佳参考。David Beazley 和 Brian K. Jones 的 Python Cookbook, 3rd ed.(O’Reilly)第一章有 20 个方便而富有见地的数据结构配方,其中大部分使用 dict 以巧妙的方式。
Greg Gandenberger 主张继续使用 collections.OrderedDict,理由是“显式胜于隐式”,向后兼容性,以及一些工具和库假定 dict 键的顺序是无关紧要的。他的帖子:“Python Dictionaries Are Now Ordered. Keep Using OrderedDict”。
PEP 3106—Revamping dict.keys(), .values() and .items() 是 Guido van Rossum 为 Python 3 提出字典视图功能的地方。在摘要中,他写道这个想法来自于 Java 集合框架。
PyPy是第一个实现 Raymond Hettinger 提出的紧凑字典建议的 Python 解释器,他们在“PyPy 上更快、更节省内存和更有序的字典”中发表了博客,承认 PHP 7 中采用了类似的布局,描述在PHP 的新哈希表实现中。当创作者引用先前的作品时,总是很棒。
在 PyCon 2017 上,Brandon Rhodes 介绍了“字典更强大”,这是他经典动画演示“强大的字典”的续集——包括动画哈希冲突!另一部更加深入的关于 Python dict内部的视频是由 Raymond Hettinger 制作的“现代字典”,他讲述了最初未能向 CPython 核心开发人员推销紧凑字典的经历,他游说了 PyPy 团队,他们采纳了这个想法,这个想法得到了推广,并最终由 INADA Naoki 贡献给了 CPython 3.6,详情请查看Objects/dictobject.c中的 CPython 代码的详细注释和设计文档Objects/dictnotes.txt。
为了向 Python 添加集合的原因在PEP 218—添加内置集合对象类型中有记录。当 PEP 218 被批准时,没有采用特殊的文字语法来表示集合。set文字是为 Python 3 创建的,并与dict和set推导一起回溯到 Python 2.7。在 PyCon 2019 上,我介绍了“集合实践:从 Python 的集合类型中学习”,描述了实际程序中集合的用例,涵盖了它们的 API 设计以及使用位向量而不是哈希表的整数元素的集合类uintset的实现,灵感来自于 Alan Donovan 和 Brian Kernighan 的优秀著作The Go Programming Language第六章中的一个示例(Addison-Wesley)。
IEEE 的Spectrum杂志有一篇关于汉斯·彼得·卢恩的故事,他是一位多产的发明家,他申请了一项关于根据可用成分选择鸡尾酒配方的穿孔卡片盒的专利,以及其他包括…哈希表在内的多样化发明!请参阅“汉斯·彼得·卢恩和哈希算法的诞生”。
¹ 通过调用 ABC 的.register()方法注册的任何类都是虚拟子类,如“ABC 的虚拟子类”中所解释的。如果设置了特定的标记位,通过 Python/C API 实现的类型也是合格的。请参阅Py_TPFLAGS_MAPPING。
² Python 术语表中关于“可散列”的条目使用“哈希值”一词,而不是哈希码。我更喜欢哈希码,因为在映射的上下文中经常讨论这个概念,其中项由键和值组成,因此提到哈希码作为值可能会令人困惑。在本书中,我只使用哈希码。
³ 请参阅PEP 456—安全和可互换的哈希算法以了解安全性问题和采用的解决方案。
⁴ 原始脚本出现在 Martelli 的“重新学习 Python”演示的第 41 页中。他的脚本实际上是dict.setdefault的演示,如我们的示例 3-5 所示。
⁵ 这是将方法作为一等函数使用的示例,是第七章的主题。
⁶ 其中一个库是Pingo.io,目前已不再进行活跃开发。
⁷ 关于子类化dict和其他内置类型的确切问题在“子类化内置类型是棘手的”中有所涵盖。
⁸ 这就是元组的存储方式。
⁹ 除非类有一个__slots__属性,如“使用 slots 节省内存”中所解释的那样。
¹⁰ 这可能很有趣,但并不是非常重要。加速只会在评估集合字面值时发生,而这最多只会发生一次 Python 进程—当模块最初编译时。如果你好奇,可以从dis模块中导入dis函数,并使用它来反汇编set字面值的字节码—例如,dis('{1}')—和set调用—dis('set([1])')。
第四章:Unicode 文本与字节
人类使用文本。计算机使用字节。
Esther Nam 和 Travis Fischer,“Python 中的字符编码和 Unicode”¹
Python 3 引入了人类文本字符串和原始字节序列之间的明显区别。将字节序列隐式转换为 Unicode 文本已经成为过去。本章涉及 Unicode 字符串、二进制序列以及用于在它们之间转换的编码。
根据您在 Python 中的工作类型,您可能认为理解 Unicode 并不重要。这不太可能,但无论如何,无法避免str与byte之间的分歧。作为奖励,您会发现专门的二进制序列类型提供了 Python 2 通用str类型没有的功能。
在本章中,我们将讨论以下主题:
-
字符、代码点和字节表示
-
二进制序列的独特特性:
bytes、bytearray和memoryview -
完整 Unicode 和传统字符集的编码
-
避免和处理编码错误
-
处理文本文件时的最佳实践
-
默认编码陷阱和标准 I/O 问题
-
使用规范化进行安全的 Unicode 文本比较
-
用于规范化、大小写折叠和强制去除变音符号的实用函数
-
使用
locale和pyuca库正确对 Unicode 文本进行排序 -
Unicode 数据库中的字符元数据
-
处理
str和bytes的双模式 API
本章新内容
Python 3 中对 Unicode 的支持是全面且稳定的,因此最值得注意的新增内容是“按名称查找字符”,描述了一种用于搜索 Unicode 数据库的实用程序——这是从命令行查找带圈数字和微笑猫的好方法。
值得一提的一项较小更改是关于 Windows 上的 Unicode 支持,自 Python 3.6 以来更好且更简单,我们将在“注意编码默认值”中看到。
让我们从不那么新颖但基础的概念开始,即字符、代码点和字节。
注意
对于第二版,我扩展了关于struct模块的部分,并在fluentpython.com的伴随网站上发布了在线版本“使用 struct 解析二进制记录”。
在那里,您还会发现“构建多字符表情符号”,描述如何通过组合 Unicode 字符制作国旗、彩虹旗、不同肤色的人以及多样化家庭图标。
字符问题
“字符串”的概念足够简单:字符串是字符序列。问题在于“字符”的定义。
在 2021 年,我们对“字符”的最佳定义是 Unicode 字符。因此,我们从 Python 3 的str中获取的项目是 Unicode 字符,就像在 Python 2 中的unicode对象中获取的项目一样——而不是从 Python 2 的str中获取的原始字节。
Unicode 标准明确将字符的身份与特定字节表示分开:
-
字符的身份——其代码点——是从 0 到 1,114,111(十进制)的数字,在 Unicode 标准中显示为带有“U+”前缀的 4 到 6 位十六进制数字,从 U+0000 到 U+10FFFF。例如,字母 A 的代码点是 U+0041,欧元符号是 U+20AC,音乐符号 G 谱号分配给代码点 U+1D11E。在 Unicode 13.0.0 中,约 13%的有效代码点有字符分配给它们,这是 Python 3.10.0b4 中使用的标准。
-
表示字符的实际字节取决于正在使用的编码。编码是一种将代码点转换为字节序列及其反向转换的算法。字母 A(U+0041)的代码点在 UTF-8 编码中被编码为单个字节
\x41,在 UTF-16LE 编码中被编码为两个字节\x41\x00。另一个例子,UTF-8 需要三个字节—\xe2\x82\xac—来编码欧元符号(U+20AC),但在 UTF-16LE 中,相同的代码点被编码为两个字节:\xac\x20。
从代码点转换为字节是编码;从字节转换为代码点是解码。参见 示例 4-1。
示例 4-1. 编码和解码
>>> s = 'café'
>>> len(s) # ①
4
>>> b = s.encode('utf8') # ②
>>> b
b'caf\xc3\xa9' # ③
>>> len(b) # ④
5
>>> b.decode('utf8') # ⑤
'café'
①
str 'café' 有四个 Unicode 字符。
②
使用 UTF-8 编码将 str 编码为 bytes。
③
bytes 字面量有一个 b 前缀。
④
bytes b 有五个字节(“é”的代码点在 UTF-8 中编码为两个字节)。
⑤
使用 UTF-8 编码将 bytes 解码为 str。
提示
如果你需要一个记忆辅助来帮助区分 .decode() 和 .encode(),说服自己字节序列可以是晦涩的机器核心转储,而 Unicode str 对象是“人类”文本。因此,将 bytes 解码 为 str 以获取可读文本是有意义的,而将 str 编码 为 bytes 用于存储或传输也是有意义的。
尽管 Python 3 的 str 在很大程度上就是 Python 2 的 unicode 类型换了个新名字,但 Python 3 的 bytes 并不仅仅是旧的 str 更名,还有与之密切相关的 bytearray 类型。因此,在进入编码/解码问题之前,值得看一看二进制序列类型。
字节要点
新的二进制序列类型在许多方面与 Python 2 的 str 不同。首先要知道的是,有两种基本的内置二进制序列类型:Python 3 中引入的不可变 bytes 类型和早在 Python 2.6 中添加的可变 bytearray。² Python 文档有时使用通用术语“字节字符串”来指代 bytes 和 bytearray。我避免使用这个令人困惑的术语。
bytes 或 bytearray 中的每个项都是从 0 到 255 的整数,而不是像 Python 2 的 str 中的单个字符字符串。然而,二进制序列的切片始终产生相同类型的二进制序列,包括长度为 1 的切片。参见 示例 4-2。
示例 4-2. 作为 bytes 和 bytearray 的五字节序列
>>> cafe = bytes('café', encoding='utf_8') # ①
>>> cafe
b'caf\xc3\xa9' >>> cafe[0] # ②
99 >>> cafe[:1] # ③
b'c' >>> cafe_arr = bytearray(cafe)
>>> cafe_arr # ④
bytearray(b'caf\xc3\xa9') >>> cafe_arr[-1:] # ⑤
bytearray(b'\xa9')
①
可以从 str 构建 bytes,并给定一个编码。
②
每个项都是 range(256) 中的整数。
③
bytes 的切片也是 bytes ——即使是单个字节的切片。
④
bytearray 没有字面量语法:它们显示为带有 bytes 字面量作为参数的 bytearray()。
⑤
bytearray 的切片也是 bytearray。
警告
my_bytes[0] 检索一个 int,但 my_bytes[:1] 返回长度为 1 的 bytes 序列,这只是因为我们习惯于 Python 的 str 类型,其中 s[0] == s[:1]。对于 Python 中的所有其他序列类型,1 项不等于长度为 1 的切片。
尽管二进制序列实际上是整数序列,但它们的字面值表示反映了 ASCII 文本经常嵌入其中的事实。因此,根据每个字节值的不同,使用四种不同的显示方式:
-
对于十进制代码为 32 到 126 的字节——从空格到
~(波浪号)——使用 ASCII 字符本身。 -
对于制表符、换行符、回车符和
\对应的字节,使用转义序列\t、\n、\r和\\。 -
如果字节序列中同时出现字符串定界符
'和",则整个序列由'定界,并且任何'都会被转义为\'。³ -
对于其他字节值,使用十六进制转义序列(例如,
\x00是空字节)。
这就是为什么在 示例 4-2 中你会看到 b'caf\xc3\xa9':前三个字节 b'caf' 在可打印的 ASCII 范围内,而最后两个不在范围内。
bytes和bytearray都支持除了依赖于 Unicode 数据的格式化方法(format,format_map)和那些依赖于 Unicode 数据的方法(包括casefold,isdecimal,isidentifier,isnumeric,isprintable和encode)之外的所有str方法。这意味着您可以使用熟悉的字符串方法,如endswith,replace,strip,translate,upper等,与二进制序列一起使用——只使用bytes而不是str参数。此外,如果正则表达式是从二进制序列而不是str编译而成,则re模块中的正则表达式函数也适用于二进制序列。自 Python 3.5 以来,%运算符再次适用于二进制序列。⁴
二进制序列有一个str没有的类方法,称为fromhex,它通过解析以空格分隔的十六进制数字对构建二进制序列:
>>> bytes.fromhex('31 4B CE A9')
b'1K\xce\xa9'
构建bytes或bytearray实例的其他方法是使用它们的构造函数,并提供:
-
一个
str和一个encoding关键字参数 -
一个可提供值从 0 到 255 的项目的可迭代对象
-
一个实现缓冲区协议的对象(例如,
bytes,bytearray,memoryview,array.array),它将源对象的字节复制到新创建的二进制序列中
警告
直到 Python 3.5,还可以使用单个整数调用bytes或bytearray来创建一个以空字节初始化的该大小的二进制序列。这个签名在 Python 3.5 中被弃用,并在 Python 3.6 中被移除。请参阅PEP 467—二进制序列的次要 API 改进。
从类似缓冲区的对象构建二进制序列是一个涉及类型转换的低级操作。在示例 4-3 中看到演示。
示例 4-3。从数组的原始数据初始化字节
>>> import array
>>> numbers = array.array('h', [-2, -1, 0, 1, 2]) # ①
>>> octets = bytes(numbers) # ②
>>> octets
b'\xfe\xff\xff\xff\x00\x00\x01\x00\x02\x00' # ③
①
类型码'h'创建一个短整数(16 位)的array。
②
octets保存构成numbers的字节的副本。
③
这是代表 5 个短整数的 10 个字节。
从任何类似缓冲区的源创建bytes或bytearray对象将始终复制字节。相反,memoryview对象允许您在二进制数据结构之间共享内存,正如我们在“内存视图”中看到的那样。
在这对 Python 中二进制序列类型的基本探索之后,让我们看看它们如何转换为/从字符串。
基本编码器/解码器
Python 发行版捆绑了 100 多个编解码器(编码器/解码器),用于文本到字节的转换以及反之。每个编解码器都有一个名称,如'utf_8',通常还有别名,如'utf8','utf-8'和'U8',您可以将其用作函数中的encoding参数,如open(),str.encode(),bytes.decode()等。示例 4-4 展示了相同文本编码为三种不同的字节序列。
示例 4-4。使用三种编解码器对字符串“El Niño”进行编码,生成非常不同的字节序列
>>> for codec in ['latin_1', 'utf_8', 'utf_16']:
... print(codec, 'El Niño'.encode(codec), sep='\t')
...
latin_1 b'El Ni\xf1o'
utf_8 b'El Ni\xc3\xb1o'
utf_16 b'\xff\xfeE\x00l\x00 \x00N\x00i\x00\xf1\x00o\x00'
图 4-1 展示了各种编解码器从字符(如字母“A”到 G 大调音符)生成字节的情况。请注意,最后三种编码是可变长度的多字节编码。

图 4-1。十二个字符,它们的代码点以及它们在 7 种不同编码中的字节表示(星号表示该字符无法在该编码中表示)。
图 4-1 中所有那些星号清楚地表明,一些编码,如 ASCII 甚至多字节 GB2312,无法表示每个 Unicode 字符。然而,UTF 编码被设计用于处理每个 Unicode 代码点。
在图 4-1 中显示的编码被选为代表性样本:
latin1又称iso8859_1
重要,因为它是其他编码的基础,例如cp1252和 Unicode 本身(注意latin1字节值如何出现在cp1252字节和代码点中)。
cp1252
由 Microsoft 创建的有用的latin1超集,添加了诸如弯引号和€(欧元)等有用符号;一些 Windows 应用程序称其为“ANSI”,但它从未是真正的 ANSI 标准。
cp437
IBM PC 的原始字符集,带有绘制框线字符。与latin1不兼容,后者出现得更晚。
gb2312
用于编码中国大陆使用的简体中文汉字的传统标准;亚洲语言的几种广泛部署的多字节编码之一。
utf-8
截至 2021 年 7 月,网络上最常见的 8 位编码远远是 UTF-8,“W³Techs:网站字符编码使用统计”声称 97% 的网站使用 UTF-8,这比我在 2014 年 9 月第一版书中写这段话时的 81.4% 要高。
utf-16le
UTF 16 位编码方案的一种形式;所有 UTF-16 编码通过称为“代理对”的转义序列支持 U+FFFF 之上的代码点。
警告
UTF-16 在 1996 年取代了原始的 16 位 Unicode 1.0 编码——UCS-2。尽管 UCS-2 自上个世纪以来已被弃用,但仍在许多系统中使用,因为它仅支持到 U+FFFF 的代码点。截至 2021 年,超过 57% 的分配代码点在 U+FFFF 以上,包括所有重要的表情符号。
现在完成了对常见编码的概述,我们将转向处理编码和解码操作中的问题。
理解编码/解码问题
尽管存在一个通用的UnicodeError异常,Python 报告的错误通常更具体:要么是UnicodeEncodeError(将str转换为二进制序列时),要么是UnicodeDecodeError(将二进制序列读入str时)。加载 Python 模块时,如果源编码意外,则还可能引发SyntaxError。我们将在接下来的部分展示如何处理所有这些错误。
提示
当遇到 Unicode 错误时,首先要注意异常的确切类型。它是UnicodeEncodeError、UnicodeDecodeError,还是提到编码问题的其他错误(例如SyntaxError)?要解决问题,首先必须理解它。
处理 UnicodeEncodeError
大多数非 UTF 编解码器仅处理 Unicode 字符的一小部分。将文本转换为字节时,如果目标编码中未定义字符,则会引发UnicodeEncodeError,除非通过向编码方法或函数传递errors参数提供了特殊处理。错误处理程序的行为显示在示例 4-5 中。
示例 4-5. 编码为字节:成功和错误处理
>>> city = 'São Paulo'
>>> city.encode('utf_8') # ①
b'S\xc3\xa3o Paulo' >>> city.encode('utf_16')
b'\xff\xfeS\x00\xe3\x00o\x00 \x00P\x00a\x00u\x00l\x00o\x00' >>> city.encode('iso8859_1') # ②
b'S\xe3o Paulo' >>> city.encode('cp437') # ③
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/.../lib/python3.4/encodings/cp437.py", line 12, in encode
return codecs.charmap_encode(input,errors,encoding_map)
UnicodeEncodeError: 'charmap' codec can't encode character '\xe3' in
position 1: character maps to <undefined> >>> city.encode('cp437', errors='ignore') # ④
b'So Paulo' >>> city.encode('cp437', errors='replace') # ⑤
b'S?o Paulo' >>> city.encode('cp437', errors='xmlcharrefreplace') # ⑥
b'São Paulo'
①
UTF 编码处理任何str。
②
iso8859_1也适用于'São Paulo'字符串。
③
cp437 无法编码'ã'(带有波浪符号的“a”)。默认错误处理程序'strict'会引发UnicodeEncodeError。
④
error='ignore'处理程序跳过无法编码的字符;这通常是一个非常糟糕的主意,会导致数据悄悄丢失。
⑤
在编码时,error='replace'用'?'替换无法编码的字符;数据也会丢失,但用户会得到提示有问题的线索。
⑥
'xmlcharrefreplace'用 XML 实体替换无法编码的字符。如果不能使用 UTF,也不能承受数据丢失,这是唯一的选择。
注意
codecs错误处理是可扩展的。您可以通过向codecs.register_error函数传递名称和错误处理函数来为errors参数注册额外的字符串。请参阅the codecs.register_error documentation。
ASCII 是我所知的所有编码的一个常见子集,因此如果文本完全由 ASCII 字符组成,编码应该总是有效的。Python 3.7 添加了一个新的布尔方法str.isascii()来检查您的 Unicode 文本是否是 100% 纯 ASCII。如果是,您应该能够在任何编码中将其编码为字节,而不会引发UnicodeEncodeError。
处理 UnicodeDecodeError
并非每个字节都包含有效的 ASCII 字符,并非每个字节序列都是有效的 UTF-8 或 UTF-16;因此,当您在将二进制序列转换为文本时假定其中一个编码时,如果发现意外字节,则会收到UnicodeDecodeError。
另一方面,许多传统的 8 位编码,如'cp1252'、'iso8859_1'和'koi8_r',能够解码任何字节流,包括随机噪音,而不报告错误。因此,如果您的程序假定了错误的 8 位编码,它将悄悄地解码垃圾数据。
提示
乱码字符被称为 gremlins 或 mojibake(文字化け—日语中的“转换文本”)。
Example 4-6 说明了使用错误的编解码器可能会产生乱码或UnicodeDecodeError。
示例 4-6. 从str解码为字节:成功和错误处理
>>> octets = b'Montr\xe9al' # ①
>>> octets.decode('cp1252') # ②
'Montréal' >>> octets.decode('iso8859_7') # ③
'Montrιal' >>> octets.decode('koi8_r') # ④
'MontrИal' >>> octets.decode('utf_8') # ⑤
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 5:
invalid continuation byte >>> octets.decode('utf_8', errors='replace') # ⑥
'Montr�al'
①
编码为latin1的单词“Montréal”;'\xe9'是“é”的字节。
②
使用 Windows 1252 解码有效,因为它是latin1的超集。
③
ISO-8859-7 用于希腊语,因此'\xe9'字节被错误解释,不会发出错误。
④
KOI8-R 用于俄语。现在'\xe9'代表西里尔字母“И”。
⑤
'utf_8'编解码器检测到octets不是有效的 UTF-8,并引发UnicodeDecodeError。
⑥
使用'replace'错误处理,\xe9会被“�”(代码点 U+FFFD)替换,这是官方的 Unicode REPLACEMENT CHARACTER,用于表示未知字符。
加载具有意外编码的模块时出现 SyntaxError
UTF-8 是 Python 3 的默认源编码,就像 ASCII 是 Python 2 的默认编码一样。如果加载包含非 UTF-8 数据且没有编码声明的 .py 模块,则会收到如下消息:
SyntaxError: Non-UTF-8 code starting with '\xe1' in file ola.py on line
1, but no encoding declared; see https://python.org/dev/peps/pep-0263/
for details
由于 UTF-8 在 GNU/Linux 和 macOS 系统中被广泛部署,一个可能的情况是在 Windows 上用cp1252打开一个 .py 文件。请注意,即使在 Windows 的 Python 中,这种错误也会发生,因为 Python 3 源代码在所有平台上的默认编码都是 UTF-8。
要解决这个问题,在文件顶部添加一个魔术coding注释,如 Example 4-7 所示。
示例 4-7. ola.py:葡萄牙语中的“Hello, World!”
# coding: cp1252
print('Olá, Mundo!')
提示
现在 Python 3 源代码不再限于 ASCII,并且默认使用优秀的 UTF-8 编码,因此对于像'cp1252'这样的遗留编码的源代码,最好的“修复”方法是将它们转换为 UTF-8,并且不再使用coding注释。如果您的编辑器不支持 UTF-8,那么是时候换一个了。
假设您有一个文本文件,无论是源代码还是诗歌,但您不知道其编码。如何检测实际的编码?答案在下一节中。
如何发现字节序列的编码
如何找到字节序列的编码?简短回答:你无法。你必须被告知。
一些通信协议和文件格式,比如 HTTP 和 XML,包含明确告诉我们内容如何编码的头部。你可以确定一些字节流不是 ASCII,因为它们包含超过 127 的字节值,而 UTF-8 和 UTF-16 的构建方式也限制了可能的字节序列。
然而,考虑到人类语言也有其规则和限制,一旦假定一系列字节是人类纯文本,可能可以通过启发式和统计方法来嗅探其编码。例如,如果b'\x00'字节很常见,那么它可能是 16 位或 32 位编码,而不是 8 位方案,因为纯文本中的空字符是错误的。当字节序列b'\x20\x00'经常出现时,更可能是 UTF-16LE 编码中的空格字符(U+0020),而不是晦涩的 U+2000 EN QUAD字符—不管那是什么。
这就是包“Chardet—通用字符编码检测器”是如何工作的,猜测其中的一个支持的 30 多种编码。Chardet是一个你可以在程序中使用的 Python 库,但也包括一个命令行实用程序,chardetect。这是它在本章源文件上报告的内容:
$ chardetect 04-text-byte.asciidoc
04-text-byte.asciidoc: utf-8 with confidence 0.99
尽管编码文本的二进制序列通常不包含其编码的明确提示,但 UTF 格式可能在文本内容前面添加字节顺序标记。接下来将对此进行解释。
BOM:一个有用的小精灵
在示例 4-4 中,你可能已经注意到 UTF-16 编码序列开头有一对额外的字节。这里再次展示:
>>> u16 = 'El Niño'.encode('utf_16')
>>> u16
b'\xff\xfeE\x00l\x00 \x00N\x00i\x00\xf1\x00o\x00'
这些字节是b'\xff\xfe'。这是一个BOM—字节顺序标记—表示进行编码的 Intel CPU 的“小端”字节顺序。
在小端机器上,对于每个代码点,最低有效字节先出现:字母'E',代码点 U+0045(十进制 69),在字节偏移 2 和 3 中编码为69和0:
>>> list(u16)
[255, 254, 69, 0, 108, 0, 32, 0, 78, 0, 105, 0, 241, 0, 111, 0]
在大端 CPU 上,编码会被颠倒;'E'会被编码为0和69。
为了避免混淆,UTF-16 编码在要编码的文本前面加上特殊的不可见字符零宽不换行空格(U+FEFF)。在小端系统上,它被编码为b'\xff\xfe'(十进制 255,254)。因为按设计,Unicode 中没有 U+FFFE 字符,字节序列b'\xff\xfe'必须表示小端编码中的零宽不换行空格,所以编解码器知道要使用哪种字节顺序。
有一种 UTF-16 的变体——UTF-16LE,明确是小端的,另一种是明确是大端的,UTF-16BE。如果使用它们,就不会生成 BOM:
>>> u16le = 'El Niño'.encode('utf_16le')
>>> list(u16le)
[69, 0, 108, 0, 32, 0, 78, 0, 105, 0, 241, 0, 111, 0]
>>> u16be = 'El Niño'.encode('utf_16be')
>>> list(u16be)
[0, 69, 0, 108, 0, 32, 0, 78, 0, 105, 0, 241, 0, 111]
如果存在 BOM,应该由 UTF-16 编解码器过滤,这样你只会得到文件的实际文本内容,而不包括前导的零宽不换行空格。Unicode 标准规定,如果一个文件是 UTF-16 且没有 BOM,应该假定为 UTF-16BE(大端)。然而,Intel x86 架构是小端的,因此在实际中有很多没有 BOM 的小端 UTF-16。
这整个字节序问题只影响使用多字节的编码,比如 UTF-16 和 UTF-32。UTF-8 的一个重要优势是,无论机器的字节序如何,它都会产生相同的字节序列,因此不需要 BOM。然而,一些 Windows 应用程序(特别是记事本)仍然会向 UTF-8 文件添加 BOM—Excel 依赖 BOM 来检测 UTF-8 文件,否则它会假定内容是用 Windows 代码页编码的。Python 的编解码器注册表中称带有 BOM 的 UTF-8 编码为 UTF-8-SIG。UTF-8-SIG 中编码的字符 U+FEFF 是三字节序列b'\xef\xbb\xbf'。因此,如果一个文件以这三个字节开头,很可能是带有 BOM 的 UTF-8 文件。
Caleb 关于 UTF-8-SIG 的提示
技术审查员之一 Caleb Hattingh 建议在读取 UTF-8 文件时始终使用 UTF-8-SIG 编解码器。这是无害的,因为 UTF-8-SIG 可以正确读取带或不带 BOM 的文件,并且不会返回 BOM 本身。在写入时,我建议为了一般的互操作性使用 UTF-8。例如,如果 Python 脚本以 #!/usr/bin/env python3 开头,可以在 Unix 系统中使其可执行。文件的前两个字节必须是 b'#!' 才能正常工作,但 BOM 打破了这个约定。如果有特定要求需要将数据导出到需要 BOM 的应用程序中,请使用 UTF-8-SIG,但请注意 Python 的 编解码器文档 表示:“在 UTF-8 中,不鼓励使用 BOM,通常应避免使用。”
现在我们转向在 Python 3 中处理文本文件。
处理文本文件
处理文本 I/O 的最佳实践是“Unicode 三明治”(图 4-2)。⁵ 这意味着 bytes 应尽早解码为 str(例如,在打开文件进行读取时)。三明治的“馅料”是程序的业务逻辑,在这里文本处理完全在 str 对象上进行。您永远不应该在其他处理过程中进行编码或解码。在输出时,str 应尽可能晚地编码为 bytes。大多数 Web 框架都是这样工作的,当使用它们时我们很少接触 bytes。例如,在 Django 中,您的视图应输出 Unicode str;Django 本身负责将响应编码为 bytes,默认使用 UTF-8。
Python 3 更容易遵循 Unicode 三明治的建议,因为内置的 open() 在读取和写入文本模式文件时进行必要的解码和编码,因此从 my_file.read() 获取的内容并传递给 my_file.write(text) 的都是 str 对象。
因此,使用文本文件似乎很简单。但是,如果依赖默认编码,您将受到影响。

图 4-2. Unicode 三明治:文本处理的当前最佳实践。
考虑 示例 4-8 中的控制台会话。您能发现 bug 吗?
示例 4-8. 平台编码问题(如果您在自己的计算机上尝试此操作,可能会看到问题,也可能不会)
>>> open('cafe.txt', 'w', encoding='utf_8').write('café')
4
>>> open('cafe.txt').read()
'café'
Bug:我在写入文件时指定了 UTF-8 编码,但在读取文件时未这样做,因此 Python 假定 Windows 默认文件编码为代码页 1252,并且文件中的尾随字节被解码为字符 'é' 而不是 'é'。
我在 Windows 10(版本 18363)上运行了 Python 3.8.1 64 位上的 示例 4-8。在最近的 GNU/Linux 或 macOS 上运行相同的语句完全正常,因为它们的默认编码是 UTF-8,给人一种一切正常的假象。如果在打开文件进行写入时省略了编码参数,将使用区域设置的默认编码,我们将使用相同的编码正确读取文件。但是,这个脚本将根据平台或甚至相同平台中的区域设置生成具有不同字节内容的文件,从而创建兼容性问题。
提示
必须在多台机器上运行或在多个场合上运行的代码绝不能依赖于编码默认值。在打开文本文件时始终传递显式的 encoding= 参数,因为默认值可能会从一台机器变为另一台机器,或者从一天变为另一天。
示例 4-8 中一个有趣的细节是,第一条语句中的 write 函数报告写入了四个字符,但在下一行读取了五个字符。示例 4-9 是 示例 4-8 的扩展版本,解释了这个问题和其他细节。
示例 4-9. 仔细检查在 Windows 上运行的 示例 4-8 中的 bug 以及如何修复它
>>> fp = open('cafe.txt', 'w', encoding='utf_8')
>>> fp # ①
<_io.TextIOWrapper name='cafe.txt' mode='w' encoding='utf_8'> >>> fp.write('café') # ②
4 >>> fp.close()
>>> import os
>>> os.stat('cafe.txt').st_size # ③
5 >>> fp2 = open('cafe.txt')
>>> fp2 # ④
<_io.TextIOWrapper name='cafe.txt' mode='r' encoding='cp1252'> >>> fp2.encoding # ⑤
'cp1252' >>> fp2.read() # ⑥
'café' >>> fp3 = open('cafe.txt', encoding='utf_8') # ⑦
>>> fp3
<_io.TextIOWrapper name='cafe.txt' mode='r' encoding='utf_8'> >>> fp3.read() # ⑧
'café' >>> fp4 = open('cafe.txt', 'rb') # ⑨
>>> fp4 # ⑩
<_io.BufferedReader name='cafe.txt'> >>> fp4.read() ⑪
b'caf\xc3\xa9'
①
默认情况下,open使用文本模式并返回一个具有特定编码的TextIOWrapper对象。
②
TextIOWrapper上的write方法返回写入的 Unicode 字符数。
③
os.stat显示文件有 5 个字节;UTF-8 将'é'编码为 2 个字节,0xc3 和 0xa9。
④
打开一个没有明确编码的文本文件会返回一个TextIOWrapper,其编码设置为来自区域设置的默认值。
⑤
TextIOWrapper对象有一个编码属性,可以进行检查:在这种情况下是cp1252。
⑥
在 Windows 的cp1252编码中,字节 0xc3 是“Ô(带波浪符的 A),0xa9 是版权符号。
⑦
使用正确的编码打开相同的文件。
⑧
预期结果:对于'café'相同的四个 Unicode 字符。
⑨
'rb'标志以二进制模式打开文件进行读取。
⑩
返回的对象是BufferedReader而不是TextIOWrapper。
⑪
读取返回的是字节,符合预期。
提示
除非需要分析文件内容以确定编码,否则不要以二进制模式打开文本文件——即使这样,你应该使用 Chardet 而不是重复造轮子(参见“如何发现字节序列的编码”)。普通代码应该只使用二进制模式打开二进制文件,如光栅图像。
Example 4-9 中的问题涉及依赖默认设置打开文本文件。如下一节所示,有几个来源可以提供这些默认值。
警惕编码默认值
几个设置影响 Python 中 I/O 的编码默认值。查看 Example 4-10 中的default_encodings.py脚本。
Example 4-10. 探索编码默认值
import locale
import sys
expressions = """
locale.getpreferredencoding()
type(my_file)
my_file.encoding
sys.stdout.isatty()
sys.stdout.encoding
sys.stdin.isatty()
sys.stdin.encoding
sys.stderr.isatty()
sys.stderr.encoding
sys.getdefaultencoding()
sys.getfilesystemencoding()
"""
my_file = open('dummy', 'w')
for expression in expressions.split():
value = eval(expression)
print(f'{expression:>30} -> {value!r}')
Example 4-10 在 GNU/Linux(Ubuntu 14.04 至 19.10)和 macOS(10.9 至 10.14)上的输出是相同的,显示UTF-8在这些系统中随处可用:
$ python3 default_encodings.py
locale.getpreferredencoding() -> 'UTF-8'
type(my_file) -> <class '_io.TextIOWrapper'>
my_file.encoding -> 'UTF-8'
sys.stdout.isatty() -> True
sys.stdout.encoding -> 'utf-8'
sys.stdin.isatty() -> True
sys.stdin.encoding -> 'utf-8'
sys.stderr.isatty() -> True
sys.stderr.encoding -> 'utf-8'
sys.getdefaultencoding() -> 'utf-8'
sys.getfilesystemencoding() -> 'utf-8'
然而,在 Windows 上,输出是 Example 4-11。
Example 4-11. Windows 10 PowerShell 上的默认编码(在 cmd.exe 上输出相同)
> chcp # ①
Active code page: 437
> python default_encodings.py # ②
locale.getpreferredencoding() -> 'cp1252' # ③
type(my_file) -> <class '_io.TextIOWrapper'>
my_file.encoding -> 'cp1252' # ④
sys.stdout.isatty() -> True # ⑤
sys.stdout.encoding -> 'utf-8' # ⑥
sys.stdin.isatty() -> True
sys.stdin.encoding -> 'utf-8'
sys.stderr.isatty() -> True
sys.stderr.encoding -> 'utf-8'
sys.getdefaultencoding() -> 'utf-8'
sys.getfilesystemencoding() -> 'utf-8'
①
chcp显示控制台的活动代码页为437。
②
运行default_encodings.py并输出到控制台。
③
locale.getpreferredencoding()是最重要的设置。
④
文本文件默认使用locale.getpreferredencoding()。
⑤
输出将发送到控制台,因此sys.stdout.isatty()为True。
⑥
现在,sys.stdout.encoding与chcp报告的控制台代码页不同!
Windows 本身以及 Python 针对 Windows 的 Unicode 支持在我写这本书的第一版之后变得更好了。示例 4-11 曾经在 Windows 7 上的 Python 3.4 中报告了四种不同的编码。stdout、stdin和stderr的编码曾经与chcp命令报告的活动代码页相同,但现在由于 Python 3.6 中实现的PEP 528—将 Windows 控制台编码更改为 UTF-8,以及cmd.exe中的 PowerShell 中的 Unicode 支持(自 2018 年 10 月的 Windows 1809 起)。⁶ 当stdout写入控制台时,chcp和sys.stdout.encoding说不同的事情是很奇怪的,但现在我们可以在 Windows 上打印 Unicode 字符串而不会出现编码错误——除非用户将输出重定向到文件,正如我们很快将看到的。这并不意味着所有你喜欢的表情符号都会出现在控制台中:这也取决于控制台使用的字体。
另一个变化是PEP 529—将 Windows 文件系统编码更改为 UTF-8,也在 Python 3.6 中实现,将文件系统编码(用于表示目录和文件名称)从微软专有的 MBCS 更改为 UTF-8。
然而,如果示例 4-10 的输出被重定向到文件,就像这样:
Z:\>python default_encodings.py > encodings.log
然后,sys.stdout.isatty()的值变为False,sys.stdout.encoding由locale.getpreferredencoding()设置,在该机器上为'cp1252'—但sys.stdin.encoding和sys.stderr.encoding仍然为utf-8。
提示
在示例 4-12 中,我使用'\N{}'转义来表示 Unicode 文字,其中我们在\N{}内写入字符的官方名称。这样做相当冗长,但明确且安全:如果名称不存在,Python 会引发SyntaxError——比起写一个可能错误的十六进制数,这样做要好得多,但你只能在很久以后才会发现。你可能想要写一个解释字符代码的注释,所以\N{}的冗长是容易接受的。
这意味着像示例 4-12 这样的脚本在打印到控制台时可以正常工作,但在输出被重定向到文件时可能会出现问题。
示例 4-12. stdout_check.py
import sys
from unicodedata import name
print(sys.version)
print()
print('sys.stdout.isatty():', sys.stdout.isatty())
print('sys.stdout.encoding:', sys.stdout.encoding)
print()
test_chars = [
'\N{HORIZONTAL ELLIPSIS}', # exists in cp1252, not in cp437
'\N{INFINITY}', # exists in cp437, not in cp1252
'\N{CIRCLED NUMBER FORTY TWO}', # not in cp437 or in cp1252
]
for char in test_chars:
print(f'Trying to output {name(char)}:')
print(char)
示例 4-12 显示了sys.stdout.isatty()的结果,sys.stdout.encoding的值,以及这三个字符:
-
'…'HORIZONTAL ELLIPSIS—存在于 CP 1252 中,但不存在于 CP 437 中。 -
'∞'INFINITY—存在于 CP 437 中,但不存在于 CP 1252 中。 -
'㊷'CIRCLED NUMBER FORTY TWO—在 CP 1252 或 CP 437 中不存在。
当我在 PowerShell 或cmd.exe上运行stdout_check.py时,它的运行情况如图 4-3 所示。

图 4-3. 在 PowerShell 上运行stdout_check.py。
尽管chcp报告活动代码为 437,但sys.stdout.encoding为 UTF-8,因此HORIZONTAL ELLIPSIS和INFINITY都能正确输出。CIRCLED NUMBER FORTY TWO被一个矩形替换,但不会引发错误。可能它被识别为有效字符,但控制台字体没有显示它的字形。
然而,当我将stdout_check.py的输出重定向到文件时,我得到了图 4-4。

图 4-4. 在 PowerShell 上运行stdout_check.py,重定向输出。
图 4-4 展示的第一个问题是UnicodeEncodeError,提到字符'\u221e',因为sys.stdout.encoding是'cp1252'—一个不包含INFINITY字符的代码页。
使用type命令读取out.txt,或者使用 Windows 编辑器如 VS Code 或 Sublime Text,显示的不是水平省略号,而是'à'(带重音的拉丁小写字母 A)。事实证明,在 CP 1252 中,字节值 0x85 表示'…',但在 CP 437 中,相同的字节值代表'à'。因此,似乎活动代码页确实很重要,但并不是以明智或有用的方式,而是作为糟糕的 Unicode 经历的部分解释。
注意
我使用配置为美国市场的笔记本电脑,运行 Windows 10 OEM 来运行这些实验。为其他国家本地化的 Windows 版本可能具有不同的编码配置。例如,在巴西,Windows 控制台默认使用代码页 850,而不是 437。
为了总结这个令人疯狂的默认编码问题,让我们最后看一下示例 4-11 中的不同编码:
-
如果在打开文件时省略
encoding参数,则默认值由locale.getpreferredencoding()给出(在示例 4-11 中为'cp1252')。 -
在 Python 3.6 之前,
sys.stdout|stdin|stderr的编码是由PYTHONIOENCODING环境变量设置的,现在该变量被忽略,除非PYTHONLEGACYWINDOWSSTDIO设置为非空字符串。否则,标准 I/O 的编码对于交互式 I/O 是 UTF-8,或者如果输出/输入被重定向到/从文件,则由locale.getpreferredencoding()定义。 -
sys.getdefaultencoding()在 Python 中用于二进制数据与str之间的隐式转换。不支持更改此设置。 -
sys.getfilesystemencoding()用于对文件名进行编码/解码(而不是文件内容)。当open()以str参数作为文件名时使用它;如果文件名以bytes参数给出,则不做更改地传递给操作系统 API。
注意
在 GNU/Linux 和 macOS 上,默认情况下,所有这些编码都设置为 UTF-8,已经有好几年了,因此 I/O 处理所有 Unicode 字符。在 Windows 上,不仅在同一系统中使用不同的编码,而且通常是像'cp850'或'cp1252'这样只支持 ASCII 的代码页,还有 127 个额外字符,这些字符在不同编码之间并不相同。因此,Windows 用户更有可能遇到编码错误,除非他们特别小心。
总结一下,最重要的编码设置是由locale.getpreferredencoding()返回的:它是打开文本文件和当sys.stdout/stdin/stderr被重定向到文件时的默认值。然而,文档部分内容如下:
locale.getpreferredencoding(do_setlocale=True)根据用户偏好返回用于文本数据的编码。用户偏好在不同系统上表达方式不同,有些系统可能无法以编程方式获取,因此此函数只返回一个猜测。[…]
因此,关于编码默认值的最佳建议是:不要依赖于它们。
如果您遵循 Unicode 三明治的建议并始终明确指定程序中的编码,您将避免很多痛苦。不幸的是,即使您将您的bytes正确转换为str,Unicode 也是令人头痛的。接下来的两节涵盖了在 ASCII 领域简单的主题,在 Unicode 行星上变得非常复杂的文本规范化(即将文本转换为用于比较的统一表示)和排序。
为了可靠比较而规范化 Unicode
字符串比较变得复杂的原因在于 Unicode 具有组合字符:附加到前一个字符的变音符号和其他标记,在打印时会显示为一个字符。
例如,单词“café”可以用四个或五个代码点组成,但结果看起来完全相同:
>>> s1 = 'café'
>>> s2 = 'cafe\N{COMBINING ACUTE ACCENT}'
>>> s1, s2
('café', 'café')
>>> len(s1), len(s2)
(4, 5)
>>> s1 == s2
False
在“e”后面放置COMBINING ACUTE ACCENT(U+0301)会呈现“é”。在 Unicode 标准中,像'é'和'e\u0301'这样的序列被称为“规范等价物”,应用程序应将它们视为相同。但是 Python 看到两个不同的代码点序列,并认为它们不相等。
解决方案是unicodedata.normalize()。该函数的第一个参数是四个字符串之一:'NFC','NFD','NFKC'和'NFKD'。让我们从前两个开始。
规范化形式 C(NFC)将代码点组合以生成最短等效字符串,而 NFD 将分解,将组合字符扩展为基本字符和单独的组合字符。这两种规范化使比较按预期工作,如下一个示例所示:
>>> from unicodedata import normalize
>>> s1 = 'café'
>>> s2 = 'cafe\N{COMBINING ACUTE ACCENT}'
>>> len(s1), len(s2)
(4, 5)
>>> len(normalize('NFC', s1)), len(normalize('NFC', s2))
(4, 4)
>>> len(normalize('NFD', s1)), len(normalize('NFD', s2))
(5, 5)
>>> normalize('NFC', s1) == normalize('NFC', s2)
True
>>> normalize('NFD', s1) == normalize('NFD', s2)
True
键盘驱动程序通常生成组合字符,因此用户输入的文本默认情况下将是 NFC。但是,为了安全起见,在保存之前最好使用normalize('NFC', user_text)对字符串进行规范化。NFC 也是 W3C 在“全球网络字符模型:字符串匹配和搜索”中推荐的规范化形式。
一些单个字符被 NFC 规范化为另一个单个字符。电阻单位欧姆(Ω)的符号被规范化为希腊大写 omega。它们在视觉上是相同的,但它们比较不相等,因此规范化是必不可少的,以避免意外:
>>> from unicodedata import normalize, name
>>> ohm = '\u2126'
>>> name(ohm)
'OHM SIGN'
>>> ohm_c = normalize('NFC', ohm)
>>> name(ohm_c)
'GREEK CAPITAL LETTER OMEGA'
>>> ohm == ohm_c
False
>>> normalize('NFC', ohm) == normalize('NFC', ohm_c)
True
另外两种规范化形式是 NFKC 和 NFKD,其中字母 K 代表“兼容性”。这些是更强的规范化形式,影响所谓的“兼容性字符”。尽管 Unicode 的一个目标是为每个字符有一个单一的“规范”代码点,但一些字符出现多次是为了与现有标准兼容。例如,MICRO SIGN,µ(U+00B5),被添加到 Unicode 以支持与包括它在内的latin1的往返转换,即使相同的字符是希腊字母表的一部分,具有代码点U+03BC(GREEK SMALL LETTER MU)。因此,微符号被视为“兼容性字符”。
在 NFKC 和 NFKD 形式中,每个兼容字符都被一个或多个字符的“兼容分解”替换,这些字符被认为是“首选”表示,即使存在一些格式损失——理想情况下,格式应该由外部标记负责,而不是 Unicode 的一部分。举例来说,一个半分数'½'(U+00BD)的兼容分解是三个字符的序列'1/2',而微符号'µ'(U+00B5)的兼容分解是小写的希腊字母 mu'μ'(U+03BC)。⁷
下面是 NFKC 在实践中的工作方式:
>>> from unicodedata import normalize, name
>>> half = '\N{VULGAR FRACTION ONE HALF}'
>>> print(half)
½
>>> normalize('NFKC', half)
'1⁄2'
>>> for char in normalize('NFKC', half):
... print(char, name(char), sep='\t')
...
1 DIGIT ONE
⁄ FRACTION SLASH
2 DIGIT TWO
>>> four_squared = '4²'
>>> normalize('NFKC', four_squared)
'42'
>>> micro = 'µ'
>>> micro_kc = normalize('NFKC', micro)
>>> micro, micro_kc
('µ', 'μ')
>>> ord(micro), ord(micro_kc)
(181, 956)
>>> name(micro), name(micro_kc)
('MICRO SIGN', 'GREEK SMALL LETTER MU')
尽管'1⁄2'是'½'的一个合理替代品,而微符号实际上是一个小写希腊字母 mu,但将'4²'转换为'42'会改变含义。一个应用程序可以将'4²'存储为'4<sup>2</sup>',但normalize函数对格式一无所知。因此,NFKC 或 NFKD 可能会丢失或扭曲信息,但它们可以生成方便的中间表示形式用于搜索和索引。
不幸的是,对于 Unicode 来说,一切总是比起初看起来更加复杂。对于VULGAR FRACTION ONE HALF,NFKC 规范化产生了用FRACTION SLASH连接的 1 和 2,而不是SOLIDUS,即“斜杠”—ASCII 代码十进制 47 的熟悉字符。因此,搜索三字符 ASCII 序列'1/2'将找不到规范化的 Unicode 序列。
警告
NFKC 和 NFKD 规范会导致数据丢失,应仅在特殊情况下如搜索和索引中应用,而不是用于文本的永久存储。
当准备文本进行搜索或索引时,另一个有用的操作是大小写折叠,我们的下一个主题。
大小写折叠
大小写折叠基本上是将所有文本转换为小写,还有一些额外的转换。它由str.casefold()方法支持。
对于只包含 latin1 字符的任何字符串 s,s.casefold() 产生与 s.lower() 相同的结果,只有两个例外——微符号 'µ' 被更改为希腊小写 mu(在大多数字体中看起来相同),德语 Eszett 或 “sharp s”(ß)变为 “ss”:
>>> micro = 'µ'
>>> name(micro)
'MICRO SIGN'
>>> micro_cf = micro.casefold()
>>> name(micro_cf)
'GREEK SMALL LETTER MU'
>>> micro, micro_cf
('µ', 'μ')
>>> eszett = 'ß'
>>> name(eszett)
'LATIN SMALL LETTER SHARP S'
>>> eszett_cf = eszett.casefold()
>>> eszett, eszett_cf
('ß', 'ss')
有将近 300 个代码点,str.casefold() 和 str.lower() 返回不同的结果。
和 Unicode 相关的任何事物一样,大小写折叠是一个困难的问题,有很多语言特殊情况,但 Python 核心团队努力提供了一个解决方案,希望能适用于大多数用户。
在接下来的几节中,我们将利用我们的规范化知识开发实用函数。
用于规范化文本匹配的实用函数
正如我们所见,NFC 和 NFD 是安全的,并允许在 Unicode 字符串之间进行明智的比较。对于大多数应用程序,NFC 是最佳的规范化形式。str.casefold() 是进行不区分大小写比较的方法。
如果您使用多种语言的文本,像 示例 4-13 中的 nfc_equal 和 fold_equal 这样的一对函数对您的工具箱是有用的补充。
示例 4-13. normeq.py: 规范化的 Unicode 字符串比较
"""
Utility functions for normalized Unicode string comparison.
Using Normal Form C, case sensitive:
>>> s1 = 'café'
>>> s2 = 'cafe\u0301'
>>> s1 == s2
False
>>> nfc_equal(s1, s2)
True
>>> nfc_equal('A', 'a')
False
Using Normal Form C with case folding:
>>> s3 = 'Straße'
>>> s4 = 'strasse'
>>> s3 == s4
False
>>> nfc_equal(s3, s4)
False
>>> fold_equal(s3, s4)
True
>>> fold_equal(s1, s2)
True
>>> fold_equal('A', 'a')
True
"""
from unicodedata import normalize
def nfc_equal(str1, str2):
return normalize('NFC', str1) == normalize('NFC', str2)
def fold_equal(str1, str2):
return (normalize('NFC', str1).casefold() ==
normalize('NFC', str2).casefold())
超出 Unicode 标准中的规范化和大小写折叠之外,有时候进行更深层次的转换是有意义的,比如将 'café' 改为 'cafe'。我们将在下一节看到何时以及如何进行。
极端的“规范化”:去除变音符号
谷歌搜索的秘密酱包含许多技巧,但其中一个显然是忽略变音符号(例如,重音符号、锐音符等),至少在某些情况下是这样。去除变音符号并不是一种适当的规范化形式,因为它经常改变单词的含义,并且在搜索时可能产生误报。但它有助于应对生活中的一些事实:人们有时懒惰或无知于正确使用变音符号,拼写规则随时间变化,这意味着重音符号在活语言中来来去去。
除了搜索之外,去除变音符号还可以使 URL 更易读,至少在基于拉丁语言的语言中是这样。看看关于圣保罗市的维基百科文章的 URL:
https://en.wikipedia.org/wiki/S%C3%A3o_Paulo
%C3%A3 部分是 URL 转义的,UTF-8 渲染的单个字母 “ã”(带有波浪符的 “a”)。即使拼写不正确,以下内容也更容易识别:
https://en.wikipedia.org/wiki/Sao_Paulo
要从 str 中移除所有变音符号,可以使用类似 示例 4-14 的函数。
示例 4-14. simplify.py: 用于移除所有组合标记的函数
import unicodedata
import string
def shave_marks(txt):
"""Remove all diacritic marks"""
norm_txt = unicodedata.normalize('NFD', txt) # ①
shaved = ''.join(c for c in norm_txt
if not unicodedata.combining(c)) # ②
return unicodedata.normalize('NFC', shaved) # ③
①
将所有字符分解为基本字符和组合标记。
②
过滤掉所有组合标记。
③
重新组合所有字符。
示例 4-15 展示了几种使用 shave_marks 的方法。
示例 4-15. 使用 shave_marks 的两个示例,来自 示例 4-14
>>> order = '“Herr Voß: • ½ cup of Œtker™ caffè latte • bowl of açaí.”'
>>> shave_marks(order)
'“Herr Voß: • ½ cup of Œtker™ caffe latte • bowl of acai.”' # ①
>>> Greek = 'Ζέφυρος, Zéfiro'
>>> shave_marks(Greek)
'Ζεφυρος, Zefiro' # ②
①
仅字母 “è”、“ç” 和 “í” 被替换。
②
“έ” 和 “é” 都被替换了。
来自 示例 4-14 的函数 shave_marks 运行良好,但也许它做得太过了。通常移除变音符号的原因是将拉丁文本更改为纯 ASCII,但 shave_marks 也会改变非拉丁字符,比如希腊字母,这些字母仅仅通过失去重音就不会变成 ASCII。因此,有必要分析每个基本字符,并仅在基本字符是拉丁字母时才移除附加标记。这就是 示例 4-16 的作用。
示例 4-16. 从拉丁字符中移除组合标记的函数(省略了导入语句,因为这是来自 示例 4-14 的 simplify.py 模块的一部分)
def shave_marks_latin(txt):
"""Remove all diacritic marks from Latin base characters"""
norm_txt = unicodedata.normalize('NFD', txt) # ①
latin_base = False
preserve = []
for c in norm_txt:
if unicodedata.combining(c) and latin_base: # ②
continue # ignore diacritic on Latin base char
preserve.append(c) # ③
# if it isn't a combining char, it's a new base char
if not unicodedata.combining(c): # ④
latin_base = c in string.ascii_letters
shaved = ''.join(preserve)
return unicodedata.normalize('NFC', shaved) # ⑤
①
将所有字符分解为基本字符和组合标记。
②
当基本字符为拉丁字符时,跳过组合标记。
③
否则,保留当前字符。
④
检测新的基本字符,并确定它是否为拉丁字符。
⑤
重新组合所有字符。
更激进的一步是将西方文本中的常见符号(例如,卷曲引号、破折号、项目符号等)替换为ASCII等效符号。这就是示例 4-17 中的asciize函数所做的。
示例 4-17. 将一些西方排版符号转换为 ASCII(此片段也是示例 4-14 中simplify.py的一部分)
single_map = str.maketrans("""‚ƒ„ˆ‹‘’“”•–—˜›""", # ①
"""'f"^<''""---~>""")
multi_map = str.maketrans({ # ②
'€': 'EUR',
'…': '...',
'Æ': 'AE',
'æ': 'ae',
'Œ': 'OE',
'œ': 'oe',
'™': '(TM)',
'‰': '<per mille>',
'†': '**',
'‡': '***',
})
multi_map.update(single_map) # ③
def dewinize(txt):
"""Replace Win1252 symbols with ASCII chars or sequences"""
return txt.translate(multi_map) # ④
def asciize(txt):
no_marks = shave_marks_latin(dewinize(txt)) # ⑤
no_marks = no_marks.replace('ß', 'ss') # ⑥
return unicodedata.normalize('NFKC', no_marks) # ⑦
①
为字符替换构建映射表。
②
为字符到字符串替换构建映射表。
③
合并映射表。
④
dewinize不影响ASCII或latin1文本,只影响cp1252中的 Microsoft 附加内容。
⑤
应用dewinize并移除变音符号。
⑥
用“ss”替换 Eszett(我们这里不使用大小写折叠,因为我们想保留大小写)。
⑦
对具有其兼容性代码点的字符进行 NFKC 规范化以组合字符。
示例 4-18 展示了asciize的使用。
示例 4-18. 使用示例 4-17 中的asciize的两个示例
>>> order = '“Herr Voß: • ½ cup of Œtker™ caffè latte • bowl of açaí.”'
>>> dewinize(order)
'"Herr Voß: - ½ cup of OEtker(TM) caffè latte - bowl of açaí."' # ①
>>> asciize(order)
'"Herr Voss: - 1⁄2 cup of OEtker(TM) caffe latte - bowl of acai."' # ②
①
dewinize替换卷曲引号、项目符号和™(商标符号)。
②
asciize应用dewinize,删除变音符号,并替换'ß'。
警告
不同语言有自己的去除变音符号的规则。例如,德语将'ü'改为'ue'。我们的asciize函数不够精细,因此可能不适合您的语言。但对葡萄牙语来说,它的效果还可以接受。
总结一下,在simplify.py中的函数远远超出了标准规范化,并对文本进行了深度处理,有可能改变其含义。只有您可以决定是否走得这么远,了解目标语言、您的用户以及转换后的文本将如何使用。
这就结束了我们对规范化 Unicode 文本的讨论。
现在让我们来解决 Unicode 排序问题。
对 Unicode 文本进行排序
Python 通过逐个比较每个序列中的项目来对任何类型的序列进行排序。对于字符串,这意味着比较代码点。不幸的是,这对于使用非 ASCII 字符的人来说产生了无法接受的结果。
考虑对在巴西种植的水果列表进行排序:
>>> fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
>>> sorted(fruits)
['acerola', 'atemoia', 'açaí', 'caju', 'cajá']
不同区域设置的排序规则不同,但在葡萄牙语和许多使用拉丁字母表的语言中,重音符号和塞迪利亚很少在排序时产生差异。⁸ 因此,“cajá”被排序为“caja”,并且必须位于“caju”之前。
排序后的fruits列表应为:
['açaí', 'acerola', 'atemoia', 'cajá', 'caju']
在 Python 中对非 ASCII 文本进行排序的标准方法是使用locale.strxfrm函数,根据locale模块文档,“将一个字符串转换为可用于区域设置感知比较的字符串”。
要启用locale.strxfrm,您必须首先为您的应用程序设置一个合适的区域设置,并祈祷操作系统支持它。示例 4-19 中的命令序列可能适用于您。
示例 4-19. locale_sort.py:使用locale.strxfrm函数作为排序键
import locale
my_locale = locale.setlocale(locale.LC_COLLATE, 'pt_BR.UTF-8')
print(my_locale)
fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
sorted_fruits = sorted(fruits, key=locale.strxfrm)
print(sorted_fruits)
在 GNU/Linux(Ubuntu 19.10)上运行 示例 4-19,安装了 pt_BR.UTF-8 区域设置,我得到了正确的结果:
'pt_BR.UTF-8'
['açaí', 'acerola', 'atemoia', 'cajá', 'caju']
因此,在排序时需要在使用 locale.strxfrm 作为键之前调用 setlocale(LC_COLLATE, «your_locale»)。
不过,还有一些注意事项:
-
因为区域设置是全局的,不建议在库中调用
setlocale。您的应用程序或框架应该在进程启动时设置区域设置,并且不应该在之后更改它。 -
操作系统必须安装区域设置,否则
setlocale会引发locale.Error: unsupported locale setting异常。 -
您必须知道如何拼写区域设置名称。
-
区域设置必须由操作系统的制造商正确实现。我在 Ubuntu 19.10 上成功了,但在 macOS 10.14 上没有成功。在 macOS 上,调用
setlocale(LC_COLLATE, 'pt_BR.UTF-8')返回字符串'pt_BR.UTF-8'而没有任何投诉。但sorted(fruits, key=locale.strxfrm)产生了与sorted(fruits)相同的不正确结果。我还在 macOS 上尝试了fr_FR、es_ES和de_DE区域设置,但locale.strxfrm从未起作用。⁹
因此,标准库提供的国际化排序解决方案有效,但似乎只在 GNU/Linux 上得到很好的支持(也许在 Windows 上也是如此,如果您是专家的话)。即使在那里,它也依赖于区域设置,会带来部署上的麻烦。
幸运的是,有一个更简单的解决方案:pyuca 库,可以在 PyPI 上找到。
使用 Unicode Collation Algorithm 进行排序
James Tauber,多产的 Django 贡献者,一定感受到了痛苦,并创建了 pyuca,这是 Unicode Collation Algorithm(UCA)的纯 Python 实现。示例 4-20 展示了它的易用性。
示例 4-20。使用 pyuca.Collator.sort_key 方法
>>> import pyuca
>>> coll = pyuca.Collator()
>>> fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
>>> sorted_fruits = sorted(fruits, key=coll.sort_key)
>>> sorted_fruits
['açaí', 'acerola', 'atemoia', 'cajá', 'caju']
这个方法简单易行,在 GNU/Linux、macOS 和 Windows 上都可以运行,至少在我的小样本中是这样的。
pyuca 不考虑区域设置。如果需要自定义排序,可以向 Collator() 构造函数提供自定义排序表的路径。默认情况下,它使用 allkeys.txt,这是项目捆绑的。这只是 来自 Unicode.org 的默认 Unicode Collation Element Table 的副本。
PyICU:Miro 的 Unicode 排序推荐
(技术审阅员 Miroslav Šedivý 是一位多语言使用者,也是 Unicode 方面的专家。这是他对 pyuca 的评价。)
pyuca 有一个排序算法,不考虑各个语言中的排序顺序。例如,在德语中 Ä 在 A 和 B 之间,而在瑞典语中它在 Z 之后。看看 PyICU,它像区域设置一样工作,而不会更改进程的区域设置。如果您想要在土耳其语中更改 iİ/ıI 的大小写,也需要它。PyICU 包含一个必须编译的扩展,因此在某些系统中安装可能比只是 Python 的 pyuca 更困难。
顺便说一句,那个排序表是组成 Unicode 数据库的许多数据文件之一,我们下一个主题。
Unicode 数据库
Unicode 标准提供了一个完整的数据库,以几个结构化的文本文件的形式存在,其中不仅包括将代码点映射到字符名称的表,还包括有关各个字符及其相关性的元数据。例如,Unicode 数据库记录了字符是否可打印、是否为字母、是否为十进制数字,或者是否为其他数字符号。这就是 str 方法 isalpha、isprintable、isdecimal 和 isnumeric 的工作原理。str.casefold 也使用了来自 Unicode 表的信息。
注意
unicodedata.category(char) 函数从 Unicode 数据库返回 char 的两个字母类别。更高级别的 str 方法更容易使用。例如,label.isalpha() 如果 label 中的每个字符属于以下类别之一,则返回 True:Lm、Lt、Lu、Ll 或 Lo。要了解这些代码的含义,请参阅英文维基百科的 “Unicode 字符属性”文章 中的 “通用类别”。
按名称查找字符
unicodedata 模块包括检索字符元数据的函数,包括 unicodedata.name(),它返回标准中字符的官方名称。图 4-5 展示了该函数的使用。¹⁰

图 4-5. 在 Python 控制台中探索 unicodedata.name()。
您可以使用 name() 函数构建应用程序,让用户可以按名称搜索字符。图 4-6 展示了 cf.py 命令行脚本,它接受一个或多个单词作为参数,并列出具有这些单词在官方 Unicode 名称中的字符。cf.py 的完整源代码在 示例 4-21 中。

图 4-6. 使用 cf.py 查找微笑的猫。
警告
表情符号在各种操作系统和应用程序中的支持差异很大。近年来,macOS 终端提供了最好的表情符号支持,其次是现代 GNU/Linux 图形终端。Windows cmd.exe 和 PowerShell 现在支持 Unicode 输出,但截至我在 2020 年 1 月撰写本节时,它们仍然不显示表情符号——至少不是“开箱即用”。技术评论员莱昂纳多·罗查尔告诉我有一个新的、由微软推出的开源 Windows 终端,它可能比旧的微软控制台具有更好的 Unicode 支持。我还没有时间尝试。
在 示例 4-21 中,请注意 find 函数中的 if 语句,使用 .issubset() 方法快速测试 query 集合中的所有单词是否出现在从字符名称构建的单词列表中。由于 Python 丰富的集合 API,我们不需要嵌套的 for 循环和另一个 if 来实现此检查。
示例 4-21. cf.py:字符查找实用程序
#!/usr/bin/env python3
import sys
import unicodedata
START, END = ord(' '), sys.maxunicode + 1 # ①
def find(*query_words, start=START, end=END): # ②
query = {w.upper() for w in query_words} # ③
for code in range(start, end):
char = chr(code) # ④
name = unicodedata.name(char, None) # ⑤
if name and query.issubset(name.split()): # ⑥
print(f'U+{code:04X}\t{char}\t{name}') # ⑦
def main(words):
if words:
find(*words)
else:
print('Please provide words to find.')
if __name__ == '__main__':
main(sys.argv[1:])
①
设置搜索的代码点范围的默认值。
②
find 接受 query_words 和可选的关键字参数来限制搜索范围,以便进行测试。
③
将 query_words 转换为大写字符串集合。
④
获取 code 的 Unicode 字符。
⑤
获取字符的名称,如果代码点未分配,则返回 None。
⑥
如果有名称,将其拆分为单词列表,然后检查 query 集合是否是该列表的子集。
⑦
打印出以 U+9999 格式的代码点、字符和其名称的行。
unicodedata 模块还有其他有趣的函数。接下来,我们将看到一些与获取具有数字含义的字符信息相关的函数。
字符的数字含义
unicodedata 模块包括函数,用于检查 Unicode 字符是否表示数字,如果是,则返回其人类的数值,而不是其代码点数。示例 4-22 展示了 unicodedata.name() 和 unicodedata.numeric() 的使用,以及 str 的 .isdecimal() 和 .isnumeric() 方法。
示例 4-22. Unicode 数据库数字字符元数据演示(标注描述输出中的每列)
import unicodedata
import re
re_digit = re.compile(r'\d')
sample = '1\xbc\xb2\u0969\u136b\u216b\u2466\u2480\u3285'
for char in sample:
print(f'U+{ord(char):04x}', # ①
char.center(6), # ②
're_dig' if re_digit.match(char) else '-', # ③
'isdig' if char.isdigit() else '-', # ④
'isnum' if char.isnumeric() else '-', # ⑤
f'{unicodedata.numeric(char):5.2f}', # ⑥
unicodedata.name(char), # ⑦
sep='\t')
①
以U+0000格式的代码点。
②
字符在长度为 6 的str中居中。
③
如果字符匹配r'\d'正则表达式,则显示re_dig。
④
如果char.isdigit()为True,则显示isdig。
⑤
如果char.isnumeric()为True,则显示isnum。
⑥
数值格式化为宽度为 5 和 2 位小数。
⑦
Unicode 字符名称。
运行示例 4-22 会给你图 4-7,如果你的终端字体有所有这些字形。

图 4-7. macOS 终端显示数字字符及其元数据;re_dig表示字符匹配正则表达式r'\d'。
图 4-7 的第六列是在字符上调用unicodedata.numeric(char)的结果。它显示 Unicode 知道代表数字的符号的数值。因此,如果你想创建支持泰米尔数字或罗马数字的电子表格应用程序,就去做吧!
图 4-7 显示正则表达式r'\d'匹配数字“1”和梵文数字 3,但不匹配一些其他被isdigit函数视为数字的字符。re模块对 Unicode 的了解不如它本应该的那样深入。PyPI 上提供的新regex模块旨在最终取代re,并提供更好的 Unicode 支持。¹¹我们将在下一节回到re模块。
在本章中,我们使用了几个unicodedata函数,但还有许多我们没有涉及的函数。查看标准库文档中的unicodedata模块。
接下来我们将快速查看双模式 API,提供接受str或bytes参数的函数,并根据类型进行特殊处理。
双模式 str 和 bytes API
Python 标准库有接受str或bytes参数并根据类型表现不同的函数。一些示例可以在re和os模块中找到。
正则表达式中的 str 与 bytes
如果用bytes构建正则表达式,模式如\d和\w只匹配 ASCII 字符;相反,如果这些模式给定为str,它们将匹配 ASCII 之外的 Unicode 数字或字母。示例 4-23 和图 4-8 比较了str和bytes模式如何匹配字母、ASCII 数字、上标和泰米尔数字。
示例 4-23. ramanujan.py:比较简单str和bytes正则表达式的行为
import re
re_numbers_str = re.compile(r'\d+') # ①
re_words_str = re.compile(r'\w+')
re_numbers_bytes = re.compile(rb'\d+') # ②
re_words_bytes = re.compile(rb'\w+')
text_str = ("Ramanujan saw \u0be7\u0bed\u0be8\u0bef" # ③
" as 1729 = 1³ + 12³ = 9³ + 10³.") # ④
text_bytes = text_str.encode('utf_8') # ⑤
print(f'Text\n {text_str!r}')
print('Numbers')
print(' str :', re_numbers_str.findall(text_str)) # ⑥
print(' bytes:', re_numbers_bytes.findall(text_bytes)) # ⑦
print('Words')
print(' str :', re_words_str.findall(text_str)) # ⑧
print(' bytes:', re_words_bytes.findall(text_bytes)) # ⑨
①
前两个正则表达式是str类型。
②
最后两个是bytes类型。
③
Unicode 文本搜索,包含泰米尔数字1729(逻辑行一直延续到右括号标记)。
④
此字符串在编译时与前一个字符串连接(参见“2.4.2. 字符串文字连接”中的Python 语言参考)。
⑤
需要使用bytes正则表达式来搜索bytes字符串。
⑥](#co_unicode_text_versus_bytes_CO15-6)
str模式r'\d+'匹配泰米尔和 ASCII 数字。
⑦
bytes模式rb'\d+'仅匹配数字的 ASCII 字节。
⑧
str模式r'\w+'匹配字母、上标、泰米尔语和 ASCII 数字。
⑨
bytes模式rb'\w+'仅匹配字母和数字的 ASCII 字节。

图 4-8。从示例 4-23 运行 ramanujan.py 的屏幕截图。
示例 4-23 是一个简单的例子,用来说明一个观点:你可以在str和bytes上使用正则表达式,但在第二种情况下,ASCII 范围之外的字节被视为非数字和非单词字符。
对于str正则表达式,有一个re.ASCII标志,使得\w、\W、\b、\B、\d、\D、\s和\S只执行 ASCII 匹配。详细信息请参阅re 模块的文档。
另一个重要的双模块是os。
os函数中的 str 与 bytes
GNU/Linux 内核不支持 Unicode,因此在现实世界中,您可能会发现由字节序列组成的文件名,这些文件名在任何明智的编码方案中都无效,并且无法解码为str。使用各种操作系统的客户端的文件服务器特别容易出现这个问题。
为了解决这个问题,所有接受文件名或路径名的os模块函数都以str或bytes形式接受参数。如果调用这样的函数时使用str参数,参数将自动使用sys.getfilesystemencoding()命名的编解码器进行转换,并且 OS 响应将使用相同的编解码器进行解码。这几乎总是您想要的,符合 Unicode 三明治最佳实践。
但是,如果您必须处理(或者可能修复)无法以这种方式处理的文件名,您可以将bytes参数传递给os函数以获得bytes返回值。这个功能让您可以处理任何文件或路径名,无论您可能遇到多少小精灵。请参阅示例 4-24。
示例 4-24。listdir使用str和bytes参数和结果
>>> os.listdir('.') # ①
['abc.txt', 'digits-of-π.txt'] >>> os.listdir(b'.') # ②
[b'abc.txt', b'digits-of-\xcf\x80.txt']
①
第二个文件名是“digits-of-π.txt”(带有希腊字母π)。
②
给定一个byte参数,listdir以字节形式返回文件名:b'\xcf\x80'是希腊字母π的 UTF-8 编码。
为了帮助处理作为文件名或路径名的str或bytes序列,os模块提供了特殊的编码和解码函数os.fsencode(name_or_path)和os.fsdecode(name_or_path)。自 Python 3.6 起,这两个函数都接受str、bytes或实现os.PathLike接口的对象作为参数。
Unicode 是一个深奥的领域。是时候结束我们对str和bytes的探索了。
章节总结
我们在本章开始时否定了1 个字符 == 1 个字节的概念。随着世界采用 Unicode,我们需要将文本字符串的概念与文件中表示它们的二进制序列分开,而 Python 3 强制执行这种分离。
在简要概述二进制序列数据类型——bytes、bytearray和memoryview后,我们开始了编码和解码,列举了一些重要的编解码器,然后介绍了如何防止或处理由 Python 源文件中错误编码引起的臭名昭著的UnicodeEncodeError、UnicodeDecodeError和SyntaxError。
在没有元数据的情况下考虑编码检测的理论和实践:理论上是不可能的,但实际上 Chardet 软件包对一些流行的编码做得相当不错。然后介绍了字节顺序标记作为 UTF-16 和 UTF-32 文件中唯一常见的编码提示,有时也会在 UTF-8 文件中找到。
在下一节中,我们演示了如何打开文本文件,这是一个简单的任务,除了一个陷阱:当你打开文本文件时,encoding= 关键字参数不是强制的,但应该是。如果你未指定编码,你最终会得到一个在不同平台上不兼容的“纯文本”生成程序,这是由于冲突的默认编码。然后我们揭示了 Python 使用的不同编码设置作为默认值以及如何检测它们。对于 Windows 用户来说,一个令人沮丧的认识是这些设置在同一台机器内往往具有不同的值,并且这些值是相互不兼容的;相比之下,GNU/Linux 和 macOS 用户生活在一个更幸福的地方,UTF-8 几乎是默认编码。
Unicode 提供了多种表示某些字符的方式,因此规范化是文本匹配的先决条件。除了解释规范化和大小写折叠外,我们还提供了一些实用函数,您可以根据自己的需求进行调整,包括像删除所有重音这样的彻底转换。然后我们看到如何通过利用标准的 locale 模块正确对 Unicode 文本进行排序——带有一些注意事项——以及一个不依赖于棘手的 locale 配置的替代方案:外部的 pyuca 包。
我们利用 Unicode 数据库编写了一个命令行实用程序,通过名称搜索字符——感谢 Python 的强大功能,只需 28 行代码。我们还简要介绍了其他 Unicode 元数据,并对一些双模式 API 进行了概述,其中一些函数可以使用 str 或 bytes 参数调用,产生不同的结果。
进一步阅读
Ned Batchelder 在 2012 年 PyCon US 的演讲“实用 Unicode,或者,我如何停止痛苦?”非常出色。Ned 是如此专业,他提供了演讲的完整文本以及幻灯片和视频。
“Python 中的字符编码和 Unicode:如何(╯°□°)╯︵ ┻━┻ 有尊严地处理”(幻灯片,视频)是 Esther Nam 和 Travis Fischer 在 PyCon 2014 上的出色演讲,我在这个章节中找到了这个简洁的题记:“人类使用文本。计算机使用字节。”
Lennart Regebro——本书第一版的技术审查者之一——在短文“澄清 Unicode:什么是 Unicode?”中分享了他的“Unicode 有用的心智模型(UMMU)”。Unicode 是一个复杂的标准,所以 Lennart 的 UMMU 是一个非常有用的起点。
Python 文档中官方的“Unicode HOWTO”从多个不同角度探讨了这个主题,从一个很好的历史介绍,到语法细节,编解码器,正则表达式,文件名,以及 Unicode-aware I/O 的最佳实践(即 Unicode 三明治),每个部分都有大量额外的参考链接。Mark Pilgrim 的精彩书籍Dive into Python 3(Apress)的第四章,“字符串”也提供了 Python 3 中 Unicode 支持的很好介绍。在同一本书中,第十五章描述了 Chardet 库是如何从 Python 2 移植到 Python 3 的,这是一个有价值的案例研究,因为从旧的 str 到新的 bytes 的转换是大多数迁移痛点的原因,这也是一个设计用于检测编码的库的核心关注点。
如果你了解 Python 2 但是对 Python 3 感到陌生,Guido van Rossum 的“Python 3.0 有什么新特性”列出了 15 个要点,总结了发生的变化,并提供了许多链接。Guido 以直率的话语开始:“你所知道的关于二进制数据和 Unicode 的一切都发生了变化。”Armin Ronacher 的博客文章“Python 中 Unicode 的更新指南”深入探讨了 Python 3 中 Unicode 的一些陷阱(Armin 不是 Python 3 的铁粉)。
第三版的Python Cookbook(O’Reilly)中的第二章“字符串和文本”,由大卫·比兹利和布莱恩·K·琼斯编写,包含了几个处理 Unicode 标准化、文本清理以及在字节序列上执行面向文本操作的示例。第五章涵盖了文件和 I/O,并包括“第 5.17 节 写入字节到文本文件”,展示了在任何文本文件下始终存在一个可以在需要时直接访问的二进制流。在后续的食谱中,struct模块被用于“第 6.11 节 读取和写入二进制结构数组”。
尼克·科格兰的“Python 笔记”博客有两篇与本章非常相关的文章:“Python 3 和 ASCII 兼容的二进制协议”和“在 Python 3 中处理文本文件”。强烈推荐。
Python 支持的编码列表可在codecs模块文档中的“标准编码”中找到。如果需要以编程方式获取该列表,请查看随 CPython 源代码提供的/Tools/unicode/listcodecs.py脚本。
书籍Unicode Explained由尤卡·K·科尔佩拉(O’Reilly)和Unicode Demystified由理查德·吉拉姆(Addison-Wesley)撰写,虽然不是针对 Python 的,但在我学习 Unicode 概念时非常有帮助。Programming with Unicode由维克多·斯汀纳自由出版,涵盖了 Unicode 的一般概念,以及主要操作系统和几种编程语言的工具和 API。
W3C 页面“大小写折叠:简介”和“全球网络字符模型:字符串匹配”涵盖了标准化概念,前者是一个简单的介绍,后者是一个以干燥标准语言撰写的工作组说明书—与“Unicode 标准附录#15—Unicode 标准化形式”相同的语调。Unicode.org的“常见问题,标准化”部分更易读,马克·戴维斯的“NFC FAQ”也是如此—他是几个 Unicode 算法的作者,也是本文撰写时 Unicode 联盟的主席。
2016 年,纽约现代艺术博物馆(MoMA)将 1999 年由 NTT DOCOMO 的栗田茂高设计的原始表情符号加入了其收藏品。回顾历史,Emojipedia发表了“关于第一个表情符号集的纠正”,将日本的 SoftBank 归功于已知最早的表情符号集,于 1997 年在手机上部署。SoftBank 的集合是 Unicode 中 90 个表情符号的来源,包括 U+1F4A9(PILE OF POO)。马修·罗森伯格的emojitracker.com是一个实时更新的 Twitter 表情符号使用计数仪表板。在我写这篇文章时,FACE WITH TEARS OF JOY(U+1F602)是 Twitter 上最受欢迎的表情符号,记录的出现次数超过 3,313,667,315 次。
¹ PyCon 2014 演讲“Python 中的字符编码和 Unicode”(幻灯片,视频)的第 12 张幻灯片。
² Python 2.6 和 2.7 也有bytes,但它只是str类型的别名。
³ 小知识:Python 默认使用的 ASCII“单引号”字符实际上在 Unicode 标准中被命名为 APOSTROPHE。真正的单引号是不对称的:左边是 U+2018,右边是 U+2019。
⁴ 在 Python 3.0 到 3.4 中不起作用,给处理二进制数据的开发人员带来了很多痛苦。这种逆转在PEP 461—为 bytes 和 bytearray 添加%格式化中有记录。
⁵ 我第一次看到“Unicode 三明治”这个术语是在 Ned Batchelder 在 2012 年美国 PyCon 大会上的优秀“务实的 Unicode”演讲中。
⁶ 来源:“Windows 命令行:Unicode 和 UTF-8 输出文本缓冲区”。
⁷ 有趣的是,微符号被认为是一个“兼容字符”,但欧姆符号不是。最终结果是 NFC 不会触及微符号,但会将欧姆符号更改为大写希腊字母 omega,而 NFKC 和 NFKD 会将欧姆符号和微符号都更改为希腊字符。
⁸ 重音符号只在两个单词之间唯一的区别是它们时才会影响排序—在这种情况下,带有重音符号的单词会在普通单词之后排序。
⁹ 再次,我找不到解决方案,但发现其他人报告了相同的问题。其中一位技术审阅者 Alex Martelli 在他的 Macintosh 上使用setlocale和locale.strxfrm没有问题,他的 macOS 版本是 10.9。总结:结果可能有所不同。
¹⁰ 那是一张图片—而不是代码清单—因为在我写这篇文章时,O’Reilly 的数字出版工具链对表情符号的支持不佳。
¹¹ 尽管在这个特定样本中,它并不比re更擅长识别数字。
第五章:数据类构建器
数据类就像孩子一样。它们作为一个起点是可以的,但要作为一个成熟的对象参与,它们需要承担一些责任。
马丁·福勒和肯特·贝克¹
Python 提供了几种构建简单类的方法,这些类只是一组字段,几乎没有额外功能。这种模式被称为“数据类”,而dataclasses是支持这种模式的包之一。本章涵盖了三种不同的类构建器,您可以将它们用做编写数据类的快捷方式:
collections.namedtuple
最简单的方法——自 Python 2.6 起可用。
typing.NamedTuple
一种需要在字段上添加类型提示的替代方法——自 Python 3.5 起,3.6 中添加了class语法。
@dataclasses.dataclass
一个类装饰器,允许比以前的替代方案更多的定制化,增加了许多选项和潜在的复杂性——自 Python 3.7 起。
在讨论完这些类构建器之后,我们将讨论为什么数据类也是一个代码异味的名称:一种可能是糟糕面向对象设计的症状的编码模式。
注意
typing.TypedDict可能看起来像另一个数据类构建器。它使用类似的语法,并在 Python 3.9 的typing模块文档中的typing.NamedTuple之后描述。
但是,TypedDict不会构建您可以实例化的具体类。它只是一种语法,用于为将用作记录的映射值接受的函数参数和变量编写类型提示,其中键作为字段名。我们将在第十五章的TypedDict中看到它们。
本章的新内容
本章是流畅的 Python第二版中的新内容。第一版的第二章中出现了“经典命名元组”一节,但本章的其余部分是全新的。
我们从三个类构建器的高级概述开始。
数据类构建器概述
考虑一个简单的类来表示地理坐标对,如示例 5-1 所示。
示例 5-1。class/coordinates.py
class Coordinate:
def __init__(self, lat, lon):
self.lat = lat
self.lon = lon
那个Coordinate类完成了保存纬度和经度属性的工作。编写__init__样板变得非常乏味,特别是如果你的类有超过几个属性:每个属性都被提及三次!而且那个样板并没有为我们购买我们期望从 Python 对象中获得的基本功能:
>>> from coordinates import Coordinate
>>> moscow = Coordinate(55.76, 37.62)
>>> moscow
<coordinates.Coordinate object at 0x107142f10> # ①
>>> location = Coordinate(55.76, 37.62)
>>> location == moscow # ②
False >>> (location.lat, location.lon) == (moscow.lat, moscow.lon) # ③
True
①
从object继承的__repr__并不是很有用。
②
无意义的==;从object继承的__eq__方法比较对象 ID。
③
比较两个坐标需要显式比较每个属性。
本章涵盖的数据类构建器会自动提供必要的__init__、__repr__和__eq__方法,以及其他有用的功能。
注意
这里讨论的类构建器都不依赖继承来完成工作。collections.namedtuple和typing.NamedTuple都构建了tuple子类的类。@dataclass是一个类装饰器,不会以任何方式影响类层次结构。它们每个都使用不同的元编程技术将方法和数据属性注入到正在构建的类中。
这里是一个使用namedtuple构建的Coordinate类——一个工厂函数,根据您指定的名称和字段构建tuple的子类:
>>> from collections import namedtuple
>>> Coordinate = namedtuple('Coordinate', 'lat lon')
>>> issubclass(Coordinate, tuple)
True >>> moscow = Coordinate(55.756, 37.617)
>>> moscow
Coordinate(lat=55.756, lon=37.617) # ①
>>> moscow == Coordinate(lat=55.756, lon=37.617) # ②
True
①
有用的__repr__。
②
有意义的__eq__。
较新的typing.NamedTuple提供了相同的功能,为每个字段添加了类型注释:
>>> import typing
>>> Coordinate = typing.NamedTuple('Coordinate',
... [('lat', float), ('lon', float)])
>>> issubclass(Coordinate, tuple)
True
>>> typing.get_type_hints(Coordinate)
{'lat': <class 'float'>, 'lon': <class 'float'>}
提示
一个带有字段作为关键字参数构造的类型命名元组也可以这样创建:
Coordinate = typing.NamedTuple('Coordinate', lat=float, lon=float)
这更易读,也让您提供字段和类型的映射作为 **fields_and_types。
自 Python 3.6 起,typing.NamedTuple 也可以在 class 语句中使用,类型注解的写法如 PEP 526—变量注解的语法 中描述的那样。这样更易读,也方便重写方法或添加新方法。示例 5-2 是相同的 Coordinate 类,具有一对 float 属性和一个自定义的 __str__ 方法,以显示格式为 55.8°N, 37.6°E 的坐标。
示例 5-2. typing_namedtuple/coordinates.py
from typing import NamedTuple
class Coordinate(NamedTuple):
lat: float
lon: float
def __str__(self):
ns = 'N' if self.lat >= 0 else 'S'
we = 'E' if self.lon >= 0 else 'W'
return f'{abs(self.lat):.1f}°{ns}, {abs(self.lon):.1f}°{we}'
警告
尽管 NamedTuple 在 class 语句中出现为超类,但实际上并非如此。typing.NamedTuple 使用元类的高级功能² 来自定义用户类的创建。看看这个:
>>> issubclass(Coordinate, typing.NamedTuple)
False
>>> issubclass(Coordinate, tuple)
True
在 typing.NamedTuple 生成的 __init__ 方法中,字段按照在 class 语句中出现的顺序作为参数出现。
像 typing.NamedTuple 一样,dataclass 装饰器支持 PEP 526 语法来声明实例属性。装饰器读取变量注解并自动生成类的方法。为了对比,可以查看使用 dataclass 装饰器编写的等效 Coordinate 类,如 示例 5-3 中所示。
示例 5-3. dataclass/coordinates.py
from dataclasses import dataclass
@dataclass(frozen=True)
class Coordinate:
lat: float
lon: float
def __str__(self):
ns = 'N' if self.lat >= 0 else 'S'
we = 'E' if self.lon >= 0 else 'W'
return f'{abs(self.lat):.1f}°{ns}, {abs(self.lon):.1f}°{we}'
请注意,示例 5-2 和 示例 5-3 中的类主体是相同的——区别在于 class 语句本身。@dataclass 装饰器不依赖于继承或元类,因此不应干扰您对这些机制的使用。³ 示例 5-3 中的 Coordinate 类是 object 的子类。
主要特点
不同的数据类构建器有很多共同点,如 表 5-1 所总结的。
表 5-1. 三种数据类构建器之间的选定特点比较;x 代表该类型数据类的一个实例
| namedtuple | NamedTuple | dataclass | |
|---|---|---|---|
| 可变实例 | 否 | 否 | 是 |
| class 语句语法 | 否 | 是 | 是 |
| 构造字典 | x._asdict() | x._asdict() | dataclasses.asdict(x) |
| 获取字段名 | x._fields | x._fields | [f.name for f in dataclasses.fields(x)] |
| 获取默认值 | x._field_defaults | x._field_defaults | [f.default for f in dataclasses.fields(x)] |
| 获取字段类型 | 不适用 | x.annotations | x.annotations |
| 使用更改创建新实例 | x._replace(…) | x._replace(…) | dataclasses.replace(x, …) |
| 运行时新类 | namedtuple(…) | NamedTuple(…) | dataclasses.make_dataclass(…) |
警告
typing.NamedTuple 和 @dataclass 构建的类具有一个 __annotations__ 属性,其中包含字段的类型提示。然而,不建议直接从 __annotations__ 中读取。相反,获取该信息的推荐最佳实践是调用 inspect.get_annotations(MyClass)(Python 3.10 中添加)或 typing.get_type_hints(MyClass)(Python 3.5 到 3.9)。这是因为这些函数提供额外的服务,如解析类型提示中的前向引用。我们将在本书的后面更详细地讨论这个问题,在 “运行时注解问题” 中。
现在让我们讨论这些主要特点。
可变实例
这些类构建器之间的一个关键区别是,collections.namedtuple 和 typing.NamedTuple 构建 tuple 的子类,因此实例是不可变的。默认情况下,@dataclass 生成可变类。但是,装饰器接受一个关键字参数 frozen—如 示例 5-3 中所示。当 frozen=True 时,如果尝试在初始化实例后为字段分配值,类将引发异常。
类语句语法
只有typing.NamedTuple和dataclass支持常规的class语句语法,这样可以更容易地向正在创建的类添加方法和文档字符串。
构造字典
这两种命名元组变体都提供了一个实例方法(._asdict),用于从数据类实例中的字段构造一个dict对象。dataclasses模块提供了一个执行此操作的函数:dataclasses.asdict。
获取字段名称和默认值
所有三种类构建器都允许您获取字段名称和可能为其配置的默认值。在命名元组类中,这些元数据位于._fields和._fields_defaults类属性中。您可以使用dataclasses模块中的fields函数从装饰的dataclass类中获取相同的元数据。它返回一个具有多个属性的Field对象的元组,包括name和default。
获取字段类型
使用typing.NamedTuple和@dataclass帮助定义的类具有字段名称到类型的映射__annotations__类属性。如前所述,使用typing.get_type_hints函数而不是直接读取__annotations__。
具有更改的新实例
给定一个命名元组实例x,调用x._replace(**kwargs)将返回一个根据给定关键字参数替换了一些属性值的新实例。dataclasses.replace(x, **kwargs)模块级函数对于dataclass装饰的类的实例也是如此。
运行时新类
尽管class语句语法更易读,但它是硬编码的。一个框架可能需要在运行时动态构建数据类。为此,您可以使用collections.namedtuple的默认函数调用语法,该语法同样受到typing.NamedTuple的支持。dataclasses模块提供了一个make_dataclass函数来实现相同的目的。
在对数据类构建器的主要特性进行概述之后,让我们依次专注于每个特性,从最简单的开始。
经典的命名元组
collections.namedtuple函数是一个工厂,构建了增强了字段名称、类名和信息性__repr__的tuple子类。使用namedtuple构建的类可以在需要元组的任何地方使用,并且实际上,Python 标准库的许多函数现在用于返回元组的地方现在返回命名元组以方便使用,而不会对用户的代码产生任何影响。
提示
由namedtuple构建的类的每个实例占用的内存量与元组相同,因为字段名称存储在类中。
示例 5-4 展示了我们如何定义一个命名元组来保存有关城市信息的示例。
示例 5-4. 定义和使用命名元组类型
>>> from collections import namedtuple
>>> City = namedtuple('City', 'name country population coordinates') # ①
>>> tokyo = City('Tokyo', 'JP', 36.933, (35.689722, 139.691667)) # ②
>>> tokyo
City(name='Tokyo', country='JP', population=36.933, coordinates=(35.689722, 139.691667)) >>> tokyo.population # ③
36.933 >>> tokyo.coordinates
(35.689722, 139.691667) >>> tokyo[1]
'JP'
①
创建命名元组需要两个参数:一个类名和一个字段名称列表,可以作为字符串的可迭代对象或作为单个以空格分隔的字符串给出。
②
字段值必须作为单独的位置参数传递给构造函数(相反,tuple构造函数接受一个单一的可迭代对象)。
③
你可以通过名称或位置访问这些字段。
作为tuple子类,City继承了一些有用的方法,比如__eq__和用于比较运算符的特殊方法,包括__lt__,它允许对City实例的列表进行排序。
除了从元组继承的属性和方法外,命名元组还提供了一些额外的属性和方法。示例 5-5 展示了最有用的:_fields类属性,类方法_make(iterable)和_asdict()实例方法。
示例 5-5. 命名元组属性和方法(继续自上一个示例)
>>> City._fields # ①
('name', 'country', 'population', 'location') >>> Coordinate = namedtuple('Coordinate', 'lat lon')
>>> delhi_data = ('Delhi NCR', 'IN', 21.935, Coordinate(28.613889, 77.208889))
>>> delhi = City._make(delhi_data) # ②
>>> delhi._asdict() # ③
{'name': 'Delhi NCR', 'country': 'IN', 'population': 21.935, 'location': Coordinate(lat=28.613889, lon=77.208889)} >>> import json
>>> json.dumps(delhi._asdict()) # ④
'{"name": "Delhi NCR", "country": "IN", "population": 21.935, "location": [28.613889, 77.208889]}'
①
._fields 是一个包含类的字段名称的元组。
②
._make() 从可迭代对象构建 City;City(*delhi_data) 将执行相同操作。
③
._asdict() 返回从命名元组实例构建的 dict。
④
._asdict() 对于将数据序列化为 JSON 格式非常有用,例如。
警告
直到 Python 3.7,_asdict 方法返回一个 OrderedDict。自 Python 3.8 起,它返回一个简单的 dict——现在我们可以依赖键插入顺序了。如果你一定需要一个 OrderedDict,_asdict 文档建议从结果构建一个:OrderedDict(x._asdict())。
自 Python 3.7 起,namedtuple 接受 defaults 关键字参数,为类的 N 个最右字段的每个字段提供一个默认值的可迭代对象。示例 5-6 展示了如何为 reference 字段定义一个带有默认值的 Coordinate 命名元组。
示例 5-6。命名元组属性和方法,继续自示例 5-5
>>> Coordinate = namedtuple('Coordinate', 'lat lon reference', defaults=['WGS84'])
>>> Coordinate(0, 0)
Coordinate(lat=0, lon=0, reference='WGS84')
>>> Coordinate._field_defaults
{'reference': 'WGS84'}
在“类语句语法”中,我提到使用 typing.NamedTuple 和 @dataclass 支持的类语法更容易编写方法。你也可以向 namedtuple 添加方法,但这是一种 hack。如果你对 hack 不感兴趣,可以跳过下面的框。
现在让我们看看 typing.NamedTuple 的变化。
带类型的命名元组
Coordinate 类与示例 5-6 中的默认字段可以使用 typing.NamedTuple 编写,如示例 5-8 所示。
示例 5-8。typing_namedtuple/coordinates2.py
from typing import NamedTuple
class Coordinate(NamedTuple):
lat: float # ①
lon: float
reference: str = 'WGS84' # ②
①
每个实例字段都必须带有类型注释。
②
reference 实例字段带有类型和默认值的注释。
由 typing.NamedTuple 构建的类除了那些 collections.namedtuple 生成的方法和从 tuple 继承的方法外,没有任何其他方法。唯一的区别是存在 __annotations__ 类属性——Python 在运行时完全忽略它。
鉴于 typing.NamedTuple 的主要特点是类型注释,我们将在继续探索数据类构建器之前简要介绍它们。
类型提示 101
类型提示,又称类型注释,是声明函数参数、返回值、变量和属性预期类型的方式。
你需要了解的第一件事是,类型提示完全不受 Python 字节码编译器和解释器的强制执行。
注意
这是对类型提示的非常简要介绍,足以理解 typing.NamedTuple 和 @dataclass 声明中使用的注释的语法和含义。我们将在第八章中介绍函数签名的类型提示,以及在第十五章中介绍更高级的注释。在这里,我们将主要看到使用简单内置类型的提示,比如 str、int 和 float,这些类型可能是用于注释数据类字段的最常见类型。
无运行时效果
将 Python 类型提示视为“可以由 IDE 和类型检查器验证的文档”。
这是因为类型提示对 Python 程序的运行时行为没有影响。查看示例 5-9。
示例 5-9。Python 不会在运行时强制执行类型提示
>>> import typing
>>> class Coordinate(typing.NamedTuple):
... lat: float
... lon: float
...
>>> trash = Coordinate('Ni!', None)
>>> print(trash)
Coordinate(lat='Ni!', lon=None) # ①
①
我告诉过你:运行时不进行类型检查!
如果你在 Python 模块中键入示例 5-9 的代码,它将运行并显示一个无意义的 Coordinate,没有错误或警告:
$ python3 nocheck_demo.py
Coordinate(lat='Ni!', lon=None)
类型提示主要用于支持第三方类型检查器,如Mypy或PyCharm IDE内置的类型检查器。这些是静态分析工具:它们检查 Python 源代码“静止”,而不是运行代码。
要看到类型提示的效果,你必须在你的代码上运行其中一个工具—比如一个检查器。例如,这是 Mypy 对前面示例的看法:
$ mypy nocheck_demo.py
nocheck_demo.py:8: error: Argument 1 to "Coordinate" has
incompatible type "str"; expected "float"
nocheck_demo.py:8: error: Argument 2 to "Coordinate" has
incompatible type "None"; expected "float"
正如你所看到的,鉴于Coordinate的定义,Mypy 知道创建实例的两个参数必须是float类型,但对trash的赋值使用了str和None。⁵
现在让我们谈谈类型提示的语法和含义。
变量注释语法
typing.NamedTuple和@dataclass都使用在PEP 526中定义的变量注释语法。这是在class语句中定义属性的上下文中对该语法的快速介绍。
变量注释的基本语法是:
var_name: some_type
PEP 484 中的“可接受的类型提示”部分解释了什么是可接受的类型,但在定义数据类的上下文中,这些类型更有可能有用:
-
一个具体的类,例如,
str或FrenchDeck -
一个参数化的集合类型,如
list[int],tuple[str, float],等等。 -
typing.Optional,例如,Optional[str]—声明一个可以是str或None的字段
你也可以用一个值初始化变量。在typing.NamedTuple或@dataclass声明中,如果在构造函数调用中省略了相应的参数,那个值将成为该属性的默认值:
var_name: some_type = a_value
变量注释的含义
我们在“无运行时效果”中看到类型提示在运行时没有效果。但在导入时—模块加载时—Python 会读取它们以构建__annotations__字典,然后typing.NamedTuple和@dataclass会使用它们来增强类。
我们将从示例 5-10 中的一个简单类开始这个探索,这样我们以后可以看到typing.NamedTuple和@dataclass添加的额外功能。
示例 5-10. meaning/demo_plain.py:带有类型提示的普通类
class DemoPlainClass:
a: int # ①
b: float = 1.1 # ②
c = 'spam' # ③
①
a成为__annotations__中的一个条目,但在类中不会创建名为a的属性。
②
b被保存为注释,并且也成为一个具有值1.1的类属性。
③
c只是一个普通的类属性,不是一个注释。
我们可以在控制台中验证,首先读取DemoPlainClass的__annotations__,然后尝试获取其名为a、b和c的属性:
>>> from demo_plain import DemoPlainClass
>>> DemoPlainClass.__annotations__
{'a': <class 'int'>, 'b': <class 'float'>}
>>> DemoPlainClass.a
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: type object 'DemoPlainClass' has no attribute 'a'
>>> DemoPlainClass.b
1.1
>>> DemoPlainClass.c
'spam'
注意,__annotations__特殊属性是由解释器创建的,用于记录源代码中出现的类型提示—即使在一个普通类中也是如此。
a只作为一个注释存在。它不会成为一个类属性,因为没有值与它绑定。⁶ b和c作为类属性存储,因为它们绑定了值。
这三个属性都不会出现在DemoPlainClass的新实例中。如果你创建一个对象o = DemoPlainClass(),o.a会引发AttributeError,而o.b和o.c将检索具有值1.1和'spam'的类属性——这只是正常的 Python 对象行为。
检查一个typing.NamedTuple
现在让我们检查一个使用与示例 5-10 中DemoPlainClass相同属性和注释构建的类,该类使用typing.NamedTuple(示例 5-11)。
示例 5-11. meaning/demo_nt.py:使用typing.NamedTuple构建的类
import typing
class DemoNTClass(typing.NamedTuple):
a: int # ①
b: float = 1.1 # ②
c = 'spam' # ③
①
a成为一个注释,也成为一个实例属性。
②
b是另一个注释,也成为一个具有默认值1.1的实例属性。
③
c只是一个普通的类属性;没有注释会引用它。
检查DemoNTClass,我们得到:
>>> from demo_nt import DemoNTClass
>>> DemoNTClass.__annotations__
{'a': <class 'int'>, 'b': <class 'float'>}
>>> DemoNTClass.a
<_collections._tuplegetter object at 0x101f0f940>
>>> DemoNTClass.b
<_collections._tuplegetter object at 0x101f0f8b0>
>>> DemoNTClass.c
'spam'
这里我们对a和b的注释与我们在示例 5-10 中看到的相同。但是typing.NamedTuple创建了a和b类属性。c属性只是一个具有值'spam'的普通类属性。
a和b类属性是描述符,这是第二十三章中介绍的一个高级特性。现在,将它们视为类似于属性获取器的属性:这些方法不需要显式调用运算符()来检索实例属性。实际上,这意味着a和b将作为只读实例属性工作——当我们回想起DemoNTClass实例只是一种花哨的元组,而元组是不可变的时,这是有道理的。
DemoNTClass也有一个自定义的文档字符串:
>>> DemoNTClass.__doc__
'DemoNTClass(a, b)'
让我们检查DemoNTClass的一个实例:
>>> nt = DemoNTClass(8)
>>> nt.a
8
>>> nt.b
1.1
>>> nt.c
'spam'
要构造nt,我们至少需要将a参数传递给DemoNTClass。构造函数还接受一个b参数,但它有一个默认值1.1,所以是可选的。nt对象具有预期的a和b属性;它没有c属性,但 Python 会像往常一样从类中检索它。
如果尝试为nt.a、nt.b、nt.c甚至nt.z分配值,您将收到略有不同的错误消息的AttributeError异常。尝试一下并思考这些消息。
检查使用 dataclass 装饰的类
现在,我们将检查示例 5-12。
示例 5-12. meaning/demo_dc.py:使用@dataclass装饰的类
from dataclasses import dataclass
@dataclass
class DemoDataClass:
a: int # ①
b: float = 1.1 # ②
c = 'spam' # ③
①
a变成了一个注释,也是由描述符控制的实例属性。
②
b是另一个注释,也成为一个具有描述符和默认值1.1的实例属性。
③
c只是一个普通的类属性;没有注释会引用它。
现在让我们检查DemoDataClass上的__annotations__、__doc__和a、b、c属性:
>>> from demo_dc import DemoDataClass
>>> DemoDataClass.__annotations__
{'a': <class 'int'>, 'b': <class 'float'>}
>>> DemoDataClass.__doc__
'DemoDataClass(a: int, b: float = 1.1)'
>>> DemoDataClass.a
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: type object 'DemoDataClass' has no attribute 'a'
>>> DemoDataClass.b
1.1
>>> DemoDataClass.c
'spam'
__annotations__和__doc__并不奇怪。然而,在DemoDataClass中没有名为a的属性——与示例 5-11 中的DemoNTClass相反,后者具有一个描述符来从实例中获取a作为只读属性(那个神秘的<_collections._tuplegetter>)。这是因为a属性只会存在于DemoDataClass的实例中。它将是一个公共属性,我们可以获取和设置,除非类被冻结。但是b和c存在为类属性,b保存了b实例属性的默认值,而c只是一个不会绑定到实例的类属性。
现在让我们看看DemoDataClass实例的外观:
>>> dc = DemoDataClass(9)
>>> dc.a
9
>>> dc.b
1.1
>>> dc.c
'spam'
再次,a和b是实例属性,c是我们通过实例获取的类属性。
如前所述,DemoDataClass实例是可变的—并且在运行时不进行类型检查:
>>> dc.a = 10
>>> dc.b = 'oops'
我们甚至可以做更愚蠢的赋值:
>>> dc.c = 'whatever'
>>> dc.z = 'secret stash'
现在dc实例有一个c属性—但这并不会改变c类属性。我们可以添加一个新的z属性。这是正常的 Python 行为:常规实例可以有自己的属性,这些属性不会出现在类中。⁷
关于 @dataclass 的更多信息
到目前为止,我们只看到了@dataclass的简单示例。装饰器接受几个关键字参数。这是它的签名:
@dataclass(*, init=True, repr=True, eq=True, order=False,
unsafe_hash=False, frozen=False)
第一个位置的*表示剩余参数只能通过关键字传递。表格 5-2 描述了这些参数。
表格 5-2. @dataclass装饰器接受的关键字参数
| 选项 | 含义 | 默认值 | 注释 |
|---|---|---|---|
init |
生成__init__ |
True |
如果用户实现了__init__,则忽略。 |
repr |
生成__repr__ |
True |
如果用户实现了__repr__,则忽略。 |
eq |
生成__eq__ |
True |
如果用户实现了__eq__,则忽略。 |
order |
生成__lt__、__le__、__gt__、__ge__ |
False |
如果为True,则在eq=False时引发异常,或者如果定义或继承了将要生成的任何比较方法。 |
unsafe_hash |
生成__hash__ |
False |
复杂的语义和几个注意事项—参见:数据类文档。 |
frozen |
使实例“不可变” | False |
实例将相对安全免受意外更改,但实际上并非不可变。^(a) |
^(a) @dataclass通过生成__setattr__和__delattr__来模拟不可变性,当用户尝试设置或删除字段时,会引发dataclass.FrozenInstanceError—AttributeError的子类。 |
默认设置实际上是最常用的常见用例的最有用设置。你更有可能从默认设置中更改的选项是:
frozen=True
防止对类实例的意外更改。
order=True
允许对数据类的实例进行排序。
鉴于 Python 对象的动态特性,一个好奇的程序员很容易绕过frozen=True提供的保护。但是在代码审查中,这些必要的技巧应该很容易被发现。
如果eq和frozen参数都为True,@dataclass会生成一个合适的__hash__方法,因此实例将是可散列的。生成的__hash__将使用所有未被单独排除的字段数据,使用我们将在“字段选项”中看到的字段选项。如果frozen=False(默认值),@dataclass将将__hash__设置为None,表示实例是不可散列的,因此覆盖了任何超类的__hash__。
PEP 557—数据类对unsafe_hash有如下说明:
虽然不建议这样做,但你可以通过
unsafe_hash=True强制数据类创建一个__hash__方法。如果你的类在逻辑上是不可变的,但仍然可以被改变,这可能是一个特殊的用例,应该仔细考虑。
我会保留unsafe_hash。如果你觉得必须使用该选项,请查看dataclasses.dataclass文档。
可以在字段级别进一步定制生成的数据类。
字段选项
我们已经看到了最基本的字段选项:使用类型提示提供(或不提供)默认值。你声明的实例字段将成为生成的__init__中的参数。Python 不允许在具有默认值的参数之后使用没有默认值的参数,因此在声明具有默认值的字段之后,所有剩余字段必须也具有默认值。
可变默认值是初学 Python 开发者常见的错误来源。在函数定义中,当函数的一个调用改变了默认值时,易变默认值很容易被破坏,从而改变了后续调用的行为——这是我们将在“可变类型作为参数默认值:不好的想法”中探讨的问题(第六章)。类属性经常被用作实例的默认属性值,包括在数据类中。@dataclass使用类型提示中的默认值生成带有默认值的参数供__init__使用。为了防止错误,@dataclass拒绝了示例 5-13 中的类定义。
示例 5-13. dataclass/club_wrong.py:这个类会引发ValueError。
@dataclass
class ClubMember:
name: str
guests: list = []
如果加载了具有ClubMember类的模块,你会得到这个:
$ python3 club_wrong.py
Traceback (most recent call last):
File "club_wrong.py", line 4, in <module>
class ClubMember:
...several lines omitted...
ValueError: mutable default <class 'list'> for field guests is not allowed:
use default_factory
ValueError消息解释了问题并建议解决方案:使用default_factory。示例 5-14 展示了如何纠正ClubMember。
示例 5-14. dataclass/club.py:这个ClubMember定义可行
from dataclasses import dataclass, field
@dataclass
class ClubMember:
name: str
guests: list = field(default_factory=list)
在示例 5-14 的guests字段中,不是使用字面列表作为默认值,而是通过调用dataclasses.field函数并使用default_factory=list来设置默认值。
default_factory参数允许你提供一个函数、类或任何其他可调用对象,每次创建数据类的实例时都会调用它以构建默认值。这样,ClubMember的每个实例都将有自己的list,而不是所有实例共享来自类的相同list,这很少是我们想要的,通常是一个错误。
警告
很好的是@dataclass拒绝了具有list默认值的字段的类定义。但是,请注意,这是一个部分解决方案,仅适用于list、dict和set。其他用作默认值的可变值不会被@dataclass标记。你需要理解问题并记住使用默认工厂来设置可变默认值。
如果你浏览dataclasses模块文档,你会看到一个用新语法定义的list字段,就像示例 5-15 中一样。
示例 5-15. dataclass/club_generic.py:这个ClubMember定义更加精确
from dataclasses import dataclass, field
@dataclass
class ClubMember:
name: str
guests: list[str] = field(default_factory=list) # ①
①
list[str]表示“一个str的列表”。
新的语法list[str]是一个参数化的泛型类型:自 Python 3.9 以来,list内置接受方括号表示法来指定列表项的类型。
警告
在 Python 3.9 之前,内置的集合不支持泛型类型表示法。作为临时解决方法,在typing模块中有相应的集合类型。如果你需要在 Python 3.8 或更早版本中使用参数化的list类型提示,你必须导入typing中的List类型并使用它:List[str]。有关此问题的更多信息,请参阅“遗留支持和已弃用的集合类型”。
我们将在第八章中介绍泛型。现在,请注意示例 5-14 和 5-15 都是正确的,Mypy 类型检查器不会对这两个类定义提出任何异议。
区别在于guests: list表示guests可以是任何类型对象的list,而guests: list[str]表示guests必须是每个项都是str的list。这将允许类型检查器在将无效项放入列表的代码中找到(一些)错误,或者从中读取项。
default_factory 很可能是field函数的最常见选项,但还有其他几个选项,列在表 5-3 中。
表 5-3. field函数接受的关键字参数
| 选项 | 含义 | 默认值 |
|---|---|---|
default |
字段的默认值 | _MISSING_TYPE^(a) |
default_factory |
用于生成默认值的 0 参数函数 | _MISSING_TYPE |
init |
在__init__参数中包含字段 |
True |
repr |
在__repr__中包含字段 |
True |
compare |
在比较方法__eq__、__lt__等中使用字段 |
True |
hash |
在__hash__计算中包含字段 |
None^(b) |
metadata |
具有用户定义数据的映射;被@dataclass忽略 |
None |
^(a) dataclass._MISSING_TYPE 是一个标志值,表示未提供选项。它存在的原因是我们可以将None设置为实际的默认值,这是一个常见用例。^(b) 选项hash=None表示只有在compare=True时,该字段才会在__hash__中使用。 |
default选项的存在是因为field调用取代了字段注释中的默认值。如果要创建一个默认值为False的athlete字段,并且还要在__repr__方法中省略该字段,你可以这样写:
@dataclass
class ClubMember:
name: str
guests: list = field(default_factory=list)
athlete: bool = field(default=False, repr=False)
后初始化处理
由 @dataclass 生成的 __init__ 方法只接受传递的参数并将它们分配给实例字段的实例属性,或者如果缺少参数,则分配它们的默认值。但您可能需要做的不仅仅是这些来初始化实例。如果是这种情况,您可以提供一个 __post_init__ 方法。当存在该方法时,@dataclass 将在生成的 __init__ 中添加代码,以调用 __post_init__ 作为最后一步。
__post_init__ 的常见用例是验证和基于其他字段计算字段值。我们将学习一个简单的示例,该示例使用 __post_init__ 来实现这两个目的。
首先,让我们看看名为 HackerClubMember 的 ClubMember 子类的预期行为,如 示例 5-16 中的文档测试所描述。
示例 5-16. dataclass/hackerclub.py: HackerClubMember 的文档测试
"""
``HackerClubMember`` objects accept an optional ``handle`` argument::
>>> anna = HackerClubMember('Anna Ravenscroft', handle='AnnaRaven')
>>> anna
HackerClubMember(name='Anna Ravenscroft', guests=[], handle='AnnaRaven')
If ``handle`` is omitted, it's set to the first part of the member's name::
>>> leo = HackerClubMember('Leo Rochael')
>>> leo
HackerClubMember(name='Leo Rochael', guests=[], handle='Leo')
Members must have a unique handle. The following ``leo2`` will not be created,
because its ``handle`` would be 'Leo', which was taken by ``leo``::
>>> leo2 = HackerClubMember('Leo DaVinci')
Traceback (most recent call last):
...
ValueError: handle 'Leo' already exists.
To fix, ``leo2`` must be created with an explicit ``handle``::
>>> leo2 = HackerClubMember('Leo DaVinci', handle='Neo')
>>> leo2
HackerClubMember(name='Leo DaVinci', guests=[], handle='Neo')
"""
请注意,我们必须将 handle 作为关键字参数提供,因为 HackerClubMember 继承自 ClubMember 的 name 和 guests,并添加了 handle 字段。生成的 HackerClubMember 的文档字符串显示了构造函数调用中字段的顺序:
>>> HackerClubMember.__doc__
"HackerClubMember(name: str, guests: list = <factory>, handle: str = '')"
这里,<factory> 是指某个可调用对象将为 guests 生成默认值的简便方式(在我们的例子中,工厂是 list 类)。关键是:要提供一个 handle 但没有 guests,我们必须将 handle 作为关键字参数传递。
dataclasses 模块文档中的“继承”部分 解释了在存在多级继承时如何计算字段的顺序。
注意
在 第十四章 中,我们将讨论错误使用继承,特别是当超类不是抽象类时。创建数据类的层次结构通常不是一个好主意,但在这里,它帮助我们缩短了 示例 5-17 的长度,侧重于 handle 字段声明和 __post_init__ 验证。
示例 5-17 展示了实现方式。
示例 5-17. dataclass/hackerclub.py: HackerClubMember 的代码
from dataclasses import dataclass
from club import ClubMember
@dataclass
class HackerClubMember(ClubMember): # ①
all_handles = set() # ②
handle: str = '' # ③
def __post_init__(self):
cls = self.__class__ # ④
if self.handle == '': # ⑤
self.handle = self.name.split()[0]
if self.handle in cls.all_handles: # ⑥
msg = f'handle {self.handle!r} already exists.'
raise ValueError(msg)
cls.all_handles.add(self.handle) # ⑦
①
HackerClubMember 扩展了 ClubMember。
②
all_handles 是一个类属性。
③
handle 是一个类型为 str 的实例字段,其默认值为空字符串;这使其成为可选的。
④
获取实例的类。
⑤
如果 self.handle 是空字符串,则将其设置为 name 的第一部分。
⑥
如果 self.handle 在 cls.all_handles 中,则引发 ValueError。
⑦
将新的 handle 添加到 cls.all_handles。
示例 5-17 的功能正常,但对于静态类型检查器来说并不令人满意。接下来,我们将看到原因以及如何解决。
类型化类属性
如果我们使用 Mypy 对 示例 5-17 进行类型检查,我们会受到批评:
$ mypy hackerclub.py
hackerclub.py:37: error: Need type annotation for "all_handles"
(hint: "all_handles: Set[<type>] = ...")
Found 1 error in 1 file (checked 1 source file)
不幸的是,Mypy 提供的提示(我在审阅时使用的版本是 0.910)在 @dataclass 使用的上下文中并不有用。首先,它建议使用 Set,但我使用的是 Python 3.9,因此可以使用 set,并避免从 typing 导入 Set。更重要的是,如果我们向 all_handles 添加一个类型提示,如 set[…],@dataclass 将找到该注释,并将 all_handles 变为实例字段。我们在“检查使用 dataclass 装饰的类”中看到了这种情况。
在 PEP 526—变量注释的语法 中定义的解决方法很丑陋。为了编写带有类型提示的类变量,我们需要使用一个名为 typing.ClassVar 的伪类型,它利用泛型 [] 符号来设置变量的类型,并声明它为类属性。
为了让类型检查器和 @dataclass 满意,我们应该在 示例 5-17 中这样声明 all_handles:
all_handles: ClassVar[set[str]] = set()
那个类型提示表示:
all_handles是一个类型为set-of-str的类属性,其默认值为空set。
要编写该注释的代码,我们必须从 typing 模块导入 ClassVar。
@dataclass 装饰器不关心注释中的类型,除了两种情况之一,这就是其中之一:如果类型是 ClassVar,则不会为该属性生成实例字段。
在声明仅初始化变量时,字段类型对 @dataclass 有影响的另一种情况是我们接下来要讨论的。
不是字段的初始化变量
有时,您可能需要向 __init__ 传递不是实例字段的参数。这些参数被 dataclasses 文档 称为仅初始化变量。要声明这样的参数,dataclasses 模块提供了伪类型 InitVar,其使用与 typing.ClassVar 相同的语法。文档中给出的示例是一个数据类,其字段从数据库初始化,并且必须将数据库对象传递给构造函数。
示例 5-18 展示了说明“仅初始化变量”部分的代码。
示例 5-18. 来自 dataclasses 模块文档的示例
@dataclass
class C:
i: int
j: int = None
database: InitVar[DatabaseType] = None
def __post_init__(self, database):
if self.j is None and database is not None:
self.j = database.lookup('j')
c = C(10, database=my_database)
注意 database 属性的声明方式。InitVar 将阻止 @dataclass 将 database 视为常规字段。它不会被设置为实例属性,并且 dataclasses.fields 函数不会列出它。但是,database 将是生成的 __init__ 将接受的参数之一,并且也将传递给 __post_init__。如果您编写该方法,必须在方法签名中添加相应的参数,如示例 5-18 中所示。
这个相当长的 @dataclass 概述涵盖了最有用的功能——其中一些出现在之前的部分中,比如“主要特性”,在那里我们并行讨论了所有三个数据类构建器。dataclasses 文档 和 PEP 526—变量注释的语法 中有所有细节。
在下一节中,我将展示一个更长的示例,使用 @dataclass。
@dataclass 示例:Dublin Core 资源记录
经常使用 @dataclass 构建的类将具有比目前呈现的非常简短示例更多的字段。Dublin Core 为一个更典型的 @dataclass 示例提供了基础。
Dublin Core Schema 是一组可以用于描述数字资源(视频、图像、网页等)以及实体资源(如书籍或 CD)和艺术品等对象的词汇术语。⁸
维基百科上的 Dublin Core
标准定义了 15 个可选字段;示例 5-19 中的 Resource 类使用了其中的 8 个。
示例 5-19. dataclass/resource.py: Resource 类的代码,基于 Dublin Core 术语
from dataclasses import dataclass, field
from typing import Optional
from enum import Enum, auto
from datetime import date
class ResourceType(Enum): # ①
BOOK = auto()
EBOOK = auto()
VIDEO = auto()
@dataclass
class Resource:
"""Media resource description."""
identifier: str # ②
title: str = '<untitled>' # ③
creators: list[str] = field(default_factory=list)
date: Optional[date] = None # ④
type: ResourceType = ResourceType.BOOK # ⑤
description: str = ''
language: str = ''
subjects: list[str] = field(default_factory=list)
①
这个 Enum 将为 Resource.type 字段提供类型安全的值。
②
identifier 是唯一必需的字段。
③
title 是第一个具有默认值的字段。这迫使下面的所有字段都提供默认值。
④
date 的值可以是 datetime.date 实例,或者是 None。
⑤
type 字段的默认值是 ResourceType.BOOK。
示例 5-20 展示了一个 doctest,演示了代码中 Resource 记录的外观。
示例 5-20. dataclass/resource.py: Resource 类的代码,基于 Dublin Core 术语
>>> description = 'Improving the design of existing code'
>>> book = Resource('978-0-13-475759-9', 'Refactoring, 2nd Edition',
... ['Martin Fowler', 'Kent Beck'], date(2018, 11, 19),
... ResourceType.BOOK, description, 'EN',
... ['computer programming', 'OOP'])
>>> book # doctest: +NORMALIZE_WHITESPACE
Resource(identifier='978-0-13-475759-9', title='Refactoring, 2nd Edition',
creators=['Martin Fowler', 'Kent Beck'], date=datetime.date(2018, 11, 19),
type=<ResourceType.BOOK: 1>, description='Improving the design of existing code',
language='EN', subjects=['computer programming', 'OOP'])
由 @dataclass 生成的 __repr__ 是可以的,但我们可以使其更易读。这是我们希望从 repr(book) 得到的格式:
>>> book # doctest: +NORMALIZE_WHITESPACE
Resource(
identifier = '978-0-13-475759-9',
title = 'Refactoring, 2nd Edition',
creators = ['Martin Fowler', 'Kent Beck'],
date = datetime.date(2018, 11, 19),
type = <ResourceType.BOOK: 1>,
description = 'Improving the design of existing code',
language = 'EN',
subjects = ['computer programming', 'OOP'],
)
示例 5-21 是用于生成最后代码片段中所示格式的__repr__的代码。此示例使用dataclass.fields来获取数据类字段的名称。
示例 5-21. dataclass/resource_repr.py:在示例 5-19 中实现的Resource类中实现的__repr__方法的代码
def __repr__(self):
cls = self.__class__
cls_name = cls.__name__
indent = ' ' * 4
res = [f'{cls_name}('] # ①
for f in fields(cls): # ②
value = getattr(self, f.name) # ③
res.append(f'{indent}{f.name} = {value!r},') # ④
res.append(')') # ⑤
return '\n'.join(res) # ⑥
①
开始res列表以构建包含类名和开括号的输出字符串。
②
对于类中的每个字段f…
③
…从实例中获取命名属性。
④
附加一个缩进的行,带有字段的名称和repr(value)—这就是!r的作用。
⑤
附加闭括号。
⑥
从res构建一个多行字符串并返回它。
通过这个受到俄亥俄州都柏林灵感的例子,我们结束了对 Python 数据类构建器的介绍。
数据类很方便,但如果过度使用它们,您的项目可能会受到影响。接下来的部分将进行解释。
数据类作为代码异味
无论您是通过自己编写所有代码来实现数据类,还是利用本章描述的类构建器之一,都要意识到它可能在您的设计中信号问题。
在重构:改善现有代码设计,第 2 版(Addison-Wesley)中,Martin Fowler 和 Kent Beck 提供了一个“代码异味”目录—代码中可能表明需要重构的模式。标题为“数据类”的条目开头是这样的:
这些类具有字段、获取和设置字段的方法,除此之外什么都没有。这样的类是愚蠢的数据持有者,往往被其他类以过于详细的方式操纵。
在福勒的个人网站上,有一篇标题为“代码异味”的启发性文章。这篇文章与我们的讨论非常相关,因为他将数据类作为代码异味的一个例子,并建议如何处理。以下是完整的文章。⁹
面向对象编程的主要思想是将行为和数据放在同一个代码单元中:一个类。如果一个类被广泛使用但本身没有重要的行为,那么处理其实例的代码可能分散在整个系统的方法和函数中(甚至重复)—这是维护头痛的根源。这就是为什么福勒的重构涉及将责任带回到数据类中。
考虑到这一点,有几种常见情况下,拥有一个几乎没有行为的数据类是有意义的。
数据类作为脚手架
在这种情况下,数据类是一个初始的、简单的类实现,用于启动新项目或模块。随着时间的推移,该类应该拥有自己的方法,而不是依赖其他类的方法来操作其实例。脚手架是临时的;最终,您的自定义类可能会完全独立于您用来启动它的构建器。
Python 也用于快速问题解决和实验,然后保留脚手架是可以的。
数据类作为中间表示
数据类可用于构建即将导出到 JSON 或其他交换格式的记录,或者保存刚刚导入的数据,跨越某些系统边界。Python 的数据类构建器都提供了一个方法或函数,将实例转换为普通的dict,您总是可以调用构造函数,使用作为关键字参数扩展的**的dict。这样的dict非常接近 JSON 记录。
在这种情况下,数据类实例应被视为不可变对象—即使字段是可变的,也不应在其处于这种中间形式时更改它们。如果这样做,您将失去将数据和行为紧密结合的主要优势。当导入/导出需要更改值时,您应该实现自己的构建器方法,而不是使用给定的“作为字典”方法或标准构造函数。
现在我们改变主题,看看如何编写匹配任意类实例而不仅仅是我们在“使用序列进行模式匹配”和“使用映射进行模式匹配”中看到的序列和映射的模式。
匹配类实例
类模式旨在通过类型和—可选地—属性来匹配类实例。类模式的主题可以是任何类实例,不仅仅是数据类的实例。¹⁰
类模式有三种变体:简单、关键字和位置。我们将按照这个顺序来学习它们。
简单类模式
我们已经看到了一个简单类模式作为子模式在“使用序列进行模式匹配”中的示例:
case [str(name), _, _, (float(lat), float(lon))]:
该模式匹配一个四项序列,其中第一项必须是str的实例,最后一项必须是一个包含两个float实例的 2 元组。
类模式的语法看起来像一个构造函数调用。以下是一个类模式,匹配float值而不绑定变量(如果需要,case 体可以直接引用x):
match x:
case float():
do_something_with(x)
但这很可能是您代码中的一个错误:
match x:
case float: # DANGER!!!
do_something_with(x)
在前面的示例中,case float:匹配任何主题,因为 Python 将float视为一个变量,然后将其绑定到主题。
float(x)的简单模式语法是一个特例,仅适用于列在“类模式”部分末尾的 PEP 634—结构化模式匹配:规范中的九个受祝福的内置类型:
bytes dict float frozenset int list set str tuple
在这些类中,看起来像构造函数参数的变量—例如,在我们之前看到的序列模式中的str(name)中的x—被绑定到整个主题实例或与子模式匹配的主题部分,如示例中的str(name)所示:
case [str(name), _, _, (float(lat), float(lon))]:
如果类不是这九个受祝福的内置类型之一,那么类似参数的变量表示要与该类实例的属性进行匹配的模式。
关键字类模式
要了解如何使用关键字类模式,请考虑以下City类和示例 5-22 中的五个实例。
示例 5-22. City类和一些实例
import typing
class City(typing.NamedTuple):
continent: str
name: str
country: str
cities = [
City('Asia', 'Tokyo', 'JP'),
City('Asia', 'Delhi', 'IN'),
City('North America', 'Mexico City', 'MX'),
City('North America', 'New York', 'US'),
City('South America', 'São Paulo', 'BR'),
]
给定这些定义,以下函数将返回一个亚洲城市列表:
def match_asian_cities():
results = []
for city in cities:
match city:
case City(continent='Asia'):
results.append(city)
return results
模式City(continent='Asia')匹配任何City实例,其中continent属性值等于'Asia',而不管其他属性的值如何。
如果您想收集country属性的值,您可以编写:
def match_asian_countries():
results = []
for city in cities:
match city:
case City(continent='Asia', country=cc):
results.append(cc)
return results
模式City(continent='Asia', country=cc)匹配与之前相同的亚洲城市,但现在cc变量绑定到实例的country属性。如果模式变量也称为country,这也适用:
match city:
case City(continent='Asia', country=country):
results.append(country)
关键字类模式非常易读,并且适用于具有公共实例属性的任何类,但它们有点冗长。
位置类模式在某些情况下更方便,但它们需要主题类的显式支持,我们将在下一节中看到。
位置类模式
给定示例 5-22 中的定义,以下函数将使用位置类模式返回一个亚洲城市列表:
def match_asian_cities_pos():
results = []
for city in cities:
match city:
case City('Asia'):
results.append(city)
return results
模式City('Asia')匹配任何City实例,其中第一个属性值为'Asia',而不管其他属性的值如何。
如果您要收集country属性的值,您可以编写:
def match_asian_countries_pos():
results = []
for city in cities:
match city:
case City('Asia', _, country):
results.append(country)
return results
模式City('Asia', _, country)匹配与之前相同的城市,但现在country变量绑定到实例的第三个属性。
我提到了“第一个”或“第三个”属性,但这到底是什么意思?
使City或任何类与位置模式配合工作的是一个名为__match_args__的特殊类属性的存在,这是本章中的类构建器自动创建的。这是City类中__match_args__的值:
>>> City.__match_args__
('continent', 'name', 'country')
如您所见,__match_args__声明了属性的名称,按照它们在位置模式中使用的顺序。
在“支持位置模式匹配”中,我们将编写代码为一个我们将在没有类构建器帮助的情况下创建的类定义__match_args__。
提示
您可以在模式中组合关键字和位置参数。可能列出用于匹配的实例属性中的一些,但不是全部,可能需要在模式中除了位置参数之外还使用关键字参数。
是时候进行章节总结了。
章节总结
本章的主题是数据类构建器collections.namedtuple,typing.NamedTuple和dataclasses.dataclass。我们看到,每个都从作为工厂函数参数提供的描述生成数据类,或者从class语句中生成具有类型提示的后两者。特别是,两种命名元组变体都生成tuple子类,仅添加按名称访问字段的能力,并提供一个列出字段名称的_fields类属性,作为字符串元组。
接下来,我们并排研究了三个类构建器的主要特性,包括如何将实例数据提取为dict,如何获取字段的名称和默认值,以及如何从现有实例创建新实例。
这促使我们首次研究类型提示,特别是用于注释class语句中属性的提示,使用 Python 3.6 中引入的符号,PEP 526—变量注释语法。总体而言,类型提示最令人惊讶的方面可能是它们在运行时根本没有任何影响。Python 仍然是一种动态语言。需要外部工具,如 Mypy,利用类型信息通过对源代码的静态分析来检测错误。在对 PEP 526 中的语法进行基本概述后,我们研究了在普通类和由typing.NamedTuple和@dataclass构建的类中注释的效果。
接下来,我们介绍了@dataclass提供的最常用功能以及dataclasses.field函数的default_factory选项。我们还研究了在数据类上下文中重要的特殊伪类型提示typing.ClassVar和dataclasses.InitVar。这个主题以基于 Dublin Core Schema 的示例结束,示例说明了如何使用dataclasses.fields在自定义的__repr__中迭代Resource实例的属性。
然后,我们警告可能滥用数据类,违反面向对象编程的基本原则:数据和触及数据的函数应该在同一个类中。没有逻辑的类可能是放错逻辑的迹象。
在最后一节中,我们看到了模式匹配如何与任何类的实例一起使用,而不仅仅是本章介绍的类构建器构建的类。
进一步阅读
Python 对我们涵盖的数据类构建器的标准文档非常好,并且有相当多的小例子。
对于特别的 @dataclass,PEP 557—数据类 的大部分内容都被复制到了 dataclasses 模块文档中。但 PEP 557 还有一些非常信息丰富的部分没有被复制,包括 “为什么不只使用 namedtuple?”,“为什么不只使用 typing.NamedTuple?”,以及以这个问答结束的 “原理” 部分:
在哪些情况下不适合使用数据类?
API 兼容元组或字典是必需的。需要超出 PEPs 484 和 526 提供的类型验证,或者需要值验证或转换。
Eric V. Smith,PEP 557 “原理”
在 RealPython.com 上,Geir Arne Hjelle 写了一篇非常完整的 “Python 3.7 中数据类的终极指南”。
在 PyCon US 2018 上,Raymond Hettinger 提出了 “数据类:终结所有代码生成器的代码生成器”(视频)。
对于更多功能和高级功能,包括验证,由 Hynek Schlawack 领导的 attrs 项目 在 dataclasses 出现多年之前,并提供更多功能,承诺“通过解除您实现对象协议(也称为 dunder 方法)的繁琐工作,带回编写类的乐趣。” Eric V. Smith 在 PEP 557 中承认 attrs 对 @dataclass 的影响。这可能包括 Smith 最重要的 API 决定:使用类装饰器而不是基类和/或元类来完成工作。
Glyph——Twisted 项目的创始人——在 “每个人都需要的一个 Python 库” 中写了一篇关于 attrs 的优秀介绍。attrs 文档包括 替代方案的讨论。
书籍作者、讲师和疯狂的计算机科学家 Dave Beazley 写了 cluegen,又一个数据类生成器。如果你看过 Dave 的任何演讲,你就知道他是一个从第一原则开始元编程 Python 的大师。因此,我发现从 cluegen 的 README.md 文件中了解到鼓励他编写 Python 的 @dataclass 替代方案的具体用例,以及他提出解决问题方法的哲学,与提供工具相对立:工具可能一开始使用更快,但方法更灵活,可以带你走得更远。
将 数据类 视为代码坏味道,我找到的最好的来源是 Martin Fowler 的书 重构,第二版。这个最新版本缺少了本章前言的引语,“数据类就像孩子一样……”,但除此之外,这是 Fowler 最著名的书的最佳版本,特别适合 Python 程序员,因为示例是用现代 JavaScript 编写的,这比 Java 更接近 Python——第一版的语言。
网站 Refactoring Guru 也对 数据类代码坏味道 进行了描述。
¹ 来自《重构》,第一版,第三章,“代码中的坏味道,数据类”部分,第 87 页(Addison-Wesley)。
² 元类是 第二十四章,“类元编程” 中涵盖的主题之一。
³ 类装饰器在 第二十四章,“类元编程” 中有介绍,与元类一起。两者都是超出继承可能的方式来定制类行为。
⁴ 如果你了解 Ruby,你会知道在 Ruby 程序员中,注入方法是一种众所周知但有争议的技术。在 Python 中,这并不常见,因为它不适用于任何内置类型——str,list 等。我认为这是 Python 的一个福音。
⁵ 在类型提示的背景下,None不是NoneType的单例,而是NoneType本身的别名。当我们停下来思考时,这看起来很奇怪,但符合我们的直觉,并且使函数返回注解在返回None的常见情况下更容易阅读。
⁶ Python 没有未定义的概念,这是 JavaScript 设计中最愚蠢的错误之一。感谢 Guido!
⁷ 在__init__之后设置属性会破坏“dict 工作方式的实际后果”中提到的__dict__键共享内存优化。
⁸ 来源:都柏林核心 英文维基百科文章。
⁹ 我很幸运在 Thoughtworks 有马丁·福勒作为同事,所以只用了 20 分钟就得到了他的许可。
¹⁰ 我将这部分内容放在这里,因为这是最早关注用户定义类的章节,我认为与类一起使用模式匹配太重要,不能等到书的第二部分。我的理念是:了解如何使用类比定义类更重要。
第六章:对象引用、可变性和回收
“你很伤心,”骑士焦急地说:“让我唱首歌来安慰你。[…] 这首歌的名字叫‘鳕鱼的眼睛’。”
“哦,那就是歌的名字吗?”爱丽丝试图表现出兴趣。
“不,你没理解,”骑士说,看起来有点恼火。“那就是名字的称呼。名字真的就是‘老老老人’。”
改编自刘易斯·卡罗尔,《镜中世界》
爱丽丝和骑士设定了我们在本章中将看到的基调。主题是对象和它们的名称之间的区别。一个名称不是对象;一个名称是一个独立的东西。
我们通过提出一个关于 Python 中变量的比喻来开始本章:变量是标签,而不是盒子。如果引用变量对你来说是老生常谈,这个类比可能仍然有用,如果你需要向他人解释别名问题。
然后我们讨论对象标识、值和别名的概念。元组的一个令人惊讶的特性被揭示出来:它们是不可变的,但它们的值可能会改变。这引发了对浅复制和深复制的讨论。引用和函数参数是我们接下来的主题:可变参数默认值的问题以及如何安全处理客户端传递的可变参数。
本章的最后几节涵盖了垃圾回收、del命令以及 Python 对不可变对象玩弄的一些技巧。
这是一个相当枯燥的章节,但它的主题是许多真实 Python 程序中微妙错误的核心。
本章新内容
这里涵盖的主题非常基础和稳定。在第二版中没有值得一提的变化。
我添加了一个使用is测试哨兵对象的示例,并在“选择==还是 is”的末尾警告了is运算符的误用。
这一章曾经在本书的第四部分,但我决定将其提前,因为它作为第二部分“数据结构”的结尾要比作为“面向对象习语”的开头更好。
注意
这本书第一版中关于“弱引用”的部分现在是fluentpython.com上的一篇文章。
让我们从忘掉变量就像存储数据的盒子开始。
变量不是盒子
1997 年,我在麻省理工学院参加了一门关于 Java 的暑期课程。教授琳恩·斯坦¹指出,通常的“变量就像盒子”比喻实际上阻碍了理解面向对象语言中引用变量的理解。Python 变量就像 Java 中的引用变量;一个更好的比喻是,变量视为附加到对象的名称的标签。下一个示例和图将帮助您理解为什么。
示例 6-1 是一个简单的互动,而“变量就像盒子”这个想法无法解释。图 6-1 说明了为什么盒子的比喻对于 Python 是错误的,而便利贴提供了一个有助于理解变量实际工作方式的图像。
示例 6-1。变量a和b持有对同一列表的引用,而不是列表的副本
>>> a = [1, 2, 3] # ①
>>> b = a # ②
>>> a.append(4) # ③
>>> b # ④
[1, 2, 3, 4]
①
创建一个列表[1, 2, 3],并将变量a绑定到它。
②
将变量b绑定到与a引用相同的值。
③
通过向a引用的列表追加另一个项目来修改列表。
④
你可以通过变量b看到效果。如果我们把b看作是一个盒子,里面存放着从a盒子中复制的[1, 2, 3],这种行为就毫无意义了。

图 6-1。如果你把变量想象成箱子,就无法理解 Python 中的赋值;相反,把变量想象成便利贴,示例 6-1 就变得容易解释了。
因此,b = a语句并不会复制箱子a的内容到箱子b中。它将标签b附加到已经有标签a的对象上。
Stein 教授也非常谨慎地谈到了赋值。例如,在谈论模拟中的一个跷跷板对象时,她会说:“变量s被赋给了跷跷板”,但从不说“跷跷板被赋给了变量s”。对于引用变量,更合理的说法是变量被赋给了对象,而不是反过来。毕竟,对象在赋值之前就已经创建了。示例 6-2 证明了赋值的右侧先发生。
由于动词“赋值”被以矛盾的方式使用,一个有用的替代方法是“绑定”:Python 的赋值语句x = …将x名称绑定到右侧创建或引用的对象上。对象必须在名称绑定到它之前存在,正如示例 6-2 所证明的那样。
示例 6-2。只有在对象创建后,变量才会绑定到对象上。
>>> class Gizmo:
... def __init__(self):
... print(f'Gizmo id: {id(self)}')
...
>>> x = Gizmo()
Gizmo id: 4301489152 # ①
>>> y = Gizmo() * 10 # ②
Gizmo id: 4301489432 # ③
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for *: 'Gizmo' and 'int'
>>> >>> dir() # ④
['Gizmo', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', 'x']
①
输出Gizmo id: …是创建Gizmo实例的副作用。
②
乘以Gizmo实例会引发异常。
③
这里有证据表明在尝试乘法之前实际上实例化了第二个Gizmo。
④
但变量y从未被创建,因为异常发生在赋值的右侧正在被评估时。
提示
要理解 Python 中的赋值,首先阅读右侧:那里是创建或检索对象的地方。之后,左侧的变量将绑定到对象上,就像贴在上面的标签一样。只需忘记箱子。
因为变量只是标签,所以一个对象可以有多个标签分配给它。当发生这种情况时,就会出现别名,这是我们下一个主题。
身份、相等性和别名
路易斯·卡罗尔是查尔斯·卢特维奇·道奇森教授的笔名。卡罗尔先生不仅等同于道奇森教授,他们是一体的。示例 6-3 用 Python 表达了这个想法。
示例 6-3。charles和lewis指向同一个对象。
>>> charles = {'name': 'Charles L. Dodgson', 'born': 1832}
>>> lewis = charles # ①
>>> lewis is charles
True >>> id(charles), id(lewis) # ②
(4300473992, 4300473992) >>> lewis['balance'] = 950 # ③
>>> charles
{'name': 'Charles L. Dodgson', 'born': 1832, 'balance': 950}
①
lewis是charles的别名。
②
is运算符和id函数证实了这一点。
③
向lewis添加一个项目等同于向charles添加一个项目。
然而,假设有一个冒名顶替者——我们称他为亚历山大·佩达琴科博士——声称自己是查尔斯·L·道奇森,出生于 1832 年。他的证件可能相同,但佩达琴科博士不是道奇森教授。图 6-2 说明了这种情况。

图 6-2。charles和lewis绑定到同一个对象;alex绑定到一个相等值的单独对象。
示例 6-4 实现并测试了图 6-2 中所示的alex对象。
示例 6-4。alex和charles比较相等,但alex不是charles。
>>> alex = {'name': 'Charles L. Dodgson', 'born': 1832, 'balance': 950} # ①
>>> alex == charles # ②
True >>> alex is not charles # ③
True
①
alex指的是一个与分配给charles的对象相同的对象的复制品。
②
这些对象之所以相等是因为dict类中的__eq__实现。
③
但它们是不同的对象。这是写负身份比较的 Pythonic 方式:a is not b。
示例 6-3 是别名的一个例子。在那段代码中,lewis和charles是别名:两个变量绑定到同一个对象。另一方面,alex不是charles的别名:这些变量绑定到不同的对象。绑定到alex和charles的对象具有相同的值—这是==比较的内容—但它们具有不同的身份。
在Python 语言参考中,“3.1. 对象、值和类型”中指出:
一个对象的身份一旦创建就不会改变;您可以将其视为对象在内存中的地址。
is运算符比较两个对象的身份;id()函数返回表示其身份的整数。
对象的 ID 的真正含义取决于实现。在 CPython 中,id()返回对象的内存地址,但在另一个 Python 解释器中可能是其他内容。关键点是 ID 保证是唯一的整数标签,并且在对象的生命周期内永远不会更改。
在实践中,我们编程时很少使用id()函数。通常使用is运算符进行身份检查,该运算符比较对象的 ID,因此我们的代码不需要显式调用id()。接下来,我们将讨论is与==的区别。
提示
对于技术审阅员 Leonardo Rochael,最常见使用id()的情况是在调试时,当两个对象的repr()看起来相似,但您需要了解两个引用是别名还是指向不同的对象。如果引用在不同的上下文中—比如不同的堆栈帧—使用is运算符可能不可行。
选择==和is之间
==运算符比较对象的值(它们持有的数据),而is比较它们的身份。
在编程时,我们通常更关心对象的值而不是对象的身份,因此在 Python 代码中,==比is出现得更频繁。
但是,如果您要将变量与单例进行比较,则使用is是有意义的。到目前为止,最常见的情况是检查变量是否绑定到None。这是建议的做法:
x is None
而其否定的正确方式是:
x is not None
None是我们用is测试的最常见的单例。哨兵对象是我们用is测试的另一个单例的例子。以下是创建和测试哨兵对象的一种方法:
END_OF_DATA = object()
# ... many lines
def traverse(...):
# ... more lines
if node is END_OF_DATA:
return
# etc.
is运算符比==更快,因为它无法被重载,所以 Python 不必查找和调用特殊方法来评估它,计算就像比较两个整数 ID 一样简单。相反,a == b是a.__eq__(b)的语法糖。从object继承的__eq__方法比较对象 ID,因此它产生与is相同的结果。但大多数内置类型使用更有意义的实现覆盖__eq__,实际上考虑对象属性的值。相等性可能涉及大量处理—例如,比较大型集合或深层嵌套结构时。
警告
通常我们更关心对象的相等性而不是身份。检查None是is运算符的唯一常见用例。我在审查代码时看到的大多数其他用法都是错误的。如果不确定,请使用==。这通常是您想要的,并且也适用于None—尽管不如is快。
总结一下关于身份与相等性的讨论,我们会看到著名的不可变tuple并不像您期望的那样不变。
元组的相对不可变性
元组,像大多数 Python 集合(列表、字典、集合等)一样,都是容器:它们保存对对象的引用。² 如果所引用的项是可变的,即使元组本身不变,它们也可能发生变化。换句话说,元组的不可变性实际上是指tuple数据结构的物理内容(即它保存的引用),而不是扩展到所引用的对象。
示例 6-5 说明了元组的值因所引用的可变对象的更改而发生变化的情况。元组中永远不会改变的是它包含的项的标识。
示例 6-5。t1和t2最初比较相等,但在元组t1内更改可变项后,它们变得不同
>>> t1 = (1, 2, [30, 40]) # ①
>>> t2 = (1, 2, [30, 40]) # ②
>>> t1 == t2 # ③
True >>> id(t1[-1]) # ④
4302515784 >>> t1[-1].append(99) # ⑤
>>> t1
(1, 2, [30, 40, 99]) >>> id(t1[-1]) # ⑥
4302515784 >>> t1 == t2 # ⑦
False
①
t1是不可变的,但t1[-1]是可变的。
②
构建一个元组t2,其项与t1的项相等。
③
尽管是不同的对象,t1和t2比较相等,正如预期的那样。
④
检查t1[-1]列表的标识。
⑤
在原地修改t1[-1]列表。
⑥
t1[-1]的标识没有改变,只是它的值改变了。
⑦
t1和t2现在是不同的。
这种相对不可变性是谜题“A += Assignment Puzzler”背后的原因。这也是为什么一些元组是不可哈希的,正如我们在“什么是可哈希的”中所看到的。
在需要复制对象时,相等性和标识之间的区别会产生进一步的影响。副本是一个具有不同 ID 的相等对象。但是,如果一个对象包含其他对象,副本是否也应该复制内部对象,还是可以共享它们?这并没有单一的答案。继续阅读以了解讨论。
默认情况下是浅拷贝
复制列表(或大多数内置的可变集合)的最简单方法是使用类型本身的内置构造函数。例如:
>>> l1 = [3, [55, 44], (7, 8, 9)]
>>> l2 = list(l1) # ①
>>> l2
[3, [55, 44], (7, 8, 9)] >>> l2 == l1 # ②
True >>> l2 is l1 # ③
False
①
list(l1)创建了l1的一个副本。
②
这些拷贝是相等的…
③
…但是指向两个不同的对象。
对于列表和其他可变序列,使用快捷方式l2 = l1[:]也会创建一个副本。
然而,使用构造函数或[:]会产生一个浅拷贝(即,最外层容器被复制,但副本填充的是对原始容器持有的相同项的引用)。这节省内存,并且如果所有项都是不可变的,则不会出现问题。但是,如果有可变项,这可能会导致令人不快的惊喜。
在示例 6-6 中,我们创建了一个包含另一个列表和一个元组的列表的浅拷贝,然后进行更改以查看它们对所引用对象的影响。
提示
如果你手头有一台连接的电脑,我强烈推荐观看示例 6-6 的交互式动画,网址为Online Python Tutor。就我所知,直接链接到pythontutor.com上的准备好的示例并不总是可靠,但这个工具非常棒,所以抽出时间复制粘贴代码是值得的。
示例 6-6. 对包含另一个列表的列表进行浅复制;复制并粘贴此代码以在 Online Python Tutor 中查看动画
l1 = [3, [66, 55, 44], (7, 8, 9)]
l2 = list(l1) # ①
l1.append(100) # ②
l1[1].remove(55) # ③
print('l1:', l1)
print('l2:', l2)
l2[1] += [33, 22] # ④
l2[2] += (10, 11) # ⑤
print('l1:', l1)
print('l2:', l2)
①
l2是l1的浅复制。这个状态在图 6-3 中描述。
②
在l1后添加100对l2没有影响。
③
在这里我们从内部列表l1[1]中移除55。这会影响到l2,因为l2[1]与l1[1]绑定到同一个列表。
④
对于像l2[1]引用的列表这样的可变对象,运算符+=会就地修改列表。这种变化在l1[1]上可见,因为l1[1]是l2[1]的别名。
⑤
在元组上使用+=会创建一个新的元组并重新绑定变量l2[2]。这等同于执行l2[2] = l2[2] + (10, 11)。现在l1和l2中最后位置的元组不再是同一个对象。参见图 6-4。

图 6-3. 在示例 6-6 中赋值l2 = list(l1)后的程序状态。l1和l2指向不同的列表,但这些列表共享对同一内部列表对象[66, 55, 44]和元组(7, 8, 9)的引用。 (图示由 Online Python Tutor 生成。)
示例 6-6 的输出是示例 6-7,对象的最终状态在图 6-4 中描述。
示例 6-7. 示例 6-6 的输出
l1: [3, [66, 44], (7, 8, 9), 100]
l2: [3, [66, 44], (7, 8, 9)]
l1: [3, [66, 44, 33, 22], (7, 8, 9), 100]
l2: [3, [66, 44, 33, 22], (7, 8, 9, 10, 11)]

图 6-4. l1和l2的最终状态:它们仍然共享对同一列表对象的引用,现在包含[66, 44, 33, 22],但操作l2[2] += (10, 11)创建了一个新的元组,内容为(7, 8, 9, 10, 11),与l1[2]引用的元组(7, 8, 9)无关。 (图示由 Online Python Tutor 生成。)
现在应该清楚了,浅复制很容易实现,但可能并不是你想要的。如何进行深复制是我们下一个话题。
任意对象的深复制和浅复制
使用浅复制并不总是问题,但有时你需要进行深复制(即不共享嵌入对象引用的副本)。copy模块提供了deepcopy和copy函数,用于返回任意对象的深复制和浅复制。
为了说明copy()和deepcopy()的用法,示例 6-8 定义了一个简单的类Bus,代表一辆载有乘客的校车,然后在路线上接送乘客。
示例 6-8. 公共汽车接送乘客
class Bus:
def __init__(self, passengers=None):
if passengers is None:
self.passengers = []
else:
self.passengers = list(passengers)
def pick(self, name):
self.passengers.append(name)
def drop(self, name):
self.passengers.remove(name)
现在,在交互式示例 6-9 中,我们将创建一个bus对象(bus1)和两个克隆体—一个浅复制(bus2)和一个深复制(bus3)—来观察当bus1放下一个学生时会发生什么。
示例 6-9. 使用copy和deepcopy的效果
>>> import copy
>>> bus1 = Bus(['Alice', 'Bill', 'Claire', 'David'])
>>> bus2 = copy.copy(bus1)
>>> bus3 = copy.deepcopy(bus1)
>>> id(bus1), id(bus2), id(bus3)
(4301498296, 4301499416, 4301499752) # ①
>>> bus1.drop('Bill')
>>> bus2.passengers
['Alice', 'Claire', 'David'] # ②
>>> id(bus1.passengers), id(bus2.passengers), id(bus3.passengers)
(4302658568, 4302658568, 4302657800) # ③
>>> bus3.passengers
['Alice', 'Bill', 'Claire', 'David'] # ④
①
使用copy和deepcopy,我们创建了三个不同的Bus实例。
②
在bus1删除'Bill'后,bus2也缺少了他。
③
检查passengers属性显示bus1和bus2共享相同的列表对象,因为bus2是bus1的浅拷贝。
④
bus3是bus1的深拷贝,因此其passengers属性引用另一个列表。
请注意,在一般情况下,制作深拷贝并不是一件简单的事情。对象可能具有导致天真算法陷入无限循环的循环引用。deepcopy函数记住已复制的对象,以优雅地处理循环引用。这在示例 6-10 中有演示。
示例 6-10。循环引用:b引用a,然后附加到a;deepcopy仍然成功复制a
>>> a = [10, 20]
>>> b = [a, 30]
>>> a.append(b)
>>> a
[10, 20, [[...], 30]]
>>> from copy import deepcopy
>>> c = deepcopy(a)
>>> c
[10, 20, [[...], 30]]
此外,在某些情况下,深拷贝可能太深。例如,对象可能引用不应复制的外部资源或单例。您可以通过实现__copy__()和__deepcopy__()特殊方法来控制copy和deepcopy的行为,如copy模块文档中所述。
通过别名共享对象也解释了 Python 中参数传递的工作原理,以及在参数默认值中使用可变类型的问题。接下来将介绍这些问题。
函数参数作为引用
Python 中的唯一参数传递模式是共享调用。这是大多数面向对象语言使用的模式,包括 JavaScript、Ruby 和 Java(这适用于 Java 引用类型;基本类型使用按值调用)。共享调用意味着函数的每个形式参数都会得到每个参数中引用的副本。换句话说,函数内部的参数成为实际参数的别名。
这种方案的结果是函数可以更改作为参数传递的任何可变对象,但它不能更改这些对象的标识(即,它不能完全用另一个对象替换对象)。示例 6-11 展示了一个简单函数在其中一个参数上使用+=的情况。当我们将数字、列表和元组传递给函数时,传递的实际参数会以不同的方式受到影响。
示例 6-11。一个函数可以更改它接收到的任何可变对象
>>> def f(a, b):
... a += b
... return a
...
>>> x = 1
>>> y = 2
>>> f(x, y)
3 >>> x, y # ①
(1, 2) >>> a = [1, 2]
>>> b = [3, 4]
>>> f(a, b)
[1, 2, 3, 4] >>> a, b # ②
([1, 2, 3, 4], [3, 4]) >>> t = (10, 20)
>>> u = (30, 40)
>>> f(t, u) # ③
(10, 20, 30, 40) >>> t, u
((10, 20), (30, 40))
①
数字x保持不变。
②
列表a已更改。
③
元组t保持不变。
与函数参数相关的另一个问题是在默认情况下使用可变值,如下所述。
将可变类型用作参数默认值:不好的主意
具有默认值的可选参数是 Python 函数定义的一个很好的特性,允许我们的 API 在保持向后兼容的同时发展。但是,应避免将可变对象作为参数的默认值。
为了说明这一点,在示例 6-12 中,我们从示例 6-8 中获取Bus类,并将其__init__方法更改为创建HauntedBus。在这里,我们试图聪明地避免在以前的__init__中使用passengers=None的默认值,而是使用passengers=[],从而避免了if。这种“聪明”让我们陷入了麻烦。
示例 6-12。一个简单的类来说明可变默认值的危险
class HauntedBus:
"""A bus model haunted by ghost passengers"""
def __init__(self, passengers=[]): # ①
self.passengers = passengers # ②
def pick(self, name):
self.passengers.append(name) # ③
def drop(self, name):
self.passengers.remove(name)
①
当未传递passengers参数时,此参数绑定到默认的空列表对象。
②
这个赋值使得self.passengers成为passengers的别名,而passengers本身是默认列表的别名,当没有传递passengers参数时。
③
当使用.remove()和.append()方法与self.passengers一起使用时,实际上是在改变函数对象的属性的默认列表。
示例 6-13 展示了HauntedBus的诡异行为。
示例 6-13. 被幽灵乘客缠身的公交车
>>> bus1 = HauntedBus(['Alice', 'Bill']) # ①
>>> bus1.passengers
['Alice', 'Bill'] >>> bus1.pick('Charlie')
>>> bus1.drop('Alice')
>>> bus1.passengers # ②
['Bill', 'Charlie'] >>> bus2 = HauntedBus() # ③
>>> bus2.pick('Carrie')
>>> bus2.passengers
['Carrie'] >>> bus3 = HauntedBus() # ④
>>> bus3.passengers # ⑤
['Carrie'] >>> bus3.pick('Dave')
>>> bus2.passengers # ⑥
['Carrie', 'Dave'] >>> bus2.passengers is bus3.passengers # ⑦
True >>> bus1.passengers # ⑧
['Bill', 'Charlie']
①
bus1从一个有两名乘客的列表开始。
②
到目前为止,bus1没有什么意外。
③
bus2从空开始,所以默认的空列表被分配给了self.passengers。
④
bus3也是空的,再次分配了默认列表。
⑤
默认值不再是空的!
⑥
现在被bus3选中的Dave出现在了bus2中。
⑦
问题在于bus2.passengers和bus3.passengers指向同一个列表。
⑧
但bus1.passengers是一个独立的列表。
问题在于没有初始乘客列表的HauntedBus实例最终共享同一个乘客列表。
这类 bug 可能很微妙。正如示例 6-13 所展示的,当使用乘客实例化HauntedBus时,它的表现如预期。只有当HauntedBus从空开始时才会发生奇怪的事情,因为这时self.passengers变成了passengers参数的默认值的别名。问题在于每个默认值在函数定义时被计算—即通常在模块加载时—并且默认值变成函数对象的属性。因此,如果默认值是一个可变对象,并且你对其进行更改,这种更改将影响到函数的每次未来调用。
在运行示例 6-13 中的代码后,你可以检查HauntedBus.__init__对象,并看到幽灵学生缠绕在其__defaults__属性中:
>>> dir(HauntedBus.__init__) # doctest: +ELLIPSIS
['__annotations__', '__call__', ..., '__defaults__', ...]
>>> HauntedBus.__init__.__defaults__
(['Carrie', 'Dave'],)
最后,我们可以验证bus2.passengers是绑定到HauntedBus.__init__.__defaults__属性的第一个元素的别名:
>>> HauntedBus.__init__.__defaults__[0] is bus2.passengers
True
可变默认值的问题解释了为什么None通常被用作可能接收可变值的参数的默认值。在示例 6-8 中,__init__检查passengers参数是否为None。如果是,self.passengers绑定到一个新的空列表。如果passengers不是None,正确的实现将该参数的副本绑定到self.passengers。下一节将解释为什么复制参数是一个好的实践。
使用可变参数进行防御性编程
当你编写一个接收可变参数的函数时,你应该仔细考虑调用者是否希望传递的参数被更改。
例如,如果你的函数接收一个dict并在处理过程中需要修改它,那么这种副作用是否应该在函数外部可见?实际上这取决于上下文。这实际上是对函数编写者和调用者期望的一种调整。
本章中最后一个公交车示例展示了TwilightBus如何通过与其客户共享乘客列表来打破期望。在研究实现之前,看看示例 6-14 中TwilightBus类如何从类的客户的角度工作。
示例 6-14。当被TwilightBus放下时,乘客消失了
>>> basketball_team = ['Sue', 'Tina', 'Maya', 'Diana', 'Pat'] # ①
>>> bus = TwilightBus(basketball_team) # ②
>>> bus.drop('Tina') # ③
>>> bus.drop('Pat')
>>> basketball_team # ④
['Sue', 'Maya', 'Diana']
①
basketball_team拥有五个学生名字。
②
一个TwilightBus装载着球队。
③
公交车放下一个学生,然后又一个。
④
被放下的乘客从篮球队中消失了!
TwilightBus违反了“最少惊讶原则”,这是接口设计的最佳实践。³ 当公交车放下一个学生时,他们的名字从篮球队名单中被移除,这确实令人惊讶。
示例 6-15 是TwilightBus的实现以及问题的解释。
示例 6-15。一个简单的类,展示了修改接收参数的危险性
class TwilightBus:
"""A bus model that makes passengers vanish"""
def __init__(self, passengers=None):
if passengers is None:
self.passengers = [] # ①
else:
self.passengers = passengers # ②
def pick(self, name):
self.passengers.append(name)
def drop(self, name):
self.passengers.remove(name) # ③
①
当passengers为None时,我们小心地创建一个新的空列表。
②
然而,这个赋值使self.passengers成为passengers的别名,而passengers本身是传递给__init__的实际参数的别名(即示例 6-14 中的basketball_team)。
③
当使用.remove()和.append()方法与self.passengers一起使用时,实际上是在修改作为构造函数参数传递的原始列表。
这里的问题是公交车别名化了传递给构造函数的列表。相反,它应该保留自己的乘客列表。修复方法很简单:在__init__中,当提供passengers参数时,应该用其副本初始化self.passengers,就像我们在示例 6-8 中正确做的那样:
def __init__(self, passengers=None):
if passengers is None:
self.passengers = []
else:
self.passengers = list(passengers) # ①
①
复制passengers列表,或者如果它不是列表,则将其转换为list。
现在我们对乘客列表的内部处理不会影响用于初始化公交车的参数。作为一个额外的好处,这个解决方案更加灵活:现在传递给passengers参数的参数可以是一个tuple或任何其他可迭代对象,比如一个set甚至是数据库结果,因为list构造函数接受任何可迭代对象。当我们创建自己的列表来管理时,我们确保它支持我们在.pick()和.drop()方法中使用的必要的.remove()和.append()操作。
提示
除非一个方法明确意图修改作为参数接收的对象,否则在类中简单地将其分配给实例变量会导致别名化参数对象。如果有疑问,请复制。你的客户会更加满意。当然,复制并非免费:在 CPU 和内存方面会有成本。然而,导致微妙错误的 API 通常比稍慢或使用更多资源的 API 更大的问题。
现在让我们谈谈 Python 语句中最被误解的之一:del。
del 和 垃圾回收
对象永远不会被显式销毁;然而,当它们变得不可达时,它们可能被垃圾回收。
The Python Language Reference 中 “Data Model” 章节
del 的第一个奇怪之处在于它不是一个函数,而是一个语句。我们写 del x 而不是 del(x)—尽管后者也可以工作,但只是因为在 Python 中表达式 x 和 (x) 通常表示相同的东西。
第二个令人惊讶的事实是 del 删除的是引用,而不是对象。Python 的垃圾收集器可能会间接地将对象从内存中丢弃,作为 del 的间接结果,如果被删除的变量是对象的最后一个引用。重新绑定一个变量也可能导致对象的引用数达到零,从而导致其销毁。
>>> a = [1, 2] # ①
>>> b = a # ②
>>> del a # ③
>>> b # ④
[1, 2] >>> b = [3] # ⑤
①
创建对象 [1, 2] 并将 a 绑定到它。
②
将 b 绑定到相同的 [1, 2] 对象。
③
删除引用 a。
④
[1, 2] 没有受到影响,因为 b 仍然指向它。
⑤
将 b 重新绑定到不同的对象会移除对 [1, 2] 的最后一个引用。现在垃圾收集器可以丢弃该对象。
警告
有一个 __del__ 特殊方法,但它不会导致实例的销毁,并且不应该被您的代码调用。__del__ 在实例即将被销毁时由 Python 解释器调用,以便让它有机会释放外部资源。您很少需要在自己的代码中实现 __del__,但一些 Python 程序员却花时间编写它却没有好的理由。正确使用 __del__ 是相当棘手的。请参阅 The Python Language Reference 中 “Data Model” 章节的 __del__ 特殊方法文档。
在 CPython 中,垃圾回收的主要算法是引用计数。基本上,每个对象都会记录指向它的引用计数。一旦该 refcount 达到零,对象立即被销毁:CPython 调用对象的 __del__ 方法(如果定义了)然后释放为对象分配的内存。在 CPython 2.0 中,添加了一种分代垃圾回收算法,用于检测涉及引用循环的对象组—即使有指向它们的未解除引用,当所有相互引用都包含在组内时。Python 的其他实现具有更复杂的垃圾收集器,不依赖于引用计数,这意味着当没有更多引用指向对象时,__del__ 方法可能不会立即被调用。请参阅 A. Jesse Jiryu Davis 的 “PyPy、垃圾回收和死锁” 讨论 __del__ 的不当和适当使用。
为了演示对象生命周期的结束,示例 6-16 使用 weakref.finalize 注册一个回调函数,当对象被销毁时将被调用。
示例 6-16. 当没有更多引用指向对象时观察对象结束
>>> import weakref
>>> s1 = {1, 2, 3}
>>> s2 = s1 # ①
>>> def bye(): # ②
... print('...like tears in the rain.')
...
>>> ender = weakref.finalize(s1, bye) # ③
>>> ender.alive # ④
True >>> del s1
>>> ender.alive # ⑤
True >>> s2 = 'spam' # ⑥
...like tears in the rain. >>> ender.alive
False
①
s1 和 s2 是指向相同集合 {1, 2, 3} 的别名。
②
此函数不得是即将被销毁的对象的绑定方法或以其他方式保留对它的引用。
③
在s1引用的对象上注册bye回调。
④
在调用finalize对象之前,.alive属性为True。
⑤
正如讨论的那样,del并没有删除对象,只是删除了对它的s1引用。
⑥
重新绑定最后一个引用s2会使{1, 2, 3}变得不可访问。它被销毁,bye回调被调用,ender.alive变为False。
示例 6-16 的重点在于明确del并不会删除对象,但对象可能在使用del后变得不可访问而被删除。
你可能想知道为什么在示例 6-16 中{1, 2, 3}对象被销毁。毕竟,s1引用被传递给finalize函数,该函数必须保持对它的引用以便监视对象并调用回调。这是因为finalize持有对{1, 2, 3}的弱引用。对对象的弱引用不会增加其引用计数。因此,弱引用不会阻止目标对象被垃圾回收。弱引用在缓存应用中很有用,因为你不希望缓存的对象因为被缓存引用而保持活动状态。
注意
弱引用是一个非常专业的主题。这就是为什么我选择在第二版中跳过它。相反,我在fluentpython.com上发布了“弱引用”。
Python 对不可变对象的戏法
注意
这个可选部分讨论了一些对 Python 的用户来说并不重要的细节,可能不适用于其他 Python 实现甚至未来的 CPython 版本。尽管如此,我看到有人遇到这些边缘情况,然后开始错误地使用is运算符,所以我觉得值得一提。
令人惊讶的是,对于元组t,t[:]并不会创建一个副本,而是返回对同一对象的引用。如果写成tuple(t)也会得到对同一元组的引用。⁴ 示例 6-17 证明了这一点。
示例 6-17. 从另一个元组构建的元组实际上是完全相同的元组
>>> t1 = (1, 2, 3)
>>> t2 = tuple(t1)
>>> t2 is t1 # ①
True >>> t3 = t1[:]
>>> t3 is t1 # ②
True
①
t1和t2绑定到同一个对象。
②
t3也是如此。
相同的行为也可以观察到str、bytes和frozenset的实例。请注意,frozenset不是一个序列,因此如果fs是一个frozenset,fs[:]不起作用。但fs.copy()具有相同的效果:它欺骗性地返回对同一对象的引用,根本不是副本,正如示例 6-18 所示。⁵
示例 6-18. 字符串字面量可能创建共享对象
>>> t1 = (1, 2, 3)
>>> t3 = (1, 2, 3) # ①
>>> t3 is t1 # ②
False >>> s1 = 'ABC'
>>> s2 = 'ABC' # ③
>>> s2 is s1 # ④
True
①
从头开始创建一个新元组。
②
t1和t3相等,但不是同一个对象。
③
从头开始创建第二个str。
④
令人惊讶:a和b指向同一个str!
共享字符串字面量是一种名为内部化的优化技术。CPython 使用类似的技术来避免程序中频繁出现的数字(如 0、1、-1 等)的不必要重复。请注意,CPython 并不会对所有字符串或整数进行内部化,它用于执行此操作的标准是一个未记录的实现细节。
警告
永远不要依赖于str或int的内部化!始终使用==而不是is来比较字符串或整数的相等性。内部化是 Python 解释器内部使用的优化。
本节讨论的技巧,包括frozenset.copy()的行为,是无害的“谎言”,可以节省内存并使解释器更快。不要担心它们,它们不应该给你带来任何麻烦,因为它们只适用于不可变类型。也许这些琐事最好的用途是与其他 Python 爱好者打赌。⁶
章节总结
每个 Python 对象都有一个标识、一个类型和一个值。对象的值随时间可能会改变,只有对象的值可能会随时间改变。⁷
如果两个变量引用具有相等值的不可变对象(a == b为True),实际上很少关心它们是引用副本还是别名引用相同对象,因为不可变对象的值不会改变,只有一个例外。这个例外是不可变集合,例如元组:如果不可变集合保存对可变项的引用,那么当可变项的值发生变化时,其值实际上可能会改变。在实践中,这种情况并不常见。在不可变集合中永远不会改变的是其中对象的标识。frozenset类不会受到这个问题的影响,因为它只能保存可散列的元素,可散列对象的值根据定义永远不会改变。
变量保存引用在 Python 编程中有许多实际后果:
-
简单赋值不会创建副本。
-
使用
+=或*=进行增强赋值会在左侧变量绑定到不可变对象时创建新对象,但可能会就地修改可变对象。 -
将新值分配给现有变量不会更改先前绑定到它的对象。这被称为重新绑定:变量现在绑定到不同的对象。如果该变量是先前对象的最后一个引用,那么该对象将被垃圾回收。
-
函数参数作为别名传递,这意味着函数可能会改变作为参数接收的任何可变对象。除了制作本地副本或使用不可变对象(例如,传递元组而不是列表)外,没有其他方法可以阻止这种情况发生。
-
使用可变对象作为函数参数的默认值是危险的,因为如果参数在原地更改,则默认值也会更改,影响到依赖默认值的每个未来调用。
在 CPython 中,对象一旦引用数达到零就会被丢弃。如果它们形成具有循环引用但没有外部引用的组,它们也可能被丢弃。
在某些情况下,保留对一个对象的引用可能是有用的,这个对象本身不会保持其他对象的存活。一个例子是一个类想要跟踪其所有当前实例。这可以通过弱引用来实现,这是更有用的集合WeakValueDictionary、WeakKeyDictionary、WeakSet以及weakref模块中的finalize函数的基础机制。有关更多信息,请参阅fluentpython.com上的“弱引用”章节。
进一步阅读
Python 语言参考的“数据模型”章节以清晰的方式解释了对象的标识和值。
Wesley Chun,Core Python 系列书籍的作者,在 2011 年的 EuroPython 上做了题为Understanding Python’s Memory Model, Mutability, and Methods的演讲,不仅涵盖了本章的主题,还涉及了特殊方法的使用。
Doug Hellmann 撰写了关于“copy – Duplicate Objects”和“weakref—Garbage-Collectable References to Objects”的帖子,涵盖了我们刚讨论过的一些主题。
更多关于 CPython 分代垃圾收集器的信息可以在gc 模块文档中找到,其中以“此模块提供了一个可选垃圾收集器的接口。”开头。这里的“可选”修饰词可能令人惊讶,但“数据模型”章节也指出:
实现可以延迟垃圾收集或完全省略它——垃圾收集的实现质量如何取决于实现,只要不收集仍然可达的对象。
Pablo Galindo 在Python 开发者指南中深入探讨了 Python 的 GC 设计,针对 CPython 实现的新手和有经验的贡献者。
CPython 3.4 垃圾收集器改进了具有__del__方法的对象的处理,如PEP 442—Safe object finalization中所述。
维基百科有一篇关于string interning的文章,提到了这种技术在几种语言中的使用,包括 Python。
维基百科还有一篇关于“Haddocks’ Eyes”的文章,这是我在本章开头引用的 Lewis Carroll 的歌曲。维基百科编辑写道,这些歌词被用于逻辑和哲学作品中“阐述名称概念的符号地位:名称作为识别标记可以分配给任何东西,包括另一个名称,从而引入不同级别的符号化。”
¹ Lynn Andrea Stein 是一位屡获殊荣的计算机科学教育家,目前在奥林工程学院任教。
² 相比之下,像str、bytes和array.array这样的扁平序列不包含引用,而是直接保存它们的内容——字符、字节和数字——在连续的内存中。
³ 在英文维基百科中查看最少惊讶原则。
⁴ 这是明确记录的。在 Python 控制台中键入help(tuple)以阅读:“如果参数是一个元组,则返回值是相同的对象。”在写这本书之前,我以为我对元组了解一切。
⁵ 使copy方法不复制任何内容的无害谎言是为了接口兼容性:它使frozenset更兼容set。无论两个相同的不可变对象是相同的还是副本,对最终用户都没有影响。
⁶ 这些信息的可怕用途是在面试候选人或为“认证”考试编写问题时询问。有无数更重要和更有用的事实可用于检查 Python 知识。
⁷ 实际上,通过简单地将不同的类分配给其__class__属性,对象的类型可以更改,但这是纯粹的邪恶,我后悔写下这个脚注。
第二部分:函数作为对象
第七章:函数作为一等对象
我从未认为 Python 受到函数式语言的重大影响,无论人们说什么或想什么。我更熟悉命令式语言,如 C 和 Algol 68,尽管我将函数作为一等对象,但我并不认为 Python 是一种函数式编程语言。
Guido van Rossum,Python BDFL¹
Python 中的函数是一等对象。编程语言研究人员将“一等对象”定义为一个程序实体,可以:
-
在运行时创建
-
赋值给变量或数据结构中的元素
-
作为参数传递给函数
-
作为函数的结果返回
在 Python 中,整数、字符串和字典是函数的一等对象的其他示例——这里没有什么花哨的东西。将函数作为一等对象是函数式语言(如 Clojure、Elixir 和 Haskell)的一个重要特性。然而,一等函数非常有用,以至于它们被流行的语言(如 JavaScript、Go 和 Java(自 JDK 8 起))采用,这些语言都不声称自己是“函数式语言”。
本章和第三部分的大部分内容探讨了将函数视为对象的实际应用。
提示
术语“一等函数”被广泛用作“函数作为一等对象”的简称。这并不理想,因为它暗示了函数中的“精英”。在 Python 中,所有函数都是一等对象。
本章的新内容
部分“可调用对象的九种类型”在本书第一版中标题为“可调用对象的七种类型”。新的可调用对象是原生协程和异步生成器,分别在 Python 3.5 和 3.6 中引入。它们都在第二十一章中介绍,但为了完整起见,它们与其他可调用对象一起提及在这里。
“仅位置参数” 是一个新的部分,涵盖了 Python 3.8 中添加的一个特性。
我将运行时访问函数注解的讨论移到了“在运行时读取类型提示”。在我写第一版时,PEP 484—类型提示 仍在考虑中,人们以不同的方式使用注解。自 Python 3.5 起,注解应符合 PEP 484。因此,在讨论类型提示时,最好的地方是在这里。
注意
本书的第一版有关函数对象内省的部分过于低级,分散了本章的主题。我将这些部分合并到了一个名为“函数参数内省”在 fluentpython.com的帖子中。
现在让我们看看为什么 Python 函数是完整的对象。
将函数视为对象
示例 7-1 中的控制台会话显示了 Python 函数是对象。在这里,我们创建一个函数,调用它,读取其 __doc__ 属性,并检查函数对象本身是否是 function 类的一个实例。
示例 7-1。创建和测试一个函数,然后读取其 __doc__ 并检查其类型
>>> def factorial(n): # ①
... """returns n!"""
... return 1 if n < 2 else n * factorial(n - 1)
...
>>> factorial(42)
1405006117752879898543142606244511569936384000000000 >>> factorial.__doc__ # ②
'returns n!' >>> type(factorial) # ③
<class 'function'>
①
这是一个控制台会话,所以我们在“运行时”创建一个函数。
②
__doc__ 是函数对象的几个属性之一。
③
factorial 是 function 类的一个实例。
__doc__ 属性用于生成对象的帮助文本。在 Python 控制台中,命令 help(factorial) 将显示类似于 图 7-1 的屏幕。

图 7-1。factorial 的帮助屏幕;文本是从函数的 __doc__ 属性构建的。
示例 7-2 展示了函数对象的“第一类”特性。我们可以将其赋值给变量fact,并通过该名称调用它。我们还可以将factorial作为参数传递给map函数。调用map(function, iterable)会返回一个可迭代对象,其中每个项目都是调用第一个参数(一个函数)对第二个参数(一个可迭代对象)中的连续元素的结果,本例中为range(10)。
示例 7-2. 通过不同名称使用factorial,并将factorial作为参数传递
>>> fact = factorial
>>> fact
<function factorial at 0x...>
>>> fact(5)
120
>>> map(factorial, range(11))
<map object at 0x...>
>>> list(map(factorial, range(11)))
[1, 1, 2, 6, 24, 120, 720, 5040, 40320, 362880, 3628800]
拥有头等函数使得以函数式风格编程成为可能。函数式编程的一个特点是使用高阶函数,我们的下一个主题。
高阶函数
一个将函数作为参数或返回函数作为结果的函数是高阶函数。一个例子是map,如示例 7-2 所示。另一个是内置函数sorted:可选的key参数允许您提供一个要应用于每个项目以进行排序的函数,正如我们在“list.sort 与 sorted 内置函数”中看到的。例如,要按长度对单词列表进行排序,可以将len函数作为key传递,如示例 7-3 所示。
示例 7-3. 按长度对单词列表进行排序
>>> fruits = ['strawberry', 'fig', 'apple', 'cherry', 'raspberry', 'banana']
>>> sorted(fruits, key=len)
['fig', 'apple', 'cherry', 'banana', 'raspberry', 'strawberry']
>>>
任何一个参数为一个参数的函数都可以用作键。例如,为了创建一个韵典,将每个单词倒着拼写可能很有用。在示例 7-4 中,请注意列表中的单词根本没有改变;只有它们的反向拼写被用作排序标准,以便浆果出现在一起。
示例 7-4. 按单词的反向拼写对单词列表进行排序
>>> def reverse(word):
... return word[::-1]
>>> reverse('testing')
'gnitset'
>>> sorted(fruits, key=reverse)
['banana', 'apple', 'fig', 'raspberry', 'strawberry', 'cherry']
>>>
在函数式编程范式中,一些最著名的高阶函数包括map、filter、reduce和apply。apply函数在 Python 2.3 中已被弃用,并在 Python 3 中移除,因为它不再必要。如果需要使用动态参数集调用函数,可以编写fn(*args, **kwargs),而不是apply(fn, args, kwargs)。
map、filter和reduce高阶函数仍然存在,但对于它们的大多数用例,都有更好的替代方案,如下一节所示。
map、filter 和 reduce 的现代替代品
函数式语言通常提供map、filter和reduce高阶函数(有时使用不同的名称)。map和filter函数在 Python 3 中仍然是内置函数,但自列表推导式和生成器表达式引入以来,它们变得不再那么重要。列表推导式或生成器表达式可以完成map和filter的工作,但更易读。考虑示例 7-5。
示例 7-5. 使用map和filter生成的阶乘列表与编码为列表推导式的替代方案进行比较
>>> list(map(factorial, range(6))) # ①
[1, 1, 2, 6, 24, 120] >>> [factorial(n) for n in range(6)] # ②
[1, 1, 2, 6, 24, 120] >>> list(map(factorial, filter(lambda n: n % 2, range(6)))) # ③
[1, 6, 120] >>> [factorial(n) for n in range(6) if n % 2] # ④
[1, 6, 120] >>>
①
从 0!到 5!构建一个阶乘列表。
②
使用列表推导式进行相同的操作。
③
列出了奇数阶乘数的列表,直到 5!,同时使用map和filter。
④
列表推导式可以完成相同的工作,取代map和filter,使得lambda变得不再必要。
在 Python 3 中,map和filter返回生成器——一种迭代器形式,因此它们的直接替代品现在是生成器表达式(在 Python 2 中,这些函数返回列表,因此它们最接近的替代品是列表推导式)。
reduce函数从 Python 2 中的内置函数降级为 Python 3 中的functools模块。它最常见的用例,求和,更适合使用自 2003 年发布 Python 2.3 以来可用的sum内置函数。这在可读性和性能方面是一个巨大的胜利(参见示例 7-6)。
示例 7-6. 使用 reduce 和 sum 对整数求和,直到 99
>>> from functools import reduce # ①
>>> from operator import add # ②
>>> reduce(add, range(100)) # ③
4950 >>> sum(range(100)) # ④
4950 >>>
①
从 Python 3.0 开始,reduce 不再是内置函数。
②
导入 add 来避免创建一个仅用于添加两个数字的函数。
③
对整数求和,直到 99。
④
使用 sum 完成相同的任务—无需导入和调用 reduce 和 add。
注意
sum 和 reduce 的共同思想是对系列中的连续项目应用某种操作,累积先前的结果,从而将一系列值减少为单个值。
其他减少内置函数是 all 和 any:
all(iterable)
如果可迭代对象中没有假值元素,则返回 True;all([]) 返回 True。
any(iterable)
如果可迭代对象中有任何元素为真,则返回 True;any([]) 返回 False。
我在 “向量取 #4:哈希和更快的 ==” 中对 reduce 进行了更详细的解释,在那里,一个持续的示例为使用这个函数提供了有意义的上下文。在本书后面的部分,当重点放在可迭代对象上时,将总结减少函数,见 “可迭代对象减少函数”。
为了使用高阶函数,有时创建一个小的、一次性的函数是很方便的。这就是匿名函数存在的原因。我们将在下面介绍它们。
匿名函数
lambda 关键字在 Python 表达式中创建一个匿名函数。
然而,Python 的简单语法限制了 lambda 函数的主体必须是纯表达式。换句话说,主体不能包含其他 Python 语句,如 while、try 等。赋值语句 = 也是一个语句,因此不能出现在 lambda 中。可以使用新的赋值表达式语法 :=,但如果你需要它,你的 lambda 可能太复杂和难以阅读,应该重构为使用 def 的常规函数。
匿名函数的最佳用法是在作为高阶函数的参数列表的上下文中。例如,示例 7-7 是从 示例 7-4 重写的韵脚索引示例,使用 lambda,而不定义一个 reverse 函数。
示例 7-7. 使用 lambda 按照它们的反向拼写对单词列表进行排序
>>> fruits = ['strawberry', 'fig', 'apple', 'cherry', 'raspberry', 'banana']
>>> sorted(fruits, key=lambda word: word[::-1])
['banana', 'apple', 'fig', 'raspberry', 'strawberry', 'cherry']
>>>
在高阶函数的参数的有限上下文之外,匿名函数在 Python 中很少有用。语法限制往往使得非平凡的 lambda 要么难以阅读,要么无法工作。如果一个 lambda 难以阅读,我强烈建议您遵循 Fredrik Lundh 的重构建议。
lambda 语法只是一种语法糖:lambda 表达式创建一个函数对象,就像 def 语句一样。这只是 Python 中几种可调用对象中的一种。下一节将回顾所有这些对象。
可调用对象的九种类型
调用运算符 () 可以应用于除函数以外的其他对象。要确定对象是否可调用,请使用内置函数 callable()。截至 Python 3.9,数据模型文档 列出了九种可调用类型:
用户定义的函数
使用 def 语句或 lambda 表达式创建。
内置函数
在 C 中实现的函数(对于 CPython),如 len 或 time.strftime。
内置方法
在 C 中实现的方法,比如 dict.get。
方法
在类的主体中定义的函数。
类
当调用一个类时,它运行其 __new__ 方法来创建一个实例,然后运行 __init__ 来初始化它,最后将实例返回给调用者。因为 Python 中没有 new 运算符,调用一个类就像调用一个函数一样。²
类实例
如果一个类定义了 __call__ 方法,那么它的实例可以被调用为函数—这是下一节的主题。
生成器函数
在其主体中使用yield关键字的函数或方法。调用时,它们返回一个生成器对象。
本机协程函数
使用async def定义的函数或方法。调用时,它们返回一个协程对象。在 Python 3.5 中添加。
异步生成器函数
使用async def定义的函数或方法,在其主体中有yield。调用时,它们返回一个用于与async for一起使用的异步生成器。在 Python 3.6 中添加。
生成器、本机协程和异步生成器函数与其他可调用对象不同,它们的返回值永远不是应用程序数据,而是需要进一步处理以产生应用程序数据或执行有用工作的对象。生成器函数返回迭代器。这两者在第十七章中有所涉及。本机协程函数和异步生成器函数返回的对象只能在异步编程框架(如asyncio)的帮助下使用。它们是第二十一章的主题。
提示
鉴于 Python 中存在各种可调用类型,确定对象是否可调用的最安全方法是使用callable()内置函数:
>>> abs, str, 'Ni!'
(<built-in function abs>, <class 'str'>, 'Ni!')
>>> [callable(obj) for obj in (abs, str, 'Ni!')]
[True, True, False]
我们现在开始构建作为可调用对象的类实例。
用户定义的可调用类型
Python 函数不仅是真实对象,而且任意 Python 对象也可以被制作成类似函数的行为。实现__call__实例方法就是全部所需。
示例 7-8 实现了一个BingoCage类。可以从任何可迭代对象构建一个实例,并且以随机顺序存储内部项目的list。调用实例会弹出一个项目。³
示例 7-8. bingocall.py:BingoCage只做一件事:从一个打乱顺序的列表中挑选项目
import random
class BingoCage:
def __init__(self, items):
self._items = list(items) # ①
random.shuffle(self._items) # ②
def pick(self): # ③
try:
return self._items.pop()
except IndexError:
raise LookupError('pick from empty BingoCage') # ④
def __call__(self): # ⑤
return self.pick()
①
__init__接受任何可迭代对象;构建本地副本可防止对作为参数传递的任何list产生意外副作用。
②
shuffle能够正常工作,因为self._items是一个list。
③
主要方法。
④
如果self._items为空,则使用自定义消息引发异常。
⑤
bingo.pick()的快捷方式:bingo()。
这里是示例 7-8 的简单演示。请注意bingo实例如何被调用为函数,并且callable()内置函数将其识别为可调用对象:
>>> bingo = BingoCage(range(3))
>>> bingo.pick()
1
>>> bingo()
0
>>> callable(bingo)
True
实现__call__的类是创建类似函数的对象的简单方法,这些对象具有必须在调用之间保持的一些内部状态,例如BingoCage中剩余项目的情况。__call__的另一个很好的用例是实现装饰器。装饰器必须是可调用的,有时方便在装饰器的调用之间“记住”一些东西(例如,用于记忆化的缓存昂贵计算的结果以供以后使用)或将复杂实现拆分为单独的方法。
使用闭包是创建具有内部状态的函数的功能方法。闭包以及装饰器是第九章的主题。
现在让我们探索 Python 提供的强大语法,用于声明函数参数并将参数传递给它们。
从位置参数到仅关键字参数
Python 函数最好的特性之一是极其灵活的参数处理机制。与之密切相关的是在调用函数时使用*和**将可迭代对象和映射解包为单独的参数。要查看这些功能的实际应用,请参见示例 7-9 的代码以及在示例 7-10 中展示其用法的测试。
示例 7-9。tag生成 HTML 元素;一个关键字参数class_用于传递class属性,因为class是 Python 中的关键字
def tag(name, *content, class_=None, **attrs):
"""Generate one or more HTML tags"""
if class_ is not None:
attrs['class'] = class_
attr_pairs = (f' {attr}="{value}"' for attr, value
in sorted(attrs.items()))
attr_str = ''.join(attr_pairs)
if content:
elements = (f'<{name}{attr_str}>{c}</{name}>'
for c in content)
return '\n'.join(elements)
else:
return f'<{name}{attr_str} />'
tag函数可以以许多方式调用,就像示例 7-10 所示。
示例 7-10。从示例 7-9 调用tag函数的许多方法
>>> tag('br') # ①
'<br />'
>>> tag('p', 'hello') # ②
'<p>hello</p>'
>>> print(tag('p', 'hello', 'world'))
<p>hello</p>
<p>world</p>
>>> tag('p', 'hello', id=33) # ③
'<p id="33">hello</p>'
>>> print(tag('p', 'hello', 'world', class_='sidebar')) # ④
<p class="sidebar">hello</p>
<p class="sidebar">world</p>
>>> tag(content='testing', name="img") # ⑤
'<img content="testing" />'
>>> my_tag = {'name': 'img', 'title': 'Sunset Boulevard',
... 'src': 'sunset.jpg', 'class': 'framed'}
>>> tag(**my_tag) # ⑥
'<img class="framed" src="sunset.jpg" title="Sunset Boulevard" />'
①
单个位置参数会生成一个具有该名称的空tag。
②
第一个参数之后的任意数量的参数将被*content捕获为一个tuple。
③
在tag签名中未明确命名的关键字参数将被**attrs捕获为一个dict。
④
class_参数只能作为关键字参数传递。
⑤
第一个位置参数也可以作为关键字传递。
⑥
使用**前缀my_tag dict将其所有项作为单独的参数传递,然后绑定到命名参数,其余参数由**attrs捕获。在这种情况下,我们可以在参数dict中有一个'class'键,因为它是一个字符串,不会与 Python 中的class保留字冲突。
关键字参数是 Python 3 的一个特性。在示例 7-9 中,class_参数只能作为关键字参数给出,永远不会捕获未命名的位置参数。要在定义函数时指定关键字参数,请在参数前加上*命名它们。如果您不想支持可变位置参数但仍想要关键字参数,请在签名中放置一个单独的*,就像这样:
>>> def f(a, *, b):
... return a, b
...
>>> f(1, b=2)
(1, 2)
>>> f(1, 2)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: f() takes 1 positional argument but 2 were given
请注意,关键字参数不需要具有默认值:它们可以是强制性的,就像前面示例中的b一样。
仅限位置参数
自 Python 3.8 以来,用户定义的函数签名可以指定位置参数。这个特性在内置函数中一直存在,比如divmod(a, b),它只能使用位置参数调用,而不能像divmod(a=10, b=4)那样调用。
要定义一个需要位置参数的函数,请在参数列表中使用/。
这个来自“Python 3.8 有什么新特性”的示例展示了如何模拟divmod内置函数:
def divmod(a, b, /):
return (a // b, a % b)
/左侧的所有参数都是仅限位置的。在/之后,您可以指定其他参数,它们的工作方式与通常一样。
警告
参数列表中的/在 Python 3.7 或更早版本中是语法错误。
例如,考虑来自示例 7-9 的tag函数。如果我们希望name参数是仅限位置的,我们可以在函数签名中的它后面添加/,就像这样:
def tag(name, /, *content, class_=None, **attrs):
...
您可以在“Python 3.8 有什么新特性”和PEP 570中找到其他仅限位置参数的示例。
在深入研究 Python 灵活的参数声明功能后,本章的其余部分将介绍标准库中用于以函数式风格编程的最有用的包。
函数式编程包
尽管 Guido 明确表示他并没有设计 Python 成为一个函数式编程语言,但由于头等函数、模式匹配以及像operator和functools这样的包的支持,函数式编码风格可以被很好地使用,我们将在接下来的两节中介绍它们。
运算符模块
在函数式编程中,使用算术运算符作为函数很方便。例如,假设您想要乘以一系列数字以计算阶乘而不使用递归。要执行求和,您可以使用 sum,但没有相应的乘法函数。您可以使用 reduce——正如我们在 “map、filter 和 reduce 的现代替代品” 中看到的那样——但这需要一个函数来将序列的两个项相乘。示例 7-11 展示了如何使用 lambda 解决这个问题。
示例 7-11. 使用 reduce 和匿名函数实现阶乘
from functools import reduce
def factorial(n):
return reduce(lambda a, b: a*b, range(1, n+1))
operator 模块提供了几十个运算符的函数等效版本,因此您不必编写像 lambda a, b: a*b 这样的琐碎函数。有了它,我们可以将 示例 7-11 重写为 示例 7-12。
示例 7-12. 使用 reduce 和 operator.mul 实现阶乘
from functools import reduce
from operator import mul
def factorial(n):
return reduce(mul, range(1, n+1))
operator 替换的另一组单一用途的 lambda 是用于从序列中选择项或从对象中读取属性的函数:itemgetter 和 attrgetter 是构建自定义函数的工厂来执行这些操作。
示例 7-13 展示了 itemgetter 的一个常见用法:按一个字段的值对元组列表进行排序。在示例中,城市按国家代码(字段 1)排序打印。本质上,itemgetter(1) 创建一个函数,给定一个集合,返回索引 1 处的项。这比编写和阅读 lambda fields: fields[1] 更容易,后者执行相同的操作。
示例 7-13. 使用 itemgetter 对元组列表进行排序(数据来自 示例 2-8)
>>> metro_data = [
... ('Tokyo', 'JP', 36.933, (35.689722, 139.691667)),
... ('Delhi NCR', 'IN', 21.935, (28.613889, 77.208889)),
... ('Mexico City', 'MX', 20.142, (19.433333, -99.133333)),
... ('New York-Newark', 'US', 20.104, (40.808611, -74.020386)),
... ('São Paulo', 'BR', 19.649, (-23.547778, -46.635833)),
... ]
>>>
>>> from operator import itemgetter
>>> for city in sorted(metro_data, key=itemgetter(1)):
... print(city)
...
('São Paulo', 'BR', 19.649, (-23.547778, -46.635833))
('Delhi NCR', 'IN', 21.935, (28.613889, 77.208889))
('Tokyo', 'JP', 36.933, (35.689722, 139.691667))
('Mexico City', 'MX', 20.142, (19.433333, -99.133333))
('New York-Newark', 'US', 20.104, (40.808611, -74.020386))
如果将多个索引参数传递给 itemgetter,则它构建的函数将返回提取的值的元组,这对于按多个键排序很有用:
>>> cc_name = itemgetter(1, 0)
>>> for city in metro_data:
... print(cc_name(city))
...
('JP', 'Tokyo')
('IN', 'Delhi NCR')
('MX', 'Mexico City')
('US', 'New York-Newark')
('BR', 'São Paulo')
>>>
因为 itemgetter 使用 [] 运算符,它不仅支持序列,还支持映射和任何实现 __getitem__ 的类。
itemgetter 的姐妹是 attrgetter,它通过名称创建提取对象属性的函数。如果将多个属性名称作为参数传递给 attrgetter,它还会返回一个值元组。此外,如果任何参数名称包含 .(点),attrgetter 将浏览嵌套对象以检索属性。这些行为在 示例 7-14 中展示。这不是最短的控制台会话,因为我们需要构建一个嵌套结构来展示 attrgetter 处理带点属性的方式。
示例 7-14. 使用 attrgetter 处理先前定义的 namedtuple 列表 metro_data(与 示例 7-13 中出现的相同列表)
>>> from collections import namedtuple
>>> LatLon = namedtuple('LatLon', 'lat lon') # ①
>>> Metropolis = namedtuple('Metropolis', 'name cc pop coord') # ②
>>> metro_areas = Metropolis(name, cc, pop, LatLon(lat, lon)) ![3
... for name, cc, pop, (lat, lon) in metro_data]
>>> metro_areas[0]
Metropolis(name='Tokyo', cc='JP', pop=36.933, coord=LatLon(lat=35.689722, lon=139.691667)) >>> metro_areas[0].coord.lat # ④
35.689722 >>> from operator import attrgetter
>>> name_lat = attrgetter('name', 'coord.lat') # ⑤
>>> >>> for city in sorted(metro_areas, key=attrgetter('coord.lat')): # ⑥
... print(name_lat(city)) # ⑦
...
('São Paulo', -23.547778) ('Mexico City', 19.433333) ('Delhi NCR', 28.613889) ('Tokyo', 35.689722) ('New York-Newark', 40.808611)
①
使用 namedtuple 定义 LatLon。
②
还要定义 Metropolis。
③
使用 Metropolis 实例构建 metro_areas 列表;注意嵌套元组解包以提取 (lat, lon) 并将其用于构建 Metropolis 的 coord 属性的 LatLon。
④
访问元素 metro_areas[0] 以获取其纬度。
⑤
定义一个 attrgetter 来检索 name 和 coord.lat 嵌套属性。
⑥
再次使用 attrgetter 按纬度对城市列表进行排序。
⑦
使用 ⑤ 中定义的 attrgetter 仅显示城市名称和纬度。
这是在 operator 中定义的函数的部分列表(以 _ 开头的名称被省略,因为它们主要是实现细节):
>>> [name for name in dir(operator) if not name.startswith('_')]
['abs', 'add', 'and_', 'attrgetter', 'concat', 'contains',
'countOf', 'delitem', 'eq', 'floordiv', 'ge', 'getitem', 'gt',
'iadd', 'iand', 'iconcat', 'ifloordiv', 'ilshift', 'imatmul',
'imod', 'imul', 'index', 'indexOf', 'inv', 'invert', 'ior',
'ipow', 'irshift', 'is_', 'is_not', 'isub', 'itemgetter',
'itruediv', 'ixor', 'le', 'length_hint', 'lshift', 'lt', 'matmul',
'methodcaller', 'mod', 'mul', 'ne', 'neg', 'not_', 'or_', 'pos',
'pow', 'rshift', 'setitem', 'sub', 'truediv', 'truth', 'xor']
列出的 54 个名称中大多数都是不言自明的。以i为前缀的名称组和另一个运算符的名称,例如iadd、iand等,对应于增强赋值运算符,例如+=、&=等。如果第一个参数是可变的,这些会在原地更改第一个参数;如果不是,该函数的工作方式类似于没有i前缀的函数:它只是返回操作的结果。
在剩余的operator函数中,methodcaller是我们将要介绍的最后一个。它在某种程度上类似于attrgetter和itemgetter,因为它会即时创建一个函数。它创建的函数会按名称在给定的对象上调用一个方法,就像示例 7-15 中所示的那样。
示例 7-15。methodcaller的演示:第二个测试显示了额外参数的绑定
>>> from operator import methodcaller
>>> s = 'The time has come'
>>> upcase = methodcaller('upper')
>>> upcase(s)
'THE TIME HAS COME'
>>> hyphenate = methodcaller('replace', ' ', '-')
>>> hyphenate(s)
'The-time-has-come'
示例 7-15 中的第一个测试只是为了展示methodcaller的工作原理,但如果您需要将str.upper作为一个函数使用,您可以直接在str类上调用它,并传递一个字符串作为参数,就像这样:
>>> str.upper(s)
'THE TIME HAS COME'
示例 7-15 中的第二个测试表明,methodcaller也可以进行部分应用,冻结一些参数,就像functools.partial函数一样。这是我们下一个主题。Bold Textopmod07
使用functools.partial冻结参数
functools模块提供了几个高阶函数。我们在“map、filter 和 reduce 的现代替代品”中看到了reduce。另一个是partial:给定一个可调用对象,它会生成一个新的可调用对象,其中原始可调用对象的一些参数绑定为预定值。这对于将接受一个或多个参数的函数适应需要较少参数的回调函数的 API 很有用。示例 7-16 是一个微不足道的演示。
示例 7-16。使用partial在需要一个参数可调用对象的地方使用两个参数函数
>>> from operator import mul
>>> from functools import partial
>>> triple = partial(mul, 3) # ①
>>> triple(7) # ②
21 >>> list(map(triple, range(1, 10))) # ③
[3, 6, 9, 12, 15, 18, 21, 24, 27]
①
从mul创建新的triple函数,将第一个位置参数绑定为3。
②
测试它。
③
使用triple与map;在这个例子中,mul无法与map一起使用。
一个更有用的示例涉及到我们在“为可靠比较标准化 Unicode”中看到的unicode.normalize函数。如果您使用来自许多语言的文本,您可能希望在比较或存储之前对任何字符串s应用unicode.normalize('NFC', s)。如果您经常这样做,最好有一个nfc函数来执行,就像示例 7-17 中那样。
示例 7-17。使用partial构建一个方便的 Unicode 标准化函数
>>> import unicodedata, functools
>>> nfc = functools.partial(unicodedata.normalize, 'NFC')
>>> s1 = 'café'
>>> s2 = 'cafe\u0301'
>>> s1, s2
('café', 'café')
>>> s1 == s2
False
>>> nfc(s1) == nfc(s2)
True
partial以可调用对象作为第一个参数,后跟要绑定的任意数量的位置参数和关键字参数。
示例 7-18 展示了partial与示例 7-9 中的tag函数一起使用,冻结一个位置参数和一个关键字参数。
示例 7-18。演示partial应用于示例 7-9 中的tag函数
>>> from tagger import tag
>>> tag
<function tag at 0x10206d1e0> # ①
>>> from functools import partial
>>> picture = partial(tag, 'img', class_='pic-frame') # ②
>>> picture(src='wumpus.jpeg')
'<img class="pic-frame" src="wumpus.jpeg" />' # ③
>>> picture
functools.partial(<function tag at 0x10206d1e0>, 'img', class_='pic-frame') # ④
>>> picture.func # ⑤
<function tag at 0x10206d1e0> >>> picture.args
('img',) >>> picture.keywords
{'class_': 'pic-frame'}
①
从示例 7-9 导入tag并显示其 ID。
②
通过使用tag从tag创建picture函数,通过使用'img'固定第一个位置参数和'pic-frame'关键字参数。
③
picture按预期工作。
④
partial()返回一个functools.partial对象。⁴
⑤
一个functools.partial对象具有提供对原始函数和固定参数的访问的属性。
functools.partialmethod 函数与 partial 执行相同的工作,但设计用于与方法一起使用。
functools 模块还包括设计用作函数装饰器的高阶函数,例如 cache 和 singledispatch 等。这些函数在第九章中有介绍,该章还解释了如何实现自定义装饰器。
章节总结
本章的目标是探索 Python 中函数的头等性质。主要思想是你可以将函数分配给变量,将它们传递给其他函数,将它们存储在数据结构中,并访问函数属性,从而使框架和工具能够根据这些信息进行操作。
高阶函数,作为函数式编程的基本要素,在 Python 中很常见。sorted、min 和 max 内置函数,以及 functools.partial 都是该语言中常用的高阶函数的例子。使用 map、filter 和 reduce 不再像以前那样常见,这要归功于列表推导式(以及类似的生成器表达式)以及新增的归约内置函数如 sum、all 和 any。
自 Python 3.6 起,可调用对象有九种不同的类型,从使用 lambda 创建的简单函数到实现 __call__ 的类实例。生成器和协程也是可调用的,尽管它们的行为与其他可调用对象非常不同。所有可调用对象都可以通过内置函数 callable() 进行检测。可调用对象提供了丰富的语法来声明形式参数,包括仅限关键字参数、仅限位置参数和注释。
最后,我们介绍了 operator 模块和 functools.partial 中的一些函数,通过最小化对功能受限的 lambda 语法的需求,促进了函数式编程。
进一步阅读
接下来的章节将继续探讨使用函数对象进行编程。第八章专注于函数参数和返回值中的类型提示。第九章深入探讨了函数装饰器——一种特殊的高阶函数,以及使其工作的闭包机制。第十章展示了头等函数如何简化一些经典的面向对象设计模式。
在Python 语言参考中,“3.2. 标准类型层次结构”介绍了九种可调用类型,以及所有其他内置类型。
Python Cookbook 第 3 版(O’Reilly)的第七章,由 David Beazley 和 Brian K. Jones 撰写,是对当前章节以及本书的第九章的极好补充,主要涵盖了相同概念但采用不同方法。
如果你对关键字参数的原理和用例感兴趣,请参阅PEP 3102—关键字参数。
了解 Python 中函数式编程的绝佳入门是 A. M. Kuchling 的“Python 函数式编程 HOWTO”。然而,该文本的主要焦点是迭代器和生成器的使用,这是第十七章的主题。
StackOverflow 上的问题“Python: 为什么 functools.partial 是必要的?”有一篇由经典著作Python in a Nutshell(O’Reilly)的合著者 Alex Martelli 所撰写的高度信息化(且有趣)的回答。
思考问题“Python 是一种函数式语言吗?”,我创作了我最喜欢的演讲之一,“超越范式”,我在 PyCaribbean、PyBay 和 PyConDE 上做过演讲。请查看我在柏林演讲中遇到本书两位技术审阅者 Miroslav Šedivý 和 Jürgen Gmach 的幻灯片和视频。
¹ 来自 Guido 的Python 的起源博客的“Python‘函数式’特性的起源”。
² 调用一个类通常会创建该类的一个实例,但通过重写__new__可以实现其他行为。我们将在“使用 new 实现灵活的对象创建”中看到一个例子。
³ 既然我们已经有了random.choice,为什么要构建一个BingoCage?choice函数可能多次返回相同的项,因为选定的项未从给定的集合中移除。调用BingoCage永远不会返回重复的结果——只要实例填充了唯一的值。
⁴ functools.py的源代码显示,functools.partial是用 C 实现的,并且默认情况下使用。 如果不可用,自 Python 3.4 起提供了partial的纯 Python 实现。
⁵ 在将代码粘贴到网络论坛时,还存在缩进丢失的问题,但我岔开了话题。
第八章:函数中的类型提示
还应强调Python 将保持动态类型语言,并且作者从未希望通过约定使类型提示成为强制要求。
Guido van Rossum,Jukka Lehtosalo 和Łukasz Langa,PEP 484—类型提示¹
类型提示是自 2001 年发布的 Python 2.2 中的类型和类的统一以来 Python 历史上最大的变化。然而,并非所有 Python 用户都同等受益于类型提示。这就是为什么它们应该始终是可选的。
PEP 484—类型提示引入了函数参数、返回值和变量的显式类型声明的语法和语义。其目标是通过静态分析帮助开发人员工具在不实际运行代码测试的情况下发现 Python 代码库中的错误。
主要受益者是使用 IDE(集成开发环境)和 CI(持续集成)的专业软件工程师。使类型提示对该群体具有吸引力的成本效益分析并不适用于所有 Python 用户。
Python 的用户群比这个宽广得多。它包括科学家、交易员、记者、艺术家、制造商、分析师和许多领域的学生等。对于他们中的大多数人来说,学习类型提示的成本可能更高——除非他们已经了解具有静态类型、子类型和泛型的语言。对于许多这些用户来说,由于他们与 Python 的交互方式以及他们的代码库和团队的规模较小——通常是“一个人的团队”,因此收益会较低。Python 的默认动态类型在编写用于探索数据和想法的代码时更简单、更具表现力,比如数据科学、创意计算和学习,
本章重点介绍 Python 函数签名中的类型提示。第十五章探讨了类的上下文中的类型提示,以及其他typing模块功能。
本章的主要主题包括:
-
一个关于使用 Mypy 逐渐类型化的实践介绍
-
鸭子类型和名义类型的互补视角
-
注解中可能出现的主要类型类别概述——这大约占了本章的 60%
-
类型提示可变参数(
*args,**kwargs) -
类型提示和静态类型化的限制和缺点
本章的新内容
本章是全新的。类型提示出现在我完成第一版流畅的 Python之后的 Python 3.5 中。
鉴于静态类型系统的局限性,PEP 484 的最佳想法是引入逐渐类型系统。让我们从定义这个概念开始。
关于逐渐类型化
PEP 484 向 Python 引入了逐渐类型系统。其他具有逐渐类型系统的语言包括微软的 TypeScript、Dart(由 Google 创建的 Flutter SDK 的语言)和 Hack(Facebook 的 HHVM 虚拟机支持的 PHP 方言)。Mypy 类型检查器本身起初是一种语言:一种逐渐类型化的 Python 方言,带有自己的解释器。Guido van Rossum 说服了 Mypy 的创造者 Jukka Lehtosalo,使其成为检查带注释的 Python 代码的工具。
逐渐类型系统:
是可选的
默认情况下,类型检查器不应对没有类型提示的代码发出警告。相反,当无法确定对象类型时,类型检查器会假定Any类型。Any类型被认为与所有其他类型兼容。
不会在运行时捕获类型错误
静态类型检查器、linter 和 IDE 使用类型提示来发出警告。它们不能阻止在运行时将不一致的值传递给函数或分配给变量。
不会增强性能
类型注释提供的数据理论上可以允许在生成的字节码中进行优化,但截至 2021 年 7 月,我所知道的任何 Python 运行时都没有实现这样的优化。²
逐步类型化最好的可用性特性是注释始终是可选的。
使用静态类型系统,大多数类型约束很容易表达,许多很繁琐,一些很困难,而一些则是不可能的。³ 你很可能会写出一段优秀的 Python 代码,具有良好的测试覆盖率和通过的测试,但仍然无法添加满足类型检查器的类型提示。没关系;只需省略有问题的类型提示并发布!
类型提示在所有级别都是可选的:你可以有完全没有类型提示的整个包,当你将其中一个这样的包导入到使用类型提示的模块时,你可以让类型检查器保持沉默,并且你可以添加特殊注释来让类型检查器忽略代码中特定的行。
提示
寻求 100% 的类型提示覆盖可能会刺激没有经过适当思考的类型提示,只是为了满足指标。这也会阻止团队充分利用 Python 的强大和灵活性。当注释会使 API 不够用户友好,或者不必要地复杂化其实现时,应该自然地接受没有类型提示的代码。
实践中的逐步类型化
让我们看看逐步类型化在实践中是如何工作的,从一个简单的函数开始,逐渐添加类型提示,由 Mypy 指导。
注意
有几个与 PEP 484 兼容的 Python 类型检查器,包括 Google 的 pytype、Microsoft 的 Pyright、Facebook 的 Pyre—以及嵌入在 IDE 中的类型检查器,如 PyCharm。我选择了 Mypy 作为示例,因为它是最知名的。然而,其他类型检查器可能更适合某些项目或团队。例如,Pytype 设计用于处理没有类型提示的代码库,并仍然提供有用的建议。它比 Mypy 更宽松,还可以为您的代码生成注释。
我们将为一个返回带有计数和单数或复数词的字符串的 show_count 函数添加注释:
>>> show_count(99, 'bird')
'99 birds'
>>> show_count(1, 'bird')
'1 bird'
>>> show_count(0, 'bird')
'no birds'
示例 8-1 展示了show_count的源代码,没有注释。
示例 8-1. messages.py 中没有类型提示的 show_count
def show_count(count, word):
if count == 1:
return f'1 {word}'
count_str = str(count) if count else 'no'
return f'{count_str} {word}s'
从 Mypy 开始
要开始类型检查,我在 messages.py 模块上运行 mypy 命令:
…/no_hints/ $ pip install mypy
[lots of messages omitted...]
…/no_hints/ $ mypy messages.py
Success: no issues found in 1 source file
使用默认设置的 Mypy 在 示例 8-1 中没有发现任何问题。
警告
我正在使用 Mypy 0.910,在我审阅这篇文章时是最新版本(2021 年 7 月)。Mypy 的 “介绍” 警告说它“正式是测试版软件。偶尔会有破坏向后兼容性的更改。” Mypy 给我至少一个与我在 2020 年 4 月写这一章时不同的报告。当你阅读这篇文章时,你可能会得到与这里显示的不同的结果。
如果函数签名没有注释,Mypy 默认会忽略它—除非另有配置。
对于 示例 8-2,我还有 pytest 单元测试。这是 messages_test.py 中的代码。
示例 8-2. messages_test.py 中没有类型提示
from pytest import mark
from messages import show_count
@mark.parametrize('qty, expected', [
(1, '1 part'),
(2, '2 parts'),
])
def test_show_count(qty, expected):
got = show_count(qty, 'part')
assert got == expected
def test_show_count_zero():
got = show_count(0, 'part')
assert got == 'no parts'
现在让我们根据 Mypy 添加类型提示。
使 Mypy 更严格
命令行选项 --disallow-untyped-defs 会使 Mypy 标记任何没有为所有参数和返回值添加类型提示的函数定义。
在测试文件上使用 --disallow-untyped-defs 会产生三个错误和一个注意:
…/no_hints/ $ mypy --disallow-untyped-defs messages_test.py
messages.py:14: error: Function is missing a type annotation
messages_test.py:10: error: Function is missing a type annotation
messages_test.py:15: error: Function is missing a return type annotation
messages_test.py:15: note: Use "-> None" if function does not return a value
Found 3 errors in 2 files (checked 1 source file)
对于逐步类型化的第一步,我更喜欢使用另一个选项:--disallow-incomplete-defs。最初,它对我毫无意义:
…/no_hints/ $ mypy --disallow-incomplete-defs messages_test.py
Success: no issues found in 1 source file
现在我可以只为 messages.py 中的 show_count 添加返回类型:
def show_count(count, word) -> str:
这已经足够让 Mypy 查看它。使用与之前相同的命令行检查 messages_test.py 将导致 Mypy 再次查看 messages.py:
…/no_hints/ $ mypy --disallow-incomplete-defs messages_test.py
messages.py:14: error: Function is missing a type annotation
for one or more arguments
Found 1 error in 1 file (checked 1 source file)
现在我可以逐步为每个函数添加类型提示,而不会收到关于我没有注释的函数的警告。这是一个完全注释的签名,满足了 Mypy:
def show_count(count: int, word: str) -> str:
提示
与其像--disallow-incomplete-defs这样输入命令行选项,你可以按照Mypy 配置文件文档中描述的方式保存你喜欢的选项。你可以有全局设置和每个模块的设置。以下是一个简单的mypy.ini示例:
[mypy]
python_version = 3.9
warn_unused_configs = True
disallow_incomplete_defs = True
默认参数值
示例 8-1 中的show_count函数只适用于常规名词。如果复数不能通过添加's'来拼写,我们应该让用户提供复数形式,就像这样:
>>> show_count(3, 'mouse', 'mice')
'3 mice'
让我们进行一点“类型驱动的开发”。首先我们添加一个使用第三个参数的测试。不要忘记为测试函数添加返回类型提示,否则 Mypy 将不会检查它。
def test_irregular() -> None:
got = show_count(2, 'child', 'children')
assert got == '2 children'
Mypy 检测到了错误:
…/hints_2/ $ mypy messages_test.py
messages_test.py:22: error: Too many arguments for "show_count"
Found 1 error in 1 file (checked 1 source file)
现在我编辑show_count,在示例 8-3 中添加了可选的plural参数。
示例 8-3. hints_2/messages.py中带有可选参数的showcount
def show_count(count: int, singular: str, plural: str = '') -> str:
if count == 1:
return f'1 {singular}'
count_str = str(count) if count else 'no'
if not plural:
plural = singular + 's'
return f'{count_str} {plural}'
现在 Mypy 报告“成功”。
警告
这里有一个 Python 无法捕捉的类型错误。你能发现吗?
def hex2rgb(color=str) -> tuple[int, int, int]:
Mypy 的错误报告并不是很有帮助:
colors.py:24: error: Function is missing a type
annotation for one or more arguments
color参数的类型提示应为color: str。我写成了color=str,这不是一个注释:它将color的默认值设置为str。
根据我的经验,这是一个常见的错误,很容易忽视,特别是在复杂的类型提示中。
以下细节被认为是类型提示的良好风格:
-
参数名和
:之间没有空格;:后有一个空格 -
在默认参数值之前的
=两侧留有空格
另一方面,PEP 8 表示如果对于特定参数没有类型提示,则=周围不应有空格。
使用None作为默认值
在示例 8-3 中,参数plural被注释为str,默认值为'',因此没有类型冲突。
我喜欢那个解决方案,但在其他情况下,None是更好的默认值。如果可选参数期望一个可变类型,那么None是唯一明智的默认值——正如我们在“可变类型作为参数默认值:不好的主意”中看到的。
要将None作为plural参数的默认值,签名将如下所示:
from typing import Optional
def show_count(count: int, singular: str, plural: Optional[str] = None) -> str:
让我们解开这个问题:
-
Optional[str]表示plural可以是str或None。 -
你必须明确提供默认值
= None。
如果你没有为plural分配默认值,Python 运行时将把它视为必需参数。记住:在运行时,类型提示会被忽略。
请注意,我们需要从typing模块导入Optional。在导入类型时,使用语法from typing import X是一个好习惯,可以缩短函数签名的长度。
警告
Optional不是一个很好的名称,因为该注释并不使参数变为可选的。使其可选的是为参数分配默认值。Optional[str]只是表示:该参数的类型可以是str或NoneType。在 Haskell 和 Elm 语言中,类似的类型被命名为Maybe。
现在我们已经初步了解了渐进类型,让我们考虑在实践中“类型”这个概念意味着什么。
类型由支持的操作定义
文献中对类型概念有许多定义。在这里,我们假设类型是一组值和一组可以应用于这些值的函数。
PEP 483—类型提示的理论
在实践中,将支持的操作集合视为类型的定义特征更有用。⁴
例如,从适用操作的角度来看,在以下函数中x的有效类型是什么?
def double(x):
return x * 2
x参数类型可以是数值型(int、complex、Fraction、numpy.uint32等),但也可以是序列(str、tuple、list、array)、N 维numpy.array,或者任何实现或继承接受int参数的__mul__方法的其他类型。
然而,请考虑这个带注释的 double。现在请忽略缺失的返回类型,让我们专注于参数类型:
from collections import abc
def double(x: abc.Sequence):
return x * 2
类型检查器将拒绝该代码。如果告诉 Mypy x 的类型是 abc.Sequence,它将标记 x * 2 为错误,因为 Sequence ABC 没有实现或继承 __mul__ 方法。在运行时,该代码将与具体序列(如 str、tuple、list、array 等)以及数字一起工作,因为在运行时会忽略类型提示。但类型检查器只关心显式声明的内容,abc.Sequence 没有 __mul__。
这就是为什么这一节的标题是“类型由支持的操作定义”。Python 运行时接受任何对象作为 x 参数传递给 double 函数的两个版本。计算 x * 2 可能有效,也可能会引发 TypeError,如果 x 不支持该操作。相比之下,Mypy 在分析带注释的 double 源代码时会声明 x * 2 为错误,因为它对于声明的类型 x: abc.Sequence 是不支持的操作。
在渐进式类型系统中,我们有两种不同类型观点的相互作用:
鸭子类型
Smalltalk——开创性的面向对象语言——以及 Python、JavaScript 和 Ruby 采用的视角。对象具有类型,但变量(包括参数)是无类型的。实际上,对象的声明类型是什么并不重要,只有它实际支持的操作才重要。如果我可以调用 birdie.quack(),那么在这个上下文中 birdie 就是一只鸭子。根据定义,鸭子类型只在运行时强制执行,当尝试对对象进行操作时。这比名义类型更灵活,但会在运行时允许更多的错误。⁵
名义类型
C++、Java 和 C# 采用的视角,由带注释的 Python 支持。对象和变量具有类型。但对象只在运行时存在,类型检查器只关心在变量(包括参数)被注释为类型提示的源代码中。如果 Duck 是 Bird 的一个子类,你可以将一个 Duck 实例分配给一个被注释为 birdie: Bird 的参数。但在函数体内,类型检查器认为调用 birdie.quack() 是非法的,因为 birdie 名义上是一个 Bird,而该类不提供 .quack() 方法。在运行时实际参数是 Duck 也无关紧要,因为名义类型是静态强制的。类型检查器不运行程序的任何部分,它只读取源代码。这比鸭子类型更严格,优点是在构建流水线中更早地捕获一些错误,甚至在代码在 IDE 中输入时。
Example 8-4 是一个愚蠢的例子,对比了鸭子类型和名义类型,以及静态类型检查和运行时行为。⁶
示例 8-4. birds.py
class Bird:
pass
class Duck(Bird): # ①
def quack(self):
print('Quack!')
def alert(birdie): # ②
birdie.quack()
def alert_duck(birdie: Duck) -> None: # ③
birdie.quack()
def alert_bird(birdie: Bird) -> None: # ④
birdie.quack()
①
Duck 是 Bird 的一个子类。
②
alert 没有类型提示,因此类型检查器会忽略它。
③
alert_duck 接受一个 Duck 类型的参数。
④
alert_bird 接受一个 Bird 类型的参数。
使用 Mypy 对 birds.py 进行类型检查,我们发现了一个问题:
…/birds/ $ mypy birds.py
birds.py:16: error: "Bird" has no attribute "quack"
Found 1 error in 1 file (checked 1 source file)
通过分析源代码,Mypy 发现 alert_bird 是有问题的:类型提示声明了 birdie 参数的类型为 Bird,但函数体调用了 birdie.quack(),而 Bird 类没有这样的方法。
现在让我们尝试在 daffy.py 中使用 birds 模块,参见 Example 8-5。
示例 8-5. daffy.py
from birds import *
daffy = Duck()
alert(daffy) # ①
alert_duck(daffy) # ②
alert_bird(daffy) # ③
①
这是有效的调用,因为 alert 没有类型提示。
②
这是有效的调用,因为 alert_duck 接受一个 Duck 参数,而 daffy 是一个 Duck。
③
有效的调用,因为alert_bird接受一个Bird参数,而daffy也是一个Bird——Duck的超类。
在daffy.py上运行 Mypy 会引发与在birds.py中定义的alert_bird函数中的quack调用相同的错误:
…/birds/ $ mypy daffy.py
birds.py:16: error: "Bird" has no attribute "quack"
Found 1 error in 1 file (checked 1 source file)
但是 Mypy 对daffy.py本身没有任何问题:这三个函数调用都是正确的。
现在,如果你运行daffy.py,你会得到以下结果:
…/birds/ $ python3 daffy.py
Quack!
Quack!
Quack!
一切正常!鸭子类型万岁!
在运行时,Python 不关心声明的类型。它只使用鸭子类型。Mypy 在alert_bird中标记了一个错误,但在运行时使用daffy调用它是没有问题的。这可能会让许多 Python 爱好者感到惊讶:静态类型检查器有时会发现我们知道会执行的程序中的错误。
然而,如果几个月后你被要求扩展这个愚蠢的鸟类示例,你可能会感激 Mypy。考虑一下woody.py模块,它也使用了birds,在示例 8-6 中。
示例 8-6. woody.py
from birds import *
woody = Bird()
alert(woody)
alert_duck(woody)
alert_bird(woody)
Mypy 在检查woody.py时发现了两个错误:
…/birds/ $ mypy woody.py
birds.py:16: error: "Bird" has no attribute "quack"
woody.py:5: error: Argument 1 to "alert_duck" has incompatible type "Bird";
expected "Duck"
Found 2 errors in 2 files (checked 1 source file)
第一个错误在birds.py中:在alert_bird中的birdie.quack()调用,我们之前已经看过了。第二个错误在woody.py中:woody是Bird的一个实例,所以调用alert_duck(woody)是无效的,因为该函数需要一个Duck。每个Duck都是一个Bird,但并非每个Bird都是一个Duck。
在运行时,woody.py中的所有调用都失败了。这些失败的连续性在示例 8-7 中的控制台会话中最好地说明。
示例 8-7. 运行时错误以及 Mypy 如何帮助
>>> from birds import *
>>> woody = Bird()
>>> alert(woody) # ①
Traceback (most recent call last):
...
AttributeError: 'Bird' object has no attribute 'quack'
>>>
>>> alert_duck(woody) # ②
Traceback (most recent call last):
...
AttributeError: 'Bird' object has no attribute 'quack'
>>>
>>> alert_bird(woody) # ③
Traceback (most recent call last):
...
AttributeError: 'Bird' object has no attribute 'quack'
①
Mypy 无法检测到这个错误,因为alert中没有类型提示。
②
Mypy 报告了问题:"alert_duck"的第 1 个参数类型不兼容:"Bird";预期是"Duck"。
③
自从示例 8-4 以来,Mypy 一直在告诉我们alert_bird函数的主体是错误的:"Bird"没有属性"quack"。
这个小实验表明,鸭子类型更容易上手,更加灵活,但允许不支持的操作在运行时引发错误。名义类型在运行前检测错误,但有时可能会拒绝实际运行的代码,比如在示例 8-5 中的调用alert_bird(daffy)。即使有时候能够运行,alert_bird函数的命名是错误的:它的主体确实需要支持.quack()方法的对象,而Bird没有这个方法。
在这个愚蠢的例子中,函数只有一行。但在实际代码中,它们可能会更长;它们可能会将birdie参数传递给更多函数,并且birdie参数的来源可能相距多个函数调用,这使得很难准确定位运行时错误的原因。类型检查器可以防止许多这样的错误在运行时发生。
注意
类型提示在适合放在书中的小例子中的价值是有争议的。随着代码库规模的增长,其好处也会增加。这就是为什么拥有数百万行 Python 代码的公司——如 Dropbox、Google 和 Facebook——投资于团队和工具,支持公司范围内采用类型提示,并在 CI 管道中检查其 Python 代码库的重要部分。
在本节中,我们探讨了鸭子类型和名义类型中类型和操作的关系,从简单的double()函数开始——我们没有为其添加适当的类型提示。当我们到达“静态协议”时,我们将看到如何为double()添加类型提示。但在那之前,还有更基本的类型需要了解。
可用于注释的类型
几乎任何 Python 类型都可以用作类型提示,但存在限制和建议。此外,typing模块引入了有时令人惊讶的语义的特殊构造。
本节涵盖了您可以在注释中使用的所有主要类型:
-
typing.Any -
简单类型和类
-
typing.Optional和typing.Union -
泛型集合,包括元组和映射
-
抽象基类
-
通用可迭代对象
-
参数化泛型和
TypeVar -
typing.Protocols—静态鸭子类型的关键 -
typing.Callable -
typing.NoReturn—一个结束这个列表的好方法
我们将依次介绍每一个,从一个奇怪的、显然无用但至关重要的类型开始。
任意类型
任何渐进式类型系统的基石是Any类型,也称为动态类型。当类型检查器看到这样一个未标记的函数时:
def double(x):
return x * 2
它假设这个:
def double(x: Any) -> Any:
return x * 2
这意味着x参数和返回值可以是任何类型,包括不同的类型。假定Any支持每种可能的操作。
将Any与object进行对比。考虑这个签名:
def double(x: object) -> object:
这个函数也接受每种类型的参数,因为每种类型都是object的子类型。
然而,类型检查器将拒绝这个函数:
def double(x: object) -> object:
return x * 2
问题在于object不支持__mul__操作。这就是 Mypy 报告的内容:
…/birds/ $ mypy double_object.py
double_object.py:2: error: Unsupported operand types for * ("object" and "int")
Found 1 error in 1 file (checked 1 source file)
更一般的类型具有更窄的接口,即它们支持更少的操作。object类实现的操作比abc.Sequence少,abc.Sequence实现的操作比abc.MutableSequence少,abc.MutableSequence实现的操作比list少。
但Any是一个神奇的类型,它同时位于类型层次结构的顶部和底部。它同时是最一般的类型—所以一个参数n: Any接受每种类型的值—和最专门的类型,支持每种可能的操作。至少,这就是类型检查器如何理解Any。
当然,没有任何类型可以支持每种可能的操作,因此使用Any可以防止类型检查器实现其核心任务:在程序因运行时异常而崩溃之前检测潜在的非法操作。
子类型与一致性
传统的面向对象的名义类型系统依赖于子类型关系。给定一个类T1和一个子类T2,那么T2是T1的子类型。
考虑这段代码:
class T1:
...
class T2(T1):
...
def f1(p: T1) -> None:
...
o2 = T2()
f1(o2) # OK
调用f1(o2)是对 Liskov 替换原则—LSP 的应用。Barbara Liskov⁷实际上是根据支持的操作定义是子类型:如果类型T2的对象替代类型T1的对象并且程序仍然正确运行,那么T2就是T1的子类型。
继续上述代码,这显示了 LSP 的违反:
def f2(p: T2) -> None:
...
o1 = T1()
f2(o1) # type error
从支持的操作的角度来看,这是完全合理的:作为一个子类,T2继承并且必须支持T1支持的所有操作。因此,T2的实例可以在期望T1的实例的任何地方使用。但反之不一定成立:T2可能实现额外的方法,因此T1的实例可能无法在期望T2的实例的任何地方使用。这种对支持的操作的关注体现在名称行为子类型化中,也用于指代 LSP。
在渐进式类型系统中,还有另一种关系:与一致,它适用于子类型适用的地方,对于类型Any有特殊规定。
与一致的规则是:
-
给定
T1和子类型T2,那么T2是与T1一致的(Liskov 替换)。 -
每种类型都与一致
Any:你可以将每种类型的对象传递给声明为Any类型的参数。 -
Any是与每种类型一致的:你总是可以在需要另一种类型的参数时传递一个Any类型的对象。
考虑前面定义的对象o1和o2,这里是有效代码的示例,说明规则#2 和#3:
def f3(p: Any) -> None:
...
o0 = object()
o1 = T1()
o2 = T2()
f3(o0) #
f3(o1) # all OK: rule #2
f3(o2) #
def f4(): # implicit return type: `Any`
...
o4 = f4() # inferred type: `Any`
f1(o4) #
f2(o4) # all OK: rule #3
f3(o4) #
每个渐进类型系统都需要像Any这样的通配类型。
提示
动词“推断”是“猜测”的花哨同义词,在类型分析的背景下使用。Python 和其他语言中的现代类型检查器并不要求在每个地方都有类型注释,因为它们可以推断出许多表达式的类型。例如,如果我写x = len(s) * 10,类型检查器不需要一个显式的本地声明来知道x是一个int,只要它能找到len内置函数的类型提示即可。
现在我们可以探索注解中使用的其余类型。
简单类型和类
像int、float、str和bytes这样的简单类型可以直接在类型提示中使用。标准库、外部包或用户定义的具体类——FrenchDeck、Vector2d和Duck——也可以在类型提示中使用。
抽象基类在类型提示中也很有用。当我们研究集合类型时,我们将回到它们,并在“抽象基类”中看到它们。
在类之间,一致的定义类似于子类型:子类与其所有超类一致。
然而,“实用性胜过纯粹性”,因此有一个重要的例外情况,我将在下面的提示中讨论。
int 与复杂一致
内置类型int、float和complex之间没有名义子类型关系:它们是object的直接子类。但 PEP 484声明 int与float一致,float与complex一致。在实践中是有道理的:int实现了float的所有操作,而且int还实现了额外的操作——位运算如&、|、<<等。最终结果是:int与complex一致。对于i = 3,i.real是3,i.imag是0。
可选和联合类型
我们在“使用 None 作为默认值”中看到了Optional特殊类型。它解决了将None作为默认值的问题,就像这个部分中的示例一样:
from typing import Optional
def show_count(count: int, singular: str, plural: Optional[str] = None) -> str:
构造Optional[str]实际上是Union[str, None]的快捷方式,这意味着plural的类型可以是str或None。
Python 3.10 中更好的可选和联合语法
自 Python 3.10 起,我们可以写str | bytes而不是Union[str, bytes]。这样打字更少,而且不需要从typing导入Optional或Union。对比show_count的plural参数的类型提示的旧语法和新语法:
plural: Optional[str] = None # before
plural: str | None = None # after
|运算符也适用于isinstance和issubclass来构建第二个参数:isinstance(x, int | str)。更多信息,请参阅PEP 604—Union[]的补充语法。
ord内置函数的签名是Union的一个简单示例——它接受str或bytes,并返回一个int:⁸
def ord(c: Union[str, bytes]) -> int: ...
这是一个接受str但可能返回str或float的函数示例:
from typing import Union
def parse_token(token: str) -> Union[str, float]:
try:
return float(token)
except ValueError:
return token
如果可能的话,尽量避免创建返回Union类型的函数,因为这会给用户增加额外的负担——迫使他们在运行时检查返回值的类型以知道如何处理它。但在前面代码中的parse_token是一个简单表达式求值器上下文中合理的用例。
提示
在“双模式 str 和 bytes API”中,我们看到接受str或bytes参数的函数,但如果参数是str则返回str,如果参数是bytes则返回bytes。在这些情况下,返回类型由输入类型确定,因此Union不是一个准确的解决方案。为了正确注释这样的函数,我们需要一个类型变量—在“参数化泛型和 TypeVar”中介绍—或重载,我们将在“重载签名”中看到。
Union[]需要至少两种类型。嵌套的Union类型与扁平化的Union具有相同的效果。因此,这种类型提示:
Union[A, B, Union[C, D, E]]
与以下相同:
Union[A, B, C, D, E]
Union 对于彼此不一致的类型更有用。例如:Union[int, float] 是多余的,因为 int 与 float 是一致的。如果只使用 float 来注释参数,它也将接受 int 值。
泛型集合
大多数 Python 集合是异构的。例如,你可以在 list 中放入任何不同类型的混合物。然而,在实践中,这并不是非常有用:如果将对象放入集合中,你可能希望以后对它们进行操作,通常这意味着它们必须至少共享一个公共方法。⁹
可以声明带有类型参数的泛型类型,以指定它们可以处理的项目的类型。
例如,一个 list 可以被参数化以限制其中元素的类型,就像你在 示例 8-8 中看到的那样。
示例 8-8. tokenize 中的 Python ≥ 3.9 类型提示
def tokenize(text: str) -> list[str]:
return text.upper().split()
在 Python ≥ 3.9 中,这意味着 tokenize 返回一个每个项目都是 str 类型的 list。
注释 stuff: list 和 stuff: list[Any] 意味着相同的事情:stuff 是任意类型对象的列表。
提示
如果你使用的是 Python 3.8 或更早版本,概念是相同的,但你需要更多的代码来使其工作,如可选框中所解释的 “遗留支持和已弃用的集合类型”。
PEP 585—标准集合中的泛型类型提示 列出了接受泛型类型提示的标准库集合。以下列表仅显示那些使用最简单形式的泛型类型提示 container[item] 的集合:
list collections.deque abc.Sequence abc.MutableSequence
set abc.Container abc.Set abc.MutableSet
frozenset abc.Collection
tuple 和映射类型支持更复杂的类型提示,我们将在各自的部分中看到。
截至 Python 3.10,目前还没有很好的方法来注释 array.array,考虑到 typecode 构造参数,该参数确定数组中存储的是整数还是浮点数。更难的问题是如何对整数范围进行类型检查,以防止在向数组添加元素时在运行时出现 OverflowError。例如,具有 typecode='B' 的 array 只能容纳从 0 到 255 的 int 值。目前,Python 的静态类型系统还无法应对这一挑战。
现在让我们看看如何注释泛型元组。
元组类型
有三种注释元组类型的方法:
-
元组作为记录
-
具有命名字段的元组作为记录
-
元组作为不可变序列
元组作为记录
如果将 tuple 用作记录,则使用内置的 tuple 并在 [] 中声明字段的类型。
例如,类型提示将是 tuple[str, float, str],以接受包含城市名称、人口和国家的元组:('上海', 24.28, '中国')。
考虑一个接受一对地理坐标并返回 Geohash 的函数,用法如下:
>>> shanghai = 31.2304, 121.4737
>>> geohash(shanghai)
'wtw3sjq6q'
示例 8-11 展示了如何定义 geohash,使用了来自 PyPI 的 geolib 包。
示例 8-11. coordinates.py 中的 geohash 函数
from geolib import geohash as gh # type: ignore # ①
PRECISION = 9
def geohash(lat_lon: tuple[float, float]) -> str: # ②
return gh.encode(*lat_lon, PRECISION)
①
此注释阻止 Mypy 报告 geolib 包没有类型提示。
②
lat_lon 参数注释为具有两个 float 字段的 tuple。
提示
对于 Python < 3.9,导入并在类型提示中使用 typing.Tuple。它已被弃用,但至少会保留在标准库中直到 2024 年。
具有命名字段的元组作为记录
要为具有许多字段的元组或代码中多处使用的特定类型的元组添加注释,我强烈建议使用 typing.NamedTuple,如 第五章 中所示。示例 8-12 展示了使用 NamedTuple 对 示例 8-11 进行变体的情况。
示例 8-12. coordinates_named.py 中的 NamedTuple Coordinates 和 geohash 函数
from typing import NamedTuple
from geolib import geohash as gh # type: ignore
PRECISION = 9
class Coordinate(NamedTuple):
lat: float
lon: float
def geohash(lat_lon: Coordinate) -> str:
return gh.encode(*lat_lon, PRECISION)
如“数据类构建器概述”中所解释的,typing.NamedTuple是tuple子类的工厂,因此Coordinate与tuple[float, float]是一致的,但反之则不成立——毕竟,Coordinate具有NamedTuple添加的额外方法,如._asdict(),还可以有用户定义的方法。
在实践中,这意味着将Coordinate实例传递给以下定义的display函数是类型安全的:
def display(lat_lon: tuple[float, float]) -> str:
lat, lon = lat_lon
ns = 'N' if lat >= 0 else 'S'
ew = 'E' if lon >= 0 else 'W'
return f'{abs(lat):0.1f}°{ns}, {abs(lon):0.1f}°{ew}'
元组作为不可变序列
要注释用作不可变列表的未指定长度元组,必须指定一个类型,后跟逗号和...(这是 Python 的省略号标记,由三个句点组成,而不是 Unicode U+2026—水平省略号)。
例如,tuple[int, ...]是一个具有int项的元组。
省略号表示接受任意数量的元素>= 1。无法指定任意长度元组的不同类型字段。
注释stuff: tuple[Any, ...]和stuff: tuple意思相同:stuff是一个未指定长度的包含任何类型对象的元组。
这里是一个columnize函数,它将一个序列转换为行和单元格的表格,形式为未指定长度的元组列表。这对于以列形式显示项目很有用,就像这样:
>>> animals = 'drake fawn heron ibex koala lynx tahr xerus yak zapus'.split()
>>> table = columnize(animals)
>>> table
[('drake', 'koala', 'yak'), ('fawn', 'lynx', 'zapus'), ('heron', 'tahr'),
('ibex', 'xerus')]
>>> for row in table:
... print(''.join(f'{word:10}' for word in row))
...
drake koala yak
fawn lynx zapus
heron tahr
ibex xerus
示例 8-13 展示了columnize的实现。注意返回类型:
list[tuple[str, ...]]
示例 8-13. columnize.py返回一个字符串元组列表
from collections.abc import Sequence
def columnize(
sequence: Sequence[str], num_columns: int = 0
) -> list[tuple[str, ...]]:
if num_columns == 0:
num_columns = round(len(sequence) ** 0.5)
num_rows, reminder = divmod(len(sequence), num_columns)
num_rows += bool(reminder)
return [tuple(sequence[i::num_rows]) for i in range(num_rows)]
通用映射
通用映射类型被注释为MappingType[KeyType, ValueType]。内置的dict和collections以及collections.abc中的映射类型在 Python ≥ 3.9 中接受该表示法。对于早期版本,必须使用typing.Dict和typing模块中的其他映射类型,如“遗留支持和已弃用的集合类型”中所述。
示例 8-14 展示了一个函数返回倒排索引以通过名称搜索 Unicode 字符的实际用途——这是示例 4-21 的一个变体,更适合我们将在第二十一章中学习的服务器端代码。
给定起始和结束的 Unicode 字符代码,name_index返回一个dict[str, set[str]],这是一个将每个单词映射到具有该单词在其名称中的字符集的倒排索引。例如,在对 ASCII 字符从 32 到 64 进行索引后,这里是映射到单词'SIGN'和'DIGIT'的字符集,以及如何找到名为'DIGIT EIGHT'的字符:
>>> index = name_index(32, 65)
>>> index['SIGN']
{'$', '>', '=', '+', '<', '%', '#'}
>>> index['DIGIT']
{'8', '5', '6', '2', '3', '0', '1', '4', '7', '9'}
>>> index['DIGIT'] & index['EIGHT']
{'8'}
示例 8-14 展示了带有name_index函数的charindex.py源代码。除了dict[]类型提示外,这个示例还有三个本书中首次出现的特性。
示例 8-14. charindex.py
import sys
import re
import unicodedata
from collections.abc import Iterator
RE_WORD = re.compile(r'\w+')
STOP_CODE = sys.maxunicode + 1
def tokenize(text: str) -> Iterator[str]: # ①
"""return iterable of uppercased words"""
for match in RE_WORD.finditer(text):
yield match.group().upper()
def name_index(start: int = 32, end: int = STOP_CODE) -> dict[str, set[str]]:
index: dict[str, set[str]] = {} # ②
for char in (chr(i) for i in range(start, end)):
if name := unicodedata.name(char, ''): # ③
for word in tokenize(name):
index.setdefault(word, set()).add(char)
return index
①
tokenize是一个生成器函数。第十七章是关于生成器的。
②
局部变量index已经被注释。没有提示,Mypy 会说:需要为'index'注释类型(提示:“index: dict[<type>, <type>] = ...”)。
③
我在if条件中使用了海象操作符:=。它将unicodedata.name()调用的结果赋给name,整个表达式的值就是该结果。当结果为''时,为假值,index不会被更新。¹¹
注意
当将dict用作记录时,通常所有键都是str类型,具体取决于键的不同类型的值。这在“TypedDict”中有所涵盖。
抽象基类
在发送内容时要保守,在接收内容时要开放。
波斯特尔法则,又称韧性原则
表 8-1 列出了几个来自 collections.abc 的抽象类。理想情况下,一个函数应该接受这些抽象类型的参数,或者在 Python 3.9 之前使用它们的 typing 等效类型,而不是具体类型。这样可以给调用者更多的灵活性。
考虑这个函数签名:
from collections.abc import Mapping
def name2hex(name: str, color_map: Mapping[str, int]) -> str:
使用 abc.Mapping 允许调用者提供 dict、defaultdict、ChainMap、UserDict 子类或任何其他是 Mapping 的子类型的类型的实例。
相比之下,考虑这个签名:
def name2hex(name: str, color_map: dict[str, int]) -> str:
现在 color_map 必须是一个 dict 或其子类型之一,比如 defaultDict 或 OrderedDict。特别是,collections.UserDict 的子类不会通过 color_map 的类型检查,尽管这是创建用户定义映射的推荐方式,正如我们在 “子类化 UserDict 而不是 dict” 中看到的那样。Mypy 会拒绝 UserDict 或从它派生的类的实例,因为 UserDict 不是 dict 的子类;它们是同级。两者都是 abc.MutableMapping 的子类。¹²
因此,一般来说最好在参数类型提示中使用 abc.Mapping 或 abc.MutableMapping,而不是 dict(或在旧代码中使用 typing.Dict)。如果 name2hex 函数不需要改变给定的 color_map,那么 color_map 的最准确的类型提示是 abc.Mapping。这样,调用者不需要提供实现 setdefault、pop 和 update 等方法的对象,这些方法是 MutableMapping 接口的一部分,但不是 Mapping 的一部分。这与 Postel 法则的第二部分有关:“在接受输入时要宽容。”
Postel 法则还告诉我们在发送内容时要保守。函数的返回值始终是一个具体对象,因此返回类型提示应该是一个具体类型,就像来自 “通用集合” 的示例一样—使用 list[str]:
def tokenize(text: str) -> list[str]:
return text.upper().split()
在 typing.List 的条目中,Python 文档中写道:
list的泛型版本。用于注释返回类型。为了注释参数,最好使用抽象集合类型,如Sequence或Iterable。
在 typing.Dict 和 typing.Set 的条目中也有类似的评论。
请记住,collections.abc 中的大多数 ABCs 和其他具体类,以及内置集合,都支持类似 collections.deque[str] 的泛型类型提示符号,从 Python 3.9 开始。相应的 typing 集合仅需要支持在 Python 3.8 或更早版本中编写的代码。变成泛型的类的完整列表出现在 “实现” 部分的 PEP 585—标准集合中的类型提示泛型 中。
结束我们关于类型提示中 ABCs 的讨论,我们需要谈一谈 numbers ABCs。
数字塔的崩塌
numbers 包定义了在 PEP 3141—为数字定义的类型层次结构 中描述的所谓数字塔。该塔是一种线性的 ABC 层次结构,顶部是 Number:
-
Number -
Complex -
Real -
Rational -
Integral
这些 ABCs 对于运行时类型检查非常有效,但不支持静态类型检查。PEP 484 的 “数字塔” 部分拒绝了 numbers ABCs,并规定内置类型 complex、float 和 int 应被视为特殊情况,如 “int 与 complex 一致” 中所解释的那样。
我们将在 “numbers ABCs 和数字协议” 中回到这个问题,在 第十三章 中,该章节专门对比协议和 ABCs。
实际上,如果您想要为静态类型检查注释数字参数,您有几个选择:
-
使用
int、float或complex中的一个具体类型—正如 PEP 488 建议的那样。 -
声明一个联合类型,如
Union[float, Decimal, Fraction]。 -
如果想避免硬编码具体类型,请使用像
SupportsFloat这样的数值协议,详见“运行时可检查的静态协议”。
即将到来的章节“静态协议”是理解数值协议的先决条件。
与此同时,让我们来看看对于类型提示最有用的 ABC 之一:Iterable。
可迭代对象
我刚引用的 typing.List 文档建议在函数参数类型提示中使用 Sequence 和 Iterable。
Iterable 参数的一个示例出现在标准库中的 math.fsum 函数中:
def fsum(__seq: Iterable[float]) -> float:
存根文件和 Typeshed 项目
截至 Python 3.10,标准库没有注释,但 Mypy、PyCharm 等可以在 Typeshed 项目中找到必要的类型提示,形式为存根文件:特殊的带有 .pyi 扩展名的源文件,具有带注释的函数和方法签名,但没有实现——类似于 C 中的头文件。
math.fsum 的签名在 /stdlib/2and3/math.pyi 中。__seq 中的前导下划线是 PEP 484 中关于仅限位置参数的约定,解释在“注释仅限位置参数和可变参数”中。
示例 8-15 是另一个使用 Iterable 参数的示例,产生的项目是 tuple[str, str]。以下是函数的使用方式:
>>> l33t = [('a', '4'), ('e', '3'), ('i', '1'), ('o', '0')]
>>> text = 'mad skilled noob powned leet'
>>> from replacer import zip_replace
>>> zip_replace(text, l33t)
'm4d sk1ll3d n00b p0wn3d l33t'
示例 8-15 展示了它的实现方式。
示例 8-15. replacer.py
from collections.abc import Iterable
FromTo = tuple[str, str] # ①
def zip_replace(text: str, changes: Iterable[FromTo]) -> str: # ②
for from_, to in changes:
text = text.replace(from_, to)
return text
①
FromTo 是一个类型别名:我将 tuple[str, str] 赋给 FromTo,以使 zip_replace 的签名更易读。
②
changes 需要是一个 Iterable[FromTo];这与 Iterable[tuple[str, str]] 相同,但更短且更易读。
Python 3.10 中的显式 TypeAlias
PEP 613—显式类型别名引入了一个特殊类型,TypeAlias,用于使创建类型别名的赋值更加可见和易于类型检查。从 Python 3.10 开始,这是创建类型别名的首选方式:
from typing import TypeAlias
FromTo: TypeAlias = tuple[str, str]
abc.Iterable 与 abc.Sequence
math.fsum 和 replacer.zip_replace 都必须遍历整个 Iterable 参数才能返回结果。如果给定一个无限迭代器,比如 itertools.cycle 生成器作为输入,这些函数将消耗所有内存并导致 Python 进程崩溃。尽管存在潜在的危险,但在现代 Python 中,提供接受 Iterable 输入的函数即使必须完全处理它才能返回结果是相当常见的。这样一来,调用者可以选择将输入数据提供为生成器,而不是预先构建的序列,如果输入项的数量很大,可能会节省大量内存。
另一方面,来自示例 8-13 的 columnize 函数需要一个 Sequence 参数,而不是 Iterable,因为它必须获取输入的 len() 来提前计算行数。
与 Sequence 类似,Iterable 最适合用作参数类型。作为返回类型太模糊了。函数应该更加精确地说明返回的具体类型。
与 Iterable 密切相关的是 Iterator 类型,在 示例 8-14 中用作返回类型。我们将在第十七章中回到这个话题,讨论生成器和经典迭代器。
参数化泛型和 TypeVar
参数化泛型是一种泛型类型,写作 list[T],其中 T 是一个类型变量,将在每次使用时绑定到特定类型。这允许参数类型反映在结果类型上。
示例 8-16 定义了sample,一个接受两个参数的函数:类型为T的元素的Sequence和一个int。它从第一个参数中随机选择的相同类型T的元素的list。
示例 8-16 展示了实现。
示例 8-16。sample.py
from collections.abc import Sequence
from random import shuffle
from typing import TypeVar
T = TypeVar('T')
def sample(population: Sequence[T], size: int) -> list[T]:
if size < 1:
raise ValueError('size must be >= 1')
result = list(population)
shuffle(result)
return result[:size]
这里有两个例子说明我在sample中使用了一个类型变量:
-
如果使用类型为
tuple[int, ...]的元组——这与Sequence[int]一致——那么类型参数是int,因此返回类型是list[int]。 -
如果使用
str——这与Sequence[str]一致——那么类型参数是str,因此返回类型是list[str]。
为什么需要 TypeVar?
PEP 484 的作者希望通过添加typing模块引入类型提示,而不改变语言的其他任何内容。通过巧妙的元编程,他们可以使[]运算符在类似Sequence[T]的类上起作用。但括号内的T变量名称必须在某处定义,否则 Python 解释器需要进行深层更改才能支持通用类型符号作为[]的特殊用途。这就是为什么需要typing.TypeVar构造函数:引入当前命名空间中的变量名称。像 Java、C#和 TypeScript 这样的语言不需要事先声明类型变量的名称,因此它们没有 Python 的TypeVar类的等价物。
另一个例子是标准库中的statistics.mode函数,它返回系列中最常见的数据点。
这里是来自文档的一个使用示例:
>>> mode([1, 1, 2, 3, 3, 3, 3, 4])
3
如果不使用TypeVar,mode可能具有示例 8-17 中显示的签名。
示例 8-17。mode_float.py:对float和子类型进行操作的mode¹³
from collections import Counter
from collections.abc import Iterable
def mode(data: Iterable[float]) -> float:
pairs = Counter(data).most_common(1)
if len(pairs) == 0:
raise ValueError('no mode for empty data')
return pairs[0][0]
许多mode的用法涉及int或float值,但 Python 还有其他数值类型,希望返回类型遵循给定Iterable的元素类型。我们可以使用TypeVar来改进该签名。让我们从一个简单但错误的参数化签名开始:
from collections.abc import Iterable
from typing import TypeVar
T = TypeVar('T')
def mode(data: Iterable[T]) -> T:
当类型参数T首次出现在签名中时,它可以是任何类型。第二次出现时,它将意味着与第一次相同的类型。
因此,每个可迭代对象都与Iterable[T]一致,包括collections.Counter无法处理的不可哈希类型的可迭代对象。我们需要限制分配给T的可能类型。我们将在接下来的两节中看到两种方法。
限制的 TypeVar
TypeVar接受额外的位置参数来限制类型参数。我们可以改进mode的签名,接受特定的数字类型,就像这样:
from collections.abc import Iterable
from decimal import Decimal
from fractions import Fraction
from typing import TypeVar
NumberT = TypeVar('NumberT', float, Decimal, Fraction)
def mode(data: Iterable[NumberT]) -> NumberT:
这比以前好,这是 2020 年 5 月 25 日typeshed上statistics.pyi存根文件中mode的签名。
然而,statistics.mode文档中包含了这个例子:
>>> mode(["red", "blue", "blue", "red", "green", "red", "red"])
'red'
匆忙之间,我们可以将str添加到NumberT的定义中:
NumberT = TypeVar('NumberT', float, Decimal, Fraction, str)
当然,这样做是有效的,但如果它接受str,那么NumberT的命名就非常不合适。更重要的是,我们不能永远列出类型,因为我们意识到mode可以处理它们。我们可以通过TypeVar的另一个特性做得更好,接下来介绍。
有界的 TypeVar
查看示例 8-17 中mode的主体,我们看到Counter类用于排名。Counter 基于dict,因此data可迭代对象的元素类型必须是可哈希的。
起初,这个签名似乎可以工作:
from collections.abc import Iterable, Hashable
def mode(data: Iterable[Hashable]) -> Hashable:
现在的问题是返回项的类型是Hashable:一个只实现__hash__方法的 ABC。因此,类型检查器不会让我们对返回值做任何事情,除了调用hash()。并不是很有用。
解决方案是TypeVar的另一个可选参数:bound关键字参数。它为可接受的类型设置了一个上限。在示例 8-18 中,我们有bound=Hashable,这意味着类型参数可以是Hashable或其任何子类型。¹⁴
示例 8-18。mode_hashable.py:与示例 8-17 相同,但具有更灵活的签名
from collections import Counter
from collections.abc import Iterable, Hashable
from typing import TypeVar
HashableT = TypeVar('HashableT', bound=Hashable)
def mode(data: Iterable[HashableT]) -> HashableT:
pairs = Counter(data).most_common(1)
if len(pairs) == 0:
raise ValueError('no mode for empty data')
return pairs[0][0]
总结一下:
-
限制类型变量将被设置为
TypeVar声明中命名的类型之一。 -
有界类型变量将被设置为表达式的推断类型——只要推断类型与
TypeVar的bound=关键字参数中声明的边界一致即可。
注意
不幸的是,声明有界TypeVar的关键字参数被命名为bound=,因为动词“绑定”通常用于表示设置变量的值,在 Python 的引用语义中最好描述为将名称绑定到值。如果关键字参数被命名为boundary=会更少令人困惑。
typing.TypeVar构造函数还有其他可选参数——covariant和contravariant——我们将在第十五章中介绍,“Variance”中涵盖。
让我们用AnyStr结束对TypeVar的介绍。
预定义的 AnyStr 类型变量
typing模块包括一个预定义的TypeVar,名为AnyStr。它的定义如下:
AnyStr = TypeVar('AnyStr', bytes, str)
AnyStr在许多接受bytes或str的函数中使用,并返回给定类型的值。
现在,让我们来看看typing.Protocol,这是 Python 3.8 的一个新特性,可以支持更具 Python 风格的类型提示的使用。
静态协议
注意
在面向对象编程中,“协议”概念作为一种非正式接口的概念早在 Smalltalk 中就存在,并且从一开始就是 Python 的一个基本部分。然而,在类型提示的背景下,协议是一个typing.Protocol子类,定义了一个类型检查器可以验证的接口。这两种类型的协议在第十三章中都有涉及。这只是在函数注释的背景下的简要介绍。
如PEP 544—Protocols: Structural subtyping (static duck typing)中所述,Protocol类型类似于 Go 中的接口:通过指定一个或多个方法来定义协议类型,并且类型检查器验证在需要该协议类型的地方这些方法是否被实现。
在 Python 中,协议定义被写作typing.Protocol子类。然而,实现协议的类不需要继承、注册或声明与定义协议的类的任何关系。这取决于类型检查器找到可用的协议类型并强制执行它们的使用。
这是一个可以借助Protocol和TypeVar解决的问题。假设您想创建一个函数top(it, n),返回可迭代对象it中最大的n个元素:
>>> top([4, 1, 5, 2, 6, 7, 3], 3)
[7, 6, 5]
>>> l = 'mango pear apple kiwi banana'.split()
>>> top(l, 3)
['pear', 'mango', 'kiwi']
>>>
>>> l2 = [(len(s), s) for s in l]
>>> l2
[(5, 'mango'), (4, 'pear'), (5, 'apple'), (4, 'kiwi'), (6, 'banana')]
>>> top(l2, 3)
[(6, 'banana'), (5, 'mango'), (5, 'apple')]
一个参数化的泛型top看起来像示例 8-19 中所示的样子。
示例 8-19。带有未定义T类型参数的top函数
def top(series: Iterable[T], length: int) -> list[T]:
ordered = sorted(series, reverse=True)
return ordered[:length]
问题是如何约束T?它不能是Any或object,因为series必须与sorted一起工作。sorted内置实际上接受Iterable[Any],但这是因为可选参数key接受一个函数,该函数从每个元素计算任意排序键。如果您给sorted一个普通对象列表但不提供key参数会发生什么?让我们试试:
>>> l = [object() for _ in range(4)]
>>> l
[<object object at 0x10fc2fca0>, <object object at 0x10fc2fbb0>,
<object object at 0x10fc2fbc0>, <object object at 0x10fc2fbd0>]
>>> sorted(l)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: '<' not supported between instances of 'object' and 'object'
错误消息显示sorted在可迭代对象的元素上使用<运算符。这就是全部吗?让我们做另一个快速实验:¹⁵
>>> class Spam:
... def __init__(self, n): self.n = n
... def __lt__(self, other): return self.n < other.n
... def __repr__(self): return f'Spam({self.n})'
...
>>> l = [Spam(n) for n in range(5, 0, -1)]
>>> l
[Spam(5), Spam(4), Spam(3), Spam(2), Spam(1)]
>>> sorted(l)
[Spam(1), Spam(2), Spam(3), Spam(4), Spam(5)]
那证实了:我可以对Spam列表进行sort,因为Spam实现了__lt__——支持<运算符的特殊方法。
因此,示例 8-19 中的 T 类型参数应该限制为实现 __lt__ 的类型。在 示例 8-18 中,我们需要一个实现 __hash__ 的类型参数,因此我们可以使用 typing.Hashable 作为类型参数的上界。但是现在在 typing 或 abc 中没有适合的类型,因此我们需要创建它。
示例 8-20 展示了新的 SupportsLessThan 类型,一个 Protocol。
示例 8-20. comparable.py: SupportsLessThan Protocol 类型的定义
from typing import Protocol, Any
class SupportsLessThan(Protocol): # ①
def __lt__(self, other: Any) -> bool: ... # ②
①
协议是 typing.Protocol 的子类。
②
协议的主体有一个或多个方法定义,方法体中有 ...。
如果类型 T 实现了 P 中定义的所有方法,并且类型签名匹配,则类型 T 与协议 P 一致。
有了 SupportsLessThan,我们现在可以在 示例 8-21 中定义这个可工作的 top 版本。
示例 8-21. top.py: 使用 TypeVar 和 bound=SupportsLessThan 定义 top 函数
from collections.abc import Iterable
from typing import TypeVar
from comparable import SupportsLessThan
LT = TypeVar('LT', bound=SupportsLessThan)
def top(series: Iterable[LT], length: int) -> list[LT]:
ordered = sorted(series, reverse=True)
return ordered[:length]
让我们来测试 top。示例 8-22 展示了一部分用于 pytest 的测试套件。首先尝试使用生成器表达式调用 top,该表达式生成 tuple[int, str],然后使用 object 列表。对于 object 列表,我们期望得到一个 TypeError 异常。
示例 8-22. top_test.py: top 测试套件的部分清单
from collections.abc import Iterator
from typing import TYPE_CHECKING # ①
import pytest
from top import top
# several lines omitted
def test_top_tuples() -> None:
fruit = 'mango pear apple kiwi banana'.split()
series: Iterator[tuple[int, str]] = ( # ②
(len(s), s) for s in fruit)
length = 3
expected = [(6, 'banana'), (5, 'mango'), (5, 'apple')]
result = top(series, length)
if TYPE_CHECKING: # ③
reveal_type(series) # ④
reveal_type(expected)
reveal_type(result)
assert result == expected
# intentional type error
def test_top_objects_error() -> None:
series = [object() for _ in range(4)]
if TYPE_CHECKING:
reveal_type(series)
with pytest.raises(TypeError) as excinfo:
top(series, 3) # ⑤
assert "'<' not supported" in str(excinfo.value)
①
typing.TYPE_CHECKING 常量在运行时始终为 False,但类型检查器在进行类型检查时会假装它为 True。
②
显式声明 series 变量的类型,以使 Mypy 输出更易读。¹⁶
③
这个 if 阻止了接下来的三行在测试运行时执行。
④
reveal_type() 不能在运行时调用,因为它不是常规函数,而是 Mypy 的调试工具—这就是为什么没有为它导入任何内容。对于每个 reveal_type() 伪函数调用,Mypy 将输出一条调试消息,显示参数的推断类型。
⑤
这一行将被 Mypy 标记为错误。
前面的测试通过了—但无论是否在 top.py 中有类型提示,它们都会通过。更重要的是,如果我用 Mypy 检查该测试文件,我会看到 TypeVar 正如预期的那样工作。查看 示例 8-23 中的 mypy 命令输出。
警告
截至 Mypy 0.910(2021 年 7 月),reveal_type 的输出在某些情况下并不精确显示我声明的类型,而是显示兼容的类型。例如,我没有使用 typing.Iterator,而是使用了 abc.Iterator。请忽略这个细节。Mypy 的输出仍然有用。在讨论输出时,我会假装 Mypy 的这个问题已经解决。
示例 8-23. mypy top_test.py 的输出(为了可读性而拆分的行)
…/comparable/ $ mypy top_test.py
top_test.py:32: note:
Revealed type is "typing.Iterator[Tuple[builtins.int, builtins.str]]" # ①
top_test.py:33: note:
Revealed type is "builtins.list[Tuple[builtins.int, builtins.str]]"
top_test.py:34: note:
Revealed type is "builtins.list[Tuple[builtins.int, builtins.str]]" # ②
top_test.py:41: note:
Revealed type is "builtins.list[builtins.object*]" # ③
top_test.py:43: error:
Value of type variable "LT" of "top" cannot be "object" # ④
Found 1 error in 1 file (checked 1 source file)
①
在 test_top_tuples 中,reveal_type(series) 显示它是一个 Iterator[tuple[int, str]]—这是我明确声明的。
②
reveal_type(result) 确认了 top 调用返回的类型是我想要的:给定 series 的类型,result 是 list[tuple[int, str]]。
③
在 test_top_objects_error 中,reveal_type(series) 显示为 list[object*]。Mypy 在任何推断的类型后面加上 *:我没有在这个测试中注释 series 的类型。
④
Mypy 标记了这个测试故意触发的错误:Iterable series的元素类型不能是object(必须是SupportsLessThan类型)。
协议类型相对于 ABCs 的一个关键优势是,一个类型不需要任何特殊声明来与协议类型一致。这允许创建一个协议利用预先存在的类型,或者在我们无法控制的代码中实现的类型。我不需要派生或注册str、tuple、float、set等类型到SupportsLessThan以在期望SupportsLessThan参数的地方使用它们。它们只需要实现__lt__。而类型检查器仍然能够完成其工作,因为SupportsLessThan被明确定义为Protocol—与鸭子类型常见的隐式协议相反,这些协议对类型检查器是不可见的。
特殊的Protocol类在PEP 544—Protocols: Structural subtyping (static duck typing)中引入。示例 8-21 展示了为什么这个特性被称为静态鸭子类型:注释top的series参数的解决方案是说“series的名义类型并不重要,只要它实现了__lt__方法。”Python 的鸭子类型总是允许我们隐式地说这一点,让静态类型检查器一头雾水。类型检查器无法阅读 CPython 的 C 源代码,或者执行控制台实验来发现sorted只需要元素支持<。
现在我们可以为静态类型检查器明确地定义鸭子类型。这就是为什么说typing.Protocol给我们静态鸭子类型是有意义的。¹⁷
还有更多关于typing.Protocol的内容。我们将在第四部分回来讨论它,在第十三章中对比结构化类型、鸭子类型和 ABCs——另一种形式化协议的方法。此外,“重载签名”(第十五章)解释了如何使用@typing.overload声明重载函数签名,并包括了一个使用typing.Protocol和有界TypeVar的广泛示例。
注意
typing.Protocol使得可以注释“类型由支持的操作定义”中提到的double函数而不会失去功能。关键是定义一个带有__mul__方法的协议类。我邀请你将其作为练习完成。解决方案出现在“类型化的 double 函数”中(第十三章)。
Callable
为了注释回调参数或由高阶函数返回的可调用对象,collections.abc模块提供了Callable类型,在尚未使用 Python 3.9 的情况下在typing模块中可用。Callable类型的参数化如下:
Callable[[ParamType1, ParamType2], ReturnType]
参数列表—[ParamType1, ParamType2]—可以有零个或多个类型。
这是在我们将在“lis.py 中的模式匹配:案例研究”中看到的一个repl函数的示例:¹⁸
def repl(input_fn: Callable[[Any], str] = input]) -> None:
在正常使用中,repl函数使用 Python 的input内置函数从用户那里读取表达式。然而,对于自动化测试或与其他输入源集成,repl接受一个可选的input_fn参数:一个与input具有相同参数和返回类型的Callable。
内置的input在 typeshed 上有这个签名:
def input(__prompt: Any = ...) -> str: ...
input的签名与这个Callable类型提示一致:
Callable[[Any], str]
没有语法来注释可选或关键字参数类型。typing.Callable的文档说“这样的函数类型很少用作回调类型。”如果你需要一个类型提示来匹配具有灵活签名的函数,用...替换整个参数列表—就像这样:
Callable[..., ReturnType]
泛型类型参数与类型层次结构的交互引入了一个新的类型概念:variance。
Callable 类型中的 variance
想象一个简单的温度控制系统,其中有一个简单的update函数,如示例 8-24 所示。update函数调用probe函数获取当前温度,并调用display显示温度给用户。probe和display都作为参数传递给update是为了教学目的。示例的目标是对比两个Callable注释:一个有返回类型,另一个有参数类型。
示例 8-24。说明 variance。
from collections.abc import Callable
def update( # ①
probe: Callable[[], float], # ②
display: Callable[[float], None] # ③
) -> None:
temperature = probe()
# imagine lots of control code here
display(temperature)
def probe_ok() -> int: # ④
return 42
def display_wrong(temperature: int) -> None: # ⑤
print(hex(temperature))
update(probe_ok, display_wrong) # type error # ⑥
def display_ok(temperature: complex) -> None: # ⑦
print(temperature)
update(probe_ok, display_ok) # OK # ⑧
①
update接受两个可调用对象作为参数。
②
probe必须是一个不带参数并返回float的可调用对象。
③
display接受一个float参数并返回None。
④
probe_ok与Callable[[], float]一致,因为返回一个int不会破坏期望float的代码。
⑤
display_wrong与Callable[[float], None]不一致,因为没有保证一个期望int的函数能处理一个float;例如,Python 的hex函数接受一个int但拒绝一个float。
⑥
Mypy 标记这行是因为display_wrong与update的display参数中的类型提示不兼容。
⑦
display_ok与Callable[[float], None]一致,因为一个接受complex的函数也可以处理一个float参数。
⑧
Mypy 对这行很满意。
总结一下,当代码期望返回float的回调时,提供返回int的回调是可以的,因为int值总是可以在需要float的地方使用。
正式地说,Callable[[], int]是subtype-ofCallable[[], float]——因为int是subtype-offloat。这意味着Callable在返回类型上是协变的,因为类型int和float的subtype-of关系与使用它们作为返回类型的Callable类型的关系方向相同。
另一方面,当需要处理float时,提供一个接受int参数的回调是类型错误的。
正式地说,Callable[[int], None]不是subtype-ofCallable[[float], None]。虽然int是subtype-offloat,但在参数化的Callable类型中,关系是相反的:Callable[[float], None]是subtype-ofCallable[[int], None]。因此我们说Callable在声明的参数类型上是逆变的。
“Variance”在第十五章中详细解释了 variance,并提供了不变、协变和逆变类型的更多细节和示例。
提示
目前,可以放心地说,大多数参数化的泛型类型是invariant,因此更简单。例如,如果我声明scores: list[float],那告诉我可以分配给scores的对象。我不能分配声明为list[int]或list[complex]的对象:
-
一个
list[int]对象是不可接受的,因为它不能容纳float值,而我的代码可能需要将其放入scores中。 -
一个
list[complex]对象是不可接受的,因为我的代码可能需要对scores进行排序以找到中位数,但complex没有提供__lt__,因此list[complex]是不可排序的。
现在我们来讨论本章中最后一个特殊类型。
NoReturn
这是一种特殊类型,仅用于注释永远不返回的函数的返回类型。通常,它们存在是为了引发异常。标准库中有数十个这样的函数。
例如,sys.exit()引发SystemExit来终止 Python 进程。
它在typeshed中的签名是:
def exit(__status: object = ...) -> NoReturn: ...
__status参数是仅位置参数,并且具有默认值。存根文件不详细说明默认值,而是使用...。__status的类型是object,这意味着它也可能是None,因此标记为Optional[object]将是多多的。
在第二十四章中,示例 24-6 在__flag_unknown_attrs中使用NoReturn,这是一个旨在生成用户友好和全面错误消息的方法,然后引发AttributeError。
这一史诗般章节的最后一节是关于位置和可变参数。
注释位置参数和可变参数
回想一下从示例 7-9 中的tag函数。我们上次看到它的签名是在“仅位置参数”中:
def tag(name, /, *content, class_=None, **attrs):
这里是tag,完全注释,写成几行——长签名的常见约定,使用换行符的方式,就像蓝色格式化程序会做的那样:
from typing import Optional
def tag(
name: str,
/,
*content: str,
class_: Optional[str] = None,
**attrs: str,
) -> str:
注意对于任意位置参数的类型提示*content: str;这意味着所有这些参数必须是str类型。函数体中content的类型将是tuple[str, ...]。
在这个例子中,任意关键字参数的类型提示是**attrs: str,因此函数内部的attrs类型将是dict[str, str]。对于像**attrs: float这样的类型提示,函数内部的attrs类型将是dict[str, float]。
如果attrs参数必须接受不同类型的值,你需要使用Union[]或Any:**attrs: Any。
仅位置参数的/符号仅适用于 Python ≥ 3.8。在 Python 3.7 或更早版本中,这将是语法错误。PEP 484 约定是在每个位置参数名称前加上两个下划线。这里是tag签名,再次以两行的形式,使用 PEP 484 约定:
from typing import Optional
def tag(__name: str, *content: str, class_: Optional[str] = None,
**attrs: str) -> str:
Mypy 理解并强制执行声明位置参数的两种方式。
为了结束这一章,让我们简要地考虑一下类型提示的限制以及它们支持的静态类型系统。
不完美的类型和强大的测试
大型公司代码库的维护者报告说,许多错误是由静态类型检查器发现的,并且比在代码运行在生产环境后才发现这些错误更便宜修复。然而,值得注意的是,在我所知道的公司中,自动化测试在静态类型引入之前就是标准做法并被广泛采用。
即使在它们最有益处的情况下,静态类型也不能被信任为正确性的最终仲裁者。很容易找到:
假阳性
工具会报告代码中正确的类型错误。
假阴性
工具不会报告代码中不正确的类型错误。
此外,如果我们被迫对所有内容进行类型检查,我们将失去 Python 的一些表现力:
-
一些方便的功能无法进行静态检查;例如,像
config(**settings)这样的参数解包。 -
属性、描述符、元类和一般元编程等高级功能对类型检查器的支持较差或超出理解范围。
-
类型检查器落后于 Python 版本,拒绝甚至在分析具有新语言特性的代码时崩溃——在某些情况下超过一年。
通常的数据约束无法在类型系统中表达,甚至是简单的约束。例如,类型提示无法确保“数量必须是大于 0 的整数”或“标签必须是具有 6 到 12 个 ASCII 字母的字符串”。总的来说,类型提示对捕捉业务逻辑中的错误并不有帮助。
鉴于这些注意事项,类型提示不能成为软件质量的主要支柱,强制性地使其成为例外会放大缺点。
将静态类型检查器视为现代 CI 流水线中的工具之一,与测试运行器、代码检查器等一起。CI 流水线的目的是减少软件故障,自动化测试可以捕获许多超出类型提示范围的错误。你可以在 Python 中编写的任何代码,都可以在 Python 中进行测试,无论是否有类型提示。
注
本节的标题和结论受到 Bruce Eckel 的文章“强类型 vs. 强测试”的启发,该文章也发表在 Joel Spolsky(Apress)编辑的文集The Best Software Writing I中。Bruce 是 Python 的粉丝,也是关于 C++、Java、Scala 和 Kotlin 的书籍的作者。在那篇文章中,他讲述了他是如何成为静态类型支持者的,直到学习 Python 并得出结论:“如果一个 Python 程序有足够的单元测试,它可以和有足够单元测试的 C++、Java 或 C#程序一样健壮(尽管 Python 中的测试编写速度更快)。”
目前我们的 Python 类型提示覆盖到这里。它们也是第十五章的主要内容,该章涵盖了泛型类、变异、重载签名、类型转换等。与此同时,类型提示将在本书的几个示例中做客串出现。
章节总结
我们从对渐进式类型概念的简要介绍开始,然后转向实践方法。没有一个实际读取类型提示的工具,很难看出渐进式类型是如何工作的,因此我们开发了一个由 Mypy 错误报告引导的带注解函数。
回到渐进式类型的概念,我们探讨了它是 Python 传统鸭子类型和用户更熟悉的 Java、C++等静态类型语言的名义类型的混合体。
大部分章节都致力于介绍注解中使用的主要类型组。我们涵盖的许多类型与熟悉的 Python 对象类型相关,如集合、元组和可调用对象,扩展以支持类似Sequence[float]的泛型表示。许多这些类型是在 Python 3.9 之前在typing模块中实现的临时替代品,直到标准类型被更改以支持泛型。
一些类型是特殊实体。Any、Optional、Union和NoReturn与内存中的实际对象无关,而仅存在于类型系统的抽象领域中。
我们研究了参数化泛型和类型变量,这为类型提示带来了更多灵活性,而不会牺牲类型安全性。
使用Protocol使参数化泛型变得更加表达丰富。因为它仅出现在 Python 3.8 中,Protocol目前并不广泛使用,但它非常重要。Protocol实现了静态鸭子类型:Python 鸭子类型核心与名义类型之间的重要桥梁,使静态类型检查器能够捕捉错误。
在介绍一些类型的同时,我们通过 Mypy 进行实验,以查看类型检查错误,并借助 Mypy 的神奇reveal_type()函数推断类型。
最后一节介绍了如何注释位置参数和可变参数。
类型提示是一个复杂且不断发展的主题。幸运的是,它们是一个可选功能。让我们保持 Python 对最广泛用户群体的可访问性,并停止宣扬所有 Python 代码都应该有类型提示的说法,就像我在类型提示布道者的公开布道中看到的那样。
我们的退休 BDFL¹⁹领导了 Python 中类型提示的推动,因此这一章的开头和结尾都以他的话语开始:
我不希望有一个我在任何时候都有道义义务添加类型提示的 Python 版本。我真的认为类型提示有它们的位置,但也有很多时候不值得,而且很棒的是你可以选择使用它们。²⁰
Guido van Rossum
进一步阅读
Bernát Gábor 在他的优秀文章中写道,“Python 中类型提示的现状”:
只要值得编写单元测试,就应该使用类型提示。
我是测试的忠实粉丝,但我也做很多探索性编码。当我在探索时,测试和类型提示并不有用。它们只是累赘。
Gábor 的文章是我发现的关于 Python 类型提示的最好介绍之一,还有 Geir Arne Hjelle 的“Python 类型检查(指南)”。Claudio Jolowicz 的“超现代 Python 第四章:类型”是一个更简短的介绍,也涵盖了运行时类型检查验证。
想要更深入的了解,Mypy 文档是最佳来源。它对于任何类型检查器都很有价值,因为它包含了关于 Python 类型提示的教程和参考页面,不仅仅是关于 Mypy 工具本身。在那里你还会找到一份方便的速查表和一个非常有用的页面,介绍了常见问题和解决方案。
typing模块文档是一个很好的快速参考,但它并没有详细介绍。PEP 483—类型提示理论包括了关于协变性的深入解释,使用Callable来说明逆变性。最终的参考资料是与类型提示相关的 PEP 文档。已经有 20 多个了。PEP 的目标受众是 Python 核心开发人员和 Python 的指导委员会,因此它们假定读者具有大量先前知识,绝对不是轻松阅读。
如前所述,第十五章涵盖了更多类型相关主题,而“进一步阅读”提供了额外的参考资料,包括表 15-1,列出了截至 2021 年底已批准或正在讨论的类型 PEPs。
“了不起的 Python 类型提示”是一个有价值的链接集合,包含了工具和参考资料。
¹ PEP 484—类型提示,“基本原理和目标”;粗体强调保留自原文。
² PyPy 中的即时编译器比类型提示有更好的数据:它在 Python 程序运行时监视程序,检测使用的具体类型,并为这些具体类型生成优化的机器代码。
³ 例如,截至 2021 年 7 月,不支持递归类型—参见typing模块问题#182,定义 JSON 类型和 Mypy 问题#731,支持递归类型。
⁴ Python 没有提供控制类型可能值集合的语法—除了在Enum类型中。例如,使用类型提示,你无法将Quantity定义为介于 1 和 1000 之间的整数,或将AirportCode定义为 3 个字母的组合。NumPy 提供了uint8、int16和其他面向机器的数值类型,但在 Python 标准库中,我们只有具有非常小值集合(NoneType、bool)或极大值集合(float、int、str、所有可能的元组等)的类型。
⁵ 鸭子类型是一种隐式的结构类型形式,Python ≥ 3.8 也支持引入typing.Protocol。这将在本章后面—“静态协议”—进行介绍,更多细节请参见第十三章。
⁶ 继承经常被滥用,并且很难在现实但简单的示例中证明其合理性,因此请接受这个动物示例作为子类型的快速说明。
⁷ 麻省理工学院教授、编程语言设计师和图灵奖获得者。维基百科:芭芭拉·利斯科夫。
⁸ 更准确地说,ord仅接受len(s) == 1的str或bytes。但目前的类型系统无法表达这个约束。
⁹ 在 ABC 语言——最初影响 Python 设计的语言中——每个列表都受限于接受单一类型的值:您放入其中的第一个项目的类型。
¹⁰ 我对typing模块文档的贡献之一是在 Guido van Rossum 的监督下将“模块内容”下的条目重新组织为子部分,并添加了数十个弃用警告。
¹¹ 在一些示例中,我使用:=是有意义的,但我在书中没有涵盖它。请参阅PEP 572—赋值表达式获取所有详细信息。
¹² 实际上,dict是abc.MutableMapping的虚拟子类。虚拟子类的概念在第十三章中有解释。暂时知道issubclass(dict, abc.MutableMapping)为True,尽管dict是用 C 实现的,不继承任何东西自abc.MutableMapping,而只继承自object。
¹³ 这里的实现比 Python 标准库中的statistics模块更简单。
¹⁴ 我向typeshed贡献了这个解决方案,这就是为什么mode在statistics.pyi中的注释截至 2020 年 5 月 26 日。
¹⁵ 多么美妙啊,打开一个交互式控制台并依靠鸭子类型来探索语言特性,就像我刚才做的那样。当我使用不支持它的语言时,我非常想念这种探索方式。
¹⁶ 没有这个类型提示,Mypy 会将series的类型推断为Generator[Tuple[builtins.int, builtins.str*], None, None],这是冗长的但与Iterator[tuple[int, str]]一致,正如我们将在“通用可迭代类型”中看到的。
¹⁷ 我不知道谁发明了术语静态鸭子类型,但它在 Go 语言中变得更加流行,该语言的接口语义更像 Python 的协议,而不是 Java 的名义接口。
¹⁸ REPL 代表 Read-Eval-Print-Loop,交互式解释器的基本行为。
¹⁹ “终身仁慈独裁者”。参见 Guido van Rossum 关于“BDFL 起源”。
²⁰ 来自 YouTube 视频,“Guido van Rossum 关于类型提示(2015 年 3 月)”。引用开始于13’40”。我进行了一些轻微的编辑以提高清晰度。
²¹ 来源:“与艾伦·凯的对话”。
第九章:装饰器和闭包
有人对将这个功能命名为“装饰器”的选择提出了一些抱怨。主要的抱怨是该名称与其在 GoF 书中的用法不一致。¹ 名称 decorator 可能更多地归因于其在编译器领域的用法—语法树被遍历并注释。
PEP 318—函数和方法的装饰器
函数装饰器让我们在源代码中“标记”函数以增强其行为。这是强大的东西,但要掌握它需要理解闭包—当函数捕获在其体外定义的变量时,我们就得到了闭包。
Python 中最晦涩的保留关键字是 nonlocal,引入于 Python 3.0。如果你遵循严格的以类为中心的面向对象编程规范,作为 Python 程序员可以过上富有成效的生活而永远不使用它。然而,如果你想要实现自己的函数装饰器,你必须理解闭包,然后 nonlocal 的必要性就显而易见了。
除了在装饰器中的应用外,闭包在使用回调函数的任何类型编程和在适当时以函数式风格编码时也是必不可少的。
本章的最终目标是准确解释函数装饰器的工作原理,从最简单的注册装饰器到更复杂的带参数装饰器。然而,在达到这个目标之前,我们需要涵盖:
-
Python 如何评估装饰器语法
-
Python 如何确定变量是局部的
-
闭包的存在及工作原理
-
nonlocal解决了什么问题
有了这个基础,我们可以进一步探讨装饰器的主题:
-
实现一个行为良好的装饰器
-
标准库中强大的装饰器:
@cache、@lru_cache和@singledispatch -
实现一个带参数的装饰器
本章新内容
Python 3.9 中新增的缓存装饰器 functools.cache 比传统的 functools.lru_cache 更简单,因此我首先介绍它。后者在“使用 lru_cache”中有介绍,包括 Python 3.8 中新增的简化形式。
“单分派泛型函数”进行了扩展,现在使用类型提示,这是自 Python 3.7 以来使用 functools.singledispatch 的首选方式。
“带参数的装饰器”现在包括一个基于类的示例,示例 9-27。
我将第十章,“具有头等函数的设计模式”移到了第 II 部分的末尾,以改善书籍的流畅性。“装饰器增强策略模式”现在在该章节中,以及使用可调用对象的策略设计模式的其他变体。
我们从一个非常温和的装饰器介绍开始,然后继续进行章节开头列出的其余项目。
装饰器 101
装饰器是一个可调用对象,接受另一个函数作为参数(被装饰的函数)。
装饰器可能对被装饰的函数进行一些处理,并返回它或用另一个函数或可调用对象替换它。²
换句话说,假设存在一个名为 decorate 的装饰器,这段代码:
@decorate
def target():
print('running target()')
与编写以下内容具有相同的效果:
def target():
print('running target()')
target = decorate(target)
最终结果是一样的:在这两个片段的末尾,target 名称绑定到 decorate(target) 返回的任何函数上—这可能是最初命名为 target 的函数,也可能是另一个函数。
要确认被装饰的函数是否被替换,请查看示例 9-1 中的控制台会话。
示例 9-1. 装饰器通常会用不同的函数替换一个函数
>>> def deco(func):
... def inner():
... print('running inner()')
... return inner # ①
...
>>> @deco
... def target(): # ②
... print('running target()')
...
>>> target() # ③
running inner() >>> target # ④
<function deco.<locals>.inner at 0x10063b598>
①
deco 返回其 inner 函数对象。
②
target 被 deco 装饰。
③
调用被装饰的 target 实际上运行 inner。
④
检查发现 target 现在是对 inner 的引用。
严格来说,装饰器只是一种语法糖。正如我们刚才看到的,你总是可以像调用任何常规可调用对象一样简单地调用装饰器,传递另一个函数。有时这实际上很方便,特别是在进行 元编程 时——在运行时更改程序行为。
三个关键事实概括了装饰器的要点:
-
装饰器是一个函数或另一个可调用对象。
-
装饰器可能会用不同的函数替换被装饰的函数。
-
装饰器在模块加载时立即执行。
现在让我们专注于第三点。
Python 执行装饰器时
装饰器的一个关键特点是它们在被装饰的函数定义后立即运行。通常是在 导入时间(即 Python 加载模块时)运行。考虑 示例 9-2 中的 registration.py。
示例 9-2. registration.py 模块
registry = [] # ①
def register(func): # ②
print(f'running register({func})') # ③
registry.append(func) # ④
return func # ⑤
@register # ⑥
def f1():
print('running f1()')
@register
def f2():
print('running f2()')
def f3(): # ⑦
print('running f3()')
def main(): # ⑧
print('running main()')
print('registry ->', registry)
f1()
f2()
f3()
if __name__ == '__main__':
main() # ⑨
①
registry 将保存被 @register 装饰的函数的引用。
②
register 接受一个函数作为参数。
③
显示正在被装饰的函数,以供演示。
④
在 registry 中包含 func。
⑤
返回 func:我们必须返回一个函数;在这里我们返回接收到的相同函数。
⑥
f1 和 f2 被 @register 装饰。
⑦
f3 没有被装饰。
⑧
main 显示 registry,然后调用 f1()、f2() 和 f3()。
⑨
只有当 registration.py 作为脚本运行时才会调用 main()。
将 registration.py 作为脚本运行的输出如下:
$ python3 registration.py
running register(<function f1 at 0x100631bf8>)
running register(<function f2 at 0x100631c80>)
running main()
registry -> [<function f1 at 0x100631bf8>, <function f2 at 0x100631c80>]
running f1()
running f2()
running f3()
注意,register 在任何模块中的其他函数之前运行(两次)。当调用 register 时,它接收被装饰的函数对象作为参数,例如 <function f1 at 0x100631bf8>。
模块加载后,registry 列表保存了两个被装饰函数 f1 和 f2 的引用。这些函数以及 f3 只有在被 main 显式调用时才会执行。
如果 registration.py 被导入(而不是作为脚本运行),输出如下:
>>> import registration
running register(<function f1 at 0x10063b1e0>)
running register(<function f2 at 0x10063b268>)
此时,如果检查 registry,你会看到:
>>> registration.registry
[<function f1 at 0x10063b1e0>, <function f2 at 0x10063b268>]
示例 9-2 的主要观点是强调函数装饰器在模块导入时立即执行,但被装饰的函数只有在显式调用时才运行。这突出了 Python 程序员所称的 导入时间 和 运行时 之间的区别。
注册装饰器
考虑到装饰器在实际代码中通常的应用方式,示例 9-2 在两个方面都有些不同寻常:
-
装饰器函数在与被装饰函数相同的模块中定义。真正的装饰器通常在一个模块中定义,并应用于其他模块中的函数。
-
register装饰器返回与传入的相同函数。实际上,大多数装饰器定义一个内部函数并返回它。
即使 示例 9-2 中的 register 装饰器返回未更改的装饰函数,这种技术也不是无用的。许多 Python 框架中使用类似的装饰器将函数添加到某个中央注册表中,例如将 URL 模式映射到生成 HTTP 响应的函数的注册表。这些注册装饰器可能会或可能不会更改被装饰的函数。
我们将在 “装饰器增强的策略模式”(第十章)中看到一个注册装饰器的应用。
大多数装饰器确实会改变被装饰的函数。它们通常通过定义内部函数并返回它来替换被装饰的函数来实现。几乎总是依赖闭包才能正确运行使用内部函数的代码。要理解闭包,我们需要退一步,回顾一下 Python 中变量作用域的工作原理。
变量作用域规则
在示例 9-3 中,我们定义并测试了一个函数,该函数读取两个变量:一个局部变量a—定义为函数参数—和一个在函数中任何地方都未定义的变量b。
示例 9-3. 读取局部变量和全局变量的函数
>>> def f1(a):
... print(a)
... print(b)
...
>>> f1(3)
3
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 3, in f1
NameError: global name 'b' is not defined
我们得到的错误并不令人惊讶。继续从示例 9-3 中,如果我们为全局b赋值然后调用f1,它可以工作:
>>> b = 6
>>> f1(3)
3
6
现在,让我们看一个可能会让你惊讶的例子。
查看示例 9-4 中的f2函数。它的前两行与示例 9-3 中的f1相同,然后对b进行赋值。但在赋值之前的第二个print失败了。
示例 9-4. 变量b是局部的,因为它在函数体中被赋值
>>> b = 6
>>> def f2(a):
... print(a)
... print(b)
... b = 9
...
>>> f2(3)
3
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 3, in f2
UnboundLocalError: local variable 'b' referenced before assignment
请注意,输出以3开头,这证明了print(a)语句已执行。但第二个print(b)从未运行。当我第一次看到这个时,我感到惊讶,认为应该打印6,因为有一个全局变量b,并且在print(b)之后对局部b进行了赋值。
但事实是,当 Python 编译函数体时,它决定将b视为局部变量,因为它是在函数内部赋值的。生成的字节码反映了这个决定,并将尝试从局部作用域获取b。稍后,当调用f2(3)时,f2的函数体获取并打印局部变量a的值,但在尝试获取局部变量b的值时,它发现b是未绑定的。
这不是一个错误,而是一个设计选择:Python 不要求您声明变量,但假设在函数体中分配的变量是局部的。这比 JavaScript 的行为要好得多,后者也不需要变量声明,但如果您忘记声明变量是局部的(使用var),您可能会在不知情的情况下覆盖全局变量。
如果我们希望解释器将b视为全局变量,并且仍然在函数内部为其赋新值,我们使用global声明:
>>> b = 6
>>> def f3(a):
... global b
... print(a)
... print(b)
... b = 9
...
>>> f3(3)
3
6
>>> b
9
在前面的示例中,我们可以看到两个作用域的运作:
模块全局作用域
由分配给任何类或函数块之外的值的名称组成。
f3 函数的局部作用域
由分配给参数的值或直接在函数体中分配的名称组成。
另一个变量可能来自的作用域是非局部,对于闭包是至关重要的;我们稍后会看到它。
在更深入地了解 Python 中变量作用域工作原理之后,我们可以在下一节“闭包”中讨论闭包。如果您对示例 9-3 和 9-4 中的函数之间的字节码差异感兴趣,请参阅以下侧边栏。
闭包
在博客圈中,有时会混淆闭包和匿名函数。许多人会因为这两个特性的平行历史而混淆它们:在函数内部定义函数并不那么常见或方便,直到有了匿名函数。而只有在有嵌套函数时闭包才重要。因此,很多人会同时学习这两个概念。
实际上,闭包是一个函数—我们称之为f—具有扩展作用域,包含在f的函数体中引用的不是全局变量或f的局部变量的变量。这些变量必须来自包含f的外部函数的局部作用域。
函数是匿名的与否并不重要;重要的是它可以访问在其函数体之外定义的非全局变量。
这是一个难以理解的概念,最好通过一个例子来解释。
考虑一个avg函数来计算不断增长的数值序列的平均值;例如,商品的整个历史上的平均收盘价。每天都会添加一个新的价格,并且平均值是根据到目前为止的所有价格计算的。
从一张干净的画布开始,这就是如何使用avg:
>>> avg(10)
10.0
>>> avg(11)
10.5
>>> avg(12)
11.0
avg是从哪里来的,它在哪里保留了先前值的历史记录?
起步时,示例 9-7 是基于类的实现。
示例 9-7. average_oo.py:用于计算移动平均值的类
class Averager():
def __init__(self):
self.series = []
def __call__(self, new_value):
self.series.append(new_value)
total = sum(self.series)
return total / len(self.series)
Averager类创建可调用的实例:
>>> avg = Averager()
>>> avg(10)
10.0
>>> avg(11)
10.5
>>> avg(12)
11.0
现在,示例 9-8 是一个功能实现,使用高阶函数make_averager。
示例 9-8. average.py:用于计算移动平均值的高阶函数
def make_averager():
series = []
def averager(new_value):
series.append(new_value)
total = sum(series)
return total / len(series)
return averager
当调用时,make_averager返回一个averager函数对象。每次调用averager时,它都会将传递的参数附加到序列中,并计算当前平均值,如示例 9-9 所示。
示例 9-9. 测试示例 9-8
>>> avg = make_averager()
>>> avg(10)
10.0
>>> avg(11)
10.5
>>> avg(15)
12.0
注意示例的相似之处:我们调用Averager()或make_averager()来获取一个可调用对象avg,它将更新历史序列并计算当前平均值。在示例 9-7 中,avg是Averager的一个实例,在示例 9-8 中,它是内部函数averager。无论哪种方式,我们只需调用avg(n)来将n包含在序列中并获得更新后的平均值。
很明显,Averager类的avg保留历史记录的地方:self.series实例属性。但第二个示例中的avg函数从哪里找到series呢?
请注意,series是make_averager的局部变量,因为赋值series = []发生在该函数的主体中。但当调用avg(10)时,make_averager已经返回,并且它的局部作用域早已消失。
在averager中,series是一个自由变量。这是一个技术术语,意味着一个在局部作用域中未绑定的变量。参见图 9-1。

图 9-1. averager的闭包将该函数的作用域扩展到包括自由变量series的绑定。
检查返回的averager对象显示了 Python 如何在__code__属性中保存局部和自由变量的名称,该属性表示函数的编译体。示例 9-10 展示了这些属性。
示例 9-10. 检查由示例 9-8 创建的函数
>>> avg.__code__.co_varnames
('new_value', 'total')
>>> avg.__code__.co_freevars
('series',)
series的值保存在返回的函数avg的__closure__属性中。avg.__closure__中的每个项目对应于avg.__code__.co_freevars中的一个名称。这些项目是cells,它们有一个名为cell_contents的属性,其中可以找到实际值。示例 9-11 展示了这些属性。
示例 9-11. 从示例 9-9 继续
>>> avg.__code__.co_freevars
('series',)
>>> avg.__closure__
(<cell at 0x107a44f78: list object at 0x107a91a48>,)
>>> avg.__closure__[0].cell_contents
[10, 11, 12]
总结一下:闭包是一个函数,保留在函数定义时存在的自由变量的绑定,以便在函数被调用时使用,而定义作用域不再可用时可以使用它们。
请注意,函数可能需要处理非全局外部变量的唯一情况是当它嵌套在另一个函数中并且这些变量是外部函数的局部作用域的一部分时。
非局部声明
我们先前的make_averager实现效率不高。在示例 9-8 中,我们将所有值存储在历史序列中,并且每次调用averager时都计算它们的sum。更好的实现方式是只存储总和和到目前为止的项目数,并从这两个数字计算平均值。
示例 9-12 是一个有问题的实现,只是为了说明一个观点。你能看出它在哪里出错了吗?
示例 9-12. 一个破损的高阶函数,用于计算不保留所有历史记录的运行平均值
def make_averager():
count = 0
total = 0
def averager(new_value):
count += 1
total += new_value
return total / count
return averager
如果尝试 示例 9-12,你会得到以下结果:
>>> avg = make_averager()
>>> avg(10)
Traceback (most recent call last):
...
UnboundLocalError: local variable 'count' referenced before assignment
>>>
问题在于语句 count += 1 实际上意味着与 count = count + 1 相同,当 count 是一个数字或任何不可变类型时。因此,我们实际上是在 averager 的主体中对 count 赋值,这使其成为一个局部变量。同样的问题也影响到 total 变量。
我们在 示例 9-8 中没有这个问题,因为我们从未给 series 赋值;我们只调用了 series.append 并在其上调用了 sum 和 len。所以我们利用了列表是可变的这一事实。
但对于像数字、字符串、元组等不可变类型,你只能读取,而不能更新。如果尝试重新绑定它们,比如 count = count + 1,那么实际上是隐式创建了一个局部变量 count。它不再是一个自由变量,因此不会保存在闭包中。
为了解决这个问题,Python 3 中引入了 nonlocal 关键字。它允许你将一个变量声明为自由变量,即使它在函数内部被赋值。如果向 nonlocal 变量赋予新值,闭包中存储的绑定将会改变。我们最新的 make_averager 的正确实现看起来像 示例 9-13。
示例 9-13. 计算不保留所有历史记录的运行平均值(使用 nonlocal 修复)
def make_averager():
count = 0
total = 0
def averager(new_value):
nonlocal count, total
count += 1
total += new_value
return total / count
return averager
在学习了 nonlocal 的使用之后,让我们总结一下 Python 的变量查找工作原理。
变量查找逻辑
当定义一个函数时,Python 字节码编译器根据以下规则确定如何获取其中出现的变量 x:³
-
如果有
global x声明,x来自并被赋值给模块的x全局变量。⁴ -
如果有
nonlocal x声明,x来自并被赋值给最近的周围函数的x局部变量,其中x被定义。 -
如果
x是参数或在函数体中被赋值,则x是局部变量。 -
如果引用了
x但未被赋值且不是参数:-
x将在周围函数体的本地作用域(非本地作用域)中查找。 -
如果在周围作用域中找不到,将从模块全局作用域中读取。
-
如果在全局作用域中找不到,将从
__builtins__.__dict__中读取。
-
现在我们已经介绍了 Python 闭包,我们可以有效地使用嵌套函数实现装饰器。
实现一个简单的装饰器
示例 9-14 是一个装饰器,用于记录装饰函数的每次调用并显示经过的时间、传递的参数以及调用的结果。
示例 9-14. clockdeco0.py: 显示函数运行时间的简单装饰器
import time
def clock(func):
def clocked(*args): # ①
t0 = time.perf_counter()
result = func(*args) # ②
elapsed = time.perf_counter() - t0
name = func.__name__
arg_str = ', '.join(repr(arg) for arg in args)
print(f'[{elapsed:0.8f}s] {name}({arg_str}) -> {result!r}')
return result
return clocked # ③
①
定义内部函数 clocked 来接受任意数量的位置参数。
②
这行代码之所以有效,是因为 clocked 的闭包包含了 func 自由变量。
③
返回内部函数以替换装饰的函数。
示例 9-15 演示了 clock 装饰器的使用。
示例 9-15. 使用 clock 装饰器
import time
from clockdeco0 import clock
@clock
def snooze(seconds):
time.sleep(seconds)
@clock
def factorial(n):
return 1 if n < 2 else n*factorial(n-1)
if __name__ == '__main__':
print('*' * 40, 'Calling snooze(.123)')
snooze(.123)
print('*' * 40, 'Calling factorial(6)')
print('6! =', factorial(6))
运行 示例 9-15 的输出如下所示:
$ python3 clockdeco_demo.py
**************************************** Calling snooze(.123)
[0.12363791s] snooze(0.123) -> None
**************************************** Calling factorial(6)
[0.00000095s] factorial(1) -> 1
[0.00002408s] factorial(2) -> 2
[0.00003934s] factorial(3) -> 6
[0.00005221s] factorial(4) -> 24
[0.00006390s] factorial(5) -> 120
[0.00008297s] factorial(6) -> 720
6! = 720
工作原理
记住这段代码:
@clock
def factorial(n):
return 1 if n < 2 else n*factorial(n-1)
实际上是这样的:
def factorial(n):
return 1 if n < 2 else n*factorial(n-1)
factorial = clock(factorial)
因此,在这两个示例中,clock将factorial函数作为其func参数(参见示例 9-14)。然后创建并返回clocked函数,Python 解释器将其分配给factorial(在第一个示例中,在幕后)。实际上,如果导入clockdeco_demo模块并检查factorial的__name__,您会得到以下结果:
>>> import clockdeco_demo
>>> clockdeco_demo.factorial.__name__
'clocked'
>>>
所以factorial现在实际上持有对clocked函数的引用。从现在开始,每次调用factorial(n),都会执行clocked(n)。实质上,clocked执行以下操作:
-
记录初始时间
t0。 -
调用原始
factorial函数,保存结果。 -
计算经过的时间。
-
格式化并显示收集的数据。
-
返回第 2 步保存的结果。
这是装饰器的典型行为:它用新函数替换装饰函数,新函数接受相同的参数并(通常)返回装饰函数应该返回的内容,同时还进行一些额外处理。
提示
在 Gamma 等人的设计模式中,装饰器模式的简短描述以“动态地为对象附加额外的责任”开始。函数装饰器符合这一描述。但在实现层面上,Python 装饰器与原始设计模式作品中描述的经典装饰器几乎没有相似之处。“讲台”有更多相关内容。
示例 9-14 中实现的clock装饰器存在一些缺陷:它不支持关键字参数,并且掩盖了装饰函数的__name__和__doc__。示例 9-16 使用functools.wraps装饰器从func复制相关属性到clocked。此外,在这个新版本中,关键字参数被正确处理。
示例 9-16. clockdeco.py:改进的时钟装饰器
import time
import functools
def clock(func):
@functools.wraps(func)
def clocked(*args, **kwargs):
t0 = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - t0
name = func.__name__
arg_lst = [repr(arg) for arg in args]
arg_lst.extend(f'{k}={v!r}' for k, v in kwargs.items())
arg_str = ', '.join(arg_lst)
print(f'[{elapsed:0.8f}s] {name}({arg_str}) -> {result!r}')
return result
return clocked
functools.wraps只是标准库中可用的装饰器之一。在下一节中,我们将介绍functools提供的最令人印象深刻的装饰器:cache。
标准库中的装饰器
Python 有三个内置函数专门用于装饰方法:property、classmethod和staticmethod。我们将在“使用属性进行属性验证”中讨论property,并在“classmethod 与 staticmethod”中讨论其他内容。
在示例 9-16 中,我们看到另一个重要的装饰器:functools.wraps,一个用于构建行为良好的装饰器的辅助工具。标准库中一些最有趣的装饰器是cache、lru_cache和singledispatch,它们都来自functools模块。我们将在下一节中介绍它们。
使用functools.cache进行记忆化
functools.cache装饰器实现了记忆化:⁵一种通过保存先前调用昂贵函数的结果来优化的技术,避免对先前使用的参数进行重复计算。
提示
functools.cache在 Python 3.9 中添加。如果需要在 Python 3.8 中运行这些示例,请将@cache替换为@lru_cache。对于 Python 的早期版本,您必须调用装饰器,写成@lru_cache(),如“使用 lru_cache”中所述。
一个很好的演示是将@cache应用于痛苦缓慢的递归函数,以生成斐波那契数列中的第n个数字,如示例 9-17 所示。
示例 9-17. 计算斐波那契数列中第 n 个数字的非常昂贵的递归方式
from clockdeco import clock
@clock
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 2) + fibonacci(n - 1)
if __name__ == '__main__':
print(fibonacci(6))
运行fibo_demo.py的结果如下。除了最后一行外,所有输出都是由clock装饰器生成的:
$ python3 fibo_demo.py
[0.00000042s] fibonacci(0) -> 0
[0.00000049s] fibonacci(1) -> 1
[0.00006115s] fibonacci(2) -> 1
[0.00000031s] fibonacci(1) -> 1
[0.00000035s] fibonacci(0) -> 0
[0.00000030s] fibonacci(1) -> 1
[0.00001084s] fibonacci(2) -> 1
[0.00002074s] fibonacci(3) -> 2
[0.00009189s] fibonacci(4) -> 3
[0.00000029s] fibonacci(1) -> 1
[0.00000027s] fibonacci(0) -> 0
[0.00000029s] fibonacci(1) -> 1
[0.00000959s] fibonacci(2) -> 1
[0.00001905s] fibonacci(3) -> 2
[0.00000026s] fibonacci(0) -> 0
[0.00000029s] fibonacci(1) -> 1
[0.00000997s] fibonacci(2) -> 1
[0.00000028s] fibonacci(1) -> 1
[0.00000030s] fibonacci(0) -> 0
[0.00000031s] fibonacci(1) -> 1
[0.00001019s] fibonacci(2) -> 1
[0.00001967s] fibonacci(3) -> 2
[0.00003876s] fibonacci(4) -> 3
[0.00006670s] fibonacci(5) -> 5
[0.00016852s] fibonacci(6) -> 8
8
浪费是显而易见的:fibonacci(1)被调用了八次,fibonacci(2)被调用了五次,等等。但只需添加两行代码来使用cache,性能就得到了很大改善。参见示例 9-18。
示例 9-18. 使用缓存实现更快的方法
import functools
from clockdeco import clock
@functools.cache # ①
@clock # ②
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 2) + fibonacci(n - 1)
if __name__ == '__main__':
print(fibonacci(6))
①
这行代码适用于 Python 3.9 或更高版本。有关支持较早 Python 版本的替代方法,请参阅“使用 lru_cache”。
②
这是堆叠装饰器的一个示例:@cache应用于@clock返回的函数。
堆叠装饰器
要理解堆叠装饰器的意义,回想一下@是将装饰器函数应用于其下方的函数的语法糖。如果有多个装饰器,它们的行为类似于嵌套函数调用。这个:
@alpha
@beta
def my_fn():
...
与此相同:
my_fn = alpha(beta(my_fn))
换句话说,首先应用beta装饰器,然后将其返回的函数传递给alpha。
在示例 9-18 中使用cache,fibonacci函数仅对每个n值调用一次:
$ python3 fibo_demo_lru.py
[0.00000043s] fibonacci(0) -> 0
[0.00000054s] fibonacci(1) -> 1
[0.00006179s] fibonacci(2) -> 1
[0.00000070s] fibonacci(3) -> 2
[0.00007366s] fibonacci(4) -> 3
[0.00000057s] fibonacci(5) -> 5
[0.00008479s] fibonacci(6) -> 8
8
在另一个测试中,计算fibonacci(30)时,示例 9-18 在 0.00017 秒内完成了所需的 31 次调用(总时间),而未缓存的示例 9-17 在 Intel Core i7 笔记本上花费了 12.09 秒,因为它调用了fibonacci(1)832,040 次,总共 2,692,537 次调用。
被装饰函数接受的所有参数必须是可散列的,因为底层的lru_cache使用dict来存储结果,键是由调用中使用的位置和关键字参数生成的。
除了使愚蠢的递归算法可行外,@cache在需要从远程 API 获取信息的应用程序中表现出色。
警告
如果缓存条目数量非常大,functools.cache可能会消耗所有可用内存。我认为它更适合用于短暂的命令本。在长时间运行的进程中,我建议使用适当的maxsize参数使用functools.lru_cache,如下一节所述。
使用 lru_cache
functools.cache装饰器实际上是围绕旧的functools.lru_cache函数的简单包装器,后者更灵活,与 Python 3.8 及更早版本兼容。
@lru_cache的主要优势在于其内存使用受maxsize参数限制,其默认值相当保守,为 128,这意味着缓存最多同时保留 128 个条目。
LRU 的首字母缩写代表最近最少使用,意味着长时间未被读取的旧条目将被丢弃,以腾出空间给新条目。
自 Python 3.8 以来,lru_cache可以以两种方式应用。这是最简单的使用方法:
@lru_cache
def costly_function(a, b):
...
另一种方式——自 Python 3.2 起可用——是将其作为函数调用,使用():
@lru_cache()
def costly_function(a, b):
...
在这两种情况下,将使用默认参数。这些是:
maxsize=128
设置要存储的条目的最大数量。缓存满后,最近最少使用的条目将被丢弃以为新条目腾出空间。为了获得最佳性能,maxsize应为 2 的幂。如果传递maxsize=None,LRU 逻辑将被禁用,因此缓存工作速度更快,但条目永远不会被丢弃,这可能会消耗过多内存。这就是@functools.cache的作用。
typed=False
确定不同参数类型的结果是否分开存储。例如,在默认设置中,被视为相等的浮点数和整数参数仅存储一次,因此对f(1)和f(1.0)的调用将只有一个条目。如果typed=True,这些参数将产生不同的条目,可能存储不同的结果。
这是使用非默认参数调用@lru_cache的示例:
@lru_cache(maxsize=2**20, typed=True)
def costly_function(a, b):
...
现在让我们研究另一个强大的装饰器:functools.singledispatch。
单分发通用函数
想象我们正在创建一个用于调试 Web 应用程序的工具。我们希望为不同类型的 Python 对象生成 HTML 显示。
我们可以从这样的函数开始:
import html
def htmlize(obj):
content = html.escape(repr(obj))
return f'<pre>{content}</pre>'
这将适用于任何 Python 类型,但现在我们想扩展它以生成一些类型的自定义显示。一些示例:
str
用'<br/>\n'替换嵌入的换行符,并使用<p>标签代替<pre>。
int
以十进制和十六进制形式显示数字(对 bool 有特殊情况)。
list
输出一个 HTML 列表,根据其类型格式化每个项目。
float 和 Decimal
通常输出值,但也以分数形式呈现(为什么不呢?)。
我们想要的行为在 Example 9-19 中展示。
Example 9-19. htmlize() 生成针对不同对象类型定制的 HTML
>>> htmlize({1, 2, 3}) # ①
'<pre>{1, 2, 3}</pre>' >>> htmlize(abs)
'<pre><built-in function abs></pre>' >>> htmlize('Heimlich & Co.\n- a game') # ②
'<p>Heimlich & Co.<br/>\n- a game</p>' >>> htmlize(42) # ③
'<pre>42 (0x2a)</pre>' >>> print(htmlize(['alpha', 66, {3, 2, 1}])) # ④
<ul> <li><p>alpha</p></li> <li><pre>66 (0x42)</pre></li> <li><pre>{1, 2, 3}</pre></li> </ul> >>> htmlize(True) # ⑤
'<pre>True</pre>' >>> htmlize(fractions.Fraction(2, 3)) # ⑥
'<pre>2/3</pre>' >>> htmlize(2/3) # ⑦
'<pre>0.6666666666666666 (2/3)</pre>' >>> htmlize(decimal.Decimal('0.02380952'))
'<pre>0.02380952 (1/42)</pre>'
①
原始函数为 object 注册,因此它作为一个通用函数来处理与其他实现不匹配的参数类型。
②
str 对象也会进行 HTML 转义,但会被包裹在 <p></p> 中,并在每个 '\n' 前插入 <br/> 换行符。
③
int 以十进制和十六进制的形式显示,在 <pre></pre> 中。
④
每个列表项根据其类型进行格式化,并将整个序列呈现为 HTML 列表。
⑤
尽管 bool 是 int 的子类型,但它得到了特殊处理。
⑥
以分数形式展示 Fraction。
⑦
以近似分数等价形式展示 float 和 Decimal。
函数 singledispatch
因为在 Python 中我们没有 Java 风格的方法重载,所以我们不能简单地为我们想要以不同方式处理的每种数据类型创建 htmlize 的变体。在 Python 中的一个可能的解决方案是将 htmlize 转变为一个分发函数,使用一系列的 if/elif/… 或 match/case/… 调用专门函数,如 htmlize_str,htmlize_int 等。这种方法对我们模块的用户来说是不可扩展的,而且很笨重:随着时间的推移,htmlize 分发器会变得太大,它与专门函数之间的耦合会非常紧密。
functools.singledispatch 装饰器允许不同模块为整体解决方案做出贡献,并让您轻松为属于第三方包的类型提供专门函数,而这些包您无法编辑。如果您用 @singledispatch 装饰一个普通函数,它将成为一个通用函数的入口点:一组函数以不同方式执行相同操作,取决于第一个参数的类型。这就是所谓的单分派。如果使用更多参数来选择特定函数,我们将有多分派。Example 9-20 展示了如何实现。
警告
functools.singledispatch 自 Python 3.4 起存在,但自 Python 3.7 起才支持类型提示。Example 9-20 中的最后两个函数展示了在 Python 3.4 以来所有版本中都有效的语法。
Example 9-20. @singledispatch 创建一个自定义的 @htmlize.register 来将几个函数捆绑成一个通用函数
from functools import singledispatch
from collections import abc
import fractions
import decimal
import html
import numbers
@singledispatch # ①
def htmlize(obj: object) -> str:
content = html.escape(repr(obj))
return f'<pre>{content}</pre>'
@htmlize.register # ②
def _(text: str) -> str: # ③
content = html.escape(text).replace('\n', '<br/>\n')
return f'<p>{content}</p>'
@htmlize.register # ④
def _(seq: abc.Sequence) -> str:
inner = '</li>\n<li>'.join(htmlize(item) for item in seq)
return '<ul>\n<li>' + inner + '</li>\n</ul>'
@htmlize.register # ⑤
def _(n: numbers.Integral) -> str:
return f'<pre>{n} (0x{n:x})</pre>'
@htmlize.register # ⑥
def _(n: bool) -> str:
return f'<pre>{n}</pre>'
@htmlize.register(fractions.Fraction) # ⑦
def _(x) -> str:
frac = fractions.Fraction(x)
return f'<pre>{frac.numerator}/{frac.denominator}</pre>'
@htmlize.register(decimal.Decimal) # ⑧
@htmlize.register(float)
def _(x) -> str:
frac = fractions.Fraction(x).limit_denominator()
return f'<pre>{x} ({frac.numerator}/{frac.denominator})</pre>'
①
@singledispatch 标记了处理 object 类型的基本函数。
②
每个专门函数都使用 @«base».register 进行装饰。
③
运行时给定的第一个参数的类型决定了何时使用这个特定的函数定义。专门函数的名称并不重要;_ 是一个很好的选择,可以让这一点清晰明了。⁶
④
为了让每个额外的类型得到特殊处理,需要注册一个新的函数,并在第一个参数中使用匹配的类型提示。
⑤
numbers ABCs 对于与 singledispatch 一起使用很有用。⁷
⑥
bool是numbers.Integral的子类型,但singledispatch逻辑寻找具有最具体匹配类型的实现,而不考虑它们在代码中出现的顺序。
⑦
如果您不想或无法向装饰的函数添加类型提示,可以将类型传递给@«base».register装饰器。这种语法适用于 Python 3.4 或更高版本。
⑧
@«base».register装饰器返回未装饰的函数,因此可以堆叠它们以在同一实现上注册两个或更多类型。⁸
在可能的情况下,注册专门的函数以处理抽象类(ABCs)如numbers.Integral和abc.MutableSequence,而不是具体实现如int和list。这样可以使您的代码支持更多兼容类型的变化。例如,Python 扩展可以提供numbers.Integral的子类作为int类型的替代方案。
提示
使用 ABCs 或typing.Protocol与@singledispatch允许您的代码支持现有或未来的类,这些类是这些 ABCs 的实际或虚拟子类,或者实现了这些协议。ABCs 的使用和虚拟子类的概念是第十三章的主题。
singledispatch机制的一个显著特点是,您可以在系统中的任何模块中注册专门的函数。如果以后添加了一个具有新用户定义类型的模块,您可以轻松提供一个新的自定义函数来处理该类型。您可以为您没有编写且无法更改的类编写自定义函数。
singledispatch是标准库中经过深思熟虑的添加,它提供的功能比我在这里描述的要多。PEP 443—单分派通用函数是一个很好的参考,但它没有提到后来添加的类型提示的使用。functools模块文档已经改进,并在其singledispatch条目中提供了更多最新的覆盖范例。
注意
@singledispatch并非旨在将 Java 风格的方法重载引入 Python。一个具有许多重载方法变体的单个类比具有一长串if/elif/elif/elif块的单个函数更好。但这两种解决方案都有缺陷,因为它们在单个代码单元(类或函数)中集中了太多责任。@singledispatch的优势在于支持模块化扩展:每个模块可以为其支持的每种类型注册一个专门的函数。在实际用例中,您不会像示例 9-20 中那样将所有通用函数的实现放在同一个模块中。
我们已经看到一些接受参数的装饰器,例如@lru_cache()和htmlize.register(float),由@singledispatch在示例 9-20 中创建。下一节将展示如何构建接受参数的装饰器。
参数化装饰器
在源代码中解析装饰器时,Python 将装饰的函数作为第一个参数传递给装饰器函数。那么如何使装饰器接受其他参数呢?答案是:创建一个接受这些参数并返回装饰器的装饰器工厂,然后将其应用于要装饰的函数。令人困惑?当然。让我们从基于我们看到的最简单的装饰器register的示例开始:示例 9-21。
示例 9-21. 来自示例 9-2 的简化 registration.py 模块,这里为方便起见重复显示
registry = []
def register(func):
print(f'running register({func})')
registry.append(func)
return func
@register
def f1():
print('running f1()')
print('running main()')
print('registry ->', registry)
f1()
一个参数化注册装饰器
为了方便启用或禁用register执行的函数注册,我们将使其接受一个可选的active参数,如果为False,则跳过注册被装饰的函数。示例 9-22 展示了如何。从概念上讲,新的register函数不是一个装饰器,而是一个装饰器工厂。当调用时,它返回将应用于目标函数的实际装饰器。
示例 9-22. 要接受参数,新的register装饰器必须被调用为一个函数
registry = set() # ①
def register(active=True): # ②
def decorate(func): # ③
print('running register'
f'(active={active})->decorate({func})')
if active: # ④
registry.add(func)
else:
registry.discard(func) # ⑤
return func # ⑥
return decorate # ⑦
@register(active=False) # ⑧
def f1():
print('running f1()')
@register() # ⑨
def f2():
print('running f2()')
def f3():
print('running f3()')
①
registry现在是一个set,因此添加和移除函数更快。
②
register接受一个可选的关键字参数。
③
decorate内部函数是实际的装饰器;注意它如何将一个函数作为参数。
④
仅在active参数(从闭包中检索)为True时注册func。
⑤
如果not active并且func in registry,则移除它。
⑥
因为decorate是一个装饰器,所以它必须返回一个函数。
⑦
register是我们的装饰器工厂,因此它返回decorate。
⑧
必须将@register工厂作为一个函数调用,带上所需的参数。
⑨
如果没有传递参数,则必须仍然调用register作为一个函数—@register()—即,返回实际装饰器decorate。
主要点是register()返回decorate,然后应用于被装饰的函数。
示例 9-22 中的代码位于registration_param.py模块中。如果我们导入它,我们会得到这个:
>>> import registration_param
running register(active=False)->decorate(<function f1 at 0x10063c1e0>)
running register(active=True)->decorate(<function f2 at 0x10063c268>)
>>> registration_param.registry
[<function f2 at 0x10063c268>]
注意只有f2函数出现在registry中;f1没有出现,因为active=False被传递给register装饰器工厂,所以应用于f1的decorate没有将其添加到registry中。
如果我们不使用@语法,而是将register作为一个常规函数使用,装饰一个函数f所需的语法将是register()(f)来将f添加到registry中,或者register(active=False)(f)来不添加它(或移除它)。查看示例 9-23 了解如何向registry添加和移除函数的演示。
示例 9-23. 使用示例 9-22 中列出的 registration_param 模块
>>> from registration_param import *
running register(active=False)->decorate(<function f1 at 0x10073c1e0>) running register(active=True)->decorate(<function f2 at 0x10073c268>) >>> registry # ①
{<function f2 at 0x10073c268>} >>> register()(f3) # ②
running register(active=True)->decorate(<function f3 at 0x10073c158>) <function f3 at 0x10073c158> >>> registry # ③
{<function f3 at 0x10073c158>, <function f2 at 0x10073c268>} >>> register(active=False)(f2) # ④
running register(active=False)->decorate(<function f2 at 0x10073c268>) <function f2 at 0x10073c268> >>> registry # ⑤
{<function f3 at 0x10073c158>}
①
当模块被导入时,f2在registry中。
②
register()表达式返回decorate,然后应用于f3。
③
前一行将f3添加到registry中。
④
这个调用从registry中移除了f2。
⑤
确认只有f3保留在registry中。
参数化装饰器的工作方式相当复杂,我们刚刚讨论的比大多数都要简单。参数化装饰器通常会替换被装饰的函数,它们的构建需要另一层嵌套。现在我们将探讨这样一个函数金字塔的架构。
带参数的时钟装饰器
在本节中,我们将重新访问clock装饰器,添加一个功能:用户可以传递一个格式字符串来控制时钟函数报告的输出。参见示例 9-24。
注意
为简单起见,示例 9-24 基于初始clock实现示例 9-14,而不是使用@functools.wraps改进的实现示例 9-16,后者添加了另一个函数层。
示例 9-24. 模块 clockdeco_param.py:带参数时钟装饰器
import time
DEFAULT_FMT = '[{elapsed:0.8f}s] {name}({args}) -> {result}'
def clock(fmt=DEFAULT_FMT): # ①
def decorate(func): # ②
def clocked(*_args): # ③
t0 = time.perf_counter()
_result = func(*_args) # ④
elapsed = time.perf_counter() - t0
name = func.__name__
args = ', '.join(repr(arg) for arg in _args) # ⑤
result = repr(_result) # ⑥
print(fmt.format(**locals())) # ⑦
return _result # ⑧
return clocked # ⑨
return decorate # ⑩
if __name__ == '__main__':
@clock() ⑪
def snooze(seconds):
time.sleep(seconds)
for i in range(3):
snooze(.123)
①
clock是我们的带参数装饰器工厂。
②
decorate是实际的装饰器。
③
clocked包装了被装饰的函数。
④
_result是被装饰函数的实际结果。
⑤
_args保存了clocked的实际参数,而args是用于显示的str。
⑥
result是_result的str表示,用于显示。
⑦
在这里使用**locals()允许引用clocked的任何局部变量在fmt中。¹⁰
⑧
clocked将替换被装饰的函数,因此它应该返回该函数返回的任何内容。
⑨
decorate返回clocked。
⑩
clock返回decorate。
⑪
在这个自测中,clock()被无参数调用,因此应用的装饰器将使用默认格式str。
如果你在 shell 中运行示例 9-24,你会得到这个结果:
$ python3 clockdeco_param.py
[0.12412500s] snooze(0.123) -> None
[0.12411904s] snooze(0.123) -> None
[0.12410498s] snooze(0.123) -> None
为了练习新功能,让我们看一下示例 9-25 和 9-26,它们是使用clockdeco_param的另外两个模块以及它们生成的输出。
示例 9-25. clockdeco_param_demo1.py
import time
from clockdeco_param import clock
@clock('{name}: {elapsed}s')
def snooze(seconds):
time.sleep(seconds)
for i in range(3):
snooze(.123)
示例 9-25 的输出:
$ python3 clockdeco_param_demo1.py
snooze: 0.12414693832397461s
snooze: 0.1241159439086914s
snooze: 0.12412118911743164s
示例 9-26. clockdeco_param_demo2.py
import time
from clockdeco_param import clock
@clock('{name}({args}) dt={elapsed:0.3f}s')
def snooze(seconds):
time.sleep(seconds)
for i in range(3):
snooze(.123)
示例 9-26 的输出:
$ python3 clockdeco_param_demo2.py
snooze(0.123) dt=0.124s
snooze(0.123) dt=0.124s
snooze(0.123) dt=0.124s
注意
第一版的技术审阅员 Lennart Regebro 认为装饰器最好编写为实现__call__的类,而不是像本章示例中的函数那样。我同意这种方法对于复杂的装饰器更好,但为了解释这种语言特性的基本思想,函数更容易理解。参见“进一步阅读”,特别是 Graham Dumpleton 的博客和wrapt模块,用于构建装饰器的工业级技术。
下一节展示了 Regebro 和 Dumpleton 推荐风格的示例。
基于类的时钟装饰器
最后一个例子,示例 9-27 列出了一个作为类实现的带参数clock装饰器的实现,其中使用了__call__。对比示例 9-24 和示例 9-27。你更喜欢哪一个?
示例 9-27. 模块 clockdeco_cls.py:作为类实现的带参数时钟装饰器
import time
DEFAULT_FMT = '[{elapsed:0.8f}s] {name}({args}) -> {result}'
class clock: # ①
def __init__(self, fmt=DEFAULT_FMT): # ②
self.fmt = fmt
def __call__(self, func): # ③
def clocked(*_args):
t0 = time.perf_counter()
_result = func(*_args) # ④
elapsed = time.perf_counter() - t0
name = func.__name__
args = ', '.join(repr(arg) for arg in _args)
result = repr(_result)
print(self.fmt.format(**locals()))
return _result
return clocked
①
与clock外部函数不同,clock类是我们的带参数装饰器工厂。我用小写字母c命名它,以明确表明这个实现是示例 9-24 中的一个可替换项。
②
在clock(my_format)中传入的参数被分配给了这里的fmt参数。类构造函数返回一个clock的实例,其中my_format存储在self.fmt中。
③
__call__使clock实例可调用。当调用时,实例将用clocked替换被装饰的函数。
④
clocked包装了被装饰的函数。
我们的函数装饰器探索到此结束。我们将在第二十四章中看到类装饰器。
章节总结
我们在本章涵盖了一些困难的领域。我尽力使旅程尽可能顺利,但我们确实进入了元编程的领域。
我们从一个没有内部函数的简单@register装饰器开始,最后完成了一个涉及两个嵌套函数级别的参数化@clock()。
虽然注册装饰器在本质上很简单,但在 Python 框架中有真正的应用。我们将在第十章中将注册思想应用于策略设计模式的一个实现。
理解装饰器实际工作原理需要涵盖导入时间和运行时之间的差异,然后深入研究变量作用域、闭包和新的nonlocal声明。掌握闭包和nonlocal不仅有助于构建装饰器,还有助于为 GUI 或异步 I/O 编写事件导向的程序,并在有意义时采用函数式风格。
参数化装饰器几乎总是涉及至少两个嵌套函数,如果您想使用@functools.wraps来生成提供更好支持更高级技术的装饰器,则可能涉及更多嵌套函数。其中一种技术是堆叠装饰器,我们在示例 9-18 中看到了。对于更复杂的装饰器,基于类的实现可能更易于阅读和维护。
作为标准库中参数化装饰器的示例,我们访问了functools模块中强大的@cache和@singledispatch。
进一步阅读
Brett Slatkin 的Effective Python第 2 版(Addison-Wesley)的第 26 条建议了函数装饰器的最佳实践,并建议始终使用functools.wraps——我们在示例 9-16 中看到的。¹¹
Graham Dumpleton 在他的一系列深入的博客文章中介绍了实现行为良好的装饰器的技术,从“你实现的 Python 装饰器是错误的”开始。他在这方面的深厚专业知识也很好地包含在他编写的wrapt模块中,该模块简化了装饰器和动态函数包装器的实现,支持内省,并在进一步装饰、应用于方法以及用作属性描述符时表现正确。第 III 部分的第二十三章是关于描述符的。
《Python Cookbook》第 3 版(O’Reilly)的第九章“元编程”,作者是 David Beazley 和 Brian K. Jones,包含了从基本装饰器到非常复杂的装饰器的几个示例,其中包括一个可以作为常规装饰器或装饰器工厂调用的示例,例如,@clock或@clock()。这在该食谱书中是“食谱 9.6. 定义一个带有可选参数的装饰器”。
Michele Simionato 编写了一个旨在“简化普通程序员对装饰器的使用,并通过展示各种非平凡示例来普及装饰器”的软件包。它在 PyPI 上作为decorator 软件包提供。
当装饰器在 Python 中仍然是一个新功能时创建的,Python 装饰器库维基页面有数十个示例。由于该页面多年前开始,一些显示的技术已经过时,但该页面仍然是一个极好的灵感来源。
“Python 中的闭包”是 Fredrik Lundh 的一篇简短博客文章,解释了闭包的术语。
PEP 3104—访问外部作用域中的名称 描述了引入 nonlocal 声明以允许重新绑定既不是本地的也不是全局的名称。它还包括了如何在其他动态语言(Perl、Ruby、JavaScript 等)中解决这个问题的优秀概述,以及 Python 可用的设计选项的利弊。
在更理论层面上,PEP 227—静态嵌套作用域 记录了在 Python 2.1 中引入词法作用域作为一个选项,并在 Python 2.2 中作为标准的过程,解释了在 Python 中实现闭包的原因和设计选择。
PEP 443 提供了单分派通用函数的理由和详细描述。Guido van Rossum 在 2005 年 3 月的一篇博客文章 “Python 中的五分钟多方法” 通过使用装饰器实现了通用函数(又称多方法)。他的代码支持多分派(即基于多个位置参数的分派)。Guido 的多方法代码很有趣,但这只是一个教学示例。要了解现代、适用于生产的多分派通用函数的实现,请查看 Martijn Faassen 的 Reg—作者是面向模型驱动和 REST 专业的 Morepath web 框架的作者。
¹ 这是 1995 年的设计模式一书,由所谓的四人帮(Gamma 等,Addison-Wesley)撰写。
² 如果你在前一句中将“函数”替换为“类”,你就得到了类装饰器的简要描述。类装饰器在 第二十四章 中有介绍。
³ 感谢技术审阅者 Leonardo Rochael 提出这个总结。
⁴ Python 没有程序全局作用域,只有模块全局作用域。
⁵ 为了澄清,这不是一个打字错误:memoization 是一个与“memorization”模糊相关的计算机科学术语,但并不相同。
⁶ 不幸的是,当 Mypy 0.770 看到多个同名函数时会报错。
⁷ 尽管在 “数值塔的崩塌” 中有警告,number ABCs 并没有被弃用,你可以在 Python 3 代码中找到它们。
⁸ 也许有一天你也能用单个无参数的 @htmlize.register 和使用 Union 类型提示来表达这个,但当我尝试时,Python 报错,提示 Union 不是一个类。因此,尽管 @singledispatch 支持 PEP 484 的语法,但语义还没有实现。
⁹ 例如,NumPy 实现了几种面向机器的整数和浮点数类型。
¹⁰ 技术审阅者 Miroslav Šedivý 指出:“这也意味着代码检查工具会抱怨未使用的变量,因为它们倾向于忽略对 locals() 的使用。” 是的,这是静态检查工具如何阻止我和无数程序员最初被 Python 吸引的动态特性的又一个例子。为了让代码检查工具满意,我可以在调用中两次拼写每个本地变量:fmt.format(elapsed=elapsed, name=name, args=args, result=result)。我宁愿不这样做。如果你使用静态检查工具,非常重要的是要知道何时忽略它们。
¹¹ 我想尽可能简化代码,所以我并没有在所有示例中遵循 Slatkin 的优秀建议。
第十章:具有一等函数的设计模式
符合模式并不是好坏的衡量标准。
拉尔夫·约翰逊,设计模式经典著作的合著者¹
在软件工程中,设计模式是解决常见设计问题的通用配方。你不需要了解设计模式来阅读本章。我将解释示例中使用的模式。
编程中设计模式的使用被设计模式:可复用面向对象软件的元素(Addison-Wesley)一书所推广,作者是 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides,也被称为“四人组”。这本书是一个包含 23 种模式的目录,其中有用 C++代码示例的类排列,但也被认为在其他面向对象语言中也很有用。
尽管设计模式是与语言无关的,但这并不意味着每种模式都适用于每种语言。例如,第十七章将展示在 Python 中模拟Iterator模式的配方是没有意义的,因为该模式已嵌入在语言中,并以生成器的形式准备好使用,不需要类来工作,并且比经典配方需要更少的代码。
设计模式的作者在介绍中承认,实现语言决定了哪些模式是相关的:
编程语言的选择很重要,因为它影响一个人的观点。我们的模式假设具有 Smalltalk/C++级别的语言特性,这种选择决定了什么可以轻松实现,什么不能。如果我们假设过程式语言,我们可能会包括称为“继承”、“封装”和“多态性”的设计模式。同样,一些我们的模式直接受到不太常见的面向对象语言的支持。例如,CLOS 具有多方法,这减少了像 Visitor 这样的模式的需求²
在他 1996 年的演讲中,“动态语言中的设计模式”,Peter Norvig 指出原始设计模式书中的 23 种模式中有 16 种在动态语言中变得“不可见或更简单”(幻灯片 9)。他谈到的是 Lisp 和 Dylan 语言,但许多相关的动态特性也存在于 Python 中。特别是在具有一等函数的语言环境中,Norvig 建议重新思考被称为 Strategy、Command、Template Method 和 Visitor 的经典模式。
本章的目标是展示如何——在某些情况下——函数可以像类一样完成工作,代码更易读且更简洁。我们将使用函数作为对象重构 Strategy 的实现,消除大量样板代码。我们还将讨论简化 Command 模式的类似方法。
本章的新内容
我将这一章移到第三部分的末尾,这样我就可以在“装饰增强的 Strategy 模式”中应用注册装饰器,并在示例中使用类型提示。本章中使用的大多数类型提示并不复杂,它们确实有助于可读性。
案例研究:重构 Strategy
Strategy 是一个很好的设计模式示例,在 Python 中,如果你利用函数作为一等对象,它可能会更简单。在接下来的部分中,我们使用设计模式中描述的“经典”结构来描述和实现 Strategy。如果你熟悉经典模式,可以直接跳到“面向函数的 Strategy”,我们将使用函数重构代码,显著减少行数。
经典 Strategy
图 10-1 中的 UML 类图描述了展示 Strategy 模式的类排列。

图 10-1. 使用策略设计模式实现订单折扣处理的 UML 类图。
设计模式 中对策略模式的总结如下:
定义一组算法家族,封装每个算法,并使它们可以互换。策略让算法独立于使用它的客户端变化。
在电子商务领域中应用策略的一个明显例子是根据客户属性或订购商品的检查计算订单折扣。
考虑一个在线商店,具有以下折扣规则:
-
拥有 1,000 或更多忠诚积分的顾客每个订单可以获得全局 5% 的折扣。
-
每个订单中有 20 个或更多单位的行项目都会获得 10% 的折扣。
-
至少有 10 个不同商品的订单可以获得 7% 的全局折扣。
为简洁起见,假设订单只能应用一个折扣。
策略模式的 UML 类图在 图 10-1 中描述。参与者有:
上下文
通过将一些计算委托给实现替代算法的可互换组件来提供服务。在电子商务示例中,上下文是一个 Order,它被配置为根据几种算法之一应用促销折扣。
策略
实现不同算法的组件之间的公共接口。在我们的例子中,这个角色由一个名为 Promotion 的抽象类扮演。
具体策略
Strategy 的具体子类之一。FidelityPromo、BulkPromo 和 LargeOrderPromo 是实现的三个具体策略。
示例 10-1 中的代码遵循 图 10-1 中的蓝图。如 设计模式 中所述,具体策略由上下文类的客户端选择。在我们的例子中,在实例化订单之前,系统会以某种方式选择促销折扣策略并将其传递给 Order 构造函数。策略的选择超出了模式的范围。
示例 10-1. 实现具有可插入折扣策略的 Order 类
from abc import ABC, abstractmethod
from collections.abc import Sequence
from decimal import Decimal
from typing import NamedTuple, Optional
class Customer(NamedTuple):
name: str
fidelity: int
class LineItem(NamedTuple):
product: str
quantity: int
price: Decimal
def total(self) -> Decimal:
return self.price * self.quantity
class Order(NamedTuple): # the Context
customer: Customer
cart: Sequence[LineItem]
promotion: Optional['Promotion'] = None
def total(self) -> Decimal:
totals = (item.total() for item in self.cart)
return sum(totals, start=Decimal(0))
def due(self) -> Decimal:
if self.promotion is None:
discount = Decimal(0)
else:
discount = self.promotion.discount(self)
return self.total() - discount
def __repr__(self):
return f'<Order total: {self.total():.2f} due: {self.due():.2f}>'
class Promotion(ABC): # the Strategy: an abstract base class
@abstractmethod
def discount(self, order: Order) -> Decimal:
"""Return discount as a positive dollar amount"""
class FidelityPromo(Promotion): # first Concrete Strategy
"""5% discount for customers with 1000 or more fidelity points"""
def discount(self, order: Order) -> Decimal:
rate = Decimal('0.05')
if order.customer.fidelity >= 1000:
return order.total() * rate
return Decimal(0)
class BulkItemPromo(Promotion): # second Concrete Strategy
"""10% discount for each LineItem with 20 or more units"""
def discount(self, order: Order) -> Decimal:
discount = Decimal(0)
for item in order.cart:
if item.quantity >= 20:
discount += item.total() * Decimal('0.1')
return discount
class LargeOrderPromo(Promotion): # third Concrete Strategy
"""7% discount for orders with 10 or more distinct items"""
def discount(self, order: Order) -> Decimal:
distinct_items = {item.product for item in order.cart}
if len(distinct_items) >= 10:
return order.total() * Decimal('0.07')
return Decimal(0)
请注意,在 示例 10-1 中,我将 Promotion 编码为抽象基类(ABC),以使用 @abstractmethod 装饰器并使模式更加明确。
示例 10-2 展示了用于演示和验证实现前述规则的模块操作的 doctests。
示例 10-2. 应用不同促销策略的 Order 类的示例用法
>>> joe = Customer('John Doe', 0) # ①
>>> ann = Customer('Ann Smith', 1100) >>> cart = (LineItem('banana', 4, Decimal('.5')), # ②
... LineItem('apple', 10, Decimal('1.5')), ... LineItem('watermelon', 5, Decimal(5))) >>> Order(joe, cart, FidelityPromo()) # ③
<Order total: 42.00 due: 42.00> >>> Order(ann, cart, FidelityPromo()) # ④
<Order total: 42.00 due: 39.90> >>> banana_cart = (LineItem('banana', 30, Decimal('.5')), # ⑤
... LineItem('apple', 10, Decimal('1.5'))) >>> Order(joe, banana_cart, BulkItemPromo()) # ⑥
<Order total: 30.00 due: 28.50> >>> long_cart = tuple(LineItem(str(sku), 1, Decimal(1)) # ⑦
... for sku in range(10)) >>> Order(joe, long_cart, LargeOrderPromo()) # ⑧
<Order total: 10.00 due: 9.30> >>> Order(joe, cart, LargeOrderPromo()) <Order total: 42.00 due: 42.00>
①
两位顾客:joe 没有忠诚积分,ann 有 1,100 积分。
②
一个购物车有三个行项目。
③
FidelityPromo 促销不给 joe 任何折扣。
④
ann 因为拥有至少 1,000 积分,所以可以获得 5% 的折扣。
⑤
banana_cart 有 30 个单位的 "banana" 产品和 10 个苹果。
⑥
由于 BulkItemPromo,joe 在香蕉上获得 $1.50 的折扣。
⑦
long_cart 有 10 个不同的商品,每个商品价格为 $1.00。
⑧
joe 因为 LargerOrderPromo 而获得整个订单 7% 的折扣。
示例 10-1 可以完美地运行,但是在 Python 中可以使用函数作为对象来实现相同的功能,代码更少。下一节将展示如何实现。
面向函数的策略
示例 10-1 中的每个具体策略都是一个只有一个方法discount的类。此外,策略实例没有状态(没有实例属性)。你可以说它们看起来很像普通函数,你说得对。示例 10-3 是示例 10-1 的重构,用简单函数替换具体策略并移除Promo抽象类。在Order类中只需要做出小的调整。³
示例 10-3。Order类中实现的折扣策略作为函数
from collections.abc import Sequence
from dataclasses import dataclass
from decimal import Decimal
from typing import Optional, Callable, NamedTuple
class Customer(NamedTuple):
name: str
fidelity: int
class LineItem(NamedTuple):
product: str
quantity: int
price: Decimal
def total(self):
return self.price * self.quantity
@dataclass(frozen=True)
class Order: # the Context
customer: Customer
cart: Sequence[LineItem]
promotion: Optional[Callable[['Order'], Decimal]] = None # ①
def total(self) -> Decimal:
totals = (item.total() for item in self.cart)
return sum(totals, start=Decimal(0))
def due(self) -> Decimal:
if self.promotion is None:
discount = Decimal(0)
else:
discount = self.promotion(self) # ②
return self.total() - discount
def __repr__(self):
return f'<Order total: {self.total():.2f} due: {self.due():.2f}>'
# ③
def fidelity_promo(order: Order) -> Decimal: # ④
"""5% discount for customers with 1000 or more fidelity points"""
if order.customer.fidelity >= 1000:
return order.total() * Decimal('0.05')
return Decimal(0)
def bulk_item_promo(order: Order) -> Decimal:
"""10% discount for each LineItem with 20 or more units"""
discount = Decimal(0)
for item in order.cart:
if item.quantity >= 20:
discount += item.total() * Decimal('0.1')
return discount
def large_order_promo(order: Order) -> Decimal:
"""7% discount for orders with 10 or more distinct items"""
distinct_items = {item.product for item in order.cart}
if len(distinct_items) >= 10:
return order.total() * Decimal('0.07')
return Decimal(0)
①
这个类型提示说:promotion可能是None,也可能是一个接受Order参数并返回Decimal的可调用对象。
②
要计算折扣,请调用self.promotion可调用对象,并传递self作为参数。请查看下面的提示原因。
③
没有抽象类。
④
每个策略都是一个函数。
为什么是 self.promotion(self)?
在Order类中,promotion不是一个方法。它是一个可调用的实例属性。因此,表达式的第一部分self.promotion检索到了可调用对象。要调用它,我们必须提供一个Order实例,而在这种情况下是self。这就是为什么在表达式中self出现两次的原因。
“方法是描述符”将解释将方法自动绑定到实例的机制。这不适用于promotion,因为它不是一个方法。
示例 10-3 中的代码比示例 10-1 要短。使用新的Order也更简单,如示例 10-4 中的 doctests 所示。
示例 10-4。Order类使用函数作为促销的示例用法
>>> joe = Customer('John Doe', 0) # ①
>>> ann = Customer('Ann Smith', 1100) >>> cart = [LineItem('banana', 4, Decimal('.5')), ... LineItem('apple', 10, Decimal('1.5')), ... LineItem('watermelon', 5, Decimal(5))] >>> Order(joe, cart, fidelity_promo) # ②
<Order total: 42.00 due: 42.00> >>> Order(ann, cart, fidelity_promo) <Order total: 42.00 due: 39.90> >>> banana_cart = [LineItem('banana', 30, Decimal('.5')), ... LineItem('apple', 10, Decimal('1.5'))] >>> Order(joe, banana_cart, bulk_item_promo) # ③
<Order total: 30.00 due: 28.50> >>> long_cart = [LineItem(str(item_code), 1, Decimal(1)) ... for item_code in range(10)] >>> Order(joe, long_cart, large_order_promo) <Order total: 10.00 due: 9.30> >>> Order(joe, cart, large_order_promo) <Order total: 42.00 due: 42.00>
①
与示例 10-1 相同的测试固定装置。
②
要将折扣策略应用于Order,只需将促销函数作为参数传递。
③
这里和下一个测试中使用了不同的促销函数。
注意示例 10-4 中的标注——每个新订单不需要实例化一个新的促销对象:这些函数已经准备好使用。
有趣的是,在设计模式中,作者建议:“策略对象通常是很好的享元。”⁴ 该作品的另一部分中对享元模式的定义是:“享元是一个可以在多个上下文中同时使用的共享对象。”⁵ 建议共享以减少在每个新上下文中重复应用相同策略时创建新具体策略对象的成本——在我们的例子中,每个新的Order实例。因此,为了克服策略模式的一个缺点——运行时成本——作者建议应用另一种模式。同时,您的代码行数和维护成本正在积累。
一个更棘手的用例,具有内部状态的复杂具体策略可能需要将策略和享元设计模式的所有部分结合起来。但通常具体策略没有内部状态;它们只处理来自上下文的数据。如果是这种情况,那么请务必使用普通的函数,而不是编写实现单方法接口的单方法类的单方法类。函数比用户定义类的实例更轻量级,而且不需要享元,因为每个策略函数在 Python 进程加载模块时只创建一次。一个普通函数也是“一个可以同时在多个上下文中使用的共享对象”。
现在我们已经使用函数实现了策略模式,其他可能性也出现了。假设您想创建一个“元策略”,为给定的Order选择最佳可用折扣。在接下来的几节中,我们研究了使用各种方法利用函数和模块作为对象实现此要求的额外重构。
选择最佳策略:简单方法
在示例 10-4 中的测试中给定相同的顾客和购物车,我们现在在示例 10-5 中添加了三个额外的测试。
示例 10-5。best_promo函数应用所有折扣并返回最大值
>>> Order(joe, long_cart, best_promo) # ①
<Order total: 10.00 due: 9.30>
>>> Order(joe, banana_cart, best_promo) # ②
<Order total: 30.00 due: 28.50>
>>> Order(ann, cart, best_promo) # ③
<Order total: 42.00 due: 39.90>
①
best_promo为顾客joe选择了larger_order_promo。
②
这里joe因为订购了大量香蕉而从bulk_item_promo获得了折扣。
③
使用一个简单的购物车结账,best_promo为忠实顾客ann提供了fidelity_promo的折扣。
best_promo的实现非常简单。参见示例 10-6。
示例 10-6。best_promo在函数列表上迭代找到最大折扣
promos = [fidelity_promo, bulk_item_promo, large_order_promo] # ①
def best_promo(order: Order) -> Decimal: # ②
"""Compute the best discount available"""
return max(promo(order) for promo in promos) # ③
①
promos:作为函数实现的策略列表。
②
best_promo接受Order的实例作为参数,其他*_promo函数也是如此。
③
使用生成器表达式,我们将promos中的每个函数应用于order,并返回计算出的最大折扣。
示例 10-6 很简单:promos是一个函数列表。一旦您习惯于函数是一等对象的概念,自然而然地会发现构建包含函数的数据结构通常是有意义的。
尽管示例 10-6 有效且易于阅读,但存在一些重复代码可能导致微妙的错误:要添加新的促销策略,我们需要编写该函数并记得将其添加到promos列表中,否则新的促销将在显式传递给Order时起作用,但不会被best_promotion考虑。
继续阅读解决此问题的几种解决方案。
在模块中查找策略
Python 中的模块也是头等对象,标准库提供了几个函数来处理它们。Python 文档中对内置的globals描述如下:
globals()
返回表示当前全局符号表的字典。这始终是当前模块的字典(在函数或方法内部,这是定义它的模块,而不是调用它的模块)。
示例 10-7 是一种有些巧妙的使用globals来帮助best_promo自动找到其他可用的*_promo函数的方法。
示例 10-7。promos列表是通过检查模块全局命名空间构建的
from decimal import Decimal
from strategy import Order
from strategy import (
fidelity_promo, bulk_item_promo, large_order_promo # ①
)
promos = promo for name, promo in globals().items() ![2 if name.endswith('_promo') and # ③
name != 'best_promo' # ④
]
def best_promo(order: Order) -> Decimal: # ⑤
"""Compute the best discount available"""
return max(promo(order) for promo in promos)
①
导入促销函数,以便它们在全局命名空间中可用。⁶
②
遍历 globals() 返回的 dict 中的每个项目。
③
仅选择名称以 _promo 结尾的值,并…
④
…过滤掉 best_promo 本身,以避免在调用 best_promo 时出现无限递归。
⑤
best_promo 没有变化。
收集可用促销的另一种方法是创建一个模块,并将所有策略函数放在那里,除了 best_promo。
在 示例 10-8 中,唯一的显著变化是策略函数列表是通过内省一个名为 promotions 的单独模块构建的。请注意,示例 10-8 依赖于导入 promotions 模块以及提供高级内省函数的 inspect。
示例 10-8. promos 列表通过检查新的 promotions 模块进行内省构建
from decimal import Decimal
import inspect
from strategy import Order
import promotions
promos = [func for _, func in inspect.getmembers(promotions, inspect.isfunction)]
def best_promo(order: Order) -> Decimal:
"""Compute the best discount available"""
return max(promo(order) for promo in promos)
函数 inspect.getmembers 返回对象的属性—在本例中是 promotions 模块—可选择通过谓词(布尔函数)进行过滤。我们使用 inspect.isfunction 仅从模块中获取函数。
示例 10-8 不受函数名称的影响;重要的是 promotions 模块只包含计算订单折扣的函数。当然,这是代码的一个隐含假设。如果有人在 promotions 模块中创建一个具有不同签名的函数,那么在尝试将其应用于订单时,best_promo 将会出错。
我们可以添加更严格的测试来过滤函数,例如检查它们的参数。示例 10-8 的重点不是提供一个完整的解决方案,而是强调模块内省的一个可能用法。
一个更明确的动态收集促销折扣函数的替代方法是使用一个简单的装饰器。接下来就是这个。
装饰器增强策略模式
回想一下我们对 示例 10-6 的主要问题是在函数定义中重复函数名称,然后在 promos 列表中重复使用这些名称,供 best_promo 函数确定适用的最高折扣。重复是有问题的,因为有人可能会添加一个新的促销策略函数,并忘记手动将其添加到 promos 列表中——在这种情况下,best_promo 将悄悄地忽略新策略,在系统中引入一个微妙的错误。示例 10-9 使用了 “注册装饰器” 中介绍的技术解决了这个问题。
示例 10-9. promos 列表由 Promotion 装饰器填充
Promotion = Callable[[Order], Decimal]
promos: list[Promotion] = [] # ①
def promotion(promo: Promotion) -> Promotion: # ②
promos.append(promo)
return promo
def best_promo(order: Order) -> Decimal:
"""Compute the best discount available"""
return max(promo(order) for promo in promos) # ③
@promotion # ④
def fidelity(order: Order) -> Decimal:
"""5% discount for customers with 1000 or more fidelity points"""
if order.customer.fidelity >= 1000:
return order.total() * Decimal('0.05')
return Decimal(0)
@promotion
def bulk_item(order: Order) -> Decimal:
"""10% discount for each LineItem with 20 or more units"""
discount = Decimal(0)
for item in order.cart:
if item.quantity >= 20:
discount += item.total() * Decimal('0.1')
return discount
@promotion
def large_order(order: Order) -> Decimal:
"""7% discount for orders with 10 or more distinct items"""
distinct_items = {item.product for item in order.cart}
if len(distinct_items) >= 10:
return order.total() * Decimal('0.07')
return Decimal(0)
①
promos 列表是一个模块全局变量,并且初始为空。
②
Promotion 是一个注册装饰器:它返回未更改的 promo 函数,并将其附加到 promos 列表中。
③
best_promo 不需要更改,因为它依赖于 promos 列表。
④
任何被 @promotion 装饰的函数都将被添加到 promos 中。
这种解决方案比之前提出的其他解决方案有几个优点:
-
促销策略函数不必使用特殊名称—不需要
_promo后缀。 -
@promotion装饰器突出了被装饰函数的目的,并且使得暂时禁用促销变得容易:只需注释掉装饰器。 -
促销折扣策略可以在系统中的任何其他模块中定义,只要对它们应用
@promotion装饰器。
在下一节中,我们将讨论命令——另一个设计模式,有时通过单方法类实现,而普通函数也可以胜任。
命令模式
命令是另一个设计模式,可以通过将函数作为参数传递来简化。图 10-2 显示了命令模式中类的排列。

图 10-2。使用命令设计模式实现的菜单驱动文本编辑器的 UML 类图。每个命令可能有不同的接收者:实现动作的对象。对于PasteCommand,接收者是文档。对于OpenCommand,接收者是应用程序。
命令的目标是将调用操作的对象(调用者)与实现它的提供对象(接收者)解耦。在《设计模式》中的示例中,每个调用者是图形应用程序中的菜单项,而接收者是正在编辑的文档或应用程序本身。
思路是在两者之间放置一个Command对象,实现一个具有单个方法execute的接口,该方法调用接收者中的某个方法执行所需的操作。这样,调用者不需要知道接收者的接口,不同的接收者可以通过不同的Command子类进行适配。调用者配置具体命令并调用其execute方法来操作它。请注意,在图 10-2 中,MacroCommand可以存储一系列命令;其execute()方法调用存储的每个命令中的相同方法。
引用自《设计模式》,“命令是回调的面向对象替代品。”问题是:我们是否需要回调的面向对象替代品?有时是,但并非总是。
我们可以简单地给调用者一个函数,而不是给一个Command实例。调用者可以直接调用command(),而不是调用command.execute()。MacroCommand可以用实现__call__的类来实现。MacroCommand的实例将是可调用对象,每个对象都保存着未来调用的函数列表,就像在示例 10-10 中实现的那样。
示例 10-10。每个MacroCommand实例都有一个内部命令列表
class MacroCommand:
"""A command that executes a list of commands"""
def __init__(self, commands):
self.commands = list(commands) # ①
def __call__(self):
for command in self.commands: # ②
command()
①
从commands参数构建列表确保它是可迭代的,并在每个MacroCommand实例中保留命令引用的本地副本。
②
当调用MacroCommand的实例时,self.commands中的每个命令按顺序调用。
命令模式的更高级用法——支持撤销,例如——可能需要更多于简单回调函数的内容。即使如此,Python 提供了几种值得考虑的替代方案:
-
像示例 10-10 中的
MacroCommand一样的可调用实例可以保持必要的任何状态,并提供除__call__之外的额外方法。 -
闭包可以用来在函数调用之间保存内部状态。
这里我们重新思考了使用一等函数的命令模式。在高层次上,这里的方法与我们应用于策略模式的方法类似:用可调用对象替换实现单方法接口的参与者类的实例。毕竟,每个 Python 可调用对象都实现了单方法接口,而该方法被命名为__call__。
章节总结
正如 Peter Norvig 在经典《设计模式》书籍出现几年后指出的,“23 个模式中有 16 个模式在 Lisp 或 Dylan 中的某些用法上比在 C++ 中具有质量上更简单的实现”(Norvig 的 “动态语言中的设计模式”演示文稿第 9 页)。Python 共享 Lisp 和 Dylan 语言的一些动态特性,特别是一流函数,这是我们在本书的这部分关注的重点。
从本章开头引用的同一次演讲中,在反思《设计模式:可复用面向对象软件的元素》20 周年时,Ralph Johnson 表示该书的一个失败之处是:“过分强调模式作为设计过程中的终点而不是步骤。”⁷ 在本章中,我们以策略模式作为起点:一个我们可以使用一流函数简化的工作解决方案。
在许多情况下,函数或可调用对象提供了在 Python 中实现回调的更自然的方式,而不是模仿 Gamma、Helm、Johnson 和 Vlissides 在《设计模式》中描述的策略或命令模式。本章中策略的重构和命令的讨论是更一般洞察的例子:有时你可能会遇到一个设计模式或一个需要组件实现一个具有单一方法的接口的 API,而该方法具有一个泛泛的名称,如“execute”、“run”或“do_it”。在 Python 中,这种模式或 API 通常可以使用函数作为一流对象来实现,减少样板代码。
进一步阅读
在《Python Cookbook,第三版》中,“配方 8.21 实现访问者模式”展示了一个优雅的访问者模式实现,其中一个 NodeVisitor 类处理方法作为一流对象。
在设计模式的一般主题上,Python 程序员的阅读选择并不像其他语言社区那样广泛。
Learning Python Design Patterns,作者是 Gennadiy Zlobin(Packt),是我见过的唯一一本完全致力于 Python 中模式的书。但 Zlobin 的作品相当简短(100 页),涵盖了原始 23 个设计模式中的 8 个。
Expert Python Programming,作者是 Tarek Ziadé(Packt),是市场上最好的中级 Python 书籍之一,其最后一章“有用的设计模式”从 Python 视角呈现了几个经典模式。
Alex Martelli 关于 Python 设计模式的几次演讲。有他的一个 EuroPython 2011 演讲视频 和一个 他个人网站上的幻灯片集。多年来我发现了不同长度的幻灯片和视频,所以值得彻底搜索他的名字和“Python 设计模式”这几个词。一位出版商告诉我 Martelli 正在撰写一本关于这个主题的书。当它出版时,我一定会买。
在 Java 上下文中有许多关于设计模式的书籍,但其中我最喜欢的是Head First Design Patterns,第二版,作者是埃里克·弗里曼和伊丽莎白·罗布森(O’Reilly)。它解释了 23 个经典模式中的 16 个。如果你喜欢Head First系列的古怪风格,并需要对这个主题有一个介绍,你会喜欢这部作品。它以 Java 为中心,但第二版已经更新,以反映 Java 中添加了一流函数,使得一些示例更接近我们在 Python 中编写的代码。
从动态语言的角度,具有鸭子类型和一流函数的视角重新审视模式,《Design Patterns in Ruby》作者是 Russ Olsen(Addison-Wesley)提供了许多见解,这些见解也适用于 Python。尽管它们在语法上有许多差异,在语义层面上,Python 和 Ruby 更接近于彼此,而不是 Java 或 C++。
在“动态语言中的设计模式”(幻灯片)中,彼得·诺维格展示了头等函数(和其他动态特性)如何使原始设计模式中的一些模式变得更简单或不再必要。
原著设计模式书的介绍由 Gamma 等人撰写,其价值超过了书中的 23 种模式目录,其中包括从非常重要到很少有用的配方。广为引用的设计原则,“针对接口编程,而不是实现”和“优先使用对象组合而非类继承”,都来自该介绍部分。
将模式应用于设计最初源自建筑师克里斯托弗·亚历山大等人,在书籍模式语言(牛津大学出版社)中展示。亚历山大的想法是创建一个标准词汇,使团队在设计建筑时能够共享共同的设计决策。M. J. 多米努斯撰写了“‘设计模式’并非如此”,一个引人入胜的幻灯片和附录文本,论证了亚历山大原始模式的愿景更加深刻,更加人性化,也适用于软件工程。
¹ 来自拉尔夫·约翰逊在 IME/CCSL,圣保罗大学,2014 年 11 月 15 日展示的“设计模式中一些故障的根本原因分析”演讲中的幻灯片。
² 引自设计模式第 4 页。
³ 由于 Mypy 中的一个错误,我不得不使用@dataclass重新实现Order。您可以忽略这个细节,因为这个类与NamedTuple一样工作,就像示例 10-1 中一样。如果Order是一个NamedTuple,当检查promotion的类型提示时,Mypy 0.910 会崩溃。我尝试在特定行添加# type ignore,但 Mypy 仍然崩溃。如果使用@dataclass构建Order,Mypy 会正确处理相同的类型提示。截至 2021 年 7 月 19 日,问题#9397尚未解决。希望在您阅读此文时已经修复。
⁴ 请参阅设计模式第 323 页。
⁵ 同上,第 196 页。
⁶ flake8 和 VS Code 都抱怨这些名称被导入但未被使用。根据定义,静态分析工具无法理解 Python 的动态特性。如果我们听从这些工具的每一个建议,我们很快就会用 Python 语法编写冗长且令人沮丧的类似 Java 的代码。
⁷ “设计模式中一些故障的根本原因分析”,由约翰逊在 IME-USP 于 2014 年 11 月 15 日展示。
第三部分:类和协议
第十一章:一个 Python 风格的对象
使库或框架成为 Pythonic 是为了让 Python 程序员尽可能轻松和自然地学会如何执行任务。
Python 和 JavaScript 框架的创造者 Martijn Faassen。¹
由于 Python 数据模型,您定义的类型可以像内置类型一样自然地行为。而且这可以在不继承的情况下实现,符合鸭子类型的精神:你只需实现对象所需的方法,使其行为符合预期。
在之前的章节中,我们研究了许多内置对象的行为。现在我们将构建行为像真正的 Python 对象一样的用户定义类。你的应用程序类可能不需要并且不应该实现本章示例中那么多特殊方法。但是如果你正在编写一个库或框架,那么将使用你的类的程序员可能希望它们的行为像 Python 提供的类一样。满足这种期望是成为“Pythonic”的一种方式。
本章从第一章结束的地方开始,展示了如何实现在许多不同类型的 Python 对象中经常看到的几个特殊方法。
在本章中,我们将看到如何:
-
支持将对象转换为其他类型的内置函数(例如
repr()、bytes()、complex()等) -
实现一个作为类方法的替代构造函数
-
扩展 f-strings、
format()内置函数和str.format()方法使用的格式迷你语言 -
提供对属性的只读访问
-
使对象可哈希以在集合中使用和作为
dict键 -
使用
__slots__节省内存
当我们开发Vector2d时,我们将做所有这些工作,这是一个简单的二维欧几里德向量类型。这段代码将是第十二章中 N 维向量类的基础。
示例的演变将暂停讨论两个概念性主题:
-
如何以及何时使用
@classmethod和@staticmethod装饰器 -
Python 中的私有和受保护属性:用法、约定和限制
本章的新内容
我在本章的第二段中添加了一个新的引语和一些文字,以解释“Pythonic”的概念——这在第一版中只在最后讨论过。
“格式化显示”已更新以提及在 Python 3.6 中引入的 f-strings。这是一个小改变,因为 f-strings 支持与format()内置和str.format()方法相同的格式迷你语言,因此以前实现的__format__方法可以与 f-strings 一起使用。
本章的其余部分几乎没有变化——自 Python 3.0 以来,特殊方法大部分相同,核心思想出现在 Python 2.2 中。
让我们开始使用对象表示方法。
对象表示
每种面向对象的语言至少有一种标准方法可以从任何对象获取字符串表示。Python 有两种:
repr()
返回一个表示开发者想要看到的对象的字符串。当 Python 控制台或调试器显示一个对象时,你会得到这个。
str()
返回一个表示用户想要看到的对象的字符串。当你print()一个对象时,你会得到这个。
特殊方法__repr__和__str__支持repr()和str(),正如我们在第一章中看到的。
有两个额外的特殊方法支持对象的替代表示:__bytes__和__format__。__bytes__方法类似于__str__:它被bytes()调用以获取对象表示为字节序列。关于__format__,它被 f-strings、内置函数format()和str.format()方法使用。它们调用obj.__format__(format_spec)以获取使用特殊格式代码的对象的字符串显示。我们将在下一个示例中介绍__bytes__,然后介绍__format__。
警告
如果您从 Python 2 转换而来,请记住,在 Python 3 中,__repr__,__str__ 和 __format__ 必须始终返回 Unicode 字符串(类型 str)。 只有 __bytes__ 应该返回字节序列(类型 bytes)。
向量类 Redux
为了演示生成对象表示所使用的许多方法,我们将使用类似于我们在第一章中看到的 Vector2d 类。 我们将在本节和未来的章节中继续完善它。 示例 11-1 说明了我们从 Vector2d 实例中期望的基本行为。
示例 11-1。 Vector2d 实例有几种表示形式
>>> v1 = Vector2d(3, 4)
>>> print(v1.x, v1.y) # ①
3.0 4.0
>>> x, y = v1 # ②
>>> x, y
(3.0, 4.0)
>>> v1 # ③
Vector2d(3.0, 4.0)
>>> v1_clone = eval(repr(v1)) # ④
>>> v1 == v1_clone # ⑤
True
>>> print(v1) # ⑥
(3.0, 4.0)
>>> octets = bytes(v1) # ⑦
>>> octets
b'd\\x00\\x00\\x00\\x00\\x00\\x00\\x08@\\x00\\x00\\x00\\x00\\x00\\x00\\x10@'
>>> abs(v1) # ⑧
5.0
>>> bool(v1), bool(Vector2d(0, 0)) # ⑨
(True, False)
①
Vector2d 的组件可以直接作为属性访问(无需 getter 方法调用)。
②
Vector2d 可以解包为一组变量的元组。
③
Vector2d 的 repr 模拟了构造实例的源代码。
④
在这里使用 eval 显示 Vector2d 的 repr 是其构造函数调用的忠实表示。²
⑤
Vector2d 支持与 == 的比较;这对于测试很有用。
⑥
print 调用 str,对于 Vector2d 会产生一个有序对显示。
⑦
bytes 使用 __bytes__ 方法生成二进制表示。
⑧
abs 使用 __abs__ 方法返回 Vector2d 的大小。
⑨
bool 使用 __bool__ 方法,对于零大小的 Vector2d 返回 False,否则返回 True。
Vector2d 来自示例 11-1,在 vector2d_v0.py 中实现(示例 11-2)。 该代码基于示例 1-2,除了 + 和 * 操作的方法,我们稍后会看到在第十六章中。 我们将添加 == 方法,因为它对于测试很有用。 到目前为止,Vector2d 使用了几个特殊方法来提供 Pythonista 在设计良好的对象中期望的操作。
示例 11-2。 vector2d_v0.py:到目前为止,所有方法都是特殊方法
from array import array
import math
class Vector2d:
typecode = 'd' # ①
def __init__(self, x, y):
self.x = float(x) # ②
self.y = float(y)
def __iter__(self):
return (i for i in (self.x, self.y)) # ③
def __repr__(self):
class_name = type(self).__name__
return '{}({!r}, {!r})'.format(class_name, *self) # ④
def __str__(self):
return str(tuple(self)) # ⑤
def __bytes__(self):
return (bytes([ord(self.typecode)]) + # ⑥
bytes(array(self.typecode, self))) # ⑦
def __eq__(self, other):
return tuple(self) == tuple(other) # ⑧
def __abs__(self):
return math.hypot(self.x, self.y) # ⑨
def __bool__(self):
return bool(abs(self)) # ⑩
①
typecode 是我们在将 Vector2d 实例转换为/从 bytes 时将使用的类属性。
②
在 __init__ 中将 x 和 y 转换为 float 可以及早捕获错误,这在 Vector2d 被使用不合适的参数调用时很有帮助。
③
__iter__ 使 Vector2d 可迭代;这就是解包工作的原因(例如,x, y = my_vector)。 我们简单地通过使用生成器表达式逐个产生组件来实现它。³
④
__repr__ 通过使用 {!r} 插值组件来构建字符串;因为 Vector2d 是可迭代的,*self 将 x 和 y 组件提供给 format。
⑤
从可迭代的 Vector2d 中,很容易构建一个用于显示有序对的 tuple。
⑥
要生成 bytes,我们将类型码转换为 bytes 并连接...
⑦
...通过迭代实例构建的 array 转换为的 bytes。
⑧
要快速比较所有组件,将操作数构建为元组。 这适用于 Vector2d 的实例,但存在问题。 请参阅以下警告。
⑨
大小是由x和y分量形成的直角三角形的斜边的长度。
⑩
__bool__使用abs(self)来计算大小,然后将其转换为bool,因此0.0变为False,非零为True。
警告
示例 11-2 中的__eq__方法适用于Vector2d操作数,但当将Vector2d实例与持有相同数值的其他可迭代对象进行比较时也返回True(例如,Vector(3, 4) == [3, 4])。这可能被视为一个特性或一个错误。进一步讨论需要等到第十六章,当我们讨论运算符重载时。
我们有一个相当完整的基本方法集,但我们仍然需要一种方法来从bytes()生成的二进制表示中重建Vector2d。
另一种构造方法
由于我们可以将Vector2d导出为字节,自然我们需要一个从二进制序列导入Vector2d的方法。在标准库中寻找灵感时,我们发现array.array有一个名为.frombytes的类方法,非常适合我们的目的——我们在“数组”中看到了它。我们采用其名称,并在vector2d_v1.py中的Vector2d类方法中使用其功能(示例 11-3)。
示例 11-3. vector2d_v1.py 的一部分:此片段仅显示了frombytes类方法,添加到 vector2d_v0.py 中的Vector2d定义中(示例 11-2)
@classmethod # ①
def frombytes(cls, octets): # ②
typecode = chr(octets[0]) # ③
memv = memoryview(octets[1:]).cast(typecode) # ④
return cls(*memv) # ⑤
①
classmethod装饰器修改了一个方法,使其可以直接在类上调用。
②
没有self参数;相反,类本身作为第一个参数传递—按照惯例命名为cls。
③
从第一个字节读取typecode。
④
从octets二进制序列创建一个memoryview,并使用typecode进行转换。⁴
⑤
将从转换结果中得到的memoryview解包为构造函数所需的一对参数。
我刚刚使用了classmethod装饰器,它非常特定于 Python,所以让我们谈谈它。
类方法与静态方法
Python 教程中没有提到classmethod装饰器,也没有提到staticmethod。任何在 Java 中学习面向对象编程的人可能会想知道为什么 Python 有这两个装饰器而不是其中的一个。
让我们从classmethod开始。示例 11-3 展示了它的用法:定义一个在类上而不是在实例上操作的方法。classmethod改变了方法的调用方式,因此它接收类本身作为第一个参数,而不是一个实例。它最常见的用途是用于替代构造函数,就像示例 11-3 中的frombytes一样。请注意frombytes的最后一行实际上通过调用cls参数来使用cls参数以构建一个新实例:cls(*memv)。
相反,staticmethod装饰器改变了一个方法,使其不接收特殊的第一个参数。实质上,静态方法就像一个普通函数,只是它存在于类体中,而不是在模块级别定义。示例 11-4 对比了classmethod和staticmethod的操作。
示例 11-4. 比较classmethod和staticmethod的行为
>>> class Demo:
... @classmethod
... def klassmeth(*args):
... return args # ①
... @staticmethod
... def statmeth(*args):
... return args # ②
...
>>> Demo.klassmeth() # ③
(<class '__main__.Demo'>,) >>> Demo.klassmeth('spam')
(<class '__main__.Demo'>, 'spam') >>> Demo.statmeth() # ④
() >>> Demo.statmeth('spam')
('spam',)
①
klassmeth只返回所有位置参数。
②
statmeth也是如此。
③
无论如何调用,Demo.klassmeth都将Demo类作为第一个参数接收。
④
Demo.statmeth的行为就像一个普通的旧函数。
注意
classmethod装饰器显然很有用,但在我的经验中,staticmethod的好用例子非常少见。也许这个函数即使从不涉及类也与之密切相关,所以你可能希望将其放在代码附近。即使如此,在同一模块中在类的前面或后面定义函数大多数情况下已经足够接近了。⁵
现在我们已经看到了classmethod的用途(以及staticmethod并不是很有用),让我们回到对象表示的问题,并看看如何支持格式化输出。
格式化显示
f-strings、format()内置函数和str.format()方法通过调用它们的.__format__(format_spec)方法将实际格式化委托给每种类型。format_spec是一个格式说明符,它可以是:
-
format(my_obj, format_spec)中的第二个参数,或 -
无论在 f-string 中的用
{}括起来的替换字段中的冒号后面的内容,还是在fmt.str.format()中的fmt中
例如:
>>> brl = 1 / 4.82 # BRL to USD currency conversion rate
>>> brl
0.20746887966804978 >>> format(brl, '0.4f') # ①
'0.2075' >>> '1 BRL = {rate:0.2f} USD'.format(rate=brl) # ②
'1 BRL = 0.21 USD' >>> f'1 USD = {1 / brl:0.2f} BRL' # ③
'1 USD = 4.82 BRL'
①
格式说明符是'0.4f'。
②
格式说明符是'0.2f'。替换字段中的rate部分不是格式说明符的一部分。它确定哪个关键字参数进入该替换字段。
③
再次,说明符是'0.2f'。1 / brl表达式不是其中的一部分。
第二个和第三个标注指出了一个重要的观点:例如'{0.mass:5.3e}'这样的格式字符串实际上使用了两种不同的表示法。冒号左边的'0.mass'是替换字段语法的field_name部分,它可以是 f-string 中的任意表达式。冒号后面的'5.3e'是格式说明符。格式说明符中使用的表示法称为格式规范迷你语言。
提示
如果 f-strings、format()和str.format()对你来说是新的,课堂经验告诉我最好先学习format()内置函数,它只使用格式规范迷你语言。在你掌握了这个要领之后,阅读“格式化字符串字面值”和“格式化字符串语法”来了解在 f-strings 和str.format()方法中使用的{:}替换字段符号,包括!s、!r和!a转换标志。f-strings 并不使str.format()过时:大多数情况下 f-strings 解决了问题,但有时最好在其他地方指定格式化字符串,而不是在将要呈现的地方。
一些内置类型在格式规范迷你语言中有自己的表示代码。例如——在几个其他代码中——int类型支持分别用于输出基数 2 和基数 16 的b和x,而float实现了用于固定点显示的f和用于百分比显示的%:
>>> format(42, 'b')
'101010'
>>> format(2 / 3, '.1%')
'66.7%'
格式规范迷你语言是可扩展的,因为每个类都可以根据自己的喜好解释format_spec参数。例如,datetime模块中的类使用strftime()函数和它们的__format__方法中的相同格式代码。以下是使用format()内置函数和str.format()方法的几个示例:
>>> from datetime import datetime
>>> now = datetime.now()
>>> format(now, '%H:%M:%S')
'18:49:05'
>>> "It's now {:%I:%M %p}".format(now)
"It's now 06:49 PM"
如果一个类没有__format__,则从object继承的方法返回str(my_object)。因为Vector2d有一个__str__,所以这样可以:
>>> v1 = Vector2d(3, 4)
>>> format(v1)
'(3.0, 4.0)'
然而,如果传递了格式说明符,object.__format__会引发TypeError:
>>> format(v1, '.3f')
Traceback (most recent call last):
...
TypeError: non-empty format string passed to object.__format__
我们将通过实现自己的格式迷你语言来解决这个问题。第一步是假设用户提供的格式说明符是用于格式化向量的每个float组件。这是我们想要的结果:
>>> v1 = Vector2d(3, 4)
>>> format(v1)
'(3.0, 4.0)'
>>> format(v1, '.2f')
'(3.00, 4.00)'
>>> format(v1, '.3e')
'(3.000e+00, 4.000e+00)'
示例 11-5 实现了__format__以产生刚才显示的内容。
示例 11-5. Vector2d.__format__ 方法,第一部分
# inside the Vector2d class
def __format__(self, fmt_spec=''):
components = (format(c, fmt_spec) for c in self) # ①
return '({}, {})'.format(*components) # ②
①
使用内置的format应用fmt_spec到每个向量组件,构建格式化字符串的可迭代对象。
②
将格式化字符串插入公式'(x, y)'中。
现在让我们向我们的迷你语言添加自定义格式代码:如果格式说明符以'p'结尾,我们将以极坐标形式显示向量:<r, θ>,其中r是幅度,θ(theta)是弧度角。格式说明符的其余部分(在'p'之前的任何内容)将像以前一样使用。
提示
在选择自定义格式代码的字母时,我避免与其他类型使用的代码重叠。在格式规范迷你语言中,我们看到整数使用代码'bcdoxXn',浮点数使用'eEfFgGn%',字符串使用's'。因此,我选择了'p'来表示极坐标。因为每个类都独立解释这些代码,所以在新类型的自定义格式中重用代码字母不是错误,但可能会让用户感到困惑。
要生成极坐标,我们已经有了用于幅度的__abs__方法,我们将使用math.atan2()函数编写一个简单的angle方法来获取角度。这是代码:
# inside the Vector2d class
def angle(self):
return math.atan2(self.y, self.x)
有了这个,我们可以增强我们的__format__以生成极坐标。参见示例 11-6。
示例 11-6. Vector2d.__format__ 方法,第二部分,现在包括极坐标
def __format__(self, fmt_spec=''):
if fmt_spec.endswith('p'): # ①
fmt_spec = fmt_spec[:-1] # ②
coords = (abs(self), self.angle()) # ③
outer_fmt = '<{}, {}>' # ④
else:
coords = self # ⑤
outer_fmt = '({}, {})' # ⑥
components = (format(c, fmt_spec) for c in coords) # ⑦
return outer_fmt.format(*components) # ⑧
①
格式以'p'结尾:使用极坐标。
②
从fmt_spec中删除'p'后缀。
③
构建极坐标的tuple:(magnitude, angle)。
④
用尖括号配置外部格式。
⑤
否则,使用self的x, y组件作为直角坐标。
⑥
用括号配置外部格式。
⑦
生成组件格式化字符串的可迭代对象。
⑧
将格式化字符串插入外部格式。
通过示例 11-6,我们得到类似于以下结果:
>>> format(Vector2d(1, 1), 'p')
'<1.4142135623730951, 0.7853981633974483>'
>>> format(Vector2d(1, 1), '.3ep')
'<1.414e+00, 7.854e-01>'
>>> format(Vector2d(1, 1), '0.5fp')
'<1.41421, 0.78540>'
正如本节所示,扩展格式规范迷你语言以支持用户定义的类型并不困难。
现在让我们转向一个不仅仅关于外观的主题:我们将使我们的Vector2d可散列,这样我们就可以构建向量集,或者将它们用作dict键。
一个可散列的 Vector2d
截至目前,我们的Vector2d实例是不可散列的,因此我们无法将它们放入set中:
>>> v1 = Vector2d(3, 4)
>>> hash(v1)
Traceback (most recent call last):
...
TypeError: unhashable type: 'Vector2d'
>>> set([v1])
Traceback (most recent call last):
...
TypeError: unhashable type: 'Vector2d'
要使Vector2d可散列,我们必须实现__hash__(__eq__也是必需的,我们已经有了)。我们还需要使向量实例不可变,正如我们在“什么是可散列”中看到的。
现在,任何人都可以执行v1.x = 7,而代码中没有任何提示表明更改Vector2d是被禁止的。这是我们想要的行为:
>>> v1.x, v1.y
(3.0, 4.0)
>>> v1.x = 7
Traceback (most recent call last):
...
AttributeError: can't set attribute
我们将通过在示例 11-7 中使x和y组件成为只读属性来实现这一点。
示例 11-7. vector2d_v3.py:仅显示使Vector2d成为不可变的更改;在示例 11-11 中查看完整清单
class Vector2d:
typecode = 'd'
def __init__(self, x, y):
self.__x = float(x) # ①
self.__y = float(y)
@property # ②
def x(self): # ③
return self.__x # ④
@property # ⑤
def y(self):
return self.__y
def __iter__(self):
return (i for i in (self.x, self.y)) # ⑥
# remaining methods: same as previous Vector2d
①
使用正好两个前导下划线(零个或一个尾随下划线)使属性私有化。⁶
②
@property装饰器标记属性的 getter 方法。
③
getter 方法的名称与其公共属性相对应:x。
④
只需返回self.__x。
⑤
重复相同的公式用于y属性。
⑥
每个仅读取x、y分量的方法都可以保持原样,通过self.x和self.y读取公共属性而不是私有属性,因此此列表省略了类的其余代码。
注意
Vector.x和Vector.y是只读属性的示例。读/写属性将在第二十二章中介绍,我们将深入探讨@property。
现在,我们的向量相对安全免受意外变异,我们可以实现__hash__方法。它应返回一个int,理想情况下应考虑在__eq__方法中也使用的对象属性的哈希值,因为比较相等的对象应具有相同的哈希值。__hash__特殊方法的文档建议计算一个包含组件的元组的哈希值,这就是我们在示例 11-8 中所做的。
示例 11-8。vector2d_v3.py:hash的实现
# inside class Vector2d:
def __hash__(self):
return hash((self.x, self.y))
通过添加__hash__方法,我们现在有了可散列的向量:
>>> v1 = Vector2d(3, 4)
>>> v2 = Vector2d(3.1, 4.2)
>>> hash(v1), hash(v2)
(1079245023883434373, 1994163070182233067)
>>> {v1, v2}
{Vector2d(3.1, 4.2), Vector2d(3.0, 4.0)}
提示
实现属性或以其他方式保护实例属性以创建可散列类型并不是绝对必要的。正确实现__hash__和__eq__就足够了。但是,可散列对象的值永远不应更改,因此这提供了一个很好的借口来谈论只读属性。
如果您正在创建具有合理标量数值的类型,还可以实现__int__和__float__方法,这些方法由int()和float()构造函数调用,在某些情况下用于类型强制转换。还有一个__complex__方法来支持complex()内置构造函数。也许Vector2d应该提供__complex__,但我会把这留给你作为一个练习。
支持位置模式匹配
到目前为止,Vector2d实例与关键字类模式兼容——在“关键字类模式”中介绍。
在示例 11-9 中,所有这些关键字模式都按预期工作。
示例 11-9。Vector2d主题的关键字模式——需要 Python 3.10
def keyword_pattern_demo(v: Vector2d) -> None:
match v:
case Vector2d(x=0, y=0):
print(f'{v!r} is null')
case Vector2d(x=0):
print(f'{v!r} is vertical')
case Vector2d(y=0):
print(f'{v!r} is horizontal')
case Vector2d(x=x, y=y) if x==y:
print(f'{v!r} is diagonal')
case _:
print(f'{v!r} is awesome')
但是,如果您尝试使用这样的位置模式:
case Vector2d(_, 0):
print(f'{v!r} is horizontal')
你会得到:
TypeError: Vector2d() accepts 0 positional sub-patterns (1 given)
要使Vector2d与位置模式配合使用,我们需要添加一个名为__match_args__的类属性,按照它们将用于位置模式匹配的顺序列出实例属性:
class Vector2d:
__match_args__ = ('x', 'y')
# etc...
现在,当编写用于匹配Vector2d主题的模式时,我们可以节省一些按键,如您在示例 11-10 中所见。
示例 11-10。Vector2d主题的位置模式——需要 Python 3.10
def positional_pattern_demo(v: Vector2d) -> None:
match v:
case Vector2d(0, 0):
print(f'{v!r} is null')
case Vector2d(0):
print(f'{v!r} is vertical')
case Vector2d(_, 0):
print(f'{v!r} is horizontal')
case Vector2d(x, y) if x==y:
print(f'{v!r} is diagonal')
case _:
print(f'{v!r} is awesome')
__match_args__类属性不需要包括所有公共实例属性。特别是,如果类__init__具有分配给实例属性的必需和可选参数,可能合理地在__match_args__中命名必需参数,但不包括可选参数。
让我们退后一步,回顾一下我们到目前为止在Vector2d中编码的内容。
Vector2d 的完整列表,版本 3
我们已经在Vector2d上工作了一段时间,只展示了一些片段,因此示例 11-11 是vector2d_v3.py的综合完整列表,包括我在开发时使用的 doctests。
示例 11-11。vector2d_v3.py:完整的版本
"""
A two-dimensional vector class
>>> v1 = Vector2d(3, 4)
>>> print(v1.x, v1.y)
3.0 4.0
>>> x, y = v1
>>> x, y
(3.0, 4.0)
>>> v1
Vector2d(3.0, 4.0)
>>> v1_clone = eval(repr(v1))
>>> v1 == v1_clone
True
>>> print(v1)
(3.0, 4.0)
>>> octets = bytes(v1)
>>> octets
b'd\\x00\\x00\\x00\\x00\\x00\\x00\\x08@\\x00\\x00\\x00\\x00\\x00\\x00\\x10@'
>>> abs(v1)
5.0
>>> bool(v1), bool(Vector2d(0, 0))
(True, False)
Test of ``.frombytes()`` class method:
>>> v1_clone = Vector2d.frombytes(bytes(v1))
>>> v1_clone
Vector2d(3.0, 4.0)
>>> v1 == v1_clone
True
Tests of ``format()`` with Cartesian coordinates:
>>> format(v1)
'(3.0, 4.0)'
>>> format(v1, '.2f')
'(3.00, 4.00)'
>>> format(v1, '.3e')
'(3.000e+00, 4.000e+00)'
Tests of the ``angle`` method::
>>> Vector2d(0, 0).angle()
0.0
>>> Vector2d(1, 0).angle()
0.0
>>> epsilon = 10**-8
>>> abs(Vector2d(0, 1).angle() - math.pi/2) < epsilon
True
>>> abs(Vector2d(1, 1).angle() - math.pi/4) < epsilon
True
Tests of ``format()`` with polar coordinates:
>>> format(Vector2d(1, 1), 'p') # doctest:+ELLIPSIS
'<1.414213..., 0.785398...>'
>>> format(Vector2d(1, 1), '.3ep')
'<1.414e+00, 7.854e-01>'
>>> format(Vector2d(1, 1), '0.5fp')
'<1.41421, 0.78540>'
Tests of `x` and `y` read-only properties:
>>> v1.x, v1.y
(3.0, 4.0)
>>> v1.x = 123
Traceback (most recent call last):
...
AttributeError: can't set attribute 'x'
Tests of hashing:
>>> v1 = Vector2d(3, 4)
>>> v2 = Vector2d(3.1, 4.2)
>>> len({v1, v2})
2
"""
from array import array
import math
class Vector2d:
__match_args__ = ('x', 'y')
typecode = 'd'
def __init__(self, x, y):
self.__x = float(x)
self.__y = float(y)
@property
def x(self):
return self.__x
@property
def y(self):
return self.__y
def __iter__(self):
return (i for i in (self.x, self.y))
def __repr__(self):
class_name = type(self).__name__
return '{}({!r}, {!r})'.format(class_name, *self)
def __str__(self):
return str(tuple(self))
def __bytes__(self):
return (bytes([ord(self.typecode)]) +
bytes(array(self.typecode, self)))
def __eq__(self, other):
return tuple(self) == tuple(other)
def __hash__(self):
return hash((self.x, self.y))
def __abs__(self):
return math.hypot(self.x, self.y)
def __bool__(self):
return bool(abs(self))
def angle(self):
return math.atan2(self.y, self.x)
def __format__(self, fmt_spec=''):
if fmt_spec.endswith('p'):
fmt_spec = fmt_spec[:-1]
coords = (abs(self), self.angle())
outer_fmt = '<{}, {}>'
else:
coords = self
outer_fmt = '({}, {})'
components = (format(c, fmt_spec) for c in coords)
return outer_fmt.format(*components)
@classmethod
def frombytes(cls, octets):
typecode = chr(octets[0])
memv = memoryview(octets[1:]).cast(typecode)
return cls(*memv)
总结一下,在本节和前几节中,我们看到了一些您可能希望实现以拥有完整对象的基本特殊方法。
注意
只有在您的应用程序需要时才实现这些特殊方法。最终用户不在乎构成应用程序的对象是否“Pythonic”。
另一方面,如果您的类是其他 Python 程序员使用的库的一部分,您实际上无法猜测他们将如何处理您的对象,他们可能期望我们正在描述的更多“Pythonic”行为。
如示例 11-11 中所编码的,Vector2d是一个关于对象表示相关特殊方法的教学示例,而不是每个用户定义类的模板。
在下一节中,我们将暂时离开Vector2d,讨论 Python 中私有属性机制的设计和缺点——self.__x中的双下划线前缀。
Python 中的私有和“受保护”的属性
在 Python 中,没有像 Java 中的private修饰符那样创建私有变量的方法。在 Python 中,我们有一个简单的机制来防止在子类中意外覆盖“私有”属性。
考虑这种情况:有人编写了一个名为Dog的类,其中内部使用了一个mood实例属性,但没有暴露它。你需要将Dog作为Beagle的子类。如果你在不知道名称冲突的情况下创建自己的mood实例属性,那么你将覆盖从Dog继承的方法中使用的mood属性。这将是一个令人头疼的调试问题。
为了防止这种情况发生,如果你将一个实例属性命名为__mood(两个前导下划线和零个或最多一个尾随下划线),Python 会将该名称存储在实例__dict__中,前缀是一个前导下划线和类名,因此在Dog类中,__mood变成了_Dog__mood,而在Beagle中变成了_Beagle__mood。这种语言特性被称为名称修饰。
示例 11-12 展示了来自示例 11-7 中Vector2d类的结果。
示例 11-12. 私有属性名称通过前缀_和类名“修饰”
>>> v1 = Vector2d(3, 4)
>>> v1.__dict__
{'_Vector2d__y': 4.0, '_Vector2d__x': 3.0}
>>> v1._Vector2d__x
3.0
名称修饰是关于安全性,而不是安全性:它旨在防止意外访问,而不是恶意窥探。图 11-1 展示了另一个安全设备。
知道私有名称是如何被修饰的人可以直接读取私有属性,就像示例 11-12 的最后一行所示的那样——这对调试和序列化实际上是有用的。他们还可以通过编写v1._Vector2d__x = 7来直接为Vector2d的私有组件赋值。但如果你在生产代码中这样做,如果出现问题,就不能抱怨了。
名称修饰功能并不受所有 Python 爱好者的喜爱,以及写作为self.__x的名称的倾斜外观也不受欢迎。一些人更喜欢避免这种语法,只使用一个下划线前缀通过约定“保护”属性(例如,self._x)。对于自动双下划线修饰的批评者,他们建议通过命名约定来解决意外属性覆盖的问题。Ian Bicking——pip、virtualenv 等项目的创建者写道:
永远不要使用两个前导下划线。这是非常私有的。如果担心名称冲突,可以使用显式的名称修饰(例如,
_MyThing_blahblah)。这与双下划线基本相同,只是双下划线会隐藏,而显式名称修饰则是透明的。⁷

图 11-1. 开关上的盖子是一个安全设备,而不是安全设备:它防止事故,而不是破坏。
单个下划线前缀在属性名称中对 Python 解释器没有特殊含义,但在 Python 程序员中是一个非常强烈的约定,你不应该从类外部访问这样的属性。⁸。尊重一个将其属性标记为单个下划线的对象的隐私是很容易的,就像尊重将ALL_CAPS中的变量视为常量的约定一样容易。
在 Python 文档的某些角落中,带有单个下划线前缀的属性被称为“受保护的”⁹。通过约定以self._x的形式“保护”属性的做法很普遍,但将其称为“受保护的”属性并不那么常见。有些人甚至将其称为“私有”属性。
总之:Vector2d的组件是“私有的”,我们的Vector2d实例是“不可变的”——带有引号——因为没有办法使它们真正私有和不可变。¹⁰
现在我们回到我们的Vector2d类。在下一节中,我们将介绍一个特殊的属性(不是方法),它会影响对象的内部存储,对内存使用可能有巨大影响,但对其公共接口影响很小:__slots__。
使用__slots__节省内存
默认情况下,Python 将每个实例的属性存储在名为__dict__的dict中。正如我们在“dict 工作原理的实际后果”中看到的,dict具有显着的内存开销——即使使用了该部分提到的优化。但是,如果你定义一个名为__slots__的类属性,其中包含一系列属性名称,Python 将使用替代的存储模型来存储实例属性:__slots__中命名的属性存储在一个隐藏的引用数组中,使用的内存比dict少。让我们通过简单的示例来看看它是如何工作的,从示例 11-13 开始。
示例 11-13。Pixel类使用__slots__
>>> class Pixel:
... __slots__ = ('x', 'y') # ①
...
>>> p = Pixel() # ②
>>> p.__dict__ # ③
Traceback (most recent call last):
...
AttributeError: 'Pixel' object has no attribute '__dict__'
>>> p.x = 10 # ④
>>> p.y = 20
>>> p.color = 'red' # ⑤
Traceback (most recent call last):
...
AttributeError: 'Pixel' object has no attribute 'color'
①
在创建类时必须存在__slots__;稍后添加或更改它没有效果。属性名称可以是tuple或list,但我更喜欢tuple,以明确表明没有改变的必要。
②
创建一个Pixel的实例,因为我们看到__slots__对实例的影响。
③
第一个效果:Pixel的实例没有__dict__。
④
正常设置p.x和p.y属性。
⑤
第二个效果:尝试设置一个未在__slots__中列出的属性会引发AttributeError。
到目前为止,一切顺利。现在让我们在示例 11-14 中创建Pixel的一个子类,看看__slots__的反直觉之处。
示例 11-14。OpenPixel是Pixel的子类
>>> class OpenPixel(Pixel): # ①
... pass
...
>>> op = OpenPixel()
>>> op.__dict__ # ②
{} >>> op.x = 8 # ③
>>> op.__dict__ # ④
{} >>> op.x # ⑤
8 >>> op.color = 'green' # ⑥
>>> op.__dict__ # ⑦
{'color': 'green'}
①
OpenPixel没有声明自己的属性。
②
惊喜:OpenPixel的实例有一个__dict__。
③
如果你设置属性x(在基类Pixel的__slots__中命名)…
④
…它不存储在实例__dict__中…
⑤
…但它存储在实例的隐藏引用数组中。
⑥
如果你设置一个未在__slots__中命名的属性…
⑦
…它存储在实例__dict__中。
示例 11-14 显示了__slots__的效果只被子类部分继承。为了确保子类的实例没有__dict__,你必须在子类中再次声明__slots__。
如果你声明__slots__ = ()(一个空元组),那么子类的实例将没有__dict__,并且只接受基类__slots__中命名的属性。
如果你希望子类具有额外的属性,请在__slots__中命名它们,就像示例 11-15 中所示的那样。
示例 11-15。ColorPixel,Pixel的另一个子类
>>> class ColorPixel(Pixel):
... __slots__ = ('color',) # ①
>>> cp = ColorPixel()
>>> cp.__dict__ # ②
Traceback (most recent call last):
...
AttributeError: 'ColorPixel' object has no attribute '__dict__'
>>> cp.x = 2
>>> cp.color = 'blue' # ③
>>> cp.flavor = 'banana'
Traceback (most recent call last):
...
AttributeError: 'ColorPixel' object has no attribute 'flavor'
①
本质上,超类的__slots__被添加到当前类的__slots__中。不要忘记单项元组必须有一个尾随逗号。
②
ColorPixel实例没有__dict__。
③
你可以设置此类和超类的__slots__中声明的属性,但不能设置其他属性。
“既能节省内存又能使用它”是可能的:如果将'__dict__'名称添加到__slots__列表中,那么你的实例将保留__slots__中命名的属性在每个实例的引用数组中,但也将支持动态创建的属性,这些属性将存储在通常的__dict__中。如果你想要使用@cached_property装饰器(在“第 5 步:使用 functools 缓存属性”中介绍),这是必要的。
当然,在__slots__中有'__dict__'可能完全打败它的目的,这取决于每个实例中静态和动态属性的数量以及它们的使用方式。粗心的优化比过早的优化更糟糕:你增加了复杂性,但可能得不到任何好处。
另一个你可能想要保留的特殊每实例属性是__weakref__,这对于对象支持弱引用是必要的(在“del 和垃圾回收”中简要提到)。该属性默认存在于用户定义类的实例中。但是,如果类定义了__slots__,并且你需要实例成为弱引用的目标,则需要在__slots__中包含'__weakref__'。
现在让我们看看将__slots__添加到Vector2d的效果。
简单的槽节省度量
示例 11-16 展示了在Vector2d中实现__slots__。
示例 11-16. vector2d_v3_slots.py:__slots__属性是Vector2d的唯一添加
class Vector2d:
__match_args__ = ('x', 'y') # ①
__slots__ = ('__x', '__y') # ②
typecode = 'd'
# methods are the same as previous version
①
__match_args__列出了用于位置模式匹配的公共属性名称。
②
相比之下,__slots__列出了实例属性的名称,这些属性在这种情况下是私有属性。
为了测量内存节省,我编写了mem_test.py脚本。它接受一个带有Vector2d类变体的模块名称作为命令行参数,并使用列表推导式构建一个包含 10,000,000 个Vector2d实例的list。在示例 11-17 中显示的第一次运行中,我使用vector2d_v3.Vector2d(来自示例 11-7);在第二次运行中,我使用具有__slots__的版本,来自示例 11-16。
示例 11-17. mem_test.py 创建了 10 百万个Vector2d实例,使用了命名模块中定义的类
$ time python3 mem_test.py vector2d_v3
Selected Vector2d type: vector2d_v3.Vector2d
Creating 10,000,000 Vector2d instances
Initial RAM usage: 6,983,680
Final RAM usage: 1,666,535,424
real 0m11.990s
user 0m10.861s
sys 0m0.978s
$ time python3 mem_test.py vector2d_v3_slots
Selected Vector2d type: vector2d_v3_slots.Vector2d
Creating 10,000,000 Vector2d instances
Initial RAM usage: 6,995,968
Final RAM usage: 577,839,104
real 0m8.381s
user 0m8.006s
sys 0m0.352s
如示例 11-17 所示,当每个 10 百万个Vector2d实例中使用__dict__时,脚本的 RAM 占用量增长到了 1.55 GiB,但当Vector2d具有__slots__属性时,降低到了 551 MiB。__slots__版本也更快。这个测试中的mem_test.py脚本基本上处理加载模块、检查内存使用情况和格式化结果。你可以在fluentpython/example-code-2e存储库中找到它的源代码。
提示
如果你处理数百万个具有数值数据的对象,你应该真的使用 NumPy 数组(参见“NumPy”),它们不仅内存高效,而且具有高度优化的数值处理函数,其中许多函数一次操作整个数组。我设计Vector2d类只是为了在讨论特殊方法时提供背景,因为我尽量避免在可以的情况下使用模糊的foo和bar示例。
总结__slots__的问题
如果正确使用,__slots__类属性可能会提供显著的内存节省,但有一些注意事项:
-
你必须记得在每个子类中重新声明
__slots__,以防止它们的实例具有__dict__。 -
实例只能拥有
__slots__中列出的属性,除非在__slots__中包含'__dict__'(但这样做可能会抵消内存节省)。 -
使用
__slots__的类不能使用@cached_property装饰器,除非在__slots__中明确命名'__dict__'。 -
实例不能成为弱引用的目标,除非在
__slots__中添加'__weakref__'。
本章的最后一个主题涉及在实例和子类中覆盖类属性。
覆盖类属性
Python 的一个显著特点是类属性可以用作实例属性的默认值。在Vector2d中有typecode类属性。它在__bytes__方法中使用了两次,但我们设计上将其读取为self.typecode。因为Vector2d实例是在没有自己的typecode属性的情况下创建的,所以self.typecode将默认获取Vector2d.typecode类属性。
但是,如果写入一个不存在的实例属性,就会创建一个新的实例属性,例如,一个typecode实例属性,而同名的类属性则保持不变。但是,从那时起,每当处理该实例的代码读取self.typecode时,实例typecode将被检索,有效地遮蔽了同名的类属性。这打开了使用不同typecode自定义单个实例的可能性。
默认的Vector2d.typecode是'd',意味着每个向量分量在导出为bytes时将被表示为 8 字节的双精度浮点数。如果在导出之前将Vector2d实例的typecode设置为'f',则每个分量将以 4 字节的单精度浮点数导出。示例 11-18 演示了这一点。
注意
我们正在讨论添加自定义实例属性,因此示例 11-18 使用了没有__slots__的Vector2d实现,如示例 11-11 中所列。
示例 11-18。通过设置以前从类继承的typecode属性来自定义实例
>>> from vector2d_v3 import Vector2d
>>> v1 = Vector2d(1.1, 2.2)
>>> dumpd = bytes(v1)
>>> dumpd
b'd\x9a\x99\x99\x99\x99\x99\xf1?\x9a\x99\x99\x99\x99\x99\x01@' >>> len(dumpd) # ①
17 >>> v1.typecode = 'f' # ②
>>> dumpf = bytes(v1)
>>> dumpf
b'f\xcd\xcc\x8c?\xcd\xcc\x0c@' >>> len(dumpf) # ③
9 >>> Vector2d.typecode # ④
'd'
①](#co_a_pythonic_object_CO13-1)
默认的bytes表示长度为 17 字节。
②
在v1实例中将typecode设置为'f'。
③
现在bytes转储的长度为 9 字节。
④
Vector2d.typecode保持不变;只有v1实例使用typecode为'f'。
现在应该清楚为什么Vector2d的bytes导出以typecode为前缀:我们想要支持不同的导出格式。
如果要更改类属性,必须直接在类上设置,而不是通过实例。你可以通过以下方式更改所有实例(没有自己的typecode)的默认typecode:
>>> Vector2d.typecode = 'f'
然而,在 Python 中有一种惯用的方法可以实现更持久的效果,并且更明确地说明更改。因为类属性是公共的,它们会被子类继承,所以习惯上是通过子类来定制类数据属性。Django 类基视图广泛使用这种技术。示例 11-19 展示了如何实现。
示例 11-19。ShortVector2d是Vector2d的子类,只覆盖了默认的typecode
>>> from vector2d_v3 import Vector2d
>>> class ShortVector2d(Vector2d): # ①
... typecode = 'f'
...
>>> sv = ShortVector2d(1/11, 1/27) # ②
>>> sv
ShortVector2d(0.09090909090909091, 0.037037037037037035) # ③
>>> len(bytes(sv)) # ④
9
①
创建ShortVector2d作为Vector2d的子类,只是为了覆盖typecode类属性。
②
为演示构建ShortVector2d实例sv。
③
检查sv的repr。
④
检查导出字节的长度为 9,而不是之前的 17。
这个例子还解释了为什么我没有在Vector2d.__repr__中硬编码class_name,而是从type(self).__name__获取它,就像这样:
# inside class Vector2d:
def __repr__(self):
class_name = type(self).__name__
return '{}({!r}, {!r})'.format(class_name, *self)
如果我在class_name中硬编码,Vector2d的子类如ShortVector2d将不得不覆盖__repr__以更改class_name。通过从实例的type中读取名称,我使__repr__更安全地继承。
我们结束了构建一个简单类的覆盖,利用数据模型与 Python 的其他部分协作:提供不同的对象表示,提供自定义格式代码,公开只读属性,并支持 hash() 以与集合和映射集成。
章节总结
本章的目的是演示在构建一个良好的 Python 类时使用特殊方法和约定。
vector2d_v3.py(在 示例 11-11 中显示)比 vector2d_v0.py(在 示例 11-2 中显示)更符合 Python 风格吗?vector2d_v3.py 中的 Vector2d 类显然展示了更多的 Python 特性。但是第一个或最后一个 Vector2d 实现是否合适取决于它将被使用的上下文。Tim Peter 的“Python 之禅”说:
简单胜于复杂。
对象应该尽可能简单,符合需求,而不是语言特性的大杂烩。如果代码是为了一个应用程序,那么它应该专注于支持最终用户所需的内容,而不是更多。如果代码是为其他程序员使用的库,那么实现支持 Python 程序员期望的特殊方法是合理的。例如,__eq__ 可能不是支持业务需求所必需的,但它使类更容易测试。
我在扩展 Vector2d 代码的目标是为了讨论 Python 特殊方法和编码约定提供背景。本章的示例演示了我们在 Table 1-1(第一章)中首次看到的几个特殊方法:
-
字符串/字节表示方法:
__repr__、__str__、__format__和__bytes__ -
将对象转换为数字的方法:
__abs__、__bool__和__hash__ -
__eq__运算符,用于支持测试和哈希(以及__hash__)
在支持转换为 bytes 的同时,我们还实现了一个替代构造函数 Vector2d.frombytes(),这为讨论装饰器 @classmethod(非常方便)和 @staticmethod(不太有用,模块级函数更简单)提供了背景。frombytes 方法受到了 array.array 类中同名方法的启发。
我们看到 格式规范迷你语言 可通过实现 __format__ 方法来扩展,该方法解析提供给 format(obj, format_spec) 内置函数或在 f-strings 中使用的替换字段 '{:«format_spec»}' 中的 format_spec。
为了使 Vector2d 实例可哈希,我们努力使它们是不可变的,至少通过将 x 和 y 属性编码为私有属性,然后将它们公开为只读属性来防止意外更改。然后,我们使用推荐的异或实例属性哈希的技术实现了 __hash__。
我们随后讨论了在 Vector2d 中声明 __slots__ 属性的内存节省和注意事项。因为使用 __slots__ 会产生副作用,所以只有在处理非常大量的实例时才是有意义的——考虑的是百万级的实例,而不仅仅是千个。在许多这种情况下,使用 pandas 可能是最佳选择。
我们讨论的最后一个主题是覆盖通过实例访问的类属性(例如,self.typecode)。我们首先通过创建实例属性,然后通过子类化和在类级别上重写来实现。
在整个章节中,我提到示例中的设计选择是通过研究标准 Python 对象的 API 而得出的。如果这一章可以用一句话总结,那就是:
要构建 Pythonic 对象,观察真实的 Python 对象的行为。
古老的中国谚语
进一步阅读
本章涵盖了数据模型的几个特殊方法,因此主要参考资料与第一章中提供的相同,该章节提供了相同主题的高层次视图。为方便起见,我将在此重复之前的四个推荐,并添加一些其他的:
Python 语言参考的“数据模型”章节
我们在本章中使用的大多数方法在“3.3.1.基本自定义”中有文档记录。
Python 速查手册, 第 3 版,作者 Alex Martelli, Anna Ravenscroft 和 Steve Holden
深入讨论了特殊方法。
Python 食谱, 第 3 版,作者 David Beazley 和 Brian K. Jones
通过示例演示了现代 Python 实践。特别是第八章“类和对象”中有几个与本章讨论相关的解决方案。
Python 基础参考, 第 4 版,作者 David Beazley
详细介绍了数据模型,即使只涵盖了 Python 2.6 和 3.0(在第四版中)。基本概念都是相同的,大多数数据模型 API 自 Python 2.2 以来都没有改变,当时内置类型和用户定义类被统一起来。
在 2015 年,我完成第一版流畅的 Python时,Hynek Schlawack 开始了attrs包。从attrs文档中:
attrs是 Python 包,通过解除你实现对象协议(也称为 dunder 方法)的繁琐,为编写类带来乐趣。
我在“进一步阅读”中提到attrs作为@dataclass的更强大替代品。来自第五章的数据类构建器以及attrs会自动为你的类配备几个特殊方法。但了解如何自己编写这些特殊方法仍然是必要的,以理解这些包的功能,决定是否真正需要它们,并在必要时覆盖它们生成的方法。
在本章中,我们看到了与对象表示相关的所有特殊方法,除了__index__和__fspath__。我们将在第十二章中讨论__index__,“一个切片感知的 getitem”。我不会涉及__fspath__。要了解更多信息,请参阅PEP 519—添加文件系统路径协议。
早期意识到对象需要不同的字符串表示的需求出现在 Smalltalk 中。1996 年 Bobby Woolf 的文章“如何将对象显示为字符串:printString 和 displayString”讨论了该语言中printString和displayString方法的实现。从那篇文章中,我借用了“开发者想要看到的方式”和“用户想要看到的方式”这两个简洁的描述,用于定义repr()和str()在“对象表示”中。
¹ 来自 Faassen 的博客文章“什么是 Pythonic?”
² 我在这里使用eval来克隆对象只是为了说明repr;要克隆一个实例,copy.copy函数更安全更快。
³ 这一行也可以写成yield self.x; yield.self.y。关于__iter__特殊方法、生成器表达式和yield关键字,我在第十七章中还有很多要说。
⁴ 我们在“内存视图”中简要介绍了memoryview,解释了它的.cast方法。
⁵ 本书的技术审阅员之一 Leonardo Rochael 不同意我对 staticmethod 的低评价,并推荐 Julien Danjou 的博文“如何在 Python 中使用静态、类或抽象方法的权威指南”作为反驳意见。Danjou 的文章非常好;我推荐它。但这并不足以改变我的对 staticmethod 的看法。你需要自己决定。
⁶ 私有属性的利弊是即将到来的“Python 中的私有和‘受保护’属性”的主题。
⁷ 来自“粘贴风格指南”。
⁸ 在模块中,顶层名称前的单个 _ 确实有影响:如果你写 from mymod import *,带有 _ 前缀的名称不会从 mymod 中导入。然而,你仍然可以写 from mymod import _privatefunc。这在Python 教程,第 6.1 节,“关于模块的更多内容”中有解释。
⁹ 一个例子在gettext 模块文档中。
¹⁰ 如果这种情况让你沮丧,并且让你希望 Python 在这方面更像 Java,那就不要阅读我对 Java private 修饰符相对强度的讨论,见“Soapbox”。
¹¹ 参见“可能的最简单的工作方式:与沃德·坎宁安的对话,第五部分”。
第十二章:序列的特殊方法
不要检查它是否是一只鸭子:检查它是否像一只鸭子一样嘎嘎叫,走路,等等,具体取决于你需要与之进行语言游戏的鸭子行为子集。(
comp.lang.python,2000 年 7 月 26 日)Alex Martelli
在本章中,我们将创建一个表示多维Vector类的类——这是从第十一章的二维Vector2d中迈出的重要一步。Vector将表现得像一个标准的 Python 不可变的扁平序列。它的元素将是浮点数,并且在本章结束时将支持以下功能:
-
基本序列协议:
__len__和__getitem__ -
安全表示具有许多项目的实例
-
适当的切片支持,生成新的
Vector实例 -
聚合哈希,考虑每个包含元素的值
-
自定义格式化语言扩展
我们还将使用__getattr__实现动态属性访问,以替换我们在Vector2d中使用的只读属性——尽管这不是序列类型的典型做法。
代码密集的展示将被一个关于协议作为非正式接口的概念讨论所打断。我们将讨论协议和鸭子类型的关系,以及当你创建自己的类型时的实际影响。
本章的新内容
本章没有重大变化。在“协议和鸭子类型”末尾附近的提示框中有一个新的typing.Protocol的简短讨论。
在“一个切片感知的 getitem”中,示例 12-6 中__getitem__的实现比第一版更简洁和健壮,这要归功于鸭子类型和operator.index。这种变化延续到了本章和第十六章中对Vector的后续实现。
让我们开始吧。
Vector:用户定义的序列类型
我们实现Vector的策略将是使用组合,而不是继承。我们将把分量存储在一个浮点数的数组中,并将实现Vector所需的方法,使其表现得像一个不可变的扁平序列。
但在我们实现序列方法之前,让我们确保我们有一个基线实现的Vector,它与我们先前的Vector2d类兼容——除非这种兼容性没有意义。
Vector 第一版:与 Vector2d 兼容
Vector的第一个版本应尽可能与我们先前的Vector2d类兼容。
但是,按设计,Vector构造函数与Vector2d构造函数不兼容。我们可以通过在__init__中使用*args来接受任意数量的参数使Vector(3, 4)和Vector(3, 4, 5)起作用,但是序列构造函数的最佳实践是在构造函数中将数据作为可迭代参数接受,就像所有内置序列类型一样。示例 12-1 展示了实例化我们新的Vector对象的一些方法。
示例 12-1。Vector.__init__和Vector.__repr__的测试
>>> Vector([3.1, 4.2])
Vector([3.1, 4.2])
>>> Vector((3, 4, 5))
Vector([3.0, 4.0, 5.0])
>>> Vector(range(10))
Vector([0.0, 1.0, 2.0, 3.0, 4.0, ...])
除了一个新的构造函数签名外,我确保了我对Vector2d(例如,Vector2d(3, 4))进行的每个测试都通过并产生了与两个分量Vector([3, 4])相同的结果。
警告
当一个Vector有超过六个分量时,repr()产生的字符串会被缩写为...,就像在示例 12-1 的最后一行中看到的那样。这在可能包含大量项目的任何集合类型中至关重要,因为repr用于调试,你不希望一个大对象在控制台或日志中跨越数千行。使用reprlib模块生成有限长度的表示,就像示例 12-2 中那样。reprlib模块在 Python 2.7 中被命名为repr。
示例 12-2 列出了我们第一个版本的Vector的实现(此示例基于示例 11-2 和 11-3 中显示的代码)。
示例 12-2. vector_v1.py:派生自 vector2d_v1.py
from array import array
import reprlib
import math
class Vector:
typecode = 'd'
def __init__(self, components):
self._components = array(self.typecode, components) # ①
def __iter__(self):
return iter(self._components) # ②
def __repr__(self):
components = reprlib.repr(self._components) # ③
components = components[components.find('['):-1] # ④
return f'Vector({components})'
def __str__(self):
return str(tuple(self))
def __bytes__(self):
return (bytes([ord(self.typecode)]) +
bytes(self._components)) # ⑤
def __eq__(self, other):
return tuple(self) == tuple(other)
def __abs__(self):
return math.hypot(*self) # ⑥
def __bool__(self):
return bool(abs(self))
@classmethod
def frombytes(cls, octets):
typecode = chr(octets[0])
memv = memoryview(octets[1:]).cast(typecode)
return cls(memv) # ⑦
①
self._components实例“受保护”的属性将保存带有Vector组件的array。
②
为了允许迭代,我们返回一个self._components上的迭代器。¹
③
使用reprlib.repr()获取self._components的有限长度表示(例如,array('d', [0.0, 1.0, 2.0, 3.0, 4.0, ...]))。
④
在将字符串插入Vector构造函数调用之前,删除array('d',前缀和尾随的)。
⑤
直接从self._components构建一个bytes对象。
⑥
自 Python 3.8 起,math.hypot接受 N 维点。我之前使用过这个表达式:math.sqrt(sum(x * x for x in self))。
⑦
与之前的frombytes唯一需要更改的地方在于最后一行:我们直接将memoryview传递给构造函数,而不像之前那样使用*进行解包。
我使用reprlib.repr的方式值得一提。该函数通过限制输出字符串的长度并用'...'标记截断来生成大型或递归结构的安全表示。我希望Vector的repr看起来像Vector([3.0, 4.0, 5.0])而不是Vector(array('d', [3.0, 4.0, 5.0])),因为Vector内部有一个array是一个实现细节。因为这些构造函数调用构建了相同的Vector对象,我更喜欢使用带有list参数的更简单的语法。
在编写__repr__时,我本可以使用这个表达式生成简化的components显示:reprlib.repr(list(self._components))。然而,这样做是浪费的,因为我需要将每个项从self._components复制到一个list中,只是为了使用list的repr。相反,我决定直接将reprlib.repr应用于self._components数组,并在[]之外截断字符。这就是示例 12-2 中__repr__的第二行所做的事情。
提示
由于在调试中的作用,对对象调用repr()不应引发异常。如果在__repr__的实现中出现问题,您必须处理该问题,并尽力产生一些可用的输出,以便用户有机会识别接收者(self)。
请注意,__str__、__eq__和__bool__方法与Vector2d中保持不变,frombytes中只有一个字符发生了变化(最后一行删除了一个*)。这是使原始Vector2d可迭代的好处之一。
顺便说一句,我们本可以从Vector2d中派生Vector,但出于两个原因我选择不这样做。首先,不兼容的构造函数确实使得子类化不可取。我可以通过在__init__中进行一些巧妙的参数处理来解决这个问题,但第二个原因更重要:我希望Vector是一个独立的实现序列协议的类的示例。这就是我们接下来要做的事情,在讨论术语协议之后。
协议和鸭子类型
早在第一章中,我们就看到在 Python 中创建一个完全功能的序列类型并不需要继承任何特殊类;你只需要实现满足序列协议的方法。但我们在谈论什么样的协议呢?
在面向对象编程的上下文中,协议是一种非正式接口,仅在文档中定义,而不在代码中定义。例如,在 Python 中,序列协议仅包括__len__和__getitem__方法。任何实现这些方法的类Spam,具有标准签名和语义,都可以在期望序列的任何地方使用。Spam是这个或那个的子类无关紧要;重要的是它提供了必要的方法。我们在示例 1-1 中看到了这一点,在示例 12-3 中重现。
示例 12-3。示例 1-1 中的代码,这里为方便起见重现
import collections
Card = collections.namedtuple('Card', ['rank', 'suit'])
class FrenchDeck:
ranks = [str(n) for n in range(2, 11)] + list('JQKA')
suits = 'spades diamonds clubs hearts'.split()
def __init__(self):
self._cards = [Card(rank, suit) for suit in self.suits
for rank in self.ranks]
def __len__(self):
return len(self._cards)
def __getitem__(self, position):
return self._cards[position]
示例 12-3 中的FrenchDeck类利用了许多 Python 的功能,因为它实现了序列协议,即使在代码中没有声明。有经验的 Python 编程人员会查看它并理解它是一个序列,即使它是object的子类。我们说它是一个序列,因为它行为像一个序列,这才是重要的。
这被称为鸭子类型,源自亚历克斯·马特利在本章开头引用的帖子。
因为协议是非正式且不受强制执行的,所以如果您知道类将被使用的特定上下文,通常可以只实现协议的一部分。例如,为了支持迭代,只需要__getitem__;不需要提供__len__。
提示
使用PEP 544—Protocols: Structural subtyping (static duck typing),Python 3.8 支持协议类:typing构造,我们在“静态协议”中学习过。Python 中这个新用法的“协议”一词具有相关但不同的含义。当我需要区分它们时,我会写静态协议来指代协议类中规范化的协议,而动态协议则指传统意义上的协议。一个关键区别是静态协议实现必须提供协议类中定义的所有方法。第十三章的“两种协议”有更多细节。
我们现在将在Vector中实现序列协议,最初没有适当的切片支持,但稍后会添加。
Vector 第二版:可切片序列
正如我们在FrenchDeck示例中看到的,如果您可以将对象中的序列属性委托给一个序列属性,比如我们的self._components数组,那么支持序列协议就非常容易。这些__len__和__getitem__一行代码是一个很好的开始:
class Vector:
# many lines omitted
# ...
def __len__(self):
return len(self._components)
def __getitem__(self, index):
return self._components[index]
有了这些补充,现在所有这些操作都可以正常工作:
>>> v1 = Vector([3, 4, 5])
>>> len(v1)
3
>>> v1[0], v1[-1]
(3.0, 5.0)
>>> v7 = Vector(range(7))
>>> v7[1:4]
array('d', [1.0, 2.0, 3.0])
如您所见,即使支持切片,但并不是很好。如果Vector的切片也是Vector实例而不是array,那将更好。旧的FrenchDeck类也有类似的问题:当您对其进行切片时,会得到一个list。在Vector的情况下,当切片产生普通数组时,会丢失很多功能。
考虑内置序列类型:每一个,在切片时,都会产生自己类型的新实例,而不是其他类型的实例。
要使Vector生成Vector实例作为切片,我们不能简单地将切片委托给array。我们需要分析在__getitem__中获得的参数并做正确的事情。
现在,让我们看看 Python 如何将语法my_seq[1:3]转换为my_seq.__getitem__(...)的参数。
切片的工作原理
一个示例胜过千言万语,所以看看示例 12-4。
示例 12-4。检查__getitem__和切片的行为
>>> class MySeq:
... def __getitem__(self, index):
... return index # ①
...
>>> s = MySeq()
>>> s[1] # ②
1 >>> s[1:4] # ③
slice(1, 4, None) >>> s[1:4:2] # ④
slice(1, 4, 2) >>> s[1:4:2, 9] # ⑤
(slice(1, 4, 2), 9) >>> s[1:4:2, 7:9] # ⑥
(slice(1, 4, 2), slice(7, 9, None))
①
对于这个演示,__getitem__只是返回传递给它的任何内容。
②
单个索引,没什么新鲜事。
③
表示1:4变为slice(1, 4, None)。
④
slice(1, 4, 2)意味着从 1 开始,到 4 结束,步长为 2。
⑤
惊喜:[]内部有逗号意味着__getitem__接收到一个元组。
⑥
元组甚至可以包含多个slice对象。
现在让我们更仔细地看看slice本身在示例 12-5 中。
示例 12-5。检查slice类的属性
>>> slice # ①
<class 'slice'> >>> dir(slice) # ②
['__class__', '__delattr__', '__dir__', '__doc__', '__eq__',
'__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'indices', 'start', 'step', 'stop']
①
slice是一个内置类型(我们在“切片对象”中首次看到它)。
②
检查一个slice,我们发现数据属性start、stop和step,以及一个indices方法。
在示例 12-5 中调用dir(slice)会显示一个indices属性,这个属性实际上是一个非常有趣但鲜为人知的方法。以下是help(slice.indices)的内容:
S.indices(len) -> (start, stop, stride)
假设长度为len的序列,计算由S描述的扩展切片的start和stop索引以及stride长度。超出边界的索引会像在正常切片中一样被截断。
换句话说,indices暴露了内置序列中实现的复杂逻辑,以优雅地处理缺失或负索引以及比原始序列长的切片。这个方法生成针对给定长度序列的非负start、stop和stride整数的“标准化”元组。
这里有几个例子,考虑一个长度为len == 5的序列,例如,'ABCDE':
>>> slice(None, 10, 2).indices(5) # ①
(0, 5, 2) >>> slice(-3, None, None).indices(5) # ②
(2, 5, 1)
①
'ABCDE'[:10:2]等同于'ABCDE'[0:5:2]。
②
'ABCDE'[-3:]等同于'ABCDE'[2:5:1]。
在我们的Vector代码中,我们不需要使用slice.indices()方法,因为当我们得到一个切片参数时,我们将把它的处理委托给_components数组。但是如果你不能依赖底层序列的服务,这个方法可以节省大量时间。
现在我们知道如何处理切片了,让我们看看改进的Vector.__getitem__实现。
一个了解切片的__getitem__
示例 12-6 列出了使Vector表现为序列所需的两个方法:__len__和__getitem__(后者现在已实现以正确处理切片)。
示例 12-6。vector_v2.py 的一部分:向Vector类添加了__len__和__getitem__方法,这些方法来自 vector_v1.py(参见示例 12-2)
def __len__(self):
return len(self._components)
def __getitem__(self, key):
if isinstance(key, slice): # ①
cls = type(self) # ②
return cls(self._components[key]) # ③
index = operator.index(key) # ④
return self._components[index] # ⑤
①
如果key参数是一个slice…
②
…获取实例的类(即Vector)并…
③
…调用该类以从_components数组的切片构建另一个Vector实例。
④
如果我们可以从key中得到一个index…
⑤
…返回_components中的特定项。
operator.index()函数调用__index__特殊方法。该函数和特殊方法在PEP 357—允许任何对象用于切片中定义,由 Travis Oliphant 提出,允许 NumPy 中的众多整数类型用作索引和切片参数。operator.index()和int()之间的关键区别在于前者是为此特定目的而设计的。例如,int(3.14)返回3,但operator.index(3.14)会引发TypeError,因为float不应该用作索引。
注意
过度使用isinstance可能是糟糕的面向对象设计的迹象,但在__getitem__中处理切片是一个合理的用例。在第一版中,我还对key进行了isinstance测试,以测试它是否为整数。使用operator.index避免了这个测试,并且如果无法从key获取index,则会引发带有非常详细信息的TypeError。请参见示例 12-7 中的最后一个错误消息。
一旦将示例 12-6 中的代码添加到Vector类中,我们就具有了适当的切片行为,正如示例 12-7 所示。
示例 12-7。增强的Vector.__getitem__的测试,来自示例 12-6
>>> v7 = Vector(range(7)) >>> v7[-1] # ①
6.0 >>> v7[1:4] # ②
Vector([1.0, 2.0, 3.0]) >>> v7[-1:] # ③
Vector([6.0]) >>> v7[1,2] # ④
Traceback (most recent call last): ... TypeError: 'tuple' object cannot be interpreted as an integer
①
整数索引仅检索一个分量值作为float。
②
切片索引会创建一个新的Vector。
③
长度为 1 的切片也会创建一个Vector。
④
Vector不支持多维索引,因此索引或切片的元组会引发错误。
向量第三版:动态属性访问
从Vector2d到Vector的演变中,我们失去了通过名称访问向量分量的能力(例如,v.x,v.y)。我们现在正在处理可能具有大量分量的向量。尽管如此,使用快捷字母(如x,y,z)而不是v[0],v[1]和v[2]访问前几个分量可能更方便。
这是我们想要提供的用于读取向量前四个分量的替代语法:
>>> v = Vector(range(10))
>>> v.x
0.0
>>> v.y, v.z, v.t
(1.0, 2.0, 3.0)
在Vector2d中,我们使用@property装饰器提供了对x和y的只读访问(示例 11-7)。我们可以在Vector中编写四个属性,但这样做会很繁琐。__getattr__特殊方法提供了更好的方法。
当属性查找失败时,解释器会调用__getattr__方法。简单来说,给定表达式my_obj.x,Python 会检查my_obj实例是否有名为x的属性;如果没有,搜索会到类(my_obj.__class__)然后沿着继承图向上走。² 如果未找到x属性,则会调用my_obj类中定义的__getattr__方法,传入self和属性名称作为字符串(例如,'x')。
示例 12-8 列出了我们的__getattr__方法。基本上,它检查正在寻找的属性是否是字母xyzt中的一个,如果是,则返回相应的向量分量。
示例 12-8。vector_v3.py的一部分:Vector类中添加的__getattr__方法
__match_args__ = ('x', 'y', 'z', 't') # ①
def __getattr__(self, name):
cls = type(self) # ②
try:
pos = cls.__match_args__.index(name) # ③
except ValueError: # ④
pos = -1
if 0 <= pos < len(self._components): # ⑤
return self._components[pos]
msg = f'{cls.__name__!r} object has no attribute {name!r}' # ⑥
raise AttributeError(msg)
①
设置__match_args__以允许在__getattr__支持的动态属性上进行位置模式匹配。³
②
获取Vector类以备后用。
③
尝试获取__match_args__中name的位置。
④
.index(name)在未找到name时引发ValueError;将pos设置为-1。(我更愿意在这里使用类似str.find的方法,但tuple没有实现它。)
⑤
如果pos在可用分量的范围内,则返回该分量。
⑥
如果执行到这一步,请引发带有标准消息文本的AttributeError。
实现__getattr__并不难,但在这种情况下还不够。考虑示例 12-9 中的奇怪交互。
示例 12-9。不当行为:对v.x赋值不会引发错误,但会引入不一致性。
>>> v = Vector(range(5))
>>> v
Vector([0.0, 1.0, 2.0, 3.0, 4.0]) >>> v.x # ①
0.0 >>> v.x = 10 # ②
>>> v.x # ③
10 >>> v
Vector([0.0, 1.0, 2.0, 3.0, 4.0]) # ④
①
将元素 v[0] 作为 v.x 访问。
②
将新值分配给 v.x。这应该引发异常。
③
读取 v.x 显示新值 10。
④
然而,矢量组件没有发生变化。
你能解释发生了什么吗?特别是,如果向矢量组件数组中没有的值尝试 v.x 返回 10,那么为什么第二次会这样?如果你一时不知道,那就仔细研究一下在示例 12-8 之前给出的 __getattr__ 解释。这有点微妙,但是是理解本书后面内容的重要基础。
经过一番思考后,继续进行,我们将详细解释发生了什么。
示例 12-9 中的不一致性是由于 __getattr__ 的工作方式引入的:Python 仅在对象没有命名属性时才调用该方法作为后备。然而,在我们分配 v.x = 10 后,v 对象现在有一个 x 属性,因此 __getattr__ 将不再被调用来检索 v.x:解释器将直接返回绑定到 v.x 的值 10。另一方面,我们的 __getattr__ 实现不关心除 self._components 外的实例属性,从中检索列在 __match_args__ 中的“虚拟属性”的值。
我们需要自定义在我们的 Vector 类中设置属性的逻辑,以避免这种不一致性。
回想一下,在第十一章中关于最新 Vector2d 示例的情况,尝试分配给 .x 或 .y 实例属性会引发 AttributeError。在 Vector 中,我们希望任何尝试分配给所有单个小写字母属性名称时都引发相同的异常,以避免混淆。为此,我们将实现 __setattr__,如示例 12-10 中所列。
示例 12-10. Vector 类中的 __setattr__ 方法的一部分,位于 vector_v3.py 中。
def __setattr__(self, name, value):
cls = type(self)
if len(name) == 1: # ①
if name in cls.__match_args__: # ②
error = 'readonly attribute {attr_name!r}'
elif name.islower(): # ③
error = "can't set attributes 'a' to 'z' in {cls_name!r}"
else:
error = '' # ④
if error: # ⑤
msg = error.format(cls_name=cls.__name__, attr_name=name)
raise AttributeError(msg)
super().__setattr__(name, value) # ⑥
①
对单个字符属性名称进行特殊处理。
②
如果 name 是 __match_args__ 中的一个,设置特定的错误消息。
③
如果 name 是小写的,设置关于所有单个字母名称的错误消息。
④
否则,设置空白错误消息。
⑤
如果存在非空错误消息,则引发 AttributeError。
⑥
默认情况:调用超类上的 __setattr__ 以获得标准行为。
提示
super() 函数提供了一种动态访问超类方法的方式,在像 Python 这样支持多重继承的动态语言中是必不可少的。它用于将某些任务从子类中的一个方法委托给超类中的一个合适的方法,就像在示例 12-10 中所看到的那样。关于 super 还有更多内容,请参阅“多重继承和方法解析顺序”。
在选择与 AttributeError 一起显示的错误消息时,我的第一个检查对象是内置的 complex 类型的行为,因为它们是不可变的,并且有一对数据属性,real 和 imag。尝试更改 complex 实例中的任一属性都会引发带有消息 "can't set attribute" 的 AttributeError。另一方面,尝试设置只读属性(如我们在“可散列的 Vector2d”中所做的)会产生消息 "read-only attribute"。我从这两个措辞中汲取灵感,以设置 __setitem__ 中的 error 字符串,但对于被禁止的属性更加明确。
注意,我们并不禁止设置所有属性,只是单个字母、小写属性,以避免与支持的只读属性x、y、z和t混淆。
警告
知道在类级别声明__slots__可以防止设置新的实例属性,很容易就会想要使用这个特性,而不是像我们之前那样实现__setattr__。然而,正如在“总结与__slots__相关的问题”中讨论的所有注意事项,仅仅为了防止实例属性创建而使用__slots__是不推荐的。__slots__应该仅用于节省内存,而且只有在这是一个真正的问题时才使用。
即使不支持写入Vector分量,这个示例中有一个重要的要点:当你实现__getattr__时,很多时候你需要编写__setattr__,以避免对象中的不一致行为。
如果我们想允许更改分量,我们可以实现__setitem__以启用v[0] = 1.1和/或__setattr__以使v.x = 1.1起作用。但Vector将保持不可变,因为我们希望在接下来的部分使其可哈希。
Vector 第四版:哈希和更快的==
再次我们要实现一个__hash__方法。连同现有的__eq__,这将使Vector实例可哈希。
Vector2d中的__hash__(示例 11-8)计算了由两个分量self.x和self.y构建的tuple的哈希值。现在我们可能正在处理成千上万个分量,因此构建tuple可能成本太高。相反,我将对每个分量的哈希值依次应用^(异或)运算符,就像这样:v[0] ^ v[1] ^ v[2]。这就是functools.reduce函数的用途。之前我说过reduce不像以前那样流行,⁴但计算所有向量分量的哈希值是一个很好的使用案例。图 12-1 描述了reduce函数的一般思想。

图 12-1。减少函数——reduce、sum、any、all——从序列或任何有限可迭代对象中产生单个聚合结果。
到目前为止,我们已经看到functools.reduce()可以被sum()替代,但现在让我们正确解释它的工作原理。关键思想是将一系列值减少为单个值。reduce()的第一个参数是一个二元函数,第二个参数是一个可迭代对象。假设我们有一个二元函数fn和一个列表lst。当你调用reduce(fn, lst)时,fn将被应用于第一对元素——fn(lst[0], lst[1])——产生第一个结果r1。然后fn被应用于r1和下一个元素——fn(r1, lst[2])——产生第二个结果r2。现在fn(r2, lst[3])被调用以产生r3 … 依此类推,直到最后一个元素,当返回一个单一结果rN。
这是如何使用reduce计算5!(5 的阶乘)的方法:
>>> 2 * 3 * 4 * 5 # the result we want: 5! == 120
120
>>> import functools
>>> functools.reduce(lambda a,b: a*b, range(1, 6))
120
回到我们的哈希问题,示例 12-11 展示了通过三种方式计算累积异或的想法:使用一个for循环和两个reduce调用。
示例 12-11。计算从 0 到 5 的整数的累积异或的三种方法
>>> n = 0
>>> for i in range(1, 6): # ①
... n ^= i
...
>>> n
1 >>> import functools
>>> functools.reduce(lambda a, b: a^b, range(6)) # ②
1 >>> import operator
>>> functools.reduce(operator.xor, range(6)) # ③
1
①
使用for循环和一个累加变量进行聚合异或。
②
使用匿名函数的functools.reduce。
③
使用functools.reduce用operator.xor替换自定义lambda。
在示例 12-11 中的备选方案中,最后一个是我最喜欢的,for循环排在第二位。你更喜欢哪种?
正如在“operator 模块”中所看到的,operator以函数形式提供了所有 Python 中缀运算符的功能,减少了对lambda的需求。
要按照我喜欢的风格编写Vector.__hash__,我们需要导入functools和operator模块。示例 12-12 展示了相关的更改。
示例 12-12。vector_v4.py 的一部分:从 vector_v3.py 添加两个导入和Vector类的__hash__方法
from array import array
import reprlib
import math
import functools # ①
import operator # ②
class Vector:
typecode = 'd'
# many lines omitted in book listing...
def __eq__(self, other): # ③
return tuple(self) == tuple(other)
def __hash__(self):
hashes = (hash(x) for x in self._components) # ④
return functools.reduce(operator.xor, hashes, 0) # ⑤
# more lines omitted...
①
导入functools以使用reduce。
②
导入operator以使用xor。
③
对__eq__没有更改;我在这里列出它是因为在源代码中保持__eq__和__hash__靠近是一个好习惯,因为它们需要一起工作。
④
创建一个生成器表达式,以惰性计算每个组件的哈希值。
⑤
将hashes传递给reduce,使用xor函数计算聚合哈希码;第三个参数0是初始化器(参见下一个警告)。
警告
使用reduce时,最好提供第三个参数,reduce(function, iterable, initializer),以防止出现此异常:TypeError: reduce() of empty sequence with no initial value(出色的消息:解释了问题以及如何解决)。initializer是如果序列为空时返回的值,并且作为减少循环中的第一个参数使用,因此它应该是操作的身份值。例如,对于+,|,^,initializer应该是0,但对于*,&,它应该是1。
如示例 12-12 中实现的__hash__方法是一个完美的 map-reduce 计算示例(图 12-2)。

图 12-2。Map-reduce:将函数应用于每个项目以生成新系列(map),然后计算聚合(reduce)。
映射步骤为每个组件生成一个哈希值,减少步骤使用xor运算符聚合所有哈希值。使用map而不是genexp使映射步骤更加可见:
def __hash__(self):
hashes = map(hash, self._components)
return functools.reduce(operator.xor, hashes)
提示
在 Python 2 中,使用map的解决方案效率较低,因为map函数会构建一个包含结果的新list。但在 Python 3 中,map是惰性的:它创建一个生成器,按需产生结果,从而节省内存——就像我们在示例 12-8 的__hash__方法中使用的生成器表达式一样。
当我们谈论减少函数时,我们可以用另一种更便宜的方式来替换我们快速实现的__eq__,至少对于大向量来说,在处理和内存方面更便宜。正如示例 11-2 中介绍的,我们有这个非常简洁的__eq__实现:
def __eq__(self, other):
return tuple(self) == tuple(other)
这适用于Vector2d和Vector——甚至将Vector([1, 2])视为(1, 2)相等,这可能是一个问题,但我们暂时忽略这一点。⁵ 但对于可能有数千个组件的Vector实例来说,这是非常低效的。它构建了两个元组,复制了操作数的整个内容,只是为了使用tuple类型的__eq__。对于Vector2d(只有两个组件),这是一个很好的快捷方式,但对于大型多维向量来说不是。比较一个Vector和另一个Vector或可迭代对象的更好方法将是示例 12-13。
示例 12-13。使用for循环中的zip实现的Vector.__eq__方法,用于更高效的比较
def __eq__(self, other):
if len(self) != len(other): # ①
return False
for a, b in zip(self, other): # ②
if a != b: # ③
return False
return True # ④
①
如果对象的长度不同,则它们不相等。
②
zip生成一个由每个可迭代参数中的项目组成的元组生成器。如果您对zip不熟悉,请参阅“了不起的 zip”。在①中,需要进行len比较,因为zip在其中一个输入耗尽时会停止生成值而没有警告。
③
一旦两个分量不同,立即返回False。
④
否则,对象相等。
提示
zip函数的命名是根据拉链拉链器而来,因为物理设备通过相互锁定来自拉链两侧的牙齿对来工作,这与zip(left, right)所做的事情是一个很好的视觉类比。与压缩文件无关。
示例 12-13 是高效的,但all函数可以在一行中产生与for循环相同的聚合计算:如果操作数中对应分量之间的所有比较都为True,则结果为True。一旦有一个比较为False,all就返回False。示例 12-14 展示了使用all的__eq__的外观。
示例 12-14. 使用zip和all实现的Vector.__eq__:与示例 12-13 相同的逻辑
def __eq__(self, other):
return len(self) == len(other) and all(a == b for a, b in zip(self, other))
请注意,我们首先检查操作数的长度是否相等,因为zip将停止在最短的操作数处。
示例 12-14 是我们在vector_v4.py中选择的__eq__的实现。
我们通过将Vector2d的__format__方法重新引入到Vector中来结束本章。
Vector Take #5: Formatting
Vector的__format__方法将类似于Vector2d的方法,但不是提供极坐标的自定义显示,而是使用球坐标——也称为“超球面”坐标,因为现在我们支持n维,而在 4D 及以上的维度中,球体是“超球体”。⁶ 因此,我们将自定义格式后缀从'p'改为'h'。
提示
正如我们在“Formatted Displays”中看到的,当扩展格式规范迷你语言时,最好避免重用内置类型支持的格式代码。特别是,我们扩展的迷你语言还使用浮点数格式代码'eEfFgGn%'的原始含义,因此我们绝对必须避免这些。整数使用'bcdoxXn',字符串使用's'。我选择了'p'来表示Vector2d的极坐标。代码'h'表示超球面坐标是一个不错的选择。
例如,给定 4D 空间中的Vector对象(len(v) == 4),'h'代码将产生类似于<r, Φ₁, Φ₂, Φ₃>的显示,其中r是大小(abs(v)),其余数字是角分量Φ₁,Φ₂,Φ₃。
这里是来自vector_v5.py的 doctests 中 4D 空间中球坐标格式的一些示例(参见示例 12-16):
>>> format(Vector([-1, -1, -1, -1]), 'h')
'<2.0, 2.0943951023931957, 2.186276035465284, 3.9269908169872414>'
>>> format(Vector([2, 2, 2, 2]), '.3eh')
'<4.000e+00, 1.047e+00, 9.553e-01, 7.854e-01>'
>>> format(Vector([0, 1, 0, 0]), '0.5fh')
'<1.00000, 1.57080, 0.00000, 0.00000>'
在我们可以实现__format__中所需的微小更改之前,我们需要编写一对支持方法:angle(n)用于计算一个角坐标(例如,Φ₁),以及angles()用于返回所有角坐标的可迭代对象。我不会在这里描述数学内容;如果你感兴趣,维基百科的“n-sphere”条目有我用来从Vector的分量数组中计算球坐标的公式。
示例 12-16 是vector_v5.py的完整清单,汇总了自从“Vector Take #1: Vector2d Compatible”以来我们实现的所有内容,并引入了自定义格式。
示例 12-16. vector_v5.py:包含最终Vector类的 doctests 和所有代码;标注突出显示了支持__format__所需的添加内容
"""
A multidimensional ``Vector`` class, take 5
A ``Vector`` is built from an iterable of numbers::
>>> Vector([3.1, 4.2])
Vector([3.1, 4.2])
>>> Vector((3, 4, 5))
Vector([3.0, 4.0, 5.0])
>>> Vector(range(10))
Vector([0.0, 1.0, 2.0, 3.0, 4.0, ...])
Tests with two dimensions (same results as ``vector2d_v1.py``)::
>>> v1 = Vector([3, 4])
>>> x, y = v1
>>> x, y
(3.0, 4.0)
>>> v1
Vector([3.0, 4.0])
>>> v1_clone = eval(repr(v1))
>>> v1 == v1_clone
True
>>> print(v1)
(3.0, 4.0)
>>> octets = bytes(v1)
>>> octets
b'd\\x00\\x00\\x00\\x00\\x00\\x00\\x08@\\x00\\x00\\x00\\x00\\x00\\x00\\x10@'
>>> abs(v1)
5.0
>>> bool(v1), bool(Vector([0, 0]))
(True, False)
Test of ``.frombytes()`` class method:
>>> v1_clone = Vector.frombytes(bytes(v1))
>>> v1_clone
Vector([3.0, 4.0])
>>> v1 == v1_clone
True
Tests with three dimensions::
>>> v1 = Vector([3, 4, 5])
>>> x, y, z = v1
>>> x, y, z
(3.0, 4.0, 5.0)
>>> v1
Vector([3.0, 4.0, 5.0])
>>> v1_clone = eval(repr(v1))
>>> v1 == v1_clone
True
>>> print(v1)
(3.0, 4.0, 5.0)
>>> abs(v1) # doctest:+ELLIPSIS
7.071067811...
>>> bool(v1), bool(Vector([0, 0, 0]))
(True, False)
Tests with many dimensions::
>>> v7 = Vector(range(7))
>>> v7
Vector([0.0, 1.0, 2.0, 3.0, 4.0, ...])
>>> abs(v7) # doctest:+ELLIPSIS
9.53939201...
Test of ``.__bytes__`` and ``.frombytes()`` methods::
>>> v1 = Vector([3, 4, 5])
>>> v1_clone = Vector.frombytes(bytes(v1))
>>> v1_clone
Vector([3.0, 4.0, 5.0])
>>> v1 == v1_clone
True
Tests of sequence behavior::
>>> v1 = Vector([3, 4, 5])
>>> len(v1)
3
>>> v1[0], v1[len(v1)-1], v1[-1]
(3.0, 5.0, 5.0)
Test of slicing::
>>> v7 = Vector(range(7))
>>> v7[-1]
6.0
>>> v7[1:4]
Vector([1.0, 2.0, 3.0])
>>> v7[-1:]
Vector([6.0])
>>> v7[1,2]
Traceback (most recent call last):
...
TypeError: 'tuple' object cannot be interpreted as an integer
Tests of dynamic attribute access::
>>> v7 = Vector(range(10))
>>> v7.x
0.0
>>> v7.y, v7.z, v7.t
(1.0, 2.0, 3.0)
Dynamic attribute lookup failures::
>>> v7.k
Traceback (most recent call last):
...
AttributeError: 'Vector' object has no attribute 'k'
>>> v3 = Vector(range(3))
>>> v3.t
Traceback (most recent call last):
...
AttributeError: 'Vector' object has no attribute 't'
>>> v3.spam
Traceback (most recent call last):
...
AttributeError: 'Vector' object has no attribute 'spam'
Tests of hashing::
>>> v1 = Vector([3, 4])
>>> v2 = Vector([3.1, 4.2])
>>> v3 = Vector([3, 4, 5])
>>> v6 = Vector(range(6))
>>> hash(v1), hash(v3), hash(v6)
(7, 2, 1)
Most hash codes of non-integers vary from a 32-bit to 64-bit CPython build::
>>> import sys
>>> hash(v2) == (384307168202284039 if sys.maxsize > 2**32 else 357915986)
True
Tests of ``format()`` with Cartesian coordinates in 2D::
>>> v1 = Vector([3, 4])
>>> format(v1)
'(3.0, 4.0)'
>>> format(v1, '.2f')
'(3.00, 4.00)'
>>> format(v1, '.3e')
'(3.000e+00, 4.000e+00)'
Tests of ``format()`` with Cartesian coordinates in 3D and 7D::
>>> v3 = Vector([3, 4, 5])
>>> format(v3)
'(3.0, 4.0, 5.0)'
>>> format(Vector(range(7)))
'(0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0)'
Tests of ``format()`` with spherical coordinates in 2D, 3D and 4D::
>>> format(Vector([1, 1]), 'h') # doctest:+ELLIPSIS
'<1.414213..., 0.785398...>'
>>> format(Vector([1, 1]), '.3eh')
'<1.414e+00, 7.854e-01>'
>>> format(Vector([1, 1]), '0.5fh')
'<1.41421, 0.78540>'
>>> format(Vector([1, 1, 1]), 'h') # doctest:+ELLIPSIS
'<1.73205..., 0.95531..., 0.78539...>'
>>> format(Vector([2, 2, 2]), '.3eh')
'<3.464e+00, 9.553e-01, 7.854e-01>'
>>> format(Vector([0, 0, 0]), '0.5fh')
'<0.00000, 0.00000, 0.00000>'
>>> format(Vector([-1, -1, -1, -1]), 'h') # doctest:+ELLIPSIS
'<2.0, 2.09439..., 2.18627..., 3.92699...>'
>>> format(Vector([2, 2, 2, 2]), '.3eh')
'<4.000e+00, 1.047e+00, 9.553e-01, 7.854e-01>'
>>> format(Vector([0, 1, 0, 0]), '0.5fh')
'<1.00000, 1.57080, 0.00000, 0.00000>'
"""
from array import array
import reprlib
import math
import functools
import operator
import itertools # ①
class Vector:
typecode = 'd'
def __init__(self, components):
self._components = array(self.typecode, components)
def __iter__(self):
return iter(self._components)
def __repr__(self):
components = reprlib.repr(self._components)
components = components[components.find('['):-1]
return f'Vector({components})'
def __str__(self):
return str(tuple(self))
def __bytes__(self):
return (bytes([ord(self.typecode)]) +
bytes(self._components))
def __eq__(self, other):
return (len(self) == len(other) and
all(a == b for a, b in zip(self, other)))
def __hash__(self):
hashes = (hash(x) for x in self)
return functools.reduce(operator.xor, hashes, 0)
def __abs__(self):
return math.hypot(*self)
def __bool__(self):
return bool(abs(self))
def __len__(self):
return len(self._components)
def __getitem__(self, key):
if isinstance(key, slice):
cls = type(self)
return cls(self._components[key])
index = operator.index(key)
return self._components[index]
__match_args__ = ('x', 'y', 'z', 't')
def __getattr__(self, name):
cls = type(self)
try:
pos = cls.__match_args__.index(name)
except ValueError:
pos = -1
if 0 <= pos < len(self._components):
return self._components[pos]
msg = f'{cls.__name__!r} object has no attribute {name!r}'
raise AttributeError(msg)
def angle(self, n): # ②
r = math.hypot(*self[n:])
a = math.atan2(r, self[n-1])
if (n == len(self) - 1) and (self[-1] < 0):
return math.pi * 2 - a
else:
return a
def angles(self): # ③
return (self.angle(n) for n in range(1, len(self)))
def __format__(self, fmt_spec=''):
if fmt_spec.endswith('h'): # hyperspherical coordinates
fmt_spec = fmt_spec[:-1]
coords = itertools.chain([abs(self)],
self.angles()) # ④
outer_fmt = '<{}>' # ⑤
else:
coords = self
outer_fmt = '({})' # ⑥
components = (format(c, fmt_spec) for c in coords) # ⑦
return outer_fmt.format(', '.join(components)) # ⑧
@classmethod
def frombytes(cls, octets):
typecode = chr(octets[0])
memv = memoryview(octets[1:]).cast(typecode)
return cls(memv)
①
导入itertools以在__format__中使用chain函数。
②
使用从n-sphere article调整的公式计算一个角坐标。
③
创建一个生成器表达式,按需计算所有角坐标。
④
使用itertools.chain生成genexp,以便无缝迭代幅度和角坐标。
⑤
配置带尖括号的球坐标显示。
⑥
配置带括号的笛卡尔坐标显示。
⑦
创建一个生成器表达式,以便按需格式化每个坐标项。
⑧
将格式化的组件用逗号分隔放在方括号或括号内。
注意
在__format__、angle和angles中大量使用生成器表达式,但我们的重点在于提供__format__以使Vector达到与Vector2d相同的实现水平。当我们在第十七章中讨论生成器时,我们将使用Vector中的一些代码作为示例,然后详细解释生成器技巧。
这就结束了本章的任务。Vector类将在第十六章中通过中缀运算符进行增强,但我们在这里的目标是探索编写特殊方法的技术,这些方法在各种集合类中都很有用。
章节总结
本章中的Vector示例旨在与Vector2d兼容,除了使用接受单个可迭代参数的不同构造函数签名外,就像内置序列类型所做的那样。Vector通过仅实现__getitem__和__len__就表现得像一个序列,这促使我们讨论协议,即鸭子类型语言中使用的非正式接口。
然后我们看了一下my_seq[a:b:c]语法在幕后是如何工作的,通过创建一个slice(a, b, c)对象并将其传递给__getitem__。有了这个知识,我们使Vector正确响应切片操作,通过返回新的Vector实例,就像预期的 Python 序列一样。
下一步是通过诸如my_vec.x这样的表示法为前几个Vector组件提供只读访问。我们通过实现__getattr__来实现这一点。这样做打开了通过编写my_vec.x = 7来为这些特殊组件赋值的可能性,揭示了一个潜在的错误。我们通过实现__setattr__来修复这个问题,以禁止向单个字母属性赋值。通常,当你编写__getattr__时,你需要添加__setattr__,以避免不一致的行为。
实现__hash__函数为使用functools.reduce提供了完美的背景,因为我们需要对所有Vector组件的哈希值连续应用异或运算符^,以产生整个Vector的聚合哈希码。在__hash__中应用reduce后,我们使用all内置的 reduce 函数来创建一个更高效的__eq__方法。
对Vector的最后一个增强是通过支持球坐标作为默认笛卡尔坐标的替代来重新实现Vector2d中的__format__方法。我们使用了相当多的数学和几个生成器来编写__format__及其辅助函数,但这些都是实现细节——我们将在第十七章中回到生成器。最后一节的目标是支持自定义格式,从而实现Vector能够做到与Vector2d一样的一切,甚至更多。
正如我们在第十一章中所做的那样,这里我们经常研究标准 Python 对象的行为,以模拟它们并为Vector提供“Pythonic”的外观和感觉。
在第十六章中,我们将在Vector上实现几个中缀运算符。数学将比这里的angle()方法简单得多,但探索 Python 中中缀运算符的工作方式是面向对象设计的一课。但在我们开始运算符重载之前,我们将暂时离开单个类的工作,转而关注组织多个类的接口和继承,这是第十三章和第十四章的主题。
进一步阅读
在Vector示例中涵盖的大多数特殊方法也出现在第十一章的Vector2d示例中,因此“进一步阅读”中的参考资料在这里都是相关的。
强大的reduce高阶函数也被称为 fold、accumulate、aggregate、compress 和 inject。更多信息,请参阅维基百科的“Fold (higher-order function)”文章,该文章重点介绍了该高阶函数在递归数据结构的函数式编程中的应用。该文章还包括一张列出了几十种编程语言中类似 fold 函数的表格。
“Python 2.5 中的新功能”简要解释了__index__,旨在支持__getitem__方法,正如我们在“一个支持切片的 getitem”中看到的。PEP 357—允许任何对象用于切片详细介绍了从 C 扩展的实现者的角度看它的必要性——Travis Oliphant,NumPy 的主要创造者。Oliphant 对 Python 的许多贡献使其成为一种领先的科学计算语言,从而使其在机器学习应用方面处于领先地位。
¹ iter()函数在第十七章中有介绍,还有__iter__方法。
² 属性查找比这更复杂;我们将在第五部分中看到详细内容。现在,这个简化的解释就足够了。
³ 尽管__match_args__存在于支持 Python 3.10 中的模式匹配,但在之前的 Python 版本中设置这个属性是无害的。在本书的第一版中,我将其命名为shortcut_names。新名称具有双重作用:支持case子句中的位置模式,并保存__getattr__和__setattr__中特殊逻辑支持的动态属性的名称。
⁴ sum、any和all涵盖了reduce的最常见用法。请参阅“map、filter 和 reduce 的现代替代品”中的讨论。
⁵ 我们将认真考虑Vector([1, 2]) == (1, 2)这个问题,在“运算符重载 101”中。
⁶ Wolfram Mathworld 网站有一篇关于超球体的文章;在维基百科上,“超球体”重定向到“n-球体”条目。
⁷ 我为这个演示调整了代码:在 2003 年,reduce是内置的,但在 Python 3 中我们需要导入它;此外,我用my_list和sub替换了x和y的名称,用于子列表。
第十三章:接口、协议和 ABCs
针对接口编程,而不是实现。
Gamma、Helm、Johnson、Vlissides,《面向对象设计的第一原则》¹
面向对象编程关乎接口。在 Python 中理解类型的最佳方法是了解它提供的方法——即其接口——如 “类型由支持的操作定义”(第八章)中所讨论的。
根据编程语言的不同,我们有一种或多种定义和使用接口的方式。自 Python 3.8 起,我们有四种方式。它们在 类型映射(图 13-1)中有所描述。我们可以总结如下:
鸭子类型
Python 从一开始就采用的类型化方法。我们从 第一章 开始学习鸭子类型。
鹅式类型
自 Python 2.6 起由抽象基类(ABCs)支持的方法,依赖于对象与 ABCs 的运行时检查。鹅式类型 是本章的一个重要主题。
静态类型
类似 C 和 Java 这样的静态类型语言的传统方法;自 Python 3.5 起由 typing 模块支持,并由符合 PEP 484—类型提示 的外部类型检查器强制执行。这不是本章的主题。第八章的大部分内容以及即将到来的 第十五章 关于静态类型。
静态鸭子类型
由 Go 语言推广的一种方法;由 typing.Protocol 的子类支持——Python 3.8 中新增——也由外部类型检查器强制执行。我们首次在 “静态协议”(第八章)中看到这一点。
类型映射
图 13-1 中描述的四种类型化方法是互补的:它们各有优缺点。不应该否定其中任何一种。

图 13-1。上半部分描述了仅使用 Python 解释器进行运行时类型检查的方法;下半部分需要外部静态类型检查器,如 MyPy 或 PyCharm 这样的 IDE。左侧象限涵盖基于对象结构的类型化——即对象提供的方法,而不考虑其类或超类的名称;右侧象限依赖于对象具有明确定义的类型:对象的类名或其超类的名称。
这四种方法都依赖于接口来工作,但静态类型可以通过仅使用具体类型而不是接口抽象,如协议和抽象基类,来实现——这样做效果不佳。本章讨论了鸭子类型、鹅式类型和静态鸭子类型——围绕接口展开的类型学科。
本章分为四个主要部分,涵盖了类型映射中四个象限中的三个:图 13-1。
-
“两种类型协议” 比较了两种结构类型与协议的形式——即类型映射的左侧。
-
“编程鸭子” 深入探讨了 Python 的常规鸭子类型,包括如何使其更安全,同时保持其主要优势:灵活性。
-
“鹅式类型” 解释了使用 ABCs 进行更严格的运行时类型检查。这是最长的部分,不是因为它更重要,而是因为书中其他地方有更多关于鸭子类型、静态鸭子类型和静态类型的部分。
-
“静态协议” 涵盖了
typing.Protocol子类的用法、实现和设计——对于静态和运行时类型检查很有用。
本章的新内容
本章经过大幅编辑,比第一版《流畅的 Python》中对应的第十一章长约 24%。虽然有些部分和许多段落是相同的,但也有很多新内容。以下是亮点:
-
本章的介绍和类型映射(图 13-1)是新内容。这是本章和所有涉及 Python ≥ 3.8 中类型的其他章节中大部分新内容的关键。
-
“两种类型的协议”解释了动态协议和静态协议之间的相似之处和不同之处。
-
“防御性编程和‘快速失败’” 主要复制了第一版的内容,但进行了更新,现在有一个部分标题以突出其重要性。
-
“静态协议”是全新的。它在“静态协议”(第八章)的初始介绍基础上进行了扩展。
-
在图 13-2、13-3 和 13-4 中更新了
collections.abc的类图,包括 Python 3.6 中的CollectionABC。
《流畅的 Python》第一版中有一节鼓励使用numbers ABCs 进行鹅式类型化。在“数字 ABC 和数值协议”中,我解释了为什么如果您计划同时使用静态类型检查器和鹅式类型检查器的运行时检查,应该使用typing模块中的数值静态协议。
两种类型的协议
根据上下文,计算机科学中的“协议”一词有不同的含义。诸如 HTTP 之类的网络协议指定了客户端可以发送给服务器的命令,例如GET、PUT和HEAD。我们在“协议和鸭子类型”中看到,对象协议指定了对象必须提供的方法以履行某种角色。第一章中的FrenchDeck示例演示了一个对象协议,即序列协议:允许 Python 对象表现为序列的方法。
实现完整的协议可能需要多个方法,但通常只实现部分也是可以的。考虑一下示例 13-1 中的Vowels类。
示例 13-1。使用__getitem__部分实现序列协议
>>> class Vowels:
... def __getitem__(self, i):
... return 'AEIOU'[i]
...
>>> v = Vowels()
>>> v[0]
'A'
>>> v[-1]
'U'
>>> for c in v: print(c)
...
A
E
I
O
U
>>> 'E' in v
True
>>> 'Z' in v
False
实现__getitem__足以允许按索引检索项目,并支持迭代和in运算符。__getitem__特殊方法实际上是序列协议的关键。查看Python/C API 参考手册中的这篇文章,“序列协议”部分。
int PySequence_Check(PyObject *o)
如果对象提供序列协议,则返回1,否则返回0。请注意,对于具有__getitem__()方法的 Python 类,除非它们是dict子类[...],否则它将返回1。
我们期望序列还支持len(),通过实现__len__来实现。Vowels没有__len__方法,但在某些情况下仍然表现为序列。这对我们的目的可能已经足够了。这就是为什么我喜欢说协议是一种“非正式接口”。这也是 Smalltalk 中对协议的理解方式,这是第一个使用该术语的面向对象编程环境。
除了关于网络编程的页面外,Python 文档中“协议”一词的大多数用法指的是这些非正式接口。
现在,随着 Python 3.8 中采纳了PEP 544—协议:结构子类型(静态鸭子类型),在 Python 中,“协议”一词有了另一层含义——与之密切相关,但又不同。正如我们在“静态协议”(第八章)中看到的,PEP 544 允许我们创建typing.Protocol的子类来定义一个或多个类必须实现(或继承)以满足静态类型检查器的方法。
当我需要具体说明时,我会采用这些术语:
动态协议
Python 一直拥有的非正式协议。动态协议是隐式的,按照约定定义,并在文档中描述。Python 最重要的动态协议由解释器本身支持,并在《Python 语言参考》的“数据模型”章节中有详细说明。
静态协议
由 PEP 544—协议:结构子类型(静态鸭子类型) 定义的协议,自 Python 3.8 起。静态协议有明确的定义:typing.Protocol 的子类。
它们之间有两个关键区别:
-
一个对象可能只实现动态协议的一部分仍然是有用的;但为了满足静态协议,对象必须提供协议类中声明的每个方法,即使你的程序并不需要它们。
-
静态协议可以被静态类型检查器验证,但动态协议不能。
这两种协议共享一个重要特征,即类永远不需要声明支持某个协议,即通过继承来支持。
除了静态协议,Python 还提供了另一种在代码中定义显式接口的方式:抽象基类(ABC)。
本章的其余部分涵盖了动态和静态协议,以及 ABC。
编程鸭
让我们从 Python 中两个最重要的动态协议开始讨论:序列和可迭代协议。解释器会尽最大努力处理提供了即使是最简单实现的对象,下一节将解释这一点。
Python 探究序列
Python 数据模型的哲学是尽可能与基本的动态协议合作。在处理序列时,Python 会尽最大努力与即使是最简单的实现一起工作。
图 13-2 显示了 Sequence 接口如何被正式化为一个 ABC。Python 解释器和内置序列如 list、str 等根本不依赖于该 ABC。我只是用它来描述一个完整的 Sequence 预期支持的内容。

图 13-2. Sequence ABC 和 collections.abc 中相关抽象类的 UML 类图。继承箭头从子类指向其超类。斜体字体的名称是抽象方法。在 Python 3.6 之前,没有 Collection ABC——Sequence 是 Container、Iterable 和 Sized 的直接子类。
提示
collections.abc 模块中的大多数 ABC 存在的目的是为了正式化由内置对象实现并被解释器隐式支持的接口——这两者都早于 ABC 本身。这些 ABC 对于新类是有用的起点,并支持运行时的显式类型检查(又称为 鹅式类型化)以及静态类型检查器的类型提示。
研究 图 13-2,我们看到 Sequence 的正确子类必须实现 __getitem__ 和 __len__(来自 Sized)。Sequence 中的所有其他方法都是具体的,因此子类可以继承它们的实现——或提供更好的实现。
现在回想一下 示例 13-1 中的 Vowels 类。它没有继承自 abc.Sequence,只实现了 __getitem__。
没有 __iter__ 方法,但 Vowels 实例是可迭代的,因为——作为后备——如果 Python 发现 __getitem__ 方法,它会尝试通过调用从 0 开始的整数索引的方法来迭代对象。因为 Python 足够聪明以迭代 Vowels 实例,所以即使缺少 __contains__ 方法,它也可以使 in 运算符正常工作:它会进行顺序扫描以检查项目是否存在。
总结一下,鉴于类似序列的数据结构的重要性,Python 通过在 __iter__ 和 __contains__ 不可用时调用 __getitem__ 来使迭代和 in 运算符正常工作。
第一章中的原始FrenchDeck也没有继承abc.Sequence,但它实现了序列协议的两种方法:__getitem__和__len__。参见示例 13-2。
示例 13-2。一叠卡片的序列(与示例 1-1 相同)
import collections
Card = collections.namedtuple('Card', ['rank', 'suit'])
class FrenchDeck:
ranks = [str(n) for n in range(2, 11)] + list('JQKA')
suits = 'spades diamonds clubs hearts'.split()
def __init__(self):
self._cards = [Card(rank, suit) for suit in self.suits
for rank in self.ranks]
def __len__(self):
return len(self._cards)
def __getitem__(self, position):
return self._cards[position]
第一章中的几个示例之所以有效,是因为 Python 对任何类似序列的东西都给予了特殊处理。Python 中的可迭代协议代表了鸭子类型的极端形式:解释器尝试两种不同的方法来迭代对象。
为了明确起见,我在本节中描述的行为是在解释器本身中实现的,主要是用 C 语言编写的。它们不依赖于Sequence ABC 中的方法。例如,Sequence类中的具体方法__iter__和__contains__模拟了 Python 解释器的内置行为。如果你感兴趣,请查看Lib/_collections_abc.py中这些方法的源代码。
现在让我们研究另一个例子,强调协议的动态性,以及为什么静态类型检查器无法处理它们。
Monkey Patching:在运行时实现协议
Monkey patching 是在运行时动态更改模块、类或函数,以添加功能或修复错误。例如,gevent 网络库对 Python 的标准库的部分进行了 monkey patching,以允许轻量级并发而无需线程或async/await。²
来自示例 13-2 的FrenchDeck类缺少一个重要特性:它无法被洗牌。几年前,当我第一次编写FrenchDeck示例时,我确实实现了一个shuffle方法。后来我有了一个 Pythonic 的想法:如果FrenchDeck像一个序列一样工作,那么它就不需要自己的shuffle方法,因为已经有了random.shuffle,文档中描述为“原地洗牌序列x”。
标准的random.shuffle函数的使用方式如下:
>>> from random import shuffle
>>> l = list(range(10))
>>> shuffle(l)
>>> l
[5, 2, 9, 7, 8, 3, 1, 4, 0, 6]
提示
当遵循已建立的协议时,你提高了利用现有标准库和第三方代码的机会,这要归功于鸭子类型。
然而,如果我们尝试对FrenchDeck实例进行洗牌,就会出现异常,就像示例 13-3 中一样。
示例 13-3。random.shuffle无法处理FrenchDeck
>>> from random import shuffle
>>> from frenchdeck import FrenchDeck
>>> deck = FrenchDeck()
>>> shuffle(deck)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File ".../random.py", line 265, in shuffle
x[i], x[j] = x[j], x[i]
TypeError: 'FrenchDeck' object does not support item assignment
错误消息很明确:'FrenchDeck'对象不支持项目赋值。问题在于shuffle是原地操作,通过在集合内部交换项目,而FrenchDeck只实现了不可变序列协议。可变序列还必须提供__setitem__方法。
因为 Python 是动态的,我们可以在运行时修复这个问题,甚至在交互式控制台中也可以。示例 13-4 展示了如何做到这一点。
示例 13-4。Monkey patching FrenchDeck使其可变并与random.shuffle兼容(继续自示例 13-3)
>>> def set_card(deck, position, card): # ①
... deck._cards[position] = card
...
>>> FrenchDeck.__setitem__ = set_card # ②
>>> shuffle(deck) # ③
>>> deck[:5]
[Card(rank='3', suit='hearts'), Card(rank='4', suit='diamonds'), Card(rank='4', suit='clubs'), Card(rank='7', suit='hearts'), Card(rank='9', suit='spades')]
①
创建一个以deck, position, 和card为参数的函数。
②
将该函数分配给FrenchDeck类中名为__setitem__的属性。
③
现在deck可以被洗牌了,因为我添加了可变序列协议的必要方法。
__setitem__特殊方法的签名在Python 语言参考中的“3.3.6. 模拟容器类型”中定义。这里我将参数命名为deck, position, card,而不是语言参考中的self, key, value,以显示每个 Python 方法都是作为普通函数开始的,将第一个参数命名为self只是一种约定。在控制台会话中这样做没问题,但在 Python 源文件中最好使用文档中记录的self, key, 和value。
诀窍在于set_card知道deck对象有一个名为_cards的属性,而_cards必须是一个可变序列。然后,set_card函数被附加到FrenchDeck类作为__setitem__特殊方法。这是猴子补丁的一个例子:在运行时更改类或模块,而不触及源代码。猴子补丁很强大,但实际打补丁的代码与要打补丁的程序非常紧密耦合,通常处理私有和未记录的属性。
除了是猴子补丁的一个例子,示例 13-4 突显了动态鸭子类型协议的动态性:random.shuffle不关心参数的类,它只需要对象实现可变序列协议的方法。甚至不用在意对象是否“出生”时就具有必要的方法,或者后来某种方式获得了这些方法。
鸭子类型不需要非常不安全或难以调试。下一节将展示一些有用的代码模式,以检测动态协议,而不需要显式检查。
防御性编程和“快速失败”
防御性编程就像防御性驾驶:一套增强安全性的实践,即使面对粗心的程序员或驾驶员。
许多错误只能在运行时捕获——即使在主流的静态类型语言中也是如此。³在动态类型语言中,“快速失败”是更安全、更易于维护的程序的极好建议。快速失败意味着尽快引发运行时错误,例如,在函数体的开头立即拒绝无效参数。
这里有一个例子:当你编写接受要在内部处理的项目序列的代码时,不要通过类型检查强制要求一个list参数。相反,接受参数并立即从中构建一个list。这种代码模式的一个例子是本章后面的示例 13-10 中的__init__方法:
def __init__(self, iterable):
self._balls = list(iterable)
这样可以使你的代码更灵活,因为list()构造函数处理任何适合内存的可迭代对象。如果参数不可迭代,调用将立即失败,并显示一个非常清晰的TypeError异常,就在对象初始化时。如果想更明确,可以用try/except包装list()调用以自定义错误消息——但我只会在外部 API 上使用这些额外的代码,因为问题对于代码库的维护者来说很容易看到。无论哪种方式,有问题的调用将出现在回溯的最后,使得修复问题变得直截了当。如果在类构造函数中没有捕获无效参数,程序将在稍后的某个时刻崩溃,当类的其他方法需要操作self._balls时,而它不是一个list。那么根本原因将更难找到。
当数据不应该被复制时,例如因为数据太大或者函数设计需要在原地更改数据以使调用者受益时,调用list()会很糟糕。在这种情况下,像isinstance(x, abc.MutableSequence)这样的运行时检查将是一个好方法。
如果你担心得到一个无限生成器——这不是一个常见问题——你可以先调用len()来检查参数。这将拒绝迭代器,同时安全地处理元组、数组和其他现有或将来完全实现Sequence接口的类。调用len()通常非常便宜,而无效的参数将立即引发错误。
另一方面,如果任何可迭代对象都可以接受,那么尽快调用iter(x)以获得一个迭代器,正如我们将在“为什么序列可迭代:iter 函数”中看到的。同样,如果x不可迭代,这将快速失败,并显示一个易于调试的异常。
在我刚刚描述的情况下,类型提示可以更早地捕捉一些问题,但并非所有问题。请记住,类型Any与其他任何类型都是一致的。类型推断可能导致变量被标记为Any类型。当发生这种情况时,类型检查器就会一头雾水。此外,类型提示在运行时不会被强制执行。快速失败是最后的防线。
利用鸭子类型的防御性代码也可以包含处理不同类型的逻辑,而无需使用isinstance()或hasattr()测试。
一个例子是我们如何模拟collections.namedtuple中的field_names参数处理:field_names接受一个由空格或逗号分隔的标识符组成的单个字符串,或者一组标识符。示例 13-5 展示了我如何使用鸭子类型来实现它。
示例 13-5. 鸭子类型处理字符串或字符串可迭代对象
try: # ①
field_names = field_names.replace(',', ' ').split() # ②
except AttributeError: # ③
pass # ④
field_names = tuple(field_names) # ⑤
if not all(s.isidentifier() for s in field_names): # ⑥
raise ValueError('field_names must all be valid identifiers')
①
假设它是一个字符串(EAFP = 宁愿请求原谅,也不要事先获准)。
②
将逗号转换为空格并将结果拆分为名称列表。
③
抱歉,field_names不像一个str那样嘎嘎叫:它没有.replace,或者返回我们无法.split的东西。
④
如果引发了AttributeError,那么field_names不是一个str,我们假设它已经是一个名称的可迭代对象。
⑤
为了确保它是可迭代的并保留我们自己的副本,将我们拥有的内容创建为一个元组。tuple比list更紧凑,还可以防止我的代码误改名称。
⑥
使用str.isidentifier来确保每个名称都是有效的。
示例 13-5 展示了一种情况,鸭子类型比静态类型提示更具表现力。没有办法拼写一个类型提示,说“field_names必须是由空格或逗号分隔的标识符字符串”。这是namedtuple在 typeshed 上的签名的相关部分(请查看stdlib/3/collections/init.pyi的完整源代码):
def namedtuple(
typename: str,
field_names: Union[str, Iterable[str]],
*,
# rest of signature omitted
如您所见,field_names被注释为Union[str, Iterable[str]],就目前而言是可以的,但不足以捕捉所有可能的问题。
在审查动态协议后,我们转向更明确的运行时类型检查形式:鹅式类型检查。
鹅式类型检查
抽象类代表一个接口。
C++的创始人 Bjarne Stroustrup⁴
Python 没有interface关键字。我们使用抽象基类(ABCs)来定义接口,以便在运行时进行显式类型检查,同时也受到静态类型检查器的支持。
Python 术语表中关于抽象基类的条目对它们为鸭子类型语言带来的价值有很好的解释:
抽象基类通过提供一种定义接口的方式来补充鸭子类型,当其他技术(如
hasattr())显得笨拙或微妙错误时(例如,使用魔术方法)。ABCs 引入虚拟子类,这些子类不继承自一个类,但仍然被isinstance()和issubclass()所识别;请参阅abc模块文档。⁵
鹅式类型检查是一种利用 ABCs 的运行时类型检查方法。我将让 Alex Martelli 在“水禽和 ABCs”中解释。
注
我非常感谢我的朋友 Alex Martelli 和 Anna Ravenscroft。我在 2013 年的 OSCON 上向他们展示了Fluent Python的第一个大纲,他们鼓励我将其提交给 O’Reilly 出版。两人后来进行了彻底的技术审查。Alex 已经是本书中被引用最多的人,然后他提出要写这篇文章。请开始,Alex!
总结一下,鹅打字包括:
-
从 ABC 继承以明确表明你正在实现先前定义的接口。
-
运行时使用 ABC 而不是具体类作为
isinstance和issubclass的第二个参数进行类型检查。
Alex 指出,从 ABC 继承不仅仅是实现所需的方法:开发人员的意图也是明确声明的。这种意图也可以通过注册虚拟子类来明确表示。
注意
使用register的详细信息在“ABC 的虚拟子类”中有介绍,本章后面会详细介绍。现在,这里是一个简短的示例:给定FrenchDeck类,如果我希望它通过类似issubclass(FrenchDeck, Sequence)的检查,我可以通过以下代码将其作为Sequence ABC 的虚拟子类:
from collections.abc import Sequence
Sequence.register(FrenchDeck)
如果你检查 ABC 而不是具体类,那么使用isinstance和issubclass会更加可接受。如果与具体类一起使用,类型检查会限制多态性——这是面向对象编程的一个重要特征。但是对于 ABCs,这些测试更加灵活。毕竟,如果一个组件没有通过子类化实现 ABC,但确实实现了所需的方法,那么它总是可以在事后注册,以便通过这些显式类型检查。
然而,即使使用 ABCs,你也应该注意,过度使用isinstance检查可能是代码异味的表现——这是 OO 设计不佳的症状。
通常情况下,使用isinstance检查的if/elif/elif链执行不同操作取决于对象类型通常是不可以的:你应该使用多态性来实现这一点——即,设计你的类使解释器分派调用到正确的方法,而不是在if/elif/elif块中硬编码分派逻辑。
另一方面,如果必须强制执行 API 契约,则对 ABC 执行isinstance检查是可以的:“伙计,如果你想调用我,你必须实现这个”,正如技术审查员 Lennart Regebro 所说。这在具有插件架构的系统中特别有用。在框架之外,鸭子类型通常比类型检查更简单、更灵活。
最后,在他的文章中,Alex 多次强调了在创建 ABCs 时需要克制的必要性。过度使用 ABCs 会在一门因其实用性和实用性而流行的语言中引入仪式感。在流畅的 Python审查过程中,Alex 在一封电子邮件中写道:
ABCs 旨在封装由框架引入的非常一般的概念、抽象概念——诸如“一个序列”和“一个确切的数字”。[读者]很可能不需要编写任何新的 ABCs,只需正确使用现有的 ABCs,就可以获得 99.9%的好处,而不会严重风险设计错误。
现在让我们看看鹅打字的实践。
从 ABC 继承
遵循 Martelli 的建议,在大胆发明自己之前,我们将利用现有的 ABC,collections.MutableSequence。在示例 13-6 中,FrenchDeck2明确声明为collections.MutableSequence的子类。
示例 13-6. frenchdeck2.py:FrenchDeck2,collections.MutableSequence的子类
from collections import namedtuple, abc
Card = namedtuple('Card', ['rank', 'suit'])
class FrenchDeck2(abc.MutableSequence):
ranks = [str(n) for n in range(2, 11)] + list('JQKA')
suits = 'spades diamonds clubs hearts'.split()
def __init__(self):
self._cards = [Card(rank, suit) for suit in self.suits
for rank in self.ranks]
def __len__(self):
return len(self._cards)
def __getitem__(self, position):
return self._cards[position]
def __setitem__(self, position, value): # ①
self._cards[position] = value
def __delitem__(self, position): # ②
del self._cards[position]
def insert(self, position, value): # ③
self._cards.insert(position, value)
①
__setitem__是我们启用洗牌所需的全部…
②
…但是从MutableSequence继承会强制我们实现__delitem__,该 ABC 的一个抽象方法。
③
我们还需要实现insert,MutableSequence的第三个抽象方法。
Python 在导入时不会检查抽象方法的实现(当加载和编译 frenchdeck2.py 模块时),而是在运行时当我们尝试实例化 FrenchDeck2 时才会检查。然后,如果我们未实现任何抽象方法,我们将收到一个 TypeError 异常,其中包含类似于 "Can't instantiate`` abstract class FrenchDeck2 with abstract methods __delitem__, insert" 的消息。这就是为什么我们必须实现 __delitem__ 和 insert,即使我们的 FrenchDeck2 示例不需要这些行为:因为 MutableSequence ABC 要求它们。
如 图 13-3 所示,Sequence 和 MutableSequence ABCs 中并非所有方法都是抽象的。

图 13-3. MutableSequence ABC 及其来自 collections.abc 的超类的 UML 类图(继承箭头从子类指向祖先类;斜体名称是抽象类和抽象方法)。
要将 FrenchDeck2 写为 MutableSequence 的子类,我必须付出实现 __delitem__ 和 insert 的代价,而我的示例并不需要这些。作为回报,FrenchDeck2 从 Sequence 继承了五个具体方法:__contains__, __iter__, __reversed__, index, 和 count。从 MutableSequence 中,它还获得了另外六个方法:append, reverse, extend, pop, remove, 和 __iadd__—它支持用于原地连接的 += 运算符。
每个 collections.abc ABC 中的具体方法都是根据类的公共接口实现的,因此它们可以在不了解实例内部结构的情况下工作。
提示
作为具体子类的编码者,您可能能够用更高效的实现覆盖从 ABCs 继承的方法。例如,__contains__ 通过对序列进行顺序扫描来工作,但如果您的具体序列保持其项目排序,您可以编写一个更快的 __contains__,它使用标准库中的 bisect 函数进行二分搜索。请查看 fluentpython.com 上的 “使用 Bisect 管理有序序列” 了解更多信息。
要很好地使用 ABCs,您需要了解可用的内容。接下来我们将回顾 collections 中的 ABCs。
标准库中的 ABCs
自 Python 2.6 起,标准库提供了几个 ABCs。大多数在 collections.abc 模块中定义,但也有其他的。例如,您可以在 io 和 numbers 包中找到 ABCs。但最常用的在 collections.abc 中。
提示
标准库中有两个名为 abc 的模块。这里我们谈论的是 collections.abc。为了减少加载时间,自 Python 3.4 起,该模块是在 collections 包之外实现的—在 Lib/_collections_abc.py—因此它是单独从 collections 导入的。另一个 abc 模块只是 abc(即 Lib/abc.py),其中定义了 abc.ABC 类。每个 ABC 都依赖于 abc 模块,但我们不需要自己导入它,除非要创建全新的 ABC。
图 13-4 是在 collections.abc 中定义的 17 个 ABCs 的摘要 UML 类图(不包括属性名称)。collections.abc 的文档中有一个很好的表格总结了这些 ABCs,它们之间的关系以及它们的抽象和具体方法(称为“mixin 方法”)。在 图 13-4 中有大量的多重继承。我们将在 第十四章 中专门讨论多重继承,但现在只需说当涉及到 ABCs 时,通常不是问题。⁷

图 13-4. collections.abc 中 ABCs 的 UML 类图。
让我们回顾一下 图 13-4 中的聚类:
Iterable, Container, Sized
每个集合应该继承这些 ABC 或实现兼容的协议。Iterable 支持 __iter__ 迭代,Container 支持 __contains__ 中的 in 运算符,Sized 支持 __len__ 中的 len()。
Collection
这个 ABC 没有自己的方法,但在 Python 3.6 中添加了它,以便更容易从 Iterable、Container 和 Sized 继承。
Sequence、Mapping、Set
这些是主要的不可变集合类型,每种类型都有一个可变的子类。MutableSequence 的详细图表在 图 13-3 中;对于 MutableMapping 和 MutableSet,请参见 第三章 中的图 3-1 和 3-2。
MappingView
在 Python 3 中,从映射方法 .items()、.keys() 和 .values() 返回的对象分别实现了 ItemsView、KeysView 和 ValuesView 中定义的接口。前两者还实现了 Set 的丰富接口,其中包含我们在 “集合操作” 中看到的所有运算符。
Iterator
请注意,迭代器子类 Iterable。我们在 第十七章 中进一步讨论这一点。
Callable、Hashable
这些不是集合,但 collections.abc 是第一个在标准库中定义 ABC 的包,这两个被认为是足够重要以被包含在内。它们支持对必须是可调用或可哈希的对象进行类型检查。
对于可调用检测,内置函数 callable(obj) 比 insinstance(obj, Callable) 更方便。
如果 insinstance(obj, Hashable) 返回 False,则可以确定 obj 不可哈希。但如果返回值为 True,可能是一个误报。下一个框解释了这一点。
在查看一些现有的 ABC 后,让我们通过从头开始实现一个 ABC 并将其投入使用来练习鹅子打字。这里的目标不是鼓励每个人开始左右创建 ABC,而是学习如何阅读标准库和其他包中找到的 ABC 的源代码。
定义和使用 ABC
这个警告出现在第一版 Fluent Python 的“接口”章节中:
ABC,就像描述符和元类一样,是构建框架的工具。因此,只有少数 Python 开发人员可以创建 ABC,而不会对其他程序员施加不合理的限制和不必要的工作。
现在 ABC 在类型提示中有更多潜在用途,以支持静态类型。如 “抽象基类” 中所讨论的,使用 ABC 而不是具体类型在函数参数类型提示中给调用者更多的灵活性。
为了证明创建一个 ABC 的合理性,我们需要为在框架中使用它作为扩展点提供一个上下文。因此,这是我们的背景:想象一下你需要在网站或移动应用程序上以随机顺序显示广告,但在显示完整广告库之前不重复显示广告。现在让我们假设我们正在构建一个名为 ADAM 的广告管理框架。其要求之一是支持用户提供的非重复随机选择类。⁸ 为了让 ADAM 用户清楚地知道“非重复随机选择”组件的期望,我们将定义一个 ABC。
在关于数据结构的文献中,“栈”和“队列”描述了抽象接口,以物理对象的实际排列为基础。我将效仿并使用一个现实世界的隐喻来命名我们的 ABC:宾果笼和彩票吹风机是设计用来从有限集合中随机挑选项目,直到集合耗尽而不重复的机器。
ABC 将被命名为 Tombola,以宾果的意大利名称和混合数字的翻转容器命名。
Tombola ABC 有四个方法。两个抽象方法是:
.load(…)
将项目放入容器中。
.pick()
从容器中随机移除一个项目,并返回它。
具体方法是:
.loaded()
如果容器中至少有一个项目,则返回True。
.inspect()
返回一个从容器中当前项目构建的tuple,而不更改其内容(内部排序不保留)。
图 13-5 展示了Tombola ABC 和三个具体实现。

图 13-5. ABC 和三个子类的 UML 图。Tombola ABC 的名称和其抽象方法以斜体书写,符合 UML 约定。虚线箭头用于接口实现——这里我用它来显示TomboList不仅实现了Tombola接口,而且还注册为Tombola的虚拟子类—正如我们将在本章后面看到的。⁹
示例 13-7 展示了Tombola ABC 的定义。
示例 13-7. tombola.py:Tombola是一个具有两个抽象方法和两个具体方法的 ABC
import abc
class Tombola(abc.ABC): # ①
@abc.abstractmethod
def load(self, iterable): # ②
"""Add items from an iterable."""
@abc.abstractmethod
def pick(self): # ③
"""Remove item at random, returning it.
This method should raise `LookupError` when the instance is empty.
"""
def loaded(self): # ④
"""Return `True` if there's at least 1 item, `False` otherwise."""
return bool(self.inspect()) # ⑤
def inspect(self):
"""Return a sorted tuple with the items currently inside."""
items = []
while True: # ⑥
try:
items.append(self.pick())
except LookupError:
break
self.load(items) # ⑦
return tuple(items)
①
要定义一个 ABC,需要继承abc.ABC。
②
抽象方法使用@abstractmethod装饰器标记,通常其主体除了文档字符串外是空的。¹⁰
③
文档字符串指示实现者在没有项目可挑选时引发LookupError。
④
一个 ABC 可以包含具体方法。
⑤
ABC 中的具体方法必须仅依赖于 ABC 定义的接口(即 ABC 的其他具体或抽象方法或属性)。
⑥
我们无法知道具体子类将如何存储项目,但我们可以通过连续调用.pick()来构建inspect结果来清空Tombola…
⑦
…然后使用.load(…)将所有东西放回去。
提示
抽象方法实际上可以有一个实现。即使有,子类仍将被强制重写它,但他们可以使用super()调用抽象方法,为其添加功能而不是从头开始实现。有关@abstractmethod用法的详细信息,请参阅abc模块文档。
.inspect() 方法的代码在示例 13-7 中有些愚蠢,但它表明我们可以依赖.pick()和.load(…)来检查Tombola中的内容——通过挑选所有项目并将它们加载回去,而不知道项目实际上是如何存储的。这个示例的重点是强调在抽象基类(ABCs)中提供具体方法是可以的,只要它们仅依赖于接口中的其他方法。了解它们的内部数据结构后,Tombola的具体子类可以始终用更智能的实现覆盖.inspect(),但他们不必这样做。
示例 13-7 中的.loaded()方法只有一行,但很昂贵:它调用.inspect()来构建tuple,然后对其应用bool()。这样做是有效的,但具体子类可以做得更好,我们将看到。
注意,我们对.inspect()的绕道实现要求我们捕获self.pick()抛出的LookupError。self.pick()可能引发LookupError也是其接口的一部分,但在 Python 中无法明确表示这一点,除非在文档中(参见示例 13-7 中抽象pick方法的文档字符串)。
我选择了LookupError异常,因为它在 Python 异常层次结构中与IndexError和KeyError的关系,这是实现具体Tombola时最有可能引发的异常。因此,实现可以引发LookupError、IndexError、KeyError或LookupError的自定义子类以符合要求。参见图 13-6。

图 13-6。Exception类层次结构的一部分。¹¹
①
LookupError是我们在Tombola.inspect中处理的异常。
②
IndexError是我们尝试从序列中获取超出最后位置的索引时引发的LookupError子类。
③
当我们使用不存在的键从映射中获取项时,会引发KeyError。
现在我们有了自己的Tombola ABC。为了见证 ABC 执行的接口检查,让我们尝试用一个有缺陷的实现来愚弄Tombola,参见示例 13-8。
示例 13-8。一个虚假的Tombola不会被发现
>>> from tombola import Tombola
>>> class Fake(Tombola): # ①
... def pick(self):
... return 13
...
>>> Fake # ②
<class '__main__.Fake'> >>> f = Fake() # ③
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class Fake with abstract method load
①
将Fake声明为Tombola的子类。
②
类已创建,目前没有错误。
③
当我们尝试实例化Fake时,会引发TypeError。消息非常清楚:Fake被视为抽象,因为它未能实现Tombola ABC 中声明的抽象方法之一load。
所以我们定义了我们的第一个 ABC,并让它验证一个类的工作。我们很快将子类化Tombola ABC,但首先我们必须了解一些 ABC 编码规则。
ABC 语法细节
声明 ABC 的标准方式是继承abc.ABC或任何其他 ABC。
除了ABC基类和@abstractmethod装饰器外,abc模块还定义了@abstractclassmethod、@abstractstaticmethod和@abstractproperty装饰器。然而,在 Python 3.3 中,这三个装饰器已被弃用,因为现在可以在@abstractmethod之上堆叠装饰器,使其他装饰器变得多余。例如,声明抽象类方法的首选方式是:
class MyABC(abc.ABC):
@classmethod
@abc.abstractmethod
def an_abstract_classmethod(cls, ...):
pass
警告
堆叠函数装饰器的顺序很重要,在@abstractmethod的情况下,文档是明确的:
当
abstractmethod()与其他方法描述符结合使用时,应将其应用为最内层的装饰器…¹²
换句话说,在@abstractmethod和def语句之间不得出现其他装饰器。
现在我们已经解决了这些 ABC 语法问题,让我们通过实现两个具体的子类来使用Tombola。
ABC 的子类化
鉴于Tombola ABC,我们现在将开发两个满足其接口的具体子类。这些类在图 13-5 中有所描述,以及下一节将讨论的虚拟子类。
示例 13-9 中的BingoCage类是示例 7-8 的变体,使用了更好的随机化程序。这个BingoCage实现了所需的抽象方法load和pick。
示例 13-9。bingo.py:BingoCage是Tombola的具体子类
import random
from tombola import Tombola
class BingoCage(Tombola): # ①
def __init__(self, items):
self._randomizer = random.SystemRandom() # ②
self._items = []
self.load(items) # ③
def load(self, items):
self._items.extend(items)
self._randomizer.shuffle(self._items) # ④
def pick(self): # ⑤
try:
return self._items.pop()
except IndexError:
raise LookupError('pick from empty BingoCage')
def __call__(self): # ⑥
self.pick()
①
这个BingoCage类明确扩展了Tombola。
②
假设我们将用于在线游戏。random.SystemRandom在os.urandom(…)函数之上实现了random API,该函数提供了“适用于加密用途”的随机字节,根据os模块文档。
③
将初始加载委托给.load(…)方法。
④
我们使用我们的SystemRandom实例的.shuffle()方法,而不是普通的random.shuffle()函数。
⑤
pick的实现如示例 7-8 中所示。
⑥
__call__也来自示例 7-8。虽然不需要满足Tombola接口,但添加额外的方法也没有坏处。
BingoCage继承了Tombola的昂贵loaded和愚蠢的inspect方法。都可以用快得多的一行代码重写,就像示例 13-10 中那样。关键是:我们可以懒惰并只继承来自 ABC 的次优具体方法。从Tombola继承的方法对于BingoCage来说并不像它们本应该的那样快,但对于任何正确实现pick和load的Tombola子类,它们确实提供了正确的结果。
示例 13-10 展示了Tombola接口的一个非常不同但同样有效的实现。LottoBlower不是洗“球”并弹出最后一个,而是从随机位置弹出。
示例 13-10。lotto.py:LottoBlower是一个具体子类,覆盖了Tombola的inspect和loaded方法。
import random
from tombola import Tombola
class LottoBlower(Tombola):
def __init__(self, iterable):
self._balls = list(iterable) # ①
def load(self, iterable):
self._balls.extend(iterable)
def pick(self):
try:
position = random.randrange(len(self._balls)) # ②
except ValueError:
raise LookupError('pick from empty LottoBlower')
return self._balls.pop(position) # ③
def loaded(self): # ④
return bool(self._balls)
def inspect(self): # ⑤
return tuple(self._balls)
①
初始化程序接受任何可迭代对象:该参数用于构建一个列表。
②
random.randrange(…)函数在范围为空时会引发ValueError,因此我们捕获并抛出LookupError,以便与Tombola兼容。
③
否则,随机选择的项目将从self._balls中弹出。
④
重写loaded以避免调用inspect(就像示例 13-7 中的Tombola.loaded一样)。通过直接使用self._balls来工作,我们可以使其更快—不需要构建一个全新的tuple。
⑤
用一行代码重写inspect。
示例 13-10 展示了一个值得一提的习惯用法:在__init__中,self._balls存储list(iterable)而不仅仅是iterable的引用(即,我们并没有简单地赋值self._balls = iterable,从而给参数起了个别名)。正如在“防御性编程和‘快速失败’”中提到的,这使得我们的LottoBlower灵活,因为iterable参数可以是任何可迭代类型。同时,我们确保将其项存储在list中,这样我们就可以pop项。即使我们总是得到列表作为iterable参数,list(iterable)也会产生参数的副本,这是一个很好的做法,考虑到我们将从中删除项目,而客户端可能不希望提供的列表被更改。¹³
现在我们来到鹅类型的关键动态特性:使用register方法声明虚拟子类。
一个 ABC 的虚拟子类
鹅类型的一个重要特征——也是为什么它值得一个水禽名字的原因之一——是能够将一个类注册为 ABC 的虚拟子类,即使它没有继承自它。这样做时,我们承诺该类忠实地实现了 ABC 中定义的接口——Python 会相信我们而不进行检查。如果我们撒谎,我们将被通常的运行时异常捕获。
这是通过在 ABC 上调用register类方法来完成的。注册的类然后成为 ABC 的虚拟子类,并且将被issubclass识别为这样,但它不会继承 ABC 的任何方法或属性。
警告
虚拟子类不会从其注册的 ABC 继承,并且在任何时候都不会检查其是否符合 ABC 接口,即使在实例化时也是如此。此外,静态类型检查器目前无法处理虚拟子类。详情请参阅Mypy issue 2922—ABCMeta.register support。
register方法通常作为一个普通函数调用(参见“实践中的 register 用法”),但也可以用作装饰器。在示例 13-11 中,我们使用装饰器语法并实现TomboList,Tombola的虚拟子类,如图 13-7 所示。

图 13-7. TomboList的 UML 类图,list的真实子类和Tombola的虚拟子类。
示例 13-11. tombolist.py:类TomboList是Tombola的虚拟子类
from random import randrange
from tombola import Tombola
@Tombola.register # ①
class TomboList(list): # ②
def pick(self):
if self: # ③
position = randrange(len(self))
return self.pop(position) # ④
else:
raise LookupError('pop from empty TomboList')
load = list.extend # ⑤
def loaded(self):
return bool(self) # ⑥
def inspect(self):
return tuple(self)
# Tombola.register(TomboList) # ⑦
①
Tombolist被注册为Tombola的虚拟子类。
②
Tombolist扩展了list。
③
Tombolist从list继承其布尔行为,如果列表不为空则返回True。
④
我们的pick调用self.pop,从list继承,传递一个随机的项目索引。
⑤
Tombolist.load与list.extend相同。
⑥
loaded委托给bool。¹⁴
⑦
总是可以以这种方式调用register,当你需要注册一个你不维护但符合接口的类时,这样做是很有用的。
请注意,由于注册,函数issubclass和isinstance的行为就好像TomboList是Tombola的子类一样:
>>> from tombola import Tombola
>>> from tombolist import TomboList
>>> issubclass(TomboList, Tombola)
True
>>> t = TomboList(range(100))
>>> isinstance(t, Tombola)
True
然而,继承受到一个特殊的类属性__mro__的指导——方法解析顺序。它基本上按照 Python 用于搜索方法的顺序列出了类及其超类。¹⁵ 如果你检查TomboList的__mro__,你会看到它只列出了“真正”的超类——list和object:
>>> TomboList.__mro__
(<class 'tombolist.TomboList'>, <class 'list'>, <class 'object'>)
Tombola不在Tombolist.__mro__中,所以Tombolist不会从Tombola继承任何方法。
这结束了我们的TombolaABC 案例研究。在下一节中,我们将讨论registerABC 函数在实际中的使用方式。
实践中的 register 用法
在示例 13-11 中,我们使用Tombola.register作为一个类装饰器。在 Python 3.3 之前,register 不能像那样使用——它必须在类定义之后作为一个普通函数调用,就像示例 13-11 末尾的注释建议的那样。然而,即使现在,它更广泛地被用作一个函数来注册在其他地方定义的类。例如,在collections.abc模块的源代码中,内置类型tuple、str、range和memoryview被注册为Sequence的虚拟子类,就像这样:
Sequence.register(tuple)
Sequence.register(str)
Sequence.register(range)
Sequence.register(memoryview)
其他几种内置类型在_collections_abc.py中被注册为 ABC。这些注册只会在导入该模块时发生,这是可以接受的,因为你无论如何都需要导入它来获取 ABC。例如,你需要从collections.abc导入MutableMapping来执行类似isinstance(my_dict, MutableMapping)的检查。
对 ABC 进行子类化或向 ABC 注册都是显式使我们的类通过issubclass检查的方法,以及依赖于issubclass的isinstance检查。但有些 ABC 也支持结构化类型。下一节将解释。
带有 ABCs 的结构化类型
ABC 主要与名义类型一起使用。当类Sub明确从AnABC继承,或者与AnABC注册时,AnABC的名称与Sub类关联起来—这就是在运行时,issubclass(AnABC, Sub)返回True的原因。
相比之下,结构类型是通过查看对象的公共接口结构来确定其类型的:如果一个对象实现了类型定义中定义的方法,则它与该类型一致。动态和静态鸭子类型是结构类型的两种方法。
事实证明,一些 ABC 也支持结构类型。在他的文章“水禽和 ABC”中,Alex 表明一个类即使没有注册也可以被识别为 ABC 的子类。以下是他的例子,增加了使用issubclass的测试:
>>> class Struggle:
... def __len__(self): return 23
...
>>> from collections import abc
>>> isinstance(Struggle(), abc.Sized)
True
>>> issubclass(Struggle, abc.Sized)
True
类Struggle被issubclass函数认为是abc.Sized的子类(因此,也被isinstance认为是)因为abc.Sized实现了一个名为__subclasshook__的特殊类方法。
Sized的__subclasshook__检查类参数是否有名为__len__的属性。如果有,那么它被视为Sized的虚拟子类。参见示例 13-12。
示例 13-12。来自Lib/_collections_abc.py源代码中Sized的定义
class Sized(metaclass=ABCMeta):
__slots__ = ()
@abstractmethod
def __len__(self):
return 0
@classmethod
def __subclasshook__(cls, C):
if cls is Sized:
if any("__len__" in B.__dict__ for B in C.__mro__): # ①
return True # ②
return NotImplemented # ③
①
如果在C.__mro__中列出的任何类(即C及其超类)的__dict__中有名为__len__的属性…
②
…返回True,表示C是Sized的虚拟子类。
③
否则返回NotImplemented以让子类检查继续进行。
注意
如果你对子类检查的细节感兴趣,请查看 Python 3.6 中ABCMeta.__subclasscheck__方法的源代码:Lib/abc.py。注意:它有很多的 if 语句和两次递归调用。在 Python 3.7 中,Ivan Levkivskyi 和 Inada Naoki 为了更好的性能,用 C 重写了abc模块的大部分逻辑。参见Python 问题 #31333。当前的ABCMeta.__subclasscheck__实现只是调用了_abc_subclasscheck。相关的 C 源代码在cpython/Modules/_abc.c#L605中。
这就是__subclasshook__如何使 ABC 支持结构类型。你可以用 ABC 规范化一个接口,可以对该 ABC 进行isinstance检查,而仍然可以让一个完全不相关的类通过issubclass检查,因为它实现了某个方法(或者因为它做了足够的事情来说服__subclasshook__为它背书)。
在我们自己的 ABC 中实现__subclasshook__是个好主意吗?可能不是。我在 Python 源代码中看到的所有__subclasshook__的实现都在像Sized这样声明了一个特殊方法的 ABC 中,它们只是检查那个特殊方法的名称。鉴于它们的“特殊”地位,你可以非常确定任何名为__len__的方法都会按照你的期望工作。但即使在特殊方法和基本 ABC 的领域,做出这样的假设也是有风险的。例如,映射实现了__len__、__getitem__和__iter__,但它们被正确地不认为是Sequence的子类型,因为你不能使用整数偏移或切片检索项目。这就是为什么abc.Sequence类不实现__subclasshook__。
对于你和我可能编写的 ABCs,__subclasshook__可能会更不可靠。我不准备相信任何实现或继承load、pick、inspect和loaded的Spam类都能保证像Tombola一样行为。最好让程序员通过将Spam从Tombola继承或使用Tombola.register(Spam)来确认。当然,你的__subclasshook__也可以检查方法签名和其他特性,但我认为这并不值得。
静态协议
注意
静态协议是在“静态协议”(第八章)中引入的。我考虑延迟对协议的所有覆盖,直到本章,但决定最初在函数中的类型提示的介绍中包括协议,因为鸭子类型是 Python 的一个重要部分,而没有协议的静态类型检查无法很好地处理 Pythonic API。
我们将通过两个简单示例和对数字 ABCs 和协议的讨论来结束本章。让我们首先展示静态协议如何使得我们可以对我们在“类型由支持的操作定义”中首次看到的double()函数进行注释和类型检查。
有类型的 double 函数
当向更习惯于静态类型语言的程序员介绍 Python 时,我最喜欢的一个例子就是这个简单的double函数:
>>> def double(x):
... return x * 2
...
>>> double(1.5)
3.0
>>> double('A')
'AA'
>>> double([10, 20, 30])
[10, 20, 30, 10, 20, 30]
>>> from fractions import Fraction
>>> double(Fraction(2, 5))
Fraction(4, 5)
在引入静态协议之前,没有实际的方法可以为double添加类型提示,而不限制其可能的用途。¹⁷
由于鸭子类型的存在,double甚至可以与未来的类型一起使用,比如我们将在“为标量乘法重载 *”(第十六章)中看到的增强Vector类:
>>> from vector_v7 import Vector
>>> double(Vector([11.0, 12.0, 13.0]))
Vector([22.0, 24.0, 26.0])
Python 中类型提示的初始实现是一种名义类型系统:注释中的类型名称必须与实际参数的类型名称或其超类的名称匹配。由于不可能命名所有支持所需操作的协议的类型,因此在 Python 3.8 之前无法通过类型提示描述鸭子类型。
现在,通过typing.Protocol,我们可以告诉 Mypy,double接受一个支持x * 2的参数x。示例 13-13 展示了如何实现。
示例 13-13. double_protocol.py: 使用Protocol定义double的定义
from typing import TypeVar, Protocol
T = TypeVar('T') # ①
class Repeatable(Protocol):
def __mul__(self: T, repeat_count: int) -> T: ... # ②
RT = TypeVar('RT', bound=Repeatable) # ③
def double(x: RT) -> RT: # ④
return x * 2
①
我们将在__mul__签名中使用这个T。
②
__mul__是Repeatable协议的本质。self参数通常不会被注释,其类型被假定为类。在这里,我们使用T来确保结果类型与self的类型相同。此外,请注意,此协议中的repeat_count限制为int。
③
RT类型变量受Repeatable协议的约束:类型检查器将要求实际类型实现Repeatable。
④
现在类型检查器能够验证x参数是一个可以乘以整数的对象,并且返回值与x的类型相同。
本示例说明了为什么PEP 544的标题是“协议:结构子类型(静态鸭子类型)”。给定给double的实际参数x的名义类型是无关紧要的,只要它呱呱叫,也就是说,只要它实现了__mul__。
可运行时检查的静态协议
在类型映射中(图 13-1),typing.Protocol出现在静态检查区域—图表的下半部分。然而,当定义typing.Protocol子类时,您可以使用@runtime_checkable装饰器使该协议支持运行时的isinstance/issubclass检查。这是因为typing.Protocol是一个 ABC,因此支持我们在“使用 ABC 进行结构化类型检查”中看到的__subclasshook__。
截至 Python 3.9,typing模块包含了七个可供直接使用的运行时可检查的协议。以下是其中两个,直接引用自typing文档:
class typing.SupportsComplex
一个具有一个抽象方法__complex__的 ABC。
class typing.SupportsFloat
一个具有一个抽象方法__float__的 ABC。
这些协议旨在检查数值类型的“可转换性”:如果一个对象o实现了__complex__,那么您应该能够通过调用complex(o)来获得一个complex——因为__complex__特殊方法存在是为了支持complex()内置函数。
示例 13-14 展示了typing.SupportsComplex协议的源代码。
示例 13-14. typing.SupportsComplex协议源代码
@runtime_checkable
class SupportsComplex(Protocol):
"""An ABC with one abstract method __complex__."""
__slots__ = ()
@abstractmethod
def __complex__(self) -> complex:
pass
关键在于__complex__抽象方法。¹⁸ 在静态类型检查期间,如果一个对象实现了只接受self并返回complex的__complex__方法,则该对象将被视为与SupportsComplex协议一致。
由于@runtime_checkable类装饰器应用于SupportsComplex,因此该协议也可以与isinstance检查一起在示例 13-15 中使用。
示例 13-15. 在运行时使用SupportsComplex
>>> from typing import SupportsComplex
>>> import numpy as np
>>> c64 = np.complex64(3+4j) # ①
>>> isinstance(c64, complex) # ②
False >>> isinstance(c64, SupportsComplex) # ③
True >>> c = complex(c64) # ④
>>> c
(3+4j) >>> isinstance(c, SupportsComplex) # ⑤
False >>> complex(c)
(3+4j)
①
complex64是 NumPy 提供的五种复数类型之一。
②
NumPy 的任何复数类型都不是内置的complex的子类。
③
但 NumPy 的复数类型实现了__complex__,因此它们符合SupportsComplex协议。
④
因此,您可以从中创建内置的complex对象。
⑤
遗憾的是,complex内置类型不实现__complex__,尽管如果c是complex,那么complex(c)可以正常工作。
由于上述最后一点,如果您想测试对象c是否为complex或SupportsComplex,您可以将类型元组作为isinstance的第二个参数提供,就像这样:
isinstance(c, (complex, SupportsComplex))
另一种方法是使用numbers模块中定义的Complex ABC。内置的complex类型和 NumPy 的complex64和complex128类型都注册为numbers.Complex的虚拟子类,因此这样可以工作:
>>> import numbers
>>> isinstance(c, numbers.Complex)
True
>>> isinstance(c64, numbers.Complex)
True
在第一版的流畅的 Python中,我推荐使用numbers ABCs,但现在这不再是一个好建议,因为这些 ABCs 不被静态类型检查器识别,正如我们将在“数字 ABC 和数值协议”中看到的那样。
在本节中,我想演示一个运行时可检查的协议如何与isinstance一起工作,但事实证明这个示例并不是isinstance的一个特别好的用例,因为侧边栏“鸭子类型是你的朋友”解释了这一点。
提示
如果您正在使用外部类型检查器,那么显式的isinstance检查有一个优点:当您编写一个条件为isinstance(o, MyType)的if语句时,那么 Mypy 可以推断在if块内,o对象的类型与MyType一致。
现在我们已经看到如何在运行时使用静态协议与预先存在的类型如complex和numpy.complex64,我们需要讨论运行时可检查协议的限制。
运行时协议检查的限制
我们已经看到类型提示通常在运行时被忽略,这也影响了对静态协议进行isinstance或issubclass检查。
例如,任何具有__float__方法的类在运行时被认为是SupportsFloat的虚拟子类,即使__float__方法不返回float。
查看这个控制台会话:
>>> import sys
>>> sys.version
'3.9.5 (v3.9.5:0a7dcbdb13, May 3 2021, 13:17:02) \n[Clang 6.0 (clang-600.0.57)]'
>>> c = 3+4j
>>> c.__float__
<method-wrapper '__float__' of complex object at 0x10a16c590>
>>> c.__float__()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: can't convert complex to float
在 Python 3.9 中,complex类型确实有一个__float__方法,但它只是为了引发一个带有明确错误消息的TypeError。如果那个__float__方法有注释,返回类型将是NoReturn,我们在NoReturn中看到过。
但在typeshed上对complex.__float__进行类型提示不会解决这个问题,因为 Python 的运行时通常会忽略类型提示,并且无法访问typeshed存根文件。
继续前面的 Python 3.9 会话:
>>> from typing import SupportsFloat
>>> c = 3+4j
>>> isinstance(c, SupportsFloat)
True
>>> issubclass(complex, SupportsFloat)
True
因此我们有了误导性的结果:针对SupportsFloat的运行时检查表明你可以将complex转换为float,但实际上会引发类型错误。
警告
Python 3.10.0b4 中修复了complex类型的特定问题,移除了complex.__float__方法。
但总体问题仍然存在:isinstance/issubclass检查只关注方法的存在或不存在,而不检查它们的签名,更不用说它们的类型注释了。而且这不太可能改变,因为这样的运行时类型检查会带来无法接受的性能成本。¹⁹
现在让我们看看如何在用户定义的类中实现静态协议。
支持静态协议
回想一下我们在第十一章中构建的Vector2d类。考虑到complex数和Vector2d实例都由一对浮点数组成,支持从Vector2d到complex的转换是有意义的。
示例 13-16 展示了__complex__方法的实现,以增强我们在示例 11-11 中看到的Vector2d的最新版本。为了完整起见,我们可以通过一个fromcomplex类方法支持反向操作,从complex构建一个Vector2d。
示例 13-16. vector2d_v4.py: 转换为和从complex的方法
def __complex__(self):
return complex(self.x, self.y)
@classmethod
def fromcomplex(cls, datum):
return cls(datum.real, datum.imag) # ①
①
这假设datum有.real和.imag属性。我们将在示例 13-17 中看到一个更好的实现。
鉴于前面的代码,以及Vector2d在示例 11-11 中已经有的__abs__方法,我们得到了这些特性:
>>> from typing import SupportsComplex, SupportsAbs
>>> from vector2d_v4 import Vector2d
>>> v = Vector2d(3, 4)
>>> isinstance(v, SupportsComplex)
True
>>> isinstance(v, SupportsAbs)
True
>>> complex(v)
(3+4j)
>>> abs(v)
5.0
>>> Vector2d.fromcomplex(3+4j)
Vector2d(3.0, 4.0)
对于运行时类型检查,示例 13-16 是可以的,但为了更好的静态覆盖和使用 Mypy 进行错误报告,__abs__,__complex__ 和 fromcomplex 方法应该得到类型提示,如示例 13-17 所示。
示例 13-17. vector2d_v5.py: 为研究中的方法添加注释
def __abs__(self) -> float: # ①
return math.hypot(self.x, self.y)
def __complex__(self) -> complex: # ②
return complex(self.x, self.y)
@classmethod
def fromcomplex(cls, datum: SupportsComplex) -> Vector2d: # ③
c = complex(datum) # ④
return cls(c.real, c.imag)
①
需要float返回注释,否则 Mypy 推断为Any,并且不检查方法体。
②
即使没有注释,Mypy 也能推断出这返回一个complex。根据您的 Mypy 配置,注释可以避免警告。
③
这里SupportsComplex确保datum是可转换的。
④
这种显式转换是必要的,因为SupportsComplex类型没有声明.real和.imag属性,这在下一行中使用。例如,Vector2d没有这些属性,但实现了__complex__。
如果在模块顶部出现from __future__ import annotations,fromcomplex的返回类型可以是Vector2d。这个导入会导致类型提示被存储为字符串,而不会在导入时被评估,当函数定义被评估时。没有__future__导入annotations,此时Vector2d是一个无效的引用(类尚未完全定义),应该写成字符串:'Vector2d',就好像它是一个前向引用一样。这个__future__导入是在PEP 563—注解的延迟评估中引入的,实现在 Python 3.7 中。这种行为原计划在 3.10 中成为默认值,但该更改被推迟到以后的版本。²⁰ 当这种情况发生时,这个导入将是多余的,但无害的。
接下来,让我们看看如何创建——以及稍后扩展——一个新的静态协议。
设计一个静态协议
在学习鹅类型时,我们在“定义和使用 ABC”中看到了Tombola ABC。在这里,我们将看到如何使用静态协议定义一个类似的接口。
Tombola ABC 指定了两种方法:pick和load。我们也可以定义一个具有这两种方法的静态协议,但我从 Go 社区中学到,单方法协议使得静态鸭子类型更有用和灵活。Go 标准库有几个类似Reader的接口,这是一个仅需要read方法的 I/O 接口。过一段时间,如果你意识到需要一个更完整的协议,你可以将两个或更多的协议组合起来定义一个新的协议。
使用随机选择项目的容器可能需要重新加载容器,也可能不需要,但肯定需要一种方法来实际选择,因此这就是我选择最小RandomPicker协议的方法。该协议的代码在示例 13-18 中,其使用由示例 13-19 中的测试演示。
示例 13-18。randompick.py:RandomPicker的定义
from typing import Protocol, runtime_checkable, Any
@runtime_checkable
class RandomPicker(Protocol):
def pick(self) -> Any: ...
注意
pick方法返回Any。在“实现通用静态协议”中,我们将看到如何使RandomPicker成为一个带有参数的通用类型,让协议的使用者指定pick方法的返回类型。
示例 13-19。randompick_test.py:RandomPicker的使用
import random
from typing import Any, Iterable, TYPE_CHECKING
from randompick import RandomPicker # ①
class SimplePicker: # ②
def __init__(self, items: Iterable) -> None:
self._items = list(items)
random.shuffle(self._items)
def pick(self) -> Any: # ③
return self._items.pop()
def test_isinstance() -> None: # ④
popper: RandomPicker = SimplePicker([1]) # ⑤
assert isinstance(popper, RandomPicker) # ⑥
def test_item_type() -> None: # ⑦
items = [1, 2]
popper = SimplePicker(items)
item = popper.pick()
assert item in items
if TYPE_CHECKING:
reveal_type(item) # ⑧
assert isinstance(item, int)
①](#co_interfaces__protocols__and_abcs_CO14-1)
定义实现它的类时,不需要导入静态协议。这里我只导入RandomPicker是为了稍后在test_isinstance中使用它。
②](#co_interfaces__protocols__and_abcs_CO14-2)
SimplePicker实现了RandomPicker——但它并没有继承它。这就是静态鸭子类型的作用。
③](#co_interfaces__protocols__and_abcs_CO14-3)
Any是默认返回类型,因此此注释并不是严格必要的,但它确实使我们正在实现示例 13-18 中定义的RandomPicker协议更清晰。
④](#co_interfaces__protocols__and_abcs_CO14-4)
如果你希望 Mypy 查看测试,请不要忘记为你的测试添加-> None提示。
⑤](#co_interfaces__protocols__and_abcs_CO14-5)
我为popper变量添加了一个类型提示,以显示 Mypy 理解SimplePicker是与之一致的。
⑥](#co_interfaces__protocols__and_abcs_CO14-6)
这个测试证明了SimplePicker的一个实例也是RandomPicker的一个实例。这是因为@runtime_checkable装饰器应用于RandomPicker,并且SimplePicker有一个所需的pick方法。
⑦](#co_interfaces__protocols__and_abcs_CO14-7)
这个测试调用了SimplePicker的pick方法,验证它是否返回了给SimplePicker的项目之一,然后对返回的项目进行了静态和运行时检查。
⑧
这行代码会在 Mypy 输出中生成一个注释。
正如我们在示例 8-22 中看到的,reveal_type是 Mypy 识别的“魔术”函数。这就是为什么它不被导入,我们只能在typing.TYPE_CHECKING保护的if块内调用它,这个块只有在静态类型检查器的眼中才是True,但在运行时是False。
示例 13-19 中的两个测试都通过了。Mypy 在该代码中没有看到任何错误,并显示了pick返回的item上reveal_type的结果:
$ mypy randompick_test.py
randompick_test.py:24: note: Revealed type is 'Any'
创建了我们的第一个协议后,让我们研究一些相关建议。
协议设计的最佳实践
在使用 Go 中的静态鸭子类型 10 年后,很明显,窄协议更有用——通常这样的协议只有一个方法,很少有超过两个方法。Martin Fowler 撰写了一篇定义角色接口的文章,在设计协议时要记住这个有用的概念。
有时候你会看到一个协议在使用它的函数附近定义——也就是说,在“客户端代码”中定义,而不是在库中定义。这样做可以轻松创建新类型来调用该函数,这对于可扩展性和使用模拟进行测试是有益的。
窄协议和客户端代码协议的实践都避免了不必要的紧密耦合,符合接口隔离原则,我们可以总结为“客户端不应被迫依赖于他们不使用的接口”。
页面“贡献给 typeshed”推荐了这种静态协议的命名约定(以下三点引用原文):
-
对于代表清晰概念的协议,请使用简单名称(例如,
Iterator,Container)。 -
对于提供可调用方法的协议,请使用
SupportsX(例如,SupportsInt,SupportsRead,SupportsReadSeek)。²¹ -
对于具有可读和/或可写属性或 getter/setter 方法的协议,请使用
HasX(例如,HasItems,HasFileno)。
Go 标准库有一个我喜欢的命名约定:对于单方法协议,如果方法名是动词,可以添加“-er”或“-or”以使其成为名词。例如,不要使用SupportsRead,而是使用Reader。更多示例包括Formatter,Animator和Scanner。有关灵感,请参阅 Asuka Kenji 的“Go(Golang)标准库接口(精选)”。
创建简约协议的一个好理由是以后可以根据需要扩展它们。我们现在将看到创建一个带有额外方法的派生协议并不困难。
扩展协议
正如我在上一节开始时提到的,Go 开发人员在定义接口时倾向于保持最小主义——他们称之为静态协议。许多最广泛使用的 Go 接口只有一个方法。
当实践表明一个具有更多方法的协议是有用的时候,与其向原始协议添加方法,不如从中派生一个新协议。在 Python 中扩展静态协议有一些注意事项,正如示例 13-20 所示。
示例 13-20. randompickload.py: 扩展RandomPicker
from typing import Protocol, runtime_checkable
from randompick import RandomPicker
@runtime_checkable # ①
class LoadableRandomPicker(RandomPicker, Protocol): # ②
def load(self, Iterable) -> None: ... # ③
①
如果希望派生协议可以进行运行时检查,必须再次应用装饰器——其行为不会被继承。²²
②
每个协议必须明确将typing.Protocol命名为其基类之一,除了我们正在扩展的协议。这与 Python 中继承的方式不同。²³
③
回到“常规”面向对象编程:我们只需要声明这个派生协议中新增的方法。pick方法声明是从RandomPicker继承的。
这结束了本章中定义和使用静态协议的最终示例。
为了结束本章,我们将讨论数字 ABCs 及其可能被数字协议替代的情况。
数字 ABCs 和数字协议
正如我们在“数字塔的崩塌”中看到的,标准库中numbers包中的 ABCs 对于运行时类型检查效果很好。
如果需要检查整数,可以使用isinstance(x, numbers.Integral)来接受int、bool(它是int的子类)或其他由外部库提供并将其类型注册为numbers ABCs 虚拟子类的整数类型。例如,NumPy 有21 种整数类型——以及几种浮点类型注册为numbers.Real,以及以不同位宽注册为numbers.Complex的复数。
提示
令人惊讶的是,decimal.Decimal并未注册为numbers.Real的虚拟子类。原因是,如果您的程序需要Decimal的精度,那么您希望受到保护,以免将精度较低的浮点数与Decimal混合。
遗憾的是,数字塔并不适用于静态类型检查。根 ABC——numbers.Number——没有方法,因此如果声明x: Number,Mypy 将不允许您在x上进行算术运算或调用任何方法。
如果不支持numbers ABCs,那么还有哪些选项?
寻找类型解决方案的好地方是typeshed项目。作为 Python 标准库的一部分,statistics模块有一个对应的statistics.pyi存根文件,其中包含了对typeshed上几个函数进行类型提示的定义。在那里,您会找到以下定义,用于注释几个函数:
_Number = Union[float, Decimal, Fraction]
_NumberT = TypeVar('_NumberT', float, Decimal, Fraction)
这种方法是正确的,但有限。它不支持标准库之外的数字类型,而numbers ABCs 在运行时支持这些数字类型——当数字类型被注册为虚拟子类时。
当前的趋势是推荐typing模块提供的数字协议,我们在“可运行时检查的静态协议”中讨论过。
不幸的是,在运行时,数字协议可能会让您失望。正如在“运行时协议检查的限制”中提到的,Python 3.9 中的complex类型实现了__float__,但该方法仅存在于引发TypeError并附带明确消息“无法将复数转换为浮点数”:同样的原因,它也实现了__int__。这些方法的存在使得在 Python 3.9 中isinstance返回误导性的结果。在 Python 3.10 中,那些无条件引发TypeError的complex方法被移除了。²⁴
另一方面,NumPy 的复数类型实现了__float__和__int__方法,只有在第一次使用每个方法时才会发出警告:
>>> import numpy as np
>>> cd = np.cdouble(3+4j)
>>> cd
(3+4j)
>>> float(cd)
<stdin>:1: ComplexWarning: Casting complex values to real
discards the imaginary part
3.0
相反的问题也会发生:内置类complex、float和int,以及numpy.float16和numpy.uint8,都没有__complex__方法,因此对于它们,isinstance(x, SupportsComplex)返回False。²⁵ NumPy 的复数类型,如np.complex64,确实实现了__complex__以转换为内置的complex。
然而,在实践中,complex()内置构造函数处理所有这些类型的实例都没有错误或警告:
>>> import numpy as np
>>> from typing import SupportsComplex
>>> sample = [1+0j, np.complex64(1+0j), 1.0, np.float16(1.0), 1, np.uint8(1)]
>>> [isinstance(x, SupportsComplex) for x in sample]
[False, True, False, False, False, False]
>>> [complex(x) for x in sample]
[(1+0j), (1+0j), (1+0j), (1+0j), (1+0j), (1+0j)]
这表明isinstance检查对SupportsComplex的转换表明这些转换为complex将失败,但它们都成功了。在 typing-sig 邮件列表中,Guido van Rossum 指出,内置的complex接受一个参数,这就是为什么这些转换起作用的原因。
另一方面,Mypy 在定义如下的to_complex()函数时接受这六种类型的所有参数:
def to_complex(n: SupportsComplex) -> complex:
return complex(n)
在我写这篇文章时,NumPy 没有类型提示,因此其数值类型都是Any。²⁶ 另一方面,Mypy 在某种程度上“意识到”内置的int和float可以转换为complex,尽管在 typeshed 中只有内置的complex类有一个__complex__方法。²⁷
总之,尽管数值类型不应该难以进行类型检查,但目前的情况是:类型提示 PEP 484 避开了数值塔,并隐含地建议类型检查器硬编码内置complex、float和int之间的子类型关系。Mypy 这样做了,并且还实用地接受int和float与SupportsComplex一致,尽管它们没有实现__complex__。
提示
当我尝试将数值Supports*协议与complex进行转换时,使用isinstance检查时我只发现了意外结果。如果你不使用复数,你可以依赖这些协议而不是numbers ABCs。
本节的主要要点是:
-
numbersABCs 适用于运行时类型检查,但不适用于静态类型检查。 -
数值静态协议
SupportsComplex、SupportsFloat等在静态类型检查时效果很好,但在涉及复数时在运行时类型检查时不可靠。
现在我们准备快速回顾本章内容。
章节总结
键盘映射(图 13-1)是理解本章内容的关键。在简要介绍了四种类型方法后,我们对比了动态和静态协议,分别支持鸭子类型和静态鸭子类型。这两种类型的协议共享一个基本特征,即类永远不需要明确声明支持任何特定协议。一个类通过实现必要的方法来支持一个协议。
接下来的主要部分是“编程鸭子”,我们探讨了 Python 解释器为使序列和可迭代动态协议工作所做的努力,包括部分实现两者。然后我们看到一个类如何通过动态添加额外方法来在运行时实现一个协议,通过猴子补丁。鸭子类型部分以防御性编程的提示结束,包括使用try/except检测结构类型而无需显式的isinstance或hasattr检查,并快速失败。
在 Alex Martelli 介绍鹅类型之后“水禽和 ABCs”,我们看到如何对现有的 ABCs 进行子类化,调查了标准库中重要的 ABCs,并从头开始创建了一个 ABC,然后通过传统的子类化和注册来实现。为了结束这一部分,我们看到__subclasshook__特殊方法如何使 ABCs 能够通过识别提供符合 ABC 中定义接口的方法的不相关类来支持结构类型。
最后一个重要部分是“静态协议”,我们在这里恢复了静态鸭子类型的覆盖范围,这始于第八章,在“静态协议”中。我们看到@runtime_checkable装饰器如何利用__subclasshook__来支持运行时的结构化类型,尽管最佳使用静态协议的方式是与静态类型检查器一起使用,这样可以考虑类型提示以使结构化类型更可靠。接下来,我们讨论了静态协议的设计和编码以及如何扩展它。本章以“数字 ABCs 和数字协议”结束,讲述了数字塔的荒废状态以及提出的替代方案存在的一些缺陷:Python 3.8 中添加到typing模块的数字静态协议,如SupportsFloat等。
本章的主要信息是我们在现代 Python 中有四种互补的接口编程方式,每种方式都有不同的优势和缺点。在任何规模较大的现代 Python 代码库中,您可能会发现每种类型方案都有适用的用例。拒绝这些方法中的任何一种都会使您作为 Python 程序员的工作变得比必要的更加困难。
话虽如此,Python 在仅支持鸭子类型的情况下取得了广泛的流行。其他流行的语言,如 JavaScript、PHP 和 Ruby,以及 Lisp、Smalltalk、Erlang 和 Clojure 等不那么流行但非常有影响力的语言,都通过利用鸭子类型的力量和简单性产生了巨大影响。
进一步阅读
要快速了解类型的利弊,以及typing.Protocol对于静态检查代码库健康的重要性,我强烈推荐 Glyph Lefkowitz 的帖子“我想要一个新的鸭子:typing.Protocol和鸭子类型的未来”。我还从他的帖子“接口和协议”中学到了很多,比较了typing.Protocol和zope.interface——一种早期用于在松散耦合的插件系统中定义接口的机制,被Plone CMS、Pyramid web framework和Twisted异步编程框架等项目使用,这是 Glyph 创建的一个项目。²⁸
有关 Python 的优秀书籍几乎可以定义为对鸭子类型的广泛覆盖。我最喜欢的两本 Python 书籍在Fluent Python第一版之后发布了更新:Naomi Ceder 的The Quick Python Book第 3 版(Manning)和 Alex Martelli、Anna Ravenscroft 和 Steve Holden(O’Reilly)的Python in a Nutshell第 3 版。
有关动态类型的利弊讨论,请参阅 Guido van Rossum 与 Bill Venners 的访谈“Python 中的合同:与 Guido van Rossum 的对话,第四部分”。Martin Fowler 在他的帖子“动态类型”中对这场辩论进行了深入而平衡的探讨。他还写了“角色接口”,我在“最佳协议设计实践”中提到过。尽管这不是关于鸭子类型的,但这篇文章对 Python 协议设计非常相关,因为他对比了狭窄的角色接口与一般类的更广泛的公共接口。
Mypy 文档通常是与 Python 中静态类型相关的任何信息的最佳来源,包括他们在“协议和结构子类型”章节中讨论的静态鸭子类型。
剩下的参考资料都是关于鹅类型的。Beazley 和 Jones 的Python Cookbook,第 3 版(O’Reilly)有一节关于定义 ABC(Recipe 8.12)。这本书是在 Python 3.4 之前编写的,所以他们没有使用现在更受欢迎的通过从abc.ABC子类化来声明 ABC 的语法(相反,他们使用了metaclass关键字,我们只在第二十四章中真正需要它)。除了这个小细节,这个配方很好地涵盖了主要的 ABC 特性。
Doug Hellmann 的Python 标准库示例(Addison-Wesley)中有一章关于abc模块。它也可以在 Doug 出色的PyMOTW—Python 本周模块网站上找到。Hellmann 还使用了旧式的 ABC 声明方式:PluginBase(metaclass=abc.ABCMeta),而不是自 Python 3.4 起可用的更简单的PluginBase(abc.ABC)。
在使用 ABCs 时,多重继承不仅很常见,而且几乎是不可避免的,因为每个基本集合 ABCs—Sequence、Mapping和Set—都扩展了Collection,而Collection又扩展了多个 ABCs(参见图 13-4)。因此,第十四章是本章的一个重要后续。
PEP 3119—引入抽象基类 提供了 ABC 的理由。PEP 3141—数字类型的类型层次结构 展示了numbers模块的 ABC,但在 Mypy 问题#3186 “int is not a Number?”的讨论中包含了一些关于为什么数字塔不适合静态类型检查的论点。Alex Waygood 在 StackOverflow 上写了一个全面的答案,讨论了注释数字类型的方法。我将继续关注 Mypy 问题#3186,期待这个传奇的下一章有一个让静态类型和鹅类型兼容的美好结局——因为它们应该是兼容的。
¹ 设计模式:可复用面向对象软件的元素,“介绍”,p. 18。
² Wikipedia 上的“猴子补丁”文章中有一个有趣的 Python 示例。
³ 这就是为什么自动化测试是必要的。
⁴ Bjarne Stroustrup, C++的设计与演化, p. 278 (Addison-Wesley)。
⁵ 检索日期为 2020 年 10 月 18 日。
⁶ 当然,你也可以定义自己的 ABCs,但我会劝阻除了最高级的 Pythonista 之外的所有人这样做,就像我会劝阻他们定义自己的自定义元类一样……即使对于那些拥有对语言的每一个折叠和褶皱深度掌握的“最高级的 Pythonista”来说,这些都不是经常使用的工具。这种“深度元编程”,如果适用的话,是为了那些打算由大量独立开发团队扩展的广泛框架的作者而设计的……不到“最高级的 Pythonista”的 1%可能会需要这个! — A.M.
⁷ 多重继承被认为是有害的,并且在 Java 中被排除,除了接口:Java 接口可以扩展多个接口,Java 类可以实现多个接口。
⁸ 或许客户需要审计随机器;或者机构想提供一个作弊的随机器。你永远不知道……
⁹ “注册”和“虚拟子类”不是标准的 UML 术语。我使用它们来表示一个特定于 Python 的类关系。
¹⁰ 在抽象基类存在之前,抽象方法会引发NotImplementedError来表示子类负责实现它们。在 Smalltalk-80 中,抽象方法体会调用subclassResponsibility,这是从object继承的一个方法,它会产生一个带有消息“我的子类应该重写我的消息之一”的错误。
¹¹ 完整的树在《Python 标准库》文档的“5.4. 异常层次结构”部分中。
¹² @abc.abstractmethod在abc模块文档中的条目。
¹³ 第六章中的“使用可变参数进行防御性编程”专门讨论了我们刚刚避免的别名问题。
¹⁴ 我用load()的相同技巧无法用于loaded(),因为list类型没有实现__bool__,我必须绑定到loaded的方法。bool() 内置不需要__bool__就能工作,因为它也可以使用__len__。请参阅 Python 文档的“内置类型”章节中的“4.1. 真值测试”。
¹⁵ 在“多重继承和方法解析顺序”中有一个完整的解释__mro__类属性的部分。现在,这个简短的解释就够了。
¹⁶ 类型一致性的概念在“子类型与一致性”中有解释。
¹⁷ 好吧,double() 并不是很有用,除了作为一个例子。但是在 Python 3.8 添加静态协议之前,Python 标准库有许多函数无法正确注释。我通过使用协议添加类型提示来帮助修复了 typeshed 中的一些错误。例如,修复“Mypy 是否应该警告可能无效的 max 参数?”的拉取请求利用了一个 _SupportsLessThan 协议,我用它增强了 max、min、sorted 和 list.sort 的注释。
¹⁸ __slots__ 属性与当前讨论无关—这是我们在“使用 slots 节省内存”中讨论的优化。
¹⁹ 感谢 PEP 544(关于协议)的合著者伊万·列夫基夫斯基指出,类型检查不仅仅是检查x的类型是否为T:它是关于确定x的类型与T是一致的,这可能是昂贵的。难怪 Mypy 即使对短小的 Python 脚本进行类型检查也需要几秒钟的时间。
²⁰ 阅读 Python Steering Council 在 python-dev 上的决定。
²¹ 每个方法都是可调用的,所以这个准则并没有说太多。也许“提供一个或两个方法”?无论如何,这只是一个指导方针,不是一个严格的规则。
²² 有关详细信息和原理,请参阅 PEP 544 中关于@runtime_checkable的部分—协议:结构子类型(静态鸭子类型)。
²³ 再次,请阅读 PEP 544 中关于“合并和扩展协议”的详细信息和原理。
²⁴ 请参阅Issue #41974—删除 complex.__float__、complex.__floordiv__ 等。
²⁵ 我没有测试 NumPy 提供的所有其他浮点数和整数变体。
²⁶ NumPy 的数字类型都已注册到相应的numbers ABCs 中,但 Mypy 忽略了这一点。
²⁷ 这是 typeshed 的一种善意的谎言:截至 Python 3.9,内置的complex类型实际上并没有__complex__方法。
²⁸ 感谢技术审阅者 Jürgen Gmach 推荐“接口和协议”文章。
第十四章:继承:是好是坏
[...] 我们需要一个更好的关于继承的理论(现在仍然需要)。例如,继承和实例化(这是一种继承)混淆了实用性(例如为了节省空间而分解代码)和语义(用于太多任务,如:专门化、泛化、种类化等)。
Alan Kay,“Smalltalk 的早期历史”¹
本章讨论继承和子类化。我假设你对这些概念有基本的了解,你可能从阅读Python 教程或从其他主流面向对象语言(如 Java、C#或 C++)的经验中了解这些概念。在这里,我们将重点关注 Python 的四个特点:
-
super()函数
-
从内置类型继承的陷阱
-
多重继承和方法解析顺序
-
Mixin 类
多重继承是一个类具有多个基类的能力。C++支持它;Java 和 C#不支持。许多人认为多重继承带来的麻烦不值得。在早期 C++代码库中被滥用后,Java 故意将其排除在外。
本章介绍了多重继承,供那些从未使用过的人,并提供了一些关于如何应对单一或多重继承的指导,如果你必须使用它。
截至 2021 年,对继承的过度使用存在明显的反对意见,不仅仅是多重继承,因为超类和子类之间紧密耦合。紧密耦合意味着对程序的某一部分进行更改可能会在其他部分产生意想不到的深远影响,使系统变得脆弱且难以理解。
然而,我们必须维护设计有复杂类层次结构的现有系统,或者使用强制我们使用继承的框架——有时甚至是多重继承。
我将通过标准库、Django 网络框架和 Tkinter GUI 工具包展示多重继承的实际用途。
本章新内容
本章主题没有与 Python 相关的新功能,但我根据第二版技术审阅人员的反馈进行了大量编辑,特别是 Leonardo Rochael 和 Caleb Hattingh。
我写了一个新的开头部分,重点关注super()内置函数,并更改了“多重继承和方法解析顺序”中的示例,以更深入地探讨super()如何支持协作式 多重继承。
“Mixin 类”也是新内容。“现实世界中的多重继承”已重新组织,并涵盖了标准库中更简单的 mixin 示例,然后是复杂的 Django 和复杂的 Tkinter 层次结构。
正如章节标题所示,继承的注意事项一直是本章的主要主题之一。但越来越多的开发人员认为这是一个问题,我在“章节总结”和“进一步阅读”的末尾添加了几段关于避免继承的内容。
我们将从神秘的super()函数的概述开始。
super()函数
对于可维护的面向对象 Python 程序,一致使用super()内置函数至关重要。
当子类重写超类的方法时,通常需要调用超类的相应方法。以下是推荐的方法,来自collections模块文档中的一个示例,“OrderedDict 示例和配方”部分:²
class LastUpdatedOrderedDict(OrderedDict):
"""Store items in the order they were last updated"""
def __setitem__(self, key, value):
super().__setitem__(key, value)
self.move_to_end(key)
为了完成其工作,LastUpdatedOrderedDict重写了__setitem__以:
-
使用
super().__setitem__调用超类上的该方法,让其插入或更新键/值对。 -
调用
self.move_to_end以确保更新的key位于最后位置。
调用重写的__init__方法特别重要,以允许超类在初始化实例时发挥作用。
提示
如果你在 Java 中学习面向对象编程,可能会记得 Java 构造方法会自动调用超类的无参构造方法。Python 不会这样做。你必须习惯编写这种模式:
def __init__(self, a, b) :
super().__init__(a, b)
... # more initialization code
你可能见过不使用super()而是直接在超类上调用方法的代码,就像这样:
class NotRecommended(OrderedDict):
"""This is a counter example!"""
def __setitem__(self, key, value):
OrderedDict.__setitem__(self, key, value)
self.move_to_end(key)
这种替代方法在这种特定情况下有效,但出于两个原因不建议使用。首先,它将基类硬编码了。OrderedDict的名称出现在class语句中,也出现在__setitem__中。如果将来有人更改class语句以更改基类或添加另一个基类,他们可能会忘记更新__setitem__的内容,从而引入错误。
第二个原因是,super实现了处理具有多重继承的类层次结构的逻辑。我们将在“多重继承和方法解析顺序”中回顾这一点。为了总结这个关于super的复习,回顾一下在 Python 2 中我们如何调用它,因为旧的带有两个参数的签名是具有启发性的:
class LastUpdatedOrderedDict(OrderedDict):
"""This code works in Python 2 and Python 3"""
def __setitem__(self, key, value):
super(LastUpdatedOrderedDict, self).__setitem__(key, value)
self.move_to_end(key)
现在super的两个参数都是可选的。Python 3 字节码编译器在调用方法中的super()时会自动检查周围的上下文并提供这些参数。这些参数是:
type
实现所需方法的超类的搜索路径的起始位置。默认情况下,它是包含super()调用的方法所属的类。
object_or_type
对象(例如方法调用)或类(例如类方法调用)作为方法调用的接收者。默认情况下,如果super()调用发生在实例方法中,接收者就是self。
无论是你还是编译器提供这些参数,super()调用都会返回一个动态代理对象,该对象会在type参数的超类中找到一个方法(例如示例中的__setitem__),并将其绑定到object_or_type,这样在调用方法时就不需要显式传递接收者(self)了。
在 Python 3 中,你仍然可以显式提供super()的第一个和第二个参数。³ 但只有在特殊情况下才需要,例如跳过部分 MRO 进行测试或调试,或者解决超类中不希望的行为。
现在让我们讨论对内置类型进行子类化时的注意事项。
对内置类型进行子类化是棘手的
在 Python 的最早版本中,无法对list或dict等内置类型进行子类化。自 Python 2.2 起,虽然可以实现,但有一个重要的警告:内置类型的代码(用 C 编写)通常不会调用用户定义类中重写的方法。关于这个问题的一个简短描述可以在 PyPy 文档的“PyPy 和 CPython 之间的区别”部分中找到,“内置类型的子类”。
官方上,CPython 没有明确规定子类中重写的方法何时会被隐式调用或不会被调用。作为近似值,这些方法永远不会被同一对象的其他内置方法调用。例如,在
dict的子类中重写的__getitem__()不会被内置的get()方法调用。
示例 14-1 说明了这个问题。
示例 14-1. 我们对__setitem__的重写被内置dict的__init__和__update__方法所忽略。
>>> class DoppelDict(dict):
... def __setitem__(self, key, value):
... super().__setitem__(key, [value] * 2) # ①
...
>>> dd = DoppelDict(one=1) # ②
>>> dd
{'one': 1} >>> dd['two'] = 2 # ③
>>> dd
{'one': 1, 'two': [2, 2]} >>> dd.update(three=3) # ④
>>> dd
{'three': 3, 'one': 1, 'two': [2, 2]}
①
DoppelDict.__setitem__在存储时会复制值(没有好理由,只是为了有一个可见的效果)。它通过委托给超类来实现。
②
从dict继承的__init__方法明显忽略了__setitem__的重写:'one'的值没有复制。
③
[]操作符调用我们的__setitem__,并按预期工作:'two'映射到重复的值[2, 2]。
④
dict的update方法也没有使用我们的__setitem__版本:'three'的值没有被复制。
这种内置行为违反了面向对象编程的一个基本规则:方法的搜索应始终从接收者的类(self)开始,即使调用发生在一个由超类实现的方法内部。这就是所谓的“后期绑定”,Smalltalk 之父 Alan Kay 认为这是面向对象编程的一个关键特性:在任何形式为x.method()的调用中,要调用的确切方法必须在运行时确定,基于接收者x的类。⁴ 这种令人沮丧的情况导致了我们在“标准库中 missing 的不一致使用”中看到的问题。
问题不仅限于实例内的调用——无论self.get()是否调用self.__getitem__()——还会发生在其他类的覆盖方法被内置方法调用时。示例 14-2 改编自PyPy 文档。
示例 14-2. AnswerDict的__getitem__被dict.update绕过。
>>> class AnswerDict(dict):
... def __getitem__(self, key): # ①
... return 42
...
>>> ad = AnswerDict(a='foo') # ②
>>> ad['a'] # ③
42 >>> d = {}
>>> d.update(ad) # ④
>>> d['a'] # ⑤
'foo' >>> d
{'a': 'foo'}
①
AnswerDict.__getitem__总是返回42,无论键是什么。
②
ad是一个加载了键-值对('a', 'foo')的AnswerDict。
③
ad['a']返回42,如预期。
④
d是一个普通dict的实例,我们用ad来更新它。
⑤
dict.update方法忽略了我们的AnswerDict.__getitem__。
警告
直接对dict、list或str等内置类型进行子类化是容易出错的,因为内置方法大多忽略用户定义的覆盖。不要对内置类型进行子类化,而是从collections模块派生你的类,使用UserDict、UserList和UserString,这些类设计得易于扩展。
如果你继承collections.UserDict而不是dict,那么示例 14-1 和 14-2 中暴露的问题都会得到解决。请参见示例 14-3。
示例 14-3. DoppelDict2和AnswerDict2按预期工作,因为它们扩展了UserDict而不是dict。
>>> import collections
>>>
>>> class DoppelDict2(collections.UserDict):
... def __setitem__(self, key, value):
... super().__setitem__(key, [value] * 2)
...
>>> dd = DoppelDict2(one=1)
>>> dd
{'one': [1, 1]}
>>> dd['two'] = 2
>>> dd
{'two': [2, 2], 'one': [1, 1]}
>>> dd.update(three=3)
>>> dd
{'two': [2, 2], 'three': [3, 3], 'one': [1, 1]}
>>>
>>> class AnswerDict2(collections.UserDict):
... def __getitem__(self, key):
... return 42
...
>>> ad = AnswerDict2(a='foo')
>>> ad['a']
42
>>> d = {}
>>> d.update(ad)
>>> d['a']
42
>>> d
{'a': 42}
为了衡量子类化内置类型所需的额外工作,我将示例 3-9 中的StrKeyDict类重写为子类化dict而不是UserDict。为了使其通过相同的测试套件,我不得不实现__init__、get和update,因为从dict继承的版本拒绝与覆盖的__missing__、__contains__和__setitem__合作。UserDict子类从示例 3-9 开始有 16 行,而实验性的dict子类最终有 33 行。⁵
明确一点:本节只涉及内置类型的 C 语言代码中方法委托的问题,只影响直接从这些类型派生的类。如果你子类化了一个用 Python 编写的基类,比如UserDict或MutableMapping,你就不会受到这个问题的困扰。⁶
现在让我们关注一个在多重继承中出现的问题:如果一个类有两个超类,当我们调用super().attr时,Python 如何决定使用哪个属性,但两个超类都有同名属性?
多重继承和方法解析顺序
任何实现多重继承的语言都需要处理当超类实现同名方法时可能出现的命名冲突。这称为“菱形问题”,在图 14-1 和示例 14-4 中有所说明。

图 14-1. 左:leaf1.ping()调用的激活顺序。右:leaf1.pong()调用的激活顺序。
示例 14-4. diamond.py:类Leaf、A、B、Root形成了图 14-1 中的图形
class Root: # ①
def ping(self):
print(f'{self}.ping() in Root')
def pong(self):
print(f'{self}.pong() in Root')
def __repr__(self):
cls_name = type(self).__name__
return f'<instance of {cls_name}>'
class A(Root): # ②
def ping(self):
print(f'{self}.ping() in A')
super().ping()
def pong(self):
print(f'{self}.pong() in A')
super().pong()
class B(Root): # ③
def ping(self):
print(f'{self}.ping() in B')
super().ping()
def pong(self):
print(f'{self}.pong() in B')
class Leaf(A, B): # ④
def ping(self):
print(f'{self}.ping() in Leaf')
super().ping()
①
Root提供ping、pong和__repr__以使输出更易于阅读。
②
类A中的ping和pong方法都调用了super()。
③
类B中只有ping方法调用了super()。
④
类Leaf只实现了ping,并调用了super()。
现在让我们看看在Leaf的实例上调用ping和pong方法的效果(示例 14-5)。
示例 14-5. 在Leaf对象上调用ping和pong的文档测试
>>> leaf1 = Leaf() # ①
>>> leaf1.ping() # ②
<instance of Leaf>.ping() in Leaf
<instance of Leaf>.ping() in A
<instance of Leaf>.ping() in B
<instance of Leaf>.ping() in Root
>>> leaf1.pong() # ③
<instance of Leaf>.pong() in A
<instance of Leaf>.pong() in B
①
leaf1是Leaf的一个实例。
②
调用leaf1.ping()会激活Leaf、A、B和Root中的ping方法,因为前三个类中的ping方法都调用了super().ping()。
③
调用leaf1.pong()通过继承激活了A中的pong,然后调用super.pong(),激活了B.pong。
示例 14-5 和图 14-1 中显示的激活顺序由两个因素决定:
-
Leaf类的方法解析顺序。 -
每个方法中使用
super()
每个类都有一个名为__mro__的属性,其中包含一个指向超类的元组,按照方法解析顺序排列,从当前类一直到object类。⁷ 对于Leaf类,__mro__如下:
>>> Leaf.__mro__ # doctest:+NORMALIZE_WHITESPACE
(<class 'diamond1.Leaf'>, <class 'diamond1.A'>, <class 'diamond1.B'>,
<class 'diamond1.Root'>, <class 'object'>)
注意
查看图 14-1,您可能会认为 MRO 描述了一种广度优先搜索,但这只是对于特定类层次结构的一个巧合。 MRO 由一个名为 C3 的已发布算法计算。其在 Python 中的使用详细介绍在 Michele Simionato 的“Python 2.3 方法解析顺序”中。这是一篇具有挑战性的阅读,但 Simionato 写道:“除非您大量使用多重继承并且具有非平凡的层次结构,否则您不需要理解 C3 算法,您可以轻松跳过本文。”
MRO 仅确定激活顺序,但每个类中的特定方法是否激活取决于每个实现是否调用了super()。
考虑使用pong方法的实验。Leaf类没有对其进行覆盖,因此调用leaf1.pong()会通过继承激活Leaf.__mro__的下一个类中的实现:A类。方法A.pong调用super().pong()。接下来是 MRO 中的B类,因此激活B.pong。但是该方法不调用super().pong(),因此激活顺序到此结束。
MRO 不仅考虑继承图,还考虑超类在子类声明中列出的顺序。换句话说,如果在diamond.py(示例 14-4)中Leaf类声明为Leaf(B, A),那么类B会在Leaf.__mro__中出现在A之前。这会影响ping方法的激活顺序,并且会导致leaf1.pong()通过继承激活B.pong,但A.pong和Root.pong永远不会运行,因为B.pong不调用super()。
当一个方法调用super()时,它是一个合作方法。合作方法实现合作多重继承。这些术语是有意的:为了工作,Python 中的多重继承需要涉及方法的积极合作。在B类中,ping进行合作,但pong不进行合作。
警告
一个非合作方法可能导致微妙的错误。许多编码者阅读示例 14-4 时可能期望当方法A.pong调用super.pong()时,最终会激活Root.pong。但如果B.pong在之前激活,那就会出错。这就是为什么建议每个非根类的方法m都应该调用super().m()。
合作方法必须具有兼容的签名,因为你永远不知道A.ping是在B.ping之前还是之后调用的。激活顺序取决于每个同时继承两者的子类声明中A和B的顺序。
Python 是一种动态语言,因此super()与 MRO 的交互也是动态的。示例 14-6 展示了这种动态行为的一个令人惊讶的结果。
示例 14-6。diamond2.py:演示super()动态性质的类
from diamond import A # ①
class U(): # ②
def ping(self):
print(f'{self}.ping() in U')
super().ping() # ③
class LeafUA(U, A): # ④
def ping(self):
print(f'{self}.ping() in LeafUA')
super().ping()
①
类A来自diamond.py(示例 14-4)。
②
类U与diamond模块中的A或Root无关。
③
super().ping()做什么?答案:这取决于情况。继续阅读。
④
LeafUA按照这个顺序子类化U和A。
如果你创建一个U的实例并尝试调用ping,你会得到一个错误:
>>> u = U()
>>> u.ping()
Traceback (most recent call last):
...
AttributeError: 'super' object has no attribute 'ping'
super()返回的'super'对象没有属性'ping',因为U的 MRO 有两个类:U和object,而后者没有名为'ping'的属性。
然而,U.ping方法并非完全没有希望。看看这个:
>>> leaf2 = LeafUA()
>>> leaf2.ping()
<instance of LeafUA>.ping() in LeafUA
<instance of LeafUA>.ping() in U
<instance of LeafUA>.ping() in A
<instance of LeafUA>.ping() in Root
>>> LeafUA.__mro__ # doctest:+NORMALIZE_WHITESPACE
(<class 'diamond2.LeafUA'>, <class 'diamond2.U'>,
<class 'diamond.A'>, <class 'diamond.Root'>, <class 'object'>)
LeafUA中的super().ping()调用激活U.ping,后者通过调用super().ping()也进行合作,激活A.ping,最终激活Root.ping。
注意LeafUA的基类是(U, A),按照这个顺序。如果基类是(A, U),那么leaf2.ping()永远不会到达U.ping,因为A.ping中的super().ping()会激活Root.ping,而该方法不调用super()。
在一个真实的程序中,类似U的类可能是一个mixin 类:一个旨在与多重继承中的其他类一起使用,以提供额外功能的类。我们将很快学习这个,在“Mixin Classes”中。
总结一下关于 MRO 的讨论,图 14-2 展示了 Python 标准库中 Tkinter GUI 工具包复杂多重继承图的一部分。

图 14-2。左:Tkinter Text小部件类及其超类的 UML 图。右:Text.__mro__的漫长曲折路径用虚线箭头绘制。
要研究图片,请从底部的Text类开始。Text类实现了一个功能齐全的、多行可编辑的文本小部件。它本身提供了丰富的功能,但也继承了许多其他类的方法。左侧显示了一个简单的 UML 类图。右侧用箭头装饰,显示了 MRO,如示例 14-7 中列出的,借助print_mro便利函数。
示例 14-7. tkinter.Text的 MRO
>>> def print_mro(cls):
... print(', '.join(c.__name__ for c in cls.__mro__))
>>> import tkinter
>>> print_mro(tkinter.Text)
Text, Widget, BaseWidget, Misc, Pack, Place, Grid, XView, YView, object
现在让我们谈谈混入。
混入类
混入类设计为与至少一个其他类一起在多重继承安排中被子类化。混入不应该是具体类的唯一基类,因为它不为具体对象提供所有功能,而只是添加或自定义子类或兄弟类的行为。
注意
混入类是 Python 和 C++中没有明确语言支持的约定。Ruby 允许明确定义和使用作为混入的模块——一组方法,可以包含以添加功能到类。C#、PHP 和 Rust 实现了特征,这也是混入的一种明确形式。
让我们看一个简单但方便的混入类的示例。
不区分大小写的映射
示例 14-8 展示了UpperCaseMixin,一个设计用于提供对具有字符串键的映射进行不区分大小写访问的类,通过在添加或查找这些键时将它们大写。
示例 14-8. uppermixin.py:UpperCaseMixin支持不区分大小写的映射
import collections
def _upper(key): # ①
try:
return key.upper()
except AttributeError:
return key
class UpperCaseMixin: # ②
def __setitem__(self, key, item):
super().__setitem__(_upper(key), item)
def __getitem__(self, key):
return super().__getitem__(_upper(key))
def get(self, key, default=None):
return super().get(_upper(key), default)
def __contains__(self, key):
return super().__contains__(_upper(key))
①
这个辅助函数接受任何类型的key,并尝试返回key.upper();如果失败,则返回未更改的key。
②
这个混入实现了映射的四个基本方法,总是调用super(),如果可能的话,将key大写。
由于UpperCaseMixin的每个方法都调用super(),这个混入取决于一个实现或继承具有相同签名方法的兄弟类。为了发挥其作用,混入通常需要出现在使用它的子类的 MRO 中的其他类之前。实际上,这意味着混入必须首先出现在类声明中基类元组中。示例 14-9 展示了两个示例。
示例 14-9. uppermixin.py:使用UpperCaseMixin的两个类
class UpperDict(UpperCaseMixin, collections.UserDict): # ①
pass
class UpperCounter(UpperCaseMixin, collections.Counter): # ②
"""Specialized 'Counter' that uppercases string keys""" # ③
①
UpperDict不需要自己的实现,但UpperCaseMixin必须是第一个基类,否则将调用UserDict的方法。
②
UpperCaseMixin也适用于Counter。
③
不要使用pass,最好提供一个文档字符串来满足class语句语法中需要主体的需求。
这里是uppermixin.py中的一些 doctests,用于UpperDict:
>>> d = UpperDict([('a', 'letter A'), (2, 'digit two')])
>>> list(d.keys())
['A', 2]
>>> d['b'] = 'letter B'
>>> 'b' in d
True
>>> d['a'], d.get('B')
('letter A', 'letter B')
>>> list(d.keys())
['A', 2, 'B']
还有一个关于UpperCounter的快速演示:
>>> c = UpperCounter('BaNanA')
>>> c.most_common()
[('A', 3), ('N', 2), ('B', 1)]
UpperDict和UpperCounter看起来几乎像是魔法,但我不得不仔细研究UserDict和Counter的代码,以使UpperCaseMixin与它们一起工作。
例如,我的第一个版本的UpperCaseMixin没有提供get方法。那个版本可以与UserDict一起工作,但不能与Counter一起工作。UserDict类继承了collections.abc.Mapping的get方法,而该get方法调用了我实现的__getitem__。但是,当UpperCounter加载到__init__时,键并没有大写。这是因为Counter.__init__使用了Counter.update,而Counter.update又依赖于从dict继承的get方法。然而,dict类中的get方法并不调用__getitem__。这是在“标准库中 missing 的不一致使用”中讨论的问题的核心。这也是对利用继承的程序的脆弱和令人困惑的本质的鲜明提醒,即使在小规模上也是如此。
下一节将涵盖多个多重继承的示例,通常包括 Mixin 类。
现实世界中的多重继承
在《设计模式》一书中,⁸几乎所有的代码都是用 C++ 编写的,但多重继承的唯一示例是适配器模式。在 Python 中,多重继承也不是常态,但有一些重要的例子我将在本节中评论。
ABCs 也是 Mixins
在 Python 标准库中,最明显的多重继承用法是collections.abc包。这并不具有争议性:毕竟,即使是 Java 也支持接口的多重继承,而 ABCs 是接口声明,可以选择性地提供具体方法实现。⁹
Python 官方文档中对collections.abc使用术语mixin 方法来表示许多集合 ABCs 中实现的具体方法。提供 mixin 方法的 ABCs 扮演两个角色:它们是接口定义,也是 mixin 类。例如,collections.UserDict的实现依赖于collections.abc.MutableMapping提供的几个 mixin 方法。
ThreadingMixIn 和 ForkingMixIn
http.server包提供了HTTPServer和ThreadingHTTPServer类。后者是在 Python 3.7 中添加的。其文档中说:
类http.server.ThreadingHTTPServer(server_address, RequestHandlerClass)
这个类与HTTPServer相同,但使用线程来处理请求,使用了ThreadingMixIn。这对于处理预先打开套接字的网络浏览器非常有用,对于这些套接字,HTTPServer将无限期等待。
这是 Python 3.10 中ThreadingHTTPServer类的完整源代码:
class ThreadingHTTPServer(socketserver.ThreadingMixIn, HTTPServer):
daemon_threads = True
socketserver.ThreadingMixIn的源代码有 38 行,包括注释和文档字符串。示例 14-10 展示了其实现的摘要。
示例 14-10. Python 3.10 中 Lib/socketserver.py 的一部分
class ThreadingMixIn:
"""Mixin class to handle each request in a new thread."""
# 8 lines omitted in book listing
def process_request_thread(self, request, client_address): # ①
... # 6 lines omitted in book listing
def process_request(self, request, client_address): # ②
... # 8 lines omitted in book listing
def server_close(self): # ③
super().server_close()
self._threads.join()
①
process_request_thread 不调用super(),因为它是一个新方法,而不是一个覆盖。它的实现调用了HTTPServer提供或继承的三个实例方法。
②
这覆盖了HTTPServer从socketserver.BaseServer继承的process_request方法,启动一个线程,并将实际工作委托给在该线程中运行的process_request_thread。它不调用super()。
③
server_close 调用super().server_close()停止接受请求,然后等待process_request启动的线程完成其工作。
ThreadingMixIn出现在socketserver模块文档中,旁边是ForkingMixin。后者旨在支持基于os.fork()的并发服务器,这是一种在符合POSIX的类 Unix 系统中启动子进程的 API。
Django 通用视图混合类
注意
您不需要了解 Django 才能阅读本节。我使用框架的一小部分作为多重继承的实际示例,并将尽力提供所有必要的背景知识,假设您在任何语言或框架中具有一些服务器端 Web 开发经验。
在 Django 中,视图是一个可调用对象,接受一个request参数——代表一个 HTTP 请求的对象,并返回一个代表 HTTP 响应的对象。我们在这里讨论的是不同的响应。它们可以是简单的重定向响应,没有内容主体,也可以是一个在线商店中的目录页面,从 HTML 模板渲染并列出多个商品,带有购买按钮和到详细页面的链接。
最初,Django 提供了一组称为通用视图的函数,实现了一些常见用例。例如,许多站点需要显示包含来自多个项目的信息的搜索结果,列表跨越多个页面,对于每个项目,都有一个链接到包含有关其详细信息的页面。在 Django 中,列表视图和详细视图被设计为一起解决这个问题:列表视图呈现搜索结果,详细视图为每个单独项目生成一个页面。
然而,最初的通用视图是函数,因此它们是不可扩展的。如果您需要做类似但不完全像通用列表视图的事情,您将不得不从头开始。
类视图的概念是在 Django 1.3 中引入的,连同一组通用视图类,组织为基类、混合类和可直接使用的具体类。在 Django 3.2 中,基类和混合类位于django.views.generic包的base模块中,如图 14-3 所示。在图表的顶部,我们看到两个负责非常不同职责的类:View和TemplateResponseMixin。

图 14-3. django.views.generic.base模块的 UML 类图。
提示
学习这些类的一个很好的资源是Classy Class-Based Views网站,您可以轻松浏览它们,查看每个类中的所有方法(继承的、重写的和添加的方法),查看图表,浏览它们的文档,并跳转到它们在 GitHub 上的源代码。
View是所有视图的基类(它可以是 ABC),它提供核心功能,如dispatch方法,该方法委托给具体子类实现的“处理程序”方法,如get、head、post等,以处理不同的 HTTP 动词。¹⁰ RedirectView类仅继承自View,您可以看到它实现了get、head、post等。
View的具体子类应该实现处理程序方法,那么为什么这些方法不是View接口的一部分呢?原因是:子类可以自由地实现它们想要支持的处理程序。TemplateView仅用于显示内容,因此它只实现get。如果向TemplateView发送 HTTP POST请求,继承的View.dispatch方法会检查是否存在post处理程序,并生成 HTTP 405 Method Not Allowed响应。¹¹
TemplateResponseMixin提供的功能只对需要使用模板的视图感兴趣。例如,RedirectView没有内容主体,因此不需要模板,也不继承自此混合类。TemplateResponseMixin为TemplateView和其他模板渲染视图提供行为,如ListView、DetailView等,定义在django.views.generic子包中。图 14-4 描述了django.views.generic.list模块和base模块的部分。
对于 Django 用户来说,图 14-4 中最重要的类是ListView,它是一个聚合类,没有任何代码(其主体只是一个文档字符串)。 当实例化时,ListView 通过object_list实例属性具有一个可迭代的对象,模板可以通过它来显示页面内容,通常是数据库查询返回多个对象的结果。 生成这些对象的可迭代对象的所有功能来自MultipleObjectMixin。 该混合类还提供了复杂的分页逻辑——在一个页面中显示部分结果和链接到更多页面。
假设您想创建一个不会呈现模板,但会以 JSON 格式生成对象列表的视图。 这就是BaseListView的存在。 它提供了一个易于使用的扩展点,将View和MultipleObjectMixin功能结合在一起,而不需要模板机制的开销。
Django 基于类的视图 API 是多重继承的一个更好的例子,比 Tkinter 更好。 特别是,很容易理解其混合类:每个混合类都有一个明确定义的目的,并且它们都以…Mixin后缀命名。

图 14-4. django.views.generic.list 模块的 UML 类图。 这里基本模块的三个类已经折叠(参见图 14-3)。 ListView 类没有方法或属性:它是一个聚合类。
基于类的视图并不被 Django 用户普遍接受。许多人以一种有限的方式使用它们,作为不透明的盒子,但当需要创建新东西时,许多 Django 程序员继续编写处理所有这些责任的单片视图函数,而不是尝试重用基本视图和混合类。
学习如何利用基于类的视图以及如何扩展它们以满足特定应用程序需求确实需要一些时间,但我发现研究它们是值得的。 它们消除了大量样板代码,使得重用解决方案更容易,甚至改善了团队沟通——例如,通过定义模板的标准名称,以及传递给模板上下文的变量。 基于类的视图是 Django 视图的“轨道”。
Tkinter 中的多重继承
Python 标准库中多重继承的一个极端例子是Tkinter GUI 工具包。 我使用了 Tkinter 小部件层次结构的一部分来说明图 14-2 中的 MRO。 图 14-5 显示了tkinter基础包中的所有小部件类(tkinter.ttk子包中有更多小部件)。

图 14-5. Tkinter GUI 类层次结构的摘要 UML 图;标记为«mixin»的类旨在通过多重继承为其他类提供具体方法。
当我写这篇文章时,Tkinter 已经有 25 年的历史了。 它并不是当前最佳实践的例子。 但它展示了当编码人员不欣赏其缺点时如何使用多重继承。 当我们在下一节讨论一些良好实践时,它将作为一个反例。
请考虑来自图 14-5 的这些类:
➊ Toplevel:Tkinter 应用程序中顶级窗口的类。
➋ Widget:可以放置在窗口上的每个可见对象的超类。
➌ Button:一个普通的按钮小部件。
➍ Entry:一个单行可编辑文本字段。
➎ Text:一个多行可编辑文本字段。
这些类的 MRO 是由print_mro函数显示的,该函数来自示例 14-7:
>>> import tkinter
>>> print_mro(tkinter.Toplevel)
Toplevel, BaseWidget, Misc, Wm, object
>>> print_mro(tkinter.Widget)
Widget, BaseWidget, Misc, Pack, Place, Grid, object
>>> print_mro(tkinter.Button)
Button, Widget, BaseWidget, Misc, Pack, Place, Grid, object
>>> print_mro(tkinter.Entry)
Entry, Widget, BaseWidget, Misc, Pack, Place, Grid, XView, object
>>> print_mro(tkinter.Text)
Text, Widget, BaseWidget, Misc, Pack, Place, Grid, XView, YView, object
注意
按照当前标准,Tkinter 的类层次结构非常深。Python 标准库的很少部分具有超过三到四级的具体类,Java 类库也是如此。然而,有趣的是,Java 类库中一些最深层次的层次结构恰好是与 GUI 编程相关的包:java.awt 和 javax.swing。现代、免费的 Smalltalk 版本 Squeak 包括功能强大且创新的 Morphic GUI 工具包,同样具有深层次的类层次结构。根据我的经验,GUI 工具包是继承最有用的地方。
注意这些类与其他类的关系:
-
Toplevel是唯一一个不从Widget继承的图形类,因为它是顶层窗口,不像一个 widget 那样行为;例如,它不能附加到窗口或框架。Toplevel从Wm继承,提供了主机窗口管理器的直接访问函数,如设置窗口标题和配置其边框。 -
Widget直接从BaseWidget和Pack、Place、Grid继承。这三个类是几何管理器:它们负责在窗口或框架内排列小部件。每个类封装了不同的布局策略和小部件放置 API。 -
Button,像大多数小部件一样,只从Widget继承,但间接从Misc继承,为每个小部件提供了数十种方法。 -
Entry从支持水平滚动的Widget和XView继承。 -
Text从Widget、XView和YView继承以支持垂直滚动。
现在我们将讨论一些多重继承的良好实践,并看看 Tkinter 是否符合这些实践。
处理继承
阿兰·凯在前言中写道:关于继承还没有能够指导实践程序员的一般理论。我们拥有的是经验法则、设计模式、“最佳实践”、巧妙的首字母缩写、禁忌等。其中一些提供了有用的指导方针,但没有一个是普遍接受的或总是适用的。
使用继承很容易创建难以理解和脆弱的设计,即使没有多重继承。由于我们没有一个全面的理论,这里有一些避免意大利面式类图的提示。
偏向对象组合而不是类继承
这个小节的标题是《设计模式》书中的面向对象设计的第二原则¹²,也是我在这里能提供的最好建议。一旦你熟悉了继承,就很容易过度使用它。将对象放在一个整洁的层次结构中符合我们的秩序感;程序员只是为了好玩而这样做。
偏向组合会导致更灵活的设计。例如,在tkinter.Widget类的情况下,widget 实例可以持有对几何管理器的引用,并调用其方法,而不是从所有几何管理器继承方法。毕竟,一个Widget不应该“是”一个几何管理器,但可以通过委托使用其服务。然后,您可以添加一个新的几何管理器,而不必触及 widget 类层次结构,也不必担心名称冲突。即使在单一继承的情况下,这个原则也增强了灵活性,因为子类化是一种紧密耦合的形式,而高继承树往往是脆弱的。
组合和委托可以替代使用 Mixin 使行为可用于不同类,但不能替代使用接口继承来定义类型层次结构。
理解每种情况下为何使用继承
处理多重继承时,有必要清楚地了解在每种特定情况下为何进行子类化。主要原因包括:
-
接口继承创建一个子类型,暗示一个“是一个”关系。这最好通过 ABCs 完成。
-
实现的继承避免了代码重复使用。Mixin 可以帮助实现这一点。
在实践中,这两种用法通常同时存在,但只要您能清楚地表达意图,就应该这样做。继承用于代码重用是一个实现细节,它经常可以被组合和委托替代。另一方面,接口继承是框架的支柱。接口继承应尽可能只使用 ABC 作为基类。
使用 ABC 明确接口
在现代 Python 中,如果一个类旨在定义一个接口,它应该是一个明确的 ABC 或typing.Protocol子类。ABC 应该仅从abc.ABC或其他 ABC 继承。多重继承 ABC 并不成问题。
使用明确的混合类进行代码重用
如果一个类旨在为多个不相关的子类提供方法实现以供重用,而不意味着“是一个”关系,则应该是一个明确的混合类。从概念上讲,混合类不定义新类型;它只是捆绑方法以供重用。混合类不应该被实例化,具体类不应该仅从混合类继承。每个混合类应提供一个特定的行为,实现少量且非常相关的方法。混合类应避免保留任何内部状态;即混合类不应具有实例属性。
在 Python 中没有正式的方法来声明一个类是混合类,因此强烈建议它们以Mixin后缀命名。
为用户提供聚合类
主要通过从混合项继承而构建的类,不添加自己的结构或行为,被称为聚合类。
Booch 等人¹³
如果某些 ABC 或混合类的组合对客户端代码特别有用,请提供一个以合理方式将它们组合在一起的类。
例如,这里是 Django ListView类的完整源代码,位于图 14-4 右下角:
class ListView(MultipleObjectTemplateResponseMixin, BaseListView):
"""
Render some list of objects, set by `self.model` or `self.queryset`.
`self.queryset` can actually be any iterable of items, not just a queryset.
"""
ListView的主体是空的,但该类提供了一个有用的服务:它将一个混合类和一个应该一起使用的基类组合在一起。
另一个例子是tkinter.Widget,它有四个基类,没有自己的方法或属性,只有一个文档字符串。由于Widget聚合类,我们可以创建一个新的小部件,其中包含所需的混合项,而无需弄清楚它们应该以何种顺序声明才能按预期工作。
请注意,聚合类不一定要完全为空,但它们通常是。
只对设计为可子类化的类进行子类化
在关于本章的一条评论中,技术审阅员 Leonardo Rochael 提出了以下警告。
警告
由于超类方法可能以意想不到的方式忽略子类覆盖,因此从任何复杂类继承并覆盖其方法是容易出错的。尽可能避免覆盖方法,或者至少限制自己只继承易于扩展的类,并且只以设计为可扩展的方式进行扩展。
这是一个很好的建议,但我们如何知道一个类是否被设计为可扩展?
第一个答案是文档(有时以文档字符串或甚至代码注释的形式)。例如,Python 的socketserver包被描述为“一个网络服务器框架”。它的BaseServer类被设计为可子类化,正如其名称所示。更重要的是,类的文档和源代码中的文档字符串明确指出了哪些方法是打算由子类重写的。
在 Python ≥ 3.8 中,通过PEP 591—为类型添加 final 修饰符提供了一种明确制定这些设计约束的新方法。该 PEP 引入了一个@final装饰器,可应用于类或单独的方法,以便 IDE 或类型检查器可以报告误用尝试对这些类进行子类化或覆盖这些方法的情况。¹⁴
避免从具体类继承
从具体类进行子类化比从 ABC 和 mixin 进行子类化更危险,因为具体类的实例通常具有内部状态,当您覆盖依赖于该状态的方法时,很容易破坏该状态。即使您的方法通过调用super()来合作,并且内部状态是使用__x语法保存在私有属性中,仍然有无数种方法可以通过方法覆盖引入错误。
在“水禽和 ABC”中,Alex Martelli 引用了 Scott Meyer 的More Effective C++,其中说:“所有非叶类都应该是抽象的。”换句话说,Meyer 建议只有抽象类应该被子类化。
如果必须使用子类化进行代码重用,则应将用于重用的代码放在 ABC 的 mixin 方法中或明确命名的 mixin 类中。
我们现在将从这些建议的角度分析 Tkinter。
Tkinter:优点、缺点和丑闻
先前部分中的大多数建议在 Tkinter 中并未遵循,特别例外是“向用户提供聚合类”。即使如此,这也不是一个很好的例子,因为像在“更青睐对象组合而非类继承”中讨论的那样,将几何管理器集成到Widget中可能效果更好。
请记住,Tkinter 自 1994 年发布的 Python 1.1 起就是标准库的一部分。Tkinter 是建立在 Tcl 语言的出色 Tk GUI 工具包之上的一层。Tcl/Tk 组合最初并非面向对象,因此 Tk API 基本上是一个庞大的函数目录。但是,该工具包在设计上是面向对象的,尽管在其原始的 Tcl 实现中不是。
tkinter.Widget的文档字符串以“内部类”开头。这表明Widget可能应该是一个 ABC。尽管Widget没有自己的方法,但它确实定义了一个接口。它的含义是:“您可以依赖于每个 Tkinter 小部件提供基本小部件方法(__init__、destroy和数十个 Tk API 函数),以及所有三个几何管理器的方法。”我们可以同意这不是一个很好的接口定义(它太宽泛了),但它是一个接口,而Widget将其“定义”为其超类接口的并集。
Tk类封装了 GUI 应用程序逻辑,继承自Wm和Misc的两者都不是抽象或 mixin(Wm不是一个适当的 mixin,因为TopLevel仅从它继承)。Misc类的名称本身就是一个非常明显的代码异味。Misc有 100 多个方法,所有小部件都继承自它。为什么每个小部件都需要处理剪贴板、文本选择、定时器管理等方法?您实际上无法将内容粘贴到按钮中或从滚动条中选择文本。Misc应该拆分为几个专门的 mixin 类,并且不是所有小部件都应该从每个 mixin 类继承。
公平地说,作为 Tkinter 用户,您根本不需要了解或使用多重继承。这是隐藏在您将在自己的代码中实例化或子类化的小部件类背后的实现细节。但是,当您键入dir(tkinter.Button)并尝试在列出的 214 个属性中找到所需的方法时,您将遭受过多多重继承的后果。如果您决定实现一个新的 Tk 小部件,您将需要面对这种复杂性。
提示
尽管存在问题,Tkinter 是稳定的、灵活的,并且如果使用tkinter.ttk包及其主题小部件,提供现代外观和感觉。此外,一些原始小部件,如Canvas和Text,功能强大。你可以在几小时内将Canvas对象转换为简单的拖放绘图应用程序。如果你对 GUI 编程感兴趣,Tkinter 和 Tcl/Tk 绝对值得一看。
这标志着我们对继承迷宫的探索结束了。
章节总结
本章从单一继承的情况下对super()函数进行了回顾。然后我们讨论了子类化内置类型的问题:它们在 C 中实现的原生方法不会调用子类中的重写方法,除了极少数特殊情况。这就是为什么当我们需要自定义list、dict或str类型时,更容易子类化UserList、UserDict或UserString——它们都定义在collections模块中,实际上包装了相应的内置类型并将操作委托给它们——这是标准库中偏向组合而非继承的三个例子。如果期望的行为与内置提供的行为非常不同,可能更容易子类化collections.abc中的适当 ABC,并编写自己的实现。
本章的其余部分致力于多重继承的双刃剑。首先,我们看到了方法解析顺序,编码在__mro__类属性中,解决了继承方法中潜在命名冲突的问题。我们还看到了super()内置函数在具有多重继承的层次结构中的行为,有时会出乎意料。super()的行为旨在支持混入类,然后我们通过UpperCaseMixin对不区分大小写映射的简单示例进行了研究。
我们看到了多重继承和混入方法在 Python 的 ABCs 中的使用,以及在socketserver线程和分叉混入中的使用。更复杂的多重继承用法由 Django 的基于类的视图和 Tkinter GUI 工具包示例。尽管 Tkinter 不是现代最佳实践的例子,但它是我们可能在遗留系统中找到的过于复杂的类层次结构的例子。
为了结束本章,我提出了七条应对继承的建议,并在对 Tkinter 类层次结构的评论中应用了其中一些建议。
拒绝继承——甚至是单一继承——是一个现代趋势。21 世纪创建的最成功的语言之一是 Go。它没有名为“类”的构造,但你可以构建作为封装字段结构的类型,并且可以将方法附加到这些结构上。Go 允许定义接口,编译器使用结构化类型检查这些接口,即静态鸭子类型,与 Python 3.8 之后的协议类型非常相似。Go 有特殊的语法用于通过组合构建类型和接口,但它不支持继承——甚至不支持接口之间的继承。
所以也许关于继承的最佳建议是:如果可以的话,尽量避免。但通常情况下,我们没有选择:我们使用的框架会强加它们自己的设计选择。
进一步阅读
在阅读清晰度方面,适当的组合优于继承。由于代码更多地被阅读而不是被编写,一般情况下应避免子类化,尤其不要混合各种类型的继承,并且不要使用子类化进行代码共享。
Hynek Schlawack,《Python 中的子类化再探》
在这本书的最终审阅期间,技术审阅员 Jürgen Gmach 推荐了 Hynek Schlawack 的帖子“Subclassing in Python Redux”—前述引用的来源。Schlawack 是流行的attrs包的作者,并且是 Twisted 异步编程框架的核心贡献者,这是 Glyph Lefkowitz 于 2002 年发起的项目。随着时间的推移,核心团队意识到他们在设计中过度使用了子类化,根据 Schlawack 的说法。他的帖子很长,引用了其他重要的帖子和演讲。强烈推荐。
在同样的结论中,Hynek Schlawack 写道:“不要忘记,更多时候,一个函数就是你所需要的。”我同意,这正是为什么在类和继承之前,《Fluent Python》深入讲解函数的原因。我的目标是展示在创建自己的类之前,利用标准库中现有类可以实现多少功能。
Guido van Rossum 的论文“Unifying types and classes in Python 2.2”介绍了内置函数的子类型、super函数以及描述符和元类等高级特性。这些特性自那时以来并没有发生重大变化。Python 2.2 是语言演进的一个了不起的成就,添加了几个强大的新特性,形成了一个连贯的整体,而不会破坏向后兼容性。这些新特性是 100%选择性的。要使用它们,我们只需显式地子类化object——直接或间接地创建所谓的“新样式类”。在 Python 3 中,每个类都是object的子类。
David Beazley 和 Brian K. Jones(O’Reilly)的《Python Cookbook》,第三版(https://fpy.li/pycook3)中有几个示例展示了super()和 mixin 类的使用。你可以从启发性的部分“8.7. Calling a Method on a Parent Class”开始,然后从那里跟随内部引用。
Raymond Hettinger 的帖子“Python’s super() considered super!”从积极的角度解释了 Python 中super和多重继承的工作原理。这篇文章是作为对 James Knight 的“Python’s Super is nifty, but you can’t use it (Previously: Python’s Super Considered Harmful)”的回应而撰写的。Martijn Pieters 对“How to use super() with one argument?”的回应包括对super的简明而深入的解释,包括它与描述符的关系,这是我们只会在第二十三章中学习的概念。这就是super的本质。在基本用例中使用起来很简单,但它是一个强大且复杂的工具,涉及一些 Python 中最先进的动态特性,很少在其他语言中找到。
尽管这些帖子的标题是关于super内置函数的,但问题实际上并不是 Python 3 中不像 Python 2 那样丑陋的super内置函数。真正的问题在于多重继承,这本质上是复杂且棘手的。Michele Simionato 在他的“Setting Multiple Inheritance Straight”中不仅仅是批评,实际上还提出了一个解决方案:他实现了 traits,这是一种源自 Self 语言的明确形式的 mixin。Simionato 在 Python 中有一系列关于多重继承的博客文章,包括“The wonders of cooperative inheritance, or using super in Python 3”;“Mixins considered harmful,” part 1和part 2;以及“Things to Know About Python Super,” part 1、part 2和part 3。最早的帖子使用了 Python 2 的super语法,但仍然具有相关性。
我阅读了 Grady Booch 等人的第三版《面向对象的分析与设计》,强烈推荐它作为独立于编程语言的面向对象思维的通用入门书籍。这是一本罕见的涵盖多重继承而没有偏见的书籍。
现在比以往任何时候都更时尚地避免继承,所以这里有两个关于如何做到这一点的参考资料。Brandon Rhodes 写了 “组合优于继承原则”,这是他出色的 Python 设计模式 指南的一部分。Augie Fackler 和 Nathaniel Manista 在 PyCon 2013 上提出了 “对象继承的终结与新模块化的开始”。Fackler 和 Manista 谈到围绕接口和处理实现这些接口的对象的函数来组织系统,避免类和继承的紧密耦合和失败模式。这让我很想起 Go 的方式,但他们为 Python 提倡这种方式。
¹ Alan Kay, “Smalltalk 的早期历史”,发表于 SIGPLAN Not. 28, 3 (1993 年 3 月), 69–95. 也可在线获取 链接。感谢我的朋友 Christano Anderson,在我撰写本章时分享了这个参考资料。
² 我只修改了示例中的文档字符串,因为原文有误导性。它说:“按照键最后添加的顺序存储项目”,但这并不是明确命名的 LastUpdatedOrderedDict 所做的。
³ 也可以只提供第一个参数,但这并不实用,可能很快就会被弃用,Guido van Rossum 创建 super() 时也表示支持。请参见 “是时候废弃未绑定的 super 方法了吗?” 中的讨论。
⁴ 有趣的是,C++ 中有虚方法和非虚方法的概念。虚方法是晚期绑定的,但非虚方法在编译时绑定。尽管我们在 Python 中编写的每个方法都像虚方法一样晚期绑定,但用 C 编写的内置对象似乎默认具有非虚方法,至少在 CPython 中是这样。
⁵ 如果你感兴趣,实验在 14-inheritance/strkeydict_dictsub.py 文件中的 fluentpython/example-code-2e 仓库中。
⁶ 顺便说一句,在这方面,PyPy 的行为比 CPython 更“正确”,但代价是引入了一点不兼容性。详细信息请参见 “PyPy 和 CPython 之间的差异”。
⁷ 类还有一个 .mro() 方法,但那是元类编程的高级特性,提到了 “类作为对象”。在类的正常使用过程中,__mro__ 属性的内容才是重要的。
⁸ Erich Gamma, Richard Helm, Ralph Johnson, 和 John Vlissides,设计模式:可复用面向对象软件的元素 (Addison-Wesley)。
⁹ 正如之前提到的,Java 8 允许接口提供方法实现。这一新特性在官方 Java 教程中称为 “默认方法”。
¹⁰ Django 程序员知道 as_view 类方法是 View 接口中最显著的部分,但在这里对我们并不重要。
¹¹ 如果你对设计模式感兴趣,你会注意到 Django 的调度机制是 模板方法模式 的动态变体。它是动态的,因为 View 类不强制子类实现所有处理程序,而是 dispatch 在运行时检查是否为特定请求提供了具体处理程序。
¹² 这个原则出现在该书的引言第 20 页。
¹³ Grady Booch 等人,面向对象的分析与设计及应用,第 3 版 (Addison-Wesley),第 109 页。
¹⁴ PEP 591 还引入了一个Final注释,用于标记不应重新赋值或覆盖的变量或属性。
¹⁵ Alan Kay 在 SIGPLAN Not. 28, 3(1993 年 3 月)中写道:“Smalltalk 的早期历史”,69-95 页。也可在线查看链接。感谢我的朋友 Christiano Anderson,在我撰写本章时分享了这个参考资料。
¹⁶ 我的朋友和技术审阅员 Leonardo Rochael 解释得比我更好:“Perl 6 的持续存在,但始终未到来,使 Perl 本身的发展失去了意志力。现在 Perl 继续作为一个独立的语言进行开发(截至目前为止已经到了版本 5.34),没有因为曾经被称为 Perl 6 的语言而被废弃的阴影。”
第十五章:关于类型提示的更多内容
我学到了一个痛苦的教训,对于小程序来说,动态类型很棒。对于大型程序,你需要更加纪律严明的方法。如果语言给予你这种纪律,而不是告诉你“嗯,你可以做任何你想做的事情”,那会更有帮助。
Guido van Rossum,蒙提·派森的粉丝¹
本章是第八章的续集,涵盖了更多关于 Python 渐进类型系统的内容。主要议题包括:
-
重载函数签名
-
typing.TypedDict用于对作为记录使用的dicts进行类型提示 -
类型转换
-
运行时访问类型提示
-
通用类型
-
声明一个通用类
-
变异:不变、协变和逆变类型
-
通用静态协议
-
本章的新内容
本章是《流畅的 Python》第二版中的新内容。让我们从重载开始。
重载签名
Python 函数可以接受不同组合的参数。@typing.overload装饰器允许对这些不同组合进行注释。当函数的返回类型取决于两个或更多参数的类型时,这一点尤为重要。
考虑内置函数sum。这是help(sum)的文本:
>>> help(sum)
sum(iterable, /, start=0)
Return the sum of a 'start' value (default: 0) plus an iterable of numbers
When the iterable is empty, return the start value.
This function is intended specifically for use with numeric values and may
reject non-numeric types.
内置函数sum是用 C 编写的,但typeshed为其提供了重载类型提示,在builtins.pyi中有:
@overload
def sum(__iterable: Iterable[_T]) -> Union[_T, int]: ...
@overload
def sum(__iterable: Iterable[_T], start: _S) -> Union[_T, _S]: ...
首先让我们看看重载的整体语法。这是存根文件(.pyi)中关于sum的所有代码。实现将在另一个文件中。省略号(...)除了满足函数体的语法要求外没有其他作用,类似于pass。因此,.pyi文件是有效的 Python 文件。
正如在“注释位置参数和可变参数”中提到的,__iterable中的两个下划线是 PEP 484 对位置参数的约定,由 Mypy 强制执行。这意味着你可以调用sum(my_list),但不能调用sum(__iterable = my_list)。
类型检查器尝试将给定的参数与每个重载签名进行匹配,按顺序。调用sum(range(100), 1000)不匹配第一个重载,因为该签名只有一个参数。但它匹配第二个。
你也可以在普通的 Python 模块中使用@overload,只需在函数的实际签名和实现之前写上重载的签名即可。示例 15-1 展示了如何在 Python 模块中注释和实现sum。
示例 15-1。mysum.py:带有重载签名的sum函数的定义
import functools
import operator
from collections.abc import Iterable
from typing import overload, Union, TypeVar
T = TypeVar('T')
S = TypeVar('S') # ①
@overload
def sum(it: Iterable[T]) -> Union[T, int]: ... # ②
@overload
def sum(it: Iterable[T], /, start: S) -> Union[T, S]: ... # ③
def sum(it, /, start=0): # ④
return functools.reduce(operator.add, it, start)
①
我们在第二个重载中需要这第二个TypeVar。
②
这个签名是针对简单情况的:sum(my_iterable)。结果类型可能是T——my_iterable产生的元素的类型,或者如果可迭代对象为空,则可能是int,因为start参数的默认值是0。
③
当给定start时,它可以是任何类型S,因此结果类型是Union[T, S]。这就是为什么我们需要S。如果我们重用T,那么start的类型将必须与Iterable[T]的元素类型相同。
④
实际函数实现的签名没有类型提示。
这是为了注释一行函数而写的很多行代码。我知道这可能有点过头了。至少这不是一个foo函数。
如果你想通过阅读代码了解@overload,typeshed有数百个示例。在typeshed上,Python 内置函数的存根文件在我写这篇文章时有 186 个重载——比标准库中的任何其他函数都多。
利用渐进类型
追求 100% 的注释代码可能会导致添加大量噪音但很少价值的类型提示。简化类型提示以简化重构可能会导致繁琐的 API。有时最好是务实一些,让一段代码没有类型提示。
我们称之为 Pythonic 的方便 API 往往很难注释。在下一节中,我们将看到一个例子:需要六个重载才能正确注释灵活的内置 max 函数。
Max Overload
给利用 Python 强大动态特性的函数添加类型提示是困难的。
在研究 typeshed 时,我发现了 bug 报告 #4051:Mypy 没有警告说将 None 作为内置 max() 函数的参数之一是非法的,或者传递一个在某个时刻产生 None 的可迭代对象也是非法的。在任一情况下,你会得到像这样的运行时异常:
TypeError: '>' not supported between instances of 'int' and 'NoneType'
max 的文档以这句话开头:
返回可迭代对象中的最大项或两个或多个参数中的最大项。
对我来说,这是一个非常直观的描述。
但如果我必须为以这些术语描述的函数注释,我必须问:它是哪个?一个可迭代对象还是两个或更多参数?
实际情况更加复杂,因为 max 还接受两个可选关键字参数:key 和 default。
我在 Python 中编写了 max 来更容易地看到它的工作方式和重载注释之间的关系(内置的 max 是用 C 编写的);参见 Example 15-2。
Example 15-2. mymax.py:max 函数的 Python 重写
# imports and definitions omitted, see next listing
MISSING = object()
EMPTY_MSG = 'max() arg is an empty sequence'
# overloaded type hints omitted, see next listing
def max(first, *args, key=None, default=MISSING):
if args:
series = args
candidate = first
else:
series = iter(first)
try:
candidate = next(series)
except StopIteration:
if default is not MISSING:
return default
raise ValueError(EMPTY_MSG) from None
if key is None:
for current in series:
if candidate < current:
candidate = current
else:
candidate_key = key(candidate)
for current in series:
current_key = key(current)
if candidate_key < current_key:
candidate = current
candidate_key = current_key
return candidate
这个示例的重点不是 max 的逻辑,所以我不会花时间解释它的实现,除了解释 MISSING。MISSING 常量是一个用作哨兵的唯一 object 实例。它是 default= 关键字参数的默认值,这样 max 可以接受 default=None 并仍然区分这两种情况:
-
用户没有为
default=提供值,因此它是MISSING,如果first是一个空的可迭代对象,max将引发ValueError。 -
用户为
default=提供了一些值,包括None,因此如果first是一个空的可迭代对象,max将返回该值。
为了修复 问题 #4051,我写了 Example 15-3 中的代码。²
Example 15-3. mymax.py:模块顶部,包括导入、定义和重载
from collections.abc import Callable, Iterable
from typing import Protocol, Any, TypeVar, overload, Union
class SupportsLessThan(Protocol):
def __lt__(self, other: Any) -> bool: ...
T = TypeVar('T')
LT = TypeVar('LT', bound=SupportsLessThan)
DT = TypeVar('DT')
MISSING = object()
EMPTY_MSG = 'max() arg is an empty sequence'
@overload
def max(__arg1: LT, __arg2: LT, *args: LT, key: None = ...) -> LT:
...
@overload
def max(__arg1: T, __arg2: T, *args: T, key: Callable[[T], LT]) -> T:
...
@overload
def max(__iterable: Iterable[LT], *, key: None = ...) -> LT:
...
@overload
def max(__iterable: Iterable[T], *, key: Callable[[T], LT]) -> T:
...
@overload
def max(__iterable: Iterable[LT], *, key: None = ...,
default: DT) -> Union[LT, DT]:
...
@overload
def max(__iterable: Iterable[T], *, key: Callable[[T], LT],
default: DT) -> Union[T, DT]:
...
我的 Python 实现的 max 与所有那些类型导入和声明的长度大致相同。由于鸭子类型,我的代码没有 isinstance 检查,并且提供了与那些类型提示相同的错误检查,但当然只在运行时。
@overload 的一个关键优势是尽可能精确地声明返回类型,根据给定的参数类型。我们将通过逐组一到两个地研究max的重载来看到这个优势。
实现了 SupportsLessThan 的参数,但未提供 key 和 default
@overload
def max(__arg1: LT, __arg2: LT, *_args: LT, key: None = ...) -> LT:
...
# ... lines omitted ...
@overload
def max(__iterable: Iterable[LT], *, key: None = ...) -> LT:
...
在这些情况下,输入要么是实现了 SupportsLessThan 的类型 LT 的单独参数,要么是这些项目的 Iterable。max 的返回类型与实际参数或项目相同,正如我们在 “Bounded TypeVar” 中看到的。
符合这些重载的示例调用:
max(1, 2, -3) # returns 2
max(['Go', 'Python', 'Rust']) # returns 'Rust'
提供了 key 参数,但没有提供 default
@overload
def max(__arg1: T, __arg2: T, *_args: T, key: Callable[[T], LT]) -> T:
...
# ... lines omitted ...
@overload
def max(__iterable: Iterable[T], *, key: Callable[[T], LT]) -> T:
...
输入可以是任何类型 T 的单独项目或单个 Iterable[T],key= 必须是一个接受相同类型 T 的参数并返回一个实现 SupportsLessThan 的值的可调用对象。max 的返回类型与实际参数相同。
符合这些重载的示例调用:
max(1, 2, -3, key=abs) # returns -3
max(['Go', 'Python', 'Rust'], key=len) # returns 'Python'
提供了 default 参数,但没有 key
@overload
def max(__iterable: Iterable[LT], *, key: None = ...,
default: DT) -> Union[LT, DT]:
...
输入是一个实现 SupportsLessThan 的类型 LT 的项目的可迭代对象。default= 参数是当 Iterable 为空时的返回值。因此,max 的返回类型必须是 LT 类型和 default 参数类型的 Union。
符合这些重载的示例调用:
max([1, 2, -3], default=0) # returns 2
max([], default=None) # returns None
提供了 key 和 default 参数
@overload
def max(__iterable: Iterable[T], *, key: Callable[[T], LT],
default: DT) -> Union[T, DT]:
...
输入是:
-
任何类型
T的项目的可迭代对象 -
接受类型为
T的参数并返回实现SupportsLessThan的类型LT的值的可调用函数 -
任何类型
DT的默认值
max的返回类型必须是类型T或default参数的类型的Union:
max([1, 2, -3], key=abs, default=None) # returns -3
max([], key=abs, default=None) # returns None
从重载max中得到的经验教训
类型提示允许 Mypy 标记像max([None, None])这样的调用,并显示以下错误消息:
mymax_demo.py:109: error: Value of type variable "_LT" of "max"
cannot be "None"
另一方面,为了维持类型检查器而写这么多行可能会阻止人们编写方便灵活的函数,如max。如果我不得不重新发明min函数,我可以重构并重用大部分max的实现。但我必须复制并粘贴所有重载的声明——尽管它们对于min来说是相同的,除了函数名称。
我的朋友 João S. O. Bueno——我认识的最聪明的 Python 开发者之一——在推特上发表了这篇推文:
尽管很难表达
max的签名——但它很容易理解。我理解的是,与 Python 相比,注释标记的表现力非常有限。
现在让我们来研究TypedDict类型构造。一开始我认为它并不像我想象的那么有用,但它有其用途。尝试使用TypedDict来处理动态结构(如 JSON 数据)展示了静态类型处理的局限性。
TypedDict
警告
使用TypedDict来保护处理动态数据结构(如 JSON API 响应)中的错误是很诱人的。但这里的示例清楚地表明,对 JSON 的正确处理必须在运行时完成,而不是通过静态类型检查。要使用类型提示对类似 JSON 的结构进行运行时检查,请查看 PyPI 上的pydantic包。
Python 字典有时被用作记录,其中键用作字段名称,不同类型的字段值。
例如,考虑描述 JSON 或 Python 中的一本书的记录:
{"isbn": "0134757599",
"title": "Refactoring, 2e",
"authors": ["Martin Fowler", "Kent Beck"],
"pagecount": 478}
在 Python 3.8 之前,没有很好的方法来注释这样的记录,因为我们在“通用映射”中看到的映射类型限制所有值具有相同的类型。
这里有两个尴尬的尝试来注释类似前述 JSON 对象的记录:
Dict[str, Any]
值可以是任何类型。
Dict[str, Union[str, int, List[str]]]
难以阅读,并且不保留字段名称和其相应字段类型之间的关系:title应该是一个str,不能是一个int或List[str]。
PEP 589—TypedDict: 具有固定键集的字典的类型提示解决了这个问题。示例 15-4 展示了一个简单的TypedDict。
示例 15-4。books.py:BookDict定义
from typing import TypedDict
class BookDict(TypedDict):
isbn: str
title: str
authors: list[str]
pagecount: int
乍一看,typing.TypedDict可能看起来像是一个数据类构建器,类似于typing.NamedTuple—在第五章中介绍过。
语法上的相似性是误导的。TypedDict非常不同。它仅存在于类型检查器的利益,并且在运行时没有影响。
TypedDict提供了两个东西:
-
类似类的语法来注释每个“字段”的值的
dict类型提示。 -
一个构造函数,告诉类型检查器期望一个带有指定键和值的
dict。
在运行时,像BookDict这样的TypedDict构造函数是一个安慰剂:它与使用相同参数调用dict构造函数具有相同效果。
BookDict创建一个普通的dict也意味着:
-
伪类定义中的“字段”不会创建实例属性。
-
你不能为“字段”编写具有默认值的初始化程序。
-
不允许方法定义。
让我们在运行时探索一个BookDict的行为(示例 15-5)。
示例 15-5。使用BookDict,但并非完全按照预期
>>> from books import BookDict
>>> pp = BookDict(title='Programming Pearls', # ①
... authors='Jon Bentley', # ②
... isbn='0201657880',
... pagecount=256)
>>> pp # ③
{'title': 'Programming Pearls', 'authors': 'Jon Bentley', 'isbn': '0201657880',
'pagecount': 256} >>> type(pp)
<class 'dict'> >>> pp.title # ④
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'dict' object has no attribute 'title'
>>> pp['title']
'Programming Pearls' >>> BookDict.__annotations__ # ⑤
{'isbn': <class 'str'>, 'title': <class 'str'>, 'authors': typing.List[str],
'pagecount': <class 'int'>}
①
你可以像使用dict构造函数一样调用BookDict,使用关键字参数,或传递一个dict参数,包括dict文字。
②
糟糕…我忘记了 authors 接受一个列表。但渐进式类型意味着在运行时没有类型检查。
③
调用 BookDict 的结果是一个普通的 dict…
④
…因此您不能使用 object.field 记法读取数据。
⑤
类型提示位于 BookDict.__annotations__ 中,而不是 pp。
没有类型检查器,TypedDict 就像注释一样有用:它可以帮助人们阅读代码,但仅此而已。相比之下,来自 第五章 的类构建器即使不使用类型检查器也很有用,因为在运行时它们会生成或增强一个自定义类,您可以实例化。它们还提供了 表 5-1 中列出的几个有用的方法或函数。
示例 15-6 构建了一个有效的 BookDict,并尝试对其进行一些操作。这展示了 TypedDict 如何使 Mypy 能够捕获错误,如 示例 15-7 中所示。
示例 15-6. demo_books.py: 在 BookDict 上进行合法和非法操作
from books import BookDict
from typing import TYPE_CHECKING
def demo() -> None: # ①
book = BookDict( # ②
isbn='0134757599',
title='Refactoring, 2e',
authors=['Martin Fowler', 'Kent Beck'],
pagecount=478
)
authors = book['authors'] # ③
if TYPE_CHECKING: # ④
reveal_type(authors) # ⑤
authors = 'Bob' # ⑥
book['weight'] = 4.2
del book['title']
if __name__ == '__main__':
demo()
①
记得添加返回类型,这样 Mypy 不会忽略函数。
②
这是一个有效的 BookDict:所有键都存在,并且具有正确类型的值。
③
Mypy 将从 BookDict 中 'authors' 键的注释中推断出 authors 的类型。
④
typing.TYPE_CHECKING 仅在程序进行类型检查时为 True。在运行时,它始终为 false。
⑤
前一个 if 语句阻止了在运行时调用 reveal_type(authors)。reveal_type 不是运行时 Python 函数,而是 Mypy 提供的调试工具。这就是为什么没有为它导入的原因。在 示例 15-7 中查看其输出。
⑥
demo 函数的最后三行是非法的。它们会在 示例 15-7 中导致错误消息。
对 demo_books.py 进行类型检查,来自 示例 15-6,我们得到 示例 15-7。
示例 15-7. 对 demo_books.py 进行类型检查
…/typeddict/ $ mypy demo_books.py
demo_books.py:13: note: Revealed type is 'built-ins.list[built-ins.str]' # ①
demo_books.py:14: error: Incompatible types in assignment
(expression has type "str", variable has type "List[str]") # ②
demo_books.py:15: error: TypedDict "BookDict" has no key 'weight' # ③
demo_books.py:16: error: Key 'title' of TypedDict "BookDict" cannot be deleted # ④
Found 3 errors in 1 file (checked 1 source file)
①
这个注释是 reveal_type(authors) 的结果。
②
authors 变量的类型是从初始化它的 book['authors'] 表达式的类型推断出来的。您不能将 str 赋给类型为 List[str] 的变量。类型检查器通常不允许变量的类型更改。³
③
无法为不属于 BookDict 定义的键赋值。
④
无法删除属于 BookDict 定义的键。
现在让我们看看在函数签名中使用 BookDict,以进行函数调用的类型检查。
想象一下,你需要从书籍记录生成类似于这样的 XML:
<BOOK>
<ISBN>0134757599</ISBN>
<TITLE>Refactoring, 2e</TITLE>
<AUTHOR>Martin Fowler</AUTHOR>
<AUTHOR>Kent Beck</AUTHOR>
<PAGECOUNT>478</PAGECOUNT>
</BOOK>
如果您正在编写要嵌入到微型微控制器中的 MicroPython 代码,您可能会编写类似于 示例 15-8 中所示的函数。⁴
示例 15-8. books.py: to_xml 函数
AUTHOR_ELEMENT = '<AUTHOR>{}</AUTHOR>'
def to_xml(book: BookDict) -> str: # ①
elements: list[str] = [] # ②
for key, value in book.items():
if isinstance(value, list): # ③
elements.extend(
AUTHOR_ELEMENT.format(n) for n in value) # ④
else:
tag = key.upper()
elements.append(f'<{tag}>{value}</{tag}>')
xml = '\n\t'.join(elements)
return f'<BOOK>\n\t{xml}\n</BOOK>'
①
示例的整个重点:在函数签名中使用 BookDict。
②
经常需要注释开始为空的集合,否则 Mypy 无法推断元素的类型。⁵
③
Mypy 理解 isinstance 检查,并在此块中将 value 视为 list。
④
当我将key == 'authors'作为if条件来保护这个块时,Mypy 在这一行发现了一个错误:“"object"没有属性"iter"”,因为它推断出从book.items()返回的value类型为object,而object不支持生成器表达式所需的__iter__方法。通过isinstance检查,这可以工作,因为 Mypy 知道在这个块中value是一个list。
示例 15-9(#from_json_any_ex)展示了一个解析 JSON str并返回BookDict的函数。
示例 15-9. books_any.py:from_json函数
def from_json(data: str) -> BookDict:
whatever = json.loads(data) # ①
return whatever # ②
①
json.loads()的返回类型是Any。⁶
②
我可以返回whatever—类型为Any—因为Any与每种类型都一致,包括声明的返回类型BookDict。
示例 15-9 的第二点非常重要要记住:Mypy 不会在这段代码中标记任何问题,但在运行时,whatever中的值可能不符合BookDict结构—实际上,它可能根本不是dict!
如果你使用--disallow-any-expr运行 Mypy,它会抱怨from_json函数体中的两行代码:
…/typeddict/ $ mypy books_any.py --disallow-any-expr
books_any.py:30: error: Expression has type "Any"
books_any.py:31: error: Expression has type "Any"
Found 2 errors in 1 file (checked 1 source file)
前一段代码中提到的第 30 行和 31 行是from_json函数的主体。我们可以通过在whatever变量初始化时添加类型提示来消除类型错误,就像示例 15-10 中那样。
示例 15-10. books.py:带有变量注释的from_json函数。
def from_json(data: str) -> BookDict:
whatever: BookDict = json.loads(data) # ①
return whatever # ②
①
当将类型为Any的表达式立即分配给带有类型提示的变量时,--disallow-any-expr不会导致错误。
②
现在whatever的类型是BookDict,即声明的返回类型。
警告
不要被示例 15-10 的虚假类型安全感所蒙蔽!从静态代码看,类型检查器无法预测json.loads()会返回任何类似于BookDict的东西。只有运行时验证才能保证这一点。
静态类型检查无法防止与本质上动态的代码出现错误,比如json.loads(),它在运行时构建不同类型的 Python 对象,正如示例 15-11、15-12 和 15-13 所展示的。
示例 15-11. demo_not_book.py:from_json返回一个无效的BookDict,而to_xml接受它
from books import to_xml, from_json
from typing import TYPE_CHECKING
def demo() -> None:
NOT_BOOK_JSON = """
{"title": "Andromeda Strain",
"flavor": "pistachio",
"authors": true}
"""
not_book = from_json(NOT_BOOK_JSON) # ①
if TYPE_CHECKING: # ②
reveal_type(not_book)
reveal_type(not_book['authors'])
print(not_book) # ③
print(not_book['flavor']) # ④
xml = to_xml(not_book) # ⑤
print(xml) # ⑥
if __name__ == '__main__':
demo()
①
这行代码不会产生有效的BookDict—查看NOT_BOOK_JSON的内容。
②
让我们揭示一些类型。
③
这不应该是问题:print可以处理object和其他任何类型。
④
BookDict没有'flavor'键,但 JSON 源有…会发生什么?
⑤
记住签名:def to_xml(book: BookDict) -> str:
⑥
XML 输出会是什么样子?
现在我们用 Mypy 检查demo_not_book.py(示例 15-12)。
示例 15-12. demo_not_book.py的 Mypy 报告,为了清晰起见重新格式化
…/typeddict/ $ mypy demo_not_book.py
demo_not_book.py:12: note: Revealed type is
'TypedDict('books.BookDict', {'isbn': built-ins.str,
'title': built-ins.str,
'authors': built-ins.list[built-ins.str],
'pagecount': built-ins.int})' # ①
demo_not_book.py:13: note: Revealed type is 'built-ins.list[built-ins.str]' # ②
demo_not_book.py:16: error: TypedDict "BookDict" has no key 'flavor' # ③
Found 1 error in 1 file (checked 1 source file)
①
显式类型是名义类型,而不是not_book的运行时内容。
②
同样,这是not_book['authors']的名义类型,如BookDict中定义的那样。而不是运行时类型。
③
这个错误是针对print(not_book['flavor'])这一行的:该键在名义类型中不存在。
现在让我们运行demo_not_book.py,并在示例 15-13 中显示输出。
示例 15-13. 运行 demo_not_book.py 的输出
…/typeddict/ $ python3 demo_not_book.py
{'title': 'Andromeda Strain', 'flavor': 'pistachio', 'authors': True} # ①
pistachio # ②
<BOOK> # ③
<TITLE>Andromeda Strain</TITLE>
<FLAVOR>pistachio</FLAVOR>
<AUTHORS>True</AUTHORS>
</BOOK>
①
这实际上不是一个 BookDict。
②
not_book['flavor'] 的值。
③
to_xml 接受一个 BookDict 参数,但没有运行时检查:垃圾进,垃圾出。
示例 15-13 显示 demo_not_book.py 输出了无意义的内容,但没有运行时错误。在处理 JSON 数据时使用 TypedDict 并没有提供太多类型安全性。
如果你通过鸭子类型的视角查看示例 15-8 中to_xml的代码,那么参数book必须提供一个返回类似(key, value)元组可迭代对象的.items()方法,其中:
-
key必须有一个.upper()方法 -
value可以是任何东西
这个演示的重点是:当处理具有动态结构的数据,比如 JSON 或 XML 时,TypedDict 绝对不能替代运行时的数据验证。为此,请使用pydantic。
TypedDict 具有更多功能,包括支持可选键、有限形式的继承以及另一种声明语法。如果您想了解更多,请查看 PEP 589—TypedDict: Type Hints for Dictionaries with a Fixed Set of Keys。
现在让我们将注意力转向一个最好避免但有时不可避免的函数:typing.cast。
类型转换
没有完美的类型系统,静态类型检查器、typeshed 项目中的类型提示或具有类型提示的第三方包也不是完美的。
typing.cast() 特殊函数提供了一种处理类型检查故障或代码中不正确类型提示的方法。Mypy 0.930 文档解释:
Casts 用于消除杂乱的类型检查器警告,并在类型检查器无法完全理解情况时为其提供一点帮助。
在运行时,typing.cast 绝对不起作用。这是它的实现:
def cast(typ, val):
"""Cast a value to a type.
This returns the value unchanged. To the type checker this
signals that the return value has the designated type, but at
runtime we intentionally don't check anything (we want this
to be as fast as possible).
"""
return val
PEP 484 要求类型检查器“盲目相信”cast 中声明的类型。PEP 484 的“Casts”部分提供了一个需要 cast 指导的示例:
from typing import cast
def find_first_str(a: list[object]) -> str:
index = next(i for i, x in enumerate(a) if isinstance(x, str))
# We only get here if there's at least one string
return cast(str, a[index])
对生成器表达式的 next() 调用将返回 str 项的索引或引发 StopIteration。因此,如果没有引发异常,find_first_str 将始终返回一个 str,而 str 是声明的返回类型。
但如果最后一行只是 return a[index],Mypy 将推断返回类型为 object,因为 a 参数声明为 list[object]。因此,需要 cast() 来指导 Mypy。⁷
这里是另一个使用 cast 的示例,这次是为了纠正 Python 标准库中过时的类型提示。在示例 21-12 中,我创建了一个 asyncio Server 对象,并且我想获取服务器正在侦听的地址。我编写了这行代码:
addr = server.sockets[0].getsockname()
但 Mypy 报告了这个错误:
Value of type "Optional[List[socket]]" is not indexable
2021 年 5 月 typeshed 中 Server.sockets 的类型提示对 Python 3.6 是有效的,其中 sockets 属性可以是 None。但在 Python 3.7 中,sockets 变成了一个始终返回 list 的属性,如果服务器没有 sockets,则可能为空。自 Python 3.8 起,getter 返回一个 tuple(用作不可变序列)。
由于我现在无法修复 typeshed,⁸ 我添加了一个 cast,就像这样:
from asyncio.trsock import TransportSocket
from typing import cast
# ... many lines omitted ...
socket_list = cast(tuple[TransportSocket, ...], server.sockets)
addr = socket_list[0].getsockname()
在这种情况下使用 cast 需要花费几个小时来理解问题,并阅读 asyncio 源代码以找到正确的 sockets 类型:来自未记录的 asyncio.trsock 模块的 TransportSocket 类。我还必须添加两个 import 语句和另一行代码以提高可读性。⁹ 但代码更安全。
细心的读者可能会注意到,如果 sockets 为空,sockets[0] 可能会引发 IndexError。但就我对 asyncio 的理解而言,在 示例 21-12 中不会发生这种情况,因为 server 在我读取其 sockets 属性时已准备好接受连接,因此它不会为空。无论如何,IndexError 是一个运行时错误。Mypy 甚至在像 print([][0]) 这样的简单情况下也无法发现问题。
警告
不要过于依赖 cast 来消除 Mypy 的警告,因为当 Mypy 报告错误时,通常是正确的。如果你经常使用 cast,那是一个代码异味。你的团队可能在误用类型提示,或者你的代码库中可能存在低质量的依赖项。
尽管存在缺点,cast 也有其有效用途。以下是 Guido van Rossum 关于它的一些观点:
有什么问题,偶尔调用
cast()或添加# type: ignore注释吗?¹⁰
完全禁止使用 cast 是不明智的,特别是因为其他解决方法更糟糕:
-
# type: ignore提供的信息较少。¹¹ -
使用
Any是具有传染性的:由于Any与所有类型一致,滥用它可能通过类型推断产生级联效应,削弱类型检查器在代码其他部分检测错误的能力。
当然,并非所有类型错误都可以使用 cast 修复。有时我们需要 # type: ignore,偶尔需要 Any,甚至可以在函数中不留类型提示。
接下来,让我们谈谈在运行时使用注释。
在运行时读取类型提示
在导入时,Python 读取函数、类和模块中的类型提示,并将它们存储在名为 __annotations__ 的属性中。例如,考虑 示例 15-14 中的 clip 函数。¹²
示例 15-14. clipannot.py:clip 函数的带注释签名
def clip(text: str, max_len: int = 80) -> str:
类型提示存储为函数的 __annotations__ 属性中的 dict:
>>> from clip_annot import clip
>>> clip.__annotations__
{'text': <class 'str'>, 'max_len': <class 'int'>, 'return': <class 'str'>}
'return' 键映射到 -> 符号后的返回类型提示,在 示例 15-14 中。
请注意,注释在导入时由解释器评估,就像参数默认值也会被评估一样。这就是为什么注释中的值是 Python 类 str 和 int,而不是字符串 'str' 和 'int'。注释的导入时评估是 Python 3.10 的标准,但如果 PEP 563 或 PEP 649 成为标准行为,这可能会改变。
运行时的注释问题
类型提示的增加使用引发了两个问题:
-
当使用许多类型提示时,导入模块会消耗更多的 CPU 和内存。
-
引用尚未定义的类型需要使用字符串而不是实际类型。
这两个问题都很重要。第一个问题是因为我们刚刚看到的:注释在导入时由解释器评估并存储在 __annotations__ 属性中。现在让我们专注于第二个问题。
有时需要将注释存储为字符串,因为存在“前向引用”问题:当类型提示需要引用在同一模块下定义的类时。然而,在源代码中问题的常见表现根本不像前向引用:当方法返回同一类的新对象时。由于在 Python 完全评估类体之前类对象未定义,类型提示必须使用类名作为字符串。以下是一个示例:
class Rectangle:
# ... lines omitted ...
def stretch(self, factor: float) -> 'Rectangle':
return Rectangle(width=self.width * factor)
将前向引用类型提示写为字符串是 Python 3.10 的标准和必需做法。静态类型检查器从一开始就设计用于处理这个问题。
但在运行时,如果编写代码读取 stretch 的 return 注释,你将得到一个字符串 'Rectangle' 而不是实际类型,即 Rectangle 类的引用。现在你的代码需要弄清楚那个字符串的含义。
typing模块包括三个函数和一个分类为内省助手的类,其中最重要的是typing.get_type_hints。其部分文档如下:
get_type_hints(obj, globals=None, locals=None, include_extras=False)
[…] 这通常与obj.__annotations__相同。此外,以字符串文字编码的前向引用通过在globals和locals命名空间中评估来处理。[…]
警告
自 Python 3.10 开始,应该使用新的inspect.get_annotations(…)函数,而不是typing.get_type_hints。然而,一些读者可能尚未使用 Python 3.10,因此在示例中我将使用typing.get_type_hints,自从typing模块在 Python 3.5 中添加以来就可用。
PEP 563—注释的延迟评估已经获得批准,使得不再需要将注释写成字符串,并减少类型提示的运行时成本。其主要思想在“摘要”的这两句话中描述:
本 PEP 建议更改函数注释和变量注释,使其不再在函数定义时评估。相反,它们以字符串形式保留在注释中。
从 Python 3.7 开始,这就是在任何以此import语句开头的模块中处理注释的方式:
from __future__ import annotations
为了展示其效果,我将与顶部的__future__导入行相同的clip函数的副本放在了一个名为clip_annot_post.py的模块中。
在控制台上,当我导入该模块并读取clip的注释时,这是我得到的结果:
>>> from clip_annot_post import clip
>>> clip.__annotations__
{'text': 'str', 'max_len': 'int', 'return': 'str'}
如您所见,所有类型提示现在都是普通字符串,尽管它们在clip的定义中并非作为引号字符串编写(示例 15-14)。
typing.get_type_hints函数能够解析许多类型提示,包括clip中的类型提示:
>>> from clip_annot_post import clip
>>> from typing import get_type_hints
>>> get_type_hints(clip)
{'text': <class 'str'>, 'max_len': <class 'int'>, 'return': <class 'str'>}
调用get_type_hints会给我们真实的类型,即使在某些情况下原始类型提示是作为引号字符串编写的。这是在运行时读取类型提示的推荐方式。
PEP 563 的行为原计划在 Python 3.10 中成为默认行为,无需__future__导入。然而,FastAPI 和 pydantic 的维护者发出警告,称这一变化将破坏依赖运行时类型提示的代码,并且无法可靠使用get_type_hints。
在 python-dev 邮件列表上的讨论中,PEP 563 的作者 Łukasz Langa 描述了该函数的一些限制:
[…] 结果表明,
typing.get_type_hints()存在一些限制,使得其在一般情况下在运行时成本高昂,并且更重要的是无法解析所有类型。最常见的例子涉及生成类型的非全局上下文(例如,内部类、函数内的类等)。但是,一个前向引用的典型例子是:具有接受或返回其自身类型对象的方法的类,如果使用类生成器,则typing.get_type_hints()也无法正确处理。我们可以做一些技巧来连接这些点,但总体来说并不是很好。¹³
Python 的指导委员会决定将 PEP 563 的默认行为推迟到 Python 3.11 或更高版本,以便开发人员有更多时间提出解决 PEP 563 试图解决的问题的解决方案,而不会破坏运行时类型提示的广泛使用。PEP 649—使用描述符推迟评估注释正在考虑作为可能的解决方案,但可能会达成不同的妥协。
总结一下:截至 Python 3.10,运行时读取类型提示并不是 100%可靠的,可能会在 2022 年发生变化。
注意
在大规模使用 Python 的公司中,他们希望获得静态类型的好处,但不想在导入时评估类型提示的代价。静态检查发生在开发人员的工作站和专用 CI 服务器上,但在生产容器中,模块的加载频率和数量要高得多,这种成本在规模上是不可忽略的。
这在 Python 社区中引发了紧张气氛,一方面是希望类型提示仅以字符串形式存储,以减少加载成本,另一方面是希望在运行时也使用类型提示的人,比如 pydantic 和 FastAPI 的创建者和用户,他们更希望将类型对象存储起来,而不是评估这些注释,这是一项具有挑战性的任务。
处理问题
鉴于目前的不稳定局势,如果您需要在运行时阅读注释,我建议:
-
避免直接读取
__annotations__;而是使用inspect.get_annotations(从 Python 3.10 开始)或typing.get_type_hints(自 Python 3.5 起)。 -
编写自己的自定义函数,作为
inspect.get_annotations或typing.get_type_hints周围的薄包装,让您的代码库的其余部分调用该自定义函数,以便将来的更改局限于单个函数。
为了演示第二点,这里是在 示例 24-5 中定义的Checked类的前几行,我们将在 第二十四章 中学习:
class Checked:
@classmethod
def _fields(cls) -> dict[str, type]:
return get_type_hints(cls)
# ... more lines ...
Checked._fields 类方法保护模块的其他部分不直接依赖于typing.get_type_hints。如果get_type_hints在将来发生变化,需要额外的逻辑,或者您想用inspect.get_annotations替换它,更改将局限于Checked._fields,不会影响程序的其余部分。
警告
鉴于关于运行时检查类型提示的持续讨论和提出的更改,官方的“注释最佳实践”文档是必读的,并且可能会在通往 Python 3.11 的道路上进行更新。这篇指南是由 Larry Hastings 撰写的,他是 PEP 649—使用描述符延迟评估注释 的作者,这是一个解决由 PEP 563—延迟评估注释 提出的运行时问题的替代提案。
本章的其余部分涵盖了泛型,从如何定义一个可以由用户参数化的泛型类开始。
实现一个通用类
在 示例 13-7 中,我们定义了Tombola ABC:一个类似于宾果笼的接口。来自 示例 13-10 的LottoBlower 类是一个具体的实现。现在我们将研究一个通用版本的LottoBlower,就像在 示例 15-15 中使用的那样。
示例 15-15. generic_lotto_demo.py:使用通用抽奖机类
from generic_lotto import LottoBlower
machine = LottoBlowerint) # ①
first = machine.pick() # ②
remain = machine.inspect() # ③
①
要实例化一个通用类,我们给它一个实际的类型参数,比如这里的int。
②
Mypy 将正确推断first是一个int…
③
… 而remain是一个整数的元组。
此外,Mypy 还报告了参数化类型的违规情况,并提供了有用的消息,就像 示例 15-16 中显示的那样。
示例 15-16. generic_lotto_errors.py:Mypy 报告的错误
from generic_lotto import LottoBlower
machine = LottoBlowerint
## error: List item 1 has incompatible type "float"; # ①
## expected "int"
machine = LottoBlowerint)
machine.load('ABC')
## error: Argument 1 to "load" of "LottoBlower" # ②
## has incompatible type "str";
## expected "Iterable[int]"
## note: Following member(s) of "str" have conflicts:
## note: Expected:
## note: def __iter__(self) -> Iterator[int]
## note: Got:
## note: def __iter__(self) -> Iterator[str]
①
在实例化LottoBlower[int]时,Mypy 标记了float。
②
在调用.load('ABC')时,Mypy 解释了为什么str不行:str.__iter__返回一个Iterator[str],但LottoBlower[int]需要一个Iterator[int]。
示例 15-17 是实现。
示例 15-17. generic_lotto.py:一个通用的抽奖机类
import random
from collections.abc import Iterable
from typing import TypeVar, Generic
from tombola import Tombola
T = TypeVar('T')
class LottoBlower(Tombola, Generic[T]): # ①
def __init__(self, items: Iterable[T]) -> None: # ②
self._balls = listT
def load(self, items: Iterable[T]) -> None: # ③
self._balls.extend(items)
def pick(self) -> T: # ④
try:
position = random.randrange(len(self._balls))
except ValueError:
raise LookupError('pick from empty LottoBlower')
return self._balls.pop(position)
def loaded(self) -> bool: # ⑤
return bool(self._balls)
def inspect(self) -> tuple[T, ...]: # ⑥
return tuple(self._balls)
①
泛型类声明通常使用多重继承,因为我们需要子类化Generic来声明形式类型参数——在本例中为T。
②
__init__中的items参数的类型为Iterable[T],当实例声明为LottoBlower[int]时,变为Iterable[int]。
③
load方法也受到限制。
④
T的返回类型现在在LottoBlower[int]中变为int。
⑤
这里没有类型变量。
⑥
最后,T设置了返回的tuple中项目的类型。
提示
typing模块文档中的“用户定义的泛型类型”部分很简短,提供了很好的例子,并提供了一些我这里没有涵盖的更多细节。
现在我们已经看到如何实现泛型类,让我们定义术语来谈论泛型。
泛型类型的基本术语
这里有几个我在学习泛型时发现有用的定义:¹⁴
泛型类型
声明有一个或多个类型变量的类型。
例子:LottoBlower[T],abc.Mapping[KT, VT]
形式类型参数
出现在泛型类型声明中的类型变量。
例子:前面例子abc.Mapping[KT, VT]中的KT和VT
参数化类型
声明为具有实际类型参数的类型。
例子:LottoBlower[int],abc.Mapping[str, float]
实际类型参数
在声明参数化类型时给定的实际类型。
例子:LottoBlower[int]中的int
下一个主题是如何使泛型类型更灵活,引入协变、逆变和不变的概念。
方差
注意
根据您在其他语言中对泛型的经验,这可能是本书中最具挑战性的部分。方差的概念是抽象的,严谨的表述会使这一部分看起来像数学书中的页面。
在实践中,方差主要与想要支持新的泛型容器类型或提供基于回调的 API 的库作者有关。即使如此,通过仅支持不变容器,您可以避免许多复杂性——这基本上是我们现在在 Python 标准库中所拥有的。因此,在第一次阅读时,您可以跳过整个部分,或者只阅读关于不变类型的部分。
我们首次在“可调用类型的方差”中看到了方差的概念,应用于参数化泛型Callable类型。在这里,我们将扩展这个概念,涵盖泛型集合类型,使用“现实世界”的类比使这个抽象概念更具体。
想象一下学校食堂有一个规定,只能安装果汁分配器。只有果汁分配器是被允许的,因为它们可能提供被学校董事会禁止的苏打水。¹⁵¹⁶
不变的分配器
让我们尝试用一个可以根据饮料类型进行参数化的泛型BeverageDispenser类来模拟食堂场景。请参见例 15-18。
例 15-18. invariant.py:类型定义和install函数
from typing import TypeVar, Generic
class Beverage: # ①
"""Any beverage."""
class Juice(Beverage):
"""Any fruit juice."""
class OrangeJuice(Juice):
"""Delicious juice from Brazilian oranges."""
T = TypeVar('T') # ②
class BeverageDispenser(Generic[T]): # ③
"""A dispenser parameterized on the beverage type."""
def __init__(self, beverage: T) -> None:
self.beverage = beverage
def dispense(self) -> T:
return self.beverage
def install(dispenser: BeverageDispenser[Juice]) -> None: # ④
"""Install a fruit juice dispenser."""
①
Beverage、Juice和OrangeJuice形成一个类型层次结构。
②
简单的TypeVar声明。
③
BeverageDispenser的类型参数化为饮料的类型。
④
install是一个模块全局函数。它的类型提示强制执行只有果汁分配器是可接受的规则。
鉴于例 15-18 中的定义,以下代码是合法的:
juice_dispenser = BeverageDispenser(Juice())
install(juice_dispenser)
然而,这是不合法的:
beverage_dispenser = BeverageDispenser(Beverage())
install(beverage_dispenser)
## mypy: Argument 1 to "install" has
## incompatible type "BeverageDispenser[Beverage]"
## expected "BeverageDispenser[Juice]"
任何饮料的分配器都是不可接受的,因为食堂需要专门用于果汁的分配器。
令人惊讶的是,这段代码也是非法的:
orange_juice_dispenser = BeverageDispenser(OrangeJuice())
install(orange_juice_dispenser)
## mypy: Argument 1 to "install" has
## incompatible type "BeverageDispenser[OrangeJuice]"
## expected "BeverageDispenser[Juice]"
专门用于橙汁的分配器也是不允许的。只有BeverageDispenser[Juice]才行。在类型术语中,我们说BeverageDispenser(Generic[T])是不变的,当BeverageDispenser[OrangeJuice]与BeverageDispenser[Juice]不兼容时——尽管OrangeJuice是Juice的子类型。
Python 可变集合类型——如list和set——是不变的。来自示例 15-17 的LottoBlower类也是不变的。
一个协变分配器
如果我们想更灵活地建模分配器作为一个通用类,可以接受某种饮料类型及其子类型,我们必须使其协变。示例 15-19 展示了如何声明BeverageDispenser。
示例 15-19. covariant.py:类型定义和install函数
T_co = TypeVar('T_co', covariant=True) # ①
class BeverageDispenser(Generic[T_co]): # ②
def __init__(self, beverage: T_co) -> None:
self.beverage = beverage
def dispense(self) -> T_co:
return self.beverage
def install(dispenser: BeverageDispenser[Juice]) -> None: # ③
"""Install a fruit juice dispenser."""
①
在声明类型变量时,设置covariant=True;_co是typeshed上协变类型参数的常规后缀。
②
使用T_co来为Generic特殊类进行参数化。
③
对于install的类型提示与示例 15-18 中的相同。
以下代码有效,因为现在Juice分配器和OrangeJuice分配器都在协变BeverageDispenser中有效:
juice_dispenser = BeverageDispenser(Juice())
install(juice_dispenser)
orange_juice_dispenser = BeverageDispenser(OrangeJuice())
install(orange_juice_dispenser)
但是,任意饮料的分配器也是不可接受的:
beverage_dispenser = BeverageDispenser(Beverage())
install(beverage_dispenser)
## mypy: Argument 1 to "install" has
## incompatible type "BeverageDispenser[Beverage]"
## expected "BeverageDispenser[Juice]"
这就是协变性:参数化分配器的子类型关系与类型参数的子类型关系方向相同变化。
逆变垃圾桶
现在我们将模拟食堂设置垃圾桶的规则。让我们假设食物和饮料都是用生物降解包装,剩菜剩饭以及一次性餐具也是生物降解的。垃圾桶必须适用于生物降解的废物。
注意
为了这个教学示例,让我们做出简化假设,将垃圾分类为一个整洁的层次结构:
-
废物是最一般的垃圾类型。所有垃圾都是废物。 -
生物降解是一种可以随时间被生物分解的垃圾类型。一些废物不是生物降解的。 -
可堆肥是一种特定类型的生物降解垃圾,可以在堆肥桶或堆肥设施中高效地转化为有机肥料。在我们的定义中,并非所有生物降解垃圾都是可堆肥的。
为了模拟食堂中可接受垃圾桶的规则,我们需要通过一个示例引入“逆变性”概念,如示例 15-20 所示。
示例 15-20. contravariant.py:类型定义和install函数
from typing import TypeVar, Generic
class Refuse: # ①
"""Any refuse."""
class Biodegradable(Refuse):
"""Biodegradable refuse."""
class Compostable(Biodegradable):
"""Compostable refuse."""
T_contra = TypeVar('T_contra', contravariant=True) # ②
class TrashCan(Generic[T_contra]): # ③
def put(self, refuse: T_contra) -> None:
"""Store trash until dumped."""
def deploy(trash_can: TrashCan[Biodegradable]):
"""Deploy a trash can for biodegradable refuse."""
①
垃圾的类型层次结构:废物是最一般的类型,可堆肥是最具体的。
②
T_contra是逆变类型变量的常规名称。
③
TrashCan在废物类型上是逆变的。
根据这些定义,以下类型的垃圾桶是可接受的:
bio_can: TrashCan[Biodegradable] = TrashCan()
deploy(bio_can)
trash_can: TrashCan[Refuse] = TrashCan()
deploy(trash_can)
更一般的TrashCan[Refuse]是可接受的,因为它可以接受任何类型的废物,包括生物降解。然而,TrashCan[Compostable]不行,因为它不能接受生物降解:
compost_can: TrashCan[Compostable] = TrashCan()
deploy(compost_can)
## mypy: Argument 1 to "deploy" has
## incompatible type "TrashCan[Compostable]"
## expected "TrashCan[Biodegradable]"
让我们总结一下我们刚刚看到的概念。
变异回顾
变异是一个微妙的属性。以下部分总结了不变、协变和逆变类型的概念,并提供了一些关于它们推理的经验法则。
不变类型
当两个参数化类型之间没有超类型或子类型关系时,泛型类型 L 是不变的,而不管实际参数之间可能存在的关系。换句话说,如果 L 是不变的,那么 L[A] 不是 L[B] 的超类型或子类型。它们在两个方面都不一致。
如前所述,Python 的可变集合默认是不变的。list 类型是一个很好的例子:list[int] 与 list[float] 不一致,反之亦然。
一般来说,如果一个形式类型参数出现在方法参数的类型提示中,并且相同的参数出现在方法返回类型中,那么为了确保在更新和读取集合时的类型安全,该参数必须是不变的。
例如,这是 list 内置的类型提示的一部分typeshed:
class list(MutableSequence[_T], Generic[_T]):
@overload
def __init__(self) -> None: ...
@overload
def __init__(self, iterable: Iterable[_T]) -> None: ...
# ... lines omitted ...
def append(self, __object: _T) -> None: ...
def extend(self, __iterable: Iterable[_T]) -> None: ...
def pop(self, __index: int = ...) -> _T: ...
# etc...
注意 _T 出现在 __init__、append 和 extend 的参数中,以及 pop 的返回类型中。如果 _T 在 _T 中是协变或逆变的,那么没有办法使这样的类类型安全。
协变类型
考虑两种类型 A 和 B,其中 B 与 A 一致,且它们都不是 Any。一些作者使用 <: 和 :> 符号来表示这样的类型关系:
A :> B
A 是 B 的超类型或相同。
B <: A
B 是 A 的子类型或相同。
给定 A :> B,泛型类型 C 在 C[A] :> C[B] 时是协变的。
注意 :> 符号的方向在 A 在 B 的左侧时是相同的。协变泛型类型遵循实际类型参数的子类型关系。
不可变容器可以是协变的。例如,typing.FrozenSet 类是如何 文档化 作为一个协变的,使用传统名称 T_co 的类型变量:
class FrozenSet(frozenset, AbstractSet[T_co]):
将 :> 符号应用于参数化类型,我们有:
float :> int
frozenset[float] :> frozenset[int]
迭代器是协变泛型的另一个例子:它们不是只读集合,如 frozenset,但它们只产生输出。任何期望一个产生浮点数的 abc.Iterator[float] 的代码可以安全地使用一个产生整数的 abc.Iterator[int]。Callable 类型在返回类型上是协变的,原因类似。
逆变类型
给定 A :> B,泛型类型 K 在 K[A] <: K[B] 时是逆变的。
逆变泛型类型颠倒了实际类型参数的子类型关系。
TrashCan 类是一个例子:
Refuse :> Biodegradable
TrashCan[Refuse] <: TrashCan[Biodegradable]
逆变容器通常是一个只写数据结构,也称为“接收器”。标准库中没有这样的集合的例子,但有一些具有逆变类型参数的类型。
Callable[[ParamType, …], ReturnType] 在参数类型上是逆变的,但在 ReturnType 上是协变的,正如我们在 “Callable 类型的方差” 中看到的。此外,Generator、Coroutine 和 AsyncGenerator 有一个逆变类型参数。Generator 类型在 “经典协程的泛型类型提示” 中有描述;Coroutine 和 AsyncGenerator 在 第二十一章 中有描述。
对于关于方差的讨论,主要观点是逆变的形式参数定义了用于调用或发送数据到对象的参数类型,而不同的协变形式参数定义了对象产生的输出类型——产生类型或返回类型,取决于对象。 “发送” 和 “产出” 的含义在 “经典协程” 中有解释。
我们可以从这些关于协变输出和逆变输入的观察中得出有用的指导方针。
协变的经验法则
最后,以下是一些关于推理方差时的经验法则:
-
如果一个形式类型参数定义了从对象中输出的数据类型,那么它可以是协变的。
-
如果形式类型参数定义了一个类型,用于在对象初始构建后进入对象的数据,它可以是逆变的。
-
如果形式类型参数定义了一个用于从对象中提取数据的类型,并且同一参数定义了一个用于将数据输入对象的类型,则它必须是不变的。
-
为了保险起见,使形式类型参数不变。
Callable[[ParamType, …], ReturnType]展示了规则#1 和#2:ReturnType是协变的,而每个ParamType是逆变的。
默认情况下,TypeVar创建的形式参数是不变的,这就是标准库中的可变集合是如何注释的。
“经典协程的通用类型提示”继续讨论关于方差的内容。
接下来,让我们看看如何定义通用的静态协议,将协变的思想应用到几个新的示例中。
实现通用的静态协议
Python 3.10 标准库提供了一些通用的静态协议。其中之一是SupportsAbs,在typing 模块中实现如下:
@runtime_checkable
class SupportsAbs(Protocol[T_co]):
"""An ABC with one abstract method __abs__ that is covariant in its
return type."""
__slots__ = ()
@abstractmethod
def __abs__(self) -> T_co:
pass
T_co根据命名约定声明:
T_co = TypeVar('T_co', covariant=True)
由于SupportsAbs,Mypy 将此代码识别为有效,如您在示例 15-21 中所见。
示例 15-21。abs_demo.py:使用通用的SupportsAbs协议
import math
from typing import NamedTuple, SupportsAbs
class Vector2d(NamedTuple):
x: float
y: float
def __abs__(self) -> float: # ①
return math.hypot(self.x, self.y)
def is_unit(v: SupportsAbs[float]) -> bool: # ②
"""'True' if the magnitude of 'v' is close to 1."""
return math.isclose(abs(v), 1.0) # ③
assert issubclass(Vector2d, SupportsAbs) # ④
v0 = Vector2d(0, 1) # ⑤
sqrt2 = math.sqrt(2)
v1 = Vector2d(sqrt2 / 2, sqrt2 / 2)
v2 = Vector2d(1, 1)
v3 = complex(.5, math.sqrt(3) / 2)
v4 = 1 # ⑥
assert is_unit(v0)
assert is_unit(v1)
assert not is_unit(v2)
assert is_unit(v3)
assert is_unit(v4)
print('OK')
①
定义__abs__使Vector2d与SupportsAbs一致。
②
使用float参数化SupportsAbs确保…
③
…Mypy 接受abs(v)作为math.isclose的第一个参数。
④
在SupportsAbs的定义中,感谢@runtime_checkable,这是一个有效的运行时断言。
⑤
剩下的代码都通过了 Mypy 检查和运行时断言。
⑥
int类型也与SupportsAbs一致。根据typeshed,int.__abs__返回一个int,这与is_unit类型提示中为v参数声明的float类型参数一致。
类似地,我们可以编写RandomPicker协议的通用版本,该协议在示例 13-18 中介绍,该协议定义了一个返回Any的单个方法pick。
示例 15-22 展示了如何使通用的RandomPicker在pick的返回类型上具有协变性。
示例 15-22。generic_randompick.py:定义通用的RandomPicker
from typing import Protocol, runtime_checkable, TypeVar
T_co = TypeVar('T_co', covariant=True) # ①
@runtime_checkable
class RandomPicker(Protocol[T_co]): # ②
def pick(self) -> T_co: ... # ③
①
将T_co声明为协变。
②
这使RandomPicker具有协变的形式类型参数。
③
使用T_co作为返回类型。
通用的RandomPicker协议可以是协变的,因为它的唯一形式参数用于返回类型。
有了这个,我们可以称之为一个章节。
章节总结
章节以一个简单的使用@overload的例子开始,接着是一个我们详细研究的更复杂的例子:正确注释max内置函数所需的重载签名。
接下来是typing.TypedDict特殊构造。我选择在这里介绍它,而不是在第五章中看到typing.NamedTuple,因为TypedDict不是一个类构建器;它只是一种向需要具有特定一组字符串键和每个键特定类型的dict添加类型提示的方式——当我们将dict用作记录时,通常在处理 JSON 数据时会发生这种情况。该部分有点长,因为使用TypedDict可能会给人一种虚假的安全感,我想展示在尝试将静态结构化记录转换为本质上是动态的映射时,运行时检查和错误处理是不可避免的。
接下来我们讨论了typing.cast,这是一个旨在指导类型检查器工作的函数。仔细考虑何时使用cast很重要,因为过度使用会妨碍类型检查器。
接下来是运行时访问类型提示。关键点是使用typing.get_type_hints而不是直接读取__annotations__属性。然而,该函数可能对某些注解不可靠,我们看到 Python 核心开发人员仍在努力找到一种方法,在减少对 CPU 和内存使用的影响的同时使类型提示在运行时可用。
最后几节是关于泛型的,首先是LottoBlower泛型类——我们后来了解到它是一个不变的泛型类。该示例后面是四个基本术语的定义:泛型类型、形式类型参数、参数化类型和实际类型参数。
接下来介绍了主题的主要内容,使用自助餐厅饮料分配器和垃圾桶作为不变、协变和逆变通用类型的“现实生活”示例。接下来,我们对 Python 标准库中的示例进行了复习、形式化和进一步应用这些概念。
最后,我们看到了如何定义通用的静态协议,首先考虑typing.SupportsAbs协议,然后将相同的思想应用于RandomPicker示例,使其比第十三章中的原始协议更加严格。
注意
Python 的类型系统是一个庞大且快速发展的主题。本章不是全面的。我选择关注那些广泛适用、特别具有挑战性或在概念上重要且因此可能长期相关的主题。
进一步阅读
Python 的静态类型系统最初设计复杂,随着每年的发展变得更加复杂。表 15-1 列出了截至 2021 年 5 月我所知道的所有 PEP。要覆盖所有内容需要一整本书。
表 15-1。关于类型提示的 PEP,标题中带有链接。带有*号的 PEP 编号在typing文档的开头段落中提到。Python 列中的问号表示正在讨论或尚未实施的 PEP;“n/a”出现在没有特定 Python 版本的信息性 PEP 中。
| PEP | 标题 | Python | 年份 |
|---|---|---|---|
| 3107 | 函数注解 | 3.0 | 2006 |
| 483* | 类型提示理论 | n/a | 2014 |
| 484* | 类型提示 | 3.5 | 2014 |
| 482 | 类型提示文献综述 | n/a | 2015 |
| 526* | 变量注解的语法 | 3.6 | 2016 |
| 544* | 协议:结构子类型(静态鸭子类型) | 3.8 | 2017 |
| 557 | 数据类 | 3.7 | 2017 |
| 560 | 类型模块和泛型类型的核心支持 | 3.7 | 2017 |
| 561 | 分发和打包类型信息 | 3.7 | 2017 |
| 563 | 注解的延迟评估 | 3.7 | 2017 |
| 586* | 字面类型 | 3.8 | 2018 |
| 585 | 标准集合中的泛型类型提示 | 3.9 | 2019 |
| 589* | TypedDict:具有固定键集的字典的类型提示 | 3.8 | 2019 |
| 591* | 向 typing 添加 final 修饰符 | 3.8 | 2019 |
| 593 | 灵活的函数和变量注释 | ? | 2019 |
| 604 | 将联合类型写为 X | Y | 3.10 | 2019 |
| 612 | 参数规范变量 | 3.10 | 2019 |
| 613 | 显式类型别名 | 3.10 | 2020 |
| 645 | 允许将可选类型写为 x? | ? | 2020 |
| 646 | 可变泛型 | ? | 2020 |
| 647 | 用户定义的类型守卫 | 3.10 | 2021 |
| 649 | 使用描述符延迟评估注释 | ? | 2021 |
| 655 | 将个别 TypedDict 项目标记为必需或可能缺失 | ? | 2021 |
Python 的官方文档几乎无法跟上所有内容,因此Mypy 的文档是一个必不可少的参考。强大的 Python 作者:帕特里克·维亚福雷(O’Reilly)是我知道的第一本广泛涵盖 Python 静态类型系统的书籍,于 2021 年 8 月出版。你现在可能正在阅读第二本这样的书籍。
关于协变的微妙主题在 PEP 484 的章节中有专门讨论,同时也在 Mypy 的“泛型”页面以及其宝贵的“常见问题”页面中有涵盖。
阅读值得的PEP 362—函数签名对象,如果你打算使用补充typing.get_type_hints函数的inspect模块。
如果你对 Python 的历史感兴趣,你可能会喜欢知道,Guido van Rossum 在 2004 年 12 月 23 日发布了“向 Python 添加可选静态类型”。
“Python 3 中的类型在野外:两种类型系统的故事” 是由 Rensselaer Polytechnic Institute 和 IBM TJ Watson 研究中心的 Ingkarat Rak-amnouykit 等人撰写的研究论文。该论文调查了 GitHub 上开源项目中类型提示的使用情况,显示大多数项目并未使用它们,而且大多数具有类型提示的项目显然也没有使用类型检查器。我发现最有趣的是对 Mypy 和 Google 的 pytype 不同语义的讨论,他们得出结论称它们“本质上是两种不同的类型系统”。
两篇关于渐进式类型的重要论文是吉拉德·布拉查的“可插入式类型系统”,以及埃里克·迈杰和彼得·德雷顿撰写的“可能时使用静态类型,需要时使用动态类型:编程语言之间的冷战结束”¹⁷
通过阅读其他语言实现相同思想的一些书籍的相关部分,我学到了很多:
-
原子 Kotlin 作者:布鲁斯·埃克尔和斯维特兰娜·伊萨科娃(Mindview)
-
Effective Java,第三版 作者:乔舒亚·布洛克(Addison-Wesley)
-
使用类型编程:TypeScript 示例 作者:弗拉德·里斯库蒂亚(Manning)
-
编程 TypeScript 作者:鲍里斯·切尔尼(O’Reilly)
-
Dart 编程语言 作者:吉拉德·布拉查(Addison-Wesley)¹⁸
对于一些关于类型系统的批判观点,我推荐阅读维克多·尤代肯的文章“类型理论中的坏主意”和“类型有害 II”。
最后,我惊讶地发现了 Ken Arnold 的“泛型有害论”,他是 Java 的核心贡献者,也是官方Java 编程语言书籍(Addison-Wesley)前四版的合著者之一——与 Java 的首席设计师 James Gosling 合作。
遗憾的是,Arnold 的批评也适用于 Python 的静态类型系统。在阅读许多有关类型提示 PEP 的规则和特例时,我不断想起 Gosling 文章中的这段话:
这就提出了我总是为 C++引用的问题:我称之为“例外规则的 N^(th)次例外”。听起来是这样的:“你可以做 x,但在情况 y 下除外,除非 y 做 z,那么你可以如果...”
幸运的是,Python 比 Java 和 C++有一个关键优势:可选的类型系统。当类型提示变得太繁琐时,我们可以关闭类型检查器并省略类型提示。
¹ 来自 YouTube 视频“语言创作者对话:Guido van Rossum、James Gosling、Larry Wall 和 Anders Hejlsberg”,于 2019 年 4 月 2 日直播。引用开始于1:32:05,经过简化编辑。完整的文字记录可在https://github.com/fluentpython/language-creators找到。
² 我要感谢 Jelle Zijlstra——一个typeshed的维护者——教会了我很多东西,包括如何将我最初的九个重载减少到六个。
³ 截至 2020 年 5 月,pytype 允许这样做。但其常见问题解答中表示将来会禁止这样做。请参见 pytype常见问题解答中的“为什么 pytype 没有捕捉到我更改了已注释变量的类型?”问题。
⁴ 我更喜欢使用lxml包来生成和解析 XML:它易于上手,功能齐全且速度快。不幸的是,lxml 和 Python 自带的ElementTree不适用于我假想的微控制器的有限 RAM。
⁵ Mypy 文档在其“常见问题和解决方案”页面中讨论了这个问题,在“空集合的类型”一节中有详细说明。
⁶ Brett Cannon、Guido van Rossum 等人自 2016 年以来一直在讨论如何为json.loads()添加类型提示,在Mypy 问题#182:定义 JSON 类型中。
⁷ 示例中使用enumerate旨在混淆类型检查器。Mypy 可以正确分析直接生成字符串而不经过enumerate索引的更简单的实现,因此不需要cast()。
⁸ 我报告了typeshed的问题#5535,“asyncio.base_events.Server sockets 属性的错误类型提示”,Sebastian Rittau 很快就修复了。然而,我决定保留这个例子,因为它展示了cast的一个常见用例,而我写的cast是无害的。
⁹ 老实说,我最初在带有server.sockets[0]的行末添加了一个# type: ignore注释,因为经过一番调查,我在asyncio 文档和一个测试用例中找到了类似的行,所以我怀疑问题不在我的代码中。
¹⁰ 2020 年 5 月 19 日消息发送至 typing-sig 邮件列表。
¹¹ 语法# type: ignore[code]允许您指定要消除的 Mypy 错误代码,但这些代码并不总是容易解释。请参阅 Mypy 文档中的“错误代码”。
¹² 我不会详细介绍 clip 的实现,但如果你感兴趣,可以阅读 clip_annot.py 中的整个模块。
¹³ 2021 年 4 月 16 日发布的信息 “PEP 563 in light of PEP 649”。
¹⁴ 这些术语来自 Joshua Bloch 的经典著作 Effective Java,第三版(Addison-Wesley)。定义和示例是我自己的。
¹⁵ 我第一次看到 Erik Meijer 在 Gilad Bracha 的 The Dart Programming Language 一书(Addison-Wesley)的 前言 中使用自助餐厅类比来解释方差。
¹⁶ 比禁书好多了!
¹⁷ 作为脚注的读者,你可能记得我将 Erik Meijer 归功于用自助餐厅类比来解释方差。
¹⁸ 那本书是为 Dart 1 写的。Dart 2 有重大变化,包括类型系统。尽管如此,Bracha 是编程语言设计领域的重要研究者,我发现这本书对 Dart 的设计视角很有价值。
¹⁹ 参见 PEP 484 中 “Covariance and Contravariance” 部分的最后一段。
第十六章:运算符重载
有一些事情让我感到矛盾,比如运算符重载。我在 C++ 中看到太多人滥用它,所以我把运算符重载略去了,这是一个相当个人的选择。
Java 的创始人詹姆斯·高斯林¹
在 Python 中,你可以使用以下公式计算复利:
interest = principal * ((1 + rate) ** periods - 1)
出现在操作数之间的运算符,如 1 + rate,是中缀运算符。在 Python 中,中缀运算符可以处理任意类型。因此,如果你处理真实货币,你可以确保 principal、rate 和 periods 是精确的数字 —— Python decimal.Decimal 类的实例 —— 并且该公式将按照写入的方式工作,产生精确的结果。
但是在 Java 中,如果你从 float 切换到 BigDecimal 以获得精确的结果,你就不能再使用中缀运算符了,因为它们只适用于原始类型。这是在 Java 中使用 BigDecimal 数字编写的相同公式:
BigDecimal interest = principal.multiply(BigDecimal.ONE.add(rate)
.pow(periods).subtract(BigDecimal.ONE));
显然,中缀运算符使公式更易读。运算符重载是支持用户定义或扩展类型的中缀运算符表示法的必要条件,例如 NumPy 数组。在一个高级、易于使用的语言中具有运算符重载可能是 Python 在数据科学领域取得巨大成功的关键原因,包括金融和科学应用。
在“模拟数值类型”(第一章)中,我们看到了一个简单的 Vector 类中运算符的实现。示例 1-2 中的 __add__ 和 __mul__ 方法是为了展示特殊方法如何支持运算符重载,但是它们的实现中存在一些微妙的问题被忽略了。此外,在示例 11-2 中,我们注意到 Vector2d.__eq__ 方法认为这是 True:Vector(3, 4) == [3, 4] ——这可能有或没有意义。我们将在本章中解决这些问题,以及:
-
中缀运算符方法应如何表示无法处理操作数
-
使用鸭子类型或鹅类型处理各种类型的操作数
-
丰富比较运算符的特殊行为(例如,
==,>,<=等) -
增强赋值运算符(如
+=)的默认处理方式,以及如何对其进行重载
本章的新内容
鹅类型是 Python 的一个关键部分,但 numbers ABCs 在静态类型中不受支持,因此我改变了示例 16-11 以使用鸭子类型而不是针对 numbers.Real 的显式 isinstance 检查。²
我在第一版的 Fluent Python 中介绍了 @ 矩阵乘法运算符,当 3.5 版本还处于 alpha 阶段时,它被视为即将到来的变化。因此,该运算符不再是一个旁注,而是在“使用 @ 作为中缀运算符”的章节流中整合了进去。我利用鹅类型使 __matmul__ 的实现比第一版更安全,而不会影响灵活性。
“进一步阅读” 现在有几个新的参考资料 —— 包括 Guido van Rossum 的一篇博客文章。我还添加了两个展示运算符重载在数学领域之外有效使用的库:pathlib 和 Scapy。
运算符重载 101
运算符重载允许用户定义的对象与中缀运算符(如 + 和 |)或一元运算符(如 - 和 ~)进行交互。更一般地说,函数调用(())、属性访问(.)和项目访问/切片([])在 Python 中也是运算符,但本章涵盖一元和中缀运算符。
运算符重载在某些圈子里名声不佳。这是一种语言特性,可能会被滥用,导致程序员困惑、错误和意外的性能瓶颈。但如果使用得当,它会导致愉快的 API 和可读的代码。Python 在灵活性、可用性和安全性之间取得了良好的平衡,通过施加一些限制:
-
我们不能改变内置类型的运算符的含义。
-
我们不能创建新的运算符,只能重载现有的运算符。
-
有一些运算符无法重载:
is,and,or,not(但位运算符&,|,~可以)。
在第十二章中,我们已经在Vector中有一个中缀运算符:==,由__eq__方法支持。在本章中,我们将改进__eq__的实现,以更好地处理除Vector之外的类型的操作数。然而,富比较运算符(==,!=,>,<,>=,<=)是运算符重载中的特殊情况,因此我们将从重载Vector中的四个算术运算符开始:一元-和+,然后是中缀+和*。
让我们从最简单的话题开始:一元运算符。
一元运算符
Python 语言参考,“6.5. 一元算术和位运算”列出了三个一元运算符,这里显示它们及其相关的特殊方法:
-,由__neg__实现
算术一元取反。如果x是-2,那么-x == 2。
+,由__pos__实现
算术一元加号。通常x == +x,但也有一些情况不成立。如果你感兴趣,可以查看“当 x 和 +x 不相等时”。
~,由__invert__实现
位取反,或整数的位反,定义为~x == -(x+1)。如果x是2,那么~x == -3。³
Python 语言参考的“数据模型”章节还将abs()内置函数列为一元运算符。相关的特殊方法是__abs__,正如我们之前看到的。
支持一元运算符很容易。只需实现适当的特殊方法,该方法只接受一个参数:self。在类中使用适当的逻辑,但遵循运算符的一般规则:始终返回一个新对象。换句话说,不要修改接收者(self),而是创建并返回一个适当类型的新实例。
对于-和+,结果可能是与self相同类的实例。对于一元+,如果接收者是不可变的,则应返回self;否则,返回self的副本。对于abs(),结果应该是一个标量数字。
至于~,如果不处理整数中的位,很难说会得到什么合理的结果。在pandas数据分析包中,波浪线对布尔过滤条件取反;请参阅pandas文档中的“布尔索引”以获取示例。
正如之前承诺的,我们将在第十二章的Vector类上实现几个新的运算符。示例 16-1 展示了我们已经在示例 12-16 中拥有的__abs__方法,以及新添加的__neg__和__pos__一元运算符方法。
示例 16-1. vector_v6.py:一元运算符 - 和 + 添加到示例 12-16
def __abs__(self):
return math.hypot(*self)
def __neg__(self):
return Vector(-x for x in self) # ①
def __pos__(self):
return Vector(self) # ②
①
要计算-v,构建一个新的Vector,其中包含self的每个分量的取反。
②
要计算+v,构建一个新的Vector,其中包含self的每个分量。
请记住,Vector实例是可迭代的,Vector.__init__接受一个可迭代的参数,因此__neg__和__pos__的实现简洁明了。
我们不会实现__invert__,因此如果用户在Vector实例上尝试~v,Python 将引发TypeError并显示清晰的消息:“一元~的错误操作数类型:'Vector'。”
以下侧边栏涵盖了一个关于一元+的好奇心,也许有一天可以帮你赢得一次赌注。
重载 + 实现向量加法
Vector类是一个序列类型,在官方 Python 文档的“数据模型”章节中的“3.3.6. 模拟容器类型”部分指出,序列应该支持+运算符进行连接和*进行重复。然而,在这里我们将实现+和*作为数学向量运算,这有点困难,但对于Vector类型更有意义。
提示
如果用户想要连接或重复Vector实例,他们可以将其转换为元组或列表,应用运算符,然后再转换回来——这要归功于Vector是可迭代的,并且可以从可迭代对象构建:
>>> v_concatenated = Vector(list(v1) + list(v2))
>>> v_repeated = Vector(tuple(v1) * 5)
将两个欧几里德向量相加会得到一个新的向量,其中的分量是操作数的分量的成对相加。举例说明:
>>> v1 = Vector([3, 4, 5])
>>> v2 = Vector([6, 7, 8])
>>> v1 + v2
Vector([9.0, 11.0, 13.0])
>>> v1 + v2 == Vector([3 + 6, 4 + 7, 5 + 8])
True
如果我们尝试将长度不同的两个Vector实例相加会发生什么?我们可以引发一个错误,但考虑到实际应用(如信息检索),最好是用零填充最短的Vector。这是我们想要的结果:
>>> v1 = Vector([3, 4, 5, 6])
>>> v3 = Vector([1, 2])
>>> v1 + v3
Vector([4.0, 6.0, 5.0, 6.0])
鉴于这些基本要求,我们可以像示例 16-4 中那样实现__add__。
示例 16-4. Vector.__add__ 方法,第一种情况
# inside the Vector class
def __add__(self, other):
pairs = itertools.zip_longest(self, other, fillvalue=0.0) # ①
return Vector(a + b for a, b in pairs) # ②
①
pairs是一个生成器,产生元组(a, b),其中a来自self,b来自other。如果self和other的长度不同,fillvalue会为最短的可迭代对象提供缺失值。
②
从生成器表达式构建一个新的Vector,为pairs中的每个(a, b)执行一次加法。
注意__add__如何返回一个新的Vector实例,并且不改变self或other。
警告
实现一元或中缀运算符的特殊方法永远不应更改操作数的值。带有这些运算符的表达式预期通过创建新对象来产生结果。只有增强赋值运算符可以更改第一个操作数(self),如“增强赋值运算符”中所讨论的。
示例 16-4 允许将Vector添加到Vector2d,以及将Vector添加到元组或任何产生数字的可迭代对象,正如示例 16-5 所证明的那样。
示例 16-5. Vector.__add__ 第一种情况也支持非Vector对象
>>> v1 = Vector([3, 4, 5])
>>> v1 + (10, 20, 30)
Vector([13.0, 24.0, 35.0])
>>> from vector2d_v3 import Vector2d
>>> v2d = Vector2d(1, 2)
>>> v1 + v2d
Vector([4.0, 6.0, 5.0])
示例 16-5 中+的两种用法都有效,因为__add__使用了zip_longest(…),它可以消耗任何可迭代对象,并且用于构建新Vector的生成器表达式仅执行zip_longest(…)产生的对中的a + b,因此产生任意数量项的可迭代对象都可以。
然而,如果我们交换操作数(示例 16-6),混合类型的加法会失败。
示例 16-6. Vector.__add__ 第一种情况在非Vector左操作数上失败
>>> v1 = Vector([3, 4, 5])
>>> (10, 20, 30) + v1
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: can only concatenate tuple (not "Vector") to tuple
>>> from vector2d_v3 import Vector2d
>>> v2d = Vector2d(1, 2)
>>> v2d + v1
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for +: 'Vector2d' and 'Vector'
为了支持涉及不同类型对象的操作,Python 为中缀运算符特殊方法实现了一种特殊的调度机制。给定表达式a + b,解释器将执行以下步骤(也参见图 16-1):
-
如果
a有__add__,则调用a.__add__(b)并返回结果,除非它是NotImplemented。 -
如果
a没有__add__,或者调用它返回NotImplemented,则检查b是否有__radd__,然后调用b.__radd__(a)并返回结果,除非它是NotImplemented。 -
如果
b没有__radd__,或者调用它返回NotImplemented,则引发TypeError,并显示不支持的操作数类型消息。
提示
__radd__方法被称为__add__的“反射”或“反转”版本。我更喜欢称它们为“反转”特殊方法。⁴

图 16-1. 使用__add__和__radd__计算a + b的流程图。
因此,为了使示例 16-6 中的混合类型加法起作用,我们需要实现Vector.__radd__方法,如果左操作数不实现__add__,或者实现了但返回NotImplemented以表示不知道如何处理右操作数,则 Python 将调用它作为后备。
警告
不要混淆NotImplemented和NotImplementedError。第一个NotImplemented是一个特殊的单例值,中缀运算符特殊方法应该返回以告诉解释器它无法处理给定的操作数。相反,NotImplementedError是一个异常,抽象类中的存根方法可能会引发以警告子类必须实现它们。
__radd__的最简单的工作实现在示例 16-7 中显示。
示例 16-7. Vector方法__add__和__radd__
# inside the Vector class
def __add__(self, other): # ①
pairs = itertools.zip_longest(self, other, fillvalue=0.0)
return Vector(a + b for a, b in pairs)
def __radd__(self, other): # ②
return self + other
①
与示例 16-4 中的__add__没有变化;这里列出是因为__radd__使用它。
②
__radd__只是委托给__add__。
__radd__通常很简单:只需调用适当的运算符,因此在这种情况下委托给__add__。这适用于任何可交换的运算符;当处理数字或我们的向量时,+是可交换的,但在 Python 中连接序列时不是可交换的。
如果__radd__简单地调用__add__,那么这是实现相同效果的另一种方法:
def __add__(self, other):
pairs = itertools.zip_longest(self, other, fillvalue=0.0)
return Vector(a + b for a, b in pairs)
__radd__ = __add__
示例 16-7 中的方法适用于Vector对象,或具有数字项的任何可迭代对象,例如Vector2d,一组整数的tuple,或一组浮点数的array。但如果提供了一个不可迭代的对象,__add__将引发一个带有不太有用消息的异常,就像示例 16-8 中一样。
示例 16-8. Vector.__add__方法需要一个可迭代的操作数
>>> v1 + 1
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "vector_v6.py", line 328, in __add__
pairs = itertools.zip_longest(self, other, fillvalue=0.0)
TypeError: zip_longest argument #2 must support iteration
更糟糕的是,如果一个操作数是可迭代的,但其项无法添加到Vector中的float项中,则会得到一个误导性的消息。请参见示例 16-9。
示例 16-9. Vector.__add__方法需要具有数字项的可迭代对象
>>> v1 + 'ABC'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "vector_v6.py", line 329, in __add__
return Vector(a + b for a, b in pairs)
File "vector_v6.py", line 243, in __init__
self._components = array(self.typecode, components)
File "vector_v6.py", line 329, in <genexpr>
return Vector(a + b for a, b in pairs)
TypeError: unsupported operand type(s) for +: 'float' and 'str'
我尝试添加Vector和一个str,但消息抱怨float和str。
示例 16-8 和 16-9 中的问题实际上比晦涩的错误消息更深:如果一个运算符特殊方法由于类型不兼容而无法返回有效结果,它应该返回NotImplemented而不是引发TypeError。通过返回NotImplemented,您为另一个操作数类型的实现者留下了机会,在 Python 尝试调用反向方法时执行操作。
符合鸭子类型的精神,我们将避免测试other操作数的类型,或其元素的类型。我们将捕获异常并返回NotImplemented。如果解释器尚未颠倒操作数,则将尝试这样做。如果反向方法调用返回NotImplemented,那么 Python 将引发TypeError,并显示标准错误消息,如“不支持的操作数类型:Vector和str”。
Vector加法的特殊方法的最终实现在示例 16-10 中。
示例 16-10. vector_v6.py:向 vector_v5.py 添加了运算符+方法(示例 12-16)
def __add__(self, other):
try:
pairs = itertools.zip_longest(self, other, fillvalue=0.0)
return Vector(a + b for a, b in pairs)
except TypeError:
return NotImplemented
def __radd__(self, other):
return self + other
注意,__add__现在捕获TypeError并返回NotImplemented。
警告
如果中缀运算符方法引发异常,则会中止运算符分派算法。在TypeError的特定情况下,通常最好捕获它并返回 NotImplemented。这允许解释器尝试调用反向运算符方法,如果它们是不同类型的,则可能正确处理交换操作数的计算。
到目前为止,我们已经通过编写__add__和__radd__安全地重载了+运算符。现在我们将处理另一个中缀运算符:*。
为标量乘法重载*
Vector([1, 2, 3]) * x是什么意思?如果x是一个数字,那将是一个标量乘积,结果将是一个每个分量都乘以x的新Vector——也被称为逐元素乘法:
>>> v1 = Vector([1, 2, 3])
>>> v1 * 10
Vector([10.0, 20.0, 30.0])
>>> 11 * v1
Vector([11.0, 22.0, 33.0])
注意
涉及Vector操作数的另一种产品类型将是两个向量的点积,或者矩阵乘法,如果你将一个向量视为 1×N 矩阵,另一个向量视为 N×1 矩阵。我们将在我们的Vector类中实现该运算符,详见“使用@作为中缀运算符”。
再次回到我们的标量乘积,我们从可能起作用的最简单的__mul__和__rmul__方法开始:
# inside the Vector class
def __mul__(self, scalar):
return Vector(n * scalar for n in self)
def __rmul__(self, scalar):
return self * scalar
这些方法确实有效,除非提供了不兼容的操作数。scalar参数必须是一个数字,当乘以一个float时产生另一个float(因为我们的Vector类在内部使用float数组)。因此,一个complex数是不行的,但标量可以是一个int、一个bool(因为bool是int的子类),甚至是一个fractions.Fraction实例。在示例 16-11 中,__mul__方法没有对scalar进行显式类型检查,而是将其转换为float,如果失败则返回NotImplemented。这是鸭子类型的一个明显例子。
示例 16-11. vector_v7.py:添加*方法
class Vector:
typecode = 'd'
def __init__(self, components):
self._components = array(self.typecode, components)
# many methods omitted in book listing, see vector_v7.py
# in https://github.com/fluentpython/example-code-2e
def __mul__(self, scalar):
try:
factor = float(scalar)
except TypeError: # ①
return NotImplemented # ②
return Vector(n * factor for n in self)
def __rmul__(self, scalar):
return self * scalar # ③
①
如果scalar无法转换为float…
②
…我们不知道如何处理它,所以我们返回NotImplemented,让 Python 尝试在scalar操作数上执行__rmul__。
③
在这个例子中,__rmul__通过执行self * scalar来正常工作,委托给__mul__方法。
通过示例 16-11,我们可以将Vectors乘以通常和不太常见的数值类型的标量值:
>>> v1 = Vector([1.0, 2.0, 3.0])
>>> 14 * v1
Vector([14.0, 28.0, 42.0])
>>> v1 * True
Vector([1.0, 2.0, 3.0])
>>> from fractions import Fraction
>>> v1 * Fraction(1, 3)
Vector([0.3333333333333333, 0.6666666666666666, 1.0])
现在我们可以将Vector乘以标量,让我们看看如何实现Vector乘以Vector的乘积。
注意
在Fluent Python的第一版中,我在示例 16-11 中使用了鹅类型:我用isinstance(scalar, numbers.Real)检查了__mul__的scalar参数。现在我避免使用numbers ABCs,因为它们不受 PEP 484 支持,而且在运行时使用无法静态检查的类型对我来说似乎不是一个好主意。
或者,我可以针对我们在“运行时可检查的静态协议”中看到的typing.SupportsFloat协议进行检查。在那个示例中,我选择了鸭子类型,因为我认为精通 Python 的人应该对这种编码模式感到舒适。
另一方面,在示例 16-12 中的__matmul__是鹅类型的一个很好的例子,这是第二版中新增的。
使用@作为中缀运算符
@符号众所周知是函数装饰器的前缀,但自 2015 年以来,它也可以用作中缀运算符。多年来,在 NumPy 中,点积被写为numpy.dot(a, b)。函数调用符号使得从数学符号到 Python 的长公式更难以转换,因此数值计算社区游说支持PEP 465—用于矩阵乘法的专用中缀运算符,这在 Python 3.5 中实现。今天,你可以写a @ b来计算两个 NumPy 数组的点积。
@运算符由特殊方法__matmul__、__rmatmul__和__imatmul__支持,命名为“矩阵乘法”。这些方法目前在标准库中没有被使用,但自 Python 3.5 以来,解释器已经认可它们,因此 NumPy 团队——以及我们其他人——可以在用户定义的类型中支持@运算符。解析器也已更改以处理新运算符(在 Python 3.4 中,a @ b是语法错误)。
这些简单的测试展示了@应该如何与Vector实例一起工作:
>>> va = Vector([1, 2, 3])
>>> vz = Vector([5, 6, 7])
>>> va @ vz == 38.0 # 1*5 + 2*6 + 3*7
True
>>> [10, 20, 30] @ vz
380.0
>>> va @ 3
Traceback (most recent call last):
...
TypeError: unsupported operand type(s) for @: 'Vector' and 'int'
示例 16-12 展示了相关特殊方法的代码。
示例 16-12. vector_v7.py:操作符@方法
class Vector:
# many methods omitted in book listing
def __matmul__(self, other):
if (isinstance(other, abc.Sized) and # ①
isinstance(other, abc.Iterable)):
if len(self) == len(other): # ②
return sum(a * b for a, b in zip(self, other)) # ③
else:
raise ValueError('@ requires vectors of equal length.')
else:
return NotImplemented
def __rmatmul__(self, other):
return self @ other
①
两个操作数必须实现__len__和__iter__…
②
…并且具有相同的长度以允许…
③
…sum、zip和生成器表达式的一个美妙应用。
Python 3.10 中的新 zip() 特性
zip 内置函数自 Python 3.10 起接受一个strict关键字参数。当strict=True时,当可迭代对象的长度不同时,函数会引发ValueError。默认值为False。这种新的严格行为符合 Python 的快速失败哲学。在示例 16-12 中,我会用try/except ValueError替换内部的if,并在zip调用中添加strict=True。
示例 16-12 是实践中鹅类型的一个很好的例子。如果我们将other操作数与Vector进行测试,我们将剥夺用户使用列表或数组作为@操作数的灵活性。只要一个操作数是Vector,我们的@实现就支持其他操作数是abc.Sized和abc.Iterable的实例。这两个 ABC 都实现了__subclasshook__,因此任何提供__len__和__iter__的对象都满足我们的测试——无需实际子类化这些 ABC,甚至无需向它们注册,如“使用 ABC 进行结构化类型检查”中所解释的那样。特别是,我们的Vector类既不是abc.Sized的子类,也不是abc.Iterable的子类,但它通过了对这些 ABC 的isinstance检查,因为它具有必要的方法。
在深入讨论“富比较运算符”的特殊类别之前,让我们回顾一下 Python 支持的算术运算符。
算术运算符总结
通过实现+、*和@,我们看到了编写中缀运算符的最常见模式。我们描述的技术适用于表 16-1 中列出的所有运算符(就地运算符将在“增强赋值运算符”中介绍)。
表 16-1. 中缀运算符方法名称(就地运算符用于增强赋值;比较运算符在表 16-2 中)
| 运算符 | 正向 | 反向 | 就地 | 描述 |
|---|---|---|---|---|
+ |
__add__ |
__radd__ |
__iadd__ |
加法或连接 |
- |
__sub__ |
__rsub__ |
__isub__ |
减法 |
* |
__mul__ |
__rmul__ |
__imul__ |
乘法或重复 |
/ |
__truediv__ |
__rtruediv__ |
__itruediv__ |
真除法 |
// |
__floordiv__ |
__rfloordiv__ |
__ifloordiv__ |
地板除法 |
% |
__mod__ |
__rmod__ |
__imod__ |
取模 |
divmod() |
__divmod__ |
__rdivmod__ |
__idivmod__ |
返回地板除法商和模数的元组 |
**, pow() |
__pow__ |
__rpow__ |
__ipow__ |
指数运算^(a) |
@ |
__matmul__ |
__rmatmul__ |
__imatmul__ |
矩阵乘法 |
& |
__and__ |
__rand__ |
__iand__ |
位与 |
| | | __or__ |
__ror__ |
__ior__ |
位或 |
^ |
__xor__ |
__rxor__ |
__ixor__ |
位异或 |
<< |
__lshift__ |
__rlshift__ |
__ilshift__ |
位左移 |
>> |
__rshift__ |
__rrshift__ |
__irshift__ |
位右移 |
^(a) pow 接受一个可选的第三个参数,modulo:pow(a, b, modulo),在直接调用时也由特殊方法支持(例如,a.__pow__(b, modulo))。 |
富比较运算符使用不同的规则。
富比较运算符
Python 解释器对富比较运算符==、!=、>、<、>=和<=的处理与我们刚才看到的类似,但在两个重要方面有所不同:
-
在前向和反向运算符调用中使用相同的方法集。规则总结在表 16-2 中。例如,在
==的情况下,前向和反向调用都调用__eq__,只是交换参数;前向调用__gt__后跟着反向调用__lt__,参数交换。 -
在
==和!=的情况下,如果缺少反向方法,或者返回NotImplemented,Python 会比较对象 ID 而不是引发TypeError。
表 16-2. 富比较运算符:当初始方法调用返回NotImplemented时调用反向方法
| 组 | 中缀运算符 | 前向方法调用 | 反向方法调用 | 回退 |
|---|---|---|---|---|
| 相等性 | a == b |
a.__eq__(b) |
b.__eq__(a) |
返回id(a) == id(b) |
a != b |
a.__ne__(b) |
b.__ne__(a) |
返回not (a == b) |
|
| 排序 | a > b |
a.__gt__(b) |
b.__lt__(a) |
引发TypeError |
a < b |
a.__lt__(b) |
b.__gt__(a) |
引发TypeError |
|
a >= b |
a.__ge__(b) |
b.__le__(a) |
引发TypeError |
|
a <= b |
a.__le__(b) |
b.__ge__(a) |
引发TypeError |
鉴于这些规则,让我们审查并改进Vector.__eq__方法的行为,该方法在vector_v5.py中编码如下(示例 12-16):
class Vector:
# many lines omitted
def __eq__(self, other):
return (len(self) == len(other) and
all(a == b for a, b in zip(self, other)))
该方法产生了示例 16-13 中的结果。
示例 16-13. 将Vector与Vector、Vector2d和tuple进行比较
>>> va = Vector([1.0, 2.0, 3.0])
>>> vb = Vector(range(1, 4))
>>> va == vb # ①
True >>> vc = Vector([1, 2])
>>> from vector2d_v3 import Vector2d
>>> v2d = Vector2d(1, 2)
>>> vc == v2d # ②
True >>> t3 = (1, 2, 3)
>>> va == t3 # ③
True
①
具有相等数值组件的两个Vector实例比较相等。
②
如果它们的组件相等,Vector和Vector2d也相等。
③
Vector也被视为等于包含相同数值的tuple或任何可迭代对象。
示例 16-13 中的结果可能不理想。我们真的希望Vector被视为等于包含相同数字的tuple吗?我对此没有硬性规定;这取决于应用上下文。《Python 之禅》说:
面对模棱两可的情况,拒绝猜测的诱惑。
在评估操作数时过于宽松可能导致令人惊讶的结果,程序员讨厌惊喜。
借鉴于 Python 本身,我们可以看到[1,2] == (1, 2)是False。因此,让我们保守一点并进行一些类型检查。如果第二个操作数是Vector实例(或Vector子类的实例),那么使用与当前__eq__相同的逻辑。否则,返回NotImplemented并让 Python 处理。参见示例 16-14。
示例 16-14. vector_v8.py:改进了Vector类中的__eq__
def __eq__(self, other):
if isinstance(other, Vector): # ①
return (len(self) == len(other) and
all(a == b for a, b in zip(self, other)))
else:
return NotImplemented # ②
①
如果other操作数是Vector的实例(或Vector子类的实例),则像以前一样执行比较。
②
否则,返回NotImplemented。
如果您使用来自示例 16-14 的新Vector.__eq__运行示例 16-13 中的测试,现在得到的结果如示例 16-15 所示。
示例 16-15. 与示例 16-13 相同的比较:最后结果改变
>>> va = Vector([1.0, 2.0, 3.0])
>>> vb = Vector(range(1, 4))
>>> va == vb # ①
True >>> vc = Vector([1, 2])
>>> from vector2d_v3 import Vector2d
>>> v2d = Vector2d(1, 2)
>>> vc == v2d # ②
True >>> t3 = (1, 2, 3)
>>> va == t3 # ③
False
①
与预期一样,与之前相同的结果。
②
与之前相同的结果,但为什么?解释即将到来。
③
不同的结果;这就是我们想要的。但是为什么会起作用?继续阅读…
在 示例 16-15 中的三个结果中,第一个不是新闻,但最后两个是由 示例 16-14 中的 __eq__ 返回 NotImplemented 导致的。以下是在一个 Vector 和一个 Vector2d 的示例中发生的情况,vc == v2d,逐步进行:
-
要评估
vc == v2d,Python 调用Vector.__eq__(vc, v2d)。 -
Vector.__eq__(vc, v2d)验证v2d不是Vector并返回NotImplemented。 -
Python 得到
NotImplemented的结果,因此尝试Vector2d.__eq__(v2d, vc)。 -
Vector2d.__eq__(v2d, vc)将两个操作数转换为元组并进行比较:结果为True(Vector2d.__eq__的代码在 示例 11-11 中)。
至于比较 va == t3,在 示例 16-15 中的 Vector 和 tuple 之间,实际步骤如下:
-
要评估
va == t3,Python 调用Vector.__eq__(va, t3)。 -
Vector.__eq__(va, t3)验证t3不是Vector并返回NotImplemented。 -
Python 得到
NotImplemented的结果,因此尝试tuple.__eq__(t3, va)。 -
tuple.__eq__(t3, va)不知道什么是Vector,所以返回NotImplemented。 -
在
==的特殊情况下,如果反向调用返回NotImplemented,Python 将比较对象 ID 作为最后的手段。
对于 != 我们不需要为 __ne__ 实现,因为从 object 继承的 __ne__ 的后备行为适合我们:当 __eq__ 被定义且不返回 NotImplemented 时,__ne__ 返回该结果的否定。
换句话说,给定我们在 示例 16-15 中使用的相同对象,!= 的结果是一致的:
>>> va != vb
False
>>> vc != v2d
False
>>> va != (1, 2, 3)
True
从 object 继承的 __ne__ 的工作方式如下代码所示——只是原始代码是用 C 编写的:⁶
def __ne__(self, other):
eq_result = self == other
if eq_result is NotImplemented:
return NotImplemented
else:
return not eq_result
在介绍了中缀运算符重载的基本知识之后,让我们转向另一类运算符:增强赋值运算符。
增强赋值运算符
我们的 Vector 类已经支持增强赋值运算符 += 和 *=。这是因为增强赋值对于不可变接收者通过创建新实例并重新绑定左侧变量来工作。
示例 16-16 展示了它们的运行方式。
示例 16-16. 使用 += 和 *= 与 Vector 实例
>>> v1 = Vector([1, 2, 3])
>>> v1_alias = v1 # ①
>>> id(v1) # ②
4302860128 >>> v1 += Vector([4, 5, 6]) # ③
>>> v1 # ④
Vector([5.0, 7.0, 9.0]) >>> id(v1) # ⑤
4302859904 >>> v1_alias # ⑥
Vector([1.0, 2.0, 3.0]) >>> v1 *= 11 # ⑦
>>> v1 # ⑧
Vector([55.0, 77.0, 99.0]) >>> id(v1)
4302858336
①
创建一个别名,以便稍后检查 Vector([1, 2, 3]) 对象。
②
记住绑定到 v1 的初始 Vector 的 ID。
③
执行增强加法。
④
预期的结果…
⑤
…但是创建了一个新的 Vector。
⑥
检查 v1_alias 以确认原始的 Vector 没有被改变。
⑦
执行增强乘法。
⑧
再次,预期的结果,但是创建了一个新的 Vector。
如果一个类没有实现 Table 16-1 中列出的原地操作符,增强赋值运算符将作为语法糖:a += b 将被完全解释为 a = a + b。这是对于不可变类型的预期行为,如果你有 __add__,那么 += 将可以工作而无需额外的代码。
然而,如果你实现了一个原地操作符方法,比如 __iadd__,那么该方法将被调用来计算 a += b 的结果。正如其名称所示,这些操作符预期会就地更改左操作数,并且不会像结果那样创建一个新对象。
警告
不可变类型如我们的 Vector 类不应该实现原地特殊方法。这是相当明显的,但无论如何值得声明。
为了展示就地运算符的代码,我们将扩展BingoCage类,从示例 13-9 实现__add__和__iadd__。
我们将子类称为AddableBingoCage。示例 16-17 是我们想要+运算符的行为。
示例 16-17。+运算符创建一个新的AddableBingoCage实例
>>> vowels = 'AEIOU'
>>> globe = AddableBingoCage(vowels) # ①
>>> globe.inspect()
('A', 'E', 'I', 'O', 'U')
>>> globe.pick() in vowels # ②
True
>>> len(globe.inspect()) # ③
4
>>> globe2 = AddableBingoCage('XYZ') # ④
>>> globe3 = globe + globe2
>>> len(globe3.inspect()) # ⑤
7
>>> void = globe + [10, 20] # ⑥
Traceback (most recent call last):
...
TypeError: unsupported operand type(s) for +: 'AddableBingoCage' and 'list'
①
创建一个具有五个项目(每个vowels)的globe实例。
②
弹出其中一个项目,并验证它是否是vowels之一。
③
确认globe只剩下四个项目。
④
创建第二个实例,有三个项目。
⑤
通过将前两个实例相加创建第三个实例。这个实例有七个项目。
⑥
尝试将AddableBingoCage添加到list中会导致TypeError。当我们的__add__方法返回NotImplemented时,Python 解释器会产生该错误消息。
因为AddableBingoCage是可变的,示例 16-18 展示了当我们实现__iadd__时它将如何工作。
示例 16-18。现有的AddableBingoCage可以使用+=加载(继续自示例 16-17)
>>> globe_orig = globe # ①
>>> len(globe.inspect()) # ②
4
>>> globe += globe2 # ③
>>> len(globe.inspect())
7
>>> globe += ['M', 'N'] # ④
>>> len(globe.inspect())
9
>>> globe is globe_orig # ⑤
True
>>> globe += 1 # ⑥
Traceback (most recent call last):
...
TypeError: right operand in += must be 'Tombola' or an iterable
①
创建一个别名,以便稍后检查对象的标识。
②
这里的globe有四个项目。
③
一个AddableBingoCage实例可以接收来自同一类的另一个实例的项目。
④
+=的右操作数也可以是任何可迭代对象。
⑤
在整个示例中,globe一直指的是与globe_orig相同的对象。
⑥
尝试将不可迭代的内容添加到AddableBingoCage中会失败,并显示适当的错误消息。
注意+=运算符相对于第二个操作数更加宽松。对于+,我们希望两个操作数的类型相同(在这种情况下为AddableBingoCage),因为如果我们接受不同类型,可能会导致对结果类型的混淆。对于+=,情况更加清晰:左侧对象在原地更新,因此对结果的类型没有疑问。
提示
通过观察list内置类型的工作方式,我验证了+和+=的对比行为。编写my_list + x,你只能将一个list连接到另一个list,但如果你写my_list += x,你可以使用右侧的任何可迭代对象x扩展左侧的list。这就是list.extend()方法的工作方式:它接受任何可迭代的参数。
现在我们清楚了AddableBingoCage的期望行为,我们可以查看其在示例 16-19 中的实现。回想一下,BingoCage,来自示例 13-9,是TombolaABC 的具体子类,来自示例 13-7。
示例 16-19。bingoaddable.py:AddableBingoCage扩展BingoCage以支持+和+=
from tombola import Tombola
from bingo import BingoCage
class AddableBingoCage(BingoCage): # ①
def __add__(self, other):
if isinstance(other, Tombola): # ②
return AddableBingoCage(self.inspect() + other.inspect())
else:
return NotImplemented
def __iadd__(self, other):
if isinstance(other, Tombola):
other_iterable = other.inspect() # ③
else:
try:
other_iterable = iter(other) # ④
except TypeError: # ⑤
msg = ('right operand in += must be '
"'Tombola' or an iterable")
raise TypeError(msg)
self.load(other_iterable) # ⑥
return self # ⑦
①
AddableBingoCage扩展BingoCage。
②
我们的__add__只能与Tombola的实例作为第二个操作数一起使用。
③
在__iadd__中,从other中检索项目,如果它是Tombola的实例。
④
否则,尝试从other中获取一个迭代器。⁷
⑤
如果失败,引发一个解释用户应该做什么的异常。 在可能的情况下,错误消息应明确指导用户解决方案。
⑥
如果我们走到这一步,我们可以将 other_iterable 加载到 self 中。
⑦
非常重要:可变对象的增强赋值特殊方法必须返回 self。 这是用户的期望。
我们可以通过对比在示例 16-19 中产生结果的 __add__ 和 __iadd__ 中的 return 语句来总结就地运算符的整个概念:
__add__
通过调用构造函数 AddableBingoCage 来生成结果以构建一个新实例。
__iadd__
通过修改后返回 self 生成结果。
结束这个示例时,对示例 16-19 的最后观察:按设计,AddableBingoCage 中没有编写 __radd__,因为没有必要。 前向方法 __add__ 仅处理相同类型的右操作数,因此如果 Python 尝试计算 a + b,其中 a 是 AddableBingoCage 而 b 不是,则返回 NotImplemented—也许 b 的类可以使其工作。 但是如果表达式是 b + a 而 b 不是 AddableBingoCage,并且返回 NotImplemented,那么最好让 Python 放弃并引发 TypeError,因为我们无法处理 b。
提示
一般来说,如果一个前向中缀运算符方法(例如 __mul__)设计为仅与与 self 相同类型的操作数一起使用,那么实现相应的反向方法(例如 __rmul__)是没有用的,因为根据定义,只有在处理不同类型的操作数时才会调用它。
我们的 Python 运算符重载探索到此结束。
章节总结
我们从回顾 Python 对运算符重载施加的一些限制开始:不能在内置类型本身中重新定义运算符,重载仅限于现有运算符,有一些运算符被排除在外(is、and、or、not)。
我们从一元运算符入手,实现了 __neg__ 和 __pos__。 接下来是中缀运算符,从 + 开始,由 __add__ 方法支持。 我们看到一元和中缀运算符应通过创建新对象来生成结果,并且永远不应更改其操作数。 为了支持与其他类型的操作,我们返回 NotImplemented 特殊值—而不是异常—允许解释器通过交换操作数并调用该运算符的反向特殊方法(例如 __radd__)再次尝试。 Python 用于处理中缀运算符的算法在图 16-1 中总结。
混合操作数类型需要检测我们无法处理的操作数。 在本章中,我们以两种方式实现了这一点:在鸭子类型方式中,我们只是继续尝试操作,如果发生 TypeError 异常,则捕获它;稍后,在 __mul__ 和 __matmul__ 中,我们通过显式的 isinstance 测试来实现。 这些方法各有利弊:鸭子类型更灵活,但显式类型检查更可预测。
一般来说,库应该利用鸭子类型——打开对象的大门,无论它们的类型如何,只要它们支持必要的操作即可。然而,Python 的运算符分发算法可能在与鸭子类型结合时产生误导性的错误消息或意外的结果。因此,在编写用于运算符重载的特殊方法时,使用isinstance调用 ABCs 进行类型检查的纪律通常是有用的。这就是亚历克斯·马特利所称的鹅类型技术,我们在“鹅类型”中看到了。鹅类型是灵活性和安全性之间的一个很好的折衷方案,因为现有或未来的用户定义类型可以声明为 ABC 的实际或虚拟子类。此外,如果一个 ABC 实现了__subclasshook__,那么对象通过提供所需的方法可以通过该 ABC 的isinstance检查—不需要子类化或注册。
我们接下来讨论的话题是丰富的比较运算符。我们用__eq__实现了==,并发现 Python 在object基类中提供了一个方便的!=实现,即__ne__。Python 评估这些运算符的方式与>, <, >=, 和 <=略有不同,对于选择反向方法有特殊逻辑,并且对于==和!=有后备处理,因为 Python 比较对象 ID 作为最后的手段,从不生成错误。
在最后一节中,我们专注于增强赋值运算符。我们看到 Python 默认将它们处理为普通运算符后跟赋值的组合,即:a += b被完全解释为a = a + b。这总是创建一个新对象,因此适用于可变或不可变类型。对于可变对象,我们可以实现就地特殊方法,比如__iadd__用于+=,并改变左操作数的值。为了展示这一点,我们放下了不可变的Vector类,开始实现一个BingoCage子类,支持+=用于向随机池添加项目,类似于list内置支持+=作为list.extend()方法的快捷方式。在这个过程中,我们讨论了+相对于接受的类型更为严格的问题。对于序列类型,+通常要求两个操作数是相同类型,而+=通常接受任何可迭代对象作为右操作数。
进一步阅读
Guido van Rossum 在“为什么运算符有用”中写了一篇很好的运算符重载辩护。Trey Hunner 在博客“Python 中的元组排序和深度比较”中辩称,Python 中的丰富比较运算符比程序员从其他语言转换过来时可能意识到的更灵活和强大。
运算符重载是 Python 编程中一个常见的地方,其中isinstance测试很常见。围绕这些测试的最佳实践是鹅类型,详见“鹅类型”。如果你跳过了这部分,请确保阅读一下。
运算符特殊方法的主要参考是 Python 文档中的“数据模型”章节。另一个相关阅读是Python 标准库中numbers模块的“9.1.2.2. 实现算术运算”。
一个聪明的运算符重载例子出现在 Python 3.4 中添加的pathlib包中。它的Path类重载了/运算符,用于从字符串构建文件系统路径,如文档中所示的示例:
>>> p = Path('/etc')
>>> q = p / 'init.d' / 'reboot'
>>> q
PosixPath('/etc/init.d/reboot')
另一个非算术运算符重载的例子是Scapy库,用于“发送、嗅探、解剖和伪造网络数据包”。在 Scapy 中,/运算符通过堆叠来自不同网络层的字段来构建数据包。详见“堆叠层”。
如果你即将实现比较运算符,请研究functools.total_ordering。这是一个类装饰器,可以自动生成定义了至少一些富比较运算符的类中的所有富比较运算符的方法。请参考functools 模块文档。
如果你对动态类型语言中的运算符方法分派感兴趣,两篇开创性的文章是 Dan Ingalls(原 Smalltalk 团队成员)的“处理多态的简单技术”,以及 Kurt J. Hebel 和 Ralph Johnson(Johnson 因为是原始《设计模式》书籍的作者之一而出名)的“Smalltalk-80 中的算术和双重分派”。这两篇论文深入探讨了动态类型语言(如 Smalltalk、Python 和 Ruby)中多态的强大之处。Python 不使用这些文章中描述的双重分派来处理运算符。Python 算法使用前向和后向运算符对于用户定义的类来说更容易支持,但需要解释器进行特殊处理。相比之下,经典的双重分派是一种通用技术,你可以在 Python 或任何面向对象的语言中使用,超越了中缀运算符的特定上下文,事实上,Ingalls、Hebel 和 Johnson 使用非常不同的例子来描述它。
文章“C 语言家族:与丹尼斯·里奇、比雅尼·斯特劳斯特鲁普和詹姆斯·高斯林的访谈”,我引用了本章前言中的摘录,发表于Java Report,2000 年 7 月,第 5 卷第 7 期,以及C++ Report,2000 年 7 月/8 月,第 12 卷第 7 期,还有本章“讲台”中使用的另外两个片段。如果你对编程语言设计感兴趣,请务必阅读该访谈。
¹ 来源:“C 语言家族:与丹尼斯·里奇、比雅尼·斯特劳斯特鲁普和詹姆斯·高斯林的访谈”。
² Python 标准库中剩余的 ABC 对于鹅类型和静态类型仍然有价值。numbers ABC 的问题在“数字 ABC 和数值协议”中有解释。
³ 请参考https://en.wikipedia.org/wiki/Bitwise_operation#NOT解释按位非操作。
⁴ Python 文档同时使用这两个术语。“数据模型”章节使用“reflected”,但numbers模块文档中的“9.1.2.2. 实现算术运算”提到“forward”和“reverse”方法,我认为这个术语更好,因为“forward”和“reversed”清楚地命名了每个方向,而“reflected”没有明显的对应词。
⁵ 请参考“讲台”讨论该问题。
⁶ object.__eq__和object.__ne__的逻辑在 CPython 源代码的Objects/typeobject.c中的object_richcompare函数中。
⁷ iter内置函数将在下一章中介绍。在这里,我可以使用tuple(other),它也可以工作,但会建立一个新的tuple,而所有.load(…)方法需要的只是对其参数进行迭代。
第四部分:控制流
第十七章:迭代器、生成器和经典协程
当我在我的程序中看到模式时,我认为这是一个麻烦的迹象。程序的形状应该只反映它需要解决的问题。代码中的任何其他规律性对我来说都是一个迹象,至少对我来说,这表明我使用的抽象不够强大——通常是我手动生成我需要编写的某个宏的扩展。
Paul Graham,Lisp 程序员和风险投资家¹
迭代对于数据处理是基础的:程序将计算应用于数据系列,从像素到核苷酸。如果数据不适合内存,我们需要惰性地获取项目——一次一个,并按需获取。这就是迭代器的作用。本章展示了迭代器设计模式是如何内置到 Python 语言中的,因此您永远不需要手动编写它。
Python 中的每个标准集合都是可迭代的。可迭代是提供迭代器的对象,Python 使用它来支持诸如:
-
for循环 -
列表、字典和集合推导
-
解包赋值
-
集合实例的构建
本章涵盖以下主题:
-
Python 如何使用
iter()内置函数处理可迭代对象 -
如何在 Python 中实现经典迭代器模式
-
经典迭代器模式如何被生成器函数或生成器表达式替代
-
详细介绍生成器函数的工作原理,逐行描述
-
利用标准库中的通用生成器函数
-
使用
yield from表达式组合生成器 -
为什么生成器和经典协程看起来相似但用法却截然不同,不应混合使用
本章的新内容
“使用 yield from 的子生成器” 从一页发展到六页。现在它包括了演示使用yield from生成器行为的更简单实验,以及逐步开发树数据结构遍历的示例。
新的部分解释了Iterable、Iterator和Generator类型的类型提示。
本章的最后一个重要部分,“经典协程”,是对一个主题的介绍,第一版中占据了一个 40 页的章节。我更新并将“经典协程”章节移至伴随网站的帖子,因为这是读者最具挑战性的章节,但在 Python 3.5 引入原生协程后,其主题的相关性较小,我们将在第二十一章中学习。
我们将开始学习iter()内置函数如何使序列可迭代。
一系列单词
我们将通过实现一个Sentence类来开始探索可迭代对象:你可以将一些文本传递给它的构造函数,然后逐个单词进行迭代。第一个版本将实现序列协议,并且它是可迭代的,因为所有序列都是可迭代的——正如我们在第一章中所看到的。现在我们将看到确切的原因。
示例 17-1 展示了一个从文本中提取单词的Sentence类。
示例 17-1。sentence.py:一个将文本按单词索引提取的Sentence类
import re
import reprlib
RE_WORD = re.compile(r'\w+')
class Sentence:
def __init__(self, text):
self.text = text
self.words = RE_WORD.findall(text) # ①
def __getitem__(self, index):
return self.words[index] # ②
def __len__(self): # ③
return len(self.words)
def __repr__(self):
return 'Sentence(%s)' % reprlib.repr(self.text) # ④
①
.findall 返回一个字符串列表,其中包含正则表达式的所有非重叠匹配。
②
self.words保存了.findall的结果,因此我们只需返回给定索引处的单词。
③
为了完成序列协议,我们实现了__len__,尽管不需要使其可迭代。
④
reprlib.repr是一个实用函数,用于生成数据结构的缩写字符串表示,这些数据结构可能非常庞大。²
默认情况下,reprlib.repr将生成的字符串限制为 30 个字符。查看示例 17-2 中的控制台会话,了解如何使用Sentence。
示例 17-2。在Sentence实例上测试迭代
>>> s = Sentence('"The time has come," the Walrus said,') # ①
>>> s
Sentence('"The time ha... Walrus said,') # ②
>>> for word in s: # ③
... print(word)
The time has come the Walrus said >>> list(s) # ④
['The', 'time', 'has', 'come', 'the', 'Walrus', 'said']
①
从字符串创建一个句子。
②
注意使用reprlib.repr生成的__repr__输出中的...。
③
Sentence实例是可迭代的;我们马上就会看到原因。
④
作为可迭代对象,Sentence对象可用作构建列表和其他可迭代类型的输入。
在接下来的页面中,我们将开发其他通过示例 17-2 中测试的Sentence类。然而,示例 17-1 中的实现与其他实现不同,因为它也是一个序列,所以你可以通过索引获取单词:
>>> s[0]
'The'
>>> s[5]
'Walrus'
>>> s[-1]
'said'
Python 程序员知道序列是可迭代的。现在我们将看到具体原因。
为什么序列是可迭代的:iter 函数
每当 Python 需要对对象x进行迭代时,它会自动调用iter(x)。
iter内置函数:
-
检查对象是否实现了
__iter__,并调用它以获取迭代器。 -
如果未实现
__iter__,但实现了__getitem__,那么iter()会创建一个迭代器,尝试从 0(零)开始按索引获取项目。 -
如果失败,Python 会引发
TypeError,通常会显示'C'对象不可迭代,其中C是目标对象的类。
这就是为什么所有的 Python 序列都是可迭代的:根据定义,它们都实现了__getitem__。事实上,标准序列也实现了__iter__,你的序列也应该实现,因为通过__getitem__进行迭代是为了向后兼容,可能在未来会被移除——尽管在 Python 3.10 中尚未被弃用,我怀疑它会被移除。
如“Python 挖掘序列”中所述,这是一种极端的鸭子类型:一个对象被视为可迭代对象不仅当它实现了特殊方法__iter__,还当它实现了__getitem__。看一下:
>>> class Spam:
... def __getitem__(self, i):
... print('->', i)
... raise IndexError()
...
>>> spam_can = Spam()
>>> iter(spam_can)
<iterator object at 0x10a878f70>
>>> list(spam_can)
-> 0
[]
>>> from collections import abc
>>> isinstance(spam_can, abc.Iterable)
False
如果一个类提供了__getitem__,则iter()内置函数接受该类的实例作为可迭代对象,并从实例构建迭代器。Python 的迭代机制将从 0 开始调用__getitem__,并将IndexError作为没有更多项目的信号。
请注意,尽管spam_can是可迭代的(其__getitem__可以提供项目),但它不被isinstance识别为abc.Iterable。
在鹅类型方法中,可迭代对象的定义更简单但不够灵活:如果一个对象实现了__iter__方法,则被视为可迭代对象。不需要子类化或注册,因为abc.Iterable实现了__subclasshook__,如“使用 ABC 进行结构化类型”中所示。以下是一个演示:
>>> class GooseSpam:
... def __iter__(self):
... pass
...
>>> from collections import abc
>>> issubclass(GooseSpam, abc.Iterable)
True
>>> goose_spam_can = GooseSpam()
>>> isinstance(goose_spam_can, abc.Iterable)
True
提示
截至 Python 3.10,检查对象x是否可迭代的最准确方法是调用iter(x),如果不可迭代则处理TypeError异常。这比使用isinstance(x, abc.Iterable)更准确,因为iter(x)还考虑了传统的__getitem__方法,而Iterable ABC 则不考虑。
明确检查对象是否可迭代可能不值得,如果在检查之后立即对对象进行迭代。毕竟,当尝试在不可迭代对象上进行迭代时,Python 引发的异常足够清晰:TypeError: 'C' object is not iterable。如果你可以比简单地引发TypeError更好,那么在try/except块中这样做而不是进行显式检查。显式检查可能在稍后持有对象以进行迭代时是有意义的;在这种情况下,尽早捕获错误会使调试更容易。
iter()内置函数更常被 Python 自身使用,而不是我们自己的代码。我们可以用第二种方式使用它,但这并不是广为人知的。
使用可调用对象调用 iter
我们可以使用两个参数调用iter()来从函数或任何可调用对象创建迭代器。在这种用法中,第一个参数必须是一个可调用对象,以便重复调用(不带参数)以产生值,第二个参数是一个sentinel:一个标记值,当可调用对象返回该值时,迭代器会引发StopIteration而不是产生该标记值。
以下示例展示了如何使用iter来掷一个六面骰子,直到掷出1:
>>> def d6():
... return randint(1, 6)
...
>>> d6_iter = iter(d6, 1)
>>> d6_iter
<callable_iterator object at 0x10a245270>
>>> for roll in d6_iter:
... print(roll)
...
4
3
6
3
注意这里的iter函数返回一个callable_iterator。示例中的for循环可能运行很长时间,但永远不会显示1,因为那是标记值。与迭代器一样,示例中的d6_iter对象在耗尽后变得无用。要重新开始,我们必须通过再次调用iter()来重新构建迭代器。
iter的文档包括以下解释和示例代码:
iter()第二种形式的一个常用应用是构建块读取器。例如,从二进制数据库文件中读取固定宽度的块,直到达到文件末尾:
from functools import partial
with open('mydata.db', 'rb') as f:
read64 = partial(f.read, 64)
for block in iter(read64, b''):
process_block(block)
为了清晰起见,我添加了read64赋值,这在原始示例中没有。partial()函数是必需的,因为传递给iter()的可调用对象不应该需要参数。在示例中,一个空的bytes对象是标记值,因为这就是f.read在没有更多字节可读时返回的值。
下一节详细介绍了可迭代对象和迭代器之间的关系。
可迭代对象与迭代器
从“为什么序列是可迭代的:iter 函数”中的解释,我们可以推断出一个定义:
可迭代的
任何iter内置函数可以获取迭代器的对象。实现返回迭代器的__iter__方法的对象是可迭代的。序列始终是可迭代的,实现接受基于 0 的索引的__getitem__方法的对象也是可迭代的。
重要的是要清楚可迭代对象和迭代器之间的关系:Python 从可迭代对象获取迭代器。
这里是一个简单的for循环,遍历一个str。这里的可迭代对象是str 'ABC'。你看不到它,但幕后有一个迭代器:
>>> s = 'ABC'
>>> for char in s:
... print(char)
...
A
B
C
如果没有for语句,我们必须用while循环手动模拟for机制,那么我们需要写下面的代码:
>>> s = 'ABC'
>>> it = iter(s) # ①
>>> while True:
... try:
... print(next(it)) # ②
... except StopIteration: # ③
... del it # ④
... break # ⑤
...
A B C
①
从可迭代对象构建迭代器it。
②
反复调用迭代器上的next以获取下一个项目。
③
当没有更多项目时,迭代器会引发StopIteration。
④
释放对it的引用——迭代器对象被丢弃。
⑤
退出循环。
StopIteration 表示迭代器已耗尽。这个异常由iter()内置处理,它是for循环和其他迭代上下文(如列表推导、可迭代解包等)逻辑的一部分。
Python 的迭代器标准接口有两个方法:
__next__
返回系列中的下一个项目,如果没有更多,则引发StopIteration。
__iter__
返回self;这允许迭代器在期望可迭代对象的地方使用,例如在for循环中。
该接口在collections.abc.Iterator ABC 中得到规范化,它声明了__next__抽象方法,并且子类化Iterable——在那里声明了抽象的__iter__方法。参见图 17-1。

图 17-1。Iterable和Iterator ABCs。斜体的方法是抽象的。一个具体的Iterable.__iter__应返回一个新的Iterator实例。一个具体的Iterator必须实现__next__。Iterator.__iter__方法只返回实例本身。
collections.abc.Iterator的源代码在示例 17-3 中。
示例 17-3。abc.Iterator类;从Lib/_collections_abc.py中提取
class Iterator(Iterable):
__slots__ = ()
@abstractmethod
def __next__(self):
'Return the next item from the iterator. When exhausted, raise StopIteration'
raise StopIteration
def __iter__(self):
return self
@classmethod
def __subclasshook__(cls, C): # ①
if cls is Iterator:
return _check_methods(C, '__iter__', '__next__') # ②
return NotImplemented
①
__subclasshook__支持使用isinstance和issubclass进行结构类型检查。我们在“使用 ABC 进行结构类型检查”中看到了它。
②
_check_methods 遍历类的__mro__以检查方法是否在其基类中实现。它在同一Lib/_collections_abc.py模块中定义。如果方法已实现,则C类将被识别为Iterator的虚拟子类。换句话说,issubclass(C, Iterable)将返回True。
警告
Iterator ABC 的抽象方法在 Python 3 中是it.__next__(),在 Python 2 中是it.next()。通常情况下,应避免直接调用特殊方法。只需使用next(it):这个内置函数在 Python 2 和 3 中都会执行正确的操作,这对于那些从 2 迁移到 3 的代码库很有用。
Python 3.9 中Lib/types.py模块源代码中有一条注释说:
# Iterators in Python aren't a matter of type but of protocol. A large
# and changing number of builtin types implement *some* flavor of
# iterator. Don't check the type! Use hasattr to check for both
# "__iter__" and "__next__" attributes instead.
实际上,abc.Iterator的__subclasshook__方法就是这样做的。
提示
根据Lib/types.py中的建议和Lib/_collections_abc.py中实现的逻辑,检查对象x是否为迭代器的最佳方法是调用isinstance(x, abc.Iterator)。由于Iterator.__subclasshook__,即使x的类不是Iterator的真实或虚拟子类,此测试也有效。
回到我们的Sentence类,从示例 17-1 中,您可以清楚地看到迭代器是如何通过 Python 控制台由iter()构建并由next()消耗的:
>>> s3 = Sentence('Life of Brian') # ①
>>> it = iter(s3) # ②
>>> it # doctest: +ELLIPSIS
<iterator object at 0x...> >>> next(it) # ③
'Life' >>> next(it)
'of' >>> next(it)
'Brian' >>> next(it) # ④
Traceback (most recent call last):
...
StopIteration
>>> list(it) # ⑤
[] >>> list(iter(s3)) # ⑥
['Life', 'of', 'Brian']
①
创建一个包含三个单词的句子s3。
②
从s3获取一个迭代器。
③
next(it) 获取下一个单词。
④
没有更多的单词了,所以迭代器会引发StopIteration异常。
⑤
一旦耗尽,迭代器将始终引发StopIteration,这使其看起来像是空的。
⑥
要再次遍历句子,必须构建一个新的迭代器。
因为迭代器所需的唯一方法是__next__和__iter__,所以没有办法检查是否还有剩余的项,除非调用next()并捕获StopIteration。此外,无法“重置”迭代器。如果需要重新开始,必须在第一次构建迭代器的可迭代对象上调用iter()。在迭代器本身上调用iter()也不会有帮助,因为正如前面提到的,Iterator.__iter__是通过返回self来实现的,因此这不会重置已耗尽的迭代器。
这种最小接口是合理的,因为实际上,并非所有迭代器都可以重置。例如,如果一个迭代器正在从网络中读取数据包,就无法倒带。³
来自 Example 17-1 的第一个Sentence版本之所以可迭代,是因为iter()内置函数对序列的特殊处理。接下来,我们将实现Sentence的变体,这些变体实现了__iter__以返回迭代器。
带有__iter__的句子类
下一个Sentence的变体实现了标准的可迭代协议,首先通过实现迭代器设计模式,然后使用生成器函数。
句子接收 #2: 经典迭代器
下一个Sentence实现遵循设计模式书中经典迭代器设计模式的蓝图。请注意,这不是 Python 的惯用写法,因为接下来的重构将非常清楚地表明。但是,展示可迭代集合和与之一起使用的迭代器之间的区别是有用的。
Example 17-4 中的Sentence类是可迭代的,因为它实现了__iter__特殊方法,该方法构建并返回一个SentenceIterator。这就是可迭代对象和迭代器之间的关系。
示例 17-4. sentence_iter.py: 使用迭代器模式实现的Sentence
import re
import reprlib
RE_WORD = re.compile(r'\w+')
class Sentence:
def __init__(self, text):
self.text = text
self.words = RE_WORD.findall(text)
def __repr__(self):
return f'Sentence({reprlib.repr(self.text)})'
def __iter__(self): # ①
return SentenceIterator(self.words) # ②
class SentenceIterator:
def __init__(self, words):
self.words = words # ③
self.index = 0 # ④
def __next__(self):
try:
word = self.words[self.index] # ⑤
except IndexError:
raise StopIteration() # ⑥
self.index += 1 # ⑦
return word # ⑧
def __iter__(self): # ⑨
return self
①
__iter__方法是对先前Sentence实现的唯一补充。这个版本没有__getitem__,以明确表明该类之所以可迭代是因为它实现了__iter__。
②
__iter__ 通过实例化并返回一个迭代器来实现可迭代协议。
③
SentenceIterator 持有对单词列表的引用。
④
self.index 确定下一个要获取的单词。
⑤
获取self.index处的单词。
⑥
如果在self.index处没有单词,则引发StopIteration。
⑦
增加 self.index。
⑧
返回单词。
⑨
实现 self.__iter__。
Example 17-4 中的代码通过 Example 17-2 中的测试。
注意,在这个示例中,实际上并不需要在SentenceIterator中实现__iter__,但这样做是正确的:迭代器应该同时实现__next__和__iter__,这样做可以使我们的迭代器通过issubclass(SentenceIterator, abc.Iterator)测试。如果我们从abc.Iterator继承SentenceIterator,我们将继承具体的abc.Iterator.__iter__方法。
这是一项繁重的工作(对于我们这些被宠坏的 Python 程序员来说)。请注意,SentenceIterator 中的大部分代码都涉及管理迭代器的内部状态。很快我们将看到如何避免这种繁琐的工作。但首先,让我们简要地讨论一下可能会诱人但却是错误的实现快捷方式。
不要将可迭代对象作为自身的迭代器。
在构建可迭代对象和迭代器时常见的错误是混淆两者。明确一点:可迭代对象具有一个 __iter__ 方法,每次实例化一个新的迭代器。迭代器实现了一个返回单个项的 __next__ 方法,以及一个返回 self 的 __iter__ 方法。
因此,迭代器也是可迭代的,但可迭代的对象不是迭代器。
可能会诱人在 Sentence 类中实现 __next__ 以及 __iter__,使每个 Sentence 实例同时成为自身的可迭代器和迭代器。但这很少是一个好主意。根据在 Google 审查 Python 代码方面拥有丰富经验的 Alex Martelli 的说法,这也是一个常见的反模式。
设计模式 书中关于迭代器设计模式的“适用性”部分说:
使用迭代器模式
- 访问聚合对象的内容而不暴露其内部表示。
- 以支持聚合对象的多次遍历。
- 为不同的聚合结构提供统一的遍历接口(即支持多态迭代)。
要“支持多次遍历”,必须能够从同一个可迭代实例中获取多个独立的迭代器,并且每个迭代器必须保持自己的内部状态,因此模式的正确实现要求每次调用 iter(my_iterable) 都会创建一个新的独立迭代器。这就是为什么在这个例子中我们需要 SentenceIterator 类。
现在经典的迭代器模式已经得到了正确的演示,我们可以放手了。Python 从 Barbara Liskov 的 CLU 语言 中引入了 yield 关键字,因此我们不需要手动“生成”代码来实现迭代器。
接下来的章节将呈现更符合 Python 习惯的 Sentence 版本。
Sentence Take #3:生成器函数
同样功能的 Python 实现使用了生成器,避免了实现 SentenceIterator 类的所有工作。生成器的正确解释就在 Example 17-5 之后。
Example 17-5. sentence_gen.py:使用生成器实现的 Sentence
import re
import reprlib
RE_WORD = re.compile(r'\w+')
class Sentence:
def __init__(self, text):
self.text = text
self.words = RE_WORD.findall(text)
def __repr__(self):
return 'Sentence(%s)' % reprlib.repr(self.text)
def __iter__(self):
for word in self.words: # ①
yield word # ②
# ③
# done! # ④
①
遍历 self.words。
②
产出当前的 word。
③
明确的 return 不是必需的;函数可以“顺利执行”并自动返回。无论哪种方式,生成器函数不会引发 StopIteration:当完成生成值时,它只是退出。
④
不需要单独的迭代器类!
这里我们再次看到了一个不同的 Sentence 实现,通过了 Example 17-2 中的测试。
回到 Example 17-4 中的 Sentence 代码,__iter__ 调用了 SentenceIterator 构造函数来构建一个迭代器并返回它。现在 Example 17-5 中的迭代器实际上是一个生成器对象,在调用 __iter__ 方法时会自动构建,因为这里的 __iter__ 是一个生成器函数。
紧随其后是对生成器的全面解释。
生成器的工作原理
任何在其主体中具有 yield 关键字的 Python 函数都是一个生成器函数:一个在调用时返回生成器对象的函数。换句话说,生成器函数是一个生成器工厂。
提示
区分普通函数和生成器函数的唯一语法是后者的函数体中有一个yield关键字。有人认为应该使用新关键字gen来声明生成器函数,而不是def,但 Guido 不同意。他的论点在PEP 255 — Simple Generators中。⁵
示例 17-6 展示了一个简单生成器函数的行为。⁶
示例 17-6. 一个生成三个数字的生成器函数
>>> def gen_123():
... yield 1 # ①
... yield 2
... yield 3
...
>>> gen_123 # doctest: +ELLIPSIS
<function gen_123 at 0x...> # ②
>>> gen_123() # doctest: +ELLIPSIS
<generator object gen_123 at 0x...> # ③
>>> for i in gen_123(): # ④
... print(i)
1
2
3
>>> g = gen_123() # ⑤
>>> next(g) # ⑥
1
>>> next(g)
2
>>> next(g)
3
>>> next(g) # ⑦
Traceback (most recent call last):
...
StopIteration
①
生成器函数的函数体通常在循环中有yield,但不一定;这里我只是重复了三次yield。
②
仔细观察,我们可以看到gen_123是一个函数对象。
③
但是当调用gen_123()时,会返回一个生成器对象。
④
生成器对象实现了Iterator接口,因此它们也是可迭代的。
⑤
我们将这个新的生成器对象赋给g,这样我们就可以对其进行实验。
⑥
因为g是一个迭代器,调用next(g)会获取yield产生的下一个项目。
⑦
当生成器函数返回时,生成器对象会引发StopIteration。
生成器函数构建一个包装函数体的生成器对象。当我们在生成器对象上调用next()时,执行会前进到函数体中的下一个yield,而next()调用会评估在函数体暂停时产生的值。最后,由 Python 创建的封闭生成器对象在函数体返回时引发StopIteration,符合Iterator协议。
提示
我发现在谈论从生成器获得的值时严谨是有帮助的。说生成器“返回”值是令人困惑的。函数返回值。调用生成器函数返回一个生成器。生成器产生值。生成器不以通常的方式“返回”值:生成器函数体中的return语句会导致生成器对象引发StopIteration。如果在生成器中return x,调用者可以从StopIteration异常中检索到x的值,但通常使用yield from语法会自动完成,我们将在“从协程返回值”中看到。
示例 17-7 使for循环和函数体之间的交互更加明确。
示例 17-7. 一个在运行时打印消息的生成器函数
>>> def gen_AB():
... print('start')
... yield 'A' # ①
... print('continue')
... yield 'B' # ②
... print('end.') # ③
...
>>> for c in gen_AB(): # ④
... print('-->', c) # ⑤
...
start # ⑥
--> A # ⑦
continue # ⑧
--> B # ⑨
end. # ⑩
>>> ⑪
①
在for循环中对④的第一次隐式调用next()将打印'start'并在第一个yield处停止,产生值'A'。
②
for循环中第二次隐式调用next()将打印'continue'并在第二个yield处停止,产生值'B'。
③
第三次调用next()将打印'end.'并穿过函数体的末尾,导致生成器对象引发StopIteration。
④
为了迭代,for机制执行等效于g = iter(gen_AB())以获取一个生成器对象,然后在每次迭代时执行next(g)。
⑤
循环打印-->和next(g)返回的值。这个输出只会在生成器函数内部的print调用输出之后出现。
⑥
文本start来自生成器体中的print('start')。
⑦
生成器体中的yield 'A'产生值A,被for循环消耗,赋给变量c,导致输出--> A。
⑧
迭代继续,第二次调用next(g),将生成器体从yield 'A'推进到yield 'B'。第二个print在生成器体中输出continue。
⑨
yield 'B'产生值B,被for循环消耗,赋给循环变量c,因此循环打印--> B。
⑩
迭代继续,第三次调用next(it),推进到函数体的末尾。由于生成器体中的第三个print,输出中出现了end.。
⑪
当生成器函数运行到末尾时,生成器对象会引发StopIteration异常。for循环机制捕获该异常,循环干净地终止。
现在希望清楚了示例 17-5 中的Sentence.__iter__是如何工作的:__iter__是一个生成器函数,当调用时,会构建一个实现Iterator接口的生成器对象,因此不再需要SentenceIterator类。
第二个Sentence版本比第一个更简洁,但不像它可以那样懒惰。如今,懒惰被认为是一个好特性,至少在编程语言和 API 中是这样。懒惰的实现将产生值推迟到最后可能的时刻。这样可以节省内存,也可能避免浪费 CPU 周期。
我们将构建懒惰的Sentence类。
懒惰的句子
Sentence的最终变体是懒惰的,利用了re模块中的懒惰函数。
句子第四次尝试:懒惰生成器
Iterator接口被设计为懒惰的:next(my_iterator)每次产生一个项目。懒惰的相反是急切:懒惰评估和急切评估是编程语言理论中的技术术语。
到目前为止,我们的Sentence实现并不懒惰,因为__init__急切地构建了文本中所有单词的列表,并将其绑定到self.words属性。这需要处理整个文本,而且列表可能使用的内存和文本本身一样多(可能更多;这取决于文本中有多少非单词字符)。如果用户只迭代前几个单词,大部分工作将是徒劳的。如果你想知道,“在 Python 中有没有一种懒惰的方法?”答案通常是“是的”。
re.finditer函数是re.findall的惰性版本。re.finditer返回一个生成器,按需产生re.MatchObject实例,而不是一个列表。如果有很多匹配,re.finditer可以节省大量内存。使用它,我们的第三个Sentence版本现在是惰性的:只有在需要时才从文本中读取下一个单词。代码在示例 17-8 中。
示例 17-8. sentence_gen2.py: 使用调用re.finditer生成器函数实现的Sentence
import re
import reprlib
RE_WORD = re.compile(r'\w+')
class Sentence:
def __init__(self, text):
self.text = text # ①
def __repr__(self):
return f'Sentence({reprlib.repr(self.text)})'
def __iter__(self):
for match in RE_WORD.finditer(self.text): # ②
yield match.group() # ③
①
不需要有一个words列表。
②
finditer在self.text上的RE_WORD匹配中构建一个迭代器,产生MatchObject实例。
③
match.group()从MatchObject实例中提取匹配的文本。
生成器是一个很好的快捷方式,但可以用生成器表达式进一步简化代码。
第五种句子:惰性生成器表达式
我们可以用生成器表达式替换前一个Sentence类中的简单生成器函数(示例 17-8)。就像列表推导式构建列表一样,生成器表达式构建生成器对象。示例 17-9 对比了它们的行为。
示例 17-9. gen_AB生成器函数被列表推导式使用,然后被生成器表达式使用
>>> def gen_AB(): # ①
... print('start')
... yield 'A'
... print('continue')
... yield 'B'
... print('end.')
...
>>> res1 = [x*3 for x in gen_AB()] # ②
start continue end. >>> for i in res1: # ③
... print('-->', i)
...
--> AAA --> BBB >>> res2 = (x*3 for x in gen_AB()) # ④
>>> res2
<generator object <genexpr> at 0x10063c240> >>> for i in res2: # ⑤
... print('-->', i)
...
start # ⑥
--> AAA continue --> BBB end.
①
这是与示例 17-7 中相同的gen_AB函数。
②
列表推导式急切地迭代由gen_AB()返回的生成器对象产生的项目:'A'和'B'。注意下面行中的输出:start,continue,end.
③
这个for循环迭代由列表推导式构建的res1列表。
④
生成器表达式返回res2,一个生成器对象。这里生成器没有被消耗。
⑤
只有当for循环迭代res2时,这个生成器才从gen_AB获取项目。for循环的每次迭代隐式调用next(res2),进而调用gen_AB()返回的生成器对象上的next(),将其推进到下一个yield。
⑥
注意gen_AB()的输出如何与for循环中的print输出交错。
我们可以使用生成器表达式进一步减少Sentence类中的代码量。参见示例 17-10。
示例 17-10. sentence_genexp.py: 使用生成器表达式实现的Sentence
import re
import reprlib
RE_WORD = re.compile(r'\w+')
class Sentence:
def __init__(self, text):
self.text = text
def __repr__(self):
return f'Sentence({reprlib.repr(self.text)})'
def __iter__(self):
return (match.group() for match in RE_WORD.finditer(self.text))
与示例 17-8 唯一的区别是__iter__方法,在这里不是一个生成器函数(没有yield),而是使用生成器表达式构建一个生成器,然后返回它。最终结果是一样的:__iter__的调用者得到一个生成器对象。
生成器表达式是一种语法糖:它们总是可以被生成器函数替代,但有时更加方便。下一节将介绍生成器表达式的用法。
何时使用生成器表达式
在实现示例 12-16 中的Vector类时,我使用了几个生成器表达式。这些方法中的每一个都有一个生成器表达式:__eq__、__hash__、__abs__、angle、angles、format、__add__和__mul__。在所有这些方法中,列表推导也可以工作,但会使用更多内存来存储中间列表值。
在示例 17-10 中,我们看到生成器表达式是一种创建生成器的语法快捷方式,而无需定义和调用函数。另一方面,生成器函数更加灵活:我们可以使用多个语句编写复杂逻辑,甚至可以将它们用作协程,正如我们将在“经典协程”中看到的那样。
对于更简单的情况,一目了然的生成器表达式更易于阅读,就像Vector示例所示。
我在选择要使用的语法时的经验法则很简单:如果生成器表达式跨越多行,我更倾向于出于可读性考虑编写生成器函数。
语法提示
当将生成器表达式作为函数或构造函数的单个参数传递时,您无需为函数调用编写一组括号,然后再为生成器表达式加上另一组括号。只需一对即可,就像在示例 12-16 中Vector调用__mul__方法时一样,如下所示:
def __mul__(self, scalar):
if isinstance(scalar, numbers.Real):
return Vector(n * scalar for n in self)
else:
return NotImplemented
但是,如果在生成器表达式之后还有更多的函数参数,您需要将其括在括号中,以避免SyntaxError。
我们看到的Sentence示例演示了生成器扮演经典迭代器模式的角色:从集合中检索项。但是,我们也可以使用生成器产生独立于数据源的值。下一节将展示一个示例。
但首先,让我们简要讨论迭代器和生成器之间重叠概念。
算术级数生成器
经典的迭代器模式完全关乎遍历:导航某些数据结构。但是,基于一种方法来获取系列中的下一个项的标准接口在项是实时生成的情况下也很有用,而不是从集合中检索。例如,range内置函数生成整数的有界算术级数(AP)。如果您需要生成任何类型的数字的算术级数,而不仅仅是整数,该怎么办?
示例 17-11 展示了我们马上将看到的ArithmeticProgression类的一些控制台测试。示例 17-11 中构造函数的签名是ArithmeticProgression(begin, step[, end])。range内置函数的完整签名是range(start, stop[, step])。我选择实现不同的签名,因为在算术级数中step是必需的,但end是可选的。我还将参数名称从start/stop更改为begin/end,以明确表明我选择了不同的签名。在示例 17-11 的每个测试中,我对结果调用list()以检查生成的值。
示例 17-11。ArithmeticProgression类演示
>>> ap = ArithmeticProgression(0, 1, 3)
>>> list(ap)
[0, 1, 2]
>>> ap = ArithmeticProgression(1, .5, 3)
>>> list(ap)
[1.0, 1.5, 2.0, 2.5]
>>> ap = ArithmeticProgression(0, 1/3, 1)
>>> list(ap)
[0.0, 0.3333333333333333, 0.6666666666666666]
>>> from fractions import Fraction
>>> ap = ArithmeticProgression(0, Fraction(1, 3), 1)
>>> list(ap)
[Fraction(0, 1), Fraction(1, 3), Fraction(2, 3)]
>>> from decimal import Decimal
>>> ap = ArithmeticProgression(0, Decimal('.1'), .3)
>>> list(ap)
[Decimal('0'), Decimal('0.1'), Decimal('0.2')]
请注意,生成的算术级数中的数字类型遵循 Python 算术的数字强制转换规则,即begin + step的类型。在示例 17-11 中,您会看到int、float、Fraction和Decimal数字的列表。示例 17-12 列出了ArithmeticProgression类的实现。
示例 17-12。ArithmeticProgression类
class ArithmeticProgression:
def __init__(self, begin, step, end=None): # ①
self.begin = begin
self.step = step
self.end = end # None -> "infinite" series
def __iter__(self):
result_type = type(self.begin + self.step) # ②
result = result_type(self.begin) # ③
forever = self.end is None # ④
index = 0
while forever or result < self.end: # ⑤
yield result # ⑥
index += 1
result = self.begin + self.step * index # ⑦
①
__init__需要两个参数:begin和step;如果end是None,则序列将是无界的。
②
获取self.begin和self.step的添加类型。例如,如果一个是int,另一个是float,result_type将是float。
③
这一行创建了一个result,其数值与self.begin相同,但被强制转换为后续加法的类型。⁷
④
为了可读性,如果self.end属性为None,forever标志将为True,导致一个无界系列。
⑤
这个循环运行forever,或直到结果匹配或超过self.end。当这个循环退出时,函数也会退出。
⑥
当前的result被生成。
⑦
下一个潜在的结果被计算。它可能永远不会被产生,因为while循环可能终止。
在示例 17-12 的最后一行,我选择忽略每次循环中将self.step添加到前一个result中,而是选择忽略前一个result,并通过将self.begin添加到self.step乘以index来添加每个新的result。这避免了连续添加后浮点错误的累积效应。这些简单的实验使差异变得明显:
>>> 100 * 1.1
110.00000000000001
>>> sum(1.1 for _ in range(100))
109.99999999999982
>>> 1000 * 1.1
1100.0
>>> sum(1.1 for _ in range(1000))
1100.0000000000086
来自示例 17-12 的ArithmeticProgression类按预期工作,并且是使用生成器函数实现__iter__特殊方法的另一个示例。然而,如果一个类的整个目的是通过实现__iter__来构建一个生成器,我们可以用生成器函数替换类。毕竟,生成器函数本质上是一个生成器工厂。
示例 17-13 展示了一个名为aritprog_gen的生成器函数,它与ArithmeticProgression执行相同的工作,但代码更少。如果只调用aritprog_gen而不是ArithmeticProgression,则示例 17-11 中的所有测试都会通过。⁸
示例 17-13. aritprog_gen生成器函数
def aritprog_gen(begin, step, end=None):
result = type(begin + step)(begin)
forever = end is None
index = 0
while forever or result < end:
yield result
index += 1
result = begin + step * index
示例 17-13 非常优雅,但请记住:标准库中有大量现成的生成器可供使用,下一节将展示使用itertools模块的更短实现。
使用itertools的算术进度
Python 3.10 中的itertools模块有 20 个生成器函数,可以以各种有趣的方式组合。
例如,itertools.count 函数返回一个生成器,产生数字。没有参数时,它产生以0开头的一系列整数。但是你可以提供可选的start和step值来实现类似于我们的aritprog_gen函数的结果:
>>> import itertools
>>> gen = itertools.count(1, .5)
>>> next(gen)
1
>>> next(gen)
1.5
>>> next(gen)
2.0
>>> next(gen)
2.5
警告
itertools.count永远不会停止,因此如果调用list(count()),Python 将尝试构建一个填满所有已制造的内存芯片的list。实际上,在调用失败之前,您的机器会变得非常不高兴。
另一方面,有itertools.takewhile函数:它返回一个消耗另一个生成器并在给定谓词评估为False时停止的生成器。因此,我们可以将两者结合起来写成这样:
>>> gen = itertools.takewhile(lambda n: n < 3, itertools.count(1, .5))
>>> list(gen)
[1, 1.5, 2.0, 2.5]
利用takehwhile和count,示例 17-14 更加简洁。
示例 17-14. aritprog_v3.py:这与之前的aritprog_gen函数相同
import itertools
def aritprog_gen(begin, step, end=None):
first = type(begin + step)(begin)
ap_gen = itertools.count(first, step)
if end is None:
return ap_gen
return itertools.takewhile(lambda n: n < end, ap_gen)
请注意,示例 17-14 中的aritprog_gen不是一个生成器函数:它的主体中没有yield。但它返回一个生成器,就像生成器函数一样。
但是,请记住,itertools.count会重复添加step,因此它生成的浮点数序列不像示例 17-13 那样精确。
示例 17-14 的要点是:在实现生成器时,要了解标准库中提供了什么,否则很可能会重复造轮子。这就是为什么下一节涵盖了几个可直接使用的生成器函数。
标准库中的生成器函数
标准库提供了许多生成器,从提供逐行迭代的纯文本文件对象,到令人惊叹的os.walk函数,该函数在遍历目录树时产生文件名,使递归文件系统搜索就像一个for循环一样简单。
os.walk生成器函数令人印象深刻,但在本节中,我想专注于以任意可迭代对象作为参数并返回生成器的通用函数,这些生成器产生选定的、计算的或重新排列的项目。在下面的表格中,我总结了两打这样的函数,来自内置的itertools和functools模块。为方便起见,我根据高级功能对它们进行了分组,而不管它们在哪里定义。
第一组包含过滤生成器函数:它们产生输入可迭代对象生成的项目子集,而不改变项目本身。像takewhile一样,表 17-1 中列出的大多数函数都接受一个predicate,这是一个一参数布尔函数,将应用于输入中的每个项目,以确定是否将项目包含在输出中。
表 17-1. 过滤生成器函数
| 模块 | 函数 | 描述 |
|---|---|---|
itertools |
compress(it, selector_it) |
并行消耗两个可迭代对象;每当selector_it中对应的项目为真时,从it中产生项目 |
itertools |
dropwhile(predicate, it) |
消耗it,跳过predicate计算为真时的项目,然后产生所有剩余项目(不再进行进一步检查) |
| (内置) | filter(predicate, it) |
对iterable的每个项目应用predicate,如果predicate(item)为真,则产生该项目;如果predicate为None,则只产生真值项目 |
itertools |
filterfalse(predicate, it) |
与filter相同,但predicate逻辑取反:每当predicate计算为假时产生项目 |
itertools |
islice(it, stop) or islice(it, start, stop, step=1) |
从it的切片中产生项目,类似于s[:stop]或s[start:stop:step],除了it可以是任何可迭代对象,且操作是惰性的 |
itertools |
takewhile(predicate, it) |
当predicate计算为真时产生项目,然后停止,不再进行进一步检查 |
示例 17-15 中的控制台列表显示了表 17-1 中所有函数的使用。
示例 17-15. 过滤生成器函数示例
>>> def vowel(c):
... return c.lower() in 'aeiou'
...
>>> list(filter(vowel, 'Aardvark'))
['A', 'a', 'a']
>>> import itertools
>>> list(itertools.filterfalse(vowel, 'Aardvark'))
['r', 'd', 'v', 'r', 'k']
>>> list(itertools.dropwhile(vowel, 'Aardvark'))
['r', 'd', 'v', 'a', 'r', 'k']
>>> list(itertools.takewhile(vowel, 'Aardvark'))
['A', 'a']
>>> list(itertools.compress('Aardvark', (1, 0, 1, 1, 0, 1)))
['A', 'r', 'd', 'a']
>>> list(itertools.islice('Aardvark', 4))
['A', 'a', 'r', 'd']
>>> list(itertools.islice('Aardvark', 4, 7))
['v', 'a', 'r']
>>> list(itertools.islice('Aardvark', 1, 7, 2))
['a', 'd', 'a']
下一组包含映射生成器:它们产生从输入可迭代对象的每个单独项目计算得到的项目,或者在map和starmap的情况下,产生自输入可迭代对象的项目。表 17-2 中的生成器每个输入可迭代对象产生一个结果。如果输入来自多个可迭代对象,则一旦第一个输入可迭代对象耗尽,输出就会停止。
表 17-2. 映射生成器函数
| 模块 | 函数 | 描述 |
|---|---|---|
itertools |
accumulate(it, [func]) |
产生累积和;如果提供了func,则产生将其应用于第一对项目的结果,然后应用于第一个结果和下一个项目等的结果 |
| (内置) | enumerate(iterable, start=0) |
产生形式为(index, item)的 2 元组,其中index从start计数,item取自iterable |
| (内置) | map(func, it1, [it2, …, itN]) |
将func应用于it的每个项目,产生结果;如果给出了 N 个可迭代对象,则func必须接受 N 个参数,并且可迭代对象将并行消耗 |
itertools |
starmap(func, it) |
将func应用于it的每个项目,产生结果;输入可迭代对象应产生可迭代对象iit,并且func被应用为func(*iit) |
Example 17-16 演示了itertools.accumulate的一些用法。
示例 17-16。itertools.accumulate生成器函数示例
>>> sample = [5, 4, 2, 8, 7, 6, 3, 0, 9, 1]
>>> import itertools
>>> list(itertools.accumulate(sample)) # ①
[5, 9, 11, 19, 26, 32, 35, 35, 44, 45] >>> list(itertools.accumulate(sample, min)) # ②
[5, 4, 2, 2, 2, 2, 2, 0, 0, 0] >>> list(itertools.accumulate(sample, max)) # ③
[5, 5, 5, 8, 8, 8, 8, 8, 9, 9] >>> import operator
>>> list(itertools.accumulate(sample, operator.mul)) # ④
[5, 20, 40, 320, 2240, 13440, 40320, 0, 0, 0] >>> list(itertools.accumulate(range(1, 11), operator.mul))
[1, 2, 6, 24, 120, 720, 5040, 40320, 362880, 3628800] # ⑤
①
运行总和。
②
运行最小值。
③
运行最大值。
④
运行乘积。
⑤
从1!到10!的阶乘。
Table 17-2 的其余函数显示在 Example 17-17 中。
示例 17-17。映射生成器函数示例
>>> list(enumerate('albatroz', 1)) # ①
[(1, 'a'), (2, 'l'), (3, 'b'), (4, 'a'), (5, 't'), (6, 'r'), (7, 'o'), (8, 'z')] >>> import operator
>>> list(map(operator.mul, range(11), range(11))) # ②
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100] >>> list(map(operator.mul, range(11), [2, 4, 8])) # ③
[0, 4, 16] >>> list(map(lambda a, b: (a, b), range(11), [2, 4, 8])) # ④
[(0, 2), (1, 4), (2, 8)] >>> import itertools
>>> list(itertools.starmap(operator.mul, enumerate('albatroz', 1))) # ⑤
['a', 'll', 'bbb', 'aaaa', 'ttttt', 'rrrrrr', 'ooooooo', 'zzzzzzzz'] >>> sample = [5, 4, 2, 8, 7, 6, 3, 0, 9, 1]
>>> list(itertools.starmap(lambda a, b: b / a,
... enumerate(itertools.accumulate(sample), 1))) # ⑥
[5.0, 4.5, 3.6666666666666665, 4.75, 5.2, 5.333333333333333, 5.0, 4.375, 4.888888888888889, 4.5]
①
从1开始对单词中的字母编号。
②
从0到10的整数的平方。
③
并行从两个可迭代对象中相乘的数字:当最短的可迭对象结束时,结果停止。
④
这就是zip内置函数的作用。
⑤
根据单词中的位置重复每个字母,从1开始。
⑥
运行平均值。
接下来,我们有合并生成器组 - 所有这些都从多个输入可迭代对象中产生项目。chain和chain.from_iterable按顺序消耗输入可迭代对象(一个接一个地),而product、zip和zip_longest并行消耗输入可迭代对象。参见 Table 17-3。
Table 17-3。合并多个输入可迭代对象的生成器函数
| 模块 | 函数 | 描述 |
|---|---|---|
itertools |
chain(it1, …, itN) |
从it1,然后从it2等无缝地产生所有项目 |
itertools |
chain.from_iterable(it) |
从it生成的每个可迭代对象中产生所有项目,一个接一个地无缝地;it将是一个可迭代对象,其中项目也是可迭代对象,例如,元组列表 |
itertools |
product(it1, …, itN, repeat=1) |
笛卡尔积:通过组合来自每个输入可迭代对象的项目生成 N 元组,就像嵌套的for循环可以产生的那样;repeat允许多次消耗输入可迭代对象 |
| (内置) | zip(it1, …, itN, strict=False) |
从并行获取的每个项目构建 N 元组,默默地在第一个可迭代对象耗尽时停止,除非给出strict=True^(a) |
itertools |
zip_longest(it1, …, itN, fillvalue=None) |
从并行获取的每个项目构建 N 元组,仅在最后一个可迭代对象耗尽时停止,用fillvalue填充空白 |
^(a) strict关键字参数是 Python 3.10 中的新参数。当strict=True时,如果任何可迭代对象的长度不同,则会引发ValueError。默认值为False,以确保向后兼容性。 |
示例 17-18 展示了itertools.chain和zip生成器函数及其相关函数的使用。请记住,zip函数是以拉链拉链(与压缩无关)命名的。zip和itertools.zip_longest都是在“神奇的 zip”中引入的。
示例 17-18. 合并生成器函数示例
>>> list(itertools.chain('ABC', range(2))) # ①
['A', 'B', 'C', 0, 1] >>> list(itertools.chain(enumerate('ABC'))) # ②
[(0, 'A'), (1, 'B'), (2, 'C')] >>> list(itertools.chain.from_iterable(enumerate('ABC'))) # ③
[0, 'A', 1, 'B', 2, 'C'] >>> list(zip('ABC', range(5), [10, 20, 30, 40])) # ④
[('A', 0, 10), ('B', 1, 20), ('C', 2, 30)] >>> list(itertools.zip_longest('ABC', range(5))) # ⑤
[('A', 0), ('B', 1), ('C', 2), (None, 3), (None, 4)] >>> list(itertools.zip_longest('ABC', range(5), fillvalue='?')) # ⑥
[('A', 0), ('B', 1), ('C', 2), ('?', 3), ('?', 4)]
①
通常使用两个或更多可迭代对象调用chain。
②
当使用单个可迭代对象调用chain时,它不会产生任何有用的效果。
③
但是chain.from_iterable从可迭代对象中获取每个项目,并按顺序链接它们,只要每个项目本身是可迭代的。
④
zip可以并行消耗任意数量的可迭代对象,但是生成器总是在第一个可迭代对象结束时停止。在 Python ≥ 3.10 中,如果给定strict=True参数并且一个可迭代对象在其他可迭代对象之前结束,则会引发ValueError。
⑤
itertools.zip_longest的工作原理类似于zip,只是它会消耗所有输入的可迭代对象,根据需要用None填充输出元组。
⑥
fillvalue关键字参数指定自定义填充值。
itertools.product生成器是计算笛卡尔积的一种懒惰方式,我们在“笛卡尔积”中使用了多个for子句的列表推导式构建。具有多个for子句的生成器表达式也可以用于懒惰地生成笛卡尔积。示例 17-19 演示了itertools.product。
示例 17-19. itertools.product生成器函数示例
>>> list(itertools.product('ABC', range(2))) # ①
[('A', 0), ('A', 1), ('B', 0), ('B', 1), ('C', 0), ('C', 1)] >>> suits = 'spades hearts diamonds clubs'.split()
>>> list(itertools.product('AK', suits)) # ②
[('A', 'spades'), ('A', 'hearts'), ('A', 'diamonds'), ('A', 'clubs'), ('K', 'spades'), ('K', 'hearts'), ('K', 'diamonds'), ('K', 'clubs')] >>> list(itertools.product('ABC')) # ③
[('A',), ('B',), ('C',)] >>> list(itertools.product('ABC', repeat=2)) # ④
[('A', 'A'), ('A', 'B'), ('A', 'C'), ('B', 'A'), ('B', 'B'), ('B', 'C'), ('C', 'A'), ('C', 'B'), ('C', 'C')] >>> list(itertools.product(range(2), repeat=3))
[(0, 0, 0), (0, 0, 1), (0, 1, 0), (0, 1, 1), (1, 0, 0), (1, 0, 1), (1, 1, 0), (1, 1, 1)] >>> rows = itertools.product('AB', range(2), repeat=2)
>>> for row in rows: print(row)
...
('A', 0, 'A', 0) ('A', 0, 'A', 1) ('A', 0, 'B', 0) ('A', 0, 'B', 1) ('A', 1, 'A', 0) ('A', 1, 'A', 1) ('A', 1, 'B', 0) ('A', 1, 'B', 1) ('B', 0, 'A', 0) ('B', 0, 'A', 1) ('B', 0, 'B', 0) ('B', 0, 'B', 1) ('B', 1, 'A', 0) ('B', 1, 'A', 1) ('B', 1, 'B', 0) ('B', 1, 'B', 1)
①
一个具有三个字符的str和一个具有两个整数的range的笛卡尔积产生六个元组(因为3 * 2是6)。
②
两个卡片等级('AK')和四个花色的乘积是一系列八元组。
③
给定一个单个可迭代对象,product生成一系列单元组,不是很有用。
④
repeat=N关键字参数告诉产品消耗每个输入可迭代对象N次。
一些生成器函数通过产生每个输入项多个值来扩展输入。它们在表 17-4 中列出。
表 17-4. 将每个输入项扩展为多个输出项的生成器函数
| 模块 | 函数 | 描述 |
|---|---|---|
itertools |
combinations(it, out_len) |
从it产生的项目中产生out_len个项目的组合 |
itertools |
combinations_with_replacement(it, out_len) |
从it产生的项目中产生out_len个项目的组合,包括重复的项目的组合 |
itertools |
count(start=0, step=1) |
从start开始,按step递增,无限地产生数字 |
itertools |
cycle(it) |
从it中产生项目,存储每个项目的副本,然后无限地重复产生整个序列 |
itertools |
pairwise(it) |
从输入可迭代对象中获取连续的重叠对^(a) |
itertools |
permutations(it, out_len=None) |
从it产生的项目中产生out_len个项目的排列;默认情况下,out_len为len(list(it)) |
itertools |
repeat(item, [times]) |
重复产生给定的项目,除非给定了times次数 |
^(a) itertools.pairwise在 Python 3.10 中添加。 |
itertools中的count和repeat函数返回生成器,从无中生有地产生项目:它们都不接受可迭代对象作为输入。我们在“使用 itertools 进行算术进度”中看到了itertools.count。cycle生成器备份输入可迭代对象并重复产生其项目。示例 17-20 演示了count、cycle、pairwise和repeat的用法。
示例 17-20。count、cycle、pairwise和repeat
>>> ct = itertools.count() # ①
>>> next(ct) # ②
0 >>> next(ct), next(ct), next(ct) # ③
(1, 2, 3) >>> list(itertools.islice(itertools.count(1, .3), 3)) # ④
[1, 1.3, 1.6] >>> cy = itertools.cycle('ABC') # ⑤
>>> next(cy)
'A' >>> list(itertools.islice(cy, 7)) # ⑥
['B', 'C', 'A', 'B', 'C', 'A', 'B'] >>> list(itertools.pairwise(range(7))) # ⑦
[(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)] >>> rp = itertools.repeat(7) # ⑧
>>> next(rp), next(rp)
(7, 7) >>> list(itertools.repeat(8, 4)) # ⑨
[8, 8, 8, 8] >>> list(map(operator.mul, range(11), itertools.repeat(5))) # ⑩
[0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50]
①
构建一个count生成器ct。
②
从ct中检索第一个项目。
③
我无法从ct中构建一个list,因为ct永远不会停止,所以我获取了接下来的三个项目。
④
如果count生成器由islice或takewhile限制,我可以构建一个list。
⑤
从'ABC'构建一个cycle生成器,并获取其第一个项目,'A'。
⑥
只有通过islice限制,才能构建一个list;这里检索了接下来的七个项目。
⑦
对于输入中的每个项目,pairwise产生一个包含该项目和下一个项目(如果有下一个项目)的 2 元组。在 Python ≥ 3.10 中可用。
⑧
构建一个repeat生成器,永远产生数字7。
⑨
通过传递times参数,可以限制repeat生成器:这里数字8将产生4次。
⑩
repeat的常见用法:在map中提供一个固定参数;这里提供了5的倍数。
combinations、combinations_with_replacement和permutations生成器函数——连同product——在itertools文档页面中被称为组合生成器。itertools.product与其余组合函数之间也有密切关系,正如示例 17-21 所示。
示例 17-21。组合生成器函数从每个输入项目中产生多个值
>>> list(itertools.combinations('ABC', 2)) # ①
[('A', 'B'), ('A', 'C'), ('B', 'C')] >>> list(itertools.combinations_with_replacement('ABC', 2)) # ②
[('A', 'A'), ('A', 'B'), ('A', 'C'), ('B', 'B'), ('B', 'C'), ('C', 'C')] >>> list(itertools.permutations('ABC', 2)) # ③
[('A', 'B'), ('A', 'C'), ('B', 'A'), ('B', 'C'), ('C', 'A'), ('C', 'B')] >>> list(itertools.product('ABC', repeat=2)) # ④
[('A', 'A'), ('A', 'B'), ('A', 'C'), ('B', 'A'), ('B', 'B'), ('B', 'C'), ('C', 'A'), ('C', 'B'), ('C', 'C')]
①
从'ABC'中的项目中生成len()==2的所有组合;生成的元组中的项目顺序无关紧要(它们可以是集合)。
②
从'ABC'中的项目中生成len()==2的所有组合,包括重复项目的组合。
③
从'ABC'中的项目中生成len()==2的所有排列;生成的元组中的项目顺序是相关的。
④
从'ABC'和'ABC'中的笛卡尔积(这是repeat=2的效果)。
我们将在本节中介绍的最后一组生成器函数旨在以某种方式重新排列输入可迭代对象中的所有项目。以下是返回多个生成器的两个函数:itertools.groupby和itertools.tee。该组中的另一个生成器函数,reversed内置函数,是本节中唯一一个不接受任何可迭代对象作为输入的函数,而只接受序列。这是有道理的:因为reversed将从最后到第一个产生项目,所以它只能与已知长度的序列一起使用。但它通过根据需要产生每个项目来避免制作反转副本的成本。我将itertools.product函数与表 17-3 中的合并生成器放在一起,因为它们都消耗多个可迭代对象,而表 17-5 中的生成器最多只接受一个输入可迭代对象。
表 17-5. 重新排列生成器函数
| 模块 | 函数 | 描述 |
|---|---|---|
itertools |
groupby(it, key=None) |
产生形式为(key, group)的 2 元组,其中key是分组标准,group是产生组中项目的生成器 |
| (内置) | reversed(seq) |
以从最后到第一个的顺序从seq中产生项目;seq必须是一个序列或实现__reversed__特殊方法 |
itertools |
tee(it, n=2) |
产生一个元组,其中包含n个独立产生输入可迭代对象的项目的生成器 |
示例 17-22 演示了itertools.groupby和reversed内置函数的使用。请注意,itertools.groupby假定输入可迭代对象按分组标准排序,或者至少按照该标准对项目进行了分组,即使不完全排序。技术审阅者 Miroslav Šedivý建议了这种用例:您可以按时间顺序对datetime对象进行排序,然后按星期几进行分组,以获取星期一数据组,接着是星期二数据组,依此类推,然后再次是下周的星期一数据组,依此类推。
示例 17-22. itertools.groupby
>>> list(itertools.groupby('LLLLAAGGG')) # ①
[('L', <itertools._grouper object at 0x102227cc0>), ('A', <itertools._grouper object at 0x102227b38>), ('G', <itertools._grouper object at 0x102227b70>)] >>> for char, group in itertools.groupby('LLLLAAAGG'): # ②
... print(char, '->', list(group))
...
L -> ['L', 'L', 'L', 'L'] A -> ['A', 'A',] G -> ['G', 'G', 'G'] >>> animals = ['duck', 'eagle', 'rat', 'giraffe', 'bear',
... 'bat', 'dolphin', 'shark', 'lion']
>>> animals.sort(key=len) # ③
>>> animals
['rat', 'bat', 'duck', 'bear', 'lion', 'eagle', 'shark', 'giraffe', 'dolphin'] >>> for length, group in itertools.groupby(animals, len): # ④
... print(length, '->', list(group))
...
3 -> ['rat', 'bat'] 4 -> ['duck', 'bear', 'lion'] 5 -> ['eagle', 'shark'] 7 -> ['giraffe', 'dolphin'] >>> for length, group in itertools.groupby(reversed(animals), len): # ⑤
... print(length, '->', list(group))
...
7 -> ['dolphin', 'giraffe'] 5 -> ['shark', 'eagle'] 4 -> ['lion', 'bear', 'duck'] 3 -> ['bat', 'rat'] >>>
①
groupby产生(key, group_generator)的元组。
②
处理groupby生成器涉及嵌套迭代:在这种情况下,外部for循环和内部list构造函数。
③
按长度对animals进行排序。
④
再次循环遍历key和group对,以显示key并将group扩展为list。
⑤
这里reverse生成器从右到左迭代animals。
该组中最后一个生成器函数是iterator.tee,具有独特的行为:它从单个输入可迭代对象产生多个生成器,每个生成器都从输入中产生每个项目。这些生成器可以独立消耗,如示例 17-23 所示。
示例 17-23. itertools.tee生成多个生成器,每个生成器都生成输入生成器的每个项目
>>> list(itertools.tee('ABC'))
[<itertools._tee object at 0x10222abc8>, <itertools._tee object at 0x10222ac08>]
>>> g1, g2 = itertools.tee('ABC')
>>> next(g1)
'A'
>>> next(g2)
'A'
>>> next(g2)
'B'
>>> list(g1)
['B', 'C']
>>> list(g2)
['C']
>>> list(zip(*itertools.tee('ABC')))
[('A', 'A'), ('B', 'B'), ('C', 'C')]
请注意,本节中的几个示例使用了生成器函数的组合。这些函数的一个很好的特性是:因为它们接受生成器作为参数并返回生成器,所以它们可以以许多不同的方式组合在一起。
现在我们将回顾标准库中另一组对可迭代对象敏感的函数。
可迭代对象减少函数
表 17-6 中的函数都接受一个可迭代对象并返回一个单一结果。它们被称为“reducing”、“folding”或“accumulating”函数。我们可以使用functools.reduce实现这里列出的每一个内置函数,但它们作为内置函数存在是因为它们更容易地解决了一些常见的用例。有关functools.reduce的更长解释出现在“向量取#4:哈希和更快的==”中。
在all和any的情况下,有一个重要的优化functools.reduce不支持:all和any短路——即,它们在确定结果后立即停止消耗迭代器。请参见示例 17-24 中any的最后一个测试。
表 17-6. 读取可迭代对象并返回单个值的内置函数
| 模块 | 函数 | 描述 |
|---|---|---|
| (内置) | all(it) |
如果it中所有项目都为真,则返回True,否则返回False;all([])返回True |
| (内置) | any(it) |
如果it中有任何项目为真,则返回True,否则返回False;any([])返回False |
| (内置) | max(it, [key=,] [default=]) |
返回it中项目的最大值;^(a) key是一个排序函数,就像sorted中一样;如果可迭代对象为空,则返回default |
| (内置) | min(it, [key=,] [default=]) |
返回it中项目的最小值。^(b) key是一个排序函数,就像sorted中一样;如果可迭代对象为空,则返回default |
functools |
reduce(func, it, [initial]) |
返回将func应用于第一对项目的结果,然后应用于该结果和第三个项目,依此类推;如果给定,initial将与第一个项目形成初始对 |
| (内置) | sum(it, start=0) |
it中所有项目的总和,加上可选的start值(在添加浮点数时使用math.fsum以获得更好的精度) |
^(a) 也可以称为max(arg1, arg2, …, [key=?]),在这种情况下,返回参数中的最大值。^(b) 也可以称为min(arg1, arg2, …, [key=?]),在这种情况下,返回参数中的最小值。 |
all和any的操作在示例 17-24 中有所体现。
示例 17-24. 对一些序列使用all和any的结果
>>> all([1, 2, 3])
True >>> all([1, 0, 3])
False >>> all([])
True >>> any([1, 2, 3])
True >>> any([1, 0, 3])
True >>> any([0, 0.0])
False >>> any([])
False >>> g = (n for n in [0, 0.0, 7, 8])
>>> any(g) # ①
True >>> next(g) # ②
8
①
any在g上迭代直到g产生7;然后any停止并返回True。
②
这就是为什么8仍然保留着。
另一个接受可迭代对象并返回其他内容的内置函数是sorted。与生成器函数reversed不同,sorted构建并返回一个新的list。毕竟,必须读取输入可迭代对象的每个单个项目以便对它们进行排序,排序发生在一个list中,因此sorted在完成后只返回该list。我在这里提到sorted是因为它消耗任意可迭代对象。
当然,sorted和减少函数只适用于最终会停止的可迭代对象。否则,它们将继续收集项目并永远不会返回结果。
注意
如果你已经看到了本章节最重要和最有用的内容,剩下的部分涵盖了大多数人不经常看到或需要的高级生成器功能,比如yield from结构和经典协程。
还有关于类型提示可迭代对象、迭代器和经典协程的部分。
yield from语法提供了一种组合生成器的新方法。接下来是这个。
使用yield from的子生成器
yield from表达式语法在 Python 3.3 中引入,允许生成器将工作委托给子生成器。
在引入yield from之前,当生成器需要产生另一个生成器生成的值时,我们使用for循环:
>>> def sub_gen():
... yield 1.1
... yield 1.2
...
>>> def gen():
... yield 1
... for i in sub_gen():
... yield i
... yield 2
...
>>> for x in gen():
... print(x)
...
1
1.1
1.2
2
我们可以使用yield from得到相同的结果,就像你在示例 17-25 中看到的那样。
示例 17-25. 测试驱动yield from
>>> def sub_gen():
... yield 1.1
... yield 1.2
...
>>> def gen():
... yield 1
... yield from sub_gen()
... yield 2
...
>>> for x in gen():
... print(x)
...
1
1.1
1.2
2
在示例 17-25 中,for循环是客户端代码,gen是委托生成器,sub_gen是子生成器。请注意,yield from会暂停gen,sub_gen接管直到耗尽。由sub_gen生成的值直接通过gen传递给客户端for循环。同时,gen被挂起,无法看到通过它传递的值。只有当sub_gen完成时,gen才会恢复。
当子生成器包含带有值的return语句时,该值可以通过在表达式中使用yield from在委托生成器中捕获。示例 17-26 演示了这一点。
示例 17-26. yield from 获取子生成器的返回值
>>> def sub_gen():
... yield 1.1
... yield 1.2
... return 'Done!'
...
>>> def gen():
... yield 1
... result = yield from sub_gen()
... print('<--', result)
... yield 2
...
>>> for x in gen():
... print(x)
...
1
1.1
1.2
<-- Done!
2
现在我们已经了解了yield from的基础知识,让我们研究一些简单但实用的用法示例。
重塑链
我们在表 17-3 中看到,itertools提供了一个chain生成器,从多个可迭代对象中产生项目,首先迭代第一个,然后迭代第二个,依此类推,直到最后一个。这是在 Python 中使用嵌套for循环实现的chain的自制版本:¹⁰
>>> def chain(*iterables):
... for it in iterables:
... for i in it:
... yield i
...
>>> s = 'ABC'
>>> r = range(3)
>>> list(chain(s, r))
['A', 'B', 'C', 0, 1, 2]
在前面的代码中,chain生成器依次委托给每个可迭代对象it,通过驱动内部for循环中的每个it。该内部循环可以用yield from表达式替换,如下一个控制台列表所示:
>>> def chain(*iterables):
... for i in iterables:
... yield from i
...
>>> list(chain(s, t))
['A', 'B', 'C', 0, 1, 2]
在这个示例中使用yield from是正确的,代码读起来更好,但似乎只是一点点语法糖而已。现在让我们开发一个更有趣的示例。
遍历树
在本节中,我们将看到在脚本中使用yield from来遍历树结构。我将逐步构建它。
本示例的树结构是 Python 的异常层次结构。但是该模式可以适应显示目录树或任何其他树结构。
从 Python 3.10 开始,异常层次结构从零级的BaseException开始,深达五级。我们的第一步是展示零级。
给定一个根类,在示例 17-27 中的tree生成器会生成其名称并停止。
示例 17-27. tree/step0/tree.py:生成根类的名称并停止
def tree(cls):
yield cls.__name__
def display(cls):
for cls_name in tree(cls):
print(cls_name)
if __name__ == '__main__':
display(BaseException)
示例 17-27 的输出只有一行:
BaseException
下一个小步骤将我们带到第 1 级。tree生成器将生成根类的名称和每个直接子类的名称。子类的名称缩进以显示层次结构。这是我们想要的输出:
$ python3 tree.py
BaseException
Exception
GeneratorExit
SystemExit
KeyboardInterrupt
示例 17-28 产生了该输出。
示例 17-28. tree/step1/tree.py:生成根类和直接子类的名称
def tree(cls):
yield cls.__name__, 0 # ①
for sub_cls in cls.__subclasses__(): # ②
yield sub_cls.__name__, 1 # ③
def display(cls):
for cls_name, level in tree(cls):
indent = ' ' * 4 * level # ④
print(f'{indent}{cls_name}')
if __name__ == '__main__':
display(BaseException)
①
为了支持缩进输出,生成类的名称和其在层次结构中的级别。
②
使用__subclasses__特殊方法获取子类列表。
③
产出子类和第 1 级的名称。
④
构建缩进字符串,为 level 乘以 4 个空格。在零级时,这将是一个空字符串。
在 示例 17-29 中,我重构了 tree,将根类的特殊情况与子类分开处理,现在在 sub_tree 生成器中处理。在 yield from 处,tree 生成器被挂起,sub_tree 接管产出值。
示例 17-29. tree/step2/tree.py: tree 产出根类名称,然后委托给 sub_tree
def tree(cls):
yield cls.__name__, 0
yield from sub_tree(cls) # ①
def sub_tree(cls):
for sub_cls in cls.__subclasses__():
yield sub_cls.__name__, 1 # ②
def display(cls):
for cls_name, level in tree(cls): # ③
indent = ' ' * 4 * level
print(f'{indent}{cls_name}')
if __name__ == '__main__':
display(BaseException)
①
委托给 sub_tree 产出子类的名称。
②
产出每个子类和第 1 级的名称。由于 tree 内部有 yield from sub_tree(cls),这些值完全绕过了 tree 生成器函数…
③
… 并直接在这里接收。
为了进行 深度优先 树遍历,在产出第 1 级的每个节点后,我想要产出该节点的第 2 级子节点,然后继续第 1 级。一个嵌套的 for 循环负责处理这个问题,就像 示例 17-30 中一样。
示例 17-30. tree/step3/tree.py: sub_tree 深度优先遍历第 1 和第 2 级
def tree(cls):
yield cls.__name__, 0
yield from sub_tree(cls)
def sub_tree(cls):
for sub_cls in cls.__subclasses__():
yield sub_cls.__name__, 1
for sub_sub_cls in sub_cls.__subclasses__():
yield sub_sub_cls.__name__, 2
def display(cls):
for cls_name, level in tree(cls):
indent = ' ' * 4 * level
print(f'{indent}{cls_name}')
if __name__ == '__main__':
display(BaseException)
这是从 示例 17-30 运行 step3/tree.py 的结果:
$ python3 tree.py
BaseException
Exception
TypeError
StopAsyncIteration
StopIteration
ImportError
OSError
EOFError
RuntimeError
NameError
AttributeError
SyntaxError
LookupError
ValueError
AssertionError
ArithmeticError
SystemError
ReferenceError
MemoryError
BufferError
Warning
GeneratorExit
SystemExit
KeyboardInterrupt
你可能已经知道这将会发生什么,但我将再次坚持小步慢走:让我们通过添加另一个嵌套的 for 循环来达到第 3 级。程序的其余部分没有改变,因此 示例 17-31 仅显示了 sub_tree 生成器。
示例 17-31. tree/step4/tree.py 中的 sub_tree 生成器
def sub_tree(cls):
for sub_cls in cls.__subclasses__():
yield sub_cls.__name__, 1
for sub_sub_cls in sub_cls.__subclasses__():
yield sub_sub_cls.__name__, 2
for sub_sub_sub_cls in sub_sub_cls.__subclasses__():
yield sub_sub_sub_cls.__name__, 3
在 示例 17-31 中有一个明显的模式。我们使用 for 循环获取第 N 级的子类。每次循环,我们产出第 N 级的一个子类,然后开始另一个 for 循环访问第 N+1 级。
在 “重新发明链” 中,我们看到如何用相同的生成器上的 yield from 替换驱动生成器的嵌套 for 循环。如果我们使 sub_tree 接受一个 level 参数,并递归地 yield from 它,将当前子类作为新的根类和下一个级别编号传递。参见 示例 17-32。
示例 17-32. tree/step5/tree.py: 递归的 sub_tree 走到内存允许的极限
def tree(cls):
yield cls.__name__, 0
yield from sub_tree(cls, 1)
def sub_tree(cls, level):
for sub_cls in cls.__subclasses__():
yield sub_cls.__name__, level
yield from sub_tree(sub_cls, level+1)
def display(cls):
for cls_name, level in tree(cls):
indent = ' ' * 4 * level
print(f'{indent}{cls_name}')
if __name__ == '__main__':
display(BaseException)
示例 17-32 可以遍历任意深度的树,仅受 Python 递归限制。默认限制允许有 1,000 个待处理函数。
任何关于递归的好教程都会强调有一个基本情况以避免无限递归的重要性。基本情况是一个有条件返回而不进行递归调用的条件分支。基本情况通常使用 if 语句实现。在 示例 17-32 中,sub_tree 没有 if,但在 for 循环中有一个隐式条件:如果 cls.__subclasses__() 返回一个空列表,则循环体不会执行,因此不会发生递归调用。基本情况是当 cls 类没有子类时。在这种情况下,sub_tree 不产出任何内容。它只是返回。
示例 17-32 按预期工作,但我们可以通过回顾我们在达到第 3 级时观察到的模式来使其更简洁(示例 17-31):我们产生一个带有级别N的子类,然后开始一个嵌套的循环以访问级别N+1。在示例 17-32 中,我们用yield from 替换了该嵌套循环。现在我们可以将tree 和sub_tree 合并为一个单一的生成器。示例 17-33 是此示例的最后一步。
示例 17-33. tree/step6/tree.py:tree 的递归调用传递了一个递增的level 参数
def tree(cls, level=0):
yield cls.__name__, level
for sub_cls in cls.__subclasses__():
yield from tree(sub_cls, level+1)
def display(cls):
for cls_name, level in tree(cls):
indent = ' ' * 4 * level
print(f'{indent}{cls_name}')
if __name__ == '__main__':
display(BaseException)
在“使用 yield from 的子生成器”开头,我们看到yield from 如何将子生成器直接连接到客户端代码,绕过委托生成器。当生成器用作协程并且不仅产生而且从客户端代码消耗值时,这种连接变得非常重要,正如我们将在“经典协程”中看到的那样。
在第一次遇到yield from 后,让我们转向对可迭代和迭代器进行类型提示。
通用可迭代类型
Python 标准库有许多接受可迭代参数的函数。在您的代码中,这些函数可以像我们在示例 8-15 中看到的zip_replace 函数一样进行注释,使用collections.abc.Iterable(或者如果必须支持 Python 3.8 或更早版本,则使用typing.Iterable,如“遗留支持和已弃用的集合类型”中所解释的那样)。参见示例 17-34。
示例 17-34. replacer.py 返回一个字符串元组的迭代器
from collections.abc import Iterable
FromTo = tuple[str, str] # ①
def zip_replace(text: str, changes: Iterable[FromTo]) -> str: # ②
for from_, to in changes:
text = text.replace(from_, to)
return text
①
定义类型别名;虽然不是必需的,但可以使下一个类型提示更易读。从 Python 3.10 开始,FromTo 应该具有类型提示typing.TypeAlias,以阐明此行的原因:FromTo: TypeAlias = tuple[str, str]。
②
注释changes 以接受FromTo 元组的Iterable。
Iterator 类型出现的频率不如Iterable 类型高,但编写起来也很简单。示例 17-35 展示了熟悉的斐波那契生成器,并加上了注释。
示例 17-35. fibo_gen.py:fibonacci 返回一个整数生成器
from collections.abc import Iterator
def fibonacci() -> Iterator[int]:
a, b = 0, 1
while True:
yield a
a, b = b, a + b
注意,类型Iterator 用于使用yield编写的函数生成器,以及手动编写的作为类的迭代器,具有__next__。还有一个collections.abc.Generator 类型(以及相应的已弃用typing.Generator),我们可以用它来注释生成器对象,但对于用作迭代器的生成器来说,这显得冗长。
示例 17-36,经过 Mypy 检查后,发现Iterator 类型实际上是Generator 类型的一个简化特例。
示例 17-36. itergentype.py:注释迭代器的两种方法
from collections.abc import Iterator
from keyword import kwlist
from typing import TYPE_CHECKING
short_kw = (k for k in kwlist if len(k) < 5) # ①
if TYPE_CHECKING:
reveal_type(short_kw) # ②
long_kw: Iterator[str] = (k for k in kwlist if len(k) >= 4) # ③
if TYPE_CHECKING: # ④
reveal_type(long_kw)
①
生成器表达式,产生长度小于5个字符的 Python 关键字。
②
Mypy 推断:typing.Generator[builtins.str*, None, None]。¹¹
③
这也产生字符串,但我添加了一个明确的类型提示。
④
显式类型:typing.Iterator[builtins.str]。
abc.Iterator[str] 与abc.Generator[str, None, None] 一致,因此 Mypy 在示例 17-36 的类型检查中不会发出错误。
Iterator[T] 是 Generator[T, None, None] 的快捷方式。这两个注释都表示“生成器产生类型为 T 的项目,但不消耗或返回值。” 能够消耗和返回值的生成器是协程,我们下一个主题。
经典协程
注意
PEP 342—通过增强生成器实现协程 引入了 .send() 和其他功能,使得可以将生成器用作协程。PEP 342 使用的“协程”一词与我在此处使用的含义相同。
不幸的是,Python 的官方文档和标准库现在使用不一致的术语来指代用作协程的生成器,迫使我采用“经典协程”限定词以与较新的“本机协程”对象形成对比。
Python 3.5 之后,使用“协程”作为“本机协程”的同义词成为趋势。但 PEP 342 并未被弃用,经典协程仍按最初设计的方式工作,尽管它们不再受 asyncio 支持。
在 Python 中理解经典协程很令人困惑,因为它们实际上是以不同方式使用的生成器。因此,让我们退一步考虑 Python 中另一个可以以两种方式使用的特性。
我们在 “元组不仅仅是不可变列表” 中看到,我们可以将 tuple 实例用作记录或不可变序列。当用作记录时,预期元组具有特定数量的项目,并且每个项目可能具有不同的类型。当用作不可变列表时,元组可以具有任意长度,并且所有项目都预期具有相同的类型。这就是为什么有两种不同的方式使用类型提示注释元组的原因:
# A city record with name, country, and population:
city: tuple[str, str, int]
# An immutable sequence of domain names:
domains: tuple[str, ...]
与生成器类似的情况也发生在生成器上。它们通常用作迭代器,但也可以用作协程。协程 实际上是一个生成器函数,在其主体中使用 yield 关键字创建。协程对象 在物理上是一个生成器对象。尽管在 C 中共享相同的底层实现,但在 Python 中生成器和协程的用例是如此不同,以至于有两种方式对它们进行类型提示:
# The `readings` variable can be bound to an iterator
# or generator object that yields `float` items:
readings: Iterator[float]
# The `sim_taxi` variable can be bound to a coroutine
# representing a taxi cab in a discrete event simulation.
# It yields events, receives `float` timestamps, and returns
# the number of trips made during the simulation:
sim_taxi: Generator[Event, float, int]
使人困惑的是,typing 模块的作者决定将该类型命名为 Generator,而实际上它描述了旨在用作协程的生成器对象的 API,而生成器更常用作简单的迭代器。
typing 文档描述了 Generator 的形式类型参数如下:
Generator[YieldType, SendType, ReturnType]
当生成器用作协程时,SendType 才相关。该类型参数是调用 gen.send(x) 中的 x 的类型。在对被编码为迭代器而不是协程的生成器调用 .send() 是错误的。同样,ReturnType 仅对协程进行注释有意义,因为迭代器不像常规函数那样返回值。将生成器用作迭代器的唯一明智操作是直接或间接通过 for 循环和其他形式的迭代调用 next(it)。YieldType 是调用 next(it) 返回的值的类型。
Generator 类型具有与 typing.Coroutine 相同的类型参数:
Coroutine[YieldType, SendType, ReturnType]
typing.Coroutine 文档实际上说:“类型变量的方差和顺序与 Generator 相对应。”但 typing.Coroutine(已弃用)和 collections.abc.Coroutine(自 Python 3.9 起为通用)旨在仅注释本机协程,而不是经典协程。如果要在经典协程中使用类型提示,您将遭受将它们注释为 Generator[YieldType, SendType, ReturnType] 的困惑。
David Beazley 创建了一些关于经典协程的最佳演讲和最全面的研讨会。在他的 PyCon 2009 课程手册 中,有一张幻灯片标题为“Keeping It Straight”,内容如下:
- 生成器产生用于迭代的数据
- 协程是数据的消费者
- 为了避免大脑爆炸,请不要混淆这两个概念。
- 协程与迭代无关。
- 注意:在协程中使用
yield产生一个值是有用的,但它与迭代无关。¹²
现在让我们看看经典协程是如何工作的。
示例:计算移动平均值的协程
在讨论闭包时,我们在第九章中研究了用于计算移动平均值的对象。示例 9-7 展示了一个类,而示例 9-13 则展示了一个返回函数的高阶函数,该函数在闭包中跨调用保留total和count变量。示例 17-37 展示了如何使用协程实现相同的功能。¹³
示例 17-37. coroaverager.py:计算移动平均值的协程。
from collections.abc import Generator
def averager() -> Generator[float, float, None]: # ①
total = 0.0
count = 0
average = 0.0
while True: # ②
term = yield average # ③
total += term
count += 1
average = total/count
①
此函数返回一个生成器,产生float值,通过.send()接受float值,并不返回有用的值。¹⁴
②
这个无限循环意味着只要客户端代码发送值,协程就会继续产生平均值。
③
这里的yield语句暂停协程,向客户端产生一个结果,然后—稍后—接收调用者发送给协程的值,开始无限循环的另一个迭代。
在协程中,total和count可以是局部变量:不需要实例属性或闭包来在协程在等待下一个.send()时保持上下文。这就是为什么协程在异步编程中是回调的有吸引力替代品——它们在激活之间保持本地状态。
示例 17-38 运行 doctests 以展示averager协程的运行情况。
示例 17-38. coroaverager.py:运行平均值协程的 doctest,参见示例 17-37。
>>> coro_avg = averager() # ①
>>> next(coro_avg) # ②
0.0
>>> coro_avg.send(10) # ③
10.0
>>> coro_avg.send(30)
20.0
>>> coro_avg.send(5)
15.0
①
创建协程对象。
②
启动协程。这会产生average的初始值:0.0。
③
现在我们可以开始了:每次调用.send()都会产生当前的平均值。
在示例 17-38 中,调用next(coro_avg)使协程前进到yield,产生average的初始值。您也可以通过调用coro_avg.send(None)来启动协程——这实际上就是next()内置函数的作用。但是您不能发送除None之外的任何值,因为协程只能在yield行处暂停时接受发送的值。调用next()或.send(None)以前进到第一个yield的操作称为“激活协程”。
每次激活后,协程都会在yield关键字处精确地暂停,等待发送值。coro_avg.send(10)这一行提供了该值,导致协程激活。yield表达式解析为值 10,并将其赋给term变量。循环的其余部分更新total、count和average变量。while循环中的下一次迭代会产生average,协程再次在yield关键字处暂停。
细心的读者可能急于知道如何终止 averager 实例(例如 coro_avg)的执行,因为它的主体是一个无限循环。通常我们不需要终止生成器,因为一旦没有更多有效引用,它就会被垃圾回收。如果需要显式终止它,请使用 .close() 方法,如 示例 17-39 中所示。
示例 17-39. coroaverager.py:从 示例 17-38 继续
>>> coro_avg.send(20) # ①
16.25
>>> coro_avg.close() # ②
>>> coro_avg.close() # ③
>>> coro_avg.send(5) # ④
Traceback (most recent call last):
...
StopIteration
①
coro_avg 是在 示例 17-38 中创建的实例。
②
.close() 方法在挂起的 yield 表达式处引发 GeneratorExit。如果在协程函数中未处理,异常将终止它。GeneratorExit 被包装协程的生成器对象捕获,这就是我们看不到它的原因。
③
对先前关闭的协程调用 .close() 没有任何效果。
④
尝试在已关闭的协程上使用 .send() 会引发 StopIteration。
除了 .send() 方法,PEP 342—通过增强生成器实现协程 还介绍了一种协程返回值的方法。下一节将展示如何实现。
从协程返回一个值
现在我们将学习另一个用于计算平均值的协程。这个版本不会产生部分结果,而是返回一个包含项数和平均值的元组。我将列表分成两部分:示例 17-40 和 示例 17-41。
示例 17-40. coroaverager2.py:文件顶部
from collections.abc import Generator
from typing import Union, NamedTuple
class Result(NamedTuple): # ①
count: int # type: ignore # ②
average: float
class Sentinel: # ③
def __repr__(self):
return f'<Sentinel>'
STOP = Sentinel() # ④
SendType = Union[float, Sentinel] # ⑤
①
示例 17-41 中的 averager2 协程将返回一个 Result 实例。
②
Result 实际上是 tuple 的一个子类,它有一个我不需要的 .count() 方法。# type: ignore 注释防止 Mypy 抱怨有一个 count 字段。¹⁵
③
一个用于创建具有可读 __repr__ 的哨兵值的类。
④
我将使用的哨兵值来使协程停止收集数据并返回结果。
⑤
我将用这个类型别名作为协程 Generator 返回类型的第二个类型参数,即 SendType 参数。
SendType 定义在 Python 3.10 中也有效,但如果不需要支持早期版本,最好在导入 typing 后像这样写:
SendType: TypeAlias = float | Sentinel
使用 | 而不是 typing.Union 如此简洁易读,以至于我可能不会创建该类型别名,而是会像这样编写 averager2 的签名:
def averager2(verbose: bool=False) -> Generator[None, float | Sentinel, Result]:
现在,让我们研究协程代码本身(示例 17-41)。
示例 17-41. coroaverager2.py:返回结果值的协程
def averager2(verbose: bool = False) -> Generator[None, SendType, Result]: # ①
total = 0.0
count = 0
average = 0.0
while True:
term = yield # ②
if verbose:
print('received:', term)
if isinstance(term, Sentinel): # ③
break
total += term # ④
count += 1
average = total / count
return Result(count, average) # ⑤
①
对于这个协程,yield 类型是 None,因为它不产生数据。它接收 SendType 的数据,并在完成时返回一个 Result 元组。
②
像这样使用yield只在协程中有意义,它们被设计用来消耗数据。这里产生None,但从.send(term)接收一个term。
③
如果term是一个Sentinel,就从循环中退出。多亏了这个isinstance检查…
④
…Mypy 允许我将term添加到total中,而不会出现错误,即我无法将float添加到可能是float或Sentinel的对象中。
⑤
只有当Sentinel被发送到协程时,这行才会被执行。
现在让我们看看如何使用这个协程,从一个简单的例子开始,实际上并不产生结果(示例 17-42)。
示例 17-42. coroaverager2.py:展示.cancel()
>>> coro_avg = averager2()
>>> next(coro_avg)
>>> coro_avg.send(10) # ①
>>> coro_avg.send(30)
>>> coro_avg.send(6.5)
>>> coro_avg.close() # ②
①
请记住averager2不会产生部分结果。它产生None,Python 控制台会忽略它。
②
在这个协程中调用.close()会使其停止,但不会返回结果,因为在协程的yield行引发了GeneratorExit异常,所以return语句永远不会被执行。
现在让我们在示例 17-43 中使其工作。
示例 17-43. coroaverager2.py:展示带有Result的StopIteration的 doctest
>>> coro_avg = averager2()
>>> next(coro_avg)
>>> coro_avg.send(10)
>>> coro_avg.send(30)
>>> coro_avg.send(6.5)
>>> try:
... coro_avg.send(STOP) # ①
... except StopIteration as exc:
... result = exc.value # ②
...
>>> result # ③
Result(count=3, average=15.5)
①
发送STOP标记使协程退出循环并返回一个Result。包装协程的生成器对象然后引发StopIteration。
②
StopIteration实例有一个value属性,绑定到终止协程的return语句的值。
③
信不信由你!
将返回值“偷运”出协程并包装在StopIteration异常中的这个想法是一个奇怪的技巧。尽管如此,这个奇怪的技巧是PEP 342—通过增强生成器实现协程的一部分,并且在StopIteration异常和Python 语言参考第六章的“Yield 表达式”部分有记录。
一个委托生成器可以直接使用yield from语法获取协程的返回值,如示例 17-44 所示。
示例 17-44. coroaverager2.py:展示带有Result的StopIteration的 doctest
>>> def compute():
... res = yield from averager2(True) # ①
... print('computed:', res) # ②
... return res # ③
...
>>> comp = compute() # ④
>>> for v in [None, 10, 20, 30, STOP]: # ⑤
... try:
... comp.send(v) # ⑥
... except StopIteration as exc: # ⑦
... result = exc.value
received: 10
received: 20
received: 30
received: <Sentinel>
computed: Result(count=3, average=20.0)
>>> result # ⑧
Result(count=3, average=20.0)
①
res将收集averager2的返回值;yield from机制在处理标记协程终止的StopIteration异常时检索返回值。当True时,verbose参数使协程打印接收到的值,以便使其操作可见。
②
当这个生成器运行时,请留意这行的输出。
③
返回结果。这也将被包装在StopIteration中。
④
创建委托协程对象。
⑤
这个循环将驱动委托协程。
⑥
第一个发送的值是None,用于启动协程;最后一个是停止它的标志。
⑦
捕获StopIteration以获取compute的返回值。
⑧
在averager2和compute输出的行之后,我们得到Result实例。
尽管这里的示例并没有做太多事情,但代码很难理解。使用.send()调用驱动协程并检索结果是复杂的,除非使用yield from—但我们只能在委托生成器/协程内部使用该语法,最终必须由一些非平凡的代码驱动,如示例 17-44 所示。
前面的示例表明直接使用协程是繁琐和令人困惑的。添加异常处理和协程.throw()方法,示例变得更加复杂。我不会在本书中涵盖.throw(),因为—就像.send()一样—它只对手动驱动协程有用,但我不建议这样做,除非你正在从头开始创建一个基于协程的新框架。
注意
如果您对经典协程有更深入的了解—包括.throw()方法—请查看fluentpython.com伴随网站上的“经典协程”。该文章包括类似 Python 的伪代码,详细说明了yield from如何驱动生成器和协程,以及一个小的离散事件模拟,演示了在没有异步编程框架的情况下使用协程实现并发的形式。
在实践中,与协程一起进行有效的工作需要专门框架的支持。这就是 Python 3.3 中asyncio为经典协程提供的支持。随着 Python 3.5 中本地协程的出现,Python 核心开发人员正在逐渐淘汰asyncio中对经典协程的支持。但底层机制非常相似。async def语法使本地协程在代码中更容易识别,这是一个很大的好处。在内部,本地协程使用await而不是yield from来委托给其他协程。第二十一章就是关于这个的。
现在让我们用一个关于协变和逆变的类型提示对协程进行总结。
经典协程的通用类型提示
回到“逆变类型”,我提到typing.Generator是少数几个具有逆变类型参数的标准库类型之一。现在我们已经学习了经典协程,我们准备理解这种通用类型。
这是typing.Generator在 Python 3.6 的typing.py模块中是如何声明的的:¹⁶
T_co = TypeVar('T_co', covariant=True)
V_co = TypeVar('V_co', covariant=True)
T_contra = TypeVar('T_contra', contravariant=True)
# many lines omitted
class Generator(Iterator[T_co], Generic[T_co, T_contra, V_co],
extra=_G_base):
通用类型声明意味着Generator类型提示需要我们之前看到的那三个类型参数:
my_coro : Generator[YieldType, SendType, ReturnType]
从形式参数中的类型变量中,我们看到YieldType和ReturnType是协变的,但SendType是逆变的。要理解原因,考虑到YieldType和ReturnType是“输出”类型。两者描述了从协程对象—即作为协程对象使用时的生成器对象—输出的数据。
这是合理的,因为任何期望一个产生浮点数的协程的代码可以使用一个产生整数的协程。这就是为什么Generator在其YieldType参数上是协变的。相同的推理也适用于ReturnType参数—也是协变的。
使用在“协变类型”中介绍的符号,第一个和第三个参数的协变性由指向相同方向的:>符号表示:
float :> int
Generator[float, Any, float] :> Generator[int, Any, int]
YieldType和ReturnType是“方差法则的基本原则”的第一个规则的例子:
- 如果一个形式类型参数定义了对象中出来的数据的类型,它可以是协变的。
另一方面,SendType是一个“输入”参数:它是协程对象的.send(value)方法的value参数的类型。需要向协程发送浮点数的客户端代码不能使用具有int作为SendType的协程,因为float不是int的子类型。换句话说,float不与int一致。但客户端可以使用具有complex作为SendType的协程,因为float是complex的子类型,因此float与complex一致。
:>符号使得第二个参数的逆变性可见:
float :> int
Generator[Any, float, Any] <: Generator[Any, int, Any]
这是第二个方差法则的一个例子:
- 如果一个形式类型参数定义了对象在初始构造之后进入的数据的类型,它可以是逆变的。
这个关于方差的欢快讨论完成了本书中最长的章节。
章节总结
迭代在语言中是如此深入,以至于我喜欢说 Python 理解迭代器。[¹⁷] 在 Python 语义中集成迭代器模式是设计模式在所有编程语言中并非都适用的一个主要例子。在 Python 中,一个经典的手动实现的迭代器,如示例 17-4,除了作为教学示例外,没有实际用途。
在本章中,我们构建了几个版本的一个类,用于迭代可能非常长的文本文件中的单词。我们看到 Python 如何使用iter()内置函数从类似序列的对象创建迭代器。我们构建了一个经典的迭代器作为一个带有__next__()的类,然后我们使用生成器使得Sentence类的每次重构更加简洁和可读。
然后我们编写了一个算术级数的生成器,并展示了如何利用itertools模块使其更简单。随后是标准库中大多数通用生成器函数的概述。
然后我们在简单生成器的上下文中研究了yield from表达式,使用了chain和tree示例。
最后一个主要部分是关于经典协程的,这是在 Python 3.5 中添加原生协程后逐渐失去重要性的一个主题。尽管在实践中难以使用,经典协程是原生协程的基础,而yield from表达式是await的直接前身。
还涵盖了Iterable、Iterator和Generator类型的类型提示—其中后者提供了一个具体且罕见的逆变类型参数的例子。
进一步阅读
生成器的详细技术解释出现在Python 语言参考中的“6.2.9. Yield expressions”。定义生成器函数的 PEP 是PEP 255—Simple Generators。
由于包含了所有示例,itertools模块文档非常出色。尽管该模块中的函数是用 C 实现的,但文档展示了如何用 Python 编写其中一些函数,通常是通过利用模块中的其他函数。用法示例也很棒;例如,有一个片段展示如何使用accumulate函数根据时间给定的付款列表摊销贷款利息。还有一个“Itertools Recipes”部分,其中包含使用itertools函数作为构建块的其他高性能函数。
除了 Python 标准库之外,我推荐使用More Itertools包,它遵循了itertools传统,提供了强大的生成器,并附带大量示例和一些有用的技巧。
David Beazley 和 Brian K. Jones(O’Reilly)合著的第三版Python Cookbook的第四章“迭代器和生成器”涵盖了这个主题的 16 个配方,从许多不同角度着重于实际应用。其中包括一些使用yield from的启发性配方。
Sebastian Rittau,目前是typeshed的顶级贡献者,解释了为什么迭代器应该是可迭代的,正如他在 2006 年指出的那样,“Java:迭代器不可迭代”。
“Python 3.3 中的新功能”部分在PEP 380—委托给子生成器的语法中用示例解释了yield from语法。我在fluentpython.com上的文章“经典协程”深入解释了yield from,包括其在 C 中实现的 Python 伪代码。
David Beazley 是 Python 生成器和协程的最高权威。他与 Brian Jones 合著的第三版Python Cookbook(O’Reilly)中有许多关于协程的示例。Beazley 在 PyCon 上关于这个主题的教程以其深度和广度而闻名。第一个是在 PyCon US 2008 上的“系统程序员的生成器技巧”。PyCon US 2009 看到了传奇的“协程和并发的奇特课程”(所有三部分的难以找到的视频链接:part 1,part 2和part 3)。他在 2014 年蒙特利尔 PyCon 的教程是“生成器:最终前沿”,其中他处理了更多并发示例,因此实际上更多关于第二十一章中的主题。Dave 无法抵制在他的课堂上让大脑爆炸,因此在“最终前沿”的最后部分,协程取代了经典的访问者模式在算术表达式求值器中。
协程允许以新的方式组织代码,就像递归或多态(动态分派)一样,需要一些时间来适应它们的可能性。一个有趣的经典算法被用协程重写的例子在 James Powell 的文章“使用协程的贪婪算法”中。
Brett Slatkin 的Effective Python,第 1 版(Addison-Wesley)有一章标题为“考虑使用协程并发运行多个函数”的精彩短章。该章节不在Effective Python的第二版中,但仍然可以作为在线示例章节获得。Slatkin 提供了我见过的最好的使用yield from驱动协程的示例:约翰·康威的生命游戏的实现,其中协程管理游戏运行时每个单元格的状态。我重构了生命游戏示例的代码——将实现游戏的函数和类与 Slatkin 原始代码中使用的测试片段分开。我还将测试重写为文档测试,这样您就可以查看各个协程和类的输出而无需运行脚本。重构后的示例发布在GitHub gist上。
¹ 来自“书呆子的复仇”,一篇博客文章。
² 我们首次在“向量 Take #1:Vector2d 兼容”中使用了reprlib。
³ 感谢技术审阅员 Leonardo Rochael 提供这个很好的例子。
⁴ 在审查这段代码时,Alex Martelli 建议这个方法的主体可以简单地是return iter(self.words)。他是对的:调用self.words.__iter__()的结果也将是一个迭代器,正如应该的那样。然而,在这里我使用了一个带有yield的for循环来介绍生成器函数的语法,这需要使用yield关键字,我们将在下一节中看到。在审查本书第二版时,Leonardo Rochael 建议__iter__的主体还有另一个快捷方式:yield from self.words。我们稍后也会介绍yield from。
⁵ 有时在命名生成器函数时我会添加gen前缀或后缀,但这不是一种常见做法。当然,如果您正在实现一个可迭代对象,那么您不能这样做:必需的特殊方法必须命名为__iter__。
⁶ 感谢 David Kwast 提出这个例子。
⁷ 在 Python 2 中,有一个名为coerce()的内置函数,但在 Python 3 中已经消失了。这被认为是不必要的,因为数值强制转换规则在算术运算符方法中是隐含的。因此,我能想到的将初始值强制转换为与系列其余部分相同类型的最佳方法是执行加法并使用其类型来转换结果。我在 Python-list 中询问了这个问题,并从 Steven D’Aprano 那里得到了一个很好的回答。
⁸ 流畅的 Python代码库中的17-it-generator/目录包含了文档测试和一个名为aritprog_runner.py的脚本,该脚本针对aritprog.py*脚本的所有变体运行测试。
⁹ 这里,“映射”一词与字典无关,而是与map内置函数有关。
¹⁰ chain和大多数itertools函数是用 C 编写的。
¹¹ 截至版本 0.910,Mypy 仍在使用已弃用的typing类型。
¹² “关于协程和并发的一门奇特课程”中的幻灯片 33,“保持直线”。
¹³ 这个例子受到 Python-ideas 列表中 Jacob Holm 的一段代码片段的启发,标题为“Yield-From: Finalization guarantees”。稍后的线程中出现了一些变体,Holm 在消息 003912中进一步解释了他的想法。
¹⁴ 实际上,除非某个异常中断循环,否则它永远不会返回。Mypy 0.910 接受 None 和 typing.NoReturn 作为生成器返回类型参数,但它还接受 str 在该位置,因此显然它目前无法完全分析协程代码。
¹⁵ 我考虑过更改字段的名称,但 count 是协程中局部变量的最佳名称,并且在书中的类似示例中我也使用了这个变量的名称,因此在 Result 字段中使用相同的名称是有道理的。我毫不犹豫地使用 # type: ignore 来避免静态类型检查器的限制和烦恼,当提交到工具时会使代码变得更糟或不必要复杂时。
¹⁶ 自 Python 3.7 起,typing.Generator 和其他与 collections.abc 中的 ABCs 对应的类型被重构,使用了对应 ABC 的包装器,因此它们的泛型参数在 typing.py 源文件中不可见。这就是为什么我在这里引用 Python 3.6 源代码的原因。
¹⁷ 根据Jargon 文件,grok 不仅仅是学习某事,而是吸收它,使其“成为你的一部分,成为你身份的一部分”。
¹⁸ Gamma 等人,《设计模式:可复用面向对象软件的元素》,第 261 页。
¹⁹ 代码是用 Python 2 编写的,因为其中一个可选依赖项是名为 Bruma 的 Java 库,我们可以在使用 Jython 运行脚本时导入它——而 Jython 尚不支持 Python 3。
²⁰ 用于读取复杂的 .mst 二进制文件的库实际上是用 Java 编写的,因此只有在使用 Jython 解释器(版本为 2.5 或更新版本)执行 isis2json.py 时才能使用此功能。有关更多详细信息,请参阅存储库中的 README.rst 文件。依赖项是在需要它们的生成器函数内导入的,因此即使只有一个外部库可用,脚本也可以运行。
第十八章:with、match 和 else 块
上下文管理器可能几乎与子例程本身一样重要。我们只是初步了解了它们。[…] Basic 有一个
with语句,在许多语言中都有with语句。但它们的功能不同,它们都只是做一些非常浅显的事情,它们可以避免重复的点式[属性]查找,但它们不进行设置和拆卸。仅仅因为它们有相同的名称,不要认为它们是相同的东西。with语句是一件大事。Raymond Hettinger,Python 雄辩的传道者
本章讨论的是在其他语言中不太常见的控制流特性,因此往往在 Python 中被忽视或未充分利用。它们包括:
-
with语句和上下文管理器协议 -
使用
match/case进行模式匹配 -
for、while和try语句中的else子句
with 语句建立了一个临时上下文,并可靠地在上下文管理器对象的控制下将其拆除。这可以防止错误并减少样板代码,同时使 API 更安全、更易于使用。Python 程序员发现 with 块除了自动关闭文件外还有许多其他用途。
我们在之前的章节中已经看到了模式匹配,但在这里我们将看到语言的语法如何可以表示为序列模式。这一观察解释了为什么 match/case 是创建易于理解和扩展的语言处理器的有效工具。我们将研究 Scheme 语言的一个小但功能齐全的子集的完整解释器。相同的思想可以应用于开发模板语言或在更大系统中编码业务规则的 DSL(领域特定语言)。
else 子句并不是一件大事,但在与 for、while 和 try 一起正确使用时有助于传达意图。
本章新内容
“lis.py 中的模式匹配:案例研究” 是一个新的部分。
我更新了“contextlib 实用工具”,涵盖了自 Python 3.6 以来添加到 contextlib 模块的一些功能,以及 Python 3.10 中引入的新的带括号的上下文管理器语法。
让我们从强大的 with 语句开始。
上下文管理器和 with 块
上下文管理器对象存在以控制 with 语句,就像迭代器存在以控制 for 语句一样。
with 语句旨在简化一些常见的 try/finally 用法,它保证在代码块结束后执行某些操作,即使代码块由 return、异常或 sys.exit() 调用终止。finally 子句中的代码通常释放关键资源或恢复一些临时更改的先前状态。
Python 社区正在为上下文管理器找到新的创造性用途。标准库中的一些示例包括:
-
在
sqlite3模块中管理事务—参见“将连接用作上下文管理器”。 -
安全处理锁、条件和信号量,如
threading模块文档中所述。 -
为
Decimal对象设置自定义环境进行算术运算—参见decimal.localcontext文档。 -
为测试修补对象—参见
unittest.mock.patch函数。
上下文管理器接口由 __enter__ 和 __exit__ 方法组成。在 with 的顶部,Python 调用上下文管理器对象的 __enter__ 方法。当 with 块完成或由于任何原因终止时,Python 调用上下文管理器对象的 __exit__ 方法。
最常见的例子是确保文件对象会关闭。示例 18-1 是使用 with 关闭文件的详细演示。
示例 18-1。文件对象作为上下文管理器的演示
>>> with open('mirror.py') as fp: # ①
... src = fp.read(60) # ②
...
>>> len(src)
60 >>> fp # ③
<_io.TextIOWrapper name='mirror.py' mode='r' encoding='UTF-8'> >>> fp.closed, fp.encoding # ④
(True, 'UTF-8') >>> fp.read(60) # ⑤
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: I/O operation on closed file.
①
fp绑定到打开的文本文件,因为文件的__enter__方法返回self。
②
从fp中读取60个 Unicode 字符。
③
fp变量仍然可用——with块不像函数那样定义新的作用域。
④
我们可以读取fp对象的属性。
⑤
但是我们无法从fp中读取更多文本,因为在with块结束时,调用了TextIOWrapper.__exit__方法,它关闭了文件。
示例 18-1 中的第一个标注点提出了一个微妙但至关重要的观点:上下文管理器对象是在评估with后的表达式的结果,但绑定到目标变量(在as子句中)的值是上下文管理器对象的__enter__方法返回的结果。
恰好open()函数返回TextIOWrapper的一个实例,其__enter__方法返回self。但在不同的类中,__enter__方法也可能返回其他对象,而不是上下文管理器实例。
无论以何种方式退出with块的控制流,__exit__方法都会在上下文管理器对象上调用,而不是在__enter__返回的任何对象上调用。
with语句的as子句是可选的。在open的情况下,我们总是需要它来获得文件的引用,以便我们可以在其上调用方法。但是一些上下文管理器返回None,因为它们没有有用的对象可以返回给用户。
示例 18-2 展示了一个完全轻松的上下文管理器的操作,旨在突出上下文管理器和其__enter__方法返回的对象之间的区别。
示例 18-2. 测试LookingGlass上下文管理器类
>>> from mirror import LookingGlass
>>> with LookingGlass() as what: # ①
... print('Alice, Kitty and Snowdrop') # ②
... print(what)
...
pordwonS dna yttiK ,ecilA
YKCOWREBBAJ
>>> what # ③
'JABBERWOCKY'
>>> print('Back to normal.') # ④
Back to normal.
①
上下文管理器是LookingGlass的一个实例;Python 在上下文管理器上调用__enter__,结果绑定到what。
②
打印一个str,然后打印目标变量what的值。每个print的输出都会被反转。
③
现在with块已经结束。我们可以看到__enter__返回的值,保存在what中,是字符串'JABBERWOCKY'。
④
程序输出不再被反转。
示例 18-3 展示了LookingGlass的实现。
示例 18-3. mirror.py:LookingGlass上下文管理器类的代码
import sys
class LookingGlass:
def __enter__(self): # ①
self.original_write = sys.stdout.write # ②
sys.stdout.write = self.reverse_write # ③
return 'JABBERWOCKY' # ④
def reverse_write(self, text): # ⑤
self.original_write(text[::-1])
def __exit__(self, exc_type, exc_value, traceback): # ⑥
sys.stdout.write = self.original_write # ⑦
if exc_type is ZeroDivisionError: # ⑧
print('Please DO NOT divide by zero!')
return True # ⑨
# ⑩
①
Python 会以除self之外没有其他参数调用__enter__。
②
保留原始的sys.stdout.write方法,以便稍后恢复。
③
用我们自己的方法替换sys.stdout.write,进行 Monkey-patch。
④
返回'JABBERWOCKY'字符串,这样我们就有了一些东西可以放在目标变量what中。
⑤
我们替换了sys.stdout.write,将text参数反转并调用原始实现。
⑥
如果一切顺利,Python 会用None, None, None调用__exit__;如果引发异常,则三个参数将获得异常数据,如本示例后所述。
⑦
恢复原始方法sys.stdout.write。
⑧
如果异常不是None且其类型是ZeroDivisionError,则打印一条消息…
⑨
…并返回True以告诉解释器异常已被处理。
⑩
如果__exit__返回None或任何假值,则with块中引发的任何异常都将传播。
提示
当真实应用接管标准输出时,它们通常希望将sys.stdout替换为另一个类似文件的对象一段时间,然后再切换回原始状态。contextlib.redirect_stdout上下文管理器正是这样做的:只需将它传递给将替代sys.stdout的文件类对象。
解释器使用无参数—除了隐式的self—调用__enter__方法。传递给__exit__的三个参数是:
exc_type
异常类(例如ZeroDivisionError)。
exc_value
异常实例。有时,传递给异常构造函数的参数—如错误消息—可以在exc_value.args中找到。
traceback
一个traceback对象。²
要详细了解上下文管理器的工作原理,请参见示例 18-4,其中LookingGlass在with块之外使用,因此我们可以手动调用其__enter__和__exit__方法。
示例 18-4. 在没有with块的情况下使用LookingGlass
>>> from mirror import LookingGlass
>>> manager = LookingGlass() # ①
>>> manager # doctest: +ELLIPSIS
<mirror.LookingGlass object at 0x...>
>>> monster = manager.__enter__() # ②
>>> monster == 'JABBERWOCKY' # ③
eurT
>>> monster
'YKCOWREBBAJ'
>>> manager # doctest: +ELLIPSIS
>... ta tcejbo ssalGgnikooL.rorrim<
>>> manager.__exit__(None, None, None) # ④
>>> monster
'JABBERWOCKY'
①
实例化并检查manager实例。
②
调用管理器的__enter__方法并将结果存储在monster中。
③
monster是字符串'JABBERWOCKY'。True标识符出现颠倒,因为所有通过stdout输出的内容都经过我们在__enter__中打补丁的write方法。
④
调用manager.__exit__以恢复先前的stdout.write。
Python 3.10 中的括号上下文管理器
Python 3.10 采用了一个新的、更强大的解析器,允许新的语法超出旧的LL(1)解析器所能实现的范围。一个语法增强是允许括号上下文管理器,就像这样:
with (
CtxManager1() as example1,
CtxManager2() as example2,
CtxManager3() as example3,
):
...
在 3.10 之前,我们必须编写嵌套的with块。
标准库包括contextlib包,其中包含用于构建、组合和使用上下文管理器的方便函数、类和装饰器。
contextlib实用程序
在自己编写上下文管理器类之前,请查看 Python 文档中的contextlib—“用于with语句上下文的实用程序”。也许您即将构建的内容已经存在,或者有一个类或一些可调用对象可以让您的工作更轻松。
除了在示例 18-3 之后提到的redirect_stdout上下文管理器之外,Python 3.5 中还添加了redirect_stderr—它的功能与前者相同,但用于指向stderr的输出。
contextlib包还包括:
closing
一个函数,用于从提供close()方法但不实现__enter__/__exit__接口的对象构建上下文管理器。
suppress
一个上下文管理器,用于临时忽略作为参数给出的异常。
nullcontext
一个什么都不做的上下文管理器,用于简化围绕可能不实现合适上下文管理器的对象的条件逻辑。当with块之前的条件代码可能或可能不为with语句提供上下文管理器时,它充当替代品—Python 3.7 中添加。
contextlib模块提供的类和装饰器比刚才提到的装饰器更广泛适用:
@contextmanager
一个装饰器,让您可以从简单的生成器函数构建上下文管理器,而不是创建一个类并实现接口。参见“使用@contextmanager”。
AbstractContextManager
一个正式化上下文管理器接口的 ABC,并通过子类化使得创建上下文管理器类变得更容易——在 Python 3.6 中添加。
ContextDecorator
用于定义基于类的上下文管理器的基类,也可以用作函数修饰符,将整个函数在受控上下文中运行。
ExitStack
一个允许您进入可变数量上下文管理器的上下文管理器。当with块结束时,ExitStack以 LIFO 顺序(最后进入,最先退出)调用堆叠的上下文管理器的__exit__方法。当您不知道在with块中需要进入多少上下文管理器时,请使用此类;例如,当同时打开来自任意文件列表的所有文件时。
在 Python 3.7 中,contextlib添加了AbstractAsyncContextManager,@asynccontextmanager和AsyncExitStack。它们类似于名称中不带async部分的等效实用程序,但设计用于与新的async with语句一起使用,该语句在第二十一章中介绍。
这些实用程序中最常用的是@contextmanager修饰符,因此它值得更多关注。该修饰符也很有趣,因为它展示了与迭代无关的yield语句的用法。
使用@contextmanager
@contextmanager修饰符是一个优雅且实用的工具,它将三个独特的 Python 特性结合在一起:函数修饰符、生成器和with语句。
使用@contextmanager减少了创建上下文管理器的样板代码:不需要编写一个具有__enter__/__exit__方法的整个类,只需实现一个生成器,其中包含一个应该生成__enter__方法返回的内容。
在使用@contextmanager修饰的生成器中,yield将函数体分为两部分:yield之前的所有内容将在解释器调用__enter__时在with块的开头执行;yield之后的代码将在块结束时调用__exit__时运行。
示例 18-5 用生成器函数替换了示例 18-3 中的LookingGlass类。
示例 18-5. mirror_gen.py:使用生成器实现的上下文管理器
import contextlib
import sys
@contextlib.contextmanager # ①
def looking_glass():
original_write = sys.stdout.write # ②
def reverse_write(text): # ③
original_write(text[::-1])
sys.stdout.write = reverse_write # ④
yield 'JABBERWOCKY' # ⑤
sys.stdout.write = original_write # ⑥
①
应用contextmanager修饰符。
②
保留原始的sys.stdout.write方法。
③
reverse_write稍后可以调用original_write,因为它在其闭包中可用。
④
将sys.stdout.write替换为reverse_write。
⑤
产生将绑定到with语句中as子句的目标变量的值。生成器在此处暂停,而with的主体执行。
⑥
当控制退出with块时,执行继续在yield之后;这里恢复原始的sys.stdout.write。
示例 18-6 展示了looking_glass函数的运行。
示例 18-6. 测试looking_glass上下文管理器函数
>>> from mirror_gen import looking_glass
>>> with looking_glass() as what: # ①
... print('Alice, Kitty and Snowdrop')
... print(what)
...
pordwonS dna yttiK ,ecilA
YKCOWREBBAJ
>>> what
'JABBERWOCKY'
>>> print('back to normal')
back to normal
①
与示例 18-2 唯一的区别是上下文管理器的名称:looking_glass而不是LookingGlass。
contextlib.contextmanager修饰符将函数包装在一个实现__enter__和__exit__方法的类中。³
该类的__enter__方法:
-
调用生成器函数以获取生成器对象——我们将其称为
gen。 -
调用
next(gen)来驱动它到yield关键字。 -
返回由
next(gen)产生的值,以允许用户将其绑定到with/as形式中的变量。
当with块终止时,__exit__方法:
-
检查是否将异常作为
exc_type传递;如果是,则调用gen.throw(exception),导致异常在生成器函数体内的yield行中被引发。 -
否则,调用
next(gen),恢复yield后生成器函数体的执行。
示例 18-5 存在一个缺陷:如果在with块的主体中引发异常,Python 解释器将捕获它并在looking_glass内的yield表达式中再次引发它。但那里没有错误处理,因此looking_glass生成器将在不恢复原始sys.stdout.write方法的情况下终止,使系统处于无效状态。
示例 18-7 添加了对ZeroDivisionError异常的特殊处理,使其在功能上等同于基于类的示例 18-3。
示例 18-7. mirror_gen_exc.py:基于生成器的上下文管理器实现异常处理,与示例 18-3 具有相同的外部行为
import contextlib
import sys
@contextlib.contextmanager
def looking_glass():
original_write = sys.stdout.write
def reverse_write(text):
original_write(text[::-1])
sys.stdout.write = reverse_write
msg = '' # ①
try:
yield 'JABBERWOCKY'
except ZeroDivisionError: # ②
msg = 'Please DO NOT divide by zero!'
finally:
sys.stdout.write = original_write # ③
if msg:
print(msg) # ④
①
为可能的错误消息创建一个变量;这是与示例 18-5 相关的第一个更改。
②
处理ZeroDivisionError,设置一个错误消息。
③
撤消对sys.stdout.write的猴子补丁。
④
如果已设置错误消息,则显示错误消息。
回想一下,__exit__方法告诉解释器已通过返回一个真值处理了异常;在这种情况下,解释器会抑制异常。另一方面,如果__exit__没有显式返回一个值,解释器会得到通常的None,并传播异常。使用@contextmanager,默认行为被反转:装饰器提供的__exit__方法假定任何发送到生成器中的异常都已处理并应该被抑制。
提示
在yield周围有一个try/finally(或with块)是使用@contextmanager的不可避免的代价,因为你永远不知道你的上下文管理器的用户会在with块内做什么。⁴
@contextmanager的一个鲜为人知的特性是,用它装饰的生成器也可以作为装饰器使用。⁵ 这是因为@contextmanager是使用contextlib.ContextDecorator类实现的。
示例 18-8 展示了从示例 18-5 中使用的looking_glass上下文管理器作为装饰器。
示例 18-8. looking_glass上下文管理器也可以作为装饰器使用
>>> @looking_glass()
... def verse():
... print('The time has come')
...
>>> verse() # ①
emoc sah emit ehT
>>> print('back to normal') # ②
back to normal
①
looking_glass在verse主体运行之前和之后执行其工作。
②
这证实了原始的sys.write已经恢复。
将示例 18-8 与示例 18-6 进行对比,在其中looking_glass被用作上下文管理器。
@contextmanager在标准库之外的一个有趣的现实生活示例是 Martijn Pieters 的使用上下文管理器进行原地文件重写。示例 18-9 展示了它的使用方式。
示例 18-9. 用于原地重写文件的上下文管理器
import csv
with inplace(csvfilename, 'r', newline='') as (infh, outfh):
reader = csv.reader(infh)
writer = csv.writer(outfh)
for row in reader:
row += ['new', 'columns']
writer.writerow(row)
inplace函数是一个上下文管理器,为您提供两个句柄—在示例中为infh和outfh—指向同一个文件,允许您的代码同时读取和写入。它比标准库的fileinput.input函数更容易使用(顺便说一句,它也提供了一个上下文管理器)。
如果您想研究 Martijn 的inplace源代码(列在帖子中),请查找yield关键字:在它之前的所有内容都涉及设置上下文,这包括创建备份文件,然后打开并生成可读和可写文件句柄的引用,这些引用将由__enter__调用返回。yield后的__exit__处理关闭文件句柄,并在出现问题时从备份中恢复文件。
这结束了我们对with语句和上下文管理器的概述。让我们转向完整示例中的match/case。
lis.py 中的模式匹配:一个案例研究
在“解释器中的模式匹配序列”中,我们看到了从 Peter Norvig 的lis.py解释器的evaluate函数中提取的序列模式的示例,该解释器已移植到 Python 3.10。在本节中,我想更广泛地介绍lis.py的工作原理,并探讨evaluate的所有case子句,不仅解释模式,还解释解释器在每个case中的操作。
除了展示更多的模式匹配,我写这一部分有三个原因:
-
Norvig 的lis.py是惯用 Python 代码的一个很好的例子。
-
Scheme 的简单性是语言设计的典范。
-
了解解释器如何工作让我更深入地理解了 Python 和一般编程语言——无论是解释型还是编译型。
在查看 Python 代码之前,让我们稍微了解一下 Scheme,这样您就可以理解这个案例研究——如果您以前没有见过 Scheme 或 Lisp 的话。
Scheme 语法
在 Scheme 中,表达式和语句之间没有区别,就像我们在 Python 中所看到的那样。此外,没有中缀运算符。所有表达式都使用前缀表示法,如(+ x 13)而不是x + 13。相同的前缀表示法用于函数调用—例如,(gcd x 13)—和特殊形式—例如,(define x 13),我们在 Python 中会写成赋值语句x = 13。Scheme 和大多数 Lisp 方言使用的表示法称为S 表达式。⁶
示例 18-10 展示了 Scheme 中的一个简单示例。
示例 18-10. Scheme 中的最大公约数
(define (mod m n)
(- m (* n (quotient m n))))
(define (gcd m n)
(if (= n 0)
m
(gcd n (mod m n))))
(display (gcd 18 45))
示例 18-10 展示了三个 Scheme 表达式:两个函数定义—mod和gcd—以及一个调用display,它将输出 9,即(gcd 18 45)的结果。示例 18-11 是相同的 Python 代码(比递归欧几里德算法的英文解释更短)。
示例 18-11. 与示例 18-10 相同,用 Python 编写
def mod(m, n):
return m - (m // n * n)
def gcd(m, n):
if n == 0:
return m
else:
return gcd(n, mod(m, n))
print(gcd(18, 45))
在惯用 Python 中,我会使用%运算符而不是重新发明mod,并且使用while循环而不是递归会更有效率。但我想展示两个函数定义,并尽可能使示例相似,以帮助您阅读 Scheme 代码。
Scheme 没有像 Python 中那样的迭代控制流命令,如while或for。迭代是通过递归完成的。请注意,在 Scheme 和 Python 示例中没有赋值。广泛使用递归和最小使用赋值是函数式编程的标志。⁷
现在让我们回顾一下 Python 3.10 版本的lis.py代码。完整的源代码和测试位于 GitHub 存储库fluentpython/example-code-2e的18-with-match/lispy/py3.10/目录中。
导入和类型
[示例 18-12 显示了lis.py的前几行。TypeAlias和|类型联合运算符的使用需要 Python 3.10。
示例 18-12. lis.py:文件顶部
import math
import operator as op
from collections import ChainMap
from itertools import chain
from typing import Any, TypeAlias, NoReturn
Symbol: TypeAlias = str
Atom: TypeAlias = float | int | Symbol
Expression: TypeAlias = Atom | list
定义的类型有:
Symbol
str的别名。在lis.py中,Symbol用于标识符;没有带有切片、分割等操作的字符串数据类型。⁸
Atom
一个简单的句法元素,如数字或 Symbol—与由不同部分组成的复合结构相对,如列表。
表达式
Scheme 程序的构建块是由原子和列表组成的表达式,可能是嵌套的。
解析器
Norvig 的解析器是 36 行代码,展示了 Python 的强大之处,应用于处理 S-表达式的简单递归语法,没有字符串数据、注释、宏和标准 Scheme 的其他特性,这些特性使解析变得更加复杂(示例 18-13)。
示例 18-13. lis.py:主要解析函数
def parse(program: str) -> Expression:
"Read a Scheme expression from a string."
return read_from_tokens(tokenize(program))
def tokenize(s: str) -> list[str]:
"Convert a string into a list of tokens."
return s.replace('(', ' ( ').replace(')', ' ) ').split()
def read_from_tokens(tokens: list[str]) -> Expression:
"Read an expression from a sequence of tokens."
# more parsing code omitted in book listing
该组的主要函数是 parse,它接受一个 S-表达式作为 str 并返回一个 Expression 对象,如 示例 18-12 中定义的:一个 Atom 或一个可能包含更多原子和嵌套列表的 list。
Norvig 在 tokenize 中使用了一个聪明的技巧:他在输入的每个括号前后添加空格,然后拆分它,结果是一个包含 '(' 和 ')' 作为单独标记的句法标记列表。这个快捷方式有效,因为在 lis.py 的小 Scheme 中没有字符串类型,所以每个 '(' 或 ')' 都是表达式分隔符。递归解析代码在 read_from_tokens 中,这是一个 14 行的函数,你可以在 fluentpython/example-code-2e 仓库中阅读。我会跳过它,因为我想专注于解释器的其他部分。
这里是从 lispy/py3.10/examples_test.py 中提取的一些 doctest:
>>> from lis import parse
>>> parse('1.5')
1.5
>>> parse('ni!')
'ni!'
>>> parse('(gcd 18 45)')
['gcd', 18, 45]
>>> parse('''
... (define double
... (lambda (n)
... (* n 2)))
... ''')
['define', 'double', ['lambda', ['n'], ['*', 'n', 2]]]
这个 Scheme 子集的解析规则很简单:
-
看起来像数字的标记被解析为
float或int。 -
其他任何不是
'('或')'的内容都被解析为Symbol—一个可用作标识符的str。这包括 Scheme 中有效但不在 Python 中的标识符的源文本,如+、set!和make-counter。 -
'('和')'内的表达式被递归解析为包含原子的列表或可能包含原子和更多嵌套列表的嵌套列表。
使用 Python 解释器的术语,parse 的输出是一个 AST(抽象语法树):一个方便的表示 Scheme 程序的嵌套列表形成树状结构,其中最外层列表是主干,内部列表是分支,原子是叶子(图 18-1)。

图 18-1. 一个 Scheme lambda 表达式,表示为源代码(具体语法)、作为树和作为 Python 对象序列(抽象语法)。
环境
Environment 类扩展了 collections.ChainMap,添加了一个 change 方法来更新链式字典中的值,ChainMap 实例将这些值保存在映射列表中:self.maps 属性。change 方法用于支持后面描述的 Scheme (set! …) 形式;参见 示例 18-14。
示例 18-14. lis.py:Environment 类
class Environment(ChainMap[Symbol, Any]):
"A ChainMap that allows changing an item in-place."
def change(self, key: Symbol, value: Any) -> None:
"Find where key is defined and change the value there."
for map in self.maps:
if key in map:
map[key] = value # type: ignore[index]
return
raise KeyError(key)
注意,change 方法只更新现有键。⁹ 尝试更改未找到的键会引发 KeyError。
这个 doctest 展示了 Environment 的工作原理:
>>> from lis import Environment
>>> inner_env = {'a': 2}
>>> outer_env = {'a': 0, 'b': 1}
>>> env = Environment(inner_env, outer_env)
>>> env['a'] # ①
2 >>> env['a'] = 111 # ②
>>> env['c'] = 222
>>> env
Environment({'a': 111, 'c': 222}, {'a': 0, 'b': 1}) >>> env.change('b', 333) # ③
>>> env
Environment({'a': 111, 'c': 222}, {'a': 0, 'b': 333})
①
在读取值时,Environment 的工作方式类似于 ChainMap:键从左到右在嵌套映射中搜索。这就是为什么 outer_env 中 a 的值被 inner_env 中的值遮蔽。
②
使用 [] 赋值会覆盖或插入新项,但始终在第一个映射 inner_env 中进行,本例中。
③
env.change('b', 333) 寻找 'b' 键并在 outer_env 中就地分配一个新值。
接下来是 standard_env() 函数,它构建并返回一个加载了预定义函数的 Environment,类似于 Python 的 __builtins__ 模块,始终可用(示例 18-15)。
示例 18-15. lis.py:standard_env()构建并返回全局环境
def standard_env() -> Environment:
"An environment with some Scheme standard procedures."
env = Environment()
env.update(vars(math)) # sin, cos, sqrt, pi, ...
env.update({
'+': op.add,
'-': op.sub,
'*': op.mul,
'/': op.truediv,
# omitted here: more operator definitions
'abs': abs,
'append': lambda *args: list(chain(*args)),
'apply': lambda proc, args: proc(*args),
'begin': lambda *x: x[-1],
'car': lambda x: x[0],
'cdr': lambda x: x[1:],
# omitted here: more function definitions
'number?': lambda x: isinstance(x, (int, float)),
'procedure?': callable,
'round': round,
'symbol?': lambda x: isinstance(x, Symbol),
})
return env
总结一下,env映射加载了:
-
Python 的
math模块中的所有函数 -
从 Python 的
op模块中选择的运算符 -
使用 Python 的
lambda构建的简单但强大的函数 -
Python 内置函数重命名,比如
callable改为procedure?,或者直接映射,比如round
REPL
Norvig 的 REPL(读取-求值-打印-循环)易于理解但不用户友好(参见 Example 18-16)。如果没有向lis.py提供命令行参数,则main()会调用repl()函数—在模块末尾定义。在lis.py>提示符下,我们必须输入正确和完整的表达式;如果忘记关闭一个括号,lis.py会崩溃。¹⁰
示例 18-16. REPL 函数
def repl(prompt: str = 'lis.py> ') -> NoReturn:
"A prompt-read-eval-print loop."
global_env = Environment({}, standard_env())
while True:
ast = parse(input(prompt))
val = evaluate(ast, global_env)
if val is not None:
print(lispstr(val))
def lispstr(exp: object) -> str:
"Convert a Python object back into a Lisp-readable string."
if isinstance(exp, list):
return '(' + ' '.join(map(lispstr, exp)) + ')'
else:
return str(exp)
这里是关于这两个函数的简要解释:
repl(prompt: str = 'lis.py> ') -> NoReturn
调用standard_env()为全局环境提供内置函数,然后进入一个无限循环,读取和解析每个输入行,在全局环境中评估它,并显示结果—除非它是None。global_env可能会被evaluate修改。例如,当用户定义新的全局变量或命名函数时,它会存储在环境的第一个映射中—在repl的第一行中的Environment构造函数调用中的空dict中。
lispstr(exp: object) -> str
parse的反函数:给定表示表达式的 Python 对象,parse返回其 Scheme 源代码。例如,给定['+', 2, 3],结果是'(+ 2 3)'。
评估器
现在我们可以欣赏 Norvig 的表达式求值器的美丽之处—通过match/case稍微美化了一下。Example 18-17 中的evaluate函数接受由parse构建的Expression和一个Environment。
evaluate的主体是一个带有表达式exp的单个match语句。case模式以惊人的清晰度表达了 Scheme 的语法和语义。
示例 18-17. evaluate接受一个表达式并计算其值
KEYWORDS = ['quote', 'if', 'lambda', 'define', 'set!']
def evaluate(exp: Expression, env: Environment) -> Any:
"Evaluate an expression in an environment."
match exp:
case int(x) | float(x):
return x
case Symbol(var):
return env[var]
case ['quote', x]:
return x
case ['if', test, consequence, alternative]:
if evaluate(test, env):
return evaluate(consequence, env)
else:
return evaluate(alternative, env)
case ['lambda', [*parms], *body] if body:
return Procedure(parms, body, env)
case ['define', Symbol(name), value_exp]:
env[name] = evaluate(value_exp, env)
case ['define', [Symbol(name), *parms], *body] if body:
env[name] = Procedure(parms, body, env)
case ['set!', Symbol(name), value_exp]:
env.change(name, evaluate(value_exp, env))
case [func_exp, *args] if func_exp not in KEYWORDS:
proc = evaluate(func_exp, env)
values = [evaluate(arg, env) for arg in args]
return proc(*values)
case _:
raise SyntaxError(lispstr(exp))
让我们研究每个case子句及其作用。在某些情况下,我添加了注释,显示出一个 S 表达式,当解析为 Python 列表时会匹配该模式。从examples_test.py提取的 Doctests 展示了每个case。
评估数字
case int(x) | float(x):
return x
主题:
int或float的实例。
操作:
原样返回值。
示例:
>>> from lis import parse, evaluate, standard_env
>>> evaluate(parse('1.5'), {})
1.5
评估符号
case Symbol(var):
return env[var]
主题:
Symbol的实例,即作为标识符使用的str。
操作:
在env中查找var并返回其值。
示例:
>>> evaluate(parse('+'), standard_env())
<built-in function add>
>>> evaluate(parse('ni!'), standard_env())
Traceback (most recent call last):
...
KeyError: 'ni!'
(quote …)
quote特殊形式将原子和列表视为数据而不是要评估的表达式。
# (quote (99 bottles of beer))
case ['quote', x]:
return x
主题:
以符号'quote'开头的列表,后跟一个表达式x。
操作:
返回x而不对其进行评估。
示例:
>>> evaluate(parse('(quote no-such-name)'), standard_env())
'no-such-name'
>>> evaluate(parse('(quote (99 bottles of beer))'), standard_env())
[99, 'bottles', 'of', 'beer']
>>> evaluate(parse('(quote (/ 10 0))'), standard_env())
['/', 10, 0]
没有quote,测试中的每个表达式都会引发错误:
-
在环境中查找
no-such-name,引发KeyError -
(99 bottles of beer)无法评估,因为数字 99 不是命名特殊形式、运算符或函数的Symbol -
(/ 10 0)会引发ZeroDivisionError
(if …)
# (if (< x 0) 0 x)
case ['if', test, consequence, alternative]:
if evaluate(test, env):
return evaluate(consequence, env)
else:
return evaluate(alternative, env)
主题:
以'if'开头的列表,后跟三个表达式:test、consequence和alternative。
操作:
评估test:
-
如果为真,则评估
consequence并返回其值。 -
否则,评估
alternative并返回其值。
示例:
>>> evaluate(parse('(if (= 3 3) 1 0))'), standard_env())
1
>>> evaluate(parse('(if (= 3 4) 1 0))'), standard_env())
0
consequence和alternative分支必须是单个表达式。如果分支中需要多个表达式,可以使用(begin exp1 exp2…)将它们组合起来,作为lis.py中的一个函数提供—参见 Example 18-15。
(lambda …)
Scheme 的lambda形式定义了匿名函数。它不受 Python 的lambda的限制:任何可以用 Scheme 编写的函数都可以使用(lambda ...)语法编写。
# (lambda (a b) (/ (+ a b) 2))
case ['lambda' [*parms], *body] if body:
return Procedure(parms, body, env)
主题:
以'lambda'开头的列表,后跟:
-
零个或多个参数名的列表。
-
一个或多个收集在
body中的表达式(保证body不为空)。
操作:
创建并返回一个新的Procedure实例,其中包含参数名称、作为主体的表达式列表和当前环境。
例子:
>>> expr = '(lambda (a b) (* (/ a b) 100))'
>>> f = evaluate(parse(expr), standard_env())
>>> f # doctest: +ELLIPSIS
<lis.Procedure object at 0x...>
>>> f(15, 20)
75.0
Procedure类实现了闭包的概念:一个可调用对象,包含参数名称、函数体和函数定义所在环境的引用。我们将很快研究Procedure的代码。
(define …)
define关键字以两种不同的语法形式使用。最简单的是:
# (define half (/ 1 2))
case ['define', Symbol(name), value_exp]:
env[name] = evaluate(value_exp, env)
主题:
以'define'开头的列表,后跟一个Symbol和一个表达式。
操作:
评估表达式并将其值放入env中,使用name作为键。
例子:
>>> global_env = standard_env()
>>> evaluate(parse('(define answer (* 7 6))'), global_env)
>>> global_env['answer']
42
此case的 doctest 创建一个global_env,以便我们可以验证evaluate将answer放入该Environment中。
我们可以使用简单的define形式来创建变量或将名称绑定到匿名函数,使用(lambda …)作为value_exp。
标准 Scheme 提供了一种快捷方式来定义命名函数。这是第二种define形式:
# (define (average a b) (/ (+ a b) 2))
case ['define', [Symbol(name), *parms], *body] if body:
env[name] = Procedure(parms, body, env)
主题:
以'define'开头的列表,后跟:
-
以
Symbol(name)开头的列表,后跟零个或多个收集到名为parms的列表中的项目。 -
一个或多个收集在
body中的表达式(保证body不为空)。
操作:
-
创建一个新的
Procedure实例,其中包含参数名称、作为主体的表达式列表和当前环境。 -
将
Procedure放入env中,使用name作为键。
Example 18-18 中的 doctest 定义了一个名为%的计算百分比的函数,并将其添加到global_env中。
例 18-18. 定义一个名为%的计算百分比的函数
>>> global_env = standard_env()
>>> percent = '(define (% a b) (* (/ a b) 100))'
>>> evaluate(parse(percent), global_env)
>>> global_env['%'] # doctest: +ELLIPSIS
<lis.Procedure object at 0x...>
>>> global_env'%'
85.0
调用evaluate后,我们检查%是否绑定到一个接受两个数值参数并返回百分比的Procedure。
第二个define case的模式不强制要求parms中的项目都是Symbol实例。我必须在构建Procedure之前检查这一点,但我没有这样做——为了使代码像 Norvig 的那样易于理解。
(set! …)
set!形式更改先前定义的变量的值。¹¹
# (set! n (+ n 1))
case ['set!', Symbol(name), value_exp]:
env.change(name, evaluate(value_exp, env))
主题:
以'set!'开头的列表,后跟一个Symbol和一个表达式。
操作:
使用评估表达式的结果更新env中的name的值。
Environment.change方法从本地到全局遍历链接的环境,并用新值更新第一次出现的name。如果我们不实现'set!'关键字,我们可以在解释器中的任何地方都使用 Python 的ChainMap作为Environment类型。
现在我们来到一个函数调用。
函数调用
# (gcd (* 2 105) 84)
case [func_exp, *args] if func_exp not in KEYWORDS:
proc = evaluate(func_exp, env)
values = [evaluate(arg, env) for arg in args]
return proc(*values)
主题:
一个或多个项目的列表。
保护确保func_exp不是['quote', 'if', 'define', 'lambda', 'set!']中的一个——在 Example 18-17 中的evaluate之前列出。
模式匹配任何具有一个或多个表达式的列表,将第一个表达式绑定到func_exp,其余的绑定到args作为列表,可能为空。
操作:
-
评估
func_exp以获得函数proc。 -
评估
args中的每个项目以构建参数值列表。 -
用值作为单独参数调用
proc,返回结果。
例子:
>>> evaluate(parse('(% (* 12 14) (- 500 100))'), global_env)
42.0
此 doctest 继续自 Example 18-18:假设global_env有一个名为%的函数。给定给%的参数是算术表达式,以强调在调用函数之前对参数进行评估。
此case中的保护是必需的,因为[func_exp, *args]匹配任何具有一个或多个项目的主题序列。但是,如果func_exp是一个关键字,并且主题没有匹配任何先前的情况,那么这实际上是一个语法错误。
捕获语法错误
如果主题exp不匹配任何先前的情况,通用case会引发SyntaxError:
case _:
raise SyntaxError(lispstr(exp))
这是一个作为SyntaxError报告的格式不正确的(lambda …)的示例:
>>> evaluate(parse('(lambda is not like this)'), standard_env())
Traceback (most recent call last):
...
SyntaxError: (lambda is not like this)
如果函数调用的 case 没有拒绝关键字的保护,(lambda is not like this) 表达式将被处理为函数调用,这将引发 KeyError,因为 'lambda' 不是环境的一部分——就像 lambda 不是 Python 内置函数一样。
Procedure: 实现闭包的类
Procedure 类实际上可以被命名为 Closure,因为它代表的就是一个函数定义和一个环境。函数定义包括参数的名称和构成函数主体的表达式。当函数被调用时,环境用于提供自由变量的值:出现在函数主体中但不是参数、局部变量或全局变量的变量。我们在“闭包”中看到了闭包和自由变量的概念。
我们学会了如何在 Python 中使用闭包,但现在我们可以更深入地了解闭包是如何在 lis.py 中实现的:
class Procedure:
"A user-defined Scheme procedure."
def __init__( # ①
self, parms: list[Symbol], body: list[Expression], env: Environment
):
self.parms = parms # ②
self.body = body
self.env = env
def __call__(self, *args: Expression) -> Any: # ③
local_env = dict(zip(self.parms, args)) # ④
env = Environment(local_env, self.env) # ⑤
for exp in self.body: # ⑥
result = evaluate(exp, env)
return result # ⑦
①
当函数由 lambda 或 define 形式定义时调用。
②
保存参数名称、主体表达式和环境以备后用。
③
在 case [func_exp, *args] 子句的最后一行由 proc(*values) 调用。
④
构建 local_env,将 self.parms 映射为局部变量名称,将给定的 args 映射为值。
⑤
构建一个新的合并 env,将 local_env 放在首位,然后是 self.env—即在函数定义时保存的环境。
⑥
迭代 self.body 中的每个表达式,在合并的 env 中对其进行评估。
⑦
返回最后一个表达式评估的结果。
在lis.py中的 evaluate 后有几个简单的函数:run 读取一个完整的 Scheme 程序并执行它,main 调用 run 或 repl,取决于命令行——类似于 Python 的做法。我不会描述这些函数,因为它们没有什么新内容。我的目标是与你分享 Norvig 的小解释器的美丽之处,更深入地了解闭包的工作原理,并展示 match/case 如何成为 Python 的一个很好的补充。
结束这一关于模式匹配的扩展部分,让我们正式定义一下OR-pattern的概念。
使用 OR-patterns
一系列由 | 分隔的模式是一个OR-pattern:如果任何子模式成功,则它成功。“评估数字”中的模式就是一个 OR-pattern:
case int(x) | float(x):
return x
OR-pattern 中的所有子模式必须使用相同的变量。这种限制是必要的,以确保变量在匹配的子模式中都是可用的,无论匹配的是哪个子模式。
警告
在 case 子句的上下文中,| 运算符有特殊的含义。它不会触发 __or__ 特殊方法,该方法处理其他上下文中的表达式,如 a | b,在这些上下文中,它被重载以执行诸如集合并集或整数按位或等操作,具体取决于操作数。
OR-pattern 不限于出现在模式的顶层。你也可以在子模式中使用 |。例如,如果我们希望 lis.py 接受希腊字母 λ (lambda)¹² 以及 lambda 关键字,我们可以像这样重写模式:
# (λ (a b) (/ (+ a b) 2) )
case ['lambda' | 'λ', [*parms], *body] if body:
return Procedure(parms, body, env)
现在我们可以转向本章的第三个也是最后一个主题:Python 中出现 else 子句的不寻常位置。
这样做,然后那样:else 块超出 if
这并不是什么秘密,但是这是一个被低估的语言特性:else子句不仅可以用在if语句中,还可以用在for、while和try语句中。
for/else、while/else和try/else的语义密切相关,但与if/else非常不同。最初,else这个词实际上阻碍了我对这些特性的理解,但最终我习惯了它。
这里是规则:
for
当for循环完成时,else块将运行一次(即,如果for被break中止,则不会运行)。
while
当while循环退出时,else块只会运行一次,因为条件变为假(即,如果while被break中止,则不会运行)。
try
当try块中没有引发异常时,else块将运行。官方文档还指出:“else子句中的异常不会被前面的except子句处理。”
在所有情况下,如果异常或return、break或continue语句导致控制跳出复合语句的主块,则else子句也会被跳过。
注意
我认为else在除了if之外的所有情况下都是一个非常糟糕的关键字选择。它暗示了一个排除性的替代方案,比如,“运行这个循环,否则做那个”。但是在循环中else的语义是相反的:“运行这个循环,然后做那个”。这表明then是一个更好的关键字选择——这在try的上下文中也是有意义的:“尝试这个,然后做那个”。然而,添加一个新关键字会对语言造成破坏性的改变——这不是一个容易做出的决定。
在这些语句中使用else通常会使代码更易读,避免设置控制标志或编写额外的if语句。
在循环中使用else通常遵循以下代码段的模式:
for item in my_list:
if item.flavor == 'banana':
break
else:
raise ValueError('No banana flavor found!')
在try/except块的情况下,else起初可能看起来是多余的。毕竟,在以下代码段中,只有当dangerous_call()不引发异常时,after_call()才会运行,对吗?
try:
dangerous_call()
after_call()
except OSError:
log('OSError...')
然而,这样做将after_call()放在try块内没有任何好处。为了清晰和正确,try块的主体应该只包含可能引发预期异常的语句。这样更好:
try:
dangerous_call()
except OSError:
log('OSError...')
else:
after_call()
现在清楚了try块是在保护dangerous_call()可能出现的错误,而不是在after_call()中。明确的是,只有在try块中没有引发异常时,after_call()才会执行。
在 Python 中,try/except通常用于控制流,而不仅仅用于错误处理。甚至在官方 Python 术语表中都有一个缩略语/口号来记录这一点:
EAFP
宽恕比许可更容易。这种常见的 Python 编码风格假设存在有效的键或属性,并在假设被证明为假时捕获异常。这种干净且快速的风格的特点是存在许多 try 和 except 语句。这种技术与许多其他语言(如 C)常见的LBYL风格形成对比。
然后,术语表定义了 LBYL:
LBYL
三思而后行。这种编码风格在进行调用或查找之前明确测试前提条件。这种风格与EAFP方法形成对比,其特点是存在许多 if 语句。在多线程环境中,LBYL 方法可能会在“观察”和“跳转”之间引入竞争条件。例如,如果代码
if key in mapping: return mapping[key]在测试后,但查找前,另一个线程从映射中删除了键,那么就会失败。这个问题可以通过使用锁或使用 EAFP 方法来解决。
鉴于 EAFP 风格,了解并善于使用try/except语句中的else块更有意义。
注意
当讨论match语句时,一些人(包括我在内)认为它也应该有一个else子句。最终决定不需要,因为case _:可以完成相同的工作。¹³
现在让我们总结本章。
章节总结
本章从上下文管理器和with语句的含义开始,迅速超越了其常见用法,自动关闭已打开的文件。我们实现了一个自定义上下文管理器:LookingGlass类,具有__enter__/__exit__方法,并看到如何在__exit__方法中处理异常。雷蒙德·赫廷格在他 2013 年 PyCon 美国大会的主题演讲中强调了一个关键点,即with不仅用于资源管理;它是一个用于分解常见设置和拆卸代码,或者需要在另一个过程之前和之后执行的任何一对操作的工具。¹⁴
我们回顾了contextlib标准库模块中的函数。其中之一,@contextmanager装饰器,使得可以使用一个yield的简单生成器实现上下文管理器—这比编写至少两个方法的类更为简洁。我们将LookingGlass重新实现为looking_glass生成器函数,并讨论了在使用@contextmanager时如何处理异常。
然后我们研究了彼得·诺维格优雅的lis.py,这是一个用惯用 Python 编写的 Scheme 解释器,重构为在evaluate中使用match/case—这是任何解释器核心功能的函数。理解evaluate如何工作需要回顾一点 Scheme,一个 S 表达式的解析器,一个简单的 REPL,以及通过collection.ChainMap的Environment子类构建嵌套作用域。最终,lis.py成为一个探索远不止模式匹配的工具。它展示了解释器的不同部分如何协同工作,阐明了 Python 本身的核心特性:为什么保留关键字是必要的,作用域规则如何工作,以及闭包是如何构建和使用的。
进一步阅读
在Python 语言参考的第八章,“复合语句”中,几乎涵盖了关于if、for、while和try语句中else子句的所有内容。关于 Pythonic 使用try/except,无论是否有else,雷蒙德·赫廷格在 StackOverflow 上对问题“在 Python 中使用 try-except-else 是一个好习惯吗?”有一个精彩的回答。Python 速查手册,第 3 版,由马特利等人编写,有一章关于异常,对 EAFP 风格进行了出色的讨论,归功于计算先驱格雷斯·霍珀创造了“宽恕比许可更容易”的短语。
Python 标准库,第四章,“内置类型”,有一个专门介绍“上下文管理器类型”的部分。__enter__/__exit__特殊方法也在Python 语言参考中的“With 语句上下文管理器”中有文档。上下文管理器是在PEP 343—“with”语句中引入的。
雷蒙德·赫廷格在他的PyCon US 2013 主题演讲中将with语句视为“胜利的语言特性”。他还在同一会议上的演讲中展示了一些有趣的上下文管理器应用,标题为“将代码转变为优美、惯用的 Python”。
杰夫·普雷辛的博客文章“Python with语句示例”对使用pycairo图形库的上下文管理器示例很有趣。
contextlib.ExitStack类基于尼古劳斯·拉特的原始想法,他写了一篇简短的文章解释为什么它很有用:“关于 Python 的 ExitStack 之美”。在那篇文章中,拉特认为ExitStack类似于但比 Go 语言中的defer语句更灵活—我认为这是那种语言中最好的想法之一。
Beazley 和 Jones 在他们的Python Cookbook第 3 版中为非常不同的目的设计了上下文管理器。“Recipe 8.3. 使对象支持上下文管理协议”实现了一个LazyConnection类,其实例是上下文管理器,在with块中自动打开和关闭网络连接。“Recipe 9.22. 简单定义上下文管理器”介绍了一个用于计时代码的上下文管理器,以及另一个用于对list对象进行事务性更改的上下文管理器:在with块内,会创建list实例的工作副本,并将所有更改应用于该工作副本。只有当with块在没有异常的情况下完成时,工作副本才会替换原始列表。简单而巧妙。
Peter Norvig 在文章中描述了他的小型 Scheme 解释器,分别是“(如何编写(Lisp)解释器(用 Python))”和“(一个(更好的)Lisp 解释器(用 Python))”。lis.py和lispy.py的代码在norvig/pytudes存储库中。我的存储库fluentpython/lispy包括了lis.py的mylis分支,更新到 Python 3.10,具有更好的 REPL、命令行集成、示例、更多测试和学习 Scheme 的参考资料。学习和实验的最佳 Scheme 方言和环境是Racket。
¹ PyCon US 2013 主题演讲:“Python 的卓越之处”;关于with的部分从 23:00 开始,到 26:15 结束。
² self接收的三个参数正是在try/finally语句的finally块中调用sys.exc_info()时得到的内容。这是有道理的,考虑到with语句旨在取代大多数try/finally的用法,而调用sys.exc_info()通常是必要的,以确定需要什么清理操作。
³ 实际的类名是_GeneratorContextManager。如果你想确切地了解这是如何工作的,请阅读 Python 3.10 中Lib/contextlib.py中的源代码。
⁴ 这个提示是从本书的技术审阅员之一 Leonardo Rochael 的评论中直接引用的。说得好,Leo!
⁵ 至少我和其他技术审阅员在 Caleb Hattingh 告诉我们之前并不知道这一点。谢谢,Caleb!
⁶ 人们抱怨 Lisp 中有太多括号,但仔细的缩进和一个好的编辑器大多可以解决这个问题。主要的可读性问题是使用相同的(f …)符号来表示函数调用和像(define …)、(if …)和(quote …)这样根本不像函数调用的特殊形式。
⁷ 为了使递归迭代实用和高效,Scheme 和其他函数式语言实现了proper tail calls。关于这一点的更多信息,请参见“Soapbox”。
⁸ 但 Norvig 的第二个解释器,lispy.py,支持字符串作为数据类型,以及高级功能,如语法宏、延续和正确的尾调用。然而,lispy.py几乎比lis.py长三倍,而且更难理解。
⁹ # type: ignore[index]注释是因为typeshed问题#6042,在我审阅本章时尚未解决。ChainMap被注释为MutableMapping,但maps属性的类型提示表示它是Mapping列表,间接地使整个ChainMap在 Mypy 看来是不可变的。
¹⁰ 当我研究 Norvig 的 lis.py 和 lispy.py 时,我开始了一个名为 mylis 的分支,添加了一些功能,包括一个 REPL,可以接受部分 S-表达式,并提示继续,类似于 Python 的 REPL 知道我们还没有完成并呈现次要提示符(...),直到我们输入一个可以评估的完整表达式或语句。mylis 也可以优雅地处理一些错误,但仍然很容易崩溃。它远没有 Python 的 REPL 那么健壮。
¹¹ 赋值是许多编程教程中教授的第一个特性之一,但 set! 只出现在最知名的 Scheme 书籍计算机程序的构造与解释,第 2 版, 由 Abelson 等人(麻省理工学院出版社)编写,也称为 SICP 或“巫师书”。以函数式风格编码可以让我们在没有典型的命令式和面向对象编程中的状态更改的情况下走得更远。
¹² λ(U+03BB)的官方 Unicode 名称是 GREEK SMALL LETTER LAMDA。这不是拼写错误:Unicode 数据库中该字符的名称是“lamda”,没有“b”。根据英文维基百科文章“Lambda”,Unicode 联盟采用了这种拼写,是因为“希腊国家机构表达的偏好”。
¹³ 在 python-dev 邮件列表中观察讨论时,我认为 else 被拒绝的一个原因是在 match 中如何缩进它缺乏共识:else 应该与 match 缩进在同一级别,还是与 case 缩进在同一级别?
¹⁴ 请参见“Python is Awesome”中的第 21 页幻灯片。
第十九章:并发模型在 Python 中
并发是关于同时处理许多事情。
并行是同时做许多事情的概念。
不同,但相关。
一个是关于结构,一个是关于执行。
并发提供了一种结构解决问题的方法,该问题可能(但不一定)是可并行化的。
Rob Pike,Go 语言的共同发明人¹
本章是关于如何让 Python 处理“许多事情同时发生”。这可能涉及并发或并行编程,即使对术语敏感的学者们对如何使用这些术语存在分歧。在本章的引言中,我将采用 Rob Pike 的非正式定义,但请注意,我发现有些论文和书籍声称是关于并行计算,但实际上主要是关于并发。²
在 Pike 看来,并行是并发的特例。所有并行系统都是并发的,但并非所有并发系统都是并行的。在 2000 年代初,我们使用单核机器在 GNU Linux 上同时处理 100 个进程。现代笔记本电脑具有 4 个 CPU 核心,在正常的日常使用中通常会同时运行 200 多个进程。要并行执行 200 个任务,您需要 200 个核心。因此,在实践中,大多数计算是并发的而不是并行的。操作系统管理数百个进程,确保每个进程都有机会取得进展,即使 CPU 本身一次只能做四件事。
本章假设您没有并发或并行编程的先前知识。在简要的概念介绍之后,我们将研究简单示例,介绍并比较 Python 的核心包用于并发编程:threading,multiprocessing和asyncio。
本章的最后 30%是对第三方工具,库,应用服务器和分布式任务队列的高级概述,所有这些都可以增强 Python 应用程序的性能和可伸缩性。这些都是重要的主题,但超出了专注于核心 Python 语言特性的书籍的范围。尽管如此,我认为在《流畅的 Python》第二版中解决这些主题很重要,因为 Python 在并发和并行计算方面的适用性不仅限于标准库提供的内容。这就是为什么 YouTube,DropBox,Instagram,Reddit 等在开始时能够实现 Web 规模,使用 Python 作为他们的主要语言,尽管一直有人声称“Python 不具备扩展性”。
本章新内容
本章是《流畅的 Python》第二版中的新内容。“一个并发的 Hello World”中的旋转示例以前在关于asyncio的章节中。在这里它们得到改进,并提供 Python 处理并发的三种方法的第一个示例:线程,进程和本机协程。
剩下的内容是新的,除了一些最初出现在concurrent.futures和asyncio章节中的段落。
“Python 在多核世界中”与本书其他部分不同:没有代码示例。目标是提及重要工具,您可能希望学习以实现高性能并发和并行,超越 Python 标准库所能实现的范围。
大局观
有许多因素使并发编程变得困难,但我想谈谈最基本的因素:启动线程或进程很容易,但如何跟踪它们呢?³
当您调用一个函数时,调用代码会被阻塞,直到函数返回。因此,您知道函数何时完成,并且可以轻松获取其返回的值。如果函数引发异常,调用代码可以在调用点周围使用try/except来捕获错误。
当你启动一个线程或进程时,这些熟悉的选项不可用:你不会自动知道它何时完成,获取结果或错误需要设置一些通信渠道,比如消息队列。
此外,启动线程或进程并不廉价,因此你不希望启动其中一个只是为了执行一个计算然后退出。通常情况下,你希望通过将每个线程或进程变成一个“工作者”,进入一个循环并等待输入来分摊启动成本。这进一步复杂了通信,并引入了更多问题。当你不再需要一个工作者时,如何让它退出?如何让它退出而不中断正在进行的工作,留下半成品数据和未释放的资源—比如打开的文件?再次,通常的答案涉及消息和队列。
协程很容易启动。如果你使用await关键字启动一个协程,很容易获得它返回的值,它可以被安全地取消,并且你有一个明确的地方来捕获异常。但协程通常由异步框架启动,这使得它们像线程或进程一样难以监控。
最后,Python 协程和线程不适合 CPU 密集型任务,我们将会看到。
这就是为什么并发编程需要学习新的概念和编码模式。让我们首先确保我们对一些核心概念有共识。
一点行话
以下是我将在本章和接下来的两章中使用的一些术语:
并发性
能够处理多个待处理任务,逐个或并行(如果可能)地取得进展,以便每个任务最终成功或失败。如果单核 CPU 运行一个交错执行待处理任务的 OS 调度程序,那么它就具备并发能力。也被称为多任务处理。
并行性
能够同时执行多个计算的能力。这需要一个多核 CPU、多个 CPU、一个GPU,或者一个集群中的多台计算机。
执行单元
执行代码并发的通用术语,每个都有独立的状态和调用堆栈。Python 本地支持三种执行单元:进程、线程 和 协程。
进程
计算机程序在运行时的一个实例,使用内存和 CPU 时间片。现代桌面操作系统通常同时管理数百个进程,每个进程都在自己的私有内存空间中隔离。进程通过管道、套接字或内存映射文件进行通信,所有这些通信方式只能传递原始字节。Python 对象必须被序列化(转换)为原始字节才能从一个进程传递到另一个进程。这是昂贵的,而且并非所有的 Python 对象都是可序列化的。一个进程可以生成子进程,每个子进程称为一个子进程。它们也彼此隔离,也与父进程隔离。进程允许抢占式多任务处理:操作系统调度程序抢占—即暂停—每个运行的进程,以允许其他进程运行。这意味着一个冻结的进程不能冻结整个系统—理论上。
线程
单个进程内的执行单元。当一个进程启动时,它使用一个线程:主线程。一个进程可以通过调用操作系统 API 创建更多线程以并发操作。进程内的线程共享相同的内存空间,其中保存着活跃的 Python 对象。这允许线程之间轻松共享数据,但也可能导致数据损坏,当多个线程同时更新同一对象时。与进程一样,线程也在操作系统调度程序的监督下实现抢占式多任务处理。一个线程消耗的资源比执行相同工作的进程少。
协程
一个可以暂停自身并稍后恢复的函数。在 Python 中,经典协程是由生成器函数构建的,而原生协程则是用async def定义的。“经典协程”介绍了这个概念,而第二十一章涵盖了原生协程的使用。Python 协程通常在同一个线程中在事件循环的监督下运行,也在同一个线程中。异步编程框架如asyncio、Curio或Trio提供了事件循环和支持非阻塞、基于协程的 I/O 的支持库。协程支持协作式多任务:每个协程必须使用yield或await关键字明确放弃控制,以便另一个可以同时进行(但不是并行)。这意味着协程中的任何阻塞代码都会阻止事件循环和所有其他协程的执行,与进程和线程支持的抢占式多任务相反。另一方面,每个协程消耗的资源比执行相同工作的线程或进程少。
队列
一个数据结构,让我们以 FIFO 顺序(先进先出)放置和获取项目。队列允许独立的执行单元交换应用程序数据和控制消息,如错误代码和终止信号。队列的实现根据底层并发模型而变化:Python 标准库中的queue包提供了支持线程的队列类,而multiprocessing和asyncio包实现了自己的队列类。queue和asyncio包还包括不是 FIFO 的队列:LifoQueue和PriorityQueue。
锁
一个执行单元可以使用的对象,用于同步它们的操作并避免破坏数据。在更新共享数据结构时,运行的代码应持有相关锁。这会通知程序的其他部分等待,直到锁被释放才能访问相同的数据结构。最简单类型的锁也被称为互斥锁(用于互斥排除)。锁的实现取决于底层并发模型。
争用
争用有限资源。当多个执行单元尝试访问共享资源(如锁或存储)时,资源争用就会发生。还有 CPU 争用,当计算密集型进程或线程必须等待操作系统调度程序为它们分配 CPU 时间时。
现在让我们使用一些行话来理解 Python 中的并发支持。
进程、线程和 Python 臭名昭著的 GIL
这就是我们刚刚看到的概念如何应用于 Python 编程的 10 个要点:
-
Python 解释器的每个实例都是一个进程。您可以使用multiprocessing或concurrent.futures库启动额外的 Python 进程。Python 的subprocess库旨在启动进程来运行外部程序,无论使用何种语言编写。
-
Python 解释器使用单个线程来运行用户程序和内存垃圾收集器。您可以使用threading或concurrent.futures库启动额外的 Python 线程。
-
对象引用计数和其他内部解释器状态的访问受到一个锁的控制,全局解释器锁(GIL)。在任何时候只有一个 Python 线程可以持有 GIL。这意味着无论 CPU 核心数量如何,只有一个线程可以同时执行 Python 代码。
-
为了防止 Python 线程无限期地持有 GIL,Python 的字节码解释器默认每 5 毫秒暂停当前 Python 线程,释放 GIL。然后线程可以尝试重新获取 GIL,但如果有其他线程在等待,操作系统调度程序可能会选择其中一个继续进行。
-
当我们编写 Python 代码时,我们无法控制 GIL。但是一个内置函数或用 C 编写的扩展——或者任何与 Python/C API 级别进行接口的语言——可以在运行耗时任务时释放 GIL。
-
每个调用系统调用的 Python 标准库函数都会释放 GIL。这包括所有执行磁盘 I/O、网络 I/O 和
time.sleep()的函数。NumPy/SciPy 库中的许多 CPU 密集型函数,以及zlib和bz2模块中的压缩/解压缩函数也会释放 GIL。 -
在 Python/C API 级别集成的扩展还可以启动其他不受 GIL 影响的非 Python 线程。这些无 GIL 的线程通常不能更改 Python 对象,但它们可以读取和写入支持缓冲区协议的对象的底层内存,如
bytearray、array.array和NumPy数组。 -
GIL 对使用 Python 线程进行网络编程的影响相对较小,因为 I/O 函数会释放 GIL,并且与读写内存相比,读写网络总是意味着高延迟。因此,每个单独的线程都会花费大量时间在等待上,因此它们的执行可以交错进行,而对整体吞吐量的影响不大。这就是为什么 David Beazley 说:“Python 线程非常擅长无所事事。”
-
GIL 争用会减慢计算密集型 Python 线程的速度。对于这种任务,顺序、单线程的代码更简单、更快。
-
要在多个核心上运行 CPU 密集型的 Python 代码,必须使用多个 Python 进程。
这里有来自threading模块文档的一个很好的总结:
CPython 实现细节:在 CPython 中,由于全局解释器锁,只有一个线程可以同时执行 Python 代码(尽管某些性能导向的库可能克服这一限制)。如果你希望应用程序更好地利用多核机器的计算资源,建议使用
multiprocessing或concurrent.futures.ProcessPoolExecutor。然而,对于同时运行多个 I/O 密集型任务,线程仍然是一个合适的模型。
前一段以“CPython 实现细节”开头,因为 GIL 不是 Python 语言定义的一部分。Jython 和 IronPython 实现没有 GIL。不幸的是,它们都落后了——仍在追踪 Python 2.7。高性能的PyPy 解释器在其 2.7 和 3.7 版本中也有 GIL——截至 2021 年 6 月的最新版本。
注意
这一节没有提到协程,因为默认情况下它们在彼此之间共享同一个 Python 线程,并与异步框架提供的监督事件循环共享,因此 GIL 不会影响它们。在异步程序中可以使用多个线程,但最佳实践是一个线程运行事件循环和所有协程,而其他线程执行特定任务。这将在“委托任务给执行器”中解释。
现在已经足够的概念了。让我们看一些代码。
一个并发的 Hello World
在关于线程和如何避免 GIL 的讨论中,Python 贡献者 Michele Simionato发布了一个示例,类似于并发的“Hello World”:展示 Python 如何“边走边嚼”的最简单程序。
Simionato 的程序使用了multiprocessing,但我对其进行了调整,引入了threading和asyncio。让我们从threading版本开始,如果你学过 Java 或 C 中的线程,这可能看起来很熟悉。
使用线程的旋转器
接下来几个示例的想法很简单:启动一个函数,在终端中以动画方式显示字符,同时阻塞 3 秒钟,让用户知道程序在“思考”,而不是停滞不前。
该脚本制作了一个动画旋转器,以相同的屏幕位置显示字符串"\|/-"中的每个字符。当慢速计算完成时,旋转器所在行将被清除,并显示结果:Answer: 42。
图 19-1 显示了旋转示例的两个版本的输出:首先是使用线程,然后是使用协程。如果你离开电脑,想象最后一行的\在旋转。

图 19-1. 脚本 spinner_thread.py 和 spinner_async.py 产生类似的输出:一个旋转器对象的 repr 和文本“Answer: 42”。在截图中,spinner_async.py 仍在运行,并显示动画消息“/ thinking!”;3 秒后,该行将被替换为“Answer: 42”。
让我们首先回顾spinner_thread.py脚本。示例 19-1 列出了脚本中的前两个函数,示例 19-2 显示了其余部分。
示例 19-1. spinner_thread.py:spin和slow函数
import itertools
import time
from threading import Thread, Event
def spin(msg: str, done: Event) -> None: # ①
for char in itertools.cycle(r'\|/-'): # ②
status = f'\r{char} {msg}' # ③
print(status, end='', flush=True)
if done.wait(.1): # ④
break # ⑤
blanks = ' ' * len(status)
print(f'\r{blanks}\r', end='') # ⑥
def slow() -> int:
time.sleep(3) # ⑦
return 42
①
这个函数将在一个单独的线程中运行。done参数是threading.Event的一个实例,用于同步线程。
②
这是一个无限循环,因为itertools.cycle每次产生一个字符,永远循环遍历字符串。
③
文本模式动画的技巧:使用回车 ASCII 控制字符('\r')将光标移回行的开头。
④
Event.wait(timeout=None)方法在另一个线程设置事件时返回True;如果超时时间到期,则返回False。0.1 秒的超时设置了动画的“帧率”为 10 FPS。如果希望旋转器旋转得更快,可以使用较小的超时时间。
⑤
退出无限循环。
⑥
通过用空格覆盖并将光标移回开头来清除状态行。
⑦
slow()将被主线程调用。想象这是一个在网络上慢速调用 API。调用sleep会阻塞主线程,但 GIL 会被释放,因此旋转器线程可以继续执行。
提示
这个示例的第一个重要见解是time.sleep()会阻塞调用线程,但会释放 GIL,允许其他 Python 线程运行。
spin和slow函数将并发执行。主线程——程序启动时唯一的线程——将启动一个新线程来运行spin,然后调用slow。按设计,Python 中没有终止线程的 API。你必须发送消息来关闭它。
threading.Event类是 Python 中最简单的线程协调机制。Event实例有一个内部布尔标志,起始值为False。调用Event.set()将标志设置为True。当标志为 false 时,如果一个线程调用Event.wait(),它将被阻塞,直到另一个线程调用Event.set(),此时Event.wait()返回True。如果给Event.wait(s)传递了秒数的超时时间,当超时时间到期时,此调用将返回False,或者在另一个线程调用Event.set()时立即返回True。
在示例 19-2 中列出的supervisor函数使用Event来向spin函数发出退出信号。
示例 19-2. spinner_thread.py:supervisor和main函数
def supervisor() -> int: # ①
done = Event() # ②
spinner = Thread(target=spin, args=('thinking!', done)) # ③
print(f'spinner object: {spinner}') # ④
spinner.start() # ⑤
result = slow() # ⑥
done.set() # ⑦
spinner.join() # ⑧
return result
def main() -> None:
result = supervisor() # ⑨
print(f'Answer: {result}')
if __name__ == '__main__':
main()
①
supervisor 将返回 slow 的结果。
②
threading.Event 实例是协调 main 线程和 spinner 线程活动的关键,如下所述。
③
要创建一个新的 Thread,请将函数作为 target 关键字参数,并通过 args 传递的元组提供给 target 作为位置参数。
④
显示 spinner 对象。输出为 <Thread(Thread-1, initial)>,其中 initial 是线程的状态,表示它尚未启动。
⑤
启动 spinner 线程。
⑥
调用 slow,会阻塞 main 线程。与此同时,辅助线程正在运行旋转动画。
⑦
将 Event 标志设置为 True;这将终止 spin 函数内的 for 循环。
⑧
等待 spinner 线程完成。
⑨
运行 supervisor 函数。我编写了单独的 main 和 supervisor 函数,使得这个示例看起来更像示例 19-4 中的 asyncio 版本。
当 main 线程设置 done 事件时,spinner 线程最终会注意到并干净地退出。
现在让我们看一个类似的例子,使用 multiprocessing 包。
使用进程的旋转器
multiprocessing 包支持在单独的 Python 进程中运行并发任务,而不是线程。当你创建一个 multiprocessing.Process 实例时,一个全新的 Python 解释器会作为后台的子进程启动。由于每个 Python 进程都有自己的 GIL,这使得你的程序可以使用所有可用的 CPU 核心,但最终取决于操作系统调度程序。我们将在“自制进程池”中看到实际效果,但对于这个简单的程序来说,这并没有真正的区别。
本节的重点是介绍 multiprocessing 并展示其 API 模仿了 threading API,使得将简单程序从线程转换为进程变得容易,如 spinner_proc.py 中所示(示例 19-3)。
示例 19-3. spinner_proc.py:只显示更改的部分;其他所有内容与 spinner_thread.py 相同
import itertools
import time
from multiprocessing import Process, Event # ①
from multiprocessing import synchronize # ②
def spin(msg: str, done: synchronize.Event) -> None: # ③
# [snip] the rest of spin and slow functions are unchanged from spinner_thread.py
def supervisor() -> int:
done = Event()
spinner = Process(target=spin, # ④
args=('thinking!', done))
print(f'spinner object: {spinner}') # ⑤
spinner.start()
result = slow()
done.set()
spinner.join()
return result
# [snip] main function is unchanged as well
①
基本的 multiprocessing API 模仿了 threading API,但类型提示和 Mypy 暴露了这种差异:multiprocessing.Event 是一个函数(不像 threading.Event 那样是一个类),它返回一个 synchronize.Event 实例…
②
…迫使我们导入 multiprocessing.synchronize…
③
…来写这个类型提示。
④
Process 类的基本用法类似于 Thread。
⑤
spinner 对象显示为 <Process name='Process-1' parent=14868 initial>,其中 14868 是运行 spinner_proc.py 的 Python 实例的进程 ID。
threading 和 multiprocessing 的基本 API 相似,但它们的实现非常不同,而 multiprocessing 有一个更大的 API 来处理多进程编程的复杂性。例如,从线程转换为进程时的一个挑战是如何在被操作系统隔离且无法共享 Python 对象的进程之间进行通信。这意味着跨进程边界的对象必须进行序列化和反序列化,这会产生额外的开销。在 Example 19-3 中,跨进程边界的唯一数据是 Event 状态,它是在支持 multiprocessing 模块的 C 代码中实现的低级操作系统信号量。¹⁰
提示
自 Python 3.8 起,标准库中有一个 multiprocessing.shared_memory 包,但它不支持用户定义类的实例。除了原始字节外,该包允许进程共享 ShareableList,这是一个可变序列类型,可以容纳固定数量的 int、float、bool 和 None 类型的项目,以及每个项目最多 10 MB 的 str 和 bytes。请查看 ShareableList 文档以获取更多信息。
现在让我们看看如何使用协程而不是线程或进程来实现相同的行为。
使用协程的旋转器
注意
Chapter 21 完全致力于使用协程进行异步编程。这只是一个高层介绍,用来对比线程和进程并发模型的方法。因此,我们将忽略许多细节。
操作系统调度程序的工作是分配 CPU 时间来驱动线程和进程。相比之下,协程由应用级事件循环驱动,该事件循环管理一个挂起协程的队列,逐个驱动它们,监视由协程发起的 I/O 操作触发的事件,并在每次事件发生时将控制权传递回相应的协程。事件循环和库协程以及用户协程都在单个线程中执行。因此,在协程中花费的任何时间都会减慢事件循环和所有其他协程。
如果我们从 main 函数开始,然后研究 supervisor,那么协程版本的旋转器程序会更容易理解。这就是 Example 19-4 所展示的内容。
Example 19-4. spinner_async.py:main 函数和 supervisor 协程
def main() -> None: # ①
result = asyncio.run(supervisor()) # ②
print(f'Answer: {result}')
async def supervisor() -> int: # ③
spinner = asyncio.create_task(spin('thinking!')) # ④
print(f'spinner object: {spinner}') # ⑤
result = await slow() # ⑥
spinner.cancel() # ⑦
return result
if __name__ == '__main__':
main()
①
main 是此程序中唯一定义的常规函数,其他都是协程。
②
asyncio.run 函数启动事件循环,驱动最终会启动其他协程的协程。main 函数将保持阻塞,直到 supervisor 返回。supervisor 的返回值将是 asyncio.run 的返回值。
③
本机协程使用 async def 定义。
④
asyncio.create_task 调度了 spin 的最终执行,立即返回一个 asyncio.Task 实例。
⑤
spinner 对象的 repr 看起来像 <Task pending name='Task-2' coro=<spin() running at /path/to/spinner_async.py:11>>。
⑥
await 关键字调用 slow,阻塞 supervisor 直到 slow 返回。slow 的返回值将被赋给 result。
⑦
Task.cancel 方法在 spin 协程内部引发 CancelledError 异常,我们将在 Example 19-5 中看到。
Example 19-4 展示了运行协程的三种主要方式:
asyncio.run(coro())
从常规函数中调用以驱动通常是程序中所有异步代码的入口点的协程对象,就像本示例中的supervisor一样。此调用会阻塞,直到coro的主体返回。run()调用的返回值是coro的主体返回的任何内容。
asyncio.create_task(coro())
从协程中调用以安排另一个协程最终执行。此调用不会挂起当前协程。它返回一个Task实例,一个包装协程对象并提供控制和查询其状态的方法的对象。
await coro()
从协程中调用以将控制传递给coro()返回的协程对象。这将挂起当前协程,直到coro的主体返回。await表达式的值是coro的主体返回的任何内容。
注意
记住:将协程作为coro()调用会立即返回一个协程对象,但不会运行coro函数的主体。驱动协程主体的工作是事件循环的工作。
现在让我们研究示例 19-5 中的spin和slow协程。
示例 19-5. spinner_async.py:spin和slow协程
import asyncio
import itertools
async def spin(msg: str) -> None: # ①
for char in itertools.cycle(r'\|/-'):
status = f'\r{char} {msg}'
print(status, flush=True, end='')
try:
await asyncio.sleep(.1) # ②
except asyncio.CancelledError: # ③
break
blanks = ' ' * len(status)
print(f'\r{blanks}\r', end='')
async def slow() -> int:
await asyncio.sleep(3) # ④
return 42
①
我们不需要在spinner_thread.py中使用的Event参数,该参数用于表示slow已完成其工作(示例 19-1)。
②
使用await asyncio.sleep(.1)代替time.sleep(.1),以暂停而不阻塞其他协程。查看此示例之后的实验。
③
当在控制此协程的Task上调用cancel方法时,会引发asyncio.CancelledError。是时候退出循环了。
④
slow协程也使用await asyncio.sleep而不是time.sleep。
实验:打破旋转器以获得洞察
这是我推荐的一个实验,以了解spinner_async.py的工作原理。导入time模块,然后转到slow协程,并将await asyncio.sleep(3)替换为调用time.sleep(3),就像在示例 19-6 中一样。
示例 19-6. spinner_async.py:将await asyncio.sleep(3)替换为time.sleep(3)
async def slow() -> int:
time.sleep(3)
return 42
观察行为比阅读有关它的内容更容易记忆。继续,我会等待。
当您运行实验时,您会看到以下内容:
-
显示了类似于这样的旋转器对象:
<Task pending name='Task-2' coro=<spin() running at /path/to/spinner_async.py:12>>。 -
旋转器永远不会出现。程序会在 3 秒钟内挂起。
-
显示
Answer: 42,然后程序结束。
要理解发生了什么,请记住,使用asyncio的 Python 代码只有一个执行流程,除非您明确启动了额外的线程或进程。这意味着在任何时候只有一个协程在执行。并发是通过控制从一个协程传递到另一个协程来实现的。在示例 19-7 中,让我们关注在拟议实验期间supervisor和slow协程中发生了什么。
示例 19-7. spinner_async_experiment.py:supervisor和slow协程
async def slow() -> int:
time.sleep(3) # ④
return 42
async def supervisor() -> int:
spinner = asyncio.create_task(spin('thinking!')) # ①
print(f'spinner object: {spinner}') # ②
result = await slow() # ③
spinner.cancel() # ⑤
return result
①
创建了spinner任务,最终驱动spin的执行。
②
显示Task为“挂起”状态。
③
await表达式将控制传递给slow协程。
④
time.sleep(3)会阻塞 3 秒钟;程序中不会发生任何其他事情,因为主线程被阻塞了,而且它是唯一的线程。操作系统将继续进行其他活动。3 秒后,sleep解除阻塞,slow返回。
⑤
在slow返回后,spinner任务被取消。控制流从未到达spin协程的主体。
spinner_async_experiment.py教导了一个重要的教训,如下警告所述。
警告
除非你想暂停整个程序,否则不要在asyncio协程中使用time.sleep(…)。如果一个协程需要花一些时间什么都不做,它应该await asyncio.sleep(DELAY)。这会将控制权交还给asyncio事件循环,它可以驱动其他待处理的协程。
并排的监督者
spinner_thread.py和spinner_async.py的行数几乎相同。supervisor函数是这些示例的核心。让我们详细比较一下。示例 19-8 仅列出了示例 19-2 中的supervisor。
示例 19-8. spinner_thread.py:线程化的supervisor函数
def supervisor() -> int:
done = Event()
spinner = Thread(target=spin,
args=('thinking!', done))
print('spinner object:', spinner)
spinner.start()
result = slow()
done.set()
spinner.join()
return result
作为比较,示例 19-9 展示了示例 19-4 中的supervisor协程。
示例 19-9. spinner_async.py:异步的supervisor协程
async def supervisor() -> int:
spinner = asyncio.create_task(spin('thinking!'))
print('spinner object:', spinner)
result = await slow()
spinner.cancel()
return result
这里是需要注意的两个supervisor实现之间的差异和相似之处的摘要:
-
一个
asyncio.Task大致相当于一个threading.Thread。 -
一个
Task驱动一个协程对象,而一个Thread调用一个可调用对象。 -
一个协程使用
await关键字显式地让出控制。 -
你不需要自己实例化
Task对象,你通过将协程传递给asyncio.create_task(…)来获取它们。 -
当
asyncio.create_task(…)返回一个Task对象时,它已经被安排运行,但必须显式调用start方法来告诉Thread实例运行。 -
在线程化的
supervisor中,slow是一个普通函数,由主线程直接调用。在异步的supervisor中,slow是一个由await驱动的协程。 -
没有 API 可以从外部终止一个线程;相反,你必须发送一个信号,比如设置
doneEvent对象。对于任务,有Task.cancel()实例方法,它会在当前挂起协程体中的await表达式处引发CancelledError。 -
supervisor协程必须在main函数中使用asyncio.run启动。
这个比较应该帮助你理解asyncio如何编排并发作业,与使用Threading模块的方式相比,后者可能更为熟悉。
关于线程与协程的最后一点:如果你使用线程进行了一些非平凡的编程,你会知道由于调度程序可以随时中断线程,因此理解程序是多么具有挑战性。你必须记住持有锁以保护程序的关键部分,以避免在多步操作的中途被中断,这可能会导致数据处于无效状态。
使用协程,你的代码默认受到保护,不会被中断。你必须显式await来让程序的其余部分运行。与持有锁以同步多个线程的操作相反,协程是“同步”的定义:任何时候只有一个协程在运行。当你想放弃控制时,你使用await将控制权交还给调度程序。这就是为什么可以安全地取消一个协程:根据定义,只有在协程被挂起在await表达式时才能取消协程,因此你可以通过处理CancelledError异常来执行清理。
time.sleep()调用会阻塞但不执行任何操作。现在我们将尝试使用一个 CPU 密集型调用来更好地理解 GIL,以及 CPU 密集型函数在异步代码中的影响。
GIL 的真正影响
在线程代码(示例 19-1)中,你可以用你喜欢的库中的 HTTP 客户端请求替换slow函数中的time.sleep(3)调用,旋转动画将继续旋转。这是因为设计良好的网络库在等待网络时会释放 GIL。
你还可以将slow协程中的asyncio.sleep(3)表达式替换为等待来自设计良好的异步网络库的响应的await,因为这些库提供的协程在等待网络时会将控制权交还给事件循环。与此同时,旋转动画将继续旋转。
对于 CPU 密集型代码,情况就不同了。考虑示例 19-10 中的is_prime函数,如果参数是质数则返回True,否则返回False。
示例 19-10. primes.py:一个易于阅读的素数检查,来自 Python 的ProcessPoolExecutor示例
def is_prime(n: int) -> bool:
if n < 2:
return False
if n == 2:
return True
if n % 2 == 0:
return False
root = math.isqrt(n)
for i in range(3, root + 1, 2):
if n % i == 0:
return False
return True
在我现在使用的公司笔记本电脑上,调用is_prime(5_000_111_000_222_021)大约需要 3.3 秒。¹²
快速测验
鉴于我们迄今所见,请花时间考虑以下三部分问题。答案的一部分有点棘手(至少对我来说是这样)。
如果你对旋转动画进行以下更改,假设
n = 5_000_111_000_222_021——这个让我的机器花费 3.3 秒来验证的质数,那么旋转动画会发生什么变化呢?
- 在spinner_proc.py中,用调用
is_prime(n)替换time.sleep(3)?- 在spinner_thread.py中,用调用
is_prime(n)替换time.sleep(3)?- 在spinner_async.py中,用调用
is_prime(n)替换await asyncio.sleep(3)?
在运行代码或继续阅读之前,我建议你自己想出答案。然后,你可能想按照建议复制和修改spinner_.py*示例。
现在是答案,从简单到困难。
1. 多进程的答案
旋转动画由一个子进程控制,因此在父进程计算素数测试时它会继续旋转。¹³
2. 线程的答案
旋转动画由一个辅助线程控制,因此在主线程计算素数测试时它会继续旋转。
起初我没有得到这个答案:我预期旋转动画会冻结,因为我高估了 GIL 的影响。
在这个特定示例中,旋转动画会继续旋转,因为 Python 默认每 5ms 挂起运行线程,使 GIL 可供其他挂起线程使用。因此,运行is_prime的主线程每 5ms 被中断一次,允许辅助线程唤醒并迭代一次for循环,直到调用done事件的wait方法,此时它将释放 GIL。然后主线程将获取 GIL,并且is_prime计算将继续进行 5ms。
这对这个特定示例的运行时间没有明显影响,因为spin函数快速迭代一次并在等待done事件时释放 GIL,因此对 GIL 的争夺不多。运行is_prime的主线程大部分时间都会持有 GIL。
在这个简单的实验中,我们使用线程来处理计算密集型任务,因为只有两个线程:一个占用 CPU,另一个每秒只唤醒 10 次以更新旋转动画。
但是,如果有两个或更多线程争夺大量 CPU 时间,你的程序将比顺序代码慢。
3. asyncio 的答案
如果在 spinner_async.py 示例的 slow 协程中调用 is_prime(5_000_111_000_222_021),那么旋转器将永远不会出现。效果与我们在 示例 19-6 中替换 await asyncio.sleep(3) 为 time.sleep(3) 时相同:根本没有旋转。控制流将从 supervisor 传递到 slow,然后到 is_prime。当 is_prime 返回时,slow 也返回,supervisor 恢复,甚至在执行一次旋转器任务之前取消 spinner 任务。程序会在约 3 秒钟内冻结,然后显示答案。
到目前为止,我们只尝试了对一个 CPU 密集型函数的单次调用。下一部分将展示多个 CPU 密集型调用的并发执行。
自制进程池
注意
我编写这一部分是为了展示多进程用于 CPU 密集型任务的使用,以及使用队列分发任务和收集结果的常见模式。第二十章 将展示一种更简单的方式将任务分发给进程:concurrent.futures 包中的 ProcessPoolExecutor,它在内部使用队列。
在本节中,我们将编写程序来计算 20 个整数样本的素性,范围从 2 到 9,999,999,999,999,999—即 10¹⁶ – 1,或超过 2⁵³。样本包括小型和大型素数,以及具有小型和大型素数因子的合数。
sequential.py 程序提供了性能基准。以下是一个示例运行:
$ python3 sequential.py
2 P 0.000001s
142702110479723 P 0.568328s
299593572317531 P 0.796773s
3333333333333301 P 2.648625s
3333333333333333 0.000007s
3333335652092209 2.672323s
4444444444444423 P 3.052667s
4444444444444444 0.000001s
4444444488888889 3.061083s
5555553133149889 3.451833s
5555555555555503 P 3.556867s
5555555555555555 0.000007s
6666666666666666 0.000001s
6666666666666719 P 3.781064s
6666667141414921 3.778166s
7777777536340681 4.120069s
7777777777777753 P 4.141530s
7777777777777777 0.000007s
9999999999999917 P 4.678164s
9999999999999999 0.000007s
Total time: 40.31
结果显示在三列中:
-
要检查的数字。
-
如果是素数,则为
P,否则为空。 -
检查该特定数字的素性所花费的经过时间。
在本示例中,总时间大约等于每个检查的时间之和,但是它是单独计算的,正如您在 示例 19-12 中所看到的。
示例 19-12. sequential.py:小数据集的顺序素性检查
#!/usr/bin/env python3
"""
sequential.py: baseline for comparing sequential, multiprocessing,
and threading code for CPU-intensive work.
"""
from time import perf_counter
from typing import NamedTuple
from primes import is_prime, NUMBERS
class Result(NamedTuple): # ①
prime: bool
elapsed: float
def check(n: int) -> Result: # ②
t0 = perf_counter()
prime = is_prime(n)
return Result(prime, perf_counter() - t0)
def main() -> None:
print(f'Checking {len(NUMBERS)} numbers sequentially:')
t0 = perf_counter()
for n in NUMBERS: # ③
prime, elapsed = check(n)
label = 'P' if prime else ' '
print(f'{n:16} {label} {elapsed:9.6f}s')
elapsed = perf_counter() - t0 # ④
print(f'Total time: {elapsed:.2f}s')
if __name__ == '__main__':
main()
①
check 函数(在下一个 callout 中)返回一个带有 is_prime 调用的布尔值和经过时间的 Result 元组。
②
check(n) 调用 is_prime(n) 并计算经过的时间以返回一个 Result。
③
对于样本中的每个数字,我们调用 check 并显示结果。
④
计算并显示总经过时间。
基于进程的解决方案
下一个示例 procs.py 展示了使用多个进程将素性检查分布到多个 CPU 核心上。这是我用 procs.py 得到的时间:
$ python3 procs.py
Checking 20 numbers with 12 processes:
2 P 0.000002s
3333333333333333 0.000021s
4444444444444444 0.000002s
5555555555555555 0.000018s
6666666666666666 0.000002s
142702110479723 P 1.350982s
7777777777777777 0.000009s
299593572317531 P 1.981411s
9999999999999999 0.000008s
3333333333333301 P 6.328173s
3333335652092209 6.419249s
4444444488888889 7.051267s
4444444444444423 P 7.122004s
5555553133149889 7.412735s
5555555555555503 P 7.603327s
6666666666666719 P 7.934670s
6666667141414921 8.017599s
7777777536340681 8.339623s
7777777777777753 P 8.388859s
9999999999999917 P 8.117313s
20 checks in 9.58s
输出的最后一行显示 procs.py 比 sequential.py 快了 4.2 倍。
理解经过时间
请注意,第一列中的经过时间是用于检查该特定数字的。例如,is_prime(7777777777777753) 几乎花费了 8.4 秒才返回 True。同时,其他进程正在并行检查其他数字。
有 20 个数字需要检查。我编写了 procs.py 来启动与 CPU 核心数量相等的工作进程,这个数量由 multiprocessing.cpu_count() 确定。
在这种情况下,总时间远远小于各个检查的经过时间之和。在启动进程和进程间通信中存在一些开销,因此最终结果是多进程版本仅比顺序版本快约 4.2 倍。这很好,但考虑到代码启动了 12 个进程以利用笔记本电脑上的所有核心,有点令人失望。
注意
multiprocessing.cpu_count()函数在我用来撰写本章的 MacBook Pro 上返回12。实际上是一个 6-CPU Core-i7,但由于超线程技术,操作系统报告有 12 个 CPU,每个核心执行 2 个线程。然而,当一个线程不像同一核心中的另一个线程那样努力工作时,超线程效果更好——也许第一个在缓存未命中后等待数据,而另一个在进行数字计算。无论如何,没有免费午餐:这台笔记本电脑在不使用大量内存的计算密集型工作中表现得像一台 6-CPU 机器,比如简单的素数测试。
多核素数检查的代码
当我们将计算委托给线程或进程时,我们的代码不会直接调用工作函数,因此我们不能简单地获得返回值。相反,工作由线程或进程库驱动,并最终产生需要存储的结果。协调工作人员和收集结果是并发编程中常见队列的用途,也是分布式系统中的用途。
procs.py中的许多新代码涉及设置和使用队列。文件顶部在示例 19-13 中。
警告
SimpleQueue在 Python 3.9 中添加到multiprocessing中。如果您使用的是早期版本的 Python,可以在示例 19-13 中用Queue替换SimpleQueue。
示例 19-13。procs.py:多进程素数检查;导入、类型和函数
import sys
from time import perf_counter
from typing import NamedTuple
from multiprocessing import Process, SimpleQueue, cpu_count # ①
from multiprocessing import queues # ②
from primes import is_prime, NUMBERS
class PrimeResult(NamedTuple): # ③
n: int
prime: bool
elapsed: float
JobQueue = queues.SimpleQueue[int] # ④
ResultQueue = queues.SimpleQueue[PrimeResult] # ⑤
def check(n: int) -> PrimeResult: # ⑥
t0 = perf_counter()
res = is_prime(n)
return PrimeResult(n, res, perf_counter() - t0)
def worker(jobs: JobQueue, results: ResultQueue) -> None: # ⑦
while n := jobs.get(): # ⑧
results.put(check(n)) # ⑨
results.put(PrimeResult(0, False, 0.0)) # ⑩
def start_jobs(
procs: int, jobs: JobQueue, results: ResultQueue ⑪
) -> None:
for n in NUMBERS:
jobs.put(n) ⑫
for _ in range(procs):
proc = Process(target=worker, args=(jobs, results)) ⑬
proc.start() ⑭
jobs.put(0) ⑮
①
尝试模拟threading,multiprocessing提供multiprocessing.SimpleQueue,但这是绑定到预定义实例的低级BaseContext类的方法。我们必须调用这个SimpleQueue来构建一个队列,不能在类型提示中使用它。
②
multiprocessing.queues有我们在类型提示中需要的SimpleQueue类。
③
PrimeResult包括检查素数的数字。将n与其他结果字段一起保持简化后续显示结果。
④
这是main函数将用于向执行工作的进程发送数字的SimpleQueue的类型别名。
⑤
第二个将在main中收集结果的SimpleQueue的类型别名。队列中的值将是由要测试素数的数字和一个Result元组组成的元组。
⑥
这类似于sequential.py。
⑦
worker获取一个包含要检查的数字的队列,另一个用于放置结果。
⑧
在这段代码中,我使用数字0作为毒丸:一个信号,告诉工作进程完成。如果n不是0,则继续循环。¹⁴
⑨
调用素数检查并将PrimeResult入队。
⑩
发回一个PrimeResult(0, False, 0.0),以让主循环知道这个工作进程已完成。
⑪
procs是将并行计算素数检查的进程数。
⑫
将要检查的数字入队到jobs中。
⑬
为每个工作进程分叉一个子进程。每个子进程将在其自己的worker函数实例内运行循环,直到从jobs队列中获取0。
⑭
启动每个子进程。
⑮
为每个进程入队一个0,以终止它们。
现在让我们来研究procs.py中的main函数在示例 19-14 中。
示例 19-14. procs.py:多进程素数检查;main函数
def main() -> None:
if len(sys.argv) < 2: # ①
procs = cpu_count()
else:
procs = int(sys.argv[1])
print(f'Checking {len(NUMBERS)} numbers with {procs} processes:')
t0 = perf_counter()
jobs: JobQueue = SimpleQueue() # ②
results: ResultQueue = SimpleQueue()
start_jobs(procs, jobs, results) # ③
checked = report(procs, results) # ④
elapsed = perf_counter() - t0
print(f'{checked} checks in {elapsed:.2f}s') # ⑤
def report(procs: int, results: ResultQueue) -> int: # ⑥
checked = 0
procs_done = 0
while procs_done < procs: # ⑦
n, prime, elapsed = results.get() # ⑧
if n == 0: # ⑨
procs_done += 1
else:
checked += 1 # ⑩
label = 'P' if prime else ' '
print(f'{n:16} {label} {elapsed:9.6f}s')
return checked
if __name__ == '__main__':
main()
①
如果没有给出命令行参数,则将进程数量设置为 CPU 核心数;否则,根据第一个参数创建相同数量的进程。
②
jobs和results是示例 19-13 中描述的队列。
③
启动proc进程来消费jobs并发布results。
④
检索结果并显示它们;report在⑥中定义。
⑤
显示检查的数字数量和总经过时间。
⑥
参数是procs的数量和用于发布结果的队列。
⑦
循环直到所有进程完成。
⑧
获取一个PrimeResult。在队列上调用.get()会阻塞,直到队列中有一个项目。也可以将其设置为非阻塞,或设置超时。有关详细信息,请参阅SimpleQueue.get文档。
⑨
如果n为零,则一个进程退出;增加procs_done计数。
⑩
否则,增加checked计数(以跟踪检查的数字)并显示结果。
结果将不会按照提交作业的顺序返回。这就是为什么我必须在每个PrimeResult元组中放入n。否则,我将无法知道哪个结果属于每个数字。
如果主进程在所有子进程完成之前退出,则可能会看到由multiprocessing中的内部锁引起的FileNotFoundError异常的令人困惑的回溯。调试并发代码总是困难的,而调试multiprocessing更加困难,因为在类似线程的外观背后有着复杂性。幸运的是,我们将在第二十章中遇到的ProcessPoolExecutor更易于使用且更健壮。
注意
感谢读者 Michael Albert 注意到我在早期发布时发布的代码在示例 19-14 中有一个竞争条件。竞争条件是一个可能发生也可能不发生的错误,取决于并发执行单元执行操作的顺序。如果“A”发生在“B”之前,一切都很好;但如果“B”先发生,就会出现问题。这就是竞争条件。
如果你感兴趣,这个差异显示了错误以及我如何修复它:example-code-2e/commit/2c123057—但请注意,我后来重构了示例,将main的部分委托给start_jobs和report函数。在同一目录中有一个README.md文件解释了问题和解决方案。
尝试使用更多或更少的进程
你可能想尝试运行procs.py,传递参数来设置工作进程的数量。例如,这个命令…
$ python3 procs.py 2
…将启动两个工作进程,几乎比sequential.py快两倍—如果您的计算机至少有两个核心并且没有太忙于运行其他程序。
我用 1 到 20 个进程运行了procs.py 12 次,总共 240 次运行。然后我计算了相同进程数量的所有运行的中位时间,并绘制了图 19-2。

图 19-2。每个进程数的中位运行时间从 1 到 20。1 个进程的最长中位时间为 40.81 秒。6 个进程的最短中位时间为 10.39 秒,由虚线表示。
在这台 6 核笔记本电脑中,6 个进程的最短中位时间为 10.39 秒,由图 19-2 中的虚线标记。我预计在 6 个进程后运行时间会增加,因为 CPU 争用,而在 10 个进程时达到 12.51 秒的局部最大值。我没有预料到,也无法解释为什么在 11 个进程时性能有所提高,并且从 13 到 20 个进程时几乎保持不变,中位时间仅略高于 6 个进程的最低中位时间。
基于线程的非解决方案
我还编写了threads.py,这是使用threading而不是multiprocessing的procs.py版本。代码非常相似——在将这两个 API 之间的简单示例转换时通常是这种情况。¹⁶ 由于 GIL 和is_prime的计算密集型特性,线程版本比示例 19-12 中的顺序代码慢,并且随着线程数量的增加而变慢,因为 CPU 争用和上下文切换的成本。要切换到新线程,操作系统需要保存 CPU 寄存器并更新程序计数器和堆栈指针,触发昂贵的副作用,如使 CPU 缓存失效,甚至可能交换内存页面。¹⁷
接下来的两章将更多地介绍 Python 中的并发编程,使用高级concurrent.futures库来管理线程和进程(第二十章)以及asyncio库用于异步编程(第二十一章)。
本章的其余部分旨在回答以下问题:
鉴于迄今为止讨论的限制,Python 如何在多核世界中蓬勃发展?
Python 在多核世界中
请考虑这段引用自广为引用的文章“软件中的并发:免费午餐已经结束”(作者:Herb Sutter):
从英特尔和 AMD 到 Sparc 和 PowerPC 等主要处理器制造商和架构,他们几乎用尽了传统方法来提升 CPU 性能的空间。他们不再试图提高时钟速度和直线指令吞吐量,而是大规模转向超线程和多核架构。2005 年 3 月。[在线提供]。
Sutter 所说的“免费午餐”是软件随着时间推移变得更快,而无需额外的开发人员努力的趋势,因为 CPU 一直在以更快的速度执行指令代码。自 2004 年以来,这种情况不再成立:时钟速度和执行优化已经达到瓶颈,现在任何显著的性能提升都必须来自于利用多核或超线程,这些进步只有为并发执行编写的代码才能受益。
Python 的故事始于 20 世纪 90 年代初,当时 CPU 仍在以指令代码执行的方式呈指数级增长。当时除了超级计算机外,几乎没有人谈论多核 CPU。当时,决定使用 GIL 是理所当然的。GIL 使解释器在单核运行时更快,其实现更简单。¹⁸ GIL 还使得通过 Python/C API 编写简单扩展变得更容易。
注意
我之所以写“简单扩展”,是因为扩展根本不需要处理 GIL。用 C 或 Fortran 编写的函数可能比 Python 中的等效函数快几百倍。¹⁹ 因此,在许多情况下,可能不需要释放 GIL 以利用多核 CPU 的增加复杂性。因此,我们可以感谢 GIL 为 Python 提供了许多扩展,这无疑是该语言如今如此受欢迎的关键原因之一。
尽管有全局解释器锁(GIL),Python 在需要并发或并行执行的应用程序中蓬勃发展,这要归功于绕过 CPython 限制的库和软件架构。
现在让我们讨论 Python 在 2021 年多核、分布式计算世界中的系统管理、数据科学和服务器端应用开发中的应用。
系统管理
Python 被广泛用于管理大量服务器、路由器、负载均衡器和网络附加存储(NAS)。它也是软件定义网络(SDN)和道德黑客的主要选择。主要的云服务提供商通过由提供者自己或由他们庞大的 Python 用户社区编写的库和教程来支持 Python。
在这个领域,Python 脚本通过向远程机器发出命令来自动化配置任务,因此很少有需要进行 CPU 绑定操作。线程或协程非常适合这样的工作。特别是,我们将在第二十章中看到的concurrent.futures包可以用于同时在许多远程机器上执行相同的操作,而不需要太多复杂性。
除了标准库之外,还有一些流行的基于 Python 的项目用于管理服务器集群:像Ansible和Salt这样的工具,以及像Fabric这样的库。
还有越来越多支持协程和asyncio的系统管理库。2016 年,Facebook 的生产工程团队报告:“我们越来越依赖于 AsyncIO,这是在 Python 3.4 中引入的,并且在将代码库从 Python 2 迁移时看到了巨大的性能提升。”
数据科学
数据科学—包括人工智能—和科学计算非常适合 Python。这些领域的应用程序需要大量计算,但 Python 用户受益于一个庞大的用 C、C++、Fortran、Cython 等编写的数值计算库生态系统—其中许多能够利用多核机器、GPU 和/或异构集群中的分布式并行计算。
截至 2021 年,Python 的数据科学生态系统包括令人印象深刻的工具,例如:
两个基于浏览器的界面—Jupyter Notebook 和 JupyterLab—允许用户在远程机器上运行和记录潜在跨网络运行的分析代码。两者都是混合 Python/JavaScript 应用程序,支持用不同语言编写的计算内核,通过 ZeroMQ 集成—一种用于分布式应用的异步消息传递库。Jupyter这个名字实际上来自于 Julia、Python 和 R,这三种是 Notebook 支持的第一批语言。建立在 Jupyter 工具之上的丰富生态系统包括Bokeh,一个强大的交互式可视化库,让用户能够浏览和与大型数据集或持续更新的流数据进行交互,得益于现代 JavaScript 引擎和浏览器的性能。
根据O’Reilly 2021 年 1 月报告,这是两个顶尖的深度学习框架,根据他们在 2020 年学习资源使用情况。这两个项目都是用 C++编写的,并且能够利用多核、GPU 和集群。它们也支持其他语言,但 Python 是它们的主要关注点,也是大多数用户使用的语言。TensorFlow 由 Google 内部创建和使用;PyTorch 由 Facebook 创建。
一个并行计算库,可以将工作分配给本地进程或机器集群,“在世界上一些最大的超级计算机上进行了测试”——正如他们的主页所述。Dask 提供了紧密模拟 NumPy、pandas 和 scikit-learn 的 API——这些是当今数据科学和机器学习中最流行的库。Dask 可以从 JupyterLab 或 Jupyter Notebook 中使用,并利用 Bokeh 不仅用于数据可视化,还用于显示数据和计算在进程/机器之间的流动的交互式仪表板,几乎实时地展示。Dask 如此令人印象深刻,我建议观看像这样的15 分钟演示视频,其中项目的维护者之一 Matthew Rocklin 展示了 Dask 在 AWS 上的 8 台 EC2 机器上的 64 个核心上处理数据的情况。
这些只是一些例子,说明数据科学界正在创造利用 Python 最佳优势并克服 CPython 运行时限制的解决方案。
服务器端 Web/Mobile 开发
Python 在 Web 应用程序和支持移动应用程序的后端 API 中被广泛使用。谷歌、YouTube、Dropbox、Instagram、Quora 和 Reddit 等公司是如何构建 Python 服务器端应用程序,为数亿用户提供 24x7 服务的?答案远远超出了 Python “开箱即用”提供的范围。
在讨论支持 Python 大规模应用的工具之前,我必须引用 Thoughtworks Technology Radar 中的一句警告:
高性能嫉妒/Web 规模嫉妒
我们看到许多团队陷入困境,因为他们选择了复杂的工具、框架或架构,因为他们可能需要扩展”。像 Twitter 和 Netflix 这样的公司需要支持极端负载,因此需要这些架构,但他们也有极其熟练的开发团队能够处理复杂性。大多数情况并不需要这种工程壮举;团队应该控制他们对Web 规模的嫉妒,而选择简单的解决方案来完成工作[²⁰]。
在Web 规模上,关键是允许横向扩展的架构。在那一点上,所有系统都是分布式系统,没有单一的编程语言可能适合解决方案的每个部分。
分布式系统是一个学术研究领域,但幸运的是一些从业者已经写了一些基于扎实研究和实践经验的易懂的书籍。其中之一是 Martin Kleppmann,他是《设计数据密集型应用》(O’Reilly)的作者。
考虑 Kleppmann 的书中的第 19-3 图,这是许多架构图中的第一个。以下是我在参与的 Python 项目中看到或拥有第一手知识的一些组件:
-
应用程序缓存:[²¹] memcached,Redis,Varnish
-
关系型数据库:PostgreSQL,MySQL
-
文档数据库:Apache CouchDB,MongoDB
-
全文索引:Elasticsearch,Apache Solr
-
消息队列:RabbitMQ,Redis

图 19-3. 一个可能的结合多个组件的系统架构[²²]
在每个类别中还有其他工业级开源产品。主要云服务提供商也提供了他们自己的专有替代方案。
Kleppmann 的图是通用的,与语言无关——就像他的书一样。对于 Python 服务器端应用程序,通常部署了两个特定组件:
-
一个应用服务器,用于在几个 Python 应用程序实例之间分发负载。应用服务器将出现在图 19-3 中的顶部,处理客户端请求,然后再到达应用程序代码。
-
建立在图 19-3 右侧的消息队列周围的任务队列,提供了一个更高级、更易于使用的 API,将任务分发给在其他机器上运行的进程。
接下来的两节探讨了这些组件,在 Python 服务器端部署中被推荐为最佳实践。
WSGI 应用程序服务器
WSGI——Web 服务器网关接口——是 Python 框架或应用程序接收来自 HTTP 服务器的请求并向其发送响应的标准 API。²³ WSGI 应用程序服务器管理一个或多个运行应用程序的进程,最大限度地利用可用的 CPU。
图 19-4 说明了一个典型的 WSGI 部署。
提示
如果我们想要合并前面一对图表,图 19-4 中虚线矩形的内容将取代 图 19-3 顶部的实线“应用程序代码”矩形。
Python web 项目中最知名的应用程序服务器有:
对于 Apache HTTP 服务器的用户,mod_wsgi 是最佳选择。它与 WSGI 一样古老,但仍在积极维护。并且现在提供了一个名为 mod_wsgi-express 的命令行启动器,使其更易于配置,并更适合在 Docker 容器中使用。

图 19-4. 客户端连接到一个 HTTP 服务器,该服务器提供静态文件并将其他请求路由到应用程序服务器,后者分叉子进程来运行应用程序代码,利用多个 CPU 核心。WSGI API 是应用程序服务器和 Python 应用程序代码之间的粘合剂。
uWSGI 和 Gunicorn 是我所知道的最近项目中的首选。两者通常与 NGINX HTTP 服务器一起使用。uWSGI 提供了许多额外功能,包括应用程序缓存、任务队列、类似 cron 的定期任务以及许多其他功能。然而,与 Gunicorn 相比,uWSGI 要难以正确配置得多。²⁵
2018 年发布的 NGINX Unit 是著名 NGINX HTTP 服务器和反向代理的制造商推出的新产品。
mod_wsgi 和 Gunicorn 仅支持 Python web 应用程序,而 uWSGI 和 NGINX Unit 也可以与其他语言一起使用。请浏览它们的文档以了解更多信息。
主要观点:所有这些应用程序服务器都可以通过分叉多个 Python 进程来使用服务器上的所有 CPU 核心,以运行传统的使用旧的顺序代码编写的 Web 应用程序,如 Django、Flask、Pyramid 等。这就解释了为什么可以作为 Python web 开发人员谋生,而无需学习 threading、multiprocessing 或 asyncio 模块:应用程序服务器会透明地处理并发。
ASGI——异步服务器网关接口
WSGI 是一个同步 API。它不支持使用 async/await 实现 WebSockets 或 HTTP 长轮询的协程——这是在 Python 中实现最有效的方法。ASGI 规范 是 WSGI 的继任者,专为异步 Python web 框架设计,如 aiohttp、Sanic、FastAPI 等,以及逐渐添加异步功能的 Django 和 Flask。
现在让我们转向另一种绕过 GIL 以实现更高性能的服务器端 Python 应用程序的方法。
分布式任务队列
当应用服务器将请求传递给运行您代码的 Python 进程之一时,您的应用需要快速响应:您希望进程尽快可用以处理下一个请求。但是,某些请求需要执行可能需要较长时间的操作,例如发送电子邮件或生成 PDF。这就是分布式任务队列旨在解决的问题。
Celery 和 RQ 是最知名的具有 Python API 的开源任务队列。云服务提供商也提供他们自己的专有任务队列。
这些产品包装了一个消息队列,并提供了一个高级 API,用于将任务委托给工作者,可能在不同的机器上运行。
注意
在任务队列的背景下,使用 生产者 和 消费者 这两个词,而不是传统的客户端/服务器术语。例如,Django 视图处理程序生成作业请求,这些请求被放入队列中,以便由一个或多个 PDF 渲染进程消耗。
直接引用自 Celery 的 FAQ,以下是一些典型的用例:
- 在后台运行某些东西。例如,尽快完成网页请求,然后逐步更新用户页面。这给用户留下了良好性能和“灵敏度”的印象,即使实际工作可能需要一些时间。
- 在网页请求完成后运行某些内容。
- 确保某事已完成,通过异步执行并使用重试。
- 定期调度工作。
除了解决这些直接问题外,任务队列还支持水平扩展。生产者和消费者是解耦的:生产者不调用消费者,而是将请求放入队列中。消费者不需要了解生产者的任何信息(但如果需要确认,则请求可能包含有关生产者的信息)。至关重要的是,随着需求增长,您可以轻松地添加更多的工作者来消耗任务。这就是为什么 Celery 和 RQ 被称为分布式任务队列。
回想一下,我们简单的procs.py(示例 19-13)使用了两个队列:一个用于作业请求,另一个用于收集结果。Celery 和 RQ 的分布式架构使用了类似的模式。两者都支持使用 Redis NoSQL 数据库作为消息队列和结果存储。Celery 还支持其他消息队列,如 RabbitMQ 或 Amazon SQS,以及其他数据库用于结果存储。
这就结束了我们对 Python 中并发性的介绍。接下来的两章将继续这个主题,重点关注标准库中的 concurrent.futures 和 asyncio 包。
章节总结
经过一点理论,本章介绍了在 Python 的三种本机并发编程模型中实现的旋转器脚本:
-
线程,使用
threading包 -
进程,使用
multiprocessing -
使用
asyncio进行异步协程
然后,我们通过一个实验探讨了 GIL 的真正影响:将旋转器示例更改为计算大整数的素性,并观察结果行为。这直观地证明了 CPU 密集型函数必须在 asyncio 中避免,因为它们会阻塞事件循环。尽管 GIL 的存在,线程版本的实验仍然有效,因为 Python 周期性地中断线程,而且示例仅使用了两个线程:一个执行计算密集型工作,另一个每秒仅驱动动画 10 次。multiprocessing 变体绕过了 GIL,为动画启动了一个新进程,而主进程则执行素性检查。
下一个示例,计算多个素数,突出了 multiprocessing 和 threading 之间的区别,证明只有进程才能让 Python 受益于多核 CPU。Python 的 GIL 使线程比顺序代码更糟糕,用于重型计算。
GIL 主导了关于 Python 并发和并行计算的讨论,但我们不应该过高估计其影响。这就是“Python 在多核世界中的应用”的观点。例如,GIL 并不影响 Python 在系统管理中的许多用例。另一方面,数据科学和服务器端开发社区已经通过针对其特定需求定制的工业级解决方案绕过了 GIL。最后两节提到了支持 Python 服务器端应用程序规模化的两个常见元素:WSGI 应用程序服务器和分布式任务队列。
进一步阅读
本章有一个广泛的阅读列表,因此我将其分成了子章节。
线程和进程并发
在第二十章中涵盖的concurrent.futures库在底层使用线程、进程、锁和队列,但您不会看到它们的单独实例;它们被捆绑并由ThreadPoolExecutor和ProcessPoolExecutor的更高级抽象管理。如果您想了解更多关于使用这些低级对象进行并发编程的实践,Jim Anderson 的“Python 中的线程简介”是一个很好的首次阅读。Doug Hellmann 在他的网站和书籍The Python 3 Standard Library by Example(Addison-Wesley)中有一章标题为“进程、线程和协程并发”的章节。
Brett Slatkin 的Effective Python,第 2 版(Addison-Wesley),David Beazley 的Python Essential Reference,第 4 版(Addison-Wesley),以及 Martelli 等人的Python in a Nutshell,第 3 版(O’Reilly)是其他涵盖threading和multiprocessing的一般 Python 参考资料。广泛的multiprocessing官方文档在其“编程指南”部分中包含有用的建议。
Jesse Noller 和 Richard Oudkerk 贡献了multiprocessing包,该包在PEP 371—将 multiprocessing 包添加到标准库中介绍。该包的官方文档是一个 93 KB 的.rst文件,大约 63 页,使其成为 Python 标准库中最长的章节之一。
在High Performance Python,第 2 版,(O’Reilly)中,作者 Micha Gorelick 和 Ian Ozsvald 包括了一个关于multiprocessing的章节,其中包含一个关于使用不同策略检查质数的示例,与我们的procs.py示例不同。对于每个数字,他们将可能因子的范围—从 2 到sqrt(n)—分成子范围,并让每个工作进程迭代其中一个子范围。他们的分而治之方法是科学计算应用程序的典型特征,其中数据集庞大,工作站(或集群)拥有比用户更多的 CPU 核心。在处理来自许多用户的请求的服务器端系统上,让每个进程从头到尾处理一个计算更简单、更有效—减少了进程之间的通信和协调开销。除了multiprocessing,Gorelick 和 Ozsvald 还提出了许多其他开发和部署高性能数据科学应用程序的方法,利用多个核心、GPU、集群、性能分析工具和像 Cython 和 Numba 这样的编译器。他们的最后一章,“实战经验”,是其他高性能 Python 计算从业者贡献的短案例的宝贵收集。
Advanced Python Development由 Matthew Wilkes(Apress)编写,是一本罕见的书,其中包含简短的示例来解释概念,同时展示如何构建一个准备投入生产的现实应用程序:一个类似于 DevOps 监控系统或用于分布式传感器的 IoT 数据收集器的数据聚合器。Advanced Python Development中的两章涵盖了使用threading和asyncio进行并发编程。
Jan Palach 的Parallel Programming with Python(Packt,2014)解释了并发和并行背后的核心概念,涵盖了 Python 的标准库以及Celery。
“关于线程的真相”是 Caleb Hattingh(O’Reilly)在Using Asyncio in Python第二章的标题。该章节涵盖了线程的利弊,其中包括了几位权威来源的引人注目的引用,明确指出线程的基本挑战与 Python 或 GIL 无关。引用自Using Asyncio in Python第 14 页的原文:
这些主题反复出现:
- 线程使代码难以理解。
- 线程是大规模并发(成千上万个并发任务)的一种低效模型。
如果你想通过艰难的方式了解关于线程和锁的推理有多困难——而又不用担心工作——可以尝试 Allen Downey 的练习册The Little Book of Semaphores(Green Tea Press)。Downey 书中的练习从简单到非常困难到无法解决,但即使是简单的练习也会让人大开眼界。
GIL
如果你对 GIL 感兴趣,请记住我们无法从 Python 代码中控制它,因此权威参考在 C-API 文档中:Thread State and the Global Interpreter Lock。Python Library and Extension FAQ回答了:“我们不能摆脱全局解释器锁吗?”。值得阅读的还有 Guido van Rossum 和 Jesse Noller(multiprocessing包的贡献者)的帖子,分别是“摆脱 GIL 并不容易”和“Python 线程和全局解释器锁”。
CPython Internals由 Anthony Shaw(Real Python)解释了 CPython 3 解释器在 C 编程层面的实现。 Shaw 最长的章节是“并行性和并发性”:深入探讨了 Python 对线程和进程的本机支持,包括使用 C/Python API 从扩展中管理 GIL。
最后,David Beazley 在“理解 Python GIL”中进行了详细探讨。在演示文稿的第 54 页中,Beazley 报告了在 Python 3.2 中引入的新 GIL 算法对特定基准测试处理时间的增加。根据 Antoine Pitrou 在 Beazley 提交的错误报告中的评论,在真实工作负载中,这个问题并不显著:Python 问题#7946。
超越标准库的并发
Fluent Python专注于核心语言特性和标准库的核心部分。Full Stack Python是这本书的绝佳补充:它涵盖了 Python 的生态系统,包括“开发环境”,“数据”,“Web 开发”和“DevOps”等部分。
我已经提到了两本涵盖使用 Python 标准库进行并发的书籍,它们还包括了大量关于第三方库和工具的内容:High Performance Python,第 2 版和Parallel Programming with Python。Francesco Pierfederici 的Distributed Computing with Python(Packt)涵盖了标准库以及云提供商和 HPC(高性能计算)集群的使用。
“Python,性能和 GPU”是 Matthew Rocklin 在 2019 年 6 月发布的“关于从 Python 使用 GPU 加速器的最新情况”。
“Instagram 目前拥有世界上最大规模的Django Web 框架部署,完全使用 Python 编写。”这是 Instagram 软件工程师 Min Ni 撰写的博文“在 Instagram 中使用 Python 的 Web 服务效率”的开头句。该文章描述了 Instagram 用于优化其 Python 代码库效率的指标和工具,以及在每天部署其后端“30-50 次”时检测和诊断性能回归。
由 Harry Percival 和 Bob Gregory(O’Reilly)撰写的Architecture Patterns with Python: Enabling Test-Driven Development, Domain-Driven Design, and Event-Driven Microservices介绍了 Python 服务器端应用程序的架构模式。作者还在cosmicpython.com免费提供了这本书的在线版本。
用于在进程之间并行执行任务的两个优雅且易于使用的库是由 João S. O. Bueno 开发的lelo和由 Nat Pryce 开发的python-parallelize。lelo包定义了一个@parallel装饰器,您可以将其应用于任何函数,使其神奇地变为非阻塞:当您调用装饰的函数时,它的执行将在另一个进程中开始。Nat Pryce 的python-parallelize包提供了一个parallelize生成器,将for循环的执行分布到多个 CPU 上。这两个包都构建在multiprocessing库上。
Python 核心开发者 Eric Snow 维护着一个Multicore Python维基,其中记录了他和其他人努力改进 Python 对并行执行的支持的笔记。Snow 是PEP 554—Stdlib 中的多个解释器的作者。如果得到批准并实施,PEP 554 将为未来的增强奠定基础,最终可能使 Python 能够在没有multiprocessing开销的情况下使用多个核心。其中最大的障碍之一是多个活动子解释器和假定单个解释器的扩展之间的复杂交互。
Python 维护者 Mark Shannon 还创建了一个有用的表格,比较了 Python 中的并发模型,在他、Eric Snow 和其他开发者在python-dev邮件列表上讨论子解释器时被引用。在 Shannon 的表格中,“理想的 CSP”列指的是 Tony Hoare 在 1978 年提出的理论通信顺序进程模型。Go 也允许共享对象,违反了 CSP 的一个基本约束:执行单元应通过通道进行消息传递。
Stackless Python(又名Stackless)是 CPython 的一个分支,实现了微线程,这些线程是应用级轻量级线程,而不是操作系统线程。大型多人在线游戏EVE Online是基于Stackless构建的,游戏公司CCP雇用的工程师一度是Stackless的维护者。Stackless的一些特性在Pypy解释器和greenlet包中重新实现,后者是gevent网络库的核心技术,而后者又是Gunicorn应用服务器的基础。
并发编程的演员模型是高度可扩展的 Erlang 和 Elixir 语言的核心,并且也是 Scala 和 Java 的 Akka 框架的模型。如果你想在 Python 中尝试演员模型,请查看Thespian和Pykka库。
我剩下的推荐几乎没有提到 Python,但对于对本章主题感兴趣的读者仍然相关。
超越 Python 的并发性和可扩展性
Alvaro Videla 和 Jason J. W. Williams(Manning)的RabbitMQ 实战是一本非常精心编写的介绍 RabbitMQ 和高级消息队列协议(AMQP)标准的书籍,其中包含 Python、PHP 和 Ruby 的示例。无论您的技术堆栈的其余部分如何,即使您计划在幕后使用 Celery 与 RabbitMQ,我也推荐这本书,因为它涵盖了分布式消息队列的概念、动机和模式,以及在规模上操作和调整 RabbitMQ。
阅读 Paul Butcher(Pragmatic Bookshelf)的七周七并发模型让我受益匪浅,书中有着优美的副标题当线程解开。该书的第一章介绍了使用 Java 中的线程和锁编程的核心概念和挑战。该书的其余六章致力于作者认为更好的并发和并行编程的替代方案,支持不同的语言、工具和库。示例使用了 Java、Clojure、Elixir 和 C(用于关于使用OpenCL 框架进行并行编程的章节)。CSP 模型以 Clojure 代码为例,尽管 Go 语言值得赞扬,因为它推广了这种方法。Elixir 是用于说明 actor 模型的示例的语言。一个免费提供的额外章节关于 actor 使用 Scala 和 Akka 框架。除非您已经了解 Scala,否则 Elixir 是一个更易于学习和实验 actor 模型和 Erlang/OTP 分布式系统平台的语言。
Thoughtworks 的 Unmesh Joshi 为 Martin Fowler 的博客贡献了几页关于“分布式系统模式”的文档。开篇页面是该主题的绝佳介绍,附有各个模式的链接。Joshi 正在逐步添加模式,但已有的内容蕴含了在关键任务系统中多年辛苦积累的经验。
Martin Kleppmann 的设计数据密集型应用(O'Reilly)是一本罕见的由具有深厚行业经验和高级学术背景的从业者撰写的书籍。作者在领英和两家初创公司的大规模数据基础设施上工作,然后成为剑桥大学分布式系统研究员。Kleppmann 的每一章都以大量参考文献结尾,包括最新的研究结果。该书还包括许多启发性的图表和精美的概念地图。
我很幸运能够参加 Francesco Cesarini 在 OSCON 2016 上关于可靠分布式系统架构的出色研讨会:“使用 Erlang/OTP 进行可扩展性设计和架构”(在 O'Reilly 学习平台上的视频)。尽管标题如此,视频中的 9:35 处,Cesarini 解释道:
我即将说的内容很少会是特定于 Erlang 的[...]. 事实仍然是,Erlang 将消除许多制约系统具有弹性、永不失败且可扩展性的偶发困难。因此,如果您使用 Erlang 或在 Erlang 虚拟机上运行的语言,将会更容易。
那个研讨会基于 Francesco Cesarini 和 Steve Vinoski(O'Reilly)的使用 Erlang/OTP 进行可扩展性设计的最后四章。
编写分布式系统具有挑战性和令人兴奋,但要小心web-scale envy。KISS 原则仍然是可靠的工程建议。
查看 Frank McSherry、Michael Isard 和 Derek G. Murray 撰写的论文“可扩展性!但以什么代价?”。作者们在学术研讨会中发现了需要数百个核心才能胜过“胜任的单线程实现”的并行图处理系统。他们还发现了“在所有报告的配置中都不如一个线程表现”的系统。
这些发现让我想起了一个经典的黑客警句:
我的 Perl 脚本比你的 Hadoop 集群更快。
¹ 演讲“并发不等于并行”的第 8 张幻灯片。
² 我曾与 Imre Simon 教授一起学习和工作,他喜欢说科学中有两个主要的罪过:用不同的词来表示同一件事和用一个词表示不同的事物。Imre Simon(1943-2009)是巴西计算机科学的先驱,对自动机理论做出了重要贡献,并开创了热带数学领域。他还是自由软件和自由文化的倡导者。
³ 这一部分是由我的朋友 Bruce Eckel 提出的,他是有关 Kotlin、Scala、Java 和 C++的书籍的作者。
⁴ 调用sys.getswitchinterval()以获取间隔;使用sys.setswitchinterval(s)来更改它。
⁵ 系统调用是用户代码调用操作系统内核函数的一种方式。I/O、定时器和锁是通过系统调用提供的一些内核服务。要了解更多,请阅读维基百科的“系统调用”文章。
⁶ zlib和bz2模块在Antoine Pitrou 的 python-dev 消息中被特别提到,他为 Python 3.2 贡献了时间切片 GIL 逻辑。
⁷ 来源:Beazley 的“生成器:最终领域”教程第 106 页幻灯片。
⁸ 来源:“线程对象”部分的最后一段。
⁹ Unicode 有许多对简单动画有用的字符,比如盲文图案。我使用 ASCII 字符"\|/-"来保持示例简单。
¹⁰ 信号量是一个基本构件,可用于实现其他同步机制。Python 提供了不同的信号量类,用于线程、进程和协程。我们将在“使用 asyncio.as_completed 和一个线程”中看到asyncio.Semaphore(第二十一章)。
¹¹ 感谢技术评论家 Caleb Hattingh 和 Jürgen Gmach,他们让我没有忽视greenlet和gevent。
¹² 这是一台配备有 6 核、2.2 GHz 英特尔酷睿 i7 处理器的 15 英寸 MacBook Pro 2018。
¹³ 今天这是真实的,因为你可能正在使用具有抢占式多任务的现代操作系统。NT 时代之前的 Windows 和 OSX 时代之前的 macOS 都不是“抢占式”的,因此任何进程都可以占用 100%的 CPU 并冻结整个系统。今天我们并没有完全摆脱这种问题,但请相信这位老者:这在 20 世纪 90 年代困扰着每个用户,硬重置是唯一的解决方法。
¹⁴ 在这个例子中,0是一个方便的标记。None也经常用于这个目的。使用0简化了PrimeResult的类型提示和worker的代码。
¹⁵ 在不失去我们身份的情况下幸存下来是一个相当不错的人生目标。
¹⁶ 请查看Fluent Python代码库中的19-concurrency/primes/threads.py。
¹⁷ 要了解更多,请参阅英文维基百科中的“上下文切换”。
¹⁸ 这可能是促使 Ruby 语言创始人松本行弘(Yukihiro Matsumoto)在他的解释器中使用 GIL 的相同原因。
¹⁹ 在大学的一个练习中,我不得不用 C 实现 LZW 压缩算法。但我先用 Python 写了它,以检查我对规范的理解。C 版本大约快了 900 倍。
²⁰ 来源:Thoughtworks 技术咨询委员会,《技术雷达》—2015 年 11 月。
²¹ 对比应用程序缓存—直接被应用程序代码使用—与 HTTP 缓存,它们将放置在图 19-3 的顶部边缘,用于提供静态资产如图像、CSS 和 JS 文件。内容交付网络(CDN)提供另一种类型的 HTTP 缓存,部署在更接近应用程序最终用户的数据中心。
²² 图表改编自马丁·克莱普曼(O'Reilly)的《数据密集型应用设计》第 1-1 图。
²³ 一些演讲者拼写 WSGI 首字母缩写,而其他人则将其发音为一个与“whisky”押韵的单词。
²⁴ uWSGI的拼写中“u”是小写的,但发音为希腊字母“µ”,因此整个名称听起来像“micro-whisky”,但“k”换成“g”。
²⁵ 彼得·斯佩尔(Peter Sperl)和本·格林(Ben Green)撰写了“为生产部署配置 uWSGI”,解释了uWSGI中许多默认设置对许多常见部署场景都不适用。斯佩尔在2019 年 EuroPython上介绍了他们建议的摘要。强烈推荐给uWSGI的用户。
²⁶ 卡勒布(Caleb)是Fluent Python本版的技术审查员之一。
²⁷ 感谢卢卡斯·布鲁尼亚尔蒂(Lucas Brunialti)给我发送了这个演讲链接。
²⁸ Python 的threading和concurrent.futures API 受到 Java 标准库的重大影响。
²⁹ Erlang 社区将“进程”一词用于 actors。在 Erlang 中,每个进程都是自己循环中的一个函数,因此它们非常轻量级,可以在单台机器上同时激活数百万个进程,与本章其他地方讨论的重量级操作系统进程没有关系。所以这里我们有教授西蒙描述的两种罪行的例子:使用不同的词来表示相同的事物,以及使用一个词来表示不同的事物。
第二十章:并发执行器
抨击线程的人通常是系统程序员,他们心中有着典型应用程序员终其一生都不会遇到的用例。[...] 在 99%的用例中,应用程序员可能会遇到的情况是,生成一堆独立线程并将结果收集到队列中的简单模式就是他们需要了解的一切。
米歇尔·西莫纳托,Python 深思者¹
本章重点介绍了封装“生成一堆独立线程并将结果收集到队列中”模式的concurrent.futures.Executor类,这是米歇尔·西莫纳托描述的。并发执行器使得这种模式几乎可以轻松使用,不仅适用于线程,还适用于进程——对于计算密集型任务非常有用。
在这里,我还介绍了futures的概念——代表操作异步执行的对象,类似于 JavaScript 的 promises。这个基本概念不仅是concurrent.futures的基础,也是asyncio包的基础,是第二十一章的主题。
本章亮点
我将本章从“使用 Futures 进行并发”改名为“并发执行器”,因为执行器是这里涵盖的最重要的高级特性。Futures 是低级对象,在“Futures 在哪里?”中重点介绍,但在本章的其他部分基本上是不可见的。
所有 HTTP 客户端示例现在都使用新的HTTPX库,提供同步和异步 API。
在“带有进度显示和错误处理的下载”实验的设置现在更简单了,这要归功于 Python 3.7 中添加到http.server包中的多线程服务器。以前,标准库只有单线程的BaseHttpServer,不适合用于并发客户端的实验,因此我不得不在本书第一版中使用外部工具。
“使用 concurrent.futures 启动进程”现在演示了执行器如何简化我们在“多核素数检查器的代码”中看到的代码。
最后,我将大部分理论内容移至新的第十九章,“Python 中的并发模型”。
并发网络下载
并发对于高效的网络 I/O 至关重要:应用程序不应该闲置等待远程机器,而应该在收到响应之前做其他事情。²
为了用代码演示,我编写了三个简单的程序来从网络上下载 20 个国家的国旗图片。第一个flags.py按顺序运行:只有在上一个图片下载并保存在本地后才请求下一个图片。另外两个脚本进行并发下载:它们几乎同时请求多个图片,并在图片到达时保存。flags_threadpool.py脚本使用concurrent.futures包,而flags_asyncio.py使用asyncio。
示例 20-1 展示了运行三个脚本三次的结果。我还在 YouTube 上发布了一个73 秒的视频,这样你就可以看到它们运行时 macOS Finder 窗口显示保存的标志。这些脚本正在从fluentpython.com下载图片,该网站位于 CDN 后面,因此在第一次运行时可能会看到较慢的结果。示例 20-1 中的结果是在多次运行后获得的,因此 CDN 缓存已经热了。
示例 20-1 三个脚本 flags.py、flags_threadpool.py 和 flags_asyncio.py 的典型运行结果
$ python3 flags.py
BD BR CD CN DE EG ET FR ID IN IR JP MX NG PH PK RU TR US VN # ① 20 flags downloaded in 7.26s # ② $ python3 flags.py
BD BR CD CN DE EG ET FR ID IN IR JP MX NG PH PK RU TR US VN
20 flags downloaded in 7.20s
$ python3 flags.py
BD BR CD CN DE EG ET FR ID IN IR JP MX NG PH PK RU TR US VN
20 flags downloaded in 7.09s
$ python3 flags_threadpool.py
DE BD CN JP ID EG NG BR RU CD IR MX US PH FR PK VN IN ET TR
20 flags downloaded in 1.37s # ③ $ python3 flags_threadpool.py
EG BR FR IN BD JP DE RU PK PH CD MX ID US NG TR CN VN ET IR
20 flags downloaded in 1.60s
$ python3 flags_threadpool.py
BD DE EG CN ID RU IN VN ET MX FR CD NG US JP TR PK BR IR PH
20 flags downloaded in 1.22s
$ python3 flags_asyncio.py # ④ BD BR IN ID TR DE CN US IR PK PH FR RU NG VN ET MX EG JP CD
20 flags downloaded in 1.36s
$ python3 flags_asyncio.py
RU CN BR IN FR BD TR EG VN IR PH CD ET ID NG DE JP PK MX US
20 flags downloaded in 1.27s
$ python3 flags_asyncio.py
RU IN ID DE BR VN PK MX US IR ET EG NG BD FR CN JP PH CD TR # ⑤ 20 flags downloaded in 1.42s
①
每次运行的输出以下载的国旗国家代码开头,并以显示经过的时间的消息结束。
②
flags.py下载 20 张图像平均用时 7.18 秒。
③
flags_threadpool.py的平均时间为 1.40 秒。
④
对于flags_asyncio.py,平均时间为 1.35 秒。
⑤
注意国家代码的顺序:使用并发脚本下载时,每次下载的顺序都不同。
并发脚本之间的性能差异不大,但它们都比顺序脚本快五倍以上——这仅针对下载几千字节的 20 个文件的小任务。如果将任务扩展到数百个下载,那么并发脚本可以比顺序代码快 20 倍或更多。
警告
在针对公共网络服务器测试并发 HTTP 客户端时,您可能会无意中发动拒绝服务(DoS)攻击,或被怀疑这样做。在示例 20-1 的情况下,这样做是可以的,因为这些脚本是硬编码为仅发出 20 个请求。我们将在本章后面使用 Python 的http.server包来运行测试。
现在让我们研究示例 20-1 中测试的两个脚本的实现:flags.py和flags_threadpool.py。第三个脚本flags_asyncio.py将在第二十一章中介绍,但我想一起展示这三个脚本以阐明两点:
-
无论您使用哪种并发构造——线程还是协程——如果正确编码,您将看到网络 I/O 操作的吞吐量大大提高。
-
对于可以控制发出多少请求的 HTTP 客户端,线程和协程之间的性能差异不大。³
进入代码部分。
一个顺序下载脚本
示例 20-2 包含flags.py的实现,这是我们在示例 20-1 中运行的第一个脚本。它并不是很有趣,但我们将重用大部分代码和设置来实现并发脚本,因此它值得一提。
注意
为了清晰起见,在示例 20-2 中没有错误处理。我们稍后会处理异常,但这里我想专注于代码的基本结构,以便更容易将此脚本与并发脚本进行对比。
示例 20-2. flags.py:顺序下载脚本;一些函数将被其他脚本重用
import time
from pathlib import Path
from typing import Callable
import httpx # ①
POP20_CC = ('CN IN US ID BR PK NG BD RU JP '
'MX PH VN ET EG DE IR TR CD FR').split() # ②
BASE_URL = 'https://www.fluentpython.com/data/flags' # ③
DEST_DIR = Path('downloaded') # ④
def save_flag(img: bytes, filename: str) -> None: # ⑤
(DEST_DIR / filename).write_bytes(img)
def get_flag(cc: str) -> bytes: # ⑥
url = f'{BASE_URL}/{cc}/{cc}.gif'.lower()
resp = httpx.get(url, timeout=6.1, # ⑦
follow_redirects=True) # ⑧
resp.raise_for_status() # ⑨
return resp.content
def download_many(cc_list: list[str]) -> int: # ⑩
for cc in sorted(cc_list): ⑪
image = get_flag(cc)
save_flag(image, f'{cc}.gif')
print(cc, end=' ', flush=True) ⑫
return len(cc_list)
def main(downloader: Callable[[list[str]], int]) -> None: ⑬
DEST_DIR.mkdir(exist_ok=True) ⑭
t0 = time.perf_counter() ⑮
count = downloader(POP20_CC)
elapsed = time.perf_counter() - t0
print(f'\n{count} downloads in {elapsed:.2f}s')
if __name__ == '__main__':
main(download_many) ⑯
①
导入httpx库。它不是标准库的一部分,因此按照惯例,导入应放在标准库模块之后并空一行。
②
ISO 3166 国家代码列表,按人口递减顺序列出前 20 个人口最多的国家。
③
存放国旗图像的目录。⁴
④
图像保存的本地目录。
⑤
将img字节保存到DEST_DIR中的filename。
⑥
给定一个国家代码,构建 URL 并下载图像,返回响应的二进制内容。
⑦
为网络操作添加合理的超时是个好习惯,以避免无故阻塞几分钟。
⑧
默认情况下,HTTPX不会遵循重定向。⁵
⑨
这个脚本中没有错误处理,但是如果 HTTP 状态不在 2XX 范围内,此方法会引发异常——强烈建议避免静默失败。
⑩
download_many是用于比较并发实现的关键函数。
⑪
按字母顺序循环遍历国家代码列表,以便轻松查看输出中保留了顺序;返回下载的国家代码数量。
⑫
逐个显示一个国家代码,以便我们可以看到每次下载发生时的进度。end=' '参数用空格字符替换了通常在每行末尾打印的换行符,因此所有国家代码都逐步显示在同一行中。需要flush=True参数,因为默认情况下,Python 输出是行缓冲的,这意味着 Python 仅在换行后显示打印的字符。
⑬
必须使用将进行下载的函数调用main;这样,我们可以在threadpool和ascyncio示例中的其他download_many实现中将main用作库函数。
⑭
如果需要,创建DEST_DIR;如果目录已存在,则不会引发错误。
⑮
运行downloader函数后记录并报告经过的时间。
⑯
使用download_many函数调用main。
提示
HTTPX库受到 Pythonic requests包的启发,但建立在更现代的基础上。关键是,HTTPX提供同步和异步 API,因此我们可以在本章和下一章的所有 HTTP 客户端示例中使用它。Python 的标准库提供了urllib.request模块,但其 API 仅支持同步,并且不够用户友好。
flags.py 实际上没有什么新内容。它作为比较其他脚本的基准,并且我在实现它们时将其用作库,以避免冗余代码。现在让我们看看使用concurrent.futures重新实现的情况。
使用 concurrent.futures 进行下载
concurrent.futures包的主要特点是ThreadPoolExecutor和ProcessPoolExecutor类,它们实现了一个 API,用于在不同线程或进程中提交可调用对象进行执行。这些类透明地管理一组工作线程或进程以及队列来分发作业和收集结果。但接口非常高级,对于像我们的标志下载这样的简单用例,我们不需要了解任何这些细节。
示例 20-3 展示了实现并发下载的最简单方法,使用ThreadPoolExecutor.map方法。
示例 20-3. flags_threadpool.py:使用futures.ThreadPoolExecutor的线程下载脚本
from concurrent import futures
from flags import save_flag, get_flag, main # ①
def download_one(cc: str): # ②
image = get_flag(cc)
save_flag(image, f'{cc}.gif')
print(cc, end=' ', flush=True)
return cc
def download_many(cc_list: list[str]) -> int:
with futures.ThreadPoolExecutor() as executor: # ③
res = executor.map(download_one, sorted(cc_list)) # ④
return len(list(res)) # ⑤
if __name__ == '__main__':
main(download_many) # ⑥
①
从flags模块中重用一些函数(示例 20-2)。
②
用于下载单个图像的函数;这是每个工作线程将执行的内容。
③
将ThreadPoolExecutor实例化为上下文管理器;executor.__exit__方法将调用executor.shutdown(wait=True),这将阻塞直到所有线程完成。
④
map方法类似于内置的map,不同之处在于download_one函数将并发地从多个线程调用;它返回一个生成器,您可以迭代以检索每个函数调用返回的值—在本例中,每次调用download_one都将返回一个国家代码。
⑤
返回获得的结果数量。如果任何线程调用引发异常,当 list 构造函数内部的隐式 next() 调用尝试从 executor.map 返回的迭代器中检索相应的返回值时,异常会在此处引发。
⑥
从 flags 模块调用 main 函数,传递并发版本的 download_many。
请注意,来自 示例 20-3 的 download_one 函数本质上是来自 示例 20-2 中的 download_many 函数中 for 循环的主体。这是在编写并发代码时常见的重构:将顺序 for 循环的主体转换为一个要并发调用的函数。
提示
示例 20-3 非常简短,因为我能够重用顺序执行的 flags.py 脚本中的大部分函数。concurrent.futures 最好的特性之一是使得在传统的顺序代码之上添加并发执行变得简单。
ThreadPoolExecutor 构造函数接受几个未显示的参数,但第一个且最重要的是 max_workers,设置要执行的工作线程的最大数量。当 max_workers 为 None(默认值)时,ThreadPoolExecutor 使用以下表达式决定其值—自 Python 3.8 起:
max_workers = min(32, os.cpu_count() + 4)
这个理念在 ThreadPoolExecutor 文档 中有解释:
这个默认值至少保留了 5 个工作线程用于 I/O 绑定任务。对于释放 GIL 的 CPU 绑定任务,它最多利用 32 个 CPU 核心。它避免在多核机器上隐式使用非常大的资源。
ThreadPoolExecutor现在在启动max_workers工作线程之前重用空闲的工作线程。
总之:max_workers 的默认计算是合理的,ThreadPoolExecutor 避免不必要地启动新的工作线程。理解 max_workers 背后的逻辑可能会帮助您决定何时以及如何自行设置它。
这个库被称为 concurrency.futures,但在 示例 20-3 中看不到 futures,所以你可能会想知道它们在哪里。接下来的部分会解释。
未来在哪里?
Futures 是 concurrent.futures 和 asyncio 的核心组件,但作为这些库的用户,我们有时看不到它们。示例 20-3 在幕后依赖于 futures,但我编写的代码并没有直接涉及它们。本节是 futures 的概述,其中包含一个展示它们运作的示例。
自 Python 3.4 起,标准库中有两个名为 Future 的类:concurrent.futures.Future 和 asyncio.Future。它们的作用相同:Future 类的实例代表一个延迟计算,可能已经完成,也可能尚未完成。这在某种程度上类似于 Twisted 中的 Deferred 类、Tornado 中的 Future 类以及现代 JavaScript 中的 Promise。
Futures 封装了待处理的操作,以便我们可以将它们放入队列,检查它们是否完成,并在结果(或异常)可用时检索结果。
关于 futures 的一个重要事项是,你和我不应该创建它们:它们应该由并发框架专门实例化,无论是 concurrent.futures 还是 asyncio。原因在于:Future 代表着最终会运行的东西,因此必须安排其运行,这是框架的工作。特别是,concurrent.futures.Future 实例仅在使用 concurrent.futures.Executor 子类提交可调用对象以执行时才会创建。例如,Executor.submit() 方法接受一个可调用对象,安排其运行,并返回一个 Future。
应用代码不应该改变 future 的状态:当它所代表的计算完成时,并发框架会改变 future 的状态,我们无法控制何时发生这种情况。
两种类型的Future都有一个非阻塞的.done()方法,返回一个布尔值,告诉你被该future包装的可调用是否已执行。然而,客户端代码通常不会反复询问future是否完成,而是要求通知。这就是为什么两种Future类都有一个.add_done_callback()方法:你给它一个可调用对象,当future完成时,该可调用对象将以future作为唯一参数被调用。请注意,回调可调用对象将在运行包装在future中的函数的工作线程或进程中运行。
还有一个.result()方法,在future完成时两种类中的工作方式相同:它返回可调用对象的结果,或者在执行可调用对象时抛出的任何异常。然而,当future未完成时,result方法在两种Future的行为上有很大不同。在concurrency.futures.Future实例中,调用f.result()将阻塞调用者的线程,直到结果准备就绪。可以传递一个可选的timeout参数,如果在指定时间内future未完成,result方法将引发TimeoutError。asyncio.Future.result方法不支持超时,await是在asyncio中获取future结果的首选方式,但await不能与concurrency.futures.Future实例一起使用。
两个库中的几个函数返回future;其他函数在其实现中使用future的方式对用户来说是透明的。后者的一个例子是我们在示例 20-3 中看到的Executor.map:它返回一个迭代器,其中__next__调用每个future的result方法,因此我们得到future的结果,而不是future本身。
为了实际查看future,我们可以重写示例 20-3 以使用concurrent.futures.as_completed函数,该函数接受一个future的可迭代对象,并返回一个迭代器,按照完成的顺序产生future。
使用futures.as_completed仅需要更改download_many函数。高级executor.map调用被两个for循环替换:一个用于创建和调度future,另一个用于检索它们的结果。在此过程中,我们将添加一些print调用来显示每个future在完成前后的状态。示例 20-4 展示了新download_many函数的代码。download_many函数的代码从 5 行增长到 17 行,但现在我们可以检查神秘的future。其余函数与示例 20-3 中的相同。
示例 20-4. flags_threadpool_futures.py: 在download_many函数中用executor.submit和futures.as_completed替换executor.map。
def download_many(cc_list: list[str]) -> int:
cc_list = cc_list[:5] # ①
with futures.ThreadPoolExecutor(max_workers=3) as executor: # ②
to_do: list[futures.Future] = []
for cc in sorted(cc_list): # ③
future = executor.submit(download_one, cc) # ④
to_do.append(future) # ⑤
print(f'Scheduled for {cc}: {future}') # ⑥
for count, future in enumerate(futures.as_completed(to_do), 1): # ⑦
res: str = future.result() # ⑧
print(f'{future} result: {res!r}') # ⑨
return count
①
为了演示,只使用人口最多的前五个国家。
②
将max_workers设置为3,这样我们可以在输出中看到待处理的future。
③
按字母顺序遍历国家代码,以明确结果将无序到达。
④
executor.submit调度可调用对象的执行,并返回代表此挂起操作的future。
⑤
存储每个future,以便稍后使用as_completed检索它们。
⑥
显示带有国家代码和相应future的消息。
⑦
as_completed在future完成时产生future。
⑧
获取这个future的结果。
⑨
显示future及其结果。
注意,在这个例子中,future.result() 调用永远不会阻塞,因为 future 是从 as_completed 中出来的。示例 20-5 展示了示例 20-4 的一次运行的输出。
示例 20-5. flags_threadpool_futures.py 的输出
$ python3 flags_threadpool_futures.py
Scheduled for BR: <Future at 0x100791518 state=running> # ① Scheduled for CN: <Future at 0x100791710 state=running>
Scheduled for ID: <Future at 0x100791a90 state=running>
Scheduled for IN: <Future at 0x101807080 state=pending> # ② Scheduled for US: <Future at 0x101807128 state=pending>
CN <Future at 0x100791710 state=finished returned str> result: 'CN' # ③ BR ID <Future at 0x100791518 state=finished returned str> result: 'BR' # ④ <Future at 0x100791a90 state=finished returned str> result: 'ID'
IN <Future at 0x101807080 state=finished returned str> result: 'IN'
US <Future at 0x101807128 state=finished returned str> result: 'US'
5 downloads in 0.70s
①
未来按字母顺序安排;未来的 repr() 显示其状态:前三个是 running,因为有三个工作线程。
②
最后两个未来是 pending,等待工作线程。
③
这里的第一个 CN 是在工作线程中的 download_one 的输出;其余行是 download_many 的输出。
④
在主线程的 download_many 显示结果之前,两个线程在输出代码。
提示
我建议尝试 flags_threadpool_futures.py。如果你多次运行它,你会看到结果的顺序变化。将 max_workers 增加到 5 将增加结果顺序的变化。将其减少到 1 将使此脚本按顺序运行,结果的顺序将始终是 submit 调用的顺序。
我们看到了两个使用 concurrent.futures 的下载脚本变体:一个在示例 20-3 中使用 ThreadPoolExecutor.map,另一个在示例 20-4 中使用 futures.as_completed。如果你对 flags_asyncio.py 的代码感兴趣,可以查看第二十一章中的示例 21-3 进行了解。
现在让我们简要看一下使用 concurrent.futures 绕过 GIL 处理 CPU 密集型任务的简单方法。
使用 concurrent.futures 启动进程
concurrent.futures 文档页面 的副标题是“启动并行任务”。该软件包支持在多核计算机上进行并行计算,因为它支持使用 ProcessPoolExecutor 类在多个 Python 进程之间分发工作。
ProcessPoolExecutor 和 ThreadPoolExecutor 都实现了Executor 接口,因此使用 concurrent.futures 从基于线程的解决方案切换到基于进程的解决方案很容易。
对于下载标志示例或任何 I/O 密集型任务,使用 ProcessPoolExecutor 没有优势。很容易验证这一点;只需更改示例 20-3 中的这些行:
def download_many(cc_list: list[str]) -> int:
with futures.ThreadPoolExecutor() as executor:
到这里:
def download_many(cc_list: list[str]) -> int:
with futures.ProcessPoolExecutor() as executor:
ProcessPoolExecutor 的构造函数也有一个 max_workers 参数,默认为 None。在这种情况下,执行器将工作进程的数量限制为 os.cpu_count() 返回的数量。
进程使用更多内存,启动时间比线程长,所以 ProcessPoolExecutor 的真正价值在于 CPU 密集型任务。让我们回到“自制进程池”中的素数检查示例,使用 concurrent.futures 重新编写它。
多核素数检查器 Redux
在“多核素数检查器的代码”中,我们研究了 procs.py,一个使用 multiprocessing 检查一些大数的素数性质的脚本。在示例 20-6 中,我们使用 ProcessPoolExecutor 在 proc_pool.py 程序中解决了相同的问题。从第一个导入到最后的 main() 调用,procs.py 有 43 行非空代码,而 proc_pool.py 只有 31 行,比原来的短了 28%。
示例 20-6. proc_pool.py: procs.py 使用 ProcessPoolExecutor 重写
import sys
from concurrent import futures # ①
from time import perf_counter
from typing import NamedTuple
from primes import is_prime, NUMBERS
class PrimeResult(NamedTuple): # ②
n: int
flag: bool
elapsed: float
def check(n: int) -> PrimeResult:
t0 = perf_counter()
res = is_prime(n)
return PrimeResult(n, res, perf_counter() - t0)
def main() -> None:
if len(sys.argv) < 2:
workers = None # ③
else:
workers = int(sys.argv[1])
executor = futures.ProcessPoolExecutor(workers) # ④
actual_workers = executor._max_workers # type: ignore # ⑤
print(f'Checking {len(NUMBERS)} numbers with {actual_workers} processes:')
t0 = perf_counter()
numbers = sorted(NUMBERS, reverse=True) # ⑥
with executor: # ⑦
for n, prime, elapsed in executor.map(check, numbers): # ⑧
label = 'P' if prime else ' '
print(f'{n:16} {label} {elapsed:9.6f}s')
time = perf_counter() - t0
print(f'Total time: {time:.2f}s')
if __name__ == '__main__':
main()
①
不需要导入 multiprocessing、SimpleQueue 等;concurrent.futures 隐藏了所有这些。
②
PrimeResult元组和check函数与procs.py中看到的相同,但我们不再需要队列和worker函数。
③
如果没有给出命令行参数,我们不再决定使用多少工作进程,而是将workers设置为None,让ProcessPoolExecutor自行决定。
④
在➐中我在with块之前构建了ProcessPoolExecutor,这样我就可以在下一行显示实际的工作进程数。
⑤
_max_workers是ProcessPoolExecutor的一个未记录的实例属性。我决定使用它来显示workers变量为None时的工作进程数。Mypy在我访问它时正确地抱怨,所以我放了type: ignore注释来消除警告。
⑥
将要检查的数字按降序排序。这将揭示proc_pool.py与procs.py在行为上的差异。请参见本示例后的解释。
⑦
使用executor作为上下文管理器。
⑧
executor.map调用返回由check返回的PrimeResult实例,顺序与numbers参数相同。
如果你运行示例 20-6,你会看到结果严格按降序出现,就像示例 20-7 中所示。相比之下,procs.py的输出顺序(在“基于进程的解决方案”中显示)受到检查每个数字是否为质数的难度的影响。例如,procs.py在顶部显示了 7777777777777777 的结果,因为它有一个较低的除数 7,所以is_prime很快确定它不是质数。
相比之下,7777777536340681 是 88191709²,因此is_prime将花费更长的时间来确定它是一个合数,甚至更长的时间来找出 7777777777777753 是质数—因此这两个数字都出现在procs.py输出的末尾。
运行proc_pool.py,你会观察到结果严格按降序排列,但在显示 9999999999999999 的结果后,程序似乎会卡住。
示例 20-7. proc_pool.py 的输出
$ ./proc_pool.py
Checking 20 numbers with 12 processes:
9999999999999999 0.000024s # ① 9999999999999917 P 9.500677s # ② 7777777777777777 0.000022s # ③ 7777777777777753 P 8.976933s
7777777536340681 8.896149s
6666667141414921 8.537621s
6666666666666719 P 8.548641s
6666666666666666 0.000002s
5555555555555555 0.000017s
5555555555555503 P 8.214086s
5555553133149889 8.067247s
4444444488888889 7.546234s
4444444444444444 0.000002s
4444444444444423 P 7.622370s
3333335652092209 6.724649s
3333333333333333 0.000018s
3333333333333301 P 6.655039s
299593572317531 P 2.072723s
142702110479723 P 1.461840s
2 P 0.000001s
Total time: 9.65s
①
这行出现得非常快。
②
这行需要超过 9.5 秒才能显示出来。
③
所有剩下的行几乎立即出现。
这就是proc_pool.py表现出这种方式的原因:
-
如前所述,
executor.map(check, numbers)返回的结果与给定的numbers顺序相同。 -
默认情况下,proc_pool.py使用与 CPU 数量相同的工作进程数——当
max_workers为None时,这就是ProcessPoolExecutor的做法。在这台笔记本电脑上是 12 个进程。 -
因为我们按降序提交
numbers,第一个是 9999999999999999;以 9 为除数,它会迅速返回。 -
第二个数字是 9999999999999917,样本中最大的质数。这将比所有其他数字检查花费更长的时间。
-
与此同时,其余的 11 个进程将检查其他数字,这些数字要么是质数,要么是具有大因子的合数,要么是具有非常小因子的合数。
-
当负责 9999999999999917 的工作进程最终确定那是一个质数时,所有其他进程已经完成了最后的工作,因此结果会立即显示出来。
注意
尽管proc_pool.py的进度不像procs.py那样明显,但对于相同数量的工作进程和 CPU 核心,总体执行时间几乎与图 19-2 中描述的相同。
理解并发程序的行为并不直接,因此这里有第二个实验,可以帮助你可视化Executor.map的操作。
试验Executor.map
让我们来研究Executor.map,现在使用一个具有三个工作线程的ThreadPoolExecutor运行五个可调用函数,输出带时间戳的消息。代码在示例 20-8 中,输出在示例 20-9 中。
示例 20-8。demo_executor_map.py:ThreadPoolExecutor的map方法的简单演示。
from time import sleep, strftime
from concurrent import futures
def display(*args): # ①
print(strftime('[%H:%M:%S]'), end=' ')
print(*args)
def loiter(n): # ②
msg = '{}loiter({}): doing nothing for {}s...'
display(msg.format('\t'*n, n, n))
sleep(n)
msg = '{}loiter({}): done.'
display(msg.format('\t'*n, n))
return n * 10 # ③
def main():
display('Script starting.')
executor = futures.ThreadPoolExecutor(max_workers=3) # ④
results = executor.map(loiter, range(5)) # ⑤
display('results:', results) # ⑥
display('Waiting for individual results:')
for i, result in enumerate(results): # ⑦
display(f'result {i}: {result}')
if __name__ == '__main__':
main()
①
这个函数简单地打印出它收到的任何参数,前面加上格式为[HH:MM:SS]的时间戳。
②
loiter除了在开始时显示消息、休眠n秒,然后在结束时显示消息外什么也不做;制表符用于根据n的值缩进消息。
③
loiter返回n * 10,因此我们可以看到如何收集结果。
④
创建一个具有三个线程的ThreadPoolExecutor。
⑤
向executor提交五个任务。由于只有三个线程,因此只有其中三个任务会立即启动:调用loiter(0)、loiter(1)和loiter(2);这是一个非阻塞调用。
⑥
立即显示调用executor.map的results:它是一个生成器,正如示例 20-9 中的输出所示。
⑦
for循环中的enumerate调用将隐式调用next(results),这将进而在(内部的)代表第一个调用loiter(0)的_f future 上调用_f.result()。result方法将阻塞直到 future 完成,因此此循环中的每次迭代都必须等待下一个结果准备就绪。
鼓励你运行示例 20-8,看到显示逐步更新。在此过程中,尝试调整ThreadPoolExecutor的max_workers参数以及产生executor.map调用参数的range函数,或者用手动选择的值列表替换它以创建不同的延迟。
示例 20-9 展示了示例 20-8 的一个运行示例。
示例 20-9。来自示例 20-8 的 demo_executor_map.py 的示例运行。
$ python3 demo_executor_map.py
[15:56:50] Script starting. # ① [15:56:50] loiter(0): doing nothing for 0s... # ② [15:56:50] loiter(0): done.
[15:56:50] loiter(1): doing nothing for 1s... # ③ [15:56:50] loiter(2): doing nothing for 2s...
[15:56:50] results: <generator object result_iterator at 0x106517168> # ④ [15:56:50] loiter(3): doing nothing for 3s... # ⑤ [15:56:50] Waiting for individual results:
[15:56:50] result 0: 0 # ⑥ [15:56:51] loiter(1): done. # ⑦ [15:56:51] loiter(4): doing nothing for 4s...
[15:56:51] result 1: 10 # ⑧ [15:56:52] loiter(2): done. # ⑨ [15:56:52] result 2: 20
[15:56:53] loiter(3): done.
[15:56:53] result 3: 30
[15:56:55] loiter(4): done. # ⑩ [15:56:55] result 4: 40
①
此运行开始于 15:56:50。
②
第一个线程执行loiter(0),因此它将休眠 0 秒并在第二个线程有机会启动之前返回,但结果可能有所不同。⁶
③
loiter(1)和loiter(2)立即启动(因为线程池有三个工作线程,可以同时运行三个函数)。
④
这表明executor.map返回的results是一个生成器;到目前为止,无论任务数量和max_workers设置如何,都不会阻塞。
⑤
因为loiter(0)已经完成,第一个工作线程现在可以开始第四个线程执行loiter(3)。
⑥
这是执行可能会阻塞的地方,取决于给loiter调用的参数:results生成器的__next__方法必须等待第一个 future 完成。在这种情况下,它不会阻塞,因为对loiter(0)的调用在此循环开始之前已经完成。请注意,到目前为止,所有操作都发生在同一秒内:15:56:50。
⑦
一秒钟后,loiter(1)完成,在 15:56:51。线程被释放以启动loiter(4)。
⑧
loiter(1)的结果显示为:10。现在for循环将阻塞等待loiter(2)的结果。
⑨
模式重复:loiter(2)完成,显示其结果;loiter(3)也是如此。
⑩
直到loiter(4)完成前有 2 秒的延迟,因为它在 15:56:51 开始,并且 4 秒内什么也没做。
Executor.map函数易于使用,但通常最好在准备就绪时获取结果,而不考虑提交的顺序。为此,我们需要Executor.submit方法和futures.as_completed函数的组合,正如我们在 Example 20-4 中看到的那样。我们将在“使用 futures.as_completed”中回到这种技术。
提示
executor.submit和futures.as_completed的组合比executor.map更灵活,因为您可以submit不同的可调用函数和参数,而executor.map设计为在不同的参数上运行相同的可调用函数。此外,您传递给futures.as_completed的 future 集合可能来自多个执行器——也许一些是由ThreadPoolExecutor实例创建的,而其他一些来自ProcessPoolExecutor。
在下一节中,我们将使用新要求恢复标志下载示例,这将迫使我们迭代futures.as_completed的结果,而不是使用executor.map。
带有进度显示和错误处理的下载
如前所述,“并发 Web 下载”中的脚本没有错误处理,以使其更易于阅读,并对比三种方法的结构:顺序,线程和异步。
为了测试处理各种错误条件,我创建了flags2示例:
flags2_common.py
该模块包含所有flags2示例中使用的常见函数和设置,包括一个main函数,负责命令行解析,计时和报告结果。这实际上是支持代码,与本章主题无直接关系,因此我不会在这里列出源代码,但您可以在fluentpython/example-code-2e存储库中阅读:20-executors/getflags/flags2_common.py。
flags2_sequential.py
具有适当错误处理和进度条显示的顺序 HTTP 客户端。其download_one函数也被flags2_threadpool.py使用。
flags2_threadpool.py
基于futures.ThreadPoolExecutor的并发 HTTP 客户端,用于演示错误处理和进度条的集成。
flags2_asyncio.py
与上一个示例具有相同功能,但使用asyncio和httpx实现。这将在“增强 asyncio 下载器”中介绍,在第二十一章中。
在测试并发客户端时要小心
在公共 Web 服务器上测试并发 HTTP 客户端时,您可能每秒生成许多请求,这就是拒绝服务(DoS)攻击的方式。在命中公共服务器时,请谨慎限制您的客户端。对于测试,请设置本地 HTTP 服务器。有关说明,请参阅“设置测试服务器”。
flags2示例最显著的特点是它们具有一个使用tqdm包实现的动画文本模式进度条。我在 YouTube 上发布了一个108 秒的视频来展示进度条,并对比三个flags2脚本的速度。在视频中,我从顺序下载开始,但在 32 秒后中断了,因为要花费超过 5 分钟才能访问 676 个 URL 并获取 194 个标志。然后我分别运行了线程和asyncio脚本三次,每次都在 6 秒内完成任务(即,速度超过 60 倍)。图 20-1 显示了两个屏幕截图:运行flags2_threadpool.py时和脚本完成后。

图 20-1。左上角:flags2_threadpool.py 运行时由 tqdm 生成的实时进度条;右下角:脚本完成后相同的终端窗口。
最简单的tqdm示例出现在项目的README.md中的动画.gif中。如果在安装了tqdm包后在 Python 控制台中输入以下代码,您将看到一个动画进度条,其中的注释是:
>>> import time
>>> from tqdm import tqdm
>>> for i in tqdm(range(1000)):
... time.sleep(.01)
...
>>> # -> progress bar will appear here <-
除了整洁的效果外,tqdm函数在概念上也很有趣:它消耗任何可迭代对象,并生成一个迭代器,当它被消耗时,显示进度条并估计完成所有迭代所需的剩余时间。为了计算这个估计值,tqdm需要获得一个具有len的可迭代对象,或者另外接收期望的项目数量作为total=参数。将tqdm与我们的flags2示例集成提供了一个机会,深入了解并发脚本的实际工作原理,强制我们使用futures.as_completed和asyncio.as_completed函数,以便tqdm可以在每个未来完成时显示进度。
flags2示例的另一个特点是命令行界面。所有三个脚本都接受相同的选项,您可以通过在任何脚本中使用-h选项来查看它们。示例 20-10 显示了帮助文本。
示例 20-10。flags2 系列脚本的帮助界面
$ python3 flags2_threadpool.py -h
usage: flags2_threadpool.py [-h] [-a] [-e] [-l N] [-m CONCURRENT] [-s LABEL]
[-v]
[CC [CC ...]]
Download flags for country codes. Default: top 20 countries by population.
positional arguments:
CC country code or 1st letter (eg. B for BA...BZ)
optional arguments:
-h, --help show this help message and exit
-a, --all get all available flags (AD to ZW)
-e, --every get flags for every possible code (AA...ZZ)
-l N, --limit N limit to N first codes
-m CONCURRENT, --max_req CONCURRENT
maximum concurrent requests (default=30)
-s LABEL, --server LABEL
Server to hit; one of DELAY, ERROR, LOCAL, REMOTE
(default=LOCAL)
-v, --verbose output detailed progress info
所有参数都是可选的。但-s/--server对于测试是必不可少的:它让您选择在测试中使用哪个 HTTP 服务器和端口。传递这些不区分大小写的标签之一,以确定脚本将在哪里查找标志:
本地
使用http://localhost:8000/flags;这是默认设置。您应该配置一个本地 HTTP 服务器以在端口 8000 回答。查看以下说明。
远程
使用http://fluentpython.com/data/flags;这是我拥有的一个公共网站,托管在共享服务器上。请不要对其进行过多的并发请求。fluentpython.com域名由Cloudflare CDN(内容交付网络)处理,因此您可能会注意到初始下载速度较慢,但当 CDN 缓存热身时速度会加快。
延迟
使用http://localhost:8001/flags;一个延迟 HTTP 响应的服务器应该监听端口 8001。我编写了slow_server.py来使实验更加容易。您可以在Fluent Python代码库的20-futures/getflags/目录中找到它。查看以下说明。
错误
使用http://localhost:8002/flags;一个返回一些 HTTP 错误的服务器应该监听端口 8002。接下来是说明。
设置测试服务器
如果您没有用于测试的本地 HTTP 服务器,我在fluentpython/example-code-2e代码库的20-executors/getflags/README.adoc中使用仅 Python ≥ 3.9(无外部库)编写了设置说明。简而言之,README.adoc描述了如何使用:
python3 -m http.server
本地服务器端口 8000
python3 slow_server.py
在端口 8001 上的DELAY服务器,在每个响应之前增加随机延迟 0.5 秒至 5 秒
python3 slow_server.py 8002 --error-rate .25
在端口 8002 上的ERROR服务器,除了随机延迟外,还有 25%的几率返回“418 我是一个茶壶”错误响应
默认情况下,每个flags2.py脚本将使用默认的并发连接数从LOCAL服务器(http://localhost:8000/flags)获取人口最多的 20 个国家的标志,这在脚本之间有所不同。示例 20-11 展示了使用所有默认值运行flags2_sequential.py*脚本的示例。要运行它,您需要一个本地服务器,如“测试并发客户端时要小心”中所解释的那样。
示例 20-11. 使用所有默认值运行 flags2_sequential.py:LOCAL 站点,前 20 个标志,1 个并发连接
$ python3 flags2_sequential.py
LOCAL site: http://localhost:8000/flags
Searching for 20 flags: from BD to VN
1 concurrent connection will be used.
--------------------
20 flags downloaded.
Elapsed time: 0.10s
您可以通过多种方式选择要下载的标志。示例 20-12 展示了如何下载所有以字母 A、B 或 C 开头的国家代码的标志。
示例 20-12. 运行 flags2_threadpool.py 从DELAY服务器获取所有以 A、B 或 C 开头的国家代码前缀的标志
$ python3 flags2_threadpool.py -s DELAY a b c
DELAY site: http://localhost:8001/flags
Searching for 78 flags: from AA to CZ
30 concurrent connections will be used.
--------------------
43 flags downloaded.
35 not found.
Elapsed time: 1.72s
无论如何选择国家代码,要获取的标志数量都可以通过-l/--limit选项限制。示例 20-13 演示了如何运行确切的 100 个请求,结合-a选项获取所有标志和-l 100。
示例 20-13. 运行 flags2_asyncio.py 从ERROR服务器获取 100 个标志(-al 100),使用 100 个并发请求(-m 100)
$ python3 flags2_asyncio.py -s ERROR -al 100 -m 100
ERROR site: http://localhost:8002/flags
Searching for 100 flags: from AD to LK
100 concurrent connections will be used.
--------------------
73 flags downloaded.
27 errors.
Elapsed time: 0.64s
这是flags2示例的用户界面。让我们看看它们是如何实现的。
flags2 示例中的错误处理
处理 flags2 示例中所有三个示例中 HTTP 错误的常见策略是,404 错误(未找到)由负责下载单个文件的函数(download_one)处理。任何其他异常都会传播以由download_many函数或supervisor协程处理—在asyncio示例中。
再次,我们将从研究顺序代码开始,这样更容易跟踪—并且大部分被线程池脚本重用。示例 20-14 展示了在flags2_sequential.py和flags2_threadpool.py脚本中执行实际下载的函数。
示例 20-14. flags2_sequential.py:负责下载的基本函数;两者在 flags2_threadpool.py 中都被重用
from collections import Counter
from http import HTTPStatus
import httpx
import tqdm # type: ignore # ①
from flags2_common import main, save_flag, DownloadStatus # ②
DEFAULT_CONCUR_REQ = 1
MAX_CONCUR_REQ = 1
def get_flag(base_url: str, cc: str) -> bytes:
url = f'{base_url}/{cc}/{cc}.gif'.lower()
resp = httpx.get(url, timeout=3.1, follow_redirects=True)
resp.raise_for_status() # ③
return resp.content
def download_one(cc: str, base_url: str, verbose: bool = False) -> DownloadStatus:
try:
image = get_flag(base_url, cc)
except httpx.HTTPStatusError as exc: # ④
res = exc.response
if res.status_code == HTTPStatus.NOT_FOUND:
status = DownloadStatus.NOT_FOUND # ⑤
msg = f'not found: {res.url}'
else:
raise # ⑥
else:
save_flag(image, f'{cc}.gif')
status = DownloadStatus.OK
msg = 'OK'
if verbose: # ⑦
print(cc, msg)
return status
①
导入tqdm进度条显示库,并告诉 Mypy 跳过检查它。⁷
②
从flags2_common模块导入一对函数和一个Enum。
③
如果 HTTP 状态码不在range(200, 300)中,则引发HTTPStetusError。
④
download_one捕获HTTPStatusError以处理特定的 HTTP 代码 404…
⑤
通过将其本地status设置为DownloadStatus.NOT_FOUND来处理; DownloadStatus是从flags2_common.py导入的Enum。
⑥
其他任何HTTPStatusError异常都会重新引发以传播给调用者。
⑦
如果设置了-v/--verbose命令行选项,则显示国家代码和状态消息;这是您在详细模式下看到进度的方式。
示例 20-15 列出了download_many函数的顺序版本。这段代码很简单,但值得研究,以与即将出现的并发版本进行对比。关注它如何报告进度,处理错误和统计下载量。
示例 20-15. flags2_sequential.py:download_many的顺序实现
def download_many(cc_list: list[str],
base_url: str,
verbose: bool,
_unused_concur_req: int) -> Counter[DownloadStatus]:
counter: Counter[DownloadStatus] = Counter() # ①
cc_iter = sorted(cc_list) # ②
if not verbose:
cc_iter = tqdm.tqdm(cc_iter) # ③
for cc in cc_iter:
try:
status = download_one(cc, base_url, verbose) # ④
except httpx.HTTPStatusError as exc: # ⑤
error_msg = 'HTTP error {resp.status_code} - {resp.reason_phrase}'
error_msg = error_msg.format(resp=exc.response)
except httpx.RequestError as exc: # ⑥
error_msg = f'{exc} {type(exc)}'.strip()
except KeyboardInterrupt: # ⑦
break
else: # ⑧
error_msg = ''
if error_msg:
status = DownloadStatus.ERROR # ⑨
counter[status] += 1 # ⑩
if verbose and error_msg: ⑪
print(f'{cc} error: {error_msg}')
return counter ⑫
①
这个Counter将统计不同的下载结果:DownloadStatus.OK、DownloadStatus.NOT_FOUND或DownloadStatus.ERROR。
②
cc_iter保存按字母顺序排列的国家代码列表。
③
如果不在详细模式下运行,将cc_iter传递给tqdm,它会返回一个迭代器,该迭代器会产生cc_iter中的项目,并同时显示进度条。
④
连续调用download_one。
⑤
由get_flag引发的 HTTP 状态码异常,且未被download_one处理的异常在此处理。
⑥
其他与网络相关的异常在此处理。任何其他异常都会中止脚本,因为调用download_many的flags2_common.main函数没有try/except。
⑦
如果用户按下 Ctrl-C,则退出循环。
⑧
如果download_one没有发生异常,清除错误消息。
⑨
如果发生错误,相应地设置本地status。
⑩
为该status增加计数。
⑪
在详细模式下,显示当前国家代码的错误消息(如果有)。
⑫
返回counter,以便main函数可以在最终报告中显示数字。
我们现在将学习重构后的线程池示例,flags2_threadpool.py。
使用futures.as_completed
为了集成tqdm进度条并处理每个请求的错误,flags2_threadpool.py脚本使用了futures.ThreadPoolExecutor和我们已经见过的futures.as_completed函数。示例 20-16 是flags2_threadpool.py的完整代码清单。只实现了download_many函数;其他函数是从flags2_common.py和flags2_sequential.py中重用的。
示例 20-16. flags2_threadpool.py:完整代码清单
from collections import Counter
from concurrent.futures import ThreadPoolExecutor, as_completed
import httpx
import tqdm # type: ignore
from flags2_common import main, DownloadStatus
from flags2_sequential import download_one # ①
DEFAULT_CONCUR_REQ = 30 # ②
MAX_CONCUR_REQ = 1000 # ③
def download_many(cc_list: list[str],
base_url: str,
verbose: bool,
concur_req: int) -> Counter[DownloadStatus]:
counter: Counter[DownloadStatus] = Counter()
with ThreadPoolExecutor(max_workers=concur_req) as executor: # ④
to_do_map = {} # ⑤
for cc in sorted(cc_list): # ⑥
future = executor.submit(download_one, cc,
base_url, verbose) # ⑦
to_do_map[future] = cc # ⑧
done_iter = as_completed(to_do_map) # ⑨
if not verbose:
done_iter = tqdm.tqdm(done_iter, total=len(cc_list)) # ⑩
for future in done_iter: ⑪
try:
status = future.result() ⑫
except httpx.HTTPStatusError as exc: ⑬
error_msg = 'HTTP error {resp.status_code} - {resp.reason_phrase}'
error_msg = error_msg.format(resp=exc.response)
except httpx.RequestError as exc:
error_msg = f'{exc} {type(exc)}'.strip()
except KeyboardInterrupt:
break
else:
error_msg = ''
if error_msg:
status = DownloadStatus.ERROR
counter[status] += 1
if verbose and error_msg:
cc = to_do_map[future] ⑭
print(f'{cc} error: {error_msg}')
return counter
if __name__ == '__main__':
main(download_many, DEFAULT_CONCUR_REQ, MAX_CONCUR_REQ)
①
从flags2_sequential中重用download_one(示例 20-14)。
②
如果没有给出-m/--max_req命令行选项,这将是最大并发请求的数量,实现为线程池的大小;如果要下载的标志数量较少,实际数量可能会更小。
③
MAX_CONCUR_REQ限制了最大并发请求的数量,不管要下载的标志数量或-m/--max_req命令行选项的值如何。这是为了避免启动过多线程带来的显著内存开销的安全预防措施。
④
使用max_workers设置为由main函数计算的concur_req创建executor,concur_req是以下两者中较小的一个:MAX_CONCUR_REQ、cc_list的长度,或者-m/--max_req命令行选项的值。这样可以避免创建过多的线程。
⑤
这个dict将把每个代表一个下载的Future实例与相应的国家代码进行映射,以便进行错误报告。
⑥
按字母顺序遍历国家代码列表。结果的顺序将取决于 HTTP 响应的时间,但如果线程池的大小(由concur_req给出)远小于len(cc_list),您可能会注意到按字母顺序批量下载。
⑦
每次调用 executor.submit 都会安排一个可调用函数的执行,并返回一个 Future 实例。第一个参数是可调用函数,其余参数是它将接收的参数。
⑧
将 future 和国家代码存储在 dict 中。
⑨
futures.as_completed 返回一个迭代器,每当任务完成时就会产生一个 future。
⑩
如果不处于详细模式,将 as_completed 的结果用 tqdm 函数包装起来以显示进度条;因为 done_iter 没有 len,我们必须告诉 tqdm 预期的项目数量是多少,作为 total= 参数,这样 tqdm 就可以估计剩余的工作量。
⑪
遍历已完成的 futures。
⑫
在 future 上调用 result 方法会返回可调用函数的返回值,或者在执行可调用函数时捕获的任何异常。这个方法可能会阻塞等待解决,但在这个例子中不会,因为 as_completed 只返回已完成的 future。
⑬
处理潜在的异常;这个函数的其余部分与示例 20-15)中的顺序 download_many 相同,除了下一个 callout。
⑭
为了提供错误消息的上下文,使用当前的 future 作为键从 to_do_map 中检索国家代码。这在顺序版本中是不必要的,因为我们是在国家代码列表上进行迭代,所以我们知道当前的 cc;而在这里我们是在 futures 上进行迭代。
提示
示例 20-16 使用了一个在 futures.as_completed 中非常有用的习语:构建一个 dict 来将每个 future 映射到在 future 完成时可能有用的其他数据。这里的 to_do_map 将每个 future 映射到分配给它的国家代码。这使得很容易对 futures 的结果进行后续处理,尽管它们是无序生成的。
Python 线程非常适合 I/O 密集型应用程序,而 concurrent.futures 包使得在某些用例中相对简单地使用它变得可能。通过 ProcessPoolExecutor,您还可以在多个核心上解决 CPU 密集型问题——如果计算是“尴尬地并行”的话。这结束了我们对 concurrent.futures 的基本介绍。
章节总结
我们通过比较两个并发的 HTTP 客户端和一个顺序的客户端来开始本章,演示了并发解决方案相对于顺序脚本显示出的显著性能提升。
在学习基于 concurrent.futures 的第一个例子之后,我们更仔细地研究了 future 对象,无论是 concurrent.futures.Future 的实例还是 asyncio.Future,强调了这些类有什么共同之处(它们的差异将在第二十一章中强调)。我们看到如何通过调用 Executor.submit 创建 futures,并使用 concurrent.futures.as_completed 迭代已完成的 futures。
然后,我们讨论了如何使用 concurrent.futures.ProcessPoolExecutor 类与多个进程一起工作,绕过 GIL 并使用多个 CPU 核心来简化我们在第十九章中首次看到的多核素数检查器。
在接下来的部分中,我们看到了 concurrent.futures.ThreadPoolExecutor 如何通过一个示教性的例子工作,启动了几个任务,这些任务只是等待几秒钟,除了显示它们的状态和时间戳。
接下来我们回到了下载标志的示例。通过增加进度条和适当的错误处理来增强它们,促使进一步探索future.as_completed生成器函数,展示了一个常见模式:在提交时将 futures 存储在dict中以将进一步信息链接到它们,这样我们可以在 future 从as_completed迭代器中出来时使用该信息。
进一步阅读
concurrent.futures包是由 Brian Quinlan 贡献的,他在 PyCon Australia 2010 年的一次名为“未来即将到来!”的精彩演讲中介绍了它。Quinlan 的演讲没有幻灯片;他通过在 Python 控制台中直接输入代码来展示库的功能。作为一个激励性的例子,演示中展示了一个短视频,其中 XKCD 漫画家/程序员 Randall Munroe 无意中对 Google 地图发起了 DoS 攻击,以构建他所在城市周围的驾驶时间彩色地图。该库的正式介绍是PEP 3148 - futures - 异步执行计算。在 PEP 中,Quinlan 写道,concurrent.futures库“受到了 Javajava.util.concurrent包的重大影响。”
有关concurrent.futures的其他资源,请参阅第十九章。所有涵盖 Python 的threading和multiprocessing的参考资料也包括“使用线程和进程进行并发处理”。
¹ 来自 Michele Simionato 的帖子“Python 中的线程、进程和并发性:一些思考”,总结为“消除多核(非)革命周围的炒作以及关于线程和其他形式并发性的一些(希望是)明智的评论。”
² 特别是如果您的云服务提供商按秒租用机器,而不管 CPU 有多忙。
³ 对于可能受到许多客户端攻击的服务器,有一个区别:协程比线程更具扩展性,因为它们使用的内存比线程少得多,并且还减少了上下文切换的成本,我在“基于线程的非解决方案”中提到过。
⁴ 这些图片最初来自CIA 世界概况,这是一份公共领域的美国政府出版物。我将它们复制到我的网站上,以避免对cia.gov发起 DOS 攻击的风险。
⁵ 设置follow_redirects=True对于这个示例并不需要,但我想强调HTTPX和requests之间的这个重要区别。此外,在这个示例中设置follow_redirects=True给了我将来在其他地方托管图像文件的灵活性。我认为HTTPX默认设置为follow_redirects=False是明智的,因为意外的重定向可能掩盖不必要的请求并复杂化错误诊断。
⁶ 你的体验可能有所不同:使用线程,你永远不知道几乎同时发生的事件的确切顺序;在另一台机器上,可能会看到loiter(1)在loiter(0)完成之前开始,特别是因为sleep总是释放 GIL,所以即使你睡眠 0 秒,Python 也可能切换到另一个线程。
⁷ 截至 2021 年 9 月,当前版本的tdqm中没有类型提示。没关系。世界不会因此而终结。感谢 Guido 提供可选类型提示!
⁸ 来自 PyCon 2009 年演示的“关于协程和并发性的一门好奇课程”教程的幻灯片#9。
第二十一章:异步编程
异步编程的常规方法的问题在于它们是全有或全无的命题。你要么重写所有代码以便没有阻塞,要么你只是在浪费时间。
Alvaro Videla 和 Jason J. W. Williams,《RabbitMQ 实战》¹
本章涉及三个密切相关的主题:
-
Python 的
async def,await,async with和async for构造 -
支持这些构造的对象:原生协程和异步上下文管理器、可迭代对象、生成器和推导式的异步变体
-
asyncio和其他异步库
本章建立在可迭代对象和生成器的思想上(第十七章,特别是“经典协程”),上下文管理器(第十八章),以及并发编程的一般概念(第十九章)。
我们将研究类似于我们在第二十章中看到的并发 HTTP 客户端,使用原生协程和异步上下文管理器进行重写,使用与之前相同的HTTPX库,但现在通过其异步 API。我们还将看到如何通过将慢速操作委托给线程或进程执行器来避免阻塞事件循环。
在 HTTP 客户端示例之后,我们将看到两个简单的异步服务器端应用程序,其中一个使用越来越受欢迎的FastAPI框架。然后我们将介绍由async/await关键字启用的其他语言构造:异步生成器函数,异步推导式和异步生成器表达式。为了强调这些语言特性与asyncio无关的事实,我们将看到一个示例被重写以使用Curio——由 David Beazley 发明的优雅而创新的异步框架。
最后,我写了一个简短的部分来总结异步编程的优势和陷阱。
这是很多内容要涵盖的。我们只有空间来展示基本示例,但它们将说明每个想法的最重要特点。
提示
asyncio文档在 Yury Selivanov²重新组织后要好得多,将对应用程序开发者有用的少数函数与用于创建诸如 Web 框架和数据库驱动程序的低级 API 分开。
对于asyncio的书籍长度覆盖,我推荐 Caleb Hattingh(O’Reilly)的在 Python 中使用 Asyncio。完全透明:Caleb 是本书的技术审阅者之一。
本章的新内容
当我写第一版流畅的 Python时,asyncio库是临时的,async/await关键字不存在。因此,我不得不更新本章中的所有示例。我还创建了新的示例:域探测脚本,FastAPI网络服务以及与 Python 的新异步控制台模式的实验。
新的章节涵盖了当时不存在的语言特性,如原生协程、async with、async for以及支持这些构造的对象。
“异步工作原理及其不足之处”中的思想反映了我认为对于任何使用异步编程的人来说都是必读的艰辛经验。它们可能会为你节省很多麻烦——无论你是使用 Python 还是 Node.js。
最后,我删除了关于asyncio.Futures的几段内容,这现在被认为是低级asyncioAPI 的一部分。
一些定义
在“经典协程”的开头,我们看到 Python 3.5 及更高版本提供了三种协程类型:
原生协程
使用async def定义的协程函数。您可以使用await关键字从一个本机协程委托到另一个本机协程,类似于经典协程使用yield from。async def语句始终定义一个本机协程,即使在其主体中未使用await关键字。await关键字不能在本机协程之外使用。³
经典协程
一个生成器函数,通过my_coro.send(data)调用接收发送给它的数据,并通过在表达式中使用yield来读取该数据。经典协程可以使用yield from委托给其他经典协程。经典协程不能由await驱动,并且不再受asyncio支持。
基于生成器的协程
使用@types.coroutine装饰的生成器函数—在 Python 3.5 中引入。该装饰器使生成器与新的await关键字兼容。
在本章中,我们专注于本机协程以及异步生成器:
异步生成器
使用async def定义的生成器函数,在其主体中使用yield。它返回一个提供__anext__的异步生成器对象,这是一个用于检索下一个项目的协程方法。
@asyncio.coroutine 没有未来⁴
对于经典协程和基于生成器的协程,@asyncio.coroutine装饰器在 Python 3.8 中已被弃用,并计划在 Python 3.11 中删除,根据Issue 43216。相反,根据Issue 36921,@types.coroutine应该保留。它不再受asyncio支持,但在Curio和Trio异步框架的低级代码中使用。
一个异步示例:探测域名
想象一下,你即将在 Python 上开始一个新博客,并计划注册一个使用 Python 关键字和.DEV后缀的域名,例如:AWAIT.DEV. 示例 21-1 是一个使用asyncio检查多个域名的脚本。这是它产生的输出:
$ python3 blogdom.py
with.dev
+ elif.dev
+ def.dev
from.dev
else.dev
or.dev
if.dev
del.dev
+ as.dev
none.dev
pass.dev
true.dev
+ in.dev
+ for.dev
+ is.dev
+ and.dev
+ try.dev
+ not.dev
请注意,域名是无序的。如果运行脚本,您将看到它们一个接一个地显示,延迟不同。+符号表示您的计算机能够通过 DNS 解析域名。否则,该域名未解析,可能可用。⁵
在blogdom.py中,DNS 探测通过本机协程对象完成。由于异步操作是交错进行的,检查这 18 个域名所需的时间远远少于按顺序检查它们所需的时间。实际上,总时间几乎与单个最慢的 DNS 响应的时间相同,而不是所有响应时间的总和。
示例 21-1 显示了blogdom.py的代码。
示例 21-1. blogdom.py:搜索 Python 博客的域名
#!/usr/bin/env python3
import asyncio
import socket
from keyword import kwlist
MAX_KEYWORD_LEN = 4 # ①
async def probe(domain: str) -> tuple[str, bool]: # ②
loop = asyncio.get_running_loop() # ③
try:
await loop.getaddrinfo(domain, None) # ④
except socket.gaierror:
return (domain, False)
return (domain, True)
async def main() -> None: # ⑤
names = (kw for kw in kwlist if len(kw) <= MAX_KEYWORD_LEN) # ⑥
domains = (f'{name}.dev'.lower() for name in names) # ⑦
coros = [probe(domain) for domain in domains] # ⑧
for coro in asyncio.as_completed(coros): # ⑨
domain, found = await coro # ⑩
mark = '+' if found else ' '
print(f'{mark} {domain}')
if __name__ == '__main__':
asyncio.run(main()) ⑪
①
设置域名关键字的最大长度,因为长度较短更好。
②
probe返回一个包含域名和布尔值的元组;True表示域名已解析。返回域名将使显示结果更容易。
③
获取对asyncio事件循环的引用,以便我们可以在下一步中使用它。
④
loop.getaddrinfo(…)协程方法返回一个五部分参数元组,以使用套接字连接到给定地址。在这个例子中,我们不需要结果。如果我们得到了结果,域名就解析了;否则,它没有解析。
⑤
main必须是一个协程,这样我们就可以在其中使用await。
⑥
生成器以不超过MAX_KEYWORD_LEN长度的 Python 关键字。
⑦
生成器以.dev后缀的域名为结果。
⑧
通过使用probe协程调用每个domain参数来构建协程对象列表。
⑨
asyncio.as_completed是一个生成器,按照完成的顺序而不是提交的顺序,产生传递给它的协程的结果。它类似于我们在第二十章中看到的futures.as_completed,示例 20-4。
⑩
到这一步,我们知道协程已经完成,因为这就是as_completed的工作原理。因此,await表达式不会阻塞,但我们需要它来获取coro的结果。如果coro引发了未处理的异常,它将在这里重新引发。
⑪
asyncio.run启动事件循环,并仅在事件循环退出时返回。这是使用asyncio的脚本的常见模式:将main实现为协程,并在if __name__ == '__main__':块中使用asyncio.run来驱动它。
提示
asyncio.get_running_loop函数在 Python 3.7 中添加,用于在协程内部使用,如probe所示。如果没有运行的循环,asyncio.get_running_loop会引发RuntimeError。它的实现比asyncio.get_event_loop更简单更快,后者可能在必要时启动事件循环。自 Python 3.10 起,asyncio.get_event_loop已被弃用,最终将成为asyncio.get_running_loop的别名。
Guido 的阅读异步代码的技巧
在asyncio中有很多新概念需要掌握,但如果你采用 Guido van Rossum 本人建议的技巧:眯起眼睛,假装async和await关键字不存在,那么你会意识到协程读起来就像普通的顺序函数。
例如,想象一下这个协程的主体…
async def probe(domain: str) -> tuple[str, bool]:
loop = asyncio.get_running_loop()
try:
await loop.getaddrinfo(domain, None)
except socket.gaierror:
return (domain, False)
return (domain, True)
…的工作方式类似于以下函数,只是它神奇地永远不会阻塞:
def probe(domain: str) -> tuple[str, bool]: # no async
loop = asyncio.get_running_loop()
try:
loop.getaddrinfo(domain, None) # no await
except socket.gaierror:
return (domain, False)
return (domain, True)
使用语法await loop.getaddrinfo(...)避免阻塞,因为await挂起当前协程对象。例如,在执行probe('if.dev')协程期间,getaddrinfo('if.dev', None)创建了一个新的协程对象。等待它会启动低级addrinfo查询,并将控制权返回给事件循环,而不是suspend的probe(‘if.dev’)协程。事件循环然后可以驱动其他待处理的协程对象,比如probe('or.dev')。
当事件循环收到getaddrinfo('if.dev', None)查询的响应时,特定的协程对象恢复并将控制返回给suspend在await处的probe('if.dev'),现在可以处理可能的异常并返回结果元组。
到目前为止,我们只看到asyncio.as_completed和await应用于协程。但它们处理任何可等待对象。下面将解释这个概念。
新概念:可等待对象
for 关键字与可迭代对象一起使用。await 关键字与可等待对象一起使用。
作为asyncio的最终用户,这些是你每天会看到的可等待对象:
-
一个本机协程对象,通过调用本机协程函数来获得
-
一个
asyncio.Task,通常通过将协程对象传递给asyncio.create_task()来获得
然而,最终用户代码并不总是需要在Task上await。我们使用asyncio.create_task(one_coro())来安排one_coro以并发执行,而无需等待其返回。这就是我们在spinner_async.py中对spinner协程所做的事情(示例 19-4)。如果你不打算取消任务或等待它,就没有必要保留从create_task返回的Task对象。创建任务足以安排协程运行。
相比之下,我们使用await other_coro()来立即运行other_coro并等待其完成,因为我们需要它的结果才能继续。在spinner_async.py中,supervisor协程执行了res = await slow()来执行slow并获取其结果。
在实现异步库或为asyncio本身做贡献时,您可能还会处理这些更低级别的可等待对象:
-
具有返回迭代器的
__await__方法的对象;例如,一个asyncio.Future实例(asyncio.Task是asyncio.Future的子类) -
使用 Python/C API 编写的对象具有
tp_as_async.am_await函数,返回一个迭代器(类似于__await__方法)
现有的代码库可能还有一种额外的可等待对象:基于生成器的协程对象—正在被弃用中。
注意
PEP 492 指出,await表达式“使用yield from实现,并增加了验证其参数的额外步骤”,“await只接受可等待对象”。PEP 没有详细解释该实现,但参考了PEP 380,该 PEP 引入了yield from。我在fluentpython.com的“经典协程”部分的“yield from 的含义”中发布了详细解释。
现在让我们来学习一个下载固定一组国旗图像的脚本的asyncio版本。
使用 asyncio 和 HTTPX 进行下载
flags_asyncio.py脚本从fluentpython.com下载了一组固定的 20 个国旗。我们在“并发网络下载”中首次提到它,但现在我们将详细研究它,应用我们刚刚看到的概念。
截至 Python 3.10,asyncio仅直接支持 TCP 和 UDP,标准库中没有异步 HTTP 客户端或服务器包。我在所有 HTTP 客户端示例中使用HTTPX。
我们将从底向上探索flags_asyncio.py,即首先查看在示例 21-2 中设置操作的函数。
警告
为了使代码更易于阅读,flags_asyncio.py没有错误处理。随着我们引入async/await,最初专注于“快乐路径”是有用的,以了解如何在程序中安排常规函数和协程。从“增强 asyncio 下载器”开始,示例包括错误处理和更多功能。
本章和第二十章中的flags_.py示例共享代码和数据,因此我将它们放在example-code-2e/20-executors/getflags目录中。
示例 21-2. flags_asyncio.py:启动函数
def download_many(cc_list: list[str]) -> int: # ①
return asyncio.run(supervisor(cc_list)) # ②
async def supervisor(cc_list: list[str]) -> int:
async with AsyncClient() as client: # ③
to_do = [download_one(client, cc)
for cc in sorted(cc_list)] # ④
res = await asyncio.gather(*to_do) # ⑤
return len(res) # ⑥
if __name__ == '__main__':
main(download_many)
①
这需要是一个普通函数—而不是协程—这样它就可以被flags.py模块的main函数传递和调用(示例 20-2)。
②
执行驱动supervisor(cc_list)协程对象的事件循环,直到其返回。这将在事件循环运行时阻塞。此行的结果是supervisor的返回值。
③
httpx中的异步 HTTP 客户端操作是AsyncClient的方法,它也是一个异步上下文管理器:具有异步设置和拆卸方法的上下文管理器(有关更多信息,请参阅“异步上下文管理器”)。
④
通过为每个要检索的国旗调用一次download_one协程来构建协程对象列表。
⑤
等待asyncio.gather协程,它接受一个或多个可等待参数,并等待它们全部完成,按照提交的可等待对象的顺序返回结果列表。
⑥
supervisor返回asyncio.gather返回的列表的长度。
现在让我们回顾flags_asyncio.py的顶部(示例 21-3)。我重新组织了协程,以便我们可以按照它们被事件循环启动的顺序来阅读它们。
示例 21-3. flags_asyncio.py:导入和下载函数
import asyncio
from httpx import AsyncClient # ①
from flags import BASE_URL, save_flag, main # ②
async def download_one(client: AsyncClient, cc: str): # ③
image = await get_flag(client, cc)
save_flag(image, f'{cc}.gif')
print(cc, end=' ', flush=True)
return cc
async def get_flag(client: AsyncClient, cc: str) -> bytes: # ④
url = f'{BASE_URL}/{cc}/{cc}.gif'.lower()
resp = await client.get(url, timeout=6.1,
follow_redirects=True) # ⑤
return resp.read() # ⑥
①
必须安装httpx——它不在标准库中。
②
从flags.py(示例 20-2)中重用代码。
③
download_one必须是一个原生协程,这样它就可以await在get_flag上——后者执行 HTTP 请求。然后显示下载标志的代码,并保存图像。
④
get_flag需要接收AsyncClient来发起请求。
⑤
httpx.AsyncClient实例的get方法返回一个ClientResponse对象,也是一个异步上下文管理器。
⑥
网络 I/O 操作被实现为协程方法,因此它们由asyncio事件循环异步驱动。
注意
为了提高性能,get_flag内部的save_flag调用应该是异步的,以避免阻塞事件循环。然而,asyncio目前并没有像 Node.js 那样提供异步文件系统 API。
“使用 asyncio.as_completed 和线程”将展示如何将save_flag委托给一个线程。
您的代码通过await显式委托给httpx协程,或通过异步上下文管理器的特殊方法(如AsyncClient和ClientResponse)隐式委托,正如我们将在“异步上下文管理器”中看到的那样。
本地协程的秘密:谦逊的生成器
我们在“经典协程”中看到的经典协程示例与flags_asyncio.py之间的一个关键区别是后者中没有可见的.send()调用或yield表达式。您的代码位于asyncio库和您正在使用的异步库(如HTTPX)之间,这在图 21-1 中有所说明。

图 21-1. 在异步程序中,用户的函数启动事件循环,使用asyncio.run调度初始协程。每个用户的协程通过await表达式驱动下一个协程,形成一个通道,使得像HTTPX这样的库与事件循环之间能够进行通信。
在幕后,asyncio事件循环进行.send调用来驱动您的协程,您的协程await其他协程,包括库协程。正如前面提到的,await大部分实现来自yield from,后者也进行.send调用来驱动协程。
await链最终会到达一个低级可等待对象,它返回一个生成器,事件循环可以响应诸如计时器或网络 I/O 之类的事件来驱动它。这些await链末端的低级可等待对象和生成器深入到库中实现,不是其 API 的一部分,可能是 Python/C 扩展。
使用asyncio.gather和asyncio.create_task等函数,您可以启动多个并发的await通道,实现由单个事件循环在单个线程驱动的多个 I/O 操作的并发执行。
一切或无事可做问题
请注意,在 示例 21-3 中,我无法重用 flags.py 中的 get_flag 函数(示例 20-2)。我必须将其重写为一个协程,以使用 HTTPX 的异步 API。为了在 asyncio 中获得最佳性能,我们必须用 await 或 asyncio.create_task 替换每个执行 I/O 操作的函数,以便在函数等待 I/O 时将控制返回给事件循环。如果无法将阻塞函数重写为协程,应该在单独的线程或进程中运行它,正如我们将在 “委托任务给执行器” 中看到的。
这就是我选择本章的引语的原因,其中包括这样的建议:“你需要重写所有的代码,以便没有任何阻塞,否则你只是在浪费时间。”
出于同样的原因,我也无法重用 flags_threadpool.py 中的 download_one 函数(示例 20-3)。示例 21-3 中的代码使用 await 驱动 get_flag,因此 download_one 也必须是一个协程。对于每个请求,在 supervisor 中创建一个 download_one 协程对象,并且它们都由 asyncio.gather 协程驱动。
现在让我们研究出现在 supervisor(示例 21-2)和 get_flag(示例 21-3)中的 async with 语句。
异步上下文管理器
在 “上下文管理器和 with 语句” 中,我们看到一个对象如何在其类提供 __enter__ 和 __exit__ 方法的情况下用于在 with 块的主体之前和之后运行代码。
现在,考虑来自 asyncpg asyncio 兼容的 PostgreSQL 驱动器事务文档中的 示例 21-4。
示例 21-4. asyncpg PostgreSQL 驱动器文档中的示例代码
tr = connection.transaction()
await tr.start()
try:
await connection.execute("INSERT INTO mytable VALUES (1, 2, 3)")
except:
await tr.rollback()
raise
else:
await tr.commit()
数据库事务是上下文管理器协议的自然适用对象:事务必须启动,使用 connection.execute 更改数据,然后根据更改的结果进行回滚或提交。
在像 asyncpg 这样的异步驱动器中,设置和收尾需要是协程,以便其他操作可以同时进行。然而,经典 with 语句的实现不支持协程来执行 __enter__ 或 __exit__ 的工作。
这就是为什么 PEP 492—使用 async 和 await 语法的协程 引入了 async with 语句,它与实现了 __aenter__ 和 __aexit__ 方法的异步上下文管理器一起工作。
使用 async with,示例 21-4 可以像下面这样从 asyncpg 文档 中的另一个片段中编写:
async with connection.transaction():
await connection.execute("INSERT INTO mytable VALUES (1, 2, 3)")
在 asyncpg.Transaction 类中,__aenter__ 协程方法执行 await self.start(),而 __aexit__ 协程则等待私有的 __rollback 或 __commit 协程方法,取决于是否发生异常。使用协程来实现 Transaction 作为异步上下文管理器,使 asyncpg 能够同时处理许多事务。
Caleb Hattingh 关于 asyncpg
asyncpg 的另一个非常棒的地方是,它还解决了 PostgreSQL 缺乏高并发支持的问题(它为每个连接使用一个服务器端进程),通过为内部连接到 Postgres 本身实现了一个连接池。
这意味着你不需要像在 asyncpg 文档 中解释的那样额外使用 pgbouncer 这样的工具。⁶
回到 flags_asyncio.py,httpx 的 AsyncClient 类是一个异步上下文管理器,因此它可以在其 __aenter__ 和 __aexit__ 特殊协程方法中使用可等待对象。
注意
“异步生成器作为上下文管理器”展示了如何使用 Python 的contextlib创建一个异步上下文管理器,而无需编写类。由于先决主题:“异步生成器函数”,这个解释稍后在本章中提供。
现在我们将通过一个进度条增强asyncio标志下载示例,这将使我们更深入地探索asyncio API。
加强 asyncio 下载器
请回顾一下“带进度显示和错误处理的下载”,flags2示例集共享相同的命令行界面,并在下载进行时显示进度条。它们还包括错误处理。
提示
我鼓励您尝试使用flags2示例来培养对并发 HTTP 客户端性能的直觉。使用-h选项查看示例 20-10 中的帮助屏幕。使用-a、-e和-l命令行选项来控制下载数量,使用-m选项来设置并发下载数量。针对LOCAL、REMOTE、DELAY和ERROR服务器运行测试。发现最大化各服务器吞吐量所需的最佳并发下载数量。根据“设置测试服务器”中的描述调整测试服务器的选项。
例如,示例 21-5 展示了尝试从ERROR服务器获取 100 个标志(-al 100),使用 100 个并发请求(-m 100)。结果中的 48 个错误要么是 HTTP 418 错误,要么是超时错误——slow_server.py的预期(误)行为。
示例 21-5。运行 flags2_asyncio.py
$ python3 flags2_asyncio.py -s ERROR -al 100 -m 100
ERROR site: http://localhost:8002/flags
Searching for 100 flags: from AD to LK
100 concurrent connections will be used.
100%|█████████████████████████████████████████| 100/100 [00:03<00:00, 30.48it/s]
--------------------
52 flags downloaded.
48 errors.
Elapsed time: 3.31s
在测试并发客户端时要负责任
即使线程和asyncio HTTP 客户端之间的整体下载时间没有太大差异,asyncio可以更快地发送请求,因此服务器更有可能怀疑遭受到 DoS 攻击。为了真正全力运行这些并发客户端,请使用本地 HTTP 服务器进行测试,如“设置测试服务器”中所述。
现在让我们看看flags2_asyncio.py是如何实现的。
使用asyncio.as_completed和一个线程
在示例 21-3 中,我们将几个协程传递给asyncio.gather,它返回一个列表,其中包含按提交顺序排列的协程的结果。这意味着asyncio.gather只有在所有等待完成时才能返回。然而,为了更新进度条,我们需要在完成时获取结果。
幸运的是,asyncio中有一个与我们在线程池示例中使用的as_completed生成器函数等效的函数。
示例 21-6 显示了flags2_asyncio.py脚本的顶部,其中定义了get_flag和download_one协程。示例 21-7 列出了源代码的其余部分,包括supervisor和download_many。由于错误处理,此脚本比flags_asyncio.py更长。
示例 21-6。flags2_asyncio.py:脚本的顶部部分;其余代码在示例 21-7 中
import asyncio
from collections import Counter
from http import HTTPStatus
from pathlib import Path
import httpx
import tqdm # type: ignore
from flags2_common import main, DownloadStatus, save_flag
# low concurrency default to avoid errors from remote site,
# such as 503 - Service Temporarily Unavailable
DEFAULT_CONCUR_REQ = 5
MAX_CONCUR_REQ = 1000
async def get_flag(client: httpx.AsyncClient, # ①
base_url: str,
cc: str) -> bytes:
url = f'{base_url}/{cc}/{cc}.gif'.lower()
resp = await client.get(url, timeout=3.1, follow_redirects=True) # ②
resp.raise_for_status()
return resp.content
async def download_one(client: httpx.AsyncClient,
cc: str,
base_url: str,
semaphore: asyncio.Semaphore,
verbose: bool) -> DownloadStatus:
try:
async with semaphore: # ③
image = await get_flag(client, base_url, cc)
except httpx.HTTPStatusError as exc: # ④
res = exc.response
if res.status_code == HTTPStatus.NOT_FOUND:
status = DownloadStatus.NOT_FOUND
msg = f'not found: {res.url}'
else:
raise
else:
await asyncio.to_thread(save_flag, image, f'{cc}.gif') # ⑤
status = DownloadStatus.OK
msg = 'OK'
if verbose and msg:
print(cc, msg)
return status
①
get_flag与示例 20-14 中的顺序版本非常相似。第一个区别:它需要client参数。
②
第二和第三个区别:.get是AsyncClient的方法,它是一个协程,因此我们需要await它。
③
使用semaphore作为异步上下文管理器,以便整个程序不被阻塞;只有当信号量计数为零时,此协程才会被挂起。有关更多信息,请参阅“Python 的信号量”。
④
错误处理逻辑与download_one中的相同,来自示例 20-14。
⑤
保存图像是一个 I/O 操作。为了避免阻塞事件循环,在一个线程中运行save_flag。
所有网络 I/O 都是通过asyncio中的协程完成的,但文件 I/O 不是。然而,文件 I/O 也是“阻塞的”——因为读取/写入文件比读取/写入 RAM 要花费数千倍的时间。如果使用网络附加存储,甚至可能涉及网络 I/O。
自 Python 3.9 起,asyncio.to_thread协程使得将文件 I/O 委托给asyncio提供的线程池变得容易。如果需要支持 Python 3.7 或 3.8,“委托任务给执行器”展示了如何添加几行代码来实现。但首先,让我们完成对 HTTP 客户端代码的研究。
使用信号量限制请求
我们正在研究的网络客户端应该被限制(即,限制)以避免向服务器发送过多并发请求。
信号量是一种同步原语,比锁更灵活。信号量可以被多个协程持有,最大数量可配置。这使其成为限制活动并发协程数量的理想选择。“Python 的信号量”有更多信息。
在flags2_threadpool.py(示例 20-16)中,通过在download_many函数中将所需的max_workers参数设置为concur_req来完成限流。在flags2_asyncio.py中,通过supervisor函数创建一个asyncio.Semaphore(在示例 21-7 中显示),并将其作为semaphore参数传递给示例 21-6 中的download_one。
现在让我们看一下示例 21-7 中剩下的脚本。
示例 21-7. flags2_asyncio.py:脚本从示例 21-6 继续
async def supervisor(cc_list: list[str],
base_url: str,
verbose: bool,
concur_req: int) -> Counter[DownloadStatus]: # ①
counter: Counter[DownloadStatus] = Counter()
semaphore = asyncio.Semaphore(concur_req) # ②
async with httpx.AsyncClient() as client:
to_do = [download_one(client, cc, base_url, semaphore, verbose)
for cc in sorted(cc_list)] # ③
to_do_iter = asyncio.as_completed(to_do) # ④
if not verbose:
to_do_iter = tqdm.tqdm(to_do_iter, total=len(cc_list)) # ⑤
error: httpx.HTTPError | None = None # ⑥
for coro in to_do_iter: # ⑦
try:
status = await coro # ⑧
except httpx.HTTPStatusError as exc:
error_msg = 'HTTP error {resp.status_code} - {resp.reason_phrase}'
error_msg = error_msg.format(resp=exc.response)
error = exc # ⑨
except httpx.RequestError as exc:
error_msg = f'{exc} {type(exc)}'.strip()
error = exc # ⑩
except KeyboardInterrupt:
break
if error:
status = DownloadStatus.ERROR ⑪
if verbose:
url = str(error.request.url) ⑫
cc = Path(url).stem.upper() ⑬
print(f'{cc} error: {error_msg}')
counter[status] += 1
return counter
def download_many(cc_list: list[str],
base_url: str,
verbose: bool,
concur_req: int) -> Counter[DownloadStatus]:
coro = supervisor(cc_list, base_url, verbose, concur_req)
counts = asyncio.run(coro) ⑭
return counts
if __name__ == '__main__':
main(download_many, DEFAULT_CONCUR_REQ, MAX_CONCUR_REQ)
①
supervisor接受与download_many函数相同的参数,但不能直接从main中调用,因为它是一个协程,不像download_many那样是一个普通函数。
②
创建一个asyncio.Semaphore,不允许使用此信号量的协程中有超过concur_req个活动协程。concur_req的值由flags2_common.py中的main函数根据命令行选项和每个示例中设置的常量计算得出。
③
创建一个协程对象列表,每个调用download_one协程对应一个。
④
获取一个迭代器,将会在完成时返回协程对象。我没有直接将这个as_completed调用放在下面的for循环中,因为根据用户对详细程度的选择,我可能需要用tqdm迭代器包装它以显示进度条。
⑤
使用tqdm生成器函数包装as_completed迭代器以显示进度。
⑥
使用None声明和初始化error;如果在try/except语句之外引发异常,将使用此变量来保存异常。
⑦
迭代完成的协程对象;此循环类似于示例 20-16 中的download_many中的循环。
⑧
await协程以获取其结果。这不会阻塞,因为as_completed只会产生已完成的协程。
⑨
这个赋值是必要的,因为exc变量的作用域仅限于这个except子句,但我需要保留其值以供以后使用。
⑩
与之前相同。
⑪
如果出现错误,设置status。
⑫
在详细模式下,从引发的异常中提取 URL…
⑬
…并提取文件名以显示国家代码。
⑭
download_many实例化supervisor协程对象,并将其传递给事件循环以使用asyncio.run,在事件循环结束时收集supervisor返回的计数器。
在示例 21-7 中,我们无法使用我们在示例 20-16 中看到的将未来映射到国家代码的映射,因为asyncio.as_completed返回的可等待对象与我们传递给as_completed调用的可等待对象相同。在内部,asyncio机制可能会用最终产生相同结果的其他可等待对象替换我们提供的可等待对象。⁸
提示
由于在失败的情况下无法使用可等待对象作为键从dict中检索国家代码,我不得不从异常中提取国家代码。为此,我将异常保留在error变量中,以便在try/except语句之外检索。Python 不是块作用域语言:诸如循环和try/except之类的语句不会在其管理的块中创建局部作用域。但是,如果except子句将异常绑定到变量,就像我们刚刚看到的exc变量一样,那个绑定仅存在于该特定except子句内部的块中。
这里结束了对与flags2_threadpool.py在功能上等效的asyncio示例的讨论。
下一个示例演示了使用协程依次执行一个异步任务的简单模式。这值得我们关注,因为有经验的 JavaScript 用户都知道,依次运行一个异步函数是导致嵌套编码模式(称为doom 金字塔)的原因。await关键字让这个问题消失了。这就是为什么await现在成为 Python 和 JavaScript 的一部分。
为每个下载进行多个请求
假设您想要保存每个国家的国旗与国家名称和国家代码一起,而不仅仅是国家代码。现在您需要为每个旗帜进行两个 HTTP 请求:一个用于获取国旗图像本身,另一个用于获取与图像相同目录中的metadata.json文件,其中记录了国家的名称。
在线程脚本中协调多个请求很容易:只需依次发出一个请求,然后另一个请求,两次阻塞线程,并将两个数据(国家代码和名称)保存在本地变量中,以便在保存文件时使用。如果您需要在具有回调的异步脚本中执行相同操作,则需要嵌套函数,以便在闭包中可用国家代码和名称,直到可以保存文件,因为每个回调在不同的局部作用域中运行。await关键字可以解决这个问题,允许您依次驱动异步请求,共享驱动协程的局部作用域。
提示
如果你正在使用现代 Python 进行异步应用程序编程,并且有很多回调,那么你可能正在应用在现代 Python 中没有意义的旧模式。如果你正在编写一个与不支持协程的遗留或低级代码进行交互的库,这是合理的。无论如何,StackOverflow 的问答“future.add_done_callback()的用例是什么?”解释了为什么在低级代码中需要回调,但在现代 Python 应用级代码中并不是很有用。
asyncio标志下载脚本的第三个变体有一些变化:
get_country
这个新协程为国家代码获取metadata.json文件,并从中获取国家名称。
download_one
这个协程现在使用await委托给get_flag和新的get_country协程,使用后者的结果构建要保存的文件名。
让我们从get_country的代码开始(示例 21-8)。请注意,它与示例 21-6 中的get_flag非常相似。
示例 21-8. flags3_asyncio.py:get_country协程
async def get_country(client: httpx.AsyncClient,
base_url: str,
cc: str) -> str: # ①
url = f'{base_url}/{cc}/metadata.json'.lower()
resp = await client.get(url, timeout=3.1, follow_redirects=True)
resp.raise_for_status()
metadata = resp.json() # ②
return metadata['country'] # ③
①
这个协程返回一个包含国家名称的字符串——如果一切顺利的话。
②
metadata将从响应的 JSON 内容构建一个 Python dict。
③
返回国家名称。
现在让我们看看修改后的download_one在示例 21-9 中,与示例 21-6 中的相同协程相比,只有几行代码发生了变化。
示例 21-9. flags3_asyncio.py:download_one协程
async def download_one(client: httpx.AsyncClient,
cc: str,
base_url: str,
semaphore: asyncio.Semaphore,
verbose: bool) -> DownloadStatus:
try:
async with semaphore: # ①
image = await get_flag(client, base_url, cc)
async with semaphore: # ②
country = await get_country(client, base_url, cc)
except httpx.HTTPStatusError as exc:
res = exc.response
if res.status_code == HTTPStatus.NOT_FOUND:
status = DownloadStatus.NOT_FOUND
msg = f'not found: {res.url}'
else:
raise
else:
filename = country.replace(' ', '_') # ③
await asyncio.to_thread(save_flag, image, f'{filename}.gif')
status = DownloadStatus.OK
msg = 'OK'
if verbose and msg:
print(cc, msg)
return status
①
持有semaphore以await获取get_flag…
②
…再次为get_country。
③
使用国家名称创建文件名。作为一个命令行用户,我不喜欢在文件名中看到空格。
比嵌套回调好多了!
我将对get_flag和get_country的调用放在由semaphore控制的独立with块中,因为尽可能短暂地持有信号量和锁是一个良好的实践。
我可以使用asyncio.gather并行调度get_flag和get_country,但如果get_flag引发异常,则没有图像可保存,因此运行get_country是没有意义的。但有些情况下,使用asyncio.gather同时命中几个 API 而不是等待一个响应再发出下一个请求是有意义的。
在flags3_asyncio.py中,await语法出现了六次,async with出现了三次。希望你能掌握 Python 中的异步编程。一个挑战是要知道何时必须使用await以及何时不能使用它。原则上答案很简单:你await协程和其他可等待对象,比如asyncio.Task实例。但有些 API 很棘手,以看似任意的方式混合协程和普通函数,就像我们将在示例 21-14 中使用的StreamWriter类一样。
示例 21-9 总结了flags示例集。现在让我们讨论在异步编程中使用线程或进程执行者。
将任务委托给执行者
Node.js 相对于 Python 在异步编程方面的一个重要优势是 Node.js 标准库,它为所有 I/O 提供了异步 API,而不仅仅是网络 I/O。在 Python 中,如果不小心,文件 I/O 可能会严重降低异步应用程序的性能,因为在主线程中读取和写入存储会阻塞事件循环。
在示例 21-6 的download_one协程中,我使用了这行代码将下载的图像保存到磁盘上:
await asyncio.to_thread(save_flag, image, f'{cc}.gif')
如前所述,asyncio.to_thread是在 Python 3.9 中添加的。如果需要支持 3.7 或 3.8,则用示例 21-10 中的行替换那一行。
示例 21-10. 替代await asyncio.to_thread的行
loop = asyncio.get_running_loop() # ①
loop.run_in_executor(None, save_flag, # ②
image, f'{cc}.gif') # ③
①
获取事件循环的引用。
②
第一个参数是要使用的执行器;传递None会选择asyncio事件循环中始终可用的默认ThreadPoolExecutor。
③
你可以向要运行的函数传递位置参数,但如果需要传递关键字参数,则需要使用functool.partial,如run_in_executor文档中所述。
新的asyncio.to_thread函数更易于使用,更灵活,因为它还接受关键字参数。
asyncio本身的实现在一些地方使用run_in_executor。例如,我们在示例 21-1 中看到的loop.getaddrinfo(…)协程是通过调用socket模块中的getaddrinfo函数来实现的——这是一个可能需要几秒钟才能返回的阻塞函数,因为它依赖于 DNS 解析。
异步 API 中的常见模式是使用run_in_executor在协程中包装作为实现细节的阻塞调用。这样,您提供了一个一致的协程接口供await驱动,并隐藏了出于实用原因需要使用的线程。用于 MongoDB 的Motor异步驱动程序具有与async/await兼容的 API,实际上是一个围绕与数据库服务器通信的线程核心的外观。Motor 的首席开发人员 A. Jesse Jiryu Davis 在“异步 Python 和数据库的响应”中解释了他的理由。剧透:Davis 发现在线程池在数据库驱动程序的特定用例中更高效——尽管有一个关于异步方法总是比网络 I/O 的线程更快的神话。
将显式Executor传递给loop.run_in_executor的主要原因是,如果要执行的函数对 CPU 密集型,则可以使用ProcessPoolExecutor,以便在不同的 Python 进程中运行,避免争用 GIL。由于高启动成本,最好在supervisor中启动ProcessPoolExecutor,并将其传递给需要使用它的协程。
《Python 异步编程》的作者 Caleb Hattingh(O’Reilly)是本书的技术审阅者之一,并建议我添加关于执行器和asyncio的以下警告。
Caleb 关于 run_in_executors 的警告
使用run_in_executor可能会产生难以调试的问题,因为取消操作的工作方式可能不如预期。使用执行器的协程仅仅给出了取消的假象:底层线程(如果是ThreadPoolExecutor)没有取消机制。例如,在run_in_executor调用内创建的长时间运行的线程可能会阻止您的asyncio程序干净地关闭:asyncio.run将等待执行器完全关闭才返回,并且如果执行器的作业没有以某种方式停止,它将永远等待。我倾向于希望该函数被命名为run_in_executor_uncancellable。
现在我们将从客户端脚本转向使用asyncio编写服务器。
编写 asyncio 服务器
TCP 服务器的经典玩具示例是回显服务器。我们将构建稍微有趣的玩具:首先使用FastAPI和 HTTP,然后仅使用asyncio和纯 TCP 实现服务器端 Unicode 字符搜索实用程序。
这些服务器允许用户根据我们在“Unicode 数据库”中讨论的unicodedata模块中的标准名称中的单词查询 Unicode 字符。图 21-2 展示了与web_mojifinder.py进行的会话,这是我们将构建的第一个服务器。

图 21-2. 浏览器窗口显示来自 web_mojifinder.py 服务的“mountain”搜索结果。
这些示例中的 Unicode 搜索逻辑在Fluent Python代码存储库中的charindex.py模块中的InvertedIndex类中。在那个小模块中没有并发,所以我将在接下来的可选框中简要概述。您可以跳到“一个 FastAPI Web 服务”中的 HTTP 服务器实现。
一个 FastAPI Web 服务
我编写了下一个示例—web_mojifinder.py—使用FastAPI:这是“ASGI—异步服务器网关接口”中提到的 Python ASGI Web 框架之一。图 21-2 是前端的屏幕截图。这是一个超级简单的 SPA(单页应用程序):在初始 HTML 下载后,UI 通过客户端 JavaScript 与服务器通信来更新。
FastAPI旨在为 SPA 和移动应用程序实现后端,这些应用程序主要由返回 JSON 响应的 Web API 端点组成,而不是服务器呈现的 HTML。 FastAPI利用装饰器、类型提示和代码内省来消除大量用于 Web API 的样板代码,并自动发布交互式 OpenAPI(又名Swagger)文档,用于我们创建的 API。图 21-4 展示了web_mojifinder.py的自动生成的/docs页面。

图 21-4. /search端点的自动生成 OpenAPI 模式。
示例 21-11 是web_mojifinder.py的代码,但那只是后端代码。当您访问根 URL/时,服务器会发送form.html文件,其中包括 81 行代码,其中包括 54 行 JavaScript 代码,用于与服务器通信并将结果填充到表中。如果您有兴趣阅读纯粹的无框架 JavaScript,请在Fluent Python代码存储库中找到21-async/mojifinder/static/form.html。
要运行web_mojifinder.py,您需要安装两个包及其依赖项:FastAPI和uvicorn。¹⁰ 这是在开发模式下使用uvicorn运行示例 21-11 的命令:
$ uvicorn web_mojifinder:app --reload
参数为:
web_mojifinder:app
包名称、冒号和其中定义的 ASGI 应用程序的名称——app是常规名称。
--reload
使uvicorn监视应用程序源文件的更改并自动重新加载它们。仅在开发过程中有用。
现在让我们研究web_mojifinder.py的源代码。
示例 21-11. web_mojifinder.py:完整源码
from pathlib import Path
from unicodedata import name
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from charindex import InvertedIndex
STATIC_PATH = Path(__file__).parent.absolute() / 'static' # ①
app = FastAPI( # ②
title='Mojifinder Web',
description='Search for Unicode characters by name.',
)
class CharName(BaseModel): # ③
char: str
name: str
def init(app): # ④
app.state.index = InvertedIndex()
app.state.form = (STATIC_PATH / 'form.html').read_text()
init(app) # ⑤
@app.get('/search', response_model=list[CharName]) # ⑥
async def search(q: str): # ⑦
chars = sorted(app.state.index.search(q))
return ({'char': c, 'name': name(c)} for c in chars) # ⑧
@app.get('/', response_class=HTMLResponse, include_in_schema=False)
def form(): # ⑨
return app.state.form
# no main funcion # ⑩
①
与本章主题无关,但值得注意的是pathlib通过重载的/运算符的优雅使用。¹¹
②
此行定义了 ASGI 应用程序。它可以简单到app = FastAPI()。所示的参数是自动生成文档的元数据。
③
一个带有char和name字段的 JSON 响应的pydantic模式。¹²
④
构建index并加载静态 HTML 表单,将两者附加到app.state以供以后使用。
⑤
当此模块由 ASGI 服务器加载时运行init。
⑥
/search端点的路由;response_model使用CharName pydantic模型描述响应格式。
⑦
FastAPI假设在函数或协程签名中出现的任何参数,而不在路由路径中的参数将传递到 HTTP 查询字符串中,例如,/search?q=cat。由于q没有默认值,如果查询字符串中缺少q,FastAPI将返回 422(无法处理的实体)状态。
⑧
返回与response_model模式兼容的dicts的可迭代对象允许FastAPI根据@app.get装饰器中的response_model构建 JSON 响应。
⑨
常规函数(即非异步函数)也可以用于生成响应。
⑩
这个模块没有主函数。在这个示例中,它由 ASGI 服务器—uvicorn加载和驱动。
示例 21-11 没有直接调用asyncio。FastAPI是建立在Starlette ASGI 工具包之上的,而Starlette又使用asyncio。
还要注意,search的主体不使用await、async with或async for,因此它可以是一个普通函数。我将search定义为协程只是为了展示FastAPI知道如何处理它。在真实的应用程序中,大多数端点将查询数据库或访问其他远程服务器,因此FastAPI支持可以利用异步库进行网络 I/O 的协程是FastAPI和 ASGI 框架的关键优势。
提示
我编写的init和form函数用于加载和提供静态 HTML 表单,这是为了让示例变得简短且易于运行。推荐的最佳实践是在 ASGI 服务器前面放置一个代理/负载均衡器来处理所有静态资产,并在可能的情况下使用 CDN(内容交付网络)。其中一个这样的代理/负载均衡器是Traefik,一个自称为“边缘路由器”的工具,“代表您的系统接收请求并找出哪些组件负责处理它们”。FastAPI有项目生成脚本,可以准备您的代码来实现这一点。
爱好类型提示的人可能已经注意到search和form中没有返回类型提示。相反,FastAPI依赖于路由装饰器中的response_model=关键字参数。FastAPI文档中的“响应模型”页面解释了:
响应模型在此参数中声明,而不是作为函数返回类型注释,因为路径函数实际上可能不返回该响应模型,而是返回一个 dict、数据库对象或其他模型,然后使用
response_model执行字段限制和序列化。
例如,在search中,我返回了一个dict项的生成器,而不是CharName对象的列表,但这对于FastAPI和pydantic来说已经足够验证我的数据并构建与response_model=list[CharName]兼容的适当 JSON 响应。
现在我们将专注于tcp_mojifinder.py脚本,该脚本正在回答图 21-5 中的查询。
一个 asyncio TCP 服务器
tcp_mojifinder.py程序使用普通 TCP 与像 Telnet 或 Netcat 这样的客户端通信,因此我可以使用asyncio编写它而无需外部依赖项—也无需重新发明 HTTP。图 21-5 展示了基于文本的用户界面。

图 21-5. 使用 tcp_mojifinder.py 服务器进行 Telnet 会话:查询“fire”。
这个程序比web_mojifinder.py长一倍,所以我将演示分为三部分:示例 21-12、示例 21-14 和示例 21-15。tcp_mojifinder.py的顶部—包括import语句—在示例 21-14 中,但我将从描述supervisor协程和驱动程序的main函数开始。
示例 21-12. tcp_mojifinder.py:一个简单的 TCP 服务器;继续查看示例 21-14
async def supervisor(index: InvertedIndex, host: str, port: int) -> None:
server = await asyncio.start_server( # ①
functools.partial(finder, index), # ②
host, port) # ③
socket_list = cast(tuple[TransportSocket, ...], server.sockets) # ④
addr = socket_list[0].getsockname()
print(f'Serving on {addr}. Hit CTRL-C to stop.') # ⑤
await server.serve_forever() # ⑥
def main(host: str = '127.0.0.1', port_arg: str = '2323'):
port = int(port_arg)
print('Building index.')
index = InvertedIndex() # ⑦
try:
asyncio.run(supervisor(index, host, port)) # ⑧
except KeyboardInterrupt: # ⑨
print('\nServer shut down.')
if __name__ == '__main__':
main(*sys.argv[1:])
①
这个await快速获取了一个asyncio.Server实例,一个 TCP 套接字服务器。默认情况下,start_server创建并启动服务器,因此它已准备好接收连接。
②
start_server的第一个参数是client_connected_cb,一个在新客户端连接开始时运行的回调函数。回调函数可以是一个函数或一个协程,但必须接受两个参数:一个asyncio.StreamReader和一个asyncio.StreamWriter。然而,我的finder协程还需要获取一个index,所以我使用functools.partial来绑定该参数并获得一个接受读取器和写入器的可调用对象。将用户函数适配为回调 API 是functools.partial的最常见用例。
③
host和port是start_server的第二个和第三个参数。在asyncio文档中查看完整的签名。
④
这个cast是必需的,因为typeshed对Server类的sockets属性的类型提示已过时—截至 2021 年 5 月。参见typeshed上的Issue #5535。¹³
⑤
显示服务器的第一个套接字的地址和端口。
⑥
尽管start_server已经将服务器作为并发任务启动,但我需要在server_forever方法上await,以便我的supervisor在此处暂停。如果没有这行,supervisor将立即返回,结束由asyncio.run(supervisor(…))启动的循环,并退出程序。Server.serve_forever的文档中说:“如果服务器已经接受连接,则可以调用此方法。”
⑦
构建倒排索引。¹⁴
⑧
启动运行supervisor的事件循环。
⑨
捕获KeyboardInterrupt以避免在终止运行它的终端上使用 Ctrl-C 停止服务器时出现令人分心的回溯。
如果您研究服务器控制台上生成的输出,可以更容易地理解tcp_mojifinder.py中的控制流程,在示例 21-13 中列出。
示例 21-13. tcp_mojifinder.py:这是图 21-5 中描述的会话的服务器端
$ python3 tcp_mojifinder.py
Building index. # ① Serving on ('127.0.0.1', 2323). Hit Ctrl-C to stop. # ② From ('127.0.0.1', 58192): 'cat face' # ③ To ('127.0.0.1', 58192): 10 results.
From ('127.0.0.1', 58192): 'fire' # ④ To ('127.0.0.1', 58192): 11 results.
From ('127.0.0.1', 58192): '\x00' # ⑤ Close ('127.0.0.1', 58192). # ⑥ ^C # ⑦ Server shut down. # ⑧ $
①
main输出。在下一行出现之前,我在我的机器上看到了 0.6 秒的延迟,因为正在构建索引。
②
supervisor输出。
③
finder中while循环的第一次迭代。TCP/IP 堆栈将端口 58192 分配给了我的 Telnet 客户端。如果将多个客户端连接到服务器,您将在输出中看到它们的各种端口。
④
finder中while循环的第二次迭代。
⑤
我在客户端终端上按下了 Ctrl-C;finder中的while循环退出。
⑥
finder协程显示此消息然后退出。与此同时,服务器仍在运行,准备为另一个客户端提供服务。
⑦
我在服务器终端上按下了 Ctrl-C;server.serve_forever被取消,结束了supervisor和事件循环。
⑧
由main输出。
在main构建索引并启动事件循环后,supervisor快速显示Serving on…消息,并在await server.serve_forever()行处暂停。此时,控制流进入事件循环并留在那里,偶尔返回到finder协程,每当需要等待网络发送或接收数据时,它将控制权交还给事件循环。
当事件循环处于活动状态时,将为连接到服务器的每个客户端启动一个新的finder协程实例。通过这种方式,这个简单的服务器可以同时处理许多客户端。直到服务器上发生KeyboardInterrupt或其进程被操作系统终止。
现在让我们看看tcp_mojifinder.py的顶部,其中包含finder协程。
示例 21-14. tcp_mojifinder.py:续自示例 21-12
import asyncio
import functools
import sys
from asyncio.trsock import TransportSocket
from typing import cast
from charindex import InvertedIndex, format_results # ①
CRLF = b'\r\n'
PROMPT = b'?> '
async def finder(index: InvertedIndex, # ②
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter) -> None:
client = writer.get_extra_info('peername') # ③
while True: # ④
writer.write(PROMPT) # can't await! # ⑤
await writer.drain() # must await! # ⑥
data = await reader.readline() # ⑦
if not data: # ⑧
break
try:
query = data.decode().strip() # ⑨
except UnicodeDecodeError: # ⑩
query = '\x00'
print(f' From {client}: {query!r}') ⑪
if query:
if ord(query[:1]) < 32: ⑫
break
results = await search(query, index, writer) ⑬
print(f' To {client}: {results} results.') ⑭
writer.close() ⑮
await writer.wait_closed() ⑯
print(f'Close {client}.') # ⑰
①
format_results对InvertedIndex.search的结果进行显示,在文本界面(如命令行或 Telnet 会话)中非常有用。
②
为了将finder传递给asyncio.start_server,我使用functools.partial对其进行了包装,因为服务器期望一个只接受reader和writer参数的协程或函数。
③
获取与套接字连接的远程客户端地址。
④
此循环处理一个对话,直到从客户端接收到控制字符为止。
⑤
StreamWriter.write方法不是一个协程,只是一个普通函数;这一行发送?>提示符。
⑥
StreamWriter.drain刷新writer缓冲区;它是一个协程,因此必须使用await来驱动它。
⑦
StreamWriter.readline是一个返回bytes的协程。
⑧
如果没有接收到任何字节,则客户端关闭了连接,因此退出循环。
⑨
将bytes解码为str,使用默认的 UTF-8 编码。
⑩
当用户按下 Ctrl-C 并且 Telnet 客户端发送控制字节时,可能会发生UnicodeDecodeError;如果发生这种情况,为简单起见,用空字符替换查询。
⑪
将查询记录到服务器控制台。
⑫
如果接收到控制字符或空字符,则退出循环。
⑬
执行实际的search;代码将在下面呈现。
⑭
将响应记录到服务器控制台。
⑮
关闭StreamWriter。
⑯
等待StreamWriter关闭。这在.close()方法文档中推荐。
⑰
将此客户端会话的结束记录到服务器控制台。
这个示例的最后一部分是search协程,如示例 21-15 所示。
示例 21-15. tcp_mojifinder.py:search协程
async def search(query: str, # ①
index: InvertedIndex,
writer: asyncio.StreamWriter) -> int:
chars = index.search(query) # ②
lines = (line.encode() + CRLF for line # ③
in format_results(chars))
writer.writelines(lines) # ④
await writer.drain() # ⑤
status_line = f'{"─" * 66} {len(chars)} found' # ⑥
writer.write(status_line.encode() + CRLF)
await writer.drain()
return len(chars)
①
search必须是一个协程,因为它写入一个StreamWriter并必须使用它的.drain()协程方法。
②
查询反向索引。
③
这个生成器表达式将产生用 UTF-8 编码的字节字符串,包含 Unicode 代码点、实际字符、其名称和一个CRLF序列,例如,b'U+0039\t9\tDIGIT NINE\r\n'。
④
发送lines。令人惊讶的是,writer.writelines不是一个协程。
⑤
但writer.drain()是一个协程。不要忘记await!
⑥
构建一个状态行,然后发送它。
请注意,tcp_mojifinder.py中的所有网络 I/O 都是以bytes形式;我们需要解码从网络接收的bytes,并在发送之前对字符串进行编码。在 Python 3 中,默认编码是 UTF-8,这就是我在本示例中所有encode和decode调用中隐式使用的编码。
警告
请注意,一些 I/O 方法是协程,必须使用await来驱动,而其他一些是简单的函数。例如,StreamWriter.write是一个普通函数,因为它写入缓冲区。另一方面,StreamWriter.drain——用于刷新缓冲区并执行网络 I/O 的协程,以及StreamReader.readline——但不是StreamWriter.writelines!在我写这本书的第一版时,asyncio API 文档通过清晰标记协程得到了改进。
tcp_mojifinder.py代码利用了高级别的asyncio Streams API,提供了一个可直接使用的服务器,因此你只需要实现一个处理函数,可以是一个普通回调函数或一个协程。还有一个更低级别的Transports and Protocols API,受到Twisted框架中传输和协议抽象的启发。请参考asyncio文档以获取更多信息,包括使用该低级别 API 实现的TCP 和 UDP 回显服务器和客户端。
我们下一个主题是async for和使其工作的对象。
异步迭代和异步可迭代对象
我们在“异步上下文管理器”中看到了async with如何与实现__aenter__和__aexit__方法返回可等待对象的对象一起工作——通常是协程对象的形式。
同样,async for适用于异步可迭代对象:实现了__aiter__的对象。然而,__aiter__必须是一个常规方法——不是一个协程方法——并且必须返回一个异步迭代器。
异步迭代器提供了一个__anext__协程方法,返回一个可等待对象——通常是一个协程对象。它们还应该实现__aiter__,通常返回self。这反映了我们在“不要让可迭代对象成为自身的迭代器”中讨论的可迭代对象和迭代器的重要区别。
aiopg异步 PostgreSQL 驱动程序文档中有一个示例,演示了使用async for来迭代数据库游标的行:
async def go():
pool = await aiopg.create_pool(dsn)
async with pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute("SELECT 1")
ret = []
async for row in cur:
ret.append(row)
assert ret == [(1,)]
在这个示例中,查询将返回一行,但在实际情况下,你可能会对SELECT查询的响应有成千上万行。对于大量响应,游标不会一次性加载所有行。因此,很重要的是async for row in cur:不会阻塞事件循环,而游标可能正在等待更多行。通过将游标实现为异步迭代器,aiopg可以在每次__anext__调用时让出事件循环,并在后来从 PostgreSQL 接收更多行时恢复。
异步生成器函数
你可以通过编写一个带有__anext__和__aiter__的类来实现异步迭代器,但有一种更简单的方法:编写一个使用async def声明的函数,并在其体内使用yield。这与生成器函数简化经典的迭代器模式的方式相似。
让我们研究一个简单的例子,使用async for并实现一个异步生成器。在示例 21-1 中,我们看到了blogdom.py,一个探测域名的脚本。现在假设我们找到了我们在那里定义的probe协程的其他用途,并决定将其放入一个新模块—domainlib.py—与一个新的multi_probe异步生成器一起,该生成器接受一个域名列表,并在探测时产生结果。
我们很快将看到domainlib.py的实现,但首先让我们看看它如何与 Python 的新异步控制台一起使用。
尝试使用 Python 的异步控制台
自 Python 3.8 起,你可以使用-m asyncio命令行选项运行解释器,以获得一个“异步 REPL”:一个导入asyncio,提供运行事件循环,并在顶级提示符接受await、async for和async with的 Python 控制台——否则在外部协程之外使用时会产生语法错误。¹⁵
要尝试domainlib.py,请转到你本地Fluent Python代码库中的21-async/domains/asyncio/目录。然后运行:
$ python -m asyncio
你会看到控制台启动,类似于这样:
asyncio REPL 3.9.1 (v3.9.1:1e5d33e9b9, Dec 7 2020, 12:10:52)
[Clang 6.0 (clang-600.0.57)] on darwin
Use "await" directly instead of "asyncio.run()".
Type "help", "copyright", "credits" or "license" for more information.
>>> import asyncio
>>>
注意标题中说你可以使用await而不是asyncio.run()来驱动协程和其他可等待对象。另外:我没有输入import asyncio。asyncio模块会自动导入,并且该行使用户清楚地了解这一事实。
现在让我们导入domainlib.py并尝试其两个协程:probe和multi_probe(示例 21-16)。
示例 21-16. 在运行python3 -m asyncio后尝试domainlib.py
>>> await asyncio.sleep(3, 'Rise and shine!') # ①
'Rise and shine!' >>> from domainlib import *
>>> await probe('python.org') # ②
Result(domain='python.org', found=True) # ③
>>> names = 'python.org rust-lang.org golang.org no-lang.invalid'.split() # ④
>>> async for result in multi_probe(names): # ⑤
... print(*result, sep='\t')
...
golang.org True # ⑥
no-lang.invalid False python.org True rust-lang.org True >>>
①
尝试一个简单的await来看看异步控制台的运行情况。提示:asyncio.sleep()接受一个可选的第二个参数,在你await它时返回。
②
驱动probe协程。
③
probe的domainlib版本返回一个名为Result的命名元组。
④
制作一个域名列表。.invalid顶级域名保留用于测试。对于这些域的 DNS 查询总是从 DNS 服务器获得 NXDOMAIN 响应,意味着“该域名不存在”。¹⁶
⑤
使用async for迭代multi_probe异步生成器以显示结果。
⑥
注意结果不是按照传递给multiprobe的域的顺序出现的。它们会在每个 DNS 响应返回时出现。
示例 21-16 表明multi_probe是一个异步生成器,因为它与async for兼容。现在让我们进行一些更多的实验,从那个示例继续,使用示例 21-17。
示例 21-17. 更多实验,从示例 21-16 继续
>>> probe('python.org') # ①
<coroutine object probe at 0x10e313740> >>> multi_probe(names) # ②
<async_generator object multi_probe at 0x10e246b80> >>> for r in multi_probe(names): # ③
... print(r)
...
Traceback (most recent call last):
...
TypeError: 'async_generator' object is not iterable
①
调用一个原生协程会给你一个协程对象。
②
调用异步生成器会给你一个async_generator对象。
③
我们不能使用常规的for循环与异步生成器,因为它们实现了__aiter__而不是__iter__。
异步生成器由async for驱动,它可以是一个块语句(如示例 21-16 中所见),它还出现在异步推导式中,我们很快会介绍。
实现异步生成器
现在让我们研究domainlib.py中的代码,使用multi_probe异步生成器(示例 21-18)。
示例 21-18. domainlib.py:用于探测域的函数
import asyncio
import socket
from collections.abc import Iterable, AsyncIterator
from typing import NamedTuple, Optional
class Result(NamedTuple): # ①
domain: str
found: bool
OptionalLoop = Optional[asyncio.AbstractEventLoop] # ②
async def probe(domain: str, loop: OptionalLoop = None) -> Result: # ③
if loop is None:
loop = asyncio.get_running_loop()
try:
await loop.getaddrinfo(domain, None)
except socket.gaierror:
return Result(domain, False)
return Result(domain, True)
async def multi_probe(domains: Iterable[str]) -> AsyncIterator[Result]: # ④
loop = asyncio.get_running_loop()
coros = [probe(domain, loop) for domain in domains] # ⑤
for coro in asyncio.as_completed(coros): # ⑥
result = await coro # ⑦
yield result # ⑧
①
NamedTuple使得从probe得到的结果更易于阅读和调试。
②
这个类型别名是为了避免书中列表过长。
③
probe现在获得了一个可选的loop参数,以避免在此协程由multi_probe驱动时重复调用get_running_loop。
④
异步生成器函数产生一个异步生成器对象,可以注释为AsyncIterator[SomeType]。
⑤
构建包含不同domain的probe协程对象列表。
⑥
这不是async for,因为asyncio.as_completed是一个经典生成器。
⑦
等待协程对象以检索结果。
⑧
返回result。这一行使multi_probe成为一个异步生成器。
注意
示例 21-18 中的for循环可以更简洁:
for coro in asyncio.as_completed(coros):
yield await coro
Python 将其解析为yield (await coro),所以它有效。
我认为在书中第一个异步生成器示例中使用该快捷方式可能会让人困惑,所以我将其拆分为两行。
给定domainlib.py,我们可以演示在domaincheck.py中使用multi_probe异步生成器的方法:一个脚本,接受一个域后缀并搜索由短 Python 关键字组成的域。
这是domaincheck.py的一个示例输出:
$ ./domaincheck.py net
FOUND NOT FOUND
===== =========
in.net
del.net
true.net
for.net
is.net
none.net
try.net
from.net
and.net
or.net
else.net
with.net
if.net
as.net
elif.net
pass.net
not.net
def.net
多亏了domainlib,domaincheck.py的代码非常简单,如示例 21-19 所示。
示例 21-19. domaincheck.py:使用 domainlib 探测域的实用程序
#!/usr/bin/env python3
import asyncio
import sys
from keyword import kwlist
from domainlib import multi_probe
async def main(tld: str) -> None:
tld = tld.strip('.')
names = (kw for kw in kwlist if len(kw) <= 4) # ①
domains = (f'{name}.{tld}'.lower() for name in names) # ②
print('FOUND\t\tNOT FOUND') # ③
print('=====\t\t=========')
async for domain, found in multi_probe(domains): # ④
indent = '' if found else '\t\t' # ⑤
print(f'{indent}{domain}')
if __name__ == '__main__':
if len(sys.argv) == 2:
asyncio.run(main(sys.argv[1])) # ⑥
else:
print('Please provide a TLD.', f'Example: {sys.argv[0]} COM.BR')
①
生成长度最多为4的关键字。
②
生成具有给定后缀作为 TLD 的域名。
③
为表格输出格式化标题。
④
在multi_probe(domains)上异步迭代。
⑤
将indent设置为零或两个制表符,以将结果放在正确的列中。
⑥
使用给定的命令行参数运行main协程。
生成器有一个与迭代无关的额外用途:它们可以转换为上下文管理器。这也适用于异步生成器。
异步生成器作为上下文管理器
编写我们自己的异步上下文管理器并不是一个经常出现的编程任务,但如果您需要编写一个,考虑使用 Python 3.7 中添加到contextlib模块的@asynccontextmanager装饰器。这与我们在“使用@contextmanager”中学习的@contextmanager装饰器非常相似。
一个有趣的示例结合了@asynccontextmanager和loop.run_in_executor,出现在 Caleb Hattingh 的书Using Asyncio in Python中。示例 21-20 是 Caleb 的代码,只有一个变化和添加的标注。
示例 21-20. 使用@asynccontextmanager和loop.run_in_executor的示例
from contextlib import asynccontextmanager
@asynccontextmanager
async def web_page(url): # ①
loop = asyncio.get_running_loop() # ②
data = await loop.run_in_executor( # ③
None, download_webpage, url)
yield data # ④
await loop.run_in_executor(None, update_stats, url) # ⑤
async with web_page('google.com') as data: # ⑥
process(data)
①
被修饰的函数必须是一个异步生成器。
②
对 Caleb 的代码进行了小更新:使用轻量级的get_running_loop代替get_event_loop。
③
假设download_webpage是使用requests库的阻塞函数;我们在单独的线程中运行它以避免阻塞事件循环。
④
在此yield表达式之前的所有行将成为装饰器构建的异步上下文管理器的__aenter__协程方法。data的值将在下面的async with语句中的as子句后绑定到data变量。
⑤
yield之后的行将成为__aexit__协程方法。在这里,另一个阻塞调用被委托给线程执行器。
⑥
使用web_page和async with。
这与顺序的@contextmanager装饰器非常相似。请参阅“使用 @contextmanager”以获取更多详细信息,包括在yield行处的错误处理。有关@asynccontextmanager的另一个示例,请参阅contextlib文档。
现在让我们通过将它们与本地协程进行对比来结束异步生成器函数的覆盖范围。
异步生成器与本地协程
以下是本地协程和异步生成器函数之间的一些关键相似性和差异:
-
两者都使用
async def声明。 -
异步生成器的主体中始终包含一个
yield表达式—这就是使其成为生成器的原因。本地协程永远不包含yield。 -
本地协程可能会
return除None之外的某个值。异步生成器只能使用空的return语句。 -
本地协程是可等待的:它们可以被
await表达式驱动或传递给许多接受可等待参数的asyncio函数,例如create_task。异步生成器不可等待。它们是异步可迭代对象,由async for或异步推导驱动。
是时候谈谈异步推导了。
异步推导和异步生成器表达式
PEP 530—异步推导引入了在 Python 3.6 中开始使用async for和await语法的推导和生成器表达式。
PEP 530 定义的唯一可以出现在async def体外的构造是异步生成器表达式。
定义和使用异步生成器表达式
给定来自示例 21-18 的multi_probe异步生成器,我们可以编写另一个异步生成器,仅返回找到的域的名称。下面是如何实现的——再次使用启动了-m asyncio的异步控制台:
>>> from domainlib import multi_probe
>>> names = 'python.org rust-lang.org golang.org no-lang.invalid'.split()
>>> gen_found = (name async for name, found in multi_probe(names) if found) # ①
>>> gen_found
<async_generator object <genexpr> at 0x10a8f9700> # ②
>>> async for name in gen_found: # ③
... print(name)
...
golang.org python.org rust-lang.org
①
使用async for使其成为异步生成器表达式。它可以在 Python 模块的任何地方定义。
②
异步生成器表达式构建了一个async_generator对象——与multi_probe等异步生成器函数返回的对象完全相同。
③
异步生成器对象由async for语句驱动,而async for语句只能出现在async def体内或我在此示例中使用的魔术异步控制台中。
总结一下:异步生成器表达式可以在程序的任何地方定义,但只能在本地协程或异步生成器函数内消耗。
PEP 530 引入的其余构造只能在本地协程或异步生成器函数内定义和使用。
异步推导
PEP 530 的作者 Yury Selivanov 通过下面重现的三个简短代码片段证明了异步推导的必要性。
我们都同意我们应该能够重写这段代码:
result = []
async for i in aiter():
if i % 2:
result.append(i)
就像这样:
result = [i async for i in aiter() if i % 2]
此外,给定一个原生协程 fun,我们应该能够编写这样的代码:
result = [await fun() for fun in funcs]
提示
在列表推导式中使用 await 类似于使用 asyncio.gather。但是 gather 通过其可选的 return_exceptions 参数使您对异常处理有更多控制。Caleb Hattingh 建议始终设置 return_exceptions=True(默认为 False)。请查看 asyncio.gather 文档 了解更多信息。
回到神奇的异步控制台:
>>> names = 'python.org rust-lang.org golang.org no-lang.invalid'.split()
>>> names = sorted(names)
>>> coros = [probe(name) for name in names]
>>> await asyncio.gather(*coros)
[Result(domain='golang.org', found=True),
Result(domain='no-lang.invalid', found=False),
Result(domain='python.org', found=True),
Result(domain='rust-lang.org', found=True)]
>>> [await probe(name) for name in names]
[Result(domain='golang.org', found=True),
Result(domain='no-lang.invalid', found=False),
Result(domain='python.org', found=True),
Result(domain='rust-lang.org', found=True)]
>>>
请注意,我对名称列表进行了排序,以显示结果按提交顺序输出。
PEP 530 允许在列表推导式以及 dict 和 set 推导式中使用 async for 和 await。例如,这里是一个在异步控制台中存储 multi_probe 结果的 dict 推导式:
>>> {name: found async for name, found in multi_probe(names)}
{'golang.org': True, 'python.org': True, 'no-lang.invalid': False,
'rust-lang.org': True}
我们可以在 for 或 async for 子句之前的表达式中使用 await 关键字,也可以在 if 子句之后的表达式中使用。这里是在异步控制台中的一个集合推导式,仅收集找到的域:
>>> {name for name in names if (await probe(name)).found}
{'rust-lang.org', 'python.org', 'golang.org'}
由于 __getattr__ 运算符 .(点)的优先级较高,我不得不在 await 表达式周围加上额外的括号。
再次强调,所有这些推导式只能出现在 async def 主体内或在增强的异步控制台中。
现在让我们谈谈 async 语句、async 表达式以及它们创建的对象的一个非常重要的特性。这些构造经常与 asyncio 一起使用,但实际上它们是独立于库的。
异步超越 asyncio:Curio
Python 的 async/await 语言构造与任何特定的事件循环或库无关。¹⁷ 由于特殊方法提供的可扩展 API,任何足够有动力的人都可以编写自己的异步运行时环境和框架,以驱动原生协程、异步生成器等。
这就是大卫·比兹利在他的 Curio 项目中所做的。他对重新思考如何利用这些新语言特性构建一个从头开始的框架很感兴趣。回想一下,asyncio 是在 Python 3.4 中发布的,它使用 yield from 而不是 await,因此其 API 无法利用异步上下文管理器、异步迭代器以及 async/await 关键字所可能实现的一切。因此,与 asyncio 相比,Curio 具有更清晰的 API 和更简单的实现。
示例 21-21 展示了重新使用 Curio 编写的 blogdom.py 脚本(示例 21-1)。
示例 21-21. blogdom.py:示例 21-1,现在使用 Curio
#!/usr/bin/env python3
from curio import run, TaskGroup
import curio.socket as socket
from keyword import kwlist
MAX_KEYWORD_LEN = 4
async def probe(domain: str) -> tuple[str, bool]: # ①
try:
await socket.getaddrinfo(domain, None) # ②
except socket.gaierror:
return (domain, False)
return (domain, True)
async def main() -> None:
names = (kw for kw in kwlist if len(kw) <= MAX_KEYWORD_LEN)
domains = (f'{name}.dev'.lower() for name in names)
async with TaskGroup() as group: # ③
for domain in domains:
await group.spawn(probe, domain) # ④
async for task in group: # ⑤
domain, found = task.result
mark = '+' if found else ' '
print(f'{mark} {domain}')
if __name__ == '__main__':
run(main()) # ⑥
①
probe 不需要获取事件循环,因为…
②
…getaddrinfo 是 curio.socket 的顶级函数,而不是 loop 对象的方法—就像在 asyncio 中一样。
③
TaskGroup 是 Curio 中的一个核心概念,用于监视和控制多个协程,并确保它们都被执行和清理。
④
TaskGroup.spawn 是启动由特定 TaskGroup 实例管理的协程的方法。该协程由一个 Task 包装。
⑤
使用 async for 在 TaskGroup 上迭代会在每个完成时产生 Task 实例。这对应于 示例 21-1 中使用 for … as_completed(…): 的行。
⑥
Curio 开创了这种在 Python 中启动异步程序的明智方式。
要进一步扩展上述观点:如果您查看第一版 Fluent Python 中关于 asyncio 的代码示例,您会看到反复出现这样的代码行:
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.close()
Curio的TaskGroup是一个异步上下文管理器,替代了asyncio中的几个临时 API 和编码模式。我们刚刚看到如何遍历TaskGroup使得asyncio.as_completed(…)函数变得不再必要。另一个例子:这段来自“任务组”文档的代码收集了组中所有任务的结果:
async with TaskGroup(wait=all) as g:
await g.spawn(coro1)
await g.spawn(coro2)
await g.spawn(coro3)
print('Results:', g.results)
任务组支持结构化并发:一种并发编程形式,将一组异步任务的所有活动限制在单个入口和出口点。这类似于结构化编程,它避免了GOTO命令,并引入了块语句来限制循环和子程序的入口和出口点。当作为异步上下文管理器使用时,TaskGroup确保所有在内部生成的任务在退出封闭块时完成或取消,并处理引发的任何异常。
注意
结构化并发可能会在即将发布的 Python 版本中被asyncio采用。在PEP 654–异常组和 except*中出现了强烈迹象,该 PEP 已经获得了 Python 3.11 的批准。“动机”部分提到了Trio的“nurseries”,他们对任务组的命名方式:“受 Trio nurseries 启发,在asyncio中实现更好的任务生成 API 是这个 PEP 的主要动机。”
Curio的另一个重要特性是更好地支持在同一代码库中使用协程和线程进行编程——这在大多数复杂的异步程序中是必需的。使用await spawn_thread(func, …)启动线程会返回一个具有类似Task接口的AsyncThread对象。线程可以调用协程,这要归功于一个特殊的AWAIT(coro)函数——因为await现在是一个关键字,所以用全大写命名。
Curio还提供了一个UniversalQueue,可用于协调线程、Curio协程和asyncio协程之间的工作。没错,Curio具有允许其在一个线程中与另一个线程中的asyncio一起运行的功能,在同一进程中通过UniversalQueue和UniversalEvent进行通信。这些“通用”类的 API 在协程内外是相同的,但在协程中,您需要在调用前加上await前缀。
当我在 2021 年 10 月写这篇文章时,HTTPX是第一个与Curio兼容的 HTTP 客户端库,但我还不知道有哪些异步数据库库支持它。在Curio存储库中有一组令人印象深刻的网络编程示例,包括一个使用WebSocket的示例,以及实现RFC 8305—Happy Eyeballs并发算法的另一个示例,用于连接到 IPv6 端点,如果需要的话快速回退到 IPv4。
Curio的设计具有影响力。由 Nathaniel J. Smith 创建的Trio框架受Curio的启发很深。Curio可能也促使 Python 贡献者改进了asyncio API 的可用性。例如,在最早的版本中,asyncio用户经常需要获取并传递loop对象,因为一些基本函数要么是loop方法,要么需要一个loop参数。在 Python 的最新版本中,不再经常需要直接访问循环,实际上,几个接受可选loop参数的函数现在正在弃用该参数。
异步类型的类型注释是我们下一个讨论的主题。
异步对象的类型提示
本机协程的返回类型描述了在该协程上await时会得到什么,这是出现在本机协程函数体中return语句的对象类型。¹⁸
本章提供了许多带注释的本机协程示例,包括来自示例 21-21 的probe:
async def probe(domain: str) -> tuple[str, bool]:
try:
await socket.getaddrinfo(domain, None)
except socket.gaierror:
return (domain, False)
return (domain, True)
如果您需要注释一个接受协程对象的参数,则通用类型是:
class typing.Coroutine(Awaitable[V_co], Generic[T_co, T_contra, V_co]):
...
这种类型以及以下类型是在 Python 3.5 和 3.6 中引入的,用于注释异步对象:
class typing.AsyncContextManager(Generic[T_co]):
...
class typing.AsyncIterable(Generic[T_co]):
...
class typing.AsyncIterator(AsyncIterable[T_co]):
...
class typing.AsyncGenerator(AsyncIterator[T_co], Generic[T_co, T_contra]):
...
class typing.Awaitable(Generic[T_co]):
...
使用 Python ≥ 3.9,使用这些的collections.abc等价物。
我想强调这些通用类型的三个方面。
第一点:它们在第一个类型参数上都是协变的,这是从这些对象中产生的项目的类型。回想一下“协变法则”的规则#1:
如果一个正式类型参数定义了对象初始构造后传入对象的数据类型,那么它可以是逆变的。
第二点:AsyncGenerator和Coroutine在倒数第二个参数上是逆变的。这是事件循环调用以驱动异步生成器和协程的低级.send()方法的参数类型。因此,它是一个“输入”类型。因此,它可以是逆变的,根据“逆变法则”#2:
如果一个正式类型参数定义了对象初始构造后进入对象的数据类型,那么它可以是逆变的。
第三点:AsyncGenerator没有返回类型,与我们在“经典协程的通用类型提示”中看到的typing.Generator形成对比。通过引发StopIteration(value)来返回值是使生成器能够作为协程运行并支持yield from的一种技巧,正如我们在“经典协程”中看到的那样。在异步对象之间没有这种重叠:AsyncGenerator对象不返回值,并且与用typing.Coroutine注释的本机协程对象完全分开。
最后,让我们简要讨论异步编程的优势和挑战。
异步工作原理及其不足之处
本章结束部分讨论了关于异步编程的高层思想,无论您使用的是哪种语言或库。
让我们首先解释为什么异步编程如此吸引人的第一个原因,接着是一个流行的神话,以及如何处理它。
绕过阻塞调用
Node.js 的发明者 Ryan Dahl 通过说“我们完全错误地进行 I/O”来介绍他的项目的理念。他将阻塞函数定义为执行文件或网络 I/O 的函数,并认为我们不能像对待非阻塞函数那样对待它们。为了解释原因,他展示了表 21-1 的第二列中的数字。
表 21-1。从不同设备读取数据的现代计算机延迟;第三列显示了按比例的时间,这样我们这些慢人类更容易理解
| 设备 | CPU 周期 | 比例“人类”尺度 |
|---|---|---|
| L1 缓存 | 3 | 3 秒 |
| L2 缓存 | 14 | 14 秒 |
| RAM | 250 | 250 秒 |
| 磁盘 | 41,000,000 | 1.3 年 |
| 网络 | 240,000,000 | 7.6 年 |
要理解表 21-1 的意义,请记住具有 GHz 时钟的现代 CPU 每秒运行数十亿个周期。假设一个 CPU 每秒运行恰好 10 亿个周期。该 CPU 可以在 1 秒内进行超过 3.33 亿次 L1 缓存读取,或者在同一时间内进行 4 次(四次!)网络读取。表 21-1 的第三列通过将第二列乘以一个常数因子来将这些数字放入透视中。因此,在另一个宇宙中,如果从 L1 缓存读取需要 3 秒,那么从网络读取将需要 7.6 年!
表 21-1 解释了为什么对异步编程采取纪律性方法可以导致高性能服务器。挑战在于实现这种纪律。第一步是认识到“I/O 绑定系统”是一个幻想。
I/O 绑定系统的神话
一个常见的重复的梗是异步编程对“I/O 绑定系统”有好处。我以艰难的方式学到,没有“I/O 绑定系统”。你可能有 I/O 绑定函数。也许你系统中绝大多数函数都是 I/O 绑定的;即它们花费更多时间等待 I/O 而不是处理数据。在等待时,它们将控制权让给事件循环,然后事件循环可以驱动其他挂起的任务。但不可避免地,任何非平凡系统都会有一些部分是 CPU 绑定的。即使是微不足道的系统在压力下也会显露出来。在“讲台”中,我讲述了两个异步程序的故事,它们因 CPU 绑定函数减慢事件循环而严重影响性能。
鉴于任何非平凡系统都会有 CPU 绑定函数,处理它们是异步编程成功的关键。
避免 CPU 绑定陷阱
如果你在规模上使用 Python,你应该有一些专门设计用于检测性能回归的自动化测试,一旦它们出现就立即检测到。这在异步代码中至关重要,但也与线程化的 Python 代码相关—因为 GIL。如果你等到减速开始困扰开发团队,那就太晚了。修复可能需要一些重大改变。
当你确定存在 CPU 占用瓶颈时,以下是一些选项:
-
将任务委托给 Python 进程池。
-
将任务委托给外部任务队列。
-
用 Cython、C、Rust 或其他编译为机器码并与 Python/C API 接口的语言重写相关代码,最好释放 GIL。
-
决定你可以承受性能损失并且什么都不做—但记录这个决定以便以后更容易恢复。
外部任务队列应该在项目开始时尽快选择和集成,这样团队中的任何人在需要时都不会犹豫使用它。
最后一个选项—什么都不做—属于技术债务类别。
并发编程是一个迷人的话题,我很想写更多关于它的内容。但这不是本书的主要焦点,而且这已经是最长的章节之一,所以让我们结束吧。
章节总结
对于常规的异步编程方法的问题在于它们都是全有或全无的命题。你要重写所有代码,以便没有任何阻塞,否则你只是在浪费时间。
Alvaro Videla 和 Jason J. W. Williams,《RabbitMQ 实战》
我选择这个章节的引语有两个原因。在高层次上,它提醒我们通过将慢任务委托给不同的处理单元来避免阻塞事件循环,从简单的线程到分布式任务队列。在较低层次上,它也是一个警告:一旦你写下第一个async def,你的程序不可避免地会有越来越多的async def、await、async with和async for。并且使用非异步库突然变得具有挑战性。
在第十九章中简单的spinner示例之后,我们的主要重点是使用本机协程进行异步编程,从blogdom.py DNS 探测示例开始,接着是awaitables的概念。在阅读flags_asyncio.py的源代码时,我们发现了第一个异步上下文管理器的示例。
flag 下载程序的更高级变体引入了两个强大的函数:asyncio.as_completed 生成器和loop.run_in_executor 协程。我们还看到了使用信号量限制并发下载数量的概念和应用—这是对表现良好的 HTTP 客户端的预期。
服务器端异步编程通过mojifinder示例进行展示:一个FastAPI web 服务和tcp_mojifinder.py—后者仅使用asyncio和 TCP 协议。
异步迭代和异步可迭代是接下来的主要话题,包括async for、Python 的异步控制台、异步生成器、异步生成器表达式和异步推导式。
本章的最后一个示例是使用Curio框架重写的blogdom.py,以演示 Python 的异步特性并不局限于asyncio包。Curio还展示了结构化并发的概念,这可能对整个行业产生影响,为并发代码带来更多的清晰度。
最后,在“异步工作原理及其不足之处”下的章节中讨论了异步编程的主要吸引力,对“I/O-bound 系统”的误解,以及如何处理程序中不可避免的 CPU-bound 部分。
进一步阅读
大卫·比兹利在 PyOhio 2016 年的主题演讲“异步中的恐惧和期待”是一个精彩的、现场编码的介绍,展示了由尤里·谢利万诺夫在 Python 3.5 中贡献的async/await关键字所可能带来的语言特性的潜力。在演讲中,比兹利曾抱怨await不能在列表推导式中使用,但谢利万诺夫在同年稍后实现了PEP 530—异步推导式,并在 Python 3.6 中修复了这个问题。除此之外,比兹利演讲中的其他内容都是永恒的,他演示了本章中我们看到的异步对象是如何工作的,而无需任何框架的帮助——只需一个简单的run函数,使用.send(None)来驱动协程。仅在最后,比兹利展示了Curio,这是他在那一年开始的一个实验,看看在没有回调或未来基础的情况下,只使用协程能走多远。事实证明,你可以走得很远——正如Curio的演变和后来由纳撒尼尔·J·史密斯创建的Trio所证明的那样。Curio的文档中有链接指向比兹利在该主题上的更多讲话。
除了创建Trio,纳撒尼尔·J·史密斯还撰写了两篇深度博客文章,我强烈推荐:“在后 async/await 世界中对异步 API 设计的一些思考”,对比了Curio的设计与asyncio的设计,以及“关于结构化并发的笔记,或者:Go 语句为何有害”,关于结构化并发。史密斯还在 StackOverflow 上对问题“asyncio 和 trio 之间的核心区别是什么?”给出了一篇长而富有信息量的回答。
要了解更多关于asyncio包的信息,我在本章开头提到了我所知道的最好的书面资源:由尤里·谢利万诺夫在 2018 年开始的官方文档以及卡勒布·哈廷的书籍Using Asyncio in Python(O’Reilly)。在官方文档中,请务必阅读“使用 asyncio 进行开发”:记录了asyncio调试模式,并讨论了常见的错误和陷阱以及如何避免它们。
对于异步编程的一个非常易懂的、30 分钟的介绍,以及asyncio,可以观看米格尔·格林伯格在 PyCon 2017 上的“面向完全初学者的异步 Python”。另一个很好的介绍是迈克尔·肯尼迪的“揭秘 Python 的 Async 和 Await 关键字”,其中我了解到了unsync库,提供了一个装饰器来将协程、I/O-bound 函数和 CPU-bound 函数的执行委托给asyncio、threading或multiprocessing。
在 EuroPython 2019 上,Lynn Root —— PyLadies 的全球领导者 —— 呈现了优秀的 “高级 asyncio:解决实际生产问题”,这是她在 Spotify 担任工程师的经验所得。
在 2020 年,Łukasz Langa 制作了一系列关于 asyncio 的优秀视频,从 “学习 Python 的 AsyncIO #1—异步生态系统” 开始。Langa 还制作了非常酷的视频 “AsyncIO + 音乐” 为 2020 年的 PyCon,不仅展示了 asyncio 在一个非常具体的事件驱动领域中的应用,还从基础开始解释了它。
另一个被事件驱动编程主导的领域是嵌入式系统。这就是为什么 Damien George 在他的 MicroPython 解释器中为微控制器添加了对 async/await 的支持。在 2018 年的澳大利亚 PyCon 上,Matt Trentini 展示了 uasyncio 库,这是 MicroPython 标准库中 asyncio 的一个子集。
想要更深入地思考 Python 中的异步编程,请阅读 Tom Christie 的博文 “Python 异步框架—超越开发者部落主义”。
最后,我推荐阅读 Bob Nystrom 的 “你的函数是什么颜色?”,讨论了普通函数与异步函数(即协程)在 JavaScript、Python、C# 和其他语言中不兼容的执行模型。剧透警告:Nystrom 的结论是,做对了的语言是 Go,那里所有函数都是同一颜色。我喜欢 Go 的这一点。但我也认为 Nathaniel J. Smith 在他写的 “Go 语句有害” 中有一定道理。没有什么是完美的,而并发编程总是困难的。
¹ Videla & Williams 的 RabbitMQ 实战(Manning),第四章,“用 Rabbit 解决问题:编码和模式”,第 61 页。
² Selivanov 在 Python 中实现了 async/await,并撰写了相关的 PEPs 492、525 和 530。
³ 有一个例外:如果你使用 -m asyncio 选项运行 Python,你可以直接在 >>> 提示符下使用 await 驱动本机协程。这在 “使用 Python 的异步控制台进行实验” 中有解释。
⁴ 对不起,我忍不住了。
⁵ 我写这篇文章时,true.dev 的年费为 360 美元。我看到 for.dev 已注册,但未配置 DNS。
⁶ 这个提示是由技术审阅员 Caleb Hattingh 的评论原文引用。谢谢,Caleb!
⁷ 感谢 Guto Maia 指出,在他阅读本章第一版草稿时,信号量的概念没有得到解释。
⁸ 关于这个问题的详细讨论可以在我在 python-tulip 群组中发起的一个主题中找到,标题为 “asyncio.as_completed 还可能产生哪些其他 futures?”。Guido 回应,并就 as_completed 的实现以及 asyncio 中 futures 和协程之间的密切关系提供了见解。
⁹ 屏幕截图中的带框问号不是你正在阅读的书籍或电子书的缺陷。这是 U+101EC—PHAISTOS DISC SIGN CAT 字符,这个字符在我使用的终端字体中缺失。Phaistos 圆盘 是一件古代文物,上面刻有象形文字,发现于克里特岛。
¹⁰ 你可以使用另一个 ASGI 服务器,如 hypercorn 或 Daphne,而不是 uvicorn。查看官方 ASGI 文档中关于 实现 的页面获取更多信息。
¹¹ 感谢技术审阅员 Miroslav Šedivý指出在代码示例中使用pathlib的好地方。
¹² 如第八章中所述,pydantic在运行时强制执行类型提示,用于数据验证。
¹³ 截至 2021 年 10 月,问题#5535 已关闭,但自那时起 Mypy 并没有发布新版本,因此错误仍然存在。
¹⁴ 技术审阅员 Leonardo Rochael 指出,可以使用loop.run_with_executor()在supervisor协程中将构建索引的工作委托给另一个线程,因此服务器在构建索引的同时即可立即接受请求。这是正确的,但在这个示例中,查询索引是这个服务器唯一要做的事情,所以这并不会带来很大的收益。
¹⁵ 这对于像 Node.js 控制台这样的实验非常有用。感谢 Yury Selivanov 为异步 Python 做出的又一次出色贡献。
¹⁶ 请参阅RFC 6761—特殊用途域名。
¹⁷ 这与 JavaScript 相反,其中async/await被硬编码到内置事件循环和运行时环境中,即浏览器、Node.js 或 Deno。
¹⁸ 这与经典协程的注解不同,如“经典协程的通用类型提示”中所讨论的。
¹⁹ 视频:“Node.js 简介”在 4:55 处。
²⁰ 直到 Go 1.5 发布之前,使用单个线程是默认设置。多年前,Go 已经因为能够实现高度并发的网络系统而赢得了当之无愧的声誉。这是另一个证据,表明并发不需要多个线程或 CPU 核心。
²¹ 不管技术选择如何,这可能是这个项目中最大的错误:利益相关者没有采用 MVP 方法——尽快交付一个最小可行产品,然后以稳定的步伐添加功能。
第五部分:元编程
第二十二章:动态属性和属性
属性的关键重要性在于,它们的存在使得将公共数据属性作为类的公共接口的一部分完全安全且确实可取。
Martelli、Ravenscroft 和 Holden,“为什么属性很重要”¹
在 Python 中,数据属性和方法统称为属性。方法是可调用的属性。动态属性呈现与数据属性相同的接口——即,obj.attr——但是根据需要计算。这遵循 Bertrand Meyer 的统一访问原则:
模块提供的所有服务都应通过统一的表示法可用,这种表示法不会泄露它们是通过存储还是计算实现的。²
在 Python 中有几种实现动态属性的方法。本章涵盖了最简单的方法:@property装饰器和__getattr__特殊方法。
实现__getattr__的用户定义类可以实现我称之为虚拟属性的动态属性变体:这些属性在类的源代码中没有明确声明,也不在实例__dict__中存在,但可能在用户尝试读取不存在的属性时在其他地方检索或在需要时动态计算,例如obj.no_such_attr。
编写动态和虚拟属性是框架作者所做的元编程。然而,在 Python 中,基本技术很简单,因此我们可以在日常数据整理任务中使用它们。这就是我们将在本章开始的方式。
本章的新内容
本章大部分更新的动机来自对@functools.cached_property(Python 3.8 中引入)的讨论,以及@property与@functools.cache(3.9 中新引入)的联合使用。这影响了出现在“计算属性”中的Record和Event类的代码。我还添加了一项重构以利用PEP 412—共享键字典优化。
为了突出更相关的特性,同时保持示例的可读性,我删除了一些非必要的代码——将旧的DbRecord类合并到Record中,用dict替换shelve.Shelve,并删除了下载 OSCON 数据集的逻辑——示例现在从Fluent Python代码库中的本地文件中读取。
使用动态属性进行数据整理
在接下来的几个示例中,我们将利用动态属性处理 O’Reilly 为 OSCON 2014 会议发布的 JSON 数据集。示例 22-1 展示了该数据集中的四条记录。³
示例 22-1。来自 osconfeed.json 的示例记录;一些字段内容已缩写
{
"Schedule": {
"conferences": [{"serial": 115 }],
"events": [
{
"serial": 34505,
"name": "Why Schools Don´t Use Open Source to Teach Programming",
"event_type": "40-minute conference session",
"time_start": "2014-07-23 11:30:00",
"time_stop": "2014-07-23 12:10:00",
"venue_serial": 1462,
"description": "Aside from the fact that high school programming...",
"website_url": "http://oscon.com/oscon2014/public/schedule/detail/34505",
"speakers": [157509],
"categories": ["Education"]
}
],
"speakers": [
{
"serial": 157509,
"name": "Robert Lefkowitz",
"photo": null,
"url": "http://sharewave.com/",
"position": "CTO",
"affiliation": "Sharewave",
"twitter": "sharewaveteam",
"bio": "Robert ´r0ml´ Lefkowitz is the CTO at Sharewave, a startup..."
}
],
"venues": [
{
"serial": 1462,
"name": "F151",
"category": "Conference Venues"
}
]
}
}
示例 22-1 展示了 JSON 文件中的 895 条记录中的 4 条。整个数据集是一个带有键"Schedule"的单个 JSON 对象,其值是另一个具有四个键"conferences"、"events"、"speakers"和"venues"的映射。这四个键中的每一个都映射到一个记录列表。在完整数据集中,"events"、"speakers"和"venues"列表有几十个或几百个记录,而"conferences"只有在示例 22-1 中显示的那一条记录。每条记录都有一个"serial"字段,这是记录在列表中的唯一标识符。
我使用 Python 控制台来探索数据集,如示例 22-2 所示。
示例 22-2. 交互式探索 osconfeed.json
>>> import json
>>> with open('data/osconfeed.json') as fp:
... feed = json.load(fp) # ①
>>> sorted(feed['Schedule'].keys()) # ②
['conferences', 'events', 'speakers', 'venues'] >>> for key, value in sorted(feed['Schedule'].items()):
... print(f'{len(value):3} {key}') # ③
...
1 conferences 484 events 357 speakers
53 venues >>> feed['Schedule']['speakers'][-1]['name'] # ④
'Carina C. Zona' >>> feed['Schedule']['speakers'][-1]['serial'] # ⑤
141590 >>> feed['Schedule']['events'][40]['name']
'There *Will* Be Bugs' >>> feed['Schedule']['events'][40]['speakers'] # ⑥
[3471, 5199]
①
feed是一个包含嵌套字典和列表、字符串和整数值的dict。
②
列出"Schedule"内的四个记录集合。
③
显示每个集合的记录计数。
④
浏览嵌套的字典和列表以获取最后一个演讲者的姓名。
⑤
获取相同演讲者的序列号。
⑥
每个事件都有一个带有零个或多个演讲者序列号的'speakers'列表。
使用动态属性探索类似 JSON 的数据
示例 22-2 足够简单,但是feed['Schedule']['events'][40]['name']这样的语法很繁琐。在 JavaScript 中,您可以通过编写feed.Schedule.events[40].name来获取相同的值。在 Python 中,可以很容易地实现一个类似dict的类来做同样的事情——网络上有很多实现。⁴ 我写了FrozenJSON,比大多数方案更简单,因为它只支持读取:它只是用于探索数据。FrozenJSON也是递归的,自动处理嵌套的映射和列表。
示例 22-3 是FrozenJSON的演示,源代码显示在示例 22-4 中。
示例 22-3. FrozenJSON来自示例 22-4,允许读取属性如name,并调用方法如.keys()和.items()
>>> import json
>>> raw_feed = json.load(open('data/osconfeed.json'))
>>> feed = FrozenJSON(raw_feed) # ①
>>> len(feed.Schedule.speakers) # ②
357
>>> feed.keys()
dict_keys(['Schedule'])
>>> sorted(feed.Schedule.keys()) # ③
['conferences', 'events', 'speakers', 'venues']
>>> for key, value in sorted(feed.Schedule.items()): # ④
... print(f'{len(value):3} {key}')
...
1 conferences
484 events
357 speakers
53 venues
>>> feed.Schedule.speakers[-1].name # ⑤
'Carina C. Zona'
>>> talk = feed.Schedule.events[40]
>>> type(talk) # ⑥
<class 'explore0.FrozenJSON'>
>>> talk.name
'There *Will* Be Bugs'
>>> talk.speakers # ⑦
[3471, 5199]
>>> talk.flavor # ⑧
Traceback (most recent call last):
...
KeyError: 'flavor'
①
从由嵌套字典和列表组成的raw_feed构建一个FrozenJSON实例。
②
FrozenJSON允许通过属性表示法遍历嵌套字典;这里显示了演讲者列表的长度。
③
也可以访问底层字典的方法,比如.keys(),以检索记录集合名称。
④
使用items(),我们可以检索记录集合的名称和内容,以显示每个集合的len()。
⑤
一个list,比如feed.Schedule.speakers,仍然是一个列表,但其中的项目如果是映射,则转换为FrozenJSON。
⑥
events列表中的第 40 项是一个 JSON 对象;现在它是一个FrozenJSON实例。
⑦
事件记录有一个带有演讲者序列号的speakers列表。
⑧
尝试读取一个不存在的属性会引发KeyError,而不是通常的AttributeError。
FrozenJSON类的关键是__getattr__方法,我们已经在“Vector Take #3: Dynamic Attribute Access”中的Vector示例中使用过它,通过字母检索Vector组件:v.x、v.y、v.z等。需要记住的是,只有在通常的过程无法检索属性时(即,当实例、类或其超类中找不到命名属性时),解释器才会调用__getattr__特殊方法。
示例 22-3 的最后一行揭示了我的代码存在一个小问题:尝试读取一个不存在的属性应该引发AttributeError,而不是显示的KeyError。当我实现错误处理时,__getattr__方法变得两倍长,分散了我想展示的最重要的逻辑。考虑到用户会知道FrozenJSON是由映射和列表构建的,我认为KeyError并不会太令人困惑。
示例 22-4. explore0.py:将 JSON 数据集转换为包含嵌套FrozenJSON对象、列表和简单类型的FrozenJSON
from collections import abc
class FrozenJSON:
"""A read-only façade for navigating a JSON-like object
using attribute notation
"""
def __init__(self, mapping):
self.__data = dict(mapping) # ①
def __getattr__(self, name): # ②
try:
return getattr(self.__data, name) # ③
except AttributeError:
return FrozenJSON.build(self.__data[name]) # ④
def __dir__(self): # ⑤
return self.__data.keys()
@classmethod
def build(cls, obj): # ⑥
if isinstance(obj, abc.Mapping): # ⑦
return cls(obj)
elif isinstance(obj, abc.MutableSequence): # ⑧
return [cls.build(item) for item in obj]
else: # ⑨
return obj
①
从mapping参数构建一个dict。这确保我们得到一个映射或可以转换为映射的东西。__data上的双下划线前缀使其成为私有属性。
②
只有当没有具有该name的属性时才会调用__getattr__。
③
如果name匹配实例__data dict中的属性,则返回该属性。这就是处理像feed.keys()这样的调用的方式:keys方法是__data dict的一个属性。
④
否则,从self.__data中的键name获取项目,并返回调用FrozenJSON.build()的结果。⁵
⑤
实现__dir__支持dir()内置函数,这将支持标准 Python 控制台以及 IPython、Jupyter Notebook 等的自动补全。这段简单的代码将基于self.__data中的键启用递归自动补全,因为__getattr__会动态构建FrozenJSON实例——对于交互式数据探索非常有用。
⑥
这是一个替代构造函数,@classmethod装饰器的常见用法。
⑦
如果obj是一个映射,用它构建一个FrozenJSON。这是鹅类型的一个例子——如果需要复习,请参阅“鹅类型”。
⑧
如果它是一个MutableSequence,它必须是一个列表,⁶因此我们通过递归地将obj中的每个项目传递给.build()来构建一个list。
⑨
如果不是dict或list,则返回原样。
FrozenJSON实例有一个名为_FrozenJSON__data的私有实例属性,如“Python 中的私有和‘受保护’属性”中所解释的那样。尝试使用其他名称检索属性将触发__getattr__。该方法首先查看self.__data dict是否具有该名称的属性(而不是键!);这允许FrozenJSON实例处理dict方法,比如通过委托给self.__data.items()来处理items。如果self.__data没有具有给定name的属性,__getattr__将使用name作为键从self.__data中检索项目,并将该项目传递给FrozenJSON.build。这允许通过build类方法将 JSON 数据中的嵌套结构转换为另一个FrozenJSON实例。
请注意,FrozenJSON不会转换或缓存原始数据集。当我们遍历数据时,__getattr__会一遍又一遍地创建FrozenJSON实例。对于这个大小的数据集和仅用于探索或转换数据的脚本来说,这是可以接受的。
任何生成或模拟来自任意来源的动态属性名称的脚本都必须处理一个问题:原始数据中的键可能不适合作为属性名称。下一节将解决这个问题。
无效属性名称问题
FrozenJSON代码不处理作为 Python 关键字的属性名称。例如,如果构建一个这样的对象:
>>> student = FrozenJSON({'name': 'Jim Bo', 'class': 1982})
你无法读取student.class,因为class是 Python 中的保留关键字:
>>> student.class
File "<stdin>", line 1
student.class
^
SyntaxError: invalid syntax
当然你总是可以这样做:
>>> getattr(student, 'class')
1982
但FrozenJSON的理念是提供对数据的便捷访问,因此更好的解决方案是检查传递给FrozenJSON.__init__的映射中的键是否是关键字,如果是,则在其末尾添加_,这样就可以像这样读取属性:
>>> student.class_
1982
通过用示例 22-5 中的版本替换示例 22-4 中的一行__init__,可以实现这一点。
示例 22-5. explore1.py:为 Python 关键字添加_作为属性名称的后缀
def __init__(self, mapping):
self.__data = {}
for key, value in mapping.items():
if keyword.iskeyword(key): # ①
key += '_'
self.__data[key] = value
①
keyword.iskeyword(…)函数正是我们需要的;要使用它,必须导入keyword模块,这在这个片段中没有显示。
如果 JSON 记录中的键不是有效的 Python 标识符,可能会出现类似的问题:
>>> x = FrozenJSON({'2be':'or not'})
>>> x.2be
File "<stdin>", line 1
x.2be
^
SyntaxError: invalid syntax
在 Python 3 中,这些有问题的键很容易检测,因为str类提供了s.isidentifier()方法,告诉您s是否是根据语言语法的有效 Python 标识符。但将一个不是有效标识符的键转换为有效的属性名称并不是简单的。一个解决方案是实现__getitem__,允许使用x['2be']这样的表示法进行属性访问。为简单起见,我不会担心这个问题。
在考虑动态属性名称之后,让我们转向FrozenJSON的另一个重要特性:build类方法的逻辑。Frozen.JSON.build被__getattr__使用,根据正在访问的属性的值返回不同类型的对象:嵌套结构转换为FrozenJSON实例或FrozenJSON实例列表。
相同的逻辑可以实现为__new__特殊方法,而不是类方法,我们将在下面看到。
使用__new__进行灵活的对象创建
我们经常将__init__称为构造方法,但这是因为我们从其他语言中采用了术语。在 Python 中,__init__将self作为第一个参数,因此当解释器调用__init__时,对象已经存在。此外,__init__不能返回任何内容。因此,它实际上是一个初始化器,而不是构造函数。
当调用类以创建实例时,Python 在该类上调用的特殊方法来构造实例是__new__。它是一个类方法,但得到特殊处理,因此不适用@classmethod装饰器。Python 获取__new__返回的实例,然后将其作为__init__的第一个参数self传递。我们很少需要编写__new__,因为从object继承的实现对绝大多数用例都足够了。
如果必要,__new__方法也可以返回不同类的实例。当发生这种情况时,解释器不会调用__init__。换句话说,Python 构建对象的逻辑类似于这个伪代码:
# pseudocode for object construction
def make(the_class, some_arg):
new_object = the_class.__new__(some_arg)
if isinstance(new_object, the_class):
the_class.__init__(new_object, some_arg)
return new_object
# the following statements are roughly equivalent
x = Foo('bar')
x = make(Foo, 'bar')
示例 22-6 展示了FrozenJSON的一个变体,其中前一个build类方法的逻辑移至__new__。
示例 22-6. explore2.py:使用__new__而不是build来构建可能是FrozenJSON实例的新对象。
from collections import abc
import keyword
class FrozenJSON:
"""A read-only façade for navigating a JSON-like object
using attribute notation
"""
def __new__(cls, arg): # ①
if isinstance(arg, abc.Mapping):
return super().__new__(cls) # ②
elif isinstance(arg, abc.MutableSequence): # ③
return [cls(item) for item in arg]
else:
return arg
def __init__(self, mapping):
self.__data = {}
for key, value in mapping.items():
if keyword.iskeyword(key):
key += '_'
self.__data[key] = value
def __getattr__(self, name):
try:
return getattr(self.__data, name)
except AttributeError:
return FrozenJSON(self.__data[name]) # ④
def __dir__(self):
return self.__data.keys()
①
作为类方法,__new__的第一个参数是类本身,其余参数与__init__得到的参数相同,除了self。
②
默认行为是委托给超类的__new__。在这种情况下,我们从object基类调用__new__,将FrozenJSON作为唯一参数传递。
③
__new__的其余行与旧的build方法完全相同。
④
以前调用FrozenJSON.build的地方;现在我们只需调用FrozenJSON类,Python 会通过调用FrozenJSON.__new__来处理。
__new__方法将类作为第一个参数,因为通常创建的对象将是该类的实例。因此,在FrozenJSON.__new__中,当表达式super().__new__(cls)有效地调用object.__new__(FrozenJSON)时,由object类构建的实例实际上是FrozenJSON的实例。新实例的__class__属性将保存对FrozenJSON的引用,即使实际的构造是由解释器的内部实现的object.__new__在 C 中执行。
OSCON JSON 数据集的结构对于交互式探索并不有用。例如,索引为40的事件,标题为'There *Will* Be Bugs',有两位演讲者,3471和5199。查找演讲者的姓名很麻烦,因为那些是序列号,而Schedule.speakers列表不是按照它们进行索引的。要获取每位演讲者,我们必须遍历该列表,直到找到一个具有匹配序列号的记录。我们的下一个任务是重组数据,以准备自动检索链接记录。
我们在第十一章“可散列的 Vector2d”中首次看到@property装饰器。在示例 11-7 中,我在Vector2d中使用了两个属性,只是为了使x和y属性只读。在这里,我们将看到计算值的属性,从而讨论如何缓存这些值。
OSCON JSON 数据中的'events'列表中的记录包含指向'speakers'和'venues'列表中记录的整数序列号。例如,这是会议演讲的记录(省略了描述):
{
"serial": 33950,
"name": "There *Will* Be Bugs",
"event_type": "40-minute conference session",
"time_start": "2014-07-23 14:30:00",
"time_stop": "2014-07-23 15:10:00",
"venue_serial": 1449,
"description": "If you're pushing the envelope of programming...",
"website_url": "http://oscon.com/oscon2014/public/schedule/detail/33950",
"speakers": [3471, 5199],
"categories": ["Python"]
}
我们将实现一个具有venue和speakers属性的Event类,以便自动返回链接数据,换句话说,“解引用”序列号。给定一个Event实例,示例 22-7 展示了期望的行为。
示例 22-7。读取venue和speakers返回Record对象
>>> event # ①
<Event 'There *Will* Be Bugs'> >>> event.venue # ②
<Record serial=1449> >>> event.venue.name # ③
'Portland 251' >>> for spkr in event.speakers: # ④
... print(f'{spkr.serial}: {spkr.name}') ... 3471: Anna Martelli Ravenscroft 5199: Alex Martelli
①
给定一个Event实例…
②
…读取event.venue返回一个Record对象,而不是一个序列号。
③
现在很容易获取venue的名称。
④
event.speakers属性返回一个Record实例列表。
和往常一样,我们将逐步构建代码,从Record类和一个函数开始,该函数读取 JSON 数据并返回一个带有Record实例的dict。
步骤 1:基于数据创建属性
示例 22-8 展示了指导这一步骤的 doctest。
示例 22-8. 测试 schedule_v1.py(来自示例 22-9)
>>> records = load(JSON_PATH) # ①
>>> speaker = records['speaker.3471'] # ②
>>> speaker # ③
<Record serial=3471>
>>> speaker.name, speaker.twitter # ④
('Anna Martelli Ravenscroft', 'annaraven')
①
load一个带有 JSON 数据的dict。
②
records中的键是由记录类型和序列号构建的字符串。
③
speaker是在示例 22-9 中定义的Record类的实例。
④
可以将原始 JSON 中的字段作为Record实例属性检索。
schedule_v1.py的代码在示例 22-9 中。
示例 22-9. schedule_v1.py:重新组织 OSCON 日程数据
import json
JSON_PATH = 'data/osconfeed.json'
class Record:
def __init__(self, **kwargs):
self.__dict__.update(kwargs) # ①
def __repr__(self):
return f'<{self.__class__.__name__} serial={self.serial!r}>' # ②
def load(path=JSON_PATH):
records = {} # ③
with open(path) as fp:
raw_data = json.load(fp) # ④
for collection, raw_records in raw_data['Schedule'].items(): # ⑤
record_type = collection[:-1] # ⑥
for raw_record in raw_records:
key = f'{record_type}.{raw_record["serial"]}' # ⑦
records[key] = Record(**raw_record) # ⑧
return records
①
这是一个常见的快捷方式,用关键字参数构建属性的实例(详细解释如下)。
②
使用serial字段构建自定义的Record表示,如示例 22-8 所示。
③
load最终将返回Record实例的dict。
④
解析 JSON,返回本机 Python 对象:列表、字典、字符串、数字等。
⑤
迭代四个名为'conferences'、'events'、'speakers'和'venues'的顶级列表。
⑥
record_type是列表名称去掉最后一个字符,所以speakers变成speaker。在 Python ≥ 3.9 中,我们可以更明确地使用collection.removesuffix('s')来做到这一点——参见PEP 616—删除前缀和后缀的字符串方法。
⑦
构建格式为'speaker.3471'的key。
⑧
创建一个Record实例,并将其保存在带有key的records中。
Record.__init__方法展示了一个古老的 Python 技巧。回想一下,对象的__dict__是其属性所在的地方——除非在类中声明了__slots__,就像我们在“使用 slots 节省内存”中看到的那样。因此,使用映射更新实例__dict__是一种快速创建该实例中一堆属性的方法。⁷
注意
根据应用程序的不同,Record类可能需要处理不是有效属性名称的键,就像我们在“无效属性名称问题”中看到的那样。处理这个问题会分散这个示例的关键思想,并且在我们正在读取的数据集中并不是一个问题。
在示例 22-9 中Record的定义是如此简单,以至于你可能会想为什么我没有在之前使用它,而是使用更复杂的FrozenJSON。有两个原因。首先,FrozenJSON通过递归转换嵌套映射和列表来工作;Record不需要这样做,因为我们转换的数据集中没有映射嵌套在映射或列表中。记录只包含字符串、整数、字符串列表和整数列表。第二个原因:FrozenJSON提供对嵌入的__data dict属性的访问——我们用它来调用像.keys()这样的方法——现在我们也不需要那个功能了。
注意
Python 标准库提供了类似于Record的类,其中每个实例都有一个从给定给__init__的关键字参数构建的任意属性集:types.SimpleNamespace、argparse.Namespace和multiprocessing.managers.Namespace。我编写了更简单的Record类来突出显示基本思想:__init__更新实例__dict__。
重新组织日程数据集后,我们可以增强Record类,自动检索event记录中引用的venue和speaker记录。我们将在接下来的示例中使用属性来实现这一点。
第 2 步:检索链接记录的属性
下一个版本的目标是:给定一个event记录,读取其venue属性将返回一个Record。这类似于 Django ORM 在访问ForeignKey字段时的操作:您将获得链接的模型对象,而不是键。
我们将从venue属性开始。查看示例 22-10 中的部分交互作为示例。
示例 22-10. 从 schedule_v2.py 的 doctests 中提取
>>> event = Record.fetch('event.33950') # ①
>>> event # ②
<Event 'There *Will* Be Bugs'>
>>> event.venue # ③
<Record serial=1449>
>>> event.venue.name # ④
'Portland 251'
>>> event.venue_serial # ⑤
1449
①
Record.fetch静态方法从数据集中获取一个Record或一个Event。
②
请注意,event是Event类的一个实例。
③
访问event.venue将返回一个Record实例。
④
现在很容易找出event.venue的名称。
⑤
Event实例还具有来自 JSON 数据的venue_serial属性。
Event是Record的一个子类,添加了一个venue来检索链接的记录,以及一个专门的__repr__方法。
本节的代码位于schedule_v2.py模块中,位于Fluent Python代码库中。示例有近 60 行,所以我将分部分呈现,从增强的Record类开始。
示例 22-11. schedule_v2.py:具有新fetch方法的Record类
import inspect # ①
import json
JSON_PATH = 'data/osconfeed.json'
class Record:
__index = None # ②
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
def __repr__(self):
return f'<{self.__class__.__name__} serial={self.serial!r}>'
@staticmethod # ③
def fetch(key):
if Record.__index is None: # ④
Record.__index = load()
return Record.__index[key] # ⑤
①
inspect将在示例 22-13 中使用。
②
__index私有类属性最终将保存对load返回的dict的引用。
③
fetch是一个staticmethod,明确表示其效果不受调用它的实例或类的影响。
④
如有需要,填充Record.__index。
⑤
使用它来检索具有给定key的记录。
提示
这是一个使用staticmethod的例子。fetch方法始终作用于Record.__index类属性,即使从子类调用,如Event.fetch()—我们很快会探讨。将其编码为类方法会产生误导,因为不会使用cls第一个参数。
现在我们来看Event类中属性的使用,列在示例 22-12 中。
示例 22-12. schedule_v2.py:Event类
class Event(Record): # ①
def __repr__(self):
try:
return f'<{self.__class__.__name__} {self.name!r}>' # ②
except AttributeError:
return super().__repr__()
@property
def venue(self):
key = f'venue.{self.venue_serial}'
return self.__class__.fetch(key) # ③
①
Event扩展了Record。
②
如果实例具有name属性,则用于生成自定义表示。否则,委托给Record的__repr__。
③
venue属性从venue_serial属性构建一个key,并将其传递给从Record继承的fetch类方法(使用self.__class__的原因将很快解释)。
Example 22-12 的venue方法的第二行返回self.__class__.fetch(key)。为什么不简单地调用self.fetch(key)?简单形式适用于特定的 OSCON 数据集,因为没有带有'fetch'键的事件记录。但是,如果事件记录有一个名为'fetch'的键,那么在特定的Event实例内,引用self.fetch将检索该字段的值,而不是Event从Record继承的fetch类方法。这是一个微妙的错误,它很容易在测试中被忽略,因为它取决于数据集。
警告
在从数据创建实例属性名称时,总是存在由于类属性(如方法)的遮蔽或由于意外覆盖现有实例属性而导致的错误风险。这些问题可能解释了为什么 Python 字典一开始就不像 JavaScript 对象。
如果Record类的行为更像映射,实现动态的__getitem__而不是动态的__getattr__,那么就不会有由于覆盖或遮蔽而导致的错误风险。自定义映射可能是实现Record的 Pythonic 方式。但是如果我选择这条路,我们就不会研究动态属性编程的技巧和陷阱。
该示例的最后一部分是 Example 22-13 中修改后的load函数。
示例 22-13. schedule_v2.py:load函数
def load(path=JSON_PATH):
records = {}
with open(path) as fp:
raw_data = json.load(fp)
for collection, raw_records in raw_data['Schedule'].items():
record_type = collection[:-1] # ①
cls_name = record_type.capitalize() # ②
cls = globals().get(cls_name, Record) # ③
if inspect.isclass(cls) and issubclass(cls, Record): # ④
factory = cls # ⑤
else:
factory = Record # ⑥
for raw_record in raw_records: # ⑦
key = f'{record_type}.{raw_record["serial"]}'
records[key] = factory(**raw_record) # ⑧
return records
①
到目前为止,与schedule_v1.py中的load没有任何变化(Example 22-9)。
②
将record_type大写以获得可能的类名;例如,'event'变为'Event'。
③
从模块全局范围获取该名称的对象;如果没有这样的对象,则获取Record类。
④
如果刚刚检索到的对象是一个类,并且是Record的子类…
⑤
…将factory名称绑定到它。这意味着factory可以是Record的任何子类,取决于record_type。
⑥
否则,将factory名称绑定到Record。
⑦
创建key并保存记录的for循环与以前相同,只是…
⑧
…存储在records中的对象由factory构造,该factory可以是Record或根据record_type选择的Event等子类。
请注意,唯一具有自定义类的record_type是Event,但如果编写了名为Speaker或Venue的类,load将在构建和保存记录时自动使用这些类,而不是默认的Record类。
现在我们将相同的想法应用于Events类中的新speakers属性。
第三步:覆盖现有属性
Example 22-12 中venue属性的名称与"events"集合中的记录字段名称不匹配。它的数据来自venue_serial字段名称。相比之下,events集合中的每个记录都有一个speakers字段,其中包含一系列序列号。我们希望将该信息作为Event实例中的speakers属性公开,该属性返回Record实例的列表。这种名称冲突需要特别注意,正如 Example 22-14 所示。
示例 22-14. schedule_v3.py:speakers属性
@property
def speakers(self):
spkr_serials = self.__dict__['speakers'] # ①
fetch = self.__class__.fetch
return [fetch(f'speaker.{key}')
for key in spkr_serials] # ②
①
我们想要的数据在speakers属性中,但我们必须直接从实例__dict__中检索它,以避免对speakers属性的递归调用。
②
返回一个具有与 spkr_serials 中数字对应的键的所有记录列表。
在 speakers 方法内部,尝试读取 self.speakers 将会快速引发 RecursionError。然而,如果通过 self.__dict__['speakers'] 读取相同的数据,Python 通常用于检索属性的算法将被绕过,属性不会被调用,递归被避免。因此,直接读取或写入对象的 __dict__ 中的数据是一种常见的 Python 元编程技巧。
警告
解释器通过首先查看 obj 的类来评估 obj.my_attr。如果类具有与 my_attr 名称相同的属性,则该属性会遮蔽同名的实例属性。“属性覆盖实例属性” 中的示例将演示这一点,而 第二十三章 将揭示属性是作为描述符实现的——这是一种更强大和通用的抽象。
当我编写 示例 22-14 中的列表推导式时,我的程序员蜥蜴大脑想到:“这可能会很昂贵。” 实际上并不是,因为 OSCON 数据集中的事件只有少数演讲者,所以编写任何更复杂的东西都会过早优化。然而,缓存属性是一个常见的需求,但也有一些注意事项。让我们在接下来的示例中看看如何做到这一点。
步骤 4:定制属性缓存
缓存属性是一个常见的需求,因为人们期望像 event.venue 这样的表达式应该是廉价的。⁸ 如果 Record.fetch 方法背后的 Event 属性需要查询数据库或 Web API,某种形式的缓存可能会变得必要。
在第一版 Fluent Python 中,我为 speakers 方法编写了自定义缓存逻辑,如 示例 22-15 所示。
示例 22-15. 使用 hasattr 的自定义缓存逻辑会禁用键共享优化
@property
def speakers(self):
if not hasattr(self, '__speaker_objs'): # ①
spkr_serials = self.__dict__['speakers']
fetch = self.__class__.fetch
self.__speaker_objs = [fetch(f'speaker.{key}')
for key in spkr_serials]
return self.__speaker_objs # ②
①
如果实例没有名为 __speaker_objs 的属性,则获取演讲者对象并将它们存储在那里。
②
返回 self.__speaker_objs。
在 示例 22-15 中手动缓存是直接的,但在实例初始化后创建属性会破坏 PEP 412—Key-Sharing Dictionary 优化,如 “dict 工作原理的实际后果” 中所解释的。根据数据集的大小,内存使用量的差异可能很重要。
一个类似的手动解决方案,与键共享优化很好地配合使用,需要为 Event 类编写一个 __init__,以创建必要的 __speaker_objs 并将其初始化为 None,然后在 speakers 方法中检查这一点。参见 示例 22-16。
示例 22-16. 在 __init__ 中定义存储以利用键共享优化
class Event(Record):
def __init__(self, **kwargs):
self.__speaker_objs = None
super().__init__(**kwargs)
# 15 lines omitted...
@property
def speakers(self):
if self.__speaker_objs is None:
spkr_serials = self.__dict__['speakers']
fetch = self.__class__.fetch
self.__speaker_objs = [fetch(f'speaker.{key}')
for key in spkr_serials]
return self.__speaker_objs
示例 22-15 和 22-16 展示了在传统 Python 代码库中相当常见的简单缓存技术。然而,在多线程程序中,像这样的手动缓存会引入可能导致数据损坏的竞争条件。如果两个线程正在读取以前未缓存的属性,则第一个线程将需要计算缓存属性的数据(示例中的 __speaker_objs),而第二个线程可能会读取尚不完整的缓存值。
幸运的是,Python 3.8 引入了 @functools.cached_property 装饰器,它是线程安全的。不幸的是,它带来了一些注意事项,接下来会解释。
步骤 5:使用 functools 缓存属性
functools 模块提供了三个用于缓存的装饰器。我们在 “使用 functools.cache 进行记忆化”(第九章)中看到了 @cache 和 @lru_cache。Python 3.8 引入了 @cached_property。
functools.cached_property 装饰器将方法的结果缓存到具有相同名称的实例属性中。例如,在 示例 22-17 中,venue 方法计算的值存储在 self 中的 venue 属性中。之后,当客户端代码尝试读取 venue 时,新创建的 venue 实例属性将被使用,而不是方法。
示例 22-17. 使用 @cached_property 的简单示例
@cached_property
def venue(self):
key = f'venue.{self.venue_serial}'
return self.__class__.fetch(key)
在 “第 3 步:覆盖现有属性的属性” 中,我们看到属性通过相同名称的实例属性进行遮蔽。如果这是真的,那么 @cached_property 如何工作呢?如果属性覆盖了实例属性,那么 venue 属性将被忽略,venue 方法将始终被调用,每次计算 key 并运行 fetch!
答案有点令人沮丧:cached_property 是一个误称。@cached_property 装饰器并不创建一个完整的属性,而是创建了一个 非覆盖描述符。描述符是一个管理另一个类中属性访问的对象。我们将在 第二十三章 中深入探讨描述符。property 装饰器是一个用于创建 覆盖描述符 的高级 API。第二十三章 将详细解释 覆盖 与 非覆盖 描述符的区别。
现在,让我们暂时搁置底层实现,关注从用户角度看 cached_property 和 property 之间的区别。Raymond Hettinger 在 Python 文档 中很好地解释了它们:
cached_property()的机制与property()有所不同。普通属性会阻止属性写入,除非定义了 setter。相比之下,cached_property允许写入。
cached_property装饰器仅在查找时运行,并且仅当同名属性不存在时才运行。当它运行时,cached_property会写入具有相同名称的属性。随后的属性读取和写入优先于cached_property方法,并且它的工作方式类似于普通属性。缓存的值可以通过删除属性来清除。这允许
cached_property方法再次运行。⁹
回到我们的 Event 类:@cached_property 的具体行为使其不适合装饰 speakers,因为该方法依赖于一个名为 speakers 的现有属性,其中包含活动演讲者的序列号。
警告
@cached_property 有一些重要的限制:
-
如果装饰的方法已经依赖于同名实例属性,则它不能作为
@property的即插即用替代品。 -
它不能在定义了
__slots__的类中使用。 -
它打败了实例
__dict__的键共享优化,因为它在__init__之后创建了一个实例属性。
尽管存在这些限制,@cached_property 以简单的方式满足了常见需求,并且是线程安全的。它的 Python 代码 是使用 可重入锁 的一个示例。
@cached_property 的 文档 建议了一个替代解决方案,我们可以在 speakers 上使用 @property 和 @cache 装饰器叠加,就像 示例 22-18 中展示的那样。
示例 22-18. 在 @property 上叠加 @cache
@property # ①
@cache # ②
def speakers(self):
spkr_serials = self.__dict__['speakers']
fetch = self.__class__.fetch
return [fetch(f'speaker.{key}')
for key in spkr_serials]
①
顺序很重要:@property 放在最上面…
②
…@cache。
从“堆叠装饰器”中回想一下该语法的含义。示例 22-18 的前三行类似于:
speakers = property(cache(speakers))
@cache应用于speakers,返回一个新函数。然后,该函数被@property装饰,将其替换为一个新构造的属性。
这结束了我们对只读属性和缓存装饰器的讨论,探索 OSCON 数据集。在下一节中,我们将开始一个新系列的示例,创建读/写属性。
使用属性进行属性验证
除了计算属性值外,属性还用于通过将公共属性更改为由 getter 和 setter 保护的属性来强制执行业务规则,而不影响客户端代码。让我们通过一个扩展示例来详细讨论。
LineItem 第一次尝试:订单中的商品类
想象一个销售散装有机食品的商店的应用程序,客户可以按重量订购坚果、干果或谷物。在该系统中,每个订单将包含一系列行项目,每个行项目可以由一个类的实例表示,如示例 22-19 中所示。
示例 22-19。bulkfood_v1.py:最简单的LineItem类
class LineItem:
def __init__(self, description, weight, price):
self.description = description
self.weight = weight
self.price = price
def subtotal(self):
return self.weight * self.price
这很简单明了。也许太简单了。示例 22-20 展示了一个问题。
示例 22-20。负重导致负小计
>>> raisins = LineItem('Golden raisins', 10, 6.95)
>>> raisins.subtotal()
69.5
>>> raisins.weight = -20 # garbage in...
>>> raisins.subtotal() # garbage out...
-139.0
这只是一个玩具示例,但并不像你想象的那样幻想。这是亚马逊.com 早期的一个故事:
我们发现客户可以订购负数数量的书!然后我们会用价格给他们的信用卡记账,我猜,等待他们发货。
亚马逊.com 创始人兼首席执行官杰夫·贝索斯¹⁰
我们如何解决这个问题?我们可以改变LineItem的接口,使用 getter 和 setter 来处理weight属性。那将是 Java 的方式,这并不是错误的。
另一方面,能够通过简单赋值来设置物品的weight是很自然的;也许系统已经在生产中,其他部分已经直接访问item.weight。在这种情况下,Python 的做法是用属性替换数据属性。
LineItem 第二次尝试:一个验证属性
实现一个属性将允许我们使用一个 getter 和一个 setter,但LineItem的接口不会改变(即,设置LineItem的weight仍然写作raisins.weight = 12)。
示例 22-21 列出了一个读/写weight属性的代码。
示例 22-21。bulkfood_v2.py:带有weight属性的LineItem
class LineItem:
def __init__(self, description, weight, price):
self.description = description
self.weight = weight # ①
self.price = price
def subtotal(self):
return self.weight * self.price
@property # ②
def weight(self): # ③
return self.__weight # ④
@weight.setter # ⑤
def weight(self, value):
if value > 0:
self.__weight = value # ⑥
else:
raise ValueError('value must be > 0') # ⑦
①
这里属性 setter 已经在使用中,确保不会创建带有负weight的实例。
②
@property装饰 getter 方法。
③
所有实现属性的方法都共享公共属性的名称:weight。
④
实际值存储在私有属性__weight中。
⑤
装饰的 getter 具有.setter属性,这也是一个装饰器;这将 getter 和 setter 绑定在一起。
⑥
如果值大于零,我们设置私有__weight。
⑦
否则,将引发ValueError。
请注意,现在无法创建具有无效重量的LineItem:
>>> walnuts = LineItem('walnuts', 0, 10.00)
Traceback (most recent call last):
...
ValueError: value must be > 0
现在我们已经保护了weight免受用户提供负值的影响。尽管买家通常不能设置物品的价格,但是文书错误或错误可能会创建一个具有负price的LineItem。为了防止这种情况,我们也可以将price转换为属性,但这将在我们的代码中产生一些重复。
记住保罗·格雷厄姆在第十七章中的引用:“当我在我的程序中看到模式时,我认为这是一个麻烦的迹象。”重复的治疗方法是抽象。有两种抽象属性定义的方法:使用属性工厂或描述符类。描述符类方法更灵活,我们将在第二十三章中全面讨论它。实际上,属性本身是作为描述符类实现的。但在这里,我们将通过实现一个函数作为属性工厂来继续探讨属性。
但在我们实现属性工厂之前,我们需要更深入地了解属性。
对属性进行适当的查看
尽管经常被用作装饰器,但property内置实际上是一个类。在 Python 中,函数和类通常是可互换的,因为两者都是可调用的,而且没有用于对象实例化的new运算符,因此调用构造函数与调用工厂函数没有区别。并且两者都可以用作装饰器,只要它们返回一个适当替代被装饰的可调用对象。
这是property构造函数的完整签名:
property(fget=None, fset=None, fdel=None, doc=None)
所有参数都是可选的,如果没有为其中一个参数提供函数,则生成的属性对象不允许相应的操作。
property类型是在 Python 2.2 中添加的,但@装饰器语法只在 Python 2.4 中出现,因此在几年内,属性是通过将访问器函数作为前两个参数来定义的。
用装饰器的方式定义属性的“经典”语法在示例 22-22 中有所说明。
示例 22-22。bulkfood_v2b.py:与示例 22-21 相同,但不使用装饰器
class LineItem:
def __init__(self, description, weight, price):
self.description = description
self.weight = weight
self.price = price
def subtotal(self):
return self.weight * self.price
def get_weight(self): # ①
return self.__weight
def set_weight(self, value): # ②
if value > 0:
self.__weight = value
else:
raise ValueError('value must be > 0')
weight = property(get_weight, set_weight) # ③
①
一个普通的 getter。
②
一个普通的 setter。
③
构建property并将其分配给一个公共类属性。
在某些情况下,经典形式比装饰器语法更好;我们将很快讨论的属性工厂的代码就是一个例子。另一方面,在一个有许多方法的类体中,装饰器使得明确哪些是 getter 和 setter,而不依赖于在它们的名称中使用get和set前缀的约定。
类中存在属性会影响实例中属性的查找方式,这可能一开始会让人感到惊讶。下一节将解释。
属性覆盖实例属性
属性始终是类属性,但实际上管理类的实例中的属性访问。
在“覆盖类属性”中,我们看到当一个实例及其类都有相同名称的数据属性时,实例属性会覆盖或遮蔽类属性——至少在通过该实例读取时是这样的。示例 22-23 说明了这一点。
示例 22-23。实例属性遮蔽类data属性
>>> class Class: # ①
... data = 'the class data attr'
... @property
... def prop(self):
... return 'the prop value'
...
>>> obj = Class()
>>> vars(obj) # ②
{} >>> obj.data # ③
'the class data attr' >>> obj.data = 'bar' # ④
>>> vars(obj) # ⑤
{'data': 'bar'} >>> obj.data # ⑥
'bar' >>> Class.data # ⑦
'the class data attr'
①
使用两个类属性data属性和prop属性定义Class。
②
vars返回obj的__dict__,显示它没有实例属性。
③
从obj.data中读取Class.data的值。
④
写入 obj.data 创建一个实例属性。
⑤
检查实例以查看实例属性。
⑥
现在从 obj.data 读取将检索实例属性的值。当从 obj 实例读取时,实例 data 遮蔽了类 data。
⑦
Class.data 属性保持不变。
现在,让我们尝试覆盖 obj 实例上的 prop 属性。继续之前的控制台会话,我们有示例 22-24。
示例 22-24. 实例属性不会遮蔽类属性(续自示例 22-23)
>>> Class.prop # ①
<property object at 0x1072b7408> >>> obj.prop # ②
'the prop value' >>> obj.prop = 'foo' # ③
Traceback (most recent call last):
...
AttributeError: can't set attribute
>>> obj.__dict__['prop'] = 'foo' # ④
>>> vars(obj) # ⑤
{'data': 'bar', 'prop': 'foo'} >>> obj.prop # ⑥
'the prop value' >>> Class.prop = 'baz' # ⑦
>>> obj.prop # ⑧
'foo'
①
直接从 Class 中读取 prop 会检索属性对象本身,而不会运行其 getter 方法。
②
读取 obj.prop 执行属性的 getter。
③
尝试设置实例 prop 属性失败。
④
直接将 'prop' 放入 obj.__dict__ 中有效。
⑤
我们可以看到 obj 现在有两个实例属性:data 和 prop。
⑥
然而,读取 obj.prop 仍然会运行属性的 getter。属性不会被实例属性遮蔽。
⑦
覆盖 Class.prop 会销毁属性对象。
⑧
现在 obj.prop 检索实例属性。Class.prop 不再是属性,因此不再覆盖 obj.prop。
作为最后的演示,我们将向 Class 添加一个新属性,并看到它如何覆盖实例属性。示例 22-25 接续了示例 22-24。
示例 22-25. 新类属性遮蔽现有实例属性(续自示例 22-24)
>>> obj.data # ①
'bar' >>> Class.data # ②
'the class data attr' >>> Class.data = property(lambda self: 'the "data" prop value') # ③
>>> obj.data # ④
'the "data" prop value' >>> del Class.data # ⑤
>>> obj.data # ⑥
'bar'
①
obj.data 检索实例 data 属性。
②
Class.data 检索类 data 属性。
③
用新属性覆盖 Class.data。
④
obj.data 现在被 Class.data 属性遮蔽。
⑤
删除属性。
⑥
obj.data 现在再次读取实例 data 属性。
本节的主要观点是,像 obj.data 这样的表达式并不会从 obj 开始搜索 data。搜索实际上从 obj.__class__ 开始,只有在类中没有名为 data 的属性时,Python 才会在 obj 实例本身中查找。这适用于一般的覆盖描述符,其中属性只是一个例子。对描述符的进一步处理必须等到第二十三章。
现在回到属性。每个 Python 代码单元——模块、函数、类、方法——都可以有一个文档字符串。下一个主题是如何将文档附加到属性上。
属性文档
当工具如控制台的 help() 函数或 IDE 需要显示属性的文档时,它们会从属性的 __doc__ 属性中提取信息。
如果与经典调用语法一起使用,property 可以将文档字符串作为 doc 参数:
weight = property(get_weight, set_weight, doc='weight in kilograms')
getter 方法的文档字符串——带有 @property 装饰器本身——被用作整个属性的文档。图 22-1 展示了从示例 22-26 中的代码生成的帮助屏幕。

图 22-1. Python 控制台的屏幕截图,当发出命令 help(Foo.bar) 和 help(Foo) 时。源代码在示例 22-26 中。
示例 22-26. 属性的文档
class Foo:
@property
def bar(self):
"""The bar attribute"""
return self.__dict__['bar']
@bar.setter
def bar(self, value):
self.__dict__['bar'] = value
现在我们已经掌握了这些属性的基本要点,让我们回到保护 LineItem 的 weight 和 price 属性只接受大于零的值的问题上来,但不需要手动实现两个几乎相同的 getter/setter 对。
编写属性工厂
我们将创建一个工厂来创建 quantity 属性,因为受管属性代表应用程序中不能为负或零的数量。示例 22-27 展示了 LineItem 类使用两个 quantity 属性实例的清晰外观:一个用于管理 weight 属性,另一个用于 price。
示例 22-27. bulkfood_v2prop.py:使用 quantity 属性工厂
class LineItem:
weight = quantity('weight') # ①
price = quantity('price') # ②
def __init__(self, description, weight, price):
self.description = description
self.weight = weight # ③
self.price = price
def subtotal(self):
return self.weight * self.price # ④
①
使用工厂定义第一个自定义属性 weight 作为类属性。
②
这第二次调用构建了另一个自定义属性 price。
③
这里属性已经激活,确保拒绝负数或 0 的 weight。
④
这些属性也在此处使用,检索存储在实例中的值。
请记住属性是类属性。在构建每个 quantity 属性时,我们需要传递将由该特定属性管理的 LineItem 属性的名称。在这一行中不得不两次输入单词 weight 是不幸的:
weight = quantity('weight')
但避免重复是复杂的,因为属性无法知道将绑定到它的类属性名称。记住:赋值语句的右侧首先被评估,因此当调用 quantity() 时,weight 类属性甚至不存在。
注意
改进 quantity 属性,使用户无需重新输入属性名称是一个非常棘手的元编程问题。我们将在第二十三章中解决这个问题。
示例 22-28 列出了 quantity 属性工厂的实现。¹¹
示例 22-28. bulkfood_v2prop.py:quantity 属性工厂
def quantity(storage_name): # ①
def qty_getter(instance): # ②
return instance.__dict__[storage_name] # ③
def qty_setter(instance, value): # ④
if value > 0:
instance.__dict__[storage_name] = value # ⑤
else:
raise ValueError('value must be > 0')
return property(qty_getter, qty_setter) # ⑥
①
storage_name 参数确定每个属性的数据存储位置;对于 weight,存储名称将是 'weight'。
②
qty_getter 的第一个参数可以命名为 self,但这将很奇怪,因为这不是一个类体;instance 指的是将存储属性的 LineItem 实例。
③
qty_getter 引用 storage_name,因此它将在此函数的闭包中保留;值直接从 instance.__dict__ 中检索,以绕过属性并避免无限递归。
④
qty_setter 被定义,同时将 instance 作为第一个参数。
⑤
value 直接存储在 instance.__dict__ 中,再次绕过属性。
⑥
构建自定义属性对象并返回它。
值得仔细研究的 示例 22-28 部分围绕着 storage_name 变量展开。当你以传统方式编写每个属性时,在 getter 和 setter 方法中硬编码了存储值的属性名称。但在这里,qty_getter 和 qty_setter 函数是通用的,它们依赖于 storage_name 变量来知道在实例 __dict__ 中获取/设置托管属性的位置。每次调用 quantity 工厂来构建属性时,storage_name 必须设置为一个唯一的值。
函数 qty_getter 和 qty_setter 将被工厂函数最后一行创建的 property 对象包装。稍后,当调用执行它们的职责时,这些函数将从它们的闭包中读取 storage_name,以确定从哪里检索/存储托管属性值。
在 示例 22-29 中,我创建并检查一个 LineItem 实例,暴露存储属性。
示例 22-29. bulkfood_v2prop.py:探索属性和存储属性
>>> nutmeg = LineItem('Moluccan nutmeg', 8, 13.95) >>> nutmeg.weight, nutmeg.price # ①
(8, 13.95) >>> nutmeg.__dict__ # ②
{'description': 'Moluccan nutmeg', 'weight': 8, 'price': 13.95}
①
通过遮蔽同名实例属性的属性来读取 weight 和 price。
②
使用 vars 检查 nutmeg 实例:这里我们看到用于存储值的实际实例属性。
注意我们的工厂构建的属性如何利用 “属性覆盖实例属性” 中描述的行为:weight 属性覆盖了 weight 实例属性,以便每个对 self.weight 或 nutmeg.weight 的引用都由属性函数处理,而绕过属性逻辑的唯一方法是直接访问实例 __dict__。
示例 22-28 中的代码可能有点棘手,但很简洁:它的长度与仅定义 weight 属性的装饰的 getter/setter 对相同,如 示例 22-21 中所示。在 示例 22-27 中,LineItem 定义看起来更好,没有 getter/setter 的干扰。
在一个真实的系统中,同样类型的验证可能出现在许多字段中,跨越几个类,并且 quantity 工厂将被放置在一个实用模块中,以便反复使用。最终,这个简单的工厂可以重构为一个更可扩展的描述符类,具有执行不同验证的专门子类。我们将在 第二十三章 中进行这样的操作。
现在让我们结束对属性的讨论,转向属性删除的问题。
处理属性删除
我们可以使用 del 语句来删除变量,也可以删除属性:
>>> class Demo:
... pass
...
>>> d = Demo()
>>> d.color = 'green'
>>> d.color
'green'
>>> del d.color
>>> d.color
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Demo' object has no attribute 'color'
在实践中,删除属性并不是我们在 Python 中每天都做的事情,而且要求使用属性处理它更加不寻常。但是它是被支持的,我可以想到一个愚蠢的例子来演示它。
在属性定义中,@my_property.deleter 装饰器包装了负责删除属性的方法。正如承诺的那样,愚蠢的 示例 22-30 受到了《Monty Python and the Holy Grail》中黑骑士场景的启发。¹²
示例 22-30. blackknight.py
class BlackKnight:
def __init__(self):
self.phrases = [
('an arm', "'Tis but a scratch."),
('another arm', "It's just a flesh wound."),
('a leg', "I'm invincible!"),
('another leg', "All right, we'll call it a draw.")
]
@property
def member(self):
print('next member is:')
return self.phrases[0][0]
@member.deleter
def member(self):
member, text = self.phrases.pop(0)
print(f'BLACK KNIGHT (loses {member}) -- {text}')
blackknight.py 中的文档测试在 示例 22-31 中。
示例 22-31. blackknight.py:示例 22-30 的文档测试(黑骑士永不认输)
>>> knight = BlackKnight()
>>> knight.member
next member is:
'an arm'
>>> del knight.member
BLACK KNIGHT (loses an arm) -- 'Tis but a scratch.
>>> del knight.member
BLACK KNIGHT (loses another arm) -- It's just a flesh wound.
>>> del knight.member
BLACK KNIGHT (loses a leg) -- I'm invincible!
>>> del knight.member
BLACK KNIGHT (loses another leg) -- All right, we'll call it a draw.
使用经典的调用语法而不是装饰器,fdel 参数配置了删除函数。例如,在 BlackKnight 类的主体中,member 属性将被编码为:
member = property(member_getter, fdel=member_deleter)
如果您没有使用属性,属性删除也可以通过实现更低级的__delattr__特殊方法来处理,如“属性处理的特殊方法”中所述。编写一个带有__delattr__的愚蠢类留给拖延的读者作为练习。
属性是一个强大的功能,但有时更简单或更低级的替代方案更可取。在本章的最后一节中,我们将回顾 Python 为动态属性编程提供的一些核心 API。
处理属性的基本属性和函数
在本章中,甚至在本书之前,我们已经使用了 Python 提供的一些用于处理动态属性的内置函数和特殊方法。本节将它们的概述放在一个地方,因为它们的文档分散在官方文档中。
影响属性处理的特殊属性
下面列出的许多函数和特殊方法的行为取决于三个特殊属性:
__class__
对象的类的引用(即obj.__class__与type(obj)相同)。Python 仅在对象的类中查找__getattr__等特殊方法,而不在实例本身中查找。
__dict__
存储对象或类的可写属性的映射。具有__dict__的对象可以随时设置任意新属性。如果一个类具有__slots__属性,则其实例可能没有__dict__。请参阅__slots__(下一节)。
__slots__
可以在类中定义的属性,用于节省内存。__slots__是一个命名允许的属性的字符串tuple。¹³ 如果__slots__中没有'__dict__'名称,那么该类的实例将不会有自己的__dict__,并且只允许在这些实例中列出的属性。更多信息请参阅“使用 slots 节省内存”。
用于属性处理的内置函数
这五个内置函数执行对象属性的读取、写入和内省:
dir([object])
列出对象的大多数属性。官方文档说dir用于交互使用,因此它不提供属性的全面列表,而是提供一个“有趣”的名称集。dir可以检查实现了__dict__或未实现__dict__的对象。dir本身不列出__dict__属性,但列出__dict__键。类的几个特殊属性,如__mro__、__bases__和__name__,也不被dir列出。您可以通过实现__dir__特殊方法来自定义dir的输出,就像我们在示例 22-4 中看到的那样。如果未提供可选的object参数,则dir列出当前范围中的名称。
getattr(object, name[, default])
从object中获取由name字符串标识的属性。主要用例是检索我们事先不知道的属性(或方法)。这可能会从对象的类或超类中获取属性。如果没有这样的属性存在,则getattr会引发AttributeError或返回default值(如果给定)。一个很好的使用getattr的例子是在标准库的cmd包中的Cmd.onecmd方法中,它用于获取和执行用户定义的命令。
hasattr(object, name)
如果命名属性存在于object中,或者可以通过object(例如通过继承)获取,则返回True。文档解释说:“这是通过调用 getattr(object, name)并查看它是否引发 AttributeError 来实现的。”
setattr(object, name, value)
如果object允许,将value分配给object的命名属性。这可能会创建一个新属性或覆盖现有属性。
vars([object])
返回object的__dict__;vars无法处理定义了__slots__且没有__dict__的类的实例(与dir相反,后者处理这些实例)。如果没有参数,vars()与locals()执行相同的操作:返回表示局部作用域的dict。
用于属性处理的特殊方法
当在用户定义的类中实现时,这里列出的特殊方法处理属性的检索、设置、删除和列出。
使用点符号表示法或内置函数getattr、hasattr和setattr访问属性会触发这里列出的适当的特殊方法。直接在实例__dict__中读取和写入属性不会触发这些特殊方法——这是需要绕过它们的常用方式。
章节“3.3.11. 特殊方法查找”中的“数据模型”一章警告:
对于自定义类,只有在对象的类型上定义了特殊方法时,隐式调用特殊方法才能保证正确工作,而不是在对象的实例字典中定义。
换句话说,假设特殊方法将在类本身上检索,即使操作的目标是实例。因此,特殊方法不会被具有相同名称的实例属性遮蔽。
在以下示例中,假设有一个名为Class的类,obj是Class的一个实例,attr是obj的一个属性。
对于这些特殊方法中的每一个,无论是使用点符号表示法还是“用于属性处理的内置函数”中列出的内置函数之一,都没有关系。例如,obj.attr和getattr(obj, 'attr', 42)都会触发Class.__getattribute__(obj, 'attr')。
__delattr__(self, name)
当尝试使用del语句删除属性时始终调用;例如,del obj.attr触发Class.__delattr__(obj, 'attr')。如果attr是一个属性,则如果类实现了__delattr__,则其删除方法永远不会被调用。
__dir__(self)
在对象上调用dir时调用,以提供属性列表;例如,dir(obj)触发Class.__dir__(obj)。在所有现代 Python 控制台中,也被用于制表完成。
__getattr__(self, name)
仅在尝试检索命名属性失败时调用,之后搜索obj、Class及其超类。表达式obj.no_such_attr、getattr(obj, 'no_such_attr')和hasattr(obj, 'no_such_attr')可能会触发Class.__getattr__(obj, 'no_such_attr'),但仅当在obj或Class及其超类中找不到该名称的属性时。
__getattribute__(self, name)
当尝试直接从 Python 代码中检索命名属性时始终调用(解释器在某些情况下可能会绕过此方法,例如获取__repr__方法)。点符号表示法和getattr以及hasattr内置函数会触发此方法。__getattr__仅在__getattribute__之后调用,并且仅在__getattribute__引发AttributeError时才会调用。为了检索实例obj的属性而不触发无限递归,__getattribute__的实现应该使用super().__getattribute__(obj, name)。
__setattr__(self, name, value)
当尝试设置命名属性时始终调用。点符号和setattr内置触发此方法;例如,obj.attr = 42和setattr(obj, 'attr', 42)都会触发Class.__setattr__(obj, 'attr', 42)。
警告
实际上,因为它们被无条件调用并影响几乎每个属性访问,__getattribute__和__setattr__特殊方法比__getattr__更难正确使用,后者仅处理不存在的属性名称。使用属性或描述符比定义这些特殊方法更不容易出错。
这结束了我们对属性、特殊方法和其他编写动态属性技术的探讨。
章节总结
我们通过展示简单类的实际示例来开始动态属性的覆盖。第一个示例是FrozenJSON类,它将嵌套的字典和列表转换为嵌套的FrozenJSON实例和它们的列表。FrozenJSON代码演示了使用__getattr__特殊方法在读取属性时动态转换数据结构。FrozenJSON的最新版本展示了使用__new__构造方法将一个类转换为灵活的对象工厂,不限于自身的实例。
然后,我们将 JSON 数据集转换为存储Record类实例的dict。Record的第一个版本只有几行代码,并引入了“bunch”习惯用法:使用self.__dict__.update(**kwargs)从传递给__init__的关键字参数构建任意属性。第二次迭代添加了Event类,通过属性实现自动检索链接记录。计算属性值有时需要缓存,我们介绍了几种方法。
在意识到@functools.cached_property并非总是适用后,我们了解了一种替代方法:按顺序将@property与@functools.cache结合使用。
属性的覆盖继续在LineItem类中进行,其中部署了一个属性来保护weight属性免受没有业务意义的负值或零值的影响。在更深入地了解属性语法和语义之后,我们创建了一个属性工厂,以强制在weight和price上执行相同的验证,而无需编写多个 getter 和 setter。属性工厂利用了微妙的概念——如闭包和属性覆盖实例属性——以使用与手动编码的单个属性定义相同数量的行提供优雅的通用解决方案。
最后,我们简要介绍了使用属性处理属性删除的方法,然后概述了核心 Python 语言中支持属性元编程的关键特殊属性、内置函数和特殊方法。
进一步阅读
属性处理和内省内置函数的官方文档位于Python 标准库的第二章,“内置函数”中。相关的特殊方法和__slots__特殊属性在Python 语言参考的“3.3.2. 自定义属性访问”中有文档。解释了绕过实例调用特殊方法的语义在“3.3.9. 特殊方法查找”中。在Python 标准库的第四章,“内置类型”中,“4.13. 特殊属性”涵盖了__class__和__dict__属性。
Python Cookbook,第 3 版,作者 David Beazley 和 Brian K. Jones(O’Reilly)包含了本章主题的几个示例,但我将重点介绍三个杰出的示例:“Recipe 8.8. 在子类中扩展属性”解决了从超类继承的属性内部方法覆盖的棘手问题;“Recipe 8.15. 委托属性访问”实现了一个代理类,展示了本书中“属性处理的特殊方法”中的大多数特殊方法;以及令人印象深刻的“Recipe 9.21. 避免重复的属性方法”,这是在示例 22-28 中呈现的属性工厂函数的基础。
Python in a Nutshell, 第三版,由 Alex Martelli, Anna Ravenscroft, 和 Steve Holden (O’Reilly) 是严谨和客观的。他们只用了三页来讨论属性,但这是因为该书遵循了公理化的展示风格:前面的 15 页左右提供了对 Python 类语义的彻底描述,包括描述符,这是属性在幕后实际上是如何实现的。所以当 Martelli 等人讨论属性时,他们在这三页中包含了许多见解—包括我选择用来开启本章的内容。
Bertrand Meyer—在本章开头引用的统一访问原则定义中—开创了契约式设计方法,设计了 Eiffel 语言,并撰写了优秀的 面向对象软件构造,第二版 (Pearson)。前六章提供了我见过的最好的面向对象分析和设计的概念介绍之一。第十一章介绍了契约式设计,第三十五章提供了 Meyer 对一些有影响力的面向对象语言的评估:Simula、Smalltalk、CLOS (Common Lisp Object System)、Objective-C、C++ 和 Java,并简要评论了其他一些语言。直到书的最后一页,他才透露他所使用的易读的伪代码“符号”是 Eiffel。
¹ Alex Martelli, Anna Ravenscroft, 和 Steve Holden, Python in a Nutshell, 第三版 (O’Reilly), 第 123 页。
² Bertrand Meyer, 面向对象软件构造,第二版 (Pearson),第 57 页。
³ OSCON—O’Reilly 开源大会—成为了 COVID-19 大流行的牺牲品。我用于这些示例的原始 744 KB JSON 文件在 2021 年 1 月 10 日之后不再在线。你可以在osconfeed.json 的示例代码库中找到一份副本。
⁵ 表达式 self.__data[name] 是可能发生 KeyError 异常的地方。理想情况下,应该处理它并引发 AttributeError,因为这是从 __getattr__ 中期望的。勤奋的读者被邀请将错误处理编码为练习。
⁶ 数据的来源是 JSON,而 JSON 数据中唯一的集合类型是 dict 和 list。
⁷ 顺便说一句,Bunch 是 Alex Martelli 用来分享这个提示的类的名称,这个提示来自于 2001 年的一篇名为“简单但方便的‘一堆命名东西’类”的食谱。
⁸ 这实际上是 Meyer 的统一访问原则的一个缺点,我在本章开头提到过。如果你对这个讨论感兴趣,可以阅读可选的“讲台”。
⁹ 来源:@functools.cached_property 文档。我知道 Raymond Hettinger 撰写了这份解释,因为他是作为我提出问题的回应而撰写的:bpo42781—functools.cached_property 文档应该解释它是非覆盖的。Hettinger 是官方 Python 文档和标准库的主要贡献者。他还撰写了优秀的“描述符指南”,这是第二十三章的重要资源。
¹⁰ 杰夫·贝佐斯在 华尔街日报 的报道“一个推销员的诞生”中的直接引用(2011 年 10 月 15 日)。请注意,截至 2021 年,您需要订阅才能阅读这篇文章。
¹¹ 这段代码改编自《Python Cookbook》第 3 版的“食谱 9.21。避免重复的属性方法”,作者是 David Beazley 和 Brian K. Jones(O’Reilly)。
¹² 这血腥场面在 2021 年 10 月我审阅时在 Youtube 上可供观看。
¹³ Alex Martelli 指出,虽然__slots__可以编码为一个list,但最好明确地始终使用一个tuple,因为在类体被处理后更改__slots__中的列表没有效果,因此在那里使用可变序列会产生误导。
¹⁴ Alex Martelli,《Python 速查手册》,第 2 版(O’Reilly),第 101 页。
¹⁵ 我即将提到的原因在《Dr. Dobbs Journal》的文章中提到,标题为“Java 的新特性有害”,作者是 Jonathan Amsterdam,以及在屡获殊荣的书籍Effective Java第 3 版的“考虑使用静态工厂方法代替构造函数”中,作者是 Joshua Bloch(Addison-Wesley)。
第二十三章:属性描述符
了解描述符不仅提供了更大的工具集,还深入了解了 Python 的工作原理,并欣赏了其设计的优雅之处。
Raymond Hettinger,Python 核心开发者和专家¹
描述符是在多个属性中重用相同访问逻辑的一种方式。例如,在 ORM 中,如 Django ORM 和 SQLAlchemy 中的字段类型是描述符,管理数据从数据库记录中的字段流向 Python 对象属性,反之亦然。
描述符是实现由__get__、__set__和__delete__方法组成的动态协议的类。property类实现了完整的描述符协议。与动态协议一样,部分实现是可以的。事实上,我们在实际代码中看到的大多数描述符只实现了__get__和__set__,许多只实现了这些方法中的一个。
描述符是 Python 的一个显著特征,不仅在应用程序级别部署,还在语言基础设施中部署。用户定义的函数是描述符。我们将看到描述符协议如何允许方法作为绑定或非绑定方法运行,具体取决于它们的调用方式。
理解描述符是掌握 Python 的关键。这就是本章的主题。
在本章中,我们将重构我们在“使用属性进行属性验证”中首次看到的大量食品示例,将属性替换为描述符。这将使在不同类之间重用属性验证逻辑变得更容易。我们将解决覆盖和非覆盖描述符的概念,并意识到 Python 函数也是描述符。最后,我们将看到一些关于实现描述符的提示。
本章的新内容
由于 Python 3.6 中添加了描述符协议的__set_name__特殊方法,“LineItem Take #4: 自动命名存储属性”中的Quantity描述符示例得到了极大简化。
我删除了以前在“LineItem Take #4: 自动命名存储属性”中的属性工厂示例,因为它变得无关紧要:重点是展示解决Quantity问题的另一种方法,但随着__set_name__的添加,描述符解决方案变得简单得多。
以前出现在“LineItem Take #5: 新的描述符类型”中的AutoStorage类也消失了,因为__set_name__使其变得过时。
描述符示例:属性验证
正如我们在“编写属性工厂”中看到的,属性工厂是一种避免重复编写获取器和设置器的方法,通过应用函数式编程模式来实现。属性工厂是一个高阶函数,它创建一个参数化的访问器函数集,并从中构建一个自定义属性实例,使用闭包来保存像storage_name这样的设置。解决相同问题的面向对象方式是使用描述符类。
我们将继续之前留下的LineItem示例系列,在“编写属性工厂”中,通过将quantity属性工厂重构为Quantity描述符类来使其更易于使用。
LineItem Take #3: 一个简单的描述符
正如我们在介绍中所说,实现__get__、__set__或__delete__方法的类是描述符。您通过将其实例声明为另一个类的类属性来使用描述符。
我们将创建一个Quantity描述符,LineItem类将使用两个Quantity实例:一个用于管理weight属性,另一个用于price。图表有助于理解,所以看一下图 23-1。

图 23-1。LineItem使用名为Quantity的描述符类的 UML 类图。在 UML 中带有下划线的属性是类属性。请注意,weight 和 price 是附加到LineItem类的Quantity实例,但LineItem实例也有自己的 weight 和 price 属性,其中存储这些值。
请注意,单词weight在图 23-1 中出现两次,因为实际上有两个名为weight的不同属性:一个是LineItem的类属性,另一个是将存在于每个LineItem对象中的实例属性。price也适用于此。
理解描述符的术语
实现和使用描述符涉及几个组件,精确命名这些组件是很有用的。在本章的示例中,我将使用以下术语和定义来描述。一旦看到代码,它们将更容易理解,但我想提前列出这些定义,以便您在需要时可以参考它们。
描述符类
实现描述符协议的类。在图 23-1 中就是Quantity。
托管类
声明描述符实例为类属性的类。在图 23-1 中,LineItem是托管类。
描述符实例
每个描述符类的实例,声明为托管类的类属性。在图 23-1 中,每个描述符实例由一个带有下划线名称的组合箭头表示(下划线表示 UML 中的类属性)。黑色菱形接触LineItem类,其中包含描述符实例。
托管实例
托管类的一个实例。在这个例子中,LineItem实例是托管实例(它们没有显示在类图中)。
存储属性
托管实例的属性,保存该特定实例的托管属性的值。在图 23-1 中,LineItem实例的属性weight和price是存储属性。它们与描述符实例不同,后者始终是类属性。
托管属性
托管类中的公共属性,由描述符实例处理,值存储在存储属性中。换句话说,描述符实例和存储属性为托管属性提供基础设施。
重要的是要意识到Quantity实例是LineItem的类属性。这一关键点在图 23-2 中由磨坊和小玩意突出显示。

图 23-2。使用 MGN(磨坊和小玩意符号)注释的 UML 类图:类是生产小玩意的磨坊。Quantity磨坊生成两个带有圆形头部的小玩意,它们附加到LineItem磨坊:weight 和 price。LineItem磨坊生成具有自己的 weight 和 price 属性的矩形小玩意,其中存储这些值。
现在足够涂鸦了。这里是代码:示例 23-1 展示了Quantity描述符类,示例 23-2 列出了使用两个Quantity实例的新LineItem类。
示例 23-1。bulkfood_v3.py:Quantity描述符不接受负值
class Quantity: # ①
def __init__(self, storage_name):
self.storage_name = storage_name # ②
def __set__(self, instance, value): # ③
if value > 0:
instance.__dict__[self.storage_name] = value # ④
else:
msg = f'{self.storage_name} must be > 0'
raise ValueError(msg)
def __get__(self, instance, owner): # ⑤
return instance.__dict__[self.storage_name]
①
描述符是基于协议的特性;不需要子类化来实现。
②
每个Quantity实例都将有一个storage_name属性:这是用于在托管实例中保存值的存储属性的名称。
③
当尝试对托管属性进行赋值时,将调用__set__。在这里,self是描述符实例(即LineItem.weight或LineItem.price),instance是托管实例(一个LineItem实例),value是正在分配的值。
④
我们必须直接将属性值存储到__dict__中;调用setattr(instance, self.storage_name)将再次触发__set__方法,导致无限递归。
⑤
我们需要实现__get__,因为被管理属性的名称可能与storage_name不同。owner参数将很快解释。
实现__get__是必要的,因为用户可能会编写类似于这样的内容:
class House:
rooms = Quantity('number_of_rooms')
在House类中,被管理的属性是rooms,但存储属性是number_of_rooms。给定一个名为chaos_manor的House实例,读取和写入chaos_manor.rooms会通过附加到rooms的Quantity描述符实例,但读取和写入chaos_manor.number_of_rooms会绕过描述符。
请注意,__get__接收三个参数:self、instance和owner。owner参数是被管理类的引用(例如LineItem),如果您希望描述符支持检索类属性以模拟 Python 在实例中找不到名称时检索类属性的默认行为,则很有用。
如果通过类(如LineItem.weight)检索被管理属性(例如weight),则描述符__get__方法的instance参数的值为None。
为了支持用户的内省和其他元编程技巧,最好让__get__在通过类访问被管理属性时返回描述符实例。为此,我们将像这样编写__get__:
def __get__(self, instance, owner):
if instance is None:
return self
else:
return instance.__dict__[self.storage_name]
示例 23-2 演示了在LineItem中使用Quantity。
示例 23-2. bulkfood_v3.py:Quantity描述符管理LineItem中的属性
class LineItem:
weight = Quantity('weight') # ①
price = Quantity('price') # ②
def __init__(self, description, weight, price): # ③
self.description = description
self.weight = weight
self.price = price
def subtotal(self):
return self.weight * self.price
①
第一个描述符实例将管理weight属性。
②
第二个描述符实例将管理price属性。
③
类主体的其余部分与bulkfood_v1.py中的原始代码一样简单干净(示例 22-19)。
示例 23-2 中的代码按预期运行,防止以$0 的价格出售松露:³
>>> truffle = LineItem('White truffle', 100, 0)
Traceback (most recent call last):
...
ValueError: value must be > 0
警告
在编写描述符__get__和__set__方法时,请记住self和instance参数的含义:self是描述符实例,instance是被管理实例。管理实例属性的描述符应将值存储在被管理实例中。这就是为什么 Python 提供instance参数给描述符方法的原因。
存储每个被管理属性的值在描述符实例本身中可能很诱人,但是错误的。换句话说,在__set__方法中,而不是编写:
instance.__dict__[self.storage_name] = value
诱人但错误的替代方案是:
self.__dict__[self.storage_name] = value
要理解为什么这样做是错误的,请考虑__set__的前两个参数的含义:self和instance。这里,self是描述符实例,实际上是被管理类的类属性。您可能在内存中同时拥有成千上万个LineItem实例,但只有两个描述符实例:类属性LineItem.weight和LineItem.price。因此,您存储在描述符实例本身中的任何内容实际上是LineItem类属性的一部分,因此在所有LineItem实例之间共享。
示例 23-2 的一个缺点是在被管理类主体中实例化描述符时需要重复属性名称。如果LineItem类可以这样声明就好了:
class LineItem:
weight = Quantity()
price = Quantity()
# remaining methods as before
目前,示例 23-2 需要显式命名每个Quantity,这不仅不方便,而且很危险。如果一个程序员复制粘贴代码时忘记编辑两个名称,并写出类似price = Quantity('weight')的内容,程序将表现糟糕,每当设置price时都会破坏weight的值。
问题在于——正如我们在第六章中看到的——赋值的右侧在变量存在之前执行。表达式Quantity()被评估为创建一个描述符实例,而Quantity类中的代码无法猜测描述符将绑定到的变量的名称(例如weight或price)。
幸运的是,描述符协议现在支持名为__set_name__的特殊方法。我们将看到如何使用它。
注意
描述符存储属性的自动命名曾经是一个棘手的问题。在流畅的 Python第一版中,我在本章和下一章中花了几页和几行代码来介绍不同的解决方案,包括使用类装饰器,然后在第二十四章中使用元类。这在 Python 3.6 中得到了极大简化。
LineItem 第 4 版:自动命名存储属性
为了避免在描述符实例中重新输入属性名称,我们将实现__set_name__来设置每个Quantity实例的storage_name。__set_name__特殊方法在 Python 3.6 中添加到描述符协议中。解释器在class体中找到的每个描述符上调用__set_name__——如果描述符实现了它。⁴
在示例 23-3 中,LineItem描述符类不需要__init__。相反,__set_item__保存了存储属性的名称。
示例 23-3. bulkfood_v4.py:__set_name__为每个Quantity描述符实例设置名称
class Quantity:
def __set_name__(self, owner, name): # ①
self.storage_name = name # ②
def __set__(self, instance, value): # ③
if value > 0:
instance.__dict__[self.storage_name] = value
else:
msg = f'{self.storage_name} must be > 0'
raise ValueError(msg)
# no __get__ needed # ④
class LineItem:
weight = Quantity() # ⑤
price = Quantity()
def __init__(self, description, weight, price):
self.description = description
self.weight = weight
self.price = price
def subtotal(self):
return self.weight * self.price
①
self是描述符实例(而不是托管实例),owner是托管类,name是owner的属性的名称,在owner的类体中将此描述符实例分配给的名称。
②
这就是示例 23-1 中的__init__所做的事情。
③
这里的__set__方法与示例 23-1 中完全相同。
④
实现__get__是不必要的,因为存储属性的名称与托管属性的名称匹配。表达式product.price直接从LineItem实例获取price属性。
⑤
现在我们不需要将托管属性名称传递给Quantity构造函数。这是这个版本的目标。
查看示例 23-3,您可能会认为这是为了管理几个属性而编写的大量代码,但重要的是要意识到描述符逻辑现在抽象为一个单独的代码单元:Quantity类。通常我们不会在使用它的同一模块中定义描述符,而是在一个专门设计用于跨应用程序使用的实用程序模块中定义描述符——即使在许多应用程序中,如果您正在开发一个库或框架。
有了这个想法,示例 23-4 更好地代表了描述符的典型用法。
示例 23-4. bulkfood_v4c.py:LineItem定义简洁;Quantity描述符类现在位于导入的model_v4c模块中
import model_v4c as model # ①
class LineItem:
weight = model.Quantity() # ②
price = model.Quantity()
def __init__(self, description, weight, price):
self.description = description
self.weight = weight
self.price = price
def subtotal(self):
return self.weight * self.price
①
导入实现Quantity的model_v4c模块。
②
使用model.Quantity。
Django 用户会注意到 示例 23-4 看起来很像一个模型定义。这不是巧合:Django 模型字段就是描述符。
因为描述符是以类的形式实现的,我们可以利用继承来重用一些用于新描述符的代码。这就是我们将在下一节中做的事情。
LineItem 第五版:一种新的描述符类型
想象中的有机食品店遇到了麻烦:某种方式创建了一个带有空白描述的行项目实例,订单无法完成。为了防止这种情况发生,我们将创建一个新的描述符 NonBlank。在设计 NonBlank 时,我们意识到它将非常类似于 Quantity 描述符,除了验证逻辑。
这促使进行重构,生成 Validated,一个覆盖 __set__ 方法的抽象类,调用必须由子类实现的 validate 方法。
然后我们将重写 Quantity,并通过继承 Validated 并编写 validate 方法来实现 NonBlank。
Validated、Quantity 和 NonBlank 之间的关系是《设计模式》经典中描述的 模板方法 的应用:
模板方法以抽象操作的形式定义算法,子类重写这些操作以提供具体行为。⁵
在 示例 23-5 中,Validated.__set__ 是模板方法,self.validate 是抽象操作。
示例 23-5. model_v5.py:Validated 抽象基类
import abc
class Validated(abc.ABC):
def __set_name__(self, owner, name):
self.storage_name = name
def __set__(self, instance, value):
value = self.validate(self.storage_name, value) # ①
instance.__dict__[self.storage_name] = value # ②
@abc.abstractmethod
def validate(self, name, value): # ③
"""return validated value or raise ValueError"""
①
__set__ 将验证委托给 validate 方法…
②
…然后使用返回的 value 更新存储的值。
③
validate 是一个抽象方法;这就是模板方法。
Alex Martelli 更喜欢将这种设计模式称为 自委托,我同意这是一个更具描述性的名称:__set__ 的第一行自委托给 validate。⁶
本示例中的具体 Validated 子类是 Quantity 和 NonBlank,如 示例 23-6 所示。
示例 23-6. model_v5.py:Quantity 和 NonBlank,具体的 Validated 子类
class Quantity(Validated):
"""a number greater than zero"""
def validate(self, name, value): # ①
if value <= 0:
raise ValueError(f'{name} must be > 0')
return value
class NonBlank(Validated):
"""a string with at least one non-space character"""
def validate(self, name, value):
value = value.strip()
if not value: # ②
raise ValueError(f'{name} cannot be blank')
return value # ③
①
实现 Validated.validate 抽象方法所需的模板方法。
②
如果前导和尾随空格被剥离后没有剩余内容,则拒绝该值。
③
要求具体的 validate 方法返回经过验证的值,这为它们提供了清理、转换或规范化接收到的数据的机会。在这种情况下,value 被返回时没有前导或尾随空格。
model_v5.py 的用户不需要知道所有这些细节。重要的是他们可以使用 Quantity 和 NonBlank 来自动验证实例属性。请查看 示例 23-7 中的最新 LineItem 类。
示例 23-7. bulkfood_v5.py:LineItem 使用 Quantity 和 NonBlank 描述符
import model_v5 as model # ①
class LineItem:
description = model.NonBlank() # ②
weight = model.Quantity()
price = model.Quantity()
def __init__(self, description, weight, price):
self.description = description
self.weight = weight
self.price = price
def subtotal(self):
return self.weight * self.price
①
导入 model_v5 模块,并给它一个更友好的名称。
②
将 model.NonBlank 投入使用。其余代码保持不变。
我们在本章中看到的 LineItem 示例展示了描述符管理数据属性的典型用法。像 Quantity 这样的描述符被称为覆盖描述符,因为其 __set__ 方法覆盖(即拦截和覆盖)了受管实例中同名实例属性的设置。然而,也有非覆盖描述符。我们将在下一节详细探讨这种区别。
覆盖与非覆盖描述符
请记住,Python 处理属性的方式存在重要的不对称性。通过实例读取属性通常会返回实例中定义的属性,但如果实例中没有这样的属性,则会检索类属性。另一方面,向实例分配属性通常会在实例中创建属性,而不会对类产生任何影响。
这种不对称性也影响到 descriptors,实际上创建了两种广泛的 descriptors 类别,取决于是否实现了__set__方法。如果存在__set__,则该类是 overriding descriptor;否则,它是 nonoverriding descriptor。在我们研究下面示例中的 descriptor 行为时,这些术语将会有意义。
观察不同 descriptor 类别需要一些类,因此我们将使用 Example 23-8 中的代码作为接下来章节的测试基础。
提示
Example 23-8 中的每个__get__和__set__方法都调用print_args,以便以可读的方式显示它们的调用。理解print_args和辅助函数cls_name和display并不重要,所以不要被它们分散注意力。
示例 23-8. descriptorkinds.py:用于研究 descriptor overriding 行为的简单类。
### auxiliary functions for display only ###
def cls_name(obj_or_cls):
cls = type(obj_or_cls)
if cls is type:
cls = obj_or_cls
return cls.__name__.split('.')[-1]
def display(obj):
cls = type(obj)
if cls is type:
return f'<class {obj.__name__}>'
elif cls in [type(None), int]:
return repr(obj)
else:
return f'<{cls_name(obj)} object>'
def print_args(name, *args):
pseudo_args = ', '.join(display(x) for x in args)
print(f'-> {cls_name(args[0])}.__{name}__({pseudo_args})')
### essential classes for this example ###
class Overriding: # ①
"""a.k.a. data descriptor or enforced descriptor"""
def __get__(self, instance, owner):
print_args('get', self, instance, owner) # ②
def __set__(self, instance, value):
print_args('set', self, instance, value)
class OverridingNoGet: # ③
"""an overriding descriptor without ``__get__``"""
def __set__(self, instance, value):
print_args('set', self, instance, value)
class NonOverriding: # ④
"""a.k.a. non-data or shadowable descriptor"""
def __get__(self, instance, owner):
print_args('get', self, instance, owner)
class Managed: # ⑤
over = Overriding()
over_no_get = OverridingNoGet()
non_over = NonOverriding()
def spam(self): # ⑥
print(f'-> Managed.spam({display(self)})')
①
一个带有__get__和__set__的 overriding descriptor 类。
②
print_args函数被这个示例中的每个 descriptor 方法调用。
③
没有__get__方法的 overriding descriptor。
④
这里没有__set__方法,因此这是一个 nonoverriding descriptor。
⑤
托管类,使用每个 descriptor 类的一个实例。
⑥
spam方法在这里用于比较,因为方法也是 descriptors。
在接下来的章节中,我们将研究对Managed类及其一个实例上的属性读取和写入的行为,逐个检查定义的不同 descriptors。
Overriding Descriptors
实现__set__方法的 descriptor 是overriding descriptor,因为虽然它是一个类属性,但实现__set__的 descriptor 将覆盖对实例属性的赋值尝试。这就是 Example 23-3 的实现方式。属性也是 overriding descriptors:如果您不提供 setter 函数,property类的默认__set__将引发AttributeError,以表示该属性是只读的。通过 Example 23-8 中的代码,可以在 Example 23-9 中看到对 overriding descriptor 的实验。
警告
Python 的贡献者和作者在讨论这些概念时使用不同的术语。我从书籍Python in a Nutshell中采用了“overriding descriptor”。官方 Python 文档使用“data descriptor”,但“overriding descriptor”突出了特殊行为。Overriding descriptors 也被称为“enforced descriptors”。非 overriding descriptors 的同义词包括“nondata descriptors”或“shadowable descriptors”。
示例 23-9. overriding descriptor 的行为
>>> obj = Managed() # ①
>>> obj.over # ②
-> Overriding.__get__(<Overriding object>, <Managed object>, <class Managed>)
>>> Managed.over # ③
-> Overriding.__get__(<Overriding object>, None, <class Managed>)
>>> obj.over = 7 # ④
-> Overriding.__set__(<Overriding object>, <Managed object>, 7)
>>> obj.over # ⑤
-> Overriding.__get__(<Overriding object>, <Managed object>, <class Managed>)
>>> obj.__dict__['over'] = 8 # ⑥
>>> vars(obj) # ⑦
{'over': 8}
>>> obj.over # ⑧
-> Overriding.__get__(<Overriding object>, <Managed object>, <class Managed>)
①
为测试创建Managed对象。
②
obj.over触发 descriptor __get__ 方法,将托管实例obj作为第二个参数传递。
③
Managed.over触发 descriptor __get__ 方法,将None作为第二个参数(instance)传递。
④
对obj.over进行赋值会触发 descriptor __set__ 方法,将值7作为最后一个参数传递。
⑤
读取obj.over仍然会调用描述符__get__方法。
⑥
绕过描述符,直接将值设置到obj.__dict__。
⑦
验证该值是否在obj.__dict__中,位于over键下。
⑧
然而,即使有一个名为over的实例属性,Managed.over描述符仍然会覆盖尝试读取obj.over。
覆盖没有 get 的描述符
属性和其他覆盖描述符,如 Django 模型字段,实现了__set__和__get__,但也可以只实现__set__,就像我们在示例 23-2 中看到的那样。在这种情况下,只有描述符处理写入。通过实例读取描述符将返回描述符对象本身,因为没有__get__来处理该访问。如果通过直接访问实例__dict__创建了一个同名实例属性,并通过该实例访问设置了一个新值,则__set__方法仍将覆盖进一步尝试设置该属性,但读取该属性将简单地从实例中返回新值,而不是返回描述符对象。换句话说,实例属性将遮蔽描述符,但仅在读取时。参见示例 23-10。
示例 23-10. 没有__get__的覆盖描述符
>>> obj.over_no_get # ①
<__main__.OverridingNoGet object at 0x665bcc>
>>> Managed.over_no_get # ②
<__main__.OverridingNoGet object at 0x665bcc>
>>> obj.over_no_get = 7 # ③
-> OverridingNoGet.__set__(<OverridingNoGet object>, <Managed object>, 7)
>>> obj.over_no_get # ④
<__main__.OverridingNoGet object at 0x665bcc>
>>> obj.__dict__['over_no_get'] = 9 # ⑤
>>> obj.over_no_get # ⑥
9
>>> obj.over_no_get = 7 # ⑦
-> OverridingNoGet.__set__(<OverridingNoGet object>, <Managed object>, 7)
>>> obj.over_no_get # ⑧
9
①
这个覆盖描述符没有__get__方法,因此读取obj.over_no_get会从类中检索描述符实例。
②
如果我们直接从托管类中检索描述符实例,也会发生同样的事情。
③
尝试将值设置为obj.over_no_get会调用__set__描述符方法。
④
因为我们的__set__不进行更改,再次读取obj.over_no_get将从托管类中检索描述符实例。
⑤
通过实例__dict__设置一个名为over_no_get的实例属性。
⑥
现在over_no_get实例属性遮蔽了描述符,但仅用于读取。
⑦
尝试为obj.over_no_get分配一个值仍然会通过描述符集。
⑧
但是对于读取,只要有同名实例属性,该描述符就会被遮蔽。
非覆盖描述符
一个不实现__set__的描述符是一个非覆盖描述符。设置一个同名的实例属性将遮蔽描述符,在该特定实例中无法处理该属性。方法和@functools.cached_property被实现为非覆盖描述符。示例 23-11 展示了非覆盖描述符的操作。
示例 23-11. 非覆盖描述符的行为
>>> obj = Managed()
>>> obj.non_over # ①
-> NonOverriding.__get__(<NonOverriding object>, <Managed object>, <class Managed>)
>>> obj.non_over = 7 # ②
>>> obj.non_over # ③
7
>>> Managed.non_over # ④
-> NonOverriding.__get__(<NonOverriding object>, None, <class Managed>)
>>> del obj.non_over # ⑤
>>> obj.non_over # ⑥
-> NonOverriding.__get__(<NonOverriding object>, <Managed object>, <class Managed>)
①
obj.non_over触发描述符__get__方法,将obj作为第二个参数传递。
②
Managed.non_over是一个非覆盖描述符,因此没有__set__干扰此赋值。
③
现在obj有一个名为non_over的实例属性,它遮蔽了Managed类中同名的描述符属性。
④
Managed.non_over描述符仍然存在,并通过类捕获此访问。
⑤
如果删除non_over实例属性...
⑥
…然后读取obj.non_over会触发类中描述符的__get__方法,但请注意第二个参数是受控实例。
在之前的示例中,我们看到了对实例属性进行多次赋值,属性名与描述符相同,并根据描述符中是否存在__set__方法而产生不同的结果。
类中属性的设置不能由附加到同一类的描述符控制。特别是,这意味着描述符属性本身可以被赋值给类,就像下一节所解释的那样。
在类中覆盖描述符
无论描述符是覆盖还是非覆盖的,都可以通过对类的赋值来覆盖。这是一种猴子补丁技术,但在示例 23-12 中,描述符被整数替换,这将有效地破坏任何依赖描述符进行正确操作的类。
示例 23-12. 任何描述符都可以在类本身上被覆盖
>>> obj = Managed() # ①
>>> Managed.over = 1 # ②
>>> Managed.over_no_get = 2
>>> Managed.non_over = 3
>>> obj.over, obj.over_no_get, obj.non_over # ③
(1, 2, 3)
①
创建一个新实例以供后续测试。
②
覆盖类中的描述符属性。
③
描述符真的消失了。
示例 23-12 揭示了关于读取和写入属性的另一个不对称性:尽管可以通过附加到受控类的__get__的描述符来控制类属性的读取,但是通过附加到同一类的__set__的描述符无法处理类属性的写入。
提示
为了控制类中属性的设置,您必须将描述符附加到类的类中,换句话说,元类。默认情况下,用户定义类的元类是type,您无法向type添加属性。但是在第二十四章中,我们将创建自己的元类。
现在让我们专注于描述符在 Python 中如何用于实现方法。
方法是描述符
当在实例上调用时,类中的函数会变成绑定方法,因为所有用户定义的函数都有一个__get__方法,因此当附加到类时,它们作为描述符运行。示例 23-13 演示了从示例 23-8 中引入的Managed类中读取spam方法。
示例 23-13. 方法是一个非覆盖描述符
>>> obj = Managed()
>>> obj.spam # ①
<bound method Managed.spam of <descriptorkinds.Managed object at 0x74c80c>>
>>> Managed.spam # ②
<function Managed.spam at 0x734734>
>>> obj.spam = 7 # ③
>>> obj.spam
7
①
从obj.spam读取会得到一个绑定的方法对象。
②
但是从Managed.spam读取会得到一个函数。
③
给obj.spam赋值会隐藏类属性,使得obj实例无法从spam方法中访问。
函数不实现__set__,因为它们是非覆盖描述符,正如示例 23-13 的最后一行所示。
从示例 23-13 中另一个关键点是obj.spam和Managed.spam检索到不同的对象。与描述符一样,当通过受控类进行访问时,函数的__get__返回对自身的引用。但是当访问通过实例进行时,函数的__get__返回一个绑定的方法对象:一个可调用对象,包装函数并将受控实例(例如obj)绑定到函数的第一个参数(即self),就像functools.partial函数所做的那样(如“使用 functools.partial 冻结参数”中所示)。要更深入地了解这种机制,请查看示例 23-14。
示例 23-14. method_is_descriptor.py:一个从UserString派生的Text类
import collections
class Text(collections.UserString):
def __repr__(self):
return 'Text({!r})'.format(self.data)
def reverse(self):
return self[::-1]
现在让我们来研究Text.reverse方法。参见示例 23-15。
示例 23-15. 使用方法进行实验
>>> word = Text('forward')
>>> word # ①
Text('forward')
>>> word.reverse() # ②
Text('drawrof')
>>> Text.reverse(Text('backward')) # ③
Text('drawkcab')
>>> type(Text.reverse), type(word.reverse) # ④
(<class 'function'>, <class 'method'>)
>>> list(map(Text.reverse, ['repaid', (10, 20, 30), Text('stressed')])) # ⑤
['diaper', (30, 20, 10), Text('desserts')]
>>> Text.reverse.__get__(word) # ⑥
<bound method Text.reverse of Text('forward')>
>>> Text.reverse.__get__(None, Text) # ⑦
<function Text.reverse at 0x101244e18>
>>> word.reverse # ⑧
<bound method Text.reverse of Text('forward')>
>>> word.reverse.__self__ # ⑨
Text('forward')
>>> word.reverse.__func__ is Text.reverse # ⑩
True
①
Text实例的repr看起来像一个Text构造函数调用,可以创建一个相同的实例。
②
reverse方法返回拼写颠倒的文本。
③
在类上调用的方法作为一个函数。
④
注意不同类型:一个function和一个method。
⑤
Text.reverse作为一个函数运行,甚至可以处理不是Text实例的对象。
⑥
任何函数都是非覆盖描述符。使用实例调用其__get__将检索绑定到该实例的方法。
⑦
使用None作为instance参数调用函数的__get__将检索函数本身。
⑧
表达式word.reverse实际上调用了Text.reverse.__get__(word),返回绑定方法。
⑨
绑定方法对象有一个__self__属性,保存着调用该方法的实例的引用。
⑩
绑定方法的__func__属性是指向所管理类中原始函数的引用。
绑定方法对象还有一个__call__方法,用于处理实际的调用。这个方法调用__func__中引用的原始函数,将方法的__self__属性作为第一个参数传递。这就是传统self参数的隐式绑定方式的工作原理。
将函数转换为绑定方法的方式是描述符在语言中作为基础设施使用的一个典型例子。
在深入了解描述符和方法的工作原理之后,让我们来看看关于它们使用的一些建议。
描述符使用提示
以下列表解决了刚才描述的描述符特性的一些实际后果:
使用property保持简单
内置的property创建覆盖描述符,实现__set__和__get__,即使你没有定义一个 setter 方法。⁷ 属性的默认__set__会引发AttributeError: can't set attribute,因此属性是创建只读属性的最简单方式,避免了下面描述的问题。
只读描述符需要__set__
如果你使用描述符类来实现一个只读属性,你必须记得编写__get__和__set__,否则在实例上设置一个同名属性将会遮蔽描述符。只读属性的__set__方法应该只是引发AttributeError并附带适当的消息。⁸
验证描述符只能与__set__一起使用
在仅用于验证的描述符中,__set__方法应该检查其接收到的value参数,如果有效,直接在实例的__dict__中使用描述符实例名称作为键设置它。这样,从实例中读取具有相同名称的属性将尽可能快,因为它不需要__get__。查看示例 23-3 的代码。
使用__get__可以高效地进行缓存
如果只编写 __get__ 方法,则具有非覆盖描述符。 这些对于进行一些昂贵的计算然后通过在实例上设置同名属性来缓存结果很有用。⁹ 同名实例属性将遮蔽描述符,因此对该属性的后续访问将直接从实例 __dict__ 中获取,而不再触发描述符 __get__。 @functools.cached_property 装饰器实际上生成一个非覆盖描述符。
非特殊方法可以被实例属性遮蔽
因为函数和方法只实现 __get__,它们是非覆盖描述符。 诸如 my_obj.the_method = 7 这样的简单赋值意味着通过该实例进一步访问 the_method 将检索数字 7 —— 而不会影响类或其他实例。 但是,这个问题不会干扰特殊方法。 解释器只在类本身中查找特殊方法,换句话说,repr(x) 被执行为 x.__class__.__repr__(x),因此在 x 中定义的 __repr__ 属性对 repr(x) 没有影响。 出于同样的原因,实例中存在名为 __getattr__ 的属性不会颠覆通常的属性访问算法。
实例中非特殊方法如此容易被覆盖可能听起来脆弱且容易出错,但在我个人超过 20 年的 Python 编码中从未受到过这方面的影响。 另一方面,如果您正在进行大量的动态属性创建,其中属性名称来自您无法控制的数据(就像我们在本章的前面部分所做的那样),那么您应该意识到这一点,并可能实现一些过滤或转义动态属性名称以保持理智。
注意
FrozenJSON 类在 示例 22-5 中受到实例属性遮蔽方法的保护,因为它的唯一方法是特殊方法和 build 类方法。 只要始终通过类访问类方法,类方法就是安全的,就像我在 示例 22-5 中使用 FrozenJSON.build 一样——后来在 示例 22-6 中被 __new__ 取代。 “计算属性” 中介绍的 Record 和 Event 类也是安全的:它们只实现特殊方法、静态方法和属性。 属性是覆盖描述符,因此不会被实例属性遮蔽。
结束本章时,我们将介绍两个我们在属性中看到但在描述符的上下文中尚未解决的功能:文档和处理尝试删除托管属性。
描述符文档字符串和覆盖删除
描述符类的文档字符串用于记录托管类中每个描述符的实例。 图 23-4 显示了带有示例 23-6 和 23-7 中的 Quantity 和 NonBlank 描述符的 LineItem 类的帮助显示。
这有点令人不满意。 对于 LineItem,例如,添加 weight 必须是千克的信息会很好。 这对于属性来说是微不足道的,因为每个属性处理一个特定的托管属性。 但是使用描述符,Quantity 描述符类用于 weight 和 price。¹⁰
我们讨论了与属性一起讨论的第二个细节,但尚未使用描述符处理尝试删除托管属性的尝试。这可以通过在描述符类中实现__delete__方法来完成,而不是通常的__get__和/或__set__。我故意省略了对__delete__的覆盖,因为我认为实际使用是罕见的。如果您需要此功能,请参阅“实现描述符”部分的Python 数据模型文档。编写一个带有__delete__的愚蠢描述符类留给悠闲的读者作为练习。

图 23-4。在发出命令help(LineItem.weight)和help(LineItem)时 Python 控制台的屏幕截图。
章节总结
本章的第一个示例是从第二十二章的LineItem示例中延续的。在示例 23-2 中,我们用描述符替换了属性。我们看到描述符是一个提供实例的类,这些实例被部署为托管类中的属性。讨论这种机制需要特殊术语,引入了诸如托管实例和存储属性之类的术语。
在“LineItem Take #4: Automatic Naming of Storage Attributes”中,我们取消了要求使用显式storage_name声明Quantity描述符的要求,这是多余且容易出错的。解决方案是在Quantity中实现__set_name__特殊方法,将托管属性的名称保存为self.storage_name。
“LineItem Take #5: A New Descriptor Type”展示了如何对抽象描述符类进行子类化,以在构建具有一些共同功能的专门描述符时共享代码。
然后,我们研究了提供或省略__set__方法的描述符的不同行为,区分了重写和非重写描述符,即数据和非数据描述符。通过详细测试,我们揭示了描述符何时控制何时被遮蔽、绕过或覆盖。
随后,我们研究了一类特定的非重写描述符:方法。控制台实验揭示了当通过实例访问时,附加到类的函数如何通过利用描述符协议成为方法。
结束本章,“描述符使用技巧”提供了实用技巧,而“描述符文档字符串和重写删除”则简要介绍了如何记录描述符。
注意
正如在“本章新内容”中所指出的,本章中的几个示例由于描述符协议中 Python 3.6 中添加的__set_name__特殊方法而变得简单得多。这就是语言的进化!
进一步阅读
除了对“数据模型”章节的必要参考外,Raymond Hettinger 的“描述符指南”是一个宝贵的资源——它是官方 Python 文档中HowTo 系列的一部分。
与 Python 对象模型主题一样,Martelli、Ravenscroft 和 Holden 的Python in a Nutshell,第 3 版(O’Reilly)是权威且客观的。Martelli 还有一个名为“Python 的对象模型”的演示,深入介绍了属性和描述符(请参阅幻灯片和视频)。
警告
请注意,在 2016 年采用 PEP 487 之前编写或记录的描述符覆盖内容可能在今天显得过于复杂,因为在 Python 3.6 之前的版本中不支持__set_name__。
对于更多实际示例,《Python Cookbook》,第 3 版,作者 David Beazley 和 Brian K. Jones(O’Reilly)有许多示例说明描述符,其中我想强调“6.12. 读取嵌套和可变大小的二进制结构”,“8.10. 使用惰性计算属性”,“8.13. 实现数据模型或类型系统”和“9.9. 定义装饰器作为类”。最后一种方法解决了函数装饰器、描述符和方法交互的深层问题,解释了如果将函数装饰器实现为具有__call__的类,还需要实现__get__以便与装饰方法和函数一起使用。
PEP 487—更简单的类创建自定义引入了__set_name__特殊方法,并包括一个验证描述符的示例。
¹ Raymond Hettinger,《描述符指南》(https://fpy.li/descrhow)。
² 在 UML 类图中,类和实例被绘制为矩形。虽然在类图中有视觉差异,但实例很少在类图中显示,因此开发人员可能不会将其识别为实例。
³ 白松露每磅成本数千美元。不允许以 0.01 美元出售松露留给有企图的读者作为练习。我知道一个人实际上因为在线商店的错误(这次不是Amazon.com)而以 18 美元购买了一本价值 1800 美元的统计百科全书。
⁴ 更准确地说,__set_name__是由type.__new__调用的——表示类的对象的构造函数。内置的type实际上是一个元类,用户定义类的默认类。这一点一开始很难理解,但请放心:第二十四章专门讨论了类的动态配置,包括元类的概念。
⁵ Gamma 等人,《设计模式:可复用面向对象软件的元素》,第 326 页。
⁶ Alex Martelli 的“Python 设计模式”演讲第 50 页幻灯片(https://fpy.li/23-1)。强烈推荐。
⁷ property装饰器还提供了一个__delete__方法,即使您没有定义删除方法。
⁸ Python 在这类消息中并不一致。尝试更改complex数的c.real属性会得到AttributeError: readonly attribute,但尝试更改complex的方法c.conjugate会得到AttributeError: 'complex' object attribute 'conjugate' is read-only。甚至“read-only”的拼写也不同。
⁹ 但是,请记住,在__init__方法运行后创建实例属性会破坏关键共享内存优化,正如从“dict 工作原理的实际后果”中讨论的那样。
¹⁰ 自定义每个描述符实例的帮助文本实际上是非常困难的。一种解决方案需要为每个描述符实例动态构建一个包装类。
第二十四章:类元编程
每个人都知道调试比一开始编写程序要困难两倍。所以如果你在编写时尽可能聪明,那么你将如何调试呢?
Brian W. Kernighan 和 P. J. Plauger,《编程风格的要素》¹
类元编程是在运行时创建或自定义类的艺术。在 Python 中,类是一等对象,因此可以使用函数在任何时候创建一个新类,而无需使用 class 关键字。类装饰器也是函数,但设计用于检查、更改甚至替换装饰的类为另一个类。最后,元类是类元编程的最高级工具:它们让你创建具有特殊特性的全新类别的类,例如我们已经看到的抽象基类。
元类很强大,但很难证明其合理性,甚至更难正确使用。类装饰器解决了许多相同的问题,并且更容易理解。此外,Python 3.6 实现了 PEP 487—更简单的类创建自定义,提供了支持以前需要元类或类装饰器完成的任务的特殊方法。²
本章按复杂性递增的顺序介绍了类元编程技术。
警告
这是一个令人兴奋的话题,很容易让人着迷。因此,我必须提供这些建议。
为了可读性和可维护性,你可能应该避免在应用代码中使用本章描述的技术。
另一方面,如果你想编写下一个伟大的 Python 框架,这些就是你的工具。
本章新内容
第一版《流畅的 Python》“类元编程”章节中的所有代码仍然可以正确运行。然而,由于自 Python 3.6 以来添加了新功能,一些先前的示例不再代表最简单的解决方案。
我用不同的示例替换了那些示例,突出了 Python 的新元编程特性或添加了进一步的要求,以证明使用更高级技术的合理性。一些新示例利用类型提示提供了类构建器,类似于 @dataclass 装饰器和 typing.NamedTuple。
“现实世界中的元类” 是一个关于元类适用性的高层考虑的新部分。
提示
一些最好的重构是通过删除由更新和更简单的解决相同问题的方法所导致的冗余代码来实现的。这适用于生产代码以及书籍。
我们将从审查 Python 数据模型中为所有类定义的属性和方法开始。
类作为对象
像 Python 中的大多数程序实体一样,类也是对象。每个类在 Python 数据模型中都有一些属性,这些属性在《Python 标准库》的“内置类型”章节中的 “4.13. 特殊属性” 中有文档记录。这本书中已经多次出现了其中的三个属性:__class__、__name__ 和 __mro__。其他类的标准属性包括:
cls.__bases__
类的基类元组。
cls.__qualname__
类或函数的限定名称,这是从模块的全局范围到类定义的点路径。当类在另一个类内部定义时,这是相关的。例如,在 Django 模型类中,比如 Ox,有一个名为 Meta 的内部类。Meta 的 __qualname__ 是 Ox.Meta,但它的 __name__ 只是 Meta。此属性的规范是 PEP 3155—类和函数的限定名称。
cls.__subclasses__()
此方法返回类的直接子类列表。该实现使用弱引用以避免超类和其子类之间的循环引用——后者在其__bases__属性中保留对超类的强引用。该方法列出当前内存中的子类。尚未导入的模块中的子类不会出现在结果中。
cls.mro()
解释器在构建类时调用此方法,以获取存储在类的__mro__属性中的超类元组。元类可以重写此方法以自定义正在构建的类的方法解析顺序。
提示
此部分提到的属性都不会被dir(…)函数列出。
现在,如果一个类是一个对象,那么一个类的类是什么?
类型:内置类工厂
我们通常认为type是一个返回对象类的函数,因为type(my_object)的作用是返回my_object.__class__。
然而,type是一个在用三个参数调用时创建新类的类。
考虑这个简单的类:
class MyClass(MySuperClass, MyMixin):
x = 42
def x2(self):
return self.x * 2
使用type构造函数,你可以用这段代码在运行时创建MyClass:
MyClass = type('MyClass',
(MySuperClass, MyMixin),
{'x': 42, 'x2': lambda self: self.x * 2},
)
那个type调用在功能上等同于之前的class MyClass…块语句。
当 Python 读取一个class语句时,它调用type以使用这些参数构建类对象:
name
出现在class关键字之后的标识符,例如,MyClass。
bases
在类标识符之后的括号中给出的超类元组,如果在class语句中未提及超类,则为(object,)。
dict
属性名称到值的映射。可调用对象变成方法,就像我们在“方法是描述符”中看到的那样。其他值变成类属性。
注意
type构造函数接受可选的关键字参数,这些参数会被type本身忽略,但会原封不动地传递到__init_subclass__中,后者必须消耗这些参数。我们将在“介绍 init_subclass”中学习这个特殊方法,但我不会涉及关键字参数的使用。更多信息,请阅读PEP 487—更简单的类创建自定义。
type类是一个元类:一个构建类的类。换句话说,type类的实例是类。标准库提供了一些其他元类,但type是默认的:
>>> type(7)
<class 'int'>
>>> type(int)
<class 'type'>
>>> type(OSError)
<class 'type'>
>>> class Whatever:
... pass
...
>>> type(Whatever)
<class 'type'>
我们将在“元类 101”中构建自定义元类。
接下来,我们将使用内置的type来创建一个构建类的函数。
一个类工厂函数
标准库有一个类工厂函数,在本书中出现了多次:collections.namedtuple。在第五章中,我们还看到了typing.NamedTuple和@dataclass。所有这些类构建器都利用了本章介绍的技术。
我们将从一个用于可变对象类的超级简单工厂开始——这是@dataclass的最简单替代品。
假设我正在编写一个宠物店应用程序,我想将狗的数据存储为简单记录。但我不想写这样的样板代码:
class Dog:
def __init__(self, name, weight, owner):
self.name = name
self.weight = weight
self.owner = owner
无聊…每个字段名称出现三次,而且那些样板代码甚至不能为我们提供一个漂亮的repr:
>>> rex = Dog('Rex', 30, 'Bob')
>>> rex
<__main__.Dog object at 0x2865bac>
借鉴collections.namedtuple,让我们创建一个record_factory,可以动态创建像Dog这样的简单类。示例 24-1 展示了它应该如何工作。
示例 24-1. 测试record_factory,一个简单的类工厂
>>> Dog = record_factory('Dog', 'name weight owner') # ①
>>> rex = Dog('Rex', 30, 'Bob')
>>> rex # ②
Dog(name='Rex', weight=30, owner='Bob')
>>> name, weight, _ = rex # ③
>>> name, weight
('Rex', 30)
>>> "{2}'s dog weighs {1}kg".format(*rex) # ④
"Bob's dog weighs 30kg"
>>> rex.weight = 32 # ⑤
>>> rex
Dog(name='Rex', weight=32, owner='Bob')
>>> Dog.__mro__ # ⑥
(<class 'factories.Dog'>, <class 'object'>)
①
工厂可以像namedtuple一样调用:类名,后跟用单个字符串中的空格分隔的属性名称。
②
漂亮的repr。
③
实例是可迭代的,因此它们可以在赋值时方便地解包…
④
…或者当传递给format等函数时。
⑤
记录实例是可变的。
⑥
新创建的类继承自object——与我们的工厂无关。
record_factory的代码在示例 24-2 中。³
示例 24-2. record_factory.py:一个简单的类工厂
from typing import Union, Any
from collections.abc import Iterable, Iterator
FieldNames = Union[str, Iterable[str]] # ①
def record_factory(cls_name: str, field_names: FieldNames) -> type[tuple]: # ②
slots = parse_identifiers(field_names) # ③
def __init__(self, *args, **kwargs) -> None: # ④
attrs = dict(zip(self.__slots__, args))
attrs.update(kwargs)
for name, value in attrs.items():
setattr(self, name, value)
def __iter__(self) -> Iterator[Any]: # ⑤
for name in self.__slots__:
yield getattr(self, name)
def __repr__(self): # ⑥
values = ', '.join(f'{name}={value!r}'
for name, value in zip(self.__slots__, self))
cls_name = self.__class__.__name__
return f'{cls_name}({values})'
cls_attrs = dict( # ⑦
__slots__=slots,
__init__=__init__,
__iter__=__iter__,
__repr__=__repr__,
)
return type(cls_name, (object,), cls_attrs) # ⑧
def parse_identifiers(names: FieldNames) -> tuple[str, ...]:
if isinstance(names, str):
names = names.replace(',', ' ').split() # ⑨
if not all(s.isidentifier() for s in names):
raise ValueError('names must all be valid identifiers')
return tuple(names)
①
用户可以将字段名称提供为单个字符串或字符串的可迭代对象。
②
接受类似于collections.namedtuple的前两个参数;返回一个type,即一个类,其行为类似于tuple。
③
构建属性名称的元组;这将是新类的__slots__属性。
④
此函数将成为新类中的__init__方法。它接受位置参数和/或关键字参数。⁴
⑤
按照__slots__给定的顺序产生字段值。
⑥
生成漂亮的repr,遍历__slots__和self。
⑦
组装类属性的字典。
⑧
构建并返回新类,调用type构造函数。
⑨
将由空格或逗号分隔的names转换为str列表。
示例 24-2 是我们第一次在类型提示中看到type。如果注释只是-> type,那意味着record_factory返回一个类,这是正确的。但是注释-> type[tuple]更精确:它表示返回的类将是tuple的子类。
record_factory在示例 24-2 的最后一行构建了一个由cls_name的值命名的类,以object作为其唯一的直接基类,并且具有一个加载了__slots__、__init__、__iter__和__repr__的命名空间,其中最后三个是实例方法。
我们可以将__slots__类属性命名为其他任何名称,但然后我们必须实现__setattr__来验证被分配的属性名称,因为对于类似记录的类,我们希望属性集始终相同且顺序相同。但是,请记住,__slots__的主要特点是在处理数百万个实例时节省内存,并且使用__slots__有一些缺点,讨论在“使用 slots 节省内存”中。
警告
由record_factory创建的类的实例不可序列化,也就是说,它们无法使用pickle模块的dump函数导出。解决这个问题超出了本示例的范围,本示例旨在展示type类在简单用例中的应用。要获取完整解决方案,请查看collections.namedtuple的源代码;搜索“pickling”一词。
现在让我们看看如何模拟更现代的类构建器,比如typing.NamedTuple,它接受一个用户定义的class语句编写的类,并自动增强其功能。
引入__init_subclass__。
__init_subclass__和__set_name__都在PEP 487—更简单的类创建自定义中提出。我们第一次在“LineItem Take #4: Automatic Naming of Storage Attributes”中看到描述符的__set_name__特殊方法。现在让我们研究__init_subclass__。
在第五章中,我们看到typing.NamedTuple和@dataclass允许程序员使用class语句为新类指定属性,然后通过类构建器增强该类,自动添加必要的方法如__init__,__repr__,__eq__等。
这两个类构建器都会读取用户class语句中的类型提示以增强类。这些类型提示还允许静态类型检查器验证设置或获取这些属性的代码。然而,NamedTuple和@dataclass在运行时不利用类型提示进行属性验证。下一个示例中的Checked类会这样做。
注意
不可能支持每种可能的静态类型提示进行运行时类型检查,这可能是为什么typing.NamedTuple和@dataclass甚至不尝试的原因。然而,一些也是具体类的类型可以与Checked一起使用。这包括通常用于字段内容的简单类型,如str,int,float和bool,以及这些类型的列表。
示例 24-3 展示了如何使用Checked构建Movie类。
示例 24-3. initsub/checkedlib.py:创建Checked的Movie子类的 doctest
>>> class Movie(Checked): # ①
... title: str # ②
... year: int
... box_office: float
...
>>> movie = Movie(title='The Godfather', year=1972, box_office=137) # ③
>>> movie.title
'The Godfather'
>>> movie # ④
Movie(title='The Godfather', year=1972, box_office=137.0)
①
Movie继承自Checked—我们稍后将在示例 24-5 中定义。
②
每个属性都用构造函数进行了注释。这里我使用了内置类型。
③
必须使用关键字参数创建Movie实例。
④
作为回报,您会得到一个漂亮的__repr__。
用作属性类型提示的构造函数可以是任何可调用的函数,接受零个或一个参数并返回适合预期字段类型的值,或者通过引发TypeError或ValueError拒绝参数。
在示例 24-3 中使用内置类型作为注释意味着这些值必须被类型的构造函数接受。对于int,这意味着任何x,使得int(x)返回一个int。对于str,在运行时任何值都可以,因为str(x)在 Python 中适用于任何x。⁵
当不带参数调用时,构造函数应返回其类型的默认值。⁶
这是 Python 内置构造函数的标准行为:
>>> int(), float(), bool(), str(), list(), dict(), set()
(0, 0.0, False, '', [], {}, set())
在Movie这样的Checked子类中,缺少参数会导致实例使用字段构造函数返回的默认值。例如:
>>> Movie(title='Life of Brian')
Movie(title='Life of Brian', year=0, box_office=0.0)
构造函数在实例化期间和在实例上直接设置属性时用于验证:
>>> blockbuster = Movie(title='Avatar', year=2009, box_office='billions')
Traceback (most recent call last):
...
TypeError: 'billions' is not compatible with box_office:float
>>> movie.year = 'MCMLXXII'
Traceback (most recent call last):
...
TypeError: 'MCMLXXII' is not compatible with year:int
Checked 子类和静态类型检查
在一个带有Movie实例movie的.py源文件中,如示例 24-3 中定义的,Mypy 将此赋值标记为类型错误:
movie.year = 'MCMLXXII'
然而,Mypy 无法检测到这个构造函数调用中的类型错误:
blockbuster = Movie(title='Avatar', year='MMIX')
这是因为Movie继承了Checked.__init__,该方法的签名必须接受任何关键字参数以支持任意用户定义的类。
另一方面,如果您声明一个带有类型提示list[float]的Checked子类字段,Mypy 可以标记具有不兼容内容的列表的赋值,但Checked将忽略类型参数并将其视为list。
现在让我们看一下checkedlib.py的实现。第一个类是Field描述符,如示例 24-4 所示。
示例 24-4. initsub/checkedlib.py:Field描述符类
from collections.abc import Callable # ①
from typing import Any, NoReturn, get_type_hints
class Field:
def __init__(self, name: str, constructor: Callable) -> None: # ②
if not callable(constructor) or constructor is type(None): # ③
raise TypeError(f'{name!r} type hint must be callable')
self.name = name
self.constructor = constructor
def __set__(self, instance: Any, value: Any) -> None:
if value is ...: # ④
value = self.constructor()
else:
try:
value = self.constructor(value) # ⑤
except (TypeError, ValueError) as e: # ⑥
type_name = self.constructor.__name__
msg = f'{value!r} is not compatible with {self.name}:{type_name}'
raise TypeError(msg) from e
instance.__dict__[self.name] = value # ⑦
①
请注意,自 Python 3.9 以来,用于注释的Callable类型是collections.abc中的 ABC,而不是已弃用的typing.Callable。
②
这是一个最小的Callable类型提示;constructor的参数类型和返回类型都隐含为Any。
③
对于运行时检查,我们使用callable内置函数。⁷ 对type(None)的测试是必要的,因为 Python 将类型中的None解读为NoneType,即None的类(因此可调用),但是一个无用的构造函数,只返回None。
④
如果Checked.__init__将value设置为...(内置对象Ellipsis),我们将不带参数调用constructor。
⑤
否则,使用给定的value调用constructor。
⑥
如果constructor引发这些异常中的任何一个,我们将引发TypeError,并提供一个包含字段和构造函数名称的有用消息;例如,'MMIX'与 year:int 不兼容。
⑦
如果没有引发异常,则将value存储在instance.__dict__中。
在__set__中,我们需要捕获TypeError和ValueError,因为内置构造函数可能会引发其中之一,具体取决于参数。例如,float(None)引发TypeError,但float('A')引发ValueError。另一方面,float('8')不会引发错误,并返回8.0。我在此声明,这是这个玩具示例的一个特性,而不是一个 bug。
提示
在“LineItem Take #4: 自动命名存储属性”中,我们看到了描述符的方便__set_name__特殊方法。我们在Field类中不需要它,因为描述符不是在客户端源代码中实例化的;用户声明的类型是构造函数,正如我们在Movie类中看到的(示例 24-3)。相反,Field描述符实例是由Checked.__init_subclass__方法在运行时创建的,我们将在示例 24-5 中看到。
现在让我们专注于Checked类。我将其拆分为两个列表。示例 24-5 显示了该类的顶部,其中包含此示例中最重要的方法。其余方法在示例 24-6 中。
示例 24-5. initsub/checkedlib.py:Checked类的最重要方法
class Checked:
@classmethod
def _fields(cls) -> dict[str, type]: # ①
return get_type_hints(cls)
def __init_subclass__(subclass) -> None: # ②
super().__init_subclass__() # ③
for name, constructor in subclass._fields().items(): # ④
setattr(subclass, name, Field(name, constructor)) # ⑤
def __init__(self, **kwargs: Any) -> None:
for name in self._fields(): # ⑥
value = kwargs.pop(name, ...) # ⑦
setattr(self, name, value) # ⑧
if kwargs: # ⑨
self.__flag_unknown_attrs(*kwargs) # ⑩
①
我编写了这个类方法,以隐藏对typing.get_type_hints的调用,使其不被类的其他部分所知晓。如果我需要支持 Python ≥ 3.10,我会调用inspect.get_annotations。请查看“运行时注解的问题”以了解这些函数的问题。
②
当定义当前类的子类时,会调用__init_subclass__。它将新的子类作为第一个参数传递进来,这就是为什么我将参数命名为subclass而不是通常的cls。有关更多信息,请参阅“init_subclass 不是典型的类方法”。
③
super().__init_subclass__()并非绝对必要,但应该被调用,以便与可能在相同继承图中实现.__init_subclass__()的其他类友好相处。请参阅“多重继承和方法解析顺序”。
④
遍历每个字段的name和constructor…
⑤
…在subclass上创建一个属性,该属性的name绑定到一个使用name和constructor参数化的Field描述符。
⑥
对于类字段中的每个name…
⑦
…从kwargs中获取相应的value并将其从kwargs中删除。使用...(Ellipsis对象)作为默认值允许我们区分给定值为None的参数和未给定的参数。⁸
⑧
这个setattr调用触发了Checked.__setattr__,如示例 24-6 所示。
⑨
如果kwargs中还有剩余项,它们的名称与声明的字段不匹配,__init__将失败。
⑩
错误由__flag_unknown_attrs报告,列在示例 24-6 中。它使用*names参数来传递未知属性名称。我在*kwargs中使用单个星号将其键作为参数序列传递。
现在让我们看看Checked类的剩余方法,从示例 24-5 继续。请注意,我在_fields和_asdict方法名称前加上_的原因与collections.namedtuple API 相同:为了减少与用户定义的字段名称发生冲突的机会。
示例 24-6. initsub/checkedlib.py:Checked类的剩余方法
def __setattr__(self, name: str, value: Any) -> None: # ①
if name in self._fields(): # ②
cls = self.__class__
descriptor = getattr(cls, name)
descriptor.__set__(self, value) # ③
else: # ④
self.__flag_unknown_attrs(name)
def __flag_unknown_attrs(self, *names: str) -> NoReturn: # ⑤
plural = 's' if len(names) > 1 else ''
extra = ', '.join(f'{name!r}' for name in names)
cls_name = repr(self.__class__.__name__)
raise AttributeError(f'{cls_name} object has no attribute{plural} {extra}')
def _asdict(self) -> dict[str, Any]: # ⑥
return {
name: getattr(self, name)
for name, attr in self.__class__.__dict__.items()
if isinstance(attr, Field)
}
def __repr__(self) -> str: # ⑦
kwargs = ', '.join(
f'{key}={value!r}' for key, value in self._asdict().items()
)
return f'{self.__class__.__name__}({kwargs})'
①
拦截所有尝试设置实例属性。这是为了防止设置未知属性。
②
如果属性name已知,则获取相应的descriptor。
③
通常我们不需要显式调用描述符__set__。在这种情况下是必要的,因为__setattr__拦截所有尝试在实例上设置属性的尝试,包括在存在覆盖描述符(如Field)的情况下。⁹
④
否则,属性name是未知的,__flag_unknown_attrs将引发异常。
⑤
构建一个有用的错误消息,列出所有意外参数,并引发AttributeError。这是NoReturn特殊类型的一个罕见例子,详见NoReturn。
⑥
从Movie对象的属性创建一个dict。我会将这个方法命名为_as_dict,但我遵循了collections.namedtuple中_asdict方法开始的惯例。
⑦
实现一个好的__repr__是在这个例子中拥有_asdict的主要原因。
Checked示例说明了在实现__setattr__以阻止实例化后设置任意属性时如何处理覆盖描述符。在这个例子中,实现__setattr__是否值得讨论是有争议的。如果没有,设置movie.director = 'Greta Gerwig'将成功,但director属性不会以任何方式被检查,并且不会出现在__repr__中,也不会包含在_asdict返回的dict中——这两者在示例 24-6 中定义。
在record_factory.py(示例 24-2)中,我使用__slots__类属性解决了这个问题。然而,在这种情况下,这种更简单的解决方案是不可行的,如下所述。
为什么__init_subclass__无法配置__slots__
__slots__属性仅在它是传递给type.__new__的类命名空间中的条目之一时才有效。向现有类添加__slots__没有效果。Python 仅在类构建后调用__init_subclass__,此时配置__slots__已经太晚了。类装饰器也无法配置__slots__,因为它甚至比__init_subclass__应用得更晚。我们将在“发生了什么:导入时间与运行时”中探讨这些时间问题。
要在运行时配置 __slots__,您自己的代码必须构建作为 type.__new__ 的最后一个参数传递的类命名空间。为此,您可以编写一个类工厂函数,例如 record_factory.py,或者您可以采取核心选项并实现一个元类。我们将看到如何在 “元类 101” 中动态配置 __slots__。
在 PEP 487 简化了 Python 3.7 中使用 __init_subclass__ 自定义类创建的过程之前,类似的功能必须使用类装饰器来实现。这是下一节的重点。
使用类装饰器增强类
类装饰器是一个可调用对象,类似于函数装饰器:它以装饰的类作为参数,并应返回一个用于替换装饰类的类。类装饰器通常通过属性赋值在装饰类本身后注入更多方法后返回装饰类本身。
选择类装饰器而不是更简单的 __init_subclass__ 最常见的原因可能是为了避免干扰其他类特性,如继承和元类。¹⁰
在本节中,我们将学习 checkeddeco.py,它提供了与 checkedlib.py 相同的服务,但使用了类装饰器。和往常一样,我们将从 checkeddeco.py 中的 doctests 中提取的用法示例开始查看(示例 24-7)。
示例 24-7. checkeddeco.py:创建使用 @checked 装饰的 Movie 类
>>> @checked
... class Movie:
... title: str
... year: int
... box_office: float
...
>>> movie = Movie(title='The Godfather', year=1972, box_office=137)
>>> movie.title
'The Godfather'
>>> movie
Movie(title='The Godfather', year=1972, box_office=137.0)
示例 24-7 和 示例 24-3 之间唯一的区别是 Movie 类的声明方式:它使用 @checked 装饰而不是继承 Checked。否则,外部行为相同,包括类型验证和默认值分配在 “引入 init_subclass” 中示例 24-3 之后显示的内容。
现在让我们看看 checkeddeco.py 的实现。导入和 Field 类与 checkedlib.py 中的相同,列在 示例 24-4 中。没有其他类,只有 checkeddeco.py 中的函数。
之前在 __init_subclass__ 中实现的逻辑现在是 checked 函数的一部分——类装饰器列在 示例 24-8 中。
示例 24-8. checkeddeco.py:类装饰器
def checked(cls: type) -> type: # ①
for name, constructor in _fields(cls).items(): # ②
setattr(cls, name, Field(name, constructor)) # ③
cls._fields = classmethod(_fields) # type: ignore # ④
instance_methods = ( # ⑤
__init__,
__repr__,
__setattr__,
_asdict,
__flag_unknown_attrs,
)
for method in instance_methods: # ⑥
setattr(cls, method.__name__, method)
return cls # ⑦
①
请记住,类是 type 的实例。这些类型提示强烈暗示这是一个类装饰器:它接受一个类并返回一个类。
②
_fields 是模块中稍后定义的顶层函数(在 示例 24-9 中)。
③
用 Field 描述符实例替换 _fields 返回的每个属性是 __init_subclass__ 在 示例 24-5 中所做的。这里还有更多的工作要做...
④
从 _fields 中构建一个类方法,并将其添加到装饰类中。type: ignore 注释是必需的,因为 Mypy 抱怨 type 没有 _fields 属性。
⑤
将成为装饰类的实例方法的模块级函数。
⑥
将每个 instance_methods 添加到 cls 中。
⑦
返回装饰后的 cls,实现类装饰器的基本约定。
checkeddeco.py 中的每个顶层函数都以下划线开头,除了 checked 装饰器。这种命名约定有几个原因是合理的:
-
checked是 checkeddeco.py 模块的公共接口的一部分,但其他函数不是。 -
示例 24-9 中的函数将被注入到装饰类中,而前导的
_减少了与装饰类的用户定义属性和方法的命名冲突的机会。
checkeddeco.py的其余部分列在示例 24-9 中。这些模块级函数与checkedlib.py的Checked类的相应方法具有相同的代码。它们在示例 24-5 和 24-6 中有解释。
请注意,_fields函数在checkeddeco.py中承担了双重职责。它在checked装饰器的第一行中用作常规函数,并且还将被注入为装饰类的类方法。
示例 24-9. checkeddeco.py:要注入到装饰类中的方法
def _fields(cls: type) -> dict[str, type]:
return get_type_hints(cls)
def __init__(self: Any, **kwargs: Any) -> None:
for name in self._fields():
value = kwargs.pop(name, ...)
setattr(self, name, value)
if kwargs:
self.__flag_unknown_attrs(*kwargs)
def __setattr__(self: Any, name: str, value: Any) -> None:
if name in self._fields():
cls = self.__class__
descriptor = getattr(cls, name)
descriptor.__set__(self, value)
else:
self.__flag_unknown_attrs(name)
def __flag_unknown_attrs(self: Any, *names: str) -> NoReturn:
plural = 's' if len(names) > 1 else ''
extra = ', '.join(f'{name!r}' for name in names)
cls_name = repr(self.__class__.__name__)
raise AttributeError(f'{cls_name} has no attribute{plural} {extra}')
def _asdict(self: Any) -> dict[str, Any]:
return {
name: getattr(self, name)
for name, attr in self.__class__.__dict__.items()
if isinstance(attr, Field)
}
def __repr__(self: Any) -> str:
kwargs = ', '.join(
f'{key}={value!r}' for key, value in self._asdict().items()
)
return f'{self.__class__.__name__}({kwargs})'
checkeddeco.py模块实现了一个简单但可用的类装饰器。Python 的@dataclass做了更多的事情。它支持许多配置选项,向装饰类添加更多方法,处理或警告有关与装饰类中的用户定义方法的冲突,并甚至遍历__mro__以收集在装饰类的超类中声明的用户定义属性。Python 3.9 中dataclasses包的源代码超过 1200 行。
对于元编程类,我们必须意识到 Python 解释器在构建类时何时评估代码块。接下来将介绍这一点。
当发生什么时:导入时间与运行时
Python 程序员谈论“导入时间”与“运行时”,但这些术语并没有严格定义,它们之间存在一个灰色地带。
在导入时,解释器:
-
从顶部到底部一次性解析一个.py模块的源代码。这是可能发生
SyntaxError的时候。 -
编译要执行的字节码。
-
执行编译模块的顶层代码。
如果本地__pycache__中有最新的.pyc文件可用,则解析和编译将被跳过,因为字节码已准备就绪。
尽管解析和编译明显是“导入时间”活动,但在那个时候可能会发生其他事情,因为 Python 中的几乎每个语句都是可执行的,它们可能运行用户代码并可能改变用户程序的状态。
特别是,import语句不仅仅是一个声明,¹¹,而且当模块在进程中首次导入时,它实际上运行模块的所有顶层代码。对同一模块的进一步导入将使用缓存,然后唯一的效果将是将导入的对象绑定到客户模块中的名称。该顶层代码可以执行任何操作,包括典型的“运行时”操作,例如写入日志或连接到数据库。¹²这就是为什么“导入时间”和“运行时”之间的边界模糊:import语句可以触发各种“运行时”行为。反过来,“导入时间”也可能发生在运行时的深处,因为import语句和__import__()内置可以在任何常规函数内使用。
这一切都相当抽象和微妙,所以让我们做一些实验,看看发生了什么。
评估时间实验
考虑一个evaldemo.py脚本,它使用了一个类装饰器、一个描述符和一个基于__init_subclass__的类构建器,所有这些都在builderlib.py模块中定义。这些模块有几个print调用来展示发生了什么。否则,它们不执行任何有用的操作。这些实验的目标是观察这些print调用发生的顺序。
警告
在单个类中同时应用类装饰器和__init_subclass__类构建器很可能是过度设计或绝望的迹象。这种不寻常的组合在这些实验中很有用,可以展示类装饰器和__init_subclass__对类应用的更改的时间。
让我们从builderlib.py开始,分为两部分:示例 24-10 和示例 24-11。
示例 24-10. builderlib.py:模块顶部
print('@ builderlib module start')
class Builder: # ①
print('@ Builder body')
def __init_subclass__(cls): # ②
print(f'@ Builder.__init_subclass__({cls!r})')
def inner_0(self): # ③
print(f'@ SuperA.__init_subclass__:inner_0({self!r})')
cls.method_a = inner_0
def __init__(self):
super().__init__()
print(f'@ Builder.__init__({self!r})')
def deco(cls): # ④
print(f'@ deco({cls!r})')
def inner_1(self): # ⑤
print(f'@ deco:inner_1({self!r})')
cls.method_b = inner_1
return cls # ⑥
①
这是一个类构建器,用于实现…
②
…一个__init_subclass__方法。
③
定义一个函数,将在下面的赋值中添加到子类中。
④
一个类装饰器。
⑤
要添加到装饰类的函数。
⑥
返回作为参数接收的类。
继续查看示例 24-11 中的builderlib.py…
示例 24-11. builderlib.py:模块底部
class Descriptor: # ①
print('@ Descriptor body')
def __init__(self): # ②
print(f'@ Descriptor.__init__({self!r})')
def __set_name__(self, owner, name): # ③
args = (self, owner, name)
print(f'@ Descriptor.__set_name__{args!r}')
def __set__(self, instance, value): # ④
args = (self, instance, value)
print(f'@ Descriptor.__set__{args!r}')
def __repr__(self):
return '<Descriptor instance>'
print('@ builderlib module end')
①
一个描述符类,用于演示当…
②
…创建一个描述符实例,当…
③
…__set_name__将在owner类构建期间被调用。
④
像其他方法一样,这个__set__除了显示其参数外什么也不做。
如果你在 Python 控制台中导入builderlib.py,你会得到以下内容:
>>> import builderlib
@ builderlib module start
@ Builder body
@ Descriptor body
@ builderlib module end
注意builderlib.py打印的行前缀为@。
现在让我们转向evaldemo.py,它将触发builderlib.py中的特殊方法(示例 24-12)。
示例 24-12. evaldemo.py:用于实验builderlib.py的脚本
#!/usr/bin/env python3
from builderlib import Builder, deco, Descriptor
print('# evaldemo module start')
@deco # ①
class Klass(Builder): # ②
print('# Klass body')
attr = Descriptor() # ③
def __init__(self):
super().__init__()
print(f'# Klass.__init__({self!r})')
def __repr__(self):
return '<Klass instance>'
def main(): # ④
obj = Klass()
obj.method_a()
obj.method_b()
obj.attr = 999
if __name__ == '__main__':
main()
print('# evaldemo module end')
①
应用一个装饰器。
②
子类化Builder以触发其__init_subclass__。
③
实例化描述符。
④
这只会在模块作为主程序运行时调用。
evaldemo.py中的print调用显示了#前缀。如果你再次打开控制台并导入evaldemo.py,示例 24-13 就是输出结果。
示例 24-13. evaldemo.py的控制台实验
>>> import evaldemo
@ builderlib module start # ①
@ Builder body @ Descriptor body @ builderlib module end # evaldemo module start # Klass body # ②
@ Descriptor.__init__(<Descriptor instance>) # ③
@ Descriptor.__set_name__(<Descriptor instance>,
<class 'evaldemo.Klass'>, 'attr') # ④
@ Builder.__init_subclass__(<class 'evaldemo.Klass'>) # ⑤
@ deco(<class 'evaldemo.Klass'>) # ⑥
# evaldemo module end
①
前四行是from builderlib import…的结果。如果你在之前的实验后没有关闭控制台,它们将不会出现,因为builderlib.py已经被加载。
②
这表明 Python 开始读取Klass的主体。此时,类对象还不存在。
③
描述符实例被创建并绑定到命名空间中的attr,Python 将把它传递给默认的类对象构造函数:type.__new__。
④
此时,Python 内置的type.__new__已经创建了Klass对象,并在每个提供该方法的描述符类的描述符实例上调用__set_name__,将Klass作为owner参数传递。
⑤
然后type.__new__在Klass的超类上调用__init_subclass__,将Klass作为唯一参数传递。
⑥
当type.__new__返回类对象时,Python 会应用装饰器。在这个例子中,deco返回的类会绑定到模块命名空间中的Klass。
type.__new__的实现是用 C 语言编写的。我刚描述的行为在 Python 的“数据模型”参考中的“创建类对象”部分有文档记录。
请注意,evaldemo.py的main()函数(示例 24-12)没有在控制台会话中执行(示例 24-13),因此没有创建Klass的实例。我们看到的所有操作都是由“import time”操作触发的:导入builderlib和定义Klass。
如果你将evaldemo.py作为脚本运行,你将看到与示例 24-13 相同的输出,但在最后之前会有额外的行。额外的行是运行main()(示例 24-14)的结果。
示例 24-14。作为程序运行evaldemo.py
$ ./evaldemo.py
[... 9 lines omitted ...]
@ deco(<class '__main__.Klass'>) # ①
@ Builder.__init__(<Klass instance>) # ②
# Klass.__init__(<Klass instance>)
@ SuperA.__init_subclass__:inner_0(<Klass instance>) # ③
@ deco:inner_1(<Klass instance>) # ④
@ Descriptor.__set__(<Descriptor instance>, <Klass instance>, 999) # ⑤
# evaldemo module end
①
前 10 行(包括这一行)与示例 24-13 中显示的相同。
②
在Klass.__init__中由super().__init__()触发。
③
在main中由obj.method_a()触发;method_a是由SuperA.__init_subclass__注入的。
④
在main中由obj.method_b()触发;method_b是由deco注入的。
⑤
在main中由obj.attr = 999触发。
具有__init_subclass__和类装饰器的基类是强大的工具,但它们仅限于使用type.__new__在内部构建的类。在需要调整传递给type.__new__的参数的罕见情况下,您需要一个元类。这是本章和本书的最终目的地。
元类 101
[元类]比 99%的用户应该担心的更深奥。如果你想知道是否需要它们,那就不需要(真正需要它们的人确信自己需要它们,并不需要解释为什么)。
Tim Peters,Timsort 算法的发明者和多产的 Python 贡献者¹³
元类是一个类工厂。与示例 24-2 中的record_factory相比,元类是作为一个类编写的。换句话说,元类是一个其实例是类的类。图 24-1 使用 Mills & Gizmos 符号表示了一个元类:一个生产另一个元类的工厂。

图 24-1。元类是一个构建类的类。
考虑 Python 对象模型:类是对象,因此每个类必须是另一个类的实例。默认情况下,Python 类是type的实例。换句话说,type是大多数内置和用户定义类的元类:
>>> str.__class__
<class 'type'>
>>> from bulkfood_v5 import LineItem
>>> LineItem.__class__
<class 'type'>
>>> type.__class__
<class 'type'>
为了避免无限递归,type的类是type,正如最后一行所示。
请注意,我并不是说str或LineItem是type的子类。我要说的是str和LineItem是type的实例。它们都是object的子类。图 24-2 可能会帮助您面对这个奇怪的现实。

图 24-2。两个图表都是正确的。左边的图表强调str、type和LineItem是object的子类。右边的图表清楚地表明str、object和LineItem是type的实例,因为它们都是类。
注意
类object和type有一个独特的关系:object是type的一个实例,而type是object的一个子类。这种关系是“魔法”的:它不能在 Python 中表达,因为任何一个类都必须在另一个类定义之前存在。type是其自身的实例的事实也是神奇的。
下一个片段显示collections.Iterable的类是abc.ABCMeta。请注意,Iterable是一个抽象类,但ABCMeta是一个具体类——毕竟,Iterable是ABCMeta的一个实例:
>>> from collections.abc import Iterable
>>> Iterable.__class__
<class 'abc.ABCMeta'>
>>> import abc
>>> from abc import ABCMeta
>>> ABCMeta.__class__
<class 'type'>
最终,ABCMeta的类也是type。 每个类都是type的实例,直接或间接,但只有元类也是type的子类。 这是理解元类最重要的关系:元类(例如ABCMeta)从type继承了构造类的能力。 图 24-3 说明了这种关键关系。

图 24-3。Iterable是object的子类,也是ABCMeta的实例。 object和ABCMeta都是type的实例,但这里的关键关系是ABCMeta也是type的子类,因为ABCMeta是一个元类。 在这个图表中,Iterable是唯一的抽象类。
这里的重要要点是元类是type的子类,这就是使它们作为类工厂运作的原因。 通过实现特殊方法,元类可以定制其实例,如下一节所示。
元类如何定制类
要使用元类,了解__new__如何在任何类上运行至关重要。 这在“使用 new 进行灵活的对象创建”中讨论过。
当元类即将创建一个新实例(即类)时,类似的机制发生在“元”级别。 考虑这个声明:
class Klass(SuperKlass, metaclass=MetaKlass):
x = 42
def __init__(self, y):
self.y = y
要处理该class语句,Python 使用这些参数调用MetaKlass.__new__:
meta_cls
元类本身(MetaKlass),因为__new__作为类方法运行。
cls_name
字符串Klass。
bases
单元素元组(SuperKlass,),在多重继承的情况下有更多元素。
cls_dict
一个类似于:
{x: 42, `__init__`: <function __init__ at 0x1009c4040>}
当您实现MetaKlass.__new__时,您可以检查并更改这些参数,然后将它们传递给super().__new__,后者最终将调用type.__new__来创建新的类对象。
在super().__new__返回后,您还可以对新创建的类进行进一步处理,然后将其返回给 Python。 然后,Python 调用SuperKlass.__init_subclass__,传递您创建的类,然后对其应用类装饰器(如果存在)。 最后,Python 将类对象绑定到其名称在周围的命名空间中 - 通常是模块的全局命名空间,如果class语句是顶级语句。
元类__new__中最常见的处理是向cls_dict中添加或替换项目 - 代表正在构建的类的命名空间的映射。 例如,在调用super().__new__之前,您可以通过向cls_dict添加函数来向正在构建的类中注入方法。 但是,请注意,添加方法也可以在构建类之后完成,这就是为什么我们能够使用__init_subclass__或类装饰器来完成的原因。
在type.__new__运行之前,您必须向cls_dict添加的一个属性是__slots__,如“为什么 init_subclass 无法配置 slots”中讨论的那样。 元类的__new__方法是配置__slots__的理想位置。 下一节将展示如何做到这一点。
一个很好的元类示例
这里介绍的MetaBunch元类是Python in a Nutshell,第 3 版第四章中最后一个示例的变体,作者是 Alex Martelli,Anna Ravenscroft 和 Steve Holden,编写以在 Python 2.7 和 3.5 上运行。 假设是 Python 3.6 或更高版本,我能够进一步简化代码。
首先,让我们看看Bunch基类提供了什么:
>>> class Point(Bunch):
... x = 0.0
... y = 0.0
... color = 'gray'
...
>>> Point(x=1.2, y=3, color='green')
Point(x=1.2, y=3, color='green')
>>> p = Point()
>>> p.x, p.y, p.color
(0.0, 0.0, 'gray')
>>> p
Point()
请记住,Checked根据类变量类型提示为子类中的Field描述符分配名称,这些描述符实际上不会成为类的属性,因为它们没有值。
另一方面,Bunch 的子类使用具有值的实际类属性,然后这些值成为实例属性的默认值。生成的 __repr__ 省略了等于默认值的属性的参数。
MetaBunch — Bunch 的元类 — 从用户类中声明的类属性生成新类的 __slots__。这阻止了未声明属性的实例化和后续赋值:
>>> Point(x=1, y=2, z=3)
Traceback (most recent call last):
...
AttributeError: No slots left for: 'z'
>>> p = Point(x=21)
>>> p.y = 42
>>> p
Point(x=21, y=42)
>>> p.flavor = 'banana'
Traceback (most recent call last):
...
AttributeError: 'Point' object has no attribute 'flavor'
现在让我们深入研究 示例 24-15 中 MetaBunch 的优雅代码。
示例 24-15. metabunch/from3.6/bunch.py:MetaBunch 元类和 Bunch 类
class MetaBunch(type): # ①
def __new__(meta_cls, cls_name, bases, cls_dict): # ②
defaults = {} # ③
def __init__(self, **kwargs): # ④
for name, default in defaults.items(): # ⑤
setattr(self, name, kwargs.pop(name, default))
if kwargs: # ⑥
extra = ', '.join(kwargs)
raise AttributeError(f'No slots left for: {extra!r}')
def __repr__(self): # ⑦
rep = ', '.join(f'{name}={value!r}'
for name, default in defaults.items()
if (value := getattr(self, name)) != default)
return f'{cls_name}({rep})'
new_dict = dict(__slots__=[], __init__=__init__, __repr__=__repr__) # ⑧
for name, value in cls_dict.items(): # ⑨
if name.startswith('__') and name.endswith('__'): # ⑩
if name in new_dict:
raise AttributeError(f"Can't set {name!r} in {cls_name!r}")
new_dict[name] = value
else: ⑪
new_dict['__slots__'].append(name)
defaults[name] = value
return super().__new__(meta_cls, cls_name, bases, new_dict) ⑫
class Bunch(metaclass=MetaBunch): ⑬
pass
①
要创建一个新的元类,继承自 type。
②
__new__ 作为一个类方法工作,但类是一个元类,所以我喜欢将第一个参数命名为 meta_cls(mcs 是一个常见的替代方案)。其余三个参数与直接调用 type() 创建类的三参数签名相同。
③
defaults 将保存属性名称和它们的默认值的映射。
④
这将被注入到新类中。
⑤
读取 defaults 并使用从 kwargs 弹出的值或默认值设置相应的实例属性。
⑥
如果 kwargs 中仍有任何项,这意味着没有剩余的插槽可以放置它们。我们认为快速失败是最佳实践,因此我们不希望悄悄地忽略额外的项。一个快速有效的解决方案是从 kwargs 中弹出一项并尝试在实例上设置它,故意触发 AttributeError。
⑦
__repr__ 返回一个看起来像构造函数调用的字符串 — 例如,Point(x=3),省略了具有默认值的关键字参数。
⑧
初始化新类的命名空间。
⑨
遍历用户类的命名空间。
⑩
如果找到双下划线 name,则将项目复制到新类命名空间,除非它已经存在。这可以防止用户覆盖由 Python 设置的 __init__、__repr__ 和其他属性,如 __qualname__ 和 __module__。
⑪
如果不是双下划线 name,则追加到 __slots__ 并将其 value 保存在 defaults 中。
⑫
构建并返回新类。
⑬
提供一个基类,这样用户就不需要看到 MetaBunch。
MetaBunch 起作用是因为它能够在调用 super().__new__ 之前配置 __slots__ 以构建最终类。通常在元编程时,理解操作的顺序至关重要。让我们进行另一个评估时间实验,这次使用元类。
元类评估时间实验
这是 “评估时间实验” 的一个变体,加入了一个元类。builderlib.py 模块与之前相同,但主脚本现在是 evaldemo_meta.py,列在 示例 24-16 中。
示例 24-16. evaldemo_meta.py:尝试使用元类进行实验
#!/usr/bin/env python3
from builderlib import Builder, deco, Descriptor
from metalib import MetaKlass # ①
print('# evaldemo_meta module start')
@deco
class Klass(Builder, metaclass=MetaKlass): # ②
print('# Klass body')
attr = Descriptor()
def __init__(self):
super().__init__()
print(f'# Klass.__init__({self!r})')
def __repr__(self):
return '<Klass instance>'
def main():
obj = Klass()
obj.method_a()
obj.method_b()
obj.method_c() # ③
obj.attr = 999
if __name__ == '__main__':
main()
print('# evaldemo_meta module end')
①
从 metalib.py 导入 MetaKlass,我们将在 示例 24-18 中看到。
②
将 Klass 声明为 Builder 的子类和 MetaKlass 的实例。
③
此方法是由 MetaKlass.__new__ 注入的,我们将会看到。
警告
为了科学研究,示例 24-16 违背一切理性,将三种不同的元编程技术应用于 Klass:一个装饰器,一个使用 __init_subclass__ 的基类,以及一个自定义元类。如果你在生产代码中这样做,请不要责怪我。再次强调,目标是观察这三种技术干扰类构建过程的顺序。
与之前的评估时间实验一样,这个例子除了打印显示执行流程的消息外什么也不做。示例 24-17 展示了 metalib.py 顶部部分的代码—其余部分在 示例 24-18 中。
示例 24-17. metalib.py:NosyDict 类
print('% metalib module start')
import collections
class NosyDict(collections.UserDict):
def __setitem__(self, key, value):
args = (self, key, value)
print(f'% NosyDict.__setitem__{args!r}')
super().__setitem__(key, value)
def __repr__(self):
return '<NosyDict instance>'
我编写了 NosyDict 类来重写 __setitem__ 以显示每个 key 和 value 在设置时的情况。元类将使用一个 NosyDict 实例来保存正在构建的类的命名空间,揭示 Python 更多的内部工作原理。
metalib.py 的主要吸引力在于 示例 24-18 中的元类。它实现了 __prepare__ 特殊方法,这是 Python 仅在元类上调用的类方法。__prepare__ 方法提供了影响创建新类过程的最早机会。
提示
在编写元类时,我发现采用这种特殊方法参数的命名约定很有用:
-
对于实例方法,使用
cls而不是self,因为实例是一个类。 -
对于类方法,使用
meta_cls而不是cls,因为类是一个元类。请记住,__new__表现为类方法,即使没有@classmethod装饰器。
示例 24-18. metalib.py:MetaKlass
class MetaKlass(type):
print('% MetaKlass body')
@classmethod # ①
def __prepare__(meta_cls, cls_name, bases): # ②
args = (meta_cls, cls_name, bases)
print(f'% MetaKlass.__prepare__{args!r}')
return NosyDict() # ③
def __new__(meta_cls, cls_name, bases, cls_dict): # ④
args = (meta_cls, cls_name, bases, cls_dict)
print(f'% MetaKlass.__new__{args!r}')
def inner_2(self):
print(f'% MetaKlass.__new__:inner_2({self!r})')
cls = super().__new__(meta_cls, cls_name, bases, cls_dict.data) # ⑤
cls.method_c = inner_2 # ⑥
return cls # ⑦
def __repr__(cls): # ⑧
cls_name = cls.__name__
return f"<class {cls_name!r} built by MetaKlass>"
print('% metalib module end')
①
__prepare__ 应该声明为类方法。它不是实例方法,因为在 Python 调用 __prepare__ 时正在构建的类还不存在。
②
Python 调用元类的 __prepare__ 来获取一个映射,用于保存正在构建的类的命名空间。
③
返回 NosyDict 实例以用作命名空间。
④
cls_dict 是由 __prepare__ 返回的 NosyDict 实例。
⑤
type.__new__ 要求最后一个参数是一个真实的 dict,所以我给了它从 UserDict 继承的 NosyDict 的 data 属性。
⑥
在新创建的类中注入一个方法。
⑦
像往常一样,__new__ 必须返回刚刚创建的对象—在这种情况下是新类。
⑧
在元类上定义 __repr__ 允许自定义类对象的 repr()。
在 Python 3.6 之前,__prepare__ 的主要用途是提供一个 OrderedDict 来保存正在构建的类的属性,以便元类 __new__ 可以按照用户类定义源代码中的顺序处理这些属性。现在 dict 保留插入顺序,__prepare__ 很少被需要。你将在 “使用 prepare 进行元类黑客” 中看到它的创造性用法。
在 Python 控制台中导入 metalib.py 并不是很令人兴奋。请注意使用 % 作为此模块输出的行的前缀:
>>> import metalib
% metalib module start
% MetaKlass body
% metalib module end
如果导入 evaldemo_meta.py,会发生很多事情,正如你在 示例 24-19 中所看到的。
示例 24-19. 使用 evaldemo_meta.py 的控制台实验
>>> import evaldemo_meta
@ builderlib module start @ Builder body @ Descriptor body @ builderlib module end % metalib module start % MetaKlass body % metalib module end # evaldemo_meta module start # ①
% MetaKlass.__prepare__(<class 'metalib.MetaKlass'>, 'Klass', # ②
(<class 'builderlib.Builder'>,)) % NosyDict.__setitem__(<NosyDict instance>, '__module__', 'evaldemo_meta') # ③
% NosyDict.__setitem__(<NosyDict instance>, '__qualname__', 'Klass') # Klass body @ Descriptor.__init__(<Descriptor instance>) # ④
% NosyDict.__setitem__(<NosyDict instance>, 'attr', <Descriptor instance>) # ⑤
% NosyDict.__setitem__(<NosyDict instance>, '__init__',
<function Klass.__init__ at …>) # ⑥
% NosyDict.__setitem__(<NosyDict instance>, '__repr__',
<function Klass.__repr__ at …>) % NosyDict.__setitem__(<NosyDict instance>, '__classcell__', <cell at …: empty>) % MetaKlass.__new__(<class 'metalib.MetaKlass'>, 'Klass',
(<class 'builderlib.Builder'>,), <NosyDict instance>) # ⑦
@ Descriptor.__set_name__(<Descriptor instance>,
<class 'Klass' built by MetaKlass>, 'attr') # ⑧
@ Builder.__init_subclass__(<class 'Klass' built by MetaKlass>) @ deco(<class 'Klass' built by MetaKlass>) # evaldemo_meta module end
①
在此之前的行是导入 builderlib.py 和 metalib.py 的结果。
②
Python 调用 __prepare__ 来开始处理 class 语句。
③
在解析类体之前,Python 将__module__和__qualname__条目添加到正在构建的类的命名空间中。
④
创建描述符实例…
⑤
…并绑定到类命名空间中的attr。
⑥
__init__和__repr__方法被定义并添加到命名空间中。
⑦
Python 完成处理类体后,调用MetaKlass.__new__。
⑧
__set_name__、__init_subclass__和装饰器按照这个顺序被调用,在元类的__new__方法返回新构造的类之后。
如果将evaldemo_meta.py作为脚本运行,将调用main(),并会发生一些其他事情(示例 24-20)。
示例 24-20。将evaldemo_meta.py作为程序运行
$ ./evaldemo_meta.py
[... 20 lines omitted ...]
@ deco(<class 'Klass' built by MetaKlass>) # ①
@ Builder.__init__(<Klass instance>)
# Klass.__init__(<Klass instance>)
@ SuperA.__init_subclass__:inner_0(<Klass instance>)
@ deco:inner_1(<Klass instance>)
% MetaKlass.__new__:inner_2(<Klass instance>) # ②
@ Descriptor.__set__(<Descriptor instance>, <Klass instance>, 999)
# evaldemo_meta module end
①
前 21 行,包括这一行,与示例 24-19 中显示的相同。
②
由main中的obj.method_c()触发;method_c是由MetaKlass.__new__注入的。
现在让我们回到Checked类的概念,其中Field描述符实现了运行时类型验证,并看看如何使用元类来实现。
用于 Checked 的元类解决方案
我不想鼓励过早优化和过度设计,所以这里有一个虚构的场景来证明使用__slots__重写checkedlib.py的合理性,这需要应用元类。随意跳过。
我们接下来将研究的metaclass/checkedlib.py模块是initsub/checkedlib.py的一个可替换项。它们中嵌入的 doctests 是相同的,以及用于 pytest 的checkedlib_test.py 文件。
checkedlib.py中的复杂性对用户进行了抽象。这里是使用该包的脚本的源代码:
from checkedlib import Checked
class Movie(Checked):
title: str
year: int
box_office: float
if __name__ == '__main__':
movie = Movie(title='The Godfather', year=1972, box_office=137)
print(movie)
print(movie.title)
这个简洁的Movie类定义利用了三个Field验证描述符的实例,一个__slots__配置,从Checked继承的五个方法,以及一个元类将它们全部整合在一起。checkedlib的唯一可见部分是Checked基类。
考虑图 24-4。 Mills & Gizmos Notation 通过使类和实例之间的关系更加可见来补充 UML 类图。
例如,使用新的checkedlib.py的Movie类是CheckedMeta的一个实例,并且是Checked的一个子类。此外,Movie的title、year和box_office类属性是Field的三个单独实例。每个Movie实例都有自己的_title、_year和_box_office属性,用于存储相应字段的值。
现在让我们从Field类开始研究代码,如示例 24-21 所示。
Field描述符类现在有点不同。在先前的示例中,每个Field描述符实例将其值存储在具有相同名称的属性中。例如,在Movie类中,title描述符将字段值存储在托管实例中的title属性中。这使得Field不需要提供__get__方法。
然而,当类像Movie一样使用__slots__时,不能同时拥有相同名称的类属性和实例属性。每个描述符实例都是一个类属性,现在我们需要单独的每个实例存储属性。代码使用带有单个_前缀的描述符名称。因此,Field实例有单独的name和storage_name属性,并且我们实现Field.__get__。

图 24-4。带有 MGN 注释的 UML 类图:CheckedMeta元工厂构建Movie工厂。Field工厂构建title、year和box_office描述符,它们是Movie的类属性。字段的每个实例数据存储在Movie的_title、_year和_box_office实例属性中。请注意checkedlib的包边界。Movie的开发者不需要理解checkedlib.py内部的所有机制。
示例 24-21 显示了带有storage_name和__get__的Field描述符的源代码。
示例 24-21。元类/checkedlib.py:带有storage_name和__get__的Field描述符
class Field:
def __init__(self, name: str, constructor: Callable) -> None:
if not callable(constructor) or constructor is type(None):
raise TypeError(f'{name!r} type hint must be callable')
self.name = name
self.storage_name = '_' + name # ①
self.constructor = constructor
def __get__(self, instance, owner=None):
if instance is None: # ②
return self
return getattr(instance, self.storage_name) # ③
def __set__(self, instance: Any, value: Any) -> None:
if value is ...:
value = self.constructor()
else:
try:
value = self.constructor(value)
except (TypeError, ValueError) as e:
type_name = self.constructor.__name__
msg = f'{value!r} is not compatible with {self.name}:{type_name}'
raise TypeError(msg) from e
setattr(instance, self.storage_name, value) # ④
①
从name参数计算storage_name。
②
如果__get__的instance参数为None,则描述符是从托管类本身而不是托管实例中读取的。因此我们返回描述符。
③
否则,返回存储在名为storage_name的属性中的值。
④
__set__现在使用setattr来设置或更新托管属性。
示例 24-22 显示了驱动此示例的元类的代码。
示例 24-22。元类/checkedlib.py:CheckedMeta元类
class CheckedMeta(type):
def __new__(meta_cls, cls_name, bases, cls_dict): # ①
if '__slots__' not in cls_dict: # ②
slots = []
type_hints = cls_dict.get('__annotations__', {}) # ③
for name, constructor in type_hints.items(): # ④
field = Field(name, constructor) # ⑤
cls_dict[name] = field # ⑥
slots.append(field.storage_name) # ⑦
cls_dict['__slots__'] = slots # ⑧
return super().__new__(
meta_cls, cls_name, bases, cls_dict) # ⑨
①
__new__是CheckedMeta中唯一实现的方法。
②
仅在cls_dict不包含__slots__时增强类。如果__slots__已经存在,则假定它是Checked基类,而不是用户定义的子类,并按原样构建类。
③
为了获取之前示例中的类型提示,我们使用typing.get_type_hints,但这需要一个现有的类作为第一个参数。此时,我们正在配置的类尚不存在,因此我们需要直接从cls_dict(Python 作为元类__new__的最后一个参数传递的正在构建的类的命名空间)中检索__annotations__。
④
迭代type_hints以…
⑤
…为每个注释属性构建一个Field…
⑥
…用Field实例覆盖cls_dict中的相应条目…
⑦
…并将字段的storage_name追加到我们将用于的列表中…
⑧
…填充cls_dict中的__slots__条目——正在构建的类的命名空间。
⑨
最后,我们调用super().__new__。
metaclass/checkedlib.py的最后部分是Checked基类,这个库的用户将从中派生类来增强他们的类,如Movie。
这个版本的Checked的代码与initsub/checkedlib.py中的Checked相同(在示例 24-5 和示例 24-6 中列出),有三个变化:
-
添加一个空的
__slots__,以向CheckedMeta.__new__表明这个类不需要特殊处理。 -
移除
__init_subclass__。它的工作现在由CheckedMeta.__new__完成。 -
移除
__setattr__。它变得多余,因为向用户定义的类添加__slots__可以防止设置未声明的属性。
示例 24-23 是Checked的最终版本的完整列表。
示例 24-23。元类/checkedlib.py:Checked基类
class Checked(metaclass=CheckedMeta):
__slots__ = () # skip CheckedMeta.__new__ processing
@classmethod
def _fields(cls) -> dict[str, type]:
return get_type_hints(cls)
def __init__(self, **kwargs: Any) -> None:
for name in self._fields():
value = kwargs.pop(name, ...)
setattr(self, name, value)
if kwargs:
self.__flag_unknown_attrs(*kwargs)
def __flag_unknown_attrs(self, *names: str) -> NoReturn:
plural = 's' if len(names) > 1 else ''
extra = ', '.join(f'{name!r}' for name in names)
cls_name = repr(self.__class__.__name__)
raise AttributeError(f'{cls_name} object has no attribute{plural} {extra}')
def _asdict(self) -> dict[str, Any]:
return {
name: getattr(self, name)
for name, attr in self.__class__.__dict__.items()
if isinstance(attr, Field)
}
def __repr__(self) -> str:
kwargs = ', '.join(
f'{key}={value!r}' for key, value in self._asdict().items()
)
return f'{self.__class__.__name__}({kwargs})'
这结束了一个带有验证描述符的类构建器的第三次渲染。
下一节涵盖了与元类相关的一些一般问题。
真实世界中的元类
元类很强大,但也很棘手。在决定实现元类之前,请考虑以下几点。
现代特性简化或替代元类
随着时间的推移,几种常见的元类用法被新的语言特性所取代:
类装饰器
比元类更容易理解,更不太可能与基类和元类发生冲突。
__set_name__
避免需要自定义元类逻辑来自动设置描述符的名称。¹⁵
__init_subclass__
提供了一种透明对终端用户进行自定义类创建的方式,甚至比装饰器更简单——但可能会在复杂的类层次结构中引入冲突。
内置dict保留键插入顺序
消除了使用__prepare__的#1 原因:提供一个OrderedDict来存储正在构建的类的命名空间。Python 只在元类上调用__prepare__,因此如果您需要按照源代码中出现的顺序处理类命名空间,则必须在 Python 3.6 之前使用元类。
截至 2021 年,CPython 的每个活跃维护版本都支持刚才列出的所有功能。
我一直在倡导这些特性,因为我看到我们行业中有太多不必要的复杂性,而元类是复杂性的入口。
元类是稳定的语言特性
元类是在 2002 年与所谓的“新式类”、描述符和属性一起在 Python 2.2 中引入的。
令人惊讶的是,Alex Martelli 于 2002 年 7 月首次发布的MetaBunch示例在 Python 3.9 中仍然有效——唯一的变化是在 Python 3 中指定要使用的元类的方式,即使用语法class Bunch(metaclass=MetaBunch):。
我提到的“现代特性简化或替代元类”中的任何添加都不会破坏使用元类的现有代码。但是,使用元类的遗留代码通常可以通过利用这些特性来简化,尤其是如果可以放弃对不再维护的 Python 版本(3.6 之前的版本)的支持。
一个类只能有一个元类
如果您的类声明涉及两个或更多个元类,您将看到这个令人困惑的错误消息:
TypeError: metaclass conflict: the metaclass of a derived class
must be a (non-strict) subclass of the metaclasses of all its bases
这可能发生在没有多重继承的情况下。例如,像这样的声明可能触发TypeError:
class Record(abc.ABC, metaclass=PersistentMeta):
pass
我们看到abc.ABC是abc.ABCMeta元类的一个实例。如果Persistent元类本身不是abc.ABCMeta的子类,则会出现元类冲突。
处理该错误有两种方法:
-
找到其他方法来做你需要做的事情,同时避免涉及到的元类之一。
-
编写自己的
PersistentABCMeta元类,作为abc.ABCMeta和PersistentMeta的子类,使用多重继承,并将其作为Record的唯一元类。¹⁶
提示
我可以想象实现满足截止日期的两个基本元类的元类的解决方案。根据我的经验,元类编程总是比预期时间长,这使得在严格的截止日期之前采用这种方法是有风险的。如果您这样做并且达到了截止日期,代码可能会包含微妙的错误。即使没有已知的错误,您也应该将这种方法视为技术债务,因为它很难理解和维护。
元类应该是实现细节
除了type,整个 Python 3.9 标准库中只有六个元类。较为知名的元类可能是abc.ABCMeta、typing.NamedTupleMeta和enum.EnumMeta。它们中没有一个旨在明确出现在用户代码中。我们可能将它们视为实现细节。
尽管您可以使用元类进行一些非常古怪的元编程,但最好遵循最少惊讶原则,以便大多数用户确实将元类视为实现细节。¹⁷
近年来,Python 标准库中的一些元类已被其他机制替换,而不会破坏其包的公共 API。未来保护这类 API 的最简单方法是提供一个常规类,供用户子类化以访问元类提供的功能,就像我们在示例中所做的那样。
为了总结我们对类元编程的覆盖范围,我将与您分享我在研究本章时发现的最酷、最小的元类示例。
使用 prepare 的元类技巧
当我为第二版更新这一章节时,我需要找到简单但具有启发性的示例来替换自 Python 3.6 以来不再需要元类的bulkfood LineItem代码。
最简单且最有趣的元类概念是由巴西 Python 社区中更为人熟知的 João S. O. Bueno(简称 JS)给我的。他的想法之一是创建一个自动生成数值常量的类:
>>> class Flavor(AutoConst):
... banana
... coconut
... vanilla
...
>>> Flavor.vanilla
2
>>> Flavor.banana, Flavor.coconut
(0, 1)
是的,代码如图所示是有效的!实际上,这是autoconst_demo.py中的一个 doctest。
这里是用户友好的AutoConst基类和其背后的元类,实现在autoconst.py中:
class AutoConstMeta(type):
def __prepare__(name, bases, **kwargs):
return WilyDict()
class AutoConst(metaclass=AutoConstMeta):
pass
就是这样。
显然,技巧在于WilyDict。
当 Python 处理用户类的命名空间并读取banana时,它在__prepare__提供的映射中查找该名称:一个WilyDict的实例。WilyDict实现了__missing__,在“missing 方法”中有介绍。WilyDict实例最初没有'banana'键,因此触发了__missing__方法。它会即时创建一个具有键'banana'和值0的项目,并返回该值。Python 对此很满意,然后尝试检索'coconut'。WilyDict立即添加该条目,值为1,并返回它。同样的情况也发生在'vanilla',然后映射到2。
我们之前已经看到了__prepare__和__missing__。真正的创新在于 JS 如何将它们结合在一起。
这里是WilyDict的源代码,也来自autoconst.py:
class WilyDict(dict):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.__next_value = 0
def __missing__(self, key):
if key.startswith('__') and key.endswith('__'):
raise KeyError(key)
self[key] = value = self.__next_value
self.__next_value += 1
return value
在实验过程中,我发现 Python 在正在构建的类的命名空间中查找__name__,导致WilyDict添加了一个__name__条目,并增加了__next_value。因此,我在__missing__中添加了那个if语句,以便为看起来像 dunder 属性的键引发KeyError。
autoconst.py包既需要又展示了对 Python 动态类构建机制的掌握。
我很高兴为AutoConstMeta和AutoConst添加更多功能,但是我不会分享我的实验,而是让您享受 JS 的巧妙技巧。
以下是一些想法:
-
如果你有值,可以检索常量名称。例如,
Flavor[2]可以返回'vanilla'。您可以通过在AutoConstMeta中实现__getitem__来实现这一点。自 Python 3.9 起,您可以在AutoConst本身中实现__class_getitem__。 -
支持对类进行迭代,通过在元类上实现
__iter__。我会让__iter__产生常量作为(name, value)对。 -
实现一个新的
Enum变体。这将是一项重大工作,因为enum包中充满了技巧,包括具有数百行代码和非平凡__prepare__方法的EnumMeta元类。
尽情享受!
注意
__class_getitem__特殊方法是在 Python 3.9 中添加的,以支持通用类型,作为PEP 585—标准集合中的类型提示通用的一部分。由于__class_getitem__,Python 的核心开发人员不必为内置类型编写新的元类来实现__getitem__,以便我们可以编写像list[int]这样的通用类型提示。这是一个狭窄的功能,但代表了元类的更广泛用例:实现运算符和其他特殊方法以在类级别工作,例如使类本身可迭代,就像Enum子类一样。
总结
元类、类装饰器以及__init_subclass__对以下方面很有用:
在某些情况下,类元编程也可以帮助解决性能问题,通过在导入时执行通常在运行时重复执行的任务。
最后,让我们回顾一下亚历克斯·马特利在他的文章“水禽和 ABC”中的最终建议:
而且,不要在生产代码中定义自定义 ABCs(或元类)…如果你有这种冲动,我敢打赌这很可能是“所有问题看起来都像钉子”综合症的情况,对于刚刚得到闪亮新锤子的人来说,你(以及未来维护你代码的人)将更喜欢坚持简单直接的代码,避免深入这样的领域。
我相信马特利的建议不仅适用于 ABCs 和元类,还适用于类层次结构、运算符重载、函数装饰器、描述符、类装饰器以及使用__init_subclass__的类构建器。
这些强大的工具主要用于支持库和框架开发。应用程序自然应该使用这些工具,如 Python 标准库或外部包提供的那样。但在应用程序代码中实现它们通常是过早的抽象。
好的框架是被提取出来的,而不是被发明的。¹⁸
大卫·海涅迈尔·汉森,Ruby on Rails 的创始人
章节总结
本章以类对象中发现的属性概述开始,比如__qualname__和__subclasses__()方法。接下来,我们看到了type内置函数如何用于在运行时构建类。
引入了__init_subclass__特殊方法,设计了第一个旨在用Field实例替换用户定义子类中属性类型提示的Checked基类,这些实例应用构造函数以在运行时强制执行这些属性的类型。
通过一个@checked类装饰器实现了相同的想法,它为用户定义的类添加特性,类似于__init_subclass__允许的。我们看到,无论是__init_subclass__还是类装饰器都无法动态配置__slots__,因为它们只在类创建后操作。
“导入时间”和“运行时”概念通过实验证明了涉及模块、描述符、类装饰器和__init_subclass__时 Python 代码执行顺序的清晰。
我们对元类的覆盖始于对type作为元类的整体解释,以及用户定义的元类如何实现__new__以自定义它构建的类。然后我们看到了我们的第一个自定义元类,经典的MetaBunch示例使用__slots__。接下来,另一个评估时间实验展示了元类的__prepare__和__new__方法在__init_subclass__和类装饰器之前被调用,为更深层次的类定制提供了机会。
第三次迭代的Checked类构建器使用Field描述符和自定义__slots__配置,随后是关于实践中元类使用的一些一般考虑。
最后,我们看到了由乔昂·S·O·布恩诺发明的AutoConst黑客,基于一个具有__prepare__返回实现__missing__的映射的元类的狡猾想法。在不到 20 行的代码中,autoconst.py展示了结合 Python 元编程技术的强大力量。
我还没有找到一种语言能像 Python 一样,既适合初学者,又适合专业人士,又能激发黑客的兴趣。感谢 Guido van Rossum 和所有使之如此的人。
进一步阅读
Caleb Hattingh——本书的技术审阅者——编写了autoslot包,提供了一个元类,通过检查__init__的字节码并找到对self属性的所有赋值来自动创建一个__slots__属性在用户定义的类中。这非常有用,也是一个优秀的学习示例:autoslot.py中只有 74 行代码,包括 20 行注释解释最困难的部分。
本章在 Python 文档中的基本参考资料是Python 语言参考中“数据模型”章节中的“3.3.3. 自定义类创建”,涵盖了__init_subclass__和元类。在“内置函数”页面的type类文档,以及Python 标准库中“内置类型”章节的“4.13. 特殊属性”也是必读的。
在Python 标准库中,types模块文档涵盖了 Python 3.3 中添加的两个简化类元编程的函数:types.new_class和types.prepare_class。
类装饰器在PEP 3129—类装饰器中得到正式规范,由 Collin Winter 编写,参考实现由 Jack Diederich 编写。PyCon 2009 年的演讲“类装饰器:彻底简化”(视频),也是由 Jack Diederich 主持,是该功能的一个快速介绍。除了@dataclass之外,在 Python 标准库中一个有趣且简单得多的类装饰器示例是functools.total_ordering,它为对象比较生成特殊方法。
对于元类,在 Python 文档中的主要参考资料是PEP 3115—Python 3000 中的元类,其中引入了__prepare__特殊方法。
Python 速查手册,第 3 版,由 Alex Martelli、Anna Ravenscroft 和 Steve Holden 编写,权威性很高,但是在PEP 487—简化类创建发布之前编写。该书中的主要元类示例——MetaBunch——仍然有效,因为它不能用更简单的机制编写。Brett Slatkin 的Effective Python,第 2 版(Addison-Wesley)包含了几个关于类构建技术的最新示例,包括元类。
要了解 Python 中类元编程的起源,我推荐 Guido van Rossum 在 2003 年的论文“统一 Python 2.2 中的类型和类”。该文本也适用于现代 Python,因为它涵盖了当时称为“新式”类语义的内容——Python 3 中的默认语义,包括描述符和元类。Guido 引用的参考文献之一是Ira R. Forman 和 Scott H. Danforth的Putting Metaclasses to Work: a New Dimension in Object-Oriented Programming(Addison-Wesley),这本书在Amazon.com上获得了五星评价,他在评论中补充说:
这本书为 Python 2.2 中的元类设计做出了贡献
真遗憾这本书已经绝版;我一直认为这是我所知道的关于协同多重继承这一困难主题的最佳教程,通过 Python 的
super()函数支持。¹⁹
如果你对元编程感兴趣,你可能希望 Python 拥有终极的元编程特性:语法宏,就像 Lisp 系列语言以及最近的 Elixir 和 Rust 所提供的那样。语法宏比 C 语言中的原始代码替换宏更强大且更不容易出错。它们是特殊函数,可以在编译步骤之前使用自定义语法重写源代码为标准代码,使开发人员能够引入新的语言构造而不改变编译器。就像运算符重载一样,语法宏可能会被滥用。但只要社区理解并管理这些缺点,它们支持强大且用户友好的抽象,比如 DSL(领域特定语言)。2020 年 9 月,Python 核心开发者 Mark Shannon 发布了PEP 638—语法宏,提倡这一点。在最初发布一年后,PEP 638 仍处于草案阶段,没有关于它的讨论。显然,这不是 Python 核心开发者的首要任务。我希望看到 PEP 638 进一步讨论并最终获得批准。语法宏将允许 Python 社区在对核心语言进行永久更改之前尝试具有争议性的新功能,比如海象操作符(PEP 572)、模式匹配(PEP 634)以及评估类型提示的替代规则(PEP 563 和 649)。与此同时,你可以通过MacroPy包尝试语法宏的味道。
¹ 引自《编程风格的要素》第二版第二章“表达式”,第 10 页。
² 这并不意味着 PEP 487 打破了使用这些特性的代码。这只是意味着一些在 Python 3.6 之前使用类装饰器或元类的代码现在可以重构为使用普通类,从而简化并可能提高效率。
³ 感谢我的朋友 J. S. O. Bueno 对这个示例的贡献。
⁴ 我没有为参数添加类型提示,因为实际类型是Any。我添加了返回类型提示,否则 Mypy 将不会检查方法内部。
⁵ 对于任何对象来说都是如此,除非它的类重写了从object继承的__str__或__repr__方法并具有错误的实现。
⁶ 这个解决方案避免使用None作为默认值。避免空值是一个好主意。一般情况下很难避免,但在某些情况下很容易。在 Python 和 SQL 中,我更喜欢用空字符串代替None或NULL来表示缺失的数据。学习 Go 强化了这个想法:在 Go 中,原始类型的变量和结构字段默认初始化为“零值”。如果你感兴趣,可以查看在线 Go 之旅中的“零值”。
⁷ 我认为callable应该适用于类型提示。截至 2021 年 5 月 6 日,这是一个未解决的问题。
⁸ 如在“循环、哨兵和毒丸”中提到的,Ellipsis对象是一个方便且安全的哨兵值。它已经存在很长时间了,但最近人们发现它有更多的用途,正如我们在类型提示和 NumPy 中看到的。
⁹ 重写描述符的微妙概念在“重写描述符”中有解释。
¹⁰ 这个理由出现在PEP 557–数据类的摘要中,解释了为什么它被实现为一个类装饰器。
¹¹ 与 Java 中的import语句相比,后者只是一个声明,让编译器知道需要某些包。
¹² 我并不是说仅仅因为导入模块就打开数据库连接是一个好主意,只是指出这是可以做到的。
¹³ 发送给 comp.lang.python 的消息,主题:“c.l.p.中的尖刻”。这是 2002 年 12 月 23 日同一消息的另一部分,在前言中引用。那天 TimBot 受到启发。
¹⁴ 作者们很友好地允许我使用他们的例子。MetaBunch首次出现在 Martelli 于 2002 年 7 月 7 日在 comp.lang.python 组发布的消息中,主题是“一个不错的元类示例(回复:Python 中的结构)”,在讨论 Python 中类似记录的数据结构之后。Martelli 的原始代码适用于 Python 2.2,只需进行一次更改即可在 Python 3 中使用元类,您必须在类声明中使用 metaclass 关键字参数,例如,Bunch(metaclass=MetaBunch),而不是旧的约定,即添加一个__metaclass__类级属性。
¹⁵ 在《流畅的 Python》第一版中,更高级版本的LineItem类使用元类仅仅是为了设置属性的存储名称。请查看第一版代码库中bulkfood 的元类代码。
¹⁶ 如果您考虑到使用元类的多重继承的影响而感到头晕,那很好。我也会远离这个解决方案。
¹⁷ 在决定研究 Django 的模型字段是如何实现之前,我靠写 Django 代码谋生几年。直到那时我才了解描述符和元类。
¹⁸ 这句话被广泛引用。我在 DHH 的博客中发现了一个早期的直接引用帖子,发布于 2005 年。
¹⁹ 我买了一本二手书,发现它是一本非常具有挑战性的阅读。
²⁰ 请参见第 xvii 页。完整文本可在Berkeley.edu上找到。
²¹ 《机器之美:优雅与技术之心》 作者 David Gelernter(Basic Books)开篇讨论了工程作品中的优雅和美学,从桥梁到软件。后面的章节不是很出色,但开篇值得一读。
后记
Python 是一个成年人的语言。
Alan Runyan,Plone 联合创始人
Alan 的简洁定义表达了 Python 最好的特质之一:它不会干扰你,而是让你做你必须做的事情。这也意味着它不会给你工具来限制别人对你的代码和构建的对象所能做的事情。
30 岁时,Python 仍在不断增长。但当然,它并不完美。对我来说,最令人恼火的问题之一是标准库中对CamelCase、snake_case和joinedwords的不一致使用。但语言定义和标准库只是生态系统的一部分。用户和贡献者的社区是 Python 生态系统中最好的部分。
这是社区最好的一个例子:在第一版中写关于asyncio时,我感到沮丧,因为 API 有许多函数,其中几十个是协程,你必须用yield from调用协程—现在用await—但你不能对常规函数这样做。这在asyncio页面中有记录,但有时你必须读几段才能找出特定函数是否是协程。所以我给 python-tulip 发送了一封标题为“建议:在asyncio文档中突出显示协程”的消息。asyncio核心开发者 Victor Stinner;aiohttp的主要作者 Andrew Svetlov;Tornado 的首席开发人员 Ben Darnell;以及Twisted的发明者 Glyph Lefkowitz 加入了讨论。Darnell 提出了一个解决方案,Alexander Shorin 解释了如何在 Sphinx 中实现它,Stinner 添加了必要的配置和标记。在我提出问题不到 12 小时后,整个asyncio在线文档集都更新了今天你可以看到的coroutine标签。
那个故事并不发生在一个独家俱乐部。任何人都可以加入 python-tulip 列表,当我写这个提案时,我只发过几次帖子。这个故事说明了一个真正对新想法和新成员开放的社区。Guido van Rossum 过去常常出现在 python-tulip 中,并经常回答基本问题。
另一个开放性的例子:Python 软件基金会(PSF)一直致力于增加 Python 社区的多样性。一些令人鼓舞的结果已经出现。2013 年至 2014 年,PSF 董事会首次选举了女性董事:Jessica McKellar 和 Lynn Root。2015 年,Diana Clarke 在蒙特利尔主持了 PyCon 北美大会,大约三分之一的演讲者是女性。PyLadies 成为一个真正的全球运动,我为我们在巴西有这么多 PyLadies 分部感到自豪。
如果你是 Python 爱好者但还没有参与社区,我鼓励你这样做。寻找你所在地区的 PyLadies 或 Python 用户组(PUG)。如果没有,就创建一个。Python 无处不在,所以你不会孤单。如果可以的话,参加活动。也参加线上活动。在新冠疫情期间,我在线会议的“走廊轨道”中学到了很多东西。来参加 PythonBrasil 大会—多年来我们一直有国际演讲者。和其他 Python 爱好者一起交流不仅带来知识分享,还有真正的好处。比如真正的工作和真正的友谊。
我知道如果没有多年来在 Python 社区结识的许多朋友的帮助,我不可能写出这本书。
我的父亲,贾伊罗·拉马尔,曾经说过“Só erra quem trabalha”,葡萄牙语中的“只有工作的人会犯错”,这是一个避免被犯错的恐惧所束缚的好建议。在写这本书的过程中,我肯定犯了很多错误。审阅者、编辑和早期发布的读者发现了很多错误。在第一版早期发布的几个小时内,一位读者在书的勘误页面上报告了错别字。其他读者提供了更多报告,朋友们直接联系我提出建议和更正。O’Reilly 的编辑们在制作过程中会发现其他错误,一旦我停止写作就会开始。我对任何错误和次优的散文负责并致歉。
我很高兴完成这第二版,包括错误,我非常感谢在这个过程中帮助过我的每个人。
希望很快能在某个现场活动中见到你。如果看到我,请过来打个招呼!
进一步阅读
我将以关于“Pythonic”的参考资料结束本书——这本书试图解决的主要问题。
Brandon Rhodes 是一位出色的 Python 教师,他的演讲“Python 美学:美丽和我为什么选择 Python”非常出色,标题中使用了 Unicode U+00C6(LATIN CAPITAL LETTER AE)。另一位出色的教师 Raymond Hettinger 在 PyCon US 2013 上谈到了 Python 中的美:“将代码转化为美丽、惯用的 Python”。
伊恩·李在 Python-ideas 上发起的“风格指南的演变”主题值得一读。李是pep8包的维护者,用于检查 Python 源代码是否符合 PEP 8 规范。为了检查本书中的代码,我使用了flake8,它包含了pep8、pyflakes,以及 Ned Batchelder 的McCabe 复杂度插件。
除了 PEP 8,其他有影响力的风格指南还有Google Python 风格指南和Pocoo 风格指南,这两个团队为我们带来了 Flake、Sphinx、Jinja 2 等伟大的 Python 库。
Python 之旅者指南!是关于编写 Pythonic 代码的集体作品。其中最多产的贡献者是 Kenneth Reitz,由于他出色的 Pythonic requests 包,他成为了社区英雄。David Goodger 在 PyCon US 2008 上做了一个名为“像 Pythonista 一样编码:Python 的惯用法”的教程。如果打印出来,教程笔记有 30 页长。Goodger 创建了 reStructuredText 和 docutils——Sphinx 的基础,Python 出色的文档系统(顺便说一句,这也是 MongoDB 和许多其他项目的官方文档系统)。
马蒂恩·法森在“什么是 Pythonic?”中直面这个问题。在 python-list 中,有一个同名主题的讨论线程。马蒂恩的帖子是 2005 年的,而主题是 2003 年的,但 Pythonic 的理念并没有改变太多——语言本身也是如此。一个标题中带有“Pythonic”的很棒的主题是“Pythonic way to sum n-th list element?”,我在“Soapbox”中广泛引用了其中的内容。
PEP 3099 — Python 3000 中不会改变的事情解释了为什么许多事情仍然保持原样,即使 Python 3 进行了重大改革。很长一段时间,Python 3 被昵称为 Python 3000,但它提前了几个世纪到来——这让一些人感到沮丧。PEP 3099 是由 Georg Brandl 撰写的,汇编了许多由BDFL,Guido van Rossum 表达的观点。“Python Essays”页面列出了 Guido 本人撰写的几篇文章。


浙公网安备 33010602011771号