PEP 692 – 使用 TypedDict 实现更精确的 **kwargs 类型
- 作者:
- Franek Magiera <framagie at gmail.com>
- 赞助人:
- Jelle Zijlstra <jelle.zijlstra at gmail.com>
- 讨论列表:
- Discourse 帖子
- 状态:
- 最终
- 类型:
- 标准跟踪
- 主题:
- 类型提示
- 创建日期:
- 2022-05-29
- Python 版本:
- 3.12
- 历史记录:
- 2022-05-29, 2022-07-12, 2022-07-12
- 决议:
- Discourse 消息
摘要
目前,**kwargs
可以使用类型提示,只要所有由其指定的关键字参数都是相同类型即可。但是,这种行为可能非常有限。因此,在本 PEP 中,我们提出了一种新的方法来启用更精确的 **kwargs
类型提示。新方法围绕使用 TypedDict
来对包含不同类型关键字参数的 **kwargs
进行类型提示。
动机
目前,使用类型 T
对 **kwargs
进行注释意味着 kwargs
类型实际上是 dict[str, T]
。例如
def foo(**kwargs: str) -> None: ...
表示 foo
中的所有关键字参数都是字符串(即 kwargs
的类型为 dict[str, str]
)。这种行为限制了仅对所有关键字参数都是相同类型的情况进行 **kwargs
类型注释的能力。但是,关键字参数通常由 **kwargs
传递,并且具有不同的类型,这些类型取决于关键字的名称。在这些情况下,无法对 **kwargs
进行类型注释。这对于已经存在的代码库尤其是一个问题,因为为了引入正确的类型注释而重构代码的需求可能被认为不值得付出努力。这反过来又阻止了项目获得类型提示可以提供的所有好处。
此外,**kwargs
可用于减少代码量,在某些情况下,存在一个作为公共 API 一部分的顶级函数,它调用了许多辅助函数,所有这些函数都期望相同的关键字参数。不幸的是,如果这些辅助函数要使用 **kwargs
,则如果它们期望的关键字参数类型不同,则无法正确地对其进行类型提示。此外,即使关键字参数类型相同,也无法检查函数是否使用它实际期望的关键字名称进行调用。
如 预期用途 部分所述,使用 **kwargs
并不总是最佳选择。尽管如此,它仍然是一种广泛使用的模式。因此,围绕支持更精确的 **kwargs
类型提示进行了大量讨论,并且它成为对 Python 社区很大一部分有价值的功能。这一点在 mypy GitHub 问题 4441 中得到了最好的体现,该问题包含了许多可以从本提案中受益的实际案例。
另一个值得一提的使用案例是 **kwargs
方便的地方,即当函数应该适应没有默认值的可选关键字参数时。当通常用作默认值以指示没有用户输入的值(如 None
)可以由用户传入并应导致有效的非默认行为时,可能会出现对这种模式的需求。例如,此问题 出现在 流行的 httpx
库中。
基本原理
PEP 589 引入了 TypedDict
类型构造器,它支持由字符串键和可能不同类型的值组成的字典类型。由以双星号开头的形式参数(例如 **kwargs
)表示的函数的关键字参数作为字典接收。此外,此类函数通常使用解包字典来提供关键字参数。这使得 TypedDict
成为用于更精确的 **kwargs
类型提示的完美候选者。此外,使用 TypedDict
,可以在静态类型分析期间考虑关键字名称。但是,使用 TypedDict
指定 **kwargs
类型意味着,如前所述,由 **kwargs
指定的每个关键字参数本身都是一个 TypedDict
。例如
class Movie(TypedDict):
name: str
year: int
def foo(**kwargs: Movie) -> None: ...
表示 foo
中的每个关键字参数本身都是一个 Movie
字典,该字典具有一个类型值为字符串的 name
键和一个类型值为整数的 year
键。因此,为了支持将 kwargs
类型指定为 TypedDict
而不破坏当前行为,必须引入新的构造。
为了支持此用例,我们建议重用 Unpack
,该构造最初在 PEP 646 中引入。这样做有几个原因
- 它的名称非常适合并且直观,适用于
**kwargs
类型提示用例,因为我们的目的是从提供的TypedDict
中“解包”关键字参数。 *args
的当前类型提示方式将扩展到**kwargs
,并且它们应该具有类似的行为。- 无需引入任何新的特殊形式。
- 在本 PEP 中描述的目的使用
Unpack
不会干扰 PEP 646 中描述的使用案例。
规范
使用 Unpack
,我们引入了一种新的注释 **kwargs
的方法。继续前面的示例
def foo(**kwargs: Unpack[Movie]) -> None: ...
表示 **kwargs
包含由 Movie
指定的两个关键字参数(即类型为 str
的 name
关键字和类型为 int
的 year
关键字)。这表示应如下调用该函数
kwargs: Movie = {"name": "Life of Brian", "year": 1979}
foo(**kwargs) # OK!
foo(name="The Meaning of Life", year=1983) # OK!
当使用 Unpack
时,类型检查器将函数体内的 kwargs
视为 TypedDict
def foo(**kwargs: Unpack[Movie]) -> None:
assert_type(kwargs, Movie) # OK!
使用新的注释不会有任何运行时影响 - 类型检查器应该只考虑它。以下各节中提到的任何错误都与类型检查器错误相关。
使用标准字典的函数调用
将类型为 dict[str, object]
的字典作为 **kwargs
参数传递给 **kwargs
使用 Unpack
进行注释的函数必须生成类型检查器错误。另一方面,对于使用标准未类型化字典的函数的行为可能取决于类型检查器。例如
def foo(**kwargs: Unpack[Movie]) -> None: ...
movie: dict[str, object] = {"name": "Life of Brian", "year": 1979}
foo(**movie) # WRONG! Movie is of type dict[str, object]
typed_movie: Movie = {"name": "The Meaning of Life", "year": 1983}
foo(**typed_movie) # OK!
another_movie = {"name": "Life of Brian", "year": 1979}
foo(**another_movie) # Depends on the type checker.
关键字冲突
用于对 **kwargs
进行类型提示的 TypedDict
可能会包含函数签名中已定义的键。如果重复的名称是标准参数,则类型检查器应报告错误。如果重复的名称是仅限位置的参数,则不应生成任何错误。例如
def foo(name, **kwargs: Unpack[Movie]) -> None: ... # WRONG! "name" will
# always bind to the
# first parameter.
def foo(name, /, **kwargs: Unpack[Movie]) -> None: ... # OK! "name" is a
# positional-only parameter,
# so **kwargs can contain
# a "name" keyword.
必填和非必填键
默认情况下,TypedDict
中的所有键都是必需的。可以通过将字典的 total
参数设置为 False
来覆盖此行为。此外,PEP 655 引入了新的类型限定符 - typing.Required
和 typing.NotRequired
- 用于指定特定键是必需的还是可选的。
class Movie(TypedDict):
title: str
year: NotRequired[int]
当使用 TypedDict
为 **kwargs
键入时,所有必需键和非必需键都应分别对应于必需和非必需的函数关键字参数。因此,如果调用方不支持必需键,则类型检查器必须报告错误。
赋值
仅当函数类型与 **kwargs: Unpack[Movie]
键入的函数和其他可调用类型兼容时,它们的赋值才能通过类型检查。这可能发生在下面描述的场景中。
源和目标都包含 **kwargs
目标函数和源函数都具有 **kwargs: Unpack[TypedDict]
参数,并且目标函数的 TypedDict
可分配给源函数的 TypedDict
,并且其余参数兼容。
class Animal(TypedDict):
name: str
class Dog(Animal):
breed: str
def accept_animal(**kwargs: Unpack[Animal]): ...
def accept_dog(**kwargs: Unpack[Dog]): ...
accept_dog = accept_animal # OK! Expression of type Dog can be
# assigned to a variable of type Animal.
accept_animal = accept_dog # WRONG! Expression of type Animal
# cannot be assigned to a variable of type Dog.
源包含 **kwargs
,目标不包含
目标可调用对象不包含 **kwargs
,源可调用对象包含 **kwargs: Unpack[TypedDict]
,并且目标函数的关键字参数可分配给源函数的 TypedDict
中的相应键。此外,非必需键应对应于可选函数参数,而必需键应对应于必需函数参数。同样,其余参数必须兼容。继续前面的示例
class Example(TypedDict):
animal: Animal
string: str
number: NotRequired[int]
def src(**kwargs: Unpack[Example]): ...
def dest(*, animal: Dog, string: str, number: int = ...): ...
dest = src # OK!
值得指出的是,目标函数的参数必须是仅限关键字参数,才能与 TypedDict
中的键和值兼容。
def dest(dog: Dog, string: str, number: int = ...): ...
dog: Dog = {"name": "Daisy", "breed": "labrador"}
dest(dog, "some string") # OK!
dest = src # Type checker error!
dest(dog, "some string") # The same call fails at
# runtime now because 'src' expects
# keyword arguments.
当目标可调用对象包含 **kwargs: Unpack[TypedDict]
而源可调用对象不包含 **kwargs
时,应不允许这种情况。这是因为,当子类的实例被分配给具有基类类型的变量,然后在目标可调用对象的调用中解包时,我们无法确定是否正在传递额外的关键字参数。
def dest(**kwargs: Unpack[Animal]): ...
def src(name: str): ...
dog: Dog = {"name": "Daisy", "breed": "Labrador"}
animal: Animal = dog
dest = src # WRONG!
dest(**animal) # Fails at runtime.
即使没有继承,类似的情况也可能发生,因为 TypedDict
之间的兼容性是基于结构化子类型的。
源包含未类型化的 **kwargs
目标可调用对象包含 **kwargs: Unpack[TypedDict]
,源可调用对象包含未键入的 **kwargs
。
def src(**kwargs): ...
def dest(**kwargs: Unpack[Movie]): ...
dest = src # OK!
源包含传统类型化的 **kwargs: T
目标可调用对象包含 **kwargs: Unpack[TypedDict]
,源可调用对象包含传统键入的 **kwargs: T
,并且目标函数 TypedDict
的每个字段都可分配给类型为 T
的变量。
class Vehicle:
...
class Car(Vehicle):
...
class Motorcycle(Vehicle):
...
class Vehicles(TypedDict):
car: Car
moto: Motorcycle
def dest(**kwargs: Unpack[Vehicles]): ...
def src(**kwargs: Vehicle): ...
dest = src # OK!
另一方面,如果目标可调用对象包含未键入的或传统键入的 **kwargs: T
,而源可调用对象使用 **kwargs: Unpack[TypedDict]
键入,则应生成错误,因为传统键入的 **kwargs
不会检查关键字名称。
总而言之,函数参数应表现为逆变的,函数返回值应表现为协变的。
在函数内部将 kwargs 传递给另一个函数
前面的一点 提到了通过将子类实例分配给具有基类类型的变量来可能传递额外关键字参数的问题。让我们考虑以下示例
class Animal(TypedDict):
name: str
class Dog(Animal):
breed: str
def takes_name(name: str): ...
dog: Dog = {"name": "Daisy", "breed": "Labrador"}
animal: Animal = dog
def foo(**kwargs: Unpack[Animal]):
print(kwargs["name"].capitalize())
def bar(**kwargs: Unpack[Animal]):
takes_name(**kwargs)
def baz(animal: Animal):
takes_name(**animal)
def spam(**kwargs: Unpack[Animal]):
baz(kwargs)
foo(**animal) # OK! foo only expects and uses keywords of 'Animal'.
bar(**animal) # WRONG! This will fail at runtime because 'breed' keyword
# will be passed to 'takes_name' as well.
spam(**animal) # WRONG! Again, 'breed' keyword will be eventually passed
# to 'takes_name'.
在上面的示例中,对 foo
的调用不会在运行时导致任何问题。即使 foo
期望类型为 Animal
的 kwargs
,但如果它接收了额外的参数也没有关系,因为它只读取和使用它需要的部分,完全忽略任何额外的值。
对 bar
和 spam
的调用将失败,因为一个意外的关键字参数将传递给 takes_name
函数。
因此,用解包的 TypedDict
提示的 kwargs
只能传递给另一个函数,如果要传递解包的 kwargs 的函数在其签名中也包含 **kwargs
,因为这样额外的关键字在函数调用期间的运行时不会导致错误。否则,类型检查器应该生成错误。
在类似于上面 bar
函数的情况下,可以通过显式地取消引用所需字段并将其用作参数来执行函数调用,从而解决此问题。
def bar(**kwargs: Unpack[Animal]):
name = kwargs["name"]
takes_name(name)
使用 Unpack
与除 TypedDict
以外的其他类型
如 基本原理 部分所述,TypedDict
是键入 **kwargs
最自然的候选者。因此,在键入 **kwargs
的上下文中,使用除 TypedDict
之外的其他类型的 Unpack
应该不被允许,类型检查器应该在这种情况下生成错误。
对 Unpack
的更改
目前,在键入上下文中使用 Unpack
与使用星号语法是可互换的。
>>> Unpack[Movie]
*<class '__main__.Movie'>
因此,为了与新的用例兼容,Unpack
的 repr
应该更改为简单的 Unpack[T]
。
预期用途
本提案的预期用例在 动机 部分进行了描述。总之,更精确的 **kwargs
键入可以为已经存在的代码库带来好处,这些代码库最初决定使用 **kwargs
,但现在已经足够成熟,可以通过类型提示使用更严格的契约。使用 **kwargs
还可以帮助减少代码重复和复制粘贴的数量,当有一堆函数需要相同的关键字参数集时。最后,**kwargs
对于函数需要方便没有明显默认值的可选关键字参数的情况很有用。
但是,必须指出的是,在某些情况下,与本 PEP 中提出的使用 TypedDict
键入 **kwargs
相比,还有更好的工具可供选择。例如,在编写新代码时,如果所有关键字参数都是必需的或具有默认值,那么显式地编写所有内容都比使用 **kwargs
和 TypedDict
更好。
def foo(name: str, year: int): ... # Preferred way.
def foo(**kwargs: Unpack[Movie]): ...
类似地,当通过存根为第三方库键入类型时,最好显式地声明函数签名 - 这是键入此类函数的唯一方法,如果它有默认参数。在这种情况下尝试使用 TypedDict
为函数键入类型时,可能会出现另一个问题,即某些标准函数参数可能会被视为仅限关键字参数。
def foo(name, year): ... # Function in a third party library.
def foo(Unpack[Movie]): ... # Function signature in a stub file.
foo("Life of Brian", 1979) # This would be now failing type
# checking but is fine.
foo(name="Life of Brian", year=1979) # This would be the only way to call
# the function now that passes type
# checking.
因此,在这种情况下,最好再次显式地键入此类函数,如下所示:
def foo(name: str, year: int): ...
此外,为了使 IDE 和文档页面受益,公共 API 中的函数应尽可能优先使用显式关键字参数。
如何教授
本 PEP 可以链接到 typing
模块的文档中。此外,可以在上述文档中添加一个关于使用 Unpack
的新部分。类似的部分也可以添加到 mypy 文档 和 typing RTD 文档 中。
参考实现
mypy 类型检查器 已经 支持 使用 Unpack
进行更精确的 **kwargs
键入。
被拒绝的想法
TypedDict
联合
可以创建键入字典的联合。但是,支持使用键入字典的联合键入 **kwargs
将大大增加本 PEP 实现的复杂性,并且似乎没有令人信服的用例来证明支持这一点。因此,使用键入字典的联合键入本 PEP 上下文中描述的 **kwargs
会导致错误。
class Book(TypedDict):
genre: str
pages: int
TypedDictUnion = Movie | Book
def foo(**kwargs: Unpack[TypedDictUnion]) -> None: ... # WRONG! Unsupported use
# of a union of
# TypedDicts to type
# **kwargs
相反,期望键入字典联合的函数可以重载。
@overload
def foo(**kwargs: Unpack[Movie]): ...
@overload
def foo(**kwargs: Unpack[Book]): ...
更改 **kwargs
注释的含义
实现本 PEP 目的的一种方法是更改 **kwargs
注释的含义,以便注释应用于整个 **kwargs
字典,而不是单个元素。为了保持一致性,我们必须对 *args
注释进行类似的更改。
这个想法在一次键入社区会议上进行了讨论,并且大家一致认为这种改变不值得付出代价。没有明确的迁移路径,*args
和 **kwargs
注释的当前含义在生态系统中已得到很好的确立,并且类型检查器必须为当前合法的代码引入新的错误。
引入新的语法
在本 PEP 的先前版本中,建议使用双星号语法来支持更精确的 **kwargs
键入。使用此语法,函数可以如下注释
def foo(**kwargs: **Movie): ...
这将与以下含义相同:
def foo(**kwargs: Unpack[Movie]): ...
这大大增加了 PEP 的范围,因为它需要更改语法并为 Unpack
特殊形式添加新的 dunder。同时,引入新语法的理由也不够充分,并成为整个 PEP 的障碍。因此,我们决定放弃将新语法作为本 PEP 的一部分引入的想法,并可能在单独的 PEP 中再次提出。
版权
本文档置于公共领域或根据 CC0-1.0-Universal 许可证,以两者中许可范围更宽者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0692.rst
上次修改时间:2024-02-16 16:12:21 GMT