关于 PyStringObject,有一个地球人都知道的严重影响 Python 程序执行效率的问题,有一种说法,绝大部分执行效率特别低下的 Python 程序都是由于没有注意到这个问题所致。下面我们就来看看这个在Python中举足轻重的问题——字符串连接。
假如现在有两个字符串 Python 和 Ruby,在Java或C#中,都可以通过使用“+”操作符,将两个字符串连接在一起,得到一个新的字符串“PythonRuby”。当然,Python中同样提供了利用“+”操作符连接字符串的功能,然而不幸的是,这样的做法正是万恶之源。
Python中通过“+”进行字符串连接的方法效率及其低下,其根源在于Python中的PyStringObject对象是一个不可变对象。这就意味着当进行字符串连接时,实际上是必须要创建一个新的PyStringObject对象。这样,如果要连接N个PyStringObject对象,那么就必须进行N-1次的内存申请及内存搬运的工作。这将严重影响Python的执行效率。
官方推荐的做法是通过利用PyStringObject对象的join操作来对存储在list或tuple中的一组PyStringObject对象进行连接操作,这种做法只需要分配一次内存,执行效率将大大提高。
下面我们通过考察源码来更细致地了解这一问题。
通过“+”操作符对字符串进行连接时,会调用string_concat函数:
- [stringobject.c]
- static PyObject* string_concat(register PyStringObject *a, register PyObject *bb)
- {
- register unsigned int size;
- register PyStringObject *op;
- #define b ((PyStringObject *)bb)
- ……
- size = a->ob_size + b->ob_size;
- /* Inline PyObject_NewVar */
- op = (PyStringObject *)PyObject_MALLOC(sizeof(PyStringObject) + size);
- if (op == NULL)
- return PyErr_NoMemory();
- PyObject_INIT_VAR(op, &PyString_Type, size);
- op->ob_shash = -1;
- op->ob_sstate = SSTATE_NOT_INTERNED;
- memcpy(op->ob_sval, a->ob_sval, (int) a->ob_size);
- memcpy(op->ob_sval + a->ob_size, b->ob_sval, (int) b->ob_size);
- op->ob_sval[size] = '\0';
- return (PyObject *) op;
- #undef b
- }
对于任意两个PyStringObject对象的连接,就会进行一次内存申请的动作。而如果利用PyStringObject对象的join操作,则会进行如下的动作(假设是对list中的PyStringObject对象进行连接):
- [stringobject.c]
- static PyObject* string_join(PyStringObject *self, PyObject *orig)
- {
- char *sep = PyString_AS_STRING(self);
- const int seplen = PyString_GET_SIZE(self);
- PyObject *res = NULL;
- char *p;
- int seqlen = 0;
- size_t sz = 0;
- int i;
- PyObject *seq, *item;
- //获得list中PyStringObject对象的个数,保存在seqlen中
-
- for (i = 0; i < seqlen; i++)
- {
- const size_t old_sz = sz;
- item = PySequence_Fast_GET_ITEM(seq, i);
- sz += PyString_GET_SIZE(item);
- if (i != 0)
- sz += seplen;
- }
- /* 申请内存空间 */
- res = PyString_FromStringAndSize((char*)NULL, (int)sz);
- /* 连接list中的每一个PyStringObject对象*/
- p = PyString_AS_STRING(res);
- for (i = 0; i < seqlen; ++i){
- size_t n;
- /* 获得list中的一个PyStringObject对象*/
- item = PySequence_Fast_GET_ITEM(seq, i);
- n = PyString_GET_SIZE(item);
- memcpy(p, PyString_AS_STRING(item), n);
- p += n;
- if (i < seqlen - 1)
- {
- memcpy(p, sep, seplen);
- p += seplen;
- }
- }
- Py_DECREF(seq);
- return res;
- }
执行join操作时,会首先统计出在list中一共有多少个PyStringObject对象,并统计这些PyStringObject对象所维护的字符串一共有多长,然后申请内存,将list中所有的PyStringObject对象维护的字符串都拷贝到新开辟的内存空间中。注意,这里只进行了一次内存空间的申请,就完成了N个PyStringObject对象的连接操作。相比于“+”操作符,待连接的PyStringObject对象越多,效率的提升也越明显。
在这一节,我们对PyStringObject在运行时行为进行两项观察。
首先,观察Intern机制,在Python Interactive环境中,创建一个PyStringObject后,就会对这个PyStringObject对象进行Intern操作,所以我们预期内容相同的PyStringObject对象在Intern后应该是同一个对象,图4是观察的结果:
通过在string_length中添加打印地址的代码,我们可以在运行时获得每一个PyStringObject对象的地址,从观察结果中可以看到,无论是对于一般的字符串,还是对于单个字符,Intern机制最终都使不同的PyStringObject指针指向了相同的对象。
然后,我们观察Python中进行缓冲处理的字符对象,同样是通过在string_length中添加代码,打印出缓冲池中从a到e的字符对象的引用计数信息。需要注意的是,为了避免执行len(…)对引用计数的影响,我们并不会对a到e的字符对象调用len操作,而是对另外的PyStringObject对象调用len操作:
- static void ShowCharater()
- {
- char a = 'a';
- PyStringObject** posA = characters+(unsigned short)a;
- int i;
- for(i = 0; i < 5; ++i)
- {
- PyStringObject* strObj = posA[i];
- printf("%s, %d\n", strObj->ob_sval, strObj->ob_refcnt);
- }
- }
图5展示了观察的结果,可以看到,在创建字符对象时,Python 确实只是使用了缓冲池里的对象,而没有创建新的对象。