007、变量与内存模型:Python 对象、引用计数与 is == 的本质区别
007、变量与内存模型Python 对象、引用计数与 is 的本质区别上周帮同事排查一个诡异的bug代码逻辑看起来完全正确但某个条件判断就是进不去。他写的是if user is None:但实际调试发现user的值确实是None可条件就是 False。我让他打印type(user)结果返回的是NoneType再打印id(user)和id(None)两个地址不一样。他当场懵了——明明赋值了user None怎么地址不同这就是典型的“变量不是盒子”的认知陷阱。变量是标签不是盒子很多从C/C转过来的同学脑子里天然带着“变量是存储数据的容器”这个模型。Python完全不是这回事。Python的变量更像是一个便利贴贴到对象上。当你写a 42不是把42这个整数放进一个叫a的盒子里而是创建了一个整数对象42然后把标签a贴上去。# 这里踩过坑以为a和b是两个独立变量a[1,2,3]ba# b不是复制了列表而是贴了同一个对象的第二个标签b.append(4)print(a)# [1, 2, 3, 4] a也被改了因为a和b指向同一个对象这个例子我见过无数新人踩坑。如果你真的需要复制用b a.copy()或者b a[:]别直接赋值。对象的三要素id、type、value每个Python对象在内存中都有三个核心属性id对象的内存地址可以用id()获取创建后不变type对象类型用type()获取创建后不变value对象的值可变对象可以改变不可变对象不能x256y256print(id(x),id(y))# 地址相同因为Python对小整数做了缓存x257y257print(id(x),id(y))# 地址不同大整数不缓存别这样写代码依赖小整数缓存机制来判断对象是否相同。这是CPython的实现细节不是语言规范。换到PyPy或者Jython可能就炸了。引用计数Python的自动内存管理每个对象内部维护一个计数器记录有多少个标签引用贴在上面。当引用计数降到0对象就被回收。importsys a[]print(sys.getrefcount(a))# 2因为getrefcount本身也传入了引用baprint(sys.getrefcount(a))# 3b也引用了同一个列表delbprint(sys.getrefcount(a))# 2b被删除引用减1引用计数有个经典问题循环引用。两个对象互相引用外部没有其他引用指向它们但引用计数永远不会降到0。Python的垃圾回收器gc模块会定期检测并清理这种循环引用但如果你写的是性能敏感的代码最好手动打破循环。# 别这样写循环引用导致内存泄漏classNode:def__init__(self):self.parentNoneself.childNoneaNode()bNode()a.childb b.parenta# 即使del a, del b这两个对象仍然互相引用gc会处理但延迟is 和 的本质区别这是面试高频题也是实际开发中容易翻车的地方。比较的是值value调用__eq__方法is比较的是id内存地址相当于id(a) id(b)a[1,2,3]b[1,2,3]print(ab)# True值相同print(aisb)# False不同对象caprint(cisa)# True同一个对象什么时候用is判断单例对象比如None、True、False。别用判断 None虽然结果一样但is更快而且更符合语义。# 推荐写法ifuserisNone:print(用户不存在)# 别这样写ifuserNone:# 虽然能工作但语义不对而且慢print(用户不存在)有个坑某些自定义类重写了__eq__方法导致的行为变得诡异。比如 pandas 的 DataFramedf1 df2返回的是元素级的布尔矩阵不是单个布尔值。这时候用is判断对象是否相同更安全。可变对象与不可变对象的陷阱不可变对象int、str、tuple等一旦创建就不能修改。但注意不可变对象内部的引用可能是可变的。# 这里踩过坑以为tuple不可变就安全t(1,[2,3],4)t[1].append(5)# 可以修改tuple本身没变但内部列表变了print(t)# (1, [2, 3, 5], 4)别把可变对象塞进tuple里当不可变用这是给自己埋雷。函数参数的传递机制Python的参数传递是“传对象引用”不是传值也不是传引用。听起来绕看代码就明白了defmodify_list(lst):lst.append(4)# 修改了传入的对象lst[5,6,7]# 重新绑定不影响外部my_list[1,2,3]modify_list(my_list)print(my_list)# [1, 2, 3, 4]append生效了但重新赋值没生效函数内部对参数重新赋值只是把局部变量指向了另一个对象不影响外部。但通过参数修改对象内部状态外部会感知到。# 别这样写默认参数用可变对象defadd_item(item,target[]):# 这个空列表只创建一次target.append(item)returntargetprint(add_item(1))# [1]print(add_item(2))# [1, 2] 同一个列表被反复使用正确的做法是用None作为默认值函数内部再创建defadd_item(item,targetNone):iftargetisNone:target[]target.append(item)returntarget个人经验建议调试时多用id()当你怀疑两个变量是否指向同一个对象时直接打印id()比猜靠谱得多。我调试循环引用问题时经常用id()追踪对象流向。is只用于 None、True、False 和单例别拿is比较整数、字符串即使小整数缓存机制让你偶尔得到正确结果但这是不可靠的。我见过生产环境因为字符串驻留机制在不同Python版本表现不同而出的bug。理解引用计数有助于排查内存泄漏如果你的Python进程内存只增不减检查是否有对象被意外持有引用。用gc.get_objects()可以列出所有被gc追踪的对象配合objgraph库画引用图定位问题很快。写代码时默认假设变量是引用除非你明确知道自己在做复制否则所有赋值操作都是贴标签。这个思维转变过来很多坑自然就避开了。别过度优化有些人为了省内存到处用is判断结果代码可读性变差。Python的已经很快了除非在性能热点否则优先保证代码清晰。最后说一句理解Python的内存模型不是让你背概念而是让你在遇到诡异bug时脑子里能浮现出“变量是标签”这个画面然后顺着引用链去排查。我那个同事后来改成了if user is None:还是不行最后发现是user被某个第三方库的装饰器包装成了代理对象。所以永远保持怀疑永远打印id()验证。