NEP 16 — 用于识别“鸭子数组”的抽象基类#

作者:

Nathaniel J. Smith <njs@pobox.com>

状态:

已撤回

类型:

标准轨

创建:

2018-03-06

决议:

numpy/numpy#12174

注意

此NEP已被撤回,取而代之的是NEP 22 — NumPy数组的鸭子类型 – 高级概述中描述的基于协议的方法。

摘要#

我们建议添加一个抽象基类AbstractArray,以便第三方类可以声明它们能够“像”ndarray一样工作,以及一个asabstractarray函数,该函数的功能类似于asarray,但它会将AbstractArray实例原样传递。

详细说明#

NumPy和第三方包中的许多函数都以类似以下代码开头:

def myfunc(a, b):
    a = np.asarray(a)
    b = np.asarray(b)
    ...

这确保了abnp.ndarray对象,因此myfunc可以继续假设它们在语义上(在Python级别)以及在内存存储方式方面(在C级别)都像ndarray一样工作。但是,许多这些函数仅在Python级别上处理数组,这意味着它们实际上并不需要ndarray对象本身:它们也可以与任何“像”ndarray一样的Python对象一起工作,例如稀疏数组、dask的延迟数组或xarray的标记数组。

但是,目前,这些库无法表达它们的对象可以像ndarray一样工作,而且像myfunc这样的函数也无法表达它们对任何像ndarray一样工作的对象都感到满意。本NEP的目的是提供这两个功能。

有时人们建议为此目的使用np.asanyarray,但不幸的是,它的语义完全相反:它保证它返回的对象使用与ndarray相同的内存布局,但它根本没有告诉你它的语义,这使得它在实践中几乎不可能安全地使用。事实上,NumPy附带的两个ndarray子类——np.matrixnp.ma.masked_array——确实具有不相容的语义,如果将它们传递给像myfunc这样的函数,而该函数没有将它们作为特例进行检查,那么它可能会默默地返回不正确的结果。

声明对象可以像数组一样工作#

我们可以使用两种基本方法来检查对象是否像数组一样工作。我们可以检查类上的特殊属性:

def quacks_like_array(obj):
    return bool(getattr(type(obj), "__quacks_like_array__", False))

或者,我们可以定义一个抽象基类(ABC)

def quacks_like_array(obj):
    return isinstance(obj, AbstractArray)

如果您了解ABC的工作原理,这实际上等同于维护一个已声明实现AbstractArray接口的类型的全局集合,然后检查其成员资格。

在这两者之间,ABC方法似乎具有一些优势:

  • 这是Python的标准,“显而易见的方法”。

  • 可以内省ABC(例如,help(np.AbstractArray)会做一些有用的事情)。

  • ABC可以提供有用的mixin方法。

  • ABC与其他功能集成,例如mypy类型检查、functools.singledispatch等。

一个显而易见的事情是检查这个选择是否会影响速度。在CPython 3.7 预发布版(修订版 c4d77a661138d,自编译,无PGO)上使用附加的基准测试脚本,在一台运行Linux的Thinkpad T450s上,我们发现:

np.asarray(ndarray_obj)      330 ns
np.asarray([])              1400 ns

Attribute check, success      80 ns
Attribute check, failure      80 ns

ABC, success via subclass    340 ns
ABC, success via register()  700 ns
ABC, failure                 370 ns

注释

  • 包含前两行是为了将其他行放在上下文中。

  • 使用3.7是因为getattr和ABC在这个版本中都进行了大量的优化,它更能代表Python的长期未来。(失败的getattr不再一定会构造异常对象,并且ABC已在C中重新实现。)

  • “成功”行指的是quacks_like_array将返回True的情况。“失败”行是它将返回False的情况。

  • ABC的第一次测量是像这样定义的子类:

    class MyArray(AbstractArray):
        ...
    

    第二次是针对像这样定义的子类:

    class MyArray:
        ...
    
    AbstractArray.register(MyArray)
    

    我不知道为什么这两者之间存在如此大的差异。

实际上,无论哪种方式,我们都只会在首先检查众所周知的类型(如ndarraylist等)之后才进行完整的测试。这就是NumPy目前检查其他双下划线属性的方式,同样的想法也适用于这两种方法。因此,这些数字不会影响常见情况,只会影响我们实际上拥有AbstractArray或其他将最终通过__array____array_interface__或最终作为对象数组的第三方对象的情况。

总而言之,使用ABC会比使用属性稍慢一些,但这不会影响最常见的路径,并且减速的幅度相当小(在已经花费更长时间的操作上约为250 ns)。此外,我们还可以进一步优化它(例如,通过维护一个已知是AbstractArray子类的类型的微型LRU缓存,假设大多数代码一次只使用这些类型中的一个或两个),而且还不清楚这是否重要——如果asarray无操作直通的速度成为配置文件中显示的瓶颈,那么我们可能已经使它们更快了!(这很容易快速处理,但我们没有这样做。)

鉴于ABC的语义和可用性优势,这似乎是可以接受的权衡。

asabstractarray的规范#

给定AbstractArrayasabstractarray的定义很简单:

def asabstractarray(a, dtype=None):
    if isinstance(a, AbstractArray):
        if dtype is not None and dtype != a.dtype:
            return a.astype(dtype)
        return a
    return asarray(a, dtype=dtype)

