清華大佬告訴你如何使用Python:高效操作文件的三個建議

前言

在這個世界上,人們每天都在用 Python 完成著不同的工作。而文件操作,則是大家最常需要解決的任務之一。使用 Python,你可以輕鬆為他人生成精美的報表,也可以用短短几行代碼快速解析、整理上萬份數據文件。

清華大佬告訴你如何使用Python:高效操作文件的三個建議

當我們編寫與文件相關的代碼時,通常會關注這些事情:我的代碼是不是足夠快?我的代碼有沒有事半功倍的完成任務? 在這篇文章中,我會與你分享與之相關的幾個編程建議。我會向你推薦一個被低估的 Python 標準庫模塊、演示一個讀取大文件的最佳方式、最後再分享我對函數設計的一點思考。

如下圖回覆01獲得學習Python zi 廖!

清華大佬告訴你如何使用Python:高效操作文件的三個建議

下面,讓我們進入第一個“模塊安利”時間吧。

注意:因為不同操作系統的文件系統大不相同,本文的主要編寫環境為 Mac OS/Linux 系統,其中一些代碼可能並不適用於 Windows 系統。

建議一:使用 pathlib 模塊

如果你需要在 Python 裡進行文件處理,那麼標準庫中的 os 和 os.path 兄弟倆一定是你無法避開的兩個模塊。在這兩個模塊裡,有著非常多與文件路徑處理、文件讀寫、文件狀態查看相關的工具函數。

讓我用一個例子來展示一下它們的使用場景。有一個目錄裡裝了很多數據文件,但是它們的後綴名並不統一,既有 .txt,又有 .csv。我們需要把其中以 .txt 結尾的文件都修改為 .csv 後綴名。

我們可以寫出這樣一個函數:

清華大佬告訴你如何使用Python:高效操作文件的三個建議

讓我們看看,上面的代碼一共用到了哪些與文件處理相關的函數:

os.listdir(path):列出 path 目錄下的所有文件(含文件夾)

os.path.splitext(filename):切分文件名裡面的基礎名稱和後綴部分

os.path.join(path,filename):組合需要操作的文件名為絕對路徑

os.rename(...):重命名某個文件

上面的函數雖然可以完成需求,但說句實話,即使在寫了很多年 Python 代碼後,我依然覺得:這些函數不光很難記,而且最終的成品代碼也不怎麼討人喜歡。

使用 pathlib 模塊改寫代碼

為了讓文件處理變得更簡單,Python 在 3.4 版本引入了一個新的標準庫模塊:pathlib。它基於面向對象思想設計,封裝了非常多與文件操作相關的功能。如果使用它來改寫上面的代碼,結果會大不相同。

使用 pathlib 模塊後的代碼:

清華大佬告訴你如何使用Python:高效操作文件的三個建議

和舊代碼相比,新函數只需要兩行代碼就完成了工作。而這兩行代碼主要做了這麼幾件事:

1.首先使用 Path(path) 將字符串路徑轉換為 Path 對象

2.調用 .glob('*.txt') 對路徑下所有內容進行模式匹配並以生成器方式返回,結果仍然是 Path 對象,所以我們可以接著做後面的操作

3.使用 .with_suffix('.csv') 直接獲取使用新後綴名的文件全路徑

4.調用 .rename(target) 完成重命名

相比 os 和 os.path,引入 pathlib 模塊後的代碼明顯更精簡,也更有整體統一感。所有文件相關的操作都是一站式完成。

其他用法

除此之外,pathlib 模塊還提供了很多有趣的用法。比如使用 / 運算符來組合文件路徑:

清華大佬告訴你如何使用Python:高效操作文件的三個建議

或者使用 .read_text() 來快速讀取文件內容:

清華大佬告訴你如何使用Python:高效操作文件的三個建議

除了我在文章裡介紹的這些,pathlib 模塊還提供了非常多有用的方法,強烈建議去 官方文檔 詳細瞭解一下。

如果上面這些都不足以讓你動心,那麼我再多給你一個使用 pathlib 的理由:PEP-519 裡定義了一個專門用於“文件路徑”的新對象協議,這意味著從該 PEP 生效後的 Python 3.6 版本起,pathlib 裡的 Path 對象,可以和以前絕大多數只接受字符串路徑的標準庫函數兼容使用:

清華大佬告訴你如何使用Python:高效操作文件的三個建議

