Skip to content

Latest commit

 

History

History
802 lines (573 loc) · 63 KB

File metadata and controls

802 lines (573 loc) · 63 KB

九、通用设计模式

设计模式在著名的四人帮GoF)一书设计模式:可重用面向对象软件的元素中诞生以来,一直是软件工程中一个广泛的话题。设计模式有助于解决针对特定场景的抽象的常见问题。当它们被正确地实现时,解决方案的总体设计可以从中受益。

在本章中,我们将介绍一些最常见的设计模式,但不是从在特定条件下(一旦设计了模式)应用工具的角度,而是分析设计模式如何有助于干净的代码。在介绍实现设计模式的解决方案之后,我们将分析最终实现如何比选择不同的路径更好。

作为本分析的一部分,我们将看到如何在 Python 中具体实现设计模式。因此,我们将看到 Python 的动态特性意味着实现与其他静态类型语言(许多设计模式最初都是针对这些语言设计的)存在一些差异。这意味着当涉及到 Python 时,您应该记住设计模式的一些特殊性,并且在某些情况下,尝试在不适合的地方应用设计模式是不符合 Python 的。

在本章中,我们将介绍以下主题:

  • 通用设计模式
  • Python 中不适用的设计模式,以及应遵循的惯用替代方案
  • 实现最常见设计模式的 python 方法
  • 理解好的抽象是如何自然演变成模式的

有了前几章的知识,我们现在能够在更高的设计层次上分析代码,同时考虑代码的详细实现(我们如何以最有效地使用 Python 特性的方式编写代码?)。

在本章中,我们将分析如何使用设计模式来实现更干净的代码,首先在下一节中分析一些初始注意事项。

Python 中的设计模式注意事项

面向对象设计模式是在我们处理所解决的问题的模型时,在不同的场景中出现的软件构造思想。因为它们是高级概念,所以很难将它们与特定的编程语言联系起来。相反,它们是关于对象在应用程序中如何交互的更一般的概念。当然,它们会有不同语言的实现细节,但这并不构成设计模式的本质。

这是设计模式的理论方面,它是一个抽象的概念,表达了解决方案中对象布局的概念。关于面向对象设计,特别是设计模式,还有很多其他书籍和其他资源,因此在本书中,我们将重点介绍 Python 的实现细节。

鉴于 Python 的特性,实际上并不需要一些经典的设计模式。这意味着 Python 已经支持使这些模式不可见的特性。有些人认为它们在 Python 中不存在,但请记住,不可见并不意味着不存在。它们就在那里,只是嵌入到 Python 本身中,所以我们甚至可能不会注意到它们。

另一些平台的实现要简单得多,这也要归功于该语言的动态特性,而其余的平台与其他平台几乎相同,只是略有不同。

无论如何,在 Python 中实现干净代码的重要目标是知道要实现什么模式以及如何实现。这意味着认识到 Python 已经抽象的一些模式,以及我们如何利用它们。例如,尝试实现迭代器模式的标准定义(正如我们在不同语言中所做的那样)是完全不符合 Python 的,因为(正如我们已经介绍过的)迭代深深地嵌入在 Python 中,而且我们可以创建直接在for中工作的对象循环使这成为正确的继续方式。

一些创造模式也发生了类似的事情。类是 Python 中的常规对象,函数也是。到目前为止,我们已经在几个示例中看到,它们可以被传递、装饰、重新分配等等。这意味着,无论我们希望对对象进行何种定制,我们都可以在不需要任何特定工厂类设置的情况下进行。此外,在 Python 中没有创建对象的特殊语法(例如,没有new关键字)。这也是为什么在大多数情况下,一个简单的函数调用会像工厂一样工作的另一个原因。

其他模式仍然需要,我们将看到,通过一些小的修改,我们可以充分利用语言提供的功能(魔术方法或标准库),使它们更具 python 风格。

在所有可用的模式中,并非所有模式都是同样频繁的,也不是同样有用的,因此我们将重点关注主要的模式,即我们希望在应用程序中看到最多的模式,我们将遵循务实的方法来实现这一点。

实用的设计模式

本主题中的规范参考文献由 GoF 编写,介绍了 23 种设计模式,每种模式都属于创作、结构和行为类别之一。现有模式甚至有更多的模式或变体,但我们不应该背诵所有这些模式,而应该专注于记住两件事。有些模式在 Python 中是不可见的,我们使用它们可能甚至没有注意到。第二,并非所有模式都相同;其中一些是非常有用的,因此它们经常被发现,而另一些则用于更具体的情况。

在本节中,我们将回顾最常见的模式,即最有可能从我们的设计中出现的模式。注意这里使用了这个词。我们不应该强制将设计模式应用到我们正在构建的解决方案中,而是应该不断发展、重构和改进我们的解决方案,直到模式出现。

因此,设计模式不是发明出来的,而是被发现的。当代码中反复出现的情况暴露出来时,类、对象和相关组件的一般和更抽象的布局就会出现在我们用来标识模式的名称下。

设计模式的名称包含了许多概念。这可能是设计模式最好的地方;它们提供了一种语言。通过设计模式,更容易有效地传达设计思想。当两个或两个以上的软件工程师共享相同的词汇表,其中一个提到策略时,房间里的其他软件工程师可以立即思考所有的类,以及它们之间的关系,它们的机制是什么,等等,而不必重复这个解释。

读者会注意到,本章中显示的代码与所讨论的设计模式的规范或原始设想不同。原因不止一个。第一个原因是,这些例子采取了更务实的方法,针对特定场景的解决方案,而不是探索一般的设计理论。第二个原因是模式是用 Python 的特殊性实现的,在某些情况下非常微妙,但在其他情况下,差异是显而易见的,通常简化了代码。

创建模式

在软件工程中,创建模式是那些处理对象实例化的模式,试图将抽象掉大部分复杂性(如确定初始化对象的参数、可能需要的所有相关对象等),为了给用户留下一个更简单、更安全的界面。对象创建的基本形式可能会导致设计问题或增加设计的复杂性。创造性设计模式通过某种方式控制对象的创建来解决这个问题。

在创建对象的五种模式中,我们将主要讨论用于避免 singleton 模式并将其替换为 Borg 模式(最常用于 Python 应用程序)的变体,并讨论它们的区别和优势。

工厂

正如引言中提到的,Python 的核心特性之一是,一切都是一个对象,因此,它们都可以被平等对待。这意味着对于类、函数或自定义对象,我们可以或不能做的事情没有特殊的区别。它们都可以通过参数传递、赋值等等。

正是由于这个原因,许多工厂模式通常不需要。我们可以简单地定义一个函数来构造一组对象,甚至可以通过一个参数传递我们想要创建的类。

当我们使用pyinject作为库来帮助我们进行依赖项注入和复杂对象的初始化时,我们看到了一种工厂的例子。在需要处理复杂设置的情况下,我们希望确保使用依赖项注入来初始化对象,而不重复我们自己,我们可以使用库,如pyinject或在代码中提出类似的结构。

