GoFデザインパターンは現代でも有用か(2) — Go言語で不要になったパターン

TL;DR

Go言語の3つの設計方針(クラス継承の排除、値セマンティクス、第一級関数)により、GoFの23パターンのうち10パターンが不要になりました。これらはOOPの制約の中で生まれた処方箋であり、制約そのものを取り除いたGo言語では必要なくなったものです。

この記事は「GoFデザインパターンは現代でも有用か — Go言語から再考する」シリーズの第2回(全4回)です。前回: 「GoFデザインパターンは現代でも有用か(1) — OOPのパラダイムシフトとGoFの誕生

Cover
Table of Contents

前回の振り返り

前回は、OOPが手続き型プログラミングからのパラダイムシフトとして登場した経緯、OOPの設計ノウハウを体系化したGoFデザインパターンが広く受け入れられた歴史、そして現代ではOOPの中核を意図的に排除したGo言語が登場したことを振り返りました。

Go言語の設計方針は、OOPとの対比で次の3点に集約されます。

  • クラス継承を排除し、合成(composition)と暗黙的インターフェースで代替した
  • 参照セマンティクスではなく値セマンティクスをデフォルトにした
  • 振る舞いもオブジェクトに包む制約を廃し、第一級関数とチャネル/ゴルーチンで直接扱えるようにした

この設計方針を踏まえて、GoFの23パターンをGo言語の視点から再検討してみましょう。

「クラス継承」の副作用への処方箋だったパターン

OOP「クラス継承」がもたらしたもの

ソフトウェアの保守性と拡張性を担保するために、「既存のコードを変更せずに機能を拡張する」という原則 — 開放閉鎖原則(Open-Closed Principle)が求められてきました。手続き型言語では、機能を追加するたび既存コードへ分岐を追加するしかなく、変更が既存の動作を壊すリスクが常にありました。OOPはこの課題に対して、クラス継承という新たなメカニズムで解決を図りました。基底クラスを定義し、サブクラスで振る舞いをオーバーライドすることで、既存コードに手を加えずに機能を拡張します。これがOOPの基本戦略でした。

しかし、継承自体が新たな問題を生みます。

  • 脆弱な基底クラス問題 — 親クラスを変更すると、全てのサブクラスに影響が波及する。「変更せずに拡張」したかったのに、親を変えた瞬間に子が壊れる
  • 密結合 — サブクラスは親クラスの実装詳細に依存する。拡張のつもりが、内部実装への依存を生む
  • 単一継承の制約 — Java等では1つのクラスしか継承できない。拡張の方向が1軸に制限される
  • 深い階層の可読性低下 — 継承が何段にも重なると、実際の振る舞いを追うのが困難になる

デザインパターンは、この継承の副作用を管理するための処方箋でした。以下のパターンが該当します。

  • Template Method — 親クラスでアルゴリズムの骨格を定義し、サブクラスで具体的なステップをオーバーライドする。継承による骨格と詳細の分離を体系化したもの
  • Factory Method — オブジェクトの生成をサブクラスに委ねることで、生成ロジックの拡張を継承で管理する
  • Abstract Factory — 関連するオブジェクト群の生成を抽象クラスの階層で管理する
  • Bridge — 抽象クラスの階層と実装クラスの階層を分離し、それぞれ独立して拡張可能にする。継承階層を2つに分けることで拡張の柔軟性を確保する

これらはいずれも「継承をどう使えば安全に拡張できるか」を体系化したものであり、継承というメカニズムが前提にあって初めて意味を持つパターンです。

Go言語の解法 — 継承のない拡張性

Go言語はこの問題の連鎖に対して、根本的に異なる道を選びました。クラス継承を排除し、暗黙的インターフェースと合成(composition) によって開放閉鎖原則を実現しています。

  • 暗黙的インターフェースimplementsの宣言が不要で、メソッドのシグネチャが合致すれば自動的にインターフェースを満たす。既存の型に触れることなく、新しいインターフェースへ適合させられる。つまり、既存コードを変更せず新しい抽象に対応できる
  • 合成(composition) — 構造体の埋め込み(embedding)により、既存の型を変更せずに振る舞いを追加できる。継承のような親子の密結合が生まれず、拡張しても既存コードへの影響がない

継承の副作用が存在しないため、その副作用を管理するためのデザインパターンも必要ありません。これらのパターンが「Go言語では成立しない」のは、解決すべき問題が言語設計の時点で回避されているからです。

「参照セマンティクス」を補完するパターン

「参照セマンティクス」がもたらしたもの

OOPの多くの言語は参照セマンティクス1を採用しています。これはOOPの設計思想と深く結びついています。OOPでは「このオブジェクトとあのオブジェクトは同一か」というオブジェクトの同一性(identity)が重要であり、基底クラスの参照を通じて派生クラスのメソッドを呼び出すポリモーフィズムも参照なしでは成立しません。参照セマンティクスはOOPにとって必然的な選択でした。

