iimon TECH BLOG

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

積読解消プロジェクト「リファクタリング(第2版)既存のコードを安全に改善する」Part2

1. はじめに

株式会社iimonでエンジニアをしている齋藤です。

以前、「リファクタリング(第2版)既存のコードを安全に改善する」という本を読んでテックブログを書きました。前回はリファクタリングする意味やメリットなどを書いた4章までの感想でした。

tech.iimon.co.jp

今回は6章から12章までの具体的なリファクタリング方法の一部をまとめました。

自分の理解のためにコード例は本書にでてくるものとは異なるもので書いています。

本書では実際に方法だけでなくどのような順序でリファクタリングするかまで詳しく書いてありますので、もしこのブログを見て気になった方は本書をぜひ手にとって頂ければと思います。

2. 6章 リファクタリングはじめの一歩

パラメータオブジェクトの導入

複数の引数をひとつのオブジェクトにまとめて関数に渡すパターンです。

データを構造体にまとめることには意味があります。データ構造間の関係を明示することができるからです。新たな構造体を使えば関数のパラメーター数は少なく済みます。その構造体を使うすべての関数が、構造体の要素を取得するのに同じ名前を使うことで、一貫性の向上にも役立たちます。構造体を新たな抽象に引き上げると、問題領域の理解がすっきりしてきます。

p.146 パラメータの導入 動機 一部抜粋

→この手法を使用すると引数の順番を気にしなくなったり、シグネチャを変えずにパラメーターを追加・削除できるので積極的に使っていきたいです。3つ以上のパラメータがあったら検討するのがよさそうですね。もちろん数だけに注目するのではなく内容を見ることも重要です。

コード例

導入前

       // 配送料を計算する
      function getShippingCost(prefecture, city, street) {
        console.log(`配送先: ${prefecture} ${city} ${street}`);
        let cost = 1000;
        // 都道府県で基本料金を決定
        if (prefecture === "東京都") {
          cost = 500;
        }
        // 離島エリアは追加料金
        if (city.includes("島")) {
          cost += 300;
        }
        // 番地が遠いエリアは追加料金
        if (street.includes("山奥")) {
          cost += 500;
        }
        return cost;
      }

      // 配送可能なエリアか判定する
      function isDeliverable(prefecture, city, street) {
        // 配送不可の都道府県
        if (["沖縄県", "北海道"].includes(prefecture)) {
          return false;
        }
        // 配送不可の市区町村
        if (city.includes("離島")) {
          return false;
        }
        // 配送不可の番地
        if (street.includes("配送不可番地")) {
          return false;
        }
        return true;
      }

      // 使用:同じ3つの引数を毎回渡す
      const pref = "東京都";
      const city = "渋谷区";
      const street = "神南1-2-3";

      getShippingCost(pref, city, street); // → 500
      isDeliverable(pref, city, street); // → true

導入後

 // 住所クラスを定義
      class Address {
        constructor(prefecture, city, street) {
          this.prefecture = prefecture;
          this.city = city;
          this.street = street;
        }
        toString() {
          return `${this.prefecture} ${this.city} ${this.street}`;
        }
      }
      // 配送料を計算する
      function getShippingCost(address) {
        console.log(`配送先: ${address.toString()}`);

        let cost = 1000;
        // 都道府県で基本料金を決定
        if (address.prefecture === "東京都") {
          cost = 500;
        }
        // 離島エリアは追加料金
        if (address.city.includes("島")) {
          cost += 300;
        }
        // 番地が遠いエリアは追加料金
        if (address.street.includes("山奥")) {
          cost += 500;
        }
        return cost;
      }

      // 配送可能なエリアか判定する
      function isDeliverable(address) {
        // 配送不可の都道府県
        if (["沖縄県", "北海道"].includes(address.prefecture)) {
          return false;
        }
        // 配送不可の市区町村
        if (address.city.includes("離島")) {
          return false;
        }
        // 配送不可の番地
        if (address.street.includes("配送不可番地")) {
          return false;
        }
        return true;
      }

      // 使用:Addressを1つ渡すだけ
      const address = new Address("東京都", "渋谷区", "神南1-2-3");

      const cost = getShippingCost(address);   // → 500
      const deliverable = isDeliverable(address);     // → true

      console.log(`配送料: ${cost}円`);
      console.log(`配送可能: ${deliverable ? "はい" : "いいえ"}`);

