AI 技术博客
返回首页
Python 基础 19 分钟阅读

读取与写入 API 全解:read/readline/迭代与 writelines

Python文件读写readseek

引言

第 1 篇解决了「open() 怎么开、什么时候关」的问题,本篇把打开之后的事讲透:读的四个姿势——read(size)、readline()、readlines()、逐行迭代;写的两个成员——write() 与 writelines();以及横跨两者的指针操作 tell()/seek() 和文件末尾(EOF)语义。这三组 API 贯穿所有真实文件任务:日志分析逐行扫、配置解析先整读、结果落盘再拼接——本篇学完,你能根据场景选出正确的组合,而不是把几个方法背下来乱用。

阅读前提:第 1 篇的 open 与模式矩阵(尤其 r/w/a 与 r+ 的差异);第四章第 8 篇的异常处理。本篇依旧只用系统临时目录做演示,路径高级操作留给第 4 篇。所有代码在 Python 3.12 本机实测,输出逐字一致。

概念与原理

文件指针:读到哪里、写到哪里

文件对象内部维护一个文件指针(file position),标识下一次读/写发生的字节位置。每读(写)一次,指针自动前进;f.tell() 查询当前位置,f.seek(offset, whence) 移动它。指针是「流式访问」的物理基础:读 10 个字符后再读 10 个,得到的是第 11 到 20 个字符,而不是从头再来——这正是读取超大文件的前提。

一个必须牢记的分界:文本模式下指针是「不透明 cookie」,二进制模式下才是字节偏移。文本模式由于存在多字节编码,「字符位置」与「字节位置」并不一一对应,所以文档只允许 seek(0) 或 seek(你之前 tell() 得到的值);二进制模式则可以任意按字节跳转。下面实测会展示违反这条规则的后果。

read 的语义与 EOF:末尾返回空字符串

read(size) 最多返回 size 个字符(文本模式)/ 字节(二进制模式)——不是「必须返回 size 个」,文件剩余不足时就返回剩下的。不带参数则是「读到末尾」。文件读尽之后再调用 read(),返回值是空字符串 '',而不是 None。这不是巧合而是刻意设计:read() 的成功返回值永远是 str 类型,'' 表达「数据已经读完,没有更多」;None 在 Python 的惯例里留给「没有返回值/失败」的场合。用 data is None 判断文件是否读完永远为 False,下面实测证明。同理,readline() 读到末尾返回 '',用 while True: line = f.readline(); if not line: break 就是最朴素的读取循环。

三种「按行读」的适用场景

按行读有三兄弟。readline(size):一次返回一行(含末尾换行符),size 可限制单行读取量。readlines():一次返回全部行组成的列表——只适合小文件,因为它把「整个文件」变成了「所有行对象」,内存开销等于文件内容加列表结构。而逐行迭代(for line in f)是工程首选:文件对象本身就是迭代器(第 1 篇实测过 iter(f) is f),for 循环底层反复调用 readline(),一次只解出并保留一行,配合内部按块(默认约 8 KB)预读,内存占用与文件大小无关。

write 与 writelines:不自动加换行

write(s) 把 s 写入文件并返回写入的字符数(文本模式)。writelines(iterable) 则把序列里的每个字符串依次写进去——注意:它不会在元素之间补任何换行符。这是与直觉最大的偏差:很多人以为 writelines 的名字暗示「按行写」,实际上它只是「批量 write」,换行符必须包含在数据里。为什么这样设计?因为「行」的定义随业务而变(日志要不要时间戳、CSV 要不要空行、平台用 \n 还是 \r\n),库不该替调用方做格式化决定。由此也得出严格的对应关系:readlines() 读出的每一行都自带 \n,所以「读出来、改一点、再 writelines 写回去」是畅通的;而自己拼的行列表必须逐个补 \n,否则所有内容会黏成一行(下面实测)。

tell/seek:指针的存档与恢复

