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
常用参数
- –explicit-package-bases:当子 package 没有
__init__.py
时,mypy 会将其视为 workspace root package 下的 package,加上后会以目录结构推断 python 文件对应的包名,而不必每个 package 都加__init__.py
,非常讨厌 - –exclude:有些目录下只是放一些实验代码,不想花时间加类型,传一个正则排除扫描,其实缺一个功能,要是可以与 .gitignore 同步就好了,hatch 有这个功能
- –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 |
|
好了,现在你已经掌握 Python with type 了,下面学习泛型
1 |
|
讨论一下错误写法 lst: list[T] = []
,T
是一个类型参数,因此仅在类型声明时可以使用,在为实例注释类型时填充这个类型参数,上述写法的 T 不会有任何机会被填充,因此没有意义。如果 T 有 bound=Base
的约束,你的想法可能是约束 T 需要是 Base 或其子类型,这时直接用 list[Base]
就可以了。另外,据我所知,Python 目前还没有约束类需要是某类父类的约束规则,但是似乎可以通过 contravariant 间接实现。
1 |
|
解释下 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:
- T1=C T2=B 是 T1=C T2=C 的超类型。
- T1=D T2=C 是 T1=C T2=C 的子类型。
- T1=D T2=B 也是 T1=C T2=C 的子类型。
- T1=D T2=C 与 T1=C T2=B 之间没有直接的父子关系。
1 |
|
好了,现在你已经掌握 Python 泛型了,下面用一个需求练手,定义一个函数类型,接收一个参数,参数类型需要实现某函数,返回类型同样需要实现某(不同的)函数
1 |
|
使用 Callable[[ArgType], RetType]
(或者说 subtyping 方案)是不行的,因为 Callable 中的参数是逆变的,下面用泛型实现
1 |
|
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 |
|
GPT: 最后,根据你的函数逻辑和对类型信息的需求来选择最合适的方式。如果你确定函数不会用到具体的配置类型信息,或者不需要对返回的配置对象做特殊处理,那么直接使用 BaseModelConfig 可能是更直接的选择。如果函数需要处理具体类型,那么使用 TypeVar 就是正确的做法。