Skip to content

Latest commit

 

History

History
466 lines (304 loc) · 37 KB

File metadata and controls

466 lines (304 loc) · 37 KB

十五、设计原则和模式

面向对象设计有许多考虑因素。在本章中,我们将从 Python 语言的细节中后退一步,了解一些基本原则。这些原则为如何设计有状态对象提供了基本指导。我们将研究 Python 中这些原则的具体应用。

我们将遵循的一般方法由实体设计原则定义,如下所示:

  • S单一责任
  • O笔/关闭
  • Liskov 置换
  • I界面分离
  • D依附性反转

虽然这些原则有一个巧妙的助记符,但我们不会按此顺序介绍它们。接口分离原则ISP)似乎最有助于将复杂问题分解为单个类定义。剩下的大部分原则都有助于优化类定义的特性。单一责任原则SRP)似乎没有其他原则那么集中,因此它更适合作为总结而非起点。更多信息,请参见http://butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod 用于原始概念。链接站点还包括一些附加概念。我们在本书中的目的是为这些原则提供一些 python 上下文。在本章中,我们将介绍以下主题:

  • 立体设计原则
  • 固体原理设计试验
  • 通过继承或组合构建要素
  • Python 和 libstdc 之间的相似之处++

技术要求

本章的代码文件可在上找到 https://git.io/fj2UX

立体设计原则

固体设计原则的一个目标是限制变更或扩展对设计的影响。对已建立的软件进行更改有点像向大海中扔一块鹅卵石:最初会有一个飞溅,随后会有更改的涟漪向外扩散。当试图修复或扩展设计糟糕的软件时,初始飞溅半径覆盖了一切;涟漪很大,会导致许多问题。在设计良好的软件中,飞溅半径很小。

作为一个具体的例子,考虑一个类来表示多米诺骨牌。每个磁贴都有 2 个数字,从 0 到 6,从而产生 28 个不同的磁贴。类设计看起来或多或少像一个两元组。28 个 tile 的整个集合可以通过一对嵌套的for语句生成,也可以通过一个包含两个for子句的生成器表达式生成。

然而,在一些游戏中,瓷砖的上限可能是 9、12 甚至 15。具有不同的上限会导致代表多米诺骨牌总体集合的类别发生变化。在 Python 中,这种变化可能很小,就像向对象构造函数添加一个默认参数limit=6一样。在设计糟糕的软件中,数字6出现在类定义中的多个位置,并且变化的范围很大。

在玩的时候,一些双数字的多米诺骨牌,特别是双六,可以扮演特殊的角色。在一些游戏中,双数牌被称为旋转器,对游戏状态有着巨大的影响。在其他游戏中,双号牌仅用于确定谁先玩,对整体影响很小。角色的这些更改可能会导致类定义的设计发生更改。实心设计将隔离对应用程序某一部分的更改,从而限制干扰软件不相关部分的涟漪。

在许多游戏中,两个数字的总和就是瓷砖的价值;胜利者的分数基于其他玩家手中未铺瓷砖的值之和。这意味着通常应首先播放具有较高点值的磁贴,而具有较低点值的磁贴代表较少的风险。这就提出了一系列将磁贴按顺序排序的策略,从而导致设计变化。

所有这些规则的变化都表明我们在课堂设计上需要灵活。如果我们专注于一个特定游戏的规则,那么我们的类定义就不能轻易地在其他游戏中重用或修改。最大化类定义的价值意味着提供一个足够通用的类来解决许多密切相关的问题。我们将从包含许多设计缺陷的类定义开始。拙劣的设计如下:

    import     random
    from     typing     import     Tuple, List, Iterator

        class     DominoBoneYard:

                    def         __init__    (    self    , limit:     int     =     6    ) ->     None    :
            self    ._dominoes = [
            (x, y)     for     x     in         range    (limit +     1    ) 
                for     y     in         range    (x +     1    )
        ]
        random.shuffle(    self    ._dominoes)

        def     double(    self    , domino: Tuple[    int    ,     int    ]) ->     bool    :
        x, y = domino
            return     x == y

        def     score(    self    , domino: Tuple[    int    ,     int    ]) ->     int    :
            return domino[0] + domino[1]    

        def     hand_iter(    self    , players:     int     =     4    ) -> Iterator[List[Tuple[    int    ,     int    ]]]:
            for     p     in         range    (players):
                yield         self    ._dominoes[p *     7    :p *     7     +     7    ]

    def can_play_first(self, hand: List[Tuple[int, int]]) -> bool:
 for d in hand:
 if self.double(d) and d[0] == 6:
 return True
 return False

        def     score_hand(    self    , hand: List[Tuple[    int    ,     int    ]]) ->     int    :
            return sum(d[0]+d[1] for d in hand)    

        def     rank_hand(    self    , hand: List[Tuple[    int    ,     int    ]]) ->     None    :
        hand.sort(    key    =    self    .score,     reverse    =    True    )

        def     doubles_indices(    self    , hand: List[Tuple[    int    ,     int    ]]) -> List[    int    ]:
            return     [i     for     i     in         range    (    len    (hand))     if         self    .double(hand[i])]

