四年完成400萬行Python代碼檢查,甚至順手寫了個編譯器
作為 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、抑或是自定義類型?
- 這個參數需要的是一份清單、一個元組還是一個組?
只要有了類型注釋,開發者能夠很輕松地回答這些與代碼片段相關的問題,例如:
- class Resource:
- id: bytes
- ...
- def read_metadata(self,
- items: Sequence[str]) -> Dict[str, MetadataItem]:
- ...
- 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 守護進程
增量構建雖然提升了 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 代碼(有時候確實沒什么好辦法,只能硬著頭皮重寫)。后者通常要比在編譯器中自動轉換容易得多,不過從長遠來看,我們更傾向于實現自動化轉換。但還是要具體問題具體分析,有時候為了以最低的投入獲得更大的性能提升,我們也會抄近路。
實操:檢查 400 萬行代碼
在完成上述工作后,還面臨一個重要挑戰(也是 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/