6.2. ndarray

ndarray 是在 numpy 中保存数值数据的类型。它二合一:一个单一的紧凑数据块,以及位于该数据块前面的、说明如何读取它的小型描述符。

6.2.1. 盒子内部

数据块首尾相接地保存数组的每个元素,元素之间没有任何额外内容。每个元素占用相同数量的字节 —— uint8 值的数组每个一字节,uint16 每个两字节,float 每个四字节。一个 256 元素的 uint8 数组正好是 256 字节的数据;而同样这 256 个数字放在 Python list 中要占用一千字节 —— 无论该值实际需要多少位,每个元素都要占一个 32 位的槽位。

描述符记录数据块的 含义。五个值就足以描述任何矩形数组,无论它有多少维:

  • dtype —— 元素类型。决定每个元素占用多少字节,以及它能容纳的取值范围(在 数据类型(Dtypes) 中讲述)。

  • itemsize —— 单个元素的字节宽度,由 dtype 推导而来。

  • ndim —— 维数(向量为 1,矩阵为 2,体数据为 3,最多到 4)。

  • shape —— 以元组形式表示的每个维度上的大小。

  • strides —— 如何在数据块中步进以遍历每个轴。在 形状与步幅 中讲述。

就这些。numpy 中每一条快速路径 —— 算术运算、归约、广播、切片 —— 都直接基于这五个值加上数据指针来工作,没有任何逐元素的 Python 开销。

6.2.2. 这种设计带来的好处

由“紧凑数据块 + 小型描述符”推导出三个属性,它们定义了本节其余部分的行为方式。

逐元素运算作为一次调用运行。 两个形状匹配的数组之间的 a + b 会把这两个缓冲区相加并写入第三个,全部在一次库调用内完成。np.sin(a) 对每个元素的正弦做同样的事。算术运算符、比较运算符和按位运算符全都以这种方式工作。

以不同方式看待同一份数据是免费的。 请求数组的某个子区域,或请求同一份数据以另一种形状排布,不会移动任何字节。该操作会返回一个指向 同一 数据块的新描述符。结果被称为 视图 —— 通向同一底层缓冲区的第二个窗口。通过视图进行写入就是写入源数据。

混合形状仍然有效。 较短的数组对较长的数组、一行对一个矩阵、一列对一行 —— numpy 会通过 广播 把它们对齐,这是一小组用于决定哪个短轴拉伸以匹配长轴的规则。这种拉伸是虚拟的;不会复制任何数据。

6.2.3. 这种设计的代价

同样的设计带来两条限制。

每个元素都具有相同的类型。 一个列表可以让一个 int 紧挨着一个 str,再紧挨着一个由三个 int 值组成的列表;而 ndarray 不行。dtype 在构造时就固定下来。数据类型(Dtypes) 页面讲述了 numpy 支持的那一小组类型,以及由固定一种类型而衍生出的规则。

扩展数组并非免费。 列表会在末尾保留备用槽位,并以低代价支持 .appendndarray 的大小恰好是它所需要的;追加意味着要分配一个新的、更大的缓冲区,并把旧内容复制进去。它有意不提供 append() 方法。在摄像头上正确的模式是预先按最终大小分配目标数组,然后 填充 它;性能 讲述了这一技术。

凭借用于数据的紧凑类型化缓冲区、用于元数据的小型描述符,以及三项行为保证(快速的逐元素运算、对同一数据的免复制替代视图,以及可广播的形状),ndarray 成为本章其余部分所依托的基础。数组究竟如何产生 —— 来自字面量、来自预填充的分配、来自外设缓冲区 —— 是接下来要解决的实际问题。