资讯专栏INFORMATION COLUMN

Python学习之路30-接口:从协议到抽象基类

LucasTwilight / 3425人阅读

摘要:本篇内容将从鸭子类型的动态协议,逐渐过渡到使接口更明确能验证实现是否符合规定的抽象基类。抽象基类介绍完动态实现接口后,现在开始讨论抽象基类,它属于静态显示地实现接口。标准库中的抽象基类从开始,标准库提供了抽象基类。

《流畅的Python》笔记。

本篇是“面向对象惯用方法”的第四篇,主要讨论接口。本篇内容将从鸭子类型的动态协议,逐渐过渡到使接口更明确、能验证实现是否符合规定的抽象基类(Abstract Base Class, ABC)。

1. 前言

本篇讨论Python中接口的实现问题,主要内容如下:

补充用鸭子协议实现部分接口的一种重要方法:猴子补丁;

说明抽象基类的常见用途,即,实现接口时作为超类使用;

说明抽象基类如何检查具体子类是否符合接口定义,以及如何使用注册机制声明一个类实现了某个接口;

说明如何不通过子类化或注册,也能让抽象基类自动“识别”任何符合接口的类。

补充在正文之前

在Python中,“X类对象”,“X协议”和“X接口”都是一个意思。并且,除了抽象基类,类实现或继承的公开属性(方法或数据属性),包括特殊方法,都可以看做接口。

关于接口,还有一个很实用的补充定义:对象公开方法的子集,让对象在系统中扮演特定的角色。

2. 猴子补丁

猴子补丁并不是Python特有,它指动态语言中,不用修改源代码,在运行时就能对代码的功能进行动态的追加或变更。下面的代码展示了猴子补丁的用法:

# 代码2.1
# 在文件中定义
class MyList:
    def __init__(self, iterable):
        self._data = list(iterable)

    def __len__(self):
        return len(self._data)

    def __getitem__(self, index):
        return self._data[index]

# 下面的代码在控制台运行
>>> from random import shuffle
>>> from my_list import MyList
>>> mylist = MyList(range(10))
>>> def set_item(temp, i, item):
...     temp._data[i] = item
...    
>>> MyList.__setitem__ = set_item
>>> shuffle(mylist)
>>> deck[:]
[6, 3, 0, 1, 5, 4, 2, 7, 9, 8]

解释

Python中,交互式控制台中也支持猴子补丁;

要使用random.shuffle函数,对象必须实现__setitem__方法,上述代码在运行时动态添加所需方法;

猴子补丁很强大,但打补丁的代码与要打补丁的程序耦合十分紧密,而且往往要处理隐藏的部分(比如“受保护的”属性)和没有文档的部分。

上述代码中set_item函数的第一个参数并不是self,这是想说明,每个Python方法说到底都是普通函数,把第一个参数命名为self只是一种约定(但别随意打破这种约定)。

这里之所以讲猴子补丁,主要是为了说明协议可以是动态的:即使对象最初没有实现某个协议,当需要时,我们也能为它动态添加。

3. 抽象基类

介绍完动态实现接口后,现在开始讨论抽象基类,它属于静态显示地实现接口。

3.1 基本概要说明

有时候我们需要明确区分“抽象类”(并不是指“抽象基类”)与“接口”:以自然界为例,“抽象类”一般用于同一物种同一行为,而“接口”则用于不同物种同一行为。当然,这两个概念有交叉的部分,某些行为既可以归到“接口“,也可以归到”抽象类“,而最后归到谁就见仁见智了。但这两个概念又有很大的相似之处,它们的实质都是:让某些对象拥有同名的方法或属性,但具体实现不一定相同

Java更注重这两者的特性,而Python、C++则更注重这两者的共性。也因此,Java不支持多重继承(当然,也是为了降低复杂性),用明确的接口类interface来区分与abstract class;而在Python和C++中,则用抽象基类充当接口。所以,在Python中,直接继承自抽象基类,更多表明的是”要实现某种接口或协议“,而非”要新建某个具体类的子类“。

