首页 > Python基础教程 >
-
教你阅读 Cpython 的源码(二)
在上节教你阅读 Cpython 的源码(一)中,我们从编写Python到执行代码的过程中看到Python语法和其内存管理机制。
在本节,我们将从代码层面去讨论 ,Python的编译过程。
调用Python二进制文件可以通过以下五种方式:
1.使用-c和Python命令运行单个命令
2.使用-m和模块名称启动模块
3.使用文件名运行文件
4.使用shell管道运行stdin输入
5.启动REPL并一次执行一个命令
整个运行过程你可以通过检查下面三个源文件进行了解:
1.Programs/python.c
是一个简单的入口文件。
2.Modules/main.c
汇集加载配置,执行代码和清理内存整个过程的代码文件。
3.Python/initconfig.c
从系统环境加载配置,并将其与任何命令行标志合并。
此图显示了如何调用每个函数:
执行模式由配置确定。
CPython源代码样式:
与Python代码的PEP8样式指南类似,CPython C代码有一个官方样式指南,最初于2001年设计并针对现代版本进行了更新。
这里有一些命名标准方便你调试跟踪源代码:
-
对公共函数使用Py前缀,静态函数不使用。Py_前缀保留用于
Py_FatalError
等全局服务例程。特定的对象(如特定的对象类型API)使用较长的前缀,例如PyString_
用于字符串函数。 -
公众函数和变量,使用首写字母大写,单词之间下划线分割的形式,
例如:PyObject_GetAttr
,Py_BuildValue
,PyExc_TypeError
。 -
有时,加载器必须能够看到内置函数。
我们使用_Py前缀,例如_PyObject_Dump
。 -
宏应具有混合字母前缀,首字母大写,例如
PyString_AS_STRING
,Py_PRINT_RAW
。
创建运行环境的配置
通过上图可以看到,在执行Python代码之前,首先会建立配置。在文件Include/cpython/initconfig.h
中名为PyConfig
的对象会定义一个配置的数据结构。
配置数据结构包括以下内容:
- 各种模式的运行时标志,如调试和优化模式
- 执行模式,例如是否传递了文件名,提供了stdin或模块名称
- 扩展选项,由-X 指定
-
运行时设置的环境变量
配置数据主要是CPython在运行时用于启用和禁用各种功能。Python还附带了几个命令行界面选项。
在Python中,你可以使用-v标志启用详细模式。在详细模式下,Python将在加载模块时将消息打印到屏幕:
$ ./python.exe -v -c "print('hello world')"
# installing zipimport hook
import zipimport # builtin
# installed zipimport hook
...
你可以在PyConfig
的struct中的Include/cpython/initconfig.h
中看到此标志的定义:
/* --- PyConfig ---------------------------------------------- */
typedef struct {
int _config_version; /* Internal configuration version,
used for ABI compatibility */
int _config_init; /* _PyConfigInitEnum value */
...
/* If greater than 0, enable the verbose mode: print a message each time a
module is initialized, showing the place (filename or built-in module)
from which it is loaded.
If greater or equal to 2, print a message for each file that is checked
for when searching for a module. Also provides information on module
cleanup at exit.
Incremented by the -v option. Set by the PYTHONVERBOSE environment
variable. If set to -1 (default), inherit Py_VerboseFlag value. */
int verbose;
在Python/initconfig.c
中,建立了从环境变量和运行时命令行标志读取设置的逻辑。
在config_read_env_vars
函数中,读取环境变量并用于为配置设置分配值:
static PyStatus
config_read_env_vars(PyConfig *config)
{
PyStatus status;
int use_env = config->use_environment;
/* 获取环境变量 */
_Py_get_env_flag(use_env, &config->parser_debug, "PYTHONDEBUG");
_Py_get_env_flag(use_env, &config->verbose, "PYTHONVERBOSE");
_Py_get_env_flag(use_env, &config->optimization_level, "PYTHONOPTIMIZE");
_Py_get_env_flag(use_env, &config->inspect, "PYTHONINSPECT");
对于详细设置,你可以看到如果PYTHONVERBOSE
存在,PYTHONVERBOSE
的值用于设置&config-> verbose
的值,如果环境变量不存在,则将保留默认值-1。
然后再次在initconfig.c
中的config_parse_cmdline
函数中,用命令行标志来设置值:
static PyStatus
config_parse_cmdline(PyConfig *config, PyWideStringList *warnoptions,
Py_ssize_t *opt_index)
{
...
switch (c) {
...
case 'v':
config->verbose++;
break;
...
/* This space reserved for other options */
default:
/* unknown argument: parsing failed */
config_usage(1, program);
return _PyStatus_EXIT(2);
}
} while (1);
此值之后由_Py_GetGlobalVariablesAsDict
函数复制到全局变量Py_VerboseFlag
。
在Python中,可以使用具名元组类型的对象sys.flags
访问运行时标志,如详细模式,安静模式。-X标志在sys._xoptions
字典中都可用。
$ ./python.exe -X dev -q
>>> import sys
>>> sys.flags
sys.flags(debug=0, inspect=0, interactive=0, optimize=0, dont_write_bytecode=0,
no_user_site=0, no_site=0, ignore_environment=0, verbose=0, bytes_warning=0,
quiet=1, hash_randomization=1, isolated=0, dev_mode=True, utf8_mode=0)
>>> sys._xoptions
{'dev': True}
除了initconfig.h
中的运行时配置外,还有构建配置,它位于根文件夹中的pyconfig.h
内。
此文件在构建过程的配置步骤中动态创建,或由Visual Studio for Windows系统动态创建。
可以通过运行以下命令查看构建配置:
$ ./python.exe -m sysconfig
读取文件/输入
一旦CPython具有运行时配置和命令行参数,就可以确定它需要执行的内容了。
此任务由Modules/main.c
中的pymain_main
函数处理。
根据新创建的配置实例,CPython现在将执行通过多个选项提供的代码。
通过-c输入
最简单的是为CPython提供一个带-c选项的命令和一个带引号的Python代码。
例如:
$ ./python.exe -c "print('hi')"
hi
下图是整个过程的流程图
首先,在modules/main.c
中执行pymain_run_command
函数,将在-c中传递的命令作为C程序中wchar_t *
的参数。
wchar_t*
类型通常被用作Cpython中Unicode的低级存储数据类型,因为该类型的大小可以存储utf8字符。
将wchar_t *
转换为Python字符串时,Objects/unicodetype.c
文件有一个辅助函数PyUnicode_FromWideChar
,它会返回一个PyObject
,其类型为str。然后,通过PyUnicode_AsUTF8String
,完成对UTF8的编码,并将Python中的str对象转换为Python字节类型。
完成后,pymain_run_command
会将Python字节对象传递给PyRun_SimpleStringFlags
执行,但首先会通过 PyBytes_AsString
将字节对象再次转换为str类型。
static int
pymain_run_command(wchar_t *command, PyCompilerFlags *cf)
{
PyObject *unicode, *bytes;
int ret;
unicode = PyUnicode_FromWideChar(command, -1);
if (unicode == NULL) {
goto error;
}
if (PySys_Audit("cpython.run_command", "O", unicode) < 0) {
return pymain_exit_err_print();
}
bytes = PyUnicode_AsUTF8String(unicode);
Py_DECREF(unicode);
if (bytes == NULL) {
goto error;
}
ret = PyRun_SimpleStringFlags(PyBytes_AsString(bytes), cf);
Py_DECREF(bytes);
return (ret != 0);
error:
PySys_WriteStderr("Unable to decode the command from the command line:\n");
return pymain_exit_err_print();
}
将wchar_t *
转换为Unicode,字节,然后转换为字符串大致相当于以下内容:
unicode = str(command)
bytes_ = bytes(unicode.encode('utf8'))
# call PyRun_SimpleStringFlags with bytes_
PyRun_SimpleStringFlags
函数是Python/pythonrun.c
的一部分。它的目的是将这个简单的命令转换为Python模块,然后将其发送以执行。由于Python
模块需要将__main__
作为独立模块执行,因此它会自动创建。
int
PyRun_SimpleStringFlags(const char *command, PyCompilerFlags *flags)
{
PyObject *m, *d, *v;
m = PyImport_AddModule("__main__"); #创建__main__模块
if (m == NULL)
return -1;
d = PyModule_GetDict(m);
v = PyRun_StringFlags(command, Py_file_input, d, d, flags);
if (v == NULL) {
PyErr_Print();
return -1;
}
Py_DECREF(v);
return 0;
}
一旦PyRun_SimpleStringFlags
创建了一个模块和一个字典,它就会调用PyRun_StringFlags
函数,它会创建一个伪文件名,然后调用Python解析器从字符串创建一个AST并返回一个模块,mod
。
你将在下一节中深入研究AST和Parser代码。
通过-m输入
执行 Python 命令的另一个方法,通过使用 -m 然后知道一个模块名。一个典型的例子是python -m unittest
,运行一个unittest
测试模块。使用-m标志意味着在模块包中,你想要执行__main__
中的任何内容。它还意味着你要在sys.path
中搜索指定的模块。所以,使用这种搜索机制之后,你不需要去记忆unittest
模块它位于那个位置。
为什么会这样呢?接下来就让我们一起看看原因。
在Modules/main.c
中,当使用-m标志运行命令行时,它会调用pymain_run_module
函数,并将传入模块的名称作为modname
参数传递。
然后CPython将导入标准库模块runpy
,并通过PyObject_Call
函数执行它。导入模块的操作是在函数PyImport_ImportModule
进行的。
static int
pymain_run_module(const wchar_t *modname, int set_argv0)
{
PyObject *module, *runpy, *runmodule, *runargs, *result;
runpy = PyImport_ImportModule("runpy");
...
runmodule = PyObject_GetAttrString(runpy, "_run_module_as_main");
...
module = PyUnicode_FromWideChar(modname, wcslen(modname));
...
runargs = Py_BuildValue("(Oi)", module, set_argv0);
...
result = PyObject_Call(runmodule, runargs, NULL);
...
if (result == NULL) {
return pymain_exit_err_print();
}
Py_DECREF(result);
return 0;
}
在这个函数中,您还将看到另外两个C API函数:PyObject_Call
和PyObject_GetAttrString
。
因为PyImport_ImportModule
返回一个核心对象类型PyObject *
,所以需要调用特殊函数来获取属性并调用它。
在Python中,如果你需要调用某个函数属性,你可以使用getattr()
函数。类似的,在C API中,它将调用Objects/object.c
文件中的PyObject_GetAttrString
方法。如果你要在python中运行一个callable
类型的对象,你需要使用括号运行它,或者调用其__call__()
属性。在Objects/object.c
中对__call__()
进行了实现。
hi = "hi!"
hi.upper() == hi.upper.__call__() # this is the same
runpy
模块就在Lib/runpy.py
,它是纯Python写的。
执行python -m <module>
相当于运行python -m runpy <module>
。
创建runpy模块是为了抽象在操作系统上定位和执行模块的过程。
runpy做了一些事情来运行目标模块:
-
为你提供的模块名称调用
__import __()
-
将
__name__
(模块名称)设置为名为__main__
的命名空间 -
在
__main__
命名空间内执行该模块
runpy模块还支持执行目录和zip文件。
通过文件名输入
如果Python命令的第一个参数是文件名,例如,python test.py
。Cpython会打开一个文件的句柄,类似我们在Python中使用open(),并将句柄传递给Python/pythonrun.c.
文件里的PyRun_SimpleFileExFlags()
。
这里有三种方式:
1.如果文件后缀是.pyc,就会调用run_pyc_file()
。
2.如果文件后缀是.py,将调用PyRun_FileExFlags()
。
3.如果文件路径是stdin,用户运行了命令| python会将stdin视为文件句柄并运行PyRun_FileExFlags()
。
下面是上述过程的C代码
int
PyRun_SimpleFileExFlags(FILE *fp, const char *filename, int closeit,
PyCompilerFlags *flags)
{
...
m = PyImport_AddModule("__main__");
...
if (maybe_pyc_file(fp, filename, ext, closeit)) {
...
v = run_pyc_file(pyc_fp, filename, d, d, flags);
} else {
/* When running from stdin, leave __main__.__loader__ alone */
if (strcmp(filename, "<stdin>") != 0 &&
set_main_loader(d, filename, "SourceFileLoader") < 0) {
fprintf(stderr, "python: failed to set __main__.__loader__\n");
ret = -1;
goto done;
}
v = PyRun_FileExFlags(fp, filename, Py_file_input, d, d,
closeit, flags);
}
...
return ret;
}
使用PyRun_FileExFlags()通过文件输入
对于使用stdin和脚本文件方式,CPython会将文件句柄传递给位于pythonrun.c
文件中的PyRun_FileExFlags()
。PyRun_FileExFlags()的目的类似于用于-c
输入PyRun_SimpleStringFlags()
,Cpython会把文件句柄加载到PyParser_ASTFromFileObject()
中。
我们将在下一节介绍Parser和AST模块
因为这是一个完整的脚本,所以它不用像使用-c的方式需要通过PyImport_AddModule("__main__")
创建__main__模块。
与PyRun_SimpleStringFlags
相同,一旦PyRun_FileExFlags()
从文件创建了一个Python模块,它就会将它发送到run_mod()
来执行。
run_mod()可以在Python/pythonrun.c
中找到,并将模块发送到AST以编译成代码对象,代码对象是用于存储字节码操作的格式,并保存到.pyc文件中。
C代码片段
static PyObject *
run_mod(mod_ty mod, PyObject *filename, PyObject *globals, PyObject *locals,
PyCompilerFlags *flags, PyArena *arena)
{
PyCodeObject *co;
PyObject *v;
co = PyAST_CompileObject(mod, filename, flags, -1, arena);
if (co == NULL)
return NULL;
if (PySys_Audit("exec", "O", co) < 0) {
Py_DECREF(co);
return NULL;
}
v = run_eval_code_obj(co, globals, locals);
Py_DECREF(co);
return v;
}
我们将在下一节中介绍CPython编译器和字节码。
对run_eval_code_obj()
的调用是一个简单的包装函数,然后它会调用Python/eval.c
文件中的PyEval_EvalCode()
函数。PyEval_EvalCode()函数是CPython的主要评估循环,它会迭代每个字节码语句并在本地机器上执行它。
使用run_pyc_file() 通过编译字节码输入
在PyRun_SimpleFileExFlags()
中,有一个判断子句为用户提供了.pyc文件的文件路径。如果文件路径以.pyc结尾,则不是将文件作为纯文本文件加载并解析它,它会假定.pyc文件的内容是字节码,并保存到磁盘中。
文件Python/pythonrun.c
中的run_py_file()
方法,使用文件句柄从.pyc文件中编组(marshals)代码对象。编组(Marshaling)是一个技术术语,作用是将文件内容复制到内存中并将其转换为特定的数据结构。磁盘上的代码对象数据结构是CPython编译器缓存已编译代码的方式,因此每次调用脚本时都不需要解析它。
C代码
static PyObject *
run_pyc_file(FILE *fp, const char *filename, PyObject *globals,
PyObject *locals, PyCompilerFlags *flags)
{
PyCodeObject *co;
PyObject *v;
...
v = PyMarshal_ReadLastObjectFromFile(fp);
...
if (v == NULL || !PyCode_Check(v)) {
Py_XDECREF(v);
PyErr_SetString(PyExc_RuntimeError,
"Bad code object in .pyc file");
goto error;
}
fclose(fp);
co = (PyCodeObject *)v;
v = run_eval_code_obj(co, globals, locals);
if (v && flags)
flags->cf_flags |= (co->co_flags & PyCF_MASK);
Py_DECREF(co);
return v;
}
一旦代码对象被封送到内存,它就被发送到run_eval_code_obj()
,它会调用Python/ceval.c
来执行代码。
词法分析(Lexing)和句法分析(Parsing)
在阅读和执行 Python 文件的过程中,我们深入了解了解析器和AST模块,并对函数PyParser_ASTFromFileObject()
函数进行了调用。
我们继续看Python/pythonrun.c
,该文件的PyParser_ASTFromFileObject()
方法将拿到一个文件句柄,编译器标志和PyArena实例,并使用PyParser_ParseFileObject()
将文件对象转换为节点对象。
节点对象将使用AST函数PyAST_FromNodeObject
转换为模块。
C代码
mod_ty
PyParser_ASTFromFileObject(FILE *fp, PyObject *filename, const char* enc,
int start, const char *ps1,
const char *ps2, PyCompilerFlags *flags, int *errcode,
PyArena *arena)
{
...
node *n = PyParser_ParseFileObject(fp, filename, enc,
&_PyParser_Grammar,
start, ps1, ps2, &err, &iflags);
...
if (n) {
flags->cf_flags |= iflags & PyCF_MASK;
mod = PyAST_FromNodeObject(n, flags, filename, arena);
PyNode_Free(n);
...
return mod;
}
谈到了PyParser_ParseFileObject()
函数,我们需要切换到Parser/parsetok.c
文件以及谈谈CPython解释器的解析器-标记化器阶段。
此函数有两个重要任务:
1.在Parser/tokenizer.c
中使用PyTokenizer_FromFile()
实例化标记化器状态tok_state结构体。
2.使用Parser/parsetok.c
中的parsetok()
将标记(tokens)转换为具体的解析树(节点列表)。
node *
PyParser_ParseFileObject(FILE *fp, PyObject *filename,
const char *enc, grammar *g, int start,
const char *ps1, const char *ps2,
perrdetail *err_ret, int *flags)
{
struct tok_state *tok;
...
if ((tok = PyTokenizer_FromFile(fp, enc, ps1, ps2)) == NULL) {
err_ret->error = E_NOMEM;
return NULL;
}
...
return parsetok(tok, g, start, err_ret, flags);
}
tok_state(在Parser/tokenizer.h中定义)是存储由tokenizer生成的所有临时数据的数据结构。它被返回到解析器-标记器(parser-tokenizer),因为parsetok()
需要数据结构来开发具体的语法树。
在parsetok()
的内部,他会调用结构体tok_state,在循环中调用tok_get(),直到文件耗尽并且找不到更多的标记(tokens)为止。
tok_get()
位于Parser/tokenizer.c
文件,其实为类型迭代器(iterator),它将继续返回解析树中的下一个token。
tok_get()是整个CPython代码库中最复杂的函数之一。它有超过640行的代码,包括数十年的边缘案例,以及新语言功能和语法。
其中一个比较简单的例子是将换行符转换为NEWLINE标记的部分:
static int
tok_get(struct tok_state *tok, char **p_start, char **p_end)
{
...
/* Newline */
if (c == '\n') {
tok->atbol = 1;
if (blankline || tok->level > 0) {
goto nextline;
}
*p_start = tok->start;
*p_end = tok->cur - 1; /* Leave '\n' out of the string */
tok->cont_line = 0;
if (tok->async_def) {
/* We're somewhere inside an 'async def' function, and
we've encountered a NEWLINE after its signature. */
tok->async_def_nl = 1;
}
return NEWLINE;
}
...
}
在这个例子里,NEWLINE是一个标记(tokens),其值在Include/token.h
中定义。
所有标记都是常量int值,并且在我们运行make regen-grammar
时生成了Include/token.h
文件。
PyParser_ParseFileObject()
返回的node类型对下一阶段至关重要,它会将解析树转换为抽象语法树(AST)。
typedef struct _node {
short n_type;
char *n_str;
int n_lineno;
int n_col_offset;
int n_nchildren;
struct _node *n_child;
int n_end_lineno;
int n_end_col_offset;
} node;
由于CST可能是语法,令牌ID或者符号树,因此编译器很难根据Python语言做出快速决策。
这就是下一阶段将CST转换为更高层次结构的AST的原因。此任务由Python/ast.c
模块执行,该模块具有C版和Python API版本。在跳转到AST之前,有一种方法可以从解析器阶段访问输出。CPython有一个标准的库模块parser,它使用Python API去展示C函数的内容。该模块被记录为CPython的实现细节,因此你不会在其他Python解释器中看到它。此外,函数的输出也不容易阅读。输出将采用数字形式,使用make regen-grammar
阶段生成的symbol和token编号,存储在Includ/token.h
和Include/symbol.h
中。
>>> from pprint import pprint
>>> import parser
>>> st = parser.expr('a + 1')
>>> pprint(parser.st2list(st))
[258,
[332,
[306,
[310,
[311,
[312,
[313,
[316,
[317,
[318,
[319,
[320,
[321, [322, [323, [324, [325, [1, 'a']]]]]],
[14, '+'],
[321, [322, [323, [324, [325, [2, '1']]]]]]]]]]]]]]]]],
[4, ''],
[0, '']]
为了便于理解,你可以获取symbol和token模块中的所有数字,将它们放入字典中,并使用名称递归替换parser.st2list()输出的值。
import symbol
import token
import parser
def lex(expression):
symbols = {v: k for k, v in symbol.__dict__.items() if isinstance(v, int)}
tokens = {v: k for k, v in token.__dict__.items() if isinstance(v, int)}
lexicon = {**symbols, **tokens}
st = parser.expr(expression)
st_list = parser.st2list(st)
def replace(l: list):
r = []
for i in l:
if isinstance(i, list):
r.append(replace(i))
else:
if i in lexicon:
r.append(lexicon[i])
else:
r.append(i)
return r
return replace(st_list)
你可以使用简单的表达式运行lex(),例如a+ 1,查看它如何表示为解析器树:
>>> from pprint import pprint
>>> pprint(lex('a + 1'))
['eval_input',
['testlist',
['test',
['or_test',
['and_test',
['not_test',
['comparison',
['expr',
['xor_expr',
['and_expr',
['shift_expr',
['arith_expr',
['term',
['factor', ['power', ['atom_expr', ['atom', ['NAME', 'a']]]]]],
['PLUS', '+'],
['term',
['factor',
['power', ['atom_expr', ['atom', ['NUMBER', '1']]]]]]]]]]]]]]]]],
['NEWLINE', ''],
['ENDMARKER', '']]
在输出中,你可以看到小写的符号(symbols),例如'test'和大写的标记(tokens),例如'NUMBER'。
抽象语法树
CPython解释器的下一个阶段是将解析器生成的CST转换为可以执行的更合理的结构。
该结构是代码的更高级别表示,称为抽象语法树(AST)。
AST是使用CPython解释器进程内联生成的,但你也可以使用标准库中的ast