ARTICLE DETAIL

资讯详情

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

Python异常处理与文件操作实战:从基础语法到脚本稳定运行

Python异常处理与文件操作实战:从基础语法到脚本稳定运行 我至今记得第一次在真实项目里遇到“文件操作挂了”时的场景。那是一个自动拉数据的脚本每天凌晨去读取前一天生成的报表结果某天早上发现数据缺失——原因是报表文件名里多了个空格程序直接崩了异常信息沉在日志里没人看。后来我把那个脚本改成“异常处理 文件操作”双管齐下再也没出过一起“半夜掉线”的事故。在Python里异常处理和文件操作是两门基础课但很多人把它们分开学却不明白它们在实际项目里几乎永远是同时出现的。无论你是刚装好Python准备写第一个正经脚本还是已经写过几百行却总是被文件报错搞得头大都会发现文件系统可能出任何问题——文件不存在、权限不够、文件被占用、磁盘满了、编码不一致。而Python的异常处理机制正是你为这些“必然会出现的问题”做准备的方式。我今天想用一个实战视角把try/except、with、路径处理、编码、批量文件操作这些点串起来聊透适合刚学完Python语法、准备写真实脚本的初学者也适合写了一些代码但总被各类文件报错卡住的朋友。1. 为什么每个文件操作背后都应该有一套异常处理1.1 文件系统是这个世界上最不靠谱的“外部依赖”你可以把程序运行时的文件系统想象成一个公共车库不是只有你的车在里面别人可能占了你的车位管理员可能今天不给你开门你的钥匙可能打不开那个锁车库还可能在某个角落漏水把你的车泡了。你没法控制这些你能控制的只有“车子出了问题之后自己该怎么处理”。文件操作也一样。以下这些异常几乎每个写文件脚本的人都遇到过FileNotFoundError文件不存在可能路径写错可能文件被删了PermissionError权限不够或文件被其他程序锁定UnicodeDecodeError / UnicodeEncodeError编码不对读出来乱码写进去报错IsADirectoryError你试图把一个目录当文件打开OSError的各类子异常磁盘满、I/O错误、路径过长……在实际项目里这些异常不是“偶发”是“必然”。只要脚本跑得足够久、处理足够多的机器和文件早晚会碰到。我这几年维护数据处理脚本最大的体会就是不处理异常的文件操作等于把自己裸奔在线上。1.2 异常处理不是“防止出错”而是“决定出错后怎么办”很多初学者对异常处理的理解是try一下就不报错了。这是误解。try/except从来不是让程序“不报错”而是让程序“在报错时按照你预设的方式继续干活或安全退出”。比如读取一批文件其中一个文件编码不对。没写异常处理时整个程序崩掉后续几十个文件全部来不及处理。写了异常处理时你可以选择跳过这个文件、记录这个文件、用一个默认值替代、继续处理下一个。这几种行为完全由你决定而不是由Python解释器替你做“终止程序”这个决定。这背后是Python的一种理念宁可显式地出错也不要静默地继续。真实项目里最恐怖的不是报错而是“没报错但结果是错的”。所以你既不能裸奔不处理异常也不能瞎吞except后什么都不干。1.3 try/except/else/finally的完整认知先把最常用的结构写出来用一个读配置文件的例子try: with open(config.yaml, r, encodingutf-8) as f: content f.read() except FileNotFoundError: print(配置文件不存在使用默认配置) content except PermissionError: print(没有权限读取配置文件使用默认配置) content else: # try块没有异常时才会执行 print(配置文件读取成功) finally: # 无论是否异常都会执行这里通常放必须收尾的操作 print(结束配置加载流程)这个结构里四个关键词各有分工try把可能出问题的代码放进来except捕获并处理指定类型的异常else没有异常时执行用来放“只有一切正常才该做的事”finally无论有没有异常都要执行用来放“释放资源、关闭连接”这类善后很多人不知道else的用途其实它很实用当你只想在“没有异常”时做某件事又不想让这件事被前面的异常捕获逻辑误伤时用else最合适。比如上面的例子如果不用else你可能会在try的最后一行直接print但那样如果print本身抛了异常虽然print很少抛也会被except捕获。这里的“为什么”是else能帮你缩小except的作用范围让异常处理更精准。异常处理的作用范围越小程序就越可控。1.4 except后面到底该写什么一个常见的错误写法是except:或者except Exception:。except:会捕获所有异常包括KeyboardInterrupt你按CtrlC想中断程序和SystemExit程序主动退出。如果你把它们也吞了你会发现自己无法正常中断程序——这是很吓人的。所以除非你真的知道自己在干什么否则不要用裸except。except Exception:比裸except好一些因为它至少放过了KeyboardInterrupt和SystemExit但仍然太宽泛。“到底该捕获什么异常”是个很讲究的活。我的经验是能捕获具体异常就捕获具体异常比如FileNotFoundError、PermissionError确实需要统一兜底时用except Exception并记录异常信息尽量避免捕获BaseException更不要裸except来看一个对比# 不好的写法想处理所有情况结果把退出指令也拦了 try: process_file() except: pass # 不好的写法捕获太宽且不记录 try: process_file() except Exception: pass # 比较合理的写法 try: process_file() except FileNotFoundError: log.error(文件不存在) except PermissionError: log.error(没有权限请检查文件权限) except Exception: log.exception(处理文件时发生未知错误)后面我会专门说“记录异常”这个动作为什么重要。2. 异常类型层级与自定义异常从会用到会用对2.1 异常层级的那些事Python的异常是一个树状层级顶层是BaseException下面分成两大类被except Exception捕获的实现类以及像KeyboardInterrupt、SystemExit这种不该被随便捕获的。Exception下面才是我们日常见到的FileNotFoundError、ValueError、KeyError、TypeError等等。理解这个树状结构最大的价值在于捕获父类异常时会连子类一起捕获。比如except OSError能捕获FileNotFoundError、PermissionError它们都是OSError的子类。这导致了一个需要特别注意的问题如果用except Exception兜底你要知道它什么都兜包括ValueError、TypeError这样的编程错误。所以我的建议是编程错误也就是代码本身的bug应该尽早暴露而不是被异常处理吞掉。用except Exception时至少要把异常信息记录下来方便后续排查。2.2 多except的执行顺序try: number int(abc) except Exception: print(捕获到Exception) except ValueError: print(捕获到ValueError)上面这段代码里ValueError那段永远不会执行因为Exception在它前面被匹配了。异常捕获是自上而下匹配一旦命中就不再往后找。所以要先写具体的异常再写宽泛的异常。这个顺序问题我在Review别人代码时经常见到。写成上面的样子你可能会想反正Exception兜底了ValueError不执行也无所谓。但在真实场景里如果你本来想在ValueError分支做特殊处理比如让用户重新输入而它永远不执行程序的行为就会跟你预期的不一样。正确顺序try: number int(abc) except ValueError: print(捕获到ValueError) except Exception: print(捕获到Exception)2.3 什么时候需要自定义异常自定义异常不是炫技它在两类场景里非常有用一是库和框架的设计。如果你写一个给别人用的模块你希望在模块内部出现问题时抛出一个语义明确的异常让调用方能够精准捕获。比如定义一个ConfigError比调用方去捕获FileNotFoundError或KeyError更清晰。二是大型项目的错误分层。项目里可以定义基类异常再派生子异常。调用方只需要捕获基类就能统一处理一类问题。自定义异常很简单import os class ConfigError(Exception): pass def load_config(filepath): if not os.path.exists(filepath): raise ConfigError(f配置文件不存在: {filepath})然后调用方try: load_config(app.yaml) except ConfigError as e: print(f加载配置失败: {e})2.4 raise、raise from与异常链在except块里有时候你不仅要处理异常还想把它重新抛出去或者把它转换成另一种异常。这时会用到raise和raise from。raise直接重抛当前异常保留原始堆栈适合“我先记一笔日志然后继续让上层处理”的场景。raise ... from ...适合“转换异常类型但保留原因”的场景try: with open(data.csv, encodingutf-8) as f: data f.read() except UnicodeDecodeError as e: raise ValueError(data.csv不是UTF-8编码内容无法读取) from e这样上层看到的异常是ValueError但通过__cause__还能追溯到原始的UnicodeDecodeError排查起来不会丢线索。这个“异常链”概念刚开始用不上但当你维护一个多层的项目时你会感谢它。3. 文件操作的核心open、with、路径、编码、目录3.1 open的基本功模式、编码、路径open()是Python内置的文件打开函数签名是open(file, moder, encodingNone, ...)。很多教材只写mode和encoding但实际使用里这三样都能坑人。模式部分常用几个模式含义注意事项r只读文件不存在会抛FileNotFoundErrorw写入覆盖文件存在则清空不存在则创建a追加文件存在则在末尾追加x独占创建文件已存在会抛FileExistsError适合防止覆盖r读写不会清空文件指针在开头w读写覆盖会清空文件b二进制模式与上面组合如rb、wb我在项目里最常用的组合是读文件用r写文本用w或a写二进制用wb需要防止误覆盖时用x。这个x模式很多新手不知道但它特别适合“这个文件必须是我新建的不能被覆盖”的场景。encoding部分一般指定encodingutf-8。Python 3里open默认编码是locale相关的在Windows上可能是GBK在Linux上通常是UTF-8这就导致同一个脚本换台机器跑可能突然乱码。解决办法就是显式指定encoding不要依赖系统默认值。这个坑我下面会详细说。路径部分有两个常见坑一是Windows路径反斜杠。写open(C:\Users\name\file.txt)时\U、\n之类会被当成转义字符。最简单的方式是用正斜杠open(C:/Users/name/file.txt)或者用raw stringopen(rC:\Users\name\file.txt)。我推荐用pathlib后面细说。二是相对路径的工作目录问题。相对路径是相对“当前工作目录”的而当前工作目录不一定是脚本所在的目录。如果你在项目根目录启动脚本脚本里又写了open(data/file.txt)这个路径是相对项目根目录的。但如果脚本在子目录里启动就可能找不到文件。最好的做法是用pathlib.Path(__file__).parent来定位脚本所在目录再拼接相对路径。3.2 with语句为什么它比open/finally强# 不推荐的写法 f open(test.txt, w) f.write(hello) f.close() # 推荐的写法 with open(test.txt, w, encodingutf-8) as f: f.write(hello)不用with的问题在于如果f.write()那行抛了异常close()就不会执行文件句柄泄漏。句柄泄漏的后果是文件一直处于被占用状态Windows上可能无法删除或重命名这个文件大量脚本反复打开不关闭最终会耗尽文件描述符。with语句的原理是上下文管理器。open()返回的对象实现了__enter__和__exit____exit__里自动调用了close。也就是说无论with代码块内部是否抛出异常退出时都会自动关闭文件。它是Python里“资源自动清理”的典型机制也适用于网络连接、锁对象等场景。3.3 读取和写入的细节读取文件有几种常见方式f.read()一次性读全部内容返回字符串。适合小文件。f.readline()一次读一行适合逐行处理。f.readlines()读全部行返回列表。适合需要随机访问行的场景。for line in f:文件对象本身是迭代器逐行迭代内存友好。这是我最推荐的读取大文件的方式。写入方面f.write(string)不会自动换行要自己加\nf.writelines(list)也是写入的每个元素之间不会自动加分隔符需要确保每个元素自带换行符。刚入门时以为writelines会自动加换行结果全都粘在一起这个坑几乎人人踩过。另外写入文本和二进制有一个重要区别二进制模式必须传bytes不能传str。比如f.write(bhello)如果你写的是hello会报TypeError。这个在爬虫下载文件、处理图片时特别常见。3.4 目录操作从os.path到pathlib处理文件和目录老方法是用os和os.path模块新方法是Python 3.4的pathlib。我强烈建议新代码直接上pathlib原因有三路径拼接用/运算符比字符串拼接更可读Path(data) / file.csv / sub。跨平台自动处理正反斜杠问题。提供了一整套文件操作方法如.exists()、.is_file()、.mkdir()、.glob()等。from pathlib import Path base_dir Path(__file__).parent data_dir base_dir / data report_file data_dir / report.csv if not data_dir.exists(): data_dir.mkdir(parentsTrue, exist_okTrue) for file_path in data_dir.glob(*.log): print(file_path)你可能会问os模块还学不学当然可以学因为老项目里全是os.path代码你读得懂别人的代码也很重要。但自己写新代码时能用pathlib就用pathlib代码更简洁还不容易踩路径分隔符的坑。操作os模块写法pathlib写法拼接路径os.path.join(data, file.txt)Path(data) / file.txt判断存在os.path.exists(path)Path(path).exists()取文件名os.path.basename(path)Path(path).name列出指定后缀文件os.listdir 字符串匹配Path(dir).glob(*.log)创建目录os.makedirs(path, exist_okTrue)Path(path).mkdir(parentsTrue, exist_okTrue)4. 实战批量处理文件异常处理怎么救人4.1 一个现实的场景假设你有个目录里面放着几十个日志文件每个文件一行行记录操作日志。你需要写一个脚本扫描目录下所有.txt文件统计每个文件里有多少行包含ERROR把结果汇总成一个report.csv并且在处理单个文件出错时不能中断整个流程最后把出错的文件名记录下来。这个场景特别适合演示“异常处理文件操作”的组合拳因为文件系统里几乎什么错都可能出现某个文件是空的、编码不是UTF-8、某个文件没权限、甚至目录下混进了一个非txt文件。4.2 第一步遍历文件用pathlib遍历from pathlib import Path log_dir Path(logs) report_path Path(report.csv) files list(log_dir.glob(*.txt)) print(f发现 {len(files)} 个txt文件)4.3 第二步带异常处理的读取error_count_total 0 failed_files [] error_count_per_file {} for file_path in files: try: with file_path.open(r, encodingutf-8) as f: count 0 for line in f: if ERROR in line: count 1 error_count_per_file[file_path.name] count error_count_total count except FileNotFoundError: failed_files.append((file_path.name, 文件不存在)) except PermissionError: failed_files.append((file_path.name, 无权限)) except UnicodeDecodeError as e: failed_files.append((file_path.name, f编码错误: {e})) except Exception as e: failed_files.append((file_path.name, f其他错误: {e}))这里有几个细节值得说逐行迭代比f.read()内存友好日志文件可能很大。with file_path.open(r, encodingutf-8) as f:可以自动关闭文件。捕获多个具体异常保证一个文件出错不影响其他文件。4.4 第三步写入报告with report_path.open(w, encodingutf-8-sig, newline) as f: f.write(文件名,ERROR数量\n) for file_name, count in error_count_per_file.items(): f.write(f{file_name},{count}\n) if failed_files: print(以下文件处理失败) for file_name, reason in failed_files: print(f{file_name}: {reason})写CSV时特别注意编码和换行。Excel打开CSV时用UTF-8可能变成乱码因为Windows上Excel默认用ANSI。一个实用的技巧是写入UTF-8 with BOM也就是encodingutf-8-sig这样Excel打开时就能正确识别。这里我用newline是为了防止在Windows上写入CSV时出现多余空行这是个非常典型的坑不加这个参数每条记录后面会多一个空行。4.5 为什么这样设计这个脚本的设计原则有三个单个文件失败不中断整体任务失败信息可见而不是被吞掉或默默跳过结果落盘方便后续自动化处理实际工作中你可能还要考虑先把失败文件写到一个failed.txt里第二天再来处理或者失败超过N个就直接报警。这些都可以在这个骨架上扩展。5. 我踩过的文件操作与异常处理的那些坑5.1 编码问题的完整排查链路有一次跑脚本读取一个从Windows导出的CSV控制台直接报UnicodeDecodeError: utf-8 codec cant decode byte 0xd6 in position 0: invalid continuation byte。我当时第一反应是文件坏了后来一想0xd6这个字节序列在GBK里对应的是“中”字的某个字节。问题根本不在文件而是文件是GBK编码的。排查思路可以这样走用file命令Linux或Hex编辑器看文件头/字节风格用不同的encoding去试探读取找到不乱码的那一种读取时统一转成UTF-8保存以后不要再猜Python的chardet或charset-normalizer库可以自动检测编码但检测有概率不准关键场景还是要知道源头编码。5.2 把异常吞掉之后的诡异Bug我曾经在一个脚本里写过这样的代码try: result parse_data() except Exception: print(解析失败)看起来没问题解析失败就打印提示。但问题在于“解析失败”的原因可能有很多如果只是打印而不记录具体异常信息第二天发现问题时根本无从排查。而且这个print出现在循环里刷屏刷得很快日志里全是“解析失败”没什么用。正确的做法是至少记录异常类型和消息最好用logging模块或者traceback.format_exc()把完整堆栈记录下来import logging logger logging.getLogger(__name__) try: result parse_data() except Exception: logger.exception(解析数据失败)logger.exception会自动附带当前异常信息这是排查问题的大杀器。这个坑是“吞异常”最常见的形式不记录、不抛出、只打印一句看起来像是处理了的话。5.3 Windows文件被占用的权限问题你在Windows的Excel里打开了一个xlsx文件然后写个Python脚本去删除它十有八九会报PermissionError。这不是脚本问题而是文件被另一个程序锁定了。处理思路不是硬删而是提示用户关闭占用该文件的程序或者等待重试或者使用os.replace把文件挪到临时位置再删除但锁定的文件也未必能挪这个场景特别能体现异常处理的另一个价值给用户一个清晰的提示而不是让程序崩溃后留下一句不知所云的OSError。5.4 大文件处理的性能与稳定性用f.read()读一个几GB的文件内存直接爆掉。用for line in f逐行读内存占用就很稳。如果文件行特别长比如一行几MB可以考虑用f.readline(1024)之类分块读取。另外当脚本处理几千个文件时每个文件的打开关闭都做对系统才不会出现“Too many open files”的错误。这个在Linux上尤其明显。6. 新手最容易忽略的五个细节6.1 文件对象是可迭代的新手会用readlines()拿列表然后遍历。其实直接用for line in f更省内存行为也完全一样。这个细节很多人用了一年才发现。我见过有人为了“保险”先把整个文件读进内存再去做字符串处理结果一个几百MB的文件把服务器内存吃了一半。用迭代器逐行处理写起来更简洁跑起来更稳。# 不推荐 with open(big.log, encodingutf-8) as f: lines f.readlines() for line in lines: process(line) # 推荐 with open(big.log, encodingutf-8) as f: for line in f: process(line)6.2 写入时把需要的目录先建好open(report/data.csv, w)会在report目录不存在时报FileNotFoundError。你要先Path(report).mkdir(parentsTrue, exist_okTrue)。这个坑看起来很小但在自动化任务里第一次跑就直接中断了。parentsTrue表示连父目录一起建exist_okTrue表示目录已经存在时不报错。如果你担心目录名有拼写问题可以先print(data_dir)看看解析出来的完整路径。6.3 使用with打开多个文件可以这样with open(a.txt) as f1, open(b.txt) as f2:同时管理两个文件。不要嵌套好几层with代码会很难看。Python 3.10还支持with (open(...) as f1, open(...) as f2):的括号写法更清晰。比如合并两个文本文件with open(a.txt, encodingutf-8) as f1, open(b.txt, encodingutf-8) as f2, open(merged.txt, w, encodingutf-8) as out: for line in f1: out.write(line) for line in f2: out.write(line)6.4 异常对象不只是给你看的except Exception as e: print(e)只是把异常消息打印出来但e.args、e.__cause__、e.with_traceback()这些属性里藏的信息才是排查的关键。真的排查复杂问题时用traceback模块处理比只打印一个e有用得多。举个例子如果你在处理配置文件时遇到KeyErrorstr(e)只会告诉你哪个key不存在但e.__traceback__可以告诉你具体是哪一行调用链出了问题。遇到诡异Bug时完整堆栈几乎是无价的线索。所以我在自己的脚本里几乎都用logging.exception要么就保留raise很少让异常信息只“打印一句话”就结束。6.5 别把except当if用有一个很常见的错误用异常处理来控制逻辑流程。比如检查文件是否存在用try: open(); except: not_exists而不是先用os.path.exists()判断。异常处理的开销比普通判断大而且它会捕获比你预期更多的异常。能用条件判断明确表达的就先用条件判断。# 不推荐 try: f open(config.yaml) except FileNotFoundError: use_default() else: use_file(f) # 推荐 if Path(config.yaml).exists(): use_file(Path(config.yaml)) else: use_default()当然这也不绝对。如果文件在判断之后、打开之前可能被删除那么用异常处理也能起到兜底作用。关键是别把异常机制当成普通的if分支用应该让异常处理只处理“真正的意外情况”。6.6 批量改动前先备份血的教训有一次我在写一个清理脚本要批量把某些日志文件里的敏感信息打码后覆盖回原文件。因为没做备份结果一个正则写错了把一堆文件的内容全给清空了。那一刻我意识到任何批量写文件的操作之前都应该先备份原文件或者先输出到新目录验证无误后再覆盖。具体做法可以是这样import shutil src_dir Path(logs) backup_dir Path(backup) backup_dir.mkdir(exist_okTrue) for file_path in src_dir.glob(*.log): shutil.copy2(file_path, backup_dir / file_path.name) # 再继续你的处理这个习惯不需要花很多时间但能避免很多“手滑”事故。我在公司里也见过不少同事因为少了这一步最后只能靠Git找回文件——但有些文件根本没进Git。我在实际项目中摸爬滚打这些年后最深的体会就是Python基础语法只是起点异常处理和文件操作才是写真实脚本的第一道坎。说句实在话把这两个主题学扎实能帮你省下大半调试时间。如果你现在刚开始学建议找两三份真实文件比如Excel导出的CSV、系统日志、爬虫抓下来的网页用文中的代码骨架去处理一遍亲手把异常和文件操作“打断骨头连着筋”地练一练。等你能做到“项目直接跑、文件随便丢、报错不慌”的时候Python这门语言在你手里才算真正活了起来。
返回列表