虽然这个类可以用于一些常见的游戏,但它有很多问题。如果我们想扩展或修改其他游戏的这个定义,几乎任何更改都会产生巨大的涟漪。

我们将调用一种特别困难的方法,can_play_first()方法。在一场有 4 名玩家的双 6 游戏中,所有 28 个多米诺骨牌都会被处理。4 只手中的一只手将有双 6;那个选手先上场。然而,在双人游戏中,只有 14 个多米诺骨牌会被处理,并且有 50%的几率两个玩家都没有双人 6。为了涵盖这种常见情况,该规则通常被称为最高双人比赛优先。这门课不容易找到除了双 6 之外的任何双精度。

这个类应该被分解成许多更小的类。我们将在下面的章节中介绍每一个坚实的设计原则,看看它们如何指导我们进行更好的设计。我们希望构建对象模型来更好地支持解决各种密切相关的问题。

界面分离原理

接口隔离原则ISP的一个定义是,不应强制 c客户依赖于他们不使用的方法。其思想是在一个类中提供最少数量的方法。这导致了集中定义,通常将设计分为多个类,以隔离任何更改的影响。

这一原则似乎对设计产生了最显著的影响,因为它将模型分解为多个类,每个类都有一个集中的接口。其他四个原则似乎从一开始就遵循,在初始分解后提供改进。

前面显示的类定义中嵌入的类型提示表明该类中至少涉及三个不同的接口。我们可以看到以下类型提示:

  • 多米诺骨牌的整体集合List[Tuple[int, int]],用于处理双手
  • 每个单独的 domino,由类型提示以Tuple[int, int]的形式定义。
  • 多米诺骨牌之手,也由List[Tuple[int, int]]形式的类型提示定义。这可能是一种模糊性,通过具有不同目的的类似类型而暴露出来。
  • doubles_indices()查询一只手的特定多米诺骨牌,结果为List[int]。这可能还不足以作为另一个类定义的依据。

如果我们基于这些单独的接口分解初始类,那么我们将拥有许多可以独立演化的类。有些类可以在各种游戏中广泛重用;其他类必须创建特定于游戏的扩展。修订后的一对类显示在以下代码中:

    class     Domino(NamedTuple):
    v1:     int
                v2:     int

                    def     double(    self    ) ->     bool    :
            return         self    .v1 ==     self    .v2

        def     score(    self    ) ->     int    :
            return         self    .v1 +     self    .v2

    class     Hand(    list    ):

    def __init__(self, *args: Domino) -> None:
        super().__init__(cast(Tuple[Any], args))

        def     score(    self    ) ->     int    :
            return         sum    (d.score()     for     d     in         self    )

        def     rank(    self    ) ->     None    :
            self    .sort(    key    =    lambda     d: d.score(),     reverse    =    True    )

        def     doubles_indices(    self    ) -> List[    int    ]:
            return     [i     for     i     in         range    (    len    (    self    ))     if         self    [i].double()]

Domino 类保留了基本的Tuple[int, int]结构,但为类提供了一个合理的名称,并为磁贴上显示的两个值提供了名称。当我们打印对象时,使用NamedTuple的结果是更有用的repr()值。

Hand类的__init__()方法实际上没有做多少有用的工作。应用于类型Type[Any]cast()函数和args对象在运行时不执行任何操作。cast()是对mypy的提示,args的值应被视为具有Tuple[Any]类型,而不是更严格的Domino类型。如果没有这一点,我们将得到一个错误,即list.__init__()方法预期对象为Any类型。

