使用 Mypy 检查 30 万行 Python 代码
反馈
总体而言:我对 Mypy 持积极的看法。 作为核心基础设施的开发人员(跨服务和跨团队使用的公共库),我认为它极其有用。
**一句话总结:**虽然采用 Mypy 是有代价的(前期和持续的投入、学习曲线等),但我发现它对于维护大型 Python 代码库有着不可估量的价值。Mymy 可能不适合于所有人,但它十分适合我。
Mypy 是什么?
如果你很熟悉 Mypy,可跳过本节。)
def greeting(name: str) -> str: return 'Hello ' + namePython 在 2014 年通过 PEP-484 定义了这种类型注解语法。虽然这些注解是语言的一部分,但 Python(以及相关的第一方工具)实际上并不拿它们来强制做到类型安全。
在 Spring 集成 Mypy
- 像其它增量类型检查工具一样(例如 Flow),随着代码库的注解越来越多,Mypy 的价值会与时俱增。由于 Mypy 可以并且将会用最少的注解捕获 bug,所以你在代码库上投入注解的时间越多,它就会变得越有价值。
- 像其它增量类型检查工具一样(例如 Flow),随着代码库的注解越来越多,Mypy 的价值会与时俱增。由于 Mypy 可以并且将会用最少的注解捕获 bug,所以你在代码库上投入注解的时间越多,它就会变得越有价值。
尽管有所犹豫,我们还是决定给 Mypy 一个机会。在公司内部,我们有强烈偏好于静态类型的工程师文化(除了 Python,我们写了很多 Rust 和 TypeScript)。所以,我们准备使用 Mypy。
反馈
总体而言:我对 Mypy 持积极的看法。 作为核心基础设施的开发人员(跨服务和跨团队使用的公共库),我认为它极其有用。
我将在以后的任何 Python 项目中继续使用它。好处
Zulip 早在 2016 年写了一篇漂亮的文章,内容关于使用 Mypy 的好处(这篇文章也被收入了 Mypy 官方文档 中)。
我不想重述静态类型的所有好处(它很好),但我想简要地强调他们在帖子中提到的几个好处:-
改善可读性:有了类型注解,代码趋向于自描述(与文档字符串不同,这种描述的准确性可以静态地强制执行)。(英:self-documenting)
-
**捕获错误:**是真的!Mypy 确实能找出 bug。从始至终。
-
**自信地重构:**这是 Mypy 最有影响力的一个好处。有了 Mypy 的广泛覆盖,我可以自信地发布涉及数百甚至数千个文件的更改。当然,这与上一条好处有关——我们用 Mypy 找出的大多数 bug 都是在重构时发现的。
痛点
Zulip 的帖子同样强调了他们在迁移 Mypy 时所经历的痛点(与静态代码分析工具的交互,循环导入)。
坦率地说,我在 Mypy 上经历的痛点与 Zulip 文章中提到的不一样。我把它们分成三类:-
外部库缺乏类型注解
-
Mypy 学习曲线
-
对抗类型系统
1. 外部库缺乏类型注解
最重要的痛点是,我们引入的大多数第三方 Python 库要么是无类型的,要么不兼容 PEP-561。在实践中,这意味着对这些外部库的引用会被解析为不兼容,这会大大削弱类型的覆盖率。
每当在环境里添加一个第三方库时,我们都会在mypy.ini 里添加一个许可条目,它告诉 Mypy 要忽略那些模块的类型注解(有类型或提供类型存根的库,比较罕见):[mypy-altair.*] ignore_missing_imports = True [mypy-apache_beam.*] ignore_missing_imports = True [mypy-bokeh.*] ignore_missing_imports = True ...由于有了这样的安全出口,即使是随便写的注解也不会生效。例如,Mypy 允许这样做:
import pandas as pd def return_data_frame() -> pd.DataFrame: """Mypy interprets pd.DataFrame as Any, so returning a str is fine!""" return "Hello, world!"除了第三方库,我们在 Python 标准库上也遇到了一些不顺。例如,functools.lru_cache 尽管在 typeshed 里有类型注解,但由于复杂的原因,它不保留底层函数的签名,所以任何用 @functools.lru_cache 装饰的函数都会被移除所有类型注解。
import functools @functools.lru_cache def add_one(x: float) -> float: return x + 1 add_one("Hello, world!")第三方库的情况正在改善。例如,NumPy 在 1.20 版本中开始提供类型。Pandas 也有一系列公开的类型存根 ,但它们被标记为不完整的。(添加存根到这些库是非常重要的,这是一个巨大的成就!)另外值得一提的是,我最近在 Twitter 上看到了 Wolt 的 Python 项目模板 ,它也默认包括类型。
2. Mypy 学习曲线
if condition: value: str = "Hello, world" else: # Not ok -- we declared `value` as `str`, and this is `None`! value = None ... if condition: value: str = "Hello, world" else: # Not ok -- we already declared the type of `value`. value: Optional[str] = None ... # This is ok! if condition: value: Optional[str] = "Hello, world" else: value = None另外,还有一个容易混淆的例子:
from typing import Literal def my_func(value: Literal['a', 'b']) -> None: ... for value in ('a', 'b'): # Not ok -- `value` is `str`, not `Literal['a', 'b']`. my_func(value)
除了学习曲线之外,还有持续地注解函数和变量的开销。我曾建议对某些“种类”的代码(如探索性数据分析)放宽我们的 Mypy 规则——然而,团队的感觉是注解是值得的,这件事很酷。
3. 对抗类型系统
在编写代码时,我会尽量避免几件事,以免导致自己与类型系统作斗争:写出我知道可行的代码,并强迫 Mypy 接受。@overload def clean(s: str) -> str: ... @overload def clean(s: None) -> None: ... def clean(s: Optional[str]) -> Optional[str]: if s: return s.strip().replace("\u00a0", " ") else: return None
@overload def lookup( paths: Iterable[str], *, strict: Literal[False] ) -> Mapping[str, Optional[str]]: ... @overload def lookup( paths: Iterable[str], *, strict: Literal[True] ) -> Mapping[str, str]: ... @overload def lookup( paths: Iterable[str] ) -> Mapping[str, Optional[str]]: ... def lookup( paths: Iterable[str], *, strict: Literal[True, False] = False ) -> Any: pass即使这是一个 hack——你不能传一个bool到 find_many_latest,你必须传一个字面量 True 或False。
from typing import TypedDict class Point(TypedDict): x: float y: float a: Point = {"x": 1, "y": 2} # error: Expected TypedDict key to be string literal b: Point = {**a, "y": 3}在实践中,很难用TypedDict对象做一些 Pythonic 的事情。我最终倾向于使用 dataclass 或 typing.NamedTuple 对象。
F = TypeVar("F", bound=Callable[..., Any]) def decorator(func: F) -> F: def wrapper(*args: Any, **kwargs: Any): return func(*args, **kwargs) return cast(F, wrapper) @decorator def f(a: int) -> str: return str(a)但是,我发现使用装饰器做任何花哨的事情(特别是不保留签名的情况),都会导致代码难以类型化或者充斥着强制类型转换。
提示与技巧
最后,我要介绍几个在使用 Mypy 时很有用的技巧。
1. reveal_type
# No need to import anything. Just call `reveal_type`. # Your editor will flag it as an undefined reference -- just ignore that. x = 1 reveal_type(x) # Revealed type is "builtins.int"当你处理泛型时,reveal_type 特别地有用,因为它可以帮助你理解泛型是如何被“填充”的、类型是否被缩小了,等等。
2. Mypy 作为一个库
Mypy 可以用作一个运行时库!
def test_check_function(self) -> None: result = api.run( [ os.path.join( os.path.dirname(__file__), "type_check_examples/function.py", ), "--no-incremental", ], ) actual = result[0].splitlines() expected = [ # fmt: off 'type_check_examples/function.py:14: error: Incompatible return value type (got "str", expected "int")', # noqa: E501 'type_check_examples/function.py:19: error: Missing positional argument "x" in call to "__call__" of "FunctionPipeline"', # noqa: E501 'type_check_examples/function.py:22: error: Argument "x" to "__call__" of "FunctionPipeline" has incompatible type "str"; expected "int"', # noqa: E501 'type_check_examples/function.py:25: note: Revealed type is "builtins.int"', # noqa: E501 'type_check_examples/function.py:28: note: Revealed type is "builtins.int"', # noqa: E501 'type_check_examples/function.py:34: error: Unexpected keyword argument "notify_on" for "options" of "Expression"', # noqa: E501 'pipeline.py:307: note: "options" of "Expression" defined here', # noqa: E501 "Found 4 errors in 1 file (checked 1 source file)", # fmt: on ] self.assertEqual(actual, expected)
3. GitHub 上的问题
当搜索如何解决某个类型问题时,我经常会找到 Mypy 的 GitHub Issues (比 Stack Overflow 还多)。它可能是 Mypy 类型相关问题的解决方案和 How-To 的最佳知识源头。你会发现其核心团队(包括 Guido)对重要问题的提示和建议。
4. typing-extensions
typing模块在每个 Python 版本中都有很多改进,同时,还有一些特性会通过typing-extensions模块向后移植。
例如,虽然只使用 Python 3.8,但我们借助typing-extensions ,在前面提到的工作流编排库中使用了 3.10 版本的ParamSpec。(遗憾的是,PyCharm 似乎不支持通过typing-extensions 引入的ParamSpec 语法,并将其标记为一个错误,但是,还算好吧。)当然,Python 本身语法变化而出现的特性,不能通过typing-extensions 获得。5. NewType
在typing模块中有很多有用的辅助对象,NewType是我的最爱之一。
NewType可让你创建出不同于现有类型的类型。例如,你可以使用NewType来定义合规的谷歌云存储 URL,而不仅是str类型,比如:
from typing import NewType GCSUrl = NewType("GCSUrl", str) def download_blob(url: GCSUrl) -> None: ... # Incompatible type "str"; expected "GCSUrl" download_blob("gs://my_bucket/foo/bar/baz.jpg") # Ok! download_blob(GCSUrl("gs://my_bucket/foo/bar/baz.jpg"))
6. 性能
Mypy 的性能并不是我们的主要问题。Mypy 将类型检查结果保存到缓存中,能加快重复调用的速度(据其文档称:“Mypy 增量地执行类型检查,复用前一次运行的结果,以加快后续运行的速度”)。
在我们最大的服务中运行 mypy,冷缓存大约需要 50-60 秒,热缓存大约需要 1-2 秒。-
Mypy 守护进程在后台持续运行 Mypy,让它在内存中保持缓存状态。虽然 Mypy 在运行后将结果缓存到磁盘,但是守护进程确实是更快。(我们使用了一段时间的默认 Mypy 守护进程,但因共享状态导致一些问题后,我禁用了它——我不记得具体细节了。)
-
共享远程缓存。如前所述,Mypy 在每次运行后都会将类型检查结果缓存到磁盘——但是如果在新机器或新容器上运行 Mypy(就像在 CI 上一样),则不会有缓存的好处。解决方案是在磁盘上预置一个最近的缓存结果(即,预热缓存)。Mypy 文档概述了这个过程,但它相当复杂,具体内容取决于你自己的设置。我们最终可能会在自己的 CI 系统中启用它——暂时还没有去做。
结论
Mypy 对我们产生了很大的影响,提升了我们发布代码时的信心。虽然采纳它需要付出一定的成本,但我们并不后悔。
除了工具本身的价值之外,Mypy 还是一个让人印象非常深刻的项目,我非常感谢维护者们多年来为它付出的工作。在每一个 Mypy 和 Python 版本中,我们都看到了对typing模块、注解语法和 Mypy 本身的显著改进。(例如:新的联合类型语法(X|Y)、ParamSpec和TypeAlias,这些都包含在 Python 3.10 中。)