卷 I · 一条列CH 05深度 5/23

改这个,那个会不会跟着变

卷 I 的最后一章,处理一个只有两种可能、却极难靠肉眼判断的问题:我手上这个数组,和它是从哪儿切出来的那个,是不是同一块内存?答案决定了「改一个会不会影响另一个」,而两种情况打印出来完全一样。这条区别在 numpy 里造成困惑,到了 pandas 里就升级成了那个人人都遇到过、人人都没看懂的警告。

切片是视图花式索引是副本10 个元素锁住 381 MB

▷ 先猜一下
a = np.arange(10)          # [0 1 2 3 4 5 6 7 8 9]
b = a[2:6]                 # 切片
c = a[[2, 3, 4, 5]]        # 花式索引
b[0] = 999
c[1] = 888

问:此时 a 是什么?

A [0 1 2 3 4 5 6 7 8 9]——b 和 c 都是副本,a 没变 B [0 1 999 888 4 5 6 7 8 9]——两个都改到了 a 上 C [0 1 999 3 4 5 6 7 8 9]——只有切片改到了 a 上 D [0 1 2 888 4 5 6 7 8 9]——只有花式索引改到了 a 上

一条界线,两边行为完全相反

numpy 的规则很干脆,而且是可以推理出来的,不用背:

◆ 主线

能用「一组固定的 stride」描述出来的取法,返回视图(共用内存);描述不出来的,返回副本。

a[2:6] 能:从第 2 格起,每次跳 8 字节,取 4 个。改个头就行。
a[[2, 3, 4, 5]] 不能——虽然这次的下标恰好等距,但下标列表可以是任意的[7, 0, 3] 也合法),numpy 不会为「这次碰巧等距」开特例。所以它老老实实复制。

这条规则和第 2 章那张说明书是同一件事:视图 = 换一张说明书,副本 = 换一块字节。

把常见取法列个表。这张表值得贴在手边:

写法叫什么共用内存改它会不会影响原数组
a[2:6]a[::2]a[:, 1]基本切片
a.Ta.reshape(...)(可行时)形状变换
a.ravel()摊平(能共享就共享)
a[[2, 3, 4, 5]]花式索引不会
a[a > 5]布尔索引不会
a.flatten()a.copy()显式复制不会

有两个不用记的裁判:

np.shares_memory(a, b)     # 这两个是不是碰同一块字节
b.base                     # 视图会指向它的「主人」;副本是 None
b.flags.owndata            # 这块内存是不是它自己的

一个特别容易漏的变体:就地改,还是重新绑定

上面讲的是「取出来的东西」。还有一半的事故来自「写回去的方式」:

x = np.arange(5); y = x
x += 1                     # 就地加:改的是那块字节
print(y)                   # [1 2 3 4 5]      ← y 跟着变了

x = np.arange(5); y = x
x = x + 1                  # 新建一个数组,把 x 重新指过去
print(y)                   # [0 1 2 3 4]      ← y 没变

x += 1x = x + 1 在 Python 的整数上完全等价,在 numpy 数组上不等价。前者调用 __iadd__,在原地改字节;后者算出一个新数组再重新绑定名字。

顺带解释一个常见的困惑。这个能改到原数组:

a[a > 5] += 1              # 有效!a 真的变了

看起来和「布尔索引返回副本」矛盾,其实不然:这一行走的是 a.__setitem__路径),numpy 直接按掩码写回原数组,中间那个「副本」根本不存在。而下面这个就不行:

b = a[a > 5]              # 这里真的复制了
b += 1                    # 改的是副本,a 纹丝不动

一步写完 = 改原数组;先取出来再改 = 改副本。记住这句话,pandas 那个警告就已经理解了一半——第 7 章会接上这条线。

另一半的账:视图会把整块内存扣住不放

这一条比上面所有的都安静,因为它不会算错任何数,只会让你的内存莫名其妙下不去。

huge = np.arange(50_000_000, dtype=np.float64)   # 381 MB
tiny = huge[:10]                                  # 只要前 10 个
del huge                                          # 以为释放了

本机实测:

tiny 有几个元素        10
tiny.flags.owndata     False        ← 它没有自己的内存
tiny.base.nbytes       381 MB       ← 它扣着那 381 MB

huge[:10].copy() 之后
  .flags.owndata       True         ← 这个才真的只有 80 字节

del huge 只删掉了那个名字。只要还有一个视图活着,底下那块 381 MB 的 buffer 就不会被回收。十个元素扣住三百多兆——这在「读一个大文件、只留一小段」的代码里特别常见。

解法一行:确定只要一小段的时候,写 .copy()这是全书少数几个「主动多复制一次反而更省」的地方。

✎ 术语正名

视图(view)和副本(copy)这对词,在数据库、图形、UI 框架里各有各的含义,这里说的是最朴素的那个:两个变量指着同一块内存,还是各指一块。

更值得辨析的是「浅拷贝/深拷贝」。Java、Kotlin、JavaScript 里的浅拷贝说的是「对象层级只复制一层,内部引用共享」;numpy 这里没有层级——一个数组要么共享那块字节,要么不共享,没有中间状态。所以别把 list 的浅拷贝直觉带过来。

还有一个词:基本切片(basic slicing,用冒号)与高级索引(advanced indexing,用列表或布尔数组)。numpy 官方文档就是用这两个词区分视图和副本的,它们是这一章那条界线的正式名字。

