精通 Python 设计模式——结构型设计模式

86 阅读38分钟

在上一章中,我们介绍了创建型模式以及帮助我们处理对象创建过程的面向对象编程模式。接下来要介绍的类别是结构型设计模式。结构型设计模式提出了一种通过组合对象来提供新功能的方式。

本章我们将讨论以下主题:

  • 适配器(Adapter)模式
  • 装饰器(Decorator)模式
  • 桥接(Bridge)模式
  • 外观(Facade)模式
  • 享元(Flyweight)模式
  • 代理(Proxy)模式

读完本章后,你将具备使用结构型设计模式高效且优雅地组织代码的能力。

技术要求

参见第 1 章给出的要求。

适配器(Adapter)模式

适配器模式是一种结构型设计模式,用于让两个不兼容的接口能够协同工作。这到底意味着什么?如果我们有一个老组件,想把它接入新系统;或者有一个新组件,想把它接到老系统上——二者通常不能在不改代码的情况下直接通信。但改动代码并不总是可行:要么我们拿不到源码,要么改动成本不划算。在这种情况下,可以编写一层额外的“适配”层,对双方接口进行必要的转换,使其能够通信。这层就叫适配器(adapter)

一般来说,如果你需要使用的接口期望调用 function_a(),而你手头只有 function_b(),就可以用适配器把 function_b() **转换(适配)**为 function_a()

真实世界示例

从多数欧洲国家去英国或美国旅行(或反过来)时,你需要一个电源插头适配器给笔记本充电。把某些设备连到电脑上时,也需要USB 转接器

在软件领域,Zope 工具包(ZTK)中的 zope.interface 包(pypi.org/project/zop…)提供了定义接口与执行接口适配的工具。这些工具被用于多个Python Web 框架项目的核心(包括 Pyramid 和 Plone)。

注意
在 Python 引入内置机制(先是抽象基类 ABC,后来是 Protocol 协议)之前,由 Zope 应用服务器与 ZTK 背后的团队(zope.dev/)提出的 zope.interface 是在 Python 中处理接口的一种解决方案。

适配器模式的使用场景

通常,不兼容的两个接口里会有一个是外部的旧的/遗留的。如果接口是外部的,意味着我们无法访问其源码;如果是旧的,往往不实用也不经济去重构它。

在已有实现之后再通过适配器让它们协同工作是一种不错的做法,因为它不需要访问外部接口的源代码;在需要复用遗留代码时,它也常常是务实的解决方案。需要注意的是,这可能引入一些难以调试的副作用,因此要谨慎使用。

实现适配器模式 —— 适配遗留类

设想我们有一个遗留支付系统和一个新的支付网关。适配器模式可以让它们在不修改既有代码的前提下协同工作,如下所示。

遗留支付系统用一个类实现,核心逻辑在 make_payment() 方法中:

class OldPaymentSystem:
    def __init__(self, currency):
        self.currency = currency
    def make_payment(self, amount):
        print(
            f"[OLD] Pay {amount} {self.currency}"
        )

新的支付系统实现如下,提供 execute_payment() 方法:

class NewPaymentGateway:
    def __init__(self, currency):
        self.currency = currency
    def execute_payment(self, amount):
        print(
            f"Execute payment of {amount} {self.currency}"
        )

现在我们添加一个适配器类来完成适配。适配器通过属性 system 保存需要被适配的对象(也称 adaptee,被适配者)。适配器暴露 make_payment() 方法,并在内部调用被适配对象的 execute_payment() 完成支付:

class PaymentAdapter:
    def __init__(self, system):
        self.system = system
    def make_payment(self, amount):
        self.system.execute_payment(amount)

这样,PaymentAdapter 就把 NewPaymentGateway 的接口适配成了 OldPaymentSystem 的接口。

加入一个 main() 测试函数:

def main():
    old_system = OldPaymentSystem("euro")
    print(old_system)
    new_system = NewPaymentGateway("euro")
    print(new_system)
    adapter = PaymentAdapter(new_system)
    adapter.make_payment(100)

完整实现见 ch04/adapter/adapt_legacy.py,要点回顾:

  • 遗留系统类 OldPaymentSystem,提供 make_payment()
  • 新系统类 NewPaymentGateway,提供 execute_payment()
  • 适配器类 PaymentAdapter:保存被适配对象,并在 make_payment() 中转调 self.system.execute_payment(amount)
  • 测试代码并在 if __name__ == "__main__" 下调用。

运行 python ch04/adapter/adapt_legacy.py,输出类似:

<__main__.OldPaymentSystem object at 0x10ee58fd0>
<__main__.NewPaymentGateway object at 0x10ee58f70>
Execute payment of 100 euro

现在你已经明白了:这种适配技术让我们可以在原本期望旧接口的代码中使用新的支付网关

实现适配器模式 —— 把多个类适配成统一接口

再看一个场景:某俱乐部的活动。俱乐部有两项主要活动:

  1. 招募有才华的艺人来俱乐部表演
  2. 组织演出和活动为顾客带来娱乐

核心有一个表示俱乐部的 Club 类,俱乐部执行的主要动作是 organize_event()(文中描述也提到 organize_performance(),而代码使用的是 organize_event())。代码如下:

class Club:
    def __init__(self, name):
        self.name = name
    def __str__(self):
        return f"the club {self.name}"
    def organize_event(self):
        return "hires an artist to perform"

大多数时候俱乐部会请 DJ,但我们希望能组织多样化的演出:音乐家/乐队、舞者、单口喜剧等。

在调研可复用的现有代码时,我们找到一个开源库,里面有两个有用的类:MusicianDancer。在 Musician 类中,主要动作在 play() 方法里;在 Dancer 类中,主要动作在 dance() 方法里。

为了表明这两个类来自外部库,我们将它们放在单独模块(ch04/adapter/external.py)中:

class Musician:
    def __init__(self, name):
        self.name = name
    def __str__(self):
        return f"the musician {self.name}"
    def play(self):
        return "plays music"

class Dancer:
    def __init__(self, name):
        self.name = name
    def __str__(self):
        return f"the dancer {self.name}"
    def dance(self):
        return "does a dance performance"

我们正在编写的代码只知道如何在 Club 上调用 organize_event(),它并不知道 play()dance() 的存在。

