💬

filepath.Walkとfilepath.WalkDir

2021/10/17に公開

この記事を書こうと思った動機

  • 自分は今の仕事内容で全ファイルを列挙して調べるという作業が多いです
  • その時、個人的にも好きで、プロジェクトでも推奨されているGo言語を利用して色々ツールを作っています
  • その時、 filepath.Walk を使っていましたが、ある時 filepath.WalkDir が追加されていることを最近知りました
    • どうやらGo 1.16で追加されたようです
  • filepath.WalkDir がパフォーマンスいいらしいので色々調べてみました

filepath.Walkfilepath.WalkDir の違い

  • Issue に書いてありますがざっくりとだけ書きます
    • 間違ってたらすみません

共通していること

  • 第一引数の root に指定されたディレクトリから再帰的にファイルやディレクトリを列挙し、
    第二引数の fn でフィルタリングやエラーハンドリングする
  • シンボリックリンク未対応

filepath.Walk

  • 最初からある方
  • fn のコールバック関数型
type WalkFunc func(path string, info fs.FileInfo, err error) error
  • root に指定されたディレクトリ内容を file.ReadDir ですべて列挙してから fn のコールバックに渡していく

filepath.WalkDir

  • Go 1.16で追加されたほう
  • fn のコールバック関数型
type WalkDirFunc func(path string, d DirEntry, err error) error
  • 先に fn のコールバックを呼び出して filepath.SkipDir が返ってきたら以降の処理を飛ばす処理を行う
    • このため err != nil && err != filepath.SkipDir の場合 fn がもう一度呼び出される可能性がある
    • ただ、file.ReadDir は高コストなので基本的には速くなるはずとのこと

検証コード

かんたんな実装コード

  • GOPATH内になるファイルを列挙する簡単なコードとしました
    • 誰の環境でも試せるようにGOPATHとしましたが、検索するルートはお好みです
main.go
package main

import (
	"fmt"
	"io/fs"
	"os"
	"path/filepath"

	"github.com/pkg/errors"
)

func findFilesWithWalk(root string) ([]string, error) {
	findList := []string{}

	err := filepath.Walk(root, func(path string, info fs.FileInfo, err error) error {
		if err != nil {
			return errors.Wrap(err, "failed filepath.Walk")
		}

		if info.IsDir() {
			return nil
		}

		findList = append(findList, path)
		return nil
	})
	return findList, err
}

func findFilesWithWalkDir(root string) ([]string, error) {
	findList := []string{}

	err := filepath.WalkDir(root, func(path string, info fs.DirEntry, err error) error {
		if err != nil {
			return errors.Wrap(err, "failed filepath.WalkDir")
		}

		if info.IsDir() {
			return nil
		}

		findList = append(findList, path)
		return nil
	})
	return findList, err
}

func main() {
	SearchPath := os.ExpandEnv("${GOPATH}")

	findFilesWalk, err := findFilesWithWalk(SearchPath)
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}

	findFilesWalkDir, err := findFilesWithWalkDir(SearchPath)
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}

	// 結果一致確認
	// まずは検出数が同じか見る
	fmt.Printf("findFilesWithWalk %v\n", len(findFilesWalk))
	fmt.Printf("findFilesWithWalkDir %v\n", len(findFilesWalkDir))

	// 次に内容一致確認
	// 検出数が同じ状態で片方をmapのキーにして、その後キーをもう片方の要素で削除したとき
	// 0になれば一致したと言えるはず
	findMap := map[string]struct{}{}
	for _, filename := range findFilesWalk {
		findMap[filename] = struct{}{}
	}

	fmt.Printf("findMap %v\n", len(findMap))

	for _, filename := range findFilesWalkDir {
		delete(findMap, filename)
	}

	fmt.Printf("findMap %v\n", len(findMap))
}
実行結果
$ go run main.go
findFilesWithWalk 136220
findFilesWithWalkDir 136220
findMap 136220
findMap 0
  • 内容は一致しました
  • filepath.Walkfilepath.WalkDir の実装の違いはコールバック関数に引数くらいです

簡単なベンチマークコード

  • 単純に上記で書いた findFilesWithWalkfindFilesWithWalkDir を回数分比較するだけのコードとします
main_test.go
package main

import (
	"os"
	"testing"
)

func Benchmark_findFilesWithWalkDir(b *testing.B) {
	SearchPath := os.ExpandEnv("${GOPATH}")

	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		findFilesWithWalkDir(SearchPath)
	}
}

func Benchmark_findFilesWithWalk(b *testing.B) {
	SearchPath := os.ExpandEnv("${GOPATH}")

	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		findFilesWithWalk(SearchPath)
	}
}
実行結果
$ go test -bench . -benchmem
goos: windows
goarch: amd64
pkg: walk_test
cpu: AMD Ryzen 7 3700X 8-Core Processor
Benchmark_findFilesWithWalkDir-16              1        2804402600 ns/op        166610536 B/op   1196620 allocs/op
Benchmark_findFilesWithWalk-16                 1        10702168800 ns/op       316355376 B/op   1661835 allocs/op
PASS
ok      walk_test       13.553s
  • 結構な差が出ましたね
  • 今回の実装の場合 filepath.WalkDir のほうが
    filepath.Walk よりも以下の全てにおいていい結果が出ました
    • 実行時間
    • アロケート容量
    • アロケート回数

まとめ

  • Go 1.16以上ででディレクトリ内を列挙する実装は filepath.WalkDir を使ったほうがいいようです
    • filepath.Walk のほうがいいケースがあるかもしれませんが、
      自分は上記のような使い方を頻繁にするので
      filepath.WalkDir を積極的に使うようにします

Discussion