再谈 Python 中的继承(译)

2023-10-19 09:38:17 浏览数 (1)

本文是 Subclassing in Python Redux 的中文版。在阅读的过程中,我发现与我的「友好的 Python」不谋而合,故向作者请求翻译此文。版权归原作者 Hynek Schlawack 所有。除非特别说明,本文所有的「我」均指原作者 Hynek。

继承与组合之间的冲突就和面向对象编程一样古老。一些最新的语言,如 GoRust,证明了你不需要继承也能编写代码。但是具体在 Python 语言中,有什么实用的继承的方法呢?

任何长期关注我的人都知道,我是坚定地站在组合而非继承的阵营。然而 Python 设计如此,有时如果不用继承,你就无法写出惯常的代码。我写这篇文章的目的就是思考这个问题,这个「有时」是何时,并解开我对这个问题的直觉1。

我知道这篇文章很长。事实上,这是我自 2006 年毕业论文以来写的最长的一篇文章。客观地说,我应该把它分成至少三个部分。这样会更有利于(SEO!社交媒体!点击率!),也更有可能让人们真正读到最后。 但我还是希望它单独成篇。我希望它是我多年来所学的精髓的提炼。当然,你想停下来就停下来,下次再读——反正这篇文章就在这里。

我们先从细微之处开始。为什么许多关于继承的讨论如此令人沮丧,没有结果?其中一个原因是继承的类型不止一种。正如那篇精彩的文章《为什么继承没有任何意义》所解释的2,继承有有三种类型,不应混为一谈——无论你对继承有什么看法。

这有可能造成三类人互相争论,每个人都认为自己的方式正确,永远不去找共同点。我们都看到过这些讨论是如何展开的。

根据我的经验,如果严格分开使用——一种是好的,一种是可选但有用的,还有一种是差的。继承的大多数问题都源于我们试图同时使用一种以上的继承类型——或者在对象设计中重点使用差的那种类型。

在所有情况下,你都要牺牲阅读的便利来换取写代码的便利。这不一定是坏事,软件设计在于权衡,你可以得出结论,在某些情况下这是完全值得的。这就是为什么我不指望你会赞同下面的所有内容,但我希望能引发一些思考,以帮助你在未来做出决定。

但现在絮叨到此为止,让我们看看这三种类型。从差的那一种开始。

类型一:代码共享

Namespaces are one honking great idea – let’s do more of those! — Tim Peters,Python 之禅

大多数对继承的批评来自于代码共享,这是理所当然的。我不觉得我有什么可以补充的,所以我打算直接放几个比我更睿智和更杰出的作品链接:

  • 组合优于继承原则,Brandon Rhodes,
  • 对象继承的终结,新模块化的开始,Augie Fackler 与 Nathaniel Manista,PyCon US 2013,
  • 以及 无招胜有招,Sandi Metz,RailsConf 2015.

简而言之,总体上有三个问题:

  1. 不止一个轴上的变化。这是,Brandon 的文章和 Sandi 演讲的后半部分的主要思想。这不容易解释,所以我将直接引用他们的作品,但其本质是:如果你想定制一个类的多于一个行为方面,通过继承共享代码是行不通的。它会导致子类爆炸。 这不是一种观点或一种权衡。这是一个事实。
  2. 类和实例命名空间混淆。如果你在一个继承自一个或多个基类的类中有一个属性 self.x,那么你就需要研究并耗费脑力来找出 x 的来源。阅读代码时如此,调试时也如此。 这也意味着总是存在这样的危险:在同一层次结构中的两个类,它们彼此不认识,却拥有一个同名的属性。虽然 Python 有双下划线前缀(__x)的概念来处理这种情况,但这被认为是不可取的,因为它更像是君子协定。 问题在于,如果不是各方面都知情,就不可能达成知情共识。这个问题在多重继承及其极端形式混入(mixin)中加倍地恶化。你依赖那些你可能无法控制的、彼此不了解的类,在同一个命名空间中共存。 另一个问题是,你无法控制从基类暴露给用户的方法和属性。它们就在那里,污染了你的 API。随着时间的推移,你的基类不断发展,添加或重命名方法和属性,变化可能在发生。这就是 attrs(以及最终的 dataclasses)选择使用类装饰器而不是子类的其中一个原因:你必须慎重对待你附加到类中的东西,以防不小心把一些东西泄露给所有的子类。
  3. 从属关系不明。这是前一个问题的一个特例,也是 Augie 和 Nathaniel 演讲的重点。如果每个方法都在 self 上,那么在看调用的时候就搞不清它来自哪里了。除非你非常仔细,否则每一次尝试理解控制流都会以捕风捉影而告终。一旦涉及到多重继承,你最好阅读一下 MRO 和 super() 的内容。我认为,如果一个可概括为「super() 是干什么用的?」的问题能在 StackOverflow 上得到近 3000 次的赞和超过 1000 个收藏,那就有些不对劲了。 如果你构建的 API 需要继承来实现或覆盖已有方法并能在其他地方调用,那么所有这些都会变得更加麻烦。Twistedasyncio 都分别在他们的 Protocol3 类中犯了这些错,给我留下了永久的阴影。最常见的问题是,要找出哪些方法是存在的(尤其是在像 Twisted 这样的深层次结构中)非常麻烦,以及如果你方法的名字错了一点点,基类找不到,往往会静默地失败4。 「基于继承的设计也是一个巨大的错误」可能是编程中最常说的一句话。— Cory Benfield 的推特

