Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

PEP 246 – 对象适配

作者:
Alex Martelli <aleaxit at gmail.com>, Clark C. Evans <cce at clarkevans.com>
状态:
已拒绝
类型:
标准跟踪
创建日期:
2001年3月21日
Python 版本:
2.5
发布历史:
2001年3月29日,2005年1月10日

目录

拒绝通知

我拒绝这个PEP。更好的事情即将发生;现在说具体是什么还为时过早,但它不会与本PEP中的提案过于相似,所以最好重新开始一个PEP。GvR。

摘要

本提案提出了一种可扩展的协作机制,用于将传入对象适配到期望支持特定协议(例如特定类型、类或接口)的对象的上下文中。

本提案提供了一个内置的“adapt”函数,对于任何对象X和任何协议Y,它都可以用于向Python环境请求一个符合Y的X版本。在幕后,该机制会询问对象X:“你现在是,或者你知道如何包装自己来提供一个支持协议Y的对象吗?”如果此请求失败,该函数会询问协议Y:“对象X支持你吗,或者你知道如何包装它以获得这样一个支持者吗?”这种二元性很重要,因为协议可以在对象之后开发,反之亦然,而本PEP允许这两种情况都能以非侵入性方式支持预先存在的组件。

最后,如果对象和协议都不知道彼此,该机制可以检查适配器工厂注册表,在该注册表中,可以将能够将某些对象适配到某些协议的可调用对象动态注册。提案的这一部分是可选的:可以通过确保某些类型的协议和/或对象可以接受适配器工厂的动态注册来达到相同的效果,例如通过合适的自定义元类。然而,这个可选部分允许适配变得更加灵活和强大,并且对协议或其他对象都没有侵入性,从而使适配获得了与Python标准库的“copy_reg”模块为序列化和持久化提供的大致相同的优势。

本提案并未具体限制协议是**什么**,“符合协议”到底**意味着**什么,或者包装器应该精确地做什么。这些遗漏旨在使本提案与现有协议类别兼容,例如现有的类型和类系统,以及已为Python提出或实现的许多“接口”概念,例如 PEP 245 中的接口,Zope3 [2] 中的接口,或2004年末和2005年初在BDFL的Artima博客中讨论的接口 [3]。然而,其中也包含了一些对这些主题的思考,意在启发而非规范。

动机

目前在 Python 中没有标准化的机制来检查对象是否支持特定协议。通常,某些方法的存在,特别是特殊方法如 __getitem__,被用作支持特定协议的指示器。这种技术对于 BDFL(终身仁慈独裁者)认可的少数特定协议运行良好。基于检查 'isinstance' 的替代技术(内置类 "basestring" 专门用于让你使用 'isinstance' 来检查对象“是否是[内置]字符串”)也是如此。这两种方法都不能轻易且普遍地扩展到标准 Python 核心之外,由应用程序和第三方框架定义的其他协议。

比检查对象是否已经支持给定协议更重要的是,如果支持尚不存在,则为对象获取合适的适配器(包装器或代理)的任务。例如,字符串不支持文件协议,但您可以将其包装到 StringIO 实例中,以获得一个确实支持该协议并从其包装的字符串获取数据的对象;这样,您可以将字符串(适当包装后)传递给需要可读作文件的对象作为其参数的子系统。不幸的是,目前没有通用、标准化的方法来自动化这种极其重要的“通过包装进行适配”操作。

通常,如今,当您将对象传递给期望特定协议的上下文时,要么对象知道上下文并提供自己的包装器,要么上下文知道对象并对其进行适当的包装。这些方法的困难在于,此类适配是一次性的,并非集中在用户代码的单个位置,也未以通用技术执行等。这种缺乏标准化增加了代码重复,相同的适配器出现在多个位置,或者鼓励重写类而不是进行适配。在任何一种情况下,可维护性都会受到影响。

如果有一个标准函数可以被调用来验证对象是否符合特定协议,并在方便时提供一个包装器——所有这些都无需在每个库的文档中寻找适用于特定情况的咒语,那将是非常好的。

