第 23 章 文件与 IO:编码坑与流式

这一章专治「代码在我机器上能跑,到同事的 Windows 上就乱码」「几 GB 日志一次读进内存把服务搞挂」这类只有真实项目里才遇得到的 IO 苦活。

墨叔:老哥,问你个看似简单的问题。你在 Go 里读一个文件,一般是 data, err := os.ReadFile(path),拿到的 data 是 []byte,要不要当字符串用、怎么解码成 UTF-8,全是你自己说了算。Python 呢?open(path).read() 直接给你一个 str。你有没有想过:这一下「自动给字符串」的背后,藏着多少坑?

老哥:……还真没细想。平时 open 一把梭,出乱码了再 encoding='utf-8' 补上,补上就完事。

墨叔:这就是新手和老手的分水岭。Python 的 open 默认是文本模式,它替你做了一件事:读字节、按某种编码解码成 str。一旦「某种编码」不是你文件真正的编码,乱码就来了,而且往往静悄悄——不是抛异常,是给你一堆看不懂的字符。今天咱们把文件 IO 这摊事拆成四块:文本 vs 二进制、编码的坑、流式读取、临时文件,每一块都和 Go 对照着看。

一、文本模式与二进制模式:str 和 bytes 的楚河汉界

先说最底层的区别。文件在磁盘上永远是字节。所谓「文本」还是「二进制」,不是文件本身的属性,是你怎么读它。

# 文本模式(默认 "r"):读出 str,Python 替你做 decode
with open("notes.txt", "r", encoding="utf-8") as f:
    text: str = f.read()
 
# 二进制模式("rb"):读出 bytes,原样字节,不过问编码
with open("notes.txt", "rb") as f:
    raw: bytes = f.read()

关键点:文本模式 read() 返回 str,二进制模式 read() 返回 bytes。这个区别不是语法糖,它决定了你后面所有操作。

  • 你要算字符长度、按行切、做正则、拼 JSON、写回另一个文件——用 str,所以大多时候用文本模式。
  • 你要做哈希校验、传输、解析固定格式的协议、处理图片/压缩包——用 bytes,必须二进制模式。

墨叔:你品一下这个类比。文本模式像「快递柜已经帮你拆好箱、把东西摆整齐了再递给你」;二进制模式像「整箱原封不动给你,拆不拆、怎么拆你自己来」。拆箱省事,但拆错了(编码对不上)你拿到的是一堆破烂还不知道。原箱最稳,但所有解码逻辑得你自己写。

一个最容易栽的坑:跨模式操作。文本模式打开的文件,你往里写 bytes 会报错;二进制模式打开的文件,你往里写 str 也会报错。

with open("out.txt", "w", encoding="utf-8") as f:
    f.write(b"hello")        # TypeError: write() argument must be str, not bytes
 
with open("out.txt", "wb") as f:
    f.write("hello")         # TypeError: a bytes-like object is required, not 'str'

这跟 Go 的边界其实一脉相承:Go 里 []byte 和 string 也是两套类型,互转要显式 string(b) / []byte(s)。Python 的 str 和 bytes 互转同样要显式:

s = "你好"
b = s.encode("utf-8")     # str -> bytes, 得到 b'\xe4\xbd\xa0\xe5\xa5\xbd'
s2 = b.decode("utf-8")     # bytes -> str, 得到 "你好"

老哥:那是不是永远用二进制模式最安全?想用字符串的时候自己 decode 一下?

墨叔:安全,但别走极端。二进制模式把编码责任全甩给你,好处是「绝不静默乱码」——decode 失败一定抛 UnicodeDecodeError,你立刻知道。文本模式的好处是上层代码全在 str 世界里,不用处处 encode/decode。真实项目里我的经验是:处理「已知是文本的、要被业务消费的」文件用文本模式 + 显式 encoding;处理「要原样搬运/校验/不关心内容的」文件用二进制模式。后面讲编码坑时你就明白为什么「显式 encoding」四个字这么重。

二、编码的坑:utf-8 是默认,但不是真相

