先说一个会颠覆你直觉的事实。下面四行代码,猜猜最后print(a)出什么:
a=[1,2,3]b=a# 你以为这是"复制一份"b.append(4)print(a)答案是[1, 2, 3, 4]——a 跟着 b 一起变了。
如果你觉得"这不科学",说明你脑子里的"变量"还是 C / Java 那套模型:变量是装值的盒子。而 Python 的变量,根本不是盒子。这一篇就把这个最底层、却几乎没人讲透的认知扳过来。因为往后你遇到"为什么函数改了我的心列表"“为什么两个字典神秘同步”“为什么小整数用is比较有时为真有时为假”——根子全在这。
一、案例理解:三个让你怀疑人生的现场
除了开头的别名污染,还有两个新手高发坑:
坑 1:函数"顺手"改了外面的列表。
defadd_one(lst):lst.append(1)x=[10]add_one(x)print(x)# [10, 1] ← 函数里改的,外面跟着变你以为"传进去的是值,函数内改不影响外面"。实际函数拿到的是对象的引用。
坑 2:默认参数会"记住"上一次。
defcollect(item,bucket=[]):bucket.append(item)returnbucketprint(collect(1))# [1]print(collect(2))# [1, 2] ← 第二次调用还在用同一个列表!bucket=[]这个默认值,在函数定义时只创建一次,之后每次调用都复用同一个列表。这不是特性,是无数生产 bug 的来源。
这三个坑有一个共同的根。
二、原理解析:名字是标签,不是盒子
这是全篇最重要的心智模型,请刻进脑子里。
C / Java 的模型(盒子):int a = 1;的意思是"在内存里划一块叫a的格子,把1放进去"。变量是容器,赋值是往容器里塞东西。你有两个变量,就有两份独立的内存。
Python 的模型(标签):a = [1, 2, 3]先在大内存里造出一个列表对象,再贴一张写着a的标签在它身上。b = a不是复制,是给同一个对象再贴一张b标签。所以通过b改对象,a看到的当然是同一个东西——因为它们本来就是一张桌子上的两枚便利贴。
证据,看id()(对象在内存里的身份证)和引用计数:
importsys x=[1,2,3]print("id(x) =",id(x))y=x# 再贴一张标签print("id(y) == id(x) ?",id(y)==id(x))# True —— 同一个对象,两张标签print("引用计数 =",sys.getrefcount(x)-1)# 2 —— x、y 各持有一份引用跑起来长这样(在python-basics/venv运行):
id(x) = 2223720481664 id(y) == id(x) ? True (同一个对象,两张标签) 引用计数(getrefcount) = 2 (x/y 各持有一份)从这个模型能推出三条你以前可能没想清的结论:
- 类型属于对象,不属于名字。连数字、函数、类都是对象,都有
id()。同一个名字可以先指向 int 再指向 str(a = 1; a = "hi"合法),因为"名字"只是标签,想贴谁贴谁。这就是"动态类型"的本质——它不是"变量没有类型",而是"类型跟随对象走"。 - 可变性是对象的属性,不是名字的属性。
list可变、int/str不可变。这决定了"改"和"换"是两件完全不同的事(见第三节)。 - "复制"在 Python 里是件需要显式说的事。
b = a是贴标签,b = a.copy()或b = a[:]才是真复制。很多 bug 就是因为没分清这两者。
三、完整代码:把四个真相一次跑给你看
下面这段代码把"标签模型 / 别名污染 / 小整数缓存 / None 单例"四个点一次验证(已在python-basics/venv真跑,输出原样):
importsys# ① 名字是贴在对象上的标签,不是装值的盒子x=[1,2,3]print("id(x) =",id(x))y=xprint("id(y) == id(x) ?",id(y)==id(x)," (同一个对象,两张标签)")print("引用计数(getrefcount) =",sys.getrefcount(x)-1," (x/y 各持有一份)")# ② 可变的坑:别名会互相污染a=[1,2,3]b=a b.append(4)print("a =",a,"| b =",b,"| a is b ?",aisb)# ③ 不可变:字符串不能改,只能换新对象s="hello"t=s t=t+"!"# t 指向新对象,s 不受影响print("s =",repr(s),"| t =",repr(t),"| s is t ?",sist)# ④ 小整数缓存(-5..256 复用同一对象)print("int('256') is int('256') ?",int('256')isint('256')," (256 在缓存区)")print("int('257') is int('257') ?",int('257')isint('257')," (257 不在缓存区,两次 new)")a,b=int('257'),int('257')print("id 不同:",id(a)!=id(b)," 但相等:",a==b)# ⑤ None 是单例n1=None;n2=Noneprint("None is None ?",n1isn2)真实输出:
id(x) = 2223720481664 id(y) == id(x) ? True (同一个对象,两张标签) 引用计数(getrefcount) = 2 (x/y 各持有一份) a = [1, 2, 3, 4] | b = [1, 2, 3, 4] | a is b ? True s = 'hello' | t = 'hello!' | s is t ? False int('256') is int('256') ? True (256 在缓存区) int('257') is int('257') ? False (257 不在缓存区,两次 new) id 不同: True 但相等: True None is None ? True请你盯着第 ④ 组看三秒:同样是"等于 257 的两个对象",is却是 False,但==是 True。这正是下一节要掰开的"身份 vs 相等"。
四、数据类型的真相(远比你以为的深)
Python 内置类型不多,但每个都有"教科书不写、踩了才知道"的反直觉点。
int —— 不可变 + 小整数缓存。整数对象一旦创建就不能改(x = x + 1是让x改贴新对象,不是改原对象)。解释器启动时就把-5到256的整数造好放在缓存池,全程复用。所以int('256') is int('256')为True;但257超出缓存区,每次都new,is就为False。结论:永远用==比数值,别用is——is比的是"是不是同一个对象",不是"值等不等"。
float —— 二进制浮点有误差。0.1 + 0.2得到0.30000000000000004,不是 bug,是 IEEE 754 用二进制近似十进制小数时的固有行为:
print(0.1+0.2)# 0.30000000000000004涉及钱的相等比较,用decimal.Decimal或允许误差(abs(a - b) < 1e-9)。
bool —— 是 int 的子类。True == 1、True + 1 == 2都成立。别写if x == True:(直接用if x:),更别拿布尔当数字累加——能跑,但读代码的人想打人。
str —— 不可变 + 拼接造新对象。字符串不能原地改,s = s + "x"每次都造新字符串。所以循环里拼大字符串要用"".join(parts),而不是反复+=(后者是平方级开销)。
None —— 唯一单例。表示"没有值"时永远用None,判断用is None(不是== None)。None全局只有一个,身份比较既快又对。
容器 list / tuple / dict / set。关键差异在"可变 vs 不可变":
list/dict/set可变,能原地增删改;tuple不可变,但它只是浅不可变——元素本身若可变(比如 tuple 里套 list),里面的东西照样能改:
t=(1,2,[3,4])try:t[0]=9exceptTypeErrorase:print("t[0]=9 报错:",e)t[2].append(5)print("改 t[2] 内部后 t =",t)# (1, 2, [3, 4, 5])输出:
t[0]=9 报错: 'tuple' object does not support item assignment 改 t[2] 内部后 t = (1, 2, [3, 4, 5]) ← tuple 没变,但里面的 list 变了一句话收住:可变性是对象自带的属性。改对象会影响所有指向它的标签;换标签(重新赋值)才不影响别人。
五、优缺点:动态类型与"隐式共享"是一体两面
好处:动态类型 + 一切皆对象,让 Python 写起来极快——同一个函数能接 list 也能接 tuple,代码复用度高;可变对象原地修改省内存、效率高。
代价:正因为"标签共享同一对象",可变对象的别名会隐式互相污染。函数拿到可变参数后在内部改了它,调用方完全不知情——这是 Python 项目里最高频的一类 bug。防御手段:
- 函数内要改入参前,先
.copy()一份; - 不希望被改的数据用
tuple当"不可变契约"; - 默认参数永远用
None占位,函数体里再bucket = bucket or [],避开坑 2。
六、横向对比:Python vs C/Java 的变量模型
| 维度 | C / Java(盒子模型) | Python(标签模型) |
|---|---|---|
| 变量是什么 | 一块固定类型的内存 | 贴在对象上的名字 |
b = a语义 | 复制值(两份独立内存) | 再贴一张标签(共享对象) |
| 类型在哪 | 绑在变量上 | 绑在对象上 |
| 改对象的影响 | 只影响自己那块 | 所有标签都看到 |
==vsis | 通常只有值比较 | ==比值、is比身份,要分清 |
理解了这个对照,你就明白:Python 的"奇怪"不是设计失误,而是"名字与对象解耦"这一选择的必然结果。接受它,bug 就少一半。
追击问句
- 既然
b = a是共享,那[1, 2, 3] == [1, 2, 3]为什么是True而is是False?(提示:回到"值 vs 身份"。) a = ([1], [2]); b = a; b[0].append(9)之后a会变成什么?先猜,再在 venv 里跑。
AI 辅助实践
把本文任意一段代码复制到python-basics/venv里跑(先python-basics/venv/Scripts/activate,再python 文件名.py),重点观察id()和is的输出,亲手验证"标签模型"。想加深印象,试着制造一个"两个变量神秘同步"的 bug 再修掉它。
下期预告
1.3 运算符与表达式:为什么-3 ** 2等于-9而不是9?a < x < b到底是怎么算的?运算符背后藏着"方法调用"和"短路求值"两个大招,下篇拆开。
附录:SEO 常见问题(FAQ)
Q1:Python 的变量到底存在哪?
变量名是贴在对象上的"标签",对象存在堆内存里;id()能看到对象的内存地址。
Q2:Python 里b = a是复制吗?
不是。b = a让b和a指向同一个对象;要复制用a.copy()或copy.deepcopy()。
Q3:is和==有什么区别?is比"是不是同一个对象"(身份,id相等),==比"值是否相等"。比数值永远用==。
Q4:为什么 Python 的小整数用is比较有时为真?-5到256的整数被解释器缓存复用,所以is为True;超出范围每次新建对象,is为False。这是实现细节,不要依赖。
Q5:Python 默认参数用空列表有什么坑?
默认值在定义时只建一次,多次调用会共享同一个列表,导致数据串台。用None占位、函数内再初始化。