Skip to content

Latest commit

 

History

History
728 lines (526 loc) · 42.2 KB

File metadata and controls

728 lines (526 loc) · 42.2 KB

二十三、Python 设计模式 II

在本章中,我们将介绍更多的设计模式。我们将再次介绍规范示例以及 Python 中的任何常见替代实现。我们将讨论以下内容:

  • 适配器模式
  • 立面图案
  • 惰性初始化和 flyweight 模式
  • 命令模式
  • 抽象工厂模式
  • 构图模式

适配器模式

与我们在前一章中回顾的大多数模式不同,适配器模式设计用于与现有代码交互。我们不会设计一组实现适配器模式的全新对象。适配器用于允许两个先前存在的对象一起工作,即使它们的接口不兼容。与允许您将 Micro USB 充电电缆插入 USB-C 手机的显示适配器一样,适配器对象位于两个不同接口之间,在它们之间进行动态转换。适配器对象的唯一目的是执行此转换。调整可能需要执行多种任务,例如将参数转换为不同的格式、重新排列参数顺序、调用不同名称的方法或提供默认参数。

在结构上,适配器模式类似于简化的装饰器模式。装饰器通常提供它们所替换的相同接口,而适配器在两个不同接口之间映射。这在下图中以 UML 形式描述:

这里,Interface1希望调用一个名为make_action(一些,参数)的方法。我们已经有了一个完美的Interface2类,它可以完成我们想要的一切(为了避免重复,我们不想重写它!),但它提供了一个名为的方法来替代它。适配器类实现make_action接口,并将参数映射到现有接口。

这里的优点是,从一个接口映射到另一个接口的代码都在一个地方。另一种选择真的很难看;每当需要访问此代码时,我们都必须在多个位置执行翻译。

例如,假设我们有以下预先存在的类,它以YYYY-MM-DD格式获取字符串日期并计算一个人在该日期的年龄:

class AgeCalculator:
    def __init__(self, birthday):
        self.year, self.month, self.day = (
            int(x) for x in birthday.split("-")
        )

    def calculate_age(self, date):
        year, month, day = (int(x) for x in date.split("-"))
        age = year - self.year
        if (month, day) < (self.month, self.day):
            age -= 1
        return age

这是一个非常简单的类,它完成了它应该做的事情。但是我们不得不怀疑程序员是怎么想的,他们使用了一个特殊格式的字符串,而不是使用 Python 极其有用的内置datetime库。作为尽责的程序员,只要有可能就重用代码,我们编写的大多数程序都会与datetime对象交互,而不是字符串。

我们有几个选项来解决这个问题。我们可以重写类来接受datetime对象,这可能更准确。但是,如果这个类是由第三方提供的,而我们不知道如何或者不能改变它的内部结构,那么我们需要一个替代方案。我们可以按原样使用该类,并且每当我们想要计算datetime.date对象的年龄时,我们可以调用datetime.date.strftime('%Y-%m-%d')将其转换为正确的格式。但这种转换在很多地方都会发生,更糟糕的是,如果我们将%m错误地输入为%M,它将给出当前分钟,而不是输入的月份。想象一下,如果你在十几个不同的地方写下了这些,当你意识到自己的错误时,却不得不回去修改。它不是可维护的代码,而且它打破了枯燥的原则。

相反,我们可以编写一个适配器,允许将正常日期插入正常的AgeCalculator类,如下代码所示:

import datetime 

class DateAgeAdapter:
    def _str_date(self, date):
        return date.strftime("%Y-%m-%d")

    def __init__(self, birthday):
        birthday = self._str_date(birthday)
        self.calculator = AgeCalculator(birthday)

    def get_age(self, date):
        date = self._str_date(date)
        return self.calculator.calculate_age(date)

此适配器将datetime.datedatetime.time(它们与strftime具有相同的接口)转换为我们原来的AgeCalculator可以使用的字符串。现在,我们可以将原始代码与新接口一起使用。我将方法签名更改为get_age,以证明调用接口可能也在寻找不同的方法名称,而不仅仅是不同类型的参数。

创建一个类作为适配器是实现此模式的常用方法,但是,与往常一样,在 Python 中还有其他方法。继承和多重继承可用于向类添加功能。例如,我们可以在date类上添加一个适配器,使其与原始AgeCalculator类一起工作,如下所示:

import datetime 
class AgeableDate(datetime.date): 
    def split(self, char): 
        return self.year, self.month, self.day 

正是这样的代码让人怀疑 Python 是否应该合法。我们在子类中添加了一个split方法,该方法接受一个参数(我们忽略),并返回一个年、月和日的元组。这与原始的AgeCalculator类完美地结合在一起,因为代码对一个特殊格式的字符串调用strip,在这种情况下,strip返回一个年、月和日的元组。AgeCalculator代码只关心strip是否存在并返回可接受值;它不在乎我们是否真的在一个字符串中传递。以下代码确实有效:

