CPython: ¿Por qué + = para las cadenas cambia el id de la variable de cadena

Cpython optimiza las operaciones de incremento de cadena. Al inicializar la memoria para una cadena, el progtwig deja espacio de expansión adicional para que, al boost, la cadena original no se copie a la nueva ubicación. mi pregunta es por qué cambia el id de la variable de cadena.

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

por qué el id de la variable de cadena cambia.

La optimización que está intentando activar es un detalle de implementación de CPython y es algo muy sutil: hay muchos detalles (por ejemplo, uno que está experimentando) que pueden prevenirlo.

Para obtener una explicación detallada, es necesario sumergirse en la implementación del CPython, así que primero trataré de dar una explicación para alzar la mano, lo que debería dar al menos una idea de lo que está sucediendo. Los detalles sangrientos estarán en la segunda parte que resalta las partes del código importantes.


Echemos un vistazo a esta función, que muestra el comportamiento deseado / optimizado

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

Llamándolo, lleva a la siguiente salida:

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

Es decir, se crea una nueva cadena solo cada 8 adiciones, de lo contrario se reutiliza la cadena antigua (o como veremos la memoria). La primera identificación se imprime solo 6 veces porque comienza a imprimirse cuando el tamaño del objeto Unicode es 2 módulo 8 (y no 0 como en los casos posteriores).

La primera pregunta es, si una cadena es inmutable en CPython, ¿cómo (o mejor cuando) se puede cambiar? Obviamente, no podemos cambiar la cadena si está vinculada a diferentes variables, pero podríamos cambiarla, si la variable actual es la única referencia, que puede verificarse con bastante facilidad debido al recuento de referencias de CPython (y es la por lo que esta optimización no está disponible para otras implementaciones que no utilizan el recuento de referencias).

Cambiemos la función de arriba agregando una referencia adicional:

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

Llamarlo lleva a:

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

Esto explica tu observación:

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

Su cadena s está internada (vea, por ejemplo, esta respuesta de SO para obtener más información sobre el internado de cadenas y el grupo de enteros), y por lo tanto s no es solo una “utilizando” esta cadena y, por lo tanto, esta cadena no se puede cambiar.

Si evitamos la internación, podemos ver, que la cadena se reutiliza:

 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! 

Pero, ¿cómo funciona esta optimización?

CPython utiliza su propia gestión de memoria, el asignador pymalloc , que está optimizado para objetos pequeños con tiempos de vida cortos. Los bloques de memoria utilizados son múltiplos de 8 bytes, lo que significa que si al asignador solo se le solicita 1 byte, aún se marcan 8 bytes como utilizados (más precisamente debido a la asignación de 8 bytes de los punteros devueltos, los 7 bytes restantes no pueden ser utilizado para otros objetos).

Sin embargo, existe la función PyMem_Realloc : si se le pide al asignador que reasigne un bloque de 1 byte como un bloque de 2 bytes, no hay nada que hacer, de todos modos había algunos bytes reservados.

De esta manera, si solo hay una referencia a la cadena, CPython puede pedirle al asignador que reasigne la cadena y requiera un byte más. En 7 casos de 8 no hay nada que hacer para el asignador y el byte adicional queda disponible casi gratis.

Sin embargo, si el tamaño de la cadena cambia en más de 7 bytes, la copia se convierte en obligatoria:

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

Además, pymalloc PyMem_RawMalloc a PyMem_RawMalloc , que suele ser el administrador de memoria del tiempo de ejecución de C, y la optimización anterior para cadenas ya no es posible:

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

En realidad, si las direcciones son diferentes después de cada reasignación depende del asignador de memoria del tiempo de ejecución de C y su estado. Si la memoria no se desfragmenta, las posibilidades son altas, que realloc logra extender la memoria sin copiar (pero no fue el caso en mi máquina como hice con estos experimentos), vea también esta publicación de SO .


Para los curiosos, aquí está el seguimiento completo de la operación str1+=str2 , que se puede seguir fácilmente en un depurador :

Eso es lo que pasa:

El += se comstack en BINARY_ADD -optcode y cuando se evalúa en ceval.c , hay un manejo especial / enganche para objetos Unicode (vea 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 termina llamando a PyUnicode_Append , que verifica si el operando izquierdo es modificable (que básicamente verifica que solo hay una referencia, que la cadena no está internada y algunas otras cosas más) y lo cambia de tamaño o crea un nuevo objeto Unicode de otra manera:

 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 termina llamando a resize_compact (principalmente porque en nuestro caso solo tenemos caracteres PyObject_REALLOC ), que termina llamando a PyObject_REALLOC :

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

que básicamente llamará 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! ... } ... } 

Donde INDEX2SIZE simplemente se redondea al múltiplo de 8 más cercano:

 #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

Las cuerdas son inmutables. Usar += en una str no es una operación en el lugar; crea un nuevo objeto con una nueva dirección de memoria, que es lo que id() proporciona en la implementación de CPython.


Para str específicamente, __iadd__ no está definido, por lo que la operación recae en __add__ o __radd__ . Vea la sección del modelo de datos de los documentos de Python para más detalles.

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