“追踪变量中有哪些变量”这句话,我这些年帮同事排查问题的时候听过太多次。乍一听有点绕,拆开其实就是三件事:当前作用域里到底声明了哪些变量、某个对象或者字典内部装了哪些字段、顺着调用关系往上层翻,调用栈和闭包里还压着哪些变量没释放。这三件事看着稀松平常,真正要查得干净、查得全,反而能卡住不少人。这篇文章我就用 Python 3 当主线,把这三类“变量里的变量”逐一捋一遍,再顺手给一个能递归列出所有变量的探查工具。如果你调试时对着dir()的输出发懵,或者怀疑某个共享变量被偷偷改了,这篇应该能帮你少走不少弯路。
1. 先拆清楚:你追的是作用域、对象属性,还是容器里的键值
我见过太多人一遇到“变量不对”就立刻把对象整个 print 出来,打出来一看全是object at 0x...,什么有效信息都没有。问题不在于 print 不 print,而在于没先想清楚:你要找的“变量里的变量”到底在哪一层。
1.1 同名的一句话,背后是三个不同的问题
第一种场景是作用域层。你在一个函数里执行到一半,想知道“当前还能用哪些名字”,比如某个变量名从哪冒出来的、某个模块导入了什么、函数参数实际叫什么。这层要找的是命名空间里的绑定关系,dir()、vars()、locals()、globals()是主力。
第二种场景是对象属性层。你拿到一个实例对象,想知道它身上有哪些字段、哪些字段是实例自己的、哪些是从类或者父类继承下来的、哪些是动态算出来的。这层要查__dict__、__slots__、MRO,甚至__getattr__生成的“幽灵属性”。
第三种场景是容器内容层。你拿着一个 dict、list、dataclass 或者嵌套配置,想知道里面到底有多少层、每一层有哪些键值。这层靠的是递归遍历和路径输出,打印对象本身往往看不出全貌。
1.2 为什么必须先定位层级
因为三层的答案工具完全不同,而且互相不能替换。vars(obj)只能给看实例属性,看不到类属性;dir()能看到继承过来的名字,但取不到值;locals()在函数里只给你一份“近似快照”,改它根本不安全。如果你一开始就没分清在查哪一层,后面很容易被工具的返回结果误导,排查完一圈才发现方向从一开始就是错的。
我自己的判断习惯很简单:先问“这个东西是普通变量、对象、还是函数里的状态”。普通变量就去查作用域;对象就去查属性和类结构;函数就去查参数默认值、闭包 cell 和调用帧。这个习惯比任何 API 都重要。
| 目标层 | 典型问题 | 首选工具 | 主要盲区 |
|---|---|---|---|
| 作用域层 | 当前函数有哪些局部变量 | locals()、globals()、dir() | 局部变量视图是快照,改了不可靠 |
| 对象属性层 | 实例拥有哪些字段 | vars(obj)、__dict__、inspect.getmembers | 类属性、槽位、动态属性看不见 |
| 容器内容层 | 嵌套结构里有哪些键值 | 递归遍历 + 路径打印 | repr 截断、循环引用导致递归无限 |
定位完成之后,接下来的问题就是:用什么工具去“翻”这一层。Python 内置的几个探查工具各有脾气,下面逐个说清楚。
2. Python 内置变量探查工具全盘梳理:dir/vars/locals/globals 各自该信几分
2.1 dir() 返回的是“名字列表”,而不是变量本身
很多人把dir()当成万能钥匙,拿到一个对象就dir(obj)看半天。它的文档原意是:不传参时,返回当前作用域里的名字列表;传对象时,返回这个对象能访问到的属性名。注意,它返回的是字符串列表,不是变量和值的映射。
给你看个实际例子:
import inspect obj = {"name": "alice", "age": 30} print(dir(obj)) # ['__class__', '__contains__', '__delattr__', '__delitem__', ..., # 'clear', 'copy', 'fromkeys', 'get', 'items', 'keys', 'pop', # 'setdefault', 'update', 'values']列表里你会看到keys、values、items这些方法名,但它们都是字典对象的方法,不是 dict 里存的业务键。真正的内容键name和age根本不会出现在dir()里。所以当有人问“这个 dict 有哪些变量”时,用dir()回答是驴唇不对马嘴,正确做法是直接看.keys()。
dir()还有一个隐蔽问题:它的实现基于对象的__dir__方法,通常会合并实例属性和类属性,但它不保证这些属性一定可以安全访问。你看到obj.prop这个名字,但prop可能是个 property,底层 getter 一摸就抛异常。所以dir()适合用来找“候选名单”,不适合用来“直接取值”。
2.2 vars() 返回字典,但它只认dict
vars()不带参数时返回当前作用域的局部变量字典,带对象参数时返回该对象的__dict__。它的优点是直接把变量名和值放在一起,遍历起来非常顺手:
class Point: def __init__(self, x, y): self.x = x self.y = y p = Point(2, 3) print(vars(p)) # {'x': 2, 'y': 3}看上去很完美,但有个大坑:vars(obj)本质是“读obj.__dict__”。如果这个对象没有__dict__,它会直接抛TypeError: vars() argument must have __dict__ attribute。哪些对象没有?最典型的就是声明了__slots__的类实例,以及一部分内置对象。这种情况下,你得退回去用dir()拿名字、再用getattr()拿值,或者直接去读类里的__slots__声明。
还有一个更容易忽略的点:vars(obj)看到的只是实例自己的字段。如果类的层级上还挂着类变量,它不会出现在结果里。
class Counter: total = 0 # 类变量 def __init__(self): self.value = 1 # 实例变量 c = Counter() print(vars(c)) # {'value': 1},total 不在里面你单独看vars(c)会觉得这个对象只有value,但c.total明明可以访问。这就是“对象里有哪些变量”的经典答错场景。
2.3 locals() 在函数内部是个“近似快照”,globals() 才是活的
作用域层有两个看起来很像的东西:locals()和globals()。它们名字里都有“local/global”,用起来却差很多。
在模块顶层,两者返回同一个全局字典,基本等价。但进入函数之后,locals()就变得微妙了。CPython 实现中,函数内的locals()会返回一个专用字典,它会在调用时把当前局部变量的值复制一份进去。这意味着它不是一个实时视图。你往locals()返回的字典里塞一个新键,函数的真实局部变量不会因此多出这个键;反过来,函数后续执行改变了局部变量,那些已经拿到的locals()字典里的旧值也不会自动更新。
我调试的时候只把locals()当“一次性快照”看,绝不拿它当修改工具。想看当前函数有哪些局部变量名,直接dir()就行;想拿某个变量的当前值,直接引用变量名;想知道调用链各层的局部变量,用后面要讲的帧对象,比locals()靠谱得多。
globals()则不一样,它返回的是模块全局命名空间的真实引用。你在解释器里执行globals()["temp_value"] = 1,之后直接写temp_value就能取到。不过正因为它是活的,调试时改动它要非常谨慎,一个不留神就可能给模块挂上莫名其妙的全局变量,污染后面所有逻辑。
2.4 什么时候该换 inspect.getmembers
dir()给名字,vars()给__dict__的内容,但如果需要“把所有能访问到的属性和它的值一起拿出来”,inspect.getmembers()是更顺手的工具。
import inspect from pprint import pprint class Service: def __init__(self): self.host = "127.0.0.1" self.port = 3306 def connect(self): return f"{self.host}:{self.port}" svc = Service() members = inspect.getmembers(svc) pprint(members[:5]) # [('__class__', <class '__main__.Service'>), # ('__delattr__', <method-wrapper '__delattr__' of ...>), # ('__dict__', {'host': '127.0.0.1', 'port': 3306}), # ('__dir__', <bound method ...>), # ('__doc__', None)]注意到没有,getmembers()默认会把一堆__class__、__dict__、__doc__这些下划线开头的“元信息”也列出来。平时用的时候我强烈建议加第二个参数做谓词过滤,比如只看方法、只看函数、或者只看非下划线属性:
public_members = inspect.getmembers(svc, lambda name, value: not name.startswith("_"))不过要小心,getmembers()内部会逐个对属性做getattr求值,如果某个属性是 property 且 getter 有大副作用,或者会抛异常,这一步就会出事。安全起见,可以用inspect.getattr_static()去拿静态描述符,或者做好异常捕获,后面写递归探查函数时我会专门处理这个问题。
3. 深入对象内部:实例属性、类属性、槽位、动态属性都要留个心眼
工具只是第一步,真正容易翻车的是对“对象属性来源”的判断。一个属性看上去存在,但它的“老家”可能根本不在实例上。
3.1dict只看得到实例自己写的变量
实例属性存放在对象的__dict__里,这没错。但“对象能访问到的属性”是实例属性、类属性、父类属性一路合并的结果。vars(obj)只截取了最前面那一小块,剩下的大部分被它默认忽略了。所以排查顺序应该是:先看vars(obj),再看type(obj).__dict__,最后沿着 MRO 向上逐层翻。
3.2slots类没有实例dict,要从类侧和 dir 侧拼全貌
当一个类定义了__slots__,它的实例不再拥有__dict__,好处是省内存、属性访问更快,坏处是你没法用vars(obj)看到实例字段。
class Slotted: __slots__ = ("x", "y") def __init__(self): self.x = 1 self.y = 2 s = Slotted() # vars(s) # TypeError: vars() argument must have __dict__ attribute这时候最稳的做法是两条腿走路:先看类声明Slotted.__slots__知道有哪些槽位,再用getattr(s, slot)取每个槽位的值。dir(s)会列出包括x、y在内的所有可用名字,但它会把类的其他方法也混进来,需要自己过滤。
3.3 顺着 MRO 找属性的真正来源:类变量和父类变量
有时候属性名在dir(obj)里能看到,但vars(obj)里就是没有。这时候十有八九是类属性或者父类属性。属性解析的顺序是:实例自身__dict__-> 类__dict__-> 第一个父类__dict__-> 父类的父类……这个顺序就是 MRO(Method Resolution Order)。
我可以写个很小的工具函数,用来查一个属性到底来自哪一层:
def find_attr_origin(cls, name): for klass in cls.__mro__: if name in klass.__dict__: return klass return None class Base: shared = "base" class Child(Base): pass print(find_attr_origin(Child, "shared")) # <class '__main__.Base'>这个函数在排查“为什么这个变量莫名其妙存在”时非常有用。比如你怀疑某字段是哪儿来的,照着 MRO 顺序一翻,立刻就能看到是不是某个基类偷偷声明的类属性。
3.4getattr会让对象长出“幽灵属性”
比类属性更隐蔽的是__getattr__。只要一个类实现了这个方法,你访问任何不存在的属性,它都会收到一个名字参数,然后返回点东西。结果就是:hasattr(obj, anything)几乎永远返回 True,dir(obj)里却没有这个名字,vars(obj)里更不会有。
class Proxy: def __getattr__(self, name): return f"ghost:{name}" p = Proxy() print(p.whatever) # ghost:whatever print(vars(p)) # {},什么都没有 print("whatever" in dir(p)) # False这类“幽灵属性”是变量追踪里最折磨人的一种。你明明 printp.whatever有输出,但翻遍所有属性表都找不到来源。碰到这种情况,排查重心要立刻从“查属性表”切换到“查该类有没有定义__getattr__、有没有利用__getattribute__做转发”。如果有,所有属性都可能不是真实存储的字段,而是现算的。
3.5 捎带看看 JavaScript 里的枚举盲区
如果你平时也写前端,会发现同样的坑换个壳又出现。Object.keys(obj)只能拿到可枚举的自有字符串属性,不可枚举的直接被忽略;Object.getOwnPropertyNames(obj)能连不可枚举一起拿到;Reflect.ownKeys(obj)才把 Symbol 键也翻出来。很多“JSON.stringify 为什么漏字段”的问题,根源就是属性被定义成了enumerable: false,和 Python 里__slots__藏字段的逻辑异曲同工。思路相通:先分清名字、值、来源,再决定用哪个 API。
4. 顺着调用栈和闭包往回追:帧对象里还压着哪些变量
对象属性查完了,还有一类变量特别容易被忽略:它们不在当前对象里,也不在模块全局里,而是挂在调用栈的某个帧上,或者被闭包函数一路带到了现在。
4.1 从当前帧出发,逐层往回翻局部变量
Python 的调试器之所以能告诉你“现在在哪个函数里有哪些局部变量”,靠的就是帧对象。在函数内部,inspect.currentframe()能拿到当前帧,每个帧都有f_locals、f_globals、f_back几个关键属性。f_back指向上一个调用者的帧,顺着它就能把整条调用链翻一遍。
import inspect def dump_frames(): frame = inspect.currentframe() while frame: info = f"{frame.f_code.co_name} @ {frame.f_lineno}" names = list(frame.f_locals.keys()) print(f"{info}: {names}") frame = frame.f_back def outer(): a = 10 inner() def inner(): b = 20 dump_frames() outer() # inner @ 17: ['b', 'frame'] # outer @ 13: ['a', 'inner'] # <module> @ 19: []看到没,dump_frames()不只看到自己这一层的b和临时变量frame,还往上看到了outer()里的a和inner函数本身,甚至看到了模块顶层。这在排查“某个变量到底是在哪一层被引入/被修改”的时候特别管用。
4.2 还没进函数就先查“局中名单”:co_varnames
dir()只能在执行到函数内部时看到当前已定义的局部变量,但函数代码对象上其实刻着全部局部变量名,哪怕那一行还没执行到。用fn.__code__.co_varnames就能拿到。
def sample(a, b=2): c = a + b return c print(sample.__code__.co_varnames) # ('a', 'b', 'c')co_varnames会把参数和函数体内所有赋值过的局部变量都列出来,是“追踪变量中有哪些变量”这件事里最高效的一手名单。我调试那种“变量名拼错了但没报错”的问题时,经常先把co_varnames打印出来,再对照手写代码,错字一眼就能看出来。
4.3 闭包捕获的变量藏在closure里
闭包函数比普通函数多一点东西:它捕获外层函数的局部变量。这些变量不会出现在locals()里,也不会随着外层函数结束而消失,而是被藏在函数对象的__closure__元组中。
def make_counter(): count = 0 def counter(): nonlocal count count += 1 return count return counter c = make_counter() print(c()) # 1 print(c()) # 2 print(c.__code__.co_freevars) # ('count',) print(c.__closure__[0].cell_contents) # 2co_freevars给出闭包捕获的自由变量名,__closure__里的每个 cell 对象通过cell_contents给出当前值。在排查“为什么两次调用之间状态一直保留”的问题时,__closure__是比globals()更准确的落脚点。
4.4 对递归和异常排查的实际用法
帧对象和闭包这两套技术,我在两种场景下用得最多。
一是递归排查。递归函数出问题时,光看最表层没法确认中间过程。写一个遍历函数,每进入一层就把当前参数和局部变量记下来,递归结束后一起打印,能很快看出哪一层的参数变了形。
二是异常现场。异常对象的__traceback__可以逐帧拿到tb_frame.f_locals,相当于程序在异常瞬间的“案发现场快照”。日志里打一行 traceback 往往不够,把每一帧的局部变量也 dump 出来,信息量立刻翻倍。
需要提醒的是:帧对象f_locals在很多情况下也是只读近似视图,别试图往里边塞值去“修复现场”;调试器场景里它可能有一些魔法能力,但生产代码里绝不该依赖这玩意儿改局部变量。性能上帧遍历也不便宜,只适合调试和小范围诊断,不适合在热路径里常驻。
5. 写一个递归探查函数:把嵌套对象里的所有变量按路径列出来
聊了这么多工具和概念,现在动手把核心需求落地成一个可复用的函数:给定任意对象,递归列出它里面嵌套的所有“变量路径”,一直追到基本类型为止。
5.1 为什么 pprint 和 repr 不够用
Python 标准库的pprint很擅长打印字典和列表,但对任意类实例基本无能为力,打到对象上只能得到object at 0x...这种地址。repr则取决于类有没有实现__repr__,很多类压根没实现,输出全是地址。而且这两种方式都不带“路径”,你看一个很深的嵌套 dict,根本不知道3到底是config["retry"][0]还是config["retry"][1]。递归加路径才是正确答案。
5.2 完整实现与关键设计
我写了一个probe_vars函数,它的思路是:维护一个输出列表和已访问集合,递归地往下走;遇到 dict 就展开键,遇到 list/tuple/set 就按下标展开,遇到普通对象就优先展开__dict__,没有__dict__就退回dir()加getattr()枚举;用深度限制防止对象图太深,用id()集合防止循环引用。
def probe_vars(obj, out=None, prefix="", seen=None, depth=0, max_depth=4, include_private=False): if out is None: out = [] if seen is None: seen = set() # 基本类型直接输出 if isinstance(obj, (int, float, bool, str, bytes, type(None))): out.append(f"{prefix} = {obj!r}") return out # 可变容器要防循环引用 obj_id = id(obj) if isinstance(obj, (dict, list, set, frozenset)) or hasattr(obj, "__dict__"): if obj_id in seen: out.append(f"{prefix} = <circular: {type(obj).__name__}>") return out seen.add(obj_id) if depth >= max_depth: out.append(f"{prefix} = <max_depth reached>") return out if isinstance(obj, dict): for k, v in obj.items(): child_prefix = f"{prefix}.{k}" if prefix else str(k) probe_vars(v, out, child_prefix, seen, depth + 1, max_depth, include_private) return out if isinstance(obj, (list, tuple, set, frozenset)): for i, v in enumerate(obj): probe_vars(v, out, f"{prefix}[{i}]", seen, depth + 1, max_depth, include_private) return out if isinstance(obj, (type,)): names = dir(obj) elif hasattr(obj, "__dict__"): names = list(obj.__dict__.keys()) else: names = [n for n in dir(obj) if not n.startswith("_")] for name in names: if not include_private and name.startswith("_"): continue try: value = getattr(obj, name) except Exception as exc: out.append(f"{prefix}.{name} = <raises {type(exc).__name__}>") continue probe_vars(value, out, f"{prefix}.{name}", seen, depth + 1, max_depth, include_private) return out几个设计点值得说明一下。include_private默认关掉,不然每次都会刷出一大堆__class__、__dict__、__doc__之类的元信息,严重干扰找业务字段。深度默认max_depth=4,防止嵌套特别深的对象把终端刷爆。异常处理是整个函数最关键的保险,枚举属性时遇见会抛异常的 property,我把它记成<raises TypeError>而不是让整个函数崩掉。这样工具函数才能抗住真实世界里那些“一半属性能访问、一半属性一摸就炸”的类。
5.3 输出效果实测
拿一个常见的配置对象试一下:
class Config: def __init__(self): self.host = "127.0.0.1" self.port = 5432 self.options = {"timeout": 5, "retry": [1, 2, 3]} cfg = Config() for line in probe_vars(cfg): print(line)输出长这样:
.host = '127.0.0.1' .port = 5432 .options.timeout = 5 .options.retry[0] = 1 .options.retry[1] = 2 .options.retry[2] = 3每条输出都带着完整路径,哪里藏了变量一目了然。如果把max_depth调大,还能继续往retry元素内部追。配合find_attr_origin和帧遍历,基本能覆盖大多数“追踪变量中有哪些变量”的需求。
在处理 dataclass 时,还有一个补充视角:数据类会声明字段,但实例可能还没赋值。如果只想看“声明了哪些字段”,可以用dataclasses.fields();如果想把整个对象转成嵌套 dict 后再探查,可以用dataclasses.asdict()。这两者在排查序列化问题时比直接看__dict__更贴近业务定义。
6. 一次“多出个变量”的真实验尸过程:默认参数与闭包最爱藏东西
工具都备齐了,最后看一个真实感很强的排查案例。这类问题在后台服务里特别常见:同一个函数被调用多次,第二次的结果里总带着第一次的残留数据。
6.1 案例 A:函数默认参数里的可变列表成了公共缓存
某服务里有一个请求函数,最初写成这样:
def request_data(url, cache=[]): if url not in cache: cache.append(url) return cache第一次调用request_data("/api/a")返回["/api/a"],第二次调用request_data("/api/b")返回["/api/a", "/api/b"]。从调用者的角度看,明明只是请求一个新地址,结果里却莫名多出上一次的“变量”。我当时排查的顺序是:
- 先打印实例
__dict__,发现这个请求对象没有可疑字段。 - 再用
globals()搜了一遍模块级变量,没找到任何叫 cache 的全局名单。 - 最后才转到函数对象本身,打印
request_data.__defaults__,看到里面躺着一个非空列表,真相立刻浮现。
根因就是:Python 的默认参数是在函数定义时求值的,defaults=[]这个列表对象只创建一次,后续所有不传 cache 的调用共享同一个列表。修复方式就是标准的 None 哨兵:
def request_data(url, cache=None): if cache is None: cache = [] if url not in cache: cache.append(url) return cache这个案例给的教训很简单:查变量不要只查运行时的实例,还要查函数本身。func.__defaults__和func.__kwdefaults__是函数对象自带的“隐藏变量区”,专放默认参数,可变默认参数的串扰问题基本都从这里查起。
| 排查步骤 | 指令 | 发现 |
|---|---|---|
| 第一步 | vars(obj)/obj.__dict__ | 实例上没有可疑字段 |
| 第二步 | globals() | 模块全局没有 cache |
| 第三步 | request_data.__defaults__ | 找到非空列表,根因确认 |
6.2 案例 B:闭包里“不应该还在”的计数器
另一类藏变量更隐蔽,发生在闭包捕获里。某事件处理器由工厂函数创建,每次事件之后计数却不归零。
def make_handler(): count = 0 def handle(event): nonlocal count count += event return count return handle handler = make_handler() print(handler(1)) # 1 print(handler(2)) # 3直接查globals()和局部变量都找不到count,因为它是外层函数make_handler的局部变量,被内层函数捕获后挂在了handle.__closure__里。排查链路很直接:
handler.__code__.co_freevars显示('count',),说明它捕获了一个外层变量。handler.__closure__[0].cell_contents显示当前值。- 确认这个计数器被所有调用共享,所以第二次调用不可能是从 0 开始。
这种问题修起来要回到工厂函数的设计:要么把状态放进显式的实例对象,要么每次创建 handler 时重新调用工厂,让闭包捕获全新的 cell。核心是理解“闭包里的变量是捕获的引用,不是值拷贝”。
6.3 我总结的三个排查习惯
这几轮排查下来,我的习惯已经固化成三步。
第一,先定层,再动工具。作用域问题用locals/globals,对象问题用vars/dir/getmembers,函数状态问题直接查__defaults__和__closure__,调用链问题查帧对象。上来就乱打印,多半会白费时间。
第二,先拿名字,再取价值。名字列表只是候选名单,dir()里有的名字不一定能安全访问,property 的 getter 可能抛异常。取每个值之前先想好异常兜底,否则探查过程本身就变成事故现场。
第三,共享可变对象是排查的重灾区。任何“第二次调用多出变量”“多个请求互相串数据”的诡异现象,优先怀疑默认参数、类变量、闭包捕获这三处。这三处共享的引用一旦被修改,就是一个隐性全局变量,光看堆栈很难察觉。
我在实际调这些坑的时候,最大的感触是:大多数“奇怪变量”根本不是凭空长出来的,只是藏在了平时不会去翻的地方。把作用域、对象属性、函数默认参数、闭包 cell、调用帧这几层挨个走一遍,绝大部分谜题都能水落石出。