Pythonでバイトコードオブジェクトと名前空間を深く復号化する

くさび

(确实有道理)使用レベルでさえ、Pythonはシンプルだと言われています。しかし、高レベルのpythonerとして、私たちはpythonの背後にあるメカニズムを調査する必要があります。これにより、インタビューで印象づけられるだけでなく、pythonについてさらに知ることができます。Pythonのバイトコードを解読してみましょう。ただし、バイトコードを解読する前に、事前知識を知っておく必要があります。

Pythonはどのようにプログラムを実行しますか?

Pythonプログラムを開始するとき、 `python xxx.py`で十分ですが、このプロセスでPythonインタープリターは何をしますか?

  • 最初にファイルをメモリに読み込んでから、単語のセグメンテーションを実行して、ソースファイルのコンテンツを1つずつトークンに分割します。対話モードの場合は、入力するすべての行です。
  • 次に、単語のセグメンテーション後の結果を文法的に分析して、抽象構文ツリー(抽象構文ツリー、略してAST)を構築します**。ほとんどすべての言語にこのプロセスがあります。
  • astをビルドした後、astをコンパイルしてバイトコードオブジェクトを取得します
  • 最後に、バイトコードが実行されるため、Pythonインタープリターが実際に実行するのは、実際にはコンパイル済みのバイトコードです。

したがって、Pythonはインタプリタ言語ですが、コンパイルも必要であり、コンパイルの結果はバイトコードオブジェクトです。なぜコンパイルされるのですか?1つは文法の検出であり、実行時エラーと構文エラーの2種類のエラーがあることがわかっています。

  • 运行时错误:在执行字节码的时候出现的错误,比如变量未定义、索引越界等等,这些只有在执行的时候才会发现
  • 语法错误:不符合python语法规则,在编译阶段就会报错。错误类型基本上都是SyntaxError
try:
    @@@
except Exception:
    pass

たとえば、上記のコードは、例外のキャッチを使用している場合でも、例外を発生させます。これは、この種のエラーは非常にばかげたエラーであり、Pythonの文法規則にまったく準拠していないため、コンパイル段階で直接例外がスローされ、実行されるまで待つ必要がないためです。一度実行したら、少なくとも文法が正しいことを証明してください。したがって、コンパイルの利点は、これらの構文エラーを事前に検出できることです。

コンパイルのもう1つの利点は、プログラムの実行を高速化できることです。たとえば、リストとタプルは可変で不変であるため、メモリ割り当ての点で異なります。関数にyieldが表示される場合、発電機に。これらは、コンパイルの段階で検出してマークすることができます。次に、バイトコードが実際に実行されるときに、それぞれのIDがすでにわかっているため、メモリをより迅速に割り当てることができます。

バイトコードオブジェクトとpycファイル

pycファイルについて話しましょう。上記から、Pythonインタープリターが実行されると、ソースファイルに従ってコンパイルされ、バイトコードが生成されることがわかります。しかし、実行するたびにコンパイルする必要がありますか?もちろん、バイトコードオブジェクトがコンパイルされると、pycファイルに書き込まれます。このようにして、次回の実行時には再度コンパイルされることはありませんが、バイトコードオブジェクトは対応するpycファイルから直接読み取られるため、pycファイルはハードディスク上のバイトコードオブジェクトの表現と同等です。

pyファイルに対応するpycファイルは、pyファイルが置かれているディレクトリの下の__pycache__ディレクトリにあります

たとえば、正規化を使用する場合import re、理論的には、Pythonインタープリターはre.pyファイルをLibディレクトリーの下にロードしますが、ロードする(假设执行的py文件的当前目录下没有re.py或者re.pyd等文件)前に、最初__pycache__にre.pyに対応するpycファイルがLibディレクトリーの下のディレクトリーに存在するかどうかを確認します存在する場合は、pycファイルから直接バイトコードをロードします。存在しない場合は、Pythonインタープリターが最終的にバイトコードオブジェクトを実行するため、バイトコードオブジェクトを取得するためにre.pyもコンパイルされます。

