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 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"] = 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
这看起来像一个错误,但仅仅是因为(不必要的)能力,可以用不同的 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 一致,如果 A 与 B 结构兼容。当且仅当所有以下条件都满足时,这才是真的
- 对于
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_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 添加到列出的其他 PEP 中。
- 添加
typing.ReadOnly,并链接到 TypedDict 和本 PEP。 - 将以下文本添加到 TypedDict 条目
ReadOnly 类型限定符表示在 TypedDict 定义中声明的项可以读取但不能修改(添加、修改或删除)。当值确切类型未知时,这很有用,因此修改它会破坏结构子类型。插入示例
参考实现
被拒绝的替代方案
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
然而,在起草过程中,情况发生了变化
- pyright 以前在这种类型判别案例中与 mypy 类似地工作,但由于它是一种常见的惯用法,更改为允许原始示例而没有错误,尽管存在不健全性
- 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