这篇文章将讨论接口:从鸭子类型的代表特征-动态协议,到使用接口更明确、能验证实现是否符合规定的抽象基类(Abstract Base Class,ABC)。抽象基类与描述符和元类一样,是用于构建框架的工具,一般都不需要自己编写抽象基类,因为很容易过度设计。
Python 文化中的接口和协议
引入抽象基类之前,Python 就已经很成功了,即便现在也很少有代码使用抽象基类。协议可以定义为非正式的接口,而接口在 Python 这种动态类型语言中是按照如下方式运作的:
- 虽然 Python 没有 interface 关键字,但是每个类都有接口,这就是类实现或继承的公开属性(方法或数据属性),包括特殊方法,例如
__getitem__
、__add__
等等 - 受保护的属性(单个前导线)和私有属性(两个前导线)不在接口中,即便我们可以轻松地访问受保护的属性、通过一些技巧来访问到私有属性,我们也不应该去访问它们,不要违背约定
- 另外,不要觉得把公开数据属性放入对象的接口中有何不妥,因为需要的话,总能实现读值和设值方法,把数据属性变为特性,而不影响客户代码
接口还可以这样定义:对象公开方法的子集,让对象在系统中扮演特定的角色。接口是实现特定角色(例如可迭代对象)的方法集合,而协议就是提前约定好这些非正式的接口。协议与继承没有关系,一个类可能会实现多个接口,从而让实例扮演多个角色。
协议是接口,但是不是正式的,协议的接口集合只由文档和约定定义,因此协议不能像正式接口那样添加限制。一个类可能只实现部分接口,这是允许的。对 Python 程序员来说:X 类对象、X 协议和 X 接口都是一个意思。协议风格的接口与继承完全没有关系,实现同一个协议的各个类是相互独立的。
序列协议是 Python 最基础的协议之一。即便对象只实现了那个协议最基本的一部分,解释器也会负责地处理。
Python 序列
Python 数据模型的哲学是尽量支持基本协议,对序列来说,即便是最简单的实现,Python 也会力求做到最好。例如如下是定义为抽象基类的 Sequence 正式接口:
接下来实现一个 Foo 类,它没有继承 abc.Sequence,而且只实现了序列协议的一个方法 __getitem__
:
1 | class Foo: |
1 | f = Foo() |
- 虽然没有
__iter__
方法,Foo 实例仍然是可迭代对象,因为发现有__getitem__
方法时,Python 会调用它。传入从 0 开始的整数索引,尝试迭代对象(这是一种后备机制) - 尽管没有
__contains__
方法,Python 也能通过迭代实例来完成检查,因此能使用 in 运算符
因此鉴于序列协议的重要性,如果没有 __iter__
和 __contains__
方法,Python 会调用 __getitem__
方法,设法让迭代和 in 运算符可用。
使用猴子补丁在运行时实现协议
Python 是动态语言,我们可以在运行时随时为实例添加属性,从而实现某个接口,使对象符合某个协议的要求,这也是协议的动态本性。例如为了让 Foo 类支持就地随机排序,其需要实现可变的序列协议,也就是说其必须提供 __setitem__
方法:
1 | from random import shuffle |
Python 是动态语言,因此可以在运行时修复该问题:
1 | def set_foo_item(foo, position, value): |
这里把我们定义的 set_foo_item
函数赋值给 Foo 类的 __setitem__
属性。这样 Foo 类就实现了可变序列协议所需要的方法。这种技术称为 猴子补丁
:在运行时修改类或模块,而不改动源码。猴子补丁很强大,但是打补丁的代码与要打补丁的程序耦合十分紧密。
这个例子也说明了协议的动态性,random.shuffle 函数不关心参数的类型,只要那个对象实现了部分可变序列协议即可。即便对象一开始没有所需的方法也没有关系,后来再提供也行。对象的类型无关紧要,只要实现了特定的协议即可。
抽象基类
前面介绍的是 Python 常规的协议风格接口,接下来将讨论抽象基类。除了鸭子类型(即忽略对象的真正类型,转而关注对象有没有实现所需的方法、签名和语义),这里引入了一种新的类型风格,即白鹅类型(goose typing):白鹅类型指的是只要 cls 是抽象基类,即 cls 的元类是 abc.ABCMeta,就可以使用 isinstance(obj, cls)
。
与具体类相比,抽象基类有很多理论上的优点,Python 的抽象基类还有一个重要的实用优势:可以使用 register 类方法在终端用户代码中把某个类声明为一个抽象基类的 虚拟子类
(为此,被注册的类必须满足抽象基类对方法名称和签名的要求,最重要的是满足底层语义契约,但是开发那个类时不需要了解抽象基类,更不用继承抽象基类)。
有时,为了让抽象基类识别子类,甚至不用注册。其实抽象基类的本质就是几个特殊方法:
1 | class Bar: |
关于抽象基类的使用,最好遵守以下建议:
- 如果实现的类体现了 numbers、collections.abc 或其他框架中抽象基类的概念,要么继承相应的抽象基类(必要时),要么把类注册到相应的抽象基类中
- 开发程序时,不要使用提供注册功能的库或框架,要自己动手注册
- 不要在生产代码中定义抽象基类(或元类),因为你一般都不会真的需要
- 如果必须要检查参数的类型,可以通过 isinstance 或 issubclass 测试抽象类型
即便是抽象基类,也不能滥用 isinstance 检查,用多了可能导致代码异味,即表明面对对象设计得不好。在一连串 if/elif/else 中使用 isinstance 做检查,然后根据对象的类型执行不同的操作,通常不是好的做法,此时应该使用多态,即采用一定的方式定义类,让解释器把调用分派给正确的方法,而不是靠 if/else 块硬编码分派逻辑。另一方面,如果必须强制执行 API 契约,通常可以使用 isinstance 检查抽象基类。而在框架之外,鸭子类型通常比类型检查更简单,也更灵活。
以 collections.namedtuple 处理 field_names 参数的方式为例,field_names 的值可以是单个字符串(以空格或逗号分隔标识符),也可以是一个标识符序列。此时可能你会使用 isinstance 判断,但是也可以使用鸭子类型:首先假设输入的是单个字符串,然后把逗号替换成空格,然后拆分成名称列表,如果不是则直接认为名称已经是可迭代对象了:
1 | try: |
定义抽象基类的子类
抽象基类是用于封装框架引入的一般性概念和抽象的,例如一个序列、一个确切的数。一般都不需要自己编写新的抽象基类,只需要正确使用现有的抽象基类,就能获得大量好处,而且不用冒着设计不当导致的巨大风险。
下面的例子将 FrenchDeck 声明为 collections.MutableSequece 的子类:
1 | #!/usr/bin/env python3 |
继承 MutableSequence 的类必须实现 __delitem__
和 insert
方法,它们是 MutableSequence 的抽象方法。导入时(加载并编译模块),Python 不会检查抽象方法的实现,而在实例化该类型的对象时才会真正检查。因此如果没有正确实现某个抽象方法,Python 会抛出 TypeError 异常。也就是说,即使 FrechDeck 类不需要 __delitem__
和 insert
的行为,也必须实现他们,因为它继承自 MutableSequence 抽象基类。
抽象基类的方法也不全是抽象的,子类可以直接继承抽象基类的具体方法。或者子类也可以覆盖从抽象基类中继承的方法,以更高效的方式重新实现他们。
标准库中的抽象基类
大多数抽象基类在 collections.abc 模块中定义,当然其他地方也有,例如 numbers 和 io 包中也有一些抽象基类。但是 collections.abc 中的抽象基类最为常用。
Python 官方文档 中介绍了 collections.abc 模块中定义的抽象基类,说明了它们之间的相互关系,以及各个基类提供的抽象方法和具体方法。比较重要的抽象基类如下:
- Iterable、Container 和 Sized:分别通过 iter 方法支持迭代、通过 contains 方法支持 in 运算符、通过 len 方法支持 len() 函数
- Sequence、Mapping 和 Set:不可变集合类型
- MutableSequece、MutableMapping、MutableSet:可变的集合类型
- MapppingView:
.items()
、.keys()
、.values()
返回的对象分别是 ItemView、KeysView 和 ValuesView 的实例 - Callable 和 Hashable:主要为内置函数 isinstance 提供支持,以一种安全的方式判断对象能不能调用或散列
- Iterator,它是 Iterable 的子类
除了 collections.abc 之后,标准库中最有用的抽象基类包是 numbers,它定义了数字塔。其中 Number 是位于最顶端的超类,随后是 Complex 子类,依次往下,最底端是 Integral:
- Number
- Complex
- Real
- Rational
- Integral
定义并使用一个抽象基类
接下来定义一个抽象基类 Tombola,它随机从有限的集合中选出元素,选出的元素没有重复,直至选完为止:
1 | #!/usr/bin/env python3 |
- 自己定义的类要继承
abc.ABC
- 抽象方法使用
@abcstractmethod
装饰器,而且定义体中通常只有文档字符串。当然抽象方法是可以有实现代码的,但是即便实现了,子类也必须覆盖抽象方法。但是子类可以使用 super() 函数调用抽象方法,为它添加功能,而不是从头开始实现 - 抽象基类也可以包含具体方法,抽象基类的具体方法只能依赖抽象基类定义的接口
1 | from tombola import Tombola |
- 可以看到创建子类的时候没有报错
- 当尝试实例化 Fake 时抛出了 TypeError。错误信息显示:Fake 类是一个抽象类,因为它没有实现 load 方法
声明抽象基类最简单的方式是继承 abc.ABC 或其他抽象基类。abc.ABC
是 Python3.4 新增的类,如果你使用旧版本的 python,那么无法继承现有的抽象基类,此时可以在 class 语句中使用 metaclass=
关键字,把值设置为 abc.ABCMeta
。metaclass 关键字参数是 Python3 引入的,在 Python2 中必须使用 __metaclass__
类属性。
@abstractmethod 可以和
@classmethod、
@staticmethod、
@property 组合起来使用。与其他方法描述符一起使用时,
@abstractmethod` 应该放在最里层。
定义抽象基类的子类
如下 BingoCase 类是 Tombola 抽象基类的具体子类:
1 | #!/usr/bin/env python3 |
BingoCase 类直接从 Tombola 继承了耗时的 loaded 和 inspect 方法。这两个方法都可以覆盖,但是这里并没有。我们可以偷懒,直接从抽象基类中继承不是那么理想的具体方法
而下面定义的子类 LotteryBlower 则覆盖了 loaded 和 inspect 方法:
1 | #!/usr/bin/env python3 |
这里有个习惯做法:在 __init__
方法,保存的是 list(iterable),而不是 iterable 的引用。这样更灵活,因为 iterable 参数可以是任何可迭代的类型,而把元素存入列表中还确保能取出元素。即使 list(iterable) 会创建参数的副本,这依然是一个好的做法,因为我们会从中删除元素,而客户可能可能不希望自己提供的列表被修改。
Tombola 的虚拟子类
白鹅类型的一个基本特性(也是值得用水禽来命名的原因):即便不继承,也有办法把一个类注册为抽象基类的虚拟子类。这样做时,我们保证注册的类忠实地实现了抽象基类定义的接口,而 Python 会相信我们,从而不做检查(即便在实例化时也不会检查)。如果我们没有这样做,常规的运行时异常会把我们捕获。
注册虚拟子类的方式是在抽象基类上调用 register 方法,之后注册的类会变成抽象基类的虚拟子类,而且 issubclass 和 isinstance 等函数都能识别,但是注册的类不会从抽象基类中继承任何方法和属性。
1 | #!/usr/bin/env python3 |
如果是 Python3.3 或之前的版本,不能把 .register
当做类装饰器使用,必须使用标准的调用语法 Tombola.register(TomboList)
。
1 | from tombola import Tombola |
可以看到 TomboList 是 Tombola 的子类。但是,之前介绍过类的继承关系在一个特殊的类属性中指定 __mro__
,即方法解析顺序(Method Resolution Order),它会按照顺序列出类及其超类,Python 会按照这个顺序搜索方法。它只会列出真实的超类:
1 | TomboList.__mro__ |
由于 __mro__
不会包含虚拟的超类,因此虚拟子类不会从其虚拟超类中继承任何方法。
另外,可以通过 __subclasses__()
获取某个类的直接子类列表,不包含虚拟子类。而通过 _abc_registery
这个 WeakSet 对象,可以获取抽象类注册的虚拟子类的弱引用。只有抽象基类有这个数据属性。
鹅的行为有可能像鸭子
即便不注册,抽象基类也能把一个类识别为虚拟子类,例如:
1 | >>> class Foo: |
这是因为 abc.Sized
实现了一个特殊的类方法,名为 __subclasshook__
。它的是实现如下:
1 |
|
对 C.__mro__(即 C 及其超类)中所列的类来说,如果类的 __dict__
属性中有名为 __len__
的属性,则返回 True,否则返回 NotImplemented。__subclasshook__
在白鹅类型中添加了一些鸭子类型的踪迹,在自己定义的抽象基类中要不要实现 __subclasshook__
方法呢?可能不需要。在我们自己编写的抽象基类中实现 __subclasshook__
可靠性很低。
尽管抽象基类使得类型检查变得更容易,但是不应该在程序中过度使用它。Python 的核心在于它是一门动态语言,它带来了极大的的灵活性。如果处处都强制实行类型约束,那么会使代码变得更加复杂。我们应该拥抱 Python 的灵活性。
继承的优缺点
接下来将探讨继承和子类化,重点是说明对 Python 而言尤为重要的两个细节:
- 子类化内置类型的缺点
- 多重继承和方法解析顺序
子类化内置类型很麻烦
内置类型可以被子类化,但是有一个重要的注意事项:内置类型(使用 C 语言编写)不会调用用户定义的类覆盖的特殊方法。下面是一个例子:
1 | class Double(dict): |
- 继承自 dict 的
__init__
方法忽略了我们覆盖的__setitem__
方法 []
运算符会调用我们覆盖的__setitem__
方法- 继承自 dict 的 update 方法也没有使用我们覆盖的
__setitem__
方法
原生类型的这种行为违背了面对对象编程的基本原则:始终应该从实例(self)所属的类开始搜索方法,即使在超类实现的类中调用也是如此。不只实例内部的调用有这个问题,内置类型的方法调用其他类的方法,如果被覆盖了,也不会被调用。如下例子汇总,dict.update 方法忽略了 AnswerDict.getitem 方法。:
1 | class AnswerDict(dict): |
直接子类化内置类型容易出错,因为内置类型的方法通常会忽略用户覆盖的方法。不要子类化内置类型,用户自己定义的类应该继承 collections 模块中的类,这些类做了特殊设计,因此易于扩展。
多重继承和方法解析顺序
与多重继承有关的另一个问题是:如果同级别的超类定义了同名属性,Python 如何确定使用哪个?任何实现多重继承的语言都要处理潜在的命名冲突,这种冲突由不想关的祖先类实现同名方法引起。
如下展示了一个例子:
1 | #!/usr/bin/env python3 |
1 | from pingpong import * |
- 可以看到直接调用 d.pong(),直接运行的是 B 类中的版本
- 超类中的方法都可以直接调用,此时要把实例作为显示参数传入
Python 会按照特定的顺序遍历继承图,这个顺序叫方法解析顺序(Method Resolution Order,MRO)。类都有一个名为 mro 的属性,它是一个元祖,按照方法解析顺序列出各个超类,从当前类一直向上,直到 object 类:
1 | D.__mro__ |
如果想把方法调用委托给超类,推荐的方式是使用内置的 super() 函数。使用 super() 调用方法时,会遵守方法解析顺序。也可以直接在类上调用实例方法,此时必须显式传入 self() 参数。
从上面的例子也可以看出,方法解析顺序不仅考虑继承图,还考虑子类声明中列出的超类顺序。
处理多重继承
在 Python 标准库中,最常使用多重继承的是 collections.abc 包。继承有很多用途,而多重继承增加了可选方案和复杂度。使用多重继承容易得出令人费解和脆弱的设计,下面是避免把类图搅乱的一些建议:
- 把接口继承和实现继承区分开来:继承接口,创建子类型,实现
是什么
关系;继承实现,通过重用避免代码重复。一定要明确意图,通过继承重用代码是实现细节,通常可以换用组合和委托模式,而接口继承则是框架的支柱
- 把接口继承和实现继承区分开来:继承接口,创建子类型,实现
- 使用抽象基类显式表示接口:现代 Python 中,如果类的作用是定义接口,应该明确它定义为抽象基类。
- 通过混入重用代码:如果一个类的作用是为多个不相关的子类提供方法实现,从而实现重用,但不体现
是什么
关系,应该把那个类明确定义为混入类(mixin class)。从概念上讲,混入不定义新类型,只是打包方法,便于重用。混入类绝对不能实例化,而且具体类不能只继承混入类。混入类应该提供某方面的特定行为,只实现少量关系非常紧密的方法
- 通过混入重用代码:如果一个类的作用是为多个不相关的子类提供方法实现,从而实现重用,但不体现
- 在名称中明确指明混入:通过在名称中加入 Mixin 后缀,把类标明为混入类
- 抽象基类可以作为混入,反过来则不成立:抽象基类可以实现具体方法,因此可以作为混入使用。不过抽象基类会定义类型,而混入则做不到
- 不要子类化多个具体类:具体类可以没有,或最多只有一个具体超类。
- 为用户提供聚合类:如果抽象基类或混入的组合对客户代码非常有用,那就提供一个类,使用于理解的方式把它们结合起来。这种类可以称为聚合类
- 优先使用对象组合,而不是类继承:优先使用组合能让设计更灵活。组合和委托可以替代混入,把行为提供给不同的类,但是不能取代接口继承去定义类型层次结构