用Python Typing提升程式碼的可維護性: 從基本標註到泛型標註
自1991年Python問世以來,簡潔明了的語法與靈活性使其快速地成為最受歡迎的動態類型語言之一。從資料處理、網絡開發到現在的AI模型開發,都是Python被大幅採用的領域。然而,隨著項目規模複雜性的增加,Python動態類型的特性也成為一把雙刃劍。在這種背景下,Python的Typing系統—類型標註 (Type Hinting) 的引入,成為了提升程式碼質量和可維護性的關鍵。
Python Typing可以明確指定變數和函數的期望類型,這提升了程式碼的可讀性和清晰度,減少了在開發和維護過程中的不確定性。再搭配靜態分析工具的功能,能夠提供更精確的提示和錯誤檢查。然而,實踐的關鍵挑戰是如何在保持Python本身的動態特性和靈活性的同時,合理地應用類型標註。
文章難度:★★☆☆☆
閱讀建議: 這篇文章會介紹Python Typing系統的核心操作,包括基本的型態標注與泛型,後續再針對如何合理地標註並保持靈活性上給出一些實作建議。在基礎知識上要先對Python語法有一定程度認知,並且具備物件導向程式設計的知識。如果對於型態標註已經有一定認識的人,可以直接跳到泛型標註或是實踐建議
推薦背景知識:Python, Python typing, dynamic programming language, object-oriented programming, generic programming.
Python Typing (類型標註)
Python一直被認為是一種動態類型語言,這意味著在寫程式碼時不一定要預先設定變數的類型,而是在程式碼運行時才確定變數的類型。這種靈活性是Python非常受歡迎的原因之一,但也帶來了某些挑戰,特別是在大型或復雜的程式開發中。在這些情況下,缺乏類型信息可能導致難以debug、程式碼不易理解、或需要重構時風險也較高。
引入類型標註 (Type Hinting) [1] 是對這個問題的一種解決方案。從Python 3.5開始,Python支持在程式碼中添加類型標註。這些標註不會影響程序的運行,但它們可以被類型檢查器、IDE和其他工具用來進行更好的靜態分析。這樣做的好處包括提高程式碼的可讀性、易於維護、更早地發現錯誤等。
在Python 3.5之前,Python沒有官方的類型標註系統。開發者通常通過註釋、文檔或命名約定來指示變數類型。Python 3.5引入了
typing
模塊,開始支持類型標註。這一變化代表了Python社群對於提高程式碼質量和可維護性的認可和努力。
基本類型標註
在深入了解如何在Python中使用類型標註之前,以下先從基礎開始,了解一些基本概念和標註方法。
標註整數、浮點數、字符串
類型標註最基本的形式是為變數指定一個類型。例如,如果你有一個整數變數,你可以這樣標註:
number: int = 5
這裡,: int
告訴任何閱讀這段程式碼的人(或類型檢查器)number
應該是一個整數。同樣地,對於浮點數和字符串,標註方法如下:
price: float = 19.99
name: str = "Alice"
標註其他Python內建的型別: list / dict / tuple / set
對於其他Python內建的型別,如list
、dict
、tuple
和set
,在Python 3.9以上的版本,可以直接使用原本的context進行標註。在Python 3.7以上,則可以在任何.py檔的開頭導入from __future__ import annotations
來實現這樣的支援。
names: list[str] = ["Alice", "Bob", "Charlie"]
ages: dict[str, int] = {"Alice": 30, "Bob": 28}
coordinates: tuple[float, float] = (35.6895, 139.6917)
unique_numbers: set[int] = {1, 2, 3}
- List標註為
list[ElementType]
,不管裡面的數值個數。 - Tuple標註為
tuple[Type1, Type2, ...]
,通常會標出數目與個別型別。 - Dict標註為
dict[KeyType, ValueType]
,key與value的型別會個別標註。 - Set標註為
set[ElementType]
,不管裡面的數值個數。
除了這幾個常見的型別,在collections裡常用的型別,如
deque
、OrderedDict
也都可以這樣直接標註,詳細可參考Generic Alias Type [2]。
至於更舊的的版本 (包含Python 3.6以下),就要使用typing
模塊提供了專門的類型標註方法。
from typing import List, Dict, Tuple, Set
names: List[str] = ["Alice", "Bob", "Charlie"]
ages: Dict[str, int] = {"Alice": 30, "Bob": 28}
coordinates: Tuple[float, float] = (35.6895, 139.6917)
unique_numbers: Set[int] = {1, 2, 3}
這邊要特別注意,雖然Python 3.7以上都可以使用Generic Alias Type來進行標註,但是有許多常用框架仍然會需要使用到typing裡的標註,常見的例子之一就是pydantic [3]。
多種類型與None
當變數可以有多種類型或甚至包含是None
時,就需要考慮到聯集與None
的標註語法。在Python 3.10以上可以使用|
來快速實現聯集。或是在Python 3.7以上,則可以在任何.py檔的開頭導入from __future__ import annotations
來實現一樣的支援。
def double(number: int | float) -> float:
return number * 2
如果一個函數參數可以是str
或None
,可以這樣標註:
def greet(name: str | None = None):
if name is not None:
print(f"Hello, {name}!")
else:
print("Hello, world!")
一樣的,更舊的的版本 (包含Python 3.6以下),就要使用typing
模塊提供了專門的類型標註方法Optional
與Union
。
from typing import Optional, Union
def greet(name: Optional[str] = None):
if name is not None:
print(f"Hello, {name}!")
else:
print("Hello, world!")
def double(number: Union[int, float]) -> float:
return number * 2
任何型態Any
當不確定變數的類型,或者它可以是任意類型時,可以使用Any
,這被廣泛地使用在args與kwargs上。但實際上,其他情況應該盡量避免使用Any
,因為它會失去類型標註的大部分優點。
from typing import Any
def log_data(*args: Any, **kwargs: Any) -> None:
for arg in args:
print(arg)
for key, value in kwargs.items():
print(f"{key}: {value}")
可調用Callable
Callable
是用於標註可調用對象(如函數)的類型。例如,如果有一個接受函數作為參數的高階函數,可以這樣標註:
from typing import Callable
def apply_function(f: Callable[[int], str], x: int) -> str:
return f(x)
自定義類型和別名
自定義類型允許你為現有的類型定義一個新名稱。這在希望為特定的應用案例提供更具描述性的類型名稱或是大量重複使用的長標注時非常有用。
from typing import List, Tuple
Coordinates = Tuple[float, float]
CoordinateList = List[Coordinates]def draw_line(coords: CoordinateList) -> None:
pass
在上面這個範例中,Coordinates
和CoordinateList
是自定義的類型別名,分別代表單個坐標和坐標列表。
進階類型標註
在深入探索Python的類型標註系統時,不可避免地會遇到一些繁雜的概念。這些多半是一些進階語法的標注方法,比如一些容易混淆的標註類型或是泛型 (generic) 的標註。
區分並正確使用 Tuple, List, Sequence
tuple
跟list
這兩種資料,在Python中有時確實是是容易被誤用的。其實這兩個資料結構最大的差別在於可變性 (immutable)。為了能夠進行正確地標註,當然在寫程式碼時使用正確的結構是最基本的,不過有時為了更好地標註,需要採用特殊的Sequence
語法進行標註。
首先,tuple
是一種不可變的容器,適合於存儲一組固定數量且可能類型不同的元素,它的不變性賦予了數據一種安全和穩定性。例如,在函數返回多個值或表示經緯度等固定數據組合時,tuple
是理想的選擇。
point: tuple[int, int] = (10, 20)
相對於tuple
的靜態特性,list
則提供了更大的靈活性。作為一種可變的容器,list
允許增加、刪除或修改其元素,使其成為處理動態數據集合的理想選擇。無論是處理一系列同類型的數據,還是簡單地追加元素到列表,List
都能輕鬆應對。
names: list[str] = ["Alice", "Bob", "Charlie"]
而Sequence
則是一個更為廣泛的概念,它可以包含任何有序的元素集合,包括list
和tuple
。在不確定或不想限制具體序列類型的情況下,使用Sequence
作為類型標註可以增加函數或方法的靈活性和泛用性。它允許開發者編寫更加通用的程式碼,能夠處理多種不同的序列類型。
from typing import Sequence
def process_sequence(seq: Sequence[int]) -> None:
pass
簡單來說,大概念是這樣的:
tuple
用於固定長度和不同類型的元素集合。list
用於可變長度,同類型元素的集合。Sequence
表示不可變序列,通常用於函數參數以確保數據不會被修改。同時在泛型編程中扮演著橋樑的角色,連接著各種序列類型。
泛型標註 (Generic)
泛型程式設計 (generic programming) [4] 是程式設計語言的一種風格或形式。泛型允許程式設計師在強型別程式設計語言中編寫程式碼時使用一些以後才指定的類型,在實例化時作為參數指明這些類型。
使用泛型的優勢在於它們提供了極大的靈活性。你可以創建一個框架,它可以與多種不同的數據類型一起工作,而不需要為每一種數據類型重寫程式碼。這不僅使程式碼更加乾淨、更易於理解和維護,而且還增強了的重複利用的可能性。
Python本身作為一門動態類型語言,天然支持一種形式的泛型編程。在Python中,不需要事先指定變數或函數參數的具體類型,這使得相同的函數可以接受不同類型的參數,這種特性提供了天然的泛型支持。
不過隨著提升類型安全與程式碼清晰性的Typing機制導入。也自然在標註上產生了一些額外的知識需要理解。
TypeVar:創建泛型變量
在Python Typing中,TypeVar
(泛型變量) 允許在聲明類型時不指定具體的類型,而是保留一個位置,以後可以用具體的類型來填充。這對於創建可重用的函數和類非常有用。
想像以下這個function,單純的輸入一個list,並回傳list的第一個值。
def first(my_list):
return my_list[0]
因為list的內容本身就可以接受泛型變量,因此在原始Python編碼時,這是一個很直覺的function。但是typing時,return的數值就會變得難以標註。
在這個情況,使用TypeVar
可以正確標註地標註泛型函數。在這裡,T
就是一個泛型變量,可以在後續的類型標註中使用。
from typing import TypeVar
T = TypeVar('T')
def first(my_list: Sequence[T]) -> T:
return my_list[0]
在理解TypeVar
時,有時TypeVar
與Union
的採用是容易混淆的。
TypeVar
的適用情況:希望函數或類對於所有參數和返回值保持類型上的一致性時,應該使用TypeVar
。它常用於泛型編程,特別是當你希望同一類型的多個值能夠以同樣的方式處理時。Union
的適用情況:當一個參數或返回值可以屬於幾個不相關的類型時,使用Union
。它用於表示多種可能性,而不強調類型之間的關聯。
Generic: 創建泛型類
TypeVar
用於聲明類型時不指定具體的類型,而Generic
則用於創建,或者說標註泛型類 (generic class)。對於許多人來說,這可能不是一個很常見與直覺的做法,但當今天class中的泛型需要型態告知時,這又是不可避免的做法。
當創建一個class時,使其繼承自Generic[T]
,其中T
是一個或多個TypeVar
實例,這時候創建的類別就是泛型類。
from typing import TypeVar, Generic
T = TypeVar("T")
class DataStore(Generic[T]):
def __init__(self):
self.storage: list[T] = []
def add_item(self, item: T) -> None:
self.storage.append(item)
def get_item(self, index: int) -> T:
return self.storage[index]
string_store = DataStore[str]()
想像一下如果不使用標註
Generic[T]
來標註,雖然self.storage
已經被標註為list[T]
了,但不論是其他協作者或是靜態檢查器都還是會難以區分現在丟進self.storage
裡的型態是否為合理的。
泛型類的繼承
繼承泛型類並正確標註新的類別可以讓class更具表達力且安全。當一個泛型類被繼承時,子類可以繼承父類的泛型參數,或者選擇提供具體的類型。
class StringDataStore(DataStore[str]):
pass
string_store = StringDataStore()
在這個例子中,StringDataStore
繼承自DataStore
並指定了T
為str
。這樣,所有與storage
相關的操作都將被限制為僅適用於字符串。
易混淆的情境: 以工廠模式為例
在使用泛型類和工廠模式時,容易混淆的一個情境是確保工廠方法返回正確的類型實例。特別是在繼承層次 (Hierarchical Inheritance) 時,確保每個子類的工廠方法返回該子類的實例,而不是基類的實例。
考慮以下使用泛型類的工廠模式例子:
class Base(Generic[T]):
@classmethod
def build(cls: Type[T]) -> T:
return cls()
class Derived1(Base["Derived1"]):
pass
class Derived2(Derived1):
pass
base_instance = Base.build() # base_instance被標註為Base的實例
d1_instance = Derived1.build() # d1_instance被標註為Derived1的實例
d2_instance = Derived2.build() # d1_instance被錯誤地標註為Derived1的實例
在這個例子中,雖然Derived1
有正確地利用泛型類並用Base[“Derived1”]
標註,但繼承自Derived1
的Derived2
就無法直接進行標註。這時候一個方法就是用多重繼承,但單純為了標註就進行多重繼承是很不合比例原則的狀態。
class Derived2(Derived1, Base["Derived2"]):
pass
這個問題其實要回到根本思考,什麼東西需要被設計為一個泛型類。實際上,在這個範例來說,其實根本不需要讓它是一個泛型類。最簡潔的做法就是單純使用TypeVar
標註self
或是cls
,不需要使用Generic
建立泛型類。
class Base:
@classmethod
def build(cls: Type[T]) -> T:
return cls()
class Derived1(Base):
pass
class Derived2(Derived1):
pass
base_instance = Base.build() # base_instance被標註為Base的實例
d1_instance = Derived1.build() # d1_instance被標註為Derived1的實例
d2_instance = Derived2.build() # d1_instance被標註為Derived2的實例
在Python 3.11後,也可以使用
typing
裡的Self
進行標註。不過這個方法即使使用from __future__ import annotations
,在舊版的Python或IDE有時還是會遇到問題,因此如果Python版本不到還是建議用TypeVar
標註。
類型檢查器
雖然Python是動態類型語言,但類型標註提供了一種機制來進行靜態類型檢查,這也讓類型檢查器可以好好發揮。
簡介幾種常見的類型檢查器
- mypy [5]: 最流行的Python靜態類型檢查器之一,可以通過命令行工具使用。
- pyright [6]: 由Microsoft開發,是一個快速的類型檢查器,常用於Visual Studio Code。
檢查器的使用: 以mypy為例
與black一樣,mypy可以直接通過pip安裝。
pip install mypy
安裝後就可以在command line中運行mypy來進行程式碼的靜態檢查。
mypy path/to/your/script.py
配置文件
mypy
支持使用配置文件mypy.ini
或.mypy.ini
來定制檢查設置,可以控制mypy
的各種行為,例如忽略某些文件夾,選擇性地啟用、禁用特定的檢查與嚴格度等。
[mypy]
ignore_missing_imports = True
strict_optional = True
warn_return_any = True
自動化檢查
當然,像mypy
這樣的靜態檢查器,最好還是與pre-commit hook [7]或是CI流程綁定,自動化地對程式碼進行靜態檢查。比如說,把以下的設定加入到.pre-commit-config.yaml
,就可以在commit時觸發靜態檢查。
- repo: https://github.com/pre-commit/mirrors-mypy
rev: '' # Use the sha / tag you want to point at
hooks:
- id: mypy
Python類型標註的實踐建議
在針對Python進行類型標註時,個人認為很重要的一個觀念是標注時要符合比例原則。因為Python本身具備良好的靈活性,動態建立的instance具備極為良好的擴展性。但反之這也是程式碼難以debug與閱讀的根本原因之一 (跟JavaScript有點像),而適當地使用類型標註就是為了在動態型別中與靜態標註間取得一個適當的平衡點。
型態標註的核心精神是合理標註,勿揠苗助長。
適當地使用動態類型
儘管類型標註增加了程式碼的明確性,但在某些情況下,保持Python的動態類型特性是有益的。例如,當處理多種數據類型的通用函數時,過於嚴格的類型標註可能限制了函數的靈活性。
在這些情況下,可以使用較為寬鬆的類型標註,例如使用Any
,或者根據上下文使用Union
類型。重要的是要找到類型標註的明確性和程式碼靈活性之間的平衡。
有效地使用類型標註
當需要進行標註時,個人認為漸進式、一致性、明確與簡潔的平衡、謹慎使用寬鬆標註則是標註的訣竅。
- 漸進式採用: 如果你正在現有項目中引入類型標註,可以逐步進行,不需要一次完成所有文件。
- 一致性: 在整個項目中保持類型標註的一致性非常重要。這不僅有助於可讀性,還有助於維護和測試。
- 明確性與簡潔性的平衡: 良好的類型標註應該提供足夠的信息,同時避免過於冗長或複雜。
- 謹慎使用寬鬆標註: 盡管
Any
在某些情況下是有用的,但過度使用會削弱類型標註的優點。
提高可讀性和維護性的技巧
- 結合文檔: 類型標註應該與函數和方法的文檔一起使用,比如說sphinx [8],以提供更全面的程式碼理解。
- 使用專門的類型別名: 對於複雜的類型結構,使用類型別名可以增加程式碼的清晰度。
在當今快速變化的軟體開發環境中,保持程式碼的清晰性和可維護性變得越來越重要。Python的類型標註系統為提供了程式碼更好的控制和理解能力。無論是對於個人開發者還是團隊合作,合理地運用類型標註都能帶來顯著的好處。
不過技術和工具是為了解決問題和提高效率而存在的,類型標註如果應用得當,將極大地促進程式專案的延伸性。但仍然需要謹記於心的是,程式碼的根本永遠都是程式碼,在進行標注時不應為了標注而過於破壞開發的靈活性,這就太過於揠苗助長了。
好了~這篇文章就先到這邊。老話一句,軟體領域每年都會有大量高質量的產出與更新,說真的要跟緊不是一件容易的事。所以我的觀點可能也會存在瑕疵,若有發現什麼錯誤或值得討論的地方,歡迎回覆文章或來信一起討論 :)
Related Topics
Reference
[1] Python typing — Support for type hints [Official Doc]
[2] Python Generic Alias Type [Official Doc]
[3] Pydantic [Official Doc]
[4] Generic programming [Wikipedia]
[5] mypy [Officail Doc]
[6] pyright [Officail Doc]
[7] pre-commit [Official Doc]
[7] sphinx [Official Doc]