Skip to main content

Chapter 29: Callable Runtime

Python 代码里的函数调用看起来只有一对括号。运行时真正执行的是一条跨层路径:先求值被调用对象,再准备位置参数和关键字参数,然后进入 CPython 的调用协议,随后完成参数绑定、创建新的执行状态,把参数放入 fast locals,最后执行目标 code object 或 C 级 callable。

本章用一个贯穿例子追踪这条路径。读完后,应能定位一次调用卡在 callable 判断、参数绑定、递归深度、frame 创建、fast locals 初始化还是函数体执行阶段;也应能区分语言语义里“调用一个对象”和 CPython 实现里 tp_call、vectorcall、frame、code object 之间的责任边界。

贯穿材料如下。这个例子同时包含 bound method、位置参数、默认参数、可变位置参数、keyword-only 参数和可变关键字参数,足够覆盖本章的调用路径。

class PriceService:
def quote(self, sku, qty=1, *flags, currency="USD", **meta):
return sku, qty, flags, currency, meta

service = PriceService()
result = service.quote("book", 2, "promo", currency="EUR", channel="app")

从语言层看,service.quote(...) 是 call expression。根据 Python 3.14 表达式参考中的 calls 规则,被调用的 primary 必须求值得到 callable object,所有参数表达式会在尝试调用前完成求值。CPython 层再把这个语义落到具体协议和对象状态上。调用优化改变的是参数传递表示和临时对象开销,调用语义仍由 callable、signature、frame 和异常路径共同决定。

29.1 vectorcall and call protocol

Python 的调用入口是“对象是否可调用”。函数对象、内置函数、bound method、class object,以及定义了 __call__ 的实例,都可以成为 call expression 的 primary。对 service.quote(...) 来说,primary 的求值先执行属性访问:service.quote 会经过方法绑定,得到一个把 service 作为隐含接收者的 callable,然后括号内的参数才进入调用协议。

CPython 把“调用 callable object”拆成两个 C 级协议。传统 tp_call 使用 args tuple 和 kwargs dict 表示参数,形态接近 Python 代码里的 callable(*args, **kwargs)Python 3.14 C API Call Protocol 明确说明 CPython 支持 tp_call 和 vectorcall 两类调用协议,tp_call 的参数约定是位置参数 tuple 加关键字参数 dict。

vectorcall 是 CPython 为降低调用路径临时对象开销引入的协议。它来自 PEP 590,在 CPython 3.8 引入底层 API,3.9 使用公开名称。vectorcall 的核心表示是:位置参数和关键字参数的值放在一段连续的 C 数组中,nargsf 携带位置参数数量,kwnames 是关键字参数名组成的 tuple。这样,解释器栈上已经排好的参数可以直接交给被调用对象,减少为了调用而临时构造 args tuple 和 kwargs dict 的机会。

把贯穿例子放入这条路径,可以得到一个稳定模型:

这张图的边界是一次普通 Python 函数调用。它展示调用前半段的对象路径:属性访问负责产生 callable,参数求值负责产生对象引用,vectorcall 或 tp_call 负责把这些对象引用传给被调用者,后续才进入函数签名绑定和 frame 执行。图里没有展开 descriptor、opcode specialization 和异常表细节,因为它们分别属于属性访问、adaptive runtime 和异常路径的更细层级。

vectorcall 的收益来自参数表示匹配执行器已有的数据形态。解释器执行 call opcode 前,实参通常已经在 value stack 或等价的内部临时位置上。若被调用对象支持 vectorcall,CPython 可以把这些对象指针按调用约定传入被调用者。若目标路径最终仍需要 tuple 和 dict,例如某些 C 扩展内部立刻把参数转换回传统格式,那么 vectorcall 的收益会被转换成本抵消。

调用协议的第一个工程边界是语义一致性。官方 C API 文档说明,支持 vectorcall 的 class 也应实现 tp_call,并保持相同语义;推荐做法是把 tp_call 设置为 PyVectorcall_Call()。这条约束保证不同入口进入同一 callable 时,参数绑定、异常类型和返回值行为保持一致。CPython 3.12 起还调整了 class 的 __call__ 被重新赋值时 vectorcall flag 的处理,因此阅读旧版本源码和当前版本源码时,应把 vectorcall 支持视为版本敏感实现细节。

