CPython:为什么 += for strings 会改变字符串变量的 id

CPython: Why does += for strings change the id of string variable

Cpython优化了字符串自增操作,在为字符串初始化内存时,程序会为其留出额外的扩展space,因此自增时,不会将原字符串复制到新位置。 我的问题是为什么字符串变量的id改变了。

>>> s = 'ab'
>>> id(s)
991736112104
>>> s += 'cd'
>>> id(s)
991736774080

为什么字符串变量的id变了

字符串是不可变的。在 str 上使用 += 不是就地操作;它创建一个具有新内存地址的新对象,这是 id() 在 CPython 的实现下给出的内容。


对于 str 具体而言,__iadd__ 未定义,因此操作回退到 __add____radd__。有关详细信息,请参阅 Python 文档的 data model 部分。

>>> hasattr(s, '__iadd__')                                                                                                                                
False

您尝试触发的优化是 CPython 的一个实现细节,是一件非常微妙的事情:有许多细节(e.f。您正在经历的一个)可以阻止它。

要获得详细的解释,需要深入了解 CPython 的实现,所以首先我会尝试给出一个简单的解释,至少应该给出正在发生的事情的要点。血淋淋的细节将在第二部分突出显示重要的代码部分。


让我们看一下这个函数,它展示了 desired/optimized 行为

def add_str(str1, str2, n):
    for i in range(n):
        str1+=str2
        print(id(str1))
    return str1

调用它会导致以下输出:

>>> add_str("1","2",100)
2660336425032
... 4 times
2660336425032
2660336418608
... 6 times
2660336418608
2660336361520
... 6 times
2660336361520
2660336281800
 and so on

即每添加 8 次就会创建一个新字符串,否则旧字符串(或我们将看到的内存)将被重用。第一个 id 只打印了 6 次,因为它在 unicode-object 的大小为 2 modulo 8 时开始打印(而不是在后面的情况下为 0)。

第一个问题是,如果字符串在 CPython 中是不可变的,那么如何(或更好的时间)更改它?显然,如果字符串绑定到不同的变量,我们不能更改它 - 但我们可以更改它,如果当前变量是唯一的引用 - 由于 CPython 的引用计数,可以很容易地检查它(它是此优化不适用于不使用引用计数的其他实现的原因。

让我们通过添加额外的引用来更改上面的函数:

def add_str2(str1, str2, n):
    for i in range(n):
        ref = str1
        str1+=str2
        print(id(str1))
    return str1

调用它会导致:

>>> add_str2("1","2",20)
2660336437656
2660337149168
2660337149296
2660337149168
2660337149296
... every time a different string - there is copying!

这实际上解释了您的观察结果:

import sys
s = 'ab'
print(sys.getrefcount(s))
# 9
print(id(s))
# 2660273077752
s+='a'
print(id(s))
# 2660337158664  Different

您的字符串 sinterned (see for example 以获取有关字符串驻留和整数池的更多信息),因此 s 不仅是一个 "using" 这个字符串,因此也是这个字符串无法更改。

如果我们避免实习,我们可以看到,字符串被重用了:

import sys
s = 'ab'*21  # will not be interned
print(sys.getrefcount(s))
# 2, that means really not interned
print(id(s))
# 2660336107312
s+='a'
print(id(s))
# 2660336107312  the same id!

但是这种优化是如何进行的?

CPython 使用自己的内存管理 - the pymalloc allocator, which is optimized for small objects with short lifetimes. The used memory-blocks are multiple of 8 bytes, that means if allocator is asked for only 1 byte, still 8 bytes are marked as used (more precise because of the 8-byte aligment 返回的指针,其余 7 个字节不能用于其他对象)。

但是有函数PyMem_Realloc:如果要求分配器将 1 字节块重新分配为 2 字节块,则无事可做 - 无论如何都有一些保留字节。

这样一来,如果字符串只有一个引用,CPython 可以要求分配器重新分配字符串并要求多一个字节。在 8 例中的 7 例中,分配器无事可做,附加字节几乎免费可用。

但是,如果字符串的大小变化超过 7 个字节,则必须进行复制:

>>> add_str("1", "1"*8, 20)  # size change of 8
2660337148912
2660336695312
2660336517728
... every time another id

此外,pymalloc回落到PyMem_RawMalloc,通常是C-runtime的内存管理器,上面对字符串的优化已经不可能了:

>>> add_str("1"*512, "1", 20) #  str1 is larger as 512 bytes
2660318800256
2660318791040
2660318788736
2660318807744
2660318800256
2660318796224
... every time another id

实际上,每次重新分配后地址是否不同取决于C运行时的内存分配器及其状态。如果内存未进行碎片整理,那么 realloc 无需复制即可扩展内存的可能性很高(但在我的机器上并非如此,因为我进行了这些实验),另请参见


出于好奇,这里是 str1+=str2 操作的完整回溯,可以在 a debugger 中轻松跟踪:

事情就是这样:

+= 被编译为 BINARY_ADD-optcode,当在 ceval.c 中求值时,有一个 hook/special handling for unicode objects(见 PyUnicode_CheckExact):

case TARGET(BINARY_ADD): {
    PyObject *right = POP();
    PyObject *left = TOP();
    PyObject *sum;
    ...
    if (PyUnicode_CheckExact(left) &&
             PyUnicode_CheckExact(right)) {
        sum = unicode_concatenate(left, right, f, next_instr);
        /* unicode_concatenate consumed the ref to left */
    }
    ...

unicode_concatenate ends up calling PyUnicode_Append, which checks whether the left-operand is modifiable (which basically checks 只有一个引用,字符串未被保留,还有一些其他内容)并调整它的大小或创建一个新的 unicode 对象,否则:

if (unicode_modifiable(left)
    && ...)
{
    /* append inplace */
    if (unicode_resize(p_left, new_len) != 0)
        goto error;

    /* copy 'right' into the newly allocated area of 'left' */
    _PyUnicode_FastCopyCharacters(*p_left, left_len, right, 0, right_len);
}
else {
    ...
    /* Concat the two Unicode strings */
    res = PyUnicode_New(new_len, maxchar);
    if (res == NULL)
        goto error;
    _PyUnicode_FastCopyCharacters(res, 0, left, 0, left_len);
    _PyUnicode_FastCopyCharacters(res, left_len, right, 0, right_len);
    Py_DECREF(left);
    ...
}

unicode_resize ends up calling resize_compact (mostly because in our case we have only ascii-characters), which ends up 调用 PyObject_REALLOC:

...
new_unicode = (PyObject *)PyObject_REALLOC(unicode, new_size);
...

基本上会调用 pymalloc_realloc:

static int
pymalloc_realloc(void *ctx, void **newptr_p, void *p, size_t nbytes)
{
    ...
    /* pymalloc is in charge of this block */
    size = INDEX2SIZE(pool->szidx);
    if (nbytes <= size) {
        /* The block is staying the same or shrinking.
          ....
            *newptr_p = p;
            return 1; // 1 means success!
          ...
    }
    ...
}

其中 INDEX2SIZE 只是四舍五入到最接近的 8 的倍数:

#define ALIGNMENT               8               /* must be 2^N */
#define ALIGNMENT_SHIFT         3

/* Return the number of bytes in size class I, as a uint. */
#define INDEX2SIZE(I) (((uint)(I) + 1) << ALIGNMENT_SHIFT)

问了。