Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 增强提案

PEP 701 – f 字符串的语法形式化

作者:
Pablo Galindo <pablogsal at python.org>, Batuhan Taskaya <batuhan at python.org>, Lysandros Nikolaou <lisandrosnik at gmail.com>, Marta Gómez Macías <cyberwitch at google.com>
讨论对象:
Discourse 主题
状态:
已接受
类型:
标准跟踪
创建:
2022 年 11 月 15 日
Python 版本:
3.12
历史记录:
2022 年 12 月 19 日
决议:
Discourse 消息

目录

摘要

本文档建议解除PEP 498中最初制定的某些限制,并为 f 字符串提供一个可以直接集成到解析器中的形式化语法。提议的 f 字符串语法形式化将对 f 字符串的解析和解释方式产生一些轻微的副作用,这将为最终用户和库开发人员带来大量优势,同时也会显著降低用于解析 f 字符串的代码的维护成本。

动机

当 f 字符串最初在PEP 498中引入时,规范是在没有为 f 字符串提供正式语法的基础上提供的。此外,该规范包含一些强制执行的限制,以便可以将 f 字符串的解析实现到 CPython 中,而无需修改现有的词法分析器。这些限制之前已被认识到,并且在PEP 536中曾尝试过解除它们,但是这些工作从未被实现PEP 536最初收集了一些限制,具体如下:

  1. 无法在表达式的部分使用分隔 f 字符串的引号字符
    >>> f'Magic wand: { bag['wand'] }'
                                 ^
    SyntaxError: invalid syntax
    
  2. 一种以前考虑过的方法会导致执行的代码中出现转义序列,并且在 f 字符串中是被禁止的
    >>> f'Magic wand { bag[\'wand\'] } string'
    SyntaxError: f-string expression portion cannot include a backslash
    
  3. 即使在多行 f 字符串中,注释也是禁止的
    >>> f'''A complex trick: {
    ... bag['bag']  # recursive bags!
    ... }'''
    SyntaxError: f-string expression part cannot include '#'
    
  4. 在许多其他使用表达式而不是仅使用变量名称的字符串插值方法的语言中,可以对表达式进行任意嵌套而无需展开转义序列。以下是一些例子
    # Ruby
    "#{ "#{1+2}" }"
    
    # JavaScript
    `${`${1+2}`}`
    
    # Swift
    "\("\(1+2)")"
    
    # C#
    $"{$"{1+2}"}"
    

这些限制从语言用户角度来看没有任何目的,并且可以通过为 f 字符串字面量提供没有异常的常规语法并使用专用解析代码来解除它们。

f 字符串的另一个问题是,CPython 中的当前实现依赖于将 f 字符串标记化为STRING标记,并对这些标记进行后处理。这存在以下问题:

  1. 它为 CPython 解析器增加了相当大的维护成本。这是因为解析代码需要手动编写,这在历史上导致了许多不一致和错误。在 C 中手动编写和维护解析代码一直被认为是容易出错且危险的,因为它需要处理原始词法分析器缓冲区上的大量手动内存管理。
  2. f 字符串解析代码无法使用 PEP 617 中最初引入的新 PEG 解析器允许的新的改进的错误消息机制。这些错误消息带来的改进得到了广泛的认可,但不幸的是,f 字符串无法从中受益,因为它们是在解析机制的另一个部分中解析的。这尤其令人遗憾,因为 f 字符串的几个语法特性由于表达式部分中发生的不同的隐式标记化而可能令人困惑(例如,f"{y:=3}" 不是赋值表达式)。
  3. 其他 Python 实现无法知道它们是否正确地实现了 f 字符串,因为与其他语言特性不同,它们不是官方 Python 语法的一部分。这一点很重要,因为几个著名的替代实现正在使用 CPython 的 PEG 解析器,例如 PyPy,或者基于官方 PEG 语法。f 字符串使用单独的解析器,这阻止了这些替代实现利用官方语法并从语法中获得的错误消息改进中受益。

该提案的早期版本最初在Python-Dev 上进行了讨论,并在2022 年 Python 语言峰会上进行了介绍,并获得了热烈的欢迎。

基本原理

本 PEP 建议在新的 Python PEG 解析器 (PEP 617) 的基础上重新定义“f 字符串”,尤其强调字符串组件和表达式(或替换,{...})组件之间的明确分离。PEP 498 将“f 字符串”的语法部分总结如下:

在 Python 源代码中,f 字符串是一个以“f”为前缀的字面字符串,其中包含大括号内的表达式。表达式将被其值替换。

但是,PEP 498 还包含一个正式的排除列表,列出了表达式组件中可以或不能包含的内容(主要是由于现有解析器的限制)。通过明确建立正式语法,我们现在也有能力将 f 字符串的表达式组件定义为真正的“任何适用的 Python 表达式”(在特定上下文中),而不受实现细节强加的限制的约束。

上述形式化工作和前提也为 Python 程序员带来了显著的益处,因为它能够简化和消除模糊的限制。这减少了 f 字符串字面量(以及整个 Python 语言)的心理负担和认知复杂度。

  1. 表达式组件可以包含任何普通 Python 表达式可以包含的字符串字面量。这打开了在 f 字符串的表达式组件中嵌套字符串字面量(格式化与否)的可能性,并且这些字符串字面量使用相同的引号类型(和长度)。
    >>> f"These are the things: {", ".join(things)}"
    
    >>> f"{source.removesuffix(".py")}.c: $(srcdir)/{source}"
    
    >>> f"{f"{f"infinite"}"}" + " " + f"{f"nesting!!!"}"
    

    此“功能”并非普遍被认为是可取的,一些用户发现它难以阅读。有关对此的不同观点的讨论,请参阅关于引号重用的注意事项部分。

  2. 另一个大多数人认为不直观的问题是,f 字符串的表达式组件中不支持反斜杠。一个经常出现的例子是在表达式的部分中包含换行符以连接容器。例如:
    >>> a = ["hello", "world"]
    >>> f"{'\n'.join(a)}"
    File "<stdin>", line 1
        f"{'\n'.join(a)}"
                        ^
    SyntaxError: f-string expression part cannot include a backslash
    

    对此的常见解决方法是将换行符赋值给中间变量,或在创建 f 字符串之前预先创建整个字符串。

    >>> a = ["hello", "world"]
    >>> joined = '\n'.join(a)
    >>> f"{joined}"
    'hello\nworld'
    

    现在新的 PEG 解析器可以轻松地支持反斜杠,因此在表达式的部分中允许反斜杠显得更加自然。

    >>> a = ["hello", "world"]
    >>> f"{'\n'.join(a)}"
    'hello\nworld'
    
  3. 在本文档中提出的更改之前,f 字符串的嵌套方式没有明确的限制,但由于字符串引号无法在 f 字符串的表达式组件中重复使用,因此无法对 f 字符串进行任意嵌套。实际上,这可以写入的最嵌套的 f 字符串:
    >>> f"""{f'''{f'{f"{1+1}"}'}'''}"""
    '2'
    

    由于本 PEP 允许将任何有效的 Python 表达式放置在 f 字符串的表达式组件中,因此现在可以重复使用引号,因此可以对 f 字符串进行任意嵌套。

    >>> f"{f"{f"{f"{f"{f"{1+1}"}"}"}"}"}"
    '2'
    

    虽然这只是允许任意表达式的一个结果,但本 PEP 的作者并不认为这是一个根本性的优势,我们已决定语言规范不会明确要求这种嵌套可以是任意的。这是因为允许任意深度的嵌套会给词法分析器实现带来很多额外的复杂性(尤其是在词法分析器/解析器管道需要允许“取消标记化”以支持“f 字符串调试表达式”的情况下,并且在允许任意嵌套时,这会特别繁重)。因此,实现可以自由地根据需要对嵌套深度施加限制。请注意,这种情况并不罕见,因为 CPython 实现已经在很多地方施加了几个限制,包括对括号和方括号嵌套深度的限制、对块嵌套的限制、if 语句中分支数量的限制、星号解包中表达式数量的限制等。

规范

f 字符串的正式建议的 PEG 语法规范如下(有关语法的详细信息,请参阅PEP 617):

