The first step in refactoring

If you want to add a feature to a program, but find that the code is not easy to change due to the lack of good structure, refactor the program to make it easier to add a feature, and then add the feature.

The first example of refactoring

//plays.json...
{
  "hamlet": {"name": "Hamlet", "type": "tragedy"},
  "as-like": {"name": "As You Like It", "type": "comedy"},
  "othello": {"name": "Othello", "type": "tragedy"},
}
//invoices.json...
[
  {
      "customer": "BigCo",
      "performances": [
          {"playID": "Hamlet", "audience": 55},
          {"playID": "as-like", "audience": 35},
          {"playID": "othello", "audience": 40},
      ]
  }
]
// The following function is used to print bill details
function statement (invoice, plays) {
  let totalAmount = 0;
  let volumeCredits = 0;
  let result = `Statement for ${invoice.customer}\n`;
  const format = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", minimumFractionDigits: 2 }).format;
  for (let perf of invoice.performances) {
      const play = plays[perf.playID];
      let thisAmount = 0;

      switch (play.type) {
          case "tragedy":
              thisAmount = 40000;
              if (perf.audience > 30) {
                  thisAmount += 1000 * (perf.audience - 30);
              }
              break;
          case "comedy":
              thisAmount = 30000;
              if (perf.audience > 20) {
                  thisAmount += 10000 + 500 * (perf.audience - 20);
              }
              thisAmount += 300 * perf.audience;
              break;
          default:
              throw new Error(`unknow type: ${play.type}`);
      }
      // add volume credits
      volumeCredits += Math.max(perf.audience - 30, 0);
      // add extra credit for every ten comedy attendees
      if ("comedy" === play.type) {
          volumeCredits += Math.floor(perf.audience / 5);
      }
      // print line for this order
      result += `${play.name}: ${format(thisAmount/100)} (${perf.audience} seats)\n`;
      totalAmount += thisAmount;
  }
  result += `Amount owed is ${format(totalAmount/100)}\n`;
  result += `You earned ${volumeCredits} credits\n`;
  return result;
}
// Use the json data file above to test the output

How about this program design? The code organization is not clear, but it is still within tolerable limits. If you put all the code in one function in a larger program, it is difficult to understand.

In this example, if the user wants to make several modifications to the system:

1. Output detailed list in HTML format

2. Actors try to make more breakthroughs in performance types, whether historical drama, pastoral drama, pastoral drama, historical tragedy, etc., which has an impact on the billing method and the calculation method of points

We need to emphasize that it is the change of requirements that makes refactoring necessary. If a piece of code works normally and will not be modified, it is completely unnecessary to refactor it. It's better to improve, but some people really need to understand its working principle and find it difficult to understand, so they need to improve the code.

First step

The first step in refactoring is always the same: I have to make sure that the code to be modified has a reliable set of tests. Before refactoring, check whether you have a reliable test set. These tests must be self checking.

When refactoring, we need to rely on tests. Testing is a bug detector, which can protect us from our own mistakes. Building a test system is too important for refactoring. Testing will be discussed in detail in later chapters.

Decompose statement function

This code is to calculate the cost of a drama performance. Let's decompose it first. This reconstruction technique is called refining function, for example, it is named amountFor(performance).

Every time we want to extract a piece of code into a function, we need to follow a standard process to minimize the possibility of mistakes. First, check which variables leave the original scope. For perf, play and thisAmount in this example, the first two variables will be used by the refined function, but will not be modified. They can be passed in as parameters. thisAmount will be modified and can be returned directly from the function.

function amountFor (aPerformance, play) {
  let result= 0;
  switch (play.type) {
      case "tragedy":
          result= 40000;
          if (aPerformance.audience > 30) {
              result+= 1000 * (aPerformance.audience - 30);
          }
          break;
      case "comedy":
          result= 30000;
          if (aPerformance.audience > 20) {
              result+= 10000 + 500 * (aPerformance.audience - 20);
          }
          result+= 300 * aPerformance.audience;
          break;
      default:
          throw new Error(`unknow type: ${play.type}`);
  }
  return result;
}

Refactoring technology is to modify the program at a small pace. If you make a mistake, you can easily find it and run the test after each modification. If you make too many changes, you may get into trouble in debugging and spend a lot of practice.

Remove play variable

Observe the parameters of the amountFor function. Aproperties come from the loop variable and change every loop, but play is calculated from the. Therefore, it is not necessary to pass it in as a parameter. We can calculate it in the amountFor function. Generally, too many temporary variables with local scope will make the refining function more complex. This reconstruction method is to replace the temporary variables with queries.

Delete the definition of play variable, which is returned by the function playFor, and remove the function parameter play of amountFor

function playFor(aPerformance) {
  return plays[aPerformance.playID]
}

