GuoSir 2019-09-29
作为 Python 的大用户之一,Dropbox 公司内部聚集了数百万行 Python 代码,动态类型的存在让代码越来越难以理解。因此,公司开始利用 mypy 逐步将代码转换为静态类型。虽然效果得到了充分验证,但整个过程充满了各种错误和失败。
本文,Dropbox 公司完整输出了从项目研究到实践的 Python 静态检查全过程,以期对各位开发者有所帮助。
事实上,Python 已经成为 Dropbox 公司使用范围最广的语言,其广泛适用于后端服务与桌面客户端应用程序等(当然,Dropbox 公司也在大量使用 Go、TypeScript 以及 Rust 等语言)。在 Dropbox 公司数以百万计的 Python 代码行中,动态类型的存在让代码越来越难以理解,并严重影响生产力水平。为了缓解这一问题,Dropbox 公司一直在利用 mypy 逐步将代码转换为静态类型(顺带一提,mypy 可能是目前 Python 当中最流行的独立类型检查器,属于开源项目,其核心开发团队来自于 Dropbox。)。
截至目前,Dropbox 已经在成千上万个项目当中使用 mypy,而且效果都得到了很好地验证。但对于此次全方位检查 Python 代码,Dropbox 仍然抱着忐忑的心情,整个过程也充满了错误与失败。在今天的文章中,Dropbox 将向大家分享 Python 静态检查之旅——从最早的学术研究项目,到现在逐步让类型检查与类型提示成为 Python 社区中众多开发人员的常规操作。现在,已经有多种工具支持类型检查功能,包括各类 IDE 与代码分析器等。
如果开发者只使用过动态类型的 Python,当然有可能对静态类型以及 mypy 感到陌生。甚至,不少开发者就是因为动态类型而喜欢上 Python,但这事儿在逻辑上就有点莫名其妙。其关键应该在于,静态类型检查是实现规模化的前提:项目越大,需要的静态类型就越多。
一旦项目中包含成千上万行代码,而且有多位工程师在同时使用,以往开发经验告诉我们,理解代码内容就成了保障开发人员工作效率的关键所在。如果没有类型注释,基本的代码作用推理(例如找到函数的有效参数,或者可能的返回值类型)就会成为一大难题。以下是几个在缺少类型注释时,开发人员难以回答的典型问题:
只要有了类型注释,开发者能够很轻松地回答这些与代码片段相关的问题,例如:
在理想情况下,我们当然希望把这一切都记录在文档中,但拥有从业经验的开发者肯定知道没这么好的事儿。即使存在此类文档,我们也无法完全信任其中的内容——例如内容含糊不清或者不够准确,因此带来巨大的误解空间。对于大型团队或代码库,这类问题可能产生巨大的影响:
虽然 Python 在项目早期与中期阶段表现良好,但当项目发展到特定阶段后,成功的项目与使用 Python 语言的企业可能面临一个关键性决定:我们是否需要利用静态类型语言重写所有内容?
类似 mypy 这样的类型检查器主要负责提供用于类型描述的形式语言,并通过验证所获得的类型与实现(以及可能存在的可选项)间的匹配解决这一难题。更具体地讲,类型检查器专门提供经过验证的文档。
当然,除此之外,类型检查器还可带来其它助益:
在 Dropbox,我们成立了一个三人小队,从 2015 年底开始研究 mypy。成员分别是 Guido、Greg Price 以及 David Fisher。从那时起,工作开始快速推进。首先,在 mypy 采用面前的最大障碍就是性能。我们一直在将其运行在 CPython 解释器上,这对于 mypy 这样的工具来说速度有点不够用。(作为包含 JIT 编译器的 Python 替代性方案,PyPy 在这方面也帮不上什么忙。)
幸运的是,我们实现了一系列算法层面的改进。我们采用的第一项加速措施就是 增量检查。 其背后的思路非常简单:如果模块的所有依赖关系都与 mypy 运行前的状态毫无区别,那我们完全可以使用前一次运行的缓存数据获取依赖关系,意味着只需要类型检查修改了的文件及其依赖关系。mypy 则在此基础上更进一步:如果模块的外部接口没有改变,mypy 甚至不需要重新检查导入该模块的其它模块。
在对现有代码进行批量注释时,增量检查确实非常有用,因为其中往往涉及 mypy 的大量迭代运行,用以处理陆续插入且逐渐细化的类型。最初的 mypy 运行仍然相当缓慢,这是因为它需要处理大量依赖项。为此,我们实现了远程缓存。如果 mypy 检测到本地缓存可能已经过期,mypy 将从集中存储库下载整个代码库的最新缓存快照。在此之后,它会以下载到的缓存为基础执行增量构建。这又进一步提高了性能表现。
到 2016 年底,Dropbox 公司已经有大约 42 万行 Python 完成了类型注释。很多用户都热衷于类型检查,而 mypy 的使用则在 Dropbox 各团队之间迅速传播。
情况看起来相当不错,但距离真正的成功还有很长的路。我们开始定期进行内部用户调查,借以找出痛点,并确定需要优先考虑的工作(这种习惯直到今天也一直被保持下来)。其中,有两项请求始终排名最高:更大的类型检查覆盖范围以及更快的 mypy 运行速度。 很明显,我们的性能与采用提升工作还没有全部完成。为此,我们还得在这两项任务上再多下点力气。
性能提升方法一:使用 mypy 守护进程
增量构建虽然提升了 mypy 的速度,但仍然没有达到顶峰。大量增量运行可能需要一分钟的处理时长。对于任何面对大型 Python 代码库的用户来讲,其中的原因相信并不难理解:循环导入。
我们拥有数百个模块,模块相互间接导入。如果导入周期的任何文件发生变更,那么 mypy 就必须处理周期中的所有文件,同时还得处理在此周期内导入该模块的所有其它模块。其中最臭名昭著的循环就是“纠结(tangle)”,它给 Dropbox 带来了很大麻烦。其中一度包含有数百个模块,众多测试级乃至产品级功能都要或直接或间接地将其导入。
我们一直在考虑打理这种纠结无比的依赖关系,但却始终没有合适的方法着手进行。毕竟我们不熟悉的代码太多了。因此,我们想出了另一个办法——即使存在这种“纠结”,我们同样可以提升 mypy 速度。答案就是,使用 mypy 守护进程。守护进程是一项服务器进程,负责执行两项非常重要的工作。
首先,它将关于整体代码库的信息保存在内存中,这样每次 mypy 运行就不再需要加载数千条与所导入依赖项相对应的缓存数据。其次,它会跟踪函数与其构造之间的细粒度依赖关系。例如,如果函数 foo 调用函数 bar,那么就存在一项从 bar 到 foo 的依赖关系。当文件发生变更时,守护程序会首先单独处理已经变更的文件;接下来,它会查找该文件中包含的外部可见变更,例如变更的函数签名。守护程序所采用的细粒度依赖项管理机制,能够确保只重新检查实际变更的那些函数——换言之,只检查极少数函数。
实现上述目标当然是个巨大的挑战,因为我们最初的 mypy 实现方案只适合一次处理一个文件。但在实际需求发生变化之后——例如当某个类获得一个新的基类时,我们必须重新处理大量边缘情况。经过艰苦卓绝的努力与投入,我们成功将大部分增量运行缩短至几秒钟。这是一场伟大的胜利,至少在我们当事人看来相当伟大!
性能提升方法二:将 Python 编译为 C
配合之前提到的远程缓存,mypy 守护进程几乎完全解决了增量类用例,工程师们只需要对少量文件进行迭代变更即可。但是,最差情况下的性能表现仍然远未达到最佳状态。进行一次彻底的 mypy build 可能需要 15 分钟,这样的结果当然无法令人满意。由于工程师们在不断编写新代码,并在现有代码当中添加类型注释,因此情况每周都在恶化。我们的用户渴望获得更高的性能,而我们也自然不能让大家失望。
因此,我们决定延续 mypy 立项之初的重要想法——将 Python 编译为 C。 遗憾的是,Cython(一款现成的 Python 到 C 编译器)并不能提供任何显著的加速效果,因此 我们决定从零开始编写编译器。 由于 mypy 代码库(使用 Python 编写)已经全面完成类型注释,因此利用这些注释来加快速度自然是符合逻辑的选择。我构建了一套快速概念验证原型,其在各类微基准测试中将性能提升了 10 倍以上。我们的想法是将 Python 模块编译为 CPython C 扩展模块,并将类型注释转换为运行时类型检查(在运行时中通常被忽略的类型注释,仅供类型检查器使用)。我们开始着手将 mypy 实现由 Python 迁移至真正的静态类型语言,这恰好与 Python 的迁移思路完全匹配。(这种跨语言迁移正成为新的常态,mypy 最初由 Alore 编写,但后来则转换为 Java/Python 自定义语法的混合体。)
对 CPython 扩展 API 的定位,是保持项目整体可管理性的关键所在。我们不需要实现虚拟机或者 mypy 所需要的任何库。此外,我们仍然可以利用一切原有 Python 生态系统与工具(例如 pytest),并能够在开发期间继续使用经过解释的 Python 代码,从而实现极快的编辑测试周期且不必等待编译过程。
这款被我们命名为 mypyc 的编译器(因为它利用 mypy 作为前端来执行类型分析)非常成功。总体而言,我们在不使用缓存的前提下实现了大约 4 倍的运行性能提升。mypyc 项目的核心开发在小团队的推动之下用了大约 4 个月即告完成,团队成员包括 Michael Sullivan、Ivan Levkivskyi、Hugh Han 和我自己。很明显,这里的工作量远少于使用 C++ 或者 Go 完全重写 mypy,相关影响也要小得多。我们希望 mypyc 最终能够被交付至 Dropbox 的其他工程师手上,供他们编译并加速自己的更多代码。
在达成如此出色的性能提升效果的过程中,我们尝试了不少有趣的性能工程方法。编译器可以利用快速、低级 C 构造实现众多操作的加速。例如,对某个已编译函数的调用会被翻译成 C 函数调用,而后者要比调用解释函数快得多。另外,某些操作(例如字典查找)仍然会回退至常规的 CPython C API 调用,从而略微提升编译时的调用速度。总而言之,我们摆脱了解释带来的性能开销,从而稍稍改善了操作的速度表现。
我们还进行了一系列分析工作,希望了解“慢速操作”中的普遍共性。有了这些数据,我们尝试调整 mypyc 为这些操作生成速度更快的 C 代码,或者利用更快的操作方式重写相关 Python 代码(有时候确实没什么好办法,只能硬着头皮重写)。后者通常要比在编译器中自动转换容易得多,不过从长远来看,我们更倾向于实现自动化转换。但还是要具体问题具体分析,有时候为了以最低的投入获得更大的性能提升,我们也会抄近路。
在完成上述工作后,还面临一个重要挑战(也是 mypy 用户调查中排名第二的重要要求)就是提升类型检查的覆盖范围。 我们尝试了多种方法以实现这项目标:从有机增长,到专注于 mypy 团队的手动调整,再到静态与动态自动化类型推理等。最后,我们发现其中并不存在简单的实现策略,但我们将多种方法结合起来,从而显著提高了能够在代码库中实现的快速注释工作量。
结果就是,我们在最大的 Python 库(后端代码)中的注释行数在大约三年之内增长至近 400 万行,这些全都迁移成了静态类型代码。mypy 现在支持多种覆盖报告,能够帮助我们轻松跟踪相关进度。具体来讲,我们可以报告各类不够明确的类型来源——例如在注释中使用的显式、未经检查的类型,或者未进行类型注释的已导入第三方库等。为了在 Dropbox 当中改善类型检查精度,我们还在中央 Python 类型库中为不少流行的开源库提供经过针对性改进的类型定义(即 stub 文件)。
我们实现了(并在后续 PEP 当中标准化了)新的类型系统,旨在为某些惯用的 Python 模式提供更精确的类型。其中一个典型例子正是 TypeDict,其负责提供 JSON 类字典类型。字典当中包含一组固定的字符串键,各个字符串拥有不同的值类型。我们后续还将不断扩展这套类型系统,同时考虑改进对 Python 数字堆栈的支持能力。
以下是 Dropbox 在提升注释覆盖率时,设定的核心工作要点:
经验总结 检查 400 万行代码绝非易事,我们在整个过程中遇到不少挑战,当然也犯过错误。下面,我想总结经验教训,希望能给大家带来启示。
文件丢失。 起步之初,我们的 mypy 版本只需处理少量内部文件——或者说,从未接触过 build 之外的一切。在添加第一条注释时,文件被隐式添加到 build 当中。如果从 build 外部的模块导入任何内容,则会获得 Any 类型的值——而这些值根本就不会被纳入检查范围。这导致类型分析精度大打折扣,并在迁移早期给我们带来了不少麻烦。虽然现在已经解决了,而且也算是一种典型做法,但在最糟糕的情况下,如果两个孤立的类型检查机制被合并起来,而这两种机制之间又互不兼容,那么我们就必须对注释进行大量更改!回想起来,我们应该尽早将基础库模块添加到 mypy build 中。
注释遗留代码。 在刚刚开始时,我们面对着超过 400 万行的现有 Python 代码。很明显,对如此规模的代码进行注释是项浩大的工程。我们编写了一款名为 PyAnnotate 的工具,它能够在运行测试的同时收集类型,并根据类型结果插入类型注释——但最终这款工具并没能得到广泛采用。理由很简单:收集类型的速度很慢,而生成的类型通常也需要大量人为调整。我们也考虑过在每一次 build 测试时对一小部分实时网络请求自动运行这款工具,但考虑到这两种方式都可能带来较大风险,最终只能作罢。
大多数代码都是由代码所有者手动注释。 我们提供关于高价值模块与函数的报告,以帮助简化注释流程。那些在数百个位置使用的库模块,自然是注释工作中的优先考量对象;正在被替换的遗留服务同样值得关注。此外,我们还尝试利用静态分析为遗留代码生成静态注释。
导入周期。 导入周期(也就是「tangle」或者说纠结周期)的存在令 mypy 提速变得非常困难。我们还需要努力让 mypy 支持来自导入周期的各种习惯。我们最近刚刚完成了一个重大项目的重新设计,最终解决了大多数导入周期问题。这些解决方案实际上源自项目早期研究中使用的 Alore 语言。Alore 的语法使得导入周期的处理变得更轻松。当然,我们也在这种简单的实现中继承了某些限制因素(对 Alore 来说倒不是什么问题)。Python 之所以很难搞定导入周期,是因为其语句当中可能指代多种事物。例如,赋值可能实际上定义了一个类型别名,而且 mypy 在大部分导入周期处理完成之后一直无法检测到该类型。Alore 就不存在这种模糊性。总之,有些早期设计中不经意做出的决定,很可能成为多年之后的痛苦根源!
从早期原型设计到如今对 400 万行代码进行类型检查,这是一段漫长的旅程。在过程当中,我们对 Python 的类型提示进行了标准化,建立起围绕 Python 类型检查发展出的新兴生态系统、为 IDE 与编辑器开发出类型提示支持机制,在多种类型检查器之间进行功能权衡并实现了库支持能力。