iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

「なぜダメか」で学ぶクラス設計

導入

こんにちは!iimonで「入力速いもん」の開発を担当しているなかむ〜です!

弊社のメイン製品である「入力速いもん」は、リリースから時間が経っていることもあり、現在はレガシーな設計から「新しく保守しやすい設計」への移行を絶賛進めている最中です。

移行後の新しい設計を見て「すごくいいな!」と直感的に感動したのですが、一方で「自分は何をもって『良い設計』だと判断しているのか?」がいまいち言語化できていないことにも気づきました。

そこで今回は、良い設計・悪い設計を自分なりに整理し、言語化する良い機会だと思いこのテーマを選びました。 「こう書くと破綻する」というパターンを知ることで、日々のコードレビューや設計判断に活かしていきたいですね!

基本のおさらい

用語の認識を揃えるために簡単におさらいします。

オブジェクト指向の基本用語

用語 説明
オブジェクト データと振る舞いを持つ「もの」
クラス オブジェクトの設計図
インスタンス クラスから実際に生成されたオブジェクト
クラス変数(static変数) クラス自体に属する変数。全インスタンスで共有される
インスタンス変数 インスタンスごとに固有の変数。

オブジェクト指向の三大要素

用語 説明
カプセル化 データとそれを操作するメソッドをひとまとめにし、内部の実装詳細を外部から隠蔽すること
継承 共通の振る舞いを基底クラスに持たせ、派生クラスでは差分だけを定義できる仕組み
ポリモーフィズム 同じインターフェースで異なる振る舞いを実現できること

悪い設計の例

以下のAnimalクラスは悪い設計なのですが、一体どこが良くないのでしょうか? 型の問題もありますが今回は設計の話に集中します

class Animal {
  name: string
  type: string
  age: number
  zooName: string 
  feedingSchedule: Record<string, string>
  veterinaryRecord: { date: string; detail: string }[]

  constructor(name: string, type: string, age: number, zooName: string) {
    this.name = name
    this.type = type
    this.age = age
    this.zooName = zooName

 // 餌スケジュール
    this.feedingSchedule = {}

 // 動物診療記録
    this.veterinaryRecord = []
  }

  makeSound(): string {
    if (this.type === "dog") return "ワン"
    else if (this.type === "cat") return "ニャー"
    else if (this.type === "bird") return "ピヨ"
    else if (this.type === "fish") return "..."
    else return "謎の鳴き声"
  }

  move(): string {
    if (this.type === "dog" || this.type === "cat") return "走る"
    else if (this.type === "bird") return "飛ぶ"
    else if (this.type === "fish") return "泳ぐ"
    else return "移動する"
  }

// 食料の残りを確認
  checkFoodStock(food: string): boolean {
    const stock: Record<string, number> = { meat: 10, fish: 5, grass: 20 }
    return stock[food] > 0
  }

  // 請求書を生成
  generateVetBill(): number {
    return this.veterinaryRecord.length * 5000
  }

  // 動物園のキャパシティ
  updateZooCapacity(zoo: { capacity: number, animals: Animal[] }): void {
    zoo.capacity -= 1
    zoo.animals.push(this)
  }
}

まず、目につくのはAnimalクラスに色々な責務が混同してしまっていることですね。クラス設計は単一の目的を達成するために設計されることが理想とされています。 動物の振る舞いがあることは良くても、動物園や請求書があるのはこの単一責任の法則に反しているので分けていきます。

// ============================================
// 1. Animal: 動物そのものの情報と振る舞いだけ
// ============================================

class Animal {
  name: string
  type: string
  age: number

  constructor(name: string, type: string, age: number) {
    this.name = name
    this.type = type
    this.age = age
  }

  makeSound(): string {
    if (this.type === "dog") return "ワン"
    else if (this.type === "cat") return "ニャー"
    else if (this.type === "bird") return "ピヨ"
    else if (this.type === "fish") return "..."
    else return "謎の鳴き声"
  }

  move(): string {
    if (this.type === "dog" || this.type === "cat" ) return "走る"
    else if (this.type === "bird") return "飛ぶ"
    else if (this.type === "fish") return "泳ぐ"
    else return "移動する"
  }
}


