Python中源码之字符串底层解析

文章目录

  • 1、Python2中的PystringObject
    • 1.2 PyStringObject的创建
    • 1.3 intern机制
    • 1.4 字符串缓冲池
    • 1.5 PyStringObject的某些操作效率
  • 2、Python3中的PyUnicodeObject

  Python的对象分为 “可变对象” 和 “不可变对象”, 可变对象也还可以分为 “可变” 和 “不可变”,这里所谓的可变就是说对象所维护的数据是可以变化的,举个例子说明,list容器中的元素可以进行添加、删除、修改等操作,也就是说这个容器对象所维护的数据是可以动态变化的;而所谓 “不可变” 就是说,此对象中所维护的数据一旦创建后就不能发生改变,即便对这个对象进行某种操作后生成的数据也只能是一个新的地址,例如tuple容器。这一节我们将研究Python中的字符串对象。

1、Python2中的PystringObject

  在本节我们也会先来解析一下Python2中对于字符串对象的实现,然后再简述一下在Python3中的字符串对象和Python2中有什么不同。实际Python中的字符串对象就是通过PyStringObject来实现的, 它是一个内存大小可变的一个对象(不是说不可变吗?怎么又可变了?什么鬼?)。之所以说它可变是因为在创建字符串对象的时候,我们是不能提前预知字符串的长度的,所以在PyStringObject对象中必须要有可以用来记录字符串长度的成员。举个例子,‘java’ 和 ‘Python’ 这两个字符串的长度显然是不一样的,因此这两个字符串所占用的内存空间也是不一样的。But talk is cheap, show me the code, 让我们来看看底层C语言的实现

// stringobject.h
typedef struct {
    PyObject_VAR_HEAD;  // 这是在PyObject中定义的宏
    long ob_shash;
    int ob_sstate;
    char ob_sval[1];
}PyStringObject;

可以非常清楚地看见,PyStringObject的头部实际上是一个PyObject_VAR_HEAD,这个头部中维护了一个ob_size的变量,这个变量用来保存可变内存的大小。

  ob_shash变量是用来缓存该对象的哈希值,之所以缓存哈希值是因为避免重复计算,它具体的实现算法大家有兴趣可以去参考一下源码,这里我们的重点是解析Python,就不展开了。

  ob_sstate变量用于记录该对象是否已经经过了intern机制的处理,这个intern机制是个啥?我们后面会详细聊这个牛逼哄哄的玩意儿。

  ob_sval是一个字符数组,这是个啥玩意儿?为啥数组长度只有1 ?你接着往下看就知道了。实际上这货是一个字符指针,这个指针指向了一段内存,而这段内存就是这个字符串对象中所维护的实际的字符串。这段装有实际字符串的字节数(在c语言中一个字符用一个字节来存储)就是由上面说的PyObject_VAR_HEAD中的ob_size变量来维护的。需要注意的是,ob_sval这个字符指针指向的内存字节数也就是长度并不是ob_size,而是ob_size+1。我们知道在C语言中,对于一段字符串结束的标志是一个叫做 ‘\0’ 的字符,所以在PyStringObject的字符串对象中,不以 ‘\0’ 作为结束处理,万一这个字符串中间有这个字符呢,那不就傻X了吗?所以我们在最后末尾添加结束字符,所以这段内存就必须满足ob_sval[ob_size + 1] = ‘\0’. 实际上在Python2中所有变长对象的实现机制都是基于这个叫做ob_size的玩意儿来的。

  • 1.2 PyStringObject的创建

  与Python中的整数对象一样,PyStringObject对象也有多种创建方式。原生的创建方式就是通过PyString_FromString

// stringobject.c
PyObject* PyString_FromString(const char *str) {
    register size_t size;
    register PyStringObject *op;
    // (1)判断字符串长度
    size = strlen(str);
    if (size > PY_SSIZE_T_MAX) {
        return null;
}
    // (2)处理null string
    if (size == 0 && (op = nullstring) != NULL) {
        return (PyObject *)op;
}
    // (3)处理字符
    if (size == 1 && (op = characters[*str & UCHAR_MAX]) != NULL) {
        return (PyObject *)op;
}
    // 创建新的PyStringObject对象
    op = (PyStringObject *)PyObject_MALLOC(sizeof(PyStringObject) + size);
    PyObject_INIT_VAR(OP, &PyString_Type, size);
    op -> ob_shash = -1;   // 在对象创建时对象哈希值置为 -1
    op -> ob_sstate = SSTATE_NOT_INTERNED;
    memcpy(op -> ob_sval, str, size+1);
    //........
}

