如果你學過 C/C++ 或 Java,這些靜態語言在宣告變數的時候,就會要求程式設計師同時宣告該變數是什麼型別(Type),在 Python 也可以做到這種註釋型別的要求,就是使用 Type Hints(型別提示)。
Type Hints 是透過型別註釋(Type Annotation)讓程式更容易閱讀、也讓開發更有效率的語法,Typing 模組則可說是增強 Type Hints 的工具,筆者用 FastAPI 結合 pydantic 實作機器學習 API 時,就時常用到 Typing,也在學習過程中犯過許多菜鳥等級的錯誤,因此想把它們筆記下來與大家分享。
這篇文章將先介紹使用 Type Hints 究竟有什麼好處,並提供剛認識 Type Hints 的讀者入門學習資源。文章的後半段,我將分享三個我使用 Type Hints 與 Typing 犯錯並修正的經驗,主題分別是類別型別回傳、抽象型別、以及 Optional
使用方法,適合稍有 Type Hints 基礎的讀者,希望讀者不要再跟我犯相似的錯。
開始前,本文可能會有兩個容易混淆的中文詞彙,在此先釐清:
- 類別:指的是 Class
- 型別:指的是 Type
此外,為確保本文程式碼都可執行,建議使用 Python 3.9 以上版本。
目錄
Type Hints 有什麼好處?
更好讀、更好維護
加上型別註解後,程式碼會變得更加清晰好讀,修改或者維護的工作會變得更加容易。請看以下範例:
# 參考範例:https://gaborbernat.github.io/pycon-us-2019/
## 沒有 Type Hints 型別註釋
## 每個引數要怎麼用,大概要看完函式主體才知道
def send_request(request_data,
headers,
user_id,
as_json):
pass
## 有 Type Hints 型別註釋
## 函式主體還沒寫、可以略知一二每個引數的角色
def send_request(request_data : Any, # 可以是任何型別
headers: Optional[dict[str, str]], # 可以是 None 或者是從 str 映射到 str 的 dict
user_id: Optional[UserId] = None, # 可以是 None 或者是 UserId 類別
as_json: bool = True): # 必須是 bool
pass
更容易 Debug
讓 IDE 能幫你更多
在 IDE (整合開發環境)裡寫 Python,加 Type Hints 會給更多提示訊息,讓你可以用自動完成少打很多字,或者在程式碼落落長時,不會迷路忘記自己寫的變數究竟是什麼型別。以下範例中,用 Type Hints 告知函式引數是字串後,在函式內用到該引數的場合,編輯器會提示我有哪些字串函式能用:

(註:上方 VSCode 範例,需要加裝 Python 擴充 才會出現 Code 提示的功能)
型別檢查器
使用型別檢查器,讓你在花時間執行程式之前,就先偵測到 Bug!
Type Hints 要發揮威力,不得不提到型別檢查器(Type Checker),常見的範例是 mypy,它是一種靜態(Static)型別檢查器,意思是程式碼還沒有執行,它會先直接分析你的 Python 程式碼、根據型別相關的註釋找出問題。執行程式是要花時間的,如果還沒執行程式,就能先抓出一部分 Bug,光是這樣、寫程式效率就會提高許多!
如果你好奇:究竟 mypy 是怎麼分析以及抓出問題的?答案都寫在 PEP 484 的規範裡了,有興趣就盡情地讀完它吧。
除了 mypy 以外,各個大科技公司也有自己開發的型別檢查器,各位開發者可以玩玩看:
以下文章將會以 mypy 示範 Type Hints 程式碼。
Type Hints 基礎學習資源
這篇文章接下來的篇幅將會著重在我的個人學習與踩坑心得,若是完全初學 Type Hints 的讀者,這裡我也分享幾個筆者喜歡的 Type Hints 學習資源:
- Eirik Berge 的 Type Hints 基礎教學
- Amo Chen 的 Typing 模組教學
- 最核心的 Type Hints 官方文件:PEP 484
- 看過一點 Typing、想快速複習:mypy 文件裡的 Type Hints Cheatsheet
我犯過的 3 個菜鳥錯誤
類別定義內,回傳自己的物件必須用字串
創建類別(Class)的時候,如果該類別要回傳自己類別的物件,語法上不能單純寫類別、而是要用字串的形式來寫出該類別,以下是犯錯的例子:
# test.py
class Rectangle:
def __init__(self, height: int = 1, width: int = 1):
self.height = height
self.width = width
# -> "Rectangle" ( O 正確語法 )
# -> Rectangle ( X 錯誤語法 )
def get_largest_square(self) -> Rectangle: # 這行是錯誤示範
side_len = min(self.height, self.width)
return Rectangle(side_len, side_len)
def __str__(self):
return f"""
這是個長方形
高度 = {self.height}
寬度 = {self.width}
"""
rect = Rectangle(7, 20)
sqr = rect.get_largest_square()
print(sqr)
此程式碼將產生以下錯誤畫面:
$ python3 test.py
Traceback (most recent call last):
File "/Users/HaoSquare/test.py", line 3, in <module>
class Rectangle:
File "/Users/HaoSquare/test.py", line 10, in Rectangle
def get_largest_square(self) -> Rectangle: # 這行是錯誤示範
NameError: name 'Rectangle' is not defined
以上範例中,get_largest_square()
函式要回傳的是 Rectangle 類別的新物件,但是在類別定義的內部、Python 還看不懂 Rectangle 這個識別字,因此 -> Rectangle
會出錯,回傳的型別註釋需要改成將類別以字串表示 -> "Rectangle"
才能解決。
若是使用 Python 3.7 以上的版本,有另外一種解法:在程式頂端加上 from __future__ import annotations
,這樣子回傳類別自己物件的 -> Rectangle
寫法也沒問題了。詳情請參考 StackOverflow 討論。
寫 Union[set, list, tuple]
太囉唆,改用 Iterable
如果 Type Hints 要表達「某幾種型別,任一種都可以」,可以使用 Typing 模組中 Union[..., ..., ...]
的語法,例如 Union[str, int]
表示字串或整數其中一種。
(註:Python 3.10 後,也可以用 |
符號來表達 Union
,例如 str | int
等同於 Union[str, int]
,請見 PEP 604)
我自己常遇到一種情況,是要函式的某個引數可以接受任何可遍歷物件(Iterable),包括 set、list、或 tuple 等等,Typing 裡直覺的寫法會像是 Union[set, list, tuple]
,請參考以下範例:
# test.py
from typing import Union
def print_iterable_items(data: Union[set, list, tuple]):
print(f"遍歷: {data}")
for item in data:
print(item)
print("")
return None
print_iterable_items({1, 3, 5})
print_iterable_items({1: -1, 3: -1, 5: -1}.values())
print_iterable_items([1, 3, 5])
print_iterable_items((1, 3, 5))
print_iterable_items("abc")
程式執行後一小段範例輸出如下:
$ python3 test.py
...
遍歷: abc
a
b
c
...
程式雖然在執行時($ python3 test.py
)沒發生問題,但是 mypy 檢查就不會讓我過關了:
$ mypy test.py
test.py:13: error: Argument 1 to "print_iterable_items" has incompatible type "ValuesView[int]"; expected "Union[Set[Any], List[Any], Tuple[Any, ...]]"
test.py:16: error: Argument 1 to "print_iterable_items" has incompatible type "str"; expected "Union[Set[Any], List[Any], Tuple[Any, ...]]"
Found 2 errors in 1 file (checked 1 source file)
mypy 的錯誤訊息提出了 Union[set, list, tuple]
這個寫法的問題:沒有包含 dict 跟 str,雖然這兩個型別實際上也可遍歷,但是他們不符合函式 Type Hints 註釋的預期,所以不過關。
Union[set, list, tuple]
這種寫法不可能包含所有可遍歷物件,硬要寫進去所有我「想得到的可遍歷物件」只會又冗又長,像是 Union[set, list, tuple, dict, str]
,而且每次寫都要害怕自己遺漏了誰;如果是自定義了新的 可遍歷物件(例如自己寫個 MyArray
類別),又要自己記得手動加進去 Union
裡面、相當麻煩。
解決以上問題的方案,是使用 Iterable
這個 Typing 註釋,若符合可遍歷物件的 要求(實作 __iter__()
),就能被包含在 Iterable
的範圍裡。
要改進上述範例程式,只要把程式碼的前幾行改成:
# test.py
from typing import Union
from collections.abc import Iterable
def print_iterable_items(data: Iterable):
...
### 以下同上方範例,在此省略 ###
Iterable
會把 set、dict、str 等等全都包含進去,mypy 就會停止抱怨了:
$ mypy test.py
Success: no issues found in 1 source file
補充:更多抽象類別
事實上,Iterable
只是抽象類別(Abstract Base Class)的其中一種。抽象類別的簡化解釋:它不是特定一個具體的類別,只要某個類別符合該抽象類別定出的「協議」,該類別就可以是該抽象類別的一員。
上方例子中,Iterable
的協議是要實作 __iter__()
函式,set、dict、或 list 都符合,所以他們都算是 Iterable
。
抽象類別許多的「協議」都被規定在 collection.abc
裡,大多常見的類別分類邏輯都可以在這裡找到。例如,當我的程式碼不只需要可遍歷物件、還限定需要有排序的話,就可以使用 collection.abc
中的 Reversible
或 Sequence
:
# test.py
from typing import Sequence
seq: Sequence
seq = [1, 2, 3] # list 有序
seq = (1, 2, 3) # tuple 有序
seq = {1: -1, 2: -1, 3: -1} # dict 無序
seq = {1, 2, 3} # set 無序
上面這段程式碼,Sequence
的抽象類別註釋中,dict 與 set 沒有排序或順序(在協議裡實際是指沒有實作「反向排序的函式」),所以 mypy 檢查的時候,就不會放他們過關了。
$ mypy test.py
test.py:8: error: Incompatible types in assignment (expression has type "Dict[int, int]", variable has type "Sequence[Any]")
test.py:9: error: Incompatible types in assignment (expression has type "Set[int]", variable has type "Sequence[Any]")
Found 2 errors in 1 file (checked 1 source file)
Optional
在可變引數的好處
Typing 裡,
Typing 官方文件 –Optional[X]
的意思等同於Union[X, None]
,表示可以接受X
型別或是None
。Optional
筆者好豪在 過去的文章 曾討論過:函式參數的預設引數不可以直接設置可變物件,不這麼做會有什麼問題,請參考 原文章,以下直接列出最佳做法的範例程式:
# 函式參數的預設引數會是可變物件(例如 list)時
# 需要把設置預設值的行為從參數列
# 移動到函式主體(function body)再做
def foo(a=None):
if a is None:
a = []
a.append(5)
return a
我初學 Type Hints 的時候,曾有過錯誤的想法:既然函式的預設引數都是 None
了,那在參數列就沒必要、也沒辦法 Type Hints 檢查型別了吧?我最多只能在函式主體(function body)額外加上型別檢查:
def foo(a=None):
if a is None:
a = []
elif not isinstance(a, list):
print("嘿!你的引數不是 list 也不是 None")
return None
a.append(5)
return a
以上範例看起來稍嫌冗長,用 Optional
來寫就能讓程式更簡潔好讀。Optional
改寫後,除了引數可接受 None
以外、重要的是可以獲得更容易 debug 的額外好處。
例如以下兩支程式,同樣是 list 用到的 append()
函式出現筆誤,有運用 Typing 的 Optional
就可以用 mypy 事先抓出 bug,沒有用 Typing 則是要執行程式(runtime)才發現問題。請閱讀以下範例說明:
可變引數陷阱範例 1:使用 Optional
,mypy 及早發現問題
# test_with_optional.py
from typing import Optional
def foo(a: Optional[list] = None):
if a is None:
a = []
a.appenddddd(5)
return a
print(foo())
## mypy 事先發現問題,不用花時間執行程式
$ mypy test_with_optional.py
test_with_optional.py:8: error: "List[Any]" has no attribute "appenddddd"
Found 1 error in 1 file (checked 1 source file)
## 執行程式後,理所當然反映出 bug
$ python3 test_with_optional.py
Traceback (most recent call last):
File "/Users/HaoSquare/test_with_optional.py", line 11, in <module>
print(foo())
File "/Users/HaoSquare/test_with_optional.py", line 8, in foo
a.appenddddd(5)
AttributeError: 'list' object has no attribute 'appenddddd'
可變引數陷阱範例 2:未使用 Optional
,執行程式才發現問題
# test_without_optional.py
def foo(a=None):
if a is None:
a = []
a.appenddddd(5)
return a
print(foo())
## 沒有 Type Hints、mypy 就不檢查,自然就沒發現問題
$ mypy test_without_optional.py
Success: no issues found in 1 source file
## 花時間跑完了程式,才知道有 bug
$ python3 test_without_optional.py
Traceback (most recent call last):
File "/Users/HaoSquare/test_without_optional.py", line 9, in <module>
print(foo())
File "/Users/HaoSquare/test_without_optional.py", line 6, in foo
a.appenddddd(5)
AttributeError: 'list' object has no attribute 'appenddddd'
結語
這些只是我學習 Type Hints 踩過的三個坑而已,還有很多犯過的錯,太過微不足道就沒在本文提出來。乍看之下,多寫 Type Hints 除了程式碼要多打幾個字以外、還要面對型別檢查器的拷問,貌似很難學,幸好,Python 實際上是 Gradual Typing,意思是不需要整個程式碼通通都實作 Type Hints,只有一部分有註釋型別的話、型別檢查器也只會檢查那一小部分;而其他沒有註釋型別的程式碼,不會被型別檢查器抱怨。
因此,建議讀者從一個變數、一個函式開始,慢慢練習加入 Type Hints 與 Typing(也像本文一樣慢慢踩坑),隨著程式碼與經驗的累積,自然就會開始感受到 Typing 的好用之處!
參考資料:
感謝你閱讀這篇好豪的 Python Type Hints 學習心得,如果你想認識更多實用的 Python 模組,推薦你繼續閱讀以下兩篇教學:
- Python pathlib 教學:檔案路徑操作超簡單,不再煩惱前斜線或後斜線!
- Python argparse 教學:比 sys.argv 更好用,讓命令列引數整潔又有序
- Python 虛擬環境入門:用 venv 管理套件版本,寫出乾淨又穩健的程式
也歡迎追蹤 好豪的粉絲專頁 與 Threads 帳號,我會持續分享 Python 技巧、資料科學等知識。
點選下方按鈕可以將本文加入書籤、或者分享給更多正在學 Python 的朋友。