NEP 16 — 标识“鸭子数组”的抽象基类#
- 作者:
Nathaniel J. Smith <njs@pobox.com>
- 状态:
已撤回
- 类型:
标准轨道
- 创建时间:
2018-03-06
- 解决时间:
注意
此 NEP 已撤回,采纳 NEP 22 — NumPy 数组的鸭子类型——高级概述 中描述的基于协议的方法。
摘要#
我们建议添加一个抽象基类 AbstractArray,以便第三方类可以声明它们能够“像”ndarray 一样“嘎嘎叫”,并添加一个 asabstractarray 函数,其功能类似于 asarray,但它会按原样传递 AbstractArray 实例。
详细描述#
NumPy 和第三方包中的许多函数都以类似以下代码开始:
def myfunc(a, b):
a = np.asarray(a)
b = np.asarray(b)
...
这确保了 a 和 b 是 np.ndarray 对象,因此 myfunc 可以继续假设它们在语义上(在 Python 级别)以及在内存存储方式上(在 C 级别)都像 ndarrays 一样工作。但是,其中许多函数仅处理 Python 级别的数组,这意味着它们实际上并不需要ndarray 对象本身:它们可以与任何“像”ndarray 一样“嘎嘎叫”的 Python 对象一样工作,例如稀疏数组、dask 的惰性数组或 xarray 的带标签数组。
然而,目前,这些库没有办法表达它们的对象可以像 ndarray 一样“嘎嘎叫”,并且像 myfunc 这样的函数也没有办法表达它们可以接受任何像 ndarray 一样“嘎嘎叫”的对象。此 NEP 的目的是提供这两个功能。
有时人们建议为此目的使用 np.asanyarray,但不幸的是,它的语义正好相反:它保证它返回的对象使用与 ndarray 相同的内存布局,但对它的语义一无所知,这使得它在实践中几乎无法安全使用。事实上,NumPy 自带的两个 ndarray 子类——np.matrix 和 np.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)
我不知道为什么这之间会有如此大的差异。
实际上,无论哪种方式,我们都只会在首先检查已知的类型(如 ndarray、list 等)之后才进行完整测试。 NumPy 目前就是这样检查其他双下划线属性的,同样的概念也适用于这两种方法。因此,这些数字不会影响常见情况,只会影响实际遇到 AbstractArray,或者最终通过 __array__ 或 __array_interface__ 或成为对象数组的其他第三方对象的情况。
总而言之,使用 ABC 会比使用属性稍慢一些,但这不影响最常见路径,并且减慢的幅度相当小(在大约 250 ns 的操作上,该操作已经花费了这么长时间)。此外,我们还可以进一步优化这一点(例如,通过维护一个已知是 AbstractArray 子类的类型的少量 LRU 缓存,假设大多数代码一次只使用一两个这些类型),并且非常不确定这是否重要——如果 asarray 的 no-op 传递速度成为性能瓶颈,那么我们可能已经使其更快了! (快速通道实现此功能很容易,但我们没有。)
鉴于 ABC 的语义和可用性优势,这似乎是可接受的权衡。
asabstractarray 的规范#
给定 AbstractArray,asabstractarray 的定义很简单
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 使用。我们可能还想添加一些基本属性,如 ndim、shape、dtype。
添加新的抽象方法会有点棘手,因为 ABC 在子类化时强制执行它们;因此,简单地添加一个新的 @abstractmethod 将会破坏向后兼容性。如果这成为问题,我们可以使用一些技巧来实现一个 @upcoming_abstractmethod 装饰器,它只会在缺少方法时发出警告,并将其视为常规的弃用周期。(在这种情况下,我们要弃用的是“对缺少功能 X 的抽象数组的支持”。)
命名#
ABC 的名称不太重要,因为它只会在不经常且相对专业的情况下被引用。函数的名称很重要,因为大多数现有的 asarray 实例都应该被它替换,并且在将来,除非有特殊原因要使用 asarray,否则每个人都应该默认使用它。这表明它的名称实际上应该比 asarray更短、更易记……这很困难。我在这个草稿中使用了 asabstractarray,但对此并不满意,因为它太长了,而且人们不太可能通过无休止的劝诫习惯性地开始使用它。
一种选择是实际更改 asarray 的语义,使其本身按原样传递 AbstractArray 对象。但我担心可能有很多代码调用 asarray,然后将结果传递给一个不进行任何进一步类型检查的 C 函数(因为它知道它的调用者已经使用了 asarray)。如果我们允许 asarray 返回 AbstractArray 对象,然后有人调用其中一个 C 包装器并传递一个像稀疏数组这样的 AbstractArray 对象,那么他们就会遇到段错误。现在,在相同的情况下,asarray 将调用对象的 __array__ 方法,或使用缓冲区接口创建一个视图,或传递具有对象 dtype 的数组,或引发错误,或类似操作。可能在大多数情况下,这些结果都不是真正可取的,所以也许将其变成段错误也可以?但考虑到我们不知道这种代码的常见程度,这是危险的。另一方面,如果我们从头开始,这可能是理想的解决方案。
我们不能使用 asanyarray 或 array,因为它们已经被占用了。
还有其他想法吗?np.cast、np.coerce?
实现#
将
NDArrayOperatorsMixin重命名为AbstractArray(留下一个别名以兼容旧版本),并使其成为 ABC。添加
asabstractarray(或我们最终称呼它的任何名称),以及可能的 C API 等效项。开始将 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)")
版权#
本文档已置于公共领域。