Python 3 的 open 默认编码是什么?很多人脱口而出「utf-8」。错。准确说:取决于运行平台,由 locale.getpreferredencoding() 决定。

import locale
print(locale.getpreferredencoding())   # Linux 通常 UTF-8;Windows 老版本常是 cp936(gbk)/cp1252

这意味着同一段代码:

with open("data.txt") as f:   # 没写 encoding,用平台默认
    content = f.read()

在 Linux 的 UTF-8 环境跑得好好的,到了 Windows 中文系统(默认 gbk/cp936),遇到一个 UTF-8 编码的文件,立刻 UnicodeDecodeError 或者更糟——静默读成一堆乱码字符串。

墨叔:老哥,你回想 Go。Go 的 string 本质是「任意字节序列」,它对编码完全中立,你从文件读出来的 []byte 是什么就是什么,不经过任何解码。所以 Go 几乎不遇到「读出来乱码」——要么你手动 utf8.Valid 校验,要么你直接当字节搬。Python 不一样,它在你不知情时替你解码,这反而制造了「我以为读对了其实读坏了」的隐患。

真实项目里怎么避坑?三条铁律:

  1. 永远显式写 encoding。别依赖默认。
# 不管在哪台机器跑,都按 UTF-8 解读
with open("data.txt", "r", encoding="utf-8") as f:
    content = f.read()
  1. 遇到「可能编码不对」的历史文件,用 errors 参数决定策略,而不是让它直接炸:
# 遇错跳过(会丢信息,但程序不崩);用于「先跑起来看看」
with open("legacy.txt", "r", encoding="utf-8", errors="replace") as f:
    content = f.read()        # 非法字节变成 U+FFFD 替换符
 
# 更务实:探测编码再读(依赖 chardet/charset-normalizer)
from charset_normalizer import from_path
best = from_path("legacy.txt").best()
if best is not None:
    content = str(best)
  1. BOM 这个隐形杀手。Windows 记事本存 UTF-8 会带 BOM(\ufeff 开头)。你 encoding="utf-8" 读出来,第一行莫名其妙多了个 \ufeff。解法是用 utf-8-sig:
with open("from_windows.txt", "r", encoding="utf-8-sig") as f:
    content = f.read()    # 自动吃掉 BOM

老哥:encoding 选错了会怎样?是报错还是悄悄错?

墨叔:两种都可能,这正是危险所在。如果文件里出现了当前编码「没法表示」的字节(比如用 gbk 去读一个纯 UTF-8 含生僻字的文件),默认 errors="strict" 会直接抛 UnicodeDecodeError,这个还算仁慈——至少你马上知道。怕的是 errors 不是 strict 的环境(某些库默认 replace),它把非法字节换成 ? 或替换符,文件「读通了」但内容被悄悄改了。生产里这种「数据静默损坏」比崩溃可怕十倍。所以我的原则是:读用户输入/外部文件,宁可 strict 让它报错,也别 replace 让它掩盖。

三、流式读取:别把几 GB 文件一口吞进内存

这是项目里最常被忽视、一出事最致命的一条。先看反面教材:

# 危险:一次性读入内存
with open("huge.log", "r", encoding="utf-8") as f:
    lines = f.readlines()    # 文件 8GB?内存直接爆

read() 和 readlines() 都是「全读」。你写个日志分析脚本,本地 200MB 测试文件没事,上线碰到 6GB 日志,服务 OOM 被 Kill——经典事故。

正确姿势是流式,一行一行、一块一块地消费,文件句柄是个迭代器:

# 逐行流式:每次只在内存里留一行
with open("huge.log", "r", encoding="utf-8") as f:
    for line in f:                  # 文件对象本身就是可迭代的行迭代器
        if "ERROR" in line:
            process(line)           # 处理完这一行,下一行才加载

这里 for line in f 的底层是惰性迭代:每次只从磁盘读一块、解码出一行给你,前面的行已经被 GC 回收。内存占用是「常数级」,跟文件大小无关。这跟 Go 里 bufio.Scanner 一行一行扫一个道理:

