Coming in Go 1.16: ReadDir and DirEntry

Ben Hoyt

Go 1.16で登場: ReadDirとDirEntry

原文は Ben Hoyt により に公開されました。 このブログを購読する

Pythonのos.scandir関数とPEP 471(scandirの最初の提案)の主著者として、GoがGo 1.16で同様の機能を追加するのを見てとてもうれしく思いました。Go 1.16は2021年2月下旬にリリース予定です。

Goではos.ReadDirという名前になり、昨年9月に提案されました。100件以上のコメントと設計への度重なる調整を経て、10月にRuss Coxによってコミットされました。ファイルシステムに依存しないバージョンも、新しいio/fsパッケージにfs.ReadDirとして含まれています。

なぜReadDirが必要なのか

一言で言えば、パフォーマンスのためです。

ディレクトリエントリを読み取るシステム関数を呼び出すと、OSは通常、ファイル名その種別(Windowsではファイルサイズや最終更新時刻などのstat情報も)を返します。しかし、GoとPythonの当初のインターフェースはこの追加情報を捨ててしまっていたため、エントリごとにstatを1回呼び出す必要がありました。システムコール自体がそもそも安価ではないうえ、statはディスク、少なくともディスクキャッシュからの読み込みを伴う可能性があります。

ディレクトリツリーを再帰的にたどる際には、再帰すべきかどうか判断するために、エントリがファイルなのかディレクトリなのかを知る必要があります。そのため、単純なディレクトリツリーの走査でさえ、ディレクトリエントリの読み取り各エントリのstatが必要でした。しかし、OSが提供するファイル種別の情報を利用すれば、そうしたstat呼び出しを回避し、ディレクトリの走査を数倍、ネットワークファイルシステムでは数十倍も高速化できます。Python版のベンチマークも参照してください。

残念ながら、どちらの言語もディレクトリ読み取りの設計が最適ではないところから始まっており、statを追加で呼び出さなければ種別情報にアクセスできませんでした。Pythonではos.listdir、Goではioutil.ReadDirです。

私がPythonのscandirの背後にあるアイデアを最初に思いついたのは2012年で、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属性へのアクセスで例外が発生することは決してありませんが、メソッド呼び出しはOSやファイルシステム、エントリがシンボリックリンクかどうかによってOSErrorを送出する場合があります。たとえばLinuxでは、statは常にシステムコールを実行するため例外が発生し得ますが、is_X系のメソッドは通常そうなりません。

Goではos.ReadDir(path)を呼び出すと、os.DirEntryオブジェクトのスライスが返されます。その定義は次のとおりです。

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です。Pythonにおけるscandirの素晴らしい点は、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は内部でlistdirの代わりにscandirを使うようになったため、OSやファイルシステムに応じて、このコードは魔法のように1.5倍から20倍高速になります。

Go(1.16以前)にもfilepath.Walkという類似の関数がありますが、残念ながらFileInfoインターフェースは各メソッド呼び出しからエラーを返せるように設計されていませんでした。見てきたように、これらのメソッドはシステムコールを伴うことがあります。たとえば、Sizeのようなstat情報はLinuxでは常にシステムコールが必要です。そのためGoではメソッドがエラーを返す必要があります(Pythonでは例外を送出します)。

既存のコードが魔法のように高速化されるように、エラー処理に目をつぶってFileInfoインターフェースを再利用しようという誘惑もありました。実際、issue 41188はRuss Coxによるまさにその提案で(それが聞こえるほどひどい考えではないことを示すデータも添えられています)。しかし、statはエラーを返すことがあり、エラー時にファイルサイズが0として返されるといった事態が起こり得ます。そのため、既存のAPIに無理やり押し込もうとする試みには強い反発があり、最終的にRussは合意が得られなかったことを認め、代わりにDirEntryインターフェースを提案しました。

つまり、パフォーマンス向上を得るには、filepath.Walkの呼び出しをfilepath.WalkDirに変更する必要があります。両者は非常によく似ていますが、walk関数が受け取るのがFileInfoではなくDirEntryになる点が異なります。

既存のfilepath.Walk関数を使ったlist_non_dotのGo版は次のようになります。

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でも引き続き動作しますが、パフォーマンス上の利点を得たい場合はごく小さな変更が必要です。この場合はWalkWalkDirに、os.FileInfoos.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設計は困難です。クロスプラットフォームでOSに関わるAPI設計はなおさら困難です。次にプログラミング言語の標準ライブラリを設計する際には、ぜひここを正しく設計してください! :-)

いずれにせよ、Goのディレクトリ読み取りサポートが、もはやPythonに後れを取ったり――あるいは歩みで遅れを取ったり――することがなくなることをうれしく思います。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント