python函数的执行过程

简介

在解释器接手之前,Python会执行其他3个步骤:词法分析,语法解析和编译。这三步合起来把源代码转换成code object,它包含着解释器可以理解的指令。而解释器的工作就是解释code object中的指令。

Python通常被称为解释型语言,就像Ruby,Perl一样,它们和编译型语言相对,比如C,Rust。然而,这里的术语并不是它看起来的那样精确。大多数解释型语言包括Python,确实会有编译这一步。而Python被称为解释型的原因是相对于编译型语言,它在编译这一步的工作相对较少(解释器做相对多的工作)。在这章后面你会看到,Python的编译器比C语言编译器需要更少的关于程序行为的信息。

解释器

Python解释器是一个虚拟机,模拟真实计算机的软件。这个虚拟机是栈机器,它用几个栈来完成操作(与之相对的是寄存器机器,它从特定的内存地址读写数据)。

Python解释器是一个字节码解释器:它的输入是一些命令集合称作字节码。当你写Python代码时,词法分析器,语法解析器和编译器生成code object让解释器去操作。每个code object都包含一个要被执行的指令集合 — 它就是字节码 — 另外还有一些解释器需要的信息。字节码是Python代码的一个中间层表示:它以一种解释器可以理解的方式来表示源代码。这和汇编语言作为C语言和机器语言的中间表示很类似。

基本执行

首先来看一个函数

def add() -> None:
    a = 1
    b = 2
    c = a + b
    print(c)

Python在运行时会暴露一大批内部信息,并且我们可以通过REPL直接访问这些信息。对于函数对象add,add.code 是与其关联的 code object,而 add.code.co_code 就是它的字节码。如下所示

字节码对象 <code object add at 0x0000020F80AA4F30, file “test01.py”, line 1>

字节码 b’\x97\x00d\x01}\x00d\x02}\x01|\x00|\x01z\x00\x00\x00}\x02t\x01\x00\x00\x00\x00\x00\x00\x00\x00|\x02\xab\x01\x00\x00\x00\x00\x00\x00\x01\x00y\x00’

汇编

计算机只能执行01010101……等二进制内容,汇编就是把人可以识别的汇编代码转化为计算机可以识别的二进制代码

反汇编

把计算机可以识别的二进制内容转化为人可以识别的汇编代码

关于dis

python中的dis模块可以查看一句python代码的cpu运行轨迹,也就是cpu指令 如果只是读取数据时,如读取一个函数,此时数据是安全的,因为不涉及任何修改 当改数据时,可能涉及数据不安全,如多个线程同时修改一个数据,原因是一句代码对应了多条cpu指令,如有4条指令,当执行完第2条时,cpu时间轮换了,此时的数据可能会发生错误。

编译

python在运行过程中会有一个main moudle,也就是包含 if name==’main’或者直接运行的py文件。 将用户自定义的moudle编译成pyCodeObject对象加载到内存中,编译结束后,将其以pyc文件保存在磁盘上。 这个pyCodeObject包含了python源代码中的字符串,常量值,以及通过语法解析后编译生成的字节码指令。 如何生成pyc文件

  1. 运行的时候自定义的模块会自动生成

  2. python -m compileall file_path利用Python命令手动生成

  3. 利用py_compile包进行编译

光看这样一串字节码是无法理解是什么意思的,我们可以使用这样一个工具:Python标准库中的dis module。dis是一个字节码反汇编器。反汇编器以为机器而写的底层代码作为输入,比如汇编代码和字节码,然后以人类可读的方式输出 下面让我们来反编译一下刚才那个函数:

dis.dis(add)
# 输出
| (1) | (2) | (3) | (4)          | (5) | (6)            | (7) |
| --- | --- | --- | ------------ | --- | -------------- |
| 7   |     | 10  | LOAD_FAST    | 0   | (a)            |
|     |     | 12  | LOAD_FAST    | 1   | (b)            |
|     |     | 14  | BINARY_OP    | 0   | (+)            |
|     |     | 18  | STORE_FAST   | 2   | (c             |
| 8   |     | 20  | LOAD_GLOBAL  | 1   | (NULL + print) |
|     |     | 30  | LOAD_FAST    | 2   | (c)            |
|     |     | 32  | CALL         | 1   |
|     |     | 40  | POP_TOP      |     |
|     |     | 42  | RETURN_CONST | 0   | (None)         |
|     |

我们来解释下每一列代表什么意思: 第1列表示源代码所在行号; 第2列表示可选地指示执行的当前指令(例如,当字节码来自框架对象时) 第3列一个标签,表示从早期指令到此指令的可能JUMP; 第4列,字节码中与字节索引相对应的地址(这些是2的倍数,因为Python 3.6对每条指令使用2字节,而之前的版本可能会有所不同); 第5列,指令名称(也称为opname),每个指令名称都在dis模块中简要解释,它们的实现可以在ceval.c(CPython的核心文件)中找到 第6列: Python内部用于获取一些常量或变量、管理堆栈、跳转到特定指令等的指令的参数(如果有的话) 从上面可以看出 a = 1 使用了两条指令来表示:LOAD_CONST 和 STORE_FAST ,分别表示加载常量 1 和存储变量 a 。 第7列: 对解释器的参数的人性化的解释. Python解释器中使用一个字节来表示指令,使用两个字节来表示一个指令的参数(为什么用两个字节表示指令的参数?如果Python使用一个字节,每个code object你只能有256个常量/名字,而用两个字节,就增加到了256的平方,65536个)。 查看字节码对象和字节码

# 查看字节码对象
print(add.__code__)
# 查看字节码
print(add.__code__.co_code)
# 转换为list
list((add.__code__.co_code)

栈帧

Python 的函数栈帧中记录的信息是动态变化的。函数栈帧是在函数调用时创建的,用于存储函数执行过程中的各种信息,比如局部变量、参数、当前执行指令等。随着函数的执行过程,函数栈帧中的信息会动态地变化。 具体来说,随着函数的执行,函数栈帧会动态地推入和弹出栈。当函数被调用时,会创建一个新的函数栈帧并推入栈顶,表示函数的执行。当函数执行完毕或遇到 return 语句时,函数栈帧会从栈顶弹出,被销毁。在函数执行过程中,函数栈帧中的信息会不断地更新和变化,包括局部变量的值、当前执行指令的位置等。 Python虚拟机是一个栈机器。它能顺序执行指令,在指令间跳转,压入或弹出栈值。 但是这和我们心想的解释器还有一定距离。在前面的那个例子中,最后一条指令是RETURN_VALUE,它和return语句想对应。但是它返回到哪里去呢? 为了回答这个问题,我们必须要增加一层复杂性:frame。一个frame是一些信息的集合和代码的执行上下文。frames在Python代码执行时动态的创建和销毁。每个frame对应函数的一次调用。— 所以每个frame只有一个code object与之关联,而一个code object可以很多frame。比如你有一个函数递归的调用自己10次,这时有11个frame。总的来说,Python程序的每个作用域有一个frame,比如,每个module,每个函数调用,每个类定义。 Frame存在于调用栈中,一个和我们之前讨论的完全不同的栈。(你最熟悉的栈就是调用栈,就是你经常看到的异常回溯,每个以”File ‘program.py’”开始的回溯对应一个frame。)解释器在执行字节码时操作的栈,我们叫它数据栈。其实还有第三个栈,叫做块栈,用于特定的控制流块,比如循环和异常处理。调用栈中的每个frame都有它自己的数据栈和块栈。 让我们用一个具体的例子来说明

def bar1(y: int) -> int:
    z = y + 3
    return z


def foo1() -> int:
    a = 1
    b = 2
    return a + bar1(b)


foo1()

现在,解释器在bar函数的调用中。调用栈中有3个fram:一个对应于module层,一个对应函数foo,别一个对应函数bar。一旦bar返回,与它对应的frame就会从调用栈中弹出并丢弃。 字节码指令RETURN_VALUE告诉解释器在frame间传递一个值。首先,它把位于调用栈栈顶的frame中的数据栈的栈顶值弹出。然后把整个frame弹出丢弃。最后把这个值压到下一个frame的数据栈中。 那为什么一个frame必须要独立拥有一个数据栈呢? Python真的很少依赖于每个frame有一个数据栈这个特性。在Python中几乎所有的操作都会清空数据栈,所以所有的frame公用一个数据栈是没问题的。在上面的例子中,当bar执行完后,它的数据栈为空。即使foo公用这一个栈,它的值也不会受影响。然而,对应生成器,一个关键的特点是它能暂停一个frame的执行,返回到其他的frame,一段时间后它能返回到原来的frame,并以它离开时的同样的状态继续执行。

函数调用

函数的执行过程从表面上看只是被调用的过程,但它的背后却是用栈这种数据结构实现的。理解了函数的调用过程,递归也就变得容易理解。 下面用一段代码来展示函数的调用

def a():
    print("进入函数a")
    c()
    print("函数a执行结束")

def b():
    print("进入函数b")
    c()
    print("函数b执行结束")


def c():
    print("进入函数c")

a()

为什么函数c执行结束后要回到函数中,而不是函数b中?换一种问法,如果单独执行函数c,函数执行结束后既不会回到函数a,也不会回到函数b,为什么在函数a中执行函数c,函数c直接结束后就要回到函数a呢?

函数调用与栈

在函数a中调用函数c,函数c执行结束后程序要回到函数a中继续执行,我们可以大胆猜测,一定是在某个地方记录了是在函数a中调用执行了函数c,所以才能准确的找到回去的路线,这个记录函数调用信息的地方就是栈。

栈是一种先进后出的数据结构,本文需要你对栈这种数据结构有一定的了解,否则,接下来要讲述的内容你很难理解。

在调用函数时,解释器会将函数的调用信息保存到栈中,这些保存的信息包括:

  1. 函数在第几行代码被执行

  2. 函数所在脚本

  3. 函数里的局部变量 在python中,可以通过sys._getframe()来查看这些信息

import sys

def a(count):
    b(count - 1)
    print(count)

def b(count):
    c(count - 1)
    print(count)

def c(count):
    c_frame = sys._getframe()       # 函数c的frame
    print(c_frame.f_code, c_frame.f_lineno, c_frame.f_locals)

    b_frame = c_frame.f_back        # 函数b的frame
    print(b_frame.f_code, b_frame.f_lineno, b_frame.f_locals)

    a_frame = b_frame.f_back        # 函数a的frame
    print(a_frame.f_code, a_frame.f_lineno, a_frame.f_locals)
    print(count)

a(3)
# 输出
<code object c at 0x000001E1E9293BF0, file "f:\Project\flask-app\tests\test01.py", line 14> 16 {'count': 1, 'c_frame': <frame at 0x000001E1E8E73780, file 'f:\\Project\\flask-app\\tests\\test01.py', line 16, code c>}
<code object b at 0x000001E1E8E91330, file "f:\Project\flask-app\tests\test01.py", line 9> 10 {'count': 2}
<code object a at 0x000001E1E8E91430, file "f:\Project\flask-app\tests\test01.py", line 4> 5 {'count': 3}

f_code 保存代码信息 f_lineno 保存当前函数执行到第几行,也叫函数返回地址 f_locals 保存当前函数局部变量 函数的调用信息保存在frame结构中,通过f_back可以获得上一层函数的调用信息。

函数调用过程

在调用函数时,会将当前信息保存到栈中,这其中就包括当前执行到第几行(f_lineno),当前的上下文环境(f_locals),当函数执行结束后,解释器则根据这些信息进行调度,它要根据f_lineno找到下一行要执行的代码,同时根据f_locals来还原现场。仍然以第2小节中的内容来透视函数的调用过程 20240513105806

函数逐个调用的过程中,每一次调用都会向栈里压如一次有关函数调用的信息,这其中最重要的就是f_lineno 和 f_locals,一个记录当前函数调用发生在第几行,一个记录当前环境下的变量信息,前者是为了在函数执行结束后找到回来的位置,后者是为了回来以后还原上下文环境。 当c函数执行结束后,栈顶的c_frame被移除,此时栈顶保存的信息是b_frame,b_frame保存的信息包括 <code object b at 0x10f659db0, file “/Users/kwsy/kwsy/coolpython/demo2.py”, line 7> 8 {’count’: 2} 通过frame里信息可以得知,上一次函数调用发生在demo2.py的第8行,因此下一行代码应该执行第9行代码,通过f_locals得知,count的值应该是2,利用f_locals里的信息,还原了现场环境,最终print(count)的输出结果是2

函数调用示例

def foo1(b,b1=3):
    print("foo1 called",b,b1)

def foo2(c):
    foo3(c)
    print("foo2 called",c)

def foo3(d):
    print("foo3 called",d)

def main():
    print("main called")
    foo1(100,101)
    foo2(200)
    print("main ending")

main()

全局帧是程序的顶层帧,它表示整个程序的全局作用域。在全局作用域中定义的变量和函数都属于全局命名空间,在全局帧中存储了这些全局变量和函数的信息。 全局帧通常在 Python 解释器启动时就被创建,并持续存在于整个程序执行过程中。它负责管理全局命名空间中的变量和函数,并提供给程序的其他部分进行访问和修改。 全局帧是 Python 解释器内部的一个数据结构,通常不直接暴露给用户。可以使用一些技巧来查看全局作用域中的变量和函数:

  1. 使用 globals() 函数:globals() 函数返回一个包含全局作用域中所有变量和它们的值的字典。

  2. 使用 dir() 函数:dir() 函数返回指定对象的属性和方法列表。如果不指定对象,则返回当前作用域内的所有变量和函数名。 调用过程: 全局帧中生成foo1,foo2,foo3,main函数对象

  3. 调用main函数

  4. main中查找print函数压栈,将常量字符串压栈

  5. main中全局查找函数foo1压栈,将常量100,101压栈,调用函数foo1,创建栈帧,print函数压栈,字符串和变量b,b1压栈,调用函数,弹出栈顶,返回值

  6. main中全局查找foo2函数压栈,将常量200压栈,调用foo2,创建栈帧,foo3函数压栈,变量c引用压栈,调用foo3,创建栈帧,foo3完成print函数调用后返回,foo2恢复调用,执行print后,返回值.main中foo2调用结束弹出栈顶

  7. main函数继续执行print函数调用,弹出栈顶,main函数返回 函数调用–全局查找函数–函数压栈–常量字符串压栈–创建栈帧–执行函数体–返回执行结果–函数出栈