scanner := bufio.NewScanner(file)
for scanner.Scan() {
    line := scanner.Text()
    if strings.Contains(line, "ERROR") {
        process(line)
    }
}

二进制模式要按「块」流式,用 read(size):

CHUNK = 64 * 1024
with open("big.bin", "rb") as f:
    while chunk := f.read(CHUNK):    # 3.11+ 支持 while 里 := 海象运算符
        hasher.update(chunk)          # 比如边读边算哈希,永不满载内存

墨叔:老哥,你品这俩的性价比。流式读,收益是内存恒定、能处理比你物理内存大得多的文件、程序不会被 OOM;代价几乎为零——代码没复杂多少,反而更短。唯一的「代价」是如果你真需要「随机访问第 N 行」或「反复扫整个文件」,流式就得重读,这时候才该一次性读进来(或上索引)。所以经验法则:默认流式,只有明确需要全量在内存时才用 read()。

还有个细节:逐行流式时,不要在循环里反复 open 同一文件,也别在循环里 f.seek 乱跳——那是另一种性能坑。一个文件一个 with,从头流到尾,最干净。

四、贴近项目的例子:大日志按时间窗口切分 + 校验和

给你一个能直接搬进项目的场景:有个 5GB 的访问日志,要按「每个分片不超过 500MB」切成多个小文件,同时给每个小文件算 SHA-256 落地到清单。

import hashlib
from pathlib import Path
 
def split_log(src: str, out_dir: str, max_bytes: int = 500 * 1024 * 1024) -> list[str]:
    out_dir = Path(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)
    manifest: list[str] = []
 
    # 二进制流式读,避免任何编码假设,也便于精确按字节切
    with open(src, "rb") as f:
        part_no = 0
        written = 0
        out = None
        hasher = None
        for chunk in iter(lambda: f.read(8 * 1024 * 1024), b""):   # 每次 8MB
            if out is None or written + len(chunk) > max_bytes:
                if out is not None:
                    out.close()
                    manifest.append(f"{part_no:04d}.log  {hasher.hexdigest()}")
                part_no += 1
                written = 0
                hasher = hashlib.sha256()
                out = open(out_dir / f"part-{part_no:04d}.log", "wb")
            out.write(chunk)
            hasher.update(chunk)
            written += len(chunk)
        if out is not None:
            out.close()
            manifest.append(f"{part_no:04d}.log  {hasher.hexdigest()}")
    return manifest
 
if __name__ == "__main__":
    m = split_log("access.log", "splits/")
    Path("splits/MANIFEST.txt").write_text("\n".join(m), encoding="utf-8")

注意几个工程点:用 iter(lambda: f.read(n), b"") 做个定长块的迭代器(比 while 更函数式);二进制模式避免编码问题;每个分片独立算哈希,最后落清单。换成 Go,你会用 io.CopyN + bufio + crypto/sha256,思路完全一致,只是 Python 这版更短。

五、临时文件:别手动画 /tmp/xxx 再自己删

项目里常要「处理中途写个临时文件,用完即删」。新手爱手写:

# 不推荐:自己管理,崩溃了就残留,还可能与别人撞名
tmp = "/tmp/work.tmp"
with open(tmp, "w") as f:
    f.write(...)
# 忘了删,或者中途异常没走到删除 -> 垃圾越积越多

标准库 tempfile 就是干这个的,而且和 with 配合能自动清理:

import tempfile
 
# 临时文件:退出 with 块自动删除
with tempfile.NamedTemporaryFile("w", suffix=".tmp", delete=True) as tf:
    tf.write("中间结果")
    tf.flush()                 # 确保落盘,别的进程/子命令才能读到
    # 用 tf.name 把路径传给外部命令
    run_external(tf.name)
 
# 临时目录:整个目录退出时清空
with tempfile.TemporaryDirectory() as d:
    path = Path(d) / "stage.txt"
    path.write_text("staging", encoding="utf-8")
    # ... 在 d 里随便折腾
# 离开块,d 连同里面所有文件被删干净

