Go 1.16 即將推出:ReadDir 與 DirEntry
身為 Python 的 os.scandir 函式與 PEP 471(scandir 的原始提案)的主要作者,看到 Go 在即將於 2021 年 2 月下旬發佈的 Go 1.16 中加入類似的功能,我感到非常高興。
在 Go 中,它將被稱為 os.ReadDir,並於去年九月提出。經過超過 100 則留言與數次設計調整後,已在十月由 Russ Cox 提交。新版 io/fs 套件中也收錄了與檔案系統無關的版本,即 fs.ReadDir。
為什麼需要 ReadDir?
簡短的答案是:效能。
當你呼叫系統函式來讀取目錄項目時,作業系統通常會同時回傳檔名與其類型(而在 Windows 上,還會包含 stat 資訊,例如檔案大小與最後修改時間)。然而,Go 與 Python 原先的介面卻丟棄了這些額外資訊,迫使你對每個項目再額外發起一次 stat 呼叫。系統呼叫本身就並不便宜,而 stat 還可能需要從磁碟讀取,或至少讀取磁碟快取。
在遞迴遍歷目錄樹時,你需要知道某個項目是檔案還是目錄,才能決定是否要遞迴深入。因此,即使只是簡單的目錄樹遍歷,也得讀取目錄項目並對每個項目執行 stat。但若善用作業系統提供的檔案類型資訊,就能省去這些 stat 呼叫,讓目錄遍歷速度快上數倍(在網路檔案系統上甚至可快上數十倍)。關於 Python 版本的一些效能數據,請見效能評測。
遺憾的是,兩種語言一開始讀取目錄的設計都不夠理想,無法在不額外呼叫 stat 的情況下取得類型資訊:Python 的是 os.listdir,Go 的則是 ioutil.ReadDir。
我最早在 2012 年想出 Python 中 scandir 背後的構想,並為 2015 年發佈的 Python 3.5 實作了它(閱讀更多關於這個過程的介紹)。此後它也不斷改進與擴充,例如支援 with 陳述式與檔案描述符。
至於 Go,除了基於我在 Python 版本上的經驗而提出幾則留言建議改進之外,我並未參與其提案或實作。
Python 與 Go
來看看新的「讀取目錄」介面,特別是 Python 與 Go 有多麼相似。
在 Python 中,你呼叫 os.scandir(path),它會回傳一個由 os.DirEntry 物件組成的迭代器,其定義如下:
class DirEntry:
# This entry's filename.
name: str
# This entry's full path: os.path.join(scandir_path, entry.name).
path: str
# Return inode or file ID for this entry.
def inode(self) -> int: ...
# Return True if this entry is a directory.
def is_dir(self, follow_symlinks=True) -> bool: ...
# Return True if this entry is a regular file.
def is_file(self, follow_symlinks=True) -> bool: ...
# Return True if this entry is a symbolic link.
def is_symlink(self) -> bool: ...
# Return stat information for this entry.
def stat(self, follow_symlinks=True) -> stat_result: ...存取 name 與 path 屬性永遠不會引發例外,但方法呼叫則可能會引發 OSError,取決於作業系統、檔案系統,以及該項目是否為符號連結。舉例來說,在 Linux 上,stat 始終會執行系統呼叫,因此可能會引發例外,但 is_X 系列方法通常不會。
在 Go 中,你呼叫 os.ReadDir(path),它會回傳一個 os.DirEntry 物件的 slice,長這樣:
type DirEntry interface {
// Returns the name of this entry's file (or subdirectory).
Name() string
// Reports whether the entry describes a directory.
IsDir() bool
// Returns the type bits for the entry (a subset of FileMode).
Type() FileMode
// Returns the FileInfo (stat information) for this entry.
Info() (FileInfo, error)
}你可以立刻看出兩者的相似之處,不過秉持 Go 一貫的風格,Go 的版本相對簡潔一些。事實上,如果要我重做 Python 的 scandir,我可能會力推稍微更簡單的介面——特別是拿掉 follow_symlinks 參數,並讓它預設不追蹤符號連結。
以下是一個使用 os.scandir 的範例——一個遞迴計算目錄及其所有子目錄中檔案總大小的函式:
def get_tree_size(path):
total = 0
with os.scandir(path) as entries:
for entry in entries:
if entry.is_dir(follow_symlinks=False):
total += get_tree_size(entry.path)
else:
total += entry.stat(follow_symlinks=False).st_size
return total在 Go(等 1.16 推出後)則會長這樣:
func GetTreeSize(path string) (int64, error) {
entries, err := os.ReadDir(path)
if err != nil {
return 0, err
}
var total int64
for _, entry := range entries {
if entry.IsDir() {
size, err := GetTreeSize(filepath.Join(path, entry.Name()))
if err != nil {
return 0, err
}
total += size
} else {
info, err := entry.Info()
if err != nil {
return 0, err
}
total += info.Size()
}
}
return total, nil
}兩者的高層結構很相似,不過當然會有人說:「看,Go 的錯誤處理帶來了多少樣板程式碼!」這話沒錯——Python 的程式碼非常簡潔。在小型腳本中這樣就很夠用了,而這正是 Python 的強項。
然而,在正式環境的程式碼或更為嚴謹的命令列工具中,你會希望在 stat 呼叫周圍捕捉錯誤,或許忽略權限錯誤,或將其記錄下來。Go 的程式碼明確指出錯誤可能發生,也讓你更容易加上日誌記錄或更友善的錯誤訊息。
更高層次的目錄樹遍歷
此外,兩種語言都有用來遞迴遍歷目錄樹的更高層次函式。在 Python 中,那就是 os.walk。scandir 在 Python 中美妙之處在於,os.walk 的簽章完全不需要改動,因此所有現有的 os.walk 使用者(數量眾多)都能自動獲得加速。
例如,使用 os.walk 來印出目錄樹中所有非隱藏檔(非以點開頭)的路徑:
def list_non_dot(path):
paths = []
for root, dirs, files in os.walk(path):
# Modify dirs to skip directories starting with '.'
dirs[:] = [d for d in dirs if not d.startswith('.')]
for f in files:
if f.startswith('.'):
continue
paths.append(os.path.join(root, f))
return sorted(paths)自 Python 3.5 起,os.walk 在底層改用 scandir 而非 listdir,因此這段程式碼會神奇地快上 1.5 到 20 倍,實際倍數取決於作業系統與檔案系統。
Go(1.16 之前)也有類似的函式 filepath.Walk,但遺憾的是,FileInfo 介面當初並未設計成能讓各個方法回報錯誤。如我們所見,這些方法有時會執行系統呼叫——例如,像 Size 這類 stat 資訊在 Linux 上就一定需要系統呼叫。因此在 Go 中,這些方法需要回傳 error(在 Python 中則是引發例外)。
原本有人想乾脆略過錯誤處理,直接重用 FileInfo 介面,讓現有程式碼能神奇地獲得加速。事實上,議題 41188 就是 Russ Cox 提出這樣建議的提案(並附上數據來說明這個想法其實沒聽起來那麼糟)。然而,stat 確實可能回傳錯誤,因此有可能在出錯時把檔案大小誤回傳為 0。結果,試圖將其硬塞進現有 API 的做法遭到相當大的反對,最後 Russ 也承認缺乏共識,轉而提出了 DirEntry 介面。
這意味著,要獲得效能提升,必須將 filepath.Walk 的呼叫改為 filepath.WalkDir——兩者非常相似,差別只在於遍歷函式接收的是 DirEntry 而非 FileInfo。
以下是使用現有 filepath.Walk 函式的 Go 版 list_non_dot 範例:
func ListNonDot(path string) ([]string, error) {
var paths []string
err := filepath.Walk(path, func(p string, info os.FileInfo,
err error) error {
if strings.HasPrefix(info.Name(), ".") {
if info.IsDir() {
return filepath.SkipDir
}
return err
}
if !info.IsDir() {
paths = append(paths, p)
}
return err
})
return paths, err
}這段程式碼在 Go 1.16 當然仍可繼續運作,但若想獲得效能上的好處,就得做一些非常小的修改——在這個例子中,只要把 Walk 改成 WalkDir,並把 os.FileInfo 改成 os.DirEntry:
err := filepath.WalkDir(path, func(p string, info os.DirEntry,順帶一提,在 Linux 上對我的家目錄執行第一個函式,在快取就緒後大約需要 580 毫秒。使用 Go 1.16 的新版本則約需 370 毫秒——大約快了 1.5 倍。差距不算巨大,但仍值得——而且在網路檔案系統與 Windows 上,你會獲得更大的加速。
總結
新的 ReadDir API 易於使用,並透過 fs.ReadDir 與新的檔案系統介面完美整合。而要加速現有的 Walk 呼叫,你只需做些微不足道的調整即可切換到 WalkDir。
API 設計很難。跨平台、與作業系統相關的 API 設計更是難上加難。設計下一個程式語言的標準函式庫時,務必把這件事做對! :-)
無論如何,我很高興 Go 在讀取目錄方面的支援不再落後——或者說,不再步行落後——於 Python。
隨機一篇部落格
留言
登入後參與討論