关于不可变字符串的变化ID问题

56

关于 Python 2.7 版本中类型为 str 的对象的 id 有些让我困惑。由于 str 类型是不可变的,因此我原以为一旦创建就会始终保持相同的 id。我认为我的表述可能不太好,所以我将贴出输入和输出序列的示例。

>>> id('so')
140614155123888
>>> id('so')
140614155123848
>>> id('so')
140614155123808

因此,与此同时,它一直在变化。但是,在将一个变量指向该字符串之后,事情发生了改变:

>>> so = 'so'
>>> id('so')
140614155123728
>>> so = 'so'
>>> id(so)
140614155123728
>>> not_so = 'so'
>>> id(not_so)
140614155123728

看起来一旦变量持有该值,它就会冻结该id。事实上,在执行del sodel not_so后,id('so')的输出再次开始改变。

这与(小)整数的行为不同。

我知道不可变性和具有相同id之间没有真正的连接;尽管如此,我正在试图找出这种行为的源头。我相信那些熟悉Python内部工作原理的人比我更少惊讶,所以我正在尝试达到相同的水平...

更新

使用不同的字符串尝试得到了不同的结果...

>>> id('hello')
139978087896384
>>> id('hello')
139978087896384
>>> id('hello')
139978087896384

现在它相等的...


5
Python默认情况下不会对字符串进行内部化。很多Python内部代码确实会显式地对某些字符串值(属性名、标识符等)进行内部化,但这并不适用于任意字符串。 - Martijn Pieters
相反,Python 可以自由地重用内存槽。您需要创建具有更长生命周期的对象。 - Martijn Pieters
@Bach 一旦变量保存了该值 这个语句在Python中是否正确?请阅读这篇文章 - overexchange
5个回答

85

CPython并不保证默认情况下对所有字符串进行内部化,但实际上,Python代码库中的许多地方都会重用已创建的字符串对象。许多Python内部使用(C等效的)sys.intern()函数调用来明确内部化Python字符串,但除非你遇到这些特殊情况之一,否则两个相同的Python字符串字面量将产生不同的字符串。

Python也可以自由地重用内存位置,并且Python还通过在编译时一次性存储不可变的文字来优化它们,并将其与代码对象中的字节码一起存储。Python REPL(交互式解释器)还将最近的表达式结果存储在_名称中,这使得事情更加混乱。

因此,你偶尔看到相同的id出现。

仅运行id(<string literal>)行在REPL中经历了几个步骤:

  1. The line is compiled, which includes creating a constant for the string object:

    >>> compile("id('foo')", '<stdin>', 'single').co_consts
    ('foo', None)
    

    This shows the stored constants with the compiled bytecode; in this case a string 'foo' and the None singleton. Simple expressions consisting of that produce an immutable value may be optimised at this stage, see the note on optimizers, below.

  2. On execution, the string is loaded from the code constants, and id() returns the memory location. The resulting int value is bound to _, as well as printed:

    >>> import dis
    >>> dis.dis(compile("id('foo')", '<stdin>', 'single'))
      1           0 LOAD_NAME                0 (id)
                  3 LOAD_CONST               0 ('foo')
                  6 CALL_FUNCTION            1
                  9 PRINT_EXPR          
                 10 LOAD_CONST               1 (None)
                 13 RETURN_VALUE        
    
  3. The code object is not referenced by anything, reference count drops to 0 and the code object is deleted. As a consequence, so is the string object.

Python可以重用相同的内存位置来创建新的字符串对象,如果您重新运行相同的代码。如果您多次重复此代码,则通常会导致打印相同的内存地址。这通常取决于您对Python内存的其他操作。
ID的重用是不可预测的;如果在此期间垃圾回收器运行以清除循环引用,则可能释放其他内存,并且您将获得新的内存地址。
接下来,Python编译器还将intern任何存储为常量的Python字符串,只要它看起来足够像有效的标识符即可。 Python code object factory function PyCode_New将通过调用intern_string_constants()来intern包含仅ASCII字母,数字或下划线的任何字符串对象。该函数递归遍历常量结构,并针对找到的任何字符串对象v执行:
if (all_name_chars(v)) {
    PyObject *w = v;
    PyUnicode_InternInPlace(&v);
    if (w != v) {
        PyTuple_SET_ITEM(tuple, i, v);
        modified = 1;
    }
}

其中 all_name_chars() 被记录为

/* all_name_chars(s): true iff s matches [a-zA-Z0-9_]* */

由于您创建了符合该标准的字符串,它们被放入池中,这就是为什么您在第二个测试中看到了相同 ID 被用于 'so' 字符串的原因:只要对池中版本的引用存在,池化就会导致未来的 'so' 文本重用池中的字符串对象,即使在新的代码块中并绑定到不同的标识符。在您的第一个测试中,您没有保存对字符串的引用,因此池化的字符串在被重复使用之前被丢弃。
顺便说一下,您的新名称 so = 'so' 将字符串绑定到一个包含相同字符的名称中。换句话说,您正在创建一个名称和值相等的全局变量。由于 Python 会对标识符和限定常量进行池化,因此最终使用相同的字符串对象作为标识符及其值。
>>> compile("so = 'so'", '<stdin>', 'single').co_names[0] is compile("so = 'so'", '<stdin>', 'single').co_consts[0]
True

如果您创建的字符串既不是代码对象常量,也包含字母+数字+下划线范围之外的字符,则会看到id()值不被重用:
>>> some_var = 'Look ma, spaces and punctuation!'
>>> some_other_var = 'Look ma, spaces and punctuation!'
>>> id(some_var)
4493058384
>>> id(some_other_var)
4493058456
>>> foo = 'Concatenating_' + 'also_helps_if_long_enough'
>>> bar = 'Concatenating_' + 'also_helps_if_long_enough'
>>> foo is bar
False
>>> foo == bar
True

