★本文是《通过内置对象理解 Python》系列文章第二部分 ”
在上一节的基础上,下面从一些最有趣的内容开始,这些内容构建了 Python 作为一种语言的基础,逐一对内置函数进行探讨。
compile
, exec
和 eval
的工作原理以下面的代码为例:
x = [1, 2]
print(x)
可以将此代码保存到一个文件中并运行,或者在 Python 交互模式中键入它。在这两种情况下,得到的输出结果都将是 [1, 2]
。
第三中情况,可以将程序以字符串形式传给 Python 的内置函数 exec()
:
>>> code = '''
... x = [1, 2]
... print(x)
... '''
>>> exec(code)
[1, 2]
exec()
(函数名称是 execute 的缩写)以字符串形式接收一些 Python 代码,并将其作为 Python 代码运行。默认情况下,exec()
将和其余代码在相同的作用域中运行。这意味着,它可以读取和操作变量,就像 Python 文件中的任何其他代码片段一样。
>>> x = 5
>>> exec('print(x)')
5
exec()
允许在执行真正的动态代码。例如,可以从互联网上下载一个 Python 文件,将其内容传给 exec()
,它会运行该文件中的程序(但请千万不要这么做) 。
在大多数情况下,编写代码时并不真的需要 exec()
。它用于实现一些真正的动态行为(如在运行时创建动态类,就像 collections.namedtuple
所作的那样,或修改正在从 Python 文件读取的代码(如在 zxpy中)。不过,这里不重点讨论这个,下面要探讨的是 exec()
的执行过程。
exec()
不仅可以接收字符串并将其作为代码运行,还可以接收代码对象,即 Python 程序编译后的“字节码”版本的程序。它们不仅包含从 Python 代码中生成的精确指令,还存储了代码中使用的变量和常量等。
代码对象是从 ASTs(abstract syntax trees,抽象语法树)生成的,ASTs 本身是由运行在代码串上的解析器生成的。
下面,通过例子来了解其内涵。首先,导入 ast
模块,用于生成一个 AST。
>>> import ast
>>> code = '''
... x = [1, 2]
... print(x)
... '''
>>> tree = ast.parse(code)
>>> print(ast.dump(tree, indent=2))
Module(
body=[
Assign(
targets=[
Name(id='x', ctx=Store())],
value=List(
elts=[
Constant(value=1),
Constant(value=2)],
ctx=Load())),
Expr(
value=Call(
func=Name(id='print', ctx=Load()),
args=[
Name(id='x', ctx=Load())],
keywords=[]))],
type_ignores=[])
看着有点难,接下来抽丝剥茧。
可以将 AST
视为一个 Python 模块。
>>> print(ast.dump(tree, indent=2))
Module(
body=[
...
该模块的 body 部分含有两个子模块(两个语句):
第一个是 Assign
语句 …
Assign(
...
赋值给 x
…
targets=[
Name(id='x', ctx=Store())],
...
包含两个常量 1
和 2
的列表 List
的值。
value=List(
elts=[
Constant(value=1),
Constant(value=2)],
ctx=Load())),
),
第二个是 Expr
语句,在本例中是调用一个函数 …
Expr(
value=Call(
...
它的函数名称为 print
,参数值为 x
。
func=Name(id='print', ctx=Load()),
args=[
Name(id='x', ctx=Load())],
所以 Assign
部分描述的是 x = [1, 2]
,而 Expr
描述的是 print(x)
。现在看起来没那么难,对吧?
补充知识: Tokenizer
实际上,在将代码解析为 AST 之前,还需要执行名为 Lexing 的一个步骤。
它指的是根据 Python 语法将源代码转换为 token。从下面的内容中可以看到 Python 如何将源码 token 化。这里使用了 tokenize
模块:
$ cat code.py
x = [1, 2]
print(x)
$ py -m tokenize code.py
0,0-0,0: ENCODING 'utf-8'
1,0-1,1: NAME 'x'
1,2-1,3: OP '='
1,4-1,5: OP '['
1,5-1,6: NUMBER '1'
1,6-1,7: OP ','
1,8-1,9: NUMBER '2'
1,9-1,10: OP ']'
1,10-1,11: NEWLINE '
'
2,0-2,5: NAME 'print'
2,5-2,6: OP '('
2,6-2,7: NAME 'x'
2,7-2,8: OP ')'
2,8-2,9: NEWLINE '
'
3,0-3,0: ENDMARKER ''
所谓 token 化,就是将源码转换为最基本的标记符(token),比如变量名、括号、字符串和数字。它还跟踪每个 token 的行号和位置,这有助于指向错误信息的确切位置。
这个“ token 流”就被解析为 AST 的内容。
(补充知识完毕)
现在有了一个 AST 对象,接下来使用内置的编译器将它编译成有字节码组成的代码对象,并且用 exec()
函数执行代码对象,其效果就如同之前的运行结果一样:
>>> import ast
>>> code = '''
... x = [1, 2]
... print(x)
... '''
>>> tree = ast.parse(code)
>>> code_obj = compile(tree, 'myfile.py', 'exec')
>>> exec(code_obj)
[1, 2]
但是现在,可以看看代码对象是什么样子的,先看它的一些属性:
>>> code_obj.co_code
b'd\x00d\x01g\x02Z\x00e\x01e\x00\x83\x01\x01\x00d\x02S\x00'
>>> code_obj.co_filename
'myfile.py'
>>> code_obj.co_names
('x', 'print')
>>> code_obj.co_consts
(1, 2, None)
可以看到代码中使用的变量 x
和 print
,以及常数 1
和 2
。此外,关于源码文件的更多信息都可以在代码对象中找到。它包含了直接在 Python 虚拟机中运行所需的所有信息,以便生成输出。
如果你想深入了解字节码的含义,下面关于 dis
模块的补充知识可以参考。
补充知识::dis
模块
Python 中的 dis
模块可以把字节码以人类能理解的方式可视化地表达出来,以帮助弄清 Python 在幕后做什么。它接收字节码、常量和变量信息,并产生如下结果:
>>> import dis
>>> dis.dis('''
... x = [1, 2]
... print(x)
... ''')
1 0 LOAD_CONST 0 (1)
2 LOAD_CONST 1 (2)
4 BUILD_LIST 2
6 STORE_NAME 0 (x)
2 8 LOAD_NAME 1 (print)
10 LOAD_NAME 0 (x)
12 CALL_FUNCTION 1
14 POP_TOP
16 LOAD_CONST 2 (None)
18 RETURN_VALUE
>>>
这表明:
1
和 2
加到栈上,并从栈最上面的 2
构建一个列表,将其存储到变量 x
中。print
和 x
加到栈上,并以栈最上面的 1
为参数调用函数(意思是,将参数 x
传给 print()
函数并执行此函数)。然后通过执行 POP_TOP
删除函数的返回值,因为我们没有应用或存储 print(x)
的返回值。最后的两行从文件执行的末尾返回 None
,这不起任何作用。第1行中的 LOADC_ONST
称为一个操作码(opcode),观察操作码左半边的数字,相邻的两个数字之间差距为2,这是因为把对象存储为操作码时,每个字节码的字长是 2。由此也可知,上述示例中的字符串长度为20个字节常。
>>> code_obj = compile('''
... x = [1, 2]
... print(x)
... ''', 'test', 'exec')
>>> code_obj.co_code
b'd\x00d\x01g\x02Z\x00e\x01e\x00\x83\x01\x01\x00d\x02S\x00'
>>> len(code_obj.co_code)
20
可以确认生成的字节码正好是20字节。
(补充知识完毕)
函数 eval()
非常类似于 exec()
,只是它只接受表达式作为参数,不能像 exec()
那样以一条或者一组语句为参数。而且,与 exec()
的另一个不同之处是,它返回参数中表达式的结果。
例如:
>>> result = eval('1 + 1')
>>> result
2
You can also go the long, detailed route with eval
, you just need to tell ast.parse
and compile
that you’re expecting to evaluate this code for its value, instead of running it like a Python file.
函数对象 eval
可以应用于很多地方,比如在 ast.parse
和 compile
中,如果要执行表达式,但不是类似 Python 文件那样,可以用下面的方式:
>>> expr = ast.parse('1 + 1', mode='eval')
>>> code_obj = compile(expr, '<code>', 'eval')
>>> eval(code_obj)
2
【未完,待续】
其他系列:
点击【阅读原文】查看更多内容