このようにパラメータオブジェクトを導入することによって以下のメリットがあります。

  • 引数の順番を間違えるリスクがなくなる
  • データの関係性が明確になる

→導入前は prefcitystreet と3つの変数が別々に存在しており、これらが住所に関係していると分かりにくい。Address クラスという構造体にまとめることで、「この3つは住所を構成する要素である」という関係が明確になります。

  • 一貫したアクセス方法にできる

→関数によって引数名が違う可能性があるが、導入後は全て同じ名前を引数にすることができる

// 開発者Aが作った関数
function getShippingCost(prefecture, city, street) { ... }

// 開発者Bが作った関数(引数名が違う)
function isDeliverable(pref, c, addr) { ... }
// 開発者Aが作った関数
function getShippingCost(address) {
  console.log(address.prefecture, address.city, address.street);
}

// 開発者Bが作った関数(同じ名前でアクセス)
function isDeliverable(address) {
  console.log(address.prefecture, address.city, address.street);
}
  • 拡張が容易になる

    →この例でいうと、郵便番号を追加したい場合でもaddress だけに追加すればよい

本書には書いていませんが、パラメータオブジェクトを使うと下記みたいなメリットもあります。

パラメータオブジェクトでのデフォルト値の設定

通常の関数でのデフォルト値の設定方法

function createUser(name, email, age = 20, role = "user", isActive = true) {
  console.log(name, email, age, role, isActive);
}

createUser("田中", "tanaka@example.com");
// → 田中 tanaka@example.com 20 user true

createUser("田中", "tanaka@example.com", 30);
// → 田中 tanaka@example.com 30 user true

createUser("田中", "tanaka@example.com", 30, "admin");
// → 田中 tanaka@example.com 30 admin true

この場合だと、、途中のパラメーターをスキップできない場面に遭遇する

これを解決するには明示的にundefinednullを渡す必要がある。

function createUser(name, email, age = 20, role = "user", isActive = true) {
  console.log(name, email, age, role, isActive);
}

// age はデフォルトのままで、role だけ変えたい場合...
createUser("田中", "tanaka@example.com", ???, "admin");                                 
createUser("田中", "tanaka@example.com", undefined, "admin");
// → 田中 tanaka@example.com 20 admin true

// または null を入れる(でもデフォルト値は適用されない)
createUser("田中", "tanaka@example.com", null, "admin");
// → 田中 tanaka@example.com null admin true

パラメータオブジェクトをつかうことでundefinednull を使うことなくスキップ処理をすることができます

function createUser({ name, email, age = 20, role = "user", isActive = true }) {
  console.log(name, email, age, role, isActive);
}

// role だけ変えたい
createUser({ name: "田中", email: "tanaka@example.com", role: "admin" });
// → 田中 tanaka@example.com 20 admin true 

// isActive だけ変えたい
createUser({ name: "田中", email: "tanaka@example.com", isActive: false });
// → 田中 tanaka@example.com 20 user false

3. 7章 カプセル化

レコードのカプセル化

レコード構造とは関連するデータと一緒にグループ化する直感的な方法を提供し、緩いデータの群れの代わりに意味のあるデータ単位を渡すことを可能とします。

p.168 カプセル化 レコードのカプセル化 動機 一部抜粋

よく使われるレコード構造の例

// APIからのレスポンス
const response = {
  status: 200,
  data: { id: 1, name: "商品A" }
};

// 設定ファイル
const config = {
  host: "localhost",
  port: 3000,
  debug: true
};

// JSONデータ
const json = {
  users: [
    { id: 1, name: "田中" },
    { id: 2, name: "佐藤" }
  ]
};

