DEV Community

codemee
codemee

Posted on

這是什麼妖術?Python 的屬性 (property) 運作原理

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

使用 @property 裝飾器建立屬性

剛剛提到的需求實際上是做不到的, 因為資料就是資料, 沒有修改當然是不會變的, 不過 Python 提供有一種神奇的機制, 可以讓你用讀取物件資料的語法叫用物件的方法, 由於實際上是叫用物件方法, 所以就可以透過運算產生傳回值, 使用起來就跟讀取物件資料一樣, 但是讀取到的值卻會變化。這個機制就叫做屬性 (property), 可以藉由 @property 等裝飾器來實作。

假設我們想要實作一種物件, 內含 age 資料, 可以告訴我們這個物件從建立到現在已經存活多少秒?為了要計算秒數, 後續的範例都預設已經匯入 time 模組, 因此可以叫用 time.time() 取得目前時間:

>>> time.time()
1646532639.2636657
>>>
Enter fullscreen mode Exit fullscreen mode

另外, 我們也希望可以在需要的時候直接設定存活時間重新計時。根據上述需求設計的類別如下:

>>> 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
...
>>>
Enter fullscreen mode Exit fullscreen mode

這個類別有幾個需要特別說明的地方:

  • __init__() 中將建立物件的時間記錄下來, 之後就可以根據這個時間點計算物件的存活時間。
  • @porperty 裝飾器則是將下一列的 age() 方法變成 age 屬性, 當我們透過 . 運算器讀取 age 屬性時, 就會自動叫用它。
  • @age.setter 裝飾器則是讓下一列的 age() 變成設定 age 屬性的方法, 當利用指派敘述設定 age 屬性時, 就會自動叫用它。

我知道你心中可能還有一些疑問, 不過我們就先來看看怎們使用這個類別:

>>> c = C()
>>> c.age
6
>>> c.age
9
>>>
Enter fullscreen mode Exit fullscreen mode

建立好物件後的確可以用 物件.資料 這樣的語法來讀取, 看起來就像是讀取物件內一般資料一樣, 而且隨著時間推移, 讀取到的存活時間的確會變長。接著試試設定存活時間:

>>> c.age = 0
>>> c.age
1
Enter fullscreen mode Exit fullscreen mode

確實也可以像是設定物件內的一般資料那樣利用指派敘述完成, 設定後就依照新的存活時間計算。

雖然整個運作都正確, 但我們不禁疑惑起來, 這到底是什麼妖術、@property 施了什麼魔法?為什麼類別內寫了兩個同名的方法卻都還可以正確運作?

要解答疑惑前, 先來看看現在 C 類別裡的 age 是什麼:

>>> C.__dict__['age']
<property object at 0x0000018CD4B64040>
>>> vars(C)['age']
<property object at 0x0000018CD4B64040>
>>>
Enter fullscreen mode Exit fullscreen mode

咦?明明類別裡只有 age() 函式, 但現在 age 變成是 property 類別的物件了。要想了解 property 類別, 我們得先瞭解真正讓屬性得以運作的根本--描述器 (descriptor)

使用描述器 (descriptor) 建立屬性

我們之所以可以透過 . 運算器讀寫資料的語法叫用物件內的方法, 真正在背後搞鬼的是描述器 (descriptor), 它負責描述透過 . 運算器讀寫指定名稱的資料時實際要進行的工作。

以下先以讀取資料為例, 說明如何建立描述器。描述器並不是特定類別的物件, 而是具備特定方法的物件, 對於用來讀取資料的描述器, 就必須要有 __get__() 方法:

>>> class Age:
...     def __get__(self, obj, objType=None):
...         return int(time.time() - obj.start)
...
>>>
Enter fullscreen mode Exit fullscreen mode

描述器必須搭配依附的物件使用, 當 __get__() 被叫用時, obj 就是依附的物件, objType 是該物件所屬的類別, 透過 obj 就可以存取所依附物件內的資料, 在本例中就讀取 start 來計算存活時間。

設計好可建立描述器的類別後, 接著就是實際要搭配描述器運作的類別:

>>> class D:
...     age = Age()
...     def __init__(self):
...         self.start = time.time()
...
>>>
Enter fullscreen mode Exit fullscreen mode

要使用描述器的類別必須在類別內放置描述器, 這樣就完成, 來試用看看:

>>> d = D()
>>> d.age
3
>>> d.age
4
>>> d.age
6
Enter fullscreen mode Exit fullscreen mode

. 運算器發現 age 是一個具備 __get_() 的描述器時, 不會直接把描述器當成運算結果, 而是叫用描述器的 __get__(), 以它的傳回值作為運算結果, 在本例中就會執行 Age 類別的 __get__(), 傳回物件的存活時間。

對於要用來設定資料的描述器, 就必須要具備 __set__() 方法, 一旦在指派敘述中發現指派的標的是描述器時, 就會被自動叫用。例如:

>>> class Age:
...     def __get__(self, obj, objType=None):
...         return int(time.time() - obj.start)
...     def __set__(self, obj, value):
...         obj.start = time.time() - value
...
Enter fullscreen mode Exit fullscreen mode

這樣 Age 就會是一個負責讀寫資料的描述器, 搭配使用的類別完全不用修改:

>>> class D:
...     age = Age()
...     def __init__(self):
...         self.start = time.time()
...
Enter fullscreen mode Exit fullscreen mode

現在除了可以讀取 age 以外, 也可以設定 age 了, 而且實際的存取工作是由描述器內的方法完成:

