超越基本内容#

发现之旅不在于探寻新大陆,而在于拥有
新的视角。
马塞尔·普鲁斯特
发现就是看到每个人都看到过的东西,并且思考别人
没有思考过的事情。
阿尔伯特·圣捷尔吉

遍历数组中的元素#

基本遍历#

常见的算法需求之一是能够遍历多维数组中的所有元素。数组迭代器对象可以轻松地以一个通用的方法完成此任务,适用于任何维度的数组。当然,如果你知道要使用的维度数,则可以随时编写嵌套 for 循环来完成遍历。但是,如果你要编写可用于任何维度数的代码,则可以使用数组迭代器。当访问数组的 .flat 属性时,将返回数组迭代器对象。

基本用法是调用 PyArray_IterNew ( array ),其中 array 是 ndarray 对象(或其子类之一)。返回的对象是数组迭代器对象(ndarray 的 .flat 属性返回的相同对象)。这个对象通常会被转换为 PyArrayIterObject*,以便于访问其成员。唯一需要的是成员 iter->size,其中包含数组的总大小、iter->index,其中包含当前一维数组的索引和 iter->dataptr,其中包含指向当前数组元素数据的指针。有时访问 iter->ao 也会很有用,此指针指向基础 ndarray 对象。

在处理数组当前元素中的数据后,可以使用宏 PyArray_ITER_NEXT ( iter ) 获取数组的下一个元素。迭代总是以 C 样式的连续方式进行(最后一个索引变化最快)。PyArray_ITER_GOTO ( iter, destination ) 可用于跳转到数组中的特定点,其中 destination 是 npy_intp 数据类型数组,其中有足够的空间来处理基础数组中的维度数。偶尔可以 PyArray_ITER_GOTO1D ( iter, index ) 会很有用,它将跳转到由 index 值给定的 1-d 索引。但是,最常见的用法在下例中给出。

PyObject *obj; /* assumed to be some ndarray object */
PyArrayIterObject *iter;
...
iter = (PyArrayIterObject *)PyArray_IterNew(obj);
if (iter == NULL) goto fail;   /* Assume fail has clean-up code */
while (iter->index < iter->size) {
    /* do something with the data at it->dataptr */
    PyArray_ITER_NEXT(it);
}
...

您还可以使用 PyArrayIter_Check ( obj ) 来确保您有一个迭代器对象和 PyArray_ITER_RESET ( iter ) 来将迭代器对象重置为数组的开头。

在此处应该强调的是,如果您的数组已经是连续的,则可能不需要数组迭代器(使用数组迭代器会起作用,但会比您可以编写的最快代码慢)。数组迭代器的主要目的是封装对具有任意步长的 N 维数组的迭代。它们在 NumPy 源代码本身的许多地方使用。如果您已经知道您的数组是连续的(Fortran 或 C),则只需将元素大小添加到一个运行指针变量中即可高效地遍历数组。换句话说,对于连续的情况,以下代码可能会更快(假设为双精度)。

npy_intp size;
double *dptr;  /* could make this any variable type */
size = PyArray_SIZE(obj);
dptr = PyArray_DATA(obj);
while(size--) {
   /* do something with the data at dptr */
   dptr++;
}

遍历除一个轴之外的所有轴#

一种常见的算法是循环遍历数组的所有元素,并通过发出函数调用对每个元素执行一些函数。由于函数调用可能很耗时,因此加速此类算法的一种方法是编写函数以使其采用数据向量,然后编写迭代,以便每次对整个维度的数据执行函数调用。这会增加每个函数调用所做的工作量,从而将函数调用开销减少到总时间的一小部分。即使没有函数调用执行循环内部,也可以通过在具有最高元素数的维度上执行内部循环来获得优势,以利用微处理器上的速度增强功能,这些微处理器使用流水线来增强基本操作。

PyArray_IterAllButAxis ( array, &dim ) 构造一个迭代器对象,该对象经过修改,以便它不会在维度 dim 上迭代。对这个迭代器对象的唯一限制是 PyArray_ITER_GOTO1D ( it, ind ) 宏无法使用(因此如果您将此对象传回 Python,则扁平索引也无法使用——因此您不应该这样做)。请注意,从这个例程返回的对象通常仍然被强制转换为 PyArrayIterObject *。所做的所有事情都是修改返回的迭代器的步长和维度,以模拟在 array[…,0,…] 上迭代,其中 0 位于\(\textrm{dim}^{\textrm{th}}\) 维度。如果维度为负,则会找到并使用具有最大轴的维度。

遍历多个数组#

很多时候,需要同时遍历几个数组。通用函数就是这种情况的一个示例。如果您只想遍历具有相同形状的数组,那么创建多个迭代器对象就是标准操作。例如,下面的代码遍历了两个假设为相同形状和大小的数组(实际上,obj1 至少必须具有与 obj2 一样多的元素总数)

