这一章专治「代码在我机器上能跑,到同事的 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 对照着看。
with open("out.txt", "w", encoding="utf-8") as f: f.write(b"hello") # TypeError: write() argument must be str, not byteswith open("out.txt", "wb") as f: f.write("hello") # TypeError: a bytes-like object is required, not 'str'
# 逐行流式:每次只在内存里留一行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 * 1024with open("big.bin", "rb") as f: while chunk := f.read(CHUNK): # 3.11+ 支持 while 里 := 海象运算符 hasher.update(chunk) # 比如边读边算哈希,永不满载内存
墨叔:老哥,你品这俩的性价比。流式读,收益是内存恒定、能处理比你物理内存大得多的文件、程序不会被 OOM;代价几乎为零——代码没复杂多少,反而更短。唯一的「代价」是如果你真需要「随机访问第 N 行」或「反复扫整个文件」,流式就得重读,这时候才该一次性读进来(或上索引)。所以经验法则:默认流式,只有明确需要全量在内存时才用 read()。
还有个细节:逐行流式时,不要在循环里反复 open 同一文件,也别在循环里 f.seek 乱跳——那是另一种性能坑。一个文件一个 with,从头流到尾,最干净。
import hashlibfrom pathlib import Pathdef 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 manifestif __name__ == "__main__": m = split_log("access.log", "splits/") Path("splits/MANIFEST.txt").write_text("\n".join(m), encoding="utf-8")
你接手一个老系统,导出的 CSV 在 Linux 上读出来正常,在 Windows 上第一行开头多了一个看不见的字符,而且偶尔某些行变成乱码。请用这一章的知识推断:最可能的两个原因分别是什么?各该用什么 encoding / 参数修?如果文件编码未知且不能提前确定,你会怎么写「稳健读取」的代码?
有个 20GB 的 NDJSON(每行一个 JSON)文件,要统计其中 status 字段为 "error" 的记录数。如果直接 json.load(open(f)) 会怎样?请写一个流式、内存恒定的版本(提示:for line in f + 每行 json.loads),并指出它相比「全读进来再处理」在工程上的收益与潜在代价。
Go 里你常用 defer f.Close()。Python 里 with open(...) as f 和它是同一个东西吗?如果 with 块里读文件时抛了异常,f 还会被关吗?上下文管理器的「异常安全」是怎么保证的?如果不用 with 手写 try/finally,你会怎么写才等价?
import jsoncount = 0with 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