需求

在考虑对象是否符合协议时,有几种情况需要检查

  1. 当协议是类型或类,且对象的类型或类恰好是该类型或类的实例(而非子类)时。在这种情况下,合规性是自动的。
  2. 当对象了解协议,并且认为自身符合协议,或者知道如何适当地包装自身时。
  3. 当协议了解对象,并且对象已经符合协议,或者协议知道如何适当地包装对象时。
  4. 当协议是一种类型或类,并且对象是其子类的成员。这与上面的第一个情况(a)不同,因为继承(不幸的是)不一定意味着可替代性,因此必须谨慎处理。
  5. 当上下文了解对象和协议,并且知道如何适配对象以满足所需的协议时。这可以使用适配器注册表或类似方法。

上面的第四种情况很微妙。当子类改变方法的签名,或限制方法参数接受的域(参数类型的“协变”),或扩展协域以包含基类可能永远不会产生的值(返回类型的“逆变”)时,可能会发生可替代性的中断。虽然基于类继承的符合性**应该**是自动的,但本提案允许对象发出信号表明它不符合基类协议。

然而,如果Python获得了一些标准的“官方”接口机制,那么“快速路径”情况(a)可以并且应该扩展到协议是接口,而对象是声称符合该接口的类型或类的实例。例如,如果 [3] 中讨论的“interface”关键字被Python采纳,则可以使用情况(a)的“快速路径”,因为实现接口的可实例化类将不允许破坏可替代性。

规范

本提案引入了一个新的内置函数 adapt(),它是支持这些需求的基础。

adapt() 函数有三个参数

  • obj,要适配的对象
  • protocol,对象请求的协议
  • alternate,一个可选对象,如果对象无法适配,则返回该对象

adapt() 函数成功执行后,如果对象已经符合协议,则返回传入的 obj 对象;否则,返回一个次级对象 wrapper,它提供了对象符合协议的视图。包装器的定义是故意模糊的,并且包装器在必要时可以是一个拥有自己状态的完整对象。然而,设计意图是,适配包装器应该持有对其所包装的原始对象的引用,以及(如果需要)它不能委托给包装器对象的最小额外状态。

一个出色的适配包装器示例是 StringIO 实例,它将传入的字符串适配为可读作文本文件:包装器持有对字符串的引用,但自行处理“当前读取点”(从被包装字符串的**何处**获取下一个“readline”调用的字符),因为它不能将其委托给被包装对象(字符串没有“当前读取点”的概念,也没有任何与此概念远程相关的东西)。

如果将对象适配到协议失败,则会引发 AdaptationError(它是 TypeError 的子类),除非使用了 alternate 参数,在这种情况下,将返回 alternate 参数。

为了实现需求中列出的第一种情况,adapt() 函数首先检查对象的类型或对象的类是否与协议相同。如果是,则 adapt() 函数直接返回该对象,不再进行其他操作。

为了启用第二种情况,当对象了解协议时,对象必须具有 __conform__() 方法。这个可选方法接受两个参数

  • self,被适配的对象
  • protocol,请求的协议

就像当今 Python 中的任何其他特殊方法一样,__conform__ 旨在从对象的类中获取,而不是从对象本身中获取(对于所有对象,除了“经典类”的实例,只要我们仍需支持后者)。这使得将来可以在 Python 的类型对象中添加一个可能的“tp_conform”槽,如果需要的话。

对象可以返回自身作为 __conform__ 的结果,以表明符合性。或者,对象也可以选择返回一个符合协议的包装对象。如果对象知道它不符合协议,尽管它属于协议子类,那么 __conform__ 应该引发 LiskovViolation 异常(AdaptationError 的子类)。最后,如果对象无法确定其符合性,它应该返回 None 以启用其余机制。如果 __conform__ 引发任何其他异常,“adapt”只会传播它。

为了启用第三种情况,当协议了解对象时,协议必须具有 __adapt__() 方法。这个可选方法接受两个参数

  • self,请求的协议
  • obj,被适配的对象