レコード構造の厄介なところはレコードに格納されている値と計算した値の明確な区別を強要されることです

p.168 カプセル化 レコードのカプセル化 動機 一部抜粋

レコード構造では、「保存されている値」と「計算で求める値」のどちらを使うか、設計時に決めなければいけません。整数の範囲を保持するレコード構造の場合の例を見てみましょう

const rangeA = {
  start: 1,
  end: 5
};

const rangeB = {
  start: 1,
  length: 4
};

// lengthが欲しい時は毎回計算する
const length = rangeA.end - rangeA.start;  // 4

// endが欲しい時は毎回計算する
const end = rangeB.start + rangeB.length;  // 5

どのようなことが厄介なのか??

関数を作成する人は、どちらの形式が渡されるか呼び出し元を確認する必要がある

function processRange(range) {
  // rangeA の形式? rangeB の形式?
  // endがある?それともlengthがある?
  // 確認しないと分からない
}

processRange(rangeA);  // { start, end }
processRange(rangeB);  // { start, length }

使う側が計算方法を知る必要がある

// rangeA を使う場合
const lengthA = rangeA.end - rangeA.start;

// rangeB を使う場合
const endB = rangeB.start + rangeB.length;

// 形式によって計算方法が違う

設計変更が難しい

// 最初:startとendで設計
const rangeA = { start: 1, end: 5 };

// 後から「lengthも欲しい」となった場合
// 全ての使用箇所を確認・修正する必要がある

変更可能なデータはレコード化するよりクラス構造にするとよいでしょう。

class Range {
  constructor(start, end) {
    this.start = start;
    this.end = end;
  }

  get length() {
    return this.end - this.start;
  }
}

const rangeA = new Range(1, 5);
const rangeB = new Range(10, 20);

// 使う側は形式を気にしなくていい
console.log(rangeA.start);   // 1
console.log(rangeA.end);     // 5
console.log(rangeA.length);  // 4(計算だが、使う側は知らなくていい)

クラスにすることで以下が解決します:

  • 関数を作成する人は、どちらの形式が渡されるか呼び出し元を確認する必要がある

 →クラスを使えば「必ずこの形式」と決まるので、呼び出し元を確認する必要がなくなる

function processRange(range) {
  // Rangeクラスと分かっている
  // start, end, length が必ずある
  // 呼び出し元を確認する必要がない
  console.log(range.start);
  console.log(range.end);
  console.log(range.length);
}

processRange(new Range(1, 5));
processRange(new Range(10, 20));
  • 使う側が計算方法を知る必要がある

    →get length() メソッドが計算を隠蔽しているので、使う側はrange.length と書くだけでいい

  • 設計変更が容易

    クラスなら必要なプロパティをgetterで最初から提供できるし、後から追加してもクラス内部に1つ追加するだけで済む

    中間点(midpoint)も欲しい」となった場合

        // getterを1つ追加するだけ
              get midpoint() {
                return this.start + this.length / 2;
              }
    

また以下のようなマップ、辞書、連想配列のようなフィールド名に任意の名前をつかうデータ構造はどのようなフィールドを保持しているか分かりにくいので、クラスにしてフィールドを明示するのがよいでしょう。

const userA = new Map();
userA.set("name", "田中");
userA.set("age", 30);

const userB = new Map();
userB.set("userName", "佐藤");   // キーが違う
userB.set("mail", "sato@example.com");  // キーが違う

// どんなキー(フィールド)があるか分からない
class User {
  constructor(name, age, email) {
    this._name = name;
    this._age = age;
    this._email = email;
  }

  get name() { return this._name; }
  get age() { return this._age; }
  get email() { return this._email; }
}

const userA = new User("田中", 30, "tanaka@example.com");
const userB = new User("佐藤", 25, "sato@example.com");

// 全員が同じフィールドを持つ
console.log(userA.name);   // "田中"
console.log(userB.name);   // "佐藤"

4. 8章 特性の移動

ここの章では関数やフィールド、ステートメントの適切なクラスやモジュールに移動させるリファクタリング方法などが記載されています。ループについてのリファクタリング記法も書いてあり、ループを取り除くリファクタリングについては前回の記事で書いています!

5. 9章 データの再編成

より良い変数の分離の仕方や時には変数を取り除くことも重要であるとし、そのリファクタリング手法、参照オブジェクト、値オブジェクトの変更について記載があります。

6. 10章 条件記述の単純化

プログラムの複雑さの多くに起因する条件記述を理解しやすくするためのリファクタリング手法について記載しています。前回の記事で複数の条件記述で同じ分岐条件を使っている場合の「ポリモーフィズムによる条件記述の置き換え」を説明しています

7. 11章 APIリファクタリング

普段よく聞く「Web API」はサーバーの機能を呼び出すためのインターフェースのことですが、こちらの章の「API」は関数やクラスの呼び出し方のことを指します。関数の引数を変えたり、関数をクラスに置き換えたりすることが「APIの変更」にあたります。

APIとWeb APIの違い

qiita.com

すぐれたAPIのためにデータを更新する関数とデータを参照するだけの関数を明確に分離したり、関数間でデータ構造が受け渡されるときに必要以上に細切れになるのを防ぐリファクタリング方法を記載しています。

コマンドにおける関数の置き換え

関数自身をオブジェクトとしてカプセル化することが有用な場合もあり、そのようなオブジェクトを「コマンドオブジェクト」または単にコマンドと呼びます。このオブジェクトは核となる単一のメソッドを持ちます。このオブジェクトの目的は要求を表すそのメソッドを実行することです。

APIリファクタリング コマンドによる関数の置き換え 動機 一部抜粋

これにはメリットがいくつかあるとされており、大量のデータを受け渡すようなかなり複雑な関数であればあるほど効果を発揮します。

メリット

  • 複雑な関数の分解

    →複数のメソッドとフィールドで分解できる

  • テスト・デバッグができる

    →個別のメソッドを直接呼び出せる

  • 関数の制御と表現において高い柔軟性

    →パラメータを組み立てるメソッドを提供できたり、undo(直前の操作を取り消す)のような補完的な機能も作成できる。

注意すべきこと

こうした柔軟性は複雑さという犠牲を払うことによって得られることを忘れてはいけません。

第一級関数とコマンドだったら95%の確率で関数を選びます。簡単なアプローチでは実現できない機能が特別に必要な場合にのみ、コマンドを選びます。

APIリファクタリング コマンドによる関数の置き換え 動機 一部抜粋

どっちのがより複雑なコードにならないかを考えながらコードを書く必要があります。

コード例

置き換え前:関数

function calculateTotal(cart, customer, coupon) {
  let subtotal = 0;
  let discount = 0;
  let shipping = 0;

  // 小計を計算
  for (const item of cart.items) {
    subtotal += item.price * item.quantity;
  }
  // 会員割引
  if (customer.isMember) {
    discount += subtotal * 0.1;
  }
  // クーポン割引
  if (coupon && coupon.isValid) {
    discount += coupon.amount;
  }
  // 送料計算
  if (subtotal < 5000) {
    shipping = 500;
  }
  return subtotal - discount + shipping;
}
// 使用
const total = calculateTotal(cart, customer, coupon)

コマンドによる置き換え

class TotalCalculator {
  constructor(cart) {
    this._cart = cart;
    this._customer = null;
    this._coupon = null;
  }

  // パラメータを組み立てるメソッド
  setCustomer(customer) {
    this._customer = customer;
    return this;
  }

  setCoupon(coupon) {
    this._coupon = coupon;
    return this;
  }

  execute() {
    this._subtotal = 0;
    this._discount = 0;
    this._shipping = 0;

    this._calculateSubtotal();
    this._applyMemberDiscount();
    this._applyCouponDiscount();
    this._calculateShipping();

    return this._subtotal - this._discount + this._shipping;
  }

  _calculateSubtotal() {
    for (const item of this._cart.items) {
      this._subtotal += item.price * item.quantity;
    }
  }

  _applyMemberDiscount() {
    if (this._customer && this._customer.isMember) {
      this._discount += this._subtotal * 0.1;
    }
  }

  _applyCouponDiscount() {
    if (this._coupon && this._coupon.isValid) {
      this._discount += this._coupon.amount;
    }
  }

  _calculateShipping() {
    if (this._subtotal < 5000) {
      this._shipping = 500;
    }
  }
}

// 使用:段階的にパラメータを設定
const calculator = new TotalCalculator(cart);
calculator.setCustomer(customer);
calculator.setCoupon(coupon);
const total = calculator.execute();

この置き換えにより、

  • 1つの関数に詰め込まれていた処理が、4つのメソッドに分解されている(_calculateSubtotal()_applyMemberDiscount()_applyCouponDiscount()_calculateShipping() )

    →個別にテストやデバッグができたり、処理の流れがわかりやすい

  • パラメータを組み立てるメソッドがあることにより、必要なものだけ設定できる柔軟性の向上やパラメータの追加が容易になる。

// カートだけで計算(ゲスト購入)
const calculator1 = new TotalCalculator(cart);
const total1 = calculator1.execute();

// 会員だけ設定(クーポンなし)
const calculator2 = new TotalCalculator(cart);
calculator2.setCustomer(customer);
const total2 = calculator2.execute();

// クーポンだけ設定(ゲストがクーポン使用)
const calculator3 = new TotalCalculator(cart);
calculator3.setCoupon(coupon);
const total3 = calculator3.execute();

// 両方設定
const calculator4 = new TotalCalculator(cart);
calculator4.setCustomer(customer);
calculator4.setCoupon(coupon);
const total4 = calculator4.execute();

関数だけの場合→関数だと全部渡す必要がある

// カートだけで計算(ゲスト購入)
calculateTotal(cart, null, null); // nullだらけ
// 会員だけ設定(クーポンなし)
calculateTotal(cart, customer, null);  // 何がnullか分かりにくい
// クーポンだけ設定(ゲストがクーポン使用)
calculateTotal(cart, null, coupon);
// 両方設定
calculateTotal(cart, customer, coupon);

undo機能の実装方法

関数だと元の状態がわからないので、取り消しようがない

function insertText(document, text) {
  document.content += text;
  // 元の状態を覚えていない
  // 取り消しようがない
}

insertText(document, "Hello");
insertText(document, " World");

// "Hello" だけの状態に戻したい...
// でも方法がない

コマンドでundo機能の実装→_previousContent に元の状態を保持する

class InsertTextCommand {
  constructor(document, text) {
    this._document = document;
    this._text = text;
    this._previousContent = null;  // 元の状態を保存
  }

  execute() {
    this._previousContent = this._document.content;  // 実行前の状態を保存
    this._document.content += this._text;
  }

  undo() {
    this._document.content = this._previousContent;  // 元に戻す
  }
}

// 使用
const command1 = new InsertTextCommand(document, "Hello");
command1.execute();  // "Hello"

const command2 = new InsertTextCommand(document, " World");
command2.execute();  // "Hello World"

command2.undo();     // "Hello" に戻る
command1.undo();     // "" に戻る

本書では逆に関数が簡単になったときの関数におけるコマンドの置き換えも紹介があります

8. 12章 継承の取り扱い

この章ではオブジェクト指向プログラミングの最もよく知られた機能である継承についての記載です。特性を継承階層に沿って上下に移動させたり、クラスを追加したり取り除いたり、サブクラスを追加するなどのリファクタリング方法を記載しています

メソッド、フィールド、コンストラクタの引き上げ

サブクラスの共通メソッドなどを親クラスに移動することで重複したコードをまとめる効果があるメソッド、フィールド、コンストラクタの引き上げがあります。

コンストラクタの引き上げ前

class Vehicle {
  constructor() {}
}

class Car extends Vehicle {
  constructor(brand, doors) {
    super();
    this._brand = brand;
    this._doors = doors;
  }
}

class Motorcycle extends Vehicle {
  constructor(brand, hasCarrier) {
    super();
    this._brand = brand;
    this._hasCarrier = hasCarrier;
  }
}

コンストラクタの引き上げ後

class Vehicle {
  constructor(brand) {
    this._brand = brand;  // 共通部分を親に移動
  }
}

class Car extends Vehicle {
  constructor(brand, doors) {
    super(brand);
    this._doors = doors;
  }
}

class Motorcycle extends Vehicle {
  constructor(brand, hasCarrier) {
    super(brand);
    this._hasCarrier = hasCarrier;
  }
}

メソッド、フィールドの押し下げ

特定のクラスだけで使うものを分離するメソッド、フィールドの押し下げがあります。こうすることでクラス構造が明快になります

フィールドの押し下げ前

class Shape {
  constructor() {
    this._color = "black";
    this._radius = 0;  // Circleしか使わない
  }
}

class Circle extends Shape {}

class Rectangle extends Shape {} // Rectangleは _radius を使わないのに持っている

フィールドの押し下げ後

class Shape {
  constructor() {
    this._color = "black";  // 共通フィールドだけ残す
  }
}

class Circle extends Shape {
  constructor() {
    super();
    this._radius = 0;  // 使うクラスに移動
  }
}

class Rectangle extends Shape {}

スーパークラスの抽出

2つのクラスが同じようなことをしていたらスーパークラスにまとめて重複したコードを削除しましょう。

スーパークラス抽出前 _name_agegetName()getAge() が重複している

class Dog {
  constructor(name, age) {
    this._name = name;
    this._age = age;
  }

  getName() {
    return this._name;
  }

  getAge() {
    return this._age;
  }

  bark() {
    console.log("ワン");
  }
}

class Cat {
  constructor(name, age) {
    this._name = name;
    this._age = age;
  }

  getName() {
    return this._name;
  }

  getAge() {
    return this._age;
  }

  meow() {
    console.log("ニャー");
  }
}

スーパークラス(Animal)抽出後

// 共通部分を親クラスに抽出
class Animal {
  constructor(name, age) {
    this._name = name;
    this._age = age;
  }

  getName() {
    return this._name;
  }

  getAge() {
    return this._age;
  }
}

class Dog extends Animal {
  constructor(name, age) {
    super(name, age);
  }

  bark() {
    console.log("ワン");
  }
}

class Cat extends Animal {
  constructor(name, age) {
    super(name, age);
  }

  meow() {
    console.log("ニャー");
  }
}

サブクラスの削除

将来つかうであろうと作成したサブクラスで結局使用されなかったものや存在価値がなくなったと思われるサブクラスは削除しましょう。判断価値がないと判断するのもコストがかかります

サブクラス削除前

class Employee {
  get typeCode() { return "E"; }
}

class Manager extends Employee {
  get typeCode() { return "M"; }
}

class Engineer extends Employee {
  get typeCode() { return "G"; }
}

サブクラスの削除 サブクラスをスーパークラスのフィールドに置き換える

class Employee {
  constructor(typeCode) {
    this._typeCode = typeCode;
  }

  get typeCode() { return this._typeCode; }
}

9. まとめ

この本はボリュームがあり一度読んだだけでは身につかないと思います。

実際に一通り読みましたが、リファクタリング手法についての詳細までは覚えておりません。

しかし、うっすらと概要は覚えているので常に本書のリファクタリング手法の何かが使えないかと頭の片隅に置きながらコードを書きたいと思います。その度に本書を何度も見返して少しずつリファクタリング手法を身に着けていきたいです。

ここまで読んでくださってありがとうございました。

弊社ではエンジニアを募集しています!少しでもご興味がありましたら、ぜひカジュアル面談でお話しましょう!

iimon採用サイト / Wantedly

参考記事

qiita.com

本のリンク

https://www.ohmsha.co.jp/book/9784274224546/