🍄

マリオで学ぶSOLID原則

2023/10/27に公開
7

はじめに

最近オブジェクト指向とデザインパターンについて学び始めたので、勉強しつつ記事にまとめていきたいと思います。

初回はSOLID原則についてです。SOLID原則はオブジェクト指向プログラミングにおいて、開発者にとって読みやすく、メンテナンスが可能なプログラムを作成しやすくするために考えられたルールです。

この記事では、オブジェクト指向プログラミングの重要な開発原則であるSOLID原則について皆さんが想像しやすいマリオのクラス実装を例に解説していきます。

1. S (Single Responsibility):単一責任の原則

クラスは単一の責任を持つべきと言う原則です。
ここでの責任というのは、オブジェクトが持っている機能のことです。
一つのクラスができる機能(責任)が複数あると、クラス内部の関数が強い結合を起こす可能性が高ま理望ましくありません。

次のマリオクラスを見てみましょう。

class Mario {
  jump() {}
  private coin = 0;
  getCoin() {
    this.coin += 1;
  }
}

このMarioクラスには、マリオがジャンプする機能と、獲得コインを集計する機能の2つが存在します。しかし、獲得コインを集計する機能をMarioクラスが持つのは適切でしょうか?

例えば、ルイージというキャラクターもコインを集める場合、そのコインの獲得枚数はマリオクラスが管理すべきでしょうか?

このような問題は、Marioクラスが多すぎる責任を持っている(単一責任の原則に反している)ことが原因です。そこで、マリオの行動を表すクラスとコインの管理を担当するクラスとして分けてみましょう。

class Mario {
  jump() {}
}

class CoinManager {
  private coin = 0;
  getCoin() {
    this.coin += 1;
  }
}

このように単一責任の原則に従ってクラスを分割すると、それぞれのクラスの役割が明確になり、機能の拡張もしやすくなります。

2. O (Open-Closed):オープン・クローズドの原則

クラスは、拡張にはオープンで、変更にはクローズドであるべきという原則です。
つまり、クラスは既存のコードを変更せずに修正または追加できるように設計しなければならないということです。Characterクラスからマリオとルイージを実装していきます。

class Character {
  private name: string;
  constructor(name) {
    this.name = name;
  }
}

class CharacterAction {
  jump(character) {
    if (character.name === "Mario") {
      return "Mario jumps 10 units!";
    }
    if (character.name === "Luigi") {
      return "Luigi jumps 12 units!";
    }
    // 新しいキャラクターが追加されるたびに、このメソッドを修正しなければならない
  }
}

const mario = new Character('Mario');
const luigi = new Character('Luigi');
const actionPerformer = new CharacterAction();

console.log(actionPerformer.jump(mario));  // Mario jumps 10 units!
console.log(actionPerformer.jump(luigi));  // Luigi jumps 12 units!

このコードでは、新しいキャラクターが追加されるたびにCharacterActionのjumpメソッドを修正しなければならないため、オープン・クローズドの原則に反しています。

オープン・クローズドの原則に従うためには、既存のコードを修正することなく新しいキャラクターを追加できるように設計する必要があります。

class Character {
  protected name: string;
  constructor(name) {
    this.name = name;
  }

  jump() {
    return `${this.name} jumps!`;
  }
}

// Marioクラス
class Mario extends Character {
  constructor() {
    super("Mario");
  }

  jump() {
    return `${this.name} jumps 10 units!`;
  }
}

// Luigiクラス
class Luigi extends Character {
  constructor() {
    super("Luigi");
  }

  jump() {
    return `${this.name} jumps 12 units!`;
  }
}

const mario = new Mario();
const luigi = new Luigi();

console.log(mario.jump()); // Mario jumps 10 units!
console.log(luigi.jump()); // Luigi jumps 12 units!

この設計において、新しいキャラクターを追加したい場合(例えば、ピーチ姫)は、新たにPeachクラスをCharacterクラスを継承して作成すればよく、既存のコード(MarioクラスやLuigiクラス)の修正は不要になります。
これにより、オープン・クローズドの原則に従った設計となります。

3. L (Liskov Substitution): リスコフの置換原則

「親クラスのインスタンスが適用されるコードに対して、子クラスのインスタンスで置き換えても、問題なく動くべき」という原則です。
例としてマリオクラス(親)と、それを継承したヨッシークラス(子)を実装します。

