Skip to content

八、拷贝与可变性陷阱(P67–P72) ​

P67 · 知识点:浅拷贝 ​

【题目】输出结果是?

python
a = [[1, 2], [3, 4]]
b = a[:]
b[0][0] = 99
print(a[0][0])

A. 99 B. 1 C. 抛异常 D. None

答案:A

【考点】切片是浅拷贝:外层新列表,内层元素共享引用。

【结论】选 A。切片实现浅拷贝,外层列表独立但内层对象共享引用,因此修改内层元素会同时影响原列表与拷贝列表。

【推导过程】

python
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 的赋值机制与拷贝层级:

  1. 赋值(b = a):仅将变量名绑定到同一对象,不产生新对象,b is a 为 True。
  2. 浅拷贝(b = a[:] / b = a.copy() / list(a) / copy.copy(a)):创建最外层新容器对象,但容器内的元素引用保持不变。对于单层列表效果等同于独立副本;对于嵌套结构,内层对象仍共享。
  3. 深拷贝(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)

【自测】 以下代码的输出是什么?

python
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 模块的设计哲学与实现细节:

  1. copy.copy(x)(浅拷贝):

    • 对列表:新建列表,填入原列表元素的引用。
    • 对字典:新建字典,键值对的值引用不变。
    • 对自定义对象:调用对象的 copy 方法(若定义),否则默认复制最外层实例,但实例属性仍共享引用。
  2. copy.deepcopy(x)(深拷贝):

    • 递归遍历对象图,对每一个遇到的可变对象都创建新实例。
    • 使用 memo 字典记录已拷贝的对象,避免循环引用导致无限递归。
    • 若对象定义了 deepcopy 方法,则优先调用用户自定义逻辑。
    • 不可变对象(如 int、str、tuple)通常不会被真正复制,因为不可变意味着“无法修改,共享也无风险”。
  3. 性能考量:

    • 深拷贝的时间复杂度与对象图的总规模成正比,对于大型嵌套结构可能开销较大。
    • 若只需外层隔离,优先使用浅拷贝;若需要完全独立,再使用深拷贝。

【记忆锚点】 “浅拷贝换瓶不换酒,深拷贝连酒也重酿。”

【易混对比】

特性copy.copy(浅拷贝)copy.deepcopy(深拷贝)
外层对象新建新建
内层可变对象共享引用递归新建
循环引用处理不涉及memo 字典去重
自定义协议copydeepcopy
性能开销低高(与对象规模相关)

换问法:若有一个字典 d = {'k': [1, 2]},执行 d2 = copy.copy(d) 后 d2['k'].append(3),此时 d['k'] 的内容是什么?(答案:[1, 2, 3])

【自测】 如何判断一个拷贝是深是浅?

python
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 的变量本质与拷贝语义:

  1. 变量即标签:Python 中的变量名是指向堆中对象的引用(标签)。b = a 只是将标签 b 贴到 a 所指向的对象上,对象本身没有任何变化。此时 id(a) == id(b),a is b 为 True。

  2. .copy() 方法:列表、字典、集合等内置可变类型都提供了 .copy() 方法,它会创建同类型的新容器对象,并将原容器中的元素引用填入新容器。这是浅拷贝的典型实现。

  3. 区分赋值与拷贝的关键:

    • a is b:判断两个变量是否指向同一对象(身份比较)。
    • a == b:判断两个对象的值是否相等(值比较)。
    • 赋值后 is 为 True;拷贝后 is 为 False(但内层对象 is 可能仍为 True)。
  4. 何时需要拷贝:

    • 当你希望修改新变量而不影响原变量时,必须执行拷贝操作。
    • 对于嵌套结构,若内层也需要隔离,则需深拷贝。

【记忆锚点】 “等号是牵线,copy 是克隆;一个共命运,一个各前程。”

【易混对比】

操作是否产生新对象内层对象是否共享is 结果修改是否互相影响
b = a否是(同一对象)True是
b = a.copy()是是(共享引用)False(外层)外层互不影响,内层共享影响
b = copy.deepcopy(a)是否False(全部)完全独立

换问法:执行 b = a 后,b.append(3),此时 a 的内容会变化吗?(答案:会变化,因为 a 与 b 指向同一列表对象)

【自测】 以下代码的输出是什么?

python
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)机制详解:

  1. 驻留的本质:将相同且不可变的字符串对象在内存中仅存一份,所有引用该内容的变量指向同一内存地址。这可以节省内存并加速字典键的比较(is 比 == 更快)。

  2. CPython 的驻留策略:

    • 编译期驻留:源代码中符合标识符规则的字符串字面量(仅含字母、数字、下划线,且长度通常不超过 20 个字符,具体阈值随版本变化)会被自动驻留。
    • 强制驻留:sys.intern() 可显式将任意字符串加入驻留池。
    • 非驻留场景:运行期动态拼接、格式化、转换生成的字符串(如 str(123)、"a" + "b" 在运行期执行时)通常不保证驻留。
  3. is 与 == 的根本区别:

    • is 比较对象身份(内存地址),受驻留、实现细节影响,结果不可靠。
    • == 比较对象值(调用 eq),是判断字符串内容是否相同的唯一可靠方式。
    • 编程规范:比较字符串内容时永远使用 ==,仅在判断 None 时使用 is。
  4. 不同实现的差异:驻留是 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 的标识符形式。结论不变——比较内容永远用 ==。)

【自测】 以下代码的输出是什么?

python
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 · 知识点:+= 与列表 ​

【题目】关于情形一和情形二的区别,正确的是?

python
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 保持不变。

【推导过程】

python
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)的语义差异:

  1. a += b 的解析规则:

    • Python 首先尝试调用 a.__iadd__(b)。
    • 若对象实现了 iadd 并返回结果,则使用返回结果(对列表而言,iadd 就是 extend,返回 self)。
    • 若未实现 iadd 或返回 NotImplemented,则退化为 a = a + b。
  2. 列表的 iadd 与 add:

    • __iadd__:原地将可迭代对象的元素追加到列表末尾,时间复杂度 O(k),不创建新列表对象。
    • __add__:创建新列表,将左右操作数的元素合并后返回,时间复杂度 O(n+k),原列表不变。
  3. 不可变对象的 +=:

    • 对于 int、str、tuple 等不可变类型,不存在 iadd 方法,因此 a += b 完全等价于 a = a + b,必然创建新对象。
  4. 赋值与修改的复合效应:

    • 情形一中 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,))

【自测】 以下代码的输出是什么?

python
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 等方法均可删除元素。

【知识点】 迭代中修改可变序列的安全模式:

  1. 索引偏移问题:

    • 列表使用连续内存存储引用数组,删除位置 i 的元素后,i+1 及之后的所有元素前移一位。
    • 若使用 for i in range(len(L)) 正向遍历并删除,删除后指针已移动到 i+1,但原 i+1 位置的元素已前移到 i,因此被跳过。
  2. 解决方案对比:

    • 方案一(推荐):遍历副本 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[:]): ...。
  3. 集合与字典的特殊性:

    • 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 被跳过漏删)

【自测】 以下代码的输出是什么?

python
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 = [...]。

持续学习,持续积累。