所以,無需猶豫,趕緊把 pathlib 模塊用起來吧。

Hint: 如果你使用的是更早的 Python 版本,可以嘗試安裝 pathlib2 模塊 。

建議二:掌握如何流式讀取大文件

幾乎所有人都知道,在 Python 裡讀取文件有一種“標準做法”:首先使用 withopen(fine_name) 上下文管理器的方式獲得一個文件對象,然後使用 for 循環迭代它,逐行獲取文件裡的內容。

下面是一個使用這種“標準做法”的簡單示例函數:

清華大佬告訴你如何使用Python:高效操作文件的三個建議

假如我們有一個文件 small_file.txt,那麼使用這個函數可以輕鬆計算出 9 的數量。

清華大佬告訴你如何使用Python:高效操作文件的三個建議

為什麼這種文件讀取方式會成為標準?這是因為它有兩個好處:

1.with 上下文管理器會自動關閉打開的文件描述符

2.在迭代文件對象時,內容是一行一行返回的,不會佔用太多內存

標準做法的缺點

但這套標準做法並非沒有缺點。如果被讀取的文件裡,根本就沒有任何換行符,那麼上面的第二個好處就不成立了。當代碼執行到 forlineinfile 時,line 將會變成一個非常巨大的字符串對象,消耗掉非常可觀的內存。

讓我們來做個試驗:有一個 5GB 大的文件 big_file.txt,它裡面裝滿了和 small_file.txt 一樣的隨機字符串。只不過它存儲內容的方式稍有不同,所有的文本都被放在了同一行裡:

清華大佬告訴你如何使用Python:高效操作文件的三個建議

如果我們繼續使用前面的 count_nine 函數去統計這個大文件裡 9 的個數。那麼在我的筆記本上,這個過程會足足花掉 65 秒,並在執行過程中吃掉機器 2GB 內存 [注1]。

使用 read 方法分塊讀取

為了解決這個問題,我們需要暫時把這個“標準做法”放到一邊,使用更底層的 file.read() 方法。與直接循環迭代文件對象不同,每次調用 file.read(chunk_size) 會直接返回從當前位置往後讀取 chunk_size 大小的文件內容,不必等待任何換行符出現。

所以,如果使用 file.read() 方法,我們的函數可以改寫成這樣:

清華大佬告訴你如何使用Python:高效操作文件的三個建議

在新函數中,我們使用了一個 while 循環來讀取文件內容,每次最多讀取 8kb 大小,這樣可以避免之前需要拼接一個巨大字符串的過程,把內存佔用降低非常多。

利用生成器解耦代碼

假如我們在討論的不是 Python,而是其他編程語言。那麼可以說上面的代碼已經很好了。但是如果你認真分析一下 count_nine_v2 函數,你會發現在循環體內部,存在著兩個獨立的邏輯:數據生成(read 調用與 chunk 判斷)數據消費。而這兩個獨立邏輯被耦合在了一起。

正如我在《編寫地道循環》這篇文章裡所提到的,為了提升複用能力,我們可以定義一個新的 chunked_file_reader 生成器函數,由它來負責所有與“數據生成”相關的邏輯。這樣 count_nine_v3裡面的主循環就只需要負責計數即可。

清華大佬告訴你如何使用Python:高效操作文件的三個建議

進行到這一步,代碼似乎已經沒有優化的空間了,但其實不然。iter(iterable) 是一個用來構造迭代器的內建函數,但它還有一個更少人知道的用法。當我們使用 iter(callable,sentinel) 的方式調用它時,會返回一個特殊的對象,迭代它將不斷產生可調用對象 callable 的調用結果,直到結果為 setinel 時,迭代終止。

清華大佬告訴你如何使用Python:高效操作文件的三個建議

最終,只需要兩行代碼,我們就完成了一個可複用的分塊文件讀取函數。那麼,這個函數在性能方面的表現如何呢?

和一開始的 2GB 內存/耗時 65 秒 相比,使用生成器的版本只需要 7MB 內存 / 12 秒 就能完成計算。效率提升了接近 4 倍,內存佔用更是不到原來的 1%。

建議三:設計接受文件對象的函數

統計完文件裡的 “9” 之後,讓我們換一個需求。現在,我想要統計每個文件裡出現了多少個英文元音字母(aeiou)。只要對之前的代碼稍作調整,很快就可以寫出新函數 count_vowels。

