跳至主要内容

無所不在的描述器

為你自己學 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 例外,打完收工!

流程整理

我稍微順一下整個屬性的查找流程,順便也把幾個名詞用整理一下:

  • 描述器(Descriptor):D
  • 資料描述器(Data Descriptor):DD
  • 非資料描述器(Non-Data Descriptor):NDD
  • 方法描述器(Method Descriptor):MD

流程:

  • 1 先看看這個屬性是不是一個 D
    • 1A 如果是,而且還是一個 DD 的話,就會執行它的 __get__() 方法並回傳結果。
    • 1B 如果只是個 MD,先記錄下來,繼續往下找。
  • 2 從物件的 __dict__ 裡找是不是有符合的屬性:
    • 2A 如果有,就是它了
    • 2B 如果沒有,再繼續往步驟 4 前進
  • 3 如果在步驟 1B 有記錄的話,表示這是一個 MD,把它設定給 method 變數,不繼續往下找。
  • 4 在步驟 1A 查到的屬性,如果它是一個 NDD 的話,執行它的 __get__() 方法並回傳結果。
  • 5 走到這裡,那麼它應該是一個普通的類別屬性(不是任何一種 D),就直接回傳它。
  • 6 以上流程都沒有符合的話,丟出 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 嗎?我教你啊 :)

想要成為軟體工程師嗎?這不是條輕鬆的路,除了興趣之外,還需要足夠的決心、設定目標並持續學習,我們的ASTROCamp 軟體工程師培訓營提供專業的前後端課程培訓,幫助你在最短時間內建立正確且扎實的軟體開發技能,有興趣而且不怕吃苦的話不妨來試試看!