⌨ 自己跑一遍

十行看完全部:

import numpy as np

a = np.arange(10)
b, c = a[2:6], a[[2, 3, 4, 5]]
print(np.shares_memory(a, b), np.shares_memory(a, c))   # True False
b[0] = 999; c[1] = 888
print(a)          # [  0   1 999   3   4   5   6   7   8   9]   ← 只有 b 改到了 a

x = np.arange(5); y = x; x += 1;      print(y)   # [1 2 3 4 5]
x = np.arange(5); y = x; x = x + 1;   print(y)   # [0 1 2 3 4]

a = np.arange(10)
a[a > 5] += 1;   print(a)             # 有效,一步写完
d = a[a > 5]; d += 1; print(a)        # 无效,先取出来了

再看那个扣内存的(会占约 400 MB,跑完就退出):

huge = np.arange(50_000_000, dtype=np.float64)
tiny = huge[:10]
del huge
print(tiny.flags.owndata, tiny.base.nbytes / 1024**2, "MB")   # False 381.0 MB
safe = tiny.copy()
print(safe.flags.owndata, safe.base)                          # True None

python3 -c "import numpy as np;a=np.arange(10);print(np.shares_memory(a,a[2:6]),np.shares_memory(a,a[[2,3,4,5]]))"

养成一个习惯:拿不准的时候不要推理,直接 np.shares_memory(a, b)。它是这一章唯一需要记的 API。

▸ 在现实里
  • Java 的 String.substring,JDK 6 与 JDK 7 的那次改动。JDK 6 的 substring 返回一个共享原字符数组的视图——于是「从一个 10 MB 的字符串里取 5 个字符」会让那 10 MB 永远不被回收,这是当年著名的内存泄漏。JDK 7 改成了复制。和这一章那个 381 MB 是同一个 bug,同一个解法。
  • Kotlin 的 subListtoListsubList 返回视图(改它会改原列表,原列表结构变了它还会抛 ConcurrentModificationException),toList 返回副本。同一条界线。
  • Go 的 slice 和它的 caps[2:6] 共享底层数组,append 到超过容量才会复制——这是 Go 面试题的常客,本质也是这一章。
  • 数据库视图与物化视图。普通 view 每次查询实时算(跟着底表变),materialized view 存一份快照(不跟着变)。取舍完全一样:视图省空间但被底层牵着,副本独立但要占地方、还会过期。
✗ 这个直觉是错的

「取一部分出来就是复制一份,改它当然不影响原来的。」

这条直觉来自 Python 的 list——lst[2:6] 确实是复制。numpy 反了过来:基本切片返回视图,改它就是改原数组。这是从 Python 转到 numpy 时最容易带错的一个习惯。

而且它没有任何外部特征:b = a[2:6]c = a[[2,3,4,5]]shapedtype、打印结果完全相同,唯一的差别在 b.base 是不是 None。所以这类 bug 的典型症状是:函数 A 改了自己的局部变量,函数 B 里的数据莫名变了。

判据:函数参数是数组时,如果你打算改它,要么在文档里写明「会就地修改」,要么进门先 arr = arr.copy()numpy 生态里的惯例是显式提供 out=inplace= 参数,而不是偷偷改——你自己写的函数也该照这个规矩来。

◇ 揭晓

正确答案是 C[0 1 999 3 4 5 6 7 8 9]。切片改到了 a,花式索引没有。

A 「都是副本」——这是 Python list 的行为。lst[2:6] 复制,arr[2:6] 不复制。同一个语法,两种语义,而且没有任何视觉提示。 B 「两个都改到了」——如果花式索引也返回视图,这就对了。但花式索引的下标可以是任意顺序、可以重复、可以越界检查后重排,没有任何一组固定的 stride 能描述它,所以只能复制。 D 「只有花式索引改到了」——方向正好反了。可以这样记:写起来越花哨的取法,越可能是副本;朴素的冒号切片才是那个会「传染」的。 再补一条容易被忽略的:c[1] = 888 这一行本身没有任何问题,c 确实变了。问题在于你以为你在改 a。这类 bug 的可怕之处正在于它两边都没报错
⌗ 交接单
一个数组,加上一次「取一部分」的操作。 一个新数组。它要么和原数组共用字节(基本切片、转置、可行的 reshape),要么是一份独立的复制(花式索引、布尔索引、.copy())。 没人。两种结果的 shapedtype、打印全都一样。唯一的检查手段是 np.shares_memory().base。这也是为什么 pandas 3 干脆废掉了这个歧义——见第 7 章。

这一章的一句话

能用一组 stride 描述的取法给你视图,不能的给你副本;两者长得一模一样,行为正好相反——而这条区别不只是「会不会改到原数组」,还包括「十个元素能不能扣住三百八十一兆内存」。

卷 I 到此结束。你现在手上有一条列:一块连续的字节,一张写着 shapestridesdtype 的说明书,一套叫广播的对齐规则,和一条视图与副本的界线。

下一卷开始给这条列贴名字。pandas 做的事听起来只是「给行和列起个名」,但这个改动的后果比它听起来大得多:两个各有三个数的 Series 相加,结果有四行,其中两行是 NaN。而这不是 bug,是 pandas 与 numpy 最根本的那条分界线。