精通 Python 设计模式——基础设计原则

102 阅读17分钟

设计原则构成任何良好架构软件的基石。它们像指路明灯,帮助开发者在避免糟糕设计陷阱的同时,走上构建可维护、可扩展且健壮应用的正确道路。

本章将探讨所有开发者在项目中都应了解并践行的核心设计原则。我们会讨论四条基础原则。第一条“封装变化(Encapsulate What Varies)”教你如何隔离代码中可能发生变化的部分,从而更容易修改与扩展应用。接着,“多用组合(Favor Composition)”让你理解为何通常应当用简单对象的组装来构建复杂对象,而不是通过继承来获取功能。第三条“面向接口编程(Program to Interfaces)”展示了针对接口而非具体类进行编码的力量,提升灵活性与可维护性。最后,通过“松散耦合(Loose Coupling)”原则,你将把握降低组件间依赖的重要性,使代码更易于重构与测试。

本章将涵盖以下主要主题:

  • 遵循“封装变化(Encapsulate What Varies)”原则
  • 遵循“多用组合、少用继承(Favor Composition Over Inheritance)”原则
  • 遵循“面向接口而非实现(Program to Interfaces, Not Implementations)”原则
  • 遵循“松散耦合(Loose Coupling)”原则

在本章结束时,你将扎实掌握这些原则,并学会如何在 Python 中实践它们,为全书其余内容打下基础。

技术要求

阅读本书各章,你需要可运行的 Python 3.12 环境;在个别章节的少量特殊场景中,Python 3.11 也可使用。
此外,请安装 Mypy 静态类型检查器(www.mypy-lang.org),运行以下命令即可:,:-mp7i01dha677i8jbvycg046au8va/)

python3.12 -m pip install --user mypy

示例代码位于 GitHub 仓库:
github.com/PacktPublis…

关于 Python 可执行文件

全书中我们将以 python3.12python 来指代用于执行示例代码的 Python 可执行文件。请根据你自己的环境、惯例和工作流进行相应调整与替换。

遵循“封装变化(Encapsulate What Varies)”原则

软件开发中最常见的挑战之一是应对变化。需求会演进,技术在进步,用户需求也会改变。因此,至关重要的是编写能够适应变化、且不会在整个程序或应用中引发连锁修改的代码。这正是“封装变化”原则发挥作用的地方。

这意味着什么?

该原则背后的思想很直接:将最可能发生变化的代码部分隔离并加以封装。这样,你就为这些易变元素建立了一道“防护墙”,从而保护其余代码不受影响。通过这种封装,你可以在系统的一处进行修改,而不必波及其他部分。

好处

封装变化带来多方面收益,主要包括:

  • 易于维护:需要变更时,只需修改被封装的部分,降低在其他位置引入缺陷的风险。
  • 灵活性增强:被封装的组件可以轻松替换或扩展,使架构更具适应性。
  • 可读性提升:将可变要素隔离后,代码更有条理,也更易理解。

实现封装的技术

正如我们介绍的,封装有助于隐藏数据,仅暴露必要的功能。这里将介绍在 Python 中增强封装的关键技术:多态(polymorphism)以及Getter/Setter 技术

多态(Polymorphism)

在编程中,多态允许不同类的对象被当作同一父类的对象来对待。它是面向对象编程(OOP)的核心概念之一,使得一个统一的接口可以表示不同的类型。多态有助于实现诸如策略模式等优雅的设计模式,也是编写整洁、可维护 Python 代码的有效途径。

Getter 与 Setter

这是类中的特殊方法,用于对属性值进行受控访问:getter 用于读取属性值,setter 用于修改属性值。通过这些方法,你可以加入校验逻辑或副作用(例如日志记录),从而遵循封装原则。它们提供了一种控制并保护对象状态的方式,尤其适用于封装由其他实例变量派生出的复杂属性。

此外,为补充 Getter/Setter 技术,Python 还提供了一种更优雅的方式——property(属性)技术。这是 Python 的内置特性,允许将属性访问无缝转换为方法调用。借助属性,你可以确保对象的内部状态免受不正确或有害的操作影响,而无需显式定义 getter 和 setter 方法。

@property 装饰器允许你定义一个在访问属性时自动调用的方法,相当于 getter。同样,@属性名.setter 装饰器允许你定义一个在尝试修改属性值时被调用的方法,相当于 setter。通过这种方式,你可以将校验或其他动作直接嵌入这些方法中,使代码更整洁。