单态和共享态(单态)

另一方面,单例模式不是 Python 完全抽象出来的。事实是,大多数时候,这种模式不是真的需要,就是一个糟糕的选择。单例有很多问题(毕竟,它们实际上是面向对象软件的一种全局变量,因此是一种糟糕的做法)。它们很难进行单元测试,任何对象都可能随时对它们进行修改,这一事实使它们很难预测,而且它们的副作用可能会带来问题。

作为一般原则,我们应该尽可能避免使用单例。如果在某些极端情况下需要它们,那么在 Python 中实现这一点的最简单方法是使用模块。我们可以在一个模块中创建一个对象,一旦它到了那里,就可以从导入的模块的每个部分使用它。Python 本身确保模块已经是单例的,从这个意义上说,无论模块被导入多少次,从多少个地方导入,同一个模块始终是要加载到sys.modules中的模块。因此,在这个 Python 模块中初始化的对象将是唯一的。

请注意,这与单例不完全相同。单例的思想是创建一个类,无论您调用它多少次,它都将始终为您提供相同的对象。上一段中提出的想法是关于拥有一个独特的对象。不管它的类是如何定义的,我们只创建一个对象一次,然后多次使用同一个对象。这些有时被称为众所周知的物体;不需要多个同类的对象。

我们对这些物体已经很熟悉了。仔细考虑一下。对于整个 Python 解释器,我们不需要超过一个。一些开发人员声称“None是 Python 中的一个单例。”我有点不同意这种说法。这是一个众所周知的东西:一些我们都知道的东西,我们不需要另一个。TrueFalse也是如此。尝试创建不同类型的布尔值是没有意义的。

共享状态

与其强迫我们的设计有一个只创建一个实例的单例,无论如何调用、构造或初始化对象,不如跨多个实例复制数据。

单态模式(SNGMONO)的思想是,我们可以有许多只是常规对象的实例,而不必关心它们是否是单态(因为它们只是对象)。这种模式的好处是,这些对象将以完全透明的方式同步其信息,而不必担心其内部工作方式。

这使得这种模式成为一种更好的选择,不仅因为它的方便,而且因为它不太容易出错,并且不存在单例的缺点(关于它们的可测试性、创建派生类等等)。

我们可以在许多级别上使用此模式,具体取决于需要同步的信息量。

在其最简单的形式中,我们可以假设只需要一个属性就可以在所有实例中反映出来。如果是这种情况,那么实现就像使用类变量一样简单,我们只需要提供一个正确的接口来更新和检索属性的值。

假设我们有一个对象,它必须pull最新tag版本的Git存储库中的某个代码。该对象可能有多个实例,当每个客户端调用获取代码的方法时,该对象将使用其属性中的tag版本。在任何时候,此tag都可以更新为更新版本,我们希望任何其他实例(新的或已经创建的)在调用fetch操作时使用此新分支,如下代码所示:

class GitFetcher:
    _current_tag = None
    def __init__(self, tag):
        self.current_tag = tag
    @property
    def current_tag(self):
        if self._current_tag is None:
            raise AttributeError("tag was never set")
        return self._current_tag
    @current_tag.setter
    def current_tag(self, new_tag):
        self.__class__._current_tag = new_tag
    def pull(self):
        logger.info("pulling from %s", self.current_tag)
        return self.current_tag 

读者可以简单地验证,创建多个具有不同版本的GitFetcher类型的对象将导致所有对象在任何时候都设置为最新版本,如下代码所示:

>>> f1 = GitFetcher(0.1)
>>> f2 = GitFetcher(0.2)
>>> f1.current_tag = 0.3
>>> f2.pull()
0.3
>>> f1.pull()
0.3 

如果我们需要更多的属性,或者我们希望封装更多的共享属性,以使设计更简洁,我们可以使用描述符。

下面代码中所示的描述符解决了这个问题,虽然它确实需要更多的代码,但它也封装了一个更具体的责任,部分代码实际上从我们原来的类中移开,使它更具内聚性并且符合单一责任原则:

class SharedAttribute:
    def __init__(self, initial_value=None):
        self.value = initial_value
        self._name = None
    def __get__(self, instance, owner):
        if instance is None:
            return self
        if self.value is None:
            raise AttributeError(f"{self._name} was never set")
        return self.value
    def __set__(self, instance, new_value):
        self.value = new_value
    def __set_name__(self, owner, name):
        self._name = name 

除了这些考虑之外,模式现在更加可重用也是事实。如果我们想重复这个逻辑,我们只需要创建一个新的描述符对象即可(符合 DRY 原则)。

如果我们现在想要做同样的事情,但是对于当前分支,我们创建了这个新的 class 属性,并且该类的其余部分保持不变,同时仍然具有所需的逻辑,如下面的代码所示:

class GitFetcher:
    current_tag = SharedAttribute()
    current_branch = SharedAttribute()
    def __init__(self, tag, branch=None):
        self.current_tag = tag
        self.current_branch = branch
    def pull(self):
        logger.info("pulling from %s", self.current_tag)
        return self.current_tag 

现在,这种新方法的平衡和权衡应该已经很清楚了。这个新的实现使用了更多的代码,但它是可重用的,因此从长远来看,它可以节省代码行(和重复的逻辑)。再次参考三个或更多实例规则来决定是否应该创建这样的抽象。

这个解决方案的另一个重要好处是,它还减少了单元测试的重复(因为我们只需要测试SharedAttribute类,而不是测试它的所有用途)。

在这里重用代码将使我们对解决方案的整体质量更有信心,因为现在我们只需要为描述符对象编写单元测试,而不是为所有使用它的类编写单元测试(只要单元测试证明描述符是正确的,我们可以安全地假设它们是正确的)。

博格模式

以前的解决方案应该适用于大多数情况,但如果我们真的必须选择单例(这必须是一个非常好的例外),那么还有最后一个更好的替代方案,只是这是一个风险更高的方案。

这是实际的单状态模式,在 Python 中称为 Borg 模式。其思想是创建一个能够在同一类的所有实例中复制其所有属性的对象。事实上,每一个属性都在被复制,这是一个警告,要记住不希望出现的副作用。尽管如此,这种模式比单例模式有许多优势。

在本例中,我们将前一个对象拆分为两个,一个在Git标记上工作,另一个在分支上工作。我们使用的代码将使博格模式工作:

class BaseFetcher:
    def __init__(self, source):
        self.source = source
class TagFetcher(BaseFetcher):
    _attributes = {}
    def __init__(self, source):
        self.__dict__ = self.__class__._attributes
        super().__init__(source)
    def pull(self):
        logger.info("pulling from tag %s", self.source)
        return f"Tag = {self.source}"
class BranchFetcher(BaseFetcher):
    _attributes = {}
    def __init__(self, source):
        self.__dict__ = self.__class__._attributes
        super().__init__(source)
    def pull(self):
        logger.info("pulling from branch %s", self.source)
        return f"Branch = {self.source}" 

