一个让我排查了三个小时的Bug
去年写了一个用户行为追踪模块,需要对每个访问用户记录一系列操作日志:
def log_user_action(action, history=[]):
history.append(action)
print(f"当前操作: {action}")
print(f"操作历史: {history}")
return history
看起来平平无奇对吧?一个函数记录用户操作,默认参数history=[]让每次调用都从空列表开始。
测试的时候单独跑,一切正常:
log_user_action("登录")
# 当前操作: 登录
# 操作历史: ['登录']
上线之后,奇怪的事情发生了。运维同事跑过来跟我说:“日志系统出问题了,用户A的日志里全是B用户的操作。”
我赶紧去查代码,模拟了两个用户交替调用:
# 用户A
log_user_action("A登录")
log_user_action("A浏览商品")
# 用户B
log_user_action("B登录")
猜猜输出是什么?
当前操作: A登录
操作历史: ['A登录']
当前操作: A浏览商品
操作历史: ['A登录', 'A浏览商品']
当前操作: B登录
操作历史: ['A登录', 'A浏览商品', 'B登录'] # B的列表里有A的数据!
用户B的操作历史里,赫然出现了用户A的操作记录。两个用户的数据串在一起,完全乱套了。
我盯着代码看了半天,history=[]明明每次都传了个空列表,怎么会累积呢?
那天下午,我把Python的可变和不可变对象、默认参数的求值时机彻底研究了一遍。今天把这些坑讲清楚,希望你别重蹈我的覆辙。
第一步:先搞清楚可变和不可变对象
Python里的对象分两种:
不可变对象: 一旦创建,值就不能改。数字、字符串、元组、布尔值、None都属于这一类。
a = 1
b = a
a = 2 # 这不是改1,是让a指向2
print(b) # 还是1 —— b指向的对象没变
当你以为你在“改”一个不可变对象时,实际上是创建了一个新对象,然后让变量指向新对象。
s = "hello"
print(id(s)) # 假设是 140234567890
s = s + " world"
print(id(s)) # 新的id —— 创建了新字符串
可变对象: 创建之后可以原地修改内容。列表、字典、集合、自定义类的实例都属于这一类。
lst = [1, 2, 3]
print(id(lst)) # 假设是 140234567890
lst.append(4)
print(id(lst)) # 还是 140234567890 —— 对象没变,内容变了
可变对象就像一块白板,你可以在上面擦擦写写,白板还是那块白板。不可变对象就像刻在石头上的字,想改只能换块石头。
第二步:Python默认参数的一个冷门特性
搞清楚可变和不可变之后,现在说关键问题:
Python函数的默认参数,在函数定义时就被求值了,而不是在函数调用时。
什么意思?
def add(x, y=[]):
y.append(x)
return y
当Python解释器读到def add(x, y=[]):这一行的时候,它就执行了[],创建了一个空列表对象,然后把这个对象“焊死”在函数对象上。之后的每一次调用,用的都是这同一个列表对象。
每次调用add(1),都是往那个同一个列表里追加元素。这就是为什么用户B的日志里会出现用户A的数据——他们用的是同一个history列表。
验证一下:
def test_default(arg=[]):
arg.append(1)
print(id(arg)) # 每次打印的id都一样
test_default() # 打印某个id
test_default() # 打印同样的id
test_default() # 还是同样的id
三调用的arg指向的是同一个列表对象。但如果是不可变对象作为默认参数:
def test_default(arg=1):
arg = arg + 1
print(id(arg)) # 每次打印的id都不同
test_default() # 某个id
test_default() # 不同的id
test_default() # 又是不同的id
每次对不可变对象进行“修改”,实际上是创建了新对象,原来的默认参数对象纹丝不动。
第三步:为什么会有这种设计?
你可能会问:为啥Python要这么设计?这不是坑人吗?
其实这个设计背后的逻辑是:Python的函数是对象,默认参数是函数对象的属性。
def foo(x, y=10):
pass
print(foo.__defaults__) # (10,)
默认参数在函数定义时被求值,作为元组保存在__defaults__属性里。这样每次调用函数的时候,直接从这个属性里读默认值,不需要重新求值——提高了执行效率。
这个设计对不可变对象来说完全没问题。10就是10,谁来了它都不变。
但问题是Python不限制默认参数的类型。如果你用了可变对象(列表、字典),这个对象也是定义时创建一次,然后被所有调用共享。这就出事了。
这算不算设计缺陷?Python之父Guido van Rossum在2012年的邮件里承认:“这个问题让每个人都踩过坑。如果时光倒流,我会让默认参数在函数调用时求值。”但作为一门已经跑了几十年的语言,这种兼容性问题改不了,只能靠开发者自己注意。
第四步:正确的写法
既然知道了问题,怎么改?
用None做哨兵值,在函数内部创建新对象:
# 错误写法
def log_user_action(action, history=[]):
history.append(action)
return history
# 正确写法
def log_user_action(action, history=None):
if history is None:
history = []
history.append(action)
return history
现在每次调用,如果没传history,都会创建一个全新的空列表。用户之间不再互相污染:
log_user_action("A登录") # ['A登录']
log_user_action("A浏览") # ['A登录', 'A浏览']
log_user_action("B登录") # ['B登录'] —— 干净的新列表
None是不可变对象,作为默认参数绝对安全。用None做哨兵,在函数内部判断并创建可变对象,这是Python社区公认的标准写法。
同样的套路适用于所有可变对象:
# 字典
def process_data(data, config=None):
if config is None:
config = {}
# ...
# 集合
def track_tags(tags, tag_set=None):
if tag_set is None:
tag_set = set()
# ...
# 自定义对象
def create_user(name, profile=None):
if profile is None:
profile = UserProfile()
# ...
第五步:还有一些更隐蔽的坑
坑一:类属性里的可变对象
class User:
permissions = [] # 类属性,所有实例共享
alice = User()
bob = User()
alice.permissions.append("admin")
print(bob.permissions) # ['admin'] —— Bob也被加了权限!
正确写法是在__init__里创建实例属性:
class User:
def __init__(self):
self.permissions = [] # 每个实例自己的一份
坑二:函数默认参数依赖另一个可变默认参数
# 极其危险的写法
def build_url(base="", params={}):
# ...
def make_request(url=build_url(""), headers={}):
# ...
默认参数之间如果有依赖关系,一个变了另一个也跟着变,调试的时候能把人逼疯。
坑三:缓存陷阱
有人想用默认参数做缓存:
def expensive_calc(key, cache={}):
if key in cache:
return cache[key]
result = really_slow_calculation(key)
cache[key] = result
return result
这代码看起来聪明,实际上有问题——cache是全局共享的,不会被清理,内存会一直涨。而且测试之间会互相污染。正确的做法是用functools.lru_cache。
第六步:一个常见的面试题
这个坑太经典了,面试官特别喜欢考:
def func(a, b=[]):
b.append(a)
return b
print(func(1))
print(func(2))
print(func(3, [])) # 注意这里传了参数
print(func(4))
输出是什么?
func(1):[1]func(2):[1, 2]—— 同一个列表func(3, []):[3]—— 传了新列表,不受默认参数影响func(4):[1, 2, 4]—— 又回到了那个累积的列表
这道题考的就是你对默认参数求值时机和可变对象共享的理解。能答上来,说明你已经避开了这个坑。
总结
一句话记住:默认参数在定义时只创建一次,可变对象会被所有调用共享;不可变对象安全,可变对象危险。
用我那个日志系统的例子来总结:
- **用
history=[]**:像是给所有用户发了一本共同的日记本,A写的内容B也能看到 - **用
history=None**:像是每个用户发了一本全新的空白日记本,各写各的
现在我写函数但凡默认参数是可变的(列表、字典、集合),一定用None做哨兵,在函数内部创建。这个习惯救了我无数次,希望也能救你。
有一个简单的记忆方法:默认参数用不可变,可变对象内部造。每次写完函数看一眼默认参数,如果是[]或{},立马改成None。想清楚再动手,少排查几小时Bug。