← 返回文章流

再谈 Python 中的继承(译)

本文是 Subclassing in Python Redux 的中文版。在阅读的过程中,我发现与我的「友好的 Python」不谋而合,故向作者请求翻译此文。版权归原作者 Hynek Schlawack 所有。除非特别说明,本文所有的「我」均指原作者 Hynek。 <!-- more --> 继承与组合之间的冲突就和面向对象编程一样古老。一些最新的语言,如 Go 和 Rust,证明了你不需要继承也能编写代码。但是具体在 Python 语言中,有什么实用的继承的方法呢? 任何长期关注我的人都知道,我是坚定地站在组合而非继承的阵营。然而 Python 设计如此,有时如果不用继承,你就无法写出惯常的代码。我写这篇文章的目的就是思考这个问题,这个「有时」是何时,并解开我对这个问题的直觉[^1]。 [^1]: 首先明确我不打算谈论标准库的 API。诚然,SimpleHTTPServer 要求你必须继承,但这是一个 API 的选择,并不是 Python 的固有设计。 我知道这篇文章很长。事实上,这是我自 2006 年毕业论文以来写的最长的一篇文章。客观地说,我应该把它分成至少三个部分。这样会更有利于(SEO!社交媒体!点击率!),也更有可能让人们真正读到最后。 但我还是希望它单独成篇。我希望它是我多年来所学的精髓的提炼。当然,你想停下来就停下来,下次再读——反正这篇文章就在这里。 我们先从细微之处开始。为什么许多关于继承的讨论如此令人沮丧,没有结果?其中一个原因是继承的类型不止一种。正如那篇精彩的文章《为什么继承没有任何意义》所解释的^2,继承有有三种类型,不应混为一谈——无论你对继承有什么看法。 这有可能造成三类人互相争论,每个人都认为自己的方式正确,永远不去找共同点。我们都看到过这些讨论是如何展开的。 根据我的经验,如果严格分开使用——一种是好的,一种是可选但有用的,还有一种是差的。继承的大多数问题都源于我们试图同时使用一种以上的继承类型——或者在对象设计中重点使用差的那种类型。 在所有情况下,你都要牺牲阅读的便利来换取写代码的便利。这不一定是坏事,软件设计在于权衡,你可以得出结论,在某些情况下这是完全值得的。这就是为什么我不指望你会赞同下面的所有内容,但我希望能引发一些思考,以帮助你在未来做出决定。 但现在絮叨到此为止,让我们看看这三种类型。从差的那一种开始。 类型一:代码共享 Namespaces are one honking great idea – let’s do more of those! <cite>— Tim Peters,Python 之禅</cite> 大多数对继承的批评来自于代码共享,这是理所当然的。我不觉得我有什么可以补充的,所以我打算直接放几个比我更睿智和更杰出的作品链接: 组合优于继承原则,Brandon Rhodes, 对象继承的终结,新模块化的开始,Augie Fackler 与 Nathaniel Manista,PyCon US 2013, 以及 无招胜有招,Sandi Metz,RailsConf 2015. 简而言之,总体上有三个问题: 不止一个轴上的变化。这是,Brandon 的文章和 Sandi 演讲的后半部分的主要思想。这不容易解释,所以我将直接引用他们的作品,但其本质是:如果你想定制一个类的多于一个行为方面,通过继承共享代码是行不通的。它会导致子类爆炸。 这不是一种观点或一种权衡。这是一个事实。 类和实例命名空间混淆。如果你在一个继承自一个或多个基类的类中有一个属性 self.x,那么你就需要研究并耗费脑力来找出 x 的来源。阅读代码时如此,调试时也如此。 这也意味着总是存在这样的危险:在同一层次结构中的两个类,它们彼此不认识,却拥有一个同名的属性。虽然 Python 有双下划线前缀(__x)的概念来处理这种情况,但这被认为是不可取的,因为它更像是君子协定。 问题在于,如果不是各方面都知情,就不可能达成知情共识。这个问题在多重继承及其极端形式混入(mixin)中加倍地恶化。你依赖那些你可能无法控制的、彼此不了解的类,在同一个命名空间中共存。 另一个问题是,你无法控制从基类暴露给用户的方法和属性。它们就在那里,污染了你的 API。随着时间的推移,你的基类不断发展,添加或重命名方法和属性,变化可能在发生。这就是 attrs(以及最终的 dataclasses)选择使用类装饰器而不是子类的其中一个原因:你必须慎重对待你附加到类中的东西,以防不小心把一些东西泄露给所有的子类。 从属关系不明。这是前一个问题的一个特例,也是 Augie 和 Nathaniel 演讲的重点。如果每个方法都在 self 上,那么在看调用的时候就搞不清它来自哪里了。除非你非常仔细,否则每一次尝试理解控制流都会以捕风捉影而告终。一旦涉及到多重继承,你最好阅读一下 MRO 和 super() 的内容。我认为,如果一个可概括为「super() 是干什么用的?」的问题能在 StackOverflow 上得到近 3000 次的赞和超过 1000 个收藏,那就有些不对劲了。 如果你构建的 API 需要继承来实现或覆盖已有方法并能在其他地方调用,那么所有这些都会变得更加麻烦。Twisted 和 asyncio 都分别在他们的 Protocol[^3] 类中犯了这些错,给我留下了永久的阴影。最常见的问题是,要找出哪些方法是存在的(尤其是在像 Twisted 这样的深层次结构中)非常麻烦,以及如果你方法的名字错了一点点,基类找不到,往往会静默地失败[^4]。 「基于继承的设计也是一个巨大的错误」可能是编程中最常说的一句话。<cite>— Cory Benfield 的推特</cite> [^3]: 与下面要谈的 typing.Protocol 完全无关。 [^4]: 我在这里点出 Twisted,因为当我们意识到我们的错误时,我就是核心团队的一员。这是一个公认的错误,而不是隐藏的。 只有当我需要改变一个不受我控制的类的行为时,我才使用继承来共享代码。我认为这是一种不那么恶劣的猴子补丁(monkeypatch) 的方式。通常情况下,用适配器、外观、代理或装饰器模式会更好,但在有些情况下,如果你只想改变一个小的细节,你需要委托的方法数量会让你抓狂。 在任何情况下,都不要把它当成你设计中的核心部分。 类型二:抽象数据类型(接口) 抽象数据类型(ADT)主要是为了收紧接口协议。你可以说你想要一个具有某些属性、方法的对象,而不关心其他的。在许多语言中,它们被称为接口,听起来没有那么高大上,所以我从现在开始将使用这个术语。 由于 Python 是动态类型的语言,而且类型注解是可选的,所以你不需要正式的接口。然而,有一种明确定义接口的方法还是非常有帮助的,你需要它来使一段代码发挥作用。而且,自从 Mypy 这样的类型检查器出现后,它们已经成为某种经过验证的 API 文档,我觉得这很好。 例如,你想写一个函数,接受具有 read() 方法的对象,你将以某种方式定义一个具有该方法的接口 Reader(下面即将解释如何定义),并像这样使用它: def printer(r: Reader) -> None: print(r.read()) printer(FooReader()) 你的 print() 函数并不关心 read() 在做什么,只要它返回一个可以打印的字符串。它可以返回一个预定义的字符串,读取一个文件,或者进行一个网络 API 调用。 printer() 不在乎,如果你试图在它身上调用 read() 以外的任何其他方法,你的类型检查器就会报警。 Python 标准带有两种定义接口的方法: 抽象基类(ABC)是 zope.interface 的低配版,使用名义子类型工作。它从 Python 2.6 起就存在了,标准库中用得到处都是。 请注意,不是每个抽象基类都是抽象数据类型。有时它只是一个不完整的类,你应该通过继承它并实现其抽象方法来完成它——而不是一个接口。不过,这种区别并不总是百分百清晰的。 协议(Protocol)通过使用结构子类型来避免继承。它是在 Python 3.8 中添加的,但是 typing-extensions 可以让它最低在 Python 3.5 中可用。 名义子类型和结构子类型这两个词太大了,但好在解释起来很直接。 名义子类型 名义子类型意思是你必须告诉类型系统,你的类是一个接口定义的子类型。ABC 通常通过继承来实现这一点,但你也可以使用 register() 方法。 下面展示了你如何从上面的介绍中定义 Reader 接口并将 FooReader 和 BarReader 标记为它的实现: 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 更多的行为时,它就会出问题。然后,关于继承共享代码的那一节中的问题就会重新出现。为了避免这种情况,让类保持单一责任。 [^5]: 有点令人困惑的是,这也被称为「实现一个协议」。在 Python 文档中搜索 protocol 这个词,可以得到一堆结果。 结构子类型 结构子类型也就是鸭子类型:如果你的类满足了一个协议的约束,它就会自动被认为是它的一个子类型。因此,一个类可以从各种包中实现许多协议而无需它们的存在! 默认情况下,这只对类型检查器起作用,但如果你应用 typing.runtime_checkable(),你也可以对它们执行 isinstance() 检查。 上节中的例子如下: 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(

来自 Frost's Blog作者主页
在原博客阅读全文 (新标签页打开)

以上内容来自博客订阅源,版权归原作者。关注作者后可以在 关注列表 第一时间查看作者文章。

一起聊聊 0

尊重不同观点

还没有评论。欢迎分享第一个想法。

RSS SUBSCRIPTIONS

选择 RSS 订阅

把博客汇的文章添加到你常用的阅读器。

关注文章 RSS 订阅本订阅需要登录后使用