しかし、参照セマンティクスは副作用も伴います。変数に代入してもオブジェクト自体はコピーされず、同じ実体を指す参照が増えるだけです。C言語でいえば、すべての変数がポインタ経由でデータにアクセスしているようなものであり、手続き型言語のグローバル変数と同様に「どこで変更されるか追跡できない」という課題をオブジェクト単位で持ち込んでしまいます。この性質がオブジェクトの複製や状態の保存・復元を厄介な問題にし、それを解決するためのデザインパターンが必要でした。

Go言語は値セマンティクス2を採用しています。OOPを捨てたことで、参照をデフォルトにする必要がなくなりました。値セマンティクスは並行処理の安全性(ゴルーチン間でコピーを渡せば共有状態が減る)と予測可能性(代入の振る舞いが明示的で単純)をもたらします。参照が必要な場合は、ポインタで明示的に指定します。

この「デフォルトは値、参照が必要なら明示せよ」という設計方針は、Go言語独自のものではありません。Rustはムーブセマンティクスをデフォルトとし参照は&で明示します。Swiftは構造体(struct)を値型とし、Appleは構造体の使用を推奨しています。OOP以降の現代的な言語設計に共通するトレンドです。

この値セマンティクスにより、以下のパターンが解決しようとした問題は大幅に簡素化されます。

既存オブジェクトをコピーして新しいオブジェクトを生成する

  • Prototype — 参照セマンティクスの世界では、オブジェクトの「正しいコピー」を作ること自体が設計上の課題であり、Clone()メソッドの実装やプロトタイプ登録といった仕組みが必要だった

Go言語では、構造体の代入がそのままコピーです。b := aと書けばフィールドがすべて複製されます。ポインタを含む構造体の深いコピーには注意が必要ですが、パターンとしての仕組みは不要です。

オブジェクトの内部状態を保存し復元する

  • Memento — Originator(状態を持つ対象)、Memento(保存された状態)、Caretaker(保存を管理する者)の3者構造で、カプセル化を壊さずに状態を保存・復元する仕組みを実現していた

Go言語では、構造体をそのままコピーすればスナップショットになります。saved := currentStateで保存、currentState = savedで復元です。OOPが苦心した「カプセル化を維持しつつ状態を外部に保存する」という問題自体が、値セマンティクスによって解消されています。

「振る舞い」をオブジェクトに包む制約への処方箋だったパターン

「すべてがオブジェクト」がもたらしたもの

OOPの「すべてがオブジェクト」という原則は、データと振る舞いを一体化することで、手続き型言語のようにデータと関数がバラバラに散らばる問題を解消しました。統一的なモデルで設計を語れることは、OOPの大きな功績です。

しかし、ソフトウェアでは要件の変化や文脈の違いに応じて、データはそのままに処理だけを差し替える場面が頻繁に発生します。同じデータを異なる基準でソートしたい、同じリクエスト処理を本番ではDB接続、テストではモックに差し替えたい、といった場面です。OOPの「すべてがオブジェクト」という原則は、このような振る舞いだけを渡したい場面でも、それをオブジェクト(クラス)に包むことを強制しました。関数を1つ渡したいだけなのに、インターフェースを定義し、具象クラスを実装し、そのインスタンスを生成する。この儀式が、以下のパターンを生みました。

Go言語では、第一級関数であり、チャネルとゴルーチンが言語に組み込まれています。振る舞いをオブジェクトに包む必要がなくなったことで、これらのパターンは不要になりました。

アルゴリズムを動的に切り替える

  • Strategy — Strategyインターフェースを定義し、各アルゴリズムを具象クラスとして実装し、コンテキストオブジェクトに注入するという手順が必要だった

Javaでは、ソート順を差し替えるだけでもインターフェースと具象クラスが必要です。

// Strategyインターフェース
interface SortStrategy {
    void sort(int[] data);
}

// 具象クラス:昇順
class AscendingSort implements SortStrategy {
    public void sort(int[] data) { /* 昇順ソート */ }
}

// 具象クラス:降順
class DescendingSort implements SortStrategy {
    public void sort(int[] data) { /* 降順ソート */ }
}

// コンテキストクラス
class Sorter {
    private SortStrategy strategy;

    Sorter(SortStrategy strategy) {
        this.strategy = strategy;
    }

    void execute(int[] data) {
        strategy.sort(data);
    }
}

// 使用側
Sorter s = new Sorter(new AscendingSort());
s.execute(data);

Go言語では第一級関数をサポートしています。ソート処理を行う関数が、ソート順を関数として受け取るだけで済みます。

// ソート順を関数として受け取る。クラスもインターフェースも不要
func executeSort(data []int, less func(i, j int) bool) {
    sort.Slice(data, less)
}

