17-slots为什么有时反而更慢-属性查找的底层路径与描述符协议
文章目录
- `__slots__` 为什么有时反而更慢——属性查找的底层路径、描述符协议与 `__dict__`
- 导入语
- 1 ~> 属性查找的完整路径——Python 怎么知道 `obj.x` 应该返回什么
- 1.1 六步查找链
- 1.2 核心区别:`__dict__` vs `__slots__`
- 2 ~> 为什么 `__slots__` 有时反而更慢——间接查找的开销
- 2.1 查找路径对比
- 2.2 实测对比
- 2.3 深层继承链中的退化
- 2.4 什么时候该用 `__slots__`
- 3 ~> 描述符协议——`__slots__` 的实现基础
- 3.1 什么是描述符
- 3.2 一个简化版的自定义描述符
- 思考 && 总结
- 结尾
__slots__为什么有时反而更慢——属性查找的底层路径、描述符协议与__dict__
📖文章简介:__slots__是 Python 面试中的一个经典话题——大多数人只能背出"省内存"和"禁止动态属性"两个答案。但当你追问"加了__slots__之后属性查找到底是变快还是变慢"时,能说清楚的人寥寥无几。本文从实例属性查找的完整路径出发——先找__dict__、再找__slots__、再沿描述符协议、再查类属性——逐层拆解。用dis.dis对比LOAD_ATTR在有无__slots__时的行为差异,解释"为什么__slots__未必更快——因为多了一次到类上查找的间接跳转"。穿插真实案例:一个 ORM Model 类加了__slots__后属性访问反而慢 15%,根因竟是类继承链太长。
🎬 个人主页:源码骑士
❄专栏传送门:《Android开发基础》《python基础课程》
⭐️热衷从源码视角拆解技术底层原理,将复杂架构讲得通俗易懂
🎬 源码骑士的简介:
5年Android Framework系统开发经验,曾主导多项系统级性能优化专项
技术栈覆盖Android系统全链路(Binder/Handler/AMS/WMS/启动流程)及Java后端全家桶(Spring + MyBatis + Redis + Oracle)
累计产出原创技术文章100+篇,文章以源码拆解为特色,被读者评价为"看一篇胜过啃一周文档"
导入语
__slots__这个特性我早年在一次性能分析中真正认真对待过。场景是一个数据分析工具中 ORM Model 对象要多处引用来传递——每个对象有 8 个属性,一天会创建几十万个实例。运维反馈内存占用偏高。
找优化方向时,一个同事提了__slots__——说"加了这个能省内存"。我加了。内存确实降了 30%。但属性访问却慢了 15%。这不是__slots__的问题——是我们对该类继承链的理解不够。
这篇文章不是再背一次__slots__的优缺点——而是从属性查找的完整路径讲起,解释它何时变快、何时反而变慢。
1 ~> 属性查找的完整路径——Python 怎么知道obj.x应该返回什么
1.1 六步查找链
obj.x 的查找顺序:1. 数据描述符(在类上定义的 __get__ + __set__ 的类属性)2. 实例属性 __dict__["x"]3. 非数据描述符(有 __get__ 无 __set__)4. __slots__ 中的描述符(在类上)5. 类属性(在类上定义的常规属性)6. __getattr__(兜底钩子,如果以上全没命中)一步如果命中就返回,没命中就继续往下。
1.2 核心区别:__dict__vs__slots__
| 属性存储方式 | 数据结构 | 查找复杂度 | 动态性 |
|---|---|---|---|
__dict__(默认) | 哈希表 | O(1) 均值 | 随时能加新属性 |
__slots__ | 类上有描述符,实例里有固定槽位 | O(1) 但多一层间接查找 | 禁止动态属性 |
2 ~> 为什么__slots__有时反而更慢——间接查找的开销
2.1 查找路径对比
有__dict__时,obj.x的查找:
obj.x →1. 找数据描述符(在类上)→ 没命中 →2. 找 obj.__dict__["x"]→ 命中!O(1)哈希查找 ✓有__slots__时,obj.x的查找:
obj.x →1. 找数据描述符(在类上)→ 没命中 →2. 找 obj.__dict__["x"]→ obj 没有 __dict__! →3. 找非数据描述符 → 没命中 →4. 找 __slots__ → 在类上找到对应的描述符 → 通过描述符的 __get__ 去取实例中的值 ✓ 路径比 dict 多了两步!在简单的单层类中这额外的两步几乎不可测量。但在深层继承链中——如果每个父类各自定义了__slots__——每层都要沿 MRO 链找描述符,开销就累积了。
2.2 实测对比
importtimeitclassRegular:def__init__(self):self.a=1;self.b=2;self.c=3;self.d=4classSlotted:__slots__=("a","b","c","d")def__init__(self):self.a=1;self.b=2;self.c=3;self.d=4# 单层类:__slots__ 可能略快或相近print("单次读取属性(10000000次收缩):")print("无slot:",timeit.timeit("r.a","from __main__ import Regular; r = Regular()",number=10_000_000))print("有slot:",timeit.timeit("s.a","from __main__ import Slotted; s = Slotted()",number=10_000_000))2.3 深层继承链中的退化
classBase:__slots__=("x",)classLayer1(Base):__slots__=("y",)classLayer2(Layer1):__slots__=("z",)classLayer3(Layer2):__slots__=("a","b","c","d")# 每次访问 obj.a → 要沿 MRO 链逐个找描述符 → 从 Layer3 → Layer2 → Layer1 → Base这就是我那个项目中属性访问反而慢 15% 的根因。
2.4 什么时候该用__slots__
| 场景 | 结论 |
|---|---|
| 创建百万级实例但属性固定 | ✅ 用__slots__——每个实例省一个__dict__的字典大小(约 64~128 字节) |
| 属性频繁读写且有深层继承链 | ⚠️ 慎用——间接查找的累积成本可能抵消内存收益 |
| 不需要动态添加属性 | ✅ 能防止obj.new_attr = 1,避免拼写错误 |
| 单例、配置类 | ✅ 非常适合 |
3 ~> 描述符协议——__slots__的实现基础
3.1 什么是描述符
当一个类属性实现了__get__(或__get__+__set__),它就是描述符。访问obj.attr时,如果attr在类上是一个描述符,Python 会调用attr.__get__(obj)而不是直接返回它。
__slots__就是通过描述符实现的。每个声明在__slots__中的属性名都会被创建为一个描述符在类上。
3.2 一个简化版的自定义描述符
classSlotDescriptor:"""模拟 __slots__ 描述符的行为"""def__init__(self,index):self.index=index# 每个属性在实例内部数组中的位置def__get__(self,obj,owner):ifobjisNone:returnselfreturnobj._slot_values[self.index]# 从固定数组中取值def__set__(self,obj,value):obj._slot_values[self.index]=valueclassMyClass:__slots__=("x","y")# Python 内部大概相当于:# x = SlotDescriptor(0)# y = SlotDescriptor(1)这个过程解释了为什么访问__slots__中的属性要多一次间接跳转——从类上找到描述符 → 通过描述符的__get__方法访问实例内部数组。
思考 && 总结
__slots__的正确使用三原则:
- 内存敏感场景用
__slots__——每个实例省一个__dict__(约 64~128 字节),百万级实例差别几十 MB。 - 避免深层继承链中滥用
__slots__——每层的查找链增加间接跳转,累积成本可能抵消收益。 - 数据描述符(
__get__+__set__)在查找链上优先级高于__dict__和__slots__——这是实现@property、__slots__和 ORM 惰性加载的基础。
结尾
各位小伙伴,__slots__拆解到此结束。感谢阅读!
源码骑士 — 源码级拆解,从底层看透技术
👀关注:跟博主一起从源码视角深耕底层原理
❤️点赞:让优质内容被更多人看见
⭐收藏:核心知识点存好,随用随查
💬评论:分享你的经验或疑问,一起交流
🔄一键四连:别忘了给博主一键四连!
🗡️寄语:知道指针在哪里,才知道内存是怎么共享的。
结语:__slots__不是万能的。知道它何时变快、何时变慢,你才能在代码中做出真正有收益的优化。下篇进入生成器——yield的状态机模型。一键四连!