>>> bd = AgeableDate(1975, 6, 14)
>>> today = AgeableDate.today()
>>> today
AgeableDate(2015, 8, 4)
>>> a = AgeCalculator(bd)
>>> a.calculate_age(today)
40  

这是可行的,但这是一个愚蠢的想法。在这个特定的实例中,这样的适配器将很难维护。我们很快就会忘记为什么需要向date类添加strip方法。方法名称不明确。这可能是适配器的本质,但显式创建适配器而不是使用继承通常可以明确其目的。

我们有时也可以使用 monkey 补丁向现有类添加方法,而不是继承。它不适用于datetime对象,因为它不允许在运行时添加属性。但是,在普通类中,我们可以添加一个新方法,提供调用代码所需的自适应接口。或者,我们可以扩展或修补AgeCalculator本身,以更符合我们需求的方式取代calculate_age方法。

最后,通常可以使用函数作为适配器;这显然不符合适配器模式的实际设计,但是如果我们回想一下,函数本质上是使用__call__方法的对象,那么它就变成了一种明显的适配器自适应。

立面图案

facade 模式旨在为复杂的组件系统提供简单的接口。对于复杂的任务,我们可能需要直接与这些对象进行交互,但是对于不需要这些复杂交互的系统,通常有一种典型的用法。facade 模式允许我们定义一个新的对象来封装系统的这种典型用法。任何时候我们想要访问公共功能,我们都可以使用单一对象的简化界面。如果项目的另一部分需要访问更复杂的功能,它仍然能够直接与系统交互。facade 模式的 UML 图实际上依赖于子系统,但在某种程度上,它看起来是这样的:

外观在许多方面都像适配器。主要区别在于 facade 试图从复杂接口中抽象出一个更简单的接口,而适配器只尝试将一个现有接口映射到另一个接口。

让我们为电子邮件应用程序编写一个简单的外观。我们在第 20 章Python 面向对象快捷方式中看到,用 Python 发送电子邮件的底层库非常复杂。用于接收消息的两个库更糟糕。

如果有一个简单的类,允许我们发送一封电子邮件,并通过 IMAP 或 POP3 连接列出收件箱中当前的电子邮件,那就太好了。为了简短起见,我们将继续使用 IMAP 和 SMTP:这两个完全不同的子系统碰巧处理电子邮件。我们的 facade 只执行两项任务:向特定地址发送电子邮件,以及在 IMAP 连接上检查收件箱。它对连接进行了一些常见的假设,例如 SMTP 和 IMAP 的主机位于同一地址,两者的用户名和密码相同,并且它们使用标准端口。这涵盖了许多电子邮件服务器的情况,但是如果程序员需要更大的灵活性,他们总是可以绕过 facade 直接访问这两个子系统。

使用电子邮件服务器的主机名、用户名和登录密码初始化该类:

import smtplib 
import imaplib 

class EmailFacade: 
    def __init__(self, host, username, password): 
        self.host = host 
        self.username = username 
        self.password = password 

send_email方法格式化电子邮件地址和消息,并使用smtplib发送。这不是一项复杂的任务,但需要相当多的修改才能将传递到 facade 的自然输入参数转换为正确的格式,以使smtplib能够发送消息,如下所示:

    def send_email(self, to_email, subject, message):
        if not "@" in self.username:
            from_email = "{0}@{1}".format(self.username, self.host)
        else:
            from_email = self.username
        message = (
            "From: {0}\r\n" "To: {1}\r\n" "Subject: {2}\r\n\r\n{3}"
        ).format(from_email, to_email, subject, message)

        smtp = smtplib.SMTP(self.host)
        smtp.login(self.username, self.password)
        smtp.sendmail(from_email, [to_email], message)

方法开头的if语句捕获的是username是否是邮箱地址中的整个还是仅是@符号左侧的部分;不同的主机对登录详细信息的处理方式不同。

最后,获取收件箱中当前邮件的代码非常混乱。IMAP 协议是痛苦的过度设计,imaplib标准库只是协议上的一个薄层。但我们要简化它,如下所示:

    def get_inbox(self):
        mailbox = imaplib.IMAP4(self.host)
        mailbox.login(
            bytes(self.username, "utf8"), bytes(self.password, "utf8")
        )
        mailbox.select()
        x, data = mailbox.search(None, "ALL")
        messages = []
        for num in data[0].split():
            x, message = mailbox.fetch(num, "(RFC822)")
            messages.append(message[0][1])
        return messages

现在,如果我们把所有这些加在一起,我们就有了一个简单的 facade 类,它可以以相当简单的方式发送和接收消息;比直接与这些复杂的库交互要简单得多。