Hand实例的分数取决于Hand集合中各个Domino对象的分数。将其与前面显示的score_hand()score()功能进行比较。糟糕的设计在两个地方重复了重要的算法细节。对其中一个地方的小改动也必须在另一个地方进行,从而使改动引起更大的轰动。

double_indices()函数相当复杂,因为它处理的是多米诺骨牌的索引位置,而不是多米诺骨牌对象本身。具体来说,for i in range(len(self))的使用意味着i变量的值将是一个整数,self[i]将是索引值等于i变量值的Domino对象。此函数提供了多米诺骨牌的索引,double()方法为True

要继续此示例,多米诺骨牌的总体集合显示在以下代码中:

    class     DominoBoneYard2:

        def         __init__    (    self    , limit:     int     =     6    ) ->     None    :
            self    ._dominoes = [Domino(x, y)     for     x     in         range    (limit +     1    )     for     y     in         range    (x +     1    )]
        random.shuffle(    self    ._dominoes)

        def     hand_iter(    self    , players:     int     =     4    ) -> Iterator[Hand]:
            for     p     in         range    (players):
                yield     Hand(    self    ._dominoes[p *     7    :p *     7     +     7    ])

这将在创建初始多米诺骨牌集时创建单个Domino实例。然后,在向玩家发送多米诺骨牌时,它会创建单个Hand实例。

因为接口被最小化,我们可以考虑改变多米诺骨牌的方式,而不打破每个瓦片或瓦片手的基本定义。具体来说,所示的设计与未处理给玩家的瓷砖不匹配。例如,在双人游戏中,将有 14 个未使用的瓷砖。在一些游戏中,这些都被忽略了。在其他游戏中,玩家必须在此池中进行选择。将此功能添加到原始类可能会干扰其他接口,这与处理机制无关。向DominoBoneyard2类添加特性不会带来破坏DominoHand对象行为的风险。

例如,我们可以更改以下代码:

    class     DominoBoneYard3(DominoBoneYard2):

        def     hand_iter(    self    , players:     int     =     4    ) -> Iterator[Hand3]:
            for     p     in         range    (players):
            hand,     self    ._dominoes = Hand3(    self    ._dominoes[:    7    ]),     self    ._dominoes[    7    :]
                yield     hand

这将保留self._dominoes序列中所有未死多米诺骨牌。draw()方法可以在初始交易后一次消费一个多米诺骨牌。该变更不涉及任何其他类别定义的变更;这种隔离降低了在其他类中引入令人惊讶或困惑的问题的风险。

里氏代换原则

Liskov 替换原理LSP)是以 CLU 语言发明者、计算机科学家芭芭拉·里斯科夫的名字命名的。这种语言强调了集群的概念,其中包含对象表示的描述以及该对象上所有操作的实现。有关这种早期面向对象编程语言的更多信息,请参见http://www.pmg.lcs.mit.edu/CLU.html

LSP 通常被总结为亚型必须可替代其基础类型。这个建议倾向于指导我们创建多态类型层次结构。例如,如果我们希望向Hand类添加特性,我们应该确保Hand的任何子类都可以直接替换Hand

在 Python 中,通过添加新方法扩展超类的子类是一种理想的设计。这个子类扩展直接演示了 LSP。

当子类方法与超类具有不同的实现但具有相同的类型提示签名时,这也证明了优雅的 Liskov 可替换性。前面所示的示例包括DominoBoneYard2DominoBoneYard3。这两个类都有相同的方法,具有相同的类型提示和参数。实现是不同的。子类可以替代父类。

在某些情况下,我们希望有一个子类使用额外的参数或具有稍微不同的类型签名。子类方法与超类方法不匹配的设计通常不太理想,应考虑替代设计。在许多情况下,这应该通过包装超类而不是扩展它来完成。

包装类以添加要素是一种创建新实体的方法,而不会产生 Liskov 替换问题。以下是一个例子:

    class     FancyDealer4:

        def         __init__    (    self    ):
            self    .boneyard = DominoBoneYard3()

        def     hand_iter(    self    , players:     int    , tiles:     int    ) -> Iterator[Hand3]:
            if     players * tiles >     len    (    self    .boneyard._dominoes):
                raise         ValueError    (    f"Can't deal players=        {    players    }         tiles=        {    tiles    }        "    )
            for     p     in         range    (players):
            hand = Hand3(    self    .boneyard._dominoes[:tiles])
                self    .boneyard._dominoes =     self    .boneyard._dominoes[tiles:]
                yield     hand

FancyDealer4类定义不是先前DominoBoneYard2DominoBoneYard3类的子类。这个包装器为hand_iter()方法定义了一个独特的签名:这是一个附加参数,没有默认值。FancyDealer4的每个实例包装一个DominoBoneYard3实例;此对象用于管理可用磁贴的详细信息

包装一个类会明确声明 LSP 不是类设计的一个特性。编写包装器还是创建子类的选择通常由 LSP 决定。

Python 使用默认值和关键字参数提供了极大的灵活性。在很多情况下,我们可以考虑重写一个超类来提供合适的默认值。这通常是避免创建更多子类或包装类的一种方法。在某些语言中,用于继承的编译器规则需要相当聪明,才能获得一个类层次结构,在这个层次结构中可以使用子类代替超类。在 Python 中,很少需要聪明;相反,我们通常可以添加可选参数。

开放/封闭原则

开启/关闭原则OCP建议了两个互补的目标。一方面,类应该对扩展开放。另一方面,它也应该禁止修改。我们希望通过包装或子类来设计类以支持扩展。作为一种习惯,我们希望避免修改类。当需要新特性时,一种合理的方法是扩展类以添加特性。

当我们想要引入更改或新特性时,理想的途径是通过扩展现有类。这将保留所有遗留特性,保留原始测试,以通过添加新特性确认以前的特性未被破坏。

当保持类对扩展开放时,会出现两种设计更改或调整:

  • 需要在方法签名与父类匹配的位置添加子类。子类可能有其他方法,但它将包含所有父类特性,并且可以用来代替父类。此设计也遵循 LSP。
  • 需要添加包装类,以提供与另一个类层次结构不兼容的附加功能。包装器类会跳出直接 Liskov 替换,因为包装器的新特性不会与其他类直接兼容。

在这两种情况下,随着设计的发展,原始类定义保持不变。新特性要么是满足 LSP 的扩展,要么是从旧类定义创建新类的包装器。这种设计是保持类对扩展开放的结果。

前面所示的示例类DominoBoneYard2DominoBoneYard3都严重未能遵循 OCP。在这两个类别中,一只手上的瓷砖数量固定为 7。这个文本值使得类很难扩展。我们被迫创建FancyDealer4类来解决这个设计缺陷。

DominoBoneYard2 类的更好设计将导致更容易扩展到此层次结构中的所有类。Python 中一个很好的小改动是将常量值转换为类级属性。此更改显示在以下代码示例中:

    class     DominoBoneYard2b:

    hand_size:     int     =     7

                    def         __init__    (    self    , limit:     int     =     6    ) ->     None    :
            self    ._dominoes = [Domino(x, y)     for     x     in         range    (limit +     1    )     for     y     in         range    (x +     1    )]
        random.shuffle(    self    ._dominoes)

        def     hand_iter(    self    , players:     int     =     4    ) -> Iterator[Hand3]:
            for     p     in         range    (players):
            hand = Hand3(    self    ._dominoes[:    self    .hand_size])
                self    ._dominoes =     self    ._dominoes[    self    .hand_size:]
                yield     hand

DominoBoneYard2b类引入一个类级变量,使每只手的大小成为一个参数。这使得类对扩展更加开放:子类可以进行更改,而无需修改任何进一步的编程。这并不总是最好的返工方式,但它的优点是变化很小。Python 语言促进了这些类型的更改。self.hand_size引用可以是实例的属性,也可以是类的整体属性。

我们还可以在其他地方扩展这个课程。我们将把其中一些作为依赖项反转原则的一部分

依赖倒置原则

依赖倒置原则DIP)有一个不幸的名字;倒置一词似乎暗示存在某种明显的依赖性,我们应该倒置明显的依赖性规则。实际上,该原理被描述为具有基于最抽象的超类的类依赖性,而不是基于特定的具体实现类。

