Python 進階主題與實務應用:Descriptor / Property
簡介
在 Python 中,**屬性(attribute)**的存取看似簡單,卻隱藏了強大的機制——descriptor 和 property。
它們讓開發者可以在讀取、寫入或刪除屬性時,自動執行額外的驗證、轉換或快取等邏輯,從而把「資料」與「行為」更乾淨地分離。
在大型專案或框架中,合理使用 descriptor / property 不但能提升程式的可維護性,還能避免重複的驗證程式碼,讓 API 更具表意性。本篇文章將從概念說明、實作範例,到常見陷阱與最佳實踐,完整帶你掌握這兩個進階工具的使用方式。
核心概念
1. Descriptor 基礎
在 Python 的資料模型中,任何實作了 __get__、__set__、__delete__ 其中之一的物件,都稱為 descriptor。當 descriptor 被放在類別屬性上時,Python 會在屬性存取的過程中自動呼叫對應的方法。
| 方法 | 何時被呼叫 | 典型用途 |
|---|---|---|
__get__(self, instance, owner) |
讀取屬性時(obj.attr) |
取得值、計算屬性、快取 |
__set__(self, instance, value) |
寫入屬性時(obj.attr = v) |
驗證、轉型、觸發事件 |
__delete__(self, instance) |
刪除屬性時(del obj.attr) |
清理資源、移除快取 |
小提醒:
instance為屬性所屬的實例,若透過類別本身 (Class.attr) 存取,instance會是None。
範例 1:最簡單的只讀 descriptor
class ReadOnly:
def __init__(self, value):
self._value = value
def __get__(self, instance, owner):
return self._value
class Demo:
const = ReadOnly(3.14)
print(Demo.const) # 3.14
d = Demo()
print(d.const) # 3.14
# d.const = 2 # AttributeError: can't set attribute
說明:ReadOnly 只實作 __get__,因此屬性變成唯讀。
2. Property:descriptor 的便利封裝
property 本質上是一個 內建的 descriptor,提供了更友善的語法糖。使用 @property、@<prop>.setter、@<prop>.deleter 裝飾器,我們可以把 getter、setter、deleter 分別寫在同一個屬性名稱下。
範例 2:使用 property 實作溫度單位轉換
class Temperature:
def __init__(self, celsius: float):
self._c = celsius # 真正儲存的私有屬性
@property
def celsius(self) -> float:
"""取得攝氏溫度"""
return self._c
@celsius.setter
def celsius(self, value: float):
if value < -273.15:
raise ValueError("溫度低於絕對零度!")
self._c = value
@property
def fahrenheit(self) -> float:
"""自動把攝氏轉成華氏"""
return self._c * 9 / 5 + 32
@fahrenheit.setter
def fahrenheit(self, value: float):
self.celsius = (value - 32) * 5 / 9 # 重新利用 celsius setter
t = Temperature(25)
print(t.fahrenheit) # 77.0
t.fahrenheit = 212
print(t.celsius) # 100.0
說明:celsius 與 fahrenheit 共享同一個底層儲存 _c,但對外提供 不同的介面,同時保有驗證邏輯。
3. 自訂 Descriptor:資料驗證與快取
在實務上,我們常需要 在每次寫入時驗證資料型別或範圍,或是 把計算結果快取起來,這時自訂 descriptor 會比單純的 property 更靈活。
範例 3:型別驗證的 TypedDescriptor
class Typed:
"""接受一個目標型別,於 __set__ 時自動驗證"""
def __init__(self, name: str, expected_type: type):
self.name = name
self.expected_type = expected_type
def __get__(self, instance, owner):
return instance.__dict__[self.name]
def __set__(self, instance, value):
if not isinstance(value, self.expected_type):
raise TypeError(f"{self.name} 必須是 {self.expected_type.__name__}")
instance.__dict__[self.name] = value
def __delete__(self, instance):
del instance.__dict__[self.name]
class Person:
name = Typed('name', str)
age = Typed('age', int)
p = Person()
p.name = "Alice"
p.age = 30
# p.age = "thirty" # TypeError: age 必須是 int
說明:Typed 把 型別檢查 的邏輯抽離成可重複使用的 descriptor,讓類別本身保持簡潔。
4. Lazy Property:延遲計算與快取
有時候屬性值的計算成本很高,卻不一定會被使用。Lazy Property 在第一次存取時才計算,之後直接返回快取結果。
範例 4:實作 lazy_property 裝飾器
class lazy_property:
"""一次計算,多次快取的 descriptor"""
def __init__(self, func):
self.func = func
self.attr_name = f'_{func.__name__}_cached'
def __get__(self, instance, owner):
if instance is None:
return self
if not hasattr(instance, self.attr_name):
setattr(instance, self.attr_name, self.func(instance))
return getattr(instance, self.attr_name)
class Data:
@lazy_property
def heavy_computation(self):
print("執行昂貴的計算…")
return sum(i * i for i in range(10_000))
d = Data()
print(d.heavy_computation) # 第一次會印出「執行昂貴的計算…」
print(d.heavy_computation) # 之後直接回傳快取值,不再印訊息
說明:lazy_property 只在第一次讀取時呼叫原始函式,之後直接使用已儲存的結果,對於 IO 密集 或 計算密集 的屬性非常有用。
5. Descriptor 與 __slots__ 的配合
使用 __slots__ 可以減少物件的記憶體佔用,但同時會限制動態屬性的加入。若想在 __slots__ 類別中仍保留 descriptor 的彈性,需要把 descriptor 放在類別層級,而非實例字典。
class SlotDemo:
__slots__ = ('_x',) # 只允許 _x 這個欄位
y = Typed('y', int) # descriptor 仍然可以使用
def __init__(self, x):
self._x = x
s = SlotDemo(5)
s.y = 10
# s.z = 1 # AttributeError: 'SlotDemo' object has no attribute 'z'
說明:y 仍然是 descriptor,因為它不依賴 __dict__,所以與 __slots__ 完美共存。
常見陷阱與最佳實踐
| 陷阱 | 可能的後果 | 建議的解法 |
|---|---|---|
在 __get__ 中直接返回 self |
會導致屬性存取變成 descriptor 本身,無法取得實際值 | 必須根據 instance 判斷,通常返回 instance.__dict__[self.name] |
忘記在 __set__ 中更新實例的字典 |
寫入後屬性仍舊保持舊值,或拋出 KeyError |
使用 instance.__dict__[self.name] = value 或自訂儲存策略 |
| 在 property setter 中直接改寫底層屬性,忽略其他 setter 的驗證 | 產生不一致的狀態 | 讓其他屬性 setter 呼叫相同的驗證函式,或把驗證抽成獨立 descriptor |
在 lazy_property 中使用可變物件作為快取值 |
多執行緒環境下可能產生 race condition | 使用 threading.Lock 包裹快取建立,或改用 functools.lru_cache |
| 過度使用 descriptor,導致類別過於複雜 | 可讀性下降,除錯困難 | 僅在需要跨多個類別共用或需要額外控制時才抽離成 descriptor,否則使用 property 即可 |
最佳實踐
- 保持單一職責:descriptor 應只負責「控制存取」的工作,其他業務邏輯盡量放在普通方法或服務層。
- 使用
functools.wraps(或自訂裝飾器)保留原函式的__doc__與__name__,提升自動產生文件的品質。 - 測試邊界條件:尤其是
__set__的驗證,應寫單元測試確保錯誤訊息清晰且不會意外通過。 - 文件化:在類別說明中標註哪些屬性是 descriptor,讓使用者一眼就能知道它們的特殊行為。
實際應用場景
| 場景 | 為何使用 descriptor / property |
|---|---|
| 資料模型的欄位驗證(如 ORM、Pydantic) | 把型別、範圍、正則驗證抽成 reusable descriptor,減少模型類別的樣板程式碼。 |
| 計算屬性快取(如圖形座標、統計值) | lazy_property 可以在首次存取時計算,之後直接返回,提升效能。 |
| 跨模組的共用設定(如全域配置) | 用 descriptor 把設定寫入內部字典或檔案,同時提供即時驗證與變更通知。 |
| 安全敏感屬性(如 密碼、金額) | 在 __set__ 內自動加密或格式化,__get__ 只返回掩碼,避免資訊外洩。 |
| 自訂容器類別(如 2D 向量、時間序列) | 透過 descriptor 實作 x, y、start, end 等屬性,使其行為與內部資料結構解耦。 |
案例:假設開發一個簡易的金融交易系統,所有金額欄位必須以 Decimal 儲存且四捨五入到兩位小數。使用
TypedDecimaldescriptor 可以一次解決型別、精度與驗證問題,避免在每個模型裡重複寫Decimal(value).quantize(...)。
from decimal import Decimal, ROUND_HALF_UP
class TypedDecimal:
def __init__(self, name):
self.name = name
def __get__(self, instance, owner):
return instance.__dict__[self.name]
def __set__(self, instance, value):
d = Decimal(value).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
if d < 0:
raise ValueError(f"{self.name} 不能為負數")
instance.__dict__[self.name] = d
class Trade:
amount = TypedDecimal('amount')
price = TypedDecimal('price')
t = Trade()
t.amount = '123.456' # 自動轉成 Decimal('123.46')
t.price = 9.99
print(t.amount, t.price) # 123.46 9.99
總結
Descriptor 與 property 是 Python 物件導向中 隱藏卻強大的魔法。
- Descriptor 提供最底層的屬性控制介面,適合跨類別共用或需要自訂
__delete__的情境。 - property 則是更易讀、易寫的封裝,適合單一類別內的簡單 getter/setter。
透過本篇的概念說明、實作範例與最佳實踐,你應該已能在日常開發中自行判斷何時使用 descriptor,何時直接以 property 完成需求。將驗證、快取、轉型等通用邏輯抽離成 descriptor,不僅能 提升程式碼可讀性,還能 降低錯誤率,在大型專案中更顯其價值。
快把這些工具加入你的程式庫,讓 Python 的物件模型發揮出更大的威力吧! 🚀