流畅的python笔记(十一)接口:从协议到抽象类型

目录

一、python文化中的接口与协议

二、python喜欢序列

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

四、Alex的水禽

五、定义抽象基类的子类(白鹅类型使用)

六、标准库中的抽象基类

collections.abc模块中的抽象基类

Iterable、Container、Sized

Sequence、Mapping、Set

MappingView

Callable、Hashable

Iterator

numbers模块(抽象基类的数字塔)

七、定义并使用一个抽象基类

抽象基类Tombola定义

抽象基类句法详解

定义Tombola抽象基类的子类

Tombola的虚拟子类

八、Tombola子类的测试方法

九、python使用register的方式

十、鹅的行为有可能像鸭子


一、python文化中的接口与协议

接口定义:每个类都有接口,类实现或继承的公开属性(方法或数据属性),包括特殊方法,都是接口。受保护的属性和私有属性不在接口中(即便在python并不存在真正受保护的和私有的属性,而只是采用命名约定实现的)。

接口补充定义:对象公开方法的自己,让对象在系统中扮演特定的角色。python文档中的文件类对象或可迭代对象就是指这样一些类,它们实现了文件类对象的方法集合或可迭代需要的方法集合。

协议是接口,但不是正式的,只由文档和约定定义。比如序列协议,是python最基础的协议之一,即便对象只实现了该协议最基础的一部分,解释器也会负责任地处理。

python有两种风格的接口,一种是协议:即只在文档和约定中定义;一种是抽象基类

鸭子类型:对象类型无关紧要,只要实现特定协议即可。

二、python喜欢序列

下图是定义为抽象基类的Sequence正式接口。

看上去序列类型需要实现__contains__,__iter__,__len__等特殊方法。一般来说,__contains__用来支持in运算符,__iter__用来支持可迭代,__len__用来求长度。__getitem__是用来访问元素。但是只实现序列协议中的__getitem__方法,不实现__len__和__contains__等方法,也足够支持访问元素、迭代和使用in运算符了。

如果要用for循环迭代对象,但是发现没有__iter__方法,python会调用__getitem__方法,传入从0开始的整数索引来尝试迭代对象(为了迭代对象,解释器会尝试调用两个不同的方法)。如果没有__contains__方法,则python会做全面检查,即迭代对象,看有没有指定的元素,因此能用in运算符。

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

运行时实现协议,体现协议的动态本性。

        本书中的纸牌类FrenchDeck有个缺陷:无法洗牌。python中标准库random中有个random.shuffle函数,可用于就地打乱序列,但是无法用于FrenchDeck类的对象,因为其不支持赋值,即FrenchDeck是不可变的序列,要想将其变为可变序列,还需要实现__setitem__方法。

        python是动态语言,可以在运行时修正这个问题。下面在控制台中修正。

  1. 定义一个函数用来绑定到__setitem__上,其参数是self(对象本身,这里是deck)、key(position)、value(card)。
  2. 把上边函数赋值给FrenchDeck的__setitem__属性。
  3. random.shuffle函数不关心参数类型,只要参数对象实现了部分可变序列协议即可,即使一开始没有也没关系,后来再提供也可。

从set_card函数定义也可以看出,python中不管是普通方法还是类中定义的方法,本质都是普通函数,把第一个参数命名为self只是一种约定而已。

        把set_card函数赋值给特殊方法__setitem__,从而把它依附到FrenchDeck类上,这种计数叫猴子补丁:在运行时修改类或模块,而不改动源码。

        上边例子中的协议都只是在文档中定义的,而没有直接使用抽象基类。

四、Alex的水禽

  • 鸭子类型:忽略对象的真正类型,转而关注对象有没有实现所需的方法、签名和语义。对python来说,基本上指避免使用isinstance检查对象类型(更别提type())。
  • 白鹅类型:只要cls是抽象基类,就可以使用isinstance(obj, cls)来判断对象obj是不是它的子类。

python中的抽象基类有一个实用优势:可以使用register类方法把某个类声明为一个抽象基类的虚拟子类,被注册的类必须满足抽象基类对方法名称和签名的要求,最重要的是满足底层语义契约。但是开发那个类时不用了解抽象基类,更不用继承抽象基类。这大大打破了严格的强耦合。

        有时让抽象基类识别子类甚至不用注册,因为抽象基类的本质就是几个特殊方法

如上例子,无需注册,抽象基类abc.Sized也能把Struggle识别为自己的子类,只要实现了特殊方法__len__即可(要使用正确的句法和语义实现,句法要求:没有参数。语义实现要求:返回一个非负整数)。

        一般情况下,如果要实现的类体现了numbers、collections.abc(python中抽象基类主要定义在numbers,collections.abc这两个模块中)或其它模块中抽象基类的概念,要么继承相应的抽象基类(必要时),要么把类注册到相应的抽象基类中。如果要检查对象是不是属于某一抽象基类,可以使用instance来判断。最后,不要自己定义抽象基类或元类

