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

Python 增強提案 (Python Enhancement Proposals)

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 為歷史文件:請參閱 readonlytyping.ReadOnly 以獲取最新的規範與文件。標準類型規範維護於 typing 規範網站;運行時類型行為描述於 CPython 文件中。

×

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

摘要

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"] = Nonedel 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 在結構上相容,則 AB 一致。當且僅當滿足以下所有條件時,此說法成立

  • 對於 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_extrasTrue

assert get_type_hints(Example)['b'] == int
assert get_type_hints(Example, include_extras=True)['b'] == ReadOnly[int]

typing.get_origintyping.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 定義中聲明的項目可被讀取但不能變異(新增、修改或刪除)。當值的確切類型尚不清楚時,這很有用,因為修改它會破壞結構子類型。插入範例

參考實作

pyright 1.1.333 完全實現了此提案.

被否決的替代方案

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。


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

最後修改: 2025-02-01 07:28:42 GMT