// ============================================
// 2. FeedingManager: 餌の管理
// ============================================

class FeedingManager {
  setSchedule(animal: Animal, time: string): void { ... }
  checkFoodStock(food: string): boolean { ... }
}

// ============================================
// 3. VeterinaryService: 診療記録と請求
// ============================================

class VeterinaryService {
  addRecord(animal: Animal, record: string): void { ... }
  generateBill(animal: Animal): number { ... }
}

// ============================================
// 4. Zoo: 動物園の管理
// ============================================

class Zoo {
  name: string
  capacity: number
  animals: Animal[] = []

  constructor(name: string, capacity: number) { ... }
  addAnimal(animal: Animal): void { ... }
}

単一責任にした後、だいぶクラスの中がスッキリしました。 インスタンス変数の数も減ったので、Animalクラスを呼び出す他のコードに与える影響も減りました。

次に問題になるのは、if, else ifで分岐している makeSoundmoveです。switch caseで分岐していても同様です。 何が問題かというと、動物の種類を増やしたいときに、毎回分岐を増やすのは面倒です。 また、動物の種類がもっと増えたり、分岐させるメソッドが増えたりすると追加漏れが発生することも考えられます。

これを解決するには、抽象クラスにmakeSoundmoveメソッドを定義することです。 抽象クラスとは具体的な処理を持たない抽象メソッドを1つ以上持つクラスのことで、抽象クラスを継承する派生クラスには、抽象メソッドの実装を強制することが出来ます。 抽象クラスを導入したおかげで、新しい動物を追加するとき、既存コードを変更せず新しいクラスを作成するだけで済みます。

また、前提でお話したように、同じメソッドの名前で異なる動作をさせることをポリモーフィズムと言います。

// ============================================
// 1. Animal: 抽象クラスとして定義
// ============================================

abstract class Animal {
  constructor(
    public name: string,
    public age: number,
  ) {}

  abstract makeSound(): string
  abstract move(): string
}

// ============================================
// 2. 派生クラス: 動物ごとに具体的な振る舞いを実装
// ============================================

class Dog extends Animal {
  makeSound(): string { return "ワン" }
  move(): string { return "走る" }
}

class Cat extends Animal {
  makeSound(): string { return "ニャー" }
  move(): string { return "歩く" }
}

class Bird extends Animal {
  makeSound(): string { return "ピヨ" }
  move(): string { return "飛ぶ" }
}

class Fish extends Animal {
  makeSound(): string { return "..." }
  move(): string { return "泳ぐ" }
}

// ============================================
// 使用例
// ============================================

const dog = new Dog("ポチ", 3)
const bird = new Bird("ピー太", 1)

dog.makeSound()  // "ワン"
bird.move()      // "飛ぶ"

ただ気をつけたいのは、派生クラスが必ず基底クラスの振る舞いを継承してもおかしくない設計をすることです。 先程のコードでは魚はmakeSound()の期待する動作を満たしません。 もし、以下のような動物の鳴き声を出すメソッドがある場合、ニモは...と鳴きますといったおかしな文脈になってしまいます。

「基底クラスの代わりに派生クラスを使っても、プログラムの正しさが保たれるべき」というリスコフの置換原則(LSP)が存在します。

const animals: Animal[] = [
  new Dog("ポチ", 3),
  new Fish("ニモ", 1),
]


// Animal型として統一的に扱うと...
function introduceAnimals(animals: Animal[]): void {
  for (const animal of animals) {
      // "ニモは...と鳴きます" ← Fishでは意味が通らない
    console.log(`${animal.name}${animal.makeSound()}と鳴きます`)
  }
}

継承が不自然にならないように、インターフェースを使用して特定の派生クラスのみ持つ機能を分けていきます。 先ほど紹介した抽象クラスは「何であるか(is-a)」を共有するのに対し、インターフェースは「何ができるか(can-do)」に使います。

