编写整洁的Python代码(第2版)

978-7-115-58811-1
作者: 马里亚诺·阿那亚(Mariano Anaya)
译者: 袁国忠
编辑: 吴晋瑜
分类: Python

图书目录:

详情

这是一本介绍如何实现Python代码整洁的书,主要介绍如何使用Python 3.9引入的新特性提升编码技能。此外,本书还介绍了以下内容:通过利用自动化工具建立高效的开发环境,利用Python中的魔法方法来编写更好的代码,抽象代码复杂性并封装细节,使用Python特有的特性创建高级的面向对象设计,通过使用面向对象设计的软件工程原则创建强大的抽象来消除代码重复,使用装饰器和描述符创建特定于Python的解决方案,运用单元测试方法有效重构代码,以及通过实现整洁的代码库为构建坚实的架构打下基础等。 本书对新手程序员和有经验的程序员同样适用,也适合想通过编写Python代码来节省成本和提高效率的团队领导、软件架构师和高级软件工程师参考。当然在阅读本书前,读者应掌握一些Python基础知识。

图书摘要

版权信息

书名:编写整洁的Python代码(第2版)

ISBN:978-7-115-58811-1

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

著    [西] 马里亚诺•阿那亚(Mariano Anaya)

译    袁国忠

责任编辑 吴晋瑜

人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

读者服务热线:(010)81055410

反盗版热线:(010)81055315

读者服务:

微信扫码关注【异步社区】微信公众号,回复“e58811”获取本书配套资源以及异步社区15天VIP会员卡,近千本电子书免费畅读。

Copyright ©Packt Publishing 2021. First published in the English language under the title Clean Code in Python, Second Edition (9781800560215). All rights reserved.

本书由英国Packt Publishing公司授权人民邮电出版社有限公司出版。未经出版者书面许可,对本书的任何部分不得以任何方式或任何手段复制和传播。

版权所有,侵权必究。


这是一本介绍如何实现Python代码整洁的书,主要介绍如何使用Python 3.9引入的新特性提升编码技能。此外,本书还介绍了以下内容:通过利用自动化工具建立高效的开发环境,利用Python中的魔法方法来编写更好的代码,抽象代码复杂性并封装细节,使用Python特有的特性创建高级的面向对象设计,通过使用面向对象设计的软件工程原则创建强大的抽象来消除代码重复,使用装饰器和描述符创建特定于Python的解决方案,运用单元测试方法有效重构代码,以及通过实现整洁的代码库为构建坚实的架构打下基础等。

本书对新手程序员和有经验的程序员同样适用,也适合想通过编写Python代码来节省成本和提高效率的团队领导、软件架构师和高级软件工程师参考。当然在阅读本书前,读者应掌握一些Python基础知识。


Mariano Anaya 专注于软件开发和指导同行的软件工程师;所涉及的主要领域包括软件架构、函数式编程和分布式系统;曾在2016年和2017年度欧洲Python大会及2019年度欧洲开源开发者会议(FOSDEM)上发表过演讲。更详细的信息请访问其GitHub账户(用户名为rmariano)。


Tarek Ziadé 经验丰富的Python开发人员;创立了法国Python用户组(AFPY),参与了Python打包功能的开发工作;使用法语(他的母语)和英语编著过Python图书;在Mozilla工作了十多年,主要从事工具和服务开发及使用Python开发大型项目的工作;当前为Elastic首席工程师。


本书适合对软件设计感兴趣或想更深入地学习Python的软件工程从业人员阅读,要求读者熟悉面向对象软件设计原则,并具备一定的代码编写经验。

无论你是团队负责人、软件架构师还是资深软件工程师,也不管你从事的工作是新项目开发还是遗留系统维护,只要想学习优良的Python编码技巧以节省成本、提高效率,本书都很有吸引力。

本书内容按从易到难的顺序编排。前几章介绍Python基础知识,为学习主要的Python习惯用法、函数和实用程序提供了绝佳途径。重点不是使用Python解决问题,而是以符合Python语言习惯的方式解决问题。

本书介绍了一些进阶主题,如装饰器、描述符和异步编程的介绍,可惠及经验丰富的程序员。有些案例从Python内部工作原理的角度进行了分析,可帮助读者更深入地了解Python。

本书多处专门探讨了如何从头到尾构建项目,涉及工具、环境配置和软件发布良好实践等方面,使用Python处理数据的科学家可从中受益。

需要强调的是,上面使用了“从业人员”一词,这昭示着本书奉行实用主义原则,示例以案例研究需求为限,同时兼顾真实软件项目的语境。本书并非学术著作,请谨慎对待其中的定义、评论和推荐,请以挑剔、务实的眼光看待,而非全盘接受。毕竟,实用比纯粹更重要。

第 1 章简要介绍搭建 Python 开发环境所需的主要工具,涵盖为卓有成效地使用Python必须具备的基础知识,提供一些确保项目代码易于阅读的指导原则,如用于静态分析、文档编写、类型检查和格式设置的工具。对编码标准有统一的认识是件好事,但从业人员往往心有余而力不足。有鉴于此,本章最后讨论了可助你更有成效地完成工作的工具。

第2章介绍后续章节将用到的一些重要Python习惯用法,探讨Python独有的特性及用法,并着手树立如下观念:符合Python语言习惯的代码通常质量高得多。

第3章探讨那些旨在让代码更容易维护的通用软件工程原则。在第2章的基础上,我们将介绍一般性整洁设计理念及如何在Python中实现它们。

第4章介绍一系列面向对象软件设计的原则。缩略语SOLID是软件工程领域的行业术语,指的是一系列面向对象软件设计的原则。通过阅读本章,你将知道这些原则也适用于Python。尤其重要的是,你将学习依赖注入如何让代码更易于维护,在后续章节中,这个概念很有用。

第5章介绍最出色的Python特性之一——装饰器。我们先介绍如何创建(用于函数和类的)装饰器,再将装饰器付诸应用——使用它们来重用代码、分离职责及创建粒度更细的函数。本章另一个有趣的知识点是,如何利用装饰器来简化复杂和重复的函数签名。

第6章探讨让面向对象设计更上一层楼的Python描述符。描述符主要与框架和工具相关,但也可用来提高代码的可读性和可重用性。通过阅读本章,读者将对Python有更深入的认识。

第7章首先说明生成器是一个极其出色的Python特性。迭代是Python的核心组成部分,这可能让你认为它开辟了一条通往新编程模型的道路。通过使用生成器和迭代器,可以用不同的思路编写程序。学习有关生成器的知识后,你将学习Python协程以及异步编程基础知识。最后,本章阐述了用于异步编程和异步迭代的新语法(和新的魔法方法)。

第8章讨论单元测试在确保代码库易于维护方面的重要性。我们将讨论在代码库演进和维护过程中不可或缺的重构,还有对重构来说至关重要的单元测试。所有这一切,都离不开合适工具(主要是模块unittest和pytest)的支持。最后,你将了解到,优良测试的秘诀不在于测试本身,而在于代码是可测试的。

第9章探讨如何在Python中实现常见的设计模式,但不从解决问题的角度出发,而是如何使用设计模式来给出更佳、更易于维护的问题解决方案。本章介绍了让有些设计模式不可见的Python独特之处,并从实用主义的角度出发实现了一些模式。最后,本章讨论了Python特有的设计模式。

第10章聚焦于“整洁代码是良好架构的基石”这一理念。在系统部署期间,第1章提及的所有细节以及后续章节探讨的所有内容都将发挥至关重要的作用。

本书要求读者具备一定的编程经验、熟悉Python基本语法并掌握结构化编程和面向对象设计等基本编程知识。

要测试代码,需要先下载并安装Python。要运行代码,请使用Python 3.9+,同时强烈建议你创建虚拟环境。你也可以在Docker镜像中测试代码。

为方便阅读,本书使用了一些特殊体例格式,具体如下。

1.黑体表示新术语或需要强调的内容。

2. 表示警告或重要说明。

3. 表示提示和小窍门。


