ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python的默认参数把我坑惨了,原来写[]和写None的区别这么大

Python的默认参数把我坑惨了,原来写[]和写None的区别这么大 小张花了整整一个下午就为了找一个bug。他写了一个函数用来给用户添加标签。逻辑很简单传入用户ID和标签把标签追加到用户的标签列表里。代码大概长这样def add_tag(tag, tags[]): tags.append(tag) return tags print(add_tag(Python)) print(add_tag(Java)) print(add_tag(Go))他预期的输出是[Python] [Java] [Go]实际输出是[Python] [Python, Java] [Python, Java, Go]小张盯着屏幕怀疑自己是不是没睡醒。他明明每次调用都没传第二个参数为什么列表里的内容会累积难道Python的默认参数不是每次调用都重新创建一个空列表吗答案是不是。这个坑几乎每个Python开发者都踩过。它藏在Python最基础的语法里却能让老手也翻车。先把问题复现清楚为了看清这个问题的本质我们把代码稍微改一下def add_tag(tag, tags[]): print(f调用前 tags 的 id: {id(tags)}) tags.append(tag) print(f调用后 tags 的内容: {tags}) return tags print(--- 第一次调用 ---) add_tag(Python) print(--- 第二次调用 ---) add_tag(Java) print(--- 第三次调用 ---) add_tag(Go)输出--- 第一次调用 --- 调用前 tags 的 id: 140234567890 调用后 tags 的内容: [Python] --- 第二次调用 --- 调用前 tags 的 id: 140234567890 调用后 tags 的内容: [Python, Java] --- 第三次调用 --- 调用前 tags 的 id: 140234567890 调用后 tags 的内容: [Python, Java, Go]注意那个id三次调用完全一样。也就是说tags这个默认参数从头到尾都是同一个列表对象。第一次调用往里面加了Python第二次调用时它还在所以又加了Java第三次继续累积。问题的根源在于Python的默认参数在函数定义时就被求值了而且只求值一次。默认参数到底在什么时候被创建很多人下意识地认为默认参数是每次调用函数时“临时”创建的。比如调用add_tag(Python)时Python发现第二个参数没传于是创建一个空列表[]把它赋给tags。下次调用时再创建一个新的。但真实情况不是这样。Python在执行def语句时会把这个函数的默认参数值计算出来然后保存在函数对象的一个属性里。这个属性叫__defaults__。之后每次调用函数如果某个参数没有传值Python就直接从__defaults__里取出那个对象来用。我们用代码验证一下def add_tag(tag, tags[]): tags.append(tag) return tags print(add_tag.__defaults__)输出([],)第一次调用之前__defaults__里存的是一个空列表。调用add_tag(Python)之后这个列表被修改了。再看一次add_tag(Python) print(add_tag.__defaults__)输出([Python],)__defaults__里的列表已经被改变了。第二次调用时Python拿到的就是这个已经被修改过的列表而不是一个新的空列表。整个过程可以这样理解def语句执行的那一刻Python看到了tags[]于是创建了一个列表对象把它存进函数的默认参数表里。之后这个列表对象就一直跟着这个函数直到函数被销毁。每次调用不传参数用的都是同一个列表。这就好比你开了一家店门口放了一个意见箱。你本来想的是“每个顾客来了都放一个新箱子”但实际上你只在开店那天放了一个箱子之后就再也没换过。所有顾客的意见都塞进了同一个箱子里。为什么写None就没问题知道了原因解决方案就很直接了不要用可变对象作为默认参数。def add_tag(tag, tagsNone): if tags is None: tags [] tags.append(tag) return tags print(add_tag(Python)) print(add_tag(Java)) print(add_tag(Go))输出[Python] [Java] [Go]这次对了。为什么因为None是一个不可变对象。函数定义时__defaults__里存的是None。每次调用时如果没传参数Python取出None赋给tags。然后函数内部检查tags is None如果是就创建一个新的空列表。关键的区别在于新建列表这个动作发生在函数调用时而不是函数定义时。每次调用都会执行tags []所以每次都是一个全新的列表。这里有一个常见的写法误区def add_tag(tag, tagsNone): tags tags or [] tags.append(tag) return tags看起来没问题但有个隐患如果调用者传了一个空列表[]进来tags or []会返回一个新的空列表而不是使用调用者传进来的那个。调用者的列表不会被修改。这可能会导致意外的行为。所以推荐用is None判断而不是用or。不只列表字典、集合都有这个问题[]是最常见的坑但绝不是唯一的。任何可变对象作为默认参数都有同样的问题。字典def add_config(key, value, config{}): config[key] value return config print(add_config(a, 1)) print(add_config(b, 2))输出{a: 1} {a: 1, b: 2}集合def add_item(item, itemsset()): items.add(item) return items print(add_item(a)) print(add_item(b))输出{a} {a, b}自定义对象也一样class Logger: def __init__(self): self.logs [] def log(message, loggerLogger()): logger.logs.append(message) return logger.logs print(log(第一条)) print(log(第二条))输出[第一条] [第一条, 第二条]规律是一样的只要默认参数是可变对象它就会被所有调用共享。为什么Python要这样设计看到这里你可能会问Python为什么要这么设计这不是故意坑人吗其实这个设计是有原因的。Python的默认参数值是在函数定义时求值的这意味着一件很重要的事情默认参数可以依赖函数定义时的上下文。比如import datetime def log(message, timestampdatetime.datetime.now()): print(f[{timestamp}] {message}) log(程序启动)这里datetime.datetime.now()在函数定义时被调用了一次之后每次调用log都用的是同一个时间戳。这可能不是你想要的效果但它展示了默认参数的一个特性它是在定义时确定的不是调用时。再比如默认参数可以引用之前定义的变量DEFAULT_TIMEOUT 30 def connect(host, timeoutDEFAULT_TIMEOUT): pass如果默认参数在每次调用时才求值那么DEFAULT_TIMEOUT如果在之后被修改connect的默认行为也会跟着变。但因为在定义时求值函数的行为就固定下来了。Python选择“定义时求值”而不是“调用时求值”是为了让函数的行为更可预测。但代价就是可变默认参数会被共享。这个设计本身没有错错的是我们使用它的方式。可变对象作为默认参数几乎从来都不是我们想要的行为。这个坑为什么这么难发现这个问题的隐蔽性在于它在简单场景下不会暴露。如果你只调用一次函数完全没问题。如果你每次调用都传了第二个参数也没问题。只有当你不传参数、并且多次调用时问题才会显现。更麻烦的是它可能不会立刻出错。数据会静默地累积直到某个时刻才引发异常。比如你写了一个处理请求的函数默认参数是个字典用来存缓存。你以为每次请求都是独立的结果上一个请求的数据泄漏到了下一个请求里。这种bug在测试环境可能永远发现不了一到生产环境就出问题。还有一个让人困惑的地方默认参数在函数定义时只求值一次这条规则很多人其实“知道”但没有真正理解。考试能答对写代码时照样踩坑。因为直觉上“默认值”听起来就像是每次调用时才会用到的东西。怎么彻底避免这个问题最根本的原则只有一条永远不要用可变对象作为默认参数。具体来说以下这些都不能作为默认参数[]{}set()bytearray()任何自定义的可变对象任何包含可变对象的容器正确的做法是默认值用None在函数体内判断并创建。def process(itemsNone): if items is None: items [] # 继续处理这个模式适用于所有情况。如果你觉得每次都写if xxx is None太麻烦可以用一个辅助函数def ensure_list(value): return [] if value is None else value def add_tag(tag, tagsNone): tags ensure_list(tags) tags.append(tag) return tags但说实话多写两行代码换来的是代码行为的确定性这笔买卖很划算。有没有故意用可变默认参数的情况有但很少而且通常不值得。有些人利用可变默认参数来实现缓存def fib(n, cache{0: 0, 1: 1}): if n not in cache: cache[n] fib(n - 1) fib(n - 2) return cache[n]这段代码能跑而且性能不错。但它有几个问题缓存无法清空、无法限制大小、多线程不安全。真要缓存用functools.lru_cache更清晰from functools import lru_cache lru_cache(maxsizeNone) def fib(n): if n 2: return n return fib(n - 1) fib(n - 2)还有人用可变默认参数来记录函数被调用的历史def track_call(arg, history[]): history.append(arg) return history但这种“隐藏状态”让函数的行为变得不可预测调试起来很痛苦。不如显式传一个列表进来或者用类来管理状态。在绝大多数情况下可变默认参数带来的便利远远小于它带来的风险。所以业界共识就是别用。一个容易忽略的变体类属性和默认参数类似类属性也有共享的问题class User: tags [] def add_tag(self, tag): self.tags.append(tag) u1 User() u2 User() u1.add_tag(Python) print(u2.tags)输出[Python]u2的tags里居然有u1添加的标签。因为tags []是类属性所有实例共享同一个列表。解决方案是在__init__里初始化class User: def __init__(self): self.tags []这个坑和默认参数的坑本质上是同一个问题可变对象被多个上下文共享。再回到小张的故事小张最后把代码改成了def add_tag(tag, tagsNone): if tags is None: tags [] tags.append(tag) return tags问题解决了。他顺手查了一下项目里还有没有类似的写法结果发现了七八处。有的是默认参数用了{}有的是类属性用了[]还有一处是默认参数用了datetime.now()导致所有日志的时间戳都一样。他花了一个小时全部改完然后在团队群里发了一条消息“以后写默认参数别用[]用None。别问我怎么知道的。”这个坑不会报错不会崩溃只会在某个不经意的时刻让你怀疑人生。而避免它的成本只是多写一行if xxx is None。Python的默认参数在定义时求值而且只求值一次。可变对象一旦被共享所有调用都会互相影响。写[]你得到的是一个共享的列表写None你得到的是每次调用都新建的列表。区别就是这么大。
返回列表