使用 Python typing 模組對你的同事好一點
Last updated on Sep 24, 2023 in Python 程式設計 - 中階 by Amo Chen ‐ 6 min read
由於 Python 動態型別(執行階段可以任意改變變數的型別)的特性,所以很多時候開發者們都會透過變數的命名讓他人知道該變數的型別,例如:
dicts = [{"key": "value"}, {"key": "values"}]
複數型的 dicts 命名讓其他人在閱讀時能夠大致猜到它可能是個字典(dict)的列表(list)。
但是現代專案不可能經常是如此簡單的結構,有時光從命名仍難以了解是什麼型別的變數,例如:
def get_value(json):
return parse(json)
當我們看到上述函式中的 json 時,就會疑惑它是什麼? str? dict? 而回傳的值到底長怎樣,有什麼 key 可以使用?也由於這種不確定性,所以在除錯甚至協同開發時都需要實際執行才能夠知道該變數到底是什麼型態,在複雜的大型專案中甚至會成為一種痛苦。
p.s. Javascript 也有相同的痛點,所以才有 TypeScript 問世
這種情況,我們除了用心命名之外,還可以搭配使用 typing 模組來改善!
本文環境
- Python 3.7 以上
typing 模組
typing 是 Python 3.5 之後內建的模組。
該模組並非用來規定 Python 程式必須使用什麼型別,而是透過型別註釋(type annotations)讓開發協作者可以更加了解某個變數的型別,也讓第三方的工具能夠實作型別檢查器(type checker)。
p.s. Python 本身並不會強迫函式或者變數必須使用規定型別的變數,就算你標錯型別,程式還是可以繼續執行
譬如,本文一開始的範例,就可以改成:
def get_value(json: str) -> dict:
return parse(json)
上述 json: str
是代表變數 json
的型別註釋(type annotation)是字串str
,代表 應該 傳進去字串型別的變數。
而 -> dict
則代表該函式回傳的型別 應是 字典(dictionary),
這邊會用 應該/應是
也是呼應Python 本身並不會強迫函式或者變數必須使用規定型別的變數,事實上只要執行過程不會出現任何錯誤,就算其他人不小心傳進錯誤的型別,也不會造成問題,例如以下範例:
def get_first_char(s: str) -> str:
return s[1]
print(get_first_char('abc'))
print(get_first_char([1, 2, 3]))
其執行結果為:
b
2
前述範例將 list 型態的變數傳進 get_first_char
中也未導致任何錯誤,證明型別註釋(type annotations)僅是類似註解般的存在,沒有任何強迫必須使用特定型別的效果,但即使如此,型別註釋也為 Python 帶來更好的可維護性。
大致了解型別註釋的用途與侷限之後,就可以進一步學習 typing 模組囉!
typing.List 或 []
Python 內建的型別(例如: int, float, dict, list, …)可以直接使用型別註釋,但如果是較複雜的資料型別就需要 typing 模組的輔助。
Python 3.9 以下(不含 3.9)
例如當我們想表達都是 int 的列表(list),例如 [1, 2, 3]
,並無法直接這樣表達:
def test(x: list[int]):
pass
Python 3.9 以下,上述的寫法會導致以下錯誤:
TypeError: 'type' object is not subscriptable
Python 3.9 以下正確的方式是使用 typing 模組的 List 進行表達:
from typing import List
def test(x: List[int]):
pass
Python 3.9 以上(含 3.9)
Python 3.9 之後,可以簡單使用 []
作為 List 的表達,例如:
def test(x: list[int]):
pass
p.s. Python 3.9 之後也捨棄 typing.List
的用法
typing.Union
得益於 Python 的動態型別,使得我們可以讓一個函式或方法能夠接受多種型別。
例如,以下函式能夠同時接受字串與數字:
def print_something(x):
if isinstance(x, (int, str, )):
print(f'Got {x}')
else:
raise TypeError('Only int & str are accepted')
Python 3.10 以下(不含 3.10)
此種情況下,如果我們想標示型別註釋(type annotation)的話,可以利用 typing.Union
:
from typing import Union
def print_something(x: Union[int, str]):
if isinstance(x, (int, str, )):
print(f'Got {x}')
else:
raise TypeError('Only int & str are accepted')
Python 3.10 以上(含 3.10)
Python 3.10 之後,可以用 |
取代 typing.Union
, 前述範例可以進一步簡化為:
def print_something(x: int | str):
if isinstance(x, (int, str, )):
print(f'Got {x}')
else:
raise TypeError('Only int & str are accepted')
p.s. 此處用法與 TypeScript 相似,詳見 Union Types
typing.Dict
通常使用字典(Dictionary)時,我們會存放一致型別的資料在字典中,例如以下的 word_count_mapping
, key 是字串, value 是整數:
word_count_mapping = {
'zoo': 1,
'zip': 2,
'google': 10,
'python': 12,
}
Python 3.9 以下(不含 3.9)
此時可以善用 typing.Dict
為 key 與 value 標上型別,例如 Dict[str, int]
代表一個字典的 key 是字串, value 則是整數:
word_count_mapping: Dict[str, int] = {
'zoo': 1,
'zip': 2,
'google': 10,
'python': 12,
}
def update_count(mapping: Dict[str, int], key: str, count: int):
mapping[key] = count
Python 3.9 以上(含 3.9)
Python 3.9 之後,已經捨棄 typing.Dict
的用法,所以前述範例可以直接簡化為:
def update_count(mapping: dict[str, int], key: str, count: int):
mapping[key] = count
Generic Alias Type
行文至此,可以發現 Python 3.9 之後對 typing 模組做了不少的優化,讓型別註釋變得更直覺。
Python 內建的型別,諸如 list, set, dict, frozenset, orderedDict 等等,在 Python 3.9 之前都需要使用 typing 模組標注型別:
typing.List
typing.Set
typing.FrozenSet
typing.DefaultDict
typing.OrderedDict
typing.ChainMap
typing.Counter
typing.Deque
在 Python 3.9 之後都改為不需要 typing 模組即可使用,舉 defaultdict 為例,可以直接標註型別:
from collections import defaultdict
def getValue(d: defaultdict[str, int], key: str):
return d[key]
有興趣的話,詳見 Generic Alias Type 。
typing.TypedDict
實務上也經常會為字典型的變數規定格式,例如以下的字典,該字典必須有 username
與 password
2 個 key 存在才行:
login_dict = {
'username': 'abc',
'password': 'pass123456',
}
def valid_username_password(login_dict):
pass
針對這種情況,可以使用 typing.TypedDict
進行註釋:
from typing import TypedDict
class LoginDict(TypedDict):
username: str
password: str
login_dict: LoginDict = {
'username': 'abc',
'password': 'pass123456',
}
def valid_username_password(login_dict: LoginDict):
pass
typing.Iterable
Python 中所謂的 iterable 指的是有實作 __iter__()
與 __next__()
的物件(object),例如 str, dict, list, tuple 都是 Iterable ,所以都能夠透過 for
走訪。
Python 3.9 以下(不含 3.9)
Python 3.9 以下如果是可以接受 iterable 的函式或方法,可以使用 typing.Iterable
進行標示:
from typing import Iterable
def print_iterable(x: Iterable):
for i in x:
print(i)
Python 3.9 以上(含 3.9)
Python 3.9 以上,可以直接簡化為:
from collections.abc from Iterable
def print_iterable(x: Iterable):
for i in x:
print(i)
typing.Protocol
詳見 Python 的 typing.Protocol 怎麼使用?
typing.Any
如果是不限定型別的情況下,可以使用 typing.Any
,譬如 print
其實就很適合可以使用 typing.Any
:
from typing import Any
def print_anything(*args: Any):
print(*args)
object
可以代替 typing.Any
嗎?
雖然 Python 中所有的物件都繼承自 object
,如果像下列範例這樣指定型別也不會造成 type checker 偵測到錯誤:
def print_anything(x: object):
print(x)
print_anything([1, 2, 3])
可是如果我們欲存取 object
內的屬性或方法時,就可能有問題發生,如下列範例,雖然我們語意上想用 object
表達任何型別:
def print_json(x: object):
print(x.__json__)
print_json([1, 2, 3])
但其實這種寫法會讓 type checker 真的去檢查 object 是否有 __json__
屬性,而導致錯誤發生:
error: "object" has no attribute "__json__" [attr-defined]
所以,結論是 object
並不能代替 typing.Any
。
如果想表達接受任何型別,請使用 typing.Any
。1
typing.Any: Special type indicating an unconstrained type.
- Every type is compatible with Any.
- Any is compatible with every type.
mypy
型別註釋能夠提供團隊成員對於變數型別的了解,不過仍不能真正解決型別誤用的情況,畢竟 Python 的型別註釋也只是註釋,並無法偵測誤用/錯用型別的情況。
更積極的做法是透過型別檢查器(type checker)幫助我們偵測誤用/錯用型別的情況,例如 mypy 。
p.s. 除了 mypy
也可以使用 Facebook 開發的 pyre
安裝 mypy
的指令如下:
$ pip install mypy
p.s. 本文版本 1.0.1
接著以實際例子偵測誤用/錯用型別的情況,例如以下檔案 test.py :
from typing import Dict
word_count_mapping: Dict[str, int] = {
'zoo': 1,
'zip': 2,
'google': 10,
'python': 12,
}
def update_count(mapping: Dict[str, int], key: str, count: int):
mapping[key] = count
update_count(word_count_mapping, 'google', '100')
我們以指令 mypy
進行檢查:
$ mypy test.py
可以發現 mypy
為我們偵測到 1 個型別錯誤:
test.py:16: error: Argument 3 to "update_count" has incompatible type "str"; expected "int" [arg-type]
Found 1 error in 1 file (checked 1 source file)
update_count
的第 3 個參數必須是 int 才行。
上述範例是針對單一檔案的檢查,如果是想針對整個資料夾的話,只要將檔案名稱改為資料夾名稱即可:
$ mypy direcotry/
mypy
還有很多選項與設定可供使用,在此不多加贅述,詳情請閱讀 mypy 官方文件 。
如果嫌每次都要輸入指令進行檢查很麻煩,可以將指令放在 git hook 中,例如 pre-commit 就能夠在 commit 之前進行檢查。
建立新型別 - NewType
型別不夠用的話,可以使用 NewType 建立新型別,例如官方範例:
UserId = NewType('UserId', int)
first_user = UserId(1)
利用 NewType 建立的新型別好處是 type checker 可以更好抓到型別錯誤,也可以強迫其他開發協作者發現程式內可能的錯誤或問題。
如下列的密碼雜湊函數:
def gen_passwd_hash(s: str):
pass
大家都只要傳入字串即可得到密碼的雜湊值,但是在代入字串前必須做些處理,例如檢查字串長度以及是否為高強度的密碼等等,我們可以用 NewType 強迫其他協作者意識到此處不可以亂代入字串,因此可進一步改為:
from typing import NewType
ValidPasswd = NewType('ValidPasswd', str)
def gen_passwd_hash(s: ValidPasswd):
pass
當有人試圖使用以下程式碼產生密碼雜湊時,就會讓 mypy 檢查到錯誤!
hash = gen_passwd_hash('hehehe')
mypy 檢查到錯誤:
error: Argument 1 to "gen_passwd_hash" has incompatible type "str"; expected "ValidPasswd" [arg-type]
Found 1 error in 1 file (checked 1 source file)
於是,我們只要提供一個函數專門檢查字串並回傳 ValidPasswd
型別的字串,就讓他人都能夠按照規則使用 gen_passwd_hash
函式,例如:
def check_passwd(s: str) -> ValidPasswd:
...(略)...
return ValidPasswd(s)
總結
以上是 typing 模組的大致功用, typing 模組還有許多型別與功能可以利用,有興趣的人可以詳閱官方文件。
另外,使用 typing 模組時,要注意 Python 的版本,如本文所述, Python 3.9 之後的 typing 模組在使用上會更直覺好用,如果是開發新專案,也許可以考慮使用 Python 3.9 之後的版本。
以上!
Happy Coding!
References
https://docs.python.org/3/library/typing.html
感謝大大林敬智指正
object
不能代替typing.Any
使用 ↩︎