返回介绍

11.4 Alex Martelli 的水禽

发布于 2024-02-05 21:59:47 字数 5823 浏览 0 评论 0 收藏 0

介绍完 Python 常规的协议风格接口后,下面讨论抽象基类。不过在分析示例和细节之前,我们要看 Alex Martelli 写的一篇短文。这篇短文说明了 Python 为什么引入抽象基类。

 非常感谢 Alex Martelli。本书引用最多的就是他说的话,后来他变成了本书的技术编辑之一。他的见解已经非常宝贵了,现在又愿意撰写这篇短文。Python 社区有他的存在真是幸运。接下来交给你了,Alex !

水禽和抽象基类

Alex Martelli 撰

维基百科说是我协助传播了“鸭子类型”这种言简意赅的说法(即忽略对象的真正类型,转而关注对象有没有实现所需的方法、签名和语义)。

对 Python 来说,这基本上是指避免使用 isinstance 检查对象的类型(更别提 type(foo) is bar 这种更糟的检查方式了,这样做没有任何好处,甚至禁止最简单的继承方式)。

总的来说,鸭子类型在很多情况下十分有用;但是在其他情况下,随着发展,通常有更好的方式。事情是这样的……

近代,属和种(包括但不限于水禽所属的鸭科)基本上是根据表型系统学(phenetics)分类的。表征学关注的是形态和举止的相似性……主要是表型系统学特征。因此使用“鸭子类型”比喻是贴切的。

然而,平行进化往往会导致不相关的种产生相似的特征,形态和举止方面都是如此,但是生态位的相似性是偶然的,不同的种仍属不同的生态位。编程语言中也有这种“偶然的相似性”,比如说下述经典的面向对象编程示例:

class Artist:
  def draw(self): ...
    
class Gunslinger:
  def draw(self): ...
    
class Lottery:
  def draw(self): ...

显然,只因为 x 和 y 两个对象刚好都有一个名为 draw 的方法,而且调用时不用传入参数,即 x.draw() 和 y.draw(),远远不能确保二者可以相互调用,或者具有相同的抽象。也就是说,从这样的调用中不能推导出语义相似性。相反,我们需要一位渊博的程序员主动把这种等价维持在一定层次上。

生物(和其他学科)遇到的这个问题,迫切需要(从很多方面来说,是催生)表征学之外的分类方式解决,即支序系统学(cladistics)。这种分类学主要根据从共同祖先那里继承的特征分类,而不是单独进化的特征。(近些年,DNA 测序变得便宜又快,这使支序学的实用地位变得更高。)

例如,草雁(以前认为与其他鹅类比较相似)和麻鸭(以前认为与其他鸭类比较相似)现在被分到 Tadornidae 亚科(表明二者的相似性比鸭科中其他动物高,因为它们的共同祖先比较接近)。此外,DNA 分析表明,白翅木鸭与美洲家鸭(属于麻鸭)不是很像,至少没有形态和举止看起来那么像,因此把木鸭单独分成了一属,完全不在 Tadornidae 亚科中。

知道这些有什么用呢?视情况而定!比如,逮到一只水禽后,决定如何烹制才最美味时,显著的特征(不是全部,例如一身羽毛并不重要)主要是口感和风味(过时的表征学),这比支序学重要得多。但在其他方面,如对不同病原体的抗性(圈养水禽还是放养),DNA 接近性的作用就大多了……

因此,参照水禽的分类学演化,我建议在鸭子类型的基础上增加白鹅类型(goose typing)。

白鹅类型指,只要 cls 是抽象基类,即 cls 的元类是 abc.ABCMeta,就可以使用 isinstance(obj, cls)。

collections.abc 中有很多有用的抽象类(Python 标准库的 numbers 模块中还有一些)。3

