Python的默认参数把我坑惨了,原来可变对象和不可变对象的区别这么大

Python的默认参数把我坑惨了,原来可变对象和不可变对象的区别这么大 一个让我排查了三个小时的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(arg1): arg arg 1 print(id(arg)) # 每次打印的id都不同 test_default() # 某个id test_default() # 不同的id test_default() # 又是不同的id每次对不可变对象进行“修改”实际上是创建了新对象原来的默认参数对象纹丝不动。第三步为什么会有这种设计你可能会问为啥Python要这么设计这不是坑人吗其实这个设计背后的逻辑是Python的函数是对象默认参数是函数对象的属性。def foo(x, y10): 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, historyNone): 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, configNone): if config is None: config {} # ... # 集合 def track_tags(tags, tag_setNone): if tag_set is None: tag_set set() # ... # 自定义对象 def create_user(name, profileNone): 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(urlbuild_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也能看到**用historyNone**像是每个用户发了一本全新的空白日记本各写各的现在我写函数但凡默认参数是可变的列表、字典、集合一定用None做哨兵在函数内部创建。这个习惯救了我无数次希望也能救你。有一个简单的记忆方法默认参数用不可变可变对象内部造。每次写完函数看一眼默认参数如果是[]或{}立马改成None。想清楚再动手少排查几小时Bug。