改这个,那个会不会跟着变
卷 I 的最后一章,处理一个只有两种可能、却极难靠肉眼判断的问题:我手上这个数组,和它是从哪儿切出来的那个,是不是同一块内存?答案决定了「改一个会不会影响另一个」,而两种情况打印出来完全一样。这条区别在 numpy 里造成困惑,到了 pandas 里就升级成了那个人人都遇到过、人人都没看懂的警告。
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 是什么?
一条界线,两边行为完全相反
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.T、a.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 += 1 和 x = 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 的
subList与toList。subList返回视图(改它会改原列表,原列表结构变了它还会抛ConcurrentModificationException),toList返回副本。同一条界线。 - Go 的 slice 和它的
cap。s[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]] 的 shape、dtype、打印结果完全相同,唯一的差别在 b.base 是不是 None。所以这类 bug 的典型症状是:函数 A 改了自己的局部变量,函数 B 里的数据莫名变了。
判据:函数参数是数组时,如果你打算改它,要么在文档里写明「会就地修改」,要么进门先 arr = arr.copy()。numpy 生态里的惯例是显式提供 out= 或 inplace= 参数,而不是偷偷改——你自己写的函数也该照这个规矩来。
正确答案是 C:[0 1 999 3 4 5 6 7 8 9]。切片改到了 a,花式索引没有。
list 的行为。lst[2:6] 复制,arr[2:6] 不复制。同一个语法,两种语义,而且没有任何视觉提示。
B 「两个都改到了」——如果花式索引也返回视图,这就对了。但花式索引的下标可以是任意顺序、可以重复、可以越界检查后重排,没有任何一组固定的 stride 能描述它,所以只能复制。
D 「只有花式索引改到了」——方向正好反了。可以这样记:写起来越花哨的取法,越可能是副本;朴素的冒号切片才是那个会「传染」的。
再补一条容易被忽略的:c[1] = 888 这一行本身没有任何问题,c 确实变了。问题在于你以为你在改 a。这类 bug 的可怕之处正在于它两边都没报错。
.copy())。
没人。两种结果的 shape、dtype、打印全都一样。唯一的检查手段是 np.shares_memory() 或 .base。这也是为什么 pandas 3 干脆废掉了这个歧义——见第 7 章。
这一章的一句话
能用一组 stride 描述的取法给你视图,不能的给你副本;两者长得一模一样,行为正好相反——而这条区别不只是「会不会改到原数组」,还包括「十个元素能不能扣住三百八十一兆内存」。
卷 I 到此结束。你现在手上有一条列:一块连续的字节,一张写着 shape/strides/dtype 的说明书,一套叫广播的对齐规则,和一条视图与副本的界线。
下一卷开始给这条列贴名字。pandas 做的事听起来只是「给行和列起个名」,但这个改动的后果比它听起来大得多:两个各有三个数的 Series 相加,结果有四行,其中两行是 NaN。而这不是 bug,是 pandas 与 numpy 最根本的那条分界线。