如果协议发现对象符合要求,它可以直接返回 obj。或者,该方法可以返回一个符合协议的包装器。如果协议知道对象不符合要求,尽管它属于协议的子类,那么 __adapt__ 应该引发 LiskovViolation 异常(AdaptationError 的子类)。最后,当无法确定符合性时,此方法应返回 None 以启用其余机制。如果 __adapt__ 引发任何其他异常,“adapt”只会传播它。

第四种情况,当对象的类是协议的子类时,由内置的 adapt() 函数处理。在正常情况下,如果 "isinstance(object, protocol)",则 adapt() 直接返回对象。然而,如果对象不可替换,则 __conform__()__adapt__() 方法(如上所述)可能会引发 LiskovViolationAdaptationError 的子类)以阻止此默认行为。

如果前四种机制均未奏效,作为最后的尝试,'adapt' 会回退到检查一个适配器工厂注册表,该注册表按协议和 obj 的类型进行索引,以满足第五种情况。适配器工厂可以动态地注册和从注册表中删除,以便在对象和协议互不了解的情况下提供“第三方适配”,这种方式对对象和协议均无侵入性。

预期用途

adapt 的典型预期用途是在代码中,该代码“从外部”接收到某个对象 X(作为参数或作为某个函数调用的结果),并且需要根据某个协议 Y 使用该对象。“协议”Y旨在指示一个接口,通常会增加一些语义约束(例如“契约式设计”方法中通常使用的约束),并且通常还包含一些实用预期(例如“某个操作的运行时间不应超过 O(N)”之类的);本提案并未规定如何设计协议,也未规定如何或是否检查协议合规性,也未规定声称合规但实际不合规可能产生的后果(缺乏“语法”合规性——方法名称和签名——通常会导致异常;缺乏“语义”合规性可能导致微妙且可能偶发的错误[想象一个声称线程安全但实际上存在微妙竞争条件的方法,例如];缺乏“实用”合规性通常会导致代码运行**正确**,但速度太慢以至于无法实际使用,或者有时会导致内存或磁盘空间等资源耗尽)。

当协议 Y 是一个具体的类型或类时,对其的符合性意味着一个对象允许所有可以在 Y 的实例上执行的操作,并具有“可比较”的语义和实用性。例如,一个假设的单链表对象 X 不应该声称符合协议“list”,即使它实现了 list 的所有方法:因为索引 X[n] 需要 O(n) 的时间,而相同的操作在 list 上是 O(1),这就产生了差异。另一方面,StringIO.StringIO 的实例确实符合协议“file”,即使某些操作(例如 'marshal' 模块中的操作)可能不允许相互替换,因为它们执行了显式类型检查:从协议符合性的角度来看,此类类型检查是“超出范围”的。

尽管此约定使得将具体类型或类用作本提案目的的协议是可行的,但这种用法通常不是最优的。调用“adapt”的代码很少需要某个具体类型的所有功能,特别是对于文件、列表、字典等丰富类型;很少有包装器能够以良好的实用性以及与具体类型真正相同的语法和语义提供所有这些功能。

相反,一旦本提案被接受,就需要开始一项设计工作,以识别 Python 中目前使用的协议(特别是标准库中的协议)的基本特征,并使用某种“接口”构造对其进行形式化(不一定需要任何新语法:一个简单的自定义元类就可以让我们开始,并且该工作的结果稍后可以迁移到最终被 Python 语言接受的任何“接口”构造)。通过这样一套更正式设计的协议,使用“adapt”的代码将能够要求,比如说,适配成“一个可读可查找的类文件对象”,或者其他它具体需要的、具有适当“粒度”的任何东西,而不是过于笼统地要求符合“文件”协议。

适配不是“类型转换”。当对象X本身不符合协议Y时,将X适配到Y意味着使用某种包装器对象Z,该对象Z持有对X的引用,并实现Y所需的任何操作,主要是通过适当的方式委托给X。例如,如果X是一个字符串而Y是“文件”,那么将X适配到Y的正确方法是创建一个 StringIO(X),**而不是**调用 file(X) [这会尝试打开一个名为X的文件]。

