Following system colour scheme - Python 增強提案 Selected dark colour scheme - Python 增強提案 Selected light colour scheme - Python 增強提案

Python 增強提案

PEP 3103 – Switch/Case 語句

作者:
Guido van Rossum <guido at python.org>
狀態:
已拒絕
型別:
標準跟蹤
建立日期:
25-Jun-2006
Python 版本:
3.0
釋出歷史:
26-Jun-2006

目錄

拒絕通知

在 2007 年 PyCon 大會上,我在主題演講期間進行的一次快速投票顯示,該提案沒有得到普遍支援。因此,我駁回它。

摘要

最近,Python 開發郵件列表上關於新增 switch 語句的討論非常熱烈。在這份 PEP 中,我試圖從各種提案中提煉出我自己的偏好,討論不同的替代方案,並解釋我的選擇。我還將表明我對討論的替代方案有多麼強烈的看法。

此 PEP 應被視為 PEP 275 的替代方案。我的觀點與該 PEP 的作者有些不同,但我感謝該 PEP 所做的努力。

此 PEP 為已討論的語法和語義的許多變體引入了規範名稱,例如“替代方案 1”、“學校 II”、“方案 3”等等。希望這些名稱有助於討論。

基本原理

一個常見的程式設計習慣是考慮一個表示式並根據其值執行不同的操作。這通常透過 if/elif 測試鏈來完成;我將這種形式稱為“if/elif 鏈”。我們希望為這種習慣引入新語法有兩個主要原因:

  • 它很重複:變數和測試運算子(通常是‘==’或‘in’)在每個 if/elif 分支中都會重複出現。
  • 它效率低下:當表示式匹配最後一個測試值(或根本不匹配任何測試值)時,它將與前面每個測試值進行比較。

這兩個抱怨都相對溫和;透過不同的寫法,在可讀性或效能方面並不能獲得太多收益。然而,許多語言中都存在某種形式的 switch 語句,並且可以合理地期望將其新增到 Python 中將允許我們比以往更清晰、更高效地編寫某些程式碼。

有些分派形式不適合擬議的 switch 語句;例如,當 case 的數量不是靜態已知時,或者當希望將不同 case 的程式碼放在不同的類或檔案中時。

基本語法

我在這裡考慮 PEP 275 中首次提出的語法的幾種變體。還有許多其他可能性,但我認為它們並沒有增加什麼。

我最近已經轉向了替代方案 1。

我應該注意到,此處所有替代方案都具有“隱式 break”屬性:在特定 case 的 suite 結束時,控制流會跳轉到整個 switch 語句的末尾。沒有辦法將控制權從一個 case 傳遞到另一個 case。這與 C 不同,在 C 中,需要顯式的‘break’語句來防止“fall through”到下一個 case。

在所有替代方案中,else suite 是可選的。在這裡使用‘else’而不是引入新的保留字‘default’(如 C 中)更符合 Python 的習慣。

語義將在下一個頂級部分討論。

替代方案 1

這是 PEP 275 中首選的形式。

switch EXPR:
    case EXPR:
        SUITE
    case EXPR:
        SUITE
    ...
    else:
        SUITE

主要缺點是,包含所有操作的 suites 會縮排兩級;這可以透過將 cases 縮排“半級”(例如,如果通用縮排級別為 4,則為 2 個空格)來解決。

替代方案 2

這是 Fredrik Lundh 首選的形式;它透過不縮排 cases 來區分。

switch EXPR:
case EXPR:
    SUITE
case EXPR:
    SUITE
....
else:
    SUITE

不選擇它的原因包括對自動縮排編輯器、摺疊編輯器等的預期困難;以及使用者感到困惑。在 Python 中,沒有哪種情況是行尾以冒號開頭,後面跟著一個未縮排的行。

替代方案 3

這與替代方案 2 相同,但省略了 switch 後面的冒號。

switch EXPR
case EXPR:
    SUITE
case EXPR:
    SUITE
....
else:
    SUITE

這個替代方案的希望是它不會像平均 Python 感知文字編輯器那樣擾亂自動縮排邏輯。但對我來說,它看起來很奇怪。

替代方案 4

這省略了‘case’關鍵字,因為它是多餘的。

switch EXPR:
    EXPR:
        SUITE
    EXPR:
        SUITE
    ...
    else:
        SUITE

