作者:Charlie Marsh
譯者:豌豆花下貓@Python貓
英文:Using Mypy in production at Spring (https://notes.crmarsh.com/using-mypy-in-production-at-spring)
在 Spring ,我們維護了一個大型的 Python 單體程式碼庫(英:monorepo),用上了 Mypy 最嚴格的設定項,實現了 Mypy 全覆蓋。簡而言之,這意味著每個函數簽名都是帶註解的,並且不允許有隱式的 Any
轉換。
(譯註:此處的 Spring 並不是 Java 中那個著名的 Spring 框架,而是一家生物科技公司,專注於找到與年齡相關的疾病的療法,2022 年 3 月曾獲得比爾&梅琳達·蓋茲基金會 120 萬美元的資助。)
誠然,程式碼行數是一個糟糕的衡量標準,但可作一個粗略的估計:我們的程式碼倉有超過 30 萬行 Python 程式碼,其中大約一半構成了核心的資料平臺,另一半是由資料科學家和機器學習研究員編寫的終端使用者程式碼。
我有個大膽的猜測,就這個規模而言,這是最全面的加了型別的 Python 程式碼倉之一。
我們在 2019 年 7 月首次引入了 Mypy,大約一年後實現了全面的型別覆蓋,從此成為了快樂的 Mypy 使用者。
幾周前,我跟 Leo Boytsov 和 Erik Bernhardsson 在 Twitter 上對 Python 型別有一次簡短的討論——然後我看到 Will McGugan 也對型別大加讚賞。由於 Mypy 是我們在 Spring 公司釋出和迭代 Python 程式碼的關鍵部分,我想寫一下我們在過去幾年中大規模使用它的經驗。
一句話總結:雖然採用 Mypy 是有代價的(前期和持續的投入、學習曲線等),但我發現它對於維護大型 Python 程式碼庫有著不可估量的價值。Mymy 可能不適合於所有人,但它十分適合我。
(如果你很熟悉 Mypy,可跳過本節。)
Mypy 是 Python 的一個靜態型別檢查工具。如果你寫過 Python 3,你可能會注意到 Python 支援型別註解,像這樣:
def greeting(name: str) -> str:
return 'Hello ' + name
Python 在 2014 年通過 PEP-484 定義了這種型別註解語法。雖然這些註解是語言的一部分,但 Python(以及相關的第一方工具)實際上並不拿它們來強制做到型別安全。
相反,型別檢查通過第三方工具來實現。Mypy 就是這樣的工具。Facebook 的 Pyre 也是這樣的工具——但就我所知,Mypy 更受歡迎(Mypy 在 GitHub 上有兩倍多的星星,它是 Pants 預設使用的工具)。IntelliJ 也有自己的型別檢查工具,支援在 PyCharm 中實現型別推斷。這些工具都聲稱自己「相容 PEP-484」,因為它們使用 Python 本身定義的型別註解。
(譯註:最著名的型別檢查工具還有谷歌的pytype
和微軟的pyright
,關於基本情況介紹與對比,可查閱這篇文章 )
換句話說:Python 認為自己的責任是定義型別註解的語法和語意(儘管 PEP-484 本身很大程度上受到了 Mypy 現有版本的啟發),但有意讓第三方工具來檢查這些語意。
請注意,當你使用像 Mypy 這樣的工具時,你是在 Python 本身之外執行它的——比如,當你執行mypy path/to/file.py
後,Mypy 會把推斷出的違規程式碼都吐出來。Python 在執行時顯露但不利用那些型別註解。
(順便一提:在寫本文時,我瞭解到相比於 Pypy 這樣的專案,Mypy 最初有著非常不同的目標。那時還沒有 PEP-484(它的靈感來自 Mypy!),所以 Mypy 定義了自己的語法,與 Python 不同,並實現了自己的執行時(也就是說,Mypy 程式碼是通過 Mypy 執行的)。當時,Mypy 的目標之一是利用靜態型別、不可變性等來提高效能——而且明確地避開了與 CPython 相容。Mypy 在 2013 年切換到相容 Python 的語法,而 PEP-484 在 2015 年才推出。(「使用靜態型別加速 Python」的概念催生了 Mypyc,它仍然是一個活躍的專案,可用於編譯 Mypy 本身。))
我們在 2019 年 7 月將 Mypy 引入程式碼庫(#1724)。當首次發起提議時,我們有兩個主要的考慮:
儘管有所猶豫,我們還是決定給 Mypy 一個機會。在公司內部,我們有強烈偏好於靜態型別的工程師文化(除了 Python,我們寫了很多 Rust 和 TypeScript)。所以,我們準備使用 Mypy。
我們首先型別化了一些檔案。一年後,我們完成了全部程式碼的型別化(#2622),並升級到最嚴格的 Mypy 設定(最關鍵的是 disallow_untyped_defs
,它要求對所有函數簽名進行註解),從那時起,我們一直維護著這些設定。(Wolt 團隊有一篇很好的文章,他們稱之為「專業級的 Mypy 設定」,巧合的是,我們使用的正是這種設定。)
Mypy 設定:https://blog.wolt.com/engineering/2021/09/30/professional-grade-mypy-configuration/
總體而言:我對 Mypy 持積極的看法。 作為核心基礎設施的開發人員(跨服務和跨團隊使用的公共庫),我認為它極其有用。
我將在以後的任何 Python 專案中繼續使用它。
Zulip 早在 2016 年寫了一篇漂亮的文章,內容關於使用 Mypy 的好處(這篇文章也被收入了 Mypy 官方檔案 中)。
Zulip 博文:https://blog.zulip.com/2016/10/13/static-types-in-python-oh-mypy/
我不想重述靜態型別的所有好處(它很好),但我想簡要地強調他們在貼文中提到的幾個好處:
第三點的價值怎麼強調都不為過。毫不誇張地說,在 Mypy 的幫助下,我釋出更改的速度快了十倍,甚至快了一百倍。
雖然這是完全主觀的,但在寫這篇文章時,我意識到:我信任 Mypy。雖然程度還不及,比如說 OCaml 編譯器,但它完全改變了我維護 Python 程式碼的關係,我無法想象回到沒有註解的世界。
Zulip 的貼文同樣強調了他們在遷移 Mypy 時所經歷的痛點(與靜態程式碼分析工具的互動,迴圈匯入)。
坦率地說,我在 Mypy 上經歷的痛點與 Zulip 文章中提到的不一樣。我把它們分成三類:
讓我們來逐一回顧一下:
最重要的痛點是,我們引入的大多數第三方 Python 庫要麼是無型別的,要麼不相容 PEP-561。在實踐中,這意味著對這些外部庫的參照會被解析為不相容,這會大大削弱型別的覆蓋率。
每當在環境裡新增一個第三方庫時,我們都會在mypy.ini
裡新增一個許可條目,它告訴 Mypy 要忽略那些模組的型別註解(有型別或提供型別存根的庫,比較罕見):
[mypy-altair.*]
ignore_missing_imports = True
[mypy-apache_beam.*]
ignore_missing_imports = True
[mypy-bokeh.*]
ignore_missing_imports = True
...
由於有了這樣的安全出口,即使是隨便寫的註解也不會生效。例如,Mypy 允許這樣做:
import pandas as pd
def return_data_frame() -> pd.DataFrame:
"""Mypy interprets pd.DataFrame as Any, so returning a str is fine!"""
return "Hello, world!"
除了第三方庫,我們在 Python 標準庫上也遇到了一些不順。例如,functools.lru_cache
儘管在 typeshed 裡有型別註解,但由於複雜的原因,它不保留底層函數的簽名,所以任何用 @functools.lru_cache
裝飾的函數都會被移除所有型別註解。
例如,Mypy 允許這樣做:
import functools
@functools.lru_cache
def add_one(x: float) -> float:
return x + 1
add_one("Hello, world!")
第三方庫的情況正在改善。例如,NumPy 在 1.20 版本中開始提供型別。Pandas 也有一系列公開的型別存根 ,但它們被標記為不完整的。(新增存根到這些庫是非常重要的,這是一個巨大的成就!)另外值得一提的是,我最近在 Twitter 上看到了 Wolt 的 Python 專案模板 ,它也預設包括型別。
所以,型別正在變得不再罕見。過去當我們新增一個有型別註解的依賴時,我會感到驚訝。有型別註解的庫還是少數,並未成為主流。
大多數加入 Spring 的人沒有使用過 Mypy(寫過 Python),儘管他們基本知道並熟悉 Python 的型別註解語法。
同樣地,在面試中,候選人往往不熟悉typing
模組。我通常在跟候選人作廣泛的技術討論時,會展示一個使用了typing.Protocol
的程式碼片段,我不記得有任何候選人看到過這個特定的構造——當然,這完全沒問題!但這體現了 typing 在 Python 生態的流行程度。
所以,當我們招募團隊成員時,Mypy 往往是他們必須學習的新東西。雖然型別註解語法的基礎很簡單,但我們經常聽到這樣的問題:「為什麼 Mypy 會這樣?」、「為什麼 Mypy 在這裡報錯?」等等。
例如,這是一個通常需要解釋的例子:
if condition:
value: str = "Hello, world"
else:
# Not ok -- we declared `value` as `str`, and this is `None`!
value = None
...
if condition:
value: str = "Hello, world"
else:
# Not ok -- we already declared the type of `value`.
value: Optional[str] = None
...
# This is ok!
if condition:
value: Optional[str] = "Hello, world"
else:
value = None
另外,還有一個容易混淆的例子:
from typing import Literal
def my_func(value: Literal['a', 'b']) -> None:
...
for value in ('a', 'b'):
# Not ok -- `value` is `str`, not `Literal['a', 'b']`.
my_func(value)
當解釋之後,這些例子的「原因」是有道理的,但我不可否認的是,團隊成員需要耗費時間去熟悉 Mypy。有趣的是,我們團隊中有人說 PyCharm 的型別輔助感覺還不如在同一個 IDE 中使用 TypeScript 得到的有用和完整(即使有足夠的靜態型別)。不幸的是,這只是使用 Mypy 的代價。
除了學習曲線之外,還有持續地註解函數和變數的開銷。我曾建議對某些「種類」的程式碼(如探索性資料分析)放寬我們的 Mypy 規則——然而,團隊的感覺是註解是值得的,這件事很酷。
在編寫程式碼時,我會盡量避免幾件事,以免導致自己與型別系統作鬥爭:寫出我知道可行的程式碼,並強迫 Mypy 接受。
首先是@overload
,來自typing
模組:非常強大,但很難正確使用。當然,如果需要過載一個方法,我就會使用它——但是,就像我說的,如果可以的話,我寧可避免它。
基本原理很簡單:
@overload
def clean(s: str) -> str:
...
@overload
def clean(s: None) -> None:
...
def clean(s: Optional[str]) -> Optional[str]:
if s:
return s.strip().replace("\u00a0", " ")
else:
return None
但通常,我們想要做一些事情,比如「基於布林值返回不同的型別,帶有預設值」,這需要這樣的技巧:
@overload
def lookup(
paths: Iterable[str], *, strict: Literal[False]
) -> Mapping[str, Optional[str]]:
...
@overload
def lookup(
paths: Iterable[str], *, strict: Literal[True]
) -> Mapping[str, str]:
...
@overload
def lookup(
paths: Iterable[str]
) -> Mapping[str, Optional[str]]:
...
def lookup(
paths: Iterable[str], *, strict: Literal[True, False] = False
) -> Any:
pass
即使這是一個 hack——你不能傳一個bool
到 find_many_latest
,你必須傳一個字面量 True
或False
。
同樣地,我也遇到過其它問題,使用 @typing.overload
或者@overload
、在類方法中使用@overload
,等等。
其次是TypedDict
,同樣來自typing
模組:可能很有用,但往往會產生笨拙的程式碼。
例如,你不能解構一個TypedDict
——它必須用字面量 key 構造——所以下方第二種寫法是行不通的:
from typing import TypedDict
class Point(TypedDict):
x: float
y: float
a: Point = {"x": 1, "y": 2}
# error: Expected TypedDict key to be string literal
b: Point = {**a, "y": 3}
在實踐中,很難用TypedDict
物件做一些 Pythonic 的事情。我最終傾向於使用 dataclass
或 typing.NamedTuple
物件。
第三是裝飾器。Mypy 的 檔案 對保留簽名的裝飾器和裝飾器工廠有一個規範的建議。它很先進,但確實有效:
F = TypeVar("F", bound=Callable[..., Any])
def decorator(func: F) -> F:
def wrapper(*args: Any, **kwargs: Any):
return func(*args, **kwargs)
return cast(F, wrapper)
@decorator
def f(a: int) -> str:
return str(a)
但是,我發現使用裝飾器做任何花哨的事情(特別是不保留簽名的情況),都會導致程式碼難以型別化或者充斥著強制型別轉換。
這可能是一件好事!Mypy 確實改變了我編寫 Python 的方式:耍小聰明的程式碼更難被正確地型別化,因此我儘量避免編寫討巧的程式碼。
(裝飾器的另一個問題是我前面提過的@functools.lru_cache
:由於裝飾器最終定義了一個全新的函數,所以如果你不正確地註解程式碼,就可能會出現嚴重而令人驚訝的錯誤。)
我對迴圈匯入也有類似的感覺——由於要匯入型別作為註解使用,這就可能導致出現本可避免的迴圈匯入(這也是 Zulip 團隊強調的一個痛點)。雖然迴圈匯入是 Mypy 的一個痛點,但這通常意味著系統或程式碼本身存在著設計缺陷,這是 Mypy 強迫我們去考慮的問題。
不過,根據我的經驗,即使是經驗豐富的 Mypy 使用者,在型別檢查通過之前,他們也需對本來可以正常工作的程式碼進行一兩處更正。
(順便說一下:Python 3.10 使用ParamSpec
對裝飾器的情況作了重大的改進。)
最後,我要介紹幾個在使用 Mypy 時很有用的技巧。
在程式碼中新增reveal_type
,可以讓 Mypy 在對檔案進行型別檢查時,顯示出變數的推斷型別。這是非常非常非常有用的。
最簡單的例子是:
# No need to import anything. Just call `reveal_type`.
# Your editor will flag it as an undefined reference -- just ignore that.
x = 1
reveal_type(x) # Revealed type is "builtins.int"
當你處理泛型時,reveal_type
特別地有用,因為它可以幫助你理解泛型是如何被「填充」的、型別是否被縮小了,等等。
Mypy 可以用作一個執行時庫!
我們內部有一個工作流編排庫,看起來有點像 Flyte 或 Prefect。細節並不重要,但值得注意的是,它是完全型別化的——因此我們可以靜態地提升待執行任務的型別安全性,因為它們被連結在一起。
把型別弄準確是非常具有挑戰性的。為了確保它完好,不被意外的Any
毒害,我們在一組檔案上寫了呼叫 Mypy 的單元測試,並斷言 Mypy 丟擲的錯誤能匹配一系列預期內的異常:
def test_check_function(self) -> None:
result = api.run(
[
os.path.join(
os.path.dirname(__file__),
"type_check_examples/function.py",
),
"--no-incremental",
],
)
actual = result[0].splitlines()
expected = [
# fmt: off
'type_check_examples/function.py:14: error: Incompatible return value type (got "str", expected "int")', # noqa: E501
'type_check_examples/function.py:19: error: Missing positional argument "x" in call to "__call__" of "FunctionPipeline"', # noqa: E501
'type_check_examples/function.py:22: error: Argument "x" to "__call__" of "FunctionPipeline" has incompatible type "str"; expected "int"', # noqa: E501
'type_check_examples/function.py:25: note: Revealed type is "builtins.int"', # noqa: E501
'type_check_examples/function.py:28: note: Revealed type is "builtins.int"', # noqa: E501
'type_check_examples/function.py:34: error: Unexpected keyword argument "notify_on" for "options" of "Expression"', # noqa: E501
'pipeline.py:307: note: "options" of "Expression" defined here', # noqa: E501
"Found 4 errors in 1 file (checked 1 source file)",
# fmt: on
]
self.assertEqual(actual, expected)
當搜尋如何解決某個型別問題時,我經常會找到 Mypy 的 GitHub Issues (比 Stack Overflow 還多)。它可能是 Mypy 型別相關問題的解決方案和 How-To 的最佳知識源頭。你會發現其核心團隊(包括 Guido)對重要問題的提示和建議。
主要的缺點是,GitHub Issue 中的每個評論僅僅是某個特定時刻的評論——2018 年的一個問題可能已經解決了,去年的一個變通方案可能有了新的最佳實踐。所以在查閱 issue 時,一定要把這一點牢記於心。
typing
模組在每個 Python 版本中都有很多改進,同時,還有一些特性會通過typing-extensions
模組向後移植。
例如,雖然只使用 Python 3.8,但我們藉助typing-extensions
,在前面提到的工作流編排庫中使用了3.10 版本的ParamSpec
。(遺憾的是,PyCharm 似乎不支援通過typing-extensions
引入的ParamSpec
語法,並將其標記為一個錯誤,但是,還算好吧。)當然,Python 本身語法變化而出現的特性,不能通過typing-extensions
獲得。
在 typing
模組中有很多有用的輔助物件,NewType
是我的最愛之一。
NewType
可讓你建立出不同於現有型別的型別。例如,你可以使用NewType
來定義合規的谷歌雲端儲存 URL,而不僅是str
型別,比如:
from typing import NewType
GCSUrl = NewType("GCSUrl", str)
def download_blob(url: GCSUrl) -> None:
...
# Incompatible type "str"; expected "GCSUrl"
download_blob("gs://my_bucket/foo/bar/baz.jpg")
# Ok!
download_blob(GCSUrl("gs://my_bucket/foo/bar/baz.jpg"))
通過向download_blob
的呼叫者指出它的意圖,我們使這個函數具備了自描述能力。
我發現 NewType
對於將原始型別(如 str
和 int
)轉換為語意上有意義的型別特別有用。
Mypy 的效能並不是我們的主要問題。Mypy 將型別檢查結果儲存到快取中,能加快重複呼叫的速度(據其檔案稱:「Mypy 增量地執行型別檢查,複用前一次執行的結果,以加快後續執行的速度」)。
在我們最大的服務中執行 mypy
,冷快取大約需要 50-60 秒,熱快取大約需要 1-2 秒。
至少有兩種方法可以加速 Mypy,這兩種方法都利用了以下的技術(我們內部沒有使用):
Mypy 對我們產生了很大的影響,提升了我們釋出程式碼時的信心。雖然採納它需要付出一定的成本,但我們並不後悔。
除了工具本身的價值之外,Mypy 還是一個讓人印象非常深刻的專案,我非常感謝維護者們多年來為它付出的工作。在每一個 Mypy 和 Python 版本中,我們都看到了對 typing
模組、註解語法和 Mypy 本身的顯著改進。(例如:新的聯合型別語法( X|Y
)、 ParamSpec
和 TypeAlias
,這些都包含在 Python 3.10 中。)
原文釋出於 2022 年 8 月 21 日。
作者:Charlie Marsh
譯者:豌豆花下貓@Python貓
英文:Using Mypy in production at Spring (https://notes.crmarsh.com/using-mypy-in-production-at-spring)