Python 入门指南(五)(2)

简介: Python 入门指南(五)

Python 入门指南(五)(1)https://developer.aliyun.com/article/1507415

案例研究

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

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

一个明显的对象是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参数!我们将在第二十四章中讨论自动化测试,测试面向对象的程序。目前,只需使用解释器检查一些东西就足够了:

>>> 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
    def modify_tags(self, note_id, tags):
        """Find the note with the given id and change its
        tags to the given value."""
        self._find_note(note_id).tags = tags

现在应该可以工作了。让我们看看菜单界面。界面需要呈现菜单并允许用户输入选择。这是我们的第一次尝试:

import sys
from notebook import Notebook
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,那么我们会遇到麻烦。

用户输入不存在的笔记 ID 的问题可以通过更改笔记本上的两个modify方法来解决,检查_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("这个方法将做一些事情")就足以让整体设计就位。这被称为自顶向下设计,在这种设计中,你先解决不同的交互,并描述它们应该如何工作,然后再实际实现它们所做的事情。相反,自底向上设计首先实现细节,然后将它们全部联系在一起。这两种模式在不同的时候都很有用,但对于理解面向对象的原则,自顶向下的工作流更合适。

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

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

总结

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

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

第十七章:当对象相似时

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

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

  • 基本继承
  • 从内置类型继承
  • 多重继承
  • 多态和鸭子类型

基本继承

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

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

class MySubClass(object): 
    pass 

这就是继承!从技术上讲,这个例子与我们在第十六章中的第一个例子没有什么不同,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)上找不到字段,那么它将在类上找到,并且因此将引用相同的单个列表。

对于这个语法要小心,因为如果你使用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 "
            f"'{order}' order to '{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
        Contact.all_contacts.append(self)

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

>>> c1 = Contact("John A", "johna@example.net")
>>> c2 = Contact("John B", "johnb@example.net")
>>> c3 = Contact("Jenna C", "jennac@example.net")
>>> [c.name for c in Contact.all_contacts.search('John')]
['John A', 'John B']  

你是否想知道我们如何将内置语法[]改变成我们可以继承的东西?使用[]创建一个空列表实际上是使用list()创建一个空列表的快捷方式;这两种语法的行为是相同的:

>>> [] == list()
True  

实际上,[]语法实际上是所谓的语法糖,在幕后调用list()构造函数。list数据类型是一个我们可以扩展的类。事实上,列表本身扩展了object类:

>>> isinstance([], object)
True  

作为第二个例子,我们可以扩展dict类,它与列表类似,是在使用{}语法缩写时构造的类:

class LongNameDict(dict): 
    def longest_key(self): 
        longest = None 
        for key in self: 
            if not longest or len(key) > len(longest): 
                longest = key 
        return longest 

这在交互式解释器中很容易测试:

>>> longkeys = LongNameDict()
>>> longkeys['hello'] = 1
>>> longkeys['longest yet'] = 5
>>> longkeys['hello2'] = 'world'
>>> longkeys.longest_key()
'longest yet'  

大多数内置类型都可以类似地扩展。常见的扩展内置类包括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()调用可以在任何方法内部进行。因此,所有方法都可以通过覆盖和调用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类进行 monkey patching(我们将在第二十章中简要介绍 monkey patching,Python 面向对象的快捷方式)。这是通过定义一个接受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__方法解析顺序)属性来动态调整。这超出了本书的范围。如果您认为您需要了解它,我们建议阅读Expert Python ProgrammingTarek ZiadéPackt 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方法被调用了两次。如果该方法正在执行实际工作,比如两次存入银行账户,这可能会导致一些隐匿的错误。

多重继承要记住的一件事是,我们只想调用类层次结构中的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调用之后执行的,打印输出的顺序是每个方法实际执行的顺序。让我们从后往前看输出,看看是谁在调用什么。

首先,Subclasscall_me调用了super().call_me(),这恰好是在引用

LeftSubclass.call_me()。然后LeftSubclass.call_me()方法调用super().call_me(),但在这种情况下,super()指的是RightSubclass.call_me()

特别注意super调用调用LeftSubclass的超类(即BaseClass)上的方法。相反,它调用RightSubclass,即使它不是LeftSubclass的直接父类!这是next方法,而不是父方法。然后RightSubclass调用BaseClass,并且super调用确保了类层次结构中的每个方法都被执行一次。

