ソフトウェア設計

SwiftのCompositeパターン:ツリー構造を1つのオブジェクトとして扱う

フォルダーの中にフォルダー、その中にさらにファイル。こうしたツリー構造をコードで扱ったことがあるなら、一度は途方に暮れたはずです。

読了 5 分
SwiftのCompositeパターン:ツリー構造を1つのオブジェクトとして扱うのカバー画像

フォルダーの中にフォルダー、その中にさらにファイル。こうしたツリー構造をコードで扱ったことがあるなら、一度は途方に暮れたはずです。

「これはフォルダー?それともファイル?」と毎回ifで分岐していると、コードはすぐに汚れてしまいます。

ファイルエクスプローラーのようなものを作ると、よく直面する問題です。そんなときに使えるのがCompositeパターンです。

Compositeパターンは、個々のオブジェクト(ファイル)とその集合(フォルダー)を同じ型として扱い、ツリー全体を1つのオブジェクトのように操作できる構造パターンです。

今日は、私が実際に書いたコードとともに、このパターンをSwiftで実装する方法を整理します。

まずは要点

  1. Compositeパターン=LeafとCompositeを同じプロトコルでまとめる
  2. クライアントはファイルかフォルダーかを意識せず、同じように呼び出す
  3. フォルダーの計算は、子に再び尋ねる再帰構造
  4. Swiftではprotocolだけで簡潔に実装できる

Compositeパターンとは?

文字どおり「合成」です。小さなものを集めて、1つの大きなまとまりにします。

最も身近な例はファイルシステムです。

ファイルは、それ自体で完結するLeafです。一方、フォルダーはファイルや別のフォルダーを含められる枝です。

フォルダーの中にフォルダー。この構造がまさにCompositeです
フォルダーの中にフォルダー。この構造がまさにCompositeです

しかし、ユーザーの視点は少し違います。フォルダーでもファイルでも、私たちは「開いて」と命令するだけです。

このように個々の要素と集合を同じ方法で扱えるようにするのが、Compositeパターンの目的です。

構成要素は3つに分かれます。

図にすると、これほどシンプルです
図にすると、これほどシンプルです
  • Component:共通インターフェース(プロトコル)
  • Leaf:子を持たない末端オブジェクト(ファイル)
  • Composite:子を格納するオブジェクト(フォルダー)

Swiftでどう実装する?

まず共通プロトコルを定義します。名前とサイズを取得できるようにします。

以下は、ファイルとフォルダーが共通して従うルール(Component)です。

protocol FileComponent {
    var name: String { get }
    func size() -> Int   // バイトサイズを返す
}

次はLeafにあたるファイルです。自分のサイズを返すだけなのでシンプルです。

struct File: FileComponent {
    let name: String
    let bytes: Int
    func size() -> Int { bytes }  // 自身のサイズをそのまま返す
}

ポイントはフォルダー(Composite)です。子を配列で保持し、サイズを尋ねられると子にもう一度尋ねます。

struct Folder: FileComponent {
    let name: String
    var children: [FileComponent] = []
    func size() -> Int {
        children.reduce(0) { $0 + $1.size() }  // 子の合計(再帰)
    }
}

ここで重要なのは、size()が自分自身を再び呼び出す再帰構造だという点です。

フォルダーが何階層重なっていても、この1行が最下層まで自動的にたどります。


実際に使うとこんな感じ

それでは、ファイルとフォルダーを組み合わせてツリーを作ってみます。

let root = Folder(name: "ドキュメント", children: [
    File(name: "メモ.txt", bytes: 100),
    Folder(name: "写真", children: [
        File(name: "旅行.jpg", bytes: 2000)
    ])
])
print(root.size())  // 2100 出力

わかりますか?root.size()を1回呼ぶだけで、フォルダー内の全ファイルのサイズが合計されます。

実際に動かして最も感心したのは、まさにここでした。

クライアントコードでは、「これはフォルダーかファイルか」と一度も尋ねません。

size()を呼ぶだけです。あとは各オブジェクトが自動的に処理します。

例をそのままPlaygroundに貼り付けて実行してみました
例をそのままPlaygroundに貼り付けて実行してみました

分岐文で処理する場合との違いは?

if文で型を分けて処理すればよいのでは?」と思うかもしれません。

可能です。ただし、ツリーが深くなるほど差が大きくなります。

項目 分岐文(if/switch)方式 Compositeパターン
型の判定 毎回自分で確認 不要
新しい型の追加 各所の分岐文を修正 プロトコルに準拠
再帰処理 自分で走査コードを書く オブジェクトが自動処理
コードの可読性 深くなるほど複雑 一定に保てる

分岐方式では、新しい種類(例:ショートカットリンク)が増えるたび、あちこちにifを追加する必要があります。

一方Compositeなら、FileComponentに準拠する新しい型を1つ作るだけで済みます。既存コードを変更する必要はありません。


よくある質問

Q. LeafとCompositeのインターフェースは完全に同じである必要がありますか?

理想的にはそうです。ただし、ファイルに「子を追加」のような機能は不自然です。その場合は、共通動作(sizeなど)だけをプロトコルに置き、子の管理はフォルダーだけに任せるのが実用的です。

Q. classではなくstructを使ってもよいですか?

はい。上の例のように値型でも十分動作します。ただし、ツリーを頻繁に変更し、参照を共有する必要があるなら、classのほうが便利な場合があります。


ツリー構造を前にしてif地獄に陥っているなら、Compositeパターンを試してみてください。

ファイルシステムだけでなく、ビュー階層、メニュー、組織図など、「中にさらに中」がある構造ならどこでもよく適合します。

今日の例をそのままPlaygroundに貼って実行すれば、きっと感覚をつかめます。

あわせて読みたい