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

Python 增強提案 (Python Enhancement Proposals)

PEP 698 – 用於靜態型別檢查的覆寫修飾器

作者:
Steven Troxler <steven.troxler at gmail.com>, Joshua Xu <jxu425 at fb.com>, Shannon Zhu <szhu at fb.com>
贊助人:
Jelle Zijlstra <jelle.zijlstra at gmail.com>
討論於:
Discourse 討論串
狀態:
最終 (Final)
類型:
標準軌跡 (Standards Track)
主題:
類型標註 (Typing)
建立日期:
2022年9月5日
Python 版本:
3.12
公告歷史:
2022年5月20日, 2022年8月17日, 2022年10月11日, 2022年11月7日
決議:
Discourse 訊息

目錄

重要資訊

本 PEP 是一份歷史文件:請參閱 @override@typing.override 以取得最新的規範與說明文件。正規的型別規範維護於 typing 規範網站;執行時期的型別行為則描述於 CPython 文件中。

×

有關如何提議更改型別規格,請參閱 typing 規格更新流程

摘要

本 PEP 提議在 Python 型別系統中加入一個 @override 修飾器。這將允許型別檢查器防止因基底類別修改了衍生類別所繼承的方法而導致的一類錯誤。

動機

型別檢查器的主要目的之一,是在程式碼進行重構或變更時,標記出破壞了既有語意結構的部分,這樣使用者就能在不需手動審核程式碼的情況下,識別並修正整個專案中的問題。

安全的重構

Python 的型別系統目前無法識別當覆寫函式的 API 變更時,有哪些呼叫點(call sites)需要隨之更新以保持一致。這使得重構與轉換程式碼變得更加危險。

考慮以下簡單的繼承結構

class Parent:
    def foo(self, x: int) -> int:
        return x

class Child(Parent):
    def foo(self, x: int) -> int:
        return x + 1

def parent_callsite(parent: Parent) -> None:
    parent.foo(1)

def child_callsite(child: Child) -> None:
    child.foo(1)

如果超類別上被覆寫的方法被重新命名或刪除,型別檢查器只會提醒我們更新直接處理該基底型別的呼叫點。但型別檢查器只能看到新的程式碼,而無法得知我們所做的變更,因此它無法知道我們可能也需要重新命名子類別上的相同方法。

即便我們可能正在引入錯誤,型別檢查器仍會欣然接受這段程式碼

class Parent:
    # Rename this method
    def new_foo(self, x: int) -> int:
        return x

class Child(Parent):
    # This (unchanged) method used to override `foo` but is unrelated to `new_foo`
    def foo(self, x: int) -> int:
        return x + 1

def parent_callsite(parent: Parent) -> None:
    # If we pass a Child instance we’ll now run Parent.new_foo - likely a bug
    parent.new_foo(1)

def child_callsite(child: Child) -> None:
    # We probably wanted to invoke new_foo here. Instead, we forked the method
    child.foo(1)

這段程式碼雖然能通過型別檢查,但存在兩個潛在的錯誤來源

  • 如果我們將一個 Child 實例傳遞給 parent_callsite 函式,它將會呼叫 Parent.new_foo 中的實作,而不是 Child.foo。這很可能是個錯誤——如果我們不需要自訂行為,當初大概也不會寫出 Child.foo
  • 我們的系統可能依賴於 Child.fooParent.foo 的行為方式相似。但除非我們及早發現,否則我們現在已經將方法分岔(forked),且在未來的重構中,很可能沒人會意識到對 new_foo 行為的重大變更可能也需要更新 Child.foo,這可能導致日後出現重大錯誤。

重構錯誤的程式碼在型別上是安全的,但通常並非我們的本意,且可能導致系統行為異常。這類錯誤很難追蹤,因為我們的新程式碼很可能在執行時不會拋出例外。測試較難捕捉這類問題,而隱性錯誤在生產環境中可能需要更久才能被發現。

我們已知在多個使用型別系統的程式碼庫中,曾因這類錯誤重構而導致生產環境中斷。這是我們將 @override 修飾器加入型別系統的主要動機,它讓開發者能明確表達 Parent.fooChild.foo 之間的關係,以便型別檢查器偵測這類問題。

原理

子類別實作變得更明確

我們認為相較於隱性覆寫,明確的覆寫會使不熟悉的程式碼更容易閱讀。開發者在閱讀使用 @override 的子類別實作時,能立刻看出哪些方法正在覆寫基底類別的功能;沒有這個修飾器,唯一能快速得知的方法就是使用靜態分析工具。

其他語言與執行時期函式庫的先例

其他語言中的靜態覆寫檢查

許多熱門程式語言都支援覆寫檢查。例如

Python 中的執行時期覆寫檢查

目前,已有一個 Overrides 函式庫 提供了 @overrides [原文如此] 與 @final 修飾器,並在執行時期強制執行它們。

