Skip to content

接口:从协议到抽象基类

此笔记记录于《流畅的python》,大部分为其中的摘要,少部分为笔者自己的理解;笔记为jupyter转的markdown,原始版jupyter笔记在这个仓库

python文化中的接口和协议

基本的事实是,Python语言没有interface关键字,而且除了抽象基类,每个类都有接口:类实现或继承的公开属性(方法或数据属性),包括特殊方法,如__getitem____add__

按照定义,受保护的属性和私有属性不在接口中:即便“受保护的”属性也只是采用命名约定实现的(单个前导下划线);私有属性可以轻松地访问(参见9.7节),原因也是如此。不要违背这些约定。

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

python喜欢序列

Python数据模型的哲学是尽量支持基本协议。对序列来说,即便是最简单的实现,Python也会力求做到最好。

python
# 这里只实现序列协议中的一部分,即__getitem__方法
class Foo:
    def __getitem__(self, pos):
        return range(0, 30, 10)[pos]
python
f = Foo()
print(f[1])  # 10
10
python
for i in f:
    print(i)
0
10
20
python
20 in f  # True
True
python
15 in f
False

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

综上,鉴于序列协议的重要性,如果没有__iter____contains__方法,Python会调用__getitem__方法,设法让迭代和in运算符可用。

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

python
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]

如果我们想进行洗牌操作,我们可以使用random.shuffle来打乱,但是由于这涉及到赋值操作,我们还需要设置__setitem__方法。

python


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

FrenchDeck.__setitem__ = set_card
python
from random import shuffle

deck = FrenchDeck()

shuffle(deck)
deck[:5]
[Card(rank='6', suit='hearts'),
 Card(rank='4', suit='clubs'),
 Card(rank='7', suit='clubs'),
 Card(rank='2', suit='spades'),
 Card(rank='9', suit='spades')]

这种技术叫猴子补丁:在运行时修改类或模块,而不改动源码。

Alex Martelli的水禽

  • 对Python来说,鸭子类型基本上是指避免使用isinstance检查对象的类型(更别提type(foo) is bar这种更糟的检查方式了,这样做没有任何好处,甚至禁止最简单的继承方式)
  • 总的来说,鸭子类型在很多情况下十分有用;但是在其他情况下,随着发展,通常有更好的方式。事情是这样的……
  • 近代,属和种(包括但不限于水禽所属的鸭科)基本上是根据表型系统学(phenetics)分类的。表征学关注的是形态和举止的相似性……主要是表型系统学特征。因此使用“鸭子类型”比喻是贴切的。

然而,平行进化往往会导致不相关的种产生相似的特征,形态和举止方面都是如此,但是生态位的相似性是偶然的,不同的种仍属不同的生态位。编程语言中也有这种“偶然的相似性”

python
class Artist:
    def draw(self): ...
class Gunslinger:
    def draw(self): ...
class Lottery:
    def draw(self): ...

显然,只因为x和y两个对象刚好都有一个名为draw的方法,而且调用时不用传入参数。显然,只因为x和y两个对象刚好都有一个名为draw的方法,而且调用时不用传入参数

生物(和其他学科)遇到的这个问题,迫切需要(从很多方面来说,是催生)表征学之外的分类方式解决,即支序系统学(cladistics)。这种分类学主要根据从共同祖先那里继承的特征分类,而不是单独进化的特征。(近些年,DNA测序变得便宜又快,这使支序学的实用地位变得更高。)

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

因此,参照水禽的分类学演化,我建议在鸭子类型的基础上增加白鹅类型(goose typing)。白鹅类型指,只要cls是抽象基类,即cls的元类是abc.ABCMeta,就可以使用isinstance(obj, cls)

Python的抽象基类还有一个重要的实用优势:可以使用register类方法在终端用户的代码中把某个类“声明”为一个抽象基类的“虚拟”子类(为此,被注册的类必须满足抽象基类对方法名称和签名的要求,最重要的是要满足底层语义契约;但是,开发那个类时不用了解抽象基类,更不用继承抽象基类)。这大大地打破了严格的强耦合,与面向对象编程人员掌握的知识有很大出入,因此使用继承时要小心。

python
class Struggle:
    def __len__(self):
        return 23
  
from collections import abc
isinstance(Struggle(), abc.Sized)  # True
True

可以看出,无需注册,abc.Sized也能把Struggle识别为自己的子类,只要实现了特殊方法__len__即可

