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

57 投票
5 回答
13836 浏览
提问于 2025-04-18 09:57

关于Python 2.7中字符串类型(str)对象的id,我有点困惑。因为字符串是不可变的,所以我本以为一旦创建,它的id就应该一直保持不变。可能我表达得不太清楚,所以我来举个例子,看看输入和输出的情况。

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

在这个过程中,它的id却一直在变化。不过,当有一个变量指向这个字符串后,情况就变了:

>>> 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 个回答

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
1

要更简单地理解这个行为,可以查看以下这个链接:数据类型和变量

在“字符串的特殊性”这一部分,用特殊字符作为例子来说明你的问题。

1

在你的第一个例子中,每次都会创建一个新的字符串 'so',所以它们的身份(id)是不同的。

而在第二个例子中,你把这个字符串绑定到一个变量上,这样Python就可以保持一个共享的字符串副本。

4

这种行为是特定于Python的交互式命令行。如果我把下面的内容放在一个.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是一样的。

85

CPython 默认并不保证会对所有字符串进行“内存重用”,但实际上,Python 的很多地方会重复使用已经创建的字符串对象。很多 Python 的内部实现会使用 sys.intern() 函数 来明确地进行字符串的内存重用,不过,除非你碰到那些特殊情况,否则两个相同的字符串字面量会产生不同的字符串。

Python 也可以自由地“重用”内存位置,并且 Python 会通过在编译时将不可变的“字面量”存储一次来进行优化,这些字面量会和字节码一起存储在代码对象中。Python 的 REPL(交互式解释器)还会把最近的表达式结果存储在 _ 这个名字里,这让事情变得更加复杂。

因此,你会时不时看到相同的 ID 出现。

在 REPL 中运行 id(<string literal>) 这一行会经历几个步骤:

  1. 这一行会被编译,这包括为字符串对象创建一个常量:

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

    这显示了与编译字节码一起存储的常量;在这个例子中是字符串 'foo'None 单例。简单的表达式如果产生不可变值,可能会在这个阶段被优化,下面会有关于优化器的说明。

  2. 在执行时,字符串会从代码常量中加载,id() 返回内存位置。结果的 int 值会被绑定到 _,并且会被打印出来:

    >>> 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. 代码对象没有被任何东西引用,引用计数降到 0,代码对象被删除。因此,字符串对象也被删除。

然后,如果你重新运行相同的代码,Python 可能会“重用”相同的内存位置来创建一个新的字符串对象。这通常会导致如果你重复这段代码,会打印出相同的内存地址。这取决于你对 Python 内存的其他操作

ID 的重用是 不可预测 的;如果在此期间垃圾回收器运行以清理循环引用,其他内存可能会被释放,你就会得到新的内存地址。

接下来,Python 编译器也会对任何存储为常量的 Python 字符串进行内存重用,只要它看起来像一个有效的标识符。Python 的 代码对象工厂函数 PyCode_New 会通过调用 intern_string_constants() 来对任何只包含 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 优化器(Python 版本 < 3.7),要么使用更强大的 AST 优化器(3.7 及更新版本)来预先计算(折叠)涉及常量的简单表达式的结果。peephole 优化器将输出限制在长度为 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

撰写回答