Python 基础体系 · 第 35/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python weakref 与 slots:生命周期观察、对象布局和内存取舍

Python 对象通常同时承担三件事:

  1. 保存状态;
  2. 参与引用关系;
  3. 通过属性系统暴露行为。

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 ────┘

useranother 是两个强引用。删除其中一个绑定:

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()

原因有两个:

  1. 两次调用可能得到不同结果;
  2. 在多线程环境下,其他线程可能在两次调用之间释放对象。

官方文档明确建议把 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。官方文档特别提醒,funcargskwargs 都不能直接或间接拥有被 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()withtry...finally 显式释放。Python 文档明确不建议依赖对象的立即最终化。(docs.python.org)

__del__ 也不是 del x 的同义词:

del x

只是删除名称绑定,通常表现为减少一次引用;只有在对象真正进入销毁流程时,才可能调用 __del__。对象可能仍被其他名称、容器、异常 traceback 或循环引用保持存活。(docs.python.org)

因此:

  • __del__ 适合极少数确实需要参与对象最终化的类型;
  • weakref.finalize 更适合为第三方对象或普通 Python 对象注册独立清理函数;
  • 确定性的资源释放应使用显式 API,而不是等待垃圾回收。

5. 哪些对象可以被弱引用

并非所有对象都支持弱引用。

通常支持弱引用的对象包括:

  • Python 定义的类实例;
  • Python 函数;
  • 实例方法;
  • 集合类型;
  • 一些文件对象、生成器、套接字、代码对象和扩展类型。

listdict 本身不直接支持弱引用:

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

tupleint 等类型即使子类化,也可能仍不支持弱引用;这属于 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

xy 的存储位置由类型在创建时确定,而不是每个实例都分配一个属性字典。

严格来说,__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__ 的内存收益主要来自:

  1. 不再为每个实例建立独立的 __dict__
  2. 属性值引用可以存放在固定布局中;
  3. 省去了动态属性名映射所需的部分结构。

属性查找可能因此更简单或更快,但速度并不是语言规范承诺的固定收益,实际结果取决于 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__")

这个类同时拥有:

  • 固定的 value slot;
  • 实例字典;
  • 弱引用入口。

但这通常削弱了使用 __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 构建变化,不能预先写死。比较时应注意:

  1. 两个类必须保存相同的逻辑数据;
  2. 创建数量应足够大,以减少固定开销的影响;
  3. 测量前后要处理前一轮对象;
  4. 不能把 tracemalloc 的结果等同于进程 RSS;
  5. 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

这里有三层职责:

  1. Image.__slots__ 固定实例字段;
  2. __weakref__ 允许缓存弱引用它;
  3. 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__ 越多越好”或“缓存都应该用弱引用”,而是要同时回答三个问题:

  1. 对象的字段集合是否稳定;
  2. 某个容器或回调是否应该拥有对象;
  3. 对象销毁后,程序是否仍然能够正确处理缓存未命中、引用失效和清理时机变化。

只有在这三个问题都得到明确回答后,weakref__slots__ 才是可靠的对象模型设计工具,而不只是表面的内存技巧。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。