ndarray 的子类化#
简介#
ndarray 的子类化相对简单,但与其他 Python 对象相比,它有一些复杂之处。在本页中,我们将解释允许您对 ndarray 进行子类化的机制,以及实现子类的含义。
ndarrays 和对象创建#
ndarray 的子类化之所以复杂,是因为 ndarray 类的新的实例可以通过三种不同的方式产生。它们是
显式构造函数调用 - 如
MySubClass(params)
。这是 Python 实例创建的通常途径。视图转换 - 将现有的 ndarray 转换为给定的子类
从模板创建新实例 - 从模板实例创建新实例。例如,从子类化数组返回切片,从 ufuncs 创建返回类型以及复制数组。有关更多详细信息,请参阅 从模板创建新实例
后两种是 ndarray 的特征 - 为了支持诸如数组切片之类的事情。ndarray 子类化的复杂性是由于 numpy 必须支持后两种实例创建途径的机制造成的。
何时使用子类化#
除了 NumPy 数组子类化的额外复杂性之外,子类还会遇到意外的行为,因为某些函数可能会将子类转换为基类并“忘记”与子类关联的任何其他信息。如果您使用未明确测试的 NumPy 方法或函数,这可能会导致意外的行为。
另一方面,与其他互操作性方法相比,子类化可能很有用,因为许多事情将“正常工作”。
这意味着子类化可能是一种方便的方法,并且很长一段时间以来,它也常常是唯一可用的方法。但是,NumPy 现在提供了“与 NumPy 的互操作性”中描述的其他互操作性协议。对于许多用例,这些互操作性协议现在可能更适合或补充子类化的使用。
如果以下情况,子类化可能是一个不错的选择
您不太担心可维护性或除您自己以外的其他用户:子类将更快地实现,并且可以“按需”添加其他互操作性。并且用户很少,可能的意外情况不是问题。
您认为子类信息被忽略或静默丢失没有问题。一个例子是
np.memmap
,其中“忘记”数据被内存映射不会导致错误的结果。有时会让用户感到困惑的子类的一个例子是 NumPy 的掩码数组。在引入它们时,子类化是实现的唯一方法。但是,今天我们可能会尝试避免子类化,而只依赖于互操作性协议。
请注意,子类作者也可能希望研究 与 NumPy 的互操作性 以支持更复杂的用例或解决意外行为。
astropy.units.Quantity
和 xarray
是与 NumPy 很好地互操作的类数组对象的示例。Astropy 的 Quantity
是一个使用子类化和互操作性协议双重方法的示例。
视图转换#
视图转换是标准的 ndarray 机制,通过它您可以获取任何子类的 ndarray,并返回该数组作为另一个(指定)子类的视图
>>> import numpy as np
>>> # create a completely useless ndarray subclass
>>> class C(np.ndarray): pass
>>> # create a standard ndarray
>>> arr = np.zeros((3,))
>>> # take a view of it, as our useless subclass
>>> c_arr = arr.view(C)
>>> type(c_arr)
<class '__main__.C'>
从模板创建新实例#
ndarray 子类的新的实例还可以通过与 视图转换 非常相似的机制产生,当 numpy 发现它需要从模板实例创建新的实例时。最明显的地方是当您获取子类化数组的切片时。例如
>>> v = c_arr[1:]
>>> type(v) # the view is of type 'C'
<class '__main__.C'>
>>> v is c_arr # but it's a new instance
False
该切片是原始 c_arr
数据的视图。因此,当我们从 ndarray 获取视图时,我们会返回一个新的 ndarray,其类相同,指向原始数据。
在使用 ndarray 的其他地方,我们也需要这样的视图,例如复制数组 (c_arr.copy()
),创建 ufunc 输出数组(另请参阅 ufuncs 和其他函数的 __array_wrap__) 以及归约方法(如 c_arr.mean()
)。
视图转换和从模板创建新实例的关系#
这些路径都使用相同的机制。我们在这里进行区分,因为它们会导致方法的不同输入。具体来说,视图转换 意味着您已从 ndarray 的任何潜在子类创建了数组类型的新的实例。从模板创建新实例 意味着您已从预先存在的实例创建了类的新的实例,例如,允许您复制特定于子类的属性。
对子类化的影响#
如果我们对 ndarray 进行子类化,则不仅需要处理数组类型的显式构造,还需要处理 视图转换 或 从模板创建新实例。NumPy 具有执行此操作的机制,正是这种机制使得子类化略有不寻常。
ndarray 用于在子类中支持视图和从模板创建新实例的机制有两个方面。
首先是使用 ndarray.__new__
方法进行对象初始化的主要工作,而不是更常用的 __init__
方法。其次是使用 __array_finalize__
方法允许子类在创建视图和从模板创建新实例后进行清理。
关于 __new__
和 __init__
的简要 Python 简介#
__new__
是标准的 Python 方法,如果存在,则在创建类实例时在 __init__
之前调用。有关更多详细信息,请参阅 python __new__ 文档。
例如,考虑以下 Python 代码
>>> class C:
... def __new__(cls, *args):
... print('Cls in __new__:', cls)
... print('Args in __new__:', args)
... # The `object` type __new__ method takes a single argument.
... return object.__new__(cls)
... def __init__(self, *args):
... print('type(self) in __init__:', type(self))
... print('Args in __init__:', args)
这意味着我们得到
>>> c = C('hello')
Cls in __new__: <class '__main__.C'>
Args in __new__: ('hello',)
type(self) in __init__: <class '__main__.C'>
Args in __init__: ('hello',)
当我们调用 C('hello')
时,__new__
方法将其自己的类作为第一个参数,以及传递的参数,即字符串 'hello'
。在 python 调用 __new__
后,它通常(见下文)调用我们的 __init__
方法,其中 __new__
的输出作为第一个参数(现在是类实例),然后是传递的参数。
如您所见,对象可以在 __new__
方法或 __init__
方法中初始化,或者两者兼而有之,事实上 ndarray 没有 __init__
方法,因为所有初始化都在 __new__
方法中完成。
为什么使用 __new__
而不是通常的 __init__
?因为在某些情况下,对于 ndarray,我们希望能够返回某个其他类的对象。考虑以下情况
class D(C):
def __new__(cls, *args):
print('D cls is:', cls)
print('D args in __new__:', args)
return C.__new__(C, *args)
def __init__(self, *args):
# we never get here
print('In D __init__')
这意味着
>>> obj = D('hello')
D cls is: <class 'D'>
D args in __new__: ('hello',)
Cls in __new__: <class 'C'>
Args in __new__: ('hello',)
>>> type(obj)
<class 'C'>
C
的定义与之前相同,但对于 D
,__new__
方法返回类 C
的实例而不是 D
。请注意,不会调用 D
的 __init__
方法。通常,当 __new__
方法返回的类对象与定义它的类不同时,不会调用该类的 __init__
方法。
这就是 ndarray 类子类能够返回保留类类型的视图的方式。在获取视图时,标准的 ndarray 机制使用类似以下内容创建新的 ndarray 对象:
obj = ndarray.__new__(subtype, shape, ...
其中 subdtype
是子类。因此,返回的视图与子类相同,而不是类 ndarray
。
这解决了返回相同类型的视图的问题,但现在我们有了新的问题。ndarray 的机制可以通过这种方式设置类,在其获取视图的标准方法中,但 ndarray __new__
方法不知道我们在自己的 __new__
方法中做了什么来设置属性,等等。(旁注 - 为什么不调用 obj = subdtype.__new__(...
然后?因为我们可能没有具有相同调用签名的 __new__
方法)。
__array_finalize__
的作用#
__array_finalize__
是 numpy 提供的机制,允许子类处理创建新实例的各种方式。
请记住,子类实例可以通过这三种方式产生
显式构造函数调用 (
obj = MySubClass(params)
)。这将调用通常的MySubClass.__new__
然后(如果存在)MySubClass.__init__
序列。
我们的 MySubClass.__new__
方法只在显式构造函数调用时被调用,因此我们不能依赖 MySubClass.__new__
或 MySubClass.__init__
来处理视图转换和从模板创建新实例。事实证明,MySubClass.__array_finalize__
确实在所有三种对象创建方法中都被调用,因此这通常是我们进行对象创建管理的地方。
对于显式构造函数调用,我们的子类将需要创建其自身类的新的 ndarray 实例。在实践中,这意味着我们(代码的作者)将需要调用
ndarray.__new__(MySubClass,...)
、对super().__new__(cls, ...)
进行类层次结构准备的调用,或对现有数组进行视图转换(见下文)对于视图转换和从模板创建新实例,在 C 级别调用相当于
ndarray.__new__(MySubClass,...
的内容。
__array_finalize__
接收的参数在上述三种实例创建方法中有所不同。
以下代码允许我们查看调用序列和参数
import numpy as np
class C(np.ndarray):
def __new__(cls, *args, **kwargs):
print('In __new__ with class %s' % cls)
return super().__new__(cls, *args, **kwargs)
def __init__(self, *args, **kwargs):
# in practice you probably will not need or want an __init__
# method for your subclass
print('In __init__ with class %s' % self.__class__)
def __array_finalize__(self, obj):
print('In array_finalize:')
print(' self type is %s' % type(self))
print(' obj type is %s' % type(obj))
现在
>>> # Explicit constructor
>>> c = C((10,))
In __new__ with class <class 'C'>
In array_finalize:
self type is <class 'C'>
obj type is <type 'NoneType'>
In __init__ with class <class 'C'>
>>> # View casting
>>> a = np.arange(10)
>>> cast_a = a.view(C)
In array_finalize:
self type is <class 'C'>
obj type is <type 'numpy.ndarray'>
>>> # Slicing (example of new-from-template)
>>> cv = c[:1]
In array_finalize:
self type is <class 'C'>
obj type is <class 'C'>
__array_finalize__
的签名是
def __array_finalize__(self, obj):
可以看出,super
调用(转到 ndarray.__new__
)将新对象(self
)以及从中获取视图的对象 (obj
) 传递给 __array_finalize__
。从上面的输出可以看出,self
始终是我们子类的新的创建的实例,并且 obj
的类型在三种实例创建方法中有所不同
从显式构造函数调用时,
obj
为None
从视图转换调用时,
obj
可以是 ndarray 的任何子类的实例,包括我们自己的实例。从模板创建新实例调用时,
obj
是我们自己子类的另一个实例,我们可以使用它来更新新的self
实例。
因为 __array_finalize__
是唯一始终看到创建新实例的方法,所以它是为新对象属性填充实例默认值(以及其他任务)的合理位置。
通过一个例子可能更清楚。
简单示例 - 向 ndarray 添加额外属性#
import numpy as np
class InfoArray(np.ndarray):
def __new__(subtype, shape, dtype=float, buffer=None, offset=0,
strides=None, order=None, info=None):
# Create the ndarray instance of our type, given the usual
# ndarray input arguments. This will call the standard
# ndarray constructor, but return an object of our type.
# It also triggers a call to InfoArray.__array_finalize__
obj = super().__new__(subtype, shape, dtype,
buffer, offset, strides, order)
# set the new 'info' attribute to the value passed
obj.info = info
# Finally, we must return the newly created object:
return obj
def __array_finalize__(self, obj):
# ``self`` is a new object resulting from
# ndarray.__new__(InfoArray, ...), therefore it only has
# attributes that the ndarray.__new__ constructor gave it -
# i.e. those of a standard ndarray.
#
# We could have got to the ndarray.__new__ call in 3 ways:
# From an explicit constructor - e.g. InfoArray():
# obj is None
# (we're in the middle of the InfoArray.__new__
# constructor, and self.info will be set when we return to
# InfoArray.__new__)
if obj is None: return
# From view casting - e.g arr.view(InfoArray):
# obj is arr
# (type(obj) can be InfoArray)
# From new-from-template - e.g infoarr[:3]
# type(obj) is InfoArray
#
# Note that it is here, rather than in the __new__ method,
# that we set the default value for 'info', because this
# method sees all creation of default objects - with the
# InfoArray.__new__ constructor, but also with
# arr.view(InfoArray).
self.info = getattr(obj, 'info', None)
# We do not need to return anything
使用该对象如下所示
>>> obj = InfoArray(shape=(3,)) # explicit constructor
>>> type(obj)
<class 'InfoArray'>
>>> obj.info is None
True
>>> obj = InfoArray(shape=(3,), info='information')
>>> obj.info
'information'
>>> v = obj[1:] # new-from-template - here - slicing
>>> type(v)
<class 'InfoArray'>
>>> v.info
'information'
>>> arr = np.arange(10)
>>> cast_arr = arr.view(InfoArray) # view casting
>>> type(cast_arr)
<class 'InfoArray'>
>>> cast_arr.info is None
True
此类不是很有用,因为它与裸 ndarray 对象具有相同的构造函数,包括传递缓冲区和形状等等。我们可能更希望构造函数能够获取来自通常的 numpy 调用 np.array
的已形成的 ndarray 并返回一个对象。
稍微更真实的示例 - 添加到现有数组的属性#
这是一个类,它获取一个已经存在的标准 ndarray,将其转换为我们的类型,并添加一个额外的属性。
import numpy as np
class RealisticInfoArray(np.ndarray):
def __new__(cls, input_array, info=None):
# Input array is an already formed ndarray instance
# We first cast to be our class type
obj = np.asarray(input_array).view(cls)
# add the new attribute to the created instance
obj.info = info
# Finally, we must return the newly created object:
return obj
def __array_finalize__(self, obj):
# see InfoArray.__array_finalize__ for comments
if obj is None: return
self.info = getattr(obj, 'info', None)
所以
>>> arr = np.arange(5)
>>> obj = RealisticInfoArray(arr, info='information')
>>> type(obj)
<class 'RealisticInfoArray'>
>>> obj.info
'information'
>>> v = obj[1:]
>>> type(v)
<class 'RealisticInfoArray'>
>>> v.info
'information'
__array_ufunc__
用于 ufuncs#
版本 1.13 中的新功能。
子类可以通过重写默认的ndarray.__array_ufunc__
方法来覆盖在执行 NumPy 通用函数 (ufunc) 时的行为。此方法会替代ufunc 执行,并且应该返回操作的结果,或者如果请求的操作未实现,则返回NotImplemented
。
__array_ufunc__
的签名如下:
def __array_ufunc__(ufunc, method, *inputs, **kwargs):
ufunc 是被调用的通用函数对象。
method 是一个字符串,指示 Ufunc 的调用方式,可以是
"__call__"
,表示直接调用,也可以是其方法之一:"reduce"
、"accumulate"
、"reduceat"
、"outer"
或"at"
。inputs 是一个元组,包含传递给
ufunc
的输入参数。kwargs 包含传递给函数的任何可选或关键字参数。这包括任何
out
参数,这些参数始终包含在一个元组中。
一个典型的实现会将任何属于自身类的输入或输出转换为普通 ndarray,使用super()
将所有内容传递给超类,最后在可能的逆转换后返回结果。以下是一个示例,取自_core/tests/test_umath.py
中的测试用例test_ufunc_override_with_super
。
input numpy as np
class A(np.ndarray):
def __array_ufunc__(self, ufunc, method, *inputs, out=None, **kwargs):
args = []
in_no = []
for i, input_ in enumerate(inputs):
if isinstance(input_, A):
in_no.append(i)
args.append(input_.view(np.ndarray))
else:
args.append(input_)
outputs = out
out_no = []
if outputs:
out_args = []
for j, output in enumerate(outputs):
if isinstance(output, A):
out_no.append(j)
out_args.append(output.view(np.ndarray))
else:
out_args.append(output)
kwargs['out'] = tuple(out_args)
else:
outputs = (None,) * ufunc.nout
info = {}
if in_no:
info['inputs'] = in_no
if out_no:
info['outputs'] = out_no
results = super().__array_ufunc__(ufunc, method, *args, **kwargs)
if results is NotImplemented:
return NotImplemented
if method == 'at':
if isinstance(inputs[0], A):
inputs[0].info = info
return
if ufunc.nout == 1:
results = (results,)
results = tuple((np.asarray(result).view(A)
if output is None else output)
for result, output in zip(results, outputs))
if results and isinstance(results[0], A):
results[0].info = info
return results[0] if len(results) == 1 else results
因此,这个类实际上并没有做任何有趣的事情:它只是将任何自身的实例转换为常规的 ndarray(否则,我们会得到无限递归!),并添加了一个info
字典,用于说明它转换了哪些输入和输出。因此,例如:
>>> a = np.arange(5.).view(A)
>>> b = np.sin(a)
>>> b.info
{'inputs': [0]}
>>> b = np.sin(np.arange(5.), out=(a,))
>>> b.info
{'outputs': [0]}
>>> a = np.arange(5.).view(A)
>>> b = np.ones(1).view(A)
>>> c = a + b
>>> c.info
{'inputs': [0, 1]}
>>> a += b
>>> a.info
{'inputs': [0, 1], 'outputs': [0]}
请注意,另一种方法是使用getattr(ufunc, methods)(*inputs, **kwargs)
而不是super
调用。对于此示例,结果将相同,但如果另一个操作数也定义了__array_ufunc__
,则存在差异。例如,假设我们计算np.add(a, b)
,其中b
是另一个类的实例B
,并且该类具有重写。如果您像示例中那样使用super
,ndarray.__array_ufunc__
会注意到b
有一个重写,这意味着它本身无法计算结果。因此,它将返回NotImplemented,我们的类A
也会返回。然后,控制权将传递给b
,它要么知道如何处理我们并产生结果,要么不知道并返回NotImplemented,从而引发TypeError
。
相反,如果我们用getattr(ufunc, method)
替换我们的super
调用,我们实际上执行的是np.add(a.view(np.ndarray), b)
。同样,将调用B.__array_ufunc__
,但现在它将一个ndarray
作为另一个参数。很可能,它会知道如何处理它,并向我们返回一个新的B
类实例。我们的示例类没有设置来处理这种情况,但如果例如要使用__array_ufunc__
重新实现MaskedArray
,它很可能是最好的方法。
最后需要注意的是:如果super
路线适合给定的类,那么使用它的一个好处是它有助于构建类层次结构。例如,假设我们的另一个类B
也在其__array_ufunc__
实现中使用了super
,并且我们创建了一个依赖于这两者的类C
,即class C(A, B)
(为简单起见,没有另一个__array_ufunc__
重写)。然后,对C
实例的任何 ufunc 都将传递到A.__array_ufunc__
,A
中的super
调用将转到B.__array_ufunc__
,B
中的super
调用将转到ndarray.__array_ufunc__
,从而允许A
和B
协作。
__array_wrap__
用于通用函数和其他函数#
在 NumPy 1.13 之前,通用函数的行为只能使用__array_wrap__
和__array_prepare__
进行调整(后者现已移除)。这两个允许更改通用函数的输出类型,但与__array_ufunc__
不同,不允许对输入进行任何更改。希望最终弃用这些方法,但__array_wrap__
也被 NumPy 的其他函数和方法使用,例如squeeze
,因此目前仍然需要它才能实现完整的功能。
从概念上讲,__array_wrap__
“包装操作”,即允许子类设置返回值的类型并更新属性和元数据。让我们通过一个示例展示它是如何工作的。首先,我们回到更简单的示例子类,但使用不同的名称和一些打印语句。
import numpy as np
class MySubClass(np.ndarray):
def __new__(cls, input_array, info=None):
obj = np.asarray(input_array).view(cls)
obj.info = info
return obj
def __array_finalize__(self, obj):
print('In __array_finalize__:')
print(' self is %s' % repr(self))
print(' obj is %s' % repr(obj))
if obj is None: return
self.info = getattr(obj, 'info', None)
def __array_wrap__(self, out_arr, context=None, return_scalar=False):
print('In __array_wrap__:')
print(' self is %s' % repr(self))
print(' arr is %s' % repr(out_arr))
# then just call the parent
return super().__array_wrap__(self, out_arr, context, return_scalar)
我们在新数组的实例上运行一个通用函数。
>>> obj = MySubClass(np.arange(5), info='spam')
In __array_finalize__:
self is MySubClass([0, 1, 2, 3, 4])
obj is array([0, 1, 2, 3, 4])
>>> arr2 = np.arange(5)+1
>>> ret = np.add(arr2, obj)
In __array_wrap__:
self is MySubClass([0, 1, 2, 3, 4])
arr is array([1, 3, 5, 7, 9])
In __array_finalize__:
self is MySubClass([1, 3, 5, 7, 9])
obj is MySubClass([0, 1, 2, 3, 4])
>>> ret
MySubClass([1, 3, 5, 7, 9])
>>> ret.info
'spam'
请注意,通用函数(np.add
)已使用参数self
作为obj
,以及out_arr
作为加法结果(ndarray)调用了__array_wrap__
方法。反过来,默认的__array_wrap__
(ndarray.__array_wrap__
)已将结果转换为MySubClass
类,并调用了__array_finalize__
- 因此复制了info
属性。所有这些都在 C 层面完成。
但是,我们可以做任何我们想做的事情。
class SillySubClass(np.ndarray):
def __array_wrap__(self, arr, context=None, return_scalar=False):
return 'I lost your data'
>>> arr1 = np.arange(5)
>>> obj = arr1.view(SillySubClass)
>>> arr2 = np.arange(5)
>>> ret = np.multiply(obj, arr2)
>>> ret
'I lost your data'
因此,通过为我们的子类定义一个特定的__array_wrap__
方法,我们可以调整通用函数的输出。__array_wrap__
方法需要self
,然后是一个参数 - 它是通用函数或另一个 NumPy 函数的结果 - 以及一个可选参数context。通用函数将此参数作为 3 个元素的元组传递:(通用函数的名称、通用函数的参数、通用函数的域),但其他 NumPy 函数不会传递。尽管如上所示,可以执行其他操作,但__array_wrap__
应该返回其包含类的实例。请参阅掩码数组子类以了解实现。__array_wrap__
始终传递一个 NumPy 数组,该数组可能是也可能不是子类(通常是调用方的子类)。
额外注意事项 - 自定义__del__
方法和 ndarray.base#
ndarray 解决的问题之一是跟踪 ndarray 及其视图的内存所有权。考虑我们已创建了一个 ndarray,arr
,并使用v = arr[1:]
获取了一个切片。这两个对象正在查看相同的内存。NumPy 使用base
属性跟踪特定数组或视图的数据来源。
>>> # A normal ndarray, that owns its own data
>>> arr = np.zeros((4,))
>>> # In this case, base is None
>>> arr.base is None
True
>>> # We take a view
>>> v1 = arr[1:]
>>> # base now points to the array that it derived from
>>> v1.base is arr
True
>>> # Take a view of a view
>>> v2 = v1[1:]
>>> # base points to the original array that it was derived from
>>> v2.base is arr
True
通常,如果数组拥有自己的内存,例如本例中的arr
,则arr.base
将为 None - 有一些例外情况 - 有关更多详细信息,请参阅 NumPy 手册。
base
属性可用于判断我们拥有的是视图还是原始数组。这反过来在我们需要知道在删除子类数组时是否执行某些特定的清理时非常有用。例如,我们可能只想在删除原始数组时执行清理,而不是删除视图。有关此工作原理的示例,请查看numpy._core
中的memmap
类。
子类化和下游兼容性#
在对ndarray
进行子类化或创建模仿ndarray
接口的鸭子类型时,您有责任决定您的 API 与 NumPy 的 API 的一致程度。为了方便起见,许多具有相应ndarray
方法(例如sum
、mean
、take
、reshape
)的 NumPy 函数的工作原理是检查函数的第一个参数是否具有相同名称的方法。如果存在,则会调用该方法,而不是将参数强制转换为 NumPy 数组。
例如,如果您希望您的子类或鸭子类型与 NumPy 的sum
函数兼容,则此对象sum
方法的方法签名应如下所示:
def sum(self, axis=None, dtype=None, out=None, keepdims=False):
...
这与np.sum
的方法签名完全相同,因此现在如果用户在此对象上调用np.sum
,NumPy 将调用对象自身的sum
方法并将上述签名中列出的这些参数传递进来,并且不会引发错误,因为签名彼此完全兼容。
但是,如果您决定偏离此签名并执行以下操作:
def sum(self, axis=None, dtype=None):
...
此对象不再与np.sum
兼容,因为如果您调用np.sum
,它将传递意外的参数out
和keepdims
,从而引发 TypeError。
如果您希望与 NumPy 及其后续版本(可能添加新的关键字参数)保持兼容,但又不想显示 NumPy 的所有参数,则您的函数签名应接受**kwargs
。例如:
def sum(self, axis=None, dtype=None, **unused_kwargs):
...
此对象现在再次与np.sum
兼容,因为任何多余的参数(即不是axis
或dtype
的关键字)都将隐藏在**unused_kwargs
参数中。