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

Python 增强提案

PEP 705 – TypedDict: 只读项

作者:
Alice Purcell <alicederyn at gmail.com>
发起人:
Pablo Galindo <pablogsal at gmail.com>
讨论至:
Discourse 帖子
状态:
最终版
类型:
标准跟踪
主题:
类型标注
创建日期:
2022-11-07
Python 版本:
3.13
发布历史:
2022-09-30, 2022-11-02, 2023-03-14, 2023-10-17, 2023-11-04
决议:
2024-02-29

目录

重要

本 PEP 为历史文档:请参阅 readonlytyping.ReadOnly 以获取最新规范和文档。规范类型规范在 类型规范网站 维护;运行时类型行为在 CPython 文档中描述。

×

有关如何提议更改类型规范的信息,请参阅类型规范更新过程

摘要

PEP 589 定义了用于具有固定键集的字典的结构类型 TypedDict。由于 TypedDict 是一种可变类型,因此很难正确地标注接受只读参数的方法,同时又不阻止有效输入。

本 PEP 提出了一个新的类型限定符 typing.ReadOnly,以支持这些用法。它不修改 Python 语法。TypedDict 只读键的正确使用仅由静态类型检查器强制执行,Python 本身在运行时不会强制执行。

动机

使用(可能嵌套的)带有字符串键的字典表示结构化数据是 Python 程序中常见的模式。PEP 589 允许在预先知道确切类型时对这些值进行类型检查,但很难编写接受更具体变体的只读代码:例如,值可能是子类型或限制了可能类型的联合。这在为服务编写 API 时尤其常见,这些 API 可能支持各种输入结构,并且通常不需要修改其输入。

纯函数

考虑尝试为函数 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]

但是假设我们有另一个类型,其中年份是必需的

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

这看起来像一个错误,但仅仅是因为(不必要的)能力,可以用不同的 HasTimestamp 实例覆盖 HasTimestampedMetadata 实例持有的 metadata 项,该实例可能不再是 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 建议 ReadOnly 仅在 TypedDict 中有效。未来可能的扩展是在其他上下文中支持它,例如在协议中。

规范

添加了一个新的 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 中引入的行为一致。

继承

子类可以将只读项重新声明为非只读,允许它们被修改

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]]
  • 只读项表现为协变,因为它们不能被修改。这类似于 Sequence 等容器类型,并且与非只读项(表现为不变)不同。例如
    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
    

更新方法

除了现有的类型检查规则外,如果一个带有只读项的 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__,它们将是分别包含所有只读键和非只读键的冻结集

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 添加到列出的其他 PEP 中。
  • 添加 typing.ReadOnly,并链接到 TypedDict 和本 PEP。
  • 将以下文本添加到 TypedDict 条目

ReadOnly 类型限定符表示在 TypedDict 定义中声明的项可以读取但不能修改(添加、修改或删除)。当值确切类型未知时,这很有用,因此修改它会破坏结构子类型。插入示例

参考实现

pyright 1.1.333 完全实现了此提案.

被拒绝的替代方案

TypedMapping 协议类型

本 PEP 的早期版本提出了一个 TypedMapping 协议类型,其行为与只读 TypedDict 非常相似,但没有运行时类型为 dict 的限制。本 PEP 当前版本中描述的行为可以通过从 TypedMapping 继承 TypedDict 来获得。这暂时搁置,因为它更复杂,并且没有一个强有力的用例来驱动额外的复杂性。

高阶 ReadOnly 类型

可以添加一个通用的高阶类型,该类型删除其参数的修改方法,例如 ReadOnly[MovieRecord]。对于 TypedDict,这就像将 ReadOnly 添加到每个项,包括超类中声明的项。这自然会希望为比 TypedDict 子类更广泛的类型集定义,并且还引发了它是否以及如何应用于嵌套类型的问题。我们决定将本 PEP 的范围缩小。

将类型命名为 Readonly

Read-only 通常带连字符,并且将带短划线的单词转换为驼峰式时,通常约定将每个单词的首字母大写。这似乎与维基百科上驼峰式的定义一致:驼峰式将每个单词的首字母大写。也就是说,Python 示例或反例(最好来自核心 Python 库),或关于约定的更明确的指导,将不胜感激。

重用 Final 注解

Final 注解阻止属性被修改,就像提议的 ReadOnly 限定符对 TypedDict 项所做的那样。但是,它也被记录为阻止子类中的重新定义;来自 PEP 591

typing.Final 类型限定符用于指示变量或属性不应被重新赋值、重新定义或覆盖。

这不符合 ReadOnly 的预期用途。我们选择引入一个新的限定符,而不是通过让 Final 在不同上下文中行为不同来引入混淆。

只读标志

本 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 标志无法表达额外类型,已将其从提案中删除,以避免歧义和意外。

通过复制及其他方法支持类型检查的只读限定符移除

本 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 存根是可靠的,并且如果本 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

然而,在起草过程中,情况发生了变化

因此,在本 PEP 中解决此问题的紧迫性较低,并已推迟到 PEP-728。


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

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