Python字符串驻留机制

由于CPython的优化,有时会发生字符串驻留(intern)

即在某些情况下尝试使用现有的不可变对象而不是每次都创建一个新对象

这些驻留的对象在内部使用类似字典的结构(驻留池)进行驻留

在被驻留之后,许多变量可能指向内存中的相同字符串对象,从而节省内存

下面的代码片段中,字符串是被隐式驻留的,何时隐式驻留字符串取决于字符串本身的特点

也就是说字符串是否会被隐式驻留是有条件的

(至于显式驻留,后面会讲到,以下的 CPython 源码剖析全部基于 CPython3.6)

字符串只在编译期间被隐式驻留

a = "some_string"
b = "some" + "_" + "string"
a is b
>>> True

很明显,字符串 “some_string” 被 CPython 隐式驻留了,所以 b 才会与 a 引用相同的字符串对象

而且可以注意到字符串是在编译时(compile-time)被驻留的

因为这里字符串的拼接发生在编译时,我们可以反汇编验证一下:

from dis import dis
dis("""id("some" + "_" + "string")""")
>>>           0 LOAD_NAME                0 (id)
              2 LOAD_CONST               4 ('some_string')
              4 CALL_FUNCTION            1
              6 RETURN_VALUE

反汇编的结果展示的是运行时(run-time)的字节码

可以看到,字符串 ‘some_string’ 在编译时就已经拼接好了

再加上之前 a 变量的赋值操作在编译时已经被隐式驻留了

所以在运行时可以直接引用(LOAD_CONST),从而指向同一个字符串对象

为了对比,我们看这种情况:

a = "some_string"
b = ''.join(['some','_','string'])
a is b
>>> False

join函数返回的也是 “some_string”,却不是同一个对象

因为这里是调用函数来拼接字符串的,发生在‘运行时’

在前面的‘编译时’CPython并不知道结果是 “some_string”

CPython看到的只是 ‘some’, ‘_’, ‘string’ 这三个字符串

所以最终的 “some_string” 是新创建的字符串对象而不是之前的驻留对象

我们反汇编来验证一下就明白了:

dis("""''.join(['some','_','string'])""")
>>>           0 LOAD_CONST               0 ('')
              2 LOAD_ATTR                0 (join)
              4 LOAD_CONST               1 ('some')
              6 LOAD_CONST               2 ('_')
              8 LOAD_CONST               3 ('string')
             10 BUILD_LIST               3
             12 CALL_FUNCTION            1
             14 RETURN_VALUE

运行时并没有字符串 “somestring”,而是 ‘some’, ‘\‘, ‘string’

当然,若之前已经驻留了 ‘some’, ‘_’, ‘string’,

则这里的 ‘some’, ‘_’, ‘string’ 将分别引用同一个对象

但对结果 “some_string” 没有任何影响,无论如何 “some_string” 将是一个新的字符串对象

其实只要分清楚字符串的拼接到底发生在编译期间还是运行期间就明白了,再来看一个例子:

a = 'some'
b = "some_string"
a + '_string' is b
>>> False

同样的,最后发生的字符串拼接是在运行期间而不是编译期间,因为 a 变量在编译期间不会被’some’替换

反汇编一下就明白了:

dis("""a + '_string'""")
>>>           0 LOAD_NAME                0 (a)
              2 LOAD_CONST               0 ('_string')
              4 BINARY_ADD
              6 RETURN_VALUE

dis("""'some' + '_string'""")
>>>           0 LOAD_CONST               2 ('some_string')
              2 RETURN_VALUE

只有包含ASCII字母,数字或下划线的字符串才会被隐式驻留

a = "wtf"
b = "wtf"
a is b
>>> True

a = "wtf!"
b = "wtf!"
a is b
>>> False

字符串 “wtf!” 包含了非合法的字符 “!”,因此不会引用同一驻留对象

因为解释器仅对看起来像 Python 标识符的字符串驻留,而 Python 标识符正是由下划线、字母和数字组成的

这在CPython的 源码中 可以看到具体的实现,这里只分析主要的代码片段:

#define NAME_CHARS \
    "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz" // 只含ASCII字母,数字和下划线

/* all_name_chars(s): true iff all chars in s are valid NAME_CHARS */

