为什么 Python 的语法规范不包括文档字符串和注释?

Why does Python's grammar specification not include docstrings and comments?

正在咨询官方Python grammar specification as of Python 3.6.

我找不到任何注释语法(它们似乎以 # 开头)和文档字符串(它们应该以 ''' 出现)。快速查看 the lexical analysis 页面也无济于事 - 文档字符串在那里定义为 longstrings 但未出现在语法规范中。名为 STRING 的类型进一步出现,但未引用其定义。

鉴于此,我很好奇 CPython 编译器如何知道注释和文档字符串是什么。这个壮举是如何完成的?

我最初猜测注释和文档字符串在 CPython 编译器的第一遍中被删除,但是这就引出了 help() 如何呈现相关文档字符串的问题。

第 1 部分

评论会怎样?

注释(任何以 # 开头的内容)在 tokenization/lexical 分析期间将被忽略,因此无需编写规则来解析它们。它们不向 interpreter/compiler 提供任何语义信息,因为它们只是为了 reader 而提高程序的冗长程度,因此它们被忽略了。

这是 ANSI C 编程语言的 lex 规范:http://www.quut.com/c/ANSI-C-grammar-l-1998.html。我想提请您注意此处处理评论的方式:

"/*"            { comment(); }
"//"[^\n]*      { /* consume //-comment */ }

现在,看看 int 的规则。

"int"           { count(); return(INT); }

这是处理 int 和其他标记的 lex 函数:

void count(void)
{
    int i;

    for (i = 0; yytext[i] != '[=12=]'; i++)
        if (yytext[i] == '\n')
            column = 0;
        else if (yytext[i] == '\t')
            column += 8 - (column % 8);
        else
            column++;

    ECHO;
}

你在这里看到它以 ECHO 语句结尾,这意味着它是一个有效的标记,必须被解析。

现在,这是处理评论的 lex 函数:

void comment(void)
{
    char c, prev = 0;

    while ((c = input()) != 0)      /* (EOF maps to 0) */
    {
        if (c == '/' && prev == '*')
            return;
        prev = c;
    }
    error("unterminated comment");
}

这里没有 ECHO。因此,没有返回任何内容。

这是一个有代表性的例子,但 python 做的事情完全一样。


第 2 部分

文档字符串会发生什么变化?

注意:我回答的这一部分是对@MartijnPieters 回答的补充。这并不意味着复制他在 post 中提供的任何信息。现在,话虽如此,...

I originally guessed that comments and docstrings are removed in a first pass by the CPython compiler[...]

Docstrings(未分配给任何变量名称的字符串文字,'...'"..."'''...'''"""...""" 中的任何内容)确实已处理。它们被解析为简单的字符串文字(STRING+ 标记),正如 Martijn Pieters 在 中提到的那样。对于当前的文档,只是顺便提到文档字符串被分配给 function/class/module 的 __doc__ 属性。它是如何完成的并没有在任何地方深入提及。

实际发生的是它们被标记化并解析为字符串文字,生成的结果解析树将包含它们。从解析树生成字节码,文档字符串在 __doc__ 属性中的正确位置(它们不是字节码的明确一部分,如下所示)。我不会详细介绍,因为我上面链接的答案非常详细地描述了相同的内容。

当然,完全忽略它们也是可以的。如果您使用 python -OO-OO 标志代表 "optimize intensely",与代表 "optimize mildly" 的 -O 相对,结果字节码存储在 .pyo 文件,其中不包括文档字符串。

如下图所示:

使用以下代码创建文件 test.py

def foo():
    """ docstring """
    pass

现在,我们将在设置正常标志的情况下编译这段代码。

>>> code = compile(open('test.py').read(), '', 'single')
>>> import dis
>>> dis.dis(code)
  1           0 LOAD_CONST               0 (<code object foo at 0x102b20ed0, file "", line 1>)
              2 LOAD_CONST               1 ('foo')
              4 MAKE_FUNCTION            0
              6 STORE_NAME               0 (foo)
              8 LOAD_CONST               2 (None)
             10 RETURN_VALUE

如您所见,字节码中并没有提到我们的docstring。然而,他们在那里。要获取文档字符串,您可以这样做...

>>> code.co_consts[0].co_consts
(' docstring ', None)

因此,如您所见,文档字符串 确实 保留,只是不作为主要字节码的一部分。现在,让我们重新编译这段代码,但将优化级别设置为 2(相当于 -OO 开关):

>>> code = compile(open('test.py').read(), '', 'single', optimize=2)
>>> dis.dis(code)
  1           0 LOAD_CONST               0 (<code object foo at 0x102a95810, file "", line 1>)
              2 LOAD_CONST               1 ('foo')
              4 MAKE_FUNCTION            0
              6 STORE_NAME               0 (foo)
              8 LOAD_CONST               2 (None)
             10 RETURN_VALUE

不,区别,但是...

>>> code.co_consts[0].co_consts
(None,)

文档字符串现在已经消失了。

-O-OO 标志只删除东西(字节码的优化是默认完成的...-O 从中删除断言语句和 if __debug__: 套件生成的字节码,而 -OO 另外忽略文档字符串)。生成的编译时间将略有减少。此外,执行速度保持不变,除非你有大量的 assertif __debug__: 语句,否则对性能没有影响。

此外,请记住,只有当文档字符串是 function/class/module 定义中的第一件事时,它们才会被保留。所有额外的字符串都在编译期间被简单地删除。如果将 test.py 更改为以下内容:

def foo():
    """ docstring """

    """test"""
    pass

然后用optimization=0重复相同的过程,这是在编译时存储在co_consts变量中的:

>>> code.co_consts[0].co_consts
(' docstring ', None)

意思是,""" test """被忽略了。您会感兴趣的是,此删除是作为字节码基础优化的一部分完成的。


第 3 部分

补充阅读

(您可能会像我一样发现这些参考文献很有趣。)

  1. What does Python optimization (-O or PYTHONOPTIMIZE) do?

  2. What do the python file extensions, .pyc .pyd .pyo stand for?

  3. Are Python docstrings and comments stored in memory when a module is loaded?

  4. Working with compile()

  5. dis模块

  6. peephole.c(由 Martijn 提供)- 所有编译器优化的源代码。这个特别有意思,如果你能看懂的话!

文档字符串不是一个单独的语法实体。它只是一个常规 simple_stmt(遵循该规则一直到 atomSTRING+ *。如果它是 first 语句在函数体、class 或模块中,然后它 被编译器用作 作为文档字符串。

这在参考文档中记录为 class and def 复合语句的脚注:

[3] A string literal appearing as the first statement in the function body is transformed into the function’s __doc__ attribute and therefore the function’s docstring.

[4] A string literal appearing as the first statement in the class body is transformed into the namespace’s __doc__ item and therefore the class’s docstring.

目前没有为模块指定相同内容的参考文档,我认为这是一个文档错误。

注释已被分词器删除,永远不需要解析为语法。他们的全部要点在语法层面上是没有意义的。参见词法分析文档的Comments section

A comment starts with a hash character (#) that is not part of a string literal, and ends at the end of the physical line. A comment signifies the end of the logical line unless the implicit line joining rules are invoked. Comments are ignored by the syntax; they are not tokens.

大胆强调我的。所以 tokenizer 完全跳过评论:

/* Skip comment */
if (c == '#') {
    while (c != EOF && c != '\n') {
        c = tok_nextc(tok);
    }
}

请注意 Python 源代码经过 3 个步骤:

  1. 标记化
  2. 正在解析
  3. 编译

语法只适用于解析阶段;注释被删除到分词器中,文档字符串仅对编译器有特殊意义。

为了说明解析器如何不将文档字符串视为字符串文字表达式以外的任何内容,您可以将任何 Python 解析结果作为 抽象语法树 访问,通过 ast module。这会产生 Python 个对象,这些对象 直接 反映 Python 语法分析器产生的分析树,然后从中编译 Python 字节码:

>>> import ast
>>> function = 'def foo():\n    "docstring"\n'
>>> parse_tree = ast.parse(function)
>>> ast.dump(parse_tree)
"Module(body=[FunctionDef(name='foo', args=arguments(args=[], vararg=None, kwonlyargs=[], kw_defaults=[], kwarg=None, defaults=[]), body=[Expr(value=Str(s='docstring'))], decorator_list=[], returns=None)])"
>>> parse_tree.body[0]
<_ast.FunctionDef object at 0x107b96ba8>
>>> parse_tree.body[0].body[0]
<_ast.Expr object at 0x107b16a20>
>>> parse_tree.body[0].body[0].value
<_ast.Str object at 0x107bb3ef0>
>>> parse_tree.body[0].body[0].value.s
'docstring'

所以你有 FunctionDef 对象,作为主体中的第一个元素,表达式是一个 Str,值为 'docstring'。然后是 编译器 生成一个代码对象,将该文档字符串存储在一个单独的属性中。

你可以用compile() function; again, this is using the actual codepaths the Python interpreter uses. We'll use the dis module把AST编译成字节码来为我们反编译字节码:

>>> codeobj = compile(parse_tree, '', 'exec')
>>> import dis
>>> dis.dis(codeobj)
  1           0 LOAD_CONST               0 (<code object foo at 0x107ac9d20, file "", line 1>)
              2 LOAD_CONST               1 ('foo')
              4 MAKE_FUNCTION            0
              6 STORE_NAME               0 (foo)
              8 LOAD_CONST               2 (None)
             10 RETURN_VALUE

因此编译后的代码生成了模块的顶级语句。 MAKE_FUNCTION opcode 使用存储的代码对象(顶级代码对象常量的一部分)来构建函数。因此,我们查看索引 0 处的嵌套代码对象:

>>> dis.dis(codeobj.co_consts[0])
  1           0 LOAD_CONST               1 (None)
              2 RETURN_VALUE

这里的文档字符串似乎消失了。该函数无非就是 return None。文档字符串改为存储为常量:

>>> codeobj.co_consts[0].co_consts
('docstring', None)

当执行 MAKE_FUNCTION 操作码时,它是第一个常量,前提是它是一个字符串,它被转换为函数对象的 __doc__ 属性。

编译后,我们可以在给定的命名空间中执行带有 exec() function 的代码对象,这会添加一个带有文档字符串的函数对象:

>>> namespace = {}
>>> exec(codeobj, namespace)
>>> namespace['foo']
<function foo at 0x107c23e18>
>>> namespace['foo'].__doc__
'docstring'

所以编译器的工作是确定什么时候是文档字符串。这是用 C 代码完成的,在 compiler_isdocstring() function:

static int
compiler_isdocstring(stmt_ty s)
{
    if (s->kind != Expr_kind)
        return 0;
    if (s->v.Expr.value->kind == Str_kind)
        return 1;
    if (s->v.Expr.value->kind == Constant_kind)
        return PyUnicode_CheckExact(s->v.Expr.value->v.Constant.value);
    return 0;
}

这是从文档字符串有意义的位置调用的;对于模块和 classes,在 compiler_body(), and for functions, in compiler_function().


TLDR:注释不是语法的一部分,因为语法分析器甚至看不到注释。它们被分词器跳过。 Docstrings 不是语法的一部分,因为对于语法解析器来说,它们只是字符串文字。编译步骤(采用解析器的解析树输出)将这些字符串表达式解释为文档字符串。


*完整语法规则路径为simple_stmt -> small_stmt -> expr_stmt -> testlist_star_expr -> star_expr -> expr -> xor_expr -> and_expr -> shift_expr -> arith_expr -> term -> factor -> power -> atom_expr -> atom -> STRING+