前言
這是 “Python 工匠”系列的第 11 篇文章。[檢視系列所有文章]

在這個世界上,人們每天都在用 Python 完成着不同的工作。而檔案操作,則是大家最常需要解決的任務之一。使用 Python,你可以輕松為他人生成精美的報表,也可以用短短幾行代碼快速解析、整理上萬份資料檔案。
當我們編寫與檔案相關的代碼時,通常會關注這些事情:
我的代碼是不是足夠快?我的代碼有沒有事半功倍的完成任務?在這篇文章中,我會與你分享與之相關的幾個程式設計建議。我會向你推薦一個被低估的 Python 标準庫子產品、示範一個讀取大檔案的最佳方式、最後再分享我對函數設計的一點思考。
下面,讓我們進入第一個“子產品安利”時間吧。
注意: 因為不同作業系統的檔案系統大不相同,本文的主要編寫環境為 Mac OS/Linux 系統,其中一些代碼可能并不适用于 Windows 系統。
建議一:使用 pathlib 子產品
如果你需要在 Python 裡進行檔案處理,那麼标準庫中的
os
和
os.path
兄弟倆一定是你無法避開的兩個子產品。在這兩個子產品裡,有着非常多與檔案路徑處理、檔案讀寫、檔案狀态檢視相關的工具函數。
讓我用一個例子來展示一下它們的使用場景。有一個目錄裡裝了很多資料檔案,但是它們的字尾名并不統一,既有
.txt
,又有
.csv
。我們需要把其中以
.txt
結尾的檔案都修改為
.csv
字尾名。
我們可以寫出這樣一個函數:
import os
import os.path
def unify_ext_with_os_path(path):
"""統一目錄下的 .txt 檔案名字尾為 .csv
"""
for filename in os.listdir(path):
basename, ext = os.path.splitext(filename)
if ext == '.txt':
abs_filepath = os.path.join(path, filename)
os.rename(abs_filepath, os.path.join(path, f'{basename}.csv'))
讓我們看看,上面的代碼一共用到了哪些與檔案處理相關的函數:
-
:列出 path 目錄下的所有檔案(含檔案夾)os.listdir(path)
-
:切分檔案名裡面的基礎名稱和字尾部分os.path.splitext(filename)
-
:組合需要操作的檔案名為絕對路徑os.path.join(path, filename)
-
:重命名某個檔案os.rename(...)
上面的函數雖然可以完成需求,但說句實話,即使在寫了很多年 Python 代碼後,我依然覺得:
這些函數不光很難記,而且最終的成品代碼也不怎麼讨人喜歡。使用 pathlib 子產品改寫代碼
為了讓檔案處理變得更簡單,Python 在 3.4 版本引入了一個新的标準庫子產品:pathlib。它基于面向對象思想設計,封裝了非常多與檔案操作相關的功能。如果使用它來改寫上面的代碼,結果會大不相同。
使用 pathlib 子產品後的代碼:
from pathlib import Path
def unify_ext_with_pathlib(path):
for fpath in Path(path).glob('*.txt'):
fpath.rename(fpath.with_suffix('.csv'))
和舊代碼相比,新函數隻需要兩行代碼就完成了工作。而這兩行代碼主要做了這麼幾件事:
- 首先使用 Path(path) 将字元串路徑轉換為
對象Path
- 調用 .glob('*.txt') 對路徑下所有内容進行模式比對并以生成器方式傳回,結果仍然是
對象,是以我們可以接着做後面的操作Path
- 使用 .with_suffix('.csv') 直接擷取使用新字尾名的檔案全路徑
- 調用 .rename(target) 完成重命名
相比
os
和
os.path
,引入
pathlib
子產品後的代碼明顯更精簡,也更有整體統一感。所有檔案相關的操作都是一站式完成。
其他用法
除此之外,pathlib 子產品還提供了很多有趣的用法。比如使用
/
運算符來組合檔案路徑:
# 舊朋友:使用 os.path 子產品
>>> import os.path
>>> os.path.join('/tmp', 'foo.txt')
'/tmp/foo.txt'
# ✨ 新潮流:使用 / 運算符
>>> from pathlib import Path
>>> Path('/tmp') / 'foo.txt'
PosixPath('/tmp/foo.txt')
或者使用
.read_text()
來快速讀取檔案内容:
# 标準做法,使用 with open(...) 打開檔案
>>> with open('foo.txt') as file:
... print(file.read())
...
foo
# 使用 pathlib 可以讓這件事情變得更簡單
>>> from pathlib import Path
>>> print(Path('foo.txt').read_text())
foo
除了我在文章裡介紹的這些,pathlib 子產品還提供了非常多有用的方法,強烈建議去 官方文檔 詳細了解一下。
如果上面這些都不足以讓你動心,那麼我再多給你一個使用 pathlib 的理由:PEP-519 裡定義了一個專門用于“檔案路徑”的新對象協定,這意味着從該 PEP 生效後的 Python 3.6 版本起,pathlib 裡的 Path 對象,可以和以前絕大多數隻接受字元串路徑的标準庫函數相容使用:
>>> p = Path('/tmp')
# 可以直接對 Path 類型對象 p 進行 join
>>> os.path.join(p, 'foo.txt')
'/tmp/foo.txt'
是以,無需猶豫,趕緊把 pathlib 子產品用起來吧。
Hint: 如果你使用的是更早的 Python 版本,可以嘗試安裝 pathlib2 子產品 。
建議二:掌握如何流式讀取大檔案
幾乎所有人都知道,在 Python 裡讀取檔案有一種“标準做法”:首先使用
with open(fine_name)
上下文管理器的方式獲得一個檔案對象,然後使用
for
循環疊代它,逐行擷取檔案裡的内容。
下面是一個使用這種“标準做法”的簡單示例函數:
def count_nine(fname):
"""計算檔案裡包含多少個數字 '9'
"""
count = 0
with open(fname) as file:
for line in file:
count += line.count('9')
return count
假如我們有一個檔案
small_file.txt
,那麼使用這個函數可以輕松計算出 9 的數量。
# small_file.txt
feiowe9322nasd9233rl
aoeijfiowejf8322kaf9a
# OUTPUT: 3
print(count_nine('small_file.txt'))
為什麼這種檔案讀取方式會成為标準?這是因為它有兩個好處:
-
上下文管理器會自動關閉打開的檔案描述符with
- 在疊代檔案對象時,内容是一行一行傳回的,不會占用太多記憶體
标準做法的缺點
但這套标準做法并非沒有缺點。如果被讀取的檔案裡,根本就沒有任何換行符,那麼上面的第二個好處就不成立了。
當代碼執行到for line in file
時,line 将會變成一個非常巨大的字元串對象,消耗掉非常可觀的記憶體。 讓我們來做個試驗:有一個
5GB大的檔案
big_file.txt
,它裡面裝滿了和
small_file.txt
一樣的随機字元串。隻不過它存儲内容的方式稍有不同,所有的文本都被放在了同一行裡:
# FILE: big_file.txt
df2if283rkwefh... <剩餘 5GB 大小> ...
如果我們繼續使用前面的
count_nine
函數去統計這個大檔案裡
9
的個數。那麼在我的筆記本上,這個過程會足足花掉
65秒,并在執行過程中吃掉機器
2GB記憶體 [注1]。
使用 read 方法分塊讀取
為了解決這個問題,我們需要暫時把這個“标準做法”放到一邊,使用更底層的
file.read()
方法。與直接循環疊代檔案對象不同,每次調用
file.read(chunk_size)
會直接傳回從目前位置往後讀取
chunk_size
大小的檔案内容,不必等待任何換行符出現。
是以,如果使用
file.read()
方法,我們的函數可以改寫成這樣:
def count_nine_v2(fname):
"""計算檔案裡包含多少個數字 '9',每次讀取 8kb
"""
count = 0
block_size = 1024 * 8
with open(fname) as fp:
while True:
chunk = fp.read(block_size)
# 當檔案沒有更多内容時,read 調用将會傳回空字元串 ''
if not chunk:
break
count += chunk.count('9')
return count
在新函數中,我們使用了一個
while
循環來讀取檔案内容,每次最多讀取 8kb 大小,這樣可以避免之前需要拼接一個巨大字元串的過程,把記憶體占用降低非常多。
利用生成器解耦代碼
假如我們在讨論的不是 Python,而是其他程式設計語言。那麼可以說上面的代碼已經很好了。但是如果你認真分析一下
count_nine_v2
函數,你會發現在循環體内部,存在着兩個獨立的邏輯:
資料生成(read 調用與 chunk 判斷)與
資料消費。而這兩個獨立邏輯被耦合在了一起。
正如我在《編寫道地循環》裡所提到的,為了提升複用能力,我們可以定義一個新的
chunked_file_reader
生成器函數,由它來負責所有與“資料生成”相關的邏輯。這樣
count_nine_v3
裡面的主循環就隻需要負責計數即可。
def chunked_file_reader(fp, block_size=1024 * 8):
"""生成器函數:分塊讀取檔案内容
"""
while True:
chunk = fp.read(block_size)
# 當檔案沒有更多内容時,read 調用将會傳回空字元串 ''
if not chunk:
break
yield chunk
def count_nine_v3(fname):
count = 0
with open(fname) as fp:
for chunk in chunked_file_reader(fp):
count += chunk.count('9')
return count
進行到這一步,代碼似乎已經沒有優化的空間了,但其實不然。iter(iterable) 是一個用來構造疊代器的内建函數,但它還有一個更少人知道的用法。當我們使用
iter(callable, sentinel)
的方式調用它時,會傳回一個特殊的對象,疊代它将不斷産生可調用對象 callable 的調用結果,直到結果為 setinel 時,疊代終止。
def chunked_file_reader(file, block_size=1024 * 8):
"""生成器函數:分塊讀取檔案内容,使用 iter 函數
"""
# 首先使用 partial(fp.read, block_size) 構造一個新的無需參數的函數
# 循環将不斷傳回 fp.read(block_size) 調用結果,直到其為 '' 時終止
for chunk in iter(partial(file.read, block_size), ''):
yield chunk
最終,隻需要兩行代碼,我們就完成了一個可複用的分塊檔案讀取函數。那麼,這個函數在性能方面的表現如何呢?
和一開始的
2GB 記憶體/耗時 65 秒相比,使用生成器的版本隻需要
7MB 記憶體 / 12 秒就能完成計算。效率提升了接近 4 倍,記憶體占用更是不到原來的 1%。
建議三:設計接受檔案對象的函數
統計完檔案裡的 “9” 之後,讓我們換一個需求。現在,我想要統計每個檔案裡出現了多少個英文元音字母(aeiou)。隻要對之前的代碼稍作調整,很快就可以寫出新函數
count_vowels
。
def count_vowels(filename):
"""統計某個檔案中,包含元音字母(aeiou)的數量
"""
VOWELS_LETTERS = {'a', 'e', 'i', 'o', 'u'}
count = 0
with open(filename, 'r') as fp:
for line in fp:
for char in line:
if char.lower() in VOWELS_LETTERS:
count += 1
return count
# OUTPUT: 16
print(count_vowels('small_file.txt'))
和之前“統計 9”的函數相比,新函數變得稍微複雜了一些。為了保證程式的正确性,我需要為它寫一些單元測試。但當我準備寫測試時,卻發現這件事情非常麻煩,主要問題點如下:
- 函數接收檔案路徑作為參數,是以我們需要傳遞一個實際存在的檔案
- 為了準備測試用例,我要麼提供幾個樣闆檔案,要麼寫一些臨時檔案
- 而檔案是否能被正常打開、讀取,也成了我們需要測試的邊界情況
上面的函數應該如何改進呢?答案是:讓函數依賴“檔案對象”而不是檔案路徑。
修改後的函數代碼如下:
def count_vowels_v2(fp):
"""統計某個檔案中,包含元音字母(aeiou)的數量
"""
VOWELS_LETTERS = {'a', 'e', 'i', 'o', 'u'}
count = 0
for line in fp:
for char in line:
if char.lower() in VOWELS_LETTERS:
count += 1
return count
# 修改函數後,打開檔案的職責被移交給了上層函數調用者
with open('small_file.txt') as fp:
print(count_vowels_v2(fp))
這個改動帶來的主要變化,在于它提升了函數的适用面。 因為 Python 是“鴨子類型”的,雖然函數需要接受檔案對象,但其實我們可以把任何實作了檔案協定的 “類檔案對象(file-like object)” 傳入
count_vowels_v2
函數中。
而 Python 中有着非常多“類檔案對象”。比如 io 子產品内的 StringIO 對象就是其中之一。它是一種基于記憶體的特殊對象,擁有和檔案對象幾乎一緻的接口設計。
利用 StringIO,我們可以非常友善的為函數編寫單元測試。
# 注意:以下測試函數需要使用 pytest 執行
import pytest
from io import StringIO
@pytest.mark.parametrize(
"content,vowels_count", [
# 使用 pytest 提供的參數化測試工具,定義測試參數清單
# (檔案内容, 期待結果)
('', 0),
('Hello World!', 3),
('HELLO WORLD!', 3),
('你好,世界', 0),
]
)
def test_count_vowels_v2(content, vowels_count):
# 利用 StringIO 構造類檔案對象 "file"
file = StringIO(content)
assert count_vowels_v2(file) == vowels_count
使用 pytest 運作測試可以發現,函數可以通過所有的用例:
❯ pytest vowels_counter.py
====== test session starts ======
collected 4 items
vowels_counter.py ... [100%]
====== 4 passed in 0.06 seconds ======
而讓編寫單元測試變得更簡單,并非修改函數依賴後的唯一好處。除了 StringIO 外,subprocess 子產品調用系統指令時用來存儲标準輸出的 PIPE 對象,也是一種“類檔案對象”。這意味着我們可以直接把某個指令的輸出傳遞給
count_vowels_v2
函數來計算元音字母數:
import subprocess
# 統計 /tmp 下面所有一級子檔案名(目錄名)有多少元音字母
p = subprocess.Popen(['ls', '/tmp'], stdout=subprocess.PIPE, encoding='utf-8')
# p.stdout 是一個流式類檔案對象,可以直接傳入函數
# OUTPUT: 42
print(count_vowels_v2(p.stdout))
正如之前所說,将函數參數修改為“檔案對象”,最大的好處是提高了函數的
适用面和
可組合性。通過依賴更為抽象的“類檔案對象”而非檔案路徑,給函數的使用方式開啟了更多可能,StringIO、PIPE 以及任何其他滿足協定的對象都可以成為函數的客戶。
不過,這樣的改造并非毫無缺點,它也會給調用方帶來一些不便。假如調用方就是想要使用檔案路徑,那麼就必須得自行處理檔案的打開操作。
如何編寫相容二者的函數
有沒有辦法即擁有“接受檔案對象”的靈活性,又能讓傳遞檔案路徑的調用方更友善?答案是:有,而且标準庫中就有這樣的例子。
打開标準庫裡的
xml.etree.ElementTree
子產品,翻開裡面的
ElementTree.parse
方法。你會發現這個方法即可以使用檔案對象調用,也接受字元串的檔案路徑。而它實作這一點的手法也非常簡單易懂:
def parse(self, source, parser=None):
"""*source* is a file name or file object, *parser* is an optional parser
"""
close_source = False
# 通過判斷 source 是否有 "read" 屬性來判定它是不是“類檔案對象”
# 如果不是,那麼調用 open 函數打開它并負擔起在函數末尾關閉它的責任
if not hasattr(source, "read"):
source = open(source, "rb")
close_source = True
使用這種基于“鴨子類型”的靈活檢測方式,
count_vowels_v2
函數也同樣可以被改造得更友善,我在這裡就不再重複啦。
總結
檔案操作我們在日常工作中經常需要接觸的領域,使用更友善的子產品、利用生成器節約記憶體以及編寫适用面更廣的函數,可以讓我們編寫出更高效的代碼。
讓我們最後再總結一下吧:
- 使用 pathlib 子產品可以簡化檔案和目錄相關的操作,并讓代碼更直覺
- PEP-519 定義了表示“檔案路徑”的标準協定,Path 對象實作了這個協定
- 通過定義生成器函數來分塊讀取大檔案可以節約記憶體
- 使用
可以在一些特定場景簡化代碼iter(callable, sentinel)
- 難以編寫測試的代碼,通常也是需要改進的代碼
- 讓函數依賴“類檔案對象”可以提升函數的适用面和可組合性
看完文章的你,有沒有什麼想吐槽的?請留言或者在 項目 Github Issues 告訴我吧。
附錄
- 題圖來源: Photo by Devon Divine on Unsplash
- 更多系列文章位址:https://github.com/piglei/one-python-craftsman
系列其他文章:
- 所有文章索引 [Github]
- Python 工匠:編寫條件分支代碼的技巧
- Python 工匠:異常處理的三個好習慣
- Python 工匠:編寫道地循環的兩個建議
注解
- 視機器空閑記憶體的多少,這個過程可能會消耗比 2GB 更多的記憶體。