Python 里 pickle 到底存了什么:拆开 __reduce__、__getstate__ 和那串能执行代码的字节码

简介: pickle 是一段会被执行的指令流,不是数据快照,因此绝不能反序列化不可信输入。本文用本机 CPython 3.13.14 实测:HIGHEST_PROTOCOL=5 与 DEFAULT_PROTOCOL=4、__getstate__ 定制状态、__reduce__ 两元组与绑定方法副作用陷阱、模块级函数按 GLOBAL 指令找回、循环与共享引用保持、lambda 与生成器与文件句柄不可 pickle、__slots__ 协议版本差异,附对照表与六条排错清单

一句话结论:pickle 存的不是「数据」,而是一段描述「怎么把对象重新造出来」的指令流。反序列化就是照着这段指令执行一遍——所以它能重建循环引用,也能执行你没写过的函数。

本文所有数字均来自本机 CPython 3.13.14 实测,代码可直接运行。

一、先看它存出来的东西长什么样

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 最实用的一处定制:

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):

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 的逐项填充和迭代器,这里不展开。

这里有个很容易讲错的细节,我实测踩了一遍:

calls = []
class BoomBound:
    def __reduce__(self):
        return (calls.append, ('REDUCE_CALLED',))
pickle.loads(pickle.dumps(BoomBound()))
print(calls)   # []  ← 空的

很多人会拿这个例子说「reduce 没生效」。其实是生效了,只是 calls.append 是绑定方法,pickle 把宿主列表 calls 一起序列化了,副作用落在反序列化出来的那份副本上,原来的列表当然看不到。

换成模块级函数就原形毕露:

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 不能

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__ 有个协议版本陷阱

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 的字节流没有跨版本兼容承诺。

九、六条排错清单

  1. loads 报 AttributeError: Can't get attribute 'X' on <module '__main__'> —— 类定义所在模块路径变了。反序列化时按模块名+限定名找类,重排目录、在 notebook 里定义、脚本改名都会踩。把类放进稳定的可导入模块。
  2. __getstate__ 里删了字段但没写 __setstate__ —— 反序列化后访问该字段报 AttributeError。
  3. __slots__ 类在协议 0/1 下报错 —— 升到 ≥2,或自己实现状态存取。
  4. PicklingError 提到 lambda / 局部函数 —— 提到模块顶层,或用 cloudpickle。
  5. 「明明 __reduce__ 执行了却看不到副作用」—— 检查是不是用了绑定方法,宿主对象被复制了(见第四节)。
  6. 改了类字段后旧数据 load 出怪值 —— pickle 不存 schema,字段增删靠默认 __setstate__ 直接 update,老数据里多出来的键会悄悄留在 __dict__。显式写 __setstate__ 做版本迁移。

十、一段可直接跑的完整示例

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 次循环计时结果,不同机器会有浮动,量级关系稳定。

相关文章
|
12天前
|
Linux 开发工具 iOS开发
Codex保姆级教程(下载+安装+登录+使用,含所有版本,2026最新)
Codex是OpenAI推出的开源AI编程智能体,本地运行,可理解整个代码库,支持自然语言指令改代码、执行命令、跑测试、修Bug,兼容Win/macOS/Linux。(239字)
4886 33
|
12天前
|
JavaScript API iOS开发
DeepSeek Harness下载、安装、配置一篇搞定(2026整理,送常用插件)
DeepSeek Harness(DSH)是DeepSeek于2026年开源的MIT协议AI Agent框架,支持插件化组装模型、工具、沙箱与UI,兼容主流大模型。本文提供免Node.js的桌面端安装包(Win/macOS),含详细图文教程及热门插件推荐。
4555 52
|
13天前
|
iOS开发 Docker MacOS
【2026实测】Docker Desktop下载、安装、汉化、配置教程(附官网安装包)
Docker Desktop 是 Docker 官方推出的桌面容器管理工具,集成引擎、CLI、图形界面与 Kubernetes,支持 Win/macOS/Linux。2026 最新版 4.88.1 强化 ARM 原生运行、镜像加速及资源可视化,附中文汉化与国内镜像配置指南。
2191 7
|
7天前
|
云安全 人工智能 安全
|
7天前
|
Linux 开发工具 iOS开发
2026最新codex下载安装保姆级使用教程(下载+安装+登录+使用,含所有历史版本)
本文详解OpenAI Codex国内快速上手指南:提供Win/macOS/Linux全平台免安装包、npm/Homebrew等多方式安装、账号/API Key双登录、简体中文切换,覆盖下载、安装、汉化、使用及高频避坑(如微软商店失败、npm超时等),10分钟即可跑通。
1604 3
|
5天前
|
人工智能 安全 API
DeepSeek Harness下载、安装、配置一篇搞定(2026整理,送常用插件)
DeepSeek Harness(dsh)是DeepSeek AI于2026年开源的轻量级Agent运行框架,基于Cordis内核,践行“一切皆插件”理念。支持Web/桌面双端,开箱即用;内置多模型接入、丰富插件生态(超3.5万),适配编程、办公、创作等场景。(239字)
1074 0
|
13天前
|
安全 算法 定位技术
《高性价比人生指南》完整原版:普通人用最少的资源,过好这一生的底层逻辑~How To Live Better
📖《高性价比人生指南》是一份基于实证、拒绝焦虑的生存手册,倡导以“人生时薪”为视角,重新计算时间、金钱与精力的成本与收益。它不承诺逆袭,只教人算清账:省下的钱若耗掉时间便亏;买体验比买物品更值;睡眠是最高杠杆的健康投资;关系贵在情绪价值而非表面热络;学习重在改变行为而非自我安慰。核心理念:用低成本、高回报的微小行动,换取长期的选择权——不是成为谁,而是掌控自己人生的主动权。
2210 6
|
6天前
|
人工智能 并行计算 调度
本地 AI 一键部署工具 LocModel:Windows 端集成画图、视频生成、Agent 的开源模型运行方案
LocModel 是专为 Windows 设计的一站式本地 AI 工具,支持零配置启动、自动硬件检测、GGUF 模型一键加载、文生图/视频生成及 Agent 自动化任务。适配 8G 显存,内置显存分片调度,轻松实现 AI 漫剧批量创作与离线大模型调用。(239 字)