// 関数を変数に代入するだけで「具象クラス」に相当する
ascending := func(i, j int) bool { return data[i] < data[j] }
descending := func(i, j int) bool { return data[i] > data[j] }

// 使用側
executeSort(data, ascending)

Javaでは振る舞いを差し替えるためにインターフェース、具象クラス、コンテキストクラスの3層が必要でした。Go言語では関数を引数として渡すだけで同じことが実現できます。

操作の実行・取り消し・キューイングを可能にする

  • Command — リクエストをオブジェクトとしてカプセル化し、操作をデータとして扱えるようにしていた

Go言語では関数とクロージャがCommandオブジェクトの役割を果たします。関数をスライスに格納してキューイングする、クロージャで実行時のコンテキストを閉じ込める、といった操作が言語機能として自然に書けます。

データ構造に新しい操作を追加する

  • Visitor — データ構造に対する操作を追加したいとき、操作をVisitorオブジェクトに包み、ダブルディスパッチ(accept/visit)で呼び出す必要があった

Go言語ではtype switchにより、型に応じた処理分岐を直接記述できます。操作をオブジェクトに包む必要も、ダブルディスパッチのような間接的な仕組みも不要です。

オブジェクトの状態変化を通知する

  • Observer — Subject/Observerインターフェースを定義し、登録・通知・解除のメカニズムを実装する必要があった

エラーイベントが発生したら、ログ出力とアラート送信の2つの処理に通知する例で対比してみます。 Javaでは、Observerインターフェースの定義、Subject(EventSource)への登録メカニズム、通知ループの実装が必要です。

// Observerインターフェース
interface Observer {
    void update(String event);
}

// 具象クラス:ログ出力
class LogObserver implements Observer {
    public void update(String event) {
        System.out.println("Logger: " + event);
    }
}

// 具象クラス:アラート送信
class AlertObserver implements Observer {
    public void update(String event) {
        sendAlert(event);
    }
}

// Subject:Observerの登録と通知を管理する
class EventSource {
    private List<Observer> observers = new ArrayList<>();

    void addObserver(Observer o) { observers.add(o); }

    void notifyObservers(String event) {
        for (Observer o : observers) {
            o.update(event);
        }
    }
}

// 使用側:Observerを登録し、イベントを通知する
EventSource source = new EventSource();
source.addObserver(new LogObserver());
source.addObserver(new AlertObserver());
source.notifyObservers("error occurred");

Go言語では、チャネルとゴルーチンがこの役割を担います。Observerをチャネルのスライスで管理し、動的に登録できます。

// チャネル:Observerへの通知経路をスライスで管理
observers := []chan string{}

// Observer登録:チャネルを作成し、ゴルーチンで待ち受ける
logCh := make(chan string)
observers = append(observers, logCh)
go func() {
    for event := range logCh {
        fmt.Println("Logger:", event)
    }
}()

alertCh := make(chan string)
observers = append(observers, alertCh)
go func() {
    for event := range alertCh {
        sendAlert(event)
    }
}()

// 全Observerに通知
for _, ch := range observers {
    ch <- "error occurred"
}

Javaではインターフェース定義、登録メカニズム、通知ループが必要でした。Go言語ではチャネルが型安全な通知メカニズムとなり、ゴルーチンが通知を受け取る側の並行処理を自然に表現します。

まとめ

本記事では、Go言語の3つの設計方針がGoFのデザインパターンをどのように不要にしたかを確認しました。

Go言語の設計方針不要になったパターン
クラス継承を排除し、合成と暗黙的インターフェースで代替Template Method, Factory Method, Abstract Factory, Bridge
参照セマンティクスではなく値セマンティクスをデフォルトにPrototype, Memento
振る舞いもオブジェクトに包む制約を廃し、第一級関数とチャネル/ゴルーチンで直接扱えるようにStrategy, Command, Visitor, Observer

これらのパターンが不要になったのは、パターンの設計思想が間違っていたからではありません。OOPの制約の中で生まれた処方箋が、制約そのものを取り除いたGo言語では必要なくなった、ということです。

しかし、GoFの23パターンすべてがOOPの制約に起因するわけではありません。次回は、言語やパラダイムに依存しない普遍的な設計原則として、現代でも有用なパターンを検討します。

  1. 参照セマンティクス(reference semantics)とは、変数への代入がオブジェクトの参照(ポインタ)をコピーする振る舞いのこと。代入先を変更すると、元のオブジェクトも影響を受ける。Java、Pythonなどが採用している(C#はクラスが参照型、構造体が値型と区別している)。

  2. 値セマンティクス(value semantics)とは、変数への代入がデータそのものをコピーする振る舞いのこと。代入先を変更しても、元のデータには影響しない。Go言語の構造体やC言語の構造体が採用している。

次回: 「GoFデザインパターンは現代でも有用か(3) — 言語に吸収されたパターンと非推奨のSingleton