環境
Python 3.9
https://github.com/python/cpython/blob/3.9/
定義
- 在 Python 中一個類別只要定義了
__get__、__set__或__delete__都會將這個類別變為描述器
https://docs.python.org/3/howto/descriptor.html#definition-and-introduction
| |
此程式會輸出 meow,代表說我們在使用 o.name 時,會去呼叫 Name 這個類別的 __get__ 函式。
接下來,我們針對 foo.b 此種操作進行分析研究,便會發現描述器的身影。
LOAD_ATTR
LOAD_ATTR 在 Python bytescode 中是個十分常見的 opcode,當我們使用形如 a.b 的操作(求某個值)時,就會使用這個 opcode,另外,若我們要在這裡保存值,就會使用 STORE_ATTR。使用 dis 這個模組來反組譯分析 CPython bytecode
https://docs.python.org/zh-tw/3/library/dis.html
In [2]: dis.dis("foo.b")
1 0 LOAD_NAME 0 (foo)
2 LOAD_ATTR 1 (b)
4 RETURN_VALUE
In [3]: dis.dis("foo.b = 'hello'")
1 0 LOAD_CONST 0 ('hello')
2 LOAD_NAME 0 (foo)
4 STORE_ATTR 1 (b)
6 LOAD_CONST 1 (None)
8 RETURN_VALUE
接下來,我們要理解描述器的原理,我們就必須去看這兩個 opcode 的原始碼,接下來以 LOAD_ATTR 為例。
可以看到在 Python/ceval.c 中,包含了 LOAD_ATTR 的 case,又可以發現,他主要是使用了 PyObject_GetAttr。
| |
經過追蹤,發現最後的運作邏輯位於 Objects/object.c 的 _PyObject_GenericGetAttrWithDict 中。顯然,tp 存的便是 object 的 type,接下來就著重在分析這個函式。
| |
往下看到 line17,發現他正在型別裡面尋找 name,也就代表了我們的描述器必須要定義到型別的類別定義中,才算有效的(在 __init__ 中無效,將不會觸發 descriptor)
| |
例如上方的例子,line7 中將 o 的 型別存到 tp 變數中,line9 存取了 tp.owo 就會報錯,原因是這個變數(owo)在這個類別被實例化後,呼叫 __init__ 後才會被定義,也就是放到實例化後的物件的 __dict__ 中。
反之,由於 name 本來就是成員變數,因此便可成功存取。
| |
往下看,在確定取得 descr 後,line4 嘗試地在描述器中找 tp_descr_get,也就是我們剛才定義的 __get__ 函數,注意他有將結果存下來 f = Py_TYPE(descr)->tp_descr_get;,後面會使用到。
line5 這個描述器拿去 PyDescr_IsData 判斷,這個判斷就是看他有沒有 __set__ 函數 。
也就是當目前這個描述器同時有 __get__ 與 __set__ 函數時,就會立刻進行 __get__ 內的操作!
| |
繼續往下看到 dict 的操作,這個非常單純。line1-20就是找到這個物件本身的 __dict__。line21-40 則是從 __dict__ 中看有沒有要找的 name,有的話則回傳。
⚠️注意,這邊 dict 的操作都是對原物件做事,並不是對描述器。
以 a.b 為例,這邊都是在 a 上操作。
| |
接下來,這個部分代表了,有 __get__ 但沒有 __set__ 函數時,就會在這回傳。
注意,這邊用到的 f 是之前在判斷同時有 __get__ 與 __set__ 的時候存下來的。
| |
最後,若有 descr 則直接回傳,否則就報錯,就是大家常看到的 type object 'A' has no attribute 'b'
到這邊,我們大致能推出以下的優先級。
- 如果
__get__與__set__都有,優先使用描述器(Descriptor):- 當一個描述器同時實現了
__get__和__set__方法時,Python 會認為它是一個資料描述器(Data Descriptor)。資料描述器的一個特點是它們對屬性的訪問有更高的優先級。
- 當一個描述器同時實現了
- 如果不是,則在自己的
__dict__裡面找:- 如果描述器沒有作為資料描述器或者只實現了
__get__方法的非資料描述器(Non-Data Descriptor)或者找不到描述器,Python 會繼續在物件的__dict__屬性字典中尋找是否存在該屬性。
- 如果描述器沒有作為資料描述器或者只實現了
- 若在
__dict__也找不到,嘗試用描述器的__get__:- 如果在物件的
__dict__中找不到該屬性,Python 會檢查是否存在只實現了__get__方法的非資料描述器,如果存在,則回傳該描述器的__get__方法。
- 如果在物件的
- 都沒有,直接回傳描述器:
- 如果上述步驟都無法找到該屬性,最後會返回描述器物件本身,如果連描述器也不存在,則會拋出 AttributeError。
畫成圖:

範例一 - 兩者皆無
class A:
name = "meow"
o = A()
print(o.__dict__)
print(o.name)
output
{} meow
由於在運行 o.name 的時候,在本身的型別有找到 name 這個成員,但這個成員既沒有實現 __get__ 也沒有實現 __set__ 方法,該物件自身的 __dict__ 也找不到 name。最後,就會直接回傳此成員。
範例二 - 被 __dict__ 搶先 1
class Name:
def __get__(self, obj, objtype):
print("get trigger")
return "meow"
class A:
def __init__(self):
self.name = Name()
o = A()
print(o.__dict__)
print(o.name)
output
{'name': <__main__.Name object at 0x000001F6BB0497F0>} <__main__.Name object at 0x000001F6BB0497F0>
在 line12 實例化 A 這個類別後,代表了 name 會被加到 o 的 __dict__ 內。
在運行 o.name 後,由於發現在 o 的類型 A 中沒有發現 name 這個成員,接下來在 o 的 __dict__ 中發現了 name,因此直接回傳。
範例三 - 被 __dict__ 搶先 2
class Name:
def __get__(self, obj, objtype):
print("get trigger")
return "meow"
class A:
name = Name()
o = A()
print(o.name)
o.name = "wolf"
print(o.__dict__)
print(o.name)
output
get trigger meow {'name': 'wolf'} wolf
在 line11 實例化 A 這個類別產生了 o 物件後,line13 主動地將 {name:"wolf"} 放到 o 的 __dict__ 中。
跟上一個範例類似,不同的是 name 有被定義在類別 A 內。
在運行 o.name 後,由於發現在 o 的類型 A 中有發現 name 這個成員,但只有實現 __get__ 這個成員,沒有發現 __set__ 因此現階段不會回傳,接下來在 o 的 __dict__ 中發現了 name,因此直接回傳。
另外一個值得注意的是 print("get trigger") 並沒有被執行。
__get__ 與 __set__ 的描述器才會在找自身的 __dict__ 前被回傳,否則 __dict__ 會先被查找使用。範例四 - 最高優先度的資料描述器
class Name:
def __get__(self, obj, objtype):
print("get trigger")
return "meow"
class A:
name = Name()
o = A()
print(o.name)
o.name = "wolf"
print(o.__dict__)
print(o.name)
Name.__set__ = lambda self, obj, value: None
print(o.name)
output
get trigger meow {'name': 'wolf'} wolf get trigger meow
這是一個反直覺的例子,我們在上個例子的 line13 加上了一行,也就是在 Name 這個類別中加上了一個任意的 __set__ 函式,這樣在初次判斷描述器時,就會發現同時實現了 __get__ 和 __set__ 方法,因此就會直接回傳 __get__ 的內容,看到結果是 meow
值得注意的是 print("get trigger") 有被執行。
ref
https://www.youtube.com/watch?v=Fp7aQO3QuS0&pp=ygUQcHl0aG9uIOaPj-i_sOWZqA%3D%3D https://openhome.cc/zh-tw/python/meta-programming/descriptor/