Python函数的实现原理源码分析


这篇文章主要介绍“Python函数的实现原理源码分析”,在日常操作中,相信很多人在Python函数的实现原理源码分析问题上存在疑惑,小编查阅了各式资料,整理出简单好用的操作方法,希望对大家解答”Python函数的实现原理源码分析”的疑惑有所帮助!接下来,请跟着小编一起来学习吧! 函数是任何一门编程语言都具备的基本元素,它可以将多个要执行的操作组合起来,一个函数代表了一系列的操作。而且在调用函数时会干什么来着,没错,要创建栈帧,用于函数的执行。Python 一切皆对象,函数也不例外。函数在底层是通过 PyFunctionObject 结构体实现的,定义在 funcobject.h 中。我们来实际获取一下这些成员,看看它们在 Python 中是如何表现的。func_code:函数的字节码func_globals:global 名字空间func_defaults:函数参数的默认值func_kwdefaults:只能通过关键字的方式传递的 “参数” 和 “该参数的默认值” 组成的字典在前面加上一个 *,就表示后面的参数必须通过关键字的方式传递。因为如果不通过关键字参数传递的话,那么无论多少个位置参数都会被 * 接收,无论如何也不可能传递给 name、age。我们知道如果定义了 *args,那么函数可以接收任意个位置参数,然后这些参数以元组的形式保存在 args 里面。但这里我们不需要,我们只是希望后面的参数必须通过关键字参数传递,因此前面写一个 * 即可,当然写 *args 也是可以的。func_closure:闭包对象注意:查看闭包属性我们使用的是内层函数,不是外层的 foo。func_doc:函数的 docstringfunc_name:函数的名字当然不光是函数,方法、类、模块都有自己的名字。func_dict:函数的属性字典因为函数在底层也是由一个类实例化得到的,所以它可以有自己的属性字典,只不过这个字典一般为空。当然啦,我们也可以整点骚操作:所以虽然叫函数,但它也是由某个类型对象实现的。func_weakreflist:弱引用列表Python无法获取这个属性,底层没有提供相应的接口,关于弱引用此处就不深入讨论了。func_module:函数所在的模块类、方法、协程也有 __module__ 属性。func_annotations:类型注解func_qualname:全限定名全限定名要更加地完整一些。但是这个类底层没有暴露给我们,我们不能直接用,因为函数通过 def 创建即可,不需要通过类型对象来创建。前面我们说到函数在底层是由 PyFunctionObject 结构体实现的,它里面有一个 func_code 成员,指向一个 PyCodeObject 对象,函数就是根据它创建的。因为 PyCodeObject 是对一段代码的静态表示,Python 编译器在将源代码编译之后,对里面的每一个代码块(code block)都会生成一个、并且是唯一一个 PyCodeObject 对象。该对象包含了这个代码块的一些静态信息,也就是可以从源代码当中看到的信息。比如某个函数对应的代码块里面有一个 a = 1 这样的表达式,那么符号 a 和整数 1、以及它们之间的联系就是静态信息,而这些信息会被静态存储起来。符号 a 被存在符号表 co_varnames 中;整数 1 被存在常量池 co_consts 中;这两者之间是一个赋值语句,因此会有两条指令:LOAD_CONST 和 STORE_FAST,它们存在字节码指令序列 co_code 中;以上这些信息是编译的时候就可以得到的,因此 PyCodeObject 对象是编译之后的结果。但是 PyFunctionObject 对象是何时产生的呢?显然它是 Python 代码在运行时动态产生的,更准确的说,是虚拟机在执行一个 def 语句的时候创建的。当虚拟机在当前栈帧中执行字节码时发现了 def 语句,那么就代表发现了新的 PyCodeObject 对象,因为它们是可以层层嵌套的。所以虚拟机会根据这个 PyCodeObject 对象创建对应的 PyFunctionObject 对象,然后将函数名和 PyFunctionObject 对象(函数体)组成键值对放在当前的 local 空间中。而在 PyFunctionObject 对象中,也需要拿到相关的静态信息,因此会有一个 func_code 成员指向 PyCodeObject。除此之外,PyFunctionObject 对象中还包含了一些函数在执行时所必需的动态信息,即上下文信息。比如 func_globals,就是函数在执行时关联的 global 空间,说白了就是在局部变量找不到的时候能够找全局变量,可如果连 global 空间都没有的话,那即便想找也无从下手呀。而 global 作用域中的符号和值必须在运行时才能确定,所以这部分必须在运行时动态创建,无法静态存储在 PyCodeObject 中,因此要根据 PyCodeObject 对象创建 PyFunctionObject 对象。总之一切的目的,都是为了更好地执行字节码。我们举个例子:调用的时候,会从 local 空间中取出符号 foo 对应的 PyFunctionObject 对象。然后根据这个 PyFunctionObject 对象创建 PyFrameObject 对象,也就是为函数创建一个栈帧,随后将执行权交给新创建的栈帧,并在新创建的栈帧中执行字节码。通过上面的分析,我们知道了函数是虚拟机在遇到 def 语句的时候创建的,并保存在 local 空间中。当我们通过函数名()的方式调用时,会从 local 空间取出和函数名绑定的函数对象,然后执行。那么问题来了,函数(对象)是怎么创建的呢?或者说虚拟机是如何完成 PyCodeObject 对象到 PyFunctionObject 对象之间的转变呢?显然想了解这其中的奥秘,就必须从字节码入手。源代码很简单,定义一个变量 name 和函数 foo,然后调用函数。显然源代码在编译之后会产生两个 PyCodeObject,一个是模块的,一个是函数 foo 的,我们来看一下。上面有一个有趣的现象,就是源代码的行号。之前看到源代码的行号都是从上往下、依次增大的,这很好理解,毕竟一条一条解释嘛。但是这里却发生了变化,先执行了第 6 行,之后再执行第 4 行。如果是从 Python 层面的函数调用来理解的话,很容易一句话就解释了,因为函数只有在调用的时候才会执行,而调用肯定发生在创建之后。但是从字节码的角度来理解的话,我们发现函数的声明和实现是分离的,是在不同的 PyCodeObject 对象中。确实如此,虽然函数名和函数体是一个整体,但是虚拟机在实现的时候,却在物理上将它们分离开了。正所谓函数即变量,我们可以把函数当成普通的变量来处理。函数名就是变量名,它位于模块对应的 PyCodeObject 的符号表中;函数体就是变量指向的值,它是基于一个独立的 PyCodeObject 构建的。换句话说,在编译时,函数体里面的代码会位于一个新的 PyCodeObject 对象当中,所以函数的声明和实现是分离的。至此,函数的结构就已经非常清晰了。所以函数名和函数体是分离的,它们存储在不同的 PyCodeObject 对象当中。分析完结构之后,重点就要落在 MAKE_FUNCTION 指令上了,我们说当遇到 def foo(a, b) 的时候,就知道要创建函数了。在语法上这是函数的声明语句,但从虚拟机的角度来看这其实是函数对象的创建语句。所以下面我们就要分析一下这个指令,看看它到底是怎么将一个 PyCodeObject 对象变成一个 PyFunctionObject 对象的。整个步骤很好理解,先通过 LOAD_CONST 将 PyCodeObject 对象和符号 foo 压入栈中。然后执行 MAKE_FUNCTION 的时候,将两者从栈中弹出,再加上当前栈帧对象中维护的 global 名字空间,三者作为参数传入 PyFunction_NewWithQualName 函数中,从而构建出相应的函数对象。上面的函数比较简单,如果再加上类型注解、以及默认值,会有什么效果呢?这里我们加上了类型注解和默认值,看看它的字节码指令会有什么变化?0 LOAD_CONST 0 (‘satori’)
2 STORE_NAME 0 (name)

4 LOAD_CONST 7 ((1, 2))
6 LOAD_NAME 1 (int)
8 LOAD_NAME 1 (int)
10 LOAD_CONST 3 ((‘a’, ‘b’))
12 BUILD_CONST_KEY_MAP 2
14 LOAD_CONST 4 ()
16 LOAD_CONST 5 ('foo')
18 MAKE_FUNCTION 5 (defaults, annotations)
......
......
不难发现,在构建函数时会先将默认值以元组的形式压入运行时栈;然后再根据使用了类型注解的参数和类型构建一个字典,并将这个字典压入运行时栈。后续创建函数的时候,会将默认值保存在 func_defaults 成员中,类型注解对应的字典会保存在 func_annotations 成员中。基于类型注解和描述符,我们便可以像静态语言一样,实现函数参数的类型约束。介绍完描述符之后,我们会举例说明。我们通过一些骚操作,来更好地理解一下函数。之前说 是函数的类型对象,而这个类底层没有暴露给我们,但是可以通过曲线救国的方式进行获取。那么下面就来创建函数:是不是很神奇呢?另外我们说函数在访问变量时,显然先从自身的符号表中查找,如果没有再去找全局变量。这是因为,我们在创建函数的时候将 global 名字空间传进去了,如果我们不传递呢?因此现在我们又从 Python 的角度理解了一遍,为什么函数能够在局部变量找不到的时候,去找全局变量。原因就在于构建函数的时候,将 global 名字空间交给了函数,使得函数可以在 global 空间进行变量查找,所以它才能够找到全局变量。而我们这里给了一个空字典,那么显然就找不到 gender 这个变量了。此外我们还可以为函数指定默认值:我们看到函数 f 明明接收三个参数,但是调用时不传递居然也不会报错,原因就在于我们指定了默认值。而默认值可以在定义函数的时候指定,也可以通过 __defaults__ 指定,但很明显我们应该通过前者来指定。如果你用的是 pycharm,那么会在 f() 这个位置给你飘黄,提示你参数没有传递。但我们知道,由于使用 __defaults__ 已经设置了默认值,所以这里是不会报错的。只不过 pycharm 没有检测到,当然基本上所有的 IDE 都无法做到这一点,毕竟动态语言。另外 __defaults__ 接收的元组里面的元素个数和参数个数不匹配怎么办?由于元组里面只有两个元素,意味着我们在调用时需要至少传递一个参数,而这个参数会赋值给 name。原因就是在设置默认值的时候是从后往前设置的,也就是 “female” 会给赋值给 gender,15 会赋值给 age。而 name 没有得到默认值,那么它就需要调用者显式传递了。为啥 Python 在设置默认值是从后往前设置呢?如果从前往后设置的话,会出现什么后果呢?显然此时 15 会赋值给 name,”female” 会赋值给 age,那么函数就等价于如下:这样的函数能够通过编译吗?显然是不行的,因为默认参数必须在非默认参数的后面。所以 Python 的这个做法是完全正确的,必须要从后往前进行设置。另外我们知道默认值的个数是小于等于参数个数的,如果大于会怎么样呢?依旧从后往前进行设置,当所有参数都有默认值了,那么就结束了。当然,如果不使用 __defaults__,是不可能出现默认值个数大于参数个数的。可要是 __defaults__ 指向的元组先结束,那么没有得到默认值的参数就必须由我们来传递了。最后再来说一下如何深拷贝一个函数。首先如果是你的话,你会怎么拷贝一个函数呢?不出意外的话,你应该会使用 copy 模块。修改 f 的 __defaults__,会对 new_f 产生影响,因此我们并没有实现函数的深度拷贝。事实上,copy 模块无法对函数、方法、回溯栈、栈帧、模块、文件、套接字等类型的实例实现深度拷贝。那我们应该怎么做呢?此时修改 f 不会影响 new_f,当然在拷贝的时候也可以自定义属性。其实上面实现的深拷贝,本质上就是定义了一个新的函数。由于是两个不同的函数,那么自然就没有联系了。再来看看如何检测一个函数有哪些参数,首先函数的局部变量(包括参数)在编译时就已经确定,会存在符号表 co_varnames 中。注意:在定义函数的时候,* ** 最多只能出现一次。显然 a 和 b 必须通过位置参数传递,c 和 d 可以通过位置参数和关键字参数传递,e 和 f 必须通过关键字参数传递。而从打印的符号表来看,里面的符号是有顺序的。参数永远处于函数内部定义的局部变量的前面,比如 g 和 h 就是函数内部定义的局部变量,所以它在所有参数的后面。而对于参数,* ** 会位于最后面,其它参数位置不变。所以除了 g 和 h,最后面的就是 args 和 kwargs。那么接下来我们就可以进行检测了。以上我们检测出了函数都有哪些参数,你也可以将其封装成一个函数,实现代码的复用。然后需要注意一下 args 和 kwargs,打印的内容主要取决定义时使用的名字。如果定义的时候是 *ARGS 和 **KWARGS,那么这里就会打印 ARGS 和 KWARGS,只不过一般我们都叫做 *args 和 **kwargs。但如果我们定义的时候不是 *args,只是一个 *,那么它就不是参数了。单独的一个 * 只是为了强制要求后面的参数必须通过关键字参数的方式传递。到目前为止,我们聊了聊 Python 函数的底层实现,并且还演示了如何通过函数的类型对象自定义一个函数,以及如何获取函数的参数。虽然这在工作中没有太大意义,但是可以让我们深刻理解函数的行为。下面我来探讨一下函数在底层是怎么调用的,但是在介绍调用之前,我们需要补充一个知识点。函数实际上分为两种:如果是 Python 实现的函数,底层会对应 PyFunctionObject。其类型在 Python 里面是 ,在底层是 PyFunction_Type;如果是 C 实现的函数,底层会对应 PyCFunctionObject。其类型在 Python 里面是 ,在底层是 PyCFunction_Type;像内置函数、使用 C 扩展编写的函数,它们都是 PyCFunctionObject。另外从名字上可以看出 PyCFunctionObject 不仅用于 C 实现的函数,还用于方法。关于方法,我们后续在介绍类的时候细说,这里暂时不做深入讨论。总之对于 Python 函数和 C 函数,底层在实现的时候将两者分开了,因为 C 函数可以有更快的执行方式。注意这里说的 C 函数,指的是 C 实现的 Python 函数。像内置函数就是 C 实现的,比如 sum、getattr 等等。好了,下面来看函数调用的具体细节。还是以一个简单的函数为例,看看它的字节码:相信现在看字节码已经不是什么问题了,然后我们看到调用函数用的是 CALL_FUNCTION 指令,那么这个指令都做了哪些事情呢?CALL_FUNCTION 这个指令之前提到过,但是函数的核心执行流程是在 call_function 里面,它位于 ceval.c 中,我们来看一下。因此接下来重点就在 _PyObject_Vectorcall 函数上面,在该函数内部又会调用其它函数,最终会走到 _PyFunction_Fa免费云主机域名stCallDict 这里。所以函数调用时会有两种方式:因此我们看到,总共有两条途径,分别针对有无关键字参数。但是最终殊途同归,都会走到 PyEval_EvalFrameEx 那里,然后虚拟机在新的栈帧中执行新的 PyCodeObject。不过可能有人会好奇,我们之前说过:PyFrameObject 是根据 PyCodeObject 创建的PyFunctionObject 也是根据 PyCodeObject 创建的那么 PyFrameObject 和 PyFunctionObject 之间有啥关系呢?如果把 PyCodeObject 比喻成妹子,那么 PyFunctionObject 就是妹子的备胎,PyFrameObject 就是妹子的心上人。其实在栈帧中执行指令时候,PyFunctionObject 的影响就已经消失了,真正对栈帧产生影响的是PyFunctionObject 里面的 PyCodeObject 对象和 global 名字空间。也就是说,最终是 PyFrameObject 和 PyCodeObject 两者如胶似漆,跟 PyFunctionObject 之间没有关系,所以 PyFunctionObject 辛苦一场,实际上是为别人做了嫁衣。PyFunctionObject 主要是对 PyCodeObject 和 global 名字空间的一种打包和运输方式。到此,关于“Python函数的实现原理源码分析”的学习就结束了,希望能够解决大家的疑惑。理论与实践的搭配能更好的帮助大家学习,快去试试吧!若想继续学习更多相关知识,请继续关注百云主机网站,小编会继续努力为大家带来更多实用的文章!

相关推荐: 无法加载控制器 1.php错误如何解决

这篇文章主要介绍“无法加载控制器 1.php错误如何解决”的相关知识,小编通过实际案例向大家展示操作过程,操作方法简单快捷,实用性强,希望这篇“无法加载控制器 1.php错误如何解决”文章能帮助大家解决问题。 文件不存在当我们在代码中指定了错误的控制器文件名时…

免责声明:本站发布的图片视频文字,以转载和分享为主,文章观点不代表本站立场,本站不承担相关法律责任;如果涉及侵权请联系邮箱:360163164@qq.com举报,并提供相关证据,经查实将立刻删除涉嫌侵权内容。

Like (0)
Donate 微信扫一扫 微信扫一扫
Previous 07/06 10:14
Next 07/06 10:14

相关推荐