CPython: почему + = для строк меняет идентификатор строковой переменной - PullRequest
0 голосов
/ 02 января 2019

Cpython оптимизирует операции приращения строки. При инициализации памяти для строки программа оставляет для нее дополнительное пространство расширения, поэтому при приращении исходная строка не копируется в новое место. мой вопрос, почему идентификатор строковой переменной изменяется.

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

почему идентификатор строковой переменной изменяется.

Ответы [ 2 ]

0 голосов
/ 04 января 2019

Оптимизация, которую вы пытаетесь вызвать, является деталью реализации 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 ​​добавлений, в противном случае старая строка (или, как мы увидим в памяти) используется повторно.Первый идентификатор печатается только 6 раз, потому что он начинает печатать, когда размер объекта Юникод равен 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

Ваша строка s равна интернирована (см., Например, этот SO-ответ для получения дополнительной информации об интернировании строк и целочисленном пуле), и, таким образом, 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 использует собственное управление памятью - распределитель pymalloc , который оптимизирован для небольших объектов с коротким временем жизни.Используемые блоки памяти кратны 8 байтам, что означает, что если для распределителя требуется только 1 байт, все еще 8 байт помечаются как использованные (более точно, из-за 8-байтового значения возвращаемого значенияуказатели на оставшиеся 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 удастся расширить память без копирования (но на моей машине это было не так, как я делал эти эксперименты), см. Также этот SO-post .


Для любопытных, вот полный след от операции str1+=str2, за которой можно легко следовать в отладчике :

То естьчто происходит:

+= скомпилирован в BINARY_ADD -топкод и при оценке в ceval.c, есть специальная обработка ловушки / для объектов Unicode (см. 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, который проверяет, является ли левый операнд изменяемым (который в основном проверяет , что существует только одна ссылка, строка не интернируется и некоторые другие вещи) и изменяет ее размер или создает новый объект 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 в конечном итоге вызывает resize_compact (в основном потому, что в нашем случае у нас есть только символы ascii), , который заканчивается вызовом 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)

qed.

0 голосов
/ 03 января 2019

Строки являются неизменными. Использование += на str не является операцией на месте; он создает новый объект с новым адресом памяти, который id() дает при реализации CPython.


В частности, str, __iadd__ не определено, поэтому операция возвращается к __add__ или __radd__. Подробности смотрите в разделе модель данных документации по Python.

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