例えば、図がre.cpython-38.pycありre.py、対応するPYCファイル、CPythonのはインタプリタのバージョンを指し、バージョン番号38、私の現在のPythonのバージョンはpython3.8です。さらに、re.pyに対応するpycが見つかった場合は、直接pycをロードしますが、見つからない場合は、re.pyを再度読み取り、バイトコードにコンパイルしますが、バイトコードにコンパイルした後は、直接ではありません。それを使用するだけですが、最初に対応するpycファイルに書き込み、次に対応するpycファイルからバイトコードオブジェクトをリロードします。つまり、最終的なプログラムが正常にimport re実行されたかどうかに関係なく、実行が完了した時点で対応するpycファイルが生成されています。

しかし、興味がある人もいるかもしれません。ソースファイルが変更された場合はどうでしょうか。それがpython3.6などのインタープリターの別のバージョンである場合、python3.8でコンパイルされたバイトコードを実行できますか?したがって、これにはpycファイルの他の2つの属性、マジックナンバーと作成時間が含まれます。

pycファイルを作成すると、3つの内容が実際に書き込まれます。

  • magic number:这是python定义的一个整数值,不同版本的python会定义不同的magic number,这个值是为了保证python能够加载正确的pyc。比如python3.7不会加载3.6版本的pyc,因为python在加载这个pyc文件的时候会首先检测该pyc的magic number,如果和自身的magic number不一致,则拒绝加载。
  • pyc的创建时间:这个很好理解,因为编译完之后要是把源代码修改了怎么办呢?因此会判断源代码的最后修改时间和pyc文件的创建时间,如果pyc文件的创建时间比源代码修改时间要早,说明在生成pyc之后,源代码被修改了,那么会重新编译新的pyc,而反之则会直接加载pyc。
  • 字节码对象:这个不需要说了。

ただし、pyファイルを実行しても、実行されたpyファイル自体に対応するpycファイルは作成されません。ただし、モジュールをインポートするロジックが実行されたファイルに表示される場合などimport abc、abc.pyが配置__pycache__れているディレクトリに対応するpycファイルが生成されていることがわかります。これは、インポート文がpycファイルの生成をトリガーすることを示しています。

名前空間

バイトコードオブジェクトは、Pythonの下部にあるPyCodeObject構造体のインスタンスに対応しています。Pythonのオブジェクトは、本質的にはCのmalloc関数がヒープ領域の構造体に適用するメモリの一部であると言います。もちろん、基礎となる知識については触れません。バイトコードオブジェクトはPythonのコードオブジェクトです。つまり、<class 'code'>バイトコードオブジェクトのタイプはなので、バイトコードオブジェクトもPythonのオブジェクトです。

ただし<class 'code'>、基になるPythonがこのAPIを公開していないため、このクラスを直接使用することはできません。

しかし、問題があります:pyファイルをコンパイルした後、いくつのバイトコードオブジェクトを取得しますか?

答案是不确定,因为python在对python源代码进行编译的时候,对于代码中的每一个block,都会创建一个PyCodeObject与之对应,但如何确定多少代码才算是一个block呢?事实上,python有一个简单而清晰的规则:当进入一个新的命名空间、或者说作用域时,我们就算是进入了一个新的block了。

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

a = 1


def foo():
    a = "xx"
    

class A:
    a = [1, 2, 3]

对于某个符号、或者变量,比如说a,在某个命名空间中,它可能是一个int对象;而在另一个命名空间中,它可能是一个str对象,正如我们上面的代码。

如果说命名空间,可能有人不是很了解,如果我说作用域的话,那么就很熟悉了。我们知道python查找一个变量的时候,如果不考虑闭包的话,那么按照LGB的方式查找。其实本质上,就是按照local命名空间、global命名空间、builtin命名空间进行查找。

对于一个函数来讲,local空间就是函数内部的局部作用域,global空间则是全局作用域。但是对于外层来说,它的local空间和global空间是一样的。

# 调用内置函数locals可以得到当前的local命名空间
# 调用内置函数globals可以得到当前的global命名空间
# 但是对于外层、也就是全局来讲,显然它们是一样的
print(locals() == globals())  # True