>>> d = D()
>>> d.age
2
>>> d.age
9
>>> d.age = 0
>>> d.age
2
>>> d.age
3
>>>
Enter fullscreen mode Exit fullscreen mode

這樣我們就設計出和剛剛以 @property 裝飾器實作功能相同的屬性。

要特別留意的是如果實作的是唯讀的屬性, 請務必在描述器內加上 __set__(), 並在其內引發 AttributeError 例外, 否則如果進行指派, 會因為沒有 __set__() 方法不被視為描述器, 以一般資料處理, 就把描述器移除了。例如:

>>> 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
>>>
Enter fullscreen mode Exit fullscreen mode

因為 Age__get__(), 讀取時會被視為描述器, 但是 Age 沒有 __set__(), 在指派時不會被當成描述器, 就會變成在物件上新增一項名稱為 age 的資料:

>>> d.__dict__
{'start': 1646556621.0307684}
>>> d.age = 0
>>> d.__dict__
{'start': 1646556621.0307684, 'age': 0}
>>> d.age
0
>>>
Enter fullscreen mode Exit fullscreen mode

你可以看到在指派前因為 age 是類別內的資料, 所以在 d 物件的字典內並不會出現。但是在指派後 d 物件的字典內就出現了 age 項目, 此後存取 age 時都是讀取 d 物件內的 age, 不再是 D 類別內的 age 物件了, 因此不管讀再多次都是得到剛剛指派的 0, 原本設計的描述器就不會生效了。不過這影響的只有 d 物件, 若是再產生一個 D 類別的物件, 仍可以正常運作:

>>> d1 = D()
>>> d1.age
7
>>>
Enter fullscreen mode Exit fullscreen mode

以下是唯讀屬性的正確做法:

>>> 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.
>>>
Enter fullscreen mode Exit fullscreen mode

一旦嘗試指派新值給唯讀屬性時, 就會引發例外, 即可避免剛剛的問題了。

使用 property 物件當描述器

前述的做法必須自行設計描述器, 實作上有些繁瑣, 所以 Python 提供有一個現成的類別, 叫做 property, 可以協助我們快速產生描述器。只要先準備好負責讀寫屬性的方法, 再將這些方法當成引數傳入 property 的建構方法, 就可以產生描述器, 而且描述器內的 __get__()__set__() 會幫你叫用對應的方法。請看以下的範例:

>>> 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)
...
Enter fullscreen mode Exit fullscreen mode

property 建構方法中前兩個參數分別就是負責讀寫的方法, 執行結果如下:

>>> e = E()
>>> e.age
2
>>> e.age
3
>>> e.age
5
>>> e.age = 0
>>> e.age
2
>>>
Enter fullscreen mode Exit fullscreen mode

跟之前自行使用描述器實作的功能一模一樣。

property 類別提供有 getter()setter() 可以單獨設定 __get__()__set__() 要叫用的方法, 所以你也可以把工作分段, 像是這樣:

>>> 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)
...
>>>
Enter fullscreen mode Exit fullscreen mode

在類別中我們先建立了唯讀的屬性, 然後再指定設定屬性的函式, 結果功能不變:

>>> e = E()
>>> e.age
2
>>> e.age
3
>>> e.age
4
>>> e.age = 0
>>> e.age
1
>>>
Enter fullscreen mode Exit fullscreen mode

@property = 裝飾器 + 描述器

在上一個實作範例中, 你應該已經發現了在使用 property 分段建立描述器時, 其實就是裝飾器, 我們把剛剛的範例重新編排會更清楚:

>>> 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)
...
Enter fullscreen mode Exit fullscreen mode

你可以看到這兩列都是把 age() 包裝後傳回來, 再用同樣的 age 命名:

age = property(age)
...
age = age_setter(age)
Enter fullscreen mode Exit fullscreen mode

第一列叫用 property 類別的建構方法, 第二列是叫用 property 類別的 setter()。既然這就是裝飾器的意義, 直接改用裝飾器還可以讓程式更簡潔清楚:

>>> 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
...
>>>
Enter fullscreen mode Exit fullscreen mode

如果你回頭看本文一開始的範例, 會發現根本就是同樣的程式。到了這裡, 我們已經了解了 Python 中屬性的運作原理了。

小結

雖然你並不需要了解這麼多細節就可以快快樂樂樂地用 @property 建立屬性, 不過這些細節有助於在遇到屬性的相關問題時更清楚發生了什麼事?同時藉由這些細節, 也可以學到裝飾器的實務應用, 以及如何設計簡潔通用的架構, 來延伸系統的功能。我自己非常建議大家學到神奇的妖術時都嘗試去挖掘其中的奧秘, 除了有趣, 也能將前人的智慧應用在自己的程式中, 一舉數得。

最後要再補充的是, 你也可以看到 Python 所謂以慣例取代規則的實例, 像是 __get__() 這種前後夾著兩個底線的是特別方法, 主要是給系統在特定時機自動叫用, 通常都有搭配的運作機制, 我們自己的程式不用該直接叫用。另外, 雖然慣例上類別名稱都是首字母大寫, 但是像是 property 卻是完全小寫, 這是因為 property 主要是用在裝飾器上, 而不是讓我們拿來建立單獨存在的物件。了解這一些, 有助於遇到個別名稱時, 能快速知道可能用途, 避免破壞內部機制運作。

Latest comments (2)

Collapse
 
reactwindd profile image
reactwindd

很少看到有华人 :o

Collapse
 
codemee profile image
codemee

多累積就會多中文使用者了