Refine the logic of calculating the audience integral

The advantage of removing the play variable is to remove a local scope variable. Refining this logic is simpler. There are also two variables: perf can be easily passed in as a parameter, but volumeCredits is an accumulation variable, which will be updated in the loop iteration. The simplest way is to refine the whole block logic into a new function:

function volumeCreditsFor(perf) {
  let volumeCredits = 0;
  volumeCredits += Math.max(perf.audience - 30, 0);
  if ("comedy" === perf.play.type) {
      volumeCredits += Math.floor(perf.audience / 5);
  }
  return volumeCredits;
}

Remove format variable

Here is a typical scenario of "assigning a function to a temporary variable". A better way is to replace it with an explicitly declared function.

function usd(aNumber) {
  return new Intl.NumberFormat("en-US", 
          { style: "currency", currency: "USD",
           minimumFractionDigits: 2 }).format(aNumber/100);
}

Remove the sum of audience points

The process of removing volumeCredits is mainly divided into the following four steps:

1. Use the splitting cycle to separate the accumulation process;

2. Use the move statement to gather the declaration of the accumulation variable and the accumulation process together;

3. Use the extraction function to extract the function for calculating the total number;

4. Use inline variables to completely remove intermediate variables.

function totalAmount() {
  let result = 0;
  for (let perf of invoice.performances) {
      result += amountFor(perf);
  }
  return result;
}
function totalVolumeCredits() {
  let result = 0;
  for (let perf of invoice.performances) {
      result += volumeCreditsFor(perf);
  }
  return result;
}

Split calculation phase and format phase

So far, our refactoring is mainly to add enough structure to the original function and see its logical structure. There are many ways to realize reuse, and the splitting stage is one of them. Now divide the logic into two parts: one part calculates the data required by the detailed list, and the other part renders the data into text or HTML. Then separate into two files:

// statement.js...
import createStatementData from "./createStatementData";
function statement (invoice, plays) {
  return renderPlainText(createStatementData(invoice, plays));

}
function renderPlainText (data) {
  let result = `Statement for ${data.customer}\n`;
  for (let perf of data.performances) {
    result += `${perf.play.name}: ${usd(perf.amount)} (${perf.audience} seats)\n`;
  }
  result += `Amount owed is ${usd(data.totalAmount)}\n`;
  result += `You earned ${data.totalVolumeCredites} credits\n`;
  return result;
}
function htmlStatement(invoice, plays) {
  return renderHtml(createStatementData(invoice, plays));
}
function renderHtml(data) {
  let result = `<h1>Statement for ${data.customer}</h1>\n`;
  result += '<table>\n';
  result += '<tr><th>play</th><th>seates</th><th>costs</th></tr>';
  for (let perf of data.performances) {
    result += ` <tr><td>${perf.play.name}</td><td>${perf.audiance}</td>`;
    result += `<td>${usd(perf.amount)}</td></tr>`
  }
  result += '</table>\n';
  result += `<p>Amount owed is <em>${usd(data.totalAmount)}</em></p>\n`;
  result += `<p>You earned <em>${data.totalVolumeCredites}</em> credits</p>\n`;
  return result;
}
function usd(aNumber) {
  return new Intl.NumberFormat("en-US", 
          { style: "currency", currency: "USD",
           minimumFractionDigits: 2 }).format(aNumber/100);
}
// createStatementData.js...
export default function createStatementData(invoice, plays) {
  const result = {};
  result.customer = invoice.customer;
  result.performances = invoice.performances.map(enrichPerformance);
  result.totalAmount = totalAmount(result);
  result.totalVolumeCredits = totalVolumeCredits(result);
  return result;
}
function enrichPerformance(aPerformance) {
  const result = Object.assign({}, aPerformance);
  result.play = playFor(result);
  result.amount = amountFor(result);
  result.volumeCredits = volumeCreditsFor(result);
  return result;
}
function playFor(aPerformance) {
  return plays[aPerformance.playID]
}
function amountFor (aPerformance) {
  let result = 0;
  switch (aPerformance.play.type) {
      case "tragedy":
        result = 40000;
          if (aPerformance.audience > 30) {
            result += 1000 * (aPerformance.audience - 30);
          }
          break;
      case "comedy":
        result = 30000;
          if (aPerformance.audience > 20) {
            result += 10000 + 500 * (aPerformance.audience - 20);
          }
          result += 300 * aPerformance.audience;
          break;
      default:
          throw new Error(`unknow type: ${aPerformance.play.type}`);
  }
  return result;
}
function volumeCreditsFor(aPerformance) {
  let result = 0;
  result += Math.max(aPerformance.audience - 30, 0);
  if ("comedy" === aPerformance.play.type) {
    result += Math.floor(aPerformance.audience / 5);
  }
  return result;
}
function totalAmount() {
  // Replace cycle with pipe
  // let result = 0;
  // for (let perf of invoice.performances) {
  //     result += amountFor(perf);
  // }
  // return result;
  return data.performances.reduce((total, p) => total + p.amount, 0)
}
function totalVolumeCredits(data) {
  // let result = 0;
  // for (let perf of invoice.performances) {
  //     result += volumeCreditsFor(perf);
  // }
  // return result;
  return data.performances.reduce((total, p) => total + p.volumeCredits, 0)
}

