PEP 705 – TypedDict:只读项
- 作者:
- Alice Purcell <alicederyn at gmail.com>
- 赞助人:
- Pablo Galindo <pablogsal at gmail.com>
- 讨论邮件列表:
- Discourse 线程
- 状态:
- 最终
- 类型:
- 标准跟踪
- 主题:
- 类型提示
- 创建日期:
- 2022年11月7日
- Python 版本:
- 3.13
- 历史记录:
- 2022年9月30日, 2022年11月2日, 2023年3月14日, 2023年10月17日, 2023年11月4日
- 决议:
- Discourse 消息
摘要
PEP 589 定义了结构类型 TypedDict
,用于键集固定的字典。由于 TypedDict
是一个可变类型,因此很难正确地注释接受只读参数的方法,而不会阻止有效的输入。
此 PEP 提出了一种新的类型限定符 typing.ReadOnly
来支持这些用法。它没有进行任何 Python 语法更改。只读键的正确使用旨在仅由静态类型检查器强制执行,并且 Python 本身在运行时不会强制执行。
动机
使用(可能嵌套的)带有字符串键的字典来表示结构化数据是 Python 程序中的一种常见模式。PEP 589 允许在预先知道确切类型时对这些值进行类型检查,但很难编写接受更具体变体的只读代码:例如,其中值可能是子类型或限制可能的类型的联合。在为服务编写 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]
但是假设我们有另一种类型,其中 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
这看起来像一个错误,但仅仅是由于(不需要的)能够用不同的 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 中的新功能。特别是,在这些新规则下,任何不使用新功能的类型对,如果(且仅当)它们已经一致时,将是一致的。
如果 A
在结构上与 B
兼容,则 TypedDict 类型 A
与 TypedDict 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
的约束。然后可以通过从 TypedMapping 继承 TypedDict 来获得此 PEP 当前版本中描述的行为。由于这更复杂,并且没有强烈的用例来推动额外的复杂性,因此目前已将其搁置。
一个高阶 ReadOnly 类型
可以添加一个广义的高阶类型,它从其参数中删除 mutator 方法,例如 ReadOnly[MovieRecord]
。对于 TypedDict,这就像为每个项添加 ReadOnly
,包括在超类中声明的项。这自然希望为比 TypedDict 子类更广泛的类型集定义,并且还引发了关于它是否以及如何应用于嵌套类型的问题。我们决定将此 PEP 的范围缩小。
将类型命名为 Readonly
Read-only
通常用连字符连接,并且在转换为 CamelCase 时将初始大写字母放在用连字符分隔的单词上似乎是常见的约定。这似乎与维基百科上 CamelCase 的定义一致:CamelCase 将每个单词的首字母大写。也就是说,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
最后修改时间:2024-09-03 15:53:33 GMT