関数型ファイルシステム
2025-04-09 13:00:25
原文: Functional file system by Mark Seemann
ユニットテストを可能にするために、ファイルシステムを関数型プログラミングの考え方でモデル化するにはどうすればよいでしょうか? 本稿ではその概要を解説します。
私が関数型プログラミングを好む多くの理由の一つは、それが本質的にテスト可能である点です。オブジェクト指向プログラミングでは、テストを可能にするために、しばしば面倒な手順を踏む必要があります。これは、コンピュータのファイルシステムを扱う必要がある場合も同様です。試しにウェブで「ファイルシステム インターフェース」や「ファイルシステム モック」と検索してみてください。私はあえてリンクを載せません。なぜなら、そのような疑問はXY問題(質問者が本来解決したい問題Xではなく、二次的な問題Yについて質問してしまうこと)だと考えるからです。よく提案される方法は、適切な解決策ではないと私は思います。
いずれにせよ、関数型プログラミングにおいて、依存関係の注入(Dependency Injection)は関数的ではありません。なぜなら、すべてを不純にしてしまうからです。では、ファイルシステムをどのようにモデル化すれば、純粋性を保ち、その上に構築したいロジックから切り離され、かつ、ほとんどのタスクを実行できるだけの十分な表現力を持たせることができるのでしょうか?
それは、ファイルシステムを木構造(ツリー)、あるいは森(フォレスト)としてモデル化することです。
ファイルシステムは階層構造
ファイルシステムが階層構造、すなわち木構造であることは、驚くにはあたりません。各論理ドライブが木構造の根(ルート)となり、ファイルが葉(リーフ)、ディレクトリが内部ノードに対応します。どこかで聞いたような話ではありませんか? これはRose木(分岐数が一定でない多分木)に似ています。
Rose木は不変(イミュータブル)なデータ構造です。これ以上に関数的なものはそうありません。ファイルシステムをモデル化するのに、Rose木(またはフォレスト)を使わない手はないでしょう。では、実際のファイルシステムとのやり取りはどうなるでしょうか? 通常、オブジェクト指向で抽象化と実際のファイルシステムを切り離そうと試みる場合、WriteAllText
、GetFileSystemEntries
、CreateDirectory
といった多態的な操作を目にするでしょう。これらは、(モック可能な)メソッドとして実装する必要があり、多くの場合、質素なオブジェクト(テスト対象のロジックを単純なオブジェクトに委譲するパターン)として実装されます。しかし、インターフェース群の代わりにファイルシステムをフォレストとしてモデル化すると、実際のファイルシステムとのやり取りは、そもそも抽象化の範囲外になります。これは、オブジェクト指向設計から関数型プログラミングへの典型的な視点の転換です。
オブジェクト指向設計では、通常、振る舞いを持つデータをモデル化しようとします。それが現実世界の状況にうまく適合する場合もありますが、このファイルシステムの場合はそうではありません。振る舞いを持つファイルオブジェクトやディレクトリオブジェクトは存在しますが、ファイルシステムの実際の構造は暗黙的です。それはオブジェクト間の相互作用の中に隠されてしまっています。
一方、ファイルシステムを木構造としてモデル化することで、データの構造を明示的に利用します。木構造をプログラムのメモリに読み込む方法や、木構造を実際のファイルシステムに反映させる方法は、抽象化の対象外です。入出力に関しては、好きなように実装できます。ディレクトリ構造のモデルをメモリ上に構築してしまえば、あとは思う存分操作できます。Rose木はファンクター(構造を保ったまま中身を変換できる性質を持つ型)なので、どのような変換を行っても構造は維持されることが保証されています。これは、アプリケーションのこれらの(純粋な)部分については、テストを書く必要すらないかもしれないことを意味します。きっと、例を見れば納得していただけるはずです。
画像アーキビストの例
例として、古いコードレビューの質問に答えてみましょう。2015年にすでに回答はしていますが、当時の回答には今ではあまり満足していません。しかし、この質問自体は素晴らしいものです。なぜなら、「抽象化はインターフェースか抽象基底クラスを使わなければ実現できない」という考え方から、人々がいかに抜け出しにくいかを明確に示しているからです。2015年当時、私はデリゲート(ひいては関数)が匿名インターフェースであることはとっくに理解していましたが、純粋な振る舞いと不純な振る舞いをどう分離するかについては、まだ掴みきれていませんでした。質問のシナリオはこうです。画像ファイルの集まりを調べ、各ファイルから撮影日時のメタデータを抽出し、その情報に基づいてファイルを新しいディレクトリ構造に移動する小さなプログラムを実装するにはどうすればよいか? 例えば、当初は画像のモチーフ(被写体)に応じて、様々なディレクトリにファイルを整理していたとします。
しかし、すぐにこの整理方法は破綻することに気づきます。なぜなら、一枚の画像に複数のモチーフが含まれていたらどうするのでしょうか? そこで、代わりにファイルを年月に基づいて整理することにします。
このアプリケーションには、明らかに何らかの入出力が伴いますが、ユニットテストしたいロジックも含まれています。メタデータの解析、各画像ファイルの移動先の決定、画像以外のファイルの除外などが必要です。
オブジェクト指向による画像アーキビスト
このような画像アーキビストプログラムをオブジェクト指向設計で実装する場合、ユニットテスト中にファイルシステムを「モック」できるように、依存関係の注入を使うかもしれません。そうすると、典型的なプログラムは実行時に次のように動作するでしょう。
プログラムは、(多態的なインターフェースを通して)ファイルシステムと、細かく頻繁にやり取りします。典型的には、ファイルを1つ読み込み、メタデータをロードし、ファイルの配置場所を決定して、そこにコピーします。そして次のファイルに移ります(並列処理される可能性もあります)。プログラムの実行中、常に入出力が発生しているため、純粋なコードと不純なコードを分離することが難しくなります。たとえこのようなプログラムをF#で書いたとしても、それは関数型アーキテクチャとは言い難いでしょう。このようなアーキテクチャは、理論上はテスト可能ですが、私の経験上、モックやスタブを使ってこのような頻繁で細かなやり取りを再現しようとすると、脆い(壊れやすい)テストになりがちです。
関数型による画像アーキビスト
関数型プログラミングでは、依存関係という考え方を捨てる必要があります。代わりに、私が不純/純粋/不純のサンドイッチと呼ぶ単純なアーキテクチャに頼ることがしばしば有効です。具体的には、以下のようになります。
- ディスクからデータを読み込む(不純)
- データを変換する(純粋)
- データをディスクに書き込む(不純)
この場合、典型的なプログラムは実行時に次のように動作するでしょう。
プログラムが開始されると、まずディスクからデータを木構造として読み込みます。次に、メモリ上にある対象ファイルのモデルを操作します。そして処理が終わったら、木構造全体を走査して変更を適用します。これにより、コードベースにおける純粋な部分と不純な部分が、より明確に分離されます。純粋な部分の割合が大きくなり、ユニットテストも容易になります。
サンプルコード
この記事では、関数型アーキテクチャの概要を説明しました。次の2つの記事で、これを実際にどのように実装するかを見ていきます。まず、Haskellで上記のアーキテクチャを実装します。Haskellで動作することが確認できれば、このアーキテクチャが実際に関数型相互作用の法則を尊重していることが分かります。次に、Haskellの実装をベースにして、F#への移植を示します。
これら2つの記事は、同じアーキテクチャを採用しています。両方読んでも、どちらか好きな方だけ読んでも構いません。ソースコードはGitHubで公開されています。
まとめ
オブジェクト指向プログラミングから関数型プログラミングへ移行する際の最も難しい問題の一つは、設計アプローチが根本的に異なることです。よく知られたデザインパターンや原則の多くは、簡単には応用できません。依存関係の注入もその一つです。多くの場合、関数的なアプローチで取り組むためには、いわばモデルをひっくり返す必要があるのです。
ほとんどのオブジェクト指向プログラマは、オブジェクト指向設計は「名詞」に焦点を当てると言うでしょうが、実際には、相互作用や振る舞いを中心に展開されることがよくあります。それが適切な場合もありますが、そうでない場合も少なくありません。
対照的に、関数型プログラミングは、よりデータ指向の視点を取る傾向があります。データを読み込み、操作し、出力(公開)する。もしデータに適したデータ構造を見つけ出すことができれば、それは関数型アーキテクチャの実装に向けて順調に進んでいる証拠と言えるでしょう。