12
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GoFデザインパターン23個を今のJavaで書き直したら、自分で組み立てるのは8個だけだった

12
Last updated at Posted at 2024-04-30

GoFデザインパターンの23個を、全部覚える必要はありません。

Java 21以降の言語機能で書き直してみると、Visitorは35行が19行になり、Strategyはクラス3つがラムダ3行になりました。筆者の分類では、23個のうち15個は言語機能かライブラリがすでに肩代わりしていて、自分で組み立てる場面が残っているのは8個です。

この記事で持ち帰れるもの:

  • 23パターンを「言語機能で数行に/ライブラリの中に入った/今も自分で組む」の3つに分けた一覧表
  • 従来の書き方と現代のJavaの書き方を、実際にコンパイル・実行して比べた結果(行数つき)
  • sealed と switch で Visitor を置き換えるときの落とし穴(default を書くと安全性が消える)
  • それでもパターン名を知っておく意味(AIにコードを書かせる今だからこそ)

GoFデザインパターンとは(30秒で)

GoF(Gang of Four)は、1994年に出版された書籍『Design Patterns: Elements of Reusable Object-Oriented Software』の著者4人、Erich Gamma、Richard Helm、Ralph Johnson、John Vlissidesの愛称です。この本がオブジェクト指向設計の定石として整理した23個のパターンを、GoFデザインパターンと呼びます。

分類は3つです。

分類 何を扱うか パターン
生成 オブジェクトの作り方 Singleton, Prototype, Factory Method, Abstract Factory, Builder
構造 クラスやオブジェクトの組み合わせ方 Adapter, Bridge, Composite, Decorator, Facade, Flyweight, Proxy
振る舞い オブジェクト間の責任の分け方・やりとり Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor

引っかかるのは、本の年です。1994年のC++とSmalltalkを前提にした23個が、ラムダもrecordもある今のJavaで、同じ重さで必要なのでしょうか。

実際に書き直して比べてみた

検証環境: macOS 26.5.2(Apple Silicon)、OpenJDK 23.0.1。コードはすべて javac ファイル名.java && java クラス名 でコンパイル・実行し、出力を確認しました。使っている言語機能はすべてJava 21で正式機能になっているもので、javac --release 21 でも同じ結果になることを確認しています。行数は空行を含むファイル全体の行数です。

Strategy:クラス3つが、ラムダ3行に

Strategyは「計算方法(戦略)を差し替え可能にする」パターンです。例は割引計算です。

従来の書き方(27行):

import java.util.List;

interface DiscountStrategy {
    int apply(int price);
}

class NoDiscount implements DiscountStrategy {
    public int apply(int price) { return price; }
}

class MemberDiscount implements DiscountStrategy {
    public int apply(int price) { return price * 90 / 100; }
}

class CouponDiscount implements DiscountStrategy {
    public int apply(int price) { return Math.max(0, price - 500); }
}

public class Main {
    public static void main(String[] args) {
        List<DiscountStrategy> strategies =
            List.of(new NoDiscount(), new MemberDiscount(), new CouponDiscount());
        for (DiscountStrategy s : strategies) {
            System.out.println(s.apply(3000));
        }
    }
}

ラムダ(Java 8〜)で書いた場合(15行):

import java.util.List;
import java.util.function.IntUnaryOperator;

public class Main {
    public static void main(String[] args) {
        List<IntUnaryOperator> strategies = List.of(
            price -> price,
            price -> price * 90 / 100,
            price -> Math.max(0, price - 500)
        );
        for (IntUnaryOperator s : strategies) {
            System.out.println(s.applyAsInt(3000));
        }
    }
}

出力はどちらも同じです。

3000
2700
2500

IntUnaryOperator は「intを受け取ってintを返す関数」を表す、標準ライブラリの関数型インターフェース(メソッドが1つだけのインターフェース)です。戦略ごとにクラスを作る必要は、もうありません。

では「Strategyは不要になった」と言い切れるか。私はまだ早いと考えています。消えたのはクラスの定型文であって、「計算方法を外から差し込めるようにする」という設計判断は残っています。ラムダの一覧を眺めて「これはStrategyだ」と見抜けるかどうかは、パターンを知っているかどうかで分かれます。

Visitor:35行が19行に、しかも安全性は落ちない

