程云来 / 杭州
Back to Blog·Python
·About 11 min

元编程的价值与代价:一处规则,多个函数生效

围绕给计算器统一加日志这一件事,理解为什么使用元编程、函数包装如何工作、需要守住哪些约定,以及何时收益值得付出代价。


为什么用元编程:把重复的日志流程交给一处维护

设想一个小计算器,只有加法 add 和除法 divide。现在要求两种计算都记录日志:调用前输出函数名,成功后输出结果,失败时输出异常类型。计算本身不变。

直接把日志写进函数就能做到。下面先展示加法的成功路径;除法也需要相同的步骤,失败处理还要另外补上:

python
def add(a, b):
    print("调用 add")
    result = a + b
    print(f"返回 {result}")
    return result

如果只有这个函数,直接写很清楚。但随着计算方法增加,每个函数都要维护“开始 → 执行 → 成功或失败”的流程。以后修改失败日志的规则,需要检查每个函数有没有改全。

把打印动作提取成公共函数,可以统一日志格式;但各个计算函数仍然要安排打印时机、捕获异常、转交结果。这里希望复用的是包围一次计算的完整流程

如果能把这个流程写成 trace,再让它给计算函数安装日志行为,使用处就可以变成这样。下面先看目标形态,trace 的实现放在下一节:

python
@trace
def add(a, b):
    return a + b


@trace
def divide(a, b):
    return a / b

两个函数只保留各自的计算规则,日志流程统一交给 trace。新增一种计算时应用同一条规则;修改日志流程时,也只修改这条规则。省下的是反复维护相同流程的工作。 包装器本身也需要代码,两个小函数未必减少总行数,但需要同步修改的位置变少了。

这体现了元编程(Metaprogramming)的思想:让程序把程序自身的表示或构件作为处理对象,检查、生成或改变它们的结构与行为。 在计算器里,add 处理数字,trace 则处理函数,给函数增加统一的调用行为。

这个需求适合用元编程思考,是因为计算规则各不相同,而围绕计算的日志规则相同。现在还缺少一步:trace 怎样接收一个函数,又怎样让后续调用自动经过日志流程?

原理:接收原函数,返回一个带日志的新入口

先区分 dividedivide(8, 2)。前者引用函数对象,后者才执行计算。Python 中的函数对象可以传给另一个函数,所以 trace 可以把原函数作为参数 func 接收进来。Python 3.13:函数定义

接收以后,trace 创建一个新函数 wrapper:它先输出日志,再调用 func,最后记录并转交结果。trace 返回的是这个新函数,计算结果要等新函数被调用后才产生。

原函数的代码不需要改写。只要执行 divide = trace(divide),名字 divide 就会指向包装后的入口。Python 的装饰器(Decorator)语法把这一步放在定义旁边:对于本例,@trace 相当于定义原函数后做这次赋值。包装发生在执行函数定义时。Python 3.13:装饰器语义

下面把这条关系写成完整代码。wrapper*args**kwargs 接收调用参数并转交给 func@wraps(func) 保留函数名称等元数据,因此输出仍能识别 divide。成功时返回结果,失败时记录后继续抛出异常。

示例使用 Python 3.13 标准库,无须第三方依赖;于 2026-09-07 在 CPython 3.13.3 验证。可直接点击代码块运行,也可以保存为 metaprogramming_demo.py,在本地执行 python3 metaprogramming_demo.py。固定输入依次为 add(8, 2)divide(8, 2)divide(8, 0)

PyPython · 浏览器本地运行
未运行
from functools import wraps


def trace(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"调用 {func.__name__}")
        try:
            result = func(*args, **kwargs)
        except Exception as error:
            print(f"失败 {type(error).__name__}")
            raise
        print(f"返回 {result}")
        return result

    return wrapper


@trace
def add(a, b):
    return a + b


@trace
def divide(a, b):
    return a / b