第二个工程边界是 bound method。service.quote 先经过方法绑定,再进入 PriceService.quote 对应的 callable 路径。属性访问阶段会把函数和实例结合成一个可调用对象,调用时把 self 放入第一个形式参数槽。vectorcall 对 bound method 这类“向前转发调用并补一个接收者”的路径很有价值,因为它允许调用端和中间 callable 使用参数数组传递对象引用,减少单纯为了拼接参数而分配新容器的机会。

第三个工程边界是 callable 检查和错误归属。service.quote("book") 进入调用路径后,错误可能来自多个阶段:service.quote 属性不存在会触发属性访问错误;得到的对象不可调用会触发 call expression 层的 TypeError;参数无法匹配签名会触发参数绑定错误;函数体内部计算失败则属于执行阶段。阅读异常时,先确认错误发生在调用协议前、绑定阶段还是函数体内,通常比直接盯着异常最后一行更可靠。

29.2 Argument binding

Argument binding 负责把调用处传入的对象引用放入函数定义的形式参数槽。它处理的输入是调用表达式已经求值得到的实参对象;它产生的输出是一个按函数签名排列的局部变量初始状态。对贯穿例子来说,绑定结果可以写成这样的逻辑状态:self 指向 servicesku 指向字符串对象 "book"qty 指向整数对象 2flags 指向 tuple ("promo",)currency 指向字符串对象 "EUR"meta 指向 dict {"channel": "app"}

语言规范把函数调用参数绑定描述为“槽位填充”过程。位置参数先按顺序填入前面的形式参数槽,关键字参数再按名字填入对应槽。某个槽已经被填充又收到同名关键字时,调用失败并抛出 TypeError。未填充的槽若有默认值,就使用函数定义时保存的默认对象;仍未填充且无默认值的槽会触发缺参错误。这个模型解释了很多看似分散的调用错误。

下面的短例子展示同一个签名在不同调用形态下的绑定结果。它服务于一个判断:参数绑定处理的是对象引用进入参数槽的顺序,函数体还没有开始执行。

def quote(sku, qty=1, *flags, currency="USD", **meta):
return sku, qty, flags, currency, meta

quote("book")
quote("book", 2, "promo", currency="EUR", channel="app")
quote(sku="book", qty=2)

第一次调用只填入 skuqtycurrency 使用默认对象,flags 得到空 tuple,meta 得到空 dict。第二次调用把两个位置参数填入 skuqty,多出来的位置参数进入 flagscurrency 由关键字填入,额外关键字进入 meta。第三次调用用关键字直接填入 skuqty,其余参数由默认规则和可变参数规则补齐。这里的“默认对象”有明确生命周期:默认值在函数定义时计算一次,并保存在 function object 上;调用时只是把该对象引用放入参数槽。

*args**kwargs 参与的是调用处展开和被调用方收集两个不同阶段。调用处的 *iterable 会先展开成额外位置参数,调用处的 **mapping 会先展开成额外关键字参数;被调用方签名里的 *flags 收集多余位置参数,**meta 收集多余关键字参数。两边都使用星号语法,但一个发生在调用表达式准备实参阶段,一个发生在函数签名绑定阶段。

关键字参数还有一个容易影响排查的顺序边界。调用处所有参数表达式先求值,然后星号展开和关键字整理才进入绑定。若某个参数表达式本身抛出异常,被调用函数还没有获得执行机会。若展开后的关键字重复,或某个关键字无法匹配形式参数且签名没有 **meta,错误归属到参数绑定阶段。这个边界让异常栈读法更清楚:绑定错误通常指向调用行,函数体内部错误通常会多出被调用函数的 frame。

inspect.Signature.bind() 可以作为观察参数绑定的标准库模型。官方 inspect.Signature.bind 文档 说明它会把位置参数和关键字参数映射到参数名,成功时返回 BoundArguments,失败时抛出 TypeError。这个工具不等同于 CPython 执行器内部实现,但它提供了与语言签名规则一致的可观察接口,适合调试装饰器、RPC 参数转发和动态调用包装。

import inspect

signature = inspect.signature(quote)
bound = signature.bind("book", 2, "promo", currency="EUR", channel="app")
print(bound.arguments)

这段代码会显示显式绑定的参数映射。需要注意,BoundArguments 默认只记录显式绑定的实参;若希望把默认值也写入映射,需要调用 apply_defaults()。这个行为正好对应 runtime 边界:默认值是签名绑定可用的后备对象,是否在调试输出里展开,是标准库观察接口的选择。

