流畅的python--第十三章 接口、协议和抽象基类
面向对象编程全靠接口。在 Python 中,支撑一个类型的
是它提供的方法,也就是接口。
在不同的编程语言中,接口的定义和使用方式不尽相同。从 Python 3.8
开始,有 4 种方式,如图 13-1 中的类型图所示。这 4 种方式概述如下。
- 鸭子类型
自 Python 诞生以来默认使用的类型实现方式。从第 1 章开始,本书一直在研究鸭子类型。 - 大鹅类型
自 Python 2.6 开始,由抽象基类支持的方式,该方式会在运行时检查对象是否符合抽象基类的要求。大鹅类型是本章的主要话题。 - 静态类型
C
和Java
等传统静态类型语言采用的方式。自Python 3.5
开始,由
typing
模块支持,由符合“PEP 484—Type Hints”
要求的外部类型检查
工具实施检查。本章不涉及该方式。第 8 章的大多数内容和第 15 章讨论了静态类型。 - 静态鸭子类型
因Go
语言而流行的方式。由typing.Protocol
(Python 3.8
新增)的子类支持,由外部类型检查工具实施检查。静态鸭子类型首次出现在 8.5.10 节。
类型图
图 13-1 描述的 4 种类型实现方式各有优缺点,相辅相成,缺一不可。
图 13-1:上半部分是只使用 Python
解释器在运行时检查类型的方式;
下半部分则要借助外部静态类型检查工具,例如 MyPy
或 PyCharm
等
IDE
。左边两象限中的类型基于对象的结构(对象提供的方法),与
对象所属的类或超类无关;右边两象限中的类型要求对象有明确的类
型名称:对象所属类的名称,或者超类的名称。
这 4 种方式全都依靠接口,不过静态类型可以只使用具体类型实现(效
果差),而不使用协议和抽象基类等接口抽象。本章涵盖围绕接口实现
的 3 种类型:鸭子类型、大鹅类型和静态鸭子类型。
两种协议
在计算机科学中,根据上下文,“协议”一词有不同的含义。HTTP
这种
网络协议指明了客户端可向服务器发送的命令,例如 GET
、PUT
和
HEAD
。12.4 节讲过,对象协议指明为了履行某个角色,对象必须实现哪
些方法。第 1 章中的 FrenchDeck
示例演示了一个对象协议,即序列协
议:一个 Python
对象想表现得像一个序列需要提供的方法。
完全实现一个协议可能需要多个方法,不过,通常可以只实现部分协
议。下面以示例 13-1 中的 Vowels
类为例。
示例13-1 使用__getitem__
方法实现部分序列协议
只要实现 __getitem__
方法,就可以按索引获取项,以及支持迭代和
in
运算符。其实,特殊方法 __getitem__
是序列协议的核心。
如果对象提供序列协议,就返回 1
,否则返回 0
。注意,除了 dict
子类,如果一个 Python
类有 __getitem__()
方法,则也返回 1……
我们预期序列支持 len()
函数,也就是要实现 __len__
方法。Vowels
没有 __len__
方法,不过在某些上下文中依然算得上是序列。而有些
时候,这就足够了。所以,我经常说协议是“非正式接口”。第一个使
用“协议”这个术语的面向对象编程环境 Smalltalk
也是这么理解协议的。
- 动态协议
Python 一直有的非正式协议。动态协议是隐含的,按约定定义,在
文档中描述。Python 大多数重要的动态协议由解释器支持,在《Python
语言参考手册》的第 3 章“数据模型”中说明。 - 静态协议
“PEP 544—Protocols: Structural subtyping (static duck typing)”定义的
协议,自 Python 3.8 开始支持。静态协议要使用typing.Protocol
子 类显式定义。
二者之间的主要区别如下。
- 对象可以只实现动态协议的一部分,但是如果想满足静态协议,则对象必须提供协议类中声明的每一个方法,即使程序用不到。
- 静态协议可以使用静态类型检查工具确认,动态协议则不能。
两种协议共有一个基本特征:类无须通过名称(例如通过继承)声明支持什么协议。除了静态协议,Python 还提供了另一种定义显式接口的方式,即抽象基类。
利用鸭子类型编程
我们以 Python
中两个最重要的协议(序列协议和可迭代协议)为例展
开对动态协议的讨论。即使对象只实现了这些协议的最少一部分,也会
引起解释器的注意。
Python
喜欢序列
Python
数据模型的哲学是尽量支持基本的动态协议。对序列来说,即便
是最简单的实现,Python
也会力求做到最好。
图 13-2 展示的是通过一个抽象基类确立的 Sequence
接口。Python
解
释器和 list
、str
等内置序列根本不依赖那个抽象基类。我只是利用
它说明一个功能完善的序列应该支持什么操作。
图 13-2:Sequence
抽象基类和 collections.abc
中相关抽象类的
UML
类图。箭头由子类指向超类。以斜体显示的是抽象方法。Python3.6
之前的版本中没有 Collection
抽象基类,Sequence
是
Container
、Iterable
和 Sized
的直接子类
从图 13-2 可以看出,为了确保行为正确,Sequence
的子类必须实现
__getitem__
和 __len__
(来自 Sized
)。Sequence
中的其他方法都
是具体的,因此子类可以继承或者提供更好的实现。
再回顾一下示例 13-1 中的 Vowels
类。那个类没有继承
abc.Sequence
,而且只实现了 __getitem__
。
虽然没有 __iter__
方法,但是 Vowels
实例仍然可以迭代。这是因为
如果发现有 __getitem__
方法,那么 Python
就会调用它,传入从 0
开
始的整数索引,尝试迭代对象(这是一种后备机制)。尽管缺少
__contains__
方法,但是 Python
足够智能,能正确迭代 Vowels
实
例,因此也能使用 in 运算符:Python
做全面检查,判断指定的项是否
存在。
综上所述,鉴于序列类数据结构的重要性,如果没有 __iter__
方法和
__contains__
方法,则 Python
会调用__getitem__
方法,设法让迭
代和 in
运算符可用。
第 1 章定义的 FrenchDeck
类也没有继承 abc.Sequence
,但是实现了
序列协议的两个方法:__getitem__
和 __len__
。如示例 13-2 所示。
示例 13-2 一摞有序的纸牌(与示例 1-1 相同)
import collections
Card = collections.namedtuple('Card', ['rank', 'suit'])
class FrenchDeck:
ranks = [str(n) for n in range(2, 11)] + list('JQKA')
suits = 'spades diamonds clubs hearts'.split()
def __init__(self):
self._cards = [Card(rank, suit) for suit in self.suits
for rank in self.ranks]
def __len__(self):
return len(self._cards)
def __getitem__(self, position):
return self._cards[position]
第 1 章中的那些示例之所以能用,是因为 Python
会特殊对待看起来像
序列的对象。Python
的迭代协议是鸭子类型的一种极端形式:为了迭代
对象,解释器会尝试调用两个不同的方法。
需要明确指出的是,本节描述的行为在解释器自身中实现,大多数是用
C
语言实现的,不依赖 Sequence
抽象基类的方法。例如,Sequence
类中的具体方法 __iter__
和 __contains__
是对 Python
解释器内置
行为的模仿。如果觉得好奇,可以到 Lib/_collections_abc.py
文件中阅读
这些方法的源码。
下面再分析一个示例,强调协议的动态本性,并解释静态类型检查工具
为什么没机会处理动态协议。
使用猴子补丁在运行时实现协议
猴子补丁在运行时动态修改模块、类或函数,以增加功能或修正 bug
。
例如,网络库 gevent
对部分 Python
标准库打了猴子补丁,不借助线程
或 async/await
实现一种轻量级并发。
示例 13-2 中的 FrenchDeck
类缺少一个重要功能:无法洗牌。几年
前,我在第一次编写 FrenchDeck
示例时实现了 shuffle
方法。后
来,在对 Python
风格有了深刻理解后我发现,既然 FrenchDeck
的行
为像序列,那么它就不需要 shuffle
方法,因为有现成的
random.shuffle
函数可用。根据文档,该函数的作用是“就地打乱序
列 x
”。
标准库中的 random.shuffle
函数用法如下所示。
🚩 遵守既定协议很有可能增加利用现有标准库和第三方代码的可能性,这得益于鸭子类型。
然而,如果尝试打乱 FrenchDeck
实例,则会出现异常,如示例 13-3所示。
示例 13-3 random.shuffle
函数不能打乱 FrenchDeck
实例
错误消息相当明确:'FrenchDeck' object does not support item assignment
('FrenchDeck'
对象不支持为项赋值)。这个问题
的原因在于,shuffle
函数会就地操作,调换容器内项的位置,而
FrenchDeck
只实现了不可变序列协议。可变序列还必须提供__setitem__
方法。
因为 Python
是动态语言,所以可以在运行时修正这个问题,甚至在交
互式控制台中就能做到。修正方法如示例 13-4 所示。
示例 13-4 为 FrenchDeck
打猴子补丁,把它变成可变序列,以使
random.shuffle
函数能够对其进行处理
❶ 定义一个函数,参数为 deck
、position
和 card
。
❷ 把上述函数赋值给 FrenchDeck
类的 __setitem__
属性。
❸ 现在可以打乱 deck
了,因为我添加了可变序列协议所需的方法。
Python
语言参考手册》的 3.3.7 节定义了特殊方法 __setitem__
的签
名。该手册中使用的参数是 self
, key
, value
,而这里使用的是
deck
, position
, card
。这么做是为了告诉你,Python
方法说到底就
是普通函数,把第一个参数命名为 self
只是一种约定。在控制台会话
中使用那几个参数没问题,不过在 Python
源码文件中最好按照文档那
样使用 self
、key
和 value
。
这里的关键是,set_card
函数要知道 deck
对象有一个名为_cards
的
属性,而且 _cards
的值必须是可变序列。然后,我们把 set_card
函
数依附到 FrenchDeck
类上,作为特殊方法 __setitem__
。这就是猴
子补丁:在运行时修改类或模块,而不改动源码。虽然猴子补丁很强
大,但是打补丁的代码与被打补丁的程序耦合十分紧密,而且往往要处
理文档没有明确说明的私有属性。
除了举例说明猴子补丁,示例 13-4 还强调了动态鸭子类型中的协议是
动态的:random.shuffle
函数不关心参数所属的类,只要那个对象实
现了可变序列协议的方法即可。即便对象一开始没有所需的方法也没关
系,可以之后再提供。
鸭子类型的安全性看似不可控,而且增加了调试难度,其实不然。
13.4.3 节将介绍一些检测动态协议的编程模式,免得我们自己动手检查。
防御性编程和“快速失败”
防御性编程就像防御性驾驶:有一套提高安全的实践,即使是粗心的程
序员(或司机)也不会造成灾难。
许多 bug
只有在运行时才能捕获,即使主流的静态类型语言也是如
此。 对于动态类型语言,“快速失败”可以提升程序的安全性,让程序
更易于维护。快速失败的意思是尽早抛出运行时错误,例如,在函数主
体开头就拒绝无效的参数。
如果一个函数接受一系列项,在内部按照列表处理,那么就不要通过类
型检查强制要求传入一个列表。正确的做法是立即利用参数构建一个列
表。示例 13-10 中的 __init__
方法就采用了这种编程模式。
示例 13-5 利用鸭子类型处理一个字符串或由字符串构成的可迭代对象
try: ❶
field_names = field_names.replace(',', ' ').split() ❷
except AttributeError: ❸
pass ❹
field_names = tuple(field_names) ❺
if not all(s.isidentifier() for s in field_names): ❻
raise ValueError('field_names must all be valid identifiers')
❶ 假设是一个字符串(EAFP 原则:取得原谅比获得许可容易)。
❷ 把逗号替换成空格,再拆分成名称列表。
❸ 抱歉,field_names
的行为不像是字符串:没有 .replace
方法,
或者返回的结果无法拆分。
❹ 如果抛出 AttributeError
,说明 field_names
不是字符串,那就
假设 field_names
是由名称构成的可迭代对象。
❺ 为了确保是可迭代对象,也为了留存一份副本,根据现有数据创建
一个元组。元组比列表紧凑,还能防止代码意外改动名称。
❻ 使用 str.isidentifier
确保每个名称都是有效的标识符。
大鹅类型
抽象类表示接口。
Python
没有 interface
关键字。我们使用抽象基类定义接口,在运行
时显式检查类型(静态类型检查工具也支持)。
在 Python
术语表中,“抽象基类”词条很好地解释了抽象基类为鸭子类
型语言带来的好处。
抽象基类是对鸭子类型的补充,提供了一种定义接口的方式。相比
之下,其他技术(例如 hasattr()
)则显得笨拙或者不太正确(例
如使用魔法方法)。抽象基类引入了虚拟子类,这种类不继承其他
类,却能被 isinstance()
和 issubclass()
识别。
大鹅类型是一种利用抽象基类实现的运行时检查方式。
维基百科上说是我协助传播了鸭子类型(忽略对象的真正类型,转
而关注对象有没有实现所需的方法、签名和语义)这种言简意赅的
说法。
对 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
测序变得既便宜又快速,这使支序学的实用地位变得更高。)
例如,草雁(以前认为与其他鹅类比较相似)和麻鸭(以前认为与
其他鸭类比较相似)现在被分到麻鸭亚科(表明二者的相似性比鸭
科中其他动物高,因为它们的共同祖先比较接近)。此外,DNA
分析表明,白翅木鸭与美洲家鸭(属于麻鸭)不是很像,至少没有
形态和举止看起来那么像,因此把木鸭单独分成了一属,完全不在
麻鸭亚科中。
知道这些有什么用呢?视情况而定!比如,逮到一只水禽后,决定
如何烹制才最美味时,显著的特征(不是全部,例如一身羽毛并不
重要)主要是口感和风味(过时的表征学),这比支序学重要得
多。但在其他方面,如对不同病原体的抗性(圈养水禽还是放
养),DNA
接近性的作用就大多了……
因此,参照水禽的分类学演化,我建议在鸭子类型的基础上补充
(不是完全取代,因为在某些时候,鸭子类型还有它的作用)大鹅类型。
大鹅类型指的是,只要 cls
是抽象基类(cls
的元类是
abc.ABCMeta
),就可以使用 isinstance(obj, cls)
。
其实,抽象基类的本质就是几个特殊方法。
可以看出,无须注册,abc.Sized
也能把Struggle
识别为自己的
子类,只要实现了特殊方法 __len__
即可。(要使用正确的句法
和语义实现,前者要求没有参数,后者要求返回一个非负整数,指
明对象的“长度”。如果不使用规定的句法和语义实现诸如 __len__
之类的特殊方法,那么将导致非常严重的问题。)
最后我想说的是:如果实现的类体现了
numbers
、collections.abc
或其他框架中抽象基类的概念,则
要么继承相应的抽象基类(必要时),要么把类注册到相应的抽象
基类中。开始开发程序时,不要使用提供注册功能的库或框架,要
自己动手注册。作为最常见的情况,如果必须检查参数的类型(例
如检查是不是“序列”),则可以像下面这样做。
isinstance(the_arg, collections.abc.Sequence)
此外,不要在生产代码中定义抽象基类(或元类)……如果很想这
样做,我打赌可能是因为你想“找碴儿”。刚拿到新工具的人都有大
干一场的冲动。如果能避开这些深奥的概念,那么你(以及未来的
代码维护人员)的生活将更愉快,因为代码会变得简洁明了。
综上所述,大鹅类型要求:
- 定义抽象基类的子类,明确表明你在实现既有的接口;
- 运行时检查类型时,
isinstance
和issubclass
的第二个参数要使用抽象基类,而不是具体类。
下面通过实例讲解大鹅类型。
子类化一个抽象基类
我们将遵循 Martelli
的建议,先利用现有的抽象基类
collections.MutableSequence
,然后再“斗胆”自己定义。示例 13-6
明确地把 FrenchDeck2
声明为了 collections.MutableSequence
的子类。
示例 13-6 frenchdeck2.py:collections.MutableSequence
的子类 FrenchDeck2
from collections import namedtuple, abc
Card = namedtuple('Card', ['rank', 'suit'])
class FrenchDeck2(abc.MutableSequence):
ranks = [str(n) for n in range(2, 11)] + list('JQKA')
suits = 'spades diamonds clubs hearts'.split()
def __init__(self):
self._cards = [Card(rank, suit) for suit in self.suits
for rank in self.ranks]
def __len__(self):
return len(self._cards)
def __getitem__(self, position):
return self._cards[position]
def __setitem__(self, position, value): #❶
self._cards[position] = value
def __delitem__(self, position): #❷
del self._cards[position]
def insert(self, position, value): #❸
self._cards.insert(position, value)
❶ 为了支持洗牌,只需实现 __setitem__
方法即可。
❷ 但是,继承MutableSequence
的类必须实现 __delitem__
方法,
这是 MutableSequence
类的一个抽象方法。
❸ 此外,还要实现 insert
方法,这是 MutableSequence
类的第三个
抽象方法。
Python
在导入时(加载并编译 frenchdeck2.py
模块时)不检查抽象方法
的实现,在运行时实例化 FrenchDeck2
类时才真正检查。因此,如果
没有正确实现某个抽象方法,那么 Python
就会抛出 TypeError
异常,
错误消息为 "Can't instantiate abstract class FrenchDeck2
with abstract methods delitem, insert"。正是这个原因,
即便 FrenchDeck2
类不需要 __delitem__
和 insert
提供的行为,也
要实现,这是 MutableSequence
抽象基类的要求。
如图 13-3 所示,抽象基类 Sequence
和 MutableSequence
的方法不全是抽象的。
图 13-3:MutableSequence
抽象基类和 collections.abc
中它的超
类的 UML
类图(箭头由子类指向祖先,以斜体显示的名称是抽象类和抽象方法)
为了把 FrenchDeck2
声明为 MutableSequence
的子类,我不得不实
现例子中用不到的 __delitem__
方法和 insert
方法。作为回
报,FrenchDeck2
从 Sequence 继承了 5 个具体方
法:__contains__
、__iter__
、__reversed__
、index
和 count
。
另外,FrenchDeck2
还从 MutableSequence
继承了 6 个方法:append
、reverse
、extend
、pop
、remove
和 __iadd__
(为就
地拼接的 +=
运算符提供支持)。
在 collections.abc
中,每个抽象基类的具体方法都是作为类的公开
接口实现的,因此无须知道实例的内部结构。
作为实现具体子类的人,你可以覆盖从抽象基类继承的方
法,以更高效的方式重新实现。例如,__contains__
方法会全面
扫描序列,但是,如果你定义的序列按顺序保存元素,则可以重新
定义 __contains__
方法,使用标准库中的 bisect
函数做二分查
找,从而提升搜索速度。
标准库中的抽象基类
标准库中有两个名为 abc
的模块,这里说的是
collections.abc
。为了减少加载时间,Python 3.4
在
collections
包之外实现这个模块(在 Lib/_collections_abc.py
中),因此要与 collections
分开导入。另一个 abc 模块就是
abc(
Lib/abc.py),这里定义的是
abc.ABC类。每个抽象基类都 依赖
abc模块,但是不用导入它,除非自己动手定义新抽象基类。 图 13-4 是
collections.abc 模块中 17 个抽象基类的 UML 类图(简 图,没有属性名称)。
collections.abc 的文档中有一张不错的表 格,对这些抽象基类做了总结,说明了它们相互之间的关系,以及各个 基类提供的抽象方法和具体方法(叫作“混入方法”)。图 13-4 中有很 多多重继承。 ![](https://img2024.cnblogs.com/blog/2583196/202406/2583196-20240613141206486-1102586800.png) 图 13-4:
collections.abc 模块中抽象基类的
UML `类图
下面详述一下图 13-4 中那一群基类。
Iterable
、Container
和Sized
每个容器都应该继承这3
个抽象基类,或者实现兼容的协
议。Iterable
通过__iter__
方法支持迭代,Container
通过
__contains__
方法支持in
运算符,Sized
通过__len__
方法支持len()
函数。Collection
这个抽象基类是Python 3.6
新增的,自身没有方法,目的是方便子
类化Iterable
、Container
和Sized
。Sequence
、Mapping
和Set
这 3 个抽象基类是主要的不可变容器类型,而且各自都有可变的子
类。MutableSequence
的详细类图见图 13-3,MutableMapping
和MutableSet
的类图见图 3-1 和图 3-2。MappingView
在Python 3
中,映射方法.items()
、.keys()
和.values()
返
回的对象分别实现了ItemsView
、KeysView
和ValuesView
定义的接
口。前两个还实现了丰富的Set
接口,拥有“集合运算”一节讲到的所有运算符。Iterator
注意它是 Iterable 的子类。Callable
和Hashable
这两个不是容器,只不过因为collections.abc
是标准库中定义
抽象基类的第一个模块,而它们又太重要了,因此才被放在这里。它们
可以在类型检查中用于指定可调用和可哈希的对象。
检查对象能不能调用,内置函数callable(obj)
比
insinstance(obj, Callable)
使用起来更方便。
如果insinstance(obj, Hashable)
返回False
,那么可以确定obj
不可哈希。然而,返回True
则可能是误判。使用isinstance
检查Hashable
和Iterable
,结果可能不准确
使用 isinstance
和 issubclass
测试抽象基类 Hashable
和
Iterable
,结果很有可能让人误解。
如果 isinstance(obj, Hashable)
返回 True
,那么仅仅表示
obj
所属的类实现或继承了 __hash__
方法。假如 obj
是包含不可
哈希项的元组,那么即便 isinstance
的检查结果为真,obj
仍是
不可哈希对象。技术审校 Jürgen Gmach
指出,利用鸭子类型判断
一个实例是否可哈希是最准确的,即调用 hash(obj)
。如果 obj
不可哈希,那么该调用就会抛出 TypeError
。
另外,即使 isinstance(obj, Iterable)
返回 False
,Python
依然可以通过 __getitem__
(基于 0
的索引)迭代 obj
。
判断一个对象是否可以迭代,唯一可靠的方式是调用iter(obj)
。
了解了一些现有的抽象基类之后,下面从零开始实现一个抽象基类,然
后实际使用,以此实践大鹅类型。这么做的目的不是鼓励所有人自己动
手定义抽象基类,而是借此教你如何阅读标准库和其他包中的抽象基类
源码。
定义并使用一个抽象基类
抽象基类与描述符和元类一样,是用于构建框架的工具。因此,只
有少数 Python
开发者编写的抽象基类不会对用户施加不必要的限
制,让他们做无用功。
如今,抽象基类的作用更广,可用在类型提示中,支持静态类型。8.5.7
节讲过,把函数参数类型提示中的具体类型换成抽象基类能为调用方提
供更大的灵活性。
为了证明有必要定义抽象基类,需要在框架中找到使用它的场景。想象
一下这个场景:你想在网站或移动应用程序中显示随机广告,但是在整
个广告清单轮转一遍之前,不重复显示广告。假设我们在构建一个名为
ADAM
的广告管理框架。它的职责之一是,支持用户提供随机挑选的无
重复类。 为了让 ADAM
的用户明确理解“随机挑选的无重复”组件是什
么意思,我们将定义一个抽象基类。
受到“栈”和“队列”(以物体的排放方式说明抽象接口)的启发,我将使
用现实世界中的物品命名这个抽象基类:宾果机和彩票机是随机从有限
的集合中挑选物品的机器,选出的物品没有重复,直到选完为止。
把这个抽象基类命名为 Tombola
,这是宾果机和打乱数字的滚动容器的意大利语名。
抽象基类 Tombola
有 4 个方法,其中两个是抽象方法。
.load(...)
把元素放入容器。.pick()
从容器中随机拿出一个元素,再返回这个元素。.loaded()
如果容器中至少有一个元素,就返回True
。.inspect()
返回由容器中现有的元素构成的元组,不改变容器的内容(内部的顺序不保留)。
图 13-5 展示了抽象基类Tombola
和 3 个具体实现。
图 13-5:一个抽象基类和3
个子类的UML
类图。根据UML
的约定,
抽象基类Tombola
和它的抽象方法使用斜体。虚线箭头表示接口实
现,这里表示TomboList
不仅实现了Tombola
接口,还被注册为
Tombola
的虚拟子类(详见本章后文)
抽象基类Tombola
的定义如示例 13-7 所示。
示例 13-7tombola.py:Tombola
是有两个抽象方法和两个具体方法的抽象基类
import abc
class Tombola(abc.ABC): #❶
@abc.abstractmethod
def load(self, iterable): #❷
"""从可迭代对象中添加元素"""
@abc.abstractmethod
def pick(self): #❸
"""随机删除元素,再返回被删除的元素。
如果实例为空,那么这个方法应该抛出LookupError
"""
def loaded(self): #❹
"""如果至少有一个元素,就返回True,否则返回False"""
return bool(self.inspect()) #❺
def inspect(self):
"""返回由容器中的当前元素构成的有序元组"""
items = []
while True: #❻
try:
items.append(self.pick())
except LookupError:
break
self.load(items) #❼
return tuple(items)
❶ 继承 abc.ABC
,定义一个抽象基类。
❷ 抽象方法使用 @abstractmethod
装饰器标记,主体通常只有文档字
符串。
❸ 根据文档字符串,如果没有元素可选,那么应该抛出LookupError
。
❹ 抽象基类可以包含具体方法。
❺ 抽象基类中的具体方法只能依赖抽象基类定义的接口(只能使用抽
象基类中的其他具体方法、抽象方法或特性)。
❻ 我们不知道具体子类如何存储元素,但可以不断调用 .pick()
方
法,把 Tombola
清空……
❼ ……然后再使用 .load(...)
把所有元素放回去。
其实,抽象方法可以有实现代码。即便实现了,子类也必须
覆盖抽象方法,但是在子类中可以使用 super()
函数调用抽象方
法,在此基础上添加功能,而不是从头开始实
现。@abstractmethod
装饰器的用法请参见 abc
模块的文档。
虽然示例 13-7 中的 .inspect()
方法的实现方式有些笨拙,但是表
明,有了 .pick()
方法和 .load(...)
方法,如果想查看 Tombola
中
的内容,可以先把所有元素挑出,然后再放回去——毕竟我们不知道元
素具体是如何存储的。这个示例的目的是强调抽象基类可以提供具体方
法,只要仅依赖接口中的其他方法就行。Tombola
的具体子类知晓内部
数据结构,可以使用更聪明的实现覆盖 .inspect()
方法,但这不是强
制要求。
示例 13-7 中的 .loaded()
方法只有一行代码,但是耗时:调用
.inspect()
方法构建有序元组的目的仅仅是在其上调用 bool()
函数。
注意,实现 .inspect()
方法采用的迂回方式要求捕获 self.pick()
抛出的 LookupError。self.pick()
会抛出 LookupError
这一事实也
是接口的一部分,但是在 Python
中没办法明确表明,只能在文档中说
明(参见示例 13-7 中抽象方法 pick
的文档字符串)。
选择使用 LookupError
异常的原因是,在 Python
的异常层次关系中,
它与 IndexError
和 KeyError
有关,而这两个是具体实现 Tombola
所用的数据结构最有可能抛出的异常。因此,实现代码可能会抛出
LookupError、IndexError、KeyError
,或者符合要求的
LookupError
自定义子类。异常的部分层次结构如图 13-6 所示。
图 13-6:Exception
类的部分层次结构
❶ 在 Tombola.inspect
方法中处理的是 LookupError
异常。
❷ IndexError
是 LookupError
的子类,会在尝试从序列中获取索引
超过最后位置的元素时抛出。
❸ 当使用不存在的键从映射中获取元素时,抛出 KeyError
异常。
我们自己定义的抽象基类 Tombola
完成了。为了一睹抽象基类对接口
所做的检查,下面尝试使用一个有缺陷的实现来“糊弄”Tombola
,如示例 13-8 所示。
示例 13-8 不符合 Tombola
要求的子类无法蒙混过关
❶ 把Fake
声明为 Tombola
的子类。
❷ 创建了 Fake
类,目前没有错误。
❸ 尝试实例化 Fake
时抛出了 TypeError
。错误消息十分明确,Python
认为 Fake
是抽象类,因为它没有实现抽象基类 Tombola
声明的抽象方法之一 load
。
我们的第一个抽象基类定义好了,而且还用它实际验证了一个类。稍后
将定义抽象基类 Tombola
的子类,在此之前必须说明抽象基类的一些
编程规则。
抽象基类句法详解
声明抽象基类的标准方式是继承 abc.ABC
或其他抽象基类。
除了ABC
基类和 @abstractmethod
装饰器,abc
模块还定义了
@abstractclassmethod
装饰器、@abstractstaticmethod
装饰器和
@abstractproperty
装饰器。然而,后 3
个装饰器在 Python 3.3
中弃
用了,因为现在可以在 @abstractmethod
之上叠放装饰器,那 3
个就
显得多余了。例如,声明抽象类方法的推荐做法如下所示。
class MyABC(abc.ABC):
@classmethod
@abc.abstractmethod
def an_abstract_classmethod(cls, ...):
pass
也就是说,在@abstractmethod
和 def
语句之间不能有其他装饰器。
说明抽象基类的句法之后,接下来要实现几个具体子代,实际使用Tombola
。
子类化抽象基类 Tombola
定义好抽象基类 Tombola
之后,要开发两个具体子类,满足 Tombola
规定的接口。这两个子类的类图如图 13-5 所示,图中还有 13.5.6 节将
要讨论的一个虚拟子类。
示例 13-9 中的 BingoCage
类是在示例 7-8 的基础上修改的,使用了更
好的随机发生器。BingoCage
实现了所需的抽象方法 load
和 pick
。
示例 13-9 bingo.py:BingoCage
是 Tombola
的具体子类
import random
from tombola import Tombola
class BingoCage(Tombola): #❶
def __init__(self, items):
self._randomizer = random.SystemRandom()# ❷
self._items = []
self.load(items)# ❸
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()
❶ BingoCage
类会显式扩展 Tombola
类。
❷ 假设将在线上游戏中使用这个随机发生器。random.SystemRandom
使用 os.urandom(...)
函数实现 random API
。根据 os
模块的文
档,os.urandom(...)
函数生成“适合用于加密”的随机字节序列。
❸ 委托 .load(...)
方法实现初始加载。
❹ 没有使用 random.shuffle()
函数,而是使用了 SystemRandom
实
例的 .shuffle()
方法。
❺ pick
方法的实现方式与示例 7-8 一样。
❻ __call__
方法也跟示例 7-8 中的一样。为了满足Tombola
接口,无
须实现这个方法,不过额外增加方法也没有危害。
BingoCage
从 Tombola
中继承了耗时的 loaded
方法和笨拙的
inspect
方法。这两个方法都可以覆盖,变成示例 13-10 中速度更快的
一行代码。这里想表达的观点是,我们可以偷懒,直接从抽象基类中继
承不是那么理想的具体方法。从 Tombola
中继承的方法没有
BingoCage
自己定义的那么快,不过只要 Tombola
的子类正确实现
pick
方法和 load
方法,就能提供正确的结果。
示例 13-10 是 Tombola
接口的另一种实现,虽然与之前不同,但完全有
效。LottoBlower
打乱“数字球”后没有取出最后一个,而是取出了一个
随机位置上的球。
示例 13-10 lotto.py:LottoBlower
是 Tombola
的具体子类,覆
盖了继承的 inspect
方法和 loaded
方法
import random
from tombola import Tombola
class LottoBlower(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:
raise LookupError('pick from empty LottoBlower')
return self._balls.pop(position) #❸
def loaded(self):# ❹
return bool(self._balls)
def inspect(self): #❺
return tuple(self._balls)
❶ 初始化方法接受任何可迭代对象,使用传入的参数构建一个列表。
❷ 如果范围为空,那么 random.randrange(...)
函数就会抛出
ValueError
。为了兼容 Tombola
,可以捕获该异常,重新抛出LookupError
。
❸ 否则,从 self._balls
中取出随机选中的元素。
❹ 覆盖 loaded
方法,避免调用 inspect
方法(示例 13-7 中的
Tombola.loaded
方法就是这么做的)。可以直接处理
self._balls
,而不必构建整个元组,从而提升速度。
❺ 覆盖 inspect
方法,仅用一行代码。
示例 13-10 中有个习惯做法值得指出:在 __init__
方法
中,self._balls
存储的是 list(iterable)
,而不是 iterable
的引
用(没有直接把 iterable
赋值给 self._balls,为参数创建别名)。
13.4.3 节说过,这样做使得 LottoBlower
更灵活,因为 iterable
参数
可以是任何可迭代类型。把元素存入列表中还可以确保能取出元素。就
算 iterable
参数始终传入列表,list(iterable)
也会创建参数的副
本,这依然是好的做法,因为要从中删除元素,而客户可能不希望自己
提供的列表被修改。
接下来介绍大鹅类型的重要动态特性:使用 register
方法声明虚拟子类。
抽象基类的虚拟子类
大鹅类型的一个基本特征(也是值得用水禽来命名的原因之一)是,即
便不继承,也有办法把一个类注册为抽象基类的虚拟子类。这样做时,
我们承诺注册的类忠实地实现了抽象基类定义的接口,而 Python
会相
信我们,不再检查。如果我们说谎了,那么常规的运行时异常会把我们
捕获。
注册虚拟子类的方式是在抽象基类上调用 register
类方法。这么做之
后,注册的类就变成了抽象基类的虚拟子类,而且 issubclass
函数能
够识别这种关系,但是注册的类不会从抽象基类中继承任何方法或属性。
虚拟子类不继承注册的抽象基类,而且任何时候都不检查它
是否符合抽象基类的接口,即便在实例化时也不会检查。另外,静
态类型检查工具目前也无法处理虚拟子类。
register
方法通常作为普通函数调用(参见 13.5.7 节),不过也可以
作为装饰器使用。在示例 13-11 中,我们使用装饰器句法实现了
Tombola
的虚拟子类 TomboList
,如图 13-7 所示。
图 13-7:TomboList
的 UML
类图,它既是list
的真实子类,也是Tombola
的虚拟子类
示例 13-11 tombolist.py:TomboList
是 Tombola
的虚拟子类
from random import randrange
from tombola import Tombola
@Tombola.register #❶
class TomboList(list): #❷
def pick(self):
if self: #❸
position = randrange(len(self))
return self.pop(position) #❹
else:
raise LookupError('pop from empty TomboList')
load = list.extend #❺
def loaded(self):
return bool(self) #❻
def inspect(self):
return tuple(self)
# Tombola.register(TomboList) #❼
❶ 把 Tombolist
注册为 Tombola
的虚拟子类。
❷ Tombolist
扩展 list
。
❸ Tombolist
从 list
继承布尔值行为,在列表不为空时返回 True
。
❹ pick
调用从 list
继承的 self.pop
方法,传入一个随机的元素索引。
❺ Tombolist.load
等同于 list.extend
。
❻ loaded
委托 bool
。
❼ 始终可以这样调用 register
。如果需要注册不是自己维护的类,却
能满足指定的接口,就可以这么做。
注册之后,可以使用 issubclass
函数和 isinstance
函数判断
TomboList
是不是 Tombola
的子类。
然而,类的继承关系是在一个名为 __mro__
(Method ResolutionOrder
,方法解析顺序)的特殊类属性中指定的。这个属性的作用很简
单,它会按顺序列出类及其超类,而 Python
会按照这个顺序搜索方
法。 查看 TomboList
类的 __mro__
属性,你会发现它只列出了“真
实”的超类,即list
和 object
。
Tombolist.__mro__
中没有 Tombola
,因此 Tombolist
没有从Tombola
中继承任何方法。
register
的实际使用
在示例 13-11 中,我们把 Tombola.register 当作一个类装饰器使用。
在 Python 3.3 之前,register 不能这样使用,必须像示例 13-11 末尾
的注释那样,作为一个普通函数在类主体之后调用。然而,即便是现
在,仍然经常把 register
当作普通函数调用,注册其他地方定义的
类。例如,在 collections.abc
模块的源码中,内置类型
tuple、str、range
和 memoryview
会像下面这样被注册为 Sequence
的虚拟子类。
Sequence.register(tuple)
Sequence.register(str)
Sequence.register(range)
Sequence.register(memoryview)
另外,还有几个内置类型也会在 _collections_abc.py
中注册为抽象基类
的虚拟子类。注册过程仅在导入模块时发生是没有问题的,因为如果想
使用抽象基类,则必须导入模块。例如,从 collections.abc
中导入
MutableMapping
之后才能执行 isinstance(my_dict,MutableMapping)
检查。
子类化抽象基类或者注册到抽象基类上都能让类通过 issubclass
检查
和 isinstance
检查(后者依赖前者)。但是,有些抽象基类还支持结
构类型,详见 13.5.8 节。
13.5.8 使用抽象基类实现结构类型
抽象基类最常用于实现名义类型。假如一个类 Sub
会显式继承抽象基类
AnABC
,或者注册到AnABC
上,那么 AnABC
这个名称就和 Sub
连在了
一起,因此在运行时,issubclass(AnABC, Sub)
会返回 True
。
相比之下,结构类型通过对象公开接口的结构判断对象的类型,如果一
个对象实现了某个类型定义的方法,那么该对象就与该类型相容。 动
态鸭子类型和静态鸭子类型是实现结构类型的两种方式。
其实,某些抽象基类也支持结构类型。Alex
在“水禽和抽象基类”附注栏
中说过,未注册的类也可能被识别为抽象基类的子类。下面再次给出他
举的例子(增加了 issubclass
测试)。
经 issubclass
函数判断,Struggle
类是abc.Sized
的子类(进而isinstance
也得出同样的结论),因为 abc.Sized
实现了一个名为
__subclasshook__
的特殊的类方法。
Sized
类的 __subclasshook__
方法会检查通过参数传入的类有没有
名为 __len__
的属性。如果有,就认为是 Sized
的虚拟子类。详见示例 13-12。
示例 13-12 源文件 Lib/_collections_abc.py
中 Sized
的定义
class Sized(metaclass=ABCMeta):
__slots__ = ()
@abstractmethod
def __len__(self):
return 0
@classmethod
def __subclasshook__(cls, C):
if cls is Sized:
if any("__len__" in B.__dict__ for B in C.__mro__): #❶
return True #❷
return NotImplemented #❸
❶ 如果 C.__mro__
列出的某个类(C
及其超类)的 __dict__
中有名
为 __len__
的属性……
❷ ……就返回 True
,表明 C
是 Sized
的虚拟子类。
❸ 否则,返回 NotImplemented
,让子类检查继续下去。
抽象基类对结构类型的支持就是通过 __subclasshook__
实现的。可
以使用抽象基类确立接口,使用 isinstance
检查该抽象基类,一个完
全无关的类仍然能通过 issubclass
检查,因为该类实现了特定的方法
(或者该类竭力说服了 __subclasshook__
为它“担保”)。
那么,自己定义的抽象基类应该实现 __subclasshook__
方法吗?或
许不应该。在 Python
源码中,我只见过 Sized
这种仅有一个特殊方法
的抽象基类实现了 __subclasshook__
方法,而且只检查那个特殊方
法的名称。由于__len__
是“特殊”方法,因此我们可以十分肯定它的
作用符合预期。然而,即便是特殊方法和基本的抽象基类,这种假设也
是有风险的。例如,虽然映射实现了 __len__
、__getitem__
和
__iter__
,但是肯定不能把它看作 Sequence
的子类型,因为无法通
过整数偏移或切片从映射中获取元素。鉴于此,abc.Sequence
类没有
实现 __subclasshook__
方法。
对于我们自己编写的抽象基类,__subclasshook__
的可信度并不高。
假如有一个名为 Spam
的类,它实现或继承了 load、pick、inspect
和 loaded
等方法,但是我并不能百分之百确定它的行为与 Tombola 类
似。让程序员把Spam
定义为 Tombola
的子类,或者使用
Tombola.register(Spam)
注册,这样才能板上钉钉。当然,实现
__subclasshook__
方法时还可以检查方法签名和其他功能,但是我认
为没这个必要。
静态协议
本节将通过两个简单的示例来讲解静态协议,顺带讨论一下数值抽象基
类和协议。首先说明如何利用静态协议来注解 8.4 节见过的 double()
函数,并对它做类型检查。
为 double
函数添加类型提示
在向更习惯静态类型语言的程序员介绍 Python
时,我喜欢用简单的
double
函数举例。
引入静态协议之前,几乎不可能为 double
函数添加完美的类型提示,
用途总会受到限制。
得益于鸭子类型,double
函数甚至支持未来可能出现的类型,例如
16.5 节的增强的 Vector
类。
示例 13-13 double_protocol.py
:使用 Protocol
定义 double
函数
from typing import TypeVar, Protocol
T = TypeVar('T') #❶
class Repeatable(Protocol):
def __mul__(self: T, repeat_count: int) -> T: ... #❷
RT = TypeVar('RT', bound=Repeatable) #❸
def double(x: RT) -> RT: #❹
return x * 2
❶ T
在__mul__
签名中使用。
❷ __mul__
是 Repeatable
协议的核心。self
参数通常不注解,因为
默认假定为所在的类。这里使用 T 是为了确保返回值的类型与 self
相
同。另外注意,这个协议把 repeat_count
限制为 int
类型。
❸ 类型变量 RT
的上界由 Repeatable
协议限定,类型检查工具将要求
具体使用的类型实现 Repeatable
协议。
❹ 现在,类型检查工具可以确认 x
参数是一个可以乘以整数的对象,
而且返回值的类型与x
相同。
运行时可检查的静态协议
在类型图(参见图 13-1)中,typing.Protocol
位于静态检查区域,
即图的下半部分。然而,定义 typing.Protocol
的子类时,可以借由
@runtime_checkable
装饰器让协议支持在运行时使用
isinstance/issubclass
检查。这背后的原因是,typing.Protocol
是一个抽象基类,因此它支持 13.5.8 节讲过的 __subclasshook__
。
从 Python 3.9
开始,typing
模块提供了 7
个可在运行时检查的协议。
下面是其中两个,直接摘自 typing
模块的文档。
class typing.SupportsComplex
抽象基类,有一个抽象方法__complex__
。class typing.SupportsFloat
抽象基类,有一个抽象方法__float__
。
这些协议旨在检查数值类型可否转换类型。如果对象 o
实现了
__complex__
,那么调用 complex(o)
应该得到一个 complex
值,因
为在背后支持内置函数 complex()
的就是特殊方法 __complex__
。
示例 13-14 是 typing.SupportsComplex
协议的源码。
示例 13-14 typing.SupportsComplex
协议的源码
@runtime_checkable
class SupportsComplex(Protocol):
"""具有一个抽象方法__complex__的抽象基类"""
__slots__ = ()
@abstractmethod
def __complex__(self) -> complex:
pass
这个协议的核心是抽象方法 __complex__
。 在静态类型检查中,如
果一个对象实现了 __complex__
方法,而且只接受参数 self
,并且返
回一个 complex
值,那么就认为该对象与 SupportsComplex
协议相容。
由于 SupportsComplex
应用了@runtime_checkable
类装饰器,因此
该协议也可以使用 isinstance
检查,如示例 13-15 所示。
❶ complex64
是 NumPy
提供的5
种复数类型之一。
❷ NumPy
中的复数类型均不是内置类型 complex
的子类。
❸ 但是,NumPy
中的复数类型实现了__complex__
方法,因此符合SupportsComplex
协议。
❹ 因此,可以使用 NumPy
中的复数类型创建内置的 complex
对象。
❺ 可惜,内置类型 complex
没有实现 __complex__
方法。不过,当 c
是一个 complex
值时,complex(c)
能得到正确的结果。
根据最后一点,如果想测试对象 c
是不是complex
或
SupportsComplex
,那么可以为 isinstance
的第二个参数提供一个
类型元组,如下所示。
isinstance(c, (complex, SupportsComplex))
另外,还可以使用 numbers
模块中定义的抽象基类 Complex
。内置类
型 complex
,以及 NumPy
中的 complex64
类型和 complex128
类型都
被注册为 numbers.Complex
的虚拟子类了,因此可以像下面这样检查。
运行时协议检查的局限性
如前所述,类型提示在运行时一般会被忽略。使用isinstance
或
issubclass
检查静态协议有类似的影响。
例如,实现 __float__
方法的类在运行时都被认定是 SupportsFloat
的虚拟子类,不管 __float__
方法是否返回一个 float
值。
在 Python 3.9
中,complex
类型确实有 __float__
方法,不过该方法
仅仅抛出 TypeError
,并输出一个明确的错误消息。如果那个
__float__
方法有注解,则返回值类型应该是 NoReturn
(参见 8.5.12节)。
但是,typeshed
项目中 complex.__float__
的类型提示解决不了这个
问题,因为 Python
的运行时一般会忽略类型提示,而且根本无法访问
typeshed
项目中的存根文件。
检查的结果容易让人误解。运行时对 SupportsFloat
的检查表明,可
以把一个 complex
值转换成 float
值,而实际情况却是抛出类型错误。
支持静态协议
请回忆一下第 11 章中构建的 Vector2d 类。既然一个复数和一个
Vector2d 实例都由一对浮点数构成,那么顺理成章,应该支持把
Vector2d 转换成 complex。
示例 13-16 给出了__complex__
方法的实现,在示例 11-11 中最后一
版的基础上增强 Vector2d
类。为了支持双向转换,还定义了类方法
fromcomplex
,执行反向操作,根据 complex
值构建 Vector2d
实例。
示例 13-16 vector2d_v4.py:
与 complex
相互转换的方法
from array import array
from typing import SupportsComplex, SupportsAbs
import math
class Vector2d:
__match_args__ = ('x', 'y')
typecode = 'd'
def __init__(self, x, y):
self.__x = float(x)
self.__y = float(y)
@property
def x(self):
return self.__x
@property
def y(self):
return self.__y
def __iter__(self):
return (i for i in (self.x, self.y))
def __repr__(self):
class_name = type(self).__name__
return '{}({!r}, {!r})'.format(class_name, *self)
def __str__(self):
return str(tuple(self))
def __bytes__(self):
return (bytes([ord(self.typecode)]) +
bytes(array(self.typecode, self)))
def __eq__(self, other):
return tuple(self) == tuple(other)
def __hash__(self):
return hash((self.x, self.y))
def __abs__(self):
return math.hypot(self.x, self.y)
def __bool__(self):
return bool(abs(self))
def angle(self):
return math.atan2(self.y, self.x)
def __format__(self, fmt_spec=''):
if fmt_spec.endswith('p'):
fmt_spec = fmt_spec[:-1]
coords = (abs(self), self.angle())
outer_fmt = '<{}, {}>'
else:
coords = self
outer_fmt = '({}, {})'
components = (format(c, fmt_spec) for c in coords)
return outer_fmt.format(*components)
def __complex__(self):
return complex(self.x, self.y)
@classmethod
def fromcomplex(cls, datum):
return cls(datum.real, datum.imag) #❶
if __name__ == "__main__":
v = Vector2d(3, 4)
print(isinstance(v, SupportsComplex))
print(isinstance(v, SupportsAbs))
print(complex(v))
print(abs(v))
print(Vector2d.fromcomplex(3+4j))
对于运行时类型检查,示例 13-16 可以胜任,但是为了让Mypy
更好地
做静态检查和错误报告,__abs__
方法、__complex__
方法和
fromcomplex
方法应该有类型提示,如示例 13-17 所示。
示例 13-17 vector2d_v5.py
:为当前研究的方法添加注解
from array import array
from typing import SupportsComplex, SupportsAbs
import math
class Vector2d:
__match_args__ = ('x', 'y')
typecode = 'd'
def __init__(self, x, y):
self.__x = float(x)
self.__y = float(y)
@property
def x(self):
return self.__x
@property
def y(self):
return self.__y
def __iter__(self):
return (i for i in (self.x, self.y))
def __repr__(self):
class_name = type(self).__name__
return '{}({!r}, {!r})'.format(class_name, *self)
def __str__(self):
return str(tuple(self))
def __bytes__(self):
return (bytes([ord(self.typecode)]) +
bytes(array(self.typecode, self)))
def __eq__(self, other):
return tuple(self) == tuple(other)
def __hash__(self):
return hash((self.x, self.y))
def __abs__(self) -> float: #❶
return math.hypot(self.x, self.y)
def __complex__(self) -> complex: #❷
return complex(self.x, self.y)
@classmethod
def fromcomplex(cls, datum: SupportsComplex): #❸
c = complex(datum) #❹
return cls(c.real, c.imag)
def __bool__(self):
return bool(abs(self))
def angle(self):
return math.atan2(self.y, self.x)
def __format__(self, fmt_spec=''):
if fmt_spec.endswith('p'):
fmt_spec = fmt_spec[:-1]
coords = (abs(self), self.angle())
outer_fmt = '<{}, {}>'
else:
coords = self
outer_fmt = '({}, {})'
components = (format(c, fmt_spec) for c in coords)
return outer_fmt.format(*components)
if __name__ == "__main__":
v = Vector2d(3, 4)
print(isinstance(v, SupportsComplex))
print(isinstance(v, SupportsAbs))
print(complex(v))
print(abs(v))
print(Vector2d.fromcomplex(3+4j))
❶ 需要把返回值类型注解为 float
,否则 Mypy
推导出的类型是 Any
,
而且不检查方法主体。
❷ 即使不注解,Mypy
也能推导出该方法返回一个 complex
值。在Mypy
的某些配置下,这个注解可以避免一个警告。
❸ SupportsComplex
确保 datum
可以转换成要求的类型。
❹ 有必要显式转换,因为 SupportsComplex
类型没有声明下一行用到的 .real
属性和 .imag
属性。例如,虽然Vector2d
没有这两个属
性,但是实现了__complex__
方法。
如果该模块的顶部有 from __future__ import annotations
,那么
fromcomplex
的返回值类型可以是 Vector2d
。有了那个导入语句,类
型提示将存储为字符串,在导入时(求解函数定义时)不做求解。不从
__future__
中导入 annotations,Vector2d
在那一刻(类尚未完整
定义)就是无效引用,应该写为字符串 'Vector2d'
,假装是向前引
用。这个 __future__
导入由“PEP 563—Postponed Evaluation of
Annotations”引入,在 Python 3.7
中实现。原本计划在 Python 3.10
中把
这个行为定为默认行为,但是后来推迟到下一个版本了。 到那时,这
个导入语句就是多余的了,但是也没有危害。
设计一个静态协议
研究大鹅类型时,我们定义了抽象基类 Tombola
(参见 13.5.3 节),现
在将使用静态协议定义一个类似的接口。
抽象基类 Tombola
有两个抽象方法:pick
和 load
。定义具有这两个
方法的静态协议也不难,不过,我从 Go
语言社区学到一项知识:单方
法协议实现的静态鸭子类型更有用且更灵活。Go
语言标准库中有多个
这样的接口,例如 Reader
,这是一个 I/O
接口,只要求一个 read
方
法。以后,如果觉得需要一个更完整的协议,可以把多个协议合而为一。
可以随机从中选择元素的容器,不一定需要重新加载容器,但是肯定需
要选择元素的方法。因此,我决定为精简的 RandomPicker
协议实现这样一个方法。该协议的代码如示例 13-18 所示,演示用法的测试如示例
13-19 所示。
示例 13-18 randompick.py
:定义 RandomPicker
from typing import Protocol, runtime_checkable, Any
@runtime_checkable
class RandomPicker(Protocol):
def pick(self) -> Any: ...
pick
方法的返回值类型是 Any
。15.8 节将说明如何让
RandomPicker
支持泛型参数,允许协议的用户指定 pick
方法的
返回值类型。
示例 13-19 randompick_test.py
:使用 RandomPicker
import random
from typing import Protocol, runtime_checkable, Any, reveal_type
from typing import Any, Iterable, TYPE_CHECKING
from randompick import RandomPicker #❶
@runtime_checkable
class RandomPicker(Protocol):
def pick(self) -> Any: ...
class SimplePicker: #❷
def __init__(self, items: Iterable) -> None:
self._items = list(items)
random.shuffle(self._items)
def pick(self) -> Any: #❸
return self._items.pop()
def test_isinstance() -> None: #❹
popper: RandomPicker = SimplePicker([1]) #❺
assert isinstance(popper, RandomPicker) #❻
def test_item_type() -> None: #❼
items = [1, 2]
popper = SimplePicker(items)
item = popper.pick()
assert item in items
if TYPE_CHECKING:
reveal_type(item) #❽
assert isinstance(item, int)
❶ 定义实现协议的类无须先导入静态协议。这里导入 RandomPicker
是因为后面的 test_isinstance
会用到。
❷ SimplePicker
实现 RandomPicker
协议,但不是后者的子类。这就
是静态鸭子类型。
❸ 默认的返回值类型就是 Any
,因此严格来说,不需要这个注解。但
是,加上注解可以明确表明我们实现的是示例 13-18 中的
RandomPicker
协议。
❹ 如果想让Mypy
检查,那么别忘了加上类型提示 -> None
。
❺ 我为 popper
变量添加了类型提示,指出 Mypy
知道 SimplePicker
是相容的。
❻ 这个测试证明,SimplePicker
的实例也是 RandomPicker
的实例。
背后的原因是,RandomPicker
应用了@runtime_checkable
装饰
器,而且 SimplePicker
有所需的 pick
方法。
❼ 这个测试在 SimplePicker
实例上调用 pick
方法,确认返回一个提
供给 SimplePicker
的元素,然后对返回的元素做静态检查和运行时检查。
❽ 这一行在 Mypy
的输出中生成一个说明。
我们在示例 8-22 中见过的 reveal_type
是能被 Mypy
识别的“魔法”函
数,无须导入,而且只能在受typing.TYPE_CHECKING
条件保护的 if
块中调用。typing.TYPE_CHECKING
条件只在静态类型检查工具眼中
为 True
,在运行时为 False
。
示例 13-19 中的两个测试均能通过,Mypy
也没有发现任何错误。对于
pick
方法返回的 item,reveal_type
输出的结果如下所示。
协议设计最佳实践
Go
语言 10 年的静态鸭子类型经验表明,窄协议(narrow protocol
)更
有用。通常,窄协议只有一个方法,很少超过两个。Martin Fowler
写了
一篇定义角色接口(role interface
)的文章,设计协议时可做考虑。
另外,有时你会发现,协议在使用它的函数附近定义,即在“客户代
码”中而不是在库中定义。这样方便调用相关函数创建新类型,也有利
于扩展和使用驭件(mock
)测试。
窄协议和客户代码协议都能有效避免紧密耦合,正符合接口隔离原则
(Interface Segregation Principle
)。这个原则可用一句话概括:“不应强
迫客户依赖用不到的接口。
“Contributing to typeshed”页面建议静态协议采用以下命名约定(以下 3
点直接引用原文,未做改动)。
- 使用朴素的名称命名协议,清楚表明概念(例如
Iterator
和Container
)。 - 使用
SupportsX
形式命名提供可调用方法的协议(例如
SupportsInt、SupportsRead
和SupportsReadSeek
)。 - 使用
HasX
形式命名有可读属性和可写属性,或者有读值方法和设
值方法的协议(例如HasItems
和HasFileno
)。
扩展一个协议
13.6.6 节开头提到,Go
语言开发人员定义接口(他们对静态协议的称
呼)时倾向于极简主义。很多广泛使用的 Go
语言接口只有一个方法。
如果实际使用中发现协议需要多个方法,那么不要直接为协议添加方
法,最好衍生原协议,创建一个新协议。在 Python
中,扩展静态协议
有几个问题需要注意,如示例 13-20 所示。
示例 13-20 randompickload.py
:扩展RandomPicker
协议
from typing import Protocol, runtime_checkable
from randompick import RandomPicker
@runtime_checkable #❶
class LoadableRandomPicker(RandomPicker, Protocol): 3❷
def load(self, Iterable) -> None: ... #❸
❶ 如果希望衍生的协议可在运行时检查,则必须再次应用这个装饰
器,因为该装饰器的行为不被继承。
❷ 每个协议都必须明确把typing.Protocol
列出来,作为基类。另
外,再列出要扩展的协议。这与 Python
中的继承不是一回事。
❸ 现在是符合“常规”的面向对象编程方式了:只需要声明衍生协议新增
的方法。pick
方法的声明继承自 RandomPicker
。
numbers
模块中的抽象基类和Numeric
协议
“论数字塔的倒下”一节讲过,标准库中 numbers
包内的抽象基类可用
于做运行时类型检查。
如果想检查是不是整数,可以使用 isinstance(x, numbers.Integral)
。int、bool
(int
的子类),以及外部库中注册
为 numbers
包中某个抽象基类的虚拟子类的整数类型,都能通过这个
测试。例如,NumPy
提供了21
个整数类型,另外还有注册为
numbers.Real
的虚拟子类的多个浮点数类型,以及注册为
numbers.Complex
的虚拟子类的不同位宽度(bit width
)的复数。
可惜,numbers
包定义的数字塔不是为静态类型检查设计的。根抽象基
类 numbers.Number
没有方法,因此,对于 x: Number
声明,Mypy
不会允许你对 x
做任何算术运算或者调用任何方法。
typeshed
项目是一个很好的选择。这个项目为 Python
标准库提供类型提
示,例如,statistics
模块的类型提示在存根文件 statistics.pyi
中。
在这个文件中你能找到以下定义,很多函数的注解用到了这两个类型。
_Number = Union[float, Decimal, Fraction]
_NumberT = TypeVar('_NumberT', float, Decimal, Fraction)
这种方式是不错,但是不全面,不支持标准库以外的数值类
型。numbers
包中的抽象基类支持在运行时检查外部数值类型,即那些
注册为虚拟子类的数值类型。
目前的趋势是使用 typing
模块提供的数值协议。
NumPy
中复数类型实现的 __float__
方法和 __int__
方法就好
一些,只在第一次使用时发出警告。
反向转换也有问题。内置类型 complex、float
和 int
,以及
numpy.float16 和 numpy.uint8
,没有实现 __complex__
方法,因
此 isinstance(x, SupportsComplex)
返回 False
。 NumPy
中的复
数类型,比如 np.complex64
,则实现了 __complex__
方法,可以转
换成内置类型 complex
。
然而,实际使用中,内置构造函数 complex()
能正确处理所有这些类
型的实例,不报错也不发出警告。
由上述代码可知,isinstance
对 SupportsComplex
的检查,有些是
失败的,但是全部都可以成功转换成 complex
类型。Guido van Rossum
在 typing-sig
邮件列表中指出,内置构造函数 complex
只接受一个参
数,所以全都可以转换。
另外,对于下面的 to_complex()
函数,使用Mypy
检查时,参数可以
接受全部 6 种类型。
def to_complex(n: SupportsComplex) -> complex:
return complex(n)
综上所述,虽然数值类型不应该这么难做类型检查,但是现在的情况
是,“PEP 484–Type Hints”有意避开数字塔,含蓄地建议类型检查工具
硬编码内置类型 complex、float
和int
之间的子类型关系。Mypy
就
是这样做的,而且从实用角度出发,还认定 int
和 float
与
SupportsComplex
相容,尽管二者没有实现 __complex__
方法。