- 前言
- 目标读者
- 非目标读者
- 本书的结构
- 以实践为基础
- 硬件
- 杂谈:个人的一点看法
- Python 术语表
- Python 版本表
- 排版约定
- 使用代码示例
- 第一部分 序幕
- 第 1 章 Python 数据模型
- 第二部分 数据结构
- 第 2 章 序列构成的数组
- 第 3 章 字典和集合
- 第 4 章 文本和字节序列
- 第三部分 把函数视作对象
- 第 5 章 一等函数
- 第 6 章 使用一等函数实现设计模式
- 第 7 章 函数装饰器和闭包
- 第四部分 面向对象惯用法
- 第 8 章 对象引用、可变性和垃圾回收
- 第 9 章 符合 Python 风格的对象
- 第 10 章 序列的修改、散列和切片
- 第 11 章 接口:从协议到抽象基类
- 第 12 章 继承的优缺点
- 第 13 章 正确重载运算符
- 第五部分 控制流程
- 第 14 章 可迭代的对象、迭代器和生成器
- 14.1 Sentence 类第1版:单词序列
- 14.2 可迭代的对象与迭代器的对比
- 14.3 Sentence 类第2版:典型的迭代器
- 14.4 Sentence 类第3版:生成器函数
- 14.5 Sentence 类第4版:惰性实现
- 14.6 Sentence 类第5版:生成器表达式
- 14.7 何时使用生成器表达式
- 14.8 另一个示例:等差数列生成器
- 14.9 标准库中的生成器函数
- 14.10 Python 3.3 中新出现的句法:yield from
- 14.11 可迭代的归约函数
- 14.12 深入分析 iter 函数
- 14.13 案例分析:在数据库转换工具中使用生成器
- 14.14 把生成器当成协程
- 14.15 本章小结
- 14.16 延伸阅读
- 第 15 章 上下文管理器和 else 块
- 第 16 章 协程
- 第 17 章 使用期物处理并发
- 第 18 章 使用 asyncio 包处理并发
- 第六部分 元编程
- 第 19 章 动态属性和特性
- 第 20 章 属性描述符
- 第 21 章 类元编程
- 结语
- 延伸阅读
- 附录 A 辅助脚本
- Python 术语表
- 作者简介
- 关于封面
11.4 Alex Martelli 的水禽
介绍完 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 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论