使用 property 技术,你可以像传统 Getter/Setter 一样实现数据封装与校验,但更符合 Python 的设计哲学。它让你的代码不仅可用,而且简洁易读,同时提升封装程度与整体代码质量。

接下来,我们通过示例更好地理解这些技术。

示例——使用多态进行封装

多态是实现可变行为封装的有力方式。以下以支付处理系统为例,假设支付方式是可变的,那么你可以将每种支付方式封装到各自的类中:

首先定义支付方式的基类,提供一个 process_payment() 方法,每个具体的支付方式将实现该方法。我们在此处封装“会变化的部分”——支付处理逻辑。代码如下:

class PaymentBase:
    def __init__(self, amount: int):
        self.amount: int = amount

    def process_payment(self):
        pass

接着,引入 CreditCardPayPal 两个类,继承自 PaymentBase,并各自实现 process_payment。这是经典的多态用法,因为你可以把 CreditCardPayPal 对象当作它们的共同父类实例来处理。代码如下:

class CreditCard(PaymentBase):
    def process_payment(self):
        msg = f"Credit card payment: {self.amount}"
        print(msg)

class PayPal(PaymentBase):
    def process_payment(self):
        msg = f"PayPal payment: {self.amount}"
        print(msg)

为了测试上述类,加入一些代码,对每个对象调用 process_payment()。使用这些类时,多态的魅力便一目了然:

if __name__ == "__main__":
    payments = [CreditCard(100), PayPal(200)]
    for payment in payments:
        payment.process_payment()

完整代码(ch01/encapsulate.py)如下:

class PaymentBase:
    def __init__(self, amount: int):
        self.amount: int = amount

    def process_payment(self):
        pass

class CreditCard(PaymentBase):
    def process_payment(self):
        msg = f"Credit card payment: {self.amount}"
        print(msg)

class PayPal(PaymentBase):
    def process_payment(self):
        msg = f"PayPal payment: {self.amount}"
        print(msg)

if __name__ == "__main__":
    payments = [CreditCard(100), PayPal(200)]
    for payment in payments:
        payment.process_payment()

测试命令:

python3.12 ch01/encapsulate.py

你应能看到如下输出:

Credit card payment: 100
PayPal payment: 200

如你所见,当支付方式变化时,程序依然能够产出预期结果。通过封装“会变化的部分”——这里是支付方式——你可以轻松添加新选项或修改现有选项,而不影响核心的支付处理逻辑。

示例——使用 property(属性)进行封装

下面定义一个 Circle 类,演示如何使用 Python 的 @property 技术为其 radius(半径)属性创建 getter 与 setter。注意,底层实际属性名为 _radius,它被名为 radius 的 property 所“隐藏/保护”。

我们按步骤编写代码:

首先定义 Circle 类及其初始化方法,在其中初始化 _radius 属性:

class Circle:
    def __init__(self, radius: int):
        self._radius: int = radius

添加 radius 属性:定义一个 radius() 方法返回底层属性值,并使用 @property 进行装饰,代码如下:

    @property
    def radius(self):
        return self._radius

添加 radius 的 setter:再定义一个同名 radius() 方法,先进行校验(例如不允许负值),通过 @radius.setter 进行装饰,代码如下:

    @radius.setter
    def radius(self, value: int):
        if value < 0:
            raise ValueError("Radius cannot be negative!")
        self._radius = value

最后,添加几行代码便于测试该类:

if __name__ == "__main__":
    circle = Circle(10)
    print(f"Initial radius: {circle.radius}")
    circle.radius = 15
    print(f"New radius: {circle.radius}")

完整代码(ch01/encapsulate_bis.py)如下:

class Circle:
    def __init__(self, radius: int):
        self._radius: int = radius

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value: int):
        if value < 0:
            raise ValueError("Radius cannot be negative!")
        self._radius = value

if __name__ == "__main__":
    circle = Circle(10)
    print(f"Initial radius: {circle.radius}")
    circle.radius = 15
    print(f"New radius: {circle.radius}")

测试命令:

python3.12 ch01/encapsulate_bis.py

你应能看到如下输出:

Initial radius: 10
New radius: 15

在第二个示例中,我们看到如何封装圆的半径这一组件,从而在需要时改变技术细节而不破坏类的对外行为。例如,setter 的校验逻辑可以演进;甚至即便更改底层属性 _radius,对使用者而言的行为也将保持不变。