墨叔:对照 Go 你想想,ioutil.TempFile(现在归到 os.CreateTemp)也是同款思路——系统保证文件名不撞、权限安全、用完你 defer os.Remove。Python 的 tempfile + with 就是把 defer 删除 这件事语言级托管了,你连 defer 都不用写。这跟第 5 章讲的「with 就是 Python 的 defer」完全打通:资源释放交给上下文管理器,别手写。

tempfile.mkstemp / mkdtemp 是不自动删的版本,当你需要把临时路径显式传给外部系统、且生命周期跨多个 with 块时才用,用完记得自己 os.remove。

六、pathlib:路径别再手拼字符串

前面例子里我混用了 "r" 模式和 pathlib.Path。这里单独点一句:真实项目里路径操作用 pathlib.Path,别用 os.path.join 拼字符串。为什么?拼字符串在跨平台时是坑源地——Windows 用 \、Linux 用 /,你 os.path.join("a", "b") 虽然能跨平台,但一旦有人手写成 "a/b" 或 "a\\b" 就埋雷。Path 用 / 运算符,天然跨平台且可读:

from pathlib import Path
 
base = Path("logs") / "2026" / "access.log"   # / 运算符拼路径,跨平台安全
base.write_text("hello", encoding="utf-8")     # 直接写文本,内部自动 open+with
data = base.read_text(encoding="utf-8")        # 直接读文本,省掉 with 样板
for p in Path("logs").glob("*.log"):           # 遍历匹配,替代 os.listdir + 过滤
    print(p.name, p.stat().st_size)

Path.write_text / read_text 内部就是「open + with + 指定 encoding」的封装,所以你既拿到了便利,又没丢掉「显式编码」这条铁律。它还能 .exists()、.mkdir(parents=True, exist_ok=True)、.relative_to()、.with_suffix(),几乎覆盖日常所有路径操作。对照 Go,你用 path.Join、filepath.Glob、os.Stat 是一组零散函数;Python 把路径对象化了,链式调用更顺。我的规矩:凡涉及路径拼接/遍历/判断存在,一律 Path,只有做底层字节流式读写才下探到 open。

还有个和路径无关但极易踩的文本模式暗坑:换行符翻译。文本模式默认开启「通用换行」——读时把 \r\n(Windows)和 \r(旧 Mac)都翻译成 \n,写时把 \n 翻译成当前平台的行尾。多数时候这是好事(跨平台一致),但当你要原样保留字节级行尾(比如解析固定格式协议、做哈希比对、处理 diff)时,它会悄悄篡改内容。解法:用 newline="" 关掉翻译,或干脆二进制模式自己处理。这个坑和编码坑同源——都是「文本模式替你做了你不知道的转换」,记住一条:要字节级精确,就离文本模式远一点。

七、对照 Go 收个尾:一张 IO 心智表

老哥:听起来 Python 的 IO 比 Go 麻烦不少,又是编码又是模式。

墨叔:麻烦在「编码自动替你做」这一个点,好处也在这一个点——你写业务代码时大部分时候活在 str 世界,不用处处 bytes<->string。其余的(流式、临时文件、分块)思路和 Go 一一对应。给你速查:

Go 习惯Python 对应要注意
os.ReadFile 拿 []byteopen(path,'rb').read() 拿 bytes想要 str 得自己 decode
os.WriteFile(path, []byte, 0644)Path(path).write_bytes(b) 或 open(...,'wb').write(b)写 str 要 'w' 模式
bufio.Scanner 逐行for line in f(文本模式)别用 readlines() 全读
io.CopyN 分块f.read(n) 循环 / iter(lambda...)二进制模式按字节切
os.CreateTemp + defer Removetempfile.NamedTemporaryFile + withwith 退出自动删
string(b) / []byte(s)b.decode() / s.encode()编码显式写,别信默认

一句话带走:文本还是二进制,先想清楚你要 str 还是 bytes;编码永远显式写 encoding;大文件永远流式;临时产物交给 tempfile + with。这四条做到,IO 相关的线上事故能少一大半。