可以看见这个函数所接受的参数是一个结束符为 ‘\0’的字符串的指针,第一步检查传入参数的长度,如果大于PY_SSIZE_T_MAX所定义的长度则不会返回字符串对象,这个变量是一个与系统相关的值,总之它很大,除非你传入一个N个g的字符串。第二步就是对空字符串的处理,这一步需要好好扳饬一哈,假如传入函数的字符串是一个空串,从代码逻辑上看好像都会返回一个PyStringObject,其实不然。我们看到在 if 语句中有一个nullstring的变量,实际上这是一个PyStringObject的指针,这个指针是负责处理空字符串,如果第一次创建一个空串,此时nullstring被初始化为NULL,所以这时会为这个空字符创建一个PyStringObject对象,并且将此对象通过intern机制共享,并且将这个被共享的对象赋值给nullstring指针。当再次需要创建一个空串对象时,则直接将nullstring指针指向的这个对象返回即可。

  如果传入的是一个有效的字符串,那么Python将会为这个对象申请内存空间,这个内存空间分为两部分,一部分是PyStringObject本身的内存,另一部分是用于存储实际字符串的空间,我们知道由于在C中,字符串是以字符数组的形式存在的,而一个字符是一个字节存储的,所以这个额外的大小就是size。需要注意的是,上面提出了一个疑问,ob_sval数组的长度为1,现在我们就来解答。上面说过这个字符数组是一个指向实际字符串的指针,也就是说这个数组的首地址实际上存放的就是字符串的第一个字符,然后依次在数组中存放其他字符,并且在最后存放一个 ‘\0’ 的结束字符,整个结构入下图所示(丑得我自己都没法儿看)。在申请完内存空间后,将对象的hash缓存值设置为-1,,将intern标志设置为SSTATE_NOT_INTERNED
Python中源码之字符串底层解析_第1张图片
这是创建字符串对象的一种方式,还有一种方式是通过PyString_FromStringAndSize,这种方式与第一种无二,只是没有传入参数必须以 ‘\0’结尾的限制。

  • 1.3 intern机制

      先上一段code
stringobject.c
PyObject* PyString_FromString(const char *str){
    // .........
    //共享长度较短的字符串对象
    if (size == 0){
        PyObject *t = (PyObject *)op;
        PyString_internInplace(&t);
        op = (PyStringObject *)t;
        nullstring = op;
} else if (size == 1){
    PyObject *t = (PyObject *)op;
    PyString_InternInplace(&t);
    op = (PyStringObject *)t;
    characters[*str & UCHAR_MAX] = op;
}
    return (PyObject *)op;
}

还记得上面我们创建字符串对象的代码吗?或许在看创建对象代码时有一点懵逼,但是看到这里相信你就豁然开朗了。没错,当字符数组的长度为1 的时候会先经过intern机制处理,并且让nullstring指向这个对象,以后在创建一个空串时就直接将这个共享的对象返回即可。其用意在于在Python运行的这个期间,对于小字符串而言只有一个PyStringObject对象,这样能够节省内存空间。此外,在进行两个字符串的比较时,如果它们都被intern了,那么只需要检查它们的PyObject*相同即可,这样也简化了字符串对象的比较,简直了。

  接下来我们来瞅瞅这个叫做PyString_internInplace的函数做了哪些骚操作。

stringobject.c
void PyString_InternInplace(PyObject **p){
    register PyStringObject *s = (PyStringObject *)(*p);
    PyObject *t;
    // 检查PyStringObject对象的类型和状态
    if (!PyString_CheckExtract(s)) return;
    if (PyString_CHECK_INTERNED(s)) return;
     
    //创建记录经过intern机制处理后的字符串对象的dict
    if (interned == NULL) interned = PyDict_New();
    // 检查PyStringObject对象s是否存在对应的intern后的PyStringObject对象
    t = PyDict_GetItem(interned, (PyObject *)s)
    if (t){
        //引用计数的调整
        Py_INCREF(t);
        Py_DECREF(*p);
        *p = t;
        return;
}
    // 在interned中记录检查PyStringObject对象 s
    PyDict_SetItem(interned, (PyObject *)s, (PyObject *)s );
    // 调整引用计数
    s -> ob_refcnt -= 2;
    //调整s中的intern状态标志
    PyString_CHECK_INTERNED(s) = SSTATE_INTERNED_MORTAL;
}

