第4回:クラスとモジュールの分割技法

はじめに

みなさん、こんにちは!前回は関数レベルのリファクタリングについて学びました。今日は一歩進んで、クラスやモジュールという大きな単位での分割技法について学んでいきます。

大きくなりすぎたクラスは、ちょうど「何でも屋さん」のようなもの。いろんな仕事を一人でこなすと、どんどん忙しくなって整理ができなくなりますよね。今日はそんなクラスを適切に分けて、それぞれの役割をはっきりさせる方法を学びます。

クラス抽出の判断基準と手順

クラス抽出が必要なサイン

クラスがいつ分割すべき時なのか、いくつか目安があります:

  • コードの行数が多すぎる:一般的に数百行を超えるクラスは要注意です
  • メソッドが多すぎる:メソッドが20個以上あるクラスは分割を検討しましょう
  • インスタンス変数が多い:たくさんのデータを持ちすぎているクラスは細分化できる可能性があります
  • クラス名があいまい:「Manager」「Handler」「Processor」など、何でも屋さんを表す名前になっていませんか?

クラス抽出の基本手順

  1. 関連する機能をグループ化する:クラス内のメソッドやフィールドを機能ごとにグループにします
  2. 新しいクラスを作成する:グループごとに新しいクラスを作ります
  3. 元のクラスから新クラスへコードを移動する:関連するメソッドとフィールドを移動します
  4. クラス間の関係を設定する:新しいクラスと元のクラスの関係を定義します
  5. インターフェースを調整する:必要に応じて公開メソッドを整理します

例えば、次のような「学生管理システム」のクラスを見てみましょう:

class StudentSystem {
  // 学生情報関連
  addStudent(name, age, grade) { /* ... */ }
  getStudentDetails(id) { /* ... */ }
  updateStudentGrade(id, newGrade) { /* ... */ }
  
  // 成績計算関連
  calculateAverageGrade() { /* ... */ }
  getTopStudents() { /* ... */ }
  
  // レポート生成関連
  generateGradeReport() { /* ... */ }
  exportToCSV() { /* ... */ }
  printReport() { /* ... */ }
}
Code language: JavaScript (javascript)

これを3つのクラスに分けると:

class StudentManager {
  addStudent(name, age, grade) { /* ... */ }
  getStudentDetails(id) { /* ... */ }
  updateStudentGrade(id, newGrade) { /* ... */ }
}

class GradeCalculator {
  calculateAverageGrade(students) { /* ... */ }
  getTopStudents(students) { /* ... */ }
}

class ReportGenerator {
  generateGradeReport(students) { /* ... */ }
  exportToCSV(report) { /* ... */ }
  printReport(report) { /* ... */ }
}
Code language: JavaScript (javascript)

このように分割すると、それぞれのクラスの役割がはっきりして、理解しやすくなります。

継承と委譲の適切な使い分け

継承とは

継承は「〜は〜である」という関係を表します。例えば「猫は動物である」「トラックは車である」といった関係です。

class Animal {
  eat() { /* ... */ }
  sleep() { /* ... */ }
}

class Cat extends Animal {
  meow() { /* ... */ }
}
Code language: JavaScript (javascript)

委譲とは

委譲は「〜は〜を持っている」「〜は〜を使う」という関係を表します。

class Engine {
  start() { /* ... */ }
  stop() { /* ... */ }
}

class Car {
  constructor() {
    this.engine = new Engine();
  }
  
  startCar() {
    this.engine.start();
    // その他の処理...
  }
}
Code language: JavaScript (javascript)

使い分けのコツ

  • 継承:本当に「〜は〜である」関係が成り立つときに使います
  • 委譲:機能を利用したいけれど、「〜は〜である」関係ではない場合に使います

継承は便利ですが、使いすぎると「継承地獄」と呼ばれる複雑な状態になることがあります。「継承より委譲を優先する」というのはオブジェクト指向プログラミングの基本原則の一つです。

身近な例えで言うと、継承は「血縁関係」、委譲は「仕事の外注」と考えるとわかりやすいかもしれません。

インターフェースを活用した依存関係の管理

インターフェースとは

インターフェースは「こういう機能を提供します」という約束・契約のようなものです。実装の詳細は隠して、使い方だけを定義します。

例えばJavaScriptでは:

// インターフェースの概念を表現
class PaymentProcessor {
  processPayment(amount) {
    throw new Error("このメソッドはサブクラスで実装する必要があります");
  }
}

// 実装クラス
class CreditCardProcessor extends PaymentProcessor {
  processPayment(amount) {
    console.log(`クレジットカードで${amount}円支払いました`);
  }
}

class PayPalProcessor extends PaymentProcessor {
  processPayment(amount) {
    console.log(`PayPalで${amount}円支払いました`);
  }
}
Code language: JavaScript (javascript)

依存関係の逆転

インターフェースを使うことで、依存関係を逆転させることができます。これは「依存性逆転の原則」と呼ばれるもので、高レベルのモジュールが低レベルのモジュールに直接依存しないようにする考え方です。