如果要测试是否继承自抽象基类,推荐使用isinstanceissubclass方法,而不是is运算。但也不要滥用这类方法,因为这种代码用多了说明面向对象设计得不好。

说道isinstance,还有个与之相关的概念,相当于“鸭子类型”的强化版:

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

小插曲:这是书中给出的标准定义,笔者读到这的时候一脸懵逼。“白鹅类型”是个名词,但这定义却是对一个过程的描述,所以“白鹅类型”到底是个啥(这到底是翻译的锅还是作者的锅)?后来谷歌了一下,再自己反复推敲,得出如下总结:鸭子类型是指某个实例实现了某个方法,就可以说它属于某个类型,不一定要继承;而白鹅类型则是指能被判定成某抽象基类的子类的实例,即,能使isinstance(obj, cls)返回Trueobj就是白鹅类型,其中cls是抽象基类。注意,这些子类并不一定是通过继承而来,也可能是通过注册而来,还可能是通过实现某些方法而来。

特别提醒:对于抽象基类(还有元类)的使用,并不建议在生产代码中自行定义新的抽象基类和元类。定义抽象基类和元类的工作一般由比较资深的Python程序员来做,适用于写框架的程序员。而即便是资深Python程序员也不常自己定义抽象基类和元类。

3.2 标准库中的抽象基类

从Python2.6开始,标准库提供了抽象基类。大多数抽象基类在collections.abc模块中定义,numbersio中也有一些。

以下是collections.abc中16个抽象基类的UML图(关于多重继承的内容将在以后的文章中讲解):

有几个抽象基类值得注意:

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

SequenceMappingSet:这三个是主要的不可变集合类型,而且各自都有可变的子类,即MutableSequenceMutableMappingMutableSet

CallableHashable:从图上可以看出,这两个抽象基类在标准库中没有子类。

numbers包中的抽象基类的继承关系则很简单,都是线性的(“数字塔”)。下面5个类从左到右依次派生:

NumberComplexRealRationalIntegral

下面我们将自行定义一个抽象基类并继承出它的子类。但这并不是鼓励各位在生产代码中自定义抽象基类!

3.3 自定义抽象基类

我们将模拟一个随机抽奖机,它的抽象基类是Tombola,它的4个方法如下:

.load(...):抽象方法,把元素放入容器;

.pick():抽象方法,从容器中随机返回一个元素,并从容器中删除该元素;

.loaded():当容器不为空是返回True

.inspect():返回一个有序元组,由容器中的现有元素构成,不修改容器的内容(容器内部元素顺序不保留)。

它和它的三个子类的UML图如下:

以下是Tombola的定义:

# 代码3.1
import abc

class Tombola(abc.ABC):
    @abc.abstractmethod
    def load(self, iterable):
        """从可迭代对象中添加元素"""

    @abc.abstractmethod
    def pick(self):
        """随机删除元素,然后将其返回。
        如果实例为空,这个方法应该抛出LookupError,
        这个异常是IndexError和KeyError的基类"""

    def loaded(self):   # 比较耗时,子类可重写
        """当容器不为空时返回True"""
        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并不会检查抽象方法的实现,在运行时才会真正检测;

如果子类并没有实现抽象基类中所有的抽象方法,那么这个子类依然是抽象基类;

抽象方法中可以有实现代码。即便实现了,子类也必须覆盖抽象方法,但可以使用super()函数调用抽象方法,为它添加功能,而不是从头开始写;

抽象基类中的具体方法只能依赖抽象基类定义的接口

标准库中有两个名为abc的模块,一个是前面说的collections.abc,另一个就是这里的abc模块。只有在新定义抽象基类的时候才用得到abc.ABC,每个抽象基类都依赖这个类。

