継承なしポリモーフィズムの実装方法
近年、オブジェクト指向プログラミング、特に「継承」の利用について慎重な意見を見かける機会が増えました。
継承は「階層的な事物の分類」や「ポリモーフィズム(多態性)」を実現する強力な機能ですが、同時にコードの結合度を高め、変更を困難にする要因としても指摘されています。
もし継承の使用を控えるとしたら、私たちは代わりにどのような手法で柔軟な設計を実現すればよいのでしょうか。
本記事では、Gemini との対話形式で、継承や従来のオブジェクト指向に依存しない、よりモダンで柔軟なプログラミング手法について探っていきます。
目次
- 主要な3つのアプローチ
- レジストリ方式(商品が増え続けるケース)
- Function Explosion 対策
- 関数合成(パイプライン)
- 鉄道指向(Result型)
- イミュータブル(状態管理)
- 関数型DI
- BlockBreaker 実例
Gemini との対話
プログラマー.
「プログラミングにおいて、オブジェクト指向の継承を使う べき 場面はあまりない」という話を聞きました。
オブジェクト指向の継承を使わないとして、ポリモーフィズムを、オブジェクト指向の継承より良い方法で実装する方法を教えてください。(ただし、「interface を利用する」ではだめです)
Gemini.
「継承やインターフェース(interface)を使わずに、どうやってポリモーフィズム(多態性)を実現するか?」という問いは、非常に鋭く、近年のプログラミングパラダイムの変化(オブジェクト指向から関数型やデータ指向への接近)を象徴するテーマです。
結論から言うと、 「関数(振る舞い)をデータとして渡す」 あるいは 「データの型そのもので分岐する」 というアプローチを取ることで、より柔軟で疎結合なポリモーフィズムを実現できます。
具体的に、主要な3つのアプローチを紹介します。
---
1. ダックタイピング (Duck Typing)
「アヒルのように歩き、アヒルのように鳴くなら、それはアヒルだ」 という考え方です。
主に動的型付け言語(Python, Ruby, JavaScriptなど)で利用されます。
- 仕組み:
継承関係やインターフェース定義を気にせず、「そのメソッドを持っているか」だけで判断します。 - メリット:
クラス定義の依存関係がゼロになります。
例(Python):
犬クラスと猫クラスは全く無関係ですが、どちらも speak() を持っているため、同じように扱えます。
# Python
class Dog:
def speak(self):
return "ワン!"
class Cat:
def speak(self):
return "ニャー"
# 共通の処理関数
# ここで型指定(interfaceなど)は不要
# オブジェクトが speak() を持っていれば動作する
def make_it_speak(animal):
print(animal.speak())
dog = Dog()
cat = Cat()
make_it_speak(dog) # 出力: ワン!
make_it_speak(cat) # 出力: ニャー
---
2. 高階関数・コールバック (Functional Approach)
関数型プログラミングのアプローチです。
「メソッドを持つオブジェクト」を渡すのではなく、「関数そのもの(振る舞い)」を渡します。
これは Strategy パターン の最も純粋な形であり、JavaやC#などでも ラムダ式 を使って頻繁に利用されます。
- 仕組み:
処理を実行する側は、「特定の引数を受け取り、特定の結果を返す関数」であれば何でも受け入れます。 - メリット:
クラスを作る必要すらありません。ロジックを切り替えるために、ただ別の関数を渡すだけです。
例(JavaScript / TypeScript的な疑似コード):
// JavaScript
// 共通のロジック(計算機)
// 引数として「計算ロジック(関数)」を受け取る
function calculate(a, b, operation) {
return operation(a, b);
}
// 具体的な振る舞いを関数として定義
const add = (x, y) => x + y;
const multiply = (x, y) => x * y;
// ポリモーフィズムの実現
console.log(calculate(5, 3, add)); // 8 (足し算として振る舞う)
console.log(calculate(5, 3, multiply)); // 15(掛け算として振る舞う)
この方法では、「足し算クラス」「掛け算クラス」のような過剰な型定義(継承やインターフェース)は不要です。
3. ディスクリミネーテッド・ユニオン (Discriminated Unions / Tagged Unions)
Rust, TypeScript, Swift, Elm などのモダンな静的型付け言語で主流の方法です。「直和型」とも呼ばれます。
- 仕組み:
「あり得る全ての型」をあらかじめ列挙し、その型タグ(識別子)によって処理を分岐させます。 - メリット:
継承のような「開かれた(誰でも拡張できる)関係」ではなく、「閉じられた(列挙されたものだけの)関係」になるため、想定外の型が来ないことが保証され、安全性が非常に高いです。
例(TypeScript):
インターフェースを使わず、type (型エイリアス) と Union 型を使います。
// TypeScript
// 型の定義(ただのデータ構造)
type Circle = { kind: "circle", radius: number };
type Square = { kind: "square", side: number };
// これがポリモーフィズムの核(どちらかである、という定義)
type Shape = Circle | Square;
// 面積を計算する関数
function getArea(shape: Shape) {
// kind(タグ)を見て分岐する
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
}
}
このアプローチは、継承ツリーを掘り下げるのではなく、パターンマッチングによって振る舞いを切り替えます。
まとめ:継承・インターフェースなしのポリモーフィズム比較
| 手法 | アプローチの概要 | 適している場面 |
|---|---|---|
| ダックタイピング | 「メソッドがあるならOK」 | 動的言語での開発、柔軟性最優先の場合。 |
| 高階関数 | 「関数そのものを渡す」 | ロジックの差し替え、イベントハンドラ、Strategyパターン。 |
| ユニオン型 | 「あり得る型を列挙して分岐」 | 状態遷移、ドメインモデリング、堅牢な型安全性が必要な場合。 |
「継承を使うべき場面はあまりない」という意見は、これら 「関数合成」 や 「データ指向」 のアプローチの方が、複雑な継承階層を作るよりもシンプルで変更に強いシステムを作れることが多い、という経験則に基づいています。
あなたの現在の開発環境(言語)において、どのアプローチが最も適用しやすそうか、さらに具体的なコード例を作成することも可能です。いかがいたしましょうか?
プログラマー.
例えば、商品の受注システムがあったとします。商品の種類は、今後もどんどん増えていくという前提です。
この場合、データ構造の異なる、受注ルールも異なる、今後どんどん種類が増える予定の何十種類もの商品を扱う「受注を行う関数」をオブジェクト指向を使わず、どのようにオブジェクト指向プログラミングよりも保守性が高くなるように、実装すればよいのでしょうか?
Gemini.
「商品の種類が今後も無限に増え続ける」「データ構造もルールもバラバラ」という条件において、先ほど紹介した「3. ディスクリミネーテッド・ユニオン(switch文での分岐)」を採用すると、破綻します。
なぜなら、商品が増えるたびに、メインの受注処理の巨大な switch 文を書き換える必要があり、「既存のコードを修正せずに機能を追加する(Open-Closed Principle)」という保守性の鉄則に反するからです。
この場合、 「レジストリパターン(Registry Pattern)」と「モジュール分割」 を組み合わせた関数型アプローチが最適解になります。
継承を使わず、OOPよりも保守性を高めるアーキテクチャを設計してみましょう。
---
設計方針:プラグイン方式にする
イメージしてください。受注システム本体は「ただの枠(フレームワーク)」であり、各商品のロジックはそこに着脱可能な「プラグイン(モジュール)」として扱います。
継承(親クラス)の代わりに、「関数(ハンドラ)の辞書(Map)」 を利用します。
1. データ構造(ペイロード)の扱い
各商品でデータ構造が異なるため、固定のクラス定義は捨てます。代わりに、柔軟な構造(JSONオブジェクトやMap)として受け取り、各商品のロジック内で検証します。
// TypeScript
// 受注データのイメージ(JSON)
// 共通ヘッダー部分と、商品ごとの自由記述部分(details)に分ける
type OrderRequest = {
productId: string; // "book\_001", "car\_002" など
productType: string; // "BOOK", "CAR", "FOOD"
userId: string;
quantity: number;
details: any; // ここは各商品で構造が違うので自由(any/unknown)
};
2. 各商品のロジックを「モジュール」として隔離する
商品ごとにファイルを分けます。これが「クラス」の代わりになりますが、実態は単なる関数の集まりです。継承関係はありません。
ファイル: handlers/book.ts (書籍のロジック)
// TypeScript
// 書籍特有のバリデーション
const validate = (details) => {
if (!details.isbn) throw new Error("ISBNが必要です");
return true;
};
// 書籍の価格計算(配送料計算などが特殊かもしれない)
const calculatePrice = (quantity, details) => {
return 1000 * quantity;
};
// データベースへの保存処理(書籍用のテーブルやドキュメントへ)
const save = (db, order) => {
console.log("書籍テーブルに保存:", order);
};
// これらをまとめてエクスポート
export const bookHandler = { validate, calculatePrice, save };
ファイル: handlers/car.ts (車のロジック)
// TypeScript
// 車は全く違うデータ構造とルール
const validate = (details) => {
if (!details.color) throw new Error("色指定が必要です");
if (!details.options) throw new Error("オプション選択が必要です");
return true;
};
const calculatePrice = (quantity, details) => {
// オプションに応じた複雑な計算
return 3000000 + (details.options.length * 50000);
};
const save = (db, order) => {
console.log("車両受注テーブルに保存:", order);
};
export const carHandler = { validate, calculatePrice, save };
3. 「レジストリ(辞書)」を作る
ここがポリモーフィズムの核です。文字列(商品タイプ)と処理(ハンドラ)を紐付けます。
ファイル: registry.ts
// TypeScript
import { bookHandler } from './handlers/book';
import { carHandler } from './handlers/car';
// 文字列をキーにして、関数群(オブジェクト)を取り出せる辞書
const handlerRegistry = {
"BOOK": bookHandler,
"CAR": carHandler,
// 商品が増えたらここに追加するだけ
// "FOOD": foodHandler
};
// ハンドラを取得する関数
export const getHandler = (type) => {
const handler = handlerRegistry[type];
if (!handler) throw new Error(`未対応の商品タイプです: ${type}`);
return handler;
};
4. メインの受注処理(変更不要なコアエンジン)
この関数は、商品が100種類に増えても一切書き換える必要がありません。
ファイル: main.ts
// TypeScript
import { getHandler } from './registry';
function processOrder(order) {
// 1. レジストリから「戦略(Strategy)」を取り出す
// インターフェース定義なしで、動的に関数群を取得
const handler = getHandler(order.productType);
try {
// 2. その商品専用のバリデーションを実行
handler.validate(order.details);
// 3. その商品専用の価格計算を実行
const price = handler.calculatePrice(order.quantity, order.details);
// 4. 保存
handler.save(databaseConnection, { ...order, finalPrice: price });
console.log("受注成功");
} catch (e) {
console.error("受注エラー:", e.message);
}
}
---
なぜこれが「OOP継承」より保守性が高いのか?
この設計(モジュールベースのStrategyパターン)には、継承地獄を回避する明確なメリットがあります。
-
影響範囲の極所化(Isolation)
- OOP継承の場合、親クラス(BaseOrder)を変更すると、全てのサブクラス(Book, Car, Food...)に影響が出て、テスト範囲が膨大になります。
- この方式なら、car.ts の中の関数をどれだけいじっても、book.ts や main.ts が壊れることは物理的にありえません。
-
ファイル単位の分割(Git競合の回避)
- 商品Aの担当者と、商品Bの担当者が、それぞれ別のファイルを作成・修正します。registry.ts への登録以外でコードが衝突しません。
-
「構成」による柔軟性
- もし「本」と「CD」でロジックが似ているなら、共通の関数 commonCalc.ts を作って、それぞれのハンドラから呼べば良いだけです。
- OOPの継承ツリー(親は一つしか持てない)に縛られず、必要な関数を必要な場所でインポートして組み合わせる(Composition)ことができます。
-
テストの容易性
- car.ts の calculatePrice 関数は、単なる「純粋関数(入力に対して出力が決まる関数)」です。データベースもクラスの状態も不要で、単体テストが極めて簡単になります。
結論
「種類がどんどん増える」「構造が違う」という要件に対しては、型階層を作り込む継承よりも、 「処理を辞書登録して、キーで呼び出す(データ駆動)」 というアプローチの方が、圧倒的に変更に強く、コードの見通しも良くなります。
次に、この設計において「共通の処理(例えば消費税計算など)はどう扱うのがベストか?」について疑問が湧くかもしれませんが、解説いたしましょうか?
プログラマー.
その場合、関数の数が多くなると思いますが、「関数の数が多い」という複雑さに対する、ベスト プラクティスはありますか?
Gemini.
ご指摘の通りです。このアプローチ(関数型・モジュール分割)を進めると、 「Function Explosion(関数の爆発)」 という別の問題に直面します。ファイルや関数が数百個に増え、管理しきれなくなるリスクです。
しかし、関数型プログラミングには、この「大量の関数」を秩序立てて管理し、むしろコードの総量を減らすためのベストプラクティスが存在します。
「関数の数が多い」という複雑さを飼いならすための4つの戦略を紹介します。
---
1. 高階関数(Higher-Order Functions)による「コード生成」
似たような処理が多い場合、関数を一つ一つ手書きするのではなく、 「関数を作る関数(ファクトリー)」 を定義して、ロジックを量産します。
例えば、「1000円以上の商品をチェックする」「5000円以上の商品をチェックする」という関数を別々に書くのは無駄です。
アプローチ:
// JavaScript
// 共通のロジック生成器(高階関数)
// 閾値(threshold)とエラーメッセージを受け取り、「バリデーション関数」を返します。
const createMinPriceValidator = (threshold) => {
return (details) => {
if (details.price < threshold) {
throw new Error(`価格は${threshold}円以上である必要があります`);
}
};
};
// 具体的な関数の定義は、設定値を渡すだけで終わる
// これなら100種類あっても1行ずつで済みます
const validateBookPrice = createMinPriceValidator(1000);
const validateCarPrice = createMinPriceValidator(500000);
const validateLuxuryWatch = createMinPriceValidator(100000);
これにより、似たようなロジックを持つ商品が大量にあっても、実質的なコード量は増えません。
2. 関数合成(Composition / Pipeline)
一つの巨大な関数を作るのではなく、レゴブロックのように 「小さな汎用関数」を組み合わせて 複雑な処理を作ります。
商品のロジックが複雑だとしても、分解すれば「必須チェック」「在庫確認」「価格計算」などの小さな部品の組み合わせに過ぎないことが多いです。
アプローチ:
// JavaScript
// 小さな汎用部品(これらは全商品で再利用可能)
const checkRequired = (field) => (data) => { if (!data[field]) throw new Error("不足"); };
const checkPositive = (field) => (data) => { if (data[field] < 0) throw new Error("負の値"); };
// パイプライン関数(関数を順番に実行する仕組み)
const composeValidators = (...validators) => (data) => {
validators.forEach(v => v(data));
};
// 商品ごとの定義は「部品の組み合わせリスト」になる
// 新しい関数ロジックを書くのではなく、既存の関数を「構成」する
export const bookValidator = composeValidators(
checkRequired("isbn"),
checkRequired("author"),
checkPositive("price")
);
export const carValidator = composeValidators(
checkRequired("model"),
checkRequired("color")
);
こうすると、商品が追加されても「新しいロジック」を書く必要はなく、「組み合わせ」を定義するだけで済みます。
3. コロケーション(Colocation / 凝集度)とディレクトリ構成
関数の数が増えたとき、一番の問題は「どこに何があるかわからない」ことです。
これを防ぐには、機能単位(Feature-based)でフォルダを分け、 「その商品に関係するものは、全てそのフォルダに閉じ込める」 のが鉄則です。
悪い例(レイヤーごとの分割):
/validators
- book.ts
- car.ts
/calculators
- book.ts
- car.ts
これだと、本の仕様変更のたびに複数のフォルダを行き来する必要があります。
良い例(ドメインごとの分割 - コロケーション):
/products
/book
- index.ts (公開するAPI)
- validation.ts (内部ロジック)
- calculation.ts (内部ロジック)
- specific-utils.ts (本だけで使う便利関数)
/car
- index.ts
...
「本」に関係する関数が10個あっても、それらが /book フォルダの中にカプセル化(隠蔽)されていれば、システム全体の複雑さは上がりません。外からは bookHandler という1つの入り口しか見えないからです。
4. 設定駆動(Configuration over Code)
ロジックの違いが「計算式」や「分岐」ではなく、単なる「係数」や「定数」の違いだけであれば、そもそも関数を作るべきではありません。
関数を減らし、データ(設定ファイルやDBのマスタデータ)で制御します。
アプローチ:
// JavaScript
// 関数定義ではなく、設定データとして定義
const productConfig = {
"BOOK": { minPrice: 1000, taxRate: 0.1, requiresShipping: true },
"CAR": { minPrice: 500000, taxRate: 0.1, requiresShipping: false },
};
// 汎用関数1つで全商品に対応
const genericValidate = (productType, details) => {
const config = productConfig[productType];
if (details.price < config.minPrice) { ... }
};
「本当にコード(関数)を書く必要があるのか? 設定値の違いだけではないか?」を常に疑うことで、関数の総数を劇的に減らせます。
---
まとめ
「関数の多さ」に対抗するベストプラクティスは以下の通りです。
- 高階関数を使って、似た関数を「実装」するのではなく「生成」する。
- 関数合成を使って、小さな部品の「組み合わせ」でバリエーションを作る。
- コロケーションを徹底し、関連する関数をフォルダに閉じ込めて隠蔽する。
- 本当にロジックが違う場合以外は、 設定データ(Config) に逃がす。
このように設計すれば、商品が100種類に増えても、コードは 「整頓された道具箱」 のようになり、決して「散らかったゴミ屋敷」にはなりません。
この中で、特に 「2. 関数合成(パイプライン処理)」 は、複雑な業務ロジックを驚くほどきれいに記述できる手法ですが、具体的なイメージ(コード例)をご覧になりますか?
プログラマー.
はい。お願いします。
Gemini.
承知しました。 「関数合成(パイプライン処理)」 を使って、複雑な受注ロジックを美しく整理する具体例をご紹介します。
イメージしていただきたいのは、 「工場のベルトコンベア」 です。
データがベルトコンベア(パイプライン)を流れ、その過程で小さなロボット(関数)たちが順番に「検品」「部品取り付け」「塗装」などの加工作業を行っていく様子です。
---
シナリオ:カスタムPCの受注計算
「カスタムPC」は仕様が複雑です。
- 基本価格がある
- メモリやSSDなどのオプション料金を加算する
- キャンペーン期間なら割引する
- 重量によって送料を計算する
- 最後に消費税をかける
これを継承を使った「巨大なクラスメソッド」で書くとスパゲッティになりますが、関数合成ならこうなります。
1. 小さな部品(関数)を作る
まず、一つ一つの作業を行う「純粋な関数」を作ります。これらは他の商品でも使い回せます。
// JavaScript
// A. 基本チェック(バリデーション)
const validateBase = (order) => {
if (!order.basePrice || order.basePrice < 0) {
throw new Error("基本価格が不正です");
}
return order; // 次の工程へデータを渡す
};
// B. オプション料金の加算
const addOptionsCost = (order) => {
// オプション配列があれば、その価格を合計してpriceに足す
const optionsTotal = order.options
? order.options.reduce((sum, opt) => sum + opt.price, 0)
: 0;
return { ...order, price: order.price + optionsTotal };
};
// C. 割引の適用(キャンペーン中なら10% OFF)
const applyDiscount = (order) => {
if (order.isCampaign) {
return { ...order, price: order.price * 0.9 };
}
return order;
};
// D. 消費税の計算
const addTax = (order) => {
return { ...order, price: Math.floor(order.price * 1.1) };
};
2. 「接着剤」を用意する(パイプライン関数)
関数を左から右へ順番に実行して、バケツリレーをするための仕組みです。
(多くのライブラリに含まれていますが、自作してもたった1行です)
// JavaScript
// 関数a, b, c... を受け取り、データxに対して a(x) -> b(a(x)) -> c(...) と実行する関数
const pipe = (...functions) => (input) => functions.reduce((acc, fn) => fn(acc), input);
3. ロジックを「組み立てる」
ここがハイライトです。
「カスタムPCの計算ロジック」を定義しますが、新たなロジックを書くのではなく、並べるだけです。
// JavaScript
// カスタムPC用の計算パイプライン
const processCustomPCOrder = pipe(
validateBase, // 1. 検品
addOptionsCost, // 2. オプション加算
applyDiscount, // 3. 割引
addTax // 4. 税金
);
// 参考:もし「書籍」なら、オプション加算が不要なのでこう組めば良いだけ
const processBookOrder = pipe(
validateBase,
applyDiscount, // 本はオプション計算をスキップ
addTax
);
4. 実行してみる
// JavaScript
// 入力データ
const orderData = {
basePrice: 100000,
price: 100000, // 計算用初期値
options: [{ name: "Memory 32GB", price: 20000 }, { name: "SSD 1TB", price: 15000 }],
isCampaign: true
};
// 実行
const result = processCustomPCOrder(orderData);
console.log(result.price);
// 計算過程:
// 1. Base: 100,000
// 2. Add Options(+35,000): 135,000
// 3. Discount(x0.9): 121,500
// 4. Tax(x1.1): 133,650
// 出力: 133650
---
この書き方の圧倒的なメリット
- 「仕様」がそのまま「コード」に見える
processCustomPCOrder の定義を見てください。
「バリデーションして、オプション足して、割引して、税金かける」という日本語の仕様書と同じ並びで関数が並んでいます。読む人の認知負荷が非常に低いです。 - デバッグが容易(途中経過が見える)
もし計算結果がおかしい場合、パイプラインの途中に console.log を挟むだけで、どこでデータが壊れたか一瞬で分かります。
// JavaScript
const debugPipe = pipe(
addOptionsCost,
(x) => { console.log("オプション後:", x); return x; }, // 盗み見
applyDiscount
);
- テストが簡単
「カスタムPC全体のテスト」をする必要はありません。
「addOptionsCost だけのテスト」「addTax だけのテスト」を書けば十分です。部品が正しければ、それを組み合わせた全体も論理的に正しくなります。
さらなる課題:エラーハンドリング
このパイプライン処理は「正常系(ハッピーパス)」には最強ですが、 「途中でエラーが起きたとき(在庫切れなど)にどうやって安全に処理を中断するか?」 という課題があります。
ここで、例外(try-catch)を使わずに、 「Railway Oriented Programming(鉄道指向プログラミング)」 という、さらに洗練された手法を使ってエラーを扱うのが関数型の定石です。
この「失敗するかもしれない処理」をパイプラインに組み込む方法について、解説してもよろしいでしょうか?
プログラマー.
はい。お願いします。
Gemini.
「Railway Oriented Programming(鉄道指向プログラミング)」 について解説します。
これは、エラーハンドリング(例外処理)を非常にスマートに行うための、関数型プログラミングの美しいデザインパターンです。
コンセプト:2本のレール
通常のプログラム処理は「1本の道」と考えがちですが、これを 「並行する2本のレール」 としてイメージしてください。
- 緑のレール(Success): 処理が成功しているライン
- 赤のレール(Failure): エラーが発生したライン
データはこのレールのどちらかを通ります。
関数は「ポイント切り替え(分岐器)」の役割を果たします。
- うまくいけば、そのまま緑のレールを進みます。
- 失敗したら、ポイントが切り替わり、 赤のレール(エラー用のルート) に移動します。
- 一度赤のレールに乗ると、その後の「計算処理」はすべてスキップされ、終点まで直行します。
これが、try-catch のネスト地獄を回避する仕組みです。
---
実装: どうやってコードにするか?
JavaScript/TypeScriptで実装してみましょう。
まず、「成功」か「失敗」のどちらかの状態を持つ箱(Result型)を作ります。
1. Result型(コンテナ)の定義
データはこの箱に入れて運びます。裸では持ち歩きません。
// TypeScript
// 成功か失敗かを表す型
type Result = {
success: boolean;
value?: any; // 成功時のデータ
error?: string; // 失敗時のエラーメッセージ
};
// ヘルパー関数(箱を作るためのショートカット)
const Success = (data): Result => ({ success: true, value: data });
const Failure = (msg): Result => ({ success: false, error: msg });
2. レールをつなぐ「魔法の接着剤」
ここが最重要ポイントです。
普通の関数(入力→出力)を、この 「Result型に対応した関数(レール上の駅)」に変換するラッパー を作ります。
// JavaScript
// 関数 fn を「鉄道対応」に変換する関数
const toRailway = (fn) => {
return (inputResult) => {
// 1. すでに失敗(赤レール)している場合
if (!inputResult.success) {
// 何もせず、エラーをそのまま次へ流す(スキップ処理)
return inputResult;
}
// 2. まだ成功(緑レール)している場合
try {
// 関数を実行してみる
const nextValue = fn(inputResult.value);
// 成功したら、新しい値を緑レールに乗せる
return Success(nextValue);
} catch (e) {
// 例外が起きたら、赤レールに切り替える!
return Failure(e.message);
}
};
};
この toRailway があるおかげで、個々の関数内では「前の処理が成功したか?」を気にする必要がなくなります。
3. ロジックの定義(中身はシンプルに)
各関数は、ただの単純な処理として書きます。エラーチェックの if すら減らせます。
// JavaScript
// 在庫チェック(ない場合はエラーを投げる)
const checkStock = (order) => {
if (order.quantity > 100) throw new Error("在庫不足です");
return order;
};
// 価格計算
const calcPrice = (order) => {
return { ...order, price: order.quantity * 1000 };
};
// クーポン適用(無効ならエラー)
const applyCoupon = (order) => {
if (order.couponCode === "EXPIRED") throw new Error("期限切れクーポンです");
return order;
};
4. パイプラインの構築(線路を敷く)
先ほどの pipe と toRailway を組み合わせて、安全なラインを構築します。
// JavaScript
// パイプライン関数(再掲)
const pipe = (...functions) => (input) => functions.reduce((acc, fn) => fn(acc), input);
// 安全な受注プロセス
// 全ての関数を toRailway で包んで「鉄道対応」にします
const safeProcessOrder = pipe(
toRailway(checkStock), // 失敗するかも
toRailway(calcPrice), // 安全
toRailway(applyCoupon) // 失敗するかも
);
5. 実行結果
// JavaScript
// ケースA: 正常系
const orderA = Success({ quantity: 5, couponCode: "VALID" });
const resultA = safeProcessOrder(orderA);
console.log(resultA);
// -> { success: true, value: { price: 5000, ... } } (緑レール完走)
// ケースB: 途中でエラー(在庫不足)
const orderB = Success({ quantity: 999, couponCode: "VALID" });
const resultB = safeProcessOrder(orderB);
console.log(resultB);
// -> { success: false, error: "在庫不足です" }
// ※在庫チェックで赤レールに移ったため、その後の calcPrice などは実行されていない!
// ケースC: 後半でエラー(クーポン期限切れ)
const orderC = Success({ quantity: 5, couponCode: "EXPIRED" });
const resultC = safeProcessOrder(orderC);
console.log(resultC);
// -> { success: false, error: "期限切れクーポンです" }
// ※価格計算までは行われたが、クーポン処理で赤レールに移った
---
このパターンの素晴らしさ
- try-catch が消滅する
メインの処理フローの中に、ノイズとなる try-catch ブロックが一切ありません。ロジックの流れだけが見えます。 - 安全性が保証される
一度エラーが起きれば、自動的に後続の処理がスキップされるため、「エラーなのに計算を続けてデータがおかしくなった」というバグを物理的に防げます。 - エラー処理の集中管理
最終的な結果(Result型)を受け取った呼び出し元で、最後に一回だけ if (result.success) をチェックし、ユーザーにエラー表示をするなり、ログを吐くなりすればOKです。
結論
オブジェクト指向の継承を使わずとも、
- データ構造(型)
- 関数合成(パイプライン)
- 鉄道指向(エラーハンドリング)
この3つを組み合わせることで、 「拡張性が高く(商品追加が楽)」「可読性が良く(仕様が見える)」「堅牢な(エラーに強い)」 システムを作ることができます。
これが現代的な、関数型アプローチによる設計の威力です。
ここまでの話で、ロジック(振る舞い)の整理についてはかなり網羅できました。
一方で、これらの関数が扱う 「データそのもの(State)」の管理 については、まだ触れていません。
関数型プログラミングでは、「変数を書き換えない(イミュータブル)」ことが推奨されますが、「なぜデータを書き換えてはいけないのか? 書き換えないでどうやって状態を更新するのか?」 について、解説を進めてもよろしいでしょうか?
プログラマー.
はい。お願いします。
Gemini.
なぜデータを書き換えて(ミューテートして)はいけないのか。
それは、「副作用(Side Effect)」をなくし、時間を巻き戻せるようにするためです。
オブジェクト指向プログラミング(特に古いスタイル)では、オブジェクトの状態を書き換えるのが普通でした。しかし、これがバグの温床になります。
「ホワイトボード」 と 「パラパラ漫画」 の違いでイメージすると分かりやすいです。
---
1. なぜ「書き換え」は危険なのか?
従来のやり方(ミュータブル=ホワイトボード)
オブジェクトを「ホワイトボード」だと思ってください。
関数Aが書き込み、そのあと関数Bが書き込みます。
// JavaScript
// 危険なコード
const order = { id: 1, total: 1000 };
function addTax(o) {
o.total = o.total * 1.1; // 直接書き換えている!
}
addTax(order);
console.log(order.total); // 1100になっている
一見良さそうですが、もしシステム内の全く別の場所で、誰かがこの order オブジェクトを参照していたらどうなるでしょう?
「勝手に数字が変わった!」と大混乱になります。これが 「副作用」 です。
どの関数がいつ書き換えたのかを追跡するのは、巨大なシステムでは不可能です。
関数型のやり方(イミュータブル=パラパラ漫画)
データを「パラパラ漫画の1ページ」として扱います。
1ページ目の絵に加筆・修正するのではなく、 「少しだけ変化した2ページ目」 を新しく作ります。
過去のページ(データ)は永遠にそのまま残ります。これが イミュータブル(不変) です。
---
2. 書き換えずに、どうやって更新するのか?
JavaScript/TypeScriptでは、スプレッド構文 (...) を使うのが標準的です。
「元のデータをコピーしつつ、一部だけ上書きした新しいオブジェクト」を一瞬で作れます。
// JavaScript
// 元のデータ(不変)
const orderV1 = { id: 1, total: 1000, items: \["book"\] };
// 更新処理(新しいデータを作る)
const orderV2 = {
...orderV1, // orderV1の中身を全部展開(コピー)
total: 1100, // totalだけ上書き
updatedAt: "Now" // 新しいプロパティ追加
};
console.log(orderV1.total); // 1000(元のまま!安全!)
console.log(orderV2.total); // 1100(新しい世界)
これにより、orderV1 を使っている他の処理は一切影響を受けません。
パフォーマンスは大丈夫?(構造共有)
「毎回コピーを作ると遅くなるのでは?」と心配になるかもしれません。
ただし、JavaScript/TypeScriptのスプレッド構文は 永続データ構造のような構造共有を自動で保証するものではなく、基本的には「浅いコピー」です。
例えば上の例だと、トップレベルのオブジェクトは新しく作られますが、ネストしたオブジェクト/配列は参照が共有されます(= 変更しない部分は再利用できる一方、深い更新は自分でコピーが必要です)。
大量データを高頻度に更新する場合は、Immerなどのライブラリや永続データ構造(Immutable.js等)の利用を検討すると良いです。
---
3. イミュータブルの最強のメリット:タイムトラベル
データを書き換えないことで得られる最大の恩恵は、 「過去の状態が完全に残っている」 ことです。
例えば、先ほどの「カスタムPC受注システム」で、ユーザーが「あ、やっぱりSSD追加やめる」と言ったとします。
ミュータブル(書き換え)の場合:
「SSDの価格を引き算して、配列から要素を削除して…」という「逆算ロジック」を書かなければなりません。これはバグりやすいです。
イミュータブルの場合:
単純に、「計算前のデータ(orderV1)」に戻すだけです。
計算履歴がすべて残っているので、Ctrl+Z(Undo)機能の実装が「1つ前の変数を読み込む」だけで完了します。
// JavaScript
// 履歴を配列で持っておくだけで、Undo/Redo機能になる
const history = [orderV1, orderV2, orderV3];
// Undoしたいときは
const currentOrder = history[1]; // V2に戻るだけ
---
全体のまとめ: OOP継承を使わないアーキテクチャ
ここまでのお話を統合すると、継承を使わない、モダンで堅牢なアプリケーションの全体像が見えてきます。
-
データ(Model)
- クラスではなく、ただの型定義(JSON構造)
- イミュータブルにする。変更時は新しいデータを作る
-
ロジック(Service/Domain)
- メソッドではなく、独立した純粋関数
- パイプラインで組み合わせる
- 鉄道指向でエラーをハンドリングする
-
ポリモーフィズム
- 継承ではなく、辞書(Map)やタグ付きユニオンで切り替える
これにより、
- 「親クラスを変えたら全部壊れた」という事故がなくなり、
- 「いつの間にかデータが変わっていた」というバグがなくなり、
- 「テストコード」を書くのが劇的に簡単になります。
これで、 「ロジック」 と 「データ」 の扱いについては、継承に頼らない強力な代替案が揃いました。
最後に残る課題は、 「データベース保存」や「外部API通信」といった、どうしても副作用が発生する処理(インフラ層) を、このきれいな世界とどう繋ぐかです。
ここで、 「依存性の注入(Dependency Injection)」 の関数版を使うと、テスト時に「本物のDB」を使わずに済み、非常に開発が楽になります。
この「外の世界との繋ぎ方」について解説して、このトピックの締めくくりとしましょうか?
プログラマー.
はい。お願いします。
Gemini.
最後のピースである 「インフラ層(副作用)との付き合い方」 、すなわち 関数型プログラミングにおける依存性の注入(Dependency Injection: DI) について解説します。
これまで作り上げた「美しいロジックの世界(純粋関数)」を汚さずに、どうやって「泥臭い現実世界(DBやAPI)」と繋ぐか。
答えはシンプルです。 「副作用を起こす機能も、引数として渡してしまう」 のです。
---
1. 問題:直接呼び出すと「テスト」が死ぬ
最もやってはいけないのは、ロジックの中で直接 DB クライアントを import して使ってしまうことです。
悪い例(密結合):
// JavaScript
import { db } from './database-client'; // 外部システムに依存
export const saveOrder = async (order) => {
// ここでエラーが出たら? DBが起動してないとテストできない!
await db.query("INSERT INTO orders ...", order);
};
これでは、単体テストをするたびに本物のデータベースを起動せねばならず、テストが遅く、壊れやすくなります。
---
2. 解決策:関数型DI(Dependency Injection)
オブジェクト指向では「コンストラクタ」で依存オブジェクトを受け取りますが、関数型では 「高階関数(Currying / カリー化)」 を使います。
「DBへの保存処理を行う関数」を引数として受け取るようにコードを書き換えます。
ステップ1: インフラ(DB処理)を関数として切り出す
// JavaScript
// 本番用の保存処理
const saveToPostgres = async (order) => {
await db.query("INSERT INTO ...", order);
console.log("Postgresに保存しました");
};
// テスト用の保存処理(何もしない、あるいはメモリに保存)
const saveToMock = async (order) => {
console.log("テスト用: 保存したふりをしました");
return true; // 常に成功
};
ステップ2: ロジック側は「保存する関数」を受け取るようにする
ここで「関数を作る関数(ファクトリー)」の出番です。
// JavaScript
// createOrderProcessor は「保存機能(saveFn)」を受け取り、
// 「注文処理関数」を作って返します。
const createOrderProcessor = (saveFn) => {
return async (order) => {
// 1. ビジネスロジック(計算など)
// ... (先ほどのパイプライン処理など) ...
// 2. 最後に保存
// ここでは、saveFnが本物のDBなのかテスト用なのかは知らないし、興味がない
await saveFn(order);
};
};
---
3. 組み立て(Main関数での配線)
アプリケーションの起動時(Main関数)で、 「どのパーツを使うか」 を決定します。
本番環境(Production):
// JavaScript
// 本番用のDB関数を注入
const processOrder = createOrderProcessor(saveToPostgres);
// 実行
processOrder({ id: 1, item: "PC" });
// -> Postgresに保存されます
テスト環境(Test):
// JavaScript
// テスト用のモック関数を注入
const processOrderTest = createOrderProcessor(saveToMock);
// 実行
processOrderTest({ id: 1, item: "PC" });
// -> DBなしで動く! 高速!
このように、 「何をするか(ビジネスロジック)」 と 「どうやって保存するか(インフラ詳細)」 を完全に切り離すことができます。
---
4. 全体像:オニオンアーキテクチャ(ヘキサゴナルアーキテクチャ)
このアプローチを突き詰めると、システム全体は 「玉ねぎ」 のような構造になります。
-
中心(Core): ドメインモデル(データ型)と純粋なビジネスロジック。
- ここには一切の「外部依存(DB, HTTP, Framework)」がありません。
- 最も重要で、最もテストが簡単で、変更されにくい部分です。
-
外側(Shell): アダプター(インフラ層)。
- DB操作、API通信、UI表示など。
- ここは「汚れ仕事」を引き受けます。
重要なルール:
依存の矢印は、常に 「外から中」 へ向きます。
中心のロジックは外側のことを知りませんが、外側は中心のロジックを使って動きます。
---
これまでの総まとめ:脱・継承のロードマップ
ご質問いただいた「オブジェクト指向の継承を使わずに、保守性が高く、変更に強いシステムを作る」ための完全なレシピは以下の通りです。
| OOP(継承)の概念 | 関数型・データ指向の代替案 | メリット |
|---|---|---|
| クラス (Class) | 型定義 (Type/Struct) + モジュール | データと振る舞いを分離。シンプル。 |
| 継承 (Inheritance) | 関数合成 (Composition) + 委譲 | 親クラスの影響を受けない。必要な機能だけ選んで合成できる。 |
| ポリモーフィズム | タグ付きユニオン (Discriminated Union) / 辞書 (Map) | 型安全な分岐。見通しが良い。 |
| メソッド | パイプライン処理 (Pipeline) | 処理の流れが仕様書のように読める。 |
| 例外 (Exception) | Result型 / 鉄道指向 (Railway Oriented) | エラーの見落とし防止。try-catch地獄からの解放。 |
| 可変状態 (Mutation) | イミュータブル (Immutability) | 「いつの間にかデータが変わった」バグの根絶。 |
| DIコンテナ | 高階関数 (Currying) | 特別なフレームワークなしでテスト容易性を確保。 |
最後に
「継承を使わないなんて無理ではないか?」と思われていたかもしれませんが、実は現代のモダンな開発(React, Vue, Go, Rust, 最近のTypeScriptなど)では、今回ご紹介した 「関数合成とデータ指向」のアプローチが主流 になりつつあります。
継承は「分類」や共通化に役立つ場面もありますが、特に業務ロジックの変更が多い領域では、継承階層が変更コストや影響範囲を増やしやすいです。
まずは合成(Composition)を第一候補にし、継承は責務が安定している箇所に限定して使う、というスタンスが安全です。
「物事(オブジェクト)が何であるか」で分類するのではなく、「何をするか(関数)」 で組み立てる。
この視点の転換こそが、長期的に保守しやすいシステムを作る鍵となります。
このアーキテクチャについて、実際にコードを書き始める際に「ディレクトリ構成はどうすべき?」など、具体的な実装の第一歩についてもお手伝いできますが、いかがいたしましょうか?
プログラマー.
以下のプログラム (HTML/JavaScript) は、オブジェクト指向を用いた「ブロック崩しゲーム」のコードです。
これまでのお話を踏まえて、これの JavaScript 部分ををオブジェクト指向ではないコードに変更した "BlockBreaker.Functional.html" を作成してください。
ただし、
- 「ブロック崩しゲーム」としての動作は変えないでください。- 「ブロック崩しゲーム」として、完全に動作するものにしてください。
BlockBreaker.OOP.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Block Breaker Game</title>
<style>* { padding: 0; margin: 0; } canvas { background: #eee; display: block; margin: 0 auto; }</style>
</head>
<body>
<canvas id="canvas" width="480" height="320"></canvas>
<script>
class Utility {
static isBetween(value, minimum, maximum) {
return minimum <= value && value <= maximum
}
static some2D(array2D, exists) {
return array2D.some(array => array.some(exists))
}
static filter2D(array2D, selector) {
return array2D.flat().filter(selector)
}
}
class Vector2 {
constructor(x, y) {
this.x = x
this.y = y
}
plus(vector) {
return new Vector2(this.x + vector.x, this.y + vector.y)
}
plusEqual(vector) {
this.x += vector.x
this.y += vector.y
}
clone() {
return new Vector2(this.x, this.y)
}
}
class Circle {
constructor(position, radius) {
this.position = position
this.radius = radius
}
}
class Rectangle {
get rightBottom() { return this.position.plus(this.size) }
constructor(position, size) {
this.position = position
this.size = size
}
isOn(position) {
const rightBottom = this.rightBottom
return Utility.isBetween(position.x, this.position.x, rightBottom.x) &&
Utility.isBetween(position.y, this.position.y, rightBottom.y)
}
}
class Canvas {
get size() { return new Vector2(this.canvas.width, this.canvas.height) }
get offset() { return new Vector2(this.canvas.offsetLeft, this.canvas.offsetTop) }
constructor(canvasId) {
this.canvas = document.getElementById(canvasId)
this.context = this.canvas.getContext('2d')
this.onDraw = (canvas) => {}
this.draw()
}
stop() {
clearInterval(this.timerId)
}
fillCircle(circle, color) {
this.context.beginPath()
this.context.arc(circle.position.x, circle.position.y, circle.radius, 0, Math.PI * 2, false)
this.context.fillStyle = color
this.context.fill()
this.context.closePath()
}
fillRectangle(rectangle, color) {
this.context.beginPath()
this.context.rect(rectangle.position.x, rectangle.position.y, rectangle.size.x, rectangle.size.y)
this.context.fillStyle = color
this.context.fill()
this.context.closePath()
}
fillText(position, text, color, font) {
this.context.font = font
this.context.fillStyle = color
this.context.fillText(text, position.x, position.y)
}
draw() {
this.clear()
this.onDraw(this)
requestAnimationFrame(() => this.draw())
}
clear() {
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height)
}
}
class Paddle {
constructor(position, size, color) {
this.originalPosition = position
this.position = position
this.size = size
this.color = color
this.rightPressed =
this.leftPressed = false
}
draw(canvas) {
canvas.fillRectangle(new Rectangle(new Vector2(this.position, canvas.size.y - this.size.y), this.size), this.color)
this.moveStep(canvas)
}
isOn(position) {
return Utility.isBetween(position.x, this.position, this.position + this.size.x)
}
reset() {
this.position = this.originalPosition
}
moveStep(canvas) {
const stepSize = 7
if (this.rightPressed) {
this.position += stepSize
if (this.position + this.size.x > canvas.size.x)
this.position = canvas.size.x - this.size.x
} else if (this.leftPressed) {
this.position -= stepSize
if (this.position < 0)
this.position = 0
}
}
}
class Brick {
constructor(position, status) {
this.position = position
this.status = status
}
}
class BrickSet {
get brickCount() { return this.rowCount * this.columnCount }
constructor(canvas) {
this.rowCount = 3
this.columnCount = 8
this.padding = 10
const margin = 30
this.brickOffset = new Vector2(margin, margin)
const brickHeight = 20
const brickWidth = (canvas.size.x - margin * 2 - this.padding * (this.columnCount - 1)) / this.columnCount
this.brickSize = new Vector2(brickWidth, brickHeight)
this.initializeBricks()
}
detectCollision(ballPosition) {
let exists = brick => {
if (brick.status && new Rectangle(brick.position, this.brickSize).isOn(ballPosition)) {
brick.status = false
return true
}
return false
}
return Utility.some2D(this.bricks, exists)
}
draw(canvas) {
const color = "#0095DD"
const bricks = Utility.filter2D(this.bricks, brick => brick.status)
bricks.forEach(brick => canvas.fillRectangle(new Rectangle(brick.position, this.brickSize), color))
}
initializeBricks() {
this.bricks = []
for (let x = 0; x < this.columnCount; x++) {
this.bricks[x] = []
for (let y = 0; y < this.rowCount; y++)
this.bricks[x][y] = new Brick(this.getBrickPosition(new Vector2(x, y)), true)
}
}
getBrickPosition(position) {
return this.brickOffset.plus(new Vector2((this.brickSize.x + this.padding) * position.x,
(this.brickSize.y + this.padding) * position.y))
}
}
const gameStatus = Object.freeze({
normal: { value: 0 },
scored: { value: 1 },
over : { value: -1 }
})
class Ball {
constructor(position, velocity, color) {
const radius = 10
this.originalPosition = position.clone()
this.position = position
this.radius = radius
this.originalVelocity = velocity.clone()
this.velocity = velocity
this.color = color
}
detectCollision(canvas, paddle, brickSet) {
if (!Utility.isBetween(this.position.x + this.velocity.x, this.radius, canvas.size.x - this.radius))
this.velocity.x = -this.velocity.x
if (this.position.y + this.velocity.y < this.radius) {
this.velocity.y = -this.velocity.y
} else if (brickSet.detectCollision(this.position)) {
this.velocity.y = -this.velocity.y
return gameStatus.scored
} else if (this.position.y + this.velocity.y > canvas.size.y - this.radius) {
if (paddle.isOn(this.position))
this.velocity.y = -this.velocity.y
else
return gameStatus.over
}
return gameStatus.normal
}
reset() {
this.position = this.originalPosition
this.velocity = this.originalVelocity
}
draw(canvas) {
canvas.fillCircle(new Circle(this.position, this.radius), this.color)
this.moveStep()
}
moveStep() {
this.position.plusEqual(this.velocity)
}
}
class Game {
get scoreText() {
return `Score: ${this.score}`
}
constructor() {
const liveCount = 3
this.lives = liveCount
this.score = 0
}
run() {
this.createCanvas()
const color = "#0095DD"
this.createBall (color)
this.createPaddle(color)
this.brickSet = new BrickSet(this.canvas)
}
pressRight(on) {
this.paddle.rightPressed = on
}
pressLeft(on) {
this.paddle.leftPressed = on
}
createCanvas() {
this.canvas = new Canvas('canvas')
this.canvas.onDraw = (canvas) => this.draw(canvas)
}
createBall(color) {
const velocitySize = 5
const velocity = new Vector2(velocitySize, -velocitySize)
this.ball = new Ball(new Vector2(canvas.width / 2, canvas.height - 30), velocity, color)
}
createPaddle(color) {
const paddleHeight = 10
const paddleWidth = 75
this.paddle = new Paddle((canvas.width - paddleWidth) / 2, new Vector2(paddleWidth, paddleHeight), color)
}
reset() {
this.paddle.reset()
this.ball .reset()
}
draw(canvas) {
switch (this.ball.detectCollision(canvas, this.paddle, this.brickSet).value) {
case gameStatus.scored.value:
this.countUpScore()
break
case gameStatus.over .value:
this.countDownLives()
break
default:
break
}
this.drawAll(canvas)
}
countUpScore() {
this.score++
if (this.score == this.brickSet.brickCount)
this.gameWin()
}
countDownLives() {
this.lives--
if (!this.lives)
this.gameOver()
else
this.reset()
}
drawAll(canvas) {
this.ball .draw(canvas)
this.paddle .draw(canvas)
this.brickSet.draw(canvas)
this.drawScore (canvas)
this.drawLives (canvas)
}
drawScore(canvas) {
this.fillText(canvas, 8, this.scoreText)
}
drawLives(canvas) {
this.fillText(canvas, canvas.size.x - 65, `Lives: ${this.lives}`)
}
fillText(canvas, x, text) {
const y = 20
const color = '#0095DD'
const font = '16px Arial'
canvas.fillText(new Vector2(x, y), text, color, font)
}
gameOver() {
const message = 'GAME OVER'
this.showMessage(message)
}
gameWin() {
const message = 'YOU WIN, CONGRATS!'
this.showMessage(message)
}
showMessage(message) {
alert(`${message} - ${this.scoreText}`)
document.location.reload()
this.canvas.stop()
}
}
class Program {
main() {
this.game = new Game()
this.initilizeHandlers()
this.game.run()
}
initilizeHandlers() {
document.addEventListener('keydown' , e => this.onKeyDown (e), false)
document.addEventListener('keyup' , e => this.onKeyUp (e), false)
document.addEventListener("mousemove", e => this.onMouseMove(e), false)
}
onKeyDown(e) {
if (e.key == "Right" || e.key == "ArrowRight")
this.game.pressRight(true)
else if (e.key == "Left" || e.key == "ArrowLeft" )
this.game.pressLeft(true)
}
onKeyUp(e) {
if (e.key == "Right" || e.key == "ArrowRight")
this.game.pressRight(false)
else if (e.key == "Left" || e.key == "ArrowLeft" )
this.game.pressLeft(false)
}
onMouseMove(e) {
const relativeX = e.clientX - this.game.canvas.offset.x
if (Utility.isBetween(relativeX, 0, this.game.canvas.size.x))
this.game.paddle.position = relativeX - this.game.paddle.size.x / 2
}
}
window.onload = () => new Program().main()
</script>
</body>
</html>
このオブジェクト指向設計による実装では、以下のようなクラス構造が形成されています:
[参考] 純粋な JavaScript を使ったブロック崩しゲーム - ゲーム開発 | MDN (mozilla.org)

