课件 · W1 Day 1-2:名字绑定与可变性
课件 · W1 Day 1-2:名字绑定与可变性
配套代码:
../产出/实验/W1-Day1-2-名字绑定与可变性.py(6 模块 / 125 行) 覆盖差异条目:#1-8(索引见../产出/差异清单-C++到Python.md,本课件只引编号不重复抄) 课时:3h(Day 1 1.5h + Day 2 1.5h) 跑法:python -X utf8 -W error "docs/01-Python补足/产出/实验/W1-Day1-2-名字绑定与可变性.py"本文件是本次课的完整讲解(复习材料),文字为主、表格为锚点—— 复习时没有 AI 可问,所以所有”你当时可能会追问的答案”都预先写在这里了。 复习顺序:先跑一遍配套代码对着输出看,再逐模块读。
本次课的一条必答判据(W1 行动清单 §0.1):
不看资料,能口头解释为什么
def collect(item, acc=[])是错的。 (另一条”为什么 Python 没有确定性析构”在 Day 5)
模块 1 · 赋值 = 给对象贴新名字,不是拷贝
猜猜看:a = [1, 2]; b = a; b.append(3) 之后打印 a,会看到什么?a is b 是 True 还是 False?
① 现象
1) 改 b 之后 a = [1, 2, 3] | a is b = True
1) 改 c 之后 a = [1, 2, 3] | a is c = False
明明只改了 b,a 却也变成了 [1, 2, 3],而且 a is b 是 True——两个名字背后是同一个对象。后两行是对照组:先用 list(a) 造一份新列表交给 c,再改 c,a 纹丝不动,a is c 是 False。前两行的”惊吓”和后两行的”正常”放在同一段代码里,就是为了让你看到:行为差异不在 append,在赋值那一刻做了什么。
② 为什么
Python 的赋值语句 b = a 从头到尾只做一件事:把名字 b 绑定到右边表达式求值得到的那个对象上。它不读对象的内容、不分配新内存、不复制任何数据——把它想象成在同一个对象上多贴一张写着 b 的标签就对了。所以 b.append(3) 调用的是那个唯一列表对象的方法,改动通过任何一张标签都能看见。
a is b 为 True 也是同一件事的另一面:is 比较的是两个名字背后是不是同一个对象,而 b = a 之后它们本来就是同一个。第二组里 c = list(a) 不同——list(...) 是构造器调用,它会新建一个列表对象,再把 a 指向的元素逐个放进去,c 从此是独立的第二份数据,改它自然不再波及 a。用身份视角再验一遍:id(a) == id(b) 为 True,id(a) == id(c) 为 False——id() 返回对象一生唯一的身份号(CPython 里就是内存地址)。
③ C++ 对照
C++ 里 auto b = a; 走的是拷贝构造:对类类型会调用拷贝构造函数,把 a 的内容逐成员复制到一块新存储里,b 是一份完全独立的副本。C++ 程序员的肌肉记忆”赋值 = 复制”在 C++ 里对类类型默认成立——但这句话在 Python 里不成立,这就是全周最尖锐的一处差异。
想在 C++ 里模拟 Python 的 b = a,要写引用绑定 auto& b = a;——此时 b 只是 a 的别名,改 b 就是改 a。把两边对齐之后结论很清晰:Python 的普通赋值对应 C++ 的引用绑定;Python 里想要 C++ 默认的”拷贝”,反而要显式调用 list(a) / copy() / deepcopy()。默认行为正好相反,移植代码时最容易在这里翻车。
④ 坑与适用
带着”赋值 = 复制”的直觉写 b = a 然后修改 b,原数据就被污染了,而且全程不报错——没有异常、没有堆栈,往往要等下游读到脏数据才发现,排查时两头看起来都”没改过”。凡是把一个列表或字典交给第二个变量名、传进函数、放进另一个容器的地方,都要先问一句:这里我要的是别名还是副本?
想要的效果确定了,写法就是确定的:要别名就 b = a(省内存,一处改处处见);要独立的外层副本就 b = list(a) 或 b = a.copy()(浅拷贝,内层仍共享——见模块 5);要连内层都独立就 copy.deepcopy(a)。
| 想要的效果 | Python 写法 | C++ 对应 |
|---|---|---|
| 别名(同一份数据) | b = a | T& b = a; |
| 浅拷贝(只复制最外层) | a.copy() / list(a) / copy.copy(a) | 默认拷贝构造 |
| 深拷贝(递归复制全部) | copy.deepcopy(a) | 手写递归深拷贝 |
对应条目:#1 名字绑定 vs 变量、#2 赋值的复制语义
模块 2 · 实参传递:形参是一个新名字
猜猜看:下面三个函数,哪个能改到调用方的数据?
def mutate(p): p.append("被改了")
def rebind(p): p = ["新对象"]
def replace_in_place(p): p[:] = ["整块替换"]
① 现象
2) mutate 之后 xs = ['原始', '被改了']
2) rebind 之后 xs = ['原始', '被改了'] ← 没变
2) p[:] = ... 之后 xs2 = ['整块替换']
三个函数体看起来都在”往 p 里写东西”,结果却分成两类:mutate 和 replace_in_place 的改动穿透回调用方,rebind 的赋值无声消失——xs 还是老样子。三个实验的差别只在函数体那一行:append、p = [...]、p[:] = [...]。
② 为什么
Python 只有一种传参机制,常被叫作 call by object reference:调用时创建形参 p 这个新名字,把它绑定到调用方传进来的那个对象上。之后函数体里发生的事,取决于你改的是对象还是名字——这两件事从此必须严格分开。
改对象,指的是对 p 指向的那个共享对象做原地操作:p.append(x)(调对象的方法)、p[0] = 1(下标赋值)、p[:] = [...](切片整体替换,本质也是对对象本身的修改)。这些操作作用在调用方也能看见的那个对象上,所以穿透回去。改名字,指的是 p = [...] 这种赋值——它只是把函数本地的名字 p 重新绑定到一个新对象上,旧对象毫发无损,调用方手里的 xs 依然指着旧对象,所以什么也看不见。三个实验恰好各演示了一种情况。
③ C++ 对照
这套行为在 C++ 里的精确对应是指针按值传递 void f(T* p):p 本身是本地副本(改 p = new T{...} 只改本地指针),但 *p 指向的数据是共享的(p->push_back(x) 调用方看得见)。函数体里每种写法都能在指针世界里找到一一对应:p.append(x) ≈ p->push_back(x),p[0] = 1 ≈ p->at(0) = 1,p[:] = [1,2] ≈ *p = T{1,2};,p = [1,2] ≈ p = new T{...},p = None ≈ p = nullptr。
注意它不是 void f(T& r) 引用传参——C++ 的引用一旦绑定就不能改绑,所以引用参数根本表达不了 p = [...] 这个动作。反过来,C++ 程序员惯用的”引用做输出参数”在 Python 里没有等价物:要么老老实实 return 新值,要么利用可变容器的原地修改(p[:] = ... 这张牌在 W3 的取消实验里还会再用)。
④ 坑与适用
最容易踩的场景:想”通过参数把结果带出来”,在函数里写 p = 新列表,然后发现调用方什么都没收到——因为那是名字改绑,不是写入调用方的变量。诊断口诀:函数体里写 p.xxx(...) 或 p[i] = ... 会穿透;写 p = ... 不会。
反过来的适用场景是:有时候你就是想在函数里随便给参数重新赋值而不影响调用方(比如把 None 归一化成默认值,p = p or [])——这时 Python 的行为反而是顺手的,不需要先拷贝一份。另外注意实验设计的一个细节:第三个实验新开了 xs2 而不是复用 xs,因为 xs 已被 mutate 污染——做实验要隔离变量,否则输出读不出来。
| 函数体里写 | 调用方数据变吗 | C++ 对应 |
|---|---|---|
p.append(x) | 变 | p->push_back(x) |
p[0] = 1 | 变 | p->at(0) = 1 |
p[:] = [1, 2] | 变 | *p = T{1, 2}; |
p = [1, 2] | 不变 | p = new T{...}(只改本地指针) |
p = None | 不变 | p = nullptr |
对应条目:#3 实参传递
模块 3 · is 比身份,== 比值
猜猜看:like = [1, 2]; same = [1, 2]——like is same 和 like == same 分别是什么?再猜:x = 257; y = 257,x is y 呢?
① 现象
3) 内容相同的两个列表:is = False | == = True
3) 同一个 code object 里的 257(常量池去重)→ x is y = True
3) 两个独立代码对象里的 257:is = False
3) 小整数缓存 [-5, 256]:id(256) == id(256) → True
3) 可变对象不缓存: [] is [] → False
第一行符合直觉:内容相同的两个列表是不同对象,is False、== True。但中间三行反直觉:同样是 257,换个写法 is 的结果就从 True 变 False。同一个代码对象里的两个字面量 257 是同一个对象;分别编译的两段代码里的 257 却是两个对象;[] is [] 永远 False。
② 为什么
== 比较的是值:它调用左操作数的 __eq__ 方法,而 __eq__ 由类型作者定义、可以重载——两个内容相同的列表相等,就是这个方法在起作用。is 比较的是身份:判断两个名字背后是不是同一个对象(等价于 id(a) == id(b)),它由解释器直接实现,不可重载。
中间三行的反直觉来自 CPython 的两个实现细节。其一是常量池去重:编译一个代码对象时,其中出现的字面量 257 只会创建一次,x = 257 和 y = 257 都绑定到这同一个 int 对象,所以同文件里 x is y 是 True——但脚本里用 exec(compile(...)) 刻意制造了两个独立编译的代码对象,各自创建各自的 257,is 就成了 False。其二是小整数缓存:解释器启动时把 [-5, 256] 范围的整数预先建好,这个范围内的整数在整个进程里是单例,所以 id(256) == id(256) 为 True;257 恰好超出范围,身份就不再有保证。可变对象(如 [])从不缓存——每次字面量求值都新建一个,这是必然的:如果 [] is [] 是 True,两份独立数据就没法共存了。
③ C++ 对照
C++ 没有 is 这个维度。== 是运算符重载,语义完全由类型作者决定;想比较两个对象的地址(即身份),必须显式写 &a == &b。关键差异在于确定性:C++ 里 int 比较就走数值比较,&a == &b 就是地址比较,一个表达式属于哪一类是写死的;Python 里同一个 x is y 却”有时 True 有时 False”,因为结果取决于常量池和缓存这些实现细节。另外 C++ 也没有”整数缓存”这种事——整数字面量是编译期常量,身份这个概念对值类型根本不适用。
④ 坑与适用
坑:拿 is 判断”值是否相等”(比如 x is 1000)。它能跑,但结果是实现细节给你的运气——跨解释器、跨编译单元都可能翻车,而且解释器自己都在警告这种写法:256 is 256 会触发 SyntaxWarning: "is" with 'int' literal,直接破坏零告警验收(这就是脚本里那行写成 id(256) == id(256) 的原因)。
适用面其实很窄而且固定:is 只用于和单例对象比身份——x is None、x is True、x is False,以及你自己定义的哨兵对象(比如模块 4 里的 acc=None 判断)。这些都是全进程唯一的对象,比身份既快又可靠。判容器是否为空用 if not a:(走 __len__ / __bool__),别用 if a == []:。脚本里那句 exec(compile("X = 257", ...)) 是元编程入口,读懂它的实验目的即可,P1 不要求会用。
| 想比较什么 | Python 写法 | C++ 对应 |
|---|---|---|
| 值是否相等 | a == b(走 __eq__) | a == b(运算符重载) |
| 是不是同一个对象 | a is b(比 id()) | &a == &b |
| 判”没有值” | x is None | p == nullptr |
| 判容器是否为空 | if not a: | a.empty() |
对应条目:#4 is vs ==
模块 4 · 可变默认参数陷阱 ★(本周必答判据之一)
猜猜看:collect_bad(1)、collect_bad(2)、collect_bad(3) 分别返回什么?如果三次调用”看起来各自新建了空列表”,三次的结果应该是什么?
① 现象
4) collect_bad(1/2/3) = [1, 2, 3] [1, 2, 3] [1, 2, 3] ← 三次返回同一个列表
4) collect_bad.__defaults__ = ([1, 2, 3],) ← 证据就在函数对象上
4) collect_ok(1/2/3) = [1] [2] [3] ← 修复版行为正常
三次调用 collect_bad,返回的不是三个 [1]、[2]、[3],而是同一个不断累积的列表的三次快照——[1, 2, 3]、[1, 2, 3]、[1, 2, 3]。__defaults__ 这行把证据钉死了:默认值那个 list 对象就存在函数对象自己身上,里面已经是 [1, 2, 3]。修复版 collect_ok 三次调用互不干扰。
② 为什么
规则一句话:默认值在 def 语句执行的那一刻求值一次,而不是每次调用时重新求值。def collect_bad(item, acc=[]) 被执行时,[] 求值产生一个 list 对象,这个对象被存进函数对象的 __defaults__ 属性——从此它跟着函数对象活一辈子。之后每次调用,只要调用方没传 acc,形参 acc 就绑定到 __defaults__ 里那同一个对象;acc.append(item) 改的是它,三次调用自然滚雪球。所以这个陷阱的本质不是”默认值写错了”,而是”所有没传 acc 的调用共享同一个列表”——和模块 1 讲的别名机制是同一个原理,只是藏在了参数表里。
collect_ok 的修复思路是把默认值换成不可变的哨兵 None:None 没有”被改坏”的问题;函数体内用 if acc is None(模块 3 的 is 规则第一次实战——None 是单例,比身份可靠)判断”调用方没传”,是则新建一个空列表。这样每次需要的调用各自新建,互不共享。
③ C++ 对照
C++ 没有这个坑。默认参数是编译期常量,或者每次调用时求值的表达式:void f(int item, std::vector<int> acc = {}) 每次调用都会构造一个新的空 vector,作用域就是本次调用的栈帧,返回即销毁,不存在跨调用共享。Python 里最接近的对应物是函数级 static 局部变量——static std::vector<int> acc; 那种在多次调用间存活、累积的东西;而那个语义在 C++ 里是要刻意写 static 才会有的,Python 却因为”定义时求值一次”这个规则,让人不小心就写出了一样的行为。
| 对比项 | Python | C++ |
|---|---|---|
| 默认值求值时机 | 函数定义时一次 | 编译期常量 / 每次调用 |
| 最接近的对应物 | def f(x, acc=[]) ≈ 函数级 static 变量 | 要刻意写 static 才有 |
| 该坑是否存在 | 存在,教科书级 | 不存在 |
④ 坑与适用
这是本周两条必答判据之一,必须徒手复现并修好。会踩的场景非常具体:写带累积器的函数(收集日志、攒结果、默认传一个 dict 存配置)时顺手写 acc=[] 或 cfg={}——单测里只调一次根本暴露不出来,上线后跨调用累积,数据悄悄串台。
什么场景”该用”可变默认值?诚实回答:几乎永远不该。唯一说得通的情形是你刻意想要跨调用共享(比如拿默认 dict 当简易缓存),但那种场景更应该显式传一个缓存对象进来,让共享这件事在调用代码里可见,而不是藏在参数表里。记住诊断工具:怀疑某个函数踩了这个坑,打印它的 __defaults__ 看一眼——恶化现场就在那里。
def collect_ok(item, acc=None):
if acc is None: # None 是单例,判身份用 is(模块 3 的规则实战)
acc = []
acc.append(item)
return acc
为什么本模块没写类型注解:
W1-行动清单.mdDay 2 提到acc: list[int] | None = None。脚本里写的是裸acc=None——这是有意的:typing 是 W2 的内容,W1 的实验脚本故意不带注解(R11 的类型注解要求从 W2 起全面执行)。
对应条目:#7 可变默认参数陷阱
模块 5 · 浅拷贝 vs 深拷贝
猜猜看:src = {"k": [1, 2]},shallow = copy.copy(src) 之后执行 shallow["k"].append(99)——src 会变吗?换成 deep = copy.deepcopy(src) 再改 deep["k"] 呢?
① 现象
5) 初始 src = {'k': [1, 2]}
5) 浅拷贝改内层后 src = {'k': [1, 2, 99]} ← src 被改了
5) 深拷贝改内层后 src = {'k': [1, 2, 99]} ← src 没变
5) 深拷贝改内层后 deep = {'k': [1, 2, 99, 100]}
5) 浅拷贝的内层是同一个对象吗: True
浅拷贝之后只改了 shallow 的内层,src 却跟着变了;最后一行给出直接证据——浅拷贝出来的 dict,它键 "k" 指向的内层列表和原对象是同一个(is 为 True)。深拷贝的行为才是直觉中”复制”该有的样子:改 deep 的内层,src 毫不知情。
② 为什么
copy.copy 只复制最外层容器:它新建一个 dict 对象,把键值对逐条抄过去——但抄的是”键到值的引用”,值本身(那个内层列表)没有复制,新旧两个 dict 的 "k" 键指向同一个列表。所以 shallow["k"].append(99) 改的是共享的内层,src 当然看得见。copy.deepcopy 则递归复制整棵对象图:内层列表也新建一份,deep["k"] 指向新列表,改它不再波及 src。
“深不深”不是靠感觉判断的,可靠手段就是脚本里那行:copy.copy(src)["k"] is src["k"]——结果 True 说明内层还在共享,False 才是真复制。还有一点读输出时必须留意:本模块是状态累积的,deepcopy 是在”src 已被污染成 [1,2,99]”之后才执行的,所以第三行 src 显示的是污染后的值。不打印第一行基线,就会误读。
③ C++ 对照
C++ 的默认拷贝构造其实也是”逐成员”的——但每个成员会各自调用自己的拷贝构造:std::map<std::string, std::vector<int>> 被拷贝时,map 新建一份,里面的每个 std::vector 成员也调用自己的拷贝构造新建一份,vector 又深拷贝其元素——所以对这种类型,默认拷贝就是全深的。关键差异在这里:
C++ 的”深不深”是类型的属性——每个成员的拷贝语义由该类型的拷贝构造决定,编译期就定死,同一类型只有一种拷贝行为。 Python 的”深不深”是调用点的选择——同一个对象,
copy()就是浅、deepcopy()就是深,两种都合法,选择权在你手上。
| 想要的效果 | Python 写法 | C++ 对应 |
|---|---|---|
| 只复制最外层 | copy.copy(a) / a.copy() / a[:] | 默认拷贝构造(逐成员,实际效果往往更深) |
| 递归复制全部 | copy.deepcopy(a) | 默认拷贝构造已覆盖 / 手写递归深拷贝 |
| 验证内层是否共享 | copy.copy(a)["k"] is a["k"] | 无运行时对应,需读类型定义 |
④ 坑与适用
两个高频坑。其一:切片也是浅拷贝——m = matrix[:] 只复制外层列表,内层的行仍然共享;对 m[0].append(x) 会改到原 matrix。同类的还有 list(a)、a.copy()、字典推导——全部只深一层。其二:P3 的 S 层(状态存储)做状态快照时会真的咬人——你以为 snapshot = state.copy() 存了历史,实际存了一堆别名,回滚时发现”历史”全被现在改掉了。做快照必须 deepcopy,或者保证被快照的结构里没有可变对象。
适用判断:内层全是不可变对象(数字、字符串、tuple——注意模块 6 的例外)时,浅拷贝等于深拷贝且便宜得多,用浅的;嵌套可变结构需要真正隔离时,才付 deepcopy 的代价——它是递归整棵对象图,慢,而且碰到不可深拷贝的对象(文件句柄、锁、socket)会直接失败,这在自查题里还会追问。
对应条目:#5 可变/不可变、#6 浅拷贝 vs 深拷贝
模块 6 · tuple 不等于完全不可变
猜猜看:t = (1, [2, 3]),t[1].append(4) 能成功吗?那 t[1] += [5] 呢——如果能抛异常,异常之后 t 里还有 5 吗?
① 现象
6) t[1].append(4) 之后 t = (1, [2, 3, 4])
6) t[1] += [5] 抛: TypeError - 'tuple' object does not support item assignment
6) 异常之后 t = (1, [2, 3, 4, 5]) ← 抛了异常,改动却留下了
6) hash(t) 抛: TypeError - unhashable type: 'list'
append 成功,tuple 内容变了;+= 抛了 TypeError——但异常之后打印 t,5 已经加进去了。抛异常的操作留下了半完成的修改,这是本模块最重要的一幕。hash(t) 也失败:含 list 的 tuple 不可哈希。
② 为什么
tuple 的”不可变”只管自己那一层:它的元素槽位在创建时固定——t[1] 这个位置永远指向同一个对象,这一点不能改。但那个被指向的对象自己如果是可变的,照样可以改:t[1].append(4) 是”读元素(得到 list)→ 调它的方法”两步,每步都合法,所以成功。
t[1] += [5] 之所以留下半完成状态,是因为它不是原子操作,而是分两步执行的复合语句:第一步对 t[1] 做 __iadd__——list 的 __iadd__ 是原地扩展(等价于 extend),这一步成功了,改动已经发生;第二步把结果写回 t[1]——tuple 不支持元素赋值,TypeError 在这里抛出。先改、后报错,异常掩盖不了第一步留下的副作用。hash(t) 失败是同一根源的另一面:tuple 的哈希要递归算每个元素的哈希,list 不可哈希,整个 tuple 就跟着不可哈希——可哈希性是逐元素要求的。
| 操作 | 结果 | 为什么 |
|---|---|---|
t = (1, [2, 3]) | 合法 | tuple 允许装可变对象 |
t[1].append(4) | 成功 | 改的是元素指向的对象,不动槽位 |
t[1] = 9 | TypeError | 元素赋值会改槽位,被禁止 |
t[1] += [5] | 先改成功,再抛错 | __iadd__ 原地改(成功)→ 写回槽位(失败) |
hash(t) | TypeError | 元素不可哈希 → 整个 tuple 不可哈希 |
③ C++ 对照
这里有一处原对话里说错、课件必须改正的细节:C++ 的 const 是传染的。const std::tuple<int, std::vector<int>> t{...} 里,std::get<1>(t) 返回的是 const std::vector<int>&,对它调 push_back 编译不过——顶层 const 沿着访问路径一路传染到内层。所以 C++ 的类型系统里,“外层槽位固定、内层可变”这个语义不是默认存在的:要么整体 const(内层也动不了),要么整体可变。想精确表达 Python tuple 这个语义,得显式设计(比如存引用或指针成员)。
第二个差异是出错时机:C++ 的 const 检查发生在动作之前(编译期直接拒绝),不存在”改了一半再报错”;Python 没有编译期检查,t[1] += [5] 的半完成状态是运行时才暴露的——而这正是”异常路径下的状态一致性”问题,Day 5 讲 with 与异常时还会回来。
④ 坑与适用
会踩的场景:想拿 tuple 当”轻量不可变记录”用,随手把一个 list 塞了进去——之后把它当 dict 的 key 或放进 set 时直接 TypeError(不可哈希);更隐蔽的是 += 这种复合语句在 tuple 里留下半完成状态,污染数据还不容易察觉。
该用的姿势:要”真不可变、可哈希”的记录,tuple 的所有元素都得是不可变的——数字、字符串、嵌套 tuple 都行,list 不行;dict / set 的 key 永远要求这个标准。把”不可变容器里放可变对象 = 不可变性只是表面的”这条通用陷阱记住,它在任何语言里都成立——C++ 的 const 成员指向堆对象时也是同一个故事。
对应条目:#8 tuple 里包可变对象
本次课练习(自查题)
- (Day 1) C++ 里「传引用」和 Python 里「传对象引用」是同一件事吗?
提示:回到模块 2 的表——注意
p = [...]那一行在 C++ 引用传参下根本不存在。 - (Day 2)
copy.deepcopy在什么结构上会退化或爆炸? 提示:想想自引用结构(deepcopy 内部有 memo 表处理环,但代价仍在)、不可深拷贝的对象(文件句柄 / socket / 锁)、以及超大对象图的递归开销。
下次课
Day 3 · 作用域与闭包(差异 #9-12),见 W1-Day3-作用域与闭包.md。
预告:lambda k=k: k 这个修复手段,用的正是本课模块 4 的”默认参数在定义时求值一次”。