☰
Python对象内存管理:属性查找、__slots__与绑定方法底层原理
2026/10/2 22:26:45 网站建设 项目流程

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解释器并不是直接去实例的字典里翻,而是遵循一条固定的查找链路:

  1. 先查实例自身的__dict__(实例属性空间);
  2. 没找到,则查实例所属类的__dict__(类属性空间);
  3. 类里也没有,则沿着类的MRO(方法解析顺序)继续往父类的__dict__里找;
  4. 都找不到,抛出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为什么不需要手动free

CPython的内存回收主要靠引用计数。对象头部那个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" # 抛出 TypeError

6.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(f"no 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__、描述符这些机制是核心竞争力,值得逐行吃透。但如果你写的是业务代码,我反而建议少用这些,代码可读性和可维护性有时候比"高级写法"更重要。让队友少挠头,也是能力。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询