在具有正式类型声明的语言中,例如 java 或 C++,引用抽象超类的建议可以帮助避免对小的更改进行复杂的编译。这些语言还需要相当复杂的依赖注入框架,以确保可以通过运行时配置更改来更改类。在 Python 中,运行时的灵活性意味着通知会有所变化。

因为 Python 使用 duck 类型,所以并不总是有一个抽象的超类来总结各种替代实现。例如,我们可以将函数参数定义为Iterable,告诉mypy允许任何遵循Iterable协议的对象:这将包括迭代器和集合

在 Python 中,DIP 引导我们使用两种技术:

  • 类型提示应该尽可能抽象。在许多情况下,它将命名方法或函数使用的相关协议。
  • 具体类型名称应参数化。

在前面的示例中,各种DominoBoneYard类定义都存在依赖性问题:它们在创建Domino对象的初始池和Hand对象时都引用具体的类名。我们不能根据需要随意替换这些类,但需要创建子类来替换引用。

下面的示例显示了该类更灵活的定义:

    class     DominoBoneYard3c:

    domino_class: Type[Domino] = Domino

    hand_class: Type[Hand] = Hand3

    hand_size:     int     =     7

                    def         __init__    (    self    , limit:     int     =     6    ) ->     None    :
            self    ._dominoes = [
                self    .domino_class(x, y)     for     x     in         range    (limit +     1    )     for     y     in         range    (x +     1    )
        ]
        random.shuffle(    self    ._dominoes)

        def     hand_iter(    self    , players:     int     =     4    ) -> Iterator[Hand]:
            for     p     in         range    (players):
            hand =     self    .hand_class(
                    self    ._dominoes[:    self    .hand_size])
                self    ._dominoes =     self    ._dominoes[    self    .hand_size:]
                yield     hand

此示例显示了如何在中心位置将依赖项定义为类定义的属性。这会将依赖关系从几个方法中的深层次重构到更为明显的位置。我们提供了一个完整的类型提示,以帮助发现类型预期的潜在误用。对于Domino类,我们没有任何选择,提示Type[Domino]似乎是多余的。然而,对于Hand3类,我们提供了Type[Hand]的提示,以显示此处可用的最抽象的类。

因为这些值是变量,所以在运行时执行依赖项注入和提供配置信息变得非常容易。我们可以使用类似于DominoBoneYard3c.hand_class = Hand4的代码来更改用于构建 hand 的类。通常,这应该在创建任何实例之前完成。类标识可以从配置文件中获取,并用于定制应用程序操作的细节。

我们可以想象一个顶级计划,包括以下内容:

configuration = get_configuration()
    DominoBoneYard3c.hand_class = configuration['hand_class']
    DominoBoneYard3c.domino_class = configuration['domino_class']

一旦类定义中注入了适当的依赖项,应用程序就可以使用这些配置的类定义。关于提供配置的更多想法,请参见第 14 章配置文件和持久化。需要注意的是,类型提示不用于检查运行时配置值。类型提示仅用于确认源代码在使用对象和类型时似乎是一致的。

单一责任原则

单一责任原则SRP可能是最难理解的原则。“一个类应该有一个,而且只有一个改变的理由”的一般性陈述将责任的定义转移到了对面向对象设计中的改变的理解上。改变有几个原因;更改类的最显著原因是添加新特性。正如前面在打开/关闭原则一节中所述,应该添加一个特性作为扩展而不是修改。

在许多情况下,接口隔离原则ISP)似乎比 SRP 为类设计提供了更具体的指导。SRP 似乎是我们遵循其他原则时类外观的总结。

当我们回顾前面例子中定义的类时,这个原则暗示了一些潜在的变化。特别是,各种DominoBoneYard类定义提供了以下特性:

  • 建立Domino实例的集合。
  • 把第一手牌交给球员。通常这是七张多米诺骨牌的四只手,但这一规则因游戏而异。这可能会耗尽收集多米诺骨牌的时间,也可能会使一些多米诺骨牌无法使用。
  • 当有一堆不死多米诺骨牌时,通过允许玩家画图来管理这些骨牌,以补充他们的双手。

我们可以说,这是一个单一的责任:向玩家发送多米诺骨牌。玩家获得多米诺骨牌有两种不同的方式(最初的交易和游戏后期的绘画),这两种机制都是一个类的责任的一部分。这是一个相当高的抽象层次,将多米诺骨牌池视为一个整体。