CPython 的内置函数和 C 扩展函数在参数绑定上还有实现边界。语言层函数签名能清楚表达 positional-only、positional-or-keyword、keyword-only、varargs 和 varkw;部分 C 实现的内置函数在文档中显示了参数名,但实现上可能不接受关键字调用。阅读这类错误时,应先看目标 callable 的真实签名能力,再判断调用处是否把参数按错误通道传入。

29.3 Call stack and recursion

一次 Python 函数调用会进入新的执行层。对用户定义函数来说,绑定完成后,CPython 需要为被调用 code object 准备一个新的 frame activation。调用者 frame 保持在调用栈上等待结果,被调用者 frame 成为当前执行状态。被调用者返回时,结果对象交回调用者;被调用者抛异常时,异常对象和 traceback 会沿调用栈向上传播。

递归只是同一个调用机制重复嵌套。下面的例子展示每次递归调用都有自己的参数绑定和 fast locals 槽位。它证明 n 的每一层绑定属于不同 frame,外层的 n 不会因为内层递减而被覆盖。

def countdown(n):
if n == 0:
return "done"
return countdown(n - 1)

countdown(3)

执行 countdown(3) 时,第一层 frame 的 n 绑定到 3。执行到 countdown(n - 1) 时,表达式 n - 1 先在当前 frame 中求值得到 2,然后创建下一层调用。第二层 frame 的 n 绑定到 2,第三层绑定到 1,第四层绑定到 0。返回值从最内层逐层回到调用者 frame。这个过程没有共享同一个局部变量槽;共享的是 function object、code object 和常量等较长生命周期对象。

调用栈提供了异常定位所需的顺序。若最内层调用触发错误,traceback 会记录从外层调用到内层失败点的 frame 链。调试时应把 traceback 看成调用路径的快照:每一层对应一次尚未完成的调用,局部变量显示该 frame 的当前状态。这里的 frame 链与上一章的 frame lifecycle 连接:调用创建 frame,返回释放或复用执行状态,异常会把 frame 关系挂入 traceback,调试工具可能延长相关对象生命周期。

递归限制属于 runtime 保护边界。官方 sys.getrecursionlimit 文档 说明递归限制是 Python 解释器栈的最大深度,用来防止无限递归导致 C stack 溢出并使解释器崩溃。这个限制面向解释器安全,算法复杂度仍由递归关系和输入规模决定。把递归函数改成循环,通常改变的是调用深度和 frame 数量;算法本身的状态转移仍需按业务规则重新表达。

import sys

print(sys.getrecursionlimit())

这段代码只能观察当前解释器配置;某个递归程序在不同环境中的安全深度,还需要结合平台和调用路径判断。实际能承受的深度还受平台 C stack、扩展模块调用路径和解释器版本影响。工程判断应优先控制递归深度和输入规模,再考虑是否调整递归限制。

CPython 的 tp_call 和 vectorcall 在递归保护上也有实现差异。C API 文档说明,使用 tp_call 进行的调用由 CPython 使用 Py_EnterRecursiveCall()Py_LeaveRecursiveCall() 处理递归检查;出于效率考虑,vectorcall 路径不会自动为所有 callee 做同样处理,callee 在需要时应显式使用递归检查 API。这个事实对 C 扩展作者和源码阅读者重要:调用协议优化压缩了通用包装层,也把一部分安全责任下放给支持 vectorcall 的 callee 实现。

在纯 Python 层排查递归错误时,可以按三步定位。先确认递归是否来自函数显式调用自身、多个函数互相调用,还是 __repr____eq__、descriptor、property 这类协议回调间接触发。再看每次调用是否缩小问题规模。最后看终止条件是否在新 frame 创建前就能命中。这个顺序把“调用栈增长”还原成“哪些 callable 在反复创建 frame”。

29.4 Fast locals and execution entry

Fast locals 是 CPython 执行用户定义函数时保存局部变量和参数的快速槽位区域。它是服务 bytecode 执行的内部槽位区域,Python 层通常通过局部名字和 locals() 观察相关状态。LOAD_FASTSTORE_FAST 这类局部变量指令可以按索引访问局部对象引用。对函数调用来说,参数绑定的结果需要先进入 fast locals,函数体 bytecode 才能开始稳定执行。

以贯穿例子为目标,进入 quote 函数体前,frame 的局部槽位大致包含这些对象引用:

# 逻辑示意,非 CPython 内存布局
self = service
sku = "book"
qty = 2
flags = ("promo",)
currency = "EUR"
meta = {"channel": "app"}

这段示意只说明执行入口状态,未复刻 CPython C 结构:函数体第一条业务 bytecode 运行前,参数名已经可以像局部变量一样读取。return sku, qty, flags, currency, meta 运行时,解释器通过 fast locals 读取这些槽位,把对象引用组装成返回 tuple,然后把返回值交回调用者。

fast locals 与 locals() 的关系需要分层理解。函数执行时,局部变量的高频读写走 fast locals 槽位;locals() 返回的是当前局部命名空间的可见映射。不同 Python 版本和实现对这个映射与内部槽位同步的细节有差异,因此工程代码应通过显式赋值改变普通函数局部变量。可稳定依赖的是语言层名字绑定语义:参数名在函数体内作为 local name 被读取和重新绑定。

默认参数的共享问题也落在这个入口边界上。默认对象保存在 function object 上;每次调用如果没有为对应参数槽提供实参,绑定阶段就把同一个默认对象引用放入当前 frame 的 fast locals。若默认值是可变对象,函数体修改该对象会影响后续调用看到的默认状态。下面的例子展示这个路径。

def collect(item, bucket=[]):
bucket.append(item)
return bucket

first = collect("a")
second = collect("b")

bucket=[] 在函数定义时创建一次。第一次调用没有提供 bucket,当前 frame 的 bucket 槽指向这个 list,函数体追加 "a"。第二次调用仍没有提供 bucket,新的 frame 的 bucket 槽继续指向同一个 list,追加 "b" 后返回的对象已经包含两次调用的状态。这个现象来自 function object 持有同一个默认对象;每次调用的 fast locals 只是接收该对象引用。

执行入口还决定了错误发生位置的可观察形态。参数绑定错误发生在函数体 bytecode 执行前,traceback 通常不会进入目标函数体的业务行。函数体内读取局部变量、调用下游函数或访问属性产生的错误,会显示目标函数 frame。调试“函数没有执行到第一行”这类问题时,应先检查参数绑定、decorator 包装、descriptor 绑定和 callable 选择。

从 CPython 源码阅读角度看,用户定义 Python 函数调用会经过函数对象的 vectorcall 实现,完成参数解析与 frame 初始化,然后进入执行器运行 code object。具体文件和函数名会随版本调整;稳定的阅读入口是:call protocol 文档用于确认协议形态,function object 源码用于看 Python function 如何接收参数,frame/eval loop 源码用于看参数如何转成执行状态。本文以 Python 3.14 文档为版本边界,不把某个具体源码行号写成跨版本事实。

29.5 Callable runtime checklist

定位一次调用,应按调用路径从外到内检查。这个顺序可以把“函数调用失败”拆成可验证阶段,减少把参数错误、协议错误和函数体错误混在一起分析。

第一步,检查 primary 求值结果。对 service.quote(...),先确认 service 是什么对象,quote 属性访问返回了什么 callable。若这里失败,问题属于名字查找、属性访问或 descriptor 绑定阶段。若返回对象不可调用,问题属于 callable surface,不需要继续分析参数绑定。

第二步,检查参数表达式求值。括号内每个表达式都会在调用尝试前完成求值。expensive()mapping[key]obj.attr 这类表达式若先抛异常,被调用函数没有进入执行阶段。排查时应把参数表达式视为调用前置代码。

第三步,检查展开和整理。调用处的 *iterable 必须产生可迭代对象,**mapping 必须产生 mapping,并且展开后的关键字需要满足字符串键和重复键规则。这个阶段失败时,错误仍归属于调用表达式准备实参。

第四步,检查调用协议选择。普通 Python 代码通常不直接选择 tp_call 或 vectorcall,但性能和 C 扩展行为会受协议形态影响。若阅读 CPython 或扩展模块,重点看目标类型是否支持 vectorcall、是否保持与 tp_call 相同语义、是否在递归路径上做了必要检查。

第五步,检查签名绑定。把位置参数、关键字参数、默认值、*args、keyword-only 参数和 **kwargs 放入同一张签名表中看。若出现重复填槽、缺参、多余位置参数、多余关键字参数,错误属于绑定阶段。inspect.Signature.bind() 适合作为 Python 层复盘工具。

第六步,检查 frame 和 fast locals。用户定义函数成功绑定参数后,参数对象引用会进入当前调用的局部槽位。递归或嵌套调用会创建新的执行层,每层都有自己的局部槽位。若问题表现为局部变量值不符合预期,应以本层 frame 的绑定来源为准,外层同名变量只能作为调用来源线索。

第七步,检查函数体执行和返回路径。函数体运行后,错误可能来自普通表达式、下游调用、协议回调、异常处理或返回值构造。此时 traceback 中出现目标函数 frame,说明调用已经越过绑定阶段。返回值本身也是对象引用,调用者收到的是被调用函数交回的对象;参数绑定表只服务于本次调用进入执行阶段。

把这个顺序应用到贯穿例子,可以得到完整判断:service.quote 先通过属性访问得到 bound method;实参 "book"2"promo""EUR""app" 先求值为对象;调用协议把这些对象引用传入目标 callable;签名绑定补入隐含 self,把多余位置参数收集进 flags,把额外关键字收集进 meta;frame 创建后,fast locals 保存参数名到对象引用的映射;函数体执行 return,调用者接收返回 tuple。

最小自检任务

阅读下面代码,判断三次调用分别在什么阶段完成绑定或失败,并说明每次调用进入函数体前的关键状态。

class Runner:
def run(self, name, count=1, *tags, dry_run=False, **options):
return name, count, tags, dry_run, options

runner = Runner()

a = runner.run("job", "urgent", dry_run=True)
b = runner.run(name="job", count=2, level="high")
c = runner.run("job", name="other")

答案要点

a 会成功调用。属性访问 runner.run 产生 bound method,self 由绑定方法提供。位置参数 "job" 填入 name,位置参数 "urgent" 填入 counttags 得到空 tuple,dry_run 由关键字填入 Trueoptions 得到空 dict。进入函数体前,fast locals 中已经有这些参数名到对象引用的绑定。

b 会成功调用。namecount 都由关键字填入,level 无法匹配显式参数名,于是被 **options 收集为 {"level": "high"}tags 得到空 tuple,dry_run 使用默认值 False。函数体执行时读取的是本次 frame 的局部槽位。

c 会在参数绑定阶段失败。第一个位置参数已经把 name 槽填为 "job",后续关键字 name="other" 再次填同一个槽,触发重复赋值的 TypeError。这个错误发生在函数体执行前,runreturn 语句没有运行。

本章知识点总结

  • 调用路径:一次调用从 primary 求值开始,经过参数求值、调用协议、参数绑定、frame 初始化和 code object 执行。
  • Callable surface:函数、bound method、class object、内置函数和带 __call__ 的对象都可以成为 call expression 的目标。
  • tp_calltp_call 使用位置参数 tuple 和关键字参数 dict 表示调用参数,语义接近 callable(*args, **kwargs)
  • vectorcall:vectorcall 使用连续参数数组、位置参数数量和关键字名 tuple 传递参数,主要降低临时 tuple 与 dict 的分配机会。
  • 语义一致:支持 vectorcall 的类型需要保持与 tp_call 一致的调用语义,源码阅读时应关注版本边界。
  • 方法绑定service.quote(...) 先通过属性访问得到 bound method,再在调用时把实例作为隐含接收者放入参数绑定路径。
  • 参数槽位:Argument binding 把位置参数、关键字参数、默认值、*args、keyword-only 参数和 **kwargs 放入形式参数槽。
  • 默认对象:默认值在函数定义时创建并保存在 function object 上,调用时把对象引用填入缺失参数槽。
  • 绑定错误:重复填槽、缺参、多余位置参数和多余关键字参数属于函数体执行前的绑定错误。
  • 递归调用:递归会反复创建新的执行层,每层拥有自己的参数绑定和局部槽位。
  • 递归限制:递归限制保护解释器和 C stack,工程上应优先控制输入规模和递归深度。
  • Fast locals:用户定义函数执行前,绑定后的参数对象引用会进入 fast locals,局部变量指令按槽位访问它们。
  • 错误归属:traceback 是否进入目标函数体,可以帮助区分调用准备、参数绑定和函数体执行阶段。
  • 检查顺序:排查调用问题应依次看 primary、参数表达式、展开整理、调用协议、签名绑定、frame 状态和函数体路径。