尽管在 Python 社区中很少提到 facade 模式,但它是 Python 生态系统的一个组成部分。由于 Python 强调语言可读性,因此该语言及其库都倾向于为复杂任务提供易于理解的接口。例如,for循环、list理解和生成器都是更复杂迭代器协议的外观。defaultdict实现是一个门面,当字典中不存在键时,它会抽象掉恼人的角落案例。第三方请求库是 HTTP 请求可读性较差的库的强大门面,后者本身就是管理基于文本的 HTTP 协议的门面。

飞锤模式

flyweight 模式是一种内存优化模式。新手 Python 程序员倾向于忽略内存优化,假设内置的垃圾收集器会处理这些问题。这通常是完全可以接受的,但是当开发具有许多相关对象的大型应用程序时,关注内存问题可以获得巨大的回报。

flyweight 图案可确保共享状态的对象可以将相同的内存用于该共享状态。它通常只有在程序显示内存问题后才能实现。在某些情况下,从一开始就设计一个最佳配置可能是有意义的,但请记住,过早优化是创建过于复杂而无法维护的程序的最有效方法。

让我们看一下 flyweight 模式的以下 UML 图:

每个飞锤都没有特定的状态。任何时候需要对SpecificState执行操作时,需要通过调用代码将该状态传递给飞锤。传统上,返回飞锤的工厂是一个单独的对象;其目的是为标识该飞锤的给定关键点返回飞锤。它的工作原理与我们在第 22 章Python 设计模式 I中讨论的单例模式类似;如果飞锤存在,我们将其返回;否则,我们将创建一个新的。在许多语言中,工厂不是作为单独的对象实现的,而是作为Flyweight类本身的静态方法实现的。

想象一下汽车销售的库存系统。每辆车都有特定的序列号和特定的颜色。但是关于那辆车的大多数细节对于特定型号的所有车都是一样的。例如,本田 Fit DX 车型是一款没有什么特色的汽车。LX 型号具有空调、倾斜、巡航和电动车窗和锁。这款运动型汽车配有精美的车轮、USB 充电器和扰流板。如果没有 flyweight 模式,每个汽车对象都必须存储一个长长的列表,其中列出了它拥有和没有的功能。考虑到本田一年销售的汽车数量,这将导致大量内存浪费。

使用 flyweight 模式,我们可以为与模型关联的特征列表创建共享对象,然后简单地为单个车辆引用该模型以及序列号和颜色。在 Python 中,flyweight 工厂通常使用时髦的__new__构造函数实现,类似于我们对 singleton 模式所做的。

与 singleton 模式不同,它只需要返回类的一个实例,我们需要能够根据键返回不同的实例。我们可以将项目存储在字典中,并根据键查找它们。然而,这种解决方案是有问题的,因为只要该项在字典中,它就会留在内存中。如果我们卖出了 LX 型号的 Fit,Fit flyweight 将不再是必要的,但它仍然会出现在字典中。我们可以在卖车的时候把它清理干净,但这不是垃圾收集器的作用吗?

我们可以利用 Python 的weakref模块来解决这个问题。这个模块提供了一个WeakValueDictionary对象,它基本上允许我们在字典中存储条目,而无需垃圾收集器关心它们。如果某个值位于弱引用字典中,并且应用程序中任何位置都没有存储对该对象的其他引用(即,我们的 LX 型号已经售完),垃圾收集器最终将为我们清理。

让我们先为我们的汽车飞锤建造工厂,如下所示:

import weakref

class CarModel:
    _models = weakref.WeakValueDictionary()

    def __new__(cls, model_name, *args, **kwargs):
        model = cls._models.get(model_name)
        if not model:
            model = super().__new__(cls)
            cls._models[model_name] = model

        return model

基本上,每当我们用一个给定的名称构造一个新的 flyweight 时,我们首先在弱引用字典中查找该名称;如果它存在,我们返回该模型;如果没有,我们将创建一个新的。无论哪种方式,我们都知道每次都会调用 flyweight 上的__init__方法,无论它是新对象还是现有对象。因此,我们的__init__方法可以类似于以下代码片段:

    def __init__(
        self,
        model_name,
        air=False,
        tilt=False,
        cruise_control=False,
        power_locks=False,
        alloy_wheels=False,
        usb_charger=False,
    ):
        if not hasattr(self, "initted"):
            self.model_name = model_name
            self.air = air
            self.tilt = tilt
            self.cruise_control = cruise_control
            self.power_locks = power_locks
            self.alloy_wheels = alloy_wheels
            self.usb_charger = usb_charger
            self.initted = True

if语句确保我们只在第一次调用__init__时初始化对象。这意味着我们可以稍后仅使用模型名调用工厂,并获得相同的 flyweight 对象。但是,由于如果不存在对 flyweight 的外部引用,flyweight 将被垃圾收集,因此我们必须小心不要意外地创建一个具有 null 值的新 flyweight。