static int
all_name_chars(PyObject *o) // o为指向待检测字符串对象的指针,通过检测则返回1,否则返回0
{
    static char ok_name_char[256]; // 所有 ASCII 字符的映射表
    static const unsigned char *name_chars = (unsigned char *)NAME_CHARS; // name_chars 指向合法字符表
    const unsigned char *s, *e; 

    if (!PyUnicode_IS_ASCII(o)) // 首先检测是否都为 ASCII 字符,避免下面检测时越界
        return 0;

    if (ok_name_char[*name_chars] == 0) {
        const unsigned char *p;
        for (p = name_chars; *p; p++) // 在 ASCII 字符映射表中把合法字符的位置设为1
            ok_name_char[*p] = 1;
    }
    s = PyUnicode_1BYTE_DATA(o); // 指向待检测字符串首字符
    e = s + PyUnicode_GET_LENGTH(o); // 指向待检测字符串尾字符
    while (s != e) { // 遍历待检测字符串,看在 ASCII 字符映射表中相应的字符是否合法(值为1)
        if (ok_name_char[*s++] == 0) // 一旦检测到非法字符(值为0)就返回0
            return 0;
    }
    return 1; // 通过检测,返回1
}

但这种检测只发生在交互式命令行中,如果我们写成一个 py 文件然后运行,即使包含非法字符也会被驻留

这也许是 CPython 更进一步的优化

同一行赋值(元组拆包)时会引用同一相等的对象,但不一定会被驻留

a, b = "wtf!", "wtf!"
a is b
>>> True

当在同一行中把 a 和 b 赋值为“wtf!”时

解释器只会创建一个新对象(即使该字符串中包含非法字符),然后两个变量同时引用该对象

如果你在不同的行上进行,它不会“知道”已经有了 “wtf!” 作为一个对象(因为 “wtf!” 不是上面提到合法字符串)

但引用同一对象这并不意味着该对象发生了驻留:

a, b = "wtf!", "wtf!"
c = "wtf!"
a is b
>>> True
a is c
>>> False
b is c
>>> False

a, b = "wtf", "wtf"
c = "wtf"
a is b
>>> True
a is c
>>> True
b is c
>>> True

可以看到,“wtf!” 虽然被 a 和 b 同时引用了,但并没有被驻留,只有合法的字符串才会被驻留

长度为0和长度为1的字符串会被驻留

a = ''
b = ''
a is b
>>> True

a = '_'
b = '_'
a is b
>>> True

a = '&'
b = '&'
a is b
>>> True

即使包含非法字符,只要长度不超过1,都会被驻留

但准确来说是 Latin-1 字符集 的字符才会被驻留:

a = 'ÿ' # \xff
b = 'ÿ'
a is b
>>> True

a = '我'
b = '我'
a is b
>>> False

len('我')
>>> 1

虽然字符串 '我' 的长度为1,但由于不是在 Latin-1 字符集的范围内,所以不会被驻留

严格来讲,单字符实际上在CPython内部使用了一个数组 unicode_latin1 来共享而不是靠 interned 字典来驻留

类似于小整数缓存池 [-5,256],unicode_latin1 相当于一个字符缓冲池:

/* Single character Unicode strings in the Latin-1 range are being
   shared as well. */
static PyObject *unicode_latin1[256] = {NULL};

static PyObject*
get_latin1_char(unsigned char ch)
{ 
    PyObject *unicode = unicode_latin1[ch]; // 尝试从 unicode_latin1 中获取单字符的引用
    if (!unicode) { // 首次获取,unicode_latin1 中还没缓存
        unicode = PyUnicode_New(1, ch); // 新建一个 PyUnicode 对象
        if (!unicode)
            return NULL;
        PyUnicode_1BYTE_DATA(unicode)[0] = ch;
        assert(_PyUnicode_CheckConsistency(unicode, 1));
        unicode_latin1[ch] = unicode; // 存放到 unicode_latin1 中,这样下次可以直接获取该引用
    }
    Py_INCREF(unicode); // 记得引用计数加1
    return unicode;
}

无论如何,当你获取单个字符时,get_latin1_char 总是返回 unicode_latin1 中共享的引用

这也就意味着在运行期间得到的单个字符也是共享的:

a = ''.join(['@']) # ‘@’在 latin1 字符集范围内
b = ''.join(['@']) 
a is b
>>> True

a = ''.join(['你']) # ‘你’不在 latin1 字符集范围内
b = ''.join(['你'])
a is b
>>> False

a = ''.join(['1','1']) # 结果不是单字符时创建两个不同的字符串
b = ''.join(['1','1']) 
a is b
>>> False

a = '123'
b = '1'
a[0] is b # 获取的是单个字符
>>> True

a = '123'
b = '12'
a[0:2] is b # 获取非单字符,a[0:2] 返回的是新的字符串对象
>>> False