Visitorは、図形の種類ごとに処理を振り分けるときなどに使うパターンです。23個の中でも、とっつきにくいと言われがちなパターンでしょう。accept と visit が互いを呼び合う「二重ディスパッチ」という仕掛けが必要だからです。

従来の書き方(35行):

interface Shape {
    <R> R accept(ShapeVisitor<R> visitor);
}

interface ShapeVisitor<R> {
    R visitCircle(Circle c);
    R visitRect(Rect r);
}

class Circle implements Shape {
    final double radius;
    Circle(double radius) { this.radius = radius; }
    public <R> R accept(ShapeVisitor<R> visitor) { return visitor.visitCircle(this); }
}

class Rect implements Shape {
    final double width, height;
    Rect(double width, double height) { this.width = width; this.height = height; }
    public <R> R accept(ShapeVisitor<R> visitor) { return visitor.visitRect(this); }
}

class AreaVisitor implements ShapeVisitor<Double> {
    public Double visitCircle(Circle c) { return Math.PI * c.radius * c.radius; }
    public Double visitRect(Rect r) { return r.width * r.height; }
}

public class Main {
    public static void main(String[] args) {
        Shape[] shapes = { new Circle(1.0), new Rect(2.0, 3.0) };
        AreaVisitor area = new AreaVisitor();
        for (Shape s : shapes) {
            System.out.printf("%.2f%n", s.accept(area));
        }
    }
}

sealed interface(Java 17〜)、record(Java 16〜)、switchのパターンマッチング(Java 21〜)で書いた場合(19行):

sealed interface Shape permits Circle, Rect {}
record Circle(double radius) implements Shape {}
record Rect(double width, double height) implements Shape {}

public class Main {
    static double area(Shape s) {
        return switch (s) {
            case Circle c -> Math.PI * c.radius() * c.radius();
            case Rect r -> r.width() * r.height();
        };
    }

    public static void main(String[] args) {
        Shape[] shapes = { new Circle(1.0), new Rect(2.0, 3.0) };
        for (Shape s : shapes) {
            System.out.printf("%.2f%n", area(s));
        }
    }
}

どちらも出力は 3.14 と 6.00 です。

sealed は「このインターフェースを実装できるのは permits に書いたクラスだけ」と宣言する機能です。record は、コンストラクタやゲッターを自動生成してくれるデータ用のクラスです。なお、減った16行のうち一部はrecordによるもの(コンストラクタとフィールド定義の省略)で、Visitorの仕掛けが消えた分だけではありません。

気になるのは安全性です。Visitorの良いところは、図形の種類を増やしたときに visitXxx の実装漏れをコンパイラが教えてくれる点でした。switch に書き換えたら、その保証は失われるのではないか。

試しに、三角形 Triangle を permits に追加し、switch には追加しないままコンパイルしました。

