你是否有这样的困扰:

  • 代码量到达一定数量级后,很难记住自己编写的函数应该传入什么类型的参数
  • 陈年代码即使代码命名规范,也弄不清楚一个函数究竟返回的什么值
  • 同事(合作者)很难理解你写的函数(或者类)在对什么类型的数据做什么样的操作,返回了什么类型的数据
  • 等等……

如果您恰好有这些(或类似)的困扰,又在现阶段没什么解决方案的话,Python的类型标注语法便是您的一个不错的选择。

Python类型标注语法从3.5版本开始引入,在此之前的版本并不能支持这个语法,因此在使用类型标注时请注意这个前提。现在我们就开始正式内容吧。

基本语法

让我们先直接看看下面的代码:

#!python3.11

def list_print(target):
for i in target:
print(i, end=" ")

def add_number(a, b):
return a + b

temp = [7, 8, 6, 2]
list_print(temp)

temp1 = add_number(2, 3.14)

上面的代码是我随便写的,它们的功能也挺简单的,让人一眼就能看出两个函数究竟在干什么事。不过……我们来看看add_number()这个函数,虽然我们的确可以看出它是对两个数进行求和,但是在Python中,字符串同样可以通过+相连接,换句话说这个函数在传入字符串时也能够正常工作。还有一个问题是参数混合传入的问题,如果调用者传入了一个int类型的数据和一个str类型的数据,那么这个函数一定是不能正常工作的,在这种情况下,一个直觉上的做法是做类型检查:

#!python3.11
...
def add_number(a, b):
assert [(a, b) for t in (int, float) if type(a) is t and b is t], "Unsupported types"

return a + b
...

在这里,我们在代码中添加了一句断言来判断两个参数的类型是否为int或者float,在发现类型不属于这两种类型时,函数便会终止执行并抛出一个AssertionError。这样的解决方案固然是好事,不过我们实际上也更希望在编写时就不要出现这种错误,为此,我们可以改成这样:

#!python3.11
...
def add_number(a: int, b: int) -> int:
assert [(a, b) for t in (int, float) if type(a) is t and b is t], "Unsupported types"

return a + b
...

在上面的基础上,我在每个参数后面加上了: [type],在函数的最后加上了-> [type],这就是最基础的类型标注语法,通过冒号来注明变量的类型,通过->来注明函数的返回值。那么基于这个知识点,最开始的代码我们就可以修改成这样(我们先假设list_print()函数只接受list):

#!python3.11

def list_print(target:list) -> None:
for i in target:
print(i, end=" ")

def add_number(a: int, b: int):
assert [(a, b) for t in (int, float) if type(a) is t and b is t], "Unsupported types"

return a + b

temp: list = [7, 8, 6, 2]
list_print(temp)

temp1: int = add_number(2, 3.14)

这里为了更好的演示类型标注,我为两个变量temptemp1也做了类型标注。在这里,由于list_number()并没有返回值,因此我们为这个函数加上的返回值标注为-> None,表明这个函数并不返回任何东西。一套最基础的类型标注就这样写好了。值得注意的是,诸如intlist等内置类型的类名实际上可以直接作为类型标注的一部分写进去,而无需顾虑它们是否会出现语法错误……吗?在这里我是基于3.11版本的Python做的类型标注,关于不同版本的变化,我们待会儿就会提到。

不过也许您已经发现了,add_number()在设计上也能够接收float作为参数,但我们的类型标注并没有反映这一点,为此我们需要引入一个新的符号:|。这个符号我想各位应该再熟悉不过了,它在类型标注里面的用法也非常简单:

#!python3.11

def add_number(a: int | float, b: int | float) -> int | float:
assert [(a, b) for t in (int, float) if type(a) is t and b is t], "Unsupported types"

return a + b
...

只需要用|分割两个及以上的类型,便能表示一个对象支持多种不同的类型,这个语法只在3.10及以上的版本有效。

截至到此,这个函数现在已经不足以支持更多的功能了,现在,让我们来把这个函数的功能扩充一下,并且换个新的名字:

#!python3.11

def is_number(target_list: list) -> bool:
"""简单实现的一个判断列表内所有元素是否都是支持类型的函数"""
if len(target_list) == 0:
return False

for i in target_list:
if isinstance(i, int) or isinstance(i, float):
pass
else:
return False

