2.3 视图与拷贝的分界线


文档摘要

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

2.3 视图与拷贝的分界线

本节摘要:视图是共享数据块的新"看板",拷贝是独立的新数据块。基本切片返回视图,花式索引、布尔掩码返回拷贝,显式 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 后缀

base 链与内存泄漏的反直觉点

视图会保住大数组不能释放:

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 节)。所以高频访问的非规整视图是"省了一次拷贝、赔上每次访问",要按使用频率算总账。

本节要点回顾

  • 定义:视图共享数据块(新 shape 与 strides、旧内存),拷贝另开新内存
  • 分界:基本切片/转置/reshape 给视图;花式索引/布尔掩码/astype/copy 给拷贝
  • 验证:np.shares_memory 或 base 属性,一行代码出结论
  • 事故模式:改切片污染原数组、函数内原地改参穿透调用方、小视图拖住大内存
  • 纪律:库函数不动入参;要备份就显式 copy,别用等号

第 3 章暂别这块"看板理论",回到数组的出生现场:不同创建方式在 dtype 与内存上各有默认行为,其中埋着静默转型的第一颗雷。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U