前几天一个朋友让我帮他看一个Python项目的打包问题:一个看起来不算复杂的爬虫工具,用PyInstaller打成exe之后居然有130多MB,而且启动还慢。我打开代码一看,发现他的主类里挂满了动态属性——有requests.Session、有上一次抓取到的完整HTML、有排列组合的临时DataFrame、还有一堆调试日志。这些对象在程序运行的时候确实有用,但生命周期和主进程一样长。我把几个临时属性用delattr删掉之后,内存直接降了一个量级,再配合序列化瘦身,最后打包出来的体积也肉眼可见地缩水了。这让我对delattr这个平时存在感极低的内置函数刮目相看。
本文就来聊聊Python对象瘦身这件事:delattr怎么用、为什么能提升代码可扩展性和打包效率、具体怎么落地到内存控制、序列化、打包和插件设计几个场景中。适合用Python写脚本、写库、跑爬虫、做数据处理,还有琢磨怎么把程序打包分发的朋友们。
1. delattr等于del obj.attr?先拆开对象的内存结构
1.1 一个Python对象的属性到底存在哪
在Python里,大多数实例对象的属性并不是“硬编码在类结构里”的,而是存在一个叫__dict__的字典中。每次执行obj.attr = value,本质上就是往obj.__dict__里塞了一个键值对。你可以把对象当成一个衣柜,属性名是标签,属性值是挂在衣柜里的衣服,__dict__就是贴在衣柜外面的衣物清单。
class Downloader: pass d = Downloader() d.url = "https://example.com" print(d.__dict__) # {'url': 'https://example.com'} delattr(d, "url") print(d.__dict__) # {}delattr(obj, "attr")做的事情很简单:从obj.__dict__中移除对应的键。如果这个属性恰好是一个数据描述符,delattr还会调用描述符的__delete__逻辑。对于绝大多数你手动挂上去的普通属性来说,它就是一行dict.pop。
1.2 delattr和del obj.attr到底有什么区别
很多人会问:既然delattr(obj, "attr")等价于del obj.attr,为什么还要多记一个函数名?区别其实只有一点:delattr允许你用字符串变量来指定属性名。
names = ["tmp_response", "tmp_html", "debug_trace"] for name in names: if name in d.__dict__: delattr(d, name)这段逻辑如果用del关键字写,就需要写三行重复代码,或者用eval这种危险操作。而delattr天然适合批量清理、框架代码、装饰器、代码生成器这些场景。只要属性名是程序运行时动态拼出来的,delattr就是比del更安全、更可控的选择。
1.3 有哪些属性delattr删不掉
delattr只会删除对象实例自身的属性,不会删除类属性。举个例子:
class Demo: amount = 10 d = Demo() d.amount = 5 delattr(d, "amount") print(d.amount) # 10这里删除的是d实例字典里的amount,删除之后访问d.amount,又会回到类属性Demo.amount = 10。如果你没有在实例上显式赋值,直接delattr(d, "amount"),那么Python会因为实例字典里没有这个键而抛出AttributeError。
这条规则的实操意义很大:delattr清理的是实例对类属性的“遮蔽”。如果你觉得对象属性太多,第一反应应该是去看看__dict__里到底有什么,而不是凭类定义猜测。
2. 对象越来越“胖”的三个典型现场
2.1 动态注入属性:写起来爽,忘起来也爽
Python的动态特性让“往对象上挂新属性”变得特别方便,很多人在写业务代码时,喜欢把中间的每一个结果都挂到self上:
class Crawler: def run(self): self.raw_html = requests.get(self.url).text self.parsed_html = parse_html(self.raw_html) self.items = extract_items(self.parsed_html) self.result_df = build_dataframe(self.items)写的时候确实很顺手,后续方法想用哪个就用哪个。可问题是,这些属性在对象销毁之前会一直存在。如果Crawler对象本身是常驻内存的单例,或者被框架缓存了,那么这些临时数据就会变成“永久数据”。一次两次无所谓,跑上几百个URL之后,对象的__dict__里会攒下大量历史残留,内存占用自然飙升。
2.2 缓存属性和临时状态:用完之后仍然霸占位置
另一种常见情况是缓存属性。为了性能,确实可以把某个计算结果缓存到对象属性上:
class Analyzer: def get_heavy_data(self): if not hasattr(self, "_heavy_data"): self._heavy_data = load_million_rows_from_db() return self._heavy_data这种写法本身没错,错的是没有给缓存设置失效机制。如果_heavy_data只需要在某个时间段内有效,之后就用不到了,那它就成了纯粹的负担。更隐蔽的是临时调试属性,比如self._last_error、self._debug_stack、self._log_handlers。这些属性在调试阶段很有用,但到了性能测试或打包发布阶段仍然留在代码里,就会被序列化、被打包、被多进程pickle传来传去,造成各种莫名其妙的问题。
2.3 三方框架注入的属性:看不见的膨胀源
还有一类属性膨胀不是你自己写的,而是框架或第三方库注入的。比如某些Web框架的请求对象,会动态往self上塞用户信息、session、缓存、连接池;某些ORM模型会缓存查询结果、原始SQL、关系对象。你不一定每时每刻都用到它们,但它们确实存在于对象的__dict__中。
想搞清楚一个对象到底有多“胖”,最简单的办法是直接看vars(obj):
def inspect_attrs(obj, limit=20): attrs = vars(obj) print(f"对象属性总数: {len(attrs)}") for i, (k, v) in enumerate(attrs.items()): if i >= limit: break print(f"{k}: {type(v).__name__} - {v!r:.50}")跑一下这个函数,你往往会发现:真正业务必需的属性只占一小半,剩下的大多是临时状态、缓存、调试日志、历史数据。这时候delattr就该登场了。
3. 动手瘦身:从临时属性到序列化体积的完整链路
3.1 给类定义一个cleanup协议
最直接的做法是给类加一个cleanup()方法,用delattr批量删除已知的临时属性:
class Crawler: def __init__(self): self.session = create_session() self.raw_html = None self.parsed = None self.stats = {} def cleanup(self): for name in ["raw_html", "parsed", "stats"]: if hasattr(self, name): delattr(self, name)这里为什么要用hasattr判断?因为如果属性在之前的异常流程中已经被删过,直接delattr会抛AttributeError。hasattr可以帮忙忽略掉已经不存在的情况。
但是hasattr也有自己的坑:如果类里定义了__getattr__和property,hasattr会触发这些逻辑,有可能产生意想不到的副作用。更精准的写法是直接判断__dict__:
def safe_delattr(obj, name): if name in vars(obj): delattr(obj, name)vars(obj)返回的就是实例__dict__,这个判断只会看实例自身有没有属性,不会触发__getattr__,也不会被类属性干扰,清理起来更干净。
3.2 序列化之前先瘦身:pickle体积下降是立竿见影的
很多Python程序都需要用pickle做对象序列化,比如multiprocessing传参、缓存到Redis、保存中间结果。pickle默认会把对象的__dict__整个打包,所以对象里挂着多少属性,序列化出来的字节数就有多大。
import pickle class Report: def __init__(self): self.data = list(range(100000)) self.tmp_cache = {i: str(i) for i in range(100000)} r = Report() print(len(pickle.dumps(r))) # 大概 1068581 字节 delattr(r, "tmp_cache") print(len(pickle.dumps(r))) # 大概 467590 字节删除一个临时缓存属性,pickle体积直接少了一半。如果对象里挂的是DataFrame、图片列表、模型权重这类大家伙,效果会更夸张。
还有一种情况是对象根本没法pickle,比如里面有线程锁、文件句柄、logging handler。这时delattr甚至可以救命:
import threading class Worker: def __init__(self): self.data = [1, 2, 3] self.lock = threading.Lock() w = Worker() # pickle.dumps(w) # 会报错,Lock不能被pickle delattr(w, "lock") data = pickle.dumps(w)在实际的多进程任务里,给所有需要跨进程传递的对象定义一个cleanup方法,在提交前删掉不可pickle的锁、连接、句柄和临时缓存,既能避免报错,又能减少序列化耗时。
3.3 打包效率到底怎么提升:把对象属性当成“资源包”来管
这里要澄清一个容易被误解的点:delattr不会直接减小你的.py文件体积,也不会让PyInstaller少打包几个标准库模块。PyInstaller分析的是源码里的import语句,不会因为对象少了一个属性就少收集一个模块。
但对象属性会通过另外两条路径影响打包产物:
第一条路径是数据资源。如果你的程序里有预生成的pickle数据文件、模型权重、词表、缓存结果,这些文件是要作为数据文件一起打包的。对象属性中如果缓存了大量临时数据,你在生成这些数据文件时就会把它们一起写进去,文件自然膨胀。先delattr再保存,数据文件会小很多,打包体积也随之下降。
第二条路径是运行时内存和启动速度。PyInstaller打包出来的程序,启动时要重新初始化整个运行时环境。如果对象一启动就带着大量历史残留属性,内存占用高、初始化慢、运行卡顿。把这些无用属性删掉后,程序启动更快,整体资源占用更低,用户体验完全不一样。
我见过一个数据分析工具,类实例上挂着十几份中间结果DataFrame,用户导出的pickle文件有80多MB,后来在导出前加了一轮delattr清理,文件直接降到15MB。再配合PyInstaller打包,输出exe的体积肉眼可见地小了一截。这就是“对象瘦身→数据资源变小→打包产物变小”的完整链路。
3.4 不要过度瘦身:接口契约属性要保留
delattr虽好,但千万别把必要的属性也删了。一个类的对外契约属性,比如配置项、主数据、回调函数,这些是业务逻辑继续运行的前提。瘦身的边界应该是“只删除对象生命周期内不再需要的东西”,而不是把属性删到只剩一个空壳。
一个简单的判断标准:删除这个属性之后,如果对象的任何公共方法再次访问它都会出问题,那它就不该被删。如果删除后只是让某个缓存失效,或者让某个临时状态消失,那就放心删。
4. 可扩展性设计里,delattr不是删除而是“恢复默认”
4.1 插件系统:卸载插件时清掉动态状态
做插件化架构的人,通常会很关心“插件可重复安装、可重复卸载”。动态属性是插件系统最容易出问题的点:插件A往主对象上挂了一个ctx_on_start,插件B也挂了一个ctx_on_start,两个插件卸载的时候如果没有清理干净,下次加载时就会互相干扰。
这时候delattr能帮你显式收敛动态属性:
class Plugin: def __init__(self): self._handlers = [] def install(self, manager): for event in ("on_start", "on_stop"): manager.register(event, self.handle_event) setattr(self, "ctx_" + event, manager) def uninstall(self): for name in [n for n in vars(self) if n.startswith("ctx_")]: delattr(self, name)卸载时批量删除以ctx_开头的动态属性,插件系统就能反复安装、卸载而不会残留上一次的上下文。这种写法比手动记一串属性名更稳健:以后新增事件、新增ctx_属性,uninstall方法不用跟着改。
4.2 用delattr实现惰性缓存的“失效”
很多类会通过__getattr__实现按需加载属性,避免在初始化时把所有东西都准备好。配合delattr,你还可以实现缓存失效:
class RemoteConfig: def __getattr__(self, name): if not name.startswith("config_"): raise AttributeError(name) value = load_from_remote(name) self.__dict__[name] = value return value def refresh(self): for name in [n for n in vars(self) if n.startswith("config_")]: delattr(self, name)第一次访问obj.config_timeout时,__getattr__去远程加载配置并缓存到实例字典。调用refresh()后,缓存被delattr清掉,下一次访问又会重新走__getattr__加载最新配置。
这个场景里,delattr不是“破坏”,而是“恢复默认状态”。它让对象的属性集合保持动态可控,既保留了惰性加载带来的性能优势,又给了外部调用者一个干净的刷新入口。这种设计非常适合配置中心、权限校验、版本化数据这些需要定期更新的场景。
4.3 装饰器里的一次性状态管理
给类写装饰器时,经常需要在方法执行期间临时记录一个状态,但方法返回后又要立刻清理。比如避免同一个方法重入:
from functools import wraps def prevent_reentry(func): @wraps(func) def wrapper(self, *args, **kwargs): flag_name = "_running_" + func.__name__ if getattr(self, flag_name, False): raise RuntimeError(f"{func.__name__} is already running") setattr(self, flag_name, True) try: return func(self, *args, **kwargs) finally: delattr(self, flag_name) return wrapper如果没有finally里的delattr,即使方法执行结束,这个临时标记也会一直留在对象上。下次调用getattr(self, flag_name, False)时,会错误地认为方法还在运行。delattr在这里保证了对象在每次方法调用后都“回到原样”,可扩展性自然更好。
5. 用错delattr会踩的坑:边界与规避方法
5.1 删除不存在的属性会抛AttributeError
delattr和直接访问不存在的属性一样,都会抛AttributeError:
d = Downloader() try: delattr(d, "not_exist") except AttributeError as e: print(e) # not_exist批量清理时建议用if name in vars(obj)判断,或者用前面写的safe_delattr。不要直接依赖hasattr,因为如果这个类实现了__getattr__,hasattr可能会触发属性生成的副作用,然后“有属性可删”,但删完之后下一次访问又会生成,造成清理无效的假象。
5.2 property和描述符的删除行为
如果一个属性的读取逻辑被@property接管,delattr并不会去删实例字典,而是会找property对象有没有定义deleter:
class A: @property def x(self): return 1 a = A() try: delattr(a, "x") except AttributeError as e: print(e) # can't delete attribute如果定义了@x.deleter,delattr(a, "x")就会调用这个deleter。所以在使用delattr清理第三方库对象时,要注意目标属性是不是描述符。如果是,删除操作可能会触发额外逻辑,而不是默默从字典里移除。
5.3 __slots__类的属性删除
使用__slots__的类没有实例字典,属性由槽描述符管理。delattr可以删除槽位上的值,但删除后访问该属性会抛AttributeError,直到再次赋值:
class S: __slots__ = ("x",) s = S() s.x = 1 delattr(s, "x") print(s.x) # AttributeError: x如果你在代码里对__slots__类的实例做动态属性清理,要先确认这个属性是否存在、是否可以被删。而且__slots__类本身不支持动态添加新属性,如果对象没有声明__dict__到__slots__里,vars(obj)会报TypeError,需要额外处理。
5.4 别把delattr当成del obj
delattr(obj, "attr")是删除对象上的一个属性,但并不会删除对象本身。一个对象能否被垃圾回收,取决于它还有没有存活的引用。就算你把一个对象的所有属性都删光,只要外部还有变量引用它,它照样活在内存里。
在某些“清理”代码里,我看到有人写:
delattr(self, "data")然后期望整个对象被回收。这是误解。如果真想减少内存占用,要同时切断所有引用,包括列表项、字典值、回调闭包等。delattr只负责把对象内部变干净,不负责让对象消失。
5.5 并发环境下的竞态
多线程场景下,一个线程正在读取obj.cache,另一个线程执行delattr(obj, "cache"),读取方就会收到AttributeError。如果属性确实可能被并发删除,读取方要用getattr(obj, "cache", None),或者做好异常捕获。更推荐的做法是在对象的清理协议里增加锁保护,但很多业务代码其实不需要考虑这么细,知道有这个坑就够了。
6. 附赠一个属性体检模板,照着抄就行
6.1 通用属性大小分析函数
想快速定位“胖”对象,可以用下面这个模板扫一遍:
import sys from types import ModuleType, FunctionType def scan_attrs(obj, depth=0, seen=None): if depth > 2: return if seen is None: seen = set() if id(obj) in seen: return seen.add(id(obj)) if isinstance(obj, (ModuleType, FunctionType)): return try: attrs = vars(obj) except TypeError: return if not attrs: return print(" " * depth + f"对象 {type(obj).__name__} 有 {len(attrs)} 个属性") for key, value in attrs.items(): size = sys.getsizeof(value) print(" " * depth + f"- {key}: {type(value).__name__}, sys.getsizeof={size}") if depth < 2: scan_attrs(value, depth + 1, seen) class BigObject: def __init__(self): self.df = list(range(10000)) self.session = {"token": "x", "history": list(range(1000))} self.ok = True scan_attrs(BigObject())sys.getsizeof不会递归计算“属性里的属性”,但它足够用于排序和定位。真要看完整内存占用,可以用pympler库的asizeof.asizeof(obj),那个更准。
6.2 批量清理模板
当你已经确定了要清理的属性列表,建议复制这个工具函数:
def safe_delattr(obj, name): if name in vars(obj): delattr(obj, name) def cleanup_attrs(obj, names): for name in names: safe_delattr(obj, name)如果清理的是动态前缀属性,就扫描vars(obj)的键:
def cleanup_by_prefix(obj, prefix): for name in [n for n in vars(obj) if n.startswith(prefix)]: safe_delattr(obj, name)6.3 打包前的瘦身检查清单
我自己的习惯是在准备打包发布前,对项目里的核心类跑一遍以下检查:
- 所有实例属性是不是对象整个生命周期内都必须存在的?
- 大体积临时数据是否已经通过
cleanup()清理? - 缓存属性有没有失效机制?没有的话是删掉还是加上?
- 序列化、多进程传参之前,是否调用过清理协议?
- 打包过程中生成的pickle、json、缓存文件,是不是已经重新生成过最小版本?
这套流程看起来很简单,但真的很管用。尤其是项目交接的时候,跑一遍属性体检,往往能发现好几个历史遗留的动态属性。它们平时不影响功能,却默默拖累内存和打包产物。把这些属性清理干净,对象瘦了,代码扩展起来也更安心。