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 层面)都像 ndarray 一样。但是,这些函数中有许多只在 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)
如果你了解 ABCs 的工作原理,这本质上等同于维护一个已声明实现 AbstractArray
接口的类型全局集合,然后检查其成员资格。
在这两者之间,ABC 方法似乎具有多项优势:
这是 Python 的标准,“显而易见”的做法。
ABC 可以自省(例如
help(np.AbstractArray)
会做一些有用的事情)。ABC 可以提供有用的混入方法(mixin methods)。
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
和 ABCs 在此版本中都获得了实质性的优化,这更能代表 Python 的长期未来。(getattr
失败不一定再构造异常对象,ABCs 在 C 中重新实现。)“成功”行指
quacks_like_array
返回 True 的情况。“失败”行是它返回 False 的情况。ABCs 的第一次测量是子类定义方式如下:
class MyArray(AbstractArray): ...
第二次测量是子类定义方式如下:
class MyArray: ... AbstractArray.register(MyArray)
我不知道这之间为什么会有如此大的差异。
实际上,无论哪种方式,我们都只会在首先检查 ndarray
、list
等知名类型之后才进行全面测试。NumPy 目前就是这样检查其他双下划线属性的,同样的想法也适用于这两种方法。因此,这些数字不会影响常见情况,只会影响我们实际拥有 AbstractArray
,或者其他第三方对象最终通过 __array__
或 __array_interface__
处理,或者最终成为对象数组的情况。
因此,总而言之,使用 ABC 会比使用属性稍微慢一些,但这不影响最常见的路径,并且速度下降的幅度相当小(在一个已经比这耗时更长的操作中,约慢 250 纳秒)。此外,我们还可以进一步优化(例如,通过保留一个已知是 AbstractArray 子类的类型的小型 LRU 缓存,假设大多数代码一次只使用这些类型中的一两种),而且目前尚不清楚这是否重要——如果 asarray
空操作直通的速度是一个在性能分析中出现的瓶颈,那么我们可能早就已经让它们更快了!(对这种情况进行快速路径处理很简单,但我们没有这样做。)
鉴于 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 函数,而该 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 接口”是现有库期望(因为它们处理实际的 ndarray)和测试(因为它们使用实际的 ndarray 进行测试)的东西,因此它是迄今为止最容易开始的地方。
讨论链接#
附录:基准测试脚本#
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)")
版权#
本文件已置于公共领域。