sealed interface Shape permits Circle, Rect, Triangle {}
record Triangle(double base, double height) implements Shape {}
Main.java:8: エラー: switch式がすべての可能な入力値をカバーしていません
        return switch (s) {
               ^
エラー1個

コンパイルエラーです。sealed で候補が閉じているので、コンパイラが「全部の型を網羅しているか」を検査できます。Visitorが二重ディスパッチでやっていた仕事を、言語がそのまま引き受けています。

ただし、落とし穴が一つあります。switch に default -> 0.0; を1行足すと、同じコードがエラーなしでコンパイルでき、Triangle は黙って 0.0 として扱われます。網羅性の検査を効かせたいなら、sealed 型の switch には default を書かないこと。「念のため default」という習慣が、ここでは安全装置を外してしまいます。

Flyweight:気づかないうちに、もう使っている

Flyweightは「同じ値のオブジェクトを使い回してメモリを節約する」パターンです。自分で書いた記憶がなくても、Javaを書く人のほとんどが日常的に使っています。

public class Flyweight {
    public static void main(String[] args) {
        Integer a = 127, b = 127;
        Integer c = 128, d = 128;
        System.out.println(a == b); // 同じインスタンスか
        System.out.println(c == d);
    }
}
true
false

Integer の自動ボクシングは Integer.valueOf を通り、-128〜127の値はキャッシュ済みの同じインスタンスが返ります(上限はJVMオプションで変えられます)。128は範囲外なので別のインスタンスになり、== が false になるわけです。

「Integer を == で比べるな」という定番の注意は、このFlyweightの副作用です。パターンを知っていると、この挙動が「謎の仕様」から「理由のある仕様」に変わります。

Observer:JDK自身が古い実装を非推奨にした

Observerは「状態の変化を、登録された相手に通知する」パターンです。Javaには初期から java.util.Observable という実装がありました。

import java.util.Observable;

public class ObserverOld {
    public static void main(String[] args) {
        Observable subject = new Observable();
        subject.addObserver((o, arg) -> System.out.println("通知: " + arg));
        System.out.println(subject.countObservers());
    }
}

javac -Xlint:deprecation ObserverOld.java でコンパイルすると、警告が出ます(抜粋)。

ObserverOld.java:5: 警告: [deprecation] java.utilのObservableは推奨されません
        Observable subject = new Observable();
        ^

javap -v java.util.Observable でクラスファイルを覗くと、@Deprecated(since="9") が付いていました。

パターンそのものが古くなったわけではありません。GUIのイベントリスナー、Java 9で入った java.util.concurrent.Flow、RxJava などのリアクティブライブラリと、形を変えて今も至るところにあります。消えたのは「Observable を継承・利用して自作する」という書き方のほうです。

23個を3つに分けてみた

23個を、何がパターンを肩代わりしているかで分けると次のとおりです。分類は筆者の判断です。想定しているJavaの機能・ライブラリを右の列に書いたので、異論があればぜひコメントで教えてください。

A. 言語機能で「数行」になった(8個)

パターン 置き換えた言語機能
Strategy ラムダ、関数型インターフェース(上で検証)
Command Runnable / ラムダ。ExecutorService に処理を渡すのはまさにこれ
Template Method 継承の代わりに、処理の一部を関数として渡す(Spring の JdbcTemplate が RowMapper などのコールバックを受け取る形)
Iterator 拡張for文(Java 5〜)。裏側は Iterable / Iterator で、Stream API も同じ役目を担う
Visitor sealed interface + switch のパターンマッチング(上で検証)
Interpreter 構文木の定義が sealed + record で数行になる(評価ロジックは switch で自分で書く)
Factory Method Supplier<T> やコンストラクタ参照 Foo::new を渡す
Singleton enum AppConfig { INSTANCE; } で済む。実務ではDIコンテナのスコープ管理に任せることが多い

B. ライブラリ・フレームワークの中に入った(7個)

パターン どこにいるか
Observer イベントリスナー、Flow API、リアクティブライブラリ
Decorator new BufferedReader(new InputStreamReader(in)) の入れ子
Proxy java.lang.reflect.Proxy、Spring の AOP(@Transactional など)
Flyweight Integer.valueOf のキャッシュ(上で検証)
Builder StringBuilder、HttpClient.newBuilder()、Lombok の @Builder
Abstract Factory DIコンテナ。Spring なら @Configuration クラスの @Bean メソッド群が、関連する部品一式の組み立てを引き受ける
Chain of Responsibility Servlet の FilterChain、Spring Security のフィルタチェーン

Proxy は、Java 標準の動的プロキシでも数行で作れます。

import java.lang.reflect.Proxy;

interface Greeter {
    String greet(String name);
}

public class ProxyDemo {
    public static void main(String[] args) {
        Greeter real = name -> "こんにちは、" + name;
        Greeter logged = (Greeter) Proxy.newProxyInstance(
            Greeter.class.getClassLoader(),
            new Class<?>[] { Greeter.class },
            (proxy, method, methodArgs) -> {
                System.out.println("[log] " + method.getName() + " 呼び出し");
                return method.invoke(real, methodArgs);
            });
        System.out.println(logged.greet("Qiita"));
    }
}
[log] greet 呼び出し
こんにちは、Qiita

@Transactional を付けたメソッドが、呼び出しの前後でトランザクション処理される。その仕組みの根っこは、この「呼び出しを横取りする代理人」です。

C. 今も自分で組み立てる(8個)

パターン よく出てくる場面
Adapter 外部APIのレスポンスを、自分のドメインの型に変換する層
Facade 複雑なサブシステムの前に、使う側向けの窓口クラスを1つ置く
Composite フォルダとファイル、UIの部品ツリーなど「入れ子を同じ扱いにしたい」とき
State 注文ステータスのように、状態によって振る舞いが変わるとき
Mediator 画面部品どうしが直接呼び合って絡まったのを、仲介役に集める
Memento 取り消し(Undo)のために状態のスナップショットを保存する
Bridge 「何をするか」と「どう実現するか」を別々に拡張したいとき(JDBCのAPIとドライバの関係が有名な例)
Prototype 既存オブジェクトを複製して新しいものを作る。JDKの Cloneable / clone() は使いにくさで知られ、コピーコンストラクタなどで自分で書くことが多い

C の8個に共通するのは、言語機能では置き換えようがないことです。Adapterが必要かどうかは、外部APIの形と自分のドメインの形が食い違っているかで決まります。ラムダもrecordも関係ありません。構文の問題ではなく、境界をどこに引くかという判断そのものだからです。Prototype も同じで、浅いコピーで済むのか、中身まで複製すべきかは、JDKが決めてくれません。

State は迷いました。sealed interface と switch で書けるので、Aに入れる選択肢もあります。それでも、状態が増えて「状態ごとの振る舞い」がまとまりを持ち始めると、状態をクラスに分けるGoF本来の形に戻したくなる。そう考えてCに置きました。

30年前、Norvig はもっと大胆だった

Peter Norvig は1996年の講演「Design Patterns in Dynamic Programming」(Object World)のスライドで、23個のうち16個は Lisp や Dylan では「見えなくなるか、より単純になる」と述べています。

この記事の A+B は15個で、そのうち13個が Norvig の16個と重なります。違うのは両端です。Norvig は State・Mediator・Facade も「単純になる側」に入れていました。この3つは、この記事ではCです。

この差は、言語の違いから来ていると考えています。Norvig が根拠にしたのは、第一級の型(State)、メソッド結合(Mediator)、モジュール(Facade)といった当時の動的言語の機能でした。Javaはラムダとsealedで Strategy や Visitor には追いつきました。一方、State・Mediator・Facade については、今のJavaでもクラスの分け方を自分で決める場面が残っている、というのが私の見立てです。

それでも、パターン名を覚える価値はある

では、A と B は覚えなくていいのか。

半分はその通りです。Visitorの二重ディスパッチを自力で書けるようになる必要は、Java 21以降ならほぼありません。

ただ、名前は別です。パターン名は、設計を短く伝えるための共通語彙だからです。「この if-else の連鎖を Strategy にして」と言えば、チームメンバーにもAIにも一言で意図が伝わります。同じことを名前なしで頼むなら、「処理ごとにクラスか関数を分けて、呼び出し側で差し替えられるようにして……」と何行も要ります。

AIがコードを書くのが当たり前になった今、この差はむしろ大きくなっていると考えています。AIは頼めばVisitorの定型コードを一瞬で出してくれます。でも、そのコードを見て「Java 21 なら sealed と switch で済む。ただし default は書かない」と判断するのは、読む側の仕事です。パターンを知らなければ、出てきたコードが古い書き方なのかどうかすら見分けられません。

私の考えは、こうです。

  • A(言語機能で数行):名前と「何を解決するか」だけ知っていればよい。書き方は現代の言語機能で
  • B(ライブラリの中):自分では書かないが、使っているライブラリの挙動を理解するために仕組みを知っておく
  • C(今も自分で組む):ここだけは、実際に書いて手に馴染ませる価値がある

個別のパターンは、このアカウントで Singleton や Prototype なども解説しています。

まとめ

  • GoFの23パターンのうち、今のJavaでは8個が言語機能で数行になり、7個がライブラリやフレームワークの中に入っていた(分類は筆者の判断)
  • Visitor は sealed + switch で35行→19行になり、実装漏れの検出も残る。ただし default を書くと検出は消える
  • Adapter・Facade・Composite など8個は、言語機能では置き換えられない「境界の判断」として今も自分で組み立てる

今のプロジェクトで、「Adapter」と「Facade」に当たるクラスを1つずつ探してみてください。名前が付いていなくても、たぶんもう書いています。

あなたなら、23個のうちどれを「今も現役」に入れますか。State を A に入れる派の意見も聞いてみたいです。

12
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?