主题
变量和对象
在理解了基本类型之后,我们现在要深入 Python 中最核心也最容易被误解的概念:变量与对象。几乎所有从其他语言转学 Python 的人,都会在某个时刻被这些问题绊住:为什么两个变量同时指向一个对象,修改其中一个会影响另一个?为什么明明传了一个列表进函数,函数里修改了它,外面的列表也变了?为什么 == 比较明明相等,结果却是 False?
这些表面看起来五花八门的问题,归根结底都指向同一个本质:Python 的变量不是容器,而是名字;Python 的对象不是值,而是真实存在于内存中的实体。理解了这个之后,一切关于赋值、传参、拷贝、比较的问题都会变得清晰明朗。
变量不是盒子,是名字
让我们从最简单的一行代码开始。
python
a = 10很多教材会画一个盒子,里面写着 10,然后盒子外面标着 a。这个比喻在某些语言里勉强说得过去,但在 Python 里它彻头彻尾就是错的。Python 真正做的事情是:先在内存中创建了一个整数对象 10,然后给这个名字 a,让它指向这个对象。换句话说,a 不是一个盒子,它是一个贴在对象上的标签。
可以用 Python 的 id 函数来验证这一点。id 返回的是对象在内存中的唯一标识:
python
a = 10
print(id(a))如果变量是容器,id 应该没有意义。但实际上每个对象都有唯一的 id,名字只是指向这个 id 的引用。
理解这个区别之后,再看赋值操作就清晰多了。当我们写 a = 20 的时候,并不是把盒子里的 10 替换成 20,而是在内存中创建了一个新对象 20,然后让 a 指向这个新对象。原本的 10 如果没有其他名字引用它,就会被 Python 的垃圾回收器清理掉。
什么是对象
既然变量只是指向对象的名字,那对象本身才是存储数据的实体。在 Python 里,一切皆对象:数字是对象,字符串是对象,列表是对象,字典是对象,函数是对象,类也是对象。甚至一个模块、一个生成器、一个线程,都是对象。
每个对象在创建之后都有三个核心属性。第一个是身份,也就是 id,在 CPython 实现中这个 id 就是对象的内存地址。第二个是类型,也就是这个对象属于什么类别——整数、字符串、列表还是其他。类型决定了对象支持哪些操作,以及对象在内存中占多大空间。第三个是值,也就是对象包含的数据本身。
这三个属性共同构成了对象的完整画像:
python
a = 10
print(id(a)) # 身份
print(type(a)) # 类型
print(a) # 值理解对象的三属性是理解 Python 一切行为的基础。比如两个对象能否相等比较,取决于它们的值是否相等;两个对象是否是同一个对象,取决于它们的身份是否相同;一个对象能否作为字典的键,取决于它是否可哈希。
不可变对象与可变对象
在 Python 的对象体系中,有一个至关重要的分类:不可变对象和可变对象。整数、浮点数、字符串、布尔值、元组这些类型都是不可变的,意思是它们创建之后,值就不能改变。列表、字典、集合则是可变的,意味着它们创建之后,内容可以随时修改。
这个区别听起来简单,但它带来的影响是深远的。
先看不可变对象。以整数为例:
python
x = 5
y = x
x = x + 1当你执行 x = x + 1 时,并不是把 x 指向的那个整数对象从 5 变成了 6。真正的过程是:先计算 x + 1,得到一个新对象 6,然后让 x 指向这个新对象。y 仍然指向原来的对象 5,所以 y 的值不受影响。这就是为什么不可变对象可以安全地在多个变量之间共享——无论怎么折腾,谁也改不了谁。
再看可变对象。以列表为例:
python
a = [1, 2, 3]
b = a
b.append(4)
print(a) # [1, 2, 3, 4]这里 a 和 b 指向的是同一个列表对象。当你对 b 执行 append 操作时,修改的是这个列表对象本身,而不是让 b 指向一个新的列表。所以 a 也看到了这个变化。这和整数的行为形成了鲜明对比。
理解了这个区别之后,你就会明白为什么有些代码的行为看起来"奇怪"。函数参数传递的问题其实也是这个机制的表现。
引用计数与垃圾回收
Python 使用引用计数作为主要的内存管理机制。每个对象都有一个计数器,记录着有多少个引用指向它。当对象被创建时,计数器初始化为 1。每当有新的引用指向这个对象时,计数器加 1;每当一个引用被删除或者指向了另一个对象时,计数器减 1。当计数器降到 0 时,说明没有任何引用指向这个对象了,它就变成了垃圾,需要被回收。
可以用 sys 模块来查看一个对象的引用计数:
python
import sys
a = []
print(sys.getrefcount(a))不过注意 getrefcount 本身会创建一个临时引用,所以返回值会比实际多 1。
引用计数机制有一个致命的弱点:无法处理循环引用。比如:
python
a = []
b = []
a.append(b)
b.append(a)这里 a 和 b 对象之间形成了循环引用:a 引用了 b,b 也引用了 a。尽管没有任何外部变量引用它们,但它们的引用计数都不是 0,引用计数机制无法回收它们。
为了解决这个问题,Python 还有一个分代垃圾回收器。它会把对象按存活时间分成几代,新创建的对象属于年轻代,存活时间越长的对象会被移动到更老的代。垃圾回收器会定期扫描每一代的对象,识别出那些虽然有循环引用但已经没有外部引用指向它们的对象,并回收它们的内存。这就是为什么即使有循环引用,Python 程序也不会发生内存泄漏的原因。
is 与 == 的区别
初学者经常搞不清楚 is 和 == 的区别。简单来说,== 比较的是两个对象的值是否相等,is 比较的是两个对象是否是同一个对象(也就是身份 id 是否相同)。
python
a = [1, 2, 3]
b = [1, 2, 3]
print(a == b) # True,值相等
print(a is b) # False,不是同一个对象在这个例子中,a 和 b 的值相等,但它们是两个独立的对象,有不同的 id。
有一个常见的坑:
python
a = "hello"
b = "hello"
print(a is b) # 可能为 True,也可能为 FalsePython 可能会对短字符串进行驻留优化,让内容相同的字符串字面量共享同一个对象。但这个行为是不确定的,不应该依赖它。
对于整数也有类似的情况:
python
a = 256
b = 256
print(a is b) # True,Python 缓存了 -5 到 256 的整数
a = 257
b = 257
print(a is b) # 不一定为 True所以永远不要用 is 来比较值是否相等,尤其是在处理外部输入时。永远用 == 比较值,用 is 比较身份。
浅拷贝与深拷贝
有时候我们需要复制一个对象,而不是复制引用。Python 提供了两种拷贝方式:浅拷贝和深拷贝。
对于列表,可以使用切片、copy 方法或者 list 函数来拷贝:
python
a = [1, 2, 3]
b = a.copy()
c = a[:]
d = list(a)但这些拷贝都是浅拷贝,只拷贝了一层对象。如果列表里面嵌套了可变对象,嵌套的部分仍然共享引用:
python
a = [1, 2, [3, 4]]
b = a.copy()
a[2].append(5)
print(b[2]) # [3, 4, 5],b 也看到了变化这是因为 a 和 b 指向的是两个不同的列表对象,但这两个列表对象的第三个元素都指向同一个内部列表 [3, 4]。
要完全独立地复制一个对象,需要使用深拷贝。深拷贝会递归地复制所有嵌套的对象:
python
import copy
a = [1, 2, [3, 4]]
b = copy.deepcopy(a)
a[2].append(5)
print(a[2]) # [3, 4, 5]
print(b[2]) # [3, 4],完全独立深拷贝会创建一套完全独立的对象层次结构,但代价是性能开销更大,且不能拷贝包含循环引用的对象。
驻留机制
Python 会在内部缓存某些不可变对象,让相同值的对象共享同一块内存。字符串的驻留可以通过 intern 机制显式实现:
python
import sys
a = sys.intern("hello")
b = sys.intern("hello")
print(a is b) # TruePython 也会自动缓存小整数,范围通常是 -5 到 256。这些优化是 Python 解释器的实现细节,不应该依赖它们来编写程序逻辑。但在某些性能敏感的场景下,理解这些优化可以帮助写出更高效的代码。
循环引用与内存管理
虽然循环引用会导致引用计数无法直接回收,但 Python 的分代垃圾回收器会定期处理它们。
首先,循环引用不一定会导致内存泄漏,因为 GC 会清理它们。但 GC 的运行有成本,频繁创建大量有循环引用的对象会影响程序性能。
其次,有些对象会屏蔽自己,不被 GC 回收。比如如果一个对象持有对自己的引用,并且这个引用是循环引用的一部分,这个对象可能不会被回收。
在实际编程中,虽然不需要过度担心循环引用导致内存泄漏,但了解其原理有助于理解为什么不应该写出故意形成循环引用的代码,以及为什么有时需要显式删除引用或者使用弱引用来避免不必要的内存保留。
理解变量和对象的本质,是理解 Python 一切行为的基础。从赋值操作到函数传参,从比较语义到内存管理,都离不开这个核心概念。当你能向别人解释清楚"Python 的变量不是容器,而是名字"的时候,你就真正掌握了这门语言的精髓。