不幸的是,現在我們被迫縮排 case 表示式,因為否則(至少在沒有‘else’關鍵字的情況下)解析器將很難區分未縮排的 case 表示式(它會繼續 switch 語句)或以表示式開頭的無關語句(例如賦值或過程呼叫)。一旦看到冒號,解析器就不夠智慧以進行回溯。這是我最不喜歡的替代方案。

擴充套件語法

還有一個額外的問題需要透過語法來解決。通常需要兩個或多個值進行相同的處理。在 C 中,這是透過將多個 case 標籤寫在一起而不加任何程式碼來實現的。“fall through”語義意味著這些都會由相同的程式碼處理。由於 Python 的 switch 將沒有 fall-through 語義(這還沒有找到支持者),我們需要另一種解決方案。這裡有一些替代方案。

替代方案 A

使用

case EXPR:

來匹配單個表示式;使用

case EXPR, EXPR, ...:

來匹配多個表示式。這裡的‘is’被解釋為,如果 EXPR 是一個帶括號的元組或其他其值為元組的表示式,則 switch 表示式必須等於該元組,而不是其元素之一。這意味著我們不能使用變數來指示多個 cases。雖然這在 C 的 switch 語句中也存在,但在 Python 中卻相對常見(例如,參見 sre_compile.py)。

替代方案 B

使用

case EXPR:

來匹配單個表示式;使用

case in EXPR_LIST:

來匹配多個表示式。如果 EXPR_LIST 是一個單獨的表示式,‘in’強制將其解釋為可迭代物件(或支援 __contains__ 的物件,在少數語義替代方案中)。如果它由多個表示式組成,則每個表示式都會被考慮進行匹配。

替代方案 C

使用

case EXPR:

來匹配單個表示式;使用

case EXPR, EXPR, ...:

來匹配多個表示式(如替代方案 A);並使用

case *EXPR:

來匹配其值為可迭代物件的表示式的元素。後兩種情況可以合併,因此實際的語法更像是這樣

case [*]EXPR, [*]EXPR, ...:

‘*’符號類似於已用於可變長度引數列表和傳遞計算出的引數列表的‘*’字首的使用,並且經常被提議用於值解包(例如,a, b, *c = X 作為 (a, b), c = X[:2], X[2:] 的替代方案)。

替代方案 D

這是替代方案 B 和 C 的混合;語法類似於替代方案 B,但它使用‘*’而不是‘in’關鍵字。這更有限,但仍允許相同的靈活性。它使用

case EXPR:

來匹配單個表示式,並使用

case *EXPR:

來匹配可迭代物件的元素。如果一個人想指定多個 case,可以這樣寫

case *(EXPR, EXPR, ...):

或者也許是這樣(儘管有點奇怪,因為‘*’和‘,’的相對優先順序與其他地方不同)

case * EXPR, EXPR, ...:

討論

替代方案 B、C 和 D 的動機是希望使用代表集合(通常是元組)的變數來指定具有相同處理的多個 case,而不是一一列舉。這樣做的動機通常是,如果你對同一組 case 有幾個 switch,那麼每次都必須一一列舉所有替代方案是很可惜的。另一個動機是能夠輕鬆高效地指定要匹配的*範圍*,類似於 Pascal 的“1..1000:”表示法。同時,我們希望防止在異常處理中常見的錯誤(Python 3000 將透過更改 except 子句的語法來解決):寫入“case 1, 2:”而本意是“case (1, 2):”,反之亦然。

可以認為,這種需求不足以增加複雜性;C 沒有表達範圍的方法,而且它比 Pascal 更常用。此外,如果選擇基於 dict 查詢的分派方法作為語義,大範圍可能會效率低下(考慮 range(1, sys.maxint))。

總而言之,我的偏好是(從最喜歡到最不喜歡)B、A、D'、C,其中 D' 是 D 去掉第三種可能性。

語義

在選擇正確的語義之前,我們需要審查幾個問題。

If/Elif 鏈 vs. Dict 分派

關於 switch 語句的語義,有幾種主要的思想流派。

  • 第一種思想(School I)希望將 switch 語句定義為等效的 if/elif 鏈(可能加入了一些最佳化)。
  • 第二種思想(School II)傾向於將其視為對預先計算的 dict 進行分派。關於何時進行預計算,有不同的選擇。
  • 還有第三種思想(School III),它同意第一種思想,即 switch 語句的定義應基於等效的 if/elif 鏈,但它承認最佳化陣營,即所有涉及的表示式都必須是可雜湊的。

