CPython:为什么+ = for strings会改变string变量的id

问题描述 投票:3回答:2

Cpython优化字符串增量操作,在为字符串初始化内存时,程序为其留下额外的扩展空间,因此,在递增时,原始字符串不会复制到新位置。我的问题是为什么字符串变量的id会发生变化。

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

为什么string变量的id改变了。

python python-3.x cpython python-internals
2个回答
5
投票

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

有关详细解释,需要深入了解CPython的实现,所以首先我将尝试给出一个挥手的解释,这至少应该给出正在发生的事情的要点。血腥细节将在第二部分中突出显示重要的代码部分。


让我们来看看这个函数,它展示了所需/优化的行为

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模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(有关字符串实习和整数池的更多信息,请参阅this SO-answer),因此s不仅仅是“使用”此字符串,因此无法更改此字符串。

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

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,它针对寿命较短的小对象进行了优化。使用的内存块是8字节的倍数,这意味着如果分配器只被要求1个字节,则仍然将8个字节标记为已使用(更精确的是因为返回指针的8-byte aligment,其余7个字节不能用于其他对象)。

然而,函数PyMem_Realloc:如果要求分配器将1字节块重新分配为2字节块,则无需执行任何操作 - 无论如何都有一些保留字节。

这样,如果只有一个对字符串的引用,CPython可以要求分配器重新分配字符串并要求更多字节。在7个8的情况下,分配器无关,而且附加字节几乎是免费的。

但是,如果字符串的大小变化超过7个字节,则复制成为必需:

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

此外,pymalloc回退到PyMem_RawMalloc,它通常是C运行时的内存管理器,上面的字符串优化不再可能:

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

实际上,每次重新分配后地址是否不同取决于C运行时的内存分配器及其状态。如果内存没有进行碎片整理,那么机会很高,realloc设法扩展内存而不进行复制(但我的机器上的情况并非如此,因为我做了这些实验),另请参阅this SO-post


对于好奇,这里是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最终调用PyUnicode_Append,它检查左操作数是否可修改(basically checks只有一个引用,字符串没有实现和其他一些东西)并调整大小或创建一个新的unicode-object否则:

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最终调用resize_compact(主要是因为在我们的例子中我们只有ascii字符),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)

是。


5
投票

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


特别是对于str__iadd__没有定义,所以操作可以回到__add____radd__。有关详细信息,请参阅Python文档的data model部分。

>>> hasattr(s, '__iadd__')                                                                                                                                
False
© www.soinside.com 2019 - 2024. All rights reserved.