物件導向程式設計 - 進階篇
雖然大家都說 Python 是一款很容易學習的程式語言,它的語法的確寫起來很簡單,不過在這些簡單的語法背後,Python 做了不少有趣的事,這些細節我相信在平常的工作大概都是用不上的,知道這些細節的好處是以後在看待 Python 的時候會有完全不同的觀點。
這個章節的內容比較難一點,所以要先跳過這章節也可以,或是等過陣子對 Python 更熟悉之後再回來看也行。既然是進階篇,我們就先從最基本但可能有點複雜的描述器開始吧!
描述器
在 Python 的物件導向世界裡,當我們試著執行 kitty.sleep()
方法或是印出 kitty.age
屬性的值的時候,背後的運作機制可能比你想像中的複雜一些。
Python 有個叫做「描述器(Descriptor)」的設計,這是一個我認為很有趣的功能,如果只是寫一些簡單的程式或只是拿 Python 來做網站的話,大概用不上它。嗯...也不是用不上,如果我們用網站開發框架像是 Django 做網站的時候,用到描述器的頻率還滿高的,只是你可能不知道它就是描述器而已。描述器可以讓我們在讀取或設定物件身上的屬性或執行方法的時候在背後偷偷做一些事情,同時它也是很多我們現在看起來很理所當然的功能的基礎。
使用情境
看一下這段程式碼:
class Cat:
def __init__(self, name, age):
self.name = name
self.age = age
kitty = Cat("凱蒂", 18)
這程式碼看起來沒什麼難度,來試用看看這個類別:
>>> nancy = Cat("南茜", -100)
>>> nancy.age
-100
咦?年齡怎麼可以是負的,這樣不行,來加個檢查:
def __init__(self, name, age):
if age < 0:
raise ValueError("年齡不能為負數")
self.name = name
self.age = age
這樣應該就沒問題了吧!嘿嘿,還記得我們在上個章節才介紹過 Python 並沒有真正的私有(Private)屬性,這裡只有在 __init__()
裡面做檢查,所以我可以一開始先乖乖的設定一個正常的數值,後續再改 age
屬性的值,像這樣:
>>> nancy = Cat("南茜", 10)
>>> nancy.age = -120
>>> nancy.age
-120
基本上是躲不掉啦。這時候就是描述器登場的時候了,描述器有分為資料描述器(Data Descriptor)和非資料描述器(Non-Data Descriptor),先不要在意這兩個名詞有什麼不同,待會你就會知道了,我們慢慢看下去...
非資料描述器
首先,所謂的「描述器」,在 Python 就只是個普通的類別而已,只是這個類別裡剛好實作了幾個魔術方法。先從最簡單的開始看:
class AgeValue:
def __get__(self, obj, obj_type):
return 18
這裡的 AgeValue
就只是普普通通的類別,上層也沒有繼承其他神奇的類別,只是有實作了 __get__()
方法,這個方法不複雜,就只是先讓它固定回傳數字 18。__get__()
方法裡面的參數晚點再做說明,但光是這樣它就可以當做一個描述器。描述器的使用方法很簡 單:
class Cat:
age = AgeValue()
我在 Cat
類別裡面定義了 age
屬性,一般可能就直接指定一個值給它,但這次是指定一個 AgeValue
類別所產生的「實體」給它,注意,是實體,不是類別喔。這樣一來,神奇的事情就發生了:
>>> kitty = Cat()
>>> kitty.__dict__
{}
>>> kitty.age
18
在前面章節曾經提過,當透過 .
的方式在找屬性的時候,首先會找這顆物件身上的字典是不是有這個屬性,如果沒有,會再往它的所屬類別找。在 Cat
類別的確有找到 age
屬性沒錯,而它就只是 AgeValue
類別的實體而已,為什麼它會回傳 18?
這是因為這個實體實作了 __get__()
方法,當透過 .
的方式存取屬性或方法的時候,如果這個值剛好是某個類別的實體,就會看看這個實體有沒有實作 __get__()
方法。如果有,Python 就會呼叫這個方法,然後就可以看到我們回傳的數字 18。
不信的話我再做個實驗,我刻意把 AgeValue
的 __get__()
方法拿掉,像這樣:
class AgeValue:
pass
class Cat:
age = AgeValue()
執行之後就會發現它就只是個普通的物件而已:
>>> kitty = Cat()
>>> kitty.age
<__main__.AgeValue object>
所以,魔法就在於這個類別有實作 __get__()
方法,當我們用 .
的方式存取這個屬性的時候,Python 就會去呼叫這個實體身上的 __get__()
方法。
在 __get__()
方法的三個參數分別是:
self
:這個self
指的是AgeValue
這個類別的實體,不是kitty
這個實體喔!obj
:承 上,這個參數才是kitty
實體。obj_type
:這個描述器掛在哪個類別裡,以上面的例子來說就是Cat
。
待會我們還會看到描述器的其他方法,如果描述器只有實作 __get__()
方法的時候,我們稱它為「非資料描述器」(Non-Data Descriptor),它只能讀取屬性的值,但沒有寫入功能。
沒有寫入功能?誰說的:
>>> kitty = Cat()
>>> kitty.age = 1000
>>> kitty.age
1000
你看,這樣不就行了嗎?你誤會了,以結果來看的好像是可以寫入,事實上這個動作跟描述器無關,這只是在這顆 kitty
物件的字典裡加了一個 age
屬性而已,看看它的 .__dict__
屬性就知道了:
>>> kitty.__dict__
{'age': 1000}
如果要讓描述器有寫入功能,我們就得要實作另一個方法了。
資料描述器
如果我們在 AgeValue
這個描述器裡面實作了 __set__()
方法,故事就會變的不一樣了:
class AgeValue:
def __init__(self, age=0):
self._age = age
def __get__(self, obj, obj_type):
return self._age
def __set__(self, obj, value):
if value < 0 or value > 150:
raise ValueError("年齡超過範圍")
self._age = value
class Cat:
age = AgeValue()
這裡我加上了 __init__()
方法讓我們有機會可以掛上這個描述器的時候順便設定初始的年齡,不過這裡的重點在於 __set__()
方法,裡面的參數跟 __get__()
差不多,self
指的也是描述器的實體,obj
才是這個物件本身,value
則是要設定的值。這個方法不像 __get__()
還有 obj_type
參數,但想想也合理,畢竟我們要設定的值就是這顆物件的屬性,跟 obj_type
沒什麼關係。
在 __init__()
方法裡也可以看到我暫時先把傳進來的 age
屬性存放在 AgeValue
的實體的 _age
屬性裡,這樣在設定屬性的時候就不會直接寫入 kitty
物件的 .__dict__
裡。雖然這樣的寫法會有別的問題,不過我們晚點再討論。
另外,在 __set__()
裡我順便做了個簡單的檢查,如果年齡不在設定的區間裡就會丟出錯誤訊息。來試用看看:
>>> kitty = Cat()
# 一開始身上什麼都沒有
>>> kitty.__dict__
{}
# 透過描述器取得 age 屬性
>>> kitty.age
0
# 設定 age 屬性
>>> kitty.age = 18
>>> kitty.__dict__
{}
>>> kitty.age
18
# 超過範圍
>>> kitty.age = 1000
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/private/tmp/tt.py", line 10, in __set__
raise ValueError("年齡超過範圍")
ValueError: 年齡超過範圍
剛才我們做 kitty.age = 18
時候是直接寫進 kitty
物件的 .__dict__
屬性裡,但有了 __set__()
方法之後,一樣的操作會發現它並沒有直接寫入 .__dict__
裡,而是透過描述器把值寫到某個地方,以我上面的範例來說,是寫入描述器裡的 _age
屬性。
跟剛才的「非資料描述器」比起來,這種有實作 __set__()
方法或是待會就會介紹到的 __delete__()
方法的描述器稱之「資料描述器」(Data Descriptor)。雖然並沒有規定資料描述器一定需要實作 __get__()
方法,但除非有什麼特別的目的,例如想做到只寫入而不讀取屬性,不然通常都會一併實作 __get__()
方法。
剛才講到,如果只有實作 __get__()
方法就是一個「非資料描述器」,讀取屬性沒問題,但寫入的時候因為沒有 __set__()
方法,所以它會直接寫到那顆物件的 .__dict__
裡。當再次讀取屬性的時候由於 __dict__
的優先順序比較高,所以原本實作的 __get__()
方法就用不上了。但「資料描述器」就不同了,如果那個屬性是資料描述器,不管是讀取或寫入就都不會讀取物件的 __dict__
屬性了:
class UselessValue:
def __set__(self, obj, value):
pass
class Cat:
age = UselessValue()
這裡我做了個完全沒用的描述器類別 UselessValue
,裡面只放了 __set__()
方法而且故意什麼事都不做,就當個開開心心的小廢物。接著我們來試試看:
>>> kitty = Cat()
>>> kitty.age
<__main__.UselessValue object>
# 設定 age 屬性
>>> kitty.age = 100
>>> kitty.age
<__main__.UselessValue object>
# __dict__ 還是空的
>>> kitty.__dict__
{}
可以看到不管是讀取或是寫入,都是直接操作描述器裡的值,而不是物件的 .__dict__
屬性。也就是說,資料描述器會遮蔽(Shadow)物件的 __dict__
,非資料描述器就沒這特性。
看到這裡,你可能會想說寫個描述器還要定義這些兩個底線方法有點麻煩,這些我們平常用不到吧,這真的有需要學嗎?如果你有使用 Django 或 Flask 框架,在操作資料庫的時候通常都會使用 ORM(Object Relational Mapping)來操作資料表,你應該會看到像這樣的寫法:
class Book(models.Model):
title = models.CharField(max_length=100)
page = models.IntegerField()
有覺得眼熟嗎?title
跟 page
這兩個屬性的寫法跟我們剛剛寫的描述器有點像吧?是的,這些就是描述器沒錯。至於為什麼這樣就能透過 .title
或 .page
來操作資料表,這就是 CharField()
跟 IntegerField()
這兩個類別裡的魔法了。
好啦,我們要回來面對該面對的問題了...
所以,值要存在哪裡?
在上面的範例中,不管是 __get__()
還是 __set__()
方法,我都是直接在 self
加上 ._age
屬性,但這 self
指的是描述器的實體,並不是我們要設定的物件,這可能會有一些問題:
# 先做出 kitty 跟 nancy 兩個物件
>>> kitty = Cat()
>>> nancy = Cat()
# 讀取以及設定 kitty 的 age 屬性,看起來一切正常
>>> kitty.age
0
>>> kitty.age = 18
# 但是 nancy 的 age 屬性也被設定成 18 了
>>> nancy.age
18
# 甚至連之後產生的物件也是...
>>> lucy = Cat()
>>> lucy.age
18