然而,数字类型和协议可能需要成为“适配不是类型转换”这一信条的例外。

Guido的“可选静态类型:停止争论”博客条目

适应的典型简单用例如下:

def f(X):
    X = adapt(X, Y)
    # continue by using X according to protocol Y

[4] 中,BDFL 提议引入以下语法

def f(X: Y):
    # continue by using X according to protocol Y

这正是适配典型用法的便捷快捷方式,并且,作为在解析器修改以接受此新语法之前的实验基础,一个语义等效的装饰器

@arguments(Y)
def f(X):
    # continue by using X according to protocol Y

BDFL 的这些想法,以及 Guido 在同一博客中的其他建议,都与本提案完全兼容。

参考实现和测试用例

以下参考实现不处理经典类:它只考虑新式类。如果需要支持经典类,添加部分应该非常清晰,尽管可能有些混乱(x.__class__type(x),直接从对象而不是从类型获取绑定方法等等)。

-----------------------------------------------------------------
adapt.py
-----------------------------------------------------------------
class AdaptationError(TypeError):
    pass
class LiskovViolation(AdaptationError):
    pass

_adapter_factory_registry = {}

def registerAdapterFactory(objtype, protocol, factory):
    _adapter_factory_registry[objtype, protocol] = factory

def unregisterAdapterFactory(objtype, protocol):
    del _adapter_factory_registry[objtype, protocol]

def _adapt_by_registry(obj, protocol, alternate):
    factory = _adapter_factory_registry.get((type(obj), protocol))
    if factory is None:
        adapter = alternate
    else:
        adapter = factory(obj, protocol, alternate)
    if adapter is AdaptationError:
        raise AdaptationError
    else:
        return adapter


def adapt(obj, protocol, alternate=AdaptationError):

    t = type(obj)

    # (a) first check to see if object has the exact protocol
    if t is protocol:
       return obj

    try:
        # (b) next check if t.__conform__ exists & likes protocol
        conform = getattr(t, '__conform__', None)
        if conform is not None:
            result = conform(obj, protocol)
            if result is not None:
                return result

        # (c) then check if protocol.__adapt__ exists & likes obj
        adapt = getattr(type(protocol), '__adapt__', None)
        if adapt is not None:
            result = adapt(protocol, obj)
            if result is not None:
                return result
    except LiskovViolation:
        pass
    else:
        # (d) check if object is instance of protocol
        if isinstance(obj, protocol):
            return obj

    # (e) last chance: try the registry
    return _adapt_by_registry(obj, protocol, alternate)

-----------------------------------------------------------------
test.py
-----------------------------------------------------------------
from adapt import AdaptationError, LiskovViolation, adapt
from adapt import registerAdapterFactory, unregisterAdapterFactory
import doctest

class A(object):
    '''
    >>> a = A()
    >>> a is adapt(a, A)   # case (a)
    True
    '''

class B(A):
    '''
    >>> b = B()
    >>> b is adapt(b, A)   # case (d)
    True
    '''

class C(object):
    '''
    >>> c = C()
    >>> c is adapt(c, B)   # case (b)
    True
    >>> c is adapt(c, A)   # a failure case
    Traceback (most recent call last):
        ...
    AdaptationError
    '''
    def __conform__(self, protocol):
        if protocol is B:
            return self

class D(C):
    '''
    >>> d = D()
    >>> d is adapt(d, D)   # case (a)
    True
    >>> d is adapt(d, C)   # case (d) explicitly blocked
    Traceback (most recent call last):
        ...
    AdaptationError
    '''
    def __conform__(self, protocol):
        if protocol is C:
            raise LiskovViolation

class MetaAdaptingProtocol(type):
    def __adapt__(cls, obj):
        return cls.adapt(obj)

class AdaptingProtocol:
    __metaclass__ = MetaAdaptingProtocol
    @classmethod
    def adapt(cls, obj):
        pass

class E(AdaptingProtocol):
    '''
    >>> a = A()
    >>> a is adapt(a, E)   # case (c)
    True
    >>> b = A()
    >>> b is adapt(b, E)   # case (c)
    True
    >>> c = C()
    >>> c is adapt(c, E)   # a failure case
    Traceback (most recent call last):
        ...
    AdaptationError
    '''
    @classmethod
    def adapt(cls, obj):
        if isinstance(obj, A):
            return obj

class F(object):
    pass

def adapt_F_to_A(obj, protocol, alternate):
    if isinstance(obj, F) and issubclass(protocol, A):
        return obj
    else:
        return alternate

def test_registry():
    '''
    >>> f = F()
    >>> f is adapt(f, A)   # a failure case
    Traceback (most recent call last):
        ...
    AdaptationError
    >>> registerAdapterFactory(F, A, adapt_F_to_A)
    >>> f is adapt(f, A)   # case (e)
    True
    >>> unregisterAdapterFactory(F, A)
    >>> f is adapt(f, A)   # a failure case again
    Traceback (most recent call last):
        ...
    AdaptationError
    >>> registerAdapterFactory(F, A, adapt_F_to_A)
    '''

doctest.testmod()

与微软QueryInterface的关系

尽管本提案与微软的(COM)QueryInterface 有些相似之处,但它在多个方面存在差异。

首先,本提案中的适配是双向的,也允许查询接口(协议),这提供了更动态的能力(更符合 Pythonic)。其次,没有特殊的“IUnknown”接口可用于检查或获取原始未包装对象标识,尽管这可以作为那些“特殊”受祝福的接口协议标识符之一被提出。第三,使用 QueryInterface,一旦对象支持特定接口,它之后必须始终支持该接口;本提案不作此保证,特别是适配器工厂可以动态地添加到注册表并随后移除。

第四,微软 QueryInterface 的实现必须支持一种等价关系——它们必须在特定意义上是自反的、对称的和传递的。根据本提案,协议适配的等效条件也将代表期望的属性

# given, to start with, a successful adaptation:
X_as_Y = adapt(X, Y)

# reflexive:
assert adapt(X_as_Y, Y) is X_as_Y

# transitive:
X_as_Z = adapt(X, Z, None)
X_as_Y_as_Z = adapt(X_as_Y, Z, None)
assert (X_as_Y_as_Z is None) == (X_as_Z is None)

# symmetrical:
X_as_Z_as_Y = adapt(X_as_Z, Y, None)
assert (X_as_Y_as_Z is None) == (X_as_Z_as_Y is None)

然而,虽然这些属性是可取的,但并非在所有情况下都能保证。QueryInterface 可以强制执行它们的等效条件,因为它在某种程度上规定了对象、接口和适配器应如何编码;本提案旨在不一定是侵入性的,可在两个相互不知情的框架之间“追溯”适配,而无需修改任何一个框架。

适配的传递性实际上有些争议,适配与继承之间的关系(如果存在的话)也是如此。

如果我们知道继承总是意味着里氏替换原则(Liskov substitutability),那么后者就不会有争议,但不幸的是我们不知道。如果某种特殊形式,例如 [4] 中提议的接口,确实能够确保里氏替换原则,那么仅仅对于那种继承,我们或许可以断言如果 X 符合 Y 且 Y 继承自 Z,则 X 符合 Z……但前提是可替换性以非常强的意义来理解,包括语义和实用性,这似乎令人怀疑。(顺便说一句:在 QueryInterface 中,继承不要求也不暗示符合性)。本提案不包含任何继承的“强”效应,除了上面具体详述的那些小效应之外。

同样,传递性可能意味着通过某个中间Y进行多次“内部”适配以获得 adapt(X, Z) 的结果,本质上类似于 adapt(adapt(X, Y), Z),对于某个合适的自动选择的Y。同样,这在适当的强约束下或许是可行的,但这种方案的实际影响对本提案的作者来说仍然不清楚。因此,本提案不包含任何情况下适配的自动或隐式传递性。