我們需要進一步將第一種思想分為 Ia 和 Ib。

  • Ia 思想的立場很簡單:switch 語句被轉換為等效的 if/elif 鏈,就這樣。它不應與最佳化掛鉤。這也是我反對這種思想的主要原因:如果沒有任何最佳化提示,switch 語句的吸引力不足以證明新語法的合理性。
  • Ib 思想的立場更復雜:它同意第二種思想,認為最佳化很重要,並且願意賦予編譯器一定的自由度來實現這一點(例如,PEP 275 解決方案 1)。特別地,switch 和 case 表示式的 hash() 可能被呼叫,也可能不被呼叫(因此它應該是無副作用的);並且 case 表示式可能不會每次都像 if/elif 鏈行為預期那樣被評估,因此 case 表示式也應該是無副作用的。我反對這一點(稍後詳述),即如果 hash() 或 case 表示式不是無副作用的,則最佳化程式碼和未最佳化程式碼的行為可能不同。

第二種思想源於這樣的認識:對常見情況進行最佳化並不容易,並且最好直面這個問題。這將在下文中清楚。

第一種思想(主要是 Ib)和第二種思想之間的區別有三方面:

  • 當使用分派 dict 進行最佳化時,如果 switch 表示式或 case 表示式不可雜湊(在這種情況下 hash() 引發異常),Ib 思想要求捕獲 hash() 失敗並回退到 if/elif 鏈。II 思想只是讓異常發生。在 hash() 中捕獲異常(如 Ib 思想所要求的)的問題在於,這可能會隱藏真正的錯誤。一種可能的解決方案是,僅當所有 case 表示式都是 int、string 或其他具有已知良好雜湊行為的內建型別時,才使用分派 dict,並且僅當 switch 表示式也是這些型別之一時才嘗試對其進行雜湊。型別物件可能也應該在這裡支援。這是 III 思想所解決的(唯一)問題。
  • 當使用分派 dict 進行最佳化時,如果任何涉及表示式的 hash() 函式返回錯誤值,在 Ib 思想下,最佳化程式碼的行為將與未最佳化程式碼不同。這是最佳化相關錯誤的一個眾所周知的問題,並浪費了大量開發時間。在 II 思想下,在這種情況下,會產生不正確的結果,但至少是一致的,這應該使除錯更容易。為上一項提出的解決方案也有助於解決此問題。
  • Ib 思想對於 case 表示式是命名常量的情況沒有好的最佳化策略。編譯器無法確定它們的值,也無法確定它們是否是真正的常量。作為一種解決方案,有人提議在 dict 確定應該採取哪個 case 後,重新評估與 case 對應的表示式,以驗證表示式的值是否未改變。但嚴格來說,為了保留真正的 if/elif 鏈語義,所有在此 case 之前的 case 表示式也必須被檢查,從而完全破壞了最佳化。另一個提出的解決方案是使用回撥來通知分派 dict 變數或屬性值(涉及 case 表示式)的變化。但這不太可能在一般情況下實現,並且需要許多名稱空間來承擔支援此類回撥的負擔,而這些回撥目前根本不存在。
  • 最後,關於對重複 case(即兩個或多個 case 具有計算結果相同的匹配表示式)的處理存在意見分歧。I 思想希望將其處理為 if/elif 鏈會如何處理(即第一個匹配獲勝,第二個匹配的程式碼將悄悄地無法到達);II 思想希望將其視為在凍結分派 dict 時出錯(以便死程式碼不會被診斷出來)。

I 思想認為 II 思想預凍結分派 dict 的方法存在問題,因為它給程式設計師增加了新的、不尋常的負擔,讓他們確切地瞭解哪些型別的 case 值被允許凍結以及何時凍結 case 值,否則他們可能會對 switch 語句的行為感到驚訝。

II 思想不認為 Ia 思想的未最佳化 switch 值得付出努力,並且它認為 Ib 思想關於最佳化的提議存在問題,這可能導致最佳化程式碼和未最佳化程式碼的行為不同。

此外,II 思想認為允許涉及不可雜湊值的 case 沒有多大價值;畢竟,如果使用者期望這些值,他們可以輕易地編寫 if/elif 鏈。II 思想也不認為允許因重疊 case 而導致的死程式碼未被標記是正確的,因為基於 dict 的分派實現使得捕獲它如此容易。

