订阅更新

浅拷贝和深拷贝踩了多少坑——Python的可变性和拷贝机制

Python手册 第 9 题 约 6 分钟读完

本文目录
  1. 先说结论
  2. 可变类型 vs 不可变类型
  3. 赋值、浅拷贝、深拷贝的区别
  4. 常见踩坑场景
  5. 实战踩坑:默认参数dict导致UDF结果污染
  6. 面试高频追问
  7. 总结

先说结论

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年大数据老兵,只讲大数据面试干货

刷题进度保存在当前浏览器