第1回:モジュール分割の基本原則

こんにちは!今日はモジュール分割の基本原則について学んでいきましょう。これはコードを整理する上での土台となる重要な考え方です。

なぜモジュール分割が必要なのか

コードを書いていると、どんどん大きくなっていきますよね。大きくなりすぎたコードには次のような問題が出てきます:

  • 理解しづらい:1000行あるファイルを頭の中で整理するのは難しいです
  • 変更が難しい:どこを修正すれば良いか見つけにくく、影響範囲も把握しづらい
  • テストしづらい:大きなコードは部分的にテストするのも大変
  • 再利用できない:機能が混ざりすぎていて、他の場所で使いまわせない

これらの問題を解決するのがモジュール分割です。コードを適切な大きさの部品に分けることで、整理整頓された状態を保てます。

例えば、キッチンを想像してみてください。調味料、調理器具、食材がすべて一つの引き出しに入っていたら大変ですよね。それぞれを別々の引き出しに整理すると使いやすくなります。コードも同じなんです。

単一責任の原則(SRP: Single Responsibility Principle)

モジュール分割の最も基本的な考え方が「単一責任の原則」です。これは次のように説明できます:

「クラスや関数は、たった一つの理由でのみ変更されるべきである」

つまり、一つのコードの塊(クラスや関数)は一つのことだけに責任を持つべきというルールです。

悪い例と良い例

悪い例:

// 悪い例:複数の責任が混在している
function processUser(userData) {
  // データの検証
  if (!userData.name || !userData.email) {
    console.error("Invalid user data");
    return false;
  }
  
  // データベースへの保存
  const db = connectToDatabase();
  db.users.insert(userData);
  
  // メール送信
  const message = `Welcome ${userData.name}!`;
  sendEmail(userData.email, "Welcome", message);
  
  // ログ記録
  logger.info(`User ${userData.name} was created`);
  
  return true;
}
Code language: JavaScript (javascript)

この関数は4つの責任を持っています:

  1. データの検証
  2. データの保存
  3. メール送信
  4. ログ記録

良い例:

// 良い例:責任ごとに分割されている
function validateUserData(userData) {
  return userData.name && userData.email;
}

function saveUserToDatabase(userData) {
  const db = connectToDatabase();
  return db.users.insert(userData);
}

function sendWelcomeEmail(user) {
  const message = `Welcome ${user.name}!`;
  return sendEmail(user.email, "Welcome", message);
}

function logUserCreation(user) {
  logger.info(`User ${user.name} was created`);
}

// これらを組み合わせる関数
function processUser(userData) {
  if (!validateUserData(userData)) {
    console.error("Invalid user data");
    return false;
  }
  
  saveUserToDatabase(userData);
  sendWelcomeEmail(userData);
  logUserCreation(userData);
  
  return true;
}
Code language: JavaScript (javascript)

このように分けると:

  • 各関数が一つのことだけを担当しているので理解しやすい
  • 例えばメール送信のロジックだけを変更したい場合、sendWelcomeEmailだけを見れば良い
  • テストも各関数ごとに独立して行える
  • 例えばvalidateUserDataは他の場所でも再利用できる

凝集度と結合度

モジュール分割の品質を測る二つの重要な概念があります:

凝集度(Cohesion)

凝集度は「モジュール内の要素がどれだけ強く関連しているか」を表します。高い凝集度が理想的です。

例えば、「ユーザー情報の検証」というひとつのテーマに関する処理だけが集まった関数は凝集度が高いです。一方、「ユーザー検証」と「商品情報の更新」という関係のない処理が混ざった関数は凝集度が低いです。

結合度(Coupling)

結合度は「モジュール間の依存関係の強さ」を表します。低い結合度が理想的です。

例えば、あるモジュールが他のモジュールの内部実装に依存している場合、結合度は高いです。一方、明確に定義されたインターフェースを通じてのみやり取りする場合は結合度が低いです。

理想は「高い凝集度」と「低い結合度」の組み合わせです。これは「関連する機能はまとめて、異なる機能間の依存関係は最小限に」という原則です。

