Python3 面向对象编程第二版(一)

原文:zh.annas-archive.org/md5/B484D481722F7AFA9E5B1ED7225BED43

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

这本书介绍了面向对象范式的术语。它专注于逐步示例的面向对象设计。它将引导我们从简单的继承,作为面向对象程序员工具箱中最有用的工具之一,通过异常处理到设计模式,这是一种以面向对象方式看待面向对象概念的方法。

在学习的过程中,我们将学会整合 Python 编程语言中面向对象和非面向对象的方面。我们将学习字符串和文件操作的复杂性,强调(正如 Python 3 所做的那样)二进制和文本数据之间的区别。

然后,我们将涵盖单元测试的乐趣,使用不止一个,而是两个单元测试框架。最后,我们将通过 Python 的各种并发范例,探讨如何使对象在同一时间内良好地协同工作。

本书涵盖的内容

这本书大致分为四个主要部分。在前四章中,我们将深入了解面向对象编程的正式原则以及 Python 如何利用它们。在第五章到第八章,我们将覆盖 Python 对这些原则的一些特殊应用,学习它们如何应用于各种 Python 内置函数。第九章到第十一章涵盖了设计模式,最后两章讨论了与 Python 编程相关的两个额外主题,可能会引起兴趣。

第一章,“面向对象设计”,涵盖了重要的面向对象概念。它主要涉及术语,如抽象、类、封装和继承。我们还简要地看一下 UML 来模拟我们的类和对象。

第二章,“Python 中的对象”,讨论了类和对象以及它们在 Python 中的使用。我们将了解 Python 对象的属性和行为,以及类如何组织成包和模块。最后,我们将看到如何保护我们的数据。

第三章,“当对象相似时”,让我们更深入地了解继承。它涵盖了多重继承,并向我们展示如何扩展内置功能。本章还涵盖了多态性和鸭子类型在 Python 中的工作原理。

第四章,“预料之外”,探讨了异常和异常处理。我们将学习如何创建自己的异常以及如何使用异常来控制程序流程。

第五章,“何时使用面向对象编程”,涉及创建和使用对象。我们将看到如何使用属性包装数据并限制数据访问。本章还讨论了 DRY 原则以及如何避免重复代码。

第六章,“Python 数据结构”,涵盖了 Python 内置类的面向对象特性。我们将涵盖元组、字典、列表和集合,以及一些更高级的集合。我们还将看到如何扩展这些标准对象。

第七章,“Python 面向对象的快捷方式”,顾名思义,涉及 Python 中的时间节省技巧。我们将看到许多有用的内置函数,如使用默认参数进行方法重载。我们还将看到函数本身也是对象,以及这如何有用。

第八章,“字符串和序列化”,讨论了字符串、文件和格式化。我们将讨论字符串、字节和字节数组之间的区别,以及将文本、对象和二进制数据序列化为几种规范表示的各种方法。

第九章,迭代器模式,向我们介绍了设计模式的概念,并涵盖了 Python 标志性的迭代器模式实现。我们将学习列表、集合和字典推导式。我们还将揭开生成器和协程的神秘面纱。

第十章,Python 设计模式 I,涵盖了几种设计模式,包括装饰器、观察者、策略、状态、单例和模板模式。每个模式都将通过适当的例子和 Python 中实现的程序进行讨论。

第十一章,Python 设计模式 II,总结了我们对设计模式的讨论,涵盖了适配器、外观、享元、命令、抽象和组合模式。提供了更多关于惯用 Python 代码与规范实现的例子。

第十二章,测试面向对象程序,首先介绍了为什么测试在 Python 应用程序中如此重要。它强调了测试驱动开发,并介绍了两种不同的测试套件:unittest 和 py.test。最后,它讨论了模拟测试对象和代码覆盖率。

第十三章,并发,是对 Python 对并发模式的支持(或不支持)的快速概述。它讨论了线程、多进程、futures 和新的 AsyncIO 库。

每一章都包括相关的例子和一个案例研究,将章节内容整合成一个可运行(尽管不完整)的程序。

本书需要什么

本书中的所有例子都依赖于 Python 3 解释器。请确保您没有使用 Python 2.7 或更早的版本。在撰写本文时,Python 3.4 是最新版本。大多数例子将在较早版本的 Python 3 上运行,但建议您使用最新版本以减少挫折感。

本书中的所有例子都应该在 Python 支持的任何操作系统上运行。如果不是这种情况,请将其报告为错误。

一些例子需要一个可用的互联网连接。您可能会希望进行课外研究和调试!

此外,本书中的一些例子依赖于不随 Python 一起提供的第三方库。它们在使用时会在书中介绍,因此您无需提前安装它们。但是,为了完整起见,这里是一个列表:

  • pip

  • requests

  • pillow

  • bitarray

本书适合人群

本书专门针对新手面向对象编程的人群。它假设您具有基本的 Python 技能。您将深入学习面向对象的原则。对于将 Python 用作“粘合”语言并希望提高编程技能的系统管理员类型来说,这是特别有用的。

如果您熟悉其他语言中的面向对象编程,那么本书将帮助您了解在 Python 生态系统中应用您的知识的惯用方式。

约定

本书使用各种文本样式来区分不同类型的信息。以下是一些这些样式的示例,以及它们的含义解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“我们在字典中查找类并将其存储在名为PropertyClass的变量中。”

一段代码设置如下:

    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()

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

    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()

任何命令行输入或输出都写成如下形式:

>>> 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']

新术语重要单词以粗体显示。您在屏幕上看到的单词,比如菜单或对话框中的单词,会以这样的方式出现在文本中:“它将失败,并显示一个参数不足的错误,类似于我们之前忘记了self参数时收到的错误。”

警告或重要说明会显示在这样的框中。

提示

提示和技巧会显示在这样的格式中。

第一章:面向对象设计

在软件开发中,设计通常被认为是编程之前的步骤。这并不正确;实际上,分析、编程和设计往往会重叠、结合和交织在一起。在本章中,我们将涵盖以下主题:

  • 面向对象的含义

  • 面向对象设计和面向对象编程之间的区别

  • 面向对象设计的基本原则

  • 基本统一建模语言UML)及其不邪恶的时候

介绍面向对象

每个人都知道什么是对象——一种我们可以感知、感觉和操纵的有形物体。我们最早接触的对象通常是婴儿玩具。木块、塑料形状和超大拼图块是常见的第一个对象。婴儿很快学会了某些对象会做某些事情:铃铛会响,按钮会按下,杠杆会拉动。

在软件开发中,对象的定义并没有太大的不同。软件对象通常不是你可以拿起、感知或感觉到的有形物体,但它们是某种可以做某些事情并且可以对其进行某些操作的模型。从正式的角度来看,对象是一组数据和相关的行为

因此,了解什么是对象,面向对象意味着什么?Oriented 简单地意味着朝向。因此,面向对象意味着功能上朝向建模对象。这是通过描述一组相互作用的对象及其数据和行为来对复杂系统进行建模的许多技术之一。

如果你读过任何炒作,你可能会遇到面向对象分析、面向对象设计、面向对象分析和设计以及面向对象编程等术语。这些都是与总体面向对象相关的高度相关的概念。

事实上,分析、设计和编程都是软件开发的各个阶段。将它们称为面向对象只是指定了正在追求的软件开发风格。

面向对象分析OOA)是查看问题、系统或任务(某人想要将其转化为应用程序)的过程,并识别对象和对象之间的交互。分析阶段关乎于需要做什么。

分析阶段的输出是一组需求。如果我们能够在一步中完成分析阶段,我们将把一个任务,比如,我需要一个网站,转化为一组需求。例如:

网站访问者需要能够(斜体代表动作,粗体代表对象):

  • 回顾我们的历史

  • 申请工作

  • 浏览比较订购产品

在某种程度上,分析是一个误称。我们之前讨论过的婴儿并不会分析积木和拼图。相反,它会探索其环境,操纵形状,并看看它们可能适合在哪里。更好的说法可能是面向对象的探索。在软件开发中,分析的初始阶段包括采访客户,研究他们的流程,并排除可能性。

面向对象设计OOD)是将这些要求转化为实现规范的过程。设计者必须命名对象,定义行为,并正式指定哪些对象可以激活其他对象上的特定行为。设计阶段关乎于如何做事情。

设计阶段的输出是一个实现规范。如果我们能够在一个步骤中完成设计阶段,我们将把在面向对象分析期间定义的需求转化为一组类和接口,这些类和接口可以在(理想情况下)任何面向对象的编程语言中实现。

面向对象编程OOP)是将这个完全定义的设计转化为一个完全符合 CEO 最初要求的工作程序的过程。

是的,没错!如果世界符合这个理想,我们可以按照旧教科书告诉我们的那样,按照完美的顺序依次遵循这些阶段,那将是可爱的。通常情况下,现实世界要复杂得多。无论我们多么努力地分离这些阶段,我们总会发现在设计时需要进一步分析的事情。当我们编程时,我们会发现设计中需要澄清的特性。

21 世纪的大多数开发都是以迭代开发模型进行的。在迭代开发中,任务的一小部分被建模、设计和编程,然后程序被审查和扩展,以改进每个特性并在一系列短期开发周期中包含新特性。

本书的其余部分是关于面向对象编程的,但在本章中,我们将在设计的背景下介绍基本的面向对象原则。这使我们能够在不必与软件语法或 Python 解释器争论的情况下理解这些(相当简单的)概念。

对象和类

因此,对象是具有相关行为的数据集合。我们如何区分对象的类型?苹果和橙子都是对象,但有一个常见的谚语说它们无法比较。在计算机编程中,苹果和橙子并不经常被建模,但让我们假设我们正在为一个果园做库存应用。为了便于例子,我们可以假设苹果放在桶里,橙子放在篮子里。

现在,我们有四种对象:苹果、橙子、篮子和桶。在面向对象建模中,用于对象类型的术语是。因此,在技术术语中,我们现在有四个对象类。