首先检查传入的对象是否是一个PyStringObject对象,因为intern机制只处理PyStringObject类型的对象;然后检查传入的对象是否已经经过了intern机制处理,如果是则直接返回,不再进行intern机制。接下来我们看见对interned变量进行了大量操作,这是个什么玩意儿?其实从定义中完全不知道是个什么鬼,定义中它是一个指向PyObject对象的静态指针变量。what?还是不求懂。但是在这里可以知道它实际上指向的是一个PyDictObject的对象,也就是Python中封装起来的dict。

  OK,敲黑板重点来了。也就是说实际上Python通过维护一个键值对映射的关系集合来实现intern机制,也就是interned变量啦。interned所指向的对象中,记录了被intern机制处理过的PyStringObject对象。当intern一个PyStringObject对象a时,会先在interned变量所指向的关系集合中去search是否存在这样一个对象b—它所维护的字符数组中的字符串与需要intern的对象相同。如果在这个关系集合中存在这样一个对象b,就做如下操作:1、将指向a的PyObject指针指向b, 2、将a的引用计数减1,对应代码中的Py_INCREF, Py_DECREF操作。所以实际上a只是一个临时的对象。

  如果不存在这样的对象b,就将a记录到interned所维护的关系集合中。有一点可能很奇怪,为什么最后引用计数会减2呢?因为在经过intern机制处理的PyStringObject对象中,采用了特殊的引用计数,在将对象添加到interned中时,PyDictObject会将这个对象进行两次引用计数加1的操作,一个是外部变量a对这个PyStringObject对象的引用,另一个是interned中key对对象的引用。因此规定interned中的a的指针不能作为有效引用,因为如果作为有效引用知道Python结束,此对象的应用最少也还有两个,这样的话这个对象就无法被销毁了,所以在最后才会有引用计数减2的操作。

  如果你听到有人说Python在创建一个字符串时,会首先在interned中去检查是否已经有该字符串所对应的对象,如果有则不用创建新的,以达到节省空间的目的(虽然一开始我也是这么说的)。那么现在你知道这种说法是错误的了,至少是不准确的。因为在创建PyStringObject对象时,并非一开始就节省了空间,在代码中可以看见无论如何一个临时的PyStringObject对象是会被创建的,也就是说不管怎么Python一定会为一个字符串创建一个PyStringObject的对象,即使字符串与interned中的某个对象字符串内容相同。那为什么必须要创建这样一个临时的对象呢,其实代码中已经给出了答案。原因就是interned所维护的关系集合中的key必须是一个PyObject类型的指针。

  • 1.4 字符串缓冲池

  在intern机制的讲解中的第一个代码中,我避开了一个问题就是当创建的字符串长度为1的时候,会有一个 “characters[*str & UCHAR_MAX] = op” 的执行语句,那么这是个什么呢?它就是一个字符缓冲池,我们来看看它的定义

static PyStringObject *characters[UCHAR_MAX + 1];

UCHAR_MAX是一个系统头文件中定义的变量。我们在讲解整数对象的时候知道,小整数的缓冲池是在Python初始化的时候创建的,但是字符缓冲池是以静态变量的形式存在,Python初始化完成后,缓冲池中所有的PyStringObject指针均为NULL。

  当创建字符串对象时,如果字符串是一个字符,则先对字符对象进行intern操作,再将intern的结果缓存到字符缓冲池characters中,我们用一个图来说明
Python中源码之字符串底层解析_第2张图片
  因此在创建PyStringObject对象是,会检查此对象是否是一个字符对象,如果是则检查缓冲池中是否已经存在此字符对象的缓冲,如果有则直接返回缓冲对象即可。代码实际上在创建对象的时候已经有所体现了,不过那时还处于懵逼状态罢了,现在一切都清晰了,世界真好!

  • 1.5 PyStringObject的某些操作效率

  As we know, 在Java中对于字符串可以使用连接符 ’+‘ 来连接字符串从而得到一个新的字符串。在Python中也提供了这样一个 + 连接符。but,其效率之低下啊。其原因通过上面分析你应该也略知一二了,没错Python中的字符串对象是一个不可变的对象,这就意味着进行字符串连接时,必须要创建一个新的PyStringObject对象,连接多少个对象则需要多少次的内存分配和搬运的操作,无疑增加了大量的开销。我们来看看底层的源码是如何实现的

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;
    // 创建新的PyStringObject对象
    op = (PyStringObject *)PyObject_MALLOC(sizeof(PyStringObject)+size);
    PyObject_INIT_VAR(op, &PyString_Type, size);
    op->ob_shash = -1;
    op->ob_sstate = SSTATE_NOT_INTERNED;
    //将a和b中的字符拷贝到新创建的PyStringObject中
    memcpy(op->ob_sval, a->ob_sval, (int)a->ob_size);
    memcpy(op->ob_sval + a->ob_size, a->ob_sval, (int)a->ob_size);
    op->ob_sval[size] = '\0';
    return (PyObject *) op;
    #undef b
}

实际上 ’+‘ 连接符是通过string_concat函数调用来实现的,在对n个对象进行连接时,每进行一次 ’+‘ 连接都会调用一次此函数,并且进行一次内存申请动作。并将原来两个字符串对象中所维护的字符数组中的实际字符串拷贝到新创建的对象中,并在末尾添加 ’\0‘ 结束字符。可以看到效率是非常低下的

  在此建议将需要连接的字符串放入一个tuple或list中,使用join来完成拼接,这种做法只需要分配一次内存,所以效率非常高,特别是需要大量字符串连接的时候。此招非常有用,至于它的实现原理,大家可以去看看源码,里面有详细的实现,这里就不展开了。

2、Python3中的PyUnicodeObject

  在Python3中情况开始变得有点不同了,在Python内部使用了Unicode编码,而表示一个字符串对象的时候被定义为一个PyUnicodeObject

typedef struct {
    PyCompactUnicodeObject _base;
    union {
        void *any;
        Py_UCS1 *latin1;
        Py_UCS2 *ucs2;
        Py_UCS4 *ucs4;
    } data;                     /* Canonical, smallest-form Unicode buffer */
} PyUnicodeObject;

typedef struct {
    PyASCIIObject _base;
    Py_ssize_t utf8_length;     /* Number of bytes in utf8, excluding the
                                 * terminating \0. */
    char *utf8;                 /* UTF-8 representation (null-terminated) */
    Py_ssize_t wstr_length;     /* Number of code points in wstr, possible
                                 * surrogates count as two code points. */
} PyCompactUnicodeObject;

typedef struct {
    PyObject_HEAD
    Py_ssize_t length;          /* Number of code points in the string */
    Py_hash_t hash;             /* Hash value; -1 if not set */
    struct {
        unsigned int compact:1;
        unsigned int ascii:1;
        unsigned int ready:1;
        unsigned int :24;
    } state;
    wchar_t *wstr;              /* wchar_t representation (null-terminated) */
} PyASCIIObject;

可以看到PyUnicodeObject的实现是非常复杂的,这也是我为什么先讲述Python2的实现原因,因为Python3中虽然内部使用了Unicode,但是在一些核心的处理上还是依然大致采用了Python2中的方式。我们可以看到在内部,依然是由一个指针来维护一段内存,这段内存里维护了真正存储的字符串,而字符串的长度不再由ob_size这个变量来维护而是通过一个wstr_length变量。在字符串内部同样也有和Python2中缓存字符串哈希值,Intern标志等变量,因此即使是一个空串,也要占一定的空间的原因。为了减少内存消耗,Python使用了三种方式表示Unicode:如果每个字符1个字节就用Latin-1编码;如果每个字符2个字节就用USC-2编码;如果每个字符4个字节据用USC-4编码。由此可知,当字符串中所有的字符都在ASCII的范围内的话,就会使用Latin-1来编码,对于大部分字符都可以使用USC-2来编码,但对于表情符号或者生僻字符就不得不使用4个字节的USC-4来编码。可以试想如下代码

# -*- coding:utf-8 -*-
# @Author: LessenPaul
# @Date: 2020/05/19
import sys
str1 = 'hello'
str2 = '你'
# 5
print(sys.getsizeof(str1) - sys.getsizeof(''))
# 10
print(sys.getsizeof(str1+str2) - sys.getsizeof(str2))

可以看到对于str1用一个字节就可以存储,因此它只占用5个字节,对于str2一个字节是没办法存储的,一旦str1和str2结合,就会采用2个字节来存储,就比原来多出5个字节。对于对象的创建和维护和Python2中大致类似但也有不同,有兴趣的可以自行去看源码,这里我就不讲述了。

你可能感兴趣的:(Python源码解析)