作者:CharlieMarsh 译者:豌豆花下猫Python猫 英文:UsingMypyinproductionatSpring(https:notes。crmarsh。comusingmypyinproductionatspring) 在Spring,我们维护了一个大型的Python单体代码库(英:monorepo),用上了Mypy最严格的配置项,实现了Mypy全覆盖。简而言之,这意味着每个函数签名都是带注解的,并且不允许有隐式的Any转换。 (译注:此处的Spring并不是Java中那个著名的Spring框架,而是一家生物科技公司,专注于找到与年龄相关的疾病的疗法,2022年3月曾获得比尔梅琳达盖茨基金会120万美元的资助。) 诚然,代码行数是一个糟糕的衡量标准,但可作一个粗略的估计:我们的代码仓有超过30万行Python代码,其中大约一半构成了核心的数据平台,另一半是由数据科学家和机器学习研究员编写的终端用户代码。 我有个大胆的猜测,就这个规模而言,这是最全面的加了类型的Python代码仓之一。 我们在2019年7月首次引入了Mypy,大约一年后实现了全面的类型覆盖,从此成为了快乐的Mypy用户。 几周前,我跟LeoBoytsov和ErikBernhardsson在Twitter上对Python类型有一次简短的讨论然后我看到WillMcGugan也对类型大加赞赏。由于Mypy是我们在Spring公司发布和迭代Python代码的关键部分,我想写一下我们在过去几年中大规模使用它的经验。 一句话总结:虽然采用Mypy是有代价的(前期和持续的投入、学习曲线等),但我发现它对于维护大型Python代码库有着不可估量的价值。Mymy可能不适合于所有人,但它十分适合我。Mypy是什么? (如果你很熟悉Mypy,可跳过本节。) Mypy是Python的一个静态类型检查工具。如果你写过Python3,你可能会注意到Python支持类型注解,像这样:defgreeting(name:str)str:returnHelloname Python在2014年通过PEP484定义了这种类型注解语法。虽然这些注解是语言的一部分,但Python(以及相关的第一方工具)实际上并不拿它们来强制做到类型安全。 相反,类型检查通过第三方工具来实现。Mypy就是这样的工具。Facebook的Pyre也是这样的工具但就我所知,Mypy更受欢迎(Mypy在GitHub上有两倍多的星星,它是Pants默认使用的工具)。IntelliJ也有自己的类型检查工具,支持在PyCharm中实现类型推断。这些工具都声称自己兼容PEP484,因为它们使用Python本身定义的类型注解。 (译注:最著名的类型检查工具还有谷歌的pytype和微软的pyright,关于基本情况介绍与对比,可查阅这篇文章《介绍几款Python类型检查工具》) 换句话说:Python认为自己的责任是定义类型注解的语法和语义(尽管PEP484本身很大程度上受到了Mypy现有版本的启发),但有意让第三方工具来检查这些语义。 请注意,当你使用像Mypy这样的工具时,你是在Python本身之外运行它的比如,当你运行mypypathtofile。py后,Mypy会把推断出的违规代码都吐出来。Python在运行时显露但不利用那些类型注解。 (顺便一提:在写本文时,我了解到相比于Pypy这样的项目,Mypy最初有着非常不同的目标。那时还没有PEP484(它的灵感来自Mypy!),所以Mypy定义了自己的语法,与Python不同,并实现了自己的运行时(也就是说,Mypy代码是通过Mypy执行的)。当时,Mypy的目标之一是利用静态类型、不可变性等来提高性能而且明确地避开了与CPython兼容。Mypy在2013年切换到兼容Python的语法,而PEP484在2015年才推出。(使用静态类型加速Python的概念催生了Mypyc,它仍然是一个活跃的项目,可用于编译Mypy本身。))在Spring集成Mypy 我们在2019年7月将Mypy引入代码库(1724)。当首次发起提议时,我们有两个主要的考虑:虽然Mypy在2012年的PyCon芬兰大会上首次亮相,并在2015年初发布了兼容PEP484的版本,但它仍然是一个相当新的工具至少对我们来说是这样。尽管我们在一些相当大的Python代码库上工作过(在可汗学院和其它地方),但团队中没有人使用过它。像其它增量类型检查工具一样(例如Flow),随着代码库的注解越来越多,Mypy的价值会与时俱增。由于Mypy可以并且将会用最少的注解捕获bug,所以你在代码库上投入注解的时间越多,它就会变得越有价值。 尽管有所犹豫,我们还是决定给Mypy一个机会。在公司内部,我们有强烈偏好于静态类型的工程师文化(除了Python,我们写了很多Rust和TypeScript)。所以,我们准备使用Mypy。 我们首先类型化了一些文件。一年后,我们完成了全部代码的类型化(2622),并升级到最严格的Mypy设置(最关键的是disallowuntypeddefs,它要求对所有函数签名进行注解),从那时起,我们一直维护着这些设置。(Wolt团队有一篇很好的文章,他们称之为专业级的Mypy配置,巧合的是,我们使用的正是这种配置。) Mypy配置:https:blog。wolt。comengineering20210930professionalgrademypyconfiguration反馈 总体而言:我对Mypy持积极的看法。作为核心基础设施的开发人员(跨服务和跨团队使用的公共库),我认为它极其有用。 我将在以后的任何Python项目中继续使用它。好处 Zulip早在2016年写了一篇漂亮的文章,内容关于使用Mypy的好处(这篇文章也被收入了Mypy官方文档中)。 Zulip博文:https:blog。zulip。com20161013statictypesinpythonohmypybenefitsofusingmypy 我不想重述静态类型的所有好处(它很好),但我想简要地强调他们在帖子中提到的几个好处:改善可读性:有了类型注解,代码趋向于自描述(与文档字符串不同,这种描述的准确性可以静态地强制执行)。(英:selfdocumenting)捕获错误:是真的!Mypy确实能找出bug。从始至终。自信地重构:这是Mypy最有影响力的一个好处。有了Mypy的广泛覆盖,我可以自信地发布涉及数百甚至数千个文件的更改。当然,这与上一条好处有关我们用Mypy找出的大多数bug都是在重构时发现的。 第三点的价值怎么强调都不为过。毫不夸张地说,在Mypy的帮助下,我发布更改的速度快了十倍,甚至快了一百倍。 虽然这是完全主观的,但在写这篇文章时,我意识到:我信任Mypy。虽然程度还不及,比如说OCaml编译器,但它完全改变了我维护Python代码的关系,我无法想象回到没有注解的世界。痛点 Zulip的帖子同样强调了他们在迁移Mypy时所经历的痛点(与静态代码分析工具的交互,循环导入)。 坦率地说,我在Mypy上经历的痛点与Zulip文章中提到的不一样。我把它们分成三类:外部库缺乏类型注解Mypy学习曲线对抗类型系统 让我们来逐一回顾一下:1。外部库缺乏类型注解 最重要的痛点是,我们引入的大多数第三方Python库要么是无类型的,要么不兼容PEP561。在实践中,这意味着对这些外部库的引用会被解析为不兼容,这会大大削弱类型的覆盖率。 每当在环境里添加一个第三方库时,我们都会在mypy。ini里添加一个许可条目,它告诉Mypy要忽略那些模块的类型注解(有类型或提供类型存根的库,比较罕见):〔mypyaltair。〕ignoremissingimportsTrue〔mypyapachebeam。〕ignoremissingimportsTrue〔mypybokeh。〕ignoremissingimportsTrue。。。 由于有了这样的安全出口,即使是随便写的注解也不会生效。例如,Mypy允许这样做:importpandasaspddefreturndataframe()pd。DataFrame:Mypyinterpretspd。DataFrameasAny,soreturningastrisfine!returnHello,world! 除了第三方库,我们在Python标准库上也遇到了一些不顺。例如,functools。lrucache尽管在typeshed里有类型注解,但由于复杂的原因,它不保留底层函数的签名,所以任何用functools。lrucache装饰的函数都会被移除所有类型注解。 例如,Mypy允许这样做:importfunctoolsfunctools。lrucachedefaddone(x:float)float:returnx1addone(Hello,world!) 第三方库的情况正在改善。例如,NumPy在1。20版本中开始提供类型。Pandas也有一系列公开的类型存根,但它们被标记为不完整的。(添加存根到这些库是非常重要的,这是一个巨大的成就!)另外值得一提的是,我最近在Twitter上看到了Wolt的Python项目模板,它也默认包括类型。 所以,类型正在变得不再罕见。过去当我们添加一个有类型注解的依赖时,我会感到惊讶。有类型注解的库还是少数,并未成为主流。2。Mypy学习曲线 大多数加入Spring的人没有使用过Mypy(写过Python),尽管他们基本知道并熟悉Python的类型注解语法。 同样地,在面试中,候选人往往不熟悉typing模块。我通常在跟候选人作广泛的技术讨论时,会展示一个使用了typing。Protocol的代码片段,我不记得有任何候选人看到过这个特定的构造当然,这完全没问题!但这体现了typing在Python生态的流行程度。 所以,当我们招募团队成员时,Mypy往往是他们必须学习的新东西。虽然类型注解语法的基础很简单,但我们经常听到这样的问题:为什么Mypy会这样?、为什么Mypy在这里报错?等等。 例如,这是一个通常需要解释的例子:ifcondition:value:strHello,worldelse:Notokwedeclaredvalueasstr,andthisisNone!valueNone。。。ifcondition:value:strHello,worldelse:Notokwealreadydeclaredthetypeofvalue。value:Optional〔str〕None。。。Thisisok!ifcondition:value:Optional〔str〕Hello,worldelse:valueNone 另外,还有一个容易混淆的例子:fromtypingimportLiteraldefmyfunc(value:Literal〔a,b〕)None:。。。forvaluein(a,b):Notokvalueisstr,notLiteral〔a,b〕。myfunc(value) 当解释之后,这些例子的原因是有道理的,但我不可否认的是,团队成员需要耗费时间去熟悉Mypy。有趣的是,我们团队中有人说PyCharm的类型辅助感觉还不如在同一个IDE中使用TypeScript得到的有用和完整(即使有足够的静态类型)。不幸的是,这只是使用Mypy的代价。 除了学习曲线之外,还有持续地注解函数和变量的开销。我曾建议对某些种类的代码(如探索性数据分析)放宽我们的Mypy规则然而,团队的感觉是注解是值得的,这件事很酷。3。对抗类型系统 在编写代码时,我会尽量避免几件事,以免导致自己与类型系统作斗争:写出我知道可行的代码,并强迫Mypy接受。 首先是overload,来自typing模块:非常强大,但很难正确使用。当然,如果需要重载一个方法,我就会使用它但是,就像我说的,如果可以的话,我宁可避免它。 基本原理很简单:overloaddefclean(s:str)str:。。。overloaddefclean(s:None)None:。。。defclean(s:Optional〔str〕)Optional〔str〕:ifs:returns。strip()。replace(,)else:returnNone 但通常,我们想要做一些事情,比如基于布尔值返回不同的类型,带有默认值,这需要这样的技巧:overloaddeflookup(paths:Iterable〔str〕,,strict:Literal〔False〕)Mapping〔str,Optional〔str〕〕:。。。overloaddeflookup(paths:Iterable〔str〕,,strict:Literal〔True〕)Mapping〔str,str〕:。。。overloaddeflookup(paths:Iterable〔str〕)Mapping〔str,Optional〔str〕〕:。。。deflookup(paths:Iterable〔str〕,,strict:Literal〔True,False〕False)Any:pass 即使这是一个hack你不能传一个bool到findmanylatest,你必须传一个字面量True或False。 同样地,我也遇到过其它问题,使用typing。overload或者overload、在类方法中使用overload,等等。 其次是TypedDict,同样来自typing模块:可能很有用,但往往会产生笨拙的代码。 例如,你不能解构一个TypedDict它必须用字面量key构造所以下方第二种写法是行不通的:fromtypingimportTypedDictclassPoint(TypedDict):x:floaty:floata:Point{x:1,y:2}error:ExpectedTypedDictkeytobestringliteralb:Point{a,y:3} 在实践中,很难用TypedDict对象做一些Pythonic的事情。我最终倾向于使用dataclass或typing。NamedTuple对象。 第三是装饰器。Mypy的文档对保留签名的装饰器和装饰器工厂有一个规范的建议。它很先进,但确实有效:FTypeVar(F,boundCallable〔。。。,Any〕)defdecorator(func:F)F:defwrapper(args:Any,kwargs:Any):returnfunc(args,kwargs)returncast(F,wrapper)decoratordeff(a:int)str:returnstr(a) 但是,我发现使用装饰器做任何花哨的事情(特别是不保留签名的情况),都会导致代码难以类型化或者充斥着强制类型转换。 这可能是一件好事!Mypy确实改变了我编写Python的方式:耍小聪明的代码更难被正确地类型化,因此我尽量避免编写讨巧的代码。 (装饰器的另一个问题是我前面提过的functools。lrucache:由于装饰器最终定义了一个全新的函数,所以如果你不正确地注解代码,就可能会出现严重而令人惊讶的错误。) 我对循环导入也有类似的感觉由于要导入类型作为注解使用,这就可能导致出现本可避免的循环导入(这也是Zulip团队强调的一个痛点)。虽然循环导入是Mypy的一个痛点,但这通常意味着系统或代码本身存在着设计缺陷,这是Mypy强迫我们去考虑的问题。 不过,根据我的经验,即使是经验丰富的Mypy用户,在类型检查通过之前,他们也需对本来可以正常工作的代码进行一两处更正。 (顺便说一下:Python3。10使用ParamSpec对装饰器的情况作了重大的改进。)提示与技巧 最后,我要介绍几个在使用Mypy时很有用的技巧。1。revealtype 在代码中添加revealtype,可以让Mypy在对文件进行类型检查时,显示出变量的推断类型。这是非常非常非常有用的。 最简单的例子是:Noneedtoimportanything。Justcallrevealtype。Youreditorwillflagitasanundefinedreferencejustignorethat。x1revealtype(x)Revealedtypeisbuiltins。int 当你处理泛型时,revealtype特别地有用,因为它可以帮助你理解泛型是如何被填充的、类型是否被缩小了,等等。2。Mypy作为一个库 Mypy可以用作一个运行时库! 我们内部有一个工作流编排库,看起来有点像Flyte或Prefect。细节并不重要,但值得注意的是,它是完全类型化的因此我们可以静态地提升待运行任务的类型安全性,因为它们被链接在一起。 把类型弄准确是非常具有挑战性的。为了确保它完好,不被意外的Any毒害,我们在一组文件上写了调用Mypy的单元测试,并断言Mypy抛出的错误能匹配一系列预期内的异常:deftestcheckfunction(self)None:resultapi。run(〔os。path。join(os。path。dirname(file),typecheckexamplesfunction。py,),noincremental,〕,)actualresult〔0〕。splitlines()expected〔fmt:offtypecheckexamplesfunction。py:14:error:Incompatiblereturnvaluetype(gotstr,expectedint),noqa:E501typecheckexamplesfunction。py:19:error:MissingpositionalargumentxincalltocallofFunctionPipeline,noqa:E501typecheckexamplesfunction。py:22:error:ArgumentxtocallofFunctionPexpectedint,noqa:E501typecheckexamplesfunction。py:25:note:Revealedtypeisbuiltins。int,noqa:E501typecheckexamplesfunction。py:28:note:Revealedtypeisbuiltins。int,noqa:E501typecheckexamplesfunction。py:34:error:UnexpectedkeywordargumentnotifyonforoptionsofExpression,noqa:E501pipeline。py:307:note:optionsofExpressiondefinedhere,noqa:E501Found4errorsin1file(checked1sourcefile),fmt:on〕self。assertEqual(actual,expected)3。GitHub上的问题 当搜索如何解决某个类型问题时,我经常会找到Mypy的GitHubIssues(比StackOverflow还多)。它可能是Mypy类型相关问题的解决方案和HowTo的最佳知识源头。你会发现其核心团队(包括Guido)对重要问题的提示和建议。 主要的缺点是,GitHubIssue中的每个评论仅仅是某个特定时刻的评论2018年的一个问题可能已经解决了,去年的一个变通方案可能有了新的最佳实践。所以在查阅issue时,一定要把这一点牢记于心。4。typingextensions typing模块在每个Python版本中都有很多改进,同时,还有一些特性会通过typingextensions模块向后移植。 例如,虽然只使用Python3。8,但我们借助typingextensions,在前面提到的工作流编排库中使用了3。10版本的ParamSpec。(遗憾的是,PyCharm似乎不支持通过typingextensions引入的ParamSpec语法,并将其标记为一个错误,但是,还算好吧。)当然,Python本身语法变化而出现的特性,不能通过typingextensions获得。5。NewType 在typing模块中有很多有用的辅助对象,NewType是我的最爱之一。 NewType可让你创建出不同于现有类型的类型。例如,你可以使用NewType来定义合规的谷歌云存储URL,而不仅是str类型,比如:fromtypingimportNewTypeGCSUrlNewType(GCSUrl,str)defdownloadblob(url:GCSUrl)None:。。。IexpectedGCSUrldownloadblob(gs:mybucketfoobarbaz。jpg)Ok!downloadblob(GCSUrl(gs:mybucketfoobarbaz。jpg)) 通过向downloadblob的调用者指出它的意图,我们使这个函数具备了自描述能力。 我发现NewType对于将原始类型(如str和int)转换为语义上有意义的类型特别有用。6。性能 Mypy的性能并不是我们的主要问题。Mypy将类型检查结果保存到缓存中,能加快重复调用的速度(据其文档称:Mypy增量地执行类型检查,复用前一次运行的结果,以加快后续运行的速度)。 在我们最大的服务中运行mypy,冷缓存大约需要5060秒,热缓存大约需要12秒。 至少有两种方法可以加速Mypy,这两种方法都利用了以下的技术(我们内部没有使用):Mypy守护进程在后台持续运行Mypy,让它在内存中保持缓存状态。虽然Mypy在运行后将结果缓存到磁盘,但是守护进程确实是更快。(我们使用了一段时间的默认Mypy守护进程,但因共享状态导致一些问题后,我禁用了它我不记得具体细节了。)共享远程缓存。如前所述,Mypy在每次运行后都会将类型检查结果缓存到磁盘但是如果在新机器或新容器上运行Mypy(就像在CI上一样),则不会有缓存的好处。解决方案是在磁盘上预置一个最近的缓存结果(即,预热缓存)。Mypy文档概述了这个过程,但它相当复杂,具体内容取决于你自己的设置。我们最终可能会在自己的CI系统中启用它暂时还没有去做。结论 Mypy对我们产生了很大的影响,提升了我们发布代码时的信心。虽然采纳它需要付出一定的成本,但我们并不后悔。 除了工具本身的价值之外,Mypy还是一个让人印象非常深刻的项目,我非常感谢维护者们多年来为它付出的工作。在每一个Mypy和Python版本中,我们都看到了对typing模块、注解语法和Mypy本身的显著改进。(例如:新的联合类型语法(XY)、ParamSpec和TypeAlias,这些都包含在Python3。10中。) 原文发布于2022年8月21日。作者:CharlieMarsh 译者:豌豆花下猫Python猫 英文:UsingMypyinproductionatSpring(https:notes。crmarsh。comusingmypyinproductionatspring) 请关注我,收获更多优质的Python文章和资讯!