例えば:

// 悪い例:直接具体クラスに依存
class CheckoutService {
  constructor() {
    this.paymentProcessor = new CreditCardProcessor(); // 直接依存している
  }
  
  checkout(amount) {
    this.paymentProcessor.processPayment(amount);
  }
}

// 良い例:インターフェースに依存
class CheckoutService {
  constructor(paymentProcessor) {
    this.paymentProcessor = paymentProcessor; // どんな支払い方法でも受け入れられる
  }
  
  checkout(amount) {
    this.paymentProcessor.processPayment(amount);
  }
}

// 使用例
const checkout = new CheckoutService(new PayPalProcessor());
checkout.checkout(5000);
Code language: JavaScript (javascript)

このように書くと、CheckoutServiceは具体的な支払い方法を知らなくても良くなります。新しい支払い方法が増えても、CheckoutServiceは変更せずに済みます。

実習:機能の関連性に基づいてクラスを再構成する

では、実際に大きなクラスを分割する実習をしてみましょう。

次のような「オンラインショッピングカート」クラスがあるとします:

class ShoppingCart {
  // カート操作
  addItem(item) { /* ... */ }
  removeItem(itemId) { /* ... */ }
  updateQuantity(itemId, quantity) { /* ... */ }
  
  // 価格計算
  calculateSubtotal() { /* ... */ }
  calculateTax() { /* ... */ }
  calculateShipping() { /* ... */ }
  calculateTotal() { /* ... */ }
  
  // 割引処理
  applyDiscountCode(code) { /* ... */ }
  checkDiscountValidity(code) { /* ... */ }
  
  // 注文処理
  placeOrder() { /* ... */ }
  processPayment(paymentDetails) { /* ... */ }
  sendOrderConfirmation() { /* ... */ }
}
Code language: JavaScript (javascript)

課題

このクラスを機能の関連性に基づいて、以下のように再構成してみましょう:

  1. カート操作を担当するクラス
  2. 価格計算を担当するクラス
  3. 割引処理を担当するクラス
  4. 注文処理を担当するクラス

解答例

// カート操作を担当するクラス
class CartManager {
  constructor() {
    this.items = [];
  }
  
  addItem(item) { /* ... */ }
  removeItem(itemId) { /* ... */ }
  updateQuantity(itemId, quantity) { /* ... */ }
  getItems() { return this.items; }
}

// 価格計算を担当するクラス
class PriceCalculator {
  calculateSubtotal(items) { /* ... */ }
  calculateTax(subtotal) { /* ... */ }
  calculateShipping(items) { /* ... */ }
  calculateTotal(items) {
    const subtotal = this.calculateSubtotal(items);
    const tax = this.calculateTax(subtotal);
    const shipping = this.calculateShipping(items);
    return subtotal + tax + shipping;
  }
}

// 割引処理を担当するクラス
class DiscountManager {
  applyDiscountCode(code, total) { /* ... */ }
  checkDiscountValidity(code) { /* ... */ }
}

// 注文処理を担当するクラス
class OrderProcessor {
  placeOrder(items, total) { /* ... */ }
  processPayment(paymentDetails, total) { /* ... */ }
  sendOrderConfirmation(orderDetails) { /* ... */ }
}

// これらを組み合わせた新しいショッピングカートクラス
class ShoppingCart {
  constructor() {
    this.cartManager = new CartManager();
    this.priceCalculator = new PriceCalculator();
    this.discountManager = new DiscountManager();
    this.orderProcessor = new OrderProcessor();
  }
  
  addItem(item) {
    this.cartManager.addItem(item);
  }
  
  // 他のメソッドも必要に応じて委譲...
  
  checkout(paymentDetails, discountCode) {
    const items = this.cartManager.getItems();
    let total = this.priceCalculator.calculateTotal(items);
    
    if (discountCode) {
      if (this.discountManager.checkDiscountValidity(discountCode)) {
        total = this.discountManager.applyDiscountCode(discountCode, total);
      }
    }
    
    this.orderProcessor.processPayment(paymentDetails, total);
    const orderDetails = this.orderProcessor.placeOrder(items, total);
    this.orderProcessor.sendOrderConfirmation(orderDetails);
    
    return orderDetails;
  }
}
Code language: JavaScript (javascript)

これで、それぞれのクラスの役割が明確になり、コードの保守性が向上しました。また、将来的に例えば割引の計算方法だけを変更したい場合は、DiscountManagerクラスだけを修正すれば良くなります。

クラス分割のメリット

  1. 理解しやすさの向上:小さなクラスは理解しやすい
  2. 変更の影響範囲の限定:一部の機能を変更する場合、その機能を担当するクラスだけ修正すれば良い
  3. 再利用性の向上:小さな責任を持つクラスは他の場所でも再利用しやすい
  4. テストのしやすさ:小さなクラスは単体テストがしやすい