只有当我需要改变一个不受我控制的类的行为时,我才使用继承来共享代码。我认为这是一种不那么恶劣的猴子补丁(monkeypatch) 的方式。通常情况下,用适配器、外观、代理或装饰器模式会更好,但在有些情况下,如果你只想改变一个小的细节,你需要委托的方法数量会让你抓狂。

在任何情况下,都不要把它当成你设计中的核心部分。

类型二:抽象数据类型(接口)

抽象数据类型(ADT)主要是为了收紧接口协议。你可以说你想要一个具有某些属性、方法的对象,而不关心其他的。在许多语言中,它们被称为接口,听起来没有那么高大上,所以我从现在开始将使用这个术语。

由于 Python 是动态类型的语言,而且类型注解是可选的,所以你不需要正式的接口。然而,有一种明确定义接口的方法还是非常有帮助的,你需要它来使一段代码发挥作用。而且,自从 Mypy 这样的类型检查器出现后,它们已经成为某种经过验证的 API 文档,我觉得这很好。

例如,你想写一个函数,接受具有 read() 方法的对象,你将以某种方式定义一个具有该方法的接口 Reader(下面即将解释如何定义),并像这样使用它:

代码语言:javascript复制
def printer(r: Reader) -> None:
    print(r.read())

printer(FooReader())

你的 print() 函数并不关心 read() 在做什么,只要它返回一个可以打印的字符串。它可以返回一个预定义的字符串,读取一个文件,或者进行一个网络 API 调用。 printer() 不在乎,如果你试图在它身上调用 read() 以外的任何其他方法,你的类型检查器就会报警。


Python 标准带有两种定义接口的方法:

  1. 抽象基类(ABC)是 zope.interface 的低配版,使用名义子类型工作。它从 Python 2.6 起就存在了,标准库中用得到处都是。 请注意,不是每个抽象基类都是抽象数据类型。有时它只是一个不完整的类,你应该通过继承它并实现其抽象方法来完成它——而不是一个接口。不过,这种区别并不总是百分百清晰的。
  2. 协议(Protocol)通过使用结构子类型来避免继承。它是在 Python 3.8 中添加的,但是 typing-extensions 可以让它最低在 Python 3.5 中可用。

名义子类型结构子类型这两个词太大了,但好在解释起来很直接。

名义子类型

名义子类型意思是你必须告诉类型系统,你的类是一个接口定义的子类型。ABC 通常通过继承来实现这一点,但你也可以使用 register() 方法。

下面展示了你如何从上面的介绍中定义 Reader 接口并将 FooReaderBarReader 标记为它的实现:

代码语言:javascript复制
import abc

class Reader(metaclass=abc.ABCMeta):
    @abc.abstractmethod
    def read(self) -> str: ...

class FooReader(Reader):
    def read(self) -> str:
        return "foo"

class BarReader:
    def read(self) -> str:
        return "bar"

Reader.register(BarReader)

assert isinstance(FooReader(), Reader)
assert isinstance(BarReader(), Reader)

