八、拷贝与可变性陷阱(P67–P72)
P67 · 知识点:浅拷贝
【题目】输出结果是?
a = [[1, 2], [3, 4]]
b = a[:]
b[0][0] = 99
print(a[0][0])A. 99 B. 1 C. 抛异常 D. None
答案:A
【考点】切片是浅拷贝:外层新列表,内层元素共享引用。
【结论】选 A。切片实现浅拷贝,外层列表独立但内层对象共享引用,因此修改内层元素会同时影响原列表与拷贝列表。
【推导过程】
a = [[1, 2], [3, 4]]
b = a[:] # 创建新外层列表,内层元素引用不变
# 此时 b[0] is a[0] → True
b[0][0] = 99 # 修改的是共享的内层列表对象
print(a[0][0]) # 输出 99【逐项辨析】 A. 99 —— 正确。b[0][0] = 99 修改的是 a 与 b 共享的内层列表,a[0][0] 同步变为 99。 B. 1 —— 错误。若深拷贝才会保持 1 不变,但切片是浅拷贝。 C. 抛异常 —— 错误。列表索引赋值合法,不会抛出异常。 D. None —— 错误。print 输出的是修改后的整数 99,而非 None。
【知识点】 Python 的赋值机制与拷贝层级:
- 赋值(b = a):仅将变量名绑定到同一对象,不产生新对象,b is a 为 True。
- 浅拷贝(b = a[:] / b = a.copy() / list(a) / copy.copy(a)):创建最外层新容器对象,但容器内的元素引用保持不变。对于单层列表效果等同于独立副本;对于嵌套结构,内层对象仍共享。
- 深拷贝(copy.deepcopy(a)):递归遍历所有嵌套层级,为每一层可变对象都创建全新副本,最终得到的对象与原对象完全独立。
列表切片 a[:] 的底层逻辑是调用 PyList_GetSlice,它会新建一个 PyListObject,然后将原列表各元素的指针(PyObject*)逐一填入新列表的 ob_item 数组。因此新列表的“格子”指向的还是原来的“房间”。
【记忆锚点】 “切片浅如水,只换外衣不换瓤;深拷贝打地基,里里外外全翻新。”
【易混对比】
| 操作 | 是否新建外层对象 | 内层对象是否共享 | 适用场景 |
|---|---|---|---|
| b = a | 否 | 是(同一对象) | 需要同一对象的多个别名 |
| b = a[:] | 是 | 是(共享引用) | 单层结构快速复制 |
| copy.deepcopy(a) | 是 | 否(完全独立) | 嵌套结构需要彻底隔离 |
换问法:若题目改为 b = copy.deepcopy(a) 后再执行 b[0][0] = 99,则 a[0][0] 输出结果为多少?(答案:1)
【自测】 以下代码的输出是什么?
import copy
a = [[1], [2]]
b = copy.copy(a)
c = copy.deepcopy(a)
b[0].append(3)
c[0].append(4)
print(a[0], c[0])答:[1, 3] [1, 4]。copy.copy 为浅拷贝,b[0] 与 a[0] 共享,append(3) 会同步影响 a;deepcopy 完全独立,c[0] 的操作不影响 a。与第 P68 题连考。
【知识关联】
- 同库关联:与 P68(深拷贝)、P69(赋值)、P22;切片/
copy.copy。 - 实现层:浅拷贝只复制最外层;嵌套对象仍共享。
- 面试追问:① 何时浅拷贝够用?② 与切片关系?
【拓展延伸】
- 变式问法:嵌套列表改内层影响两份。
- 版本差异:语义稳定。
- 工程注意点:嵌套结构要 deepcopy;注意 deepcopy 代价与循环引用。
P68 · 知识点:深拷贝
【题目】copy.deepcopy(x) 与 copy.copy(x) 的区别是?
A. 没有任何区别 B. deepcopy 递归复制所有嵌套的可变对象;copy 只复制最外层 C. copy 复制得更深 D. deepcopy 不能用于列表
答案:B
【考点】浅拷贝 vs 深拷贝的递归层级。
【结论】选 B。deepcopy 递归复制所有嵌套可变对象,而 copy 仅复制最外层容器。
【逐项辨析】 A. 没有任何区别 —— 错误。两者在嵌套结构上的行为差异显著。 B. deepcopy 递归复制所有嵌套的可变对象;copy 只复制最外层 —— 正确。这是二者的核心定义差异。 C. copy 复制得更深 —— 错误。copy 只进行浅拷贝,仅复制最外层。 D. deepcopy 不能用于列表 —— 错误。deepcopy 完全支持列表、字典、自定义对象等。
【知识点】 copy 模块的设计哲学与实现细节:
copy.copy(x)(浅拷贝):
- 对列表:新建列表,填入原列表元素的引用。
- 对字典:新建字典,键值对的值引用不变。
- 对自定义对象:调用对象的 copy 方法(若定义),否则默认复制最外层实例,但实例属性仍共享引用。
copy.deepcopy(x)(深拷贝):
- 递归遍历对象图,对每一个遇到的可变对象都创建新实例。
- 使用 memo 字典记录已拷贝的对象,避免循环引用导致无限递归。
- 若对象定义了 deepcopy 方法,则优先调用用户自定义逻辑。
- 不可变对象(如 int、str、tuple)通常不会被真正复制,因为不可变意味着“无法修改,共享也无风险”。
性能考量:
- 深拷贝的时间复杂度与对象图的总规模成正比,对于大型嵌套结构可能开销较大。
- 若只需外层隔离,优先使用浅拷贝;若需要完全独立,再使用深拷贝。
【记忆锚点】 “浅拷贝换瓶不换酒,深拷贝连酒也重酿。”
【易混对比】
| 特性 | copy.copy(浅拷贝) | copy.deepcopy(深拷贝) |
|---|---|---|
| 外层对象 | 新建 | 新建 |
| 内层可变对象 | 共享引用 | 递归新建 |
| 循环引用处理 | 不涉及 | memo 字典去重 |
| 自定义协议 | copy | deepcopy |
| 性能开销 | 低 | 高(与对象规模相关) |
换问法:若有一个字典 d = {'k': [1, 2]},执行 d2 = copy.copy(d) 后 d2['k'].append(3),此时 d['k'] 的内容是什么?(答案:[1, 2, 3])
【自测】 如何判断一个拷贝是深是浅?
import copy
a = [[0], [1]]
b = copy.copy(a)
c = copy.deepcopy(a)
print(b[0] is a[0], c[0] is a[0])答:True False。b[0] is a[0] 为 True 说明浅拷贝共享内层对象;c[0] is a[0] 为 False 说明深拷贝创建了独立的内层对象。与第 P69 题连考。
【知识关联】
- 同库关联:与 P67/P69;递归复制对象图。
- 实现层:copy.deepcopy;memo 防循环。
- 面试追问:① 深拷贝性能?② 不可变对象?
【拓展延伸】
- 变式问法:嵌套 dict/list 完全独立。
- 版本差异:语义稳定。
- 工程注意点:大数据深拷贝昂贵;优先不可变设计。
P69 · 知识点:赋值 vs 拷贝
【题目】b = a 与 b = a.copy() 的区别是?
A. 两者完全相同 B. 赋值使 b、a 指向同一对象;copy() 创建新对象(浅拷贝) C. copy() 是深拷贝 D. 赋值会深拷贝
答案:B
【考点】引用赋值不复制对象;.copy() 创建新对象。
【结论】选 B。赋值仅传递引用使两变量指向同一对象;copy() 创建新对象(浅拷贝)。
【逐项辨析】 A. 两者完全相同 —— 错误。赋值不产生新对象,copy() 产生新对象。 B. 赋值使 b、a 指向同一对象;copy() 创建新对象(浅拷贝) —— 正确。准确描述了引用赋值与浅拷贝的本质区别。 C. copy() 是深拷贝 —— 错误。列表的 .copy() 方法以及 copy.copy() 均为浅拷贝。 D. 赋值会深拷贝 —— 错误。赋值在任何情况下都不会自动触发拷贝行为。
【知识点】 Python 的变量本质与拷贝语义:
变量即标签:Python 中的变量名是指向堆中对象的引用(标签)。
b = a只是将标签 b 贴到 a 所指向的对象上,对象本身没有任何变化。此时id(a) == id(b),a is b为 True。.copy() 方法:列表、字典、集合等内置可变类型都提供了 .copy() 方法,它会创建同类型的新容器对象,并将原容器中的元素引用填入新容器。这是浅拷贝的典型实现。
区分赋值与拷贝的关键:
a is b:判断两个变量是否指向同一对象(身份比较)。a == b:判断两个对象的值是否相等(值比较)。- 赋值后
is为 True;拷贝后is为 False(但内层对象is可能仍为 True)。
何时需要拷贝:
- 当你希望修改新变量而不影响原变量时,必须执行拷贝操作。
- 对于嵌套结构,若内层也需要隔离,则需深拷贝。
【记忆锚点】 “等号是牵线,copy 是克隆;一个共命运,一个各前程。”
【易混对比】
| 操作 | 是否产生新对象 | 内层对象是否共享 | is 结果 | 修改是否互相影响 |
|---|---|---|---|---|
| b = a | 否 | 是(同一对象) | True | 是 |
| b = a.copy() | 是 | 是(共享引用) | False(外层) | 外层互不影响,内层共享影响 |
| b = copy.deepcopy(a) | 是 | 否 | False(全部) | 完全独立 |
换问法:执行 b = a 后,b.append(3),此时 a 的内容会变化吗?(答案:会变化,因为 a 与 b 指向同一列表对象)
【自测】 以下代码的输出是什么?
a = [1, 2, [3]]
b = a
c = a.copy()
a.append(4)
a[2].append(5)
print(b, c)答:[1, 2, [3, 5], 4] [1, 2, [3, 5]]。b 是赋值引用,a 的所有修改都同步;c 是浅拷贝,外层 append(4) 不影响 c,但内层列表 [3] 共享引用,append(5) 会同步影响 c[2]。与第 P70 题连考。
【知识关联】
- 同库关联:与 P22/P42/P67/P68 形成「名字 vs 对象」完整题群。
- 实现层:赋值只是名字绑定;is 可验证。
- 面试追问:① 如何证明两变量同对象?② 何时需要拷贝?
【拓展延伸】
- 变式问法:
a=b=[1]; a.append(2)b 变。 - 版本差异:语义稳定。
- 工程注意点:函数返回新对象更安全;共享可变状态易出并发 bug。
P70 · 知识点:字符串驻留
【题目】s1 = "hello"; s2 = "hello"; s1 is s2 的结果是?
A. True(CPython 对符合条件的字符串字面量进行驻留,相同内容的短字符串是同一对象) B. False C. 抛异常 D. None
答案:A
【考点】字符串驻留(intern)机制与 is 的不可靠性。
【结论】选 A。CPython 对符合条件的字符串字面量执行驻留优化,使相同内容的短字符串复用同一对象。
【逐项辨析】 A. True(CPython 对符合条件的字符串字面量进行驻留,相同内容的短字符串是同一对象) —— 正确。"hello" 为标识符规则的字符串字面量,会被驻留池复用。 B. False —— 错误。在 CPython 中,该场景下 s1 与 s2 指向同一驻留对象。 C. 抛异常 —— 错误。is 运算符对字符串完全合法,不会产生异常。 D. None —— 错误。s1 is s2 返回布尔值 True,而非 None。
【知识点】 字符串驻留(String Interning)机制详解:
驻留的本质:将相同且不可变的字符串对象在内存中仅存一份,所有引用该内容的变量指向同一内存地址。这可以节省内存并加速字典键的比较(is 比 == 更快)。
CPython 的驻留策略:
- 编译期驻留:源代码中符合标识符规则的字符串字面量(仅含字母、数字、下划线,且长度通常不超过 20 个字符,具体阈值随版本变化)会被自动驻留。
- 强制驻留:sys.intern() 可显式将任意字符串加入驻留池。
- 非驻留场景:运行期动态拼接、格式化、转换生成的字符串(如
str(123)、"a" + "b"在运行期执行时)通常不保证驻留。
is 与 == 的根本区别:
is比较对象身份(内存地址),受驻留、实现细节影响,结果不可靠。==比较对象值(调用 eq),是判断字符串内容是否相同的唯一可靠方式。- 编程规范:比较字符串内容时永远使用 ==,仅在判断 None 时使用 is。
不同实现的差异:驻留是 CPython 的优化策略,Jython、IronPython、PyPy 的实现策略不同,不应依赖驻留行为编写跨平台代码。
【记忆锚点】 “字面驻留池中聚,动态生成难相依;比较内容用等于,身份判断靠地址。”
【易混对比】
| 场景 | 是否驻留 | s1 is s2 结果 | 说明 |
|---|---|---|---|
| s1 = "abc"; s2 = "abc" | 是 | True | 标识符规则的字面量 |
| s1 = "a b"; s2 = "a b" | 否(含空格,不符合标识符形式,不入 intern 池) | 同一编译单元内实测 True:两句共用同一个常量对象(常量合并,不是驻留);REPL 逐行执行或字面量分处不同模块时实测 False | 别把这里的 True 归因为 intern;判内容一律用 == |
| s1 = "".join(["a","b"]); s2 = "ab" | 否 | False | 运行期动态生成 |
| s1 = sys.intern("xyz"); s2 = "xyz" | 是 | True | 显式强制驻留 |
换问法:若代码改为 s1 = "hello world"; s2 = "hello world"; print(s1 is s2),结果一定为 True 吗?(答案:取决于是不是同一个编译单元。同一段脚本/同一个模块内——哪怕分处不同函数——实测 True,因为两句字面量被合并成同一个常量对象;在 REPL 里逐行输入、或两个字面量分处不同模块时实测 False。根因是编译期常量合并而不是字符串驻留:"hello world" 含空格,本来就不符合自动 intern 的标识符形式。结论不变——比较内容永远用 ==。)
【自测】 以下代码的输出是什么?
a = "python"
b = "py" + "thon"
c = "".join(["py", "thon"])
print(a is b, a is c)答:True False。"py" + "thon" 在编译期可优化为常量折叠,结果与 "python" 共享驻留对象;join 在运行期执行,生成新字符串对象,不与驻留池共享。与第 P71 题连考。
【知识关联】
- 同库关联:与 P05(小整数缓存)、P33;
sys.intern。 - 实现层:编译期字面量驻留;标识符 intern;动态串不一定。
- 面试追问:① 何时 is 对字符串为 True?② intern 用途?
【拓展延伸】
- 变式问法:运行时拼接串 is 可能 False。
- 版本差异:实现细节(哪些串驻留)随版本/实现变化。
- 工程注意点:比较值用 == 不用 is;关键字匹配可考虑 intern。
P71 · 知识点:+= 与列表
【题目】关于情形一和情形二的区别,正确的是?
a = [1]
b = a
a += [2] # 情形一
# a = a + [2] # 情形二A. 两者完全相同 B. 情形一 a += [2] 原地扩展(b 也变为 [1,2]);情形二 a = a + [2] 创建新列表(b 仍为 [1]) C. 情形一会抛异常 D. 两者都创建新列表
答案:B
【考点】__iadd__(就地加)与 __add__(返回新对象)的区别。
【结论】选 B。情形一 += 调用 iadd 原地扩展列表,b 同步变化;情形二 + 调用 add 返回新列表,b 保持不变。
【推导过程】
a = [1]
b = a
# 情形一:a += [2]
# 等价调用 a.__iadd__([2])
# 列表原地扩展,id(a) 不变
# b 仍指向同一对象,故 b 变为 [1, 2]
# 情形二:a = a + [2]
# 等价调用 a = a.__add__([2])
# __add__ 返回新列表 [1, 2]
# a 重新绑定到新对象,id(a) 改变
# b 仍指向旧对象 [1],故 b 不变【逐项辨析】 A. 两者完全相同 —— 错误。两种操作在底层调用不同魔法方法,行为截然不同。 B. 情形一 a += [2] 原地扩展(b 也变为 [1,2]);情形二 a = a + [2] 创建新列表(b 仍为 [1]) —— 正确。准确描述了 iadd 与 add 的差异。 C. 情形一会抛异常 —— 错误。列表的 += 操作完全合法。 D. 两者都创建新列表 —— 错误。情形一不创建新列表,仅原地修改。
【知识点】 就地运算符(Augmented Assignment)的语义差异:
a += b的解析规则:- Python 首先尝试调用
a.__iadd__(b)。 - 若对象实现了 iadd 并返回结果,则使用返回结果(对列表而言,iadd 就是 extend,返回 self)。
- 若未实现 iadd 或返回 NotImplemented,则退化为
a = a + b。
- Python 首先尝试调用
列表的 iadd 与 add:
__iadd__:原地将可迭代对象的元素追加到列表末尾,时间复杂度 O(k),不创建新列表对象。__add__:创建新列表,将左右操作数的元素合并后返回,时间复杂度 O(n+k),原列表不变。
不可变对象的 +=:
- 对于 int、str、tuple 等不可变类型,不存在 iadd 方法,因此
a += b完全等价于a = a + b,必然创建新对象。
- 对于 int、str、tuple 等不可变类型,不存在 iadd 方法,因此
赋值与修改的复合效应:
- 情形一中 a 的 id 不变,只是对象内容变化,因此所有引用该对象的变量都能观察到变化。
- 情形二中 a 被重新绑定到新对象,旧对象未被修改,因此其他引用旧对象的变量不受影响。
【记忆锚点】 “等号加号分左右,iadd 原地 add 新;可变就地不变新,记牢 id 辨分明。”
【易混对比】
| 表达式 | 调用方法 | 是否原地修改 | 是否新建对象 | id 是否变化 | 影响其他引用 |
|---|---|---|---|---|---|
| a += [2](列表) | iadd | 是 | 否 | 否 | 是 |
| a = a + [2](列表) | add | 否 | 是 | 是 | 否 |
| s += "a"(字符串) | 退化为 add | 否 | 是 | 是 | 否 |
| t += (1,)(元组) | 退化为 add | 否 | 是 | 是 | 否 |
换问法:若将列表改为元组 a = (1,),再执行 a += (2,),此时 b 是否会同步变化?(答案:不会,元组是不可变对象,+= 退化为新建对象重新绑定,b 仍指向原元组 (1,))
【自测】 以下代码的输出是什么?
a = [1, 2]
b = a
a = a + [3]
print(b)
a += [4]
print(b)答:[1, 2] [1, 2]。
a = a + [3]创建新列表并使 a 重新绑定,b 仍指向旧列表;a += [4]对 a 当前指向的新列表原地扩展,但 b 指向的仍是最初的旧列表 [1, 2],因此两次输出均为 [1, 2]。与第 P72 题连考。
【知识关联】
- 同库关联:与 P14;
L+=[x]与L=L+[x];L*=2。 - 实现层:
+=对 list 调__iadd__就地;对 tuple 生成新对象。 - 面试追问:① 为何
L+=[x]与L=L+[x]对别名不同?② 元组呢?
【拓展延伸】
- 变式问法:函数内
L += [1]对外部是否可见。 - 版本差异:语义稳定。
- 工程注意点:明确是否就地;需要新列表写
L = L + [x]或L.copy()后改。
P72 · 知识点:遍历中删除元素
【题目】需要删除列表中满足条件的元素,正确的做法是?
A. 直接在原列表上边遍历边删除 B. 遍历原列表的副本,在原列表上删除 C. 用下标 for 循环从前往后逐个判断删除 D. 列表无法删除元素
答案:B
【考点】边遍历边删除导致索引偏移、漏删元素。
【结论】选 B。遍历原列表的副本并在原列表上删除,可避免索引偏移导致的漏删问题。
【逐项辨析】 A. 直接在原列表上边遍历边删除 —— 错误。这会导致索引偏移,出现漏删或跳过元素的现象。 B. 遍历原列表的副本,在原列表上删除 —— 正确。遍历副本保证迭代器不受原列表结构变化影响,可安全删除。 C. 用下标 for 循环从前往后逐个判断删除 —— 错误。从前往后删除同样会导致后续元素前移、下标错位,仍然漏删。 D. 列表无法删除元素 —— 错误。列表的 remove、pop、del 等方法均可删除元素。
【知识点】 迭代中修改可变序列的安全模式:
索引偏移问题:
- 列表使用连续内存存储引用数组,删除位置 i 的元素后,i+1 及之后的所有元素前移一位。
- 若使用 for i in range(len(L)) 正向遍历并删除,删除后指针已移动到 i+1,但原 i+1 位置的元素已前移到 i,因此被跳过。
解决方案对比:
- 方案一(推荐):遍历副本
for x in L[:]: if cond: L.remove(x)。L[:] 创建浅拷贝作为迭代源,原列表的修改不影响迭代器。 - 方案二(推荐):列表推导式重建
L = [x for x in L if not cond]。语义清晰、执行效率高(C 层循环)。 - 方案三(可行):反向索引删除
for i in range(len(L)-1, -1, -1): if cond: del L[i]。从后往前删除时,已遍历的区域不会因前移而错位。 - 方案四(可行):倒序遍历
for x in reversed(L[:]): ...。
- 方案一(推荐):遍历副本
集合与字典的特殊性:
- Python 3 中字典在迭代时修改(增加/删除键)会触发 RuntimeError。
- 集合同样不支持迭代中直接修改,需先转为列表或记录待删除元素。
【记忆锚点】 “正着删来跳着走,倒着删来稳当当;副本遍历最省心,推导重建最清爽。”
【易混对比】
| 删除策略 | 代码示例 | 是否安全 | 效率 | 适用场景 |
|---|---|---|---|---|
| 正向遍历删原表 | for i in range(len(L)): del L[i] | 否 | 低 | 不可使用 |
| 反向遍历删原表 | for i in range(len(L)-1,-1,-1): del L[i] | 是 | 中 | 需原地修改且保留部分元素 |
| 遍历副本删原表 | for x in L[:]: L.remove(x) | 是 | 中 | 按值删除 |
| 列表推导式重建 | L = [x for x in L if cond] | 是 | 高 | 按条件筛选保留 |
换问法:若使用 for i, x in enumerate(L): if x % 2 == 0: del L[i] 删除列表 [2, 4, 5] 中的偶数,删除后的结果是什么?(答案:[4, 5],因为删除索引 0 的 2 后,4 前移到索引 0,但指针已前进到索引 1,导致 4 被跳过漏删)
【自测】 以下代码的输出是什么?
L = [1, 2, 3, 4, 5]
for x in L[:]:
if x % 2 == 0:
L.remove(x)
print(L)答:[1, 3, 5]。遍历 L 的副本,迭代顺序为 1, 2, 3, 4, 5;每遇到偶数就在原列表 L 中删除该值。L.remove(2) 后 L=[1,3,4,5],L.remove(4) 后 L=[1,3,5]。副本遍历不受原列表结构变化影响,因此不会漏删。与第 P67 题连考。
【知识关联】
- 同库关联:与 J39(fail-fast)对照;遍历中删除。
- 实现层:下标遍历时删元素会跳项;应反向遍历或新建列表。
- 面试追问:① 正确删除写法?② 用推导式过滤?
【拓展延伸】
- 变式问法:for x in L: L.remove(x) 陷阱。
- 版本差异:语义稳定。
- 工程注意点:
L[:] = [x for x in L if ...]或L = [...]。