事实上,Python 已经成为 Dropbox 公司使用范围最广的语言,其广泛适用于后端服务与桌面客户端应用程序等(当然,Dropbox 公司也在大量使用 Go、TypeScript 以及 Rust 等语言)。
在 Dropbox 公司数以百万计的 Python 代码行中,动态类型的存在让代码越来越难以理解,并严重影响生产力水平。为了缓解这一问题,Dropbox 公司一直在利用 mypy 逐步将代码转换为静态类型(顺带一提,mypy 可能是目前 Python 当中最流行的独立类型检查器,属于开源项目,其核心开发团队来自于 Dropbox。)。
截至目前,Dropbox 已经在成千上万个项目当中使用 mypy,而且效果都得到了很好地验证。但对于此次全方位检查 Python 代码,Dropbox 仍然抱着忐忑的心情,整个过程也充满了错误与失败。在今天的文章中,Dropbox 将向大家分享 Python 静态检查之旅——从最早的学术研究项目,到现在逐步让类型检查与类型提示成为 Python 社区中众多开发人员的常规操作。现在,已经有多种工具支持类型检查功能,包括各类 IDE 与代码分析器等。
如果开发者只使用过动态类型的 Python,当然有可能对静态类型以及 mypy 感到陌生。甚至,不少开发者就是因为动态类型而喜欢上 Python,但这事儿在逻辑上就有点莫名其妙。其关键应该在于,静态类型检查是实现规模化的前提:项目越大,需要的静态类型就越多。
一旦项目中包含成千上万行代码,而且有多位工程师在同时使用,以往开发经验告诉我们,理解代码内容就成了保障开发人员工作效率的关键所在。如果没有类型注释,基本的代码作用推理(例如找到函数的有效参数,或者可能的返回值类型)就会成为一大难题。以下是几个在缺少类型注释时,开发人员难以回答的典型问题:
-
这个函数能返回 None 吗?
-
这里的 items 参数是干什么用的?
-
id 属性是什么类型:到底是 int、str、抑或是自定义类型?
-
这个参数需要的是一份清单、一个元组还是一个组?
只要有了类型注释,开发者能够很轻松地回答这些与代码片段相关的问题,例如:
-
read_metadata 并不会返回 None,因为返回类型不是 Optional[…]
-
items 参数代表一系列字符串,其不可能随意迭代。
-
id 属性为字节字符串。
在理想情况下,我们当然希望把这一切都记录在文档中,但拥有从业经验的开发者肯定知道没这么好的事儿。即使存在此类文档,我们也无法完全信任其中的内容——例如内容含糊不清或者不够准确,因此带来巨大的误解空间。对于大型团队或代码库,这类问题可能产生巨大的影响:
虽然 Python 在项目早期与中期阶段表现良好,但当项目发展到特定阶段后,成功的项目与使用 Python 语言的企业可能面临一个关键性决定:我们是否需要利用静态类型语言重写所有内容?
类似 mypy 这样的类型检查器主要负责提供用于类型描述的形式语言,并通过验证所获得的类型与实现(以及可能存在的可选项)间的匹配解决这一难题。更具体地讲,类型检查器专门提供经过验证的文档。
当然,除此之外,类型检查器还可带来其它助益:
-
类型检查器能够发现许多微妙(以及不那么微妙)的 bug。其中的典型例子,就是开发者忘记处理的 None 值或者其它一些特殊条件。
-
重构更简单,因为类型检查器通常能够准确告诉我们需要变更的代码。我们不需要进行 100% 全覆盖测试,这本身也不具备可行性。另外,我们也不需要跟踪深层堆栈以了解到底出了什么问题。
-
即使是在大型项目中 ,mypy 也能够在几分之一秒内完成完整的类型检查。运行测试通常需要几十秒或者几分钟。类型检查带来的快速反馈,能够帮助开发者更快实现迭代。这意味着不需要编写脆弱且难以维护的单元测试,用以模拟及修复现有代码以获取快速反馈。
-
以 PyCharm 以及 Visual Studio Code 为代表的 IDE 和编辑器可利用类型注释实现代码补全、高亮显示错误并支持更好的定义功能——这里仅列出几项典型的功能性应用。对于一部分程序员而言,这些功能直接决定着他们的生产效率。这类用例不需要独立的类型检查工具。当然,像 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 的速度,但仍然没有达到顶峰。大量增量运行可能需要一分钟的处理时长。对于任何面对大型 Python 代码库的用户来讲,其中的原因相信并不难理解:循环导入。
我们拥有数百个模块,模块相互间接导入。如果导入周期的任何文件发生变更,那么 mypy 就必须处理周期中的所有文件,同时还得处理在此周期内导入该模块的所有其它模块。其中最臭名昭著的循环就是“纠结(tangle)”,它给 Dropbox 带来了很大麻烦。其中一度包含有数百个模块,众多测试级乃至产品级功能都要或直接或间接地将其导入。
我们一直在考虑打理这种纠结无比的依赖关系,但却始终没有合适的方法着手进行。毕竟我们不熟悉的代码太多了。因此,我们想出了另一个办法——即使存在这种“纠结”,我们同样可以提升 mypy 速度。答案就是,使用 mypy 守护进程。守护进程是一项服务器进程,负责执行两项非常重要的工作。
首先,它将关于整体代码库的信息保存在内存中,这样每次 mypy 运行就不再需要加载数千条与所导入依赖项相对应的缓存数据。其次,它会跟踪函数与其构造之间的细粒度依赖关系。例如,如果函数 foo 调用函数 bar,那么就存在一项从 bar 到 foo 的依赖关系。当文件发生变更时,守护程序会首先单独处理已经变更的文件;接下来,它会查找该文件中包含的外部可见变更,例如变更的函数签名。守护程序所采用的细粒度依赖项管理机制,能够确保只重新检查实际变更的那些函数——换言之,只检查极少数函数。
实现上述目标当然是个巨大的挑战,因为我们最初的 mypy 实现方案只适合一次处理一个文件。但在实际需求发生变化之后——例如当某个类获得一个新的基类时,我们必须重新处理大量边缘情况。经过艰苦卓绝的努力与投入,我们成功将大部分增量运行缩短至几秒钟。这是一场伟大的胜利,至少在我们当事人看来相当伟大!
配合之前提到的远程缓存,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 在提升注释覆盖率时,设定的核心工作要点:
-
严格性。逐渐增加了对新代码的严格要求。我们先从较为简单的角度入手,要求为原有文件补充注释。现在,我们则要求在继续补充注释的同时,在新的 Python 文件中使用类型注释。
-
覆盖率报告。我们每周都会向各团队发送电子邮件报告,旨在统计他们的注释覆盖率,并提供关于最有必要注释的内容的相关建议。
-
外展。我们与各团队就 mypy 进行交流,以帮助他们快速上手这款新工具。
-
调查。我们定期进行用户调查以找到最重要的痛点,并竭尽全力解决这些问题(甚至可以发明一种新的语言来加快 mypy 的速度!)。
-
性能。我们通过 mypy 守护程序与 mypyc 改进了 mypy 性能(p75 获得高达 44 倍的性能提升),从而减少注释流程中的阻碍,并允许用户根据需要扩展类型检查代码库的规模。
-
编辑器集成。我们为 Dropbox 内部流行的各款编辑器提供了 mypy 运行集成,具体包括 PyCharm、Vim 以及 VS Code 等。这使得注释迭代变得更轻松,也提升了大家为遗留代码做注释的热情。
-
静态分析。我们编写了一款利用静态分析来推断函数签名的工具。虽然目前它只能处理非常简单的场景,但仍然帮助我们快速提升了注释覆盖范围。
-
第三方库支持。我们的不少代码都用到了 SQLAlchemy,它使用的很多动态 Python 函数无法由 PEP 484 类型进行直接建模。为此,我们制作了一个 PEP 561 stub 文件包及一款开源 mypy 插件以提供支持。
检查 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 与编辑器开发出类型提示支持机制,在多种类型检查器之间进行功能权衡并实现了库支持能力。
虽然在 Dropbox 公司内部,类型检查已经被视为一项必要工作,但我相信就整个社区而言,对 Python 代码进行类型检查仍是种新生事物。当然,我也坚信这种好习惯将不断推广并给更多人带来助益。如果大家还没有在自己的大型 Python 项目中使用类型检查,那么现在就是最好的时机——根据我的交流整理,所有尝试类型检查的开发者都后悔没有早点参与。总而言之,类型检查正帮助 Python 成长为一款更适合大型项目的出色语言。
原文链接:
https://blogs.dropbox.com/tech/2019/09/our-journey-to-type-checking-4-million-lines-of-python/
往期酷玩:
太好玩了!用Python写个弹球游戏2.0
太好玩了,我用Python写了个火影忍者版的连连看
本篇文章来源于: 菜鸟学Python
本文为原创文章,版权归知行编程网所有,欢迎分享本文,转载请保留出处!
内容反馈