abc模块中本来还有@abstractclassmethod@abstractstaticmethod@abstractproperty三个装饰器,但这三个从Python3.3起被废除了,因为这三个的功能都能在@abstractmethod上堆叠其他装饰器得到,比如实现@abstractclassmethod的功能:

# 代码3.2
class MyABC(abc.ABC):
    @classmethod
    @abc.abstractmethod
    def an_abstract_classmethod(cls, ...): pass
3.4 定义子类

以下是它的两个子类的实现代码:

# # 代码3.3
class BingoCage(Tombola):  # loaded()和inspect()延用抽象基类的实现
    def __init__(self, items):
        self._randomizer = random.SystemRandom()  # 它会调用os.urandom()
        self._items = []
        self.load(items)   # 委托给load()方法实现初始加载

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

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:  # 为了兼容Tombola,并不是抛出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))
3.5 虚拟子类

上面两个子类都是直接继承自Tombola,而白鹅类型有一个基本特性:即便不用继承,也能将一个类注册为抽象基类的虚拟子类。下面是TomboList的实现:

# 代码3.4
@Tombola.register  # 把TomboList注册为Tombola的虚拟子类
class TomboList(list):  # 它同时还是list的真实子类,而list其实是MutableSequence的虚拟子类
    def pick(self):
        if self:
            position = random.randrange(len(self))
            return self.pop(position)
        else:
            raise LookupError("pick from empty LotteryBlower")

    load = list.extend  # 当我看到居然这么实现方法时,感觉自己好肤浅......

    def loaded(self):
        return bool(self)

    def inspect(self):
        return tuple(sorted(self))

# Tombola.register(TomboList) 这是register的函数调用版本

下面是这个子类的简单使用:

# 代码3.5
>>> issubclass(TomboList, Tombola)
True   # TomboList是Tombola的子类
>>> t = TomboList(range(100))
>>> isinstance(t, Tombola)
True   # TomboList的实例也是Tombola类型
>>> TomboList.__mro__
(, , )
>>> TomboList.__subclasses__()
[, ]

解释及补充

虚拟子类不会继承注册的抽象基类,而且任何时候都不会检查它是否符合抽象基类的接口,即便在实例化时也不会检查(如果你的虚拟子类没有实现抽象方法,在实例化时不会报错,但如果是继承而来的话则会报错),所以为了避免运行时错误,虚拟子类应该实现抽象基类的全部方法;

类的继承关系存储在一个特殊的类属性__mro__中,即方法解析顺序(Method Resolution Order)。它按顺序列出类及其超类,Python则会按照这个顺序搜索方法。从上述结果可以看出,这个属性只存储了“真实的”超类。

__subclasses__方法返回类的直接子类列表,不含虚拟子类;

虽然现在register可以当做装饰器用,但更常用的做法还是把它当函数使用。

3.6 另一种虚拟子类

鹅的行为有可能像鸭子。先看如下代码:

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

这里既没有继承,也没有注册,但Struggle依然被issubclass判断为abc.Sized的子类。之所以会这样,是因为abc.Sized实现了一个特殊的类方法__subclasshook__

# # 代码3.7,abc.Sized的实现在 _collections_abc.py 中
class Sized(metaclass=ABCMeta):

    __slots__ = ()

    @abstractmethod
    def __len__(self):
        return 0

    @classmethod
    def __subclasshook__(cls, C):
        if cls is Sized:
            # 源代码中是 return _check_methods(C, "__len__"),这里修改了一下
            if any("__len__" in B.__dict__ for B in C.__mro__):
                return True
        return NotImplemented

这像不像鸭子类型?只要实现了__len__方法,这个类就是abc.Sized的子类。

在自定义的抽象基类中并不一定要实现__subclasshook__方法,因为即使在Python源码中,目前也只见到Sized这一个抽象基类实现了__subclasshook__方法,而且Sized只有一个特殊方法。在决定自行实现__subclasshook__方法之前,请想清楚你一定需要这个方法吗?你的能力能够保证这个方法的可靠性吗?

