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

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

使用 @property 裝飾器建立屬性

剛剛提到的需求實際上是做不到的, 因為資料就是資料, 沒有修改當然是不會變的, 不過 Python 提供有一種神奇的機制, 可以讓你用讀取物件資料的語法叫用物件的方法, 由於實際上是叫用物件方法, 所以就可以透過運算產生傳回值, 使用起來就跟讀取物件資料一樣, 但是讀…


This content originally appeared on DEV Community and was authored by DEV Community

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

使用 @property 裝飾器建立屬性

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

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

>>> time.time()
1646532639.2636657
>>>

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

>>> 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 屬性時, 就會自動叫用它。

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

>>> c = C()
>>> c.age
6
>>> c.age
9
>>>

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

>>> c.age = 0
>>> c.age
1

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

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

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

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

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

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

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

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

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

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

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

>>> class D:
...     age = Age()
...     def __init__(self):
...         self.start = time.time()
...
>>>

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

>>> d = D()
>>> d.age
3
>>> d.age
4
>>> d.age
6

. 運算器發現 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
...

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

>>> class D:
...     age = Age()
...     def __init__(self):
...         self.start = time.time()
...

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

>>> d = D()
>>> d.age
2
>>> d.age
9
>>> d.age = 0
>>> d.age
2
>>> d.age
3
>>>

這樣我們就設計出和剛剛以 @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
>>>

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

>>> 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 類別的物件, 仍可以正常運作:

>>> d1 = D()
>>> d1.age
7
>>>

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

>>> 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, 可以協助我們快速產生描述器。只要先準備好負責讀寫屬性的方法, 再將這些方法當成引數傳入 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)
...

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

>>> e = E()
>>> e.age
2
>>> e.age
3
>>> e.age
5
>>> e.age = 0
>>> e.age
2
>>>

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

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)
...
>>>

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

>>> e = E()
>>> e.age
2
>>> e.age
3
>>> e.age
4
>>> e.age = 0
>>> e.age
1
>>>

@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)
...

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

age = property(age)
...
age = age_setter(age)

第一列叫用 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
...
>>>

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

小結

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

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


This content originally appeared on DEV Community and was authored by DEV Community


Print Share Comment Cite Upload Translate Updates
APA

DEV Community | Sciencx (2022-03-06T10:35:03+00:00) 這是什麼妖術?Python 的屬性 (property) 運作原理. Retrieved from https://www.scien.cx/2022/03/06/%e9%80%99%e6%98%af%e4%bb%80%e9%ba%bc%e5%a6%96%e8%a1%93%ef%bc%9fpython-%e7%9a%84%e5%b1%ac%e6%80%a7-property-%e9%81%8b%e4%bd%9c%e5%8e%9f%e7%90%86/

MLA
" » 這是什麼妖術?Python 的屬性 (property) 運作原理." DEV Community | Sciencx - Sunday March 6, 2022, https://www.scien.cx/2022/03/06/%e9%80%99%e6%98%af%e4%bb%80%e9%ba%bc%e5%a6%96%e8%a1%93%ef%bc%9fpython-%e7%9a%84%e5%b1%ac%e6%80%a7-property-%e9%81%8b%e4%bd%9c%e5%8e%9f%e7%90%86/
HARVARD
DEV Community | Sciencx Sunday March 6, 2022 » 這是什麼妖術?Python 的屬性 (property) 運作原理., viewed ,<https://www.scien.cx/2022/03/06/%e9%80%99%e6%98%af%e4%bb%80%e9%ba%bc%e5%a6%96%e8%a1%93%ef%bc%9fpython-%e7%9a%84%e5%b1%ac%e6%80%a7-property-%e9%81%8b%e4%bd%9c%e5%8e%9f%e7%90%86/>
VANCOUVER
DEV Community | Sciencx - » 這是什麼妖術?Python 的屬性 (property) 運作原理. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2022/03/06/%e9%80%99%e6%98%af%e4%bb%80%e9%ba%bc%e5%a6%96%e8%a1%93%ef%bc%9fpython-%e7%9a%84%e5%b1%ac%e6%80%a7-property-%e9%81%8b%e4%bd%9c%e5%8e%9f%e7%90%86/
CHICAGO
" » 這是什麼妖術?Python 的屬性 (property) 運作原理." DEV Community | Sciencx - Accessed . https://www.scien.cx/2022/03/06/%e9%80%99%e6%98%af%e4%bb%80%e9%ba%bc%e5%a6%96%e8%a1%93%ef%bc%9fpython-%e7%9a%84%e5%b1%ac%e6%80%a7-property-%e9%81%8b%e4%bd%9c%e5%8e%9f%e7%90%86/
IEEE
" » 這是什麼妖術?Python 的屬性 (property) 運作原理." DEV Community | Sciencx [Online]. Available: https://www.scien.cx/2022/03/06/%e9%80%99%e6%98%af%e4%bb%80%e9%ba%bc%e5%a6%96%e8%a1%93%ef%bc%9fpython-%e7%9a%84%e5%b1%ac%e6%80%a7-property-%e9%81%8b%e4%bd%9c%e5%8e%9f%e7%90%86/. [Accessed: ]
rf:citation
» 這是什麼妖術?Python 的屬性 (property) 運作原理 | DEV Community | Sciencx | https://www.scien.cx/2022/03/06/%e9%80%99%e6%98%af%e4%bb%80%e9%ba%bc%e5%a6%96%e8%a1%93%ef%bc%9fpython-%e7%9a%84%e5%b1%ac%e6%80%a7-property-%e9%81%8b%e4%bd%9c%e5%8e%9f%e7%90%86/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.