Python高频踩坑清单:5大常见缺陷与根治方案

发布时间:2026/10/20 8:10:38
Python高频踩坑清单:5大常见缺陷与根治方案
写这篇东西的起因挺简单这几年不管是自己写业务代码还是给团队做代码评审总能反复撞见几个固定事故现场。有些坑新同事踩老同事也踩连我自己偶尔都会中招。后来我逐渐意识到与其每次都从头排查一遍不如把这些高频问题整理成一份有顺序、有解法、还能背下来的清单——5大核心坑 根治方案 记忆口诀按踩坑频率排序。今天就把这份清单完整摊开写清楚每个坑为什么会发生、一句话怎么根治、以及我怎么督促自己记住的。适合刚入行的开发者直接抄作业也适合带团队的读者拿去当评审标准。1. 先说清楚这五个坑是怎么被排进高频榜的这个踩坑频率排名不是拍脑袋定的我主要统计了三个来源。第一近几年经手项目的代码评审记录统计同一类问题被指出的次数第二带新人和做内部技术分享时大家当场卡壳最多的点第三线上排查故障时最终定位到的根因属于哪一类。三个来源交叉比对之后出现次数最稳定的就是这五类。要特别说明一点这个排序更接近日常开发中被撞见的概率而不是后果有多严重。像并发问题、内存泄漏这类坑一旦爆发后果往往很重但它们在日常开发里出现的机会反而没这些小问题多所以排不上这份高频榜。真正频繁出现的恰恰是那些看着不起眼、一不留神就犯的细节。五个坑的总体情况先用一张表概括排名坑典型表现一句话根治1函数默认参数用了可变对象同一个函数第二次调用结果串了用 None 做哨兵进函数再建新对象2浮点数直接做等值比较或金额计算0.1 0.2 不等于 0.3金额用 Decimal或者全用整数分3浅拷贝被当成深拷贝用改副本原数据跟着变嵌套结构用 deepcopy4循环遍历同时修改列表删除元素时漏掉部分数据先遍历副本再动手删改5函数内赋值导致局部变量遮蔽全局变量没被改到甚至报错用返回值重新接住每个坑我会按三步来讲先还原事故现场再拆底层机制最后给根治方案和一句能背下来的口诀。顺序就按上面这个频率排名来。2. 坑一频率第一默认参数里的可变对象函数记仇的元凶2.1 事故现场看一段很常见的代码def add_user(name, user_list[]): user_list.append(name) return user_list group_a add_user(用户A) print(group_a) # [用户A] group_b add_user(用户B) print(group_b) # [用户A, 用户B] ← 这里就串了第二次调用时我明明只想往空列表里加用户B结果 user_list 里躺着上次调用的用户A。这种现象在真实项目里非常隐蔽第一次调用没问题第二次、第三次开始数据越攒越乱而且越难复现往往要打好几个日志才能定位到函数签名。2.2 为什么会这样默认值只算一次Python 在函数定义的时候就会把默认参数表达式求值并且保存那个对象。也就是说user_list[]里的空列表在 def 执行时只被创建了一次之后所有不传该参数的调用共享同一个列表对象。用一个生活化的类比这就像餐厅默认给每位客人一张老主顾记单。第一桌客人点完菜单子上写了用户A第二桌客人进来服务员没开新单还是拿那张旧单子菜名自然混在一起。默认参数也一样它会被记仇——每次调用都记住上一次留下的数据。2.3 根治方案None 哨兵模式正确的写法是让默认值不可变在函数内部重新创建对象def add_user(name, user_listNone): if user_list is None: user_list [] user_list.append(name) return user_list这里把默认值改成 None。None 是不可变对象不存在被共享修改的问题。调用时不传该参数函数内部每次都会新建一个空列表调用之间完全隔离。要留意的还有不只是列表{}、set()、bytearray()这些可变对象当默认值都有问题。我在评审中还经常看到类属性、缓存容器被放进默认参数的做法同样建议全部改成 None 函数内初始化的模式。另外如果业务上确实需要保留上次结果那应该改成显式传入容器而不是靠默认参数偷偷记住。2.4 口诀默认参数别放变None 垫底换新单。这句话我每次写函数签名都会默念一遍。只要扫到函数定义里有方括号、花括号作为默认值就直接改成 None。检查速度非常快几乎不用动脑。3. 坑二频率第二浮点数精度丢了金额算错几乎都从这里开始3.1 事故现场一段让无数人跌破眼镜的代码print(0.1 0.2) # 0.30000000000000004 print(0.1 0.2 0.3) # False print(round(2.675, 2)) # 2.67而不是 2.68浮点数比较失败、金额对不上账、四舍五入结果不对——这些事故一旦出现在对账、统计、报表场景里往往折腾一整天。我见过最夸张的一次是报表系统里累计金额差了 0.01 元最后定位到源头是在求和循环里用了浮点累加。3.2 为什么会这样二进制里存不下 0.1计算机内部用二进制存储小数而很多十进制小数转换成二进制后是无限循环小数。比如 0.1 在二进制里就像十进制里的 1/3 一样写不完只能取一个近似值存起来。多个近似值参与运算误差就会累积并暴露出来。这不是 Python 独有的 bugJava、JavaScript、C 全都一样只是部分语言自身做了隐藏处理或者某些场景恰好没触发精度差异。所以别指望换语言要从设计上规避。3.3 根治方案按场景选择别一把梭第一涉及金额、价格、账目的计算直接用 Decimal而且要从字符串初始化from decimal import Decimal price Decimal(19.99) count Decimal(3) print(price * count) # 59.97第二追求性能和简单口径统一成整数最小单位比如分自己除 100 展示成元。这是电商类项目里我最推荐的做法既精确又高效。第三只做展示不做计算的场景才允许 round() 或 format() 控制输出精度。但要心里清楚这只是给用户看个大概不是精确结果。这里有个经典细节Decimal(0.1)是错误的因为 0.1 在传入之前已经是浮点近似值了正确写法是Decimal(0.1)。很多人在这一步又踩一遍坑。3.4 口诀算钱别碰浮点Decimal 稳稳算单位换成最小分账面分毫不差。我负责的项目里所有金额字段一律要求用 int 存分前端展示才转成元。这样既省掉 Decimal 的性能开销和格式问题也彻底断掉浮点金额的根。4. 坑三频率第三浅拷贝当深拷贝用副本一改原数据跟着崩4.1 事故现场看这段代码import copy a [{name: 用户A, tags: [1, 2]}] b a.copy() b[0][tags].append(3) print(a[0][tags]) # [1, 2, 3] ← 原数据被改了我明明拷贝了一份 b为什么改 b 里的 tags原列表 a 也跟着变这几乎是团队新人问我最多的问题之一。更危险的是这类 bug 往往不是立刻爆发而是改完副本后原数据在后面的逻辑里悄悄被带偏排查时很难联想到拷贝这一步。4.2 为什么会这样浅拷贝只复制了一层list.copy()、切片 [:]、dict.copy() 这些都叫浅拷贝——只复制最外层的容器里面的元素如果本身是可变对象复制出来的仍然是引用。类比一下你复印了一份合同封皮但封皮里夹着的票据是原件你拿着复印件去涂改票据原件自然跟着花。浅拷贝就是把外层容器换了新壳里面的票据还是同一张。4.3 根治方案先问自己要改几层动手拷贝之前先问两个问题这份数据有几层嵌套我接下来会不会修改嵌套层级的元素如果只是修改最外层比如往列表里添加一个字典浅拷贝完全够用。一旦要修改嵌套的列表、字典就必须用深拷贝b copy.deepcopy(a)还要记住几个容易误判的点切片对嵌套列表也是浅拷贝dict.copy() 同样是浅拷贝函数传参本质上也是复制引用所以函数内对参数对象的嵌套内容做修改照样会影响外部数据。另外deepcopy 性能比浅拷贝慢不少数据量大的时候别滥用但数据量中等、嵌套存在时慢一点换安全非常划算。4.4 口诀浅拷只管一层嵌套必须深拷动手改之前先问原数据怕不怕。我评审的时候看到 copy、切片、dict.copy 这些写法会紧接着往后看一行后面有没有对嵌套内容的赋值操作。有就当场要求改成 deepcopy 或重新构造对象。5. 坑四频率第四循环遍历同时修改列表删着删着就漏了5.1 事故现场这段代码我见过太多次nums [1, 2, 3, 4, 5, 6] for n in nums: if n 4: nums.remove(n) print(nums) # [2, 4, 5, 6] ← 2 漏删了按直觉删掉所有小于 4 的数字结果应该是 [4, 5, 6]但最终留下了 2。问题就出在遍历的同时修改同一个列表。5.2 为什么会这样索引推进和元素移动的节奏对不上for 循环在背后是按索引推进的。remove 一个元素后后面的元素会整体往前挪一格索引却继续往下走。于是本应该在下一位被检查的元素因为列表缩短、被跳过了一个位置。类比排队叫号队伍里有人被拉出去后面所有人往前补位但叫号员手里的号码没重置于是下一个号码跳过了一个人。遍历一遍下来总会有人漏掉。5.3 根治方案先拍快照再操作最稳妥的做法是遍历一个副本在原列表上做修改for n in list(nums): if n 4: nums.remove(n)如果只是想筛选出新列表直接用列表推导式、然后整体替换连 remove 都不需要nums [n for n in nums if n 4]还有一种从后往前遍历的思路也能避免跳过但代码可读性差一些新手不建议优先使用。另外提醒一句遍历字典时直接删除 keyPython 会直接抛 RuntimeError正确的做法是先取 keys 列表再在副本基础上删除。5.4 口诀边遍历边修改永远删不干净先拍快照再操作一个不落才安心。我在评审里看到 for 循环体内出现 remove、pop、del 这类操作时第一反应就是回去看循环的是不是原列表。如果是必定标注先转成副本。6. 坑五频率第五函数内赋值让全局变量原地失踪6.1 事故现场新手最容易犯的一个认知错误total 0 def add_score(score): total score add_score(10) print(total)运行直接抛UnboundLocalError: local variable total referenced before assignment。很多人以为函数里可以直接改外部变量结果要么报错要么改了没效果最后发现函数里操作的是另一个同名局部变量。6.2 为什么会这样赋值决定了变量户口Python 查找变量遵循 LEGB 顺序Local局部→ Enclosing外层闭包→ Global全局→ Built-in内置。关键在于只要函数体内某个名字出现了赋值操作Python 编译器就把这个名字标记为局部变量。于是函数里的 total 和外面的 total 根本不是同一个东西读到一个尚未赋值的局部变量自然报错。6.3 根治方案优先用返回值接住最简单、最清晰的方案是彻底不靠全局变量把外层值当参数传进去把结果传出来def add_score(score, current): return current score total add_score(10, total)有个例外要区分清楚函数内做list.append()、dict[key] value这类操作改的是对象本身不存在名字重新绑定所以不会触发局部变量遮蔽。真正要用global、nonlocal的场景非常少通常只在闭包、装饰器等特殊场景才考虑日常业务代码里建议一律用参数 返回值。6.4 口诀函数里一赋值名字就变局部想要改外层返回再接管。我写代码时养成一个习惯看到函数里有赋值语句就下意识问自己一句——这个变量名是不是跟外面的重名了重名就立刻改参数传递绝不指望碰巧能改到。7. 把五个坑串成一条总口诀评审和自学都好使单独记五句口诀说难不难但忙起来容易忘。我后来把这五句串成一段完整的话背一遍等于把写函数的过程走了一遍默认参数别放变None 垫底换新单。 算钱别碰浮点Decimal 稳稳算。 浅拷只管一层嵌套深拷才安全。 边遍历边修改快照先行不遗漏。 函数赋值是局部外层变化靠返回。记忆顺序我建议这样联想写一个函数的自然流程——先看函数签名里的默认参数再看里面的数值计算再看数据对象的拷贝再检查循环操作最后确认变量赋值。这个顺序正好对应上面五句话想忘都难。串成段之后还有个额外好处做 Code Review 时不用翻文档嘴里默念一遍这五句心里就有了一张完整的检查地图。哪句匹配当前代码的哪类问题一眼就能对上。我团队评审的效率提升很明显尤其是新人提的 PR基本能拦截掉一大半低级问题。8. 用这套口诀做代码评审自检清单我攒下的最后一层经验8.1 自检清单可以这样落地把口诀直接映射成评审检查点做成一张表贴在项目里口诀评审时对应检查默认参数别放变函数签名是否出现[]、{}、set()这类可变默认值算钱别碰浮点金额字段是否为 int 分或 Decimal有没有浮点直接参与计算浅拷只管一层copy/切片后是否有对嵌套内容的修改是否外溢到原数据边遍历边修改for 循环体内是否 remove/pop/del 了正在迭代的对象函数赋值是局部函数内是否有和外层变量重名的赋值是否误用了全局变量8.2 实际用下来的体会说点个人经验这份清单最大的价值不是防止所有 bug而是把高频低级问题从人工记忆里解放出来。我带团队之后只做了两件事第一把五句话贴在项目仓库 README 顶部的 Code Review 自检区第二要求提交 PR 前对照一遍。效果非常直观某模块从每月被同类问题打断两三次降到几个月碰不到一次。出现线上异常时我也习惯逆着口诀排查五句念完基本能覆盖大多数看着莫名其妙的小问题。最后想补充一个容易忽略的点口诀是快照不是万能钥匙。它管的是日常高频低难度问题像并发竞态、异常处理、资源释放这些更重的场景单靠口诀兜不住还是需要单元测试、压力测试这些机制来兜底。不过话说回来把最简单、最频繁的坑先拦在门外团队才能把精力留给真正复杂的问题——这五句话就是我目前用下来性价比最高的那扇门。