遵循“多用组合、少用继承(Favor Composition Over Inheritance)”原则

在面向对象编程(OOP)中,人们很容易通过继承构建复杂的类层次。继承固然有其优点,但也可能导致代码高度耦合、难以维护和扩展。因此,“多用组合、少用继承”原则应运而生。

这意味着什么?

该原则建议:与其从基类继承功能,不如用更简单的部件组合来构建对象。换言之,用多个简单对象的组合来搭建复杂对象。

好处

选择组合而非继承具有多项优势:

  • 灵活性:组合允许在运行时改变对象行为,使代码更具适应性。
  • 可复用性:更小、更简单的对象可以在应用的不同部分复用,促进代码复用。
  • 易维护:采用组合时,可以轻松替换或更新单个组件,而不影响整体系统,避免连带影响。

组合的技术

在 Python 中,组合通常通过在一个类中包含其他类的实例来实现。这有时被称为“has-a(拥有)关系”,即被组合的类与所包含的类之间的关系。Python 的动态特性使组合更加容易——不需要显式类型声明。你可以在类的 __init__ 方法中直接实例化其他对象,或通过参数传入它们。

示例——用引擎组合出汽车

在 Python 中,你可以通过在类中包含其他类的实例来使用组合。例如,考虑一个 Car 类,其中包含一个 Engine 类的实例。

先定义 Engine 类及其 start 方法:

class Engine:
    def start(self):
        print("Engine started")

然后按如下方式定义 Car 类:

class Car:
    def __init__(self):
        self.engine = Engine()

    def start(self):
        self.engine.start()
        print("Car started")

最后,添加以下代码,在程序执行时创建 Car 的实例并调用其 start 方法:

if __name__ == "__main__":
    my_car = Car()
    my_car.start()

完整代码(ch01/composition.py)如下:

class Engine:
    def start(self):
        print("Engine started")

class Car:
    def __init__(self):
        self.engine = Engine()

    def start(self):
        self.engine.start()
        print("Car started")

if __name__ == "__main__":
    my_car = Car()
    my_car.start()

测试命令:

python3.12 ch01/composition.py

你应能看到如下输出:

Engine started
Car started

如本例所示,Car 类通过 self.engine = Engine() 组合了一个 Engine 对象,因此你可以轻松将引擎替换为另一种类型,而无需修改 Car 类本身。

遵循“面向接口而非实现(Program to Interfaces, Not Implementations)”原则

在软件设计中,人们很容易沉迷于功能“如何实现”的细枝末节。然而,过度关注实现细节会让代码高度耦合、难以修改。“面向接口而非实现编程”这一原则正是为解决此类问题而提出的。

这意味着什么?

接口为类定义了一种“契约”,规定了必须实现的一组方法。
该原则鼓励我们针对接口编程,而不是针对具体类编程。这样,你的代码就不会与提供特定行为的具体类绑定在一起,从而更易于在不影响系统其余部分的情况下替换或扩展实现。

好处

面向接口编程具有多项益处:

  • 灵活性:可以在不修改调用方代码的前提下,轻松切换不同实现。
  • 可维护性:将代码从具体实现中解耦,便于更新或替换组件。
  • 可测试性:接口使单元测试更容易,因为在测试时可以轻松对接口进行 mock。

在 Python 中实现接口的技术

在 Python 中,实现“接口”的主要方式有两种:抽象基类(ABCs)协议(Protocols)

抽象基类(ABCs)

abc 模块提供的 ABC 允许你定义抽象方法,任何具体(非抽象)子类都必须实现这些方法。
下面通过示例演示如何定义一个抽象类(用作接口)并使用它。

首先,按如下方式导入 ABC 类与 abstractmethod 装饰器:

from abc import ABC, abstractmethod

然后,定义接口类:

class MyInterface(ABC):
    @abstractmethod
    def do_something(self, param: str):
        pass

接着,定义实现该接口的具体类;它继承自接口类,并为 do_something 提供实现:

class MyClass(MyInterface):
    def do_something(self, param: str):
        print(f"Doing something with: '{param}'")

加入测试代码:

if __name__ == "__main__":
    MyClass().do_something("some param")

完整代码(ch01/abstractclass.py)如下:

from abc import ABC, abstractmethod

class MyInterface(ABC):
    @abstractmethod
    def do_something(self, param: str):
        pass

