ソフトウェア設計

Swiftデコレータパターン:継承なしで機能を重ねる方法

iOS開発では、基本機能を変えずにロギングやキャッシュだけ追加したくなることがあります。しかしそのためにクラス継承を始めると、いつの間にか継承ツリーが複雑になりがちです。

読了 5 分
Swiftデコレータパターン:継承なしで機能を重ねる方法のカバー画像

iOS開発では、基本機能を変えずにロギングやキャッシュだけ追加したくなることがあります。しかしそのためにクラス継承を始めると、いつの間にか継承ツリーが複雑になりがちです。

ネットワーククライアントに機能を追加していくと、いつの間にかCachingLoggingRetryingClientのような名前のクラスができてしまいがちです。

今日は、まさにその問題を解決するSwiftデコレータパターンを整理します。継承せず、玉ねぎのように機能を重ねる方法です。

デコレータパターンは、同じプロトコルに準拠するオブジェクトで元のオブジェクトを包み、元のコードを変更せずに機能を一層ずつ追加する方法です。

先に結論を言うと、こうなります。

  1. 共通動作をプロトコルとして先に定義する
  2. デコレータはそのプロトコルに準拠し、同じプロトコル型を内部に保持する
  3. 自分の仕事(ロギングやキャッシュなど)を行い、残りは保持しているオブジェクトに委譲する
  4. 必要なだけ包めば、機能が積み重なる

デコレータパターンとは? 継承とは何が違う?

継承は「〜の一種」を表します。子は親のすべてを受け継ぎます。

問題は組み合わせです。ロギング、キャッシュ、両方に対応したクライアントを継承で作ると、組み合わせごとにクラスが一つずつ増えます。

デコレータは「〜を包んだもの」を表します。

包むオブジェクトと包まれるオブジェクトが同じインターフェースを共有するため、外側からは区別できません。何層包んでも利用側のコードはそのままです。

つまり、継承ではコンパイル時に関係が固定されますが、デコレータは実行時に自由に組み立てられます。


Swiftデコレータパターンの実装をコードで見る

例としてデータを読み込むDataLoaderを作ります。まず、全員が準拠するプロトコルです。

protocol DataLoader {
    func load(id: String) -> Data?
}

// 実際の処理を行う基本実装
struct NetworkLoader: DataLoader {
    func load(id: String) -> Data? {
        print("ネットワークへの \(id) リクエスト")
        return Data("payload-\(id)".utf8)
    }
}

ここまでは通常のプロトコルと実装です。

ここが核心です。デコレータはDataLoaderに準拠し、内部に別のDataLoaderを保持します。

// ログを記録するデコレータ
struct LoggingLoader: DataLoader {
    let wrapped: DataLoader   // 包む対象

    func load(id: String) -> Data? {
        print("[ログ] load 開始: \(id)")
        let result = wrapped.load(id: id)  // 実際の処理は委譲
        print("[ログ] load 完了: \(id)")
        return result
    }
}

LoggingLoaderは自分の仕事(ログ出力)だけを行い、実際の読み込みはwrappedに渡します。

この構造なら、何を包んでいるかを気にする必要はありません。DataLoaderでさえあればよいのです。

自分の仕事だけを行い、残りを内側のオブジェクトに委譲する構造
自分の仕事だけを行い、残りを内側のオブジェクトに委譲する構造

機能を何層にも重ねてみる

今度はキャッシュデコレータをもう一つ作り、実際に重ねてみます。

final class CachingLoader: DataLoader {
    let wrapped: DataLoader
    private var cache: [String: Data] = [:]

    init(wrapping loader: DataLoader) { self.wrapped = loader }

    func load(id: String) -> Data? {
        if let hit = cache[id] { return hit }        // キャッシュにあれば即座に返す
        let data = wrapped.load(id: id)              // なければ委譲
        cache[id] = data
        return data
    }
}

組み立てる部分こそ、デコレータパターンの醍醐味です。

let loader = LoggingLoader(
    wrapped: CachingLoader(wrapping: NetworkLoader())
)

内側から読んでください。ネットワークローダーをキャッシュで包み、それをさらにロギングで包みました。

呼び出しが入ると、ログ → キャッシュ確認 →(なければ)ネットワークの順に流れます。

包んだ層の数だけ、呼び出しも一層ずつ下っていきます
包んだ層の数だけ、呼び出しも一層ずつ下っていきます
内側からネットワーク・キャッシュ・ロギングの順に重なった構造
内側からネットワーク・キャッシュ・ロギングの順に重なった構造

順序を変えたいなら、包む順序だけ変えれば構いません。NetworkLoaderCachingLoaderもコードを一行も変更しません。

不要な機能は、その層を外せばよいだけです。


継承とデコレータ、いつ何を使う?

どちらか一方が常に正解とは限りません。状況ごとに整理しました。

状況 おすすめ
明確な「〜の一種」という関係 継承
機能を自由に組み合わせたり外したりする デコレータ
実行中に機能をオン・オフする デコレータ
元のコードを変更できない場合 デコレータ
組み合わせの数が多い場合 デコレータ

特にロギング、キャッシュ、リトライ、認証ヘッダーの追加のような本質ではない付加機能には、デコレータが適しています。

一方、無理にデコレータを使うと層が増え、デバッグ時にコールスタックが深くなる欠点もあります。この点を踏まえて使うとよいでしょう。


よくある質問二つ

Q. structとclassのどちらを使うべきですか?

キャッシュのような状態をデコレータ自身が保持するなら、classが便利です。単純な委譲だけならstructで十分です。サンプルでキャッシュだけをclassにした理由もこれです。

Q. 包む層が増えると性能に問題はありませんか?

呼び出しが一層ずつ通過するため、ごくわずかなオーバーヘッドはあります。ただしネットワークやディスクI/Oと比べれば無視できる程度で、ほとんどのアプリでは気にする必要はありません。


デコレータパターンは、結局プロトコルを一つ定義し、「同じプロトコルを保持するオブジェクト」を作って委譲するだけです。

次に機能を追加するため継承ツリーを探し始めたら、一層包む方法を思い出してみてください。コードがずっと軽くなります。今日の例をそのままプレイグラウンドに移して試すのがおすすめです🙂

あわせて読みたい