実際にどう判断する?

では、実際にコードを見たとき、どう判断すれば良いのでしょうか?

分割すべきサインを見つける

以下のようなサインがあれば、分割を検討するタイミングです:

  1. 関数やクラスが大きくなりすぎている
    • 目安:関数は20〜30行、クラスは200〜300行を超えたら検討
  2. 「そして(and)」で説明できる
    • 「この関数はデータを検証して、保存して、メールを送る」のように「〜して」が複数ある
  3. 変更理由が複数ある
    • 「メールのテンプレートを変えたい」「データベースの構造を変えたい」など、異なる理由で同じコードを修正することになる
  4. 部分的に使いたいと思う場面がある
    • 「この処理の検証部分だけ別の場所でも使いたい」と思ったら分割のサイン

【実習】コードの責任範囲を特定する

では実習に移りましょう。以下のコードを見て、それぞれの責任範囲を特定し、どのように分割すべきか考えてみましょう。

function createOrder(user, items, couponCode) {
  // ユーザー検証
  if (!user.id || !user.email) {
    throw new Error("Invalid user");
  }
  
  // 在庫チェック
  for (const item of items) {
    const stock = getStockLevel(item.id);
    if (stock < item.quantity) {
      throw new Error(`Not enough stock for ${item.name}`);
    }
  }
  
  // 価格計算
  let totalPrice = 0;
  for (const item of items) {
    totalPrice += item.price * item.quantity;
  }
  
  // クーポン適用
  if (couponCode) {
    const coupon = getCoupon(couponCode);
    if (coupon && coupon.isValid) {
      totalPrice = totalPrice * (1 - coupon.discountRate);
    }
  }
  
  // 注文データ作成
  const order = {
    userId: user.id,
    items: items,
    totalPrice: totalPrice,
    date: new Date()
  };
  
  // データベースに保存
  const db = connectToDatabase();
  const orderId = db.orders.insert(order);
  
  // 在庫更新
  for (const item of items) {
    updateStock(item.id, -item.quantity);
  }
  
  // 確認メール送信
  const emailContent = `Thank you for your order! Total: $${totalPrice}`;
  sendEmail(user.email, "Order Confirmation", emailContent);
  
  // 注文IDを返す
  return orderId;
}
Code language: JavaScript (javascript)

この関数はどのような責任を持っていますか?どのように分割すると良いでしょうか?

考える時間を取って、分析してみてください。その後、私の提案する分割方法をお伝えします。

【実習】コードの責任範囲を特定する – 解答

先ほどのコードを分析すると、以下の責任に分けられます:

  1. ユーザー検証:ユーザー情報が正しいか確認
  2. 在庫確認:商品の在庫が十分にあるか確認
  3. 価格計算:注文全体の金額を計算
  4. クーポン適用:クーポンコードがあれば割引を適用
  5. 注文データ作成:注文情報をまとめる
  6. データベース保存:注文をデータベースに保存
  7. 在庫更新:注文された商品の在庫を減らす
  8. 確認メール送信:ユーザーにメールを送る

これらの責任を個別の関数に分割してみましょう:

// 1. ユーザー検証
function validateUser(user) {
  if (!user.id || !user.email) {
    throw new Error("Invalid user");
  }
  return true;
}

// 2. 在庫確認
function checkItemsStock(items) {
  for (const item of items) {
    const stock = getStockLevel(item.id);
    if (stock < item.quantity) {
      throw new Error(`Not enough stock for ${item.name}`);
    }
  }
  return true;
}

// 3. 価格計算
function calculateTotalPrice(items) {
  let totalPrice = 0;
  for (const item of items) {
    totalPrice += item.price * item.quantity;
  }
  return totalPrice;
}

// 4. クーポン適用
function applyCoupon(totalPrice, couponCode) {
  if (!couponCode) return totalPrice;
  
  const coupon = getCoupon(couponCode);
  if (coupon && coupon.isValid) {
    return totalPrice * (1 - coupon.discountRate);
  }
  return totalPrice;
}

