ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Python文件读写与数据格式化:从测验题到工程实践

Python文件读写与数据格式化:从测验题到工程实践

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。这对实际使用体验提升很大。

第三,把处理逻辑拆成函数,每个函数只做一件事。这样你下次遇到类似需求,可以直接复用其中某几个函数,而不是从头写。

我个人在实际操作中的体会是,文件操作和数据格式化这两块,看十遍不如动手写一遍。但写的时候一定要带着“如果这个文件很大怎么办”“如果编码不对怎么办”“如果写到一半崩了怎么办”这三个问题去写。带着问题写出来的代码,和照着答案抄出来的代码,差距在下一个项目里就会显现出来。

RELATED READING

延伸阅读

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