PEP 698 – 靜態型別檢查的 override 裝飾器
- 作者:
- 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 帖子
- 狀態:
- 最終版
- 型別:
- 標準跟蹤
- 主題:
- 型別標註
- 建立日期:
- 2022年9月5日
- Python 版本:
- 3.12
- 釋出歷史:
- 2022年5月20日, 2022年8月17日, 2022年10月11日, 2022年11月7日
- 決議:
- Discourse 訊息
摘要
本 PEP 建議在 Python 型別系統中新增一個 @override 裝飾器。這將允許型別檢查器防止當基類更改派生類繼承的方法時發生的一類錯誤。
動機
型別檢查器的一個主要目的是在重構或更改破壞程式碼中預先存在的語義結構時進行標記,以便使用者可以在整個專案中識別並進行修復,而無需手動審計程式碼。
安全重構
Python 的型別系統沒有提供一種方法來識別當被重寫的函式 API 更改時需要更改的呼叫站點。這使得重構和轉換程式碼更加危險。
考慮這個簡單的繼承結構
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.foo的行為方式與Parent.foo類似。但除非我們及早發現這一點,否則我們現在已經分叉了這些方法,並且在未來的重構中,很可能沒有人會意識到new_foo行為的重大更改可能也需要更新Child.foo,這可能導致以後出現重大錯誤。
錯誤重構的程式碼是型別安全的,但可能不是我們想要的,並且可能導致我們的系統行為不正確。這個錯誤可能難以追蹤,因為我們的新程式碼很可能在不丟擲異常的情況下執行。測試不太可能發現問題,而無聲錯誤可能需要更長時間才能在生產環境中追蹤到。
我們知道多個型別化程式碼庫中發生的幾起生產中斷事故是由此類不正確重構引起的。這是我們向型別系統新增 @override 裝飾器的主要動機,它允許開發人員表達 Parent.foo 和 Child.foo 之間的關係,以便型別檢查器可以檢測問題。
基本原理
子類實現變得更加明確
我們相信,顯式重寫將使不熟悉的程式碼比隱式重寫更容易閱讀。開發人員閱讀使用 @override 的子類實現時,可以立即看到哪些方法正在重寫某個基類中的功能;如果沒有這個裝飾器,快速發現的唯一方法是使用靜態分析工具。
其他語言和執行時庫中的先例
其他語言中的靜態重寫檢查
許多流行的程式語言支援重寫檢查。例如:
Python 中的執行時重寫檢查
目前,有一個 Overrides 庫 提供了裝飾器 @overrides [原文如此] 和 @final,並將在執行時強制執行它們。
PEP 591 添加了一個 @final 裝飾器,其語義與 Overrides 庫中的相同。但是執行時庫的重寫元件完全不支援靜態檢查,這給混合/匹配支援帶來了一些困惑。
在靜態檢查中提供對 @override 的支援將增加價值,因為
- 錯誤可以更早地被捕獲,通常在編輯器中。
- 靜態檢查沒有效能開銷,這與執行時檢查不同。
- 即使在很少使用的模組中,錯誤也會很快被發現,而使用執行時檢查,這些錯誤可能在沒有所有匯入的自動化測試的情況下 undetected 一段時間。
缺點
使用 @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 中,型別檢查器總是將重寫視為可選的,但廣泛使用的 linter 可以警告缺少顯式重寫的情況。
向後相容性
預設情況下,@override 裝飾器將是可選的。不使用它的程式碼庫將像以前一樣進行型別檢查,而不會增加額外的型別安全。
執行時行為
在可能的情況下設定 __override__ = True
在執行時,@typing.override 將盡力嘗試向其引數新增一個值為 True 的屬性 __override__。這裡的“盡力”是指我們將嘗試新增屬性,但如果失敗(例如因為輸入是具有固定槽的描述符型別),我們將靜默返回原引數。
這與 @typing.final 裝飾器所做的完全相同,動機也類似:它賦予執行時庫使用 @override 的能力。作為一個具體示例,執行時庫可以檢查 __override__ 以便使用父方法文件字串自動填充子類方法的 __doc__ 屬性。
設定 __override__ 的限制
如上所述,新增 __override__ 在執行時可能會失敗,在這種情況下,我們將簡單地按原樣返回引數。
此外,即使在有效的情況下,使用者也可能難以正確處理多個裝飾器,因為成功確保 __override__ 屬性設定在最終輸出上需要理解每個裝飾器的實現
@override裝飾器需要 在 使用包裝器函式的普通裝飾器(如@functools.lru_cache)之後 執行,因為我們希望在最外層包裝器上設定__override__。這意味著它需要 位於 所有這些其他裝飾器之上。- 但是
@override需要 在 許多基於描述符的特殊裝飾器(如@property、@staticmethod和@classmethod)之前 執行。 - 如上所述,在某些情況下(例如具有固定槽的描述符或也進行包裝的描述符),可能根本無法設定
__override__屬性。
因此,設定 __override__ 的執行時支援僅是盡力而為,我們不期望型別檢查器驗證裝飾器的順序。
被拒絕的替代方案
依靠整合開發環境(IDE)確保安全
現代整合開發環境(IDE)通常提供在重新命名方法時自動更新子類的功能。但我們認為這不足以解決幾個原因
- 如果程式碼庫被拆分為多個專案,IDE 將無濟於事,並且在升級依賴項時會出現錯誤。型別檢查器是快速捕獲依賴項中破壞性更改的方法。
- 並非所有開發人員都使用此類 IDE。即使庫維護者使用 IDE,也不應假定拉取請求作者使用相同的 IDE。我們更喜歡能夠在持續整合中檢測問題,而不對開發人員的編輯器選擇做任何假設。
執行時強制
我們曾考慮讓 @typing.override 在執行時強制執行重寫安全,類似於 @overrides.overrides 今天 所做的那樣。
我們拒絕了這一點,原因有四
- 對於靜態型別檢查的使用者來說,這是否能帶來任何好處尚不清楚。
- 將至少存在一些效能開銷,導致專案在執行時強制執行時匯入速度變慢。我們估計
@overrides.overrides的實現大約需要 100 微秒,這很快,但在百萬行以上的程式碼庫中仍然可能增加一秒或更多的額外初始化時間,而這正是我們認為@typing.override最有用的地方。 - 實現可能存在一些邊緣情況,使其無法很好地工作(我們從一個此類閉源庫的維護者那裡聽說這是一個問題)。我們期望靜態強制執行是簡單可靠的。
- 我們所知的實現方法並不簡單。裝飾器在類完成評估之前執行,因此我們所知的選項要麼是檢查呼叫者的位元組碼(如
@overrides.overrides所做),要麼是使用基於元類的方法。這兩種方法似乎都不是理想的。
標記基類以強制子類進行顯式重寫
我們曾考慮包含一個類裝飾器 @require_explicit_overrides,它將提供一種方式讓基類宣告所有子類在方法重寫時必須使用 @override 裝飾器。Overrides 庫 有一個混合類 EnforceExplicitOverrides,它在執行時檢查中提供類似的行為。
我們反對這樣做,因為我們預計大型程式碼庫的所有者將從 @override 中獲益最多,而對於這些用例,強制要求顯式 @override 的嚴格模式(參見“向後相容性”部分)比標記基類的方法提供更多好處。
此外,我們認為,那些認為額外的型別安全不值得使用 @override 帶來的額外樣板的專案的作者不應被迫這樣做。擁有一個可選的嚴格模式將決定權交到專案所有者手中,而庫中 @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 中實現了一個概念驗證
- 裝飾器 @pyre_extensions.override 可以標記重寫
- Pyre 可以 按照此 PEP 的規定對該裝飾器進行型別檢查
版權
本文件置於公共領域或 CC0-1.0-Universal 許可證下,以更寬鬆者為準。
來源:https://github.com/python/peps/blob/main/peps/pep-0698.rst