また、鳴く能力がある動物だけ出力できるように型ガードで能力の有無を判定します。 interfaceはTypeScriptの型情報のため、コンパイル後のJavaScriptには残りません。 また、interfaceはクラスでもないのでanimal instanceof Vocalのように書くことは出来ません。 ではどのように、特定のインターフェースを持っているか判別するかというと、型ガードで「そのメソッドを持っているか」を存在チェック"makeSound" in animalのように判定します。

// ============================================
//  インターフェース: 能力ごとに定義
// ============================================

/** 鳴く能力 */
interface Vocal {
  makeSound(): string
}

/** 動く能力 */
interface Movable {
  move(): string
}

/** 泳ぐ能力 */
interface Swimmable {
  swim(): string
}

// ============================================
//  抽象クラス: 全動物共通の属性のみ持つ
// ============================================
abstract class Animal {
  constructor(
    public name: string,
    public age: number,
  ) {}
}

// ============================================
//  派生クラス: 必要な能力だけ実装する
// ============================================
class Dog extends Animal implements Vocal, Movable {
  makeSound(): string { return "ワン" }
  move(): string { return "走る" }
}

class Cat extends Animal implements Vocal, Movable {
  makeSound(): string { return "ニャー" }
  move(): string { return "歩く" }
}

class Bird extends Animal implements Vocal, Movable {
  makeSound(): string { return "ピヨ" }
  move(): string { return "飛ぶ" }
}

// Fish は Vocal を実装しない → makeSound() を強制されない
class Fish extends Animal implements Movable, Swimmable {
  move(): string { return "泳ぐ" }
  swim(): string { return "スイスイ泳ぐ" }
}

// ============================================
//  型ガード: 能力の有無を安全に判定する
// ============================================
function isVocal(animal: Animal): animal is Animal & Vocal {
  return "makeSound" in animal
}

function isSwimmable(animal: Animal): animal is Animal & Swimmable {
  return "swim" in animal
}
// ============================================
//  使用例
// ============================================

const animals: Animal[] = [
  new Dog("ポチ", 3),
  new Cat("タマ", 2),
  new Bird("ピー太", 1),
  new Fish("ニモ", 1),
]

function introduceAnimals(animals: Animal[]): void {
  for (const animal of animals) {
   // 鳴ける動物だけ鳴き声を出す
    if (isVocal(animal)) {
      console.log(`${animal.name}は「${animal.makeSound()}」と鳴きます`)
    } else {
      console.log(`${animal.name}は鳴きません`)
    }
  }
}

// ポチは「ワン」と鳴きます
// タマは「ニャー」と鳴きます
// ピー太は「ピヨ」と鳴きます
// ニモは鳴きません

まとめ

今回は悪い設計のAnimalクラスを段階的にリファクタリングしながら、SOLID原則のうち4つを適用しました!

やったこと SOLID原則
責務の分離(Animal / Zoo / FeedingManager) S - 単一責任の原則(SRP)
抽象クラスで分岐を排除し、拡張で対応 O - 開放閉鎖の原則(OCP)
魚のmakeSound問題を解消 L - リスコフの置換原則(LSP)
能力をインターフェースに分離 I - インターフェース分離の原則(ISP)

残りのD(依存性逆転の原則)までは今回扱えませんでしたが、興味がある方はぜひ調べてみてください..!

最初のAnimalクラスは「動けばいい」コードとしては問題なく動きますが、動物の種類を増やしたい、処理を変えたいといった変更が入るたびに修正範囲が広がり、バグの温床になります。

コードが「今後どう変わりうるか」を意識しながら設計していきたいですね!

この記事を読んで興味を持って下さった方がいらっしゃればカジュアルにお話させていただきたく、是非ご応募をお願いします!

iimon採用サイト / Wantedly / Green

参照文献

動画

書籍

  • 仙塲大也 『良いコード/悪いコードで学ぶ設計入門』(技術評論社, 2022)
  • Dustin Boswell, Trevor Foucher 『リーダブルコード』(オライリージャパン, 2012)
  • Robert C. Martin 『Clean Architecture ~達人に学ぶソフトウェアの構造と設計~』(KADOKAWA, 2018)