需要注意的事项

  • asarray 也接受一个 order= 参数,但我们这里不包含它,因为它与内存表示的细节有关,而此函数的重点是声明您不关心内存表示的细节。

  • 使用 astype 方法允许 a 对象决定如何为其特定类型实现类型转换。

  • 为了与 asarray 严格兼容,当 dtype 已经正确时,我们跳过调用 astype。比较一下

    >>> a = np.arange(10)
    
    # astype() always returns a view:
    >>> a.astype(a.dtype) is a
    False
    
    # asarray() returns the original object if possible:
    >>> np.asarray(a, dtype=a.dtype) is a
    True
    

如果您继承自 AbstractArray,您究竟承诺了什么?#

这大概会随着时间的推移而改进。理想情况下,您的类应该与真正的 ndarray 没有区别,但除了用户的期望之外,没有任何东西强制执行这一点。在实践中,声明您的类实现了 AbstractArray 接口仅仅意味着它将开始通过 asabstractarray 传递,因此通过子类化它,您是在说,如果某些代码适用于 ndarray 但对您的类无效,那么您愿意接受对此的错误报告。

首先,我们应该声明 __array_ufunc__ 为抽象方法,并将 NDArrayOperatorsMixin 方法作为 mixin 方法添加。

astype 声明为 @abstractmethod 也许也很有意义,因为它被 asabstractarray 使用。我们可能还想继续添加一些基本属性,例如 ndimshapedtype

添加新的抽象方法会有点棘手,因为 ABC 在子类化时强制执行这些方法;因此,简单地添加一个新的 @abstractmethod 将会是向后兼容性的中断。如果这成为问题,我们可以使用一些技巧来实现一个 @upcoming_abstractmethod 装饰器,如果方法缺失,则只发出警告,并将其视为常规的弃用周期。(在这种情况下,我们将弃用的是“对缺少功能 X 的抽象数组的支持”。)

命名#

ABC 的名称并不重要,因为它很少被引用,并且只在相对专业的情况下被引用。函数的名称非常重要,因为大多数现有的 asarray 实例应该被替换为此,将来,除非他们有特定原因使用 asarray,否则每个人都应该默认使用它。这表明它的名称应该比 asarray 更短、更容易记住……这很难。我在这个草稿中使用了 asabstractarray,但我并不满意,因为它太长了,人们不太可能在没有无休止的劝说下养成使用它的习惯。

一种选择是实际更改 asarray 的语义,以便它本身不变地传递 AbstractArray 对象。但我担心可能有很多代码调用 asarray,然后将结果传递给一些不做任何进一步类型检查的 C 函数(因为它知道它的调用者已经使用了 asarray)。如果我们允许 asarray 返回 AbstractArray 对象,然后有人调用这些 C 包装器之一并向其传递一个像稀疏数组这样的 AbstractArray 对象,那么他们将得到一个段错误。现在,在相同的情况下,asarray 将调用对象的 __array__ 方法,或使用缓冲区接口创建一个视图,或传递一个具有对象 dtype 的数组,或引发错误,或类似的操作。在大多数情况下,这些结果可能都不是理想的,所以也许改为产生段错误是可以的?但鉴于我们不知道此类代码有多常见,因此这是危险的。另一方面,如果我们从头开始,这可能是理想的解决方案。

我们不能使用 asanyarrayarray,因为它们已经被使用了。

还有其他想法吗?np.castnp.coerce

实现#

  1. NDArrayOperatorsMixin 重命名为 AbstractArray(保留别名以实现向后兼容性),并将其设为 ABC。

  2. 添加 asabstractarray(或我们最终将其命名为何物),可能还有一个 C API 等价物。

  3. 开始将 NumPy 内部函数迁移到在适当的地方使用 asabstractarray

向后兼容性#

这纯粹是一个新功能,因此没有兼容性问题。(除非我们决定更改 asarray 本身的语义。)

被拒绝的替代方案#

一个提出的建议是为数组接口的不同子集定义多个抽象类。本提案中没有任何内容阻止 NumPy 或第三方将来这样做,但提前猜测哪些子集会有用非常困难。“完整的 ndarray 接口”是现有库期望(因为它们与实际的 ndarrays 一起工作)并测试(因为它们用实际的 ndarrays 进行测试)的东西,因此它是迄今为止最容易开始的地方。

附录:基准测试脚本#

import perf
import abc
import numpy as np

class NotArray:
    pass

class AttrArray:
    __array_implementer__ = True

class ArrayBase(abc.ABC):
    pass

class ABCArray1(ArrayBase):
    pass

class ABCArray2:
    pass

ArrayBase.register(ABCArray2)

not_array = NotArray()
attr_array = AttrArray()
abc_array_1 = ABCArray1()
abc_array_2 = ABCArray2()

# Make sure ABC cache is primed
isinstance(not_array, ArrayBase)
isinstance(abc_array_1, ArrayBase)
isinstance(abc_array_2, ArrayBase)

runner = perf.Runner()
def t(name, statement):
    runner.timeit(name, statement, globals=globals())

t("np.asarray([])", "np.asarray([])")
arrobj = np.array([])
t("np.asarray(arrobj)", "np.asarray(arrobj)")

t("attr, False",
  "getattr(not_array, '__array_implementer__', False)")
t("attr, True",
  "getattr(attr_array, '__array_implementer__', False)")

t("ABC, False", "isinstance(not_array, ArrayBase)")
t("ABC, True, via inheritance", "isinstance(abc_array_1, ArrayBase)")
t("ABC, True, via register", "isinstance(abc_array_2, ArrayBase)")