ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python对象内存管理:属性查找、__slots__与绑定方法底层原理

Python对象内存管理:属性查找、__slots__与绑定方法底层原理 1. 先别急着敲代码Python对象在内存里到底是什么形态很多人学Python面向对象上来就是class Dog:、def __init__()、self.xxx xxx语法全会问起对象在内存里怎么放、属性访问走的是什么链路、为什么有时候类属性改了实例属性跟着变、有时候又不变一脸茫然。这些恰恰是面试必问、实战最容易出隐性bug的地方。这篇就围绕Python面向对象中的属性与方法的内存管理展开把对象、类、属性、方法在内存里的真实存在形态掰开揉碎讲清楚。先说结论Python里的一切都是对象类本身也是对象类的属性存放在类对象的__dict__里实例的属性存放在实例对象的__dict__里方法本质上是定义在类对象上的普通函数只不过在通过实例访问时经过了一套绑定机制变成了绑定方法对象。这一整套关系搞明白之后属性查找顺序、类属性与实例属性之争、__slots__省内存的原理、方法重写的本质全都迎刃而解。这篇内容适合这几类人已经写过一段时间Python、但没系统梳理过对象模型的人准备Python面试、总在类属性vs实例属性__slots__到底省了什么上栽跟头的人维护过较大型Python项目、被改一个类属性影响了所有实例这种问题坑过一次的人想优化内存占用、把程序性能压榨到极限的人。我会先讲对象在内存中的基本盘再逐个拆属性相关机制、方法绑定机制、内存优化手段最后给一个完整的排查链路帮你以后遇到谜之行为时有路可循。2. 对象与类在内存中的存在形态一切皆对象的底层逻辑2.1 对象不是一块数据而是一块带元信息的结构体在C语言里一个int变量就是4个字节存的就是数值本身但在Python里一个整数对象远不止那4个字节。CPython中每个对象都是一个PyObject结构体严格说是有ob_refcnt和ob_type这两个核心字段的头部它包含引用计数、类型指针再加上实际的数据内容。这就是为什么Python的int居然要28个字节——它不是一个裸数字而是一个数字对象。类比一下你收拾行李箱一个裸的行李箱就是数据贴了标签、写了主人姓名、备注了航班号的行李箱就是对象。标签就是类型指针告诉你这个箱子里装的是什么主人姓名和航班号就是元信息帮解释器判断这东西该怎么处理、能调哪些方法。对象在内存里的基本构成如下引用计数字段ob_refcnt用来做垃圾回收的判断依据类型指针ob_type指向它的类型对象也就是这是哪个类实例数据ob_size加上真正的数据区根据类型不同存储的内容不一样。这一层搞清楚后很多困惑就迎刃而解了比如为什么Python的a 1; b 1比a 1.0; b 1.0更省内存——小整数有缓存浮点数没有。这些都是对象模型延伸出来的东西。2.2 类对象和实例对象两类不同的对象各有一张自己的属性表Python里class语句执行完之后你会得到一个类对象。这个类对象本身也是一个对象它有类型type、有内存地址、有自己的属性。定义在类体里的变量、方法函数都会存进类对象的属性空间。而每当你执行一次实例 类()解释器会new出一个新对象这就是实例对象它也有自己的属性空间初始时通常是空的直到__init__往里塞东西。这两者的关系我建议用一个比方类是模具实例是模具造出的产品。模具本身也是物体摆在那里有它自己的刻痕类属性产品是另外的物体每一个都有自己的编号、瑕疵等个体信息实例属性。模具上的刻痕所有产品出厂时都带着一份印子但不影响产品后续自己贴标签。在CPython层面类对象和实例对象各自持有一个名为__dict__的字典属性不是所有对象都有但常规类的实例都有用来存放属性名到值的映射类名.__dict__存放类的属性与方法键是属性名值是属性值或函数对象实例.__dict__存放实例自己的属性键是属性名值是绑定后的属性值。class Dog: species Canis familiaris # 类属性 def __init__(self, name): self.name name # 实例属性 def bark(self): return f{self.name} says woof! d1 Dog(旺财) d2 Dog(来福) print(Dog.__dict__) # {__module__: __main__, species: Canis familiaris, # __init__: function Dog.__init__ at 0x..., bark: function Dog.bark at 0x..., # __dict__: attribute __dict__ of Dog objects, __weakref__: attribute __weakref__ of Dog objects} print(d1.__dict__) # {name: 旺财}看到没有——species存放在类对象的字典里name存放在实例对象的字典里而方法bark和__init__也都存放在类对象的字典里。实例的字典里根本见不到方法的影子。这解释了第一个常见的困惑为什么我d1.__dict__里看不到类属性species——因为它压根不在实例上它在类上。你之所以能用d1.species访问到它是属性查找机制在背后往上找的结果。这个查找机制下一节详细说。2.3 属性查找链实例找不到就找类类找不到就找父类当你在代码里写d1.name、d1.species、d1.bark时Python解释器并不是直接去实例的字典里翻而是遵循一条固定的查找链路先查实例自身的__dict__实例属性空间没找到则查实例所属类的__dict__类属性空间类里也没有则沿着类的MRO方法解析顺序继续往父类的__dict__里找都找不到抛出AttributeError。这个查找顺序可以用一个生活场景来理解你向妈妈要东西妈妈先翻自己的包实例属性没找到就叫你爸翻他的包类属性爸妈都没有就问爷爷奶奶父类全家都没有就告诉你家里没有这个东西报错。永远不会倒过来先翻长辈的。print(d1.species) # Canis familiaris实例字典没有去类字典找到了 d1.species Labrador # 注意这是在实例字典里新增键 print(d1.species) # Labrador实例字典命中了 print(Dog.species) # Canis familiaris类字典没变 print(d1.__dict__) # {name: 旺财, species: Labrador}这个例子是属性机制的经典考点通过实例给属性赋值永远是在实例自己的__dict__里操作不会去改类属性。所以看起来改了d1.species不影响Dog.species。反过来说如果你直接改类属性Dog.species New Species那么所有还没有自己species的实例再访问时都会拿到新值而那些早就设了自己species的实例仍然返回自己的值。这就是类属性像默认值实例属性像覆盖值这种说法的来源。3. 属性机制深挖__dict__、__slots__与属性描述符3.1__dict__不是免费的午餐一个实例要付出多少内存每次实例化一个常规类Python都会给实例对象分配一个__dict__字典。字典本身就是个开销不小的结构它有哈希表、有装载因子冗余、有键和值的指针。对于动辄几十万上百万实例的程序每个实例多这么一个空字典内存开销非常可观。来算一笔账一个空的__dict__字典在64位CPython上大约占64字节左右加上对象头部、类型指针等一个普通实例哪怕没有任何属性也至少是96字节往上。如果你创建100万个实例光空字典就是64MB的量级还没算实例本体。但如果你用__slots__声明了固定属性每个实例就不再有__dict__而是用固定长度的描述符槽位来存储属性值一个槽位就是一个指针8字节属性多了保存的也只是指针数组省下来的内存是实打实的。不过要澄清一个误区__slots__的作用不是把属性变得很小而是去掉每个实例都带着的那张字典。它省下的主要是每次实例化的固定开销而不是属性本身的存储开销。属性值该多大还是多大。3.2__slots__的正确打开方式与三个坑__slots__的用法很简单class Point: __slots__ (x, y) def __init__(self, x, y): self.x x self.y y这样声明的类实例没有__dict__只能给x、y赋值尝试p.z 3会抛AttributeError。这是特性也是限制看你需求取舍。我用__slots__时踩过几个坑列出来供参考继承时要小心如果父类没写__slots__子类写了那么子类实例依然有__dict__因为父类的__dict__机制还在子类__slots__只解决子类自己的属性槽位父类那边凭空多出来的动态属性还是往__dict__里塞。要真正全程无字典必须保证继承链上所有类都定义了__slots__。默认没有__weakref__使用__slots__后实例不再自动支持弱引用。如果你要开弱引用得在__slots__里手动加一个__weakref__槽位。与property混用要小心如果你在类里既用__slots__声明了某个名字又用property定义了同名的property描述符槽描述符和property描述符会发生冲突报ValueError的可能性很高实际项目中我先排查名字是否重了。3.3 从属性到描述符property装饰器和__getattribute__的介入很多人用property以为那只是把方法伪装成属性。其实它背后的机制是描述符协议一个类里定义了__get__、__set__、__delete__中任意一个方法的对象就是描述符。当它作为类属性存在时实例访问这个属性就不走普通的字典数据路径而是会触发描述符协议。完整的事件链路是这样的当执行obj.attr时Python实际调用的是type(obj).__getattribute__(obj, attr)注意是type(obj)也就是类不是obj上的同名方法__getattribute__内部会先在实例字典、类字典、父类字典里找这个属性如果找到的是一个描述符对象比如property对象再根据这是在实例访问还是类访问以及描述符实现了哪些方法决定调用__get__等方法如果找到的只是普通数据值比如字符串、数字直接返回。property其实是Python内置的一个类它内部实现了数据描述符协议把fget、fset、fdel三个函数包了起来所以当你在类里写class Circle: def __init__(self, radius): self._radius radius property def diameter(self): return self._radius * 2这个diameter属性在类字典里存的是一个property对象而不是数字。你访问c.diameter时触发的是property.__get__进而调用你定义的diametergetter函数。理解这一点后你就会明白为什么给实例动态添加一个和property同名的属性会被忽略或出问题class Circle: def __init__(self, radius): self._radius radius property def radius(self): return self._radius radius.setter def radius(self, value): self._radius value c Circle(5) c.radius 10 print(c.__dict__) # {_radius: 10}radius这个词根本不会进实例字典因为property是数据描述符数据描述符的优先级高于实例字典。这就是为什么描述符比实例字典优先——这是Python属性机制中一个反直觉却非常重要的点。4. 方法的内存秘密从函数到绑定方法的完整链路4.1 方法只是存在类字典里的普通函数前面看到类字典里存的方法条目值是function Dog.bark at 0x...。也就是说方法在类字典里就是一个普普通通的函数对象它并没有知道自己是某个类的方法也没有绑定某个实例。此时用Dog.bark访问得到的是裸函数你需要手动传入一个实例作为第一个参数print(Dog.bark(d1)) # d1传给self输出 旺财 says woof!而通过实例访问d1.bark得到的是一个绑定方法对象bound method。这个对象内部保存了两个关键信息被绑定的实例对象、以及底层的函数对象。当你调用d1.bark()时绑定方法对象会自动把d1作为self传进去。你可以自己验证print(d1.bark) # bound method Dog.bark of __main__.Dog object at 0x... print(type(d1.bark)) # class method print(d1.bark.__self__) # d1即被绑定的实例 print(d1.bark.__func__) # function Dog.bark at 0x...即底层函数所以方法这个词在Python里有两层含义在类层面它是函数对象Dog.__dict__[bark]在实例层面它是绑定方法对象d1.bark。每次通过实例访问方法Python都会现场生成一个新的绑定方法对象这样的性能开销是否值得关注这个问题的答案是CPython有缓存机制同一实例重复访问同一方法时会缓存绑定方法对象不必太过担心重复创建的开销。但在极高频的场景下如果你真的想绕开绑定方法对象可以用d1.bark.__func__(d1)直接调函数或者用拿到函数后不通过实例触发绑定的方式。大部分项目完全不需要这样优化知道有这回事就行。4.2 类方法、静态方法、实例方法在内存里的真实区分这三种方法的本质取决于包装在类字典里的对象是什么实例方法类字典里存的是普通function对象实例访问时被包装成绑定方法对象自动传self类方法类字典里存的是一个classmethod对象实例访问时被包装成绑定方法对象但绑定的不是实例而是类对象本身。cls参数拿到的就是类静态方法类字典里存的是一个staticmethod对象实例访问时不做任何绑定直接返回底层的裸函数参数不用自动传任何东西。用代码验证class Foo: def instance_method(self): pass classmethod def class_method(cls): pass staticmethod def static_method(): pass print(Foo.__dict__[instance_method]) # function Foo.instance_method at 0x... print(Foo.__dict__[class_method]) # classmethod object at 0x... print(Foo.__dict__[static_method]) # staticmethod object at 0x...这个差异直接决定了它们的调用约定。我在实际工程里见过不少人踩坑我在类方法里调用实例方法结果self参数不匹配我在静态方法里试图访问类属性结果报name未定义。根本原因就是没理解类字典里存的是哪种包装对象。4.3 方法重写为什么不会影响父类Python重写机制的底层基石面向对象里重写的概念放到内存机制里看会非常清晰。子类定义同名方法时子类字典里新增了一个同名的函数对象把父类字典里的同名函数对象给遮蔽了。属性查找链是先查子类字典再查父类字典所以子类版本生效父类版本原封不动。这不光对方法成立对类属性也成立。甚至对整个机制都成立继承的本质是属性查找的兜底链而不是复制粘贴。父类的属性始终只在父类的字典里有一份子类实例访问继承来的父类方法走的是查找链回溯。这个方法引出一个容易被忽略的点如果你在子类的__init__里不小心写了self.xxx ...而父类也定义了property xxx那么子类的赋值行为会因为数据描述符的优先级而变得微妙。我后面会专门用一节讲这种类体设计带来的隐性坑。5. 内存管理视角引用计数、循环引用与会凭空消失的属性5.1 引用计数Python为什么不需要手动freeCPython的内存回收主要靠引用计数。对象头部那个ob_refcnt字段记录着当前有多少个引用指向这个对象。每多一个变量赋值、每被一个容器加入引用计数就加一每删除一个变量、每从容器中移除引用计数就减一。当计数归零时对象立即被回收内存释放。所以理解属性内存管理本质上也要理解一个实例被类属性引用、被实例属性引用、被列表元素引用它的生命周期就跟这些引用绑定。举个实战例子class Node: def __init__(self, data): self.data data self.parent None self.children [] a Node(a) b Node(b) a.children.append(b) b.parent a del a del b这里a和b互相引用两个对象各自被删除了外部名字但因为互相持有引用引用计数没有归零对象就永远不会被回收。这就是循环引用问题。这种场景下需要依赖垃圾回收器gc模块定期做标记-清除来兜底但对于时间敏感的程序gc触发的卡顿可能是你需要关注的点。5.2__del__方法、弱引用与删除属性的常见误区很多初学者以为写了__del__就能精确控制对象销毁时机其实不然。__del__在对象引用计数归零、即将被回收的那一刻才由解释器调用时机并不确定还可能因为循环引用导致执行不到。我不建议在__del__里做重要清理逻辑尤其是涉及文件句柄、网络连接这类资源。想精确管理资源用上下文管理器with是更稳妥的方式。弱引用weakref也是个经常被忽略的工具。它允许你引用一个对象却不增加它的引用计数。你既能拿到对象如果它还存在又不会阻碍它被回收。这在缓存场景、观察者模式场景非常有用。由于__slots__默认会禁止__weakref__所以前面说的坑在这里又会冒出来你如果既想用__slots__省内存、又想被弱引用必须手动声明__weakref__槽位。5.3 黑洞般的类属性误用共享可变对象到底有多危险这个坑我要单独拿出来说因为它既与属性的内存存放位置有关又是实操中最容易爆的雷。当类属性是一个可变对象列表、字典、集合、自定义对象所有通过类访问该属性的实例拿到的其实是同一个对象。任何实例通过self.some_list.append(...)修改它所有实例都会看到变化。因为self.some_list经过查找链找到的是类字典里那同一个列表对象修改的是这个列表本身而不是在实例字典里新建一个列表。class Employee: department [] # 类属性是可变列表 def __init__(self, name): self.name name self.department.append(name) alice Employee(Alice) bob Employee(Bob) print(Employee.department) # [Alice, Bob]如果你本来想给每个员工一个独立的部门列表这种做法就彻底错了。正确的是在__init__里用self.department []或self.department list(Employee.default_departments)之类的方式为每个实例创建独立列表。类属性上放可变默认值是时候确认你团队里没人再犯了。6. 属性访问背后的魔法方法__getattribute__、__getattr__、__setattr__6.1 一个属性访问被拦截和分发的完整链路前面提到type(obj).__getattribute__(obj, attr)是属性访问的总入口。这一步之后如果查找失败Python会退而调用__getattr__如果这个魔法方法存在就把属性名传给它如果__getattr__也处理不了才会抛AttributeError。而__setattr__是所有属性赋值的总入口每次执行obj.attr value都会被它拦截。这意味着你可以在赋值发生时做校验、重定向、日志记录也可以在内部故意制造一些诡异的属性。这种能力在ORM框架、配置系统、数据验证库中特别常见。class Validated: def __setattr__(self, name, value): if name age and not isinstance(value, int): raise TypeError(age must be int) super().__setattr__(name, value) v Validated() v.age old # 抛出 TypeError6.2 实战案例用__getattr__实现动态属性用__setattr__做写入控制一个非常实用的案例是给一个类加上动态代理能力实例访问不存在的属性时自动从内部字典或远程接口解析。这种写法在网络SDK封装里很常见。class DynamicConfig: def __init__(self, config_dict): self._data dict(config_dict) def __getattr__(self, item): # 仅在常规查找失败时调用 if item in self._data: return self._data[item] raise AttributeError(fno such config: {item}) def __setattr__(self, name, value): if name.startswith(_): super().__setattr__(name, value) else: self._data[name] value cfg DynamicConfig({host: localhost, port: 5432}) print(cfg.host) # localhost动态属性 cfg.timeout 30 # 实际写入 _data print(cfg._data) # {host: localhost, port: 5432, timeout: 30}这种写法有个必须注意的雷__init__里执行self._data dict(...)的时候实际上会触发__setattr__而此刻_data还不存在如果__setattr__里想访问self._data就会递归调用__getattr__导致无限递归。所以上面的写法用name.startswith(_)做分流先走super().__setattr__这种正常路径避免自我依赖。这是所有重写__setattr__的人都会踩的坑先记下来。6.3__getattribute__和__getattr__的区别什么时候用哪一个简单记忆法__getattribute__拦截一切属性访问有则必有完全接管的代价__getattr__只在正常查找失败后兜底是对缺失属性的自定义反应。所以在绝大多数场景下你不需要也不应该重写__getattribute__重写__getattr__就够用了而且风险小得多。一旦重写__getattribute__里面任何属性的访问都得小心因为在函数内部你访问self.xxx或type(self)也会再次触发__getattribute__很容易造成无穷递归。网上很多魔改属性访问的代码翻车基本都是掉进这个递归陷阱。7. 从现象到本质一个完整的谜之行为排查链路这一节我用一个非常典型的bug把前面所有知识点串起来演示真正的排查思路。假设项目里出现了这样一个现象一个类的所有实例莫名其妙共享了同一个列表属性后来我把类属性改为在__init__里生成实例属性却发现某些老实例的数据还在被新实例修改。排查链路第一步定位现象。先看是所有实例共享同一份数据还是部分实例共享。用id()打印每个实例对应属性的内存地址print(id(a.items), id(b.items), id(a.__class__.items if hasattr(a.__class__, items) else None))如果三个地址一样基本可以确定是类属性共享如果只是两个地址一样可能是某处代码显式地把一个实例的属性赋值给了另一个。第二步溯源定义位置。去看类体里有没有类似items []的写法或者__init__里有没有self.items self.__class__.items这种引用类属性的写法。前者是教科书级别的坑后者是隐蔽得多的坑——你本意是用类属性的副本但self.items SomeClass.items只是让实例属性指向了同一个列表引用并没有复制。第三步检查赋值方式。如果是self.items.append(...)那改的是列表本身共享对象被污染如果是self.items self.items [...]新列表会被创建旧列表不影响其他实例。这是原地修改和重新绑定的本质区别。第四步考虑描述符和魔法方法的介入。如果类上定义了同名property实例的self.items ...会走setter而不是直接进实例字典。此时查看类字典里的items条目是不是property对象往往比在实例字典里找答案更快。第五步用__dict__输出关键证据。把类字典和实例字典都打印出来对不上号的地方就是矛盾所在。这一步做完90%的属性谜题都能定位。这一套链路我复述过无数次给身边的同事核心思想其实只有一句话看到属性行为异常先问这个属性此刻定义在哪一层而不是急着改代码。层没找对改多少遍都是白费。8. 写在最后的几点个人建议文章已经很长了最后说几段我在实际项目中沉淀下来的体会不算总结就当同行之间聊聊天。第一别神话__slots__。它确实是省内存的有效手段尤其是大量同构实例配置项、坐标点、数据行的场景下收益可观。但代价是灵活性下降不能再动态添加属性。如果你在写一个长期维护的公共类属性只有少数几个且非常稳定用__slots__是划算的如果类还在快速演进先别上等结构稳定了再优化不迟。第二把类属性当默认配置、实例属性当运行时状态这个心智模型植入团队。类属性定义的应该是所有实例共享的信息如果它和实例运行状态混在一起几乎一定会出共享变量污染的bug。我在code review里看到最多的问题就是这两个混用。第三遇到属性相关诡异问题不要靠猜先把两个__dict__类的和实例的打出来看看再查MRO基本能覆盖绝大多数场景。这个方法比print大法定位快得多比面向b站的玄学调试靠谱得多。第四如果项目里在做ORM、配置中心、序列化框架这类重魔法代码的模块__getattr__、__setattr__、描述符这些机制是核心竞争力值得逐行吃透。但如果你写的是业务代码我反而建议少用这些代码可读性和可维护性有时候比高级写法更重要。让队友少挠头也是能力。
返回列表