回文章列表
Python15 分鐘閱讀2026年9月2日

Python 型別註解 type hints 入門:用 mypy 把 bug 攔在執行之前

#Python#type hints#型別註解#mypy#靜態型別檢查#進階

文章目錄

銷售資料整理腳本又被交回你手上,這次要改的是別人(也可能是三個月前的自己)寫的一個函式:

def calc_total(orders):
    total = 0
    for o in orders:
        total += o.quantity * o.unit_price
    return total

orders 到底該傳什麼進去?是一份 dict 組成的 list,每筆訂單長得像 {'quantity': 2, 'unit_price': 690}?還是前幾篇整理成的 Order dataclass?搞不好中間有人改成直接丟一份 pandas 的 DataFrame。函式簽名完全沒講,唯一的辦法是翻開整份程式碼去找呼叫的地方是怎麼傳的——如果專案有好幾個檔案,翻找的範圍還要再乘上檔案數。

更麻煩的是傳錯了不會馬上出事。假如你手滑傳了一份 dict 組成的 list 進去,o.quantity 這一行不會報「型別不對」,而是等 Python 真的執行到這一行才丟出 AttributeError: 'dict' object has no attribute 'quantity'——如果 calc_total 是在某個很少被觸發的分支裡才會呼叫到,這個 bug 可能要等到跑月報表那天才會被發現。

這篇要做兩件事:用型別註解把「這個函式吃什麼、吐什麼」寫清楚,再用 mypy 這個工具在完全不執行程式的情況下,把型別不合的地方揪出來。

型別註解不會改變任何執行行為

在動手寫語法之前,先講清楚最容易誤會的一點:型別註解只是給人、給編輯器、給 mypy 這類工具看的,Python 直譯器執行的時候完全不會檢查它。加了註解,程式該怎麼跑還是怎麼跑,該炸還是照樣炸。

實際試一次就知道:

def add(a: int, b: int) -> int:
    return a + b


result = add('ab', 'cd')
print(result)
print(type(result))
abcd
<class 'str'>

明明標了 int,傳進去的卻是字串,Python 照樣執行、照樣把兩個字串接起來,一個錯誤都沒有。型別註解甚至不是註解(comment)那種寫完就消失的東西——它會被存進函式的 __annotations__ 屬性,執行期還查得到:

print(add.__annotations__)
{'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}

正因為 Python 直譯器不管這件事,「型別對不對」就得靠外部工具在執行前先檢查一遍——這正是 mypy 要做的事。寫過 C# 的話這個概念不陌生:C# 編譯器會在編譯階段就擋下型別不合的程式碼,Python 沒有編譯階段幫你擋,型別註解加上 mypy 就是補上這一塊。

基本寫法:變數、參數、回傳值

三個地方都用冒號 : 接型別,回傳值用箭頭 ->:

name: str = '鍵盤'
price: float = 690.0
in_stock: bool = True


def describe_product(name: str, price: float) -> str:
    return f'{name}:{price:.0f} 元'


print(describe_product('鍵盤', 690.0))
鍵盤:690 元

變數註解其實最少用——name: str = '鍵盤' 這種寫法,Python 光看等號右邊的 '鍵盤' 通常也猜得出是字串,標了反而多此一舉。型別註解真正有價值的地方是函式的參數與回傳值,因為那是呼叫者唯一看得到的介面,光看函式名字猜不出裡面要傳什麼、會拿回什麼。

容器型別:list、dict、tuple 怎麼標

orders 這種「一份 list,裡面裝著某種東西」的參數,得標出「list 裡面裝的是什麼」,不然只寫 orders: list 等於沒講:

from dataclasses import dataclass


@dataclass
class Order:
    order_id: str
    product: str
    category: str
    quantity: int
    unit_price: float
    status: str = 'pending'


def calc_total(orders: list[Order]) -> float:
    return sum(o.quantity * o.unit_price for o in orders)

list[Order] 讀作「一份 list,每個元素都是 Order」。同樣的語法套到 dict、tuple 也通:

def product_totals(orders: list[Order]) -> dict[str, float]:
    """商品名稱對應到該商品的銷售總額"""
    totals: dict[str, float] = {}
    for o in orders:
        totals[o.product] = totals.get(o.product, 0.0) + o.quantity * o.unit_price
    return totals


def order_quantities(orders: list[Order]) -> tuple[int, ...]:
    """每一筆訂單的購買數量,包成一個 tuple"""
    return tuple(o.quantity for o in orders)

dict[str, float] 是「key 是 str、value 是 float 的 dict」。tuple[int, ...] 的三個點是固定寫法,意思是「裡面每個元素都是 int,但長度不固定」——如果 tuple 長度是固定的,例如「一定剛好三個 int」,才會寫成 tuple[int, int, int],不能偷懶用 ...。

這裡的語法要挑對版本。 list[Order]、dict[str, float] 這種直接用內建型別加中括號的寫法,是 PEP 585 從 Python 3.9 開始才支援的。3.9 之前(或是你在舊教材、舊專案裡看到)會寫成 from typing import List, Dict 再用 List[Order]、Dict[str, float]——這兩種寫法功能一樣,但官方文件已經把 typing.List、typing.Dict 這批標成「自 3.9 起不建議使用」,新程式碼一律用內建的 list、dict 就好,typing 裡那批只是為了相容舊版本才留著。本站範例都在 Python 3.14 上跑,所以後面統一用內建寫法。

X | None:可有可無的值

實務上最常用到的型別註解,不是上面那些容器,而是「這個值可能有,也可能是 None」。比如依訂單編號查找,查不到的時候回傳 None:

def find_order(order_id: str, orders: list[Order]) -> Order | None:
    for o in orders:
        if o.order_id == order_id:
            return o
    return None


orders = [
    Order('A001', '鍵盤', '周邊', 2, 690.0),
    Order('A002', '滑鼠', '周邊', 3, 350.0),
]
found = find_order('A002', orders)
if found is not None:
    print(found.product)
滑鼠

Order | None 讀作「這個值要嘛是 Order,要嘛是 None」。呼叫的人(或編輯器)一看回傳值型別就知道必須先判斷 None 再往下用,不會漏掉查無此訂單的情況。

X | None 這個直接用 | 寫聯集型別的語法,是 PEP 604 從 Python 3.10 開始支援的。3.10 之前要寫 from typing import Optional 再用 Optional[Order],意思完全一樣。typing.Optional 沒有被正式棄用,但目前 Python 社群與主流風格指南對 3.10 以上的專案都建議改用 X | None——更短,也不用額外 import。用 X | Y 表達「這個值可能是 X 型別、也可能是 Y 型別」(不只限定 None)也是同樣的語法,例如 int | str。

型別別名:讓一長串型別有個好名字

型別寫著寫著會愈疊愈長。像「商品名稱對應到銷售總額」這種 dict[str, float],在整支腳本裡如果要出現五六次,每次都完整打一遍既囉唆又容易打錯。這時候可以幫它取個名字:

ProductTotals = dict[str, float]


def product_totals(orders: list[Order]) -> ProductTotals:
    totals: ProductTotals = {}
    for o in orders:
        totals[o.product] = totals.get(o.product, 0.0) + o.quantity * o.unit_price
    return totals


def print_report(totals: ProductTotals) -> None:
    for product, amount in totals.items():
        print(f'{product}:{amount:.0f} 元')


orders = [
    Order('A001', '鍵盤', '周邊', 2, 690.0),
    Order('A002', '滑鼠', '周邊', 3, 350.0),
]
print_report(product_totals(orders))
鍵盤:1380 元
滑鼠:1050 元

ProductTotals = dict[str, float] 這一行本身是一般的變數賦值,Python 3.9 以上就能這樣寫,讀起來也直覺:ProductTotals 就是 dict[str, float] 的另一個名字。如果專案用的是 Python 3.12 以上,還多一個專門為型別別名設計的語法(PEP 695):

type ProductTotals = dict[str, float]

多了 type 這個關鍵字,行為上跟賦值版本差不多,但語意上更明確地表達「這是一個型別別名,不是一般變數」。兩種寫法 mypy 都認得,用哪一種看專案的最低支援版本。

實際跑 mypy:讓它在執行前抓 bug

安裝很單純,跟裝任何套件一樣:

pip install mypy

裝好之後對著檔案跑:

mypy sales_report.py

先看一支完整標好型別、邏輯也正確的版本會發生什麼事。把前面幾個函式收進同一支 sales_report.py,Order 也換成前幾篇定案的完整版本——多了 category(商品分類)跟有預設值的 status,剛好也讓「依分類統計」這個新函式 category_totals 有東西可用,用的一樣是 dict[str, float]:

"""銷售資料整理腳本 — 核心函式(已補上完整型別註解)"""

from dataclasses import dataclass
from collections import defaultdict

ProductTotals = dict[str, float]


@dataclass
class Order:
    order_id: str
    product: str
    category: str
    quantity: int
    unit_price: float
    status: str = 'pending'


def calc_total(orders: list[Order]) -> float:
    """全部訂單的銷售總額"""
    return sum(o.quantity * o.unit_price for o in orders)


def total_quantity(orders: list[Order]) -> int:
    """全部訂單的總銷售件數,沒有訂單就回傳 0"""
    if not orders:
        return 0
    return sum(o.quantity for o in orders)


def product_totals(orders: list[Order]) -> ProductTotals:
    """依商品名稱分組,加總每個商品的銷售額"""
    totals: ProductTotals = defaultdict(float)
    for o in orders:
        totals[o.product] += o.quantity * o.unit_price
    return dict(totals)


def category_totals(orders: list[Order]) -> dict[str, float]:
    """依商品分類分組,加總每個分類的銷售額"""
    totals: dict[str, float] = defaultdict(float)
    for o in orders:
        totals[o.category] += o.quantity * o.unit_price
    return dict(totals)


def find_order(order_id: str, orders: list[Order]) -> Order | None:
    """依訂單編號查找,找不到回傳 None"""
    for o in orders:
        if o.order_id == order_id:
            return o
    return None


def order_quantities(orders: list[Order]) -> tuple[int, ...]:
    """每筆訂單的購買數量,包成一個 tuple"""
    return tuple(o.quantity for o in orders)


if __name__ == "__main__":
    sample_orders = [
        Order("A001", "鍵盤", "周邊", 2, 690.0),
        Order("A002", "滑鼠", "周邊", 3, 350.0),
        Order("A003", "鍵盤", "周邊", 1, 690.0),
        Order("A004", "螢幕", "顯示設備", 1, 4200.0),
    ]

    print("銷售總額:", calc_total(sample_orders))
    print("銷售件數:", total_quantity(sample_orders))
    print("分商品金額:", product_totals(sample_orders))
    print("分類金額:", category_totals(sample_orders))
    print("查訂單 A002:", find_order("A002", sample_orders))
    print("各筆數量:", order_quantities(sample_orders))

跑 mypy:

$ mypy sales_report.py
Success: no issues found in 1 source file

乾淨過關。實際執行 python sales_report.py 也如預期:

銷售總額: 7320.0
銷售件數: 7
分商品金額: {'鍵盤': 2070.0, '滑鼠': 1050.0, '螢幕': 4200.0}
分類金額: {'周邊': 3120.0, '顯示設備': 4200.0}
查訂單 A002: Order(order_id='A002', product='滑鼠', category='周邊', quantity=3, unit_price=350.0, status='pending')
各筆數量: (2, 3, 1, 1)

find_order 印出來的 Order 最後多了 status='pending'——這是建構 Order 時沒傳 status,自動套用了預設值,跟型別註解沒有直接關係,但看到這欄位補上了也順便確認一下:有預設值的欄位一定要放在 dataclass 定義的最後面,跟一般函式「有預設值的參數要放沒有預設值的參數後面」是同一條規則。

現在故意改壞兩個地方,模擬「改程式改到一半漏看」的真實情況:

def total_quantity(orders: list[Order]) -> int:
    """全部訂單的總銷售件數,沒有訂單就回傳 0"""
    if not orders:
        return None          # 改壞①:宣告回傳 int,這條分支卻回傳 None
    return sum(o.quantity for o in orders)
    print("銷售總額:", calc_total(sample_orders, "打折方案"))   # 改壞②:多傳了一個不存在的參數

再跑一次 mypy:

$ mypy sales_report.py
sales_report.py:27: error: Incompatible return value type (got "None", expected "int")  [return-value]
sales_report.py:68: error: Too many arguments for "calc_total"  [call-arg]
Found 2 errors in 1 file (checked 1 source file)

兩個錯誤都是執行前就抓到的,程式一次都還沒真的跑。第一個錯誤精準指出:total_quantity 宣告回傳 int,但空清單那條分支回傳的是 None——這正是「宣告回傳型別跟某條分支回傳的東西對不上」的典型例子,這種 bug 如果沒被抓到,會等到某天訂單真的是空的、下游程式碼把回傳值拿去做加法或格式化時,才炸出 TypeError。第二個錯誤是呼叫端多傳了一個 calc_total 根本沒有的參數,這種手滑在函式簽名一長就很容易發生,mypy 直接照函式定義比對呼叫端,一個字一個字算清楚。

再對照一種情況:如果函式從頭到尾都沒有型別註解,mypy 還會抓到東西嗎?

def calc_total(orders):
    return sum(o.quantity * o.unit_price for o in orders)


print(calc_total('not really a list of orders'))
$ mypy no_hints.py
Success: no issues found in 1 source file

這支程式一執行就會噴 AttributeError(字串沒有 .quantity 屬性),但 mypy 完全沒意見。原因是 calc_total 沒有任何型別註解,mypy 預設就不會去檢查它的內部邏輯,等於這個函式被排除在檢查範圍之外。型別註解不只是文件,它是 mypy 檢查的必要輸入:函式沒標,等於自願放棄這層保護。

編輯器體驗也會跟著變準

裝了型別註解之後不只 mypy 受惠,VSCode 這類編輯器內建的語言伺服器(Pylance)也會讀同一份資訊。orders: list[Order] 標好之後,在函式內打 o. 會直接列出 Order 有哪些欄位可選,不用切回去翻 dataclass 定義;呼叫 calc_total(...) 時,把游標停在括號裡也會即時顯示這個參數該傳什麼型別。沒有型別註解時,編輯器只能靠猜(型別推論在簡單情況還行,複雜一點就常常猜錯或直接放棄),自動完成的準確度差很多。

不要過度標註

看完前面這些語法,容易矯枉過正,變成每一行都要標。短腳本裡像 i = 0、name = '鍵盤' 這種一眼看出型別的區域變數,標了註解只是多打字,沒有額外的資訊量。把力氣花在函式的參數與回傳值、還有跨函式傳來傳去的資料結構(像這篇的 Order、ProductTotals)上——這些才是「讀函式簽名就該看懂在幹嘛」的地方,也是型別註解真正發揮作用、mypy 真正能幫上忙的地方。

小結

  • 型別註解不影響執行——Python 直譯器不檢查,錯照樣能跑,要靠 mypy 這類工具在執行前把關
  • 容器型別用內建寫法:list[Order]、dict[str, float]、tuple[int, ...](Python 3.9 起,舊寫法是 typing.List/typing.Dict)
  • 可有可無的值用 X | None(Python 3.10 起,舊寫法是 typing.Optional[X])
  • 型別太長就取別名:ProductTotals = dict[str, float](3.12 以上也可以用 type ProductTotals = dict[str, float])
  • 沒有型別註解的函式,mypy 沒有依據可以檢查——標註解本身就是打開這層保護的開關
  • 重點標在函式介面與資料結構,不必每個變數都標

回到文章最開頭那個看不出 orders 是什麼的 calc_total——現在你已經有能力把它、還有前面幾篇整理出來的 Order dataclass、product_totals 這些函式,全部補上型別註解,再跑一次 mypy 確認整支腳本型別一致。如果讀完《Python 函式 def 入門》覺得這篇的參數、回傳值語法很眼熟,那是因為型別註解本來就是疊加在你已經會的函式語法上,不是另一套新東西。

有了型別註解跟 mypy 站在前面把關,下一個問題自然是:邏輯本身到底對不對?型別檢查只能保證「型別對得上」,不保證「算出來的數字是對的」——這就要靠寫測試,也是系列下一篇要談的東西。

這篇提到的函式與 dataclass 設計,在「Python 基礎入門」課程裡也有對應的練習可以跟著做。


*本文程式碼與 mypy 輸出實際在 Python 3.14.3、mypy 2.3.1(2026 年 8 月發布,透過 pip install mypy 安裝)跑過驗證。list[str] 等內建泛型語法(PEP 585)需要 Python 3.9 以上;X | None 聯集語法(PEP 604)需要 Python 3.10 以上;型別別名的 type 陳述式(PEP 695)需要 Python 3.12 以上。*

參考資料: - PEP 585 – Type Hinting Generics In Standard Collections - PEP 604 – Allow writing union types as X | Y - mypy — Getting started

相關課程

Python 基礎入門

從零開始學習 Python 程式語言,掌握變數、流程控制、函式與模組化設計,建立扎實的程式設計基礎。

查看課程內容