Python 基础体系 · 第 30/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python 反射与自省:inspect、签名、Frame、AST 和安全边界
Python 的“反射”与“自省”经常被放在一起讨论,但它们关注的方向并不完全相同:
- 自省(introspection):程序观察自身或其他对象,回答“它是什么”“有哪些属性”“接受什么参数”“当前执行到哪里”。
- 反射(reflection):程序不仅观察对象,还根据名称或元数据动态获取对象、调用对象、修改对象,甚至构造新的类型或执行新的代码。
例如:
getattr(obj, "method")("value")
这里既有自省,也有反射:
- 通过
getattr()动态查找名为"method"的属性; - 得到属性对象;
- 动态调用该对象。
inspect 主要提供自省能力,ast 主要分析和改写源代码结构,frame 则把运行中的执行状态暴露出来。三者分别位于 Python 执行链路的不同层次:
flowchart LR
S[源代码] --> A[AST]
A --> C[编译器]
C --> B[代码对象 / 字节码]
B --> F[解释器 Frame]
F --> O[运行时对象]
O --> I[inspect 自省]
A --> T[AST 分析与转换]
F --> D[调试 / 跟踪 / 调用栈]
AST 观察的是“代码想表达什么”,代码对象和字节码接近“代码如何被编译”,Frame 观察的是“代码此刻如何执行”,inspect 则为这些运行时对象提供统一的查询接口。Python 语言参考明确规定,模块、函数体、类定义以及传递给 eval()、exec() 的字符串都属于代码块,而代码块在执行帧中执行。(docs.python.org)
一、先建立对象层次:名称、对象、类型和属性
1. 名称不是对象
Python 变量更准确地说是名称绑定:
x = 10
y = x
执行过程可以抽象为:
名称 x ─────┐
├──> 整数对象 10
名称 y ─────┘
x 和 y 是两个名称,整数对象 10 是一个对象。对象具有类型,类型本身也是对象:
x = 10
print(type(x)) # <class 'int'>
print(type(type(x))) # <class 'type'>
因此,反射系统通常需要回答三个不同问题:
obj = "hello"
type(obj) # obj 的直接类型
isinstance(obj, str) # obj 是否是某个类型或其子类的实例
hasattr(obj, "upper") # obj 是否能通过动态属性查找得到 upper
它们的语义不同:
type(obj) is T要求直接类型正好是T;isinstance(obj, T)允许继承关系;hasattr()实际上会尝试属性访问,因此可能触发描述符、__getattribute__()或__getattr__()。
2. 属性访问不是简单字典查询
下面这段代码中,obj.value 不一定等价于 obj.__dict__["value"]:
class Demo:
value = 10
def __getattr__(self, name):
if name == "missing":
return 42
raise AttributeError(name)
obj = Demo()
print(obj.value) # 10
print(obj.missing) # 42
属性查找可能经过:
- 类型上的数据描述符;
- 实例字典;
- 类型上的非数据描述符或普通属性;
__getattr__()兜底。
所以以下两种自省方式有重要差异:
import inspect
inspect.getmembers(obj)
inspect.getmembers_static(obj)
getmembers() 会执行动态属性查找,因此可能触发属性访问逻辑。getmembers_static() 则尽量绕过描述符协议、__getattr__() 和 __getattribute__(),适合做结构分析或避免访问副作用;但它可能看不到动态创建的属性,也可能返回描述符本身,而不是描述符执行后的值。(docs.python.org)
class Lazy:
@property
def value(self):
print("property executed")
return 10
obj = Lazy()
print(inspect.getmembers(obj, lambda x: x == 10))
# 会触发 property
print(inspect.getmembers_static(obj))
# 更可能看到 property 对象本身
这也是一个常见误区:
“枚举对象属性”并不一定是无副作用操作。
在插件系统、ORM、代理对象或远程对象中,调用 getattr() 可能触发数据库查询、网络请求、懒加载,甚至执行任意用户代码。
二、inspect:从对象、源码到调用栈
inspect 模块大体提供四类能力:
- 判断对象类别;
- 获取源码和定义位置;
- 分析函数、方法、类及其元数据;
- 检查解释器调用栈和 Frame。(docs.python.org)
1. 判断对象类别
import inspect
def f():
pass
class C:
def method(self):
pass
obj = C()
print(inspect.isfunction(f)) # True
print(inspect.isclass(C)) # True
print(inspect.ismethod(obj.method))# True
print(inspect.isroutine(f)) # True
print(inspect.iscode(f.__code__)) # True
绑定方法和函数不是同一个对象:
print(C.method)
# <function C.method ...>
print(obj.method)
# <bound method C.method of <...>>
print(obj.method.__func__ is C.method) # True
print(obj.method.__self__ is obj) # True
访问类属性时得到函数,访问实例属性时,函数描述符会把实例绑定为第一个参数。这就是为什么:
obj.method()
逻辑上等价于:
C.method(obj)
但这只是调用约定的直观表示,不应把绑定方法简单理解为“函数复制了一份”。绑定方法对象内部保存了:
- 原始函数:
__func__ - 被绑定对象:
__self__
2. 获取源码不是获取执行逻辑
import inspect
def add(a, b):
"""Return the sum."""
return a + b
print(inspect.getsource(add))
print(inspect.getdoc(add))
print(inspect.getsourcefile(add))
print(inspect.getsourcelines(add))
inspect.getsource() 依赖源码文件或可恢复的源码位置。对于内置函数、C 扩展函数、交互式解释器中定义的函数、动态生成的函数,源码可能不可用;文档规定此时可能抛出 TypeError 或 OSError。(docs.python.org)
因此:
inspect.getsource(len)
不能作为“所有函数都能反射出源码”的依据。函数对象的 __code__ 也不等价于源码:
print(add.__code__.co_filename)
print(add.__code__.co_firstlineno)
print(add.__code__.co_varnames)
print(add.__code__.co_consts)
代码对象保存的是编译后的执行信息,例如文件名、起始行号、局部变量名、常量等。注释、空白、部分源码结构已经不在代码对象中。
如果需要观察编译结果,可以使用 dis:
import dis
dis.dis(add)
不过字节码格式属于具体 Python 实现和版本相关的实现细节,不能把某一版本的指令名称、偏移量或优化结果当作语言规范。dis.dis() 在 Python 3.14 中支持 show_positions,还可以显示更完整的源代码位置信息;同时,show_caches 和 adaptive 用于观察解释器缓存和特化指令。(docs.python.org)
三、函数签名:不要手工解析 __defaults__
1. 签名描述“如何调用”
函数签名是一个形式化调用契约。考虑:
def request(
path,
/,
method="GET",
*paths,
timeout=3.0,
**headers,
):
pass
它包含五类参数:
| 参数 | 类型 | 调用方式 |
|---|---|---|
path |
仅位置参数 | 不能写成 path=... |
method |
位置或关键字参数 | 两种方式都可 |
paths |
可变位置参数 | 收集额外位置参数 |
timeout |
仅关键字参数 | 必须写成 timeout=... |
headers |
可变关键字参数 | 收集额外关键字参数 |
import inspect
sig = inspect.signature(request)
print(sig)
for name, parameter in sig.parameters.items():
print(
name,
parameter.kind,
parameter.default,
parameter.annotation,
)
inspect.signature() 是推荐的可调用对象自省 API,能够处理位置专用参数、关键字专用参数、包装函数和多种可调用对象。某些 C 实现的内置函数可能不提供足够的参数元数据,因此并非所有 callable 都能得到签名。(docs.python.org)
2. bind() 是参数匹配算法
不要把签名理解成装饰性的字符串。它可以真正执行“调用参数是否符合签名”的匹配:
def f(a, b=2, *, c=3):
return a + b + c
sig = inspect.signature(f)
bound = sig.bind(1, c=10)
print(bound.arguments)
# {'a': 1, 'c': 10}
bound.apply_defaults()
print(bound.arguments)
# {'a': 1, 'b': 2, 'c': 10}
print(bound.args)
# (1,)
print(bound.kwargs)
# {'b': 2, 'c': 10}
参数绑定可以分成几个步骤:
- 将位置参数从左到右匹配位置参数;
- 将关键字参数按名称匹配;
- 将剩余位置参数收集到
*args; - 将剩余关键字参数收集到
**kwargs; - 检查必填参数是否缺失;
- 检查是否出现重复、非法或位置方式错误;
- 只有在
apply_defaults()后,默认值才会补入BoundArguments.arguments。
例如:
try:
sig.bind(1, 2, 3)
except TypeError as exc:
print(exc)
# too many positional arguments
这里 c 是关键字专用参数,第三个位置参数不能绑定到它。
bind_partial() 则允许缺少部分必填参数,适合构造延迟调用:
partial = sig.bind_partial(1)
print(partial.arguments)
# {'a': 1}
实际调用时可以使用绑定结果:
f(*bound.args, **bound.kwargs)
3. 装饰器和 __wrapped__
装饰器经常改变函数对象:
from functools import wraps
import inspect
def log_call(func):
@wraps(func)
def wrapper(*args, **kwargs):
print("calling", func.__name__)
return func(*args, **kwargs)
return wrapper
@log_call
def greet(name: str, excited: bool = False) -> str:
return f"Hello {name}"
print(inspect.signature(greet))
# (name: str, excited: bool = False)
@wraps 会复制若干元数据,并设置 __wrapped__。inspect.signature() 默认会沿着 __wrapped__ 链解包,因此看到的是原函数签名。
如果要检查包装器本身:
print(inspect.signature(greet, follow_wrapped=False))
# (*args, **kwargs)
手工设置 __signature__ 也可以影响签名显示:
def wrapper(*args, **kwargs):
return greet(*args, **kwargs)
wrapper.__signature__ = inspect.Signature([
inspect.Parameter(
"name",
inspect.Parameter.POSITIONAL_OR_KEYWORD,
annotation=str,
)
])
print(inspect.signature(wrapper))
# (name: str)
但 __signature__ 主要是自省元数据,不会自动改变真实调用行为:
wrapper()
仍然可能因为实际包装器逻辑而报错。签名和运行时调用必须保持一致,否则会产生更难诊断的工具链错误。
4. 标注不是无害字符串
Python 3.14 中,inspect.get_annotations() 已经是 annotationlib.get_annotations() 的别名;读取标注时,选择不同格式可能导致标注表达式被求值或被解释。官方文档明确警告,读取标注可能执行其中的任意代码。(docs.python.org)
例如:
def f(x: "int | str") -> "bytes":
pass
工程代码中不应无条件执行字符串标注:
inspect.get_annotations(f, eval_str=True)
更稳妥的做法取决于需求:
- 只展示标注:优先使用字符串形式;
- 需要运行时类型对象:在可信代码和明确命名空间中求值;
- 处理第三方插件:不要因为“只是读取类型标注”就认为没有代码执行风险。
这条边界很容易被忽视,因为“自省”通常被误认为是只读操作。
四、Frame:调用栈中的一次执行状态
1. Frame 是什么
Frame,即解释器帧,是一个代码块执行时的运行状态。一个 Frame 至少关联:
- 当前执行的代码对象:
frame.f_code - 全局命名空间:
frame.f_globals - 局部命名空间:
frame.f_locals - 调用它的上层帧:
frame.f_back - 当前指令位置:
frame.f_lasti - 当前执行行号:
frame.f_lineno
可以用一个调用链观察它:
import inspect
def outer():
value = "outer"
return inner()
def inner():
number = 42
frame = inspect.currentframe()
try:
current = frame
while current is not None:
print(
current.f_code.co_name,
current.f_lineno,
sorted(current.f_locals),
)
current = current.f_back
finally:
del frame
outer()
典型输出类似:
inner ...
['current', 'frame', 'number']
outer ...
['value']
<module> ...
['inspect', 'inner', 'outer']
inspect.currentframe() 返回调用者的 Frame;在不提供 Python 栈帧支持的实现中,它可能返回 None。sys._getframe() 也可以按深度获取调用栈中的 Frame,但它是带下划线的实现相关接口,并触发审计事件。(docs.python.org)
2. f_locals 不是可靠的变量修改接口
可以观察局部变量:
def demo():
x = 1
frame = inspect.currentframe()
try:
print(frame.f_locals["x"])
finally:
del frame
demo()
但不要依赖下面这种方式修改正在执行的局部变量:
def demo():
x = 1
frame = inspect.currentframe()
try:
frame.f_locals["x"] = 99
print(x)
finally:
del frame
在 CPython 中,局部变量可能存放在优化后的快速局部变量结构中,而 f_locals 是对外暴露的映射视图;向字典写入不等于一定同步回正在执行的局部变量。不同实现和不同执行阶段也可能有差异。
因此,Frame 更适合:
- 读取调试信息;
- 生成错误上下文;
- 实现调试器、覆盖率工具和分析器;
- 追踪调用来源。
它不适合作为常规业务代码中的隐式依赖注入机制。
3. 保存 Frame 会延长对象生命周期
Frame 通过 f_locals 持有局部变量,通过 f_back 持有调用者,因此保存 Frame 可能形成引用环:
frame -> locals -> 某个对象 -> frame
例如异常处理器把 Frame 保存到全局变量,可能间接保留大对象、请求上下文和敏感数据。官方文档建议使用 finally 删除临时 Frame 引用,或者对需要保留的 Frame 调用 frame.clear() 破坏引用关系。(docs.python.org)
import inspect
def safe_inspect():
frame = inspect.currentframe()
try:
return frame.f_code.co_name
finally:
del frame
如果只需要错误位置,不要保存完整 Frame:
def location():
frame = inspect.currentframe()
try:
return {
"filename": frame.f_code.co_filename,
"function": frame.f_code.co_name,
"line": frame.f_lineno,
}
finally:
del frame
4. inspect.stack() 和 sys.settrace()
inspect.stack() 返回当前调用栈的 FrameInfo 列表:
import inspect
def a():
return b()
def b():
for item in inspect.stack():
print(item.function, item.filename, item.lineno)
a()
它适合诊断和调试,但不适合高频热路径。因为它通常需要收集多个帧、读取源码上下文并构造 FrameInfo 对象。
如果要实现逐行跟踪,可以使用 sys.settrace():
import sys
def trace(frame, event, arg):
if event == "line":
print(frame.f_code.co_name, frame.f_lineno)
return trace
def work():
x = 1
y = 2
return x + y
sys.settrace(trace)
try:
work()
finally:
sys.settrace(None)
sys.settrace() 是线程相关的;要跟踪多个线程,需要分别注册,或使用 threading.settrace()。官方文档将其定位为调试器、分析器和覆盖率工具使用的接口,其行为属于实现平台的一部分,而不是完整 Python 语言定义。(docs.python.org)
五、AST:在执行前理解和改写代码结构
1. AST 不是源码,也不是字节码
AST,即抽象语法树,是源代码经过词法和语法分析后的结构化表示:
source = "total = price * count"
可以表示为:
Module
└── Assign
├── target: Name("total", Store)
└── value: BinOp
├── Name("price", Load)
├── Mult
└── Name("count", Load)
使用 ast.dump() 查看:
import ast
tree = ast.parse("total = price * count")
print(ast.dump(tree, indent=4))
Name 节点中的 ctx 表示名称的使用上下文:
Load():读取;Store():绑定或赋值;Del():删除。
例如:
print(ast.dump(ast.parse("x"), indent=4))
print(ast.dump(ast.parse("x = 1"), indent=4))
print(ast.dump(ast.parse("del x"), indent=4))
AST 能保留语法结构,但通常不保留注释、空白等格式信息。它适合做静态分析和源码转换,不适合恢复原始源码格式。
2. ast.parse() 的模式
ast.parse("x = 1", mode="exec")
返回 Module,适合解析语句序列。
ast.parse("x + 1", mode="eval")
返回 Expression,要求输入是单个表达式。
ast.parse("(int, str) -> list[str]", mode="func_type")
用于解析旧式类型注释函数签名。
ast.parse() 本质上是以只返回 AST 的编译模式调用编译器;feature_version 可以对语法版本进行尽力而为的兼容解析,最高版本通常是当前解释器版本。(docs.python.org)
3. NodeVisitor:分析 AST
import ast
class NameCollector(ast.NodeVisitor):
def __init__(self):
self.loaded = set()
self.stored = set()
def visit_Name(self, node):
if isinstance(node.ctx, ast.Load):
self.loaded.add(node.id)
elif isinstance(node.ctx, ast.Store):
self.stored.add(node.id)
self.generic_visit(node)
tree = ast.parse("""
price = 10
count = quantity
total = price * count
""")
collector = NameCollector()
collector.visit(tree)
print(collector.loaded)
# {'quantity', 'price', 'count'}
print(collector.stored)
# {'price', 'count', 'total'}
这个分析可以帮助实现:
- 未定义名称检查;
- 依赖收集;
- API 使用扫描;
- 禁止语法检查;
- 简单的代码度量。
不过它不是完整的名称解析器。作用域、闭包、global、nonlocal、导入和动态属性都会使“出现过的名字”与“运行时实际绑定”产生差异。Python 的名称解析受代码块和作用域规则影响,局部绑定还会影响同一代码块中其他名称的解释。(docs.python.org)
4. NodeTransformer:改写 AST
下面的转换器把所有变量读取改写为 data["变量名"]:
import ast
class RewriteName(ast.NodeTransformer):
def visit_Name(self, node):
if isinstance(node.ctx, ast.Load):
return ast.copy_location(
ast.Subscript(
value=ast.Name(id="data", ctx=ast.Load()),
slice=ast.Constant(value=node.id),
ctx=ast.Load(),
),
node,
)
return node
source = "price * count"
tree = ast.parse(source, mode="eval")
new_tree = RewriteName().visit(tree)
new_tree = ast.fix_missing_locations(new_tree)
print(ast.unparse(new_tree))
# data['price'] * data['count']
code = compile(new_tree, "<rewritten>", "eval")
print(eval(code, {"__builtins__": {}}, {
"data": {"price": 10, "count": 3}
}))
# 30
这里有三个关键步骤:
visit_Name()返回新节点,替换原来的Name;ast.copy_location()将原节点的位置信息复制给新节点;ast.fix_missing_locations()为缺少位置的新子树补齐位置信息。
官方文档规定,NodeTransformer 的访问方法返回 None 时会删除节点,返回其他 AST 节点时会替换节点;新增节点没有位置信息时,应调用 fix_missing_locations()。(docs.python.org)
5. 一个完整的安全表达式检查器
如果业务只允许用户输入数学表达式,可以先解析 AST,再限制节点集合:
import ast
import operator
ALLOWED_BINOPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
}
class SafeExpression(ast.NodeVisitor):
def visit_Expression(self, node):
return self.visit(node.body)
def visit_Constant(self, node):
if isinstance(node.value, (int, float)):
return node.value
raise ValueError("只允许数字常量")
def visit_Name(self, node):
if node.id not in {"x", "y"}:
raise ValueError(f"不允许的名称: {node.id}")
return {"x": 10, "y": 3}[node.id]
def visit_BinOp(self, node):
left = self.visit(node.left)
right = self.visit(node.right)
operation = ALLOWED_BINOPS.get(type(node.op))
if operation is None:
raise ValueError(f"不允许的运算符: {type(node.op).__name__}")
return operation(left, right)
def generic_visit(self, node):
raise ValueError(f"不允许的语法节点: {type(node).__name__}")
def evaluate(expression):
tree = ast.parse(expression, mode="eval")
return SafeExpression().visit(tree)
print(evaluate("x * 2 + y"))
# 23
try:
evaluate("__import__('os').system('echo hacked')")
except ValueError as exc:
print(exc)
# 不允许的语法节点: Call
这个例子安全的原因不是“调用了 eval() 时把 __builtins__ 设为空”,而是:
- 输入先被解析为 AST;
- 只允许白名单节点;
- 不执行用户提供的任意代码;
- 最终由程序自己的解释逻辑计算结果。
如果改成:
eval(user_input, {"__builtins__": {}}, {})
只能减少一部分直接名称访问,不能构成通用安全沙箱。Python 对象模型仍然包含属性访问、类层次、异常对象、迭代器和其他可组合能力;而且计算资源消耗、递归深度和内存占用也无法仅靠限制内置名称解决。
六、ast.literal_eval() 的边界
ast.literal_eval() 只接受 Python 字面量或容器显示,例如字符串、字节串、数字、元组、列表、字典、集合、布尔值、None 和 Ellipsis。它不执行函数调用、属性访问或变量名称。(docs.python.org)
import ast
print(ast.literal_eval("[1, 2, {'x': True}]"))
# [1, 2, {'x': True}]
try:
ast.literal_eval("1 + 2")
except ValueError as exc:
print(type(exc).__name__, exc)
try:
ast.literal_eval("__import__('os')")
except ValueError as exc:
print(type(exc).__name__, exc)
但“不会执行任意 Python 代码”不等于“对任意大小输入都安全”。极深或极大的嵌套结构仍可能消耗过多 CPU、内存或递归资源。因此处理不可信输入时,仍应限制:
- 输入字节数;
- AST 深度;
- 容器元素数量;
- 数字长度;
- 总计算时间。
安全边界必须同时考虑代码能力和资源能力。
七、反射、AST 和 Frame 的职责边界
可以用三个问题区分它们:
| 问题 | 工具 |
|---|---|
| 这个对象有什么?能不能调用?签名是什么? | inspect、getattr()、Signature |
| 这段源码表达了哪些语法结构?能否在执行前改写? | ast |
| 当前执行到了哪里?局部变量是什么?是谁调用了我? | Frame、inspect.stack()、trace |
一个插件注册系统可以这样组合:
import inspect
def register_plugin(obj):
if not inspect.isclass(obj):
raise TypeError("插件必须是类")
methods = inspect.getmembers(
obj,
predicate=inspect.isfunction,
)
for name, method in methods:
if name.startswith("task_"):
sig = inspect.signature(method)
print(name, sig)
这里:
inspect.isclass()验证对象形态;getmembers()枚举类方法;inspect.isfunction()过滤 Python 函数;signature()分析调用契约。
但如果插件对象来自不可信代码,枚举成员本身也可能触发动态行为;这时可以优先考虑 getmembers_static(),并把真正的插件执行放入隔离进程,而不是试图仅用 Python 反射 API 实现安全隔离。
八、常见误解与失败路径
误解一:dir() 返回对象全部真实属性
dir() 主要用于交互式探索,不保证列出对象所有动态属性,也不保证提供完整、准确的成员语义。动态属性、元类属性和自定义 __dir__() 都会影响结果。
如果要获取当前能访问的值:
for name in dir(obj):
try:
value = getattr(obj, name)
except Exception as exc:
print(name, "failed:", type(exc).__name__)
但这段代码可能触发任意属性逻辑。若目标是静态检查,应使用静态成员分析或直接检查类字典、实例字典和 MRO。
误解二:inspect.getsource() 能恢复函数
动态创建的函数、内置函数、C 扩展函数、打包后缺失源码的模块,都可能没有可恢复的源码。源码位置元数据和源码内容是两个不同概念。
误解三:签名等于真实调用行为
__signature__ 可以被人为设置,包装器也可能接受 *args, **kwargs 后再自行校验。签名是工具可见的调用描述,不是强制执行器。真正验证调用参数时,应使用原始可调用对象或显式使用 Signature.bind()。
误解四:AST 白名单天然安全
AST 白名单只有在覆盖完整、递归规则正确、资源限制存在时才有意义。遗漏 Attribute、Call、Lambda、推导式、Subscript 或复杂常量,都可能扩大执行能力。
误解五:Frame 可以随时修改程序状态
Frame 适合观察和调试,不应依赖修改 f_locals 来改变正在执行的局部变量。不同实现的 Frame 暴露程度也可能不同。
误解六:读取标注只是读取字典
Python 3.14 的标注机制包含惰性求值和 annotationlib 格式化能力。某些标注读取路径可能执行表达式,因此第三方对象的标注不应在高权限上下文中无条件求值。(docs.python.org)
九、生产代码中的取舍
1. 优先使用稳定的公开接口
适合业务逻辑的接口包括:
inspect.signature()
inspect.getmembers_static()
inspect.isfunction()
inspect.isclass()
inspect.get_annotations()
ast.parse()
ast.NodeVisitor
ast.NodeTransformer
而以下接口更接近 CPython 或调试实现:
sys._getframe()
frame.f_lasti
code.co_code
dis.dis() 的具体指令布局
Python 3.14 的 inspect 文档还新增了 Frame 的 f_generator 属性,并增加了 CO_HAS_DOCSTRING、CO_METHOD 等代码标志;但文档同时明确说明,部分标志是 CPython 实现细节,不应作为跨实现契约。(docs.python.org)
2. 把反射结果缓存起来
如果每次请求都执行:
inspect.signature(handler)
inspect.getmembers(handler)
inspect.getsource(handler)
会增加额外开销。注册阶段完成检查后,可以缓存:
registry = {}
def register(name, handler):
registry[name] = {
"callable": handler,
"signature": inspect.signature(handler),
"is_async": inspect.iscoroutinefunction(handler),
}
缓存时仍要考虑热重载、动态替换函数和插件卸载,否则缓存的签名可能对应旧对象。
3. 不要把 Frame 留在长期对象中
错误日志可以复制需要的字段:
def frame_snapshot(frame):
return {
"function": frame.f_code.co_name,
"filename": frame.f_code.co_filename,
"line": frame.f_lineno,
}
不要直接把完整 frame、f_locals 或 traceback 对象长期放进全局缓存。它们可能保留请求参数、数据库连接、大型缓存和密钥。
4. 不可信代码需要进程级隔离
以下机制都不是通用沙箱:
eval()
exec()
compile()
ast.literal_eval()
限制 __builtins__
限制 AST 节点
它们可以降低风险或缩小语言子集,但不能替代操作系统级隔离。需要执行不可信代码时,应至少考虑独立进程、最小权限账户、资源限制、文件系统隔离、网络策略和超时终止。
十、从源码到运行时的完整诊断路径
遇到动态调用失败时,可以按以下顺序定位:
调用对象是否正确?
↓
inspect.isroutine / type / repr
↓
签名是否匹配?
↓
inspect.signature + Signature.bind
↓
装饰器是否隐藏了原函数?
↓
inspect.unwrap / follow_wrapped
↓
源码和代码对象是否对应?
↓
getsource / __code__ / dis
↓
运行时到底从哪里调用?
↓
traceback / Frame / inspect.stack
示例:
import inspect
def diagnose(callable_obj, *args, **kwargs):
print("object:", callable_obj)
print("type:", type(callable_obj))
print("is routine:", inspect.isroutine(callable_obj))
try:
sig = inspect.signature(callable_obj)
except (TypeError, ValueError) as exc:
print("signature unavailable:", exc)
return
print("signature:", sig)
try:
bound = sig.bind(*args, **kwargs)
except TypeError as exc:
print("arguments invalid:", exc)
return
print("bound arguments:", bound.arguments)
这个诊断顺序的核心是:先确认对象身份,再确认调用契约,最后才观察执行现场。如果一开始就读取 Frame 或反编译字节码,往往会把一个普通的参数错误扩大成复杂的运行时问题。
反射与自省真正有价值的地方,不是让程序“看起来很动态”,而是让程序能够在正确的层次观察自身:
- 对象层:类型、属性、方法和元数据;
- 调用层:参数、默认值、包装关系和签名;
- 源码层:语法结构、静态约束和转换;
- 执行层:Frame、调用栈、异常位置和跟踪事件;
- 安全层:哪些信息可以读取,哪些读取动作本身会执行代码。
理解这些层次后,inspect 不再只是调试工具,ast 也不只是代码格式化工具,Frame 更不只是 traceback 的内部结构。它们共同揭示了 Python 的执行模型:源码先形成语法结构,语法结构被编译为代码对象,代码对象在 Frame 中运行,运行时对象再通过反射接口暴露给程序观察和操作。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 元类:类创建流程、prepare、注册和使用边界
- 下一篇:Python 类型标注基础:Union、Literal、TypedDict、Narrowing 与边界
- 延伸:Python 执行模型:源码、AST、字节码、解释器帧与调用栈
- 延伸:Python 装饰器:函数包装、参数化、类装饰器与元数据
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论