浅拷贝和深拷贝踩了多少坑——Python的可变性和拷贝机制
先说结论
Python的变量是引用(标签),不是盒子。a = [1,2,3]不是'把列表放进a',而是'给列表贴了一个叫a的标签'。b = a不是拷贝,而是给同一个列表又贴了一个叫b的标签。修改b也会影响a(因为是同一个对象)。要独立拷贝用copy()(浅拷贝)或copy.deepcopy()(深拷贝)。
先把这段讲顺;面试官追问时,再展开下面的原理和细节。
详细讲解
可变类型 vs 不可变类型
| 类型 | 可变/不可变 | 修改行为 | 示例 |
|---|---|---|---|
| int/float/bool | 不可变 | 创建新对象 | a=1; a+=1 创建了新int对象 |
| str/bytes | 不可变 | 创建新对象 | s='hi'; s+='!' 创建了新str |
| tuple | 不可变 | 不能修改元素 | t=(1,2); t[0]=3 报错 |
| list | 可变 | 原地修改 | lst=[1,2]; lst.append(3) 原列表变了 |
| dict | 可变 | 原地修改 | d={'a':1}; d['b']=2 原字典变了 |
| set | 可变 | 原地修改 | s={1,2}; s.add(3) 原集合变了 |
赋值、浅拷贝、深拷贝的区别
import copy
original = [[1, 2], [3, 4], [5, 6]]
# 赋值(只是多一个引用,同一个对象)
assigned = original
assigned[0][0] = 99
print(original[0][0]) # 99!original也被改了
# 浅拷贝(创建新列表,但元素还是引用同一个对象)
shallow = copy.copy(original) # 或 original.copy() 或 list(original)
shallow[0][0] = 88
print(original[0][0]) # 88!内层列表还是共享的
shallow.append([7, 8])
print(len(original)) # 3,外层列表是独立的
# 深拷贝(递归拷贝所有层级,完全独立)
deep = copy.deepcopy(original)
deep[0][0] = 77
print(original[0][0]) # 88,original不受影响
常见踩坑场景
踩坑一:可变默认参数
# 大坑!默认参数只初始化一次
def process(data, cache={}):
cache[data['id']] = data['value']
return cache
result1 = process({'id': 1, 'value': 'a'})
result2 = process({'id': 2, 'value': 'b'})
print(result1) # {1: 'a', 2: 'b'} 被第二次调用污染了!
# 正确写法
def process(data, cache=None):
if cache is None:
cache = {} # 每次调用创建新dict
cache[data['id']] = data['value']
return cache
踩坑二:列表乘法创建的陷阱
# 想创建3个独立的列表
matrix = [[0] * 3] * 3 # 看起来是3x3矩阵
matrix[0][0] = 1
print(matrix) # [[1,0,0], [1,0,0], [1,0,0]] 三行都变了!
# 因为 * 3 创建的是3个对同一个列表的引用
# 正确写法
matrix = [[0] * 3 for _ in range(3)] # 每次创建新列表
matrix[0][0] = 1
print(matrix) # [[1,0,0], [0,0,0], [0,0,0]] 正确!
踩坑三:函数参数传递——'传对象引用'
# Python不是传值也不是传引用,而是'传对象引用'
def modify(lst, num):
lst.append(4) # 修改了原列表!(可变对象,原地修改)
num += 1 # 没有修改原变量!(不可变对象,创建新对象)
my_list = [1, 2, 3]
my_num = 10
modify(my_list, my_num)
print(my_list) # [1, 2, 3, 4] 被修改了!
print(my_num) # 10 没变
实战踩坑:默认参数dict导致UDF结果污染
在某元宇宙社交公司,Flink Python UDF里有一个函数用默认参数dict做配置缓存。UDF被多次调用后发现结果不对——前一次调用写入cache的数据影响了后一次调用。
原因:Python函数的默认参数在函数定义时只创建一次。所有调用共享同一个dict对象。第一次调用往cache里写了数据,第二次调用时cache里已经有了第一次的数据。
修复:把cache={}改成cache=None,函数体内if cache is None: cache = {}。每次调用创建新的dict。
教训:永远不要用可变对象(list/dict/set)作为函数的默认参数!用None替代,在函数体内创建。这是Python最经典的坑之一,但很多有经验的开发者还是会踩。
面试高频追问
追问一:==和is有什么区别?
==比较值是否相等(调用__eq__方法),is比较是否是同一个对象(内存地址相同)。a = [1,2]; b = [1,2]; a==b是True(值相等),a is b是False(不同对象)。只有判断None时用is:if x is None。
追问二:tuple里面的list可以修改吗?
可以!tuple不可变指的是tuple的元素引用不能改(不能重新指向另一个对象),但如果元素本身是可变的(比如list),可以修改元素的内容。t = ([1,2], [3,4]); t[0].append(3)是合法的——t变成了([1,2,3], [3,4])。但t[0] = [5,6]会报错。
追问三:Pandas DataFrame的赋值和拷贝怎么回事?
df2 = df是引用(修改df2会影响df)。df2 = df.copy()是浅拷贝(独立DataFrame但共享底层NumPy数组——对于大部分操作已经够用了,因为Pandas修改列时会创建新数组)。df2 = df.copy(deep=True)是深拷贝(完全独立但内存翻倍)。实际开发中df.copy()就够了。
总结
可变性和拷贝面试讲清楚三层:① 变量是引用不是盒子(赋值=贴标签,不是拷贝);② 三种拷贝的区别(赋值=同一个对象、浅拷贝=新容器旧元素、深拷贝=完全独立);③ 常见坑(可变默认参数、列表乘法、函数参数传对象引用)。能讲出默认参数dict导致UDF结果污染的踩坑经历,面试官会觉得你真正理解了Python的对象模型。
下一篇聊Python的上下文管理器:with语句为什么能自动关闭文件?怎么写自己的上下文管理器?
你们踩过Python可变性的坑吗?评论区聊聊~
关注「Wood聊大数据」| 9年大数据老兵,只讲大数据面试干货