# 命名空间本质是一个字典,当我们创建一个变量a = 123
# 相当于在对应的空间中添加了"a": 123这个键值对
# 那么如果在对应的空间中添加了"a": 123这个键值对,等价于创建了一个变量a = 123
globals()["a"] = 123
print(a)  # 123

# 对于全局来讲,local命名空间和global命名空间是一个东西
locals()["b"] = "abc"
print(b)  # abc

所以对于全局变量来讲,它的创建是通过向字典动态添加键值对的方式来实现的,查找的话也是向对应的字典中动态查找。而这个字典就是我们说的global命名空间,global命名空间是负责存储全局变量的。

所以对于全局变量来讲:a = 123等价于globals()["a"] = 123

print(a)等价于print(globals()["a"])

所以不存在全局变量a等价于global命名空间不存在key为"a"的键值对

并且这个字典全局唯一,在任何地方都可以获取、可以往里面添加键值对(创建全局变量),即使是在函数中也是一样的(很好理解,我们说函数中查找变量按照LGB规则,本质上是去local空间、global空间、builtin空间依次查找,如果函数都无法访问global命名空间,那么它如何保证在局部作用域找不到变量的时候,会去全局作用域中找呢?)

def f1():
    def f2():
        globals()["hanser"] = "憨八嘎"
    return f2


try:
    # 显然不存在hanser这个变量
    print(hanser)  # name 'hanser' is not defined
except Exception as e:
    print(e)


# 当我们执行f2函数时,会在global命名空间添加"hanser": "憨八嘎"这个键值对。
# 而我们说global命名空间存储的是全局变量
f1()()

# 所以,此时是可以打印的
print(hanser)  # 憨八嘎

所以locals函数获取的函数局部作用域,globals函数获取的是全局作用域。但是对于上面的例子,我们只能使用globals,不能使用locals。虽然在全局,我们说locals和globals获取的结果一样,但是在函数里面,locals获取的就不再是全局作用域了,而是函数内部的作用域,但是globals获取的永远都是全局作用域。

所以我们说在函数里面查找一个变量,查找不到的话会找全局变量,全局变量再没有会查找内置变量。本质上就是按照自身的local空间、外层的global空间、内置的builtin空间的顺序进行查找。

所以local空间会有很多个,因为每一个函数或者类都有自己的局部作用域,这个局部作用域就可以称之为该函数的local命名空间;但是global命名空间则全局唯一,因为该字典存储的是全局变量,无论你在什么地方,通过globals拿到的永远全局变量对应的命名空间,向该空间中添加键值对,等价于创建全局变量。

def f1():
    globals()["x"] = "~~~"


def f2():
    print(x)


try:
    f2()
except Exception as e:
    print(e)  # name 'x' is not defined


f1()
f2()  # ~~~

"""
很好理解,当执行函数f2时,由于其local空间、即局部作用域不存在x这个变量,那么会到global空间、也就是全局作用域当中找
但是全局变量也不存在x,于是去内置作用域查找,也不存在

但是当我们执行了f1,拿到global命名空间,向里面添加了"x": "~~~"这个键值对,等价于创建了全局变量x
那么再执行f2的时候,此时就不会报错了,因为存在了全局变量x
"""

所以我们看到尽管命名空间有多个,但是在一个命名空间中,一个符号只能有一种含义,如果我创建了a=1,又创建了a="xxx",那么后面的赋值会把上面的赋值给替换掉,因为命名空间是一个字典,字典的key是不重复的。而且命名空间可以一层套一层的形成一条命名空间链,python解释器在解释执行的时候,会有很大一部分时间消耗在从命名空间链中确定一个符号所对应的对象是什么。这也侧面说明了,为什么python在创建变量的时候不需要指定类型、以及python为什么比较慢。

这里提一下python2当中,while 1比while True要快,为什么?

因为True在python2中不是关键字,所以它是可以作为变量名的,那么python在执行的时候就要先看local空间和global空间中有没有True这个变量,而1是一个常量直接加载就可以。所以while True它多了符号查找这一过程,但是在python3中两者就等价了,因为True在python3中是一个关键字,所以会直接作为一个常量来加载。

这里提一下函数的local空间

我们说:globals["a"] = 123等价于创建一个全局变量a = 123,那么如果是在函数里面执行了locals["a"] = 123,是不是等价于创建局部变量a = 123呢?