如果 FooReader 没有一个叫做 read 的方法,实例化就会在运行时失败。如果你像 BarReader 那样使用 register() 方式,接口在运行时不会被验证,它将成为一个(正如文档中所说)「虚拟子类」。这让你可以自由地使用更加动态的,或者说神奇的手段来提供所需的接口。由于 register() 唯一参数是实现对象,你可以把它作为一个类的装饰器来使用,省去两行空行。

名义子类型不仅接受,而且鼓励多重继承,因为在理想情况下,没有方法,没有行为被继承,也就没有可能混合,只有类的身份被复合。一个类可以实现许多不同的接口,接口越小越好。


使用 ABC 定义接口的一个「好处」是,通过继承它们,你可以在抽象基类中添加普通的方法来偷渡到代码共享。但正如一开始提到的:混合子类类型是个坏主意。通过继承实现代码共享是个坏主意,多重继承使它成为一个更坏的主意。

公平地说,我已经看到了这种模式的良好应用,但你必须非常谨慎地使用这个方法。在 Python 中的一个成例是,当你需要根据其他定义良好的行为来实现一大堆魔法方法5。一个好的例子是 collections.UserDict。基于上述原因,它不是很好,但在 Python 的约束和文化中,它是一个很好的权衡。然而,在 UserDict 的例子中,当你试图在你的子类上增加比预期的 dict 更多的行为时,它就会出问题。然后,关于继承共享代码的那一节中的问题就会重新出现。为了避免这种情况,让类保持单一责任。

结构子类型

结构子类型也就是鸭子类型:如果你的类满足了一个协议的约束,它就会自动被认为是它的一个子类型。因此,一个类可以从各种包中实现许多协议而无需它们的存在!

默认情况下,这只对类型检查器起作用,但如果你应用 typing.runtime_checkable(),你也可以对它们执行 isinstance() 检查。

上节中的例子如下:

代码语言:javascript复制
from typing import Protocol, runtime_checkable

@runtime_checkable
class Reader(Protocol):
    def read(self) -> str: ...

class FooReader:
    def read(self) -> str:
        return "foo"

assert isinstance(FooReader(), Reader)

如你所见,FooReader 根本不知道 Reader 协议的存在!


我非常喜欢 Protocol,因为它允许我完全不受干扰地定义我需要的接口,而且这个定义可以和接口的消费者共存。当你在同一个代码库中对同一个接口有不同的实现时,这点就非常有用。例如,你可以有一个接口 MailSender,在生产环境中发送电子邮件,但在开发中只是打印到控制台6。

或者,如果你只使用第三方类的一个小子集,并希望明确是哪个子集。这就是很好的(而且是经过验证的!)文档,在为你的测试伪造实现的时候也有帮助。

关于 Protocol结构子类型的更多细节,请查看 glyph 的《我想要一只新鸭子》。

虽然这种类型的继承大多是无害的,但由于 typing.Protocol 和抽象基类的 register() 方法,你不需要对 Python 中的抽象数据类型进行继承

类型三:特化

所以我们已经介绍了一个有害的继承类型和一个不必要的继承类型,终于要说到好的类型。事实上,即便你想,在 Python 中你也无法绕过这种继承方式。除非你不想使用 Exception

有趣的是,特化常常被误解。直观地说,这很容易:如果我们说一个类 B 特化了基类 A,其实就是说类 B 是具有额外属性的 A。一只狗是一种动物,A350 是一架客机。它们拥有基类的所有属性,并增加了属性、方法,或者只是在一个层次结构中增加了一个位置7。

尽管这种简单很诱人,但它经常被错误地使用。最臭名昭著的错误是认为正方形是长方形的特化,因为从几何学上讲,它是一个特例。然而,正方形并不是拥有额外行为属性的长方形

你不能在所有可以使用长方形的地方使用正方形,除非代码知道它也接受一个正方形8。如果你不能把一个对象当作其基类的实例来交互,你就违反了里氏替换原则9,你就不能写多态的代码。

如果你仔细观察,你会发现上一节的接口是特化的一个特例。你总是把一个通用的 API 协议特化为一些具体的东西。关键的区别在于,抽象的数据类型是......嗯......抽象的。


我发现特化在表示一个具有严格层次结构的数据时相当有用。