对象和类之间有什么区别?类描述对象。它们就像创建对象的蓝图。你可能在你面前的桌子上有三个橙子。每个橙子都是一个独特的对象,但所有三个都具有与一个类相关的属性和行为:橙子的一般类。

我们库存系统中四个对象类之间的关系可以使用统一建模语言(通常简称为UML,因为三个字母的缩写永远不会过时)类图来描述。这是我们的第一个类图:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这个图表显示了橙子篮子的某种关联,以及苹果的某种关联。关联是两个类之间最基本的关系方式。

UML 在经理中非常受欢迎,有时会受到程序员的贬低。UML 图的语法通常非常明显;当你看到一个时,你不必阅读教程就能(大部分)理解发生了什么。UML 也相当容易绘制,而且相当直观。毕竟,许多人在描述类及其关系时,自然会画出盒子和它们之间的线条。基于这些直观图表的标准使得程序员之间、设计师和经理之间的交流变得容易。

然而,一些程序员认为 UML 是浪费时间。他们会援引迭代开发,认为用花哨的 UML 图表制定的正式规范在实施之前就会变得多余,并且维护这些正式图表只会浪费时间,对任何人都没有好处。

根据涉及的公司结构,这可能是真的,也可能不是真的。然而,每个由多个人组成的编程团队都会偶尔坐下来讨论他们当前正在处理的子系统的细节。在这些头脑风暴会议中,UML 非常有用,可以进行快速而轻松的交流。即使那些嘲笑正式类图的组织也倾向于在他们的设计会议或团队讨论中使用某种非正式版本的 UML。

此外,您将要与之交流的最重要的人是您自己。我们都认为自己可以记住我们所做的设计决定,但在我们的未来中总会有隐藏着*我为什么要那样做?*的时刻。如果我们保存我们在开始设计时做初始图表的纸屑,最终我们会发现它们是一个有用的参考。

然而,本章并不意味着是 UML 的教程。互联网上有许多这样的教程,还有许多关于这个主题的书籍。UML 涵盖的远不止类和对象图表;它还有用例、部署、状态变化和活动的语法。在这次面向对象设计的讨论中,我们将处理一些常见的类图语法。您会发现您可以通过示例掌握结构,并且在您自己团队或个人设计会议中会下意识地选择受 UML 启发的语法。

我们的初始图表虽然是正确的,但并没有提醒我们苹果放入桶,或者一个苹果可以放入多少个桶。它只告诉我们苹果与桶以某种方式相关联。类之间的关联通常是显而易见的,不需要进一步解释,但我们可以根据需要添加进一步的澄清。

UML 的美妙之处在于大多数东西都是可选的。我们只需要在图表中指定与当前情况相关的信息。在一个快速的白板会议中,我们可能只是快速地在方框之间画线。在正式文件中,我们可能会更详细地说明。在苹果和桶的情况下,我们可以相当有信心地说,关联是,许多苹果放入一个桶,但为了确保没有人将其与,一个苹果糟蹋一个桶混淆,我们可以增强图表如下所示:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这个图表告诉我们橙子放入篮子,并用一个小箭头显示了什么放入了什么。它还告诉我们在关联的两端可以使用的对象的数量。一个Basket可以容纳许多(用表示的)Orange对象。任何一个Orange可以放入一个Basket*。这个数字被称为对象的多重性。您也可能听到它被描述为基数。这些实际上是略微不同的术语。基数指的是集合中的实际数量,而多重性指定了这个数字可以有多小或多大。

我经常忘记关系的多重性应该放在哪一边。离一个类最近的多重性是指与关联的另一端的任何一个对象相关联的该类对象的数量。对于苹果放入桶的关联,从左到右阅读,Apple类的许多实例(即许多Apple对象)可以放入任何一个Barrel中。从右到左阅读,确切地说,一个Barrel可以与任何一个Apple相关联。

指定属性和行为

现在我们对一些基本的面向对象术语有了了解。对象是可以相互关联的类的实例。对象实例是具有自己一组数据和行为的特定对象;我们面前桌子上的一个特定的橙子被称为是橙子这个一般类的一个实例。这很简单,但是与每个对象相关联的数据和行为是什么呢?

数据描述对象

让我们从数据开始。数据通常表示特定对象的个体特征。一个类可以定义所有该类对象共享的特定特征集。任何特定对象可以对给定特征具有不同的数据值。例如,我们桌子上的三个橙子(如果我们没有吃掉)可能每个重量都不同。然后橙子类可以有一个重量属性。橙子类的所有实例都有一个重量属性,但每个橙子对于这个属性有不同的值。然而,属性不一定是唯一的;任何两个橙子可能重量相同。作为一个更现实的例子,代表不同客户的两个对象可能具有相同的名字属性值。

属性经常被称为成员属性。一些作者认为这些术语有不同的含义,通常是属性是可设置的,而属性是只读的。在 Python 中,“只读”概念相当无意义,因此在本书中,我们将看到这两个术语可以互换使用。此外,正如我们将在第五章中讨论的那样,何时使用面向对象编程,在 Python 中,property 关键字对于一种特定类型的属性具有特殊含义。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在我们的水果库存应用程序中,果农可能想知道橙子来自哪个果园,何时采摘以及重量是多少。他们可能还想跟踪每个篮子存放在哪里。苹果可能有颜色属性,桶可能有不同的大小。其中一些属性也可能属于多个类(我们可能也想知道何时采摘苹果),但是对于这个第一个例子,让我们只向我们的类图添加一些不同的属性:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

根据我们设计的详细程度,我们还可以为每个属性指定类型。属性类型通常是大多数编程语言中标准的原始类型,例如整数、浮点数、字符串、字节或布尔值。但是,它们也可以表示数据结构,例如列表、树或图,或者尤其重要的是其他类。这是设计阶段可以与编程阶段重叠的一个领域。一个编程语言中可用的各种原始类型或对象可能与其他语言中可用的有些不同。

通常,在设计阶段我们不需要过分关注数据类型,因为实现特定的细节是在编程阶段选择的。对于设计来说,通常通用名称就足够了。如果我们的设计需要一个列表容器类型,Java 程序员可以选择在实现时使用LinkedListArrayList,而 Python 程序员(就是我们!)可以在list内置和tuple之间进行选择。

到目前为止,在我们的水果种植示例中,我们的属性都是基本原始类型。然而,有一些隐含的属性我们可以明确表示——关联。对于给定的橙子,我们可能有一个包含持有该橙子的篮子的属性。

行为是动作

现在,我们知道了什么是数据,但是行为是什么呢?行为是可以在对象上发生的动作。可以在特定类的对象上执行的行为称为方法。在编程级别上,方法就像结构化编程中的函数,但它们神奇地可以访问与该对象关联的所有数据。与函数一样,方法也可以接受参数并返回

方法的参数是需要传递到被调用的方法中的对象的列表(从调用对象传入的对象通常被称为参数)。这些对象被方法用来执行它们应该做的任何行为或任务。返回的值是该任务的结果。

我们已经把我们的“比较苹果和橙子”的例子扩展到了一个基本的(虽然牵强)库存应用程序。让我们再扩展一下,看看它是否会出错。可以与橙子相关联的一个动作是采摘。如果考虑实现,采摘会通过更新橙子的篮子属性并将橙子添加到篮子橙子列表中,将橙子放入篮子中。因此,采摘需要知道它正在处理的篮子是哪一个。我们通过给采摘方法一个篮子参数来实现这一点。由于我们的果农还卖果汁,我们可以给橙子添加一个方法。当榨时,可能会返回所提取的果汁量,同时将橙子从它所在的篮子中移除。

篮子可以有一个的动作。当篮子被卖出时,我们的库存系统可能会更新一些未指定的对象的数据,用于会计和利润计算。或者,我们的橙子篮在卖出之前可能会变坏,所以我们添加了一个丢弃的方法。让我们把这些方法添加到我们的图表中:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

向单个对象添加模型和方法使我们能够创建一个相互作用的对象系统。系统中的每个对象都是某个类的成员。这些类指定了对象可以持有的数据类型以及可以在其上调用的方法。每个对象中的数据可以与同一类的其他对象的数据处于不同的状态,并且由于状态的不同,每个对象可能对方法调用做出不同的反应。

面向对象的分析和设计的关键是找出这些对象是什么,以及它们应该如何相互作用。下一节描述了可以用来使这些相互作用尽可能简单和直观的原则。

隐藏细节并创建公共接口

在面向对象设计中对对象进行建模的主要目的是确定该对象的公共接口是什么。接口是其他对象可以用来与该对象交互的属性和方法的集合。它们不需要,通常也不允许,访问对象的内部工作。一个常见的现实世界的例子是电视。我们对电视的接口是遥控器。遥控器上的每个按钮代表一个可以在电视对象上调用的方法。当我们作为调用对象访问这些方法时,我们不知道也不关心电视是通过天线、有线连接还是卫星接收信号。我们不关心调整音量时发送的电子信号,或者声音是发往扬声器还是耳机。如果我们打开电视来访问内部工作,例如将输出信号分成外部扬声器和一套耳机,我们将会失去保修。

隐藏对象的实现细节的过程称为信息隐藏。有时也被称为封装,但封装实际上是一个更全面的术语。封装的数据不一定是隐藏的。封装,字面上来说,是创建一个胶囊,所以可以想象创建一个时间胶囊。如果你把一堆信息放进一个时间胶囊,锁上并埋起来,那么它既是封装的,信息也是隐藏的。另一方面,如果时间胶囊还没有被埋起来,或者是没有锁上或是由透明塑料制成的,里面的物品仍然是封装的,但没有信息隐藏。

封装和信息隐藏之间的区别在设计层面上基本上是无关紧要的。许多实际参考资料将这些术语互换使用。作为 Python 程序员,我们实际上并不需要真正的信息隐藏(我们将在第二章中讨论这一点,Python 中的对象),因此封装的更广泛的定义是合适的。