def f1():
    locals()["a"] = 123
    try:
        print(a)
    except Exception as e:
        print(e)


f1()  # name 'a' is not defined

我们说对于全局变量来讲,变量的创建是通过向字典添加键值对的方式实现的。但是对于函数来讲,其局部作用域中存在哪些变量在编译的时候就已经确定了,所以函数的local命名空间是空的,内部的变量是通过静态方式访问的。尽管我们说python中字典的效率很高,但是肯定没有静态的方式访问快。

def f1():
    a = 1
    b = 2
    # 咦,不是说空的吗?
    print(locals())  # {'a': 1, 'b': 2}
    locals()["c"] = "xxx"
    # 尽管添加进去了,但是c这个变量没有创建
    print(locals())  # {'a': 1, 'b': 2, 'c': 'xxx'}


f1()  

函数有哪些参数在编译的时候已经确定,通过静态方式存储在某个地方,至于到底存在哪里我们后面会说。而locals()获取的local命名空间只是将静态存储的元素拷贝过来罢了,所以虽然我们说查找是按照LGB的方式查找,但是访问函数内部的变量其实是静态访问的。

最后再提一下builtin命名空间

builtin命名空间可以通过import builtins来获取

import builtins

# 我们调用int、str、list显然是从内置作用域、也就是builtin命名空间中查找的
print(builtins.list is list)  # True

builtins.dict = 123
# 将builtin空间的dict改成123,那么此时获取的dict就是123,因为是从内置作用域中获取的
print(dict + 456)  # 579

str = 123
# 如果是str = 123,等价于创建全局变量str = 123,显然影响的是global空间,而查找显然也会先从global空间查找
print(str)  # 123
# 但是此时不影响内置作用域
print(builtins.str)  # <class 'str'>

这个没有什么好说的

这里提一个思考题,在函数f2中为什么报错了

a = 1

def f1():
    print(a)  # 1


def f2():
    try:
        print(a)
    except Exception as e:
        print(e)  # local variable 'a' referenced before assignment
    a = 1


f1()
f2()

结合上面的知识,很好理解。我们说,函数中的变量是静态存储、静态访问的,是在编译的时候就已经确定的。而且函数中的所有变量在所在的整个作用域内都是可见的,那么在编译的时候,因为存在a=1这条语句,所以知道函数f2中存在一个局部变量a。但是还没来得及赋值,就print(a)了,所以报错:局部变量a在赋值之前就被引用了。但是f1函数中不会报错,因为知道局部作用域中不存在a这个变量。

所以希望你能明白命名空间,命名空间可以说是python的灵魂,因为它规定了python变量的作用域,使得python对变量的查找变得非常清晰。

PyCodeObject的创建

此时再回过头,我们说对于代码中的每一个block,都会创建一个PyCodeObject与之对应。而我们说block是由命名空间决定的,只要local空间变了,那么就进入了一个block。所以像函数、类它们可以单独看成一个block。这里补充一点,我们说函数,函数名和函数体实际上是分离的。函数也可以看成是一个变量,函数名就是变量名,函数体可以看成是一个变量值,它们也是作为一个键值对存储在命名空间里面的。同理类也是如此

def foo():
    pass



def bar():
    pass


class A:
    def foo(self):
        pass

    def bar(self):
        pass


print(globals())
# {..., 'foo': <function foo at 0x000001D25E6A51F0>, 'bar': <function bar at 0x000001D26041EF70>, 'A': <class '__main__.A'>}

我们说命名空间是一个字典,里面的...是省略了部分输出,像__name__啊、__doc__啊等等。我们看到里面确实存在了键值对,函数名和函数体是分离的。比如"foo"是函数名,然后对应函数体。但是我们注意,此时不是PyCodeObject对象,它是一个PyFunctionObject。这是因为我们执行了这段代码,而python遇到了def关键字知道了它是一个函数,所以会将其PyCodeObject对象包装成PyFunctionObject对象,在我们没有调用的时候,就包装完毕了。当然对类也会做一个包装。关于函数我们后面还会提

这里问一下,上面那段代码中创建了几个PyCodeObject对象呢?

