instance對象中的__dict__
在Python虛擬機類機制之從class對象到instance對象(五)這一章中最后的屬性訪問算法中,我們看到“a.__dict__”這樣的形式。
# 首先尋找'f'對應的descriptor(descriptor在之后會細致剖析) # 注意:hasattr會在<class A>的mro列表中尋找符號'f' if hasattr(A, 'f'): descriptor = A.f type = descriptor.__class__ if hasattr(type, '__get__') and (hasattr(type, '__set__') or 'f' not in a.__dict__): return type.__get__(descriptor, a, A) # 通過descriptor訪問失敗,在instance對象自身__dict__中尋找屬性 if 'f' in a.__dict__: return a.__dict__['f'] # instance對象的__dict__中找不到屬性,返回a的基類列表中某個基類里定義的函數 # 注意:這里的descriptor實際上指向了一個普通的函數 if descriptor: return descriptor.__get__(descriptor, a, A)
在前一章中,我們看到從<class A>創建<instance a>時,Python虛擬機僅為a申請了16個字節的內存,並沒有額外創建PyDictObject對象的動作。不過在<instance a>中,24個字節的前8個字節是PyObject,后8個字節是為兩個PyObject *申請的,難道謎底就在這多出的兩個PyObject *?
在創建<class A>時,我們曾說到,Python虛擬機設置了一個名為tp_dictoffset的域,從名字上判斷,這個可能就是instance對象中__dict__的偏移位置。下圖1-1展示了我們的猜想:
圖1-1 猜想中的a.__dict__
圖1-1中,虛線畫的dict對象就是我們期望中的a.__dict__。這個猜想可以在PyObject_GenericGetAttr中與上述的偽代碼得到證實:
object.c
PyObject * PyObject_GenericGetAttr(PyObject *obj, PyObject *name) { PyTypeObject *tp = obj->ob_type; PyObject *descr = NULL; PyObject *res = NULL; descrgetfunc f; Py_ssize_t dictoffset; PyObject **dictptr; …… dictoffset = tp->tp_dictoffset; if (dictoffset != 0) { PyObject *dict; //處理變長對象 if (dictoffset < 0) { Py_ssize_t tsize; size_t size; tsize = ((PyVarObject *)obj)->ob_size; if (tsize < 0) tsize = -tsize; size = _PyObject_VAR_SIZE(tp, tsize); dictoffset += (long)size; assert(dictoffset > 0); assert(dictoffset % SIZEOF_VOID_P == 0); } dictptr = (PyObject **) ((char *)obj + dictoffset); dict = *dictptr; if (dict != NULL) { Py_INCREF(dict); res = PyDict_GetItem(dict, name); …… } } …… }
如果dictoffset小於0,意味着A是繼承自str這樣的變長對象,Python虛擬機會對dictoffset進行一些處理,最終仍然會使dictoffset指向a的內存額外申請的位置。而PyObject_GenericGetAttr正是根據這個dictoffset獲得一個dict對象。更進一步,查看函數g中有設置self(即<instance a>)中設置的a屬性,這個instance對象的屬性設置動作也會訪問a.__dict__,而且這個動作最終調用的PyObject_GenericSetAttr也是a.__dict__最初被創建的地方:
object.c
int PyObject_GenericSetAttr(PyObject *obj, PyObject *name, PyObject *value) { PyTypeObject *tp = obj->ob_type; PyObject *descr; …… dictptr = _PyObject_GetDictPtr(obj); if (dictptr != NULL) { PyObject *dict = *dictptr; if (dict == NULL && value != NULL) { dict = PyDict_New(); if (dict == NULL) goto done; *dictptr = dict; } …… } …… }
其中_PyObject_GetDictPtr的代碼就是PyObject_GenericGetAttr中根據dictoffset獲得dict對象的那段代碼
再論descriptor
在上面的偽代碼中出現了“descriptor”,這個命名其實是有意為之,目的是喚起前面我們在Python虛擬機類機制之填充tp_dict(二)這一章中所描述過的descriptor。前面我們看到,在PyType_Ready中,Python虛擬機會填充tp_dict,其中與操作名對應的是一個個descriptor,那時我們看到的是descriptor這個概念在Python內部是如何實現的。現在,我們將要剖析的是descriptor在Python的類機制究竟會起到怎樣的作用
在Python虛擬機對class對象或instance對象進行屬性訪問時,descriptor將對屬性訪問的行為產生重大的影響,一般而言,對於一個Python中的對象obj,如果obj.__class__對應的class對象中存在__get__、__set__和__delete__三種操作,那么obj就可以稱為Python的一個descriptor。在slotdefs中,我們會看到__get__、__set__、__delete__對應的操作:
typeobject.c
static slotdef slotdefs[] = { …… TPSLOT("__get__", tp_descr_get, slot_tp_descr_get, wrap_descr_get, "descr.__get__(obj[, type]) -> value"), TPSLOT("__set__", tp_descr_set, slot_tp_descr_set, wrap_descr_set, "descr.__set__(obj, value)"), TPSLOT("__delete__", tp_descr_set, slot_tp_descr_set, wrap_descr_delete, "descr.__delete__(obj)"), …… }
在前面幾章我們看到了PyWrapperDescrObject、PyMethodDescrObject等對象,它們對應的class對象中分別為tp_descr_get設置了wrapperdescr_get、method_get等函數,所以,它們是descriptor
如果細分,那么descriptor還可分為如下兩種:
- data descriptor:type中定義了__get__和__set__的descriptor
- non data descriptor:type中只定義了__get__的descriptor
在Python虛擬機訪問instance對象的屬性時,descriptor的一個作用是影響Python虛擬機對屬性的選擇。從PyObject_GenericGetAttr的偽代碼可以看出,Python虛擬機會在instance對象自身的__dict__中尋找屬性,也會在instance對象對應的class的mro列表中尋找屬性,我們將前一種屬性稱為instance屬性,而后一種稱為class屬性
雖然PyObject_GenericGetAttr里對屬性進行選擇的算法比較復雜,但是從最終的效果上,我們可以總結處如下的兩條規則:
- Python虛擬機按照instance屬性、class屬性的順序選擇屬性,即instance屬性優先於class屬性
- 如果在class屬性中發現同名的data descriptor,那么該descriptor會優先於instance屬性被Python虛擬機選擇
這兩條規則在對屬性進行設置時仍然會被嚴格遵守,換句話說,如果執行"a.value = 1",就算在A中發現一個名為"value"的no data descriptor,那么還是會設置a.__dict__['value'] = 1,而不會設置A中已有的屬性
當最終獲得的屬性是一個descriptor,最神奇的事發生了,Python虛擬機不是簡單的返回descriptor,而是如偽代碼所示的那樣,調用descriptor.__get__,將調用的結果返回,在下面的代碼示例中,展示了descriptor對屬性訪問行為的影響:
descriptor改變返回值
>>> class A(list): ... def __get__(self, instance, owner): ... return "A.__get__" ... >>> class B(object): ... value = A() ... >>> b = B() >>> b.value 'A.__get__' >>> s = b.value >>> type(s) <class 'str'>
instance屬性優先於non data descriptor
>>> class A(list): ... def __get__(self, instance, owner): ... return "A.__get__" ... >>> class B(object): ... value = A() ... >>> b = B() >>> b.value = 1 >>> b.__dict__["value"] 1 >>> b.__class__.__dict__["value"] []
data descriptor優先於instance屬性
>>> class A(list): ... def __get__(self, instance, owner): ... return "A.__get__" ... def __set__(self, instance, value): ... print("A.__set__") ... self.append(value) ... >>> class B(object): ... value = A() ... >>> b = B() >>> b.value = 1 A.__set__ >>> b.__dict__["value"] Traceback (most recent call last): File "<stdin>", line 1, in <module> KeyError: 'value' >>> b.__class__.__dict__["value"] [1]
前面我們說,當訪問的屬性最終對應的是一個descriptor時,會調用其__get__方法,並將__get__的結果作為返回。其實這個說法不是完全正確的,仔細對比type_getattro和PyObject_GenericGetAttr的代碼,我們會發現它們在對待descriptor上存在差異。在PyObject_GenericGetAttr中,如果查詢到的descriptor存在於class對象的tp_dict中,會調用其__get__方法;若它存在於instance對象的tp_dict中,則不會調用其__get__方法
>>> class A(object): ... def __get__(self, instance, owner): ... return "Python" ... >>> class B(object): ... desc_in_class = A() ... >>> B.desc_in_class 'Python' >>> b = B() >>> b.desc_in_class 'Python' >>> b.desc_in_class = A() >>> b.desc_in_class <__main__.A object at 0x000000FBDD76C908>
到這里,我們已經看到,descriptor對屬性訪問的影響主要在兩個方面:其一是對訪問順序的影響,其二是對訪問結果的影響,第二種影響正是類的成員函數調用的關鍵
函數變身
demo1.py
class A(object): name = "Python" def __init__(self): print("A::__init__") def f(self): print("A::f") def g(self, aValue): self.value = aValue print(self.value) a = A() a.f() a.g(10)
在前面討論創建class A對象時,我們看到A.__dict__中保存了一個與符號"f"對應的PyFunctionObject對象,所以在偽代碼中的descriptor對應的就是一個PyFunctionObject對象。先拋開偽代碼中確定最終返回值的過程不說,我們從另一個角度來看一看,假設PyFunctionObject作為LOAD_ATTR的最終結果,在LOAD_ATTR指令代碼的最后被SET_TOP壓入到運行時棧,那會有什么后果呢?
在A的成員函數f的def語句中,我們看到一個self參數,self在Python中是不是一個有效的參數呢?還是它僅僅是語法意義上的占位符?這一點可以從g中看到答案,在函數g中有再這樣的語句:self.value = aValue。這條語句毫無疑問地揭示了self是一個貨真價實的參數,所以也表明了函數f也是一個帶參函數。現在,問題來了,根據我們之前對函數機制的分析,Python通常會將參數事先壓入運行時棧中,但是demo1.py中的a.f語句編譯后的指令序列中可以看到,Python在獲得a.f對應的對象后,沒有進行任何普通函數調用時將參數壓入棧的動作,而是直接執行了CALL_FUNCTION指令
a.f()調用指令
16 31 LOAD_NAME 2 (a) 34 LOAD_ATTR 3 (f) 37 CALL_FUNCTION 0 40 POP_TOP
這里沒有任何像參數的東西在棧中,棧中只有一個可能是a.f的PyFunctionObject對象,那么這個遺失的self參數究竟在什么地方?
既然棧中沒有參數,而棧中唯一的PyFunctionObject對象又需要參數,那么說明,我們之前的推理可能是錯誤的,所以,棧中的對象只能是另一種我們尚未了結的對象,由於是通過訪問屬性"f"得到的這個對象,所以一個合理的假設是:在這個對象中,還包含函數f的參數:self
在之前介紹函數機制的時候,我們似乎忘記介紹一個對象PyFunction_Type,這是PyFunctionObject對象對應的class對象,觀察PyFunction_Type對象,我們會發現與__get__對應的tp_descr_get被設置為&func_descr_get,這意味着這里的A.f實際上是一個descriptor。由於PyFunc_Type中並沒有設置func_descr_set,所以A.f是一個non data descriptor。此外,由於在a.__dict__中沒有f符號的存在,所以根據偽代碼中的算法,a.f的的返回值將被descriptor改變,其結果將是A.f.__get__,也就是func_descr_get(A.f, a, A)
funcobject.c
PyTypeObject PyFunction_Type = { …… func_descr_get, /* tp_descr_get */ …… }; …… static PyObject * func_descr_get(PyObject *func, PyObject *obj, PyObject *type) { if (obj == Py_None) obj = NULL; return PyMethod_New(func, obj, type); }
func_descr_get將A.f對應的PyFunctionObject進行了一番包裝,通過PyMethod_New在PyFunctionObject的基礎上創建了一個新的對象,於是,我們再進入到PyMethod_New
funcobject.c
PyObject * PyMethod_New(PyObject *func, PyObject *self, PyObject *klass) { register PyMethodObject *im; …… im = free_list; if (im != NULL) { //使用緩沖池 free_list = (PyMethodObject *)(im->im_self); PyObject_INIT(im, &PyMethod_Type); } else { //不使用緩沖池,直接創建PyMethodObject對象 im = PyObject_GC_New(PyMethodObject, &PyMethod_Type); if (im == NULL) return NULL; } im->im_weakreflist = NULL; Py_INCREF(func); im->im_func = func; Py_XINCREF(self); //這里就是self對象 im->im_self = self; Py_XINCREF(klass); im->im_class = klass; _PyObject_GC_TRACK(im); return (PyObject *)im; }
這里我們可以知道,原先運行時棧中已經不再是PyFunctionObject對象,而是PyMethodObject對象。看到free_list這樣熟悉的字眼,我們可以立即判斷出,在PyMethodObject的實現和管理中,Python采用了緩沖池的技術,現在來看一看這個PyMethodObject
typedef struct { PyObject_HEAD PyObject *im_func; //可調用的PyFunctionObject對象 PyObject *im_self; //用於成員函數調用的self參數,instance對象 PyObject *im_class; //class對象 PyObject *im_weakreflist; } PyMethodObject;
在PyMethod_New中,分別將im_func、im_self、im_class設置了不同的值,結合a.f,分別對應符號"f"所對應的PyFunctionObject對象,符號"a"對應的instance對象,以及<class A>對象
在Python中,將PyFunctionObject對象和一個instance對象通過PyMethodObject對象結合在一起的過程就稱為成員函數的綁定。下面的代碼清晰地展示了在訪問屬性時,發生函數綁定的結果:
>>> class A(object): ... def f(self): ... pass ... >>> a = A() >>> a.__class__.__dict__["f"] <function A.f at 0x000000FBDD74E620> >>> a.f <bound method A.f of <__main__.A object at 0x000000FBDD76CE80>>
無參函數的調用
在LOAD_ATTR指令之后,指令"37 CALL_FUNCTION 0"開始了函數調用的動作,之前我們研究過對於PyFunctionObject對象的調用,而對於PyMethodObject對象,情況則有些不同,如下:
ceval.c
static PyObject * call_function(PyObject ***pp_stack, int oparg) { int na = oparg & 0xff; int nk = (oparg >> 8) & 0xff; int n = na + 2 * nk; PyObject **pfunc = (*pp_stack) - n - 1; PyObject *func = *pfunc; PyObject *x, *w; …… if (PyCFunction_Check(func) && nk == 0) { …… } else { //[1]:從PyMethodObject對象中抽取PyFunctionObject對象和self參數 if (PyMethod_Check(func) && PyMethod_GET_SELF(func) != NULL) { PyObject *self = PyMethod_GET_SELF(func); func = PyMethod_GET_FUNCTION(func); //[2]:self參數入棧,調整參數信息變量 *pfunc = self; na++; n++; } if (PyFunction_Check(func)) x = fast_function(func, pp_stack, n, na, nk); else x = do_call(func, pp_stack, na, nk); …… } …… return x; }
調用成員函數f時,顯示傳入的參數個數為0,也就是說,調用f時,Python虛擬機沒有進行參數入棧的動作。而f顯然至少需要一個實例對象的參數,而正是在call_function中,Python虛擬機為PyMethodObject進行了一些參數處理的動作
Python虛擬機執行a.f()時,在call_function中,代碼[1]處的判斷將會成立,其中PyMethod_GET_SELF被定義為:
classobject.h
#define PyMethod_GET_SELF(meth) \ (((PyMethodObject *)meth) -> im_self)
在call_function中,func變量指向一個PyMethodObject對象,在上述代碼[1]處成立后,在if分支中又會將PyMethodObject對象中的PyFunctionObject對象和instance對象分別提取出來,在if分支中有一處最重要的代碼,即[2]處,pfunc指向的位置正是運行時棧中存放PyMethodObject對象的位置,那么這個本來屬於PyMethodObject對象的地方改為存放instance對象究竟有什么作用呢?在這里,Python虛擬機以另一種方式完成了函數參數入棧的動作,本來屬於PyMethodObject對象的內存空間被用作了函數f的self參數的容身之處,圖1-1展示了運行call_function時運行時棧的變化情況:
圖1-1 設置self參數
a是設置pfunc之前的運行時棧,b表示設置了pfunc之后的運行時棧。在call_function中,接着還會通過PyMethod_GET_FUNCTION將PyMethodObject對象中的PyFunctionObject對象取出,隨后在[2]處,Python虛擬機完成了self參數的入棧,同時還調整了維護着參數信息的na和n,調整后的結果意味着函數會獲得一個位置參數,看一看class A中的f的def語句,self正是一個位置參數
由於func在if分支之后指向了PyFunctionObject對象,所以接下來Python執行引擎將進入fast_function。到了這里,剩下的動作就和我們之前所分析的帶參函數的調用一致。實際上a.f的調用是指上就是一個帶一個位置參數的一般函數調用,而在fast_function,作為self參數的<instance a>被Python虛擬機壓入到了運行時棧中,由於a.f僅僅是一個帶位置參數的函數,所以Python執行引擎將進入快速通道,在快速通道中,運行時棧中的這個instance對象會被拷貝到新的PyFrameObject對象的f_localsplus中
ceval.c
static PyObject * fast_function(PyObject *func, PyObject ***pp_stack, int n, int na, int nk) { PyCodeObject *co = (PyCodeObject *)PyFunction_GET_CODE(func); PyObject *globals = PyFunction_GET_GLOBALS(func); PyObject *argdefs = PyFunction_GET_DEFAULTS(func); PyObject **d = NULL; int nd = 0; PCALL(PCALL_FUNCTION); PCALL(PCALL_FAST_FUNCTION); if (argdefs == NULL && co->co_argcount == n && nk == 0 && co->co_flags == (CO_OPTIMIZED | CO_NEWLOCALS | CO_NOFREE)) { //創建新的PyFrameObject對象f PyFrameObject *f; f = PyFrame_New(tstate, co, globals, NULL); if (f == NULL) return NULL; fastlocals = f->f_localsplus; //[1]:獲得棧頂指針 stack = (*pp_stack) - n; for (i = 0; i < n; i++) { //[2]: fastlocals[i] = *stack++; } …… } …… }
在調用fast_function時,參數的數量n已經由執行CALL_FUNCTION時的0變為了1,所以代碼[1]處的stack指向的位置就和圖1-1中pfunc指向的位置是一致的了,在代碼的[2]處將<instance a>作為參數拷貝到函數的參數區fastlocals中,必須將它放置到棧頂,也就是以前PyMethodObject對象所在的位置上,也也就是前面call_function那個賦值操作的原因
帶參函數的調用
Python虛擬機對類中帶參的成員函數的調用,其原理和流程與無參函數的調用是一致的,我們來看看a.g(10)的字節碼序列:
17 41 LOAD_NAME 2 (a) 44 LOAD_ATTR 4 (g) 47 LOAD_CONST 2 (10) 50 CALL_FUNCTION 1 53 POP_TOP
可以看到,和調用成員函數f的指令序列幾乎完全一致,只是多了一個"47 LOAD_CONST 2 (10)"。對於這個指令我們不會陌生,在分析函數機制的時候,我們看到它是用來將函數所需的參數壓入到運行時棧中。對於g,真正有趣的地方在於考察函數的實現代碼,從而可以看到那個作為self參數的instance對象的使用:
>>> dis.dis(A.g) 11 0 LOAD_FAST 1 (aValue) 3 LOAD_FAST 0 (self) 6 STORE_ATTR 0 (value) 12 9 LOAD_FAST 0 (self) 12 LOAD_ATTR 0 (value) 15 PRINT_ITEM 16 PRINT_NEWLINE
顯然,其中的LOAD_FAST、LOAD_ATTR、STORE_ATTR這些字節碼指令都涉及到了作為self參數的instance對象,有興趣的同學可以分析一下STORE_ATTR的代碼,可以發現其中也有類似於LOAD_ATTR中PyObject_GenericGetAttr的屬性訪問算法
其實到了這里,我們可以在更高的層次俯視一下Python的運行模型,最核心的模型其實非常簡單,可以簡化為兩條規則:
- 在某個名字空間中尋找符號對應的對象
- 對從名字空間中得到的對象進行某些操作
拋開面向對象花里胡哨的外表,其實我們會發現,class對象其實就是一個名字空間,instance對象也是一個名字空間,不過這些名字空間通過一些特殊的規則關聯在一起,使得符號的搜索過程變得復雜,從而實現了面向對象這種編程模式