Python编译器使用peephole optimizer(Python版本<3.7)或更强大的AST optimizer(3.7及更新版本)来预先计算(折叠)涉及常量的简单表达式的结果。Peepholder将其输出限制为20个或更少的序列(以防止代码对象和内存使用膨胀),而AST优化器对于长度为4096个字符的字符串使用单独的限制。这意味着,如果连接仅由名称字符组成的较短字符串的结果字符串符合当前Python版本的优化器限制,则仍然可以导致内部化字符串。
例如,在Python 3.7上,'foo'*20将导致单个内部化字符串,因为常量折叠将其转换为单个值,而在Python 3.6或更早版本中,只有'foo'*6会被折叠:
>>> import dis, sys
>>> sys.version_info
sys.version_info(major=3, minor=7, micro=4, releaselevel='final', serial=0)
>>> dis.dis("'foo' * 20")
  1           0 LOAD_CONST               0 ('foofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoofoo')
              2 RETURN_VALUE

并且

>>> dis.dis("'foo' * 6")
  1           0 LOAD_CONST               2 ('foofoofoofoofoofoo')
              2 RETURN_VALUE
>>> dis.dis("'foo' * 7")
  1           0 LOAD_CONST               0 ('foo')
              2 LOAD_CONST               1 (7)
              4 BINARY_MULTIPLY
              6 RETURN_VALUE

1
@Bach:实习是指如果已经创建了一个具有相同值的字符串对象,则重新使用该对象的行为。 - Martijn Pieters
1
@MartijnPieters 在 Python 领域拥有的广博知识令我感到惊叹。^^ - Marius Mucenicu
@MartijnPieters Python是否有某种池,在活动过程中保留国际化字符串?它是否有一种算法来决定何时读取和写入该池(也许是特定长度的字符串等)?就像Java一样,AFAIK每次创建字符串时都会检查池,以查看它是否存在,如果存在,则返回引用,如果不存在,则创建并添加到池中。我知道在Python中这样做可能不是很有效率,因为您可能会浪费运行时,而不是编译时间,因此对所有strs执行此操作可能不那么具有吸引力。 - Marius Mucenicu
2
@MariusMucenicu 是的,我的回答概述了算法的一般性描述。您还可以在Python源代码中搜索 PyUnicode_InternInPlacePyUnicode_InternFromString 函数的调用,以查看Python在哪里对字符串进行内部化(例如,在GitHub上搜索 任意一个 函数)。 - Martijn Pieters
1
@MariusMucenicu 池本身是在unicodeobject.c源代码中定义的字典对象,它还托管了用于内部化的API函数(除了一个宏)。 - Martijn Pieters
显示剩余2条评论

4
这种行为仅适用于Python交互式shell。 如果我将以下内容放入py文件中:
print id('so')
print id('so')
print id('so')

当我执行代码时,输出如下:
2888960
2888960
2888960
在CPython中,字符串常量被视为常量,这在上面代码段的字节码中可以看到:
  2           0 LOAD_GLOBAL              0 (id)
              3 LOAD_CONST               1 ('so')
              6 CALL_FUNCTION            1
              9 PRINT_ITEM          
             10 PRINT_NEWLINE       

  3          11 LOAD_GLOBAL              0 (id)
             14 LOAD_CONST               1 ('so')
             17 CALL_FUNCTION            1
             20 PRINT_ITEM          
             21 PRINT_NEWLINE       

  4          22 LOAD_GLOBAL              0 (id)
             25 LOAD_CONST               1 ('so')
             28 CALL_FUNCTION            1
             31 PRINT_ITEM          
             32 PRINT_NEWLINE       
             33 LOAD_CONST               0 (None)
             36 RETURN_VALUE  

相同的常量(即相同的字符串对象)被加载了3次,因此它们的ID是相同的。


@Bach 我的意思是 Python 交互式 shell。 - arshajii
同样的情况也出现在这里;也许 Python 的“编译器”会做一些魔法来避免为相同字符串的多个实例分配内存? - Bach
@Bach 是的,字面字符串'so'被存储为单个常量,因此每次使用它时都会加载相同的常量,这避免了每次创建新字符串的情况。 - arshajii

1
更简单的理解行为的方法是查看以下数据类型和变量
部分“字符串的奇异性”用特殊字符作为示例来说明您的问题。

1
在第一个示例中,每次都会创建一个新的字符串实例“so”,因此具有不同的id。
在第二个示例中,您将字符串绑定到变量上,Python可以维护字符串的共享副本。

3
OP正在重新绑定字符串对象。 - Martijn Pieters
3
你的解释有误;第二个例子将新的字符串字面量绑定到相同的名称以及不同的名称上。so被重新绑定,然后not_so也被重新绑定。这不是同一个字符串对象。 - Martijn Pieters

0

虽然Python不能保证内部化字符串,但它经常重复使用相同的字符串,is可能会误导。重要的是要知道,你不应该通过检查idis来判断字符串的相等性。

为了证明这一点,我发现在Python 2.6中至少有一种方法可以强制创建一个新的字符串:

>>> so = 'so'
>>> new_so = '{0}'.format(so)
>>> so is new_so 
False

这里还有更多的Python探索:

>>> id(so)
102596064
>>> id(new_so)
259679968
>>> so == new_so
True

@Bach,你认为现在它回答了这个问题吗? - Russia Must Remove Putin

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接