1. 从一道测验题看透文件操作的核心逻辑
很多人第一次接触 Python 的文件读写,都是在类似“测验7:文件和数据格式化”这样的练习里。题目本身可能只是让你统计一个文本里的词频、把字典写进 CSV、或者把一列数据格式化成对齐的输出。但我带了这么多期学习者之后发现一个规律:真正卡住大家的从来不是语法,而是对“数据在磁盘和内存之间怎么流动”这件事没有建立起直觉。
这篇内容我就借这道测验的壳,把文件操作和数据格式化这两块讲透。它适合刚学完 Python 基础语法、正准备跨过“能写脚本”这道坎的人,也适合那些写过一些代码但每次遇到编码报错、路径找不到、格式对不齐就靠搜索引擎救场的人。核心关键词就三个:文件读写、数据格式化、异常处理。把这三个吃下来,你后面处理日志、做数据清洗、写自动化脚本,基本都是一路平推。
先说清楚这道测验通常考什么。根据我见过的多套同类题目,它的考点集中在几个方向:用open()正确打开文件并选择模式、用read/readline/readlines或迭代器读取内容、把处理结果写回文件、用format或 f-string 做对齐和精度控制、以及用csv或json模块做结构化数据的序列化。看起来零散,其实背后是一条完整的数据流水线:读入 → 解析 → 处理 → 格式化 → 写出。你只要把这条线在脑子里立起来,题目怎么变都不慌。
我特别想强调一点:文件操作是 Python 里少数几个“错误代价很真实”的领域。你在内存里算错一个数,顶多结果不对;但你要是用错了模式打开文件,可能直接把源数据覆盖没了。所以这篇我会花不少篇幅讲模式选择、编码处理和异常兜底,这些都是文档里一笔带过、但实际会咬人的地方。
2. 文件读写的地基:模式、编码与路径
2.1 open 的模式到底怎么选
open()的第二个参数是模式,这是所有文件操作的起点。很多人背过那张表,但真到用的时候还是会犹豫。我把常见的几种按“会不会破坏原文件”分成两类,这样记更符合直觉。
| 模式 | 含义 | 文件不存在时 | 是否清空原内容 | 典型场景 |
|---|---|---|---|---|
r | 只读 | 报错 | 否 | 读取配置、日志分析 |
w | 只写 | 创建 | 是 | 生成新结果文件 |
a | 追加 | 创建 | 否 | 持续写日志 |
r+ | 读写 | 报错 | 否 | 原地修改 |
x | 独占创建 | 创建 | 不适用 | 防止覆盖已有文件 |
我踩过最疼的一个坑就是:调试阶段用w打开了一个数据文件想看看内容,结果一运行,文件直接空了。因为w在打开的那一刻就把文件截断了,跟你后面写不写没关系。所以我的习惯是——只要不是明确要生成新文件,一律先用r打开确认能读,再决定下一步。真要覆盖,也先备份一份。
还有一个细节:文本模式(默认)和二进制模式(加b)的区别。文本模式会自动做换行符转换和编码解码,二进制模式则是原样字节。处理图片、压缩包、序列化后的二进制数据时必须用rb/wb,否则会报解码错误。测验里如果让你读一个非纯文本文件,八成就是在考这个。
2.2 编码问题:为什么你的中文变成了乱码
编码是文件操作里最高频的翻车点。默认情况下,open()用的是系统默认编码,在有些环境是 GBK,在有些是 UTF-8。你在一台机器上写好的脚本,换台机器读同一个文件,中文就可能变成一堆问号或者方块。
解决办法很直接:显式指定encoding='utf-8'。这应该成为你的肌肉记忆。
with open('data.txt', 'r', encoding='utf-8') as f: content = f.read()写文件同理,也要指定编码,否则在某些平台上写出的中文文件在别的软件里打开就是乱码。
with open('output.txt', 'w', encoding='utf-8') as f: f.write('处理完成')注意:如果你读的文件本身不是 UTF-8 编码(比如从某些旧系统导出的 GBK 文件),强行用 UTF-8 读会抛
UnicodeDecodeError。这时候要么用encoding='gbk',要么用errors='ignore'跳过无法解码的字节。但errors='ignore'会静默丢数据,只建议在确认可以容忍丢失时使用。
我个人的经验是,处理来源不明的文本文件时,先写个小脚本探测编码,比盲目试要快。可以用chardet这类库,也可以简单地 try 几种常见编码,哪个不报错用哪个。
2.3 路径这件事,别再用字符串硬拼
新手最容易犯的错是用字符串拼接路径,比如'data/' + filename。这在 Windows 上会因为反斜杠和正斜杠的差异出问题。正确做法是用os.path.join或者更现代的pathlib。
from pathlib import Path base = Path('data') file_path = base / 'input.txt' if file_path.exists(): text = file_path.read_text(encoding='utf-8')pathlib的好处是路径就是一个对象,拼接用/运算符,判断存在用.exists(),读文本用.read_text(),写用.write_text(),代码短且跨平台。测验里如果涉及多级目录,用pathlib会省掉很多os.path的样板代码。
另外提醒一句:相对路径是相对于你运行脚本时所在的目录,不是脚本文件所在的目录。这是另一个高频困惑点。如果你在 A 目录运行 B 目录下的脚本,脚本里的相对路径会以 A 为基准。稳妥的做法是用Path(__file__).parent拿到脚本所在目录,再基于它构造路径。
3. 读取方式的选择:一次读完还是逐行处理
3.1 三种读法的适用边界
Python 提供了read()、readline()、readlines()三种主要读法,加上直接迭代文件对象,一共四种。选哪种,取决于文件大小和你后续要做什么。
read():一次性把整个文件读成一个字符串。适合小文件,比如配置文件、几百 KB 的文本。文件大了会吃光内存。readline():每次读一行。适合你只想读前几行,或者需要精细控制读取节奏的场景。readlines():一次性读成列表,每个元素是一行。适合文件不大、且你需要反复按索引访问行的场景。- 直接迭代:
for line in f:,这是最 Pythonic 的逐行读法,内存友好,适合大文件。
我实测过一个 500MB 的日志文件,用readlines()直接把内存吃到几个 G,机器开始卡;换成for line in f:之后,内存占用稳定在几十 MB。所以处理大文件时,迭代是默认选择。
with open('big_log.txt', 'r', encoding='utf-8') as f: for line in f: line = line.strip() if not line: continue process(line)3.2 换行符的处理细节
不管用哪种读法,每行末尾通常带着\n。如果你直接拿去做比较或拼接,很容易出问题。比如'abc\n' == 'abc'是 False。所以读到行之后,第一件事往往是strip()或rstrip('\n')。
但要注意,strip()会把行首行尾的所有空白都去掉,包括空格和制表符。如果你的数据本身行首有意义的空格(比如缩进敏感的格式),就得用rstrip('\n')只去换行。
还有一个坑:文件最后一行可能没有换行符。用readlines()时最后一行不会带\n,用迭代时也一样。如果你的逻辑依赖每行都有换行符,就会在最后一行出错。稳妥的写法是统一strip()后再判断。
3.3 用 with 管理资源,别手动 close
with open(...) as f:这个写法不只是省事,它保证了即使中间抛异常,文件也会被正确关闭。手动f.close()的问题是,一旦close()之前出了异常,文件句柄就泄漏了。在长时间运行的程序里,句柄泄漏最终会导致“打开文件过多”的错误。
# 不推荐 f = open('data.txt', 'r', encoding='utf-8') content = f.read() f.close() # 推荐 with open('data.txt', 'r', encoding='utf-8') as f: content = f.read()测验里如果考文件操作,用with基本是标准答案。养成习惯之后,你几乎不会再写手动 close。
4. 数据格式化:让输出既好看又能被程序读
4.1 字符串格式化的三种武器
Python 的字符串格式化经历了三代:%格式化、str.format()、f-string。现在写新代码,f-string 是首选,因为它最直观、性能也最好。但读老代码时三种都会遇到,所以都得认识。
name = '张三' score = 92.5 # % 格式化(老式) print('%s 的成绩是 %.1f' % (name, score)) # str.format() print('{} 的成绩是 {:.1f}'.format(name, score)) # f-string(推荐) print(f'{name} 的成绩是 {score:.1f}')f-string 里可以直接写表达式,比如f'{score * 1.1:.2f}',这在拼装复杂输出时非常方便。
4.2 对齐与宽度控制
测验里经常要求输出对齐的表格,比如左对齐的姓名列、右对齐的分数列。这靠格式说明符里的对齐符号和宽度数字实现。
| 符号 | 含义 | 示例 | 效果 |
|---|---|---|---|
< | 左对齐 | f'{name:<10}' | 姓名后补空格到 10 宽 |
> | 右对齐 | f'{score:>8.1f}' | 分数前补空格到 8 宽 |
^ | 居中 | f'{title:^20}' | 标题居中占 20 宽 |
0 | 补零 | f'{num:05d}' | 数字补零到 5 位 |
students = [('张三', 92.5), ('李四', 88.0), ('王五', 100.0)] for name, score in students: print(f'{name:<8}{score:>8.1f}')输出会是整齐的两列。这里的关键是宽度要统一,否则列会错位。中文和英文的显示宽度不一样,中文一个字通常占两个英文字符的宽度,所以纯用字符数对齐时,中英混排会歪。如果对美观要求高,得按显示宽度计算,或者干脆用制表符分隔。
4.3 CSV 和 JSON:结构化的两条路
当数据不是给人看,而是给程序读时,就要用结构化格式。CSV 适合表格型数据,JSON 适合嵌套的字典列表。
写 CSV 用csv模块,别自己拼逗号。因为字段里如果有逗号或引号,自己拼会破坏格式。
import csv rows = [['姓名', '分数'], ['张三', '92.5'], ['李四', '88.0']] with open('scores.csv', 'w', encoding='utf-8', newline='') as f: writer = csv.writer(f) writer.writerows(rows)注意
newline=''这个参数。在 Windows 上不加它,写出的 CSV 每行之间会多一个空行。这是csv模块的官方建议写法。
读 CSV 同理:
with open('scores.csv', 'r', encoding='utf-8', newline='') as f: reader = csv.reader(f) for row in reader: print(row)JSON 用json模块,dumps/loads处理字符串,dump/load处理文件。
import json data = {'name': '张三', 'scores': [92.5, 88.0]} with open('data.json', 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False, indent=2)ensure_ascii=False让中文原样输出而不是转成\uXXXX,indent=2让格式带缩进便于阅读。这两个参数在实际项目里几乎必加。
5. 完整实操:一个词频统计与格式化输出的脚本
5.1 需求拆解
假设测验要求:读一个英文文本文件,统计每个单词出现的次数,按次数从高到低输出前 10 个,格式化成对齐的表格,同时把结果写进一个 CSV 文件。这个需求把读取、处理、格式化、写出全串起来了。
5.2 分步实现
第一步,读取并清洗。单词统计要先统一大小写、去掉标点。
import re from collections import Counter from pathlib import Path import csv def load_words(path): text = Path(path).read_text(encoding='utf-8') text = text.lower() words = re.findall(r"[a-z']+", text) return words这里用正则[a-z']+提取单词,允许撇号是为了处理don't这类词。Counter是统计频次的利器,一行搞定。
第二步,统计并排序。
def top_words(words, n=10): counter = Counter(words) return counter.most_common(n)most_common(n)直接返回按频次降序的前 n 个,内部做了排序,不用自己写。
第三步,格式化输出。
def print_table(items): print(f'{"排名":<6}{"单词":<15}{"次数":>6}') print('-' * 27) for rank, (word, count) in enumerate(items, 1): print(f'{rank:<6}{word:<15}{count:>6}')第四步,写出 CSV。
def save_csv(items, path): with open(path, 'w', encoding='utf-8', newline='') as f: writer = csv.writer(f) writer.writerow(['排名', '单词', '次数']) for rank, (word, count) in enumerate(items, 1): writer.writerow([rank, word, count])第五步,串起来。
if __name__ == '__main__': words = load_words('input.txt') items = top_words(words, 10) print_table(items) save_csv(items, 'result.csv')5.3 参数与细节说明
enumerate(items, 1)的第二个参数是起始值,默认从 0 开始,这里从 1 开始更符合“排名”的语义。most_common在频次相同时的顺序是不保证的,如果需要稳定排序,得自己用sorted加 key。
sorted_items = sorted(counter.items(), key=lambda x: (-x[1], x[0]))[:10]这个写法先按频次降序,频次相同再按单词字母升序,结果可复现。测验里如果对顺序有要求,用这个更稳。
6. 常见问题与排查技巧实录
6.1 报错速查表
| 报错 | 常见原因 | 解决方向 |
|---|---|---|
FileNotFoundError | 路径错、文件不存在 | 检查路径,用Path.exists()确认 |
UnicodeDecodeError | 编码不匹配 | 指定正确 encoding 或探测编码 |
PermissionError | 文件被占用或无权限 | 关闭占用程序,检查权限 |
IsADirectoryError | 把目录当文件打开 | 确认路径指向文件 |
ValueError: I/O operation on closed file | 在 with 外使用文件对象 | 把操作放进 with 块内 |
6.2 几个文档里不写的经验
第一,写文件时先写临时文件再重命名。如果你要覆盖一个重要文件,直接w打开有风险——写到一半程序崩了,原文件已经没了。稳妥做法是写到xxx.tmp,写完后os.replace覆盖原文件。os.replace在大多数平台上是原子操作,要么成功要么原文件不变。
第二,读大文件时注意内存。前面说过迭代优于readlines。但如果你需要随机访问行,可以考虑用linecache模块,它按需缓存,比全读进内存省。
第三,CSV 的编码陷阱。用 Excel 打开 UTF-8 的 CSV 有时会乱码,因为 Excel 默认按本地编码解析。解决办法是写 CSV 时加 BOM,即用encoding='utf-8-sig'。这样 Excel 能正确识别,其他程序读也没问题。
第四,JSON 不支持所有 Python 类型。datetime、set、自定义对象都不能直接json.dump。要么先转成字符串或列表,要么写自定义的default函数。
import json from datetime import datetime def default_handler(obj): if isinstance(obj, datetime): return obj.isoformat() if isinstance(obj, set): return list(obj) raise TypeError(f'无法序列化 {type(obj)}') json.dumps({'time': datetime.now()}, default=default_handler)第五,格式化时注意浮点精度。f'{0.1 + 0.2:.2f}'得到0.30,但0.1 + 0.2本身是0.30000000000000004。涉及金额计算时,用decimal.Decimal而不是 float,避免累积误差。
6.3 调试文件操作的小技巧
当你怀疑文件内容不对时,别急着改代码,先打印出来看。但直接print(content)可能因为内容太长刷屏。我的习惯是打印长度和前几百个字符。
print(f'长度: {len(content)}') print(f'前200字符: {content[:200]!r}')!r会显示转义字符,能让你看到\n、\t这些不可见字符,排查格式问题特别有用。
另一个技巧是用repr()看每一行的真实内容,尤其是怀疑有隐藏字符时。
for i, line in enumerate(f): if i < 5: print(repr(line))这样能立刻发现行尾是不是多了\r,或者有没有不可见的 BOM 字符。
7. 把测验变成能力:后续可以怎么练
做完这道测验,如果你只是把答案提交了就关掉,那收获有限。我建议做三件事把它变成真正的能力。
第一,把脚本改成能处理命令行参数。用argparse让输入文件和输出文件可配置,而不是写死在代码里。这一步跨过去,你的脚本就从“练习”变成了“工具”。
import argparse parser = argparse.ArgumentParser() parser.add_argument('input', help='输入文件路径') parser.add_argument('-o', '--output', default='result.csv') parser.add_argument('-n', type=int, default=10) args = parser.parse_args()第二,加上异常处理,让脚本在文件不存在、编码错误时给出友好提示而不是一堆 traceback。这对实际使用体验提升很大。
第三,把处理逻辑拆成函数,每个函数只做一件事。这样你下次遇到类似需求,可以直接复用其中某几个函数,而不是从头写。
我个人在实际操作中的体会是,文件操作和数据格式化这两块,看十遍不如动手写一遍。但写的时候一定要带着“如果这个文件很大怎么办”“如果编码不对怎么办”“如果写到一半崩了怎么办”这三个问题去写。带着问题写出来的代码,和照着答案抄出来的代码,差距在下一个项目里就会显现出来。