Python Type Hints 教學:我犯過的 3 個菜鳥錯誤

by 好豪
Published: Last Updated on

如果你學過 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 告知函式引數是字串後,在函式內用到該引數的場合,編輯器會提示我有哪些字串函式能用:

加了 Type Hints 後,IDE 就知道怎麼多幫你一把(製圖:好豪)

(註:上方 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 學習資源:

我犯過的 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 中的 ReversibleSequence

# 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 裡,Optional[X] 的意思等同於 Union[X, None],表示可以接受 X 型別或是 None

Typing 官方文件 – 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 技巧、資料科學等知識。

點選下方按鈕可以將本文加入書籤、或者分享給更多正在學 Python 的朋友。

推薦閱讀