看书 Python 源码分析笔记 (六) 函数

第11章 Python 虚拟机中的函数机制

 

函数是程序重要的抽象. 在 Python 中使用 PyFrameObject 实现栈帧, 它们构成栈帧链.

PyFunctionObject 对象

对函数的表示内部使用此对象:

struct PyFunctionObject {
  HEAD 部分;
  PyObject *func_code;  // 对应编译后的 PyCodeObject 对象.
  PyObject *func_globals;  // 函数运行时的 global 名字空间
  PyObject *func_defaults;  // 默认参数
  PyObject *func_closure;   // 用于实现闭包.
  PyObject *func_doc;       // string 函数文档.
  PyObject *func_name;      // string 函数名称.
  PyObject *func_dict;      // 函数的 __dict__ 属性.
  ... 
  PyObject *func_module;    // 函数的 __module__ 
}

 

对象 PyCodeObject 与 PyFunctionObject 都与函数有关, 但区别很重要. code 是代码的静态表示, 一个 python
代码块编译后对应一个且仅一个 code 对象.

PyFunctionObject 则不同, 它是 python 运行时动态产生的, 更准确地说, 是在执行 def 语句时创建的. 此时函数
的静态代码即放在 func_code 字段中. 除此之外还包含一些函数在执行时需要的(动态)信息, 如 globals 等.

对于一段 python 代码, 其对应的 PyCodeObject 对象只有一个, 而产生的 PyFunctionObject 对象却可能有多个.

无参函数调用

无参函数是最简单的函数调用形式, 其不用考虑参数传递机制问题.

// 实验用代码
def f():
0   LOAD CONST  0 (code object f)
3   MAKE_FUNCTION  0
6   STORE_NAME  'f'   # 关联名字 f->code_object
  print "I'm func"
  0   LOAD_CONST  "I'm func"
  3   PRINT_ITEM
  ...

f()  // 调用函数 f
9   LOAD_NAME 'f' 
12  CALL_FUNCTION  0
15  ...

上面的实验代码中, 使用 def f() 定义了一个函数 f(), 共产生两个 PyCodeObject, 主程序一个, 函数 f 的体一个.
这里 def f() 语句是一个函数声明语句, 同时在虚拟机角度也是函数对象(PyFunctionObject) 的创建语句.

// 虚拟机指令 swtich
case MAKE_FUNCTION:
   code = POP();   // 得到为函数 f 构建的 PyCodeObject 对象
   x = PyFunction_New(code, 当前栈帧 globals 对象);  // 构造 PyFunctionObject 对象
   ...

注意这里创建的新函数对象使用当前栈帧的 globals 对象. 函数对象建立后, 在 local 名字空间建立起 f->FuncObj
的关联关系.

函数调用的指令是 CALL_FUNCTION:

case CALL_FUNCTION:
  PyObject **sp = stack_pointer;  // 栈指针
  x = call_function(&sp, oparg);
  stack_pointer = sp;  // 恢复栈指针.
  ...

// 函数 call_function @ ceval.c
PyObject *call_function(PyObject ***pp_stack, int oparg) {
  // 处理各种参数等, 略
  // 获得要执行的 PyFunctionObject 对象
  PyObject *pfunc = *pp_stack[计算出的正确位置];
  // 检查 pfunc 是函数, 略
  // 实际调用
  x = fast_function|do_call(func, ...);
  ...
}

函数快速调用通道:

PyObject *fast_function(PyObject *func, ...) {
  PyCodeObject *code = func->func_code;
  // 不相关的暂时略
  PyThreadState *thread = Py_CurrentThread();
  PyFrameObject *frame = new PyFrameObject(thread, code, ...);
  ...
  var retval = PyEval_EvalFrameEx(frame, ...)
  ...
  return retval;
}

这里的关键是创建新的栈帧 frame, 然后(递归)调用 evalxxx() 函数. 另外的执行路径追根究底也是最终调用 eval.
而这里 eval 我们已经知道是函数调用过程的实现: 创建新的栈帧, 在新的栈帧中执行代码.

函数执行时的名字空间

Python 使用 LGB 规则搜索名字空间. PyFunctionObject 会绑定一个 globals 名字空间, 该 globals 在函数
执行中被作用 globals 来搜索.

当创建函数, 即绑定 f=code 对象时, 在 py 文件层 globals=locals 名字空间, 所以将名字 f=code 放入 locals
名字空间即等于放入 globals 名字空间. (一点点小技巧)