return True

def sum(target: list) -> int | float:
assert is_number(target)

temp = 0

for i in target:
temp += i

return temp

这个名为sum()的函数接受一个列表作为参数,目的也是非常简单直接的对数据进行求和,函数里面我们使用了断言判断列表内是否有不支持的类型。对于这种需要列表中只能存在特定类型的情况,类型标注也能很好的应对(这里就只写函数定义了):

#!python3.11

def sum(target: list[int | float]): ...

通过在支持的类型后面加上[],然后在其中列出类型(用|隔开),就能很好的表示这个函数接受的列表(上面的例子)中只能是int或者float类型。

对于dict这种类型,我们也可以分别为键和值设定类型,就像这样:

#!python3.11

def read_dict(target: dict[str, int | float]) -> None: ...

类似于dict的类型比如Map,我们都可以用type[key_type, value_type]来进行标注,而且里面还支持嵌入其它类型标注语法来实现更加复杂的标注。那么这里的标注就表明这个参数接受的是keystrvalueint或者float的字典。

当然,针对list这样的序列类型,这样的写法是错误的:

#!python3.11

a: list[int, float] = [3.14, 6] # 错误的类型标注写法

类似list这样的类型的类型标注都只接受一个类型参数,因此上面的写法自然会导致静态类型检查器抛出错误。不过tuple是个例外:

a: tuple[int, str] = (20, "a")

在这里,这个类型标注表示元组的第一个元素是int类型,第二个是str类型。那么长度不定又该怎么办呢……

#!python3.11

a: tuple[int | float, ...] = [3.14, 6]

使用三个点便能很好的表达“长度不定”的含义,上面的类型标注的解释自然就是一个内部元素为int或者float的、长度不确定的元组了,请注意,三点只能作为类型参数的第二个参数。

现在回到一般化的情况,要想表示一个空的序列,那么只需要把方括号里面的内容直接换成()就好了:

#!python3.11

a: list[()] = [] # 空列表
b: tuple[()] = () # 空元组

上面的内容便是基础类型标注的所有内容了,现在让我们稍微综合一下上面的内容:

#!python3.11

class BaseSprite: ...

class Fight:
def __init__(self, global_buff_list: list["Buff"], pa: list[BaseSprite], pb: list[BaseSprite]) -> None: ...

def check_buff(self, target_buff: "Buff") -> None: ...

def start() -> None: ...

@dataclass
class Buff:
name: str

这里我们简单实现了一个战斗事件处理的类(当然,这里为了省事没有写很完整的源代码),并且加上了上面提到的大部分类型标注语法。这里有一个上面漏掉的细节,那就是如果一个类型在标注前并没有被定义,那么可以用双引号包裹来表示这个类型会在其之后创建。

基础的标注语法就这样差不多了,下面我们开始来点进阶的东西。

typing

typing是Python3.5版本引入的一个用于支持类型标注的模块,这个模块无需额外安装便可直接import到脚本中使用(当然,肯定不支持3.5以下的Python版本)。由于Python的类型标注语法是渐进式的加入到Python中并逐渐完善的,因此在每个版本中typing总是承担了一定的过渡性作用,一部分在某个版本不能使用的高版本类型注释语法就需要通过typing来编写。我们先不提typing作为低版本类型注释兼容的功能,来看看typing的一些普通用法。

抽象类

在一些情况下,我们需求的类型可能是一些有着相似功能的好几个甚至好几十个类型,例如具有序列(Sequence)功能的list、tuple和set,又或者都属于数字(number)的int和float,以及一些第三方库定义的类别。针对这些情况,第三方库固然可以通过标注其父类来解决这个问题,但是内置类型就得借助typing来解决了。还记得我们的list_print()函数吗?现在我们来修改一下:

#!python3.11

from typing import Sequence

def sequence_print(target: Sequence) -> None:
for i in target:
print(i, end=" ")

Sequence即序列,常见的list、tuple都属于这个范畴,通过一个Sequence我们就不必傻乎乎的用list | tuple这种写法了,是不是很方便?

当然,我们肯定也会碰到一个函数的某个参数支持所有类型的情况,或者干脆就是我并不知道一个函数会返回什么类型,这个时候便可以用Any来进行标注:

#!python3.11

from typing import Any

def any_function(obj: Any) -> Any: ... # 这里的函数命名不要在意(