五、定义抽象基类的子类(白鹅类型使用)

下边例子中,我们把纸牌类FrenchDeck2明确声明为抽象基类collections.MutableSequence的子类。

  1. 为了支持洗牌,实现__setitem__方法。
  2. 继承抽象基类的类必须实现抽象基类中所有的抽象方法,因此要实现MutableSequence中的抽象方法__delitem__。
  3. insert方法也是MutableSequence类的抽象方法,必须实现。

需要注意的是,继承一个抽象基类必须实现其抽象方法,但是抽象基类中不只是有抽象方法,也有具体方法,具体方法不用重新实现。但是也可以自己以更高效的方式实现,比如__contains__方法会全面扫描序列,但是如果自定义的序列按顺序保存元素,那就可以重新实现__contains__方法,改用bisect函数做二分查找。

六、标准库中的抽象基类

大多数抽象基类在collections.abc模块中定义,不过numbers和io包中也有一些抽象基类。

collections.abc模块中的抽象基类

标准库中有两个名为abc的模块,collections.abc和abc。collections.abc中定义了16个抽象基类,abc模块中定义了abc.ABC类,每个抽象基类都依赖这个类,只有在定义新的抽象基类的时候才需要导入abc模块。

        collections.abc中抽象基类UML图如下(简要版,没有属性名称):

Iterable、Container、Sized

各个集合都应该继承这三个抽象基类,或者实现兼容的协议。Iterable通过__iter__方法支持迭代,Container通过__contains__方法支持in运算符,Sized通过__len__方法支持len()函数。

Sequence、Mapping、Set

这三个是不可变集合类型,但是各个都有可变的子类,即MutableSequence、MutableMapping、MutableSet。

MappingView

python3中,映射类型的方法 .items()、.keys、.values()返回的对象分别是ItemsVies、KeysView、ValuesView的实例。

Callable、Hashable

这两个抽象基类不用于定义子类,而是用于为内置函数isinstance提供支持,以一种安全的方式判断对象能不能调用或散列。用法是isinstance(my_obj, Hashable)。

Iterator

是Iterable的子类。

numbers模块(抽象基类的数字塔)

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

如果想检查一个数是不是整数,可以使用isinstance(x, numbers.Integral),这样代码可以兼容int、bool(int 的子类)、外部库使用numbers抽象基类注册的其它类型(虚拟子类)。注意decimal.Decimal没有注册为numbers.Real的虚拟子类。

        如果想检查浮点数类型,可以用 instance(x, numbers.Real)。这样可以兼容bool、int、float、fractions.Fraction、或者外部库提供的非复数类型(比如numpy中的类型,其做了相应的注册)。 

七、定义并使用一个抽象基类

一般不用自己定义抽象基类。

抽象基类Tombola定义

        定义抽象基类Tombola, 其有两个抽象方法,两个具体方法。

  • .load(...):把元素放入容器。
  • .pich():从容器中随机拿出一个元素,返回选中的元素。
  • .loaded():如果容器中至少有一个元素,返回True。

以下是抽象基类Tombola和三个具体类的UML图:

抽象基类Tombola定义如下:

        抽象类不需要__init__方法是必须的,因为没法构造对象啊。

import abc

class Tombola(abc.ABC): # 自定义抽象基类要继承abc.ABC

    @abc.abstractmethod # 抽象方法用@abstractmethod装饰器装饰,定义体中通常只有文档字符串
    def load(self, iterable):
        """从可迭代对象中添加元素"""

    @abc.abstractmethod
    def pick(self):
        """随机删除元素,然后将其返回,如果实例为空,抛出LookupError"""

    def loaded(self): # 抽象基类也可以包含具体方法
        """如果至少有一个元素,返回True,否则False"""
        return bool(self.inspect()) # 抽象基类中的具体方法只能使用抽象基类中的其他具体方法、抽象方法或特性,比如这里的inspect,就是里另一个具体方法

    def inspect(self):
        """返回一个有序元组,由当前元素构成"""
        # 为了得到所有当前元素,不断调用pick方法把Tombola清空,然后再用load方法把所有元素放回去
        items = []
        while True:
            try:
                items.append(self.pick())
            except LookupError:
                break
        self.load(items) 
        return tuple(sorted(items))

上边抽象方法中只有文档字符串,但是实际上抽象方法中也可以有具体的实现代码,但是即使这样,子类也必须重新实现抽象方法来覆盖抽象基类中的抽象方法,但是在子类的实现代码中可以使用super()来调用抽象基类的抽象方法,实现一定程度上代码复用。

        pick()方法中如果取元素失败要求抛出LookupError,LookupError在python的异常层次关系中与IndexError和KeyError有关。

  1. Tombola.inspect方法中要处理捕获的是LookupError异常。
  2. IndexError是LookupError的子类,当尝试从序列中获取索引超过最后位置的元素时抛出。
  3. 使用不存在的键从映射中获取元素时,抛出KeyError异常。