class MyClass(MyInterface):
    def do_something(self, param: str):
        print(f"Doing something with: '{param}'")

if __name__ == "__main__":
    MyClass().do_something("some param")

测试命令:

python3.12 ch01/abstractclass.py

预期输出:

Doing something with: 'some param'

至此,你已经了解了如何在 Python 中定义一个接口以及实现该接口的具体类。

协议(Protocols)

在 Python 3.8 中通过 typing 模块引入,协议提供了一种比 ABC 更灵活的方式,即结构化鸭子类型(structural duck typing) :只要对象“拥有某些属性或方法”,就被视为满足协议,而无需特定的继承关系。
与传统“鸭子类型”仅在运行时判定兼容性不同,结构化鸭子类型允许在“编译时”(静态类型检查阶段)进行类型检查——例如在 IDE 中即可提前发现类型错误,从而让程序更健壮、更易调试。

使用协议的关键优势在于:它关注对象能做什么,而不是对象是什么。换言之,“看起来像鸭子、叫起来像鸭子,它就是鸭子”,与其继承层次无关。这一点对动态类型语言 Python 尤为实用,因为对象的行为往往比其实际类型更重要。

例如,你可以定义一个需要 draw() 方法的 Drawable 协议。任何实现了该方法的类都隐式满足该协议,无需显式继承。
再看一个更简短的例子:定义一个需要 fly() 方法的 Flyer 协议:

from typing import Protocol

class Flyer(Protocol):
    def fly(self) -> None:
        ...

到此为止!任何拥有 fly() 方法的类都被视为 Flyer,无论它是否显式继承自 Flyer。这让代码更通用、更可复用,也与前文“多用组合、少用继承”的原则相契合。稍后我们会看到协议的一个实际用例。

示例——不同类型的日志记录器(使用 ABCs)

我们用 ABC 创建一个日志接口,以适配不同的日志机制,实现如下:

导入所需内容:

from abc import ABC, abstractmethod

定义带有 log 方法的 Logger 接口:

class Logger(ABC):
    @abstractmethod
    def log(self, message: str):
        pass

实现两个具体的日志记录器,分别输出到控制台和文件:

class ConsoleLogger(Logger):
    def log(self, message: str):
        print(f"Console: {message}")

class FileLogger(Logger):
    def log(self, message: str):
        with open("log.txt", "a") as f:
            f.write(f"File: {message}\n")

定义一个使用日志器的函数:

def log_message(logger: Logger, message: str):
    logger.log(message)

注意:该函数的第一个参数类型是 Logger,也即任一实现了 Logger 接口的具体类实例(ConsoleLoggerFileLogger)。

加入测试代码:

if __name__ == "__main__":
    log_message(ConsoleLogger(), "A console log.")
    log_message(FileLogger(), "A file log.")

完整代码(ch01/interfaces.py)如下:

from abc import ABC, abstractmethod

class Logger(ABC):
    @abstractmethod
    def log(self, message: str):
        pass

class ConsoleLogger(Logger):
    def log(self, message: str):
        print(f"Console: {message}")

class FileLogger(Logger):
    def log(self, message: str):
        with open("log.txt", "a") as f:
            f.write(f"File: {message}\n")

def log_message(logger: Logger, message: str):
    logger.log(message)

if __name__ == "__main__":
    log_message(ConsoleLogger(), "A console log.")
    log_message(FileLogger(), "A file log.")

测试命令:

python3.12 ch01/interfaces.py

预期输出:

Console: A console log.

此外,在运行命令的目录下,会生成名为 log.txt 的文件,内容包含:

File: A file log.

如同 log_message 函数所示,你可以在无需修改函数本身的情况下,轻松在不同日志机制之间切换。

示例——仍是日志记录器,但改用 Protocols

我们用协议方式重写上述示例:

首先导入 Protocol

from typing import Protocol

通过继承 Protocol 定义 Logger 接口:

class Logger(Protocol):
    def log(self, message: str):
        ...

其余代码保持不变。完整代码(ch01/interfaces_bis.py)如下:

from typing import Protocol

class Logger(Protocol):
    def log(self, message: str):
        ...

class ConsoleLogger:
    def log(self, message: str):
        print(f"Console: {message}")

class FileLogger:
    def log(self, message: str):
        with open("log.txt", "a") as f:
            f.write(f"File: {message}\n")

def log_message(logger: Logger, message: str):
    logger.log(message)

if __name__ == "__main__":
    log_message(ConsoleLogger(), "A console log.")
    log_message(FileLogger(), "A file log.")

使用我们定义的协议进行静态类型检查

mypy ch01/interfaces_bis.py

预期输出:

Success: no issues found in 1 source file

运行程序:

python3.12 ch01/interfaces_bis.py

其输出与前一版本相同;同样会生成 log.txt 文件,并在终端打印:

Console: A console log.

这是正常的,因为我们只改变了接口的定义方式。协议带来的约束仅在静态检查阶段生效,运行时不会强制实施,因此不会改变代码的实际执行结果。

遵循“松散耦合(Loose Coupling)”原则

随着软件复杂度增长,组件之间的关系可能变得纠缠不清,导致系统难以理解、维护和扩展。“松散耦合”原则旨在缓解这一问题。

这意味着什么?

松散耦合是指尽量减少程序各部分之间的依赖。在松散耦合的系统中,组件彼此独立,并通过清晰定义的接口进行交互,这样当你修改某一部分时,不会轻易影响到其他部分。

好处

松散耦合带来多重优势:

  • 可维护性:依赖更少,更易于更新或替换单个组件。
  • 可扩展性:更容易向系统中添加新特性或新组件。
  • 可测试性:独立组件更容易进行隔离测试,从而提升软件整体质量。

实现松散耦合的技术

实现松散耦合的两项主要技术是依赖注入(Dependency Injection)观察者模式(Observer Pattern)
依赖注入让组件从外部获取其依赖,而非在内部自行创建,从而更易替换或 mock 这些依赖。观察者模式则允许对象发布其状态变化,让其他对象做出响应,而无需彼此紧密绑定。二者的共同目标都是减少组件间的相互依赖,使系统更加模块化、易于管理。

我们将在第 5 章《行为型设计模式》中详细讨论观察者模式。现在先通过一个示例理解如何使用依赖注入。

示例——消息服务(Message Service)

在 Python 中,可以通过依赖注入实现松散耦合。下面是一个涉及 MessageService 的简单示例。

首先,定义 MessageService 类:

class MessageService:
    def __init__(self, sender):
        self.sender = sender

    def send_message(self, message):
        self.sender.send(message)

如上所示,初始化该类时需传入一个 sender 对象;该对象具有 send 方法用于发送消息。

第二步,定义 EmailSender 类:

class EmailSender:
    def send(self, message):
        print(f"Sending email: {message}")

第三步,定义 SMSSender 类:

class SMSSender:
    def send(self, message):
        print(f"Sending SMS: {message}")

现在我们可以用 EmailSender 实例化 MessageService 并发送消息;也可以改为用 SMSSender。测试代码如下:

if __name__ == "__main__":
    email_service = MessageService(EmailSender())
    email_service.send_message("Hello via Email")

    sms_service = MessageService(SMSSender())
    sms_service.send_message("Hello via SMS")

完整代码(保存为 ch01/loose_coupling.py)如下:

class MessageService:
    def __init__(self, sender):
        self.sender = sender

    def send_message(self, message: str):
        self.sender.send(message)

class EmailSender:
    def send(self, message: str):
        print(f"Sending email: {message}")

class SMSSender:
    def send(self, message: str):
        print(f"Sending SMS: {message}")

if __name__ == "__main__":
    email_service = MessageService(EmailSender())
    email_service.send_message("Hello via Email")

    sms_service = MessageService(SMSSender())
    sms_service.send_message("Hello via SMS")

测试命令:

python3.12 ch01/loose_coupling.py

预期输出:

Sending email: Hello via Email
Sending SMS: Hello via SMS

在该示例中,MessageService 通过依赖注入与 EmailSenderSMSSender 保持松散耦合。这样就能在不修改 MessageService的情况下,轻松切换不同的发送机制。

总结

本书从开发者在编写可维护、灵活且健壮的软件时应遵循的基础设计原则入手。从封装变化多用组合面向接口编程,以及追求松散耦合,这些原则为任何 Python 开发者奠定了坚实的基础。

正如你所见,这些原则并非纯理论构想,而是能显著提升代码质量的实用准则。它们为接下来的内容铺垫了舞台:深入探讨指导面向对象设计的更专业的一组原则。

在下一章,我们将深入 SOLID 原则——这五条设计准则旨在使软件设计更易理解、更灵活且更易维护。