print(f"函数名:{divide.__name__}")
print(f"调用方收到:{add(8, 2)}")
print(f"调用方收到:{divide(8, 2)}")

try:
    divide(8, 0)
except ZeroDivisionError:
    print("调用方收到:ZeroDivisionError")
输出

点击“运行”,在你的浏览器中执行这段固定示例。

代码只读;执行发生在 Web Worker 中,不会发送到博客服务器。

预期输出:

text
函数名:divide
调用 add
返回 10
调用方收到:10
调用 divide
返回 4.0
调用方收到:4.0
调用 divide
失败 ZeroDivisionError
调用方收到:ZeroDivisionError

跟着 divide(8, 2) 走一次,就能分清日志和计算分别在哪里执行:

正在绘制图表…
查看 Mermaid 源码
sequenceDiagram
    participant C as 调用方
    participant W as wrapper(名字 divide 指向它)
    participant F as func(原 divide)
    C->>W: divide(8, 2)
    Note over W: 输出“调用 divide”
    W->>F: func(8, 2)
    F-->>W: 4.0
    Note over W: 输出“返回 4.0”
    W-->>C: 4.0

Mermaid 大图

可滚动查看图表,点击缩放比例可恢复 100%。按 Esc 关闭。

调用方先进入 wrapperwrapper 再调用 func,结果沿相反方向返回。add 也经过相同的流程,只是它对应的 wrapper 保存的是原来的加法函数。

这里还有一个时间上的疑问:trace 已经返回,wrapper 为什么仍能找到 func?因为内部函数保留了对外层变量的访问。这种内部函数连同它引用的外层环境,称为闭包(Closure)。每次调用 trace 创建的包装函数,都能访问那次传入的原函数。

因此要分清两件事:执行定义时,trace 接收函数、创建包装并替换入口;执行计算时,wrapper 才记录日志并调用原函数。本例的元编程体现在对函数入口的构造与替换,日志则是安装后产生的行为。

现在把 trace 中的 print(f"调用 {func.__name__}") 改成 print(f"[计算器] 调用 {func.__name__}"),重新运行整个代码块。加法和两次除法的调用日志都会带上前缀,两个计算函数的函数体不用改。这就验证了开篇的目标:一处维护日志规则,多处复用。修改源码后需要重新执行定义,让包装重新建立。

但新增日志只是要求的一半。开篇还说了“计算本身不变”:正常结果和除零异常,经过这层入口后也必须照常交给调用方。

注意事项:加上日志之后,计算约定仍然要成立

对这个计算器,调用方原本依赖三件事:传入的参数参与计算,成功时得到计算结果,除零时收到异常。trace 安装在调用入口上,就有责任把这三件事完整地传下去。日志打印得正确,不足以证明包装正确。

参数由 func(*args, **kwargs) 转交。结果和异常则分别依赖 return result 与裸 raise。下面在完整代码上各改一次,观察调用方收到什么;每次实验后先恢复代码,再做下一次。

先删除 wrapper 最后的 return result。加法和除法仍会打印正确结果,但调用方会收到:

text
调用 add
返回 10
调用方收到:None
调用 divide
返回 4.0
调用方收到:None

上面截取的是两次成功计算的输出。原函数已经把结果交给 wrapper,但 wrapper 没有继续返回,执行到末尾就返回了 None。问题发生在 原函数 → wrapper → 调用方 的第二段。恢复 return result,确认调用方重新收到 104.0,才能说明结果传递已经修复。

再把异常分支中的 raise 改成 return None。除零时最后只会输出“调用 divide”和“失败 ZeroDivisionError”,原来的“调用方收到:ZeroDivisionError”消失了。包装层把异常转换成普通返回,外层 except 不再执行。恢复 raise,确认调用方重新捕获除零异常,才算保留了失败行为。

函数的识别信息也要考虑。本例用 @wraps(func) 保留名称、文档字符串等元数据,并提供指向原函数的 __wrapped__ 属性。删掉它,divide.__name__ 会变成 wrapper。不过,wraps 不会替你转交结果或异常;它解决的是元数据问题。Python 3.13:functools.wraps

对这次改造,验证应从调用方出发:检查参数是否正常传递、成功返回值是否一致、除零异常是否仍能捕获,再检查日志中的函数身份。示例只包装普通同步计算函数,应用范围也应保持清楚。

这些约束能帮助我们把包装器写对,却不能消除包装本身的成本。刚才定位 None 时,即使计算函数只有一行,也必须走出函数体去检查 trace。这就是采用这层抽象后要承担的代价。

缺点:统一维护,也让理解和修改跨过更多函数

直接写日志时,打开 divide 就能看到计算前后发生了什么。改成 @trace 以后,函数体更清楚了,但一次调用的完整行为分布在 dividetrace 两处。排查日志、返回值或异常时,都需要把两处代码连起来读。

元编程减少了重复表达,也增加了理解最终行为所需的间接关系。 在这个计算器里,代价可以从已经运行过的代码中看到:

  1. 阅读与调试要多追踪一层。 divide(8, 2) 实际先进入 wrapper。刚才返回 None 的原因,就无法在 return a / b 中找到。保留函数名有助于识别,但不会消除这层调用。
  2. 共同规则出错,会影响所有使用它的函数。 少写一次 return,加法和除法一起出错。“改一处、多个函数生效”同样适用于缺陷。因此修改 trace 时,需要验证它覆盖的成功和失败场景。这种风险也存在于普通公共函数中;使用装饰器后,应沿着定义处的 @trace 查清影响范围。
  3. 每次计算要执行额外工作。 包装后的调用多经过一次函数,并输出日志。日志输出是需求本身的成本,额外包装调用则是这种实现方式引入的成本。示例没有做性能基准,不能给出固定开销;如果计算进入高频路径,建议分别测量两者。

也不能因为 trace 接收任意参数,就认为它适合任何计算方式。它假设原函数返回时计算已经完成;若以后改为异步计算,这个假设就需要重新检查。能复用一套规则的前提,是被包装对象遵守相同的执行约定。

这些判断针对本例的运行时函数包装。元编程还有其他实现方式,不一定都增加相同的调用开销。这里真正需要权衡的是:为了统一计算器的日志流程,少维护几处重复,是否值得多维护这一层关系?

什么时候值得使用:共同规则是否真的比各自维护更合适

回到最初的需求,可以从计算器今后如何变化来判断。如果只有 adddivide,日志也很少调整,直接写未必难维护。为了消除几行重复而引入包装器,不一定划算。

如果会持续增加计算函数,而且所有函数都遵守同一套开始、成功和失败日志规则,trace 的收益就更明确:新函数只需应用已有规则,修改规则也不用逐个检查计算函数的实现。此时我会考虑装饰器,同时保留前一节从调用方验证结果和异常的检查。

但如果需求逐渐变成“加法只记结果,除法要改写异常,某些函数只在特定调用处记日志”,共同流程已经出现分歧。继续往 trace 填入函数名判断,会让理解日志规则必须同时了解每种计算。此时建议把差异明确写在各自函数或调用处,再提取真正相同的部分。

决定采用前,可以沿着同一件事问三个问题:

  1. 重复的究竟是什么? 本例重复的是围绕计算的一整套日志流程,而不只是两次 print
  2. 这条规则能否稳定地用于多个对象? adddivide 计算不同,但参数转交、结果返回和异常传播遵守相同约定。
  3. 集中维护的收益能否覆盖间接调用的成本? 比较新增计算函数、调整日志规则和排查计算故障这三种日常工作,不只比较代码行数。

当三者都成立,元编程才有明确的落点:把稳定而重复的程序规则交给程序统一处理。对这个计算器来说,选择 @trace 的理由是日志流程值得集中维护;接受的代价是以后理解和修改一次计算时,还需要检查它所经过的包装层。