Although the number of lines of code has increased from more than 40 lines at the beginning of refactoring to more than 70 lines, it is mainly due to the additional packaging cost of extracting the code into the function. Although the number of lines of code increases, refactoring also improves the readability of the code, and the later expansion is much easier.

Use a polymorphic calculator to provide data

Thirdly, the code structure is adjusted to concentrate the calculations of different drama types in one place. If most modifications involve specific types of calculations, it makes sense to separate by type like this. When adding a new play, just add a subclass and return it in the create function.

export default function createStatementData(invoice, plays) {
  const result = {};
  result.customer = invoice.customer;
  result.performances = invoice.performances.map(enrichPerformance);
  result.totalAmount = totalAmount(result);
  result.totalVolumeCredits = totalVolumeCredits(result);
  return result;
}
function enrichPerformance(aPerformance) {
  const calculator = createPerformanceCalculator(aPerformance, playFor(aPerformance));
  const result = Object.assign({}, aPerformance);
  result.play = calculator.play;
  result.amount = calculator.amount;
  result.volumeCredits = calculator.volumeCredits;
  return result;
}
function playFor(aPerformance) {
  return plays[aPerformance.playID]
}
// function amountFor (aPerformance) {
//   let result = 0;
//   switch (aPerformance.play.type) {
//       case "tragedy":
//         result = 40000;
//           if (aPerformance.audience > 30) {
//             result += 1000 * (aPerformance.audience - 30);
//           }
//           break;
//       case "comedy":
//         result = 30000;
//           if (aPerformance.audience > 20) {
//             result += 10000 + 500 * (aPerformance.audience - 20);
//           }
//           result += 300 * aPerformance.audience;
//           break;
//       default:
//           throw new Error(`unknow type: ${aPerformance.play.type}`);
//   }
//   return result;
// }
// function volumeCreditsFor(aPerformance) {
//   let result = 0;
//   result += Math.max(aPerformance.audience - 30, 0);
//   if ("comedy" === aPerformance.play.type) {
//     result += Math.floor(aPerformance.audience / 5);
//   }
//   return result;
// }
function totalAmount() {
  return data.performances.reduce((total, p) => total + p.amount, 0)
}
function totalVolumeCredits(data) {
  return data.performances.reduce((total, p) => total + p.volumeCredits, 0)
}
function createPerformanceCalculator(aPerformance, aPlay) {
  switch (aPlay.type) {
    case 'tragedy': return new TragedyCalculator(aPerformance, aPlay);
    case 'comedy': return new ComedyCalculator(aPerformance, aPlay);
    default:
      throw new Error(`unknow type: ${aPlay.type}`);
  }
}
class PerformanceCalculator {
  constructor(aPerformance, aPlay) {
    this.performance = aPerformance;
    this.paly = aPlay;
  }
  get amount() {
    throw new Error('subclass responsibility');
  }
  get volumeCredits() {
    return Math.max(this.performance.audience - 30, 0);
  }
}
class TragedyCalculator extends PerformanceCalculator {
  get amount() {
    let result = 40000;
    if (this.performance.audience > 30) {
      result += 1000 * (this.performanceaudience.audience - 30);
    }
    return result;
  }
}
class ComedyCalculator extends PerformanceCalculator {
  get amount() {
    let result = 30000;
    if (this.performance.audience.audience > 20) {
        thisAmount += 10000 + 500 * (this.performance.audience - 20);
    }
    result += 300 * this.performance.audience;
    return result;
  }
  get volumeCredits() {
    return super.volumeCredits + Math.floor(this.performance.audience / 5);
  }
}

epilogue

Through this simple example, we may have a little feeling about "how to do refactoring". In the example, I only selected the code of some key steps. If you need to understand the more detailed process, you can read this book. There are three important nodes in this reconstruction: decomposing the original function into a set of nested functions, separating the calculation logic and output formatting logic in the split stage, and introducing polymorphism into the calculator to deal with the calculation logic. Each step adds more structures to better express the intent of the code.

The test of good code is whether people can modify it easily...

Keywords: Javascript

Added by Asperon on Thu, 30 Dec 2021 09:04:16 +0200