###### tags: `Python` `decorator` `property` # 這是什麼妖術?Python 的屬性 (property) 運作原理 當我們讀取物件內的資料時, 不論你讀取幾次, 只要你沒有變更該資料, 讀取到的結果都不會變。如果我們想要讓讀取到的資料會**隨時間或是物件內的其他資料變化**, 可以辦得到嗎? ## 使用 @property 裝飾器建立屬性 剛剛提到的需求實際上是**做不到**的, 因為資料就是資料, 沒有修改當然是不會變的, 不過 Python 提供有一種神奇的機制, 可以讓你**用讀取物件資料的語法叫用物件的方法**, 由於實際上是叫用物件方法, 所以就可以透過運算產生傳回值, 使用起來就跟讀取物件資料一樣, 但是讀取到的值卻會變化。這個機制就叫做**屬性 (property)**, 可以藉由 [`@property`](https://docs.python.org/3/library/functions.html#property) 等裝飾器來實作。 假設我們想要實作一種物件, 內含 `age` 資料, 可以告訴我們這個物件從建立到現在已經存活多少秒?為了要計算秒數, 後續的範例都預設已經匯入 `time` 模組, 因此可以叫用 `time.time()` 取得目前時間: ```python >>> time.time() 1646532639.2636657 >>> ``` 另外, 我們也希望可以在需要的時候直接設定存活時間重新計時。根據上述需求設計的類別如下: ```python >>> class C: ... def __init__(self): ... self.start = time.time() ... @property ... def age(self): ... return int(time.time() - self.start) ... @age.setter ... def age(self, new_age): ... self.start = time.time() - new_age ... >>> ``` 這個類別有幾個需要特別說明的地方: - 在 `__init__()` 中將建立物件的時間記錄下來, 之後就可以根據這個時間點計算物件的存活時間。 - `@porperty` 裝飾器則是將下一列的 `age()` 方法變成 `age` 屬性, 當我們透過 `.` 運算器讀取 `age` 屬性時, 就會自動叫用它。 - `@age.setter` 裝飾器則是讓下一列的 `age()` 變成設定 `age` 屬性的方法, 當利用指派敘述設定 `age` 屬性時, 就會自動叫用它。 我知道你心中可能還有一些疑問, 不過我們就先來看看怎們使用這個類別: ```python >>> c = C() >>> c.age 6 >>> c.age 9 >>> ``` 建立好物件後的確可以用 `物件.資料` 這樣的語法來讀取, 看起來就像是讀取物件內一般資料一樣, 而且隨著時間推移, 讀取到的存活時間的確會變長。接著試試設定存活時間: ```python >>> c.age = 0 >>> c.age 1 ``` 確實也可以像是設定物件內的一般資料那樣利用指派敘述完成, 設定後就依照新的存活時間計算。 雖然整個運作都正確, 但我們不禁疑惑起來, 這到底是什麼妖術、`@property` 施了什麼魔法?為什麼類別內寫了兩個同名的方法卻都還可以正確運作? 要解答疑惑前, 先來看看現在 `C` 類別裡的 `age` 是什麼: ```python >>> C.__dict__['age'] <property object at 0x0000018CD4B64040> >>> vars(C)['age'] <property object at 0x0000018CD4B64040> >>> ``` 咦?明明類別裡只有 `age()` 函式, 但現在 `age` 變成是 [`property` 類別](https://docs.python.org/3/library/functions.html#property)的物件了。要想了解 `property` 類別, 我們得先瞭解真正讓屬性得以運作的根本--**描述器 (descriptor)**。 ## 使用描述器 (descriptor) 建立屬性 我們之所以可以透過 `.` 運算器讀寫資料的語法叫用物件內的方法, 真正在背後搞鬼的是[**描述器 (descriptor)**](https://docs.python.org/3/howto/descriptor.html), 它負責描述透過 `.` 運算器讀寫指定名稱的資料時實際要進行的工作。 以下先以讀取資料為例, 說明如何建立描述器。描述器並不是特定類別的物件, 而是具備特定方法的物件, 對於用來讀取資料的描述器, 就必須要有 [`__get__()`](https://docs.python.org/3/reference/datamodel.html#object.__get__) 方法: ```python >>> class Age: ... def __get__(self, obj, objType=None): ... return int(time.time() - obj.start) ... >>> ``` 描述器必須搭配依附的物件使用, 當 `__get__()` 被叫用時, `obj` 就是依附的物件, `objType` 是該物件所屬的類別, 透過 `obj` 就可以存取所依附物件內的資料, 在本例中就讀取 `start` 來計算存活時間。 設計好可建立描述器的類別後, 接著就是實際要搭配描述器運作的類別: ```python >>> class D: ... age = Age() ... def __init__(self): ... self.start = time.time() ... >>> ``` 要使用描述器的類別必須在類別內放置描述器, 這樣就完成, 來試用看看: ```python >>> d = D() >>> d.age 3 >>> d.age 4 >>> d.age 6 ``` 當 `.` 運算器發現 `age` 是一個具備 `__get__()` 的描述器時, 不會直接把描述器當成運算結果, 而是叫用描述器的 `__get__()`, 以它的傳回值作為運算結果, 在本例中就會執行 `Age` 類別的 `__get__()`, 傳回物件的存活時間。 對於要用來設定資料的描述器, 就必須要具備 [`__set__()`](https://docs.python.org/3/reference/datamodel.html#object.__set__) 方法, 一旦在指派敘述中發現指派的標的是描述器時, 就會被自動叫用。例如: ```python >>> class Age: ... def __get__(self, obj, objType=None): ... return int(time.time() - obj.start) ... def __set__(self, obj, value): ... obj.start = time.time() - value ... ``` 這樣 `Age` 就會是一個負責讀寫資料的描述器, 搭配使用的類別完全不用修改: ```python >>> class D: ... age = Age() ... def __init__(self): ... self.start = time.time() ... ``` 現在除了可以讀取 `age` 以外, 也可以設定 `age` 了, 而且實際的存取工作是由描述器內的方法完成: ```python >>> d = D() >>> d.age 2 >>> d.age 9 >>> d.age = 0 >>> d.age 2 >>> d.age 3 >>> ``` 這樣我們就設計出和剛剛以 `@property` 裝飾器實作功能相同的屬性。 要特別留意的是如果實作的是唯讀的屬性, 請務必在描述器內加上 `__set__()`, 並在其內引發 [`AttributeError`](https://docs.python.org/3/library/exceptions.html#AttributeError) 例外, 否則如果進行指派, 會因為沒有 `__set__()` 方法不被視為描述器, 以一般資料處理, 就把描述器移除了。例如: ```python >>> class Age: ... def __get__(self, obj, objType): ... return int(time.time() - obj.start) ... >>> class D: ... age = Age() ... def __init__(self): ... self.start = time.time() ... >>> d = D() >>> d.age 5 >>> ``` 因為 `Age` 有 `__get__()`, 讀取時會被視為描述器, 但是 `Age` 沒有 `__set__()`, 在指派時不會被當成描述器, 就會變成在物件上新增一項名稱為 `age` 的資料: ```python >>> d.__dict__ {'start': 1646556621.0307684} >>> d.age = 0 >>> d.__dict__ {'start': 1646556621.0307684, 'age': 0} >>> d.age 0 >>> ``` 你可以看到在指派前因為 `age` 是類別內的資料, 所以在 `d` 物件的字典內並不會出現。但是在指派後 `d` 物件的字典內就出現了 `age` 項目, 此後存取 `age` 時都是讀取 `d` 物件內的 `age`, 不再是 `D` 類別內的 `age` 物件了, 因此不管讀再多次都是得到剛剛指派的 0, 原本設計的描述器就不會生效了。不過這影響的只有 `d` 物件, 若是再產生一個 `D` 類別的物件, 仍可以正常運作: ```python >>> d1 = D() >>> d1.age 7 >>> ``` 以下是唯讀屬性的正確做法: ```python >>> class Age: ... def __get__(self, obj, objType): ... return int(time.time() - obj.start) ... def __set__(self, obj, value): ... raise AttributeError("read only.") ... >>> class D: ... age = Age() ... def __init__(self): ... self.start = time.time() ... >>> d = D() >>> d.age 3 >>> d.age = 0 Traceback (most recent call last): File "<stdin>", line 1, in <module> File "<stdin>", line 5, in __set__ AttributeError: read only. >>> ``` 一旦嘗試指派新值給唯讀屬性時, 就會引發例外, 即可避免剛剛的問題了。 ## 使用 property 物件當描述器 前述的做法必須自行設計描述器, 實作上有些繁瑣, 所以 Python 提供有一個現成的類別, 叫做 [`property`](https://docs.python.org/3/library/functions.html#property), 可以協助我們快速產生描述器。只要先準備好負責讀寫屬性的方法, 再將這些方法當成引數傳入 `property` 的建構方法, 就可以產生描述器, 而且描述器內的 `__get__()` 與 `__set__()` 會幫你叫用對應的方法。請看以下的範例: ```python >>> class E: ... def __init__(self): ... self.start = time.time() ... def getAge(self): ... return int(time.time() - self.start) ... def setAge(self, new_age): ... self.start = time.time() - new_age ... age = property(getAge, setAge) ... ``` `property` 建構方法中前兩個參數分別就是負責讀寫的方法, 執行結果如下: ```python >>> e = E() >>> e.age 2 >>> e.age 3 >>> e.age 5 >>> e.age = 0 >>> e.age 2 >>> ``` 跟之前自行使用描述器實作的功能一模一樣。 `property` 類別提供有 `getter()` 和 `setter()` 可以單獨設定 `__get__()` 和 `__set__()` 要叫用的方法, 所以你也可以把工作分段, 像是這樣: ```python >>> class E: ... def __init__(self): ... self.start = time.time() ... def getAge(self): ... return int(time.time() - self.start) ... def setAge(self, new_age): ... self.start = time.time() - new_age ... age = property(getAge) ... age = age.setter(setAge) ... >>> ``` 在類別中我們先建立了唯讀的屬性, 然後再指定設定屬性的函式, 結果功能不變: ```python >>> e = E() >>> e.age 2 >>> e.age 3 >>> e.age 4 >>> e.age = 0 >>> e.age 1 >>> ``` ## @property = 裝飾器 + 描述器 在上一個實作範例中, 你應該已經發現了在使用 `property` 分段建立描述器時, 其實就是裝飾器, 我們把剛剛的範例重新編排會更清楚: ```python >>> class F: ... def __init__(self): ... self.start = time.time() ... def age(self): ... return int(time.time() - self.start) ... age = property(age) ... age_setter = age.setter ... def age(self, new_age): ... self.start = time.time() - new_age ... age = age_setter(age) ... ``` 你可以看到這兩列都是把 `age()` 包裝後傳回來, 再用同樣的 `age` 命名: ```python age = property(age) ... age = age_setter(age) ``` 第一列叫用 `property` 類別的建構方法, 第二列是叫用 `property` 類別的 `setter()`。既然這就是裝飾器的意義, 直接改用裝飾器還可以讓程式更簡潔清楚: ```python >>> class G: ... def __init__(self): ... self.start = time.time() ... @property ... def age(self): ... return int(time.time() - self.start) ... @age.setter ... def age(self, new_age): ... self.start = time.time() - new_age ... >>> ``` 如果你回頭看本文一開始的範例, 會發現根本就是同樣的程式。到了這裡, 我們已經了解了 Python 中屬性的運作原理了。 ## 小結 雖然你並不需要了解這麼多細節就可以快快樂樂樂地用 `@property` 建立屬性, 不過這些細節有助於在遇到屬性的相關問題時更清楚發生了什麼事?同時藉由這些細節, 也可以學到裝飾器的實務應用, 以及如何設計簡潔通用的架構, 來延伸系統的功能。我自己非常建議大家學到神奇的妖術時都嘗試去挖掘其中的奧秘, 除了有趣, 也能將前人的智慧應用在自己的程式中, 一舉數得。 最後要再補充的是, 你也可以看到 Python 所謂以慣例取代規則的實例, 像是 `__get__()` 這種前後夾著兩個底線的是**特別方法**, 主要是給系統在特定時機自動叫用, 通常都有搭配的運作機制, 我們自己的程式不用該直接叫用。另外, 雖然慣例上類別名稱都是首字母大寫, 但是像是 `property` 卻是完全小寫, 這是因為 `property` 主要是用在裝飾器上, 而不是讓我們拿來建立單獨存在的物件。了解這一些, 有助於遇到個別名稱時, 能快速知道可能用途, 避免破壞內部機制運作。