fstring
    | FSTRING_START fstring_middle* FSTRING_END
fstring_middle
    | fstring_replacement_field
    | FSTRING_MIDDLE
fstring_replacement_field
    | '{' (yield_expr | star_expressions) "="? [ "!" NAME ] [ ':' fstring_format_spec* ] '}'
fstring_format_spec:
    | FSTRING_MIDDLE
    | fstring_replacement_field

新标记 (FSTRING_START, FSTRING_MIDDLE, FSTRING_END) 在本文档的后面部分定义

本 PEP 将允许的 f 字符串嵌套级别(其他 f 字符串的表达式部分中的 f 字符串)留给实现,但指定了 5 级嵌套的下限。这样做是为了确保用户可以合理地预期能够以“合理”的深度嵌套 f 字符串。本 PEP 意味着限制嵌套不是语言规范的一部分,但语言规范也没有强制执行任意嵌套

类似地,本 PEP 将格式说明符中允许的表达式嵌套级别留给实现,但指定了 2 级嵌套的下限。这意味着以下内容应该始终有效:

f"{'':*^{1:{1}}}"

但以下内容可能有效或无效,具体取决于实现:

f"{'':*^{1:{1:{1}}}}"

新的语法将保留当前实现的抽象语法树 (AST)。这意味着本 PEP 不会对使用 f 字符串的现有代码引入任何语义更改。

处理 f 字符串调试表达式

从 Python 3.8 开始,f 字符串可以使用 = 运算符来调试表达式。例如:

>>> a = 1
>>> f"{1+1=}"
'1+1=2'

这些语义没有在 PEP 中正式介绍,它们是在当前字符串解析器中作为特殊情况在 bpo-36817 中实现的,并在 f-string 词法分析部分 中进行了文档记录。