例如,设想你想把电子邮箱账户表示为类。它们都共享一些数据,比如它们在数据库中的 ID 和邮箱地址,此外,根据账户的类型,它们(可以)有额外的属性。重要的是,这些增加的属性和方法几乎没有改变现有的属性和方法。例如,一个在服务器上存储电子邮件的邮箱需要哈希过的密码作为登录信息,而一个接收电子邮件并只将其转发到另一个邮箱地址的账户则不需要10。

你将有以下的四种方法。

方法 1:为每种情况专门创建一个类

这些将是你最终想要的类:

代码语言:javascript复制
class Mailbox:
    id: UUID
    addr: str
    pwd: str

class Forwarder:
    id: UUID
    addr: str
    targets: list[str]

地址的类型标注在类中,每个类只有它要用的字段。如果你的模型如此简单,这绝对已经足够了。只有在你有更多的字段和类型时,去除重复代码的尝试才是有意义的。

你添加到任何一个类中的任何方法都将完全独立于另一个类,不留下任何混淆的空间。你也可以配合联合类型的类型检查:Mailbox | Forwarder


通常,在任何情况下都可以从这种方法开始,因为代码重复要比错误的抽象的代价要小得多。你能直接看到所有可能的字段,使进一步的设计决策容易得多。

方法 2:只创建一个类,把字段变成可选的

条件总是会恶化,条件会重复产生。— Sandi Metz,无招胜有招

当时不计一切代码避免继承,同时避免重复自己时,你最终很可能得到以下的结果:

代码语言:javascript复制
class AddrType(enum.Enum):
    MAILBOX = "mailbox"
    FORWARDER = "forwarder"

class EmailAddr:
    type: AddrType
    id: UUID
    addr: str

    # Only useful if type == AddrType.MAILBOX
    pwd: str | None
    # Only useful if type == AddrType.FORWARDER
    target: list[str] | None

从技术上讲,这更 DRY 了,但这样会让类的实例用起来更加别扭。大多数字段的类型与存在完全取决于 type 字段的值,而 type 的存在仅仅因为所有的地址类型都共用同一个类。

这与我最喜欢的设计原则相矛盾,即让非法状态无法表示,而且使用类型检查器进行合理的检查也变得不可能,因为类型检查器会一直告诉你你在访问可能是 None 的字段。

事实上,所有在这个类上的行为都会被混在一起,这导致了大量的条件(if-elif-else 语句),大大增加了你代码的复杂性。多态的全部意义就是为了避免这种情况。

拥有可选的属性11 有可能是一面红旗。拥有需要注释来解释何时使用它们的字段则是五月节集会。正如有争议的类型注解一样,在这种情况下,它清楚地向你指出了你的模型有问题。如果没有类型检查,你必须注意到你的代码超过了它本应有的复杂,而这就不是那么直接了。


你可以让这种情况稍微不那么痛苦,把特定邮箱的数据移到一个类中,然后只让那个字段可选。这样做好一些,但仍然别扭,并且毫无必要。

方法 3:组合

这种方法把上一个方法反了过来,虽然这在我们过于简单的数据模型中用起来很傻,但是我们假设 EmailAddr 有更多的字段,以至于它值得被包装成一个独立的类:

代码语言:javascript复制
class EmailAddr:
    id: UUID
    addr: str

class Mailbox:
    email: EmailAddr
    pwd: str

class Forwarder:
    email: EmailAddr
    targets: list[str]

这种方法并没那么差!我们没有可选字段,所有的数据关系都很清楚。就可读性和清晰性而言,没有什么可抱怨的。

只是这种方法也很别扭,你不需要咨询 Guido 就能意识到它一点也不 Pythonic。那么,尽管组合应该比继承更好,为什么它看起来如此矫揉造作呢?因为 EmailAddrMailbox/Forwarder 的关系太密切了,甚至给地址字段命名都很奇怪。组合并没有让我们失望,但在这种情况下,强迫建立一个包含的关系,感觉就像是在违背规律。

方法 4:创建一个基类,然后特化它

最后,在我看来是最符合人体工程学,DRY,明显,适合类型检查的方法:

代码语言:javascript复制
class EmailAddr:
    id: UUID
    addr: str

class Mailbox(EmailAddr):
    pwd: str

