本文同步刊載於 「為你自己學 Python - 無所不在的描述器)」
描述器(Descriptor)是 Python 中一個非常有趣也是重要的特性,很多看起來很簡單的語法背後其實都是描述器,你甚至不知道已經在使用它了。描述器可以讓我們在讀取或設定物件身上的屬性或執行方法的時候在背後做一些事情,同時它也是很多我們現在看起來很理所當然的功能的基礎。
描述器有分資料描述器(Data Descriptor)和非資料描述器(Non-Data Descriptor)兩種,差別在於是否實作的方法不同,想知道更多細節可參閱「為你自己學 Python」的物件導向程式設計 - 進階篇章節介紹,這個章節我們來看看描述器在 CPython 中是怎麼實作的。
在 CPython 中,想要找某個物件身上的屬性的時候,會透過類型的 tp_getattro
成員來決定如何獲取該屬性。大多數情況下,tp_getattro
都是指向 PyObject_GenericGetAttr()
函數,從它的名字大概就能猜的出來它是個通用型的屬性讀取函數。
我先來個例子:
class Cat:
race = "貓科"
def __init__(self, name, age):
self.name = name
self.age = age
kitty = Cat("凱蒂", 18)
print(kitty.name)
最後一行要印出 kitty.name
的時候,發生了什麼事?或應該問,這個 .name
屬性是怎麼被找到的?同樣從 Bytecode 下手:
4 LOAD_BUILD_CLASS
6 LOAD_CONST 0 (<code object Cat>)
8 MAKE_FUNCTION 0
10 LOAD_CONST 1 ('Cat')
12 CALL 2
20 STORE_NAME 0 (Cat)
9 22 PUSH_NULL
24 LOAD_NAME 0 (Cat)
26 LOAD_CONST 2 ('凱蒂')
28 LOAD_CONST 3 (18)
30 CALL 2
38 STORE_NAME 1 (kitty)
10 40 PUSH_NULL
42 LOAD_NAME 2 (print)
44 LOAD_NAME 1 (kitty)
46 LOAD_ATTR 6 (name)
66 CALL 1
74 POP_TOP
76 RETURN_CONST 4 (None)
大部份的指令我們之前都看過,沒什麼特別的,倒是在讀取 .name
屬性的時候使用 LOAD_ATTR
這個指令,這個指令會去找 kitty
這個物件的 name
屬性,我們在「繼承與家族紛爭(上)」章節曾經追過這個指令在做什麼事,最後會追到一個 _PyObject_GetMethod()
函數,這個函數的行數比較多而且是一連串的判斷流程,我們一段一段往下看:
// 檔案:Objects/object.c
// ... 略 ...
PyTypeObject *tp = Py_TYPE(obj);
// ... 略 ...
PyObject *descr = _PyType_Lookup(tp, name);
descrgetfunc f = NULL;
if (descr != NULL) {
Py_INCREF(descr);
if (_PyType_HasFeature(Py_TYPE(descr), Py_TPFLAGS_METHOD_DESCRIPTOR)) {
meth_found = 1;
} else {
f = Py_TYPE(descr)->tp_descr_get;
if (f != NULL && PyDescr_IsData(descr)) {
*method = f(descr, obj, (PyObject *)Py_TYPE(obj));
Py_DECREF(descr);
return 0;
}
}
}
// ... 略 ...
Py_TPFLAGS_METHOD_DESCRIPTOR
是用來標記是否具備「方法描述器(Method Descriptor)」的特性,如果有的話,先記錄下來,然後繼續往下查找。方法描述器待會再另外介紹,我們繼續往下看 f = Py_TYPE(descr)->tp_descr_get
這行,如果有實作 tp_descr_get
成員而且還是個資料描述器的話,會呼叫這個函數並回傳結果,不繼續往下做了。這裡用來判所是不是資料描述器的 PyDescr_IsData()
函數的實作也很簡單:
// 檔案:Objects/descrobject.c
int
PyDescr_IsData(PyObject *ob)
{
return Py_TYPE(ob)->tp_descr_set != NULL;
}
就是看看 tp_descr_set
有沒有實作而已,如果有的話就是資料描述器,沒有的話就是非資料描述器,這跟我們對資料與非資料描述器的認知是一致的。接下來這段比較長一點:
// 檔案:Objects/object.c
// ... 略 ...
PyObject *dict;
if ((tp->tp_flags & Py_TPFLAGS_MANAGED_DICT)) {
PyDictOrValues* dorv_ptr = _PyObject_DictOrValuesPointer(obj);
if (_PyDictOrValues_IsValues(*dorv_ptr)) {
PyDictValues *values = _PyDictOrValues_GetValues(*dorv_ptr);
PyObject *attr = _PyObject_GetInstanceAttribute(obj, values, name);
if (attr != NULL) {
*method = attr;
Py_XDECREF(descr);
return 0;
}
dict = NULL;
}
else {
dict = dorv_ptr->dict;
}
}
else {
PyObject **dictptr = _PyObject_ComputedDictPointer(obj);
if (dictptr != NULL) {
dict = *dictptr;
}
else {
dict = NULL;
}
}
if (dict != NULL) {
Py_INCREF(dict);
PyObject *attr = PyDict_GetItemWithError(dict, name);
if (attr != NULL) {
*method = Py_NewRef(attr);
Py_DECREF(dict);
Py_XDECREF(descr);
return 0;
}
Py_DECREF(dict);
if (PyErr_Occurred()) {
Py_XDECREF(descr);
return 0;
}
}
// ... 略 ...
這段看起來有點複雜,但其實都是在類似的事,就是在這個物件的字典裡面找看看有沒有 name
這個屬性,沒有的話就繼續往下找。再繼續往下看:
// 檔案:Objects/object.c
// ... 略 ...
if (meth_found) {
*method = descr;
return 1;
}
// ... 略 ...
還記得前面有一個地方判斷是不是方法描述器嗎?如果是的話就就把這個方法描述器設定給 method
變數,然後就不繼續往下找了。如果不是方法描述器,就繼續往下找:
// 檔案:Objects/object.c
// ... 略 ...
if (f != NULL) {
*method = f(descr, obj, (PyObject *)Py_TYPE(obj));
Py_DECREF(descr);
return 0;
}
// ... 略 ...
從前面看到這裡,這個 f
不是方法描述器也不是資料描述器,它只是個非資料描述器,到這裡才輪到它執行它的 __get__()
方法並回傳結果。但如果它連個描述器都不是的話:
// 檔案:Objects/object.c
if (descr != NULL) {
*method = descr;
return 0;
}
// ... 略 ...
這個 descr
應該就是之前 _PyType_Lookup(tp, name)
函數找出來的屬性,到這裡應該就會是這個物件所屬類別的類別屬性了,以我們的例子來說就是 Cat
類別的 race
。
假如以上流程都沒有找到的話,最後就很簡單啦:
// ... 略 ...
PyErr_Format(PyExc_AttributeError,
"'%.100s' object has no attribute '%U'",
tp->tp_name, name);
set_attribute_error_context(obj, name);
return 0;
// ... 略 ...
丟出 AttributeError
例外,打完收工!
我稍微順一下整個屬性的查找流程,順便也把幾個名詞用整理一下:
D
DD
NDD
MD
流程:
D
:
DD
的話,就會執行它的 __get__()
方法並回傳結果。MD
,先記錄下來,繼續往下找。__dict__
裡找是不是有符合的屬性:
MD
,把它設定給 method
變數,不繼續往下找。NDD
的話,執行它的 __get__()
方法並回傳結果。D
),就直接回傳它。AttributeError
例外。在 Python 中,方法描述器(Method Descriptor)也是有實作部份的描述器協議(Descriptor Protocol),不過它只有實作 __get__()
方法,所以也可說它是一種非資料描述器。
所以這方法描述器在 Python 怎麼定義出來的?其實很簡單,你可能已經會了。我來舉個例子:
class Cat:
def meow(self):
print("喵喵喵")
kitty = Cat()
這樣就定義好了。咦?這看起來就是一般的實體方法不是嗎?是的,但它就是一個方法描述器,我進到 REPL 試給你看:
>>> type(Cat.meow)
<class 'function'>
>>> hasattr(Cat.meow, '__get__')
True
>>> kitty = Cat()
>>> type(kitty.meow)
<class 'method'>
>>> hasattr(kitty.meow, '__get__')
True
看到了嗎?不管是函數或是方法,它都有 __get__()
方法,這就是方法描述器的定義。
實際上,當類別裡的函數被執行的時候,也就是執行這個函數的 __get__()
方法,這個方法會回傳一個綁定方法(bound method),這個綁定方法會自動接收實體(self)或類別(cls)作為第一個參數。所以以下兩種寫法是等價的:
>>> kitty.meow()
喵喵喵
>>> Cat.meow.__get__(kitty, Cat)()
喵喵喵
// 檔案:Objects/funcobject.c
PyTypeObject PyFunction_Type = {
PyVarObject_HEAD_INIT(&PyType_Type, 0)
"function",
sizeof(PyFunctionObject),
// ... 略 ...
0, /* tp_dict */
func_descr_get, /* tp_descr_get */
0, /* tp_descr_set */
offsetof(PyFunctionObject, func_dict), /* tp_dictoffset */
0, /* tp_init */
0, /* tp_alloc */
func_new, /* tp_new */
};
而這個 tp_descr_get
成員的實作也很簡單:
static PyObject *
func_descr_get(PyObject *func, PyObject *obj, PyObject *type)
{
if (obj == Py_None || obj == NULL) {
return Py_NewRef(func);
}
return PyMethod_New(func, obj);
}
如果有傳入實體的話,就回傳一個綁定方法,否則就回傳這個函數本身,至於傳入的實體是那隻可愛的 kitty
還是 Cat
類別,就看怎麼呼叫這個方法囉。
再回頭想想剛才我整理的流程的第 3 步,如果這個屬性是一個方法描述器的話,就會直接把它設定給 method
變數,這樣就不會再往下找了,這就是為什麼我們在找 kitty.meow
屬性的時候,如果有用 def
定義了 meow
方法的時候,它也能找到這個方法。
所以,描述器這東西在 Python 裡真的到處都是,雖然你已經在用了,但可能不知道它的存在而已。
本文同步刊載於 「為你自己學 Python - 無所不在的描述器)」