天天看點

pathlib python去除字尾名_Python 工匠:高效操作檔案的三個建議

前言

這是 “Python 工匠”系列的第 11 篇文章。[檢視系列所有文章]
pathlib python去除字尾名_Python 工匠:高效操作檔案的三個建議

在這個世界上,人們每天都在用 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'))
           

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

  • os.listdir(path)

    :列出 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'))
           

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

  1. 首先使用 Path(path) 将字元串路徑轉換為

    Path

    對象
  2. 調用 .glob('*.txt') 對路徑下所有内容進行模式比對并以生成器方式傳回,結果仍然是

    Path

    對象,是以我們可以接着做後面的操作
  3. 使用 .with_suffix('.csv') 直接擷取使用新字尾名的檔案全路徑
  4. 調用 .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'))
           

為什麼這種檔案讀取方式會成為标準?這是因為它有兩個好處:

  1. with

    上下文管理器會自動關閉打開的檔案描述符
  2. 在疊代檔案對象時,内容是一行一行傳回的,不會占用太多記憶體

标準做法的缺點

但這套标準做法并非沒有缺點。如果被讀取的檔案裡,根本就沒有任何換行符,那麼上面的第二個好處就不成立了。

當代碼執行到

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”的函數相比,新函數變得稍微複雜了一些。為了保證程式的正确性,我需要為它寫一些單元測試。但當我準備寫測試時,卻發現這件事情非常麻煩,主要問題點如下:

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

上面的函數應該如何改進呢?答案是:讓函數依賴“檔案對象”而不是檔案路徑。

修改後的函數代碼如下:

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 工匠:編寫道地循環的兩個建議

注解

  1. 視機器空閑記憶體的多少,這個過程可能會消耗比 2GB 更多的記憶體。