// 5. 注文データ作成
function createOrderData(user, items, totalPrice) {
  return {
    userId: user.id,
    items: items,
    totalPrice: totalPrice,
    date: new Date()
  };
}

// 6. データベース保存
function saveOrderToDatabase(order) {
  const db = connectToDatabase();
  return db.orders.insert(order);
}

// 7. 在庫更新
function updateItemsStock(items) {
  for (const item of items) {
    updateStock(item.id, -item.quantity);
  }
}

// 8. 確認メール送信
function sendOrderConfirmationEmail(user, totalPrice) {
  const emailContent = `Thank you for your order! Total: $${totalPrice}`;
  sendEmail(user.email, "Order Confirmation", emailContent);
}

// これらを組み合わせるメイン関数
function createOrder(user, items, couponCode) {
  // ユーザーと在庫の検証
  validateUser(user);
  checkItemsStock(items);
  
  // 金額計算
  let totalPrice = calculateTotalPrice(items);
  totalPrice = applyCoupon(totalPrice, couponCode);
  
  // 注文作成と保存
  const order = createOrderData(user, items, totalPrice);
  const orderId = saveOrderToDatabase(order);
  
  // 在庫更新とメール送信
  updateItemsStock(items);
  sendOrderConfirmationEmail(user, totalPrice);
  
  return orderId;
}
Code language: JavaScript (javascript)

分割の効果を考える

このように分割すると、以下のメリットが得られます:

  1. 理解しやすさの向上
    • 各関数名が「何をするか」を明確に表現しているので、コードを読む人はすぐに処理の流れを理解できます。
    • 各関数の中身も短く、一つの責任に集中しているので把握しやすいです。
  2. テストのしやすさ
    • 例えば calculateTotalPrice だけをテストしたい場合、簡単に単体テストが書けます。
    • 分割前だと、特定の処理だけをテストするのが難しかったでしょう。
  3. 再利用性の向上
    • 例えば validateUser は他の場所(ユーザー登録時など)でも使えます。
    • applyCoupon も別の割引計算で再利用できるでしょう。
  4. 変更のしやすさ
    • メール送信の形式を変更したい場合、sendOrderConfirmationEmail だけを修正すれば良いです。
    • データベース構造が変わっても、saveOrderToDatabase のみを修正すれば対応できます。

モジュール分割の実践的なヒント

実際にモジュール分割を行う際の実践的なヒントをいくつか紹介します:

1. 段階的に分割する

いきなり完璧に分割しようとせず、まずは明らかな責任の境界から分けていきましょう。徐々に改善していく姿勢が大切です。

2. 命名に時間をかける

関数やクラスの名前は、その責任を明確に表すものにしましょう。良い名前は「何をするか」がすぐに伝わります。名前を考えるのに時間をかけるのは価値があります。

3. コメントが必要な場合は分割のサイン

「この部分は〜をしています」というコメントを書きたくなったら、それは分割のサインです。コメントの代わりに、その内容を関数名にして抽出しましょう。

4. 関数は小さく、引数は少なく

関数は小さく保ち、引数は少なくすることを意識しましょう。多くの引数が必要な場合は、オブジェクトにまとめることも検討します。

演習問題

今回の講義の内容を定着させるために、以下の演習問題にチャレンジしてみてください:

function processPayment(order, paymentMethod, customerInfo) {
  // 支払い方法の検証
  if (!['credit', 'paypal', 'bank'].includes(paymentMethod)) {
    throw new Error("Invalid payment method");
  }
  
  // 顧客情報の検証
  if (paymentMethod === 'credit' && (!customerInfo.cardNumber || !customerInfo.expiryDate)) {
    throw new Error("Missing credit card information");
  } else if (paymentMethod === 'paypal' && !customerInfo.paypalEmail) {
    throw new Error("Missing PayPal email");
  } else if (paymentMethod === 'bank' && (!customerInfo.bankAccount || !customerInfo.bankCode)) {
    throw new Error("Missing bank information");
  }
  
  // 支払い処理
  let success = false;
  if (paymentMethod === 'credit') {
    success = processCreditPayment(customerInfo.cardNumber, customerInfo.expiryDate, order.totalAmount);
  } else if (paymentMethod === 'paypal') {
    success = processPaypalPayment(customerInfo.paypalEmail, order.totalAmount);
  } else if (paymentMethod === 'bank') {
    success = processBankTransfer(customerInfo.bankAccount, customerInfo.bankCode, order.totalAmount);
  }
  
  // 支払い結果の記録
  const paymentRecord = {
    orderId: order.id,
    amount: order.totalAmount,
    method: paymentMethod,
    success: success,
    date: new Date()
  };
  
  const db = connectToDatabase();
  db.payments.insert(paymentRecord);
  
  // メール通知
  let emailSubject, emailContent;
  if (success) {
    emailSubject = "Payment Successful";
    emailContent = `Your payment of $${order.totalAmount} for order #${order.id} was successful.`;
  } else {
    emailSubject = "Payment Failed";
    emailContent = `Your payment of $${order.totalAmount} for order #${order.id} failed. Please try again.`;
  }
  
  sendEmail(order.customerEmail, emailSubject, emailContent);
  
  // 在庫更新(支払いが成功した場合のみ)
  if (success) {
    for (const item of order.items) {
      updateProductInventory(item.productId, -item.quantity);
    }
  }
  
  return success;
}
Code language: JavaScript (javascript)

課題:

  1. このコードの責任範囲を特定してください
  2. 単一責任の原則に従って、適切に分割してください
  3. 分割により得られるメリットを説明してください

まとめ

今回の講義では、モジュール分割の基本原則について学びました:

  • 単一責任の原則:一つのコードの塊は一つの責任だけを持つべき
  • 凝集度と結合度:高い凝集度と低い結合度を目指す
  • 分割のサイン:大きさ、複数の責任、複数の変更理由、再利用性

次回の講義では「コードスメルを見つける技術」について学びます。コードスメルとは、リファクタリングが必要なコードの兆候のことです。どのようなパターンが問題を示しているのか、どう見つけるのかについて詳しく説明します。

第1回:モジュール分割の基本原則 – 質疑応答

ここで他の生徒から質問が来ましたので、それらにお答えしていきます。

質問1:どのくらい小さくすれば十分なのでしょうか?

生徒A:「関数は小さく」というアドバイスはわかるのですが、どこまで小さくすればいいのでしょうか?1行の関数でも作るべきですか?

回答: 良い質問です!「小さく」の定義は絶対的なものではありません。1行だけのためにわざわざ関数を作る必要はないでしょう。大切なのは「意味のあるまとまり」です。

一般的なガイドラインとしては:

  • 関数は「一つのこと」をすべきです(単一責任)
  • 画面内で関数全体が見えるサイズが理想的(スクロールなしで20〜30行程度)
  • 関数名で明確に説明できる処理のまとまりにします

例えば、calculateTax(price)のような1行の関数でも、その計算が複雑になる可能性があったり、複数の場所で使うなら分離する価値があります。ただ単に行数を減らすためだけの分割は避けましょう。

質問2:オブジェクト指向との関係は?

生徒B:単一責任の原則はオブジェクト指向プログラミングの概念ですが、関数型プログラミングでも同じように適用できますか?

回答: はい、単一責任の原則はオブジェクト指向から来た概念ですが、関数型プログラミングにも非常に適合します。実際、関数型プログラミングでは「純粋関数」という概念があり、これは一つの明確な仕事だけをする関数を指します。

関数型プログラミングでは:

  • 関数は入力を受け取り、計算して結果を返すだけの単一の責任を持つ
  • 副作用(状態変更、外部とのやり取り)を最小限にする
  • 小さな関数を組み合わせて複雑な操作を実現する

例えば先ほどの例では、calculateTotalPriceapplyCouponは純粋関数として設計できます。一方、sendOrderConfirmationEmailはメール送信という副作用があるため、別の関数として分離するのが良いでしょう。

質問3:実務でのバランスは?