与具体类相比,抽象基类有很多理论上的优点(例如,参阅 Scott Meyer 写的《More Effective C++:35 个改善编程与设计的有效方法(中文版)》的“条款 33:将非尾端类设计为抽象类”,英文版见 http://ptgmedia.pearsoncmg.com/images/020163371x/items/item33.html),Python 的抽象基类还有一个重要的实用优势:可以使用 register 类方法在终端用户的代码中把某个类“声明”为一个抽象基类的“虚拟”子类(为此,被注册的类必须满足抽象基类对方法名称和签名的要求,最重要的是要满足底层语义契约;但是,开发那个类时不用了解抽象基类,更不用继承抽象基类)。这大大地打破了严格的强耦合,与面向对象编程人员掌握的知识有很大出入,因此使用继承时要小心。

有时,为了让抽象基类识别子类,甚至不用注册。

其实,抽象基类的本质就是几个特殊方法。例如:

>>> class Struggle:
...   def __len__(self): return 23
...
>>> from collections import abc
>>> isinstance(Struggle(), abc.Sized)
True

可以看出,无需注册,abc.Sized 也能把 Struggle 识别为自己的子类,只要实现了特殊方法 __len__ 即可(要使用正确的句法和语义实现,前者要求没有参数,后者要求返回一个非负整数,指明对象的长度;如果不使用规定的句法和语义实现特殊方法,如 __len__,会导致非常严重的问题)。

最后我想说的是:如果实现的类体现了 numbers、collections.abc 或其他框架中抽象基类的概念,要么继承相应的抽象基类(必要时),要么把类注册到相应的抽象基类中。开始开发程序时,不要使用提供注册功能的库或框架,要自己动手注册;如果必须检查参数的类型(这是最常见的),例如检查是不是“序列”,那就这样做:

isinstance(the_arg, collections.abc.Sequence)

此外,不要在生产代码中定义抽象基类(或元类)……如果你很想这样做,我打赌可能是因为你想“找茬”,刚拿到新工具的人都有大干一场的冲动。如果你能避开这些深奥的概念,你(以及未来的代码维护者)的生活将更愉快,因为代码会变得简洁明了。再会

3当然,你还可以自己定义抽象基类,但是我不建议高级 Python 程序员之外的人这么做;同样,我也不建议你自己定义元类……我说的“高级 Python 程序员”是指对 Python 语言的一招一式都了如指掌,即便对这类人来说,抽象基类和元类也不是常用工具。如此“深层次的元编程”,如果可以这么讲的话,适合框架的作者使用,这样便于众多不同的开发团队独立扩展框架……真正需要这么做的“高级 Python 程序员”不超过 1%。——Alex Martelli

除了提出“白鹅类型”之外,Alex 还指出,继承抽象基类很简单,只需要实现所需的方法,这样也能明确表明开发者的意图。这一意图还能通过注册虚拟子类来实现。

此外,使用 isinstance 和 issubclass 测试抽象基类更为人接受。过去,这两个函数用来测试鸭子类型,但用于抽象基类会更灵活。毕竟,如果某个组件没有继承抽象基类,事后还可以注册,让显式类型检查通过。

然而,即便是抽象基类,也不能滥用 isinstance 检查,用得多了可能导致代码异味,即表明面向对象设计得不好。在一连串 if/elif/elif 中使用 isinstance 做检查,然后根据对象的类型执行不同的操作,通常是不好的做法;此时应该使用多态,即采用一定的方式定义类,让解释器把调用分派给正确的方法,而不使用 if/elif/elif 块硬编码分派逻辑。

 具体使用时,上述建议有一个常见的例外:有些 Python API 接受一个字符串或字符串序列;如果只有一个字符串,可以把它放到列表中,从而简化处理。因为字符串是序列类型,所以为了把它和其他不可变序列区分开,最简单的方式是使用 isinstance(x, str) 检查。4

4可惜,在 Python 3.4 中没有能把字符串和元组或其他不可变序列区分开的抽象基类,因此必须测试 str。在 Python 2 中,basestr 类型可以协助这样的测试。basestr 不是抽象基类,但它是 str 和 unicode 的超类;然而,Python 3 把 basestr 去掉了。奇怪的是,Python 3 中有个 collections.abc.ByteString 类型,但是它只能检测 bytes 和 bytearray 类型。

另一方面,如果必须强制执行 API 契约,通常可以使用 isinstance 检查抽象基类。“老兄,如果你想调用我,必须实现这个”,正如本书技术审校 Lennart Regebro 所说的。这对采用插入式架构的系统来说特别有用。在框架之外,鸭子类型通常比类型检查更简单,也更灵活。

例如,本书有几个示例要使用序列,把它当成列表处理。我没有检查参数的类型是不是 list,而是直接接受参数,立即使用它构建一个列表。这样,我就可以接受任何可迭代对象;如果参数不是可迭代对象,调用立即失败,并且提供非常清晰的错误消息。本章后面示例 11-13 中的代码就是这么做的。当然,如果序列太长或者需要就地修改序列而导致无法复制参数,就不能采用这种方式;此时,使用 isinstance(x, abc.MutableSequence) 更好。如果可以接受任何可迭代对象,也可以调用 iter(x) 函数获得一个迭代器,详情参见 14.1.1 节。

模仿 collections.namedtuple(https://docs.python.org/3/library/collections.html#collections.namedtuple) 处理 field_names 参数的方式也是一例:field_names 的值可以是单个字符串,以空格或逗号分隔标识符,也可以是一个标识符序列。此时可能想使用 isinstance,但我会使用鸭子类型,如示例 11-7 所示。5

5这段代码摘自示例 21-2。

示例 11-7 使用鸭子类型处理单个字符串或由字符串组成的可迭代对象

try: ➊
  field_names = field_names.replace(',', ' ').split() ➋
except AttributeError: ➌
  pass ➍
field_names = tuple(field_names) ➎

❶ 假设是单个字符串(EAFP 风格,即“取得原谅比获得许可容易”)。

❷ 把逗号替换成空格,然后拆分成名称列表。

❸ 抱歉,field_names 看起来不像是字符串……没有 .replace 方法,或者返回值不能使用 .split 方法拆分。

❹ 假设已经是由名称组成的可迭代对象了。

❺ 为了确保的确是可迭代对象,也为了保存一份副本,使用所得值创建一个元组。

在那篇短文的最后,Alex 多次强调,要抑制住创建抽象基类的冲动。滥用抽象基类会造成灾难性后果,表明语言太注重表面形式,这对以实用和务实著称的 Python 可不是好事。在审阅本书的过程中,Alex 写道:

抽象基类是用于封装框架引入的一般性概念和抽象的,例如“一个序列”和“一个确切的数”。(读者)基本上不需要自己编写新的抽象基类,只要正确使用现有的抽象基类,就能获得 99.9% 的好处,而不用冒着设计不当导致的巨大风险。

下面通过实例讲解白鹅类型。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文