多重继承是一个棘手的主题。从原则上说,它非常简单:继承自多个父类的子类可以获取所有父类的功能。在实践中,并没有听起来那么有用,很多专业的程序员都建议不要这样用。
根据经验法则,如果你认为你需要使用多重继承,你可能错了;但是如果你知道(而不是认为)你需要它,那么你可能是对的。
多重继承最简单有效的形式是被称为 混入 ( mixin )的设计模式。混入的类定义是一个不会单独实例化的父类,它的存在是为了被其他类继承并添加额外的功能。例如,我们想要为Contact类添加向self.email发送邮件的功能。发送邮件是一个通用任务,我们可能想在多个其他的类中使用。因此,我们可以创建一个简单的混入类来处理邮件相关的事务:
这个MailSender类并没有特别之处(实际上,它自己根本不能独立运行,因为它依赖于一个它没有的self.email属性)。我们上面定义了两个类,因为我们在描述两个事情:混入对宿主类 的要求,以及混入能给宿主类提供的功能。我们需要创建一个类型提示Emailable来描述MailSender混入对宿主类的要求。
这种类型提示被称为 协议 ( protocol );协议通常包含方法,也可能会包含用类型提示定义的类属性,但属性通常没有赋值。协议是没完成的类,可以把它想象成描述一个类所拥有的功能的契约。协议告诉 mypy ,任何想要混入Emailable对象的类(或子类)都必须包含一个email属性,并且这个属性必须是字符类型的。
注意,我们正在使用Python的名称解析规则。self.email可能是实例变量,或者类变量,或者Emailable.email,或者一个属性。 mypy 工具会检查所有和MailSender混入在一起的实例或类的定义。我们只需要提供类属性名称和类型提示,让 mypy 清楚地知道MailSender这个混入类并没有定义属性,它混合的类将提供email属性。
由于Python的鸭子类型规则,我们可以将MailSender混入与任何定义了email属性的类一起使用。与MailSender混合的类不必是Emailable的正式子类,它只需要提供必需的属性。
为简单起见,我们没有在此处包含实际的电子邮件逻辑。如果您感兴趣,请参阅Python标准库中的smtplib模块。MailSender类确实允许我们使用多重继承定义一个新类来描述Contact和MailSender:
多重继承的语法看起来就像类定义中的参数列表,我们将两个(或多个)以逗号分隔的基类放进括号中。如果使用得当,通常子类没有自己的独特功能,而仅仅是拥有了多个父类的功能,因此它的代码块中只包含pass占位符。
我们可以测试这个混合类以查看运行中的混入:
如上面的结果所示,Contact的初始化函数仍然能够把新的联系人添加到all_contacts列表中,而混入则能够发送电子邮件给self.mail,因此可以看出一切都正常。
这看起来并不难,你可能想知道为什么本书前面警告要慎重使用多重继承。我们稍后会讨论更复杂的情况,在此之前我们先考虑一下不使用混入的其他方案:
· 我们可以使用一个单独的继承关系,并向contact子类中添加send_mail()函数。这里的缺点在于,任何其他需要发送邮件的类都需要重复这些代码。例如,如果我们想给我们软件的支付模块增加一个发送邮件的功能,这和Contact没有关系,但我们需要一个send_mail()方法,那么我们必须在支付模块中重复这段代码。
· 我们可以创建一个独立的Python函数来发送邮件,当需要发送邮件时,只需要调用这个函数并将邮箱地址作为参数传入即可(这是很常用的方案)。因为这个函数不是类的一部分,我们比较难确定是否使用了正确的封装。
· 我们可以探索几种使用组合关系而非继承关系的方案。例如,EmailableContact可以拥有一个MailSender对象作为属性而不是继承它。这会带来一个更复杂的MailSender类,因为它必须是一个能独立使用的类。这也会导致一个更复杂的EmailableContact类,因为每一个Contact必须和一个MailSender实例关联。
· 我们可以用猴子补丁(将会在第13章中简要讨论猴子补丁)在Contact类创建之后为其添加send_mail()函数。这是通过定义一个接收self参数的函数来完成的,并将其设置为一个已有类的属性。这是一个做单元测试的好方法,但不应该用在实际的软件代码中。
多重继承在混合不同类的方法时不会出问题,但是当我们不得不调用超类的方法时,就会变得很混乱。既然有很多超类,那么我们如何判断该调用哪个?又如何知道调用顺序?
让我们通过向Friend类加入address(家庭住址)来探究这些问题。我们可以采取如下几种方法:
· 地址是由街道、城市、国家及其他相关的细节所组成的一系列字符串。我们可以将这些字符串分别作为参数传递给Friend类的__init__()方法。我们也可以在将这些字符串存储在一个泛型元组或字典中之后,将其作为一个单独的参数传递给__init__()。如果不需要添加与地址相关的方法,这些方案都没问题。
· 另一个方法是创建一个新的Address类,将这些字符串保存到一起,并将它的实例作为参数传递给Friend类的__init__()方法。这种解决方案的优点在于,我们可以为数据添加行为(例如,指出方向或打印地图的方法),而不只是静态的存储。正如我们在第1章中所讨论的,这是一个组合的示例。组合中“有一个”的关系可以完美地应用在这里,并且这也允许我们在其他诸如建筑物、公司或组织等场景中重用Address类(这里也是使用dataclass的一个机会。我们将在第7章中探讨dataclass)。
· 第三种方法是使用多重继承的设计。这是可行的,但是它很难通过 mypy 的校验。因为多重继承有很多潜在的模糊的地方,从而很难有效地通过类型提示进行描述。
我们先来尝试使用多重继承的方法。我们在这里将为地址添加一个新类。我们称这个新类为AddressHolder而不是Address,因为继承关系定义的是“是一个”的关系。说Friend类是一个Address类并不准确,但是由于Friend可以拥有一个Address类,故我们可以说Friend类是一个AddressHolder。稍后,我们可能创建的其他实体(公司、建筑)同样也拥有地址。
(如果类名让人费解,或者我们怀疑是不是“是一个”关系,这就表明我们应该使用组合而不是继承。)
下面是我们的幼稚版的AddressHolder类。我们称它为幼稚版是因为它并不是很适合多重继承关系:
我们把所需的数据都通过构造方法传递进来,并赋值给实例变量。我们将会了解这种设计带来的问题,并学习一种更好的设计。
我们可以用多重继承来将这个新类添加为已有的Friend类的父类。棘手的部分是,现在有两个父类的__init__()方法都需要进行初始化,而且它们需要通过不同的参数进行初始化。如何做到这一点?可以从Friend类的有点儿幼稚的方法开始:
在这个示例中,我们直接调用每个超类的__init__()函数并明确地传递self参数给它们。这种写法理论上是可行的,我们可以直接通过这个类访问不同的变量。但是仍然存在几个问题。
首先,如果我们不明确地调用父类的初始化函数,可能会导致父类未被正确初始化。在这个示例中这样并不会导致崩溃,但可能会在其他代码中产生很难调试的崩溃情况。在有明确的__init__()方法的类中,可能会产生很多看起来很奇怪的AttributeError异常。原因是__init__()没有被调用,但这个原因非常隐蔽。
其次,更危险的是,类层级的组织可能导致超类被多次调用。看下面这个继承关系类图,如图3.1所示。
图3.1 我们多重实现的继承关系类图
Friend类的__init__()方法首先调用Contact类的__init__(),Contact又隐式地初始化了Object父类(记住,所有的类都源自Object)。然后Friend类调用AddressHolder的__init__(),而AddressHolder再一次初始化Object父类。这意味着父类被初始化了两次。对于Object类来说,这相对没什么危害,但是在某些情况下,这可能会导致灾难。想象一下,每次请求都连接两次数据库。
基类应该只被调用一次,但应该是什么时候?先调用Friend、Contact、Object,然后调用AddressHolder吗?还是先调用Friend、Contact、AddressHolder,然后调用Object?
为了更清楚地阐述这个问题,让我们看看另一个虚构的示例。这里有一个名叫BaseClass的基类,它拥有call_me()方法。它的两个子类(LeftSubclass和RightSubclass)扩展了BaseClass类,都重写了这一方法。
然后,另一个子类用多重继承和call_me()方法的第4个不同实现扩展了这两个类。由于类图的形状看起来像钻石,故被称为 钻石继承 ,如图3.2所示。
图3.2 钻石继承
让我们将这个类图翻译成代码,这个示例显示了方法何时被调用:
这个示例确保每个重写的call_me()方法都直接调用了父类的同名方法。通过将信息打印到屏幕上,可以知道每次被调用的方法是哪个,同时也通过创建不同的实例变量显示了方法被调用的次数。
self.num_base_calls+=1这行代码需要一点儿额外的解释。
这相当于self.num_base_calls=self.num_base_calls+1。当Python看到self.num_base_calls在等号的右侧时,它会首先查找实例变量,然后是类变量。我们已经创建了一个默认值为0的类变量。在执行了+1运算后,赋值语句会创建一个新的实例变量,它不会更新原来的类变量。
在第一次调用后,实例变量就存在了。通过类变量给实例变量提供默认值是一个很酷的写法。
如果我们实例化Subclass对象并调用一次call_me()方法,将会得到如下输出:
因此,我们可以清楚地看到基类的call_me()方法被调用了两次。如果这个方法在真实环境中运行两次,例如向银行账号中存款,则可能产生隐患。
Python的 方法调用顺序 ( Method Resolution Order , MRO )算法会把钻石形的关系转变成一个线性的元组。我们可以通过类的__mro__属性查看这个元组。钻石线性版本的顺序是:Subclass、LeftSubclass、RightSubclass、BaseClass、Object。这里的重点是,LeftSubclass排在RightSubclass之前(先左后右)。
关于多重继承需要记住的是,我们可能只想调用MRO序列中的“下一个”方法,而不是“父类”方法。实际上,下一个方法可能不属于该类的父类或更早的祖先。super()函数可以找到MRO序列中的下一个方法。实际上,super()最初就是为了实现更复杂的多重继承而设计的。
下面是同样的代码使用super()之后的样子。为了区分使用了super()版本的代码,我们给类名后面加了“_S”:
这是非常小的改动,我们只是将原来的直接调用替换为super()调用。钻石下部的Subclass_S子类只调用了一次super()方法,而不是分别调用左右两个父类。虽然改动很简单,但让我们执行看看有什么差别:
看起来不错,基类的方法只调用了一次。我们可以通过类的__mro__属性了解它的工作原理:
上面元组包含的类的顺序就是super()会使用的顺序。元组里最后一个类一般是内置的Object类。本章前面提到过,Object是所有类的超类。
我们再来看看super()实际上做了什么。由于print语句在super()之后调用,打印出来的结果是每个方法实际执行的顺序。让我们从后向前根据输出看看调用顺序:
1.我们首先从Subclass_S.call_me()方法开始,它调用了super().call_me()。MRO显示下一个是LeftSubclass_S。
2.我们开始调用LeftSubclass_S.call_me()方法,它又调用了super().call_me()。MRO显示下一个是RightSubclass_S,但它不是LeftSubclass_S的父类,在钻石类图中,它们是邻居关系。
3.RightSubclass_S.call_me()中又调用了super().call_me()。这次指向了BaseClass。
4.BaseClass.call_me()方法完成了执行过程:打印一条消息,并把实例变量self.num_base_calls的值设为BaseClass.num_base_calls+1。
5.然后,RightSubclass_S.call_me()方法执行结束,打印一条消息并设定实例变量self.num_right_calls。
6.然后,LeftSubclass_S.call_me()方法执行结束,打印一条消息并设定实例变量self.num_left_calls。
7.最后,Subclass_S完成它的call_me()方法,结果也是打印一条消息并设定实例变量的值。成功!
特别需要注意 ,super()调用并没有调用LeftSubclass_S的超类(也就是BaseClass)中的方法,而是调用了RightSubclass_S,虽然它不是LeftSubclass_S的直接父类,但它是MRO中的下一个类,并非父类方法。然后RightSubclass_S再调用BaseClass,通过使用super()可以确保类层级中的每一个方法都只执行一次。
当我们回到Friend多重继承的示例时,事情将会变得更复杂。在Friend类的__init__()方法中,我们需要使用不同的参数分别调用两个父类的__init__()方法:
当使用super()方法的时候,我们如何管理不同的参数呢?因为super()方法根据MRO序列的顺序进行调用,所以我们并不确定super()方法会先初始化哪个类。即使知道,我们也需要通过构造方法传递 额外的 参数的方法,让后续的混入类在调用super()方法时可以把这些参数再传递过去。
在上面的示例中,如果MRO的第一个类第一次调用super()方法时传递了name和email参数给Contact.__init__(),然后Contact.__init__()继续调用super()方法,那么它需要能够将地址相关的参数传递给MRO的下一个类的方法,也就是AddressHolder.__init__()。
每次我们需要调用超类名称相同但参数不同的方法时,都会遇到这个问题。这种冲突通常出现在特殊名称的函数中。比如,最常见的示例就是,拥有不同参数的不同的__init__()方法,也就是我们现在正在处理的问题。
Python没有处理这种名称相同但参数不同的函数的特殊语法。解决这个问题唯一的办法就是精心设计我们的类参数列表。常见的设计是将子类方法的参数列表设计成可以接收任意关键字参数,每个方法必须确保能够把自己不需要的参数再传递给自己的super()方法,以防它们在后续的MRO调用中会被用到。
这是可行的,而且也挺不错的,但这种设计很难定义清晰的类型提示。因此,我们不得不在相关的地方关闭 mypy 的类型检查。
Python的函数参数语法支持以上设计,不过这会让代码看起来很笨重。我们下面来看看Friend多重继承的新实现:
我们添加了**kwargs参数,用于把额外的关键字参数值收集到一个字典中。当我们调用Contact(name="this",email="that",street="something")时,street参数被放入kwargs字典。这些额外的参数会通过super()调用传递给下一个类。特殊参数“/”用于分割位置参数和关键字参数。同时,我们把所有字符串参数的默认值设置为空字符串。
可能你不熟悉**kwargs语法,基本上来说,它能够收集任何传递给方法但没有明显列在参数列表中的关键字参数。这些参数将被存储在一个名为kwargs(我们可以随便使用其他的名称,但是按照惯例推荐使用kw或kwargs)的字典中。当我们使用**kwargs语法调用另一个方法(例如super().__init__())时,会将字典分解之后作为正常的关键字参数传入这一方法。我们将在第8章中讨论这些细节。
我们添加了两条注释,用来告诉 mypy 忽略这两行代码的某些类型检查,这对看代码的人也有帮助。注释#type:ignore:call-arg中指明了要忽略的错误是call-arg,也就是调用参数。在我们的示例中,我们要忽略super().__init__(**kwargs),因为 mypy 无法确定代码运行时MRO的顺序。如果有人阅读代码,我们可以通过查看Friend类的代码来判断顺序:Contact和AddressHolder。这个顺序意味着,在Contact类中,super()方法会调用下一个类,也就是AddressHolder。
然而, mypy 工具并没有深入研究这一点,它通过class语句中父类的显式列表来执行。如果没有父类, mypy 就会认为super()将调用Object类的构造方法。既然*object.__init__()不能接收任何参数,所以 mypy 会认为Contact和AddressHolder中的super().init(**kwargs)的调用是错误的。实际上,在MRO的调用过程中会逐渐用完所有参数,最后到了AddressHolder的__init__()*方法时已经没有额外的参数了。
更多关于多重继承的类型提示注解的信息,可以查看链接10中的讨论。这个问题到现在还没有被关闭,足以说明它非常困难。
上一个示例完成了它的任务,但是很难回答这个问题:我们到底应该给Friend.__init__()传递什么参数?这是任何一个打算使用这个类的人首先会遇到的问题,因此我们应该为这个方法添加文档字符串,写清楚这个类和它的父类所需的完整的参数列表。
当出现拼写错误或多传了参数时,错误信息也不容易理解:TypeError:object.__init__()takes exactly one argument(the instance to initialize)。通过这句话,我们无法知道这个额外的参数是如何传递给object.__init__()的。
我们已经讨论了Python多重继承中许多容易引发错误的地方。我们需要应对所有可能的情况,不得不提前规划,这会导致我们的代码变得混乱。
使用混入模式的多重继承通常比较好用。主要思想是把混入类的方法添加到宿主类中,并保持所有的属性都在宿主类层级中管理。这样可以避免处理多重继承中复杂的初始化过程。
使用组合通常比复杂的多重继承更好。我们将在第11章与第12章中学习很多基于组合的设计模式。
继承适用于类之间清晰的“是一个”关系。多重继承展示了一些并不那么清晰的关系。比如,我们可以说“电子邮件是一种联系方式”,但如果我们说“客户是一个电子邮件”就不那么合理了。我们可以说“客户有一个电子邮件地址”或者“客户可以通过电子邮件联系”。在这种情况下,更适合使用“有一个”的组合关系,而不是“是一个”的继承关系。