Python 真实世界的数据科学(一)
零、课程路线图和时间表
在开始之前,这是整个课程计划的视图。 该网格为您提供了整个课程及其模块的主题概述,因此您可以看到我们将如何在学习使用 Python 的特定阶段过渡,您将在学习过程中学习哪些技能以及如何使用这些技能 每一点的技能。 我还为您提供了每个模块可能需要的时间估计,尽管这在很大程度上取决于您的学习方式,您每周能提供多少课程!
一、简介和第一步——深呼吸
“你交给某人一个程序,你将折磨他一整天;你教某人如何编写程序,你将折磨他一辈子。”
——程序员谚语
根据维基百科,计算机编程 为:
“ ...从计算问题的原始表述到可执行计算机程序的过程。编程涉及诸如分析,发展理解,生成算法,验证算法要求(包括其正确性和资源消耗)的活动,以及 目标编程语言实现算法(通常称为编码)”。
简而言之,编码就是告诉计算机使用它理解的语言来执行某项操作。
计算机是非常强大的工具,但不幸的是,它们无法自己思考。 因此,他们需要被告知一切。 他们需要被告知如何执行任务,如何评估条件以决定要遵循的路径,如何处理来自网络或磁盘等设备的数据,以及在发生意外情况时如何应对。 ,有东西损坏或丢失。
您可以使用许多不同的样式和语言进行编码。 难吗? 我会说“是”和“否”。 有点像写作。 每个人都可以学习如何写作,您也可以。 但是,如果您想成为一名诗人怎么办? 仅仅写作是不够的。 您必须掌握其他全部技能,这将需要更长的时间和更大的努力。
最后,一切都取决于您要走多远。 编码不仅仅是将一些有效的指令组合在一起。 不仅如此!
好的代码简短,快速,优美,易于阅读和理解,简单,易于修改和扩展,易于缩放和重构以及易于测试。 能够同时编写具有所有这些特性的代码需要花费一些时间,但是好消息是,您现在正在阅读此模块,迈出了第一步。 我毫不怀疑您可以做到。 实际上,任何人都可以一直编程,只是我们不知道它。
您想举个例子吗?
假设您要煮速溶咖啡。 您必须得到一个杯子,速溶咖啡罐,茶匙,水和水壶。 即使您不知道它,您也在评估大量数据。 您要确保水壶中有水,并且水壶已插入电源,杯子是干净的,并且罐中有足够的咖啡。 然后,您将水烧开,同时在杯子中倒入咖啡。 准备好水后,将其倒入杯子中并搅拌。
那么,如何编程?
好吧,我们收集了资源(水壶,咖啡,水,茶匙和杯子),并验证了其中的一些条件(水壶已插入,杯子很干净,有足够的咖啡)。 然后,我们开始执行两个操作(将水煮沸并将咖啡倒入杯子中),当两个动作都完成时,我们最终通过将水倒入杯子中并搅拌来结束该过程。
你能看见它吗? 我刚刚描述了咖啡程序的高级功能。 这并不难,因为这是大脑整天要做的事情:评估条件,决定采取行动,执行任务,重复其中的某些任务并在某个时刻停止。 清洁物体,放回它们,依此类推。
您现在所要做的就是学习如何解构在现实生活中自动执行的所有这些操作,以便计算机可以真正理解它们。 而且您还需要学习一种语言来进行指导。
这就是本模块的用途。 我将告诉您如何执行此操作,并通过许多简单但重点突出的示例(我最喜欢的示例)来尝试执行此操作。
适当的介绍
当我教编码时,我喜欢参考现实世界。 我相信它们可以帮助人们更好地保留这些概念。 但是,现在该变得更加严格了,从更技术的角度来看什么是编码。
编写代码时,我们在指导计算机执行什么操作。 动作在哪里发生? 在许多地方:计算机内存,硬盘驱动器,网络电缆,CPU 等。 它是一个完整的“世界”,大部分时间是真实世界的子集的表示。
如果编写允许人们在线购买衣服的软件,则必须在程序的范围内代表真实的人,真实的衣服,真实的品牌,尺寸等等。
为此,您将需要在正在编写的程序中创建和处理对象。 一个人可以是一个对象。 汽车是物体。 一双袜子是一个对象。 幸运的是,Python 非常了解对象。
任何对象具有的两个主要功能是属性和方法。 让我们以一个人物对象为例。 通常,在计算机程序中,您会将人们表示为客户或雇员。 您针对他们存储的属性包括名称,SSN,年龄,是否具有驾驶执照,电子邮件,性别等。 在计算机程序中,您存储了为了将对象用于服务目的所需的所有数据。 如果您正在编写一个网站来销售服装,则可能要存储客户的身高和体重以及其他度量,以便为他们建议合适的服装。 因此,属性是对象的特征。 我们一直在使用它们:“你能把那支笔递给我吗?” - “哪一个?” - “黑色的那个。” 在这里,我们使用笔的“黑色”属性来识别它(最有可能在蓝色和红色之间)。
方法是对象可以做的事情。 作为一个人,我有说话,走路,睡眠,唤醒,吃等方法 ,梦,写,读等。 我可以做的所有事情都可以看作代表我的对象的方法。
因此,既然您知道对象是什么,并且它们公开了可以运行的方法以及可以检查的属性,那么您就可以开始编码了。 实际上,编码只是关于管理生活在我们在软件中复制的世界子集中的那些对象。 您可以根据需要创建,使用,重用和删除对象。
根据官方 Python 文档中的数据模型一章:
“对象是 Python 的数据抽象。Python 程序中的所有数据都由对象或对象之间的关系表示。”
在下一章中,我们将仔细研究 Python 对象。 现在,我们只需要知道 Python 中的每个对象都有一个 ID(或标识),一个类型和一个值即可。
创建后,就永远不会更改对象的身份。 它是它的唯一标识符,当我们要使用它时,Python 在后台使用它来检索对象。
类型也不会改变。 类型告诉对象支持哪些操作以及可以分配给它的可能值。
该值可以更改,也可以不更改。 如果可以,则称该对象是可变的,而如果不能,则称该对象是不可变的。
我们如何使用对象? 我们给它起个名字! 给对象命名时,可以使用该名称检索对象并使用它。
从更一般的意义上讲,对象(例如数字,字符串(文本),集合等)与名称相关联。 通常,我们说这个名称是变量的名称。 您可以看到变量就像一个盒子,可以用来保存数据。
因此,您拥有了所需的所有对象:现在呢? 好吧,我们需要使用它们,对吗? 我们可能希望通过网络连接发送它们或将它们存储在数据库中。 也许将它们显示在网页上或将其写入文件。 为此,我们需要对用户填写表格,按下按钮或打开网页并执行搜索做出反应。 我们通过运行代码,评估条件来选择执行哪些部分,执行多少次以及在哪种情况下做出反应。
为此,基本上我们需要一种语言。 这就是 Python 的目的。 Python 是我们将在整个模块中一起使用的语言,指示计算机为我们做一些事情。
现在,足够多的理论知识,让我们开始吧。
输入 Python
Python 是荷兰计算机科学家和数学家 Guido Van Rossum 的奇妙生物,他决定通过 1989 年圣诞节期间向他赠送的一个项目向世界赠送礼物。该语言于 1991 年左右在公众面前露面。 现已发展成为当今世界上使用的领先编程语言之一。
我 7 岁那年开始在 Commodore VIC 20 上编程,后来被其较大的兄弟 Commodore 64 取代。该语言是 BASIC。 后来,我登陆了 Pascal,Assembly,C,C ++,Java,JavaScript,Visual Basic,PHP,ASP,ASP .NET,C#和我什至不记得的其他次要语言,但是只有当我登陆 Python 时,我终于 当您在商店中找到合适的沙发时,就会有那种感觉。 当您所有的身体部位都大喊大叫时,“买这个!这个对我们来说很完美!”
我花了一天的时间来适应它。 它的语法与我以前使用的语法略有不同,通常,我很少使用定义缩进范围的语言。 但是在摆脱了最初的不适感(例如穿新鞋)后,我爱上了它。 深。 让我们看看为什么。
关于 Python
在深入探讨的细节之前,让我们先了解一下为什么有人想要使用 Python(我建议您阅读 Wikipedia 上的 Python 页面以获得更详细的介绍)。
在我看来,Python 具有以下特质。
便携性
Python 在任何地方都可以运行,将程序从 Linux 移植到 Windows 或 Mac 通常只是固定路径和设置的问题。 Python 专为可移植性而设计,它可以处理接口后面的操作系统(OS)特定的怪癖,从而使您免于必须编写针对特定平台的代码的痛苦 。
连贯性
Python 非常逻辑一致。 您可以看到它是由一位杰出的计算机科学家设计的。 大多数时候,如果您不知道该方法,则只能猜测该方法的调用方式。
您可能没有意识到这是多么重要,特别是如果您刚开始的时候,但这是一个主要功能。 这意味着更少的头脑混乱,更少的文档浏览,以及更少的代码编写需求。
开发人员的生产力
根据 Mark Lutz(学习 Python,第 5 版,O'Reilly Media ),Python 程序的大小通常是等效 Java 程序的五分之一到三分之一,或者 C ++ 代码。 这意味着工作可以更快地完成。 更快是好的。 更快意味着对市场的反应更快。 更少的代码不仅意味着更少的代码编写,还意味着更少的代码阅读(专业编码人员阅读的内容远远超过他们编写的内容),更少的代码需要维护,调试和重构。
另一个重要的方面是,Python 无需冗长且费时的编译和链接步骤即可运行,因此您不必等待查看工作结果。
内容丰富的图书馆
Python 具有令人难以置信的标准库(据说它带有“包括电池”)。 如果这还不够的话,全世界的 Python 社区都会维护大量针对特定需求的第三方库,您可以在 Python 软件包索引(PyPI)。 当您编写 Python 代码并意识到自己需要某种功能时,在大多数情况下,至少有一个已经为您实现了该功能的库。
软件质量
Python 非常注重的可读性,一致性和质量。 语言的统一性使代码具有较高的可读性,这对于当今的代码至关重要,因为代码更多的是集体努力而不是个人经验。 Python 的另一个重要方面是其固有的多范式性质。 您可以将其用作脚本语言,但也可以利用面向对象,命令式和函数式编程样式。 它是多功能的。
软件集成
的另一个重要方面是 Python 可以与许多其他语言进行扩展和集成,这意味着,即使一家公司使用其他语言作为其主流工具,Python 仍可以在其中充当胶粘剂。 需要以某种方式互相交谈的复杂应用。 这是一个高级主题,但是在现实世界中,此功能非常重要。
满意和享受
最后但并非最不重要的是,它的乐趣! 使用 Python 很有趣。 我可以编写 8 个小时的代码,让办公室感到满意和满意,这与其他编码人员必须忍受的苦难相提并论,因为他们使用的语言无法为他们提供相同数量的精心设计的数据结构和构造。 毫无疑问,Python 使编码变得有趣。 乐趣可以提高动力和生产力。
这些就是为什么我会向所有人推荐 Python 的主要方面。 当然,我可以讨论许多其他技术和高级功能,但它们实际上与这样的介绍性部分无关。 在本模块中,它们会自然地出现。
有哪些缺点?
可能不是由于个人喜好而在 Python 中发现的唯一缺点是执行速度。 通常,Python 比其编译的兄弟慢。 运行应用时,Python 的标准实现产生称为字节码(扩展名为.pyc)的源代码的编译版本,然后由 Python 解释器运行。 这种方法的优点是可移植性,由于 Python 没有像其他语言一样被编译到机器级别,因此我们为此付出了缓慢的代价。
但是,Python 速度在今天几乎不再是问题,因此,不管这种次优功能如何,它的广泛使用。 发生的事情是,在现实生活中,硬件成本不再是问题,而且通常很容易通过并行化任务来提高速度。 不过,在进行数字运算时,可以切换到更快的 Python 实现,例如 PyPy,它可以通过实施高级编译技术将的平均速度提高 7 倍(请查看这个页面供参考)。
在进行数据科学时,您很可能会发现,与 Python 一起使用的库(例如 Pandas 和 Numpy)由于实现方式而达到了本机速度。
如果这还不够好,那么您始终可以认为 Python 正在驱动着性能关注的 Spotify 和 Instagram 等服务的后端。 但是,Python 可以完美地完成其工作。
今天谁在使用 Python?
还没说服? 让我们以来简要介绍一下今天使用 Python 的公司:Google,YouTube,Dropbox,Yahoo,Zope Corporation,Industrial Light & Magic,Walt Disney Feature Animation,Pixar,NASA,NSA,Red Hat, 诺基亚,IBM,Netflix,Yelp,英特尔,思科,惠普,高通和摩根大通(JPMorgan Chase)仅举几例。
甚至*《战地 2》,,《文明 4》 和 QuArK 之类的游戏也都使用 Python 实现。*
Python 用于许多不同的环境中,例如系统编程,Web 编程,GUI 应用,游戏和机器人技术,快速原型设计,系统集成,数据科学,数据库应用等等。
设置环境
在讨论在您的系统上安装 Python 之前,让我告诉您我将在此模块中使用哪个 Python 版本。
Python 2 vs Python 3 –精彩的辩论
Python 有两个主要的版本-过去的 Python 2 和现在的 Python 3。 这两个版本虽然非常相似,但在某些方面不兼容。
在现实世界中,Python 2 实际上与过去已经相去甚远。 简而言之,即使 Python 3 自 2008 年问世以来,过渡阶段仍未结束。 这主要是由于 Python 2 在业界得到广泛使用的事实,当然,公司并没有那么热衷于为更新而急于更新其系统,如果没问题的话, 不要解决哲学。 您可以在 Web 上阅读有关两个版本之间的过渡的全部信息。
阻碍过渡的另一个问题是第三方库的可用性。 通常,一个 Python 项目依赖于数十个外部库,当然,当您开始一个新项目时,您需要确保已经存在与版本 3 兼容的库,可以满足可能出现的任何业务需求。 如果不是这种情况,那么在 Python 3 中启动一个全新的项目就意味着引入潜在的风险,许多公司对此并不满意。
在撰写本文时,大多数最广泛使用的库已移植到 Python 3,在大多数情况下,在 Python 3 中启动项目是相当安全的。 许多库已被重写,以使其与两个版本兼容,主要是利用了六(2 x 3)库的功能,这有助于根据所使用的版本进行内省和适应行为。
本模块中的所有示例都将使用此 Python 3.4.0 运行。 它们中的大多数也可以在 Python 2 中运行(我也安装了版本 2.7.6),而那些不需要的版本只需进行一些细微调整即可适应两个版本之间的微小不兼容性。
不过,不必担心该版本的问题:实际上这不是一个大问题。
注意
如果在您阅读本课程时,如果我要指向您的网址或资源不再存在,请记住:Google 是您的朋友。
本课程需要什么
如您所见,开始时有太多的要求,所以我准备了一张表,它为您概述了该课程每个模块的需求:
|模块 1
|
模块 2
|
模块 3
|
模块 4
| | --- | --- | --- | --- | | 本模块中的所有示例均依赖于 Python 3 解释器。 本模块中的某些示例依赖于 Python 不附带的第三方库。 这些在使用时在模块中引入,因此您无需提前安装。 但是,出于完整性考虑,以下是列表:
- 点子
- 要求
- 枕头
- 位数组
| 尽管所有示例都可以在 Python Shell 中交互运行,但我们建议对该模块使用 IPython。 该模块中使用的库版本为:
- NumPy 1.9.2
- Pandas 0.16.2
- matplotlib 1.4.3
- 表 3.2.2
- pymongo 3.0.3
- Redis 2.10.3
- scikit 学习 0.16.1
| 任何现代处理器(大约从 2010 年开始)和 4 GB 的 RAM 就足够了,并且您可能也可以在较慢的系统上运行几乎所有代码。最后两章是一个例外。 在这些章节中,我将逐步使用 Amazon Web Services(AWS)运行代码。 这可能会花费您一些钱,但是优点是系统设置比在本地运行代码少。如果您不想为这些服务付费,那么所使用的工具都可以在本地计算机上设置,但是您肯定需要一个现代化的系统来运行它。 至少需要内置 2012 年且 RAM 大于 4 GB 的处理器。 | 尽管代码示例也将与 Python 2.7 兼容,但是最好安装最新版本的 Python 3(可能是 3.4.3 或更高版本)。 |
安装 Python
Python 是一种很棒的,通用的,易于使用的语言。 它可用于所有三个主要操作系统(Microsoft Windows,Mac OS X 和 Linux),并且安装程序以及文档可从官方 Python 网站下载。
注意
Windows 用户将需要设置一个环境变量,以便从命令行使用 Python。 首先,找到 Python 3 的安装位置; 默认位置为C:\Python34。 接下来,在命令行(cmd程序)中输入此命令:将环境设置为PYTHONPATH=%PYTHONPATH%;C:\Python34。 如果 Python 安装在其他目录中,请记住要更改C:\Python34。
在系统上运行 Python 之后,您应该能够打开命令提示符并运行以下代码:
$ python3
Python 3.4.0 (default, Apr 11 2014, 13:05:11)
[GCC 4.8.2] on Linux
Type "help", "copyright", "credits" or "license" for more information.
>>> print("Hello, world!")
Hello, world!
>>> exit()
请注意,我们将使用美元符号($)表示要在终端中键入命令(在 Windows 中也称为 shell 或 cmd)。 您不需要键入此字符(或它后面的空格)。 只需键入该行的其余部分,然后按输入即可。
获得上述“您好,世界!”之后 运行示例,退出程序并继续安装更高级的环境来运行 Python 代码 IPython Notebook。
安装 IPython
IPython 是用于 Python 开发的平台,其中包含许多用于运行 Python 的工具和环境,并且具有比标准解释器更多的功能。 它包含功能强大的 IPython Notebook,可让您在 Web 浏览器中编写程序。 它还格式化您的代码,显示输出,并允许您注释脚本。 这是探索数据集的绝佳工具。
要在计算机上安装 IPython,可以在命令行提示符下(而不是 Python)键入以下内容:
$ pip install ipython[all]
您需要管理员权限才能在整个系统范围内进行安装。 如果您不想(或不能)进行系统范围的更改,可以通过运行以下命令为当前用户安装它:
$ pip install --user ipython[all]
这会将 IPython 软件包安装到用户特定的位置-您将可以使用它,但是计算机上没有其他人可以使用。 如果您在安装时遇到困难,请查看官方文档以获取更多详细的安装说明。
安装 IPython Notebook 后,您可以使用以下命令启动它:
$ ipython3 notebook
这将做两件事。 首先,它将创建一个 IPython Notebook 实例,该实例将在您刚刚使用的命令提示符下运行。 其次,它将启动您的 Web 浏览器并连接到该实例,从而允许您创建一个新的笔记本。 看起来类似于以下屏幕截图(其中home/bob将被您当前的工作目录替换):
要停止运行 IPython Notebook,请打开正在运行实例的命令提示符(您之前用于运行 IPython 命令的实例)。 然后,按 Ctrl +C,将提示您Shutdown this notebook server (y/[n])?。 输入y并按输入,IPython Notebook 将关闭。
安装其他软件包
Python 3.4 将包含一个名为pip的程序,该程序是一个程序包管理器,可帮助您在系统上安装新的库。 您可以通过运行$ pip3 freeze命令来验证pip是否在系统上运行,该命令会告诉您系统上已安装了哪些软件包。
附加软件包可以通过pip安装程序安装,该程序自 Python 3.3 起已成为 Python 标准库的一部分。 有关pip的更多信息,请参见这个页面。
成功安装 Python 之后,我们可以从命令行终端执行pip来安装其他 Python 软件包:
pip install SomePackage
可以通过--upgrade标志更新已安装的软件包:
pip install SomePackage --upgrade
强烈推荐的用于科学计算的 Python 替代发行版是 Continuum Analytics 的 Anaconda。 Anaconda 是一种免费的(包括商业用途的)企业级 Python 发行版,它将用于数据科学,数学和工程学的所有基本 Python 软件包捆绑在一个用户友好的跨平台发行版中。 您可以从这个页面下载 Anaconda 安装程序,并可以从这个页面。
成功安装 Anaconda 之后,我们可以使用以下命令安装新的 Python 软件包:
conda install SomePackage
可以使用以下命令更新现有软件包:
conda update SomePackage
下面列出了用于编写本课程的主要 Python 软件包:
- NumPy
- 科学
- scikit 学习
- matplotlib
- Pandas
- 桌子
- pymongo
- 重复
由于这些软件包都托管在 Python 软件包索引 PyPI 上,因此可以很容易地通过pip安装。 要安装 NumPy,请运行:
$ pip install numpy
要安装 scikit-learn,请运行:
$ pip3 install -U scikit-learn
注意
重要
Windows 用户可能需要先安装 NumPy 和 SciPy 库,然后才能安装 scikit-learn。 这些用户可以在这个页面上获得安装说明。
大型 Linux 发行版(例如 Ubuntu 或 Red Hat)的用户可能希望从其软件包管理器中安装官方软件包。 并非所有发行版都具有 scikit-learn 的最新版本,因此请在安装前检查版本。
那些希望通过编译源代码来安装最新版本或查看更详细的安装说明的人员,可以转到这个页面以查看有关安装的正式文档。 scikit 学习。
大多数库都将具有版本的属性,因此,如果您已经安装了库,则可以快速检查其版本:
>>> import redis
>>> redis.__version__
'2.10.3'
这对于大多数库来说效果很好。 少数(例如 pymongo)使用其他属性(pymongo 仅使用版本,不带下划线)。
如何运行 Python 程序
中有几种不同的方法可以运行 Python 程序。
运行 Python 脚本
可以将 Python 用作脚本语言。 实际上,它总是证明自己非常有用。 脚本是通常执行以执行类似任务的文件(通常是较小尺寸的文件)。 许多开发人员最终拥有自己的工具库,需要执行任务时便会触发这些工具。 例如,您可以使用脚本来解析某种格式的数据并将其呈现为另一种不同的格式。 或者,您可以使用脚本来处理文件和文件夹。 您可以创建或修改配置文件,等等。 从技术上讲,在脚本中没有太多不能做的事情。
在服务器上的准确时间运行脚本是很常见的。 例如,如果您的网站数据库需要每 24 小时清洁一次(例如,存储用户会话的表,该会话很快就会过期,但不会自动清除),则可以设置一个 cron 作业,在 3 时触发脚本: 凌晨 00 每天。
注意
根据 Wikipedia 的说法,软件实用程序 Cron 是类似 Unix 的计算机操作系统中的基于时间的作业调度程序。 设置和维护软件环境的人员使用 cron 安排作业(命令或 shell 脚本)以固定的时间,日期或间隔定期运行。
我有 Python 脚本来完成所有繁琐的工作,这些工作可能要花我几分钟甚至更多的时间来手动完成,在某个时候,我决定实现自动化。 例如,我有一台没有 Fn 键来打开和关闭触摸板的笔记本电脑。 我觉得这很烦人,我不想在需要时单击几个菜单,所以我写了一个小巧的脚本,它很聪明,可以告诉我的系统切换触摸板的活动状态,现在 只需单击启动器即可完成此操作 无价。
运行 Python 交互式 shell
运行 Python 的另一种方法是调用交互式 shell。 在控制台的命令行上键入python时,我们已经看到了这一点。
因此,打开一个控制台,激活您的虚拟环境(现在应该是您的自然环境吧?),然后键入python。 您将看到几行看起来像这样的行(如果您使用的是 Linux):
Python 3.4.0 (default, Apr 11 2014, 13:05:11)
[GCC 4.8.2] on linux
Type "help", "copyright", "credits" or "license" for more information.
那些>>>是 shell 的提示符。 他们告诉您 Python 正在等待您键入内容。 如果您键入一条简单的指令,而该指令仅适合一行,那么您将看到的全部内容。 但是,如果您键入的内容需要多于一行代码,则 Shell 会将提示符更改为...,从而为您提供了直观的线索,表明您正在键入多行语句(或任何需要多于一行的代码) 代码)。
继续尝试一下,让我们做一些基本的数学运算:
>>> 2 + 4
6
>>> 10 / 4
2.5
>>> 2 ** 1024
179769313486231590772930519078902473361797697894230657273430081157732675805500963132708477322407536021120113879871393357658789768814416622492847430639474124377767893424865485276302219601246094119453082952085005768838150682342462881473913110540827237163350510684586298239947245938479716304835356329624224137216
最后一个操作向您展示了一些不可思议的东西。 我们将 2 提高到 1024 的幂,Python 可以毫不费力地处理此任务。 尝试使用 Java,C ++ 或 C#进行操作。 除非您使用特殊的库来处理这么大的数字,否则它将无法正常工作。
我每天都使用交互式 shell。 快速调试非常有用,例如,检查数据结构是否支持操作。 或者检查或运行一段代码。
当您使用 Django(Web 框架)时,交互式外壳与之耦合,并允许您逐步使用框架工具,检查数据库中的数据以及更多其他内容。 您会发现交互式外壳将很快成为您踏上旅途中最亲爱的朋友之一。
以更好的图形布局出现的另一种解决方案是使用 IDLE(集成开发环境)。 这是一个非常简单的 IDE,主要供初学者使用。 它的功能集比您在控制台中获得的裸机交互外壳要大一些,因此您可能需要对其进行探索。 它在 Windows Python 安装程序中免费提供,您可以轻松地在其他任何系统中安装它。 您可以在 Python 网站上找到有关它的信息。
吉多·范·罗苏姆(Guido Van Rossum)以英国喜剧团 Monty Python 的名字命名 Python,因此有传言说 IDLE 被选中是为了纪念 Monty Python 的创始成员之一 Erik Idle。
将 Python 作为服务运行
除了作为脚本运行之外,并且在外壳程序范围内,Python 可以进行编码并作为适当的软件运行。 在整个模块中,我们将看到有关此模式的许多示例。 当我们谈论 Python 代码的组织和运行方式时,我们将对此有更多的了解。
将 Python 作为 GUI 应用运行
Python 也可以作为 GUI(图形用户界面)运行。 有几种可用的框架,其中一些是跨平台的,另一些是特定于平台的。
Tk 是一个图形用户界面工具包,它将桌面应用开发提升到比传统方法更高的水平。 它是工具命令语言(TCL)的标准 GUI,同时也是许多其他动态语言的标准 GUI,可以生成可在 Windows,Linux 和 Mac 上无缝运行的丰富本机应用 OS X 等。
Tkinter 与 Python 捆绑在一起,因此它使程序员可以轻松访问 GUI 世界,由于这些原因,我选择它作为本模块中将要介绍的 GUI 示例的框架。
在其他 GUI 框架中,我们发现以下是最广泛使用的框架:
- PyQt
- wxPython
- PyGtk
详细描述它们不在本模块的范围内,但是您可以在 GUI Programming 部分的 Python 网站上找到所需的所有信息。 如果您正在寻找 GUI,请记住要根据一些原则选择所需的 GUI。 确保他们:
- 提供开发项目可能需要的所有功能
- 在您可能需要支持的所有平台上运行
- 依靠一个尽可能广泛和活跃的社区
- 包装可以轻松安装/访问的图形驱动程序/工具
Python 代码是如何组织的
让我们谈谈 Python 代码的组织方式。 在本段中,我们将开始做一些进一步的介绍,并介绍一些技术名称和概念。
从基础开始,Python 代码是如何组织的? 当然,您可以将代码写入文件中。 当保存扩展名为.py的文件时,该文件被称为 Python 模块。
注意
如果您使用的是 Windows 或 Mac,通常会向用户隐藏文件扩展名,请确保您更改了配置,以便可以看到文件的完整名称。 严格来说,这不是必须的要求,而是一个丰盛的建议。
将软件运行所需的所有代码保存在一个文件中是不切实际的。 该解决方案适用于脚本,这些脚本通常不超过几百行(通常比这短很多)。
一个完整的 Python 应用可以由成千上万行代码组成,因此您必须将其分散在不同的模块中。 更好,但还不够好。 事实证明,即使这样,使用代码仍然是不切实际的。 因此,Python 为您提供了另一种结构,称为包,该结构允许您将模块分组在一起。 包只不过是一个文件夹,该文件夹必须包含一个特殊文件__init__.py,该文件不需要保存任何代码,但需要存在该文件才能告诉 Python 该文件夹不仅是某个文件夹,而且 实际上是一个软件包(请注意,从 Python 3.3 __init__.py开始不再严格要求)。
与往常一样,示例将使所有这些变得更加清晰。 我在模块项目中以及当我在 Linux 控制台中键入内容时创建了一个示例结构:
$ tree -v example
我得到了ch1/example文件夹内容的树形表示,其中包含本章示例的代码。 实际的简单应用的结构如下所示:
example/
├── core.py
├── run.py
└── util
├── __init__.py
├── db.py
├── math.py
└── network.py
您可以看到在此示例的根目录中,我们有两个模块core.py和run.py以及一个包:util。 在core.py中,可能存在我们应用的核心逻辑。 另一方面,在run.py模块中,我们可能可以找到启动应用的逻辑。 我希望在util包中找到各种实用工具,实际上,我们可以猜测那里的模块是由它们所拥有的工具的类型来调用的:db.py将拥有与数据库一起使用的工具,math.py 当然会拥有数学工具(也许我们的应用处理财务数据),network.py可能会拥有在网络上发送/接收数据的工具。
如前所述,__init__.py文件只是用来告诉 Python util是一个软件包,而不仅仅是一个文件夹。
如果仅将该软件组织在模块中,则推断其结构将更加困难。 我在ch1/files_only文件夹下放了一个仅模块示例,自己看看:
$ tree -v files_only
这向我们展示了完全不同的图片:
files_only/
├── core.py
├── db.py
├── math.py
├── network.py
└── run.py
很难猜测每个模块的作用,对吗? 现在,考虑一下这只是一个简单的示例,因此您可以猜测,如果我们不能在包和模块中组织代码,那么理解一个真实的应用将变得更加困难。
我们如何使用模块和软件包
当开发人员正在编写应用时,他们很有可能需要在其不同部分应用相同的逻辑。 例如,当为来自用户可以填写网页的表格的数据编写解析器时,应用将必须验证某个字段是否包含数字。 无论如何编写这种验证的逻辑,很可能在多个地方都需要它。 例如,在民意测验应用中,向用户询问了许多问题,很可能其中几个用户将需要一个数字答案。 例如:
- 你几岁
- 您拥有多少只宠物
- 你有几个孩子
- 你结婚了多少次
在我们希望得到数字答案的每个地方复制粘贴(或更正确地说:重复)验证逻辑将是非常糟糕的做法。 这将违反 DRY(不要重复自己)的原则,该原则规定,在您的应用中,同一条代码不应重复多次。 我感到有必要强调这一原则的重要性:在您的应用中,您绝不应重复执行同一段代码(具有讽刺意味?)。
重复同一条逻辑可能会很糟糕的原因有几个,其中最重要的是:
- 逻辑中可能存在错误,因此,您必须在应用逻辑的每个位置进行纠正。
- 您可能需要修改执行验证的方式,并且再次必须在应用验证的每个位置进行更改。
- 您可能会忘记修复/修改逻辑,因为在搜索所有逻辑时都错过了它。 这将在您的应用中留下错误/不一致的行为。
- 没有充分的理由,您的代码将比所需的时间更长。
Python 是一种出色的语言,为您提供应用所有编码最佳实践所需的所有工具。 对于此特定示例,我们需要能够重用一段代码。 为了能够重用一段代码,我们需要一个可以为我们保存代码的构造,以便每次需要重复其中的逻辑时可以调用该构造。 该构造存在,称为函数。
我在这里不做详细介绍,所以请记住,函数是用于执行任务的有组织,可重用的代码块。 根据函数所属的环境,函数可以采用多种形式和名称,但是现在这并不重要。 稍后,当我们能够在模块中欣赏它们时,将看到这些细节。 函数是应用中模块化的构建块,它们几乎是必不可少的(除非您正在编写一个超级简单的脚本,否则您将一直使用函数)。
如前所述,Python 带有非常广泛的库。 现在,也许是时候定义一个库了:库 是功能和对象的集合,这些函数和对象提供了丰富语言功能的功能。
例如,在 Python 的math库中,我们可以找到许多函数,其中factorial函数就是其中之一,该函数当然会计算数字的阶乘。
注意
在数学上,非负整数N的阶乘 表示为 N! 定义为所有小于或等于 N 的正整数的乘积。例如,阶乘 5 的计算公式为:
5! = 5 * 4 * 3 * 2 * 1 = 120
0的阶乘是0! = 1,以遵守关于空产品的约定。
因此,如果您想在代码中使用此函数,则只需导入并用正确的输入值调用它即可。 如果现在还不清楚输入值和调用的概念,请不要太担心,请专注于导入部分。
注意
我们通过从库中导入所需内容来使用库,然后使用它。
在 Python 中,要计算数字 5 的阶乘,我们只需要以下代码:
>>> from math import factorial
>>> factorial(5)
120
注意
无论我们在 shell 中键入什么内容(如果它具有可打印的表示形式),都将在控制台上为我们打印(在这种情况下,函数调用的结果为:120)。
因此,让我们回到的示例,即具有core.py,run.py,util等的示例。
在我们的示例中,包util是我们的实用程序库。 我们的定制公用带可存放我们在应用中所需的所有那些可重复使用的工具(即功能)。 其中一些将处理数据库(db.py),一些将处理网络(network.py),另一些将执行数学计算(math.py),这些计算不在 Python 标准math库的范围内,因此,我们 必须自己编写代码。
现在让我们讨论另一个非常重要的概念:Python 的执行模型。
Python 的执行模型
在本段中,我希望向您介绍一些非常重要的概念,例如作为范围,名称和名称空间。 当然,您可以在官方语言参考中阅读有关 Python 执行模型的全部信息,但是我认为它是技术性和抽象性很强的,所以让我先给您一个不太正式的解释。
名称和名称空间
假设您正在寻找一个模块,因此您去了图书馆,问某人要获取的模块。 他们告诉您类似“第二层 X 分区第三行”的信息。 因此,您上楼梯,寻找 X 区段,依此类推。
进入图书馆,将所有书籍以随机的顺序堆放在一个大房间里,这是完全不同的。 没有楼层,没有部分,没有行,没有顺序。 提取模块将非常困难。
在编写代码时,我们会遇到同样的问题:我们必须尝试对其进行组织,以便对以前没有相关知识的人轻松找到所需的内容。 如果软件的结构正确,它也会促进代码重用。 另一方面,杂乱无章的软件更有可能暴露出分散的重复逻辑。
首先,让我们从模块开始。 我们通过模块名称和 Python 术语来引用模块,这就是名称。 Python 名称是其他语言称为变量的最接近的抽象。 名称基本上是指对象,并通过名称绑定操作引入。 让我们举一个简单的例子(注意,#之后的所有内容都是注释):
>>> n = 3 # integer number
>>> address = "221b Baker Street, NW1 6XE, London" # S. Holmes
>>> employee = {
... 'age': 45,
... 'role': 'CTO',
... 'SSN': 'AB1234567',
... }
>>> # let's print them
>>> n
3
>>> address
'221b Baker Street, NW1 6XE, London'
>>> employee
{'role': 'CTO', 'SSN': 'AB1234567', 'age': 45}
>>> # what if I try to print a name I didn't define?
>>> other_name
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'other_name' is not defined
我们在前面的代码中定义了三个对象(您还记得每个 Python 对象具有的三个功能吗?):
- 整数
n(类型:int,值:3) - 字符串
address(类型:str,值:Sherlock Holmes 的地址) - 字典
employee(类型:dict,值:包含三个键/值对的字典)
别担心,我知道您不应该知道字典是什么。 我们将在中看到下一章,它是 Python 数据结构之王。
注意
您输入我的员工定义时,提示是否从>>>更改为...? 这是因为定义跨越多行。
那么n,address和employee是什么? 它们是名称。 我们可以用来在代码中检索数据的名称。 它们需要保存在某个地方,以便每当我们需要检索那些对象时,我们都可以使用它们的名称来获取它们。 我们需要一些空间来容纳它们,因此:命名空间!
因此,名称空间是从名称到对象的映射。 示例包括一组内置名称(包含始终可以在任何 Python 程序中免费访问的函数),模块中的全局名称以及函数中的本地名称。 甚至对象的属性集也可以视为名称空间。
命名空间的优点在于,它们使您可以清晰地定义和组织名称,而不会出现重叠或干扰。 例如,与我们在库中寻找的模块相关联的名称空间可用于导入模块本身,如下所示:
from library.second_floor.section_x.row_three import module
我们从library命名空间开始,借助点(.)运算符,我们进入该命名空间。 在此命名空间中,我们寻找second_floor,然后再次使用.运算符进入它。 然后,我们进入section_x,最后进入最后一个命名空间row_tree,找到我们要寻找的名称:module。
当我们要处理真实的代码示例时,遍历命名空间将更加清晰。 现在,请记住,名称空间是名称与对象关联的地方。
还有一个与命名空间紧密相关的概念,我想简单地谈一谈:范围。
范围
根据 Python 的文档,范围是 Python 程序的文本区域,可直接访问名称空间。 直接访问意味着当您要查找对名称的不合格引用时,Python 会尝试在名称空间中找到它。
作用域是静态确定的,但实际上在运行时会动态使用它们。 这意味着,通过检查源代码,您可以知道对象的范围是什么,但这不会阻止软件在运行时进行更改。 Python 提供了四种不同的作用域(当然,它们不一定同时存在):
- 本地 范围,其中是最内部的范围,并包含本地名称。
- 包含和范围的**,即是任何包含函数的范围。 它包含非本地名称以及非全局名称。**
- 全局 范围包含全局名称。
- 内置 范围包含内置名称。 Python 附带了一组可以以现成的方式使用的函数,例如
print,all,abs等。 他们生活在内置范围内。
规则如下:当我们引用名称时,Python 开始在当前名称空间中查找它。 如果未找到名称,Python 将继续搜索到封闭范围,并继续进行直到搜索内置范围。 如果在搜索内置作用域后未找到名称,那么 Python 会引发一个NameError 异常,这基本上意味着该名称尚未定义(您在 前面的示例)。
因此,查找名称时扫描名称空间的顺序为:本地,包含,全局,内置 HTG8](LEGB)。
这都是非常理论上的,所以让我们看一个例子。 为了向您显示 Local 和 Enclosing 名称空间,我将必须定义一些函数。 只要记住以下代码,当您看到def时,就意味着我正在定义一个函数。
scopes1.py
# Local versus Global
# we define a function, called local
def local():
m = 7
print(m)
m = 5
print(m)
# we call, or `execute` the function local
local()
在前面的示例中,我们在全局范围和本地范围(由函数 local 定义的范围)中都定义了相同的名称m。 当我们使用以下命令执行该程序时(您是否激活了 virtualenv?):
$ python scopes1.py
我们在控制台上看到两个数字:5和7。
发生的是 Python 解释器从上到下解析文件。 首先,它找到几个注释行,将其跳过,然后解析函数local的定义。 调用该函数时,它会做两件事:它为代表数字 7 的对象设置名称并进行打印。 Python 解释器不断前进,并找到了另一个名称绑定。 这次绑定发生在全局范围内,值为 5。下一行是对print函数的调用,该函数将被执行(因此,我们将在控制台上打印出第一个值:5)。
此后,将调用函数local。 此时,Python 执行该函数,因此此时发生绑定m = 7并打印出来。
需要注意的一件非常重要的事情是,属于函数 local 的定义的代码部分由右侧的四个空格缩进。 实际上,Python 通过缩进代码来定义作用域。 您可以通过缩进进入范围,而通过不缩进可以退出范围。 一些编码器使用两个空格,另一些使用三个空格,但是建议使用的空格数为四个。 这是最大化可读性的好方法。 稍后我们将讨论在编写 Python 代码时应遵循的所有约定。
如果我们删除该m = 7行会发生什么? 记住 LEGB 规则。 Python 将开始在本地范围(功能local)中寻找m,但找不到它,它将进入下一个封闭的范围。 在这种情况下,下一个是全局的,因为local周围没有封装函数。 因此,我们将在控制台上看到两个数字5。 让我们实际看一下代码的样子:
scopes2.py
# Local versus Global
def local():
# m doesn't belong to the scope defined by the local function
# so Python will keep looking into the next enclosing scope.
# m is finally found in the global scope
print(m, 'printing from the local scope')
m = 5
print(m, 'printing from the global scope')
local()
运行scopes2.py将打印以下内容:
(.lpvenv)fab@xps:ch1$ python scopes2.py
5 printing from the global scope
5 printing from the local scope
正如预期的那样,Python 第一次打印m,然后在调用函数local时,在其范围内找不到m,因此 Python 会按照 LEGB 链查找它,直到m被 在全球范围内找到。
让我们看一个带有附加层的示例,即封闭范围:
scopes3.py
# Local, Enclosing and Global
def enclosing_func():
m = 13
def local():
# m doesn't belong to the scope defined by the local
# function so Python will keep looking into the next
# enclosing scope. This time m is found in the enclosing
# scope
print(m, 'printing from the local scope')
# calling the function local
local()
m = 5
print(m, 'printing from the global scope')
enclosing_func()
运行scopes3.py将在控制台上打印:
(.lpvenv)fab@xps:ch1$ python scopes3.py
5 printing from the global scope
13 printing from the local scope
如您所见,功能local的print指令与以前一样是指m。 m仍未在函数本身中定义,因此 Python 会按照 LEGB 顺序启动作用域。 这次在封闭范围内找到了m。
如果现在还不清楚,请不要担心。 我们将通过本模块中的示例为您提供帮助。 Python 教程(官方文档)的类部分包含有关范围和名称空间的有趣段落。 如果您希望对本主题有更深入的了解,请确保在一定时间阅读。
在结束本章之前,我想再多谈一些对象。 毕竟,Python 中的基本上所有东西都是对象,因此我认为它们值得更多关注。
有关如何编写良好代码的准则
编写好的代码似乎并不像那样容易。 就像我之前说过的,好的代码暴露出一长串的质量,这些质量很难组合在一起。 在某种程度上,编写好的代码是一门艺术。 无论您乐于在何处定居,都有可以拥抱的东西可以使您的代码立即变得更好:PEP8。
根据维基百科:
“ Python 的开发主要通过 Python 增强建议(PEP)流程进行。PEP 流程是提出主要新功能,收集社区对问题的意见以及记录设计决策的主要机制。 已经进入 Python。”
在所有 PEP 中,可能最著名的是 PEP8。 它列出了一套简单而有效的准则来定义 Python 美学,以便我们编写漂亮的 Python 代码。 如果您从本章中提出一个建议,请按照以下说明操作:使用它。 接受它。 您稍后会感谢我。
今天的编码不再是登记入住/退房业务。 相反,这更多是一种社会努力。 几个开发人员通过 git 和 mercurial 之类的工具协作编写一段代码,结果是许多不同的人掌握了这些代码。
注意
Git 和 Mercurial 可能是当今使用最广泛的分布式修订控制系统。 它们是必不可少的工具,旨在帮助开发人员团队在同一软件上进行协作。
如今,我们比以往任何时候都需要一种一致的代码编写方式,以使可读性最大化。 当一家公司的所有开发人员都遵守 PEP8 时,他们中的任何一个使用一段代码就认为自己编写了代码并不少见。 实际上,它一直在我身上发生(我总是忘记自己编写的代码)。
这具有巨大的优势:当您阅读可以编写的代码时,便可以轻松阅读。 如果没有约定,每个编码人员都将按照他们最喜欢的方式或只是按照他们的教导或习惯的方式来构造代码,这意味着必须根据他人的风格来解释每一行。 这意味着必须花更多的时间去尝试理解它。 多亏了 PEP8,我们可以避免这种情况。 我非常喜欢它,如果代码不遵守它,我不会签署代码审查。 因此,请花时间研究它,这非常重要。
在本模块的示例中,我将尽可能地尊重它。 不幸的是,我不能使用 79 个字符(这是 PEP *建议的最大行长),我将不得不减少空白行和其他内容,但是我保证您会尝试布局我的 代码,以使其尽可能具有可读性。
Python 文化
Python 已在所有编码行业中广泛采用。 许多公司将其用于许多不同的目的,并且在教育中也使用了它(由于它的许多特质和易于学习的事实,因此是一种出色的语言)。
Python 之所以如此流行的原因之一是,它周围的社区辽阔,生机勃勃,充满了才华横溢的人们。 世界各地组织了许多活动,其中大多数活动围绕 Python 或其主要网络框架 Django 进行。
Python 是开放的,拥护它的人通常也是如此。 在 Python 网站上查看社区页面以获取更多信息并参与其中!
Python 的另一个方面围绕着 Pythonic 的概念。 这与以下事实有关:Python 允许您使用其他地方没有的某些习语,至少以不同的形式或易用性使用(当我不得不使用非 Python 语言编写代码时,我会产生幽闭恐惧症) 现在)。
无论如何,这些年来,Pythonic 的概念应运而生,据我所知,它就像一样,以应有的方式在 Python 中完成工作。
为了帮助您进一步了解 Python 的文化和成为 Python 语言,我将向您展示 Python 的 Zen。 一个非常受欢迎的可爱的复活节彩蛋。 打开 Python 控制台,然后输入import this。 以下是该行的结果:
>>> 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,您必须深刻理解一些 Python 特性。 从有趣的级别开始,然后进行更深入的研究。 总是更深入。
关于 IDE 的说明
关于集成开发环境(IDE)的简短说明。 要遵循本模块中的示例,您不需要一个示例,任何文本编辑器都可以。 如果要具有语法着色和自动完成等更高级的功能,则必须获取自己的 IDE。 您可以在 Python 网站上找到完整的开源 IDE 列表(只是 Google 的“ python ides”)。 我个人使用 Sublime Text 编辑器。 免费试用,价格仅为几美元。 我一生中尝试了许多 IDE,但这是使我效率最高的一种。
两个非常重要的建议:
- 无论您选择使用哪种 IDE,都请尝试好好学习它,以便可以利用它的优势,但是并不依赖于它。 练习自己不时地与 VIM(或任何其他文本编辑器)一起工作,学习能够使用任何工具在任何平台上进行一些工作。
- 无论使用哪种文本编辑器/ IDE,在编写 Python 时,缩进都是四个空格。 不要使用制表符,不要将它们与空格混合。 使用四个空格,而不是两个,三个或五个。 只需使用四个。 整个世界都是这样工作的,并且您不想成为被淘汰者,因为您喜欢三空间布局。
二、面向对象设计
在软件开发中,通常将设计视为编程之前的步骤。 这不是真的。 实际上,分析,编程和设计往往会重叠,合并和交织。 在本章中,我们将介绍以下主题:
- 面向对象的含义
- 面向对象设计和面向对象编程之间的区别
- 面向对象设计的基本原理
- 基本的统一建模语言(UML)以及它不是邪恶的时候
引入面向对象
每个人都知道对象是什么—我们可以感知,感觉和操纵的有形事物。 我们与之互动的最早物体通常是婴儿玩具。 木块,塑料形状和超大拼图块是常见的首要对象。 婴儿很快就会知道某些物体会执行某些操作:铃响,按钮按下以及操纵杆拉动。
在软件开发中,对象的定义没有太大不同。 软件对象通常不是您可以拾取,感知或感觉到的有形事物,但是它们是可以执行某些操作并对它们执行某些操作的模型。 形式上,对象是数据和关联的行为的集合。
那么,知道什么是对象,面向对象的含义是什么? 定向简单是指将定向为。 因此,面向对象意味着在功能上指向建模对象。 这是通过描述交互对象的数据和行为来收集对象的集合,从而为复杂系统建模的众多技术之一。
如果您读过任何炒作,您可能会遇到术语“面向对象的分析”,“面向对象的设计”,“面向对象的分析和设计以及面向对象的编程”。 这些都是在通用的面向对象框架下的高度相关的概念。
实际上,分析,设计和编程都是软件开发的所有阶段。 称它们为面向对象仅指正在追求的软件开发风格。
面向对象分析(OOA)是查看问题,系统或任务(某人希望将其转变为应用)并识别的过程。 对象以及这些对象之间的交互。 分析阶段全部与*有关,*需要完成什么。
分析阶段的输出是一组需求。 如果我们要一步完成分析阶段,那么我们会将一项任务(例如我需要一个网站)转变为一组需求。 例如:
网站访问者必须能够(斜体代表操作,粗体代表对象):
- 回顾我们的历史
- 将应用于职位
- 浏览,比较和订购产品
在某些方面,分析是错误的。 我们前面讨论的婴儿不分析积木和拼图。 相反,它将探索其环境,操纵形状并查看它们可能适合的位置。 更好的措辞可能是面向对象的探索。 在软件开发中,分析的初始阶段包括采访客户,研究他们的流程以及消除可能性。
面向对象设计(OOD)是将此类要求转换为实现规范的过程。 设计人员必须命名对象,定义行为并正式指定哪些对象可以激活其他对象上的特定行为。 设计阶段是关于如何完成的。
设计阶段的输出是实现规范。 如果我们要一步一步完成设计阶段,我们将把在面向对象分析过程中定义的需求转换为可以用(理想情况下)任何面向对象编程语言实现的一组类和接口。
面向对象程序设计(OOP)是将这种完全定义的设计转换成可以执行 CEO 最初要求的工作程序的过程。
是的,对! 如果世界实现了这一理想,那就太好了,我们可以按照所有旧教科书告诉我们的那样,以完美的顺序一步一步地走过这些阶段。 像往常一样,现实世界更加模糊。 无论我们多么努力地分离这些阶段,我们在设计时总是会发现需要进一步分析的事物。 在进行编程时,我们会发现需要在设计中进行澄清的功能。
二十一世纪的大多数发展都发生在迭代发展模型中。 在迭代开发中,仅对任务的一小部分进行建模,设计和编程,然后对程序进行审查和扩展以改进每个功能,并在一系列短的开发周期中包括新功能。
本模块的其余部分与面向对象编程有关,但是在本章中,我们将介绍设计上下文中的基本面向对象原理。 这使我们能够理解这些(相当简单的)概念,而不必与软件语法或 Python 解释器争论。
对象和类
因此,对象是具有相关行为的数据的集合。 我们如何区分对象类型? 苹果和橘子都是对象,但是这是一句常见的格言,它们无法比较。 苹果和橘子在计算机编程中不是很常用的模型,但让我们假设我们正在为一个水果农场进行库存应用。 为了简化示例,我们可以假设苹果装在桶中,橘子装在篮子中。
现在,我们有四种对象:苹果,橙子,篮子和桶。 在面向对象的建模中,用于类对象的术语是类。 因此,从技术上讲,我们现在有四类对象。
对象和类之间的有什么区别? 类描述对象。 它们就像创建对象的蓝图。 您前面的桌子上可能坐着三个橙子。 每个橙子都是一个不同的对象,但是所有三个橙子都具有与一类相关的属性和行为:普通橙子。
可以使用统一建模语言(始终称为 UML)来描述,这是库存系统中四类对象之间的关系,因为三个字母的首字母缩写永远不会消失 样式)类图。 这是我们的一流图:
该图显示了橙色以某种方式与篮子关联,并且苹果也以某种方式与桶关联。 关联是两个类关联的最基本方法。
UML 在管理人员中非常流行,偶尔会被程序员贬低。 UML 图的语法通常很明显。 您无需阅读教程即可(主要)了解看到的内容。 UML 也很容易绘制,并且非常直观。 毕竟,许多人在描述类及其关系时,自然会在它们之间画线并绘制框。 具有基于这些直观图表的标准,可使程序员轻松与设计师,经理和彼此进行沟通。
但是,一些程序员认为 UML 是浪费时间。 他们以迭代开发为例,他们认为,花哨的 UML 图中完成的正式规范在实施之前将是多余的,维护这些正式图只会浪费时间,而不会使任何人受益。
根据所涉及的公司结构,这可能是正确的,也可能不是正确的。 但是,每个由一个以上人员组成的编程团队有时都必须坐下来,逐一讨论当前正在研究的子系统的详细信息。 在这些集思广益的会议中,UML 对于快速,轻松地进行交流非常有用。 即使是那些嘲笑正式类图的组织,也倾向于在他们的设计会议或团队讨论中使用一些非正式的 UML 版本。
此外,您将不得不与之沟通的最重要的人就是您自己。 我们所有人都认为我们可以记住我们所做的设计决策,但是总会有为什么这样做? 时刻藏在我们的未来中。 如果我们保留纸屑,就在开始设计时就进行了初步的制图,那么我们最终会发现它们是有用的参考。
但是,本章并不旨在成为 UML 的教程。 Internet 上有许多此类书籍,以及有关该主题的大量书籍。 UML 涵盖的范围远远超过类图和对象图。 它还具有用例,部署,状态更改和活动的语法。 在此面向对象设计的讨论中,我们将处理一些常见的类图语法。 您会发现可以通过示例了解结构,并且可以在自己的团队或个人设计会议中下意识地选择 UML 启发式语法。
我们最初的图虽然正确,但并没有提醒我们苹果在桶中进入或单个苹果可以进入多少桶。它仅告诉我们苹果与桶相关。 类之间的关联通常很明显,不需要进一步解释,但是我们可以选择根据需要添加进一步的说明。
UML 的优点在于,大多数事情都是可选的。 我们只需要在图中指定尽可能多的信息以适合当前情况即可。 在快速的白板会议中,我们可能会在框之间快速画线。 在正式文档中,我们可能会更详细。 对于苹果和木桶,我们可以很确信该关联为:许多苹果放入一桶中,但只是为了确保没有人与之混淆,一个苹果将破坏一桶[,我们可以增强该图,如下所示:
该图告诉我们,橙子装在篮子中,带有一个小箭头,该箭头指示所要装的东西。 它还告诉我们可以在关系两侧的关联中使用的对象的数量。 一个篮子可以容纳许多(以 ***** 表示)橙色对象。 任何一个橙色都可以恰好放入一个篮子中。 此数字称为对象的多重性。 您可能还会听到它被描述为基数。 这些实际上是稍微不同的术语。 基数是指集合中项目的实际数量,而多重性则指定该数量可以多小。
我经常忘记多重性在关系的哪一边。 最接近一个类的多样性是可以与关联另一端的中的任何一个对象关联的该类对象的数量。 对于苹果进入桶关联,从左到右读取,苹果类的许多实例(即许多苹果对象)可以进入任何一个苹果桶。 从右向左读取,可以将一个桶与任何一个苹果关联。
指定属性和行为
现在,我们掌握了一些基本的面向对象术语。 对象是可以相互关联的类的实例。 对象实例是具有其自己的数据和行为集的特定对象。 我们面前桌子上的特定橙子被称为是普通橙子的一个实例。 这很简单,但是与每个对象关联的这些数据和行为是什么?
数据描述对象
让我们从数据开始。 数据通常代表特定对象的个体特征。 类可以定义该类的所有对象共享的特定特征集。 对于给定的特性,任何特定对象都可以具有不同的数据值。 例如,我们桌上的三个橙子(如果我们还没有吃过)可能各自重量不同。 然后,橙色类可以具有权重属性。 Orange 类的所有实例都具有 weight 属性,但是每个 Orange 对此属性都有不同的值。 但是,属性不必唯一。 任何两个橙子的重量相同。 作为一个更现实的示例,代表不同客户的两个对象的“名字”属性可能具有相同的值。
属性通常称为成员或属性。 一些作者建议这些术语具有不同的含义,通常属性是可设置的,而属性是只读的。 在 Python 中,“只读”的概念毫无意义,因此在本模块中,我们将看到两个术语可以互换使用。 另外,正如我们将在第 5 章,“何时使用面向对象的编程”中讨论的那样,属性关键字在 Python 中对于特定类型的属性具有特殊含义。
在我们的水果清单应用中,果农可能想知道橙子来自哪个果园,何时采摘以及它的重量。 他们可能还想保持每个篮子的存放位置。 苹果可能具有颜色属性,并且桶的尺寸可能会不同。 其中一些属性可能还属于多个类(我们也可能想知道何时摘苹果),但是对于第一个示例,我们仅向类图添加一些不同的属性:
根据设计的详细程度,我们还可以为每个属性指定类型。 属性类型通常是大多数编程语言的标准原语,例如整数,浮点数,字符串,字节或布尔值。 但是,它们也可以表示数据结构,例如列表,树或图形,或者最值得注意的是其他类。 这是设计阶段可以与编程阶段重叠的区域。 一种编程语言中可用的各种原语或对象可能与其他语言中可用的不同。
通常,我们不需要在设计阶段就过度关注数据类型,因为在编程阶段会选择特定于实现的细节。 通用名称通常足以进行设计。 如果我们的设计需要列表容器类型,则 Java 程序员可以在实现时选择使用LinkedList或ArrayList,而 Python 程序员(就是我们!)可以在list内置和[ 一个tuple。
到目前为止,在我们的水果农场示例中,我们的属性都是基本的基本元素。 但是,我们可以使一些隐式属性成为显式属性-关联。 对于给定的橙色,我们可能具有一个属性,其中包含保存该橙色的购物篮。
行为就是行动
现在,我们知道什么是数据,但是什么是行为? 行为是可能在对象上发生的动作。 可以在特定类别的对象上执行的行为称为方法。 在编程级别,方法类似于结构化编程中的函数,但是它们神奇地可以访问与此对象关联的所有数据。 像函数一样,方法也可以接受参数并返回值。
方法的参数是对象的列表,需要将传递给并传递给被调用的方法(从调用对象传递来的对象通常称为自变量)。 方法使用这些对象来执行其打算执行的任何行为或任务。 返回的值是该任务的结果。
我们已经将“比较苹果和橘子”的例子扩展到一个基本的(如果牵强)清单应用中。 让我们进一步伸展一下,看看它是否破裂。 可以将与橙色关联的一种动作是选择动作。 如果您考虑实现,则选择会通过更新橙色的篮子属性,并将该橙色添加到桔子列表中,将橙色放入篮子中 在篮子上。 因此,选秀权需要知道它正在处理哪个篮筐。 为此,我们为选择方法提供了篮子参数。 由于我们的水果农也出售果汁,因此我们可以在橙中添加挤压方法。 挤压时,挤压可能会返回取回的果汁量,同时还从其中的篮子中取出橙色。
篮子可以有卖出动作。 当篮子被出售后,我们的库存系统可能会更新尚未指定的对象上的某些数据,以进行会计和利润计算。 另外,我们的橙子篮子在出售之前可能会变质,因此我们添加了丢弃方法。 让我们将这些方法添加到图中:
将模型和方法添加到单个对象可以使我们创建交互对象的系统。 系统中的每个对象都是某个类的成员。 这些类指定对象可以保存哪些数据类型以及可以在对象上调用哪些方法。 每个对象中的数据可以处于与同一类的其他对象不同的状态,并且由于状态不同,每个对象对方法调用的反应也可能不同。
面向对象的分析和设计都是关于弄清楚那些对象是什么以及它们应该如何交互。 下一节将介绍可用于使这些交互尽可能简单直观的原理。
隐藏详细信息并创建公共界面
在面向对象的设计中对对象建模的关键的目的是确定该对象的公共接口是什么。 接口是其他对象可以用来与该对象进行交互的属性和方法的集合。 它们不需要(通常也不允许)访问对象的内部工作原理。 电视是现实世界中常见的示例。 我们与电视的接口是遥控器。 遥控器上的每个按钮代表一种可以在电视对象上调用的方法。 当我们作为调用对象访问这些方法时,我们不知道或不在乎电视是从天线,电缆连接还是卫星天线接收信号。 我们不在乎发送什么电子信号来调节音量,也不管声音是发给扬声器还是耳机。 如果我们打开电视来访问内部工作原理(例如,将输出信号分配到外部扬声器和一组耳机),将使保修无效。
隐藏对象的实现或功能性详细信息的处理适当地称为信息隐藏。 有时也将其称为封装,但是封装实际上是一个更全面的术语。 封装的数据不一定是隐藏的。 封装实际上就是创建一个胶囊,因此可以考虑创建一个时间胶囊。 如果将一堆信息放入时间囊中,将其锁定并掩埋,则它们将被封装并且信息将被隐藏。 另一方面,如果时间胶囊没有被掩埋并被解锁或由透明塑料制成,则其内部的物品仍将被封装,但是不会隐藏任何信息。
封装与信息隐藏之间的区别在很大程度上无关紧要,尤其是在设计级别上。 许多实用参考文献可互换使用这些术语。 作为 Python 程序员,我们实际上并没有隐藏真正的信息,(我们将在第 2 章和 Python 中的对象中讨论其原因),因此更笼统的定义是 用于封装是合适的。
但是,公共接口非常重要。 需要对其进行仔细设计,因为将来很难对其进行更改。 更改接口将破坏正在调用该接口的所有客户端对象。 我们可以更改我们喜欢的所有内部结构,例如使其更有效,或者通过网络以及本地访问数据,并且客户端对象仍可以使用公共接口与其进行对话,而无需进行修改。 另一方面,如果我们通过更改公共访问的属性名称或通过更改方法可以接受的参数的顺序或类型来更改接口,则也必须修改所有客户端对象。 在讨论公共接口时,请保持简单。 始终根据对象的易用性(而不是编码的难度)来设计对象的界面(此建议也适用于用户界面)。
请记住,程序对象可能代表真实的对象,但是并不能使它们成为真实的对象。 他们是模特。 建模最大的天赋之一就是能够忽略无关的细节。 我小时候建造的模型车看起来像是一辆 1956 年的雷鸟,但它没有运转,并且传动轴也没有转动。 在我开始开车之前,这些细节过于复杂且无关紧要。 该模型是真实概念的抽象。
抽象是另一个与封装和信息隐藏有关的面向对象的概念。 简而言之,抽象意味着处理最适合给定任务的细节级别。 这是从内部细节中提取公共接口的过程。 汽车驾驶员需要与转向,油门踏板和制动器互动。 电动机,传动系统和制动子系统的工作对驾驶员而言无关紧要。 另一方面,机械师则在不同的抽象水平上工作,调整引擎并释放刹车。 这是汽车的两个抽象级别的示例:
现在,我们有几个新术语引用了相似的概念。 将所有这些术语概括为两句话:抽象是使用独立的公共接口和私有接口封装信息的过程。 专用接口可能会隐藏信息。
从所有这些定义中吸取的重要教训是,使我们的模型对于其他必须与之交互的对象而言是可以理解的。 这意味着会细心注意细节。 确保方法和属性的名称合理。 分析系统时,对象通常代表原始问题中的名词,而方法通常是动词。 属性通常可以用作形容词,尽管如果属性引用的是当前对象一部分的另一个对象,则它仍可能是名词。 相应地命名类,属性和方法。
不要尝试对将来可能有用的对象或动作建模。 对系统需要执行的那些任务进行精确建模,并且设计自然会倾向于具有适当抽象级别的任务。 这并不是说我们不应该考虑将来可能进行的设计修改。 我们的设计应该是开放式的,以便可以满足将来的要求。 但是,在抽象接口时,请尝试完全建模需要建模的对象,仅此而已。
设计界面时,请尝试将自己放在对象的鞋子中,并想象该对象对隐私具有强烈的偏好。 除非您认为拥有它们符合您的最大利益,否则不要让其他对象访问有关您的数据。 除非确定您希望他们能够对您执行此操作,否则不要给他们一个界面来强迫您执行特定任务。
组成
到目前为止,我们学会了将系统设计为一组交互对象,其中每个交互都涉及在适当的抽象级别上查看对象。 但是我们还不知道如何创建这些抽象级别。 有多种方法可以做到这一点。 但是,即使大多数设计模式都依赖于两种基本的面向对象的原理,即组成和继承。 合成比较简单,因此让我们开始吧。
合成是将多个对象收集在一起以创建一个新对象的行为。 当一个对象是另一对象的一部分时,合成通常是一个不错的选择。 在机械示例中,我们已经看到了构图的第一个提示。 汽车由发动机,变速器,起动机,前大灯和挡风玻璃以及许多其他部分组成。 发动机又由活塞,曲轴和气门组成。 在此示例中,组合是提供抽象级别的好方法。 汽车对象可以提供驾驶员所需的界面,同时还可以访问其组成部分,从而提供了适合技工的更深层次的抽象。 如果技工需要更多信息来诊断问题或调整发动机,则这些零件当然可以进一步分解。
这是组成的常见入门示例,但在设计计算机系统时并不太有用。 物理对象很容易分解为组件对象。 至少从古希腊人最初假设原子是物质的最小单位开始,人们就开始这样做了(当然,他们没有机会使用粒子加速器)。 计算机系统通常不如物理对象复杂,但是在这种系统中识别组件对象并不是自然而然的事情。
面向对象系统中的对象有时代表物理对象,例如人,书或电话。 但是,它们更多时候代表抽象的想法。 人们有名字,书有书名,电话是用来打电话的。 呼叫,标题,帐户,名称,约会和付款通常不被视为物理世界中的对象,但它们都是计算机系统中经常建模的组件。
让我们尝试建模一个更加面向计算机的示例,以查看实际的合成。 我们将研究计算机化的国际象棋游戏的设计。 在 80 年代和 90 年代,这是一种非常流行的消遣方式。 人们预言,计算机将有一天能够击败人类的国际象棋大师。 当这种情况在 1997 年发生(IBM 的深蓝击败世界象棋冠军加里·卡斯帕罗夫)时,尽管在计算机和人类象棋玩家之间仍存在竞争,对这个问题的兴趣却减弱了。 (计算机通常会赢。)
作为基本的高级分析,使用棋盘组在 8 X 8 网格中包含 64 个位置,在两个玩家之间进行棋盘游戏。 棋盘可以有两组可移动的十六块棋子,由两个玩家以不同的方式交替交替进行。 每块可以取其他块。 每次转动后,将要求该板在计算机屏幕上进行绘制。
我已经使用斜体标识了描述中的某些可能对象,并使用了粗体标识了一些关键方法。 这是将面向对象的分析转变为设计的第一步。 在这一点上,为了强调构图,我们将专注于董事会,而不必过多担心球员或不同类型的棋子。
让我们从尽可能高的抽象水平开始。 我们有两个玩家轮流做出动作,与国际象棋互动:
这是什么? 它看起来不太像我们以前的类图。 那是因为它不是一个类图! 这是和对象图,也称为实例图。 它描述了处于特定时间状态的系统,并描述了对象的特定实例,而不是类之间的交互。 记住,两个玩家都是同一个班级的成员,因此班级图看起来有些不同:
该图显示,恰好有两个玩家可以与一个国际象棋互动。 这也表明任何一位玩家一次只能玩一副国际象棋。
但是,我们讨论的不是 UML 的组成,因此让我们考虑一下国际象棋集合的组成。 我们目前不在乎玩家的组成。 我们可以假设玩家具有心脏和大脑以及其他器官,但是这些与我们的模型无关。 确实,没有什么能阻止所说的球员成为深蓝的人,既没有心脏也没有大脑。
这样,国际象棋的集合由一个棋盘和 32 个棋子组成。 董事会还包括 64 个职位。 您可能会争辩说棋子不是国际象棋集合的一部分,因为您可以用另一组棋子替换国际象棋集合中的棋子。 尽管在计算机化的国际象棋版本中这不太可能或不可能,但它为我们介绍了聚合。
聚合几乎完全像一样。 区别在于聚合对象可以独立存在。 一个职位不可能与另一个棋盘相关联,因此我们说棋盘是由职位组成的。 但是据说这些棋子可能独立于国际象棋棋盘而存在,但据说与该棋盘存在着总体关系。
区分聚集和组成的另一种方法是考虑对象的寿命。 如果复合(外部)对象控制何时(HTG0)创建和销毁相关(内部)对象,则最适合使用合成。 如果相关对象是独立于复合对象创建的,或者可以使该对象持久存在,则聚合关系更有意义。 另外,请记住,组成是聚合。 聚集只是组合的一种更一般的形式。 任何复合关系也是一种聚合关系,但反之亦然。
让我们描述当前的国际象棋集合组成,并向对象添加一些属性以保持复合关系:
组成关系在 UML 中表示为实心菱形。 空心菱形代表总体关系。 您会注意到,棋盘和棋子是作为国际象棋集合的一部分存储的,与对它们的引用作为属性存储在国际象棋集合上的完全相同。 这再次表明,在实践中,一旦超过设计阶段,聚集和组成之间的区别通常就不重要了。 实施后,它们的行为几乎相同。 但是,当您的团队讨论不同对象如何交互时,它可以帮助区分两者。 通常,您可以将它们视为同一事物,但是当您需要区分它们时,很高兴知道它们之间的区别(这是工作中的抽象)。
继承
我们讨论了对象之间的三种关系类型:关联,组合和聚合。 但是,我们尚未完全指定我们的国际象棋,并且这些工具似乎没有提供我们所需的全部功能。 我们讨论了玩家可能是人类还是可能是具有人工智能功能的软件的可能性。 似乎说玩家是与人相关联的,或者说人工智能实现是玩家对象的的一部分,这似乎是不对的。 我们真正需要的是能够说“深蓝是播放器”或“加里·卡斯帕罗夫是播放器”的能力。**
是通过继承形成的关系。 继承是面向对象程序设计中最著名,最著名和过度使用的关系。 继承有点像家谱。 我祖父的姓氏是 Phillips ,父亲继承了这个名字。 我从他那里继承了它(还有蓝色的眼睛和对写作的爱好)。 在面向对象的编程中,一个类可以从另一个类继承属性和方法,而不是从一个人继承特征和行为。
例如,我们的国际象棋中有 32 个棋子,但是只有六种不同类型的棋子(棋子,车子,主教,骑士,国王和王后),每一种棋子在移动时的行为都不同。 所有这些类别的棋子都具有属性,例如颜色和它们所属的棋盘,但是在棋盘上绘制时它们也具有独特的形状,并且可以进行不同的移动。 让我们看看如何从 Piece 类继承六种类型的片断:
空心箭头表示各个类别的碎片均继承自碎片类别。 所有子类型都自动具有从基类继承的国际象棋集和颜色属性。 每一块都提供不同的形状属性(在渲染板时在屏幕上绘制),并且每转一圈都有不同的移动方法将其移动到板上的新位置。
实际上,我们知道 Piece 类的所有子类都需要具有 move 方法。 否则,当木板试图移动一块时,就会感到困惑。 我们可能想创建一个新版本的国际象棋游戏,其中又增加了一块(向导)。 我们当前的设计允许我们在不使用 move 方法的情况下设计该作品。 当董事会要求棋子移动时,棋盘就会窒息。
我们可以通过在 Piece 类上创建一个伪移动方法来实现这一点。 然后,子类可以使用更具体的实现来覆盖此方法。 例如,默认实现可能会弹出一条错误消息:该部件无法移动。
子类型中的重写方法允许开发非常强大的面向对象的系统。 例如,如果我们想用人工智能实现一个玩家类,我们可以提供一个calculate_move方法,该方法采用 Board 对象并决定将哪一块移动到哪里。 一个非常基础的班级可能会随机选择一个片段和一个方向并相应地移动它。 然后,我们可以使用 Deep Blue 实现在子类中重写此方法。 头等舱适合与未加工的初学者对抗,后者将挑战大师级。 重要的是,该类中的其他方法(例如,通知董事会有关选择了哪个动作的方法)无需更改; 该实现可以在两个类之间共享。
对于国际象棋棋子,提供 move 方法的默认实现实际上没有任何意义。 我们需要做的就是指定所有子类都需要 move 方法。 这可以通过使用声明为抽象的 move 方法使 Piece 成为抽象类来实现。 抽象方法基本上说:“我们要求此方法存在于任何非抽象子类中,但我们拒绝在此类中指定实现。”
实际上,可以创建一个根本不实现任何方法的类。 这样的课程只会告诉我们该课程应该做什么,但绝对不提供有关如何操作的建议。 在面向对象的说法中,此类称为接口。
继承提供抽象
让我们探讨面向对象的 Argot 中最长的单词。 多态性是根据实现的子类来区别对待类的能力。 我们已经在所描述的 Pieces 系统中看到了。 如果进一步进行设计,我们可能会看到开发板对象可以接受来自播放器的移动,并在该棋子上调用移动功能。 董事会不必知道要处理的是哪种类型。 它所要做的只是调用 move 方法,适当的子类将把它作为 Knight 或 Pawn 来处理。
多态性很酷,但是这个词在 Python 编程中很少使用。 Python 超越了一个额外的步骤,允许将对象的子类视为父类。 用 Python 实现的板可以采用具有移动方法的任何对象,无论是主教块,汽车还是鸭子。 当移动时,Bishop 将在板上沿对角线移动,汽车将行驶到某个位置,而鸭子将根据其心情游泳或飞行。
Python 中这种类型的多态性通常称为鸭子键入:“如果它走路像鸭子一样游泳,或者像鸭子一样游泳,那就是鸭子”。 我们不在乎它是否真的是是鸭(继承),只是它会游泳或走路。 鹅和天鹅可能很容易提供我们想要的鸭子般的行为。 这使未来的设计人员可以创建新的鸟类类型,而无需实际指定水生鸟类的继承层次。 它还使他们能够创建完全不同于原始设计人员计划的插入行为。 例如,未来的设计师可能能够制作出具有相同界面的步行游泳企鹅,而不会暗示企鹅是鸭子。
多重继承
当我们想到我们自己的家族树中的继承时,我们可以看到我们从多个父母那里继承了特征。 当陌生人告诉一个骄傲的母亲她的儿子有“他的父亲的眼睛”时,她通常会回答“是的,但他让我流鼻涕”。
面向对象的设计还可以具有多重继承的功能,它允许子类从多个父类继承功能。 实际上,多重继承可能是一件棘手的事情,并且某些编程语言(最著名的是 Java)严格禁止这样做。 但是,多重继承可以有其用途。 通常,它可用于创建具有两组不同行为的对象。 例如,可以通过从两个单独的scanner和faxer对象继承来创建旨在连接到扫描仪并发送扫描文档传真的对象。
只要两个类具有不同的接口,子类从这两个类继承通常不会有害。 但是,如果我们从提供重叠接口的两个类继承,则会变得混乱。 例如,如果我们有一个具有move方法的摩托车类,并且也具有move方法的船类,并且想要将它们合并到最终的两栖车辆中,那么所得的类如何知道该怎么做 当我们叫move时? 在设计级别,需要对此进行解释,在实现级别,每种编程语言都有不同的方式来决定调用哪个父类的方法或以什么顺序进行调用。
通常,处理它的最好方法是避免它。 如果您的设计如此显示,则可能是做错了。 退后一步,再次分析系统,看看是否可以删除多继承关系,而采用其他关联或复合设计。
继承是扩展行为的强大工具。 与早期的范例相比,它也是面向对象设计最可销售的进步之一。 因此,它通常是面向对象的程序员所追求的第一个工具。 但是,重要的是要认识到,拥有锤子不会将螺钉变成钉子。 继承是完美的解决方案,因为显然是关系,但是可以滥用。 程序员经常使用继承在两种对象之间共享代码,而这两种对象之间只有很远的关系,而与的关系就不存在了。 尽管这不一定是一个糟糕的设计,但它是一个极好的机会,询问他们为什么决定以这种方式设计,以及使用其他关系或设计模式是否更合适。
案例研究
让我们通过在一个真实世界的示例上进行几次面向对象设计的迭代,将的所有新的面向对象知识结合在一起。 我们将要建模的系统是一个图书馆目录。 几个世纪以来,图书馆一直在追踪其库存,最初使用的是卡片目录,最近使用的是电子清单。 现代图书馆有基于网络的目录,我们可以在家里查询。
让我们从分析开始。 当地的图书管理员要求我们编写一个新的卡片目录程序,因为他们古老的基于 DOS 的程序丑陋且已过时。 这不会给我们太多的细节,但是在我们开始寻求更多信息之前,让我们考虑一下我们已经了解的关于库目录的知识。
目录包含书籍清单。 人们搜索它们以查找具有特定标题或特定作者的某些主题的书籍。 可以通过国际标准书号(ISBN)来唯一标识书籍。 每个模块都有一个杜威十进制系统(DDS)编号,以帮助您在特定的机架上找到它。
这个简单的分析告诉我们系统中一些明显的对象。 我们迅速将图书识别为最重要的对象,并已经提到了几个属性,例如作者,书名,主题,ISBN 和 DDS 编号,以及目录是图书的管理者。
我们还注意到可能需要或不需要在系统中建模的其他一些对象。 为了进行分类,我们需要按作者搜索模块的就是模块上的author_name属性。 但是,作者也是对象,我们可能要存储有关作者的其他数据。 在思考这一点时,我们可能还记得有些书有多位作者。 突然之间,在对象上具有单个author_name属性的想法似乎有些愚蠢。 与每个模块相关的作者列表显然是一个更好的主意。
作者和模块之间的关系显然是关联的,因为您永远不会说“模块是作者”(这不是继承),并且说“模块有作者”,尽管在语法上是正确的,但这并不意味着作者是其中的一部分 书(不是汇总)。 实际上,任何一位作者可能与多本书相关联。
我们还应注意名词(名词总是对象的良好候选者)架子。 架子是需要在编目系统中建模的对象吗? 我们如何识别单个架子? 如果某个模块存储在一个架子的末尾,然后又由于在上一个架子中插入了另一个模块而又移到下一个架子的开头,会发生什么情况?
DDS 旨在帮助在图书馆中查找实体书籍。 因此,与模块一起存储 DDS 属性应该足以找到它,而不管它存储在哪个架子上。 因此,至少在目前,我们可以从竞争对象列表中删除货架。
系统中的另一个可疑对象是用户。 我们是否需要了解有关特定用户的任何信息,例如其名称,地址或过期书籍列表? 到目前为止,图书馆员只告诉我们他们想要目录。 他们对跟踪订阅或过期通知一无所知。 在我们的内心深处,我们还注意到作者和用户都是特定的人。 将来可能会有有用的继承关系。
为了进行分类,我们决定暂时不需要识别用户。 我们可以假设用户将搜索目录,但是除了提供一个允许他们搜索的界面之外,我们不必在系统中对其进行建模。
我们已经在模块上确定了一些属性,但是目录具有哪些属性? 有没有一个图书馆有多个目录? 我们需要唯一地标识它们吗? 显然,目录必须以某种方式包含其中包含的书籍的集合,但是此列表可能不是,也不是公共接口的一部分。
行为呢? 目录显然需要一种搜索方法,可能需要针对作者,标题和主题的搜索方法。 书上有行为吗? 需要预览方法吗? 还是可以通过首页属性而不是方法来标识预览?
前面讨论中的问题都是面向对象分析阶段的一部分。 但是与这些问题混杂在一起,我们已经确定了设计中的一些关键对象。 确实,您刚刚看到的是分析和设计之间的一些微迭代。
这些迭代很可能都在与图书馆员的初次会面中发生。 但是,在这次会议之前,我们已经可以为我们具体确定的对象设计出最基本的设计:
有了这个基本图表和一支铅笔来进行交互式改进,我们与图书馆员会面。 他们告诉我们,这是一个好的开始,但是图书馆不仅提供书籍,还提供 DVD,杂志和 CD,而这些书都没有 ISBN 或 DDS 编号。 但是,所有这些类型的项目都可以通过 UPC 编号唯一标识。 我们提醒馆员,他们必须在架子上找到物品,而这些物品可能不是 UPC 整理的。 馆员解释说,每种类型的组织方式都不同。 CD 大多是有声读物,库存只有几十张,因此按作者的姓氏组织。 DVD 分为各种类型,并按标题进一步组织。 杂志按标题分类,然后按卷数和发行号进行细化。 正如我们猜想的那样,书籍是按 DDS 编号整理的。
没有以前的面向对象设计经验,我们可能会考虑将 DVD,CD,杂志和书籍的单独列表添加到我们的目录中,然后依次搜索每个列表。 麻烦的是,除了某些扩展属性之外,并标识项目的物理位置,这些项目的行为都一样。 这是继承的工作! 我们快速更新我们的 UML 图:
图书馆员理解我们的示意图的要点,但对定位功能有些困惑。 我们使用一个特定的用例来解释,其中用户正在搜索单词“ bunnies”。 用户首先将搜索请求发送到目录。 目录查询其内部项目列表,并找到标题中带有“ bunnies”的模块和 DVD。 此时,目录并不关心目录中是否装有 DVD,模块,CD 或杂志。 就目录而言,所有项目都是相同的。 但是,用户想知道如何查找实际项目,因此如果仅返回标题列表,则该目录将被删除。 因此,它在已发现的两个项目上调用定位方法。 模块的定位方法返回一个 DDS 编号,该编号可用于查找容纳模块的架子。 通过返回 DVD 的类型和标题来定位 DVD。 然后,用户可以访问 DVD 部分,找到包含该类型的部分,并找到按标题排序的特定 DVD。
正如我们所解释的,我们绘制了一个 UML 序列图的示意图,解释了各种对象如何通信:
其中,类图描述了类之间的关系,而序列图描述了对象之间传递的特定消息序列。 悬挂在每个对象上的虚线是生命线,用于描述对象的生存期。 每条生命线上的较宽方框表示该对象中的活动处理(没有方框的情况下,该对象基本上处于空闲状态,等待发生的事情)。 生命线之间的水平箭头表示特定的消息。 实线箭头表示被调用的方法,而带实心头的虚线箭头表示方法的返回值。
半箭头指示发送到对象或从对象发送的异步消息。 异步消息通常意味着第一个对象在第二个对象上调用一个方法,该方法立即返回。 经过一些处理后,第二个对象在第一个对象上调用一个方法来为其赋予一个值。 这与常规方法调用形成对照,后者在方法中进行处理并立即返回一个值。
像所有 UML 图一样,序列图最好仅在需要时使用。 为了绘制图表,在中绘制 UML 图毫无意义。 但是,当您需要在两个对象之间进行一系列交互时,顺序图是一个非常有用的工具。
不幸的是,到目前为止,我们的类图仍然是一个混乱的设计。 我们注意到 DVD 上的演员和 CD 上的艺术家都是各种类型的人,但是与模块作者的待遇不同。 馆员还提醒我们,它们的大多数 CD 是有声读物,有作者而不是艺术家。
我们如何与交往促成头衔的各种人士? 一个明显的实现是使用人物的姓名和其他相关细节创建一个Person类,然后为艺术家,作家和演员创建该子类。 但是,在这里继承真的必要吗? 出于搜索和分类目的,我们并不在乎表演和写作是两个截然不同的活动。 如果我们正在进行经济模拟,则可以给定演员和作者类,以及不同的calculate_income和perform_job方法,这很有意义,但是出于分类目的,知道这个人如何对物品做出贡献可能就足够了。 我们认识到所有项目都有一个或多个Contributor对象,因此我们将作者关系从模块移至其父类:
贡献者 / LibraryItem 关系的多重性是多对多,如两端的 ***** 字符所示 关系。 任何一个图书馆项目都可能有多个贡献者(例如,DVD 中的几个演员和一个导演)。 许多作者写了许多书,因此它们会附加到多个图书馆项目中。
尽管的微小更改看上去更简洁,更简单,但是却丢失了一些重要信息。 我们仍然可以确定是谁为特定库项目做出了贡献,但是我们不知道他们是如何做出贡献的。 他们是导演还是演员? 他们是写音频模块,还是为模块叙述的声音?
如果我们只需在贡献者类上添加contributor_type属性,那将是很好的选择,但是在与既创作书籍又执导电影的多才多艺的人打交道时,这种方法将分崩离析。
一种选择是向每个 LibraryItem 子类添加属性,以保存我们所需的信息,例如图书上的作者或 Artist ]放在 CD 上,然后使与这些属性的关系都指向 Contributor 类。 问题在于,我们失去了很多多态的优雅。 如果要列出某个项目的贡献者,则必须寻找该项目的特定属性,例如作者或 Actors。 我们可以通过在子类可以覆盖的 LibraryItem 类上添加 GetContributors 方法来缓解这种情况。 这样,目录就不必知道对象正在查询什么属性。 我们已经抽象了公共接口:
仅查看这个类图,就好像我们在做错什么。 它又大又脆弱。 它可以满足我们的所有需求,但感觉很难维护或扩展。 关系太多,修改任何一个类别都会影响太多的类别。 看起来像意大利面条和肉丸。
现在,我们已经探讨了将继承作为一种选择,并发现了它的需要,我们可以回顾一下以前的基于组合的图表,其中贡献者直接附加到 LibraryItem 上。 经过一番思考,我们可以看到我们实际上只需要向一个全新的类添加一个关系即可确定贡献者的类型。 这是面向对象设计中的重要一步。 现在,我们在设计中添加了一个类,该类旨在支持其他对象,而不是对初始需求的任何部分进行建模。 我们正在重构设计,以简化系统中的对象,而不是现实生活中的对象。 重构是程序或设计维护中必不可少的过程。 重构的目的是通过移动代码,删除重复的代码或复杂的关系来改进设计,以支持更简单,更优雅的设计。
这个新的类由贡献者和一个额外的属性组成,该属性标识人员对给定的 LibraryItem 做出的贡献的类型。 一个特定的 LibraryItem 可以有很多这样的贡献,并且一个贡献者可以以相同的方式为不同的条目做出贡献。 该图很好地传达了这种设计:
首先,这种组合关系看起来不如基于继承的关系自然。 但是,它的优点是允许我们添加新的贡献类型而无需在设计中添加新的类。 当子类具有某种专长时,继承最有用。 专业化是在子类上创建或更改属性或行为,以使其与父类有所不同。 创建一堆只用于识别不同类型对象的空类似乎很愚蠢(这种态度在 Java 和其他“一切都是对象”程序员中并不普遍,但在更实际的 Python 设计人员中很常见)。 如果我们看一下图的继承版本,我们可以看到一堆实际上不做任何事情的子类:
有时,识别何时不使用面向对象的原理很重要。 这个何时不使用继承的示例很好地提醒了对象只是工具,而不是规则。
三、Python 对象
因此,我们现在手头有一个设计,可以将其变成可行的程序了! 当然,这种情况通常不会发生。 我们将在整个模块中看到有关良好软件设计的示例和提示,但我们的重点是面向对象的编程。 因此,让我们看一下允许我们创建面向对象软件的 Python 语法。
完成本章后,我们将了解:
- 如何在 Python 中创建类和实例化对象
- 如何向 Python 对象添加属性和行为
- 如何将类组织成包和模块
- 如何建议人们不要破坏我们的数据
创建 Python 类
我们没有编写太多 Python 代码来意识到 Python 是一种非常“干净”的语言。 当我们想做某事时,我们只需要做,而无需进行大量设置。 您可能已经看到,Python 中无处不在的“ hello world”仅仅是一行。
同样,Python 3 中最简单的类如下所示:
class MyFirstClass:
pass
这是我们第一个面向对象的程序! 类定义以class关键字开头。 这之后是(由我们选择的)标识类的名称,并以冒号结尾。
注意
类名必须遵循标准的 Python 变量命名规则(它必须以字母或下划线开头,并且只能由字母,下划线或数字组成)。 此外,Python 样式指南(在网络上搜索“ PEP 8”)建议使用 CamelCase 表示法将类命名为(以大写字母开头;任何后续单词也应以 首都)。
在类定义行之后紧跟着类内容。 与其他 Python 构造一样,缩进用于分隔类,而不是许多其他语言使用的花括号或方括号。 除非有令人信服的理由,否则使用四个空格进行缩进(例如,与使用制表符作为缩进的其他人的代码相适应)。 只要按下选项卡键,任何体面的程序编辑器都可以配置为插入四个空格。
由于我们的第一堂课实际上什么也没做,因此我们仅在第二行使用pass关键字来表示不需要采取进一步的措施。
我们可能认为这个最基本的类没有什么可以做的,但是它确实允许我们实例化该类的对象。 我们可以将类加载到 Python 3 解释器中,以便我们可以交互地进行播放。 为此,将前面提到的类定义保存到名为first_class.py的文件中,然后运行命令python -i first_class.py。 -i参数告诉 Python“运行代码,然后放到交互式解释器中”。 以下解释器会话演示了与此类的基本交互:
>>> a = MyFirstClass()
>>> b = MyFirstClass()
>>> print(a)
<__main__.MyFirstClass object at 0xb7b7faec>
>>> print(b)
<__main__.MyFirstClass object at 0xb7b7fbac>
>>>
此代码从新类实例化了两个对象,分别名为a和b。 创建类的实例很简单,只需键入类名,然后输入一对括号即可。 它看起来很像一个普通的函数调用,但是 Python 知道我们是在“调用”一个类而不是一个函数,因此它知道它的工作是创建一个新对象。 当打印时,这两个对象告诉我们它们是哪个类,以及它们住在什么内存地址。 内存地址在 Python 代码中使用不多,但是在这里,它们表明涉及两个不同的对象。
添加属性
现在,我们有了一个基本类,但是它毫无用处。 它不包含任何数据,并且不执行任何操作。 要为给定对象分配属性,我们该怎么做?
事实证明,我们不必在类定义中做任何特别的事情。 我们可以使用点表示法在实例化对象上设置任意属性:
class Point:
pass
p1 = Point()
p2 = Point()
p1.x = 5
p1.y = 4
p2.x = 3
p2.y = 6
print(p1.x, p1.y)
print(p2.x, p2.y)
如果运行此代码,最后的两个print语句将告诉我们两个对象的新属性值:
5 4
3 6
此代码创建一个空的Point类,不包含任何数据或行为。 然后,它创建该类的两个实例,并为每个实例分配x和y坐标以标识二维点。 为对象的属性分配值所需要做的就是使用 <对象>。 <属性> = <值> 语法。 有时将其称为点表示法。 该值可以是任何值:Python 原语,内置数据类型或其他对象。 它甚至可以是一个函数或另一个类!
让它做某事
现在,具有属性的对象很棒,但是面向对象的编程实际上是关于对象之间的交互的。 我们对调用导致这些属性发生事情的操作感兴趣。 现在是时候将行为添加到我们的类中了。
让我们在Point类上模拟几个动作。 我们可以从名为reset的方法开始,该方法将点移动到原点(原点是x和y均为零的点)。 这是一个很好的介绍性操作,因为它不需要任何参数:
class Point:
def reset(self):
self.x = 0
self.y = 0
p = Point()
p.reset()
print(p.x, p.y)
此print语句向我们显示了属性上的两个零:
0 0
Python 中的方法与函数的格式相同。 它以关键字def开头,后跟一个空格和方法名称。 接下来是一组包含参数列表的括号(我们稍后将讨论self参数),并以冒号结尾。 下一行缩进以包含方法内部的语句。 这些语句可以是在对象本身上操作的任意 Python 代码,以及在方法认为合适时传递的任何参数。
自言自语
方法和普通函数之间的区别是所有方法都有一个必填参数。 这个参数通常被命名为self。 我从未见过程序员为此变量使用任何其他名称(约定是非常强大的功能)。 但是,没有什么可以阻止您将其称为this甚至Martha。
方法的 self参数只是对该方法在其上调用的对象的引用。 我们可以像访问其他对象一样访问该对象的属性和方法。 这正是我们在设置self对象的x和y属性时在reset方法中所做的事情。
注意,当我们调用p.reset()方法时,不必将self参数传递给它。 Python 自动为我们解决了这一问题。 它知道我们正在p对象上调用方法,因此它将自动将该对象传递给该方法。
但是,方法实际上只是一个碰巧在类上的函数。 除了在对象上调用方法之外,我们还可以在类上调用函数,将我们的对象作为self参数显式传递:
p = Point()
Point.reset(p)
print(p.x, p.y)
输出与前面的示例相同,因为在内部,发生了完全相同的过程。
如果我们忘记在类定义中包含self参数,会发生什么? Python 将保释并显示一条错误消息:
>>> class Point:
... def reset():
... pass
...
>>> p = Point()
>>> p.reset()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: reset() takes no arguments (1 given)
错误消息可能不那么清晰(“傻瓜,您忘记了self参数”会提供更多信息)。 只要记住,当您看到指示缺少参数的错误消息时,首先要检查的是您是否在方法定义中忘记了self。
更多参数
那么,如何将多个参数传递给方法? 让我们添加一个新方法,该方法允许我们将点移动到任意位置,而不仅仅是原点。 我们还可以包含一个接受另一个Point对象作为输入并返回它们之间的距离的对象:
import math
class Point:
def move(self, x, y):
self.x = x
self.y = y
def reset(self):
self.move(0, 0)
def calculate_distance(self, other_point):
return math.sqrt(
(self.x - other_point.x)**2 +
(self.y - other_point.y)**2)
# how to use it:
point1 = Point()
point2 = Point()
point1.reset()
point2.move(5,0)
print(point2.calculate_distance(point1))
assert (point2.calculate_distance(point1) ==
point1.calculate_distance(point2))
point1.move(3,4)
print(point1.calculate_distance(point2))
print(point1.calculate_distance(point1))
最后的print语句为我们提供以下输出:
5.0
4.472135955
0.0
在这里发生了很多事情。 该类现在具有三种方法。 move方法接受x和y这两个参数,并在self对象上设置值,就像上一个示例中的旧reset方法一样。 现在,旧的reset方法调用move,因为重置只是向特定已知位置的移动。
calculate_distance方法使用不太复杂的勾股定理来计算两点之间的距离。 希望您理解数学(**2表示平方,math.sqrt计算平方根),但这不是我们当前关注的,学习如何编写方法的要求。
上一示例末尾的示例代码显示了如何使用参数调用方法:只需在括号内包含参数,然后使用相同的点符号即可访问该方法。 我只是选择了一些随机位置来测试方法。 测试代码调用每种方法,并将结果打印在控制台上。 assert功能是一个简单的测试工具; 如果assert之后的语句为False(或零,空或None),则程序将失败。 在这种情况下,无论哪个点称为另一点的calculate_distance方法,我们都使用它来确保距离是相同的。
初始化对象
如果我们没有使用move或直接访问它们,而没有明确在我们的Point对象上设置x和y位置,则我们会出现一个断点,没有实际位置。 当我们尝试访问它时会发生什么?
好吧,让我们尝试一下看看。 “尝试一下,看看”是 Python 学习中非常有用的工具。 打开您的交互式解释器,然后键入。 以下交互式会话显示了如果我们尝试访问缺少的属性会发生什么。 如果您将先前的示例另存为文件或正在使用该模块随附的示例,则可以使用命令python -i filename.py将其加载到 Python 解释器中:
>>> point = Point()
>>> point.x = 5
>>> print(point.x)
5
>>> print(point.y)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Point' object has no attribute 'y'
好吧,至少它抛出了一个有用的例外。 我们将在第 4 章,“异常”中详细介绍异常。 您可能之前已经看过它们(尤其是普遍存在的 SyntaxError,意味着您键入的内容不正确!)。 此时,只需注意这意味着出了点问题。
输出对于调试很有用。 在交互式解释器中,它告诉我们在第 1 行发生的错误,该错误仅部分正确(在交互式会话中,一次仅执行一行)。 如果我们在文件中运行脚本,它将告诉我们确切的行号,从而很容易找到有问题的代码。 此外,它告诉我们错误是AttributeError,并给出了有用的信息,告诉我们该错误意味着什么。
我们可以捕获并从此错误中恢复,但是在这种情况下,感觉就像我们应该指定某种默认值一样。 也许每个新对象在默认情况下都应为reset(),或者如果我们可以强迫用户告诉我们在创建对象时这些位置应该是什么,那就更好了。
大多数面向对象的编程语言都具有构造函数的概念,这是一种特殊的方法,可在创建对象时创建并初始化该对象。 Python 有点不同; 它有一个构造函数和一个初始化程序。 除非您要执行某些操作,否则很少使用构造函数。 因此,我们将从初始化方法开始讨论。
Python 初始化方法与其他任何方法都相同,只是它有一个特殊的名称__init__。 前导和尾随的双下划线表示这是一种特殊的方法,Python 解释器会将其视为特殊情况。
注意
切勿使用前后双下划线来命名自己的函数。 对于 Python 而言,这可能毫无意义,但 Python 的设计人员将来总有可能会添加一个具有特定用途的功能,并且使用该名称时,您的代码将被破坏。
让我们从Point类上的初始化函数开始,该函数要求用户在实例化Point对象时提供x和y坐标:
class Point:
def __init__(self, x, y):
self.move(x, y)
def move(self, x, y):
self.x = x
self.y = y
def reset(self):
self.move(0, 0)
# Constructing a Point
point = Point(3, 5)
print(point.x, point.y)
现在,我们的观点永远离不开y坐标! 如果我们尝试在不包含适当初始化参数的情况下构造一个点,则它将失败,并出现参数不足错误,这与我们先前忘记self参数时收到的错误相似。
如果我们不想将这两个参数都设为必需,该怎么办? 好了,那么我们可以使用 Python 函数提供默认参数的相同语法。 关键字参数语法在每个变量名称后附加一个等号。 如果调用对象不提供此参数,则使用默认参数。 变量仍可用于该函数,但它们将具有在参数列表中指定的值。 这是一个例子:
class Point:
def __init__(self, x=0, y=0):
self.move(x, y)
在的大部分时间里,我们将初始化语句放在__init__函数中。 但是如前所述,Python 除了其初始化函数外,还具有构造函数。 您可能永远不需要使用其他 Python 构造函数,但这有助于了解它的存在,因此我们将简要介绍它。
相对于__init__,构造函数称为__new__,并且只接受一个参数。 所构造的类(在构造对象的之前将其称为*,因此没有self参数)。 它还必须返回新创建的对象。 当涉及到元编程的复杂技术时,这具有有趣的可能性,但在日常编程中不是很有用。 在实践中,几乎不需要使用__new__,并且__init__就足够了。*
自我解释
Python 是一种非常易于阅读的编程语言。 有人可能会说这是自我记录。 但是,在进行面向对象的编程时,编写 API 文档以清楚地总结每个对象和方法的工作非常重要。 使文档保持最新是很困难的。 最好的方法是将其正确写入我们的代码中。
Python 通过使用文档字符串支持此功能。 每个类,函数或方法标头都可以将标准 Python 字符串作为定义之后的第一行(以冒号结尾的行)。 该行的缩进应与以下代码相同。
Docstrings 只是用撇号(')或引号(")括起来的 Python 字符串。 通常,文档字符串很长,并且跨越多行(样式指南建议该行的长度不应超过 80 个字符),可以将其格式化为多行字符串,并用匹配的三撇号(''')或三引号( """)字符。
文档字符串应清楚,简洁地概述其描述的类或方法的目的。 它应该说明使用不立即明显的任何参数,也是包含如何使用 API的简短示例的好地方。 毫无疑问的 API 用户应该注意的任何警告或问题。
为了说明文档字符串的用法,我们将在本节结尾以我们完整记录的Point类:
import math
class Point:
'Represents a point in two-dimensional geometric coordinates'
def __init__(self, x=0, y=0):
'''Initialize the position of a new point. The x and y coordinates can be specified. If they are not, the
point defaults to the origin.'''
self.move(x, y)
def move(self, x, y):
"Move the point to a new location in 2D space."
self.x = x
self.y = y
def reset(self):
'Reset the point back to the geometric origin: 0, 0'
self.move(0, 0)
def calculate_distance(self, other_point):
"""Calculate the distance from this point to a second
point passed as a parameter.
This function uses the Pythagorean Theorem to calculate the distance between the two points. The distance is
returned as a float."""
return math.sqrt(
(self.x - other_point.x)**2 +
(self.y - other_point.y)**2)
尝试在交互式解释器中键入或加载(请记住,它是python -i filename.py)此文件。 然后,在 Python 提示符下输入help(Point)<enter>。 您应该可以很好地看到该类的格式文档,如以下屏幕截图所示:
模块和软件包
现在,我们知道了如何创建类和实例化对象,但是我们如何组织它们呢? 对于小型程序,我们可以将所有类放入一个文件中,并在文件末尾添加一个小脚本以启动它们的交互。 但是,随着项目的发展,在我们定义的许多类中很难找到一个需要编辑的类。 这是模块进入的地方。模块只是 Python 文件,仅此而已。 我们的小程序中的单个文件是一个模块。 两个 Python 文件是两个模块。 如果我们在同一文件夹中有两个文件,则可以从一个模块加载一个类,以在另一个模块中使用。
例如,如果我们正在构建一个电子商务系统,则可能会将大量数据存储在数据库中。 我们可以将所有与数据库访问相关的类和函数放在一个单独的文件中(我们称其为database.py)。 然后,我们的其他模块(例如,客户模型,产品信息和库存)可以从该模块导入类以访问数据库。
import语句用于从模块导入模块或特定的类或函数。 在上一节的Point类中,我们已经看到了这样的示例。 我们使用import语句获取 Python 的内置math模块,并在distance计算中使用其sqrt函数。
这是一个具体示例。 假设我们有一个名为database.py的模块,其中包含一个名为Database的类,以及另一个名为products.py的模块,该模块负责与产品相关的查询。 在这一点上,我们不需要考虑这些文件的内容。 我们所知道的是,products.py需要从database.py实例化Database类,以便它可以对数据库中的产品表执行查询。
import语句语法有多种变体,可用于访问该类:
import database
db = database.Database()
# Do queries on db
该版本将database模块导入到products名称空间(模块或函数中当前可访问的名称列表),因此可以使用database.<something>表示法访问database模块中的任何类或函数。 另外,我们可以使用from ... import语法仅导入所需的一个类:
from database import Database
db = Database()
# Do queries on db
如果由于某种原因products已经有一个名为Database的类,并且我们不想混淆这两个名称,则可以在products模块中使用该类时重命名该类:
from database import Database as DB
db = DB()
# Do queries on db
我们还可以在一个语句中导入多个项目。 如果我们的database模块还包含Query类,则可以使用以下两种方法导入这两个类:
from database import Database, Query
一些消息来源说,我们可以使用以下语法从database模块导入所有类和函数:
from database import *
不要这样 每个有经验的 Python 程序员都会告诉您,永远不要使用此语法。 他们将使用晦涩的理由,例如“它弄乱了名称空间”,这对于初学者来说没有太大意义。 了解为什么避免使用这种语法的一种方法是使用它,并在两年后尝试理解您的代码。 但是,现在我们可以快速解释一下,从而节省一些时间和两年编写不佳的代码!
当我们使用from database import Database在文件顶部显式导入database类时,我们可以轻松地查看Database类的来源。 我们可能会在文件的后面使用db = Database() 400 行,我们可以快速查看导入内容,以查看Database类的来源。 然后,如果需要澄清如何使用Database类,则可以访问原始文件(或在交互式解释器中导入模块并使用help(database.Database)命令)。 但是,如果使用from database import *语法,则查找该类的位置会花费更长的时间。 代码维护成为一场噩梦。
此外,如果使用常规导入,大多数编辑器都可以提供额外的功能,例如可靠的代码完成,跳转到类的定义或内联文档的功能。 import *语法通常会完全破坏其可靠执行此操作的能力。
最后,使用import *语法可以将意外对象带入我们的本地名称空间。 当然,它将导入要从中导入的模块中定义的所有类和函数,但是还将导入本身已导入到该文件中的所有类或模块!
模块中使用的每个名称都应来自明确指定的位置,无论是在该模块中定义的名称,还是从另一个模块显式导入的名称。 应该没有凭空浮出水面的魔术变量。 我们应该始终能够立即识别当前名称空间中名称的起源。 我保证,如果您使用这种邪恶的语法,那么您将有一天会经历极其沮丧的时刻:“该类来自何处?”。
整理模块
随着项目成长为越来越多的模块的集合,我们可能会发现我们想添加另一个抽象级别,即模块级别上的某种嵌套层次结构。 但是,我们不能将模块放在模块内部; 一个文件毕竟只能容纳一个文件,而模块无非就是 Python 文件。
但是,文件可以放在文件夹中,而模块也可以。 包是文件夹中模块的集合。 程序包的名称是文件夹的名称。 我们要告诉 Python 文件夹是一个包的所有工作,就是将一个文件(通常为空)放在名为__init__.py的文件夹中。 如果我们忘记了该文件,将无法从该文件夹导入模块。
让我们将模块放入工作文件夹中的ecommerce包中,其中还将包含main.py文件以启动程序。 让我们另外在ecommerce包中添加另一个包,以获取各种付款方式。 文件夹层次结构如下所示:
parent_directory/
main.py
ecommerce/
__init__.py
database.py
products.py
payments/
__init__.py
square.py
stripe.py
在包之间导入模块或类时,我们必须谨慎使用语法。 在 Python 3 中,有两种导入模块的方法:绝对导入和相对导入。
绝对进口
绝对导入指定到要导入的模块,功能或路径的完整路径。 如果需要访问products模块内部的Product类,则可以使用以下任何一种语法进行绝对导入:
import ecommerce.products
product = ecommerce.products.Product()
或者
from ecommerce.products import Product
product = Product()
或者
from ecommerce import products
product = products.Product()
import语句使用句点运算符分隔包或模块。
这些语句可在任何模块中使用。 我们可以在main.py,database模块或两个支付模块中的任何一个中使用此语法实例化Product类。 确实,假设这些软件包可用于 Python,它将能够导入它们。 例如,也可以将软件包安装到 Python 站点的 packages 文件夹中,或者可以自定义PYTHONPATH环境变量以动态告诉 Python 要搜索要导入的软件包和模块的文件夹。
那么,通过这些选择,我们选择哪种语法? 这取决于您的个人品味和手头的应用。 如果要在products模块中使用许多类和函数,则通常会使用from ecommerce import products语法导入模块名称,然后使用products.Product访问各个类。 如果我只需要products模块中的一两个类,则可以使用from ecommerce.proucts import Product语法直接导入它们。 除非我遇到某种名称冲突,否则我个人不会经常使用第一种语法(例如,我需要访问两个名为products的完全不同的模块,并且需要将它们分开)。 尽您所能使代码看起来更优雅。
相对进口
在将与相关模块一起使用时,指定完整路径似乎很愚蠢; 我们知道父模块的名称。 这是相对导入出现的地方。相对导入基本上是一种表达的方式,它表示相对于当前模块定位的类,函数或模块。 例如,如果我们正在使用products模块,并且想从其旁边的database模块导入Database类,则可以使用相对导入:
from .database import Database
database前面的句号为“使用当前包内的数据库模块”。 在这种情况下,当前软件包是包含我们当前正在编辑的products.py文件的软件包,即ecommerce软件包。
如果我们正在编辑ecommerce.payments包内的paypal模块,我们想说“使用父包内的数据库包”。 只需两个周期即可轻松完成,如下所示:
from ..database import Database
我们可以使用更多的时间来进一步提高层次结构。 当然,我们也可以一侧向下,另一侧向上。 我们没有足够深入的示例层次结构来正确地说明这一点,但是如果我们有一个包含email模块的ecommerce.contact包并且想将send_mail函数导入到我们的paypal中,则以下内容是有效的导入 ] 模块:
from ..contact.email import send_mail
此导入使用两个句点来表示付款包的父级,然后使用正常的package.module语法将返回到联系包中 。
最后,我们可以直接从包中导入代码,而不仅仅是包中的模块。 在此示例中,我们有一个ecommerce包,其中包含两个名为database.py和products.py的模块。 数据库模块包含一个db变量,可以从很多地方访问该变量。 如果可以将其导入为import ecommerce.db而不是import ecommerce.database.db会不会很方便?
还记得将目录定义为包的__init__.py文件吗? 该文件可以包含我们喜欢的任何变量或类声明,它们将作为软件包的一部分提供。 在我们的示例中,如果ecommerce/__init__.py文件包含以下行:
from .database import db
然后,我们可以使用此导入从main.py或任何其他文件访问db属性:
from ecommerce import db
如果__init__.py文件是模块而不是包,则将其视为ecommerce.py文件可能会有所帮助。 如果将所有代码放在一个模块中,然后又决定将其分解为模块包,则这也很有用。 新程序包的__init__.py文件仍然可以是与其通讯的其他模块的主要联系点,但是该代码可以在内部组织为几个不同的模块或子程序包。
但是,我建议不要将所有代码都放在__init__.py文件中。 程序员不希望该文件中发生实际的逻辑,并且与from x import *一样,如果程序员正在寻找特定代码的声明,并且直到检查__init__.py时才能找到它,它可能使它们跳闸。 。
组织模块内容
在任何一个模块中,我们都可以指定变量,类或函数。 它们可以是一种方便的方式来存储全局状态而不会导致名称空间冲突。 例如,我们已经将Database类导入到各种模块中,然后实例化它,但是从database模块中全局仅提供一个database对象可能更有意义。 database模块可能如下所示:
class Database:
# the database implementation
pass
database = Database()
然后,我们可以使用我们讨论的任何导入方法来访问database对象,例如:
from ecommerce.database import database
上一类的问题是,database对象是在首次导入模块时立即创建的,通常是在程序启动时创建的。 这并不总是理想的,因为连接到数据库可能要花一些时间,这会减慢启动速度,或者数据库连接信息可能尚不可用。 通过调用initialize_database函数来创建模块级变量,我们可以将数据库的创建推迟到实际需要时:
class Database:
# the database implementation
pass
database = None
def initialize_database():
global database
database = Database()
global关键字告诉 Python initialize_database内部的数据库变量是我们刚刚定义的模块级别。 如果我们没有将变量指定为全局变量,Python 将创建一个新的局部变量,该局部变量在方法退出时将被丢弃,而模块级别的值保持不变。
如这两个示例所示,所有模块级代码在导入时都会立即执行。 但是,如果它在方法或函数内部,则将创建该函数,但是在调用该函数之前,将不会执行其内部代码。 对于执行脚本的脚本(例如我们电子商务示例中的主脚本)来说,这可能是一件棘手的事情。 通常,我们会写一个做一些有用的事情的程序,然后发现我们想在另一个程序中从该模块导入一个函数或类。 但是,一旦导入,模块级的任何代码都会立即执行。 如果我们不小心的话,当我们确实只打算访问该模块中的几个函数时,就可以运行第一个程序。
为了解决这个问题,我们应该始终将启动代码放在一个函数中(通常称为main),并且仅在知道我们将模块作为脚本运行时才执行该函数,而不是在导入代码时执行 来自不同的脚本。 但是我们怎么知道呢?
class UsefulClass:
'''This class might be useful to other modules.'''
pass
def main():
'''creates a useful class and does something with it for our module.'''
useful = UsefulClass()
print(useful)
if __name__ == "__main__":
main()
每个模块都有一个__name__特殊变量(请记住,Python 对特殊变量使用双下划线,例如类的__init__方法),用于指定导入模块时的模块名称。 当直接用python module.py执行该模块时,永远不会导入该模块,因此__name__被任意设置为字符串"__main__"。 将所有脚本包装在if __name__ == "__main__":测试中作为一项策略,以防万一您编写一个函数时发现有一天可以被其他代码导入有用。
因此,方法进入类,进入模块,进入包。 这就是全部吗?
其实没有 这是 Python 程序中典型的事物顺序,但这不是唯一可能的布局。 类别可以在任何地方定义。 它们通常在模块级别定义,但也可以在函数或方法内部定义,如下所示:
def format_string(string, formatter=None):
'''Format a string using the formatter object, which
is expected to have a format() method that accepts
a string.'''
class DefaultFormatter:
'''Format a string in title case.'''
def format(self, string):
return str(string).title()
if not formatter:
formatter = DefaultFormatter()
return formatter.format(string)
hello_string = "hello world, how are you today?"
print(" input: " + hello_string)
print("output: " + format_string(hello_string))
输出将如下所示:
input: hello world, how are you today?
output: Hello World, How Are You Today?
format_string函数接受一个字符串和可选的格式化程序对象,然后将格式化程序应用于该字符串。 如果没有提供格式化程序,它将创建自己的格式化程序作为本地类并将其实例化。 由于是在函数范围内创建的,因此无法从该函数之外的任何位置访问此类。 类似地,也可以在其他函数内部定义函数。 通常,任何 Python 语句都可以随时执行。
这些内部类和函数有时对于一次性项目很有用,这些一次性项目在模块级别不需要或不应该拥有自己的范围,或者仅在单个方法内有意义。 但是,经常使用这种技术的 Python 代码并不常见。
谁可以访问我的数据?
大多数面向对象的编程语言都有访问控制的概念。 这与抽象有关。 对象上的某些属性和方法被标记为私有,这意味着只有该对象才能访问它们。 其他标记为受保护,这意味着仅该类和任何子类具有访问权限。 其余的都是公共的,这意味着任何其他对象都可以访问它们。
Python 不会这样做。 Python 并不真正相信执行某天可能会妨碍您的法律。 相反,它提供了未强制执行的准则和最佳实践。 从技术上讲,类的所有方法和属性都是公开可用的。 如果我们建议某个方法不应该公开使用,则可以在文档字符串中添加注释,表明该方法仅供内部使用(最好解释一下面向公众的 API 是如何工作的!)。
按照约定,我们还应该在属性或方法前加上下划线字符_。 Python 程序员会将其解释为*“这是一个内部变量,在直接访问它之前要三思而行”*。 但是,如果他们认为这样做符合他们的最大利益,那么解释器内部没有任何东西可以阻止他们访问它。 因为如果他们这么想,我们为什么要阻止他们? 我们可能不知道将来使用我们的类会有什么用。
您可以做另一件事来强烈建议外部对象不要访问属性或方法:在其前面加上双下划线__。 这将对相关属性执行名称处理。 这基本上意味着,如果外部对象确实想执行此方法,则仍然可以调用该方法,但是它需要额外的工作,并且强烈表明您要求属性保持私有。 例如:
class SecretString:
'''A not-at-all secure way to store a secret string.'''
def __init__(self, plain_string, pass_phrase):
self.__plain_string = plain_string
self.__pass_phrase = pass_phrase
def decrypt(self, pass_phrase):
'''Only show the string if the pass_phrase is correct.'''
if pass_phrase == self.__pass_phrase:
return self.__plain_string
else:
return ''
如果我们加载此类并在交互式解释器中对其进行测试,我们可以看到它隐藏了外界的纯文本字符串:
>>> secret_string = SecretString("ACME: Top Secret", "antwerp")
>>> print(secret_string.decrypt("antwerp"))
ACME: Top Secret
>>> print(secret_string.__plain_string)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'SecretString' object has no attribute
'__plain_text'
看起来可行; 没有密码,没有人可以访问我们的plain_text属性,因此它必须安全。 不过,在让我们兴奋不已之前,让我们看看破解我们的安全性有多么容易:
>>> print(secret_string._SecretString__plain_string)
ACME: Top Secret
不好了! 有人入侵了我们的机密字符串。 我们检查的好东西! 这是工作中的 Python 名称修饰。 当我们使用双下划线时,该属性以_<classname>为前缀。 当类中的方法内部访问变量时,它们将自动取消绑定。 当外部类希望访问它时,他们必须自己改名。 因此,名称改写并不能保证隐私,它只是强烈建议您这样做。 大多数 Python 程序员不会碰到另一个对象上的双下划线变量,除非他们有非常有说服力的理由这样做。
但是,大多数 Python 程序员也不会在没有令人信服的理由的情况下接触单个下划线变量。 因此,很少有充分的理由在 Python 中使用名称混用变量,这样做会引起悲伤。 例如,名称混用的变量可能对子类很有用,并且它必须自行处理。 如果其他对象愿意,可以使用单下划线前缀或一些清晰的文档字符串让其他对象访问您的隐藏信息,您认为这不是一个好主意。
第三方库
Python 附带带有一个可爱的标准库,该库是在运行 Python 的每台计算机上可用的软件包和模块的集合。 但是,您很快就会发现它不包含所需的一切。 发生这种情况时,您有两种选择:
- 自己写一个支持包
- 使用别人的代码
我们不会介绍将程序包转换为库的详细信息,但是,如果您有问题需要解决,并且不想编写代码(最好的程序员非常懒惰,并且喜欢重用现有的,经过验证的代码, 而不是自己编写),您可以在这个页面。 确定要安装的软件包后,可以使用名为pip的工具进行安装。 但是,pip并不随 Python 一起提供,但是 Python 3.4 包含一个名为ensurepip的有用工具,它将安装该工具:
python -m ensurepip
在 Linux,Mac OS 或其他 Unix 系统上,这可能会失败,在这种情况下,您需要成为 root 用户才能使其工作。 在大多数现代 Unix 系统上,可以使用sudo python -m ensurepip完成此操作。
注意
如果您使用的 Python 版本高于 Python 3.4,则需要自行下载并安装pip,因为ensurepip不存在。 您可以按照这个页面上的说明进行操作。
一旦安装了pip,并且知道要安装的软件包的名称,则可以使用以下语法来安装它:
pip install requests
但是,如果执行此操作,则将第三方库直接安装到系统 Python 目录中,或者更有可能出现错误消息,表明您没有这样做的权限。 您可以强制以管理员身份进行安装,但是 Python 社区中的普遍共识是,您应仅使用系统安装程序将第三方库安装到系统 Python 目录中。
相反,Python 3.4 提供了venv工具。 该实用程序基本上可以在您的工作目录中提供一个名为虚拟环境的微型 Python 安装。 当您激活迷你 Python 时,与 Python 相关的命令将在该目录而不是系统目录上运行。 因此,当您运行pip或python时,它根本不会碰到系统 Python。 使用方法如下:
cd project_directory
python -m venv env
source env/bin/activate # on Linux or MacOS
env/bin/activate.bat # on Windows
通常,您将为您处理的每个 Python 项目创建一个不同的虚拟环境。 您可以将虚拟环境存储在任何地方,但是我将自己的虚拟环境与其余项目文件保存在同一目录中(但在版本控制中忽略了该目录),因此首先将cd放入该目录。 然后,我们运行venv实用程序创建一个名为env的虚拟环境。 最后,我们使用最后两行之一(取决于操作系统,如注释中所示)来激活环境。 每次我们想使用该特定 virtualenv 时,都需要执行此行,然后在完成该项目时使用命令deactivate。
虚拟环境是一种使第三方依赖关系分离的好方法。 通常有不同的项目依赖于特定库的不同版本(例如,较旧的网站可能在 Django 1.5 上运行,而较新的版本可能在 Django 1.8 上运行)。 将每个项目保留在单独的 virtualenvs 中,可以轻松在任一版本的 Django 中工作。 此外,如果尝试使用其他工具安装相同的软件包,它可以防止系统安装的软件包与pip安装的软件包之间发生冲突。
案例研究
为了将所有捆绑在一起,让我们构建一个简单的命令行笔记本应用。 这是一个相当简单的任务,因此我们不会尝试使用多个软件包。 但是,我们将看到类,函数,方法和文档字符串的常见用法。
让我们从快速分析开始:笔记是存储在笔记本中的简短备忘录。 每个便笺应记录其写作日期,并可以添加标签以方便查询。 应该可以修改注释。 我们还需要能够搜索注释。 所有这些事情都应该从命令行完成。
显而易见的对象是Note对象; 不太明显的一个是Notebook容器对象。 标签和日期似乎也是对象,但是我们可以使用 Python 标准库中的日期和标签的逗号分隔字符串。 为了避免复杂性,在原型中,我们不要为这些对象定义单独的类。
Note对象具有memo本身,tags和creation_date的属性。 每个音符还需要一个唯一的整数id,以便用户可以在菜单界面中选择它们。 笔记可以有一种修改笔记内容的方法,另一种是为标签修改的方法,或者我们可以让笔记本直接访问那些属性。 为了使搜索更容易,我们应该在Note对象上放置match方法。 此方法将接受字符串,并且可以在不直接访问属性的情况下告诉我们注释是否与字符串匹配。 这样,如果我们要修改搜索参数(例如,搜索标签而不是注释内容,或者使搜索不区分大小写),则只需要在一个地方进行即可。
Notebook对象显然具有注释列表作为属性。 它还需要一个搜索方法,该方法返回已过滤笔记的列表。
但是我们如何与这些对象互动? 我们已经指定了一个命令行应用,这意味着我们可以使用不同的选项来运行程序以添加或编辑命令,或者可以通过某种菜单来选择要在笔记本上进行的操作。 我们应该尝试对其进行设计,以便支持任何一个接口,并且将来可以添加将来的接口,例如 GUI 工具箱或基于 Web 的接口。
作为设计决策,我们现在将实现菜单界面,但是将牢记命令行选项版本,以确保在设计Notebook类时要牢记可扩展性。
如果我们有两个命令行界面,每个命令行界面都与Notebook对象进行交互,则Notebook将需要一些与这些界面进行交互的方法。 除了已经讨论过的search方法之外,我们还需要add一个新的音符,以及id一个id的现有音符。 接口还需要能够列出所有注释,但是它们可以通过直接访问notes list 属性来做到这一点。
我们可能遗漏了一些细节,但这使我们对需要编写的代码有了很好的概览。 我们可以在一个简单的类图中总结所有这些内容:
在编写任何代码之前,让我们为该项目定义文件夹结构。 菜单界面显然应该在其自己的模块中,因为它将是一个可执行脚本,并且将来我们可能还会有其他可执行脚本访问笔记本。 Notebook和Note对象可以一起生活在一个模块中。 这些模块都可以存在于同一顶级目录中,而不必将它们放在包中。 空的command_option.py模块可以帮助提醒我们将来我们打算添加新的用户界面。
parent_directory/
notebook.py
menu.py
command_option.py
现在,让我们看一些代码。 我们从定义Note类开始,因为这似乎最简单。 以下示例完整介绍了Note。 示例中的文档字符串说明了它们如何组合在一起。
import datetime
# Store the next available id for all new notes
last_id = 0
class Note:
'''Represent a note in the notebook. Match against a
string in searches and store tags for each note.'''
def __init__(self, memo, tags=''):
'''initialize a note with memo and optional
space-separated tags. Automatically set the note's
creation date and a unique id.'''
self.memo = memo
self.tags = tags
self.creation_date = datetime.date.today()
global last_id
last_id += 1
self.id = last_id
def match(self, filter):
'''Determine if this note matches the filter
text. Return True if it matches, False otherwise.
Search is case sensitive and matches both text and
tags.'''
return filter in self.memo or filter in self.tags
在继续进行之前,我们应该迅速启动交互式解释器并测试我们的代码。 经常且经常进行测试,因为事情永远不会以您期望的方式发挥作用。 确实,当我测试此示例的第一个版本时,我发现我忘记了match函数中的self自变量! 现在,使用解释器检查一些事情:
>>> from notebook import Note
>>> n1 = Note("hello first")
>>> n2 = Note("hello again")
>>> n1.id
1
>>> n2.id
2
>>> n1.match('hello')
True
>>> n2.match('second')
False
看起来一切都按预期进行。 接下来创建笔记本:
class Notebook:
'''Represent a collection of notes that can be tagged,
modified, and searched.'''
def __init__(self):
'''Initialize a notebook with an empty list.'''
self.notes = []
def new_note(self, memo, tags=''):
'''Create a new note and add it to the list.'''
self.notes.append(Note(memo, tags))
def modify_memo(self, note_id, memo):
'''Find the note with the given id and change its
memo to the given value.'''
for note in self.notes:
if note.id == note_id:
note.memo = memo
break
def modify_tags(self, note_id, tags):
'''Find the note with the given id and change its
tags to the given value.'''
for note in self.notes:
if note.id == note_id:
note.tags = tags
break
def search(self, filter):
'''Find all notes that match the given filter
string.'''
return [note for note in self.notes if
note.match(filter)]
我们将在一分钟内清理。 首先,让我们对其进行测试以确保其正常工作:
>>> from notebook import Note, Notebook
>>> n = Notebook()
>>> n.new_note("hello world")
>>> n.new_note("hello again")
>>> n.notes
[<notebook.Note object at 0xb730a78c>, <notebook.Note object at
0xb73103ac>]
>>> n.notes[0].id
1
>>> n.notes[1].id
2
>>> n.notes[0].memo
'hello world'
>>> n.search("hello")
[<notebook.Note object at 0xb730a78c>, <notebook.Note object at
0xb73103ac>]
>>> n.search("world")
[<notebook.Note object at 0xb730a78c>]
>>> n.modify_memo(1, "hi world")
>>> n.notes[0].memo
'hi world'
确实有效。 虽然代码有点混乱; 我们的modify_tags和modify_memo方法几乎相同。 那不是好的编码习惯。 让我们看看如何改进它。
两种方法都试图在对笔记进行处理之前识别具有给定 ID 的笔记。 因此,让我们添加一种方法来查找具有特定 ID 的笔记。 我们将在方法名称前添加下划线,以表明该方法仅供内部使用,但是,如果需要,我们的菜单界面可以访问该方法:
def _find_note(self, note_id):
'''Locate the note with the given id.'''
for note in self.notes:
if note.id == note_id:
return note
return None
def modify_memo(self, note_id, memo):
'''Find the note with the given id and change its
memo to the given value.'''
self._find_note(note_id).memo = memo
现在应该可以使用。 让我们看一下菜单界面。 该界面只需要显示一个菜单并允许用户输入选择即可。 这是我们的第一次尝试:
import sys
from notebook import Notebook, Note
class Menu:
'''Display a menu and respond to choices when run.'''
def __init__(self):
self.notebook = Notebook()
self.choices = {
"1": self.show_notes,
"2": self.search_notes,
"3": self.add_note,
"4": self.modify_note,
"5": self.quit
}
def display_menu(self):
print("""
Notebook Menu
1\. Show all Notes
2\. Search Notes
3\. Add Note
4\. Modify Note
5\. Quit
""")
def run(self):
'''Display the menu and respond to choices.'''
while True:
self.display_menu()
choice = input("Enter an option: ")
action = self.choices.get(choice)
if action:
action()
else:
print("{0} is not a valid choice".format(choice))
def show_notes(self, notes=None):
if not notes:
notes = self.notebook.notes
for note in notes:
print("{0}: {1}\n{2}".format(
note.id, note.tags, note.memo))
def search_notes(self):
filter = input("Search for: ")
notes = self.notebook.search(filter)
self.show_notes(notes)
def add_note(self):
memo = input("Enter a memo: ")
self.notebook.new_note(memo)
print("Your note has been added.")
def modify_note(self):
id = input("Enter a note id: ")
memo = input("Enter a memo: ")
tags = input("Enter tags: ")
if memo:
self.notebook.modify_memo(id, memo)
if tags:
self.notebook.modify_tags(id, tags)
def quit(self):
print("Thank you for using your notebook today.")
sys.exit(0)
if __name__ == "__main__":
Menu().run()
此代码首先使用绝对导入来导入笔记本对象。 相对导入将不起作用,因为我们没有将代码放入包中。 Menu类的run方法重复显示菜单,并通过调用笔记本计算机上的函数来响应选择。 这是使用 Python 特有的习惯用法完成的。 用户输入的选项是字符串。 在菜单的__init__方法中,我们创建了一个字典,该字典将字符串映射到菜单对象本身上的函数。 然后,当用户做出选择时,我们从字典中检索对象。 action变量实际上是指特定的方法,并通过在变量后附加空括号(因为这些方法均不需要参数)来调用。 当然,用户可能输入了不合适的选项,因此我们在调用操作之前先检查该操作是否确实存在。
各种方法中的每一种都要求用户输入并在与之关联的Notebook对象上调用适当的方法。 对于search实现,我们注意到在过滤掉注释之后,我们需要将它们显示给用户,因此使show_notes函数具有双重职责; 它接受可选的notes参数。 如果提供,则仅显示已过滤的注释,如果未提供,则显示所有注释。 由于notes参数是可选的,因此show_notes仍可以在没有参数的情况下作为空白菜单项被调用。
如果测试此代码,我们会发现修改注释不起作用。 有两个错误,即:
- 当我们输入不存在的笔记 ID 时,笔记本崩溃。 我们绝不应该信任我们的用户输入正确的数据!
- 即使我们输入正确的 ID,它也会崩溃,因为注释 ID 是整数,但是我们的菜单正在传递字符串。
后一种错误可以通过修改Notebook类的_find_note方法来解决,以使用字符串而不是注释中存储的整数来比较值,如下所示:
def _find_note(self, note_id):
'''Locate the note with the given id.'''
for note in self.notes:
if str(note.id) == str(note_id):
return note
return None
在比较它们之前,我们只需将输入(note_id)和音符的 ID 都转换为字符串。 我们也可以将输入转换为整数,但是如果用户输入字母“ a”而不是数字“ 1”,那么我们将遇到麻烦。
可以通过更改笔记本上的两个modify方法来检查_find_note是否返回笔记,从而解决用户输入不存在的笔记 ID 的问题,如下所示:
def modify_memo(self, note_id, memo):
'''Find the note with the given id and change its
memo to the given value.'''
note = self._find_note(note_id)
if note:
note.memo = memo
return True
return False
此方法已更新为返回True或False,具体取决于是否已找到便笺。 如果用户输入了无效的注释,菜单可能会使用此返回值显示错误。 这段代码有点笨拙。 如果它引发了异常,看起来会更好一些。 我们将在第 4 章中介绍异常。
四、相同的对象
在编程世界中,重复代码被认为是邪恶的。 我们不应在不同的地方拥有相同或相似代码的多个副本。
有很多方法可以合并具有相似功能的代码段或对象。 在本章中,我们将介绍最著名的面向对象原理:继承。 正如第 1 章,“面向对象设计”中讨论的那样,继承使我们可以创建*,即两个或多个类之间的*关系,将通用逻辑抽象为超类并进行管理 子类中的特定详细信息。 特别是,我们将介绍以下方面的 Python 语法和原理:
- 基本继承
- 从内置继承
- 多重继承
- 多态性和鸭子打字
基本继承
从技术上讲,我们创建的每个类都使用继承。 所有 Python 类都是名为object的特殊类的子类。 此类提供的数据和行为很少(它提供的行为都是仅供内部使用的双下划线方法),但是它确实允许 Python 以相同的方式处理所有对象。
如果我们不显式继承其他类,则我们的类将自动继承object。 但是,我们可以公开声明我们的类是使用以下语法从object派生的:
class MySubClass(object):
pass
这就是继承! 从技术上讲,该示例与第 2 章和 Python 中的对象的第一个示例没有什么不同,因为如果我们未明确提供[ 不同的超类。 超类或父类是从其继承的类。 子类是从超类继承的类。 在这种情况下,超类是object,而MySubClass是子类。 子类也被称为是从其父类派生的,或者该子类扩展了父类。
正如您从示例中发现的一样,继承比基本类定义所需的语法更少。 只需在类名之后但在冒号终止类定义之前的括号内包括父类的名称。 这就是我们要告诉 Python 新类应该从给定的超类派生的全部操作。
我们如何在实践中应用继承? 继承最简单,最明显的用途是向现有类添加功能。 让我们从一个简单的联系人管理器开始,该管理器跟踪几个人的姓名和电子邮件地址。 联系人类负责维护类变量中所有联系人的列表,并初始化单个联系人的姓名和地址:
class Contact:
all_contacts = []
def __init__(self, name, email):
self.name = name
self.email = email
Contact.all_contacts.append(self)
这个例子向我们介绍了类变量。 由于all_contacts列表是类定义的一部分,因此该类的所有实例都共享它。 这意味着只有一个Contact.all_contacts列表,我们可以将其作为Contact.all_contacts进行访问。 不太明显的是,我们还可以在从Contact实例化的任何对象上以self.all_contacts的形式访问它。 如果在对象上找不到该字段,那么它将在类上找到,因此引用相同的单个列表。
注意
请谨慎使用此语法,因为如果使用self.all_contacts设置了变量,则实际上将创建仅与该对象关联的新的实例变量。 class 变量将保持不变,并可以通过Contact.all_contacts进行访问。
这是一个简单的类,它使我们可以跟踪有关每个联系人的几个数据。 但是,如果我们的某些联系人也是我们需要从其订购耗材的供应商怎么办? 我们可以在Contact类中添加order方法,但这将使人们意外地从客户或家人朋友的联系人那里订购商品。 相反,让我们创建一个新的Supplier类,其作用类似于我们的Contact类,但是具有一个附加的order方法:
class Supplier(Contact):
def order(self, order):
print("If this were a real system we would send "
"'{}' order to '{}'".format(order, self.name))
现在,如果我们在可靠的解释器中测试此类,我们将看到所有联系人(包括供应商)都在其__init__中接受姓名和电子邮件地址,但只有供应商具有功能上的订购方法:
>>> c = Contact("Some Body", "somebody@example.net")
>>> s = Supplier("Sup Plier", "supplier@example.net")
>>> print(c.name, c.email, s.name, s.email)
Some Body somebody@example.net Sup Plier supplier@example.net
>>> c.all_contacts
[<__main__.Contact object at 0xb7375ecc>,
<__main__.Supplier object at 0xb7375f8c>]
>>> c.order("I need pliers")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Contact' object has no attribute 'order'
>>> s.order("I need pliers")
If this were a real system we would send 'I need pliers' order to
'Sup Plier '
因此,现在我们的Supplier类可以完成联系人可以做的所有事情(包括将自己添加到all_contacts列表中)以及作为供应商需要处理的所有特殊事项。 这就是继承之美。
扩展内置
这种继承的一种有趣的用法是向内置类添加功能。 在前面看到的Contact类中,我们将联系人添加到所有联系人的列表中。 如果我们还想按名称搜索该列表怎么办? 好吧,我们可以在Contact类上添加一个方法来搜索它,但是感觉这个方法实际上属于列表本身。 我们可以使用继承来做到这一点:
class ContactList(list):
def search(self, name):
'''Return all contacts that contain the search value
in their name.'''
matching_contacts = []
for contact in self:
if name in contact.name:
matching_contacts.append(contact)
return matching_contacts
class Contact:
all_contacts = ContactList()
def __init__(self, name, email):
self.name = name
self.email = email
self.all_contacts.append(self)
我们没有实例化普通列表作为我们的类变量,而是创建了一个扩展内置list的新ContactList类。 然后,我们将此子类实例化为all_contacts列表。 我们可以如下测试新的搜索功能:
>>> c1 = Contact("John A", "johna@example.net")
>>> c2 = Contact("John B", "johnb@example.net")
>>> c3 = Contact("Jenna C", "jennac@example.net")
>>> [c.name for c in Contact.all_contacts.search('John')]
['John A', 'John B']
您是否想知道我们如何将内置语法[]更改为可以继承的语法? 实际上,使用[]创建一个空列表是使用list()创建一个空列表的简写; 这两种语法的行为相同:
>>> [] == list()
True
实际上,[]语法实际上是所谓的语法糖,它在后台调用了list()构造函数。 list数据类型是我们可以扩展的类。 实际上,列表本身扩展了object类:
>>> isinstance([], object)
True
作为第二个示例,我们可以扩展dict类,该类与列表类似,是使用{}语法简写构造的类:
class LongNameDict(dict):
def longest_key(self):
longest = None
for key in self:
if not longest or len(key) > len(longest):
longest = key
return longest
这在交互式解释器中很容易测试:
>>> longkeys = LongNameDict()
>>> longkeys['hello'] = 1
>>> longkeys['longest yet'] = 5
>>> longkeys['hello2'] = 'world'
>>> longkeys.longest_key()
'longest yet'
大多数内置类型都可以类似地扩展。 通常扩展的内置是object,list,set,dict,file和str。 有时也会继承诸如int和float之类的数字类型。
覆盖和超级
因此,对于来说,继承是一个不错的,它将新行为添加到现有类中,但是会改变行为呢? 我们的contact类仅允许使用名称和电子邮件地址。 对于大多数联系人而言,这可能就足够了,但是如果我们想为我们的密友添加电话号码怎么办?
正如我们在第 2 章,Python 中的对象中所看到的那样,我们可以通过在构造接触后在接触上设置一个phone属性来轻松地做到这一点。 但是,如果要使第三个变量在初始化时可用,则必须重写__init__。 覆盖表示更改或用子类中的新方法(具有相同名称)替换超类的方法。 无需特殊语法即可执行此操作; 子类的新创建的方法会自动调用,而不是超类的方法。 例如:
class Friend(Contact):
def __init__(self, name, email, phone):
self.name = name
self.email = email
self.phone = phone
可以覆盖的任何方法,而不仅仅是__init__。 但是,在继续之前,我们需要在此示例中解决一些问题。 我们的Contact和Friend类具有重复的代码来设置name和email属性; 这可能会使代码维护变得复杂,因为我们必须在两个或多个位置更新代码。 更令人震惊的是,我们的Friend类忽略了将自身添加到我们在Contact类上创建的all_contacts列表中。
我们真正需要的是一种在Contact类上执行原始__init__方法的方法。 这就是super功能的作用; 它返回该对象作为父类的实例,从而使我们可以直接调用父方法:
class Friend(Contact):
def __init__(self, name, email, phone):
super().__init__(name, email)
self.phone = phone
本示例首先使用super获取父对象的实例,然后在该对象上调用__init__,并传入期望的参数。 然后,它执行自己的初始化,即设置phone属性。
注意
请注意,super()语法在旧版本的 Python 中不起作用。 像列表和字典的[]和{}语法一样,它是更复杂的构造的简写形式。 在讨论多重继承时,我们将在短期内了解更多信息,但是现在知道在 Python 2 中,您必须调用super(EmailContact, self).__init__()。 特别注意,第一个参数是子类的名称,而不是某些人希望的作为要调用的父类的名称。 另外,请记住类在对象之前。 我总是忘了顺序,因此 Python 3 中的新语法为我节省了很多时间来查找它。
可以在任何方法内进行super()调用,而不仅仅是__init__。 这意味着可以通过覆盖和调用super来修改所有方法。 也可以在方法的任何点处调用super。 我们不必将调用作为方法的第一行。 例如,我们可能需要在将传入参数转发给超类之前对其进行操作或验证。
多重继承
多重继承是敏感的主题。 从原则上讲,这非常简单:从多个父类继承的子类能够从它们两个中访问功能。 实际上,这没有听起来那么有用,许多专家程序员建议不要使用它。
注意
根据经验,如果您认为需要多重继承,那么您可能错了,但是如果您知道需要继承,那么您可能是对的。
最简单且最有用的多重继承形式称为 mixin。 mixin 通常是一个超类,它本身并不存在,但可以被其他一些类继承以提供额外的功能。 例如,假设我们要向Contact类添加功能,该功能允许向self.email发送电子邮件。 发送电子邮件是我们可能想在其他许多类上使用的一项常见任务。 因此,我们可以编写一个简单的 mixin 类来为我们发送电子邮件:
class MailSender:
def send_mail(self, message):
print("Sending mail to " + self.email)
# Add e-mail logic here
为简便起见,我们将不在此处包括实际的电子邮件逻辑; 如果您对研究如何完成感兴趣,请参阅 Python 标准库中的smtplib模块。
该类没有做任何特殊的事情(实际上,它几乎不能作为一个独立的类起作用),但是它确实允许我们使用多重继承定义一个描述Contact和MailSender的新类:
class EmailableContact(Contact, MailSender):
pass
多重继承的语法看起来像类定义中的参数列表。 我们没有在括号中包含一个基类,而是包含了两个(或多个),并以逗号分隔。 我们可以测试这个新的混合动力以查看混合工作:
>>> e = EmailableContact("John Smith", "jsmith@example.net")
>>> Contact.all_contacts
[<__main__.EmailableContact object at 0xb7205fac>]
>>> e.send_mail("Hello, test e-mail here")
Sending mail to jsmith@example.net
Contact初始化程序为仍将新联系人添加到all_contacts列表中,并且 mixin 能够向self.email发送邮件,因此我们知道一切正常。
这并不难,您可能想知道关于多重继承的可怕警告是什么。 我们将在一分钟内介绍复杂性,但让我们考虑一下我们拥有的其他选项,而不是在此处使用 mixin:
- 我们本可以使用单一继承并将
send_mail函数添加到子类中。 此处的缺点是,对于所有其他需要电子邮件的类,电子邮件功能必须重复。 - 我们可以创建一个独立的 Python 函数来发送电子邮件,并在需要发送电子邮件时使用提供的正确电子邮件地址作为参数来调用该函数。
- 我们本来可以探索使用组合而不是继承的几种方法。 例如,
EmailableContact可以具有MailSender对象,而不是从其继承。 - 我们可以进行猴子补丁(在第 7 章和 Python 面向对象的快捷方式中简要介绍猴子补丁),在
Contact类之后添加send_mail方法 类已创建。 这是通过定义一个接受self参数的函数并将其设置为现有类的属性来完成的。
当混合来自不同类的方法时,多重继承可以正常工作,但是当我们必须在超类上调用方法时,它会变得非常混乱。 有多个超类。 我们怎么知道该叫哪一个? 我们怎么知道用什么顺序打电话给他们?
让我们通过在Friend类中添加家庭住址来探讨这些问题。 我们可能会采用几种方法。 地址是代表联系人的街道,城市,国家和其他相关详细信息的字符串的集合。 我们可以将每个字符串作为参数传递给Friend类的__init__方法。 我们还可以将这些字符串存储在元组或字典中,并将它们作为单个参数传递到__init__中。 如果不需要在地址中添加任何方法,这可能是最好的做法。
另一个选择是创建一个新的Address类以将这些字符串保持在一起,然后将该类的实例传递到Friend类的__init__方法中。 此解决方案的优点是我们可以向数据添加行为(例如,提供指导或打印地图的方法),而不仅仅是静态地存储它。 正如我们在第 1 章,“面向对象设计”中讨论的那样,这是组成的示例。 组成的“具有”关系是解决此问题的完美可行的解决方案,它使我们可以在建筑物,企业或组织等其他实体中重用Address类。
但是,继承也是可行的解决方案,这就是我们要探讨的内容。 让我们添加一个包含地址的新类。 我们将这个新类称为“ AddressHolder”,而不是“ Address”,因为继承定义了是关系。 说“朋友”是“地址”是不正确的,但是由于朋友可以有“地址”,因此我们可以说“朋友”是“ AddressHolder”。 以后,我们可以创建其他拥有地址的实体(公司,建筑物)。 这是我们的AddressHolder类:
class AddressHolder:
def __init__(self, street, city, state, code):
self.street = street
self.city = city
self.state = state
self.code = code
很简单; 我们只是获取所有数据,并在初始化时将其扔到实例变量中。
钻石问题
我们可以使用多重继承将该新类添加为现有Friend类的父类。 棘手的是,我们现在有两个父级__init__方法,这两个方法都需要初始化。 并且它们需要使用不同的参数进行初始化。 我们如何做到这一点? 好吧,我们可以从一个幼稚的方法开始:
class Friend(Contact, AddressHolder):
def __init__(
self, name, email, phone,street, city, state, code):
Contact.__init__(self, name, email)
AddressHolder.__init__(self, street, city, state, code)
self.phone = phone
在此示例中,我们直接在每个超类上调用__init__函数,并显式传递self参数。 这个例子在技术上是可行的。 我们可以直接在类上访问不同的变量。 但是有一些问题。
首先,如果我们忽略显式调用初始化器,则超类可能未初始化为。 这不会破坏本示例,但在常见情况下可能导致难以调试的程序崩溃。 想象一下,尝试将数据插入尚未连接的数据库中。
其次,更危险的是,由于类层次结构的组织,可能会多次调用超类。 看一下这个继承图:
Friend类中的__init__方法首先调用Contact上的__init__,这将隐式初始化object超类(请记住,所有类均源自object)。 Friend然后在AddressHolder上调用__init__,这又隐式地初始化object超类。 这意味着父类已设置两次。 对于object类,这是相对无害的,但是在某些情况下,它可能会带来灾难。 想象一下,对于每个请求尝试两次连接到数据库!
基类只能调用一次。 曾经,是的,但是什么时候? 我们先叫Friend,然后叫Contact,然后叫Object,然后叫AddressHolder? 还是Friend,然后是Contact,然后是AddressHolder,然后是Object?
注意
通过修改类的 __mro__(方法解析顺序)属性,可以即时调整方法的调用顺序。 这超出了本模块的范围。 如果您认为需要了解它,我建议 Expert Python 编程,TarekZiadé,Packt Publishing 或阅读有关该主题的原始文档,网址为 [] www.python.org/download/re…](www.python.org/download/re…) 。
让我们看第二个人为的例子,它更清楚地说明了这个问题。 在这里,我们有一个基类,它具有一个名为call_me的方法。 两个子类重写该方法,然后另一个子类使用多重继承扩展这两个方法。 由于类图的菱形形状,这被称为菱形继承:
让我们将此图转换为代码; 此示例显示了何时调用方法:
class BaseClass:
num_base_calls = 0
def call_me(self):
print("Calling method on Base Class")
self.num_base_calls += 1
class LeftSubclass(BaseClass):
num_left_calls = 0
def call_me(self):
BaseClass.call_me(self)
print("Calling method on Left Subclass")
self.num_left_calls += 1
class RightSubclass(BaseClass):
num_right_calls = 0
def call_me(self):
BaseClass.call_me(self)
print("Calling method on Right Subclass")
self.num_right_calls += 1
class Subclass(LeftSubclass, RightSubclass):
num_sub_calls = 0
def call_me(self):
LeftSubclass.call_me(self)
RightSubclass.call_me(self)
print("Calling method on Subclass")
self.num_sub_calls += 1
此示例仅确保每个重写的call_me方法都直接调用具有相同名称的父方法。 每次将信息打印到屏幕上时,它就会让我们知道方法的调用。 它还会更新类上的静态变量,以显示其已被调用多少次。 如果我们实例化一个Subclass对象并对其调用一次,则将得到以下输出:
>>> s = Subclass()
>>> s.call_me()
Calling method on Base Class
Calling method on Left Subclass
Calling method on Base Class
Calling method on Right Subclass
Calling method on Subclass
>>> print(
... s.num_sub_calls,
... s.num_left_calls,
... s.num_right_calls,
... s.num_base_calls)
1 1 1 2
因此,我们可以清楚地看到基类的call_me方法被调用了两次。 如果该方法进行两次实际工作(例如存入银行帐户),则可能会导致一些隐患。
多重继承要记住的是,我们只想在类层次结构中调用“ next”方法,而不是“ parent”方法。 实际上,该下一个方法可能不在当前类的父级或祖先上。 super关键字再次帮助我们。 实际上,super最初是为了使复杂形式的多重继承而开发的。 这是使用super编写的相同代码:
class BaseClass:
num_base_calls = 0
def call_me(self):
print("Calling method on Base Class")
self.num_base_calls += 1
class LeftSubclass(BaseClass):
num_left_calls = 0
def call_me(self):
super().call_me()
print("Calling method on Left Subclass")
self.num_left_calls += 1
class RightSubclass(BaseClass):
num_right_calls = 0
def call_me(self):
super().call_me()
print("Calling method on Right Subclass")
self.num_right_calls += 1
class Subclass(LeftSubclass, RightSubclass):
num_sub_calls = 0
def call_me(self):
super().call_me()
print("Calling method on Subclass")
self.num_sub_calls += 1
的更改很小。 我们简单地将朴素的直接调用替换为对super()的调用,尽管底部子类仅调用super一次,而不必对左右两个调用。 更改很简单,但是请看一下执行时的区别:
>>> s = Subclass()
>>> s.call_me()
Calling method on Base Class
Calling method on Right Subclass
Calling method on Left Subclass
Calling method on Subclass
>>> print(s.num_sub_calls, s.num_left_calls, s.num_right_calls,
s.num_base_calls)
1 1 1 1
看起来不错,我们的基本方法仅被调用一次。 但是super()在这里实际上在做什么? 由于print语句是在super调用之后执行的,因此打印输出按实际执行每种方法的顺序排列。 让我们从后到前查看输出,看看谁在打电话。
首先,Subclass的call_me调用super().call_me(),碰巧引用了LeftSubclass.call_me()。 然后LeftSubclass.call_me()方法调用super().call_me(),但是在这种情况下,super()指的是RightSubclass.call_me()。
请特别注意:super调用是而不是调用LeftSubclass的超类(即BaseClass)上的方法。 而是,它正在调用RightSubclass,即使它不是LeftSubclass的直接父代! 这是下一个方法,而不是父方法。 然后RightSubclass调用BaseClass,并且super调用已确保类层次结构中的每个方法执行一次。
不同的参数集
回到Friend多继承示例时,使事情变得复杂。 在Friend的__init__方法中,我们最初为两个父类调用了带有不同参数集的__init__:
Contact.__init__(self, name, email)
AddressHolder.__init__(self, street, city, state, code)
使用super时如何管理不同的参数集? 我们不一定知道哪个类super将首先尝试初始化。 即使我们做到了,我们也需要一种传递“额外”参数的方法,以便在其他子类上对super的后续调用接收正确的参数。
具体来说,如果第一次调用super将name和email参数传递给Contact.__init__,然后Contact.__init__然后调用super,则它必须能够将与地址相关的参数传递给“ 下一个”方法,即AddressHolder.__init__。
每当我们想用相同的名称但使用不同的参数集调用超类方法时,这就是一个问题。 通常,您唯一想调用带有完全不同的参数集的超类的时间是__init__,就像我们在这里所做的那样。 即使使用常规方法,我们也可能希望添加仅对一个子类或一组子类有意义的可选参数。
可悲的是,解决此问题的唯一方法是从头开始进行规划。 我们必须设计我们的基类参数列表,以接受每个子类实现不需要的任何参数的关键字参数。 最后,我们必须确保该方法自由地接受意外的参数,并将其传递给其super调用,以防它们对于继承顺序中的后续方法是必需的。
Python 的功能参数语法提供了我们执行此操作所需的所有工具,但它使整体代码显得笨重。 看一下Friend多重继承代码的正确版本:
class Contact:
all_contacts = []
def __init__(self, name='', email='', **kwargs):
super().__init__(**kwargs)
self.name = name
self.email = email
self.all_contacts.append(self)
class AddressHolder:
def __init__(self, street='', city='', state='', code='',
**kwargs):
super().__init__(**kwargs)
self.street = street
self.city = city
self.state = state
self.code = code
class Friend(Contact, AddressHolder):
def __init__(self, phone='', **kwargs):
super().__init__(**kwargs)
self.phone = phone
通过将空字符串作为默认值,我们将所有参数更改为关键字参数。 我们还确保包含一个**kwargs参数,以捕获我们的特定方法不知道如何处理的任何其他参数。 它将通过super调用将这些参数传递到下一个类。
注意
如果您不熟悉**kwargs语法,则它基本上会收集传递到该方法中但未在参数列表中明确列出的所有关键字参数。 这些参数存储在名为kwargs的字典中(我们可以随意调用该变量,但习惯上建议使用kw或kwargs)。 当我们使用**kwargs语法调用其他方法(例如super().__init__)时,它将解压缩字典并将结果作为常规关键字参数传递给该方法。 我们将在第 7 章和 Python 面向对象的快捷方式中对此进行详细介绍。
前面的示例完成了应做的工作。 但是它开始显得凌乱,并且已经很难回答以下问题:我们需要将哪些参数传递给 Friend.__init__? 对于任何打算使用该类的人来说,这是最重要的问题,因此应在方法中添加一个文档字符串以说明正在发生的情况。
此外,如果我们想重用父类中的变量,那么即使实现也不足够。 当我们将**kwargs变量传递给super时,词典不包含任何作为显式关键字参数包含的变量。 例如,在Friend.__init__中,对super的调用在kwargs词典中没有phone。 如果其他任何类需要phone参数,则需要确保它在传递的字典中。 更糟糕的是,如果我们忘记这样做,那么调试将很困难,因为超类不会抱怨,而只会将默认值(在这种情况下为空字符串)分配给变量。
有几种方法可以确保将变量向上传递。 出于某种原因,假设Contact类确实需要使用phone参数进行初始化,并且Friend类也需要对其进行访问。 我们可以执行以下任一操作:
- 不要将
phone用作显式关键字参数。 而是将其保留在kwargs词典中。Friend可以使用语法kwargs['phone']进行查找。 当它将**kwargs传递给super呼叫时,phone仍将在词典中。 - 将
phone设为显式关键字参数,但使用标准字典语法kwargs['phone'] = phone将kwargs字典传递给super之前,先对其进行更新。 - 使
phone为显式关键字参数,但使用kwargs.update方法更新kwargs词典。 如果您有几个要更新的参数,这将很有用。 您可以使用dict(phone=phone)构造函数或字典语法{'phone': phone}创建传递到update的字典。 - 使
phone为显式关键字参数,但使用语法super().__init__(phone=phone, **kwargs)将其显式传递给超级调用。
我们已经讨论了 Python 中涉及多重继承的许多警告。 当我们需要考虑所有可能的情况时,我们必须为它们做计划,我们的代码会变得混乱。 基本的多重继承可能很方便,但是在很多情况下,我们可能希望选择一种更透明的方式来组合两个不同的类,通常使用合成或我们将在第 10 章中介绍的一种设计模式。 ,“Python 设计模式 I” 和第 11 章,“Python 设计模式 II”。
多态性
在第 1 章,“面向对象设计”中向我们介绍了多态性。 这是一个很简单的名字,描述了一个简单的概念:根据所使用的子类而发生不同的行为,而不必明确知道该子类实际上是什么。 例如,假设有一个播放音频文件的程序。 媒体播放器可能需要先加载AudioFile对象,然后再加载play。 我们在对象上放置了play()方法,该方法负责解压缩或提取音频并将其路由到声卡和扬声器。 播放AudioFile的操作可能很简单,例如:
audio_file.play()
但是,对于不同类型的文件,解压缩和提取音频文件的过程非常不同。 .wav文件未压缩地存储,而.mp3,.wma和.ogg文件均具有完全不同的压缩算法。
我们可以将继承与多态一起使用以简化设计。 每种类型的文件都可以由AudioFile的不同子类表示,例如WavFile,MP3File。 每个文件都有一个play()方法,但是对于每个文件,该方法将以不同的方式实现,以确保遵循正确的提取过程。 媒体播放器对象将永远不需要知道它所指的是AudioFile的哪个子类。 它只是调用play()并以多态方式让对象处理播放的实际细节。 让我们看一个快速的骨架,显示它的外观:
class AudioFile:
def __init__(self, filename):
if not filename.endswith(self.ext):
raise Exception("Invalid file format")
self.filename = filename
class MP3File(AudioFile):
ext = "mp3"
def play(self):
print("playing {} as mp3".format(self.filename))
class WavFile(AudioFile):
ext = "wav"
def play(self):
print("playing {} as wav".format(self.filename))
class OggFile(AudioFile):
ext = "ogg"
def play(self):
print("playing {} as ogg".format(self.filename))
所有音频文件都会检查以确保在初始化时给出了有效的扩展名。 但是您是否注意到父类中的__init__方法如何能够从不同的子类访问ext类变量? 那就是工作中的多态性。 如果文件名后缀名不正确,则会引发异常(下一章将详细讨论例外情况)。 AudioFile实际上没有存储对ext变量的引用这一事实并不能阻止它能够在子类上对其进行访问。
此外,AudioFile的每个子类都以不同的方式实现play()(此示例实际上并未播放音乐;音频压缩算法确实值得一个单独的模块!)。 这也是行动中的多态性。 媒体播放器可以使用完全相同的代码来播放文件,而不管文件的类型是什么。 它不在乎它正在查看AudioFile的哪个子类。 解压缩音频文件的详细信息封装在[HTG4]中。 如果我们测试此示例,它将按我们希望的那样工作:
>>> ogg = OggFile("myfile.ogg")
>>> ogg.play()
playing myfile.ogg as ogg
>>> mp3 = MP3File("myfile.mp3")
>>> mp3.play()
playing myfile.mp3 as mp3
>>> not_an_mp3 = MP3File("myfile.ogg")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "polymorphic_audio.py", line 4, in __init__
raise Exception("Invalid file format")
Exception: Invalid file format
看看AudioFile.__init__如何能够在不真正知道其引用的子类的情况下检查文件类型?
实际上,多态是有关面向对象编程的最酷的事情之一,它使某些编程设计变得显而易见,而这在早期的范例中是不可能的。 但是,由于使用鸭子输入,Python 使多态性变得不那么酷。 在 Python 中进行鸭子输入可以让我们使用任何提供所需行为的对象,而不必强制其成为子类。 Python 的动态特性使其变得无关紧要。 以下示例未扩展AudioFile,但可以使用完全相同的接口在 Python 中进行交互:
class FlacFile:
def __init__(self, filename):
if not filename.endswith(".flac"):
raise Exception("Invalid file format")
self.filename = filename
def play(self):
print("playing {} as flac".format(self.filename))
我们的媒体播放器可以像扩展AudioFile一样轻松地播放此对象。
多态性是在许多面向对象的上下文中使用继承的最重要原因之一。 由于任何提供正确接口的对象都可以在 Python 中互换使用,因此减少了对多态通用超类的需求。 继承对于共享代码仍然有用,但是,如果要共享的只是公共接口,那么只需要鸭子输入即可。 对继承的需求的减少也减少了对多重继承的需求。 通常,当多重继承看起来是一个有效的解决方案时,我们可以使用鸭子类型来模仿多个超类之一。
当然,仅仅因为一个对象满足特定的接口(通过提供所需的方法或属性)并不意味着它会在所有情况下都能正常工作。 它必须以整个系统中有意义的方式实现该接口。 仅仅因为对象提供play()方法并不意味着它会自动与媒体播放器一起使用。 例如,我们来自第 1 章,“面向对象设计”的国际象棋 AI 对象可能具有移动棋子的play()方法。 即使它满足界面要求,但如果我们尝试将其插入媒体播放器,则此类可能会以惊人的方式中断!
鸭子类型的另一个有用的功能是,鸭子类型的对象仅需要提供那些实际上已被访问的方法和属性。 例如,如果我们需要创建一个假文件对象来读取数据,则可以创建一个具有read()方法的新对象; 如果将要与该对象交互的代码仅从文件中读取,则不必重写write方法。 更简洁地说,鸭子类型不需要提供可用对象的整个接口,它只需要满足实际访问的接口即可。
抽象基类
鸭子类型很有用,但要预先告知某个类是否将满足您所需的协议并不总是那么容易。 因此,Python 引入了抽象基类的思想。 抽象基类或 ABC 定义了一个类必须实现的一组方法和属性,才能被视为该类的鸭子型实例。 该类可以扩展抽象基类本身,以便用作该类的实例,但是它必须提供所有适当的方法。
在实践中,几乎没有必要创建新的抽象基类,但我们可能会发现实现现有 ABC 实例的机会。 我们将首先介绍如何实现 ABC,然后简要介绍如何在需要时创建自己的 ABC。
使用抽象基类
Python 标准库中存在的大多数抽象基类都位于collections模块中。 最简单的一种是Container类。 让我们在 Python 解释器中检查它,以了解此类需要哪些方法:
>>> from collections import Container
>>> Container.__abstractmethods__
frozenset(['__contains__'])
因此,Container类只有一种需要实现的抽象方法__contains__。 您可以发出help(Container.__contains__)来查看功能签名的外观:
Help on method __contains__ in module _abcoll:__contains__(self, x) unbound _abcoll.Container method
因此,我们看到__contains__需要接受一个参数。 不幸的是,帮助文件没有告诉我们该参数应该是什么,但是从 ABC 的名称和它实现的单个方法中可以很明显地看出,该参数是用户正在检查以查看容器是否容纳的值 。
该方法由list,str和dict实现,以指示给定值是否在该数据结构中。 但是,我们还可以定义一个愚蠢的容器,该容器告诉我们给定值是否在奇数整数集中:
class OddContainer:
def __contains__(self, x):
if not isinstance(x, int) or not x % 2:
return False
return True
现在,我们可以实例化OddContainer对象并确定,即使我们没有扩展Container,类也是 Container对象:
>>> from collections import Container
>>> odd_container = OddContainer()
>>> isinstance(odd_container, Container)
True
>>> issubclass(OddContainer, Container)
True
这就是为什么鸭子类型比经典多态性更出色的原因。 我们可以创建是关系,而没有使用继承(或更糟糕的是,多重继承)的开销。
关于Container ABC 的有趣之处在于,实现它的任何类都可以免费使用in关键字。 实际上,in只是委派给__contains__方法的语法糖。 具有__contains__方法的任何类都是Container,因此可以通过in关键字查询,例如:
>>> 1 in odd_container
True
>>> 2 in odd_container
False
>>> 3 in odd_container
True
>>> "a string" in odd_container
False
创建抽象基类
正如我们先前看到的一样,不必具有抽象基类来启用鸭子类型。 但是,想象一下我们正在创建一个带有第三方插件的媒体播放器。 在这种情况下,建议创建一个抽象基类以记录第三方插件应提供的 API。 abc模块提供了执行此操作所需的工具,但是我会提前警告您,这需要一些 Python 最神秘的概念:
import abc
class MediaLoader(metaclass=abc.ABCMeta):
@abc.abstractmethod
def play(self):
pass
@abc.abstractproperty
def ext(self):
pass
@classmethod
def __subclasshook__(cls, C):
if cls is MediaLoader:
attrs = set(dir(C))
if set(cls.__abstractmethods__) <= attrs:
return True
return NotImplemented
这是一个复杂的示例,其中包含一些 Python 功能,直到本模块稍后再介绍。 为了完整起见,此处包含了它,但是您无需了解所有内容就可以了解如何创建自己的 ABC。
第一个奇怪的是传递给类的metaclass关键字参数,通常您会在该类中看到父类的列表。 这是从元类编程的神秘艺术中很少使用的构造。 我们不会在本模块中介绍元类,因此您所需要知道的是,通过分配ABCMeta元类,您可以赋予您的类超能力(或至少是超类)能力。
接下来,我们看到@abc.abstractmethod和@abc.abstractproperty构造。 这些是 Python 装饰器。 我们将在第 5 章,“何时使用面向对象编程”中讨论这些内容。 现在,只知道通过将方法或属性标记为抽象,就可以说明该类的任何子类都必须实现该方法或提供该属性,才能被视为该类的适当成员。
看看如果实现提供或不提供这些属性的子类会发生什么:
>>> class Wav(MediaLoader):
... pass
...
>>> x = Wav()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class Wav with abstract methods ext, play
>>> class Ogg(MediaLoader):
... ext = '.ogg'
... def play(self):
... pass
...
>>> o = Ogg()
由于Wav类无法实现抽象属性,因此无法实例化该类。 该类仍然是合法的抽象类,但是您必须对其进行子类化才能实际执行任何操作。 Ogg类提供这两个属性,因此它可以干净地实例化。
回到MediaLoader ABC,我们来剖析__subclasshook__方法。 基本上是说,提供该 ABC 所有抽象属性的具体实现的任何类都应被视为MediaLoader的子类,即使它实际上不是从MediaLoader类继承的也是如此。
更常见的面向对象语言在接口和类的实现之间有着清晰的分隔。 例如,某些语言提供了显式的interface关键字,该关键字使我们可以定义类必须具有的方法而无需任何实现。 在这样的环境中,抽象类是一种既提供接口又提供某些但不是全部方法的具体实现的类。 任何类都可以明确声明其实现了给定的接口。
Python 的 ABC 有助于提供接口功能,而不会影响鸭子输入的好处。
揭开魔术的神秘面纱
如果要创建满足此特定约定的抽象类,则可以在不了解子类代码的情况下复制并粘贴。 我们将在整个模块中介绍大多数不寻常的语法,但让我们逐行对其进行概述。
@classmethod
该装饰器将该方法标记为类方法。 本质上说,可以在类上而不是实例化对象上调用该方法:
def __subclasshook__(cls, C):
这定义了__subclasshook__类方法。 Python 解释器调用此特殊方法来回答问题:类 C 是此类的子类吗?
if cls is MediaLoader:
我们检查该方法是否专门在此类上调用,而不是说此类的子类。 例如,这可以防止Wav类被视为Ogg类的父类:
attrs = set(dir(C))
此行所做的只是获取类具有的方法和属性集,包括其类层次结构中的所有父类:
if set(cls.__abstractmethods__) <= attrs:
此行使用集合符号来查看是否在候选类中提供了此类中的抽象方法集。 请注意,它不会检查方法是否已实现,即使它们在那里也是如此。 因此,一个类有可能是一个子类,而本身仍然是一个抽象类。
return True
如果提供了所有抽象方法,则候选类为该类的子类,我们返回True。 该方法可以合法地返回三个值之一:True,False或NotImplemented。 True和False表示该类绝对是该类的子类:
return NotImplemented
如果未满足任何条件(即,该类不是MediaLoader或未提供所有抽象方法),则返回NotImplemented。 这告诉 Python 机制使用默认机制(候选类是否显式扩展了该类?)进行子类检测。
简而言之,我们现在可以将Ogg类定义为MediaLoader类的子类,而无需实际扩展MediaLoader类:
>>> class Ogg():
... ext = '.ogg'
... def play(self):
... print("this will play an ogg file")
...
>>> issubclass(Ogg, MediaLoader)
True
>>> isinstance(Ogg(), MediaLoader)
True
案例研究
让我们尝试将我们学到的所有内容与一个更大的例子联系在一起。 我们将设计一个简单的房地产应用,使代理可以管理可用于购买或出租的财产。 物业将分为两种:公寓和房屋。 代理需要能够输入有关新属性的一些相关详细信息,列出所有当前可用的属性,以及将属性标记为已出售或已租赁。 为简便起见,我们无需担心在出售后编辑属性详细信息或重新激活属性的问题。
该项目将允许代理使用 Python 解释器提示与对象进行交互。 在这个图形用户界面和 Web 应用的世界里,您可能想知道为什么我们要创建这种老式外观的程序。 简而言之,窗口化程序和 Web 应用都需要大量开销知识和样板代码才能使它们执行所需的操作。 如果我们使用这两种范例中的任何一种来开发软件,那么我们会在 GUI 编程或 Web 编程中迷失方向,以至于忽视了我们试图掌握的面向对象原理。
幸运的是,大多数 GUI 和 Web 框架都采用了面向对象的方法,而我们现在正在研究的原理将有助于将来理解这些系统。 我们将在第 13 章,“并发”中简要讨论它们,但是完整的细节远远超出了单个模块的范围。
查看我们的要求,似乎有很多名词可以表示系统中的对象类别。 显然,我们需要表示一个属性。 房屋和公寓可能需要单独的课程。 租金和购买似乎也需要单独的代表。 由于我们现在专注于继承,因此我们将研究使用继承或多继承共享行为的方法。
House和Apartment都是属性的类型,因此Property可以是这两个类的超类。 Rental和Purchase需要额外考虑; 如果我们使用继承,则需要有单独的类,例如HouseRental和HousePurchase,并使用多重继承将它们组合在一起。 与基于合成或基于关联的设计相比,这感觉有些笨拙,但是让我们一起运行它,看看我们提出了什么。
现在,哪些属性可能与Property类相关联? 无论是公寓还是房屋,大多数人都想知道平方英尺,卧室数量和浴室数量。 (还有许多其他可以建模的属性,但对于我们的原型,我们将使其保持简单。)
如果该物业是一栋房屋,它将要宣传楼层的数量,是否有车库(有,独立或无人车库)以及院子是否被围起来。 公寓将要指出是否有阳台,以及洗衣房是套房式,硬币式还是非现场式。
这两种属性类型都需要一种方法来显示该属性的特征。 目前,没有其他行为可见。
租赁物业将需要存储每月的租金,物业是否配备,是否包括公用事业,如果不包含,则应估算其租金。 购买的物业将需要存储购买价格和估计的年度物业税。 对于我们的应用,我们只需要显示此数据,因此我们只需添加类似于其他类中使用的display()方法就可以摆脱困境。
最后,我们需要一个Agent对象,该对象保存所有属性的列表,显示这些属性,并允许我们创建新属性。 创建属性将需要提示用户输入每种属性类型的相关详细信息。 可以在Agent对象中完成此操作,但随后Agent将需要了解许多有关属性类型的信息。 这没有利用多态性。 另一种选择是将提示放在每个类的初始化器甚至是构造函数中,但这将不允许将来在 GUI 或 Web 应用中应用这些类。 更好的主意是创建一个执行提示并返回提示参数字典的静态方法。 然后,Agent所要做的就是提示用户输入财产的类型和付款方式,并要求正确的类实例化自己。
大量的设计! 下面的类图可以更清晰地传达我们的设计决策:
哇,那是很多继承箭头! 我认为不加箭头就不可能添加另一个继承级别。 即使在设计阶段,多重继承也很麻烦。
这些类最棘手的方面将是确保在继承层次结构中调用超类方法。 让我们从Property实现开始:
class Property:
def __init__(self, square_feet='', beds='',
baths='', **kwargs):
super().__init__(**kwargs)
self.square_feet = square_feet
self.num_bedrooms = beds
self.num_baths = baths
def display(self):
print("PROPERTY DETAILS")
print("================")
print("square footage: {}".format(self.square_feet))
print("bedrooms: {}".format(self.num_bedrooms))
print("bathrooms: {}".format(self.num_baths))
print()
def prompt_init():
return dict(square_feet=input("Enter the square feet: "),
beds=input("Enter number of bedrooms: "),
baths=input("Enter number of baths: "))
prompt_init = staticmethod(prompt_init)
这个类非常简单。 我们已经在__init__中添加了额外的**kwargs参数,因为我们知道它将在多重继承情况下使用。 如果我们不是多重继承链中的最后一个调用,我们还包括了对super().__init__的调用。 在这种情况下,我们正在使用关键字参数,因为我们知道在继承层次结构的其他级别上将不需要它们。
我们在prompt_init方法中看到了一些新内容。 最初创建此方法后,立即将其变为静态方法。 静态方法仅与类(类似于类变量)相关联,而不与特定的对象实例相关联。 因此,它们没有self参数。 因此,super关键字将不起作用(没有父对象,只有父类),因此我们直接在父类上直接调用 static 方法。 此方法使用 Python dict构造函数创建一个值字典,该值可以传递到__init__中。 调用input会提示每个键的值。
Apartment类扩展了Property,其结构类似:
class Apartment(Property):
valid_laundries = ("coin", "ensuite", "none")
valid_balconies = ("yes", "no", "solarium")
def __init__(self, balcony='', laundry='', **kwargs):
super().__init__(**kwargs)
self.balcony = balcony
self.laundry = laundry
def display(self):
super().display()
print("APARTMENT DETAILS")
print("laundry: %s" % self.laundry)
print("has balcony: %s" % self.balcony)
def prompt_init():
parent_init = Property.prompt_init()
laundry = ''
while laundry.lower() not in \
Apartment.valid_laundries:
laundry = input("What laundry facilities does "
"the property have? ({})".format(
", ".join(Apartment.valid_laundries)))
balcony = ''
while balcony.lower() not in \
Apartment.valid_balconies:
balcony = input(
"Does the property have a balcony? "
"({})".format(
", ".join(Apartment.valid_balconies)))
parent_init.update({
"laundry": laundry,
"balcony": balcony
})
return parent_init
prompt_init = staticmethod(prompt_init)
display()和__init__()方法使用super()调用各自的父类方法,以确保Property类被正确初始化。
prompt_init静态方法现在从父类获取字典值,然后添加其自身的一些其他值。 它调用dict.update方法将新的字典值合并到第一个字典中。 但是,该prompt_init方法看起来很丑陋。 它会循环两次,直到用户使用结构相似的代码但变量不同来输入有效输入。 提取此验证逻辑会很好,因此我们只能将其维护在一个位置。 这对以后的课程也很有用。
关于继承的所有讨论,我们可能会认为这是使用 mixin 的好地方。 相反,我们有机会研究继承不是最佳解决方案的情况。 我们要创建的方法将在静态方法中使用。 如果要从提供验证功能的类继承,则也必须以不访问该类上任何实例变量的静态方法提供该功能。 如果它不访问任何实例变量,那么使其完全成为类的意义何在? 为什么我们不只是将此验证功能设为接受输入字符串和有效答案列表的模块级功能,而只保留它呢?
让我们探讨一下此验证函数的外观:
def get_valid_input(input_string, valid_options):
input_string += " ({}) ".format(", ".join(valid_options))
response = input(input_string)
while response.lower() not in valid_options:
response = input(input_string)
return response
我们可以在解释器中测试此功能,而与我们一直在研究的所有其他类无关。 这是一个好兆头,这意味着我们设计的不同部分之间没有紧密耦合,并且可以在不影响其他代码部分的情况下独立进行改进。
>>> get_valid_input("what laundry?", ("coin", "ensuite", "none"))
what laundry? (coin, ensuite, none) hi
what laundry? (coin, ensuite, none) COIN
'COIN'
现在,让我们快速更新Apartment.prompt_init方法以使用此新功能进行验证:
def prompt_init():
parent_init = Property.prompt_init()
laundry = get_valid_input(
"What laundry facilities does "
"the property have? ",
Apartment.valid_laundries)
balcony = get_valid_input(
"Does the property have a balcony? ",
Apartment.valid_balconies)
parent_init.update({
"laundry": laundry,
"balcony": balcony
})
return parent_init
prompt_init = staticmethod(prompt_init)
与我们的原始版本相比,更易于阅读(和维护!)。 现在我们准备构建House类。 此类具有与Apartment并行的结构,但是引用了不同的提示和变量:
class House(Property):
valid_garage = ("attached", "detached", "none")
valid_fenced = ("yes", "no")
def __init__(self, num_stories='',
garage='', fenced='', **kwargs):
super().__init__(**kwargs)
self.garage = garage
self.fenced = fenced
self.num_stories = num_stories
def display(self):
super().display()
print("HOUSE DETAILS")
print("# of stories: {}".format(self.num_stories))
print("garage: {}".format(self.garage))
print("fenced yard: {}".format(self.fenced))
def prompt_init():
parent_init = Property.prompt_init()
fenced = get_valid_input("Is the yard fenced? ",
House.valid_fenced)
garage = get_valid_input("Is there a garage? ",
House.valid_garage)
num_stories = input("How many stories? ")
parent_init.update({
"fenced": fenced,
"garage": garage,
"num_stories": num_stories
})
return parent_init
prompt_init = staticmethod(prompt_init)
这里没有的新知识,因此让我们继续进行Purchase和Rental类。 尽管目的明显不同,但它们在设计上也与我们刚刚讨论的目的相似:
class Purchase:
def __init__(self, price='', taxes='', **kwargs):
super().__init__(**kwargs)
self.price = price
self.taxes = taxes
def display(self):
super().display()
print("PURCHASE DETAILS")
print("selling price: {}".format(self.price))
print("estimated taxes: {}".format(self.taxes))
def prompt_init():
return dict(
price=input("What is the selling price? "),
taxes=input("What are the estimated taxes? "))
prompt_init = staticmethod(prompt_init)
class Rental:
def __init__(self, furnished='', utilities='',
rent='', **kwargs):
super().__init__(**kwargs)
self.furnished = furnished
self.rent = rent
self.utilities = utilities
def display(self):
super().display()
print("RENTAL DETAILS")
print("rent: {}".format(self.rent))
print("estimated utilities: {}".format(
self.utilities))
print("furnished: {}".format(self.furnished))
def prompt_init():
return dict(
rent=input("What is the monthly rent? "),
utilities=input(
"What are the estimated utilities? "),
furnished = get_valid_input(
"Is the property furnished? ",
("yes", "no")))
prompt_init = staticmethod(prompt_init)
这两个类没有超类(object除外),但我们仍将其称为super().__init__,因为它们将与其他类结合使用,并且我们不知道super的顺序 将进行调用。该接口类似于House和Apartment所使用的接口,当我们将这四个类的功能组合到单独的子类中时,该接口非常有用。 例如:
class HouseRental(Rental, House):
def prompt_init():
init = House.prompt_init()
init.update(Rental.prompt_init())
return init
prompt_init = staticmethod(prompt_init)
这有点令人惊讶,因为类本身既没有__init__也没有display方法! 因为两个父类都在这些方法中适当地调用了super,所以我们仅需扩展这些类,并且这些类将以正确的顺序运行。 当然,prompt_init并非如此,因为它是一个静态方法,不会调用super,因此我们明确实现了这一方法。 在编写其他三个组合之前,我们应该测试此类以确保其行为正确:
>>> init = HouseRental.prompt_init()
Enter the square feet: 1
Enter number of bedrooms: 2
Enter number of baths: 3
Is the yard fenced? (yes, no) no
Is there a garage? (attached, detached, none) none
How many stories? 4
What is the monthly rent? 5
What are the estimated utilities? 6
Is the property furnished? (yes, no) no
>>> house = HouseRental(**init)
>>> house.display()
PROPERTY DETAILS
================
square footage: 1
bedrooms: 2
bathrooms: 3
HOUSE DETAILS
# of stories: 4
garage: none
fenced yard: no
RENTAL DETAILS
rent: 5
estimated utilities: 6
furnished: no
看起来工作正常。 prompt_init方法正在提示所有超类的初始化程序,display()也正在协作调用所有三个超类。
注意
前面示例中继承类的顺序很重要。 如果我们写的是class HouseRental(House, Rental)而不是class HouseRental(Rental, House),则display()不会调用Rental.display()! 在我们的HouseRental版本上调用display时,它指的是该方法的Rental版本,该方法调用super.display()获得House版本,然后再次调用super.display()获得属性版本。 。 如果我们将其反转,则display将引用House类的display()。 调用 super 时,它将调用Property父类的方法。 但是Property在其display方法中没有对super的调用。 这意味着将不会调用Rental类的display方法! 通过按照我们执行的顺序放置继承列表,我们确保Rental调用super,这将处理层次结构的House端。 您可能以为我们可以在Property.display()中添加super调用,但这会失败,因为Property的下一个超类是object,并且object没有display方法。 解决此问题的另一种方法是允许Rental和Purchase扩展Property类,而不是直接从object派生。 (或者我们可以动态修改方法的解析顺序,但这超出了本模块的范围。)
现在我们已经测试了它,我们准备创建其余的组合子类:
class ApartmentRental(Rental, Apartment):
def prompt_init():
init = Apartment.prompt_init()
init.update(Rental.prompt_init())
return init
prompt_init = staticmethod(prompt_init)
class ApartmentPurchase(Purchase, Apartment):
def prompt_init():
init = Apartment.prompt_init()
init.update(Purchase.prompt_init())
return init
prompt_init = staticmethod(prompt_init)
class HousePurchase(Purchase, House):
def prompt_init():
init = House.prompt_init()
init.update(Purchase.prompt_init())
return init
prompt_init = staticmethod(prompt_init)
那应该是我们最紧张的设计! 现在,我们要做的就是创建Agent类,该类负责创建新列表并显示现有列表。 让我们从更简单的属性存储和列出开始:
class Agent:
def __init__(self):
self.property_list = []
def display_properties(self):
for property in self.property_list:
property.display()
添加属性需要首先查询属性的类型以及属性是用于购买还是出租。 我们可以通过显示一个简单的菜单来做到这一点。 一旦确定了这一点,我们就可以使用我们已经开发的prompt_init层次结构提取正确的子类并提示所有详细信息。 听起来很简单? 它是。 首先,向Agent类添加一个字典类变量:
type_map = {
("house", "rental"): HouseRental,
("house", "purchase"): HousePurchase,
("apartment", "rental"): ApartmentRental,
("apartment", "purchase"): ApartmentPurchase
}
那是一些漂亮的有趣的代码。 这是一本字典,其中的键是两个不同字符串的元组,而值是类对象。 类对象? 是的,可以像普通对象或原始数据类型一样,将类传递,重命名并存储在容器中。 使用这个简单的字典,我们可以简单地劫持我们先前的get_valid_input方法,以确保获得正确的字典键并查找适当的类,如下所示:
def add_property(self):
property_type = get_valid_input(
"What type of property? ",
("house", "apartment")).lower()
payment_type = get_valid_input(
"What payment type? ",
("purchase", "rental")).lower()
PropertyClass = self.type_map[
(property_type, payment_type)]
init_args = PropertyClass.prompt_init()
self.property_list.append(PropertyClass(**init_args))
这也可能看起来很有趣! 我们在字典中查找该类,并将其存储在名为PropertyClass的变量中。 我们不确切知道哪个类可用,但是该类自己知道,因此我们可以多态调用prompt_init以获取适合传递给构造函数的值的字典。 然后,我们使用关键字参数语法将字典转换为参数,并构造新对象以加载正确的数据。
现在,我们的用户可以使用此Agent类添加和查看属性列表。 添加功能以将属性标记为可用或不可用,或者编辑和删除属性都不需要太多的工作。 我们的原型现在处于足够好的状态,可以带入房地产agent并演示其功能。 这是演示会话的工作方式:
>>> agent = Agent()
>>> agent.add_property()
What type of property? (house, apartment) house
What payment type? (purchase, rental) rental
Enter the square feet: 900
Enter number of bedrooms: 2
Enter number of baths: one and a half
Is the yard fenced? (yes, no) yes
Is there a garage? (attached, detached, none) detached
How many stories? 1
What is the monthly rent? 1200
What are the estimated utilities? included
Is the property furnished? (yes, no) no
>>> agent.add_property()
What type of property? (house, apartment) apartment
What payment type? (purchase, rental) purchase
Enter the square feet: 800
Enter number of bedrooms: 3
Enter number of baths: 2
What laundry facilities does the property have? (coin, ensuite,
one) ensuite
Does the property have a balcony? (yes, no, solarium) yes
What is the selling price? $200,000
What are the estimated taxes? 1500
>>> agent.display_properties()
PROPERTY DETAILS
================
square footage: 900
bedrooms: 2
bathrooms: one and a half
HOUSE DETAILS
# of stories: 1
garage: detached
fenced yard: yes
RENTAL DETAILS
rent: 1200
estimated utilities: included
furnished: no
PROPERTY DETAILS
================
square footage: 800
bedrooms: 3
bathrooms: 2
APARTMENT DETAILS
laundry: ensuite
has balcony: yes
PURCHASE DETAILS
selling price: $200,000
estimated taxes: 1500