超过20个字符的折叠字符串不会被驻留

a = 'aaaaaaaaaaaaaaaaaaaa' # 未折叠字符串,长度为20
b = 'aaaaaaaaaaaaaaaaaaaa'
a is b
>> True

a = 'aaaaaaaaaaaaaaaaaaaaa' # 未折叠字符串,长度为21
b = 'aaaaaaaaaaaaaaaaaaaaa'
a is b
>>> True

'a' * 20 is 'aaaaaaaaaaaaaaaaaaaa' # 'a' * 20 为折叠字符串,长度为20
>>> True

'a' * 21 is 'aaaaaaaaaaaaaaaaaaaaa' # 'a' * 21 为折叠字符串,长度为21
>>> False

'$' * 2 is '$$' # 只要含有非法字符就不会被驻留,与长度无关
>>> False

字符串折叠是常量折叠的一种,常量折叠在编译阶段会进行值替换,是窥孔优化的一种实现方式

这意味着在编译期间表达式 'a'* 20 会被 'aaaaaaaaaaaaaaaaaaa' 替换,以减少运行期间的时钟周期

但这种优化是有限制的,只有长度小于20的字符串才会发生常量折叠的优化

这在 CPython 的 源码中 可以找到相应的实现

为什么会有这种限制?想象一下由于表达式 'a'* 10 ** 10 而生成的 .pyc 文件的大小

下面我们通过反汇编来看一下就明白了:

dis("""'a' * 20""")
>>>          0 LOAD_CONST               2 ('aaaaaaaaaaaaaaaaaaaa')
              2 RETURN_VALUE

dis("""'a' * 21""")
>>>           0 LOAD_CONST               0 ('a')
              2 LOAD_CONST               1 (21)
              4 BINARY_MULTIPLY
              6 RETURN_VALUE

可以看到,超过20个字符的字符串将不会在编译时被替换,而是直接被解析成 BINARY_MULTIPLY 的乘法运算

这种常量折叠不只发生在字符串上,事实上它对所有的不可变对象(字符串、元组等)都有效:

dis("(1,)*20")
>>>           0 LOAD_CONST               3 ((1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1))
              2 RETURN_VALUE

dis("(1,)*21")
>>>           0 LOAD_CONST               2 ((1,))
              2 LOAD_CONST               1 (21)
              4 BINARY_MULTIPLY
              6 RETURN_VALUE

dis("[1]*2") # 对于可变对象没有这种优化
>>>           0 LOAD_CONST               0 (1)
              2 BUILD_LIST               1
              4 LOAD_CONST               1 (2)
              6 BINARY_MULTIPLY
              8 RETURN_VALUE

使用 sys.intern() 显式驻留

from sys import intern
a = intern("wtf!")
b = intern("wtf!")
a is b
>>> True

a = "wtf!"
b = "wtf!"
a is b # 显式驻留之前
>>> False

b = intern(a)
a is b # 显式驻留之后
>>> True

注意:驻留不是缓存,缓存的对象往往有过期时间的限制,会发生淘汰,其生命周期有限

而驻留的对象没有过期时间和淘汰这一说法,驻留的对象直到发生垃圾回收或程序结束才会被销毁

可以把驻留池看成是一个容量为无限大且没有过期时间限制的缓存池

这里补充一点:Python中字符串的 intern状态 有三个,分别是:

/* Interning state. */
#define SSTATE_NOT_INTERNED 0  // 字符串没有被interned
#define SSTATE_INTERNED_MORTAL 1 // 字符串被interned,可以被回收
#define SSTATE_INTERNED_IMMORTAL 2 // 字符串永久interned,不会被回收

字符串的结构体中 state.interned 标记该值,宏 PyUnicode_CHECK_INTERNED 返回该值:

/* Use only if you know it's a string */
#define PyUnicode_CHECK_INTERNED(op) (((PyASCIIObject *)(op))->state.interned)

state.interned在创建字符串对象时会被设为SSTATE_NOT_INTERNED

在驻留到 interned 字典后被设为 SSTATE_INTERNED_MORTAL

至于 SSTATE_INTERNED_IMMORTAL 需要手动调用 PyUnicode_InternImmortal 才会设置该值:

void
PyUnicode_InternImmortal(PyObject **p)
{
    PyUnicode_InternInPlace(p);
    if (PyUnicode_CHECK_INTERNED(*p) != SSTATE_INTERNED_IMMORTAL) {
        _PyUnicode_STATE(*p).interned = SSTATE_INTERNED_IMMORTAL;
        Py_INCREF(*p);
    }
}

