老齐教室

通过内置对象理解 Python(三)

逐一探讨所有的内置函数

在上一节的基础上,下面从一些最有趣的内容开始,这些内容构建了 Python 作为一种语言的基础,逐一对内置函数进行探讨。

compile, execeval 的工作原理

以下面的代码为例:

1
2
x = [1, 2]
print(x)

可以将此代码保存到一个文件中并运行,或者在 Python 交互模式中键入它。在这两种情况下,得到的输出结果都将是 [1, 2]

第三中情况,可以将程序以字符串形式传给 Python 的内置函数 exec()

1
2
3
4
5
6
>>> code = '''
... x = [1, 2]
... print(x)
... '''
>>> exec(code)
[1, 2]

exec() (函数名称是 execute 的缩写)以字符串形式接收一些 Python 代码,并将其作为 Python 代码运行。 默认情况下,exec() 将和其余代码在相同的作用域中运行。这意味着,它可以读取和操作变量,就像 Python 文件中的任何其他代码片段一样。

1
2
3
>>> 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。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
>>> 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 模块。

1
2
3
4
>>> print(ast.dump(tree, indent=2))
Module(
body=[
...

该模块的 body 部分含有两个子模块(两个语句):

  • 第一个是 Assign 语句 …

    1
    2
    Assign(    
    ...

    赋值给 x

    1
    2
    3
    targets=[
    Name(id='x', ctx=Store())],
    ...

    包含两个常量 12 的列表 List 的值。

    1
    2
    3
    4
    5
    6
      value=List(
    elts=[
    Constant(value=1),
    Constant(value=2)],
    ctx=Load())),
    ),
  • 第二个是 Expr 语句,在本例中是调用一个函数 …

    1
    2
    3
    Expr(
    value=Call(
    ...

    它的函数名称为 print ,参数值为 x

    1
    2
    3
    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 模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ 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() 函数执行代码对象,其效果就如同之前的运行结果一样:

1
>>> import ast>>> code = '''... x = [1, 2]... print(x)... '''>>> tree = ast.parse(code)>>> code_obj = compile(tree, 'myfile.py', 'exec')>>> exec(code_obj)[1, 2]

但是现在,可以看看代码对象是什么样子的,先看它的一些属性:

1
2
3
4
5
6
7
8
9
>>> import ast
>>> code = '''
... x = [1, 2]
... print(x)
... '''
>>> tree = ast.parse(code)
>>> code_obj = compile(tree, 'myfile.py', 'exec')
>>> exec(code_obj)
[1, 2]

可以看到代码中使用的变量 xprint ,以及常数 12 。此外,关于源码文件的更多信息都可以在代码对象中找到。 它包含了直接在 Python 虚拟机中运行所需的所有信息,以便生成输出。

如果你想深入了解字节码的含义,下面关于 dis 模块的补充知识可以参考。


补充知识:dis 模块

Python 中的 dis 模块可以把字节码以人类能理解的方式可视化地表达出来,以帮助弄清 Python 在幕后做什么。 它接收字节码、常量和变量信息,并产生如下结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
>>> 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行创建了4个字节码,将两个常数 12 加到栈上,并从栈最上面的 2 构建一个列表,将其存储到变量 x 中。
  • 第2行创建了6个字节码,它将 printx 加到栈上,并以栈最上面的 1 为参数调用函数(意思是,将参数 x 传给 print() 函数并执行此函数)。 然后通过执行 POP_TOP 删除函数的返回值,因为我们没有应用或存储 print(x) 的返回值。最后的两行从文件执行的末尾返回 None ,这不起任何作用。

第1行中的 LOADC_ONST 称为一个操作码(opcode),观察操作码左半边的数字,相邻的两个数字之间差距为2,这是因为把对象存储为操作码时,每个字节码的字长是 2。由此也可知,上述示例中的字符串长度为20个字节常。

1
2
3
4
5
6
7
8
>>> 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() 的另一个不同之处是,它返回参数中表达式的结果。

例如:

1
2
3
>>> 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.parsecompile 中,如果要执行表达式,但不是类似 Python 文件那样,可以用下面的方式:

1
2
3
4
>>> expr = ast.parse('1 + 1', mode='eval')
>>> code_obj = compile(expr, '<code>', 'eval')
>>> eval(code_obj)
2

globalslocals :包含所有

虽然生成的代码对象和定义的常量有类似的存储逻辑,但变量的值没有存储,比如:

1
2
def double(number):
return number * 2

这个函数将存储常量 2 以及变量名 number,但显然它不能包含 number 的实际值,因为只有在函数实际运行时才会给该参数赋值。

那么,这是为什么呢? 答案是 Python 将所有东西都存储在与每个局部作用域关联的字典中。 这意味着每段代码都有自己定义的“局部作用域”,“局部作用域”在代码中使用 locals() 访问,它包含对应局部作用域的变量名和值。

让我们来看看实际情况:

1
2
3
4
5
6
7
8
9
10
11
>>> value = 5
>>> def double(number):
... return number * 2
...
>>> double(value)
10
>>> locals()
{'__name__': '__main__', '__doc__': None, '__package__': None,
'__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': None,
'__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>,
'value': 5, 'double': <function double at 0x7f971d292af0>}

看最后一行:不仅 value 存储在 locals() 返回的字典中,函数 double() 本身也存储在那里!这就是 Python 存储数据的方式。

globals() 也与此类似,只不过它是指向全局作用域。所以像这样的代码:

1
2
3
4
5
6
7
magic_number = 42

def function():
x = 10
y = 20
print(locals())
print(globals())

locals() 只包含 xy ,而 globals() 则包含 magic_numberfunction 本身。

inputprint:基本功能

inputprint 可能是 Python 中用得最广泛的两个函数,很多学习者初学之时,经常用它们,看起来很简单,比如 input 接收一行文本,print 把文本打印出来,就这么简单。 对吧?

实际上,可能比你知道得更复杂。

下面是 print() 函数的完整格式:

1
print(*values, sep=' ', end='\n', file=sys.stdout, flush=False)

参数 *values 意味着可以提供任意数量的位置参数,print 都能正确地打印出它们,默认情况所打印的位置参数引用对象之间用空格分隔。

如果想让分隔符有所不同,例如,想把每个项目打印在不同的行上,可以相应地设置 sep 的值,比如为 \n

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
>>> print(1, 2, 3, 4)
1 2 3 4
>>> print(1, 2, 3, 4, sep='\n')
1
2
3
4
>>> print(1, 2, 3, 4, sep='\n\n')
1

2

3

4
>>>

还有 end 参数,通过它可以修改行末的设置,默认是行末端为 \n ,即换行,如果不换行,可以修改为 end=''

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> for i in range(10):
... print(i)
0
1
2
3
4
5
6
7
8
9
>>> for i in range(10):
... print(i, end='')
0123456789

再看 print 另外两个参数:fileflushfile 指将打印的内容输出到指定“文件”,默认值是 sys.stdout ,即打印到标准输出文件,也就是打印到控制台, 如下,也可以设置为一个具体的文件。

1
2
with open('myfile.txt', 'w') as f:   
print('Hello!', file=f)

补充知识:使用上下文管理器

在函数 print() 中,默认情况下 file=sys.stdout ,则会将所要打印的内容输出到控制台,如果将 sys.stdout 引用一个文件,则会将打印的内容输出到该文件中。例如:

1
2
3
4
5
6
7
>>> import sys
>>> print('a regular print statement')
a regular print statement
>>> file = open('myfile.txt', 'w')
>>> sys.stdout = file
>>> print('this will write to the file') # 写入到 myfile.txt
>>> file.close()

但是,如果这样做了,就无法再回到控制台,除非关闭当前交互模式,重新进入。如果关闭文件,也会遇到问题。例如:

1
2
3
4
5
6
7
8
9
10
>>> import sys
>>> print('a regular print statement')
a regular print statement
>>> file = open('myfile.txt', 'w')
>>> sys.stdout = file
>>> file.close()
>>> print('this will write to the file')
Traceback (most recent call last):
File "<stdin>", line 2, in <module>
ValueError: I/O operation on closed file.

为了避免此问题,可以使用上下文管理器做装饰器,以确保在完成任务后还原 sys.stdout

1
2
3
4
5
6
7
8
9
10
11
import sys
from contextlib import contextmanager

@contextmanager
def print_writer(file_path):
original_stdout = sys.stdout

with open(file_path, 'w') as f:
sys.stdout = f
yield # this is where everything inside the `with` statement happens
sys.stdout = original_stdout

可以这样使用它:

1
2
3
4
5
6
with print_writer('myfile.txt'):
print('Printing straight to the file!')
for i in range(5):
print(i)

print('and regular print still works!')

打印到文件或 IO 对象是一个常见的用例,contextlib 有一个函数 redirect_stdout

1
2
3
4
5
6
7
8
9
10
11
12
from contextlib import redirect_stdout

with open('this.txt', 'w') as file:
with redirect_stdout(file):
import this

with open('this.txt') as file:
print(file.read())

# Output:
# The Zen of Python, by Tim Peters
# ...

(补充知识完毕)


参数 flush=False 用于标记 print() 的文本内容发送到控制台/文件,而不是将其放入缓存。 这通常没什么区别,但如果在控制台打印一个非常长的字符串,可能要将它设置为 True ,以避免在向用户显示输出时出现延迟。

而对于 input() 函数,就没什么秘密而言了,它只是接受一个字符串作为提示符显示。

使用支付宝打赏
使用微信打赏

若你觉得我的文章对你有帮助,欢迎点击上方按钮对我打赏

关注微信公众号,读文章、听课程,提升技能