type hints in Python

Python中的类型系统,使用type hints使得整个开发过程更加顺畅.类似typescript的目的.

Type Theory

值得一提的是python目前还在蒸蒸日上,所以一些东西后面可能会有些改变,不过答题的东西是不变的,可以使用mypypython/mypy: Optional static typing for Python (github.com)(或者pyrightmicrosoft/pyright: Static Type Checker for Python (github.com))进行检查,可以使用Welcome to Pydantic - Pydantic作为数据验证,大多数IDE本身也对这个默认支持.

PEP 483 是这一切的起点.

Subtypes

一个重要的概念是subtypes(亚型)。

形式上,如果以下两个条件成立,我们说T型是U的subtypes:

  • 来自T的每个值也在U类型的值集合中。
  • 来自U型的每个函数也在T型函数的集合中。

这两个条件保证了即使类型T与U不同,类型T的变量也可以总是假装为U。

举个具体的例子,考虑T=bool和U=int。bool类型只取两个值。通常这些名称表示为True和False,但这些名称分别只是整数值1和0的别名:

Covariant, Contravariant, and Invariant

在复合类型中使用子类型时会发生什么?例如,Tuple[bool]是Tuple[int]的一个子类型吗?答案取决于复合类型,以及该类型是协变(Covariant)的、反变(Contravariant)的还是不变(Invariant)的。

  • 元组是协变(Covariant)的。这意味着它保留了其项类型的类型层次结构:Tuple[bool]是Tuple[int]的子类型,因为bool是int的子类型。
  • 列表是不变(Invariant)的。不变类型不能保证子类型。虽然List[bool]的所有值都是List[int]的值,但您可以将int附加到List[int],而不是List[bool。换句话说,子类型的第二个条件不成立,并且List[bool]不是List[int]的子类型。
  • Callable在其参数中是反变(Contravariant)的。这意味着它颠倒了类型层次结构。若Callable[[T],…]作为一个函数,它唯一的参数是T类型。Callable的一个例子[[int],…]是double()函数。反变意味着,如果期望一个在布尔上操作的函数,那么一个在int上操作的功能是可以接受的。

内置类型

1
2
3
4
5
x: int = 1
x: float = 1.0
x: bool = True
x: str = "test"
x: bytes = b"test"

在3.8及之前,使用from typing import List,Dict,Set,Tuple 来使用collections,之后可以直接使用list,dict这种.

1
2
3
4
x: list[int] = []
x: tuple[int,...] = (1, 2)
x: set[int] = {1, 2}
x: dict[str, float] = {"field": 2.0, "field2": "a"}

在3.10+,可以直接使用|代替Union

1
2
x: list[int|str] = [1, 2, "a"]
x: Optional[str]

函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
x: Callable[[int], str] = stringify
def gen(n: int) -> Iterator[int]:
for i in range(n):
yield i

def send_email(address: Union[str,list[str],None]) -> None:
...
# This says each positional arg and each keyword arg is a "str"
def call(self, *args: str, **kwargs: str) -> str:
reveal_type(args) # Revealed type is "tuple[str, ...]"
reveal_type(kwargs) # Revealed type is "dict[str, str]"
request = make_request(*args, **kwargs)
return self.do_api_query(request)
def quux(x: int,/, y: str, z: float) -> None:
...

quux(1, '2', z=3.0)

如果你想要函数的调用者在某个参数位置只能使用位置参数而不能使用关键字参数传参,那么你只需要在所需位置后面放置一个/。

如果你希望强迫调用者使用某些参数,且必须以关键字参数的形式传参,那么你只需要在所需位置的前一个位置放置一个*。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
from typing import ClassVar

class BankAccount:
account_name: str
balance: float

count: ClassVar
def __init__(self, account_name: str, initial_balance: float = 0.0) -> None:
self.account_name = account_name
self.balance = initial_balance

def deposit(self, amount: float) -> None:
self.balance += amount

def withdraw(self, amount: float) -> None:
self.balance -= amount
class AuditedBankAccount(BankAccount):
audit_log: list[str]

def __init__(self, account_name: str, initial_balance: float = 0.0) -> None:
super().__init__(account_name, initial_balance)
self.audit_log = []

def deposit(self, amount: float) -> None:
self.audit_log.append(f"Deposited {amount}")

def withdraw(self, amount: float) -> None:
self.audit_log.append(f"Withdrew {amount}")


# You can use the ClassVar annotation to declare a class variable
class Car:
seats: ClassVar[int] = 4
passengers: ClassVar[list[str]]


class A:
def __setattr__(self, key, value):
print("Setting", key, "to", value)
self.__dict__[key] = value

def __getattr__(self, key):
print("Getting", key)
return self.__dict__[key]

class Person(A):
name: str
age: int
weight: float

def __init__(self, name: str, age: int, weight: float) -> None:
self.name = name
self.age = age
self.weight = weight


p = Person("John", 30, 80.0)
print(p.name)

Forward references

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# You may want to reference a class before it is defined.
# This is known as a "forward reference".
def f(foo: A) -> int: # This will fail at runtime with 'A' is not defined
...

# However, if you add the following special import:
from __future__ import annotations
# It will work at runtime and type checking will succeed as long as there
# is a class of that name later on in the file
def f(foo: A) -> int: # Ok
...

# Another option is to just put the type in quotes
def f(foo: 'A') -> int: # Also ok
...

class A:
# This can also come up if you need to reference a class in a type
# annotation inside the definition of that class
@classmethod
def create(cls) -> A:
...

Decorators

decorator通常是将一个函数作为参数并返回另一个函数的函数。

用类型来描述这种行为可能有点棘手;我们将展示如何使用TypeVar和一种称为参数规范的特殊类型变量来实现这一点。

假设我们有装饰器,尚未进行类型注释,它保留了原始函数的签名,只打印装饰函数的名称:

1
2
3
4
5
def printing_decorator(func):
def wrapper(*args, **kwds):
print("Calling", func)
return func(*args, **kwds)
return wrapper

给这个装饰器类型注释

1
2
3
4
5
6
7
8
9
from functools import wraps
from typing import TypeVar, Callable, cast, Any
F = TypeVar("F", bound=Callable[..., Any])
def printing_decorator(func: F) -> F:
@wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
print("Calling", func.__name__)
return func(*args, **kwargs)
return cast(F, wrapper)

这仍然存在一些不足。首先,我们需要使用不安全的cast()来说服mypy wrapper()与func具有相同的签名。其次,wrapper()函数没有经过严格的类型检查,尽管wrapper函数通常足够小,所以这不是什么大问题。

1
2
3
4
5
6
7
8
9
10
11
from typing import Callable, TypeVar
from typing_extensions import ParamSpec

P = ParamSpec('P')
T = TypeVar('T')

def printing_decorator(func: Callable[P, T]) -> Callable[P, T]:
def wrapper(*args: P.args, **kwds: P.kwargs) -> T:
print("Calling", func)
return func(*args, **kwds)
return wrapper

可以使用参数规范(ParamSpec)来获得更好的类型注释:

1
2
3
4
5
6
7
8
9
from typing import TypeVar, Callable, Any,ParamSpec
P = ParamSpec("P")
T = TypeVar('T')
def printing_decorator(func: Callable[P,T]) -> Callable[P,T]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> Any:
print("Calling", func.__name__)
return func(*args, **kwargs)
return wrapper

参数规范还允许描述更改输入函数签名的装饰器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from typing import Callable, TypeVar
from typing_extensions import Concatenate, ParamSpec

P = ParamSpec('P')
T = TypeVar('T')

# We reuse 'P' in the return type, but replace 'T' with 'str'
def stringify(func: Callable[P, T]) -> Callable[P, str]:
def wrapper(*args: P.args, **kwds: P.kwargs) -> str:
return str(func(*args, **kwds))
return wrapper

@stringify
def add_forty_two(value: int) -> int:
return value + 42

a = add_forty_two(3)
reveal_type(a) # Revealed type is "builtins.str"
add_forty_two('x') # error: Argument 1 to "add_forty_two" has incompatible type "str"; expected "int"

P = ParamSpec('P')
T = TypeVar('T')

def printing_decorator(func: Callable[P, T]) -> Callable[Concatenate[str, P], T]:
def wrapper(msg: str, /, *args: P.args, **kwds: P.kwargs) -> T:
print("Calling", func, "with", msg)
return func(*args, **kwds)
return wrapper

@printing_decorator
def add_forty_two(value: int) -> int:
return value + 42

a = add_forty_two('three', 3)
1
2
3
4
5
6
7
8
9
from typing import Any, Callable, TypeVar

F = TypeVar('F', bound=Callable[..., Any])

def bare_decorator(func: F) -> F:
...

def decorator_args(url: str) -> Callable[[F], F]:
...

Generics

内置集合类是泛型类。泛型类型有一个或多个类型参数,这些参数可以是任意类型。例如,dict[int,str]具有类型参数int和str,list[int]具有类型形参int。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from typing import TypeVar, Generic

T = TypeVar('T')

class Stack(Generic[T]):
def __init__(self) -> None:
# Create an empty list with items of type T
self.items: list[T] = []

def push(self, item: T) -> None:
self.items.append(item)

def pop(self) -> T:
return self.items.pop()

def empty(self) -> bool:
return not self.items

类ClassName(Protocol[T])被允许作为类ClassName的简写class ClassName(Protocol, Generic[T])

TypedDict

Python程序经常使用带有字符串键的字典来表示对象。TypedDict允许您为表示具有固定架构的对象的字典提供精确的类型,例如{’id’:1,’items’:〔’x’〕}。

1
2
3
4
5
6
7
8
9
10
from typing import TypedDict
Movie = TypedDict('Movie', {'name': str, 'year': int})

movie: Movie = {'name': 'Blade Runner', 'year': 1982}
class Movie(TypedDict):
name: str
year: int

class BookBasedMovie(Movie):
based_on: str

NamedTuple

NamedTuple 是一个工厂函数,用于创建不可变的、具名的元组类型。它结合了元组的性能优势和类的易读性,特别适合于创建轻量级的对象,这些对象包含固定数量的字段,并且在创建后不应该被修改

1
2
3
4
5
6
7
8
from typing import NamedTuple

class Point(NamedTuple):
x: int
y: int

p = Point(11, 22)
print(p.x) # 输出: 11
  • NamedTuple 适用于创建不可变的数据结构,如坐标点、日期时间等,这些通常在创建后不会改变。
  • TypedDict 更适合创建可变的、键值对形式的数据结构,尤其当需要一个具有类型安全性的字典时,例如配置文件或用户资料

Literal

Literal类型可以指示表达式等于某个特定的primitive 值。

例如,如果我们用Literal[“foo”]类型注释一个变量,mypy将理解该变量不仅是str类型的,而且具体地等于字符串“foo”。

1
2
3
4
5
6
from typing import Final, Literal

def expects_literal(x: Literal[19]) -> None: pass

reveal_type(19)
expects_literal(19)

更多类型

  • NoReturn可以告诉mypy函数永远不会正常返回。
  • NewType允许您定义类型的变体,该变体被mypy视为单独的类型,但在运行时与原始类型相同。例如,您可以将UserId作为int的一个变体,它在运行时只是一个int。
  • @overload允许您定义一个可以接受多个不同签名的函数。如果您需要对难以正常表达的参数和返回类型之间的关系进行编码,这将非常有用。
  • Async 类型允许您使用异步和等待来键入检查程序。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from typing import NoReturn

def stop() -> NoReturn:
raise Exception('no way')


from typing import NewType

UserId = NewType('UserId', int)

def name_by_id(user_id: UserId) -> str:
...

UserId('user') # Fails type check

name_by_id(42) # Fails type check
name_by_id(UserId(42)) # OK

num: int = UserId(5) + 1

一些最佳实践

对类型别名使用 TypeAlias(但不对常规别名使用 TypeAlias)

1
2
3
4
_IntList: TypeAlias = list[int]
g = os.stat
Path = pathlib.Path
ERROR = errno.EEXIST

一般来说,当当前类型系统无法恰当地表达某一类型,或者使用正确的类型不符合人体工程学时,应使用 Any。 如果函数接受所有可能的对象作为参数,例如因为它只传递给 str(),则应使用 object 而不是 Any 作为类型注解。 同样,如果回调函数的返回值被忽略,则应使用 objec 作为类型注解。

1
2
3
def call_cb_if_int(cb: Callable[[int], object], o: object) -> None:
if isinstance(o, int):
cb(o)

对于参数,首选protocol和抽象类型(Mapping、Sequence、Iterable 等)

如果参数可接受任何字面意义上的值,则应使用 object 而不是 Any。 对于返回值,具体实现时应首选具体类型(list、dict 等)

protocol和抽象基类的返回值必须根据具体情况来判断

Protocol 与标准库中的 collections.abc 中的抽象基类(如 Sequence, Mapping 等)相似,但有关键的区别:

  • Protocol 不强制实现,而 ABC 通常要求子类显式实现所有抽象方法。
  • Protocol 更关注于类型检查和IDE的代码智能,而 ABC 主要是运行时的多态性和类型检查
1
2
3
4
5
6
>import abc
>MyAbstractClass(metaclass=abc.ABCMeta):
@abc.abstractmethod
def do_something(self):
"""必须在子类中实现的方法"""
pass

ABC 模块比较简单,限制单继承,而 ABCMeta 模块则允许多继承,并允许在抽象类中使用类方法和静态方法,从而提供了更大的灵活性。

1
2
3
def map_it(input: Iterable[str]) -> list[int]: ...
def create_map() -> dict[str, int]: ...
def to_string(o: object) -> str: ... # accepts any object
1
2
3
class MyProto(Protocol):
def foo(self) -> list[int]: ...
def bar(self) -> Mapping[str, str]: ...

避免使用Union返回类型,因为它们需要检查 isinstance()。 必要时使用 Any 或 X | Any

在可能的情况下,使用简称语法代替 Union 或 Optional 来表示联合。 联合单元的最后一个元素应为 “None”。

1
2
def foo(x: str | int) -> None: ...
def bar(x: str | None) -> int | None: ...

使用 float 代替 int | float, 使用 None 代替 Literal[None].

尽可能使用内置泛型,而不是typing中的别名。

1
2
3
4
from collections.abc import Iterable

def foo(x: type[MyClass]) -> list[str]: ...
def bar(x: Iterable[str]) -> None: ...

参考

  1. Python Type Checking (Guide) – Real Python
  2. Type hints cheat sheet - mypy 1.7.1 documentation
  3. https://python-type-challenges.zeabur.app/
  4. Typing Best Practices — typing documentation
-------------本文结束感谢您的阅读-------------
感谢阅读.

欢迎关注我的其它发布渠道