用 __slots__ 给 Python 类省内存:百万对象实测降 40% 用slots给 Python 类省内存:百万对象实测降 40%如果你写过需要在内存里同时装几十万、上百万个小对象的程序——比如把一整张表的行读进来做处理、或者跑一个节点很多的图算法——大概率遇到过这种情况:数据本身没多大,Python 进程却吃掉了好几个 G,甚至直接 OOM 被杀掉。问题往往不在你的数据,而在每个对象背后那个默认的__dict__。这篇讲清楚__slots__到底省在哪、怎么用、以及哪些坑会让它白省。先复现问题:一个普通类有多重假设我们有一个表示三维坐标点的类,只有三个 float 字段:classPoint:def__init__(self,x,y,z):self.xx self.yy self.zz看起来很省,三个 float 而已。但我们实际测一下一百万个这样的对象占多少内存:importtracemallocdefmake_points(n):return[Point(i,i1,i2)foriinrange(n)]tracemalloc.start()pointsmake_points(1_000_000)current,peaktracemalloc.get_traced_memory()print(f当前内存:{current/1024/1024:.1f}MB)tracemalloc.stop()在 CPython 3.11 上跑,输出大约是:当前内存: 176.3 MB一百万个「三个数字」的对象,吃掉了 176 MB。平均每个对象 ~180 字节,可这三个 float 的引用加起来才 24 字节。多出来的一百多字节去哪了?根因:每个实例都背着一个dictPython 默认给每个实例挂一个__dict__字典,用来存实例属性。正是这个字典让你能随时p.new_attr 1动态加属性——灵活,但代价是每个实例都要单独分配一个 dict。pPoint(1,2,3)print(p.__dict__)# {x: 1, y: 2, z: 3}p.colorred# 动态加属性,靠的就是 __dict__print(p.__dict__)# {x: 1, y: 2, z: 3, color: red}importsysprint(sys.getsizeof(p.__dict__))# 空间不小,几十到上百字节一个 dict 即使只装三个键,本身也有哈希表的额外开销。几十万个实例就是几十万个 dict,内存全耗在这上面了。正确写法:加一行slots__slots__告诉 Python:这个类的实例只会有这几个固定属性,别给它建__dict__了,直接在对象里开好固定的槽位存值。classPoint:__slots__(x,y,z)# 声明固定字段,不再有 __dict__def__init__(self,x,y,z):self.xx self.yy self.zz就加了一行。同样测一百万个:importtracemalloc tracemalloc.start()points[Point(i,i1,i2)foriinrange(1_000_000)]current,peaktracemalloc.get_traced_memory()print(f当前内存:{current/1024/1024:.1f}MB)tracemalloc.stop()输出:当前内存: 103.8 MB从 176 MB 降到 104 MB,省了约 40%。字段越多、字段本身越小(比如都是 int/float 这种紧凑值),省得越明显。而且用__slots__后属性访问是走固定偏移,不用哈希查找,读写还会略快一点。加了slots之后,这些行为会变__slots__不是免费的灵活性阉割。用之前得知道它改了什么:1. 不能再动态加属性pPoint(1,2,3)p.colorred# AttributeError: Point object has no attribute color这其实是好事——它把「拼错属性名」这类 bug 从静默新增一个属性变成了当场报错。写p.corlor 1以前会悄悄成功,现在直接抛异常。2. 没有dict了pPoint(1,2,3)print(p.__dict__)# AttributeError: Point object has no attribute __dict__如果你的代码里有vars(obj)或者obj.__dict__做序列化、日志之类的,会挂。用dataclasses.asdict或者手动列字段替代。3. 继承时要留神这是最容易踩的坑。只要继承链上任何一个类没写__slots__,子类实例就又会有__dict__,省内存的效果全没了。classBase:pass# 没写 __slots__,自带 __dict__classPoint(Base):__slots__(x,y,z)pPoint()print(hasattr(p,__dict__))# True —— 白省了!p.anything1# 居然还能动态加属性正确做法是继承链上每一层都声明__slots__,子类的 slots 只写「自己新增的字段」,不要重复父类的:classBase:__slots__(id,)classPoint(Base):__slots__(x,y,z)# 只写新增的,别再写 idpPoint()print(hasattr(p,__dict__))# False,干净4. 和 property、类变量不能重名__slots__是在类上创建了同名的描述符(descriptor)。如果你又想给同一个名字设默认值或定义 property,会冲突:classBad:__slots__(x,)x10# ValueError: x in __slots__ conflicts with class variable需要默认值就在__init__里赋,别写成类变量。和 dataclass 一起用:Python 3.10 一步到位如果你用dataclass,3.10 以后直接加slotsTrue就行,不用手写__slots__,字段名它自己收集:fromdataclassesimportdataclassdataclass(slotsTrue)classPoint:x:floaty:floatz:floatpPoint(1,2,3)print(hasattr(p,__dict__))# False# p.color red # 一样会 AttributeError这是我现在最推荐的写法:既有 dataclass 的自动__init__、__repr__、__eq__,又拿到 slots 的省内存和防错。注意 3.10 以下的 dataclass 没有slots参数,得手动写__slots__。什么时候该用,什么时候别用__slots__不是「所有类都该加」的银弹。判断标准很简单——看你会不会同时存在大量该类的实例:该用:数据类、值对象、ORM 的行对象、图/树的节点、几何点、粒子系统……凡是「同一个类要 new 出成千上万个」的场景。没必要:全局单例、配置对象、Service/Manager 这种整个进程就一两个实例的类。给它加 slots 省的那点内存忽略不计,反而牺牲了灵活性。不能用:需要动态加属性的场景(比如插件系统、给对象打运行时标记),或者依赖__dict__做元编程的。一个实用做法:先用普通类把功能写对,等到内存分析(tracemalloc/memory_profiler)发现某个类的实例是内存大头,再针对性地给它加__slots__。别一上来就给所有类加,那是过早优化。小结普通 Python 实例每个都背一个__dict__,大量小对象场景下内存全耗在这上面。__slots__ (...)声明固定字段、去掉__dict__,实测百万对象省约 40%,属性访问还略快。代价:不能动态加属性、没有__dict__、继承链每层都得写 slots 才生效、不能和同名类变量/property 冲突。Python 3.10 用dataclass(slotsTrue)一步到位,兼顾便利和省内存。判断口诀:**「会不会有一大堆这个类的实例?」会,就加;不会,就别折腾。**先测出内存热点再优化,别一把梭。

本周精选

本月热点