ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python异常处理实战:try/except/finally与自定义异常全解析

Python异常处理实战:try/except/finally与自定义异常全解析

有一次线上服务在凌晨三点报警,排查到最后发现问题特别简单:某个字典取值时,键不存在,直接抛了KeyError。日志里只看到一行堆栈,连上下文都没记录。因为程序没有做异常处理,整个请求就中断了。当时我就在想,如果写代码的时候多花五分钟把异常处理写好,可能那晚根本不用爬起来。

Python 的异常处理,很多人觉得是“出错了才用的东西”,其实它是一种提前设计的应急机制。这篇文章我按自己带新人时的思路来写,从异常机制本身讲起,再把try / except / else / finally逐个拆开,接着聊主动抛出异常和自定义异常,最后用完整案例带你走一遍实际项目里最常见的异常处理场景。内容对刚学完基础语法、想写出更稳健脚本的读者会特别友好,已经写了几年代码但没系统梳理过异常处理的朋友,也可以当查漏补缺。

1. 先弄明白:异常到底是什么

1.1 报错分两种:一种能救,一种没救

写 Python 的时候,你会遇到两类错误。第一类是语法错误,比如少写一个冒号、括号没闭合,解释器在你运行之前就能发现,直接不让你跑。这种 error 没得救,属于“代码还没出生就夭折了”。第二类是运行时异常,语法没问题,程序跑起来了,但在某一行因为某种条件不满足而中断,比如除以零、列表越界、字典取不到键、文件不存在。这类错误才是今天讨论的主角。

这两者的关系,可以类比成开车。语法错误是车子根本发动不了,你只能修;运行时异常是开着开着仪表盘亮灯了,你可以选择无视然后等着抛锚,也可以提前知道各个灯代表什么问题,在出大事之前靠边停车检查。

1.2 不处理异常会怎样:程序“硬着陆”

如果代码里没有任何异常处理,运行到出错那一行,会发生三件事。第一,程序从出错那一行直接终止,后面的代码全部不会执行。第二,如果之前正在写文件、更新数据库、修改某个全局状态,数据可能停在中间状态,这种半成品数据比没有更麻烦。第三,解释器把一大段堆栈信息(traceback)直接甩到终端,如果这是个跑在服务器上的服务,这段堆栈会进日志,但通常是以一种“只有程序员看得懂、运维同事看到就头大”的形态出现。

举一个最常见的例子:

def parse_age(text): return int(text) age = parse_age("abc") print(age + 1)

这段代码运行后,int("abc")会抛出ValueError,程序立刻终止,后面的print永远不会执行。用户可以理解“年龄必须是数字”这个业务规则,但程序给出的却是一长串技术信息。对用户来说这是“不可用”,对运行环境来说这是“不可控”。

1.3 异常是一种“事件信号”,不是洪水猛兽

深入理解异常,我建议你把它当成一个事件通知,而不是“天塌了”的信号。Python 在运行到不合法状态时,不是“炸掉”,而是“创建一个异常对象,向上抛出”,这个过程在官方文档里叫 raise。它沿着调用栈一层一层往上传递,直到代码里有人用try / except接住它,或者到达最顶层导致程序结束。

这个机制很像家里装的烟雾报警器。报警器响了,不是房子已经烧没了,而是告诉你“检测到异常状态,需要处理”。你的应对方式取决于场景:厨房炒菜油烟大,报警了,打开抽油烟机;真的起火了,赶紧疏散并报警。Python 的异常处理也一样,你不能对任何异常都“假装没听见”,但也不能对所有异常都用同一种方式处理。

2. try / except / else / finally 逐个拆开讲清楚

2.1 try / except 的基本用法与适用场景

异常处理的核心语法很简单:

try: num = int(input("请输入一个整数: ")) print(f"你输入的是 {num}") except ValueError: print("输入的不是整数,请重新运行")

