一句话结论:pickle 存的不是「数据」,而是一段描述「怎么把对象重新造出来」的指令流。反序列化就是照着这段指令执行一遍------所以它能重建循环引用,也能执行你没写过的函数。
本文所有数字均来自本机 CPython 3.13.14 实测,代码可直接运行。
一、先看它存出来的东西长什么样
python
import pickle, pickletools
data = {'name': '张衡', 'year': 132, 'items': [1, 2, 3], 'nested': {'a': None}}
b = pickle.dumps(data, protocol=0)
print(b)
# b"(dp0\nVname\np1\nV\\u5f20\\u8861\np2\nsVyear\np3\nI132\ns..."
print([o[0].name for o in pickletools.genops(b)][:12])
# ['MARK', 'DICT', 'PUT', 'UNICODE', 'PUT', 'UNICODE', 'PUT', 'SETITEM', ...]
MARK、DICT、PUT、SETITEM ------ 这是一台栈机的指令。pickle 不是把字典「拍扁」存下来,而是写了一句「建个空字典 → 压键 → 压值 → SETITEM」。PUT/GET 里的编号就是 memo 表的索引,专门用来处理共享和循环引用。
这也解释了协议 0 为什么是纯 ASCII 的:它诞生于必须能安全穿过文本通道的年代。
二、六个协议的实测差异
| 协议 | 字节数 | 纯 ASCII | round-trip |
|---|---|---|---|
| 0 | 101 | ✅ True | ✅ |
| 1 | 92 | ❌ | ✅ |
| 2 | 94 | ❌ | ✅ |
| 3 | 94 | ❌ | ✅ |
| 4 | 76 | ❌ | ✅ |
| 5 | 76 | ❌ | ✅ |
本机 HIGHEST_PROTOCOL = 5、DEFAULT_PROTOCOL = 4。协议 2 起才支持新式类的高效编码,协议 4 起支持大对象与 frame,协议 5 加了带外缓冲区(out-of-band buffers)。
一个常见误区:以为协议越高字节一定越小。上面这份数据里 2/3 反而比 1 长,4/5 才降下来;小对象上 frame 的开销要摊得开才划算。
三、__getstate__ 能让体积从 2817 字节降到 81 字节
这是 pickle 最实用的一处定制:
python
class Instrument:
def __init__(self, name, calib):
self.name, self.calib = name, calib
self._cache = {'big': list(range(1000))}
def __getstate__(self):
st = self.__dict__.copy()
del st['_cache'] # 可重建的缓存不进字节流
return st
def __setstate__(self, st):
self.__dict__.update(st)
self._cache = {} # 重建时给个干净的空缓存
i = Instrument('候风地动仪', 132)
print(len(pickle.dumps(i))) # 81
print(len(pickle.dumps(i.__dict__))) # 2817
j = pickle.loads(pickle.dumps(i))
print(j.__dict__.keys(), j._cache) # ['name','calib','_cache'], {}
实测 81 vs 2817,差 34 倍。注意 __setstate__ 里必须自己把 _cache 补回去,否则反序列化出来的对象一访问就 AttributeError。
__getstate__ 不是「加密」,它只是决定哪些状态进字节流;真要脱敏,得在进 __getstate__ 之前就删掉。
四、__reduce__:pickle 真正的总开关
__reduce__ 返回一个「可调用对象 + 参数」,反序列化时就是 callable(*args):
python
class ReduceDemo:
def __init__(self, v): self.v = v
def __reduce__(self):
return (ReduceDemo, (self.v,), {'extra': 1})
r = pickle.loads(pickle.dumps(ReduceDemo(7)))
print(r.v, r.extra) # 7 1
第三个元素会喂给 __setstate__(没定义时按默认规则更新 __dict__)。返回五元组还能控制 list/dict 的逐项填充和迭代器,这里不展开。
这里有个很容易讲错的细节,我实测踩了一遍:
python
calls = []
class BoomBound:
def __reduce__(self):
return (calls.append, ('REDUCE_CALLED',))
pickle.loads(pickle.dumps(BoomBound()))
print(calls) # [] ← 空的
很多人会拿这个例子说「reduce 没生效」。其实是生效了,只是 calls.append 是绑定方法,pickle 把宿主列表 calls 一起序列化了,副作用落在反序列化出来的那份副本上,原来的列表当然看不到。
换成模块级函数就原形毕露:
python
AUDIT = []
def _audit_call(x):
AUDIT.append(x); return x
class BoomGlobal:
def __reduce__(self):
return (_audit_call, ('REDUCE_CALLED',))
pickle.loads(pickle.dumps(BoomGlobal()))
print(AUDIT) # ['REDUCE_CALLED'] ← 真的执行了
模块级函数按 模块名 + 名字 存成 GLOBAL 指令,反序列化时按名字找回真实的那个函数 ,副作用落在真实全局上。把 _audit_call 换成 os.system、参数换成一条命令,就是教科书级的反序列化漏洞。
所以:永远不要 pickle.loads 不可信来源的字节流。 签名、加密、隔离进程都只是缓解,最可靠的是换成 json / msgpack 这类纯数据格式。
五、它能保住引用结构,json 不能
python
a = []; a.append(a)
a2 = pickle.loads(pickle.dumps(a))
print(a2[0] is a2) # True 自引用没断
shared = [1, 2]; two = [shared, shared]
t2 = pickle.loads(pickle.dumps(two))
print(t2[0] is t2[1]) # True 共享关系没被复制成两份
靠的就是前面说的 memo 表。这是 pickle 相对 json 最本质的优势:它重建的是对象图,不是树。
覆盖面实测对比:
| 类型 | json | pickle |
|---|---|---|
| int / None | ✅ | ✅ |
| tuple | ✅(转数组) | ✅(保类型) |
| bytes | ❌ | ✅ |
| set | ❌ | ✅ |
| complex | ❌ | ✅ |
六、哪些东西存不了
| 对象 | 结果 |
|---|---|
lambda x: x |
PicklingError: attribute lookup ... failed |
| 生成器表达式 | TypeError: cannot pickle 'generator' object |
sys 模块 |
TypeError: cannot pickle 'module' object |
open(...) 文件句柄 |
TypeError: cannot pickle 'TextIOWrapper' instances |
io.StringIO('x') |
✅ 能存(它自己实现了状态存取) |
最后一行值得单拎出来:不是所有「像文件」的东西都存不了,StringIO 可以,OS 级文件句柄不行。规律是------pickle 只能存「能用 Python 对象重建」的东西,绑定了运行时资源(文件描述符、栈帧、生成器状态)的就不行。
七、__slots__ 有个协议版本陷阱
python
class Slotted:
__slots__ = ('x', 'y')
def __init__(self, x, y): self.x, self.y = x, y
s = Slotted(1, 2)
pickle.loads(pickle.dumps(s, protocol=0))
# TypeError: a class that defines __slots__ without defining __getstate__
pickle.loads(pickle.dumps(s, protocol=2)) # ✅ OK
pickle.loads(pickle.dumps(s, protocol=5)) # ✅ OK
协议 0/1 依赖 __dict__ 取状态,__slots__ 类没有 __dict__,直接报错;协议 2 起有 REDUCE + __getstate__ 默认实现兜底。如果你维护一个必须兼容老协议的服务,就得自己补 __getstate__/__setstate__。
八、性能:pickle 比 json 快,也小
本机 200 次循环,200 键 × 20 元素的字典:
| 指标 | pickle(protocol=5) | json |
|---|---|---|
| dumps 总耗时 | 0.0255s | 0.0842s |
| 单次字节数 | 10106 | 15890 |
快约 3.3 倍、小约 36%。但要清醒:这是 Python 内部缓存/临时序列化场景的结论,跨语言、跨版本、需要人读或长期归档时,json 仍然更稳妥。pickle 的字节流没有跨版本兼容承诺。
九、六条排错清单
loads报AttributeError: Can't get attribute 'X' on <module '__main__'>------ 类定义所在模块路径变了。反序列化时按模块名+限定名找类,重排目录、在 notebook 里定义、脚本改名都会踩。把类放进稳定的可导入模块。__getstate__里删了字段但没写__setstate__------ 反序列化后访问该字段报AttributeError。__slots__类在协议 0/1 下报错 ------ 升到 ≥2,或自己实现状态存取。PicklingError提到 lambda / 局部函数 ------ 提到模块顶层,或用cloudpickle。- 「明明
__reduce__执行了却看不到副作用」------ 检查是不是用了绑定方法,宿主对象被复制了(见第四节)。 - 改了类字段后旧数据 load 出怪值 ------ pickle 不存 schema,字段增删靠默认
__setstate__直接update,老数据里多出来的键会悄悄留在__dict__。显式写__setstate__做版本迁移。
十、一段可直接跑的完整示例
python
import pickle, pickletools, io
class Station:
"""一个带可重建缓存的观测站"""
def __init__(self, name, year):
self.name, self.year = name, year
self._cache = {'samples': list(range(1000))}
def __getstate__(self):
st = self.__dict__.copy()
st.pop('_cache', None)
return st
def __setstate__(self, st):
self.__dict__.update(st)
self._cache = {'samples': []}
def __repr__(self):
return f'<Station {self.name} {self.year} cache={len(self._cache["samples"])}>'
s = Station('灵台', 132)
for proto in range(pickle.HIGHEST_PROTOCOL + 1):
buf = pickle.dumps(s, protocol=proto)
obj = pickle.loads(buf)
print(proto, len(buf), obj)
# 循环引用与共享引用
a = []; a.append(a)
assert pickle.loads(pickle.dumps(a))[0] is pickle.loads(pickle.dumps(a))
shared = [1, 2]; pair = [shared, shared]
t = pickle.loads(pickle.dumps(pair))
assert t[0] is t[1]
# 看指令流
print([o[0].name for o in pickletools.genops(pickle.dumps({'k': 1}, protocol=0))])
小结
pickle 的两面性来自同一个设计:它是一段会被执行的指令流,不是数据快照。正因如此,它能还原对象图、能定制状态、能比 json 快;也正因如此,绝不能拿它接不可信输入。
用它的安全姿势只有一种:字节流的生产方和消费方都在你自己的信任边界内,且类的模块路径稳定、版本可控。剩下的场景,交给 json。
实测环境:CPython 3.13.14(Windows),脚本 d57-probe.py。所有数值为本机 200/2000 次循环计时结果,不同机器会有浮动,量级关系稳定。