Python Type Hint

default

众所周知,Python 是动态类型语言,运行时不需要指定变量类型。这一点是不会改变的,但是2015年9月创始人 Guido van Rossum 在 Python 3.5 引入了一个类型系统,允许开发者指定变量类型。它的主要作用是方便开发,供IDE 和各种开发工具使用,对代码运行不产生影响,运行时会过滤类型信息。

Python的主要卖点之一就是它是动态类型的,这一点也不会改变。而在2014年9月,Guido van Rossum (Python BDFL) 创建了一个Python增强提议(PEP-484),为Python添加类型提示(Type Hints)。并在一年后,于2015年9月作为Python3.5.0的一部分发布了。于是对于存在了二十五年的Python,有了一种标准方法向代码中添加类型信息。在这篇博文中,我将探讨如何使用它。

基本使用

基本数据类型

# def 函数名(变量: 类型) -> 返回值类型
def foo(a: int, b: int) -> int: 
    return a + b

print(f(1, 2))

自定数据类型

class A:
    name = "A"

def get_name(o: A) -> str:
    return o.name

get_name(A)  
#       ~^~ 错误提示:应为类型 'A',但实际为 'Type[A]'
get_name(A())  # 正确写法

特殊情况: 出现循环依赖的情况可加引号解决此类问题,例如下面举了一个双向链表的例子

class Node:
    def __init__(self, prev: "Node"):
        self.prev = prev
        self.next = None

列表和字典

列表

# Python < 3.9
from typing import List

def my_sum(lst: List[int]) -> int:
    total = 0
    for i in lst:
        total += i
    return total

my_sum([0, 1, 2])
my_sum([0, 1, "2"])
#      ~~~~~~~~^~ 这里的"2"将会提示报错
# Python >= 3.9
def my_sum(lst: list[int]) -> int:
    total = 0
    for i in lst:
        total += i
    return total

my_sum([0, 1, 2])
my_sum([0, 1, "2"])
#      ~~~~~~~~^~ 这里的"2"将会提示报错

但如果my_sum中传入tuple也将会报错,可将类型List修改成Sequence,而Sequence也更常在实际中使用。

from typing import Sequence

def my_sum(lst: Sequence[int]) -> int:
    total = 0
    for i in lst:
        total += i
    return total

# 以下写法均合法
my_sum([0, 1, 2])
my_sum((0, 1, 2))
my_sum(b'0123')
my_sum(range(3))

字典

def my_sum(d: dict[str, int]) -> int:
    total = 0
    for i in d.values():
        total += i
    return total

my_sum({"a": 1, "b": 2, "c": 3})
my_sum({"a": 1, "b": 2, "c": "3"})
#                       ~~~~~^^^~ 当传入值类型不符时就会报错

传入值类型不固定

from typing import Union

def foo(x: Union[int, None]) -> int:
    if x is None:
        return 0
    return x

f(None)
f(0)

Python 3.10及以上版本时,x: Union[int, None]可简写成x: int | None。当传入类型有可能含有None或者其他类型时,还可使用Optional。例如

from typing import Optional

def foo(x: Optional[int]) -> int:
    if x is None:
        return 0
    return x

显然Optional的写法比Union的写法也更加清晰

变量的 Type Hints

在实际开发中,可能会想创建一个字符串列表,可以使用如下方法

user: list[str] = []
user.append(1)
#          ~^~ 将会报错:应为类型 'str' (匹配的泛型类型 '_T'),但实际为 'int'

一些其他用法

返回类型Any

在我们没有进行类型标注时,那么类型默认即为Any,只是大部分时候我们认为显式是要好于隐式的。

from typing import Any

def foo(a: Any) -> Any:
    return a

但是当函数没有返回值时,不能将函数返回值类型标注为Any,因为当函数没有返回值时,函数实际上是返回了None,下面就是一个不理想案例

from typing import Any

def foo(a: list) -> Any:
    a.append(1)

lst: list = []
i: int = foo(lst)

当函数foo类型标注为Any时(实际上函数foo返回None),变量i永远不可能从函数foo中得到一个int,但是函数foo类型标注为Any,这个时候不会报错,因为IDE认为函数foo返回任何东西都有可能。如果我们把函数foo的返回类型改为None

from typing import Any

def foo(a: list) -> None:
    a.append(1)

lst: list = []
i: int = foo(lst)
#        ~~~~^^^~ 将会报错:应为类型 'int',但实际为 'None'

上文提到没有进行类型标注时,那么类型默认即为Any,这里也亦如此,当函数foo的返回类型留空不填时还会出现如上问题,所以当函数不返回值的时候也不应该留空,还是有必要加上返回类型None的。

返回类型NoReturn