然而,重疊/重複 case 存在一些用例。假設你正在根據一些特定於作業系統的常量(例如,由 os 模組或類似模組匯出的)進行 switch。每個常量都有一個 case。但在某些作業系統上,兩個不同的常量具有相同的值(因為在這些作業系統上,它們的實現方式相同——就像 Unix 上的 O_TEXT 和 O_BINARY)。如果將重複 case 標記為錯誤,則在這些作業系統上,你的 switch 將根本無法工作。如果你能夠安排 cases,使得一個 case 比另一個 case 具有更高的優先順序,那將要好得多。

還有(更有可能)的用例是,你需要對一組 cases 進行相同處理,但其中一個成員需要進行不同處理。將異常放在前面的 case 中並處理掉會很方便。

(是的,未能診斷意外 case 重複導致的死程式碼似乎很可惜。也許這不那麼重要,並且 pychecker 可以處理它?畢竟,我們也沒有診斷重複的方法定義。)

這表明了 IIb 思想:類似於 II 思想,但冗餘 case 必須透過選擇第一個匹配來解決。在構建分派 dict 時(跳過已存在的鍵)這很容易實現。

(另一種選擇是引入新語法來指示“允許重疊 case”或“即使此 case 是死程式碼也沒關係”,但我認為這有點過度。)

就我個人而言,我屬於 II 思想:我相信基於 dict 的分派是 switch 語句的唯一真正實現,我們應該正面面對其限制,以便獲得最大的好處。我傾向於 IIb 思想——重複的 case 應該透過 case 的順序來解決,而不是被標記為錯誤。

何時凍結分派字典

對於 II 思想(基於 dict 的分派)的支持者來說,下一個大的分歧點是何時建立用於切換的 dict。我稱之為“凍結 dict”。

使這個問題變得有趣的主要原因是 Python 沒有命名的編譯時常量。概念上是常量的東西,例如 re.IGNORECASE,對編譯器來說是一個變數,沒有任何東西可以阻止惡意程式碼修改其值。

方案 1

最受限制的選項是在編譯器中凍結 dict。這將要求 case 表示式全部是字面量或僅包含字面量和運算子的編譯時表示式,其語義是編譯器已知的,因為以 Python 當前的動態語義和單模組編譯狀態,編譯器無法足夠確定地知道這些表示式中出現的任何變數的值。這被廣泛(但不普遍)認為過於嚴格。

Raymond Hettinger 是這種方法的主要倡導者。他提出了一種語法,其中 case 表示式只允許是特定型別的單個字面量。它的優點是明確且易於實現。

我對這個的主要抱怨是,透過不允許“命名常量”,我們迫使程式設計師放棄了良好的習慣。在大多數語言中引入命名常量是為了解決原始碼中出現“魔法數字”的問題。例如,sys.maxint 比 2147483647 更具可讀性。Raymond 提議使用字串字面量代替命名的“列舉”,並指出字串字面量的內容可以是該常量否則將具有的名稱。因此,我們可以寫“case ‘IGNORECASE’:”而不是“case re.IGNORECASE:”然而,如果字串字面量中有拼寫錯誤,該 case 將被悄悄忽略,誰知道何時會檢測到錯誤。然而,如果在 NAME 中有拼寫錯誤,則在求值時就會捕獲該錯誤。此外,有時常量是外部定義的(例如,在解析 JPEG 等檔案格式時),我們無法輕鬆選擇合適的字串值。使用顯式對映 dict 聽起來像是一個糟糕的 hack。

方案 2

處理此問題的最古老的提議是,在 switch 第一次執行時凍結分派 dict。此時,我們可以假設所有用作 case 表示式的命名“常量”(在程式設計師心中是常量,但對編譯器不是)都已定義——否則 if/elif 鏈成功的機會也很小。假設 switch 將被執行多次,第一次執行時做一些額外的工作,透過稍後非常快的排程時間來快速獲得回報。

這個選項的一個反對意見是,沒有明顯的物件可以儲存分派 dict。它不能儲存在程式碼物件上,程式碼物件應該是不可變的;它不能儲存在函式物件上,因為對於同一個函式可能會建立許多函式物件(例如,對於巢狀函式)。實際上,我確定可以找到解決辦法;它可以儲存在程式碼物件的某個部分,當比較兩個程式碼物件或進行 pickling 或 marshalling 程式碼物件時不考慮該部分;或者所有 switches 都可以儲存在一個以程式碼物件弱引用為索引的 dict 中。解決方案還應注意不要在多個直譯器之間洩漏 switch dict。

另一個反對意見是,第一次使用規則允許混淆程式碼,如下所示:

def foo(x, y):
    switch x:
    case y:
        print 42

對於未經訓練的人(不熟悉 Python)來說,這段程式碼將等同於這段程式碼

def foo(x, y):
    if x == y:
        print 42

但它實際上並非如此(除非它總是用第二個引數相同的值呼叫)。這已經透過建議 case 表示式不應引用區域性變數來解決,但這有些武斷。

最後一個反對意見是,在多執行緒應用程式中,第一次使用規則需要複雜的鎖定才能保證正確的語義。(第一次使用規則暗示著 case 表示式的副作用僅發生一次。)這可能與 import lock 被證明的那樣棘手,因為在評估所有 case 表示式時必須持有鎖。

方案 3

一個正在獲得支援(包括我的支援)的提議是,在包含 switch 的最內層函式被定義時凍結 switch 的 dict。switch dict 儲存在函式物件上,就像引數預設值一樣,事實上 case 表示式在同一時間和相同的作用域中被評估,就像引數預設值一樣(即,在包含函式定義的範圍內)。

這個選項的優點是避免了使選項 2 工作所需的許多精妙之處:無需鎖定,無需擔心不可變的程式碼物件或多個直譯器。它還為 locals 不能在 case 表示式中引用的原因提供了清晰的解釋。

此選項同樣適用於通常使用 switch 的情況;涉及匯入的或全域性的命名常量的情況表示式與選項 2 中的工作方式完全相同,只要它們在遇到函式定義之前被匯入或定義。

然而,一個缺點是,巢狀函式內的 switch 的分派 dict 必須在每次定義巢狀函式時重新計算。對於某些“函式式”程式設計風格,這可能會使 switch 在巢狀函式中不具吸引力。(除非所有 case 表示式都是編譯時常量;那麼編譯器當然可以自由地最佳化掉 switch 凍結程式碼,並將分派表作為程式碼物件的一部分。)

另一個缺點是,根據此選項,對於不在函式內的 switch,沒有一個明確的凍結分派 dict 的時刻。對於如何處理函式外的 switch,有幾種實際的選擇:

  1. 不允許。
  2. 將其轉換為 if/elif 鏈。
  3. 只允許編譯時常量表達式。
  4. 每次到達 switch 時計算分派 dict。
  5. 類似於 (b) 但測試所有求值的表示式都是可雜湊的。

其中,(a) 似乎過於嚴格:它比 (c) 普遍更差;而 (d) 的效能比 (b) 差,但收益很小或沒有收益。在模組級別有一個性能關鍵的內層迴圈沒有意義,因為所有區域性變數的引用在那裡都很慢;因此 (b) 是我的(微弱)最愛。也許我應該贊成 (e),它試圖防止 switch 的非典型使用;在互動模式下有效但在函式中無效的示例很令人討厭。最終,我認為這個問題並不那麼重要(除非它必須以某種方式解決),並且我願意將其留給最終實現它的人。

當 switch 出現在類中但不在函式中時,我們可以在建立表示類體的臨時函式物件的同時凍結分派 dict。這意味著 case 表示式可以引用模組全域性變數,但不能引用類變數。或者,如果我們選擇上面的 (b),我們也可以在類定義內部選擇這種實現。

方案 4

有許多提案向該語言新增一個構造,該構造使得在函式定義時預先計算的值的概念普遍可用,而無需將其與引數預設值或 case 表示式繫結。一些提議的關鍵字包括‘const’、‘static’、‘only’或‘cached’。相關的語法和語義各不相同。

這些提案超出了此 PEP 的範圍,除非建議*如果*接受此類提案,switch 有兩種受益方式:我們可以要求 case 表示式為編譯時常量或預先計算的值;或者我們可以將預先計算的值作為 case 表示式的預設(且唯一)求值模式。後者將是我的偏好,因為我認為沒有比編寫顯式 if/elif 鏈更適合動態 case 表示式的用途了。

結論

現在下結論還為時過早。我希望至少看到一個關於預先計算值的完成的提案,然後再做決定。在此期間,Python 沒有 switch 語句也很好,也許那些聲稱新增它是錯誤的人是正確的。


來源:https://github.com/python/peps/blob/main/peps/pep-3103.rst

最後修改:2025-02-01 08:55:40 GMT