让我们在 flyweight 中添加一个方法,该方法假设查找特定车型的序列号,并确定该车型是否涉及任何事故。这种方法需要访问汽车的序列号,序列号因汽车而异;它不能与 flyweight 一起存储。因此,此数据必须通过调用代码传递到方法中,如下所示:

    def check_serial(self, serial_number):
        print(
            "Sorry, we are unable to check "
            "the serial number {0} on the {1} "
            "at this time".format(serial_number, self.model_name)
        )

我们可以定义一个类来存储附加信息以及对 flyweight 的引用,如下所示:

class Car: 
    def __init__(self, model, color, serial): 
        self.model = model 
        self.color = color 
        self.serial = serial 

    def check_serial(self): 
        return self.model.check_serial(self.serial) 

我们还可以跟踪可用车型以及停车场上的单个车辆,如下所示:

>>> dx = CarModel("FIT DX")
>>> lx = CarModel("FIT LX", air=True, cruise_control=True,
... power_locks=True, tilt=True)
>>> car1 = Car(dx, "blue", "12345")
>>> car2 = Car(dx, "black", "12346")
>>> car3 = Car(lx, "red", "12347")  

现在,让我们在下面的代码片段中演示弱引用:

>>> id(lx)
3071620300
>>> del lx
>>> del car3
>>> import gc
>>> gc.collect()
0
>>> lx = CarModel("FIT LX", air=True, cruise_control=True,
... power_locks=True, tilt=True)
>>> id(lx)
3071576140
>>> lx = CarModel("FIT LX")
>>> id(lx)
3071576140
>>> lx.air
True  

id函数告诉我们对象的唯一标识符。当我们第二次调用它时,在删除对 LX 模型的所有引用并强制垃圾收集之后,我们看到 ID 已经更改。删除了CarModel __new__工厂字典中的值,并创建了一个新字典。但是,如果我们随后尝试构造第二个CarModel实例,它将返回相同的对象(ID 相同),并且,即使我们在第二次调用中没有提供任何参数,air变量仍然设置为True。这意味着该对象没有像我们设计的那样在第二次初始化。

显然,使用 flyweight 模式比仅在单个汽车类别上存储功能更复杂。我们应该选择什么时候使用它?flyweight 图案设计用于节省内存;如果我们有数十万个相似的对象,那么将相似的属性组合到一个 flyweight 中会对内存消耗产生巨大影响。

对 CPU、内存或磁盘空间进行优化的编程解决方案通常会产生比未优化的解决方案更复杂的代码。因此,在决定代码可维护性和优化之间的权衡是很重要的。在选择优化时,尝试使用 flyweight 等模式,以确保优化所引入的复杂性仅限于代码的单个部分(有详细文档记录)。

If you have a lot of Python objects in one program, one of the quickest ways to save memory is through the use of __slots__. The __slots__ magic method is beyond the scope of this book, but there is plenty of information available if you check online. If you are still low on memory, flyweight may be a reasonable solution.

命令模式

