问题描述
看到 TS 那边有一个这样的问题,所以给 Python 这边也提一个。
自问自答。我必须提名 TypedDict 和 Unpack。虽然后者还是个实验特性,但已经得到很多静态类型检查器的支持了,比如 Pyright.
你是否对这种代码深恶痛绝:
def draw_base_chart(color: str, height: int = 400, width: int = 600) -> None:
...
def draw_bar_chart(data: list[int], *args, **kwargs) -> None:
...
draw_base_chart(*args, **kwargs)
...这两个 *args 和 **kwargs 可以说是直接把 Type Hints 给干碎了!你在编辑器里也没法从这东西里获得任何提示:

在这里,你试图敲个 wid 来提示编辑器自动把 width 给补全上,但是很遗憾,因为 *args 和 **kwargs 的滥用,编辑器的智能提示被干烂了。

而且可以看到,虽然当你少了 data 时,编辑器会告诉你少了个参数——
但是当你少了 color,同样是个必选参数时:

编辑器就完全没有报错,同样“得益于”*args 和 **kwargs 的滥用。
Python 社区是否意识到了这个问题?他们当然早就意识到了,所以现在有了个实验性的解决方案。不过在此之前,让我们先回顾一下 Python 中对 *args 和 **kwargs 的类型提示。
对 *args 和 **kwargs 的 Type Hints
在 Python 中,对 *args 和 **kwargs 的类型提示大概长这样:
def tag(
name: str,
*content: str,
**attrs: str,
) -> str: ...你可能会感到有点奇怪——为什么不是 tuple[str] 和 dict[str, str],而是全都是 str?
当然,这显然是为了简洁性考虑,因为 *args 一定是个 tuple[...],而 **kwargs 一定是个 dict[str, ...],所以你只需要把这个 ... 的类型标上就行。
可是,等等,那么如果我想为 **kwargs 中各个属性分别指定类型怎么办?dict[str, str] 这种类型也太宽泛了吧?
于是 TypedDict 来了,它可以为一个字典指定更细致的类型。
TypedDict 的引入
显然,dict[KeyType, ValueType] 这样的类型提示太过宽泛了。考虑这种情况:
bd = {
'isbn': '0201657880',
'title': 'Programming Pearls',
'authors': ['Jon Bentley'],
'pagecount': 256,
}
def get_isbn(book: dict[str, Any]) -> str:
return book['isbn']现在你有许多类似这里 bd 的字典,用来表示图书信息。然后你有个 get_isbn 函数,用于获取图书的 ISBN 号。这东西的一大问题就是根本无法确定 book 上是否存在 isbn 这个属性,即使你写出这样的代码:

编辑器也完全不会报错,尽管这段代码一看就知道执行时会报错。静态检查器无法从 dict[str, Any] 这样宽泛的类型信息中得到任何进一步的信息。
自从 Python 3.8 引入了 TypedDict 之后,一切好多了:
from typing import NotRequired, TypedDict
class BookDict(TypedDict):
isbn: str
title: str
authors: list[str]
pagecount: NotRequired[int]
bd: BookDict = {
'isbn': '0201657880',
'title': 'Programming Pearls',
'authors': ['Jon Bentley'],
'pagecount': 256,
}
def get_isbn(book: BookDict) -> str:
return book['isbn']现在有了报错:

并且也有了提示:


想象一下,如果这里的 BookDict 有几十上百个属性,没有提示该有多痛苦。
顺便一提,这里的 NotRequired 是 Python 3.11 新增的,用来表示某个属性可有可没有,这不同于简单加个 | None,后者表示这个属性还是必须存在,但是可以为 None.
如果你用的 Python 版本不高,使用 Python 官方的 typing_extensions 也可以使用这些特性。
需要注意一下,TypedDict 虽然看起来好像是定义了一个 class,和 dataclass 很像,但它实际上不会在运行时基于定义做任何校验。实际上,TypedDict 也不是个 class,并且不能被继承,这和 Protocol 比较一致。如果你还不了解 Protocol,可以参见这里:
也就是说,这样的代码在运行时实际上不会出错:
bd = BookDict(isbn='0201657880', title='Programming Pearls',
authors='Jon Bentley', pagecount=256)注意到这里 authors 期待接受一个字符串数组,却传入了一个字符串,Python 运行时也不会报错。这里顺便也展示了下 TypedDict 的另一种实例化方法。
当然,一般来说仅静态检查已经足够了,毕竟一般不会有人把编辑器标红的类型错误不当回事,但如果你需要处理一些不可信数据源,比如 JSON,请千万不要依赖于 TypedDict 做数据校验——因为它压根就不会做数据校验:
def from_json(json_str: str) -> BookDict:
whatever: BookDict = json.loads(json_str)
return whatever如你所见,这里的 BookDict 类型注解在运行时起不到任何作用,它只能作为给静态检查器的类型信息来辅助静态检查。如果你有类似的运行时数据校验需求,请使用 Pydantic.
实验特性:Unpack
或许 TypedDict 还不能算是一个少有人知的特性,但这只是为了引入这里的 Unpack,这应该的确是一个知道的人不多的特性,并且即将在 Python 3.12 中成为一个正式特性。
回到开头的话题吧,我们该如何给 **kwargs 正确标上类型?有了 TypedDict 的知识,或许你会这么想:
class BaseChartArgs(TypedDict):
color: str
height: NotRequired[int]
width: NotRequired[int]
def draw_base_chart(**kwargs: BaseChartArgs) -> None:
...
def draw_bar_chart(data: list[int], *args, **kwargs: BaseChartArgs) -> None:
...
draw_base_chart(*args, **kwargs)
...不过,太遗憾了,现在编辑器还是没法提示这个:

回忆一下。对于 *args,我们标注的实际上是 tuple[...] 中这个 ... 的类型,而对于 **kwargs,我们标注的是 dict[str, ...] 中 ... 的类型。上面这种方式显然不对。
这难道就意味着我们不得不忍受只能给 **kwargs 标上一个无比泛化的类型提示,然后放弃编辑器的提示吗?
不过,正如上面所说,其实 Python 社区早就意识到了这个问题,所以很早就准备了一个实验特性 Unpack,你现在可以直接从 typings 中导入它。不过,由于实现的复杂性,它至今还是个实验特性。
from typing import NotRequired, TypedDict, Unpack
class BaseChartArgs(TypedDict):
color: str
height: NotRequired[int]
width: NotRequired[int]
def draw_base_chart(**kwargs: Unpack[BaseChartArgs]) -> None:
...
def draw_bar_chart(data: list[int], *args, **kwargs: Unpack[BaseChartArgs]) -> None:
...
draw_base_chart(*args, **kwargs)
...现在 VSCode 就成功检查出来 color 未赋值的问题了:

连智能提示也有了:

这真是不能更好的一个特性。尽管因为这仍是个实验性特性的缘故,直接作为 stubs 包发布是不太合适的,但简单地用在你的项目中,应该没有什么问题了。再等上几个月,等到 Python 3.12 稳定发布后,你就可以真正放心地使用 Unpack 了。
现在应该挺多人都没注意到 Python 这个 Type Hints 默默地已经发展得比较完备了。让我们梳理下近来 Type Hints 发展的时间节点吧:
- Python 3.8 正式引入了
Protocol,这和 TypeScript 中的interface几乎一致(如果你想的话,其实和 Go 中的interface也差不多),这意味着 Type Hints 能够很好地支持自定义结构化类型了。
from collections.abc import Iterable
from typing import Protocol, Any, TypeVar
class SupportsLessThan(Protocol):
def __lt__(self, other: Any) -> bool: ...
LT = TypeVar('LT', bound=SupportsLessThan)
def top(series: Iterable[LT], length: int) -> list[LT]:
ordered = sorted(series, reverse=True)
return ordered[:length]- Python 3.9 开始支持直接将内置的容器类当作类型使用,不再需要
from typing import List这类冗余语法了。这也许不是个什么很大的改进,但极大地提升了编写 Type Hints 时的幸福感。
def tokenize(text: str) -> list[str]:
return text.upper().split()
def count_chars(string: str) -> dict[str, int]:
result: dict[str, int] = {}
for char in string:
result[char] = result.get(char, 0) + 1- Python 3.10 引入了一个非常重磅的更新,支持直接使用
|替代Union. 之前的Optional[T]也可以改成T | None了。这不禁让人怀疑 Python 和 TypeScript 背后有什么神秘的交易。不过想想现在 Guido 也在巨硬工作了,这好像也不太奇怪。
def parse_token(token: str) -> str | float: ...
isinstance(x, int | str | tuple)- Python 3.11 也引入几个更新。其中一个是
Self,这一定程度上缓解了自引用类型不够优雅带来的问题(当然也没完全解决这个问题):
from typing import Self
class Rectangle:
# ... 前面的代码省略 ...
def stretch(self, factor: float) -> Self:
return Rectangle(width=self.width * factor)- 除此之外 Python 3.11 还引入了
TypeVarTuple,支持了可变类型参数,这才是比较重磅的——TS 可还没有这个呢。
from typing import TypeVar, TypeVarTuple
DType = TypeVar('DType')
Shape = TypeVarTuple('Shape')
class Array(Generic[DType, *Shape]):
def __abs__(self) -> Array[DType, *Shape]: ...
def __add__(self, other: Array[DType, *Shape]) -> Array[DType, *Shape]: ...然后更重量级的来了,最近 Python 3.12 引入了好几个让我有点震惊的 Type Hints 特性,一度让我怀疑我是不是以打开 TS 的方式错误地打开了 Python:
# @override 装饰器的引入,这个只是开胃小菜
from typing import override
class Base:
def get_color(self) -> str:
return 'blue'
class GoodChild(Base):
@override # OK: overrides Base.get_color
def get_color(self) -> str:
return 'yellow'
class BadChild(Base):
@override # Type checker error: does not override Base.get_color
def get_colour(self) -> str:
return 'red'
# `Unpack` 的引入使得对 `**kwargs` 的精准类型标注成为可能,正如上面提到的那样
from typing import NotRequired, TypedDict, Unpack
class BaseChartArgs(TypedDict):
color: str
height: NotRequired[int]
width: NotRequired[int]
def draw_base_chart(**kwargs: Unpack[BaseChartArgs]) -> None:
...
def draw_bar_chart(data: list[int], *args, **kwargs: Unpack[BaseChartArgs]) -> None:
...
draw_base_chart(*args, **kwargs)
...
# 从这里开始,事情变得怪了起来,说实话我以为这东西还要再过个几年才能出来
def max[T](args: Iterable[T]) -> T:
...
class list[T]:
def __getitem__(self, index: int, /) -> T:
...
def append(self, element: T) -> None:
...最让我震惊的是这个:
type Point[T] = tuple[T, T]
type IntFunc[**P] = Callable[P, int] # ParamSpec
type LabeledTuple[*Ts] = tuple[str, *Ts] # TypeVarTuple
type HashableSequence[T: Hashable] = Sequence[T] # TypeVar with bound
type IntOrStrSequence[T: (int, str)] = Sequence[T] # TypeVar with constraints于是我仔细拜读了 PEP 695:
PEP 695 - Type Parameter Syntax现在我只想问条件类型和递归类型什么时候安排上。另外 Callable[[...], ...] 这个蛋疼的语法能不能改一改,我希望再过两年可以写这种东西:type InputFunc = (expr: str, /, *. logging?: bool) -> None。目前看来条件类型和递归类型或许永远也加不进来了,但后面那个简化的函数类型语法可以期待期待。