我们也可以说这里有两种责任。我们可以说,创建Domino对象的初始集合与向玩家处理Domino对象是不同的责任。我们可以反驳说,添加和移除多米诺骨牌是维护收藏内容的唯一责任。这是一个相当低的抽象级别。

一般指导原则通常会导致需要专家判断才能做出最终决定的情况。没有简单的规则来区分适合于设计的抽象级别。

这些原则必须用于指导设计过程。它们不是一成不变的法则。最终决定取决于许多其他因素,如应用程序的总体复杂性和预期编程更改的范围。它有助于把水果挞的设计原则作为对话启动器。在审查设计时,需要评估变更周围的飞溅半径和变更的后果,这些原则为评估设计质量提供了一些维度。

让我们来看看下一节的固体原理设计测试。

可靠的原理设计测试

我们通常认为测试是应用于最终代码的东西(在第 17 章可测试性设计中,我们将详细介绍自动测试)。但是,我们也可以对实体设计进行测试。测试是用一个等价的类替换给定的类,以提供一个替代算法来实现相同的目的。如果我们已经很好地完成了设计工作,那么对一个类的更改应该会产生最小的轰动,并且几乎没有涟漪。

作为一个具体的例子,考虑本章前面所示的 AUT0T0 类,在 Fo.T2A.接口隔离原则 AUTT3E.节。我们用一个NamedTuple来表示这对数字。一些替代方案是可能的:

  • 使用frozenset保留一个或两个不同的值。如果集合中有一个值,则平铺实际上是一个双精度平铺或微调器。
  • 使用Counter保留数值的计数。如果只有一个值,且计数为 2,则平铺为双精度平铺。否则,将有两个值,每个值的计数为一。

Domino类的此类更改是否会对设计中的其他类产生影响?如果不是,那么设计就被很好地封装了。如果这些类型的更改确实破坏了设计,那么设计工作应该继续以最小化更改影响的方式重新排列类定义。

在下一节中,我们将通过继承和组合构建特性。

通过继承和组合构建特征

正如前面在Liskov 替换原则一节中提到的,有两种向类定义中添加特性的一般方法:

  • 继承通过创建子类来扩展类
  • 从一个或多个其他类合成新类

一般来说,这些选择总是存在的。每个面向对象的设计决策都涉及到在继承和组合之间进行选择。

为了使决策更加细致,Python 允许多重继承。虽然组合多个混合类在某种程度上是一种继承,但更根本的是一种组合练习

LSP 可以导致避免继承而有利于组合。一般的建议是在子类可以完全替换父类的情况下保留继承。当以某种方式更改特征以创建不是直接替换父对象的子对象时,组合可能更合适。

考虑将一些特性添加到前面显示的 AuthT0p 类中的后果。以下是两个例子:

  • Hand3子类通过引入额外的方法扩展了Hand类。此扩展与超类兼容,Hand3可以用作Hand的替代品。这似乎是通过继承进行的合理扩展。
  • FancyDealer4类引入了一个新类,该类由一个利用DominoBoneYard3类的新方法组成。这门课给hand_iter()方法带来了深刻的变化;这个变化与超类并不完全兼容。

Python 中还有更多可用的合成技术。在第 9 章装饰和混合——横切方面中,我们讨论了另外两种合成技术。在下一节中,我们将研究一些其他的类组合模式。

高级合成模式

设计模式的经典书籍之一,设计模式:可重用面向对象软件的元素,确定了许多常见的对象组合模式。这些模式中的一些与 C++或 java 编程更相关,与 Python 编程无关。例如,单例模式是 Python 模块的一级方面和 Python 类定义;Java 静态变量的复杂性对于实现这种模式来说是不必要的。

关于 Python 设计模式的更好的源代码,请访问https://python-patterns.guide 。这个 Python 模式网站上的模式描述专门针对 Python。重要的是要认识到,面向对象设计模式文献中的一些复杂性源于在存在非常严格的编译时检查的情况下创建运行时行为的优雅方式。Python 不会遇到同样类型的类型管理问题,这使得 Python 编程更加简单

Python 中的一个核心概念是duck 类型化。该概念基于以下引用:

"When I see a bird that walks like a duck and swims like a duck and quacks like a duck, I call that bird a duck."