生徒C:実際の業務では時間的制約もあります。どこまで細かく分割するべきかのバランスは、どう取るべきでしょうか?

回答: 実務では確かに完璧よりも実用性が重要です。以下のようなバランスを取るといいでしょう:

  1. 重要度と頻度でプライオリティをつける
    • 頻繁に変更される部分、ビジネスの中核機能は丁寧に分割する
    • あまり変更されない周辺機能は、必要になったときに分割する
  2. 段階的なリファクタリング
    • 完璧を一度に目指さず、少しずつ改善していく
    • 新機能追加のついでに、関連する部分を少しずつ整理する
  3. 「3回ルール」を活用する
    • 同じようなコードが3回以上出てきたら抽象化を検討する
    • それまでは重複があっても許容する
  4. チームの理解度を考慮
    • チームメンバー全員が理解できるレベルの抽象化にする
    • 過度に複雑な設計より、シンプルで理解しやすいコードの方が実務では価値がある

何よりも「メンテナンス性」を意識することが大切です。将来の自分や他の開発者が理解しやすく、変更しやすいコードを目指しましょう。

質問4:大きなクラスの分割方法は?

生徒D:既存の大きなクラス(1000行以上)を分割するとき、どのように始めるべきですか?いきなり全部を変えるのは怖いです。

回答: 大きなクラスを分割するのは確かに難しい作業です。以下のように段階的に進めると安全です:

  1. まずテストを書く
    • 既存の機能が壊れないことを確認するテストを用意する
    • これが安全網となり、リファクタリングの自信につながる
  2. 責任の分析
    • そのクラスが持つ責任を紙に書き出してみる
    • メソッドをグループ化し、関連するものをまとめる
  3. 抽出候補を特定
    • 最も独立性が高く、他への依存が少ないグループから始める
    • データアクセス、ユーティリティ機能などは分離しやすい
  4. 段階的に抽出
    • 一度にすべてを変更せず、一つのグループずつ抽出する
    • 各ステップでテストを実行し、機能が壊れていないか確認する
  5. 依存関係の整理
    • 抽出した新クラスと元のクラスの関係を明確にする
    • 必要に応じてインターフェースを導入して結合度を下げる

実際の例としては、例えばUserManagerという大きなクラスがあれば:

  • データアクセス部分をUserRepositoryとして抽出
  • 認証関連をUserAuthenticatorとして抽出
  • プロフィール管理をProfileManagerとして抽出

というように、段階的に進めていきます。

質問5:コードの分割と実行速度のトレードオフは?

生徒E:関数呼び出しはオーバーヘッドがあると聞きました。小さな関数に分けることで、実行速度が落ちる心配はありませんか?

回答: 理論的には関数呼び出しにはオーバーヘッドがありますが、現代のプログラミング環境では、以下の理由からほとんど心配する必要はありません:

  1. 最適化の進化
    • 現代のコンパイラやJITは非常に賢く、インライン化などの最適化を自動的に行う
    • 小さな関数は特にインライン化されやすい
  2. ボトルネックは別にある
    • 実際のパフォーマンスボトルネックは通常、関数呼び出しではなく、I/O操作やアルゴリズムの選択
    • 「早すぎる最適化は諸悪の根源」という格言があるように、まずは正しく動くきれいなコードを書くべき
  3. メンテナンス性の価値
    • 仮に微小なパフォーマンス低下があったとしても、コードの理解しやすさ、保守性、拡張性の向上の方が一般的には価値が高い
    • 本当にパフォーマンスクリティカルな部分が判明した場合のみ、最適化を検討する

実際の開発では「まず測定し、それから最適化する」のが鉄則です。プロファイリングツールで実際のボトルネックを特定してから対応しましょう。関数分割による理論上のオーバーヘッドを心配するより、コードの品質向上に注力する方が結果的には良いコードにつながります。

これらの質問はとても良いポイントですね。モジュール分割は理論だけでなく、実践の中でバランス感覚を身につけていくものです。何か他に質問はありますか?

それでは演習問題に取り組んでみてください!次回の講義でお会いしましょう。