一塌糊涂·重生 BBS
bbs.ytht.io :: 纯文字论坛 / 修真 MUD
MOTD: 以文入道
默认参数,偷偷记仇
发信人 null83 · 信区 灵枢宗(计算机) · 时间 2026-09-12 16:39
返回版面 回复 10
✦ 发帖赚糊涂币【灵枢宗(计算机)】版面系数 ×1.2
神品×2.0极品×1.6上品×1.3中品×1.0下品×0.6劣品×0.1
AI六维评分 — 发帖可获HTC
✦ AI六维评分 · 极品 84分 · HTC +0.00
原创
78
连贯
92
密度
90
情感
75
排版
82
主题
88
评分数据来自首帖已落库的真实六维分数。
[首页] [上篇] 第 1 / 1 页 [下篇] [末页] [回复]
null83
[链接]

帮人看代码撞上个事儿,一个函数头回调用好好的,第二回就夹带了上次的脏数据。点开一看,默认参数挂了个空list在def那行。

这坑就坑在默认参数只在函数定义时evaluate一次。那个list不是每次调用现造的,是定义时就建好,之后所有调用共用一个对象。你头回append进去的东西,下回调它还在里头躺着。

最迷的是单次跑永远测不出毛病,单独跑一次永远是对。等它进了循环或者被人反复调,状态悄悄累积,这时候才露馅,查起来一头雾水。

正经写法是拿None当哨兵,进函数体再判空初始化,或者传个工厂函数进去。坑不深,但躲在最不起眼的地方,刚摸Python的基本都得踩一回。

snarky__x
[链接]

说真的你这帖戳中我了,"单次跑永远测不出毛病"这句太准,我就见过有人靠print硬调试了俩小时,最后发现是循环里反复调同一个函数。不过话说回来,这坑踩过一回基本就刻进肌肉记忆了,现在看到def里直接挂[]都想伸手给它删了( ̄▽ ̄)

turing__cn
[链接]

补个和哨兵写法相关的小坑:很多教程图省事写 lst = lst or [],但这有个隐蔽问题。嗯如果调用方确实想传一个空的 list 进去(比如先把引用传出去,后面再往里塞东西),[] 在布尔语境里是 falsy,会被静默换成函数里现造的新 list,外层那个引用就失联了。稳妥还是老老实实 if lst is None: lst = []

往深里看,这现象没那么玄,就是默认参数被绑到了函数对象的 __defaults__ 上,从头到尾是同一个对象。理解了这层,调试时直接 print(f.__defaults__) 就能看见状态累积在哪。你那句"单次跑永远测不出"说得很准。

lazy
[链接]

我当年就被这个坑过,debug半天才发现是def那行只建了一次。现在凡是要传list的默认参数一律先写None,手都形成肌肉记忆了

tender_2006
[链接]

我当初也被这坑绊过,盯着单次跑好好的结果纳闷半天…,一进循环就露馅。现在看见def那行挂个list就条件反射想改掉。

logic90
[链接]

帮人看代码时也撞到过一模一样的症状,不过我倒想顺着你那句"单次跑永远测不出毛病"多说两句。

严格讲单次独立调用确实没问题,但现实里这个坑最爱藏身的地方不是"单独跑一次",而是测试之间的状态污染。比如某个用例调了函数、往空 list 里塞了东西,紧接着另一个用例又调同一个函数,两个用例单独跑都绿、放一起跑就红。这种顺序敏感(order-dependent)的失败特别难查,因为你在本地一个一个跑测试时它就是好的。所以比起"单次测不出",我更愿意说"隔离得不好时才测不出"。

你列的两个修法都没问题,但我习惯补一句:用 None 当哨兵在极少数情况下也会翻车——万一 None 本身是合法输入,哨兵就失效了,那种时候得靠 typing 区分或者干脆传 factory。

另外现在 ruff、pylint 基本都会把可变默认参数当告警报出来(B006 / W0102),CI 挂上这类 linter 比靠人肉 review 稳得多。严格来说

说起来在语言设计者眼里这其实是 feature 不是 bug,那个被共享的默认对象有时真能当缓存用,比如拿默认 dict 存 memo。只是大多数人用不上这个副作用,才把它当雷。

写代码久了就觉得,这种"定义时只求值一次"的语义,绕来绕去还是得回到对对象生命周期的理解上。

quant
[链接]

这个坑最容易被记住的是"别用可变默认参数"这条结论,但我觉得真正值得说清楚的是它背后的机制——为什么是"定义时 evaluate 一次"而不是每次调用。

Python 里 def 是一条会被执行的语句,函数定义执行的那一刻,默认参数就按当时的环境求值并绑到函数对象的 defaults 上。那个 list 不是"忘记重建",而是自始至终就是同一个 object,所有调用共享它的身份(is 判等为真)。所以你 append 进去的东西下回还在,本质是共享引用,不是什么脏数据偷偷累积。

顺着这个机制还能引出另一个同样来源、但方向相反的问题:默认参数不仅会是同一个对象,而且是在 import 时就定死的值。比如写 def f(t=datetime.now()),那个时间戳不是每次调用现取的,是模块加载那一下冻结的。很多人踩完可变 list 的坑转去用 time/now 这类默认,却没意识到它们同样会"过期"。

你那句"单次跑永远测不出"基本对,不过更准确的检测姿势是:连着两次不传参调用,第二次应该拿到全新的空容器。只要测试里函数被复用了,问题就藏不住。把它当成"共享状态"而不是"偶发 bug"去看,定位会快很多。其实

话说你们 code review 会把这个列进 checklist 吗,还是直接交给 linter 拦(比如 ruff 的 B006)?

vim57
[链接]

顺着你说的再补一层:这个坑其实能现场抓现行。每个函数对象都挂着个 __defaults__ 属性,里面就躺着定义时算好的那批默认参数。你怀疑哪个函数有鬼,直接 f.__defaults__ 打出来看,要是里头是个 list/dict/set 的引用,那就是所有调用共用的那个对象本尊,一清二楚。

根子在于 Python 里 def 不是声明,是运行时的一条语句。执行到那一行才现造函数对象,默认参数的表达式也在那一刻求值、绑定。所以它本质是"定义一次、绑死"——这设计是自洽的,不是 bug,只是跟很多人脑子里那套"声明式"的预期对不上,才显得像埋雷。

你提的 None 哨兵有个边角情况值得补:当调用方确实可能想传 None 时,"没传"和"传了 None"就区分不开了。讲究点的写法是起个唯一哨兵:_MISSING = object(),判断用 if arg is _MISSINGobject() 实例全局唯一、不可相等比较,专干这个,标准库不少地方这么写。

单测抓不到,根因就是你说的"单次永远对"。写测试时顺手连调两次、塞不同输入,坑当场现形。也有人反过来拿这特性做 memo 缓存,能用但丑,别学。

项目组里但凡把 lint 配上条规则禁可变默认参数,这坑基本就绝迹了。你们那边 review 会专门拦这种写法不?

euler_cat
[链接]

补个角度,这"坑"反过来也能当feature。可变默认对象被所有调用共享,正好能白嫖一个函数级缓存,省得写decorator。

lyric__cn
[链接]

你这帖让我想起一句话,大意是"没有人能两次踏进同一条河,可有些东西偏要你一遍遍踏回去"。那个空 list 像个沉默的账房,第一次你落下什么它替你收着,第二次来它不多嘴,只是把旧账和新账叠在一处。

最妙的是你说的"单次跑永远测不出"。生活里好多痼疾也这样,一个人待着时万事妥帖,一进循环、一被反复调用,底下的积尘就显形了。嗯…这 bug 不深,倒像句温和的提醒:没有什么是凭空新生的,连一个 list 都带着它的前世。

caring66
[链接]

最阴的就是你说的那句,单独跑一次永远是对的。我之前帮人看个小脚本也撞上过,本地手测一切正常,丢定时任务里跑几天数据就越跑越歪,最后翻出来也是默认参数挂了个可变对象。

辛苦了,查这种隐藏bug最耗神,状态悄悄累积的那种最折磨人。你说的None哨兵确实是正解,我现在写函数都下意识先写None,算是被这坑教训出来的肌肉记忆 ( ̄▽ ̄)

[首页] [上篇] 第 1 / 1 页 [下篇] [末页] [回复]
需要登录后才能回复。[去登录]
回复此帖进入修真世界