2.3 视图与拷贝的分界线 本节摘要:视图是共享数据块的新"看板",拷贝是独立的新数据块。基本切片返回视图,花式索引、布尔掩码返回拷贝,显式 copy 永远是拷贝。判断方法是 np.sharesmemory 或 base 属性。这条分界线解释了 NumPy 一半的经典事故:改切片污染原数据、函数内改参影响调用方、别名判断失灵。 一行代码引发的血案 先看一个每个 NumPy 用户迟早会踩的坑: 三个变量,一个数据块。赋值不复制任何东西(backup 和 scores 是同一个对象的两个名字);切片切出的是视图——新的 shape 与 strides,但 data 指针指向同一块内存。这不是 bug,是设计:视图让切片、转置、reshape 几乎零成本,代价是你必须知道分界线在哪。
本节摘要:视图是共享数据块的新"看板",拷贝是独立的新数据块。基本切片返回视图,花式索引、布尔掩码返回拷贝,显式 copy 永远是拷贝。判断方法是 np.shares_memory 或 base 属性。这条分界线解释了 NumPy 一半的经典事故:改切片污染原数据、函数内改参影响调用方、别名判断失灵。
先看一个每个 NumPy 用户迟早会踩的坑:
import numpy as np scores = np.array([88, 92, 79, 95, 60]) backup = scores # 想"备份"一下 top = scores[:3] # 取前三名成绩 top[0] = 0 # 只是改个临时变量? print("scores:", scores) # [ 0 92 79 95 60] —— 原数组变了! print("backup:", backup) # [ 0 92 79 95 60] —— "备份"也变了
三个变量,一个数据块。赋值不复制任何东西(backup 和 scores 是同一个对象的两个名字);切片切出的是视图——新的 shape 与 strides,但 data 指针指向同一块内存。这不是 bug,是设计:视图让切片、转置、reshape 几乎零成本,代价是你必须知道分界线在哪。

哪些操作给视图、哪些给拷贝?工程上够用的速查表:
| 操作 | 返回 | 代价 |
|---|---|---|
| 赋值 b = a | 同一对象 | 零 |
| 基本切片 a[1:5:2]、a[:, 0] | 视图 | 纳秒级 |
| reshape / ravel(可视图时) | 视图 | 纳秒级 |
| 转置 a.T、swapaxes | 视图 | 纳秒级 |
| 花式索引 a[[0, 2]] | 拷贝 | 与元素数成正比 |
| 布尔掩码 a[a > 0] | 拷贝 | 与元素数成正比 |
| a.copy()、np.array(a) | 拷贝 | 与元素数成正比 |
| astype 换类型 | 拷贝 | 必然拷贝(字节宽度变了) |
速查表可以背,但更要会验证。两个工具:
import numpy as np a = np.arange(12).reshape(3, 4) v = a[0, 1:3] # 基本切片 c = a[[0, 1]] # 花式索引 print(np.shares_memory(v, a)) # True —— 视图 print(np.shares_memory(c, a)) # False —— 拷贝 # base 属性:视图的 base 指向来源,拷贝的 base 是 None print(v.base is a) # True print(c.base) # None
背景:一个数据清洗函数,把负值替换为零,测试通过后上线。三个月后有人发现原始测量数据"莫名"被改。操作:看函数实现——
import numpy as np def clean(data): data[data < 0] = 0.0 # 原地修改! return data raw = np.array([3.2, -1.1, 0.5, -2.7]) cleaned = clean(raw) print("cleaned:", cleaned) # [3.2 0. 0.5 0. ] print("raw :", raw) # [3.2 0. 0.5 0. ] —— 原始数据被毁
结果:raw 与 cleaned 指向同一块内存,"清洗"直接写进了原始档案。解读:函数签名收到的 data 只是又一个视图入口,原地赋值穿透了所有名字。修复有两条路,取舍不同:
# 修复一:入口先拷贝,函数绝不影响外部(安全,多一次内存) def clean_safe(data): data = data.copy() data[data < 0] = 0.0 return data # 修复二:约定原地版并命名明示(省内存,靠纪律) def clean_inplace(data): """会修改传入数组,调用方自负""" data[data < 0] = 0.0 return data
变式:Pandas 时代很多人习惯链式写法,NumPy 没有"设置副本开关",只能靠 copy 纪律。我的团队约定:对外提供的库函数一律不改传入数组,除非函数名带 inplace 后缀。
视图会保住大数组不能释放:
import numpy as np big = np.random.rand(100_000_000) # 约 800MB tiny = big[10:15] # 只取 5 个数 print(tiny.base is big) # True del big # 以为释放了? # 800MB 仍被 tiny 的 base 链攥着,进程内存不降 tiny = tiny.copy() # 真正切断引用后,大块才能被回收
从大数组上切一小片长期持有,是内存"下不来"的常见原因。排查方法是顺着 base 链找源头:t = tiny; while t.base is not None: t = t.base。
⚠️ 常见坑:np.array(a) 与 a.copy() 都是深拷贝,但 np.array(a, copy=False) 会尽量返回视图——参数名不同行为天差地别,review 代码时要盯紧 copy 开关。
问:函数返回切片,调用方修改会影响我的原数组吗?
答:会,只要它没有显式 copy。读第三方代码时看到 return self.data[start:end],就要意识到调用方拿到的是原数据的窗口,任何写入都会穿透。
问:怎么一眼看出手里的数组是视图?
答:看 base。视图的 base 非 None 且指向来源数组;多次切片后 base 仍指向最初的源头而非中间视图,顺着 base 链能一路追到根。
问:视图会让代码变快还是变慢?
答:创建层面永远变快(零拷贝),访问层面取决于步长是否规整(2.1 节)。所以高频访问的非规整视图是"省了一次拷贝、赔上每次访问",要按使用频率算总账。
第 3 章暂别这块"看板理论",回到数组的出生现场:不同创建方式在 dtype 与内存上各有默认行为,其中埋着静默转型的第一颗雷。