清華大佬告訴你如何使用Python:高效操作文件的三個建議

和之前“統計 9”的函數相比,新函數變得稍微複雜了一些。為了保證程序的正確性,我需要為它寫一些單元測試。但當我準備寫測試時,卻發現這件事情非常麻煩,主要問題點如下:

函數接收文件路徑作為參數,所以我們需要傳遞一個實際存在的文件為了準備測試用例,我要麼提供幾個樣板文件,要麼寫一些臨時文件而文件是否能被正常打開、讀取,也成了我們需要測試的邊界情況如果,你發現你的函數難以編寫單元測試,那通常意味著你應該改進它的設計。上面的函數應該如何改進呢?

答案是:讓函數依賴“文件對象”而不是文件路徑。

修改後的函數代碼如下:

清華大佬告訴你如何使用Python:高效操作文件的三個建議

這個改動帶來的主要變化,在於它提升了函數的適用面。因為 Python 是“鴨子類型”的,雖然函數需要接受文件對象,但其實我們可以把任何實現了文件協議的 “類文件對象(file-like object)” 傳入 count_vowels_v2 函數中。

而 Python 中有著非常多“類文件對象”。比如 io 模塊內的 StringIO 對象就是其中之一。它是一種基於內存的特殊對象,擁有和文件對象幾乎一致的接口設計。

利用 StringIO,我們可以非常方便的為函數編寫單元測試。

清華大佬告訴你如何使用Python:高效操作文件的三個建議

使用 pytest 運行測試可以發現,函數可以通過所有的用例:

清華大佬告訴你如何使用Python:高效操作文件的三個建議

而讓編寫單元測試變得更簡單,並非修改函數依賴後的唯一好處。除了 StringIO 外,subprocess 模塊調用系統命令時用來存儲標準輸出的 PIPE 對象,也是一種“類文件對象”。這意味著我們可以直接把某個命令的輸出傳遞給 count_vowels_v2 函數來計算元音字母數:

清華大佬告訴你如何使用Python:高效操作文件的三個建議

正如之前所說,將函數參數修改為“文件對象”,最大的好處是提高了函數的 適用面 和 可組合性。通過依賴更為抽象的“類文件對象”而非文件路徑,給函數的使用方式開啟了更多可能,StringIO、PIPE 以及任何其他滿足協議的對象都可以成為函數的客戶。

不過,這樣的改造並非毫無缺點,它也會給調用方帶來一些不便。假如調用方就是想要使用文件路徑,那麼就必須得自行處理文件的打開操作。

如何編寫兼容二者的函數

有沒有辦法即擁有“接受文件對象”的靈活性,又能讓傳遞文件路徑的調用方更方便?

答案是:有,而且標準庫中就有這樣的例子。

打開標準庫裡的 xml.etree.ElementTree 模塊,翻開裡面的 ElementTree.parse 方法。你會發現這個方法即可以使用文件對象調用,也接受字符串的文件路徑。而它實現這一點的手法也非常簡單易懂:

清華大佬告訴你如何使用Python:高效操作文件的三個建議

使用這種基於“鴨子類型”的靈活檢測方式, count_vowels_v2 函數也同樣可以被改造得更方便,我在這裡就不再重複啦。總結

文件操作我們在日常工作中經常需要接觸的領域,使用更方便的模塊、利用生成器節約內存以及編寫適用面更廣的函數,可以讓我們編寫出更高效的代碼。

1.使用 pathlib 模塊可以簡化文件和目錄相關的操作,並讓代碼更直觀

2.PEP-519 定義了表示“文件路徑”的標準協議,Path 對象實現了這個協議

3.通過定義生成器函數來分塊讀取大文件可以節約內存

4.使用 iter(callable,sentinel) 可以在一些特定場景簡化代碼

5.難以編寫測試的代碼,通常也是需要改進的代碼

6.讓函數依賴“類文件對象”可以提升函數的適用面和可組合性

註解

視機器空閒內存的多少,這個過程可能會消耗比 2GB 更多的內存。

最後

小編近幾年在學習Python!對於想學習Python的朋友們,我想說:很多人學了一個星期就放棄了,為什麼呢?其實沒有好的學習資料給你去學習,你們是很難堅持的,這是小編收集的Python入門學習資料!

清華大佬告訴你如何使用Python:高效操作文件的三個建議


分享到:


相關文章: