Skip to content

Latest commit

 

History

History
922 lines (661 loc) · 52.6 KB

File metadata and controls

922 lines (661 loc) · 52.6 KB

十七、当对象相似时

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

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

  • 基本遗传
  • 从内置类型继承
  • 多重继承
  • 多态性与鸭子分型

基本遗传

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

如果我们没有显式地从其他类继承,我们的类将自动从object继承。但是,我们可以使用以下语法公开声明我们的类源自object

class MySubClass(object): 
    pass 

这就是遗产!从技术上讲,这个例子与我们在第 16 章中的第一个例子,Python 中的对象没有什么不同,因为如果我们不显式地提供不同的超类,Python 3 会自动继承object。超类或父类是从继承的类。子类是从超类继承的类。在这种情况下,超类是object,而MySubClass是子类。子类也可以说是从其父类派生的,或者说子类扩展了父类。

正如您可能已经从示例中了解到的那样,继承需要在基本类定义上使用最少的额外语法。只需在类名和后面的冒号之间的括号中包含父类的名称。这就是我们要告诉 Python 新类应该从给定的超类派生出来所要做的一切。

我们如何在实践中应用继承?继承最简单也是最明显的用途是向现有类添加功能。让我们从一个简单的联系人管理器开始,它跟踪几个人的姓名和电子邮件地址。Contact类负责维护类变量中所有联系人的列表,并初始化单个联系人的姓名和地址:

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类实例的任何方法中以self.all_contacts的形式访问它。如果在对象上找不到字段(通过self,则该字段将在类上找到,因此将引用同一个列表。

Be careful with this syntax, for if you ever set the variable using self.all_contacts, you will actually be creating a new instance variable associated just with that object. The class variable will still be unchanged and accessible as 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 "
            f"'{order}' order to '{self.name}'"
        )

现在,如果我们在可信的解释器中测试这个类,我们会看到所有联系人,包括供应商,都接受他们的__init__中的名称和电子邮件地址,但只有供应商有功能订单方法:

>>> c = Contact("Some Body", "[email protected]")
>>> s = Supplier("Sup Plier", "[email protected]")
>>> 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
        Contact.all_contacts.append(self)

我们创建一个新的ContactList类,扩展内置list数据类型,而不是将普通列表实例化为类变量。然后,我们将这个子类实例化为我们的all_contacts列表。我们可以按如下方式测试新的搜索功能:

>>> c1 = Contact("John A", "[email protected]")
>>> c2 = Contact("John B", "[email protected]")
>>> c3 = Contact("Jenna C", "[email protected]")
>>> [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等数字类型也偶尔从中继承。

超越与超

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

正如我们在第 16 章中所看到的,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()调用。因此,所有方法都可以通过重写和调用super来修改。也可以在方法中的任意点调用super;我们不必在第一条线路上打电话。例如,我们可能需要在将传入参数转发到超类之前对其进行操作或验证。

多重继承

多重继承是一个敏感的话题。原则上,这很简单:从多个父类继承的子类能够从这两个父类访问功能。实际上,这没有听起来那么有用,许多专家程序员建议不要使用它。

As a humorous rule of thumb, if you think you need multiple inheritance, you're probably wrong, but if you know you need it, you might be right.

最简单、最有用的多重继承形式称为混合蛋白。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", "[email protected]")
>>> 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对象作为属性,而不是从其继承。
  • 我们可以在第 20 章Python 面向对象快捷方式中简要介绍猴子补丁(monkey patch),Contact类在类创建后有一个send_mail方法。这是通过定义一个接受self参数的函数,并将其设置为现有类的属性来实现的。

当混合来自不同类的方法时,多重继承可以正常工作,但是当我们必须在超类上调用方法时,它会变得非常混乱。有多个超类。我们怎么知道该叫哪一个?我们怎么知道叫他们进来的顺序?

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

另一种选择是创建一个新的Address类来将这些字符串保存在一起,然后将该类的一个实例传递给Friend类中的__init__方法。此解决方案的优点是,我们可以向数据中添加行为(例如,提供方向或打印地图的方法),而不仅仅是静态存储数据。这是一个组合示例,正如我们在第 15 章面向对象设计中所讨论的。组合是解决这个问题的一个完全可行的解决方案,它允许我们在其他实体中重用Address类,如建筑物、企业或组织。

然而,继承也是一个可行的解决方案,这正是我们想要探索的。让我们添加一个包含地址的新类。我们将这个新类称为AddressHolder而不是Address,因为继承定义了一个 is 关系。说一个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超类(记住,所有类都派生自objectFriend然后调用AddressHolder上的__init__,这会再次隐式初始化object超类*。这意味着父类已经设置了两次。对于object类,这相对来说是无害的,但在某些情况下,它可能意味着灾难。想象一下,对于每个请求,尝试两次连接到数据库!*

*基类只能调用一次。一次,是的,但是什么时候?我们会先叫Friend,然后叫Contact,再叫Object,再叫AddressHolder吗?或者Friend,然后Contact,然后AddressHolder,然后Object

The order in which methods can be called can be adapted on the fly by modifying the __mro__ (Method Resolution Order) attribute on the class. This is beyond the scope of this book. If you think you need to understand it, we recommend Expert Python Programming, Tarek Ziadé, Packt Publishing, or read the original documentation (beware, it's deep!) on the topic at http://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方法被调用了两次。这可能会导致一些有害的错误,如果这种方法是做实际工作,如存入银行帐户,两次。

多重继承需要记住的是,我们只想调用类层次结构中的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然后调用BaseClasssuper调用确保类层次结构中的每个方法执行一次。

不同的参数集

当我们回到Friend多重继承示例时,这将使事情变得复杂。在Friend__init__方法中,我们最初为两个父类调用__init__,使用不同的参数集:

Contact.__init__(self, name, email) 
AddressHolder.__init__(self, street, city, state, code) 

使用super时,我们如何管理不同的参数集?我们不一定知道哪个类super将首先尝试初始化。即使我们这样做了,我们也需要一种方法来传递extra参数,以便在其他子类上对super的后续调用接收正确的参数。

具体来说,如果第一次调用supernameemail参数传递给Contact.__init__,然后Contact.__init__调用super,则需要能够将与地址相关的参数传递给next方法,即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调用将这些参数传递给下一个类。

If you aren't familiar with the **kwargs syntax, it basically collects any keyword arguments passed into the method that were not explicitly listed in the parameter list. These arguments are stored in a dictionary named kwargs (we can call the variable whatever we like, but convention suggests kw, or kwargs). When we call a different method (for example, super().__init__) with a **kwargs syntax, it unpacks the dictionary and passes the results to the method as normal keyword arguments. We'll cover this in detail in Chapter 20, Python Object-Oriented Shortcuts.

前面的示例执行它应该执行的操作。但是它开始看起来很混乱,很难回答这个问题,我们需要把什么论点传递给Friend.__init__?对于任何计划使用该类的人来说,这是最重要的问题,因此应该向该方法添加一个 docstring 来解释发生了什么。

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

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

  • 不要将phone作为显式关键字参数包含。相反,把它留在kwargs字典里。Friend可以使用kwargs['phone'] 语法查找。当它将**kwargs传递到super调用时,phone仍将在字典中。
  • phone设置为显式关键字参数,但在将kwargs字典传递给super之前,使用标准字典kwargs['phone'] = phone语法更新该字典。
  • 使phone成为显式关键字参数,但使用kwargs.update方法更新kwargs字典。如果有多个参数要更新,这将非常有用。您可以使用dict(phone=phone)构造函数或字典{'phone': phone}语法创建传递到update的字典。
  • 使phone成为显式关键字参数,但使用super().__init__(phone=phone, **kwargs)语法显式地将其传递给超级调用。

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

I have wasted entire days of my life trawling through complex multiple inheritance hierarchies trying to figure out what arguments I need to pass into one of the deeply nested subclasses. The author of the code tended not to document his classes and often passed the kwargs—Just in case they might be needed someday. This was a particularly bad example of using multiple inheritance when it was not needed. Multiple inheritance is a big fancy term that new coders like to show off, but I recommend avoiding it, even when you think it's a good choice. Your future self and other coders will be glad they understand your code when they have to read it later.

多态性

我们在第 15 章面向对象设计中介绍了多态性。这是一个描述简单概念的华丽名称:不同的行为取决于使用的子类,而不必明确知道子类实际上是什么。例如,想象一个播放音频文件的程序。媒体播放器可能需要加载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__如何在不知道它引用的是哪个子类的情况下检查文件类型?

多态性实际上是面向对象编程中最酷的东西之一,它使一些在早期范例中不可能实现的编程设计变得显而易见。然而,由于 duck 类型,Python 使多态性看起来不那么可怕。Python 中的 Duck 类型允许我们使用任何提供所需行为的对象,而不必强迫它成为子类。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 中互换使用,所以它减少了对多态公共超类的需要。继承对于共享代码仍然很有用,但如果共享的只是公共接口,则只需要 duck 类型。这减少了对继承的需求,也减少了对多重继承的需求;通常,当多重继承看起来是一个有效的解决方案时,我们可以使用 duck 类型来模拟多个超类中的一个。

当然,仅仅因为对象满足特定的接口(通过提供所需的方法或属性),并不意味着它可以在所有情况下简单地工作。它必须以在整个系统中有意义的方式实现该接口。仅仅因为一个对象提供了一个play()方法,并不意味着它将自动与媒体播放器一起工作。例如,来自第 15 章面向对象设计的国际象棋 AI 对象可能有一个移动棋子的play()方法。即使它满足接口,如果我们尝试将它插入媒体播放器,这个类也可能以惊人的方式崩溃!

duck 类型化的另一个有用特性是 duck 类型化对象只需要提供那些实际被访问的方法和属性。例如,如果我们需要创建一个伪文件对象来读取数据,我们可以创建一个具有read()方法的新对象;如果要与假对象交互的代码不调用它,我们不必重写write方法。更简洁地说,duck 类型不需要提供可用对象的整个接口;它只需要实现实际访问的接口。

抽象基类

虽然 duck 类型很有用,但事先判断一个类是否满足您所需的协议并不总是容易的。因此,Python 引入了抽象基类ABCs)的思想。抽象基类定义一组方法和属性,类必须实现这些方法和属性才能被视为该类的 duck 类型实例。该类可以扩展抽象基类本身,以便用作该类的实例,但它必须提供所有适当的方法。

在实践中,很少需要创建新的抽象基类,但我们可能会找到实现现有 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 

这就是为什么 duck 类型比经典多态性更可怕的原因。我们可以创建一个关系,而无需编写代码来设置继承(或者更糟的是,多重继承)的开销。

关于ContainerABC 有一件很酷的事情,就是实现它的任何类都可以免费使用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 

创建抽象基类

正如我们前面看到的,不必使用抽象基类来启用 duck 类型。然而,想象一下我们正在创建一个带有第三方插件的媒体播放器。在这种情况下,建议创建一个抽象基类来记录第三方插件应该提供的 API(文档是 ABC 的一个更强大的用例)。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 装饰程序。我们将在第 22 章Python 设计模式 I中讨论这些。现在,只要知道,通过将一个方法或属性标记为抽象,您就说明了这个类的任何子类都必须实现该方法或提供该属性,才能被视为该类的适当成员。

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

>>> 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类提供这两个属性,因此它可以干净地实例化。

回到MediaLoaderABC,让我们剖析一下__subclasshook__方法。基本上是说,任何提供此 ABC 的所有抽象属性的具体实现的类都应该被视为MediaLoader的子类,即使它实际上不是从MediaLoader类继承的。

更常见的面向对象语言在接口和类的实现之间有明确的分离。例如,一些语言提供了一个显式的interface关键字,允许我们定义一个类在没有任何实现的情况下必须具有的方法。在这样的环境中,抽象类既提供接口,也提供一些(但不是全部)方法的具体实现。任何类都可以显式地声明它实现了给定的接口。

Python 的 ABC 有助于提供接口的功能,而不会损害 duck 类型的好处。

揭开魔术的神秘面纱

如果您想创建满足此特定契约的抽象类,可以复制和粘贴子类代码,而不必理解它。我们将在本书中介绍大多数不寻常的语法,但让我们逐行回顾一下以获得概述:

    @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

个案研究

让我们试着用一个更大的例子把我们学到的东西联系起来。我们将为编程作业开发一个自动评分系统,类似于 Dataquest 或 Coursera 采用的评分系统。系统需要为课程编写者提供一个简单的基于类的接口,以创建他们的作业,如果系统没有实现该接口,则应给出有用的错误消息。作者需要能够提供他们的课程内容,并编写自定义的答案检查代码,以确保他们的学生得到正确的答案。对他们来说,能够接触到学生的名字,让内容看起来更友好一点也不错。

评分员自己需要记录学生目前正在做的作业。一个学生在完成一项作业之前可能会尝试几次。我们希望跟踪尝试的次数,以便课程作者能够改进较难课程的内容。

让我们从定义课程作者需要使用的接口开始。理想情况下,除了课程内容和答案检查代码外,还需要课程作者编写最少的额外代码。下面是我能想到的最简单的类:

class IntroToPython:
    def lesson(self):
        return f"""
            Hello {self.student}. define two variables,
            an integer named a with value 1
            and a string named b with value 'hello'

        """
    def check(self, code):
        return code == "a = 1\nb = 'hello'"

诚然,那个特定的课程作者在如何进行答案检查方面可能有点幼稚。

我们可以从定义此接口的抽象基类开始,如下所示:

class Assignment(metaclass=abc.ABCMeta):
    @abc.abstractmethod
    def lesson(self, student):
        pass

    @abc.abstractmethod
    def check(self, code):
        pass

    @classmethod
    def __subclasshook__(cls, C):
        if cls is Assignment:
            attrs = set(dir(C))
            if set(cls.__abstractmethods__) <= attrs:
                return True

        return NotImplemented

这个 ABC 定义了两个必需的抽象方法,并提供了神奇的__subclasshook__方法,允许类被视为一个子类,而不必显式地扩展它(我通常只是复制并粘贴这段代码,不值得记忆)

我们可以使用issubclass(IntroToPython, Assignment)确认IntroToPython类完成了这个接口,应该返回True。当然,如果愿意,我们可以显式扩展Assignment类,如第二个作业所示:

class Statistics(Assignment):
    def lesson(self):
        return (
            "Good work so far, "
            + self.student
            + ". Now calculate the average of the numbers "
            + " 1, 5, 18, -3 and assign to a variable named 'avg'"
        )

    def check(self, code):
        import statistics

        code = "import statistics\n" + code

        local_vars = {}
        global_vars = {}
        exec(code, global_vars, local_vars)

        return local_vars.get("avg") == statistics.mean([1, 5, 18, -3])

不幸的是,本课程的作者也相当幼稚。exec调用将在评分系统内执行学生代码,让他们访问整个系统。显然,他们要做的第一件事就是破解系统,让自己的分数达到 100%。他们可能认为这比正确完成作业容易!

接下来,我们将创建一个类,用于管理学生在给定作业中的尝试次数:

class AssignmentGrader:
    def __init__(self, student, AssignmentClass):
        self.assignment = AssignmentClass()
        self.assignment.student = student
        self.attempts = 0
        self.correct_attempts = 0

    def check(self, code):
        self.attempts += 1
        result = self.assignment.check(code)
        if result:
            self.correct_attempts += 1

        return result

    def lesson(self):
        return self.assignment.lesson()

这个类使用组合而不是继承。乍一看,这些方法存在于Assignment超类上是有意义的。这将消除恼人的lesson方法,它只是在赋值对象上代理相同的方法。当然可以将所有这些逻辑直接放在Assignment抽象基类上,甚至可以让 ABC 从AssignmentGrader类继承。事实上,我通常会建议这样做,但在这种情况下,这将迫使所有课程作者显式扩展该类,这违反了我们关于内容创作尽可能简单的要求。

最后,我们可以开始组织Grader课程,该课程负责管理哪些作业可用,以及每个学生目前正在做的作业。最有趣的部分是寄存器方法:

import uuid

class Grader:
    def __init__(self):
        self.student_graders = {}
        self.assignment_classes = {}

    def register(self, assignment_class):
        if not issubclass(assignment_class, Assignment):
            raise RuntimeError(
                "Your class does not have the right methods"
            )

        id = uuid.uuid4()
        self.assignment_classes[id] = assignment_class
        return id

这个代码块包括初始值设定项,它包括两个字典,我们将在一分钟内讨论。register方法有点复杂,所以我们将彻底剖析它。

第一件奇怪的事情是这个方法接受的参数:assignment_class。此参数旨在成为实际的类,而不是该类的实例。记住,类也是对象,可以像其他类一样传递。考虑到前面定义的IntroToPython类,我们可以在不实例化的情况下注册它,如下所示:

from grader import Grader
from lessons import IntroToPython, Statistics

grader = Grader()
itp_id = grader.register(IntroToPython)

该方法首先检查该类是否为Assignment类的子类。当然,我们实现了一个自定义的__subclasshook__方法,因此这包括了没有显式地将Assignment子类化的类。这个名字可能有点骗人!如果它没有两个必需的方法,则会引发异常。例外情况是我们将在下一章详细介绍的主题;现在,假设它会让程序生气并退出。

然后,我们生成一个随机标识符来表示特定的分配。我们将assignment_class存储在由该 ID 索引的字典中,并返回该 ID,以便调用代码将来可以查找该分配。据推测,另一个对象会将该 ID 放入某种课程教学大纲中,以便学生按顺序完成作业,但我们不会在项目的这一部分这样做。

The uuid function returns a specially formatted string called a universally unique identifier, also known as a globally unique identifier. It essentially represents an extremely large random number that is almost, but not quite, impossible to conflict with another similarly generated identifier. It is a great, quick, and clean way to create an arbitrary ID to keep track of items.

接下来,我们有start_assignment函数,它允许学生在给定作业 ID 的情况下开始作业。它所做的只是构造我们前面定义的AssignmentGrader类的一个实例,并将其放到Grader类上存储的字典中,如下所示:

    def start_assignment(self, student, id):
        self.student_graders[student] = AssignmentGrader(
            student, self.assignment_classes[id]
        )

之后,我们编写了两个代理方法,用于获取课程或检查学生当前正在处理的任何作业的代码:

    def get_lesson(self, student):
        assignment = self.student_graders[student]
        return assignment.lesson()

    def check_assignment(self, student, code):
        assignment = self.student_graders[student]
        return assignment.check(code)

最后,我们创建了一个方法,总结学生当前的作业进度。它查找作业对象并创建一个带格式的字符串,其中包含关于该学生的所有信息:

    def assignment_summary(self, student):
        grader = self.student_graders[student]
        return f"""
        {student}'s attempts at {grader.assignment.__class__.__name__}:

        attempts: {grader.attempts}
        correct: {grader.correct_attempts}

        passed: {grader.correct_attempts > 0}
        """

就这样。您会注意到,本案例研究没有使用大量的继承,考虑到本章的主题,这似乎有点奇怪,但是 duck 类型非常普遍。Python 程序设计为继承是很常见的,继承在迭代时被简化为更通用的结构。作为另一个例子,我最初将AssignmentGrader定义为继承关系,但中途意识到,出于前面概述的原因,使用组合会更好。

下面是一段测试代码,显示了连接在一起的所有这些对象:

grader = Grader()
itp_id = grader.register(IntroToPython)
stat_id = grader.register(Statistics)

grader.start_assignment("Tammy", itp_id)
print("Tammy's Lesson:", grader.get_lesson("Tammy"))
print(
    "Tammy's check:",
    grader.check_assignment("Tammy", "a = 1 ; b = 'hello'"),
)
print(
    "Tammy's other check:",
    grader.check_assignment("Tammy", "a = 1\nb = 'hello'"),
)

print(grader.assignment_summary("Tammy"))

grader.start_assignment("Tammy", stat_id)
print("Tammy's Lesson:", grader.get_lesson("Tammy"))
print("Tammy's check:", grader.check_assignment("Tammy", "avg=5.25"))
print(
    "Tammy's other check:",
    grader.check_assignment(
        "Tammy", "avg = statistics.mean([1, 5, 18, -3])"
    ),
)

print(grader.assignment_summary("Tammy"))

练习

环顾一下工作区中的一些物理对象,看看是否可以在继承层次结构中描述它们。几个世纪以来,人类一直在将世界划分成这样的分类法,所以这应该不难。对象类之间是否存在任何不明显的继承关系?如果要在计算机应用程序中对这些对象建模,它们将共享哪些属性和方法?哪些必须被多态性覆盖?它们之间有什么性质完全不同?

现在编写一些代码。不,不适用于物理层次结构;那太无聊了。物理项的属性比方法多。想想一个宠物编程项目,你在过去的一年里一直想解决它,但却从来没有考虑过。无论你想解决什么问题,试着考虑一些基本的继承关系,然后实现它们。确保您还注意到实际上不需要使用继承的关系类型。是否有任何地方需要使用多重继承?你确定吗?你能看到任何你想使用混音器的地方吗?试着拼凑出一个快速的原型。它不必是有用的,甚至不必部分工作。您已经了解了如何使用python -i测试代码;只需编写一些代码并在交互式解释器中进行测试。如果有效的话,再写一些。如果没有,请修复它!

现在,看看案例研究中的学生评分系统。它缺少了很多东西,而不仅仅是像样的课程内容!学生如何进入系统?是否有课程规定他们学习课程的顺序?如果将AssignmentGrader更改为在Assignment对象上使用继承而不是组合,会发生什么?

最后,试着为 mixin 想出一些好的用例,然后用它们进行实验,直到你意识到可能有更好的使用合成的设计!

总结

我们从简单继承(面向对象程序员工具箱中最有用的工具之一)一直到多重继承(最复杂的工具之一)。继承可用于使用继承向现有类和内置项添加功能。将类似代码抽象到父类中有助于提高可维护性。可以使用super调用父类上的方法,并且必须安全地格式化参数列表,以便在使用多重继承时这些调用能够工作。抽象基类允许您记录类必须具备哪些方法和属性才能实现特定接口,甚至允许您更改子类的定义。

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