PEP 692 – 使用 TypedDict 实现更精确的 **kwargs 类型提示
- 作者:
- Franek Magiera <framagie at gmail.com>
- 发起人:
- Jelle Zijlstra <jelle.zijlstra at gmail.com>
- 讨论至:
- Discourse 帖子
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 主题:
- 类型标注
- 创建日期:
- 2022年5月29日
- Python 版本:
- 3.12
- 发布历史:
- 2022年5月29日, 2022年7月12日, 2022年7月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 进行类型注解是不可能的。这对于已经存在的代码库来说尤其是一个问题,因为为了引入适当的类型注解而重构代码可能被认为不值得付出努力。这反过来又阻碍了项目获得类型提示所能提供的所有好处。
此外,在公共 API 中有一个顶级函数调用一组辅助函数,而所有这些辅助函数都期望相同的关键字参数的情况下,**kwargs 可以用来减少所需的代码量。不幸的是,如果这些辅助函数使用 **kwargs,那么如果它们期望的关键字参数类型不同,就没有办法正确地对其进行类型提示。此外,即使关键字参数类型相同,也无法检查函数是否使用它实际期望的关键字名称进行调用。
如预期用途一节所述,使用**kwargs并非总是最佳选择。尽管如此,它仍然是一种广泛使用的模式。因此,关于支持更精确的**kwargs类型提示的讨论很多,它成为了Python社区大部分成员都认为有价值的功能。这最好地体现在mypy GitHub issue 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,必须引入一个新的构造。
为了支持这个用例,我们建议重用最初在 PEP 646 中引入的 Unpack。这样做有几个原因:
- 它的名称对于
**kwargs类型提示用例来说非常合适且直观,因为我们的目的是从提供的TypedDict中“解包”关键字参数。 - 目前
*args的类型化方式将扩展到**kwargs,并且它们应该表现相似。 - 无需引入任何新的特殊形式。
Unpack用于本 PEP 中描述的目的,不会干扰 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 参数传递给带有 Unpack 注解的 **kwargs 的函数时,必须生成类型检查器错误。另一方面,使用标准、未类型化字典的函数的行为可能取决于类型检查器。例如
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 的函数,因为这样额外的关键字在函数调用期间不会在运行时引起错误。否则,类型检查器应生成错误。
在与上述 bar 函数类似的情况下,可以通过显式解引用所需字段并将它们用作参数来执行函数调用来解决问题
def bar(**kwargs: Unpack[Animal]):
name = kwargs["name"]
takes_name(name)
将 Unpack 与 TypedDict 以外的类型一起使用
如理由一节所述,TypedDict 是类型化 **kwargs 最自然的候选者。因此,在类型化 **kwargs 的上下文中,不应允许将 Unpack 与 TypedDict 以外的类型一起使用,并且类型检查器在这种情况下应生成错误。
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 文档中。
参考实现
mypy 类型检查器已经支持使用 Unpack 进行更精确的 **kwargs 类型提示。
Pyright 类型检查器也提供了对此功能的初步支持。
被拒绝的想法
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
相反,可以重载一个期望 TypedDict 联合的函数
@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 特殊形式添加新的双下划线。同时,引入新语法的理由不够充分,并成为整个 PEP 的阻碍。因此,我们决定放弃在本 PEP 中引入新语法的想法,并可能在单独的 PEP 中再次提出。
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0692.rst