Fluent Python - Part11 接口:从协议到抽象基类

抽象类表示接口

--- Bjarne Stroustrup (C++ 之父)

本章讨论的话题是接口:从鸭子类型的代表特征动态协议, 到使接口更明确,能验证实现是否符合规定的抽象基类(Abstract Base Class, ABC)

我们将实现一个新抽象基类,看看它的运作方式。但是,作者不建议你自己编写抽象基类,因为很容易过度设计。

Python 文化中的接口和协议

关于接口,这里有个实用的补充定义:对象公开方法的子集,让对象在系统中扮演特定的角色。Python 文档中的“文件类对象”或“可迭代对象”就是这个意思,这种说法指的不是特定的类。接口是实现特定角色的方法集合,这种理解正是 Smalltalk 程序员所说的协议,其他动态语言社区都借鉴了这个术语。协议与继承没有关系。一个类可能会实现多个接口,从而让实例扮演多个角色。

协议是接口,但不是正式的(只由文档和约定定义),因此协议不能像正式接口那样施加限制(本章后面会说明抽象基类对接口一致性的强制)。一个类可能只实现部分接口,这是允许的。

  • tips:Python3 中 memoryview 的文档(https://docs.python.org/3/library/stdtypes.html#typememoryview) 说,它能处理“支持缓冲协议的对象”,不过缓冲协议的文档是针对C API的。bytearray 的构造方法(https://docs.python.org/3/library/functions.html#bytearray) 接受“一个符合缓冲接口的对象”。如今,文档正在改变用词,使用“字节序列类对象”这样更加友好的表述。我指出这一点是为了强调,对Python程序员来说,“X类对象”“X协议”和“X接口”都是一个意思。

序列协议是Python最基础的协议之一。即便对象只实现了那个协议最基本的一部分,解释器也会负责任地处理。

Python 喜欢序列

Sequence的UML类图

Sequence 抽象基类和 collections.abc 中相关抽象类的 UML 类图,箭头由子类指向超类,以斜体显示的是抽象方法

现在,我们来看一个例子

class Foo:
    def __getitem__(self, pos):
        return range(0, 30, 10)[pos]

f = Foo()
print(f[1])

for i in f:
    print(i)

print(20 in f)

print(15 in f)

"""
output:
10
0
10
20
True
False
"""

虽然没有 __iter__ 方法,但是 Foo 实例是可迭代的对象,因为发现有 __getitem__ 方法时,Python 会调用它,传入从0开始的整数索引,尝试迭代对象(这是一种后备机制)。 尽管没有实现 __contains__ 方法,但是 Python 足够智能,能迭代 Foo 实例,因此也能使用 in 运算符:Python 会做权珉阿检查,看看有没有指定的元素。

综上,鉴于序列协议的重要性,如果没有 __iter____contains__ 方法,Python 会调用 __getitem__ 方法,设法让迭代和 in 运算符可用。Python 中的迭代是鸭子类型的一种极端形式:为了迭代对象,解释器会尝试调用两个不同的方法。

下面着重强调协议的动态本性。

使用猴子补丁在运行时实现协议

之前的 FrenchDeck 类有个重大缺陷:无法洗牌。因为 FrechDeck 实例的行为像序列,那么它就不需要 shuffle 方法,因为已经有 random.shuffle 函数可用,文档中说它的作用就是“就地打乱序列”

from random import shuffle
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]

deck = FrenchDeck()
shuffle(deck)
"""
output:
Traceback (most recent call last):
  File "b.py", line 15, in <module>
    shuffle(deck)
  File "/Library/Developer/CommandLineTools/Library/Frameworks/Python3.framework/Versions/3.8/lib/python3.8/random.py", line 307, in shuffle
    x[i], x[j] = x[j], x[i]
TypeError: 'FrenchDeck' object does not support item assignment
"""

因为 shuffle 函数要调换集合中元素的位置,而 FrechDeck 只实现了不可变的序列协议。可变的序列还必须提供 __setitem__ 方法。

Python 是动态语言,因此我们可以在运行时修正这个问题。

from random import shuffle
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]

def set_card(deck, position, card):
    deck._cards[position] = card

FrenchDeck.__setitem__ = set_card
deck = FrenchDeck()
shuffle(deck)
print(deck[:5])
"""
output:
[Card(rank='K', suit='spades'), Card(rank='Q', suit='spades'), Card(rank='3', suit='hearts'), Card(rank='3', suit='spades'), Card(rank='8', suit='diamonds')]
"""

特殊方法 __setitem__ 的签名在Python 语言参考手册中定义(https://docs.python.org/3/reference/datamodel.html?highlight=emulating%20container)。语言参考中使用的参数是 self, keyvalue, 而这里使用的是 deck, position, card。这么做是为了告诉你,每个Python 方法说到底都是普通函数,把第一个参数命名为 self 只是一种约定。

这里的关键是,set_card 函数要知道 deck 对象有一个名为 _cards 的属性,而且 _cards 的值必须是可变序列。然后,我们把 set_card 函数赋值给特殊方法 __setitem__, 从而把它依附到 FrenchDeck 类上。这种技术叫猴子补丁:在运行时修改类或模块,而不改动源码。猴子补丁很强大,但是打补丁的代码与要打补丁的程序耦合十分紧密,而且往往要处理隐藏和没有文档的部分。

除了举例说明猴子补丁之外,该示例还强调了协议是动态的:random.shuffle 函数不关心参数的类型,只要那个对象实现了部分可变序列协议即可。即便对象一开始没有所需的方法也没关系,后来再提供也行。

接下来我们将介绍抽象基类。

Alex Martelli 的水禽

现在有一个问题:我们有了鸭子类型之后,为什么还需要抽象基类呢?

鸭子类型在很多情况下十分有用,但是在其他情况下,随着发展,通常有更好的方式。为此,Alex 举了一个例子。

近代,属和种(包括但不限于水禽所属的鸭科)基本上是根据表型系统学(phenetics) 分类的。也就是关注的是形态和举止的相似性,因此,使用“鸭子类型”比喻是贴切的。

然而,平行进化往往build导致不相关的种产生相似的特征,形态和举止方面都是如此,但是生态位的相似性是偶然的,不同种仍属于不同的生态位。编程语言中也有这种“偶然的相似性”,比如下述经典的面向对象编程示例:

class Artist:
    def draw(self): ...

class Gunslinger:
    def draw(self): ...

class Lottery:
    def draw(self): ...

显然,只因为 x 和 y 两个对象刚好都有一个名为 draw 的方法,而且调用时不用传入参数,即 x.draw()y.draw(),远远不能确保二者可以相互调用,或者具有相同的抽象。也就是说,从这样的调用中不能推导出语义相似性。相反,我们需要一位渊博的程序员主动把这种等价维持在一定层次上。

生物(和其他学科) 遇到的这个问题,迫切需要(从很多方面说)表征学之外的分类方式解决,即支序系统学(cladistics)。这种分类学主要根据从共同祖先那里继承的特征分类,而不是单独进化的特征。

知道这些有什么用呢? 视情况而定!比如,逮到一只水禽后,决定如何烹饪才最美味时,显著的特征(不是全部,例如一身羽毛并不重要) 主要是口感和风味(过时的表征学), 这比支序学重要得多。但在其他方面,如对不同病原体的抗性(圈养水禽还是放养), DNA接近性的作用就大多了…

因此参照水禽的分类学演化,我建议在鸭子类型的基础上增加白鹅类型(goose typing)

白鹅类型指,只要 cls 是抽象基类,即 cls 的元类是 abc.ABCMeta ,就可以使用 isinstance(obj, cls)

鹅的行为有可能像鸭子

Alex 在他写的“水禽和抽象基类”一文中指出,即便不注册,抽象基类也能把一个类识别为虚拟子类。

class Struggle:
    def __len__(self): return 23

from collections import abc
print(isinstance(Struggle(), abc.Sized))

print(issubclass(Struggle, abc.Sized))
"""
output:
True
True
"""

这是因为 abc.Sized 实现了一个特殊的类方法,名为 __subclasshook__

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 

__subclasshook__ 在白鹅类型中添加了一些鸭子类型的踪迹。我们可以使用抽象基类定义正式接口,可以始终使用 isinstance 检查,也可以完全使用不相关的类,只要实现特定的方法即可(或者做些事情让 __subclasshook__ 信服)。当然,只有提供 __subclasshook__ 方法的抽象基类才能这么做。

接下来我们将介绍“继承的优缺点”

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值