當我們讀取物件內的資料時, 不論你讀取幾次, 只要你沒有變更該資料, 讀取到的結果都不會變。如果我們想要讓讀取到的資料會隨時間或是物件內的其他資料變化, 可以辦得到嗎?
使用 @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
主要是用在裝飾器上, 而不是讓我們拿來建立單獨存在的物件。了解這一些, 有助於遇到個別名稱時, 能快速知道可能用途, 避免破壞內部機制運作。
Top comments (2)
很少看到有华人 :o
多累積就會多中文使用者了