函数参数的实现

重点放在参数的传递机制上, 同时剖析局部变量实现方式.

Python 支持四种类别的参数:
1. 位置参数 (positional argument): f(a,b) -- a,b 是位置参数
2. 键参数 (key argument): f(name='python') -- name 是键参数
3. 扩展位置参数 (excess positional argument): def f(a, b, *list) -- *list 是扩展位置参数
4. 扩展键参数 (excess key argument): def (a, b, **keys) -- **keys 是扩展键参数

由于扩展xx参数是xx参数的更高级形式, 实际只需要记录位置参数的个数, 以及键参数的个数, 就能知道一共有多少
个参数, 共需要多少内存来存放参数. (这里可与 lisp 的参数传递方式对比, 很相似...)

下面分别研究几种参数对应的 co_nlocals, co_argcount 值, 以及关系...

def foo(a, b):  # 两个位置参数 a,b.
  pass

foo(1,2)  # 调用该函数

# hack 打印出 na=2(位置参数数量), nk=0(键参数数量)
#   co_argcount=2(共两个参数), co_nlocals=2(局部变量区含两个参数)

位置参数+键参数:

def foo(a, b):
  pass

foo(1, b=2)  # 以键参数 b=2 调用
# hack 打印出 na=1(位置参数1个), nk=1(键参数1个), n=3

对照两个例子, 作者结论是 Python 中函数参数中确定一个参数是位置参数, 还是键参数, 实际上仅仅由(调用处)
给出的函数实参的形式决定, 和定义时形参无关. (这一点和静态语言如 C, Java 不同). na(num argu) 和 nk
(num key_arg) 忠实地反映着(调用者给出的)位置参数和键参数的个数.

那么下面的问题是, 当调用者给出了位置参数,键参数之后, 如何绑定到函数定义的形参上?
我猜 na (位置实参数量)绑定到位置形参上, 多的变成扩展形参 *list, 少的补上 null/nil;
同样猜 nk (键实参) 绑定到键参数(含位置参数)上, 多余的变成扩展形参 **key, 少的补上值 null/nil.

作者猜测, python 实现中所有的扩展位置参数实际上是被存储在了一个 PyListObject 对象中.
(对比: lisp 中是存为 &rest/&body 列表......)

在调用 call_function(pp_stack, oparg) 时, oparg 两个字节分别给出位置实参, 键实参数量.

PyObject *call_function(Py_Object ***pp_stack, int oparg) {
  int na = oparg & 0xFF;  // 位置实参数量.
  int nk = oparg >> 8;    // 键实参数量.
  int n = na + 2 * nk;
  ...
}

计算的 n = na+2*nk 我猜是每位置实参占用1个栈空间, 每一个键实参占用2个栈空间(key,value是两个值).
根据 na, nk 以及记录在 PyCodeObject 中 nargcount(?也含nkeycount), 能够计算出需要绑定的形参
数量(差).

// (猜测) 当只有位置参数时的快速函数调用处理
PyObject *fast_function(PyObject *func, pp_stack, n, na, nk) {
  ...
  PyFrame_Object *frame = new PyFrame_Object(...);
  // 拷贝函数参数:
  for (i = 0 ; i < n; ++i)
    locals[i] = *stack[i];
  ...
}

为访问局部变量, Python 虚拟机提供的指令对为 LOAD_FAST/STORE_FAST. (可认为是读/写栈中变量)

位置参数的缺省值, 如 def f(a=1, b=2) 被放置到 PyFunctionObject.func_defaults 字段中. 在有缺省值
的情况下, 函数调用不走 fast_function() 快速调用通道.

而是走 PyEval_EvalCodeEx() 通道. 这里有大堆代码, 我们简略一点:

PyObject *PyEval_EvalCodeEx(PyCodeObject *code,
    PyObject *globals, PyObject *locals,  // 作用域
     PyObject **args, int argcount,  // 位置参数信息
     PyObject **kws, int kwcount,    // 键参数信息
     PyObject **defs, int defcount,  // 函数默认参数信息
     PyObject *closure) {
  ...
}

这里仅从参数我们能看出, 此函数将负责绑定位置实参->键形参, 以及键实参->键形参, 以及处理位置实参如果未
给出时的默认值绑定. (细节看代码吧, 机理了解即可).

