Python 基础体系 · 第 35/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python weakref 与 slots:生命周期观察、对象布局和内存取舍
Python 对象通常同时承担三件事:
- 保存状态;
- 参与引用关系;
- 通过属性系统暴露行为。
weakref 和 __slots__ 分别从两个方向改变这三件事:
weakref改变“一个引用是否足以延长对象生命周期”;__slots__改变“实例如何保存属性,以及实例是否拥有__dict__和弱引用入口”。
两者经常一起出现于缓存、对象注册表、图结构、事件订阅器和大量小对象模型中。但它们并不是简单的“内存优化开关”:弱引用影响可达性与生命周期,__slots__ 影响对象布局、属性查找、继承约束和动态扩展能力。
1. 先建立三个基础概念:对象、引用和可达性
Python 中的对象具有身份、类型和值。对象身份在其生命周期内不变;is 比较身份,id() 返回一个代表身份的整数。在 CPython 中,id() 通常就是对象所在的内存地址,但这属于 CPython 实现细节,不是 Python 语言层面的地址保证。(docs.python.org)
变量并不是对象本身,而是指向对象的名称绑定:
class User:
pass
user = User()
another = user
此时可以抽象为:
user ───────┐
├──> User 实例
another ────┘
user 和 another 是两个强引用。删除其中一个绑定:
del user
对象仍然可以通过 another 到达。
删除最后一个强引用:
del another
对象变得不可达。Python 语言规范只要求:仍然可达的对象不能被回收;至于不可达对象何时销毁,则由具体实现决定。CPython 通常使用引用计数,并辅以循环垃圾回收,因此大量没有循环的对象会在最后一个强引用消失后很快销毁,但程序不能依赖所有实现都立即完成销毁。(docs.python.org)
可以把对象生命周期简化成:
创建
│
▼
存在强引用 ───────────────┐
│ │
│ 最后一个强引用消失 │ 新增强引用
▼ │
不可达 │
│ │
├── 引用计数立即归零 ───┘
│
└── 或等待循环 GC
│
▼
对象销毁
这里的关键判定是:
对象是否仍然被强引用直接或间接地保持可达。
弱引用正是对“间接引用不会保持可达”这一规则的显式利用。
2. 什么是 weakref:指向对象,但不拥有对象
weakref.ref(obj) 创建一个弱引用对象。被引用的对象称为 referent,即“被引用对象”。
import weakref
class Document:
pass
document = Document()
reference = weakref.ref(document)
print(reference() is document) # True
调用弱引用对象:
reference()
会得到 referent;如果 referent 已经被销毁,则得到 None:
del document
print(reference()) # None
弱引用本身不构成保持对象存活的强引用。当一个对象只剩弱引用时,垃圾回收器可以回收该对象,并复用其内存。(docs.python.org)
这与普通引用有本质区别:
strong_holder = document
weak_holder = weakref.ref(document)
普通引用的关系是:
strong_holder ──强引用──> 对象
弱引用的关系是:
weak_holder ──弱引用──> 对象
前者增加对象的存活条件,后者只观察对象是否仍然存活。
2.1 弱引用并不是“延迟访问对象”
弱引用仍然可以暂时得到一个强引用:
obj = reference()
if obj is not None:
obj.process()
在 obj 这个局部变量存在期间,obj 是强引用,因此对象不会在这段代码中因为只有弱引用而消失。
正确模式是“一次取出,再使用”:
obj = reference()
if obj is None:
print("对象已经销毁")
else:
obj.process()
不应写成:
if reference() is not None:
reference().process()
原因有两个:
- 两次调用可能得到不同结果;
- 在多线程环境下,其他线程可能在两次调用之间释放对象。
官方文档明确建议把 ref() 的结果保存到局部变量中;这同时避免了单线程和多线程中的竞态窗口。(docs.python.org)
3. 弱引用回调发生在什么时刻
可以为弱引用注册回调:
import weakref
class Connection:
pass
def on_destroy(reference):
print("Connection 即将被销毁")
connection = Connection()
reference = weakref.ref(connection, on_destroy)
del connection
在 CPython 中,如果没有其他强引用,删除最后一个强引用通常会立即触发回调,输出类似:
Connection 即将被销毁
但更准确的语义是:
当对象即将被最终化时,回调会被调用;Python 语言层面不保证这一定紧随
del发生。
回调接收的是弱引用对象本身,而不是 referent:
def on_destroy(reference):
print(reference()) # 此时通常为 None
回调被调用时,referent 已经不可再通过该弱引用取得。(docs.python.org)
多个弱引用可以指向同一个对象:
first = weakref.ref(connection, lambda ref: print("first"))
second = weakref.ref(connection, lambda ref: print("second"))
多个回调按“后注册先调用”的顺序执行。回调中的异常不会传播回触发销毁的代码,而是按不可传播异常处理,并输出到标准错误。(docs.python.org)
3.1 回调不能反向拥有 referent
下面的写法会制造一个强引用闭包:
class Resource:
def __init__(self):
self.reference = weakref.ref(self, self._on_destroy)
def _on_destroy(self, reference):
print("destroyed")
self.reference 是弱引用对象,但回调 self._on_destroy 是绑定方法。绑定方法内部持有 self,于是关系变成:
self
│
└──> weakref 对象 ──> bound method ──> self
回调对象间接拥有 referent,导致对象可能无法销毁。
更安全的方式是让回调只捕获必要的、独立于对象本身的数据:
import weakref
def cleanup(resource_name):
print(f"清理 {resource_name}")
class Resource:
def __init__(self, name):
self.name = name
weakref.finalize(self, cleanup, name)
cleanup 接收到的是字符串 name,而不是 self。官方文档特别提醒,func、args 和 kwargs 都不能直接或间接拥有被 finalizer 监视的对象;尤其不能把对象的绑定方法作为清理函数。(docs.python.org)
4. weakref.finalize 与 __del__ 的区别
weakref.finalize(obj, func, *args, **kwargs) 创建一个最终化器。当 obj 被垃圾回收时,调用:
func(*args, **kwargs)
示例:
import tempfile
import shutil
import weakref
from pathlib import Path
class TempDirectory:
def __init__(self):
self.path = tempfile.mkdtemp(prefix="demo-")
self._finalizer = weakref.finalize(
self,
shutil.rmtree,
self.path,
)
def remove(self):
self._finalizer()
@property
def removed(self):
return not self._finalizer.alive
使用过程:
directory = TempDirectory()
path = Path(directory.path)
print(path.exists()) # True
directory.remove()
print(path.exists()) # False
print(directory.removed) # True
finalizer 最多执行一次:
finalizer()
finalizer() # 第二次返回 None,不重复执行
它还支持:
alive:是否仍然等待执行;detach():取消 finalizer,并取回其保存的对象、函数和参数;peek():查看保存的数据但不取消;atexit:进程退出时是否执行,默认是True。
进程退出时,仍然存活且 atexit 为真的 finalizer 会按创建顺序的逆序执行。不过,finalizer 不应被当作外部资源管理的唯一保障;文件、锁、数据库连接等资源仍应通过 close()、with 或 try...finally 显式释放。Python 文档明确不建议依赖对象的立即最终化。(docs.python.org)
__del__ 也不是 del x 的同义词:
del x
只是删除名称绑定,通常表现为减少一次引用;只有在对象真正进入销毁流程时,才可能调用 __del__。对象可能仍被其他名称、容器、异常 traceback 或循环引用保持存活。(docs.python.org)
因此:
__del__适合极少数确实需要参与对象最终化的类型;weakref.finalize更适合为第三方对象或普通 Python 对象注册独立清理函数;- 确定性的资源释放应使用显式 API,而不是等待垃圾回收。
5. 哪些对象可以被弱引用
并非所有对象都支持弱引用。
通常支持弱引用的对象包括:
- Python 定义的类实例;
- Python 函数;
- 实例方法;
- 集合类型;
- 一些文件对象、生成器、套接字、代码对象和扩展类型。
但 list 和 dict 本身不直接支持弱引用:
import weakref
items = []
weakref.ref(items)
# TypeError: cannot create weak reference to 'list' object
可以通过子类化部分内置类型获得支持:
import weakref
class WeakList(list):
pass
items = WeakList([1, 2, 3])
reference = weakref.ref(items)
print(reference() is items) # True
但 tuple 和 int 等类型即使子类化,也可能仍不支持弱引用;这属于 CPython 实现层面的限制。(docs.python.org)
支持弱引用需要对象布局中存在弱引用入口。对 Python 类来说,这个入口通常由普通实例机制隐式提供;一旦使用 __slots__,情况会改变。
6. __slots__ 解决的不是“属性更快”,而是改变实例布局
普通 Python 类的实例通常可以拥有实例字典:
class Point:
pass
point = Point()
point.x = 10
point.y = 20
print(point.__dict__) # {'x': 10, 'y': 20}
实例字典保存了“属性名到属性值”的映射。它的优点是灵活:
point.color = "red"
point.debug_info = {}
不同实例甚至可以拥有完全不同的属性集合。
__slots__ 则显式声明实例允许保存的成员:
class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
此时:
point = Point(10, 20)
print(point.x) # 10
print(point.y) # 20
point.color = "red"
# AttributeError
没有显式声明 __dict__ 时,实例不能添加未列出的属性;没有显式声明 __weakref__ 时,实例也不支持弱引用。Python 数据模型文档将这两项都列为 __slots__ 的直接影响。(docs.python.org)
6.1 普通实例的大致布局
从概念上,可以把普通实例抽象为:
实例对象
├── 类型信息
├── 引用计数等对象管理信息
└── __dict__ ──> 属性字典
├── "x" ──> 10
└── "y" ──> 20
属性值并不通常内嵌在字典中,而是由字典保存指向其他对象的引用。
6.2 使用 __slots__ 后的大致布局
实例对象
├── 类型信息
├── 引用计数等对象管理信息
├── slot x ──> 10
└── slot y ──> 20
x 和 y 的存储位置由类型在创建时确定,而不是每个实例都分配一个属性字典。
严格来说,__slots__ 是类级声明,类创建过程会为各个 slot 名称生成描述器。访问:
point.x
并不是从 point.__dict__ 中查找字符串 "x",而是通过类上的 slot 描述器读取实例布局中的对应位置。官方文档明确指出,__slots__ 是通过创建描述器实现的。(docs.python.org)
可以用类字典观察这一点:
class Point:
__slots__ = ("x", "y")
print(Point.__dict__["x"])
print(type(Point.__dict__["x"]))
输出会是一个描述器对象,具体类型属于实现细节,但它的职责是把属性名映射到实例中的固定 slot。
因此,__slots__ 的内存收益主要来自:
- 不再为每个实例建立独立的
__dict__; - 属性值引用可以存放在固定布局中;
- 省去了动态属性名映射所需的部分结构。
属性查找可能因此更简单或更快,但速度并不是语言规范承诺的固定收益,实际结果取决于 Python 实现、属性访问模式和基准环境。文档只说明空间节省可能显著,属性查找速度也可能改善。(docs.python.org)
7. __slots__ 与弱引用的关键交汇点:__weakref__
看下面的类:
import weakref
class Node:
__slots__ = ("value",)
node = Node()
weakref.ref(node)
结果通常是:
TypeError: cannot create weak reference to 'Node' object
原因是:定义了 __slots__ 后,实例默认不再拥有 __weakref__ 入口。
如果需要支持弱引用,必须显式加入:
import weakref
class Node:
__slots__ = ("value", "__weakref__")
node = Node()
reference = weakref.ref(node)
print(reference() is node) # True
__weakref__ 不是让用户保存业务数据的普通 slot。它是 Python 对象弱引用机制使用的特殊入口。添加它会让每个实例具备被弱引用的能力,因此会带来一定对象布局成本。
这形成了一个明确的取舍:
__slots__ = ("value",)
├── 没有实例 __dict__
├── 不支持动态属性
└── 默认不支持弱引用
__slots__ = ("value", "__weakref__")
├── 没有实例 __dict__
├── 不支持动态属性
└── 支持弱引用,但增加弱引用支持所需的布局
如果既需要动态属性又需要弱引用:
class FlexibleNode:
__slots__ = ("value", "__dict__", "__weakref__")
这个类同时拥有:
- 固定的
valueslot; - 实例字典;
- 弱引用入口。
但这通常削弱了使用 __slots__ 的主要目的:每个实例仍然可以创建 __dict__,因此不能把它当作完全固定布局的小对象。
8. 一个完整生命周期算例:普通类、slots 类和弱引用
下面的示例同时展示:
__slots__如何限制属性;__weakref__如何开启弱引用;weakref.ref如何观察销毁;gc.collect()在循环引用场景中的作用。
import gc
import weakref
class SlottedNode:
__slots__ = ("name", "peer", "__weakref__")
def __init__(self, name):
self.name = name
self.peer = None
def __repr__(self):
return f"SlottedNode({self.name!r})"
events = []
def on_destroy(reference):
events.append("destroyed")
left = SlottedNode("left")
right = SlottedNode("right")
left.peer = right
right.peer = left
reference = weakref.ref(left, on_destroy)
print(reference()) # SlottedNode('left')
del left
del right
print(reference()) # 在循环仍未被 GC 处理前,可能仍是对象
print(events) # 通常仍为空
collected = gc.collect()
print(collected) # 通常大于等于 0,具体数量不应写死
print(reference()) # None
print(events) # ['destroyed']
逐步分析:
第一步:创建对象
left = SlottedNode("left")
right = SlottedNode("right")
此时存在两个外部强引用:
left ──> left 对象
right ──> right 对象
第二步:形成循环
left.peer = right
right.peer = left
引用图变为:
left 对象 ──peer──> right 对象
▲ │
└──────peer────────┘
第三步:创建弱引用
reference = weakref.ref(left, on_destroy)
弱引用不会为 left 提供强可达性:
reference ──弱引用──> left 对象
第四步:删除外部名称
del left
del right
两个对象之间仍然相互引用,所以引用计数不会直接降到零。它们形成一个内部循环,但从程序根对象出发已经不可达。
第五步:执行循环 GC
gc.collect()
循环垃圾回收器发现这组对象不可达后回收它们,弱引用随之失效,回调被触发。
需要注意,gc.collect() 的返回值是本次收集的对象数与不可回收对象数之和,不应把某个具体数字当成稳定契约。Python 3.14 的 GC 还涉及代际行为变化,因此诊断代码应关注趋势和对象关系,而不是依赖某一个固定输出。(docs.python.org)
9. WeakValueDictionary:不会阻止值被回收的缓存
最常见的弱引用应用是缓存。
普通字典会持有值的强引用:
cache = {}
obj = object()
cache["key"] = obj
del obj
即使外部变量 obj 被删除,cache["key"] 仍然保持对象存活。
WeakValueDictionary 则只弱引用值:
import weakref
class Image:
def __init__(self, name):
self.name = name
def __repr__(self):
return f"Image({self.name!r})"
cache = weakref.WeakValueDictionary()
image = Image("logo")
cache["logo"] = image
print(cache.get("logo")) # Image('logo')
del image
print(cache.get("logo")) # None
print(dict(cache)) # {}
数据流可以表示为:
外部所有者 ──强引用──> Image
▲
│
cache ───────弱引用──────┘
外部所有者存在时,缓存可以命中;外部所有者消失后,缓存条目自动消失。
因此,弱缓存的语义不是“缓存一定保留对象”,而是:
只要对象因其他原因存活,缓存就尽量提供复用;内存紧张或所有者释放对象后,缓存可以自然失效。
缓存读取必须接受 miss:
def get_image(cache, key):
image = cache.get(key)
if image is None:
image = load_image(key)
cache[key] = image
return image
不能把弱缓存当作权威存储,也不能假设插入后对象永远存在。
10. WeakKeyDictionary:给外部对象附加元数据
WeakKeyDictionary 弱引用的是键:
import weakref
class Widget:
pass
metadata = weakref.WeakKeyDictionary()
widget = Widget()
metadata[widget] = {"theme": "dark"}
print(metadata[widget]) # {'theme': 'dark'}
del widget
当 widget 没有其他强引用时,对应的元数据条目会被删除。
它适合这样的场景:
- 不能修改对象所属的第三方类;
- 不希望通过附加属性延长对象或改变其结构;
- 元数据的生命周期应跟随对象。
但它有一个容易忽略的身份与相等性问题。字典的键查找基于哈希和相等性,而不是单纯的对象身份。官方示例展示了这种情况:插入与已有键相等但不是同一对象的新键时,值会更新,但内部保留的仍可能是原来的键;原键被销毁后,条目也会消失。(docs.python.org)
因此,如果业务要求“按对象身份关联”,而对象又重写了 __eq__ 和 __hash__,需要谨慎验证 WeakKeyDictionary 是否符合预期。必要时可以使用基于 id(obj) 的辅助结构,但必须同步处理对象销毁,否则又会引入悬挂元数据和 id 复用问题。
11. 绑定方法为什么不能直接用普通 weakref
实例方法查找会产生绑定方法对象:
class Worker:
def run(self):
print("running")
worker = Worker()
method = worker.run
method 大致包含两部分:
绑定方法
├── 原始函数 Worker.run
└── 实例 worker
如果直接写:
reference = weakref.ref(worker.run)
这个弱引用指向的是临时创建的绑定方法对象。该绑定方法对象可能很快消失,即使 worker 仍然存活。
应使用 weakref.WeakMethod:
import weakref
class Worker:
def run(self):
print("running")
worker = Worker()
method_ref = weakref.WeakMethod(worker.run)
method = method_ref()
if method is not None:
method()
WeakMethod 会分别弱引用实例和原始函数,并在两者仍存活时重新构造绑定方法。实例或函数任一消失,弱方法就失效。(docs.python.org)
这在事件订阅器中很有用:
class EventBus:
def __init__(self):
self._listeners = []
def subscribe(self, method):
self._listeners.append(weakref.WeakMethod(method))
def publish(self):
alive = []
for reference in self._listeners:
method = reference()
if method is not None:
alive.append(reference)
method()
self._listeners = alive
订阅器不会因为保存监听器方法而强行拥有监听器实例。
12. __slots__ 的继承行为:父类和子类的布局共同决定结果
12.1 子类没有定义 __slots__
class Base:
__slots__ = ("x", "__weakref__")
class Child(Base):
pass
Child 的实例仍然可以拥有 __dict__,因为子类没有继续声明 slots:
child = Child()
child.x = 1
child.extra = 2
print(child.__dict__) # {'extra': 2}
父类的 slot 仍然存在,但子类重新打开了动态属性字典。官方文档明确说明,子类如果不定义自己的 __slots__,实例会获得 __dict__ 和 __weakref__。(docs.python.org)
12.2 子类定义空 __slots__
class Base:
__slots__ = ("x", "__weakref__")
class Child(Base):
__slots__ = ()
此时 Child 不新增实例字典,也不新增实例属性:
child = Child()
child.x = 1
child.extra = 2
# AttributeError
这通常是希望整个继承层次保持固定布局时的写法。
12.3 子类增加自己的 slots
class Base:
__slots__ = ("x", "__weakref__")
class Child(Base):
__slots__ = ("y",)
实例布局可以理解为:
Base 部分
├── x
└── __weakref__
Child 部分
└── y
子类只需声明新增成员,不应重复声明父类已有的 slot 名称。
12.4 重复 slot 名称是错误设计
class Base:
__slots__ = ("x",)
class Child(Base):
__slots__ = ("x",)
这会生成两个不同层级的 x 描述器。子类的 x 会遮蔽父类 slot,父类 slot 可能只能通过直接访问父类描述器的方式取得。官方文档将这种程序行为描述为未定义,并指出未来可能增加检查。(docs.python.org)
不要把重复 slot 当作“覆盖父类字段”的机制。
13. 多重继承与 slots:为什么经常出现 TypeError
多个带有非空 slot 布局的父类不能任意合并:
class Left:
__slots__ = ("left",)
class Right:
__slots__ = ("right",)
class Combined(Left, Right):
pass
这种多重继承可能失败:
TypeError: multiple bases have instance lay-out conflict
实例布局需要确定每个字段的偏移和存储方式。两个父类都要求创建自己的实例字段时,解释器无法总是安全地把它们合并。
通常只有一个父类负责实际的 slot 布局,其他父类使用空 slots:
class Data:
__slots__ = ("value",)
class Mixin:
__slots__ = ()
class Combined(Data, Mixin):
pass
Python 数据模型明确限制了带实例属性的多重继承:多个 slotted 父类中通常只能有一个真正创建 slot 属性,其他基类必须是空布局。(docs.python.org)
14. __slots__ 并不递归节省所有内存
下面的类没有实例字典:
class Record:
__slots__ = ("name", "payload")
但它的属性值仍然可能是巨大的对象:
record = Record()
record.name = "item"
record.payload = {
"items": [1, 2, 3],
"metadata": {"source": "api"},
}
__slots__ 只改变 Record 实例自身保存属性引用的方式,不会让 payload 指向的字典、列表和字符串消失。
可以把总内存粗略分为:
总内存
≈ 实例固定部分
+ 属性存储结构
+ 属性值对象
+ 属性值对象内部的容器和引用
+ 分配器与运行时开销
__slots__ 主要影响前两项,尤其是实例数量很大、每个实例字段很少且固定时,收益才可能明显。
例如:
class WithDict:
def __init__(self, x, y):
self.x = x
self.y = y
class WithSlots:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
不要直接用某一次 sys.getsizeof() 输出断言“每个对象节省了多少字节”。sys.getsizeof() 返回对象自身的大小,并可能包含由对象分配器额外添加的垃圾回收开销,但不会递归计算对象所引用的内容;具体数值也依赖 Python 实现、构建方式和平台。(docs.python.org)
可以做相对比较:
import sys
normal = WithDict(1, 2)
slotted = WithSlots(1, 2)
print(sys.getsizeof(normal))
print(sys.getsizeof(slotted))
if hasattr(normal, "__dict__"):
print(sys.getsizeof(normal.__dict__))
这段代码只能说明当前解释器、当前平台下的对象外壳大小,不能替代真实工作负载的进程级测量。
15. 用 tracemalloc 验证整体分配,而不是猜测单个对象大小
tracemalloc 记录 Python 内存块的分配位置,可通过快照比较分配差异。应尽量在目标代码运行前启动追踪,否则启动前的分配不会出现在快照中。(docs.python.org)
下面是一个可执行的比较脚本:
import gc
import tracemalloc
class WithDict:
def __init__(self, x, y):
self.x = x
self.y = y
class WithSlots:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
def build(cls, count):
return [cls(i, i + 1) for i in range(count)]
def measure(cls, count):
gc.collect()
tracemalloc.start()
before = tracemalloc.take_snapshot()
objects = build(cls, count)
after = tracemalloc.take_snapshot()
stats = after.compare_to(before, "lineno")
total = sum(stat.size_diff for stat in stats)
print(f"{cls.__name__}: {total / 1024:.1f} KiB")
del objects
tracemalloc.stop()
measure(WithDict, 100_000)
measure(WithSlots, 100_000)
输出数值会随平台和 Python 构建变化,不能预先写死。比较时应注意:
- 两个类必须保存相同的逻辑数据;
- 创建数量应足够大,以减少固定开销的影响;
- 测量前后要处理前一轮对象;
- 不能把
tracemalloc的结果等同于进程 RSS; - C 扩展、底层分配器和非 Python 内存可能不在追踪范围内。
tracemalloc 更适合回答:
这段 Python 代码额外分配了哪些内存,以及分配发生在哪里?
它不一定能完整回答:
操作系统认为该进程占用了多少物理内存?
16. weakref 本身也有成本
弱引用不是免费的“零成本指针”。
使用弱引用通常会引入:
- 弱引用对象;
- 回调或弱容器维护结构;
- 对象内部的弱引用管理入口;
- 清理回调的调度成本;
- 弱容器的哈希表和删除逻辑。
因此,对每个对象都创建多个弱引用,可能抵消 __slots__ 节省的部分内存。
可以把一个带 slots 且支持弱引用的实例抽象为:
实例
├── 固定对象头
├── x slot
├── y slot
└── 弱引用管理入口
外部 weakref.ref 对象
└── 指向实例,并可能挂接 callback
如果对象数量很少,或对象本身占用远大于引用管理结构,弱引用成本通常不是主要问题;如果对象是数百万个只有两三个字段的小对象,就应通过基准确认:
- 只使用
__slots__; - 使用
__slots__加__weakref__; - 使用普通
__dict__; - 使用弱容器保存它们。
没有一个脱离平台和负载的固定答案。
17. 一个常见失败:缓存无意中把弱引用变成强引用
错误示例:
import weakref
class Model:
pass
cache = weakref.WeakValueDictionary()
callbacks = []
model = Model()
cache["model"] = model
callbacks.append(lambda: model)
del model
虽然缓存的值是弱引用,但 lambda 闭包捕获了 model。关系变为:
callbacks ──> lambda ──> model
cache ─────弱引用──────> model
对象仍被 callbacks 强引用,因此不会销毁。
可以改用弱引用:
model = Model()
reference = weakref.ref(model)
callbacks.append(
lambda: reference() if reference() is not None else None
)
但更稳妥的调用形式仍然是先取出一次:
def invoke(reference):
obj = reference()
if obj is not None:
return obj.process()
return None
如果闭包需要保存业务标识,应保存不可反向指向对象的标识或独立数据,而不是保存对象本身。
18. 诊断生命周期:从弱引用观察到引用链定位
18.1 用弱引用确认对象是否仍存活
import weakref
class Session:
pass
session = Session()
reference = weakref.ref(session)
print(reference() is not None)
del session
print(reference() is not None)
这是低侵入的生命周期观察方式,不需要在对象类中加入日志或 __del__。
18.2 用 gc.get_referrers() 查找直接引用者
import gc
class Session:
pass
session = Session()
owner = {"session": session}
for referrer in gc.get_referrers(session):
print(type(referrer))
gc.get_referrers() 只适合调试。它返回的是直接引用对象,而且可能包括正在构造中的临时对象;调用它本身也可能制造额外引用。文档明确警告不要把它用于调试之外的用途。(docs.python.org)
更完整的诊断流程通常是:
import gc
gc.collect()
for referrer in gc.get_referrers(session):
print(repr(referrer))
先执行一次收集,可以减少已经不可达但尚未处理的循环对象干扰。
18.3 检查对象是否被 GC 跟踪
import gc
class Node:
__slots__ = ("value", "__weakref__")
node = Node()
node.value = 1
print(gc.is_tracked(node))
gc.is_tracked() 返回当前对象是否由循环垃圾回收器跟踪。原子对象通常不被跟踪,容器和用户定义对象通常会被跟踪,但实现可以根据对象内容进行优化,因此不能仅凭该结果推导完整内存布局。(docs.python.org)
19. 用弱引用观察对象,不等于控制对象销毁
弱引用可以回答:
对象现在还存在吗?
它不能保证:
对象一定在某个时间点销毁。
尤其在以下情况下,销毁时间可能不确定:
- 使用非 CPython 实现;
- 存在循环引用;
- 垃圾回收被禁用;
- 对象被异常 traceback、全局变量或调试器保持;
- 对象在解释器关闭阶段仍然存活;
- finalizer 的创建和进程退出存在并发竞争。
例如,下面的函数可能让异常及其 traceback 暂时保持局部变量:
def run():
try:
raise RuntimeError("failure")
except RuntimeError as error:
return error
异常对象可能引用 traceback,traceback 又可能引用执行帧和局部变量。对象生命周期因此可能比表面上的局部变量作用域更长。Python 数据模型也特别提到异常 traceback 造成额外引用的可能性。(docs.python.org)
20. 何时选择普通类、__slots__ 或弱引用
可以按对象关系来判断,而不是按“优化习惯”判断。
使用普通 __dict__
适合:
- 属性集合会动态变化;
- 框架、插件或调试工具需要写入额外属性;
- 类层次复杂,依赖多重继承;
- 单个对象数量不大;
- 可读性和扩展性比实例大小更重要。
使用 __slots__
适合:
- 实例数量很大;
- 字段集合稳定;
- 希望禁止拼写错误导致的动态属性;
- 希望固定实例布局;
- 能够接受继承和序列化等方面的额外约束。
但应同时检查:
- 子类是否重新引入
__dict__; - 是否需要
__weakref__; - 是否有第三方工具依赖实例字典;
- 是否使用了不兼容的多重继承;
- 是否重复声明了父类 slot。
使用 weakref
适合:
- 缓存不应成为对象的所有者;
- 注册表只应观察外部拥有的对象;
- 元数据生命周期应跟随键对象;
- 事件订阅不应让监听器永久存活;
- 需要低侵入地观察对象生命周期。
不适合:
- 需要保证对象始终存在;
- 数据不能丢失;
- 需要确定性的资源关闭;
- 把弱缓存当作持久存储;
- 希望通过弱引用规避所有内存问题。
21. 最小完整模式:固定布局、可弱引用、可清理
下面给出一个相对完整的对象模型:
import weakref
class Image:
__slots__ = ("key", "data", "__weakref__")
def __init__(self, key, data):
self.key = key
self.data = data
class ImageCache:
def __init__(self):
self._values = weakref.WeakValueDictionary()
def remember(self, image):
self._values[image.key] = image
def get(self, key):
return self._values.get(key)
cache = ImageCache()
image = Image("logo", b"...")
cache.remember(image)
assert cache.get("logo") is image
del image
assert cache.get("logo") is None
这里有三层职责:
Image.__slots__固定实例字段;__weakref__允许缓存弱引用它;WeakValueDictionary不拥有图片对象。
如果以后给 Image 增加清理逻辑,仍应优先提供显式方法:
class Image:
__slots__ = ("key", "data", "__weakref__")
def __init__(self, key, data):
self.key = key
self.data = data
def close(self):
self.data = None
只有在确实需要“对象最终被回收时做补充动作”时,才考虑:
weakref.finalize(image, cleanup_function, independent_argument)
清理函数不能重新持有 image,也不能把 finalizer 当作确定的关闭协议。
22. 最终判断:两个机制优化的是不同层次
weakref 优化的是所有权关系:
这个引用是否应该让对象继续存活?
__slots__ 优化的是实例表示:
这个对象应该如何保存固定字段?
二者组合后的效果可以概括为:
__slots__
├── 减少实例字典相关开销
├── 限制动态属性
├── 通过描述器访问固定字段
└── 默认关闭弱引用支持
__weakref__
└── 重新开启实例被弱引用的能力
weakref.ref / 弱容器
├── 观察对象是否存活
├── 不因观察而拥有对象
└── 在对象销毁后自动失效或删除条目
真正的内存取舍不是“__slots__ 越多越好”或“缓存都应该用弱引用”,而是要同时回答三个问题:
- 对象的字段集合是否稳定;
- 某个容器或回调是否应该拥有对象;
- 对象销毁后,程序是否仍然能够正确处理缓存未命中、引用失效和清理时机变化。
只有在这三个问题都得到明确回答后,weakref 与 __slots__ 才是可靠的对象模型设计工具,而不只是表面的内存技巧。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 内存与垃圾回收:引用计数、循环 GC、分代与诊断
- 下一篇:Python GIL 与自由线程构建:互斥边界、扩展兼容和并行选择
- 延伸:Python 对象模型:type、object、属性查找、身份与生命周期
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论