Python with Type

本文主要介绍如何进行带静态类型检查的 Python 项目开发,第一部分会介绍如何使用静态类型检查工具 mypy,以及如何将其集成到 vscode 中使用,第二部分会基于具体案例介绍 Python 泛型中一些较为 tricky 的点。

为什么要做静态类型检查

我认为主要目的是提升代码可读性,从而减少 bug 的出现。依稀记得在很久以前使用某个第三方 Python 库时,想要在 IDE 中点进去看函数签名,结果发现是一个 func(*args, **kwaergs),瞬间无语。就算是只有你自己开发,也容易忘记变量的类型,为了自己以及他人的开发效率,我们会给变量加上 type hint 帮助我们记忆变量类型,需要注意的是,Python 与 Java 类似,泛型在运行期都是擦除的,type hint 只是起(有官方预发支持的)文档的作用,有时进行重构后,原有的 type hint 反而会对我们产生误导,如果有这样一个工具,从类型的源头(变量/参数/返回类型声明)开始遍历 AST,生成整个程序中变量的类型,当计算结果与我们标注的不一致时进行提醒,type hint 就不再只是一个文档,此时它能够保证静态类型的安全。这样的工具就是 static type checker。笔者也是初次使用,主要介绍 mypy 这个工具,PyTroch 也在使用,应该不至于过时。

Python 静态类型检查工具 —— Mypy

安装 pip install mypy

使用 python -m mypy /path/to/your/project/root

常用参数

  1. –explicit-package-bases:当子 package 没有 __init__.py 时,mypy 会将其视为 workspace root package 下的 package,加上后会以目录结构推断 python 文件对应的包名,而不必每个 package 都加 __init__.py,非常讨厌
  2. –exclude:有些目录下只是放一些实验代码,不想花时间加类型,传一个正则排除扫描,其实缺一个功能,要是可以与 .gitignore 同步就好了,hatch 有这个功能
  3. –ignore-missing-imports: 某些包在开发环境 python 的 site-packages 目录下不存在,避免报错

举例 python -m mypy --explicit-package-bases --exclude=3rdparty --ignore-missing-imports /work/dev/my-service

vscode 集成

装一个 Mypy Type Checker 插件即可

Python 泛型

虽然不是重点,在讲泛型前先介绍下 type hint 的基本语法,出注释行外都可以通过 type check

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
a = 1
a: int = 1
b: int | str = 1
b = 'a'
b = None # fail to type check
c = Optional[int] = None

class A:
def func(a: A) -> None: ...
d: list[int]() # from Python 3.8,后续都用这种
form typing import List
d: List[int] # before Python 3.8
e = set[int]()
f = dict[int, str]()
g = dict[str, dict[str, int]]()

好了,现在你已经掌握 Python with type 了,下面学习泛型

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
class Base: ...
class Sub(Base): ...
T = TypeVar("T", bound=Base) # T 必须是 Base 或其子类,类似 C++ 的 <template T>
V = TypeVar("V", bound=Base)
U = TypeVar("U", bound=Base, covariant=True) # 名称参数必须与变量名一致(至少是 mypy 的要求),类似的参数还有 contravariant,默认是 invariant 模式
W = TypeVar("W", bound=Base, contravariant=True)

# 定义一个泛型类, Generic[TypeArgList...] 是固定语法
class A(Generic[T, V]):
def f(self, _: T) -> T:
raise NotImplementedError() # f 不是一个泛型函数,T 在类初始化时确定

def g(self, _: V) -> V:
raise NotImplementedError() # g 是一个泛型函数,因为它包含了类型参数 U,而 U 并未在类初始化时被确定

abase = A[Base, Base]()
asub = A[Base, Sub]()
asub2 = A[Sub, Sub]()

class B(Generic[U]): ...

def fn(_: W) -> None: ... # 也可以在类外声明泛型函数

# lst: list[T] = [] # error: Type variable "tmp2.T" is unbound # 下方具体讨论

dict_with_type_restriction = dict[T, U]
a: dict_with_type_restriction = {}
a["a"] = 1 # pass,a 接受两个泛型参数,不填默认 Any,因此这里没问题

# b: dict_with_type_restriction[Base, Base] = {}
# b["b"] = 1 # 违反类型约束

# c: dict_with_type_restriction[B, Base] = {} # B 不是 Base 的子类

讨论一下错误写法 lst: list[T] = []T 是一个类型参数,因此仅在类型声明时可以使用,在为实例注释类型时填充这个类型参数,上述写法的 T 不会有任何机会被填充,因此没有意义。如果 T 有 bound=Base 的约束,你的想法可能是约束 T 需要是 Base 或其子类型,这时直接用 list[Base] 就可以了。另外,据我所知,Python 目前还没有约束类需要是某类父类的约束规则,但是似乎可以通过 contravariant 间接实现。

1
2
3
4
5
6
7
8
T = TypeVar("T", bound=Base) # 为类型参数添加约束
some_dict_type = dict[str, T] # T 稍后初始化
a: some_dict_type[int] = {} # a 的类型是 dict[str, int]

class A(Generic[T]): ... # 同样,T 稍后初始化
a = A[int]()
a_int_type_alias = A[int] # type alias
a1: a_int_type_alias = A()

解释下 invariant / covariant / contravariant,这些性质决定了 泛型类的子类与泛型类本身具有何种父子类关系,举个例子,对于传入具有 invariant 性质的 T 作为泛型参数类型的类 A,abase 与 asub 没有父子关系,而 B 则有 bsub 是 bbase 的子类,如果是 contravariant 模式,则父子关系颠倒。泛型默认是 invariant 的,函数参数、返回类型是 covariant 的,函数(Callable)参数 contravariant 的,返回类型是 covariant 的。^mypy_generic_variant

注意:与 bound 不同,bound 讨论的是对 泛型类型 的约束,in/co/contra-variant 讨论的是(接受泛型类型作为参数的)泛型类,在泛型参数具有父子关系时,泛型类之间是否具有、具有何种父子关系。

Question: 某个类接收多个泛型类型作为参数,某些是 covariant 的,某些是 contravariant 的,这种情况下,泛型类的子类与泛型类本身具有何种父子关系?

假设 B > C > D:

  1. T1=C T2=B 是 T1=C T2=C 的超类型。
  2. T1=D T2=C 是 T1=C T2=C 的子类型。
  3. T1=D T2=B 也是 T1=C T2=C 的子类型。
  4. T1=D T2=C 与 T1=C T2=B 之间没有直接的父子关系。
1
2
3
4
5
6
7
8
9
# From GPT
Example[A1, A2]是Example[B1, B2]的子类型的条件是:

A1是B1的子类型或A1等于B1(协变)
同时
A2是B2的父类型或A2等于B2(逆变)
如果Example[T1, T2]有不变的泛型参数,那么在这些参数上类型必须是完全相同的才能认为一个类是另一个类的子类型。

所以,如果Example[T1, T2]的一个实例要成为另一个实例的子类型,它必须满足以上所有条件。如果任何一个条件不满足,那么就不能说一个是另一个的子类型。

好了,现在你已经掌握 Python 泛型了,下面用一个需求练手,定义一个函数类型,接收一个参数,参数类型需要实现某函数,返回类型同样需要实现某(不同的)函数

1
2
3
4
5
6
7
8
9
class ArgType(Protocol[T]):
def f(self, _: T): ...

class RetType(Protocol[T]):
def g(self, _: T): ...

# Protocol 是 Python 中的 duck-typing,可以不用显式声明继承 Protocol,实现方法即可
class ArgImpl:
def f(self, _: SomeClass): ...

使用 Callable[[ArgType], RetType](或者说 subtyping 方案)是不行的,因为 Callable 中的参数是逆变的,下面用泛型实现

1
2
3
4
5
6
T = TypeVar("T", bound=ArgType)RetType
R = TypeVar("R", bound=BatchedResult)
InferenceFn = Callable[[T], R]

def call_func(fn: InferenceFn[T, R], arg: T) -> R:
return fn(arg)

TODO

pydantic BaseModel + Protocol 多重继承 metaclass 不同问题
https://stackoverflow.com/q/67658372

Question: 以 def generic_function(service: ModelService[U]) 为例,如果我这里对 U 可能传入任何 BaseModelConfig 的子类,那么我应当声明 U 为 TypeVar 好,还是直接把泛型参数填为 BaseModelConfig 好呢?
考虑这样的情况,假如函数返回类型 U,并且你在返回后需要这个特定子类的属性,那么 TypeVar 是有效的,否则二者都可以
举个例子

1
2
3
4
5
6
7
def f(arg: T) -> T:
pass

f(clsAInsrance).afunc()
f(clsBInstance).bfunc()

# 而使用 def f(arg: BaseCls) 就无法实现功能

GPT: 最后,根据你的函数逻辑和对类型信息的需求来选择最合适的方式。如果你确定函数不会用到具体的配置类型信息,或者不需要对返回的配置对象做特殊处理,那么直接使用 BaseModelConfig 可能是更直接的选择。如果函数需要处理具体类型,那么使用 TypeVar 就是正确的做法。


Python with Type
https://vicety.github.io/2024/02/19/写类型安全的Python/
作者
vicety
发布于
2024年2月19日
许可协议