除了上面这些,typing里面还有许多其他的抽象类可以使用,详情请查阅Python官方文档,在这里很多与类型标注相关的功能都会有所介绍。

更清晰的表达

类型标注的存在意义便是增强代码可读性,typing中定义了一些能够帮助我们更加清晰表达的类和字段,来帮助我们更好的进行类型标注。在这里我们就来看看几个比较常用的。

第一个场景,还记得之前我们对函数标注的-> None吗?这个实际上可以有两个理解:

  • 函数是真的返回None值
  • 函数没有返回值

针对这种模糊不清的表达,typing提供了一个处理没有返回值情况的类NoReturn来更加直接的表达这个含义:

#!python3.11

from typing import NoReturn

def useless_function() -> NoReturn: ... # 一个和它名字一样没什么用的函数

第二个场景,一个函数会出现返回某个类型的值或者None的情况,也许你会下意识的想到用[type] | None,这样的写法确实挺好的,不过既然连单独没有返回值的情况都能有个NoReturn来表示了,那么这里的None我们是否也有办法省略呢?

#!python3.11

from typing import Optional

def my_add(a: int, b: int) -> Optional[int]: ...

Optional在typing中的文档字符串为“Optional[X] is equivalent to Union[X, None].”,也就是说,Optional[type]就代表了type或者None,通过使用它,我们便又一次省下了一种稍微麻烦的写法。

最后,在类的__init__(self)函数中,第一个参数代表对象本身,这个self是否也能对它进行标注呢?还有一个场景是,我们定义的某个方法需要返回对象自己,这又该如何表示呢?我们来看看下面的例子:

#!python3.11

from typing import NoReturn

class Alice:
def __init__(self: "Alice", name: str) -> NoReturn: ...

def copy() -> "Alice": ...

这个写法就是我们在基础语法小节介绍的使用字符串包裹之后才会定义的类型的字段的技巧,这种标注肯定是没问题的但……不可能每个类我都得这样进行标注吧,有没有什么更简单、更通用的标注呢?

#!python3.11

from typing import NoReturn
from typing import Self

class Alice:
def __init__(self: Self, name: str) -> NoReturn: ...

def copy() -> Self: ...

Self我想无需过多解释,这个字段用于表示对象自身,有了这个字段,便可以不用傻乎乎的使用字符串包裹未定义类型名的方式来标明传入或返回自身的情况了。

类型标注兼容

还记得我们提到的typing低版本兼容的作用吗?在前面我们提过,Python的类型标注是渐进式的添加到Python中的,因此不同时期Python版本的类型标注语法也不尽相同,例如使用|分割类型的语法就是3.10才开始支持的。

类型别名

由于我在写这篇博客时是使用的3.11版本的Python来测试类型标注,因此实际上我还有一个3.12的语法没有提到,这里索性就直接说了。在3.12,我们可以通过type语句来定义一个类型别名,就像这样:

#!python3.12

type NumberList = list[int | float]

如果是使用低版本,我们也可以通过typing模块的TypeAlias类型,来显式的表明一个常规的变量定义实际上是定义类型别名:

#!python3.11
from typing import TypeAlias

NumberList: TypeAlias = list[int | float]

多类型语法

在3.10以下的Python中,使用竖线分割多个类型并不被支持,对于这种情况,我们需要使用Union

#!python3.9

# 类型标注等价于`int | float`
temp: Union[int, float] = 3.14

同样的,3.10以下版本的内置类型的名字list等并不支持类似list[]的语法,因此我们需要额外引入对应的typing类型替代:

#!python3.9
from typing import List # 以list为例
from typing import Union

# 类型标注等价于list[int | float]
temp: List[Union[int, float]] = [3.14, 89]

通过import对应类型的首字母大写的类型名,便可让我们编写与之相对应的类型标注。

有关更多类型标注相关的内容,还请参阅相关的Python官方文档,由于我平常可以拿来写博客的时间并不多,因此博客的内容肯定是不如官方文档全面甚至有可能会犯一些现阶段还没找到的错误的。

结语和参考

有关Python类型标注语法的介绍就到这里结束了,这篇博客只提到了一些本人认为常用且基础的部分,并不能涵盖所有的可能的应用场景,因此各位在实际使用时也应当多去参阅官方文档。

参考文献: