SOLIDの原則:それらは良い理由でまさに堅固です!

オブジェクト指向プログラミングに始めたばかりで、SOLIDについてちょっと迷った感じがしますか?心配しないで、この記事ではそれを説明して、コード開発でどうやって使うかの例を示します。

SOLIDって何?


オブジェクト指向プログラミングでは、SOLIDとは、ソフトウェアの理解、開発、保守を向上させることを目指した5つの設計原則を表す略語です。

この原則群を適用することで、バグが減り、コードの品質が向上し、より整理されたコードの生産、カップリングの減少、リファクタリングの向上、コードの再利用の励行が期待できます。さあ、見てみましょう。

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


SRP - 単一責任の原則

これは本当にシンプルなんだけど、すごく大切:1つのクラスは1つ、そして1つだけ、変更されるべき理由を持つべきです。

もう、複数の機能や責任を持つクラスを作るのはやめましょう。多分あなたも経験したことがあるでしょう、全部をこなすいわゆる「神クラス」を。今はうまくいってるように見えるかもしれないけど、そのクラスのロジックを変更する必要が出てきたら、問題が起こるのは確実です。

class ProfileManager {
  authenticateUser(username: string, password: string): boolean {
    // 認証のロジック
  }

  showUserProfile(username: string): UserProfile {
    // ユーザープロファイルの表示ロジック
  }

  updateUserProfile(username: string): UserProfile {
    // ユーザープロファイルの更新ロジック
  }

  setUserPermissions(username: string): void {
    // 権限設定のロジック
  }
}

この ProfileManager クラスは4つの異なるタスクを同時に実行してSRP原則に違反します。データの検証と更新、プレゼンテーション、そしてさらに、権限を設定しています。

問題が起こる可能性があること

  • 凝集度の欠如 - クラスは自分自身にない責任を持つべきではありません。
  • 一箇所に情報が多すぎる - 依存関係が多くて変更が難しくなります。
  • 自動テストを実装するのが難しい - そのようなクラスをモックするのは大変です。

SRPProfileManagerクラスに適用すると、この原則がどんな改善をもたらすかを見てみましょう:

class AuthenticationManager {
  authenticateUser(username: string, password: string): boolean {
    // 認証のロジック
  }
}

class UserProfileManager {
  showUserProfile(username: string): UserProfile {
    // ユーザープロファイルの表示ロジック
  }

  updateUserProfile(username: string): UserProfile {
    // ユーザープロファイルの更新ロジック
  }
}

class PermissionManager {
  setUserPermissions(username: string): void {
    // 権限設定のロジック
  }
}

あなたは疑問に思うかもしれません、「これはクラスにだけ適用するの?」答えは:全くそうではありません。メソッドや関数にも(そしてそこにも)適用すべきです。

// ❌
function processTasks(taskList: Task[]): void {
  taskList.forEach((task) => {
    // 複数の責任が絡む処理のロジック
    updateTaskStatus(task);
    displayTaskDetails(task);
    validateTaskCompletion(task);
    verifyTaskExistence(task);
  });
}

// ✅
function updateTaskStatus(task: Task): Task {
  // タスクステータスの更新ロジック
  return { ...task, completed: true };
}

function displayTaskDetails(task: Task): void {
  // タスクの詳細表示のロジック
  console.log(`Task ID: ${task.id}, Description: ${task.description}`);
}

function validateTaskCompletion(task: Task): boolean {
  // タスク完了の検証ロジック
  return task.completed;
}

function verifyTaskExistence(task: Task): boolean {
  // タスク存在の確認ロジック
  return tasks.some((t) => t.id === task.id);
}

美しく、エレガントで、整理されたコードです。この原則は他の原則の土台であり、それを適用することで高品質で読みやすく、保守可能なコードが作れるはずです。

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


OCP - オープン/クローズドの原則

オブジェクトまたはエンティティは拡張のために開かれているべきですが、変更のためには閉じられているべきです。 機能を追加する必要がある場合は、ソースコードを変更するよりも拡張する方が良いです。

いくつかの多角形のエリアを計算するクラスを必要とすると想像してみてください。

class Circle {
  radius: number;

  constructor(radius: number) {
    this.radius = radius;
  }

  area(): number {
    return Math.PI * this.radius ** 2;
  }
}

class Square {
  sideLength: number;

  constructor(sideLength: number) {
    this.sideLength = sideLength;
  }

  calculateArea(): number {
    return this.sideLength ** 2;
  }
}

class AreaCalculator {
  totalArea(shapes: Shape[]): number {
    let total = 0;

    shapes.forEach((shape) => {
      if (shape instanceof Square) {
        total += (shape as any).calculateArea();
      } else {
        total += shape.area();
      }
    });

    return total;
  }
}

AreaCalculatorクラスは、異なるポリゴンのエリアを計算することを任されており、それぞれが独自のエリアのロジックを持っています。新しい形、三角形や長方形などを追加したいときには、このクラスを変更して変更を加える必要があるでしょう。それが問題であり、「オープン/クローズドの原則」に違反してしまいます。

どんな解決策を思い浮かべますか?多分、クラスにもう一つメソッドを追加して、問題解決です🤩。えっと、残念ながら若いパダワン😓、それが問題なんです!

既存のクラスを変更して新しい機能を追加することは、既にうまく機能しているものにバグを導入する深刻なリスクを伴います。

覚えておいて:OCPは、クラスは変更のためには閉じ、拡張のためには開かれているべきだと言っています。

コードをリファクタリングすることで得られる美しさを見てください:

interface Shape {
  area(): number;
}

class Circle implements Shape {
  radius: number;

  constructor(radius: number) {
    this.radius = radius;
  }

  area(): number {
    return Math.PI * this.radius ** 2;
  }
}

class Square implements Shape {
  sideLength: number;

  constructor(sideLength: number) {
    this.sideLength = sideLength;
  }

  area(): number {
    return this.sideLength ** 2;
  }
}

class AreaCalculator {
  totalArea(shapes: Shape[]): number {
    let total = 0;

    shapes.forEach((shape) => {
      total += shape.area();
    });

    return total;
  }
}

AreaCalculatorクラスを見てみると、もうどのメソッドを呼び出してクラスを登録する必要はありません。インターフェースに課された契約を呼び出すことで正しくエリアメソッドをコールでき、それだけで十分です。

インターフェース Shape を実装していれば、問題なく動きます。

拡張可能な振る舞いをインターフェースの後ろに分け、依存関係を反転させてください。

アンクル・ボブ

  • 拡張のために開かれている: クラスのソースコードを変更することなく新しい機能や振る舞いを追加できます。
  • 変更のために閉じられている: クラスが既に上手く動いている機能や振る舞いを持っていれば、新しいものを追加するためにソースコードを変更しないでください。

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


LSP - リスコフの置換原則

リスコフの置換原則によれば、派生クラスはその基底クラスに置き換え可能であるべきです。

この原則はバーバラ・リスコフが1987年に紹介したもので、彼女の説明を読んで理解するのは少し複雑かもしれません。でも心配しないで、もう一つの説明と例を出してみますから。

もし型Sの各オブジェクトo1に対して、型Tのオブジェクトo2があり、Tを用いて定義されたすべてのプログラムPにとって、o1をo2で置き換えてもPの振る舞いが変わらないならば、SはTのサブタイプである。

バーバラ・リスコフ, 1987

わかりましたか?うーん、多分わからないですよね。ええと、私も最初に読んだとき(そして、それに続く何百回も)は理解しませんでしたが、もう一つの説明があります:

もしSがTのサブタイプであれば、プログラム内の型Tのオブジェクトは、このプログラムのプロパティを変更することなく、型Sのオブジェクトによって置き換えることができる。

ウィキペディア

もし視覚的な学習者であれば心配ないです、こちらが例です:

class Person {
  speakName() {
    return "I am a person!";
  }
}

class Child extends Person {
  speakName() {
    return "I am a child!";
  }
}

const person = new Person();
const child = new Child();

function printName(message: string) {
  console.log(message);
}

printName(person.speakName()); // I am a person!
printName(child.speakName()); // I am a child!

親クラスと派生クラスがパラメータとして渡されていて、コードは期待通りに動作を続けます。魔法ですか?ええ、それは友達であるバーブの魔法です。

違反の例:

  • 何もしないメソッドをオーバーライド/実装すること;
  • 基底クラスと異なるタイプの値を返すこと;
  • 予期せぬ例外を投げること;

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


ISP - インターフェース分離の原則

これは、クラスは使用しないインターフェースやメソッドの実装を強いられるべきではないと言っています。大きくて汎用的なインターフェースを作るよりも、より具体的なインターフェースを作る方が良いです。

以下の例では、Book インターフェースが作られます。そしてクラスがこのインターフェースを実装します:

interface Book {
  read(): void;
  download(): void;
}

class OnlineBook implements Book {
  read(): void {
    // 何かする
  }

  download(): void {
    // 何かする
  }
}

class PhysicalBook implements Book {
  read(): void {
    // 何かする
  }

  download(): void {
    // 本に対するこの実装は意味をなさない
    // インターフェース分離の原則に違反する
  }
}

一般的な Book インターフェースは PhysicalBook クラスに意味のない振る舞いを強制しており、ISPLSPの原則に違反しています。

ISP を使ったこの問題の解決策は以下のようになります:

interface Readable {
  read(): void;
}

interface Downloadable {
  download(): void;
}

class OnlineBook implements Readable, Downloadable {
  read(): void {
    // 何かする
  }

  download(): void {
    // 何かする
  }
}

class PhysicalBook implements Readable {
  read(): void {
    // 何かする
  }
}

今、ずっと良い感じです。 download() メソッドを Book インターフェースから取り除き、 Downloadable という派生インターフェースを追加しました。こうすることで、振る舞いを適切に分離して私たちのコンテキスト内で正しく配置し、インターフェース分離の原則をまだ尊重しています。

5. D - 依存関係逆転の原則 (Dependency Inversion Principle)


DIP - 依存関係逆転の原則

これはこうです:抽象に依存し、実装に依存してはいけません。

高水準のモジュールは低水準のモ

こちらの記事はdev.toの良い記事を日本人向けに翻訳しています。
https://dev.to/lukeskw/solid-principles-theyre-rock-solid-for-good-reason-31hn