PEP 728 – 带类型额外项的 TypedDict
- 作者:
- Zixuan James Li <p359101898 at gmail.com>
- 赞助者:
- Jelle Zijlstra <jelle.zijlstra at gmail.com>
- 讨论列表:
- Discourse 线程
- 状态:
- 草稿
- 类型:
- 标准跟踪
- 主题:
- 类型提示
- 创建日期:
- 2023-09-12
- Python 版本:
- 3.13
- 历史记录:
- 2024-02-09
摘要
本 PEP 提出了一种使用 closed
参数限制 TypedDict
额外项的方法,并使用特殊的 __extra_items__
键对其进行类型化。这解决了定义封闭的 TypedDict 类型或对可能出现在 dict
中的键的子集进行类型化的需求,同时允许指定类型的额外项。
动机
typing.TypedDict
类型可以注释字典中每个已知项的值类型。但是,由于结构化子类型,TypedDict 可以具有其类型不可见的额外项。目前没有办法限制 TypedDict 类型结构化子类型中可能存在的项的类型。
定义封闭的 TypedDict 类型
TypedDict 的当前行为阻止用户在预期类型不包含任何额外项时定义封闭的 TypedDict 类型。
由于可能存在额外项,类型检查器无法推断 TypedDict 上 .items()
和 .values()
的更精确的返回类型。这也可以通过 定义封闭的 TypedDict 类型 来解决。
另一个可能的用例是以一种安全的方式 启用类型缩小,使用 in
检查
class Movie(TypedDict):
name: str
director: str
class Book(TypedDict):
name: str
author: str
def fun(entry: Movie | Book) -> None:
if "author" in entry:
reveal_type(entry) # Revealed type is 'Movie | Book'
没有什么可以阻止与 Movie
结构兼容的 dict
具有 author
键,并且在当前规范下,类型检查器缩小其类型是不正确的。
允许特定类型的额外项
为了支持 API 接口或仅知道一部分可能键的旧代码库,明确期望某些值类型的额外键将非常有用。
但是,类型规范在检查 TypedDict 的构造方面更加严格,阻止用户这样做
class MovieBase(TypedDict):
name: str
def fun(movie: MovieBase) -> None:
# movie can have extra items that are not visible through MovieBase
...
movie: MovieBase = {"name": "Blade Runner", "year": 1982} # Not OK
fun({"name": "Blade Runner", "year": 1982}) # Not OK
虽然在构造 TypedDict 时强制执行了此限制,但由于结构化子类型,TypedDict 可能具有其类型不可见的额外项。例如
class Movie(MovieBase):
year: int
movie: Movie = {"name": "Blade Runner", "year": 1982}
fun(movie) # OK
无法通过 in
检查来确认额外项的存在并访问它们而不会破坏类型安全性,即使它们可能来自 MovieBase
的任意结构化子类型。
def g(movie: MovieBase) -> None:
if "year" in movie:
reveal_type(movie["year"]) # Error: TypedDict 'MovieBase' has no key 'year'
为了满足允许额外键的需求,已经实现了一些解决方法,但没有一个是理想的。对于 mypy,--disable-error-code=typeddict-unknown-key
抑制类型检查错误,专门针对 TypedDict 上的未知键。这牺牲了类型安全来换取灵活性,并且它不提供一种方法来指定 TypedDict 类型期望与某种类型兼容的额外键。
支持 Unpack
的额外键
PEP 692 添加了一种方法,可以使用 Unpack
通过 TypedDict 精确地注释由 **kwargs
表示的各个关键字参数的类型。但是,由于无法将 TypedDict 定义为接受任意额外项,因此无法 允许在定义 TypedDict 时未知的额外关键字参数。
鉴于在现有代码库中使用 PEP 692 之前的类型注释来表示 **kwargs
,接受并在 TypedDict 上对额外项进行类型化将非常有价值,以便可以在新的 Unpack
结构中支持旧的类型化行为。
基本原理
允许类型为 str
的额外项的类型可以松散地描述为 TypedDict 和 Mapping[str, str]
之间的交集。
TypeScript 中的 索引签名实现了这一点
type Foo = {
a: string
[key: string]: string
}
本提案旨在支持类似的功能,而无需引入类型的一般交集或语法更改,为现有类型一致性规则提供自然的扩展。
我们建议添加一个参数 closed
到 TypedDict。类似于 total
,只允许文字 True
或 False
值。当在 TypedDict 类型定义中使用 closed=True
时,我们将双下划线属性 __extra_items__
赋予特殊的含义:允许额外项,并且它们的类型应与 __extra_items__
的值类型兼容。
如果设置了 closed=True
,但没有 __extra_items__
键,则 TypedDict 将被视为包含一个项 __extra_items__: Never
。
请注意,如果未使用 closed=True
,则相同 TypedDict 类型定义上的 __extra_items__
将保留为常规项。
与索引签名不同,已知项的类型不需要与 __extra_items__
的值类型一致。
这种方法有一些优点
- 继承自然地工作。在 TypedDict 上定义的
__extra_items__
也将对它的子类可用。 - 我们可以建立在 类型规范中定义的类型一致性规则之上。在类型一致性方面,
__extra_items__
可以被视为伪项。 - 无需引入语法更改来指定额外项的类型。
- 我们可以精确地对额外项进行类型化,而无需使
__extra_items__
成为已知项的联合。 - 我们不会丢失向后兼容性,因为
__extra_items__
仍然可以用作常规键。
规范
本规范的结构与 PEP 589 类似,以突出显示对原始 TypedDict 规范的更改。
如果指定了 closed=True
,则额外项将被视为非必需项,具有与 __extra_items__
相同的类型,在确定 支持和不支持的操作时允许其键。
使用 TypedDict 类型
假设在 TypedDict 类型定义中使用了 closed=True
。
对于具有特殊 __extra_items__
键的 TypedDict 类型,在构造期间,每个未知项的值类型应为非必需且与 __extra_items__
的值类型兼容。例如
class Movie(TypedDict, closed=True):
name: str
__extra_items__: bool
a: Movie = {"name": "Blade Runner", "novel_adaptation": True} # OK
b: Movie = {
"name": "Blade Runner",
"year": 1982, # Not OK. 'int' is incompatible with 'bool'
}
在此示例中,__extra_items__: bool
并不意味着 Movie
具有一个名为 "__extra_items__"
的必需字符串键,其值类型为 bool
。相反,它指定除了“name”之外的键的值类型为 bool
且是非必需的。
也支持备选的内联语法
Movie = TypedDict("Movie", {"name": str, "__extra_items__": bool}, closed=True)
允许访问额外键。类型检查器必须从 __extra_items__
的值类型推断其值类型
def f(movie: Movie) -> None:
reveal_type(movie["name"]) # Revealed type is 'str'
reveal_type(movie["novel_adaptation"]) # Revealed type is 'bool'
当 TypedDict 类型定义 __extra_items__
而不使用 closed=True
时,closed
默认为 False
,并且该键被假定为常规键
class Movie(TypedDict):
name: str
__extra_items__: bool
a: Movie = {"name": "Blade Runner", "novel_adaptation": True} # Not OK. Unexpected key 'novel_adaptation'
b: Movie = {
"name": "Blade Runner",
"__extra_items__": True, # OK
}
对于此类非封闭的 TypedDict 类型,假设在继承或类型一致性检查期间,它们允许具有值类型 ReadOnly[object]
的非必需额外项。但是,类型检查器仍然应该拒绝在构造期间发现的额外键。
closed
不会通过子类化继承。
class MovieBase(TypedDict, closed=True):
name: str
__extra_items__: ReadOnly[str | None]
class Movie(MovieBase):
__extra_items__: str # A regular key
a: Movie = {"name": "Blade Runner", "__extra_items__": None} # Not OK. 'None' is incompatible with 'str'
b: Movie = {
"name": "Blade Runner",
"__extra_items__": "A required regular key",
"other_extra_key": None,
} # OK
这里,"__extra_items__"
在 a
中是定义在 Movie
上的普通键,其值类型从 ReadOnly[str | None]
缩小到 str
;"other_extra_key"
在 b
中是一个额外键,其值类型必须与定义在 MovieBase
上的 "__extra_items__"
的值类型保持一致。
与完整性交互
使用 Required[]
或 NotRequired[]
与特殊项 __extra_items__
结合使用是错误的。 total=False
和 total=True
对 __extra_items__
本身没有影响。
无论 TypedDict 的完整性如何,额外项都是非必需的。可用于 NotRequired
项的操作也应该可用于额外项。
class Movie(TypedDict, closed=True):
name: str
__extra_items__: int
def f(movie: Movie) -> None:
del movie["name"] # Not OK
del movie["year"] # OK
与 Unpack
交互
出于类型检查的目的,带有额外项的 Unpack[TypedDict]
应该被视为其在普通参数中的等价物,并且函数参数的现有规则仍然适用。
class Movie(TypedDict, closed=True):
name: str
__extra_items__: int
def f(**kwargs: Unpack[Movie]) -> None: ...
# Should be equivalent to
def f(*, name: str, **kwargs: int) -> None: ...
与 PEP 705 交互
当特殊项 __extra_items__
使用 ReadOnly[]
进行注释时,TypedDict 上的额外项具有只读项的属性。这与 PEP 705 中指定的继承规则相互作用。
值得注意的是,如果 TypedDict 类型声明 __extra_items__
为只读,则 TypedDict 类型的子类可以重新声明 __extra_items__
的值类型或其他非额外项的值类型。
因为非封闭的 TypedDict 类型隐式地允许具有值类型 ReadOnly[object]
的非必需额外项,所以其子类可以使用更具体的类型覆盖特殊项 __extra_items__
。
更多细节将在后面的章节中讨论。
继承
当 TypedDict 类型定义为 closed=False
(默认值)时,__extra_items__
应该像普通键一样表现并被继承。一个普通的 __extra_items__
键可以与特殊的 __extra_items__
共存,并且两者都应该在子类化时被继承。
在本节的其余部分,我们假设只要提到 __extra_items__
,就表示 closed=True
。
__extra_items__
的继承方式与普通 key: value_type
项相同。与其他键一样,类型规范 和 PEP 705 中的相同规则适用。我们将在 __extra_items__
的上下文中解释现有规则。
我们需要重新解释以下规则以定义 __extra_items__
如何与其交互。
- 不允许在子类中更改父 TypedDict 类的字段类型。
首先,不允许在子类中更改 __extra_items__
的值类型,除非它在超类中被声明为 ReadOnly
。
class Parent(TypedDict, closed=True):
__extra_items__: int | None
class Child(Parent, closed=True):
__extra_items__: int # Not OK. Like any other TypedDict item, __extra_items__'s type cannot be changed
其次,__extra_items__: T
有效地定义了 TypedDict 接受的任何未命名项的值类型,并将其标记为非必需。因此,上述限制适用于子类中定义的任何额外项。对于在子类中添加的每个项,以下所有条件都应该适用。
- 如果
__extra_items__
是只读的。- 该项可以是必需的或非必需的。
- 该项的值类型与
T
一致。
- 如果
__extra_items__
不是只读的。- 该项是非必需的。
- 该项的值类型与
T
一致。 T
与该项的值类型一致。
- 如果未重新声明
__extra_items__
,则子类按原样继承它。
例如。
class MovieBase(TypedDict, closed=True):
name: str
__extra_items__: int | None
class AdaptedMovie(MovieBase): # Not OK. 'bool' is not consistent with 'int | None'
adapted_from_novel: bool
class MovieRequiredYear(MovieBase): # Not OK. Required key 'year' is not known to 'Parent'
year: int | None
class MovieNotRequiredYear(MovieBase): # Not OK. 'int | None' is not consistent with 'int'
year: NotRequired[int]
class MovieWithYear(MovieBase): # OK
year: NotRequired[int | None]
由于这种特性,一个重要的副作用允许我们定义一个不允许额外项的 TypedDict 类型。
class MovieFinal(TypedDict, closed=True):
name: str
__extra_items__: Never
这里,使用 typing.Never
对 __extra_items__
进行注释指定 MovieFinal
中除了已知键之外不能有其他键。由于其潜在的常见用途,这等价于。
class MovieFinal(TypedDict, closed=True):
name: str
其中我们隐式地假设如果只指定 closed=True
,则默认情况下存在 __extra_items__: Never
字段。
类型一致性
除了显式定义的项的键集 S
之外,具有项 __extra_items__: T
的 TypedDict 类型被认为具有无限数量的项,所有这些项都满足以下条件。
- 如果
__extra_items__
是只读的。- 键的值类型与
T
一致。 - 键不在
S
中。
- 键的值类型与
- 如果
__extra_items__
不是只读的。- 键是非必需的。
- 键的值类型与
T
一致。 T
与键的值类型一致。- 键不在
S
中。
出于类型检查的目的,让 __extra_items__
成为一个非必需的伪项,并在 PEP 705 中现有的类型一致性规则 中声明“对于每个……项/键”时包含它,我们对其进行如下修改。
如果A
在结构上与B
兼容,则 TypedDict 类型A
与 TypedDictB
一致。当且仅当满足以下所有条件时,此条件为真。
- 对于
B
中的每个项,A
具有相应的键,除非B
中的项是只读的、非必需的,并且其顶层值类型为 (ReadOnly[NotRequired[object]]
)。 **[编辑:否则,如果在 ``A`` 中找不到具有相同名称的相应键,“__extra_items__”被视为相应键。]**- 对于
B
中的每个项,如果A
具有相应的键 **[编辑:或“__extra_items__”]**,则A
中的相应值类型与B
中的值类型一致。- 对于
B
中的每个非只读项,其值类型与A
中的相应值类型一致。 **[编辑:如果在 ``A`` 中找不到具有相同名称的相应键,“__extra_items__”被视为相应键。]**- 对于
B
中的每个必需键,相应的键在A
中是必需的。对于B
中的每个非必需键,如果该项在B
中不是只读的,则相应的键在A
中不是必需的。 **[编辑:如果在 ``A`` 中找不到具有相同名称的相应键,“__extra_items__”被视为非必需的相应键。]**
以下示例说明了这些检查的实际操作。
__extra_items__
对额外项的类型一致性检查施加了各种限制。
class Movie(TypedDict, closed=True):
name: str
__extra_items__: int | None
class MovieDetails(TypedDict, closed=True):
name: str
year: NotRequired[int]
__extra_items__: int | None
details: MovieDetails = {"name": "Kill Bill Vol. 1", "year": 2003}
movie: Movie = details # Not OK. While 'int' is consistent with 'int | None',
# 'int | None' is not consistent with 'int'
class MovieWithYear(TypedDict, closed=True):
name: str
year: int | None
__extra_items__: int | None
details: MovieWithYear = {"name": "Kill Bill Vol. 1", "year": 2003}
movie: Movie = details # Not OK. 'year' is not required in 'Movie',
# so it shouldn't be required in 'MovieWithYear' either
因为 Movie
中缺少“year”,所以 __extra_items__
被视为相应的键。“year” 是必需的,违反了规则“对于 B
中的每个必需键,相应的键在 A
中是必需的”。
当 __extra_items__
在 TypedDict 类型中被定义为只读时,某个项的值类型可以比 __extra_items__
的值类型更窄。
class Movie(TypedDict, closed=True):
name: str
__extra_items__: ReadOnly[str | int]
class MovieDetails(TypedDict, closed=True):
name: str
year: NotRequired[int]
__extra_items__: int
details: MovieDetails = {"name": "Kill Bill Vol. 2", "year": 2004}
movie: Movie = details # OK. 'int' is consistent with 'str | int'.
如果 year: ReadOnly[str | int]
是在 Movie
中定义的项,则其行为与 PEP 705 中指定的相同。
__extra_items__
作为伪项遵循其他项相同的规则,因此当两个 TypedDict 都包含 __extra_items__
时,此检查会自然地执行。
class MovieExtraInt(TypedDict, closed=True):
name: str
__extra_items__: int
class MovieExtraStr(TypedDict, closed=True):
name: str
__extra_items__: str
extra_int: MovieExtraInt = {"name": "No Country for Old Men", "year": 2007}
extra_str: MovieExtraStr = {"name": "No Country for Old Men", "description": ""}
extra_int = extra_str # Not OK. 'str' is inconsistent with 'int' for item '__extra_items__'
extra_str = extra_int # Not OK. 'int' is inconsistent with 'str' for item '__extra_items__'
非封闭的 TypedDict 类型隐式地允许具有值类型 ReadOnly[object]
的非必需额外键。这允许在该类型和封闭的 TypedDict 类型之间应用类型一致性规则。
class MovieNotClosed(TypedDict):
name: str
extra_int: MovieExtraInt = {"name": "No Country for Old Men", "year": 2007}
not_closed: MovieNotClosed = {"name": "No Country for Old Men"}
extra_int = not_closed # Not OK. 'ReadOnly[object]' implicitly on 'MovieNotClosed' is not consistent with 'int' for item '__extra_items__'
not_closed = extra_int # OK
与构造函数交互
允许额外类型为 T
的项目的 TypedDict 也允许在通过调用类对象进行构造时使用此类型的任意关键字参数。
class OpenMovie(TypedDict):
name: str
OpenMovie(name="No Country for Old Men") # OK
OpenMovie(name="No Country for Old Men", year=2007) # Not OK. Unrecognized key
class ExtraMovie(TypedDict, closed=True):
name: str
__extra_items__: int
ExtraMovie(name="No Country for Old Men") # OK
ExtraMovie(name="No Country for Old Men", year=2007) # OK
ExtraMovie(
name="No Country for Old Men",
language="English",
) # Not OK. Wrong type for extra key
# This implies '__extra_items__: Never',
# so extra keyword arguments produce an error
class ClosedMovie(TypedDict, closed=True):
name: str
ClosedMovie(name="No Country for Old Men") # OK
ClosedMovie(
name="No Country for Old Men",
year=2007,
) # Not OK. Extra items not allowed
与 Mapping[KT, VT] 交互
只要 TypedDict 类型上的值类型的并集与 VT
一致,TypedDict 类型就可以与除 Mapping[str, object]
之外的 Mapping[KT, VT]
类型保持一致。这是对类型规范中此规则的扩展。
- 所有值为
int
的 TypedDict 与Mapping[str, int]
不一致,因为由于结构化子类型,可能存在通过类型不可见的其他非int
值。可以使用Mapping
中的values()
和items()
方法访问这些值。
例如。
class MovieExtraStr(TypedDict, closed=True):
name: str
__extra_items__: str
extra_str: MovieExtraStr = {"name": "Blade Runner", "summary": ""}
str_mapping: Mapping[str, str] = extra_str # OK
int_mapping: Mapping[str, int] = extra_int # Not OK. 'int | str' is not consistent with 'int'
int_str_mapping: Mapping[str, int | str] = extra_int # OK
此外,类型检查器应该能够推断此类 TypedDict 类型上 values()
和 items()
的精确返回类型。
def fun(movie: MovieExtraStr) -> None:
reveal_type(movie.items()) # Revealed type is 'dict_items[str, str]'
reveal_type(movie.values()) # Revealed type is 'dict_values[str, str]'
与 dict[KT, VT] 交互
请注意,由于在封闭的 TypedDict 类型上存在 __extra_items__
会禁止其结构化子类型中出现其他必需键,因此我们可以在静态分析期间确定 TypedDict 类型及其结构化子类型是否会存在任何必需键。
如果 TypedDict 类型上的所有项目都满足以下条件,则该类型与 dict[str, VT]
一致。
VT
与项目的 value type 一致。- 项目的 value type 与
VT
一致。 - 该项目不是只读的。
- 该项目不是必需的。
例如。
class IntDict(TypedDict, closed=True):
__extra_items__: int
class IntDictWithNum(IntDict):
num: NotRequired[int]
def f(x: IntDict) -> None:
v: dict[str, int] = x # OK
v.clear() # OK
not_required_num: IntDictWithNum = {"num": 1, "bar": 2}
regular_dict: dict[str, int] = not_required_num # OK
f(not_required_num) # OK
在这种情况下,以前在 TypedDict 上不可用的方法是允许的。
not_required_num.clear() # OK
reveal_type(not_required_num.popitem()) # OK. Revealed type is tuple[str, int]
但是,dict[str, VT]
不一定与 TypedDict 类型一致,因为此类字典可能是字典的子类型。
class CustomDict(dict[str, int]):
...
not_a_regular_dict: CustomDict = {"num": 1}
int_dict: IntDict = not_a_regular_dict # Not OK
如何教授
选择拼写 "__extra_items__"
的目的是让新用户更容易理解此功能,而不是像 "__extra__"
这样的更短的替代方案。
这方面的详细信息应在类型规范和 typing
文档中进行记录。
向后兼容性
由于如果未指定 closed=True
,__extra_items__
将保留为常规键,因此由于此更改,没有现有的代码库会中断。
如果提案被接受,当指定 closed=True
时,__required_keys__
、__optional_keys__
、__readonly_keys__
和 __mutable_keys__
中都不应包含在同一 TypedDict 类型上定义的 "__extra_items__"
。
请注意,作为关键字参数的 closed
不会与用于使用函数语法定义键的关键字参数替代方案冲突,该语法允许诸如 TD = TypedDict("TD", foo=str, bar=int)
之类的事情,因为计划在 Python 3.13 中将其删除。
由于这是一个类型检查功能,因此只要类型检查器支持它,就可以将其提供给旧版本。
被拒绝的想法
允许额外项而不指定类型
extra=True
最初是为了定义一个接受额外项目的 TypedDict 而提出的,无论类型如何,就像 total=True
的工作方式一样。
class TypedDict(extra=True):
pass
因为它没有提供指定额外项目类型的方法,所以类型检查器需要假设额外项目的类型为 Any
,这会影响类型安全性。此外,由于结构化子类型,TypedDict 的当前行为已经允许在运行时存在未类型化的额外项目。 closed=True
在当前提案中扮演着类似的角色。
支持 TypedDict(extra=type)
在 PEP 的讨论过程中,一些类型检查器的作者强烈反对在另一个地方将类型作为值而不是注释传递。虽然这种设计可能可行,但也有一些需要考虑的部分可解决的问题。
- 前向引用的可用性与函数语法一样,当 SomeType 是前向引用时,需要使用带引号的类型或类型别名。这已经是函数语法的要求,因此实现可以潜在地重用该逻辑部分,但这仍然是
closed=True
提案没有的额外工作。 - 关于使用类型作为值的担忧在函数语法中不允许作为值类型的任何内容也不应该允许作为 extra 的参数。虽然类型检查器可能能够重用此检查,但它仍然需要以某种方式针对基于类的语法进行特殊处理。
- 如何教授值得注意的是,
extra=type
通常会被提出,因为它对于用例来说是一个直观的解决方案,因此它可能比不太明显的解决方案更容易学习。但是,更常见的用例只需要closed=True
,并且前面提到的其他缺点超过了需要教授特殊键用法的程度。
使用交集支持额外项
在 Python 的类型系统中支持交集需要仔细考虑,并且社区可能需要很长时间才能就合理的设计达成共识。
理想情况下,TypedDict 中的额外项目不应被交集上的工作阻止,也不一定需要通过交集来支持。
此外,Mapping[...]
和 TypedDict
之间的交集不等效于具有提议的 __extra_items__
特殊项目的 TypedDict 类型,因为 TypedDict
中所有已知项目的 value type 需要满足与 Mapping[...]
的 value type 的 is-subtype-of 关系。
要求已知项与 __extra_items__
的类型兼容
__extra_items__
限制了 TypedDict 类型 *未知* 键的值类型。因此,任何 *已知* 项目的 value type 不一定与 __extra_items__
的类型一致,并且 __extra_items__
的类型不一定与所有已知项目的 value type 一致。
这与 TypeScript 的 索引签名 语法不同,后者要求所有属性的类型与字符串索引的类型匹配。例如
interface MovieWithExtraNumber {
name: string // Property 'name' of type 'string' is not assignable to 'string' index type 'number'.
[index: string]: number
}
interface MovieWithExtraNumberOrString {
name: string // OK
[index: string]: number | string
}
这是在 TypeScript 的问题跟踪器 中讨论的一个已知限制,其中建议应该有一种方法将定义的键从索引签名中排除,以便可以定义类似于 MovieWithExtraNumber
的类型。
参考实现
此提案在 pyright 1.1.352 和 pyanalyze 0.12.0 中得到支持。
致谢
感谢 Jelle Zijlstra 赞助此 PEP 并提供审查反馈,Eric Traut 提出了此 PEP 重复使用的原始设计,以及 Alice Purcell 以 PEP 705 作者的身份提供他们的观点。
版权
本文档置于公共领域或根据 CC0-1.0-Universal 许可证,以较宽松者为准。
来源: https://github.com/python/peps/blob/main/peps/pep-0728.rst
上次修改时间: 2024-03-16 13:29:41 GMT