try块里放“可能会出错”的代码。如果出错,出错这一行往后的代码不会执行,直接跳到except块。如果try块一切正常,except块不会执行。这个“出错后跳过剩余代码”的行为一定要记住,否则你会写出下面这种有问题的代码:

try: data = load_data() process(data) # 假如 process 抛异常 save_result(data) # 这一行不会执行 except Exception: log("处理失败")

等下,这里有个经典认知误区。很多新手以为加了try就是“把代码保护起来了”,其实try只是划定了一块“监视区域”,它不保证区域里的每一行都会执行。一旦某行抛异常,控制权立刻跳到except,后面的行不会再执行。设计代码时,你要把try块写得尽量小,只包住“真正可能出错的那一两行”,而不是把整个业务流程都塞进去。

2.2 else 不是可有可无的摆设

try / except还有一个容易被忽略的兄弟:else。它的作用——只有try块全部正常执行完,才运行else块。看这个例子:

try: data = json.loads(raw_text) except json.JSONDecodeError: data = None else: print("解析成功,字段数:", len(data))

如果你图省事,把print直接挪进try,好像功能也差不多。但区别在“边界”。print里的len(data)虽然简单,万一data是个没有实现__len__的对象(比如某个数字),len会抛TypeError。此时如果没有else,这个TypeError会被except json.JSONDecodeError接住吗?不会,因为异常类型不同,它会继续往上抛。但如果你的except写的是宽泛的except Exception,就会把len引发的TypeError也当成“JSON 解析失败”,从而掩盖真正的 bug。

这就是else最大的价值:把“监视区”和“成功后的正常业务区”分开,避免把本不属于监视范围的错误误判。写异常处理时,时刻问自己一句:哪些异常是我想捕获的,哪些异常是我没预料到的?没预料到的,绝不应当被吞掉。

2.3 finally 保证清理动作一定执行

finally块的特殊之处在于:不管try里是成功、是抛出被except捕获的异常,还是抛出没有捕获的异常,finally都会执行。它是“无论如何都要收尾”的块,最适合放关闭文件、关闭数据库连接、释放锁、删除临时文件这类清理动作。

f = None try: f = open("config.ini", encoding="utf-8") content = f.read() except OSError as e: print("读取失败:", e) finally: if f is not None: f.close()

为什么需要finally?因为except不一定会执行,else也不一定会执行,只有finally是“签证必出”。如果你把f.close()写在try块的末尾,万一f.read()抛异常,close()就永远不会执行。文件句柄会一直占着,直到垃圾回收或者程序退出。在长驻进程里,这种资源泄漏累积到一定程度,就是“文件打开过多”的线上事故。

提示:Python 里更优雅的写法是用with open(...),它会自动在退出代码块时关闭文件,内部其实就依赖了类似finally的机制。后面我会专门讲。

3. 一张表看懂常见异常类型与捕获顺序

3.1 内建异常“家族图谱”:常见的九个异常

Python 内建了很多异常类型,入门阶段不需要背全部,但下面这几类最常见的,建议看到后能秒认。

异常类型常见触发场景一句话特征
ValueErrorint("abc")、float("1.2.3")类型正确,但值不合理
TypeError"1" + 1、len(123)类型使用不当
KeyError字典取不存在的键d["x"]容器里没有这个键
IndexError列表超出长度lst[10]下标越界
FileNotFoundErroropen("不存在.txt")文件找不到
ZeroDivisionError1 / 0除以零
AttributeErrorNone.xxx、调用不存在的方法对象没有该属性
StopIteration迭代器耗尽,next(it)取不到值迭代到底了
UnicodeDecodeError用错误编码读取文本文件编码不匹配

注意,这些异常之间是有“血缘关系”的。FileNotFoundError是OSError的子类,UnicodeDecodeError是ValueError的子类,OverflowError也是ArithmeticError的子类。所以捕获异常时可以按“父类”接,但精度会降低。你想区分“文件不存在”和“权限不足”,就不能只except OSError,最好按子类分别处理。

3.2 except 的匹配顺序:从具体到通用

Python 的except是按代码顺序从上往下匹配的,一旦某个异常被第一个匹配到的except接住,后面的except就不会再执行。所以,如果你把“范围大”的异常写在前面,后面的“范围小”的异常就永远没有机会运行。

try: risky_operation() except Exception: print("任何异常都进这里") except ZeroDivisionError: print("永远不会打印这一行")

这段代码不会报错,但它是死逻辑。ZeroDivisionError是Exception的子类,第一个except Exception会先接住它。正确姿势是反过来,把最具体的子类放前面,最宽泛的父类放最后:

try: risky_operation() except ZeroDivisionError: print("除零错误") except (TypeError, ValueError) as e: print(f"参数问题:{e}") except Exception: print("其他未知错误")

写的时候把except Exception当作最后一道兜底防线。它有它的价值,能防止程序直接崩溃,但兜底不能替代精准捕获。

3.3 为什么不要用裸 except

有新手图省事,写except:,不带任何异常类型。这在 Python 里是合法的,甚至会捕获BaseException的所有子类。问题在于,KeyboardInterrupt(用户按了 Ctrl+C)和SystemExit(解释器退出请求)也是BaseException的子类。裸except连这两兄弟也一起捕获了,结果就是:你的脚本明明可以正常被用户中断,现在必须等它把代码跑完才能停。在一些批量任务里,这种设计非常让人抓狂。

更危险的是,一条except:加一个pass,等于把所有错误信息全部吞进肚子里。程序看起来“没崩”,但行为已经完全错误,而且没有任何可排查线索。我自己带团队时定过一条铁律:代码里出现裸except,必须写注释说明原因,否则一律打回重写。

4. 主动抛出异常与自定义异常的正确姿势

4.1 raise 的三种用法

很多时候,异常不是解释器主动抛的,而是我们自己在代码里“主动点燃”。raise有三种常见用法。

第一种,手动抛出一个指定异常:

def set_age(age): if age < 0: raise ValueError("age 不能为负数") print(f"age 设置为 {age}")

第二种,不带任何参数,原样重新抛出当前异常。这个操作叫“重抛”,一般用在“捕获异常后再补充处理,但最终还是交给上层判断”的场景:

try: run_batch() except OSError as e: logger.warning("本次操作遇到系统错误") raise

注意这里不能写raise e,虽然功能看起来一样,但它会重置异常的 traceback 起点。写成raise保留原始调用栈,排查问题时能看到真正的出错位置。

第三种,使用raise ... from ...做异常链转换。比如把底层异常包装成业务异常,同时保留底层原因:

try: price = Decimal("12.34") / Decimal("0.00") except ZeroDivisionError as e: raise ValueError("计算价格失败,存在除零操作") from e

4.2 自定义异常类的实战姿势

当业务复杂到一定程度,内建异常不太够用时,就该定义自己的异常类了。比如写一个账户模块,余额不足就是“业务上的异常”,用ValueError("余额不足")当然也能跑,但调用方以后想精确捕获“余额不足”,就得去解析字符串。

自定义异常一般继承Exception,然后可以带额外属性:

class BalanceNotEnoughError(Exception): def __init__(self, balance, need): self.balance = balance self.need = need super().__init__(f"余额不足:当前 {balance},需要 {need}") def withdraw(amount): balance = 200 if amount > balance: raise BalanceNotEnoughError(balance, amount) return balance - amount

调用方可以这样写:

try: withdraw(500) except BalanceNotEnoughError as e: print(f"还差 {e.need - e.balance} 元")

自定义异常的价值不只是“换个名字”,而是把异常变成一种带数据的对象。当异常被抛到上层去,上层不需要知道底层实现细节,只需要根据异常类型决定策略。一个项目里,自定义异常命名规范、继承层级清晰,调用方写起来会非常舒服。

4.3 assert 和 raise 到底怎么选

assert是 Python 的断言语句,用于检查“这里不可能出错”的前提条件。比如一个函数认为传入的列表绝对不能为空:

def pop_last(items): assert items, "items 不能为空" return items.pop()

注意坑点:Python 运行在-O(优化模式)下时,assert语句会被跳过,完全不做校验。所以它更适合开发和测试阶段,不适合作为生产环境的正式参数校验。生产环境里的入口校验,请老老实实用raise ValueError或raise TypeError。

我的经验是分场景:内部调试和自测逻辑,用assert;对外提供函数接口的入参校验,用raise。两者混用很正常,但不能指望着assert帮你守住线上防线。

5. 实操:一个带异常处理的成绩等级转换模块

5.1 需求、接口和边界

把知识点连起来看一个完整的例子。假设我们要写一个函数convert_grade(score),把 0~100 之间的分数转换成等级:90 及以上为 A,75~89 为 B,60~74 为 C,50~59 为 D,50 以下为 E。需求要求:

  • 传入的不是数字类型(比如字符串、None)时,抛TypeError
  • 传入的数字超出 0~100 范围时,抛ValueError
  • 合法的边界值要正确处理:0、49.9、50、59.9、60、74.9、75、89.9、90、100

5.2 第一版:能跑但会崩

很多人最开始会写这样的版本:

def convert_grade(score): if score >= 90: return "A" if score >= 75: return "B" if score >= 60: return "C" if score >= 50: return "D" return "E"

这个函数在score是数字时逻辑没问题,但一旦有人调用convert_grade("85"),字符串和整数比较会直接抛TypeError;调用convert_grade(None),None和整数比较会抛TypeError;调用convert_grade(-1)或convert_grade(101),函数会返回E或A,但业务上这是非法输入,应该拒绝而不是“糊弄”过去。

5.3 第二版:入口校验加明确异常

改进版本在第一行就把不合法输入挡在外面:

def convert_grade(score): if not isinstance(score, (int, float)): raise TypeError(f"score 必须是数字,收到的是 {type(score).__name__}") if score < 0 or score > 100: raise ValueError(f"score 必须在 0~100 之间,收到的是 {score}") if score >= 90: return "A" if score >= 75: return "B" if score >= 60: return "C" if score >= 50: return "D" return "E"

顺手写一组边界测试,验证各种输入下的行为:

cases = [100, 90, 89.9, 75, 60, 59.5, 50, 0, -1, 101, None, "85"] for c in cases: try: print(f"{c!r} -> {convert_grade(c)}") except (TypeError, ValueError) as e: print(f"{c!r} -> 抛出了 {type(e).__name__}: {e}")

运行结果里,合法输入都能拿到正确等级,非法输入都能得到明确异常。这里有一个隐藏小坑值得提醒:bool类型的True是int的子类,isinstance(True, (int, float))返回True,所以convert_grade(True)会被当作1处理,返回E。如果业务上需要排除布尔值,可以在校验时加额外判断:

if type(score) not in (int, float): raise TypeError("score 必须是数字,不能是布尔值")

5.4 处理一个真实文件场景:配置文件读取

函数传参的校验只是异常处理的一部分,日常代码里更频繁的是“外部资源操作”。比如读取配置文件,文件可能不存在,可能格式错误,可能编码不对:

import json def load_config(path): try: with open(path, encoding="utf-8") as f: return json.load(f) except FileNotFoundError: return {} except json.JSONDecodeError as e: raise ValueError(f"配置文件 {path} 不是合法的 JSON: {e}") from e

这里两个点很关键。第一,用with open(...)而不是手动open/close,即使json.load中途抛异常,文件也会被自动关闭。第二,FileNotFoundError被转成“返回空字典”的默认策略,而JSONDecodeError被包装成ValueError向上抛。同样是异常,处理策略完全不同:一个是“可容忍”,一个是“必须让调用方知道”。

6. 异常处理常见问题与避坑速查

6.1 异常处理速查表

把日常遇到的高频问题整理成一张速查表,写代码时对照着看,能少踩很多坑。

症状可能原因正确做法
程序不报错,但结果完全不对裸except或捕获范围过宽缩小到具体异常类型
用户按 Ctrl+C 无法中断程序裸except捕获了KeyboardInterrupt不用裸except,或显式重新抛出
函数返回值被覆盖finally里写了returnfinally只做清理,不要写return
日志里只有一句话,没有堆栈只打了str(e)用logger.exception或exc_info=True
文件句柄越来越多只在某条分支里close()用with或finally

6.2 五大经典坑,每一个都真实发生过

第一个坑:裸except配pass吞异常。前面讲过危害,这里补一个“它怎么坑你”的场景:某段网络请求代码里,工程师写了个except: pass,结果服务每五分钟静默失败一次,日志完全看不出哪里有问题。后来打开异常日志才发现,是某个第三方接口偶尔返回超时。因为当时把异常吞了,这个“偶尔失败”持续了整整一周才被用户投诉发现。

第二个坑:捕获范围太宽,把程序 bug 也“接住了”。比如代码里写except Exception: return None,本来只想兜住ValueError,结果AttributeError、TypeError这些真正写错的 bug 也全部变成None。上游代码拿着None继续跑,又引发一连串新的问题。异常处理一定要做到“只在知道怎么处理时才捕获”。

第三个坑:finally里写return。以下代码返回结果出乎大部分人意料:

def foo(): try: return 1 finally: return 2 print(foo()) # 输出 2

finally里的return会覆盖try里的正常返回值。很多人写资源清理时习惯顺手 return 一个状态,结果把业务返回值给冲掉了。记住,finally只做关闭、释放、记录这类动作,不要写return。

第四个坑:日志只打异常消息不打堆栈。print(e)只能看到一句“division by zero”,对排查没有任何帮助。要用traceback.format_exc()或者专业日志库的logger.exception(),让堆栈信息完整进日志。这一点在长驻进程里几乎可以决定事故处理时间是五分钟还是五小时。

第五个坑:文件、连接不释放。有人会在各个分支里手动调用close,但某条异常分支漏写了,连接就一直挂着。久而久之,数据库连接池耗尽、文件句柄超限。最稳的方案是用上下文管理器with,或者在finally里统一释放。

6.3 用 EAFP 提升代码健壮性

Python 社区有一个编程风格叫 EAFP,全称“easier to ask for forgiveness than permission”,大意是“直接尝试,出错再求原谅”。对应的另一种风格叫 LBYL,“look before you leap”,意思是“先检查再行动”。

看两种写法取字典值:

# LBYL if "key" in data: value = data["key"] else: value = None # EAFP try: value = data["key"] except KeyError: value = None

EAFP 的写法在 Python 里更受推崇,原因之一是它避免了“检查和使用之间存在时间差”带来的竞态问题。比如你先检查文件存在再打开,检查通过后文件可能立刻被其他进程删掉,打开仍然失败。直接用try包住“打开”本身,反而更直接。当然,EAFP 也不意味着把所有代码都塞进try,控制好监视范围是基本功。

我在实际项目里给自己定过两条硬规矩。第一,except后面永远不写空括号,至少要写明异常类型;第二,捕获到异常后,要么立即处理,要么立即把当前异常重新抛给上层,绝不默默pass。这两条规矩看起来简单,但真的帮我少加了很多班。

最后再分享一个我常用的技巧:写任何可能被外部调用的函数时,把“入参校验”当作第一条防线,在入口处用raise挡住不合法数据。这样函数内部的业务逻辑可以保持干净,不需要到处检查某个值是不是None、是不是负数。入口收一收,内部舒展很多。这个习惯,对入门读者来说,值得在一开始就养成。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进