要了解本提案原始版本的一个实现,该实现对传递性和继承效应进行了更高级的处理,请参阅 Phillip J. Eby 的 PyProtocols [5]PyProtocols 的配套文档非常值得研究,因为它对适配器应如何编码和使用,以及适配如何消除应用程序代码中对类型检查的需求进行了深入思考。

问答

  • 问:这个提案有什么好处?

    答:典型的 Python 程序员是集成者,他们将来自不同供应商的组件连接起来。通常,为了在这些组件之间进行接口,需要中间适配器。通常,程序员的责任是研究一个组件暴露的接口和另一个组件所需的接口,确定它们是否直接兼容,或者开发一个适配器。有时供应商甚至可能包含适当的适配器,但即使如此,寻找适配器并弄清楚如何部署适配器也需要时间。

    此技术使供应商能够通过根据需要实现 __conform____adapt__ 来直接相互协作。这使集成者无需自己制作适配器。本质上,这允许组件之间进行简单的对话。集成者只需将一个组件连接到另一个组件,如果类型不自动匹配,则内置的适配机制会自动处理。

    此外,多亏了适配器注册表,“第四方”可以提供适配器,以允许完全不知情的框架之间进行互操作,而且是非侵入式的,无需集成者做任何额外的事情,只需在启动时将适当的适配器工厂安装到注册表中即可。

    只要库和框架与这里提出的适配基础设施合作(本质上是通过适当地定义和使用协议,并根据需要对收到的参数和回调工厂函数的返回值调用“adapt”),集成者的工作就会变得简单得多。

    例如,考虑 SAX1 和 SAX2 接口:它们之间需要一个适配器来切换。通常,程序员必须意识到这一点;然而,有了这个适配提案,情况就不再是这样了——事实上,多亏了适配器注册表,即使提供 SAX1 的框架和需要 SAX2 的框架彼此不知情,这种需求也可以消除。

  • 问:为什么这必须是内置的,它不能独立存在吗?

    答:是的,它可以独立工作。然而,如果是内置的,它有更大的使用机会。本提案的价值主要在于标准化:让来自不同供应商(包括 Python 标准库)的库和框架采用单一的适配方法。此外,

    1. 该机制本身就是一个单例。
    2. 如果频繁使用,作为内置功能会快得多。
    3. 它具有可扩展性,且不言而喻。
    4. 一旦“adapt”成为内置函数,它就可以支持语法扩展,甚至对类型推断系统有所帮助。
  • 问:为什么要用动词 __conform____adapt__

    答:conform,不及物动词

    1. 形式或特性相符;相似。
    2. 按照或符合;遵从。
    3. 按照当前的习俗或模式行事。

    adapt,及物动词

    1. 使适合或适应特定的用途或情况。

    来源:《美国传统英语词典》,第三版

向后兼容性

应该没有向后兼容性问题,除非有人以其他方式使用了特殊名称 __conform____adapt__,但这似乎不太可能,而且,无论如何,用户代码不应将特殊名称用于非标准目的。

本提案无需修改解释器即可实现和测试。

致谢

本提案很大程度上由 Python 主要邮件列表和 type-sig 列表上才华横溢的个人反馈而成。除了提案的作者外,要点名具体贡献者(如果我们遗漏了任何人,请见谅!):提案第一版的主要建议来自 Paul Prescod,Robin Thomas 提供了重要反馈,我们还借鉴了 Marcin 'Qrczak' Kowalczyk 和 Carlos Ribeiro 的想法。

其他贡献者(通过评论)包括 Michel Pelletier、Jeremy Hylton、Aahz Maruch、Fredrik Lundh、Rainer Deyke、Timothy Delaney 和 Huaiyu Zhu。当前版本很大程度上得益于与(其中包括)Phillip J. Eby、Guido van Rossum、Bruce Eckel、Jim Fulton 和 Ka-Ping Yee 的讨论,以及对他们在 Python 中接口和协议的使用和适配方面的提案、实现和文档的学习和思考。

参考文献和脚注


来源:https://github.com/python/peps/blob/main/peps/pep-0246.rst

最后修改时间:2025-02-01 08:55:40 GMT