/* It is already assumed that obj1 and obj2
   are ndarrays of the same shape and size.
*/
iter1 = (PyArrayIterObject *)PyArray_IterNew(obj1);
if (iter1 == NULL) goto fail;
iter2 = (PyArrayIterObject *)PyArray_IterNew(obj2);
if (iter2 == NULL) goto fail;  /* assume iter1 is DECREF'd at fail */
while (iter2->index < iter2->size)  {
    /* process with iter1->dataptr and iter2->dataptr */
    PyArray_ITER_NEXT(iter1);
    PyArray_ITER_NEXT(iter2);
}

在多个数组上广播#

当多个数组参与一个操作时,您可能希望使用数学运算(即 ufunc)使用的相同广播规则。这可以使用 PyArrayMultiIterObject 轻松完成。这是 NumPy.broadcast Python 命令返回的对象,用 C 语言使用它简直太容易了。使用函数 PyArray_MultiIterNew ( n, ...),其中 n 个输入对象代替了 ...。输入对象可以是数组或任何可以转换为数组的内容。将返回对 PyArrayMultiIterObject 的指针。广播已经完成,从而调整迭代器,所以进到每个数组的下一个元素所需要做的就是对每个输入调用 PyArray_ITER_NEXT。此增量由 PyArray_MultiIter_NEXT ( obj ) 宏(可以将多重迭代器 obj 作为 PyArrayMultiIterObject*PyObject* 来处理)。可以利用以下方法找到输入序号 i 中的数据:PyArray_MultiIter_DATAobj, i)。接下来是一个关于如何使用此特性的示例。

mobj = PyArray_MultiIterNew(2, obj1, obj2);
size = mobj->size;
while(size--) {
    ptr1 = PyArray_MultiIter_DATA(mobj, 0);
    ptr2 = PyArray_MultiIter_DATA(mobj, 1);
    /* code using contents of ptr1 and ptr2 */
    PyArray_MultiIter_NEXT(mobj);
}

函数 PyArray_RemoveSmallest ( multi ) 可用于获取多重迭代器对象并调整所有迭代器,以便迭代不会在最大维度上发生(它将该维度的大小设为 1)。使用指针进行循环的代码也很有可能需要每个迭代器的跨距数据。此信息存储在 multi->iters[i]->strides 中。

在 NumPy 源代码中有多个使用多重迭代器的示例,因为它使得 N 维广播代码非常容易编写。浏览源代码以获取更多示例。

用户定义的数据类型#

NumPy 带有 24 个内置数据类型。虽然这涵盖了绝大多数可能用例,但可以想象,用户可能需要一个附加的数据类型。现在可为 NumPy 系统添加附加的数据类型提供了一些支持。此附加数据类型将与常规数据类型非常类似,但 ufunc 必须注册一维循环才能单独处理它。此外,检查是否可以将其他数据类型“安全”地转换为此新类型或从此新类型转换时,除非您还注册了新数据类型可以转换到的类型,否则程序将始终返回“可以转换”。

NumPy 源代码包含一个自定义数据类型的示例,作为其测试套件的一部分。源代码目录中的文件 _rational_tests.c.src numpy/_core/src/umath/ 中包含一个数据类型的实现,它将有理数表示为两个 32 位整数的比率。

添加新数据类型#

要开始使用新数据类型,您需要首先定义一个新的 Python 类型来容纳新数据类型的标量。如果新类型具有二进制兼容的布局,则继承一个数组标量是可接受的。这会使您的新数据类型具有数组标量的函数和属性。新数据类型的大小必须是固定的(如果您想定义一个需要灵活表示的数据类型,例如一个可变精度数字,则使用指向对象的指针作为数据类型)。该新 Python 类型的对象结构的内存布局必须是 PyObject_HEAD 后跟数据类型所需的大小固定的内存。例如,此新 Python 类型的适合结构如下所示:

typedef struct {
   PyObject_HEAD;
   some_data_type obval;
   /* the name can be whatever you want */
} PySomeDataTypeObject;

在你定义了新的 Python 类型对象后,你必须定义一个新的 PyArray_Descr 结构,其 typeobject 成员将包含一个指向你刚定义的数据类型的指针。此外,必须定义“.f”成员中的必需函数:nonzero、copyswap、copyswapn、setitem、getitem 和 cast。但是,你在“.f”成员中定义的函数越多,新数据类型就更有用。非常重要的是将未使用函数初始化为 NULL。这可以使用 PyArray_InitArrFuncs (f) 来实现。

一旦创建了新的 PyArray_Descr 结构并填充所需信息和有用函数,你就可以调用 PyArray_RegisterDataType (new_descr)。此调用的返回值是一个整数,为你提供唯一 type_number,用于指定你的数据类型。模块应存储此类型号并使其可用,以便其他模块可以使用它来识别你的数据类型。

请注意,此 API 本质上是不线程安全的。有关 NumPy 中线程安全性的更多详细信息,请参见 thread_safety

注册转换函数#

你可能希望允许将内置(和其他用户定义的)数据类型自动转换为你的数据类型。为了使此操作成为可能,你必须向要能够从中转换的数据类型注册一个转换函数。这需要为每个你想要支持的转换编写底层转换函数,然后使用数据类型描述符注册这些函数。底层转换函数的签名为。

void castfunc(void *from, void *to, npy_intp n, void *fromarr, void *toarr)#

n 个元素从一种类型 from 转换为另一种类型 to。要从中转换的数据位于一段连续内存中,该段内存由 from 正确交换并对齐。用作转换目标的缓冲区也是连续、正确交换和对齐的。fromarr 和 toarr 参数仅应用于可变元素大小的数组(字符串、unicode、void)。

一个示例 castfunc 为

static void
double_to_float(double *from, float* to, npy_intp n,
                void* ignore1, void* ignore2) {
    while (n--) {
          (*to++) = (double) *(from++);
    }
}

此代码可注册将双精度数转换为浮点数,使用方法如下

doub = PyArray_DescrFromType(NPY_DOUBLE);
PyArray_RegisterCastFunc(doub, NPY_FLOAT,
     (PyArray_VectorUnaryFunc *)double_to_float);
Py_DECREF(doub);

注册强制转换规则#

默认情况下,假定所有用户定义的数据类型都不适合安全地转换为任何内置数据类型。此外,不假定内置数据类型适合安全地转换为用户定义的数据类型。此限制了用户定义的数据类型参与 ufunc 和 NumPy 中自动强制转换等其他时间时所用的强制转换系统。可以将数据类型注册为适合从特定数据类型对象安全地转换,从而改变此限制。PyArray_RegisterCanCast(from_descr、totype_number、scalarkind)函数应用于指定可以将数据类型对象 from_descr 转换为具有类型编号 totype_number 的数据类型。如果您不是在尝试改变标量强制转换规则,则对 scalarkind 参数使用 NPY_NOSCALAR

如果您想让您的新数据类型也能共享标量强制转换规则,则需要指定数据类型对象中的“ .f”成员中的 scalarkind 函数,以返回新数据类型应视为的标量类型(该标量的值可用于该函数)。然后,您可以注册可转换为各标量类型的的数据类型,此标量类型可能由您的用户定义的数据类型返回。如果您没有注册标量强制转换处理,那么您的所有用户定义的数据类型都将视为 NPY_NOSCALAR

注册 ufunc 循环#

您还希望为您的数据类型注册低级 ufunc 循环,以便可以无缝地对您的数据类型的 ndarray 应用数学计算。使用完全相同的 arg_types 签名注册新循环时,将自动替换以前针对该数据类型注册的任何循环。

在为一维循环注册一个 ufunc 之前,必须先创建 ufunc。然后使用所需的信息调用 PyUFunc_RegisterLoopForType (…)。如果进程成功,则此函数的返回值为 0,如果不成功,则返回 -1,同时设置错误条件。

在 C 中将 ndarray 子类型化#

自 2.2 以来一直在 Python 中潜伏的一个使用较少的功能是按子类型分类 C 中的类型。此功能是将 NumPy 以基于早已在 C 中的 Numeric 代码库为基础的重要原因之一。在 C 中使用子类型可以更大程度地灵活地管理内存。即使你仅对如何为 Python 创建新类型有基本了解,在 C 中对子类型进行分类也不难。虽然最简单的方法是从单个父类型对子类型进行分类,但也可以从多个父类型对子类型进行分类。在 C 中,多重继承通常不如在 Python 中有用,因为对 Python 子类型的限制在于它们的二进制兼容性内存布局。或许出于此原因,从单个父类型对子类型进行分类会更容易一些。

与 Python 对象对应的所有 C 结构都必须以 PyObject_HEAD(或 PyObject_VAR_HEAD)开头。同样,任何子类型必须具有的 C 结构都必须以与父类型(或在多重继承的情况下为所有父类型)完全相同的内存布局开头。原因是 Python 可能会尝试以父结构的方式访问子类型结构的成员(它会将给定的指针转换为指向父结构的指针,然后取消引用其中一个成员)。如果内存布局不兼容,则此尝试会导致行为不可预测(最终导致内存泄露和程序崩溃)。