下边例子说明,用一个不符合要求的实现来子类化抽象接口Tombola是行不通的。

 

  1. 把Fake声明为Tombola的子类,但是Tombola的两个抽象方法Fake只实现了一个
  2. Fake类创建成功
  3. 实例化Fake类的对象的时候抛出了TypeError,因为python认为Fake是抽象类,因为它有一个抽象方法load

抽象基类句法详解

声明抽象基类最简单的方法有两种:

  1. 继承abc.ABC
  2. 继承其他抽象基类,且不要实现所有父类的抽象方法

但是abc.ABC是python3.4新增的类,在python3.0到python3.4之间的版本可以在class语句中用metaclass=abc.ABCMeta,如下:

在python2中,则是用__metaclass__类属性:

注意在声明抽象方法时候用的修饰器@abstractmethod在修饰器堆叠的时候只能放在最里层,即@abstractmethod和def语句之间不能有其他修饰器。如下例子:

定义Tombola抽象基类的子类

要定义具体的子类,至少要将抽象基类的所有抽象方法覆盖一下。

  1. BingoCage类扩展Tombola类。
  2. random.SystemRandom使用os.urandom()函数来实现random API。
  3. 委托load方法进行初始化。
  4. 覆盖抽象方法load,没有使用random.shuffle函数,而是用了SystemRandom实例的shuffle方法。
  5. 覆盖抽象方法load
  6. 因为BingoCage不是抽象基类,所以其中的具体方法的定义体中的方法不用限制在类内,可以随意添加,虽然这里还是用了类内的方法。

BingoCage类从Tombola直接继承了具体方法loaded和inspect,而没有去覆盖,也可以选择不直接继承,而是对这两个方法进行优化,覆盖父类中的方法,如下例子。

 

__init__中self._balls保存的是list(iterable),即iterable的副本,而没有直接self._balls = iterable,这样可以防止修改传入的参数iterable。

Tombola的虚拟子类

白鹅类型基本特性:即便不继承,也有办法把一个类注册为抽象基类的虚拟子类。这样做需要我们自己保证注册的类忠实地实现了抽象基类定义的接口,而python对这个不做检查(任何时候都不会检查注册的虚拟子列是否符合抽象基类的接口,即便在实例化时也不会检查)。如果我们没有完全实现所有的抽象接口,则常规的运行时异常会把我们捕获。

        注册方法是在抽象基类上调用register方法。这样做之后,注册的类会变成抽象基类的虚拟子类,且issubclass和isinstance等函数能够识别,但是注册的虚拟子类不会从抽象基类中继承任何方法或属性。register方法通常作为普通的函数调用,也可以作为装饰器使用,如下例子。、

 

  1.  用装饰器把TomboList注册为Tombola的虚拟子类
  2. TomboList继承list
  3. Tombolist从list中继承了__bool__方法,列表不为空时返回True
  4. pick调用继承自list的self.pop方法,传入一个随机的元素索引
  5. TomboList.load与list.extend一样
  6. loaded方法委托给bool函数
  7. 这是register作为函数的调用方法,在python3.3之前的版本,不能把.register当作类装饰器使用,必须用函数调用方法来注册虚拟子类

下边用issubclass和isinstance函数判断TomboList是不是Tombola的子类:

类的继承关系是在一个类属性中指定的,即__mro__(Method Resolution Order,方法解析顺序),这个属性的作用时按顺序列出类以及父类,python会按这个顺序来搜索实例使用到的方法。

我们可以看到TomboList.__mro__中没有Tombola,这是因为__mro__主要作用是用于按顺序搜索实例用到的方法,而虚拟子类没有从抽象子类中继承任何方法,因此这个列表中没有虚拟继承的抽象基类。

八、Tombola子类的测试方法

用两个类属性(方法)来内省类的继承关系。

  • __subclass__() ,这个方法返回类的直接子类列表,不含虚拟子类
  • _abc_registry,只有抽象基类有这个数据属性,其值是一个WeakSet对象,即抽象基类注册的所有虚拟子类的弱引用。

九、python使用register的方式

两种方式,一种是当作装饰器使用,一种是当作类函数调用。

十、鹅的行为有可能像鸭子

即使不注册,抽象基类也能把一个类识别为虚拟子类,如下例子:

这是因为abc.Sized实现了一个特殊的类方法,__subclasshook__,只有提供了这个方法的抽象基类才能这么做,python源码中貌似只有Sized这一个抽象基类实现了这个方法。自己定义抽象基类时,不建议这么做。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值