然而,公共接口非常重要。它需要仔细设计,因为将来很难更改它。更改接口将破坏调用它的任何客户对象。我们可以随意更改内部,例如使其更有效,或者在本地和通过网络访问数据,客户对象仍然可以使用公共接口进行通信,而无需修改。另一方面,如果我们通过更改公开访问的属性名称或更改方法可以接受的参数的顺序或类型来更改接口,所有客户对象也必须进行修改。在谈到公共接口时,保持简单。始终根据使用的便捷程度而不是编码的难度来设计对象的接口(这个建议也适用于用户界面)。

记住,程序对象可能代表真实对象,但这并不意味着它们是真实对象。它们是模型。建模最大的好处之一是能够忽略不相关的细节。我小时候建的模型汽车可能在外表上看起来像一辆真正的 1956 年的雷鸟,但它无法运行,传动轴也不转动。在我开始驾驶之前,这些细节过于复杂且无关紧要。这个模型是对真实概念的抽象。

抽象是另一个与封装和信息隐藏相关的面向对象的概念。简而言之,抽象意味着处理最适合于特定任务的细节级别。它是从内部细节中提取公共接口的过程。汽车的驾驶员需要与转向、油门和刹车进行交互。发动机、传动系统和刹车子系统的工作对驾驶员并不重要。另一方面,技工在不同的抽象级别上工作,调整发动机和刹车。这是汽车的两个抽象级别的例子:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

现在,我们有几个指涉相似概念的新术语。将所有这些行话浓缩成几句话:抽象是将信息封装在单独的公共和私有接口中的过程。私有接口可能会受到信息隐藏的影响。

从所有这些定义中得出的重要教训是要使我们的模型能够被必须与它们互动的其他对象理解。这意味着要仔细关注细节。确保方法和属性有合理的名称。在分析系统时,对象通常代表原始问题中的名词,而方法通常是动词。属性通常可以作为形容词,尽管如果属性指的是当前对象的一部分的另一个对象,它仍然可能是名词。相应地命名类、属性和方法。

不要试图模拟可能在未来有用的对象或动作。只模拟系统需要执行的任务,设计自然会朝着具有适当抽象级别的方向发展。这并不是说我们不应该考虑可能的未来设计修改。我们的设计应该是开放式的,以便满足未来的需求。然而,在抽象接口时,尽量模拟确切需要模拟的内容,而不要多余。

在设计界面时,尝试将自己放在对象的角度,并想象对象对隐私有强烈偏好。除非你觉得让其他对象访问关于你的数据符合你的最佳利益,否则不要让其他对象访问关于你的数据。除非你确定你希望它们这样做,否则不要给它们一个接口来强迫你执行特定任务。

组合

到目前为止,我们学会了将系统设计为一组相互作用的对象,其中每个交互都涉及以适当的抽象级别查看对象。但我们还不知道如何创建这些抽象级别。有多种方法可以做到这一点;我们将在第八章“字符串和序列化”和第九章“迭代器模式”中讨论一些高级设计模式。但是,大多数设计模式都依赖于两个基本的面向对象原则,即组合继承。组合更简单,所以我们从它开始。

组合是将几个对象收集在一起创建一个新对象的行为。当一个对象是另一个对象的一部分时,组合通常是一个不错的选择。我们已经在机械示例中看到了组合的第一个提示。汽车由发动机、变速器、启动器、前灯和挡风玻璃等众多零部件组成。发动机又由活塞、曲轴和气门组成。在这个例子中,组合是提供抽象级别的好方法。汽车对象可以提供驾驶员所需的接口,同时也提供对其组件部分的访问,这为技工提供了适合的更深层次的抽象。当技工需要更多信息来诊断问题或调整发动机时,这些组件部分当然可以进一步分解。

这是一个常见的组合入门示例,但在设计计算机系统时并不是特别有用。物理对象很容易分解成组件对象。自古希腊人最初假设原子是物质的最小单位以来,人们一直在做这件事情(当然,他们当时没有接触到粒子加速器)。计算机系统通常比物理对象更简单,但是在这种系统中识别组件对象并不会自然发生。

面向对象系统中的对象有时代表诸如人、书籍或电话等物理对象。然而,更常见的情况是它们代表抽象概念。人有名字,书有标题,电话用于打电话。电话、标题、账户、名字、约会和付款通常不被认为是物理世界中的对象,但它们在计算机系统中经常被建模为组件。

让我们尝试建模一个更加面向计算机的例子,以了解组合的运作方式。我们将研究一个计算机化国际象棋游戏的设计。这在 80 年代和 90 年代是学者们非常受欢迎的消遣。人们曾预测计算机有一天将能够击败人类国际象棋大师。当这在 1997 年发生时(IBM 的深蓝击败了世界国际象棋冠军加里·卡斯帕罗夫),对这个问题的兴趣减弱了,尽管计算机和人类国际象棋选手之间仍然有比赛。(通常是计算机获胜。)

作为基本的高层分析,国际象棋游戏是由两名玩家使用一个包含 8x8 网格中六十四个位置的棋盘的国际象棋套装进行对弈。棋盘上可以有两组可以以不同方式移动的十六个棋子,由两名玩家轮流移动。每个棋子都可以吃掉其他棋子。棋盘将需要在每一轮之后在计算机屏幕上绘制自己。

我使用斜体标识了描述中可能的一些对象,并使用粗体标识了一些关键方法。这是将面向对象分析转化为设计的常见第一步。此时,为了强调组合,我们将专注于棋盘,而不太担心玩家或不同类型的棋子。

让我们从可能的最高抽象级别开始。我们有两个玩家通过轮流走棋与国际象棋棋盘进行交互:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这是什么?它看起来不太像我们之前的类图。那是因为它不是类图!这是一个对象图,也称为实例图。它描述了系统在特定时间点的状态,并描述了对象的特定实例,而不是类之间的交互。请记住,两个玩家都是同一个类的成员,因此类图看起来有点不同:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

该图表明只有两名玩家可以与一个国际象棋组合进行交互。它还表明任何一名玩家一次只能玩一个国际象棋组合。

然而,我们讨论的是组合,而不是 UML,所以让我们思考一下国际象棋由什么组成。我们暂时不关心玩家由什么组成。我们可以假设玩家有心脏和大脑等器官,但这些对我们的模型无关紧要。事实上,没有什么能阻止说的玩家本身就是没有心脏或大脑的深蓝。

因此,国际象棋组合由一个棋盘和 32 个棋子组成。棋盘进一步包括 64 个位置。您可以争辩说棋子不是国际象棋组合的一部分,因为您可以用不同的棋子替换国际象棋组合中的棋子。虽然在计算机化的国际象棋中这是不太可能或不可能的,但这让我们了解了聚合

聚合几乎与组合完全相同。区别在于聚合对象可以独立存在。一个位置不可能与不同的国际象棋棋盘相关联,因此我们说棋盘由位置组成。但是,棋子可能独立于国际象棋棋盘存在,因此我们说棋子与该棋盘处于聚合关系。

区分聚合和组合的另一种方法是考虑对象的生命周期。如果组合(外部)对象控制相关(内部)对象的创建和销毁,那么组合是最合适的。如果相关对象独立于组合对象创建,或者可以超出该对象的寿命,那么聚合关系更合理。此外,请记住组合是聚合;聚合只是组合的一种更一般的形式。任何组合关系也是聚合关系,但反之则不然。

让我们描述一下我们当前的国际象棋组合,并为对象添加一些属性来保存组合关系:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在 UML 中,组合关系表示为实心菱形。空心菱形表示聚合关系。您会注意到棋盘和棋子以与它们在国际象棋组合上存储的方式存储为国际象棋组合的属性。这再次表明,在实践中,一旦过了设计阶段,聚合和组合之间的区别通常是无关紧要的。在实施时,它们的行为方式大致相同。然而,当您的团队讨论不同对象如何交互时,区分它们可能有所帮助。通常情况下,您可以将它们视为相同的东西,但当您需要区分它们时,了解区别是很好的(这就是抽象在起作用)。

继承

我们讨论了对象之间的三种关系:关联、组合和聚合。然而,我们还没有完全指定我们的国际象棋棋盘,而这些工具似乎并不能给我们提供所有我们需要的功能。我们讨论了玩家可能是人类,也可能是具有人工智能的软件。说玩家与人类关联,或者说人工智能实现是玩家对象的一部分,似乎并不合适。我们真正需要的是能够说“Deep Blue 是一个玩家”或者“Gary Kasparov 是一个玩家”的能力。

is a关系是由继承形成的。继承是面向对象编程中最著名、最知名和最常用的关系。继承有点像家谱。我的祖父姓菲利普斯,我父亲继承了这个姓氏。我从他那里继承了它(还有蓝眼睛和写作的爱好)。在面向对象编程中,一个类可以从另一个类继承属性和方法,而不是从一个人那里继承特征和行为。

例如,我们的国际象棋棋盘上有 32 个棋子,但只有六种不同类型的棋子(兵、车、象、马、国王和皇后),每种棋子在移动时的行为都不同。所有这些棋子类都有属性,比如颜色和它们所属的国际象棋棋盘,但它们在国际象棋棋盘上绘制时都有独特的形状,并且移动方式也不同。让我们看看这六种棋子是如何从Piece类继承的:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

空心箭头表示棋子的各个类从Piece类继承。所有子类型都自动从基类继承chess_setcolor属性。每个棋子提供一个不同的形状属性(在渲染棋盘时绘制在屏幕上),以及一个不同的move方法,在每个回合将棋子移动到棋盘上的新位置。

我们实际上知道Piece类的所有子类都需要有一个move方法;否则,当棋盘试图移动棋子时,它会感到困惑。我们可能希望创建一个新版本的国际象棋,其中有一个额外的棋子(巫师)。我们当前的设计允许我们设计这个棋子,而不给它一个move方法。然后当棋盘要求棋子移动时,它会窒息。