当然也有函数真的不会返回东西,即他不是正常的运行完这个函数之后没有显示返回而返回None,他可能是raise了一个exception或直接退出了程序,这个时候可用NoReturn进行类型标注。

from typing import NoReturn

def error() -> NoReturn:
    raise ValueError

返回类型Callable

当我们要求传入的参数是可调用的时,我们可以使用Callable进行类型标注,下面以函数装饰器举例

from typing import Callable

def my_dec(func: Callable):
    def wrapper(*args, **kwargs):
        print("start")
        ret = func(*args, **kwargs)
        print("end")
        return ret
    return wrapper

my_dec(1)
#     ~^~ 此处报错:Argument 1 to "my_dec" has incompatible type "int";
#                  expected "Callable[..., Any]"

接下来修改一下这个函数

from typing import Callable

def my_dec(func: Callable):
    def wrapper(a: int, b: int):
        print(f"args = {a}, {b}")
        ret = func(a, b)
        print(f"result = {ret}")
        return ret
    return wrapper

@my_dec
def add(a: int, b: int) -> int:
    return a + b

add(1, 2)

这个函数装饰器的作用就是将两个参数ab打印下来并且将最后的结果打印下来,结果如下

args = 1, 2
result = 3

但是如果被装饰的函数只接受一个参数时(没有或不止两个时)就会带来一些问题,如

@my_dec
def absolute(a: int) -> int:
    return abs(a)

absolute(1, 2)

现在运行这个代码就会出错

args = 1, 2
Traceback (most recent call last):
  File "example.py", line 19, in <module>
    absolute(1, 2)
  File "example.py", line 7, in wrapper
    ret = func(a, b)
TypeError: absolute() takes 1 positional argument but 2 were given

那么现在想让这个函数func接受什么参数就返回什么值怎么办呢,就像对待列表和字典一样,做进一步定义

from typing import Callable

def my_dec(func: Callable[[int, int], int]):
    def wrapper(a: int, b: int):
        print(f"args = {a}, {b}")
        ret = func(a, b)
        print(f"result = {ret}")
        return ret
    return wrapper

# vvvvv~ 报错:Argument 1 to "my_dec" has incompatible type "Callable[[str, str], str]"; 
@my_dec     # expected "Callable[[int, int], int]"
def add(a: str, b: str) -> str:
    return a + b

# vvvvv~ 报错:Argument 1 to "my_dec" has incompatible type "Callable[[int], int]"; 
@my_dec     # expected "Callable[[int, int], int]"
def absolute(a: int) -> int:
    return abs(a)


absolute(1, 2)

返回类型Literal

当我们想规定某个参数传进的值只能是规定的值时也可以使用Type Hint中的Literal

from typing import Literal

class Person:
    def __init__(self, name: str, gender: Literal["male", "female"]):
        self.name = name
        self.gender = gender

a = Person("Li", "woman")
#         ~~~~~~~^^^^^^^~ 将会报错:应为类型 'Literal["male", "female"]',但实际为 'Literal["woman"]'
b = Person("Bob", "male")

但是有时候我们想让一个变量如g保存gender但是这样导致了一个问题,没错会报错

g = "female"
a = Person("Li", g)
#          ~~~~~~^~ Argument 2 to "Person" has incompatible type "str"; 
#                   expected "Literal['male', 'female']"

这个时候我们只要把g进行类型标注即可解决问题

g: Literal["male", "female"] = "female"
a = Person("Li", g)

但是这样g就太过麻烦,有没有办法简写呢?答案是有的,因为Type Hint在Python中也是一个object,也就是说能给他一个变量名

from typing import Literal

GenderType = Literal["male", "female"]

class Person:
    def __init__(self, name: str, gender: GenderType):
        self.name = name
        self.gender = gender

g: GenderType = "female"
a = Person("Li", g)

建立新返回类型NewType

为避免直接给类型起别名的一下小弊端,Python为我们提供了NewType,让我们新建返回类型

from typing import NewType

UserId = NewType("UserId", int)
AttackPoint = NewType("AttackPoint", int)

class Player:
    def __init__(self, uid: UserId, attack: AttackPoint):
        self.uid = uid
        self.attack = attack

    def update_attack(self, atk: AttackPoint):
        self.attack = atk

但是这也会带来一个问题,不能直接用int给值了,需要显式的将1变成UserId,100变成AttackPoint

p = Player(1, 100)
#   ~~~~~~~^~~^^^~ 竟然报错了:
#          | Argument 1 to "Player" has incompatible type "int"; expected "UserId"
#          | Argument 2 to "Player" has incompatible type "int"; expected "AttackPoint"

# 正确写法
p = Player(UserId(1), AttackPoint(100))

当然Type Hint在Run Time的时候不会有任何影响,也让我们的程序不会容易出错了,但是这样也让Python变得复杂了