15
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

今回は、より良いコードを書くための実践的な考え方、手法をまとめてみました。

コードは動けばいいという考えを変えよう

コードが動作すればいいという考え方は、習熟度によっては適切かもしれませんが、もう一つレベルを上げるためには、コードの品質にも気を配る必要があります。
そこで、リファクタリングについて学んでみましょう。

リファクタリング

リファクタリングとは、コードの品質を向上させるための手法です。
コードの振る舞いを変えずに、コードの構造を変更することで、コードの理解のしやすさ、再利用性、保守性を向上させることができます。

リファクタリングをする際の観点を考えてみます。

  1. テストのしやすさ
  2. 可読性
  3. 再利用性
  4. 拡張性
  5. パフォーマンス

テストのしやすさ

テストのしやすいコードとは、コードを分離・簡素化することです。

コードの分離・簡素化をするためには下記のような手法があります。

  • 関数やクラスの責務を明確化し、モジュールごとに独立性を保つ
  • 外部依存をインターフェースやモックオブジェクトに置き換えられる設計にする
  • 特定の入力と出力が明確な純粋関数を作る

これらの手法を用いることで、テストのしやすいコードを作ることができます。
実際に、テストしにくいコードとテストしやすいコードの例を挙げてみます。
今回の扱うテーマは、ショッピングサイトの注文処理です。

// テストがしにくいコード
function calculateTotalPrice(cartItems: any[], coupon: any, taxRate: number, shippingCost: number): number {
  let total = 0;

  // カート内のアイテムの合計金額を計算
  for (let i = 0; i < cartItems.length; i++) {
    total += cartItems[i].price * cartItems[i].quantity;
  }

  // クーポン割引を適用
  if (coupon) {
    if (coupon.type === 'percentage') {
      total -= total * coupon.value;
    } else if (coupon.type === 'fixed') {
      total -= coupon.value;
    }
  }

  // 配送コストを追加
  total += shippingCost;

  // 税金を追加
  total += total * taxRate;

  return total;
}
// テストがしやすいコード
function calculateTotalPrice(cartItems: any[], coupon: any, taxRate: number, shippingCost: number): number {
  let total = calculateItemsTotal(cartItems);
  total -= applyCouponDiscount(total, coupon);
  total += shippingCost;
  total += calculateTax(total, taxRate);
  return total;
}

