使用 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

實務上也經常會為字典型的變數規定格式,例如以下的字典,該字典必須有 usernamepassword 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.Any1

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


  1. 感謝大大林敬智指正 object 不能代替 typing.Any 使用 ↩︎

對抗久坐職業傷害

研究指出每天增加 2 小時坐著的時間,會增加大腸癌、心臟疾病、肺癌的風險,也造成肩頸、腰背疼痛等常見問題。

然而對抗這些問題,卻只需要工作時定期休息跟伸展身體即可!

你想輕鬆改變現狀嗎?試試看我們的 PomodoRoll 番茄鐘吧! PomodoRoll 番茄鐘會根據你所設定的專注時間,定期建議你 1 項辦公族適用的伸展運動,幫助你打敗久坐所帶來的傷害!

贊助我們的創作

看完這篇文章了嗎? 休息一下,喝杯咖啡吧!

如果你覺得 MyApollo 有讓你獲得實用的資訊,希望能看到更多的技術分享,邀請你贊助我們一杯咖啡,讓我們有更多的動力與精力繼續提供高品質的文章,感謝你的支持!