不同的参数集

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

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

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

具体来说,如果对super的第一个调用将nameemail参数传递给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调用的下一个类。

如果您不熟悉**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 中多重继承的许多注意事项。当我们需要考虑所有可能的情况时,我们必须为它们做计划,我们的代码会变得混乱。基本的多重继承可能很方便,但在许多情况下,我们可能希望选择一种更透明的方式来组合两个不同的类,通常使用组合或我们将在第二十二章和第二十三章中介绍的设计模式之一。

我已经浪费了我生命中的整整一天,搜索复杂的多重继承层次结构,试图弄清楚我需要传递到其中一个深度嵌套的子类的参数。代码的作者倾向于不记录他的类,并经常传递 kwargs——以防万一将来可能会需要。这是一个特别糟糕的例子,使用了不需要的多重继承。多重继承是一个新编码者喜欢炫耀的大而复杂的术语,但我建议避免使用它,即使你认为它是一个好选择。当他们以后不得不阅读代码时,你未来的自己和其他编码者会很高兴他们理解你的代码。

多态性

我们在《面向对象设计》的第十五章中介绍了多态性。这是一个华丽的名字,描述了一个简单的概念:不同的行为发生取决于使用哪个子类,而不必明确知道子类实际上是什么。举个例子,想象一个播放音频文件的程序。媒体播放器可能需要加载一个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方法,那么我们就不必覆盖write方法。简而言之,鸭子类型不需要提供可用对象的整个接口;它只需要满足实际被访问的接口。

Python 入门指南(五)(3)https://developer.aliyun.com/article/1507422

相关文章
|
3天前
|
Linux 开发工具 Python
初学者从无到有的Python语言如何入门,这份Python学习路线赶紧带走_python 从无到(1)
初学者从无到有的Python语言如何入门,这份Python学习路线赶紧带走_python 从无到(1)
初学者从无到有的Python语言如何入门,这份Python学习路线赶紧带走_python 从无到(1)
|
3天前
|
数据采集 算法 Python
2024年Python最全python基础入门:高阶函数,小米面试编程题
2024年Python最全python基础入门:高阶函数,小米面试编程题
|
3天前
|
存储 数据采集 数据挖掘
真正零基础Python入门:手把手教你从变量和赋值语句学起
真正零基础Python入门:手把手教你从变量和赋值语句学起
|
4天前
|
数据挖掘 数据处理 Python
【Python DataFrame 专栏】Python DataFrame 入门指南:从零开始构建数据表格
【5月更文挑战第19天】本文介绍了Python数据分析中的核心概念——DataFrame,通过导入`pandas`库创建并操作DataFrame。示例展示了如何构建数据字典并转换为DataFrame,以及进行数据选择、添加修改列、计算统计量、筛选和排序等操作。DataFrame适用于处理各种规模的表格数据,是数据分析的得力工具。掌握其基础和应用是数据分析之旅的重要起点。
【Python DataFrame 专栏】Python DataFrame 入门指南:从零开始构建数据表格
|
5天前
|
网络协议 网络架构 Python
Python 网络编程基础:套接字(Sockets)入门与实践
【5月更文挑战第18天】Python网络编程中的套接字是程序间通信的基础,分为TCP和UDP。TCP套接字涉及创建服务器套接字、绑定地址和端口、监听、接受连接及数据交换。UDP套接字则无连接状态。示例展示了TCP服务器和客户端如何使用套接字通信。注意选择唯一地址和端口,处理异常以确保健壮性。学习套接字可为构建网络应用打下基础。
20 7
|
6天前
|
Python
10个python入门小游戏,零基础打通关,就能掌握编程基础_python编写的入门简单小游戏
10个python入门小游戏,零基础打通关,就能掌握编程基础_python编写的入门简单小游戏
|
8天前
|
Python 索引 C语言
Python3从零基础到入门(2)—— 运算符-3
Python3从零基础到入门(2)—— 运算符
|
8天前
|
Python
Python3从零基础到入门(2)—— 运算符-2
Python3从零基础到入门(2)—— 运算符
Python3从零基础到入门(2)—— 运算符-2
|
8天前
|
Python C语言 存储
Python3从零基础到入门(2)—— 运算符-1
Python3从零基础到入门(2)—— 运算符
Python3从零基础到入门(2)—— 运算符-1
|
8天前
|
存储 C语言 Python