验证 SSTATE_INTERNED_MORTAL

a = "foo"
id(a)
>>> 89369248

a = None # a 指向其他对象,字符串"foo"在驻留池中被销毁
b = "foo" # 此时在驻留池中找不到"foo"便会创建新的对象
id(b) # 地址将不再和上面相等
>>> 89368640

深入源码

字符串的intern机制由 PyUnicode_InternInPlace 函数来实现:

/* This dictionary holds all interned unicode strings.  Note that references
   to strings in this dictionary are *not* counted in the string's ob_refcnt.
   When the interned string reaches a refcnt of 0 the string deallocation
   function will delete the reference from this dictionary.

   Another way to look at this is that to say that the actual reference
   count of a string is:  s->ob_refcnt + (s->state ? 2 : 0)
*/
static PyObject *interned = NULL; // 字符串驻留机制的核心存储结构--interned字典

void
PyUnicode_InternInPlace(PyObject **p) // 注意这里是二级指针,因为可能需要修改指针本身
{
    PyObject *s = *p;
    PyObject *t;
#ifdef Py_DEBUG
    assert(s != NULL);
    assert(_PyUnicode_CHECK(s));
#else
    if (s == NULL || !PyUnicode_Check(s))
        return;
#endif
    /* If it's a subclass, we don't really know what putting
       it in the interned dict might do. */
    /* 检查 s 是否为 PyUnicode 实例,如果为 PyUnicode 子类的实例则直接返回,不对子类进行驻留
      因为不知道要把子类的什么内容驻留在 interned 字典中 */
    if (!PyUnicode_CheckExact(s))
        return;
    if (PyUnicode_CHECK_INTERNED(s)) // 对驻留过的字符串不再执行多余的 internd 机制
        return;
    if (interned == NULL) { // 首次使用 interned 字典,需要初始化
        interned = PyDict_New(); // interned 就是一个普通的 Python Dict
        if (interned == NULL) { // 创建 interned 字典失败不会抛出错误而是默默退出
            PyErr_Clear(); /* Don't leave an exception */
            return;
        }
    }
    Py_ALLOW_RECURSION
    /* PyDict_SetDefault 就是 dict 的一个方法 setdefault 的实现
      这里设置键值都为s,注意这里 s 为引用(指针),实际上是{hash(s):s}
      值相等的字符串,即使内存地址不一样,hash值也是一样的
      因此如果 interned 中已经含有与s值相等的字符串
      则返回该字符串原来的引用指针t,否则返回当前的引用指针s
    */
    t = PyDict_SetDefault(interned, s, s);
    Py_END_ALLOW_RECURSION
    if (t == NULL) { // 出错直接返回
        PyErr_Clear();
        return;
    }
    if (t != s) { // 返回的引用指针 t 不等于 s,说明 interned 中已经含有该字符串
        Py_INCREF(t); // 增加原来字符串 t 的引用计数
        Py_SETREF(*p, t); // 修改当前字符串 p 的引用为 t,使得 p 和 t 是同一个引用,
        return;       // 这样原来的引用 p 所指向的字符串对象会被垃圾回收
        // 也就是说其实字符串对象总是会被创建的,但可能昙花一现
    }
    /* The two references in interned are not counted by refcnt.
       The deallocator will take care of this */
    /* 代码能执行到这里说明 t == s, 即 interned 中不存在该与 s 值相等的字符串,
      于是在 interned 中驻留,从而返回的 t 就是 s
      但这样 s 的引用计数会由于存入字典而被加2,因为字典中 key 和 value 都会引用该字符串
      因此我们需要手动把 s 的引用计数减去2,这样才是 s 的真正引用计数
     */
    Py_REFCNT(s) -= 2;
    _PyUnicode_STATE(s).interned = SSTATE_INTERNED_MORTAL; // 修改 intern 状态为 SSTATE_INTERNED_MORTAL
}

更多相关源码的研究可以查看 cpython/Objects/ 下的 unicodeobject.ccodeobject.c

参考资料

文章目录
  1. 1. 字符串只在编译期间被隐式驻留
  2. 2. 只有包含ASCII字母,数字或下划线的字符串才会被隐式驻留
  3. 3. 同一行赋值(元组拆包)时会引用同一相等的对象,但不一定会被驻留
  4. 4. 长度为0和长度为1的字符串会被驻留
  5. 5. 超过20个字符的折叠字符串不会被驻留
  6. 6. 使用 sys.intern() 显式驻留
  7. 7. 深入源码
  8. 8. 参考资料
|