由于看起来绑定 key 参数需要复杂的遍历查找和构造 dict/list? 的工作, 也许考虑到性能因素的函数, 就尽量
少使用 key 来传递参数, 代之以位置参数或者数组或者dict 也许是一个选择.

关于扩展位置参数和扩展键参数, 我想大致也是这样. 只是代码要写复杂一些...
如果函数有扩展位置参数, 则设置 flags CO_VARARGS; 扩展键标志 CO_VARKEYWORDS.

略看了一下, 扩展位置参数放入到了 PyTupleObject 中. tuple 按照 python 介绍是一种不可变长度的 list.
扩展键参数放入 dict 中 (PyDict_New)... 注意:成本不低的...

函数中局部变量的访问

前面述及, 函数参数实际上也是一种局部的变量(实参->形参绑定之后), 都是通过 LOAD_FAST/STORE_FAST
指令读写(位于栈中). 由于编译期能够确定局部变量数量, 所以没有必要使用 locals 名字空间做动态搜索.
不然效率太低下.

嵌套函数, 闭包与 decorator

示例嵌套函数, 实际为一个闭包:

def get_compare(base):   # 此函数返回另一个函数
  def real_compare(value):
    return value > base  # 使用外部词法域变量 base.
  return real_compare    # 返回闭包函数

compare_with_10 = get_compare(10)  # 相当于定义新函数 compare_with_10
compare_with_10(5)  # 调用...

因此为支持嵌套函数实际上需要实现闭包. 也是实现在 PyFunctionObject 中.

在 PyCodeObject 中有两个相关字段:
1. co_cellvars: 保存被嵌套的作用域中使用的变量名集合 (该变量可能会被闭包).
2. co_freevars: 保存使用了的外层作用域中的变量名集合 (将闭包的外层的变量).

分配 frame 栈帧时计算所需栈空间的公式中含有这些值:
   extras = code->co_stacksize + code->co_nlocals + ncells + nfrees;  // 后两者即上面 co_...

为实验闭包实现的 python 代码:

def get_func():      # 1.外层函数
  value = 'inner'    # 2.将被(内层函数)闭包的变量
  def inner_func():  # 3.内层函数
6   LOAD_CLOSURE  0 (value)
9   BUILD_TUPLE   1
12  LOAD_CONST    2 (Code: inner-func)
15  MAKE_CLOSURE  0
18  STORE_FAST   0  (inner-func)
    print value       # 4.使用外层函数的变量, 也即闭包了 value 变量
    0  LOAD_DEREF  0 (value) ...
  return inner_func   # 5.

f = get_func()  # 6.得到闭包(函数)
f()             # 7.调用该闭包

 

为此引入一种 cell 对象 -- PyCellObject:

struct PyCellObject {
  HEAD 部分;
  PyObject *ob_ref;  // content of the cell or NULL when empty
}

看起来当一个会被闭包的局部变量(如例子中的 value) 实现为 PyCellObject, 其实际引用到的对象才是值.
指令 STORE_DEREF, LOAD_DEREF 用于访问这种被 cell 包装起来的值.

指令 LOAD_CLOSURE 用于获取 cell 对象本身, 建立起 tuple 存放在 PyFunctionObject 对象中, 这当然
应是在指令 MAKE_CLOSURE 中实现的了.

case LOAD_CLOSURE:
  x = freevars[oparg];  // freevars 存放会被闭包的变量
  PUSH(x);
  ...

case MAKE_CLOSURE:
  func = new PyFunction()
  closure_tuple = POP();  // 闭包的变量集合
  func->SetClosure(closure_tuple)  // 绑定约束集合
  ...

这样在内部 func (即闭包函数)中, 即通过访问 closure_tuple 中保存的 cell 对象, 即访问到被闭包的外部变量
(如 value). 这是通过间接一层引用的方式来访问闭包变量, 所以这种闭包变量可以读, 也可以写. 闭包变量(的值)
可以在多个函数间共享. 当然也可以优化一些的, 对那些只读的, 或只有一家访问的...

 

在 closure 的基础上, python 实现了 decorator. (对 python 的这一语法知识不熟悉, 以后再补上......)

 

 

小结:

函数的重点(之一)是参数传递/绑定方式及其实现机制, 想支持的传递方式越多越灵活, 实现当然就越复杂.

为闭包的实现引入了新的 cell 对象, 我觉得可以把它当成指针理解...

你可能感兴趣的:(看书 Python 源码分析笔记 (六) 函数)