PEP 705 – TypedDict: 唯讀項目
- 作者:
- Alice Purcell <alicederyn at gmail.com>
- 贊助人:
- Pablo Galindo <pablogsal at gmail.com>
- 討論於:
- Discourse 討論串
- 狀態:
- 最終 (Final)
- 類型:
- 標準軌跡 (Standards Track)
- 主題:
- 類型標註 (Typing)
- 建立日期:
- 2022年11月7日
- Python 版本:
- 3.13
- 公告歷史:
- 2022年9月30日, 2022年11月2日, 2023年3月14日, 2023年10月17日, 2023年11月4日
- 決議:
- 2024年2月29日
摘要
PEP 589 定義了用於具有固定鍵集合的字典的結構化類型 TypedDict。由於 TypedDict 是一種可變類型,很難在不阻礙有效輸入的情況下,正確標註接收唯讀參數的方法。
本 PEP 提議新增一個類型限定符 typing.ReadOnly 以支援這些用法。此提案不會對 Python 語法進行更改。TypedDict 唯讀鍵的正確使用旨在僅由靜態類型檢查器強制執行,不會在運行時由 Python 本身執行。
動機
使用具有字串鍵的(可能包含巢狀的)字典來表示結構化資料是 Python 程式中的常見模式。PEP 589 允許在預先確知確切類型時對這些值進行類型檢查,但編寫接收更具體變體的唯讀程式碼很困難:例如,值可能是子類型或限制了可能類型的聯集。這在編寫服務 API 時是一個常見問題,因為這些 API 可能支援廣泛的輸入結構,且通常不需要修改其輸入。
純函數 (Pure functions)
考慮嘗試為函數 movie_string 添加類型提示
def movie_string(movie: Movie) -> str:
if movie.get("year") is None:
return movie["name"]
else:
return f'{movie["name"]} ({movie["year"]})'
我們可以使用 TypedDict 定義此 Movie 類型
from typing import NotRequired, TypedDict
class Movie(TypedDict):
name: str
year: NotRequired[int | None]
但假設我們有另一個需要 year 欄位的類型
class MovieRecord(TypedDict):
name: str
year: int
嘗試將 MovieRecord 傳入 movie_string 會導致錯誤(使用 mypy)
Argument 1 to "movie_string" has incompatible type "MovieRecord"; expected "Movie"
此特定用例本應是型別安全的,但類型檢查器正確地阻止了使用者在一般情況下將 MovieRecord 傳入 Movie 參數,因為 Movie 類別擁有變異方法,可能會允許函數破壞 MovieRecord 中的類型約束(例如透過 movie["year"] = None 或 del movie["year"])。如果我們在 Movie 中沒有變異方法,問題就會消失。這可以透過使用 PEP 544 Protocol 定義一個不可變介面來實現
from typing import Literal, Protocol, overload
class Movie(Protocol):
@overload
def get(self, key: Literal["name"]) -> str: ...
@overload
def get(self, key: Literal["year"]) -> int | None: ...
@overload
def __getitem__(self, key: Literal["name"]) -> str: ...
@overload
def __getitem__(self, key: Literal["year"]) -> int | None: ...
這非常冗長且容易出錯,並且仍然缺少重要的方法定義,例如 __contains__() 和 keys()。
更新巢狀字典
TypedDict 的結構化類型理應允許編寫僅限制其所修改項目類型的更新函數
class HasTimestamp(TypedDict):
timestamp: float
class Logs(TypedDict):
timestamp: float
loglines: list[str]
def update_timestamp(d: HasTimestamp) -> None:
d["timestamp"] = now()
def add_logline(logs: Logs, logline: str) -> None:
logs["loglines"].append(logline)
update_timestamp(logs) # Accepted by type checker
然而,一旦開始巢狀字典,這就不再起作用了
class HasTimestampedMetadata(TypedDict):
metadata: HasTimestamp
class UserAudit(TypedDict):
name: str
metadata: Logs
def update_metadata_timestamp(d: HasTimestampedMetadata) -> None:
d["metadata"]["timestamp"] = now()
def rename_user(d: UserAudit, name: str) -> None:
d["name"] = name
update_metadata_timestamp(d) # Type check error: "metadata" is not of type HasTimestamp
這看起來像是一個錯誤,但實際上僅僅是因為(非預期的)覆寫能力,即 HasTimestampedMetadata 實例所持有的 metadata 項目被另一個 HasTimestamp 實例覆寫,而後者可能不再是 Logs 實例。
雖然自 Python 3.11 起可以使用泛型解決此問題,但過程非常複雜,需要為每個巢狀字典設定一個類型參數。
原理
這些問題可以透過移除更新 TypedDict 中一個或多個項目的能力來解決。這並不意味著項目是不可變的:對底層字典的引用仍可能以不同的但相容的類型存在,且該類型中這些項目具有變異操作。這些項目是「唯讀的」,我們為此引入了一個新的 typing.ReadOnly 類型限定符。
第一個激勵性範例中的 movie_string 函數隨後可標註如下
from typing import NotRequired, ReadOnly, TypedDict
class Movie(TypedDict):
name: ReadOnly[str]
year: ReadOnly[NotRequired[int | None]]
def movie_string(movie: Movie) -> str:
if movie.get("year") is None:
return movie["name"]
else:
return f'{movie["name"]} ({movie["year"]})'
允許混合使用唯讀和非唯讀項目,從而使第二個激勵性範例能夠被正確標註
class HasTimestamp(TypedDict):
timestamp: float
class HasTimestampedMetadata(TypedDict):
metadata: ReadOnly[HasTimestamp]
def update_metadata_timestamp(d: HasTimestampedMetadata) -> None:
d["metadata"]["timestamp"] = now()
class Logs(HasTimestamp):
loglines: list[str]
class UserAudit(TypedDict):
name: str
metadata: Logs
def rename_user(d: UserAudit, name: str) -> None:
d["name"] = name
update_metadata_timestamp(d) # Now OK
除了這些好處之外,透過將函數參數標記為唯讀(透過使用具有唯讀項目的 TypedDict,如 Movie),不僅向類型檢查器,也向使用者明確表示該函數不會修改其輸入,這通常是函數介面中理想的屬性。
本 PEP 提議僅在 TypedDict 中使 ReadOnly 有效。未來的一個可能擴展是在其他上下文(例如協議)中支援它。
規範
新增了一個新的 typing.ReadOnly 類型限定符。
typing.ReadOnly 類型限定符
typing.ReadOnly 類型限定符用於指示在 TypedDict 定義中聲明的項目不可被變異(添加、修改或刪除)
from typing import ReadOnly
class Band(TypedDict):
name: str
members: ReadOnly[list[str]]
blur: Band = {"name": "blur", "members": []}
blur["name"] = "Blur" # OK: "name" is not read-only
blur["members"] = ["Damon Albarn"] # Type check error: "members" is read-only
blur["members"].append("Damon Albarn") # OK: list is mutable
替代函數式語法
TypedDict 的 替代函數式語法 也支援新的類型限定符
Band = TypedDict("Band", {"name": str, "members": ReadOnly[list[str]]})
與其他特殊類型的交互
ReadOnly[] 可以與 Required[]、NotRequired[] 和 Annotated[] 以任何巢狀順序搭配使用
class Movie(TypedDict):
title: ReadOnly[Required[str]] # OK
year: ReadOnly[NotRequired[Annotated[int, ValueRange(-9999, 9999)]]] # OK
class Movie(TypedDict):
title: Required[ReadOnly[str]] # OK
year: Annotated[NotRequired[ReadOnly[int]], ValueRange(-9999, 9999)] # OK
這與 PEP 655 中引入的行為一致。
繼承 (Inheritance)
子類別可以將唯讀項目重新宣告為非唯讀,允許其被變異
class NamedDict(TypedDict):
name: ReadOnly[str]
class Album(NamedDict):
name: str
year: int
album: Album = { "name": "Flood", "year": 1990 }
album["year"] = 1973
album["name"] = "Dark Side Of The Moon" # OK: "name" is not read-only in Album
如果唯讀項目未被重新宣告,它將保持唯讀
class Album(NamedDict):
year: int
album: Album = { "name": "Flood", "year": 1990 }
album["name"] = "Dark Side Of The Moon" # Type check error: "name" is read-only in Album
子類別可以限縮唯讀項目的值類型
class AlbumCollection(TypedDict):
albums: ReadOnly[Collection[Album]]
class RecordShop(AlbumCollection):
name: str
albums: ReadOnly[list[Album]] # OK: "albums" is read-only in AlbumCollection
子類別可以要求在超類別中是唯讀但非必須的項目
class OptionalName(TypedDict):
name: ReadOnly[NotRequired[str]]
class RequiredName(OptionalName):
name: ReadOnly[Required[str]]
d: RequiredName = {} # Type check error: "name" required
子類別可以結合這些規則
class OptionalIdent(TypedDict):
ident: ReadOnly[NotRequired[str | int]]
class User(OptionalIdent):
ident: str # Required, mutable, and not an int
請注意,這些僅是結構化類型的結果,但在此處特別標出,因為其行為與 PEP 589 中規定的規則不同。
類型一致性
本節更新了 PEP 589 中引入的類型一致性規則,以涵蓋本 PEP 的新特性。特別是,任何不使用該新特性的類型對,若它們先前已一致,則在這些新規則下仍將保持一致。
若 TypedDict 類型 A 與 TypedDict B 在結構上相容,則 A 與 B 一致。當且僅當滿足以下所有條件時,此說法成立
- 對於
B中的每個項目,A必須具有對應的鍵,除非B中的項目是唯讀、非必須且為頂層值類型 (ReadOnly[NotRequired[object]])。 - 對於
B中的每個項目,如果A具有對應的鍵,則A中的對應值類型必須與B中的值類型一致。 - 對於
B中的每個非唯讀項目,其值類型必須與A中的對應值類型一致。 - 對於
B中的每個必須鍵,對應的鍵在A中也必須是必須的。 - 對於
B中的每個非必須鍵,如果該項目在B中非唯讀,則對應的鍵在A中也必須是非必須的。
討論
- TypedDict 中所有未指定的項目隱含具有值類型
ReadOnly[NotRequired[object]]。 - 唯讀項目表現為協變 (covariant),因為它們無法被變異。這類似於
Sequence等容器類型,且不同於非唯讀項目(表現為不變/invariant)。範例class A(TypedDict): x: ReadOnly[int | None] class B(TypedDict): x: int def f(a: A) -> None: print(a["x"] or 0) b: B = {"x": 1} f(b) # Accepted by type checker
- 一個沒有顯式鍵
'x'的 TypedDict 類型A,與一個具有非必須鍵'x'的 TypedDict 類型B不一致,因為在運行時鍵'x'可能存在且具有不相容的類型(由於結構子類型化,這可能無法透過A觀察到)。此規則的唯一例外是,如果B中的項目是唯讀的,且值類型是頂層類型 (object)。例如class A(TypedDict): x: int class B(TypedDict): x: int y: ReadOnly[NotRequired[object]] a: A = { "x": 1 } b: B = a # Accepted by type checker
Update 方法
除了現有的類型檢查規則外,如果一個具有唯讀項目的 TypedDict 被另一個宣告該鍵的 TypedDict 更新,類型檢查器應報錯
class A(TypedDict):
x: ReadOnly[int]
y: int
a1: A = { "x": 1, "y": 2 }
a2: A = { "x": 3, "y": 4 }
a1.update(a2) # Type check error: "x" is read-only in A
除非所宣告的值類型為底層類型 (Never)
class B(TypedDict):
x: NotRequired[typing.Never]
y: ReadOnly[int]
def update_a(a: A, b: B) -> None:
a.update(b) # Accepted by type checker: "x" cannot be set on b
注意:沒有任何東西能與 Never 類型匹配,因此標註為該類型的項目必須不存在。
關鍵字參數類型標註
PEP 692 引入了 Unpack 以使用 TypedDict 來標註 **kwargs。以此方式使用的 TypedDict 中,將一個或多個項目標記為唯讀對函數的方法簽章沒有影響。然而,它確實會防止該項目在函數體內被修改
class Args(TypedDict):
key1: int
key2: str
class ReadOnlyArgs(TypedDict):
key1: ReadOnly[int]
key2: ReadOnly[str]
class Function(Protocol):
def __call__(self, **kwargs: Unpack[Args]) -> None: ...
def impl(**kwargs: Unpack[ReadOnlyArgs]) -> None:
kwargs["key1"] = 3 # Type check error: key1 is readonly
fn: Function = impl # Accepted by type checker: function signatures are identical
執行時期行為
TypedDict 類型將獲得兩個新屬性:__readonly_keys__ 和 __mutable_keys__,分別為包含所有唯讀鍵和非唯讀鍵的凍結集合 (frozensets)
class Example(TypedDict):
a: int
b: ReadOnly[int]
c: int
d: ReadOnly[int]
assert Example.__readonly_keys__ == frozenset({'b', 'd'})
assert Example.__mutable_keys__ == frozenset({'a', 'c'})
typing.get_type_hints 將移除任何 ReadOnly 類型限定符,除非 include_extras 為 True
assert get_type_hints(Example)['b'] == int
assert get_type_hints(Example, include_extras=True)['b'] == ReadOnly[int]
typing.get_origin 和 typing.get_args 將更新以識別 ReadOnly
assert get_origin(ReadOnly[int]) is ReadOnly
assert get_args(ReadOnly[int]) == (int,)
回溯相容性
此 PEP 為 TypedDict 新增了功能,因此檢查 TypedDict 類型的程式碼將需要變更以支援使用此功能的類型。預計這主要影響類型檢查器。
安全性影響
本 PEP 沒有已知的安全後果。
如何教導此功能
建議對 typing 模組文件進行的更改,符合當前慣例
- 將此 PEP 加入到列表中。
- 新增
typing.ReadOnly,並連結至 TypedDict 與此 PEP。 - 將以下文字新增至 TypedDict 條目
此 ReadOnly 類型限定符指出,在 TypedDict 定義中聲明的項目可被讀取但不能變異(新增、修改或刪除)。當值的確切類型尚不清楚時,這很有用,因為修改它會破壞結構子類型。插入範例
參考實作
被否決的替代方案
TypedMapping 協議類型
本 PEP 的早期版本提出了一種 TypedMapping 協議類型,行為非常像唯讀 TypedDict,但沒有運行時類型必須是 dict 的約束。本 PEP 當前版本描述的行為隨後可以透過讓 TypedDict 繼承 TypedMapping 來獲得。由於這更複雜且沒有強而有力的用例來推動額外的複雜性,該方案已被暫時擱置。
高階 ReadOnly 類型
可以新增一個廣義的高階類型,從其參數中移除變異方法,例如 ReadOnly[MovieRecord]。對於 TypedDict,這就像將 ReadOnly 新增到每個項目,包括超類別中宣告的項目。這自然希望定義在比 TypedDict 子類別更廣泛的類型集合上,同時也引發了關於它是否以及如何應用於巢狀類型的問題。我們決定將此 PEP 的範圍限制得更窄一些。
將該類型命名為 Readonly
Read-only 通常有連字號,且將轉換為 CamelCase 時以連字號分隔的單字首字母大寫似乎是常見慣例。這似乎與維基百科上 CamelCase 的定義一致:CamelCase 將每個單字的第一個字母大寫。話雖如此,若有來自核心 Python 程式庫的 Python 範例或反例,或者關於此慣例更好的明確指導,將不勝感激。
重用 Final 註解
Final 註解防止屬性被修改,就像擬議的 ReadOnly 限定符對 TypedDict 項目所做的一樣。然而,根據 PEP 591,它還被記錄為防止在子類別中重新定義;
typing.Final類型限定符用於指示變數或屬性不應被重新指派、重新定義或覆寫。
這不符合 ReadOnly 的預期用途。為了避免讓 Final 在不同上下文中表現不同而產生混淆,我們選擇引入一個新的限定符。
唯讀標記 (Readonly flag)
本 PEP 的早期版本引入了一個布林旗標,確保 TypedDict 中的所有項目都是唯讀的
class Movie(TypedDict, readonly=True):
name: str
year: NotRequired[int | None]
movie: Movie = { "name": "A Clockwork Orange" }
movie["year"] = 1971 # Type check error: "year" is read-only
然而,這在引入繼承時導致了混淆
class A(TypedDict):
key1: int
class B(A, TypedDict, readonly=True):
key2: int
b: B = { "key1": 1, "key2": 2 }
b["key1"] = 4 # Accepted by type checker: "key1" is not read-only
對於熟悉 frozen (來自 dataclasses) 的人來說,僅看 B 的定義,假設整個類型都是唯讀的可能是合理的。另一方面,對於熟悉 total 的人來說,假設唯讀僅適用於當前類型也是合理的。
最初的提案試圖透過將定義 B 的方式定為類型檢查錯誤和運行時錯誤來消除這種歧義。這對於那些預期它像 total 一樣運作的人來說,仍然是一個令人驚訝的來源。
鑑於無法使用 readonly 旗標來表達額外的類型,它已從提案中刪除,以避免歧義和意外。
支援透過 copy 等方法進行類型檢查的唯讀限定符移除
本 PEP 的早期版本要求類型檢查器支援如下代碼
class A(TypedDict):
x: ReadOnly[int]
class B(TypedDict):
x: ReadOnly[str]
class C(TypedDict):
x: int | str
def copy_and_modify(a: A) -> C:
c: C = copy.copy(a)
if not c['x']:
c['x'] = "N/A"
return c
def merge_and_modify(a: A, b: B) -> C:
c: C = a | b
if not c['x']:
c['x'] = "N/A"
return c
然而,目前在 typeshed 中沒有辦法表達這一點,這意味著類型檢查器將被迫對這些函數進行特殊處理。mypy 和 pyright 確實支援編寫這些操作的方法,儘管可以說是可讀性較差
copied: C = { **a }
merged: C = { **a, **b }
雖然不像理想中那樣靈活,但目前的 typeshed 存根 (stubs) 是健全的,如果接受此 PEP,它們將保持健全。更新 typeshed 將需要新的類型功能,例如一個類型建構子,用於表達合併兩個或多個字典後產生的類型,以及一個類型限定符,用於指示回傳值未共享(因此可能具有鬆綁的類型約束,如唯讀和泛型的不變性),加上類型檢查器應如何解釋這些功能的詳細資訊。這些可能是對語言有價值的補充,但超出了本 PEP 的範圍。
鑑於此,我們推遲了任何 typeshed 存根的更新。
防止 TypedDict 中出現未指定的鍵
考慮以下「類型識別」程式碼
class A(TypedDict):
foo: int
class B(TypedDict):
bar: int
def get_field(d: A | B) -> int:
if "foo" in d:
return d["foo"] # !!!
else:
return d["bar"]
這是一種常見的慣用法,其他語言如 Typescript 允許這樣做。然而,從技術上講,此程式碼是不健全的:B 沒有宣告 foo,但 B 的實例可能仍然存在該鍵,且關聯的值可以是任何類型
class C(TypedDict):
foo: str
bar: int
c: C = { "foo": "hi", "bar" 3 }
b: B = c # OK: C is structurally compatible with B
v = get_field(b) # Returns a string at runtime, not an int!
mypy 在標記行拒絕了 get_field 的定義,並出現了 TypedDict "B" has no key "foo" 的錯誤,這是一個相當令人困惑的錯誤訊息,但這是由這種不健全性引起的。
解決此問題的一個選項是明確防止 B 持有 foo
class B(TypedDict):
foo: NotRequired[Never]
bar: int
b: B = c # Type check error: key "foo" not allowed in B
然而,這要求可能用於識別的每個可能的鍵都必須在每個類型中明確宣告,這通常是不可行的。一個更好的選項是有一種方法可以防止所有未指定的鍵被包含在 B 中。mypy 使用來自 PEP 591 的 @final 裝飾器來支援這一點
@final
class B(TypedDict):
bar: int
這裡的推理是,這防止了 C 或任何其他類型被視為 B 的「子類別」,因此現在可以依賴 B 的實例永遠不會持有鍵 foo,即使它沒有被明確宣告為底層類型。
然而,隨著唯讀項目的引入,這種推理將暗示類型檢查器應禁止以下行為
@final
class D(TypedDict):
field: ReadOnly[Collection[str]]
@final
class E(TypedDict):
field: list[str]
e: E = { "field": ["value1", "value2"] }
d: D = e # Error?
這裡的概念性問題是 TypedDict 是結構化類型:它們實際上不能被子類別化。因此,在它們上使用 @final 並沒有明確定義;這在 PEP 591 中絕對沒有提到。
本 PEP 的早期版本建議透過為 TypedDict 新增一個旗標來解決此問題,該旗標將明確防止其他鍵被使用,但不會防止其他類型的結構相容性
class B(TypedDict, other_keys=Never):
bar: int
b: B = c # Type check error: key "foo" not allowed in B
然而,在起草過程中,情況發生了變化
- 之前在類型識別情況下與 mypy 工作方式相似的 pyright,改為允許原始範例而不會報錯,儘管存在不健全性,但因為這是一種常見的慣用法
- mypy 有一個 開放問題,跟隨 pyright 和 Typescript 的腳步,也允許這種慣用法
- 一個 PEP-728 的草案 已經建立,它是
other_keys功能的超集
因此,在此 PEP 中解決此問題的緊迫性降低了,它已被推遲到 PEP-728。
版權
本文件已進入公有領域或遵循 CC0-1.0-Universal 授權,以較寬鬆者為準。
來源: https://github.com/python/peps/blob/main/peps/pep-0705.rst
最後修改: 2025-02-01 07:28:42 GMT