pos = f.tell(); ...; f.seek(pos) 构成「存档—恢复」模式:读完一段后把位置记下来,处理完别的事再跳回来继续,常用于分块解析、断点续读。seek(offset, whence) 的 whence 有三个取值:0 相对文件开头(默认)、1 相对当前位置、2 相对末尾,因此 f.seek(0, 2) 可以快速得到文件大小(等于随后 tell() 的值)。

大文件的正确姿势:流式而非整读

内存的账要算清楚。f.read() 把整个文件变成一个 str 对象:内存占用与文件大小成正比,文本解码后甚至还可能放大(UTF-8 下一汉字 3 字节,解码成 str 后每个字符占 2 字节内部宽度,反倒更占内存)。几百 MB 的日志整读,进程内存直接几个 GB 量级,触顶时要么 OOM 崩溃,要么发生换页把机器拖死。而逐行迭代的内存是常数级:一行数据 + 一个默认缓冲块。规则:文件明显小于可用内存、且需要整体处理(全文替换、JSON 解析),整读没问题;文件可能很大(日志、导出数据),一律逐行流式处理。下面用真实数字对比两种姿势的差距。

操作与实现

read(size):按量读取与文件末尾

import tempfile, os

d = tempfile.gettempdir()
p = os.path.join(d, 'demo_read.txt')
with open(p, 'w') as f:
    f.write('0123456789abcdefghij')
f = open(p)
print('read(5):', repr(f.read(5)))
print('read(7):', repr(f.read(7)))
print('read():', repr(f.read()))
print('文件末尾再 read():', repr(f.read()))
f.close()
os.remove(p)
read(5): '01234'
read(7): '56789ab'
read(): 'cdefghij'
文件末尾再 read(): ''

三次调用依次推进指针:5 个、7 个、剩余全部;指针到底后,read() 返回 '' 而不是 None。注意文本模式下 read(5) 是「5 个字符」,不是 5 字节——第 3 篇在编码视角下还会再见到这个区别。

readline / readlines:行读取双雄

import tempfile, os

d = tempfile.gettempdir()
p = os.path.join(d, 'demo_lines.txt')
with open(p, 'w') as f:
    f.write('第一行\n第二行\n第三行\n')
f = open(p)
print('readline():', repr(f.readline()))
print('再 readline():', repr(f.readline()))
print('剩余 readlines():', f.readlines())
f.close()
os.remove(p)
readline(): '第一行\n'
再 readline(): '第二行\n'
剩余 readlines(): ['第三行\n']

readline() 每次推进一行,返回内容含末尾换行符;readlines() 一次性返回剩余所有行。两行之后文件只剩一行,所以 readlines 只有一项。

逐行迭代:大文件的默认姿势

import tempfile, os

d = tempfile.gettempdir()
p = os.path.join(d, 'demo_iter.txt')
with open(p, 'w') as f:
    f.write('a\nb\nc\n')
with open(p) as f:
    for i, line in enumerate(f, 1):
        print(f'第 {i} 行:', repr(line))
os.remove(p)
第 1 行: 'a\n'
第 2 行: 'b\n'
第 3 行: 'c\n'

for line in f 每次只解出一行,行数未知也不用预先计数。枚举器 enumerate(f, 1) 顺手给出行号——日志分析类任务的标准搭配。

write 与 writelines:换行必须自己带

import tempfile, os

d = tempfile.gettempdir()
p = os.path.join(d, 'demo_write.txt')
with open(p, 'w') as f:
    n1 = f.write('第一段')
    n2 = f.write('第二段')
    n3 = f.write('\n')
print('write 返回的字符数:', n1, n2, n3)
with open(p, 'a') as f:
    f.writelines(['甲\n', '乙\n', '丙\n'])
with open(p) as f:
    print('writelines 各带换行后:', repr(f.read()))
with open(p, 'w') as f:
    f.writelines(['甲', '乙', '丙'])
with open(p) as f:
    print('writelines 不带换行时:', repr(f.read()))
os.remove(p)
write 返回的字符数: 3 3 1
writelines 各带换行后: '第一段第二段\n甲\n乙\n丙\n'
writelines 不带换行时: '甲乙丙'

三组事实一目了然:write 返回实际写入的字符数,连续 write 依次拼在指针处;writelines 各元素自带换行符时按行写;元素不带换行符时就全部黏在一起——没有任何自动补全。所以用 writelines 前先自问:我的数据里带 \n 了吗?

tell/seek:指针的查、跳与存档恢复

import tempfile, os

d = tempfile.gettempdir()
p = os.path.join(d, 'demo_seek.txt')
with open(p, 'w') as f:
    f.write('abcdefghij')
with open(p) as f:
    print('初始 tell():', f.tell())
    print('read(4):', f.read(4))
    pos = f.tell()
    print('read(4) 后 tell():', pos)
    f.seek(pos)
    print('seek(pos) 后 read(2):', f.read(2))
    f.seek(0)
    print('seek(0) 后 read(2):', f.read(2))
    f.seek(0, 2)
    print('seek(0, 2) 后 tell() 即文件大小:', f.tell())
os.remove(p)
初始 tell(): 0
read(4): abcd
read(4) 后 tell(): 4
seek(pos) 后 read(2): ef
seek(0) 后 read(2): ab
seek(0, 2) 后 tell() 即文件大小: 10

tell() 报出指针位置,seek(pos) 跳回存档点继续读,seek(0, 2) 定位到末尾从而测出文件大小。这就是「分块解析 + 断点恢复」的骨架。文本模式下的一个经典事故现场如下——对 UTF-8 文件执行任意字节偏移的 seek:

import tempfile, os

d = tempfile.gettempdir()
p = os.path.join(d, 'demo_seek_mis.txt')
with open(p, 'w', encoding='utf-8') as f:
    f.write('你好世界')
with open(p, encoding='utf-8') as f:
    f.seek(2)   # 2 落在「你」的三字节中间
    try:
        print('read():', repr(f.read()))
    except UnicodeDecodeError as e:
        print('字符中间开始解码 ->', type(e).__name__, 'start =', e.start)
os.remove(p)
字符中间开始解码 -> UnicodeDecodeError start = 0

文本模式下 seek 只保证 0 与 tell() 返回值可用;字节偏移会落在字符中间,解码当场抛 UnicodeDecodeError(第 3 篇讲解码)。二进制模式没有这个问题,fh.seek(任意字节) 是二进制解析(文件头、定长记录)的常规操作。

文件末尾:'' 不是 None

import tempfile, os

d = tempfile.gettempdir()
p = os.path.join(d, 'demo_eof.txt')
with open(p, 'w') as f:
    f.write('abc')
with open(p) as f:
    data = f.read()
    again = f.read()
print('类型:', type(again).__name__, '| 值:', repr(again), '| 是 None:', again is None)
os.remove(p)
类型: str | 值: '' | 是 None: False

读完后再读:类型是 str,值是 '',is None 为 False。判断「是否读完」用「是否为空串」而不是「是否为 None」;同理 if line: 的惯用法依赖的是「空行 '\n' 也是真值」与「空串才是 False」的区别。

大文件:整读 vs 逐行,内存账本实测

import tempfile, os, sys

d = tempfile.gettempdir()
p = os.path.join(d, 'demo_big.txt')
line = '这是第 000001 行日志数据\n'
with open(p, 'w') as f:
    f.writelines([line] * 100000)
print('文件大小(字节):', os.path.getsize(p))
with open(p) as f:
    all_text = f.read()
print('整读后 str 字符数:', len(all_text))
print('整读后 getsizeof:', sys.getsizeof(all_text), '字节,约', round(sys.getsizeof(all_text)/2**20, 1), 'MB')
with open(p) as f:
    first = next(f)
print('单行对象 getsizeof:', sys.getsizeof(first), '字节')
with open(p) as f:
    total = sum(1 for _ in f)
