# 函数调用与虚拟机软件栈

# 准备代码

准备文件geometry.py :

# geometry.py
pi = 3.14

def circle_area(r):
    return pi * r ** 2

def cylinder_volume(r, h):
    return circle_area(r) * h

注意,文件中定义有两个函数,其中计算圆柱体体积的函数将调用计算圆面积的函数。

准备main.py

# main.py
from geometry import circle_area, cylinder_volume
circle_area(1.5)

# import

开始讨论函数调用流程之前,我们先来看看从 geometry 模块导入相关函数后虚拟机内部的状态,即执行完下面第一行指令后的状态:

from geometry import circle_area, cylinder_volume

图片描述

  • main 模块是 Python 启动后的执行入口,每个 Python 程序均从 main 开始执行;

  • geometry 是我们导入的模块,它有一个 dict 属性,指向模块属性空间;

  • ​geometry 初始化后,属性空间里有一个浮点属性 pi 以及两个函数对象, circle_area 和 cylinder_colume ;

  • 两个函数的 全局名字空间 与模块对象的 属性空间 是同一个 dict 对象;

    本质上是由于在在执行geometry code的frame中make的function - make出来的function和其出生所在的frame的globals保持一致

  • 两个函数都有一个 代码对象 ,保存函数 字节码 以及 名字常量 等静态上下文信息;

# 函数调用

# 单层调用

当解释器执行到第二行即circle_area(1.5)​时,Python 创建栈帧对象作为执行环境,准备执行编译后的代码对象:

图片描述

注意到,栈帧对象全局名字空间、局部名字空间均指向 main 模块的属性空间。 circle_area(1.5)​ 的语句中第一条字节码将名为 circle_area 的对象加载到栈顶-这是要调用的函数;第二条字节码将常量 1.5 加载到栈顶-这是准备传递给函数的变量。

执行这两个字节码后,虚拟机状态变为:

图片描述

接着是 CALL_FUNCTION 字节码,顾名思义,其正式完成调动函数的使命:

CALL_FUNCTION 先创建一个新栈帧对象,作为 circle_name 函数的执行环境。新栈帧对象通过 f_back 指针,指向前一个栈帧对象,形成一个调用链。栈帧对象从函数对象取得 代码 对象,以及执行函数时的全局名字空间:

图片描述

注意执行函数的栈帧对象 f_locals 字段为空,而不是跟 f_globals 一样指向一个 dict 对象。由于函数有多少局部变量是固定的,代码编译时就能确定。因此,没有必要用字典来实现局部名字空间,只需把局部变量依次编号,保存在栈底即可 ( r=1.5 处)。这样一来,通过编号即可快速存取局部变量,效率比字典更高。于此对应,有一个特殊的字节码 LOAD_FAST 用于加载局部变量,以操作数的编号为操作数。

最后, RETURN_VALUE 字节码将结算结果返回给调用者,执行权现在交回调用者的 CALL_FUNCTION 字节码。CALL_FUNCTION 先将结果保存到栈顶并着手回收 circle_area 函数的栈帧对象。

图片描述

# 嵌套调用

嵌套调用也是类似的,以 cylinder_volume(1.5, 2)​ 为例:

>>> cylinder_volume(1.5, 2)
14.13

Python 交互式终端同样先对这个语句进行编译,得到这样的字节码:

  1           0 LOAD_NAME                0 (cylinder_volume)
              2 LOAD_CONST               0 (1.5)
              4 LOAD_CONST               1 (2)
              6 CALL_FUNCTION            2
              8 PRINT_EXPR
             10 LOAD_CONST               2 (None)
             12 RETURN_VALUE

然后, Python 虚拟机以 main 栈帧对象为环境,执行这段字节码。当虚拟机执行到 CALL_FUNCTION 这个字节码时,创建新栈帧对象,准备执行函数调用。初始新栈帧对象时,函数参数来源于当前栈顶,而全局名字空间与代码对象来源于被调用函数对象。新栈帧对象初始化完毕,虚拟机便跳到新栈帧,开始执行 cylinder_volume 的字节码。cylinder_volume 字节码中也有 CALL_FUNCTION 指令,调用 circle_area 函数。虚拟机依样画葫芦,为 circle_area 准备栈帧,并开始执行 circle_area 的字节码:

图片描述

这样一来,随着函数调用的深入,栈帧链逐渐伸长;随着函数执行完毕并返回,栈帧链逐渐收缩。维护栈帧链条的关键是栈帧对象的 f_back 指针,它总是指向上个一栈帧对象,也就是调用者的栈帧,如上图红色箭头。我们在调试程序时,可以查看完整的堆栈信息,也是 f_back 指针的功劳。