一句话结论:memoryview 不是把数据复制一份再给你,它只是给同一块内存发了张「带格式的访问凭证」。所以改视图等于改原对象,改原对象视图立刻能看见;代价是这块内存在视图活着的时候不许被挪动。
本文所有输出均来自本机 CPython 3.13.14 实测,代码可直接粘贴运行。
一、那张「凭证」上写了什么
buf = bytearray(b'0123456789')
mv = memoryview(buf)
print(mv.format, mv.itemsize, mv.ndim, mv.shape, mv.strides,
mv.nbytes, mv.readonly, mv.c_contiguous)
本机输出:
B 1 1 (10,) (1,) 10 False True
八个字段一份不少:format='B' 表示按无符号单字节解释,itemsize=1 是每格宽度,shape=(10,) 是形状,strides=(1,) 是步长,nbytes=10 是总字节数,readonly=False 说明可写,c_contiguous=True 说明是连续内存。
这些不是 memoryview 自己猜的,是底层对象通过 buffer protocol(PEP 3118) 主动报上来的。任何实现了这套协议的类型都能被 memoryview() 包一层:bytes、bytearray、array.array、NumPy 数组、mmap 对象,以及 3.12 起你自己写的类。
二、写穿:改视图就是改原对象
mv[0] = 65
print(bytes(buf[:4])) # b'A123'
sub = mv[2:6]
print(sub.nbytes, sub.readonly, sub.obj is buf) # 4 False True
sub[0] = 88
print(bytes(buf[:7])) # b'A1X3456'
两件事值得停一下:
mv[0] = 65直接改到了buf上,视图里没有自己的数据副本;- 切片视图仍然共享同一块 buffer,
sub.obj is buf是True。这一点跟列表切片完全不同——list[2:6]是浅拷贝,改副本不会动原列表。
再看零拷贝与快照的对比:
raw = bytearray(b'AAAA')
v = memoryview(raw)
snap = v.tobytes()
raw[0] = 66
print(bytes(v), snap) # b'BAAA' b'AAAA'
tobytes() 才是复制。只调用 memoryview() 和切片,一个字节都不搬。
三、只读不是建议,是硬约束
ro = memoryview(b'abc')
print(ro.readonly, isinstance(hash(ro), int)) # True True
ro[0] = 65
# TypeError: cannot modify read-only memory
bytes 是不可变对象,导出的视图自然只读。想给一个可写视图加只读保护,用 toreadonly() 而不是复制。
顺带一个容易忽略的点:只读的、格式兼容的 memoryview 是可哈希的,能当字典键;可写的不能。
四、cast:换个解释方式,不换数据
import array
arr = array.array('i', [1, 2, 3, 4])
ma = memoryview(arr)
print(ma.format, ma.itemsize, ma.shape) # i 4 (4,)
print(ma.tobytes().hex()[:32]) # 0100000002000000...
mb = ma.cast('B')
print(mb.format, mb.itemsize, mb.shape) # B 1 (16,)
print(len(mb.tobytes())) # 16
4 个 int32 = 16 字节,cast('B') 之后变成 16 个单字节,底层还是那 16 个字节。这在解析二进制协议头、把 array.array 喂给只接受 bytes 的 API 时非常省事。
但 cast 有硬条件,长度必须是目标 itemsize 的整数倍:
memoryview(bytearray(b'abc')).cast('I')
# TypeError: memoryview: length is not a multiple of itemsize
3 字节要按 4 字节整数解释,除不尽,直接拒绝。反汇编也能看到切片本身并没有额外的复制指令:
import dis
def f_mv(b): return memoryview(b)[1:4]
print([i.opname for i in dis.get_instructions(f_mv)
if 'SUBSCR' in i.opname or 'SLICE' in i.opname or 'CALL' in i.opname][:6])
# ['CALL', 'BINARY_SLICE']
五、那道 BufferError:视图活着,内存不许动
这是零拷贝最容易被忽略的代价。
b2 = bytearray(b'abc')
m2 = memoryview(b2)
b2.extend(b'XYZ')
# BufferError: Existing exports of data: object cannot be re-sized
m2.release()
b2.extend(b'XYZ') # 现在可以了,len(b2) == 6
原因很直白:bytearray 扩容可能触发重新分配内存,而 m2 里存的就是那块内存的地址。真让它扩了,视图会变成悬空指针。CPython 选择用引用计数式的「导出计数」拦住这次 resize。
release() 之后视图也不能再用了:
m2[0]
# ValueError: operation forbidden on released memoryview object
这条对排查线上问题特别有用:看到 BufferError: Existing exports of data,不要去找 bytearray 的 bug,去查是哪个 memoryview / NumPy 视图 / mmap 还活着。用 with memoryview(x) as mv: 或 try/finally: mv.release() 是最省心的写法。
六、3.12 起,自己的类也能导出 buffer
PEP 688 把协议开放给了纯 Python 类,实现 __buffer__(self, flags) 即可:
class Box:
def __init__(self, data): self.data = bytearray(data)
def __buffer__(self, flags): return memoryview(self.data)
print(bytes(memoryview(Box(b'hi')))) # b'hi'
本机 3.13.14 实测通过。这意味着自定义容器、环形缓冲区、网络包解析器都能走原生零拷贝路径,不必先 bytes(...) 复制一遍。
七、对照表:什么时候该用、什么时候别用
| 场景 | 建议 | 原因 |
|---|---|---|
| 解析大二进制文件的头部/分片 | ✅ memoryview + 切片 | 不复制,省内存也省时间 |
| 需要把数据长期留存、脱离原对象 | ✅ tobytes() / bytes(...) | 拿独立快照 |
边持有视图边给 bytearray 扩容 | ❌ 会 BufferError | 先 release() |
bytes 视图想改内容 | ❌ TypeError | 用 bytearray 打底 |
非连续 / 带步长的视图做 cast | ❌ 只能对 C 连续视图 | 先 tobytes() 再重建 |
| 多线程共享可写视图 | ⚠️ 需要自己加锁 | 零拷贝也意味着没有隔离 |
传给只认 bytes 的第三方 API | ⚠️ 多数能收 buffer 协议,少数不能 | 不确定就 tobytes() |
八、七条排错清单
BufferError: Existing exports of data: object cannot be re-sized→ 有视图没释放,查release()或改用with。ValueError: operation forbidden on released memoryview object→ 视图已release(),别再访问。TypeError: cannot modify read-only memory→ 底层是bytes,换bytearray。TypeError: memoryview: length is not a multiple of itemsize→cast的总字节数除不尽目标格式宽度,补齐或换格式。TypeError: memoryview: casts are restricted to C-contiguous views→ 带步长的非连续视图不能直接 cast,先拷贝成连续。- 改了视图原对象没变 → 你可能对
mv.tobytes()的结果做了修改,那是副本。 - 哈希时报
TypeError: unhashable type→ 视图可写;用toreadonly()且格式须为字节型。
小结
把 memoryview 当成「带格式的指针 + 长度 + 读写权限」,所有行为就都能推出来:切片是换个窗口、cast 是换套解释、共享是默认、resize 被禁是为了不悬空。真正需要复制的只有 tobytes() 那一步。
本文所有结论均由本机 CPython 3.13.14 实测得出(探针 d59-probe.py),无外部数据引用。发布时按「中文前置、符号后置」策略命名,避免纯符号标题。