Python:从协议到抽象基类

本章话题是接口:鸭子类型代表特征动态协议; 使接口更明确、能验证实现是否副了规定的抽象基类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')]

知识点:

  1. 补丁函数要代替的是__setitem__这里没有使用self/key/value三个参数,因为Python方法也是普通函数,第一个参数命名为self只是一种约定。

  1. 关键点是,set_card函数需要知道deck对象有一个名为_cards的属性,而且_cards是可变序列。

  1. 打补丁是在类对象,不是实例对象

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

上面的示例还强调了协议是动态的: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))

知识点:

  1. 自己定义的抽象基类要继承abc.ABC

  1. 抽象方法使用@abstractmethod装饰器标记,而且定义体中通常只有字符串文档

  1. 抽象基类也可以包含具体的方法,但是具体方法里面的实现,只能使用其他的具体方法、抽象方法或特征。

其实抽象方法里面也可以有实现代码。即便实现了,子类也必须覆盖抽象方法,但是子类可以使用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()

知识点:

  1. random.SystemRandom() 使用的是os.urandom()函数实现的api,这个比random提供了更适合加密的伪随机数,用法和random相同

  1. random.SystemRandom().shuffle和random.shuffle一样,都是就地打乱可变序列的顺序。

  1. 以上子类都继承了抽象类的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))

知识点:

  1. 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

知识点:

  1. 在Python3.3或之前的版本,不能使用@Tombola.register这种语法糖形式,只能Tombola.register(TomboList) 用这种普通函数方式调用。

  1. 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是强类型的动态脚本语言。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值