相同字符串,不同的内存地址id?

735 阅读6分钟

问题描述

a = "abc!@#cba",b = "abc!@#cba",为什么id(a)不等于id(b)呢?

问题复现

先看看下面的测试实例:

# a/b的内存地址是一致的,说明此时解释器没有重新开辟内存空间
>>> a = "abc"
>>> b = "abc"
>>> id(a), id(b)
(4505588144, 4505588144)

# a/b的内存地址不是一致的,说明解释器为变量b赋值的时候,重新开辟了一个内存空间存储"abc cba"
>>> a = "abc cba"
>>> b = "abc cba"
>>> id(a), id(b)
(4506628976, 4506629104)

# 同上
>>> a = "abc!@#cba"
>>> b = "abc!@#cba"
>>> id(a), id(b)
(4506629168, 4506628976)

明明是同一个字符串,可为什么第二和第三测试样例中a/b所指向的地址却不一样呢?

问题现象

根据反复的测试后,我们把问题简单归纳成:

当字符串内部包含空格或者其他特殊字符的时候,解释器会重新为该字符串内容申请新的内存空间,此时多个相同的字符串对应的内存地址都是不一样的

原理探究

CPython不承诺在默认情况下保留所有字符串,但在实践中,Python代码库中的许多地方会重用已经创建的字符串对象。许多Python内部人员使用sys.intern()函数调用(相当于c函数)显式地实习生Python字符串,但除非遇到这些特殊情况之一,否则两个相同的Python字符串字面值将产生不同的字符串。

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

因此,您将不时地看到相同的id出现。

在REPL中只运行行id(<string literal>)需要执行以下步骤:

  1. 编译后的行,包括为string对象创建一个常量:
>>> compile("id('foo')", '<stdin>', 'single').co_consts
('foo', None)

这显示了存储的常量和编译后的字节码;在这种情况下,字符串'foo'和None。由产生不可变值的简单表达式组成的表达式可以在这个阶段进行优化,参见下面关于优化器的说明。

  1. 在执行时,从代码常量加载字符串,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
  1. 代码对象没有被任何东西引用,引用计数下降到0,代码对象被删除。因此,string对象也是如此。

然后,如果您重新运行相同的代码,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同时使用标识符和限定常量,你最终会为标识符及其值使用相同的string对象:

>>> 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编译器使用pehole优化器(Python版本< 3.7)或更强大的AST优化器(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

总结

  • Cpython解释器会适当缓存一些常量和不包含特殊字符的字符串进行重用;
  • 包含特殊字符的字符串在内存当中都是彼此独立的;

附录

关于sys.intern()

string in the table of “interned” strings and return the interned string – which is string itself or a copy. Interning strings is useful to gain a little performance on dictionary lookup – if the keys in a dictionary are interned, and the lookup key is interned, the key comparisons (after hashing) can be done by a pointer compare instead of a string compare. Normally, the names used in Python programs are automatically interned, and the dictionaries used to hold module, class or instance attributes have interned keys.

将字符串放入“internned”字符串的表中,并返回被internned的字符串——它是字符串本身或一个副本。在字典查找时,插入字符串可以获得一些性能提升——如果字典中的键被插入,查找键也被插入,那么键比较(散列后)可以通过指针比较而不是字符串内容比较来完成。通常,Python程序中使用的名称会自动转换,用于保存模块、类或实例属性的字典具有转换键。

参考内容

为什么带有空格会影响字符串之间内存id的比较

[关于不可变字符串id的变化](stackoverflow.com/questions/2…](stackoverflow.com/questions/2…)