Python Type Hints 使用指南
date
May 3, 2024
slug
python_type_hints_introduction
status
Published
tags
Python
summary
type
Post
0 前言
0.1 什么是 Type hints
Type hints 即类型提示,是 Python 在 3.5 版本中加入的语法,并在 Python 3.6 基本可用。在此后的版本中,Type hints 的功能不断扩充,至今已经能够实现一个比较完善的静态类型系统。下面的代码是一个示例。
正如其名称暗示的那样,Type hints 是”类型提示”而不是”类型检查”,Python 并不会在程序运行时检查你所标注的类型与变量的真实类型是否一致。如果不一致,也并不会产生错误。Type hints 最大的作用是在编辑器中对代码进行静态类型检查,以方便你发现因类型不一致而导致的潜在运行时错误,并增强你代码的重构能力,以提升代码的可维护性。对于大型项目而言,这格外有价值。但即便在小型项目中,Type hints 也具有足够的意义。
✨ 提示: 如果你自身对静态类型一无所知,那么 Python 的 Type hints 可能并不适合成为你接触的第一个静态类型系统。当然,你可以试着读下去,看看自己能接受多少。本文假设你有一些基本的、与包含静态类型系统的编程语言打交道的经历,如 Go、Java、C++ 或 TypeScript,否则你可能会发现本文的后半部分并不容易理解。
在大多数时候,你并不需要刻意为使用 Type hints 而做什么配置。编辑器或 IDE 通常提供了开箱即用的 Type hints 支持,如 VSCode 默认使用 Pyright (Pylance) 提供支持,PyCharm 则使用其内置的 Type hints 支持。
🌟补充: VSCode 实际使用 Pylance 为 Python 提供支持,而 Pyright 是其中的核心组件,作为 Python 的静态类型检查器 (Static type checker) 发挥作用,因此如果你使用 VSCode,会看到一些有关类型的提示信息来自 Pylance 而非 Pyright.
Python 官方提供了 mypy 作为静态类型检查器(可通过 pip 安装)。mypy 的优势在于其支持插件系统,因此一些项目可能依赖于 mypy 提供更进一步的类型支持。在大部分编辑器或 IDE 中,mypy 并不作为其默认使用的静态类型检查器(由于 mypy 的性能较差,并且对新 Type hints 特性的支持较慢),你可能需要安装相应的插件来使用它。不过,由于 mypy 的官方性,许多开源项目仍倾向于优先保证在 mypy 下正常工作,而非 Pyright 或 PyCharm 内置的 Type hints 支持。另外,mypy 也常常被认为是最准确的静态类型检查工具,例如在一些边界情况下 Pyright 的推导可能产生问题,在 mypy 上则很少见。当然,通常来说你不需要担心此类问题。
尽管 Type hints 通常用于提供静态类型检查,但运行时实际上能够读取一部分 Type hints 信息,因此有一些工具如 FastAPI 与 Pydantic 也利用 Type hints 提供运行时类型校验能力。由于这一运行时行为,添加 Type hints 有时会给你的 Python 代码带来一些微小的性能损耗,只是它们常常可以忽略不计。相比起它们带来的好处来说。
如果你熟悉其他编程语言(如 Java 或 TypeScript)中的泛型编程概念,可以尝试理解以下这段代码,它演示了
map
函数对 List 的特化版本。如果你不理解也没关系,这其中的知识点都会在后续被详细解读:下图展示了 VSCode 中 Pyright 对它的静态类型检查支持(错误提示来自于 Error Lens 插件):

Pyright 对静态类型检查支持

0.2 为什么需要 Type hints?
一个常见的误解是,如果我只使用 Python 编写小脚本或只是用来画一些图表,Type hints 对我来说就没有什么用。然而实际上,“使用 Type hints”与”不使用 Type hints”不是唯二的两种选择,你可以选择仅在必要的时候加上一点 Type hints 来方便自己(与编辑器)理解代码,而不是在所有能加上 Type hints 的地方都加上它们。这也没有必要。
其中一个用处是告知编辑器某个函数参数的类型,以获得由此带来的智能提示,如下图所示。这不会带来什么负担,却能极大提升你编写代码的体验:

如果你安装的某个第三方库也自带了合适的 Type hints,那么编辑器也能从中推导出更多信息以提供更精准的智能提示。即使某个第三方库并未使用 Type hints 编写,也可能由其他作者为其编写合适的 Type hints,并以
type-xxx
的名称发布在 PyPI 上供他人下载安装,以帮助编辑器提供更智能的提示。Python 官方维护着一个名为 typeshed 的项目,已经为相当多流行的第三方库提供了合适的 Type hints,如 six 和 requests.
当然,如果你通常使用 Python 编写小脚本、进行数据科学工作或是构建人工智能模型,那么使用 Type hints 或许没有想象中的有效,也不一定需要了解它。但如果你正在使用 Python 开发软件或是构建其他大型项目,那么使用 Type hints 能够使你享受静态类型带来的一部分优势,使重构变得更加便利,也能更好地减少代码中因类型不一致产生的潜在运行时错误。
不过,Python 的本质仍是动态类型语言,因此没有必要追求 100% 的类型提示,这反而失去了动态类型的优势,陷入了思维定势中。并且实际上目前的 Type hints 并不足以百分百兼容 Python 的灵活性,仍有不少场景是 Type hints 无法很好表示的。
如果你在使用 Type hints 的过程中没有感受到任何便利,或是已经通过大量的单元测试确保了你的 Python 代码已经能覆盖大多数情况,那么使用 Type hints 就不是完全必要的。这理所应当。
🌟说明: 本文有部分示例来自于 Fluent Python, 2nd Edition 与 Type hints 相关的章节(第 8 章和第 15 章),其余示例大部分为原创,剩余来自 Python 官方文档。目前该书已由人民邮电出版社图灵教育出版,中译名为《流畅的 Python(第 2 版)》,我个人很推荐阅读一下原书。不过,该书中关于 Type hints 的部分也存在不少错漏和疏忽,读的时候建议稍微谨慎一些。
1 基础语法
1.1 开始
首先不使用 Type Hints 实现函数
show_count
,返回一个包含数量和名词的单数/复数形式的字符串:这是它的源码:
下面为它加上 Type Hints:
Python 的 Type Hints 不仅支持基本类型,如
int
, float
, str
……也支持自定义的类型。例如:1.2 默认参数及 Optional
我们上面定义的
show_count
函数还存在一些问题。比如 mouse 的复数是 mice,但这个函数只会返回”mouses”而不是”mice”。于是我们为这里的 show_count
函数加上了默认参数:✨ 提示: 根据 PEP 8 的相关建议,在不使用 Type Hints 时,默认参数的等号两边应该没有 空格,而使用 Type Hints 时,则建议在等号两边加上空格。这或许有些违背你的直觉,但 PEP 8 的确如此规定,而本文的代码也遵照此规范。
但是这里的默认参数只是个特殊情况。有时我们需要使用
None
作为默认参数,特别是在默认参数可变的情况下 ,将 None
作为默认参数几乎是唯一的选择。
> ✨提示:关于为什么”使用可变值作为默认参数是危险的”,将在本小节的最后进行补充。那么,需要将
None
作为默认值时该怎么做呢?在 Python 3.10+ 中,建议使用 | None
用于表示其类型也可以是 None
,这是联合类型(Union type)的语法,将在下一小节介绍:在更早的 Python 版本中,你可以使用
Optional[...]
作为替代:✨提示: “Optional”这个名称具有一定的迷惑性。在 Python 中,我们经常说某一个函数参数是”可选 (Optional)“的,以表示某一个函数参数可以被传递或者不传递。然而这里的 Optional[…] 却仅仅表示某个变量的类型可以是 None,而其本身却并不具有”可选”的含义。因此在这里你仍需要写成 plural: Optiona[str] = None 而不是 plural: Optional[str],这里的 = None 并不能被省略。你可以理解为 Type hints 在运行时会被忽略,因此它们通常不具备运行时作用,所以你仍不能省略这里的 = None . 当然,在 Python 3.10+ 中,你更应该使用前一种语法而不是 Optional[…],这样更容易避免这个名称所带来的迷惑性。 ➡️补充:为什么使用可变值作为默认参数是危险 ⚠️ 的:
1.3 联合类型 (Union Type)
有时候函数可能有不同类型的返回值,甚至参数也是不同类型的。这时可以使用联合类型语法,即使用竖线
|
分隔类型:|
操作符同样支持 isinstance
和 issubclass
函数:需要注意的是,仅 Python 3.10+ 支持该语法,如果你需要在更早的版本中使用联合类型,你需要从
typing
中导入 Union
:Union
支持多个类型与嵌套。例如以下的两种用法是等价的:下面的代码将全部使用
|
而不是 Union
作演示。然而 |
用作联合类型是 Python 3.10 才加入的,因此记得在 Python 3.9 及以前的版本,仍然需要使用 Union
。➡️补充:关于"一致性 (consistency)"
- 在此处,有必要在本文中第一次普及"一致性 (consistency)"的概念,以便于接下来的讲解。这一知识可能并不容易理解。但没有关系,如果你暂时无法理解下面这段话,可以在之后阅读第 2 节中的"理解结构化类型/鸭子类型"以深入理解它。
- 如果你希望编写一个函数,它能够同时处理
int
、float
和complex
,如def print_num(num)
,你可能认为将其类型标为def print_num(num: int | float | complex) -> None
是个好主意。然而实际上这是冗余的,你只需要标注为def print_num(num: complex) -> None
就可以了。
- 要理解这个问题,首先要理解"一致性 (consistency)"的概念。在 Python 中,
int
与float
是相一致(consistent-with)的,而float
与complex
也是相一致的,因此可接受float
的地方实际也可接受int
,而可接受complex
的地方实际也可以接受int
或float
。反过来则不行,比如接受float
的地方不可以接受complex
,接受int
的地方也不可以接受float
.
- 这是因为
int
类型实现了float
类型的所有方法,而float
类型又实现了complex
类型的所有方法。比如int
除了实现了 float 类型上常规的减乘除等运算外,还额外实现了整数上才能使用的 &、|、<< 等位运算。
- 同理,
int
和float
实际上也实现了complex
的所有方法。你可以曾认为 .imag、.real 是complex
类型上独有的属性,但你实际上也可以在int
和float
上调用这两个属性,例如 i = 3, i.real 是 3,i.imag 则是 0.
- 你或许感到这里”相一致(consistent-with)“的概念有些类似于继承,你可以不太严谨地这么理解。然而实际上
int
、float
和complex
这三个类型都继承自object
,它们之间并没有真正的继承关系。
1.4 类型别名 (Type Alias)
除联合类型外,也可以为类型命名,这被称为”类型别名(Type Alias)“。在 Python 3.12+,你可以使用
type
关键字轻松创建一个类型别名:不过事实上,使用
type
创建类型别名并不是完全必要的。你也可以省略 type
,直接创建类型别名:如果你使用早于 Python 3.12 的版本,
type
关键字还不被支持,你便只能这样写。然而尽管 type
似乎不是必要的,仍建议在 Python 3.12+ 中明确写出 type
,这更清晰地表明了你只是在定义一个类型别名,而不是某个运行时使用的变量。除了定义简单的类型别名,
type
关键字还用于更方便地处理泛型定义。如果你暂时不理解泛型也没关系,这会在之后详述:如果你在使用 Python 3.10~3.11,但也希望能够像 Python 3.12+ 一样明确表示你在定义一个类型别名,你也可以使用
TypeAlias
类型,这更加清晰。不过不像 type
关键字,使用 TypeAlias
最大的作用只是使你的类型别名更清晰并且更容易被静态类型检查器发现:在第 1.16 节,会简单介绍
TypeAlias
的另一个作用,即避免前向引用类型的别名与值为特定字符串的变量混淆。但无论怎么说,TypeAlias
的功能都已经被新引入的 type
关键字完全覆盖了,并且在 Python 3.12 中被标记为了废弃 (Deprecated),所以在 Python 3.12+ 中建议尽可能使用 type
关键字。更多关于 Python 3.12 引入的 type
关键字的信息,可以参考 PEP 695 – Type Parameter Syntax 的相关部分。关于 Python 3.10 引入的 TypeAlias
的更多信息,可以参考 PEP 613 – Explicit Type Aliases.1.5 子类型 (NewType
)
有时候,你会愿意创建类型别名以增强代码可读性:
在这里,你通过为
float
起类型别名 Second
表明了 sleep
函数应当接收一个以秒为单位的时间长度。因此,通常你希望用户这样调用它:只是这并不能阻止粗心的用户将这里的时间单位当作毫秒。如果用户这么调用它,显然也不会产生错误:
毕竟类型别名只是别名,它与原本的类型没有区别。它能起到一定的文档作用,让你的代码更加易读,却不能使静态类型检查器施加更严格的约束。这时,你可能更希望创建”子类型 (Subtype)“,使用户更明确地认识到函数的作用:
如你所见,通过
NewType
创建了 float
的三个子类型 Second
、Millisecond
以及 Microsecond
. 现在 sleep
函数只接收 Second
类型,而不能接收 float
、Millisecond
或 Microsecond
. 这和继承关系有些相似,若指定使用子类型,则不能使用父类型。在用户 ID 这样的场景下使用
NewType
定义子类型可能是个不错的主意:自然,你也可以继续通过上面定义的
UserId
派生新的子类型:然而,通过
NewType
定义的子类型不是一个真正的”子类”,它无法通过 class 关键字进行继承:然而,值得注意的是通过
NewType
定义的子类型可执行的操作仍与父类型完全相同。例如即使上面定义了 UserId
类型,将两个 UserId
相加后得到的结果仍是 int
类型:1.6 强制类型转换 (Type Casting)
静态类型检查器通常能够理解你的意图。但自然也有些时候它无法正确推导出你预期的类型,因此总需要一种方案来让你手动告诉类型检查器某个变量的类型,这就是”强制类型转换 (Type Casting)”的存在价值。
如果你曾与任何一门具有静态类型系统的编程语言打过交道,可能早就熟悉这个概念了。现在让我们看个例子:
你可能暂时不熟悉这里
list[object]
的语法,没关系,这在之后会详细解释。但你应当能从直觉里察觉出它表示一个由 object
组成的列表。这段代码在逻辑上是没有问题的,只要没有发生异常,它返回的 lst[idx]
显然一定是个字符串。然而静态类型检查器并不能在如此复杂的情况下理解发生了什么。它会报告一个错误:

为此,你可以使用typing.cast强制转换某个值的类型,例如这里将lst[idx]强制转换为str以消除错误:

使用
typing.cast
强制转换某个值的类型。你可能会疑虑 cast
是否会对运行时造成性能影响,实际上几乎不会。这是它的代码实现:可以看到,
cast
只是作为一个标记,它并不在运行时产生任何作用,只是将传入的值原样返回。因此显然它也不会在运行时真正转换值的类型,只是为静态类型检查器提供了提示。一般只建议在此类编辑器无法正确推导类型,但代码逻辑正确无误的情况下使用
cast
,而不建议使用 cast
故意忽略类型检查器报告的某些潜在运行时错误。通常来说,你不需要过多使用 cast
,类型检查器一般有足够的能力进行正确推导,仅极少数情况下无法正确理解代码含义。虽说如此,如果涉及一些复杂的函数重载和泛型情况,的确得经常使用
cast
,毕竟 Python 的类型系统还算不得健壮,在处理复杂问题时并不足够智能。此外,许多第三方库也没有包含正确的 Type hints,以至于在一些检查器的严格模式下你常常需要大量使用 cast
来避免类型检查器的抱怨……1.7 Any
类型
有时你会发现自己并不能明确表示某个值的类型,此时你可以使用
Any
,表示任意类型:显然,这里的 x 可以是很多类型,例如
int
、float
甚至 np.uint32
这样的数字类型,又或者是 tuple
、list
或是 pd.Series
这样的序列类型。所以这里使用了 Any
类型,因为输入值有很多可能。在 mypy 中,任何未标注类型的变量、函数参数、函数返回值等被认为是
Any
类型,即使通过 strict_optional
选项开启了严格类型检查也是如此。例如在 mypy 中,因此下面两段代码是等价的(此处使用了 mypy 进行类型检查,而非 Pyright):因此通常来说,你可以认为标注
Any
的意义不是很大。这其实就相当于没有标注类型。并且,有些时候显式标注 Any
反而会降低静态类型检查器的推导能力,使得原本能够推导出更精确类型的地方仅仅被推导为了Any
. 下图展示了这种情况:
Any
实际上”逃避 “了静态类型检查器的类型检查。这是一种独特的类型,假如一个变量的类型是 Any
,那么任何值都能被赋值给它,同时它也能被赋值给任何类型的变量。我个人建议在任何情况下都不要显式使用 Any
,除非你的目的就是为了故意使静态类型检查器在某个地方不要理你。事实上,如果你希望表示一个值可能是某个未知 类型,使用
object
可能是个更安全的选择,它是 Python 中所有类型的基类,因此很适合这种情况。通过使用 object
,你能够更好地保证类型安全,并获得至少一部分编辑器的智能提示:
你可能注意到了,我在上面特意提到 mypy 将未标注类型的函数参数与返回值视为
Any
类型,但并非所有静态类型检查器都这样工作。VSCode 默认使用的 Pyright 就不是。事实上,Pyright 将未知参数的类型推导为 Unknown
,以提供更好的推导。这并不是一个你可以在 Python 中获取到的类型,只是 Pyright 内部工作机制所使用的类型。如果你熟悉 TypeScript,这里的 Unknown
类型与 TypeScript 中的 unknown
类型非常相似,这并不是巧合,TS 团队本就与 Pyright 团队合作密切,它们的类型系统工作原理也非常相似。把静态类型检查器切换回 Pyright,看一下 Pyright 对不标注类型的double函数的类型推导:

之前的示例在 Pyright 下产生的结果与 mypy 是一致的,就不多演示了。
如果你开启了 Pyright 的严格模式,会发现 Pyright 总是要求你明确标出函数参数和返回值的类型,这时如果你发现了似乎不得不使用
Any
的场景,我仍建议你不要使用 Any
,而是尽可能使用 object
替代。1.8 底类型 Never
和 NoReturn
类型通常包含一些值,例如
str
包含所有字符串,int
包含所有整数,某个自定义的 Dog
类也包含所有它以及其子类的实例。但一个特殊的类型除外,即”底类型 (Bottom type)”,它不包含任何值。在 Python 3.12+ 中,它被命名为
Never
,你可以从 typing 中导入它。你可能有些奇怪,在什么情况下需要这个类型。这可能的确不是一个常用的类型,但在类型系统中却有着很大的意义。思考一下,我们通常可以将类型的层次理解为一种不精确的”包含”关系。object
作为一切的基类包含着所有值,自然也包含了 Number
,Number
则作为所有数字的基类包含着一切 int
、float
和 complex
,自然就包含了 int
,而 int
又包含着一切具体的整数。而 Never
则仅仅作为一个类型,却不具有任何值(一个空集),那么它就被任何其他类型所包含,即任何类型的子类型,存在于层级的”底部”,这就是为什么称它为”底类型”。什么样的函数会返回
Never
,这样一个不具有任何值的类型?当然是永远不会返回值的函数。例如 sys.exit()
函数必定引发一个错误导致程序退出,那么它就永远不会返回值,因此我们可以这样表示它:在 Python 3.11 及之前的版本中,存在一个
NoReturn
类型。在 Python 3.12+ 中你当然也可以使用它。它的含义与 Never
一致(类型检查器将 Never
和 NoReturn
视为同一个类型的不同别名),它的名称也很清晰地表明它表示一个永远不会返回的函数的”返回值类型”,因此我们也可以将 exit
的定义写成这样:对于
exit
函数的这种情况,用 NoReturn
可能是更清晰的写法。只是在 Python 3.12+ 中,Python 官方更建议优先使用 Never
,因为它更明确表明了该类型的本质,而不是只能作为某个永远不会返回的函数的”返回值类型”使用。例如,有些时候你也可以用
Never
作为某个函数的参数表示它永远不该被调用的函数,在这种情况下它比 NoReturn
这个名称看起来要更合适。尽管我们可能很难想象到这样一个函数的存在价值:
可以用
Never
作为某个函数的参数1.9 泛化容器 (Collections)
🌟说明:有时也将”Collection”翻译为”集合”,这里为了避免与”Set”的通常译名”集”产生概念混淆,译为”容器”。
Python 中的大多数容器(
list
、tuple
、set
等)都是异构(heterogeneous)的,例如 list
就可以包含很多不同类型的值。不过在多数情况下,当使用这些数据结构时,我们倾向于在其中存储同样类型的值。毕竟我们通常希望稍后将放入容器的对象取出进行一些操作,这通常意味着它们必须共享同一个方法。在 Python 中,你可以这样表示一个容器中只包含特定的值:
在 Python 3.8 及更早的版本中,你不能像这样直接用
list
、set
等内置关键字直接表示 Python 内置的容器类型(该语法仅适用于 Python 3.9+),而是需要从 typing 中导入它们:除此之外,在 Python 3.9+ 中还有很多内置容器类型可以直接使用这种方式表示,例如
collections.deque[str]
。事实上,Python 正考虑在未来(初步计划是 Python 3.14 中)删除对冗余类型
typing.Tuple
等类型的支持,因此应该优先使用新语法(list
、tuple
、dict
)而非旧语法(typing.List
、typing.Tuple
、typing.Dict
)。如你的直觉所料,这里容器类型之后方括号
[]
中包裹的是容器中值的类型。因此,list[str]
就表示一个字符串列表,list[int | str]
就表示一个值为整数或字符串的列表,以此类推。你也可以省略这个方括号,表示你并不试图指定容器内部值的类型,例如在 mypy 中 list
等价于 list[Any]
(在 Pyright 中则等价于 list[Unknown]
)。对于映射 (Mapping) 类型(如
dict
、defaultdict
),可以通过 dict[KeyType, ValueType]
这样的语法分别表示键和值的类型:tuple
类型支持更复杂的操作,所以将在下一节叙述它的用法。这里的语法实际上是泛型语法的特殊应用,这在第 2 节会进一步详述。
遗憾的是,截至 Python 3.12,仍然很难通过 Type Hints 标注
array.array
的类型,因为 array.array
区分 int
和 float
,而在 Python 的类型系统中 int
被认为是与 float
“相一致(consistent-with)”的(正如上文提到的)。更大问题在于 Python 中的数字类型不会溢出,而 array.array
中的数字类型会发生溢出错误(OverflowError)。另外,typing 中包含一个
Sequence
类型可以表示 Python 中的序列类型(str
, tuple
, list
, array
等),同样支持方括号表示容器内值的类型。一般来说,对于函数及方法的形参,推荐优先使用
Sequence
而非 list
,以获得更好的泛化性。如果你暂时不理解为什么在这些情况下更应该使用泛化的 Sequence
,在后文中会详述。1.10 元组 (Tuple)
元组 (Tuple) 有三种用法:
- 用作记录 (Record)
- 用作具名记录 (Records with Named Fields)
- 用作不可变序列 (Immutable Sequences)
将 Tuple 用作记录 (Record) 时,可以直接将几个类型分别包含在
[]
中。例如 ('Shanghai', 'China', 24.28)
的类型就可以表示为 tuple[str, float, str]
将 Tuple 用作具名记录 (Records with named fields) 时,可以使用
NamedTuple
:这里用到了具名元组,而这是很推荐使用的,它使得代码看起来更加清晰。由于
NamedTuple
是 tuple
的子类,因此 NamedTuple
与 tuple
也是相一致(consistent-with)的,这意味着可以放心地使用 NamedTuple
代替 tuple
,例如这里的 Coordinate
也能表示 tuple[float, float]
,反之则不行,比如 tuple[float, float]
就不能表示 Coordinate
。将 Tuple 用作不可变序列 (Immutable Sequences) 时,需要使用
...
表示可变长度:值得注意的是,如果省略方括号,
tuple
等价于 tuple[Any, ...]
而非 tuple[Any]
。tuple
的 用法与list
不同,这是需要注意的。1.11 类型守卫 (Type Guard)
你可能经常遇到一种情况:你有一个类型未知或者其类型相当”宽泛”的变量,你需要通过一连串的 if 语句判断它的类型,然后分别执行不同的代码逻辑。
例如你有一个变量,它的类型是
int | str
,你需要根据它的类型分别执行不同的代码:此处,无论在print(x + 1)还是print(x.upper())
中,静态类型检查器都无法判断x究竟是int 还是str,因此在这两处都会报错:

为此,你可以使用 Python 3.10 引入的
TypeGuard
:TypeGuard[T]
用于一个至少接收一个参数且返回布尔值的函数。当使用以 TypeGuard
定义的函数时,静态类型检查器会将其第一个实参的类型”窄化 (Narrowing)“为 TypeGuard[T]
中的 T
(如果接收多个参数,多出来的实参不会被窄化)。实际上,
isinstance
就是一个 TypeGuard
,它可以被定义为 def isinstance[T](obj: object, typ: type[T], /) -> TypeGuard[T]
. 在过去,静态类型检查器会对 isinstance
做特殊处理以执行窄化。然而自 Python 3.10 起,你也可以使用 TypeGuard
自己定义这样的函数了。因此下面的代码也是合法的:在学习了 2.8 节介绍的
Protocol
后,你或许会意识到 TypeGuard
比想象中的更有用。例如你可以用 Protocol
定义一个 Finite
类型表示某个支持 __len__
的类型,然后将 hasattr(obj, "__len__")
包装为一个 TypeGuard[Finite]
。这可以很大程度上减少你对 cast
的使用。1.12 标注可变长参数与关键字参数的类型
你应该已经熟悉如何为常规的函数参数标注类型了。然而 Python 中还存在另外两种参数:形如
*args
的可变长参数和形如 **kwargs
的关键字参数。你可以使用下面展示的语法标注它们的类型:
上面代码中的
/
表示 /
前面的参数只能 通过位置指定,不能通过关键字指定。这是 Python 3.8 中新加入的特性。同样的,也可以使用 *
表示 *
后面的参数只能通过关键字指定,不能通过位置指定。这不是 Type Hints 范围内的知识,在这里提及只是作为补充,以免造成阅读时的困惑,在这里就不给出示例了。✨ 提示: 在 Python 3.7 及之前的版本中,按照 PEP 484 中的约定,使用 __ 前缀表示仅位置参数:
这里对可变参数的类型提示很好理解。例如,
content
的类型是 tuple[str, ...]
,而 attrs
的类型则是 dict[str, str]
. 如果把这里的 **attrs: str
改成 **attrs: float
的话,attrs
的实际类型就是 dict[str, float]
.1.13 可调用对象 (Callable
)
在 Python 中,对高阶函数的操作是很常见的,因此经常需要使用函数作为参数。Type hints 也提供了
Callable[[ParamType1, ParamType2, ...], ReturnType]
这样的语法表示一个可调用对象(例如函数和类)。Callable
常用于标注高阶函数的类型。例如:✨ 提示: 你可能曾看到有人从 typing 中导入 Callable,自 Python 3.9 起,collections.abc 中的泛型类型与 typing 中的相应类型已经没有区别了,因此 typing.Callable、typing.Hashable 等已经被标记为废除 (Deprecated) 了,你应该从 collections.abc 中导入它们。当然,如果你在用 Python 3.8 或更早的版本,那么你只能从 typing 中导入它们。
如果你熟悉 TypeScript,可以将这里的
Callable[[int, int], int]
理解为 (a: number, b: number) => number
,这或许更为直观。又如:
✨ 提示: 注意到这里的 Callable 使用了 “Order” 字符串作为第一个参数的类型而非 Order,这涉及到 Python 类定义的实现问题:在 Python 中,类是在读取完整个类之后才被定义的,因此在类体中无法通过直接引用类本身来表示它的类型。这里使用的是将在 1.16 节详述的”前向引用(Forward reference)“语法。暂时来说,你可以简单理解为它使用引号将类型包起来以表示尚未定义的类型。
遗憾的是,目前
Callable
本身还不支持可选参数,但可以结合 Protocol
用更复杂的形式表示带可选参数的 Callable
,这将在 2.10 节中详述。如果需要使用可变长参数,可以结合 2.4 节的
TypeVarTuple
用诸如 Callable[[*Ts], R]
或 Callable[[A, *Ts], R]
的定义来表示。另外,关于
Callable
还涉及一些与”型变 (Variance)“相关的话题,这部分内容将在第 4 节介绍。1.14 字面量(Literal
)
在 Python 3.8 中,
Literal
被引入以用于表示字面量的类型。例如:根据 PEP 586 – Literal Types 的说明,
Literal
支持整数字面量、byte
、Unicode 字符串、布尔值、枚举 (Enum) 以及 None
. 例如以下的类型都是合法的:➡️ 说明: None 和 Literal[None] 是完全等价的,静态类型检查器会将 Literal[None] 简化为 None.
你或许已经从一开始的例子中发现了,
Literal
可以接收多个参数,用于表示多个字面量类型的联合类型。这是一种简化的语法,例如 Literal["apple", "pear", "banana"]
等价于 Literal["apple"] | Literal["pear"] | Literal["banana"]
. 和 Union
一样,此种语法也支持嵌套:所以说
Literal[Literal[Literal[1, 2, 3], "foo"], 5, None]
和 Literal[1, 2, 3, "foo", 5, None]
其实也是等价的。可以看到,一定程度上,
Literal
可以替代枚举 (Enum) 类型。当然,与枚举 (Enum) 相比,Literal
并不实际提供运行时约束,这也是 Type hints 的一贯风格。但很大程度上,由于 Literal
的引入,需要使用枚举的地方已经少了很多了。1.15 字符串字面量(LiteralString
)
⚠️适用版本提示: 该特性仅在 Python 3.11+ 可用
LiteralString
可用于表示一个字符串字面量。什么时候需要用到这一特性呢?
Literal
难道不足以表示字面量吗?如果仅仅用于表示字符串,str
不也可以吗?事实上,
LiteralString
的推出是为了满足一些不太常用的安全性需求。例如在下面的例子中,我们使用了某个第三方库执行 SQL 语句,并将一些操作封装到了一个特定的函数中:这段代码看起来很好,但实际上却有着 SQL 注入的风险。例如用户可以通过下面的方式执行恶意代码:
目前一些 SQL API 提供了参数化查询方法,以提高安全性,例如 sqlite3 这个库:
然而目前 API 作者无法强制用户按照上面的用法使用,sqlite3 的文档也只能告诫读者不要从外部输入动态构建的 SQL 参数。于是在 Python 3.11 加入了
LiteralString
,允许 API 作者直接通过类型系统表明他们的意图:现在,这里的
sql
参数就不能是通过外部输入构建的了。现在再定义上面的 query_user
函数,编辑器就会在静态分析后提示错误:而其他字符串可以正常工作:
看了这些,你可能会认为
LiteralString
在大部分情况下仍然没什么用。然而,不妨想想在其他领域 LiteralString
的用途,例如应用在命令行相关的 API 上防止命令注入,或是应用在 Django 这类采用模板生成 HTML 的框架上防止 XSS 注入,甚至用在 Jinja 这类可对字符串形式的 Python 表达式直接求值渲染的框架上防止模板注入……当然,还有经典的日志注入漏洞,也可以通过 LiteralString
提高安全性。如果你当前使用的 Python 版本低于 Python 3.11,可以安装 Python 官方提供的 typing_extensions 扩展库来使用这一特性。
1.16 前向引用 (Forward Reference)
在 1.13 节中,我们简单了解了”前向引用 (Forward Reference)”的一个应用。用来在类定义内部表示类自身的类型。例如:
✨ 提示:在 2.7 节,将提到对于此种特殊情况(在类定义内部表示类自身的类型)的一种更简洁的方案。
事实上,此种用引号包裹尚未在运行时代码中定义类型的前向引用语法,不止适用于在类定义内部表示类自身。假设你首先定义了如下的类:
现在,假设你需要在
Animal
上定义一个 as_dog()
方法,通过判断自身是否是 Dog
的实例返回 Dog
或 None
. 一个错误的定义如下:这是因为在定义
Animal
时还未定义 Dog
,因此这段代码实际上会产生运行时错误。你需要用引号包裹 Dog | None
来避免运行时的未定义问题:注意,不要仅将
Dog
包裹起来写成 "Dog" | None
,这是不合法的。前向引用也适用于第 2 节中将介绍的泛型,如
"Box[str]"
. 通常来说,你总是需要将整个类型用引号包裹,而不是仅包裹尚未定义的类型。另外,有些时候你可能会尝试给前向引用起个别名。假设你不使用
type
关键字或 TypeAlias
:在这里,静态类型检查器无法区分
MyType
到底是个前向引用类型,或者仅仅是个值为 "ClassName"
的变量。为此,你可以使用 Python 3.12+ 中的 type
关键字或 Python 3.10~3.11 可用的 TypeAlias
明确表示你打算定义一个前向引用类型的别名:更多关于前向引用的信息,可以参考 PEP 563 – Postponed Evaluation of Annotations.
1.17 @override
装饰器
⚠️适用版本提示: 该特性仅在 Python 3.12+ 可用
千呼万唤始出来。终于,Python 现在也有自己的
@override
了。在过去,我们通常使用
abc
中的 ABC
和 @abstractmethod
装饰器来实现一个抽象类:不过比较遗憾的是,这其实只在运行时奏效。如果我们定义了这样一个类:
可以看到,这里把
color
拼成了 colour
. 但是类型检查器并不会提示我们这个错误。在 Python 3.12+ 中,你可以使用
@override
装饰器。当你用该装饰器装饰一个方法时,类型检查器会检查该方法是否真的重载了父类中的某个已有方法: