Skip to content

Latest commit

 

History

History
404 lines (251 loc) · 16.4 KB

协议与接口与抽象基类.md

File metadata and controls

404 lines (251 loc) · 16.4 KB

协议和鸭子类型

在Python中创建功能完善的序列类型无需使用继承,只需实现符合序列协议的方法.不过,这里说的协议是什么呢?

在面向对象编程中,协议是非正式的接口,只在文档中定义(也可以在typehint中定义),在代码中不定义.例如Python的序列协议只需要__len____getitem__两个方法.任何类(如Spam),只要使用标准的签名和语义实现了这两个方法,就能用在任何期待序列的地方.Spam是不是哪个类的子类无关紧要,只要提供了所需的方法即可.

Python文化中的接口和协议

引入抽象基类之前,Python就已经非常成功了,即便现在也很少有代码使用抽象基类.

我们把协议定义为非正式的接口,是让Python这种动态类型语言实现多态的方式.

接口在动态类型语言中是怎么运作的呢?

首先,基本的事实是Python语言没有interface关键字,而且除了抽象基类每个类都有接口:

实现或继承公开属性,包括特殊方法,如__getitem____add__.

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

另一方面,不要觉得把公开数据属性放入对象的接口中不妥,因为如果需要,总能实现读值方法和设值方法,把数据属性变成特性,使用obj.attr句法的客户代码不会受到影响.

关于接口,这里有个实用的补充定义:

对象公开方法的子集,让对象在系统中扮演特定的角色.

Python文档中的"文件类对象"或"可迭代对象"就是这个意思,这种说法指的不是特定的类.接口是实现特定角色的方法集合,其他动态语言社区都借鉴了这个术语.协议与继承没有关系.一个类可能会实现多个接口,从而让实例扮演多个角色.

协议是接口,但不是正式的(只由文档和约定定义),因此协议不能像正式接口那样施加限制(后面会说明抽象基类对接口一致性的强制).一个类可能只实现部分接口,这是允许的.有时某些API只要求"文件类对象"返回字节序列的.read()方法.在特定的上下文中可能需要其他文件操作方法,也可能不需要.

对Python程序员来说,"X 类对象","X 协议"和"X 接口"都是一个意思.

抽象基类和白鹅类型

我们讲:

当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子.

鸭子类型忽略对象的真正类型,转而关注对象有没有实现所需的方法、签名和语义.对Python来说,这基本上是指避免使用isinstance检查对象的类型,这样做没有任何好处,甚至禁止最简单的继承方式.

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

class Artist:
    def draw(self): 
        pass
    
class Gunslinger:
    def draw(self):
        pass
        
class Lottery:
    def draw(self):
        pass

语言上的歧义造成了完全不应相关的两个类有着一样的接口,因此我们需要额外的外部知识来将鸭子类型提供的等价性维持在一定的层次上.

这种时候我们应该有这样的一种规定:

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

这一思想来自于Alex Martelli的一篇文章,他管这叫白鹅类型,在流畅的python一书中有引用.

继承抽象基类很简单,只需要实现所需的方法,这样也能明确表明开发者的意图.这一意图还能通过注册虚拟子类来实现.此外,使用isinstanceissubclass测试抽象基类更为人接受.过去,这两个函数用来测试鸭子类型,但用于抽象基类会更灵活.毕竟如果某个组件没有继承抽象基类,事后 还可以注册,让显式类型检查通过.

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

另一方面,如果必须强制执行API契约,通常可以使用isinstance检查抽象基类.这对采用插入式架构的系统来说特别有用.在框架之外,鸭子类型通常比类型检查更简单,也更灵活.

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

标准库中的抽象基类

从Python 2.6 开始,标准库提供了抽象基类.大多数抽象基类在collections.abc模块中定义,不过其他地方也有.例如,numbersio包中有一些抽象基类.但是,collections.abc中的抽象基类最常用.我们来看看这个模块中有哪些抽象基类.

