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 级别)都像 ndarrays 一样工作。但是,其中许多函数仅处理 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 可以提供有用的混合方法。

  • 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 的 no-op 传递速度成为性能瓶颈,那么我们可能已经使其更快了! (快速通道实现此功能很容易,但我们没有。)

鉴于 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 方法作为混合方法添加。

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 abc

import perf

import numpy as np


class NotArray:
    pass

class AttrArray:
    __array_implementer__ = True

class ArrayBase(abc.ABC):  # noqa: B024
    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)")