function calculateItemsTotal(cartItems: any[]): number {
  return cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

function applyCouponDiscount(total: number, coupon: any): number {
  if (!coupon) return 0;
  if (coupon.type === 'percentage') {
    return total * coupon.value;
  } else if (coupon.type === 'fixed') {
    return coupon.value;
  }
  return 0;
}

function calculateTax(total: number, taxRate: number): number {
  return total * taxRate;
}

これは、calculateTotalPriceという関数が、calculateItemsTotal、applyCouponDiscount、calculateTaxという関数に分割されています。
これにより関数の分離を行い、一つひとつの関数の役割・責務が明確になりテストケースを考えるのが容易になります。
テストコードは下記のようなものになります。

テストコード(長いので折りたたみにしています。)
describe('calculateTotalPrice', () => {
  // calculateTotalPrice関数がパーセンテージクーポンを正しく適用して合計金額を計算するかをテスト
  it('should calculate total price correctly with percentage coupon', () => {
    const cartItems = [{ price: 100, quantity: 2 }, { price: 200, quantity: 1 }];
    const coupon = { type: 'percentage', value: 0.1 };
    const taxRate = 0.05;
    const shippingCost = 50;
    const result = calculateTotalPrice(cartItems, coupon, taxRate, shippingCost);
    expect(result).toBeCloseTo(315);
  });

  // calculateTotalPrice関数が固定額クーポンを正しく適用して合計金額を計算するかをテスト
  it('should calculate total price correctly with fixed coupon', () => {
    const cartItems = [{ price: 100, quantity: 2 }, { price: 200, quantity: 1 }];
    const coupon = { type: 'fixed', value: 50 };
    const taxRate = 0.05;
    const shippingCost = 50;
    const result = calculateTotalPrice(cartItems, coupon, taxRate, shippingCost);
    expect(result).toBeCloseTo(350);
  });

  // calculateTotalPrice関数がクーポンなしで合計金額を正しく計算するかをテスト
  it('should calculate total price correctly without coupon', () => {
    const cartItems = [{ price: 100, quantity: 2 }, { price: 200, quantity: 1 }];
    const coupon = null;
    const taxRate = 0.05;
    const shippingCost = 50;
    const result = calculateTotalPrice(cartItems, coupon, taxRate, shippingCost);
    expect(result).toBeCloseTo(420);
  });

  // calculateTotalPrice関数が税率がゼロの場合に合計金額を正しく計算するかをテスト
  it('should calculate total price correctly with zero tax rate', () => {
    const cartItems = [{ price: 100, quantity: 2 }, { price: 200, quantity: 1 }];
    const coupon = { type: 'percentage', value: 0.1 };
    const taxRate = 0;
    const shippingCost = 50;
    const result = calculateTotalPrice(cartItems, coupon, taxRate, shippingCost);
    expect(result).toBeCloseTo(360);
  });

  // calculateTotalPrice関数が配送コストがゼロの場合に合計金額を正しく計算するかをテスト
  it('should calculate total price correctly with zero shipping cost', () => {
    const cartItems = [{ price: 100, quantity: 2 }, { price: 200, quantity: 1 }];
    const coupon = { type: 'fixed', value: 50 };
    const taxRate = 0.05;
    const shippingCost = 0;
    const result = calculateTotalPrice(cartItems, coupon, taxRate, shippingCost);
    expect(result).toBeCloseTo(300);
  });
});

describe('calculateItemsTotal', () => {
  // calculateItemsTotal関数がカート内のアイテムの合計金額を正しく計算するかをテスト
  it('should calculate items total correctly', () => {
    const cartItems = [{ price: 100, quantity: 2 }, { price: 200, quantity: 1 }];
    const result = calculateItemsTotal(cartItems);
    expect(result).toBe(400);
  });

  // calculateItemsTotal関数が空のカートの場合に0を返すかをテスト
  it('should return 0 for empty cart', () => {
    const cartItems = [];
    const result = calculateItemsTotal(cartItems);
    expect(result).toBe(0);
  });

  // calculateItemsTotal関数が数量がゼロのアイテムを含む場合に合計金額を正しく計算するかをテスト
  it('should calculate items total correctly with zero quantity', () => {
    const cartItems = [{ price: 100, quantity: 0 }, { price: 200, quantity: 1 }];
    const result = calculateItemsTotal(cartItems);
    expect(result).toBe(200);
  });

  // calculateItemsTotal関数が価格がゼロのアイテムを含む場合に合計金額を正しく計算するかをテスト
  it('should calculate items total correctly with zero price', () => {
    const cartItems = [{ price: 0, quantity: 2 }, { price: 200, quantity: 1 }];
    const result = calculateItemsTotal(cartItems);
    expect(result).toBe(200);
  });
});

describe('applyCouponDiscount', () => {
  // applyCouponDiscount関数がパーセンテージ割引を正しく適用するかをテスト
  it('should apply percentage discount correctly', () => {
    const total = 400;
    const coupon = { type: 'percentage', value: 0.1 };
    const result = applyCouponDiscount(total, coupon);
    expect(result).toBe(40);
  });

  // applyCouponDiscount関数が固定額割引を正しく適用するかをテスト
  it('should apply fixed discount correctly', () => {
    const total = 400;
    const coupon = { type: 'fixed', value: 50 };
    const result = applyCouponDiscount(total, coupon);
    expect(result).toBe(50);
  });

  // applyCouponDiscount関数がクーポンが提供されていない場合に0を返すかをテスト
  it('should return 0 if no coupon is provided', () => {
    const total = 400;
    const result = applyCouponDiscount(total, null);
    expect(result).toBe(0);
  });

  // applyCouponDiscount関数がクーポンの値が0の場合に0を返すかをテスト
  it('should return 0 if coupon value is 0', () => {
    const total = 400;
    const coupon = { type: 'percentage', value: 0 };
    const result = applyCouponDiscount(total, coupon);
    expect(result).toBe(0);
  });

  // applyCouponDiscount関数がクーポンのタイプが不明な場合に0を返すかをテスト
  it('should return 0 if coupon type is unknown', () => {
    const total = 400;
    const coupon = { type: 'unknown', value: 50 };
    const result = applyCouponDiscount(total, coupon);
    expect(result).toBe(0);
  });
});

describe('calculateTax', () => {
  // calculateTax関数が税金を正しく計算するかをテスト
  it('should calculate tax correctly', () => {
    const total = 400;
    const taxRate = 0.05;
    const result = calculateTax(total, taxRate);
    expect(result).toBe(20);
  });

  // calculateTax関数が税率が0の場合に0を返すかをテスト
  it('should return 0 if tax rate is 0', () => {
    const total = 400;
    const taxRate = 0;
    const result = calculateTax(total, taxRate);
    expect(result).toBe(0);
  });

  // calculateTax関数が合計金額が0の場合に0を返すかをテスト
  it('should return 0 if total is 0', () => {
    const total = 0;
    const taxRate = 0.05;
    const result = calculateTax(total, taxRate);
    expect(result).toBe(0);
  });
});

可読性

可読性とは、コードの理解のしやすさです。
コードを読む人が理解しやすいように、コードを分かりやすく書くことができます。

読む人がわかりやすいように、また未来の自分がもう一度コードを読んだ時に理解しやすいようにします。

  • 意味のある変数名や関数名を使う
  • ネストを浅く保つために、早期リターン・ガード節を使う(循環複雑度を減らす)
  • 複雑なロジックをコメントで説明するのではなく、関数を小さく分割し、説明的にする(説明関数)

ガード節とは、条件分岐の中で、条件が成立しない場合に早期リターンすることで、ネストを浅く保つことができる手法です。
具体的には、下記のようなコードが挙げられます。

// ガード節を使用しない場合(クーポンの割引額適用のロジック)
function applyDiscount(amount: number, discountRate: number): number {
  if (amount > 10000) {
    return amount * discountRate;
  }
  return 0;
}

// ガード節を使用した場合
function applyDiscount(amount: number, discountRate: number): number {
  if (amount <= 0) return 0;
  if (amount > 10000) return amount * discountRate;
  return 0;
}

循環複雑度とは、コードの複雑さを測る指標です。
循環複雑度が高いコードは、テストのしにくさ、保守性の低さ、可読性の低さを示しています。

循環複雑度を減らすためには、下記のような手法があります。

  • 関数を小さく分割する
  • 関数の引数を減らす
  • 関数の責務を減らす

下記に循環複雑度が高いコードと低いコードの例を挙げてみます。

// 循環複雑度が高いコード
function processOrder(order) {
  let total = 0;
  // 注文にアイテムがあるか確認
  if (order.items && order.items.length > 0) {
    for (let i = 0; i < order.items.length; i++) {
      let item = order.items[i];
      // アイテムの価格が0より大きいか確認
      if (item.price > 0) {
        // アイテムの数量が0より大きいか確認
        if (item.quantity > 0) {
          // アイテムの価格と数量を掛けて合計に加算
          total += item.price * item.quantity;
          // 割引がある場合の処理
          if (item.discount) {
            // 割引がパーセンテージの場合
            if (item.discount.type === 'percentage') {
              total -= (item.price * item.quantity) * (item.discount.value / 100);
            // 割引が固定値の場合
            } else if (item.discount.type === 'fixed') {
              total -= item.discount.value;
            }
          }
        }
      }
    }
  }
  // 配送方法による追加料金の処理
  if (order.shipping) {
    if (order.shipping.type === 'express') {
      total += 20; // エクスプレス配送の場合
    } else if (order.shipping.type === 'standard') {
      total += 5; // 標準配送の場合
    }
  }
  // 税金の計算
  if (order.tax) {
    total += total * (order.tax / 100);
  }
  return total; // 合計金額を返す
}

上記のコードの悪い点を考えてみます。

  1. ネストが深い
  2. ロジックが複雑
  3. ロジックが重複している

修正の仕方として下記の観点で考えてみます。

  1. ネストを浅くできないか

ネストの深さを減らすため、条件文を早期リターンや早期スキップに変更していきます。

function processOrder(order) {
  let total = 0;

  // 注文にアイテムがない場合、合計は0を返す
  if (!order.items || order.items.length === 0) return total;

  // 各アイテムについて処理を行う
  for (let item of order.items) {
    // アイテムの価格または数量が0以下の場合、スキップ
    if (item.price <= 0 || item.quantity <= 0) continue;

    // アイテムの価格と数量を掛けて小計を計算
    let itemTotal = item.price * item.quantity;

    // 割引がある場合の処理
    if (item.discount) {
      // 割引がパーセンテージの場合
      if (item.discount.type === 'percentage') {
        itemTotal -= itemTotal * (item.discount.value / 100);
      // 割引が固定値の場合
      } else if (item.discount.type === 'fixed') {
        itemTotal -= item.discount.value;
      }
    }

    // アイテムの小計を合計に加算
    total += itemTotal;
  }

  // 配送方法による追加料金の処理
  if (order.shipping) {
    total += order.shipping.type === 'express' ? 20 : 5;
  }

  // 税金の計算
  if (order.tax) {
    total += total * (order.tax / 100);
  }

  // 合計金額を返す
  return total;
}

これだけでも、先程よりは読みやすくなりました。

  1. ロジックを分割できないか

次に、ロジックを分割できないかを考えてみます。
まず、処理全体として、どのようなことが行われているかを整理します。

  • アイテムの合計金額を計算
  • 配送の追加料金を計算
  • 税金を計算

これらをまとめてしまっているので、分割していきます。
ロジックを分割する際の考え方として、order.itemsの処理が、1つのコードブロックに集中しているため、これを切り出します。
まず、アイテム1つあたりの計算を担当する関数を作成します。

// アイテムの合計金額を計算する関数
function calculateItemTotal(item) {
  // 価格または数量が0以下の場合は0を返す
  if (item.price <= 0 || item.quantity <= 0) return 0;

  // アイテムの価格と数量を掛けて小計を計算
  let total = item.price * item.quantity;

  // 割引がある場合の処理
  if (item.discount) {
    // 割引がパーセンテージの場合
    if (item.discount.type === 'percentage') {
      total -= total * (item.discount.value / 100);
    // 割引が固定値の場合
    } else if (item.discount.type === 'fixed') {
      total -= item.discount.value;
    }
  }

  // 計算されたアイテムの小計を返す
  return total;
}

// 注文全体の合計金額を計算する関数
function processOrder(order) {
  let total = 0;

  // アイテムの合計金額を計算
  if (order.items) {
    for (let item of order.items) {
      total += calculateItemTotal(item);
    }
  }

  // 配送方法による追加料金の処理
  if (order.shipping) {
    total += order.shipping.type === 'express' ? 20 : 5;
  }

  // 税金の計算
  if (order.tax) {
    total += total * (order.tax / 100);
  }

  // 合計金額を返す
  return total;
}

アイテムの計算をcalclateItemTotalに移動します。
これで、processOrderから1つのアイテム計算という責務が外れました。

次に、配送コストの計算を分割していきます。

~~~
// 配送コストを計算する関数
function calculateShippingCost(shipping) {
  // 配送情報がない場合は0を返す
  if (!shipping) return 0;
  // 配送タイプがエクスプレスの場合は20、それ以外の場合は5を返す
  return shipping.type === 'express' ? 20 : 5;
}

// 注文全体の合計金額を計算する関数
function processOrder(order) {
  let total = 0;

  // アイテムの合計金額を計算
  if (order.items) {
    for (let item of order.items) {
      total += calculateItemTotal(item);
    }
  }

  // 配送コストを合計に追加
  total += calculateShippingCost(order.shipping);

  // 税金を合計に追加
  if (order.tax) {
    total += total * (order.tax / 100);
  }

  // 合計金額を返す
  return total;
}

配送コストの計算を calculateShippingCost に移動しました。
processOrder から配送ロジックを切り離し、責務をさらに分散することができました。

最後に、税金の計算を分割していきます。

~~~
// 税金を計算する関数
function calculateTax(total, taxRate) {
  // 税率が指定されていない場合は合計金額をそのまま返す
  if (!taxRate) return total;
  // 税率を適用して新しい合計金額を返す
  return total + total * (taxRate / 100);
}

// 注文全体の合計金額を計算する関数
function processOrder(order) {
  let total = 0;

  // アイテムの合計金額を計算
  if (order.items) {
    for (let item of order.items) {
      total += calculateItemTotal(item);
    }
  }

  // 配送コストを合計に追加
  total += calculateShippingCost(order.shipping);
  // 税金を合計に追加
  total = calculateTax(total, order.tax);

  // 合計金額を返す
  return total;
}
  1. ロジックを共通化できないか

今回は重複している処理というよりも、高階関数(配列の操作メソッド)を使用することで更にロジックをシンプルにすることができます。

// 循環複雑度が低いコード
// 注文全体の合計金額を計算する関数
function processOrder(order) {
  let total = 0;
  // アイテムの合計金額を計算し、合計に追加
  total += calculateItemsTotal(order.items);
  // 配送コストを計算し、合計に追加
  total += calculateShipping(order.shipping);
  // 税金を計算し、合計に追加
  total += calculateTax(total, order.tax);
  // 合計金額を返す
  return total;
}

// カート内のアイテムの合計金額を計算する関数
function calculateItemsTotal(items) {
  // 各アイテムの合計金額を計算し、合計する
  return (items || []).reduce((sum, item) => sum + calculateItemTotal(item), 0);
}

// 単一アイテムの合計金額を計算する関数
function calculateItemTotal(item) {
  // 価格または数量がゼロ以下の場合は0を返す
  if (item.price <= 0 || item.quantity <= 0) return 0;

  // アイテムの価格と数量を掛けて合計を計算
  let total = item.price * item.quantity;
  // 割引を適用
  total -= applyDiscount(item);
  // 割引後の合計を返す
  return total;
}

// 割引を適用する関数
function applyDiscount(item) {
  // 割引がない場合は0を返す
  if (!item.discount) return 0;
  // パーセンテージ割引を適用
  if (item.discount.type === 'percentage') {
    return (item.price * item.quantity) * (item.discount.value / 100);
  // 固定額割引を適用
  } else if (item.discount.type === 'fixed') {
    return item.discount.value;
  }
  // 不明な割引タイプの場合は0を返す
  return 0;
}

// 配送コストを計算する関数
function calculateShipping(shipping) {
  // 配送タイプに応じたコストを返す
  const shippingCosts = { express: 20, standard: 5 };
  return shipping ? shippingCosts[shipping.type] || 0 : 0;
}

// 税金を計算する関数
function calculateTax(total, tax) {
  // 税率が指定されている場合は税金を計算して返す
  return tax ? total * (tax / 100) : 0;
}

ただし、共通化する際の注意点として、下記のようなことが挙げられます。

  1. 過度な抽象化を避ける

    • 共通化することで却って理解が難しくなる場合は、共通化を見送る
    • 将来の変更を過度に予測した抽象化は避ける
  2. 責務の明確化

    • 共通化した関数の役割が明確であること
    • 単一責任の原則に従い、一つの関数は一つの責務のみを持つ
  3. 依存関係の管理

    • 共通化によって不必要な依存関係を作らない
    • 共通化した関数が他のモジュールに与える影響を考慮する
  4. テスト容易性の確保

    • 共通化した関数が適切にテストできることを確認
    • テストケースが複雑になりすぎないよう注意
  5. 命名の重要性

    • 共通化した関数の名前は、その役割を適切に表現する
    • 汎用的すぎる名前は避ける

このように、全体としてのコード量は、循環複雑度が低いコードの方が多くなりますが、
一つ一つのロジックがシンプルになり、それぞれの関数が独立しているため、テストのしやすさ、可読性が高くなります。

再利用性

再利用性とは、コードを再度利用することができるかどうかを指します。
再利用性の高いコードは、開発速度を落とさずに、新しい機能や、バグ修正を行うことができます。
再利用性の高いコードを書くために意識することは、汎用的で、単一の責務に特化した設計を行うことが重要です。

再利用性の低いコードの特徴として、下記のようなことが挙げられます。

  • ハードコーディング
  • 依存性が高い
  • 汎用性が低い(特定のケースでしか使用されない)
  • ドキュメントやコメントが少ない(意図や使い方が分かりづらい)
  • ロジックが複雑

これらはテストのしやすさにも影響します。

先ほどの可読性の項目で最終的に示したコードを再利用性の高いコードとしてみてみます。
修正観点は下記の通りです。

  1. 関数の分離をさらに行う
  2. 型安全性
  3. 定数やロジックを外部化
  4. バリデーションロジックの作成
// 定数やロジックを外部化
const SHIPPING_COSTS = { express: 20, standard: 5 };

export function processOrder(order: Order): number {
  const itemsTotal = calculateItemsTotal(order.items);
  const shippingCost = calculateShipping(order.shipping);
  const tax = calculateTax(itemsTotal + shippingCost, order.taxRate);

  return itemsTotal + shippingCost + tax;
}

// アイテム合計の計算
function calculateItemsTotal(items: Item[] = []): number {
  return items.reduce((sum, item) => sum + calculateItemTotal(item), 0);
}

// アイテムごとの合計を計算
function calculateItemTotal(item: Item): number {
  if (!isValidItem(item)) return 0;

  const baseTotal = item.price * item.quantity;
  const discount = applyDiscount(item);

  return baseTotal - discount;
}

// 割引計算
function applyDiscount(item: Item): number {
  if (!item.discount) return 0;

  switch (item.discount.type) {
    case 'percentage':
      return (item.price * item.quantity) * (item.discount.value / 100);
    case 'fixed':
      return item.discount.value;
    default:
      return 0;
  }
}

// 配送料計算
function calculateShipping(shipping?: Shipping): number {
  return shipping ? SHIPPING_COSTS[shipping.type] || 0 : 0;
}

// 税金計算
function calculateTax(amount: number, taxRate?: number): number {
  return taxRate ? amount * (taxRate / 100) : 0;
}

// アイテムの妥当性チェック
function isValidItem(item: Item): boolean {
  return item.price > 0 && item.quantity > 0;
}

// 型定義
export interface Order {
  items: Item[];
  shipping?: Shipping;
  taxRate?: number;
}

export interface Item {
  price: number;
  quantity: number;
  discount?: Discount;
}

export interface Discount {
  type: 'percentage' | 'fixed';
  value: number;
}

export interface Shipping {
  type: 'express' | 'standard' | string;
}

このような形で、型定義や、定数やロジックを外部化することで、再利用性が高くなります。
型定義に関しては、外部ファイルに切り出すことで、モジュールの依存度を下げることができます。

拡張性

拡張性は、再利用性と観点が重複する点も多いですが、目的が異なります。
拡張性とは、既存のコードを変更せずに、新しい機能や動作を追加することができるかどうかを指します。
設計時に拡張性を意識することが重要です。

下記の観点で、拡張性を高めていきます。

  1. クラスやモジュールの活用
  2. データやロジックの分離(デザインパターンを活用)
  3. インターフェースや抽象クラスの活用
  4. 単一責務の原則の遵守

実際に拡張性の高いコードを書いてみます。

// 金額を表すクラス
class Money {
  constructor(private amount: number) {}

  // 金額がゼロのMoneyオブジェクトを返す
  static zero(): Money {
    return new Money(0);
  }

  // 他のMoneyオブジェクトを加算する
  add(other: Money): Money {
    return new Money(this.amount + other.amount);
  }

  // 他のMoneyオブジェクトを減算する
  subtract(other: Money): Money {
    return new Money(this.amount - other.amount);
  }

  // 金額を指定した係数で乗算する
  multiply(factor: number): Money {
    return new Money(this.amount * factor);
  }

  // 金額を取得する
  getAmount(): number {
    return this.amount;
  }
}

// 割引戦略を表すインターフェース
interface DiscountStrategy {
  calculate(price: Money, quantity: number, value: number): Money;
}

// パーセンテージ割引を実装するクラス
class PercentageDiscount implements DiscountStrategy {
  // パーセンテージ割引を計算する
  calculate(price: Money, quantity: number, value: number): Money {
    return price.multiply(quantity).multiply(value / 100);
  }
}

// 固定額割引を実装するクラス
class FixedDiscount implements DiscountStrategy {
  // 固定額割引を計算する
  calculate(_: Money, __: number, value: number): Money {
    return new Money(value);
  }
}

// 配送料計算を表すインターフェース
interface ShippingCalculator {
  calculate(shippingType: string): Money;
}

// デフォルトの配送料計算を実装するクラス
class DefaultShippingCalculator implements ShippingCalculator {
  private shippingCosts: Record<string, number>;

  constructor(shippingCosts: Record<string, number>) {
    this.shippingCosts = shippingCosts;
  }

  // 配送料を計算する
  calculate(shippingType: string): Money {
    return new Money(this.shippingCosts[shippingType] || 0);
  }
}

// アイテムを表すインターフェース
interface Item {
  price: number;
  quantity: number;
  discount?: {
    type: string;
    value: number;
  };
}

// 配送情報を表すインターフェース
interface Shipping {
  type: string;
}

// 注文を表すインターフェース
interface Order {
  items: Item[];
  shipping?: Shipping;
  tax?: number;
}

// 注文結果を表すインターフェース
interface OrderResult {
  subtotal: Money;
  shipping: Money;
  tax: Money;
  total: Money;
}

// 注文処理を行うクラス
class OrderProcessor {
  private discountStrategies: Record<string, DiscountStrategy>;
  private shippingCalculator: ShippingCalculator;

  constructor(
    discountStrategies: Record<string, DiscountStrategy>,
    shippingCalculator: ShippingCalculator
  ) {
    this.discountStrategies = discountStrategies;
    this.shippingCalculator = shippingCalculator;
  }

  // 注文を処理し、結果を返す
  process(order: Order): OrderResult {
    const subtotal = this.calculateItemsTotal(order.items);
    const shipping = this.calculateShipping(order.shipping);
    const tax = this.calculateTax(subtotal.add(shipping), order.tax);
    const total = subtotal.add(shipping).add(tax);

    return {
      subtotal,
      shipping,
      tax,
      total
    };
  }

  // アイテムの合計金額を計算する
  private calculateItemsTotal(items: Item[]): Money {
    return (items || []).reduce(
      (sum, item) => sum.add(this.calculateItemTotal(item)), 
      Money.zero()
    );
  }

  // 単一アイテムの合計金額を計算する
  private calculateItemTotal(item: Item): Money {
    if (item.price <= 0 || item.quantity <= 0) return Money.zero();

    const itemPrice = new Money(item.price);
    const total = itemPrice.multiply(item.quantity);
    const discount = this.applyDiscount(item, itemPrice);
    return total.subtract(discount);
  }

  // 割引を適用する
  private applyDiscount(item: Item, itemPrice: Money): Money {
    if (!item.discount) return Money.zero();
    
    const discountStrategy = this.discountStrategies[item.discount.type];
    if (!discountStrategy) {
      console.warn(`Unknown discount type: ${item.discount.type}`);
      return Money.zero();
    }
    
    return discountStrategy.calculate(itemPrice, item.quantity, item.discount.value);
  }

  // 配送料を計算する
  private calculateShipping(shipping?: Shipping): Money {
    if (!shipping) return Money.zero();
    return this.shippingCalculator.calculate(shipping.type);
  }

  // 税金を計算する
  private calculateTax(total: Money, tax?: number): Money {
    return tax ? total.multiply(tax / 100) : Money.zero();
  }
}

実際に使用する場合は、下記のようになります。

// 使用例
// 割引戦略を定義
const discountStrategies = {
  percentage: new PercentageDiscount(), // パーセンテージ割引
  fixed: new FixedDiscount() // 固定額割引
};

// 配送料を定義
const shippingCosts = {
  express: 2000, // エクスプレス配送のコスト
  standard: 500 // 標準配送のコスト
};

// 配送料計算機を作成
const shippingCalculator = new DefaultShippingCalculator(shippingCosts);

// 注文処理クラスを作成
const orderProcessor = new OrderProcessor(discountStrategies, shippingCalculator);

// 注文データを定義
const order: Order = {
  items: [
    { price: 1000, quantity: 2, discount: { type: 'percentage', value: 10 } }, // 割引ありのアイテム
    { price: 2000, quantity: 1 } // 割引なしのアイテム
  ],
  shipping: { type: 'standard' }, // 標準配送
  tax: 10 // 税率
};

// 注文を処理し、結果を取得
const result = orderProcessor.process(order);

このように、新たにMoneyクラスや、OrderResultのインターフェースを作成したことや、OrderProcessorクラスのprocessメソッドを作成したことで、拡張性が高くなりました。

実際に仕様変更が来た場合の変更方法について考えてみます。

  1. 新しい割引タイプの追加
  • 対応方法
    • 新しい DiscountStrategy クラスを実装するだけで対応可能。
    • 現在の OrderProcessor にはStrategyを注入する仕組みがあるため、既存のコードに影響を与えずに実装可能。
  1. 配送料が動的に変動する場合(例えば、重量や距離に応じて変動)
  • 対応方法
    • ShippingCalculator を拡張または新たに実装し、重量や距離を引数として受け取る設計にする。
    • OrderProcessor に変更を加える必要がないため、影響範囲が限定的。
  1. 複数の税率が存在する場合
  • 対応方法
    • Order 内の Item に taxRate プロパティを追加。
    • calculateItemsTotal や calculateTax を調整して、商品ごとに税率を考慮。
  1. 複数の通貨が利用される場合
  • 対応方法
    • Money クラスを通貨ごとに拡張し、通貨ごとに計算を行う。
    • 通貨の変換ロジックをMoneyクラスに追加する。
  1. 特定の商品を購入すると、別の商品が割引される場合
  • 対応方法
    • 特定のプロモーション用の戦略クラス(例: PromotionStrategy)を導入し、OrderProcessor に新たな計算ステップを追加。

これらの変更に対して、おおよそ既存のコードに影響を与えずに、新たな機能・ロジックを追加することができます。

パフォーマンス

パフォーマンスは、コードの実行速度やメモリ使用量などを指します。
パフォーマンスを高めるためには、下記の観点で考えていきます。

  1. データ構造の最適化
  2. アルゴリズムの最適化
  3. メモリ管理の最適化

これらの観点でパフォーマンスを高めていきます。
特に、計算量(オーダー)を意識できるようになると、速いコードを書くことができます。

計算量のオーダーとは、アルゴリズムの実行時間が入力サイズに対してどのように増加するかを示す尺度です。
O(n)は、入力サイズに比例して実行時間が増加するアルゴリズムを表します。
O(n^2)は、入力サイズの二乗に比例して実行時間が増加するアルゴリズムを表します。
O(log n)は、入力サイズが増加しても実行時間がほぼ一定になるアルゴリズムを表します。
詳しくは、オーダー(計算量)とはの記事が参考になるかと思います。

計算量、データ構造、アルゴリズムの最適化に関しては、慣れが必要な部分が多いと思います。
アルゴリズムとデータ構造は、密に関連しているため、アルゴリズムの学習をするときに、データ構造の学習も同時に行うと良いと思います。
具体的な学習方法としては、競技プログラミングにチャレンジしてみると良いかもしれません。

おすすめの書籍を下記に挙げておきます。
競技プログラミングの鉄則

まとめ

今回は、良いコードを書くための観点について、それぞれの観点について、どのようにコードを書くと良いかを紹介しました。
参考になれば幸いです。

15
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?