本文 AI 產出,尚未審核

Python 進階主題與實務應用:Descriptor / Property

簡介

在 Python 中,**屬性(attribute)**的存取看似簡單,卻隱藏了強大的機制——descriptorproperty
它們讓開發者可以在讀取、寫入或刪除屬性時,自動執行額外的驗證、轉換或快取等邏輯,從而把「資料」與「行為」更乾淨地分離。

在大型專案或框架中,合理使用 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

說明celsiusfahrenheit 共享同一個底層儲存 _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 即可

最佳實踐

  1. 保持單一職責:descriptor 應只負責「控制存取」的工作,其他業務邏輯盡量放在普通方法或服務層。
  2. 使用 functools.wraps(或自訂裝飾器)保留原函式的 __doc____name__,提升自動產生文件的品質。
  3. 測試邊界條件:尤其是 __set__ 的驗證,應寫單元測試確保錯誤訊息清晰且不會意外通過。
  4. 文件化:在類別說明中標註哪些屬性是 descriptor,讓使用者一眼就能知道它們的特殊行為。

實際應用場景

場景 為何使用 descriptor / property
資料模型的欄位驗證(如 ORM、Pydantic) 把型別、範圍、正則驗證抽成 reusable descriptor,減少模型類別的樣板程式碼。
計算屬性快取(如圖形座標、統計值) lazy_property 可以在首次存取時計算,之後直接返回,提升效能。
跨模組的共用設定(如全域配置) 用 descriptor 把設定寫入內部字典或檔案,同時提供即時驗證與變更通知。
安全敏感屬性(如 密碼、金額) __set__ 內自動加密或格式化,__get__ 只返回掩碼,避免資訊外洩。
自訂容器類別(如 2D 向量、時間序列) 透過 descriptor 實作 x, ystart, end 等屬性,使其行為與內部資料結構解耦。

案例:假設開發一個簡易的金融交易系統,所有金額欄位必須以 Decimal 儲存且四捨五入到兩位小數。使用 TypedDecimal descriptor 可以一次解決型別、精度與驗證問題,避免在每個模型裡重複寫 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 的物件模型發揮出更大的威力吧! 🚀