如何在不改动 MusicianDancer 源码的前提下让代码工作起来?

适配器来救场!我们创建一个通用的适配器类,把若干具有不同接口的对象适配为统一接口__init__()obj 参数是待适配对象,adapted_methods 是一个字典,键是客户端期望调用的方法名,值是实际应该被调用的方法

class Adapter:
    def __init__(self, obj, adapted_methods):
        self.obj = obj
        self.__dict__.update(adapted_methods)
    def __str__(self):
        return str(self.obj)

处理不同类的实例时有两种情况:

  • 兼容对象Club)不需要适配,可直接使用;
  • 不兼容对象需要先通过 Adapter 进行适配。

这样,客户端代码就可以统一通过 organize_event()所有对象上发起调用,而无需关心彼此接口的差异。下面的 main() 展示了该设计按预期工作:

def main():
    objects = [
        Club("Jazz Cafe"),
        Musician("Roy Ayers"),
        Dancer("Shane Sparks"),
    ]
    for obj in objects:
        if hasattr(obj, "play") or hasattr(obj, "dance"):
            if hasattr(obj, "play"):
                adapted_methods = dict(
                    organize_event=obj.play
                )
            elif hasattr(obj, "dance"):
                adapted_methods = dict(
                    organize_event=obj.dance
                )
            obj = Adapter(obj, adapted_methods)
        print(f"{obj} {obj.organize_event()}")

完整实现见 ch04/adapter/adapt_to_unified_interface.py,要点回顾:

  • 从外部模块导入 MusicianDancer
  • 定义 Club 类;
  • 定义通用 Adapter 类;
  • 编写 main() 并在 if __name__ == "__main__" 下调用。

执行 python ch04/adapter/adapt_to_unified_interface.py,输出为:

the club Jazz Cafe hires an artist to perform
the musician Roy Ayers plays music
the dancer Shane Sparks does a dance performance

如你所见,我们在不修改外部类源码的前提下,使 MusicianDancer 的接口与客户端代码期望的接口相兼容

装饰器(Decorator)模式

第二个值得学习的结构型模式是装饰器模式。它允许程序员以动态且透明(不影响其他对象)的方式给对象添加职责
再者,对 Python 开发者来说,这个模式更有意思的一点马上会看到:我们可以借助 Python 内置的“装饰器”特性,用很“Pythonic”的方式来实现它。

NOTE
Python 装饰器是一个可调用对象(函数、方法或类),它接收一个函数对象 func_in 作为输入,并返回另一个函数对象 func_out。这是一种常见的技术,用于扩展函数、方法或类的行为。
详见官方文档:docs.python.org/3/reference…

实际上,这个特性你已经见过:前面章节里常用的 @abstractmethod@property 等就是装饰器,Python 还内置了不少有用的装饰器。接下来我们将学习如何实现并使用自定义装饰器

需要注意:装饰器模式 ≠ Python 装饰器特性(一一对应关系并不存在)。Python 装饰器能做的事远多于“装饰器模式”,其中之一正是实现装饰器模式

现实世界示例

装饰器模式通常用于扩展对象功能。日常生活中的类比:给枪加消音器、给相机换不同镜头,等等。
在大量使用装饰器的 Web 框架(如 Django)中,常见装饰器用途包括:

  • 基于请求限制视图访问
  • 控制特定视图的缓存策略
  • 按视图控制压缩
  • 基于特定 HTTP 请求头控制缓存
  • 注册某函数为事件订阅者
  • 用特定权限保护函数

适用场景

装饰器模式非常适合实现横切关注点(cross-cutting concerns),例如:

  • 数据校验
  • 缓存
  • 日志
  • 监控
  • 调试
  • 业务规则
  • 加密

一般来说,可复用于应用多处的通用逻辑都属于横切关注点。
另一个常见场景是 GUI 工具集:希望给组件/控件按需添加边框、阴影、配色、滚动等特性。

实现装饰器模式

Python 装饰器非常通用且强大。本节我们实现一个记忆化(memoization)装饰器。所有递归函数都能从记忆化中获益。我们尝试一个 number_sum() 函数,返回前 n 个自然数之和(注意:math 模块里有 fsum(),这里假装没有)。

先看朴素实现(ch04/decorator/number_sum_naive.py):

def number_sum(n):
    if n == 0:
        return 0
    else:
        return n + number_sum(n - 1)

if __name__ == "__main__":
    from timeit import Timer
    t = Timer(
        "number_sum(50)",
        "from __main__ import number_sum",
    )
    print("Time: ", t.timeit())

在一台样例机器上,计算前 50 个数的和要 7 秒多;运行 python ch04/decorator/number_sum_naive.py 输出类似:

Time:  7.286800935980864

尝试用记忆化提升性能。在下面的代码中,我们用 dict 作为缓存,并把测试规模改为前 300 个数(ch04/decorator/number_sum.py):

sum_cache = {0: 0}

def number_sum(n):
    if n in sum_cache:
        return sum_cache[n]
    res = n + number_sum(n - 1)
    # Add the value to the cache
    sum_cache[n] = res
    return res

if __name__ == "__main__":
    from timeit import Timer
    t = Timer(
        "number_sum(300)",
        "from __main__ import number_sum",
    )
    print("Time: ", t.timeit())

性能显著提升;示例运行(python ch04/decorator/number_sum.py):

Time:  0.1288748119986849

不过,这样做也带来问题:虽然快了,但代码不再简洁。如果我们把更多数学函数放进同一模块呢?例如再实现斐波那契:

fib_cache = {0: 0, 1: 1}

def fibonacci(n):
    if n in fib_cache:
        return fib_cache[n]
    res = fibonacci(n - 1) + fibonacci(n - 2)
    fib_cache[n] = res
    return res

问题显而易见:又多了一个 fib_cache,函数本身也更复杂。模块在无谓地变复杂
有没有办法保持函数像朴素版本那样简单,同时又能获得记忆化带来的性能?
——使用装饰器模式

首先实现一个 memoize() 装饰器。它接收需要记忆化的函数 func,使用 cache 字典存放结果。借助 functools.wraps() 保留被装饰函数的文档与签名(不是强制,但强烈建议)。由于被装饰函数需要参数(如 n),包装器采用 *args

import functools

