這是什麼妖術？Python 的屬性 (property) 運作原理
Tags: python, decorator, descriptor, property
Published: 2022-03-06 10:35:03

當我們讀取物件內的資料時, 不論你讀取幾次, 只要你沒有變更該資料, 讀取到的結果都不會變。如果我們想要讓讀取到的資料會**隨時間或是物件內的其他資料變化**, 可以辦得到嗎？

## 使用 @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` 主要是用在裝飾器上, 而不是讓我們拿來建立單獨存在的物件。了解這一些, 有助於遇到個別名稱時, 能快速知道可能用途, 避免破壞內部機制運作。