class Mario {
  protected jumpHeight = 10;
  protected positionY = 0;
  protected isOnTheGround = true;
  jump() {
    if (this.isOnTheGround) {
      this.positionY += this.jumpHeight;
      this.isOnTheGround = false;
    }
  }
}

class Yoshi extends Mario {
  jump() {
    this.positionY += this.jumpHeight;
    this.isOnTheGround = false;
  }
}

上のコードを見ると、Marioクラス(親クラス)のjumpメソッドは、キャラクターが地上にいる場合のみジャンプを実行します。しかし、Yoshiクラス(子クラス)のjumpメソッドはこの制約を持っていません。つまり、Yoshiは地上にいなくてもジャンプができます。

この違いにより、MarioのインスタンスをYoshiのインスタンスで置き換えると、プログラムの振る舞いが変わる可能性があります。したがって、この設計はリスコフの置換原則に違反していると言えます。

interface Character {
  jump: () => void;
}

class Mario implements Character {
  private jumpHeight = 10;
  private positionY = 0;
  private isOnTheGround = true;
  jump() {
    if (this.isOnTheGround) {
      this.positionY += this.jumpHeight;
      this.isOnTheGround = false;
    }
  }
}
class Yoshi implements Character {
  private jumpHeight = 10;
  private positionY = 0;
  jump() {
    this.positionY += this.jumpHeight;
  }
}

ここでCharacterは単にジャンプの機能を提供することだけを要求する抽象的なインターフェイスです。
MarioとYoshiは共にCharacterインターフェースを実装しており、それぞれのjumpメソッドは正しく機能しています。この実装はリスコフの置換原則を満たしています。

4. I (Interface Segregation):インターフェイス分離の原則

不要なインターフェースに依存することを避けるべきという原則です。
以下ではActionインターフェイスを実装したマリオクラスとファイアマリオクラスです。

interface Action {
  move: () => void;
  jump: () => void;
  fire: () => void;
}

class Mario implements Action {
  move() {
    console.log("走る");
  }
  jump() {
    console.log("ジャンプ");
  }
  fire() {
    console.log("ファイアは出せません");
  }
}

class FireMario implements Action {
  move() {
    console.log("走る");
  }
  jump() {
    console.log("ジャンプ");
  }
  fire() {
    console.log("ファイア!");
  }
}

Actionインターフェイスにfireメソッドが定義されています。しかしMarioはファイアを出せないので不要なfireメソッドを実装する必要があります。
もし、Marioクラスがfireという不要なメソッドを実装していることを知らない人がMarioクラスのfireメソッドを使ってしまうと問題が起きる可能性があります。

そこでMarioクラスが不要なfireメソッドを持たないようにインターフェイスを分離してみます。

interface Action {
  move: () => void;
  jump: () => void;
}
interface FireAction {
  fire: () => void;
}

class Mario implements Action {
  move() {
    console.log("走る");
  }
  jump() {
    console.log("ジャンプ");
  }
}

class FireMario implements Action, FireAction {
  move() {
    console.log("走る");
  }
  jump() {
    console.log("ジャンプ");
  }
  fire() {
    console.log("ファイア!");
  }
}

上記の実装でMarioクラスが不要なfireメソッドを持つ必要がなくなり、インターフェイス分離の原則を満たすようになりました。

5. D (Dependency Inversion) 依存性逆転の原則

抽象(ビジネスロジック)が詳細(具体的な実装)に依存しないようにしようという原則です。
Characterクラスを継承したMarioクラスを実装するとします。

class Character {
  protected name;
  protected positionY = 0;
  constructor(name: string) {
    this.name = name;
  }
  jump() {
    if (this.name == "mario") {
      this.positionY += 10;
    } else if (this.name == "luigi") {
      this.positionY += 15;
    }
  }
}

class Mario extends Character {
  constructor() {
    super("mario");
  }
}

上記のコードを見ると、Characterクラスは具体的なキャラクターの名前("mario"や"luigi")に依存していることが分かります。

Characterクラスのjumpメソッド内でキャラクターの名前に基づいて振る舞いを変更しています。

この設計は、高レベルのCharacterクラスが低レベルの詳細(具体的なキャラクター名)に依存しているため、依存性逆転の法則に違反しています。

abstract class Character {
  protected positionY = 0;
  abstract jump(): void;
}

class Mario extends Character {
  jump() {
    this.positionY += 10;
  }
}

class Luigi extends Character {
  jump() {
    this.positionY += 15;
  }
}