命令模式在必须执行的操作和调用这些操作的对象之间添加了一个抽象级别,通常在以后进行。在命令模式中,客户机代码创建一个Command对象,可以在以后执行。此对象了解在其上执行命令时管理其自身内部状态的接收方对象。Command对象实现一个特定的接口(通常,它有一个executedo_action方法,并且还跟踪执行该操作所需的任何参数。最后,一个或多个Invoker对象在正确的时间执行该命令。

以下是 UML 图:

命令模式的一个常见示例是图形窗口上的操作。通常,可以通过菜单栏上的菜单项、键盘快捷键、工具栏图标或关联菜单调用操作。这些都是Invoker对象的示例。实际发生的动作,如ExitSaveCopy都是CommandInterface的实现。接收退出的 GUI 窗口、接收保存的文档和接收复制命令的ClipboardManager都是可能的Receivers示例。

让我们实现一个简单的命令模式,为SaveExit操作提供命令。我们将从一些普通的接收器类开始,它们本身具有以下代码:

import sys 

class Window: 
    def exit(self): 
        sys.exit(0) 

class Document: 
    def __init__(self, filename): 
        self.filename = filename 
        self.contents = "This file cannot be modified" 

    def save(self): 
        with open(self.filename, 'w') as file: 
            file.write(self.contents) 

这些模拟类为对象建模,这些对象在工作环境中可能会做更多的工作。窗口需要处理鼠标移动和键盘事件,文档需要处理字符插入、删除和选择。但在我们的例子中,这两个类将做我们需要的事情。

现在让我们定义一些调用器类。这些将为可能发生的工具栏、菜单和键盘事件建模;同样,它们实际上并没有连接到任何东西,但我们可以在下面的代码片段中看到它们是如何与命令、接收器和客户端代码解耦的:

class ToolbarButton:
    def __init__(self, name, iconname):
        self.name = name
        self.iconname = iconname

    def click(self):
        self.command.execute()

class MenuItem:
    def __init__(self, menu_name, menuitem_name):
        self.menu = menu_name
        self.item = menuitem_name

    def click(self):
        self.command.execute()

class KeyboardShortcut:
    def __init__(self, key, modifier):
        self.key = key
        self.modifier = modifier

    def keypress(self):
        self.command.execute()

注意不同的操作方法如何在各自的命令上调用execute方法?此代码不显示在每个对象上设置的command属性。它们可以被传递到__init__函数中,但因为它们可能会被更改(例如,使用可自定义的键绑定编辑器),因此在之后设置对象的属性更有意义。

现在,让我们用以下代码连接命令本身:

class SaveCommand:
    def __init__(self, document):
        self.document = document

    def execute(self):
        self.document.save()

class ExitCommand:
    def __init__(self, window):
        self.window = window

    def execute(self):
        self.window.exit()

这些命令很简单;它们演示了基本模式,但需要注意的是,如果需要,我们可以使用命令存储状态和其他信息。例如,如果我们有一个插入字符的命令,我们可以维护当前插入的字符的状态。

现在我们所要做的就是连接一些客户机和测试代码,使命令工作。对于基本测试,我们可以在脚本末尾包含以下代码:

window = Window() 
document = Document("a_document.txt") 
save = SaveCommand(document) 
exit = ExitCommand(window) 

save_button = ToolbarButton('save', 'save.png') 
save_button.command = save 
save_keystroke = KeyboardShortcut("s", "ctrl") 
save_keystroke.command = save 
exit_menu = MenuItem("File", "Exit") 
exit_menu.command = exit 

首先,我们创建两个接收器和两个命令。然后,我们创建几个可用的调用程序,并在每个调用程序上设置正确的命令。为了测试,我们可以使用python3``-i``filename.py并运行exit_menu.click()等代码,这将结束程序,或者save_keystroke.keystroke()将保存假文件。

不幸的是,前面的例子并没有让人觉得很像蟒蛇。它们有很多“样板代码”(代码没有完成任何任务,但只为模式提供结构),而且Command类彼此都非常相似。也许我们可以创建一个将函数作为回调函数的通用命令对象?

事实上,为什么要麻烦呢?我们能为每个命令使用一个函数或方法对象吗?我们可以编写函数并直接将其用作命令,而不是使用execute()方法的对象。以下是 Python 中命令模式的常见范例:

import sys

class Window:
    def exit(self):
        sys.exit(0)

class MenuItem:
    def click(self):
        self.command()

window = Window()
menu_item = MenuItem()
menu_item.command = window.exit

现在它看起来更像 Python 了。乍一看,看起来我们已经完全删除了命令模式,并且我们已经将menu_itemWindow类紧密连接起来。但如果我们仔细观察,就会发现根本没有紧耦合。任何可调用项都可以设置为MenuItem上的命令,就像前面一样。Window.exit方法可以附加到任何调用程序。命令模式的大部分灵活性都得到了保持。为了可读性,我们牺牲了完全的解耦,但在我看来,这段代码以及许多 Python 程序员的看法都比完全抽象的版本更易于维护。

当然,因为我们可以向任何对象添加__call__方法,所以我们不局限于函数。当被调用的方法不必维护状态时,前面的示例是一个有用的快捷方式,但在更高级的使用中,我们还可以使用以下代码:

class Document:
    def __init__(self, filename):
        self.filename = filename
        self.contents = "This file cannot be modified"

    def save(self):
        with open(self.filename, "w") as file:
            file.write(self.contents)

class KeyboardShortcut:
    def keypress(self):
        self.command()

class SaveCommand:
    def __init__(self, document):
        self.document = document

    def __call__(self):
        self.document.save()

document = Document("a_file.txt")
shortcut = KeyboardShortcut()
save_command = SaveCommand(document)
shortcut.command = save_command

在这里,我们有一些看起来像第一个命令模式的东西,但有一点更为惯用。如您所见,使用 execute 方法使调用程序调用可调用对象而不是command对象并没有以任何方式限制我们。事实上,这给了我们更多的灵活性。我们可以直接链接到函数,但当情况需要时,我们可以构建一个完整的可调用command对象。

命令模式通常被扩展以支持可撤消的命令。例如,一个文本程序可以用一个单独的命令包装每个插入,不仅使用execute方法,而且使用undo方法删除该插入。图形程序可以将每个绘图动作(矩形、直线、徒手画像素等)包装在一个命令中,该命令具有将像素重置为其原始状态的undo方法。在这种情况下,命令模式的解耦显然更有用,因为每个操作都必须保持足够的状态,以便在以后撤消该操作。

抽象工厂模式

当一个系统有多个可能的实现依赖于某些配置或平台问题时,通常使用抽象工厂模式。调用代码从抽象工厂请求一个对象,而不知道将返回什么类的对象。返回的底层实现可能取决于多种因素,例如当前区域设置、操作系统或本地配置。

抽象工厂模式的常见示例包括独立于操作系统的工具包、数据库后端和特定于国家/地区的格式化程序或计算器的代码。独立于操作系统的 GUI 工具包可能使用抽象工厂模式,该模式在 Windows 下返回一组 WinForm 小部件,在 Mac 下返回 Cocoa 小部件,在 Gnome 下返回 GTK 小部件,在 KDE 下返回 QT 小部件。Django 提供了一个抽象工厂,它返回一组对象关系类,用于根据当前站点的配置设置与特定数据库后端(MySQL、PostgreSQL、SQLite 和其他)交互。如果应用程序需要部署在多个位置,则每个位置都可以通过只更改一个配置变量来使用不同的数据库后端。不同的国家有不同的系统来计算零售商品的税收、小计和总额;抽象工厂可以返回特定的税务计算对象。

如果没有具体的例子,抽象工厂模式的 UML 类图是很难理解的,所以让我们先来看看并创建一个具体的例子。在我们的示例中,我们将创建一组依赖于特定区域设置的格式化程序,并帮助我们格式化日期和货币。将有一个抽象工厂类,用于选择特定工厂,以及两个示例具体工厂,一个用于法国,一个用于美国。每个抽象工厂将为日期和时间创建格式化程序对象,可以查询这些对象以格式化特定值。下图对此进行了描述:

将该图像与之前更简单的文本进行比较,可以看出,一张图片并不总是胜过千言万语,特别是考虑到我们这里甚至没有允许使用工厂选择代码。

当然,在 Python 中,我们不必实现任何接口类,因此我们可以放弃DateFormatterCurrencyFormatterFormatterFactory。格式化类本身非常简单(如果详细),如下所示:

class FranceDateFormatter:
    def format_date(self, y, m, d):
        y, m, d = (str(x) for x in (y, m, d))
        y = "20" + y if len(y) == 2 else y
        m = "0" + m if len(m) == 1 else m
        d = "0" + d if len(d) == 1 else d
        return "{0}/{1}/{2}".format(d, m, y)

class USADateFormatter:
    def format_date(self, y, m, d):
        y, m, d = (str(x) for x in (y, m, d))
        y = "20" + y if len(y) == 2 else y
        m = "0" + m if len(m) == 1 else m
        d = "0" + d if len(d) == 1 else d
        return "{0}-{1}-{2}".format(m, d, y)

class FranceCurrencyFormatter:
    def format_currency(self, base, cents):
        base, cents = (str(x) for x in (base, cents))
        if len(cents) == 0:
            cents = "00"
        elif len(cents) == 1:
            cents = "0" + cents

        digits = []
        for i, c in enumerate(reversed(base)):
            if i and not i % 3:
                digits.append(" ")
            digits.append(c)
        base = "".join(reversed(digits))
        return "{0}€{1}".format(base, cents)

class USACurrencyFormatter:
    def format_currency(self, base, cents):
        base, cents = (str(x) for x in (base, cents))
        if len(cents) == 0:
            cents = "00"
        elif len(cents) == 1:
            cents = "0" + cents
        digits = []
        for i, c in enumerate(reversed(base)):
            if i and not i % 3:
                digits.append(",")
            digits.append(c)
        base = "".join(reversed(digits))
        return "${0}.{1}".format(base, cents)

这些类使用一些基本的字符串操作来尝试将各种可能的输入(整数、不同长度的字符串等)转换为以下格式:

| | 美国 | 法国 | | 日期 | 年月日 | 年月日 | | 货币 | $14,500.50 | 14 500€50 |

显然,在这段代码中可能会有更多的输入验证,但是对于这个例子,让我们保持简单。

现在我们已经设置了格式化程序,只需要创建格式化程序工厂,如下所示:

class USAFormatterFactory:
    def create_date_formatter(self):
        return USADateFormatter()

    def create_currency_formatter(self):
        return USACurrencyFormatter()

class FranceFormatterFactory:
    def create_date_formatter(self):
        return FranceDateFormatter()

    def create_currency_formatter(self):
        return FranceCurrencyFormatter()

现在,我们设置了选择适当格式化程序的代码。因为这是一种只需要设置一次的东西,所以我们可以将其设置为单例——但单例在 Python 中不是很有用。让我们将当前格式化程序改为模块级变量:

country_code = "US"
factory_map = {"US": USAFormatterFactory, "FR": FranceFormatterFactory}
formatter_factory = factory_map.get(country_code)()

在本例中,我们硬编码当前国家代码;在实践中,它可能会内省区域设置、操作系统或配置文件来选择代码。本例使用字典将国家代码与工厂类关联。然后,我们从字典中获取正确的类并实例化它。

当我们想要增加对更多国家的支持时,很容易看到需要做什么:创建新的格式化程序类和抽象工厂本身。记住Formatter类可能会被重用;例如,加拿大的货币格式与美国相同,但其日期格式比其南部邻国更为合理。

抽象工厂通常返回单例对象,但这不是必需的。在我们的代码中,每次调用时,它都会返回每个格式化程序的一个新实例。没有理由不能将格式化程序存储为实例变量,并为每个工厂返回相同的实例。

回顾这些示例,我们再次看到,在 Python 中似乎有很多工厂的样板代码,这些代码在 Python 中是不必要的。通常,通过为每种工厂类型(例如:美国和法国)使用单独的模块,然后确保在工厂模块中访问正确的模块,可以更容易地满足可能需要抽象工厂的要求。此类模块的包结构可能如下所示:

localize/ 
    __init__.py 
    backends/ 
        __init__.py 
        USA.py 
        France.py 
        ... 

技巧在于localize包中的__init__.py可以包含将所有请求重定向到正确后端的逻辑。有多种方法可以做到这一点。

如果我们知道后端永远不会动态变化(也就是说,没有程序重启),我们可以在__init__.py中放入一些检查当前国家代码的if语句,并使用(通常不可接受的)from``.backends.USA``import``*语法从适当的后端导入所有变量。或者,我们可以导入每个后端,并将一个current_backend变量设置为指向特定模块,如下所示:

from .backends import USA, France 

if country_code == "US": 
    current_backend = USA 

根据我们选择的解决方案,我们的客户机代码必须调用localize.format_datelocalize.current_backend.format_date以获取在当前国家/地区格式化的日期。最终结果比最初的抽象工厂模式更具 python 风格,并且在典型的使用中也同样灵活。

复合模式

复合模式允许从简单组件构建复杂的树状结构。这些称为复合对象的组件的行为有点像容器,也有点像变量,这取决于它们是否有子组件。复合对象是容器对象,其中的内容实际上可能是另一个复合对象。

传统上,复合对象中的每个组件必须是叶节点(不能包含其他对象)或复合节点。关键是复合节点和叶节点可以具有相同的接口。以下 UML 图非常简单:

然而,这个简单的模式允许我们创建复杂的元素排列,所有这些元素都满足组件对象的接口。下图描述了这种复杂安排的具体实例:

复合模式在类似文件/文件夹的树中通常很有用。无论树中的节点是普通文件还是文件夹,它都会受到移动、复制或删除节点等操作的影响。我们可以创建支持这些操作的组件接口,然后使用复合对象表示文件夹,使用叶节点表示普通文件。

当然,在 Python 中,我们可以再次利用 duck 类型隐式地提供接口,因此我们只需要编写两个类。让我们首先在以下代码中定义这些接口:

class Folder: 
    def __init__(self, name): 
        self.name = name 
        self.children = {} 

    def add_child(self, child): 
        pass 

    def move(self, new_path): 
        pass 

    def copy(self, new_path): 
        pass 

    def delete(self): 
        pass 

class File: 
    def __init__(self, name, contents): 
        self.name = name 
        self.contents = contents 

    def move(self, new_path): 
        pass 

    def copy(self, new_path): 
        pass 

    def delete(self): 
        pass 

对于每个文件夹(复合)对象,我们维护一个子对象字典。对于许多复合实现来说,一个列表就足够了,但在这种情况下,字典将有助于按名称查找子项。我们的路径将被指定为由/字符分隔的节点名,类似于 Unix shell 中的路径。

考虑到所涉及的方法,我们可以看到移动或删除节点的行为与此类似,而不管它是文件节点还是文件夹节点。但是,复制必须对文件夹节点进行递归复制,而复制文件节点则是一项简单的操作。

为了利用类似的操作,我们可以将一些常用方法提取到父类中。让我们使用丢弃的Component接口,将其更改为具有以下代码的基类:

class Component:
    def __init__(self, name):
        self.name = name

    def move(self, new_path):
        new_folder = get_path(new_path)
        del self.parent.children[self.name]
        new_folder.children[self.name] = self
        self.parent = new_folder

    def delete(self):
        del self.parent.children[self.name]

class Folder(Component):
    def __init__(self, name):
        super().__init__(name)
        self.children = {}

    def add_child(self, child):
        pass

    def copy(self, new_path):
        pass

class File(Component):
    def __init__(self, name, contents):
        super().__init__(name)
        self.contents = contents

    def copy(self, new_path):
        pass

root = Folder("")

def get_path(path):
    names = path.split("/")[1:]
    node = root
    for name in names:
        node = node.children[name]
    return node

我们已经在Component类上创建了movedelete方法。他们都访问了一个神秘的parent变量,我们还没有设置它。move方法使用模块级get_path函数,该函数从给定路径的预定义根节点中查找节点。所有文件都将添加到此根节点或该节点的子节点。对于move方法,目标应该是一个现有文件夹,否则我们将得到一个错误。正如技术书籍中的许多例子一样,错误处理令人遗憾地缺失,这有助于关注所考虑的原则。

让我们在文件夹的add_child方法中设置神秘的parent变量,如下所示:

    def add_child(self, child):
        child.parent = self
        self.children[child.name] = child

嗯,那很容易。让我们看看我们的复合文件层次结构是否与以下代码段正常工作:

$ python3 -i 1261_09_18_add_child.py

>>> folder1 = Folder('folder1')
>>> folder2 = Folder('folder2')
>>> root.add_child(folder1)
>>> root.add_child(folder2)
>>> folder11 = Folder('folder11')
>>> folder1.add_child(folder11)
>>> file111 = File('file111', 'contents')
>>> folder11.add_child(file111)
>>> file21 = File('file21', 'other contents')
>>> folder2.add_child(file21)
>>> folder2.children
{'file21': <__main__.File object at 0xb7220a4c>}
>>> folder2.move('/folder1/folder11')
>>> folder11.children
{'folder2': <__main__.Folder object at 0xb722080c>, 'file111': <__main__.File object at 
0xb72209ec>}
>>> file21.move('/folder1')
>>> folder1.children
{'file21': <__main__.File object at 0xb7220a4c>, 'folder11': <__main__.Folder object at 
0xb722084c>}  

是的,我们可以创建文件夹,将文件夹添加到其他文件夹,将文件添加到文件夹,并四处移动它们!在文件层次结构中,我们还能要求什么?

嗯,我们可以要求实施复制,但为了保护树木,让我们把它作为一种练习。

复合模式对于各种树状结构非常有用,包括 GUI 小部件层次结构、文件层次结构、树集、图形和 HTMLDOM。如前面演示的示例所示,当按照传统实现实现时,它在 Python 中可能是一个有用的模式。有时,如果只创建了一个浅树,我们就可以获得一个列表列表或字典字典,而不需要实现自定义组件、叶和复合类。其他时候,我们可以只实现一个复合类,并将叶对象和复合对象视为单个类。或者,Python 的 duck 类型可以轻松地将其他对象添加到复合层次结构中,只要它们具有正确的接口。

练习

在开始每个设计模式的练习之前,花点时间对上一节中的FileFolder对象实施copy方法。File方法应该很简单;只需创建一个具有相同名称和内容的新节点,并将其添加到新的父文件夹中。Folder上的copy方法相当复杂,因为您首先必须复制文件夹,然后递归地将其每个子文件夹复制到新位置。您可以不分青红皂白地对子对象调用copy()方法,而不管每个子对象是文件还是文件夹对象。这将告诉我们复合模式有多强大。

现在,就像前一章一样,看看我们讨论过的模式,并考虑一些可能实现它们的理想的地方。您可能希望将适配器模式应用于现有代码,因为它通常在与现有库(而不是新代码)交互时适用。如何使用适配器强制两个接口正确交互?

你能想到一个足够复杂的系统来证明使用 facade 模式的合理性吗?考虑在现实生活中如何使用立面,比如汽车的驾驶员接口,或者工厂中的控制面板。它在软件方面类似,只是 facade 界面的用户是其他程序员,而不是经过培训使用它们的人。您的最新项目中是否有复杂系统可以从 facade 模式中获益?

您可能没有任何巨大的、消耗内存的代码可以从 flyweight 模式中获益,但是您能想到它可能有用的情况吗?任何需要处理大量重叠数据的地方,都需要使用飞锤。它对银行业有用吗?在 web 应用程序中?在什么情况下采用 flyweight 模式才有意义?什么时候是过度杀戮?

命令模式如何?你能想到一些常见的(或者更好的是,不常见的)例子,在这些例子中,动作与调用的分离是有用的吗?看看你每天使用的程序,想象一下它们是如何在内部实现的。他们中的许多人可能出于这样或那样的目的使用命令模式。

抽象工厂模式,或者我们讨论的更具 Python 风格的衍生产品,对于创建一触式可配置系统非常有用。你能想出这样的系统有用的地方吗?

最后,考虑复合模式。在编程中,我们周围有树状结构;其中一些,比如我们的文件层次结构示例,是公然的;其他的则相当微妙。在什么情况下,复合模式会有用?您能想到在您自己的代码中可以使用它的地方吗?如果你稍微调整一下模式会怎么样;例如,为不同类型的对象包含不同类型的叶节点或复合节点?

总结

在本章中,我们详细介绍了几种更多的设计模式,包括它们的规范描述以及用 Python 实现它们的替代方案,Python 通常比传统的面向对象语言更灵活、更通用。适配器模式对于匹配接口很有用,而 facade 模式适合于简化接口。Flyweight 是一种复杂的模式,只有在需要内存优化时才有用。在 Python 中,命令模式通常更适合使用第一类函数作为回调来实现。抽象工厂允许根据配置或系统信息对实现进行运行时分离。复合模式普遍用于树状结构。

在下一章中,我们将讨论测试 Python 程序的重要性,以及如何进行测试,重点是面向对象的原则。