此功能不受本 PEP 中提出的更改的影响,但重要的是要说明此功能的正式处理需要词法分析器能够“反词法分析” f-string 的表达式部分。对于当前的字符串解析器来说,这不是问题,因为它可以直接在字符串令牌内容上操作。但是,将此功能集成到给定的解析器实现中,需要词法分析器跟踪 f-string 表达式部分的原始字符串内容,并在为 f-string 节点构建解析树时将其提供给解析器。纯“反词法分析”是不够的,因为如目前所指定,f-string 调试表达式保留了表达式中的空格,包括 {= 字符后面的空格。这意味着必须完整保留 f-string 表达式部分的原始字符串内容,而不仅仅是相关令牌。

解析器/词法分析器实现如何处理这个问题当然取决于实现。

新标记

引入了三个新令牌:FSTRING_STARTFSTRING_MIDDLEFSTRING_END。不同的词法分析器可能具有不同的实现,这些实现可能比这里提出的实现更有效,具体取决于特定实现的上下文。但是,以下定义将用作 CPython 公共 API(例如 tokenize 模块)的一部分,并作为参考提供,以便读者能够更好地理解提议的语法更改以及令牌的使用方式

  • FSTRING_START:此令牌包含 f-string 前缀(f/F/fr)和起始引号。
  • FSTRING_MIDDLE:此令牌包含字符串内部不是表达式部分的一部分且不是起始或结束大括号的文本部分。这可能包括起始引号和第一个表达式大括号({)之间的文本,两个表达式大括号(}{)之间的文本,以及最后一个表达式大括号(})和结束引号之间的文本。
  • FSTRING_END:此令牌包含结束引号。

这些令牌始终是字符串部分,并且在语义上等效于 STRING 令牌,但具有指定的限制。这些令牌必须在词法分析 f-string 时由词法分析器生成。这意味着 **词法分析器不能再为 f-string 生成单个令牌**。词法分析器如何发出此令牌 **没有指定**,因为这将很大程度上取决于每个实现(即使标准库中词法分析器的 Python 版本也与 PEG 解析器使用的版本不同)。

例如

f'some words {a+b:.3f} more words {c+d=} final words'

将被词法分析为

FSTRING_START - "f'"
FSTRING_MIDDLE - 'some words '
LBRACE - '{'
NAME - 'a'
PLUS - '+'
NAME - 'b'
OP - ':'
FSTRING_MIDDLE - '.3f'
RBRACE - '}'
FSTRING_MIDDLE - ' more words '
LBRACE - '{'
NAME - 'c'
PLUS - '+'
NAME - 'd'
OP - '='
RBRACE - '}'
FSTRING_MIDDLE - ' final words'
FSTRING_END - "'"

f"""some words""" 将被简单地词法分析为

FSTRING_START - 'f"""'
FSTRING_MIDDLE - 'some words'
FSTRING_END - '"""'

对 tokenize 模块的更改

tokenize 模块将被修改为在解析 f-string 时按上一节中描述的方式发出这些令牌,以便工具可以利用这种新的词法分析方案,并避免必须实现自己的 f-string 词法分析器和解析器。

如何生成这些新标记

现有词法分析器可以采用的一种方法是合并一个“词法分析器模式”栈,或者使用一个不同的词法分析器栈。这是因为当遇到 f-string 起始令牌时,词法分析器需要从“常规 Python 词法分析”切换到“f-string 词法分析”,并且由于 f-string 可以嵌套,因此需要保留上下文,直到 f-string 关闭。此外,f-string 表达式部分内的“词法分析器模式”需要表现为“常规 Python 词法分析器”的“超集”(因为它需要能够在遇到 } 终止符时切换回 f-string 词法分析,该终止符用于表达式部分以及处理 f-string 格式化和调试表达式)。作为参考,以下是修改类似 CPython 的词法分析器以发出这些新令牌的算法草案

  1. 如果词法分析器检测到 f-string 正在开始(通过检测字母“f/F”和可能的引号之一),请继续前进,直到检测到有效的引号(""""'''' 之一),并使用捕获的内容发出 FSTRING_START 令牌(“f/F”和起始引号)。将新的词法分析器模式推入词法分析器模式栈,用于“F-string 词法分析”。转到步骤 2。
  2. 继续使用令牌,直到遇到以下内容之一
    • 与起始引号匹配的结束引号。
    • 如果处于“格式说明符模式”(请参见步骤 3),则为起始大括号({)、结束大括号(})或换行符令牌(\n)。
    • 如果未处于“格式说明符模式”(请参见步骤 3),则为起始大括号({)或结束大括号(}),并且它们后面没有紧跟着另一个起始/结束大括号。

    在所有情况下,如果字符缓冲区不为空,请使用捕获的当前内容发出 FSTRING_MIDDLE 令牌,但将任何双起始/结束大括号转换为单个起始/结束大括号。现在,根据遇到的字符,继续执行以下操作

    • 如果遇到与起始引号匹配的结束引号,请转到步骤 4。
    • 如果遇到起始方括号(后面没有紧跟着另一个起始方括号),请转到步骤 3。
    • 如果遇到结束方括号(后面没有紧跟着另一个结束方括号),请为结束方括号发出令牌,并转到步骤 2。
  3. 将新的词法分析器模式推入词法分析器模式栈,用于“f-string 内的常规 Python 词法分析”,并继续使用它进行词法分析。此模式按“常规 Python 词法分析”进行词法分析,直到遇到 :} 字符,其嵌套级别与我们进入 f-string 部分时推入的起始方括号令牌相同。使用此模式,发出令牌,直到达到停止点之一。发生这种情况时,为遇到的停止字符发出相应的令牌,然后从词法分析器模式栈中弹出当前词法分析器模式,并转到步骤 2。如果停止点是 : 字符,请在“格式说明符”模式下进入步骤 2。
  4. 使用捕获的内容发出 FSTRING_END 令牌,然后弹出当前词法分析器模式(对应于“F-string 词法分析”),并返回到“常规 Python 模式”。

当然,如前所述,不可能为任意词法分析器提供如何执行此操作的精确规范,因为它将取决于要更改的词法分析器的特定实现和性质。

新语法的后果

如以下所述,从 f-string 文字中删除了 PEP 中提到的所有限制

  • 表达式部分现在可以包含用与用于分隔 f-string 文字相同的引号类型分隔的字符串。
  • 反斜杠现在可以出现在表达式中,就像在其他任何 Python 代码中一样。对于嵌套在 f-string 文字中的字符串,当评估最里面的字符串时,将扩展转义序列。
  • 现在允许在表达式方括号内使用换行符。这意味着现在允许
    >>> x = 1
    >>> f"___{
    ...     x
    ... }___"
    '___1___'
    
    >>> f"___{(
    ...     x
    ... )}___"
    '___1___'
    
  • 使用 # 字符的注释允许在 f-string 的表达式部分内使用。请注意,注释需要表达式部分的结束方括号(})出现在与注释所在行不同的行,否则它将被忽略,因为它属于注释的一部分。

关于引号重用的注意事项

这里提出的语法的一个结果是,如上所述,f-string 表达式现在可以包含用与用于分隔外部 f-string 文字相同的引号类型分隔的字符串。例如

>>> f" something { my_dict["key"] } something else "

本 PEP 的讨论主题中,人们对这方面提出了一些担忧,我们希望在这里收集它们,因为在接受或拒绝本 PEP 时应考虑这些担忧。

其中一些反对意见包括

  • 许多人发现字符串内的引号重复令人困惑且难以阅读。这是因为允许引号重复将违反 Python 当今的当前属性:字符串完全由两对连续的相同类型的引号分隔,这本身就是一个非常简单的规则。引号重复可能更难让人类解析的原因之一,从而导致代码可读性降低,是因为引号字符对起始和结束都是相同的(与其他分隔符不同)。
  • 一些用户担心引号重复可能会破坏一些依赖于简单机制来检测字符串和 f-string 的词法分析器和语法高亮工具,例如正则表达式或简单分隔符匹配工具。在 f-string 中引入引号重复将使保持这些工具正常工作变得更加棘手,或者完全破坏这些工具(例如,正则表达式无法解析具有分隔符的任意嵌套结构)。标准库中包含的 IDLE 编辑器就是一个需要进行一些工作才能对 f-string 正确应用语法高亮的工具示例。

以下是支持的一些论点

  • 许多允许类似语法结构(通常称为“字符串插值”)的语言允许引号重复和任意嵌套。这些语言包括 JavaScript、Ruby、C#、Bash、Swift 和许多其他语言。许多语言允许引号重复的事实可能是支持在 Python 中允许引号重复的一个令人信服的论据。这是因为这将使该语言对于来自其他语言的用户来说更加熟悉。
  • 由于许多其他流行语言在字符串插值结构中允许引号重复,这意味着支持这些语言的语法高亮的编辑器将已经拥有支持具有引号重复的 Python 中 f-string 的语法高亮的必要工具。这意味着,尽管处理 Python 语法高亮的代码文件需要更新以支持此新功能,但这预计不会是不可能的或非常难做到的。
  • 允许重复使用引号的一个优势是它可以与其他语法干净地组合。有时这被称为“引用透明性”。一个例子是,如果我们有 f(x+1),假设 a 是一个全新的变量,它应该与 a = x+1; f(a) 的行为相同。反之亦然。所以如果我们有
    def py2c(source):
        prefix = source.removesuffix(".py")
        return f"{prefix}.c"
    

    应该预期的是,如果我们用它的定义替换变量 prefix,答案应该是一样的

    def py2c(source):
        return f"{source.removesuffix(".py")}.c"
    
  • 代码生成器(如来自标准库的 ast.unparse)在其当前形式中依赖于复杂的算法来确保 f 字符串中的表达式适合于它们被使用的上下文。这些非平凡的算法带来了挑战,例如找到一个未使用的引号类型(通过跟踪外部引号),并生成在可能的情况下不包含反斜杠的字符串表示。允许重复使用引号和反斜杠将简化处理 f 字符串的代码生成器,因为常规 Python 表达式逻辑可以在 f 字符串内部和外部使用,而无需任何特殊处理。
  • 限制重复使用引号将大大增加所提议更改的实现复杂性。这是因为这将迫使解析器具有解析具有给定引号的 f 字符串表达式部分的上下文,以了解它是否需要拒绝重复使用该引号的表达式。在可以任意回溯的解析器(例如 PEG 解析器)中,维护这种上下文并非易事。如果我们考虑到 f 字符串可以任意嵌套,因此可能需要拒绝多种引号类型,这个问题就变得更加复杂。

    为了收集来自社区的反馈,进行了一次民意调查,以了解社区对 PEP 这一方面的看法。

向后兼容性

本 PEP 不会对 Python 语言引入任何向后不兼容的语法或语义更改。但是,tokenize 模块(标准库的准公共部分)需要更新以支持新的 f 字符串令牌(以允许工具作者正确地对 f 字符串进行标记)。有关 tokenize 的公共 API 将如何受到影响的更多详细信息,请参阅 对 tokenize 模块的更改

如何教授

由于 f 字符串的概念在 Python 社区中已经普遍存在,因此用户无需学习任何新内容。但是,由于正式的语法允许一些新的可能性,因此将正式语法添加到文档中并详细解释它很重要,明确提到哪些结构是可能的,因为本 PEP 的目标是避免混淆。

为用户提供一个简单的框架来理解可以在 f 字符串表达式中放置什么也很有益。在这种情况下,作者认为这项工作将使解释语言的这方面变得更加简单,因为它可以概括为

您可以在 f 字符串表达式中放置任何有效的 Python 表达式。

随着本 PEP 中的更改,无需澄清字符串引号仅限于与封闭字符串的引号不同,因为现在允许这样做:由于任意 Python 字符串可以包含任何可能的引号选择,因此任何 f 字符串表达式也可以。此外,无需澄清由于实现限制(如注释、换行符或反斜杠)在表达式部分中不允许某些内容。

唯一的“令人惊讶”的差异是,由于 f 字符串允许指定格式,因此在顶层允许 : 字符的表达式仍然需要用括号括起来。这对于这项工作来说并不新鲜,但重要的是要强调这种限制仍然存在。这使得更容易修改摘要

您可以在 f 字符串表达式中放置任何有效的 Python 表达式,并且顶层 : 字符后面的所有内容都将被识别为格式规范。

参考实现

可以在 实现 分支中找到参考实现。

被拒绝的想法

  1. 虽然我们认为针对允许在 f 字符串表达式中重复使用引号提出的可读性论据是有效的且非常重要的,但我们已决定建议不要在解析器级别拒绝在 f 字符串中重复使用引号。原因是本 PEP 的基石之一是减少在 CPython 中解析 f 字符串的复杂性和维护,这不仅会违背这个目标,甚至可能使实现比当前实现更加复杂。我们相信禁止重复使用引号应该在代码 linter 和代码样式工具中完成,而不是在解析器中完成,就像今天处理语言中其他令人困惑或难以理解的结构一样。
  2. 我们决定不取消某些表达式部分需要在顶层用括号括起来 ':''!' 的限制,例如
    >>> f'Useless use of lambdas: { lambda x: x*2 }'
    SyntaxError: unexpected EOF while parsing
    

    原因是这将引入相当多的复杂性,而没有真正的益处。这是因为 : 字符通常用于分隔 f 字符串格式规范。此格式规范当前被标记为字符串。由于标记器必须将 : 右侧的内容标记为字符串或令牌流,因此这将不允许解析器区分不同的语义,因为这将要求标记器回溯并生成不同的令牌集(即,首先尝试作为令牌流,如果失败,则尝试作为格式规范的字符串)。

    由于在顶层允许 lambda 和类似表达式没有根本优势,因此我们决定保留必须对这些表达式进行括号括起来的限制(如果需要)

    >>> f'Useless use of lambdas: { (lambda x: x*2) }'
    
  3. 我们决定禁止(暂时)使用转义大括号 (\{\}),除了 {{}} 语法。虽然 PEP 的作者认为允许转义大括号是一个好主意,但我们决定不将其包含在本 PEP 中,因为它对于这里提出的 f 字符串形式化来说并不严格必要,它可以独立地在常规的 CPython 问题中添加。

未解决的问题

目前还没有


来源:https://github.com/python/peps/blob/main/peps/pep-0701.rst

最后修改:2023-10-10 13:14:41 GMT