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)
如果您了解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)
我不知道为什么这两者之间存在如此大的差异。
实际上,无论哪种方式,我们都只会在首先检查众所周知的类型(如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
方法作为 mixin 方法添加。
将 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 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)")
版权#
本文档已进入公有领域。