一个服务把 20 万个配置对象加上了 __slots__,进程 RSS 从 1.2G 降到 780M——省了 35%。而同一份代码在另一处加 __slots__ 后报错:AttributeError: 'X' object has no attribute 'y'——因为某个"动态属性"被禁了。
__slots__ 是"用"不能动态加属性"换"每个对象少一个 __dict__"的交换。这个"交换"的判据很清晰:收益随"对象数 × 属性数"增长,而代价是"失去动态性"——于是"动态性需求"与"内存压力"哪个更硬,决定了该选哪边。
而它在"对象多、属性少、属性固定"时收益最大。
1. 省下的是什么
| 结构 | 每个对象多出的开销 |
|---|---|
| 普通实例 | 一个 __dict__(哈希表,通常 100+ 字节) |
__slots__ 实例 |
只有"槽位指针"数组(每个 8 字节) |
所以"属性越多,__slots__ 省得越多":普通实例的 __dict__ 会随属性增加而扩容,而槽位是固定的。
这个"反过来"很重要:很多"加 __slots__ 优化"的收益只有 5%,而它带来的"不能加属性"会让后续改动处处受限——收益低而代价持续。
反过来:属性很少(1–2 个)时,__dict__ 本身也不大,于是"省下的"可能只有几十字节。
// 一个最小的对照(量一量再决定)
import sys
class A: # 普通
def __init__(self): self.x = 1; self.y = 2
class B: # slots
__slots__ = ("x", "y")
def __init__(self): self.x = 1; self.y = 2
print(sys.getsizeof(A()) + sys.getsizeof(A().__dict__)) # 含 dict
print(sys.getsizeof(B())) # 只有对象头 + 槽位
// 注意:getsizeof 不含"dict 内部的桶",实际差更大
2. "省多少"的三个变量
| 变量 | 影响 |
|---|---|
| 属性数 | 越多省越多 |
| 对象数 | 线性放大 |
是否被 __dict__ 引用 |
有引用则省不了 |
第三项最容易被忽略:如果代码里 obj.__dict__ 被显式使用(如 vars(obj)、obj.__dict__["x"] = 1),那 __slots__ 会让它直接失败。
3. __slots__ 的四种代价
| 代价 | 表现 |
|---|---|
| 不能动态加属性 | obj.new = 1 抛 AttributeError |
不能多继承(有 __dict__ 的基类) |
槽位失效 |
| 弱引用需显式声明 | 需加 "__weakref__" |
| pickle/反射的兼容性 | 某些库假设有 __dict__ |
第二项最隐蔽:继承一个"普通类"(有 __dict__)时,子类的 __slots__ 形同虚设第二项的"识别"是"看实例有没有 __dict__":hasattr(obj, "__dict__") 为真就说明"某个基类带了它"——而这一点在"继承了很多层"时很难追。
——因为基类仍带 __dict__,实例照样有它。
// 反例:基类有 __dict__,子类 slots 白加
class Base: pass # 有 __dict__
class Child(Base):
__slots__ = ("x",) # 无效:实例仍有 __dict__
// 正解:整条继承链都不带 __dict__
class Base2:
__slots__ = ()
"省多少"可以用一行公式粗估,避免"改完才发现没用":
// 粗估公式(对象数 × 每对象节省)
每对象 ≈ 100–150 字节(含 dict 桶,按"属性数 3–6"估)
总节省 ≈ 对象数 × 每对象
// 例:20 万对象 × 120B ≈ 24MB(对 1.2G 进程约 2%)
// 结论:只有"对象数达百万"或"属性很多"时,收益才显著
4. 三种"值得加"的场景
| 场景 | 理由 |
|---|---|
| 大量小对象(10 万+) | 线性放大收益 |
| 属性固定(配置/坐标/事件) | 不动态加 |
| 内存敏感(容器/边缘) | 直接决定能否跑 |
第一项的"门槛"可以估:每个对象省约 100–150 字节(含 dict 桶),10 万对象约省 10–15MB——低于这个规模,它对"进程 RSS"的影响会被"其他分配"淹没。
第一项是"唯一真正有效"的场景:对象少时,省下的内存"看不见"——于是"加了 __slots__ 却没变化"很常见。
"属性很多"时的收益会超出线性预期,而它常被低估:
// 属性数与 dict 扩容的关系(示意)
3 个属性:dict 桶 8 个,实际占用约 104B
9 个属性:dict 桶扩容到 32 个,占用约 280B
// 于是"属性从 8 加到 9"可能是"多 100B",而不是"多 8B"
// 结论:属性越多的模型,`__slots__` 收益越超预期
5. 怎么量"有没有用"
| 步骤 | 方法 |
|---|---|
| 1 量单对象 | sys.getsizeof + pympler.asizeof |
| 2 量总量 | tracemalloc 或 RSS |
| 3 问"值不值" | 与"代码复杂度"对比 |
第二项更真实:**tracemalloc 能看到"分配点"**第二项的"优势"是"它能定位到"哪一行分配的"":于是"加了 slots 后这个函数的内存占用降了 35%"是可直接读出的结论,而不是"整体好像小了"。
,于是"加了 slots 后哪个分配点下降了"是可验证的。
// 用 tracemalloc 看"加 slots 前后的峰值"
import tracemalloc
tracemalloc.start()
objs = [B() for _ in range(200_000)]
cur, peak = tracemalloc.get_traced_memory()
print(cur / 2**20, "MiB") # 对比 A() 的版本
tracemalloc.stop()
"兼容性"要先测:__slots__ 会破坏"某些库的假设",而它在上线后才暴露。
// 三条快速兼容检查(改完就跑)
obj = B()
assert not hasattr(obj, "__dict__") # 确认真的没 dict
obj.__slots__ # 确认声明生效
import pickle; pickle.loads(pickle.dumps(obj)) # 序列化是否还能跑
// 若第 3 条失败 → 实现 __getstate__/__setstate__
6. 排错:五种"slots 相关问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 加 slots 后 AttributeError | 动态属性 | 补槽位或去掉 slots |
| 内存没降 | 属性少/对象少/基类带 dict | 先量再改 |
| 弱引用报错 | 缺 __weakref__ |
加槽位 |
| pickle 失败 | 依赖 __dict__ |
实现 __getstate__ |
| 多继承异常 | 两个基类都有槽 | 改结构 |
第三项的"报错"是"TypeError: cannot create weak reference to 'X' object"——而"加 "__weakref__""这一行容易被当成"魔法",实际它是"显式声明需要这个槽位"。
第三项在"缓存/观察者"场景常见:weakref.ref(obj) 需要对象支持弱引用,而 __slots__ 默认不支持。
最后一个"量级"对照,让"值不值"有可比性:
// 三种典型场景的收益(粗估)
10 万对象 × 5 属性:约 12MB(进程 1G 时 ≈ 1%)
100 万对象 × 3 属性:约 120MB(明显,值得)
5 千对象 × 10 属性:约 0.6MB(不值得,只增约束)
// 判据:先用"对象数 × 属性数"估,再决定要不要写
7. 边界:什么情况不该加
| 场景 | 为什么 | 做法 |
|---|---|---|
| 对象少(< 1 万) | 收益看不见 | 不加 |
| 需要动态属性(插件/ORM) | 会破坏灵活性 | 不加 |
| 继承链复杂 | 容易失效 | 不加 |
已有 dataclass(slots=True) |
用现成开关 | 用 @dataclass(slots=True) |
第四项的"额外收益"是"不必手写初始化":@dataclass(slots=True) 同时给你"字段声明 + __init__ + 槽位"——而"手写 __slots__ + __init__"容易不同步。
第四项是"现代做法":@dataclass(slots=True)(Python 3.10+)自动生成 __slots__,不必手写。
判断口径一句话:"同类对象会在内存里同时存在多少个"。最后一条与"演进"有关的边界:__slots__ 一旦加上,往后"加属性"就成了破坏性改动。所以它适合"结构已经很稳定"的模型,而不适合"还在长"的领域对象。
十万级以上且属性固定,值得加;几千个,它只增加代码约束而不省内存。
8. 自测
__slots__省下的到底是什么?为什么"属性越多省得越多"?- 三个变量里,哪一个会让"加了却没省"?为什么"用
__dict__"会让它失败? - 为什么"继承一个普通类"会让子类的
__slots__失效? - 哪一类场景是"唯一真正有效"的?为什么"对象少"时看不见收益?
- "弱引用"为什么要显式加槽位?
- 判断"该不该加
__slots__"的那个问题是什么?