函数调用与虚拟机软件栈

一年之计在于春,一日之计在于晨。

—— 南朝·梁·萧绎·《纂要》

我们已经掌握了创建函数对象的秘密,并发挥自己的聪明才智以一种全新的方式创造了一个函数对象。虽然在实际项目中,我们不会这么做,但这种新尝试让我们可以更好地理解函数的行为。

现在,我们又对函数调用的秘密充满好奇。函数是怎么调用的?参数和返回值是如何调用的?递归又是如何实现的?带着这些问题,我们再次启程,研究 circle_area 这个我们既熟悉又陌生的函数。

我们将 circle_area 定义在 geometry 模块中,文件名为 geometry.py

1
2
3
4
5
6
7
pi = 3.14

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

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

注意到,模块中还有另一个函数 cylinder_volume 用于计算圆柱体体积,参数 r 是底面圆的半径,参数 h 是圆柱体高度。 cylinder_volume 先调用 circle_area 计算底面面积,再乘以高度得到圆柱体积。

进入 geometry.py 所在目录,并启动 Python 终端,将 geometry 模块导入,即可调用相关函数:

1
2
3
>>> from geometry import circle_area, cylinder_volume
>>> circle_area(1.5)
7.065

如果你不想进入 geometry.py 所在目录,也可以将其路径加入到 sys.path ,这个方法我们在模块机制中介绍过:

1
2
>>> import sys
>>> sys.path.append('/some/path')

开始讨论函数调用流程之前,我们先来看看从 geometry 模块导入相关函数后虚拟机内部的状态:

  • main 模块是 Python 启动后的执行入口,每个 Python 程序均从 main 开始执行;
  • geometry 是我们导入的模块,它有一个 dict 属性,指向模块属性空间;
  • geometry 初始化后,属性空间里有一个浮点属性 pi 以及两个函数对象, circle_areacylinder_colume
  • 两个函数的 全局名字空间 与模块对象的 属性空间 是同一个 dict 对象;
  • 两个函数都有一个 代码对象 ,保存函数 字节码 以及 名字常量 等静态上下文信息;
  • 往下阅读前请务必理解该状态图,有疑问请复习虚拟机模块机制以及函数创建等章节,以加深理解;

每个 Python 程序都有一个 main 模块,以及与 main 模块对应的 栈帧 对象。main 模块是 Python 程序的入口,而与其对应的栈帧对象则是整个程序调用栈的起点。

当我们在交互式终端输入语句时,也是类似的。 Python 先将代码编译成代码对象,再创建一个 栈帧 对象执行该代码对象。以 circle_area(1.5) 为例,编译可得到这样的字节码:

1
2
3
4
5
6
  1           0 LOAD_NAME                0 (circle_area)
              2 LOAD_CONST               0 (1.5)
              4 CALL_FUNCTION            1
              6 PRINT_EXPR
              8 LOAD_CONST               1 (None)
             10 RETURN_VALUE

随后,Python 创建栈帧对象作为执行环境,准备执行编译后的代码对象:

注意到,栈帧对象全局名字空间、局部名字空间均指向 main 模块的属性空间。 circle_area(1.5) 的语句中,有些我们已经非常熟悉了。第一条字节码,将名为 circle_area 的对象,加载到栈顶,这是我们导入的函数。第二条字节码,将常量 1.5 加载到栈顶,这是准备传递给函数的变量。执行这两个字节码后,虚拟机状态变为:

接着是 CALL_FUNCTION 字节码,顾名思义,我们知道正式它完成了调动函数的使命。那么,这一条字节码的处理逻辑是怎么样的呢?点击“阅读原文”,获取更多详情!

【Python源码剖析】系列文章首发于公众号【小菜学编程】,敬请关注:

【Python源码剖析】系列文章首发于公众号【小菜学编程】,敬请关注: