ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Python异常处理:try-except-finally实战详解

Python异常处理:try-except-finally实战详解 先聊点实际的。写 Python 写了这么多年try-except-finally是最早让我“真香”的语法之一。刚入门时觉得它不过是“出错别崩溃”的补丁写多了才发现异常处理其实是在为代码的边界条件立规矩。它管的不只是程序会不会崩更管的是程序崩了之后资源有没有还回去、日志有没有留下来、调用方能收到一个什么样的错误信号。如果你的代码做过线上运行、脚本调度、数据报表那异常处理的掌握程度直接决定你深夜被电话叫醒的概率。这篇文章我想把try-except-finally从头到尾拆开讲清楚。从最基本的语法结构到except怎么按需捕获、finally到底什么时候执行、什么时候该主动抛异常、自定义异常类怎么设计再到实际项目里常见的坑和排查思路。内容既有面向小白的完整示例也有我踩过几次坑之后沉淀下来的实战经验。觉得有基础的朋友可以直接跳到自己需要的章节但建议还是完整过一遍因为很多细节是看文档不容易发现的。1. 异常处理的底层逻辑与基础语法1.1 为什么必须认真对待异常处理在没有异常处理的世界里程序碰到错误通常只有一种结局进程直接终止控制台甩出一段红色的堆栈信息。对用户来说轻则任务失败重则数据丢失对开发者来说如果这段堆栈信息没有被捕获和记录你连问题发生在哪个环节都无从复原。我常用一个类比来解释异常处理的意义程序里各种操作比如读文件、连数据库、发网络请求就像你让别人帮忙取东西。正常情况下他直接取回来给你但如果电梯坏了、楼门锁了、东西被人拿走了他得有一个明确的“回话”告诉你是哪种问题——而不是整个人消失让你干等或者直接回家睡觉不管你了。try-except就是那套“回话机制”finally则是保证不管任务成不成先把门锁好、把灯关了、把钥匙还回去。理解了这层逻辑再看try-except-finally就不只是记语法了而是在设计一套容错流程什么异常是预期的、什么异常是无法预期但需要兜底的、哪些资源必须无条件释放。下面从最基础的用法说起。1.2 try-except 最基础但要写完整的结构最简单的形式就是try-except两件套负责接住一块代码块里的异常try: number int(input(请输入数字: )) result 100 / number print(计算结果:, result) except ZeroDivisionError: print(错误除数不能为 0) except ValueError: print(错误输入的不是合法数字)这个例子虽然基础但已经包含了两个关键设计点一是只接住你预期内的异常类型比如这里的ZeroDivisionError和ValueError二是每一个except分支对应一种独立的处理逻辑。如果捕获的异常类型过宽比如直接写except Exception你就很难针对不同问题给出不同的提示或修复动作。但要注意上述写法在真实项目中还有个隐患如果number不是数字100 / number那行不会执行但假如用户输入了0ZeroDivisionError被捕获后程序也直接退出了并没有继续往下运行。实际业务里你可能希望出错后重新询问用户或者给一个默认值继续流程。针对这种情况通常要在except分支里做循环重试或设置兜底值下面更完整的示例会在后续操作章节展开。1.3 异常类型体系为什么except Exception不是万能的Python 的异常体系是一棵继承树树的根是BaseException真正的“所有异常的父亲”是它而非Exception。BaseException下面分出几个重要分支其中Exception是最常用的捕获基类绝大多数可预期的运行时错误都继承自它而SystemExit、KeyboardInterrupt、GeneratorExit这三者是BaseException的直接子类并不继承自Exception。这意味着如果你写了except Exception是接不住KeyboardInterrupt用户按了 CtrlC和SystemExit系统调用退出的。这在多数场景下反而是好事——按 CtrlC 本身就应该中断程序不该被普通业务处理逻辑吞掉。但如果你的脚本在自动化调度环境里运行确实需要捕获“用户中断”来做清理就得单独写一个except KeyboardInterrupt分支而且通常放在其他except之前。实际编码中我推荐的捕获策略是分层设计最外层兜底用except Exception用来防止整个程序崩溃具体业务代码块里仍然按具体异常类型分别写except。这样既有精细度也有最后的防线。下表整理了几类最常见的异常类型及其触发场景方便新手对照。异常类型触发场景建议处理动作ValueError类型正确但值非法如int(abc)校验输入值或提示重新输入TypeError类型不匹配如123 456检查变量类型做显式转换FileNotFoundError文件不存在如open(不存在的文件.txt)提示路径错误或创建文件PermissionError权限不足无法读写文件检查文件权限提示管理员授权ZeroDivisionError除数为 0业务里补上分母为 0 的校验KeyError字典访问不存在的键用dict.get()或先判断键是否存在IndexError列表索引越界用切片容错或先判断长度requests.ConnectionError网络请求连接失败重试或降级处理2. 核心细节拆解捕获、传递与资源释放2.1except捕获机制的三个细节except这块的坑最多很多老手偶尔也会栽跟头。第一点except后面接的异常类型是支持元组的但括号里如果只有一个元素别忘加逗号这在旧版本甚至会引发歧义。养成写except (ValueError, TypeError):的习惯注意逗号不能少。第二点捕获到异常对象后要用as e把异常实例取出来否则你拿不到具体的错误信息。看个实操例子try: config load_config(config.yaml) except FileNotFoundError as e: logger.error(f配置文件缺失: {e.filename}) # 这里 e 是异常实例可以取到具体文件路径 except yaml.YAMLError as e: logger.error(f配置文件语法错误: {e})有人会问e到底能干什么除了打印还能用于判断异常发生的位置、读取异常的属性比如FileNotFoundError的filename、OSError的errno甚至在后端接口里把e.args格式化到响应体返回给调用方。不要只习惯except Exception as e: print(e)多做一步排查效率完全不一样。第三点except是按顺序匹配的一旦命中一个分支就不会再往下走。所以异常类型的顺序必须是“子类在前基类在后”。比如except KeyError必须写在except Exception前面否则KeyError永远匹配不到Exception就直接被截胡了。这个顺序规则就是第 4 部分要展开讲的典型坑之一。2.2else子句区分“成功路径”和“异常路径”的优雅写法很多人不知道try-except后面还能跟一个else它的执行时机是try块里没有抛出任何异常时才执行else块里的代码。换句话说else放的是“一切正常时才该做的事”。为什么要单独加一个else而不是直接写在try末尾直接写在try末尾有一个隐患如果try末尾的代码本身也会抛异常它就会被同一个except捕获。比如你打开一个文件后在try里做了数据处理数据处理那行抛了异常except就会误以为“打开文件失败”处理逻辑就错了。用else之后else里的异常不会被前面的except捕获它会继续向上抛给外层处理这样职责边界清晰得多。看一个常见场景——读取配置并解析try: with open(app.yaml, r, encodingutf-8) as f: data f.read() except FileNotFoundError as e: logger.warning(f配置文件不存在使用默认配置: {e}) data else: try: config yaml.safe_load(data) except yaml.YAMLError as e: logger.error(f配置文件格式错误: {e}) config {}在这个例子里except FileNotFoundError只管“文件不存在”else里负责解析YAML解析时如果出错会进入内层第二个try而不会被误判为文件不存在。这种分层写法在真实企业项目里非常常见读代码的人一眼就能分清哪条路径是正常的、哪条路径是异常的。2.3finally的两种真正执行时机finally是try-except-finally三件套的边界担当。它最大的特点是无论try里有没有异常、异常有没有被捕获、except里有没有再抛新异常finally块都会执行。这句话说起来容易但有几个细节值得停下来想清楚。场景一try里没有异常finally会执行场景二try里抛了异常except捕获后处理完finally会执行场景三try里抛了异常但except没接住异常一路向上传递finally依然会在异常抛出去之前执行。第三种场景是很多人忽略的它保证了就算程序马上崩掉资源也能先还回去。来看一个典型场景数据库游标的关闭。如果你用psycopg2或pymysql建立一个游标对象后不管查询成功还是失败游标都要关闭。新手容易写出只在成功路径关闭的代码一旦查询抛异常游标一直开着连接池很快耗尽。用finally就不需要考虑那么多分支了cursor None try: cursor conn.cursor() cursor.execute(sql) rows cursor.fetchall() finally: if cursor is not None: cursor.close()这里顺便说一个关键点finally块里一定不要写return。因为finally的优先级比return还高finally里的return会覆盖try或except里所有return的返回值。这个问题太隐蔽了我在第 4 部分单独展开。3. 实操过程与核心环节实现3.1 主动抛出异常raise的正确打开方式被动捕获之外你还需要主动制造异常。raise的使用场景大致有三类第一类是参数校验失败函数一开始就拒绝非法输入第二类是在except里发现异常后不想吞掉但需要附加一些上下文信息于是重新抛出一个新异常或原样抛出第三类是业务流程中某个状态不应该出现直接抛异常提醒上层程序处理。先说最简单的参数校验和业务状态校验def transfer_money(amount: float, balance: float): if amount 0: raise ValueError(转账金额必须大于 0) if amount balance: raise InsufficientFundsError(f余额不足当前余额 {balance}, 转账金额 {amount}) return balance - amount这里的InsufficientFundsError是我自己定义的一个业务异常类继承自Exception后续会讲。raise之后调用方可以通过try-except捕获这个异常也可以让它继续向上传递。如果不想暴露内部实现细节可以用raise不带参数在已有异常上下文里重新抛出比如except OSError as e: logger.info(重试一次) raise注意这里的raise是原样重新抛出当前正在处理的异常不会丢失堆栈。而raise e虽然看起来一样但会抹掉当前的异常上下文在排查时容易丢失原始调用链。绝大多数情况下except块里直接写raise不带参数才是正解。这也是很多新手常常搞混的点。3.2 自定义异常类一个比想象中有用的工程化习惯自定义异常类并不是炫技而是为了让调用方能够按业务维度去捕获和判断错误。比如你做一个支付系统底层可能既有网络异常又有库存不足还有账户冻结。如果你全部抛Exception调用方就得用字符串匹配去判断那是灾难。将业务错误用不同的异常类表达调用方就能精确捕获比如except InsufficientFundsError和except AccountFrozenError分别走不同的处理分支。自定义异常类的代码非常简单class BizError(Exception): 业务异常基类 def __init__(self, message: str, code: str FAILED): super().__init__(message) self.message message self.code code class InsufficientFundsError(BizError): def __init__(self, message: str): super().__init__(message, codeBALANCE_NOT_ENOUGH) class AccountFrozenError(BizError): def __init__(self, message: str): super().__init__(message, codeACCOUNT_FROZEN)为什么要搞一个基类BizError因为这样你可以在最外层只写一个except BizError as e就能统一捕获所有业务异常并根据e.code返回给前端相应的错误码。如果某天新增了一个RiskControlError只要继承自BizError全局处理逻辑不用改。自定义异常不需要重写__str__方法基类默认就会输出message但如果你想让日志更友好也可以额外实现。3.3 综合案例文件处理中的三层异常复位讲完基础细节串一个接近真实开发场景的完整案例。需求是读取一个配置文件如果不存在就用默认值初始化如果存在但格式损坏则备份损坏文件并重新初始化。整个流程既要保证文件句柄关闭又要保证每一步的错误信息都能在日志中查到。import shutil import yaml from datetime import datetime DEFAULT_CONFIG {workers: 4, retries: 3} BACKUP_DIR backup def load_app_config(path): data try: with open(path, r, encodingutf-8) as f: data f.read() except FileNotFoundError as e: logger.warning(f{path} 不存在使用默认配置详情: {e}) return DEFAULT_CONFIG except PermissionError as e: logger.error(f无法读取配置文件 {path}权限不足: {e}) return DEFAULT_CONFIG else: try: config yaml.safe_load(data) if config is None: return DEFAULT_CONFIG return config except yaml.YAMLError as e: backup_name f{path}.{datetime.now():%Y%m%d%H%M%S}.bak shutil.copy2(path, backup_name) logger.error(f配置文件损坏已备份到{backup_name}: {e}) return DEFAULT_CONFIG finally: logger.debug(load_app_config 执行完毕不存在资源泄漏)这段代码的优点是层次分明外层try-except管理的是“文件是否能打开”else管理的是“打开后内容能否解析”。finally在这里虽然没有手动关闭资源的必要因为用了with语句但你可以把日志审计、计数器等收尾动作放在里面。实际项目中finally里可以放埋点、放耗时统计、放缓存清理而不只是释放资源。3.4 网络请求场景异常处理与重试文件操作之外最常遇到异常的场景就是网络请求。requests库的异常体系比较复杂我一般按“三次重试 降级返回”的模式处理import time import requests def fetch_with_retry(url, timeout5, retries3, backoff1.0): for attempt in range(1, retries 1): try: response requests.get(url, timeouttimeout) response.raise_for_status() # 对 4xx/5xx 主动抛异常 return response.json() except requests.Timeout as e: logger.warning(f第 {attempt} 次请求超时: {url}) except requests.ConnectionError as e: logger.warning(f第 {attempt} 次连接失败: {url}, {e}) except requests.HTTPError as e: status e.response.status_code if e.response else unknown if status in {500, 502, 503, 504}: logger.warning(f服务器 {status} 错误可重试: {url}) else: raise # 4xx 客户端错误直接抛出不重试 if attempt retries: time.sleep(backoff * attempt) raise requests.ConnectionError(f多次请求失败: {url})这个实现里有两个关键点。第一response.raise_for_status()会把 HTTP 状态码变成异常这样 404、500 都会被统一接住。第二raise分情况处理只有 5xx 错误才重试4xx 错误比如 404、403重试没有意义直接抛出让调用方去决定。如果第 4 部分要选一个最值得抄作业的案例我会选这个网络请求重试模板因为它把异常处理和业务判断结合得很好。4. 常见问题与排查技巧实录4.1 陷阱一except捕获顺序不对异常永远不被命中这是我最常给人 review 代码时发现的问题。写两个except分支时很容易把父类写在子类前面try: user users[alice] except Exception as e: logger.error(f未知错误: {e}) except KeyError as e: logger.error(f用户不存在: {e})上面这段代码里KeyError永远捕获不到因为KeyError是Exception的子类第一个except Exception已经把问题接走了。这个问题在语法层面完全合法运行时也不报错但行为的偏差就是静默的。经验是把越具体的异常写在越上面越宽泛的写在越下面兜底的Exception永远放最后一个。4.2 陷阱二在finally里写了return这是finally最阴险的坑。当函数同时包含return和finally时finally里的return会覆盖前面所有returndef func(): try: return 成功 finally: return finally 返回值运行一下就会发现func()的返回值是finally 返回值成功被丢弃了。这会导致调用方拿到一个完全出乎意料的结果而且没有任何报错提示。规避办法很简单finally块里只做清理操作和日志操作绝对不写return。如果你需要在finally里计算一个变量直接修改外部变量即可不要在finally里返回值。4.3 陷阱三捕获异常后直接吞掉日志里什么都没有新手最容易犯的另一个错误是try: risky_operation() except Exception: pass这种写法看似安全实际非常危险。问题不是“有异常就会崩”而是“异常发生了你却完全不知道”。在线上环境一个静默的pass意味着所有问题都指向一个不可追溯的黑洞。即使你是故意忽略某个异常也应该在代码里写清楚原因try: # 清理临时文件失败不影响主流程 os.remove(tmp_path) except OSError as e: logger.debug(f临时文件清理失败可忽略: {e})我个人的底线是except块要么有日志、要么有处理逻辑、要么有raise重新抛出三选一唯独不能直接pass。如果你暂时不知道该怎么处理就先logger.error(fUnexpected error: {e}, exc_infoTrue)保证原始堆栈信息完整落在日志里再逐步细化。4.4 陷阱四异常链断裂排查时完全找不到根源当你捕获except A as e后又抛出raise B时如果不小心原始异常A的上下文就断了。Python 3 引入了异常链的概念你可以用raise ... from ...保留原始异常try: requests.get(http://example.com/api) except requests.ConnectionError as e: raise RuntimeError(无法访问外部服务) from e这样写抛出的RuntimeError会带一个__cause__指向原始ConnectionError。日志里能看到“无法访问外部服务”下面的原始异常堆栈排查链路不会断开。如果只是简单地raise RuntimeError(...)原始异常仍然会出现在__context__中但可读性会差很多。建议在包装异常时养成用from e的习惯这是高阶工程师和低阶工程师代码气质差别最明显的地方。4.5 排查技巧用traceback模块让日志更完整实际项目里直接logger.exception(e)是记录异常最省事的方案。但如果你需要把异常信息发送到监控系统或者想让日志输出更规范可以手动使用traceback模块import traceback try: business_logic() except Exception: error_msg traceback.format_exc() logger.error(f业务处理失败完整堆栈:\n{error_msg}) send_to_monitor(error_msg)traceback.format_exc()返回的是完整的多行字符串包含异常类型、异常信息和所有调用栈帧。这段信息放在日志里哪怕过了几个月也能根据堆栈定位到具体行号。实际排查流程中我一般按三个层次看问题先看异常类型是否匹配预期再看e.args或自定义属性里的业务信息最后看堆栈中是哪一帧触发。还有一个小技巧在开发和调试阶段我会在app入口处设置一个全局的系统异常钩子比如sys.excepthook把所有未捕获异常自动写入一个专门的日志文件避免干扰正常的logging输出。这个方法在长跑脚本、定时任务里尤其管用能帮你抓住那些只在深夜运行的偶发问题。设置方式和注意点是import sys import logging logger logging.getLogger(unhandled) def global_excepthook(exc_type, exc_value, exc_tb): if issubclass(exc_type, KeyboardInterrupt): sys.__excepthook__(exc_type, exc_value, exc_tb) return logger.critical(未捕获异常, exc_info(exc_type, exc_value, exc_tb)) sys.excepthook global_excepthook这里要留意如果你把KeyboardInterrupt也统一拦截了用户在终端按 CtrlC 时就不会正常退出反而会让脚本继续挂在那里。所以拦截时一定要把KeyboardInterrupt放行交给系统默认的钩子处理。最后再分享一个我自己的习惯写任何一段包含资源申请、IO 操作、外部调用的代码时先想三件事——如果这一步抛异常了调用方会看到什么资源是否安全释放日志里能否根据错误信息直接定位出错的参数想清楚这三件事try-except-finally就不再是兜底的补丁而是代码可靠性的一部分。以前我也因为图省事直接让异常冒泡结果线上出问题时日志含糊到根本没法定位只能靠猜。后来痛定思痛在关键路径上把异常处理写完整排查问题的速度明显快了一大截。希望这篇内容能帮你少走这些弯路把异常处理从“写了不报错”推进到“写完能稳跑”。
返回列表