引用机制
在 Python 中,变量本身不存储数据,它更像一个 "标签" 或 "指针",指向内存中真正存储数据的对象。
当你执行 a = 10 时,Python 会先在内存中创建一个值为 10 的int对象,然后让变量a成为这个对象的 "引用者"(即a指向该对象)。
当你再执行 b = a 时,并不会复制 10 这个值,而是让变量b也指向同一个int对象(a和b是同一个对象的两个引用)。
在 CPython(Python 的官方实现)中,"引用" 的底层实现本质是指针映射,结合符号表和对象结构体来完成。我们可以从三个核心层面拆解其实现逻辑:
如何实现
Python 中的 "变量名"(如a、b)本身不直接关联数据,而是作为符号表(symbol table) 中的一个 "键",其对应的值是一个内存地址指针(指向真正的对象)。
执行 a = 10 时,CPython 会在当前作用域的符号表中添加一条记录:{"a": 0x7f8a1b2c3d4e}(其中0x7f8a1b2c3d4e是整数 10 的对象在内存中的地址)。
执行 b = a 时,符号表会新增 {"b": 0x7f8a1b2c3d4e}——b的指针与a完全相同,指向同一个对象。
引用计数的增减规则
- 变量赋值:a = obj(obj的引用计数 + 1);
- 变量作为参数传递:func(obj)(函数调用时,obj的引用计数 + 1);
- 变量被放入容器:list.append(obj)(容器会新增一个对obj的引用,计数 + 1)。
- 变量被重新赋值:a = other_obj(原对象的引用计数 - 1);
- 变量超出作用域:函数执行结束,内部变量失效(引用计数 - 1);
- 变量被显式删除:del a(a指向的对象引用计数 - 1);
- 对象从容器中移除:list.remove(obj)(容器对obj的引用消失,计数 - 1)。
引用传递:指针的复制
当我们进行赋值、传参等操作时,"引用传递" 的本质是指针的复制,而非数据的复制。
赋值操作:b = a 会将a在符号表中对应的指针(如0x7f8a1b2c3d4e)复制给b,此时a和b的符号表条目指向同一个内存地址,即引用同一个对象。
函数传参:def func(x): ...; func(a) 会将a的指针复制给参数x,函数内部的x与外部的a指向同一个对象(因此修改可变对象时,外部能感知到变化)。
容器存储:lst = [a] 会在列表的内部数组中存储a的指针,列表对a指向的对象产生一个新的引用(因此对象的ob_refcnt会 + 1)。
在变量赋值(如b = a)时,CPython 的执行逻辑是:
先在当前作用域的符号表中查找a对应的指针(即a指向的对象的内存地址);
然后在符号表中为b创建一个新条目,将a的指针直接复制给b;
此时,a和b的符号表条目指向同一个内存地址(即同一个对象),该对象的引用计数会增加 1(因为多了一个引用者)。
这个过程中,对象本身没有被复制,只是新增了一个指向它的 "标签"(变量)。因此确实会出现 "一个对象被多个变量引用" 的情况(即一个值有多个映射)。
a = 5
b = 5
print(id(a))
print(id(b))
list1 = [1]
list2 = [1]
print(id(list1))
print(id(list2))
可能会出现不一样的情况,就是第一个代码块出现了相同的id,而第二个代码块则是出现了不同的id,这样也就说明了。可能在执行第一个代码块的语句的时候,在内存中创建了一次,然后变量都是对内存中足够数据引用,而在第二个代码块中,却创建了两次。
在解释这个现象之前,首先要了解,什么是可变对象,什么是不可变对象。
可变对象与不可变对象
在 Python 中,可变对象(Mutable Objects) 和不可变对象(Immutable Objects) 的核心区别在于:对象创建后,其内部数据是否可以被 "原地修改"。对象的 "不可变性" 是针对自身直接存储的数据("顶层数据")而言的,而非嵌套在其中的对象。这意味着,不可变对象的 "不可变" 并非绝对的 "所有层级数据都不可变",而是 "自身结构不可修改"。这种特性直接影响了对象的使用方式和 Python 的内存管理逻辑。
比如对于list提供了append方法,实现了数据的原地修改,那么就是可变对象,对于类如int类型数据,如果修改数据,就会出现一个id不同的情况,这样不能说是原地修改,而是创建新的对象。
list1 = [1]
print(id(list1))
list1.append(2)
print(id(list1))
可以很显然的看出,id是没有变化的。也就是说明,其是可变的。
但是在上面了,可变与不可变针对的是顶层数据而言,而不是嵌套的对象。
tuple1 = (1,[1,2])
tuple1[0] = 2
运行会报错,也就是说,在()里面的数据我们认为是对象是不可用更改的,但是如果执行以下的代码,却是可以更改的。
tuple1 = (1,[1,2])
print(id(tuple1))
tuple1[1].append(3)
print(tuple1,id(tuple1))
元组的id不变,因为其顶层引用(指向列表的指针)未被修改;列表内容变化是因为列表本身是可变对象,与元组的不可变性不冲突。
其中数据确实发生了变化,而且id也没有改变,这个问题的原因就是list其实存在于tuple的内部嵌套,我们谈论的其实是外部的部分,也就是tuple这个是一个不可变类型。像tuple1这样内部存在可变类型的不可变类型,我们称之为复杂不可变类型,同样的对于内部存在可变类型的可变类型,我们也称为复杂可变类型。可哈希性不能作为判断可变性的绝对依据。不可变对象通常可哈希(如int、str),但包含可变对象的不可变类型(如(1, [2]))不可哈希;可变对象通常不可哈希(如list),但通过自定义__hash__方法的可变对象可以被哈希。因此,判断可变性的核心是 "对象能否原地修改自身内容",而非是否可哈希。而对于复杂的我们只需要了解顶层数据类型,listh,dict,set等是可以变对象,而tuple,int,str等是不可变对象。
复用机制
对于,之前的问题,其实和复用机制相关:
对于python,只有不可变对象(int、str、tuple 等)才可能被复用,因为它们的值一旦创建就无法修改,复用不会导致意外的副作用;而可变对象(list、dict、set 等)每次创建都会申请新内存(即使内容相同),因为它们的值可以被修改,复用会导致不同变量的修改互相干扰。
但是,我们说,这个复用机制对于不可变对象来说是可能的,那么有没有绝对的?
python提供了一个叫做小整数缓存机制,基于这个机制导致如果变量最终的值是在[-5,256]内的整数(小整数),那么他们这些变量指向的id必然一致,注意这里是最终,只要是最终一样,无论是什么过程得到的,那么它们的id必然一致。小整数缓存是 CPython 的实现细节(范围 [-5, 256]),仅适用于 CPython 解释器,且仅针对字面量整数或可折叠的整数表达式。其他 Python 实现(如 PyPy)可能有不同策略,因此‘必然一致’的结论仅在 CPython 环境下成立。
小整数缓存机制原理
对于整数这种最基础的数据类型,Python(主要是 CPython)会预先创建并缓存一定范围的整数对象,避免频繁创建和销毁的开销。也就是说,这些数据是预创建的。
a = 5
b = a
b += 1
b -= 1
print(id(b) == id(a))
a = 257
b = a
b += 1
b -= 1
print(id(b) == id(a))
通过结果就可以发现,经过运算后,就会改变。
设计这个机制的原因。这是 Python 开发者基于统计的优化:实际编程中,-5 到 256 之间的整数(如循环计数、状态码等)被使用的频率极高,缓存它们能显著减少内存操作开销。这个范围在 CPython 的源码中是硬编码的(可通过修改源码调整,但不建议)。
字符串驻留机制(intern机制)
对于,小整数缓存机制,我们强调了一个最终,其实对于字符串而言有一个类似的复用机制intern机制,却强调了一个过程,即字符串的创建是动态的还是静态的,也就是创建过程。
Python 的intern机制(字符串驻留)本质是一种字符串去重优化,通过将相同内容的字符串映射到内存中唯一的对象,实现内存复用和比较加速。其核心是一个全局的 “字符串池”(interned哈希表),但字符串是否被加入池中(即是否被intern),与字符串的创建方式(静态还是动态)密切相关,这背后涉及 Python 解释器的编译时优化和运行时策略。
一、intern机制的底层逻辑:字符串池与哈希表
CPython 内部维护了一个名为interned的全局哈希表(可理解为 “字符串池”),其键是字符串的内容,值是该字符串对象的指针。当一个字符串被intern后,会被加入这个哈希表;后续若创建相同内容的字符串,会先检查哈希表:若存在,则直接返回已有对象的指针(复用);若不存在,则创建新对象并加入哈希表。
这个过程的核心目的是:
减少内存占用:避免相同内容的字符串重复占用内存(尤其是高频出现的字符串,如变量名、关键字)。
加速字符串比较:两个intern后的字符串比较时,可直接通过id(内存地址)判断是否相同,无需逐字符比对("a" == "a"直接比较地址,效率从 O (n) 提升到 O (1))。
二、静态字符串:编译时自动intern的核心场景
静态字符串指在代码中直接写死的字符串字面量(如"hello"、"user123"),它们在 Python 解释器编译阶段(将源码转为字节码时)就会被处理,符合条件的会被自动intern。
1. 静态字符串自动intern的条件
并非所有静态字符串都会被自动intern,CPython 主要对符合 “标识符规则” 的字符串触发自动intern:
包含字母(a-z、A-Z)、数字(0-9)、下划线(_)
注:静态字符串是否被 intern,核心取决于是否符合‘标识符规则’(字母、数字、下划线)。对于含特殊字符的短字符串(如"hello-world"),不同环境(VS Code 脚本式执行 vs Jupyter 交互式执行)的复用差异,源于编译范围(全局分析 vs 局部分析),而非特殊字符本身被允许的范围 —— 短字符串可能因编译器的宽松优化被 intern,长字符串则通常不被 intern;
长度无严格限制,但过长的字符串可能不被自动intern(平衡优化收益和哈希表开销)。
2. 为什么限制 “标识符规则”?
因为 Python 中最频繁重复的字符串是标识符(变量名、函数名、关键字、字典键等),它们恰好符合标识符规则。对这类字符串自动intern,优化收益最高(内存节省和比较加速最明显)。而含特殊字符的字符串(如"hello@123")重复出现的概率低,自动intern的收益不足以覆盖哈希表管理的开销。
3. 编译时的 “常量折叠” 对intern的影响
静态字符串的拼接若能在编译时确定结果(即 “常量折叠”),会被视为单个静态字符串处理,进而被intern。
# 编译时可确定的拼接:被折叠为"helloworld",自动intern
a = "hello" + "world"
b = "helloworld"
print(id(a) == id(b)) # True(a和b都指向interned的"helloworld")
这里"hello" + "world"在编译阶段就会被优化为"helloworld",相当于静态字面量,因此会被自动intern。
三、动态字符串:运行时intern的限制与手动触发
动态字符串指在运行时通过计算生成的字符串(如变量拼接、format格式化、input输入等),它们的内容在编译时无法确定,因此默认不会被自动intern,即使内容与静态字符串或其他动态字符串相同。
1. 动态字符串不自动intern的原因
运行时开销:动态字符串的内容在编译时未知,若每次创建都检查字符串池,会增加运行时的哈希计算和表查询开销(尤其对于临时字符串,复用概率低,得不偿失)。
不可预测性:动态字符串可能包含任意内容(如用户输入的随机字符串),重复概率低,intern的收益有限。
# 动态拼接(运行时生成):即使内容与静态字符串相同,也不自动intern
x = "hello"
y = "world"
a = x + y # 运行时拼接,动态生成"helloworld"
b = "helloworld" # 静态字符串,已intern
print(id(a) == id(b)) # False(a未intern,b已intern)
2. 动态字符串的手动intern
若需对动态字符串进行intern(如已知会频繁重复),可通过sys.intern()手动触发,强制将其加入字符串池。
手动intern适用于:
动态生成但频繁重复的字符串(如大量相同的日志信息、数据库查询结果);
需要频繁比较的动态字符串(通过intern加速比较)。
3. 动态字符串的特殊情况:部分隐性优化
某些动态字符串在特定场景下可能被隐性intern(依赖 Python 版本和实现):
长度为 1 的动态字符串(如chr(97)生成的"a")可能被复用(因单字符字符串高频出现);
解释器内部生成的动态字符串(如属性名)可能被intern(因属性名符合标识符规则,重复率高)。
但这些是解释器的细节优化,不具备通用性,不应依赖。
编译过程与常量优化
当然复用机制,也不仅仅是有以上解释的这些,我自己在使用jupyter和vscode正常编码的时候发现对于相同的代码,检测变量却有不一样的id情况(当然这里不是指的是id的数值,而是一种关系情况)
在jupyter中:
a = 122222
b = 122222
print(id(a) == id(b))
运行结果为:
False
而在vscode中,在python3.9.23下出现了以下情况:
a = 122222
b = 122222
print(id(a) == id(b))
True
当然由于可能环境不同,效果不同,但是我来谈论一下这个情况的核心原因。其实还是和编译器有关。
首先来解释一下,编译的过程:
一、词法分析(扫描阶段):识别常量 token
编译的第一步是 “词法分析”(由扫描器tokenizer完成),其作用是将源码字符串分解为一个个最小的语法单元(token,标记)。对于常量,这一阶段的工作是:
识别常量类型:将源码中的字面量(如123、"hello"、(1,2))识别为对应的token类型(如NUMBER、STRING、TUPLE)。
初步验证格式:检查常量的语法合法性(如字符串是否闭合、数字是否有无效字符),若不合法则抛出语法错误。
例:对于源码a = 10 + "abc",扫描器会识别出NUMBER(10)、STRING("abc")等常量token,但不会处理运算逻辑(运算处理在后续阶段)。
二、语法分析(解析阶段):构建常量相关的 AST 节点
语法分析阶段(由解析器parser完成)将词法分析得到的token序列转换为抽象语法树(AST)—— 一种结构化的语法表示。对于常量,这一阶段的工作是:
构建常量节点:将常量token转换为 AST 中的常量节点(如Constant节点),记录常量的类型和值。
例如,10会被转换为Constant(value=10, kind=None)节点;
"abc"会被转换为Constant(value="abc", kind=None)节点。
处理复合常量:对于复合常量(如元组(1, "a")、列表[1, 2]),会递归构建嵌套的 AST 节点(如Tuple节点包含两个Constant子节点)。
此时的 AST 仅反映语法结构,尚未进行优化(例如1 + 2会被解析为BinOp(left=Constant(1), op=Add(), right=Constant(2)),而非直接解析为3)。
三、语义分析与优化阶段:常量优化的核心操作
语义分析(由编译器compiler完成)是对 AST 进行合法性检查和优化的阶段,常量优化主要发生在这里。
1. 常量折叠(Constant Folding):编译时计算常量表达式
对于仅由常量组成的表达式(编译时可确定结果),编译器会直接计算结果,用结果替换原表达式,避免运行时重复计算。
数值运算:1 + 2 * 3 → 折叠为7;
字符串拼接:"a" + "b" * 2 → 折叠为"abbb";
元组拼接:(1, 2) + (3,) → 折叠为(1, 2, 3);
逻辑运算:True and False → 折叠为False。
而对于以下代码:
x = "hello"
y = "world"
a = x + y
b = "helloworld"
print(id(a) == id(b))
在扫描阶段只知道是两个变量拼接,而不知道两个变量究竟是什么!所以a是动态得到。
2. 常量复用(Constant Reuse):同一作用域的相同常量共享对象
对于同一编译单元内(如同一函数、同一模块)的相同常量,编译器会确保它们指向内存中的同一个对象,避免重复创建。
不可变常量:整数、字符串、元组、布尔值等(可变对象如列表[1,2]不会复用,因为它们的值可修改,复用会导致冲突)。
检查常量池是否已有相同值的常量;
若有,则直接复用已有对象(AST 节点指向同一常量);
若没有,则将新常量加入池中。
四、字节码生成阶段:固化优化结果
经过语义分析和优化后,编译器将优化后的 AST 转换为字节码(Python 的底层指令)。常量优化的结果会被固化在字节码中:
折叠后的常量会直接作为LOAD_CONST指令的参数(如LOAD_CONST 3表示加载常量3);
复用的常量在字节码中共享同一个常量索引(常量池在字节码中以列表形式存储,相同常量对应同一索引)。
常量复用机制并非所有编译器(或解释器的编译组件)都具备,即使是同一门语言的不同实现(如 Python 的 CPython、PyPy 等),或同一实现的不同执行模式(如脚本式 vs 交互式),其常量复用的策略和强度也可能存在显著差异。Jupyter 与 VS Code 的运行结果差异,本质上就是同一编译器(CPython)在不同执行模式下优化策略不同的体现。
浅拷贝与深拷贝
之前我们解释过了可变对象和不可变对象,由于python对象优化机制的复杂,官方提供了copy模块用于,用于对象的复制。
浅拷贝
对于所有不变类型均不创建新对象,也就是说有以下代码成立:
import copy
tuple1 = (1,2)
tuple2 = copy.copy(tuple1)
print(id(tuple1) == id(tuple2))#True
对于不含嵌套可变对象的不可变类型,浅拷贝会直接复用原对象(因复制无意义),因此id相同
但是对于复杂不可变类型中的元素中的可变部分是引用,而不是创建一个完全独立的对象。
import copy
tuple1 = (1,[2])
tuple2 = copy.copy(tuple1)
tuple1[1].append(1)
print(tuple2)#(1, [2, 1])
对于可变对象均创建新对象,对于复杂可变类型中的元素中的可变部分是引用,而不是创建一个完全独立的对象。
深拷贝
深拷贝对不可变对象的处理更灵活:若不可变对象不包含嵌套的可变对象(如(1, 2)、"abc"),深拷贝可能直接复用原对象(因复制无意义);若包含嵌套的可变对象(如(1, [2])),深拷贝会复制外层不可变容器(确保容器引用的嵌套对象是新副本),但外层容器本身的类型仍为不可变(如元组的id可能变化,也可能不变,取决于实现,但嵌套的可变对象一定是新副本)。
但是对于复杂不可变类型中的元素中的可变部分是创建一个完全独立的对象。
import copy
tuple1 = (1,[2])
tuple2 = copy.deepcopy(tuple1)
tuple1[1].append(1)
print(tuple1)#(1, [2, 1])
print(tuple2)#(1, [2])
对于可变对象均创建新对象,对于复杂可变类型中的元素中的可变部分是创建一个完全独立的对象。
总结
Python 的变量机制围绕‘引用’展开,结合对象的可变性,衍生出小整数缓存、intern 字符串池等复用策略(优化内存与性能),以及深浅拷贝等手动控制独立性的工具。这些机制的差异(如脚本式与交互式执行的区别)本质是解释器在‘优化收益’与‘实时响应’之间的权衡,理解它们能帮助我们写出更高效、更可靠的代码。
ps:笔者第一次些博客,如果写的不好请见谅。