PyCodeObject

还记得我们在之前讲pyc的例子中提到的PyCodeObject了吗,通过这一节的知识后,就可以基本上得知pyc内容结构了

python源码中的PyCodeObject对象

我们说Python编译器会将Python源代码编译成字节码,虚拟机执行的也是字节码,所以要理解虚拟机的运行时(runtime)行为,就必须要先掌握字节码。而我们说字节码是被底层结构体PyCodeObject的成员co_code指向,那么我们就必须来看看这个结构体了,它的定义位于 *Include/code.h* 中。

typedef struct {
    PyObject_HEAD		/* 头部信息, 我们看到真的一切皆对象, 字节码也是个对象 */	
    int co_argcount;            /* 可以通过位置参数传递的参数个数 */
    int co_posonlyargcount;     /* 只能通过位置参数传递的参数个数,  Python3.8新增 */
    int co_kwonlyargcount;      /* 只能通过关键字参数传递的参数个数 */
    int co_nlocals;             /* 代码块中局部变量的个数,也包括参数 */
    int co_stacksize;           /* 执行该段代码块需要的栈空间 */
    int co_flags;               /* 参数类型标识 */
    int co_firstlineno;         /* 代码块在对应文件的行号 */
    PyObject *co_code;          /* 指令集, 也就是字节码, 它是一个bytes对象 */
    PyObject *co_consts;        /* 常量池, 一个元组,保存代码块中的所有常量。 */
    PyObject *co_names;         /* 一个元组,保存代码块中引用的其它作用域的变量 */
    PyObject *co_varnames;      /* 一个元组,保存当前作用域中的变量 */
    PyObject *co_freevars;      /* 内层函数引用的外层函数的作用域中的变量 */
    PyObject *co_cellvars;      /* 外层函数中作用域中被内层函数引用的变量,本质上和co_freevars是一样的 */

    Py_ssize_t *co_cell2arg;    /* 无需关注 */
    PyObject *co_filename;      /* 代码块所在的文件名 */
    PyObject *co_name;          /* 代码块的名字,通常是函数名或者类名 */
    PyObject *co_lnotab;        /* 字节码指令与python源代码的行号之间的对应关系,以PyByteObject的形式存在 */
    
    //剩下的无需关注了
    void *co_zombieframe;       /* for optimization only (see frameobject.c) */
    PyObject *co_weakreflist;   /* to support weakrefs to code objects */
    void *co_extra;
    unsigned char *co_opcache_map;
    _PyOpcache *co_opcache;
    int co_opcache_flag; 
    unsigned char co_opcache_size; 
} PyCodeObject;

这里面的每一个成员,我们后面都会逐一演示进行说明。总之Python编译器在对Python源代码进行编译的时候,对于代码中的每一个block,都会创建一个PyCodeObject与之对应。但是多少代码才算得上是一个block呢?事实上,Python有一个简单而清晰的规则:当进入一个新的名字空间,或者说作用域时,我们就算是进入了一个新的block了。这里又引出了名字空间,别急,我们后面会一点一点说,总之先举个栗子:

class A:
    a = 123

def foo():
    a = []

我们仔细观察一下上面这个文件,它在编译完之后会有三个PyCodeObject对象,一个是对应整个py文件的,一个是对应class A的,一个是对应def foo的。因为这是三个不同的作用域,所以会有三个PyCodeObject对象。

在这里,我们开始提及Python中一个至关重要的概念--名字空间(name space)、也叫命名空间、名称空间,都是一个东西。名字空间是符号的上下文环境,符号的含义取决于名字空间。更具体的说,一个变量名对应的变量值什么,在Python中是不确定的,需要命名空间来决定。

对于某个符号、或者名字(我们在前面系列中说过Python的变量只是一个名字),比如说上面代码中的a,在某个名字空间中,它可能指向一个PyLongObject对象;而在另一个名字空间中,它可能指向一个PyListObject对象。但是在一个名字空间中,一个符号只能有一种含义。而且名字空间可以一层套一层的形成一条名字空间链,Python虚拟机在执行的时候,会有很大一部分时间消耗在从名字空间链中确定一个符号所对应的对象是什么。这也侧面说明了,Python为什么比较慢。

如果你现在名字空间还不是很了解,不要紧,随着剖析的深入,你一定会对名字空间和Python在名字空间链上的行为有着越来越深刻的理解。总之现在需要记住的是:一个code block对应一个名字空间(或者说作用域)、同时也对应一个PyCodeObject对象。在Python中,类、函数、module都对应着一个独自的名字空间,因此都会有一个PyCodeObject与之对应。

如何在Python中访问PyCodeObject对象

那么我们如何才能在Python中获取到PyCodeObject对象呢?PyCodeObject对象在Python中也是一个对象,它的类型对象是<class 'code'>。但是这个类,底层没有暴露给我们,所以code对于Python来说只是一个没有定义的变量罢了。

但是我们可以通过其它的方式进行获取,首先来看看如何通过函数来获取该函数对应的字节码。

def func():
    pass


print(type(func.__code__))  # <class 'code'>

我们可以通过函数的__code__拿到底层对应的PyCodeObject对象,当然也可以获取里面的属性,我们来演示一下。

co_argcount:可以通过位置参数传递的参数个数

def foo(a, b, c=3):
    pass
print(foo.__code__.co_argcount)  # 3


def bar(a, b, *args):
    pass
print(bar.__code__.co_argcount)  # 2


def func(a, b, *args, c):
    pass
print(func.__code__.co_argcount)  # 2

foo中的参数a、b、c都可以通过位置参数传递,所以结果是3;对于bar,显然是两个,这里不包括\*args;而函数func,显然是两个,因为参数c只能通过关键字参数传递。

co_posonlyargcount:只能通过位置参数传递的参数个数,python3.8新增

def foo(a, b, c):
    pass

print(foo.__code__.co_posonlyargcount)  # 0

def bar(a, b, /, c):
    pass

print(bar.__code__.co_posonlyargcount)  # 2

注意:这里是只能通过位置参数传递的参数个数。

co_kwonlyargcount:只能通过关键字参数传递的参数个数

def foo(a, b=1, c=2, *, d, e):
    pass


print(foo.__code__.co_kwonlyargcount)  # 2

这里是d和e,它们必须通过关键字参数传递。

co_nlocals:代码块中局部变量的个数,也包括参数

def foo(a, b, *, c):
    name = "xxx"
    age = 16
    gender = "f"
    c = 33

print(foo.__code__.co_nlocals)  # 6

局部变量:a、b、c、name、age、gender,所以我们看到在编译成字节码的时候函数内局部变量的个数就已经确定了,因为它是静态存储的。

co_stacksize:执行该段代码块需要的栈空间

def foo(a, b, *, c):
    name = "xxx"
    age = 16
    gender = "f"
    c = 33

print(foo.__code__.co_stacksize)  # 1

这个不需要关注

co_firstlineno:代码块在对应文件的起始行

def foo(a, b, *, c):
    pass

# 显然是文件的第一行
print(foo.__code__.co_firstlineno)  # 1

如果函数出现了调用呢?

def foo():
    return bar

def bar():
    pass

print(foo().__code__.co_firstlineno)  # 5

如果执行foo,那么会返回函数bar,调用的就是bar函数的字节码,那么得到就是def bar():所在的行数。因为每个函数都有自己独自的命名空间,以及PyCodeObject对象。

co_names:一个元组,保存代码块中不在当前作用域的变量

c = 1

def foo(a, b):
    print(a, b, c)
    d = (list, int, str)

print(foo.__code__.co_names)  # ('print', 'c', 'list', 'int', 'str')

我们看到print、c、list、int、str都是全局或者内置变量,函数、类也可以看成是变量,它们都不在当前foo函数的作用域中。

co_varnames:一个元组,保存在当前作用域中的变量

c = 1

def foo(a, b):
    print(a, b, c)
    d = (list, int, str)

print(foo.__code__.co_varnames)  # ('a', 'b', 'd')

a、b、d是位于当前foo函数的作用域当中的,所以编译阶段便确定了局部变量是什么。

co_consts:常量池,一个元组对象,保存代码块中的所有常量。

x = 123

def foo(a, b):
    c = "abc"
    print(x)
    print(True, False, list, [1, 2, 3], {"a": 1})
    return ">>>"

# list不属于常量
print(foo.__code__.co_consts)  # (None, 'abc', True, False, 1, 2, 3, 'a', '>>>')

co_consts里面出现的都是常量,而[1, 2, 3]{"a": 1},则是将里面元素单独拿出来了。不过可能有人好奇里面的None是从哪里来的。首先a和b是不是函数的参数啊,所以co_consts里面还要有两个常量,但是我们还没传参呢,所以使用None来代替。

co_freevars:内层函数引用的外层函数的作用域中的变量

def f1():
    a = 1
    b = 2
    def f2():
        print(a)
    return f2

# 这里调用的是f2的字节码
print(f1().__code__.co_freevars)  # ('a',)

co_cellvars:外层函数中作用域中被内层函数引用的变量,本质上和co_freevars是一样的

def f1():    
    a = 1
    b = 2
    def f2():
        print(a)
    return f2

# 但这里调用的是f1的字节码
print(f1.__code__.co_cellvars)  # ('a',)

co_filename:代码块所在的文件名

def foo():
    pass


print(foo.__code__.co_filename)  # D:/satori/1.py

co_name:代码块的名字,通常是函数名或者类名

def foo():
    pass


print(foo.__code__.co_name)  # foo

co_code:字节码

def foo(a, b, /, c, *, d, e):
    f = 123
    g = list()
    g.extend([tuple, getattr, print])


print(foo.__code__.co_code)
"""
b'd\x01}\x05t\x00\x83\x00}\x06|\x06\xa0\x01t\x02t\x03t\x04g\x03\xa1\x01\x01\x00d\x00S\x00'
"""
# 这便是字节码, 当然单单是这些字节码肯定不够的, 所以还需要其它的静态信息
# 其它的信息显然连同字节码一样, 都位于PyCodeObject中

# co_lnotab: 字节码指令与python源代码的行号之间的对应关系,以PyByteObject的形式存在
print(foo.__code__.co_lnotab)  # b'\x00\x01\x04\x01\x06\x01'
"""
然而事实上,Python不会直接记录这些信息,而是会记录增量值。比如说:
字节码在co_code中的偏移量            .py文件中源代码的行号
0                                  1  
6                                  2
50                                 7

那么co_lnotab就应该是: 0 1 6 1 44 5
0和1很好理解, 就是co_code和.py文件的起始位置
而6和1表示字节码的偏移量是6, .py文件的行号增加了1
而44和5表示字节码的偏移量是44, .py文件的行号增加了5
"""

引用

《深度剖析CPython解释器》10. Python中的PyCodeObject对象与pyc文件 - 古明地盆 - 博客园 (cnblogs.com)