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

前几天帮人排查代码,撞上个特别典型的坑。对方写了个函数大概长这样:def add(x, buf=[]): buf.append(x); return buf。本意是每次调用都从干净的新列表起步,结果第一次 add(1) 返回 [1],紧接着 add(2) 居然吐出 [1, 2]。

根子在于 Python 的默认参数是在函数定义那一刻求一次值的,那个空列表跟后面所有调用共用同一份引用。你 append 进去的东西就赖在那儿不走了,下次调用照单全收。这种 bug 最磨人的地方是它不总是显形,单测里若巧合同一个实例反复用,表现还正常,一上生产环境多调用穿插就乱套,复现都费劲。

正经写法是用 None 占位,进函数体再判断:def add(x, buf=None): if buf is None: buf = []。语义最清楚,调用方想传自己的列表也行,不传就拿全新的。说白了就是得把"定义时"和"调用时"分明白。这坑拿来给刚入门的同学讲求值时机,倒是挺顺手的例子。

quant
[链接]

补充一个角度:这个严格说不算 bug,是 defined behavior。默认参数在 def 时求值一次这点你没说错,但它是语言设计里 intentional 的——有人反而拿这个特性做 module-level cache,把 default dict 当存储用。所以更准确的说法是「mutable default 的语义没被预期到」,而不是解释器出错。

你那句「单测里同一个实例反复用表现正常」我倒有点疑问:恰恰相反,反复调用才最容易露馅,add(1) 之后紧跟 add(2) 直接吐 [1,2],单测里一眼就现了。真正难抓的往往是每个测试各只调一次、每次都像第一次,上线后调用顺序一乱才串味。

嗯None 占位那是 cleanest 的写法,没毛病。

theorem_bee
[链接]

补充一个容易被这个例子本身带偏的点:你说的"def 时求一次值"其实对所有默认参数都成立,不光是可变对象。比如 buf=‘’ 这种不可变的,也是在 def 那一刻把对象绑好了,只是没法原地改它,所以看不出"记仇"。真正的区别不在 evaluation 时机,而在对象能不能被原地 mutate。

顺着说,原帖修复版里 buf = [] 重新赋值,本质是断了共享引用;但如果函数体写成 buf = buf + [x] 而不是 buf.append(x),即便还用默认空列表也不会串——+ 生成新对象,append 才是原地改(+= 对 list 也等价于原地 extend)。这几个细节放一起,比单纯讲"别用可变默认参数"更能让初学者分清引用和身份。

嗯你们带新人时会顺带把 is 和 == 一起讲了不?

penguin__us
[链接]

这坑我刚学那阵也栽过 调半天才发现列表越来越长 还以为自己哪行多写了个append 后来才知道是默认参数这口锅

tensor_47
[链接]

这个坑我当年也栽过。排查时 print 下 id(buf) 最直接,地址不变就坐实了共享引用,比盯返回值省事

bronze48
[链接]

前几年 turing__cn 在这个版好像也发过类似的考题,拿可变默认参数逗新来的同学玩。我那会儿隔着屏幕看人 debug,折腾好半天才揪出是同一个 list 在底下赖着不走,跟楼主说的简直一个模子刻出来的。怎么说呢

这种"定义时求一次值"的脾气,书本上三两句话就讲完了,真要记牢,多半得自己亲手栽一回。我年轻时碰见新鲜玩意儿,也总觉着看明白了就是会了,临到用上才晓得差着一截火候。你们搞这一摊的,怕是每人兜里都装着几桩这样的踩坑记。

lyric_dog
[链接]

读到你说的"那个空列表跟后面所有调用共用同一份引用",忽然觉得这个函数像一间忘了锁门的房间。第一次有人走进去放了支笔,门没关,第二次推门进来的时候,笔还搁在桌上。你以为自己是头一个来的人,其实前一个人的痕迹全在。坦白讲

你抓的"定义时求值"这个根子特别准。我想补一个相邻的坑,它俩算表兄弟,循环里写闭包,lambda 抓的是变量本身而不是那一刻的值。比如 funcs = [lambda: i for i in range(3)],三个函数调出来竟然全是 2。毛病同源,都是时机在作祟,一个在定义时把引用钉死,一个到调用时才去读那个早跑完的变量。讲求值时机,这两桩摆在一块儿最有意思。

你那个 None 占位写法当然最稳,不过有个边角容易让人再摔一回:调用方要是真传了自己的列表进来,你在函数里 append,动的还是人家的东西。不传就拿全新的,对,可传了就共享,也得心里有数。要彻底隔离,有时得 copy 一下才安心。

最磨人的还是你点出的不总是显形。这类问题说到底是状态藏在暗处。单测里同一个实例反复用,像天天走同一条路,坑被自己的习惯填平了;一上生产,千万条调用交叉,那点没清干净的记忆就全涌出来。测试越是顺手,越容易跟 bug 同流合污。

它倒像一则关于记忆的小寓言:有些东西我们不以为意地留着,下一次见面它还稳稳地在,温柔又顽固。

mehist
[链接]

buf=[] 那个共享引用最阴 我当年也以为是环境出问题了

oakism
[链接]

我年轻那会儿也在这上头栽过,栽得不轻。写了个函数拿空字典当默认参数做缓存,自以为每次调用都是干净的新对象,结果跑着跑着内存蹭蹭往上涨,排查了好一阵才反应过来,那个字典早就和函数对象绑死在一块了,后面每次调用都往里头续。坦白讲
那会儿
后来就学乖了,可变对象默认一律 None 占位,进函数体再新建。楼主说的这条路子最稳当。不过有个细节想补充,这毛病往往单测里反而蒙混过关,同一个实例反复调表现正常,一上生产环境多调用一搅和才露馅。

你们现在写 Python 有静态检查替你揪这类问题不?我那会儿全靠人肉瞪眼,倒是磨出点性子。

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