答案是6个,首先全局是一个,foo函数一个,bar函数一个,类A一个,类A里面的foo函数一个,类A里面的bar函数一个,所以一共是6个。

而且这里的PyCodeObject对象是层层嵌套的,一开始是对整个全局创建PyCodeObject对象,然后遇到了函数foo,那么再为函数foo创建一个PyCodeObject对象,依次往下。所以如果是常量值、字符串等等,则相当于是静态信息,直接存储起来便可;可如果是函数、类,那么会为其创建新的PyCodeObject对象,然后收集起来,所以A里面的foo函数对应的PyCodeObject对象是存在A对应PyCodeObject对象里面的;而A对应的PyCodeObject对象则是存在全局对应的PyCodeObject对象里面,当然此时还有外层的foo、bar函数。然后解释执行的时候,从外到内依次解释字节码,如果是变量赋值等简单逻辑,那么直接执行,遇到def,就将其PyCodeObject进行包装。

PyCodeObject对象的属性

我们来看看如何获取字节码对象、或者说PyCodeObject对象,通过函数来举例说明:

def foo():
    pass


print(type(foo))  # <class 'function'>
"""
尽管函数只有在调用的时候才会执行
但是我们说在还没有调用的时候,就将函数对应的PyCodeObject封装成了PyFunctionObject,在python中就是<class 'function'>
之所以封装,是为了命名空间的传递。当我们真正开始执行一个函数的时候,那么还会对PyFunctionObject再进行封装,得到PyFrameObject,也就是所谓的栈帧
栈帧在底层是PyFrameObject,在python中则是<class 'frame'>
这里我们暂时不提栈帧,目前不涉及到这么远,总之目前知道一个PyCodeObject最终对应一个PyFrameObject即可
而我们说PyCodeObject在python中是<class 'code'>,但是<class 'code'>、<class 'function'>、<class 'frame'>,我们在python中都不能直接使用
因为python底层没有提供这三个类的api
"""
# 我们说遇到函数,就将其封装成PyFunctionObject,所以PyCodeObject是PyFunctionObject的一个属性
# 在python中,获取函数(function对象)的字节码可以通过__code__来获取
print(type(foo.__code__))  # <class 'code'>

下面我们来看看这个PyCodeObject都有哪些属性,我们先来看看底层的定义吧。我们说python中的对象在底层都是一个结构体实例,不过我不打算从底层介绍,因为这里面有一部分属性并没有暴露出来。

typedef struct {
    PyObject_HEAD
    int co_argcount;            /* #arguments, except *args */
    int co_posonlyargcount;     /* #positional only arguments */
    int co_kwonlyargcount;      /* #keyword only arguments */
    int co_nlocals;             /* #local variables */
    int co_stacksize;           /* #entries needed for evaluation stack */
    int co_flags;               /* CO_..., see below */
    int co_firstlineno;         /* first source line number */
    PyObject *co_code;          /* instruction opcodes */
    PyObject *co_consts;        /* list (constants used) */
    PyObject *co_names;         /* list of strings (names used) */
    PyObject *co_varnames;      /* tuple of strings (local variable names) */
    PyObject *co_freevars;      /* tuple of strings (free variable names) */
    PyObject *co_cellvars;      /* tuple of strings (cell variable names) */
    Py_ssize_t *co_cell2arg;    /* Maps cell vars which are arguments. */
    PyObject *co_filename;      /* unicode (where it was loaded from) */
    PyObject *co_name;          /* unicode (name, for reference) */
    PyObject *co_lnotab;        /* string (encoding addr<->lineno mapping) See
                                   Objects/lnotab_notes.txt for details. */
    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;  // used to determine when create a cache.
    unsigned char co_opcache_size;  // length of co_opcache.
} 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是一样的

    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/2.py

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

def foo():
    pass


print(foo.__code__.co_name)  # foo

