本章话题是接口:鸭子类型代表特征动态协议; 使接口更明确、能验证实现是否副了规定的抽象基类ABC(Abstact Base Class).
Python语言诞生15年后,Python2.6中才引入了抽象基类,抽象基类。对于java、C#类似的语言,会觉得鸭子类型的非正式协议很新奇。
抽象基类于描述符和元类一样,是用于构建框架的工具。
其实很多时候Python开发者编写的抽象基类会对用户施加不必要的限制,做无用功。
Python文化中的接口和协议
引入抽象基类之前,Python已经很成功了,即便是现在也很少有代码使用抽象基类。
把协议定义为非正式的接口,是让Python这种动态类型语言实现多态的方式。
那么接口在动态语言中是怎么运作的呢?
首先,Python语言没有interface关键字。而且除了抽象基类,每个类都有接口:类实现或继承的公开属性(方法或者是数据属性),包括特殊方法,如__getitem__(实现切片slice) __add__(实现加法运算)。不要觉得把公开数据属性放入对象的接口中不妥,因为如果需要,总能实现读值和设值的方法,把数据属性变为特征,使用obj.attr句法的客户代码不会受到影响。比如前期实现的向量类Vector2d把x和y是公开数据属性,但是后期把xy变为了只读属性__x和__y但是用于依旧可以读取v.x和v.y因为使用特征实现了x和y 用到装饰器@property
关于接口补充定义:对象公开方法的子集,让对象在系统中扮演特定的角色。接口是实现特定角色的方法合集,这个理解正是所说的协议。协议和继承没有关系。一个类可能会实现多个接口,从而让实例扮演多个角色。
协议是接口,但不是正式的,能由文档和约定定义,因此协议不能像正式接口那样施加限制。一个类可能只实现部分接口,这是允许的。
对于Python程序员来说,“X类对象”、“X接口”、“X协议”都是一个意思。
Python序列:Sequence抽象基类
对于序列来说,即便是最简单的实现,Python也会力求做到最好。
上图是Sequence抽象基类和collections.abc中相关抽象类,剪头是子类指向超类,以斜体显示的是抽象方法。
比如下面的例子,Foo类没有继承abc.Sequence,只实现了序列协议的一个方法__getitem__,这样足够访问元素,迭代和使用in运算符了
class Foo:
def __getitem__(self, item):
return range(0, 30, 10)[item]
foo = Foo()
print(foo[1])
for x in foo: print(x)
print(20 in foo)
打印
10
0
10
20
True
可以使用for循环遍历,虽然没有实现__iter__方法,但是Foo实例是可迭代对象,因为Python发现有__getitem__方法,传入从0开始的整数索引,尝试迭代对象。(这是一种后备机制)
可以使用in运算符,虽然没有__contains__方法,但是Python足够智能,能迭代Foo实例,因此也能使用in运算符,Python会做全面的检查,看看有没有指定的元素。
综上,鉴于序列协议的重要性,如果没有__iter__和__contains__方法,Python会调用__getitem__方法,设法让迭代和in运算符可用。
Python会特殊对待看起来像序列的对象,Python中的迭代是鸭子类型的一种极端形式:为了迭代对象,解释器会尝试调用两个不同的方法。
猴子补丁:在运行时修改类或模块
属性在运行时的动态替换,叫做猴子补丁(Monkey Patch)
示例,一个实现序列协议的FrenchDeck类
class FrenchDeck:
ranks = [str(x) for x in range(2, 11)] + list('JQKA')
suits = 'spades diamonds clubs hearts'.split() # 花色
def __init__(self):
self._cards = [Card(rank, suit) for rank in self.ranks for suit in self.suits]
def __len__(self):
return len(self._cards)
def __getitem__(self, item):
return self._cards[item]
def __repr__(self):
return str(self._cards)
上面这个纸牌类无法洗牌,如果FrenchDeck的行为像序列,那么random.shuffle(x)函数应该可用,它的作用是:就地打乱序列x
>>> import random
>>> li = list(range(6))
>>> random.shuffle(li)
>>> li
[0, 4, 5, 3, 1, 2]
但是作用在FrenchDeck实例中,就会出现异常:TypeError: 'FrenchDeck' object does not support item assignment
这个问题是原因是shuffle函数要调换序列中元素的位置,而FrenchDeck只实现了不可变序列协议。可变序列还需要提供__setitem__方法。
Python是动态语言,因此我们可以在运行时修正这个问题。
cards = FrenchDeck()
def set_card(deck, position, value):
"""猴子补丁,实现__setitem__"""
deck._cards[position] = value # 需要知道有个_cards属性
FrenchDeck.__setitem__ = set_card # 打到类对象上,而不是实例
random.shuffle(cards)
print(cards[:5])
打印
[Card(rank='6', suit='clubs'), Card(rank='2', suit='clubs'), Card(rank='4', suit='spades'), Card(rank='A', suit='hearts'), Card(rank='3', suit='spades')]
知识点:
补丁函数要代替的是__setitem__这里没有使用self/key/value三个参数,因为Python方法也是普通函数,第一个参数命名为self只是一种约定。
关键点是,set_card函数需要知道deck对象有一个名为_cards的属性,而且_cards是可变序列。
打补丁是在类对象,不是实例对象
这种技术叫做猴子补丁:在运行时修改类或模块,而不改动源码。
上面的示例还强调了协议是动态的:random.shuffle函数不关心参数的类型,只要那个对象实现了部分可变序列的协议即可。即使对象一开始没有对应的方法也没关系,后来在提供也行。
这就是“鸭子类型”:对象的类型无关紧要,只要实现了特定的协议即可。
定义抽象基类的子类
现在我们重新实现一个FrenchDeck纸牌,通过继承mutableSequence实现
import collections
Card = collections.namedtuple('Card', 'rank suit')
class FrenchDeck(collections.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 rank in self.ranks for suit in self.suits]
def __getitem__(self, item):
return self._cards[item]
def __len__(self):
return len(self._cards)
def __setitem__(self, key, value):
self._cards[key] = value
def __delitem__(self, key):
del self._cards[key]
def insert(self, index, value):
self._cards.insert(index, value)
在导入这个french_deck.py模块时,Python不会检查抽象方法的时候,在运行实例化的FrenchDeck类才会真正的检查,如果没有实现抽象类的全部抽象想法,就有抛出TypeError异常。
就是这样原因FrenchDeck不需要__delitem__和insert提供的行为,但是也必须要实现。
剪头由子类指向超类,斜体显示的是抽象类和抽象方法
FrenchDeck从Sequence继承了几个拿来即用的方法__contains__/__iter__/__reversed__/index/count
从MutableSequence继承了append/extend/pop/remove/__iadd__
ps:要想实现子类,我们可以覆盖从抽象基类中继承的方法,以更高效的方式实现。比如__contains__方法会全面的扫描整个序列,如果定义的序列是有序的,可以使用bisect函数做二分查找的逻辑,重写__contains__方法,实现更快的速度搜索。
标准库中的抽象基类abc、numbers
从Python2.6开始,标准库中提供了抽象类。大多数抽象类都在collection.abc中,还有在numbers和io包中也会有抽象类。
abc模块:
在标准库中有两个abc模块,其中一个是collection.abc,这里是各种已经实现好的抽象类,可以直接继承使用。
还有一个是abc,这里一般用到abc.ABC类,每个抽象基类都依赖这个类,用于定义新的抽象基类。
下图是在Python3.4中collection.abc模块定义的16个抽象基类:
Iterable、Container、Sized
各个集合都应该继承这三个抽象基类,或者至少实现兼容的协议。Iterable通过__iter__方法实现迭代、Container通过__container__方法实现in运算符、Sized通过__len__方法实现len()函数
Sequence、Mapping、Set
这三个是主要的不可变集合类型,各自都有自己可变的子类。
MappingView
Python3中,映射方法.items()/.keys()/.values()返回的对象分别是ItemView/KeysView/ValuesView的实例
Callable、Hashable
这两个抽象基类主要为了支持内置函数isinstance判断类型,以一种安全的类型安排对象能不能调用或者散列。(判断能否调用还可以用callable()函数,但是没有hashable()函数)
Iterator
这是Iterable的子类,后面会详细讨论
numbers模块:
numbers包的定义是“数字塔”(各个抽象基类的层次是线性的),最顶层的是超类,依次往下是子类
金字塔顺序:
Number
Complex 复合数 (包含复数)
Real :实数 int、bool、float、fractions.Fraction(分数)、Numpy的非复数类型
Rational 有理数(有理数是整数(正整数、0、负整数)和分数的统称,是整数和分数的集合。整数也可看做是分母为一的分数。)
Integral : int、bool(int的子类)
检查一个数是不是整数,可以使用isinstance(x, numbers.Integral)
检查一个数是不是浮点数类型,可以使用isinstance(x, numbers.Real)
定义一个抽象基类
声明抽象类最简单的方式是继承abc.ABC或者其他抽象类。
ABCMeta用来声明这个类只能被继承,不能实例化,实例化会报错
然后abc.ABC是在Python3.4中新增的类,如果是其他旧版本的Python,有不通的方式:
Python3.4以上:
class Tombola(abc.ABC):
Python3.0~Python3.4:
class Tombola(metaclass=abc.ABCMeta):
Python2:
class Tombola:
__metaclass__=abc.ABCMeta
把方法变为抽象使用装饰器@abc.abstractmethod,在所有Python版本中都一样。
注意如果方法还有用到其他装饰器,要保证@abc.abstractmethod在最里层!
class MyABC(abc.ABC):
@classmethod
@abc.abstractmethod
def an_cls_method(cls, x):
pass
实现一个抽象基类名为Tombola,这个一个符合抽奖机的特征,有四个方法,其中前面两个是抽象方法
load() 把元素放入容器
pick() 从容器中随机拿出一个元素,返回选中的元素。
loaded() 如果容器中至少有一个元素,返回True
inspect() 返回一个有序元组,由容器中的现有元素构成,不会修改容器内容(但是顺序不保留)
示例,Tombola抽象基类。
import abc
class Tombola(abc.ABC):
@abc.abstractmethod
def load(self, iterable):
"""从可迭代对象中添加元素"""
@abc.abstractmethod
def pick(self):
"""随机删除元素,然后将其返回
如果实例为空,这个方法抛出异常LookUpError"""
def loaded(self):
"""如果至少有一个元素,返回True,否则返回FALSE"""
bool(self.inspect())
def inspect(self):
"""返回一个有序元组,由当前元素构成。"""
items = []
while True:
try:
items.append(self.pick()) # 我们不知道子类如何存储元素,不过为了得到所有元素,只能不断的pick出所有的
except LookupError:
break
self.load(items) # 刚才的循环后,容器已空,再次加进去,虽然顺序已经和之前不同了
return tuple(sorted(items))
知识点:
自己定义的抽象基类要继承abc.ABC
抽象方法使用@abstractmethod装饰器标记,而且定义体中通常只有字符串文档
抽象基类也可以包含具体的方法,但是具体方法里面的实现,只能使用其他的具体方法、抽象方法或特征。
其实抽象方法里面也可以有实现代码。即便实现了,子类也必须覆盖抽象方法,但是子类可以使用super()函数调用抽象方法。
inspect()方法实现起来有些笨拙,但也是无奈之举,因为不知道子类如何存储的元素,只能先把所有元素调出,然后再放回去。
后续继承的子类,必须实现两个抽象方法,否者就会异常:
class Fake(Tombola):
def pick(self):
return 12
print(Fake)
fake = Fake()
打印
<class '__main__.Fake'>
Traceback (most recent call last):
File "D:/PycharmProjects/hello/test2.py", line 38, in <module>
fake = Fake()
TypeError: Can't instantiate abstract class Fake with abstract methods load
创建Fake类并没有报错,尝试实例化Fake时抛出TypeError,报错信息也很明确,没有实现load方法。
定义抽象基类的子类
BingoCage类是Tombola的具体子类,实现了所需的抽象方法pike和load,继承了loaded方法,还增加了__call__方法
class BingoCage(Tombola):
def __init__(self, items):
self._randomizer = random.SystemRandom()
self._items = []
self.load(items)
def load(self, iterable):
self._items.extend(iterable)
self._randomizer.shuffle(self._items) # 类似于random.shuffle()就地随机打乱顺序
def pick(self):
try:
return self._items.pop()
except IndexError:
raise LookupError('BingoCage is empty!')
def __call__(self, *args, **kwargs):
return self.pick()
知识点:
random.SystemRandom() 使用的是os.urandom()函数实现的api,这个比random提供了更适合加密的伪随机数,用法和random相同
random.SystemRandom().shuffle和random.shuffle一样,都是就地打乱可变序列的顺序。
以上子类都继承了抽象类的loaded和inspect方法,没有进行修改。
LotteryBlower是Tombola的具体子类,覆盖了继承的loaded和inspect方法,
主要区别是:pick方法没有使用pop出最后一个球,而是取出一个随机位置上的球
class LotteryBlower(Tombola):
def __init__(self, items):
self._items = list(items)
def load(self, iterable):
self._items.extend(iterable)
def pick(self):
try:
position = random.randrange(len(self._items)) # 随机位置
except ValueError:
raise LookupError('LotteryBlower is empty!')
self._items.pop(position)
def loaded(self):
return bool(self._items)
def inspect(self):
return tuple(sorted(self._items))
知识点:
random.randrange ([start,] stop [,step]) 返回指定范围内的随机元素,start默认为0,step默认为1
虚拟子类
首先了解一下白鹅类型:
白鹅类型是指,只要clas是抽象基类,即cls的元类是abc.ABCMeta,就可以使用isinstance(obj, cls)
白鹅类型的一个基本特征:即便不继承,也有办法把一个类注册为抽象基类的虚拟子类。这样做时,我们保证注册的类忠诚地实现了抽象基类定义的接口,而Python会相信我们不做检查。如果我们没有做到,那么运行时异常会被我们捕获。
注册虚拟子类的方式是在抽象基类上调用register方法。
这样做以后,注册的类会变成抽象基类的虚拟子类,而且使用issubclass和isinstance等函数都能识别,但是注册的类不会从抽象基类中继承任何方法和属性。
虚拟子类不会继承注册的抽象基类,而且任何时候都不会检查它是否符合抽象基类的接口,即便是在实例化时也不会检查。为了避免运行时错误,虚拟子类要实现所需的全部方法。
issubclass(class, classinfo)函数用于比较 class是否是classinfo的子类。两个参数都是类对象。
isinstance(object, classinfo)函数用于比较 object是否是classinfo的子类。第一个参数的类的实例,第二个参数是类对象
声明虚拟子类的方式是在创建类时添加装饰器 @类名.register,也可以使用 类名.register(虚拟子类名)
示例,TomboList是list的真实子类和Tombola的虚拟子类
@Tombola.register # 注册为Tombola的虚拟子类
class TomboList(list):
def pick(self):
if self:
position = random.randrange(len(self))
return self.pop(position)
else:
raise LookupError('TomboList is empty!')
load = list.extend
def loaded(self):
return bool(self)
def inspect(self):
return tuple(sorted(self))
# Tombola.register(TomboList) #python3.3之前的版本使用这种写法
print(issubclass(TomboList, Tombola))
t = TomboList()
print(isinstance(t, Tombola))
打印:
True
True
知识点:
在Python3.3或之前的版本,不能使用@Tombola.register这种语法糖形式,只能Tombola.register(TomboList) 用这种普通函数方式调用。
load = list.extend 可以理解属性和方法可以混用啊,这样等于是load方法实现了
类的继承关系, 内省类继承关系
类的继承关系在一个特殊的类属性中指定__mro__,叫做方法解析顺序(Method Resolution Order)
这个属性的作用很简单,按照顺序列出类以及超类,Python会按照这个顺序搜索方法。
示例,查看TomboList类的__mro__属性,会看到只有真实的超类被列出来,也就是list和object,这也说明了虚拟子类TomboList没有在Tombola中继承任何方法。
print(TomboList.__mro__)
(<class '__main__.TomboList'>, <class 'list'>, <class 'object'>)
这两个类属性可以内省类的继承关系:
__subclasses__()
返回类的直接子类的列表,不含虚拟子类。
_abc_registry
只有抽象类有这个属性,值是一个WeakSet对象,即抽象类注册的虚拟子类的弱引用。
print(Tombola.__subclasses__())
print(list(Tombola._abc_registry))
[<class '__main__.BingoCage'>, <class '__main__.LotteryBlower'>]
[<class '__main__.TomboList'>]
子类检查的__subclassshook__
即便不注册为子类,抽象基类也能把一个类识别为虚拟子类。
下面是一个示例
class Str:
def __len__(self):
return 23
from collections import abc
print(issubclass(Str, abc.Sized))
print(isinstance(Str(), abc.Sized))
打印
True
True
上面可以看到经过issubclass和isinstance函数确认,Str是abc.Sized的子类,这是因为abc.Sized实现了一个特殊方法__subclassshook__
示例, Sized的源码
class Sized(metaclass=abc.ABCMeta):
__slots__ = ()
@abc.abstractmethod
def __len__(self):
return 0
@classmethod
def __subclassshook__(cls, C):
if cls is Sized:
if any('__len__' in B.__dict__ for B in C.__mro__): # 在C及其超类中,如果类的__dict__属性有名为__len__属性
return True # 返回True,证明了C 是Sized的子类
return NotImplemented # 否者返回NotImplemented,让子类检查。
__subclassshook__在白鹅类型中添加了一些鸭子类型的踪迹。
我们可以使用抽象基类定义正式接口,可以始终使用isinstance检查,也可以完全使用不相关的类,只要提供特定的方法即可(或者一些符合__subclassshook__方法的特性),只有提供__subclassshook__方法的抽象基类才能这样做。
建议:不要自己定义抽象基类
不要自己定义抽象基类,除非你要构建允许用户扩展的框架---
日常使用中,我们与抽象基类的联系应该是创建现有的抽象基类的子类,或者使用现有的抽象基类注册。我们还可能在isinstance检查中使用抽象基类。
需要自己从头编写新抽象基类的情况少之又少。
尽管抽象基类使得类型检查变得功更容易了,但是不应该在程序中过度使用它。Python的核心是一门动态语言,它带来了极大的灵活性。如果处处都强制 实行类型约束,那么代码会变得更加复杂。我们应该拥抱Python的灵活性。
Python是强类型的动态脚本语言。