我们可以通过在Piece类上创建一个虚拟的 move 方法来实现这一点。然后子类可以用更具体的实现覆盖这个方法。默认实现可能会弹出一个错误消息,说:该棋子无法移动

在子类型中重写方法允许开发非常强大的面向对象系统。例如,如果我们想要实现一个带有人工智能的玩家类,我们可以提供一个calculate_move方法,该方法接受一个Board对象,并决定将哪个棋子移动到哪里。一个非常基本的类可能会随机选择一个棋子和方向,然后相应地移动。然后我们可以在一个子类中重写这个方法,使用 Deep Blue 的实现。第一个类适合与一个新手玩,而后者会挑战一个国际象棋大师。重要的是,类中的其他方法,比如通知棋盘哪个移动被选择的方法,不需要改变;这个实现可以在两个类之间共享。

在国际象棋棋子的情况下,提供移动方法的默认实现并没有太多意义。我们只需要指定移动方法在任何子类中都是必需的。这可以通过将Piece作为具有声明为abstract的 move 方法的抽象类来实现。抽象方法基本上是在说,“我们要求这个方法存在于任何非抽象子类中,但我们拒绝在这个类中指定一个实现。”

事实上,可以创建一个根本不实现任何方法的类。这样的类只会告诉我们该类应该做什么,但绝对不会提供如何做的建议。在面向对象的术语中,这样的类被称为接口

继承提供了抽象

让我们来探讨面向对象术语中最长的单词。多态性是指根据实现的子类不同而以不同方式对待一个类的能力。我们已经在我们描述的棋子系统中看到了它的作用。如果我们进一步设计,我们可能会发现Board对象可以接受玩家的移动并调用棋子的move函数。棋盘不需要知道它正在处理的是什么类型的棋子。它只需要调用move方法,适当的子类就会负责将其移动为KnightPawn

多态性非常酷,但在 Python 编程中很少使用这个词。Python 在允许将对象的子类视为父类的基础上又迈出了一步。在 Python 中实现的棋盘可以接受任何具有move方法的对象,无论是象棋子、汽车还是鸭子。当调用move时,Bishop将在棋盘上对角线移动,汽车将驾驶到某个地方,而鸭子将根据心情游泳或飞行。

在 Python 中,这种多态性通常被称为鸭子类型:“如果它走起来像鸭子或游泳像鸭子,那它就是鸭子”。我们不在乎它是否真的一只鸭子(继承),只在乎它是否游泳或走路。雁和天鹅可能很容易提供我们所寻找的鸭子般的行为。这使得未来的设计者可以创建新类型的鸟类,而无需实际指定水鸟的继承层次结构。它还允许他们创建完全不同的可插拔行为,原始设计者从未计划过。例如,未来的设计者可能能够创建一个行走、游泳的企鹅,而不必暗示企鹅是鸭子。

多重继承

当我们想到家族谱系中的继承时,我们会发现我们不仅从一个父类那里继承特征。当陌生人告诉一个骄傲的母亲她的儿子有“他父亲的眼睛”时,她通常会回答类似于“是的,但他有我的鼻子”。

面向对象设计还可以包括多重继承,允许子类从多个父类中继承功能。在实践中,多重继承可能会很棘手,一些编程语言(尤其是 Java)严格禁止它。然而,多重继承也有其用途。最常见的用途是创建具有两组不同行为的对象。例如,一个旨在连接扫描仪并发送扫描文档的传真的对象可能是通过从两个独立的scannerfaxer对象继承而创建的。

只要两个类具有不同的接口,子类从它们两者继承通常不会有害。但是,如果我们从提供重叠接口的两个类继承,情况就会变得混乱。例如,如果我们有一个具有move方法的摩托车类,还有一个同样具有move方法的船类,而我们想将它们合并成终极两栖车,那么当我们调用move时,结果类如何知道该做什么?在设计层面上,这需要解释,而在实现层面上,每种编程语言都有不同的方式来决定调用哪个父类的方法,或者以什么顺序调用。

通常,处理多重继承的最佳方法是避免它。如果你的设计出现了这种情况,你可能做错了。退一步,重新分析系统,看看是否可以用其他关联或组合设计来替代多重继承关系。

继承是扩展行为的一个非常强大的工具。它也是面向对象设计相对于早期范式的最具市场竞争力的进步之一。因此,它通常是面向对象程序员首先使用的工具。然而,重要的是要认识到拥有一把锤子并不会把螺丝钉变成钉子。继承是明显是一个关系的完美解决方案,但它可能会被滥用。程序员经常使用继承来在两种只有遥远关系的对象之间共享代码,而看不到是一个关系。虽然这不一定是一个坏的设计,但这是一个很好的机会,问问他们为什么决定以这种方式设计,以及是否不同的关系或设计模式会更合适。

案例研究

让我们通过对一个有点真实世界的例子进行几次迭代的面向对象设计,将我们新的面向对象知识联系在一起。我们将要建模的系统是一个图书馆目录。图书馆几个世纪以来一直在跟踪他们的库存,最初使用卡片目录,最近使用电子库存。现代图书馆有基于网络的目录,我们可以在家里查询。

让我们从分析开始。当地的图书管理员要求我们编写一个新的卡片目录程序,因为他们古老的基于 DOS 的程序既难看又过时。这并没有给我们太多细节,但在我们开始寻求更多信息之前,让我们考虑一下我们已经对图书馆目录了解的情况。

目录包含书籍列表。人们搜索它们以找到特定主题的书籍,特定标题的书籍,或者特定作者的书籍。书籍可以通过国际标准书号(ISBN)得到唯一标识。每本书都有一个杜威十进制分类法(DDS)编号,以帮助在特定书架上找到它。

这个简单的分析告诉我们系统中一些明显的对象。我们很快确定Book是最重要的对象,已经提到了几个属性,比如作者、标题、主题、ISBN 和 DDS 编号,以及目录作为书籍的一种管理者。

我们还注意到一些其他可能需要或不需要在系统中建模的对象。为了编目的目的,我们只需要在书上搜索作者的author_name属性。然而,作者也是对象,我们可能想要存储一些关于作者的其他数据。当我们考虑这一点时,我们可能会记得一些书籍有多个作者。突然间,在对象上有一个单一的author_name属性的想法似乎有点愚蠢。与每本书相关联的作者列表显然是一个更好的主意。

作者和书籍之间的关系显然是关联,因为你绝对不会说,“一本书是一个作者”(这不是继承),而说“一本书有一个作者”,虽然在语法上是正确的,但并不意味着作者是书籍的一部分(这不是聚合)。事实上,任何一个作者可能与多本书相关联。

我们还应该注意名词(名词总是对象的良好候选者)shelf。书架是需要在目录系统中建模的对象吗?我们如何识别单独的书架?如果一本书存放在一个书架的末尾,后来因为在前一个书架中插入了另一本书而被移动到下一个书架的开头,会发生什么?

DDS 旨在帮助在图书馆中定位实体书籍。因此,将 DDS 属性与书籍一起存储应该足以找到它,无论它存储在哪个书架上。因此,至少在目前,我们可以将书架从我们竞争对象的列表中移除。

系统中另一个值得怀疑的对象是用户。我们需要了解特定用户的任何信息吗,比如他们的姓名、地址或逾期书目清单?到目前为止,图书馆员告诉我们他们只想要一个目录;他们没有提到追踪订阅或逾期通知。在我们的脑海中,我们还注意到作者和用户都是特定类型的人;在未来可能会有一个有用的继承关系。

为了编目的目的,我们决定暂时不需要为用户进行标识。我们可以假设用户将搜索目录,但我们不必在系统中积极地对他们进行建模,只需提供一个允许他们搜索的界面即可。

我们已经确定了书上的一些属性,但目录有哪些属性?一个图书馆是否有多个目录?我们需要对它们进行唯一标识吗?显然,目录必须有它所包含的书籍的集合,但这个列表可能不是公共接口的一部分。

行为呢?目录显然需要一个搜索方法,可能是针对作者、标题和主题分开的方法。书上有什么行为?它需要一个预览方法吗?或者预览可以通过一个首页属性来识别,而不是一个方法?

前面讨论中的问题都是面向对象分析阶段的一部分。但在这些问题中,我们已经确定了一些设计的关键对象。事实上,你刚刚看到的是分析和设计之间的几个微迭代。

很可能,这些迭代都会在与图书馆员的初次会议中发生。然而,在这次会议之前,我们已经可以为我们已经明确定义的对象勾勒出一个最基本的设计:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

拿着这个基本的图表和一支铅笔,我们与图书馆员会面。他们告诉我们这是一个不错的开始,但图书馆不仅仅服务于书籍,他们还有 DVD、杂志和 CD,这些都没有 ISBN 或 DDS 号码。但所有这些类型的物品都可以通过 UPC 号码唯一标识。我们提醒图书馆员他们必须在书架上找到物品,而这些物品可能并不是按照 UPC 号码组织的。图书馆员解释说每种类型都是以不同的方式组织的。CD 主要是有声书,库存只有几十张,所以它们是按作者的姓氏组织的。DVD 根据类型分开,然后按标题进一步组织。杂志按标题组织,然后按卷号和期号进行细分。书籍,正如我们猜测的那样,是按 DDS 号码组织的。

在没有以前的面向对象设计经验的情况下,我们可能会考虑向我们的目录中添加 DVD、CD、杂志和书籍的单独列表,并依次搜索每一个。问题是,除了某些扩展属性和识别物品的物理位置之外,这些物品的行为都是相似的。这就是继承的工作!我们迅速更新了我们的 UML 图表:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图书管理员理解了我们草绘的图表的要点,但对locate功能有点困惑。我们使用一个特定的用例来解释,用户正在搜索单词“bunnies”。用户首先向目录发送搜索请求。目录查询其内部的项目列表,找到了一本书和一张 DVD,标题中都有“bunnies”。此时,目录并不关心它是否持有 DVD、书、CD 还是杂志;在目录看来,所有项目都是一样的。然而,用户想知道如何找到这些实体项目,所以如果目录只返回一个标题列表,那就有点失职了。因此,它调用了两个发现的项目的locate方法。书的locate方法返回一个 DDS 号码,可以用来找到放置书的书架。DVD 通过返回 DVD 的流派和标题来定位。然后用户可以访问 DVD 部分,找到包含该流派的部分,并按标题排序找到具体的 DVD。