まとめ

今日の講義では、クラスとモジュールの分割技法について学びました:

  1. クラス抽出の判断基準と手順:大きすぎるクラスを機能ごとに分割する方法
  2. 継承と委譲の適切な使い分け:「〜は〜である」関係なら継承、それ以外なら委譲
  3. インターフェースを活用した依存関係の管理:抽象に依存することで柔軟性を高める

次回の講義では、より大きな視点で「大規模アプリケーションの構造化」について学んでいきます。それまでに、自分のプロジェクトの中で分割できそうなクラスを探してみてください。

質問タイム

みなさん、今日の講義内容について質問はありますか?

質問1:田中さんからの質問

質問:「クラスを分割するとき、どのくらいの大きさが適切ですか?小さすぎるクラスをたくさん作っても逆に複雑になりませんか?」

回答: とても良い質問です、田中さん。クラスの適切な大きさは「単一責任の原則」に従うのが基本です。つまり、クラスは一つの明確な役割だけを持つべきです。

ただ、おっしゃるとおり、小さすぎるクラスを多数作ると「断片化」が起こり、全体を把握するのが難しくなることがあります。一般的には以下のバランスを考えると良いでしょう:

  • メソッドが5~10個程度
  • 行数は100~200行程度を目安に
  • 一つの概念や機能を表現できているか

例えば、CartItemクラスが商品ID、名前、価格、数量だけを管理するのは適切ですが、ItemNameItemPriceなど、単一のプロパティだけを管理するクラスを作るのは断片化しすぎかもしれません。

プログラミングでは「適切な抽象化のレベル」を見つけることが重要です。これは経験を積むことで感覚が磨かれていきますよ。

質問2:鈴木さんからの質問

質問:「インターフェースは具体的にどう実装すればいいですか?JavaScriptではインターフェースがないと思うのですが…」

回答: 鋭い指摘をありがとう、鈴木さん。JavaScriptには確かに他の言語(JavaやC#など)のような公式のインターフェース機能はありません。でも、似たような概念は実現できます。

JavaScriptでインターフェースを表現する方法はいくつかあります:

  1. コメントとドキュメント:必要なメソッドを明示的にコメントで記述する
// PaymentProcessorインターフェース
// 必須メソッド:
// - processPayment(amount): 支払い処理を行う
Code language: JSON / JSON with Comments (json)
  1. 基底クラスと例外:先ほど示したように、抽象メソッドを例外を投げる形で定義
class PaymentProcessor {
  processPayment(amount) {
    throw new Error("サブクラスで実装してください");
  }
}
Code language: JavaScript (javascript)
  1. TypeScriptを使う:TypeScriptなら本格的なインターフェースが使えます
interface PaymentProcessor {
  processPayment(amount: number): void;
}

class CreditCardProcessor implements PaymentProcessor {
  processPayment(amount: number): void {
    // 実装
  }
}
Code language: PHP (php)

大事なのは「契約」としての概念です。「このクラスはこういうメソッドを必ず持っています」という約束を表現できればOKです。実際のプロジェクトでは、TypeScriptを使うか、JSDocなどのドキュメントツールでインターフェースを明示するのが一般的ですよ。

質問3:佐藤さんからの質問

質問:「実務では実際にクラスを分割するとき、既存のコードを壊さないようにするコツはありますか?特に大きなプロジェクトで怖くて手が出せません…」

回答: 佐藤さん、とても現実的で重要な質問ですね。確かに既存コードのリファクタリングは「動いているものを壊さない」ことが大切です。いくつかのコツを紹介します:

  1. テストを先に書く:リファクタリング前に、その機能のテストを書いておきます。これがあれば、変更後も正しく動作しているか確認できます。
  2. 小さなステップで進める:一度に大きく変えようとせず、小さな変更を繰り返します。例えば:
    • まず新しいクラスを作る
    • 一つのメソッドだけ移動してテスト
    • 次のメソッドを移動、テスト…
  3. インターフェースを保持する:最初は元のクラスのメソッドを残して、内部で新しいクラスに委譲するようにします。
class ShoppingCart {
  constructor() {
    this.priceCalculator = new PriceCalculator();
  }
  
  // 古いインターフェースを維持
  calculateTotal() {
    return this.priceCalculator.calculateTotal(this.items);
  }
}
Code language: JavaScript (javascript)
  1. バージョン管理を活用する:Gitなどのバージョン管理システムを使い、問題が起きたら戻せるようにしておきます。
  2. リファクタリングの意図を文書化する:なぜその変更をしたのか、コメントやコミットメッセージに残しておくと、チームメンバーの理解も進みます。

これらのアプローチを組み合わせると、安全にリファクタリングを進められますよ。最初は小さな変更から始めて、徐々に自信をつけていくことをお勧めします。

実務ではこのような「段階的リファクタリング」が非常に重要です。第9回の講義でこのテーマについてさらに詳しく学びますので、楽しみにしていてください。