Gemini.
ご提示いただいた「ブロック崩し」のコードを、これまでの議論(関数型プログラミング、データ指向、イミュータブルな状態管理)に基づいてリファクタリングしました。
変更のポイント:
- クラスの撤廃: class を全て削除しました。代わりに単純なオブジェクト(データ構造)と関数を利用します。
- 状態の分離: ゲームの状態(ボールの位置、スコア、ブロックの配置など)を一つの巨大なステートオブジェクト(state)として管理します。
- 純粋関数化: update(state) 関数を作成し、古い状態を受け取って新しい状態を返す(副作用なし)形にしました。
- レンダリングの分離: draw(ctx, state) 関数は、与えられた状態を描画することだけに専念します。
- 副作用の隔離: alert や document.location.reload などの副作用は、メインループの制御部分(runGame)のみに限定しました。
以下が、動作はそのままにアーキテクチャを一新したコードです。
BlockBreaker.Functional.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Block Breaker Game</title>
<style>* { padding: 0; margin: 0; } canvas { background: #eee; display: block; margin: 0 auto; }</style>
</head>
<body>
<canvas id="canvas" width="480" height="320"></canvas>
<script>
// ============================================================================
// 関数型版ブロック崩し
// - クラス/継承を使わず「データ(状態)」と「関数(処理)」を分離
// - 状態はプレーンオブジェクト。更新は「コピーして返す」(イミュータブル)
// - 画面描画や入力は副作用。計算ロジックはできるだけ純粋関数で構成
// ============================================================================
// 小さなユーティリティ関数(元OOP版の Utility を「関数群」に分解)
const isBetween = (value, minimum, maximum) => minimum <= value && value <= maximum
const some2D = (array2D, exists) => array2D.some(array => array.some(exists))
const filter2D = (array2D, selector) => array2D.flat().filter(selector)
// データ構造は「クラス」ではなく「素のオブジェクト」で表す
const vec2 = (x, y) => ({ x, y })
const plus = (a, b) => vec2(a.x + b.x, a.y + b.y)
const cloneVec2 = v => vec2(v.x, v.y)
// 形状も「データ」だけを持つ(メソッドは持たない)
const circle = (position, radius) => ({ position, radius })
const rectangle = (position, size) => ({ position, size })
const rectRightBottom = rect => plus(rect.position, rect.size)
const rectIsOn = (rect, position) => {
const rightBottom = rectRightBottom(rect)
return isBetween(position.x, rect.position.x, rightBottom.x) &&
isBetween(position.y, rect.position.y, rightBottom.y)
}
// Canvasは副作用の窓口。OOPの Canvas クラスを「関数の集合」に置き換え
const createCanvas = (canvasId) => {
const canvas = document.getElementById(canvasId)
const context = canvas.getContext('2d')
// size/offset も「関数」で提供(状態を直接触らせない)
const size = () => vec2(canvas.width, canvas.height)
const offset = () => vec2(canvas.offsetLeft, canvas.offsetTop)
const fillCircle = (c, color) => {
context.beginPath()
context.arc(c.position.x, c.position.y, c.radius, 0, Math.PI * 2, false)
context.fillStyle = color
context.fill()
context.closePath()
}
const fillRectangle = (r, color) => {
context.beginPath()
context.rect(r.position.x, r.position.y, r.size.x, r.size.y)
context.fillStyle = color
context.fill()
context.closePath()
}
const fillText = (position, text, color, font) => {
context.font = font
context.fillStyle = color
context.fillText(text, position.x, position.y)
}
const clear = () => context.clearRect(0, 0, canvas.width, canvas.height)
return { canvas, size, offset, fillCircle, fillRectangle, fillText, clear }
}
// 状態の列挙(クラスの継承ではなく、値で分岐する)
const gameStatus = Object.freeze({
normal: { value: 0 },
scored: { value: 1 },
over : { value: -1 }
})
// 「生成関数」: クラスのコンストラクタの代わり
const createPaddle = (position, size, color) => ({
originalPosition: position,
position,
size,
color,
rightPressed: false,
leftPressed: false
})
const createBall = (position, velocity, color) => ({
originalPosition: cloneVec2(position),
position,
radius: 10,
originalVelocity: cloneVec2(velocity),
velocity,
color
})
const createBrick = (position, status) => ({ position, status })
// ブロック集合も「データ」+「作る関数」
const createBrickSet = (canvas) => {
const rowCount = 3
const columnCount = 8
const padding = 10
const margin = 30
const brickOffset = vec2(margin, margin)
const brickHeight = 20
const brickWidth = (canvas.size().x - margin * 2 - padding * (columnCount - 1)) / columnCount
const brickSize = vec2(brickWidth, brickHeight)
const getBrickPosition = (position) =>
plus(brickOffset, vec2((brickSize.x + padding) * position.x, (brickSize.y + padding) * position.y))
const bricks = []
for (let x = 0; x < columnCount; x++) {
bricks[x] = []
for (let y = 0; y < rowCount; y++)
bricks[x][y] = createBrick(getBrickPosition(vec2(x, y)), true)
}
return { rowCount, columnCount, padding, brickOffset, brickSize, bricks }
}
// 衝突判定は「入力→出力」の純粋関数にする
// 破壊されたブロックは「新しい brickSet」を返す(元は書き換えない)
const detectBrickCollision = (brickSet, ballPosition) => {
let hit = false
const newBricks = brickSet.bricks.map((column, x) =>
column.map((brick, y) => {
if (hit || !brick.status) return brick
const isHit = rectIsOn(rectangle(brick.position, brickSet.brickSize), ballPosition)
if (isHit) {
hit = true
return { ...brick, status: false }
}
return brick
})
)
return { hit, brickSet: { ...brickSet, bricks: newBricks } }
}
// ボールの衝突判定も「状態を受けて状態を返す」だけ
const detectBallCollision = (state, canvas) => {
const { ball, paddle, brickSet } = state
let velocity = ball.velocity
let status = gameStatus.normal
let nextBrickSet = brickSet
if (!isBetween(ball.position.x + velocity.x, ball.radius, canvas.size().x - ball.radius))
velocity = vec2(-velocity.x, velocity.y)
if (ball.position.y + velocity.y < ball.radius) {
velocity = vec2(velocity.x, -velocity.y)
} else {
const brickResult = detectBrickCollision(brickSet, ball.position)
if (brickResult.hit) {
velocity = vec2(velocity.x, -velocity.y)
status = gameStatus.scored
nextBrickSet = brickResult.brickSet
} else if (ball.position.y + velocity.y > canvas.size().y - ball.radius) {
if (isBetween(ball.position.x, paddle.position, paddle.position + paddle.size.x))
velocity = vec2(velocity.x, -velocity.y)
else
status = gameStatus.over
}
}
return {
status,
state: {
...state,
ball: { ...ball, velocity },
brickSet: nextBrickSet
}
}
}
// 入力状態(right/left)に基づき、次のパドル位置を計算する純粋関数
const movePaddle = (paddle, canvas) => {
const stepSize = 7
let position = paddle.position
if (paddle.rightPressed) {
position += stepSize
if (position + paddle.size.x > canvas.size().x)
position = canvas.size().x - paddle.size.x
} else if (paddle.leftPressed) {
position -= stepSize
if (position < 0)
position = 0
}
return { ...paddle, position }
}
// ボールの移動(現在位置と速度だけで計算)
const moveBall = (ball) => ({
...ball,
position: plus(ball.position, ball.velocity)
})
// リセットも「新しい状態」を返すだけ
const resetPaddle = (paddle) => ({ ...paddle, position: paddle.originalPosition })
const resetBall = (ball) => ({
...ball,
position: ball.originalPosition,
velocity: ball.originalVelocity
})
// 描画は副作用。描画関数に状態を渡す
const drawPaddle = (canvas, paddle) =>
canvas.fillRectangle(rectangle(vec2(paddle.position, canvas.size().y - paddle.size.y), paddle.size), paddle.color)
const drawBall = (canvas, ball) =>
canvas.fillCircle(circle(ball.position, ball.radius), ball.color)
const drawBricks = (canvas, brickSet) => {
const color = "#0095DD"
const bricks = filter2D(brickSet.bricks, brick => brick.status)
bricks.forEach(brick => canvas.fillRectangle(rectangle(brick.position, brickSet.brickSize), color))
}
const drawScore = (canvas, score) => {
const position = vec2(8, 20)
canvas.fillText(position, `Score: ${score}`, '#0095DD', '16px Arial')
}
const drawLives = (canvas, lives) => {
const position = vec2(canvas.size().x - 65, 20)
canvas.fillText(position, `Lives: ${lives}`, '#0095DD', '16px Arial')
}
// 画面メッセージ表示は副作用(純粋関数から切り離す)
const showMessage = (message, scoreText, stop) => {
alert(`${message} - ${scoreText}`)
document.location.reload()
stop()
}
// メイン更新: 1フレーム分の「状態遷移」を返す
// ここがOOP版の Game.draw() 相当だが、thisは使わない
const updateGame = (state, canvas, stop) => {
const collision = detectBallCollision(state, canvas)
let nextState = collision.state
if (collision.status.value === gameStatus.scored.value) {
const score = nextState.score + 1
nextState = { ...nextState, score }
if (score === nextState.brickSet.rowCount * nextState.brickSet.columnCount)
showMessage('YOU WIN, CONGRATS!', `Score: ${score}`, stop)
} else if (collision.status.value === gameStatus.over.value) {
const lives = nextState.lives - 1
if (!lives)
showMessage('GAME OVER', `Score: ${nextState.score}`, stop)
else
nextState = {
...nextState,
lives,
paddle: resetPaddle(nextState.paddle),
ball: resetBall(nextState.ball)
}
}
// 描画は最後にまとめて実行(状態は既に確定している)
canvas.clear()
drawBall(canvas, nextState.ball)
drawPaddle(canvas, nextState.paddle)
drawBricks(canvas, nextState.brickSet)
drawScore(canvas, nextState.score)
drawLives(canvas, nextState.lives)
return {
...nextState,
ball: moveBall(nextState.ball),
paddle: movePaddle(nextState.paddle, canvas)
}
}
// 初期状態生成(コンストラクタの代わりに1回だけ使う関数)
const initializeGameState = (canvas) => {
const color = "#0095DD"
const velocitySize = 5
const velocity = vec2(velocitySize, -velocitySize)
const ball = createBall(vec2(canvas.canvas.width / 2, canvas.canvas.height - 30), velocity, color)
const paddleHeight = 10
const paddleWidth = 75
const paddle = createPaddle((canvas.canvas.width - paddleWidth) / 2, vec2(paddleWidth, paddleHeight), color)
return {
lives: 3,
score: 0,
ball,
paddle,
brickSet: createBrickSet(canvas)
}
}
// 入力イベントは「状態を書き換えずに、差分を作って差し替える」
// stateRef は最小限の可変箱(描画ループとイベントの橋渡し)
const bindHandlers = (stateRef, canvas) => {
document.addEventListener('keydown', e => {
if (e.key === "Right" || e.key === "ArrowRight")
stateRef.state = { ...stateRef.state, paddle: { ...stateRef.state.paddle, rightPressed: true } }
else if (e.key === "Left" || e.key === "ArrowLeft")
stateRef.state = { ...stateRef.state, paddle: { ...stateRef.state.paddle, leftPressed: true } }
}, false)
document.addEventListener('keyup', e => {
if (e.key === "Right" || e.key === "ArrowRight")
stateRef.state = { ...stateRef.state, paddle: { ...stateRef.state.paddle, rightPressed: false } }
else if (e.key === "Left" || e.key === "ArrowLeft")
stateRef.state = { ...stateRef.state, paddle: { ...stateRef.state.paddle, leftPressed: false } }
}, false)
document.addEventListener("mousemove", e => {
const relativeX = e.clientX - canvas.offset().x
if (isBetween(relativeX, 0, canvas.size().x))
stateRef.state = {
...stateRef.state,
paddle: { ...stateRef.state.paddle, position: relativeX - stateRef.state.paddle.size.x / 2 }
}
}, false)
}
// 起動処理(依存関係を「組み立てる」だけ)
const run = () => {
const canvas = createCanvas('canvas')
const stateRef = { state: initializeGameState(canvas) }
let animationId = null
const stop = () => {
if (animationId !== null)
cancelAnimationFrame(animationId)
}
bindHandlers(stateRef, canvas)
// 1フレームごとの更新: 「状態を計算→次の状態へ」
const loop = () => {
stateRef.state = updateGame(stateRef.state, canvas, stop)
animationId = requestAnimationFrame(loop)
}
loop()
}
window.onload = () => run()
</script>
</body>
</html>