PEP 591 加入了一個與 Overrides 函式庫語意相同的 @final 修飾器。但該執行時期函式庫的覆寫元件並未受到靜態支援,這在混合與匹配使用時造成了一些混淆。

提供靜態檢查對 @override 的支援具有價值,因為

  • 錯誤可以更早被捕捉,通常在編輯器中即可發現。
  • 與執行時期檢查不同,靜態檢查不會帶來效能開銷。
  • 即使在極少使用的模組中,錯誤也能被快速捕捉,而使用執行時期檢查,若沒有對所有匯入內容進行自動化測試,這些問題可能會在一段時間內未被偵測到。

缺點

使用 @override 會使程式碼變得較為冗長。

規範

當型別檢查器遇到由 @typing.override 修飾的方法時,除非該方法確實覆寫了某個祖先類別中的相容方法或屬性,否則應視為型別錯誤。

from typing import override

class Parent:
    def foo(self) -> int:
        return 1

    def bar(self, x: str) -> str:
        return x

class Child(Parent):
    @override
    def foo(self) -> int:
        return 2

    @override
    def baz(self) -> int:  # Type check error: no matching signature in ancestor
        return 1

@override 修飾器應允許用於任何型別檢查器認定方法為有效覆寫的地方,這通常不僅包含普通方法,也包含 @property@staticmethod@classmethod

沒有針對覆寫相容性的新規則

本 PEP 專注於處理新的 @override 修飾器,該修飾器明確指定受修飾方法必須覆寫祖先類別中的某個屬性。本 PEP 不提議針對此類方法的型別簽章制定任何新規則。

專案級別的嚴格執行

我們認為如果型別檢查器同時允許開發者選擇啟用「嚴格模式」,要求所有覆寫父類別的方法都必須使用該修飾器,@override 將發揮最大效益。為了向後相容,嚴格執行應設為可選啟用。

動機

要求使用 @override 的嚴格模式的主要理由是:只有在確保整個專案皆使用了 @override 修飾器的情況下,開發者才能信任重構是覆寫安全的。

還有另一類與覆寫相關的錯誤,我們只能透過嚴格模式來捕捉。

考慮以下程式碼

class Parent:
    pass

class Child(Parent):
    def foo(self) -> int:
        return 2

試想我們將其重構如下

class Parent:
    def foo(self) -> int:   # This method is new
        return 1

class Child(Parent):
    def foo(self) -> int:  # This is now an override!
        return 2

def call_foo(parent: Parent) -> int:
    return parent.foo()  # This could invoke Child.foo, which may be surprising.

此處我們程式碼的語意已改變,這可能導致兩個問題

  • 如果程式碼變更的作者不知道 Child.foo 已經存在(這在大程式碼庫中很有可能發生),他們可能會驚訝地發現 call_foo 並不總是呼叫 Parent.foo
  • 如果程式碼庫作者在撰寫子類別的覆寫時,嘗試手動到處套用 @override,他們很可能會遺漏 Child.foo 在此處也需要該修飾器的事實。

乍看之下這類變更似乎不太可能發生,但如果開發者後來意識到一或多個子類別的功能其實屬於基底類別,這種情況實際上常會發生。

透過嚴格模式,我們總能在這種情況發生時提醒開發者。

先例

我們所研究的大多數具備型別系統的物件導向程式語言,都有簡單的方法可以在整個專案中強制要求明確的覆寫

  • C#、Kotlin、Scala 與 Swift 總是要求明確的覆寫
  • TypeScript 擁有一個 –no-implicit-override 旗標來強制進行明確覆寫
  • 在 Hack 與 Java 中,型別檢查器總是將覆寫視為可選(opt-in),但廣泛使用的檢查工具(linters)可以在缺少明確覆寫時發出警告。

向後相容性

預設情況下,@override 修飾器將是可選的。不使用它的程式碼庫將如同以往進行型別檢查,而不會獲得額外的型別安全性。

運行時行為 (Runtime Behavior)

儘可能設定 __override__ = True

在執行時期,@typing.override 將盡最大努力為其參數增加一個值為 True__override__ 屬性。所謂「盡最大努力」是指我們會嘗試新增該屬性,但如果失敗(例如輸入參數為具有固定 slots 的描述器型別),我們將悄悄地直接回傳該參數。

這與 @typing.final 修飾器的運作完全相同,且動機也相似:它賦予執行時期函式庫使用 @override 的能力。具體而言,執行時期函式庫可以檢查 __override__,以便利用父類別方法的 docstring 自動填充子類別方法的 __doc__ 屬性。

設定 __override__ 的限制

如上所述,在執行時期新增 __override__ 可能會失敗,在這種情況下,我們將直接回傳該參數。