4. 总结

本篇讨论的话题只有一个,即“接口”。首先我们讨论了鸭子类型的高度动态性,它实现的是动态协议,也是非正式接口;随后我们借助“白鹅类型”,使用抽象基类明确地、显示地声明接口,然后通过子类或注册来实现这些接口。期间,我们自定义了一个抽象基类,并通过继承实现了它的两个子类,还通过注册实现了它的一个虚拟子类。

最后,还是那句话:不要轻易自定义抽象基类,除非你想构件允许用户扩展的框架。日常使用中,我们与抽象基类的联系应该是创建现有抽象基类的子类,或者使用现有的抽象基类注册。自己从头编写新抽象基类的情况非常少。

迎大家关注我的微信公众号"代码港" & 个人网站 www.vpointer.net ~

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/41920.html

相关文章

  • Python学习之路31-继承的利弊

    摘要:使用抽象基类显示表示接口如果类的作用是定义接口,应该将其明确定义为抽象基类。此外,抽象基类可以作为其他类的唯一基类,混入类则决不能作为唯一的基类,除非这个混入类继承了另一个更具体的混入这种做法非常少见。 《流畅的Python》笔记本篇是面向对象惯用方法的第五篇,我们将继续讨论继承,重点说明两个方面:继承内置类型时的问题以及多重继承。概念比较多,较为枯燥。 1. 继承内置类型 内置类型...

    tinylcy 评论0 收藏0
  • Python基础之接口——协议抽象基类

    摘要:本文重点协议是中非正式的接口了解抽象基类的基本概念以及标准库中的抽象基类掌握抽象基类的使用方法。三抽象基类的使用通过继承声明抽象基类声明抽象基类最简单的方式是继承或其他抽象基类注意在之间,继承抽象基类的语法是。 导语:本文章记录了本人在学习Python基础之面向对象篇的重点知识及个人心得,打算入门Python的朋友们可以来一起学习并交流。 本文重点: 1、协议是Python中非正式的接...

    TwIStOy 评论0 收藏0
  • 流畅的python读书笔记-第11章-接口协议抽象基类

    摘要:自己定义的抽象基类要继承。抽象基类可以包含具体方法。这里想表达的观点是我们可以偷懒,直接从抽象基类中继承不是那么理想的具体方法。 抽象基类 抽象基类的常见用途: 实现接口时作为超类使用。 然后,说明抽象基类如何检查具体子类是否符合接口定义,以及如何使用注册机制声明一个类实现了某个接口,而不进行子类化操作。 如何让抽象基类自动识别任何符合接口的类——不进行子类化或注册。 接口在动态类...

    Kyxy 评论0 收藏0
  • Python学习之路25-使用一等函数实现设计模式

    摘要:本篇主要讲述中使用函数来实现策略模式和命令模式,最后总结出这种做法背后的思想。 《流畅的Python》笔记。本篇主要讲述Python中使用函数来实现策略模式和命令模式,最后总结出这种做法背后的思想。 1. 重构策略模式 策略模式如果用面向对象的思想来简单解释的话,其实就是多态。父类指向子类,根据子类对同一方法的不同重写,得到不同结果。 1.1 经典的策略模式 下图是经典的策略模式的U...

    econi 评论0 收藏0
  • Python学习之路29-序列的修改、散列和切片

    摘要:具体方法和上一篇一样,也是用各个分量的哈希值进行异或运算,由于的分量可能很多,这里我们使用函数来归约异或值。每个分量被映射成了它们的哈希值,这些哈希值再归约成一个值这里的传入了第三个参数,并且建议最好传入第三个参数。 《流畅的Python》笔记。本篇是面向对象惯用方法的第三篇。本篇将以上一篇中的Vector2d为基础,定义多维向量Vector。 1. 前言 自定义Vector类的行为...

    马忠志 评论0 收藏0

发表评论

0条评论

最新活动
阅读需要支付1元查看
<