在 Python 中,当方法和属性符合所需的协议时,对象是可用的。实际的基类型并不重要;可用的方法定义了类在特定上下文中的适用性。

例如,我们可以定义两个类似的类,如下面的代码所示。第一个使用typing.NamedTuple作为基类:

    from     typing     import     NamedTuple
from dataclasses import dataclass

    class     Domino_1(NamedTuple):
    v1:     int
                v2:     int

                    @property
                    def     double(    self    ):
            return         self    .v1 ==     self    .v2

此替代版本使用@dataclass装饰器创建冻结对象,类似于元组:

    from dataclasses import dataclass

@dataclass    (    frozen    =    True    ,     eq    =    True    ,     order    =    True    )
    class     Domino_2:
    v1:     int
                v2:     int

                    @property
                    def     double(    self    ):
            return         self    .v1 ==     self    .v2

这两个类的行为几乎相同。它们唯一的共同类是所有对象的超类,object类。然而,这两个类在功能上是可互换的,并且可以自由地相互替换。

这种在没有公共超类的情况下拥有等价类型的能力允许灵活性,但在尝试使用mypy检查类型时也会带来困难。在某些情况下,我们可能会发现定义抽象超类纯粹是为了确保几个不同的实现都提供了公共特性。在其他情况下,我们可能需要添加如下类型提示:

Domino = Union[Domino_1, Domino_2]

这个定义提供了一个类型名Domino,它有两个具体的实现。这提供了mypy可以用来验证我们的软件的信息,而无需创建抽象超类的不必要的复杂性。我们可以为这个Union类型引入新类,而不必担心继承问题。为了实现这一点,唯一的要求是类支持应用程序实际使用的方法。

有了这个定义,我们可以使用以下类型的工厂方法来构建Domino实例:

    class     DominoBoneYard:

    domino_class: Type[Domino] = Domino_1

                    def         __init__    (    self    , limit:     int     =     6    ) ->     None    :
            self    ._dominoes:     List[Domino]     = [
                self    .domino_class(x, y) 
                for     x     in         range    (limit +     1    ) 
                    for     y     in         range    (x +     1    )
        ]
        random.shuffle(    self    ._dominoes)

__init__()方法使用类型提示List[Domino]构建self._dominoes对象。此提示包含Domino类型名称的Union[]类型提示中的所有类