两个对象都有一个基类,共享它们的初始化方法。但是为了使博格逻辑工作,他们必须再次实现它。其思想是我们使用一个类属性,它是一个字典来存储属性,然后我们让每个对象的字典(在初始化时)使用这个字典。这意味着对象字典上的任何更新都将反映在类中,其余对象的类都是相同的,因为它们的类是相同的,字典是作为引用传递的可变对象。换句话说,当我们创建这种类型的新对象时,它们都将使用同一个字典,并且该字典将不断更新。

请注意,我们不能将字典的逻辑放在基类上,因为这将在不同类的对象之间混合值,这不是我们想要的。这种样板解决方案会让很多人认为它实际上是一种习惯用法,而不是一种模式。

以实现 DRY 原则的方式对其进行抽象的一种可能方法是创建一个mixin类,如以下代码所示:

class SharedAllMixin:
    def __init__(self, *args, **kwargs):
        try:
            self.__class__._attributes
        except AttributeError:
            self.__class__._attributes = {}
        self.__dict__ = self.__class__._attributes
        super().__init__(*args, **kwargs)
class BaseFetcher:
    def __init__(self, source):
        self.source = source
class TagFetcher(SharedAllMixin, BaseFetcher):
    def pull(self):
        logger.info("pulling from tag %s", self.source)
        return f"Tag = {self.source}"
class BranchFetcher(SharedAllMixin, BaseFetcher):
    def pull(self):
        logger.info("pulling from branch %s", self.source)
        return f"Branch = {self.source}" 

这一次,我们使用类创建字典,每个类中都有属性,以防它不存在,然后继续使用相同的逻辑。

这个实现不应该在继承方面有任何重大问题,因此它是一个更可行的替代方案。

建设者

生成器模式是一个有趣的模式,它将对象的所有复杂初始化抽象掉。这种模式不依赖于语言的任何特殊性,因此它在 Python 中的适用性与在任何其他语言中的适用性相同。

虽然它解决了一个有效的案例,但它通常也是一个更可能出现在框架、库或 API 设计中的复杂案例。与为描述符提供的建议类似,我们应该将此实现保留为希望公开将由多个用户使用的 API 的情况。

这个模式的高级思想是我们需要创建一个复杂的对象,也就是说,一个需要许多其他人处理的对象。与其让用户创建所有的辅助对象,然后将它们分配给主对象,我们希望创建一个抽象,允许在一个步骤中完成所有这些。为了实现这一点,我们将有一个builder对象,它知道如何创建所有部件并将它们链接在一起,为用户提供一个接口(可以是类方法)来参数化所有关于结果对象应该是什么样子的信息。

结构模式

结构模式在需要创建更简单的接口或对象的情况下非常有用,这些对象通过扩展其功能而不增加其接口的复杂性来实现更强大的功能。

这些模式最好的地方是,我们可以创建更多有趣的对象,增强功能,并且我们可以以干净的方式实现这一点;也就是说,通过组合多个单个对象(最明显的例子是复合模式),或者通过收集许多简单而内聚的接口。

适配器

适配器模式可能是最简单的设计模式之一,同时也是最有用的设计模式之一。

也被称为包装器,该模式解决了调整两个或多个不兼容对象的接口的问题。

我们通常会遇到这样一种情况:我们的部分代码与一个模型或一组类一起工作,这些模型或类相对于一个方法是多态的。例如,如果有多个对象用于使用fetch()方法检索数据,那么我们希望维护此接口,这样就不必对代码进行重大更改。

但是现在我们需要添加一个新的数据源,唉,这个数据源没有fetch()方法。更糟糕的是,这种类型的对象不仅不兼容,而且也不是我们可以控制的(可能是另一个团队决定了 API,我们无法修改代码,或者它是来自外部库的对象)。

我们不直接使用这个对象,而是根据需要调整它的接口。有两种方法可以做到这一点。

第一种方法是创建一个从我们需要的类继承的类,并为该方法创建一个别名(如果需要,它还必须调整参数和签名),这将在内部调整调用,使其与我们需要的方法兼容。

通过继承,我们导入外部类,并创建一个新类来定义新方法,调用具有不同名称的类。在本例中,假设外部依赖项有一个名为search()的方法,该方法只使用一个参数进行搜索,因为它以不同的方式进行查询,因此我们的adapter方法不仅调用外部依赖项,还相应地转换参数,如以下代码所示:

from _adapter_base import UsernameLookup
class UserSource(UsernameLookup):
    def fetch(self, user_id, username):
        user_namespace = self._adapt_arguments(user_id, username)
        return self.search(user_namespace)
    @staticmethod
    def _adapt_arguments(user_id, username):
        return f"{user_id}:{username}" 

利用 Python 支持多重继承这一事实,我们可以使用它来创建适配器(甚至创建一个mixin类,它是一个适配器,正如我们在前面的章节中所看到的)。

然而,正如我们以前多次看到的那样,继承带来了更多的耦合(谁知道有多少其他方法是从外部库携带的?),而且它是不灵活的。从概念上讲,这也不是正确的选择,因为我们为规范的情况保留继承(继承是一种关系),在这种情况下,不清楚我们的对象是否必须是第三方库提供的类型之一(特别是因为我们没有完全理解该对象)。

因此,更好的方法是使用合成。假设我们可以为我们的对象提供一个UsernameLookup的实例,代码将非常简单,只需在采用参数之前重定向请愿,如下代码所示:

class UserSource:
    ...
    def fetch(self, user_id, username):
        user_namespace = self._adapt_arguments(user_id, username)
        return self.username_lookup.search(user_namespace) 

如果我们需要适应多种方法,并且我们也可以设计一种通用的方式来适应它们的签名,那么使用__getattr__()魔术方法将请求重定向到包装对象可能是值得的,但与通用实现一样,我们应该小心不要给解决方案增加更多的复杂性。

__getattr__()的使用可能使我们拥有一种“通用适配器”;通过以通用方式重定向调用,可以包装另一个对象并调整其所有方法的东西。但我们确实应该小心,因为这种方法会产生一些非常普遍的东西,甚至可能会有更高的风险和意想不到的副作用。如果我们想在对象上执行转换或额外功能,同时保持其原始接口,那么装饰器模式是一个更好的选择,我们将在本章后面看到。

混合成的

我们的程序中会有一些部分要求我们处理由其他对象构成的对象。我们有一个定义良好的逻辑的基本对象,然后我们将有其他容器对象,这些容器对象将分组一组基本对象,挑战是我们希望在不注意任何差异的情况下处理它们(基本对象和容器对象)。

对象在树层次结构中结构化,其中基本对象是树的叶子,而组合对象是中间节点。客户机可能希望调用它们中的任何一个来获取所调用方法的结果。但是,复合对象将充当客户机;这还将传递此请求及其包含的所有对象,无论它们是树叶还是其他中间注释,直到它们都被处理。