def memoize(func):
    cache = {}
    @functools.wraps(func)
    def memoizer(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return memoizer

现在就能在朴素版本直接套用 @memoize,既保持可读性,又得到性能:

@memoize
def number_sum(n):
    if n == 0:
        return 0
    else:
        return n + number_sum(n - 1)

@memoize
def fibonacci(n):
    if n in (0, 1):
        return n
    else:
        return fibonacci(n - 1) + fibonacci(n - 2)

最后在 main() 中演示如何调用并计时。这里把函数引用与对应的 timeit.Timer() 放在列表里,避免重复代码。注意:通过 functools.wraps__name____doc__ 能保留正确值;你可以尝试移除 @functools.wraps(func) 看看差异。

def main():
    from timeit import Timer
    to_execute = [
        (
            number_sum,
            Timer(
                "number_sum(300)",
                "from __main__ import number_sum",
            ),
        ),
        (
            fibonacci,
            Timer(
                "fibonacci(100)",
                "from __main__ import fibonacci",
            ),
        ),
    ]
    for item in to_execute:
        func = item[0]
        print(
            f'Function "{func.__name__}": {func.__doc__}'
        )
        t = item[1]
        print(f"Time: {t.timeit()}")
        print()

把完整代码组织为 ch04/decorator/decorate_math.py

  1. 导入 functools,定义 memoize()
  2. 定义并用 @memoize 装饰 number_sum()
  3. 同样装饰 fibonacci()
  4. 按上文添加 main() 与常规入口判断。

示例运行(python ch04/decorator/decorate_math.py)输出类似:

Function "number_sum": Returns the sum of the first n numbers
Time: 0.2148694
Function "fibonacci": Returns the suite of Fibonacci numbers
Time: 0.202763251

NOTE
你的执行时间可能不同。无论耗时如何,基于装饰器的实现更易维护,因此是一次“胜利”。

很好!我们同时获得了可读性可接受的性能
你也许会争论:这不算“运行期应用”装饰器模式。确实,被装饰的函数无法“反装饰”。但你仍然可以在运行期决定是否执行装饰器。这留给你做个小练习——提示:编写一个包装器型装饰器,根据某个条件来决定是否真正执行“真实装饰器”。

桥接(Bridge)模式

第三个要看的结构型模式是桥接模式。可以把桥接适配器对比理解二者的工作方式:适配器模式通常在事后用于让彼此无关的类协同工作(前文“适配器模式”的实现示例就如此);而桥接模式是在设计之初就用于将实现与抽象解耦,下面会看到具体做法。

现实世界示例

在当下的数字经济中,一个能够体现桥接思路的例子是信息产品(infoproduct) 。这类产品为某一主题提供易于获取与消费的内容:可能是 PDF 或电子书、电子书系列、视频或视频系列、在线课程、订阅式通讯,或这些形式的组合。
在软件领域,可举两类例子:

  • 设备驱动:操作系统的开发者定义设备(如打印机)厂商需要实现的接口。
  • 支付网关:不同网关的实现可以各不相同,但结账流程对上层保持一致。

适用场景

当你希望在多个对象之间共享同一实现时,桥接模式是个好主意。与其为每个对象实现一套专用类并把所有细节都塞进各自的类里,不如抽出以下“特殊部件”:

  • 一个适用于所有类的抽象(Abstraction)
  • 一个为参与对象定义的单独接口(实现层接口,Implementor)

后面的实现示例会展示这种做法。

实现桥接模式

假设我们要构建一个应用,从不同来源获取内容并由用户进行管理和分发,来源可能包括:

  • 网页(基于其 URL)
  • FTP 服务器上的资源
  • 本地文件系统中的文件
  • 数据库服务器

思路是:不要为每种内容实现单独的内容类并把“获取、组装、展示”的方法都塞进去,而是为“资源内容”定义抽象,再为负责获取内容的对象定义单独的接口。动手试试!

先定义实现层接口(Implementor)——ResourceContentFetcher,这里用 Python 的 Protocol(协议)特性:

class ResourceContentFetcher(Protocol):
    def fetch(self, path: str) -> str:
        ...

然后定义抽象——ResourceContent。关键点是通过该类上的一个属性(_imp)持有一个满足 ResourceContentFetcher 接口的实现者对象引用:

class ResourceContent:
    def __init__(self, imp: ResourceContentFetcher):
        self._imp = imp
    def get_content(self, path):
        return self._imp.fetch(path)

接着添加一个从网页获取内容的实现类:

class URLFetcher:
    def fetch(self, path):
        res = ""
        req = urllib.request.Request(path)
        with urllib.request.urlopen(
            req
        ) as response:
            if response.code == 200:
                res = response.read()
        return res

再添加一个从本地文件系统获取内容的实现类:

class LocalFileFetcher:
    def fetch(self, path):
        with open(path) as f:
            res = f.read()
        return res

基于以上,写一个 main 做演示,分别用两种获取器来取内容:

def main():
    url_fetcher = URLFetcher()
    rc = ResourceContent(url_fetcher)
    res = rc.get_content("http://python.org")
    print(
        f"Fetched content with {len(res)} characters"
    )

    localfs_fetcher = LocalFileFetcher()
    rc = ResourceContent(localfs_fetcher)
    pathname = os.path.abspath(__file__)
    dir_path = os.path.split(pathname)[0]
    path = os.path.join(dir_path, "file.txt")
    res = rc.get_content(path)
    print(
        f"Fetched content with {len(res)} characters"
    )

示例完整代码概要(ch04/bridge/bridge.py):

  • 导入所需模块(osurllib.request、以及 typing.Protocol)。

  • 定义实现者接口 ResourceContentFetcher(使用 Protocol)。

  • 定义抽象接口类 ResourceContent

  • 定义两个实现类:

    • URLFetcher:从 URL 获取内容
    • LocalFileFetcher:从本地文件系统获取内容
  • 按前述添加 main(),并使用常规入口判断调用。

运行 python ch04/bridge/bridge.py,示例输出:

Fetched content with 51265 characters
Fetched content with 1327 characters

这就是桥接模式的基本演示:通过在抽象与实现之间搭桥并解耦,你可以从不同来源抽取内容,同时把结果整合到同一个数据处理系统或用户界面中。

外观(Facade)模式

随着系统演进,复杂度常常水涨船高:类和交互关系可能越来越多、越来越难以把握。在很多情况下,我们并不希望把这些复杂性暴露给客户端。这时,下一个结构型模式就能派上用场:外观(facade)

外观模式通过一个简化的接口对外隐藏系统内部的复杂性;本质上,它是在既有复杂系统之上实现的一层抽象层

以计算机为例说明。计算机是一台复杂机器,需要多个部件协同才能工作。为便于说明,这里“计算机”指采用冯·诺依曼结构的 IBM 衍生架构机器。启动计算机是个尤其复杂的过程:CPU、主存、硬盘要就绪;从硬盘把引导加载程序装入内存;CPU 启动内核……如果把这些细节暴露给客户端就太繁琐了。我们可以创建一个外观来封装整套启动流程,确保各步骤按正确顺序执行。

从面向对象设计与编程角度看,系统内部可以有多个类,但只需要把 Computer(计算机)类暴露给客户端。客户端只需调用 Computer.start(),其余复杂工作都由 Computer 这个外观类代劳。

现实世界示例

  • 当你致电银行或公司时,通常先接入客服部门。客服人员充当你与实际处理部门(账单、技术支持、综合服务等)之间的外观,由后者的员工解决你的具体问题。
  • 汽车/摩托车的点火钥匙也可视作外观:一个简单动作激活内部极其复杂的系统。其他复杂电子设备上“一键开启”的设计同理。
  • 软件中,Django 的第三方模块 django-oscar-datacash 集成了 DataCash 支付网关。模块提供一个细粒度的 gateway 类以访问 DataCash 各种 API;其上还提供一个外观类,为不想处理细节的人提供更粗粒度的 API,并支持为审计目的保存交易记录。
  • Requests 库是外观模式的绝佳例子:它简化了 HTTP 请求的发送与响应处理,把 HTTP 协议的复杂性抽象起来,让开发者无需关心底层 socket 或复杂的 HTTP 细节即可发起请求。

适用场景

使用外观模式的最常见动机是:为复杂系统提供一个单一、简单的入口。有了外观,客户端只需调用一个方法/函数即可使用系统。与此同时,系统的内部功能并未丢失,只是被封装起来而已。

另外,把内部功能对客户端隐藏还有一个好处:可以在不影响客户端的情况下修改系统内部实现;客户端代码无需改动。

当系统有多层结构时,外观也很有用:每层提供一个外观入口,各层通过各自外观互相通信,从而降低耦合、尽量保持层与层的独立性。

实现外观模式

假设我们要构建一个多服务器(multi-server)架构的操作系统,类似 MINIX 3GNU Hurd。这类系统拥有一个微内核(microkernel)在特权态运行,其他服务(驱动服务器、进程服务器、文件服务器等)都以服务器架构在用户态、各自独立的地址空间上运行。其优点是更高的容错性、可靠性与安全性:例如,驱动都在用户态的驱动服务器上运行,某个驱动的 bug 不会导致整机崩溃,也不会影响其他服务器。缺点是额外的性能开销与编程复杂度:服务器与微内核之间以及服务器彼此之间通过消息传递通信,这比 Linux 等单体内核采用的共享内存模型要复杂。

我们先定义一个 Server 接口,并用一个 Enum 表示服务器的不同状态。通过 ABC(抽象基类)禁止直接实例化 Server,并强制各服务器必须实现 boot()kill()——不同服务器在启动、终止、重启时需要执行的动作各不相同。相关代码如下,是实现所需的第一批关键元素:

State = Enum(
    "State",
    "NEW RUNNING SLEEPING RESTART ZOMBIE",
)
# ...
class Server(ABC):
    @abstractmethod
    def __init__(self):
        pass
    def __str__(self):
        return self.name
    @abstractmethod
    def boot(self):
        pass
    @abstractmethod
    def kill(self, restart=True):
        pass

一个模块化操作系统可能包含很多有趣的服务器:文件服务器、进程服务器、认证服务器、网络服务器、图形/窗口服务器等等。下面的示例包含两个桩(stub)服务器:FileServerProcessServer。除了所有服务器都具备的 boot()kill() 外,FileServer 还提供 create_file()ProcessServer 提供 create_process()

FileServer

class FileServer(Server):
    def __init__(self):
        self.name = "FileServer"
        self.state = State.NEW
    def boot(self):
        print(f"booting the {self}")
        self.state = State.RUNNING
    def kill(self, restart=True):
        print(f"Killing {self}")
        self.state = (
            State.RESTART if restart else State.ZOMBIE
        )
    def create_file(self, user, name, perms):
        msg = (
            f"trying to create file '{name}' "
            f"for user '{user}' "
            f"with permissions {perms}"
        )
        print(msg)

ProcessServer

class ProcessServer(Server):
    def __init__(self):
        self.name = "ProcessServer"
        self.state = State.NEW
    def boot(self):
        print(f"booting the {self}")
        self.state = State.RUNNING
    def kill(self, restart=True):
        print(f"Killing {self}")
        self.state = (
            State.RESTART if restart else State.ZOMBIE
        )
    def create_process(self, user, name):
        msg = (
            f"trying to create process '{name}' "
            f"for user '{user}'"
        )
        print(msg)

OperatingSystem 类就是外观。在其 __init__() 中创建所有必要的服务器实例;客户端使用的 start() 方法是系统入口。如有需要,还可以添加更多包装方法作为访问各服务器服务的入口,比如 create_file()create_process()从客户端视角看,所有服务都由 OperatingSystem 提供,无需知道有多少服务器、各自职责是什么。

OperatingSystem 代码如下:

class OperatingSystem:
    """The Facade"""
    def __init__(self):
        self.fs = FileServer()
        self.ps = ProcessServer()
    def start(self):
        [i.boot() for i in (self.fs, self.ps)]
    def create_file(self, user, name, perms):
        return self.fs.create_file(user, name, perms)
    def create_process(self, user, name):
        return self.ps.create_process(user, name)

稍后在示例总结里你会看到,我们还放了不少占位类与服务器,旨在让你理解让系统可用所需的抽象(UserProcessFile 等)与服务器(WindowServerNetworkServer 等)。

最后,加上主程序用于测试设计:

def main():
    os = OperatingSystem()
    os.start()
    os.create_file("foo", "hello.txt", "-rw-r-r")
    os.create_process("bar", "ls /tmp")

实现摘要(完整代码见 ch04/facade.py):

  • 引入所需模块;
  • 按上文用 Enum 定义 State
  • 添加最小可运行示例所需的 UserProcessFile 类(空实现即可);
  • 定义抽象基类 Server(见上);
  • 定义 FileServerProcessServer(均继承 Server);
  • 再添加两个占位服务器 WindowServerNetworkServer
  • 定义外观类 OperatingSystem(见上);
  • 编写主程序,调用我们定义的外观。

运行 python ch04/facade.py,可见两个桩服务器打印的消息:

booting the FileServer
booting the ProcessServer
trying to create file 'hello.txt' for user 'foo' with permissions -rw-r-r
trying to create process 'ls /tmp' for user 'bar'

OperatingSystem 这个外观类干得不错:客户端无需了解“多服务器”的内部细节,就能创建文件与进程。严格说,目前这两个方法还是占位实现。作为一个有趣的练习,你可以把其中一个(甚至两个)方法实现成真正的文件/进程创建逻辑。

享元(Flyweight)模式

每当我们创建一个新对象,就需要分配额外内存。尽管理论上虚拟内存是“无限”的,但现实并非如此:当系统的物理内存耗尽时,会开始把内存页换出到二级存储(通常是机械硬盘 HDD)。由于主存与 HDD 的性能差距巨大,这在大多数情况下是不可接受的。固态硬盘(SSD)通常比 HDD 更快,但并非所有人都使用 SSD,因此 SSD 也不会在短期内完全取代 HDD。

除了内存占用,性能也是考虑因素。图形软件(包括电子游戏)需要极快地渲染 3D 信息(例如有成千上万棵树的森林、满是士兵的村庄、车流如织的城区)。如果 3D 场景中的每个对象都单独创建且不共享数据,性能会严重受限。

作为软件工程师,我们应尽量通过更好的软件设计来解决问题,而不是强迫用户购买更好或更多的硬件。享元模式是一种通过在相似对象之间共享数据来最小化内存使用、提升性能的技术。享元(flyweight)是一个共享对象,只包含与状态无关、不可变(也称内在,intrinsic)的数据;而与状态相关、可变(也称外在,extrinsic)的数据不应成为享元的一部分——这类信息在不同对象间不同,无法共享。如果享元需要外在数据,必须由客户端代码显式传入

举个例子帮助理解如何实际使用享元模式。假设我们在创建一个对性能要求极高的游戏(例如第一人称射击 FPS)。在 FPS 游戏中,玩家(士兵)会共享一些状态,比如外观行为。以《反恐精英》为例,同一阵营(反恐与恐怖分子)中的士兵看起来是一样的(外观);同一游戏里所有士兵都有一些共同动作,比如跳跃、下蹲等(行为)。这意味着我们可以创建一个享元来承载这些公共数据。当然,士兵也有大量不共享的数据(不属于享元),例如武器、生命值、位置等。

现实世界示例

享元是一个优化型设计模式,因此非计算领域的类比并不容易。你可以把它类比为现实中的缓存:许多书店会把最新/最畅销的书单独陈列在专架上。这就是一种缓存:你先在专架上找,找不到再让店员从库房(完整存储)里帮你找。

软件领域中,音乐播放器 Exaile 使用享元来复用对象(这里是音乐曲目)——如果两个曲目的 URL 相同,就没有必要创建新对象,直接复用已有对象以节省资源。

适用场景

享元的核心诉求是提升性能降低内存占用。所有嵌入式系统(手机、平板、游戏主机、微控制器等)以及性能敏感应用(游戏、3D 图形处理、实时系统等)都能从中获益。

GoF(四人帮)在书中列出了有效使用享元模式需满足的条件:

  1. 应用需要使用大量对象。
  2. 对象数量多到存储/渲染成本过高。一旦把可变状态剥离(因为需要时应由客户端显式传给享元),许多“不同”的对象就能被相对少量的共享对象替代。
  3. 对象标识(identity)对应用不重要。共享会导致标识比较失效(对客户端看来不同的对象,最终可能拥有相同标识)。

实现享元模式

我们用一个“在某区域渲染汽车”的示例来说明。为了演示输出能一屏展示,我们先造一个小型停车场;不过无论停车场多大,内存分配都保持不变

记忆化(Memoization) vs. 享元模式
记忆化是一种利用缓存避免重复计算的优化技术,并不专属于某种编程范式;在 Python 中,既可用于类方法,也可用于普通函数。
享元则是面向对象的优化设计模式,关注共享对象数据

先定义一个 Enum,表示三种车型:

CarType = Enum(
    "CarType", "SUBCOMPACT COMPACT SUV"
)

接着定义核心类 Car。类属性 pool对象池(也就是缓存)。注意 pool类属性(所有实例共享)。

通过实现特殊方法 __new__()(它在 __init__() 之前调用),我们让 Car 类具备类似自引用的能力;此处 cls 引用的就是类 Car 本身。当客户端创建 Car 实例时,会传入 car_type。我们据此检查该类型的车是否已经创建过:若已存在,返回已存在对象;否则创建新对象、放入池中并返回。

class Car:
    pool = dict()
    def __new__(cls, car_type):
        obj = cls.pool.get(car_type, None)
        if not obj:
            obj = object.__new__(cls)
            cls.pool[car_type] = obj
            obj.car_type = car_type
        return obj

render() 方法用于把车辆渲染到屏幕上。注意:所有享元未知的可变信息必须由客户端显式传入。这里为每辆车传入随机颜色位置坐标 (x, y)
如果要让 render() 更实用,还应确保不会把车渲染在彼此之上——把这当作练习;如果想让渲染更有趣,可以使用 Tkinter、Pygame 或 Kivy 等图形工具包。

    def render(self, color, x, y):
        type = self.car_type
        msg = f"render a {color} {type.name} car at ({x}, {y})"
        print(msg)

main() 展示如何使用享元。颜色从预定义列表随机选择,坐标在 1 到 100 之间随机。尽管渲染了 18 辆车,但实际只分配了 3 个对象。输出的最后几行展示了对象标识的效果:id() 返回对象的内存地址。默认情况下,每个对象的 id() 都唯一;而使用享元后,即便两个对象看起来不同,只要属于同一享元族(这里由 car_type 定义),它们的 id() 也会相同。当然,不同族之间的标识比较仍可区分,但这通常要求客户端了解实现细节

def main():
    rnd = random.Random()
    colors = [
        "white",
        "black",
        "silver",
        "gray",
        "red",
        "blue",
        "brown",
        "beige",
        "yellow",
        "green",
    ]
    min_point, max_point = 0, 100
    car_counter = 0
    for _ in range(10):
        c1 = Car(CarType.SUBCOMPACT)
        c1.render(
            random.choice(colors),
            rnd.randint(min_point, max_point),
            rnd.randint(min_point, max_point),
        )
        car_counter += 1
    for _ in range(3):
        c2 = Car(CarType.COMPACT)
        c2.render(
            random.choice(colors),
            rnd.randint(min_point, max_point),
            rnd.randint(min_point, max_point),
        )
        car_counter += 1
    for _ in range(5):
        c3 = Car(CarType.SUV)
        c3.render(
            random.choice(colors),
            rnd.randint(min_point, max_point),
            rnd.randint(min_point, max_point),
        )
        car_counter += 1
    print(f"cars rendered: {car_counter}")
    print(
        f"cars actually created: {len(Car.pool)}"
    )
    c4 = Car(CarType.SUBCOMPACT)
    c5 = Car(CarType.SUBCOMPACT)
    c6 = Car(CarType.SUV)
    print(
        f"{id(c4)} == {id(c5)}? {id(c4) == id(c5)}"
    )
    print(
        f"{id(c5)} == {id(c6)}? {id(c5) == id(c6)}"
    )

完整代码(ch04/flyweight.py)要点回顾:

  • 引入 randomEnum(来自 enum 模块);
  • 定义车型的 Enum
  • 定义 Car 类(类属性 pool__new__()render());
  • main() 第一部分:定义变量并渲染一批 SUBCOMPACT
  • 第二部分:渲染 COMPACT
  • 第三部分:渲染 SUV
  • 第四部分:统计与对比对象标识。

执行 python ch04/flyweight.py,你会看到渲染出的类型、随机颜色与坐标,以及同/异族享元对象的标识比较结果,例如:

render a gray SUBCOMPACT car at (25, 79)
render a black SUBCOMPACT car at (31, 99)
render a brown SUBCOMPACT car at (16, 74)
render a green SUBCOMPACT car at (10, 1)
render a gray SUBCOMPACT car at (55, 38)
render a red SUBCOMPACT car at (30, 45)
render a brown SUBCOMPACT car at (17, 78)
render a gray SUBCOMPACT car at (14, 21)
render a gray SUBCOMPACT car at (7, 28)
render a gray SUBCOMPACT car at (22, 50)
render a brown COMPACT car at (75, 26)
render a red COMPACT car at (22, 61)
render a white COMPACT car at (67, 87)
render a beige SUV car at (23, 93)
render a white SUV car at (37, 100)
render a red SUV car at (33, 98)
render a black SUV car at (77, 22)
render a green SUV car at (16, 51)
cars rendered: 18
cars actually created: 3
4493672400 == 4493672400? True
4493672400 == 4493457488? False

(不要指望输出完全一致,因为颜色和坐标是随机的,对象标识也取决于内存布局。)

代理(Proxy)模式

代理模式的名称来自“代理(也称替身、surrogate)对象”:在访问真实对象之前,代理会先执行一些重要动作。常见的四类代理如下:

  • 虚拟代理(virtual proxy) :使用延迟初始化,把创建代价高昂的对象推迟到真正需要时才进行。
  • 保护(权限)代理(protection/protective proxy)控制对敏感对象的访问
  • 远程代理(remote proxy) :在本地充当实际位于另一地址空间(例如远端服务器)对象的代表。
  • 智能引用代理(smart/reference proxy) :在访问对象时执行额外操作,例如引用计数线程安全检查等。

现实世界示例

  • 芯片银行卡是保护代理的好例子。借记/信用卡上的芯片需先被 ATM/读卡器读取并验证,随后输入 PIN 才能完成交易——没有卡且不知道 PIN,交易无法进行。
  • 支票相当于现金的替代品,可用于消费/交易,是远程代理的一个例子:支票为你“代理”访问银行账户。
  • 软件中,Python 的 weakref 模块提供 proxy() 方法:接收一个对象并返回其智能代理。弱引用是给对象添加引用计数支持的推荐方式。

适用场景

由于至少有以上四种常见类型,代理模式的用例很多:

  • 分布式系统(私有网络或云):部分对象在本地内存,部分在远端主机内存。若不希望客户端感知差异,可用远程代理隐藏/封装之,使应用的分布式特性对客户端透明
  • 性能优化:如果提前创建昂贵对象导致性能问题,可引入虚拟代理惰性创建,仅在需要时构造对象,从而显著提升性能。
  • 访问控制:当应用处理敏感信息(如医疗数据),需确保访问/修改者拥有足够权限。保护代理可负责所有安全相关动作。
  • 线程安全:当应用(或库/工具包/框架)使用多线程且希望把线程安全负担从客户端转移到应用内部时,可创建智能代理隐藏并处理线程安全复杂性。
  • ORM:对象-关系映射 API 也是远程代理的例子。Django、Flask、FastAPI 等常用 ORM 为关系型数据库提供面向对象的访问抽象——数据库可能在本地或远端服务器上,ORM 充当其代理。

实现代理模式 —— 虚拟代理

在 Python 中实现虚拟代理有多种方式。下面采用一种Pythonic/惯用写法(灵感源自 stackoverflow.com 上用户 Cyclone 对“Python 记忆化/延迟属性装饰器”的精彩回答)。

说明
本节中 property / variable / attribute(属性/变量)这几个词互换使用

首先,创建一个可作为装饰器使用的 LazyProperty 类。被它装饰的属性会在第一次使用时才被初始化(而不是立刻)。__init__() 中保存两个别名:

  • method 指向实际初始化属性的方法;
  • method_name 保存该方法名。
    可取消注释以打印它们的值,更好地理解用法:
class LazyProperty:
    def __init__(self, method):
        self.method = method
        self.method_name = method.__name__
        # print(f"function overriden: {self.method}")
        # print(f"function's name: {self.method_name}")

LazyProperty 实际上是一个描述符(descriptor) 。描述符是 Python 推荐的机制,用于重写属性访问方法 __get__()__set__()__delete__() 的默认行为。这里我们只重写了 __get__(),因为只需要它即可。
__get__() 会获取底层方法希望赋给属性的,并用 setattr() 手动完成赋值。它的巧妙之处在于:把方法替换成了值本身!于是该属性不仅惰性加载,而且只会被设置一次

    def __get__(self, obj, cls):
        if not obj:
            return None
        value = self.method(obj)
        # print(f'value {value}')
        setattr(obj, self.method_name, value)
        return value

然后用 Test 类演示 LazyProperty 的用法。类中有 xy_resource 三个属性;我们希望 _resource 延迟加载,因此先设为 None

class Test:
    def __init__(self):
        self.x = "foo"
        self.y = "bar"
        self._resource = None

LazyProperty 装饰 resource()。为演示起见,这里把 _resource 初始化为一个元组;真实场景中它可能是耗时/昂贵的初始化(数据库、图形等):

    @LazyProperty
    def resource(self):
        print("initializing self._resource...")
        print(f"... which is: {self._resource}")
        self._resource = tuple(range(5))
        return self._resource

main() 展示惰性初始化的行为:

def main():
    t = Test()
    print(t.x)
    print(t.y)
    # do more work...
    print(t.resource)
    print(t.resource)

注意:由于重写了 __get__(),我们可以把 resource() 当作普通属性使用(写 t.resource 而非 t.resource())。

示例代码回顾(ch04/proxy/proxy_lazy.py):

  1. 定义 LazyProperty
  2. 定义带 resource() 且被 LazyProperty 装饰的 Test
  3. 添加 main() 进行测试。

在保持注释行被注释的原始版本中运行 python ch04/proxy/proxy_lazy.py,输出类似:

foo
bar
initializing self._resource...
... which is: None
(0, 1, 2, 3, 4)
(0, 1, 2, 3, 4)

可见:

  • _resource 并非在创建实例 t 时初始化,而是在首次访问 t.resource 时才初始化;
  • 第二次访问 t.resource 时不会再初始化——因此“initializing self._resource”只打印一次

补充
OOP 中的惰性初始化常见两类:

  • 实例级:属性在第一次访问时初始化,但作用域是对象自身;每个实例各有一份副本。
  • 类/模块级:多个实例共享同一份延迟初始化的属性(本章不覆盖,可自行练习)。

实现代理模式 —— 保护代理

再看一个示例:实现一个简单的保护代理来查看/新增用户。服务有两个功能:

  • 查看用户列表:不需要特殊权限;
  • 新增用户:客户端必须提供特殊密语

SensitiveInfo 保存我们要保护的信息:users 是用户列表,read() 打印列表,add() 添加新用户:

class SensitiveInfo:
    def __init__(self):
        self.users = ["nick", "tom", "ben", "mike"]
    def read(self):
        nb = len(self.users)
        print(f"There are {nb} users: {' '.join(self.users)}")
    def add(self, user):
        self.users.append(user)
        print(f"Added user {user}")

InfoSensitiveInfo保护代理secret 是新增用户时客户端需要提供的密语。
注意:以下只是示例,实际生产中绝不能

  • 在源码中存储密码;
  • 明文存储密码;
  • 使用弱加密(如 MD5)或自定义加密方式。

Info 中,read() 只是对 SensitiveInfo.read() 的包装;add() 则在客户端提供正确密语时才允许添加用户:

class Info:
    def __init__(self):
        self.protected = SensitiveInfo()
        self.secret = "0xdeadbeef"
    def read(self):
        self.protected.read()
    def add(self, user):
        sec = input("what is the secret? ")
        if sec == self.secret:
            self.protected.add(user)
        else:
            print("That's wrong!")

main() 展示客户端如何使用代理:创建 Info 实例,通过菜单读取列表、添加用户或退出。

def main():
    info = Info()
    while True:
        print("1. read list |==| 2. add user |==| 3. quit")
        key = input("choose option: ")
        if key == "1":
            info.read()
        elif key == "2":
            name = input("choose username: ")
            info.add(name)
        elif key == "3":
            exit()
        else:
            print(f"unknown option: {key}")

完整代码回顾(ch04/proxy/proxy_protection.py):

  • 定义 SensitiveInfo
  • 定义 Info
  • 添加 main() 测试。

运行 python ch04/proxy/proxy_protection.py 的样例输出:

1. read list |==| 2. add user |==| 3. quit
choose option: 1
There are 4 users: nick tom ben mike
1. read list |==| 2. add user |==| 3. quit
choose option: 2
choose username: tom
what is the secret? 0xdeadbeef
Added user tom
1. read list |==| 2. add user |==| 3. quit
choose option: 3

可以改进之处(练习建议):

  • 严重安全漏洞:客户端仍可直接实例化 SensitiveInfo 从而绕过安全。可用 abc 模块禁止直接实例化 SensitiveInfo,并据此调整其余代码。
  • 不要明文存储密码:学习使用合适的库,安全地存储密语(例如外部文件或数据库中)。
  • 目前只支持新增用户,可添加 remove() 支持删除。

实现代理模式 —— 远程代理

设想构建一个文件管理系统,客户端对远端服务器上的文件执行操作:读、写、删。远程代理会向客户端隐藏网络请求的复杂性

先定义远端可执行操作的接口 RemoteServiceInterface,以及其实现类 RemoteService(为简化,方法仅返回字符串;真实实现会有具体的文件处理逻辑)。

from abc import ABC, abstractmethod
class RemoteServiceInterface(ABC):
    @abstractmethod
    def read_file(self, file_name):
        pass
    @abstractmethod
    def write_file(self, file_name, contents):
        pass
    @abstractmethod
    def delete_file(self, file_name):
        pass
class RemoteService(RemoteServiceInterface):
    def read_file(self, file_name):
        # Implementation for reading a file from the server
        return "Reading file from remote server"
    def write_file(self, file_name, contents):
        # Implementation for writing to a file on the server
        return "Writing to file on remote server"
    def delete_file(self, file_name):
        # Implementation for deleting a file from the server
        return "Deleting file from remote server"

定义代理 ProxyService,同样实现 RemoteServiceInterface,在本地代为调用RemoteService

class ProxyService(RemoteServiceInterface):
    def __init__(self):
        self.remote_service = RemoteService()
    def read_file(self, file_name):
        print("Proxy: Forwarding read request to RemoteService")
        return self.remote_service.read_file(file_name)
    def write_file(self, file_name, contents):
        print("Proxy: Forwarding write request to RemoteService")
        return self.remote_service.write_file(file_name, contents)
    def delete_file(self, file_name):
        print("Proxy: Forwarding delete request to RemoteService")
        return self.remote_service.delete_file(file_name)

客户端像用远端服务一样使用 ProxyService,而不需要感知远程细节。代理可以顺便添加日志、访问控制或缓存。简单测试:

if __name__ == "__main__":
    proxy = ProxyService()
    print(proxy.read_file("example.txt"))

实现回顾(完整代码见 ch04/proxy/proxy_remote.py):

  • 定义接口 RemoteServiceInterface 及其实现 RemoteService
  • 定义代理 ProxyService(同样实现接口);
  • 添加测试代码。

运行 python ch04/proxy/proxy_remote.py

Proxy: Forwarding read request to RemoteService
Reading file from remote server

远程代理的轻量示例实现成功。

实现代理模式 —— 智能代理

考虑一个共享资源(例如数据库连接)。每次对象访问该资源时,我们希望记录当前引用数量;当没有任何引用时,便可安全释放/关闭。智能代理可以为这个连接管理引用计数,确保只有在所有引用释放后才关闭连接。

如同上例,需要一个接口 DBConnectionInterface(这里换用 Protocol 来示例接口定义),以及表示真实数据库连接的类 DBConnection

from typing import Protocol
class DBConnectionInterface(Protocol):
    def exec_query(self, query):
        ...
class DBConnection:
    def __init__(self):
        print("DB connection created")
    def exec_query(self, query):
        return f"Executing query: {query}"
    def close(self):
        print("DB connection closed")

定义 SmartProxy,也实现 DBConnectionInterface(见 exec_query())。它负责按需创建连接并维护引用计数:当引用数降为 0 时关闭连接。

class SmartProxy:
    def __init__(self):
        self.cnx = None
        self.ref_count = 0
    def access_resource(self):
        if self.cnx is None:
            self.cnx = DBConnection()
        self.ref_count += 1
        print(f"DB connection now has {self.ref_count} references.")
    def exec_query(self, query):
        if self.cnx is None:
            # Ensure the connection is created
            # if not already
            self.access_resource()
        result = self.cnx.exec_query(query)
        print(result)
        # Decrement reference count after
        # executing query
        self.release_resource()
        return result
    def release_resource(self):
        if self.ref_count > 0:
            self.ref_count -= 1
            print("Reference released...")
            print(f"{self.ref_count} remaining refs.")
        if self.ref_count == 0 and self.cnx is not None:
            self.cnx.close()
            self.cnx = None

测试代码:

if __name__ == "__main__":
    proxy = SmartProxy()
    proxy.exec_query("SELECT * FROM users")
    proxy.exec_query("UPDATE users SET name = 'John Doe' WHERE id = 1")

实现回顾(完整代码见 ch04/proxy/proxy_smart.py):

  • 定义接口 DBConnectionInterface 与其实现 DBConnection
  • 定义 SmartProxy(实现接口并负责引用计数与资源管理);
  • 添加测试代码。

运行 python ch04/proxy/proxy_smart.py,示例输出:

DB connection created
DB connection now has 1 references.
Executing query: SELECT * FROM users
Reference released...
0 remaining refs.
DB connection closed
DB connection created
DB connection now has 1 references.
Executing query: UPDATE users SET name = 'John Doe' WHERE id = 1
Reference released...
0 remaining refs.
DB connection closed

这是代理模式的又一次演示:当应用的不同部分共享数据库连接且需要谨慎管理以避免资源耗尽连接泄漏时,智能代理可提供一种更健壮的解决方案。

总结

结构型模式对于编写简洁、可维护、可扩展的代码至关重要,它们为日常编码中会遇到的诸多挑战提供了解决方案。

首先,适配器模式不匹配的接口提供了灵活的协调之道。我们可以用它在遗留系统现代接口之间架起桥梁,从而让软件系统更连贯、更易管理。

接着,我们讨论了装饰器模式——一种在不使用继承的情况下、方便地扩展对象行为的方式。Python 通过其内置的装饰器特性进一步拓展了这一概念,使我们无需继承或组合,就能为任意可调用对象扩展行为。装饰器模式非常适合实现横切关注点(在“装饰器模式的适用场景”一节中已列举多个类别),我们也看到装饰器如何让函数在不牺牲性能的前提下保持整洁可读

桥接模式与适配器模式相似却不同:桥接在设计之初使用,用以解耦抽象与其实现,使二者可以独立变化。在操作系统与设备驱动、GUI、以及需要根据属性切换多套主题的网站构建器等领域,桥接模式尤为有用。我们通过内容抽取与管理的示例,定义了抽象的接口、实现者的接口,并给出了两种实现。

外观模式非常适合为希望使用复杂系统不必了解其复杂性的客户端提供一个简单接口。计算机本身就是一个外观:我们只需按下电源键即可使用,其余硬件复杂性由 BIOS、引导加载程序以及其他系统软件组件透明处理。现实中还有更多外观的例子,如银行/公司的客服部门,以及启动车辆的钥匙。我们还实现了一个多服务器操作系统接口作为外观的示例。

通常,当应用需要创建大量代价高昂且共享许多属性的对象时,会使用享元模式。关键在于将不可变(可共享)的内在属性可变(不可共享)的外在属性分离。我们实现了一个支持三种“汽车族”的渲染器;通过将可变的颜色与坐标 (x, y) 显式传入 render() 方法,我们只创建了 3 个对象而非 18 个。虽然这个数字看似不大,但如果把 18 换成 2,000,收益就非常可观了。

最后是代理模式。我们讨论了其在性能安全为用户提供简洁 API等方面的多种用例,并分别给出了示例:虚拟代理保护代理远程代理智能代理

下一章将进入行为型设计模式,它们关注的是对象之间的协作与算法