流畅的 Python笔记.
本篇是 "面向对象惯用方法" 的第四篇, 主要讨论接口. 本篇内容将从鸭子类型的动态协议, 逐渐过渡到使接口更明确, 能验证实现是否符合规定的抽象基类(Abstract Base Class, ABC).
1. 前言
本篇讨论 Python 中接口的实现问题, 主要内容如下:
补充用鸭子协议实现部分接口的一种重要方法: 猴子补丁;
说明抽象基类的常见用途, 即, 实现接口时作为超类使用;
说明抽象基类如何检查具体子类是否符合接口定义, 以及如何使用注册机制声明一个类实现了某个接口;
说明如何不通过子类化或注册, 也能让抽象基类自动 "识别" 任何符合接口的类.
补充在正文之前:
在 Python 中,"X 类对象","X 协议" 和 "X 接口" 都是一个意思. 并且, 除了抽象基类, 类实现或继承的公开属性(方法或数据属性), 包括特殊方法, 都可以看做接口.
关于接口, 还有一个很实用的补充定义: 对象公开方法的子集, 让对象在系统中扮演特定的角色.
2. 猴子补丁
猴子补丁并不是 Python 特有, 它指动态语言中, 不用修改源代码, 在运行时就能对代码的功能进行动态的追加或变更. 下面的代码展示了猴子补丁的用法:
- # 代码 2.1
- # 在文件中定义
- class MyList:
- def __init__(self, iterable):
- self._data = list(iterable)
- def __len__(self):
- return len(self._data)
- def __getitem__(self, index):
- return self._data[index]
- # 下面的代码在控制台运行
- >>> from random import shuffle
- >>> from my_list import MyList
- >>> mylist = MyList(range(10))
- >>> def set_item(temp, i, item):
- ... temp._data[i] = item
- ...
- >>> MyList.__setitem__ = set_item
- >>> shuffle(mylist)
- >>> deck[:]
- [6, 3, 0, 1, 5, 4, 2, 7, 9, 8]
解释:
Python 中, 交互式控制台中也支持猴子补丁;
要使用 random.shuffle 函数, 对象必须实现__setitem__方法, 上述代码在运行时动态添加所需方法;
猴子补丁很强大, 但打补丁的代码与要打补丁的程序耦合十分紧密, 而且往往要处理隐藏的部分 (比如 "受保护的" 属性) 和没有文档的部分.
上述代码中 set_item 函数的第一个参数并不是 self, 这是想说明, 每个 Python 方法说到底都是普通函数, 把第一个参数命名为 self 只是一种约定(但别随意打破这种约定).
这里之所以讲猴子补丁, 主要是为了说明协议可以是动态的: 即使对象最初没有实现某个协议, 当需要时, 我们也能为它动态添加.
3. 抽象基类
介绍完动态实现接口后, 现在开始讨论抽象基类, 它属于静态显示地实现接口.
3.1 基本概要说明
有时候我们需要明确区分 "抽象类"(并不是指 "抽象基类")与 "接口": 以自然界为例,"抽象类" 一般用于同一物种同一行为, 而 "接口" 则用于不同物种同一行为. 当然, 这两个概念有交叉的部分, 某些行为既可以归到 "接口", 也可以归到 "抽象类", 而最后归到谁就见仁见智了. 但这两个概念又有很大的相似之处, 它们的实质都是: 让某些对象拥有同名的方法或属性, 但具体实现不一定相同.
Java 更注重这两者的特性, 而 Python,C++ 则更注重这两者的共性. 也因此, Java 不支持多重继承(当然, 也是为了降低复杂性), 用明确的接口类 interface 来区分与 abstract class; 而在 Python 和 C++ 中, 则用抽象基类充当接口. 所以, 在 Python 中, 直接继承自抽象基类, 更多表明的是 "要实现某种接口或协议", 而非 "要新建某个具体类的子类".
如果要测试是否继承自抽象基类, 推荐使用 isinstance 和 issubclass 方法, 而不是 is 运算. 但也不要滥用这类方法, 因为这种代码用多了说明面向对象设计得不好.
说道 isinstance, 还有个与之相关的概念, 相当于 "鸭子类型" 的强化版:
白鹅类型(goose typing): 只要 cls 是抽象基类, 即 cls 的元素是 abc.ABCMeta, 就可以使用
- isinstance(obj, cls)
- .
小插曲: 这是书中给出的标准定义, 笔者读到这的时候一脸懵逼."白鹅类型" 是个名词, 但这定义却是对一个过程的描述, 所以 "白鹅类型" 到底是个啥(这到底是翻译的锅还是作者的锅)? 后来谷歌了一下, 再自己反复推敲, 得出如下总结: 鸭子类型是指某个实例实现了某个方法, 就可以说它属于某个类型, 不一定要继承; 而白鹅类型则是指能被判定成某抽象基类的子类的实例, 即, 能使
isinstance(obj, cls)
返回 True 的 obj 就是白鹅类型, 其中 cls 是抽象基类. 注意, 这些子类并不一定是通过继承而来, 也可能是通过注册而来, 还可能是通过实现某些方法而来.
特别提醒: 对于抽象基类 (还有元类) 的使用, 并不建议在生产代码中自行定义新的抽象基类和元类. 定义抽象基类和元类的工作一般由比较资深的 Python 程序员来做, 适用于写框架的程序员. 而即便是资深 Python 程序员也不常自己定义抽象基类和元类.
3.2 标准库中的抽象基类
从 Python2.6 开始, 标准库提供了抽象基类. 大多数抽象基类在 collections.abc 模块中定义, numbers 和 io 中也有一些.
以下是 collections.abc 中 16 个抽象基类的 UML 图(关于多重继承的内容将在以后的文章中讲解):
有几个抽象基类值得注意:
Iterable,Container 和 Sized: 各个集合类应该继承这三个抽象基类, 或者至少实现兼容的协议. Iterable 通过__iter__方法支持迭代; Container 通过__contains__方法支持 in 运算; Sized 通过__len__方法支持 len()函数;
Sequence,Mapping 和 Set: 这三个是主要的不可变集合类型, 而且各自都有可变的子类, 即 MutableSequence,MutableMapping 和 MutableSet.
Callable 和 Hashable: 从图上可以看出, 这两个抽象基类在标准库中没有子类.
在 numbers 包中的抽象基类的继承关系则很简单, 都是线性的("数字塔"). 下面 5 个类从左到右依次派生:
Number,Complex,Real,Rational,Integral
下面我们将自行定义一个抽象基类并继承出它的子类. 但这并不是鼓励各位在生产代码中自定义抽象基类!
3.3 自定义抽象基类
我们将模拟一个随机抽奖机, 它的抽象基类是 Tombola, 它的 4 个方法如下:
.load(...): 抽象方法, 把元素放入容器;
.pick(): 抽象方法, 从容器中随机返回一个元素, 并从容器中删除该元素;
.loaded(): 当容器不为空是返回 True;
.inspect(): 返回一个有序元组, 由容器中的现有元素构成, 不修改容器的内容(容器内部元素顺序不保留).
它和它的三个子类的 UML 图如下:
以下是 Tombola 的定义:
- # 代码 3.1
- import abc
- class Tombola(abc.ABC):
- @abc.abstractmethod
- def load(self, iterable):
- """从可迭代对象中添加元素"""
- @abc.abstractmethod
- def pick(self):
- """ 随机删除元素, 然后将其返回.
- 如果实例为空, 这个方法应该抛出 LookupError,
- 这个异常是 IndexError 和 KeyError 的基类 """
- def loaded(self): # 比较耗时, 子类可重写
- """当容器不为空时返回 True"""
- return bool(self.inspect())
- def inspect(self): # 这只是提供一种实现方式, 子类可覆盖该方法
- """返回一个有序元组, 由当前元素构成"""
- items = []
- while True:
- try: # 之所以这么获取元素, 是因为不知道子类如何存储元素
- items.append(self.pick())
- except LookupError:
- break
- self.load(items)
- return tuple(sorted(items))
解释及补充:
导入时, Python 并不会检查抽象方法的实现, 在运行时才会真正检测;
如果子类并没有实现抽象基类中所有的抽象方法, 那么这个子类依然是抽象基类;
抽象方法中可以有实现代码. 即便实现了, 子类也必须覆盖抽象方法, 但可以使用 super()函数调用抽象方法, 为它添加功能, 而不是从头开始写;
抽象基类中的具体方法只能依赖抽象基类定义的接口.
标准库中有两个名为 abc 的模块, 一个是前面说的 collections.abc, 另一个就是这里的 abc 模块. 只有在新定义抽象基类的时候才用得到 abc.ABC, 每个抽象基类都依赖这个类.
在 abc 模块中本来还有
- @abstractclassmethod
- ,
- @abstractstaticmethod
和 @abstractproperty 三个装饰器, 但这三个从 Python3.3 起被废除了, 因为这三个的功能都能在 @abstractmethod 上堆叠其他装饰器得到, 比如实现
@abstractclassmethod
的功能:
- # 代码 3.2
- class MyABC(abc.ABC):
- @classmethod
- @abc.abstractmethod
- def an_abstract_classmethod(cls, ...): pass
3.4 定义子类
以下是它的两个子类的实现代码:
- # # 代码 3.3
- class BingoCage(Tombola): # loaded()和 inspect()延用抽象基类的实现
- def __init__(self, items):
- self._randomizer = random.SystemRandom() # 它会调用 os.urandom()
- self._items = []
- self.load(items) # 委托给 load()方法实现初始加载
- def load(self, items): # 必须实现抽象方法!
- self._items.extend(items)
- self._randomizer.shuffle(self._items)
- def pick(self): # 必须实现抽象方法!
- try:
- return self._items.pop()
- except IndexError:
- raise LookupError("pick from empty BingoCage")
- def __call__(self):
- self.pick()
- class LotteryBlower(Tombola):
- def __init__(self, iterable):
- self._balls = list(iterable) # 副本
- def load(self, iterable):
- self._balls.extend(iterable)
- def pick(self):
- try:
- position = random.randrange(len(self._balls))
- except ValueError: # 为了兼容 Tombola, 并不是抛出 ValueError
- raise LookupError("pick from empty LotteryBlower")
- return self._balls.pop(position)
- def loaded(self): # 覆盖了抽象基类低效的版本
- return bool(self._balls)
- def inspect(self):
- return tuple(sorted(self._balls))
3.5 虚拟子类
上面两个子类都是直接继承自 Tombola, 而白鹅类型有一个基本特性: 即便不用继承, 也能将一个类注册为抽象基类的虚拟子类. 下面是 TomboList 的实现:
- # 代码 3.4
- @Tombola.register # 把 TomboList 注册为 Tombola 的虚拟子类
- class TomboList(list): # 它同时还是 list 的真实子类, 而 list 其实是 MutableSequence 的虚拟子类
- def pick(self):
- if self:
- position = random.randrange(len(self))
- return self.pop(position)
- else:
- raise LookupError("pick from empty LotteryBlower")
- load = list.extend # 当我看到居然这么实现方法时, 感觉自己好肤浅......
- def loaded(self):
- return bool(self)
- def inspect(self):
- return tuple(sorted(self))
- # Tombola.register(TomboList) 这是 register 的函数调用版本
下面是这个子类的简单使用:
- # 代码 3.5
- >>> issubclass(TomboList, Tombola)
- True # TomboList 是 Tombola 的子类
- >>> t = TomboList(range(100))
- >>> isinstance(t, Tombola)
- True # TomboList 的实例也是 Tombola 类型
- >>> TomboList.__mro__
- (<class 'mytest.TomboList'>, <class 'list'>, <class 'object'>)
- >>> TomboList.__subclasses__()
- [<class 'mytest.BingoCage'>, <class 'mytest.LotteryBlower'>]
解释及补充:
虚拟子类不会继承注册的抽象基类, 而且任何时候都不会检查它是否符合抽象基类的接口, 即便在实例化时也不会检查(如果你的虚拟子类没有实现抽象方法, 在实例化时不会报错, 但如果是继承而来的话则会报错), 所以为了避免运行时错误, 虚拟子类应该实现抽象基类的全部方法;
类的继承关系存储在一个特殊的类属性__mro__中, 即方法解析顺序(Method Resolution Order). 它按顺序列出类及其超类, Python 则会按照这个顺序搜索方法. 从上述结果可以看出, 这个属性只存储了 "真实的" 超类.
__subclasses__方法返回类的直接子类列表, 不含虚拟子类;
虽然现在 register 可以当做装饰器用, 但更常用的做法还是把它当函数使用.
3.6 另一种虚拟子类
鹅的行为有可能像鸭子. 先看如下代码:
- # 代码 3.6
- >>> class Struggle:
- ... def __len__(self): return 23
- ...
- >>> from collections import abc
- >>> isinstance(Struggle(), abc.Sized)
- True
- >>> issubclass(Struggle, abc.Sized)
- True
这里既没有继承, 也没有注册, 但 Struggle 依然被 issubclass 判断为 abc.Sized 的子类. 之所以会这样, 是因为 abc.Sized 实现了一个特殊的类方法__subclasshook__:
- # # 代码 3.7,abc.Sized 的实现在 _collections_abc.py 中
- class Sized(metaclass=ABCMeta):
- __slots__ = ()
- @abstractmethod
- def __len__(self):
- return 0
- @classmethod
- def __subclasshook__(cls, C):
- if cls is Sized:
- # 源代码中是 return _check_methods(C, "__len__"), 这里修改了一下
- if any("__len__" in B.__dict__ for B in C.__mro__):
- return True
- return NotImplemented
这像不像鸭子类型? 只要实现了__len__方法, 这个类就是 abc.Sized 的子类.
在自定义的抽象基类中并不一定要实现__subclasshook__方法, 因为即使在 Python 源码中, 目前也只见到 Sized 这一个抽象基类实现了__subclasshook__方法, 而且 Sized 只有一个特殊方法. 在决定自行实现__subclasshook__方法之前, 请想清楚你一定需要这个方法吗? 你的能力能够保证这个方法的可靠性吗?
4. 总结
本篇讨论的话题只有一个, 即 "接口". 首先我们讨论了鸭子类型的高度动态性, 它实现的是动态协议, 也是非正式接口; 随后我们借助 "白鹅类型", 使用抽象基类明确地, 显示地声明接口, 然后通过子类或注册来实现这些接口. 期间, 我们自定义了一个抽象基类, 并通过继承实现了它的两个子类, 还通过注册实现了它的一个虚拟子类.
最后, 还是那句话: 不要轻易自定义抽象基类, 除非你想构件允许用户扩展的框架. 日常使用中, 我们与抽象基类的联系应该是创建现有抽象基类的子类, 或者使用现有的抽象基类注册. 自己从头编写新抽象基类的情况非常少.
来源: https://juejin.im/post/5b220cc8e51d4558b2776c1b