当我们解释时,我们会草绘一个 UML 序列图,解释各个对象是如何通信的:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

类图描述了类之间的关系,序列图描述了对象之间传递的特定消息序列。从每个对象悬挂的虚线是描述对象的生命周期的生命线。每个生命线上的较宽的框表示对象中的活动处理(如果没有框,对象基本上是空闲的,等待发生某些事情)。生命线之间的水平箭头表示特定的消息。实线箭头表示被调用的方法,而带有实心头的虚线箭头表示方法返回值。

半箭头表示异步消息发送到对象或从对象发送。异步消息通常意味着第一个对象调用第二个对象的方法,然后立即返回。经过一些处理后,第二个对象调用第一个对象的方法来给它一个值。这与正常的方法调用相反,正常的方法调用在方法中进行处理,并立即返回一个值。

序列图,像所有的 UML 图表一样,最好只在需要时使用。没有必要为了画图而画 UML 图。但是,当你需要传达两个对象之间的一系列交互时,序列图是一个非常有用的工具。

不幸的是,到目前为止,我们的类图仍然是一个混乱的设计。我们注意到 DVD 上的演员和 CD 上的艺术家都是人的类型,但是与书的作者不同。图书管理员还提醒我们,他们的大部分 CD 都是有声书,有作者而不是艺术家。

我们如何处理为标题做出贡献的不同类型的人?一个明显的实现是创建一个Person类,包括人的姓名和其他相关细节,然后为艺术家、作者和演员创建这个类的子类。然而,这里真的需要继承吗?对于搜索和编目的目的,我们并不真的关心演戏和写作是两种非常不同的活动。如果我们正在进行经济模拟,给予单独的演员和作者类,并不同的calculate_incomeperform_job方法是有意义的,但是对于编目的目的,知道这个人如何为项目做出贡献可能就足够了。我们意识到所有项目都有一个或多个Contributor对象,所以我们将作者关系从书移到其父类:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Contributor/LibraryItem关系的多重性是多对多,如一个关系的两端都有*****字符表示。任何一个库项目可能有多个贡献者(例如,DVD 上有几个演员和一个导演)。许多作者写了很多书,所以他们会被附加到多个库项目上。

这个小改变,虽然看起来更清洁、更简单,但失去了一些重要的信息。我们仍然可以知道谁为特定的图书馆项目做出了贡献,但我们不知道他们是如何做出贡献的。他们是导演还是演员?他们是写了有声读物,还是为书籍配音的声音?

如果我们能在Contributor类上添加一个contributor_type属性就好了,但是当处理多才多艺的人时,他们既写书又导演电影时,这种方法就会崩溃。

一种选择是为我们的每个LibraryItem子类添加属性,以保存我们需要的信息,比如Book上的Author,或者CD上的Artist,然后将这些属性的关系都指向Contributor类。这样做的问题是我们失去了很多多态的优雅。如果我们想列出项目的贡献者,我们必须查找该项目上的特定属性,比如AuthorsActors。我们可以通过在LibraryItem类上添加一个GetContributors方法来减轻这一点,子类可以重写这个方法。然后目录永远不必知道对象正在查询的属性;我们已经抽象了公共接口:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

仅仅看这个类图,就感觉我们做错了什么。它又臃肿又脆弱。它可能做了我们需要的一切,但感觉很难维护或扩展。关系太多,任何一个类的修改都会影响太多的类。看起来就像意大利面和肉丸。

现在我们已经探讨了继承作为一种选择,并发现它不够理想,我们可能会回顾我们之前基于组合的图表,其中Contributor直接附加到LibraryItem上。经过一些思考,我们可以看到我们实际上只需要添加一个全新的类来标识贡献者的类型。这是面向对象设计中的一个重要步骤。我们现在正在向设计中添加一个旨在支持其他对象的类,而不是对初始需求的任何部分进行建模。我们正在重构设计,以便为系统中的对象提供支持,而不是现实生活中的对象。重构是程序或设计维护中的一个重要过程。重构的目标是通过移动代码、删除重复代码或复杂关系,以简单、更优雅的设计来改进设计。

这个新类由一个Contributor和一个额外的属性组成,用于标识这个人对给定LibraryItem所做的贡献类型。一个特定的LibraryItem可以有许多这样的贡献,一个贡献者可以以相同的方式为不同的项目做出贡献。这个图表很好地传达了这个设计:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

起初,这种组合关系看起来不如基于继承的关系自然。然而,它的优势在于允许我们添加新类型的贡献,而不必在设计中添加新类。当子类有某种专业化时,继承是最有用的。专业化是在子类上创建或更改属性或行为,使其在某种程度上与父类不同。为了识别不同类型的对象,创建一堆空类似乎有些愚蠢(这种态度在 Java 和其他“一切都是对象”的程序员中不太普遍,但在更实际的 Python 设计师中很常见)。如果我们看继承版本的图表,我们会看到一堆实际上什么都没做的子类:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

有时候,识别何时不使用面向对象的原则是很重要的。这个不使用继承的例子是一个很好的提醒,即对象只是工具,而不是规则。

练习

这是一本实用书,而不是教科书。因此,我不打算让你解决一堆虚假的面向对象分析问题,为一堆虚假的面向对象问题创建设计。相反,我想给你一些思考,让你可以应用到自己的项目中。如果你有以前的面向对象经验,你就不需要花太多精力在这上面。然而,如果你已经使用 Python 一段时间,但从未真正关心过所有这些类的东西,这些都是有用的思维练习。

首先,想想你最近完成的一个编程项目。识别设计中最突出的对象。尽量想出这个对象的尽可能多的属性。它有:颜色?重量?尺寸?利润?成本?名称?ID 号码?价格?风格?思考属性类型。它们是原始类型还是类?其中一些属性实际上是伪装成行为?有时看起来像数据的东西实际上是从对象上的其他数据计算出来的,你可以使用一个方法来进行这些计算。这个对象还有哪些其他方法或行为?哪些对象调用了这些方法?它们与这个对象有什么样的关系?

现在,想想即将到来的项目。这个项目是什么并不重要;它可能是一个有趣的业余项目,也可能是一个价值数百万美元的合同。它不必是一个完整的应用程序;它可能只是一个子系统。进行基本的面向对象分析。确定需求和相互作用的对象。勾画出一个包含系统最高抽象级别的类图。确定主要相互作用的对象。确定次要的支持对象。对一些最有趣的对象的属性和方法进行详细说明。将不同的对象带入不同的抽象级别。寻找可以使用继承或组合的地方。寻找应该避免使用继承的地方。

目标不是设计一个系统(尽管如果你的倾向和可用时间都满足,当然可以这样做)。目标是思考面向对象的设计。专注于你已经参与过的项目,或者未来打算参与的项目,这样更真实。

现在,访问你最喜欢的搜索引擎,查找一些关于 UML 的教程。有数十种教程,找到适合你自己学习方法的教程。为你之前确定的对象勾画一些类图或序列图。不要太过于纠结于记忆语法(毕竟,如果它很重要,你随时可以再次查阅),只需对这种语言有所了解。你的大脑中会留下一些东西,如果你能快速勾画出下一个面向对象编程讨论的图表,那么交流会变得更容易。

总结

在这一章中,我们快速浏览了面向对象范式的术语,重点放在面向对象设计上。我们可以将不同的对象分为不同类别,并通过类接口描述这些对象的属性和行为。类描述对象、抽象、封装和信息隐藏是高度相关的概念。对象之间有许多不同类型的关系,包括关联、组合和继承。UML 语法对于娱乐和沟通都很有用。

在下一章中,我们将探讨如何在 Python 中实现类和方法。

第二章:Python 中的对象

因此,我们现在手头有一个设计,并准备将该设计转化为一个可工作的程序!当然,通常情况下不会这样发生。在整本书中,我们将看到良好软件设计的示例和提示,但我们的重点是面向对象编程。因此,让我们看一下 Python 语法,以便我们可以创建面向对象的软件。

完成本章后,我们将了解:

  • 如何在 Python 中创建类和实例化对象

  • 如何向 Python 对象添加属性和行为

  • 如何将类组织成包和模块

  • 如何建议人们不要破坏我们的数据

创建 Python 类

我们不必写太多 Python 代码就能意识到 Python 是一种非常“干净”的语言。当我们想做某事时,我们只需去做,而不必经历很多设置。Python 中无处不在的“hello world”,正如你可能已经看到的,只有一行。

同样,Python 3 中最简单的类如下所示:

class MyFirstClass:
    pass

这是我们的第一个面向对象的程序!类定义以class关键字开头。然后是一个名称(我们选择的)来标识类,并以冒号结束。

注意

类名必须遵循标准的 Python 变量命名规则(必须以字母或下划线开头,只能由字母、下划线或数字组成)。此外,Python 风格指南(在网上搜索“PEP 8”)建议使用驼峰命名法(以大写字母开头;任何后续单词也应以大写字母开头)命名类。

类定义行后面是缩进的类内容。与其他 Python 结构一样,缩进用于界定类,而不是像许多其他语言一样使用大括号或方括号。除非有充分的理由(例如适应其他人使用制表符缩进的代码),否则使用四个空格进行缩进。任何体面的编程编辑器都可以配置为在按下Tab键时插入四个空格。

由于我们的第一个类实际上并没有做任何事情,因此我们在第二行使用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>
>>>

