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类型兼容。
基于类的语法
可以使用类定义语法和typing.TypedDict
作为唯一基类来定义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 中。可以通过指定 *totality* 来覆盖此行为。以下是如何使用基于类的语法实现此目的
class Movie(TypedDict, total=False):
name: str
year: int
这意味着 Movie
TypedDict 可以省略任何键。因此,这些都是有效的
m: Movie = {}
m2: Movie = {'year': 2015}
类型检查器仅预期支持 False
或 True
作为 total
参数的值。 True
为默认值,并使类主体中定义的所有项目都成为必需的。
totality 标志仅适用于 TypedDict 定义主体中定义的项目。继承的项目不会受到影响,而是使用定义它们的 TypedDict 类型的 totality。这使得可以在单个 TypedDict 类型中组合必需键和非必需键。
替代语法
本 PEP 还提出了一种替代语法,可以向旧版本的 Python(例如 3.5 和 2.7)进行反向移植,这些版本不支持 PEP 526 中引入的变量定义语法。它类似于定义命名元组的传统语法
Movie = TypedDict('Movie', {'name': str, 'year': int})
也可以使用替代语法指定 totality
Movie = TypedDict('Movie',
{'name': str, 'year': int},
total=False)
语义等效于基于类的语法。但是,此语法不支持继承,并且无法在单个类型中同时具有必需字段和非必需字段。这样做的目的是使向后兼容的语法尽可能简单,同时涵盖最常见的用例。
类型检查器仅预期接受字典显示表达式作为 TypedDict
的第二个参数。特别是,无需支持引用字典对象的变量,以简化实现。
类型一致性
非正式地说,*类型一致性* 是将 is-subtype-of 关系推广到支持 Any
类型。它在 PEP 483 中更正式地定义。本节介绍了支持 TypedDict 类型类型一致性所需的新颖的非平凡规则。
首先,任何 TypedDict 类型都与 Mapping[str, object]
一致。其次,如果 A
与 B
结构兼容,则 TypedDict 类型 A
与 TypedDict B
一致。当且仅当满足以下两个条件时,此条件才为真
- 对于
B
中的每个键,A
具有相应的键,并且A
中相应的 value type 与B
中的 value type 一致。对于B
中的每个键,B
中的 value type 也与A
中的相应 value type 一致。 - 对于
B
中的每个必需键,A
中的相应键是必需的。对于B
中的每个非必需键,A
中的相应键不是必需的。
讨论
- Value type 的行为是不变的,因为 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
- 所有 value type 都是
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
类型,则如果它们可能违反运行时类型安全性,则类型检查器应拒绝这些操作。以下是一些需要防止的最重要的类型安全违规行为
- 缺少必需键。
- value type 无效。
- 添加了 TypedDict 类型中未定义的键。
通常应拒绝不是字面量的键,因为在类型检查期间其值未知,因此可能导致上述某些违规行为。(最终值和字面量类型的使用 将其推广到涵盖最终名称和字面量类型。)
即使这不会必然生成运行时类型错误,也应将使用未知存在的键报告为错误。这些通常是错误,如果结构子类型隐藏了某些项目的类型,则这些错误可能会插入具有无效类型的 value type。例如,如果 '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
键(而不是字符串字面量或其他具有已知字符串值的表达式)的操作通常应被拒绝。这涉及破坏性操作(例如设置项目)和只读操作(例如订阅表达式)。作为上述规则的例外,对于 TypedDict 对象,应允许d.get(e)
和e in d
,对于类型为str
的任意表达式e
。这样做的动机是这些操作是安全的,并且可用于检查 TypedDict 对象。如果无法静态确定e
的字符串值,则d.get(e)
的静态类型应为object
。 clear()
不安全,因为它可能会删除必需键,其中一些可能由于结构子类型而无法直接查看。即使所有已知键都不是必需的(total=False
),popitem()
也同样不安全。del obj['key']
应被拒绝,除非'key'
是非必需键。
即使键 'x'
不是必需的,类型检查器也可能允许使用 d['x']
读取项目,而不是要求使用 d.get('x')
或显式的 'x' in d
检查。其基本原理是,全面跟踪键的存在性难以实现,并且不允许这样做可能需要对现有代码进行许多更改。
具体的类型检查规则由每个类型检查器自行决定。在某些情况下,如果为了避免为惯用代码生成误报错误,可能会接受潜在的不安全操作。
最终值和字面量类型的使用
类型检查器应该允许使用带有字符串值的最终名称(PEP 591)代替字符串字面量,用于 TypedDict 对象的操作。例如,以下代码有效
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]
类型检查器仅期望支持实际的字符串字面量,而不是最终名称或字面量类型,用于在 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 中排除,但它们是将来可能添加的扩展。
- TypedDict 不支持为未明确定义的键提供 *默认值类型*。这将允许使用任意键与 TypedDict 对象一起使用,并且只有显式枚举的键才会与普通的、统一的字典类型相比受到特殊处理。
- 无法单独指定每个键是否必填。没有提议的语法足够清晰,我们预计对此的需求有限。
- TypedDict 不能用于指定
**kwargs
参数的类型。这将允许限制允许的关键字参数及其类型。根据 PEP 484,使用 TypedDict 类型作为**kwargs
的类型意味着 TypedDict 作为任意关键字参数的 *值* 是有效的,但它不限制允许哪些关键字参数。语法**kwargs: Expand[T]
已为此提出 [6]。
致谢
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