编写更具可读性、可维护性的Python代码——使用类型标注
学习Python时,我非常羡慕VSCode能显示内置函数的参数类型、返回值类型,清晰的参数类型提示能够让我一开始就为这些函数提供正确类型的参数,并且在运行代码之前就能确定函数的返回值类型。这种便利当然可以扩展到自定义函数和类上,这就是我们今天要讲的主角——Python的类型标注系统。
注意,Python的类型标注系统于3.5版本开始引入,在此之前的版本并不兼容这些语法,本文档默认使用CPython3.14版本作为演示。
基本语法
来看看下面的代码:
1 | class Damage_Info: ... |
这是一个非常简陋的游戏系统代码,我们暂且不论它们实际运行时的效果,来看看这几个现象:
pawn2 = Pawn(2, 23)传入的name参数会触发断言异常,因为它没有按照要求传入str类型的参数Pawn.get_skill()函数同样存在一个断言,它告诉调用者这个函数还没有实现完成Pawn.hit()函数存在一个hit_target参数,它明显是告诉函数这次攻击的目标是谁,当然,它肯定是Pawn类型的对象
加上足够的断言确实可以解决多项目的沟通问题,或者提醒第二天刚睡醒的自己不要犯错,但如果能在一开始就避免这些错误——或者说一开始就告诉第二天的自己某个函数应该怎么传递参数,岂不美哉?那我们小改一下上面的代码:
1 | class Damage_Info: ... |
上述代码中,方法的参数均通过: type指定了该参数的类型,例如add_skill(self, skill: Skill);同时,用-> type声明了方法的返回值,例如hit(...) -> int,包括下面两个变量pawn1、pawn2也都做了类似的声明。
在Python的类型标注系统中,:用于声明一个变量的类型,->用于声明一个函数的返回值类型,例如下面这两行简单的代码:
1 | def my_max(a, b): |
my_max()函数接受a、b两个int类型的参数,返回值自然也是int类型。变量number的值是my_max()的返回值,该变量的类型应该也是int。那么要清晰标注上面的信息,就只需要这样:
1 | def my_max(a: int, b: int) -> int: |
类型标注的底层逻辑就这样简单,但这里其实有个问题,虽说我们将my_max()函数的两个参数标注为了int类型,但实际来说,这个函数肯定是支持int和float的混合比较的,对于这种“一个类型或另一个类型”的表达,我们可以使用|来表示:
1 | def my_max(a: int | float, b: int | float) -> int | float: |
经过修改,现在my_max()函数的两个参数,以及返回值便完整表达了它同时支持int和float两种类型,同时返回值也支持这两个类型。这里的number正常来说也应该表示为number: int | float,但因为此处调用我们可以明确最终的结果是int类型,所以我就偷懒没改()。
当然,最基本的类型标注也支持自定义类型,在一开始的代码中,我就明确使用了skill: Skill的类型标注,这是完全合法的。
了解完这些,我们就可以开始学习稍微进阶的基础语法了。
容器
我们都知道,Python提供了许多常用的容器类型,例如list,这类容器对象通常都会存有元素,例如下面的代码:
1 | a = [3, 6, 9, 12, 15, 18] |
变量a是一个内部元素均为int的list,变量b是一个key为str,value是str或者int的dict,对于这两种容器,我们可以使用类型参数语法[]来描述一个容器内元素的具体类别:
1 | a: list[int] = [3, 6, 9, 12, 15, 18] |
不过这里还有个问题,我们来看看下面的类型标注:
1 | a: list[int, str] = [1080, "1920"] |
这里看似非常明确,a是一个只有两个元素的list,其中第一个是int,第二个元素是str,这种写法是对的吗?如果各位有使用各类类型检查器,大概率会看到类似的报错:
为“list”提供的类型参数太多;应为 1,但收到 2
这当然不会影响代码的正常运行,但这里涉及到一个底层逻辑——类型系统会假定Python的多数容器类型中,里面的元素类型是相同的,而list只能接受一个类型参数,因此上面的写法是错误的,这在官方文档中也有明确说明。
因此上面正确的标注,应该是a: list[int | str]。这也能理解,list毕竟是一个变长类型的容器,那么另一个不可变的容器tuple呢?
1 | a: tuple = (3, "4") |
我们来看看官方文档中的表述:
然而,与大多数其它 Python 容器不同的是,在常见的 Python 代码中,元组中元素的类型并不相同。因此,在 Python 的类型系统中,元组是特殊情况。
也就是说,刚刚在list上作出的错误标注在tuple上是完全合法的:
1 | a: tuple[int, str] = (3, "4") |
tuple可以接受任意数量的类型参数,就上面的例子中,这个标注的意思是该它的第一个元素是int,第二个元素是str。
当然,类似于在函数定义中使用...来表示占位或暂时省略,tuple也能使用它来表示任意长度:
1 | a: tuple[int, ...] = (3, 4) |
...只能作为tuple类型参数的第二参数,也就是说,上面这个标注的意思,是该变量是元素均为int的任意长度的tuple。
最后,空元组可以用tuple[()]表示,就像这样:
1 | a: tuple[()] = () |
基础语法就是上面这些,不过从Python3.12开始,还有个类型标注语法值得我们学习。
类型别名
在刚刚的实例中,我们不止一次地用到了类似int | float的类型标注,就拿my_max()函数来说,它能接受的参数其实就是广义上的数字,这种只有两三个类型的标注其实还好说,但当我们碰到更长的情况时,总不能每一个地方都写同样长的东西,不是吗?那不妨给这一长串起个别名:
1 | type Number = int | float |
看着简洁直观多了不是吗?那么类似的,如果我想涵盖Python中所有的类型,是不是就能这样……
1 | type Any = int | float | complex | list | tuple | dict |
额……即便只是在写实例,老实说这样写也非常蠢()但有时我们也确实需要表达“任意类型”的情况不是吗?好在Python其实早就给出了解决方案:
1 | from typing import Any |
此外,刚刚我们提到,类型别名是Python3.12才添加的语法,如果是3.12之前的版本,又该怎么办呢?
1 | from typing import TypeAlias |
type Name = ...语法本质上就是创建了一个TypeAlias类型的变量,因此在3.12版本以下,我们还可以从typing导入TypeAlias类来创建完全等价的类型别名。
到此,我们就讲解完了类型标注的所有基础语法,接下来我们可以讲讲进阶的东西了,或者说——typing模块可不止上面这点东西是值得我们学习使用的。
typing
typing是Python3.5版本引入的一个用于支持类型标注的模块,这个模块是标准库的一部分,因此无需安装就能直接import到脚本中使用。由于Python的类型标注语法是渐进式的加入到Python中并逐渐完善的,因此在每个版本中typing总是承担了一定的过渡性作用,一部分在某个版本不能使用的高版本类型注释语法就需要通过typing来创建完全等价的实现(例如刚刚的类型别名语法的向下兼容)。
在此之前,我们再来看看一开始的这段代码:
1 | class Damage_Info: ... |
我其实还埋着两个坑没有处理:
get_skill()是个绝对不会返回的方法,它唯一的作用就是抛出一个断言异常hit()的第二参数使用了早期的标注语法,"Type"表示该类型会在其之后创建
我们先来解决这两个“遗留问题”
Never(NoReturn)和Self
这里我先把问题直接解决了,然后解释为什么:
1 | # from typing import NoReturn |
这里最重要的更改有三个:
- 从
typing模块引入Never(或者NoReturn)和Self类 - 将原本
hit_target的类型标注从字符串表示改为了Self - 将
get_skill()原本不知道该怎么表达“不可能返回值”,只好用-> None暂时妥协的写法,改成了-> Never
在以前的文档中,我犯了两个错误,其中一个就是误以为NoReturn跟-> None是等价的,但实际上这俩完全不同,前者表示函数绝对不会返回,而后者表示函数会正常返回,只是没有返回值而已(额……当然也有可能是真的只会返回None,不过应该没人这么写,对吧?)
不过我这个例子其实不算是“函数绝对不会返回”的情况(虽说它的确不可能返回,但设计上明显是不想让它被调用,对吧?),而是“你绝对不该调用该函数”的情况,如果你想要一个“绝对不会返回”的例子,其实官方文档就有一个:
1 | # 引用于Python官方文档 |
当然,Never还有其他有趣的用法,官方文档其实就给了不错的例子,推荐各位自己去看看。
至于Self,它表示的是当前闭包的类,这个我想无需过多解释。详情请见官方文档
泛型
我刚刚说自己犯过两个错误,这一节就是我犯的第二个错误,在那篇废弃文档中,我将这一小节的标题命名为了“抽象类”,但其实,Python官方的叫法是“泛型”,因此我做出了更改。
说回正题,我们都知道Python中存在不同的容器,像是序列类的list、tuple、set,抑或是字典类(好像不少人更喜欢称之为Map)的dict。
这就引出了一种需求,就是某个函数实际上支持序列类或者字典类的数据,像是list | tuple | set这样的写法虽说问题不大,但同样没法兼顾自定义的序列类型,类似的,字典也存在同样的问题,为了解决这个问题,Python在typing中提供了两个容器类,来看看下面的例子:
1 | from typing import Mapping |
在这里,我瞎写了一个检查数字的函数,各位就当这个函数是检查序列内的所有数字是否存在于提供的字典里就行。我们使用Sequence涵盖了所有的序列容器,用Mapping便涵盖了所有的字典容器。
其实再次回想,这俩容器类其实是泛型抽象基类,在Python官方文档中,这俩类甚至是从collections.abc模块导入进来的,但老实说我也懒得继续改说辞了,所以后面我继续沿用“泛型”这个称呼。
上面这段代码,check_number()的Sequence参数的元素类型和Mapping参数的值类型应当是保持一致的,纵使上面这个Number类型别名是个比较宽松的检查,也不妨碍实际调用者如此理解,那不妨我们一不做二不休,直接上一个更严格的泛型检查:
1 | from typing import Mapping |
这个是Python3.12版本才引入的类型标注语法,也是不少静态类型编程语言就提供的东西,其中这个[T]是一个泛型参数,反映到这段代码,就是说第一参数里元素的类型,应该跟第二个字典参数的值保持一个类型。
当然,3.12以下也是有语法作为平替的:
1 | from typing import Mapping |
通过TypeVar创建一个新的变量(其实应该叫类型变量),便能优雅的写出完全等价的东西,真是太棒了!
Optional[Type] & Union[Type, Type]
来看看这两个函数:
1 | from typing import Any |
第一个函数的say来者不拒,但是明显存在一个规则,让它有可能完全无语凝噎,这个函数老哥因为没有说清楚自己想要什么,路人给他塞一堆东西自然是合理的;第二个函数把“我想要一个数字”写到了脸上,虽说n依旧是来者不拒的参数,但至少这次我们知道了它会在什么情况下无语,这也非常合理。
其实在讲解基础语法时我故意没提,|语法其实是Python3.10版本才开始支持的,虽说我是不太相信2026年还有人在用3.10以下的版本,但要想让上面的类型标注在3.10版本以下也能使用,就得改成这样:
1 | from typing import Any |
Union[A, B]完全等价于A | B,但这并不是重点,我们可以发现……这俩函数其实有个共同点,就是返回值都是A | None,那么typing里头有没有提供比这个还简洁的类型标注呢?
1 | from typing import Any |
Optional这个单词的意思是“可选的”,诸位如果有类似按住Ctrl点击查看某个类具体实现的习惯,那么在typing里,大概能找到这样一句话:
Optional[X] is equivalent to Union[X, None].
翻译过来就是,Optional[X]等价于X | None,因此下次再碰到这种“函数可能返回值,也可能什么都不给”的情况,就能用这个类型标注来表示了。
字面量
在开发实践中,难免会出现某些字面量经常被使用的情况,例如用特定的字符串表达状态(虽说这种情况用枚举更好),亦或者某个函数需要用字符串来区分行为模式什么的,就像下面这个函数一样:
1 | def check(this_what: str) -> int: |
可以看到,这个函数至少存在六个固定的字面量表达来反馈某类状态,拿第一个举例,可能这个NPC在调查过程中瞧见了47(没错,HITMAN里的47),结果这哥们还没反应过来就被47一扳手敲晕了过去,对这个NPC来说这是个不正常的状态,因此他反馈了一个-1()
不开玩笑了,对于这种字面量表达,实际上typing里也有类型可以表示:
1 | from typing import Literal |
Literal跟作为类型标注的tuple一样,可以接受任意长度的类型参数,但Literal只能接受字面量。此外,官方文档中还提到了一些细节,这里就不做展示了。
结语
这篇文档是在发现旧文档中存在错误后,连夜重写的一篇,跟那篇废弃文档一样,这篇文档只提了一些我认为常用且比较基础的部分,并不能涵盖所有可能的应用场景(例如生成器的类型标注),因此强烈建议各位在碰到类型注解需求时,多多翻阅官方文档。
参考文献:


