文件操作的核心不是记住某一个方法,而是先打开文件得到一个文件对象,再围绕这个文件对象的文件指针进行读取、写入、移动和关闭。
可以先记住这条线:
perl
open(...)
-> 得到文件对象
-> read / readline / readlines / for / write / seek / flush
-> close
日常代码里更推荐使用 with open(...) as file:,因为代码块结束时会自动关闭文件,也会把缓冲区里还没写入文件的数据处理掉。
一、纯文本文件和二进制文件
文件最终都以二进制形式存储在磁盘上。我们平时说"文本文件"和"二进制文件",区别不在于磁盘底层是不是二进制,而在于程序读写时是否按字符编码把二进制数据转换成人能直接阅读的文本。
纯文本文件
纯文本文件在读取和写入时,需要遵循某种字符编码规范,比如 UTF-8。Python 会按照编码把磁盘上的字节解码成字符串,也会把字符串编码成字节再写回磁盘。
纯文本文件最终呈现出来的是可以直接阅读的文本信息。常见例子有:
text
.txt
.py
.md
.html
.json
.csv
写纯文本文件时,建议明确指定编码:
python
with open('note.txt', 'rt', encoding='utf-8') as file:
content = file.read()
二进制文件
二进制文件读写时不涉及字符编码。Python 不会把内容解码成字符串,而是直接读出 bytes 数据。
二进制文件通常需要能识别格式的软件进行解析,最后呈现形式可能是音频、视频、图片、文档、幻灯片等。常见例子有:
.mp3
.mp4
.doc
.ppt
.jpg
.png
.pdf
读二进制文件时要带上 b:
python
with open('image.png', 'rb') as file:
data = file.read()
这里的 data 是 bytes,不是 str。
二、open 函数和 mode 组合
open 用来打开或创建文件,返回值是文件对象。最常用的三个参数是:
| 参数 | 作用 |
|---|---|
file |
要操作的文件路径 |
mode |
文件打开模式,决定能不能读、能不能写、是否清空、是否追加 |
encoding |
字符编码,文本模式常用 utf-8 |
python
# 假设 a.txt 内容是:hello
# 1. 打开文件,得到文件对象 file
file = open('a.txt', 'rt', encoding='utf-8')
# 2. 从文件对象里读取内容
content = file.read()
# content == 'hello'
# 3. 用完后关闭文件
file.close()
更推荐写成:
python
# 假设 a.txt 内容是:hello
with open('a.txt', 'rt', encoding='utf-8') as file:
content = file.read()
# 代码块结束后,文件会自动关闭
# content == 'hello'
with 小知识
with 可以理解成"进入代码块时打开文件,离开代码块时自动收尾"。文件操作里的收尾最重要的就是 close()。
如果手动写 open 和 close,中间代码一旦报错,file.close() 可能就执行不到:
python
file = open('a.txt', 'rt', encoding='utf-8')
content = file.read()
file.close()
写成 with 后,不管代码块是正常执行完,还是中间发生异常,Python 都会帮你关闭文件:
python
with open('a.txt', 'rt', encoding='utf-8') as file:
content = file.read()
所以日常读写文件时,优先用 with open(...) as file:。它不是新的读取方式,真正读写文件的仍然是 file.read()、file.write() 这些方法;with 只是帮你把关闭文件这一步做得更稳。
mode 不是随便拼字符串,它由三类信息组合出来:
css
基础操作:r / w / x / a
数据类型:t / b
更新能力:+
基础操作
| 模式 | 含义 | 文件不存在 | 文件已存在 | 指针初始位置 |
|---|---|---|---|---|
r |
只读 | 报错 | 正常打开 | 文件开头 |
w |
写入 | 创建文件 | 清空原内容 | 文件开头 |
x |
排他性创建 | 创建文件 | 报错 | 文件开头 |
a |
追加写入 | 创建文件 | 保留原内容 | 文件末尾 |
r 是默认值,所以 open('a.txt') 等价于 open('a.txt', 'rt')。
文本模式和二进制模式
| 模式 | 含义 | 读出来的类型 | 写入时需要的类型 | 是否使用 encoding |
|---|---|---|---|---|
t |
文本模式,默认值 | str |
str |
是 |
b |
二进制模式 | bytes |
bytes |
否 |
t 是默认值,所以 r、w、a 实际上分别等价于 rt、wt、at。
二进制模式不要传 encoding:
python
with open('music.mp3', 'rb') as file:
data = file.read()
加号模式
+ 表示"更新模式",也就是同一个文件对象既能读又能写。它必须依附在 r、w、x、a 其中一个基础模式上。
| 模式 | 能力 | 关键特点 |
|---|---|---|
r+ / rt+ |
读写文本 | 文件必须存在,不清空,指针在开头 |
w+ / wt+ |
读写文本 | 文件不存在则创建,文件存在则先清空 |
x+ / xt+ |
读写文本 | 文件必须不存在,已存在就报错 |
a+ / at+ |
读写文本 | 文件不存在则创建,写入总是追加到末尾 |
rb+ |
读写二进制 | 文件必须存在,不清空,指针在开头 |
wb+ |
读写二进制 | 文件不存在则创建,文件存在则先清空 |
xb+ |
读写二进制 | 文件必须不存在,已存在就报错 |
ab+ |
读写二进制 | 文件不存在则创建,写入总是追加到末尾 |
rt+ 这种写法可以理解为:
diff
r:以读取为基础打开
t:按文本处理
+:允许读和写
因此 rt+ 适合"文件已经存在,我想读它,也可能改它"的场景。
选择模式时可以按下面这条路径判断:
rust
只读已有文件
-> r / rt / rb
重新生成一个文件
-> w / wt / wb
文件必须是新文件,不能覆盖旧文件
-> x / xt / xb
只想把内容加到末尾
-> a / at / ab
既要读又要写
-> 在上面的基础上加 +
最容易踩坑的是 w 和 w+:只要文件已经存在,打开瞬间就会清空原内容。不是等到 write 执行时才清空。
三、读取文件:指针会一直往前走
文件对象内部有一个"文件指针"。读取不是每次都从头开始,而是从指针所在位置继续向后读。每读走一段,指针就向后移动一段。
可以把它想成一根只能向前推进的指针:
scss
文件内容:abcdefg
指针: ^
read(2) -> ab
指针: ^
read(3) -> cde
指针: ^
read() -> fg
指针: ^
read(size)
read 读取文件内容。size 是可选参数。
| 写法 | 含义 |
|---|---|
file.read() |
从指针位置读取到文件末尾 |
file.read(10) |
从指针位置最多读取 10 个字符或 10 个字节 |
文本模式下,size 表示字符数量;二进制模式下,size 表示字节数量。
python
# 假设 a.txt 内容是:abcdefg
with open('a.txt', 'rt', encoding='utf-8') as file:
r1 = file.read(2) # 从开头读 2 个字符:ab
r2 = file.read(3) # 接着读 3 个字符:cde
r3 = file.read(4) # 只剩 fg,所以只能读到:fg
r4 = file.read() # 已经到文件末尾了,所以读到空字符串:''
print(r1, end='')
print(r2, end='')
print(r3, end='')
print(r4, end='')
# 最终输出:abcdefg
如果已经读到文件末尾,再继续 read,会返回空字符串:
python
# 假设 a.txt 内容是:hello
with open('a.txt', 'rt', encoding='utf-8') as file:
content = file.read() # 第一次已经把 hello 全部读完
empty = file.read() # 第二次从文件末尾继续读,只能得到 ''
print(empty == '') # True
二进制模式下读到末尾后返回的是 b''。
读取大文件时,不要直接 read() 一次性读完。更适合分块读取:
python
with open('big.txt', 'rt', encoding='utf-8') as file:
while True:
# 每次最多读取 1024 个字符,不一次性把整个文件读进内存
chunk = file.read(1024)
# 文本文件读到末尾时,read 会返回空字符串 ''
if chunk == '':
break
print(chunk, end='')
readline(size)
readline 读取当前这一行。size 是可选参数。
| 写法 | 含义 |
|---|---|
file.readline() |
读取指针所在行,通常包含行尾换行符 |
file.readline(10) |
还是只读当前这一行,但最多读 10 个字符或 10 个字节 |
注意:readline(10) 的 10 不是"读取 10 行",而是"当前这一行最多读 10 个字符或字节"。
连续读取时可以这样理解:
python
# 假设 a.txt 内容是:
# abcdefg
# 第二行
with open('a.txt', 'rt', encoding='utf-8') as file:
print(file.readline(3)) # abc,第一行还没读完
print(file.readline(3)) # def,继续读第一行
print(file.readline()) # g\n,读完第一行剩下的内容
print(file.readline()) # 第二行\n,才开始读第二行
前两次都还在第一行里读。因为第一行还没有读完,下一次 readline() 会继续从文件指针当前位置往后读,而不是自动跳到下一行。
python
# 假设 a.txt 内容是:
# 第一行
# 第二行
# 第三行
with open('a.txt', 'rt', encoding='utf-8') as file:
r1 = file.readline() # '第一行\n'
r2 = file.readline() # '第二行\n'
r3 = file.readline() # '第三行\n'
r4 = file.readline() # 已经到文件末尾,所以是 ''
print(r1.strip())
print(r2.strip())
print(r3.strip())
print(r4.strip())
strip() 会去掉字符串两边的空白字符,包括换行、空格、制表符。如果只想去掉行尾换行,更稳妥的是:
python
print(line.rstrip('\n'))
循环逐行读取:
python
with open('a.txt', 'rt', encoding='utf-8') as file:
while True:
# 每次只读一行
line = file.readline()
# 读到文件末尾时,line 会变成空字符串 ''
if line == '':
break
# rstrip('\n') 只去掉行尾换行符,不会去掉普通空格
print(line.rstrip('\n'))
直接遍历文件对象
文件对象本身可以被 for 循环遍历,每次拿到一行。这是读取文本文件最常用、也比较省内存的写法。
python
# 假设 a.txt 内容是:
# 第一行
# 第二行
# 第三行
with open('a.txt', 'rt', encoding='utf-8') as file:
for line in file:
# 第 1 次循环:line == '第一行\n'
# 第 2 次循环:line == '第二行\n'
# 第 3 次循环:line == '第三行\n'
print(line, end='')
这里不需要自己判断 line == '',for 循环会在文件结束时自动停止。
readlines(hint)
readlines 会一次性按行读取,返回一个列表。
python
# 假设 a.txt 内容是:
# 第一行
# 第二行
# 第三行
with open('a.txt', 'rt', encoding='utf-8') as file:
# readlines() 会把每一行放进列表
lines = file.readlines()
print(lines) # ['第一行\n', '第二行\n', '第三行\n']
hint 是可选参数,表示"读到的总字符数或总字节数差不多超过这个值后,就不要继续读更多行了"。它不是行数,也不是严格上限,因为 readlines 会按"整行"放进列表,不会为了凑 hint 把一行切断。
python
with open('a.txt', 'rt', encoding='utf-8') as file:
# 读到的总长度大致超过 100 后,就不再继续读更多行
lines = file.readlines(100)
读取时大概是这种感觉:
python
# 假设 a.txt 内容是:
# abc
# def
# ghi
with open('a.txt', 'rt', encoding='utf-8') as file:
# 这里的 5 不是 5 行,而是总长度提示
# 读完 'abc\n' 后长度是 4,还没超过 5
# 再读一整行 'def\n' 后长度是 8,超过 5,于是停止
lines = file.readlines(5)
print(lines) # 可能是 ['abc\n', 'def\n']
这里的 5 不是 5 行。第一行 'abc\n' 长度是 4,还没超过 5;再读一整行 'def\n' 后总长度变成 8,已经超过 5,于是停止继续读。因为它按整行返回,所以结果总长度可以超过 hint。
readlines() 会把多行内容放进列表,不适合读取体积很大的文件。大文件优先用 for line in file 或分块 read(size)。
四、写入文件:先到缓冲区,再落到文件
write 用来写入内容。文本模式写入 str,二进制模式写入 bytes。
python
with open('demo.txt', 'wt', encoding='utf-8') as file:
file.write('你好1')
file.write('你好2')
# demo.txt 最终内容是:你好1你好2
# write 不会自动换行
write 不会自动加换行。想换行要自己写 \n:
python
with open('demo.txt', 'wt', encoding='utf-8') as file:
file.write('你好1\n')
file.write('你好2\n')
# demo.txt 最终内容是:
# 你好1
# 你好2
write 有返回值,表示本次写入了多少个字符或字节:
python
with open('demo.txt', 'wt', encoding='utf-8') as file:
count = file.write('你好') # 写入 2 个字符
print(count) # 2
文件写入时,并不是每调用一次 write 就一定立刻写入磁盘。Python 和操作系统中间可能有缓冲区。缓冲区的作用是减少频繁写磁盘的成本。
lua
write(...)
-> 先写入缓冲区
-> 缓冲区满了 / flush / close / with 结束
-> 再写入文件
如果希望把缓冲区里的内容立刻推给文件对象,可以调用 flush():
python
import time
with open('demo.txt', 'at', encoding='utf-8') as file:
file.write('你好1')
file.write('你好2')
# 立刻刷新缓冲区,方便其他程序尽快看到这部分内容
file.flush()
time.sleep(10)
# flush 之后文件没有关闭,还可以继续写
file.write('你好3')
file.write('你好4')
flush() 不是"关闭文件",文件还能继续写。close() 才是关闭文件。使用 with 时,代码块结束会自动关闭文件。
常见写入模式的差别:
python
# 覆盖写入:旧内容会被清空
with open('demo.txt', 'wt', encoding='utf-8') as file:
file.write('新的内容\n')
# demo.txt 只剩:新的内容
# 追加写入:新内容写到文件末尾
with open('demo.txt', 'at', encoding='utf-8') as file:
file.write('追加内容\n')
# demo.txt 变成:新的内容 + 追加内容
# 排他性创建:文件已存在会报错,避免误覆盖
with open('new.txt', 'xt', encoding='utf-8') as file:
file.write('只允许创建新文件\n')
# 如果 new.txt 已经存在,这里会抛 FileExistsError
五、seek 和 tell:显式移动文件指针
read、readline、write 都会影响文件对象的指针。默认情况下,读写会从指针位置继续往后走。如果要主动改变指针,可以用 seek。
python
file.seek(offset, whence)
| 参数 | 含义 |
|---|---|
offset |
偏移量,要移动多少距离 |
whence |
参考点,从哪里开始计算偏移 |
whence 有三个常用值:
| 值 | 含义 |
|---|---|
0 |
从文件开头计算,默认值 |
1 |
从指针位置计算 |
2 |
从文件末尾计算 |
tell() 可以查看指针位置:
python
# 假设 a.txt 是二进制读取,内容是:abcdefg
with open('a.txt', 'rb') as file:
print(file.tell()) # 0,刚打开时指针在开头
data = file.read(5) # 读走 b'abcde'
print(data) # b'abcde'
print(file.tell()) # 5,指针移动到了第 5 个字节后面
二进制模式下,seek 按字节移动:
python
# 假设 data.bin 内容是:abcdefg
with open('data.bin', 'rb') as file:
file.seek(5) # 指针移动到第 5 个字节后面,也就是 f 前面
data = file.read(1) # 读取 1 个字节
print(data) # b'f'
从文件末尾往前移动,通常也在二进制模式下使用:
python
# 假设 data.bin 内容是:abcdefg
with open('data.bin', 'rb') as file:
file.seek(-3, 2) # 从文件末尾往前移动 3 个字节,指针到 e 前面
data = file.read(1) # 读取 1 个字节
print(data) # b'e'
文本模式下要谨慎使用 seek。中文等字符在 UTF-8 中可能占多个字节,如果随便跳到某个字节位置再读取或写入,可能刚好落在一个字符的中间,导致编码错误或文件内容被破坏。
文本模式下建议只做这些操作:
python
# 假设 a.txt 内容是:hello
with open('a.txt', 'rt+', encoding='utf-8') as file:
content = file.read() # content == 'hello',指针移动到文件末尾
# 回到文件开头
file.seek(0)
again = file.read() # again == 'hello'
# 或者移动到文件末尾
file.seek(0, 2)
file.write('追加内容')
# a.txt 最终内容是:hello追加内容
需要记住的指针规则:
rust
read(size)
-> 从指针位置读取
-> 指针向后移动 size 对应的距离
readline()
-> 从指针位置读到当前行结束
-> 指针移动到下一行开头
write(...)
-> 从指针位置写入
-> 指针移动到写入内容之后
a / a+ 模式
-> 写入总是追加到末尾
如果读写混在一起,用 seek 明确指针位置会更清楚:
python
with open('a.txt', 'rt+', encoding='utf-8') as file:
old_content = file.read() # 读完整个文件后,指针在文件末尾
# read() 已经把指针移动到末尾了。
# 如果接下来要重新读取,就必须先把指针移回开头。
file.seek(0)
again = file.read() # 重新从开头读取
六、目录和路径操作
文件操作经常要配合目录操作。os 模块提供了很多基础能力。
python
import os
创建目录
os.mkdir(path) 创建单级目录。如果目录已经存在,或者父目录不存在,都会报错。
python
# 创建 demo 目录
os.mkdir('demo')
# 如果 demo 已经存在,会抛 FileExistsError
# 如果 demo 的父目录不存在,也会报错
os.makedirs(path) 创建多级目录。默认情况下,如果目标目录已经存在,也会报错。实际开发中经常加 exist_ok=True:
python
# 一次性创建 demo/aa/bb 多级目录
os.makedirs('demo/aa/bb', exist_ok=True)
# exist_ok=True 表示:目录已经存在也不报错
删除目录
os.rmdir(path) 删除空目录。目录不存在会报错,目录非空也会报错。
python
# 只能删除空目录
os.rmdir('demo/aa/bb')
os.removedirs(path) 会递归向上删除空目录。它先删除末尾一级目录,成功后继续尝试删除父级目录,直到遇到非空目录为止。
python
# 先删除 bb,再尝试删除 aa,再尝试删除 demo
# 只会删除空目录,遇到非空目录就停下来
os.removedirs('demo/aa/bb')
如果要删除有内容的目录,需要用 shutil.rmtree(path)。这是危险操作,会删除整个目录树:
python
import shutil
# 删除 demo 整个目录树,包括里面的文件和子目录
shutil.rmtree('demo')
写这种代码前,先确认路径一定正确,尤其不要把变量误拼成项目根目录或系统目录。
判断路径
os.path.exists(path) 判断路径是否存在,文件和目录都算存在。
python
print(os.path.exists('demo/aa/bb')) # True 或 False
os.path.isdir(path) 判断路径是否是目录:
python
路径不存在 -> False
路径存在,但指向的是文件 -> False
路径存在,并且是目录 -> True
python
print(os.path.isdir('demo/aa/bb')) # 只有路径存在并且是目录时,才是 True
os.path.isfile(path) 判断路径是否是文件:
python
路径不存在 -> False
路径存在,但指向的是目录 -> False
路径存在,并且是文件 -> True
python
print(os.path.isfile('demo/a.txt')) # 只有路径存在并且是文件时,才是 True
扫描目录
os.scandir(path) 扫描指定目录的下一层内容,不会自动递归进入子目录。
python
# 假设 demo 目录下有:
# demo/a.txt
# demo/images
with os.scandir('demo') as entries:
for item in entries:
label = '目录' if item.is_dir() else '文件'
print(label, item.name)
# 可能输出:
# 文件 a.txt
# 目录 images
item.name 是当前项的名字,item.is_dir() 可以判断当前项是不是目录。
递归遍历目录
os.walk(path) 会按层级递归遍历指定目录下的所有子目录和文件。每次循环得到三个值:
bash
root:当前正在遍历的目录路径
dirs:当前目录下的子目录名列表
files:当前目录下的文件名列表
python
# 假设目录结构是:
# demo/
# a.txt
# images/
# logo.png
for root, dirs, files in os.walk('demo'):
print('当前目录:', root)
print('子目录:', dirs)
print('文件:', files)
# 第一次循环:
# root == 'demo'
# dirs == ['images']
# files == ['a.txt']
#
# 第二次循环:
# root == 'demo/images'
# dirs == []
# files == ['logo.png']
dirs 和 files 里通常只是名字,不是完整路径。如果要得到完整路径,需要拼接:
python
for root, dirs, files in os.walk('demo'):
for filename in files:
# filename 只是文件名,比如 'a.txt'
# root 是当前目录,比如 'demo'
full_path = os.path.join(root, filename)
print(full_path) # demo/a.txt
拼路径时优先用 os.path.join,不要手写 '/' 或 '\':
python
target_file = os.path.join('demo', 'aa', 'b.txt')
# macOS / Linux 上通常得到:demo/aa/b.txt
# Windows 上通常得到:demo\aa\b.txt
七、复制二进制文件
复制图片、音频、视频这类文件时,要用二进制模式。不要用文本模式读取,否则 Python 会尝试按字符编码解析内容。
下面的例子把 music.mp3 复制到目标目录中:
python
import os
source = 'music.mp3'
target_dir = 'media'
target_file = os.path.join(target_dir, 'my_music.mp3')
# 如果目标目录不存在,就先创建。
if not os.path.isdir(target_dir):
os.makedirs(target_dir)
with open(source, 'rb') as f1, open(target_file, 'wb') as f2:
while True:
# 每次读取 1024 个字节,避免大文件一次性占用太多内存。
data = f1.read(1024)
# 二进制文件读到末尾时,read 会返回 b''。
if not data:
break
# 把刚刚读到的这一小块字节写入新文件
f2.write(data)
print('复制完毕')
这个例子里有几个关键点:
python
rb:按二进制读取源文件
wb:按二进制写入目标文件,目标文件存在会被清空
read(1024):每次只读一小块
if not data:读到 b'' 时结束循环
write(data):把本次读取到的字节写入目标文件
八、常用选择表
读文件:
| 场景 | 推荐写法 |
|---|---|
| 小文本文件一次性读取 | file.read() |
| 大文本文件逐行处理 | for line in file |
| 想手动控制每次读多少 | file.read(size) |
| 只读一行 | file.readline() |
| 确定文件不大,想拿到行列表 | file.readlines() |
| 读图片、音频、视频 | open(path, 'rb') |
写文件:
| 场景 | 推荐写法 |
|---|---|
| 重新生成文本文件 | open(path, 'wt', encoding='utf-8') |
| 追加文本内容 | open(path, 'at', encoding='utf-8') |
| 防止覆盖已有文件 | open(path, 'xt', encoding='utf-8') |
| 写图片、音频、视频等字节数据 | open(path, 'wb') |
| 写完后希望尽快让其他程序看到 | file.flush() |
目录:
| 场景 | 推荐写法 |
|---|---|
| 创建单级目录 | os.mkdir(path) |
| 创建多级目录 | os.makedirs(path, exist_ok=True) |
| 删除空目录 | os.rmdir(path) |
| 删除一串空目录 | os.removedirs(path) |
| 判断路径是否存在 | os.path.exists(path) |
| 判断是否目录 | os.path.isdir(path) |
| 判断是否文件 | os.path.isfile(path) |
| 扫描当前目录下一层 | os.scandir(path) |
| 递归遍历目录树 | os.walk(path) |
| 删除有内容的目录树 | shutil.rmtree(path) |
最后把文件操作压成一句话:
文件对象 = 内容 + mode 权限 + 文件指针 + 缓冲区
mode 决定这个文件对象能做什么;文件指针决定下一次从哪里读、写到哪里;缓冲区决定写入什么时候真正落到文件里。