class Forwarder(EmailAddr):
    targets: list[str]

只要你有一个邮箱,你就知道它有一个 pwd 字段,类型检查器也知道。类型是在类中标注的,所以你不必在一个字段中重复它。严格意义上 Mailbox 是一个 EmailAddr 加上更多。

至于代码,你现在必须了解子类的职责规则,比如前面提到的里氏替换原则。这带来了额外的复杂度和脑力的开销,但是边界和责任更加清晰了。

继承需要你的了解和自律。组合则机械地迫使你遵守纪律,尽管它会让代码显得笨拙。 这可能是让你用组合最简单的理由:它给你留下的错误空间更小。

和所有类型的继承一样,代码可读性会受到影响,因为你必须在头脑中组装出最终的类,才能知道存在哪些字段。但实际上你得到的是与第一种方法相同的类。只要你不做得太过分,并且最好让定义在物理上相互接近,在这种情况下,这是最好的权衡。

这种方法非常有用,我在我的 PEM 文件解析库中使用到了,至今仍不后悔。


从本节中可以得出一个一般建议:首先要关注你的数据的结构,然后才是如何处理它。

一旦你确定了结构,行为就会更自然。一个很好的例子是 Sans I/O 运动,它显然是数据优先,因为在设计上,行为应该是可以随意替换。

只要你在特化的同时避免方法之间的跨层级互动,就应该没有大问题。但是要经常问问自己,一个函数是否足够了?尤其是当你在两个或更多的类之间协调工作,并且没有多态可利用的时候。如果你不能决定一个方法属于哪个类,那么答案往往是都不属于。

最后一定要了解一下 @singledispatch;如果你没了解过,会觉得它是魔法。

作为额外收获,如果你遵循这些方法,你会得到高度可测的对象。

蟒蛇之外

上述最后一种方法非常好用,以至于「我们没有继承」的 Go 也具备这个能力,只是叫做嵌套:

代码语言:javascript复制
type EmailAddr struct {
	addr string
}

type Mailbox struct {
	EmailAddr
	pwd string
}

这样 Mailbox 的实例也拥有了 addr 属性,就好像它自己定义了一样:https://play.golang.org/p/WSjJA6MYUDb。但你在初始化时仍然必须显式传入,而且没有实际的层次结构。没有 super()。你只能从侧面调用。这是一个对现状的妥协。

回顾之前的章节,这是方法 3 的语法,但其实在很多层面,你得到了方法 1 中的类。

在 Go 中看到这一点对我来说是一个启示,因为我自己基于直觉的子类启发式方法符合这种模式,但我不知道如何阐述。现在我可以说,当我可以,并且愿意在 Go 中使用嵌套时,我就是在用继承。

接下来呢?

至于可读性,适当的组合比继承要好。由于读代码比写代码的时候要多得多,所以一般情况下要避免使用子类,特别是不要混合多种类型的继承,也不要使用继承来共享代码。不要忘了,更多的时候,你需要的只是一个函数而已

重要的是要记住,在一个基于继承的设计中,不是想停用继承就可以停止。基于组合的设计从头到尾都是不同的,所以你可能要改变一些观念和手段。

尽管不是基于 Python 的,但我所知道的最好的对 OOP 设计的介绍是《99 瓶 OOP》,如果你还没读过建议一读。它不仅相当有指导意义,而且读起来也很有趣。

为了不显得我敷衍,我准备用一个具体的例子作为总结。

案例分析

我将使用那本特别精彩的《Python 架构模式》中润色好的代码,该书是我帮助审阅的,绝对值得你的时间和金钱。我在这里使用它是因为 Harry——他是该书的作者之一——在我抱怨过后让我写一篇博文。


我们的目标是实现仓库模式:一个允许你向数据仓库中添加和检索对象的类。此外,它还必须记住所有添加或检索的对象,并将其放在一个叫做 seen 的字段上,但这不是这篇博文所关注的。

一个重要的设计目标是让存储仓库可插拔,所以它可以,譬如说在生产中使用 Postgres 这样的数据库,在单元测试中则使用字典。但是记录对象的代码对于所有的实现都是一样的,因此你希望这部分共享代码。

