
讀寫文件是Python里最基礎(chǔ)也最繞不開的操作。無論你是在寫自動(dòng)化腳本、處理日志還是給項(xiàng)目做配置加載幾乎每天都要跟open、read、write這幾個(gè)函數(shù)打交道。很多人覺得這個(gè)功能簡單實(shí)際用起來才發(fā)現(xiàn)坑不少路徑寫錯(cuò)報(bào)FileNotFoundError中文讀出來亂碼一個(gè)不小心把幾百兆的文件一次性讀進(jìn)內(nèi)存程序直接卡死。這篇文章我就把讀寫文件這件事掰開揉碎講清楚從open的底層邏輯、各種打開模式到逐行讀大文件、編碼處理再到我踩過的一些典型問題。內(nèi)容適合剛學(xué)Python的新手也適合想系統(tǒng)補(bǔ)全細(xì)節(jié)、希望少走彎路的開發(fā)者。1. 先搞清楚讀寫文件的底層邏輯1.1 文件對象與打開方式的選擇Python讀寫文件核心就是內(nèi)置函數(shù)open()。它做兩件事第一向操作系統(tǒng)要一個(gè)文件句柄第二返回一個(gè)文件對象之后所有讀、寫、關(guān)閉操作都通過這個(gè)對象進(jìn)行。很多人學(xué)到這里會(huì)背“r只讀、w寫入、a追加”但很少去想為什么需要這么多模式。我把文件對象理解成一個(gè)水龍頭。你打開文件就是擰開水龍頭水流可以往外流讀也可以往水龍頭里灌水寫還可以一邊放水一邊接水讀寫。如果只開一個(gè)方向程序會(huì)清凈很多如果同時(shí)開兩個(gè)方向就要小心水會(huì)互相干擾。Python里最常用的模式有這幾個(gè)r只讀。文件必須已經(jīng)存在否則直接拋異常。w寫入。文件不存在就新建存在就清空原有內(nèi)容。這個(gè)模式非常危險(xiǎn)一個(gè)手滑就把舊數(shù)據(jù)全沖掉。a追加。文件不存在就新建存在則從末尾開始寫。它不會(huì)清空原有內(nèi)容。r讀寫。文件必須存在可以讀也可以寫但不會(huì)自動(dòng)清空文件。w寫讀。文件會(huì)被清空但之后可以讀。a追加讀??梢宰x整個(gè)文件但寫入只能在末尾追加。x排他創(chuàng)建。文件已存在就直接報(bào)錯(cuò)專門用來防覆蓋。實(shí)際操作中最常用的就是r、w、a偶爾用到r。很多人會(huì)問r和w到底什么區(qū)別核心在于w會(huì)清空文件而r不會(huì)。所以如果你只是想讀一個(gè)文件發(fā)現(xiàn)問題后修一下再寫回去用r更安全如果你想強(qiáng)制重寫整個(gè)文件w就夠用了非要加個(gè)反而容易讓人誤會(huì)。1.2 為什么推薦用with語句而不是手動(dòng)close剛學(xué)Python的時(shí)候我看到很多教材這樣寫文件f open(data.txt, r, encodingutf-8) content f.read() f.close()這種寫法有個(gè)隱患如果f.read()中途拋異常f.close()根本不會(huì)執(zhí)行文件句柄就漏掉了。短時(shí)間可能沒感覺但如果這個(gè)函數(shù)被反復(fù)調(diào)用句柄越積越多最后系統(tǒng)會(huì)報(bào)“打開的文件太多”之類的錯(cuò)誤。更麻煩的是在w模式下文件內(nèi)容可能只寫在緩沖區(qū)里沒等close()落盤程序就崩了數(shù)據(jù)直接丟。所以現(xiàn)代Python代碼幾乎全部用with上下文管理器with open(data.txt, r, encodingutf-8) as f: content f.read()with塊結(jié)束時(shí)會(huì)自動(dòng)調(diào)用f.close()哪怕塊內(nèi)部拋異常上下文的__exit__也會(huì)執(zhí)行釋放資源。這個(gè)設(shè)計(jì)的價(jià)值被嚴(yán)重低估。我做過一個(gè)批量處理任務(wù)最初用老式寫法連續(xù)處理一千多個(gè)文件后程序開始卡頓改成with之后問題消失。不是玄學(xué)就是句柄泄漏被堵住了。有人可能會(huì)問那一定要手動(dòng)close怎么辦可以放在try/finally里f open(data.bin, rb) try: data f.read() finally: f.close()但說實(shí)話既然with這么方便沒必要再用老辦法。能用with就用with這是我在讀寫文件這件事上最重要的經(jīng)驗(yàn)之一。2. 核心參數(shù)與讀寫方法的細(xì)節(jié)2.1 open函數(shù)的關(guān)鍵參數(shù)拆解open()最常見的是兩個(gè)參數(shù)但真正高效使用它必須理解encoding、newline、errors和buffering這幾個(gè)參數(shù)。encoding是文本模式下的編碼方案。如果不指定Python會(huì)用當(dāng)前環(huán)境默認(rèn)的編碼。同一段帶中文的文本在你的本地機(jī)器上正常換到服務(wù)器上可能直接UnicodeDecodeError。原因是不同環(huán)境默認(rèn)編碼不一樣。所以只要是文本文件我都會(huì)顯式寫encodingutf-8不亂猜。這里有個(gè)小細(xì)節(jié)讀取時(shí)如果遇到無法解碼的字符可以設(shè)置errorsignore跳過或者用errorsreplace把非法字符替換成替代符號(hào)。寫日志、爬蟲清洗文本時(shí)errorsignore能讓程序不中斷但如果數(shù)據(jù)要求嚴(yán)格最好還是errorsstrict讓它及時(shí)暴露問題不要默認(rèn)放過去。newline控制換行符處理。默認(rèn)情況下文本模式讀取時(shí)會(huì)把常見的換行符統(tǒng)一轉(zhuǎn)換成\n寫入時(shí)把\n轉(zhuǎn)換回當(dāng)前環(huán)境默認(rèn)的換行符。這在絕大多數(shù)場景沒問題但如果你處理CSV或者在不同系統(tǒng)間交換文件就會(huì)出現(xiàn)空行或者多出\r的怪問題。特別是用表格軟件打開CSV時(shí)如果行尾處理不對可能會(huì)出現(xiàn)“全部擠在一行”的現(xiàn)象。解決方法是寫入CSV時(shí)加newline。buffering控制緩沖區(qū)大小。文件讀寫不是每次操作都直接訪問磁盤而是先在內(nèi)存里攢一批數(shù)據(jù)。默認(rèn)的緩沖區(qū)策略通常都沒問題但如果需要實(shí)時(shí)寫入比如記錄日志時(shí)希望馬上落盤可以設(shè)置buffering1行緩沖或調(diào)用flush()。不需要一上來就調(diào)這個(gè)參數(shù)但至少要知道有這么回事。2.2 read、write、readline、readlines的區(qū)別文件對象提供了一堆讀寫方法選錯(cuò)會(huì)直接影響內(nèi)存占用和執(zhí)行效率。read()不帶參數(shù)時(shí)一次讀入整個(gè)文件內(nèi)容。這個(gè)方法適合小文件配置文件、幾十K的文本都沒問題。但對大文件來說等于把幾百兆數(shù)據(jù)直接塞進(jìn)內(nèi)存很可能直接撐爆。read(size)可以指定最大字符數(shù)常見做法是配合循環(huán)分批讀。readline()每次讀一行非常適合逐行處理但要注意如果你在一個(gè)超大的文件里只是過濾某些行直接for line in f更高效因?yàn)樗讓邮堑鞑粫?huì)一次性把全部行加載到內(nèi)存。readlines()則相反它會(huì)把所有行讀成一個(gè)列表文件很大時(shí)這方法其實(shí)就是read()的換行版內(nèi)存開銷一點(diǎn)不少。write()寫入字符串時(shí)不會(huì)自動(dòng)加換行。很多人第一次用write(hello)然后發(fā)現(xiàn)寫進(jìn)去沒有換行以為寫壞了。實(shí)際上換行符\n必須自己寫。writelines()也一樣它接收一個(gè)可迭代對象但不會(huì)自動(dòng)在每行之間補(bǔ)\n所以傳進(jìn)去的字符串必須自己帶換行符??催@個(gè)例子lines [第一行, 第二行, 第三行] with open(out.txt, w, encodingutf-8) as f: f.writelines(line \n for line in lines)如果不加\n三個(gè)字符串會(huì)連成一行這就是最常見的“寫入后沒有換行”問題根源。我的習(xí)慣是寫入文本時(shí)永遠(yuǎn)顯式拼接換行符不依賴任何默認(rèn)行為。3. 實(shí)操過程從零完成一個(gè)配置文件的讀寫3.1 場景設(shè)計(jì)一個(gè)簡單的配置讀寫工具假設(shè)手頭有一個(gè)配置文件app.conf里面是很簡單的鍵值對格式# 應(yīng)用配置 debugfalse port8080 name日志服務(wù)現(xiàn)在要寫一個(gè)工具讀取配置修改某個(gè)值再寫回文件。這個(gè)需求看起來很基礎(chǔ)但特別能體現(xiàn)讀寫文件的細(xì)節(jié)。第一步是讀取。用with open逐行解析跳過空行和注釋行config {} with open(app.conf, r, encodingutf-8) as f: for line in f: line line.strip() if not line or line.startswith(#): continue key, _, value line.partition() config[key.strip()] value.strip()partition()很關(guān)鍵它會(huì)返回三個(gè)部分分隔符左邊的字符串、分隔符本身、右邊的字符串。比直接split()更安全因?yàn)槿绻胹plit(, 1)還需要處理可能沒有等號(hào)的情況而partition在沒有等號(hào)時(shí)會(huì)返回原字符串和兩個(gè)空串邏輯更清晰。然后修改配置項(xiàng)config[debug] true最后寫回。這里有個(gè)容易踩的坑寫回時(shí)如果直接用w模式會(huì)先清空文件再寫入如果中途出問題配置文件就可能只剩一半。比較穩(wěn)妥的做法是先把內(nèi)容寫到臨時(shí)文件再替換原文件。但在簡單場景下直接寫回也夠用with open(app.conf, w, encodingutf-8) as f: for key, value in config.items(): f.write(f{key}{value}\n)注意字典在Python 3.7會(huì)保持插入順序所以讀取時(shí)的順序能被保留下來。如果你不關(guān)心順序直接用字典也沒問題如果需要嚴(yán)格保留注釋和順序那就不能只用字典得保留原始行結(jié)構(gòu)。很多配置管理工具會(huì)專門保留每行內(nèi)容和注釋就是因?yàn)楹唵巫值浞桨笗?huì)把所有注釋丟光。3.2 大文件處理與編碼兼容示例再來看一個(gè)更接近真實(shí)項(xiàng)目的場景處理一個(gè)很大的日志文件比如幾個(gè)GB的app.log需要統(tǒng)計(jì)某個(gè)關(guān)鍵詞出現(xiàn)了多少次。新手最容易寫成with open(app.log, r, encodingutf-8) as f: content f.read() count content.count(error)這個(gè)寫法在小文件上沒毛病但幾個(gè)GB的文件read()直接讓人懷疑人生內(nèi)存占用暴漲。正確做法是逐行迭代keyword error count 0 with open(app.log, r, encodingutf-8, errorsignore) as f: for line in f: if keyword in line: count 1 print(count)逐行迭代的好處是一次只處理一行內(nèi)存占用穩(wěn)定在很小的水平。errorsignore保證某些臟數(shù)據(jù)不會(huì)讓整個(gè)程序崩潰。你可能會(huì)擔(dān)心這樣逐行循環(huán)性能慢其實(shí)Python在迭代文件對象時(shí)底層有緩沖速度并不差甚至比你手動(dòng)readline()更快更安全。如果文件是二進(jìn)制比如圖片、壓縮包那就不能用encoding參數(shù)了。二進(jìn)制模式讀出來的是bytes對象。復(fù)制文件時(shí)常見的寫法是with open(source.png, rb) as src, open(dest.png, wb) as dst: while True: chunk src.read(4096) if not chunk: break dst.write(chunk)為什么用4096字節(jié)這么小的塊因?yàn)橐淮巫x太多會(huì)占內(nèi)存一次讀太少會(huì)增加系統(tǒng)調(diào)用。4KB到64KB是一般文件復(fù)制比較推薦的區(qū)間具體可以按場景調(diào)。寫完后dst會(huì)在with塊結(jié)束時(shí)自動(dòng)關(guān)閉把緩沖區(qū)數(shù)據(jù)刷到磁盤比手動(dòng)close()可靠得多。這個(gè)二進(jìn)制復(fù)制套路在遷移文件、處理音頻視頻時(shí)都通用。4. 常見問題與排查技巧實(shí)錄4.1 路徑和文件不存在導(dǎo)致的異常最常見的報(bào)錯(cuò)就是FileNotFoundError。很多人看到一個(gè)路徑報(bào)錯(cuò)第一反應(yīng)是“文件明明在啊”其實(shí)問題多半出在相對路徑上。Python腳本的工作目錄不一定是你雙擊腳本時(shí)的那個(gè)目錄。比如你在項(xiàng)目根目錄運(yùn)行腳本腳本里卻寫open(config/app.conf)這個(gè)相對路徑是以當(dāng)前工作目錄為基準(zhǔn)的不是腳本所在目錄??孔V的做法是用pathlib或os.path來構(gòu)建絕對路徑from pathlib import Path base_dir Path(__file__).resolve().parent file_path base_dir / config / app.conf with open(file_path, r, encodingutf-8) as f: content f.read()Path(__file__).resolve().parent能拿到腳本文件所在的絕對目錄這樣無論你從哪個(gè)目錄運(yùn)行路徑都能對上。這個(gè)寫法我在多個(gè)項(xiàng)目里反復(fù)用基本不會(huì)錯(cuò)。另外一個(gè)相關(guān)的問題是需要自動(dòng)創(chuàng)建目錄。如果你用w模式寫文件但文件所在的目錄不存在同樣會(huì)報(bào)FileNotFoundError。正確做法是先建目錄out_dir Path(logs) out_dir.mkdir(parentsTrue, exist_okTrue) with open(out_dir / app.log, a, encodingutf-8) as f: f.write(new log line\n)parentsTrue會(huì)遞歸創(chuàng)建缺失的父目錄exist_okTrue表示目錄已存在也不報(bào)錯(cuò)。這兩個(gè)參數(shù)組合起來是最穩(wěn)的。4.2 編碼亂碼與換行符問題處理中文文件時(shí)UnicodeDecodeError幾乎是必會(huì)遇到。最常見原因是用默認(rèn)編碼讀取一個(gè)UTF-8或GBK編碼的文件兩邊不對上就會(huì)報(bào)錯(cuò)或出現(xiàn)亂碼。解決思路很簡單先確認(rèn)文件的實(shí)際編碼然后在open()里顯式指定encodingutf-8很多老文件其實(shí)是GBK可以試試encodinggbk。如果實(shí)在不知道編碼可以用二進(jìn)制方式讀一段字節(jié)用一些工具去猜但最推薦的做法是讓數(shù)據(jù)方明確編碼格式避免猜來猜去。另一個(gè)很隱蔽的問題是寫CSV后用表格軟件打開中文全部亂碼。原因是很多表格軟件默認(rèn)按系統(tǒng)本地編碼打開CSV而UTF-8不帶BOM時(shí)它識(shí)別不了。解決方法是在寫CSV時(shí)用utf-8-sig編碼import csv with open(data.csv, w, newline, encodingutf-8-sig) as f: writer csv.writer(f) writer.writerow([名稱, 數(shù)量])utf-8-sig會(huì)在文件開頭寫入BOM標(biāo)記表格軟件就能正確識(shí)別編碼。另外newline非常關(guān)鍵它能避免CSV寫入時(shí)出現(xiàn)額外的空行。這個(gè)細(xì)節(jié)很多人不知道第一次寫CSV時(shí)總會(huì)多出空行多半就是忘了這個(gè)參數(shù)。換行符的坑也很多。不同操作系統(tǒng)用的行尾符號(hào)不一樣默認(rèn)情況下Python在文本模式會(huì)幫你轉(zhuǎn)換。但如果你想精確控制文件內(nèi)容比如做diff對比、簽名校驗(yàn)轉(zhuǎn)換反而壞事。這時(shí)候可以用newlinePython就不會(huì)做任何換行轉(zhuǎn)換把原始內(nèi)容原樣讀進(jìn)來或?qū)懗鋈?。我開始做跨平臺(tái)文件處理時(shí)就是因?yàn)闆]有設(shè)newline同一份文件在不同環(huán)境讀出來字節(jié)不一致排查了很久才找到原因。4.3 文件被占用、緩沖未寫入等狀態(tài)問題有時(shí)候你打開文件讀取文件正被另一個(gè)進(jìn)程占用要么報(bào)PermissionError要么讀到的數(shù)據(jù)不完整。這不是Python能直接解決的你得先釋放句柄。在腳本內(nèi)部最常見的原因是在with塊外還保留著文件對象的引用或者沒有用with導(dǎo)致文件沒關(guān)閉。還有一種情況特別容易忽悠人你在w模式下寫入內(nèi)容文件明明已經(jīng)寫入但查看磁盤文件卻發(fā)現(xiàn)是空的。原因是數(shù)據(jù)在緩沖區(qū)里沒有刷新到磁盤。with塊結(jié)束時(shí)一定會(huì)關(guān)閉文件并刷緩沖所以只要代碼結(jié)構(gòu)正確不應(yīng)該出現(xiàn)這個(gè)問題。但如果因?yàn)槟撤N原因你用了老式寫法只調(diào)了write()不調(diào)close()內(nèi)容確實(shí)可能還在內(nèi)存里。臨時(shí)方案是f.flush()它會(huì)立刻把緩沖區(qū)內(nèi)容推給操作系統(tǒng)但注意這不等同于強(qiáng)制落盤系統(tǒng)層面可能還有緩存。真要強(qiáng)制寫盤得用os.fsync()不過日常開發(fā)很少需要。另一個(gè)狀態(tài)問題是讀寫同一個(gè)文件。用w模式寫完后馬上read()讀出來是空的因?yàn)槲募羔樛T谀┪?。必須先seek(0)回到開頭with open(data.txt, w, encodingutf-8) as f: f.write(hello) f.flush() f.seek(0) data f.read()這個(gè)flush()也一樣容易被忽略。如果不先刷新seek(0)后讀到的可能還是緩沖區(qū)之前的數(shù)據(jù)而出于安全考慮在寫模式和讀模式切換之間最好先讓數(shù)據(jù)落一下緩沖??偟膩碚f盡量減少在同一文件上既讀又寫能拆成兩個(gè)文件就拆成兩個(gè)文件這是最省心的。5. 實(shí)操后想補(bǔ)充的幾個(gè)小經(jīng)驗(yàn)5.1 讀寫同一個(gè)文件最穩(wěn)妥的方式在實(shí)際項(xiàng)目里我最常用的“修改文件”方式不是r也不是w而是“讀原文件寫臨時(shí)文件替換原文件”。流程是用r讀原文件內(nèi)容處理完后用w寫到一個(gè)加了后綴的臨時(shí)文件最后用os.replace()把臨時(shí)文件替換原文件。import os from pathlib import Path src Path(data.txt) tmp src.with_suffix(.tmp) with open(src, r, encodingutf-8) as f: content f.read() content content.replace(舊值, 新值) with open(tmp, w, encodingutf-8) as f: f.write(content) os.replace(tmp, src)這樣做的好處是讀寫分離不會(huì)因?yàn)橹型緢?bào)錯(cuò)把原文件寫壞os.replace()在同一個(gè)文件系統(tǒng)內(nèi)是原子操作要么替換成功要么保持原樣不會(huì)出現(xiàn)半截文件。寫配置、改JSON、做文本替換我都建議用這個(gè)思路。雖然多寫了幾行代碼但可靠性提升非常明顯。5.2 我保留的使用習(xí)慣最后分享幾個(gè)個(gè)人習(xí)慣都是我踩過幾次坑之后慢慢總結(jié)出來的。第一凡是文本文件必寫encodingutf-8必要時(shí)加errorsignore。這個(gè)習(xí)慣看起來簡單卻能讓代碼在不同電腦、不同服務(wù)器上運(yùn)行結(jié)果保持一致。我不再相信環(huán)境默認(rèn)編碼。第二能用with絕不用手動(dòng)close()。哪怕只是讀一個(gè)幾行的配置文件我也用with。這不是強(qiáng)迫癥而是避免在異常路徑上泄漏句柄。第三讀大文件時(shí)永遠(yuǎn)用迭代不要隨便read()。我在做日志分析時(shí)最大的一個(gè)數(shù)據(jù)文件有十幾GB逐行迭代內(nèi)存占用一直平穩(wěn)。如果你不確定文件大小最好默認(rèn)它是大文件。第四寫文件前先想清楚目錄是否存在能建目錄就先建目錄。一個(gè)mkdir(parentsTrue, exist_okTrue)能省掉很多FileNotFoundError的煩惱。讀寫文件看起來簡單但這些細(xì)節(jié)才是真正影響項(xiàng)目穩(wěn)定性的地方。最好還是養(yǎng)成“每次寫代碼都多問一句這個(gè)文件名存在嗎編碼對嗎大小會(huì)不會(huì)很大”的習(xí)慣。你踩過的坑往往就藏在這些不起眼的參數(shù)里。