9.2 构建帧对象

frame object 概述

编译后的 code object 会被添加到 frame object 中。由于 frame object 是一种 Python 类型,因此它可以被 C 或 Python 引用。执行 code object 中的指令时还需要其他运行时数据,这些数据也包含在 frame object 中,例如局部变量、全局变量和内置模块。

帧对象类型

frame object 的类型是 PyObject,它包含了以下属性:

字段类型用途

f_back

PyFrameObject *

指向栈中前一个帧的指针,如果是第一帧则值为 NULL

f_code

PyCodeObject *

需要执行的 code object

f_builtins

PyObject * (dict)

内置(builtin)模块的符号表

f_globals

PyObject * (dict)

全局变量的符号表(PyDictObject

f_locals

PyObject * (dict)

局部变量的符号表

f_valuestack

PyObject **

指向最后一个局部变量的指针

f_stacktop

PyObject **

指向 f_valuestack 中下一个空闲的插槽(slot)

f_trace

PyObject *

指向自定义追踪函数的指针(请参阅"帧的执行追踪")

f_trace_lines

char

切换自定义追踪函数在行号级别进行追踪

f_trace_opcodes

char

切换自定义追踪函数在操作码级别进行追踪

f_gen

Pybject *

借用的生成器引用或 NULL

f_lasti

int

上一条执行的指令

f_lineno

int

当前行号

f_iblock

int

当前帧在 f_blockstack 中的索引

f_executing

char

标记帧是否仍在执行

f_blockstack

PyTryBlock[]

保存 for 块,try 块 和 loop 块的序列

f_localsplus

PyObject *[]

局部变量和栈的联合

相关的源文件

与 frame object 相关的源文件如下:

文件功能

Objects/frameobject.c

frame object 的实现和 Python API

Include/frameobject.h

frame object 的 API 和类型定义

Frame Object 初始化 API

帧对象的初始化 API 是 PyEval_EvalCode(),这也是解析代码对象的入口点。PyEval_EvalCode() 是对内部函数 _PyEval_EvalCode() 的封装。

_PyEval_EvalCode() 是一个非常复杂的函数,它定义了 frame object 和解释器循环求值的很多行为。同时,它也是一个需要理解的重要函数,因为它可以教会你 CPython 解释器的一些设计原则。

本节将逐步带你理解 _PyEval_EvalCode() 的执行逻辑。

_PyEval_EvalCode() 定义了许多参数。

  • tstate: 类型为 PyThreadState * ,它指向负责解析此块 code object 的线程的线程状态;

  • _co: 类型为 PyCodeObject *,它包含了将要放入 frame object 的 code object;

  • globals: 类型为 PyObject*(实际类型为一个字典),字典的键为变量名,字典的值是变量的值;

  • locals: 类型为 PyObject*(实际类型为一个字典),字典的键为变量名,字典的值是变量的值。

在 Python 中,局部变量和全局变量都以字典形式存储。你可以分别通过内置函数 local()globals() 去访问这些变量:

>>> a = 1
>>> print(locals()['a'])
1

其他的参数都是一些可选项,没有在基础 API 中使用。

  • argcount:位置参数的数量;

  • args:类型为 PyObject*(实际类型为元组),按顺序排列的位置参数;

  • closure:类型为元组,包含了要合入 code object 中 co_freevars 字段的字符串;

  • defcount:位置参数的默认值列表长度;

  • defs:位置参数的默认值列表;

  • kwargs:关键字参数值的列表;

  • kwcount:关键字参数的数量;

  • kwdefs:包含关键字参数默认值的字典;

  • kwnames:关键字参数名的列表;

  • name:求值语句的名称字符串;

  • qualname:求值语句的限定名字符串。

接下来我们探讨如何去创建一个新的帧对象,调用 _PyFrame_New_NoTrack() 可以创建一个新帧,也可以通过 C API PyFrame_New() 间接调用此API。_PyFrame_New_NoTrack() 将按以下步骤创建一个新的 PyFrameObject

  1. 将帧的 f_back 属性设置为线程状态的最后一帧;

  2. 通过设置 f_builtins 属性加载已有的内置函数,同时通过 PyModule_GetDict() 加载内置模块;

  3. f_code 属性设置为当前正在执行求值的 code object;

  4. f_valuestack 属性设置为一个空的值栈;

  5. 将栈顶指针 f_stacktop 指向 f_valuestack;

  6. 将全局变量属性 f_globals 的值设置为 globals 参数的值;

  7. 将局部变量属性 f_locals 的值设置为一个新的字典;

  8. f_lineno 设置为 code object 中的 co_firstlineno 属性,以便于产生异常时的回溯包含行号;

  9. 将其余属性都设置为它们的默认值。

使用新创建的 PyFrameObject 实例,就可以构造出 frame object 的参数:

将关键字参数转换为字典

Python 中的函数定义可以使用 **kwargs 来获取关键字参数,例如:

def example(arg, arg2=None, **kwargs):
	print(kwargs['x'], kwargs['y']) # this would resolve to a dictionary key
example(1, x=2, y=3) # 2 3

在这个例子中,未解析的参数将被复制到一个新创建的字典中。然后 kwargs 这个名字会被设置为帧中局部作用域的变量。

将位置参数转换为变量

每个位置参数(如果存在的话)都将被设置为局部作用域内的变量。在 Python 中,函数参数已经是函数体中的局部变量了。当给函数的位置参数赋值后,在函数作用域内就可以使用这些变量:

def example(arg1, arg2):
    print(arg1, arg2)

example(1, 2) # 1 2

这些变量的引用计数将会增加,因此在帧完成求解前(例如函数结束并返回时)都不会触发垃圾回收去移除这些变量。

将位置参数打包为 *args

类似于 **kwargs ,函数中以 * 开头的参数可以捕获所有剩余的位置参数。这个参数是一个元组类型的变量,并且 *args 这个名字会被设置函数作用域内的局部变量。

def example(arg, *args):
    print(arg, args[0], args[1])

example(1, 2, 3) # 1 2 3

加载关键字参数

在使用给关键字参数赋值的方式调用函数时,如果存在参数既无法解析符号名也不是位置参数,此时可以使用一个字典去接收那些剩余的关键字参数。

下面给出一个例子,参数 e 既不是位置参数也没有预设的参数名,所以它被添加到了字典参数 **remaining 中:

>>> def my_function(a, b, c=None, d=None, **remaining):
	print(a, b, c, d, remaining)

>>> my_function(a=1, b=2, c=3, d=4, e=5)
(1, 2, 3, 4, {'e': 5})

限定型位置参数是 Python 3.8 中的新特性, PEP 570 中引入的限定型位置参数可以禁止用户在位置参数上使用关键字语法。

例如,下面这个简单的函数将华氏度转换为摄氏度。注意,/ 会作为一个特殊的参数将限定型位置参数与其他函数参数分开。

def to_celsius(fahrenheit, /, options=None):
    return (fahrenheit-32)*5/9

分割符 / 左边的参数都只能以位置参数的形式调用,右边则不作限制,可以使用位置参数也可以使用关键字参数。

>>> to_celsius(110)  # OK

调用函数时,对限定型位置参数使用关键字参数语法将会抛出一个 TypeError 异常:

>>> to_celsius(fahrenheit=110)
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
TypeError: to_celsius() got some positional-only arguments
    passed as keyword arguments: 'fahrenheit'

对关键字参数字典值的解析是在所有参数都解压之后进行的。如果在第 3 个参数上使用了 / 符号,则 code object 中 co_posonlyargcount 的值将会是 2 。所以 PEP 570 中提到的限定型位置参数可以通过将 co_posonlyargcount 作为循环的次数来获取。对剩下的参数调用 PyDict_SetItem() ,就可以将他们添加到 locals 字典中。当函数执行时,每一个关键字参数都将变为函数作用域内的局部变量。

如果在定义关键字参数时添加了默认值,那它在函数作用域内就已经可用了:

def example(arg1, arg2, example_kwarg=None):
    print(example_kwarg, arg1) # example_kwarg is already a local variable.

添加缺失的位置参数

函数调用时,有一些位置参数并不在定义的位置参数列表中,这些参数会被添加到一个形如 *args 的元组中,如果这个元组不存在函数就会抛出异常。

添加缺失的关键字参数

函数调用时,有一些关键字参数并不在定义的关键字参数列表中,这些参数会被添加到一个形如 **kwargs 的字典中,如果这个字典不存在函数就会抛出异常。

折叠闭包

所有的闭包的名称都会被添加到 code object 的空闲变量名列表中。

创建生成器、协程和异步生成器

如果正在求值的 code object 存在标志表明它是一个生成器,协程或异步生成器,就会使用生成器、协程和异步库中特定的函数去创建一个新的帧,并将这个帧添加到当前的属性中。

参见

"并行和并发"这一章进一步介绍了生成器、协程和异步帧的 API 和实现细节。

接着会返回这个新产生的帧,而不是继续求解原帧的结果。只有在调用生成器、协程或异步方法时才会对这个新的帧求值。

最后,_PyEval_EvalFrame() 与新帧一起被调用。

Last updated