Python-高级实践指南-全-

Python 高级实践指南(全)

原文:Practices of the Python Pro

译者:飞龙

协议:CC BY-NC-SA 4.0

第一部分. 为什么这一切都很重要

当你开始学习新主题时,考虑大局很重要,以框架和聚焦你的思考。本书的第一部分将使你熟悉 Python 在现代软件开发中的重要性,并提供一个框架来理解软件设计原则和实践的价值,以进一步你在编程职业中的发展。

无论你是编程新手,正在寻找你想要学习的下一门语言,还是试图提高你的技能以应对更大的项目,这本书的这一部分应该会让你相信 Python 是一个很好的选择。

第一章. 更大的图景

本章涵盖

  • 在复杂软件项目中使用 Python

  • 熟悉软件设计的高级流程

  • 认识到何时应该投资于设计

我很高兴你选择了这本书;这意味着你想要在软件开发方面迈出下一步。也许你想要进入软件行业,或者也许你想要使用软件来补充你的工作。也许你甚至曾经被支付编写软件。恭喜你——你已经是一名专业人士了!像专业人士一样编码只是意味着学习那些将帮助你长期构建和维护大型软件的概念和策略。

通过继续阅读,你正在承诺学习 Python 如何帮助你思考宏大,并从编写实用脚本过渡到编写复杂软件。我将帮助你打下基础,以便你可以构建你的软件开发技能。

在你的整个职业生涯中,你可能会接触到不断增长的软件复杂性。这可能是一些你逐渐构建的软件,或者它可能是在最不合适的时候强加给你的现有代码堆。无论哪种情况,你都会希望有一套工具在你的掌握之中,这样你就可以准备好理解它。

通过阅读这本书,你将获得关于复杂软件系统如何工作的经验和熟悉度,这样你就可以利用这些专业知识来改进它们。你将学习如何在构建之前构想这些类型的系统,以最小化惊喜和风险。一旦你完成这本书,你应该能够带着新的热情一头扎进你现在感到困惑或焦虑的事情中。

你将学习如何将代码的复杂性放入易于理解、可重用的包装器中。你将确保你的代码根据其目的进行整洁的组织,这样你就可以记住是什么。这些工具将帮助你帮助自己,并在新旧项目中变得更加高效!

我将使用 Python 作为本书示例的载体。Python 已经是我最喜欢的编程语言有一段时间了,我希望它也是你的。如果你还没有机会深入了解 Python,请花时间先去了解它。《快速 Python 书》,第三版,由 Naomi Ceder 编著(Manning,2018),是一个很好的入门起点。

本书中的所有示例都是基于 Python 3 的最新版本编写的。我强烈建议你在继续之前安装 Python 3。如果你需要有关安装过程的指导,请参阅附录。

巨大的分歧

你使用的是 Python 2 还是 Python 3?尽管 Python 3 已经出现了一段时间——相当长一段时间,在 2008 年,但仍有相当多的人仍在使用 Python 2。为了更好地理解这一点,请记住,那一年 Flo Rida 的“Low”和 Alicia Keys 的“No One”都位居排行榜首位。

Python 3 带来了几个与先前版本不兼容的更改,这些更改的影响至今仍在感受到。其中许多更改已被回滚到 Python 2 的后续版本,以简化过渡。在大型项目中使用 Python 2 的开发者有一些障碍需要克服,但有些人似乎要把他们的 Python 2 软件带到坟墓里去。

如果你需要一些说服来了解为什么 Python 是一种好的语言选择,请继续阅读。

1.1. Python 是企业级语言

历史上,Python 编程语言一直被视为一种脚本语言。开发者对其性能和适用性持负面看法,选择其他语言来满足他们的企业软件需求。Python 被用于小型数据处理工作或个人工具,但企业软件仍然是 Java、C 或 SAS 等语言的工作。

1.1.1. 时代在变化

在过去几年里,Python 无法胜任企业使用的观念发生了巨大的转变。Python 现在几乎被应用于所有学科,从机器人学到机器学习再到化学。Python 为过去十年中最成功的互联网公司提供动力,并且没有任何放缓的迹象。

1.1.2. 我喜欢 Python 的几点

Python 就像一股清新的空气。像许多我的朋友和同事一样,我在学校学了很多 C++,还学了一点 MATLAB、Perl 和 PHP。我最初是用 PHP 构建网站,甚至一度尝试过 Java Spring 版本。PHP 和 Java,正如许多成功公司所证实的那样,在这个领域是完美无缺的语言,但不知何故它们并没有吸引我。

我发现 Python 在语法方面表现出色;这经常被引用为其日益流行的一个原因。其语法比其他语言更接近书面英语,因此对于编程新手以及不喜欢其他语言冗长的人来说,它可能更容易接近。我曾看到人们在请求 Python 打印'Hello world!'并看到它确实这样做时,脸上洋溢着喜悦。即使现在,我偶尔也会遇到那些时刻,当我发现一个我之前不知道的标准库模块时。

Python 易于阅读。这转化为即使是经验丰富的开发者也能更快地进行开发。Instagram 的工程师 Hui Ding 敏锐地指出:“性能速度不再是首要担忧。上市速度才是。”^([1]) Python 能够实现快速原型设计,正如你稍后将会看到的,它还有将软件固化成稳健、可维护的代码库的能力。这正是我喜欢 Python 的原因。

¹

Michelle Gienow,“Instagram 顺利过渡到 Python 3”,The New Stack,mng.bz/Ze0j。这是一篇关于 Instagram 从 Python 2 过渡到 Python 3 的精彩文章。

1.2.Python 是一种教学语言

2017 年,Stack Overflow 透露,在高收入国家,与 Python 相关的问题占平台所有问题的 10%以上,超过了所有其他主要编程语言.^([2]) Python 是目前增长最快的编程语言,这也是为什么它是一个方便的教学工具。蓬勃发展的开发者社区和在线上可获取的大量信息意味着,在接下来的几年里,它将是一个安全的选择。

²

参见 David Robinson,“Python 的惊人增长”,Stack Overflow 博客,mng.bz/m48n

在这本书的整个过程中,我将假设你具备 Python 语法、数据类型和类的基础知识。你已经见过并玩过它,但不需要因此获奖。(他们有那种奖项吗?)。任何有了一点编程经验,并且花几个小时自学和使用 Python 的人,都应该没有问题理解这本书中的代码。你将带着 Python 作为设计更大、更好软件的渠道来学习这本书。话虽如此,你在这里学到的,如果有幸,将适用于你选择的任何语言。你会发现许多软件设计概念超越了任何特定的技术。

1.3.设计是一个过程

虽然单词“设计”通常描述一个有形的成果,但设计的价值在于达到该成果的过程。考虑一下时尚设计师。他们的最终目标是创造出最终会出现在穿着者手中的作品。然而,为了设计师能够将下一个伟大的趋势带给消费者,涉及了许多步骤和人员(见图 1.1)。

图 1.1.时尚设计师的工作流程。设计师与许多人合作以完成任务。

设计师通常与面料供应商合作,以获取他们想要的款式、合身度和质感的正确材料。一旦他们设计了一件作品,他们就会与制版师合作,制作不同尺寸的产品。一旦生产出这些产品,它们就会被送到零售店,顾客最终可以购买这些衣服。这可能会花费数月时间!

就像时尚、艺术和建筑一样,软件设计是绘制系统计划的过程,以便可以执行以产生最大效果。在软件中,这些计划帮助我们理解数据的流动和在该数据上运行的系统组件。图 1.2 图 1.2 显示了电子商务工作流程的高级图,概述了用户如何通过步骤进行。

图 1.2. 电子商务网站的流程图。系统执行多项活动以完成任务。

图片

想在线购买东西的客户通常会登录,输入他们的送货信息,并支付商品。这为公司创建了一个订单,以便处理和发货。这类工作流程需要大量的设计来确保。运行这些系统的软件处理复杂的规则、错误状态检查等。而且它必须做到无懈可击,因为用户对错误很敏感。他们可能会放弃或甚至积极反对对他们不起作用的软件产品。

1.3.1. 用户体验

看起来简洁明了的流程通常需要大量的工作来创建。为所有用例创建运行顺畅的软件需要市场研究、用户测试和稳健的设计。一些产品对于预期的用例运行良好,但公司可能在发布后会发现用户正在用产品做完全意想不到的事情。软件可能适用于那个用例,但它并没有为此进行优化。设计中可能存在需要考虑的空白。

当软件运行良好时,我们几乎注意不到。使用软件产品的用户喜欢无摩擦的体验,开发软件的人也是如此。与未维护的代码一起工作可能会导致挫折,而不知道如何修复它可能会导致愤怒!深呼吸。

摩擦

想象在当地冰球场上滑冰。当你 Zamboni 完成平滑处理之后立即上冰,滑冰几乎不需要用力。你可以稍微倾斜每个步骤,让滑冰鞋做功。过了一段时间,每个人的滑冰鞋开始切割冰面。滑行变得更加困难;你必须用力推动每个步骤。

用户体验中的摩擦就像粗糙的冰面。用户可能仍然能够完成他们试图做的事情,但这并不意味着它是有趣的。无摩擦的体验是指引导用户轻松前进,直到他们几乎注意不到他们在工作。

假设你被分配更新你公司中的报告软件。它目前在其导出文件中使用逗号分隔值(CSV),但用户一直在谈论他们多么喜欢制表符分隔值(TSV)。你想,“我只是去更新输出函数中的分隔符,从逗号改为制表符!”现在想象一下打开代码,发现输出行都是这样构建的:

print(col1_name + ',' + col2_name + ',' + col3_name + ',' + col4_name)
print(first_val + ',' + second_val + ',' + third_val + ',' + fourth_val)

要将输出从 CSV 更改为 TSV,你必须在六个地方将逗号更改为制表符。这留下了一些人为错误的空间;也许你看到了打印标题的第一行,但错过了打印数据行的行。为了让下一个使用这段代码的开发者更友好,你可以在需要的地方存储分隔符值。你也可以使用 Python 函数来简化字符串构建。然后,当用户最终决定他们更喜欢逗号时,更改只需在一个地方进行:

DELIMITER = '\t'
print(DELIMITER.join([col1_name, col2_name, col3_name, col4_name]))
print(DELIMITER.join([first_val, second_val, third_val, fourth_val]))

通过坐下来从高层次思考整个系统,你将开始注意到之前没有看到的粗糙区域,或者意识到你之前的一些假设并不准确。你可能会一次又一次地惊讶自己,这种启发可以激励你继续努力。一旦你开始看到重复的模式和常见的错误,你就可以开始识别哪些刺可以拔除。这可以相当有治疗作用。

1.3.2. 你已经走过这条路

无论你是否意识到,你几乎肯定在以前经历过设计过程。想想你曾经停下来,暂时回顾一下你试图实现的目标。你是否注意到让你改变方向的事情?你是否看到了做事更有效的方法?

这些小瞬间本身就是设计过程。你评估软件的目标和当前状态,并将它们结合起来,以指导你下一步的行动。在软件过程中有意地早期生成这些时刻将带来短期和长期的好处。

1.4. 设计促进软件质量提升

我会坦白告诉你:好的设计需要时间和努力。这不是你可以免费得到的东西。尽管将设计思维嵌入到你每天进行的开发工作中是理想的,但在编写(或重写)代码之前进行独立的设计步骤是至关重要的。

规划软件系统将帮助你发现存在风险的区域。你可以确定敏感用户信息可能暴露在漏洞中。你还可以看到系统中哪些部分可能是性能瓶颈或单点故障。

通过简化、合并或拆分系统的某些部分,你可以节省时间和金钱。当单独查看一个组件时,这种收益很难识别,因为不清楚其他组件是否在做类似的工作。将系统作为一个整体来观察,可以使你重新分组,并对前进的道路做出明智的决策。

1.4.1. 软件设计中的考虑因素

我们经常考虑为“用户”编写软件,但软件可以服务于多个受众。有时“用户”是使用软件所构成产品的个人,而有时“用户”是试图开发软件额外功能的个人。通常,您是您软件的唯一用户!通过从这些不同的视角看待软件,您可以更好地确定您想要构建的软件的品质。

以下是一些消费者用来评估他们用例的软件的常见方面:

  • 速度—软件尽可能快地完成其工作。

  • 完整性—软件使用或创建的数据受到损坏的保护。

  • 资源—软件高效地使用磁盘空间和网络带宽。

  • 安全性—软件用户只能读取和写入他们授权的数据。

此外,以下是一些作为开发者您可能希望实现的结果:

  • 松散耦合—软件的组件之间没有复杂的依赖关系。

  • 直观性—开发者可以通过阅读来发现软件的本质以及它是如何工作的。

  • 灵活性—开发者可以将软件适应相关或类似的任务。

  • 可扩展性—开发者可以在不影响其他方面的情况下添加或更改软件的一个方面。

追求这些成果往往涉及现实世界的成本。例如,承诺提高软件的安全性可能意味着您在开发上需要花费更多时间。由于开发时间可能会增加您的开支,您可能选择以更高的价格出售您的软件。有效的规划和对这些成果之间权衡的理解将帮助您最小化您和您的消费者所承担的成本。

编程语言通常不会直接解决这些考虑因素中的大多数;它们只是提供工具,使开发者能够满足这些需求。例如,高级语言如 Python,允许开发者用类似人类语言而不是机器语言编写代码,提供一些防止内存损坏的保护。Python 还通过其语法鼓励使用高效的数据类型;您将在第四章中了解更多关于这一点。

话虽如此,我们仍然可以在自己身上做很多工作,因为即使是 Python 也无法预测开发者可能会犯的所有错误。这就是为什么仔细设计和整体考虑系统将有所帮助。

1.4.2. 有机生长的软件

与您当地农民市场的产品不同,有机生长的软件对您的健康并无益处。在软件的语境中,随着时间的推移有机生长的系统很可能是一个适合重构的系统。重构代码是更新代码的过程,使其设计更优,并反映您最新的最佳实践。这可能包括提高性能、可维护性或可读性。

如同术语所暗示的,自然成长的软件已经成为一个有机体,拥有自己的神经系统和大脑。其他软件的碎片可能被粘贴到上面(通常不止一次),多年未使用的函数可能在其中某个地方腐烂,可能有一个函数做了大约 150%的工作。选择何时重构这样的系统可能很困难,但通常是在你大喊“它活起来了!”的那个时刻之前。

这种现象的一个例子显示在图 1.3 中,它描述了一个电子商务网站的结账过程。它涉及几个重要步骤:

  1. 确定产品在库存中可用。

  2. 根据产品的价格,计算小计。

  3. 根据购买区域,计算:

    1. 运输和处理

  4. 根据当前的促销活动,计算任何折扣。

  5. 计算最终总额。

  6. 处理支付。

  7. 履行订单。

图 1.3。一个自然成长的电子商务系统

图片

在这个系统中,一些步骤被清晰地分开。还不错!但中间有一段粗糙的地方。看起来所有与价格相关的逻辑都发生在一个大块中。如果在这个过程中有错误,可能很难确切地知道是哪个步骤包含了错误。你可能看到价格是错误的,但可能需要浏览大量代码才能找出原因。支付处理和履行也被合并在一起,所以如果出现错误的时间不当,你可能会成功处理支付,但永远不会履行订单。这会让顾客感到不满。

使这个工作流程更健壮的一个好方法是将其逻辑步骤分开(图 1.4)。如果每个步骤都由自己的服务处理,那么特定步骤的服务只需要关注一项工作。库存服务跟踪库存中有多少商品。定价服务知道每个商品的成本和税费。这使每个步骤与其他步骤隔离开来,使得每个步骤不太可能受到错误的影响。

图 1.4。一个精心设计的电子商务系统可能的样子

图片

设计通常允许你看到系统的现有部分可以被分解成更简单的部分。这种分解的想法是我们将在接下来的章节中更深入探讨的工具之一。记住,这项工作几乎永远不会完成;重构和代码重设计会不断发生。但是,通过内化你在本书中学到的一些技术,你会发现随着时间的推移,在特定项目中这些任务会变得更容易、更快。保持敏锐,并认识到改进现有代码的机会!

1.5. 何时投资于设计

我们倾向于将精力集中在创建新的软件来完成任务。但随着项目的增长,我们忘记了实现工作代码的实施,直到它阻碍了我们的工作。有些代码经常阻碍工作,以至于它带来的麻烦比价值还要多。在这种情况下,项目积累了技术债务,因为必须进行额外的工作才能保持生产力。

当一段复杂的代码经常阻碍工作,并且当它阻碍时处理起来更加困难,你应该分配更多的时间去清理这些混乱。这通常是基于系统构建后的直觉判断,但有时你可以在早期就发现问题。

在前期进行有意识的软件设计可以节省时间和减少麻烦。当软件足够灵活,可以扩展到新的用例时,与之合作会变得愉快,因此在编写代码之前对系统进行思考是提高生产力的好方法。我喜欢将其视为一种技术投资,因为它是在前期投入工作,以期待未来的回报。

你可能遇到过这种情况是在一个框架中。框架是一系列代码的大库,作为实现某些目标的指南。一个框架可能帮助你使你的网站看起来很棒,或者它可能帮助你构建用于在视频中检测人脸的神经网络。无论其功能如何,框架都旨在提供你可以用来创建自己独特产品的构建块。为了使框架有用,它必须足够灵活,能够处理各种用例,并且足够可扩展,以便你可以编写原始开发者没有考虑到的新的功能。Python 开发者已经创建了众多框架:用于进行 HTTP 调用的 Requests;用于网络开发的 Flask 和 Django;以及用于数据分析的 Pandas,仅举几例。从某种意义上说,你编写的许多代码本身就是框架。它提供了一些你可能需要反复使用或在不同目的上使用的有用功能。考虑到这些事实来编写代码,将帮助你避免给自己设置障碍。

设计软件的过程,无论是回顾一个项目还是开始一个新的项目,都是一种投资。希望这种投资的回报将是能够适应开发者和消费者需求的代码,而不会带来过多的开销或挫败感。有时,一些代码状况不佳,但可能不值得投入大量时间和精力进行良好的设计。代码的使用频率或更新频率是一个重要的考虑因素,因为花费数周时间改进在其生命周期中只使用一两次的脚本并不经济。

1.6. 新的开始

当你开始更加关注设计时,改进的机会可能会变得压倒性。有太多东西要学习和做,试图一次性管理所有这些事情不会很有趣。逐步承担设计概念,直到它们成为你思维模式的一部分,是更可持续的成功方法。在这本书中,我将在每一章介绍一小组概念,你可以随时回顾特定的章节来巩固你所学到的知识。

1.7. 设计是民主的

到目前为止,你很可能主要独立完成项目。如果你在课程中参与了任何编码,你可能被要求自己编写所有代码。在现实世界中,大型项目很少是这样。在为商业用途编写软件的公司中,可能有数十名开发者正在为一个单一的产品工作。每个开发者都有独特的一套经验,这会影响他们选择工作的方式。这种观点的多样性可以导致更健壮的系统,因为之前的错误、失败和成功经验都会影响即将到来的工作的方向。

从其他开发者那里获取反馈对你来说是有益的,尤其是在早期阶段。做事情很少有唯一的方法,所以学习多种方法,包括它们的优缺点,将使你能够做出明智的选择,或者至少在所有其他条件相同的情况下选择感觉最好的方法。某些方法可能适用于某个用例,但不适用于另一个,所以了解多种方法将提高你的生产力。

如果你没有与活跃的开发者团队一起工作的特权,检查一些开源项目是了解软件协作性质的另一途径。寻找开发者就如何完成任务存在分歧(建设性地!)的讨论,并看看在解决问题的过程中考虑了哪些因素。导致解决方案的思维过程通常比开发者选择的特定算法更重要。这种推理和讨论能力将帮助你克服比知道特定算法更多的困难。

1.7.1. 专注

写软件时很容易陷入其中。想想你兴奋地想要完成某件事的时候。你可能急于看到你的代码工作,在这种情况下,坐下来仔细编写完美的代码往往很困难。

当处理小型脚本或进行一些探索性工作时,快速反馈周期对于保持生产力非常有价值。我经常在 Python 的读取-评估-打印循环(REPL)中做这类工作。

REPL

REPL——发音为 REH-pull——是当你终端中输入python时隐藏在>>>后面的东西。它读取你输入的内容,评估它,打印结果,并等待这一切再次发生(循环)。许多语言都提供了 REPL,以便开发者可以交互式地测试几行代码。

但要注意:在某个时候,编写快速的一行代码并看到它如何改变程序输出的来回操作会变得乏味。你将想要在文件中编写更长的或更持久的代码,并使用解释器运行它。每个人的阈值都不同;我通常在我想要重用之前写过的代码时达到我的阈值,那是在我历史记录中 15 行之前。

列表 1.1 中的示例展示了如何处理转换数据字典的过程。给定一个将美国各州映射到其首都城市的字典,你想要生成一个按字母顺序排列的所有首都城市的列表。方法大致如下:

  1. 从字典中获取城市值。

  2. 对城市值进行排序。

列表 1.1. 获取按字母顺序排列的美国首都
>>> us_capitals_by_state = {                                 *1*
    'Alabama': 'Montgomery',
    'Alaska': 'Juneau',
    ...
}
>>> capitals = us_capitals_by_state.values()                 *2*
dict_values(['Montgomery', 'Juneau'])
>>> capitals.sort()                                          *3*
Traceback (most recent call last):
   File "<stdin>", line 1, in <module>
AttributeError: 'dict_values' object has no attribute 'sort'
>>> sorted(capitals)                                         *4*
['Albany', 'Annapolis', ...]
  • 1 一个将州名映射到首府名的字典

  • 2 只需首府名称

  • 3 哎呀!这不是“列表”,因此没有“排序”方法可用。

  • 4 使用“sorted”创建的新(排序后的)列表,它接受任何可迭代对象

这个任务并不太难;在过程中只有一个失误。但随着项目的增长和你要做的更改范围的扩大,退后一步并提前规划你的行动是有帮助的。

一些深思熟虑的计划通常会节省你大量的时间,因为你不会在开发过程中前进两步,后退一步。如果你提前这样做,你还可以养成在发生时识别重构机会的好习惯,而不是在你走得更远的时候。当我处于这种模式时,我通常会转向编写真正的 Python 模块中的代码,即使我还在编写一个相当短的脚本。这促使我放慢脚步,在开发过程中始终牢记更大的目标。

在州首府代码的情况下,想象你会在许多场景下需要州首府的列表。你可能在注册表单、发货表单或账单表单上需要它。为了避免重复进行相同的计算,你可以将这个计算封装在一个函数中,并在需要时调用它,如下面的列表所示。

列表 1.2. 将州首府逻辑封装在函数中
def get_united_states_capitals():               *1*
    us_capitals_by_state = {'Alabama': ...}
    capitals = us_capitals_by_state.values()
    return sorted(capitals)
  • 1 与列表 1.1 中的代码相同,但封装在函数中

现在你有一个可重用的函数。但看这个函数,你可以看到它操作的是常量数据,但每次调用时都会进行一些计算。如果这个函数在程序中被频繁调用,可以通过进一步重构来提高其性能。

事实上,结果证明根本不需要函数。你可以通过将结果存储在常量中以供以后使用,同时仍然只进行一组计算来实现可重用性,如下面的列表所示。

列表 1.3. 重构后的代码揭示了一个更简洁的解决方案
US_CAPITALS_BY_STATE = {'Alabama': 'Montgomery', ...}    *1*
US_CAPITALS = sorted(US_CAPITALS_BY_STATE.values())      *2*
  • 1 常量数据,只定义一次

  • 2 也同样是常量,不需要函数;只需引用“US_CAPITALS”。

这还有额外的优点,即在保持可读性的同时,可以将代码行数减半。

我们刚刚经历的过程,从最初的问题陈述到最终的解决方案,是一个设计过程。随着你的进步,你可能会发现你可以越来越早地识别出改进的领域。最终,你甚至可能决定开始绘制代表几个复杂软件组件的高级图表,使用你的图表在编写任何代码之前评估机会和风险。当然,并不是每个人都这样做,所以你需要将本书中学到的知识应用到能给你带来最大价值的地方。

此时你可能会有把一切推翻,重新开始项目的冲动,但请稍等!随着你阅读本书,你会发现设计和重构软件的过程不仅相互关联,实际上它们是同一枚硬币的两面。做一件事情往往意味着做另一件事情,它们都是贯穿项目生命周期的连续过程。没有任何事物或任何人完美无缺,因此,在开始感到摩擦时,经常回顾代码是非常有价值的。

考虑到这一点,深呼吸并放松。还有很多内容要介绍。

1.8. 如何使用本书

一般而言,这本书最好从头到尾阅读。我已经安排了本书的各个部分,以便它们相互关联;后面的部分使用了前面部分的概念。在第三部分中,每个章节都是基于你在第六章中开始的一个软件项目。但如果你已经了解某些内容,可以自由地浏览或跳过这些章节,但要注意,你可能需要不时地翻回前面的章节。

大多数章节都会让你在软件开发的常规中融入新的概念或实践。如果你发现某个章节的概念特别有价值,你可能想将这些概念应用到你的项目中,直到你熟练掌握它们。一旦你感到舒适,你就可以回来阅读下一章。

请记住,示例和练习的代码在本书的 GitHub 仓库中(github.com/daneah/practices-of-the-python-pro),也请记住,大部分源代码是为了在完成练习后检查你的工作而设计的。如果你卡住了或者想要比较解决方案,可以使用提供的代码,但请首先为每个练习付出自己的努力。

开心编码!

摘要

  • Python 在复杂的企业项目中与其他主要编程语言一样,具有举足轻重的地位。

  • Python 是任何编程语言中用户增长最快的之一。

  • 设计不仅仅是你在纸上画的东西;它是你为了达到那个目标而遵循的过程。

  • 预先设计是一种投资,它会在以后给你带来干净和灵活的代码。

  • 你需要考虑具有多元化受众的软件构建。

第二部分. 设计基础

有效的软件的基础是有意的设计,在设计软件的过程中,你会发现一些相同的概念会反复出现。本书的第二部分将为你准备大型软件项目的复杂性,通过涵盖软件设计的这些基本原理。你将学习如何组织代码,使其更高效,并测试其是否按预期工作。

阅读本书的其余部分时,你将不时地看到这些概念被明确地重申。看看你是否也能将这些新学到的知识与你自己的这些概念联系起来。软件设计基础知识的频繁重复将帮助你将它们融入日常工作中,在那里它们将最为有效。

第二章. 关注点分离

本章涵盖

  • 使用 Python 的特性进行代码组织和分离

  • 选择何时以及如何将代码分离成独立的片段

  • 分离代码的粒度级别

清晰代码的基石是将各种行为划分为小而可管理的部分。清晰的代码要求你在任何给定时间记住的知识更少,这使得代码更容易推理。具有明确意图的简短代码段是朝着这个方向迈出的重要一步,但代码片段不应沿任意边界分割。通过关注点进行分离是一种有效的方法。

定义

关注点是软件处理的一个独特的行为或知识片段。关注点的粒度可以从计算平方根的方法到电子商务系统中支付管理。

在本章中,我将讨论 Python 内置的工具,用于分离代码中的关注点,以及决定何时以及如何使用它们的哲学。

注意

如果你还没有设置,你需要在你的计算机上设置 Python,以便跟随本书中的代码。安装和最佳实践都在附录中介绍,所以在你继续之前,你应该去那里设置好。当你准备好了,我就会在这里。记住,你可以在 GitHub 上找到本书示例和练习的完整源代码(github.com/daneah/practices-of-the-python-pro)。

2.1. 命名空间

与许多编程语言一样,Python 通过命名空间的概念来隔离代码。当程序运行时,它会跟踪所有已知的命名空间以及这些命名空间中可用的信息。

命名空间在几个方面都有帮助:

  • 随着软件的增长,多个概念将需要类似或相同的名称。命名空间有助于最小化冲突,使名称指向的概念保持清晰。

  • 随着软件的增长,了解代码库中已经存在的代码变得越来越困难。命名空间帮助你做出关于代码可能存在的明智猜测。

  • 当向大型代码库添加新代码时,现有的命名空间可以指导新代码应该放在哪里。如果没有明显的选择,可能需要一个新命名空间。

命名空间非常重要,实际上,它们被包含在“Python 之禅”的最后一条语句中(如果你不熟悉“Python 之禅”,可以尝试启动 Python 解释器并输入import this)。

命名空间是一个绝妙的想法——让我们多做些这样的工作!

《Python 之禅》

你在 Python 中使用过的所有变量、函数和类的名称都是某个命名空间中的名称。名称,如xtotalEssentialBusinessDomainObject,是对某物的引用。当你的 Python 代码说x = 3时,意味着“将值 3 赋给名称x”,然后你可以在代码中引用x。在 Python 中,“变量”是一个指向值的名称,尽管名称可以指向函数、类等。

2.1.1. 命名空间和导入语句

当你第一次打开 Python 解释器时,内置 命名空间被所有内置到 Python 中的内容填充。这个命名空间包含内置函数,如print()open()。这些内置函数没有前缀,你不需要做任何特别的事情就可以使用它们。Python 使它们在代码的任何地方都可用。这就是为什么著名的简单print('Hello world!')在 Python 中“Just Works™”。

与某些语言不同,你不会在 Python 代码中 显式地 创建命名空间,但你的代码结构将影响创建哪些命名空间以及它们如何交互。例如,创建 Python 模块 会自动为该模块创建一个额外的命名空间。在最简单的情况下,Python 模块是一个包含一些代码的.py 文件。例如,名为sales_tax.py的文件是“销售税模块”:

# sales_tax.py

def add_sales_tax(total, tax_rate):
    return total * tax_rate

每个模块都有一个 全局 命名空间,模块中的代码可以自由访问。不在任何东西内部嵌套的函数、类和变量都在模块的全局命名空间中:

# sales_tax.py

TAX_RATES_BY_STATE = {                        *1*
    'MI': 1.06,
    # ...
}

def add_sales_tax(total, state):
    return total * TAX_RATES_BY_STATE[state]  *2*
  • 1 TAX_RATES_BY_STATE 在模块的全局命名空间中。

  • 2 模块中的代码可以使用 TAX_RATES_BY_STATE 而无需任何麻烦。

模块中的函数和类也有一个 局部 命名空间,只有它们可以访问:

# sales_tax.py

TAX_RATES_BY_STATE = {
    'MI': 1.06,
    ...
}

def add_sales_tax(total, state):
    tax_rate = TAX_RATES_BY_STATE[state]  *1*
    return total * tax_rate               *2*
  • 1 tax_rate 只在 add_sales_tax() 的局部作用域中。

  • 2 add_sales_tax() 中的代码可以使用 tax_rate 而无需任何麻烦。

想要从另一个模块使用变量、函数或类的模块必须将其 导入 到其全局命名空间中。导入是将名称从其他地方拉入所需命名空间的一种方式。

# receipt.py

from sales_tax import add_sales_tax                     *1*

def print_receipt():
    total = ...
    state = ...
    print(f'TOTAL: {total}')
    print(f'AFTER TAX: {add_sales_tax(total, state)}')  *2*
  • 1 add_sales_tax 函数被添加到收据的全局命名空间中。

  • 2 add_sales_tax 仍然知道它自己的命名空间中的 TAX_RATES_BY_STATEtax_rate

因此,在 Python 中引用变量、函数或类时,以下条件之一必须成立:

  • 名称在 Python 内置命名空间中。

  • 名称是当前模块的全局命名空间。

  • 名称在当前代码行的局部命名空间中。

冲突名称的优先级顺序是相反的:局部名称会覆盖全局名称,全局名称会覆盖内置名称。你可以通过记住这一点,因为通常与当前代码最具体的定义是会被使用的。这可以在图 2.1 中看到。

图 2.1. 命名空间的特定性

图片

你可能曾在 Python 的冒险中遇到过NameError: name 'my_var' is not defined错误。这意味着my_var名称在代码所知的任何命名空间中都没有找到。这通常意味着你从未为my_var分配值,或者你在其他地方分配了它,需要导入它。

模块是开始分割代码的绝佳方式。如果你有一个包含许多无关函数的长脚本.py 文件,考虑将这些函数拆分到模块中。

2.1.2. 导入的多种面貌

Python 中导入的语法一开始看起来很简单,但有一些不同的方法来实现,每种方法都会在命名空间中引入的信息上产生细微的差异。之前,你从 sales_tax 模块将add_sales_tax()函数导入到 receipt 模块中:

# receipt.py

from sales_tax import add_sales_tax

这将add_sales_tax()函数添加到 receipt 模块的全局命名空间中。这很好,但假设你向 sales_tax 模块添加了十个更多函数,并想在 receipt 中使用它们。如果你继续走这条路,你最终会得到类似这样的结果:

# receipt.py

from sales_tax import add_sales_tax, add_state_tax, add_city_tax,
 add_local_millage_tax, ...

有一种替代语法对此进行了一些改进:

# receipt.py

from sales_tax import (
    add_sales_tax,
    add_state_tax,
    add_city_tax,
    add_local_millage_tax,
    ...
)

仍然不是很好。当你需要从另一个模块获取大量功能时,你可以完整地导入该模块:

# receipt.py

import sales_tax

这将整个 sales_tax 模块添加到当前命名空间中,其函数可以通过sales_tax.前缀来引用:

# receipt.py

import sales_tax

def print_receipt():
    total = ...
    locale = ...
    ...
    print(f'AFTER MILLAGE: {sales_tax.add_local_millage_tax(total, locale)}')

这的好处是避免了长的import语句,并且,正如你将在下一节中看到的,前缀有助于避免命名空间冲突。

警告

Python 允许你使用简写from themodule import *从模块中导入所有名称。这很有诱惑力,但请不要这样做!这些通配符导入可能导致名称冲突,并使问题难以调试,因为你无法看到正在导入的具体名称。坚持使用显式导入!

2.1.3. 命名空间防止冲突

如果你想在 Python 程序中获取当前时间,你可以通过从 time 模块导入time()函数来实现:

from time import time
print(time())

你应该看到如下输出:

1546021709.3412101

time()返回当前的 Unix 时间.^([1]) datetime 模块也包含一个名为time的东西,但它做的是不同的事情:

¹

请参阅维基百科文章以了解 Unix 时间的解释:en.wikipedia.org/wiki/Unix_time

from datetime import time
print(time())

这次你应该看到这个输出:

00:00:00

这个 time 实际上是一个类,调用它返回一个默认为午夜(0 小时,0 分钟等)的 datetime.time 实例。当你同时导入它们时会发生什么?

from time import time
from datetime import time
print(time())                   *1*
  • 1 这是哪个时间?

在存在歧义的情况下,Python 使用它所知道的最新定义。如果你从一个地方导入 time,然后从另一个地方导入另一个 time,它只会知道后者。如果你不使用命名空间,将很难确定代码中引用的是哪个 time(),你可能会不小心使用错误的版本。这是一个强有力的理由,应该整体导入模块;它迫使你使用模块前缀来命名,这样就可以清楚地知道名称的来源。

import time
import datetime
now = time.time()            *1*
midnight = datetime.time()   *2*
  • 1 很明显,这个时间指的是什么。

  • 2 这次时间被唯一地引用了。

有时,即使使用你迄今为止看到的工具,名称冲突也难以避免。如果你创建了一个与 Python 内置模块或第三方库同名的模块,并且你需要在同一个模块中使用它们两个,你需要更多的力量。幸运的是,这只需要一个 Python 关键字。当你导入时,可以使用 as 关键字将一个名称别名为另一个名称:

import datetime
from mycoollibrary import datetime as cooldatetime

现在如预期的那样,datetime 可用,第三方 datetime 可用为 cooldatetime

除非你有充分的理由要覆盖 Python 的内置功能,否则最好不要使用与内置相同的名称,除非你打算替换它们。但如果你不知道整个标准库(我肯定不知道!),有时仍然可能会意外发生。你可以在导入其他模块时将你的模块别名到一个新名称,但我建议重命名模块并更新代码中对其的所有引用,以确保你的导入与模块的文件名保持一致。

注意

大多数集成开发环境(IDE)会在你覆盖 Python 内置函数名称时给你一个警告,这样你就不太可能意外地走得太远。

使用这些导入实践,你应该能够无问题地导入你需要的一切。记住,模块名称前缀(如 time.datetime.)在长期来看是有帮助的,因为命名空间冲突确实会发生。当你遇到冲突时,深呼吸,自信地重新编写你的导入语句或创建一个别名,然后继续前进!

2.2. Python 中的分离层次

区分不同关注点的一种方法是通过遵循 Unix 哲学中的“做好一件事”原则。^([2]) 当你的代码中的某个特定函数或类关注单一行为时,你可以独立于使用它的代码来改进它。相比之下,如果行为在代码中重复并混合在一起,那么在不考虑——在最坏的情况下,破坏——其他几个行为的情况下更新特定行为可能会很困难。例如,网站上的许多函数可能依赖于当前认证用户的信息。如果它们都检查认证并自行获取有关该用户的信息,那么在认证细节更改时,它们都需要更新。这是一项大量工作,如果遗漏了一个函数,它可能会开始执行意外的操作或完全停止工作。

²

请参阅维基百科上关于 Unix 哲学的文章:en.wikipedia.org/wiki/Unix_philosophy

就像在 Python 中命名空间有粒度层次一样,对关注点分离的更广泛方法也是如此。关于如何使这个层次深或浅,没有固定的规则;有时调用一个函数,该函数又调用另一个函数,这是有意义的。记住,分离关注点的目标是把类似的活动放在一起,并将不同的活动隔离开来。

下一节将介绍 Python 程序用来组织和保持关注点分离的结构性工具。如果你对函数和类感到满意,可以跳到第 2.2.3 节。

2.2.1. 函数

如果你不太熟悉函数,回想一下数学课。数学函数是公式,用非 Python 语法表示,如f(x) = x² + 3,它将输入映射到输出。输入x = 5返回f(5) = 5² + 3 = 25 + 3 = 28。在软件中,函数扮演着同样的角色。给定一组输入变量,函数执行一些计算或转换并返回一个结果。

这种关于函数的思考方式自然地引出了软件中的函数通常应该是短小的观点。如果一个函数变得太长或做了太多的事情,那么它可能很难被描述,因此也很难被命名。f(x) = x² + 3是关于x的二次函数,而f(x) = x⁵ + 17x⁹ - 2x + 7则更难命名。在软件中,混合过多的概念会导致难以命名的模糊代码块。

当尝试将代码拆分时,小型函数是首先要考虑的工具之一。一个函数封装了几行代码,并为它们提供了一个清晰的名称,以便以后参考。创建一个函数不仅使正在发生的事情更清晰,而且可以根据需要重用代码。Python 本身就是这样做的:如果你使用open()读取文件或len()获取列表长度,你就已经使用了 Python 认为足够重要而封装并命名的功能。

将问题分解成小而可管理的部分的过程称为分解。想象一下蘑菇分解倒下的树木。它将复杂的分子木材转化为更基本的材料,如氮气和二氧化碳。然后这些物质被重新循环回生态系统。你的代码可以被分解成函数,这些函数可以重新循环回你的软件生态系统,如图 2.2 所示。

图 2.2. 分解的价值

假设你正在创建一个三傻大闹的粉丝网站(一个美国喜剧团体^([3]))。为了构建主页,你需要介绍三傻:拉里、库利和莫伊。给定名单和节目标题,代码应该生成字符串 'The Three Stooges: Larry, Curly, and Moe'。一个初始实现可能如下所示:

³

en.wikipedia.org/wiki/The_Three_Stooges

names = ['Larry', 'Curly', 'Moe']
message = 'The Three Stooges: '
for index, name in enumerate(names):
    if index > 0:
        message += ', '
    if index == len(names) - 1:
        message += 'and '
    message += name
print(message)

在进行了一些研究后,你意识到三傻的原阵容是不同的,你想要为每个阵容创建一个准确的页面。你的最初冲动是添加代码来为原始阵容做同样的工作:

names = ['Moe', 'Larry', 'Shemp']
message = 'The Three Stooges: '
for index, name in enumerate(names):
    if index > 0:
        message += ', '
    if index == len(names) - 1:
        message += 'and '
    message += name
print(message)

names = ['Larry', 'Curly', 'Moe']
message = 'The Three Stooges: '
for index, name in enumerate(names):
    if index > 0:
        message += ', '
    if index == len(names) - 1:
        message += 'and '
    message += name
print(message)

这方法可行,但原始代码一开始就不太清晰,现在有两个版本了!将介绍逻辑提取到函数中可以减少重复,并为代码赋予一个名称来明确其功能:

def introduce_stooges(names):                 *1*
    message = 'The Three Stooges: '
    for index, name in enumerate(names):
        if index > 0:
            message += ', '
        if index == len(names) - 1:
            message += 'and '
        message += name
    print(message)

introduce_stooges(['Moe', 'Larry', 'Shemp'])  *2*
introduce_stooges(['Larry', 'Curly', 'Moe'])
  • 1 提取的函数接受角色名称作为参数。

  • 2 可以使用相同的函数处理多组名称。

现在行为有一个清晰的名字,如果你想要花时间使代码更加清晰,你可以专注于introduce_stooges函数体本身。只要函数继续接受一个名单并继续打印你想要的介绍,你就可以确信你的代码仍然有效.^([4])

对于提取函数(以及其他有价值的练习)的详细讨论,我强烈推荐马丁·福勒和肯特·贝克合著的《重构》(第二版,Addison-Wesley Professional,2018),martinfowler.com/books/refactoring.html

对于你的三傻大闹粉丝页面感到满意,你决定扩展到其他著名团体。当你开始处理《忍者神龟》时,^([5]), 你注意到一个问题:introduce_stooges函数只介绍三傻(正如你可能猜到的)。实际上,这个函数有两个关注点:

mng.bz/RPan

  • 了解三傻大闹的电影介绍

  • 引入一个角色名单作为三傻

如何超越这个?你可以泛化函数,并通过提取组标题(“The Three Stooges”,“Teenage Mutant Ninja Turtles”等等)作为函数的另一个参数来分离第一个关注点。

def introduce(title, names):                                  *1*
    message = f'{title}: '
    for index, name in enumerate(names):
        if index > 0:
            message += ', '
        if index == len(names) - 1:
            message += 'and '
        message += name
    print(message)

introduce('The Three Stooges', ['Moe', 'Larry', 'Shemp'])     *2*
introduce('The Three Stooges', ['Larry', 'Curly', 'Moe'])

introduce( 'Teenage Mutant Ninja Turtles',                    *3*
    ['Donatello', 'Raphael', 'Michelangelo', 'Leonardo']
)

introduce('The Chipmunks', ['Alvin', 'Simon', 'Theodore'])    *3*
  • 1 从函数名中删除了“stooges”,并将标题作为参数传递。

  • 2 当函数被调用时,会传递组标题。

  • 3 可以用一个函数介绍不同的组。

该函数现在满足你粉丝网站的需求:它只知道组有一个标题和几个命名的成员,并使用这些信息来进行介绍。随着你网站的扩展,它可以轻松地接受新的组。如果在某个时候你需要改变介绍组的方式,你会知道去查看introduce()函数。

将代码分解成函数后,你可能会得到比原始代码更长的代码。但如果你仔细地根据关注点分解代码,明确地命名正在发生的事情,你应该会看到代码可读性的提升。整体代码长度并不那么重要;真正重要的是单个函数和方法的长短。

因此,对introduce函数还有一些工作要做。它的任务是使用组标题和名字形成介绍字符串。它不必知道如何使用逗号和牛津逗号等将名字列表连接起来。我们可以将这部分提取出来,作为一个单独的函数。

def join_names(names):                        *1*
    name_string = ''

    for index, name in enumerate(names):
        if index > 0:
            name_string += ', '
        if index == len(names) - 1:
            name_string += 'and '
        name_string += name
    return name_string

def introduce(title, names):                  *2*
    print(f'{title}: {join_names(names)}')
  • 1 这个函数只处理名字的连接方式。

  • 2 这个函数现在只知道介绍是标题后跟连接的名字。

这对于一些人来说可能看起来有些过度——introduce函数不再做太多。这种分解的价值在于,将每个关注点分离到函数中,当你试图修复错误、添加功能或测试代码时,会带来回报。如果你注意到名字连接的方式有错误,在join_names中找到需要更改的行比在所有内容都是单个introduce函数时更容易。

通常情况下,将关注点分离到不同的函数中可以允许进行更精确的修改;也就是说,你可以更精确地进行修改,同时对周围代码的影响最小。在整个项目过程中,这可以为你节省大量的时间。

我提到过,设计、重构,现在分解和关注点分离都是你应该融入健康迭代开发过程中的实践。这可能会开始感觉像是在旋转盘子而不是在发布代码,但随着你进入更大的软件项目,你会发现你经常使用这些实践。许多项目的长期性和成功受到代码质量的影响,而代码质量又反过来受到创建它的细心程度的影响。尝试将这些方法作为调味料撒入你的开发过程中,最初,你会发现它们最终成为主要的成分。

尝试一下

现在你已经有一些提取函数的经验了,看看 列表 2.1 中隐藏的函数,这是一个(可能很糟糕的)剪刀石头布的实现。我建议你在工作时经常运行代码,以确保行为保持一致。我在 列表 2.2 中提取了一组示例函数。作为一个提示,我将原始代码分解成了六个函数。你的结果可能会有所不同,但记住你是在追求只有一个关注点的函数。

列表 2.1. 糟糕的过程式代码
import random

options = ['rock', 'paper', 'scissors']
print('(1) Rock\n(2) Paper\n(3) Scissors')
human_choice = options[int(input('Enter the number of your choice: ')) - 1]
print(f'You chose {human_choice}')
computer_choice = random.choice(options)
print(f'The computer chose {computer_choice}')
if human_choice == 'rock':
    if computer_choice == 'paper':
        print('Sorry, paper beat rock')
    elif computer_choice == 'scissors':
        print('Yes, rock beat scissors!')
    else:
        print('Draw!')
elif human_choice == 'paper':
    if computer_choice == 'scissors':
        print('Sorry, scissors beat paper')
    elif computer_choice == 'rock':
        print('Yes, paper beat rock!')
    else:
        print('Draw!')
elif human_choice == 'scissors':
    if computer_choice == 'rock':
        print('Sorry, rock beat scissors')
    elif computer_choice == 'paper':
        print('Yes, scissors beat paper!')
    else:
        print('Draw!')
列表 2.2. 提取函数的代码
import random

OPTIONS = ['rock', 'paper', 'scissors']

def get_computer_choice():
    return random.choice(OPTIONS)

def get_human_choice():
    choice_number = int(input('Enter the number of your choice: '))
    return OPTIONS[choice_number - 1]

def print_options():
    print('\n'.join(f'({i}) {option.title()}' for i,
 option in enumerate(OPTIONS)))

def print_choices(human_choice, computer_choice):
    print(f'You chose {human_choice}')
    print(f'The computer chose {computer_choice}')

def print_win_lose(human_choice, computer_choice, human_beats,
 human_loses_to):
    if computer_choice == human_loses_to:
        print(f'Sorry, {computer_choice} beats {human_choice}')
    elif computer_choice == human_beats:
        print(f'Yes, {human_choice} beats {computer_choice}!')

def print_result(human_choice, computer_choice):
    if human_choice == computer_choice:
        print('Draw!')
    if human_choice == 'rock':
        print_win_lose('rock', computer_choice, 'scissors', 'paper')
    elif human_choice == 'paper':
        print_win_lose('paper', computer_choice, 'rock', 'scissors')
    elif human_choice == 'scissors':
        print_win_lose('scissors', computer_choice, 'paper', 'rock')

print_options()
human_choice = get_human_choice()
computer_choice = get_computer_choice()
print_choices(human_choice, computer_choice)
print_result(human_choice, computer_choice)

2.2.2. 类

代码由随着时间的推移积累的行为和数据组成。你已经看到了如何将行为提取到接受输入数据并返回结果的函数中。随着时间的推移,你可能会开始注意到几个函数经常协同工作。如果你经常将一个函数的结果传递给另一个函数,或者如果你的几个函数需要相同的数据输入,那么可能有一个正等待从你的代码中提取出来。

类是密切相关行为和数据的模板。你可以使用类来创建对象,即类的实例,这些实例具有在类中定义的数据和行为。数据成为对象的状态;在 Python 中,数据组成对象的属性,因为数据被赋予给特定的对象。行为成为方法,这些是特殊的函数,它们接收对象实例作为额外的参数(Python 开发者普遍将其命名为 self)。这允许方法访问或更改实例的状态。一起,属性和方法是类的成员

许多语言中的类包含一个构造函数,这是一个用于创建类实例的特殊方法。在 Python 中,__init__ 方法(初始化器)更常用。当调用 __init__ 时,类实例已经构建完成,该方法设置实例的初始状态。__init__ 至少接受一个参数,大多数 Python 开发者将其称为 self,它是已创建实例的引用。该方法通常接受额外的任意参数,用于设置初始状态。在 Python 中创建类实例的语法与使用函数非常相似:你使用类名而不是函数名,参数是传递给 __init__ 的参数(不包括 self)。

再看看你从“剪刀石头布”(列表 2.3)中分解出来的函数。你注意到什么了?所有行为和数据都是基于三种选择以及每个玩家选择的是哪一个。一些函数使用了相同的数据;这些事物看起来密切相关。也许一个用于玩这个游戏的类正等待诞生。

列表 2.3. 重新审视剪刀石头布代码
import random

OPTIONS = ['rock', 'paper', 'scissors']

def get_computer_choice():                               *1*
    return random.choice(OPTIONS)

def get_human_choice():
    choice_number = int(input('Enter the number of your choice: '))
    return OPTIONS[choice_number - 1]

def print_options():
    print('\n'.join(f'({i}) {option.title()}' for i,
 option in enumerate(OPTIONS)))

def print_choices(human_choice, computer_choice):        *2*
    print(f'You chose {human_choice}')
    print(f'The computer chose {computer_choice}')

def print_win_lose(human_choice, computer_choice, human_beats,
 human_loses_to):
    if computer_choice == human_loses_to:
        print(f'Sorry, {computer_choice} beats {human_choice}')
    elif computer_choice == human_beats:
        print(f'Yes, {human_choice} beats {computer_choice}!')

def print_result(human_choice, computer_choice):         *3*
    if human_choice == computer_choice:
        print('Draw!')

    if human_choice == 'rock':
        print_win_lose('rock', computer_choice, 'scissors', 'paper')
    elif human_choice == 'paper':
        print_win_lose('paper', computer_choice, 'rock', 'scissors')
    elif human_choice == 'scissors':
        print_win_lose('scissors', computer_choice, 'paper', 'rock')
  • 1 函数使用 OPTIONS 来确定玩家的选择。

  • 2 几个函数使用人类和计算机的选择进行模拟。

  • 3 人类和计算机的选择经常被传递。

由于收集和打印模拟不同部分的关注点已经很好地分离到函数中,您现在可以自由地考虑更高层次关注点的分离。通过使用如图 2.3 所示的类,可以将石头、剪刀、布从您的代码的其他部分(也许您正在制作一个完整的游艺厅!)中分离出来。注意新的simulate()方法,它将包含调用所有其他方法的代码。

图 2.3. 将相关行为和数据封装在类中

您可以从创建类定义并将函数移动到其中作为方法开始,如下所示列表所示。请记住,方法将self作为其第一个参数。

列表 2.4. 将函数移动到类中作为方法
import random

OPTIONS = ['rock', 'paper', 'scissors']

class RockPaperScissorsSimulator:
    def get_computer_choice(self):                               *1*
        return random.choice(OPTIONS)

    def get_human_choice(self):
        choice_number = int(input('Enter the number of your choice: '))
        return OPTIONS[choice_number - 1]

    def print_options(self):
        print('\n'.join(f'({i}) {option.title()}' for i,
 option in enumerate(OPTIONS)))

    def print_choices(self, human_choice, computer_choice):      *2*
        print(f'You chose {human_choice}')
        print(f'The computer chose {computer_choice}')

    def print_win_lose(self, human_choice, computer_choice,
 human_beats, human_loses_to):
        if computer_choice == human_loses_to:
            print(f'Sorry, {computer_choice} beats {human_choice}')
        elif computer_choice == human_beats:
            print(f'Yes, {human_choice} beats {computer_choice}!')

    def print_result(self, human_choice, computer_choice):
        if human_choice == computer_choice:
            print('Draw!')

       if human_choice == 'rock':
           self.print_win_lose('rock', computer_choice, 'scissors', 'paper')
        elif human_choice == 'paper':
           self.print_win_lose('paper', computer_choice, 'rock', 'scissors')
        elif human_choice == 'scissors':
           self.print_win_lose('scissors', computer_choice, 'paper', 'rock')
  • 1 方法需要一个“self”参数。

  • 2 使用现有参数的方法仍然需要“self”。

一旦移动了函数,您可以为它们创建一个新的simulate方法来调用它们。在类中,您需要编写self.some_method()来表示您想要在类上调用some_method方法(而不是命名空间中的其他函数)。请注意,尽管some_method在其定义中需要一个self参数,但在调用它时您不需要传递它。Python 会自动将self传递给方法。simulate调用函数以使模拟运行:

...

    def simulate(self):
        self.print_options()
        human_choice = self.get_human_choice()
        computer_choice = self.get_computer_choice()
        self.print_choices(human_choice, computer_choice)
        self.print_result(human_choice, computer_choice)

你可能已经注意到,尽管现在所有内容都包含在一个类中,但数据仍然被传递到各个地方。但现在,由于内容被封装,更容易做一些额外的更改。您可以创建一个初始化器来设置类所需的属性,即human_choicecomputer_choice,默认值为None

...

    def __init__(self):
        self.computer_choice = None
        self.human_choice = None

现在方法可以使用self参数而不是传递它们来访问这些属性。因此,您可以更新方法体以使用self.human_choice代替human_choice,并完全删除human_choice参数。computer_choice也得到同样的处理。

代码简化到以下列表中所示的内容。

列表 2.5. 使用self访问属性
import random

OPTIONS = ['rock', 'paper', 'scissors']

class RockPaperScissorsSimulator:
    def __init__(self):
        self.computer_choice = None
        self.human_choice = None

    def get_computer_choice(self):                                 *1*
        self.computer_choice = random.choice(OPTIONS)

    def get_human_choice(self):
        choice_number = int(input('Enter the number of your choice: '))
        self.human_choice = OPTIONS[choice_number - 1]

    def print_options(self):
        print('\n'.join(f'({i}) {option.title()}' for i,
 option in enumerate(OPTIONS)))

    def print_choices(self):                                       *2*
        print(f'You chose {self.human_choice}')                    *3*
        print(f'The computer chose {self.computer_choice}')

    def print_win_lose(self, human_beats, human_loses_to):
        if self.computer_choice == human_loses_to:
            print(f'Sorry, {self.computer_choice} beats {self.human_choice}')
        elif self.computer_choice == human_beats:
            print(f'Yes, {self.human_choice} beats {self.computer_choice}!')

    def print_result(self):
        if self.human_choice == self.computer_choice:
            print('Draw!')

        if self.human_choice == 'rock':
            self.print_win_lose('scissors', 'paper')
        elif self.human_choice == 'paper':
            self.print_win_lose('rock', 'scissors')
        elif self.human_choice == 'scissors':
            self.print_win_lose('paper', 'rock')

    def simulate(self):
        self.print_options()
        self.get_human_choice()
        self.get_computer_choice()
        self.print_choices()
        self.print_result()
  • 1 方法可以在 self 上设置属性。

  • 2 方法不需要将属性作为参数。

  • 3 方法可以读取 self 中的属性。

在整个类中对属性引用添加self.需要一些工作,但其中大部分被简化了。特别是,方法需要的参数更少,simulate方法所做的只是将其他方法粘合在一起。另一个很好的结果是,模拟石头、剪刀、布游戏的代码现在看起来像这样:

RPS = RockPaperScissorsSimulator()
RPS.simulate()

很简洁,对吧?你首先将大量代码分解成函数以分离一些关注点。然后,你将它们分组到一个类中以分离更高层次的关注点。现在,通过简短的表达式就可以调用所有幕后工作。这要归功于仔细选择和分组相关的数据和行为。

当一个类的方法和属性紧密相关时,我们称它具有高凝聚性。一个类如果其内容作为一个整体有意义,则被认为是凝聚的。我们希望我们的类具有高凝聚性,因为如果类中的所有内容都紧密相关,我们的关注点很可能被很好地分离。具有太多关注点的类具有低凝聚性,因为这些关注点会模糊类的意图。通常,我只有在这种凝聚性对我来说已经很清晰的情况下才会创建一个类;一些代码已经通过其包含的数据和行为表现出相关性。

当一个类依赖于另一个类时,我们称这两个类是耦合的。如果一个类依赖于另一个类的许多细节,以至于改变一个需要改变另一个,那么这两个类就是紧密耦合的。紧密耦合是昂贵的,因为它可能导致花费更多时间来管理变化的涟漪效应。松耦合是我们希望达到的状态。你将在第十章中学习更多实现松耦合的策略。

一组高度凝聚的类与一组清晰的函数具有相同的作用。它明确了意图,帮助我们导航现有代码,并指导我们添加新代码。所有这些都有助于我们更快地实现我们想要的特性,而不是让我们花费时间在软件的洞穴中探险。

2.2.3. 模块

你已经学习了在 Python 中创建模块的基本知识:一个包含有效 Python 代码的.py文件已经是一个模块了!我提到了何时创建模块的问题,但现在让我们回到那个问题上来。

你可能一开始就知道大部分代码都生活在一个巨大的过程块中,即script.py。如果你像我一样注意力集中时间短,你可能已经从其中提取了许多函数和类。欢迎回来。

尽管你的代码现在已经被很好地分离成具有良好命名的函数、类和方法,但它们仍然都生活在script.py文件中。最终,单文件提供的最小结构将不足以以合理的方式容纳所有代码。你将无法记得你正在寻找的函数是在第 5 行还是第 205 行。将它们分解成易于记忆的行为类别是前进的道路。

你所提出的担忧将很好地映射到你应该创建的模块。在猜测这些类别应该是什么时,要保守地投入精力。它们在开始时就会频繁变化,因为你的系统心理模型会随着发展和改进而变化。但花点时间勾勒出你认为需要什么,并保持开放的心态,即不同的结构可能在以后会更有意义。最清晰的代码是你没有写的代码:每一行都增加了额外的认知负担。在没有任何代码之后,最好的事情就是组织良好的代码

模块在其代码周围创建额外的结构,宣称,“这里的代码都是关于统计学的!”如果你需要做统计工作,你知道要import statistics并使用那里提供的内容。如果你需要的东西还没有,至少你有一个很好的想法知道把它放在哪里。你能对 500 行的script.py文件说同样的话吗?也许可以,但不会太久。

2.2.4. 包

我一直在赞扬模块的使用,因为它们能够整洁地分割代码。我们还需要其他什么吗?

记住,关注点的分离是一个层次结构,并且名称冲突仍然可能发生。假设你的粉丝网站已经变得流行,现在你需要一个数据库和一个搜索页面来跟踪所有内容。你已经编写了record.py,这是一个用于创建数据库记录的模块,以及query.py,这是一个用于查询数据库的模块:

图片

现在你需要编写一个用于创建搜索查询的模块。你叫它什么?search_query.py可能是一个不错的名字,但这样就有必要将query.py重命名为database_query.py以提高清晰度:

图片

当两个模块在名称或概念上发生冲突时,你就已经超出了你现有的结构。通过将模块分成相关组来添加更多的结构。在 Python 中,包不过是一个包含模块(.py 文件)和特殊文件的目录,该文件告诉 Python 将该目录视为包(__init__.py)。这个文件通常是空的,但它可以用于更复杂的导入管理。就像sales_tax.py文件成为“销售税模块”一样,ecommerce/目录成为“电子商务包”。

警告

“包”这个术语也指可以从 Python 包索引(PyPI)安装的第三方 Python 库。我将尽我所能在本书中澄清需要的地方,但请注意,有些资源不会做出区分。

对于数据库和搜索模块,一个数据库包和一个搜索包会很有意义。然后模块的database_search_前缀将是多余的,可以删除。

你可以将你的代码层次结构扩展到包中,这最终创建了一个良好的结构,你可以阅读和导航。每个包解决一个高级关注领域,包中的每个模块管理一个较小的关注领域。在每个模块内部,类、方法和函数进一步阐明应用程序的不同部分。

在你之前会写 import query 来使用数据库查询 模块 的情况下,你现在需要从数据库 中导入它。你可以写 import database.query,这将要求你在模块名称前加上 database.query. 前缀,或者你可以写 from database import query。如果你只在特定模块中使用数据库代码,后者可能就足够了。但如果你需要在模块中使用新的搜索查询代码 数据库代码,你必须消除歧义,并且保持前缀有助于这一点:

import database.query
import search.query

你也可以使用 from 语法并为每个模块指定别名:

from database import query as db_query
from search import query as search_query

别名可能过于冗长,如果命名不当,有时甚至可能让人困惑。请谨慎使用,以避免命名冲突。

你可以在创建初始包的过程类似的方式中嵌套包。创建一个包含 __init__.py 文件的目录,并将模块或包放在里面:

在这个例子中,所有数学代码都在 math 包中,每个数学子领域都有自己的子包,其中包含模块。如果你想查看计算积分的代码,你可以猜测它位于 math/calculus/integral.py。包的这一特性——能够导航到代码可能存在的位置——随着项目规模的扩大变得非常有价值。

导入积分模块的方式与之前相同,但需要额外的前缀来访问感兴趣的模块:

from math.calculus import integral
import math.calculus.integral

注意,from math import calculus.integral 不会工作;你只能使用 import ...from ... import ... 来导入完整的点分路径。

摘要

  • 关注点的分离是可理解代码的关键;许多设计概念直接源于这一原则。

  • 函数从过程代码中提取命名概念。清晰性和分离是提取的主要目标;重用是次要的好处。

  • 类将紧密相关的行为和数据组合成一个对象。

  • 模块将相关的类、函数和数据分组,同时保持独立关注点的分离。明确从其他模块导入代码可以使使用情况更加清晰。

  • 包有助于创建模块层次结构,这有助于命名和代码发现。

第三章. 抽象和封装

本章涵盖

  • 理解抽象在大系统中的价值

  • 将相关代码封装到类中

  • 在 Python 中使用封装、继承和组合

  • 识别 Python 中的编程风格

你已经看到,将你的代码组织成函数、类和模块是一种很好的方法来分离关注点,但你也可以使用这些技术来分离代码中的复杂性。因为很难始终记住软件的所有细节,在本章中,你将学习使用抽象和封装来创建代码的粒度级别,这样你就可以只在需要时关注细节。

3.1. 什么是抽象?

当你听到“抽象”这个词时,你想到的是什么?通常是一幅杰克逊·波洛克的作品或一个考尔德的雕塑会浮现在我的脑海中。抽象艺术的特点是摆脱具体形式,通常只暗示一个特定主题。因此,“抽象”就是将具体事物剥离其具体性的过程。在软件中谈论抽象时,这正是正确的!

3.1.1. “黑盒”

随着你开发软件,其中的一些部分将代表完整的概念。例如,一旦你完成了一个特定函数的开发,它就可以反复用于其预期目的,而不需要你过多地思考它是如何工作的。在这个时候,该函数已经成为了一个黑盒。黑盒是一种“只需工作”的计算或行为——每次需要它时不需要打开和检查(见 figure 3.1)。

图 3.1. 将工作软件视为黑盒

假设你正在构建一个自然语言处理系统,该系统可以确定产品评论是正面、负面还是中性的。这样的系统在过程中有许多步骤,如图 3.2(figure 3.2)所示:

  1. 将评论拆分成句子。

  2. 将每个句子拆分成单词或短语,通常称为标记

  3. 将单词变体简化为其根词,称为词元化

  4. 确定句子的语法结构。

  5. 通过与手动标记的训练数据进行比较来计算内容的极性。

  6. 计算极性的整体强度。

  7. 为产品评论选择最终的正面、负面或中性判断。

图 3.2. 确定产品评论是正面、负面还是中性的

情感分析工作流程中的每一步都由许多行代码组成。通过将这些代码卷入“拆分成句子”和“确定语法结构”等概念,整个工作流程比试图一次性理解所有代码更容易理解。如果有人想了解工作流程中特定步骤的详细信息,他们可以选择深入了解。这种将实现抽象化的想法对于人类理解是有用的,但它也可以在代码中形式化,以产生更稳定的结果。

在 第二章 中,你学习了如何识别代码的关注点并将它们提取到函数中。将行为抽象成函数允许你自由地更改该函数计算结果的方式,只要输入和返回数据类型保持不变。这意味着如果你发现了一个错误或更快速、更准确执行计算的方法,你可以替换该行为,而无需其他代码因此需要更改。这在你迭代软件时提供了灵活性。

3.1.2. 抽象就像洋葱

你在 图 3.2 中看到,工作流程中的每一步通常代表一些低级代码。然而,其中一些步骤,比如确定句子的语法结构,相当复杂。这样的复杂代码通常会从 抽象层 中受益;低级实用工具支持小的行为,而这些行为反过来又支持更复杂的行为。正因为如此,在大系统中编写和阅读代码常常像剥洋葱一样,一层层揭示出下面更小、更紧密的代码片段 (图 3.3)。

图 3.3. 抽象在复杂性的层次上工作。

重复使用的小而专注的行为位于底层,需要很少改变。随着你向外扩展,大概念、业务逻辑和复杂的移动部件会出现;由于需求的变化,它们更频繁地改变,但它们仍然使用较小的行为。

当你刚开始时,通常会写一个长程序,通过一系列步骤完成任务。这在进行原型设计时效果不错,但当有人需要阅读所有 100 行代码以确定他们需要在哪里进行更改或修复错误时,它就暴露了其维护性差的缺点。通过语言特性引入抽象可以使定位相关代码更容易。在 Python 中,函数、类和模块等特性有助于抽象行为。让我们看看使用 Python 中的函数如何帮助处理情感分析工作流程的前两个步骤。

当你在 列表 3.1 中处理代码时,你可能会注意到它做了两次类似的工作——将字符串分割成句子和每个句子中的单个单词的工作相当相似。每个步骤执行相同的操作,但输入不同。这通常是一个将行为分解为其自身函数的机会。

列表 3.1. 将段落分割成句子和标记的程序
import re

product_review = '''This is a fine milk, but the product
line appears to be limited in available colors. I
could only find white.'''                                    *1*

sentence_pattern = re.compile(r'(.*?\.)(\s|$)', re.DOTALL)   *2*
matches = sentence_pattern.findall(product_review)           *3*
sentences = [match[0] for match in matches]                  *4*

word_pattern = re.compile(r"([\w\-']+)([\s,.])?")            *5*
for sentence in sentences:
    matches = word_pattern.findall(sentence)
    words = [match[0] for match in matches]                  *6*
    print(words)
  • 1 产品评论作为字符串

  • 2 匹配以句号结尾的完整句子

  • 3 在评论中找到所有句子

  • 4 findall 返回 (句子,空白) 对的列表

  • 5 匹配单个单词

  • 6 对于每个句子,获取所有单词

你可以看到,找到句子单词的工作是相似的,匹配的模式是其区分特征。还有一些后勤工作也需要处理,比如处理findall的输出,这些都会使代码变得杂乱。乍一看,这段代码的意图可能并不明显。

注意

在现实的自然语言处理中,分割句子和单词是困难的,实际上非常困难,以至于解析它们的软件通常使用概率模型来确定结果。概率模型使用大量的输入测试数据来确定特定结果的可能正确性。结果可能并不总是相同的!自然语言是复杂的,当我们试图让计算机理解它们时,这一点就显现出来了。

抽象如何帮助提高句子解析?借助 Python 函数的一点点帮助,你可以简化这一点。在下面的列表中,模式匹配被抽象成get_matches_for_pattern函数。

列表 3.2. 重构句子解析
import re

def get_matches_for_pattern(pattern, string):              *1*
    matches = pattern.findall(string)
    return [match[0] for match in matches]

product_review = '...'

sentence_pattern = re.compile(r'(.*?\.)(\s|$)', re.DOTALL)
sentences = get_matches_for_pattern(                       *2*
    sentence_pattern,
    product_review,
)

word_pattern = re.compile(r"([\w\-']+)([\s,.])?")
for sentence in sentences:
    words = get_matches_for_pattern(                       *3*
        word_pattern,
        sentence
    )
    print(words)
  • 1 一个用于模式匹配的新函数

  • 2 现在你可以让函数去做这项艰苦的工作。

  • 3 你可以在需要时重用这个函数。

在更新的解析代码中,更清楚地表明了审查被分解成几个部分。通过命名良好的变量和清晰简洁的for循环,这个过程的两阶段结构也变得清晰。后来查看这段代码的人将能够阅读主要代码,只有在好奇或想要更改它时才会深入研究get_matches_for_pattern的工作。抽象引入了清晰性和代码重用。

3.1.3. 抽象是一种简化器

我想强调的是,抽象有助于使代码更容易理解;它通过将某些功能的复杂性隐藏起来直到你想要了解更多来实现这一点。这是一种在编写技术文档以及设计用于与代码库交互的接口时使用的技巧。

理解代码就像理解一本书中的一段文字。一段文字有许多句子,这些句子就像代码的行。在任何给定的句子中,你可能会遇到一个你不熟悉的单词。在软件中,这可能是执行新或不同操作的一行代码。当你在这类书籍中找到这些单词时,你可能会在字典中查找它们。在处理长时间程序时,唯一的等效方法是勤奋地注释代码。

解决这个问题的一种方法是将相关的代码片段抽象成函数,这些函数清楚地说明了它们的功能。你在列表 3.1 和 3.2 中看到了这一点。函数get_matches_for_pattern从字符串中获取给定模式的匹配项。然而,在更新之前,这段代码的意图并不那么清晰。

小贴士

在 Python 中,你可以使用文档字符串为模块、类、方法或函数添加额外的上下文。文档字符串是这些结构开头附近的一些特殊行,可以告诉读者(以及一些自动化软件)代码的行为。你可以在维基百科上了解更多关于文档字符串的信息(en.wikipedia.org/wiki/Docstring)。

抽象减少了认知负荷,即你的大脑思考或记住某件事所需的努力,这样你就可以花时间确保你的软件做它需要做的事情!

3.1.4. 分解实现抽象

正如我在第二章中提到的,分解是将某物分解为其组成部分的过程。在软件中,这意味着做你之前看到的那种事情:将执行单一功能的代码部分分离成函数。实际上,这也与第一章中关于设计和工作流程的讨论有关。这里的共同主题是,用小部分协同工作的软件通常比用一大块写出的软件更容易维护。你已经看到这可以有助于减少认知负荷并使代码更容易理解。图 3.4 展示了如何将一个庞大的系统分解成可实现的任务。

图 3.4. 将分解成细粒度组件有助于理解。

看看这些块是如何从左到右变得越来越小的吗?试图一次性用一块大东西(如左侧所示)来构建一些东西就像把你的整个房子装进一个货柜里。像右侧那样构建东西就像把你的房子的每个房间组织成你可以携带的小盒子。分解帮助你以小步骤处理大概念。

3.2. 封装

封装是面向对象编程的基础。它将分解推进了一步:而分解将相关的代码组合成函数,封装将相关的函数和数据组合成更大的结构。这个结构充当对外界的屏障(或胶囊)。Python 中有哪些结构可用?

3.2.1. Python 中的封装结构

通常,Python 中的封装是用类来完成的。在类中,函数变成方法;方法类似于函数,但它们包含在类中,并且通常接收一个输入,这个输入要么是类的实例,要么是类本身。

在 Python 中,模块也是一种封装形式。模块甚至比类更高层次;它们将多个相关的类和函数组合在一起。例如,处理 HTTP 交互的模块可以包含用于请求和响应的类,以及用于解析 URL 的实用函数。你遇到的绝大多数*.py 文件都可以被认为是模块。

Python 中可用的最大封装是。包将相关的模块封装到目录结构中。包通常在 Python 包索引(PyPI)上分发,供他人安装和重用。

看一下图 3.5,注意购物车片段被分解成不同的活动。它们也是独立的;它们在执行任务时互不依赖。活动之间的任何合作都是在更高的购物车级别上协调的。购物车本身在电子商务应用内部是独立的;它所需的信息将被传递给它。你可以将封装的代码想象成一个有城堡墙围绕的地方,其中函数和方法是进入或离开的吊桥。

图 3.5. 通过将系统分解成小部分,你可以将行为和数据封装成独立的片段。封装鼓励你减少任何给定代码部分的职责,帮助你避免复杂的依赖。

图片

你认为这些片段中的哪一个会是

  • 方法?

  • 类?

  • 模块?

  • 包?

三个最小的片段——计算税费、计算运费和减去折扣——很可能是代表购物车的类中的方法。电子商务系统似乎有足够的功能可以成为一个包,因为购物车只是该系统的一部分。包内的不同模块可以根据它们之间的相关性紧密程度而出现。但它们如果各自被城堡墙包围,它们是如何一起工作的呢?

3.2.2. Python 中的隐私期望

许多语言通过引入隐私的概念来形式化封装的“城堡墙”方面。类可以有私有的方法和数据,这些方法和数据只能由类的实例访问。这与公共的方法和数据形成对比,公共方法和数据通常被称为类的接口,因为这是其他类与之交互的方式。

Python 没有真正的私有方法或数据支持。相反,它遵循一个信任开发者会做正确事情的理念。尽管如此,在这个领域,一个常见的约定确实有所帮助。旨在类内部使用但不在类外部使用的方法和变量通常以下划线开头。这为未来的开发者提供了一个提示,即特定的方法或变量不是作为类公共接口的一部分。第三方包通常在其文档中明确指出,这些方法可能会随着版本的更新而改变,不应被明确依赖。

在 第二章 中,你学习了类之间的耦合,以及松耦合是期望的状态。一个特定的类从另一个类依赖的方法和数据越多,它们之间的耦合就越大。当一个类依赖于另一个类的 内部 时,这种情况会加剧,因为这意味着大多数类不能在孤立的情况下进行改进,否则有破坏其他代码的风险。

抽象和封装通过将相关的功能组合在一起并隐藏对其他人无关的部分来共同工作。这有时被称为“信息隐藏”,它允许类的内部(或系统总体)快速变化,而无需其他代码以相同的速度变化。

3.3. 尝试一下

我想让你现在练习一下封装。假设你正在编写代码来问候新顾客进入在线商店。问候语能让顾客感到受欢迎,并给他们留下继续停留的动机。编写一个包含单个类 Greeter 的问候模块,该类有三个方法:

  1. _day(self)—返回当前日期(例如,星期日)

  2. _part_of_day(self)—如果当前小时在中午 12 点之前,则返回“morning”,如果当前小时是中午 12 点或之后但不到下午 5 点,则返回“afternoon”,从下午 5 点开始返回“evening”

  3. greet(self, store)—给定商店名称 store 和前两个方法的输出,打印一个类似以下格式的消息

     Hi, welcome to <store>!
     How’s your <day> <part of day> going?
     Here’s a coupon for 20% off!

_day_part_of_day 方法可以表示为私有(以一个下划线开头命名),因为 Greeter 类唯一需要公开的功能是 greet。这有助于封装 Greeter 类的内部结构,使其唯一公开的关注点是执行问候本身。

小贴士

你可以使用 datetime.datetime.now() 获取当前的日期时间对象,使用 .hour 属性获取一天中的时间,使用 .strftime('%A') 获取当前星期。

结果如何?你的解决方案应该类似于以下示例。

列表 3.3. 为在线商店生成问候信息的模块
from datetime import datetime

class Greeter:
    def __init__(self, name):
        self.name = name

    def _day(self):                            *1*
        return datetime.now().strftime('%A')

    def _part_of_day(self):                    *2*
        current_hour = datetime.now().hour

        if current_hour < 12:
            part_of_day = 'morning'
        elif 12 <= current_hour < 17:
            part_of_day = 'afternoon'
        else:
            part_of_day = 'evening'

        return part_of_day

    def greet(self, store):                    *3*
        print(f'Hi, my name is {self.name}, and welcome to {store}!')
        print(f'How\'s your {self._day()} {self._part_of_day()} going?')
        print('Here\'s a coupon for 20% off!')

    ...
  • 1 格式化日期时间以获取当前星期名称

  • 2 根据当前小时确定一天中的时段

  • 3 使用所有计算出的位打印问候语

Greeter 打印所需的问候信息,所以一切都很顺利,对吧?不过,如果你仔细观察,会发现 Greeter 知道如何做得太多。Greeter 应该只负责问候人们;它不应该负责确定星期几以及现在是白天哪个时段!封装并不理想。你该怎么办呢?

3.3.1. 重构

封装和抽象通常是迭代过程。随着你编写更多的代码,之前有意义的结构可能会显得笨拙或强制。我向你保证,这是完全自然的。如果你觉得代码在跟你作对,这可能意味着是时候进行重构了。重构代码意味着更新其结构,使其更有效地满足你的需求。当你重构时,你通常会需要改变你表示行为和概念的方式。移动数据和实现是改进代码的必要部分。这有点像每隔几年就重新布置客厅,以适应你当前的心情。

现在重构你的Greeter代码,将获取关于日期和时间的函数从Greeter类中移出,并在模块内创建它们作为独立的函数。

当这些函数作为方法时,它们从未使用过self参数,所以它们看起来几乎一样,只是没有这个参数:

def day():
    return datetime.now().strftime('%A')

def part_of_day():
    current_hour = datetime.now().hour

    if current_hour < 12:
        part_of_day = 'morning'
    elif 12 <= current_hour < 17:
        part_of_day = 'afternoon'
    else:
        part_of_day = 'evening'

    return part_of_day

然后Greeter类可以通过直接引用这些函数来调用它们,而不是使用self.前缀:

class Greeter:
    ...

    def greet(self, store):

        print(f'Hi, my name is {self.name}, and welcome to {store}!')
        print(f'How\'s your {day()} {part_of_day()} going?')
        print('Here\'s a coupon for 20% off!')

现在的Greeter类只知道它需要制作问候语所需的信息,无需担心获取这些信息的细节。另外,如果需要的话,daypart_of_day函数也可以在其他地方使用,而无需引用Greeter类。这真是一举两得!

最终,你可能会开发更多与日期时间相关的功能,这时将所有这些功能重构到它们自己的模块或类中可能是有意义的。我通常等到几个函数或类呈现清晰的关联关系时才这样做,但有些开发者喜欢从一开始就严格保持事物分离。

3.4. 编程风格也是一种抽象

在过去的几年中,许多编程风格(或范式)已经变得流行,通常是从特定的商业领域或用户群体中产生的。Python 支持几种风格,它们以自己的方式是抽象的。记住,抽象是将概念存储起来以便容易消化的行为。每种编程风格以不同的方式存储信息和行为。没有一种风格是“正确”的,但有些在解决特定问题时比其他风格更好。

3.4.1. 过程式编程

我在本章和前几章中讨论并展示了一些过程式编程的例子。过程式软件更喜欢使用过程调用来操作,我们倾向于称之为“函数”。这些函数没有被封装到类中,因此它们通常只依赖于它们的输入,偶尔也依赖于一些全局状态。

NAMES = ['Abby', 'Dave', 'Keira']

def print_greetings():                       *1*
    greeting_pattern = 'Say hi to {name}!'
    nice_person_pattern = '{name} is a nice person!'
    for name in NAMES:
        print(greeting_pattern.format(name=name))
        print(nice_person_pattern.format(name=name))
  • 1 仅依赖于名称的独立函数

如果你相当新于编程,这种风格可能会让你感到熟悉,因为它是一个常见的起点。从一个长过程到调用几个函数的过程感觉更自然,因此这是一个很好的教学方法。过程式编程的优点与在 3.1.4 节 中讨论的优点有很大重叠,因为过程式编程非常重视函数。

3.4.2. 函数式编程

函数式编程 听起来 会和过程式编程一样——函数 就在名字中!但尽管函数式编程确实非常依赖函数作为抽象的形式,但思维模型却相当不同。

函数式语言要求你将程序视为函数的组合。例如,for 循环被操作列表的函数所取代。在 Python 中,你可能会写成以下这样:

numbers = [1, 2, 3, 4, 5]
for i in numbers:
    print(i * i)

在函数式语言中,你可能写成这样:

print(map((i) => i * i, [1, 2, 3, 4, 5]))

在函数式编程中,函数有时接受其他函数作为参数或作为结果返回。这在前面的代码片段中可以看到;map 接受一个接受一个参数并将其自身相乘的匿名函数。

Python 有许多函数式编程工具;其中许多可以通过内置关键字使用,其他则来自内置模块,如 functools 和 itertools。尽管 Python 支持 函数式编程,但这并不是首选的方法。函数式语言的一些常见特性,如 reduce 函数,已经被移动到 functools。

许多人认为,使用命令式 Python 方式执行这些操作更清晰。使用函数式 Python 功能的样子如下:

from functools import reduce

squares = map(lambda x: x * x, [1, 2, 3, 4, 5])
should = reduce(lambda x, y: x and y, [True, True, False])
evens = filter(lambda x: x % 2 == 0, [1, 2, 3, 4, 5])

在 Python 中,首选的做法如下:

squares = [x * x for x in [1, 2, 3, 4, 5]]
should = all([True, True, False])
evens = [x for x in [1, 2, 3, 4, 5] if x % 2 == 0]

尝试每种方法,并在之后打印变量。你会发现它们产生相同的结果;这取决于你使用哪种你认为是最容易理解的风格。

我喜欢的 Python 函数式特性之一是 functools.partial。这个函数允许你从一个现有的函数中创建一个新的函数,并设置一些原始函数的参数。这有时比编写一个调用原始函数的新函数更清晰,尤其是在通用函数表现得像具有特定名称的函数的情况下。在将数字提高到幂的情况下,x 的平方通常称为 x平方,而 x 的立方通常称为 x立方。你可以在 Python 中使用 partial 辅助函数看到这是如何工作的:

from functools import partial

def pow(x, power=1):
    return x ** power

square = partial(pow, power=2)  *1*
cube = partial(pow, power=3)    *2*
  • 1 一个新的函数,square,其行为类似于 pow(x, power=2)

  • 2 一个新的函数,cube,其行为类似于 pow(x, power=3)

使用熟悉的名字来表示行为对于那些稍后阅读你的代码的人来说非常有帮助。

函数式编程如果使用得当,与过程式编程相比可以提供许多性能优势,使其在数学和数据模拟等计算密集型领域非常有用。

3.4.3. 声明式编程

声明式编程关注于声明任务的参数,而不指定如何完成它。完成任务的具体细节大部分或全部从开发者那里抽象出来。当你需要重复执行一个高度参数化的任务,而参数只有细微变化时,这很有用。通常这种编程风格是通过领域特定语言(DSLs)实现的。DSLs 是针对特定任务集高度专业化的语言(或类似语言的标记)。HTML 就是一个这样的例子;开发者可以描述他们想要创建的页面结构,而不必说明浏览器应该如何将<table>转换为屏幕上的行和字符。另一方面,Python 是一种通用语言,可用于许多目的,并需要开发者的指导。

当你的软件允许用户执行高度重复的任务时,例如将代码翻译到另一个系统(SQL、HTML 等)或创建多个类似对象以供重复使用时,可以考虑探索声明式编程。

Python 中声明式编程的一个广泛应用的例子是 plotly 包。Plotly 允许你通过描述你想要的图表类型来创建图表。来自 plotly 文档(plot.ly/python/)的一个例子如下:

import plotly.graph_objects as go

trace1 = go.Scatter(                            *1*
    x=[1, 2, 3],                                *2*
    y=[4, 5, 6],                                *3*
    marker={'color': 'red', 'symbol': 104},     *4*
    mode='markers+lines',                       *5*
    text=['one', 'two', 'three'],               *6*
    name='1st Trace',
)
  • 1 声明构建散点图的意图

  • 2 声明 x 轴数据的形状

  • 3 声明 y 轴数据的形状;易于与 x 轴比较

  • 4 声明线条标记的外观

  • 5 声明在图中将使用标记和线条

  • 6 声明每个标记的工具提示文本

这设置了图表的数据以及视觉特性。每个期望的结果都是声明的,而不是通过程序性添加。

为了比较,想象一个程序性的方法。你不会向单个函数或类提供多个配置数据片段,而是将每个配置步骤作为更长过程的一个独立行来执行:

trace1 = go.Scatter()
trace1.set_x_data([1, 2, 3])         *1*
trace1.set_y_data([4, 5, 6])
trace1.set_marker_config({'color': 'red', 'symbol': 104, 'size': '10'})
trace1.set_mode('markers+lines')
...
  • 1 每条信息都通过方法显式设置。

当用户需要执行大量配置时,声明式风格可以提供一个更简洁的接口。

3.5. 类型、继承和多态

当我这里提到类型时,我不是指键盘上的输入。一种语言的类型,或类型系统,是它选择如何管理变量数据类型的方式。有些语言在编译时检查数据类型。有些在运行时检查。有些语言将x = 3的数据类型推断为整数,而有些则要求显式地声明int x = 3

Python 是一种动态类型语言,这意味着它在运行时确定其数据类型。它还使用一种称为鸭子类型的系统,其名称来源于习语,“如果它像鸭子走路,如果它像鸭子嘎嘎叫,那么它一定是一只鸭子。”与许多语言会在程序引用类实例上的未知方法时无法编译不同,Python 会在执行期间始终尝试调用该方法,如果实例的类上不存在该方法,则会引发一个AttributeError。通过这种机制,Python 可以实现一定程度的多态性,这是一种编程语言特性,其中不同类型的对象通过一致的方法名提供专门的行为。

在面向对象编程的兴起时期,有一个竞赛是尝试将完整的系统建模为继承类层的级联。ConsolePrinter继承自PrinterPrinter继承自BufferBuffer继承自BytesHandler,依此类推。其中一些层次结构是有意义的,但许多导致了难以更新的刚性代码。试图进行一个更改可能会导致整个树形结构从上到下或从下到上产生巨大的连锁反应。

现在,偏好已经转向将行为组合到对象中。组合是分解的逆过程;将功能片段组合在一起以实现一个完整的概念。图 3.6 对比了更严格的继承结构与由许多特性组成的对象结构。狗是一种四足动物,是一种哺乳动物,也是一种犬科动物。使用继承,你会被迫从这些类别中创建一个层次结构。所有犬科动物都是哺乳动物,这似乎是合理的,但并非所有哺乳动物都是四足动物。组合使你摆脱了层次结构的限制,同时仍然提供了两个事物之间相关性的概念。

图 3.6. 继承与组合

组合通常是通过一种称为接口的语言特性来完成的。接口是对特定类必须实现的方法和数据的形式定义。一个类可以实现多个接口,以表明它具有这些接口行为的并集。

Python 缺少接口。哦,不!如何避免深层继承层次结构?幸运的是,Python 通过鸭子类型系统和多重继承实现了这一点。与许多静态类型语言只允许一个类从另一个类继承不同,Python 可以支持从任意数量的类继承。可以使用这种机制构建类似于接口的东西,在 Python 中通常被称为mixin

假设你想创建一个可以说话和打滚的狗的模型。你知道你最终会想要为其他也能做特技的动物建模,所以为了将这些行为变成类似接口的东西,你可以用 Mixin 后缀来明确你的意图。有了这些行为混入,你将能够创建一个 Dog 类,它可以 speakroll_over,如下所示,同时有自由让未来的动物使用相同的方法 speakroll_over

列表 3.4. 多重继承提供类似接口的行为
class SpeakMixin:                            *1*
    def speak(self):
        name = self.__class__.__name__.lower()
        print(f'The {name} says, "Hello!"')

class RollOverMixin:                         *2*
    def roll_over(self):
        print('Did a barrel roll!')

class Dog(SpeakMixin, RollOverMixin):        *3*
    pass
  • 1 说话行为被封装在 SpeakMixin 中以展示其可组合性。

  • 2 RollOverMixin 中的打滚行为也是可组合的。

  • 3 你的狗可以说话,打滚,以及你教给它的任何其他东西。

现在 Dog 已经从一些混入中继承,你可以检查你的狗是否知道一些特技:

dog = Dog()
dog.speak()
dog.roll_over()

你应该看到以下输出:

The dog says, "Hello!"
Did a barrel roll!

狗知道英语的事实令人怀疑,但除此之外,一切正常。我们将在第七章 chapters 7 和第八章 8 中更深入地探讨继承和一些相关概念,所以请耐心等待!

3.6. 识别错误抽象

将抽象应用于新代码和识别现有代码中的抽象不工作几乎同样有用。这可能是因为新代码已经证明该抽象不适合所有用例,或者你可能看到一种用不同范式使代码更清晰的方法。无论哪种情况,花时间关心代码是一项他人会感激的任务,即使他们没有明确意识到这一点。

3.6.1. 方块插在圆孔里

正如我所说的,抽象应该被利用来确保事情更清晰、更容易。如果一个抽象让你不得不弯腰驼背才能让某件事工作,考虑更新它以消除摩擦或完全用新方法替换它。我试图用现有的东西使新代码工作,结果发现改变环境比适应它更容易。这里的权衡是时间和精力,包括重写代码和确保它仍然工作。然而,你前期投入的时间可能会在长远上节省大家的时间。

如果第三方包的接口导致摩擦,而你又没有时间或精力去更新他们的代码,你总是可以考虑为你的代码创建一个围绕该接口的抽象。这在软件中通常被称为适配器,我把它比作在另一个国家使用机场旅行插头。你当然不能改变法国的电源插座(至少会有人生气),而且你也没有为你的设备准备法国插头。所以即使旅行插头要价 48 欧元和你的第一个孩子,但它比为三四个不同的设备寻找和购买法国电源供应要便宜得多。在软件中,你可以创建自己的适配器类,它具有程序期望的接口,每个方法中的代码都会在幕后调用不兼容的第三方对象。

3.6.2. 聪明反被聪明误

我一直在谈论编写流畅的代码,但过于聪明的解决方案也可能很痛苦。如果这样的解决方案提供了太多的魔法而不是足够的粒度,你可能会发现其他开发者会创建自己的解决方案来完成工作,从而抵消了你提供单一工作实现的努力。健壮的软件必须权衡用例的频率和影响,以确定哪些需要适应;常见用例应该尽可能平滑,而罕见用例如果需要,可以是笨拙的或明确不支持。你的解决方案应该“足够聪明”,这是一个公认很难达到的目标。

话虽如此,如果某件事感觉不自然或笨拙,给它一些时间。如果过了一段时间后它仍然感觉不自然或笨拙,问问别人他们是否同意。如果他们说不同意,但仍然感觉不自然或笨拙,那么它可能确实是不自然或笨拙的。用抽象让世界变得更好一点吧!

摘要

  • 抽象是推迟代码必要理解的一种工具。

  • 抽象有多种形式:分解、封装、编程风格以及继承与组合。

  • 每种抽象方法都有用,但上下文和使用范围是重要的考虑因素。

  • 重构是一个迭代过程;曾经有效的抽象可能需要稍后重新审视。

第四章. 为高性能设计

本章涵盖

  • 理解时间和空间复杂度

  • 测量你代码的复杂性

  • 在 Python 中选择不同活动的数据类型

一旦你编写了工作的代码,通常还需要做额外的工作。你需要你的代码不仅完成它的任务,而且要快速完成。你的代码的性能是指它如何有效地利用资源,如内存和时间。在可接受水平上运行的软件,意味着它有效地利用资源,并在期望的时间内响应任务,被称为性能良好

软件性能每天都会影响现实世界的人们,无论他们是在尝试将最新的自拍照上传到 Instagram,还是在进行实时市场分析以挑选股票。软件应该有多高性能通常取决于用户的感知。如果某件事感觉是瞬间的,它可能已经足够快了。

软件性能也会影响最终结果。如果你的软件需要你在磁盘或数据库中存储某些东西,最小化所需的存储量可以为你节省金钱。如果软件运行得更快,那么能够提供盈利决策信息的软件可以为你赚取更多金钱。性能对现实世界有实际影响。

人类感知

人类通常将超过 100 毫秒的变化感知为瞬间的。如果他们点击一个按钮,屏幕在 50 毫秒内做出响应,他们会感到满意。当响应速度超过 100 毫秒时,人们开始注意到延迟。

对于下载大文件等长时间运行的活动,延迟有时是无法避免的。在这些情况下,准确的进度更新很重要,因为它们改变了感知进度,使其感觉更快。

4.1. 跨越时空

如果你阅读有关高性能软件的内容,你可能会遇到时间复杂度空间复杂度这样的短语。这些术语听起来像是直接来自量子力学或天体物理学,但它们在软件领域也有其位置。

时间复杂度和空间复杂度是衡量你的软件随着输入增长所需的更多执行时间、内存或磁盘存储的量。你的软件消耗时间或空间越快,其复杂度就越高。

复杂度并不是要成为一个精确的定量测量;相反,它帮助你定性理解你的软件在最坏情况下将有多快和多大规模。在本节中,我将帮助你建立对复杂度测量的直觉,以便你可以在工作中提高性能。然而,确定软件复杂度有一个正式的过程,我稍后会提到。

4.1.1. 复杂度有点复杂

我不会在这方面拐弯抹角:测量复杂度可能很困难,有时也令人困惑。在学校时,这对我来说并没有太多意义——我现在所知道的一切都是通过反复的实际应用学到的。准备好自己也要这样做。

复杂度的确定是通过一种称为渐近分析的过程来进行的,它涉及观察代码并确定其最坏情况性能的界限。

注意

请记住,复杂度测量是用来对比完成特定任务的不同方法的;它们对于比较无关的任务并不那么有用。例如,比较两个排序数字列表的算法是有用的,但你不能将列表排序算法与搜索树进行比较。确保你比较的是苹果对苹果。

在渐近分析中使用的符号一开始可能看起来很神秘,但它有一个简单的英文翻译。你通常会看到用大 O 符号表示的复杂度,这表示正在分析的代码的最坏情况性能。大 O 符号看起来像O(n²)——通常读作“n 的平方阶”——其中n是输入数量,n²是复杂度。这是“代码运行所需的时间与输入数量的平方成正比”的简写,如图 4.1 所示。O(n²)比写出来要快得多。我将在本章的其余部分更多地使用大 O 符号。

图 4.1. O(n²)是大 O 符号的简写,表示yx²的关系。

4.1.2. 时间复杂度

时间复杂度是衡量你的代码相对于其输入执行任务速度的度量。随着输入数量的增加,时间复杂度告诉你代码将以多快的速度变慢。这可以帮助你推理随着输入规模的增加,任务应该花费多长时间。

线性

线性复杂度是代码中出现最频繁的复杂度之一。这种复杂度之所以被称为线性,是因为将输入数量与时间的关系绘制成图会产生一条直线。如果你回想一下数学中直线的方程,y = mx + b,你可以将x视为输入数量,将y视为程序执行所需的时间。无论输入如何,你的程序可能都有一些开销(方程中的b,或截距),并且每个额外的输入都会增加一些执行时间(m,或斜率)。这如图 4.2 所示。

图 4.2. 可视化线性复杂度的任务

线性复杂度在软件中很常见,因为许多操作需要对列表中的每个项目执行一些任务:打印姓名列表、求整数列表的和等等。随着列表的增长,计算机需要花费的时间也会成比例增长。求 1000 个整数的和大约需要的时间是求 2000 个整数和所需时间的一半。对于某些数量的项目,n,这些活动与n线性,或者在大的 O 符号中,O(n)。

你可以通过寻找for循环来在 Python 中找到可能是O(n)的代码。对列表、集合或其他序列的单个循环很可能是线性的:

names = ['Aliya', 'Beth', 'David', 'Kareem']
for name in names:
    print(name)

即使你在循环内部执行多个步骤,这也依然成立:

names = ['Aliya', 'Beth', 'David', 'Kareem']
for name in names:
    greeting = 'Hi, my name is'
    print(f'{greeting} {name}')

即使你在同一个列表上循环固定次数,这依然成立:

names = ['Aliya', 'Beth', 'David', 'Kareem']
for name in names:
    print(f'This is {name}!')

message = 'Let\'s welcome '
for name in names:
    message += f'{name} '
print(message)

尽管你在姓名列表上循环了两次,但再次从直线的方程的角度来考虑。第一个循环每个项目需要一些时间,f,第二个循环每个项目需要一些时间,g。直线方程可能类似于y = fx + gx + b,这相当于y = (f + g)x + b。即使它是一条更陡的线,它仍然是一条线。

这就是渐近分析中“渐近”部分的作用。尽管特定的活动可能是陡峭的线性,但如果输入足够多,其他更复杂的操作仍然可以超过它,如图 4.3 所示。图 4.3。

图 4.3. 大规模的高阶复杂度

与平方成正比

另一种时间复杂度是与输入的平方成正比(O(n²))。这种情况出现在,对于列表中的每个项目,你需要查看列表中的其他每个项目时。随着输入的增加,你的代码必须遍历额外的项目,但它还需要在每次迭代中遍历这些额外的项目。执行时间的增加是成倍的。

你可以通过 Python 代码中嵌套循环的存在来发现这一点。以下代码检查列表中是否有任何重复项:

def has_duplicates(sequence):
    for index1, item1 in enumerate(sequence):        *1*
        for index2, item2 in enumerate(sequence):    *2*
            if item1 == item2 and index1 != index2:  *3*
                return True
    return False
  • 1 外循环遍历序列中的每个元素。

  • 2 内循环再次遍历每个元素,对于外循环中的每个元素。

  • 3 检查两个元素是否具有相同的值,但不是序列中的相同特定元素

O(n²)是此代码的最坏情况,因为即使只有最后的项目是重复的,或者没有重复项存在,代码仍然必须遍历所有输入才能完成。如果前两个项目是重复的,代码将会快得多,因为它可以立即停止,但检查最坏情况对于更好地了解代码的能力是有用的。大 O 符号总是测量代码的最坏情况复杂度,出于这个原因。

附加符号

有时,计算不仅是最坏情况,还有平均情况和最好情况是有用的。大θ(大Ω)符号用于最好情况分析,而大θ(大Θ)符号用于表示上界和下界具有指定的复杂度。通常这些可以帮助你从多个选择中选出最适合你想要完成的事情的方法。许多算法的复杂度可以通过在线搜索找到,例如搜索“quicksort 的复杂度”。你还可以在 Python 文档中找到一些常见操作的时空复杂度(wiki.python.org/moin/TimeComplexity)。

常数时间

理想复杂度是常数时间(O(1)),它不依赖于输入的大小。没有什么比常数时间更好,因为这要求软件随着输入的增长而加速!Python 中的一些数据类型实现了常数时间,我将在稍后详细介绍。

一些通常会是线性(或更差)的问题,在前期计算后可以变成常数。这个初始计算本身可能不是常数,但如果它允许许多后续步骤变成常数,那么这可以是一个很好的权衡。

4.1.3. 空间复杂度

就像时间复杂度一样,空间复杂度是衡量你的代码随着输入增长如何使用磁盘空间或内存的度量。然而,空间复杂度很容易被忽视,因为它并不总是直接观察到的。有时,不高效的磁盘空间使用只有在收到提示说你的电脑上没有剩余磁盘空间时才会显现出来。在编写代码时考虑空间是一个好习惯,这样你就不会耗尽资源。

垃圾清理工

另一个使空间复杂度在 Python 中更困难的事情是,你通常不自己管理内存。在某些语言中,你必须显式地分配和释放内存,这迫使你管理代码如何使用资源。Python 使用自动垃圾回收,这释放了运行程序不再使用的对象占用的内存。

内存

程序使用过多内存的常见方式是在不需要的情况下将大型数据文件完全读入内存。假设你有一个包含每个活着的人及其最喜欢的颜色的文本文件。你想要知道喜欢每种颜色的人数。你可能会考虑将整个文件作为一个行列表读取并操作该列表:

color_counts = {}

with open('all-favorite-colors.txt') as favorite_colors_file:
    favorite_colors = favorite_colors_file.read().splitlines()   *1*

for color in favorite_colors:
    if color in color_counts:
        color_counts[color] += 1
    else:
        color_counts[color] = 1
  • 1 将整个文件读入行列表

地球上有很多人。即使文件只包含一列最喜欢的颜色,并且每一行使用 1 字节的数据,该文件的大小也仍然是超过 7 GB。你可能机器上有这么多内存,但任务并不要求你一次性获取所有行信息。

在 Python 中,你可以通过for循环逐行读取文件,并且每次循环迭代中,下一行将替换内存中的当前行。尝试更新代码,每次只从文件中读取一行,并在获取所需内容后返回。

color_counts = {}

with open('all-favorite-colors.txt') as favorite_colors_file:
    for color in favorite_colors_file:                       *1*
        color = color.strip()                                *2*

        if color in color_counts:
            color_counts[color] += 1
        else:
            color_counts[color] = 1
  • 1 一次只读取一行

  • 2 从每行中移除尾随换行符

通过逐行读取并在获取所需内容后丢弃它们,你的内存使用量将仅达到文件中最长行的高度。这要好得多!空间复杂度从O(n)降低到O(1)。

磁盘空间

我过去在长期运行的应用程序中遇到过磁盘空间问题。这些问题有时很难发现,因为它们并不总是立即引起问题。可能需要几周或几个月,你才会耗尽磁盘空间,这可能是由于你的程序每次写入少量数据,或者简单地因为你有大量的存储空间。

许多大型网络应用程序会发出它们的日志,以便进行调试或分析。如果你在代码中引入了一个在生产环境中每分钟被调用 1,000 次的日志语句,这可能会迅速消耗磁盘空间。你可能想删除该行,将其移动到调用频率较低的地方,或者改进存储日志的策略。

寻找将方法从高阶复杂性转换为低阶复杂性的机会,几乎总是比试图从特定的代码行中挤出性能提升要有效得多。使用复杂性分析来了解这些机会在您的软件中的位置。继续阅读,了解您如何使用 Python 内置的一些功能来利用这些机会。

4.2. 性能和数据类型

虽然您的代码应该考虑到时间和空间复杂度,但它最终将建立在 Python 现有的数据类型之上。以下几节涵盖了多个用例,以及最适合它们的数据类型。

4.2.1. 常数时间的数据类型

记住,理想性能是常数时间,不会随着输入数量的增加而增加。Python 的dict(字典)和set(集合)类型在添加、删除和访问项时表现出这种行为。它们在底层相当相似,主要区别在于字典将键映射到值,而集合表示一组唯一的、单独的项。遍历这两种数据类型中的项仍然是O(n)时间,因为它取决于对象中包含的项的数量。但是,获取特定项或检查特定项是否存在,无论项的总数是多少,都是快速的。

假设您现在对获取所有最喜欢的颜色的唯一集合感兴趣,以便检查是否有颜色没有被表示,而不是计数世界上最喜欢的颜色。您仍然可以像以前一样逐行读取文件,但您如何表示数据并检查特定颜色呢?

尝试表示数据并检查特定颜色。然后回到这里,将您的作品与以下列表进行比较,看看您做得如何。

列表 4.1. 使用 Python 的功能来最小化空间
all_colors = set()

with open('all-favorite-colors.txt') as favorite_colors_file:
    for line in favorite_colors_file:                        *1*
        all_colors.add(line.strip())                         *2*

print('Amber Waves of Grain' in all_colors)                  *3*
  • 1 遍历文件仍然是 O(n)时间。

  • 2 向集合中添加元素的时间复杂度为 O(1),但空间复杂度为 O(n)。

  • 3 集合的成员资格是一个 O(1)问题。

通过使用集合来保存文件中遇到的独特颜色列表,您可以在循环之后以常数时间(O(1))检查集合中的特定颜色。

4.2.2. 线性时间的数据类型

Python 中的list数据类型主要表现出O(n)操作;在列表中确定成员资格或在列表的任意位置添加新项对于元素较多的列表来说较慢。从列表的末尾添加或删除元素需要O(1)时间。当存储的项无法唯一标识时,列表很有用。

tuple类型在性能上与list类似,关键区别在于元组创建后不能被更改。

4.2.3. 数据类型操作的空间复杂度

现在你已经熟悉了 Python 一些内置数据结构的时间复杂度,我将教你一些使用它们的技巧。我们之前看到的数据类型都是 可迭代的——支持对其内容进行迭代的对象(例如在 for 循环中)。对一组元素进行迭代的时间复杂度几乎总是 O(n);如果有更多元素,遍历每个元素需要更多时间。但空间复杂度如何呢?

对于我们之前看到的数据类型,它们的全部内容都存储在内存中。如果一个列表有 10 个元素,它比只有一个元素的列表在内存中占用大约 10 倍的空间,如图 4.4 所示。这意味着它们的 空间 复杂度也是 O(n)。这可能会带来问题,就像将 76 亿条记录读入内存一样有问题。如果我们不需要一次性获取所有这些数据,我们可能能够找到一种更有效的方法。

图 4.4. 列表内存占用

图片

进入 生成器。生成器是 Python 中的构造,一次产生一个值,直到下一个值被请求时才会暂停(图 4.5)。这很像你之前按行读取文件的方法。通过逐个产生值,生成器避免了将所有产生的值一次性存储在内存中。

图 4.5. 使用生成器节省空间

图片

如果你之前在 Python 中使用过 range 函数,那么你已经使用过生成器了。range 接受指定你想要的范围边界的参数。如果 range 在内存中存储了范围的所有数字,那么像 range(100_000_000) 这样的代码很快就会耗尽你的可用内存。相反,range 只存储范围的 bounds 并逐个产生值。但这是如何实现的呢?

为了高效地使用空间,生成器使用了 Python 的 yield 关键字。在产生一个值之后,它们将执行权交回调用代码。所以 yield 产生一个值,然后交回执行权。

yield 与 Python 的 return 语句非常相似,但不同之处在于你可以在 yield 一个值之后执行操作。这可以用来为下一个你想要产生的值做准备。下面的列表展示了 range 在底层是如何工作的。注意 yield 关键字的使用以及 yield 之后 current 值的递增。

列表 4.2. 使用 yield 暂停并准备
def range(*args):
    if len(args) == 1:     *1*
        start = 0
        stop = args[0]
    else:
        start = args[0]
        stop = args[1]

    current = start

    while current < stop:
        yield current      *2*
        current += 1       *3*
  • 1 解析参数以确定范围的边界

  • 2 逐个产生每个值(one at a time))

  • 3 为下一个值执行必要的设置

在这个实现中,你会看到一种模式,在生成器中会经常重复:

  1. 执行生成所有值所需的主要设置。

  2. 创建一个循环。

  3. 在循环的每次迭代中产生一个值。

  4. 更新循环下一次迭代的状。

现在尝试检查你的 range 生成器的值。例如,你可以使用 list(range(5, 10)) 将其转换为列表。你还可以通过将 range(5, 10) 保存到一个变量中,并连续调用 next(my_range) 来逐个前进一个值。

现在你已经掌握了这个模式,我想让你编写你自己的生成器。你的生成器函数 squares 将接受一个整数列表,并产生每个整数的平方。试一试,然后回到以下列表中看看你的成果。

列表 4.3. 一个生成平方数的简短生成器
def squares(items):
    for item in items:
        yield item ** 2

squares 函数最终变得相当紧凑,因为没有需要设置或状态管理的事情要做。我也说过这个函数接受一个列表,但真正酷的是你可以传入另一个生成器。squares(range (100_000_000)) 也可以正常工作。它将一次只存储范围中的一个项目和平方结果,从而节省更多的空间(如图 4.6 所示)。

图 4.6. 连锁生成器的内存使用情况

图片

我建议尽可能使用生成器而不是列表,因为如果你需要,你可以通过编写 list(range(10000))list(squares([1, 2, 3, 4])) 来从生成器中构建一个完整的列表。使用生成器可以节省内存,而且还可以节省时间,因为消耗生成器值的代码可能根本不需要所有的值。

延迟评估

每次产生一个值,并且消耗代码可能不需要你能够产生的所有值,这种想法通常被称为 延迟评估。它被称为 延迟 是因为你想做尽可能少的工作,并且只有在你被明确要求这样做的时候。想象一下,每次生成器被要求 yield 一个值时,它都会发出夸张的叹息。

4.3. 让它工作,让它正确,让它快速

“先让它工作,再让它正确,最后让它快速”这句谚语来自极限编程的创始人肯特·贝克。从表面上看,这可能意味着你应该首先编写可工作的代码,然后 重新设计它以使其清晰简洁,最后 然后 使其具有性能。但我喜欢将这些规则看作是在编写代码的每次小迭代中采取的步骤。记住,设计、实现和重构都在编码的紧密循环中发生。

4.3.1. 让它工作

这正是开发者花费大量时间做的事情。你试图将问题陈述或想法转化为实现目标的代码。开发者(包括我自己)经常在重构或性能之前一直工作在问题上。这可以感觉像是一个鸡生蛋的问题:如果“它”还没有完成,我怎么能让它快起来呢

就像分解对于软件本身是有用的,它作为分解目标为可管理块的工具也是很有用的。这些更小的目标中的每一个都可以在实现过程中逐步实现和检查,以实现更大的目标。这种方法中“让它工作”也更容易,因为“它”是一个更细粒度的目标。你更容易为“计算下落物体的速度”构思一些想法,而不是“制作一个物理引擎”。

4.3.2. 让它变得正确

让它工作,关键在于尝试从 A 点到 B 点。如果你清楚任务的目的是什么,“它工作了吗?”这是一个二元答案。

让它变得正确,关键在于重构。重构旨在以更清晰或更适应的方式重新实现现有代码,同时提供相同的一致结果.^([1])

¹

有一种观点认为,你可以为编写的代码编写测试,如果测试足够并且通过,你可以在进行更改时依赖它们,以确保你没有破坏任何东西。关于这个主题有许多优秀的文本。参见哈里·佩尔西瓦尔,Python 测试驱动开发,第二版(O’Reilly,2017)。

重构没有明确的“完成”时刻。在实现过程中,你将发现自己会迭代,当重新访问代码以添加新功能时也是如此。关于何时你绝对应该重构的一个指标,马丁·福勒的三法则说,在你已经实现了类似功能三次左右时,你应该重构你的代码,为这种行为提供一个抽象。我喜欢这个前提,因为它暗示了重构的平衡:不要立即抽象化某物,甚至在你复制了两次之后,因为这可能是过早的。等待看看会出现什么用例。它们将使你能够更有效地概括解决方案,并确保它是必要的。

让某物变得正确的一个方面是利用语言的优势。看看以下代码,它确定列表中最频繁出现的整数:

def get_number_with_highest_count(counts):       *1*
    max_count = 0
    for number, count in counts.items():
        if count > max_count:
            max_count = count
            number_with_highest_count = number
    return number_with_highest_count

def most_frequent(numbers):
    counts = {}
    for number in numbers:                       *2*
        if number in counts:
            counts[number] += 1
        else:
            counts[number] = 1

    return get_number_with_highest_count(counts)
  • 1 确定映射数字到计数的字典中计数最高的数字

  • 2 统计数字出现的次数,以查看哪个数字出现次数最多

我已经让这个工作了,但 Python 有一些工具可以使这更容易。第一个工具帮助处理增加计数的代码。对于列表中的每个数字,它必须检查我们是否已经看到它,以知道是否可以增加计数或是否需要初始化计数。Python 有一个内置的数据类型可以避免这种额外的工作:defaultdict。你可以告诉defaultdict它存储的值的类型,如果访问一个新的键,它将默认为该类型的合理值:

from collections import defaultdict       *1*

def get_number_with_highest_count(counts):
    max_count = 0
    for number, count in counts.items():
        if count > max_count:
            max_count = count
            number_with_highest_count = number
    return number_with_highest_count

def most_frequent(numbers):
    counts = defaultdict(int)             *2*
    for number in numbers:
        counts[number] += 1               *3*

    return get_number_with_highest_count(counts)
  • 1 从 collections 模块导入 defaultdict

  • 2 计数是整数,所以defaultdict中每个值的默认类型应该是 int。

  • 3 默认的 int 值是 0,所以当我们第一次看到数字时,它的计数将是 0 + 1 = 1。

还不错——你节省了一行代码,函数的精神也稍微清晰了一些。但你可以做得更好。Python 还有一个用于在序列中计数事物的辅助工具:

from collections import Counter                  *1*

def get_number_with_highest_count(counts):
    max_count = 0
    for number, count in counts.items():
        if count > max_count:
            max_count = count
            number_with_highest_count = number
    return number_with_highest_count

def most_frequent(numbers):
    counts = Counter(numbers)                     *2*
    return get_number_with_highest_count(counts)
  • 1 计数器也位于集合模块中。

  • 2 几乎与你自己制作的计数字典的行为相同

你又节省了几行代码,现在 most_frequent 的精神相当清晰:计数唯一数字,并返回计数最高的那个。但 get_number_with_highest_count 呢?它是在一个将数字映射到它们的计数的字典中寻找最大值。Python 提供了两个工具可以简化这个函数。

第一个功能是 maxmax 接受一个可迭代对象(列表、集合、字典等)并返回该可迭代对象中的最大值。在字典的情况下,max 默认返回 的最大值。counts 字典的键是数字本身,而不是计数。max 接受第二个参数,key,它是一个函数,告诉 max 使用可迭代对象的哪一部分。

Python 只会将一个参数传递给 key:可迭代对象的值。在字典的情况下,Python 会遍历它们的键,所以传递给 maxkey 参数的函数只会得到数字,而不会得到它们的计数。你需要告诉 key,当给定一个数字时,它应该在该数字处索引 counts 字典以获取计数值。在模块中编写一个单独的函数不会起作用,因为 counts 在其命名空间中根本不可用。你该如何解决这个问题?

在函数式编程中,通常会将函数作为参数传递给其他函数,有时这些传递的函数非常简短且清晰,以至于它们不需要名字。与你在 Python 中可能编写的大多数函数不同,它们是 匿名 函数,被称为 lambda。Lambda 确实是函数;它们接受参数并返回值。它们没有名字,你不能直接调用它们,但你可以将它们用作其他函数的行内参数来完成工作。

get_number_with_highest_count 函数的情况下,你可以向 max 传递一个 lambda 表达式,该表达式接受一个数字并返回 counts[number]。这解决了命名空间问题,并提供了你想要给 max 的行为。让我们看看这将如何使代码更加简洁:

from collections import Counter

def get_number_with_highest_count(counts):
    return max(                             *1*
        counts,
        key=lambda number: counts[number]
    )

def most_frequent(numbers):
    counts = Counter(numbers)
    return get_number_with_highest_count(counts)
  • 1 在迭代 counts 中的数字时,使用 counts[number](该数字的计数)作为比较值

这很简洁,而且仍然清晰。了解一种语言为哪些活动提供了哪些工具通常会帮助你编写更短的代码。

当然,短小并不总是最好。您可以将max直接移动到most_frequent函数中,但当我使用像max这样的函数,它们的行为并不总是完全清晰时,我喜欢有一个具有更清晰名称的独立函数。

一旦您写出的代码开始工作,并且足够清晰,其他人可以理解其工作方式并使用它,您就做得正确了。

4.3.3. 让它变得更快

调整代码的性能可能需要的时间与最初编写代码所需的时间一样长。复杂性分析和随后的改进需要细心和对代码中的数据类型和操作进行良好的长期审视。您需要权衡性能调整所损失的时间与将您的作品推向市场的需求。正如本章开头提到的,您还需要决定何时代码的表现“足够好”。正如他们所说,完美是足够好的敌人,因此,推出一个有价值但速度较慢的产品通常比完全不推出产品要好。

如果您的优先级是尽快将产品推向市场,那么在您的初始发布后,考虑为自己设定一些可以逐步实现的表现里程碑。这样,您可以专注于让某个功能工作,确保它工作得正确以便于未来的改进,并交付您的产品。您可能会在生产环境中运行代码时发现新的和意外的瓶颈。

您可接受的表现水平也会根据您的目标而有所不同。如果您在点击“登录”后显示一个登录网站的模态窗口,它需要瞬间发生,否则您的用户可能会离开。如果您正在尝试构建一个年度报告系统,以便客户可以看到他们的销售情况,他们可能会期望等待一段时间。

系统的架构——所有不同的服务、页面、交互等等——也会影响性能。更大的系统需要在 API、数据库和缓存之间进行更多的网络通信。它们也可能有一些在用户工作流程之外发生的进程,比如夜间累积指标以进行分析。您可以检查这个架构中执行类似任务的其它服务,以获得基线。从那里,您可以创建一个关于您软件性能的明智预期,并努力实现它。大型系统的性能超越了代码本身。

随着您编写更多的代码,将您关于数据类型和技术性能的知识应用到您的软件中。您可以开始培养一种对可能导致性能问题的代码行的感觉。嵌套循环和巨大的内存列表将开始跳入您的视线。

4.4. 工具

在现实世界中进行性能测试需要遵循基于证据的方法。这是由于具有真实用户的系统不可避免地会经历不同的行为;意外的输入、时间、硬件、网络延迟等因素共同影响着系统的性能。因此,在代码中随意尝试以偶然发现巨大的性能提升可能不是最好的时间利用方式。

4.4.1. timeit

Python 中的 timeit 模块是一个用于测试代码片段执行时间的工具。它可以从命令行或直接在你的代码中使用以获得更多控制。timeit 模块对于验证你打算做出的性能更改非常有用。

想象一下,你想要看到求和从 0 到 999 的整数所需的时间。要从命令行计时这个活动,你可以使用 Python 调用 timeit 模块:

python -m timeit "total = sum(range(1000))"

这将导致 timeit 多次运行求和代码,最终打印出一些关于执行时间的统计数据:

20000 loops, best of 5: 18.9 usec per loop

从这个输出中,你可以得出结论,求和 0–999 通常需要少于 20 微秒。

要查看求和 0–4999 如何影响结果,你可以更改你的命令并重新运行它:

python -m timeit "total = sum(range(5000))"
2000 loops, best of 5: 105 usec per loop

从这个结果中,你可以得出结论,求和整数 0–4999 所需的时间比 0–999 多出五倍以上。

请记住,timeit 实际上是在运行你的代码,由于许多变量的影响,现实世界的执行会有小的变化。除了代码之外,像电池水平和 CPU 时钟速度这样的因素也会影响计时。因此,运行你的计时命令几次以查看测量的稳定性是好的,当进行更改时,从基线寻找显著的改进。所以尽管 timeit 提供了定量测量,但最好用它来定性比较两种不同的实现,关注趋势。这就是你将注意到那些数量级改进的地方,这些改进明显加快了你的代码。

timeit 的命令行界面很棒,但当你想要测试更大的或更复杂的代码片段时,可能会有些繁琐。如果你需要更多控制被计时的内容,你可以在你的代码中使用 timeit。如果你想计时代码的特定部分而不计时所需的全部设置代码,你可以将设置步骤分开,这样它的执行时间就不会被包括在内:

from timeit import timeit
setup = 'from datetime import datetime'       *1*
statement = 'datetime.now()'                  *2*
result = timeit(setup=setup, stmt=statement)  *3*
print(f'Took an average of {result}ms')
  • 1 此代码为计时测试设置场景。

  • 2 此代码在计时器内执行。

  • 3 timeit 产生的计时结果是毫秒。

这将只计时datetime.now()调用,而不计时执行调用所需的import

假设你想证明检查一个元素是否在集合中比检查它是否在列表中更快。你将如何使用 timeit 模块来做这件事?使用set(range(10000))list(range(10000))构建输入,并计时查找300是否在其中的任务。集合快了多少?

timeit 模块多次救我于水火之中,因为它告诉我关于加速某些代码的假设是错误的。它真的节省了时间(绝对是有意为之)。

4.4.2. CPU 分析

当你使用 timeit 时,该模块正在对你的代码进行分析。分析意味着在代码运行时分析它以收集一些关于其行为的指标。timeit 模块测量你的代码运行所需的总时间,但衡量你的代码性能的另一种有洞察力的方式是通过 CPU 分析。CPU 分析让你可以看到你的代码中哪些部分执行了昂贵的计算,以及它们被调用的频率。这种输出很有用,因为它可以帮助你了解在尝试加速代码时,你首先想看哪里。

假设你编写了一个函数,它不是特别昂贵,但在你的应用程序中被多次调用。你还编写了一个昂贵的函数,但它只被调用一次。如果你只有时间修复一个,你会选择哪一个?不进行代码分析,很难知道哪个可以最快地加速你的代码。你可以使用 Python 的 cProfile 模块来找出答案。

注意

如果你尝试导入 cProfile 模块但得到错误,你可以使用 profile 模块代替。

cProfile 模块在执行你的程序时打印出关于每个被调用的方法或函数的一些信息。对于每次调用,它将显示

  • 调用的次数(ncalls

  • 在这次调用中单独花费的时间,不包括它依次调用的东西(tottime

  • 在这次调用中平均花费的时间,跨ncalls次调用(每调用一次

  • 在这次调用中累积花费的时间,包括在子调用中花费的时间(cumtime

这个信息很有用,因为它会揭示那些运行缓慢的东西——具有大的cumtime——但也会揭示那些运行速度快但被多次调用的东西。以下列表显示了一个调用函数 1000 次的玩具程序。函数调用执行所需的时间是随机的,最多 10 毫秒。

列表 4.4. 分析 Python 程序的 CPU 性能
import random
import time

def an_expensive_function():
    execution_time = random.random() / 100   *1*
    time.sleep(execution_time)

if __name__ == '__main__':
    for _ in range(1000):                    *2*
        an_expensive_function()
  • 1 执行所需时间随机(最多 10 毫秒)

  • 2 运行函数 1000 次

将此程序保存在 cpu_profiling.py 模块中。然后你可以使用 cProfile 从命令行进行分析:

python -m cProfile --sort cumtime cpu_profiling.py

在大量调用中,你可以预期一个耗时 0-10 毫秒的函数平均大约需要 5 毫秒(每调用一次)。调用它 1000 次(ncalls),你预计总共需要大约 5 秒(cumtime)。运行 cProfile 程序以查看它是否符合你的预期。你会看到很多输出,但按累积时间排序意味着an_expensive_function的调用将接近顶部:

$ python -m cProfile --sort cumtime cpu_profiling.py
         5138 function calls (5095 primitive calls) in 5.644 seconds

   Ordered by: cumulative time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
      4/1    0.000    0.000    5.644    5.644 {built-in method builtins.exec}
        1    0.002    0.002    5.644    5.644 cpu_profiling.py:1(<module>)
     1000    0.003    0.000    5.625    0.006 cpu_profiling.py:5
 (an_expensive_function)
     1000    5.622    0.006    5.622    0.006 {built-in method time.sleep}
     ...

在这次运行中,an_expensive_function在 1000 次调用中平均每次调用大约花费了 6 毫秒,导致该函数内部总共花费了 5.625 秒。

当查看 cProfile 的输出时,你会想要寻找具有高percall值或cumtime大幅增加的调用。这些特征意味着调用占用了你程序执行时间的好大一部分。加快慢速函数的速度可以显著提高程序速度,而减少被调用数千次的函数的执行时间可以带来巨大的收益。

4.5. 尝试一下

考虑以下代码。它包含一个函数sort_expensive,该函数必须对 0 到 999,999 范围内的 1000 个整数进行排序。它还包含一个函数sort_cheap,该函数只需要对 0 到 999 范围内的 10 个整数进行排序。

排序算法通常比O(1)更昂贵,所以sort_expensive函数将比sort_cheap函数耗时更长。如果你只运行每个函数一次,sort_cheap肯定能赢。但如果你需要运行sort_cheap 1,000 次,哪个操作会更快就不是很明确了。

import random

def sort_expensive():
    the_list = random.sample(range(1_000_000), 1_000)
    the_list.sort()

def sort_cheap():
    the_list = random.sample(range(1_000), 10)
    the_list.sort()

if __name__ == '__main__':
    sort_expensive()
    for i in range(1000):
        sort_cheap()

你需要分析代码以了解性能。使用 timeit 和 cProfile 模块查看每个任务的表现。

摘要

  • 在开发过程中,从一开始就设计性能,并迭代改进。

  • 仔细考虑适合任务的正确数据类型。

  • 当你不需要一次性获取所有值时,优先使用生成器而不是列表,以节省内存使用。

  • 使用 timeit 和 cProfile/profile Python 模块来测试你对复杂性和性能的假设。

第五章. 测试你的软件

本章涵盖

  • 理解测试的构成

  • 为你的应用程序使用不同的测试方法

  • 使用 unittest 框架编写测试

  • 使用 pytest 框架编写测试

  • 采用测试驱动开发

我在之前的章节中谈到了使用具有良好命名的函数编写清晰代码以提高可维护性,但这只是问题的一部分。随着你添加一个又一个功能,你能确保应用程序仍然按照你的意图运行吗?任何希望长期存在的应用程序都需要对其长期性的保证。测试可以帮助你确保新功能被正确构建,并且每次你更新代码时都可以运行这些测试来确保它保持正确。

对于必须不失败的应用程序,如发射航天器和保持飞机飞行,测试可以是一个严格、正式的过程。这些测试是严格的,并且通常是数学上可证明的。这很酷,但这对大多数 Python 应用程序来说既不必要也不实用。在本章中,你将了解 Python 开发者使用的测试方法和工具,并有机会亲自编写一些测试。

5.1. 什么是软件测试?

通俗地说,软件测试是验证软件是否按预期行为的一种实践。这可以从确保一个函数在给定特定输入时产生预期的输出,到确保你的应用程序能够处理 100 个用户同时的压力。作为开发者,我们不断地无意识地做这种形式的测试。如果你正在开发一个网站,你可能会在本地运行服务器,并在编码时在浏览器中检查你的更改。这是一种测试形式。

你可能会认为花更多时间验证你的代码是否工作意味着更少的时间来发布软件。在短期内,这是真的,尤其是在你熟悉与测试相关的工具和流程时。但从长远来看,测试将通过限制行为和性能错误的复发,并提供你可以用来在未来自信地重构代码的框架,来为你节省时间。代码对你的业务越关键,你愿意花在彻底测试它上的时间就越多。

5.1.1. 它是否如其名?

测试软件的一个原因是要确定它是否真的做了它声称的事情。一个命名良好的函数向读者描述了其意图,但正如人们所说,通往地狱的道路是由好意图铺成的。我无法计算我写了多少次函数,完全相信它忠实于其预期目的,结果却发现我犯了一个错误。

有时这些错误很容易被发现——在你熟悉的代码区域中的一个打字错误或异常可能很容易追踪。更难发现的错误是那些不会立即引起问题,但随着应用程序的进展而级联的错误。通过良好的测试,可以在早期发现问题,并保护你的应用程序免受未来类似问题的困扰。存在多种测试类别,每个类别都专注于识别特定类型的问题。我将在下面介绍一些,但你可以确信这不是一个详尽的列表。

5.1.2. 功能测试的结构

你之前看到,测试可以确保软件为给定的输入产生正确的输出。这种类型的测试被称为功能测试,因为它确保代码功能正确。这与其他类型的测试形成对比,例如性能测试,我将在第 5.6 节中介绍。

尽管功能测试策略在规模和方法上有所不同,但功能测试的基本结构始终保持一致。因为它们检查软件是否根据给定的输入产生正确的输出,所以所有功能测试都需要执行一些特定的任务,包括以下内容:

  1. 准备软件的输入

  2. 确定软件的预期输出

  3. 获取软件的实际输出

  4. 比较实际输出和预期输出,以查看它们是否匹配。

准备输入和识别预期输出是你在创建测试时作为开发人员的大部分工作,而获取和比较实际输出则是执行你的代码的问题,如图 5.1 所示。

图 5.1. 功能测试的基本流程

图片

以这种方式构建测试还有另一个有益的效果:你可以将测试读作代码工作方式的规范。当你再次回顾你很久以前(或对我来说是上周)编写的代码时,这会很有帮助。一个针对 calculate_mean 函数的良好测试可能如下所示:

给定整数列表 [1, 2, 3, 4]calculate_mean 的预期输出是 2.5验证 calculate_mean 的实际输出是否与这个预期相符

这种格式可以扩展到更大的功能工作流程。在一个电子商务系统中,“输入”可能是点击一个产品然后点击“添加到购物车”。预期的“输出”是将项目添加到购物车中。对该工作流程的功能测试可能如下所示:

假设我访问了产品页面 53-DE-232 并点击“添加到购物车”,我期望看到 53-DE-232 出现在我的购物车中

最终,当你的测试不仅验证了你的代码是否工作,还充当了如何使用它的文档时,这会很好。在下一节中,你将看到编写功能测试的这种配方如何应用于不同的测试方法。

5.2. 功能测试方法

实际上,功能测试采取了多种形式。从我们作为开发者所做的持续的小检查到在生产部署之前启动的完全自动化测试,存在一系列实践和能力。你将认识到以下测试类型中的一些,但我建议阅读关于它们的每一篇,以了解它们之间的相似性和差异。

5.2.1. 手动测试

手动测试 是运行你的应用程序,给它一些输入,并检查它是否按预期工作的实践。例如,如果你正在为网站编写注册工作流程,你会输入用户名和密码,并确保创建了一个新用户。如果你有密码要求,你希望检查使用无效密码不会创建新用户。同样,你会测试你选择的用户名已经存在的情况。

在网站上注册通常是大多数用户产品体验中一个小(且一次性)的部分,但正如你所看到的,你已经有几个需要验证的情况。如果其中任何一项出错,你的用户可能无法注册,或者他们的账户信息可能会被覆盖。鉴于这段代码如此重要,长期依赖手动测试最终会导致你错过某些东西。手动探索应用程序以寻找新的错误或新的测试内容仍然是一项有价值的活动,但它应该被视为其他类型测试的补充。

5.2.2. 自动化测试

与手动测试相比,自动化测试允许你编写大量测试,然后可以执行任意次数,而不会在试图在长周末的周五离开办公室时错过检查。如果这个假设的情况看起来过于具体,那是因为它不是假设的。我经历过。

自动化测试缩小了反馈循环,这样你可以快速看到你所做的更改是否破坏了预期的行为。与手动测试相比,你将节省的时间将使你能够进行更多富有创造性的探索性测试。当你发现需要修复的问题时,你应该将它们纳入你的自动化测试中。你可以将此视为锁定一个验证,以确保特定的错误不再发生。本章其余部分中你将看到的测试大多数可以,并且通常都是自动化的。

5.2.3. 验收测试

与添加到购物车工作流程测试最相似的是,验收测试验证系统的整体需求。通过这些测试的软件根据指定的要求是可接受的。如图 5.2 所示,验收测试回答了诸如“用户能否成功完成购买工作流程并购买他们想要的产品?”等问题。这些都是业务的关键检查——保持业务运转的事情。

图 5.2. 验收测试从用户的角度验证工作流程。

验收测试通常由业务利益相关者手动执行,但也可以通过端到端测试在一定程度上实现自动化。端到端测试确保一系列操作(从一端到另一端)可以在需要的地方流动适当的数据。如果从用户的角度表达工作流程,它几乎看起来与添加到购物车工作流程完全一样。

测试是每个人的事

类似于 Cucumber(https://cucumber.io)这样的库,允许你使用自然语言描述端到端测试,如高级动作,例如“点击提交按钮”。这些测试通常比一大堆代码更容易理解。在自然语言文档中编写步骤以记录系统,这样组织中的任何人都可以理解。

这个关于行为驱动开发(BDD)的想法让你能够在端到端测试中与他人协作,即使他们没有在编码能力方面的软件开发经验。许多组织使用 BDD 作为一种方法,首先定义期望的结果,然后仅实现代码以确保测试通过。

端到端测试通常验证对业务价值较高的区域——如果购物车不工作,没有人可以购买产品,你将失去收入——但它们也最容易出问题,因为它们跨越了如此广泛的功能范围。如果工作流程中的任何一步不起作用,整个端到端测试都会失败。创建一系列具有不同粒度的测试可以帮助表明整个工作流程是否健康,以及哪些步骤具体失败。这允许你更快地定位问题。

端到端测试是最不细粒度的,那么光谱的另一端是什么呢?

5.2.4. 单元测试

单元测试可能是你可以从这个章节中学到的最重要的东西。单元测试确保你软件的所有小部分都在正常工作,并为更大规模的测试工作,如端到端测试,奠定了坚实的基础。我将在第 5.4 节中向你展示如何开始使用 Python 进行单元测试。

定义

单元是软件的一个小型、基本的部分——就像“单位圆”中的“单位”。构成单元的是什么,这是许多哲学思考的源头,但一个良好的工作定义是,它是一段可以用于测试的代码。函数通常被认为是单元——它们可以通过使用适当的输入来独立执行。那些函数内的代码行无法独立,所以它们比单元小。类包含许多可以进一步分离的部分,所以它们通常比单元大,但它们偶尔也被视为单元。

单元测试旨在验证你应用程序中所有单个代码单元是否正常工作,每个软件的小部分都做它所说的。这些是你能写的最基础的测试,因此是开始测试的绝佳起点。

函数是功能单元测试最常见的目标。毕竟,“函数”就在名字中。这是因为函数的输入-输出特性。如果你已经将代码的关注点分离成小的函数,测试它们将是功能测试食谱的直接应用。

事实证明,使用关注点分离、封装和松散耦合来结构化你的代码的一大好处是,这使得代码更容易进行测试。测试可能会感觉枯燥乏味,所以任何减少摩擦的机会都受欢迎。代码越容易测试,你最初就越有可能编写那些测试,从而可以享受到对你软件的信心带来的回报。单元是你在坚持到目前为止学到的实践的基础上自然到达的小型、分离的部分。

大多数 Python 单元测试使用简单的相等比较来比较预期和实际输出。你现在就可以自己做一个。打开 Python 交互式解释器,创建这个calculate_mean函数:

>>> def calculate_mean(numbers):
...     return sum(numbers) / len(numbers)

现在,你可以通过一些不同的输入来测试你对这个函数的预期,比较输出与你的预期结果:

>>> 2.5 == calculate_mean([1, 2, 3, 4])
True
>>> 5.5 == calculate_mean([5, 5, 5, 6, 6, 6])
True

现在在 REPL 中尝试一些其他的数字列表,以验证calculate_mean是否给出了正确的结果。考虑一些可能改变函数行为的输入集合:

  • 它是否正确处理负数?

  • 当数字列表中包含 0 时,它是否有效?

  • 当列表为空时,它是否有效?

这类好奇心值得编写测试。它们偶尔会揭示你在代码中没有考虑到的问题,这给你一个机会在有人通过艰难的方式发现特定用例未考虑之前解决这些问题。

>>> 0.0 == calculate_mean([-1, 0, 1])
True
>>> 0.0 == calculate_mean([])             *1*
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in calculate_mean
ZeroDivisionError: division by zero
  • 1 对于尚未考虑的情况抛出异常

你可以通过如果列表为空则返回 0 来修复calculate_mean

>>> def calculate_mean(numbers):
...     if not numbers:
...         return 0
...     return sum(numbers) / len(numbers)
>>> 0.0 == calculate_mean([])
True

太好了——calculate_mean通过了我们向其投掷的所有测试用例。记住,单元测试是使更大测试努力(如端到端测试)成功的基础。为了更好地理解这种关系,我们将在接下来的几节中查看其他两个测试类别。

5.2.5. 集成测试

虽然单元测试都是确保你的代码各个部分按预期工作,但集成测试专注于确保这些单元协同工作以产生正确的行为(见图 5.3)。你可能拥有 10 个完全功能的软件单元,但如果它们不能组合起来完成你想要的功能,它们就不是很有用。而端到端工作流程测试通常从用户的角度来构建,集成测试则更多地关注代码的行为。它们处于不同的抽象层次。

图 5.3. 集成测试关注操作如何协同工作。

尽管集成测试有几个注意事项。因为集成测试需要将多个代码片段串联起来,所以构建与被测试代码结构相似的测试是很常见的。这会在测试和代码之间引入紧密耦合——产生相同结果的代码更改仍可能导致测试失败,因为测试过于关注如何实现结果。

集成测试可能比单元测试执行时间更长。它们通常不仅仅是执行一些函数并检查输出;例如,它们可能使用数据库来创建和操作记录。被测试的交互更复杂,因此执行所需的时间可能会增加。出于这些原因,集成测试的数量通常比单元测试少。

5.2.6. 测试金字塔

现在你已经看到了手动、单元和集成测试,让我们回顾一下它们之间的相互作用。像图 5.4 中那样的测试金字塔理念表明,你应该自由地应用功能测试,如单元和集成测试,但对于长、脆弱和手动测试则应更加谨慎。1] 每种方法都有其优点,你的效果将取决于应用程序和可用资源,但这是一个关于如何投资时间的合理经验法则。

¹

测试金字塔最早由 Mike Cohn 在《Succeeding with Agile》(Addison-Wesley Professional,2009 年)中描述。

图 5.4. 测试金字塔

通过确保软件的各个小部分都正常工作,然后确保它们都能协同工作,你可以获得最大的效益。再次强调,自动化这一过程将使你能够利用节省出来的时间来思考软件可能出错的新方法。然后你可以将这些想法作为新的测试纳入,并逐渐建立信心,这将支持你前进。

5.2.7. 回归测试

回归测试与其说是一种测试方法,不如说是一种在开发应用程序时遵循的过程。当你编写测试时,假设你是在说,“我想确保代码保持这种方式运行。”如果你以改变测试行为的方式更改代码,那将是一个回归。回归是一种向不希望(或至少是意外)的状态的转变,通常是不好的事情。

回归测试是在将代码部署到生产环境之前,运行你现有的测试套件的一种实践。测试套件是你随着时间的推移所积累的测试集合,这些测试可能是为了验证代码作为单元/集成测试,或者是为了修复在探索性手动测试中发现的错误。许多开发团队在持续集成(CI)环境中运行这些测试套件,在那里应用程序的更改经常被合并并测试,然后再发布。本书不涉及 CI 的全面讨论,但理念是通过运行所有测试来确保所有更改的成功。我强烈推荐查看 Travis CI(https://docs.travis-ci.com/user/for-beginners/)或 CircleCI(https://circleci.com/docs/2.0/about-circleci/)以了解更多信息。

版本控制钩子

在源代码控制系统中自动化单元测试的一种实践是使用 precommit 钩子。每次你提交代码时,钩子都会触发测试运行。如果发生任何失败,提交将失败,你将被告知在提交代码之前修复它们。大多数单元测试工具都应该很好地与这种方法集成。在持续集成环境中再次运行测试确保在代码部署前测试通过。

随着新功能的添加,新的测试会被添加到测试套件中。这些测试被锁定为回归测试,以应对未来的更改。同样,添加你发现的错误测试也很常见,这样你可以建立信心,确信特定的错误不会再次发生。就像代码一样,测试套件并不总是完美的。但依靠一个健壮的套件来告诉你事情出错,可以帮助你专注于其他领域,如创新和性能。

有了这些,让我们看看如何在 Python 中开始编写测试。

5.3. 事实陈述

创建真实测试的下一步是断言某个特定的比较是否成立。断言是事实的陈述;如果你做出的断言不成立,要么是你的一些假设不正确,要么是断言本身不正确。如果你断言“你每天早上都能在地平线上看到太阳”,大多数时候这是成立的。但当地平线上有云时,你的断言就不成立了。如果你更新你的假设以包括天空晴朗,你的断言就再次成立。

软件中的断言与此类似。它们断言某些表达式必须成立,如果断言失败,则会大声报错。在 Python 中,可以使用 assert 关键字编写断言。当断言失败时,它们会引发 AssertionError

你可以通过在比较前添加 assert 来使用断言测试 calculate_mean。一个通过断言将没有任何输出;一个失败的断言将显示 AssertionError 的回溯信息:

>>> assert 10.0 == calculate_mean([0, 10, 20])
>>> assert 1.0 == calculate_mean([1000, 3500, 7_000_000])
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AssertionError

这种行为是许多 Python 测试工具的基础。使用功能测试的配方(设置输入,确定预期输出,获取实际输出,并比较),这些工具帮助你进行比较,并在你的断言失败时提供有价值的上下文。继续阅读,了解 Python 中最广泛使用的两个测试工具如何处理对代码的断言。

5.4. 单元测试与 unittest

Unittest 是 Python 的内置测试框架。尽管它被称为 unittest,但它也可以用于集成测试。Unittest 提供了关于代码的断言功能,以及运行测试的工具。在本节中,你将了解测试是如何组织的,以及如何运行它们,你最终将有机会练习编写真实的测试。让我们开始吧!

5.4.1. 使用 unittest 进行测试组织

Unittest 提供了一组用于执行断言的功能。你之前看到了如何编写原始的 assert 语句来测试代码,但 unittest 提供了一个 TestCase 类,它具有自定义断言方法,以便更易于理解的测试输出。你的测试将继承这个类,并使用方法来做出断言。

我鼓励你使用这些测试类作为分组测试的策略。这些类很灵活——你可以使用它们来分组任何你喜欢的测试。如果你对一个类有很多测试,将它们放在自己的 TestCase 中是个好主意。如果你对一个类中的单个方法有很多测试,甚至可以只为这些创建一个 TestCase。你学习了如何使用内聚性、命名空间和关注点分离来编写代码,你也可以将这些想法应用到测试中。

5.4.2. 使用 unittest 运行测试

Unittest 提供了一个测试运行器,你可以在终端中通过输入 python -m unittest 来使用它。当你运行 unittest 测试运行器时,它将通过以下方式查找测试:

  1. 在当前目录(以及任何子目录)中查找名为 test_**_test 的模块

  2. 在这些模块中查找继承自 unittest.TestCase 的类

  3. 在这些类中查找以 test_ 开头的方法

有些人喜欢将他们的测试尽可能接近相关代码,这样更容易找到特定模块的测试。其他人喜欢将所有测试放在项目根目录下的 tests/ 目录中,以将它们与代码分开。我两种方法都试过,自己没有强烈的偏好。做对你、你的团队或你与之一同编写软件的社区有用的事情。

5.4.3. 使用 unittest 编写第一个测试

现在你已经了解了 unittest 的工作方式,你需要一些东西来测试。以下列表展示了一个你将使用的类,用于获得一些测试实践。

列表 5.1. 一个电子商务系统的产品类
class Product:
    def __init__(self, name, size, color):   *1*
        self.name = name
        self.size = size
        self.color = color

    def transform_name_for_sku(self):
        return self.name.upper()

    def transform_color_for_sku(self):
        return self.color.upper()

    def generate_sku(self):                  *2*
        """
        Generates a SKU for this product.

        Example:
            >>> small_black_shoes = Product('shoes', 'S', 'black')
            >>> small_black_shoes.generate_sku()
            'SHOES-S-BLACK'
        """
        name = self.transform_name_for_sku()
        color = self.transform_color_for_sku()
        return f'{name}-{self.size}-{color}'
  • 1 产品属性在创建 Product 实例时指定。*

  • 2 一个 SKU 唯一标识产品属性。*

这个类代表一个电子商务系统中可购买的产品。一个产品有一个名称和大小、颜色选项,这些属性的每个组合都产生一个 库存单位 (SKU)。SKU 是公司用于定价和库存的唯一、内部 ID,通常使用全大写格式。将此类定义放在 product.py 模块中。

在你创建了产品模块之后,你就可以开始编写你的第一个测试了。在 product.py 相同目录下创建一个 test_product.py 模块。首先,导入 unittest 并创建一个空的 ProductTestCase 类,该类继承自基本 TestCase 类:

import unittest

class ProductTestCase(unittest.TestCase):
    pass

如果你此时运行 python -m unittest,只有 product.py 和你的空测试用例在 test_product.py 中,它将表示它没有运行任何测试:

$ python -m unittest

----------------------------------------------------------------------
Ran 0 tests in 0.000s

OK

它可能找到了 test_product 模块和 ProductTestCase 类,但你还没有在那里编写任何测试。你可以通过向该类添加一个空测试方法来检查这一点:

import unittest

class ProductTestCase(unittest.TestCase):
    def test_working(self):
        pass

再次尝试运行测试运行器;你应该看到这次它运行了一个测试:

$ python -m unittest
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

现在你已经准备好进行真正的魔法了。记住功能性测试的结构:

  1. 设置输入。

  2. 确定预期输出。

  3. 获取实际输出。

  4. 比较预期输出和实际输出。

如果你想测试Product类中的transform_name_for_sku方法,这个食谱就变成了

  1. 创建一个具有名称、尺寸和颜色的Product实例。

  2. 注意到transform_name_for_sku返回name.upper();期望的结果是名称的大写形式。

  3. 调用Product实例的transform_name_for_sku方法并将其保存在一个变量中。

  4. 将期望结果与保存的实际结果进行比较。

你可以使用常规代码编写前三个步骤来创建Product实例和获取transform_name_for_sku的值。使用assert语句进行第四步也可以,但默认情况下AssertionError在其跟踪信息中提供的信息不多。这就是 unittest 中自定义断言方法发挥作用的地方。最常用的一个用于比较两个值的是assertEqual,它接受期望值和实际值作为参数。如果不相等,它将抛出一个AssertionError,并提供额外的信息,显示两个值之间的差异。这种附加的上下文可以帮助你更容易地找到问题。

下面是一个初步测试可能的样子:

import unittest

from product import Product

class ProductTestCase(unittest.TestCase):
    def test_transform_name_for_sku(self):
        small_black_shoes = Product('shoes', 'S', 'black')            *1*
        expected_value = 'SHOES'                                      *2*
        actual_value = small_black_shoes.transform_name_for_sku()     *3*
        self.assertEqual(expected_value, actual_value)                *4*
  • 1 为 transform_name_for_sku 的转换准备设置:具有其属性的该产品

  • 2 给定输入时,声明 generate_sku 的期望结果

  • 3 获取 generate_sku 的实际结果以进行比较

  • 4 使用特殊的相等断言方法来比较两个值

现在运行测试运行器应该仍然显示Ran 1 test,如果测试通过(应该是),你不会看到更多的输出。

看看你的测试失败是个好主意,这样可以验证如果出现问题时,它们实际上能否捕捉到你的代码中的问题。将期望值'SHOES'改为'SHOEZ',然后再次运行测试。现在,unittest 将抛出一个AssertionError,指出'SHOEZ' != 'SHOES'

$ python -m unittest
F
======================================================================
FAIL: test_transform_name_for_sku (test_product.ProductTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/dhillard/test/test_product.py", line 11, in
 test_transform_name_for_sku
    self.assertEqual(expected_value, actual_value)
AssertionError: 'SHOEZ' != 'SHOES'
- SHOEZ
?     ^
+ SHOES
?     ^

----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)

确信测试正在密切关注你的代码,你可以将其改回适当的值,然后继续进行另一个测试。

5.4.4. 使用 unittest 编写你的第一个集成测试

现在你已经了解了单元的概念以及如何进行测试,是时候看看如何测试多个单元的集成。单元测试旨在单独检查软件小块的行为,因此如果没有集成测试,就很难说这些小块是否能够作为一个整体协同工作以产生有用的结果(参见图 5.5)。

图 5.5. 单元测试和集成测试

图片

现在你可以使用 SKU 系统管理你的库存产品,人们应该能够开始购买它们。一个具有添加和删除产品功能的新ShoppingCart类是一个很好的第一步。购物车将产品存储为一个看起来像这样的字典:

{
    'SHOES-S-BLACK': {  *1*
        'quantity': 2,  *2*
        ...
    },
    'SHOES-M-BLUE': {
        'quantity': 1,
        ...
    },
}
  • 1 键是产品 SKU。

  • 2 一个关于购物车项元数据的嵌套字典,如数量

ShoppingCart类包含通过管理这个字典中的数据来添加和删除产品的方法。

from collections import defaultdict

class ShoppingCart:
    def __init__(self):
        self.products = defaultdict(lambda: defaultdict(int))            *1*

    def add_product(self, product, quantity=1):                          *2*
        self.products[product.generate_sku()]['quantity'] += quantity

    def remove_product(self, product, quantity=1):                       *3*
        sku = product.generate_sku()
        self.products[sku]['quantity'] -= quantity
        if self.products[sku]['quantity'] == 0:
            del self.products[sku]
  • 1 使用 defaultdict 简化了检查产品是否已在购物车字典中的逻辑。

  • 2 将产品的数量添加到购物车

  • 3 从购物车中移除产品的数量

ShoppingCart 的行为现在呈现了几个应该进行测试的集成点:

  • 购物车依赖于(与 Product 实例的 generate_sku 方法集成)。

  • 添加和移除产品必须协同工作;已添加的产品也必须能够被移除。

测试这些集成的方式将与单元测试非常相似;区别在于测试中执行了多少软件代码。单元测试通常只执行一个方法中的代码,并断言输出符合预期,而集成测试可能会运行多个方法,并在过程中对一些事情进行断言。

对于 ShoppingCart,一个有用的测试是将购物车初始化,添加一个产品,移除它,并确保购物车为空,如下所示。

列表 5.2. 对 ShoppingCart 类的集成测试
import unittest

from cart import ShoppingCart
from product import Product

class ShoppingCartTestCase(unittest.TestCase):      *1*
    def test_add_and_remove_product(self):
        cart = ShoppingCart()                       *2*
        product = Product('shoes', 'S', 'blue')     *3*

        cart.add_product(product)                   *4*
        cart.remove_product(product)                *5*

        self.assertDictEqual({}, cart.products)     *6*
  • 1 测试设置与早期的单元测试相似。

  • 2 创建一个购物车以添加产品

  • 3 创建一些小蓝色鞋子

  • 4 将鞋子添加到购物车

  • 5 从购物车中移除鞋子

  • 6 购物车应该是空的!

此测试调用了购物车的 __init__ 方法、产品的 generate_sku 方法以及购物车的 add_productremove_product 方法。发生了很多事情。正如你所预期的,集成测试通常会更长一些。

5.4.5. 测试双倍

你经常会需要为与另一个系统交互的代码编写测试,无论是数据库还是 API 调用。这些调用可能会对真实数据造成破坏性影响,因此在运行测试时调用它们可能会产生不良后果。它们也可能很慢,如果测试套件多次执行该代码区域,效果会更明显。这些其他系统可能甚至不在你的控制之下。通常来说,模仿它们而不是使用真实的东西更有意义。

有几种微妙不同的方式可以使用 测试双倍 来模仿这些系统:

  • 模拟—使用与真实系统行为非常相似的系统,但避免昂贵或破坏性行为

  • 存根—使用预定的值作为响应,而不是从实时系统中获取

  • 模拟—使用与真实系统具有相同接口的系统,但同时也记录交互以便稍后检查和断言

在 Python 中,模拟和存根涉及编写自己的模仿作为函数或类,并在测试执行期间告诉你的代码使用它们。另一方面,模拟通常使用 unittest.mock 模块来完成。

假设你的代码调用 API 端点来获取一些产品销售所需的税率信息。你不想在测试中真正使用这个端点,因为你看到它需要几秒钟才能响应。更不用说,它返回动态数据,所以你无法确定在测试中应该对什么值进行断言。如果代码看起来像这样:

from urllib.request import urlopen

def add_sales_tax(original_amount, country, region):
    sales_tax_rate =
 urlopen(f'https://tax-api.com/{country}/{region}').read().decode()
    return original_amount * float(sales_tax_rate)

一个带有模拟的单元测试可能看起来像这样:

import io
import unittest
from unittest import mock

from tax import add_sales_tax

class SalesTaxTestCase(unittest.TestCase):
    @mock.patch('tax.urlopen')   *1*
    def test_get_sales_tax_returns_proper_value_from_api(
            self,
            mock_urlopen  *2*
    ):
        test_tax_rate = 1.06
        mock_urlopen.return_value = io.BytesIO(  *3*
            str(test_tax_rate).encode('utf-8')
        )

        self.assertEqual(  *4*
            5 * test_tax_rate,
            add_sales_tax(5, 'USA', 'MI')
        )
  • 1 The mock.patch decorator mocks the object or method specified.

  • 2 The test function receives the mocked object or method.

  • 3 The mocked urlopen call will now return the mocked response with the expected test tax rate.

  • 4 Asserts that the add_sales_tax method calculates the new value from the tax rate returned by the API

以这种方式进行测试允许你声明,“在给定的假设下,我控制的代码表现是这样的,”其中假设是通过测试替身创建的。如果你对 requests 库按其说明工作的信心足够,你可以使用测试替身来避免将其耦合到它。如果你将来需要使用不同的 HTTP 客户端库,或者需要更改获取税率信息的 API,测试将不需要更改。

测试替身可能会过度使用。我确实有时会犯这个错误。通常,你想要使用测试替身来避免之前提到的缓慢、昂贵或破坏性的行为,但有时模拟自己的代码以完美隔离你试图测试的单元是很诱人的。这可能导致脆弱的测试,当你的代码更改时,它们经常崩溃,部分原因是因为它们与实现的结构过于相似。更改实现,你必须更改测试。

尝试编写测试用例来验证你需要的功能,但又要对底层实现的变更保持灵活性。这又是一次松耦合的应用。松耦合不仅适用于实现代码,也适用于测试代码。

5.4.6. 尝试一下

你会如何测试ProductShoppingCart类中的其他方法?考虑到功能测试的配方,尝试为剩余的方法添加额外的测试。一个全面的测试套件将包含对每个方法的断言,以及你可能从该方法期望的每个不同结果。你甚至可能会发现一个微小的错误!作为一个提示,尝试测试当你从购物车中移除比其包含的更多的物品时会发生什么。

你需要测试的一些值是字典。Unittest 有一个特殊的方法,assertDictEqual,当测试失败时,它为字典提供有用的输出。

对于像你已经写过的这样的短测试用例,你可以跳过将预期值和实际值保存为变量。直接将表达式作为assertEqual函数的参数传入:

def test_transform_name_for_sku(self):
    small_black_shoes = Product('shoes', 'S', 'black')
    self.assertEqual(
        'SHOES',
        small_black_shoes.transform_name_for_sku(),
    )

尝试过后,回来查看以下列表,看看你的表现如何。记住,在编写或更改测试后,使用 unittest 测试运行器来查看测试是否仍然通过。

列表 5.3. ProductShoppingCart的测试套件
class ProductTestCase(unittest.TestCase):
    def test_transform_name_for_sku(self):
        small_black_shoes = Product('shoes', 'S', 'black')
        self.assertEqual(
            'SHOES',
            small_black_shoes.transform_name_for_sku(),
        )

    def test_transform_color_for_sku(self):
        small_black_shoes = Product('shoes', 'S', 'black')
        self.assertEqual(
            'BLACK',
            small_black_shoes.transform_color_for_sku(),
        )

    def test_generate_sku(self):
        small_black_shoes = Product('shoes', 'S', 'black')
        self.assertEqual(
            'SHOES-S-BLACK',
            small_black_shoes.generate_sku(),
        )

class ShoppingCartTestCase(unittest.TestCase):
    def test_cart_initially_empty(self):
        cart = ShoppingCart()
        self.assertDictEqual({}, cart.products)

    def test_add_product(self):
        cart = ShoppingCart()
        product = Product('shoes', 'S', 'blue')

        cart.add_product(product)

        self.assertDictEqual({'SHOES-S-BLUE': {'quantity': 1}},
 cart.products)

    def test_add_two_of_a_product(self):
        cart = ShoppingCart()
        product = Product('shoes', 'S', 'blue')

        cart.add_product(product, quantity=2)
        self.assertDictEqual({'SHOES-S-BLUE': {'quantity': 2}},
 cart.products)

    def test_add_two_different_products(self):
        cart = ShoppingCart()
        product_one = Product('shoes', 'S', 'blue')
        product_two = Product('shirt', 'M', 'gray')

        cart.add_product(product_one)
        cart.add_product(product_two)

        self.assertDictEqual(
            {
                'SHOES-S-BLUE': {'quantity': 1},
                'SHIRT-M-GRAY': {'quantity': 1}
            },
            cart.products
        )

    def test_add_and_remove_product(self):
        cart = ShoppingCart()
        product = Product('shoes', 'S', 'blue')

        cart.add_product(product)
        cart.remove_product(product)

        self.assertDictEqual({}, cart.products)

    def test_remove_too_many_products(self):
        cart = ShoppingCart()
        product = Product('shoes', 'S', 'blue')

        cart.add_product(product)
        cart.remove_product(product, quantity=2)

        self.assertDictEqual({}, cart.products)

你可以通过更新remove_product来修复购物车中的错误,使其在数量小于或等于0 时从购物车中删除产品:

if self.products[sku]['quantity'] <= 0:
            del self.products[sku]

5.4.7. 编写有趣的测试

好的测试将使用影响被测试方法行为的数据。SKU 通常是全部大写,通常也不包含空格——只有字母、数字和破折号。但如果产品名称中包含空格怎么办?你希望在名称放入 SKU 之前移除空格。例如,一件 T 恤的 SKU 应该以'TANKTOP'开头。

这是一个新的要求,因此你可以编写一个新的测试来描述代码应该如何表现。

def test_transform_name_for_sku(self):
    medium_pink_tank_top = Product('tank top', 'M', 'pink')
    self.assertEqual(
        'TANKTOP',
        medium_pink_tank_top.transform_name_for_sku(),
    )

这个测试失败是因为当前代码返回了'TANK TOP'。这没关系,因为你还没有为名称中包含空格的产品建立支持。看到这个测试因为预期的原因而失败意味着,当你编写代码来正确处理空格时,测试应该会通过。

自己思考有趣的测试是有价值的,因为它可以在开发早期阶段提出类似的问题。然后你可以调查其他利益相关者并询问,“我们可能需要支持的所有可能的产品名称格式是什么?”如果他们的回答给你提供了新的信息,你可以在代码和测试中包含它,以提供更好的软件。

现在你已经了解了 unittest 的好处,是时候学习 pytest 了。

5.5. 使用 pytest 进行测试

虽然 unittest 是一个功能齐全且成熟的框架,它是 Python 内置的,但它有一些缺点。对于一些人来说,它感觉“非 Pythonic”,因为它使用camelCase而不是snake_case作为方法名称(这是其 JUnit 历史的遗迹)。Unittest 还要求相当多的样板代码,这使得底层测试更难理解。

Pythonic 代码

如果代码使用了 Python 语言的特征和常见的风格指南,那么它通常被称为Pythonic。Pythonic 代码使用snake_case作为变量和方法名称,使用列表推导式而不是简单的for循环,等等。

对于喜欢简洁、直接到点的测试的人来说,pytest 是一个解决方案(https://docs.pytest.org/en/latest/getting-started.html)。一旦安装了 pytest,你就可以回到之前看到的原始assert语句。pytest 在底层进行了一些隐藏的魔法操作来实现这一点,但它提供了一个流畅的体验。

Pytest 默认产生更易读的输出,告诉你关于系统、它找到的测试数量、单个测试的结果以及整体测试结果的摘要:

$ pytest
========== test session starts ==========
platform darwin -- Python 3.7.3, pytest-5.0.1, py-1.8.0,  *1*
 pluggy-0.12.0
rootdir: /path/to/ecommerce/project
collected 15 items                                        *2*

test_cart.py ............    [ 80%]                       *3*
test_product.py ..           [ 93%]
test_tax.py .                [100%]

======= 15 passed in 0.12 seconds =======                 *4*
  • 1 系统信息

  • 2 pytest 发现的测试数量

  • 3 每个模块中每个测试的状态,以及一个整体进度指示器

  • 4 整个测试套件结果的摘要

5.5.1. 使用 pytest 进行测试组织

Pytest 自动发现你的测试,就像 unittest 一样。它甚至可以发现你周围的任何 unittest 测试。一个关键的区别是,正确的 pytest 测试类被命名为 Test*,并且不需要从基类(如 unittest.TestCase)继承即可工作。

运行 pytest 测试的命令更简单:

pytest

因为 pytest 不要求你从基类继承或使用任何特殊方法,所以你并不严格需要将你的测试组织到类中。尽管如此,我仍然推荐这样做,因为它仍然是一个很好的组织工具。pytest 将在失败输出中包含测试类名等,这可以帮助你了解测试所在的位置以及它们的内容。总的来说,pytest 测试可以像 unittest 测试那样组织。

5.5.2. 将 unittest 测试转换为 pytest

因为 pytest 会发现你的现有 unittest 测试,所以你可以根据需要(以及 如果 你愿意,我想)逐步将你的测试转换为 pytest。对于你迄今为止编写的测试套件,转换看起来是这样的:

  • 从 test_product.py 中移除 unittest 导入。

  • ProductTestCase 类重命名为 TestProduct 并移除从 unittest.TestCase 的继承。

  • 将任何 self.assertEqual(expected, actual) 替换为 assert actual == expected

在 pytest 下,之前的测试用例看起来更像是以下这样。

列表 5.4. pytest 中的一个测试用例
class TestProduct:                                                    *1*
    def test_transform_name_for_sku(self):
        small_black_shoes = Product('shoes', 'S', 'black')
        assert small_black_shoes.transform_name_for_sku() == 'SHOES'  *2*

    def test_transform_color_for_sku(self):
        small_black_shoes = Product('shoes', 'S', 'black')
        assert small_black_shoes.transform_color_for_sku() == 'BLACK'

    def test_generate_sku(self):
        small_black_shoes = Product('shoes', 'S', 'black')
        assert small_black_shoes.generate_sku() == 'SHOES-S-BLACK'
  • 1 不需要从任何基类继承

  • 2 self.assertEqual 被移除;使用原始的 assert 语句代替

如你所见,pytest 导致测试代码更短,并且可以说是更易读。它还提供自己的功能框架,这使得设置测试的环境和依赖项更容易。为了深入了解 pytest 提供的所有功能,我强烈推荐 Brian Okken 的书籍,Python Testing with pytest: Simple, Rapid, Effective, and Scalable(Pragmatic Bookshelf,2017)。

你现在已经有了一些单元和集成测试;继续阅读,简要了解非功能性测试。

5.6. 功能测试之外

你在本章的大部分时间里都在学习功能测试。让代码工作并使其正确都在让代码快速之前,所以功能测试先于测试代码的速度。一旦你确保代码是可工作的,确保它是高效的就成为了下一步的好方法。

5.6.1. 性能测试

性能测试会告诉你你所做的更改如何影响内存、CPU 和磁盘使用等方面。在 第四章 中,你学习了可用于测试代码 单元 的某些性能测试工具。你使用了 timeit 模块,这也是我用来查看特定代码行和函数选项的方法。这些并不是你通常会自动执行的计算;它们是为了对两种方法进行临时比较,当你试图查看两种实现哪个更快时,它们编写起来很快。

当你开发具有多个需要保持效率的关键操作的大型应用程序时,将一些自动性能测试集成到你的流程中可能是有益的。在实际操作中,自动性能测试看起来很像回归测试;如果你部署了一个更改,并注意到应用程序开始消耗 20% 的更多内存,那么这是一个很好的迹象,表明你应该调查这个更改。当修复了缓慢的代码并看到应用程序加速时,这也是庆祝的好时机。

与产生二进制通过/失败结果的单元测试不同,性能测试更侧重于定性。如果你看到你的应用程序随着时间的推移而变慢(或部署后的突然跳跃),那就需要关注一下。这种测试的性质使得它自动化和监控起来有点困难,但解决方案是存在的。

5.6.2. 压力测试

压力测试是一种性能测试,但它能告诉你你的应用程序能承受多大的压力直到崩溃。可能它消耗了过多的 CPU、内存或网络带宽,或者变得太慢以至于用户无法可靠地使用它。无论哪种情况,压力测试都提供了你可以用来微调分配给应用程序的资源的数据。在更复杂的情况下,它可能促使你改变系统部分的设计,使其更高效。

压力测试需要比单元测试更多的基础设施和策略。为了清晰地了解负载下的性能,你需要在架构和用户行为上紧密模拟你的生产环境。由于应用级压力测试的复杂性,在我看来,它在测试金字塔中位于集成测试之上(图 5.6)。

图 5.6. 压力测试在测试金字塔中的位置

压力测试有助于你在更接近现实世界用户行为的场景中对应用程序进行性能测试。

5.7. 测试驱动开发:入门

围绕使用单元和集成测试驱动软件开发存在一个完整的思维体系。这些实践的一般名称是 测试驱动开发(TDD)。TDD 可以帮助你提前承诺进行测试,从而获得我们之前讨论过的测试的好处。

5.7.1. 它是一种心态

对我来说,TDD 的真正好处是它让我保持的心态。质量保证工程师的典型形象是总能找到你代码中的问题。这通常带有一些轻蔑的语气,但我认为这是值得注意的。列举系统可能崩溃的所有方式既有用又令人印象深刻。

Netflix 将这种理念推向了极致,即混沌工程。他们积极思考系统可能失败的方式,同时也引入了一定程度的不确定性失败。² 这导致了应对失败的创新方法。

²

想了解更多关于 Netflix 在混沌工程领域取得的进展,请查看他们关于该主题的博客文章集合:https://medium.com/netflix-techblog/tagged/chaos-engineering。

当你编写测试时,尝试成为一个混沌工程师。故意思考代码能够承受的极限,并向其投掷。当然,有一个限制——并不是所有代码都对所有输入都有可预测的反应。但在 Python 中,异常系统允许你的代码对罕见或意外的情况以可预测的方式做出反应。

5.7.2. 这是一种哲学

TDD 在其周围有一个子文化,比如何正确做的观点更强烈的,是如何正确做的观点。它是一种艺术形式,就像任何其他运动一样,产生了许多风格和批评。我发现学习不同团队如何处理他们流程中的测试方面是有用的;一旦你这样做,你就可以识别出你喜欢的部分,并将它们融入自己的工作中。

一些 TDD 文献提倡确保你的代码每一行都由测试覆盖。虽然拥有强大的测试覆盖率,能够覆盖代码可以处理的不同情况是好事,但超过一定程度的覆盖率可能会带来递减的回报。有时用测试覆盖最后几行代码意味着测试与实现之间的耦合变得更加紧密,需要通过集成测试来实现。

如果你发现测试函数行为的一些方面很尴尬或困难,尝试确定这是否是因为代码的关注点没有很好地分离,或者测试本身固有的尴尬。如果必须包含尴尬,最好是在测试中而不是在真实代码中。不要仅仅为了使测试更容易或覆盖率更强而重构代码——这样做是为了使测试更容易,并且使代码更加连贯。

摘要

  • 功能测试确保代码从给定的输入产生预期的输出。

  • 测试通过捕捉错误和简化代码重构,从长远来看可以节省你的时间。

  • 手动测试不可扩展,应该用于补充自动化测试。

  • Unittest 和 pytest 是 Python 中两个流行的单元和集成测试框架。

  • 测试驱动开发将测试放在首位,根据需求引导你到一个可工作的实现。

第三部分. 确定大型系统

在第二部分中,你学习了构成软件设计大部分概念的内容,而在第三部分中,你将开始应用它们。通过从头开始构建一个应用程序,你将看到软件设计概念如何在开发生命周期的各个阶段得到应用。

虽然设计出既工作又快速运行的软件可能是你的目标之一,但另一个目标必须是你可以和其他开发者理解并维护的软件。本书的这一部分将向你展示设计是一个具有一些灵活性迭代的过程;并不总是有正确或错误答案,而且很少有一个你可以说“完成”的点。你将学习如何识别代码中的痛点,以便你可以使用你所学的知识来最小化努力并最大化理解。

第六章. 实践中的关注点分离

本章涵盖

  • 使用分离的高级关注点开发应用程序

  • 使用特定的封装类型来放松不同关注点之间的耦合

  • 创建一个良好的分离基础以实现未来的扩展

在第二章中,我向你展示了关于 Python 中关注点分离的一些最佳实践。“分离关注点”意味着在处理不同活动的代码之间创建边界,使代码更易于理解。你学习了函数、类、模块和包如何有助于将代码分解成更容易推理的片段。尽管第二章涵盖了用于分离关注点的几个工具,但获得一些应用这些工具的经验是有帮助的。

对于许多人来说,我最好的学习方式是通过实践。当我处理一个真实的项目时,我经常发现之前没有看到的关系,或者找到新的问题去探索。在本章中,你将处理一个真实的应用程序,它展示了分离关注点的良好用例。你将在接下来的章节中对其进行改进,最终得到一个你可以用于个人用途的扩展版本。

注意

这本书和未来的章节对结构化查询语言(SQL)的使用较少,SQL 是一种用于从数据库中操作和检索数据的特定领域语言。如果你之前没有使用过 SQL,或者需要复习,你可能想在继续之前运行一个教程。Ben Brumm 的SQL in Motion课程(www.manning.com/livevideo/sql-in-motion)是一个很好的入门课程。

6.1. 命令行书签应用程序

在本章中,你将开发一个用于保存和组织书签的应用程序(关于这个的更多细节将在下一分钟介绍)。

我不是一个优秀的笔记记录者。在整个学校和我的职业生涯中,我一直在努力寻找一种记录方式,帮助我学习和保留信息。当我发现一个以新颖方式或通过有洞察力的例子解释概念的优质资源时,我就找到了宝藏,但通常我需要花时间阅读和实践该资源中的信息。因此,在过去的几年里,我积累了大量的书签。也许有一天我会抽出时间阅读它们!

大多数浏览器中默认的书签功能不足。尽管事情可以嵌套在文件夹中并给出标题,但通常很难回忆起最初为什么保存某物。我的一些书签是关于测试、性能、新编程语言等相关的代码文章。当我发现 GitHub 上的有趣仓库时,我也会使用 GitHub 的“星标”功能来保存它以供以后使用。但 GitHub 星标也是有限的;在撰写本文时,它们是一个可以按编程语言过滤的大列表。无论你使用什么书签实现,它们大多建立在相同的基本原则上。

书签是一个小的 CRUD 工作流程的例子:创建、阅读、更新和删除 (图 6.1)。这四个操作构成了世界上许多数据驱动工具的基础。你可以 创建 一个书签以供以后使用,然后 阅读 其信息以获取 URL。如果你最初给出的书签标题很令人困惑,你可能想 更新 书签的标题,并且通常在你完成它们时 删除 它们。这是一个开始你应用程序的好地方。

由于长描述是某些现有书签工具中缺失的功能之一,因此你的应用程序将直接包含该功能。你将在接下来的章节中添加更多功能,并以一种能够让你继续添加你想要的功能的方式来实现。

图 6.1. CRUD 操作是许多管理用户数据的应用程序的基础。

6.2. 漫步 Bark

你将开发 Bark,一个命令行书签应用程序。Bark 允许你创建书签,目前这些书签将由一些信息组成:

  • ID—每个书签的唯一、数字标识符

  • 标题—书签的简短文本标题,例如“GitHub”

  • URL—保存的文章或网站的链接

  • 备注—关于书签的任意长描述或解释

  • 添加日期—一个时间戳,以便你可以看到书签有多久(为了抵制那讨厌的拖延症)

Bark 还允许你列出所有已添加的书签,然后通过其 ID 删除特定的书签。这一切都是通过 命令行界面 (CLI) 管理的——你通过终端与之交互的应用程序。启动时,Bark 的 CLI 将向你展示一个选项菜单。选择每个选项时,都会触发一个操作,该操作将读取或修改数据库。

备注

本章中,你不会开发一个功能来覆盖 CRUD 的更新部分以实现书签;你将在第七章中做到这一点。

6.2.1. 分离的好处:重述

尽管 Bark 支持类似 CRUD 的操作对于这类应用程序来说相当常见,但发生的事情相当多。对于这样一个大的应用程序,记住分离关注点将提供的好处是很重要的:

  • 减少重复——如果你的软件的每一部分都做一件事情,那么当你看到两件相同的事情时,就会更容易发现。你可以分析相似的代码片段,看看是否可以将它们合并成一个单一的真实来源,以实现该行为。

  • 提高可维护性——代码的阅读次数远多于编写次数。由于每个部分都有明确的职责,因此代码可以逐步理解,这使得开发者可以跳入感兴趣的领域,了解他们需要什么,然后跳出来。

  • 易于泛化和扩展——具有单一职责的代码可以泛化以覆盖多个用例的责任,或者可以进一步拆分以支持更多样化的行为。执行多项任务的代码将难以支持这种灵活性,因为很难看到变化可能产生影响的区域。

在你完成本章的练习时,请记住这些想法。我的目标是让你在本章结束时能够继续开发并添加功能。为此,你首先需要考虑并实现一个高级架构,以支持这种结果。

6.3. 按关注点划分的初始代码结构

我尝试用简洁的解释来开发像 Bark 这样的应用程序,解释它是如何做到它所做的事情。这往往引导我走向一个初始架构。

例如,Bark 是如何工作的?它的简洁描述是什么?也许以下陈述包含了这些问题的答案:使用命令行界面,用户可以选择添加、删除和列出存储在数据库中的书签的选项.

现在我们来稍微分析一下:

  • 命令行界面——这是一种向用户展示选项并了解他们选择了哪些选项的方式。

  • 选择选项——一旦选择了某个选项,就会发生一些动作或业务逻辑。

  • 存储在数据库中——数据需要持久化以供以后使用。

这些点代表了 Bark 的高级抽象层。CLI 是应用程序的表示层。数据库是持久层。动作和业务逻辑就像是连接表示层和持久层的胶水。每个都是相当独立的关注点,如图 6.2(图 6.2)所示。这种多层架构,其中每个应用层(层)都有自由发展的空间,被许多组织使用。团队可以根据专业领域围绕每个层进行组织,如果需要,每个层都可以与其他应用程序潜在地重用。

图 6.2. CRUD 操作是许多管理用户数据的应用程序的基础。

图片

你将在阅读本章内容的过程中开发 Bark 的每一层。因为每个都是独立的关注点,所以将它们视为独立的 Python 模块是有意义的:

  • 数据库模块

  • 命令模块

  • bark 模块,其中包含实际运行Bark 应用程序的代码

我们将从持久层开始,逐步向上进行。

应用程序架构模式

将应用程序分为表示层、持久层和动作或规则的层是一种常见的模式。这种方法的某些变体非常常见,以至于它们被赋予了名称。模型-视图-控制器 (MVC)是一种用于建模数据以进行持久化的方法,为用户提供数据的视图,并允许他们通过一组动作来控制对数据的更改。模型-视图-视图模型 (MVVM)强调允许视图和数据模型自由通信。这些和其他多层架构是关注点分离的绝佳例子。

6.3.1. 持久层

持久层是 Bark 的最低层(图 6.3)。这一层将负责接收信息并将其传达给数据库。

图 6.3. 持久层处理数据存储——它是应用程序的最低层。

图片

你将使用 SQLite,这是一个便携式数据库,默认情况下通过单个文件存储数据(www.sqlite.org/index.html)。与更复杂的数据库系统相比,这很方便,因为如果出现问题,你可以从删除文件开始从头开始。

注意

尽管 SQLite 是最广泛使用的数据库之一,但默认情况下,它只在一些操作系统上安装。我建议从官方下载页面下载适用于您的操作系统的预编译二进制文件(sqlite.org/download.html)。

从数据库模块开始,您将创建一个Database-Manager类来操作数据库中的数据。Python 提供了一个内置的 sqlite3 模块,您可以使用它来获取数据库连接、执行查询和遍历结果。SQLite 数据库通常是一个具有.db 扩展名的单个文件;如果您将 sqlite3 连接到一个不存在的文件,模块会为您创建它。

数据库模块提供了您管理书签数据所需的大部分功能,包括以下内容:

  • 创建一个表(用于初始化数据库)

  • 添加或删除记录

  • 列出表中的记录

  • 根据某些标准从表中选择和排序记录

  • 计算表中记录的数量

这些任务如何进一步分解?从之前描述的业务逻辑角度来看,每个任务似乎都是相对独立的,但在持久化层呢?大多数描述的活动都可以通过构建适当的 SQL 语句并执行它来实现。执行它需要与数据库建立连接,这需要数据库文件的路径。

虽然管理持久性是一个高级关注点,但这些个别关注点是在您打开持久化层时获得的。它们应该各自独立。首先,您需要与数据库建立连接。

数据库操作

许多聪明的人已经制作了出色且健壮的包,使得在 Python 中使用数据库变得更加容易。SQLAlchemy(www.sqlalchemy.org)是一个广泛使用的工具,不仅用于与数据库交互,还通过对象关系映射(ORM)抽象数据模型。ORM 允许您在 Python 等语言中将数据库记录视为对象,而不必过多担心数据库的细节。Django Web 框架也提供 ORM 来编写数据模型。

在“做中学”的精神下,您将在本章中自己编写数据库交互代码。它限于 Bark 的范围,但如果您想对应用程序的其他部分做更多操作,可以添加或替换它。如果您需要在未来的项目中使用数据库,考虑您是想从头开始编写数据库代码还是使用这些第三方包之一。

创建和关闭数据库连接

在 Bark 运行期间,它只需要一个数据库连接——它可以重用此连接进行所有操作。要建立此连接,您可以使用sqlite3 .connect,它接受应连接到的数据库文件的路径。再次强调,如果文件不存在,它将被创建。

DatabaseManager__init__应该

  1. 接受一个包含数据库文件路径的参数(不要硬编码;分离关注点!)

  2. 使用数据库文件路径通过sqlite3 .connect(path)创建 SQLite 连接并将其存储为实例属性

当程序结束时关闭与 SQLite 数据库的连接是一个好习惯,以限制数据损坏的可能性。为了对称性,DatabaseManager__del__ 应该使用连接的 .close() 方法关闭连接。

这将为执行语句提供基础。

import sqlite3

class DatabaseManager:
    def __init__(self, database_filename):
        self.connection = sqlite3.connect(database_filename)  *1*

    def __del__(self):
        self.connection.close()                               *2*
  • 1 创建并存储数据库连接以供以后使用

  • 2 执行完成后清理连接,以确保安全

执行语句

你的 DatabaseManager 需要一种执行语句的方法。这些语句有几个共同点,因此将这些方面封装到可重用的方法中可以减少在每次想要执行新类型的语句时重写相同代码的错误可能性。

一些 SQL 语句返回数据;这些语句被称为查询。Sqlite3 使用一个称为光标的概念来管理查询结果。使用光标执行语句允许你遍历它返回的结果。不是查询的语句(如 INSERTDELETE 等)不返回任何结果,但光标通过返回一个空列表来管理这一点。

DatabaseManager 上编写一个 _execute 方法,你可以使用它来通过光标执行所有语句,返回一个你可以选择在需要的地方使用的对象。_execute 方法应该

  1. 接受一个字符串形式的语句作为参数

  2. 从数据库连接获取一个光标

  3. 使用光标执行一个语句(关于这个稍后会有更多介绍)

  4. 返回光标,它已存储执行语句的结果(如果有)

def _execute(self, statement):
    cursor = self.connection.cursor()  *1*
    cursor.execute(statement)          *2*
    return cursor                      *3*
  • 1 创建光标

  • 2 使用光标执行 SQL 语句

  • 3 返回光标,它已存储了结果

通常不是查询的语句会操作数据,如果在执行过程中发生任何问题,数据可能会被损坏。数据库通过一个称为事务的功能来防止这种情况。如果一个语句在事务中执行失败或被其他方式中断,数据库将回滚到最后已知的工作状态。Sqlite3 允许你通过一个上下文管理器来创建事务,这是一个使用 with 关键字的 Python 块,它在代码进入和退出块时提供一些特殊行为。

更新 _execute 以将 cursor 的创建、执行和返回放在一个事务中,如下所示:

def _execute(self, statement):
    with self.connection:                   *1*
        cursor = self.connection.cursor()
        cursor.execute(statement)           *2*
        return cursor
  • 1 这创建了一个数据库事务上下文。

  • 2 这是在数据库事务中发生的。

在事务中使用 .execute 可以从功能上达到目的。但是,使用占位符在 SQL 语句中放置真实值是一个好的安全习惯,以防止用户使用精心制作的查询进行恶意操作。¹] 更新 _execute 以接受两个参数:

¹

参考维基百科关于 SQL 注入的文章:en.wikipedia.org/wiki/SQL_injection

  • 一个包含占位符的 SQL 语句字符串

  • 填充语句中占位符的值列表

然后,该方法应通过将两个参数传递给游标的execute方法来执行语句,该方法接受相同的参数。它看起来可能像以下片段:

def _execute(self, statement, values=None):           *1*
        with self.connection:
            cursor = self.connection.cursor()
            cursor.execute(statement, values or [])   *2*
            return cursor
  • 1 值是可选的;一些语句没有占位符需要填充。

  • 2 执行语句,提供任何传递给占位符的值

现在你已经建立了数据库连接,并且能够在该连接上执行任意语句。记住,当你创建DatabaseManager实例时,连接会自动为你管理,因此你不需要考虑它是如何打开和关闭的,除非你想更改它。现在,语句执行是在_execute方法中管理的,因此你也不需要考虑如何执行一个语句;你只需要告诉它要执行什么语句。这就是分离关注点的力量。

现在你已经拥有了这些构建块,是时候开发一些数据库交互了。

创建表格

你需要的第一件事是存储书签数据的数据库表格。你必须使用 SQL 语句来创建这个表格。因为连接数据库和执行语句的关注点现在是抽象的,创建表格的工作包括以下内容:

  1. 确定表格的列名。

  2. 确定每列的数据类型。

  3. 构建正确的 SQL 语句来创建具有那些列的表格。

记住,每个书签都有一个 ID、标题、URL、可选的备注以及添加日期。每列的数据类型和约束如下:

  • ID—ID 是表的主键,或每条记录的主要标识符。每次添加新记录时,它应自动递增,使用AUTOINCREMENT关键字。该列是INTEGER类型;其余都是TEXT类型。

  • 标题—标题是必需的,因为如果你只有 URL,很难浏览你的现有书签。你可以通过使用NOT NULL关键字来告诉 SQLite 该列不能为空。

  • URL—URL 是必需的,因此它也具有NOT NULL属性。

  • 备注—书签的备注是可选的,因此只需要TEXT指定符。

  • 日期—添加书签的日期是必需的,因此它具有NOT NULL属性。

SQLite 中的表格创建语句使用CREATE TABLE关键字,后跟表名,括号内的列列表及其数据类型信息。因为你想让 Bark 在启动时创建表格(如果它还不存在),你可以使用CREATE TABLE IF NOT EXISTS

根据之前对书签列的描述,创建书签表的 SQL 语句看起来会是什么样子?看看你是否能写出来,然后回到这里检查你的工作与以下列表是否一致。

列表 6.1. 创建书签表的创建语句
CREATE TABLE IF NOT EXISTS bookmarks
(
    id INTEGER PRIMARY KEY AUTOINCREMENT,  *1*
    title TEXT NOT NULL,                   *2*
    url TEXT NOT NULL,
    notes TEXT,
    date_added TEXT NOT NULL
);
  • 1 每条记录的主要 ID,它在添加记录时自动递增

  • 2 NOT NULL 要求列必须填充一个值。

你现在可以编写创建表的方法了。每个列都通过一个名称,如title,映射到一个数据类型和约束,如TEXT NOT NULL,因此字典似乎是一个合适的 Python 类型来表示列。该方法需要

  1. 接受两个参数:要创建的表的名称,以及列名称映射到其数据类型和约束的字典

  2. 构建一个类似于之前显示的CREATE TABLE SQL 语句

  3. 使用DatabaseManager._execute执行该语句

现在尝试编写create_table方法,然后回头看看它与以下列表有何不同。

列表 6.2. 创建 SQLite 表
def create_table(self, table_name, columns):
        columns_with_types = [                     *1*
            f'{column_name} {data_type}'
            for column_name, data_type in columns.items()
        ]
        self._execute(                             *2*
            f'''
            CREATE TABLE IF NOT EXISTS {table_name}
            ({', '.join(columns_with_types)});
            '''
        )
  • 1 构建列定义,包括其数据类型和约束

  • 2 构建完整的创建表语句并执行

关于泛化的说明

目前,你只需要为 Bark 使用bookmarks表。我已经在这本书中论证过早期优化是不必要的,同样的,泛化也是如此。那么为什么还要创建一个通用的create_table方法呢?

当我开始使用硬编码的值构建一个方法时,我会检查将这些值参数化为方法参数是否需要很多工作。例如,将字符串'bookmarks'替换为table_name字符串参数并不需要太多工作。列及其数据类型也是如此。使用这种方法,create_table方法可以足够通用,以创建你需要的任何表格。

你将使用此方法稍后创建bookmarks表,这是 Bark 在开发应用程序时将与之交互以管理书签的方式。

添加记录

现在你能够创建一个表,你需要能够向其中添加书签记录。这是 CRUD 中的“C”部分 (图 6.4)。

SQLite 期望INSERT INTO关键字,后跟表名,以表示向表中添加新记录的意图。这后面跟着你提供的列列表,括号中的VALUES关键字,然后是提供的值,括号中的值。SQLite 中的记录插入语句看起来像这样:

INSERT INTO bookmarks
(title, url, notes, date_added)
VALUES ('GitHub', 'https://github.com',
 'A place to store repositories of code', '2019-02-01T18:46:32.125467');
图 6.4. 创建是 CRUD 操作中最基本的操作,因此它是许多系统的核心。

记住,使用占位符是一个好习惯,就像之前在_execute方法中做的那样。前述查询的哪些部分应该使用占位符?

  1. bookmarks

  2. titleurl

  3. 'GitHub', 'https://github.com', 等等

  4. 所有上述内容

只有在语句中放置文字值的地方才能使用占位符,因此 3 是正确答案。一个带有占位符的bookmarks表的INSERT语句看起来像这样:

INSERT INTO bookmarks
(title, url, notes, date_added)
VALUES (?, ?, ?, ?);

要构建这个语句,你需要在DatabaseManager中编写一个add方法,

  1. 接受两个参数:表的名称,以及映射列名称到列值的字典

  2. 构建一个占位符字符串(每个指定的列一个 ?

  3. 构建列名称的字符串

  4. 获取列值作为元组(字典的 .values() 返回一个 dict_values 对象,但不幸的是它不与 sqlite3 的 execute 方法一起工作。)

  5. 使用 _execute 执行语句,传递带有占位符的 SQL 语句和列值作为单独的参数

现在编写 add 方法,并查看以下列表以了解其比较。

列表 6.3. 向 SQLite 表中添加记录
def add(self, table_name, data):
        placeholders = ', '.join('?' * len(data))
        column_names = ', '.join(data.keys())        *1*
        column_values = tuple(data.values())         *2*

        self._execute(
            f'''
            INSERT INTO {table_name}
            ({column_names})
            VALUES ({placeholders});
            ''',
            column_values,                           *3*
        )
  • 1 数据的键是列的名称。

  • 2 .values() 返回一个 dict_values 对象,但 execute 需要一个列表或元组。

  • 3 将可选的值参数传递给 _execute

使用子句限制操作范围

要将记录插入数据库,您只需要插入的信息,但某些数据库语句与一个或多个附加 子句 一起使用。子句会影响语句将操作哪些记录。例如,不使用子句的 DELETE 语句可能会导致删除表中的所有记录。您不希望这样。

WHERE 子句可以附加到几种类型的语句上,以限制语句的效果仅限于匹配该标准的记录。您可以使用 ANDOR 组合多个 WHERE 标准。例如,在 Bark 中,每个书签记录都有一个 ID,因此您可以使用类似 WHERE id = 3 的子句来限制语句仅对特定记录进行操作。

这种限制对于查询(搜索特定记录)和常规语句都很有用。当您需要删除特定记录时,子句将很有用。

删除记录

当书签已经失去其有用性后,您需要一种方法来删除它(图 6.5)。要删除书签,您可以向数据库发出 DELETE 语句,使用 WHERE 子句通过其 ID 指定书签。

图 6.5. 删除是创建的对立面,因此大多数系统也覆盖了这项操作。

在 SQLite 中,删除 ID 为 3 的书签的语句如下所示:

DELETE FROM bookmarks
WHERE ID = 3;

create_tableadd 方法一样,您可以将标准表示为一个字典,该字典将列名称映射到您想要匹配的值。编写一个 delete 方法,

  1. 接受两个参数:要删除记录的表名,以及将列名称映射到匹配值的字典。标准应该是必需的参数,因为您不希望删除所有记录。

  2. WHERE 子句构建一个占位符字符串。

  3. 构建完整的 DELETE FROM 查询,并通过 _execute 执行它。

将您的结果与以下列表进行比较。

列表 6.4. 在 SQLite 中删除记录
def delete(self, table_name, criteria):                                 *1*
        placeholders = [f'{column} = ?' for column in criteria.keys()]
        delete_criteria = ' AND '.join(placeholders)
        self._execute(
            f'''
            DELETE FROM {table_name}
            WHERE {delete_criteria};
            ''',
            tuple(criteria.values()),                                   *2*
        )
  • 1 在这里,标准参数不是可选的;如果没有标准,将删除所有记录。

  • 2 使用 _execute 的值参数作为匹配的值

选择和排序记录

您现在可以添加和删除表中的记录,但如何检索它们?除了创建和删除信息外,您还希望能够读取您已经存储的内容 (图 6.6)。

图 6.6. 读取现有数据通常是 CRUD 应用程序的一个必要部分。

您可以使用 SELECT * FROM bookmarks (这里的 * 表示“所有列”) 和一些条件在 SQLite 中创建一个查询语句:

SELECT * FROM bookmarks
WHERE ID = 3;

此外,您可以使用 ORDER BY 子句按特定列对这些结果进行排序:

SELECT * FROM bookmarks
WHERE ID = 3
ORDER BY title;           *1*
  • 1 这将按标题列的升序排序结果。

同样,您应该在查询中存在文字值的地方使用占位符:

SELECT * FROM bookmarks
WHERE ID = ?
ORDER BY title;

您的 select 方法将与 delete 方法有些相似,但 criteria 可以是可选的。(默认情况下,它将检索所有记录。)它还应接受一个可选的 order_by 参数,指定按哪个列排序结果(默认是表的主键)。以 delete 为指导,现在编写 select 并在完成后与以下列表进行比较。

列表 6.5. 选择 SQL 表数据的方法
def select(self, table_name, criteria=None, order_by=None):
        criteria = criteria or {}                               *1*

        query = f'SELECT * FROM {table_name}'
        if criteria:                                            *2*
            placeholders = [f'{column} = ?' for column in criteria.keys()]
            select_criteria = ' AND '.join(placeholders)
            query += f' WHERE {select_criteria}'

        if order_by:                                            *3*
            query += f' ORDER BY {order_by}'

        return self._execute(                                   *4*
            query,
            tuple(criteria.values()),
        )
  • 1 条件可以默认为空,因为选择表中的所有记录是可以接受的。

  • 2 构建用于限制结果的 WHERE 子句

  • 3 构建用于排序结果的 ORDER BY 子句

  • 4 这次,您希望从 _execute 返回的值迭代结果。

您现在已创建了一个数据库连接;编写了一个 _execute 方法,用于在事务中执行带有占位符的任意 SQL 语句;并编写了添加、查询和删除记录的方法。对于目前来说,您需要做的关于操作 SQLite 数据库的事情大概就是这些了。您只用不到 100 行代码就完成了一个数据库管理器。做得不错。

接下来,您将开发与持久化层交互的业务逻辑。

6.3.2. 业务逻辑层

现在 Bark 的持久化层已经就绪,您可以开始工作于确定从持久化层中放入和取出的层 (图 6.7)。

图 6.7. 业务逻辑层确定何时以及如何从或写入持久化层的数据。

当用户与 Bark 的表示层中的某个元素交互时,Bark 需要触发业务逻辑层,最终触发持久化层中的某些操作。可能会有人想做一些类似以下的事情:

if user_input == 'add bookmark'
    # add bookmark
elif user_input == 'delete bookmark #4':
    # delete bookmark

但这样会将用户展示的文本与需要触发的操作耦合起来。您将为每个菜单选项设置新的条件,如果您想触发多个选项执行相同的命令,或者您想更改文本,您可能需要重构一些代码。如果表示层是唯一知道显示给用户菜单选项文本的地方,那就太好了。

每个动作都类似于一个需要根据用户的菜单选择来执行命令。通过将每个动作的逻辑封装为命令对象,并通过execute方法提供一种一致的方式来触发它们,可以将这些动作与表示层解耦。然后,表示层可以将菜单选项指向命令,而无需担心这些命令的工作方式。这被称为命令模式。^([2])

²

参考维基百科的“命令模式”文章以了解更多关于此模式的信息:https://en.wikipedia.org/wiki/Command _pattern.

你将开发每个 CRUD 操作和一些外围功能,作为业务逻辑层中的命令。

创建书签表

现在你正在业务逻辑层工作,创建一个新的“commands”模块来存放你将要编写的所有命令。因为大多数命令将需要使用DatabaseManager,从数据库模块导入它并创建其实例(称为db)以在命令模块中使用。记住,它的__init__方法需要指向 SQLite 数据库的文件路径;我建议将其命名为 bookmarks.db。省略任何前导路径将在与 Bark 代码相同的目录中创建数据库文件。

因为如果书签数据库表不存在,你需要初始化它,所以首先编写一个CreateBookmarksTableCommand类,其execute方法创建你的书签表。你可以使用你之前编写的db.create_table方法来创建你的书签表。在章节的后面部分,你将在 Bark 启动时触发此命令的运行。对照以下列表检查你的工作。

列表 6.6. 创建表的命令
db = DatabaseManager('bookmarks.db')                   *1*

class CreateBookmarksTableCommand:
    def execute(self):                                 *2*
        db.create_table('bookmarks', {                 *3*
            'id': 'integer primary key autoincrement',
            'title': 'text not null',
            'url': 'text not null',
            'notes': 'text',
            'date_added': 'text not null',
        })
  • 1 记住,sqlite3 如果不存在,将自动创建此数据库文件。

  • 2 这将在 Bark 启动时最终被调用。

  • 3 创建带有必要列和约束的书签表

注意,该命令只知道自己的职责(调用持久层逻辑)以及其依赖项的接口(DatabaseManager.create_table)。这是松耦合,部分原因在于将持久逻辑和(最终)表示逻辑分离。随着你完成这些练习,你应该会越来越清楚地看到分离关注点的益处。

添加书签

要添加书签,你需要将来自表示层的接收到的数据传递到持久层。数据将以字典的形式传递,映射列名到值。这是一个代码依赖于共享接口而不是实现细节的绝佳例子。如果持久层和业务逻辑层就数据格式达成一致,它们可以各自完成所需的工作,只要数据格式保持一致。

编写一个AddBookmarkCommand类来执行此操作。此类将

  1. 预期一个包含书签标题、URL 和(可选)备注信息的字典。

  2. 将当前日期时间添加到字典中作为date_added。为了获取 UTC 时间,使用datetime.datetime.utcnow().isoformat(),这是一种标准化的格式,具有广泛的兼容性。^([3])

    ³

    查阅维基百科上关于“ISO 8601”的文章以获取更多关于此时间格式的信息:https://en.wikipedia.org/wiki/ISO_8601.

  3. 使用DatabaseManager.add方法将数据插入书签表。

  4. 返回一个成功消息,该消息最终将由演示层显示。

将你的工作与以下列表进行对比检查。

列表 6.7. 添加书签的命令
from datetime import datetime

...
class AddBookmarkCommand:
    def execute(self, data):
        data['date_added'] = datetime.utcnow().isoformat()   *1*
        db.add('bookmarks', data)                            *2*
        return 'Bookmark added!'                             *3*
  • 1 当记录添加时添加当前日期时间

  • 2 使用 DatabaseManager.add 方法可以轻松添加记录。

  • 3 你将在演示层稍后使用此消息。

你现在已经编写了创建书签所需的所有业务逻辑。接下来,你将想要能够列出你添加的书签。

列出书签

Bark 需要能够显示你保存的书签——没有这个功能,Bark 将没有多少用处。你将编写一个ListBookmarksCommand,它将提供在数据库中显示书签的逻辑。

你将想要使用DatabaseManager.select方法从数据库中获取书签。默认情况下,SQLite 按创建顺序(即表的主键顺序)排序记录,但按日期或标题排序书签可能也有用。在 Bark 中,书签的 ID 和日期按相同方式排序,因为当你添加书签时,它们都严格递增,但为了以防万一,按感兴趣的列显式排序是良好的实践。

ListBookmarksCommand应该执行以下操作:

  • 接受要排序的列,并将其保存为实例属性。如果你愿意,可以将默认值设置为date_added

  • 将此信息传递给db.selectexecute方法。

  • 因为select是一个查询,所以返回结果(使用游标的.fetchall()方法)。

编写列出书签的命令,然后回到检查你的工作与以下列表的对比。

列表 6.8. 列出现有书签的命令
class ListBookmarksCommand:
    def __init__(self, order_by='date_added'):                            *1*
        self.order_by = order_by

    def execute(self):
        return db.select('bookmarks', order_by=self.order_by).fetchall()  *2*
  • 1 你可以为此命令创建按日期或标题排序的版本。

  • 2 db.select 返回一个可以迭代的游标以获取记录。

现在你已经有了添加书签和查看现有书签的功能。管理书签的最后一个步骤是删除它们的命令。

删除书签

与添加新书签类似,删除书签需要从演示层传递一些数据。不过,这次数据只是一个表示要删除的书签 ID 的整数值。

编写一个 DeleteBookmarkCommand 命令,该命令在其 execute 方法中接受此信息,并将其传递给 DatabaseManager.delete 方法。记住,delete 接受一个将列名映射到匹配值的字典;在这里,你将想要匹配 id 列中的给定值。一旦记录被删除,返回一个成功消息供表示层使用。

回来检查你的工作与以下列表是否一致。

列表 6.9. 一个删除书签的命令
class DeleteBookmarkCommand
:
    def execute(self, data):
    db.delete('bookmarks', {'id': data})   *1*
    return 'Bookmark deleted!'
  • 1 delete 接受一个列名、匹配值对的字典。
退出 Bark

剩下一点润色:一个退出 Bark 的命令。用户可以使用常用的 Ctrl-C 方法停止 Python 程序,但提供一个退出选项会更友好。

Python 提供了 sys.exit 函数用于停止程序执行。编写一个 QuitCommand,其 execute 方法使用这种方式退出程序,然后回来检查你的工作与以下列表是否一致。

列表 6.10. 一个退出程序的命令
import sys

...

class QuitCommand
:
    def execute(self):
        sys.exit()        *1*
  • 1 这应该立即退出 Bark。

现在,你可以擦去额头上的汗水……不是因为完成了,而是因为你将开始开发表示层。

6.3.3. 表示层

Bark 使用命令行界面 (CLI)。其表示层(用户所见的部分,如图 6.8 所示 figure 6.8)是终端中的文本。根据应用程序的不同,CLI 可以运行到特定任务的完成,或者它可以持续运行,直到用户明确退出。由于你编写了 QuitCommand,你可能猜测你会做后者。

Bark 的表示层包含一个无限循环:

  1. 清除屏幕

  2. 打印菜单选项

  3. 获取用户的选择

  4. 清除屏幕并执行用户选择的命令

  5. 等待用户查看结果,完成后按 Enter 键

图 6.8. 表示层显示了用户可以执行的操作以及触发它们的方式。

现在你正在开发表示层,你需要创建一个新的 bark 模块。将命令行应用程序的代码放入 if name == 'main': 块是一个好习惯;这将确保你不会意外地通过在其他地方导入 bark 模块来执行模块中的代码。如果你从一个 Hello, World! 类型的程序开始,你可以快速检查以确保一切设置正确。

在你的 bark 模块中开始以下内容:

if __name__ == '__main__':
    print('Welcome to Bark!')

尝试在终端中运行 python bark.py;你应该看到 Welcome to Bark! 作为结果。现在你可以开始将表示层连接到一些业务逻辑。

数据库初始化

记住,Bark 需要初始化数据库,如果不存在,则创建书签表。导入命令模块,并更新你的代码以执行 CreateBookmarksTableCommand,如下面的代码片段所示。在做出此更新并运行 python bark.py 后,你将看不到任何文本输出,但你应该看到创建了一个 bookmarks.db 文件。

import commands

if __name__ == '__main__':
    commands.CreateBookmarksTableCommand().execute()

虽然看起来很小,但你刚刚完成了一项相当了不起的事情。这代表了对你的 多层架构 所有层的完整遍历。表示层(到目前为止运行 bark.py 的行为)触发了业务逻辑中的命令,反过来,在持久层中设置了一个适合存储书签的表。每一层只需了解足够关于其周围环境的信息来完成其工作;事物被很好地分离,并且松散耦合。当你开始向 Bark 添加触发更多命令的菜单选项时,你将再次体验到这一点。

菜单选项

当你启动 Bark 时,它应该向你展示一个类似于以下内容的选项菜单:

(A) Add a bookmark
(B) List bookmarks by date
(T) List bookmarks by title
(D) Delete a bookmark
(Q) Quit

每个选项都有一个键盘快捷键和一个描述性标题。如果你仔细观察,这些选项中的每一个都对应于你之前编写的一个命令。因为你使用了命令模式,每个命令都可以像其他命令一样被触发——使用它的 execute 方法。命令之间的区别仅在于它们所需的设置和输入,以及从表示层的角度来看,它们执行了什么操作。

根据你对封装的了解,你将如何将表示层的项目连接到它们控制的业务逻辑?

  1. 使用条件逻辑根据用户输入调用正确的 Command 类的 execute 方法。

  2. 创建一个将显示给用户的文本与触发它的命令配对的类。

我推荐选择 2。要将每个菜单选项连接到它应该触发的命令,你可以创建一个 Option 类。该类的 __init__ 方法可以接受在菜单中显示给用户的名称、当用户选择时执行的命令的实例,以及可选的准备步骤(例如获取用户的额外输入)。所有这些都可以存储为实例属性。

当选择时,一个 Option 实例需要

  1. 运行指定的准备步骤(如果有)。

  2. 将准备步骤的返回值(如果有),传递给指定命令的 execute 方法。

  3. 打印执行结果。这些是来自业务逻辑的成功消息或书签结果。

当向用户展示时,Option 实例应表示为其文本描述;你可以使用 __str__ 来覆盖默认行为。将这项工作从获取和验证用户输入的其余代码中抽象出来,可以使你保持关注点的分离。

尝试编写 Option 类,然后查看以下列表,看看你做得怎么样。

列表 6.11。将菜单文本连接到业务逻辑命令
class Option:
    def __init__(self, name, command, prep_call=None):
        self.name = name                                     *1*
        self.command = command                               *2*
        self.prep_call = prep_call                           *3*

    def choose(self):                                        *4*
        data = self.prep_call() if self.prep_call else None  *5*
        message = self.command.execute(data) if data
else self.command.execute()                                *6*
        print(message)

    def __str__(self):                                       *7*
        return self.name
  • 1 在菜单中显示的名称

  • 2 要执行的命令实例

  • 3 在执行命令之前要调用的可选准备步骤

  • 4 当用户选择选项时将调用choose方法。

  • 5 如果指定,则调用准备步骤

  • 6 执行命令,传递准备阶段的数据(如果有的话)

  • 7 将选项表示为其名称,而不是默认的 Python 行为

Option类就位后,现在是时候开始连接你之前创建的更多业务逻辑了。记住,你需要对每个选项做一些事情:

  1. 打印用户输入以选择选项的键盘键。

  2. 打印选项文本。

  3. 检查用户的输入是否与选项匹配,如果是,则选择它。

什么 Python 数据结构适合用来存储所有选项?

  1. list

  2. set

  3. dict

每个键盘键映射到一个菜单选项,你需要检查用户的输入与可用选项是否匹配,因此你需要以某种方式存储这些配对。选择 3 是一个不错的选择,因为dict可以提供键盘键和选项配对,你可以使用字典的.items()方法迭代,以打印选项文本。我还建议特别使用collections.OrderedDict,以确保你的菜单选项始终按你指定的顺序打印。

CreateBookmarksTableCommand之后添加你的选项字典,为每个菜单选项添加一个条目。一旦字典就位,创建一个print_options函数,该函数遍历选项,并以你之前看到的格式打印它们:

(A) Add a bookmark
(B) List bookmarks by date
(T) List bookmarks by title
(D) Delete a bookmark
(Q) Quit

使用以下列表检查你的工作。

列表 6.12. 指定和打印菜单选项
def print_options(options):
    for shortcut, option in options.items():
        print(f'({shortcut}) {option}')
    print()

...

if __name__ == '__main__':
    ...
    options = {
        'A': Option('Add a bookmark', commands.AddBookmarkCommand()),
        'B': Option('List bookmarks by date',
commands.ListBookmarksCommand()),
        'T': Option('List bookmarks by title',
commands.ListBookmarksCommand(order_by='title')),
        'D': Option('Delete a bookmark', commands.DeleteBookmarkCommand()),
        'Q': Option('Quit', commands.QuitCommand()),
    }
    print_options(options)

在你添加了菜单选项后,运行 Bark 应该会打印出你添加的所有选项。你目前还不能触发它们;为此,你需要获取一些用户输入。

用户输入

我们的总体目标是线程化展示到业务逻辑到持久化,剩下要添加的是与 Bark 用户的一点点交互。获取用户所需选项的方法如下:

  1. 使用 Python 的内置input函数提示用户输入选择。

  2. 如果用户的选项与列出的选项之一匹配,则调用该选项的choose方法。

  3. 否则,重复。

你在 Python 中可能会使用什么方法来获得这种重复行为?

  1. while循环

  2. for循环

  3. 递归函数调用

由于获取用户输入没有明确的结束状态(他们可能输入无效选择四亿次),因此while循环(选项 1)最有意义。用户的选项无效时,继续提示他们。如果你喜欢,可以接受每个选项的大写和小写版本。

编写一个get_option_choice函数,在打印选项后使用它来获取用户的选项。然后调用该选项的choose方法。试一试,然后比较你的工作与以下列表。

列表 6.13. 获取用户的菜单选项选择
def option_choice_is_valid(choice, options):                *1*
    return choice in options or choice.upper() in options

def get_option_choice(options):
    choice = input('Choose an option: ')                    *2*
    while not option_choice_is_valid(choice, options):      *3*
         print('Invalid choice')
        choice = input('Choose an option: ')
    return options[choice.upper()]                          *4*

if __name__ == '__main__':
    ...

    chosen_option = get_option_choice(options)
    chosen_option.choose()
  • 1 如果字母与选项字典中的某个键匹配,则选择有效。

  • 2 从用户那里获取初始选择

  • 3 当用户的选项无效时,继续提示他们。

  • 4 一旦他们做出了有效的选择,就返回匹配的选项

在这一点上,你可以运行 Bark,一些命令,如列出书签和退出,将响应用户的输入。但有几个选项需要一些额外的准备,正如我之前提到的。你需要提供标题、描述等来添加书签,你需要指定要删除的书签的 ID。就像你为选择菜单选项获取用户输入一样,你需要提示用户这些书签数据。

这里是封装一些行为的机会。对于你需要的信息,你应该

  1. 使用标签提示用户——例如“标题”或“描述”

  2. 如果信息是必需的,并且用户按下 Enter 键而没有输入任何信息,则继续提示他们

编写三个函数——一个用于提供重复提示行为,另外两个用于获取添加或删除书签的信息。然后将每个信息获取函数作为prep_call添加到适当的Option实例中。对照以下列表检查你的结果,看看你做得如何,或者如果你卡住了。

列表 6.14. 从用户那里收集书签信息
def get_user_input(label, required=True):                   *1*
    value = input(f'{label}: ') or None                     *2*
    while required and not value:                           *3*
        value = input(f'{label}: ') or None
    return value

def get_new_bookmark_data():                                *4*
    return {
        'title': get_user_input('Title'),
        'url': get_user_input('URL'),
        'notes': get_user_input('Notes', required=False),   *5*
    }

def get_bookmark_id_for_deletion():                         *6*
    return get_user_input('Enter a bookmark ID to delete')

if __name__ == '__main__':
    ...
    'A': Option('Add a bookmark', commands.AddBookmarkCommand(),
prep_call=get_new_bookmark_data),
    ...
    'D': Option('Delete a bookmark', commands.DeleteBookmarkCommand(),
prep_call=get_bookmark_id_for_deletion),
  • 1 用于提示用户输入的通用函数

  • 2 获取初始用户输入

  • 3 在需要的情况下,如果输入为空,则继续提示

  • 4 获取添加新书签所需数据的函数

  • 5 书签的注释是可选的,所以不要继续提示。

  • 6 获取删除书签所需的信息

如果一切顺利,你现在应该能够运行 Bark 并添加、列出或删除书签!恭喜你做得很好。

深入探讨

我们刚刚覆盖了很多内容,但我想要指出一些我觉得很兴奋的事情。由于你构建 Bark 的方式,如果你想添加新功能,有一个清晰的路线图:

  1. 将你可能需要的任何新数据库操作方法添加到 database.py 中。

  2. 在 commands.py 中添加一个执行所需业务逻辑的命令类。

  3. 将新的命令连接到 bark.py 中的新菜单选项。

这有多酷?分离关注点让你可以清楚地看到在添加新功能时需要增强哪些代码区域。

在完成本章之前,还有一些需要处理的细节。

清除屏幕

在打印菜单或执行命令之前清除屏幕将使查看用户当前所处的上下文更容易。要清除屏幕,你可以委托给操作系统的命令行程序来清除终端文本。在许多操作系统中清除屏幕的命令是clear,但在 Windows 上是cls。你可以通过检查os.name来确定是否在 Windows 上——在 Windows 上这是'nt'。(Windows NT 相当于 Windows 10,就像 macOS 相当于 Mojave。)

编写一个clear_screen函数,使用os.system进行适当的调用,如下面的代码所示:

import os

def clear_screen():
    clear = 'cls' if os.name == 'nt' else 'clear'
    os.system(clear)

在调用print_options之前调用此方法,并在调用用户所选选项的.choose()方法之前调用:

if __name__ == '__main__':
    ...

    clear_screen()
    print_options(options)
    chosen_option = get_option_choice(options)
    clear_screen()
    chosen_option.choose()

这将在菜单和命令结果被反复打印时最有帮助,这是这个谜题的最后一部分。

应用程序循环

最后一步是循环运行 Bark,以便用户可以连续执行多个操作。为此,创建一个loop方法,并将除数据库初始化之外的所有内容移动到if __name__ == '__main__'块中。回到if __name__ == '__main__'块,在while True:块中调用loop。在loop的末尾,添加一行以暂停并等待用户按 Enter 键后继续。

def loop():                                           *1*
    # All the steps for showing/selecting options
    ...
    _ = input('Press ENTER to return to menu')        *2*

if __name__ == '__main__':
    commands.CreateBookmarksTableCommand().execute()

    while True:                                       *3*
        loop()
  • 1 每个菜单 > 选项 > 结果循环中发生的一切都放在这里。

  • 2 提示用户按 Enter 键,并在继续之前检查结果 (_ 表示“未使用值”)

  • 3 无限循环(直到用户选择与退出命令对应的选项)

现在,Bark 将给用户一个在每次交互后返回菜单的方法,菜单也给他们提供了一个退出选项。这涵盖了所有方面。你怎么看?我认为是时候开始使用 Bark 了。

摘要

  • 关注点的分离是实现更易读、更易于维护的代码的工具。

  • 最终用户应用程序通常分为持久层、业务逻辑层和表示层。

  • 关注点的分离与封装、抽象和松散耦合紧密相关。

  • 应用有效的关注点分离允许你在不影响周围代码的情况下添加、更改和删除功能。

第七章. 可扩展性和灵活性

本章涵盖

  • 使用控制反转使代码灵活

  • 使用接口使代码可扩展

  • 向现有代码添加新功能

在许多成熟组织中,作为开发者,你的日常工作不仅包括编写新应用程序,还包括更新现有应用程序。当你被要求向现有应用程序添加新功能时,你的目标是 扩展 该应用程序的功能,通过添加代码引入新的行为。

一些应用程序对这种改变 灵活,可以适应不断变化的需求。其他应用程序可能会与你抗争到底。在本章中,你将学习通过向 Bark 添加“导入 GitHub 星星”功能来编写灵活和可扩展的软件的策略。

7.1. 什么是可扩展代码?

如果向代码中添加新行为对现有行为的影响很小或没有影响,则称代码为可扩展的。换句话说,如果可以在不更改现有代码的情况下添加新行为,则软件是可扩展的。

想象一下像 Google Chrome 或 Mozilla Firefox 这样的网络浏览器。你可能在这些浏览器中安装了一些软件来阻止广告或轻松地将你正在阅读的文章保存到像 Evernote 这样的笔记工具中。Firefox 将这些可安装的软件组件称为插件,而 Chrome 则称为扩展,两者都是插件系统的例子。插件系统是可扩展性的实现。Chrome 和 Firefox 并非专门为广告拦截器或 Evernote 而构建的,但它们被设计成允许构建这样的扩展。

像网络浏览器这样的大型项目只有在能够满足数十万用户的需求时才能成功。提前预测所有这些需求将是一项巨大的壮举,因此可扩展系统允许在产品上市后构建满足这些需求解决方案。你并不总是需要如此前瞻性,但借鉴一些相同的概念将帮助你构建更好的软件。

与软件开发的其他许多方面一样,可扩展性是一个连续体,并且是你需要不断迭代改进的。通过实践诸如关注点分离和松散耦合等概念,你可以随着时间的推移提高你代码的可扩展性。随着你代码的可扩展性提高,你会发现添加新功能变得更快,因为你几乎可以完全专注于新的行为,而不用担心它会如何影响周围的功能。这也意味着你将更容易维护和测试你的代码,因为功能更加隔离,因此由于混合行为而引入的复杂错误的可能性更小。

7.1.1. 添加新行为

在上一章中,你编写了 Bark 应用程序的初步版本。你使用多层架构来分离持久化、操作和显示书签数据的问题。然后,你在这层抽象之上构建了一组小的功能,以使其变得有用。当你准备好添加新功能时会发生什么?

在一个理想的可扩展系统中,添加新行为涉及添加新的类、方法、函数或数据,这些新类、方法、函数或数据封装了新行为,而不改变现有代码(图 7.1)。

图 7.1. 向可扩展代码添加新行为

图 7.2. 向不可扩展代码添加新行为

将其与一个可扩展性较差的系统进行比较,其中新功能可能需要在函数中添加条件语句,在方法中添加,等等(图 7.2)。这种广泛的变化及其粒度有时被称为枪击手术,因为添加功能需要在代码中像散弹枪弹丸一样散布变化。1 这通常表明关注点的混合或以不同方式抽象或封装的机会。需要这些类型更改的代码不可扩展;创建新行为不是一项简单的工作。你需要搜索代码以找到确切需要更新的行。

¹

有关枪击手术和其他代码恶臭的更多信息,请参阅“面向对象设计中的恶臭调查”,第三国际信息技术:新一代会议(2006 年),ieeexplore.ieee.org/document/1611587

在上一章的结尾,我提到向 Bark 添加新功能是一个相对简单的事情:

  • 如果需要,在数据库模块中添加新的数据持久性逻辑

  • 为底层功能向命令模块添加新的业务逻辑

  • 在树皮模块中添加一个新选项以处理用户交互

小贴士

复制一些代码并更新新副本以执行所需操作是扩展的完全有效方法。我在使原始代码更具可扩展性的过程中偶尔使用这种方法。通过创建一个副本,对其进行修改,并观察两个版本之间的差异,我可以更容易地将复制的代码重构回一个单一的多用途版本。如果你在没有彻底理解代码所有使用方式的情况下尝试去重代码,你可能会做出过多的假设,并使你的代码对未来的变化缺乏灵活性。所以记住,复制比错误的抽象更好。

如果 Bark 在执行三项活动方面接近理想,你只需添加代码,而不会影响现有的代码。你将在本章稍后开始编写 GitHub 星标导入器时发现这一点。但是,因为现实系统很少是理想的,你仍然会发现自己需要定期更改现有代码(图 7.3)。在这些情况下,灵活性是如何应用的?

图 7.3. 实践中可扩展性的样子

7.1.2. 修改现有行为

你可能有多个原因需要更改你或其他人已经编写的代码。你可能需要更改代码的行为,例如当你正在修复错误或解决需求变更时。你可能需要重构以使代码更容易使用,同时保持行为一致。在这些情况下,你并不一定是在寻找通过添加新行为来扩展代码,但代码的灵活性仍然起着重要作用。

灵活性是衡量代码抵抗变化的能力。理想的灵活性意味着你可以轻松地将代码中的任何部分替换为另一种实现。需要通过枪击式手术( shotgun surgery)来更改的代码是僵化的;它通过让你付出更多努力来抵制变化。Kent Beck 机智地说:“对于每个期望的改变,使改变变得容易(警告:这可能很难),然后进行简单的改变。”^([2]) 通过分解、封装等实践首先打破代码的抵抗性,为使你能够实现最初意图的具体改变铺平道路。

²

Kent Beck 在 Twitter 上(2012 年 9 月 25 日),twitter.com/kentbeck/status/250733358307500032.

在我的工作中,我在正在工作的代码区域进行少量、持续的重构。例如,你正在工作的代码可能包含一个复杂的 if/else 语句集合,如列表 7.1 所示。如果你需要更改这个条件集合中的行为,你很可能会需要阅读大部分内容来理解应该在何处进行更改。而且,如果你想要进行的更改适用于每个条件的主体,你需要多次应用这个更改。

列表 7.1. 条件到结果的僵化映射
if choice == 'A':                *1*
    print('A is for apples')     *2*
elif choice == 'B':
    print('B is for bats')
...
  • 1 这个条件需要为每个选择正确更新。

  • 2 将选项映射到消息和打印消息的关注点混合在一起。

如何才能改进?

  1. 从条件检查和主体中提取信息到一个 dict

  2. 使用 for 循环检查每个可用的选择。

因为每个选择都映射到一个特定的结果,所以将行为映射到字典(选项 1)是正确的方法。通过将选择的字母映射到消息中的单词,新的代码版本可以从映射中检索到正确的单词,而不管选择了哪个选项。你不再需要不断向条件中添加 elif 语句并为新情况定义行为。相反,你可以添加一个从所选字母到消息中使用的单词的单个新映射,只在最后打印,如列表 7.2 所示。选择到消息的映射就像配置——程序用来确定如何执行的信息。配置通常比条件逻辑更容易理解。

列表 7.2. 将条件映射到结果的一种更灵活的方法
choices = {                                     *1*
    'A': 'apples',
    'B': 'bats',
    ...
}

print(f'{choice} is for {choices[choice]}')     *2*
  • 1 提取选择到消息的映射使添加新选项变得简单。

  • 2 结果是集中的,并且打印行为有所分离。

这段代码的阅读性更好。与列表 7.1 中的示例相比,你需要理解条件和每个条件的作用,而这里的版本更清晰地结构化为一系列选择和一行打印特定选择信息的代码。添加更多选择和更改打印的消息也更容易,因为它们已经被分离。这一切都是为了追求松散耦合。

7.1.3. 松散耦合

首先,可扩展性源于松散耦合的系统。没有松散耦合,系统中的大多数更改将需要类似枪击手术的开发方式。假设你没有在数据库和业务逻辑周围添加抽象层来编写 Bark——类似于以下列表。这个版本难以阅读,部分原因在于其物理布局(注意深度嵌套),以及代码块中发生了很多事情。

列表 7.3. 对 Bark 的过程式方法
if __name__ == '__main__':
    options = [...]

    while True:
        for option in options:
            print(option)                        *1*

        choice = input('Choose an option: ')

        if choice == 'A':                        *2*
            ...
            sqlite3.connect(...).execute(...)    *3*
        elif choice == 'D':
            ...
            sqlite3.connect(...).execute(...)
  • 1 深度嵌套是一个需要进一步分离的关注点的强烈提示。

  • 2 if/elif/else 难以推理。

  • 3 数据库行为是重复的,并且与用户交互混合在一起。

这段代码可以工作,但考虑一下尝试实施影响你如何连接数据库或对底层数据库进行更改的更改。这将是一个巨大的痛苦。这段代码有许多相互依赖的部分都在相互交谈,因此添加新行为意味着找出添加另一个elif的正确位置,编写一些原始 SQL,等等。因为你每次想要添加新行为时都会产生这些成本,所以这个系统扩展性不好。

想象一下固体铁块中的原子——它们紧密排列,紧紧地抓住彼此。这使得铁刚性,并抵抗弯曲或重塑。但铁匠们发现通过熔化铁来克服这一点,这会使原子松散,从而可以自由地流动。即使在冷却过程中,铁也是可塑的,能够移动和弯曲而不会断裂。

这正是你想要的代码,如图图 7.4 所示。如果每个部分只与任何其他部分松散耦合,那么这些部分可以更自由地移动而不会意外地破坏某些东西。让代码过于紧密地堆叠在一起,并允许它过度依赖周围的代码,将使你的代码陷入一种难以重塑的固态形式。

图 7.4. 灵活性与刚性的对比

图片

你在编写 Bark 时使用的松散耦合意味着可以通过在DatabaseManager类上添加新方法或对现有(集中式)方法进行有针对性的更改来添加新的数据库功能。新的业务逻辑可以封装在新的Command类中,向菜单添加内容只需在 bark 模块的options字典中创建一个新选项并将其连接到命令即可。这听起来有点像我之前描述的浏览器插件系统。Bark 不期望处理任何特定的新功能,但它们可以通过已知数量的努力添加。这个松散耦合的回顾展示了你到目前为止所学的内容如何帮助你设计灵活的代码。现在,我将教你一些新的技巧,以获得更深层次的灵活性。

7.2. 刚度解决方案

代码中的刚度与僵硬的关节非常相似。随着软件的逐渐老化,使用最少的代码往往变得最为僵硬,需要一定的技巧来再次放松它。特定类型的僵硬代码需要特定的护理,你应该定期检查代码,寻找通过重构保持其灵活性的机会。

在接下来的几节中,你将学习一些具体的方法来减少刚度。

7.2.1. 放手:控制反转

你之前了解到,通过允许对象重用行为而不将它们限制在特定的继承层次结构中,组合比继承提供了更多的优势。当你将关注点分离成许多更小的类,并希望将这些行为组合在一起时,你可以编写一个使用这些较小类实例的类。这是面向对象代码库中的常见做法。

想象你在一个处理自行车及其部件的模块中工作。你打开自行车模块,看到以下列表中的代码。当你阅读以理解代码正在做什么时,尝试评估它如何遵循封装和抽象等实践。

列表 7.4. 依赖于其他较小类的复合类
class Tire:                            *1*
    def __repr__(self):
        return 'A rubber tire'

class Frame:
    def __repr__(self):
        return 'An aluminum frame'

class Bicycle:
    def __init__(self):                *2*
        self.front_tire = Tire()
        self.back_tire = Tire()
        self.frame = Frame()

    def print_specs(self):             *3*
        print(f'Frame: {self.frame}')
        print(f'Front tire: {self.front_tire}, back tire: {self.back_tire}')

if __name__ == '__main__':             *4*
    bike = Bicycle()
    bike.print_specs()
  • 1 用于组合的小类

  • 2 自行车创建它需要的部件。

  • 3 一种打印自行车所有部件的方法

  • 4 创建自行车并打印其规格

运行此代码将打印出自行车的规格:

Frame: An aluminum frame
Front tire: A rubber tire, back tire: A rubber tire

这当然会给你一辆自行车。封装看起来不错;自行车的每个部件都生活在自己的类中。抽象层次也很有意义;在顶层有一个Bicycle,它的每个部件都可以从那个级别向下访问。那么问题在哪里?你能看到任何可能难以用这种代码结构完成的事情吗?

  1. 向自行车添加新部件

  2. 升级自行车的部件

向自行车添加新部件(选项 1)实际上并不困难。你可以在 __init__ 方法中创建一个新部件的实例,并将其存储在 Bicycle 实例中,就像其他部件一样。在当前结构中,动态升级(更改)Bicycle 实例的部件(选项 2)变得很困难,因为这些部件的类被硬编码到初始化中。

你可以说 Bicycle 依赖于 TireFrame 和它需要的其他部件。没有它们,自行车无法工作。但如果你想要 CarbonFiberFrame,你必须打开 Bicycle 类的代码来更新它。正因为如此,Tire 目前是 Bicycle刚性 依赖。

控制反转 指的是,你可以在你的类中创建依赖项的实例,而不是传递现有的实例供类使用 (图 7.5)。依赖项创建的控制被反转,将控制权交给创建 Bicycle 的代码。这是强大的。

图 7.5. 使用控制反转获得灵活性

图片

尝试更新 Bicycle.__init__ 方法,使其接受每个依赖项的参数,并将它们传递到方法中。回到以下列表中查看你的进展。

列表 7.5. 使用控制反转
class Tire:
    def __repr__(self):
        return 'A rubber tire'

class Frame:
    def __repr__(self):
        return 'An aluminum frame'

class Bicycle:
    def __init__(self, front_tire, back_tire, frame):  *1*
        self.front_tire = front_tire
        self.back_tire = back_tire
        self.frame = frame

    def print_specs(self):
        print(f'Frame: {self.frame}')
        print(f'Front tire: {self.front_tire}, back tire: {self.back_tire}')

if __name__ == '__main__':
    bike = Bicycle(Tire(), Tire(), Frame())            *2*
    bike.print_specs()
  • 1 依赖项在初始化时传递到类中。

  • 2 创建自行车的代码提供了适当的实例。

这应该会给你之前相同的结果。这看起来可能只是把问题转移了一下,但它为你的自行车提供了一定程度的自由度。现在你可以创建任何花哨的轮胎或框架,并用它们替换基本版本。只要你的 FancyTire 具有与其他轮胎相同的方法和属性,Bicycle 就不会关心。

尝试创建一个新的 CarbonFiberFrame 并升级你的自行车以使用它。回到以下列表中查看你的进展。

列表 7.6. 使用一种新的自行车框架
class CarbonFiberFrame:
    def __repr__(self):
        return 'A carbon fiber frame'

...
if __name__ == '__main__':
    bike = Bicycle(Tire(), Tire(), CarbonFiberFrame())   *1*
    bike.print_specs()                                   *2*
  • 1 碳纤维框架可以像普通框架一样容易地使用。

  • 2 你现在应该能在打印的规格中看到碳纤维框架。

这种用最少的努力替换依赖项的能力在测试你的代码时非常有价值;为了真正隔离类中的行为,你有时会想用一个测试替身替换依赖项的实际实现。对 Tire 的刚性依赖迫使你为每个 Bicycle 测试模拟 Tire 类,以实现隔离。控制反转让你摆脱这种约束,让你可以传递一个 MockTire 实例,例如。这样,你就不会忘记模拟某些东西,因为你必须为创建的 Bicycle 实例传递 某种 轮胎。

使测试更容易是遵循你在本书中学到的原则的一个重要原因。如果你的代码难以测试,它可能也难以理解。如果它容易测试,它可能也容易理解。两者都不是确定的,但它们是相关的。

7.2.2. 细节决定成败:依赖接口

你看到Bicycle依赖于Tire和其他部件,你的代码不可避免地会有这样的依赖。但另一种刚性表现是当你的高级代码过于强烈地依赖于低级依赖的细节时。我提到,只要有一个自行车轮胎具有与其他轮胎相同的方法和属性,就可以将FancyTire安装到自行车上。更正式地说,任何具有轮胎接口的对象都可以互换。

Bicycle类对(或对)特定轮胎的细节了解不多(或感兴趣)。它只关心轮胎具有特定的信息和行为集;否则,轮胎可以自由地做它们喜欢的事情。

这种在高级和低级代码之间共享已达成协议的接口(与类特定的细节相对)的做法将赋予你自由地互换实现的能力。记住,在 Python 中,鸭子类型的存在意味着不需要严格的接口。你决定哪些方法和属性组成特定的接口。作为开发者,确保你的类符合消费者期望的接口是你的责任。

在 Bark 中,业务逻辑中的Command类作为其接口的一部分提供了一个execute方法。当用户选择一个选项时,表示层使用这个接口。特定命令的实现可以改变,只要接口保持不变,就不需要在表示层进行任何更改。只有在例如Command类的execute方法需要额外的参数时,才需要更改表示层。

这也回到了内聚性的问题。紧密相关的代码不需要依赖接口;它们足够接近,以至于插入一个接口会感觉是强加的。另一方面,已经位于不同类或模块中的代码已经分离,因此使用共享接口而不是直接访问其他类可能是最佳选择。

7.2.3. 与熵作斗争:鲁棒性原则

是组织随时间逐渐解体成无序的趋势。代码通常开始时很小、整洁、易于理解,但随着时间的推移,往往会变得复杂。这种情况发生的一个原因是代码往往为了适应不同类型的输入而增长。

鲁棒性原则,也称为 Postel 定律,表述为:“在你所做的事情上要保守,在接收他人提供的内容时要宽容。” 这句话的精神是,你应该只提供实现预期结果所必需的行为,同时对外部输入的不完美或意外情况持开放态度。这并不是说你应该接受任何形式的输入,但保持灵活性可以简化代码消费者的开发过程。通过将可能的大范围输入映射到已知的小范围输出,你可以将信息流引导到一个更有限、更预期的范围(图 7.6)。

图 7.6. 将输入映射到输出时减少熵

图片

考虑内置的int()函数,它将输入转换为整数。这个函数适用于已经是整数的输入:

>>> int(3)
3

它也适用于字符串:

>>> int('3')
3

它甚至适用于浮点数,只返回整数部分:

>>> int(6.5)
6

int接受多种数据类型,并将它们全部转换为整数返回类型,只有在真正不清楚如何进行时才会抛出异常:

>>> int('Dane')
ValueError: invalid literal for int() with base 10: 'Dane'

花些时间了解代码消费者可能合理期望提供的输入范围,然后限制这些输入,以便只返回系统其他部分期望的内容。这将为系统入口点的消费者提供灵活性,同时保持底层代码必须处理的情况数量可控。

7.3. 扩展练习

现在你已经理解了可扩展和灵活设计的内容,你可以通过向 Bark 添加功能来应用这些概念。目前,Bark 是一个相对手动化的工具——你可以添加书签,但每次只能添加一个,而且用户必须自己输入所有 URL 和描述。这是一项繁琐的工作,尤其是如果他们已经在不同的工具中保存了一大堆书签的话。

你将构建一个 Bark 的 GitHub 星星导入器(图 7.7)。这个展示层的新导入选项必须完成以下任务:

  1. 提示 Bark 用户输入要导入星星的 GitHub 用户名。

  2. 询问用户是否要保留原始星星的时间戳。

  3. 触发相应的命令。

图 7.7. Bark GitHub 星星导入器的流程

图片

触发的命令必须使用 GitHub API 获取星星数据.^([3]) 我建议安装并使用 requests 包 (github.com/psf/requests)。

³

mng.bz/lony了解 GitHub 的星星仓库 API。

星标数据是分页的,所以过程看起来可能如下所示:

  1. 获取星星结果的初始页面。(端点是developer.github.com/v3/activity/starring/#list-repositories-being-starred。)

  2. 从响应中解析数据,使用它为每个标记的仓库执行一个AddBookmarkCommand

  3. 获取Link: <…>; rel=next头信息,如果存在。

  4. 如果有下一页,则重复此操作;否则,停止。

注意

要获取 GitHub 星标的时间戳,你必须在 API 请求中传递一个Accept: application/vnd.github.v3.star+json头信息。

从用户的角度来看,交互应该看起来像以下这样:

$ ./bark.py
(A) Add a bookmark
(B) List bookmarks by date
(T) List bookmarks by title
(D) Delete a bookmark
(G) Import GitHub stars
(Q) Quit

Choose an option: G
GitHub username: daneah
Preserve timestamps [Y/n]: Y
Imported 205 bookmarks from starred repos!

结果表明,Bark 按目前的编写方式并不是完全可扩展的,特别是在书签时间戳方面。目前,Bark 强制时间戳为书签创建的时间(使用datetime.datetime.utcnow().isoformat()),但你希望有保留 GitHub 星标时间戳的选项。你可以通过使用控制反转来改进这一点。

尝试更新AddBookmarkCommand以接受可选的时间戳,使用其原始行为作为后备。查看以下列表以查看你的完成情况。

列表 7.7. 反转书签时间戳的控制
class AddBookmarkCommand:

    def execute(self, data, timestamp=None):                             *1*
        data['date_added'] = timestamp or datetime.utcnow().isoformat()  *2*
        db.add('bookmarks', data)
        return 'Bookmark added!'
  • 1 添加一个可选的时间戳参数以执行

  • 2 如果提供了传入的时间戳,则使用它,否则使用当前时间作为后备

你现在已经提高了AddBookmarkCommand的灵活性,并且它足够灵活,可以处理 GitHub 星标导入所需的功能。你不需要在持久化层添加任何新功能,因此你可以专注于这个新特性的展示和业务逻辑。试一试,然后回来对照以下两个列表检查你的工作。

列表 7.8. GitHub 星标导入命令
class ImportGitHubStarsCommand:
    def _extract_bookmark_info(self, repo):                                   *1*
        return {
            'title': repo['name'],
            'url': repo['html_url'],
            'notes': repo['description'],
        }

    def execute(self, data):
        bookmarks_imported = 0

        github_username = data['github_username']
        next_page_of_results =
f'https://api.github.com/users/{github_username}/starred'                   *2*

        while next_page_of_results:                                           *3*
            stars_response = requests.get(                                    *4*
                next_page_of_results,
                headers={'Accept': 'application/vnd.github.v3.star+json'},
            )
            next_page_of_results =
stars_response.links.get('next', {}).get('url')                              *5*

            for repo_info in stars_response.json():
                repo = repo_info['repo']                                       *6*

                if data['preserve_timestamps']:
                    timestamp = datetime.strptime(
                        repo_info['starred_at'],                               *7*
                        '%Y-%m-%dT%H:%M:%SZ'                                   *8*
                    )
                else:
                    timestamp = None

                bookmarks_imported += 1
                AddBookmarkCommand().execute(                                  *9*
                    self._extract_bookmark_info(repo),
                    timestamp=timestamp,
                )

        return f'Imported {bookmarks_imported} bookmarks from starred repos!'  *10*
  • 1 给定一个仓库字典,提取创建书签所需的部分。

  • 2 星标结果的第一页的 URL

  • 3 在存在更多结果页面时继续获取星标结果

  • 4 获取下一页的结果,使用正确的头信息告诉 API 返回时间戳

  • 5 包含 rel=next 的 Link 头包含下一页的链接,如果有的话。

  • 6 标记的仓库信息

  • 7 星标创建的时间戳

  • 8 使用与现有 Bark 书签相同的格式格式化时间戳

  • 9 执行一个 AddBookmarkCommand,用仓库数据填充

  • 10 返回一个消息,指示导入了多少星标

列表 7.9. GitHub 星标导入选项
...
def get_github_import_options():                               *1*
    return {
        'github_username': get_user_input('GitHub username'),
        'preserve_timestamps':                                 *2*
            get_user_input(
                'Preserve timestamps [Y/n]',
                required=False
            ) in {'Y', 'y', None},                             *3*
    }

def loop():
    ...
    options = OrderedDict({
        ...
        'G': Option(                                           *4*
            'Import GitHub stars',
            commands.ImportGitHubStarsCommand(),
            prep_call=get_github_import_options
        ),
    })
  • 1 一个获取 GitHub 用户名以导入星标的功能

  • 2 是否保留星标最初创建时的时间

  • 3 接受“Y”、“y”或仅按 Enter 键作为用户表示“是”

  • 4 将 GitHub 导入选项添加到菜单中,使用正确的命令类和函数

更多练习

如果你想获得更多扩展 Bark 的经验,尝试实现编辑现有书签的功能。

你需要为DatabaseManager添加一个新方法来更新记录。更新记录需要用户指定要更新的记录(类似于删除)以及列名和要使用的新值。你可以将你在addselectdelete中已经写好的内容作为参考。

展示层必须提示用户输入要更新的书签 ID、要更新的列以及要使用的新值。这将连接到业务逻辑层中的新Edit-BookmarkCommand

这些都是你现在已经很擅长的内容,所以试试看!我的版本在本书的源代码中(见github.com/daneah/practices-of-the-python-pro)。

你应该看到,向一个可扩展的系统添加行为是一种低摩擦的活动。能够几乎完全专注于实现所需的行为,将现有基础设施的各个部分组合起来连接其他部分,这是一种乐趣。作为开发者,你可能会在某个罕见的时刻感觉自己像一支乐队的指挥,慢慢地将弦乐、木管乐和打击乐结合起来,创造出美妙的和谐。如果你的乐队偶尔产生更多的噪音,不要灰心。找出导致不和谐的僵硬点,看看你如何利用所学知识来解放自己。

在下一章中,你将了解更多关于继承及其适用场合的内容。

摘要

  • 编写代码,使得添加新功能意味着添加新函数、方法或类,而不需要编辑现有的代码。

  • 控制反转允许其他代码根据其需求自定义行为,而无需更改底层实现。

  • 在类之间共享已同意的接口,而不是给他们提供关于彼此的详细信息,这可以减少耦合。

  • 明确你想要处理哪些输入类型,并对输出类型严格要求。

第八章. 继承的规则(和例外)

本章涵盖

  • 使用继承和组合共同建模系统

  • 使用 Python 内置函数检查对象类型

  • 使用抽象基类使接口更加严格

如果你已经编写了自己的类或在使用 Python 的基于类的框架,你几乎肯定遇到过继承。类可以继承自其他类,最终拥有其父类的数据和行为。在本章中,你将了解 Python 中继承的细节,它在哪些情况下工作得很好,以及在哪些情况下应该避免。

8.1. 编程过去的继承

继承是在计算机编程的早期被构想出来的,尽管它已经存在很长时间了,但人们仍然就何时以及如何使用它进行热烈的辩论。在面向对象编程的大部分历史中,继承是游戏的名字。许多应用程序试图将现实世界建模为一个精心策划的对象层级,希望它能导致某种明显、整洁的结构。这种范式已经深深嵌入到面向对象编程实践中,以至于面向对象编程和继承这两个概念几乎不可分离。

8.1.1. 银弹

虽然继承有时是正确的工具,但它有时被用作每个钉子的锤子——难以捉摸的“银弹”。然而,正如银弹一样,满足所有需求的范式是一种虚构。

在面向对象编程中,类继承的普遍存在悄然地为许多开发者播下了挫败的种子,随着时间的推移,越来越多的人完全放弃了面向对象编程。这是一个不幸的结果。面向对象为问题的心理建模提供了许多好处。在建模正确的层级时,继承甚至有其位置。尽管继承不是解决你将遇到的每个数据建模问题的解决方案,但它确实是一组特定用例的正确解决方案,你将在本章后面了解更多。

在我们到达那里之前,了解类继承如何导致如此多的挫败感是很重要的。

8.1.2. 层级的挑战

面向对象编程的全部都是关于信息的分离、封装和分类。我与一些图书管理员合作,他们比我更了解分类——这些人致力于识别事物之间的关系,创建分类法或甚至本体论来对事物进行分类。^([1)] 这对于组织原始信息很有效,但一旦涉及到软件行为,它可能会带来痛苦。随着软件的增长,保持类之间的父子关系变得困难。

¹

关于信息科学中的本体论,请参阅维基百科文章:en.wikipedia.org/wiki/Ontology_(information_science)

注意

_ 父类 _ 在 Python(以及许多其他语言)中被称为超类子类被称为子类。我将在本章的其余部分使用这个术语。

一个类继承其所有超类的信息和行为,然后可以覆盖它们以执行不同的操作(图 8.1)。这可能是编程中存在的最紧密耦合。一个类与其超类完全耦合,因为它所知道和默认执行的一切都与那个超类相关联。

图 8.1. 一个超类和一个子类的继承

图片

当类层次结构增长时,看到这种耦合非常困难,因为如果你正在查看一个特定的类,并不明显另一个类是否继承自它。这会导致由于行为的不当变化而产生的错误,如图 8.2 所示。

图 8.2. 深层次的继承层次结构可能导致更多错误

图片

为了类比,在量子物理学中,两个粒子可以以一种方式纠缠在一起,即一个粒子的变化将影响另一个粒子的相同变化,无论它们在空间中相隔多远。爱因斯坦称之为“鬼魅般的远距离作用”,这意味着你无法可靠地确定粒子的状态,因为该状态可能会在任何时刻因为其孪生粒子的状态变化而改变。这对物理学来说很令人兴奋,但在软件中这是一个很大的危险。通过更改一个类,你可能会无意中改变——或者更糟,破坏——你未意识到的另一个子类的功能。这就像电影《蝴蝶效应》。(剧透警告:阿什顿·库彻的角色并不好过。)

开发者经常使用继承来重用代码,但这会带来后续的挑战。在深层层次结构中,不同级别的类可能会覆盖或补充其超类的行为。不久之后,你会发现自己在上下文穿越你的类,试图跟随信息的流动。我之前说过,我们作为开发者所做的事情应该增加我们的理解并减少认知负荷;深层层次结构与此目标相悖。那么我们为什么还在使用继承呢?

8.2. 程序设计中的继承现状

由于复杂层次结构带来的痛苦,继承已经声名狼藉。尽管如此,它本身并不是天生邪恶的。它只是被过度使用,并且出于错误的原因。

8.2.1. 真正的继承是为了什么?

尽管许多人仍然寻求使用继承来在某个类中重用代码,但这并不是继承的目的。继承是为了行为的专门化。换句话说,你应该抵制仅仅为了重用代码而子类化的冲动。创建子类是为了让一个方法返回不同的值或在其内部以不同的方式工作。

在这个意义上,子类应该被视为其超类的特殊情况。它们将重用超类的代码,但这只是由于一个自然的结果,即子类的一个实例确实是超类的一个实例。

当类B从类A继承时,我们经常说B“是”一个A。这是为了强调B的实例实际上是A的实例,并且应该看起来像A(稍后会详细说明)。这与组合相对比,如果类C使用类D的实例,我们说C“有”一个D,以强调C是由D(以及其他事物)组成的(可能还有其他)。

回想一下上一章中的自行车示例。你介绍了多种自行车车架类型,将铝制车架升级为碳纤维车架,并将轮胎升级为花哨轮胎。假设碳纤维车架花哨轮胎分别从车架轮胎继承而来。以下哪一项可以用来描述你使用继承和组合建模自行车的方式?

  1. 一个轮胎有一个自行车

  2. 一个自行车有一个轮胎

  3. 一个碳纤维车架是一个车架

  4. 一个碳纤维车架有一个车架

因为轮胎不是由自行车组成的(情况正好相反),所以 1 是不正确的,而 2 是有意义的——这是组合。而且因为碳纤维车架一个车架(它没有拥有一个车架),所以 4 也是不正确的,而 3 是有意义的——这是继承。再次强调,继承用于专门化,而组合用于可重用行为(图 8.3)。

图 8.3. 继承和组合如何协同工作

使用继承来专门化行为只是第一步。想想你如何能够替换碳纤维车架来替换自行车上的铝制车架。你可以这样做,因为每个车架都有相同的连接点。如果没有所有正确的连接,你的自行车可能会散架。你的软件也是如此。

8.2.2. 可替换性

麻省理工学院的教授 Barbara Liskov 提出了一个原则,概述了与继承相关的可替换性概念。Liskov 替换原则指出,在一个程序中,任何类的实例都必须可以被其子类之一的实例替换,而不会影响程序的正确性。*^([2]) 在这个语境中,“正确性”意味着程序保持无错误并实现相同的基本结果,尽管精确的结果可能不同或以不同的方式实现。可替换性源于子类严格遵循其超类接口。

²

关于 Liskov 替换原则的更多信息,请参阅维基百科文章:en.wikipedia.org/wiki/Liskov_substitution_principle

在 Python 中偏离这个原则并不困难。考虑以下列表,这是一段完全有效的 Python 代码,用于模拟蛞蝓和蜗牛(两种软体动物)。蜗牛蛞蝓继承(蜗牛和蛞蝓除了壳以外是一样的),你甚至可以说蜗牛通过添加有关其壳的信息来专门化蛞蝓。但是蜗牛违反了可替换性,因为使用蛞蝓的程序不能在不添加shell_size参数到__init__方法的情况下替换它,如下面的列表所示。

列表 8.1. 一个违反可替换性的子类
class Slug:
    def __init__(self, name):
        self.name = name

    def crawl(self):
        print('slime trail!')

class Snail(Slug):                         *1*
    def __init__(self, name, shell_size):  *2*
        super().__init__(name)
        self.name = name
        self.shell_size = shell_size

def race(gastropod_one, gastropod_two):
    gastropod_one.crawl()
    gastropod_two.crawl()

race(Slug('Geoffrey'), Slug('Ramona'))     *3*
race(Snail('Geoffrey'), Snail('Ramona'))   *4*
  • 1 蜗牛继承自蛞蝓。*

  • 2 使用不同的实例创建签名是违反可替换性的常见方式。*

  • 3 你可以创建两个 Slug 实例并让它们比赛。

  • 4 尝试使用没有 shell_size 参数的 Snail 会引发异常。

你可以施展更多的技巧来使这可行,但考虑这可能是组合的更好用例。毕竟,蜗牛壳。

我喜欢通过检查特定一组类所扮演的角色来思考可替换性。如果一个层次结构中的每个类都能履行所问的角色,它们很可能是可替换的。如果一个子类更改了其任何方法签名或在其特殊化过程中引发异常,它可能无法履行该角色,这可能是提示类层次结构应该以不同的方式组织的一个线索。

8.2.3. 继承的理想用例

Sandi Metz,一位来自 Smalltalk 社区的 Ruby 程序员(Smalltalk 是由对象编程先驱艾伦·凯部分编写的编程语言),提出了一套关于何时使用继承的很好的基本规则:[3])

³

更多信息请参阅 Sandi Metz,“所有的小事情”,RailsConf 2014,www.youtube.com/watch?v=8bZh5LMaSmE

  • 你正在解决的问题有一个浅层、狭窄的层次结构。

  • 子类位于对象图的叶子位置;它们不使用其他对象。

  • 子类使用(或特殊化)其超类所有的行为。

我将更详细地讨论这些内容。

浅层、狭窄层次结构

这条规则的浅层部分解决了你之前学到的深层继承层次结构的问题:深层嵌套的类层次结构可能导致难以管理并引入错误。保持层次结构小而紧凑,使得在必要时进行推理变得更加容易(图 8.4)。

图 8.4. 狭窄、浅层继承层次结构可以更有效地进行推理。

这条规则的狭窄部分意味着在层次结构中,没有哪个类应该有太多的子类。随着子类数量的增加,很难知道哪些子类提供了哪些特殊化,如果其他开发者找不到他们想要的子类,他们可能会重复创建子类。

对象图叶子的子类

你可以将你软件中的所有对象视为图中的节点,每个对象都指向它继承或通过组合使用其他对象。当使用继承时,一个类可以指向其他对象,但它的子类通常不应该有任何进一步的依赖。子类用于特殊化行为,但如果子类有一个超类或任何其他子类都没有的独特依赖,组合可能是一个更好的完成任务的方式。这是一个很好的检查,以确保你的子类在特殊化行为的同时没有添加太多新的耦合。

子类使用其超类所有的行为

这是之前你学到的“is-a”关系的结果。如果一个子类没有使用其超类中的所有行为,它是否真的是超类的一个实例?考虑一个代表鸟的类:

class Bird:
    def fly(self):
        print('flying!')

你可以子类化这个类,使得fly对某些类型的鸟有不同的行为:

class Hummingbird(Bird):
    def fly(self):
        print('zzzzzooommm!')

当你到达企鹅、几维鸟或鸵鸟时会发生什么?这些鸟都不会飞。一个可能的解决方案是以这种方式覆盖fly

class Penguin(Bird):
    def fly(self):
        print('no can do.')

你也可以覆盖fly使其不执行任何操作(pass)或引发某种类型的异常。但这违背了可替换性原则。任何知道它正在处理Penguin的代码不太可能调用fly,因此这种行为没有被使用。再次强调,将飞行行为组合到需要它的类中可能是一个更好的选择。

EXERCISE

现在你已经知道了一些需要关注的事情,尝试将继承和组合的规则应用到Bicycle示例中。自行车模块可以在本章的源代码中找到(见github.com/daneah/practices-of-the-python-pro)。

Bicycle示例遵循 Metz 描述的继承规则有多好?看看你是否能判断自行车模块中的对象是否遵循每一条规则。

回到这里看看你的表现:

  • FrameTire都有一个狭窄的、浅层层次结构;它们各自有一个在其下面的层级,最多有两个子类。

  • 不同类型的轮胎和框架不依赖于任何其他对象。

  • 不同类型的轮胎和框架使用或专门化它们超类中的所有行为。

成功!你创建的模型在需要的地方正确使用了继承,并使用组合将不同的部分组合成一个整体。继续阅读,了解 Python 提供了哪些工具来检查和使用继承。

8.3. Python 中的继承

Python 提供了一套用于检查类及其继承结构的工具,以及多种处理继承和组合的方法。本节将涵盖它们,以便当你使用继承时,你将拥有调试和测试代码的知识。

8.3.1. 类型检查

当你在调试代码时,最想知道的一件事是在特定行你正在处理的是哪种类型的对象。Python 的动态类型意味着这并不总是立即明显,所以检查是一个好习惯。

类型检查

Python 的最新版本支持类型提示,这是一种告诉开发者和自动化工具函数或方法期望哪些类型对象的方法。工具可以在不执行你的代码的情况下检查可能违反这些类型的调用。注意,Python 在执行期间不强制执行类型;这个特性严格上是开发辅助工具。

检查对象类型的基本方法是使用内置的type()函数。type(some_object)会告诉你该对象是哪个类的实例:

>>> type(42)
<class 'int'>
>>> type({'dessert': 'cookie', 'flavor': 'chocolate chip'})
<class 'dict'>

虽然这很有用,但你也会经常想知道一个对象是否是特定类或其子类的实例。Python 提供了isinstance()函数来完成这个目的:

>>> isinstance(42, int)
True
>>> isinstance(FancyTire(), Tire)  *1*
True
  • 1 你引用的任何类都需要导入到命名空间中。

最后,如果你只想知道一个类是否是另一个类的子类,Python 提供了issubclass函数:

>>> issubclass(int, int)
True
>>> issubclass(FancyTire, Tire)
True
>>> issubclass(dict, float)
False
注意

issubclass的命名有些令人困惑。因为它认为一个类是其自身的子类,所以即使你提供的两个类实际上是同一个类,它也会返回True

这些工具偶尔在真实代码中可能很有用,但它们的存在通常是一个红旗,因为基于数据类型改变行为正是行为子类的作用所在。这些内置函数对于从外部检查对象来说很好,但 Python 也提供了在类内部处理继承的有用特性。

8.3.2. 超类访问

假设你正在创建一个子类,并且需要以依赖于其超类原始行为的方式专门化其行为。你如何在 Python 中做到这一点?你可以使用内置的super()函数,如下所示,它将任何方法或属性访问转发到超类。

列表 8.2. 使用super()访问超类行为
class Teller:
    def deposit(self, amount, account):
        account.deposit(amount)
class CorruptTeller(Teller):                     *1*
    def __init__(self):
        self.coffers = 0

    def deposit(self, amount, account):          *2*
        self.coffers += amount * 0.01            *3*
        super().deposit(amount * 0.99, account)  *4*
  • 1 一个腐败的出纳员是一个出纳员。

  • 2 腐败的出纳员覆盖了默认的存款行为。

  • 3 腐败的出纳员从顶部拿走一小部分。

  • 4 他像任何出纳员一样存入剩余的金额,但使用不同的金额。

使用super()的代码如果可替换性被破坏,可能会变得特别复杂。覆盖接受不同数量参数的方法,并使用super()仅传递其中一些,可能会导致混淆和可维护性差。在 Python 的多重继承情况下,可替换性变得尤为重要。

8.3.3. 多重继承和方法解析顺序

到目前为止,我主要讨论的是单一继承,其中子类恰好有一个超类。但 Python 也支持多重继承的概念,其中子类可能有两个或更多直接超类,如图 8.5 所示。

图 8.5. 单一和多重继承

多重继承在插件架构中或当你想在单个类中实现多个接口时很有用。例如,水陆两栖车辆具有船和汽车两种接口。

你可以通过在类定义中提供多个类来在子类中从多个类继承,如图 8.3 所示。将此代码放在一个“cats”模块中,自己试一试。在运行此代码之前,你能猜出print(liger.eats())会做什么吗?

列表 8.3. Python 中的多重继承
class BigCat:
    def eats(self):
        return ['rodents']

class Lion(BigCat):                 *1*
    def eats(self):
        return ['wildebeest']

class Tiger(BigCat):                *2*
    def eats(self):
        return ['water buffalo']

class Liger(Lion, Tiger):           *3*
    def eats(self):
        return super().eats() + ['rabbit', 'cow', 'pig', 'chicken']

if __name__ == '__main__':
    lion = Lion()
    print('The lion eats', lion.eats())
    tiger = Tiger()
    print('The tiger eats', tiger.eats())
    liger = Liger()
    print('The liger eats', liger.eats())
  • 1 Lion 通过单继承是 BigCat 的子类。

  • 2 Tiger 也通过单继承是 BigCat 的子类。

  • 3 Liger 使用多重继承;它是 Lion 和 Tiger 的子类。

狮子会吃你预期的猎物吗?

The liger eats ['wildebeest', 'rabbit', 'cow', 'pig', 'chicken']

因为 LigerLionTiger 继承,你可能预期它至少会吃它们吃的相同猎物。在多重继承下,super() 的工作方式略有不同。当调用 super().eats() 时,Python 会开始搜索它应该使用的 eats() 方法定义。Python 通过一个称为 方法解析顺序 的过程来完成这项工作,该过程确定了 Python 将按顺序搜索的类列表。

这是方法解析顺序的步骤:

  1. 从左到右生成超类的深度优先排序。对于 Liger 来说,这是 Lion(最左边的父类)、BigCatLion 的唯一父类)、objectBigCat 的隐式父类)、TigerLiger 的下一个父类)、BigCatobject(见 图 8.6)。

    图 8.6. 类继承层次结构的深度优先排序

  2. 删除任何重复项。列表变为 LigerLionBigCatobjectTiger

  3. 将每个类移动,使其出现在所有子类之后。最终的列表是 LigerLionTigerBigCatobject

对于 Liger 来说,这看起来如何?完整的过程在 图 8.7 中展示。

图 8.7. Python 确定类的方法解析顺序

当你请求 super().eats() 时,Python 会通过方法解析顺序一路查找,直到在某个类(除了你调用 super() 的那个类)上找到一个 eats() 方法。正如你所见,它首先找到 Lion,返回 ['wildebeest']。然后 Liger 添加它自己的猎物列表,结果是你在输出中看到的列表。

检查方法解析顺序

你可以通过使用其 __mro__ 属性来查看任何类的 method resolution order

>>> Liger.__mro__
(<class '__main__.Liger'>, <class '__main__.Lion'>,
 <class '__main__.Tiger'>, <class '__main__.BigCat'>, <class 'object'>)

可以 通过练习 合作 多重继承使多重继承按预期工作。在合作多重继承中,每个类都承诺拥有相同的方法签名(可替换性)并在其 some_method() 中调用 super().some_method()。每个方法中 super() 的存在意味着 Python 会在找到方法后继续通过方法解析顺序。这确保了没有类会阻止执行或因意外接口而破坏事物。类们相处得很好。

尝试更新 LionTiger 类以调用 super().eats(),就像 Liger.eats() 方法做的那样。重新运行代码,然后回到这里检查它是否与以下输出匹配。

The liger eats ['rodents', 'water buffalo', 'wildebeest', 'rabbit', 'cow',
'pig', 'chicken']

虽然多重继承可能不是你每天都会用到的东西,但当你遇到它时,了解如何处理它是很重要的。随着你的软件不断增长,你需要使用不同范式的可能性也在增加,所以要做好准备。

8.3.4. 抽象基类

到目前为止,我对你说过一些关于 Python 中接口不可用的谎言。你首先需要掌握何时以及如何有效地使用继承和组合,但现在是一个深入了解的好时机。

*Python 中的抽象基类是一种使用类似继承的方式来达到实际上是一个接口的效果。抽象基类,就像其他语言中的正式接口一样,概述了其子类必须实现的哪些方法和属性。这回到了之前提到的履行角色的概念,在第 8.2.2 节。你不能直接创建抽象基类的实例;它充当了其他类行为的模板。

Python 提供了一个 abc 模块,用于简化抽象基类的创建。abc 模块提供了一些有用的构造:

  • 你可以通过从ABC类继承来表示你的类是一个抽象基类。

  • 你可以使用@abstractmethod装饰器在你的抽象基类中标记定义的方法为抽象方法。(装饰器超出了本书的范围,但你可以将abstractmethod视为你定义的方法的一个标签。)这强制规定这些方法必须在你的抽象类的任何子类中定义。

假设你正在模拟一个食物链,并确保所有的捕食类都遵循一个包含用于捕食猎物的eat方法的接口。你可以创建一个抽象基类Predator,它定义了这个方法和它的签名。然后你可以从Predator派生出一个子类,任何没有定义eat的子类都会引发异常,如以下列表所示。

列表 8.4. 使用抽象基类强制实现接口
from abc import ABC, abstractmethod

class Predator(ABC):                   *1*
    @abstractmethod                    *2*
    def eat(self, prey):               *3*
        pass                           *4*

class Bear(Predator):                  *5*
    def eat(self, prey):               *6*
        print(f'Mauling {prey}!')

class Owl(Predator):
    def eat(self, prey):
        print(f'Swooping in on {prey}!')

class Chameleon(Predator):
    def eat(self, prey):
        print(f'Shooting tongue at {prey}!')

if __name__ == '__main__':
    bear = Bear()
    bear.eat('deer')
    owl = Owl()
    owl.eat('mouse')
    chameleon = Chameleon()
    chameleon.eat('fly')
  • 1 从 ABC 继承使这个类成为一个抽象基类。

  • 2 这表示该方法必须在任何子类中定义。

  • 3 该方法签名可以在任何子类中由 IDE 进行检查。

  • 4 抽象方法没有默认实现。

  • 5 通过从抽象基类派生来声明实现接口的意图

  • 6 此方法必须定义,否则将引发异常。

小贴士

如果你使用的是 IDE,它会在你有错误的方法签名时警告你。Python 在运行时不会检查这一点,但它仍然可能会因为一些常见的错误(如参数过多或过少)而引发错误。

尝试创建一个没有eat方法的Predator新实例,然后在模块的末尾尝试创建它的实例。你应该会看到一个TypeError,指出实例无法创建,因为它没有为抽象方法eat()定义实现。

现在尝试向Bear类添加一个使其咆哮的方法。你期待会发生什么?

  1. 当实例被创建时,会引发一个TypeError,因为Predator没有将roar定义为抽象方法。

  2. 当调用roar()时,会引发一个RuntimeError,因为Predator没有将roar定义为抽象方法。

  3. 它的工作方式与任何正常的类方法一样。

在抽象基类的子类上定义额外的方法定义(选项 3)工作得很好。一个抽象基类强制其子类最小化实现它定义的方法,但额外的行为是可以接受的,因为子类仍然实现了所需的接口。也可以将额外的行为放入基类本身,并在子类中像正常继承一样接收它。但是,要避免这种做法,因为在一个声称是抽象的类中放置实际的行为可能会让阅读代码的人感到困惑。

抽象基类是 Python 的鸭子类型的一个很好的补充;如果你需要围绕你的类必须遵循的接口提供额外的保护和保证,它们就在那里供你使用。尽管如此,我并不经常使用它们。通过控制反转的组合通常对我来说已经足够了。尝试使用两者,看看哪一个对你和你的代码更有意义。

现在你已经很好地掌握了继承的不同方面,让我们来看看树皮,看看它为继承和组合提供了哪些机会。

8.4. 在树皮中的继承和组合

树皮到目前为止还没有使用继承。看看在不使用它的情况下你能走多远?但是,正如你所学的,当正确使用时,继承可以帮助你。在本节的最后,你将看到如何使用它来使树皮更加健壮。

8.4.1. 使用抽象基类重构

接口是一种声明类实现一组特定方法和属性的方式,而你刚刚了解到抽象基类可以用来增强 Python 中接口的概念。以下哪些在树皮中遵循接口?

  1. 命令模块中的命令

  2. 数据库模块中的数据库语句执行

  3. 树皮模块中的选项

树皮模块中的所有选项都表现出类似的行为,但每个选项并没有一个独特的,只有Option实例是独特的。这看起来不像是一个接口。数据库语句的执行同样被包含在一个单独的类中。命令(选项 1)利用接口;每个命令类实现了一个execute()方法,当命令被触发时会被调用。

为了确保你未来的所有命令都记得实现execute()方法,我希望你重构命令模块以使用抽象基类。你可以称这个基类为Command,并且它应该定义一个execute()方法作为abstractmethod,默认情况下会引发NotImplementedError。现有的每个命令类都应该从Command继承。

注意,现有的命令类都已经实现了 execute() 方法,所以在这一方面已经得到了覆盖。但是,execute() 方法的签名有一些不同,你了解到这不利于可替换性或处理抽象基类的情况。有些方法使用 data 参数调用,而有些则不接受任何参数。考虑一下你如何规范化这些方法,使它们具有相同的签名。以下哪个选项是可行的?

  1. 从接受 data 参数的 execute() 方法中移除 data 参数。

  2. data 作为可选的关键字参数添加到尚未接受它的 execute() 方法中。

  3. 使所有 execute() 方法接受可变数量的位置参数 (*args)。

移除 data 参数(选项 1)将阻止你能够在命令内部操作数据,这将从 Bark 中移除相当多的功能。尽管选项 3 可以工作,但通常最好在需要处理不同数量的参数的灵活性之前明确地说明你接受的参数。目前,execute() 总是需要一个或零个参数,所以我选择将 data 作为参数添加到每个方法中(选项 2)。

尝试创建 Command 抽象基类,并从它继承以创建你的命令。在这个过程中,尝试临时重命名 execute() 方法或更改它们的签名,看看你的 IDE(或 Bark)对破坏的接口有何反应。回到以下列表中查看你的结果。

列表 8.5. 命令模式的抽象基类
from abc import ABC, abstractmethod          *1*

class Command(ABC):                          *2*
    @abstractmethod
    def execute(self, data):                 *3*

class CreateBookmarksTableCommand(Command):  *4*
    def execute(self, data=None):            *5*
        ...

class AddBookmarkCommand(Command):           *6*
    ...
  • 1 从 abc 导入所需的工具

  • 2 定义 Command 基类

  • 3 定义接受数据参数的抽象方法

  • 4 每个命令都继承自 Command。

  • 5 添加数据参数(默认为 None,因此调用者可以省略它)

  • 6 已经接受数据参数的命令只需要继承自 Command。

由于 execute() 具有一致的签名,你也可以简化 bark 模块中的一行,其中选项触发 choose() 方法中的命令:

class Option:
    ...

    def choose(self):
        ...

        message = self.command.execute(data)       *1*
  • 1 总是传递数据给 execute

Bark 应该继续像以前一样工作。在这里添加抽象基类只是为了在创建未来的命令时使其更安全。如果你决定你的命令需要在将来实现额外的方法或接受额外的参数,你可以从向 Command 添加它们开始,你的 IDE 可以帮助你找到需要更新的地方。这是一个方便的开发方式。

8.4.2. 对你的继承工作进行最终检查

你已经成功使用继承使你的组合使用更加稳健。再次检查你的代码是否通过了 Metz 对良好继承使用的测试:

  • 命令具有浅层、狭窄的层次结构。 七个命令类宽,每个类都深入一个层次。

  • 命令不知道其他对象。它们确实使用了数据库连接对象,但这是一种遵循数据库接口的全局状态。

  • 命令使用或专门化其超类中的所有功能。 Command 是一个没有自身行为的抽象类。

很好。你正在使用继承,它在有道理且增加价值的地方使用,而没有强迫这种结构强加给不需要它的事物。这种批判性的审查在你继续编写和重构代码时非常有价值。

继续阅读下一章,了解如何通过保持类的小型化来保持类的可维护性。

摘要

  • 使用继承来表示真正的“是”关系(有利于行为的专业化)。

  • 使用组合来表示“有”关系(有利于代码重用)。

  • 方法解析顺序是保持多重继承直线的关键。

  • 抽象基类在 Python 中提供类似接口的控制和安全。

第九章。保持轻量级

本章涵盖

  • 使用复杂性度量来识别需要重构的代码

  • Python 语言特性用于分解代码

  • 使用 Python 语言特性来支持向后兼容性

在你的软件开发中,你将保持对分离关注点的警惕,但通常你会等到一个合理的组织结构出现,以避免创建错误的抽象。这意味着你的类将逐渐增长,直到变得难以管理。

这很像培养盆景的艺术;你需要给树时间生长,只有当它告诉你它将走向何方时,你才能鼓励它沿着那条路走。过度修剪树木会使其压力过大,强迫它采取不自然的形状可能会阻碍其茁壮成长的能力。

在本章中,你将学习如何修剪你的代码,以保持其健康和繁荣。

9.1。我的类/函数/模块应该有多大?

许多关于软件维护的在线论坛都包含这类问题。我有时 wonder 如果我们继续提问是因为我们认为最终我们可以超越到某种新的理解层面,在那里答案一直都很明显。每个后续的讨论线程都包含了一组意见、轶事和偶尔的数据点。

寻找这个问题的最终答案的愿望本身并不坏;拥有指导方针和里程碑,以便你能够识别何时应该在代码上投入时间是有用的。但了解我们用来接近这个问题的度量标准的优点和缺点也同样重要。

9.1.1。物理大小

有些人试图为函数、方法和类规定行数限制。这个指标看起来不错,因为它很容易衡量:“我的函数有 17 行长。”我反对这种做法,因为它可能迫使开发者拆分一个本可以理解得很好的函数,从而增加认知负荷。

如果你把线划在五行,那么六行的函数就突然变得不可行。这鼓励开发者玩“代码高尔夫”,试图将相同数量的逻辑压缩到更少的行中。Python 也允许这种游戏:

def valuable_customers(customers):
    return [customer for customer in customers if customer.active and
 sum(account.value for account in customer.accounts) > 1_000_000]

你能立即理解那段代码吗?它并不糟糕,但把它压缩成一行真的增加了价值吗?

看一下重写的版本,其中每个子句都有自己的行:

def valuable_customers(customers):
    return [
        customer
        for customer in customers
        if customer.active
        and sum(account.value for account in customer.accounts) > 1_000_000
    ]

合理地分解事物给阅读你代码的人一个机会,去消化每个子句,形成他们在阅读过程中发生的事情的心理模型。

我见过的行限制规则的另一种形式是“一个类应该适应一个屏幕”。这与它的严格版本共享一些痛点,同时由于屏幕尺寸和分辨率的差异,它也变得不那么可衡量。

这些指标的精神是“保持简单”,我同意这一点。但还有其他定义“简单”的方法。

9.1.2. 单一职责

对类、方法或函数的大小的一个更开放式的度量是它做了多少不同的事情。正如你从关注点的分离中学到的,理想数字是 1。对于函数和方法,这意味着执行一个计算或任务。对于类,这意味着处理某个更大的业务问题的单一、专注的方面。

如果你发现一个函数执行两个任务或一个包含两个不同关注领域的类,那么这是一个强烈的信号,表明有分离它们的机遇。但有时,看似单一的任务可能仍然足够复杂,需要进一步分解。

9.1.3. 代码复杂性

理解代码的认知和维护影响的一种更稳健的方法是通过其复杂性。就像时间和空间复杂度一样,代码复杂性是对你代码特性的定量测量,而不仅仅是阅读时感到困惑的主观度量。

复杂度测量工具是你工具箱中的一大法宝。我发现它们通常能准确地指出我作为人类可能会觉得难以阅读和理解的部分代码。在接下来的几节中,我将向你展示代码复杂性的样子,以及一些测量它的工具。

测量代码复杂性

复杂性的一个常见度量是循环复杂度。尽管这个名字听起来令人恐惧地科学,但测量循环复杂度涉及到确定通过函数或方法执行的执行路径数量。函数的结构(因此,复杂性)受到其中包含的条件表达式和循环数量的影响。

函数或方法的复杂度得分越高,你应该期望它包含的条件和循环越多。具体的得分并不总是非常有用;其随时间的变化以及它对代码中你做出的更改的反应,将帮助你编写更易于维护的软件。努力在一段时间内降低你的复杂度得分,并在确定在哪里投入重构时间时考虑复杂度高的代码片段。

你可以自己测量函数的复杂度。通过创建 控制流 的图,即代码执行时采取的路径,你可以计算图中节点和边的数量,并计算圈复杂度。以下是在程序的控制流图中表示为节点的:

  • 函数的“开始”部分(控制流进入的地方)

  • if/elif/else 条件(每个都是独立的节点)

  • for 循环

  • while 循环

  • 循环的“结束”部分(将执行路径绘制回循环的开始)

  • return 语句

考虑以下列表中的函数,它接受一个句子作为字符串或单词列表,并确定句子中是否有任何长单词。它包含一个循环和多个条件表达式。

列表 9.1. 包含条件和循环的函数
def has_long_words(sentence):
    if isinstance(sentence, str):          *1*
        sentence = sentence.split(' ')

    for word in sentence:                  *2*
        if len(word) > 10:                 *3*
            return True

    return False                           *4*
  • 1 如果是字符串,则在句子中拆分单词(条件)

  • 2 对每个单词(循环)执行工作

  • 3 如果找到长单词,则返回 True(条件)

  • 4 如果没有长单词,则返回 False

边是遵循代码可以采取的不同执行路径的箭头。函数或方法的圈复杂度,M,等于边的数量减去节点的数量,再加上 2。如果你通过绘制函数来帮助,可以为不在条件块或循环内的代码行添加节点和边,但它们不会影响整体复杂度——每个都添加一个节点和一个边,在数学上相互抵消。

has_long_words 函数有一个条件来检查输入是否为字符串,一个循环来遍历句子中的每个单词,以及循环内的一个条件来检查单词是否长。其图示显示在图 9.1 中。通过绘制控制流并简化图形为普通节点和边,你可以数出它们并把这些结果代入圈复杂度方程。在这种情况下,has_long_words 的图有 8 个节点和 10 条边,所以其复杂度是 M = E - N + 2 = 10 - 8 + 2 = 4。

图 9.1. 绘制控制流以测量圈复杂度

大多数资料建议,对于给定的函数或方法,应追求 10 或以下的复杂度。这大致相当于开发者一次可以合理理解的内容量。

除了帮助您了解代码的健康状况外,循环复杂度在测试中也非常有用。回想一下,循环复杂度衡量一个函数或方法有多少执行路径。因此,这也是您需要编写的最小不同测试用例数,以覆盖每个执行路径。这源于每个ifwhile等都需要您准备不同的预条件集来测试一个或另一个情况会发生什么。

记住,完美的测试覆盖率并不能保证您的代码实际上能工作;它只意味着您的测试导致该部分代码运行。但确保您覆盖了感兴趣的执行路径通常是好主意。未测试的执行分支通常是人们谈论“边缘情况”时所指的,这个术语有负面含义,通常意味着“我们没有想到的事情。”Ned Batchelder 的出色 Coverage 包(coverage.readthedocs.io)可以为您的测试打印分支覆盖率指标。

Halstead 复杂性

对于某些应用程序,降低发布有缺陷软件的风险与可维护性一样重要。尽管减少代码中的分支往往会使代码更易于阅读和理解,但这还没有被证明可以减少软件中的错误数量。循环复杂度预测缺陷的数量与代码行数一样好。但至少有一套度量标准试图解决缺陷率问题。

Halstead 复杂性试图定量地衡量抽象级别、可维护性和缺陷率等概念。衡量 Halstead 复杂性涉及检查程序对编程语言内置运算符的使用以及它包含多少变量和表达式。这超出了本书的范围,但我建议您阅读更多关于它的内容。(可以从维基百科文章开始:en.wikipedia.org/wiki/Halstead_complexity_measures。)如果您对探索感兴趣,Radon (radon.readthedocs.io) 可以测量您 Python 程序的 Halstead 复杂性。

回想一下您在 Bark 中编写的导入 GitHub 星标的代码(在下述列表中重现)。尝试绘制控制流并计算循环复杂度。

列表 9.2. 在 Bark 中导入 GitHub 星标的代码
def execute(self, data):
    bookmarks_imported = 0

    github_username = data['github_username']
    next_page_of_results =
 f'https://api.github.com/users/{github_username}/starred'

    while next_page_of_results:                   *1*
        stars_response = requests.get(
            next_page_of_results,
            headers={'Accept': 'application/vnd.github.v3.star+json'},
        )
        next_page_of_results = stars_response.links.get('next', {}).get('url')

        for repo_info in stars_response.json():   *2*
            repo = repo_info['repo']

            if data['preserve_timestamps']:       *3*
                timestamp = datetime.strptime(
                    repo_info['starred_at'],
                    '%Y-%m-%dT%H:%M:%SZ'
                )
            else:                                 *4*
                timestamp = None

            bookmarks_imported += 1
            AddBookmarkCommand().execute(
                self._extract_bookmark_info(repo),
                timestamp=timestamp,
            )                                     *5*

    return f'Imported {bookmarks_imported} bookmarks from starred repos!'
  • 1 另一个循环,代码将回到这里

  • 2 另一个循环,代码将回到这里

  • 3 执行的一个分支

  • 4 另一个执行分支

  • 5 返回 for 循环或,如果完成,返回 while 循环的点

当您完成时,回来检查您的工作与图 9.2 中的解决方案是否一致。

图 9.2. Bark 应用程序中函数的循环复杂度

图片

幸运的是,你不需要为每个编写的函数和方法绘制图表。市面上有许多工具,如 SonarQube (www.sonarqube.org) 和 Radon (radon.readthedocs.io),可以为你测量这些指标。这些工具甚至可以集成到你的代码编辑器中,这样你可以在开发过程中将复杂的代码拆分。

现在你已经学会了一些发现代码变得复杂的方法,你可以练习分解这种复杂性。

9.2. 分解复杂性

我有一些不太好的消息:认识到代码是复杂的是容易的部分。下一个挑战是理解如何处理特定类型的复杂性。在本章的其余部分,我会指出我在与 Python 旅行的过程中看到的一些常见的复杂性模式,并展示你可以用来解决这些问题的选项。

9.2.1. 提取配置

我将以这本书中你已经见过的例子开始:随着你的软件增长,代码的某些区域需要继续适应新的需求。

想象你正在构建一个网络服务,犹豫不决的用户可以查询以了解他们午餐应该吃什么。如果用户访问你的服务的/random端点,他们应该得到一个随机的食物,比如pizza。你的初始处理函数将用户请求作为参数接受,它可能看起来像这样:

import random

FOODS = [                         *1*
    'pizza',
    'burgers',
    'salad',
    'soup',
]

def random_food(request):         *2*
    return random.choice(FOODS)   *3*
  • 1 食物列表(这最终可能放入数据库中。)

  • 2 函数接受用户的 HTTP 请求(目前未使用)。

  • 3 从列表中随机返回一个食物,作为一个字符串

当你的服务变得流行(人们都犹豫不决)时,一些用户想要围绕它构建一个完整的应用程序。他们告诉你他们想要以 JSON 格式从你那里得到响应,因为它很容易处理。你不想改变其他用户的默认行为,所以你告诉他们,如果他们在请求中发送Accept: application/json头,你将返回 JSON 响应。(如果你不熟悉 HTTP 头,不必太担心它们的工作方式;假设request.headers是一个包含头名称到头值的字典。)你可以更新你的函数来处理这种情况:

import json
import random

...

def random_food(request):
    food = random.choice(FOODS)                               *1*

    if request.headers.get('Accept') == 'application/json':   *2*
        return json.dumps({'food': food})
    else:
        return food                                           *3*
  • 1 随机选择食物并暂时存储它

  • 2 如果请求有Accept: application/json头,则返回{"food": "pizza"},例如

  • 3 默认情况下继续返回“pizza”,例如

从循环复杂性的角度考虑这个变化;变化前后复杂性是什么?

  1. 1 之前,2 之后

  2. 2 之前,2 之后

  3. 1 之前,3 之后

  4. 2 之前,1 之后

你的初始函数没有条件语句或循环,因此复杂性为 1。因为你只添加了一个新的条件(用户请求 JSON 的情况),复杂性从 1 增加到 2(选项 1)。

为了处理新的要求,将复杂度增加 1 并不是什么坏事。但如果你继续沿着这个轨迹前进,随着每个要求的增加线性增加复杂度,你很快就会遇到棘手的代码:

...

def random_food(request):
    food = random.choice(FOODS)
    if request.headers.get('Accept') == 'application/json':
        return json.dumps({'food': food})
    elif request.headers.get('Accept') == 'application/xml':   *1*
        return f'<response><food>{food}</food></response>'
    else:
        return food
  • 1 每个额外的要求都是一个新条件,增加了复杂性。

你还记得如何解决这个问题吗?作为一个提示,观察到条件语句正在将一个值(Accept 头部的值)映射到另一个值(要返回的响应)。哪种数据结构是有意义的?

  1. 列表

  2. 元组

  3. 字典

  4. 集合

Python 字典(选项 3)将值映射到其他值,因此它非常适合重构此代码。将执行流程重构为头部值到响应格式的配置,然后根据用户的请求选择正确的格式,将简化问题。

尝试将不同的头部值和响应类型提取到一个字典中,如果用户没有请求响应格式(或请求了未知格式),则使用默认行为作为后备。完成工作后,请对照以下列表检查你的工作。

列表 9.3. 提取配置的端点
...

def random_food(request):
    food = random.choice(FOODS)

    formats = {                                               *1*
        'application/json': json.dumps({'food': food}),
        'application/xml': f'<response><food>{food}</food></response>',
    }

    return formats.get(request.headers.get('Accept'), food)   *2*
  • 1 从之前的 if/elif 条件中提取

  • 2 如果有请求的响应格式,则获取该格式;否则,回退到返回纯字符串

信不信由你,这个新解决方案又回到了 1 的圈复杂度。即使你继续向 formats 字典中添加条目,也不会增加额外的复杂度。这正是我在第四章中提到的那种收益;你已经从线性算法转变为常数算法。

将配置提取到映射中,据我所知,也使得代码的阅读性大大提高。试图筛选多个 if/elif 条件是令人疲惫的,即使它们都相当相似。相比之下,字典的键通常是可扫描的。如果你知道你要找的键,那么快速找到它是很容易的。

我们能做得更好吗?

9.2.2. 提取函数

在解决日益增长的圈复杂度问题后,random_food 函数中还有两件事在同步增长:

  • 知道要做什么的代码(将响应格式化为 JSON、XML 等等)

  • 知道如何决定要做什么的代码(基于 Accept 头部值)

这是一个分离关注点的机会。正如我在本书中多次提倡的那样,在这里提取一些函数可能会有所帮助。如果你查看 formats 字典中的每个条目,你会注意到值是 food 变量的函数。这些值中的每一个都可以是一个接受 food 参数并返回将返回给用户的格式化响应的函数,如图 9.3 所示。图 9.3。

图 9.3. 将内联表达式提取为函数

尝试将你的random_food函数更改为使用这些分离的响应格式函数。现在字典将格式映射到可以返回该格式响应的函数,而random_food将使用food值调用该函数。如果在调用formats.get(…``)之后没有可用的函数,你应该回退到一个返回未更改food值的函数;这可以使用 lambda 来完成。完成后请检查以下列表。

列表 9.4. 带有响应格式化函数的服务端点
def to_json(food):                            *1*
    return json.dumps({'food': food})

def to_xml(food):
    return f'<response><food>{food}</food></response>'

def random_food(request):
    food = random.choice(FOODS)

    formats = {                               *2*
        'application/json': to_json,
        'application/xml': to_xml,
    }

    format_function = formats.get(            *3*
        request.headers.get('Accept'),
        lambda val: val                       *4*
    )
    return format_function(food)              *5*
  • 1 提取了格式化函数

  • 2 现在将数据格式映射到相应的格式化函数

  • 3 如果有,获取适当的格式化函数

  • 4 使用 lambda 作为后备来返回未更改的食物值

  • 5 调用格式化函数并返回其响应

为了完全分离关注点,你现在可以将formats和从其中获取正确函数的业务提取到它自己的函数中,即get_format_function。这个函数接受用户的Accept头值并返回正确的格式化函数。现在尝试一下,完成后参考以下列表来检查你的工作。

列表 9.5. 将关注点分离到两个函数中
def get_format_function(accept=None):                                     *1*
    formats = {
        'application/json': to_json,
        'application/xml': to_xml,
    }

    return formats.get(accept, lambda val: val)

def random_food(request):                                                 *2*
    food = random.choice(FOODS)
    format_function = get_format_function(request.headers.get('Accept'))  *3*
    return format_function(food)
  • 1 确定要使用哪个格式化函数

  • 2 现在随机食物只需要三个简短的步骤。

  • 3 之前混合的关注点现在被抽象为函数调用。

你可能会认为这段代码更复杂;现在你有四个函数,而最初只有一个。但你在这里实现了某些东西:这些函数的循环复杂度都是 1,可读性很好,并且关注点得到了很好的分离。

你还掌握了一些可扩展的技巧,因为当你需要处理新的响应格式时,过程如下:

  1. 添加一个新函数来按需格式化响应。

  2. 将所需的Accept头值映射到新的格式化函数。

  3. 利润。

你只需添加新的代码和更新配置,就能创造新的商业价值。这是理想状态。

现在你已经了解了一些关于函数的技巧,我想向你展示一些关于类的技巧。

9.3. 分解类

类可能会像函数一样变得难以管理,而且增长速度可能更快。但分解一个类似乎比分解一个函数更让人感到恐惧。函数感觉像是构建块,而类则感觉像是成品。这是我经常努力克服的心理障碍。

你应该有信心像分解函数一样频繁地分解类。类只是你手中的另一个工具。当你发现一个类开始变得复杂时,通常是因为关注点的混合。一旦你确定了一个感觉像它自己的对象的关注点,你就有了开始分解它的足够内容。

9.3.1. 初始化复杂性

我经常看到具有复杂初始化过程的类。不管好坏,这些类通常很复杂,因为它们处理复杂的数据结构。你有没有见过如下所示的类?

列表 9.6. 构造中具有复杂领域逻辑的类
class Book:
    def __init__(self, data):
        self.title = data['title']                                 *1*
        self.subtitle = data['subtitle']

        if self.title and self.subtitle:                           *2*
            self.display_title = f'{self.title}: {self.subtitle}'
        elif self.title:
            self.display_title = self.title
        else:
            self.display_title = 'Untitled'
  • 1 从传入的数据中提取了一些字段

  • 2 来自业务领域逻辑的复杂性

当你处理的领域逻辑很复杂时,你的代码更有可能反映出这一点。在这些情况下,开发者依赖有用的抽象来理解所有内容比以往任何时候都更重要。

我已经讨论过将函数和方法提取出来作为分解代码的有用方式。你可以采取的一种方法是将 display_title 的逻辑提取到一个 set_display_title 方法中,这样你就可以从 __init__ 方法中调用它,如下面的列表所示。尝试创建一个 book 模块,并将 Book 类添加到其中,提取 display_title 的设置方法。

列表 9.7. 使用设置器简化类构造
class Book:
    def __init__(self, data):
        self.title = data['title']
        self.subtitle = data['subtitle']
        self.set_display_title()            *1*

    def set_display_title(self):            *2*
        if self.title and self.subtitle:
            self.display_title = f'{self.title}: {self.subtitle}'
        elif self.title:
            self.display_title = self.title
        else:
            self.display_title = 'Untitled'
  • 1 调用提取的函数

  • 2 提取了设置 display_title 的函数集。

这已经清理了 __init__ 方法,但这种方法引发了一些问题:

  • 在 Python 中,通常不建议使用获取器和设置器,因为它们可能会使类变得杂乱。

  • __init__ 方法中直接将所有必要的属性设置为某个初始值是一种良好的实践,但 display_title 是在另一种方法中设置的。

你可以通过将 display_title 设置为 'Untitled' 默认值来修复后者,但这可能会产生误导。如果读者没有仔细阅读,他们可能会得出结论,显示标题通常是(甚至总是)'Untitled'

有一种方法可以让你在不遭受这些缺点的情况下获得提取方法的可读性好处。这涉及到创建一个返回 display_title 值的函数。

但等等!如果你考虑一下你是如何使用 Book 的,它可能看起来像这样:

...

book = Book(data)
return book.display_title

你如何将 display_title 的逻辑变成一个函数,而无需更新第二行以返回 book.display_title()?幸运的是,Python 提供了一个工具来处理这种情况。@property 装饰器可以用来表示类上的方法应该作为属性可访问。

现在,创建一个 display_title 方法,并用 @property 装饰器装饰,使用现有的逻辑来返回正确的显示标题。完成更改后,将你的更改与以下列表进行比较。

注意

只有当 self 是它们的唯一参数时,方法才能用作属性,因为当你访问属性时,你不能向它传递任何参数。

列表 9.8. 使用 @property 简化类构造
class Book:
    def __init__(self, data):
        self.title = data['title']
        self.subtitle = data['subtitle']

    @property
    def display_title(self):                 *1*
        if self.title and self.subtitle:
            return f'{self.title}: {self.subtitle}'
        elif self.title:
            return self.title
        else:
            return 'Untitled'
  • 1 属性是一个可以作为属性引用的函数。

使用 @property,你仍然可以将 book.display_title 作为属性引用,但所有复杂性都抽象到了它自己的函数中。这减少了 __init__ 方法的复杂性,同时也使其更易于阅读。我在自己的代码中经常使用 @property

注意

因为属性是方法,反复访问它们意味着每次都会调用这些方法。这通常是可以接受的,但对于计算成本较高的属性,可能会对性能产生影响。

当有足够的功能可以抽象出一个整个 值得的方法时,你应该做什么?

9.3.2. 提取类和转发调用

当你在 第 9.2.2 节 中从 random_food 中提取 get_format_function 时,你仍然 调用 从其原始位置提取的函数。当处理类时,如果你想保持 向后兼容性,则需要发生类似的事情。向后兼容性是指在不会破坏之前依赖的实现消费者的前提下,使你的软件不断进化的实践。如果你更改函数的参数、类的名称等,消费者需要更新他们的代码,以便继续工作。为了避免这些问题,你可以从邮局的邮件转发系统中得到一些启示。

当你搬迁到新地址时,你可以告诉邮局转发你的邮件(图 9.4)。那些给你旧地址发邮件的人不需要立即知道你的新地址,因为邮局会拦截邮件并将其自动转寄给你。每次你收到寄往你旧住址的邮件时,你都可以通知发件人你的新地址,以便他们更新记录。一旦你确信你不再收到寄往旧地址的邮件,你就可以停止邮局转发。

图 9.4. 当你搬迁到新地点时,邮局可以转发邮件。

当你从一个类中提取一个类时,你希望继续提供之前存在的功能一段时间,尽管在底层进行更改,这样消费者就不需要立即担心升级他们的软件。就像你的邮件一样,你可以继续在一个类中接受调用,并在底层将它们传递给另一个类。这被称为 转发

假设你的 Book 类已经增长到需要跟踪作者信息。一开始这感觉是自然的;一本书没有作者是什么?但随着类承担更多功能,作者开始感觉像是一个独立的问题。如下所示,很快就会存在用于作者姓名的方法,这些姓名应该显示在网站上,以及它们应该如何显示在研究论文的引用中。

列表 9.9. 过于关注作者细节的 Book
class Book:
    def __init__(self, data):
        # ...
        self.author_data = data['author']         *1*

    @property
    def author_for_display(self):                 *2*
        return f'{self.author_data["first_name"]}
 {self.author_data["last_name"]}'

    @property
    def author_for_citation(self):                *3*
        return f'{self.author_data["last_name"]},
 {self.author_data["first_name"][0]}.'

假设你一直是这样使用这个 Book 类的:

book = Book({
    'title': 'Brillo-iant',
    'subtitle': 'The pad that changed everything',
    'author': {
        'first_name': 'Rusty',
        'last_name': 'Potts',
    }
})

print(book.author_for_display)
print(book.author_for_citation)
  • 1 将作者存储为从数据中创建的字典

  • 2 显示作者,例如“Dane Hillard”

  • 3 获取适合引用的作者姓名,例如“Hillard, D”

能够引用book.author_for_displaybook.author_for_citation已经很好了,你希望保留这一点。但是,在这些属性中引用author字典开始感觉笨拙,而且你知道你很快就会想要对作者做更多的事情。你该如何进行?

  1. 提取一个AuthorFormatter类,用于以不同方式格式化作者姓名。

  2. 提取一个Author类来封装作者的行为和信息。

虽然一个用于格式化作者姓名的类(选项 1)可能提供价值,但提取一个Author类(选项 2)提供了更好的关注点分离。当一个类中的几个方法共享一个公共前缀或后缀,尤其是与类名不匹配的一个,可能有一个新的类等待被提取。在这里,author_是一个迹象,表明Author类可能是有意义的。现在是时候尝试提取一个类了。

创建一个Author类(可以在同一模块中创建或从新模块中导入)。这个Author类应该包含与之前相同的信息,但以更结构化的方式。这个类应该

  • __init__中接受author_data作为字典,将字典中的每个相关值(名、姓等)存储为属性

  • 有两个属性,for_displayfor_citation,它们返回正确格式化的作者字符串

记住,你希望Book对用户仍然有效,因此你希望现在继续在Book上保留现有的author_dataauthor_for_displayauthor_for_citation行为。通过使用author_data初始化Author实例,你可以将Book.author_for_display的调用转发Author.for_display,依此类推。这样,Book将让Author做大部分工作,同时保留一个临时系统以确保调用仍然有效。现在试试看,然后回到以下列表中查看你的结果。

列表 9.10. 从Book类中提取Author
class Author:
    def __init__(self, author_data):                   *1*
        self.first_name = author_data['first_name']
        self.last_name = author_data['last_name']

    @property
    def for_display(self):                             *2*
        return f'{self.first_name} {self.last_name}'

    @property
    def for_citation(self):
        return f'{self.last_name}, {self.first_name[0]}.'

class Book:
    def __init__(self, data):
        # ...

        self.author_data = data['author']              *3*
        self.author = Author(self.author_data)         *4*

    @property
    def author_for_display(self):                      *5*
        return self.author.for_display

    @property
    def author_for_citation(self):
        return self.author.for_citation
  • 1 之前仅以字典形式存储的内容现在是有结构的属性。

  • 2 作者级别的属性比原始属性更简单。

  • 3 直到消费者不再需要它为止继续存储 author_data

  • 4 存储一个 Author 实例以转发调用

  • 5 用转发到 Author 实例的逻辑替换之前的逻辑

你注意到尽管代码现在有更多行,但每一行都简化了吗?并且查看这些类,它们包含的信息类型更容易识别。最终,Book中仍然存在的大部分代码也将被移除,到那时Book将利用Author类的组合来提供有关其作者的信息。

如果您在分解类时想对消费者非常友好,您也可以给他们留下提示,让他们知道他们应该切换到新代码。例如,您希望Book的消费者从book.author_for_display迁移到book.author.for.display,以便您可以删除转发。Python 有一个内置的系统用于此类消息,称为warnings

一种警告类型是特定的 DeprecationWarning,您可以使用它来让人们知道某些东西应该不再使用。这种警告通常会在程序输出中打印一条消息,告诉用户他们应该做出更改。一个弃用警告可以如下生成:

import warnings

warnings.warn('Do not use this anymore!', DeprecationWarning)

您可以通过在每个最终想要删除的方法中添加一个 DeprecationWarning 来帮助消费者平滑地升级他们的代码.^([1]) 现在尝试将它们添加到 Book 类的作者相关属性中。您可以说一些有用的话,比如 'Use book.author .for_display instead'。如果您现在运行代码,您应该在输出中看到类似以下警告信息:

¹

参见 Brett Slatkin,“重构 Python:为什么以及如何重构你的代码”,PyCon 2016,www.youtube.com/watch?v=D_6ybDcU5gc,了解弃用和提取技巧的宝藏。

/path/to/book.py:24: DeprecationWarning: Use book.author.for_display instead

恭喜!您已提取了一个新的类,简化了超出自身复杂性的类的复杂性。您以向后兼容的方式完成了这项工作,为用户提供提示,让他们知道即将发生什么以及如何修复它。这导致了更结构化、更易于阅读的代码,具有分离的关注点和强大的内聚性。做得好,您。

摘要

  • 代码复杂性和分离的关注点是比物理大小更好的拆分代码的指标。

  • 圈复杂度衡量了通过您代码的执行路径数量。

  • 自由提取配置、函数、方法和类,以简化复杂性。

  • 使用转发和弃用警告来暂时支持新旧做法。

第十章. 实现松耦合

本章涵盖

  • 识别紧密耦合代码的迹象

  • 降低耦合的策略

  • 消息导向编程

松耦合允许您在不担心会破坏其他地方的情况下,对代码的不同区域进行更改。它允许您在同事处理另一个功能的同时,专注于一个功能。它也是其他期望特性的基础,如可扩展性。没有松耦合,维护您代码的工作可能会迅速失控。

在本章中,您将看到紧密耦合的一些痛苦,并学习如何解决这些问题。

10.1. 定义耦合

由于耦合的概念在有效软件开发中扮演着如此重要的角色,因此了解它的含义非常重要。耦合究竟是什么?您可以将其视为您代码不同区域之间的连接组织。

10.1.1. 连接组织

耦合一开始可能是一个棘手的概念,因为它并不一定是具体的。它是一种贯穿你代码的网状结构(图 10.1)。当两段代码有高度依赖性时,这个网状结构就编织得紧密且紧张。移动任何一段代码都需要另一段代码也移动。在几乎没有相互依赖性的区域之间的网状结构是灵活的——可能是由橡皮筋制成的。你将不得不在这个网状结构较松的部分进行更多的代码更改,才能对其周围的代码产生影响。

图 10.1. 耦合是衡量不同软件组件之间相互连接程度的度量。

我喜欢这个类比,因为它并没有说在所有情况下紧密耦合都是固有的。相反,它关注紧密耦合和松散耦合的不同之处,并帮助你对你代码的结果有一个感觉——紧密耦合通常意味着当你想要重新排列事物时需要做更多的工作。它还暗示耦合是一个连续体,而不是一个二元、全有或全无的东西。

虽然耦合是在一个连续体上测量的,但确实有一些常见的表现方式。你可以学会识别这些,并根据你的需要减少软件中的耦合。不过,首先,我想给你一个更细致的定义,关于紧密耦合和松散耦合。

10.1.2. 紧密耦合

当两段代码(模块、类等)相互连接时,这种耦合被认为是紧密的。但相互连接看起来是什么样子?在你的代码中,有几个因素会创建连接:

  • 一个将另一个对象作为属性存储的类

  • 一个方法调用另一个模块的函数的类

  • 一个使用另一个对象的多个方法进行大量过程性工作的函数或方法

任何时候一个类、方法或函数需要携带大量关于另一个模块或类的知识时,这就是紧密耦合。考虑以下列表中的代码。display_book_info函数需要了解Book实例包含的所有不同信息。

列表 10.1. 与对象紧密耦合的函数
class Book:
    def __init__(self, title, subtitle, author):              *1*
        self.title = title
        self.subtitle = subtitle
        self.author = author

def display_book_info(book):
    print(f'{book.title}: {book.subtitle} by {book.author}')  *2*
  • 1 一本书将几条信息作为属性存储。

  • 2 这个函数了解所有书籍的属性。

如果Book类和display_book_info函数位于同一个模块中,这段代码可能还可以接受。它操作相关信息,并且它们都在一个地方。但随着你的代码库增长,你可能会在某个模块中找到像display_book_info这样的函数,它操作来自其他模块的类。

紧密耦合并不是固有的坏。偶尔,它只是在试图告诉你一些东西。因为display_book_info只操作来自Book的信息,并且做一些与书籍相关的事情,所以函数和类有高内聚性。它与Book耦合得如此紧密,以至于将其作为一个方法移动到Book类中是有意义的,如下面的列表所示。

列表 10.2. 通过增加内聚性来减少耦合
class Book:
    def __init__(self, title, subtitle, author):
        self.title = title
        self.subtitle = subtitle
        self.author = author

    def display_info(self):                                       *1*
        print(f'{self.title}: {self.subtitle} by {self.author}')  *2*
  • 1 函数移动到只必要参数是 self(仍然是 Book)的方法中

  • 2 所有关于书籍的引用都改为 self.

通常情况下,当两个不同的关注点之间存在紧密耦合时,这是一个问题。一些紧密耦合是高内聚但结构不佳的标志。

你可能见过或编写过类似列表 10.3 的代码。想象一下,你有一个搜索索引,用户可以向其中提交查询。搜索模块提供了清理这些查询的功能,以确保它们从索引中产生一致的结果。你编写了一个主程序,从用户那里获取查询,清理它,并打印清理后的版本。

列表 10.3. 与类细节紧密耦合的过程
import re

def remove_spaces(query):                                *1*
    query = query.strip()
    query = re.sub(r'\s+', ' ', query)
    return query

def normalize(query):                                    *2*
    query = query.casefold()
    return query

if __name__ == '__main__':
    search_query = input('Enter your search query: ')    *3*
    search_query = remove_spaces(search_query)           *4*
    search_query = normalize(search_query)
    print(f'Running a search for "{search_query}"')      *5*
  • 1 将 'George Washington' 转换为 'George Washington'

  • 2 将 'Universitätsstraße' (“大学街”)转换为 'universitätsstrasse'

  • 3 从用户那里获取查询

  • 4 移除空格并规范化大小写

  • 5 打印清理后的查询

主程序是否与搜索模块紧密耦合?

  1. 不是,因为它可以轻松地自己完成这项工作。

  2. 是的,因为它调用了搜索模块内部的一些函数。

  3. 是的,因为如果改变了清理查询的方式,它很可能会发生变化。

你可以通过评估任何给定模块的更改是否需要更改使用它的代码(选项 3)来有效地识别耦合。尽管主程序可能能完成清理函数的工作,但讨论代码中当前存在的耦合是很重要的。选项 1 是假设性的,并不能帮助你实现这一点。从一个模块中调用几个函数(选项 2)有时是耦合的迹象,但更重要的是,一个搜索模块的更改需要更改主程序的可能性。

假设你的用户报告说,他们对查询的微小变化仍然得到不一致的结果。你进行了一些调查,发现这是因为一些用户喜欢在查询周围加上引号,认为这会使它们更具体,但你的搜索索引将引号视为字面意思,只匹配包含引号的记录。你决定在运行查询之前删除引号。

按照目前的方式编写,这将涉及向搜索模块添加一个新函数,并更新所有清理查询的地方以确保它们调用新函数,如下面的列表所示。这些代码点都与搜索模块紧密耦合。

列表 10.4. 紧耦合导致变化向外扩散
def remove_quotes(query):                       *1*
    query = re.sub(r'"', '', query)
    return query

if __name__ == '__main__':
    ...
    search_query = remove_quotes(search_query)  *2*
    ...
  • 1 用于移除引号的新功能

  • 2 在你规范化查询的任何地方调用新函数

继续阅读以了解什么是松耦合以及它如何帮助你在这种情况中。

10.1.3. 松耦合

松耦合是指两段代码能够相互交互以完成任务,而不依赖于另一段代码的详细信息。这通常是通过使用共享抽象来实现的。你已经在前面的章节中学习了接口,并在 Bark 中使用共享抽象来实现命令模式。

松耦合的代码实现和使用接口;在极端情况下,它只使用接口进行交互。Python 的动态类型允许我们稍微放松这一点,但我真的想强调这一点。

如果你开始从对象之间发送的消息的角度来考虑你代码之间的交互,而不是专注于对象本身,你将开始识别更简洁的抽象和更强的内聚性。什么是消息?消息是你向对象提出的问题或你要告诉它做的事情。

重新审视以下列表中查询清理程序的主程序。你通过调用一个函数来获取新的查询,从而在查询上实现每个转换。这些都是你发送的消息。

列表 10.5. 从模块中调用函数
if __name__ == '__main__':
    search_query = input('Enter your search query: ')
    search_query = remove_spaces(search_query)           *1*
    search_query = remove_quotes(search_query)           *2*
    search_query = normalize(search_query)               *3*
    print(f'Running a search for "{search_query}"')
  • 1 告诉搜索模块去除空格

  • 2 告诉搜索模块去除引号

  • 3 告诉搜索模块规范化大小写

图 10.2. 将类之间的相互连接想象成它们发送和接收的消息

你所编写的代码完成了任务——清理查询——但你对这些消息的感觉如何?从搜索模块调用各种函数是否感觉像跳过很多圈?如果我看到这段代码,我可能会对自己说,“我只想得到清理后的查询。我不在乎它是如何做到的!”调用每个函数的过程是乏味的,尤其是在你需要在代码中清理查询时。

从你希望发送的消息的角度来考虑这个问题。一种更简洁的方法可能是发送一个单一的消息:“这是我的查询;请清理它。”你可能会采取什么方法来实现这一点?

  1. 将查询清理函数合并为一个单一函数,以去除空格和引号并规范化大小写。

  2. 将现有的函数调用包装到另一个你可以从任何地方调用的函数中。

  3. 使用类来封装查询清理逻辑。

这些方法中的任何一种都可能有效。因为关注点的分离通常是一个好主意,所以选项 1 可能不是最佳选择,因为它将多个关注点合并到一个函数中。将现有函数包装到另一个函数中(选项 2)可以保持关注点的分离,同时提供一个单一的清理行为入口点,这是好的。将这种逻辑进一步封装到类中(选项 3)可能在需要清理逻辑在步骤之间保持信息时更有意义。

尝试重构搜索模块,使每个转换函数私有,提供一个clean_query(query)函数,执行所有清理并返回清理后的查询。回来这里检查你的工作与以下列表进行对比。

列表 10.6. 简化共享接口
import re

def _remove_spaces(query):                             *1*
    query = query.strip()
    query = re.sub(r'\s+', ' ', query)
    return query

def _normalize(query):
    query = query.casefold()
    return query

def _remove_quotes(query):
    query = re.sub(r'"', '', query)
    return query

def clean_query(query):                                *2*
    query = _remove_spaces(query)
    query = _remove_quotes(query)
    query = _normalize(query)
    return query

if __name__ == '__main__':
    search_query = input('Enter your search query: ')
    search_query = clean_query(search_query)           *3*
    print(f'Running a search for "{search_query}"')
  • 1 转换被设为私有,因为它们是清理的底层细节。

  • 2 单个入口点接收原始查询,清理它,并返回它。

  • 3 消费者代码现在只需要调用一个函数,减少了耦合。

现在你想到另一种清理查询的技术时,你将能够做以下事情(如图 10.3 所示):

  1. 创建一个函数来对查询执行新的转换。

  2. clean_query内部调用新函数。

  3. 结束这一天,自信地认为所有消费者都在正确地清理查询。

你可以看到松散耦合、关注点分离和封装是如何共同工作的。通过精心设计的对外界接口的行为分离和封装,有助于实现你想要的松散耦合。

图 10.3. 使用封装和关注点分离来保持松散耦合

10.2. 识别耦合

你已经看到了紧密耦合和松散耦合的例子,但在实践中耦合可以采取几种特定的形式。给这些形式命名,并识别每种形式的迹象,将帮助你早期减轻紧密耦合,从长远来看使你更加高效。

10.2.1. 特征依赖

在你查询清理代码的早期版本中,消费者需要从搜索模块调用几个函数。当代码主要使用来自另一个区域的功能执行多个任务时,这种代码被称为特征依赖。你的主要程序感觉就像想要成为搜索模块,因为它明确地使用了它的所有功能。这在类中也很常见,如图 10.4 所示。

图 10.4. 一个类到另一个类的特征依赖

特征依赖可以通过与你修复查询清理逻辑相同的方式解决:将其卷起为源处的单个入口点。在前面的例子中,你在搜索模块中创建了一个clean_query函数。搜索模块是查询清理逻辑所在的地方,所以clean_query函数在那里非常合适。其他代码可以继续使用clean_query,无忧无虑地不知道下面发生了什么,并相信它会收到一个正确清理的查询。这段代码不再有特征依赖;它很高兴让搜索模块负责搜索相关的事情。

当你重构以消除特性嫉妒时,你会感觉好像在放弃一定程度的控制。在重构之前,你可以清楚地看到信息是如何在代码中流动的,但之后,这种流动通常被抽象层所隐藏。这需要你对交互的代码有一定的信任,相信它能够按照它所说的去做。偶尔你会感到不舒服,但一个全面的测试套件可以帮助你保持对功能的信心。

10.2.2. 散弹枪式手术

你在第七章中学习了“散弹枪式手术”,这通常是由于紧密耦合造成的。你对一个类或模块进行一次更改,就需要在代码的各个地方进行广泛的更改以保持其他代码的正常工作。每次需要更新行为时,都要在代码中穿插更改,这会让人感到非常繁琐!

通过解决特性嫉妒、分离关注点、实践良好的封装和抽象,你可以最大限度地减少需要进行的散弹枪式手术的数量。任何时候当你发现自己需要在不同的函数、方法或模块之间跳转以实现你想要做的更改时,问问自己你是否在这些代码区域之间经历了紧密耦合。然后看看有什么机会可以将一个方法移动到一个更适合的类,一个函数移动到一个更适合的模块,等等——物归其位,各得其所。

10.2.3. 泄露的抽象

正如你所学的,抽象的目标是隐藏特定任务的具体细节。消费者触发行为并接收结果,但并不关心底层发生了什么。如果你开始注意到特性嫉妒,那可能是因为存在一个泄露的抽象

泄露的抽象是指那些没有充分隐藏其细节的抽象。这种抽象声称提供了一种简单的方式来完成任务,但最终在使用时,你需要对底层有所了解。这有时会表现为特性嫉妒,但也可以很微妙,就像你马上就会看到的。

想象一个用于制作 HTTP 请求的 Python 包(比如requests)。如果你的目标仅仅是向某个 URL 发送一个GET请求并获取响应,那么对GET行为的抽象,如requests.get('https://www.google.com'),将是最适合你的。

这种抽象在大多数情况下都工作得很好,但当你失去互联网连接时会发生什么?当 Google 不可用时?当事情“只是有点奇怪”时,你的GET请求没有到达任何地方?在这些情况下,requests通常会引发一个表示问题的异常(图 10.5)。这对于错误处理很有用,但它要求调用代码了解一些可能的错误,以便知道哪些可能发生以及如何处理它们。一旦你开始在许多地方处理requests的错误,你就与之耦合了,因为你的代码期望一组特定的可能结果,这些结果特定于 requests 包。

图 10.5。抽象有时会泄露它们试图隐藏的细节。

图片

泄露发生是因为抽象需要权衡——一般来说,你在代码中越抽象一个概念,你能够提供的定制化就越少。这是因为抽象本质上是为了移除对细节的访问;你能访问的细节越少,你改变细节的方式就越少。然而,作为开发者,我们经常想要调整东西以更好地满足我们的需求,所以我们有时会提供对那些我们试图隐藏的细节的更低级访问。

当你发现自己从抽象层的高层提供对低级细节的访问时,你很可能会引入耦合。记住,松耦合依赖于接口——共享抽象,而不是具体的低级细节。继续阅读,看看你可以使用的一些具体策略来实现代码中的松耦合。

10.3. Bark 中的耦合

你可以随心所欲地分离关注点并封装行为,但那些关注点不可避免地需要相互交互。耦合是软件开发的一个必要部分,但它不必是紧密耦合。现在你已经熟悉了一些紧密耦合的迹象,是时候看看如何通过保持代码的正常运行来减少耦合的技术了。其中一些你可能已经熟悉,你将看到它们如何进一步应用于 Bark 应用程序。

记住你为 Bark 使用的多层架构,再次在图 10.6 中展示。每一层都有一个独特的关注点集合:

  • 表示层向用户展示信息,并从用户那里获取信息。

  • 业务逻辑层包含应用程序的“智慧”——与当前任务相关的逻辑。

  • 持久层存储应用程序的数据,以便稍后重用。

图 10.6。将关注点分离到多层架构中

图片

你使用命令模式将表示层连接到业务逻辑层。菜单中的每个选项都会通过该命令的execute方法触发业务逻辑中的相应命令。具有共享execute抽象的命令集是松耦合的一个很好的例子。

展示层对其连接的命令了解得非常少,而命令也不关心它们被触发的理由,只要它们收到预期的数据即可。这允许每一层独立地改变以适应新的需求。

现在思考一下业务逻辑层如何与持久化层交互。记得你创建的AddBookmarkCommand,如代码列表 10.7 所示。这个命令执行以下操作:

  1. 接收书签数据以及可选的时间戳

  2. 如果需要,生成时间戳

  3. 告诉持久化层存储书签

  4. 返回一条消息,表明添加成功

[代码列表 10.7]. 添加新书签的命令
class AddBookmarkCommand(Command):
    def execute(self, data, timestamp=None):                              *1*
        data['date_added'] = timestamp or datetime.utcnow().isoformat()   *2*
        db.add('bookmarks', data)                                         *3*
        return 'Bookmark added!'                                          *4*
  • 1 接收书签数据

  • 2 如果需要,生成时间戳

  • 3 持久化书签数据

  • 4 返回成功消息

如果我告诉你这里有一些紧密耦合呢?整个类只有五行长——你可能会问自己,“五行中能有多少耦合?”实际上,execute方法的最后两行显示了紧密耦合的迹象。

第一行有问题的代码,调用db.add,展示了与持久化层以及数据库本身的紧密耦合。换句话说,如果你将来决定想要将书签存储在数据库之外的地方——比如 JSON 文件,那么db.add就不再适用了。还存在一些特征嫉妒的问题;大多数命令直接使用DatabaseManager中的一个操作。

展示耦合的第二行是return语句。它的当前目的是什么?它返回一条消息,表明添加成功。这条消息是给谁的?用户。你在业务逻辑层处理了一块展示层的信息,这是一个抽象泄露的例子。展示层应该负责向用户展示什么。你编写的一些其他命令也有这种结构,你很快就会修复这个问题。

另一个命令,CreateBookmarksTableCommand,引入了更紧密的耦合。其名称中的Table暗示了数据库的存在,这是一个持久化层特性,然后当应用程序启动时,在展示层中引用了这个命令。这个命令跨越了你精心构建的所有抽象层!别担心,你很快就能清理这个问题。

继续阅读,了解这种耦合如何在现实生活中的情况中引起问题,以及你应该如何思考解决它。

10.4. 解决耦合问题

假设你现在被分配了将 Bark 移动化的任务。(也想象一下运行 Python 的手机!)你希望尽可能多地重用 Bark 的代码,以优化手机用户的体验,同时保持现有的命令行界面,如图 10.7 所示。

面对新的需求通常会导致代码中紧密耦合的区域暴露出来。新的用例要求你替换行为,不可避免地会揭示出你代码中缺乏灵活性的点。在 Bark 中你会找到什么?

图 10.7. 核心业务逻辑如何支持各种用例

10.4.1. 用户消息

由于移动应用倾向于关注视觉和触觉元素,你将希望除了消息外还使用图标来指示成功。刚才你看到 Bark 中的消息与业务逻辑层耦合。为了解决这个问题,你需要完全将消息的控制权释放到表示层。如何在不让每个命令明确知道它显示的消息的情况下保持命令与表示层之间的交互?

注意,某些命令的结果是成功消息,而其他命令的结果是某种类型的结果(例如,书签列表)。你可以在表示层通过拆分“成功”和“结果”的概念来处理这种情况,每个命令返回一个表示两者的状态和结果的元组。

你构建的命令都应该成功执行,所以目前每个命令的状态可以是True。最终,你可以让命令返回False,如果它们可能失败。目前返回结果的命令可以继续使用之前相同的结果,而没有结果的命令可以使用None

更新你的每个命令以返回一个status, result元组。你还需要更新表示层的Option类,以适应新的返回行为。哪种方法与迄今为止你构建的表示层相匹配?

  1. Option根据执行的命令打印不同的成功消息。

  2. 配置每个Option实例,使其在命令成功时使用特定的消息。

  3. 为你想要显示的每种消息类型对Option进行子类化。

选项 1 可能可行,但每个新的命令都会增加确定要显示哪个消息的条件逻辑。选项 3 也可能可行,但请记住,继承应该谨慎使用;不清楚是否有足够的专业行为来证明创建所有这些子类是合理的。选项 2 提供了恰到好处的定制,而不需要太多的额外努力。请记住,Bark 在重构消息时应该继续以相同的方式运行——你重构只是为了让自己开发更容易。

尝试一下,然后回到以下两个列表以获取帮助,或者查看本章的完整源代码(见github.com/daneah/practices-of-the-python-pro))。

列表 10.8. 使用接口解耦抽象层
class AddBookmarkCommand(Command):                                            *1*
    def execute(self, data, timestamp=None):
        data['date_added'] = timestamp or datetime.utcnow().isoformat()
        db.add('bookmarks', data)
        return True, None                                                     *2*

class ListBookmarksCommand(Command):                                          *3*
    def __init__(self, order_by='date_added'):
        self.order_by = order_by

    def execute(self, data=None):
        return True, db.select('bookmarks', order_by=self.order_by).fetchall()*4*
  • 1 AddBookmarkCommand 成功执行但未返回结果。

  • 2 返回值是一个 True 状态和一个 None 结果。

  • 3 ListBookmarksCommand成功并返回书签列表。

  • 4 返回值是 True 状态和书签列表。

列表 10.9。在表示层中使用状态和结果
def format_bookmark(bookmark):
    return '\t'.join(
        str(field) if field else ''
        for field in bookmark
    )

class Option:
    def __init__(self, name, command, prep_call=None,                         *1*
 success_message='{result}'):
        self.name = name
        self.command = command
        self.prep_call = prep_call
        self.success_message = success_message                                *2*

    def choose(self):
        data = self.prep_call() if self.prep_call else None
        success, result = self.command.execute(data)                          *3*

        formatted_result = ''

        if isinstance(result, list):                                          *4*
            for bookmark in result:
                formatted_result += '\n' + format_bookmark(bookmark)
        else:
            formatted_result = result

        if success:
            print(self.success_message.format(result=formatted_result))       *5*

    def __str__(self):
        return self.name

def loop():
    ...

    options = OrderedDict({
        'A': Option(
            'Add a bookmark',
            commands.AddBookmarkCommand(),
            prep_call=get_new_bookmark_data,
            success_message='Bookmark added!',                                *6*
        ),
        'B': Option(
            'List bookmarks by date',
            commands.ListBookmarksCommand(),                                  *7*
        ),
        'T': Option(
            'List bookmarks by title',
            commands.ListBookmarksCommand(order_by='title'),
        ),
        'E': Option(
            'Edit a bookmark',
            commands.EditBookmarkCommand(),
            prep_call=get_new_bookmark_info,
            success_message='Bookmark updated!'
        ),
        'D': Option(
            'Delete a bookmark',
            commands.DeleteBookmarkCommand(),
            prep_call=get_bookmark_id_for_deletion,
            success_message='Bookmark deleted!',
        ),
        'G': Option(
            'Import GitHub stars',
            commands.ImportGitHubStarsCommand(),
            prep_call=get_github_import_options,
            success_message='Imported {result} bookmarks from starred repos!',*8*
        ),
        'Q': Option(
            'Quit',
            commands.QuitCommand()
        ),
    })
  • 1 返回结果的命令的默认消息是结果本身。

  • 2 存储此选项配置的成功消息以供以后使用

  • 3 接收执行命令的状态和结果

  • 4 如果需要,格式化结果以供显示

  • 5 打印成功消息,如果需要则插入格式化的结果

  • 6 没有结果的选项可以指定一个静态的成功消息。

  • 7 应仅打印结果的选项不需要指定消息。

  • 8 具有结果和自定义信息的选项可以将两者合并。

恭喜!您已经解耦了业务逻辑和表示层。他们现在使用状态和结果的概念而不是特定的硬编码消息进行交互。在未来,当您为 Bark 构建新的移动前端时,它可以使用状态和结果来确定手机上显示的图标和消息。

10.4.2. 书签持久性

您的移动用户总是处于移动状态,因此您希望他们能够从任何地方访问他们的书签。数据库必须位于云中,通过 API 才能让他们在任何设备上查看书签。

正如您所看到的,您的命令代码中的一些区域是针对本地数据库操作的特定部分。您需要用一个新的与新的 API 交互的持久层替换数据库模块。到这一点,您应该记得共享抽象是减少耦合的好方法。尽管这可能听起来像是一项大任务,但思考本地数据库和 API 的相似之处和不同之处将帮助您概念化处理两者的抽象(图 10.8)。

尽管在细节上有所不同,但数据库和 API 持久层都需要处理一系列类似的问题。这正是抽象发挥作用的地方。正如您将每个命令简化为返回状态和结果的execute接口以将其从表示层解耦一样,您可以将持久层简化为更通用的 CRUD 操作以将其从命令解耦。然后,任何您想要构建的新持久层都可以使用相同的抽象。

图 10.8。数据库和 API 共享一些共同点。

10.4.3. 尝试一下

您已经拥有了将您的命令与DatabaseManager解耦所需的工具和知识。

使用抽象基类PersistenceLayer定义接口,创建一个位于您的命令和DatabaseManager类之间的BookmarkDatabase持久层,如图 10.9 所示。

图 10.9。使用接口和具体实现解耦命令与数据库特定性

在新的持久化模块中创建这些类;您将重构您的命令以使用此模块而不是直接使用 DatabaseManager。接口应提供适用于大多数持久化层的方法,而不是数据库或 API 特定的方法名称:

  • __init__ 用于初始配置

  • create(data) 用于创建新的书签

  • list(order_by) 用于列出所有书签

  • edit(bookmark_id, data) 用于更新书签

  • delete(bookmark_id) 用于删除书签

CreateBookmarksTableCommand 中的逻辑实际上是书签数据库持久化层的初始配置,因此您可以将其移动到 BookmarksDatabase.__init__DatabaseManager 的实例化也很好地适应了那里。然后您可以在 BookmarksDatabase 中编写 PersistenceLayer 抽象的每个方法的实现。您原始命令中的每个数据库中心方法调用(例如 db.add)都可以移动到适当的方法中,从而释放命令调用 BookmarksDatabase 的方法。尝试一下,在过程中参考以下列表和本章的完整源代码。

列表 10.10. 一个持久化接口及其实现
from abc import ABC, abstractmethod

from database import DatabaseManager

class PersistenceLayer(ABC):                                *1*
    @abstractmethod
    def create(self, data):                                 *2*
        raise NotImplementedError('Persistence layers must implement a
 create method')

    @abstractmethod
    def list(self, order_by=None):
        raise NotImplementedError('Persistence layers must implement a
 list method')

    @abstractmethod
    def edit(self, bookmark_id, bookmark_data):
        raise NotImplementedError('Persistence layers must implement an
 edit method')

    @abstractmethod
    def delete(self, bookmark_id):
        raise NotImplementedError('Persistence layers must implement a
 delete method')

class BookmarkDatabase(PersistenceLayer):                   *3*
    def __init__(self):
        self.table_name = 'bookmarks'                       *4*
        self.db = DatabaseManager('bookmarks.db')

        self.db.create_table(self.table_name, {
            'id': 'integer primary key autoincrement',
            'title': 'text not null',
            'url': 'text not null',
            'notes': 'text',
            'date_added': 'text not null',
        })

    def create(self, bookmark_data):                        *5*
        self.db.add(self.table_name, bookmark_data)

    def list(self, order_by=None):
        return self.db.select(self.table_name, order_by=order_by).fetchall()

    def edit(self, bookmark_id, bookmark_data):
        self.db.update(self.table_name, {'id': bookmark_id}, bookmark_data)

    def delete(self, bookmark_id):
        self.db.delete(self.table_name, {'id': bookmark_id})
  • 1 定义持久化层接口的抽象基类*

  • 2 每个方法对应持久化的 CRUD 操作。*

  • 3 使用数据库的特定持久化层实现。*

  • 4 使用 DatabaseManager 处理数据库创建。*

  • 5 接口每个行为的数据库特定实现。*

现在您已经有了持久化层的接口以及该接口的特定实现,该实现知道如何使用 DatabaseManager 来持久化书签,您就可以更新您的命令,使其依赖于 PersistenceLayer 接口而不是 DatabaseManager。在命令模块中,将 DatabaseManagerdb 实例替换为 persistence,即 BookmarkDatabase 的一个实例。然后遍历模块的其余部分,将 DatabaseManager 方法的调用(如 db.select)替换为 PersistenceLayer 的调用(如 persistence.list)。参考以下列表来检查您的作品。

列表 10.11. 更新业务逻辑以使用抽象
from persistence import BookmarkDatabase                          *1*

persistence = BookmarkDatabase()                                  *2*

class AddBookmarkCommand(Command):
    def execute(self, data, timestamp=None):
        data['date_added'] = timestamp or datetime.utcnow().isoformat()
        persistence.create(data)                                  *3*
        return True, None

class ListBookmarksCommand(Command):
    def __init__(self, order_by='date_added'):
        self.order_by = order_by

    def execute(self, data=None):
        return True, persistence.list(order_by=self.order_by)     *4*

class DeleteBookmarkCommand(Command):
    def execute(self, data):
        persistence.delete(data)                                  *5*
        return True, None
class EditBookmarkCommand(Command):
    def execute(self, data):
        persistence.edit(data['id'], data['update'])              *6*
        return True, None
  • 1 用 BookmarksDatabase 替代 DatabaseManager 的导入。*

  • 2 设置持久化层(将来可以替换)。*

  • 3 persistence.create 替代了 db.add。*

  • 4 persistence.list 替代了 db.select。*

  • 5 persistence.delete 替代了 db.delete。*

  • 6 persistence.edit 替代了 db.update。*

Bark 现在可以扩展到新的用例,如从 GitHub 导入星星。其关注点很好地分离,以便您可以独立地推理表示、业务逻辑和持久化。现在可以交换任何这些层以实现不同的新用例。

你可以将BookmarksDatabase替换为,比如说,一个BookmarksStorageService,它通过 HTTP API 将书签数据发送到云端。你也可以在测试时使用DummyBookmarksDatabase来替换,这样测试期间书签只保存在内存中。松耦合充满了机会!我强烈建议你自己探索其中的一些。

你应用到 Bark 的原则可以轻易地应用到许多现实世界的项目中。通过将在这里学到的知识应用到你的项目中,你将能够提高可维护性,并帮助他人理解你的代码。随着你继续构建软件,这种价值是无法用言语表达的。

在这本书的最后一部分,我们将回顾你所学到的一切,并探讨一些关于下一步探索的建议。那里见!

摘要

  • 将关注点分开,封装数据和行为,然后创建共享的抽象来松耦合。

  • 了解并使用另一个类许多细节的类可能需要被那个类所包含。

  • 通过更强的内聚性重新封装可以解决紧密耦合的问题,但引入一个双方共享的新抽象层通常也能很好地解决问题。(例如,一个菜单和一个命令可能依赖于命令返回状态和结果,而不是特定的消息。)

第四部分. 接下来是什么?

虽然教你们是一件很棒的事情,但作者在有限数量的页数中只能涵盖这么多内容。这本书的这一部分提供了一种跟踪你接下来想要学习的内容的策略。你还将对几个可能帮助你进一步发展编写一流软件道路的概念进行简要介绍。这些学习建议按主题组织,因此你可以从高层次了解每个主题,或者深入探索其中一个主题。

第十一章. 向上

本章涵盖

  • 选择在软件开发生涯中下一步要探索的道路

  • 制定持续学习的行动计划

信不信由你,你已经到达了这本书的最后一章。这不是很有趣吗?你在本书中学到了许多深思熟虑的软件设计的方面,但外面还有整个世界等待你去发现。弄清楚接下来会发生什么可能很困难。如果你不确定要探索哪些轨迹,请阅读本章以获取一些策略和主题想法。

11.1. 现在怎么办?

随着你获得经验,你将继续学到很多。你也会遇到一些你学习但还没有时间或经验去覆盖的事情。还会有一个始终存在且几乎无限的未知事物集合,你根本不知道它们。这些是那些还没有出现在你脑海中的概念,或者你还没有找到合适的词语来表达。

唐纳德·拉姆斯菲尔德简洁(且幽默)地这样说道:

已知已知——我们知道我们知道的事情。我们也知道有已知未知的事情——也就是说,我们知道有一些我们不知道的事情。但还有未知未知的事情——我们不知道我们不知道的事情。

唐纳德·拉姆斯菲尔德

成为一名有效的工程师很少仅仅意味着对某个主题有详尽的了解。更常见的是,通过知道该查找什么以及哪些资源可用,你可以有效地工作。简而言之,资源丰富性比经验更有价值。

随着你成长,你可能会积累一系列你感兴趣的博客文章、工具和主题。在构建软件的过程中,你也会出于必要性学习新事物。最终,当你决定是时候深入研究这些新主题时,制定一个学习计划可以帮助你成功。

11.1.1. 制定计划

你有没有掉进过维基百科的兔子洞?你开始阅读一个主题,突然已经是凌晨 2:37,你的浏览器里打开了 37 个标签页。你点击感兴趣的链接,有时会深入到某个路径的几层。虽然你可能觉得你浪费了整个晚上,但事实证明这是一种发现信息的有效策略。

哲学游戏

你也可以在维基百科中朝相反的方向——向上——探索。从几乎任何一篇文章开始,点击每篇文章第一段(偶尔是第二段)中的第一个链接,很可能会带你到“哲学”页面。这是因为第一个链接通常是最广泛或最一般的链接之一。试试看:

  • Beige > French > Romance language > Vulgar Latin > non-standard > language variety > sociolinguistics > society > group > social sciences > academic disciplines > knowledge > facts > reality > imaginary > object > philosophy

  • Python(编程语言)> 解释型 > 编程语言 > 形式语言 > 数学 > 数量 > 多样性 > 数 > 数学对象 > 抽象对象 > 哲学

一个思维导图以你可以视觉探索的层次结构组织信息。思维导图从一个中央的节点——你感兴趣学习的整体概念——开始。然后它分支出去,每个节点代表要探索的子主题或相关概念——就像你忍不住点击了关于“米色”页面上的“宇宙拿铁”链接一样。通过使用思维导图来列出你想要学习的东西,你可以构建出一个相当不错的不同领域需要覆盖的图景。

如果你想要了解自然语言处理,你可以绘制一个像图 11.1 中展示的思维导图。一些高级类别最终会分支到具体和复杂的话题,如词形还原和马尔可夫链。其中一些可能是你听说过但了解不多的东西,但你应该仍然把它们写下来。即使你不知道一个主题属于哪个分支,随着你对周围主题了解的深入,你最终会找到通往它的路径。

图 11.1. 学习自然语言处理的思维导图

图片

这种视觉表示有助于强调主题之间的关系,这可以帮助你保留你所学到的信息。它也像传统意义上的地图一样发挥作用;概念成为地图上的区域,你可以看到哪些区域已经绘制得很好,哪些区域尚未探索。当你致力于学习更多时,这会很有用。

如果你没有足够的经验来绘制一个完整的地图,不要担心。写下简短的事项清单仍然有效。关键是拥有一些你可以参考的东西,这会提醒你已经做了什么,以及还剩下什么。

一旦你规划好了下一步,你就可以开始学习了。

11.1.2. 执行计划

当你的学习主题已经规划好(或列出)后,你就可以开始探索可用的资源了。这些资源可能是书籍、在线课程,或者是在该主题方面有经验的某个朋友或同事。也要找出你的学习风格。有些人只需阅读就能学习,而其他人则需要编写一些真正的代码并看到一些真正的输出,才能让事情变得清晰。要富有创意。

思维导图可以很好地工作,因为你可以非线性地探索它。如果你还在熟悉术语和概念,你可能会首先探索中心外一层的主题,如图 11.2 所示。这可以帮助你了解整个情况,这将帮助你选择接下来要学习的内容时建立一些基础。在你有了方向之后,你可以选择一个你特别感兴趣的主题进行深入研究,如图 11.3 所示。关于你感兴趣的新信息的涌入是令人振奋的。

图 11.2. 首先探索主题的广度

图片

图 11.3. 深入探索单个主题

图片

小贴士

一个常见的陷阱是,在没有足够了解整个更大背景的情况下,就深入一个主题,所以请确保你保持平衡。过早地在一个地方投入过多可能会导致你形成不准确或不完整的理解,这可能会阻碍未来的学习。

成功的学习需要迭代的方法——随着你对一个主题经验的增加,你自然会找到更多可以添加到你的思维导图(或列表)中的内容。在过程中添加内容是完全可以的,但确保你在扩展到新的主题之前对已经学习的内容感到舒适。很容易分散精力!

通过跟踪你的进度来保持所有内容的有序。

11.1.3. 跟踪你的进度

学习是主观的,所以不要期望你能在大多数事情上都说自己“完成”了。在学习特定主题的过程中,有几个不同的状态:

  1. 想要或需要学习—它在你需要覆盖的主题列表中,但你还没有开始。

  2. 积极学习—你已经探索并阅读了一些关于这个主题的资源,并且正在寻找更多。

  3. 熟悉—你大致了解这个主题,并且有一些想法知道如何应用它。

  4. 舒适—你已经将这个主题的概念应用了几次,并且对其有了基本的掌握。

  5. 熟练—你已经应用了这些概念足够多,以至于了解了一些细微差别,并且知道在遇到新类型的问题时应该查阅哪些资源。

许多专业知识分类将这几种状态进一步细分,但每个层次都代表了你在行为上的可观察到的变化。了解你处于哪个层次,这样你可以更好地理解你想要投入时间的主题。你可能甚至不希望对那些你很少遇到或不与你想完成的任务相符的主题达到“熟练”水平。明确地写下这一点将帮助你保持计划更新,如图 11.4 所示。

图 11.4. 跟踪每个主题的学习进度

图片

你可能会在每个学习层面上对一个主题了解几个相关要点。它们可能不足以证明在思维导图中添加更多节点是合理的(尽管思维导图软件使这项活动成本很低),但写下它们是有帮助的。你可以使用这些笔记来衡量你在某个主题上的学习水平,它们可能会促使你重新审视需要更多工作的想法。

思维导图软件

思维导图软件帮助你创建你思想和它们之间关系的视觉表示。最简单的思维导图是由一些文本节点组成,通过线条连接。有几个商业工具,如 Lucidchart (www.lucidchart.com) 和 MindMup (www.mindmup.com),它们具有更高级的功能,但任何绘图软件,如 draw.io (draw.io),都可以提供你开始所需的东西。在你熟悉了映射之前,尝试一些简单且免费的工具。

我在学校和职业生涯中挣扎了很长时间,只有经过大量的重复才能记住信息。将事物映射出来并跟踪我的进步,证明是学习这本书中以及更多想法的有效辅助。如果你以前没有这样跟踪过你的学习,不妨试一试。

在心中有一个探索和学习新想法的框架后,继续阅读,了解在完成这本书后你可以去哪里。

11.2. 设计模式

在过去的几十年里,开发者多次解决了相同的问题。在所有这些解决方案中,某些模式已经出现。其中一些模式提供了松散耦合和可扩展性,但其他则没有。

这些软件设计模式是经过验证的解决方案,给它们命名使我们能够更具体地讨论它们。一种通用语言,即团队需要理解的共享词汇,对于实现团队寻求的结果大有裨益。

当你为 Bark 创建命令时,你使用了设计模式。正如其名,命令模式在 Bark 这样的应用程序中经常被用来解耦请求动作的代码和动作本身。命令模式在使用的任何情况下都有一些共同的部分:

  1. 接收者—执行动作的实体,如将数据持久化到数据库或进行 API 调用

  2. 命令—包含接收者执行动作所需信息的实体

  3. 调用者—触发命令以通知接收者的实体

  4. 客户端—组装调用者、命令和接收者以完成任务

在 Bark 中,这些部分如下:

  1. 持久层 PersistenceLayer 类是接收者。 它们接收足够的信息来存储或检索数据(例如,在BookmarkDatabase的情况下)。

  2. Command 类是命令。它们存储与持久化层通信所需的信息。

  3. Option 实例是调用者。当用户在菜单中选择一个选项时,它们会触发一个命令的执行。

  4. 客户端模块是客户端。 它正确地将选项与命令连接起来,以便用户的菜单选择最终导致预期的操作。

这些类的统一建模语言(UML)图示在图 11.5.^([1])中展示。UML 图是表示程序中实体之间关系的一种常见方式。本书有意简化了 UML,因为它可能会增加未经训练的眼睛的学习曲线。然而,当你学习设计模式时,你会经常看到 UML 图的出现。记住,模式本身是理解的重点——如果 UML 图对你不起作用,就阅读有关它们的内容。

¹

更多关于 UML 的信息,请参阅维基百科的“统一建模语言”文章:en.wikipedia.org/wiki/Unified_Modeling_Language

图 11.5. 在 Bark 应用程序中使用命令模式

11.2.1. Python 中设计模式的起起伏伏

你已经看到了在 Python 中使用特定设计模式的一些好处。命令模式帮助你在 Bark 中解耦抽象层,从而实现灵活的持久化、业务逻辑和展示。你将学习的许多其他模式也可能提供价值。

要了解在 Python 中应该学习并应用哪些设计模式,重要的是要理解许多设计模式是在什么背景下被开发和使用。某些设计模式的一个显著驱动因素是它们所诞生的语言或语言。许多设计模式来自 Java,一种静态类型语言。由于静态类型,像 Java 这样的语言在创建类实例等方面有意进行了限制。因此,许多设计模式是创建型的。Python 的动态类型使其摆脱了许多这些限制,所以许多创建型模式在 Python 中根本不是必要的。

最终,就像本书中的许多主题一样,设计模式是帮助你完成工作的工具。如果你试图使用设计模式来解决问题,但感觉被迫,那么在没有特定模式的情况下继续前进是可以的。在此期间,可能会有更好的模式出现在你面前。

学习更多关于设计模式的权威参考是《设计模式:可复用面向对象软件元素》。^([2]) 线上软件开发社区也有许多关于这个主题的讨论,通常包括有用的案例研究,可以帮助你进一步了解何时以及如何使用特定的模式。

²

艾瑞克·伽玛、理查德·赫尔姆、拉尔夫·约翰逊和约翰·弗利斯,设计模式:可重用面向对象软件的元素(Addison-Wesley Professional,1995 年)。

11.2.2. 从哪些术语开始

你可以从以下术语开始你的设计模式研究:

  • 设计模式

    • 创建型设计模式

    • 工厂

    • 行为设计模式

    • 命令模式

    • 结构设计模式

    • 适配器模式

11.3. 分布式系统

在现代 Web 应用程序开发中,你可能需要一个处理 HTTP 流量的服务器,一个用于持久化数据的数据库,一个用于存储频繁访问数据的缓存,等等。这些元素形成一个系统——一个由相互连接的部件组成的整体。这个系统的部件通常位于不同的机器上,在不同的数据中心,有时甚至在不同的洲,如图 11.6 所示。这些分布式系统为开发者增加了理解和管理风险的价值、复杂性和风险。

图 11.6. 分布在多个位置的系统

分布式系统表现出的某些更有趣的复杂性是它们失败的方式。

11.3.1. 分布式系统中的故障模式

即使在单台机器上,程序也可能意外崩溃。其他期望该程序正在运行的程序也可能崩溃,如果它们没有考虑到这种情况。

将应用程序的某些部分剥离以放置在新位置引入了新的、异质的故障模式。所有应用程序可能都在正常运行,但它们之间的网络连接可能失败。大多数应用程序可能能够访问数据库,但一个可能不行。分布式系统技术旨在抵御和从这些故障模式中恢复。

我发现,思考分布式系统可能失败的方式与思考功能测试的方式相似。在第五章中,你学习了创造性探索性测试作为一种方法,以尽可能多地列举出漏洞的各个方面。由于有更多的移动部件,分布式系统需要这种相同的心态在更大的规模上。

11.3.2. 解决应用程序状态

分布式系统中的一个重大问题是如何处理系统的一部分崩溃。你可能能够没有系统的一些部分继续运行,而不需要它们提供的数据。系统的其他部分可能是必要的,但不是时间敏感的,因此在它们宕机时对它们的请求可以存储并延迟,直到它们恢复。系统剩余的部分对于操作至关重要——没有它们,系统就会停止。这些都是单点故障

分布式系统旨在最小化单点故障,倾向于优雅降级——在没有特定动作或信息的情况下继续运行。像 Kubernetes (kubernetes.io/) 这样的工具通过最终一致性增强了处理故障的方法,这使您能够定义您希望系统达到的状态,并保证系统最终会达到定义的状态。将优雅降级与最终一致性相结合,导致系统更少地出现故障。

尽管分布式系统并不新鲜,但在工具和哲学方面已经有了许多最近的发展。Kubernetes 及其生态系统当然可以应用于你学习时的小系统,但在更大、更复杂的系统中它更加出色。你可能想从原则和技术开始,然后在转向专业工具之前,先练习构建几个分布式系统。

11.3.3. 开始的术语

你可以从以下术语开始研究分布式系统:

  • 分布式系统

    • 容错性

    • 最终一致性

    • 所需状态

    • 并发

    • 消息队列

11.4. 深入 Python

这可能看起来很明显,但你可以继续在 Python 方面不断成长。尽管这本书在示例中使用了 Python 来传达关于软件设计的思想,但关于 Python 语言的功能、语法和强大之处还有很多东西可以学习。

11.4.1. Python 代码风格

随着你越来越多地使用 Python,你最终会对你喜欢的代码格式有一个感觉。你会用那种风格编写代码,因为这样你以后阅读起来会更轻松。但是当其他人按照他们自己的风格阅读你的代码时,他们可能很难理解它。Python 风格指南的 Python 增强提案(PEP 8)建议一种标准的代码格式,这样你就不必花费时间在它上面纠结了.^([3]) 像 Black (github.com/psf/black) 这样的工具将这些建议更进一步,对所有代码强制执行一种确定性和有偏见的格式。这让你有更多时间去思考更大的问题,比如软件的更大设计和你试图解决的问题的业务需求。

³

你可以在 Python 网站上找到“PEP 8—Python 代码风格指南”:www.python.org/dev/peps/pep-0008/

11.4.2. 语言特性是模式

设计模式传统上是用对象及其之间的交互来讨论的。但在 Python 语法中表达某些想法的方式中也有常见的模式。在 Python 中经常以某种方式执行的事情,因为它们优雅、简短、清晰或可读,被称为“Pythonic”。这些模式对于试图理解你的代码的人来说,可能和设计模式一样重要。

Python 中的一些模式涉及使用与情况相关的数据类型,例如使用dict来映射键到值。一些模式涉及使用列表推导式或三元运算符来减少多行语句,当某件事简短且清晰到足以这样做时。了解你可以使用什么以及何时使用每个模式很重要。了解何时不使用它们也同样重要。

Python 的禅宗提供了编写 Python 代码的良好一般原则。

>>> import this
The Zen of Python, by Tim Peters

Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren’t special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one--and preferably only one--obvious way to do it.
Although that way may not be obvious at first unless you’re Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it’s a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea--let’s do more of those!

如果你将这些指南视为一个轻量级的评分标准,你可以对那些让你感到不适或感觉奇怪的代码区域进行批判性的审视。看到一些丑陋的语法,理解它试图做什么,并在网上搜索“在 Python 中做 X 的最佳方式”可以产生一些替代想法。我用来学习技巧和窍门的一种策略是关注 Twitter 上著名的 Python 用户——例如 Python 核心开发者。你通常可以通过这种方式找到你不知道你需要的信息。

对于语言的全面指南,像 Daryl Harms 和 Kenneth McDonald(Manning, 1999)所著的《Quick Python Book》;Kenneth Reitz 和 Tanya Schlusser(O’Reilly, 2016)所著的《The Hitchhiker’s Guide to Python》;以及 David Ascher 和 Alex Martelli(O’Reilly, 2002)所著的《Python Cookbook》这样的书籍,可以帮助你沉浸到这门语言中。

11.4.3. 以何为起点

在开始研究 Python 代码的示例、模式和指南时,你可以从以下术语开始:

  • Pythonic 代码

    • 以 Pythonic 方式做 X
  • Python 惯用法

  • Python 反模式

  • Python 代码检查器

11.5. 你所走过的路

作为作者,我无法预测是什么促使你拿起这本书,或者你在拿起它时有多少经验。尽管如此,如果你还在阅读,我对你现在所处的位置有一个更清晰的了解。你是自己最严厉的批评者,所以在结尾处,回顾你所学的一切非常重要。当你看到所有的一切都展现在你面前时,请记住以下几点:

  • 软件开发不是单一的事情,而是一系列最终汇聚成软件的实践。

  • 平衡所有这些实践将是一个持续性的挑战,一些实践在你专注于提高其他实践时会有所起伏。

  • 这一切都不是精确的科学。对于声称某件事是“唯一正确的方式”的声明,要持怀疑态度。

  • 你可以将这本书中学到的原则应用到大多数任何语言、框架或问题上。Python 很棒,但不要把自己局限在某个框架内。

11.5.1. 来来回回:一个开发者的故事

你在第一章中直接进入了软件设计的理念。理解软件可以是一个有意图、深思熟虑的过程,为所有后续章节奠定了基础。你可能会因为截止日期等原因而难以找到时间进行前期设计,但尽可能经常找到你的中心,这样你就可以有意识地构建软件。结果仍然是最重要的目标,但设计将帮助你尽可能顺利地实现结果。

第二章介绍了分离关注点的基本实践。大多数现代编程语言都鼓励使用函数、方法、类和模块,这是有充分理由的。将你的软件分解为其组成部分有助于减少认知负荷,同时提高代码的可维护性。关注点可以在代码的最底层分离,一直到软件的更广泛架构。

建立在 Python 关注点分离结构的基础上,你在第三章中学习了如何使用它们进行抽象和封装。除非他们有兴趣了解更多,否则让自己和其他开发者从特定任务的细节中解放出来,这是一种受欢迎的缓解。仅向软件的其他区域暴露关键细节,还可以减少集成点以及破坏消费者代码的可能性。

在更具体的领域,你在第四章中学习了如何设计性能。你看到了 Python 提供的一些数据结构以及它们在什么情况下是有用的。你还了解了一些用于定量测量软件性能的工具。衡量指标胜过对什么最快的主观猜测。

在第四章中,展示了如何测试程序是否高效,而第五章则专注于测试程序是否正确。功能测试帮助你验证你正在构建的就是你想要构建的内容。你学习了功能测试的结构以及如何使用 Python 工具编写测试。功能测试模式在语言和框架之间相当相似,所以你可以将这个信息带到任何地方。

带着一些设计的基础知识,本书的下一部分通过构建 Bark 应用程序带你进行了一次实际的旅程。在这个过程中,你达到了许多里程碑:

  • 你构建了一个多层架构来支持独立的表示层、业务逻辑层和持久化层。

  • 你使 Bark 易于扩展,以便更容易地添加新功能,然后添加了一个新功能,可以从 GitHub 导入星标作为书签。

  • 你使用了接口和命令模式来进一步减少添加或更改功能所需的工作。

  • 你松动了 Bark 不同区域之间的耦合,为新的可能性打开了大门,比如制作一个移动或 Web 应用。

一个书签工具可能不够炫酷,但在构建它的过程中你学到了一些炫酷的技术。将你获得的知识体系应用到未来的实际项目中,对于真正任务来说,一定会带来同样有效的结果。你还可以通过将新概念应用到 Bark 上来练习。你可以选择添加功能,改进现有代码,或者为其编写测试。天空才是极限!

11.5.2. 签署

你已经从这本书中学到了很多。我很高兴能教你,我希望在你在软件领域继续前进到更大的更好事物时,能听到你旅程的故事。庆祝你的胜利,从障碍中学习,用心发展。

开心编码!

摘要

  • 学习不是一个被动的过程。制定一个适合你的计划,写下它或绘制出来,并跟踪你的进度。这可以产生更多想法或下一步行动,帮助你保持动力和好奇心。

  • 尝试识别常见的问题模式和解决方案。当你遇到相同的问题时,尽早尝试几种不同的方法,看看哪种方法最顺畅。模式是工具,它们应该增强你的工作,而不是阻碍它。

  • 在你的语言中感到自在。你不需要一次性掌握所有内容,但保持好奇心,经常询问是否有更地道的代码表达方式。

  • 你从这本书的开头已经走了很长的路,所以利用这段时间来反思和休息。

附录 安装 Python

本附录涵盖

  • 哪些版本的 Python 可用,以及应该使用哪个

  • 在你的电脑上安装 Python

Python 是一种相当便携的软件,可以在大多数系统上从源代码编译。幸运的是,Python 也可能已经为你的系统预编译。本附录将帮助你设置 Python,以便你可以从命令行运行本书中的任何代码。

小贴士

如果你已经在你的电脑上安装了 Python 3 的某个版本,那么你很幸运。这里没有太多需要做的事情。请随意继续阅读,并跟随本书中的代码进行操作。

注意

如果你安装了 Python 3 的某个版本,你几乎肯定需要在运行代码时使用 python3 命令。python 在许多操作系统中被保留用于不同的 Python 安装(见 章节 A.2)。

A.1 我应该使用哪个版本的 Python?

Python 2.7 的第一个版本于 2010 年发布,在撰写本文时,macOS 随带 Python 2.7.10,这比最新的 Python 2.7 版本落后几个版本。Python 2.7 将从 2020 年 1 月 1 日起不再官方支持。

如果你已经熟悉 Python 2 并且担心升级到 Python 3,知道你将需要做的大多数代码更改都很小。当你开始新的项目时,我建议你使用 Python 3。这将使你能够编写可以持续更长时间的代码。

小贴士

有工具可以帮助你进行 Python 2 到 Python 3 的升级。Python 提供了 __future__ 模块,它允许你使用已经回滚到 Python 2 的新 Python 3 功能。这样,当你升级时,你的语法已经正确,你只需移除 future 导入即可。Six 包(两乘以三,明白了吗?)(six.readthedocs.io/) 也帮助在这两个版本之间过渡。

A.2 “系统”Python

在许多操作系统中,Python 可能已经安装,因为系统需要它来完成一些自己的任务。这个 Python 安装通常被称为“系统”Python。例如,在 macOS 上,Python 2.7 已经安装并可供使用。

当你需要安装包时,使用系统 Python 会变得复杂,因为它们都将安装在这个全局 Python 版本下。如果你安装了一个覆盖操作系统所需内容的包,或者你有多个项目需要不同版本的包,可能会开始出现一些问题。我强烈建议避免使用系统 Python。

A.3 安装其他版本的 Python

如果你之前没有安装过自己的 Python 版本,有几个选项可供选择。每个选项在功能上应该是等效的,所以你选择哪个应该取决于哪个最适合你的工作流程或对你来说最有意义。

唯一重要的是确保您有一个相对较新的 Python 版本。我推荐 Python 3.6+,因为它与大多数库有很好的兼容性,但截至本文撰写时,Python 3.8 已经可用。如果您没有特定的要求,目标安装最新版本。

A.3.1 下载官方 Python

您可以直接从 Python 的官方网站(www.python.org/downloads)下载 Python。网站应该能够检测到您的操作系统类型,并显示一个大的“下载 Python”按钮,如图 A.1 所示。如果它无法检测到您的操作系统,或者检测错误,下面有各种操作系统的直接链接。

这个下载应该像您在系统上安装的大多数其他应用程序一样工作。在 macOS 上,打开下载的文件将带您通过安装向导,如图 A.2 所示。figure A.2。在向导中选择哪些选项取决于您,但通常默认选项是合理的。

图 A.1。大黄色按钮会带您获得最新的 Python,您可以在其下方的链接中找到旧版本或其他操作系统的版本。

图片

图 A.2。我通常只是随意点击继续。

图片

A.3.2 使用 Anaconda 下载

如果你身处科学计算社区,你可能对 Anaconda(www.anaconda.com)很熟悉。Anaconda 是一个包含 Python 在内的工具套件。截至本文撰写时,Anaconda 可以使用 Python 2 或 Python 3 进行安装。检查您拥有哪个版本,并确保您已经安装了 Python 3 版本。

使用 Anaconda 的conda命令,你可以通过conda install python=3.7.3这样的命令安装大多数版本的 Python,例如。请阅读官方文档以了解您系统的安装过程。

A.4 验证安装

一旦您完成了安装过程,打开一个终端(macOS 上的终端应用程序)。从任何地方,尝试运行python命令(或python3,可能)。如果 Python 安装成功,您应该会看到一个 Python REPL 提示符,其中应该有Python 3字样:

$ python3
Python 3.7.3 (default, Jun 17 2019, 14:09:05)
[Clang 10.0.1 (clang-1001.0.46.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>

尝试输入您喜欢的代码片段,看看会发生什么:

>>> print('Hello, world!')
Hello, world!

现在您已经准备好统治世界了!

图片

图片

posted @ 2025-11-17 09:53  绝不原创的飞龙  阅读(10)  评论(0)    收藏  举报