Chapter 50: Runtime Introspection
运行时内省(runtime introspection)讨论一个具体问题:程序已经运行起来以后,怎样从对象、callable、frame、源码位置和动态事件中还原它的结构与执行路径。读完本章后,读者应能判断一个框架、调试器、文档生成器或插件系统正在观察什么对象、触发了哪些查找路径、依赖哪些元数据,以及这些观察会给对象生命周期和静态分析带来什么约束。
本章的贯穿材料是一段很小的命令分发代码。它同时包含函数签名、注解、默认值、实例属性、property、调用栈和动态调用。围绕这段代码,可以看到 inspect.signature() 如何恢复 callable 的调用契约,dir()、getattr()、__dict__ 和 MRO 如何观察对象结构,frame 如何暴露执行状态,source inspection 为什么依赖文件与 loader,trace/profile hook 又怎样把一次真实运行转成事件流。
from __future__ import annotations
import inspect
class Command:
category = "system"
def __init__(self, name: str, enabled: bool = True):
self.name = name
self.enabled = enabled
@property
def label(self) -> str:
print("label computed")
return f"{self.category}:{self.name}"
def run(command: Command, *, retries: int = 1) -> str:
frame = inspect.currentframe()
try:
caller = frame.f_back.f_code.co_name if frame and frame.f_back else "<root>"
return f"{caller}:{command.label}:{retries}"
finally:
del frame
这段代码的运行时可观察对象包括 Command 类对象、run 函数对象、Command("sync") 实例、property descriptor、run 调用产生的 frame、以及函数体内部读取到的 caller frame。内省的核心能力来自同一条链:先确定观察对象,再选择观察入口,再判断入口是否会触发描述符、__getattr__、__getattribute__ 或 frame 持有,最后把观察结果限制在当前 Python 版本和 CPython 实现边界内。
本文以 Python 3.14 文档为版本边界。inspect 文档把它的服务归为类型检查、源码获取、类与函数检查、解释器栈检查等方向,并列出 function、frame、code object 等对象的常见可观察字段;sys.settrace() 和 sys.setprofile() 属于实现调试器、覆盖率工具与 profiler 的 runtime hook,行为更接近 CPython 运行平台能力。相关接口可对照 Python 3.14 inspect 文档、Python 3.14 sys 文档 和 Python 3.14 data model。
50.1 inspect
inspect 是标准库提供的 live object 观察入口。live object 指当前解释器进程中已经存在的对象,例如 module、class、function、method、traceback、frame、code object。它解决的问题是把“对象能被调用、能访问属性、能出现在 traceback 中”这类运行时现象,整理成工具可读取的元数据。
对贯穿材料来说,inspect 可以从 run 函数对象读出 signature,可以从 Command 类对象读出成员,可以从 frame 读出当前执行位置,也可以尝试从 run 反查源码文本。它的价值在于统一入口,代价在于不同入口的副作用和可靠性差异很大。读取 signature 通常观察 function object 与 wrapper metadata;读取属性成员可能触发 descriptor;读取 frame 会持有调用栈对象;读取 source 又依赖文件路径、linecache 与 loader 信息。
import inspect
print(inspect.isclass(Command))
print(inspect.isfunction(run))
print(inspect.signature(run))
print(inspect.getmembers(Command, predicate=inspect.isfunction))
这段代码证明 inspect 观察的对象仍然是普通 Python runtime 对象。Command 的类身份来自它的 type 层级,run 的函数身份来自 function object,signature 来自函数对象、可能存在的 wrapper 和 annotation 元数据,成员列表来自属性访问路径。inspect 本身没有绕开 Python 对象模型;它只是把对象模型中可观察的入口封装成稳定 API。
内省工具的第一条判断顺序是:先判断对象类别,再判断读取入口,再判断读取是否会执行用户代码。inspect.getmembers() 会按名字取成员,取成员时可能进入 descriptor、__getattr__ 或 __getattribute__。Python 3.11 增加的 inspect.getmembers_static() 用于静态成员读取,它减少动态查找带来的执行副作用,但也会返回 descriptor 本体,并且可能漏掉动态生成的成员。工具写作者需要根据目标选择入口:调试用户看到的真实属性时使用动态读取,生成文档和扫描插件能力时优先使用静态读取。
下面的流程图只覆盖一次对象内省的选择路径,重点在入口选择和副作用判断。
图中的分支说明了 inspect 的工程边界。动态读取回答“运行时访问这个名字会得到什么”,静态读取回答“对象结构里有哪些候选成员”。这两个问题面向不同场景。动态读取适合调试行为,静态读取适合文档、schema 提取和安全扫描。
50.2 runtime introspection
runtime introspection 是在对象仍然活着时读取结构、能力和状态。它常用的入口包括 dir()、getattr()、hasattr()、vars()、__dict__、type(obj)、obj.__class__、type(obj).__mro__、descriptor 对象和 annotation。它解决的问题是从一个未知对象出发,判断这个对象暴露哪些名字、名字来自实例还是类型、访问名字时是否触发协议。
对 command = Command("sync") 做内省时,command.__dict__ 只显示实例自身的存储:name 和 enabled。dir(command) 会合并实例、类、基类以及解释器认为应该展示的名字。getattr(command, "label") 会触发 property 的 __get__,从而执行 label 方法体。Command.__dict__["label"] 看到的是 property descriptor 本体。四个入口都在观察同一个对象,但回答的问题不同。
command = Command("sync")
print(command.__dict__)
print("label" in dir(command))
print(type(command).__mro__)
print(Command.__dict__["label"])
print(getattr(command, "label"))
这段代码的关键点是 label 的可见性和求值分开。dir(command) 说明名字可发现,Command.__dict__["label"] 说明名字由类字典中的 descriptor 提供,getattr(command, "label") 说明真正读取属性时会进入 descriptor 协议。框架做自动字段发现时,如果用 getattr() 探测每个名字,property、lazy attribute、数据库字段代理和远程资源代理都可能被执行。
对象结构的稳定检查顺序可以写成四步。第一步看实例存储,例如 obj.__dict__ 或 slot 信息,确认对象自身保存了哪些状态。第二步看类型存储,例如 type(obj).__dict__ 和 MRO,确认能力来自哪个类层。第三步看 descriptor 分类,确认访问名字时返回 descriptor 本体、绑定方法还是计算结果。第四步才执行 getattr(),观察用户代码路径中真实可见的值。
这个顺序也解释了 hasattr() 的边界。hasattr(obj, name) 内部依赖属性访问并捕获 AttributeError,所以它同样可能触发用户定义的查找逻辑。对“这个名字访问时是否成功”这个问题,hasattr() 有意义;对“对象结构中是否声明过这个名字”这个问题,类字典、MRO 和静态读取更直接。
50.3 signature
signature 是 callable 的调用契约对象。它把参数名、参数种类、默认值、annotation、可变位置参数、可变关键字参数、返回 annotation 和绑定规则整理成可检查结构。inspect.signature() 解决的问题是让框架在调用前判断参数是否匹配,并在文档、CLI、RPC、依赖注入和插件系统中还原 callable 的入口形状。
贯穿材料中的 run 有一个位置或关键字参数 command,一个 keyword-only 参数 retries,以及返回 annotation。inspect.signature(run) 得到的 Signature 对象包含有序参数映射。Signature.bind() 可以把实际传入的 args 和 kwargs 映射到参数名;传入方式和函数调用规则冲突时,它会抛出 TypeError。
sig = inspect.signature(run)
print(sig)
bound = sig.bind(Command("sync"), retries=3)
print(bound.arguments)
try:
sig.bind(Command("sync"), 3)
except TypeError as exc:
print(type(exc).__name__, exc)
这段代码展示了 signature 的两个层次。sig.parameters 是结构描述,回答 callable 接收什么;sig.bind(...) 是调用规则验证,回答当前参数能否进入这个 callable。run 把 retries 声明为 keyword-only,所以第二个位置参数无法绑定到它。这个错误来自签名绑定规则,早于函数体内部的 command.label 访问。
signature 的版本边界集中在 annotation 与 wrapper 上。Python 3.10 给 inspect.signature() 增加了 globals、locals 和 eval_str,用于处理字符串化 annotation;Python 3.14 增加了 annotation_format,可以控制返回 annotation 的格式。装饰器场景中,默认 follow_wrapped=True 会沿 __wrapped__ 找到被包装 callable 的签名;传入 follow_wrapped=False 时观察 wrapper 自身。框架生成用户 API 时应明确选择其中一种语义。
CPython 对部分 C 实现 builtin callable 可能无法提供完整参数元数据,文档也把这点标注为实现细节。工程判断上,signature 适合作为“可调用入口契约”的首选来源,但调用框架仍要处理 ValueError 和 TypeError。插件系统应把“无法提取签名”作为一种合法状态,然后选择拒绝注册、要求显式 schema,或退回到约定式调用。
50.4 frame inspection
frame inspection 是对当前调用栈执行状态的观察。frame 保存 code object、globals、locals、builtins、上一层 frame、当前行号、最近指令位置和 tracing 状态。它解决的问题是把“代码正在何处执行、由谁调用、当前局部状态是什么”转成调试器、日志系统、异常诊断和测试工具可读取的对象。
贯穿材料中,run 通过 inspect.currentframe() 取得当前 frame,再经由 f_back 读取 caller 的 f_code.co_name。这说明 frame inspection 看到的是执行状态对象,而非函数对象的静态描述。run.__code__.co_name 永远是 run 的 code name,frame.f_back.f_code.co_name 则取决于当前谁调用了 run。
def dispatch() -> str:
return run(Command("sync"), retries=2)
print(dispatch())
这段代码返回值中的 caller 名称来自 dispatch 的 frame。调用关系发生变化时,frame 链也会变化。测试框架、日志库和错误上报系统常用这种能力补充 caller 文件名、行号和函数名。对应的风险是 frame 会持有局部变量、全局变量和上一层 frame。保留 frame 引用会延长这些对象的生命周期,并可能制造引用环。
inspect 文档给出的安全形态是用 try/finally 删除临时 frame 引用,必要时用 frame.clear() 断开 frame 对局部变量的引用。贯穿材料中的 finally: del frame 就是这个目的。这里的工程结论很直接:frame 适合短时间读取诊断信息;长期缓存 frame 会把调试动作变成内存生命周期问题。
frame inspection 还有实现边界。inspect.currentframe() 在 CPython 中依赖解释器提供 Python stack frame 支持;文档说明如果某实现没有这种支持,它会返回 None。因此,库代码使用 frame 时应把 None 作为正常分支处理。跨实现工具应优先暴露显式上下文参数,例如调用方名称、trace id、logger extra 字段,把 frame 读取当作增强信息。
50.5 source inspection
source inspection 是从运行时对象反查源码文本、源码文件和源码行号。它解决的问题是让文档工具、调试器、错误页面和交互式帮助系统把对象重新定位到源文件。inspect.getfile()、inspect.getsourcefile()、inspect.getsourcelines() 和 inspect.getsource() 是常见入口。
对 run 调用 inspect.getsource(run) 时,解释器需要知道函数 code object 的 co_filename,再通过文件系统、loader、linecache 和源码行号定位文本。对普通 .py 文件中的函数,这条路径通常成立。对 builtin module、C 实现函数、交互式输入、动态 exec() 生成的代码、被打包工具改写过的模块,源码可能缺失或无法准确定位。
print(run.__code__.co_filename)
print(run.__code__.co_firstlineno)
try:
print(inspect.getsource(run))
except (OSError, TypeError) as exc:
print(type(exc).__name__)
这段代码把 source inspection 的材料来源拆开了。co_filename 和 co_firstlineno 来自 code object,说明函数在编译时记录了源码位置;inspect.getsource() 返回文本,说明当前运行环境还能通过这个位置取回源码。前者是 code object metadata,后者是环境能力。二者需要同时成立。
source inspection 的工程边界是版本、部署和 loader。框架把源码文本用于错误展示时,应将它视为诊断增强,而非业务逻辑前提。文档生成器可以在源码缺失时退回到 signature、docstring、annotation 和对象 metadata。热加载、zipapp、冻结二进制、notebook、REPL、远程执行环境都会改变源码可获取性。
源码读取还会带来一致性问题。运行时对象可能来自旧版本文件,磁盘上的文件已经被替换;linecache 可能缓存旧内容;装饰器可能让可见 callable 和源码中的函数层级有偏差。可靠的解释顺序是:先看对象本身的 metadata,再看 source inspection 是否成功,再把源码文本作为定位线索使用。
50.6 object metadata
object metadata 是对象携带的可观察身份信息。常见字段包括 __name__、__qualname__、__module__、__annotations__、__doc__、__dict__,函数对象还包含 __code__、__defaults__、__kwdefaults__、__globals__、__closure__ 等。它解决的问题是让工具在不执行业务逻辑的前提下识别对象、展示对象、建立索引或生成 schema。
__name__ 是对象定义时的短名。__qualname__ 是限定名,用于表达嵌套作用域或类内定义位置。__module__ 指向定义对象的模块名。__annotations__ 保存 annotation 映射。__doc__ 保存 docstring。__dict__ 对实例通常是可变字典,对类对象通常经由 mapping proxy 暴露类 namespace。它们组合起来,可以形成比 repr(obj) 更稳定的工具身份。
print(run.__name__)
print(run.__qualname__)
print(run.__module__)
print(run.__annotations__)
print(Command.__dict__["category"])
print(Command.__dict__["label"])
这段代码说明 metadata 有不同层级。run.__annotations__ 描述函数接口,Command.__dict__["category"] 是类属性值,Command.__dict__["label"] 是 descriptor 对象。工具如果只看 dir(),会知道这些名字存在;工具如果看 __dict__,能知道名字直接声明在哪个 namespace;工具如果执行 getattr(),会得到运行时访问结果。
metadata 的稳定性需要分对象类型讨论。用户定义函数和类通常暴露较完整的 Python-level metadata。C 扩展对象、builtin function、slot descriptor、method descriptor 的 metadata 可能较少。装饰器还会改变外层 wrapper 的 __name__、__doc__ 和 signature;使用 functools.wraps() 可以把部分 metadata 复制到 wrapper 上,并通过 __wrapped__ 保留被包装对象的访问链。
annotation 的读取也有版本边界。from __future__ import annotations 会让 annotation 以字符串形式保存,后续解析需要 globals、locals 和 annotation 解析策略。框架读取 annotation 时,应区分“原始 metadata”与“求值后的类型对象”。原始 metadata 更安全,求值后的对象更方便做运行时校验,但可能执行名字解析并触发导入依赖。
50.7 runtime reflection
runtime reflection 是基于内省结果动态改变程序行为。它常见于插件发现、依赖注入、ORM、序列化、RPC、CLI 生成、测试框架和文档生成。它解决的问题是让系统在编译前未知的对象进入统一流程:先发现能力,再验证契约,再构造调用,再注册到运行时表。
围绕贯穿材料,可以把 run 注册成命令处理器。注册器读取 signature,确认第一个参数能接收 Command,读取 keyword-only 参数构造 CLI 选项,读取 __module__ 和 __qualname__ 生成命令 id,最后把 callable 放入 registry。真正调用时,它用 Signature.bind() 检查参数,再执行 callable。
registry = {}
def register(handler):
sig = inspect.signature(handler)
registry[f"{handler.__module__}.{handler.__qualname__}"] = (handler, sig)
return handler
register(run)
handler, sig = registry[f"{run.__module__}.{run.__qualname__}"]
bound = sig.bind(Command("sync"), retries=1)
print(handler(*bound.args, **bound.kwargs))
这段代码展示了 reflection 的收益:调用方无需手写每个 handler 的 glue code,注册器从 callable 自身提取足够信息。它也展示了成本:系统行为由 runtime metadata 决定,静态分析工具较难提前知道所有可调用路径,重命名、装饰器、动态生成函数和 annotation 求值策略都会影响注册结果。
reflection 的稳定设计需要保留显式契约。第一,动态发现后要生成稳定 registry,后续执行从 registry 走,减少重复扫描和重复属性读取。第二,签名、annotation、docstring 和自定义 marker 应服务同一个 schema,减少多套元数据分歧。第三,框架要定义失败策略,例如缺少 signature、annotation 解析失败、property 执行异常、handler 名称冲突时如何处理。
reflection 与静态分析之间的张力来自信息出现时间。静态分析依赖源码中可见的导入、名字绑定和调用关系;reflection 在运行时通过对象图发现关系。工程上可以用显式 decorator、配置文件、类型标注、entry point 或代码生成把一部分动态关系提前暴露给工具。这样保留 runtime 灵活性,同时给 IDE、lint、测试覆盖率和发布检查提供可见输入。
50.8 dynamic analysis
dynamic analysis 是在程序真实执行过程中捕捉路径、事件和状态。它使用 trace/profile hook、frame、dis、inspect 和 logging 等材料,回答“这次运行到底走了哪条路径”。它解决的问题是补足静态阅读无法确认的分支、调用顺序、异常传播、递归深度、热点函数和动态分发结果。
sys.settrace() 设置 trace function,trace function 接收 frame、event、arg 三个参数,常见事件包括 call、line、return、exception 和 opcode。sys.setprofile() 面向 profiler,事件粒度偏向 call、return 与 C 函数调用返回。二者都和当前线程相关;多线程调试需要为不同线程安装对应 hook。官方文档也把 settrace() 明确放在调试器、profiler、coverage 工具这类用途上。
下面的最小 trace 只记录函数调用事件。它展示 dynamic analysis 怎样从一次 dispatch() 调用中拿到真实 call path。
import sys
def trace_calls(frame, event, arg):
if event == "call":
print("call", frame.f_code.co_name)
return trace_calls
sys.settrace(trace_calls)
try:
dispatch()
finally:
sys.settrace(None)
这段代码的观察结果来自真实解释器执行。frame.f_code.co_name 提供当前进入的 code object 名称,event 说明解释器正在发生的动作,arg 在 return 或 exception 等事件中携带额外信息。trace hook 的能力强,开销也直接落在解释器事件分发上;逐行或逐 opcode 观察会显著增加运行成本。
dis 和 logging 常与 trace/profile 配合。dis 解释 code object 的 bytecode 形状,帮助判断一次源码写法会生成哪些指令族;logging 记录业务层事实,例如请求 id、handler id、参数摘要和异常状态;trace/profile 记录解释器层事件。三个材料对应不同层级:bytecode 是可执行表示,日志是业务语义,hook 是运行路径。把层级分清,诊断结果才可复用。
对同类问题的检查顺序可以收束为六步。第一步,明确要回答结构问题、调用契约问题、源码定位问题还是真实路径问题。第二步,选取对象入口,结构问题看 __dict__、MRO 和 descriptor,调用契约看 signature,源码定位看 code object 与 source inspection,真实路径看 trace/profile。第三步,判断读取是否会执行用户代码。第四步,标注 Python 版本和 CPython 实现边界。第五步,处理失败分支,例如源码缺失、signature 缺失、frame 为 None。第六步,把观察结果转成显式 schema、日志字段、registry 或诊断报告,减少后续运行时猜测。
最小自检任务
阅读下面代码,判断 scan(Handler) 和 call_handler(Handler(), 3) 分别观察到哪些对象;说明 value 在结构扫描和真实调用中的差异;说明哪个位置可能触发用户代码。
import inspect
class Handler:
kind = "demo"
@property
def value(self):
print("computed")
return 10
def handle(self, item, *, retry=0):
return self.value + item + retry
def scan(cls):
names = dir(cls)
raw = cls.__dict__["value"]
sig = inspect.signature(cls.handle)
return names, raw, sig
def call_handler(obj, item):
method = getattr(obj, "handle")
sig = inspect.signature(method)
bound = sig.bind(item, retry=1)
return method(*bound.args, **bound.kwargs)
答案要点
scan(Handler) 的观察对象主要是类对象。dir(cls) 读取类可见名字集合,cls.__dict__["value"] 读取类 namespace 中的 property descriptor 本体,inspect.signature(cls.handle) 读取类上函数对象的调用契约。这里的 value 没有被实例属性访问触发,所以不会打印 computed。
call_handler(Handler(), 3) 的观察对象主要是实例和绑定方法。getattr(obj, "handle") 通过 descriptor 绑定生成 bound method;inspect.signature(method) 读取绑定方法的签名,通常会把已经绑定的 self 从调用方需要提供的参数中移除;sig.bind(item, retry=1) 验证实际参数;method(...) 执行函数体,函数体读取 self.value,property 的 __get__ 被触发,因此会打印 computed 并返回计算值。
关键判断顺序是先区分类 namespace 与实例属性访问,再区分 descriptor 本体与 descriptor 绑定结果,最后区分 signature 绑定验证与函数体执行。scan 的结果适合做结构文档;call_handler 的结果代表真实运行路径。可能触发用户代码的位置包括 getattr(obj, "handle") 的属性访问路径、method(...) 的函数体执行,以及函数体内部读取 self.value 时的 property 计算。
本章知识点总结
- 内省对象:runtime introspection 观察的是当前进程中的 live object,包括 class、function、method、frame、traceback 和 code object。
- 入口选择:同一个对象可通过
__dict__、MRO、dir()、getattr()和inspect观察,不同入口回答不同问题。 - 副作用边界:动态属性读取可能进入 descriptor、
__getattr__或__getattribute__,结构扫描应优先判断读取路径。 - 静态读取:
inspect.getmembers_static()和inspect.getattr_static()适合被动观察对象结构,但结果可能是 descriptor 本体。 - 签名契约:
inspect.signature()把 callable 的参数种类、默认值、annotation 和返回 annotation 整理为Signature。 - 绑定验证:
Signature.bind()用函数调用规则验证args与kwargs,失败时在函数体执行前抛出TypeError。 - Frame 状态:frame 暴露 code object、locals、globals、caller、行号和 tracing 状态,适合短期诊断执行路径。
- 生命周期风险:保留 frame 引用会延长局部对象和调用栈相关对象的生命周期,读取后应及时断开引用。
- 源码定位:source inspection 依赖 code object 的源码位置、文件、loader、linecache 和部署环境共同成立。
- 对象元数据:
__name__、__qualname__、__module__、__annotations__、__doc__和__dict__共同构成工具可读身份。 - 反射设计:runtime reflection 通过发现能力、验证契约、构造调用和注册对象来支撑插件、CLI、RPC 和文档系统。
- 静态张力:reflection 把关系推迟到运行时生成,工程上应用显式 schema、decorator 或 registry 提供稳定输入。
- 动态分析:trace/profile hook、frame、
dis和 logging 分别提供解释器事件、执行状态、bytecode 形状和业务语义。 - 检查顺序:同类问题应先确定问题类型,再选择观察入口,随后判断副作用、版本边界、失败分支和结果落地方式。