Python 里弱引用到底弱在哪:拆开 weakref.ref、回调陷阱和 WeakValueDictionary

一句话结论 :弱引用是「能拿到对象、但不给对象续命」的一张便条。weakref.ref(obj) 不会让引用计数加一,所以对象一旦没有强引用,ref() 立刻返回 None;它解决的不是「访问」,而是「观察一个对象什么时候被回收」,以及「做缓存却不想把缓存本身变成内存泄漏」。

本文所有数字均来自本机 CPython 3.13.14 实测,代码可直接复制运行。

一、先看引用计数:弱引用到底「弱」在哪

python 复制代码
import sys, weakref, gc

class Node:
    def __init__(self, name): self.name = name

n = Node('a')
print(sys.getrefcount(n))          # 2
r = weakref.ref(n)
print(sys.getrefcount(n))          # 2   ← 没变
print(r() is n)                    # True

实测输出 2 → 2。sys.getrefcount 本身会因参数传递临时 +1,所以这里的 2 就是「变量 n」+「getrefcount 的临时引用」。

关键对比:如果写成 alias = n,计数会变成 3。而 weakref.ref(n) 之后仍是 2 ------ 弱引用不参与引用计数,这是「弱」的全部含义。

于是:

python 复制代码
del n
gc.collect()
print(r())      # None

对象被回收,r() 返回 None,而不是抛异常。这是 ref 与 proxy 最大的行为差异。

二、回调:注意拿到的是一个「已经死了」的引用

python 复制代码
def cb(ref):
    print('callback fired:', ref)

n = Node('a')
r = weakref.ref(n, cb)
del n
gc.collect()

实测输出:

css 复制代码
callback fired: <weakref at 0x000001F16F58B060; dead>

这里有个很容易踩的坑:回调被调用时对象已经被回收了,参数 ref 是 dead 状态,ref() 返回 None。想在回调里读对象的属性、打日志带上对象名,是拿不到的。正确做法是把需要的信息提前闭包捕获:

python 复制代码
def make_cb(name):
    def cb(ref):
        print(f'{name} 被回收了')
    return cb

r = weakref.ref(n, make_cb(n.name))

另外回调是在回收过程中同步调用的,别在里面做重活或再创建对已死对象的强引用,也别假设此时 GC 已经跑完。

三、proxy:像用原对象一样用,死了就抛异常

python 复制代码
p = Node('p')
pr = weakref.proxy(p)
print(pr.name)          # p
del p; gc.collect()
print(pr.name)          # ReferenceError

实测抛:

perl 复制代码
ReferenceError: weakly-referenced object no longer exists
形态 对象还活着 对象已回收 适用场景
weakref.ref(o) r() 返回对象 r() 返回 None 需要显式判空,最常用
weakref.proxy(o) 直接 pr.x 抛 ReferenceError 想让调用方无感,但必须能接受异常

proxy 还有个细节:type(pr) 是 ProxyType 而不是原类,isinstance 判断会失效。业务代码里我一般只用 ref,因为它把「可能没有」这件事显式暴露出来了。

四、不是所有对象都能被弱引用

实测六类内置对象:

类型 weakref.ref() 原因
int ❌ TypeError 不可变且常被缓存/小整数池常驻
str ❌ TypeError 不可变,可能驻留
tuple ❌ TypeError 不可变容器
list ❌ TypeError 未实现 __weakref__
dict ❌ TypeError 未实现 __weakref__
frozenset ✅ OK 例外:支持弱引用

报错文案统一是 cannot create weak reference to 'int' object 这类。

值得单独说 frozenset:它是不可变容器,但实测 可以 弱引用。所以「不可变就不能弱引用」不是准确规律,准确说法是 类型定义里是否带 __weakref__ 槽位。

五、__slots__ 的坑:省了内存,丢了弱引用

python 复制代码
class Slot:
    __slots__ = ('x',)
    def __init__(self): self.x = 1

weakref.ref(Slot())   # TypeError

实测报 cannot create weak reference to 'Slot' object。因为 __slots__ 明确列出了所有实例属性,默认不含 __weakref__。修法只有一个:

python 复制代码
class Slot:
    __slots__ = ('x', '__weakref__')

用 __slots__ 做内存优化、又同时要做缓存/观察者模式时,这一条几乎必踩。

六、WeakValueDictionary:缓存自动清理

python 复制代码
class Big:
    def __init__(self, k): self.k = k

wvd = weakref.WeakValueDictionary()
b = Big(1)
wvd['one'] = b
print(len(wvd), wvd.get('one'))   # 1  <Big object>
del b; gc.collect()
print(len(wvd), wvd.get('one'))   # 0  None

实测 len 从 1 自动变 0,get 返回 None,不需要手动清理。

