PEP 701 – f-string 的語法形式化
- 作者:
- 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 討論串
- 狀態:
- 已接受
- 類型:
- 標準軌跡 (Standards Track)
- 建立日期:
- 2022年11月15日
- Python 版本:
- 3.12
- 公告歷史:
- 2022年12月19日
- 決議:
- 2023年3月14日
摘要
本文件提議解除 PEP 498 最初制定的部分限制,並為 f-string 提供可直接整合至解析器(parser)中的形式化語法。提議的 f-string 語法形式化對 f-string 的解析與解釋方式會有些微副作用,但能為終端使用者與函式庫開發者帶來相當多的優勢,同時也大幅降低解析 f-string 程式碼的維護成本。
動機
當 f-string 最初在 PEP 498 中引入時,規範中並未提供 f-string 的正式語法。此外,規範中包含若干限制,這些限制是為了讓 f-string 的解析能在不修改現有詞法分析器(lexer)的情況下實作於 CPython。這些限制之前已被人們注意到,且過去曾試圖在 PEP 536 中解除它們,但這些工作從未被實作。其中一些限制(最初由 PEP 536 收集)為:
- 無法在表達式部分內使用界定 f-string 的引號字元。
>>> f'Magic wand: { bag['wand'] }' ^ SyntaxError: invalid syntax
- 先前考慮過的一種解決方法會導致執行中的程式碼出現跳脫序列(escape sequences),這在 f-string 中是被禁止的。
>>> f'Magic wand { bag[\'wand\'] } string' SyntaxError: f-string expression portion cannot include a backslash - 即使在多行 f-string 中,註解也是被禁止的。
>>> 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-string 字面值一個沒有例外情況的常規語法,並使用專門的解析程式碼來實作,從而解除這些限制。
f-string 面臨的另一個問題是,目前 CPython 的實作依賴於將 f-string 標記為 STRING Token 並對這些 Token 進行後處理。這存在以下問題:
- 它增加了 CPython 解析器的維護成本。這是因為解析程式碼需要手寫,歷史上導致了相當多的不一致和錯誤。在 C 語言中手寫和維護解析程式碼一直被認為容易出錯且危險,因為它需要處理相對於原始詞法分析器緩衝區的大量手動記憶體管理。
- f-string 解析程式碼無法利用由 PEP 617 最初引入的新 PEG 解析器所提供的新改進錯誤訊息機制。這些錯誤訊息帶來的改進廣受讚譽,但遺憾的是 f-string 無法從中受益,因為它們是在解析機制的獨立部分中解析的。這特別令人遺憾,因為 f-string 有幾個語法特徵可能會因為表達式內部發生的不同隱式詞法標記化而令人困惑(例如
f"{y:=3}"並非賦值表達式)。 - 其他 Python 實作無法得知它們是否正確實作了 f-string,因為與其他語言特性不同,它們不是官方 Python 語法的一部分。這一點很重要,因為一些著名的替代實作正在使用 CPython 的 PEG 解析器,例如 PyPy,和/或將其語法基於官方 PEG 語法。f-string 使用獨立解析器的事實,阻礙了這些替代實作利用官方語法,並從源自該語法的錯誤訊息改進中受益。
此提案的一個版本最初在 Python-Dev 上進行了討論,並在 2022 年 Python 語言峰會上發表,受到了熱烈的歡迎。
原理
透過建立在新的 Python PEG 解析器(PEP 617)之上,本 PEP 提議重新定義「f-string」,特別強調字串組件與表達式(或替換,{...})組件的明確分離。PEP 498 將「f-string」的語法部分總結如下:
在 Python 原始程式碼中,f-string 是一種字面值字串,前綴為「f」,其中包含大括號內的表達式。這些表達式會被替換為它們的值。
然而,PEP 498 也包含了一份關於表達式組件內可以或不可以包含什麼的正式排除清單(主要是由於現有解析器的限制)。透過明確建立形式語法,我們現在也有能力將 f-string 的表達式組件定義為真正「任何適用的 Python 表達式」(在該特定上下文中),而不受實作細節所強加的限制束縛。
上述形式化努力和前提對於 Python 程式設計師來說也具有顯著的好處,因為它能夠簡化並消除模糊的限制。這減少了 f-string 字面值(以及一般 Python 語言)的精神負擔和認知複雜性。
- 表達式組件可以包含任何普通 Python 表達式可以包含的字串字面值。這為在 f-string 的表達式組件中嵌套相同引號類型(和長度)的字串字面值(無論是否格式化)開啟了可能性。
>>> f"These are the things: {", ".join(things)}" >>> f"{source.removesuffix(".py")}.c: $(srcdir)/{source}" >>> f"{f"{f"infinite"}"}" + " " + f"{f"nesting!!!"}"
這種「功能」並未獲得普遍認可為理想的,一些使用者認為這難以閱讀。關於對此的不同觀點討論,請參閱關於引號重用的考量一節。
- 另一個讓大多數人感到不直觀的問題是,f-string 的表達式組件內不支援反斜線。一個不斷出現的例子是在用於連接容器的表達式部分中包含換行字元。例如:
>>> 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-string 之前先建立整個字串。
>>> a = ["hello", "world"] >>> joined = '\n'.join(a) >>> f"{joined}" 'hello\nworld'
現在新的 PEG 解析器可以輕鬆支援反斜線,在表達式部分允許反斜線感覺很自然。
>>> a = ["hello", "world"] >>> f"{'\n'.join(a)}" 'hello\nworld'
- 在本文件提議的變更之前,f-string 的嵌套方式沒有明確限制,但事實上,字串引號不能在 f-string 的表達式組件內重複使用,這使得無法任意嵌套 f-string。事實上,這是可以寫出的最深嵌套 f-string:
>>> f"""{f'''{f'{f"{1+1}"}'}'''}""" '2'
由於本 PEP 允許在 f-string 的表達式組件中放置任何有效的 Python 表達式,因此現在可以重複使用引號,從而可以任意嵌套 f-string。
>>> f"{f"{f"{f"{f"{f"{1+1}"}"}"}"}"}" '2'
儘管這只是允許任意表達式的結果,但本 PEP 的作者不認為這是一個根本性的好處,我們已決定語言規範不會明確強制要求這種嵌套可以是任意的。這是因為允許任意深度的嵌套會給詞法分析器的實作增加大量額外的複雜性(特別是因為詞法分析器/解析器管道需要允許「取消標記化(untokenizing)」以支援「f-string 除錯表達式」,而在允許任意嵌套時這尤其費力)。因此,實作如果需要,可以自由地對嵌套深度施加限制。請注意,這並非不常見的情況,因為 CPython 實作已經在各處施加了多個限制,包括對括號和中括號嵌套深度的限制、對區塊嵌套的限制、對
if陳述式中分支數量的限制、對星號解包中表達式數量的限制等等。
規範
f-string 的形式化 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
新的 Token(FSTRING_START, FSTRING_MIDDLE, FSTRING_END)定義於本文件稍後部分。
本 PEP 將允許的 f-string 嵌套層級(f-string 內部的 f-string 表達式部分)交由實作決定,但指定了至少 5 層的嵌套下限。這是為了確保使用者對能夠以「合理」深度嵌套 f-string 有合理的期望。本 PEP 暗示限制嵌套並非語言規範的一部分,但同時語言規範也不強制要求任意嵌套。
同樣地,本 PEP 將格式說明符中的表達式嵌套層級交由實作決定,但指定了至少 2 層的嵌套下限。這意味著以下內容應始終有效:
f"{'':*^{1:{1}}}"
但以下內容根據實作可能有效也可能無效:
f"{'':*^{1:{1:{1}}}}"
新語法將保留當前實作的抽象語法樹 (AST)。這意味著本 PEP 不會對使用 f-string 的現有程式碼引入任何語義上的變更。
f-string 除錯表達式的處理
自 Python 3.8 起,f-string 可透過 = 運算子用於除錯表達式。例如:
>>> a = 1
>>> f"{1+1=}"
'1+1=2'
此語義並未在 PEP 中正式引入,而是作為 bpo-36817 中的特例在當前字串解析器中實作,並記錄在f-string 詞法分析一節中。
此功能不受本 PEP 提議變更的影響,但必須明確指出的是,此功能的正式處理要求詞法分析器能夠「取消標記化(untokenize)」f-string 的表達式部分。對於當前的字串解析器來說,這不是問題,因為它可以直接對字串 Token 內容進行操作。然而,將此功能整合到給定的解析器實作中,要求詞法分析器保留 f-string 表達式部分的原始字串內容,並在為 f-string 節點建構解析樹時使其對解析器可用。單純的「取消標記化」是不夠的,因為根據目前的規範,f-string 除錯表達式保留了表達式中的空白,包括 { 和 = 字元之後的空格。這意味著 f-string 表達式部分的原始字串內容必須保持完整,而不僅僅是相關的 Token。
解析器/詞法分析器實作如何處理這個問題,當然取決於實作本身。
新 Token
引入了三個新 Token:FSTRING_START、FSTRING_MIDDLE 和 FSTRING_END。不同的詞法分析器可能有比此處提議的更高效的實作方式,具體取決於特定實作的上下文。然而,以下定義將作為 CPython 公共 API(如 tokenize 模組)的一部分使用,並提供作為參考,以便讀者能更好地理解提議的語法變更以及 Token 的使用方式:
FSTRING_START:此 Token 包含 f-string 前綴(f/F/fr)和開始引號。FSTRING_MIDDLE:此 Token 包含字串內部不屬於表達式部分,且不是開始或結束大括號的文字部分。這可以包括開始引號與第一個表達式大括號({)之間的文字、兩個表達式大括號(}與{)之間的文字,以及最後一個表達式大括號(})與結束引號之間的文字。FSTRING_END:此 Token 包含結束引號。
這些 Token 始終是字串部分,它們在語義上與具有指定限制的 STRING Token 等價。這些 Token 必須在對 f-string 進行詞法分析時由詞法分析器產生。這意味著 tokenizer 無法再為 f-string 產生單一 Token。詞法分析器如何發出此 Token 未指定,因為這將很大程度上取決於每個實作(甚至標準函式庫中 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 時如上一節所述發出這些 Token,這樣工具就可以利用這種新的標記化結構,避免必須實作自己的 f-string tokenizer 和解析器。
如何產生這些新 Token
現有詞法分析器適應發出這些 Token 的一種方法是合併一個「詞法分析器模式」堆疊,或使用不同詞法分析器的堆疊。這是因為詞法分析器在遇到 f-string 開始 Token 時需要從「常規 Python 詞法分析」切換到「f-string 詞法分析」,且由於 f-string 可以嵌套,在 f-string 關閉之前需要保留上下文。此外,f-string 表達式部分內部的「詞法分析器模式」需要表現得像是常規 Python 詞法分析器的「超集」(因為它需要在遇到表達式部分的終止符 } 時切換回 f-string 詞法分析,並處理 f-string 格式化和除錯表達式)。作為參考,以下是修改類似 CPython 的 tokenizer 以發出這些新 Token 的演算法草案:
- 如果詞法分析器檢測到 f-string 開始(檢測到字母「f/F」和可能的引號之一),請繼續前進直到檢測到有效的引號(
"、"""、'或'''之一),並發出一個包含捕獲內容(「f/F」和開始引號)的FSTRING_STARTToken。將新的 tokenizer 模式推送到「f-string 標記化」的 tokenizer 模式堆疊。前往步驟 2。 - 繼續消費 Token,直到遇到以下情況之一:
- 等於開始引號的結束引號。
- 如果處於「格式說明符模式」(參見步驟 3),則為開始大括號(
{)、結束大括號(})或換行 Token(\n)。 - 如果不在「格式說明符模式」(參見步驟 3),則為不是立即跟隨另一個開始/結束大括號的開始大括號(
{)或結束大括號(})。
在所有情況下,如果字元緩衝區不為空,請發出一個包含目前捕獲內容的
FSTRING_MIDDLEToken,但將任何雙重開始/結束大括號轉換為單一開始/結束大括號。現在,根據遇到的字元執行以下操作:- 如果遇到與開始引號匹配的結束引號,則前往步驟 4。
- 如果遇到開始括號(未立即跟隨另一個開始括號),則前往步驟 3。
- 如果遇到結束括號(未立即跟隨另一個結束括號),則為該結束括號發出一個 Token 並前往步驟 2。
- 將新的 tokenizer 模式推送到「f-string 內部的常規 Python 標記化」的 tokenizer 模式堆疊,並繼續進行標記化。此模式作為「常規 Python 標記化」進行標記化,直到遇到巢狀層級與進入 f-string 部分時推入的開始括號 Token 相同的
:或}字元。使用此模式,發出 Token 直到到達停止點之一。發生這種情況時,為遇到的停止字元發出對應的 Token,從 tokenizer 模式堆疊彈出當前的 tokenizer 模式,然後前往步驟 2。如果停止點是:字元,請以「格式說明符」模式進入步驟 2。 - 發出一個包含捕獲內容的
FSTRING_ENDToken,並彈出當前的 tokenizer 模式(對應於「f-string 標記化」),然後回到「常規 Python 模式」。
當然,如前所述,不可能為任意 tokenizer 提供應如何完成此操作的精確規範,因為這將取決於要更改的詞法分析器的具體實作和性質。
新語法的影響
如以下解釋,PEP 中提到的所有限制皆已從 f-string 字面值中解除:
- 表達式部分現在可以包含使用與界定 f-string 字面值相同類型的引號所界定的字串。
- 反斜線現在可以像 Python 程式碼中任何其他地方一樣出現在表達式中。對於嵌套在 f-string 字面值中的字串,跳脫序列會在 innermost 字串被評估時進行擴展。
- 現在允許在表達式括號內換行。這意味著以下內容現在是允許的:
>>> 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 語法高亮的檔案需要更新以支援此新功能,但並不預期這是不可能或非常困難的。
- 允許引號重用的一個優點是它能與其他語法乾淨地組合。有時這被稱為「參照透明度(referential transparency)」。一個例子是,如果我們有
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-string 內的表達式適合其使用環境。這些非平凡的演算法帶來了挑戰,例如尋找未使用的引號類型(透過追蹤外部引號),以及儘可能生成不包含反斜線的字串表示。允許引號重用和反斜線將大大簡化處理 f-string 的程式碼產生器,因為常規 Python 表達式邏輯可以在 f-string 內外使用,無需任何特殊處理。
- 限制引號重用將顯著增加所提議變更的實作複雜性。這是因為它將迫使解析器具備正在解析具有給定引號的 f-string 表達式部分的上下文,以便知道是否需要拒絕一個重用該引號的表達式。在可以任意回溯的解析器(例如 PEG 解析器)中,攜帶這種上下文並非易事。如果考慮到 f-string 可以任意嵌套,因此可能需要拒絕多種引號類型,問題將變得更加複雜。
為了收集社群的反饋,已經發起了一項民意調查,以了解社群對 PEP 這一方面的看法。
回溯相容性
本 PEP 不會引入任何向後不相容的語法或語義變更。然而,tokenize 模組(標準函式庫的準公共部分)將需要更新以支援新的 f-string Token(以允許工具作者正確地對 f-string 進行標記化)。關於 tokenize 的公共 API 將如何受到影響的更多詳細資訊,請參閱tokenize 模組的變更。
如何教學
由於 f-string 的概念在 Python 社群中已無處不在,使用者沒有學習任何新事物的根本需要。然而,由於形式化語法允許一些新的可能性,因此將形式語法加入文件並詳細說明是很重要的,明確提到哪些構造是可能的,因為本 PEP 的目標是避免混淆。
為使用者提供一個簡單的框架來理解什麼可以放在 f-string 表達式中也是有益的。在這種情況下,作者認為這項工作將使解釋該語言的這一方面變得更加簡單,因為它可以總結為:
你可以在 f-string 表達式中放置任何有效的 Python 表達式。
透過本 PEP 中的變更,無需再澄清字串引號必須與封閉字串的引號不同,因為這現在是被允許的:正如任何任意的 Python 字串可以包含任何可能的引號選擇,任何 f-string 表達式也可以。此外,無需再因實作限制(如註解、換行字元或反斜線)而澄清表達式部分中某些東西是不允許的。
唯一「令人驚訝」的差異是,由於 f-string 允許指定格式,因此在頂層允許 : 字元的表達式仍需用括號括起來。這並非這項工作的新內容,但強調這一限制仍然存在是很重要的。這允許更簡單地修改總結:
你可以在 f-string 表達式中放置任何有效的 Python 表達式,並且在頂層:字元之後的所有內容都將被識別為格式說明。
參考實作
參考實作可以在 實作 分支中找到。
否決的想法
- 雖然我們認為反對在 f-string 表達式中允許引號重用的可讀性論點是有效的且非常重要的,但我們已決定提議不在解析器層級拒絕 f-string 中的引號重用。原因是本 PEP 的基石之一是降低在 CPython 中解析 f-string 的複雜性和維護成本,這不僅會違背該目標,甚至可能使實作比當前更複雜。我們認為禁止引號重用應該在 linter 和程式碼風格工具中完成,而不是在解析器中,就像今天處理語言中其他令人困惑或難以閱讀的構造一樣。
- 我們已決定不解除某些表達式部分需要在頂層將
':'和'!'用括號括起來的限制,例如:>>> f'Useless use of lambdas: { lambda x: x*2 }' SyntaxError: unexpected EOF while parsing
原因是這將引入大量的複雜性,卻沒有帶來真正的實質好處。這是由於
:字元通常用來分隔 f-string 格式規範。此格式規範目前被標記化為字串。由於 tokenizer 必須將:右側的內容標記化為字串或 Token 流,這將不允許解析器區分不同的語義,因為那將需要 tokenizer 回溯並產生一組不同的 Token(即,首先嘗試作為 Token 流,如果失敗,則嘗試作為格式說明符的字串)。由於在頂層允許 lambda 和類似表達式沒有根本優勢,我們已決定保留這些必須在需要時用括號括起來的限制。
>>> f'Useless use of lambdas: { (lambda x: x*2) }'
- 我們已決定(暫時)不允許除了
{{和}}語法之外使用跳脫大括號(\{和\})。雖然 PEP 的作者認為允許跳脫大括號是一個好主意,但我們已決定不將其包含在本 PEP 中,因為它對於此處提議的 f-string 形式化並非絕對必要,並且可以在常規 CPython 問題中獨立添加。
待決問題
尚無
版權
本文件已進入公有領域或遵循 CC0-1.0-Universal 授權,以較寬鬆者為準。
來源:https://github.com/python/peps/blob/main/peps/pep-0701.rst