本书由异步社区出品,社区(https://www.epubit.com)为你提供相关资源和后续服务。

本书为读者提供源代码。读者可在异步社区本书页面中单击,跳转到下载界面,按提示进行操作即可。注意:为保证购书读者的权益,该操作会给出相关提示,要求输入提取码进行验证。

您还可以扫码右侧二维码, 关注【异步社区】微信公众号,回复“e58811”直接获取,同时可以获得异步社区15天VIP会员卡,近千本电子书免费畅读。

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎读者将发现的问题反馈给我们,帮助我们提升图书的质量。

如果读者发现错误,请登录异步社区,搜索到本书页面,输入勘误信息,单击“提交”按钮即可。本书的作者和编辑会对读者提交的勘误进行审核,确认并接受后,将赠予读者异步社区的100积分(积分可用于在异步社区兑换优惠券、样书或奖品)。

扫描下方二维码,读者将会在异步社区微信服务号中看到本书信息及相关的服务提示。

我们的联系邮箱是contact@epubit.com.cn。

如果读者对本书有任何疑问或建议,请发送邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果读者有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们;有意出版图书的作者也可以到异步社区投稿(直接访问www.epubit.com/ contribute即可)。

如果读者所在学校、培训机构或企业想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果读者在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请将怀疑有侵权行为的链接通过邮件发给我们。这一举动是对作者权益的保护,也是我们持续为广大读者提供有价值的内容的动力之源。

“异步社区”是人民邮电出版社旗下IT专业图书社区,致力于出版精品IT图书和相关学习产品,为作译者提供优质出版服务。异步社区创办于2015年8月,提供大量精品IT图书和电子书,以及高品质技术文章和视频课程。更多详情请访问异步社区官网https://www.epubit.com。

“异步图书”是由异步社区编辑团队策划出版的精品IT专业图书的品牌,依托于人民邮电出版社近40年的计算机图书出版积累和专业编辑团队,相关图书在封面上印有异步图书的LOGO。异步图书的出版领域包括软件开发、大数据、人工智能、测试、前端、网络技术等。

异步社区

微信服务号


本章从整洁代码的含义入手,探索与整洁代码相关的第一个概念,主要目标是让你明白,在软件项目中,整洁代码不是锦上添花的奢侈品,而是必需品。没有高质量的代码,项目将不断累积技术债务,进而面临失败的风险(如果你没有听说过技术债务,也不用担心,本章后面会深入讨论)。

本章还将详细介绍代码格式设置、文档编写等概念。同样,这些需求或任务看似多余,却对确保代码库的可维护性和可操作性至关重要。

我们将分析遵循良好编码指南的重要性。考虑到确保代码遵循指导原则是个持续不断的过程,我们就会明白使用可简化工作的自动化工具大有裨益。有鉴于此,我们将讨论如何配置在项目构建过程中自动运行的工具。

本章的目标是让你了解如下方面:何谓整洁代码;整洁代码很重要的原因;为何给代码设置格式和编写文档至关重要;如何实现这个过程的自动化。阅读本章后,你将知道如何快速地组织新项目,向代码质量上乘的目标迈进。

通过阅读本章,你将学到如下知识。

整洁代码确实意味着比格式设置更重要的东西。

制定代码格式设置标准对确保软件项目易于维护至关重要。

如何使用Python提供的特性实现代码文档的自动生成。

如何配置工具以自动对代码进行静态验证。

我们首先要理解何谓整洁代码及其对软件工程项目的成功至关重要的原因。在1.1.1小节和1.1.2小节中,我们将了解保持良好的代码质量对于高效工作是多么重要。

然后,我们将讨论这些规则的例外情况,即在什么情况下,不为偿还所有技术债务而重构代码更合算。毕竟,不存在放之四海皆准的规则,总会有例外存在。这里的重点是,正确地理解破例的原因,并确定在哪些情况下需要破例,避免在应该改进的情况下却认为不需要改进。

对于整洁代码,不存在唯一或严格的定义,也不存在衡量代码整洁程度的正式标准,因此无法通过对代码库运行工具来判断代码的优劣和可维护性。诚然,你可以运行检查器、代码校验器(linter)、静态分析器等必不可少的工具,这些工具可提供极大的帮助,但光有它们还不够,代码整洁与否并非机器或脚本能够判断(到目前为止),但专业人士能够判断。

术语“编程语言”使用了几十年,以前大家认为,借助编程语言可将想法传达给机器,让它们能够运行程序。这种想法不完全对,准确地说,编程语言中的语言是开发人员用于彼此交流想法的途径。

这也是整洁代码的真谛所在,它有赖于其他工程师能够阅读并维护代码,因此只有专业人士才能对代码整洁与否做出判断。开发人员阅读代码的时间比实际编写代码的时间多得多。每当要修改代码或添加新特性时,都必须先阅读与之相关的所有代码。我们使用编程语言(Python)来相互沟通。

有鉴于此,本书没有给出整洁代码的定义。你需要通过阅读本书,去熟悉符合Python语言习惯的代码,明了优质代码和拙劣代码之间的差别,发现良好代码和良好架构的特征,进而自己对整洁代码做出定义。阅读本书后,你将能够对代码做出自己的判断和分析,并对整洁代码有更清晰的认识。你将知道何谓整洁代码及其意味着什么,而不关心整洁代码的定义。

为什么保持代码整洁如此重要?原因有很多,但大都旨在提高可维护性、减少技术债务、有效地配合敏捷开发及成功地管理项目。

这里首先探讨与敏捷开发和持续交付相关的理念。项目要以稳定且可预测的速度不断地成功交付特性,一个必要的条件是代码库良好且易于维护。

假设你正驾车前往某个目的地,并要在特定时间到达。你必须对到达时间进行估算,以便告知等待的人。如果车况良好、道路平坦,那么估算时间通常会八九不离十。但如果路况不佳,必须时不时停下来将石头移走或避开裂缝,或者每行驶几千米就必须停下来检查发动机,那么就很难确切地知道到达时间,甚至都不知道能不能到达。这个比喻明确易懂,这里的道路就相当于代码。要以稳定、恒定和可预测的速度向前推进项目,代码必须易于维护和理解,否则,每当产品管理人员要求新增特性时,都必须停下来重构代码并偿还技术债务。

技术债务指的是因妥协或糟糕的决策导致的软件问题。可从两个不同的角度看待技术债务。一是从现在看过去,即当前面临的问题是否是以前编写的代码不佳导致的;二是从现在看将来,即如果我们现在决定走捷径,而不投入时间来开发合适的解决方案,将给未来带来什么样的问题。

“债务”一词恰如其分。为什么说是债务呢?因为以后再修改代码比现在就修改更难,其中增加的成本就是债务的利息。背上技术债务意味着明天修改代码比今天更难、成本更高(增加的成本甚至是可度量出来的),而等到后天再修改,成本会更高,以此类推。

每当团队不能按时交付,而必须停下来去修复和重构代码时,都是在为欠下的技术债务付出代价。

有人甚至认为,如果团队拥有的代码库欠下了技术债务,那么他们采用的就不是敏捷软件开发方法,因为这与敏捷开发背道而驰。严格地说,确实如此。充斥着“坏味”的代码难以修改,因此在需求发生变化时,团队将无法快速做出响应,也就无法实现持续交付。

有关技术债务,最糟糕的一点是,它意味着存在长期而根本的问题。这种问题不会发出警告,而是沉默不语;它分散在项目的各个部分,但终将在某一天某个特定的时间暴露出来,让项目无法再进行下去。

在有些更可怕的情况下,说技术债务都有点轻描淡写了,因为问题要严重得多。在前面说到的场景中,技术债务只是让团队的未来之路更为艰难,但如果实际情况要危险得多呢?假设由于走捷径导致代码非常脆弱,一个这样的简单示例是,函数中可修改的默认参数导致内存泄漏(这将在本书后面介绍)。你可以部署这些代码,而它们将在相当长的一段时间内正常运行(条件是这种缺陷没有暴露),但这实际上是一个定时炸弹,不知道在哪一天,代码满足了特定的条件,就会导致应用程序在运行时出现问题。

显然,我们要避免这样的情形。使用自动化工具是一项不错的投资,虽然它们并不能捕获所有的问题。为弥补自动化工具的这种缺陷,必须配以详尽的代码审核和良好的自动化测试。

软件有多大的用途取决于它有多容易修改。我们开发软件是为了满足某种需求,如购买飞机票、在线购物、欣赏音乐等,但需求很少是固定不变的,这意味着最初编写软件的情况发生变化时,必须及时地更新软件。如果代码无法修改(我们知道现实情况是可以修改),那么代码将毫无用处。代码库要易于修改,整洁是一个必须满足的前提条件,因此代码整洁至关重要。

在1.1.2小节中,我们探讨了整洁代码库在确保软件项目成功方面扮演的重要角色。然而,本书是为软件开发从业人员编写的,因此务实的读者可能正确地指出,这回避了一个问题:“存在合理的例外情况吗?”

如果不允许读者对某些假设提出挑战,本书就算不上一本真正的实用指南。

实际上,在有些情况下,你可能考虑放宽整洁代码库给出的某些限制。下面列出了部分这样的情形,在这些情形下,你可能跳过某些质量检查。

黑客马拉松。

编写一次性使用的简单脚本。

编程竞赛。

编写概念证明代码。

开发原型(条件是它确实是原型,将被丢弃)。

在短暂的时间内维护将被摒弃的遗留项目(同样,条件是这一点是确定的)。

在这些情形下,你可根据常识处理。例如,如果项目将在几个月后退役,那么很可能不值得为偿还其所有技术债务而排除千难万险,相反,等待它被归档可能是更好的选择。

注意到前面列出的情形有一个共同之处,那就是它们都假定代码可以不按照高质量标准编写,这也是我们根本不会再回头去多看一眼的代码。这不符合我们最初给出的前提——编写整洁代码旨在确保它们易于维护;如果代码无须维护,那么我们就可以跳过在代码上维护高质量标准的工作。

别忘了,编写整洁代码旨在让项目易于维护。这意味着我们以后能够对代码进行修改,或者代码被移交给公司的其他团队,可以使移交(和未来的维护人员的工作)更轻松。这意味着,如果项目处于维护模式,但不会被摒弃,那么也值得投入精力去偿还其技术债务。这是因为在某个我们没有想到的时点,会出现需要修复的bug,而通过偿还技术债务,有助于最大限度地提高代码的可读性。

整洁代码只关乎代码的格式设置和结构化吗?答案是否定的。

存在一些编码标准,如PEP-8,规定了如何编写代码及设置其格式。在Python中,最著名的标准是PEP-8,这个文档提供了程序编写指南,涉及间距、命名约定、行长等方面。

然而,代码整洁远远不止编码标准、设置代码格式、校验工具和其他关于代码布局的检查。代码整洁的目标是实现高质量的软件,并打造健壮而易于维护的系统。代码片段或软件组件可能完全遵循了PEP-8(或其他指南),但依然不满足上述要求。

虽然设置代码格式不是我们的主要目标,但不注意代码结构也会带来一定的风险。有鉴于此,我们将首先分析糟糕的代码结构带来的问题,并探讨如何解决这些问题。然后,我们将介绍如何配置和使用工具,让Python项目能够自动检查最常见的问题。

总之,代码整洁与PEP-8或编码风格毫无关系,它关乎的不是代码的格式和结构,而是代码的可维护性和软件的质量。然而,正如我们将看到的,正确地设置代码的格式对高效工作至关重要。

要让开发的项目符合质量标准,遵循编码指南是基本的要求。本节将探索其中的原因。在1.4节中,我们将介绍如何使用工具来自动遵循编码指南。

在良好的代码布局特征中,我们首先想到的是一致性。我们希望代码的结构一致,以使代码易于阅读和理解。如果代码不正确或结构不一致,且每个团队成员各行其是,会导致最终的代码更难理解。这样的代码容易出错和误导人,且其中的bug和微妙之处容易被人忽略。

我们要避免上述情况,并希望一眼就能读懂和理解代码。

如果开发团队的所有成员达成了一致,都采用某种结构化代码的标准方式,那么编写出来的代码看起来将熟悉得多。这让你能够快速发现模式(稍后将更详细地介绍),而将这些模式牢记在心后,将更容易理解代码和发现错误。例如,在代码不对时,你将发现到它不符合你熟悉的模式,从而引起你的注意。你将进一步观察,进而发现其中的错误。

经典著作《代码大全》(Code Complete)指出,有一篇名为“Perceptions in Chess”(1973)的论文对此做了有趣的分析。在这篇论文中,通过实验确定了不同的人是如何理解或记忆棋局的。实验的参与者涵盖了各种水平的棋手(新手、中级棋手和高手)以及不同的棋局。实验结果表明,在棋局随机的情况下,新手和高手的表现没什么不同,因为这只是一个记忆练习,所有人的表现都在相同的水平。当棋局符合逻辑并可能在实际对弈中出现(即符合规律)时,象棋高手的表现远胜于其他人。

这也适用于软件领域。作为Python软件工程专家,我们就好像是前述示例中的象棋高手。如果代码的结构是随机的,没有任何逻辑可言,或者说没有遵循任何标准,那么我们将像开发新手一样,很难发现错误。相反,如果我们习惯了阅读结构化代码,并能够根据规律快速获悉代码的意图,那么我们的表现将出色得多。

就Python而言,应遵循的编码风格是PEP-8。你可以对其进行扩展或只采用其中的某些部分以适应正在参与的项目的特殊性(如行长、字符串说明等)。

如果你发现当前参与的项目没有遵循任何编码标准,请努力争取在其代码库中遵循PEP-8。理想情况下,你所在的公司或团队应该有一个书面文档,指出大家应遵循的编码标准,这些编码标准可能是根据PEP-8改编而成的。

如果你发现团队的编码风格不一致,并在代码审核过程中就此做了多次讨论,那么再次阅读编码指南并投资购买自动验证工具可能是个不错的主意。

PEP-8指出了项目必须具备的一些重要质量特征,如下所示。

可搜索性:这指的是能够一眼识别代码中的符号,即在特定的文件(及其某个部分)中查找特定的字符串。PEP-8的要点之一是,它区分了将值赋值给变量的方式与将关键字参数传递给函数的方式。为了更好地理解这一点,我们来看一个示例。假设我们正在调试,需要找到名为location的参数值的传递位置。为此,可运行下面的grep命令,结果指出了这是在哪个文件的哪一行中进行的:

   $ grep -nr "location=" .
   ./core.py:13: location=current_location,

现在我们想知道这个变量在哪里被赋值,下面的命令可提供我们所需的信息:

   $ grep -nr "location =" .
   ./core.py:10: current_location = get_location()

PEP-8做出了这样的约定:通过关键字向函数传递参数时,不使用空格,但给变量赋值时使用。因此,我们可调整搜索条件(第一次搜索时等号两边都没有空格,而第二次搜索时等号两边都有一个空格),从而提高搜索效率。这是遵守约定带来的好处之一。

一致性:如果代码的格式一致,那么阅读起来将容易得多,这对新加入项目的人来说尤其重要。如果你希望有新的开发人员加入项目,或者要聘用新的(可能经验不足)程序员,那么他们势必要熟悉代码(可能由多个代码仓库组成),这对于新手来说尤其重要。如果在所有代码仓库的所有文件中,代码布局、文档、命名约定都相同,那么他们的工作将轻松得多。

更好的错误处理:PEP-8给出的建议之一是,尽可能减少try/except代码块中的代码。这可缩小了错误面(error surface),因为它降低了无意间隐藏异常和bug的可能性。要通过自动检查实现这一点可能很难,但可在代码审核过程中予以关注,这是绝对值得的。

代码质量:通过以结构化的方式查看代码,你将变得越来越熟练,从而一眼就能理解代码,进而轻松地发现bug和错误。另外,检查代码质量的工具也会就潜在的bug给出提示。对代码的静态分析可能有助于降低每行代码的bug率。

1.1节提到过,要确保代码整洁,格式设置是必要条件而非充分条件,还需考虑其他方面,如在代码中记录设计决策,以及尽可能使用工具来自动完成质量检查。1.3节将介绍其中的第一个考虑因素——文档。

本节介绍如何在Python代码中编写代码文档。优良的代码是不言自明的,但也包含详尽的文档。我们应该解释代码要做什么,而不是如何做。

给代码编写文档与添加注释不是一码事,这一点很重要。本节要探讨的是文档字符串和注解(annotation),因为它们是Python中用来编写代码文档的工具。但需要指出的是,这里也将简要地介绍代码注释,旨在让注释和文档之间的差别更清晰。

在Python中,代码文档很重要,因为它是动态类型的,很容易在函数和方法之间的变量或对象的值中丢失。有鉴于此,指出这种信息可让代码以后更容易阅读。

还有一个只与注解相关的原因,那就是注解可在使用Mypy或Pytype执行自动检查时提供帮助,如类型提示。你将发现,添加注解物超所值。

一般而言,应尽可能减少代码注释,因为代码应该是不言自明的,这意味着只要合理地使用抽象(如通过使用有意义的函数或对象来分割职责)并指定清晰的名称,根本就不需要在代码中添加注释。

编写注释前,看看能否使用代码(即添加新函数或使用更恰当的变量名)将其要表达的意思表达出来。

对于注释,本书的观点与其他软件工程文献完全一致:代码中包含注释说明你缺乏使用代码进行表达的能力。

然而,在有些情况下,不可避免地需要在代码中添加注释,如果不这样做将带来危险。一个这样的典型情况是,为处理不那么明显的技术细节,必须在代码中采取某种措施(例如,一个底层的外部函数存在bug,为规避这种问题,必须传递一个特殊的参数)。在这种情况下,我们的任务是以尽可能简洁而合适的方式阐述问题,并指出在代码中那样做的原因,让阅读代码的人能够明白面临的处境。

最后,还有一种代码注释是绝对糟糕的,根本没有存在的理由,那就是将代码注释掉。对于这样的代码,必须毫不手软地将其删除。别忘了,代码是用于在开发人员之间交流的语言,是设计方案的终极表示。代码就是知识。被注释掉的代码会导致混乱和矛盾,让知识受到污染。

被注释掉的代码可直接删除,根本没有将其留下的理由,考虑到现在有版本控制系统这一点后,更是如此。

总之,代码注释就是“恶魔”。虽然在有些情况下,这样的恶魔必不可少,但应尽可能避免。代码文档则完全不同,它指的是代码中有关设计或架构的说明,可让代码更为清晰,属于正能量(这也是1.3.2小节的主题,该小节主要讨论文档字符串)。

简单地说,文档字符串就是嵌入源代码中的文档,是放在代码的某个地方,用于对这部分代码的逻辑进行说明的字符串。

请注意,“文档字符串”包含“文档”一词。这很重要,因为这意味着文档字符串是诠释,而不是辩解。文档字符串不是注释,而是文档。

文档字符串用于为代码的特定组件(模块、类、方法或函数)提供文档,对其他开发人员来说很有用。其他工程师想使用你编写的组件时,很可能会查看文档字符串,以理解该组件的工作原理、预期的输入和输出等,因此尽可能地添加文档字符串是一种不错的做法。

文档字符串对于记录设计和架构决策也很有用。对于重要的Python模块、函数和类,给它们添加文档字符串可能是一个不错的主意,这样阅读者就知道组件在整个架构中所处的位置了。

为什么说在代码中包含文档字符串是一件好事(根据项目遵守的标准,甚至必不可少)呢?因为Python是动态类型的,这意味着可将任何东西作为函数的参数值,而Python不会强制或检查类似的内容。因此,假设代码中有一个函数需要修改,而且你足够幸运,这个函数及其参数的名称都是描述性的。但即便如此,你依然不太清楚应向它传递什么类型的值。在这种情况下,你怎么知道该如何使用它呢?

此时良好的文档字符串可派上用场。对函数的预期输入和输出进行说明是一种不错的做法,可帮助阅读者理解函数的工作原理。

要运行下面的代码,需要一个IPython交互shell,并根据本书的要求设置Python版本。如果没有IPython shell,也可在常规Python shell中运行这些命令,但需要将<函数名>??替换为help(<函数名>)。

请看下面这个摘自标准库的示例:

Type: method_descriptor

在上述输出中,字典的方法update的文档字符串提供了有用的信息,让我们知道可以以多种不同的方式使用它。

(1)可以使用方法.keys()传递内容(如另一个字典),这将使用作为参数传入的对象中的键来更新原始字典:

 >>> d = {}
 >>> d.update({1: "one", 2: "two"})
 >>> d
 {1: "one", 2: 'two'}

(2)可以向update传递一个由键值对组成的可迭代对象,而这些键值对将被拆封:

 >>> d.update([(3, "three"), (4, "four")])
 >>> d
 {1: 'one', 2: 'two', 3: 'three', 4: 'four'}

(3)文档字符串还指出,可以使用关键字参数中的值来更新字典:

 >>> d.update(five=5)
 >>> d
 {1: 'one', 2: 'two', 3: 'three', 4: 'four', 'five': 5}

(请注意,以这种方式调用update时,关键字参数为字符串,因此不能以类似于5="five"这样的形式设置,因为它是不正确的。)

对于要学习和了解新函数的工作原理与使用方法的人来说,这样的信息非常重要。

请注意,前面为显示函数的文档字符串,我们指定了函数的名称,并在它后面加上了两个问号(dict.update??),这是IPython交互式解释器的一个特性。这样做时,将打印指定对象的文档字符串。如果你给自己编写的函数添加文档字符串,让这些代码的使用者能够像前面获取标准库帮助信息那样获取帮助,从而明白你编写的函数的工作原理,那么这将让使用者的工作轻松得多。

文档字符串并不独立于代码,而是代码的一部分,你可直接访问它们。如果给对象定义了文档字符串,该文档字符串将通过属性__doc__成为对象的一部分:

>>> def my_function():
 """Run some computation"""
 return None
 ...
>>> my_function.__doc__ # or help(my_function)
 'Run some computation'

这意味着可在运行阶段访问文档字符串,还可使用源代码来生成或编译文档。实际上,市面上有用于生成或编译文档的工具。如果你运行Sphinx,它将为项目创建基本的文档框架,而指定扩展autodoc(sphinx.ext.autodoc)时,这个工具将从代码中提取文档字符串,并将其放在相应函数的文档页面中。

配置好生成文档的工具后,应将其公开,使其成为项目的一部分。对于开源项目,可使用read the docs,它自动为每个分支或版本(这是可配置的)生成文档。对于公司或项目,可以使用相同的工具或在内部配置这些服务,但不管如何决定,重要的是让文档可供团队的所有成员使用。

遗憾的是,文档字符串有一个缺点,就像所有文档一样,它需要不断地以手工方式进行维护:代码发生变化后,字符串文档也需相应地更新。另一个问题是,文档字符串要真正有用,就必须非常详细,这需要多行。考虑到这两点,如果你编写的函数非常简单,而且是不言自明的,那么最好避免添加多余的文档字符串,以免以后还要维护它。

确保文档正确是一个无法逃避的软件工程方面的挑战,这一点合情合理。为什么要手工编写文档呢?因为文档是供他人阅读的。如果将编写工作自动化,文档可能就没那么有用了。文档要有价值,所有团队成员都必须认为它是需要手工编写的,因此必须为此投入必要的精力。关键是要明白软件不仅关乎代码,文档也是可交付产品的一部分。因此,修改函数时,对相应的文档部分进行更新也同样重要,不管它是wiki、用户手册、README文件还是多个文档字符串。

PEP-3107引入了“注解”的概念,其背后的基本理念是给代码的阅读者以提示,让他们知道函数参数期望的值。这里使用“提示”一词是经过仔细斟酌的——注解支持类型提示。下面我们先简要地介绍注解,再讨论类型提示。

注解让你能够指定当前定义的变量的期望类型,实际上,注解不仅能指定类型,还可以指定任何元数据,这些元数据可以帮助阅读者更深入地理解变量的实际含义。

请看下面的示例:

@dataclass
class Point
    lat: float
    long: float

def locate(latitude: float, longitude: float) -> Point:
    """Find an object in the map by its coordinates"""

这里使用了float来指出latitude和longitude的期望类型。这只是向阅读函数的人提供信息,让他们知道这些期望的类型。Python不检查也不强制要求这些类型。

还可以指定函数返回值的期望类型。在这里,Point是一个用户定义的类,因此返回的将是一个Point实例。

然而,在注解中,并非只能指定类型或内置对象。基本上,所有在当前Python解释器范围内有效的东西都可以放在这里,如用于解释变量作用的字符串、用作回调或验证函数的可调用对象等。

可利用注解来提高代码的表达力。请看下面的示例,它定义了一个启动任务的函数,这个函数还接收一个指定延迟执行时间的参数:

def launch_task(delay_in_seconds):
    ...

其中,参数delay_in_seconds的名称看似很长,但并没有提供太多的信息。可接收的参数值是秒数吗?这个值能否是分数呢?

下面使用代码回答这些问题:

Seconds = float
def launch_task(delay: Seconds):
    ...

现在代码变得不言而喻了。另外,通过引入注解Seconds,创建了一个有关如何解读代码中时间的小型抽象,并可在代码库的其他地方使用它。如果以后决定修改有关Seconds的抽象(假设从现在起,只能接收整数),只需修改一个地方即可。

引入注解后,引入了一个新的特殊属性,它就是__annotations__。这让我们能够访问一个字典,这个字典将注解的名称(被用作这个字典中的键)映射到相应的值(我们为注解定义的值)。在这里的示例中,这个字典类似于下面这样:

>>> locate.__annotations__
{'latitude': <class 'float'>, 'longitude': <class 'float'>, 'return':
<class 'Point'>}

如果必要,可在代码中使用这个字典来生成文档、运行验证或执行检查。

说到通过注解来检查代码,这正是PEP-484的用武之地。这个PEP对类型提示的基本方面(即通过注解来检查函数类型的理念)做了规定。为清晰起见,下面摘录了PEP-484所做的说明:

Python仍将是一种动态类型语言,作者无意让类型提示成为强制性的,哪怕是通过约定。

类型提示旨在让独立于解释器的工具能够检查在代码中是否正确地使用了类型,并在检查到不兼容的情况时提示用户。有一些很有用的工具,它们执行有关数据类型及其在代码中使用情况的检查,以发现潜在的问题。一些示例工具,如Mypy和Pytype,将在1.4节进行更详细的解释,我们将在该节讨论如何在项目中使用和配置这些工具。现在,你可以将它视为一种代码校验器(linter),它检查代码中使用的类型的语义。有鉴于此,最好在项目中配置Mypy或Pytype,并像其他静态分析工具那样使用它。

然而,类型提示并非只是一个对代码进行类型检查的工具。对于代码中的类型,可为其创建有意义的名称和抽象。请看下面的示例,它定义了一个处理客户名单的函数。在最简单的情况下,可使用通用列表来注解:

def process_clients(clients: list):
    ...

如果我们知道,在当前的数据建模中,客户是使用由整数和字符串组成的元组表示的,就可再添加一些细节:

def process_clients(clients: list[tuple[int, str]]):
    ...

但这还是没有提供足够的信息,因此更佳的做法是,显式地给这个别名指定一个名称,这样阅读者就无须推断这种类型意味着什么了:

from typing import Tuple
Client = Tuple[int, str]
def process_clients(clients: list[Client]):
    ...

在这里,含义更清晰了,同时支持演化的数据类型。当前,元组可能是正确表示客户的最简单数据结构,但以后我们可能想将其改为其他对象或特定的类。在这种情况下,注解依然是正确的,其他所有的类型验证亦如此。

它背后的基本理念是,现在语义得到了扩大,表示的概念更有意义,让我们(人类)更容易理解代码的含义,或给定点的期望结果。

注解还带来了另一个好处。引入PEP-526和PEP-557后,我们可以按照更紧凑的方式编写类并定义小型容器对象:只需声明类中的属性并使用注解来设置其类型,同时使用装饰器@dataclass将属性声明为实例属性,而无须在__init__方法中显式地声明它们并设置它们的值:

from dataclasses import dataclass

@dataclass
class Point:
    lat: float
    long: float

>>> Point.__annotations__
{'lat': <class 'float'>, 'long': <class 'float'>}
>>> Point(1, 2)
Point(lat=1, long=2)

本书后面将讨论注解的其他重要用途,这些用途与代码设计关系紧密。探索面向对象设计的最佳实践时,我们可能想使用依赖注入之类的概念,即将代码设计成依赖于声明合约的接口。要指出代码依赖于特定的接口,最佳的方式可能是使用注解。更确切地说,有一些利用Python注解来自动提供依赖注入支持的工具。

在设计模式中,我们通常还想将部分代码与特定的实现解耦,让其依赖于抽象接口或抽象合约,从而提高代码的灵活性和可扩展性。另外,设计模式通常通过创建合适的抽象(这通常意味着使用新类来封装部分逻辑)来解决问题。在这两种情况下,对代码进行注解都将带来额外的帮助。

这个问题问得很合理,因为在注解引入之前很久的Python老旧版本中,要对函数参数或属性的类型进行说明,方法是使用文档字符串。甚至有一些格式方面的约定,它们指出了如何在文档字符串中包含有关函数的基本信息,包括每个参数的类型和含义、返回值以及函数可能引发的异常。

对于上面的问题,通过使用注解以更紧凑的方式解决了很大一部分,因此有人可能会问,是否值得同时添加文档字符串。答案是肯定的,因为文档字符串和注解互为补充。

诚然,对于以前包含在文档字符串中的有些信息,现在可以移到注解中了(在文档字符串中,不再需要指出参数的类型了,因为现在可以使用注解来指出这一点)。但这只会腾出更多的空间,让文档字符串能够包含更好的文档。特别是,对于动态和嵌套数据类型,最好提供期望的数据示例,这样我们就可以对正在处理的对象有更深入的认识。

请看下面的示例。假设有一个函数,它期望接收一个字典,以便对某些数据进行验证:

def data_from_response(response: dict) -> dict:
    if response["status"] != 200:
        raise ValueError
    return {"data": response["payload"]}

这里的函数接收一个字典,并返回另一个字典。如果键"status"对应的值不符合预期,这个函数将引发异常。然而,对于这个函数,我们没有很多其他的信息。例如,response对象的正确实例是什么样的呢?result对象的实例又是什么样的呢?要回答这两个问题,最好记录期望由参数传入并由该函数返回的数据示例。

下面来看看能否使用文档字符串更好地回答这两个问题:

def data_from_response(response: dict) -> dict:
    """If the response is OK, return its payload.

    - response: A dict like::

    {
        "status": 200, # <int>
        "timestamp": "....", # ISO format string of the current
        date time
        "payload": { ... } # dict with the returned data
    }

    - Returns a dictionary like::

    {"data": { .. } }

    - Raises:
    - ValueError if the HTTP status is != 200
    """
    if response["status"] != 200:
        raise ValueError
    return {"data": response["payload"]}

现在,我们对这个函数期望接收和返回的内容有了更深入的认识。文档提供了宝贵的输入内容,可以帮助我们理解传递的是什么,同时它还提供了宝贵的单元测试信息源。根据文档字符串,我们可以推断出应该将什么样的数据作为输入,还知道在测试中使用的值哪些是正确的,哪些是错误的。实际上,测试也可以充当可操作的代码文档,这将在本书后面更详细地说明。

这样做的好处是,我们现在知道了键的可能值以及它们的类型,还对数据是什么样的有了更具体的认识。但正如前面指出的,这样做的代价是,文档字符串占据了很多行,因为它们必须足够详细才能发挥作用。

本节将探索如何配置一些基本工具并自动执行代码检查,以利用重复验证检查。

别忘了,代码是要让我们(人)来理解的,因此只有我们才能判断什么样的代码是好的、什么样的代码是坏的,这一点很重要。我们应该花时间对代码进行审核、思考什么是好代码、其可读性和可理解性如何。审核同行编写的代码时,应该问问诸如下面这样的问题。

对其他程序员来说,这些代码易于理解吗?

这些代码是否从专业的角度解决了问题?

加入团队的新成员能否理解并有效地处理它们?

前面说过,代码必须格式良好、布局一致、适当地缩进,但光有这些还不够。此外,作为有高质量意识的工程师,我们认为这些是理所当然的,因此在阅读和编写代码时,我们关心的远不止是其布局。我们不想将时间浪费在审核这些方面;为高效地利用时间,我们将目光放在代码的实际模式上,以便能够理解其真正含义并提供有价值的审核结果。

所有这些检查都应自动化。它们应该是测试或检查项列表的一部分,而这又应该是持续集成构建的一部分。如果这些检查没有通过,则构建失败,这是确保代码结构始终保持连续性的唯一途径。这些检查也是可供团队参考的客观参数。不要让一些工程师或团队负责人总是在代码审核中参照PEP-8 给出相同的评论,而让构建自动失败,使之成为客观的东西。

本节介绍的工具将让你知道可对代码自动执行哪些检查。这些工具应执行某些标准。通常它们都是可配置的,对于不同的代码仓库,使用不同的配置是完全可行的。

使用工具旨在自动执行可重现的检查,这意味着每位工程师都可在其本地开发环境中运行工具,且得到的结果与其他团队成员相同。另外,应在持续集成(CI)构建中配置这些工具。

类型一致性是我们要自动检查的主要方面之一。Python是动态类型的,但我们可以添加类型注解,就对代码各部分的期望方面给予阅读者(和工具)以提示。注解虽然是可选的,但最好添加它们,因为这不仅可以提高代码的可读性,还让我们能够使用工具自动检查一些很可能是bug的常见错误。

Python引入类型提示后,很多执行类型一致性检查的工具应运而生。本节介绍其中的两个:Mypy和Pytype。这样的工具有很多,你可选择使用其他工具,但不管使用哪款工具,下面的原则都适用:最重要的是对变更进行自动验证,并将这些验证添加到CI构建中。Mypy是一款主流的Python静态类型检查工具,一经安装,它就会分析项目中所有的文件,以检查类型使用上的不一致。这很有用,因为在大多数情况下,它都能提前发现实际的bug,但有时也可能误报。

可以使用pip来安装它,建议将其作为项目依赖项包含在安装文件中:

$ pip install mypy

在虚拟环境中安装Mypy后,只需运行前面的命令,它就会报告所有的类型检查结果。请尽可能按报告说的做,因为在大多数情况下,它提供的见解有助于避免原本可能进入生产环境的错误。然而,这款工具也并非十全十美,因此对于你认为的误报,你可以忽略带有以下标记作为注释的代码行:

type_to_ignore = "something" # type: ignore

需要指出的是,要让这款工具以及其他任何工具发挥作用,必须在代码中小心地声明类型注解。如果类型设置过于宽泛,可能导致问题不会被工具报告。

在下面的示例中,函数接收一个参数并对其进行迭代。最初,任何可迭代对象都可行,因此我们想利用Python的动态类型功能,让函数可接收列表、元组、字典的键、集合及其他任何支持for循环的数据类型:

def broadcast_notification(
    message: str,
    relevant_user_emails: Iterable[str]
):
    for email in relevant_user_emails:
        logger.info("Sending %r to %r", message, email)

问题是如果在代码中错误地传递了这些参数,Mypy并不会报告错误:

broadcast_notification("welcome", "user1@domain.com")

这里的用法显然不合理,因为它迭代字符串中的每个字符,并试图将其作为email使用。

如果在设置这个参数的类型时更严格(假设只接收字符串列表或字符串元组),运行Mypy时将发现上述错误:

$ mypy <file-name>
error: Argument 2 to "broadcast_notification" has incompatible type
"str"; expected "Union[List[str], Tuple[str]]"

Pytype的工作原理与Mypy类似,同时也是可配置的,因此可根据项目的具体情况,调整这两个工具的配置。这款工具报告错误的方式与Mypy很像,如下所示:

File "...", line 22, in <module>: Function broadcast_notification was
called with the wrong arguments [wrong-arg-types]
 Expected: (message, relevant_user_emails: Union[List[str],
Tuple[str]])
 Actually passed: (message, relevant_user_emails: str)

然而,Pytype的一个重要不同是,它不仅根据参数检查定义,还会在运行时尝试解释代码是否正确,并报告哪些是运行时错误。例如,如果暂时违反了其中一个类型定义,只要最终结果符合声明的类型,就不会将此视为问题。虽然这通常是一个优点,但建议尽可能不要破坏你在代码中定下的规矩,同时尽可能避免无效的中间状态,因为这样代码将更容易理解且更少地依赖于副作用。

除使用1.4.1小节介绍的工具来检查程序在类型管理方面的错误外,还可使用其他工具根据更广泛的参数来验证代码。

Python中有很多检查代码结构的工具,如Pycodestyle(以前在PyPi中称为Pep8)、Flake8等,它们都基本上遵循PEP-8。这些工具都是可配置的,且使用起来很容易——只需运行它们提供的命令即可。

这些工具是运行在一组Python文件上的程序,检查代码是否符合PEP-8标准,报告违反该标准的每行代码,并指出它违反了哪条规则。

还有一些工具提供了更全面的检查,它们不仅检查对PEP-8标准的遵循情况,还检查更复杂的PEP-8未涵盖的情况。别忘了,即便严格地遵循了PEP-8标准,代码的质量也不一定优良。

例如,PEP-8关注的主要是代码的风格和结构,并不要求给每个公有方法、类和模块都添加文档字符串。另外,对于接收太多参数的函数,它也没有做任何说明(本书后面将指出,参数太多是一个糟糕的特征)。

一个这样的工具是Pylint。在验证Python项目的工具中,Pylint是全面、严格的一个,它也是可配置的。与前面介绍的工具一样,要使用它,首先需要使用pip在虚拟环境中安装它:

$ pip install pylint

然后,只需运行命令pylint,就可对代码进行检查。

可通过配置文件pylintrc来配置Pylint。在这个文件中,可指定要启用或禁用的规则、并对其他规则进行参数化(如修改最大列长)。例如,正如刚才讨论过的,我们可能不希望每个函数都有文档字符串,因为强制这样做可能影响工作效率。然而,在默认情况下,Pylint要求这样做,但我们可以在配置文件中驳回这种要求:

    [DESIGN]
    disable=missing-function-docstring

一旦这个配置文件进入稳定状态(意味着它与编码指南保持一致,无须做进一步的调整),便可将其复制到其他仓库中,并对其进行版本控制。

开发团队在编码标准方面达成一致后,建立相关的文档,然后在将在代码仓库中自动运行的工具的配置文件中实施这些标准。

最后,还要说一说另一款工具,它就是Coala。Coala更通用些(意味着支持多种语言,而不仅仅是Python),但其理念与Pylint类似:支持使用配置文件对其进行配置,并提供了一个执行代码检查的命令行工具。运行时,如果在扫描文件的过程中发现错误,这款工具可能会提醒用户,并在合适的情况下提供自动修复建议。

如果有检查工具的默认规则没有覆盖的用例,该怎么办呢?Pylint和Coala都自带了大量预定义的规则,这些规则覆盖了常见的情形,但你可能发现,在你的组织中存在一些会导致错误的模式。

如果你在代码中发现了经常容易出错的模式,建议花些时间定义自己的规则。这两款工具都是可扩展的:在Pylint中,有多个可用的插件,你还可以编写自定义插件;在Coala中,可编写与常规检查同时执行的自定义验证模块。

本章开头说过,团队就代码编写约定达成一致是明智的选择,这可避免去讨论个人偏好,进而将注意力集中在代码的本质上。但达成一致只是达成一致而已,如果这些规则得不到执行,随着时间的流逝,将没人理会它们。

除了使用工具检查代码对标准的遵循情况,直接自动设置代码的格式也很有用。

有多款可用于自动设置Python代码格式的工具 [验证代码是否遵循了PEP-8标准的工具(如Flake8),大都提供了一种对代码进行重写并使其遵循PEP-8标准的模式],它们都是可根据项目的具体情况进行配置和调整的。在这些工具中,有一个要重点说一下,它就是Black,因为它并没有提供全面的灵活性和配置。

Black有个特点,那就是以独特而确定的方式设置代码的格式,而不允许设置任何参数(行长可能是个例外)。

例如,Black总是使用双引号将字符串括起了,参数的排列顺序也总是遵循相同的结构。这可能有点死板,却是确保代码差异最小化的唯一途径。如果代码总是采用相同的结构,那么在合并请求中显示的代码更改将只有实际修改,而没有额外的美化性修改。Black比PEP-8严格,但使用起来也很方便,因为通过工具来直接设置代码的格式,我们不用操心这一点,而可专注于问题的症结。

这也是Black能够存在的原因。PEP-8定义了一些代码结构化指南,但让代码遵循PEP-8标准的方式有多种,因此依然存在找出风格差异的问题。Black根据更严格且始终是确定的PEP-8子集来设置代码格式。

例如,下面的代码遵循了PEP-8标准,但没有遵循Black的约定:

def my_function(name):
    """
    >>> my_function('black')
    'received Black'
    """
    return 'received {0}'.format(name.title())

现在可以执行下面的命令来设置这些代码的格式了:

black -l 79 *.py

结果如下:

def my_function(name):
    """
    >>> my_function('black')
    'received Black'
    """
    return "received {0}".format(name.title())

对于更复杂的代码,修改可能多得多(行尾的逗号等),但其中的理念是清晰的。最好使用工具来帮助我们处理细节,同样这只是我个人的看法。

很久以前,Golang社区就明白这一点,进而提供了标准工具库go fmt,它根据Go语言约定自动设置代码的格式。现在Python也有类似的工具,真是太好了。

安装Black后,命令black默认设置代码的格式,但还有一个--check选项,它根据标准对文件进行验证,如果没有通过验证,那么这个过程将以失败告终。在自动检查和CI流程中,也应包含这个命令。

需要指出的是,Black设置整个文件的格式,而不像其他工具那样支持部分格式设置。对包含使用不同风格的代码的遗留项目来说,这可能是个问题,因为要在项目中将Black作为格式设置标准,很可能必须接受下面两种情形之一。

(1)创建里程碑合并请求,这将使用Black来设置仓库中所有Python文件的格式。这样做的缺点是,会增加大量的噪声,还会污染仓库的版本控制历史记录。在有些情况下,你的团队可能决定接受这种风险(这取决于你有多依赖于git历史记录)。

(2)你也可使用Black设置格式时所做的代码修改覆盖历史记录。在git中,可通过对提交应用命令来(从头开始)覆盖提交。在这种情况下,可在使用Black设置格式后覆盖每个提交,让项目看起来像是从一开始就采用了这种新的形式,但有些需要注意的地方。首先,由于项目的历史记录被覆盖,因此每个人都必须刷新其仓库的本地副本;其次,如果有大量的提交,这个过程可能需要较长的时间才能完成,这取决于仓库的历史记录。

在“要么全部要么无”的格式设置方式不可接受的情况下,可使用Yapf,这款工具有很多不同于Black的地方:它是可高度定制的,同时支持部分格式设置(只设置文件中某些部分的格式)。

Yapf接收一个指定格式设置范围的参数。使用这个参数,可配置编辑器或IDE(更佳的做法是,设置一个git预提交钩子),以便自动设置刚修改的代码部分的格式。这样,每次修改代码后,都可让项目与编码标准保持一致。

结束本小节之前,我要说的是,Black是一款出色的工具,可确保代码遵循规范的标准,因此应尝试在仓库中使用它。在新创建的仓库中使用Black时,绝对不会遇到任何障碍,但在遗留的仓库中使用它时,可能会遇到障碍,这也是可以理解的。如果团队觉得在遗留仓库中使用Black太麻烦,那么Yapf等工具可能是更合适的选择。

在UNIX开发环境中,常见的工作方式是使用Makefile。Makefile是一个功能强大的工具,让你能够配置要在项目中执行的命令——主要是编译、运行等命令。另外,还可以在项目的根目录中使用Makefile,并在其中配置一些命令,以便自动执行代码的格式设置和约定方面的检查。

为此,一种不错的方法是,为测试和每个特定的测试设置目标,然后让另一个测试一起运行,如下所示:

.PHONY: typehint
typehint:
     mypy --ignore-missing-imports src/

.PHONY: test
test:
     pytest tests/

.PHONY: lint
lint:
     pylint src/

.PHONY: checklist
checklist: lint typehint test

.PHONY: black
black:
     black -l 79 *.py

.PHONY: clean
clean:
     find . -type f -name "*.pyc" | xargs rm -fr
     find . -type d -name __pycache__ | xargs rm -fr

在这里,我们(在开发计算机和CI环境构建中都)执行如下命令:

make checklist

这将按如下步骤执行所有指定的操作。

(1)检查对编码指南(如PEP-8或带有--check参数的Black)的遵循情况。

(2)检查代码中的类型使用情况。

(3)运行测试。

只要上述任何步骤失败,就认为整个过程都失败了。

可将这些工具(Black、Pylint、Mypy等)与你选择的编辑器或IDE集成,让工作更轻松。请配置编辑器,使其在用户保存文件或按相应的快捷键时执行这些类型的修改,这绝对是一项不错的投资。

需要指出的是,使用Makefile可提供极大的便利,原因有两个。首先,为自动执行大多数重复性任务提供了简单而单一的途径。新加入团队的成员可快速上手,只需知道类似于'make format'这样的命令自动设置代码的格式,而不管使用的底层工具(及其参数)如何。另外,即便以后决定更换工具(假设将Yapf更换为Black),原来的命令('make format')也依然管用。

其次,尽可能使用Makefile,这意味着可配置CI工具,使其也调用Makefile中的命令。这样,有了在项目中执行主要任务的标准化方式,同时可让CI工具包含的配置尽可能少(以后也可能对此进行修改,但这不是什么大负担)。

至此,我们对整洁代码的含义有了大致认识,并对它有了一个可行的解释,这为本书后面的内容提供了参考。

更重要的是,我们现在知道了,代码整洁比代码的结构和布局重要得多,因此必须专注于代码表达的想法,看看它们是否正确。代码整洁关乎代码的可读性和可维护性,致力于最大限度地减少技术债务,并通过代码有效地传达理念,让他人能够明白我们最初编写代码的意图。

遵循编码风格或指南至关重要,原因有多个。我们认为,这是必要条件,而非充分条件,这是任何可靠的项目都必须满足的基本条件,最好将这项工作留给工具去完成。因此,自动执行所有这些检查至关重要,有鉴于此,我们必须牢记如何配置Mypy、Pylint、Black等工具。

我们会在第2章集中介绍Python代码,并阐述如何以符合Python习惯的方式表达想法。我们将探索Python惯用法,它们让代码更紧凑、效率更高。通过分析我们将发现,Python完成任务的方式不同于其他语言。

PEP-8:针对Python编订的代码风格指南。

Mypy工具。

Pytype工具。

PEP-3107。

PEP-484。

PEP-526。

PEP-557。

PEP-585。

读者服务:

微信扫码关注【异步社区】微信公众号,回复“e58811”获取本书配套资源以及异步社区15天VIP会员卡,近千本电子书免费畅读。


本章探讨使用Python表达想法的方式及其独特之处。如果你熟悉完成某些编程任务(如获取列表的最后一个元素、迭代和搜索)的标准方式,或者使用过其他编程语言(如C、C++和Java),就会发现Python提供了完成大多数常见任务的独特机制。

在编程中,惯用法是编写代码以执行特定任务的方式。这是一种常见的方式,每次都重复并遵循相同的结构。有人甚至认为这是一种模式,但不是设计模式(稍后将探讨)。主要的区别在于设计模式是高级理念,在某种程度上独立于语言,且不能直接转换为代码;而惯用法是具体的代码,是执行特定任务时编写代码的一种方式。

由于惯用法是代码,因此依赖于语言。每种语言都有自己的惯用法,即使用该语言完成任务的方式(例如,如何使用C或C++语言打开和写入文件)。如果代码遵循了这些惯用法,就说它符合习惯,在Python中,这被称为符合Python语言习惯。

为何要按照这些建议编写符合Python语言习惯的代码呢?原因有多个。首先,正如我们将看到并分析的那样,以符合Python语言习惯的方式编写的代码性能更高,同时更紧凑、更容易理解。这些都是我们希望代码具备的特征,它们让代码能够卓有成效地工作。

其次,第1章说过,整个开发团队熟悉相同的代码模式和结构很重要,这有助于他们专注于问题的本质,还可帮助他们避免犯错。

本章的目标如下。

明白索引和切片、正确地实现可通过索引访问的对象。

实现序列和其他可迭代的对象。

学习正确的上下文管理器使用方式以及如何编写有效的上下文管理器。

使用魔法方法编写更符合Python语言习惯的代码。

避免常见的Python错误,这些错误可能导致讨厌的副作用。

2.1节首先来探讨索引和切片。

与其他语言一样,Python中也有一些支持通过索引访问其元素的数据结构或类型;Python与大多数编程语言的另一个共同之处是,第一个元素的索引为0。然而,与这些语言不同的是,Python提供了额外的特性,让我们能够以不同寻常的顺序访问元素。

例如,在C语言中,如何访问数组的最后一个元素呢?这是我刚使用Python时就尝试做过的事情。如果按照C语言使用思路,我将通过将数组长度减1来获得这个元素的位置。在Python中,这种做法可行,但也可以使用负索引;使用负索引时,从最后一个元素往前数,如下面的命令所示:

>>> my_numbers = (4, 5, 3, 9)
>>> my_numbers[-1]
9
>>> my_numbers[-3]
5

这种行事方式更符合Python语言习惯。

除获取一个元素外,还可使用切片来获取多个元素,如下面的命令所示:

>>> my_numbers = (1, 1, 2, 3, 5, 8, 13, 21)

>>> my_numbers[2:5]
(2, 3, 5)

在这里,方括号语法意味着将获取从第二个索引(含)开始到第五个索引(不含)结束的所有元素。在Python中,切片将指定区间的终点排除在外。

指定区间时,可省略起点或终点,在这种情况下,将从序列的起点开始或到序列的终点结束,如下面的命令所示:

>>> my_numbers[:3]
(1, 1, 2)
>>> my_numbers[3:]
(3, 5, 8, 13, 21)
>>> my_numbers[::] # also my_numbers[:], returns a copy
(1, 1, 2, 3, 5, 8, 13, 21)
>>> my_numbers[1:7:2]
(1, 3, 8)

第一个示例将获取索引3之前的所有元素。第二个示例将获取索引3(含)和最后一个元素之间的所有元素。在倒数第二个示例中,起点和终点都省略了,因此创建了原始元组的副本。

最后一个示例包含表示步长的第三个参数,步长指的是在区间内迭代时每次跳多少个元素。在这个示例中,意味着以每次跳两个元素的方式获取索引1和7之间的元素。

在所有这些示例中,当我们将区间传递给序列时,实际传递的是切片。请注意,切片是Python内置对象,你可创建切片并直接传递它们:

>>> interval = slice(1, 7, 2)
>>> my_numbers[interval]
(1, 3, 8)

>>> interval = slice(None, 3)
>>> my_numbers[interval] == my_numbers[:3]
True

注意,参数(起点、终点或步长)被省略时,我们认为其值为None。

在任何情况下,都应使用这种内置的切片语法,而不要使用for循环来迭代元组、字符串或列表,进而将元素排除在外。

刚才讨论的做法之所以可行,都是拜魔法方法__getitem__所赐(魔法方法是名称以两个下划线开头和两个下划线结束的方法,Python使用它们来实现特殊的行为)。每当遇到类似于myobject[key]这样的代码时,都将调用这个方法,并将key(方括号内的值)作为参数传递给它。具体地说,序列是实现了__getitem__和__len__的对象,因此是可迭代的。列表、元组和字符串都是标准库中的序列对象。

本节关注的是如何通过键获取对象中的特定元素,而不是创建序列或可迭代的对象,这个主题将在第7章探讨。

要在自定义类中实现__getitem__,必须考虑一些因素,以符合Python语言习惯。

如果自定义类是标准库对象包装器,尽可能将行为委托给底层对象。这意味着如果自定义类是列表包装器,应调用列表的同名方法,以确保自定义类是兼容的。下面是一个列表包装对象示例,在其中实现的每个方法中,都直接调用了相应的列表方法:

from collections.abc import Sequence

class Items(Sequence):
    def __init__(self, *values):
        self._values = list(values)

    def __len__(self):
        return len(self._values)

    def __getitem__(self, item):
        return self._values.__getitem__(item)

为将这个类声明为序列,它实现了模块collections.abc中的接口Sequence。要让自定义类的行为像标准对象(容器、映射等)一样,最好实现这个模块中相应的接口,因为这揭示了该类的意图,同时使用接口将迫使你实现所需的方法。

这个示例使用的是组合方法(因为它包含一个充当内部协调器的列表,而没有继承列表类)。另一种方法是使用类继承,在这种情况下,必须扩展基类collections.UserList,但这样做时,必须考虑本章最后一部分提到的注意事项。

但是,如果你正实现自己的序列,而该序列不是包装器或不依赖于底层的任何内置对象,那么请牢记如下两点。

按区间索引时,结果应该是与类的类型相同的实例。

在切片提供的区间中,必须遵守Python使用的语义,即将区间末尾的元素排除在外。

第一点是一个不易察觉的错误。获取列表的切片时,结果为列表;请求元组的区间时,结果为元组;请求子字符串时,结果为字符串。在所有这些情况下,结果的类型都与原始对象的类型相同,这合乎情理。如果你创建了一个表示日期间隔的对象,那么使用索引区间获取其中的元素时,应返回一个新的同类对象,而不能返回列表、元组或其他东西。标准库中的函数range淋漓尽致地说明了这一点。如果用间隔调用range函数,将创建一个可迭代的对象,它知道如何生成指定区间内的值。如果你使用索引区间来访问range函数调用的结果,将返回一个新的range函数调用的结果,而不是列表(这合乎情理):

>>> range(1, 100)[25:50]
range(26, 51)

第二条规则说的是一致性:如果你编写的代码与Python约定保持一致,使用者将发现它更熟悉、更容易使用。Python开发人员已经习惯了切片、range函数等的行为,如果自定义类的行为与此不同,将让人感到迷惑,这意味着更难记住,还可能引发bug。

至此,我们介绍了索引和切片,以及如何创建自己的索引和切片,2.2节将以同样的方式介绍上下文管理器:先看看标准库中上下文管理器的工作方式,再介绍如何创建自己的上下文管理器。

上下文管理器是Python提供的一个很有用的特性。它为何这么有用呢?因为它能够正确地响应模式。你经常需要运行带前置条件和后置条件的代码,即在执行主操作前后都运行某些代码。在这种情况下,非常适合使用上下文管理器。

上下文管理器主要用于资源管理。例如,打开文件时,我们要确保处理完毕后将其关闭,以免导致文件描述符泄露;打开到服务或套接字的连接时,我们要确保将其正确地关闭;使用临时文件时,我们要确保将其删除;类似的情况还有很多。

在所有这些情况下,通常你都必须记得将分配的资源释放,这是正常情况,但还需要处理异常和错误。由于需要处理所有可能的组合和执行路径,这增加了程序的调试难度;为解决这种问题,常见的做法是将执行清理工作的代码放在finally代码块中,以确保它在任何情况下都会执行。下面是一个非常简单的示例:

fd = open(filename)
try:
    process_file(fd)
finally:
    fd.close()

但下面的方式能达到同样的效果,而且它更优雅、更符合Python语言习惯:

with open(filename) as fd:
    process_file(fd)

with语句是在PEP-343中定义的,它进入上下文管理器。在这里,函数open实现了上下文管理器协议,这意味着这个代码块执行完毕后,文件将自动关闭,即便期间发生了异常。

上下文管理器包含两个魔法方法:__enter__和__exit__。在上述示例的第1行,with语句将调用第一个方法(__enter__),而这个方法返回的值将被赋给as后面指定的变量。将返回值赋给变量是可选的:方法__enter__并非必须返回值,即便它返回了值,也并非必须将这个值赋给变量。

这行代码执行完毕后,将进入一个新的上下文,可在其中运行任何Python代码。with代码块中的最后一条语句执行完毕后,将退出当前上下文,这意味着Python将调用之前调用的上下文管理器对象的方法__exit__。

如果上下文管理器代码块中的代码出现异常或错误,也会调用方法__exit__,因此在确保清理代码总是会执行方面,上下文管理器提供了便利的途径。实际上,这个方法会收到触发的异常,让我们能够以自定义的方式处理异常。

虽然在使用资源(如前面提到的文件、连接等)时,经常会用到上下文管理器,但这并非其唯一的用途。为处理特定的逻辑,可实现自定义上下文管理器。

在关注点分离以及将应该彼此独立的代码隔离方面,上下文管理器是一种不错的方式,因为如果将它们混在一起,逻辑将更难维护。

例如,假设我们要使用脚本对数据库进行备份。需要注意的是,这里的备份是脱机的,这意味着只能在数据库没有运行时对其进行备份,因此备份前必须先让数据库停止运行。备份后,我们想要重启数据库进程,而不管数据库备份过程如何。

为此,一种方法是创建一个大型函数来完成所有的工作:停止数据库服务、执行备份任务、处理异常及所有可能的边缘情况,再尝试重启数据库服务。这样的函数是什么样的呢?这是你完全能够想象得到的,因此这里不详细介绍,而直接介绍使用上下文管理器处理这种问题的方式:

def stop_database():
    run("systemctl stop postgresql.service")

def start_database():
    run("systemctl start postgresql.service")

class DBHandler:
    def __enter__(self):
        stop_database()
        return self

    def __exit__(self, exc_type, ex_value, ex_traceback):
        start_database()

def db_backup():
    run("pg_dump database")

def main():
    with DBHandler():
        db_backup()

这个示例不需要使用上下文管理器的结果,因此可以认为__enter__的返回值无关紧要,至少在这里如此。这是设计上下文管理器时需要考虑的一个问题:在上下文管理器代码块中,需要哪些东西?一般而言,最好让__enter__返回一个值,虽然并非必须这样做。

在这里,只执行了备份任务,它独立于维护任务,如前面的代码所示。前面说过,即便执行备份任务时发生错误,__exit__也会被调用。

请注意方法__exit__的签名,它接收一些值,这些值表示代码块中发生的异常。如果代码块中没有发生异常,这些值都将为None。

__exit__的返回值是一个必须考虑的问题。通常情况下,我们希望方法保持原样,不返回任何特定内容。如果这个方法返回True,将意味着不会将潜在引发的异常传播给调用者,并将在那里停止。在有些情况下,这是你想要的结果(甚至取决于引发的异常类型),但一般而言,将异常“吞掉”不是什么好主意。记住:绝不要悄无声息地传播错误。

如果没有充分的理由,千万不要从__exit__返回True。即便有充分的理由这样做,也要确认这样做的结果是你想要的。

一般而言,我们可以实现前面示例中的上下文管理器。我们所需要的只是一个实现魔法方法__enter__和__exit__的类,然后该对象将能够支持上下文管理器协议。这是实现上下文管理器的常用方式,但并非唯一的方式。

本节将介绍不同的上下文管理器实现方式(其中有些更紧凑),还将介绍如何通过标准库(特别是模块contextlib)来利用这些方式。

模块contextlib包含大量辅助函数和对象,它们实现了上下文管理器或使用既有的上下文管理器。使用它们可以帮助我们编写更紧凑的代码。

我们先来看看装饰器contextmanager。

应用于函数时,装饰器contextlib.contextmanager将函数代码转换为上下文管理器。目标函数必须是生成器函数,以便能够确定将其中哪些语句分别放在魔法方法__enter__和__exit__中。

如果你不熟悉装饰器和生成器,也没有关系,因为这里的示例都是不言自明的,惯用法理解和使用起来也很容易。这些主题将在第7章详细讨论。

可使用装饰器contextmanager将前面的示例改写成下面这样:

import contextlib

@contextlib.contextmanager
def db_handler():
    try:
        stop_database()
        yield
    finally:
       start_database()

with db_handler():
    db_backup()

这里定义了一个生成器函数,并对其应用装饰器@contextlib.contextmanager。这个函数包含一条yield语句,这让它成了生成器函数。再说一遍,就这里而言,你无须明白生成器的细节,而只需知道对其应用这个装饰器时,将把yield语句之前的代码视为方法__enter__的组成部分来运行,并将生成(yielded)的值作为上下文管理器评估的结果(__enter__的返回值)。如果使用as指定了变量(如as x:),这个值将被赋给指定的变量。在这里,没有生成任何值(这意味着生成的值为None),但如果你愿意,可以生成一条语句,以便在上下文管理器代码块中使用它。

此时,生成器函数被挂起,并进入上下文管理器。在这里,我们再次运行数据库的备份代码。执行数据库备份代码后,重新开始执行生成器函数,因此可以认为yield语句后面的所有代码都将包含在__exit__方法中。

以这样的方式编写上下文管理器的优点是,更容易重构既有函数和重用代码;另外,不想让上下文管理器属于任何对象时,最好采用这种方式来实现它(从面向对象的角度看,让上下文管理器属于特定对象,将创建没有实际用途的“假冒”类)。

添加额外的魔法方法会导致对象的耦合程度更高、承担更多的职责、支持不该支持的功能。在只是需要一个上下文管理函数(无须保留众多状态,完全与其他类隔离并独立于它们)时,使用刚才介绍的方法是不错的选择。

然而,还有其他实现上下文管理器的方式,这些方法也使用标准库中的模块contextlib。

可使用的另一个辅助类是contextlib.ContextDecorator,这是一个基类,提供了将装饰器应用于函数,使其在上下文管理器中运行的逻辑。对于上下文管理器本身的逻辑,必须通过实现前面提及的魔法方法来提供。这样做后,类将像用于函数的装饰器那样工作,或者可以混入其他类的类层次结构中,使其像上下文管理器那样行事。

要使用contextlib.ContextDecorator类,必须扩展它,并实现指定的方法:

class dbhandler_decorator(contextlib.ContextDecorator):
    def __enter__(self):
        stop_database()
        return self

    def __exit__(self, ext_type, ex_value, ex_traceback):
        start_database()

@dbhandler_decorator()
def offline_backup():
    run("pg_dump database")

与前面的示例相比,这里有一个不同之处,那就是没有with语句,不知道你注意到了没有。调用函数offline_backup()时,它将自动在上下文管理器中运行。这种逻辑是由基类contextlib.ContextDecorator提供的:将它用作包装原始函数的装饰器,以便在上下文管理器中运行。

这种方法的唯一缺点是,由于对象的工作方式,它们是彼此独立的(这是件好事),即装饰器对被装饰的函数一无所知,反之亦然。这虽然是件好事,但意味着函数offline_backup无法访问装饰器对象。然而,要访问这个装饰器,可在函数内部调用它,如下所示:

def offline_backup():
    with dbhandler_decorator() as handler: ...

装饰器的优点是,只需定义其逻辑一次,就可重用它很多次,为此只需将装饰器应用于需要同样逻辑的函数。

下面来探索contextlib的最后一个特性,看看可以从上下文管理器中得到什么,并了解一下可以使用它们做什么。

在这个库中,包含contextlib.suppress,这是一个实用工具,在知道某些异常可以忽略的情况下,可使用它来避免这些异常。这类似于在try/except代码块中运行同样的代码,并传递异常或只是将其写入日志,但不同之处在于,通过调用方法suppress,更明确地指出了在我们的逻辑中对这些异常进行了控制。

例如,请看下面的代码:

import contextlib

with contextlib.suppress(DataConversionException):
    parse_data(input_json_or_dict)

在这里,如果出现异常DataConversionException,说明输入数据已经是预期的格式,无须进行转换,因此完全可以忽略这种异常。

上下文管理器是一项非常独特的特性,让Python与众不同。因此,可将使用上下文管理器视为一种符合Python语言习惯的做法。2.3节将探讨Python的另一个有趣的特性——推导式和赋值表达式,使用它可让代码更简洁。

本书将多次涉及推导式,因为使用它们通常可让编写出来的代码更简捷,同时也更容易理解。这里为何说通常呢?因为在有些情况下,需要对收集的数据进行变换,此时使用推导式可能导致代码更复杂。在这种情况下,编写简单的for循环可能是更好的选择。

然而,在万不得已的情况下,还以可使用赋值表达式来救场。本节将讨论这些替代方式。

推荐使用推导式来创建数据结构,这样只需一条指令,而无须执行多次运算。例如,如果要创建一个列表,其中包含对一系列数字执行计算得到的结果,不应像下面这样编写代码:

numbers = []
for i in range(10):
    numbers.append(run_calculation(i))

而应直接创建列表:

numbers = [run_calculation(i) for i in range(10)]

这样编写的代码的性能通常更高,因为只使用单个Python操作,而不是反复调用list.append。如果你对代码的内部结构或不同版本之间的差异感到好奇,可研究一下模块dis,并使用以下示例调用它。

下面来看一个函数示例,一些表示云计算环境(如ARN)上资源的字符串,并返回一个集合,其中包含在这些字符串中找到的账户ID。为编写这个函数,最朴素的方式类似于下面这样:

from typing import Iterable, Set

def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
    """Given several ARNs in the form

        arn:partition:service:region:account-id:resource-id

    Collect the unique account IDs found on those strings, and return them.
    """
    collected_account_ids = set()
    for arn in arns:
        matched = re.match(ARN_REGEX, arn)
        if matched is not None:
            account_id = matched.groupdict()["account_id"]
            collected_account_ids.add(account_id)
    return collected_account_ids

显然,这里的代码很长,但完成的任务比较简单。阅读者可能对其中的语句感到迷惑,处理这些代码时一不小心就可能犯错。如果能够对其进行简化,那就太好了。为使用更少的代码实现相同的功能,可像函数式编程中那样使用几个推导式:

def collect_account_ids_from_arns(arns):
    matched_arns = filter(None, (re.match(ARN_REGEX, arn) for arn in arns))
    return {m.groupdict()["account_id"] for m in matched_arns}

在这个函数中,第1行代码与应用map和filter类似:首先尝试将正则表达式与提供的所有字符串匹配,再筛选出不为None的结果。结果是一个迭代器,我们稍后将使用它在集合推导式中提取账户ID。

相比于第一个示例,这个函数应该更容易维护,但依然需要两条语句。在Python 3.8之前,不可能使其更紧凑了,但PEP-572引入了赋值表达式,让我们能够将其重写为只有一条语句:

def collect_account_ids_from_arns(arns: Iterable[str]) -> Set[str]:
    return {
        matched.groupdict()["account_id"]
        for arn in arns
        if (matched := re.match(ARN_REGEX, arn)) is not None
    }

请注意上述推导式中第3行的语法,它在当前作用域内创建一个临时标识符,用于存储将正则表达式应用于字符串的结果,而这个标识符可在当前作用域内的其他地方重用。

在这里,第三个示例是否优于第二个示例存在争议,但它们两个肯定都优于第一个示例。在我看来,最后一个示例的表现力更强,因为其代码中的间接性更少,阅读者只需知道收集的值属于同一个作用域即可。

记住,代码并非总是越紧凑越好。如果为编写一行程序,而必须使用费解的表达式,那么根本不值得这样做,还不如使用朴素的方法。这与第3章将讨论的保持简单原则相关。

请考虑推导式的可读性,如果程序只包含单行代码时并不会更容易理解,那么就不要非得这样做。

使用赋值表达式的另一个原因是,其性能通常更高(而不仅仅是更容易理解)。如果在变换逻辑中必须使用某个函数,我们不希望在没必要的情况下调用它。将函数的结果赋给临时标识符(就像前面使用赋值表达式的示例中那样)是一种不错的优化技术,同时可提高代码的可读性。

请对使用赋值表达式可获得的性能提升进行评估。
 

2.4节将介绍Python的另一个特征——特性,同时讨论各种暴露或隐藏Python对象中数据的方式。

在Python中,对象的所有属性和函数都是公有的,而在其他语言中,属性可以是公有的、私有的或受保护的。换而言之,在Python看来,防止调用者调用对象的属性没有意义。这是Python不同于其他编程语言的另一个地方,在其他编程语言中,可将某些属性设置为私有的或受保护的。

虽然没有严格执行,但存在一些约定:以下划线开头的属性表示它是该对象的私有属性,外部代理不应调用它(但并没有禁止这样做)。

详细介绍特性前,先得说说Python中的下划线(让你明白这种约定)以及属性的作用域。

在Python中,有一些与下划线相关的约定和实现细节,这是一个有趣的主题,值得分析分析。

前面说过,默认情况下,对象的所有属性都是公有的。下面的示例证明了这一点:

>>> class Connector:
... def __init__(self, source):
... self.source = source
... self._timeout = 60
...
>>> conn = Connector("postgresql://localhost")
>>> conn.source
'postgresql://localhost'
>>> conn._timeout
60
>>> conn.__dict__
{'source': 'postgresql://localhost', '_timeout': 60}

这里通过指定属性source创建了一个Connector对象;它有两个属性——source和timeout,其中前者是公有的,而后者是私有的。然而,从接下来的几行代码可知,像这样创建对象时,其公有属性和私有属性都是可以访问的。

这段代码的意思是,_timeout应该只能在Connector对象内部访问,而不能在调用者中访问。这意味着你应该以特定的方式组织代码,以便随时都可安全地重构timeout,因为它不会从对象外部调用(而只会在内部调用),因此重构它不会影响接口。遵守这些规则可让代码更容易维护、更健壮,因为如果重构代码时保持对象的接口不变,就不会担心连锁反应。这个原则也适用于方法。

类应该只暴露与外部调用者对象相关的属性和方法,具体地说是接口指定的属性和方法。对于接口未指定的属性和方法,其名称都应以单个下划线开头。

对于以下划线开头的属性,应该将其视为私有的,不在外部调用它。对于这条规则有个例外,就是在单元测试时,如果通过访问内部属性可简化测试,就应该允许这样做,但需要指出的是,当你决定重构主类时,这种实用主义方法可能付出维护性方面的代价。请牢记下面的提示中指出的建议。

如果内部方法和属性过多,可能昭示着类承担的任务太多,未遵守单一职责原则。这可能表明你需要将其某些职责提取出来并放到协作类中。

用单个下划线开头是Python明确界定对象接口的一种方式,但存在一种常见的误解,认为可将属性和方法设置为私有的。这真是天大的误会。现在假设属性timeout被定义为以双下划线开头:

>>> class Connector:
... def __init__(self, source):
... self.source = source
... self.__timeout = 60
...
... def connect(self):
... print("connecting with {0}s".format(self.__timeout))
... # ...
...
>>> conn = Connector("postgresql://localhost")
>>> conn.connect()
connecting with 60s
>>> conn.__timeout
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
AttributeError: 'Connector' object has no attribute '__timeout'

有些开发人员使用这种方式来隐藏属性,他们以为这个示例中的__timeout是私有的,其他对象不能修改它。在上面的输出中,试图访问__timeout时引发了异常。异常为AttributeError,指出这个属性不存在。说的不是这个属性是私有的或不能访问之类的话,而是说它不存在。这应该给我们提供了线索,表明发生了别的事情,而这种行为只是副作用,并不是我们想要的效果。

实际发生的情况是,对于名称以双下划线开头的属性,Python给它重命名(这被称为名称混淆):将其重命名为_<类名>__<属性名>。在这里,属性__timeout被重命名为_Connector__timeout,因此可像下面这样访问(并修改)它:

>>> vars(conn)
{'source': 'postgresql://localhost', '_Connector__timeout': 60}
>>> conn._Connector__timeout
60
>>> conn._Connector__timeout = 30
>>> conn.connect()
connecting with 30s

请注意前面说的副作用:这个属性还存在,只是名称不同,因此前面试图访问它时引发了异常AttributeError。

Python使用双下划线根本不是为了隐藏,而是为了覆盖将被扩展多次的方法,以消除方法名发生冲突的风险。这种理由过于牵强,不足以证明值得使用这样的机制。

使用双下划线开头并非符合Python语言习惯的方法。要将属性定义为私有的,请用单下划线开头,并遵循Python约定,即这样的属性是私有的。

给属性命名时,不要以双下划线开头;同理,给方法命名时,不要以双下划线开头和结尾。

下面我们来探索相反的情况,即需要访问对象的公有属性时。对于这种属性,我们通常使用特性来定义它,2.4.2小节将探讨这一点。

在面向对象设计中,通常创建对象来表示域问题中实体的抽象。从这种意义上说,对象可封装行为和数据。通常情况下,数据的准确性决定了对象能否创建,也就是说,有些实体只能为某些数据值而存在,因此不应该允许存在不正确的值。

为此,我们创建验证方法,这种方法通常用于setter操作中。在Python,有时可以通过使用特性更紧凑地封装这些setter和getter方法。

让我们来看一个需要处理坐标的地理系统。对于表示坐标的经度和纬度,其取值必须在特定范围内才有意义,超出这个范围时,相应的坐标根本就不存在。可创建一个对象来表示坐标,但这样做时,必须确保经度和纬度的值在可接受的范围内,为此可使用特性:

class Coordinate:
    def __init__(self, lat: float, long: float) -> None:
        self._latitude = self._longitude = None
        self.latitude = lat
        self.longitude = long

    @property
    def latitude(self) -> float:
        return self._latitude

    @latitude.setter
    def latitude(self, lat_value: float) -> None:
        if lat_value not in range(-90, 90 + 1):
            raise ValueError(f"{lat_value} is an invalid value for latitude")
        self._latitude = lat_value

    @property
    def longitude(self) -> float:
        return self._longitude

    @longitude.setter
    def longitude(self, long_value: float) -> None:
        if long_value not in range(-180, 180 + 1):
            raise ValueError(f"{long_value} is an invalid value for longitude")
        self._longitude = long_value

这里使用了特性来定义经度和纬度。通过这样做,确保检索这些属性的值时,将返回存储在私有变量中的内部值。更重要的是,当用户以下面的方式试图修改这些属性的值时:

coordinate.latitude = <new-latitude-value> # similar for longitude

都将自动(而透明地)调用使用装饰器@latitude.setter声明的验证方法,并将语句右边的值(<new-latitude-value>)作为参数(在上面的代码中,为参数lat_value)传递给它。

不要为对象的所有属性都编写自定义方法get_*和set_*。在大多数情况下,让属性为常规属性足够了。仅在需要修改检索或修改属性时的逻辑时,才使用特性。

我们已经见过了对象需要保存值的情形,还知道特性如何帮助我们以一致而透明的方式管理对象的内部数据,但在有些情况下,还需根据对象的状态和内部数据做些计算。为此,使用特性大都是不错的选择。

例如,如果有一个对象,需要以特定的格式或数据类型返回值,可使用特性来执行这种计算。在前面的示例中,如果我们决定以精确到小数点后4位的方式返回坐标(而不管提供的原始数字有多少位小数),可在读取这个值的@property方法中执行这种舍入计算。

你可能发现,使用特性是一种实现命令和查询分离(CC08)的不错方式。命令和查询分离原则指出,对象的方法要么回答问题,要么执行任务,而不能同时承担这两项职责。如果方法在执行任务的同时返回一个状态,以回答有关操作执行情况的问题,它便承担了多项职责,这显然违背了函数应该做一件事情且只做一件事情的原则。

根据这个方法的名称,这还可能带来其他疑惑,让阅读者更难明白代码的意图。例如,如果一个方法名为set_email,那么代码self.set_email("a@j. com"): …是在做什么呢?将电子邮件设置为a@j.com?检查电子邮件是否已被设置为a@j.com?还是兼而有之(设置并检查状态是否正确)?

我们可以使用特性来避免这样的疑惑。装饰器@property是回答问题的查询,而@<property_name>.setter是执行操作的命令。

这个示例引出了另一条良好的建议,那就是在方法中不要做多件事情。需要赋值并检查这个值时,请将这两项操作放在两条或多条语句中。

这是什么意思呢?意思就是在刚才的示例中,应使用一个setter或getter方法来设置用户的电子邮件,并使用另一个特性来检查电子邮件。这是因为查询对象的当前状态时,通常应在不带来任何副作用的情况下返回该状态(不修改其内部表示)。对于这条规则,我们能想到的唯一例外可能是延迟特性(lazy property):只想预先计算一次,然后每次都使用计算得到的值。在其他所有情形下,都应尽可能让特性是幂等的(idempotent),同时允许一些方法修改对象的内部表示,但不要兼而有之。

每个方法都应只做一件事。如果你需要执行操作并检查状态,请使用不同的方法来分别执行这两项任务,并在不同的语句中调用这两个方法。

让我们继续在有些情况下对象需要保存值的问题。在初始化对象方面,Python提供了通用模板,这是在方法__init__中声明的。这个方法通常以下面的形式指出对象的所有属性并将属性设置为内部变量:

def __init__(self, x, y, … ):
    self.x = x
    self.y = y

从Python 3.7开始,你可以使用模块dataclasses来简化这种操作。这个模块是PEP-557引入的,在第1章介绍代码注解时用过,这里我们简要介绍一下如何用它来让代码更紧凑。

这个模块提供了装饰器@dataclass。被应用于类时,这个装饰器将把所有带注解的类属性视为实例属性,就像在初始化方法中声明了它们一样。使用这个装饰器时,将自动为类生成方法__init__,因此我们无须这样做。

这个模块还提供了field对象,可帮助我们指定某些属性的特征。例如,如果一个属性必须是可变的(如列表),你将在2.5节看到,不能给方法__init__传递默认的空列表,而必须传递None,并在__init__中检查传入的参数是否为None,如果是,就将属性设置为默认空列表。

使用field对象时,可使用参数default_factory,并向它提供类list。对于用于构建对象的可调用对象,如果它不接收任何参数,同时没有给它提供属性值,将使用参数default_factory。

既然不需要实现方法__init__,在需要执行验证时该如何办呢?存在从其他属性计算或派生属性时,又该如何办呢?为解决第二个问题,我们可以像2.4.2小节探索的那样使用特性。至于第一个问题,数据类允许你定义方法__post_init__,这个方法会被__init__自动调用,因此非常适合在这里编写后初始化逻辑。

为实际使用这些知识,我们来看一个R-Trie数据结构节点建模示例(这里的R表示radix,这意味着这种数据结构是一种基于基数R的索引树)。这种数据结构的详情及相关联的算法不在本书的探讨范围内,但为帮助你理解这个示例,这里需要指出的是,这是一种设计用于查询文本或字符串(如根据前缀找到类似或相关的单词)的数据结构。在最简单的情况下,这种数据结构包含一个值(如字符的整数表示)以及一个指向后续R个节点的数组(与链表和树一样,R-Trie也是一种递归数据结构)。在这个数组中,每个元素都隐式地定义了一个指向下一个节点的引用。例如,如果0被映射到字符a,则如果在下一个节点的索引0处包含的值不为None,就意味着有指向a的引用,因此指向的是另一个R-Trie节点。

图2.1展示了这种数据结构。

图2.1 R-Trie节点的结构

我们可编写类似于下面的代码来表示它。在这些代码中,属性名next_的末尾有一个下划线,这旨在将其与内置函数next区分开来。就这里而言,即便不添加下划线,也不会发生冲突,但如果以后需要在RTrieNode类中使用函数next(),就会有麻烦(而且通常是难以捕获的微妙错误):

from typing import List
from dataclasses import dataclass, field

R = 26


@dataclass
class RTrieNode:
    size = R
    value: int
    next_: List["RTrieNode"] = field(
        default_factory=lambda: [None] * R)

    def __post_init__(self):
        if len(self.next_) != self.size:
            raise ValueError(f"Invalid length provided for next list")

上面的示例使用了多种不同的组合。首先,我们定义了一个R为26的R-Trie,用于表示英语字母表中的字符(这对于理解代码本身不重要,但提供了更多的背景信息)。如果要存储一个单词,就从第一个字母开始,为该单词的每个字母创建一个节点。如果有到下一个字符的链接,就将其存储到数组next_中相应的位置,以此类推。

注意,在这个类中,第一个属性为size。这个属性没有注解,因此是常规的类属性,由所有节点对象共享,而不是特定对象专有的。对于这个属性,也可使用设置field(init=False)来定义,但当前的形势更紧凑。然而,如果要对属性进行注解,同时又不希望它包含在__init__中,则只能使用语法field(init=False)。

接下来是另外两个属性,它们都带注解,但考虑的方面不同。其中第一个属性(value)是一个整数,但没有默认参数,因此创建新节点时,必须通过第一个参数给这个属性提供值。第二个属性是可变的(是一个列表),它有参数default_factory,该参数被设置为一个lambda函数,而这个函数创建长度为R的列表,并将每个列表元素都设置为None。请注意,如果我们使用field(default_factory=list),也将为每个新建的对象创建一个新列表,但将无法控制列表的长度。最后,我们要进行验证,确保在创建的节点中,节点列表的长度是正确的,因此在方法__post_init__中执行这种验证。在初始化阶段,如果创建的列表长度不正确,将引发异常ValueError,从而阻止创建这类列表的任何尝试。

数据类提供了更紧凑的类编写方式,而无须在方法__init__中设置所有同名变量的模板。

如果对象无须对数据做复杂的验证或变换,可考虑使用这种创建类的方法。最后需要牢记的一点是,注解很好,但不执行数据转换。这意味着如果将属性声明为float或integer,就必须在方法__init__中执行相关的转换。以数据类的方式编写类时,不会执行这样的转换,因此可能隐藏微妙的错误。数据类适用于验证不严格且自动类型转换可行的情形。例如,完全可以定义从多种其他类型创建的对象,如从数字字符串转换为浮点数(毕竟这利用了Python的动态类型特性),条件是在方法__init__中正确地转换为所需的数据类型。

在所有需要将对象用作数据容器的情形(即使用命名元组或简单命名空间的情形)下,可能都非常适合使用数据类。在评估代码选项时,可考虑使用数据类来代替命名元组或命名空间。

Python中有默认可迭代的对象,例如,列表、元组、集合和字典不仅能够以所需的结构存储数据,还可使用for循环对其进行迭代以反复获取其中的值。

然而,在for循环中,并非只能使用内置的可迭代对象。我们还可以通过定义迭代逻辑来创建自定义的可迭代对象。

为此,也需要依赖于魔法方法。

在Python中,迭代是按照自己的协议(即迭代器协议)工作的。当你以for e in myobject:…的形式迭代对象时,Python将按下面的顺序执行两项粗略的检查。

当前对象是否包含迭代器方法__next__或__iter__。

当前对象是否是序列,包含方法__len__和__getitem__。

作为一种回调机制,序列是可迭代的,因此有两种自定义对象使其能够用于for循环中的方式。

1.创建可迭代对象

当我们试图迭代对象时,Python对其调用函数iter()。这个函数首先做的事情之一是,检查该对象是否有方法 __iter__,如果有,就执行它。

下面的代码创建一个对象,让你能够对一个日期区间进行迭代,在每次循环中生成其中的一天:

from datetime import timedelta

class DateRangeIterable:
    """An iterable that contains its own iterator object."""

    def __init__(self, start_date, end_date):
        self.start_date = start_date
        self.end_date = end_date
        self._present_day = start_date

    def __iter__(self):
        return self

    def __next__(self):
        if self._present_day >= self.end_date:
            raise StopIteration()
        today = self._present_day
        self._present_day += timedelta(days=1)
        return today

这种对象被设计成使用一对日期来创建,被迭代时,它将生成指定日期区间中的每一天,如下面的代码所示:

>>> from datetime import date
>>> for day in DateRangeIterable(date(2018, 1, 1), date(2018, 1, 5)):
... print(day)
...
2018-01-01
2018-01-02
2018-01-03
2018-01-04
>>>

其中的for循环开启对对象的新迭代。此时,Python将对对象调用函数iter(),而函数iter()将转而调用魔法方法__iter__。这个方法被定义为返回self,这表明这个对象本身是可迭代对象,因此在循环的每次迭代中,都将对对象调用函数next(),而函数next()将任务委托给方法__next__。在这个方法中,我们决定如何生成元素并每次返回一个。返回所有的元素后,我们通过引发StopIteration异常将这一点告诉Python。

这意味着实际发生的情况类似于Python不断对对象调用next(),直到出现StopIteration异常,此时Python便知道必须结束for循环了:

>>> r = DateRangeIterable(date(2018, 1, 1), date(2018, 1, 5))
>>> next(r)
datetime.date(2018, 1, 1)
>>> next(r)
datetime.date(2018, 1, 2)
>>> next(r)
datetime.date(2018, 1, 3)
>>> next(r)
datetime.date(2018, 1, 4)
>>> next(r)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File ... __next__
 raise StopIteration
StopIteration
>>>

这个示例可行,但存在一个小问题:一旦耗尽,可迭代对象将始终为空,进而引发StopIteration异常。这意味着如果在多个连续的for循环中这样做,将只有第一个循环管用,而在后面的循环中,可迭代对象将是空的:

>>> r1 = DateRangeIterable(date(2018, 1, 1), date(2018, 1, 5))
>>> ", ".join(map(str, r1))

'2018-01-01, 2018-01-02, 2018-01-03, 2018-01-04'
>>> max(r1)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
ValueError: max() arg is an empty sequence
>>>

这是迭代协议的工作方式导致的:可迭代对象创建一个迭代器,然后对这个迭代器进行迭代。在这个示例中,__iter__只是返回self,但我们可以让它在每次被调用时都创建一个新的迭代器。为修复这种问题,一种方法是新建DateRangeIterable实例,这种解决方案不完美,但可让__iter__使用每次都会创建的生成器(生成器属于迭代器对象):

class DateRangeContainerIterable:
    def __init__(self, start_date, end_date):
        self.start_date = start_date
        self.end_date = end_date

def __iter__(self):
    current_day = self.start_date
    while current_day < self.end_date:
        yield current_day
        current_day += timedelta(days=1)

这次可行了:

>>> r1 = DateRangeContainerIterable(date(2018, 1, 1), date(2018, 1, 5))
>>> ", ".join(map(str, r1))
'2018-01-01, 2018-01-02, 2018-01-03, 2018-01-04'
>>> max(r1)
datetime.date(2018, 1, 4)
>>>

差别在于,在for循环的每次迭代中,都将再次调用__iter__,而每次调用__iter__时,都将再次创建生成器。

这被称为容器可迭代对象(container iterable)。

一般而言,处理生成器时,使用容器可迭代对象是个不错的主意。
 

有关生成器的详情,将在第7章介绍。

2.创建序列

对象可能没有定义方法__iter__,但我们依然希望能够对其进行迭代。如果对象没有定义__iter__,函数将检查是否有__getitem__,如果没有,将引发TypeError异常。

序列是实现了__len__和__getitem__的对象,并希望能够从索引零开始,按顺序以每次一个的方式获取其包含的元素。这意味着你必须注意逻辑,以正确地实现__getitem__,使其能够接收这种索引,否则将无法迭代。

前面的示例具有占用内存少的优点,这意味着每次只存储一个日期,并知道如何逐个地生成日期。然而,这个示例也有缺点,那就是要获取第n个元素时,除了迭代n次,直到到达这个元素外别无他法。这是计算机科学中典型的在内存和CPU使用情况之间折中的问题。

使用可迭代对象的实现占用的内存少,但获取一个元素的时间为O(n),而使用序列的实现占用的内存多(因为必须同时存储所有的元素),但支持使用索引,因此获取元素的时间是固定的,为O(1)。

上述表示法(如O(n))被称为渐近表示法(或大O表示法),描述了算法的复杂度。简单地说,它使用以输入规模(n)为因变量的函数指出了算法需要执行的操作数。有关这种表示法的更详细信息,请参阅本章末尾列出的文献ALGO01,该文献详细地介绍了渐近表示法。

使用序列的实现可能类似于下面这样:

class DateRangeSequence:
    def __init__(self, start_date, end_date):
        self.start_date = start_date
        self.end_date = end_date
        self._range = self._create_range()

    def _create_range(self):
        days = []
        current_day = self.start_date
        while current_day < self.end_date:
            days.append(current_day)
            current_day += timedelta(days=1)
        return days

    def __getitem__(self, day_no):
        return self._range[day_no]

    def __len__(self):
        return len(self._range)

这个对象的行为如下:

>>> s1 = DateRangeSequence(date(2018, 1, 1), date(2018, 1, 5))
>>> for day in s1:
... print(day)
...
2018-01-01
2018-01-02
2018-01-03
2018-01-04
>>> s1[0]
datetime.date(2018, 1, 1)
>>> s1[3]
datetime.date(2018, 1, 4)
>>> s1[-1]
datetime.date(2018, 1, 4)

从上面的代码可知,使用负索引也可行,这是因为对象DateRangeSequence将所有操作都委托给了它包装的对象(一个列表),这是确保兼容性和行为一致的最佳方式。

决定使用这两种可能的实现中的哪种,请在内存和CPU使用情况之间权衡。一般而言,使用迭代的实现更佳(使用生成器时更是如此),但请记住需求随情况而异。

容器是实现了方法__contains__的对象。方法__contains__通常返回一个布尔值,并在出现了Python关键字in时被调用。例如,对于下面的代码:

element in container

在Python中使用时是下面这样:

container.__contains__(element)

可以想见,在正确地实现了这个方法的情况下,代码的可读性将强得多,也更符合Python语言习惯。

假设要在使用二维坐标的游戏地图上标出一些点,为此可能使用类似于下面的函数:

def mark_coordinate(grid, coord):
    if 0 <= coord.x < grid.width and 0 <= coord.y < grid.height:
        grid[coord] = MARKED

在上面的代码中,if语句中的条件检查看起来有点复杂,没有揭示代码的意图,表达力不强,最糟糕的是会导致代码重复问题(在每个需要检查边界的地方,都必须重复这条if语句)。

如果地图本身(在代码中名为grid)能够回答这个问题,结果将如何呢?更有甚者,如果地图能够将这项任务委托给更小(内聚性更强)的对象,结果又将如何呢?

通过结合使用面向对象的设计和魔法方法,可以更优雅地解决这个问题。在这个示例中,可新建一个表示网格边界的新抽象,这个抽象本身可作为一个对象,如图2.2所示。

图2.2 一个使用组合、将职责分配给不同的类并使用容器魔法方法的示例

顺便说一句,一般而言,类名为名词,且通常为单数。因此,你可能感到奇怪,上图中怎么使用了类名Boundaries呢?但只要想一想就会明白,在这里将表示网格边界的类命名为Boundaries是合理的,考虑到其用途(这里使用它来检查特定的坐标是否在边界内)就更是如此了。

使用这种设计后,可询问地图是否包含特定的坐标,而地图知道有关其边界的信息,并将查询转交给其内部协调器:

class Boundaries:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def __contains__(self, coord):
        x, y = coord
        return 0 <= x < self.width and 0 <= y < self.height

class Grid:
    def __init__(self, width, height):
        self.width = width
        self.height = height
        self.limits = Boundaries(width, height)

    def __contains__(self, coord):
        return coord in self.limits

这个实现要好得多。首先,它使用了简单的组合,并使用委托来解决问题。其中的两个对象都是内聚的,包含的逻辑最少;方法很简短,其中的逻辑不言自明:coord in self.limits非常明确地指出要解决的问题,揭示了代码的意图。

即便是从外面看,也能感受到这种实现的好处——就像是Python替我们解决了问题一样:

def mark_coordinate(grid, coord):
    if coord in grid:
        grid[coord] = MARKED

通过魔法方法__getattr__可控制获取对象属性的方式。遇到类似于<myobject>.<myattribute>这样的代码时,Python将对指定对象调用__getattribute__,从而在对象的字典中查找属性<myattribute>。如果没有找到(即对象没有要查找的属性),就调用方法__getattr__,并将属性名(myattribute)作为参数传递给它。

通过接收这个值,我们可以控制返回的值,甚至可以创建新属性等。

下面的代码演示了方法__getattr__:

class DynamicAttributes:

    def __init__(self, attribute):
        self.attribute = attribute

    def __getattr__(self, attr):
        if attr.startswith("fallback_"):
            name = attr.replace("fallback_", "")
            return f"[fallback resolved] {name}"
        raise AttributeError(
            f"{self.__class__.__name__} has no attribute {attr}"
        )

下面是对这个类对象的一些调用:

>>> dyn = DynamicAttributes("value")
>>> dyn.attribute
'value'

>>> dyn.fallback_test
'[fallback resolved] test'

>>> dyn.__dict__["fallback_new"] = "new value"
>>> dyn.fallback_new
'new value'

>>> getattr(dyn, "something", "default")
'default'

第一个调用很简单:我们只是请求对象已有的一个属性,结果为该属性的值。在第二个调用中,方法__getattr__发挥了作用,这是因为对象没有属性fallback_test,因此调用方法__getattr__,并将fallback_test传递给它。这个方法包含返回一个字符串的代码,因此我们获得的是这种变换的结果。

第三个调用很有趣,它创建了一个名为fallback_new的新属性(实际上,这个调用与dyn. fallback_new = "new value"等效),因此请求该属性时,注意到__getattr__中的逻辑并未发挥作用,因为根本就没有调用这个方法。

现在来看最后一个调用,它很有趣。有一个微妙的细节带来了翻天覆地的变化。请再看一眼方法__getattr__的代码,注意到在收到的值检索不到时,将引发异常AttributeError。这不仅是为了保持一致性(以及显示异常中的消息),而且是内置函数getattr()的要求。如果引发的是其他异常,将不会返回值默认值。

实现类似于__getattr__这样的动态方法时要小心,并谨慎地使用它们。实现__getattr__时,务必引发异常AttributeError。

在很多情况下,魔法方法__getattr__都很有用。可使用它来创建另一个对象的代理,例如,通过组合创建对象包装器时,如果要将大部分方法都委托给被包装的对象,而不是复制并定义所有这些方法,可以实现__getattr__,使其调用被包装的对象的同名方法。

另一个使用场景是需要动态计算的属性时。作者以前通过Graphene库使用GraphQL开发的一个项目中这样做过。这个库的工作方式是使用解析器(resolver)方法。基本上,属性X被请求时,都会调用方法resolve_X。由于有相关的域对象,能够解析Graphene对象所属类的每个属性X,因此实现了__getattr__,它知道到哪里去获取每个属性,这样就无须编写大量的模板代码了。

在使用魔法方法__getattr__能够避免大量重复代码和模板时,就使用它,但不要滥用,因为它会导致代码更难理解。请记住,没有显式声明而只是动态出现的属性会导致代码更难理解。决定是否使用这个方法时,务必在代码的紧凑性和可维护性之间权衡。

定义可以充当函数的对象是可能的,而且通常很方便。可调用对象的常见用途之一是创建更好的装饰器,但并非仅限于此。

当你像调用常规函数那样执行对象时,将调用其魔法方法__call__,同时传递的每个参数都将传递给方法__call__。

像这样通过对象实现函数的优点在于对象具有状态,因此在调用之间保存和维护信息。这意味着如果需要在调用之间维持内部状态,那么使用可调用对象可能是一种更方便的函数实现方式,例如,使用这种方式可实现有记忆(或内部缓存)的函数。

在Python中,如果存在对象object,语句object(*args, **kwargs)将被转换为object. __Call__(*args, **kwargs)。

想要创建像参数化函数一样工作的可调用对象或有记忆的函数时,这个方法很有用。

下面的代码使用这个方法创建了一个对象,当用户调用这个对象并提供一个参数时,将返回使用该参数值调用了这个对象多少次:

from collections import defaultdict

class CallCount:

    def __init__(self):
        self._counts = defaultdict(int)

    def __call__(self, argument):
        self._counts[argument] += 1
        return self._counts[argument]

下面是这个类的一些使用示例:

>>> cc = CallCount()
>>> cc(1)
1
>>> cc(2)
1
>>> cc(1)
2
>>> cc(1)
3
>>> cc("something")
1
>>> callable(cc)
 True

在本书后面,你将看到创建装饰器时,这个方法提供了极大的便利。

这里以备忘单的方式总结一下前几节介绍的概念,如表2.1所示。对于Python中的每种操作,都列出了它涉及的魔法方法和概念。

表2.1 Python中的魔法方法及其行为

语句

魔法方法

行为

obj[key]
obj[i:j]
obj[i:j:k]

__getitem__(key)

可订阅的对象

with obj: …

__enter__/__exit__

上下文管理器

for i in obj:
...

__iter__/__next__
__len__/__getitem__

可迭代对象
序列

obj.<attribute>

__getattr__

动态地检索属性

obj(*args,**kwargs)

__call__(*args, **kwargs)

可调用的对象

要正确地实现这些方法(以及确定需要实现哪些方法),最佳的方式是在模块collections.abc中定义抽象基类之后声明我们的类来实现相应的类。这些接口提供了需要实现的方法,让你能够更容易正确地定义类,它们还会负责正确地创建类型(对自定义对象调用函数isinstance()时,这很管用)。

前面介绍了Python中语法独特的主要特性,学习这些特性(上下文管理器、可调用对象、创建自定义序列等)后,你便能够在编写代码时充分利用Python保留关键字了,例如,可在with语句中使用自定义上下文管理器,将in运算符用于自定义容器。

经过一段时间的实践后,你将能够更熟练地使用这些Python特性,最后仅凭感觉就能编写出接口小巧而良好的抽象。时间足够长后,将出现相反的效果:你将在Python的驱使下去编程,即你会自然而然地考虑让程序的接口小巧而整洁,这样即便使用其他语言创建软件时,你也会力图去使用这些概念。例如,使用Java、C乃至Bash编程时,你也可能注意到上下文管理器有用武之地的场景。即便使用的语言没有提供现成的上下文管理器支持,你也可能编写自定义抽象来提供类似的保证。这是天大的好事,意味着这些卓越的概念已脱离具体的语言并进入了你的内心,而你能够得心应手地将其应用于不同的场景中。

所有编程语言都有其注意事项,Python也不例外。为让你对Python有更全面的认识,2.5节将简要地介绍注意事项。

要编写符合语言习惯的代码,不仅要明白语言的主要特性,还需知道一些惯用法的潜在问题以及如何避免它们。本节探讨一些常见问题,如果这些问题让你措手不及,可能会导致调试时间延长。

本章讨论的大多数要点都是要完全避免的,可以肯定地说,在几乎任何场景中,都不应存在反模式(这里是反惯用法)。因此,如果你在代码库中发现这里指出的问题,请按建议的方式进行重构。如果你在代码审核过程中发现这些问题,就意味着需要对代码进行修改。

简单地说,不要将可变对象用作函数的默认参数,否则结果将出乎意料。

请看下面这个错误的函数定义:

def wrong_user_display(user_metadata: dict = {"name": "John", "age": 30}):
    name = user_metadata.pop("name")

    age = user_metadata.pop("age")

    return f"{name} ({age})"

实际上,这里存在两个问题,除可变的默认参数外,函数体还修改了一个可变对象,带来了副作用。但主要问题是参数user_metadata的默认值。

实际上,仅在第一次没有指定参数的情况下调用时,这个函数才能正确地运行。第二次这样做(调用时没有显式地给user_metadata传递值时),将引发KeyError异常,如下所示:

>>> wrong_user_display()
'John (30)'
>>> wrong_user_display({"name": "Jane", "age": 25})
'Jane (25)'
>>> wrong_user_display()
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File ... in wrong_user_display
 name = user_metadata.pop("name")
KeyError: 'name'

原因很简单:在函数定义中,通过将包含默认数据的字典赋给参数user_metadata,因这个字典实际上只被创建一次,而变量user_metadata将指向它。Python解释器分析这些代码时,发现这个函数的签名中有一条语句,它创建一个字典并将其赋给参数user_metadata。因此,这个字典只会被创建一次,它在程序的整个生命周期内都相同。

然后,函数体修改了这个字典,而只要程序还在运行,这个字典都将驻留在内存中。当我们给参数user_metadata传递了值时,这个值将替换前面创建的默认参数值。再次调用这个函数且没有给参数user_metadata指定值时,由于前一次运行时修改了默认参数值——删除了所有的键,因此默认参数值不再包含任何键。

这个问题很容易修复,只需将参数默认值设置为None,并在函数体中重新设置参数的默认值即可。由于每个函数都有自己的作用域和生命周期,因此每当user_metadata为None时,都将把它重新设置为前述默认字典:

def user_display(user_metadata: dict = None):
    user_metadata = user_metadata or {"name": "John", "age": 30}

    name = user_metadata.pop("name")

    age = user_metadata.pop("age")

    return f"{name} ({age})"

结束本节之前,我们来介绍一下扩展内置类型时的注意事项。

扩展列表、字符串和字典等内置类型的正确方式是通过模块collections。

例如,如果你创建的类直接扩展dict,结果可能出乎意料,原因是在CPython(一种C 优化)中,类的方法不(也不应该)彼此调用,因此如果你覆盖其中一个方法,其他方法不会将这一点反映出来,导致结果出乎意料。例如,你可能覆盖__getitem__,但当你使用for循环迭代对象时,你在这个方法中定义的逻辑并没有发挥作用。

通过使用collections.UserDict,可解决上述所有问题,它给实际字典提供了透明的接口,因此更健壮。

假设我们要创建一个最初由数字创建的列表来将值转换为字符串并添加前缀。下面的方法看起来解决了这个问题,但实际上是错误的:

class BadList(list):
    def __getitem__(self, index):
        value = super().__getitem__(index)
        if index % 2 == 0:
            prefix = "even"
        else:
            prefix = "odd"
        return f"[{prefix}] {value}"

乍一看,这个对象的行为与期望的一致,但如果尝试迭代它(它毕竟是个列表),将发现结果并非我们想要的:

>>> bl = BadList((0, 1, 2, 3, 4, 5))
>>> bl[0]
'[even] 0'
>>> bl[1]
'[odd] 1'
>>> "".join(bl)
Traceback (most recent call last):
...
TypeError: sequence item 0: expected str instance, int found

函数join试图迭代这个列表(对其运行for循环),但期望返回的值为字符串。我们以为这没问题,因为我们修改了方法__getitem__,使其总是返回一个字符串。但从结果可知,根本没有调用我们修改后的__getitem__版本。

这个问题实际上是CPython的一个实现细节导致的,在诸如PyPy等平台中不会出现(有关PyPy和CPython的差异,请参阅本章末尾列出的参考资料)。

尽管如此,我们编写的代码应该是可移植的,并与所有实现都兼容,所以我们将扩展UserList(而不是list),以修复这种问题:

from collections import UserList

class GoodList(UserList):
    def __getitem__(self, index):
        value = super().__getitem__(index)
        if index % 2 == 0:
            prefix = "even"
        else:
            prefix = "odd"
        return f"[{prefix}] {value}"

现在情况看起来好多了:

>>> gl = GoodList((0, 1, 2))
>>> gl[0]
'[even] 0'
>>> gl[1]
'[odd] 1'
>>> "; ".join(gl)
'[even] 0; [odd] 1; [even] 2'

至此,你熟悉了所有主要的Python概念,同时不但知道如何编写符合Python语言习惯的代码,还知道如何避开一些陷阱。2.6节是一些补充材料。

结束本章前,要简要地介绍一下异步编程,虽然它严格地说与整洁代码没有关系,但异步代码日益流行,同时要高效地处理代码,必须能够阅读并明白它,因此能够读懂异步代码很重要。

不要直接扩展dict,而应使用collections.UserDict。自定义列表时,请使用collections.UserList,自定义字符串时,请使用collections.UserString。

异步编程与整洁代码没有关系,因此本节介绍的Python特性并不能让代码库更易于维护。本节简要地介绍Python中使用协程的语法,因为它对读者来说可能有用,同时本书后面可能出现与协程相关的示例。

异步编程背后的理念是,让代码的某些部分能够挂起,以便其他部分能够运行。通常,执行I/O操作时,很可能想让代码在此期间继续运行,以便将CPU用于执行其他任务。

这改变了编程模型。不再同步地调用,我们将以一种由事件循环调用的方式编写代码,事件循环负责调度在相同的进程和线程中同时运行所有的协程。

因此,我们创建一系列协程,它们将被添加到事件循环中。事件循环启动时,将挑选一些协程,并通过调度让它们运行。协程需要执行I/O操作时,可触发这种操作并通知事件循环,而事件循环将重新控制该协程,并在I/O操作期间调度另一个协程。在某个时点,事件循环将让前述协程从停止的地方重新开始执行。请记住,异步编程的优点在于不会因为I/O操作而阻断,这意味着在执行I/O操作期间,可跳到代码的其他地方执行并在合适的时候回来,但这并不意味着有多个进程在同时运行。执行模型还是单线程的。

为了实现Python异步编程,曾经(现在仍然)有限多可使用的框架。但在较老的Python版本中,没有专门的异步编程语法,因此框架的工作方式有点复杂,或者说不是一眼就能看明白的。从Python 3.5开始,引入了专门的协程声明语法,这改变了编写Python异步代码的方式。在Python 3.5推出稍前一点时间,在标准库中引入了默认的事件循环模块asyncio。这是Python发展历程中的两个里程碑,让异步编程简单得多了。

本节使用模块asyncio来进行异步处理,但并非只能使用它。你可以使用任何相关的库(除标准库外,还有很多其他的库,如trio、curio等)来编写异步代码。可将Python提供的协程编写语法视为API,因此只要库遵循了这个API的规定,你就可以使用它,而无须改变声明协程的方式。

协程类似于函数,与异步编程存在语法上的不同,在定义协程时,需要在其名称前面加上async def。在协程内部需要调用其他协程(可以是我们自己编写的,也可以是第三方库中定义的)时,通常需要在调用语句开头加上关键字await。关键字await让事件循环重新获得控制权,因此事件循环将恢复执行,而协程将停止执行,等待非阻断操作执行完毕后继续;与此同时,将运行另一部分代码(事件循环将调用另一个协程)。在某个时点,事件循环将再次调用原来的协程,而该协程将从原来停止的地方(紧跟在await语句后面的那行)开始执行。

我们在代码中定义的典型协程的结构如下:

async def mycoro(*args, **kwargs):
    # … logic
    await third_party.coroutine(…)
    # … more of our logic

前面说过,有一种新的协程定义语法。这种语法的一个不同之处是,不同于常规函数,当我们调用使用这种语法定义的协程时,不会运行其中的代码,而将创建一个协程对象。这个对象将包含在事件循环中,而你必须在某个时候await它,否则其代码永远不会执行:

result = await mycoro(…) # doing result = mycoro() would be erroneous

别忘了await你定义的协程,否则其代码永远都不会执行。请注意asyncio发出的警告。

前面说过,Python中有多个异步编程库,还有像前面定义的那样运行协程的事件循环。特别是,对于asyncio,有一个运行协程直到它结束的内置函数:

import asyncio
asyncio.run(mycoro(…))

有关Python协程工作原理的详情不在本书的探讨范围内,但这里的简介可让读者对相关的语法更熟悉。也就是说,协程在技术上是建立在生成器的基础之上的,而生成器将在第7章详细介绍。

本章探讨了Python的主要特性,旨在让你明白Python那些不同于其他语言的特性。在此过程中,还探讨了各种Python方法和协议以及它们的内部机制。

不同于第1章,本章更专注于Python。本书的一个要点是,整洁代码远不止遵循格式设置规则这么简单,虽然这对优良代码库来说必不可少。这只是必要条件,而不是充分条件。在接下来的几章中,我们将介绍与代码关系紧密的理念和原则,让你能够改善软件解决方案的设计和实现。

本章探讨了Python的核心——其协议和魔法方法。现在你应该清楚地知道,要编写出符合Python语言习惯的代码,不仅要遵循格式设置约定,还要充分利用Python提供的所有特性。这意味着通过使用特定的魔法方法或上下文管理器,可编写出更容易维护的代码,而通过使用推导式和赋值表达式,可编写出更简洁的语句。

本章还介绍了异步编程,你现在应该能够熟练地阅读Python异步代码。这很重要,因为异步编程正日益流行,同时熟悉异步编程有助于理解本书后面将探讨的主题。

在第3章中,我们会把这些概念付诸应用,将通用的软件工程概念同Python实现它们的方式关联起来。

在下面列出的参考资料中,可找到有关本章探讨的一些主题的更详细信息。与Python索引工作原理相关的决策是根据文献EWD831做出的,该文分析了数学和编程语言中的多种区间替代方案。

EWD831:Why numbering should start at zero

PEP-343:The "with" statement

CC08:Robert C. Martin的著作Clean Code: A Handbook of Agile Software Craftsmanship

The iter()function

Differences between PyPy and CPython

The Art of Enbugging

ALGO01:Thomas H. Cormen、Charles E. Leiserson、Ronald L. Rivest和Clifford Stein的著作Introduction to Algorithms第3版(MIT出版社)。

读者服务:

微信扫码关注【异步社区】微信公众号,回复“e58811”获取本书配套资源以及异步社区15天VIP会员卡,近千本电子书免费畅读。


相关图书

深度学习的数学——使用Python语言
深度学习的数学——使用Python语言
动手学自然语言处理
动手学自然语言处理
Web应用安全
Web应用安全
Python高性能编程(第2版)
Python高性能编程(第2版)
图像处理与计算机视觉实践——基于OpenCV和Python
图像处理与计算机视觉实践——基于OpenCV和Python
Python数据科学实战
Python数据科学实战

相关文章

相关课程