NEP 16 — 用于识别“鸭子数组”的抽象基类#
- 作者:
Nathaniel J. Smith <njs@pobox.com>
- 状态:
已撤回
- 类型:
标准跟踪
- 创建:
2018-03-06
- 决议:
注意
此 NEP 已被撤回,取而代之的是 NEP 22 中描述的基于协议的方法。
摘要#
我们建议添加一个抽象基类 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)
如果你查看 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
无操作直通的速度成为配置文件中出现的瓶颈,那么我们可能已经使它们更快了!(这很容易快速解决,但我们没有这样做。)
鉴于 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__
方法,或使用缓冲区接口创建视图,或传递一个具有对象数据类型的数组,或引发错误,或类似的操作。可能在大多数情况下,这些结果都不是真正想要的,所以也许改为产生段错误是可以的?但这是危险的,因为我们不知道此类代码有多普遍。另一方面,如果我们从头开始,这可能是理想的解决方案。
我们不能使用asanyarray
或array
,因为它们已经被使用了。
还有其他想法吗?np.cast
,np.coerce
?
实现#
将
NDArrayOperatorsMixin
重命名为AbstractArray
(保留一个别名以保持向后兼容性)并将其设为ABC。添加
asabstractarray
(或我们最终将它命名为什么),可能还有一个C API等价物。开始将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)")
版权#
本文档已进入公有领域。