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日
- 决议:
- 2023年3月14日
摘要
本文档提议取消 PEP 498 中最初提出的一些限制,并为 f-字符串提供一个可直接集成到解析器中的形式化语法。f-字符串的拟议语法形式化将对 f-字符串的解析和解释方式产生一些小的副作用,为终端用户和库开发人员带来显著优势,同时还大大降低了解析 f-字符串代码的维护成本。
动机
当 f-字符串最初在 PEP 498 中引入时,规范并未提供 f-字符串的形式语法。此外,该规范包含了一些限制,这些限制是为了在不修改现有词法分析器的情况下将 f-字符串的解析实现到 CPython 中。这些限制以前已被认识到,并曾在 PEP 536 中尝试取消它们,但 这些工作都没有实现。其中一些限制(最初由 PEP 536 收集)是:
- 在表达式部分中无法使用分隔 f-字符串的引号字符
>>> f'Magic wand: { bag['wand'] }' ^ SyntaxError: invalid syntax
- 以前考虑过的一种规避方法会导致在执行代码中出现转义序列,这在 f-字符串中是被禁止的
>>> f'Magic wand { bag[\'wand\'] } string' SyntaxError: f-string expression portion cannot include a backslash - 即使在多行 f-字符串中也禁止注释
>>> f'''A complex trick: { ... bag['bag'] # recursive bags! ... }''' SyntaxError: f-string expression part cannot include '#' - 许多其他使用表达式而不是仅使用变量名进行字符串插值的方法的语言都支持表达式的任意嵌套而不扩展转义序列。一些例子:
# Ruby "#{ "#{1+2}" }" # JavaScript `${`${1+2}`}` # Swift "\("\(1+2)")" # C# $"{$"{1+2}"}"
从语言用户的角度来看,这些限制毫无意义,可以通过为 f-字符串字面量提供一个没有例外情况的常规语法,并使用专用解析代码来实现来取消。
f-字符串的另一个问题是,CPython 中的当前实现依赖于将 f-字符串标记为 STRING 令牌并对这些令牌进行后处理。这存在以下问题:
- 这给 CPython 解析器带来了相当大的维护成本。这是因为解析代码需要手动编写,这在历史上导致了大量的F不一致和错误。手动编写和维护 C 语言解析代码一直被认为是容易出错和危险的,因为它需要处理大量关于原始词法分析器缓冲区的手动内存管理。
- f-字符串解析代码无法使用新的改进的错误消息机制,这些机制是由 PEP 617 中引入的新 PEG 解析器所允许的。这些错误消息带来的改进受到了广泛的赞扬,但不幸的是,f-字符串无法从中受益,因为它们是在解析机制的独立部分中解析的。这尤其不幸,因为 f-字符串有几个语法特性可能会由于表达式部分内部发生的不同隐式分词而令人困惑(例如
f"{y:=3}"不是赋值表达式)。 - 其他 Python 实现无法知道它们是否正确实现了 f-字符串,因为与所有其他语言特性不同,它们不是 官方 Python 语法 的一部分。这很重要,因为一些著名的替代实现正在使用 CPython 的 PEG 解析器,例如 PyPy,并且/或者它们的语法基于官方 PEG 语法。f-字符串使用单独的解析器这一事实阻碍了这些替代实现利用官方语法并从语法派生的错误消息改进中受益。
本提案的一个版本最初在 Python-Dev 上进行了讨论,并于 Python 语言峰会 2022 上发表,获得了热烈反响。
基本原理
本 PEP 建立在新的 Python PEG 解析器(PEP 617)的基础上,提议重新定义“f-字符串”,尤其强调字符串部分和表达式(或替换,{...})部分的明确分离。PEP 498 将“f-字符串”的语法部分总结如下:
在 Python 源代码中,f-字符串是一个字面量字符串,前缀为“f”,其中包含大括号内的表达式。表达式将被替换为其值。
然而,PEP 498 也包含一个关于表达式组件内部可以包含或不可以包含内容的正式排除列表(主要是由于现有解析器的限制)。通过明确建立形式语法,我们现在还能够将 f-字符串的表达式组件定义为真正“任何适用的 Python 表达式”(在特定上下文中),而不受我们实现细节所施加的限制的约束。
形式化工作和上述前提也对 Python 程序员具有显著的好处,因为它能够简化和消除模糊的限制。这降低了 f-字符串字面量(以及一般的 Python 语言)的心理负担和认知复杂性。
- 表达式组件可以包含任何普通 Python 表达式可以包含的字符串字面量。这为在 f-字符串的表达式组件中嵌套具有相同引号类型(和长度)的字符串字面量(格式化的或未格式化的)打开了可能性。
>>> f"These are the things: {", ".join(things)}" >>> f"{source.removesuffix(".py")}.c: $(srcdir)/{source}" >>> f"{f"{f"infinite"}"}" + " " + f"{f"nesting!!!"}"
这个“特性”并非普遍认为可取,有些用户觉得它难以阅读。关于不同观点的讨论,请参阅关于引号复用的考量部分。
- 另一个让大多数人觉得不直观的问题是 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'
- 在此文档提出的更改之前,f-字符串的嵌套方式没有明确的限制,但由于字符串引号不能在 f-字符串的表达式组件中重复使用,因此无法任意嵌套 f-字符串。实际上,这是可以编写的最深嵌套的 f-字符串:
>>> f"""{f'''{f'{f"{1+1}"}'}'''}""" '2'
由于本 PEP 允许在 f-字符串的表达式部分放置**任何**有效的 Python 表达式,现在可以重复使用引号,因此可以任意嵌套 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-字符串词法分析部分。
此功能不受本 PEP 提议的更改的影响,但重要的是要指定此功能的正式处理需要词法分析器能够“反标记化”f-字符串的表达式部分。这对于当前的字符串解析器来说不是问题,因为它可以直接操作字符串标记内容。但是,将此功能合并到给定的解析器实现中,需要词法分析器跟踪 f-字符串表达式部分的原始字符串内容,并在为 f-字符串节点构建解析树时将它们提供给解析器。纯粹的“反标记化”是不够的,因为正如目前所指定的,f-字符串调试表达式会保留表达式中的空格,包括 { 和 = 字符之后的空格。这意味着 f-字符串表达式部分的原始字符串内容必须保持不变,而不仅仅是相关的标记。
解析器/词法分析器实现如何处理这个问题,当然取决于具体的实现。
新令牌
引入了三个新令牌:FSTRING_START、FSTRING_MIDDLE 和 FSTRING_END。不同的词法分析器可能有不同的实现,这些实现可能比此处提出的更有效,具体取决于特定实现的上下文。然而,以下定义将作为 CPython 公共 API(例如 tokenize 模块)的一部分,并且也作为参考提供,以便读者更好地理解拟议的语法更改以及这些令牌的使用方式:
FSTRING_START:此令牌包括 f-字符串前缀(f/F/fr)和起始引号。FSTRING_MIDDLE:此令牌包含字符串内部文本的一部分,该部分不是表达式的一部分,也不是起始或结束大括号。这可以包括起始引号和第一个表达式大括号({)之间的文本,两个表达式大括号(}和{)之间的文本,以及最后一个表达式大括号(})和结束引号之间的文本。FSTRING_END:此令牌包含结束引号。
这些令牌始终是字符串部分,它们在语义上等同于带有指定限制的 STRING 令牌。这些令牌必须由词法分析器在词法分析 f-字符串时生成。这意味着**词法分析器不能再为 f-字符串生成单个令牌**。词法分析器如何发出此令牌**未指定**,因为这将严重取决于每个实现(即使标准库中 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-字符串时按照上一节所述发出这些令牌,这样工具就可以利用这种新的令牌化方案,避免实现自己的 f-字符串令牌化器和解析器。
如何生成这些新令牌
现有词法分析器适应发出这些令牌的一种方法是,集成一个“词法分析器模式”堆栈,或使用一个不同的词法分析器堆栈。这是因为当词法分析器遇到 f-字符串开始令牌时,它需要从“常规 Python 词法分析”切换到“f-字符串词法分析”,并且由于 f-字符串可以嵌套,上下文需要保留到 f-字符串关闭。此外,f-字符串表达式部分内部的“词法分析器模式”需要表现为常规 Python 词法分析器的“超集”(因为它需要能够在遇到表达式部分的 } 终止符时切换回 f-字符串词法分析,并处理 f-字符串格式化和调试表达式)。作为参考,这里是修改类似 CPython 的词法分析器以发出这些新令牌的算法草案:
- 如果词法分析器检测到 f-字符串开始(通过检测字母 'f/F' 和其中一个可能的引号),则继续前进直到检测到有效引号(
"、"""、'或'''),并发出一个FSTRING_START令牌,其中包含捕获的内容('f/F' 和起始引号)。将新的词法分析器模式推送到词法分析器模式堆栈,用于“F-字符串令牌化”。转到步骤 2。 - 继续消耗令牌,直到遇到以下情况之一:
- 与起始引号相同的结束引号。
- 如果在“格式说明符模式”中(参见步骤 3),遇到左大括号(
{)、右大括号(})或换行符(\n)。 - 如果不在“格式说明符模式”中(参见步骤 3),遇到左大括号(
{)或右大括号(}),且其后未紧跟另一个左/右大括号。
在所有情况下,如果字符缓冲区不为空,则发出一个
FSTRING_MIDDLE令牌,其中包含迄今为止捕获的内容,但将任何双重开始/结束大括号转换为单个开始/结束大括号。现在,根据遇到的字符按以下方式进行:- 如果遇到与起始引号匹配的结束引号,则转到步骤 4。
- 如果遇到起始方括号(其后未紧跟另一个起始方括号),则转到步骤 3。
- 如果遇到结束方括号(其后未紧跟另一个结束方括号),则发出结束方括号的令牌并转到步骤 2。
- 将一个新的标记器模式推送到标记器模式堆栈,用于“f-字符串中的常规 Python 标记化”,并继续使用它进行标记化。此模式像“常规 Python 标记化”一样进行标记化,直到遇到
:或}字符,其嵌套级别与我们进入 f-字符串部分时推送的起始括号标记相同。使用此模式,发出标记直到达到停止点之一。当发生这种情况时,为遇到的停止字符发出相应的标记,并从标记器模式堆栈中弹出当前标记器模式(对应于“f-字符串标记化”),然后返回“常规 Python 模式”。如果停止点是:字符,则以“格式说明符”模式进入步骤 2。 - 发出一个
FSTRING_END令牌,其中包含捕获的内容,并弹出当前词法分析器模式(对应于“F-字符串词法分析”),然后返回到“常规 Python 模式”。
当然,如前所述,不可能为任意词法分析器提供精确的实现规范,因为它将取决于要更改的词法分析器的具体实现和性质。
新语法带来的影响
PEP 中提及的所有限制都从 f-字符串字面量中取消,具体解释如下:
- 表达式部分现在可以包含与用于分隔 f-字符串字面量的引号类型相同的字符串。
- 反斜杠现在可以在表达式中出现,就像在 Python 代码的其他任何地方一样。如果字符串嵌套在 f-字符串字面量中,则在评估最内层字符串时会扩展转义序列。
- 现在表达式括号内允许换行。这意味着以下内容现在是允许的:
>>> x = 1 >>> f"___{ ... x ... }___" '___1___' >>> f"___{( ... x ... )}___" '___1___'
- 在 f-字符串的表达式部分中允许使用
#字符进行注释。请注意,注释要求表达式部分的结束括号 (}) 必须与注释在不同行,否则它将被视为注释的一部分而被忽略。
关于引用复用的考量
这里提出的语法的一个结果是,如上所述,f-字符串表达式现在可以包含与用于分隔外部 f-字符串字面量的引号类型相同的字符串。例如:
>>> f" something { my_dict["key"] } something else "
在本 PEP 的讨论帖中,对这方面提出了一些担忧,我们想在此收集这些担忧,因为在接受或拒绝本 PEP 时应将它们考虑在内。
其中一些反对意见包括:
- 许多人觉得在同一个字符串中重复使用引号令人困惑,难以阅读。这是因为允许重复使用引号会违反 Python 当前的一个特性:字符串完全由两对相同的引号连续分隔,这本身是一个非常简单的规则。引号重复使用可能导致人类难以解析,从而降低代码可读性的原因之一是,起始和结束引号字符是相同的(与使用其他分隔符不同)。
- 一些用户担心引号重复使用可能会破坏一些依赖简单机制(例如正则表达式或简单的分隔符匹配工具)来检测字符串和 f-字符串的词法分析器和语法高亮工具。在 f-字符串中引入引号重复使用将使保持这些工具正常工作变得更加棘手,或者会完全破坏这些工具(例如,正则表达式无法解析带有分隔符的任意嵌套结构)。标准库中包含的 IDLE 编辑器就是一个可能需要一些工作才能正确应用于 f-字符串语法高亮的工具。
以下是一些支持论点:
- 许多允许类似语法结构(通常称为“字符串插值”)的语言都允许引号重复使用和任意嵌套。这些语言包括 JavaScript、Ruby、C#、Bash、Swift 等。许多语言允许引号重复使用这一事实,可以成为支持在 Python 中允许它的有力论据。这是因为它将使该语言对来自其他语言的用户更加熟悉。
- 由于许多其他流行语言在字符串插值结构中允许引号重复使用,这意味着支持这些语言的语法高亮编辑器将已经拥有必要的工具来支持 Python 中带有引号重复使用的 f-字符串的语法高亮。这意味着虽然处理 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 表达式,并且顶层:字符之后的所有内容都将被识别为格式规范。
参考实现
参考实现可在 implementation 分支中找到。
被拒绝的想法
- 尽管我们认为针对 f-字符串表达式中允许引号复用而提出的可读性论点是有效且非常重要的,但我们决定不提议在解析器级别拒绝 f-字符串中的引号复用。原因在于本 PEP 的基石之一是降低 CPython 中解析 f-字符串的复杂性和维护成本,而拒绝引号复用不仅会阻碍这一目标,甚至可能使实现比当前更复杂。我们认为,禁止引号复用应该在 linter 和代码样式工具中完成,而不是在解析器中,就像目前处理语言中其他令人困惑或难以阅读的构造一样。
- 我们决定不取消某些表达式部分需要将
':'和'!'包装在顶层括号中的限制,例如:>>> 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) }'
- 我们决定(暂时)不允许使用转义大括号(
\{和\}),除了{{和}}语法。尽管 PEP 的作者认为允许转义大括号是一个好主意,但我们决定不将其包含在本 PEP 中,因为它对于此处提出的 f-字符串形式化并非严格必要,并且可以在常规 CPython 问题中独立添加。
未解决的问题
暂无
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源: https://github.com/python/peps/blob/main/peps/pep-0701.rst
最后修改: 2025-02-01 08:55:40 GMT