特化在这里不起作用,因为它方向是错的:跟踪仓库是「普通」仓库的特化。因此,我们想要共享的代码会出现在子类中。这里用不上。

因此,这本书使用了我最不喜欢的基于继承的代码共享类型:模板方法模式。这意味着基类提供了一个整体的控制流程,而子类则填补了一些细节:

  1. 用户实例化一个子类,
  2. 然后调用基类上的方法,
  3. 其中又调用了子类中的方法。

在这个例子中,子类需要实现的方法是 _add_product_get_by_sku

代码语言:javascript复制
class AbstractRepository(abc.ABC):
    seen: set[Product]

    def __init__(self) -> None:
        self.seen = set()

    def add_product(self, product: Product) -> None:
        self._add_product(product)
        self.seen.add(product)

    def get_by_sku(self, sku: str) -> Product | None:
        product = self._get_by_sku(sku)
        if product:
            self.seen.add(product)

		return product

    @abc.abstractmethod
    def _add_product(self, product: Product):
        raise NotImplementedError

    @abc.abstractmethod
    def _get_by_sku(self, sku: str) -> Product | None:
        raise NotImplementedError

因此,每个子类都必须定义 _add_product()_get_by_sku() 方法。然后用户调用 AbstractRepositoryadd_product()get_by_sku() 方法,这些方法又委托给子类的 _add_product()_get_by_sku() ,同时记住它看到过哪些 Product 类型的对象12。

热心的读者会马上发现继承的原罪:它将接口的定义与子类的共享代码混在一起。如果你想复习一下为什么这很糟糕,回头去看一下《继承没有任何意义》(我在介绍中已经贴过链接了)。

更实际的问题是,如果你想理解代码的工作流,由于去向和来源在类层级中来回横跳,这会很困难。

即使对用户来说也是如此,因为公共 API 是由抽象基类定义的,而不是你实际实例化的那个类!这一点在文档系统中往往处理得不好,你不得不在阅读时跳来跳去。


当面对这样的代码,想摆脱子类的桎梏,有两个选择:

  1. 对类进行包装。不要让它成为 self 的一部分,而是把它存储在一个实例属性中。根据需要委托给该实例的方法。
  2. 对行为参数化。一旦你需要在多维度定制一个类的行为,而通过继承共享代码的方式又不可行时,这就是要走的路。听起来很复杂,但 Sandi Metz 在前面提到的《无招胜有招》的演讲中完美地展示了这一点,只用几行代码就可以实现排序和格式的自定义。 对大多数人来说,直到点击的时候,你才会掌握——至少我是这样。

我们的例子很简单:我们只想做具体存储库要做的事情,再加上其他的事情13。因此,我们选择一号方案。如果你稍稍眯起眼睛,你会发现这里的模板子类的方式也不过是在包装一个类。除了命名空间混在一起,控制流混乱之外。

仓库类

我们通过一个叫 Repository 的协议来定义接口,取代之前的拥有一堆代码的抽象基类:

代码语言:javascript复制
class Repository(typing.Protocol):
    def add_product(self, product: Product) -> None: ...
    def get_by_sku(self, sku: str) -> Product | None: ...

当然,如果你不用类型注解,你可以忽略这一步。


一个简单的使用字典来存储数据的实现大概像这样:

代码语言:javascript复制
class DictRepository:
    _storage: dict[str, Product]

    def __init__(self):
        self._storage = {}

    def add_product(self, product: Product) -> None:
        self._storage[product.sku] = product

    def get_by_sku(self, sku: str) -> Product | None:
        return self._storage.get(sku)

仓库必须实现这两个承诺的公共方法,但整个类是自包含的。没有任何命名冲突的风险。它只有一个职责:保存和检索产品。它也不需要知道有一个叫做 Repository 的协议存在;类型检查器会帮你弄清楚它是一个实现。

跟踪仓库类

下一步,让我们来基于 Repository 实现跟踪功能,只需要在外面包装一层:

代码语言:javascript复制
class TrackingRepository:
    _repo: Repository
    seen: set[Product]

    def __init__(self, repo: Repository) -> None:
        self._repo = repo
        self.seen = set()

    def add_product(self, product: Product) -> None:
        self._repo.add_product(product)
        self.seen.add(product)

    def get_by_sku(self, sku: str) -> Product | None:
        product = self._repo.get_by_sku(sku)
        if product:
            self.seen.add(product)

        return product