这是弱引用最实用的场景:图片/大对象缓存。用普通 dict 存,缓存本身会让对象永远不被回收,缓存越大内存越涨;换成 WeakValueDictionary,业务侧没有强引用时条目自动消失。

对应的还有 WeakKeyDictionary(键弱引用,适合给对象挂元数据而不延长其生命)和 WeakSet。

⚠️ 注意 WeakValueDictionary 的值是弱引用,键仍是强引用 ,且值必须可弱引用。存 int/str 结果会直接 TypeError。

七、性能:解引用一次多少钱

本机 10 万次实测:

python 复制代码
timeit.timeit("x=n.name", setup="...", number=100000)   # 0.0022 s
timeit.timeit("x=r()",    setup="...", number=100000)   # 0.0049 s

解引用约是普通属性读取的 2.2 倍 (0.0049 / 0.0022)。单次开销在几十纳秒量级,绝大多数场景可忽略;但在热循环里对同一对象反复 r(),不如先取一次:

python 复制代码
obj = r()
if obj is not None:
    for _ in range(10000):
        obj.work()      # 别在循环里反复 r()

八、六条排错清单

  1. r() 突然返回 None → 不是 bug,是有强引用被删了。检查是否只把对象存在了 WeakValueDictionary 里。
  2. TypeError: cannot create weak reference → 对象是内置不可变类型,或用了 __slots__ 没加 __weakref__。给自定义类加 __slots__ = (..., '__weakref__')。
  3. 回调里读不到对象属性 → 回调触发时对象已死,把信息提前闭包捕获。
  4. proxy 抛 ReferenceError → 预期行为,改用 ref() + 判空,或捕获异常。
  5. WeakValueDictionary 条目莫名消失 → 检查是否有 GC(gc.collect())或临时变量出作用域;CPython 引用计数是即时回收,不是「等一会儿」。
  6. 循环引用对象一直不释放 → 弱引用不打破循环,A.b = B; B.a = A 仍需 gc 分代回收;弱引用只是让你能「观察」到它。

九、完整可运行示例

python 复制代码
import sys, weakref, gc

class Node:
    def __init__(self, name): self.name = name
    def __repr__(self): return f'Node({self.name})'

def demo():
    n = Node('a')
    print('refcount after ref:', end=' ')
    base = sys.getrefcount(n)
    r = weakref.ref(n)
    print(sys.getrefcount(n), '(unchanged)' if sys.getrefcount(n) == base else '(changed)')

    cache = weakref.WeakValueDictionary()
    cache['a'] = n
    print('cache alive:', len(cache), cache.get('a'))

    del n
    gc.collect()
    print('after del -> r():', r(), '| cache:', len(cache), cache.get('a'))

    p = Node('p'); pr = weakref.proxy(p)
    del p; gc.collect()
    try:
        pr.name
    except ReferenceError as e:
        print('proxy ->', e)

demo()

十、什么时候该用,什么时候别用

该用 :大对象缓存、观察者/监听器注册表、给对象挂元数据(WeakKeyDictionary)、调试内存泄漏时观察生命周期。

别用:需要保证对象一定存在的业务路径(用强引用 + 显式生命周期管理);拿弱引用当「延迟加载」的替代品;在回调里做关键业务。

弱引用不是「更轻的引用」,它是不参与所有权的一张便条 。想明白这一点,前面所有的 None、ReferenceError、自动消失的缓存条目,就都不奇怪了。


实测环境:CPython 3.13.14(Windows)。所有输出为本机 d56-probe.py 实际运行结果,未做修饰。

相关推荐
用户0190275816137 分钟前
用 DuckDB 存查 A 股 K 线:零服务、SQL 直接分析
python
程序员果子2 小时前
本体构建方法
人工智能·python·知识图谱·agent框架·本体
天天被压力3 小时前
【A股分钟级高频数据完全手册 #11】券商实时行情数据接口:实时交易(券商源)接入与字段
java·人工智能·python
总裁余(余登武)3 小时前
【python项目】:1操作EXCEl自动化开发
python·自动化·excel
芝梵家电数据笔记3 小时前
用字段表核对暖菜板长期使用条件:结构、暖区、质保边界
python·家电·数据校验·dataclass·选购
天天被压力3 小时前
【A股分钟级高频数据完全手册 #12】股票五档盘口数据接口:买卖五档实时盘口解析
java·人工智能·python
卷无止境15 小时前
RAGFlow 项目全景报告:从文档解析引擎到 Agent 原生数据库
后端·python
灿飞屋16 小时前
Kotlin与Java虚拟机三:控制流
android·java·开发语言·后端·python·kotlin·安卓
benchmark_cc16 小时前
单只股票五档盘口接入个人研究仓库:QuantDash 读取、校验与易错点
python·数据分析·pandas·量化交易·股票数据·quantdash