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

Python 反射与自省:inspect、签名、Frame、AST 和安全边界

Python 的“反射”与“自省”经常被放在一起讨论,但它们关注的方向并不完全相同:

  • 自省(introspection):程序观察自身或其他对象,回答“它是什么”“有哪些属性”“接受什么参数”“当前执行到哪里”。
  • 反射(reflection):程序不仅观察对象,还根据名称或元数据动态获取对象、调用对象、修改对象,甚至构造新的类型或执行新的代码。

例如:

getattr(obj, "method")("value")

这里既有自省,也有反射:

  1. 通过 getattr() 动态查找名为 "method" 的属性;
  2. 得到属性对象;
  3. 动态调用该对象。

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

xy 是两个名称,整数对象 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

属性查找可能经过:

  1. 类型上的数据描述符;
  2. 实例字典;
  3. 类型上的非数据描述符或普通属性;
  4. __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 模块大体提供四类能力:

  1. 判断对象类别;
  2. 获取源码和定义位置;
  3. 分析函数、方法、类及其元数据;
  4. 检查解释器调用栈和 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 扩展函数、交互式解释器中定义的函数、动态生成的函数,源码可能不可用;文档规定此时可能抛出 TypeErrorOSError。(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_cachesadaptive 用于观察解释器缓存和特化指令。(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}

参数绑定可以分成几个步骤:

  1. 将位置参数从左到右匹配位置参数;
  2. 将关键字参数按名称匹配;
  3. 将剩余位置参数收集到 *args
  4. 将剩余关键字参数收集到 **kwargs
  5. 检查必填参数是否缺失;
  6. 检查是否出现重复、非法或位置方式错误;
  7. 只有在 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 栈帧支持的实现中,它可能返回 Nonesys._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 使用扫描;
  • 禁止语法检查;
  • 简单的代码度量。

不过它不是完整的名称解析器。作用域、闭包、globalnonlocal、导入和动态属性都会使“出现过的名字”与“运行时实际绑定”产生差异。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

这里有三个关键步骤:

  1. visit_Name() 返回新节点,替换原来的 Name
  2. ast.copy_location() 将原节点的位置信息复制给新节点;
  3. 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__ 设为空”,而是:

  1. 输入先被解析为 AST;
  2. 只允许白名单节点;
  3. 不执行用户提供的任意代码;
  4. 最终由程序自己的解释逻辑计算结果。

如果改成:

eval(user_input, {"__builtins__": {}}, {})

只能减少一部分直接名称访问,不能构成通用安全沙箱。Python 对象模型仍然包含属性访问、类层次、异常对象、迭代器和其他可组合能力;而且计算资源消耗、递归深度和内存占用也无法仅靠限制内置名称解决。


六、ast.literal_eval() 的边界

ast.literal_eval() 只接受 Python 字面量或容器显示,例如字符串、字节串、数字、元组、列表、字典、集合、布尔值、NoneEllipsis。它不执行函数调用、属性访问或变量名称。(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 的职责边界

可以用三个问题区分它们:

问题 工具
这个对象有什么?能不能调用?签名是什么? inspectgetattr()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 白名单只有在覆盖完整、递归规则正确、资源限制存在时才有意义。遗漏 AttributeCallLambda、推导式、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_DOCSTRINGCO_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,
    }

不要直接把完整 framef_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 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。