这个类由两个东西组合而成:一个只知道是实现了 Repository 的对象,和一些 Products。如果你在 _repo 属性上使用其他未被 Repository 接口承诺的东西,无需执行代码,类型检查器就会对你报警。

小结

这个版本我喜欢多了,因为它有一个清晰的程序流。你知道方法和属性来自哪里,而不需要检查任何基类。

这种清晰的代价是仓库必须保存在我们的类上(_repo),并调用 self._repo.add_product() 而不是 self._add_product() 。这就需要多打点字。

另一方面,我们最终得到了两个独立的小类,它们之间唯一的协定是一个严格的、明确的接口。这不仅易于阅读理解,而且也易于测试

作为结束前的寄司:如果你想知道如何为代码写测试,而不是像所有的测试教程中那样只是字符串操作或两个数字相加,我希望你现在看到,学习更好的 OOP 设计也会对你有所帮助。

结语

哇,你熬过来了! 谢谢你坚持不懈地支持我! 我的最终目标是在讨论中加入更多的细微差别。我想让你明白,使用 Exception 并不意味着也要使用模板方法模式,因为「两者都是继承」。我希望我稍微成功了一点。

由于其长度,这篇文章不太可能得到大量的「看、读、转发/投票」分享。更有可能的是,它在你打开的标签页、你的阅读队列中多停留了一些时间!如果你能以某种方式分享它,帮助它传播,那再好不过。

欢迎告诉我你对本文的看法,或者你花了多少杯咖啡才读完它。我不打算在公共讨论上花太多时间,因为它们往往会变得过于激烈、迂腐和教条。我写这篇文章的原因之一是能直接对他们甩 URL,希望对你也能起到同样的作用!

我正在根据本文准备一个演讲,所以如果你希望在你的会议或者公司中加入这个演讲,请联系我!只要我有机会亲临现场。

最后,如果你想看到更多像这样的内容,可以考虑支持我。

Footnotes

  1. 首先明确我不打算谈论标准库的 API。诚然,SimpleHTTPServer 要求你必须继承,但这是一个 API 的选择,并不是 Python 的固有设计。 ↩
  2. 虽然很有见地,但链接的文章可能难以理解,因为它使用的黑话可能对你很陌生,这取决于你对其他编程语言的经验。你不需要阅读它来理解这篇博文。另一方面,读完这篇博文后可能会更容易理解。 ↩
  3. 与下面要谈的 typing.Protocol 完全无关。 ↩
  4. 我在这里点出 Twisted,因为当我们意识到我们的错误时,我就是核心团队的一员。这是一个公认的错误,而不是隐藏的。 ↩
  5. 有点令人困惑的是,这也被称为「实现一个协议」。在 Python 文档中搜索 protocol 这个词,可以得到一堆结果。 ↩
  6. HBO 你好! ↩
  7. 例如,对于 Exception 来说,一个很常见的情况是只标注某个异常是 ValueError 的一个子类,而不增加新的方法、属性或行为。 ↩
  8. 一个常见的例子是,有一段代码是用来给长方形独立操作宽度和高度的。对正方形的实现必须在改变宽度时隐式地改变高度(反之亦然),或者引发一个错误。 ↩
  9. SOLID 中的 L。 ↩
  10. 为了简洁起见,我把这个例子写得很短。显然,对于两个各有两个字段的类型,这么大费周章是没有意义的。我还使用了 Python 3.10 风格的类型注解,其中可以使用 | 来代替 typing.Union。因此 str | None 等同于 Union[str, None],而后者又等同于 Optional[str]。你也可以使用容器类型,如 list[str],而不用从 typing 中导入 List。如果你 from __future__ import annotations,而且不需要像 typing.get_type_hints() 那样的运行时类型自省,并且你的 Mypy 足够新的话,你甚至可以在更旧的 Python 版本中使用这种语法。 ↩
  11. 可以为 None 的属性。 ↩
  12. 为方便讨论,Product 的数据结构无需关注,只需要知道它有一个 str 类型的 sku 字段。 ↩
  13. 技术上来说,这其实就是「装饰器模式」。我们维持原始 API,并加上其他行为。 ↩

0 人点赞