【思考题】

  1. 你接手一个老系统,导出的 CSV 在 Linux 上读出来正常,在 Windows 上第一行开头多了一个看不见的字符,而且偶尔某些行变成乱码。请用这一章的知识推断:最可能的两个原因分别是什么?各该用什么 encoding / 参数修?如果文件编码未知且不能提前确定,你会怎么写「稳健读取」的代码?
  2. 有个 20GB 的 NDJSON(每行一个 JSON)文件,要统计其中 status 字段为 "error" 的记录数。如果直接 json.load(open(f)) 会怎样?请写一个流式、内存恒定的版本(提示:for line in f + 每行 json.loads),并指出它相比「全读进来再处理」在工程上的收益与潜在代价。
  3. Go 里你常用 defer f.Close()。Python 里 with open(...) as f 和它是同一个东西吗?如果 with 块里读文件时抛了异常,f 还会被关吗?上下文管理器的「异常安全」是怎么保证的?如果不用 with 手写 try/finally,你会怎么写才等价?

【参考答案】

  1. 两个最可能原因:① BOM。Windows 记事本/Excel 导出 UTF-8 常带 BOM(\ufeff),Linux 的 UTF-8 解读把它当普通字符藏在行首,肉眼难辨;修法是把 encoding="utf-8" 换成 encoding="utf-8-sig",它会在读时自动剥离 BOM。② 平台默认编码不一致。Windows 中文区 locale 默认 cp936(gbk),若文件实为 UTF-8,open(f) 不写 encoding 时按 gbk 解,遇 UTF-8 字节非法处静默乱码或 UnicodeDecodeError;修法是强制写 encoding="utf-8"。若编码完全未知且不能预知,稳健写法是用 charset-normalizer 探测再读:
    from charset_normalizer import from_path
    best = from_path("x.csv").best()
    text = str(best) if best is not None else ""
    或更保守:二进制读、用 errors="replace" 兜底保证不崩、再记录哪些行被替换(说明有编码问题待人工确认)。原则是「未知编码宁可用探测+显式,也别交给平台默认」。
  2. 直接 json.load(open(f)) 会先把整个 20GB 读进内存再解析,必然 OOM,且 json.load 要求一个完整 JSON 值(它不支持「每行一个独立 JSON」的 NDJSON 格式,会直接报错)。流式版本应逐行 json.loads:
    import json
    count = 0
    with open("big.ndjson", "r", encoding="utf-8") as f:
        for line in f:                 # 逐行,内存恒定
            line = line.strip()
            if not line:
                continue
            try:
                rec = json.loads(line)  # 每行独立解析
            except json.JSONDecodeError:
                continue                # 坏行跳过或记日志
            if rec.get("status") == "error":
                count += 1
    收益:内存占用与文件大小无关(常数级),能处理远超物理内存的文件,且边读边处理、不用等全读完。代价:无法随机访问、需多次统计要重读;单行若跨行断裂(不规范的 NDJSON)会失败;解析是逐行而非整体校验。工程上「只读一遍、内存恒定」的收益远大于这些代价,除非你要反复查全量才考虑建索引/进数据库。
  3. 是同一个意图(资源用后必释放)的不同语言实现,但机制不同:with open(...) as f 背后是上下文管理器协议 __enter__/__exit__;进入块调用 __enter__ 拿到 f,无论块内是否正常结束(包括抛异常、return、break)__exit__ 都会被执行,从而关闭文件——这就是「异常安全」。所以 with 块里读文件抛异常,f 一定会被关。__exit__ 收到异常三元组,文件对象的实现里无条件 self.close(),哪怕你不在 __exit__ 里处理异常它也会关(异常随后照常向上抛)。等价手写 try/finally:
    f = open("x.txt", encoding="utf-8")
    try:
        data = f.read()
    finally:
        f.close()         # 保证执行,哪怕 read() 抛异常
    with 就是把这段模板「协议化、强制化」,你没法忘记写 finally 的 close()——这正是它比 Go defer 更「防呆」的地方:Go 还得你记得写 defer f.Close(),Python 用 with 语法强制配对。