こんにちは!今日はモジュール分割の基本原則について学んでいきましょう。これはコードを整理する上での土台となる重要な考え方です。
なぜモジュール分割が必要なのか
コードを書いていると、どんどん大きくなっていきますよね。大きくなりすぎたコードには次のような問題が出てきます:
- 理解しづらい: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つの責任を持っています:
- データの検証
- データの保存
- メール送信
- ログ記録
良い例:
// 良い例:責任ごとに分割されている
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)
結合度は「モジュール間の依存関係の強さ」を表します。低い結合度が理想的です。
例えば、あるモジュールが他のモジュールの内部実装に依存している場合、結合度は高いです。一方、明確に定義されたインターフェースを通じてのみやり取りする場合は結合度が低いです。
理想は「高い凝集度」と「低い結合度」の組み合わせです。これは「関連する機能はまとめて、異なる機能間の依存関係は最小限に」という原則です。
実際にどう判断する?
では、実際にコードを見たとき、どう判断すれば良いのでしょうか?
分割すべきサインを見つける
以下のようなサインがあれば、分割を検討するタイミングです:
- 関数やクラスが大きくなりすぎている
- 目安:関数は20〜30行、クラスは200〜300行を超えたら検討
- 「そして(and)」で説明できる
- 「この関数はデータを検証して、保存して、メールを送る」のように「〜して」が複数ある
- 変更理由が複数ある
- 「メールのテンプレートを変えたい」「データベースの構造を変えたい」など、異なる理由で同じコードを修正することになる
- 部分的に使いたいと思う場面がある
- 「この処理の検証部分だけ別の場所でも使いたい」と思ったら分割のサイン
【実習】コードの責任範囲を特定する
では実習に移りましょう。以下のコードを見て、それぞれの責任範囲を特定し、どのように分割すべきか考えてみましょう。
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. ユーザー検証
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)
分割の効果を考える
このように分割すると、以下のメリットが得られます:
- 理解しやすさの向上:
- 各関数名が「何をするか」を明確に表現しているので、コードを読む人はすぐに処理の流れを理解できます。
- 各関数の中身も短く、一つの責任に集中しているので把握しやすいです。
- テストのしやすさ:
- 例えば
calculateTotalPriceだけをテストしたい場合、簡単に単体テストが書けます。 - 分割前だと、特定の処理だけをテストするのが難しかったでしょう。
- 例えば
- 再利用性の向上:
- 例えば
validateUserは他の場所(ユーザー登録時など)でも使えます。 applyCouponも別の割引計算で再利用できるでしょう。
- 例えば
- 変更のしやすさ:
- メール送信の形式を変更したい場合、
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回:モジュール分割の基本原則 – 質疑応答
ここで他の生徒から質問が来ましたので、それらにお答えしていきます。
質問1:どのくらい小さくすれば十分なのでしょうか?
生徒A:「関数は小さく」というアドバイスはわかるのですが、どこまで小さくすればいいのでしょうか?1行の関数でも作るべきですか?
回答: 良い質問です!「小さく」の定義は絶対的なものではありません。1行だけのためにわざわざ関数を作る必要はないでしょう。大切なのは「意味のあるまとまり」です。
一般的なガイドラインとしては:
- 関数は「一つのこと」をすべきです(単一責任)
- 画面内で関数全体が見えるサイズが理想的(スクロールなしで20〜30行程度)
- 関数名で明確に説明できる処理のまとまりにします
例えば、calculateTax(price)のような1行の関数でも、その計算が複雑になる可能性があったり、複数の場所で使うなら分離する価値があります。ただ単に行数を減らすためだけの分割は避けましょう。
質問2:オブジェクト指向との関係は?
生徒B:単一責任の原則はオブジェクト指向プログラミングの概念ですが、関数型プログラミングでも同じように適用できますか?
回答: はい、単一責任の原則はオブジェクト指向から来た概念ですが、関数型プログラミングにも非常に適合します。実際、関数型プログラミングでは「純粋関数」という概念があり、これは一つの明確な仕事だけをする関数を指します。
関数型プログラミングでは:
- 関数は入力を受け取り、計算して結果を返すだけの単一の責任を持つ
- 副作用(状態変更、外部とのやり取り)を最小限にする
- 小さな関数を組み合わせて複雑な操作を実現する
例えば先ほどの例では、calculateTotalPriceやapplyCouponは純粋関数として設計できます。一方、sendOrderConfirmationEmailはメール送信という副作用があるため、別の関数として分離するのが良いでしょう。
質問3:実務でのバランスは?
生徒C:実際の業務では時間的制約もあります。どこまで細かく分割するべきかのバランスは、どう取るべきでしょうか?
回答: 実務では確かに完璧よりも実用性が重要です。以下のようなバランスを取るといいでしょう:
- 重要度と頻度でプライオリティをつける:
- 頻繁に変更される部分、ビジネスの中核機能は丁寧に分割する
- あまり変更されない周辺機能は、必要になったときに分割する
- 段階的なリファクタリング:
- 完璧を一度に目指さず、少しずつ改善していく
- 新機能追加のついでに、関連する部分を少しずつ整理する
- 「3回ルール」を活用する:
- 同じようなコードが3回以上出てきたら抽象化を検討する
- それまでは重複があっても許容する
- チームの理解度を考慮:
- チームメンバー全員が理解できるレベルの抽象化にする
- 過度に複雑な設計より、シンプルで理解しやすいコードの方が実務では価値がある
何よりも「メンテナンス性」を意識することが大切です。将来の自分や他の開発者が理解しやすく、変更しやすいコードを目指しましょう。
質問4:大きなクラスの分割方法は?
生徒D:既存の大きなクラス(1000行以上)を分割するとき、どのように始めるべきですか?いきなり全部を変えるのは怖いです。
回答: 大きなクラスを分割するのは確かに難しい作業です。以下のように段階的に進めると安全です:
- まずテストを書く:
- 既存の機能が壊れないことを確認するテストを用意する
- これが安全網となり、リファクタリングの自信につながる
- 責任の分析:
- そのクラスが持つ責任を紙に書き出してみる
- メソッドをグループ化し、関連するものをまとめる
- 抽出候補を特定:
- 最も独立性が高く、他への依存が少ないグループから始める
- データアクセス、ユーティリティ機能などは分離しやすい
- 段階的に抽出:
- 一度にすべてを変更せず、一つのグループずつ抽出する
- 各ステップでテストを実行し、機能が壊れていないか確認する
- 依存関係の整理:
- 抽出した新クラスと元のクラスの関係を明確にする
- 必要に応じてインターフェースを導入して結合度を下げる
実際の例としては、例えばUserManagerという大きなクラスがあれば:
- データアクセス部分を
UserRepositoryとして抽出 - 認証関連を
UserAuthenticatorとして抽出 - プロフィール管理を
ProfileManagerとして抽出
というように、段階的に進めていきます。
質問5:コードの分割と実行速度のトレードオフは?
生徒E:関数呼び出しはオーバーヘッドがあると聞きました。小さな関数に分けることで、実行速度が落ちる心配はありませんか?
回答: 理論的には関数呼び出しにはオーバーヘッドがありますが、現代のプログラミング環境では、以下の理由からほとんど心配する必要はありません:
- 最適化の進化:
- 現代のコンパイラやJITは非常に賢く、インライン化などの最適化を自動的に行う
- 小さな関数は特にインライン化されやすい
- ボトルネックは別にある:
- 実際のパフォーマンスボトルネックは通常、関数呼び出しではなく、I/O操作やアルゴリズムの選択
- 「早すぎる最適化は諸悪の根源」という格言があるように、まずは正しく動くきれいなコードを書くべき
- メンテナンス性の価値:
- 仮に微小なパフォーマンス低下があったとしても、コードの理解しやすさ、保守性、拡張性の向上の方が一般的には価値が高い
- 本当にパフォーマンスクリティカルな部分が判明した場合のみ、最適化を検討する
実際の開発では「まず測定し、それから最適化する」のが鉄則です。プロファイリングツールで実際のボトルネックを特定してから対応しましょう。関数分割による理論上のオーバーヘッドを心配するより、コードの品質向上に注力する方が結果的には良いコードにつながります。
これらの質問はとても良いポイントですね。モジュール分割は理論だけでなく、実践の中でバランス感覚を身につけていくものです。何か他に質問はありますか?
それでは演習問題に取り組んでみてください!次回の講義でお会いしましょう。