print('逐行迭代统计行数:', total)
os.remove(p)
文件大小(字节): 3400000
整读后 str 字符数: 1700000
整读后 getsizeof: 3400058 字节,约 3.2 MB
单行对象 getsizeof: 92 字节
逐行迭代统计行数: 100000

实测的账很直观:10 万行、3.4 MB 的文件,整读变成 3.4 MB 内存(sys.getsizeof 报的 3400058 字节,约 3.2 MB);而逐行迭代时单个行对象只有 92 字节,无论文件多大,内存始终是「一行」的量级。把比例外推到 1 GB 日志:整读需要约 1 GB 内存,逐行迭代只需几十 KB。所以日志分析、数据清洗的入口永远是 for line in f,readlines() 只留给真正小且需要索引访问的文本。

易错点与陷阱

陷阱一:文本模式下 read(size) 数的是字符,不是字节

文本模式 read(100) 返回最多 100 个字符,对一个包含大量中文的文件,它在磁盘上对应的字节数可能是两到三倍(GBK 两字节、UTF-8 三字节)。用「读 100 字节」的直觉去读,要么读多了、要么截断了半个字符。需要精确按字节读、按字节跳的场景(解析二进制格式、读取文件头),应该用二进制模式 'rb' + read/seek,第 3 篇会系统对比。

陷阱二:writelines 不自动补换行,内容全部粘连

这是新手最常栽的静默错误:f.writelines(['苹果', '香蕉', '橙子']) 写出的是一整行「苹果香蕉橙子」,程序不报任何错,直到下游解析时才发现行数不对。规则记牢:writelines 等价于循环 write,要不要换行由数据自己决定。与此互补的坑是用 write 拼接内容时忘写 '\n'——拼接几百行后文件没有一处换行。

陷阱三:文本模式下对任意字节位置 seek

多字节编码下「字符边界」与「字节边界」不重合。文本模式对非 0、非 tell() 返回值的偏移 seek 是未定义行为:幸运时读出乱码,不幸时像上面的实测一样直接 UnicodeDecodeError。二进制解析务必开 'rb' 模式,让 seek 按字节自由工作。

陷阱四:用 None 判断文件是否读完

data = f.read(); if data is None: ... 的检查永远不会触发——EOF 返回的是 ''。判断读完要用空串;用 while True: line = f.readline(); if not line: break 的循环里,not line 为 True 仅当读到空串,空行(只有一个 '\n')不是空串,能正常处理——这个区分是读取循环正确性的关键。

小结

读写的全部 API 归根结底是三组概念:按量/按行/整读的三种读取(read(size)、readline、逐行迭代,readlines 只适合小文件);write/writelines 的写入(不自动补换行,返回值是字符数);tell/seek 的指针操作(文本模式只能用 0 与 tell 返回值)。EOF 返回空串而非 None,大文件必须流式逐行而不能整读。掌握这三点,日志扫描、配置解析、结果落盘三类任务就有了完整工具箱。下一站第 3 篇:编码参数——前面所有读写都隐含了「文本如何变成字节」的答案。

练习与思考题

  1. 写一个函数 count_lines(path),用逐行迭代统计文件行数,要求对任何大小的文件都保持常数级内存。思考:为什么不能先用 readlines() 再 len()?
  2. 用 writelines 把 ['第一段', '第二段', '第三段'] 写成一个每行一段的文本文件(要求恰好三行)。先想清楚换行符加在哪里,再验证 readlines() 读回的结果。
  3. 解释代码:f.seek(0, 2); size = f.tell() 为什么能得到文件大小?如果文件是用 UTF-8 编码的中文,文本模式下这个 size 是字节数还是字符数?
  4. 阅读下面的循环,说明它为什么不会漏掉「空行」、也不会死循环:
    with open(path) as f:
        while True:
            line = f.readline()
            if not line:
                break
            process(line)
  5. 思考题:为什么「文本模式 seek 只用 0 或 tell 的返回值」比「直接给字节偏移」更安全?提示:从 UTF-8 三字节汉字与字符边界的关系出发。