こちらのコードでは、Characterクラスはjumpメソッドを抽象メソッドとして定義し、具体的なキャラクターのクラス(MarioクラスやLuigiクラス)はその抽象メソッドをオーバーライドして独自の振る舞いを実装しています。これにより、基底クラスと派生クラスが互いに独立しており、依存性逆転の法則に従うようになりました。

最後に

この記事ではマリオクラスの実装を例にSOLID原則を解説しました!
完全に理解した気になっていても、アウトプットするとなると自分の理解が足りていないことに気付かされますね...
これからもオブジェクト指向とデザインバターンについてまとめていきますので、よかったら読んでみてください。

宣伝

自分の運営しているサイトでアイデアコンテストを開催しています。ご興味あればぜひ覗いてみてください。
詳細はこちらの記事をご覧ください
https://devhaunt.com

Discussion

zasciizascii

解説ありがとうございます。
interface FireActionの定義にfire()がないのはコピペミスでしょうか?
私の勘違いでしたらゴメンナサイ。

devdevdevdev

コメントありがとうございます!ご指摘の通りコピペミスだったので、修正しました🙇‍♂️

standard softwarestandard software

1,2に対応する例はこんな感じ

const createCharactor = (name, units) => ({ name, units });
const jump = (charactor) => {
  return `${charactor.name} jumps ${charactor.units} units!`;
}

const mario = createCharactor('Mario', 10);
const luigi = createCharactor('Luigi', 12);

console.log(jump(mario)); // Mario jumps 10 units!
console.log(jump(luigi)); // Luigi jumps 12 units!

3に対応する例はこんな感じ

const createCharactor =
  (name, type, jumpHeight, positionY) => ({ name, type, jumpHeight, positionY });

const isOnTheGround = (charactor) => {
  return charactor.positionY === 0;
}

const jump = (charactor) => {
  if (charactor.type === 'Mario') {
    if (isOnTheGround(charactor)) {
      charactor.positionY += charactor.jumpHeight;
    }
  } else if (charactor.type === 'Yoshi') {
    charactor.positionY += charactor.jumpHeight;
  } else {
    throw new Error('jump charactor');
  }
  // return `${charactor.name} jumps ${charactor.jumpHeight} units!`;
}

const mario = createCharactor('MARIO', 'Mario', 10, 0);
const luigi = createCharactor('YOSHI', 'Yoshi', 12, 0);

4に対応する例はこんな感じ

const mario = {
  move: () => { console.log("走る"); },
  jump: () => { console.log("ジャンプ"); },
}

const fireMario = {
  move: () => { console.log("走る"); },
  jump: () => { console.log("ジャンプ"); },
  fire: () => { console.log("ファイア!"); },
}

const hasFireball = (charactor) => {
  return typeof charactor.fire === 'function'
}

JS/TSは、パワーのある言語なのでオブジェクト指向に変に頼らずにシンプルに同じことが実現できます。

classやthisを混ぜ込むとコードの可読性が落ちるので使うべきじゃなく、

オブジェクト指向の継承は、どんな場面でもバッドパターンで暗黙的に分岐を隠蔽してしまうので、
例えば配列に全キャラクターのインスタンスが入っていて、jumpを呼び出している場合、全キャラクターのjumpを読まないと、不具合がないかどうか確認できないんだけど

jump関数の中に分岐を明確に書いていれば、読むコードはjump関数の中だけで済むので便利。
最近の関数型的なプログラミングの手法で、不具合を出しにくいコードの書き方です。

オブジェクト指向を超えたところまでいけば、シンプルさと高い可読性と不具合が減少した高い品質のコードを書ける、ということが、多くの人に知れ渡ったらいいのになー。と思い書いてみました。

超えるところまでいくには、全てを理解しないといけないのかもしれないけれども。

devdevdevdev

ただいま勉強中なので、コードへの指摘は大変助かります!
オブジェクト指向を完全に自分のものにした上で、ossなど実際の開発で用いられている質の高い設計を勉強していく必要がありますね...

阿部耕二阿部耕二

わかりやすい記事をありがとうございます。
来月、個人的にリスコフの置換原則の勉強会を開催予定でヒントをいただきました。
細かいですが1個、タイプミスと思われる箇所を見つけたので報告です。

  1. L (Liskov Substitution): リスコフの置換原則

現状>
ここでCarecterは単にジャンプの機能を提供することだけ

修正>
ここでCharacterは単にジャンプの機能を提供することだけ

devdevdevdev

コメントありがとうございます!ご指摘の箇所を修正しました🙇‍♂️