想象一下一个简化版的在线商店,里面有我们的产品。假设我们提供将这些产品分组的可能性,并且我们为客户提供每组产品的折扣。一个产品有一个价格,当顾客来付款时,就会要求这个价格。但是一组分组产品也有一个要计算的价格。我们将有一个对象来表示这个包含产品的组,并将询问价格的责任委托给每个特定的产品(也可能是另一组产品),以此类推,直到没有其他的计算。

以下代码中显示了此功能的实现:

class Product:
    def __init__(self, name: str, price: float) -> None:
        self._name = name
        self._price = price
    @property
    def price(self):
        return self._price
class ProductBundle:
    def __init__(
        self,
        name: str,
        perc_discount: float,
        *products: Iterable[Union[Product, "ProductBundle"]]
    ) -> None:
        self._name = name
        self._perc_discount = perc_discount
        self._products = products
    @property
    def price(self) -> float:
        total = sum(p.price for p in self._products)
        return total * (1 - self._perc_discount) 

我们通过属性公开了public接口,并将price保留为private属性。ProductBundle类使用此属性计算折扣值,首先将其包含的所有产品的所有价格相加。

这些对象之间的唯一区别在于,它们是使用不同的参数创建的。为了完全兼容,我们应该尝试模拟相同的接口,然后添加额外的方法,将产品添加到捆绑包中,但使用允许创建完整对象的接口。不需要这些额外的步骤是一个优势,证明了这一微小差异的合理性。

室内装修设计师

不要将装饰器模式与 Python 装饰器的概念混淆,我们已经在第 5 章中介绍了,使用装饰器改进代码。虽然有一些相似之处,但设计模式的理念却大不相同。

此模式允许我们动态扩展某些对象的功能,而无需继承。在创建更灵活的对象时,它是多重继承的一个很好的替代方法。

我们将创建一个结构,允许用户定义一组要应用于对象的操作(装饰),我们将看到每个步骤是如何按指定顺序进行的。

下面的代码示例是一个对象的简化版本,该对象通过传递给它的参数以字典的形式构造查询(例如,它可能是一个我们将用于运行对Elasticsearch的查询的对象,但代码忽略了分散注意力的实现细节,而将重点放在模式的概念上)。

在最基本的形式中,查询只返回字典以及创建字典时提供的数据。客户希望使用此对象的render()方法:

class DictQuery:
    def __init__(self, **kwargs):
        self._raw_query = kwargs
    def render(self) -> dict:
        return self._raw_query 

现在,我们希望通过对数据应用转换(过滤值、规范化值等),以不同的方式呈现查询。我们可以创建 decorator 并将其应用于render方法,但这不够灵活,如果我们想在运行时更改它们呢?或者如果我们想选择其中一些,但不选择其他?

设计是创建另一个对象,具有相同的界面和通过许多步骤增强(装饰)原始结果的能力,但可以组合。这些对象是链接的,它们中的每一个都执行它最初应该执行的操作,以及其他操作。这是一个特殊的装饰步骤。

由于 Python 有 duck 类型,我们不需要创建新的基类,也不需要将这些新对象与DictQuery一起作为该层次结构的一部分。简单地创建一个具有render()方法的新类就足够了(同样,多态性不需要继承)。此过程如下代码所示:

class QueryEnhancer:
    def __init__(self, query: DictQuery):
        self.decorated = query
    def render(self):
        return self.decorated.render()
class RemoveEmpty(QueryEnhancer):
    def render(self):
        original = super().render()
        return {k: v for k, v in original.items() if v}
class CaseInsensitive(QueryEnhancer):
    def render(self):
        original = super().render()
        return {k: v.lower() for k, v in original.items()} 

QueryEnhancer短语有一个与DictQuery客户端所期望的内容兼容的接口,因此它们是可互换的。此对象设计用于接收装饰对象。它将从中获取值并进行转换,返回代码的修改版本。

如果我们想要删除所有计算为False的值并将其规范化以形成原始查询,我们必须使用以下模式:

>>> original = DictQuery(key="value", empty="", none=None, upper="UPPERCASE", title="Title")
>>> new_query = CaseInsensitive(RemoveEmpty(original))
>>> original.render()
{'key': 'value', 'empty': '', 'none': None, 'upper': 'UPPERCASE', 'title': 'Title'}
>>> new_query.render()
{'key': 'value', 'upper': 'uppercase', 'title': 'title'} 

这是一个模式,我们也可以用不同的方式实现,利用 Python 的动态特性,以及函数是对象这一事实。我们可以使用提供给基本装饰器对象(QueryEnhancer的函数来实现此模式,并将每个装饰步骤定义为一个函数,如下代码所示:

class QueryEnhancer:
    def __init__(
        self,
        query: DictQuery,
        *decorators: Iterable[Callable[[Dict[str, str]], Dict[str, str]]]
    ) -> None:
        self._decorated = query
        self._decorators = decorators
    def render(self):
        current_result = self._decorated.render()
        for deco in self._decorators:
            current_result = deco(current_result)
        return current_result 

对于客户端,没有任何更改,因为此类通过其render()方法维护兼容性。但是,在内部,该对象的使用方式略有不同,如下代码所示:

>>> query = DictQuery(foo="bar", empty="", none=None, upper="UPPERCASE", title="Title")
>>> QueryEnhancer(query, remove_empty, case_insensitive).render()
{'foo': 'bar', 'upper': 'uppercase', 'title': 'title'} 

在前面的代码中,remove_emptycase_insensitive只是转换字典的常规函数。

在本例中,基于函数的方法似乎更容易理解。在某些情况下,可能会有更复杂的规则依赖于来自被修饰对象的数据(不仅仅是其结果),在这些情况下,可能值得采用面向对象的方法,特别是如果我们真的想创建一个对象层次结构,其中每个类实际上代表我们希望在设计中明确的一些知识。

外观

立面是的优秀图案。它在许多情况下非常有用,因为我们希望简化对象之间的交互。该模式应用于多个对象之间存在多对多关系的地方,我们希望它们相互作用。我们没有创建所有这些连接,而是在它们前面放置一个中间对象,作为外观。

立面在此布局中作为一个枢纽或单个参照点。每当一个新对象想要连接到另一个对象时,它不必为所有需要连接的N可能的对象提供N接口(需要O(N2总连接),而只需与门面对话,这将相应地重定向请求。立面后面的所有东西对于其他外部对象都是完全不透明的。

**除了主要和明显的好处(对象的解耦)之外,该模式还鼓励使用更少的接口和更好的封装进行更简单的设计。

这是一种模式,我们不仅可以用来改进领域问题的代码,还可以用来创建更好的 API。如果我们使用这个模式并提供一个单一的界面,作为我们代码的一个真实点或入口点,我们的用户将更容易与公开的功能进行交互。不仅如此,通过公开功能并将所有内容隐藏在接口后面,我们可以随意更改或重构底层代码,因为只要它在外观后面,它就不会破坏向后兼容性,我们的用户也不会受到影响。

请注意,使用 facades 的想法甚至不局限于对象和类,还适用于包(从技术上讲,包是 Python 中的对象,但仍然是)。我们可以利用立面的这种想法来决定包装的布局;也就是说,哪些内容对用户可见且可导入,哪些内容是内部的且不应直接导入。

当我们创建一个目录来构建一个包时,我们将__init__.py文件与其他文件放在一起。这是模块的根,一种外观。其余文件定义了要导出的对象,但客户端不应直接导入这些对象。__init__.py文件应该导入它们,然后客户端应该从那里获取它们。这创建了一个更好的界面,因为用户只需要知道从哪个入口点获取对象,更重要的是,包(其余文件)可以根据需要多次重构或重新排列,只要维护了init文件上的主 API,这就不会影响客户机。为了构建可维护的软件,记住这样的原则是非常重要的。

Python 本身就有这样一个例子,os模块。此模块对操作系统的功能进行分组,但在其下面,使用便携式操作系统接口POSIX)操作系统的posix模块(在 Windows 平台上称为nt。这个想法是,出于可移植性的原因,我们不应该直接导入posix模块,而应该始终导入os模块。由该模块决定从哪个平台调用它,并公开相应的功能。

行为模式

行为模式旨在解决对象应该如何协作、它们应该如何通信以及它们在运行时的接口应该是什么的问题。

我们主要讨论以下行为模式:

  • 责任链
  • 模板法
  • 命令
  • 状态

这可以通过继承静态地完成,也可以使用组合动态地完成。无论模式使用什么,我们将在下面的示例中看到的是,这些模式的共同点是,生成的代码在某些重要方面更好,无论这是因为它避免了重复,还是因为它创建了良好的抽象,从而封装了相应的行为并解耦了我们的模型。

责任链

现在我们将再看一看我们的事件系统。我们希望从日志行解析系统上发生的事件的信息(例如,从 HTTP 应用服务器转储的文本文件),并希望以一种方便的方式提取这些信息。

在我们之前的实现中,我们实现了一个有趣的解决方案,该解决方案符合打开/关闭原则,并依赖于使用__subclasses__()魔术方法来发现所有可能的事件类型,并使用正确的事件处理数据,通过封装在每个类上的方法解决责任。

这个解决方案符合我们的目的,并且非常可扩展,但是正如我们将看到的,这个设计模式将带来额外的好处。

这里的想法是,我们将以稍微不同的方式创建事件。每个事件仍然具有确定其是否可以处理特定日志行的逻辑,但它也将具有一个successor。这successor是一个新事件,是行中的下一个事件,如果第一个事件无法处理,它将继续处理文本行。逻辑很简单,我们将事件链接起来,每个事件都试图处理数据。如果可以,那么它只返回结果。如果不能,则将其传递给其successor和重复,如下代码所示:

import re
from typing import Optional, Pattern
class Event:
    pattern: Optional[Pattern[str]] = None
    def __init__(self, next_event=None):
        self.successor = next_event
    def process(self, logline: str):
        if self.can_process(logline):
            return self._process(logline)
        if self.successor is not None:
            return self.successor.process(logline)
    def _process(self, logline: str) -> dict:
        parsed_data = self._parse_data(logline)
        return {
            "type": self.__class__.__name__,
            "id": parsed_data["id"],
            "value": parsed_data["value"],
        }
    @classmethod
    def can_process(cls, logline: str) -> bool:
        return (
            cls.pattern is not None and cls.pattern.match(logline) is not None
        )
    @classmethod
    def _parse_data(cls, logline: str) -> dict:
        if not cls.pattern:
            return {}
        if (parsed := cls.pattern.match(logline)) is not None:
            return parsed.groupdict()
        return {}
class LoginEvent(Event):
    pattern = re.compile(r"(?P<id>\d+):\s+login\s+(?P<value>\S+)")
class LogoutEvent(Event):
    pattern = re.compile(r"(?P<id>\d+):\s+logout\s+(?P<value>\S+)") 

通过这个实现,我们创建了event对象,并按照处理它们的特定顺序排列它们。因为它们都有一个process()方法,所以对于这个消息,它们是多态的,所以它们对齐的顺序对客户端来说是完全透明的,并且它们中的任何一个都是透明的。不仅如此,process()方法也有同样的逻辑;如果提供的数据对于处理它的对象类型正确,它将尝试提取信息,如果不正确,它将转到行中的下一个。

这样,我们可以通过以下方式处理登录事件:

>>> chain = LogoutEvent(LoginEvent())
>>> chain.process("567: login User")
{'type': 'LoginEvent', 'id': '567', 'value': 'User'} 

注意LogoutEvent是如何接收LoginEvent作为其继承者的,当它被要求处理它无法处理的时,它重定向到正确的对象。从字典上的type键可以看出,LoginEvent才是真正创建字典的人。

此解决方案足够灵活,并且与前一个解决方案有一个有趣的特点—所有条件都是相互排斥的。只要不存在冲突,并且没有数据段具有多个处理程序,以任何顺序处理事件都不会成为问题。

但如果我们不能做出这样的假设呢?在之前的实现中,我们仍然可以更改__subclasses__()调用,以根据我们的标准创建一个列表,这将非常有效。如果我们希望在运行时确定优先级顺序(例如,由用户或客户机确定),该怎么办?这将是一个缺点。

有了新的解决方案,就有可能完成这样的需求,因为我们在运行时组装链,这样我们就可以根据需要动态地操作它。

例如,现在我们添加了一个通用类型,它对登录和注销会话事件进行分组,如以下代码所示:

class SessionEvent(Event):
    pattern = re.compile(r"(?P<id>\d+):\s+log(in|out)\s+(?P<value>\S+)") 

如果出于某种原因,在应用程序的某些部分,我们希望在登录事件之前捕获此信息,可以通过以下链完成此操作:

chain = SessionEvent(LoginEvent(LogoutEvent())) 

例如,通过更改顺序,我们可以说一个通用会话事件的优先级高于登录,而不是注销,等等。

这个模式与对象一起工作的事实使得它相对于我们以前的实现更加灵活,后者依赖于类(虽然它们在 Python 中仍然是对象,但它们并没有被排除在某种程度的刚性之外)。

模板法

模板方法是一种模式,如果实施得当,将产生重要的效益。主要是,它允许我们重用代码,还使我们的对象更灵活,更容易更改,同时保留多态性。

其思想是有一个类层次结构来定义一些行为,比如说它的公共接口的一个重要方法。层次结构的所有类共享一个公共模板,可能只需要更改其中的某些元素。因此,我们的想法是将这个通用逻辑放在父类的公共方法中,父类将在内部调用所有其他(私有)方法,而这些方法就是派生类将要修改的方法;因此,模板中的所有逻辑都被重用。

精明的读者可能已经注意到,我们在上一节中已经实现了这个模式(作为责任链示例的一部分)。注意,从Event派生的类在其特定模式中只实现一件事。对于其余的逻辑,模板在Event类中。process事件是通用的,依赖于两种辅助方法:can_process()process()(这两种方法依次调用_parse_data()

这些额外的方法依赖于类属性模式。因此,为了用新类型的对象扩展它,我们只需要创建一个新的派生类并放置正则表达式。在这之后,逻辑的其余部分将被继承,并更改此新属性。这会重用大量代码,因为处理日志行的逻辑在父类中定义了一次,而且只定义了一次。

这使得设计更加灵活,因为保存多态性也是很容易实现的。如果我们需要一个新的事件类型,由于某种原因需要一种不同的数据解析方式,我们只会在该子类中重写这个私有方法,只要它返回与原始类型相同的内容(符合 Liskov 的替换和打开/关闭原则),兼容性就会保持。这是因为从派生类调用方法的是父类。

如果我们正在设计自己的库或框架,此模式也很有用。通过这种方式安排逻辑,我们使用户能够非常轻松地更改其中一个类的行为。他们必须创建一个子类并重写特定的私有方法,结果将是一个具有新行为的新对象,该行为保证与原始对象的先前调用方兼容。

命令

命令模式为我们提供了将需要执行的动作从请求到实际执行的分离能力。除此之外,它还可以将客户机发出的原始请求与其收件人(可能是不同的对象)分开。在本节中,我们将主要关注模式的第一个方面:我们可以将订单的运行方式与实际执行的时间分开。

我们知道我们可以通过实现__call__()魔术方法来创建可调用的对象,因此我们可以初始化对象,然后稍后调用它。事实上,如果这是唯一的要求,我们甚至可以通过一个嵌套函数来实现这一点,该函数通过闭包创建另一个函数来实现延迟执行的效果。但这种模式可以扩展到不那么容易实现的目的。

这样做的目的是,该命令也可以在定义后进行修改。这意味着客户机指定要运行的命令,然后可能会更改其某些参数,添加更多选项,等等,直到有人最终决定执行该操作。

这方面的例子可以在与数据库交互的库中找到。例如,在psycopg2(一个PostgreSQL客户端库)中,我们建立了一个连接。从这里,我们得到一个游标,我们可以向该游标传递一个要运行的SQL语句。当我们调用execute方法时,对象的内部表示会发生变化,但实际上数据库中没有运行任何东西。只有当我们调用fetchall()(或类似方法)时,数据才会被实际查询并在光标中可用。

同样的情况也发生在流行的对象关系映射器 SQLAlchemyORM SQLAlchemy中)。查询是通过几个步骤定义的,一旦我们有了query对象,我们仍然可以与之交互(添加或删除过滤器、更改条件、申请订单等),直到我们决定需要查询结果为止。调用每个方法后,query对象更改其内部属性并返回self(自身)。

这些例子与我们希望实现的行为相似。创建此结构的一种非常简单的方法是使用一个对象来存储要运行的命令的参数。之后,它还必须提供与这些参数交互的方法(添加或删除过滤器,等等)。或者,我们可以向该对象添加跟踪或日志功能,以审核已发生的操作。最后,我们需要提供一个实际执行操作的方法。这个可以是__call__()或定制的。我们叫它do()

当我们处理异步编程时,这种模式可能非常有用。正如我们已经看到的,异步编程有语法上的细微差别。通过将命令的准备与执行分离,我们可以使前者仍然具有同步形式,而后者具有异步语法(假设这是需要异步运行的部分,例如,如果我们使用库连接到数据库)。

状态

状态模式是软件设计中具体化的一个明显例子,它使域问题的概念成为一个明确的对象,而不仅仅是一个边值(例如,使用字符串或整数标志来表示值或管理状态)。

第 8 章单元测试和重构中,我们有一个表示merge请求的对象,它有一个与之关联的状态(openclosed等等)。我们使用了一个枚举来表示这些状态,因为在那个时候,它们只是持有一个值(该特定状态的字符串表示)的数据。如果他们必须有一些行为,或者整个merge请求必须根据其状态和转换执行一些操作,这是不够的。

我们正在向代码的一部分添加行为,一个运行时结构,这一事实必须让我们从对象的角度来思考,因为这毕竟是对象应该做的。现在具体化了,状态不能只是一个带字符串的枚举;它必须是一个对象。

想象一下,我们必须在merge请求中添加一些规则,比如当它从open移动到closed时,所有的批准都被删除(他们必须再次检查代码)——并且当merge请求刚刚打开时,批准数被设置为零(不管它是重新打开的还是全新的merge请求)。另一个规则可能是,当merge请求被合并时,我们希望删除源分支,当然,我们希望禁止用户执行无效转换(例如,关闭的合并请求不能被合并,等等)。

如果我们将所有这些逻辑放在一个地方,即在MergeRequest类中,我们将得到一个具有大量职责(设计不佳的标志)、可能有许多方法和大量if语句的类。很难遵循代码,也很难理解哪个部分应该代表哪个业务规则。

最好将其分配到更小的对象中,每个对象的职责更少,而状态对象是实现这一点的好地方。我们为想要表示的每种状态创建一个对象,并且在它们的方法中,我们使用上述规则放置转换的逻辑。然后,MergeRequest对象将有一个状态协作器,这反过来也将知道MergeRequest(需要双调度机制在MergeRequest上运行适当的操作并处理转换)。

我们用要实现的方法集定义一个基本抽象类,然后为我们要表示的每个特定state定义一个子类。然后MergeRequest对象将所有动作委托给state,如下代码所示:

class InvalidTransitionError(Exception):
    """Raised when trying to move to a target state from an unreachable 
    Source
    state.
    """
class MergeRequestState(abc.ABC):
    def __init__(self, merge_request):
        self._merge_request = merge_request
    @abc.abstractmethod
    def open(self):
        ...
    @abc.abstractmethod
    def close(self):
        ...
    @abc.abstractmethod
    def merge(self):
        ...
    def __str__(self):
        return self.__class__.__name__
class Open(MergeRequestState):
    def open(self):
        self._merge_request.approvals = 0
    def close(self):
        self._merge_request.approvals = 0
        self._merge_request.state = Closed
    def merge(self):
        logger.info("merging %s", self._merge_request)
        logger.info(
            "deleting branch %s", 
            self._merge_request.source_branch
        )
        self._merge_request.state = Merged
class Closed(MergeRequestState):
    def open(self):
        logger.info(
            "reopening closed merge request %s", 
            self._merge_request
        )
        self._merge_request.state = Open
    def close(self):
        """Current state."""
    def merge(self):
        raise InvalidTransitionError("can't merge a closed request")
class Merged(MergeRequestState):
    def open(self):
        raise InvalidTransitionError("already merged request")
    def close(self):
        raise InvalidTransitionError("already merged request")
    def merge(self):
        """Current state."""
class MergeRequest:
    def __init__(self, source_branch: str, target_branch: str) -> None:
        self.source_branch = source_branch
        self.target_branch = target_branch
        self._state = None
        self.approvals = 0
        self.state = Open
    @property
    def state(self):
        return self._state
    @state.setter
    def state(self, new_state_cls):
        self._state = new_state_cls(self)
    def open(self):
        return self.state.open()
    def close(self):
        return self.state.close()
    def merge(self):
        return self.state.merge()
    def __str__(self):
        return f"{self.target_branch}:{self.source_branch}" 

以下列表概述了有关实施细节和应作出的设计决策的一些澄清:

  • state是一个属性,所以不仅是public,还有一个地方定义了如何为合并请求创建状态,并将self作为参数传递。
  • 抽象基类不是严格需要的,但是拥有它有好处。首先,它使我们正在处理的对象更加明确。其次,它强制每个子状态实现接口的所有方法。除此之外,还有两种选择:
    • 我们本不可能编写方法,并在尝试执行无效操作时让AttributeError引发,但这是不正确的,并且不能表示发生了什么。
    • 与这一点相关的事实是,我们本可以只使用一个简单的基类,并将这些方法留空,但是不做任何事情的默认行为并不能让我们更清楚应该发生什么。如果子类中的一个方法不应该做任何事情(比如 merge),那么最好让空方法坐在那里,并明确表示对于特定的情况,不应该做任何事情,而不是强制将该逻辑应用于所有对象。
  • MergeRequestMergeRequestState之间有链接。在进行转换的那一刻,前一个对象将不会有额外的引用,应该进行垃圾收集,因此此关系应始终为 1:1。考虑到一些小的和更详细的因素,可以使用弱引用。

以下代码显示了如何使用对象的一些示例:

>>> mr = MergeRequest("develop", "mainline") 
>>> mr.open()
>>> mr.approvals
0
>>> mr.approvals = 3
>>> mr.close()
>>> mr.approvals
0
>>> mr.open()
INFO:log:reopening closed merge request mainline:develop
>>> mr.merge()
INFO:log:merging mainline:develop
INFO:log:deleting branch develop
>>> mr.close()
Traceback (most recent call last):
...
InvalidTransitionError: already merged request 

转换状态的操作被委托给state对象,该对象MergeRequest始终保持不变(可以是ABC的任何子类)。它们都知道如何响应相同的消息(以不同的方式),因此这些对象将采取与每个转换对应的适当操作(删除分支、引发异常等),然后将MergeRequest移动到下一个状态。

由于MergeRequest将所有动作委托给其state对象,因此我们会发现,这种情况通常会在时间发生,因为它需要执行的动作是形式的self.state.open(),以此类推。我们能去掉一些样板吗?

我们可以通过__getattr__(),如以下代码所示:

class MergeRequest:
    def __init__(self, source_branch: str, target_branch: str) -> None:
        self.source_branch = source_branch
        self.target_branch = target_branch
        self._state: MergeRequestState
        self.approvals = 0
        self.state = Open
    @property
    def state(self) -> MergeRequestState:
        return self._state
    @state.setter
    def state(self, new_state_cls: Type[MergeRequestState]):
        self._state = new_state_cls(self)
    @property
    def status(self):
        return str(self.state)
    def __getattr__(self, method):
        return getattr(self.state, method)
    def __str__(self):
        return f"{self.target_branch}:{self.source_branch}" 

在代码中实现这些类型的通用重定向时要小心,因为这可能会损害可读性。有时候,最好有一些小的样板文件,但要明确说明我们的代码是做什么的。

一方面,我们可以重用一些代码并删除重复的行。这使抽象基类更有意义。在某个地方,我们希望将所有可能的行动记录在案,列在一个地方。那个地方过去是MergeRequest类,但现在那些方法已经不存在了,所以唯一剩下的真相来源是MergeRequestState。幸运的是,state属性上的类型注释确实有助于用户了解在何处查找接口定义。

用户只需看一眼就可以看到MergeRequest没有的所有内容都会被询问其state属性。从init定义中,注释会告诉我们这是一个MergeRequestState类型的对象,通过查看此接口,我们可以安全地在其上请求open()close()merge()方法。

空对象模式

空对象模式是一个与本书前几章提到的良好实践相关的想法。在这里,我们将它们形式化,并对这个想法进行更多的上下文和分析。

原则是相当简单的函数或方法必须返回一致类型的对象。如果这是有保证的,那么我们代码的客户端就可以使用多态性返回的对象,而不必对它们进行额外的检查。

在前面的示例中,我们探讨了 Python 的动态特性如何使大多数设计模式变得更容易。在某些情况下,它们完全消失,而在另一些情况下,它们更容易实现。设计模式最初的主要目标是,方法或函数不应该显式地命名它们工作所需的对象类。因此,他们建议创建接口,并重新排列对象,使其适合这些接口,以便修改设计。但在大多数情况下,Python 不需要这样做,我们可以传递不同的对象,只要它们尊重它们必须拥有的方法,那么解决方案就可以工作。

另一方面,对象不一定必须符合接口这一事实要求我们对从这些方法和函数返回的东西更加小心。正如我们的函数没有对它们接收到的内容做出任何假设一样,我们可以公平地假设代码的客户端也不会做出任何假设(我们有责任提供兼容的对象)。这可以通过合同设计来实施或验证。在这里,我们将探索一种简单的模式,帮助我们避免此类问题。

考虑在前一节中探索的责任链设计模式。我们看到了它的灵活性和它的许多优点,比如将责任分离到更小的对象中。它存在的一个问题是,我们永远不知道什么对象最终会处理消息(如果有的话)。特别是,在我们的示例中,如果没有合适的对象来处理日志行,那么该方法将简单地返回None

我们不知道用户将如何使用我们传递的数据,但我们知道他们希望有一个字典。因此,可能会发生以下错误:

AttributeError: 'NoneType' object has no attribute 'keys' 

在这种情况下,修复非常简单,process()方法的默认值应该是空字典,而不是None

确保返回一致类型的对象。

但是,如果该方法不返回字典,而是返回我们域的自定义对象,该怎么办?

为了解决这个问题,我们应该有一个类来表示该对象的空状态并返回它。如果我们的系统中有一个代表用户的类,以及一个通过用户的ID查询用户的函数,那么在没有找到用户的情况下,它应该执行以下两项操作之一:

  • 提出例外
  • 返回一个UserUnknown类型的对象

但在任何情况下都不应返回None。短语None并不代表刚刚发生的事情,调用者可能会合法地尝试向它询问方法,但它会因AttributeError而失败。

我们在前面已经讨论了异常及其优缺点,所以我们应该提到这个null对象应该与原始用户具有相同的方法,并且不对每个用户执行任何操作。

使用此结构的优点是,不仅我们避免了运行时的错误,而且此对象可能有用。它可以使代码更容易测试,甚至可以帮助调试(也许我们可以将日志记录到方法中,以了解为什么会达到这种状态,向它提供了什么数据,等等)。

通过利用 Python 几乎所有的神奇方法,可以创建一个泛型的null对象,该对象完全不做任何事情,无论如何调用,但几乎可以从任何客户端调用。这样一个物体会有点像Mock物体。由于以下原因,不建议走这条路:

  • 由于域问题,它失去了意义。回到我们的例子中,拥有一个UnknownUser类型的对象是有意义的,它让调用者清楚地知道查询出了问题。
  • 它不尊重原始接口。这是有问题的。记住,UnknownUser是一个用户,因此它必须有相同的方法。如果调用方意外地请求一个不存在的方法,那么,在这种情况下,它应该引发一个AttributeError异常,这将是好的。有了泛型的null对象,它可以做任何事情,并对任何事情做出响应,我们将丢失这些信息,bug 可能会潜入其中。如果我们选择用spec=User创建一个Mock对象,那么这个异常就会被捕获,但同样,使用Mock对象来表示实际的空状态与我们提供清晰、可理解代码的意图不符。

这种模式是一种很好的实践,它允许我们在对象中保持多态性。

关于设计模式的最后思考

我们已经在 Python 中看到了设计模式的世界,通过这样做,我们找到了常见问题的解决方案,以及帮助我们实现干净设计的更多技术。

所有这些听起来不错,但它回避了一个问题:设计模式有多好?有些人认为,它们弊大于利,它们是为那些类型系统有限(并且缺乏一流函数)使我们无法在 Python 中正常完成任务的语言创建的。另一些人则声称设计模式强制设计解决方案,造成了一些偏见,限制了原本会出现的设计,而这会更好。让我们依次看一下这些要点。

图案对设计的影响

设计模式本身不可能是好的,也不可能是坏的,而是取决于它的实现或使用方式。在某些情况下,当使用更简单的解决方案时,不需要设计模式。试图强迫一个不适合的模式是一种过度设计的情况,这显然是不好的,但这并不意味着设计模式有问题,而且在这些场景中,问题很可能根本与模式无关。有些人试图对一切进行过度设计,因为他们不明白灵活和适应性强的软件到底意味着什么。

正如我们在本书前面提到的,制作好的软件不是为了预测未来的需求(做未来学是没有意义的),而是为了解决我们现在手头上的问题,以一种不会阻止我们在未来对其进行更改的方式。它现在不必处理这些变化;它只需要足够灵活,以便将来可以修改。当未来到来时,我们仍然必须记住同一问题的三个或更多实例的规则,然后才能提出通用解决方案或适当的抽象。

这通常是设计模式应该出现的点,一旦我们正确地识别了问题,并且能够识别模式并相应地进行抽象。

让我们回到模式对语言的适用性这一主题。正如我们在本章导言中所说,设计模式是高层次的思想。它们通常指对象之间的关系及其相互作用。很难想象这些东西会从一种语言消失到另一种语言。

诚然,有些模式在 Python 中需要更少的工作,就像迭代器模式(正如本书前面大量讨论的那样,迭代器模式是用 Python 构建的)或策略一样(因为,相反,我们只需像任何其他常规对象一样传递函数;我们不需要将 strategy 方法封装到对象中,因为函数本身就是该对象)。

但实际上还需要其他模式,它们确实解决了问题,比如装饰器和复合模式。在其他情况下,有些设计模式是 Python 本身实现的,我们并不总是看到它们,就像我们在本章前面讨论的 facade 模式一样。

至于我们的设计模式将我们的解决方案引向了错误的方向,我们在这里必须小心。再一次,如果我们开始设计解决方案时考虑领域问题并创建正确的抽象,然后再看看是否有一种设计模式从该设计中出现,这会更好。让我们这样说吧。这是件坏事吗?事实上,我们正在努力解决的问题已经有了解决方案,这不是一件坏事。重新发明轮子是不好的,就像在我们的领域中多次发生的那样。此外,我们正在应用一种已经被证明和验证的模式,这一事实应该让我们对我们正在构建的内容的质量有更大的信心。

作为理论的设计模式

我看到设计模式的一个有趣方式是软件工程理论。虽然我同意代码越自然地发展越好的观点,但这并不意味着我们应该完全忽略设计模式。

设计模式的存在是因为没有必要重新发明轮子。如果有一个解决方案已经为一个特定的问题设计好了,它将节省我们在设计时思考这个想法的时间。从这个意义上讲(并再次引用第一章的类比),我喜欢将设计模式视为类似于国际象棋的开场白:职业棋手不会在游戏的早期阶段考虑每一种组合。这就是理论。它已经被研究过了。这和数学或物理公式是一样的。你应该在第一次深入理解它,知道如何推断它,并结合它的含义,但在那之后,没有必要反复发展这个理论。

作为软件工程的实践者,我们应该使用设计模式的理论来节省精力并更快地提出解决方案。除此之外,设计模式不仅应该成为语言,还应该成为构建块。

我们模型中的名称

我们应该提到我们在代码中使用的是设计模式吗?

如果设计是好的,代码是干净的,那么它就应该为自己说话。由于以下几个原因,不建议您以您正在使用的设计模式命名:

  • 我们的代码用户和其他开发人员不需要知道代码背后的设计模式,只要它按预期工作就行。
  • 说明设计模式破坏了意图揭示原则。将设计模式的名称添加到类中会使其失去部分原始含义。如果一个类表示一个查询,那么它应该被命名为QueryEnhancedQuery,这表明了该对象应该做什么。EnhancedQueryDecorator没有任何意义,Decorator后缀造成的混乱多于清晰。

在 docstring 中提到设计模式可能是可以接受的,因为它们可以作为文档使用,并且在我们的设计中表达设计思想(同样,交流)是一件好事。然而,这是不必要的。不过,大多数情况下,我们不需要知道存在设计模式。

最好的设计是那些设计模式对用户完全透明的设计。这方面的一个例子是 facade 模式如何出现在标准库中,从而使用户完全了解如何访问os模块。一个更优雅的例子是迭代器设计模式是如何被语言完全抽象出来的,我们甚至不需要考虑它。

总结

设计模式一直被视为常见问题的经验证的解决方案。这是一个正确的评估,但在本章中,我们从良好的设计技术、利用干净代码的模式的角度探讨了它们。在大多数情况下,我们研究了它们如何提供一个很好的解决方案来保护多态性、减少耦合,并创建正确的抽象,根据需要封装细节第 8 章单元测试和重构中探讨的所有相关概念。

尽管如此,设计模式最好的地方不是我们可以从应用中获得干净的设计,而是扩展的词汇表。作为一种交流工具,我们可以用他们的名字来表达我们设计的意图。有时,我们需要应用的不是整个模式,但我们可能需要从解决方案中获取模式的特定想法(例如,子结构),在这里,它们也被证明是一种更有效的沟通方式。

当我们根据模式思考来创造解决方案时,我们是在更一般的层面上解决问题。从设计模式的角度思考,使我们更接近更高层次的设计。我们可以慢慢地“缩小”并从架构的角度进行更多思考。现在我们正在解决更一般的问题,是时候开始思考系统将如何发展和长期维护(如何扩展、改变、适应等等)。

一个软件项目要在这些目标上取得成功,其核心需要干净的代码,但架构也必须干净,这是我们将在下一章中讨论的。

工具书类

以下是您可以参考的信息列表: