PEP 589 – TypedDict: 具有固定键集的字典的类型提示
- 作者:
- Jukka Lehtosalo <jukka.lehtosalo at iki.fi>
- 发起人:
- Guido van Rossum <guido at python.org>
- BDFL 委托:
- Guido van Rossum <guido at python.org>
- 讨论至:
- Typing-SIG 邮件列表
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 主题:
- 类型标注
- 创建日期:
- 2019年3月20日
- Python 版本:
- 3.8
- 发布历史:
- 决议:
- Typing-SIG 消息
摘要
PEP 484定义了统一字典的类型Dict[K, V]
,其中每个值都具有相同的类型,并且支持任意键值。它没有正确支持常见模式,即字典值的类型取决于键的字符串值。本 PEP 提出了一个类型构造函数typing.TypedDict
,以支持字典对象具有一组特定字符串键,每个键具有特定类型值的用例。
这是一个PEP 484不允许我们满意地进行注解的示例
movie = {'name': 'Blade Runner',
'year': 1982}
本 PEP 建议添加一个新的类型构造函数,名为TypedDict
,以允许精确地表示movie
的类型
from typing import TypedDict
class Movie(TypedDict):
name: str
year: int
现在类型检查器应该接受这段代码
movie: Movie = {'name': 'Blade Runner',
'year': 1982}
动机
在 Python 程序中,使用(可能嵌套的)带有字符串键的字典(而不是用户定义的类)来表示对象或结构化数据是一种常见模式。表示 JSON 对象也许是典型的用例,而且这非常流行,以至于 Python 内置了 JSON 库。本 PEP 提出了一种允许更有效地对这类代码进行类型检查的方法。
更一般地说,仅使用 Python 原生类型(如字典、字符串和列表)来表示纯数据对象具有一定的吸引力。即使不使用 JSON,它们也易于序列化和反序列化。它们无需额外努力即可轻松支持各种有用的操作,包括美观打印(通过str()
和pprint
模块)、迭代和相等性比较。
PEP 484未能妥善支持上述用例。让我们考虑一个字典对象,它恰好有两个有效的字符串键,'name'
的值类型为str
,'year'
的值类型为int
。PEP 484类型Dict[str, Any]
是合适的,但它过于宽松,因为可以使用任意字符串键,并且任意值都有效。同样,Dict[str, Union[str, int]]
也过于通用,因为键'name'
的值可以是int
,并且允许任意字符串键。此外,诸如d['name']
的下标表达式(假设d
是这种类型的字典)的类型将是Union[str, int]
,这过于宽泛。
数据类是解决此用例的最新替代方案,但在数据类可用之前,仍有大量现有代码,特别是在大型现有代码库中,类型提示和检查已被证明是有益的。与字典对象不同,数据类不直接支持 JSON 序列化,尽管有一个第三方包实现了它[1]。
规范
TypedDict 类型表示具有一组特定字符串键的字典对象,以及每个有效键的特定值类型。每个字符串键可以是必需的(它必须存在)或非必需的(它不需要存在)。
本 PEP 提出了两种定义 TypedDict 类型的方法。第一种使用基于类的语法。第二种是替代的基于赋值的语法,为了向后兼容性而提供,以允许该功能可以移植到旧版本的 Python。其原理类似于PEP 484为何支持 Python 2.7 的基于注释的注解语法:类型提示对于大型现有代码库特别有用,而这些代码库通常需要在旧版本的 Python 上运行。这两个语法选项与typing.NamedTuple
支持的语法变体并行。其他提议的功能包括 TypedDict 继承和完整性(指定键是否必需)。
本 PEP 还概述了类型检查器如何支持涉及 TypedDict 对象的类型检查操作。与PEP 484类似,此讨论故意有些模糊,以允许尝试各种不同的类型检查方法。特别是,类型兼容性应基于结构兼容性:更具体的 TypedDict 类型可以与更小(更通用)的 TypedDict 类型兼容。
基于类的语法
可以使用基于类的定义语法来定义 TypedDict 类型,其中typing.TypedDict
是唯一的基类
from typing import TypedDict
class Movie(TypedDict):
name: str
year: int
Movie
是一个 TypedDict 类型,包含两个项:'name'
(类型为str
)和'year'
(类型为int
)。
类型检查器应验证基于类的 TypedDict 定义的主体符合以下规则
- 类体应只包含形式为
key: value_type
的项定义行,可选地前面有一个文档字符串。项定义的语法与属性注解相同,但不能有初始化器,并且键名实际上指的是键的字符串值而不是属性名。 - 类型注释不能与基于类的语法一起使用,以与基于类的
NamedTuple
语法保持一致。(请注意,支持类型注释不足以向后兼容 Python 2.7,因为类定义可能具有total
关键字参数,如下所述,这在 Python 2.7 中不是有效语法。)相反,本 PEP 提供了一个替代的、基于赋值的语法以实现向后兼容性,这将在替代语法中讨论。 - 字符串字面量前向引用在值类型中是有效的。
- 不允许使用方法,因为 TypedDict 对象的运行时类型将始终只是
dict
(它从不是dict
的子类)。 - 不允许指定元类。
可以通过在主体中只包含pass
来创建空 TypedDict(如果有文档字符串,则可以省略pass
)
class EmptyDict(TypedDict):
pass
使用 TypedDict 类型
以下是类型Movie
如何使用的示例
movie: Movie = {'name': 'Blade Runner',
'year': 1982}
通常需要显式的Movie
类型注解,否则类型检查器可能会假定为普通的字典类型,以实现向后兼容性。当类型检查器可以推断出构造的字典对象应该是一个 TypedDict 时,可以省略显式注解。一个典型的例子是作为函数参数的字典对象。在此示例中,类型检查器应推断字典参数应被理解为 TypedDict
def record_movie(movie: Movie) -> None: ...
record_movie({'name': 'Blade Runner', 'year': 1982})
类型检查器应将字典显示视为 TypedDict 的另一个示例是在赋值给先前声明的 TypedDict 类型的变量时
movie: Movie
...
movie = {'name': 'Blade Runner', 'year': 1982}
对movie
的操作可以由静态类型检查器检查
movie['director'] = 'Ridley Scott' # Error: invalid key 'director'
movie['year'] = '1982' # Error: invalid value type ("int" expected)
下面的代码应该被拒绝,因为'title'
不是一个有效的键,并且'name'
键缺失
movie2: Movie = {'title': 'Blade Runner',
'year': 1982}
创建的 TypedDict 类型对象不是一个真正的类对象。以下是类型检查器预期允许的类型对象的唯一用法
- 它可以用在类型注解中以及任何任意类型提示有效的上下文中,例如在类型别名中和作为强制转换的目标类型。
- 它可以作为可调用对象使用,其关键字参数对应于 TypedDict 项目。不允许非关键字参数。示例
m = Movie(name='Blade Runner', year=1982)
被调用时,TypedDict 类型对象在运行时返回一个普通的字典对象
print(type(m)) # <class 'dict'>
- 它可以用作基类,但仅在定义派生 TypedDict 时。这将在下面详细讨论。
特别是,TypedDict 类型对象不能用于isinstance()
测试,例如isinstance(d, Movie)
。原因是目前不支持检查字典项值的类型,因为isinstance()
不适用于许多PEP 484类型,包括常见的类型如List[str]
。这对于以下情况是必需的
class Strings(TypedDict):
items: List[str]
print(isinstance({'items': [1]}, Strings)) # Should be False
print(isinstance({'items': ['x']}, Strings)) # Should be True
上述用例不受支持。这与isinstance()
不支持List[str]
是一致的。
继承
使用基于类的语法,TypedDict 类型可以从一个或多个 TypedDict 类型继承。在这种情况下,不应包含TypedDict
基类。示例
class BookBasedMovie(Movie):
based_on: str
现在BookBasedMovie
拥有键name
、year
和based_on
。它等价于这个定义,因为 TypedDict 类型使用结构兼容性
class BookBasedMovie(TypedDict):
name: str
year: int
based_on: str
这是一个多重继承的例子
class X(TypedDict):
x: int
class Y(TypedDict):
y: str
class XYZ(X, Y):
z: bool
TypedDict XYZ
有三个项:x
(类型int
)、y
(类型str
)和z
(类型bool
)。
TypedDict 不能同时继承 TypedDict 类型和非 TypedDict 基类。
关于 TypedDict 类继承的额外说明
- 不允许在子类中更改父 TypedDict 类的字段类型。示例
class X(TypedDict): x: str class Y(X): x: int # Type check error: cannot overwrite TypedDict field "x"
在上面概述的示例中,TypedDict 类注解为键
x
返回类型str
print(Y.__annotations__) # {'x': <class 'str'>}
- 多重继承不允许同名字段存在冲突类型
class X(TypedDict): x: int class Y(TypedDict): x: str class XYZ(X, Y): # Type check error: cannot overwrite TypedDict field "x" while merging xyz: bool
总和性
默认情况下,所有键都必须存在于 TypedDict 中。可以通过指定 完整性 来覆盖此设置。以下是使用基于类的语法实现此目的的方法
class Movie(TypedDict, total=False):
name: str
year: int
这意味着Movie
TypedDict 可以省略任何键。因此,以下是有效的
m: Movie = {}
m2: Movie = {'year': 2015}
类型检查器只支持字面值False
或True
作为total
参数的值。True
是默认值,它使类体中定义的所有项都成为必需项。
完整性标志仅适用于 TypedDict 定义主体中定义的项。继承的项不受影响,而是使用它们定义所在的 TypedDict 类型的完整性。这使得在单个 TypedDict 类型中同时包含必需和非必需键成为可能。
替代语法
本 PEP 还提出了一个替代语法,可以向后移植到较旧的 Python 版本,例如 3.5 和 2.7,这些版本不支持PEP 526中引入的变量定义语法。它类似于定义命名元组的传统语法
Movie = TypedDict('Movie', {'name': str, 'year': int})
也可以使用替代语法指定总和性
Movie = TypedDict('Movie',
{'name': str, 'year': int},
total=False)
语义等同于基于类的语法。然而,这种语法不支持继承,并且无法在单个类型中同时拥有必需和非必需字段。这样做的动机是尽可能简化向后兼容的语法,同时涵盖最常见的用例。
类型检查器只接受字典显示表达式作为TypedDict
的第二个参数。特别是,为了简化实现,不需要支持引用字典对象的变量。
类型一致性
非正式地说,类型一致性 是 is-subtype-of 关系的一种泛化,以支持Any
类型。它在PEP 483中更正式地定义。本节介绍了支持 TypedDict 类型的类型一致性所需的新且非平凡的规则。
首先,任何 TypedDict 类型都与Mapping[str, object]
一致。其次,TypedDict 类型A
与 TypedDict B
一致,当且仅当A
在结构上与B
兼容。这当且仅当以下两个条件都满足
- 对于
B
中的每个键,A
具有相应的键,并且A
中相应的值类型与B
中的值类型一致。对于B
中的每个键,B
中的值类型也与A
中相应的值类型一致。 - 对于
B
中的每个必需键,A
中相应的键也是必需的。对于B
中的每个非必需键,A
中相应的键也是非必需的。
讨论
- 值类型的行为是不变的,因为 TypedDict 对象是可变的。这与可变容器类型(例如
List
和Dict
)类似。以下是相关示例class A(TypedDict): x: Optional[int] class B(TypedDict): x: int def f(a: A) -> None: a['x'] = None b: B = {'x': 0} f(b) # Type check error: 'B' not compatible with 'A' b['x'] + 1 # Runtime error: None + 1
- 具有必需键的 TypedDict 类型与相同键为非必需键的 TypedDict 类型不一致,因为后者允许删除键。以下是相关示例
class A(TypedDict, total=False): x: int class B(TypedDict): x: int def f(a: A) -> None: del a['x'] b: B = {'x': 0} f(b) # Type check error: 'B' not compatible with 'A' b['x'] + 1 # Runtime KeyError: 'x'
- 不含键
'x'
的 TypedDict 类型A
与具有非必需键'x'
的 TypedDict 类型不一致,因为在运行时,键'x'
可能存在并具有不兼容的类型(由于结构子类型,可能无法通过A
看到)。示例class A(TypedDict, total=False): x: int y: int class B(TypedDict, total=False): x: int class C(TypedDict, total=False): x: int y: str def f(a: A) -> None: a['y'] = 1 def g(b: B) -> None: f(b) # Type check error: 'B' incompatible with 'A' c: C = {'x': 0, 'y': 'foo'} g(c) c['y'] + 'bar' # Runtime error: int + str
- TypedDict 与任何
Dict[...]
类型不一致,因为字典类型允许破坏性操作,包括clear()
。它们还允许设置任意键,这会损害类型安全。示例class A(TypedDict): x: int class B(A): y: str def f(d: Dict[str, int]) -> None: d['y'] = 0 def g(a: A) -> None: f(a) # Type check error: 'A' incompatible with Dict[str, int] b: B = {'x': 0, 'y': 'foo'} g(b) b['y'] + 'bar' # Runtime error: int + str
- 具有所有
int
值的 TypedDict 与Mapping[str, int]
不一致,因为可能存在通过该类型不可见的额外非int
值,原因在于结构子类型。例如,这些值可以通过Mapping
中的values()
和items()
方法访问。示例class A(TypedDict): x: int class B(TypedDict): x: int y: str def sum_values(m: Mapping[str, int]) -> int: n = 0 for v in m.values(): n += v # Runtime error return n def f(a: A) -> None: sum_values(a) # Error: 'A' incompatible with Mapping[str, int] b: B = {'x': 0, 'y': 'foo'} f(b)
支持和不支持的操作
类型检查器应支持对 TypedDict 对象的大多数dict
操作的受限形式。指导原则是,如果操作可能违反运行时类型安全,则不涉及Any
类型的操作应被类型检查器拒绝。以下是一些最重要的要防止的类型安全违规
- 缺少必需的键。
- 值具有无效类型。
- 添加了 TypedDict 类型中未定义的键。
通常应拒绝不是字面量的键,因为其值在类型检查期间是未知的,因此可能导致上述某些违规。(Final 值和 Literal 类型的使用将此推广到涵盖最终名称和字面量类型。)
使用未知存在的键应报告为错误,即使这不一定会在运行时产生类型错误。这些通常是错误,如果结构子类型隐藏了某些项目的类型,则它们可能会插入无效类型的值。例如,如果'x'
不是d
(假定为 TypedDict 类型)的有效键,则d['x'] = 1
应生成类型检查错误。
TypedDict 对象构造中包含的多余键也应该被捕获。在此示例中,director
键未在Movie
中定义,并预期会从类型检查器生成错误
m: Movie = dict(
name='Alien',
year=1979,
director='Ridley Scott') # error: Unexpected key 'director'
类型检查器应拒绝以下对 TypedDict 对象的操作,因为它们不安全,即使它们对普通字典是有效的
- 带有任意
str
键(而不是字符串字面量或其他具有已知字符串值的表达式)的操作通常应被拒绝。这涉及破坏性操作(如设置项)和只读操作(如下标表达式)。作为上述规则的一个例外,d.get(e)
和e in d
应允许用于 TypedDict 对象,对于类型为str
的任意表达式e
。这样做的动机是它们是安全的,并且对于自省 TypedDict 对象很有用。如果e
的字符串值无法静态确定,则d.get(e)
的静态类型应为object
。 clear()
不安全,因为它可能删除必需的键,其中一些可能由于结构子类型而不可直接见。popitem()
同样不安全,即使所有已知键都不是必需的(total=False
)。del obj['key']
应该被拒绝,除非'key'
是一个非必需键。
类型检查器可能允许使用d['x']
读取项,即使键'x'
不是必需的,而不是要求使用d.get('x')
或显式的'x' in d
检查。理由是全面跟踪键的存在性很难实现,并且禁止此操作可能需要对现有代码进行大量更改。
具体的类型检查规则由每个类型检查器决定。在某些情况下,如果替代方案是对惯用代码生成误报错误,则可能会接受潜在不安全的操作。
Final 值和 Literal 类型的使用
类型检查器应允许在 TypedDict 对象操作中使用具有字符串值的 final 名称(PEP 591)来代替字符串字面量。例如,这是有效的
YEAR: Final = 'year'
m: Movie = {'name': 'Alien', 'year': 1979}
years_since_epoch = m[YEAR] - 1970
同样,可以使用具有合适字面量类型(PEP 586)的表达式来代替字面量值
def get_value(movie: Movie,
key: Literal['year', 'name']) -> Union[int, str]:
return movie[key]
类型检查器仅支持实际的字符串字面量,而不支持 final 名称或字面量类型,用于在 TypedDict 类型定义中指定键。此外,只能使用布尔字面量在 TypedDict 定义中指定完整性。这样做的动机是使类型声明自包含,并简化类型检查器的实现。
向后兼容性
为了保持向后兼容性,类型检查器不应推断 TypedDict 类型,除非程序员明确表示需要。如果不确定,应推断为普通字典类型。否则,一旦 TypedDict 支持添加到类型检查器中,现有代码(以前类型检查无错误)可能会开始生成错误,因为 TypedDict 类型比字典类型更具限制性。特别是,它们不是字典类型的子类型。
参考实现
mypy [2]类型检查器支持 TypedDict 类型。运行时组件的参考实现由typing_extensions
[3]模块提供。最初的实现是在mypy_extensions
[4]模块中。
被拒绝的替代方案
一些提出的想法被拒绝了。目前的功能集似乎涵盖了广泛的范围,并且不清楚提出的哪些扩展会比仅仅略微有用。本 PEP 定义了一个基线功能,将来可能会扩展。
这些原则上被拒绝,因为它们与本提案的精神不符
- TypedDict 不可扩展,它只解决一个特定用例。TypedDict 对象在运行时是常规字典,TypedDict 不能与其他类似字典或映射的类一起使用,包括
dict
的子类。没有办法向 TypedDict 类型添加方法。这里的动机是简单性。 - TypedDict 类型定义可能用于执行字典的运行时类型检查。例如,它们可以用于验证 JSON 对象是否符合 TypedDict 类型指定的模式。本 PEP 不包括此类功能,因为本提案的重点仅在于静态类型检查,并且如基于类的语法中讨论的,其他现有类型不支持此功能。此类功能可以通过第三方库使用
typing_inspect
[5]第三方模块等方式提供。 - TypedDict 类型不能用于
isinstance()
或issubclass()
检查。原因与许多类型提示通常不支持运行时类型检查的原因类似。
这些功能被本 PEP 排除在外,但它们是未来可能添加的扩展
致谢
David Foster 为 mypy 贡献了 TypedDict 类型的最初实现。至少作者(Jukka Lehtosalo)、Ivan Levkivskyi、Gareth T、Michael Lee、Dominik Miedzinski、Roy Williams 和 Max Moroz 贡献了对实现的改进。
参考资料
版权
本文档已置于公共领域。
来源:https://github.com/python/peps/blob/main/peps/pep-0589.rst