最后我想说的是:如果实现的类体现了numbers、collections.abc或其他框架中抽象基类的概念,要么继承相应的抽象基类(必要时),要么把类注册到相应的抽象基类中。开始开发程序时,不要使用提供注册功能的库或框架,要自己动手注册;如果必须检查参数的类型(这是最常见的),例如检查是不是“序列”,那就这样做

python
isinstance(the_arg, collections.abc.Sequence)

此外,不要在生产代码中定义抽象基类(或元类)……如果你很想这样做,我打赌可能是因为你想“找茬”,刚拿到新工具的人都有大干一场的冲动。如果你能避开这些深奥的概念,你(以及未来的代码维护者)的生活将更愉快,因为代码会变得简洁明了。再会!

多态:然而,即便是抽象基类,也不能滥用isinstance检查,用得多了可能导致代码异味,即表明面向对象设计得不好。在一连串if/elif/elif中使用isinstance做检查,然后根据对象的类型执行不同的操作,通常是不好的做法;此时应该使用多态,即采用一定的方式定义类,让解释器把调用分派给正确的方法,而不使用if/elif/elif块硬编码分派逻辑

要抑制住创建抽象基类的冲动。滥用抽象基类会造成灾难性后果,表明语言太注重表面形式,这对以实用和务实著称的Python可不是好事。

定义抽象基类的子类

python
# 把FrenchDeck2声明为collections.MutableSequence的子类。
import collections
Card = collections.namedtuple('Card', ['rank', 'suit'])


class FrenchDeck2(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 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):  # 由于继承了MutableSequence,所以需要实现其对应的抽象方法
        del self._cards[position]

    def insert(self, position, value):  # 同上
        self._cards.insert(position, value)

导入时(加载并编译frenchdeck2.py模块时),Python不会检查抽象方法的实现,在运行时实例化FrenchDeck2类时才会真正检查。

标准库中的抽象基类

collectiond.abc

numbers包

numbers包定义的是“数字塔”(即各个抽象基类的层次结构是线性的),其中Number是位于最顶端的超类,随后是Complex子类,依次往下,最底端是Integral类:Number > Complex > Real > Rational > Integral。

因此,如果想检查一个数是不是整数,可以使用isinstance(x, numbers.Integral),这样代码就能接受int、bool(int的子类),或者外部库使用numbers抽象基类注册的其他类型。为了满足检查的需要,你或者你的API的用户始终可以把兼容的类型注册为numbers.Integral的虚拟子类。

定义并使用一个抽象基类

python
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(sorted(items))

白鹅类型的一个基本特性(也是值得用水禽来命名的原因):即便不继承,也有办法把一个类注册为抽象基类的虚拟子类。这样做时,我们保证注册的类忠实地实现了抽象基类定义的接口,而Python会相信我们,从而不做检查。如果我们说谎了,那么常规的运行时异常会把我们捕获。

虚拟子类不会继承注册的抽象基类,而且任何时候都不会检查它是否符合抽象基类的接口,即便在实例化时也不会检查。为了避免运行时错误,虚拟子类要实现所需的全部方法。

如下,omboList是Tombola的虚拟子类

python
from random import randrange


@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(sorted(self))
# Tombola.register(TomboList)  # ➐

Tombola子类的测试方法

  • __subclasses__(): 返回指定类的所有直接子类,不包括虚拟子类
  • _abc_registry: 一个字典,包含已经注册的虚拟子类

鹅的行为可能像鸭子

python
class Struggle:
    def __len__(self): return 23
    
from collections import abc
isinstance(Struggle(), abc.Sized)  # True
True
python
issubclass(Struggle, abc.Sized)  # True
True

经issubclass函数确认(isinstance函数也会得出相同的结论),Struggle是abc.Sized的子类,这是因为abc.Sized实现了一个特殊的类方法,名为__subclasshook__,可以用来动态识别子类。

python
# 其中的源码如下
class Sized(metaclass=ABCMeta):
    __slots__ = ()

    @abstractmethod
    def __len__(self):
        return 0

    @classmethodm
    def __subclasshook__(cls, C): # 动态识别子列
        if cls is Sized:
            if any("__len__" in B.__dict__ for B in C.__mro__):  # ➊
                return True  # ➋
        return NotImplemented  # ➌

在自己定义的抽象基类中要不要实现__subclasshook__方法呢?

在你我自己编写的抽象基类中实现__subclasshook__方法,可靠性很低。我可不相信随便一个实现或继承了load、pick、inspect和loaded的类(如Spam)的行为一定像Tombola。程序员最好让Spam继承Tombola,至少也要注册(Tombola.register(Spam)),从而确保这一点。当然,自己实现的__subclasshook__方法还可以检查方法签名和其他特性,但我觉得不值得这么做。