如果我们在使用这个类时犯了一个可怕的错误,并试图使用一些代码(如DominoBoneYard.domino_class = tuple来创建tuple对象,mypy程序将发现类型不兼容,并报告一个错误,并显示一条与Incompatible types in assignment (expression has type "Type[Tuple[Any, ...]]", variable has type "Union[Type[Domino_1], Type[Domino_2]]")类似的消息。此消息将通知我们,元组的配置选择不太可能正常工作。

Python 和 libstdc 之间的相似之处++

C++标准模板库提供了许多实现为类模板的设计模式。为了满足 C++编译器的要求,必须用特定的类型填充这些模板。我们可以使用这个库作为通用设计模式的建议。我们将研究现代 GNULibstdc++实现的一些元素,作为其他语言当前思想的代表性示例。网址为https://en.cppreference.com/w/ 提供了全面的参考。

这里的目的是将此库用作有关设计模式的建议或提示列表。这可以提供 Python 中可用特性的透视图。类库没有单一的、正确的黄金标准。语言之间的任何比较都充满了困难,因为它可能看起来像是一种语言因为缺少一个特征而有缺陷。在所有情况下,当比较语言时,任何缺失的功能都可以从现有组件中轻松构建。

在 C++库中的各种类和模板的概述在第 4 章到第 15 章中是可用的。这里比较了另一种语言中的一些常见模式,以及 Python 中类似功能的映射;详情如下:

  • 支持描述了一些基本特性,包括原子数据类型。这里定义的类型与 Python 中的intfloatstr并行。
  • 诊断 To.T1A.描述了异常的 C++实现和错误处理。
  • 实用程序描述了函子和对,它们对应于 Python 函数和两个元组。Python 将 Pair 概念扩展到更一般的tuple,并添加了NamedTuple
  • 字符串描述了更多的字符串特征。其中一些是str类型、string模块和shlex模块的一部分。
  • Apple T1。本地化 To.T2A.描述了用于本地化的 C++库的一些附加特性。这是 Pythonlocale模块的一部分。
  • Apple T1 容器描述了多个 C++容器类模板。我们将在下一节提供详细信息
  • Po.T2A.迭代器 To.T3A.描述了类似于 Python 迭代器的 C++特性。C++实现包括各种迭代器类别。例如,随机访问迭代器类似于列表的整数索引值。前向迭代器与迭代器的 Python 实现并行。C++中的输入或输出迭代器与 Python 中类似的类似于文件的对象类似。在 Python 中,提供__iter__()方法的类是可移植的。具有__next__()方法的对象是迭代器。在 C++中有双向迭代器可用,不清楚它们有多重要,但是它们在类层次结构中有一个位置。
  • 算法描述类,并在迭代器一章中扩展特性。它为并行化算法提供了一些工具。最重要的是,它包含许多常见操作。其中一些是 Pythoncollections的一部分,另一些是itertools的一部分,还有一些是 Python 内置函数。
  • Numeric 的描述了一些更高级的数字类型。Pythoncomplex类型和numbers模块提供了这些特性的 Python 版本。array模块和软件包,如numpypandas放大了这些核心功能。
  • AuthT1。输入和输出 ALE T2A.描述了 C++ I/O 类。Python Apple T0 模块定义了这些 C++特性的等价物。
  • 原子学提供了一种定义线程安全对象的方法,在该对象中,对内存的写入保证在另一个线程读取之前完成。在 Python 中,threading库可用于构建具有此行为的对象。
  • 并发描述了在应用程序中处理线程的其他方式。这也是 Pythonthreading库的一部分。

C++中的容器库包括以下类型的类和容器类定义:

  • 序列包括以下内容:
    • 阵列。这适用于固定大小的阵列。我们可以使用内置的list,或者array模块,甚至numpy包来实现这一点。
    • 载体。这对应于 Pythonlist类。
    • 德克。虽然list类具有这些特性,Collections.deque类提供了更好的性能。
    • 列表。这也对应于 Pythonlist类。
    • 转发列表。这是一个列表的扩展,允许在遍历元素时删除元素。因为这在 Python 中不是直接可用的,所以我们经常使用list(filter(rule, data))来创建一个新列表,它是旧列表的子集。
  • 关联与 中列出的无序关联基本相同。在 C++实现中,使用树形数据结构来保持密钥的有序性。在 Python 中,可以通过在键上使用sorted()来构建一组等效的特性。
  • 无序关联包括以下内容:
    • 设置。这大致相当于 Pythonset类。
    • 多集。这对应于 Pythoncollections.Counter类。
    • 地图。这对应于 Pythondict类。
    • 多地图。这可以使用defaultdict(list)构建。几个附加包为此提供了实现。参见http://werkzeug.pocoo.org/docs/0.14/datastructures/MultiDict类为例。

当我们把 C++库看作一种基准时,它看起来像 Python 提供了类似的特性。这是判断 Python 设计模式完整性的一种方法。这些类的这种替代组织有助于可视化 Python 中存在的各种实现模式。

总结

在本章中,我们从 Python 的细节中退了一步,看看坚实的设计原则。这些注意事项对于如何设计有状态对象至关重要。这些原则为我们提供了构建面向对象设计的有用思想集合。按以下顺序考虑这些原则似乎是最有用的:

  • 接口分离:为每个类构建最小的接口,重构以将一个大类定义拆分为更小的部分。
  • Liskov 替换:确保任何子类都可以替换父类;否则,考虑合成技术而不是继承。
  • 开/关:一个类应该对扩展开放,对直接修改关闭。这需要仔细考虑对于给定的类,哪些扩展是合理的。
  • 依赖倒置:一个类不应该对另一个类有简单、直接的依赖关系。应该通过变量提供类,以便运行时配置可以更改所使用的类。
  • 单一责任:这概括了定义类的目的是为了一个单一的、狭隘的目的,因此更改仅限于一个或几个类。

这些原则将隐含在以下章节中。下一章第 16 章日志和警告模块将介绍如何使用loggingwarnings模块创建审计信息以及进行调试。我们将在第 17 章可测试性设计中介绍可测试性设计以及如何使用unittestdoctest。后面的章节将介绍应用程序、软件包的设计,以及生产高质量软件的一般概念。