上次总结Python3的字典实现后的某一天,突然开窍Python的__slots__的实现应该也是类似,于是翻了翻CPython的源码,果然如此!
关于在自定义类里面添加__slots__的效果,网上已经有很多资料了,其中优点大致有:
(1)更省内存。
(2)访问属性更高效。
而本文讲的是,为什么更省内存?为什么更高效?当然为了弄明白这些,深入到CPython的源码是必不可少的。不过,心里有个猜想之后再去看源码效果或许更好,这样目的性更强,清楚自己需要关注的是什么以免在其中迷失!
我先稍微解释一下:
(1)更省内存是因为实例的属性不以字典的形式存储,而是以更紧凑的格式。
(2)更高效是因为实例在做属性查找的时候,节省了一次hash查找,改为以计算属性内存的偏移量直接读写内存。
接下来本文会从三方面分析定义了slots的作用以及影响,分别是:定义类时、创建实例为其分配内存时、以及从实例访问属性时。
1、定义类
先说一下在类定义时使用__slots__会有哪些影响
typeobject.c:
static PyObject * type_new(PyTypeObject *metatype, PyObject *args, PyObject *kwds) { ... /* Check for a __slots__ sequence variable in dict, and count it */ slots = PyDict_GetItemString(dict, "__slots__"); nslots = 0; if (slots == NULL) { /* 类定义中没有__slots__,不需要关注 */ } else { /* Have slots */ /* Make it into a tuple */ if (PyString_Check(slots) || PyUnicode_Check(slots)) slots = PyTuple_Pack(1, slots); else slots = PySequence_Tuple(slots); if (slots == NULL) { Py_DECREF(bases); return NULL; } assert(PyTuple_Check(slots)); /* Copy slots into a list, mangle names and sort them. Sorted names are needed for __class__ assignment. Convert them back to tuple at the end. */ newslots = PyList_New(nslots - add_dict - add_weak); if (newslots == NULL) goto bad_slots; for (i = j = 0; i < nslots; i++) { char *s; tmp = PyTuple_GET_ITEM(slots, i); s = PyString_AS_STRING(tmp); if ((add_dict && strcmp(s, "__dict__") == 0) || (add_weak && strcmp(s, "__weakref__") == 0)) continue; tmp =_Py_Mangle(name, tmp); if (!tmp) { Py_DECREF(newslots); goto bad_slots; } PyList_SET_ITEM(newslots, j, tmp); j++; } nslots = j; Py_DECREF(slots); if (PyList_Sort(newslots) == -1) { Py_DECREF(bases); Py_DECREF(newslots); return NULL; } slots = PyList_AsTuple(newslots); Py_DECREF(newslots); if (slots == NULL) { Py_DECREF(bases); return NULL; } } /* Allocate the type object */ /* 为类对象申请内存,这里分配内存时也考虑了存储slots需要的内存 */ type = (PyTypeObject *)metatype->tp_alloc(metatype, nslots); if (type == NULL) { Py_XDECREF(slots); Py_DECREF(bases); return NULL; } /* Add descriptors for custom slots from __slots__, or for __dict__ */ /* 将slots的数据作为member存储在类对象上,后续将会根据这个member创建具体的descriptior * 而实际上读写这个属性都是通过descriptior实现的 */ mp = PyHeapType_GET_MEMBERS(et); slotoffset = base->tp_basicsize; if (slots != NULL) { for (i = 0; i < nslots; i++, mp++) { mp->name = PyString_AS_STRING( PyTuple_GET_ITEM(slots, i)); mp->type = T_OBJECT_EX; mp->offset = slotoffset; /* __dict__ and __weakref__ are already filtered out */ assert(strcmp(mp->name, "__dict__") != 0); assert(strcmp(mp->name, "__weakref__") != 0); slotoffset += sizeof(PyObject *); } } /* 类的type->tp_basicsize这个值描述了实例所占内存的大小(当然只是内存的一部分) * 而从上面的代码可以看出,slotoffset这个值包含了nslots个指针大小。没错!这个指针就是实际存储属性用的 * 因此slots是直接存储在实例内存上面的,而属性的具体位置的偏移值信息则以member存储在类对象上 */ type->tp_basicsize = slotoffset; type->tp_itemsize = base->tp_itemsize; type->tp_members = PyHeapType_GET_MEMBERS(et); /* Always override allocation strategy to use regular heap */ type->tp_alloc = PyType_GenericAlloc; /* 调用PyType_Ready这个函数时会为类身上的每个member创建一个descriptor * 当实例访问属性时,会需要借助这个descriptor的力量:P */ if (PyType_Ready(type) < 0) { Py_DECREF(type); return NULL; } return (PyObject *)type; }
当我们定义一个类的时候,最后会调用到上面type_new这个函数。由于只关注slots,因此我省略掉了一部分的代码。可以看出,如果有定义slots,那么会将其信息以member的形式存储在类的身上。观察初始化member的代码,可以发现关于访问属性的最重要的两个数据都在其中,一个是属性的内存位置,由相对于实例的偏移值mp->offset描述。通过这个偏移值,我们能拿到属性数据在内存起始地址,但却不知道如何解释这块内存,因此还需要一个类型信息,这个信息由mp->type来补充。
剩下的工作便是在调用函数PyType_Ready时,根据member中存储的信息,创建出执行访问操作的descriptor对象。
int PyType_Ready(PyTypeObject *type) { /* Add type-specific descriptors to tp_dict */ if (type->tp_members != NULL) { if (add_members(type, type->tp_members) < 0) goto error; } return 0; error: type->tp_flags &= ~Py_TPFLAGS_READYING; return -1; } static int add_members(PyTypeObject *type, PyMemberDef *memb) { PyObject *dict = type->tp_dict; for (; memb->name != NULL; memb++) { PyObject *descr; if (PyDict_GetItemString(dict, memb->name)) continue; descr = PyDescr_NewMember(type, memb); if (descr == NULL) return -1; if (PyDict_SetItemString(dict, memb->name, descr) < 0) { Py_DECREF(descr); return -1; } Py_DECREF(descr); } return 0; }
同样的,省略了很多其它不相关的代码。可以看出,最终根据member创建出的descriptor是存储在type对象上的tp_dict中的。
2、创建实例
当创建一个类的实例时,会为其分配内存。如果这个类定义了slots,那么会申请更多的内存,slots定义的属性便是存储在这部分内存中。直接看为实例申请内存的代码:
PyObject * PyType_GenericAlloc(PyTypeObject *type, Py_ssize_t nitems) { PyObject *obj; const size_t size = _PyObject_VAR_SIZE(type, nitems+1); /* note that we need to add one, for the sentinel */ if (PyType_IS_GC(type)) obj = _PyObject_GC_Malloc(size); else obj = (PyObject *)PyObject_MALLOC(size); if (obj == NULL) return PyErr_NoMemory(); memset(obj, ‘\0‘, size); if (type->tp_flags & Py_TPFLAGS_HEAPTYPE) Py_INCREF(type); if (type->tp_itemsize == 0) (void)PyObject_INIT(obj, type); else (void) PyObject_INIT_VAR((PyVarObject *)obj, type, nitems); if (PyType_IS_GC(type)) _PyObject_GC_TRACK(obj); return obj; } #define _PyObject_VAR_SIZE(typeobj, nitems) \ (size_t) ( ( (typeobj)->tp_basicsize + (nitems)*(typeobj)->tp_itemsize + (SIZEOF_VOID_P - 1) ) & ~(SIZEOF_VOID_P - 1) )
从代码可知,实例的内存大小与其type对象的tp_basicsize是相关联的。回看之前定义类时的type_new函数,会发现tp_basicsize这个值已经是包含了slots所需的内存了(详见计算member偏移值那部分代码)。type_new为slots中的每一项都分配一个指针长度的内存,而日后实例的属性便是存储在这个位置上。这也正是slots更省内存的原因!
3、访问属性
最后来看从实例上访问slots的属性是怎样的,以读属性的值为例
/* Generic GetAttr functions - put these in your tp_[gs]etattro slot */ PyObject * _PyObject_GenericGetAttrWithDict(PyObject *obj, PyObject *name, PyObject *dict) { PyTypeObject *tp = Py_TYPE(obj); PyObject *descr = NULL; PyObject *res = NULL; descrgetfunc f; Py_ssize_t dictoffset; PyObject **dictptr; if (tp->tp_dict == NULL) { if (PyType_Ready(tp) < 0) goto done; } descr = _PyType_Lookup(tp, name); Py_XINCREF(descr); f = NULL; if (descr != NULL && PyType_HasFeature(descr->ob_type, Py_TPFLAGS_HAVE_CLASS)) { f = descr->ob_type->tp_descr_get; if (f != NULL && PyDescr_IsData(descr)) { res = f(descr, obj, (PyObject *)obj->ob_type); Py_DECREF(descr); goto done; } } if (dict == NULL) { /* Inline _PyObject_GetDictPtr */ dictoffset = tp->tp_dictoffset; if (dictoffset != 0) { 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); if (res != NULL) { Py_INCREF(res); Py_XDECREF(descr); Py_DECREF(dict); goto done; } Py_DECREF(dict); } if (f != NULL) { res = f(descr, obj, (PyObject *)Py_TYPE(obj)); Py_DECREF(descr); goto done; } if (descr != NULL) { res = descr; /* descr was already increfed above */ goto done; } PyErr_Format(PyExc_AttributeError, "‘%.50s‘ object has no attribute ‘%.400s‘", tp->tp_name, PyString_AS_STRING(name)); done: Py_DECREF(name); return res; }
当从实例身上访问一个属性时,首先尝试从类对象的tp_dict查找,是否存在对应的descriptor。若是(查找slots的属性正是如此),调用descriptor身上的tp_descr_get方法,并将方法的返回值作为这次属性查找的结果返回。
从中也可以看出,如果是访问正常的属性时,还要根据type对象的dictoffset偏移值找到实例的属性字典,然后再在这个字典中执行hash查找属性。这就是为什么定义了slots后属性查找理论上会更高效。
看看tp_descr_get方法长啥样:
PyTypeObject PyMemberDescr_Type = { PyVarObject_HEAD_INIT(&PyType_Type, 0) "member_descriptor", sizeof(PyMemberDescrObject), 0, (destructor)descr_dealloc, /* tp_dealloc */ 0, /* tp_print */ 0, /* tp_getattr */ 0, /* tp_setattr */ 0, /* tp_compare */ (reprfunc)member_repr, /* tp_repr */ 0, /* tp_as_number */ 0, /* tp_as_sequence */ 0, /* tp_as_mapping */ 0, /* tp_hash */ 0, /* tp_call */ 0, /* tp_str */ PyObject_GenericGetAttr, /* tp_getattro */ 0, /* tp_setattro */ 0, /* tp_as_buffer */ Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC, /* tp_flags */ 0, /* tp_doc */ descr_traverse, /* tp_traverse */ 0, /* tp_clear */ 0, /* tp_richcompare */ 0, /* tp_weaklistoffset */ 0, /* tp_iter */ 0, /* tp_iternext */ 0, /* tp_methods */ descr_members, /* tp_members */ member_getset, /* tp_getset */ 0, /* tp_base */ 0, /* tp_dict */ (descrgetfunc)member_get, /* tp_descr_get */ (descrsetfunc)member_set, /* tp_descr_set */ }; static PyObject * member_get(PyMemberDescrObject *descr, PyObject *obj, PyObject *type) { PyObject *res; if (descr_check((PyDescrObject *)descr, obj, &res)) return res; return PyMember_GetOne((char *)obj, descr->d_member); }
原来最后是通过函数PyMember_GetOne来获取属性。好!继续深入:
PyObject * PyMember_GetOne(const char *addr, PyMemberDef *l) { PyObject *v; if ((l->flags & READ_RESTRICTED) && PyEval_GetRestricted()) { PyErr_SetString(PyExc_RuntimeError, "restricted attribute"); return NULL; } addr += l->offset; switch (l->type) { case T_BOOL: v = PyBool_FromLong(*(char*)addr); break; case T_BYTE: v = PyInt_FromLong(*(char*)addr); break; case T_UBYTE: v = PyLong_FromUnsignedLong(*(unsigned char*)addr); break; case T_SHORT: v = PyInt_FromLong(*(short*)addr); break; case T_USHORT: v = PyLong_FromUnsignedLong(*(unsigned short*)addr); break; case T_INT: v = PyInt_FromLong(*(int*)addr); break; case T_UINT: v = PyLong_FromUnsignedLong(*(unsigned int*)addr); break; case T_LONG: v = PyInt_FromLong(*(long*)addr); break; case T_ULONG: v = PyLong_FromUnsignedLong(*(unsigned long*)addr); break; case T_PYSSIZET: v = PyInt_FromSsize_t(*(Py_ssize_t*)addr); break; case T_FLOAT: v = PyFloat_FromDouble((double)*(float*)addr); break; case T_DOUBLE: v = PyFloat_FromDouble(*(double*)addr); break; case T_STRING: if (*(char**)addr == NULL) { Py_INCREF(Py_None); v = Py_None; } else v = PyString_FromString(*(char**)addr); break; case T_STRING_INPLACE: v = PyString_FromString((char*)addr); break; case T_CHAR: v = PyString_FromStringAndSize((char*)addr, 1); break; case T_OBJECT: v = *(PyObject **)addr; if (v == NULL) v = Py_None; Py_INCREF(v); break; case T_OBJECT_EX: /* slots对应的member->type是T_OBJECT_EX */ v = *(PyObject **)addr; if (v == NULL) PyErr_SetString(PyExc_AttributeError, l->name); Py_XINCREF(v); break; #ifdef HAVE_LONG_LONG case T_LONGLONG: v = PyLong_FromLongLong(*(PY_LONG_LONG *)addr); break; case T_ULONGLONG: v = PyLong_FromUnsignedLongLong(*(unsigned PY_LONG_LONG *)addr); break; #endif /* HAVE_LONG_LONG */ default: PyErr_SetString(PyExc_SystemError, "bad memberdescr type"); v = NULL; } return v; }
终于都看到了,根据member所记录的偏移值和类型,访问属性内存的代码了!