当前位置: 首页 > 科技观察

四年完成400万行Python代码检查,甚至写了一个编译器

时间:2023-03-13 20:47:23 科技观察

作为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,但这在逻辑上有点莫名其妙。关键应该是静态类型检查是实现规模化的先决条件:项目越大,需要的静态类型就越多。一旦一个项目包含了数万行代码,并且多个工程师同时在使用,以往的开发经验告诉我们,理解代码内容就成为保证开发人员生产力的关键。如果没有类型注释,关于代码功能的基本推理(例如查找函数的有效参数或可能的返回值类型)将成为一个挑战。以下是开发人员在没有类型注释的情况下难以回答的几个典型问题:这个函数可以返回None吗?这里的items参数是做什么用的?id属性是什么类型:是int、str还是自定义类型?这个参数需要一个列表、一个元组还是一个组?只要有类型注解,开发者就可以轻松回答这些与代码片段相关的问题,例如:classResource:id:bytes...defread_metadata(self,items:Sequence[str])->Dict[str,MetadataItem]:...read_metadata不返回None,因为返回类型不是Optional[...]items参数表示一系列字符串,不能随意迭代。id属性是一个字节串。理想情况下,我们当然希望所有这些都记录在案,但有行业经验的开发人员必须知道这不是什么好事。即使存在这样的文件,我们也不能完全相信它们的内容——例如,它们含糊不清或不准确,从而留下巨大的误解空间。对于大型团队或代码库,此类问题可能会产生巨大影响:虽然Python在项目的早期和中期表现良好,但在项目发展到一定阶段后,使用Python语言的成功项目和企业可能会面临一个关键的决定:我们是否需要用静态类型语言重写所有内容?像mypy这样的类型检查器主要负责为类型描述提供形式化语言,并通过验证获取的类型是否与实现(以及可能的可选选项)相匹配来解决这个问题。更具体地说,类型检查器专门提供经过验证的文档。当然,类型检查器还可以带来其他好处:类型检查器可以发现许多细微(和不那么细微)的错误。一个典型的例子是None值或开发人员忘记处理的其他一些特殊条件。重构更容易,因为类型检查器通常可以准确地告诉我们需要更改哪些代码。我们不需要100%的全覆盖测试,它本身也不可行。此外,我们不需要跟踪深层堆栈来了解出了什么问题。即使在大型项目中,mypy也能够在几分之一秒内完成完整的类型检查。运行测试通常需要几十秒或几分钟。类型检查带来的快速反馈可以帮助开发者更快地迭代。这意味着无需编写脆弱且难以维护的单元测试来模拟和修复现有代码以获得快速反馈。PyCharm和VisualStudioCode等IDE和编辑器可以使用类型注解来实现代码补全、错误高亮和支持更好的定义函数——这里仅列举几个典型的函数式应用。对于一些程序员来说,这些功能直接决定了他们的生产力。这样的用例不需要单独的类型检查工具。当然,像mypy这样的独立工具仍然有助于保持注释与代码同步。启动迁移:性能瓶颈在Dropbox,我们组建了一个三人团队,并于2015年底开始研究mypy。成员包括Guido、GregPrice和DavidFisher。从那时起,工作进展迅速。首先,采用mypy的最大障碍是性能。我们一直在CPython解释器上运行它,它对于像mypy这样的东西来说还不够快。(PyPy是一种包含JIT编译器的Python替代方案,在这里也无济于事。)幸运的是,我们已经实现了一系列算法改进。我们采取的第一个加速是增量检查。其背后的想法很简单:如果模块的所有依赖都与mypy运行前的状态无法区分,那么我们可以使用上一次运行的缓存数据来获取依赖,这意味着我们只需要进行类型检查修改后的文件及其依赖项。mypy更进一步:如果模块的外部接口没有改变,mypy甚至不需要重新检查导入它的其他模块。增量检查在对现有代码进行批注时非常有用,这通常涉及大量迭代运行mypy以处理连续插入和逐渐细化的类型。由于必须处理大量依赖项,原始mypy仍然很慢。为此,我们实现了一个远程缓存。如果mypy检测到本地缓存可能已过期,mypy将从中央存储库下载整个代码库的最新缓存快照。之后,它会根据下载的缓存执行增量构建。这进一步提高了性能。到2016年底,Dropbox有大约420,000行Python类型注释。许多用户热衷于类型检查,mypy的使用在Dropbox团队中迅速传播开来。事情看起来很不错,但距离真正的成功还有很长的路要走。我们开始定期进行内部用户调查,以确定痛点并确定工作重点(这种习惯一直延续到今天)。其中,两个请求始终排名最高:更大的类型检查覆盖率和更快的mypy运行时。显然,我们的性能和采用改进尚未全部完成。为此,我们必须在这两个任务上多下功夫。性能提升方法一:使用mypy守护进程增量构建虽然mypy的速度有所提升,但仍未达到峰值。处理大量增量运行可能需要长达一分钟的时间。对于处理大型Python代码库的任何人来说,其原因应该很容易理解:循环导入。我们有数百个间接相互导入的模块。如果导入循环的任何文件发生变化,mypy必须处理循环中的所有文件,以及循环期间导入该模块的所有其他模块。最臭名昭著的循环之一是“缠结”,它给Dropbox带来了很多麻烦。一时间有上百个模块,直接或间接引入了很多测试级甚至生产级的功能。我们一直在考虑如何处理这个纠结的依赖关系,但一直没有合适的方法去做。毕竟我们不熟悉的代码太多了。所以,我们想出了另一种方法——即使有这种“纠结”,我们仍然可以加快mypy的速度。答案是,使用mypy守护进程。守护进程是一个服务器进程,它完成两项非常重要的工作。首先,它将有关整个代码库的信息保存在内存中,因此每次运行mypy不再需要加载与导入的依赖项对应的数千个缓存条目。其次,它跟踪函数及其构造之间的细粒度依赖关系。例如,如果函数foo调用函数bar,则存在从bar到foo的依赖关系。当一个文件改变时,守护进程首先单独处理改变的文件;接下来,它会查找该文件中包含的外部可见更改,例如更改的函数签名。守护进程采用的细粒度依赖管理机制确保只有那些实际发生变化的函数才会被重新检查——换句话说,只有极少数的函数。实现上述目标当然是一个巨大的挑战,因为我们最初的mypy实现一次只能处理一个文件。但是在实际需求发生变化之后——例如当某个类得到一个新的基类时,我们必须重新处理很多边缘情况。通过大量的努力和奉献,我们设法将大部分增量运行时间缩短到几秒钟。这是一个伟大的胜利,至少从我们客户的角度来看是相当伟大的!性能提升方法二:将Python编译成C有了前面提到的远程缓存,mypydaemon几乎完全解决了增量用例。工程师只需迭代更改少量文件。然而,最坏情况下的性能仍然远非最佳。完整的mypy构建最多可能需要15分钟,这当然不能令人满意。随着工程师继续编写新代码并向现有代码添加类型注释,情况每周都在恶化。我们的用户渴望更高的性能,我们当然不会让您失望。因此,我们决定继续mypy项目开始时的重要想法——将Python编译为C。不幸的是,Cython(现成的Python-to-C编译器)没有提供任何显着的加速,所以我们决定从头开始编写一个编译器。由于mypy代码库(用Python编写)是完全类型注释的,因此利用这些注释来加快速度是一个合乎逻辑的选择。我构建了一个快速的概念验证原型,在各种微基准测试中将性能提高了10倍以上。这个想法是将Python模块编译成CPythonC扩展模块,并将类型注释转换为运行时类型检查(类型注释通常在运行时被忽略,仅供类型检查器使用)。我们开始着手将mypy实现从Python迁移到真正的静态类型语言,这完全符合Python迁移理念。(这种跨语言迁移正在成为新常态,mypy最初由Alore编写,但后来转换为Java/Python自定义语法混合体。)定位CPython扩展API是保持项目整体可管理性的关键所在。我们不需要实现虚拟机或mypy所需的任何库。此外,我们仍然可以利用所有遗留的Python生态系统和工具(例如pytest),并在开发过程中继续使用解释型Python代码,从而实现极快的编辑-测试周期,而无需等待编译过程。这个我们命名为mypyc的编译器(因为它使用mypy作为前端来执行类型分析)非常成功。总的来说,我们在没有缓存的情况下实现了大约4倍的性能提升。mypyc项目的核心开发耗时约4个月,由包括MichaelSullivan、IvanLevkivskyi、HughHan和我在内的小团队推动。显然,这里的工作量远远小于用C++或Go完全重写mypy,相关影响也小得多。我们希望mypyc最终能够交付给其他Dropbox工程师,以编译和加速更多他们自己的代码。在实现如此巨大的性能提升的过程中,我们尝试了很多有趣的性能工程方法。编译器可以利用快速、低级的C构造来加速许多操作。例如,对编译函数的调用被翻译成C函数调用,这比调用解释函数快得多。此外,某些操作(例如字典查找)仍然会回退到常规CPythonCAPI调用,从而导致编译时调用稍微快一些。总而言之,我们摆脱了解释的性能开销,从而略微提高了操作的速度性能。我们还进行了一系列分析工作,以了解“缓慢操作”之间的一般共性。有了这些数据,我们尝试调整mypyc为这些操作生成更快的C代码,或者用更快的操作方法重写相关的Python代码(有时候真的没有什么好办法,只能硬着头皮重写)。后者通常比在编译器中进行自动转换要容易得多,但从长远来看,我们更愿意将其自动化。不过还是要具体问题具体分析。有时为了以最小的投入获得更大的性能提升,我们也会走捷径。动手实践:检查4M行代码完成上述工作后,一个重要的挑战(也是mypy用户调查中第二重要的要求)是提高类型检查的覆盖率。我们尝试了多种方法来实现这个目标:从有机增长,到以mypy团队为中心的手动调优,再到静态和动态自动类型推断等。最后,我们发现没有简单的实现策略,但我们结合了几种方法来显着增加可以在代码库中实现的快速注释工作量。结果,我们最大的Python库(后端代码)中的注释行数在大约三年内增长到近400万行,全部迁移到静态类型代码。mypy现在支持各种覆盖率报告,可帮助我们轻松跟踪进度。具体来说,我们可以报告类型歧义的各种来源——例如注释中使用的显式、未经检查的类型,或者导入的第三方库没有类型注释等。为了提高Dropbox中类型检查的准确性,我们还提供有针对性的改进了中央Python类型库中许多流行开源库的类型定义(即存根文件)。我们实现了(并在后续的PEP中进行了标准化)一个新的类型系统,旨在为某些惯用的Python模式提供更精确的类型。一个典型的例子是TypeDict,它提供了一个类似JSON字典的类型。字典包含一组固定的字符串键,每个键都有不同的值类型。未来我们会继续扩展这个类型系统,并考虑改进对Python数栈的支持。以下是Dropbox在提高注释覆盖率时设定的核心工作点:Strictness。逐渐增加了对新代码的严格要求。让我们从一个更简单的角度开始,要求对原始文档进行额外的注释。我们现在要求在新的Python文件中使用类型注释,同时继续添加注释。覆盖率报告。每周向团队发送电子邮件报告以统计他们的注释覆盖率,并就注释最多的内容提供建议。外展。我们正在与团队讨论mypy,以帮助他们快速掌握这个新工具。调查。我们定期进行用户调查以找到最重要的痛点,并尽最大努力解决它们(甚至可能发明一种新语言来加速mypy!)。表现。我们通过mypy守护进程和mypyc提高了mypy性能(p75最高可达44倍),减少了注释过程中的摩擦,并允许用户根据需要扩展类型检查代码库。编辑器集成。我们为Dropbox内的流行编辑器提供mypy运行时集成,包括PyCharm、Vim和VSCode。这使得迭代注解变得更容易,并增加了对遗留代码进行注解的热情。静态分析。我们编写了一个利用静态分析来推断函数签名的工具。虽然它目前只能处理非常简单的场景,但它仍然帮助我们快速提高注释覆盖率。第三方库支持。我们的大部分代码都使用了SQLAlchemy,它使用了许多动态Python函数,这些函数无法通过PEP484类型直接建模。为此,我们制作了一个PEP561存根包和一个开源的mypy插件来提供支持。经验教训审查400万行代码是一项不小的壮举,我们一路上遇到了挑战,当然也犯了错误。接下来总结一下经验教训,希望能给大家带来启发。文件丢失。开始时,我们的mypy版本只需要处理少数内部文件——或者更确切地说,除了build之外,从未接触过任何东西。添加第一条评论时,文件会隐式添加到构建中。如果您从build之外的模块导入任何内容,您将获得Any类型的值-并且根本不会检查这些值。这导致类型分析的准确性大大降低,并在迁移的早期给我们带来了很多麻烦。虽然这现在已经解决并且被认为是典型的做法,但在最坏的情况下,如果合并两个孤立的类型检查机制,并且这两个机制彼此不兼容,那么我们必须对注释进行大量更改!回想起来,我们应该早点将基础库模块添加到mypy构建中。注释遗留代码。刚开始时,我们面临着超过400万行的现有Python代码。显然,注释这种大小的代码是一项艰巨的任务。我们编写了一个名为PyAnnotate的工具,它能够在运行测试时收集类型并根据类型结果插入类型注释——但它最终没有被广泛采用。原因很简单:收集类型很慢,并且生成的类型通常需要大量的人工调整。我们也考虑过在每次构建测试时针对少量的实时网络请求自动运行该工具,但考虑到这两种方式都可能带来较大的风险,最终不得不放弃。大多数代码是由代码所有者手动注释的。我们提供有关高价值模块和功能的报告,以帮助简化注释过程。在数百个地方使用的库模块自然是注释工作的优先事项;正在被替换的遗留服务也值得关注。此外,我们还尝试利用静态分析为遗留代码生成静态注释。导入周期。导入循环(又名“缠结”或缠结循环)的存在使得加速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/