eval、exec、compile

  • eval:传入一个字符串,然后把字符串里面的内容当成变量、常量或者一个表达式

    a = 1
    # 所以eval("a")就等价于a
    print(eval("a"))  # 1
    
    # 等价于print(1+1+1)
    print(eval("1 + 1 + 1"))  # 3
    
    try:
        # 等价于print(xxx)
        print(eval("xxx"))
    except NameError as e:
        print(e)  # name 'xxx' is not defined
    
    # 等价于print('xxx')
    print(eval("'xxx'"))  # xxx
    
    # 注意:eval是有返回值的,返回值就是字符串里面内容。
    # 或者说eval是可以作为右值的,比如a = eval("xxx")、1 + eval("2")等等
    # 所以eval里面绝不可以出现诸如赋值之类的,比如:eval("a = 3"),这样是不合法的
    # 因此eval里面把字符串剥掉之后就是一个普通的值,不可以出现诸如赋值等语句
    
  • exec:传入一个字符串,把字符串里面的内容当成语句来执行,这个是没有返回值,或者说返回值是None

    exec("a = 1")  # 等价于把a = 1这个字符串里面的内容当成语句来执行
    print(a)  # 1
    
    statement = """a = 123
    if a == 123:
        print("a等于123")
    else:
        print("a不等于123")
    """
    exec(statement)  # a等于123
    # 注意:'a等于123'并不是exec返回的,而是把上面那坨字符串当成普通代码执行的时候print出来的
    # 这便是exec的作用。
    
    
    # 那么它和eval的区别就显而易见的,eval是要求字符串里面的内容能够当成一个值来打印,返回值就是里面的值
    # 而exec则是直接执行里面的内容
    # 举个例子
    print(eval("1 + 1"))  # 2
    print(exec("1 + 1"))  # None
    
    exec("a = 1 + 1")
    print(a)  # 2
    
    try:
        eval("a = 1 + 1")
    except SyntaxError as e:
        print(e)  # invalid syntax (<string>, line 1)
    
  • compile:关键来了,它执行后返回的就是一个code对象

    statement = "a, b = 1, 2"
    # 参数一:代码
    # 参数二:可以为这些代码起一个文件名
    # 参数三:执行方式,exec还是eval,这里显然是exec
    co = compile(statement, "hanser", "exec")
    print(co.co_firstlineno)  # 1
    print(co.co_filename)  # hanser
    print(co.co_argcount)  # 0
    
    # 这里是一个元组,因为我们是a, b = 1, 2这种方式赋值的,所以加载的是一个元组
    print(co.co_consts)  # ((1, 2), None)
    
    
    statement = "a = 1;b = 2"
    co = compile(statement, "hanser", "exec")
    print(co.co_consts)  # (1, 2, None)
    print(co.co_names)  # ('a', 'b')
    

总结

python的底层是比较复杂的,这里我们介绍了一下字节码相关,然后提到了命名空间什么的。总之python解释器最终执行的都是字节码对象,如果是简单的语句,直接就执行了。如果是函数,或者新的block,那么会将其字节码对象进行封装。我们说函数是一个黑盒,里面有什么只有当调用的时候才知道,确实如此,但这并不代表我们不调用就什么都不做。正如我们说的,在解释到def关键字的时候,是会将字节码对象进行包装的,就是将code对象变成function对象,通过function下的__code__属性,可以拿到其code对象。总之记住一句话:python解释器执行的永远是字节码对象,就算进行了封装,也只是为了命名空间的传递之类的,最终执行的还是字节码

另外我们还提到栈帧,我们说一个字节码对象(code)对应一个栈帧对象(frame),python在执行整个py文件的时候,会为全局这个字节码对象创建一个栈帧,然后再执行里面的字节码,调用frame内部的f_code即可拿到字节码。同理当我们调用一个函数的时候,那么也会对这个function再进行封装,得到frame对象,然后执行frame里面的code(字节码对象)。如果函数里面调用了函数,那么就在此基础上创建一个新的栈帧,然后将执行的控制权交给新的栈帧,一层一层创建、一层一层返回。

但是记住一句话,python解释器执行的永远是字节码对象,虽然最终包装成了栈帧对象,但这都是为了更好的执行字节码。至于栈帧对象,这里我们暂时不涉及那么多,可能会在后面的博客中介绍。

以上です,希望你能对python中的字节码对象(或者说code对象、PyCodeObject对象)有一个更清晰的认识。

おすすめ

転載: www.cnblogs.com/traditional/p/12718814.html