此外,即使在運作順利的情況下,使用者也可能難以正確處理多個修飾器,因為要確保最終產出的物件正確設定了 __override__ 屬性,需要了解每個修飾器的實作細節。

  • @override 修飾器需要在像 @functools.lru_cache 這樣使用包裝函式(wrapper functions)的普通修飾器*之後*執行,因為我們希望在最外層的包裝器上設定 __override__。這意味著它必須放置在所有其他修飾器*之上*。
  • @override 又需要在許多基於描述器(descriptor-based)的特殊修飾器(如 @property@staticmethod@classmethod)*之前*執行。
  • 如上所述,在某些情況下(例如帶有固定 slots 的描述器或同樣進行包裝的描述器),根本無法設定 __override__ 屬性。

因此,針對設定 __override__ 的執行時期支援僅是「盡最大努力」,且我們不期望型別檢查器去驗證修飾器的順序。

被拒絕的替代方案

依賴整合開發環境 (IDE) 進行安全性檢查

現代整合開發環境 (IDE) 通常提供在重新命名方法時自動更新子類別的能力。但我們認為這是不夠的,原因有幾點

  • 如果程式碼庫被拆分為多個專案,IDE 將無法提供協助,導致升級相依性時才出現錯誤。型別檢查器是捕捉相依性中破壞性變更的快速手段。
  • 並非所有開發者都使用這類 IDE。且函式庫維護者不應假設 Pull Request 的作者使用了與自己相同的 IDE。我們傾向於在持續整合(CI)中偵測問題,而無需對開發者的編輯器選擇做任何假設。

執行時期強制執行

我們曾考慮讓 @typing.override 在執行時期強制執行覆寫安全性,類似於現今 @overrides.overrides 的作法。

我們否決了此方案,原因有四

  • 對於靜態型別檢查的使用者而言,此作法無法確定能帶來任何效益。
  • 至少會帶來一些效能開銷,導致專案在匯入時變慢。我們估計 @overrides.overrides 的實作大約需要 100 微秒,這雖快,但在百萬行等級的程式碼庫中可能累積高達一秒或更多的額外初始化時間,而這正是我們認為 @typing.override 最有價值的地方。
  • 該實作方式可能在某些邊緣情況下無法良好運作(我們從某個封閉原始碼函式庫的維護者處聽聞這確實是個問題)。我們預期靜態強制執行應是簡單且可靠的。
  • 我們所知的實作方法並不簡單。修飾器在類別評估完成前執行,因此我們所知的選項不是檢查呼叫者的位元組碼(如同 @overrides.overrides),就是使用基於元類別(metaclass)的方法。這兩種方法看起來都不理想。

標記基底類別以強制子類別進行明確的覆寫

我們考慮過包含一個類別修飾器 @require_explicit_overrides,它提供基底類別一種宣告方式,要求所有子類別必須在方法覆寫時使用 @override 修飾器。Overrides 函式庫有一個混入類別 (mixin class) EnforceExplicitOverrides,在執行時期檢查中提供了類似行為。

我們決定不採用此方案,因為我們預期大型程式碼庫的擁有者將從 @override 中獲益最多,而在這些使用場景中,擁有一個要求明確使用 @override 的嚴格模式(請參閱「向後相容」章節)比單純標記基底類別能提供更多好處。

此外,我們相信不認為額外的型別安全性值得使用 @override 所帶來的額外樣板程式碼(boilerplate)的專案作者,不應被強制這樣做。擁有可選的嚴格模式將決策權交還給專案擁有者,而如果在函式庫中使用 @require_explicit_overrides,則會迫使專案擁有者即便不願也必須使用 @override

包含被覆寫的祖先類別名稱

我們考慮過允許 @override 的呼叫者指定被覆寫方法應該定義在哪個特定的祖先類別中

class Parent0:
    def foo(self) -> int:
        return 1


class Parent1:
    def bar(self) -> int:
        return 1


class Child(Parent0, Parent1):
    @override(Parent0)  # okay, Parent0 defines foo
    def foo(self) -> int:
        return 2

    @override(Parent0)  # type error, Parent0 does not define bar
    def bar(self) -> int:
        return 2

這對程式碼可讀性可能有用,因為它使深度繼承樹的覆寫結構更明確。當被覆寫的方法從一個基底類別移動到另一個時,它也可能透過提醒開發者檢查覆寫實作是否仍然合理來捕捉錯誤。

我們否決了它,因為

  • 支援此功能將增加 @override 與型別檢查器支援實作的複雜度,因此除非有相當大的益處,否則不應考慮。
  • 我們相信此功能將很少被使用,且捕捉到的錯誤相對較少。
    • Overrides 套件的作者曾指出,他的函式庫早期版本曾包含此能力,但很少派上用場,且效益似乎很小。在他將其移除後,也未曾有使用者要求恢復該功能。

參考實作

Pyre:Pyre 中已實作了一個概念驗證 (proof of concept)


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

最後修改:2024-06-11 22:12:09 GMT