はじめに
条件分岐を実装する際に、インターフェースを用いて保守性を高くできるようになればレベルアップができると聞きました。ということでインターフェースのユースケースをアウトプットします。
悪い実装
僕はバロラント(銃で人を撃つゲーム)が大好きなので、それに出てくる武器を登場させて、ifで分岐させる例を紹介します。fireWeapon関数は武器の種類と距離を引数にとり、与えるダメージと発射レートを返す関数です。アサルトライフル、スナイパーライフル、サブマシンガンがそれぞれVandal、Operator、Spectreに該当します。
type WeaponName = 'Vandal' | 'Spectre' | 'Operator';
function fireWeapon(name: WeaponName, distance: number) {
let damage = 0;
let fireRate = 0;
if (name === 'Vandal') {
damage = 40;
fireRate = 9.75;
} else if (name === 'Operator') {
damage = 150;
fireRate = 0.75;
} else if (name === 'Spectre') {
// スペクターは距離によってダメージが激減する(距離減衰)
damage = distance > 20 ? 18 : 26;
fireRate = 13.33;
}
return {
damage: damage,
fireRate: fireRate
};
}
もし武器が増えるとなると、fireWeapon関数を触る必要があります。しかし、既に動いている関数内部を触らなくてはいけないことや、else if文が大量になる未来が想像できますね。これは良くありません。
良い実装
武器によりif文を書くのではなく、武器ごとにクラスを作成しましょう。
class Vandal {
readonly name = "Vandal";
readonly fireRate = 9.75;
calculateDamage(distance: number): number {
return 40; // 距離に関わらず一定
}
}
class Operator {
readonly name = "Operator";
readonly fireRate = 0.75;
calculateDamage(distance: number): number {
return 150; // 距離に関わらず一定
}
}
class Spectre {
readonly name = "Spectre";
readonly fireRate = 13.33;
calculateDamage(distance: number): number {
// スペクター特有の複雑な減衰ロジックをここに隔離できる!
return distance > 20 ? 18 : 26;
}
}
こうすることで、各武器のロジックを分離することができました。武器を追加するときに、既存の武器の実装に手を加える必要がなくなったということです。
作った各クラスのそれぞれの武器には、名前と与えるダメージと発射レートがあります。この事実を用いて、インターフェースなるものを記述しましょう。
interface Weapon {
readonly name: WeaponName;
readonly fireRate: number; // 発射レート(秒間何発か)
calculateDamage(distance: number): number; // 距離に応じたダメージ計算
}
そして、これを用いてクラスに implements Weapon という宣言を追加します。
class Vandal implements Weapon {
readonly name = "Vandal";
readonly fireRate = 9.75;
calculateDamage(distance: number): number {
return 40; // 距離に関わらず一定
}
}
//他の武器も同様に...
こうすることで、呼び出す側はこう思います。「武器の種類によらず、Weaponを実装(implement)しているからnameとfireRateとcalculateDamageメソッドを安全に使用できる」と。
しかし、このまま使おうとすると、呼び出し側で結局こう書くことになります。
// クラスは分けたが、選ぶ場所で結局ifで分岐させている
function processFire(name: WeaponName, distance: number) {
let weapon: Weapon;
if (name === 'Vandal') {
weapon = new Vandal();
} else if (name === 'Spectre') {
weapon = new Spectre();
} // ... 結局ここが伸びていく
const damage = weapon.calculateDamage(distance);
// 各weaponはcalculateDamage()を持っているので、このweaponが何かを意識しなくて良くなった
}
解決策の一つとして、if文で「もし〜ならこのクラス」と判定するのではなく、「この名前ならこの武器をインスタンス化」という対応表を、Recordを用いて作りましょう。
const weaponRegistry: Record<WeaponName, Weapon> = {
'Vandal': new Vandal(),
'Spectre': new Spectre(),
'Operator': new Operator(),
};
このようにRecordを作成することで、
const weapon = weaponRegistry['Vandal'];
とすると、Vandalインスタンスがweapon変数に格納されます!
それでは、このweaponRegistryを用いて先ほどのif文を使用していたprocessFireを書き換えます
function processFire(name: WeaponName, distance: number) {
const weapon = weaponRegistry[name];
// ここで武器に対応するインスタンスを作成
if (!weapon) {
return;
}
const damage = weapon.calculateDamage(distance);
const fireRate = weapon.fireRate;
return {
damage,
fireRate
};
}
利点
この実装の利点を3ポイント解説します。
責務の分離
「何をするか」というロジックを、呼び出し側から切り離して独立させるという思想です。
該当箇所: class Vandal, class Operator, class Spectreなどの各武器クラス
解説: 武器ごとのダメージ計算や減衰ロジックを processFire 関数の中にベタ書きするのではなく、それぞれ独立したクラスに閉じ込めています。これにより、Vandalの数値を調整しても、Operatorにバグが混入するリスクを物理的にゼロにできます。
抽象化
「具体的な中身は知らないが、使い方は知っている」状態を作る思想です。
該当箇所: interface Weapon / weapon.calculateDamage(distance)
解説: 呼び出し側(processFire)は、取り出した武器がVandalなのかOperatorなのかを気にしません。「Weaponインターフェースを実装しているなら、calculateDamage()を呼べばダメージが返ってくるはずだ」という共通の契約だけを信じて動くことができます。
開放閉鎖の原則
「機能追加にはオープンで、既存コードの修正にはクローズであるべき」という思想です。
該当箇所: const weaponRegistry: Record = { ... }
解説: 拡張を容易(オープン)に、また、既に動いているコードに手をつける必要をなくす(クローズ)という原則です。新しい武器「Odin」を追加したいとき、メインの実行ロジック(processFire)を書き換える必要はありません。新しいクラスを作り、weaponRegistryに1行追加するだけで済みます。「既に動いているコードを触らない」ことで、予期せぬバグを防げます。
おわりに
条件分岐の実装を考えなくてはならなくなった時、今回学んだことを思い出し、インターフェースを上手に使えるようになりたいと思いました。
参考文献
- 仙塲 大也,『改訂新版 良いコード/悪いコードで学ぶ設計入門 ―保守しやすい 成長し続けるコードの書き方』,第2版,技術評論者,2024,150p,