此代码从新类实例化了两个对象,命名为ab。创建类的实例只需简单地输入类名,后跟一对括号。它看起来很像一个普通的函数调用,但 Python 知道我们正在“调用”一个类而不是一个函数,因此它知道它的工作是创建一个新对象。当打印时,这两个对象告诉我们它们是哪个类,以及它们所在的内存地址。在 Python 代码中很少使用内存地址,但在这里,它们表明涉及了两个不同的对象。

提示

下载示例代码

您可以从您在www.packtpub.com的帐户中购买的所有 Packt 图书下载示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便直接通过电子邮件接收文件。

添加属性

现在,我们有一个基本的类,但它相当无用。它不包含任何数据,也不做任何事情。我们必须怎么做才能将属性分配给给定的对象?

事实证明,在类定义中我们不必做任何特殊的事情。我们可以使用点符号在实例化的对象上设置任意属性:

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类。然后它创建了该类的两个实例,并分配给这些实例xy坐标,以标识二维空间中的一个点。我们只需要使用*. = *语法为对象的属性赋值。这有时被称为点符号。值可以是任何东西:Python 原语、内置数据类型或另一个对象。甚至可以是一个函数或另一个类!

让它做点什么

现在,拥有属性的对象很棒,但面向对象编程实际上是关于对象之间的交互。我们感兴趣的是调用导致这些属性发生变化的操作。是时候给我们的类添加行为了。

让我们模拟Point类上的一些操作。我们可以从一个名为reset的操作开始,将点移动到原点(原点是xy都为零的点)。这是一个很好的入门操作,因为它不需要任何参数:

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参数只是对方法被调用的对象的引用。我们可以访问该对象的属性和方法,就好像它是另一个对象一样。这正是我们在reset方法中设置self对象的xy属性时所做的。

请注意,当我们调用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方法接受两个参数,xy,并在self对象上设置这些值,就像前面示例中的旧reset方法一样。旧的reset方法现在调用move,因为重置只是移动到一个特定的已知位置。

calculate_distance方法使用不太复杂的勾股定理来计算两点之间的距离。我希望你理解这个数学(**表示平方,math.sqrt计算平方根),但这并不是我们当前重点学习的要求,我们的当前重点是学习如何编写方法。

前面示例的最后显示了如何使用参数调用方法的示例代码:只需在括号内包含参数,并使用相同的点表示法访问方法。我只是随机选择了一些位置来测试这些方法。测试代码调用每个方法并在控制台上打印结果。assert函数是一个简单的测试工具;如果assert后面的语句为False(或零、空或None),程序将会中止。在这种情况下,我们使用它来确保无论哪个点调用另一个点的calculate_distance方法,距离都是相同的。

初始化对象

如果我们不显式设置Point对象上的xy位置,要么使用move,要么直接访问它们,我们就会得到一个没有真实位置的损坏的点。当我们尝试访问它时会发生什么呢?

好吧,让我们试试看。"试一试看"是 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'

好吧,至少它抛出了一个有用的异常。我们将在第四章预料之外中详细介绍异常。你可能以前见过它们(特别是无处不在的SyntaxError,它意味着你输入了错误的东西!)。在这一点上,只要意识到它意味着出了问题就可以了。

输出对于调试是有用的。在交互式解释器中,它告诉我们错误发生在第 1 行,这只是部分正确的(在交互式会话中,一次只执行一行)。如果我们在文件中运行脚本,它会告诉我们确切的行号,这样很容易找到错误的代码。此外,它告诉我们错误是AttributeError,并给出了一个有用的消息告诉我们这个错误是什么意思。

我们可以捕获并从这个错误中恢复,但在这种情况下,感觉我们应该指定某种默认值。也许每个新对象默认应该是reset(),或者也许当用户创建对象时,我们可以强制用户告诉我们这些位置应该是什么。

大多数面向对象的编程语言都有构造函数的概念,这是一个特殊的方法,在创建对象时创建和初始化对象。Python 有点不同;它有一个构造函数一个初始化器。构造函数很少使用,除非你在做一些奇特的事情。所以,我们将从初始化方法开始讨论。

Python 的初始化方法与任何其他方法相同,只是它有一个特殊的名称__init__。前导和尾随的双下划线意味着这是一个 Python 解释器将视为特殊情况的特殊方法。

注意

永远不要用前导和尾随的双下划线命名自己的函数。它对 Python 可能没有任何意义,但总有可能 Python 的设计者将来会添加一个具有该名称的特殊目的的函数,当他们这样做时,你的代码将会出错。

让我们从我们的Point类上的一个初始化函数开始,当实例化Point对象时,需要用户提供xy坐标:

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 构造函数,但知道它的存在是有帮助的,所以我们会简要介绍一下。

构造函数被称为__new__,而不是__init__,并且只接受一个参数;正在构造的类(在对象构造之前调用,因此没有self参数)。它还必须返回新创建的对象。当涉及到复杂的元编程时,这具有有趣的可能性,但在日常编程中并不是非常有用。实际上,你很少甚至从不需要使用__new____init__就足够了。

解释自己

Python 是一种非常易于阅读的编程语言;有些人可能会说它是自我说明的。然而,在进行面向对象编程时,编写清晰总结每个对象和方法做什么的 API 文档是很重要的。保持文档的最新状态是困难的;最好的方法是将其直接写入我们的代码中。

Python 通过使用docstrings来支持这一点。每个类、函数或方法头部都可以有一个标准的 Python 字符串作为定义后的第一行(以冒号结尾的行)。这一行应该与后续的代码缩进相同。

文档字符串只是用撇号(')或引号(")括起来的 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模块中的任何类或函数。或者,我们可以使用fromimport语法只导入我们需要的一个类:

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 程序员都会告诉你,你永远不应该使用这种语法。他们会使用模糊的理由,比如“它会使命名空间混乱”,这对初学者来说并不太有意义。避免使用这种语法的一个方法是使用它,并在两年后尝试理解你的代码。但我们可以通过一个快速的解释节省一些时间和两年的糟糕代码!

当我们在文件顶部明确导入database类时,使用from database import Database,我们可以很容易地看到Database类来自哪里。我们可能会在文件的后面 400 行使用db = Database(),我们可以快速查看导入来看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.pydatabase模块中或两个付款模块中使用这种语法实例化Product类。实际上,假设包对 Python 可用,它将能够导入它们。例如,包也可以安装到 Python 站点包文件夹中,或者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

这个导入使用两个句点来表示payments 包的父级,然后使用正常的package.module语法来返回up到 contact 包。

最后,我们可以直接从包中导入代码,而不仅仅是包内的模块。在这个例子中,我们有一个名为database.pyproducts.pyecommerce包,数据库模块包含一个从许多地方访问的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_text)
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 的每台机器上都可用的包和模块。然而,你很快会发现它并不包含你所需要的一切。当发生这种情况时,你有两个选择:

  • 自己编写一个支持包

  • 使用别人的代码

我们不会详细介绍如何将你的包转换成库,但是如果你有一个需要解决的问题,而你又不想编写代码(最好的程序员非常懒惰,更喜欢重用现有的经过验证的代码,而不是编写自己的代码),你可能可以在Python 包索引PyPIpypi.python.org/上找到你想要的库。一旦确定了要安装的包,你可以使用一个叫做pip的工具来安装它。然而,pip并不随 Python 一起提供,但 Python 3.4 包含一个有用的工具叫做ensurepip,它会安装它:

python -m ensurepip

这可能在 Linux、Mac OS 或其他 Unix 系统上失败,如果是这样,你需要成为 root 用户才能使其工作。在大多数现代 Unix 系统上,可以使用sudo python -m ensurepip来完成这个操作。

注意

如果你使用的是 Python 3.4 之前的版本,你需要自己下载并安装pip,因为ensurepip不存在。你可以按照pip.readthedocs.org/上的说明来做这件事。

一旦安装了pip并且知道要安装的包的名称,你可以使用以下语法来安装它:

pip install requests

然而,如果这样做,你要么将第三方库直接安装到系统 Python 目录中,要么更有可能收到你没有权限这样做的错误。你可以强制以管理员身份安装,但 Python 社区的普遍共识是,你应该只使用系统安装程序将第三方库安装到系统 Python 目录中。

然而,Python 3.4 提供了venv工具。这个工具基本上为你在工作目录中提供了一个叫做虚拟环境的迷你 Python 安装。当你激活这个迷你 Python 时,与 Python 相关的命令将在该目录上运行,而不是在系统目录上运行。因此,当你运行pippython时,它不会影响系统 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的虚拟环境。最后,我们使用最后两行中的一行(取决于操作系统,如注释中所示)来激活环境。每次我们想要使用特定的虚拟环境时,都需要执行这行命令,然后在完成这个项目的工作时使用deactivate命令。

虚拟环境是保持第三方依赖项分开的绝佳方式。通常会有不同的项目依赖于特定库的不同版本(例如,一个较旧的网站可能在 Django 1.5 上运行,而更新的版本则在 Django 1.8 上运行)。将每个项目放在单独的虚拟环境中可以轻松地在 Django 的任一版本中工作。此外,如果尝试使用不同的工具安装相同的包,它可以防止系统安装的软件包和pip安装的软件包之间发生冲突。

案例研究

为了把所有这些联系在一起,让我们构建一个简单的命令行笔记本应用程序。这是一个相当简单的任务,所以我们不会尝试使用多个包。然而,我们会看到类、函数、方法和文档字符串的常见用法。

让我们从一个快速分析开始:笔记是存储在笔记本中的简短备忘录。每个笔记应记录写作日期,并可以添加标签以便进行简单查询。应该可以修改笔记。我们还需要能够搜索笔记。所有这些事情都应该从命令行完成。

显而易见的对象是Note对象;不太明显的是Notebook容器对象。标签和日期似乎也是对象,但我们可以使用 Python 标准库中的日期和逗号分隔的字符串来表示标签。为了避免复杂性,在原型中,让我们不为这些对象定义单独的类。

Note对象具有memo本身,tagscreation_date的属性。每个笔记还需要一个唯一的整数id,以便用户可以在菜单界面中选择它们。笔记可以有一个修改笔记内容的方法和另一个修改标签的方法,或者我们可以让笔记本直接访问这些属性。为了使搜索更容易,我们应该在Note对象上放置一个match方法。这个方法将接受一个字符串,并且可以告诉我们笔记是否与字符串匹配,而不直接访问属性。这样,如果我们想修改搜索参数(例如搜索标签而不是笔记内容,或者使搜索不区分大小写),我们只需要在一个地方做就可以了。

Notebook对象显然具有笔记列表作为属性。它还需要一个搜索方法,返回过滤后的笔记列表。

但是我们如何与这些对象交互?我们已经指定了一个命令行应用程序,这意味着我们可以通过不同的选项运行程序来添加或编辑命令,或者我们有一种菜单可以让我们选择对笔记本进行不同的操作。我们应该尽量设计它,以便支持任一界面,并且将来可以添加其他界面,比如 GUI 工具包或基于 Web 的界面。

作为设计决策,我们现在将实现菜单界面,但会牢记命令行选项版本,以确保我们设计Notebook类时考虑到可扩展性。

如果我们有两个命令行界面,每个界面都与Notebook对象交互,那么Notebook将需要一些方法让这些界面进行交互。我们需要能够add一个新的笔记,并且通过idmodify一个现有的笔记,除了我们已经讨论过的search方法。界面还需要能够列出所有的笔记,但它们可以通过直接访问notes列表属性来实现。

我们可能会漏掉一些细节,但这给了我们一个非常好的代码概述。我们可以用一个简单的类图总结所有这些:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在编写任何代码之前,让我们为这个项目定义文件夹结构。菜单界面应该明显地在自己的模块中,因为它将是一个可执行脚本,并且我们将来可能会有其他可执行脚本访问笔记本。NotebookNote对象可以一起存在于一个模块中。这些模块可以都存在于同一个顶级目录中,而不必将它们放在一个包中。一个空的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参数!我们将在第十章中讨论自动化测试,Python 设计模式 I。目前,只需使用解释器检查一些东西就足够了:

>>> 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_tagsmodify_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 相当特殊的习语;它是命令模式的一个轻量级版本,我们将在第十章中讨论,Python 设计模式 I。用户输入的选择是字符串。在菜单的__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方法,可以解决用户输入不存在的笔记 ID 的问题,检查_find_note是否返回了一个笔记,如下所示:

    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

这个方法已经更新为根据是否找到笔记返回TrueFalse。菜单可以使用这个返回值来显示错误,如果用户输入了无效的笔记。不过,这段代码有点笨重;如果它引发异常会好看一些。我们将在第四章中介绍,预料之外

练习

编写一些面向对象的代码。目标是使用本章学到的原则和语法,确保你可以使用它,而不仅仅是阅读它。如果你一直在做一个 Python 项目,回过头来看看,看看是否有一些对象可以创建,并添加属性或方法。如果项目很大,尝试将其分成几个模块,甚至包,并尝试使用语法。

如果你没有这样的项目,试着开始一个新的项目。它不必是你打算完成的东西,只需勾勒出一些基本的设计部分。你不需要完全实现所有东西,通常只需要一个print("this method will do something")就足以让整体设计就位。这被称为自顶向下的设计,在这种设计中,你先解决不同的交互并描述它们应该如何工作,然后再实际实现它们。相反的自底向上的设计,先实现细节,然后将它们全部联系在一起。这两种模式在不同的时候都很有用,但是对于理解面向对象的原则来说,自顶向下的工作流更合适。

如果你在想法上遇到困难,可以尝试编写一个待办事项应用程序。(提示:它会类似于笔记本应用程序的设计,但会有额外的日期管理方法。)它可以跟踪你每天想要做的事情,并允许你标记它们为已完成。

现在,尝试设计一个更大的项目。它不一定要实际做任何事情,但确保你尝试使用包和模块导入语法。在各种模块中添加一些函数,并尝试从其他模块和包中导入它们。使用相对导入和绝对导入。看看它们之间的区别,并尝试想象你何时想要使用每种导入方式。

总结

在本章中,我们学习了在 Python 中创建类并分配属性和方法是多么简单。与许多其他语言不同,Python 区分了构造函数和初始化器。它对访问控制有一种放松的态度。有许多不同级别的作用域,包括包、模块、类和函数。我们理解了相对导入和绝对导入之间的区别,以及如何管理不随 Python 一起提供的第三方包。

在下一章中,我们将学习如何使用继承来共享实现。

第三章:当对象相似时

在编程世界中,重复的代码被认为是邪恶的。我们不应该在不同的地方有相同或相似的代码的多个副本。

有许多方法可以合并具有类似功能的代码或对象。在本章中,我们将介绍最著名的面向对象原则:继承。如第一章中所讨论的,继承允许我们在两个或多个类之间创建关系,将通用逻辑抽象为超类,并在子类中管理特定细节。特别是,我们将介绍 Python 的语法和原则:

  • 基本继承

  • 从内置继承

  • 多重继承

  • 多态和鸭子类型

基本继承

从技术上讲,我们创建的每个类都使用继承。所有 Python 类都是名为object的特殊类的子类。这个类在数据和行为方面提供的很少(它提供的行为都是双下划线方法,仅供内部使用),但它确实允许 Python 以相同的方式处理所有对象。

如果我们没有明确从不同的类继承,我们的类将自动从object继承。但是,我们可以明确声明我们的类派生自object,使用以下语法:

class MySubClass(object):
    pass

这就是继承!从技术上讲,这个例子与我们在第二章中的第一个例子没有什么不同,因为如果我们没有明确提供不同的超类,Python 3 会自动从object继承。超类或父类是被继承的类。子类是继承自超类的类。在这种情况下,超类是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设置变量,您实际上将创建一个新的与该对象关联的实例变量。类变量仍然不变,并且可以作为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)

而不是将普通列表实例化为我们的类变量,我们创建一个新的ContactList类,它扩展了内置的list。然后,我们将这个子类实例化为我们的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'

大多数内置类型都可以类似地扩展。常见的扩展内置类型包括objectlistsetdictfilestr。数值类型如intfloat有时也会被继承。

覆盖和 super

因此,继承对于向现有类添加新行为非常有用,但是改变行为呢?我们的contact类只允许一个名字和一个电子邮件地址。对于大多数联系人来说,这可能已经足够了,但是如果我们想为我们的亲密朋友添加一个电话号码呢?

正如我们在第二章中看到的,Python 中的对象,我们可以很容易地通过在构造后在联系人上设置phone属性来做到这一点。但是,如果我们想在初始化时使这个第三个变量可用,我们必须覆盖__init__。覆盖意味着用子类中的新方法(具有相同名称)改变或替换超类的方法。不需要特殊的语法来做到这一点;子类新创建的方法会自动被调用,而不是超类的方法。例如:

class Friend(Contact):
    def __init__(self, name, email, phone):
        self.name = name
        self.email = email
        self.phone = phone

任何方法都可以被覆盖,不仅仅是__init__。然而,在继续之前,我们需要解决这个例子中的一些问题。我们的ContactFriend类有重复的代码来设置nameemail属性;这可能会使代码维护复杂化,因为我们必须在两个或更多的地方更新代码。更令人担忧的是,我们的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模块。

这个类并没有做任何特殊的事情(实际上,它几乎不能作为一个独立的类运行),但它确实允许我们定义一个新的类,描述了ContactMailSender,使用多重继承:

class EmailableContact(Contact, MailSender):
    pass

多重继承的语法看起来像类定义中的参数列表。在括号内不是包含一个基类,而是包含两个(或更多),用逗号分隔。我们可以测试这个新的混合体,看看 mixin 的工作情况:

>>> 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对象,而不是继承它。

  • 我们可以对Contact类进行猴子补丁(我们将在第七章中简要介绍猴子补丁,Python 面向对象快捷方式),在类创建后添加一个send_mail方法。这是通过定义一个接受self参数的函数,并将其设置为现有类的属性来完成的。

多重继承在混合来自不同类的方法时运行良好,但当我们必须调用超类的方法时,它变得非常混乱。有多个超类。我们怎么知道该调用哪一个?我们怎么知道以什么顺序调用它们?

让我们通过向我们的Friend类添加家庭地址来探讨这些问题。我们可能会采取一些方法。地址是表示联系人的街道、城市、国家和其他相关细节的字符串集合。我们可以将这些字符串中的每一个作为参数传递到Friend类的__init__方法中。我们也可以将这些字符串存储在元组或字典中,并将它们作为单个参数传递到__init__中。如果没有需要添加到地址的方法,这可能是最好的方法。

另一个选择是创建一个新的Address类来将这些字符串放在一起,然后将这个类的实例传递到我们的Friend类的__init__方法中。这种解决方案的优势在于,我们可以为数据添加行为(比如,一个给出方向或打印地图的方法),而不仅仅是静态存储它。这是组合的一个例子,正如我们在第一章中讨论的那样,面向对象设计。组合的“有一个”关系是这个问题的一个完全可行的解决方案,并且允许我们在其他实体中重用Address类,比如建筑物、企业或组织。

然而,继承也是一个可行的解决方案,这就是我们想要探讨的。让我们添加一个新的类来保存一个地址。我们将这个新类称为AddressHolder,而不是“Address”,因为继承定义了一个是一个的关系。说“Friend”是“Address”是不正确的,但由于朋友可以有一个“Address”,我们可以说“Friend”是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__方法解析顺序)属性来动态调整。这超出了本书的范围。如果您认为您需要了解它,我建议阅读Tarek ZiadéExpert Python ProgrammingPackt Publishing,或者阅读有关该主题的原始文档www.python.org/download/releases/2.3/mro/

让我们看一个更清楚地说明这个问题的第二个刻意的例子。这里有一个基类,其中有一个名为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方法被调用了两次。如果该方法正在执行实际工作,比如存款到银行账户,这可能会导致一些隐匿的错误。

多重继承需要记住的一点是,我们只想调用类层次结构中的“下一个”方法,而不是“父”方法。实际上,下一个方法可能不在当前类的父类或祖先上。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调用之后执行的,所以打印输出是每个方法实际执行的顺序。让我们从后往前看输出,看看是谁在调用什么。

首先,Subclasscall_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调用接收正确的参数。

具体来说,如果第一次调用supernameemail参数传递给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的字典中(我们可以随意命名变量,但约定建议使用kwkwargs)。当我们使用**kwargs语法调用不同的方法(例如super().__init__)时,它会解包字典并将结果作为普通关键字参数传递给方法。我们将在第七章中详细介绍这一点,Python 面向对象的快捷方式

前面的例子做到了它应该做的事情。但它开始看起来凌乱,而且很难回答这个问题,我们需要传递什么参数到 Friend.__init__?这是任何计划使用该类的人最关心的问题,因此应该在方法中添加一个文档字符串来解释正在发生的事情。

此外,即使这种实现方式也不足以实现在父类中重用变量。当我们将**kwargs变量传递给super时,字典不包括任何作为显式关键字参数包含的变量。例如,在Friend.__init__中,对super的调用在kwargs字典中没有phone。如果其他类中需要phone参数,我们需要确保它包含在传递的字典中。更糟糕的是,如果我们忘记这样做,调试将会很困难,因为超类不会抱怨,而只会将默认值(在这种情况下为空字符串)分配给变量。

有几种方法可以确保变量向上传递。假设Contact类因某种原因需要用phone参数初始化,并且Friend类也需要访问它。我们可以执行以下任一操作:

  • 不要将phone作为显式关键字参数包含在内。相反,将其留在kwargs字典中。Friend可以使用语法kwargs['phone']查找它。当它将**kwargs传递给super调用时,phone仍然会在字典中。

  • phone作为显式关键字参数,但在传递给super之前更新kwargs字典,使用标准字典语法kwargs['phone'] = phone

  • phone作为显式关键字参数,但使用kwargs.update方法更新kwargs字典。如果有多个参数需要更新,这是很有用的。您可以使用dict(phone=phone)构造函数或字典语法{'phone': phone}创建传递给update的字典。

  • phone作为显式关键字参数,但使用语法super().__init__(phone=phone, **kwargs)将其明确传递给 super 调用。

我们已经讨论了 Python 中多重继承涉及的许多注意事项。当我们需要考虑所有可能的情况时,我们必须为它们做好规划,我们的代码会变得混乱。基本的多重继承可能很方便,但在许多情况下,我们可能希望选择更透明的方式来组合两个不同的类,通常使用组合或我们将在第十章中介绍的设计模式之一,Python 设计模式 I和第十一章中介绍的设计模式之一,Python 设计模式 II

多态

我们在第一章中介绍了多态。这是一个描述简单概念的花哨名字:根据使用的子类不同,会发生不同的行为,而不必明确知道子类实际上是什么。举个例子,想象一个播放音频文件的程序。媒体播放器可能需要加载一个AudioFile对象,然后play它。我们会在对象上放一个play()方法,负责解压或提取音频并将其路由到声卡和扬声器。播放AudioFile的行为可能是非常简单的:

audio_file.play()

然而,解压和提取音频文件的过程对于不同类型的文件是非常不同的。.wav文件是未压缩的,而.mp3.wma.ogg文件都有完全不同的压缩算法。

我们可以使用继承和多态来简化设计。每种类型的文件都可以由AudioFile的不同子类来表示,例如WavFileMP3File。每个子类都会有一个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的哪个子类。解压音频文件的细节被封装起来。如果我们测试这个例子,它会按照我们的期望工作:

>>> 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()方法并不意味着它会自动与媒体播放器配合工作。例如,我们在第一章中的象棋 AI 对象,面向对象设计,可能有一个play()方法来移动棋子。即使它满足了接口,如果我们尝试将它插入到媒体播放器中,这个类很可能会以惊人的方式崩溃!

鸭子类型的另一个有用特性是,鸭子类型的对象只需要提供那些实际被访问的方法和属性。例如,如果我们需要创建一个虚假的文件对象来读取数据,我们可以创建一个具有read()方法的新对象;如果与对象交互的代码只会从文件中读取,我们就不必覆盖write方法。简而言之,鸭子类型不需要提供对象的整个接口,它只需要满足实际被访问的接口。

抽象基类

虽然鸭子类型很有用,但事先很难判断一个类是否会满足您需要的协议。因此,Python 引入了抽象基类的概念。抽象基类,或ABCs,定义了一个类必须实现的一组方法和属性,以便被视为该类的鸭子类型实例。该类可以扩展抽象基类本身,以便用作该类的实例,但它必须提供所有适当的方法。

实际上,很少需要创建新的抽象基类,但我们可能会发现需要实现现有 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 的名称和它实现的单个方法来看,很明显这个参数是用户要检查的容器是否包含的值。

这个方法由liststrdict实现,用于指示给定值是否在该数据结构中。然而,我们也可以定义一个愚蠢的容器,告诉我们给定的值是否在奇数集合中:

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 装饰器。我们将在第五章中讨论这些内容,何时使用面向对象编程。现在,只需知道通过将方法或属性标记为抽象,您声明该类的任何子类必须实现该方法或提供该属性,以便被视为该类的合格成员。

看看如果实现了或没有提供这些属性的子类会发生什么:

>>> 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 的 ABCs 有助于提供接口的功能,而不会影响鸭子类型的好处。

解密魔术

如果您想要创建满足这个特定契约的抽象类,可以复制并粘贴子类代码而不必理解它。我们将在整本书中涵盖大部分不寻常的语法,但让我们逐行地概述一下。

    @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。该方法可以合法地返回三个值中的一个:TrueFalseNotImplementedTrueFalse表示该类明确地是或不是这个类的子类:

        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 框架都采用面向对象的方法,我们现在学习的原则将有助于理解未来的这些系统。我们将在第十三章并发中简要讨论它们,但完整的细节远远超出了一本书的范围。

从我们的要求来看,似乎有很多名词可以代表我们系统中的对象类。显然,我们需要代表一个属性。房屋和公寓可能需要单独的类。租赁和购买似乎也需要单独的表示。由于我们现在专注于继承,我们将探讨使用继承或多重继承来共享行为的方法。

HouseApartment都是物业的类型,所以Property可以是这两个类的超类。RentalPurchase需要一些额外的考虑;如果我们使用继承,我们将需要有单独的类,例如HouseRentalHousePurchase,并使用多重继承来组合它们。与基于组合或关联的设计相比,这种感觉有点笨拙,但让我们试试看会得到什么。

那么,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关键字不起作用(没有父对象,只有一个父类),所以我们直接在父类上调用静态方法。这个方法使用 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)

这里没有新的东西可以探索,所以让我们继续讨论PurchaseRental类。尽管它们似乎有不同的目的,但它们的设计也与我们刚刚讨论的类似:

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调用的顺序。接口与用于HouseApartment的接口类似,当我们将这四个类的功能组合在单独的子类中时,这是非常有用的。例如:

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父类上调用方法。但是Propertydisplay方法中没有调用super。这意味着Rental类的display方法不会被调用!通过按照我们的继承列表的顺序,我们确保Rental调用super,然后处理House层次结构的一面。你可能会认为我们可以在Property.display()中添加一个super调用,但这将失败,因为Property的下一个超类是object,而object没有display方法。修复这个问题的另一种方法是允许RentalPurchase扩展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.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

练习

看看你的工作空间中的一些物理物体,看看你是否能用继承层次结构描述它们。人类几个世纪以来一直在将世界划分成这样的分类,所以这不应该难。在对象类之间是否存在一些非明显的继承关系?如果你要在计算机应用程序中对这些对象建模,它们会共享哪些属性和方法?哪些属性需要多态地重写?它们之间会有完全不同的属性吗?

现在,写一些代码。不是为了物理层次结构;那太无聊了。物理物品比方法有更多的属性。只是想想你过去一年想要解决的宠物编程项目,但从未着手解决过。无论你想解决什么问题,都试着想一些基本的继承关系。然后实施它们。确保你也注意到你实际上不需要使用继承的关系。有没有地方你可能想要使用多重继承?你确定吗?你能看到任何你想要使用 mixin 的地方吗?试着拼凑一个快速的原型。它不必有用,甚至部分工作。你已经看到如何使用python -i测试代码了;只需写一些代码并在交互式解释器中测试它。如果它有效,就写更多。如果不行,就修复它!

现在,看看房地产的例子。这实际上是多重继承的一种非常有效的应用。不过,我必须承认,当我开始设计时,我有些怀疑。看看原始问题,看看你是否能想出另一种只使用单一继承来解决它的设计。你会如何使用抽象基类来做到这一点?还有一种不使用继承的设计吗?你认为哪种是最优雅的解决方案?优雅是 Python 开发的主要目标,但每个程序员对什么是最优雅的解决方案有不同的看法。有些人倾向于使用组合来更清晰地思考和理解问题,而其他人则认为多重继承是最有用的模型。

最后,尝试向这三种设计添加一些新功能。任何你喜欢的功能都可以。我想看到一种区分可用和不可用属性的方法,首先。如果已经被租出去,对我来说没什么用!

哪种设计最容易扩展?哪种最难?如果有人问你为什么这样认为,你能解释吗?

摘要

我们已经从简单的继承,这是面向对象程序员工具箱中最有用的工具之一,一直到多重继承,这是最复杂的工具之一。继承可以用来向现有类和内置类添加功能。将相似的代码抽象成父类可以帮助增加可维护性。可以使用super调用父类上的方法,并且在使用多重继承时,必须安全地格式化参数列表,以使这些调用正常工作。

在下一章中,我们将讨论处理特殊情况的微妙艺术。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值