PyObject_HEAD 中的一个元素是一个指向类型对象结构的指针。创建一个新的 Python 类型通过创建一个新的类型对象结构以及通过填充它来用函数和指针描述类型的所需行为。通常,还会创建一个新的 C 结构来包含类型每个对象所需的实例特定信息。例如,&PyArray_Type 是一个指向 ndarray 类型对象表的指针,而 PyArrayObject* 变量是一个指向特定的 ndarray 实例的指针(ndarray 结构中的一个成员反过来是一个指向类型对象表 &PyArray_Type 的指针)。最后必须为每个新的 Python 类型调用 PyType_Ready(<类型对象指针>)。

创建子类型#

要创建子类型,必须遵循类似的过程,但只有行为不同的行为才需要在类型对象结构中添加新条目。所有其他条目都可以为 NULL,并且将由 PyType_Ready 使用父类型中适当的函数来填充。具体来说,要在 C 中创建子类型,请执行以下步骤

  1. 如需,创建一个新的 C 结构来处理类型的每个实例。典型的 C 结构如下

    typedef _new_struct {
        PyArrayObject base;
        /* new things here */
    } NewArrayObject;
    

    请注意,完整 PyArrayObject 用作第一个条目是为了确保新类型实例的二进制布局与 PyArrayObject 相同。

  2. 使用指向新函数的指针填写一个新的 Python 类型对象结构,该结构将覆盖默认行为,同时让任何应该保持不变的函数保持未填充(或为 NULL)。tp_name 元素应有所不同。

  3. 使用指向(主)父类型对象的指针填充新类型对象结构的 tp_base 成员。对于多重继承,还要使用元组填充 tp_bases 成员,该元组包含所有父对象,它们应按用于定义继承的顺序排列。请记住,所有父类型对于多重继承都必须具有相同的 C 结构,才能正常工作。

  4. 调用 PyType_Ready (<pointer_to_new_type>)。如果此函数返回负数,则表示发生故障,且未初始化类型。否则,类型已准备好使用。通常,将新类型引用放入模块词典中非常重要,以便可以从 Python 访问它。

可以通过阅读 PEP 253(可在 https://www.pythonlang.cn/dev/peps/pep-0253 获得)了解有关在 C 中创建子类型系列的更多信息。

ndarray 子类型系列的具体特性#

数组使用一些特殊的方法和属性来方便子类型与基本 ndarray 类型的交互操作。

__array_finalize__ 方法#

ndarray.__array_finalize__#

ndarray 的多个数组创建函数允许指定要创建的特定子类型。这允许在许多例程中无缝处理子类型。但是,当以这种方式创建子类型时,__new__ 方法和 __init__ 方法均不会被调用。相反,将分配子类型并填充适当的实例结构成员。最后,在对象词典中查找 __array_finalize__ 属性。如果它存在且不为 None,则它可以是包含指向 PyArray_FinalizeFunc 的指针的 PyCapsule,也可以是采用单个参数(可以为 None)的方法

如果 __array_finalize__ 属性是 PyCapsule,则指针必须是具有以下签名的函数的指针

(int) (PyArrayObject *, PyObject *)

第一个参数是新创建的子类型。第二个参数(如果非 NULL)是“父”数组(如果该数组是使用切片或其中存在明显可区分父元素的其他运算创建的)。此例程可以执行任何它想要的操作。它应该在发生错误时返回 -1,在其他情况下返回 0。

如果 __array_finalize__ 属性既不是 None,也不是 PyCapsule,则它必须是一个以父数组作为参数(如果不存在父数组,则该参数可能是 None)并返回 nothing 的 Python 方法。该方法中的错误将被捕获并处理。

__array_priority__ 属性#

ndarray.__array_priority__#

该属性允许简单但灵活地确定在涉及两个或多个子类型的运算发生时应该考虑哪一个子类型"优先"。在使用不同子类型的操作中,__array_priority__ 属性最大的子类型将决定输出的子类型。如果两个子类型具有相同的 __array_priority__,则第一个参数的子类型将决定输出。默认 __array_priority__ 属性对基本 ndarray 类型返回 0.0,对子类型返回 1.0。此属性还可以由不是 ndarray 的子类型的对象定义,并且可用于确定应该为返回输出调用哪一个 __array_wrap__ 方法。

__array_wrap__ 方法#

ndarray.__array_wrap__#

任何类或类型都可以定义此方法,该方法应采用 ndarray 作为参数,并返回一个该类型的实例。这可视为 __array__ 方法的反向操作。ufunc(和其他 NumPy 函数)使用此方法可允许其他对象通过。对于 Python >2.4,它还可用于编写一个装饰器,用于将仅适用于 ndarray 的函数转换为适用于具有 __array____array_wrap__ 方法的任何类型的函数。