作者:Charlie Marsh
成都創(chuàng)新互聯(lián)長(zhǎng)期為上1000家客戶提供的網(wǎng)站建設(shè)服務(wù),團(tuán)隊(duì)從業(yè)經(jīng)驗(yàn)10年,關(guān)注不同地域、不同群體,并針對(duì)不同對(duì)象提供差異化的產(chǎn)品和服務(wù);打造開放共贏平臺(tái),與合作伙伴共同營(yíng)造健康的互聯(lián)網(wǎng)生態(tài)環(huán)境。為濟(jì)水街道企業(yè)提供專業(yè)的成都做網(wǎng)站、成都網(wǎng)站建設(shè),濟(jì)水街道網(wǎng)站改版等技術(shù)服務(wù)。擁有10余年豐富建站經(jīng)驗(yàn)和眾多成功案例,為您定制開發(fā)。
譯者:豌豆花下貓@Python貓
英文:Using Mypy in production at Spring (https://notes.crmarsh.com/using-mypy-in-production-at-spring)
在 Spring ,我們維護(hù)了一個(gè)大型的 Python 單體代碼庫(kù)(英:monorepo),用上了 Mypy 最嚴(yán)格的配置項(xiàng),實(shí)現(xiàn)了 Mypy 全覆蓋。簡(jiǎn)而言之,這意味著每個(gè)函數(shù)簽名都是帶注解的,并且不允許有隱式的 Any
轉(zhuǎn)換。
(譯注:此處的 Spring 并不是 Java 中那個(gè)著名的 Spring 框架,而是一家生物科技公司,專注于找到與年齡相關(guān)的疾病的療法,2022 年 3 月曾獲得比爾&梅琳達(dá)·蓋茨基金會(huì) 120 萬美元的資助。)
誠(chéng)然,代碼行數(shù)是一個(gè)糟糕的衡量標(biāo)準(zhǔn),但可作一個(gè)粗略的估計(jì):我們的代碼倉(cāng)有超過 30 萬行 Python 代碼,其中大約一半構(gòu)成了核心的數(shù)據(jù)平臺(tái),另一半是由數(shù)據(jù)科學(xué)家和機(jī)器學(xué)習(xí)研究員編寫的終端用戶代碼。
我有個(gè)大膽的猜測(cè),就這個(gè)規(guī)模而言,這是最全面的加了類型的 Python 代碼倉(cāng)之一。
我們?cè)?2019 年 7 月首次引入了 Mypy,大約一年后實(shí)現(xiàn)了全面的類型覆蓋,從此成為了快樂的 Mypy 用戶。
幾周前,我跟 Leo Boytsov 和 Erik Bernhardsson 在 Twitter 上對(duì) Python 類型有一次簡(jiǎn)短的討論——然后我看到 Will McGugan 也對(duì)類型大加贊賞。由于 Mypy 是我們?cè)?Spring 公司發(fā)布和迭代 Python 代碼的關(guān)鍵部分,我想寫一下我們?cè)谶^去幾年中大規(guī)模使用它的經(jīng)驗(yàn)。
一句話總結(jié):雖然采用 Mypy 是有代價(jià)的(前期和持續(xù)的投入、學(xué)習(xí)曲線等),但我發(fā)現(xiàn)它對(duì)于維護(hù)大型 Python 代碼庫(kù)有著不可估量的價(jià)值。Mymy 可能不適合于所有人,但它十分適合我。
(如果你很熟悉 Mypy,可跳過本節(jié)。)
Mypy 是 Python 的一個(gè)靜態(tài)類型檢查工具。如果你寫過 Python 3,你可能會(huì)注意到 Python 支持類型注解,像這樣:
def greeting(name: str) -> str:
return 'Hello ' + name
Python 在 2014 年通過 PEP-484 定義了這種類型注解語法。雖然這些注解是語言的一部分,但 Python(以及相關(guān)的第一方工具)實(shí)際上并不拿它們來強(qiáng)制做到類型安全。
相反,類型檢查通過第三方工具來實(shí)現(xiàn)。Mypy 就是這樣的工具。Facebook 的 Pyre 也是這樣的工具——但就我所知,Mypy 更受歡迎(Mypy 在 GitHub 上有兩倍多的星星,它是 Pants 默認(rèn)使用的工具)。IntelliJ 也有自己的類型檢查工具,支持在 PyCharm 中實(shí)現(xiàn)類型推斷。這些工具都聲稱自己“兼容 PEP-484”,因?yàn)樗鼈兪褂?Python 本身定義的類型注解。
(譯注:最著名的類型檢查工具還有谷歌的pytype
和微軟的pyright
,關(guān)于基本情況介紹與對(duì)比,可查閱這篇文章 )
換句話說:Python 認(rèn)為自己的責(zé)任是定義類型注解的語法和語義(盡管 PEP-484 本身很大程度上受到了 Mypy 現(xiàn)有版本的啟發(fā)),但有意讓第三方工具來檢查這些語義。
請(qǐng)注意,當(dāng)你使用像 Mypy 這樣的工具時(shí),你是在 Python 本身之外運(yùn)行它的——比如,當(dāng)你運(yùn)行mypy path/to/file.py
后,Mypy 會(huì)把推斷出的違規(guī)代碼都吐出來。Python 在運(yùn)行時(shí)顯露但不利用那些類型注解。
(順便一提:在寫本文時(shí),我了解到相比于 Pypy 這樣的項(xiàng)目,Mypy 最初有著非常不同的目標(biāo)。那時(shí)還沒有 PEP-484(它的靈感來自 Mypy?。?Mypy 定義了自己的語法,與 Python 不同,并實(shí)現(xiàn)了自己的運(yùn)行時(shí)(也就是說,Mypy 代碼是通過 Mypy 執(zhí)行的)。當(dāng)時(shí),Mypy 的目標(biāo)之一是利用靜態(tài)類型、不可變性等來提高性能——而且明確地避開了與 CPython 兼容。Mypy 在 2013 年切換到兼容 Python 的語法,而 PEP-484 在 2015 年才推出。(“使用靜態(tài)類型加速 Python”的概念催生了 Mypyc,它仍然是一個(gè)活躍的項(xiàng)目,可用于編譯 Mypy 本身。))
我們?cè)?2019 年 7 月將 Mypy 引入代碼庫(kù)(#1724)。當(dāng)首次發(fā)起提議時(shí),我們有兩個(gè)主要的考慮:
盡管有所猶豫,我們還是決定給 Mypy 一個(gè)機(jī)會(huì)。在公司內(nèi)部,我們有強(qiáng)烈偏好于靜態(tài)類型的工程師文化(除了 Python,我們寫了很多 Rust 和 TypeScript)。所以,我們準(zhǔn)備使用 Mypy。
我們首先類型化了一些文件。一年后,我們完成了全部代碼的類型化(#2622),并升級(jí)到最嚴(yán)格的 Mypy 設(shè)置(最關(guān)鍵的是 disallow_untyped_defs
,它要求對(duì)所有函數(shù)簽名進(jìn)行注解),從那時(shí)起,我們一直維護(hù)著這些設(shè)置。(Wolt 團(tuán)隊(duì)有一篇很好的文章,他們稱之為“專業(yè)級(jí)的 Mypy 配置”,巧合的是,我們使用的正是這種配置。)
Mypy 配置:https://blog.wolt.com/engineering/2021/09/30/professional-grade-mypy-configuration/
總體而言:我對(duì) Mypy 持積極的看法。 作為核心基礎(chǔ)設(shè)施的開發(fā)人員(跨服務(wù)和跨團(tuán)隊(duì)使用的公共庫(kù)),我認(rèn)為它極其有用。
我將在以后的任何 Python 項(xiàng)目中繼續(xù)使用它。
Zulip 早在 2016 年寫了一篇漂亮的文章,內(nèi)容關(guān)于使用 Mypy 的好處(這篇文章也被收入了 Mypy 官方文檔 中)。
Zulip 博文:https://blog.zulip.com/2016/10/13/static-types-in-python-oh-mypy/#benefitsofusingmypy
我不想重述靜態(tài)類型的所有好處(它很好),但我想簡(jiǎn)要地強(qiáng)調(diào)他們?cè)谔又刑岬降膸讉€(gè)好處:
第三點(diǎn)的價(jià)值怎么強(qiáng)調(diào)都不為過。毫不夸張地說,在 Mypy 的幫助下,我發(fā)布更改的速度快了十倍,甚至快了一百倍。
雖然這是完全主觀的,但在寫這篇文章時(shí),我意識(shí)到:我信任 Mypy。雖然程度還不及,比如說 OCaml 編譯器,但它完全改變了我維護(hù) Python 代碼的關(guān)系,我無法想象回到?jīng)]有注解的世界。
Zulip 的帖子同樣強(qiáng)調(diào)了他們?cè)谶w移 Mypy 時(shí)所經(jīng)歷的痛點(diǎn)(與靜態(tài)代碼分析工具的交互,循環(huán)導(dǎo)入)。
坦率地說,我在 Mypy 上經(jīng)歷的痛點(diǎn)與 Zulip 文章中提到的不一樣。我把它們分成三類:
讓我們來逐一回顧一下:
最重要的痛點(diǎn)是,我們引入的大多數(shù)第三方 Python 庫(kù)要么是無類型的,要么不兼容 PEP-561。在實(shí)踐中,這意味著對(duì)這些外部庫(kù)的引用會(huì)被解析為不兼容,這會(huì)大大削弱類型的覆蓋率。
每當(dāng)在環(huán)境里添加一個(gè)第三方庫(kù)時(shí),我們都會(huì)在mypy.ini
里添加一個(gè)許可條目,它告訴 Mypy 要忽略那些模塊的類型注解(有類型或提供類型存根的庫(kù),比較罕見):
[mypy-altair.*]
ignore_missing_imports = True
[mypy-apache_beam.*]
ignore_missing_imports = True
[mypy-bokeh.*]
ignore_missing_imports = True
...
由于有了這樣的安全出口,即使是隨便寫的注解也不會(huì)生效。例如,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!"
除了第三方庫(kù),我們?cè)?Python 標(biāo)準(zhǔn)庫(kù)上也遇到了一些不順。例如,functools.lru_cache
盡管在 typeshed 里有類型注解,但由于復(fù)雜的原因,它不保留底層函數(shù)的簽名,所以任何用 @functools.lru_cache
裝飾的函數(shù)都會(huì)被移除所有類型注解。
例如,Mypy 允許這樣做:
import functools
@functools.lru_cache
def add_one(x: float) -> float:
return x + 1
add_one("Hello, world!")
第三方庫(kù)的情況正在改善。例如,NumPy 在 1.20 版本中開始提供類型。Pandas 也有一系列公開的類型存根 ,但它們被標(biāo)記為不完整的。(添加存根到這些庫(kù)是非常重要的,這是一個(gè)巨大的成就?。┝硗庵档靡惶岬氖牵易罱?Twitter 上看到了 Wolt 的 Python 項(xiàng)目模板 ,它也默認(rèn)包括類型。
所以,類型正在變得不再罕見。過去當(dāng)我們添加一個(gè)有類型注解的依賴時(shí),我會(huì)感到驚訝。有類型注解的庫(kù)還是少數(shù),并未成為主流。
大多數(shù)加入 Spring 的人沒有使用過 Mypy(寫過 Python),盡管他們基本知道并熟悉 Python 的類型注解語法。
同樣地,在面試中,候選人往往不熟悉typing
模塊。我通常在跟候選人作廣泛的技術(shù)討論時(shí),會(huì)展示一個(gè)使用了typing.Protocol
的代碼片段,我不記得有任何候選人看到過這個(gè)特定的構(gòu)造——當(dāng)然,這完全沒問題!但這體現(xiàn)了 typing 在 Python 生態(tài)的流行程度。
所以,當(dāng)我們招募團(tuán)隊(duì)成員時(shí),Mypy 往往是他們必須學(xué)習(xí)的新東西。雖然類型注解語法的基礎(chǔ)很簡(jiǎn)單,但我們經(jīng)常聽到這樣的問題:“為什么 Mypy 會(huì)這樣?”、“為什么 Mypy 在這里報(bào)錯(cuò)?”等等。
例如,這是一個(gè)通常需要解釋的例子:
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
另外,還有一個(gè)容易混淆的例子:
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)
當(dāng)解釋之后,這些例子的“原因”是有道理的,但我不可否認(rèn)的是,團(tuán)隊(duì)成員需要耗費(fèi)時(shí)間去熟悉 Mypy。有趣的是,我們團(tuán)隊(duì)中有人說 PyCharm 的類型輔助感覺還不如在同一個(gè) IDE 中使用 TypeScript 得到的有用和完整(即使有足夠的靜態(tài)類型)。不幸的是,這只是使用 Mypy 的代價(jià)。
除了學(xué)習(xí)曲線之外,還有持續(xù)地注解函數(shù)和變量的開銷。我曾建議對(duì)某些“種類”的代碼(如探索性數(shù)據(jù)分析)放寬我們的 Mypy 規(guī)則——然而,團(tuán)隊(duì)的感覺是注解是值得的,這件事很酷。
在編寫代碼時(shí),我會(huì)盡量避免幾件事,以免導(dǎo)致自己與類型系統(tǒng)作斗爭(zhēng):寫出我知道可行的代碼,并強(qiáng)迫 Mypy 接受。
首先是@overload
,來自typing
模塊:非常強(qiáng)大,但很難正確使用。當(dāng)然,如果需要重載一個(gè)方法,我就會(huì)使用它——但是,就像我說的,如果可以的話,我寧可避免它。
基本原理很簡(jiǎn)單:
@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
但通常,我們想要做一些事情,比如“基于布爾值返回不同的類型,帶有默認(rèn)值”,這需要這樣的技巧:
@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
即使這是一個(gè) hack——你不能傳一個(gè)bool
到 find_many_latest
,你必須傳一個(gè)字面量 True
或False
。
同樣地,我也遇到過其它問題,使用 @typing.overload
或者@overload
、在類方法中使用@overload
,等等。
其次是TypedDict
,同樣來自typing
模塊:可能很有用,但往往會(huì)產(chǎn)生笨拙的代碼。
例如,你不能解構(gòu)一個(gè)TypedDict
——它必須用字面量 key 構(gòu)造——所以下方第二種寫法是行不通的:
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}
在實(shí)踐中,很難用TypedDict
對(duì)象做一些 Pythonic 的事情。我最終傾向于使用 dataclass
或 typing.NamedTuple
對(duì)象。
第三是裝飾器。Mypy 的 文檔 對(duì)保留簽名的裝飾器和裝飾器工廠有一個(gè)規(guī)范的建議。它很先進(jìn),但確實(shí)有效:
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)
但是,我發(fā)現(xiàn)使用裝飾器做任何花哨的事情(特別是不保留簽名的情況),都會(huì)導(dǎo)致代碼難以類型化或者充斥著強(qiáng)制類型轉(zhuǎn)換。
這可能是一件好事!Mypy 確實(shí)改變了我編寫 Python 的方式:耍小聰明的代碼更難被正確地類型化,因此我盡量避免編寫討巧的代碼。
(裝飾器的另一個(gè)問題是我前面提過的@functools.lru_cache
:由于裝飾器最終定義了一個(gè)全新的函數(shù),所以如果你不正確地注解代碼,就可能會(huì)出現(xiàn)嚴(yán)重而令人驚訝的錯(cuò)誤。)
我對(duì)循環(huán)導(dǎo)入也有類似的感覺——由于要導(dǎo)入類型作為注解使用,這就可能導(dǎo)致出現(xiàn)本可避免的循環(huán)導(dǎo)入(這也是 Zulip 團(tuán)隊(duì)強(qiáng)調(diào)的一個(gè)痛點(diǎn))。雖然循環(huán)導(dǎo)入是 Mypy 的一個(gè)痛點(diǎn),但這通常意味著系統(tǒng)或代碼本身存在著設(shè)計(jì)缺陷,這是 Mypy 強(qiáng)迫我們?nèi)タ紤]的問題。
不過,根據(jù)我的經(jīng)驗(yàn),即使是經(jīng)驗(yàn)豐富的 Mypy 用戶,在類型檢查通過之前,他們也需對(duì)本來可以正常工作的代碼進(jìn)行一兩處更正。
(順便說一下:Python 3.10 使用ParamSpec
對(duì)裝飾器的情況作了重大的改進(jìn)。)
最后,我要介紹幾個(gè)在使用 Mypy 時(shí)很有用的技巧。
在代碼中添加reveal_type
,可以讓 Mypy 在對(duì)文件進(jìn)行類型檢查時(shí),顯示出變量的推斷類型。這是非常非常非常有用的。
最簡(jiǎn)單的例子是:
# 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"
當(dāng)你處理泛型時(shí),reveal_type
特別地有用,因?yàn)樗梢詭椭憷斫夥盒褪侨绾伪弧疤畛洹钡?、類型是否被縮小了,等等。
Mypy 可以用作一個(gè)運(yùn)行時(shí)庫(kù)!
我們內(nèi)部有一個(gè)工作流編排庫(kù),看起來有點(diǎn)像 Flyte 或 Prefect。細(xì)節(jié)并不重要,但值得注意的是,它是完全類型化的——因此我們可以靜態(tài)地提升待運(yùn)行任務(wù)的類型安全性,因?yàn)樗鼈儽绘溄釉谝黄稹?/p>
把類型弄準(zhǔn)確是非常具有挑戰(zhàn)性的。為了確保它完好,不被意外的Any
毒害,我們?cè)谝唤M文件上寫了調(diào)用 Mypy 的單元測(cè)試,并斷言 Mypy 拋出的錯(cuò)誤能匹配一系列預(yù)期內(nèi)的異常:
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)
當(dāng)搜索如何解決某個(gè)類型問題時(shí),我經(jīng)常會(huì)找到 Mypy 的 GitHub Issues (比 Stack Overflow 還多)。它可能是 Mypy 類型相關(guān)問題的解決方案和 How-To 的最佳知識(shí)源頭。你會(huì)發(fā)現(xiàn)其核心團(tuán)隊(duì)(包括 Guido)對(duì)重要問題的提示和建議。
主要的缺點(diǎn)是,GitHub Issue 中的每個(gè)評(píng)論僅僅是某個(gè)特定時(shí)刻的評(píng)論——2018 年的一個(gè)問題可能已經(jīng)解決了,去年的一個(gè)變通方案可能有了新的最佳實(shí)踐。所以在查閱 issue 時(shí),一定要把這一點(diǎn)牢記于心。
typing
模塊在每個(gè) Python 版本中都有很多改進(jìn),同時(shí),還有一些特性會(huì)通過typing-extensions
模塊向后移植。
例如,雖然只使用 Python 3.8,但我們借助typing-extensions
,在前面提到的工作流編排庫(kù)中使用了3.10 版本的ParamSpec
。(遺憾的是,PyCharm 似乎不支持通過typing-extensions
引入的ParamSpec
語法,并將其標(biāo)記為一個(gè)錯(cuò)誤,但是,還算好吧。)當(dāng)然,Python 本身語法變化而出現(xiàn)的特性,不能通過typing-extensions
獲得。
在 typing
模塊中有很多有用的輔助對(duì)象,NewType
是我的最愛之一。
NewType
可讓你創(chuàng)建出不同于現(xiàn)有類型的類型。例如,你可以使用NewType
來定義合規(guī)的谷歌云存儲(chǔ) 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
的調(diào)用者指出它的意圖,我們使這個(gè)函數(shù)具備了自描述能力。
我發(fā)現(xiàn) NewType
對(duì)于將原始類型(如 str
和 int
)轉(zhuǎn)換為語義上有意義的類型特別有用。
Mypy 的性能并不是我們的主要問題。Mypy 將類型檢查結(jié)果保存到緩存中,能加快重復(fù)調(diào)用的速度(據(jù)其文檔稱:“Mypy 增量地執(zhí)行類型檢查,復(fù)用前一次運(yùn)行的結(jié)果,以加快后續(xù)運(yùn)行的速度”)。
在我們最大的服務(wù)中運(yùn)行 mypy
,冷緩存大約需要 50-60 秒,熱緩存大約需要 1-2 秒。
至少有兩種方法可以加速 Mypy,這兩種方法都利用了以下的技術(shù)(我們內(nèi)部沒有使用):
Mypy 對(duì)我們產(chǎn)生了很大的影響,提升了我們發(fā)布代碼時(shí)的信心。雖然采納它需要付出一定的成本,但我們并不后悔。
除了工具本身的價(jià)值之外,Mypy 還是一個(gè)讓人印象非常深刻的項(xiàng)目,我非常感謝維護(hù)者們多年來為它付出的工作。在每一個(gè) Mypy 和 Python 版本中,我們都看到了對(duì) typing
模塊、注解語法和 Mypy 本身的顯著改進(jìn)。(例如:新的聯(lián)合類型語法( X|Y
)、 ParamSpec
和 TypeAlias
,這些都包含在 Python 3.10 中。)
原文發(fā)布于 2022 年 8 月 21 日。
作者:Charlie Marsh
譯者:豌豆花下貓@Python貓
英文:Using Mypy in production at Spring (https://notes.crmarsh.com/using-mypy-in-production-at-spring)