collections.abc模块中的抽象基类

标准库中有两个名为abc的模块,这里说的是collections.abc.为了减少加载时间,Python3.4在collections包之外实现这个模块,因此要与collections分开导入.另一个abc模块就是abc这里定义的是abc.ABC类.每个抽象基类都依赖这个类,但是不用导入它,除非定义新抽象基类.

collections.abc中定义了如下容器抽象基类:

ABC 继承自 抽象方法 Mixin 方法
Container --- __contains__ ---
Hashable --- __hash__ ---
Iterable --- __iter__ ---
Iterator Iterable __next__ __iter__
Reversible Iterable __reversed__ ---
Generator Iterator send, throw close, __iter__, __next__
Sized --- __len__ ---
Callable --- __call__ ---
Collection Sized, Iterable, Container __contains__, __iter__,__len__ ---
Sequence Reversible, Collection __getitem__, __len__ __contains__, __iter__, __reversed__, index,count
MutableSequence Sequence __getitem__, __setitem__,__delitem__, __len__, insert Sequence实现的方法以及append, reverse, extend,pop, remove, __iadd__
ByteString Sequence __getitem__,__len__ Sequence实现的方法
Set Collection __contains__, __iter__, __len__ __le__, __lt__, __eq__, __ne__, __gt__, __ge__, __and__, __or__,__sub__, __xor__,isdisjoint
MutableSet Set __contains__, __iter__, __len__, add, discard Set实现的方法以及clear, pop, remove, __ior__, __iand__,__ixor__, __isub__
Mapping Collection __getitem__, __iter__, __len__ __contains__,keys, items, values, get, __eq__, __ne__
MutableMapping Mapping __getitem__, __setitem__, __delitem__, __iter__, __len__ Mapping实现的方法以及pop, popitem, clear,update, setdefault
MappingView Sized --- __len__
ItemsView MappingView, Set --- __contains__, __iter__
KeysView MappingView, Set --- __contains__,__iter__
ValuesView MappingView --- __contains__, __iter__
Awaitable --- __await__ ---
Coroutine Awaitable send,throw close
AsyncIterable --- __aiter__ ---
AsyncIterator AsyncIterable __anext__ __aiter__
AsyncGenerator AsyncIterator asend, athrow aclose, __aiter__, __anext__

除此之外,其中还包括了两个特殊的抽象基类:

  • Callable

  • Hashable

这两个抽象基类与集合没有太大的关系,只不过因为collections.abc是标准库中定义抽象基类的第一个模块,而它们又太重要了,因此才把它们放到其中 .这两个抽象基类的主要作用是为内置函数isinstance提供支持,以一种安全的方式判断对象能不能调用或散列.

numbers模块中的抽象基类

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

  • Number
  • Complex
  • Real
  • Rational
  • Integral

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

与之类似,如果一个值可能是浮点数类型,可以使用isinstance(x, numbers.Real)检查.这样代码就能接受boolintfloatfractions.Fraction,或者外部库(如NumPy,它做了相应的注册)提供的非复数类型.

decimal.Decimal没有注册为numbers.Real的虚拟子类,这有点奇怪.没注册的原因是,如果你的程序需要Decimal的精度,要防止与其他低精度数字 类型混淆,尤其是浮点数.

定义一个抽象基类

为了证明有必要定义抽象基类,我们要在框架中找到使用它的场景.想象一下这个场景:

你要在网站或移动应用中显示随机广告,但是在整个广告清单轮转一遍之前,不重复显示广告.假设我们在构建一个广告管理框架,名为ADAM.它的职责之一是,支持用户提供随机挑选的无重复类.

为了让ADAM的用户明确理解"随机挑选的无重复"组件是什么意思,我们将定义一个抽象基类.

受到'栈'和'队列'启发,我将使用现实世界中的物品命名这个抽象基类:

宾果机和彩票机是随机从有限的集合中挑选物品的机器,选出的物品没有重复,直到选完为止.

我们把这个抽象基类命名为Tombola,这是宾果机和打乱数字的滚动容器的意大利名.

Tombola抽象基类有四个方法,

其中两个是抽象方法:

  • load(...):把元素放入容器.
  • .pick():从容器中随机拿出一个元素,返回选中的元素.

另外两个是具体方法:

  • loaded():如果容器中至少有一个元素,返回True。
  • inspect():返回一个有序元组,由容器中的现有元素构成,不会修改容器的内容

定义抽象基类需要使用abc模块,继承abc.ABC就可以构建抽象基类,这样它就无法实例化,

装饰器@abc.abstractmethod则可以申明方法为抽象方法,而且定义体中通常只有文档字符串.其实,抽象方法可以有实现代码.即便实现了,子类也必须覆盖抽象方法,但是在子类中可以使用super()函数调用抽象方法,为它添加功能,而不是从头开始实现.

除了@abstractmethod之外,abc模块还定义了@abstractclassmethod@abstractstaticmethod@abstractproperty 三个装饰器.然而,后三个装饰器从Python 3.3起废弃了,因为装饰器可以在@abstractmethod上堆叠,那三个就显得多余了.例如声明抽象类方法的推荐方 式是:

class MyABC(abc.ABC):
    @classmethod
    @abc.abstractmethod
    def an_abstract_classmethod(cls, ...):
       pass

PS:typehint应当使用@typing.overload标注子类中的抽象方法实现为方法覆写.

import abc
class Tombola(abc.ABC):
    @abc.abstractmethod
    def load(self, iterable):
        """从可迭代对象中添加元素。"""
        pass
    @abc.abstractmethod
    def pick(self):
        """随机删除元素,然后将其返回。
        如果实例为空,这个方法应该抛出`LookupError`。
        """
        pass
    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))

使用__init_subclass__(cls)在基类中定义子类的初始化函数[3.6]

定制类的创建使用新协议进行了简化

Simpler customisation of class creation提供了一种可以在不使用元类的情况下自定义子类的方法.每当创建一个新的子类时,新的__init_subclass__类方法会被调用,可以将其理解为子类创建前的一个钩子:

class PluginBase:
    subclasses = []
    
    def __init__(self,name = "base"):
        self.name = name

    def __init_subclass__(cls, **kwargs):
        print("subclass")
        super().__init_subclass__(**kwargs)
        cls.subclasses.append(cls)

class Plugin1(PluginBase):
#     def __init__(self):
#         print("init")
    pass

class Plugin2(PluginBase):
#     def __init__(self):
#         print("init")
    pass
subclass
subclass
Plugin1()
<__main__.Plugin1 at 0x10dfeda20>
Plugin2()
<__main__.Plugin2 at 0x10df37ac8>
PluginBase.subclasses
[__main__.Plugin1, __main__.Plugin2]
Plugin1.subclasses
[__main__.Plugin1, __main__.Plugin2]

可以看到这种方式是有副作用的,父类和子类会一起受__init_subclass__的影响.因此虽然方便但并不优雅.

定义抽象基类的子类

定义好Tombola抽象基类之后,我们要开发两个具体子类,满足Tombola规定的接口.

import random

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()
import random

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:
            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))

白鹅类型的重要动态特性了:使用register 方法声明虚拟子类

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

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

注册虚拟子类的方式是在抽象基类上调用register方法.这么做之后,注册的类会变成抽象基类的虚拟子类,而且issubclassisinstance等函数都能识别,但是注册的类不会从抽象基类中继承任何方法或属性.

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

register方法通常作为普通的函数调用,不过也可以作为装饰器使用.我们使用装饰器句法实现了TomboList类,这是Tombola 的一个虚拟子类.

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))
t = TomboList([12,23,34])
isinstance(t,Tombola)
True