首页 / 资讯中心 / 文章详情

Python十大常见错误全解析:从报错到排查的实用指南

Python十大常见错误全解析:从报错到排查的实用指南 ★ FEATURED ARTICLE
开头如果你跟我一样平时除了写代码还要顺手帮周围的同学、同事看看报错你迟早会发现一个现象Python的报错翻来覆去就那么几类。不是今天有人把Tab和空格混着用导致缩进崩了就是明天有人拿着两个列表对象比了半天发现永远是False。我做过一个小统计在我见过的项目代码和讨论里大概有八成的问题最后都落到十个经典错误上。这十类错误基本覆盖了初学者到中等水平开发者最常踩的坑而且很多坑是属于“报错信息很抽象、实际原因很朴素”的类型没经验的人绕很久有经验的人一眼就能看出来。这篇文章就把这十类错误挨个拆开每一类我都给典型的报错信息、最常见的触发场景以及我实际排查过程中用到的解决思路。为了让你快速对照先放一张总览表后面再按类别慢慢展开。序号错误现象典型报错高频场景1缩进混用Tab和空格IndentationError从网页复制代码、多人协作改动缩进2变量未定义或作用域理解错误NameError拼写不一致、函数外变量但函数内没声明3函数内同名赋值导致读不到外部变量UnboundLocalError函数内先赋值后读取全局变量4循环中创建lambda或闭包捕获同一变量结果全部一样lambda i: i放在for循环里5列表索引越界IndexError空列表取[0]、边界条件少算一位6字典键不存在KeyError用中括号取不存在的键7遍历时修改列表结构跳项或RuntimeErrorfor循环里remove元素8混淆与is比较结果不符合预期比较两个内容一样的列表9可变默认参数默认值被“记住”def f(x[])多次调用累计数据10裸except与忘记关闭资源错误被吞、文件句柄泄漏捕获所有异常却不处理、不开with下面挑几组最容易反复出现的详细讲。1. 缩进和符号语法报错里的头号选手1.1 Tab与空格混用看着对齐解释器不认我见过最离谱的一次同事写了一个函数代码在编辑器里看起来缩进完全对齐但一运行就报IndentationError。后来我把所有不可见字符显示出来才发现函数体里前三行用的是Tab后两行是用空格补的Tab默认显示宽度和空格相同眼睛根本看不出来。Python对缩进极其敏感因为缩进就是语法的一部分。同一个代码块里Tab和空格不能混用而且Python官方推荐用4个空格作为标准缩进。但你从网上复制代码时很多网页会把Tab转成空格或者在复制过程中把空格弄丢很容易混进奇怪的东西。排查这种问题先别盯着代码逻辑直接把“显示空白字符”打开。常见的编辑器都能开启比如VS Code里是View - Render Whitespace或者直接执行下面这段代码把报错位置的字符一个个打印出来with open(your_script.py, rb) as f: lines f.readlines() for lineno, line in enumerate(lines, 1): if b\t in line: print(lineno, has tab)我实际处理这类问题的心得是永远不要用Tab做缩进哪怕你的编辑器会自动把Tab转成空格。把Tab键绑定成插入4个空格是投入最低、收益最高的设置。另外如果因为缩进而反复报错先全选代码统一把Tab替换成空格再整理缩进层级比一行一行改快得多。1.2 括号不匹配、中英文标点晚上写代码容易犯缩进之外另一个高频语法错误是括号不匹配。闭着眼睛数括号很难尤其函数调用、列表推导、字典构造叠在一起时少写一个右括号报错位置往往在最后一行导致你盯着最后一行半天找不到问题。Python的报错比较聪明通常会在SyntaxError里给出箭头指向但箭头指向的是它发现错误的位置不是错误真正发生的位置。比如data { name: Alice, hobbies: [reading, coding],这里少了一个右大括号报错会指向文件末尾。遇到这种情况我一般会把这一段的括号逐个配对或者用编辑器自带的括号高亮光标放在某个括号上看它匹配到的另一半是否合理。中英文标点混用也很常见尤其是刚切换过输入法。全角逗号、全角括号混在代码里Python会直接报语法错误而且报错信息只是一个很普通的SyntaxError: invalid character (UFF0C)。这类错误解决起来不难但排查时要想到这个方向尤其是错误信息里出现invalid character时第一反应就应该是复制到文本编辑器里检查是否混入了中文符号。2. 变量名、作用域和闭包不是没定义是定义在了别处2.1 NameError先赋值再使用别跟全局变量较劲NameError: name xxx is not defined是入门阶段最常见的报错。原因一般有三类变量名拼写不一致、变量在赋值之前使用、或者把Python内置函数名给覆盖了。第一类纯属手误比如前面写了user_list后面用的时候写成了userlist。第二类就是逻辑顺序问题可能你忘了在前面初始化变量。第三类比较隐蔽我曾经见过有人把list或者map当成变量名后面又用list()创建列表结果报TypeError: int object is not callable但报错信息只显示“不是可调用的”如果没意识到是命名覆盖排查方向很容易跑偏。排查NameError时我习惯先在报错位置往上搜索这个变量名确认有没有赋值。如果确实定义了再看是不是在函数内部使用了一个函数外的变量但函数内又恰好有同名赋值。如果是后者就会引出下一个错误。2.2 UnboundLocalError函数内出现赋值就把变量当局部UnboundLocalError是我觉得“看起来像NameError但实际是作用域问题”的典型。看下面这段代码count 0 def add(): count 1 return count print(add())运行结果不是你想的1而是UnboundLocalError: local variable count referenced before assignment。原因在于Python在编译函数体时只要发现某个变量在函数内部有赋值操作就会把整个函数作用域内所有对这个变量的引用都当作局部变量对待。count 1里既有读取也有赋值于是它认为count是局部变量读取时局部变量还没定义就报了“赋值前引用”。这类问题在循环计数器、事件计数的场景里特别常见。解决办法很明确如果你想在函数里修改全局变量需要显式声明global count如果想返回全局变量而不修改直接读是没问题的。但更推荐的方式是避免依赖全局可变状态把变量作为参数传入、返回值传出。我排查这个错误的经验是看到UnboundLocalError先看函数体内有没有对同名变量做赋值。如果有即使赋值语句在读取语句之后也会触发这个错误。例如x 10 def func(): print(x) x 20 func()这里print(x)也会报错因为后面有x 20整个函数里x被判定为局部变量。初学者很容易被“先打印后赋值应该没问题”的思路迷惑实际上一旦被Python判定为局部变量函数开头读取它就是在读取一个未绑定的局部变量。2.3 lambda和闭包循环里全是一样的i闭包延迟绑定是另一个非常经典的坑。直接看代码funcs [] for i in range(3): funcs.append(lambda: i) for f in funcs: print(f())很多人预期输出0 1 2实际输出的是2 2 2。原因是lambda函数捕获的是变量i本身而不是i的值。循环结束后i的值停在2所有lambda在调用时读到的都是同一个i。解决方式有几种。最常用的是给lambda设置默认参数让默认参数在定义时把当前值绑定进去funcs [] for i in range(3): funcs.append(lambda ii: i) # 默认参数捕获当前值也可以把值绑定到一个外层函数参数中def make_func(x): return lambda: x funcs [make_func(i) for i in range(3)]我比较推荐第二种可读性更强。这里关键要理解闭包捕获的是变量绑定不是值。只要这个变量后续被重新赋值闭包读取到的就是最新值。这不算bug是语言特性但用不好会让运行结果“反直觉”。3. 容器操作的三个边界越界、缺键、遍历时改结构3.1 IndexErrorlen()-1和负索引IndexError: list index out of range太常见了尤其是循环取相邻元素、读取列表最后一个元素时。比如items [1, 2, 3] for i in range(len(items)): print(items[i 1])当i 2时items[3]越界。很多人在range(len())里习惯性1却忘了最后一个元素没有下一个。更稳妥的写法是用for i in range(len(items) - 1)或者直接用zipfor current, next_item in zip(items, items[1:]): print(current, next_item)另外读取最后一个元素时很多人用items[len(items)-1]。其实Python的负索引专门干这个items[-1]就是最后一个。[-2]是倒数第二个。但负索引也有坑如果列表为空items[-1]一样会越界。所以使用前先判断if items是基本习惯。3.2 KeyError用setdefault还是defaultdictKeyError通常发生在字典上。你想要取一个可能不存在的键直接d[key]然后程序就炸了。Python的字典不像列表有负索引这种“灵活性”它只能选择“报错”或“返回默认值”。最简单的处理是用getvalue d.get(key, default)如果希望自动创建这个键可以用setdefaultd.setdefault(key, []).append(1)defaultdict也是一种选择但它要求导入collections并且会给“不存在的键”直接当作访问存在来处理from collections import defaultdict d defaultdict(list) d[new_key].append(1)我在实际项目中不太喜欢用defaultdict因为它对“只读”场景也自动创建新键有时会掩盖逻辑错误。比如你本来打错了键名它会静默创建一个新键导致数据里冒出奇怪的空列表。所以更倾向用get或setdefault至少访问不存在的键时行为是明确的。3.3 for循环里删除元素跳项和RuntimeError这也是一类经久不衰的坑。想从列表里删除偶数lst [1, 2, 3, 4, 5] for num in lst: if num % 2 0: lst.remove(num) print(lst)你可能以为结果是[1, 3, 5]实际运行结果是[1, 3, 5]等等我实际试过这个列表[1,2,3,4,5]遍历到2时删除2列表变成[1,3,4,5]循环继续时for内部索引从当前位置继续原本索引1位置的元素变成了3但循环已经访问过索引1了下一个访问的是索引2也就是4。于是4被跳过了最后结果是[1,3,5]。这里刚好因为数字巧合看起来对但换一组[1,2,2,4]就会出问题。真正麻烦的是删除一个元素后后续元素全部前移遍历索引却还在原位置导致跳项。如果同时删多个还可能报RuntimeError: dictionary changed size during iteration因为有些容器的迭代器会检测结构变化。正确做法是收集要删的元素二次遍历删除to_remove [x for x in lst if x % 2 0] for x in to_remove: lst.remove(x)或者更高效直接用列表推导重建lst [x for x in lst if x % 2 ! 0]我强烈推荐用列表推导来解决“筛选”问题。这既能避免跳项又能减少很多不必要的临时变量。工作中看到有人在循环里反复remove我一般都会建议改成推导式。4. 类型与复制、、is三种语义别混淆4.1 比较内容is比较身份与is的区别是Python面试必问题但即使工作了几年的人偶尔也会栽在这上面。比较的是两个变量的值是否相等is比较的是两个变量是否指向同一个对象。a [1, 2, 3] b [1, 2, 3] print(a b) # True print(a is b) # False两个内容相同的列表对象在内存中是两个实例所以is是False。反过来is为True时一定为True吗不一定。如果两个对象内容不等但指向同一个对象那这个对象就不能同时不等所以指向同一对象时值必然相等。但有些自定义对象的__eq__可能会返回奇怪的结果不过那是另一个话题。在代码里我应该什么时候用is判断单例对象比如None推荐用is None判断内置小整数或常用字符串时is的结果受Python内部缓存影响行为不可控所以永远不要用is去比较数字或字符串值。我见过有人写if status is success这是不推荐的。正确写法是if status success。如果对象是自定义类的实例你只想比较“是否是那个单例对象”才用is。4.2 浅拷贝的坑改一个全变赋值拷贝的是引用不是对象本身。如果你不想要两个变量指向同一个列表就得用list.copy()或copy.copy。但真正藏坑的是浅拷贝对于嵌套列表浅拷贝只复制了外层容器内层列表仍然是同一个对象。import copy outer [[1, 2], [3, 4]] inner outer.copy() inner[0][0] 99 print(outer) # [[99, 2], [3, 4]]这里outer.copy()创建了新外层列表但里面的子列表没有复制导致修改子列表会影响到原列表。如果希望完全独立要用深拷贝deep copy.deepcopy(outer) deep[0][0] 100 print(outer) # 还是原来的deepcopy会递归复制所有对象代价是时间和内存。实际场景中比如把一份配置模板复制多份然后各自修改这时候想清楚是浅拷贝还是深拷贝特别重要。我一般遵循一个原则只拷贝一层数据时用切片或copy()处理嵌套结构时先问自己“会不会修改内层元素”会的话就直接deepcopy。4.3 类型转换和整数除法Python3的精确除法Python3里两个整数相除/结果是浮点数//才是整除。这一点从Python2转过来的人特别容易踩。比如5 / 2结果是2.5不是2。如果你想要整数结果就得5 // 2。但要注意负数整除是向下取整-5 // 2得到-3因为整除是“结果向下取整到最小整数”不是“向零取整”。这会让很多人意外。另外是类型转换的隐形错误。比如字符串和整数相加count 3 print(次数: count) # TypeError: can only concatenate str这里需要显式转换print(次数: str(count))或者用f-string。f-string是Python3.6最实用的特性推荐优先使用print(f次数: {count})还有一种是整数和布尔值的混淆。True和False在Python里是bool类型但bool是int的子类True 1是真的。如果有人不小心把布尔变量和数字相加结果可能会很怪。5. 函数默认值和异常处理隐藏状态的来源5.1 可变默认参数x[]的共享列表这是经典中的经典。定义函数时如果默认参数是可变对象比如列表、字典、集合那么函数多次调用会共享同一个对象。def append_item(item, container[]): container.append(item) return container print(append_item(1)) # [1] print(append_item(2)) # [1, 2]第二次调用时container已经不是空列表了而是上一次残留的[1]。原因是默认参数在函数定义时创建一次之后每次调用如果没有传这个参数都会直接复用同一个对象。解决方案是老生常谈的默认参数用None在函数体内重新初始化为空列表def append_item(item, containerNone): if container is None: container [] container.append(item) return container我写代码时一直遵循这个习惯哪怕短期看起来没问题也坚持用None作为可变默认参数的标准写法。因为这类bug不会立刻爆发而是会在某个时间点突然发现数据错了而且很难定位。5.2 裸except和低异常位把错误吞掉try...except是我们处理异常的主要手段但很多人喜欢写try: result risky_operation() except: pass这几乎是“静默吞错”的教科书写法。except:会捕获包括KeyboardInterrupt、SystemExit在内的所有异常一旦发生问题程序不会崩但也不会告诉你出了什么问题后续执行可能带着错误状态继续跑最终产出一个错误结果查起来非常痛苦。更糟糕的是except Exception然后pass虽然比裸except好一点但盲目吞掉异常依然会让问题藏起来。我建议的异常处理原则是尽量捕获具体异常类型比如except FileNotFoundError而不是笼统的Exception。捕获后至少要logging.error记录一下异常详细信息不要什么都不做。如果确实要忽略某种异常至少用except specific_exception as e: logger.debug(...)让日志里能查到。比如读文件时try: with open(data.txt) as f: content f.read() except FileNotFoundError as e: print(f文件不存在: {e}) else: print(content)else子句在try没有异常时执行这样可以避免在try里放大范围也让代码意图更明确。5.3 文件没关闭和编码错误用with和encoding忘记关闭文件在并发高、文件句柄多的场景下会导致OSError: [Errno 24] Too many open files。现在Python官方推荐用with语句打开文件因为它会在代码块结束时自动关闭无论是否异常。with open(data.txt, r, encodingutf-8) as f: content f.read()这里有两个容易踩的点。第一不指定encoding时Python在Windows上默认用本地编码比如cp936或gbk如果文件是UTF-8编码读取时会报UnicodeDecodeError。第二写入文件时如果不指定newlineWindows下可能会有\r\n被替换等问题。所以打开文本文件时我总是显式写encodingutf-8。处理UnicodeDecodeError时除了确保编码正确偶尔还要处理文件里混有非法字节的情况。可以用errorsreplacewith open(data.txt, r, encodingutf-8, errorsreplace) as f: content f.read()这样会把无法解码的字节替换成虽然内容有损但至少程序不会中断。先跑通再处理特殊字节是排查编码问题时的实用次序。6. 最后一点排查思路写了这么多具体错误最后想分享一个我实际排错时的通用思路。看到报错信息不要立刻去改代码先做三件事第一读完整报错包括文件名、行号和具体异常类型特别是在大型项目里真正的报错原因可能在调用链底层第二把报错行及相关变量值打印出来不要靠猜打印比想象中可靠得多第三把问题最小化试着用一个最简单的方式复现同样的报错很多时候自己写着写着就发现原因了。我见过太多人遇到KeyError就往上一行添加if key in d却不去想为什么这个键会缺失。遇到AttributeError就以为是自己的对象没有这个属性却忽略了可能是前面的赋值覆盖了这个属性。所有这类表面问题背后都有一个代码逻辑或数据结构设计上的原因把它揪出来比反复加判断要有价值得多。Python的十大常见错误说到底都是语言特性和人类习惯之间的摩擦。理解报错背后的设计原因比记忆“报错怎么办”更重要。遇到一个坑解决后顺手写下触发条件和解决思路长期下来你的排错速度会远超那些只看报错不看本质的人。
阅读完成 · 觉得有帮助?
咨询建站