[Перевод] Рефакторинг салона видеопроката на JavaScript

612696646d2a45ee881f90b4acac0e8b.pngМоя книга по рефакторингу в 1999 году начиналась с простого примера расчёта и форматирования чека для видеомагазина. На современном JavaScript есть несколько вариантов рефакторинга того кода. Здесь я изложу четыре из них: рефакторинг функций верхнего уровня; переход к вложенной функции с диспетчером; используя классы; трансформация с применением промежуточной структуры данных.

Много лет назад, когда я писал книгу по рефакторингу, я начал с (очень) простого примера рефакторинга кода, который вычислял счёт для клиента за прокат видеофильмов (в те дни нам нужно было ходить в салон для этого). Недавно я размышлял об этом примере, в частности, как бы он выглядел на современном JavaScript.

Любой рефакторинг подразумевает улучшение кода в определённом направлении, в том, которое соответствует стилю программирования команды разработчиков. Пример в книге был на Java, а Java (именно в то время) подразумевала определённый стиль программирования, объектно-ориентированный стиль. Однако с JavaScript есть гораздо больше вариантов, какой стиль выбрать. Хотя вы можете придерживаться Java-подобного объектно-ориентированного стиля, особенно с ES6 (Ecmascript 2015), не все сторонники JavaScript одобряют этот стиль. Многие действительно считают, что использовать классы Очень Плохо.

Первоначальный код салона видеопроката
Чтобы продолжить объяснение, нужно показать кое-какой код. В этом случае JavaScript-версию первоначального примера, который я написал в конце прошлого века.
  function statement(customer, movies) {
    let totalAmount = 0;
    let frequentRenterPoints = 0;
    let result = `Rental Record for ${customer.name}\n`;
    for (let r of customer.rentals) {
      let movie = movies[r.movieID];
      let thisAmount = 0;
  
      // determine amount for each movie
      switch (movie.code) {
        case "regular":
          thisAmount = 2;
          if (r.days > 2) {
            thisAmount += (r.days - 2) * 1.5;
          }
          break;
        case "new":
          thisAmount = r.days * 3;
          break;
        case "childrens":
          thisAmount = 1.5;
          if (r.days > 3) {
            thisAmount += (r.days - 3) * 1.5;
          }
          break;
      }
  
      //add frequent renter points
      frequentRenterPoints++;
      // add bonus for a two day new release rental
      if(movie.code === "new" && r.days > 2) frequentRenterPoints++;
  
      //print figures for this rental
      result += `\t${movie.title}\t${thisAmount}\n` ;
      totalAmount += thisAmount;
    }
    // add footer lines
    result += `Amount owed is ${totalAmount}\n`;
    result += `You earned ${frequentRenterPoints} frequent renter points\n`;
  
    return result;
  }

Здесь я использую ES6. Код работает на двух структурах данных, обе из которых представляют собой просто списки записей json. Запись клиента выглядит так:
{
  "name": "martin",
  "rentals": [
    {"movieID": "F001", "days": 3},
    {"movieID": "F002", "days": 1},
  ]
}

Структура списка фильмов выглядит следующим образом:
{
  "F001": {"title": "Ran",                     "code": "regular"},
  "F002": {"title": "Trois Couleurs: Bleu",     "code": "regular"},
  // etc
}
В оригинальной книге фильмы были просто представлены как объекты в структуре объектов Java. Для этой статьи я предпочёл перейти на структуру json. Предполагается, что какой-то вид глобального поиска вроде Repository не подходит для этого приложения

Метод выдаёт простое текстовое сообщение о прокате видеофильма.

Rental Record for martin
Ran 3.5
Trois Couleurs: Bleu 2
Amount owed is 5.5
You earned 2 frequent renter points

Такая выдача довольно грубая, даже для примера. Как я мог не потрудиться хотя бы пристойно отформатировать цифры? Но помните, что книга была написана во времена Java 1.1, до добавления в язык формата String. Это частично оправдывает мою лень

Функция statement пахнет как Long Method. Один лишь её размер уже наводит на подозрения. Но один дурной запах — не причина для рефакторинга. Плохо факторизованный код является проблемой, потому что его трудно понять. Если код трудно понять, то его трудно изменить, чтобы добавить новые функции или исправить ошибки. Так что если вам не нужно читать или понимать какой-то код, то его плохая структура никак не повредит вам и вы с радостью оставите его в покое на какое-то время. Поэтому, чтобы пробудить у нас интерес к этому коду, должна быть какая-то причина для его изменения. Наша причина, которую я указал в книге, — это создание HTML-версии отчёта statement примерно с такой выдачей:

Rental Record for martin

Ran3.5
Trois Couleurs: Bleu2

Amount owed is 5.5

You earned 2 frequent renter points


Как я отметил раньше, в этой статьи я изучаю некоторые способы рефакторинга кода, чтобы упростить добавление дополнительных вариантов рендеринга выдачи. Все они начинаются одинаково: разбить один метод на набор функций, охватывающих разные части логики. Когда я закончу это разбиение, то изучу четыре способа, как эти функции можно организовать для поддержки альтернативных видов рендеринга.
9a77c51f9f7b02e63bc3a2778a0c14e6.pngРазбиение на несколько функций
Каждый раз, когда я работаю со слишком длинной функцией вроде этой, моя первая мысль — попробовать разбить её на логические куски кода и сделать из них отдельные функции с помощью Extract Method. [1]. Первый кусок, который привлёк моё внимание, — это оператор switch.
  function statement(customer, movies) {
    let totalAmount = 0;
    let frequentRenterPoints = 0;
    let result = `Rental Record for ${customer.name}\n`;
    for (let r of customer.rentals) {
      let movie = movies[r.movieID];
      let thisAmount = 0;
  
      // determine amount for each movie
      switch (movie.code) {
        case "regular":
          thisAmount = 2;
          if (r.days > 2) {
            thisAmount += (r.days - 2) * 1.5;
          }
          break;
        case "new":
          thisAmount = r.days * 3;
          break;
        case "childrens":
          thisAmount = 1.5;
          if (r.days > 3) {
            thisAmount += (r.days - 3) * 1.5;
          }
          break;
      }
  
      //add frequent renter points
      frequentRenterPoints++;
      // add bonus for a two day new release rental
      if(movie.code === "new" && r.days > 2) frequentRenterPoints++;
  
      //print figures for this rental
      result += `\t${movie.title}\t${thisAmount}\n` ;
      totalAmount += thisAmount;
    }
    // add footer lines
    result += `Amount owed is ${totalAmount}\n`;
    result += `You earned ${frequentRenterPoints} frequent renter points\n`;
  
    return result;
  }

Моя среда разработки (IntelliJ) предлагает сама сделать рефакторинг автоматически, но некорректно проводит его — её способности в JavaScript не настолько продвинуты, как в рефакторинге Java. Так что я сделаю это вручную. Нужно посмотреть, какие данные использует этот кандидат на извлечение. Там три фрагмента данных:
  • Значение thisAmount вычисляется извлечённым кодом. Я могу инициировать его внутри функции и вернуть в конце.
  • Значение r на количество дней проката проверяется в цикле, я могу передать его как параметр.
  • Переменная movie — это фильм, который взяли напрокат. Временные переменные вроде этой обычно мешают во время рефакторинга процедурного кода, так что я предпочту сначала запустить Replace Temp with Query для преобразования её в функцию, которую могу вызвать из любого извлечённого кода.

Когда я закончил с Replace Temp with Query, код выглядит так:
  function statement(customer, movies) {
    let totalAmount = 0;
    let frequentRenterPoints = 0;
    let result = `Rental Record for ${customer.name}\n`;
    for (let r of customer.rentals) {
      let thisAmount = 0;
  
      // determine amount for each movie
      switch (movieFor(r).code) {
        case "regular":
          thisAmount = 2;
          if (r.days > 2) {
            thisAmount += (r.days - 2) * 1.5;
          }
          break;
        case "new":
          thisAmount = r.days * 3;
          break;
        case "childrens":
          thisAmount = 1.5;
          if (r.days > 3) {
            thisAmount += (r.days - 3) * 1.5;
          }
          break;
      }
  
      //add frequent renter points
      frequentRenterPoints++;
      // add bonus for a two day new release rental
      if(movieFor(r).code === "new" && r.days > 2) frequentRenterPoints++;
  
      //print figures for this rental
      result += `\t${movieFor(r).title}\t${thisAmount}\n` ;
      totalAmount += thisAmount;
    }
    // add footer lines
    result += `Amount owed is ${totalAmount}\n`;
    result += `You earned ${frequentRenterPoints} frequent renter points\n`;
  
    return result;
  
    function movieFor(rental) {return movies[rental.movieID];}
  }

Теперь извлекаем оператор switch.
  function statement(customer, movies) {
    let totalAmount = 0;
    let frequentRenterPoints = 0;
    let result = `Rental Record for ${customer.name}\n`;
  
    for (let r of customer.rentals) {
      const thisAmount = amountFor(r);
  
      //add frequent renter points
      frequentRenterPoints++;
      // add bonus for a two day new release rental
      if(movieFor(r).code === "new" && r.days > 2) frequentRenterPoints++;
  
      //print figures for this rental
      result += `\t${movieFor(r).title}\t${thisAmount}\n` ;
      totalAmount += thisAmount;
    }
    // add footer lines
    result += `Amount owed is ${totalAmount}\n`;
    result += `You earned ${frequentRenterPoints} frequent renter points\n`;
  
    return result;
  
    function movieFor(rental) {return movies[rental.movieID];}
  
    function amountFor(r) {
      let thisAmount = 0;
  
      // determine amount for each movie
      switch (movieFor(r).code) {
        case "regular":
          thisAmount = 2;
          if (r.days > 2) {
            thisAmount += (r.days - 2) * 1.5;
          }
          break;
        case "new":
          thisAmount = r.days * 3;
          break;
        case "childrens":
          thisAmount = 1.5;
          if (r.days > 3) {
            thisAmount += (r.days - 3) * 1.5;
          }
          break;
      }
      return thisAmount;
    }
  }

Теперь посмотрим на вычисление баллов постоянного клиента. Здесь можно произвести такую же процедуру извлечения.
function statement(customer, movies) {
  let totalAmount = 0;
  let frequentRenterPoints = 0;
  let result = `Rental Record for ${customer.name}\n`;
  for (let r of customer.rentals) {
    const thisAmount = amountFor(r);
    frequentRenterPointsFor(r);

    //print figures for this rental
    result += `\t${movieFor(r).title}\t${thisAmount}\n` ;
    totalAmount += thisAmount;
  }
  // add footer lines
  result += `Amount owed is ${totalAmount}\n`;
  result += `You earned ${frequentRenterPoints} frequent renter points\n`;

  return result;
…
  function frequentRenterPointsFor(r) {
   //add frequent renter points
    frequentRenterPoints++;
    // add bonus for a two day new release rental
    if (movieFor(r).code === "new" && r.days > 2) frequentRenterPoints++;
  }

Хотя я извлёк функцию, но мне не нравится, как она работает, обновляя переменную родительской области. Такие побочные эффекты затрудняют код, так что я его изменю его, чтобы лишить этих побочных эффектов.
function statement(customer, movies) {
  let totalAmount = 0;
  let frequentRenterPoints = 0;
  let result = `Rental Record for ${customer.name}\n`;
  for (let r of customer.rentals) {
    const thisAmount = amountFor(r);
    frequentRenterPoints += frequentRenterPointsFor(r);

    //print figures for this rental
    result += `\t${movieFor(r).title}\t${thisAmount}\n` ;
    totalAmount += thisAmount;
  }
  // add footer lines
  result += `Amount owed is ${totalAmount}\n`;
  result += `You earned ${frequentRenterPoints} frequent renter points\n`;

  return result;
…
  function frequentRenterPointsFor(r) {
    let result = 1;
    if (movieFor(r).code === "new" && r.days > 2) result++;
    return result;
  }

Воспользуюсь шансом слегка почистить две извлечённые функции, пока я их понимаю.
  function amountFor(rental) {
    let result = 0;
    switch (movieFor(rental).code) {
      case "regular":
        result = 2;
        if (rental.days > 2) {
          result += (rental.days - 2) * 1.5;
        }
        return result;
      case "new":
        result = rental.days * 3;
        return result;
      case "childrens":
        result = 1.5;
        if (rental.days > 3) {
          result += (rental.days - 3) * 1.5;
        }
        return result;
    }
    return result;
  }
  function frequentRenterPointsFor(rental) {
    return (movieFor(rental).code === "new" && rental.days > 2) ? 2 : 1;
   }
С этими функциями я мог бы сделать кое-что ещё, особенно с amountFor, и я действительно кое-что сделал в книге. Но для этой статьи я больше не буду углубляться в исследование тела этих функций

Готово, теперь возвращаюсь к телу функции.

  function statement(customer, movies) {
    let totalAmount = 0;
    let frequentRenterPoints = 0;
    let result = `Rental Record for ${customer.name}\n`;
    for (let r of customer.rentals) {
      const thisAmount = amountFor(r);
      frequentRenterPoints += frequentRenterPointsFor(r);
  
      //print figures for this rental
      result += `\t${movieFor(r).title}\t${thisAmount}\n` ;
      totalAmount += thisAmount;
    }
    // add footer lines
    result += `Amount owed is ${totalAmount}\n`;
    result += `You earned ${frequentRenterPoints} frequent renter points\n`;
  
    return result;

Общий подход, который я люблю использовать, заключается в устранении mutable-переменных. Здесь их три, одна собирает финальную строку, ещё две вычисляют значения, которые используются в этой строке. Не имею ничего против первой, но хотелось бы уничтожить две остальные. Для начала следует разбить цикл. Сначала упрощаем цикл и встраиваем постоянную величину.
  function statement(customer, movies) {
    let totalAmount = 0;
    let frequentRenterPoints = 0;
    let result = `Rental Record for ${customer.name}\n`;
    for (let r of customer.rentals) {
      frequentRenterPoints += frequentRenterPointsFor(r);
      result += `\t${movieFor(r).title}\t${amountFor(r)}\n` ;
      totalAmount += amountFor(r);
    }
    // add footer lines
    result += `Amount owed is ${totalAmount}\n`;
    result += `You earned ${frequentRenterPoints} frequent renter points\n`;
  
    return result;

Затем разбиваем цикл на три части.
  function statement(customer, movies) {
    let totalAmount = 0;
    let frequentRenterPoints = 0;
    let result = `Rental Record for ${customer.name}\n`;
    for (let r of customer.rentals) {
      frequentRenterPoints += frequentRenterPointsFor(r);
    }
    for (let r of customer.rentals) {
      result += `\t${movieFor(r).title}\t${amountFor(r)}\n`;
    }
    for (let r of customer.rentals) {
      totalAmount += amountFor(r);
    }
  
    // add footer lines
    result += `Amount owed is ${totalAmount}\n`;
    result += `You earned ${frequentRenterPoints} frequent renter points\n`;
  
    return result;
Некоторые программисты беспокоятся о проблемах с производительностью после такого рефакторинга, в таком случае посмотрите старую, но уместную статью о программной производительности

Такое разбиение позволяет потом извлечь функции для этих вычислений.

  function statement(customer, movies) {
    let result = `Rental Record for ${customer.name}\n`;
    for (let r of customer.rentals) {
      result += `\t${movieFor(r).title}\t${amountFor(r)}\n`;
    }
    result += `Amount owed is ${totalAmount()}\n`;
    result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`;
    return result;
  
    function totalAmount() {
      let result = 0;
      for (let r of customer.rentals) {
        result += amountFor(r);
      }
      return result;
    }
    function totalFrequentRenterPoints() {
      let result = 0;
      for (let r of customer.rentals) {
        result += frequentRenterPointsFor(r);
      }
      return result;
    }

Как фанат цепочек последовательного сбора данных типа collection pipeline я также отрегулирую циклы в такую цепочку.
  function totalFrequentRenterPoints() {
    return customer.rentals
      .map((r) => frequentRenterPointsFor(r))
      .reduce((a, b) => a + b)
      ;
  }
  function totalAmount() {
    return customer.rentals
      .reduce((total, r) => total + amountFor(r), 0);
  }
Не уверен, какой из этих двух типов цепочек мне больше нравитсяИсследование скомпонованной функции
Теперь посмотрим, что у нас получилось. Вот весь код.
function statement(customer, movies) {
  let result = `Rental Record for ${customer.name}\n`;
  for (let r of customer.rentals) {
    result += `\t${movieFor(r).title}\t${amountFor(r)}\n`;
  }
  result += `Amount owed is ${totalAmount()}\n`;
  result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`;
  return result;

  function totalFrequentRenterPoints() {
    return customer.rentals
      .map((r) => frequentRenterPointsFor(r))
      .reduce((a, b) => a + b)
      ;
  }
  function totalAmount() {
    return customer.rentals
      .reduce((total, r) => total + amountFor(r), 0);
  }
  function movieFor(rental) {
    return movies[rental.movieID];
  }
  function amountFor(rental) {
    let result = 0;
    switch (movieFor(rental).code) {
      case "regular":
        result = 2;
        if (rental.days > 2) {
          result += (rental.days - 2) * 1.5;
        }
        return result;
      case "new":
        result = rental.days * 3;
        return result;
      case "childrens":
        result = 1.5;
        if (rental.days > 3) {
          result += (rental.days - 3) * 1.5;
        }
        return result;
    }
    return result;
  }
  function frequentRenterPointsFor(rental) {
    return (movieFor(rental).code === "new" && rental.days > 2) ? 2 : 1;
  }
}

Теперь у меня хорошо скомпонованная функция. Основной её код занимает семь строчек, и все они относятся к форматированию итоговой строки. Код для всех вычислений перенесён в свой набор вложенных функций, каждая из которых небольшая и с внятным названием, которое показывает её предназначение.

Но я всё ещё не готов писать функцию для выдачи html. Все функции после разбиения вложены внутри общей функции statement. Так легче извлекать функции, так как они могут ссылаться на имена внутри области видимости функции, в том числе друг на друга (как amountFor вызывает movieFor) и соответствующие параметры customer и movie. Но я не могу написать простую функцию htmlStatement, которая ссылается на эти функции. Чтобы поддерживать какие-то другие форматы выдачи с использованием тех же вычислений, нужно продолжить рефакторинг. Теперь я дстиг точки, когда появляются разные варианты рефакторинга в зависимости от того, как я хочу преобразовать код. Далее я опробую каждый из этих вариантов, объясню, как работает каждый из них, а когда все четыре будут готовы, мы их сравним.

Использование параметра для определения выдачи
Один из вариантов — определить формат выдачи как аргумент функции statement. Я хотел бы начать такой рефакторинг с использования Add Parameter, извлечь существующий код для форматирования текста и дописать код в начале для отсылки к извлечённой функции, когда параметр указывает на это.
function statement(customer, movies, format = 'text') {
  switch (format) {
    case "text":
      return textStatement();
  }
  throw new Error(`unknown statement format ${format}`);
  function textStatement() {
    let result = `Rental Record for ${customer.name}\n`;
    for (let r of customer.rentals) {
      result += `\t${movieFor(r).title}\t${amountFor(r)}\n`;
    }
    result += `Amount owed is ${totalAmount()}\n`;
    result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`;
    return result;
  }

Затем я могу написать функцию генерации html и добавить условие для диспетчера.
  function statement(customer, movies, format = 'text') {
    switch (format) {
      case "text":
        return textStatement();
      case "html":
        return htmlStatement();
    }
    throw new Error(`unknown statement format ${format}`);
  
    function htmlStatement() {
      let result = `

Rental Record for ${customer.name}

\n`; result += "\n"; for (let r of customer.rentals) { result += ` \n`; } result += "
${movieFor(r).title}${amountFor(r)}
\n"; result += `

Amount owed is ${totalAmount()}

\n`; result += `

You earned ${totalFrequentRenterPoints()} frequent renter points

\n`; return result; }

Я могу использовать структуру данных для логики диспетчера.
function statement(customer, movies, format = 'text') {
  const dispatchTable = {
    "text": textStatement,
    "html": htmlStatement
  };
  if (undefined === dispatchTable[format]) throw new Error(`unknown statement format ${format}`);
  return dispatchTable[format].call();
Использование функций верхнего уровня
Проблема с написанием функции statement верхнего уровня заключается в том, что функции вычисления вложены внутрь функции statement. Очевидным выходом будет перенести их в верхний контекст.

Чтобы сделать это, я начал с поиска функции, которая не ссылается ни на какие другие, в нашем случае это movieFor.

Всякий раз, когда я перемещаю функции, мне нравится сначала скопировать функцию в новый контекст, встроить её в этот контекст, а затем заменить тело оригинальной функции вызовом на перемещённую функцию.

function topMovieFor(rental, movies) {
  return movies[rental.movieID];
}
function statement(customer, movies) {
  // [snip]
  function movieFor(rental) {
    return topMovieFor(rental, movies);
  }
  function frequentRenterPointsFor(rental) {
    return (movieFor(rental).code === "new" && rental.days > 2) ? 2 : 1;
  }

На этом этапе можно компилировать и тестировать код, чтобы проверить, если возникнут какие-то проблемы из-за смены контекста. Когда это сделано, можно встроить функцию переадресации.
function movieFor(rental, movies) {
  return movies[rental.movieID];
}
function statement(customer, movies) {
  // [snip]
  function frequentRenterPointsFor(rental) {
    return (movieFor(rental, movies).code === "new" && rental.days > 2) ? 2 : 1;
  }
Похожие изменения сделаны внутри amountFor

Одновременно со встраиванием я также переименовал функцию верхнего уровня для соответствия старому имени, так что единственным отличием сейчас остался параметр movies.

Затем проделаем это со всеми вложенными функциями.

function statement(customer, movies) {
  let result = `Rental Record for ${customer.name}\n`;
  for (let r of customer.rentals) {
    result += `\t${movieFor(r, movies).title}\t${amountFor(r, movies)}\n`;
  }
  result += `Amount owed is ${totalAmount(customer, movies)}\n`;
  result += `You earned ${totalFrequentRenterPoints(customer, movies)} frequent renter points\n`;
  return result;
}
function totalFrequentRenterPoints(customer, movies) {
  return customer.rentals
    .map((r) => frequentRenterPointsFor(r, movies))
    .reduce((a, b) => a + b)
    ;
}
function totalAmount(customer, movies) {
  return customer.rentals
    .reduce((total, r) => total + amountFor(r, movies), 0);
}
function movieFor(rental, movies) {
  return movies[rental.movieID];
}
function amountFor(rental, movies) {
  let result = 0;
  switch (movieFor(rental, movies).code) {
    case "regular":
      result = 2;
      if (rental.days > 2) {
        result += (rental.days - 2) * 1.5;
      }
      return result;
    case "new":
      result = rental.days * 3;
      return result;
    case "childrens":
      result = 1.5;
      if (rental.days > 3) {
        result += (rental.days - 3) * 1.5;
      }
      return result;
  }
  return result;
}
function frequentRenterPointsFor(rental, movies) {
  return (movieFor(rental, movies).code === "new" && rental.days > 2) ? 2 : 1;
}

Теперь я могу легко написать функцию htmlStatement.
function htmlStatement(customer, movies) {
  let result = `

Rental Record for ${customer.name}

\n`; result += "\n"; for (let r of customer.rentals) { result += ` \n`; } result += "
${movieFor(r, movies).title}${amountFor(r, movies)}
\n"; result += `

Amount owed is ${totalAmount(customer, movies)}

\n`; result += `

You earned ${totalFrequentRenterPoints(customer, movies)} frequent renter points

\n`; return result; }

Объявление некоторых локальных функций с частичным применением


Когда глобальная функция используется таким образом, списки параметров могут довольно сильно растянуться. Так что иногда может быть полезно объявить локальную функцию, которая вызывает глобальную функцию с некоторыми или всеми параметрами внутри. Эта локальная функция, которая является частичным применением глобальной функции, может пригодиться для последующего использования. Есть различные способы сделать такое в JavaScript. Один из них — присвоить локальные функции переменным.
  function htmlStatement(customer, movies) {
    const amount = () => totalAmount(customer, movies);
    const frequentRenterPoints = () => totalFrequentRenterPoints(customer, movies);
    const movie = (aRental) => movieFor(aRental, movies);
    const rentalAmount = (aRental) =>  amountFor(aRental, movies);
    let result = `

Rental Record for ${customer.name}

\n`; result += "\n"; for (let r of customer.rentals) { result += ` \n`; } result += "
${movie(r).title}${rentalAmount(r)}
\n"; result += `

Amount owed is ${amount()}

\n`; result += `

You earned ${frequentRenterPoints()} frequent renter points

\n`; return result; }

Другой способ — объявить их как вложенные функции.
  function htmlStatement(customer, movies) {
    let result = `

Rental Record for ${customer.name}

\n`; result += "\n"; for (let r of customer.rentals) { result += ` \n`; } result += "
${movie(r).title}${rentalAmount(r)}
\n"; result += `

Amount owed is ${amount()}

\n`; result += `

You earned ${frequentRenterPoints()} frequent renter points

\n`; return result; function amount() {return totalAmount(customer, movies);} function frequentRenterPoints() {return totalFrequentRenterPoints(customer, movies);} function rentalAmount(aRental) {return amountFor(aRental, movies);} function movie(aRental) {return movieFor(aRental, movies);} }

Ещё один вариант — использовать bind. Оставлю вам его для собственных изысканий — это не то, что я бы использовал здесь, поскольку предыдущие варианты мне кажутся более подходящими.Использование классов
Мне знаком именно объектный подход, так что неудивительно, что я собираюсь рассмотреть классы и объекты. В ES6 появился хороший синтаксис для классического объектного подхода. Посмотрим, как применить его в этом примере.

Первым делом обернём данные в объекты, начав с customer.

customer.es6…

  export default class Customer {
    constructor(data) {
      this._data = data;
    }
  
    get name() {return this._data.name;}
    get rentals() { return this._data.rentals;}
  }

statement.es6…
  import Customer from './customer.es6';
  
  function statement(customerArg, movies) {
    const customer = new Customer(customerArg);
    let result = `Rental Record for ${customer.name}\n`;
    for (let r of customer.rentals) {
      result += `\t${movieFor(r).title}\t${amountFor(r)}\n`;
    }
    result += `Amount owed is ${totalAmount()}\n`;
    result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`;
    return result;

До сих пор класс является простой обёрткой вокруг оригинального объекта JavaScript. Дальше сделаем такую же для rental.

rental.es6…

  export default class Rental {
    constructor(data) {
      this._data = data;
    }
    get days() {return this._data.days}
    get movieID() {return this._data.movieID}
  }

customer.es6…
  import Rental from './rental.es6'
  
  export default class Customer {
    constructor(data) {
      this._data = data;
    }
  
    get name() {return this._data.name;}
    get rentals() { return this._data.rentals.map(r => new Rental(r));}
  }

Теперь, когда классы созданы вокруг моих простых объектов json, появилась работа для Move Method. Как и во время переноса функций на верхний уровень, первым делом возьмём ту функцию, которая не обращается ни к каким другим — movieFor. Но этой функции нужен список фильмов в качестве контекста, который нужно будет сделать доступным для создаваемых объектов rental.

statement.es6…

  function statement(customerArg, movies) {
    const customer = new Customer(customerArg, movies);
    let result = `Rental Record for ${customer.name}\n`;
    for (let r of customer.rentals) {
      result += `\t${movieFor(r).title}\t${amountFor(r)}\n`;
    }
    result += `Amount owed is ${totalAmount()}\n`;
    result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`;
    return result;

class Customer…
  constructor(data, movies) {
    this._data = data;
    this._movies = movies
  }
  get rentals() { return this._data.rentals.map(r => new Rental(r, this._movies));}

class Rental…
  constructor(data, movies) {
    this._data = data;
    this._movies = movies;
  }

Когда у меня на месте все поддерживающие данные, можно перенести функцию.

statement.es6…

  function movieFor(rental) {
    return rental.movie;
  }

class Rental…
class Rental...
  get movie() {
    return this._movies[this.movieID];
  }

Как и с предыдущим перемещением, первым делом перенесём ключевое поведение функции в новый контекст, встроим его в контекст, и настроим оригинальную функцию для вызова новой функции. Когда это работает, относительно просто встроить вызовы оригинальной функции.

statement.es6…

function statement(customerArg, movies) {
  const customer = new Customer(customerArg, movies);
  let result = `Rental Record for ${customer.name}\n`;
  for (let r of customer.rentals) {
    result += `\t${r.movie.title}\t${amountFor(r)}\n`;
  }
  result += `Amount owed is ${totalAmount()}\n`;
  result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`;
  return result;
  function amountFor(rental) {
    let result = 0;
    switch (rental.movie.code) {
      case "regular":
        result = 2;
        if (rental.days > 2) {
          result += (rental.days - 2) * 1.5;
        }
        return result;
      case "new":
        result = rental.days * 3;
        return result;
      case "childrens":
        result = 1.5;
        if (rental.days > 3) {
          result += (rental.days - 3) * 1.5;
        }
        return result;
    }
    return result;
  }
  function frequentRenterPointsFor(rental) {
    return (rental.movie.code === "new" && rental.days > 2) ? 2 : 1;
  }

Можно использовать ту же базовую последовательность для перемещения в rental и двух вычислений.

statement.es6…

function statement(customerArg, movies) {
  const customer = new Customer(customerArg, movies);
  let result = `Rental Record for ${customer.name}\n`;
  for (let r of customer.rentals) {
    result += `\t${r.movie.title}\t${r.amount}\n`;
  }
  result += `Amount owed is ${totalAmount()}\n`;
  result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`;
  return result;
  function totalFrequentRenterPoints() {
    return customer.rentals
      .map((r) => r.frequentRenterPoints)
      .reduce((a, b) => a + b)
      ;
  }
  function totalAmount() {
    return customer.rentals
      .reduce((total, r) => total + r.amount, 0);
  }

class Rental…
  get frequentRenterPoints() {
    return (this.movie.code === "new" && this.days > 2) ? 2 : 1;
  }
  get amount() {
    let result = 0;
    switch (this.movie.code) {
      case "regular":
        result = 2;
        if (this.days > 2) {
          result += (this.days - 2) * 1.5;
        }
        return result;
      case "new":
        result = this.days * 3;
        return result;
      case "childrens":
        result = 1.5;
        if (this.days > 3) {
          result += (this.days - 3) * 1.5;
        }
        return result;
    }
    return result;
  }

Затем я могу переместить в customer две функции вычисления суммы.

statement.es6…

function statement(customerArg, movies) {
  const customer = new Customer(customerArg, movies);
  let result = `Rental Record for ${customer.name}\n`;
  for (let r of customer.rentals) {
    result += `\t${r.movie.title}\t${r.amount}\n`;
  }
  result += `Amount owed is ${customer.amount}\n`;
  result += `You earned ${customer.frequentRenterPoints} frequent renter points\n`;
  return result;
}

class Customer…
  get frequentRenterPoints() {
    return this.rentals
      .map((r) => r.frequentRenterPoints)
      .reduce((a, b) => a + b)
      ;
  }
  get amount() {
    return this.rentals
      .reduce((total, r) => total + r.amount, 0);
  }

Когда логика вычислений переместилась в объекты rental customer, написать html-версию statement просто.

statement.es6…

  function htmlStatement(customerArg, movies) {
    const customer = new Customer(customerArg, movies);
    let result = `

Rental Record for ${customer.name}

\n`; result += "\n"; for (let r of customer.rentals) { result += ` \n`; } result += "
${r.movie.title}${r.amount}
\n"; result += `

Amount owed is ${customer.amount}

\n`; result += `

You earned ${customer.frequentRenterPoints} frequent renter points

\n`; return result; }

Классы без синтаксиса


Синтаксис классов в ES2015 противоречив, а некоторые думают, что он вообще не нужен (косо поглядывая на Java-разработчиков). Вы можете проделать те же этапы рефакторинга и получить результаты вроде таких:
function statement(customerArg, movies) {
  const customer = createCustomer(customerArg, movies);
  let result = `Rental Record for ${customer.name()}\n`;
  for (let r of customer.rentals()) {
    result += `\t${r.movie().title}\t${r.amount()}\n`;
  }
  result += `Amount owed is ${customer.amount()}\n`;
  result += `You earned ${customer.frequentRenterPoints()} frequent renter points\n`;
  return result;
}

function createCustomer(data, movies) {
  return {
    name: () => data.name,
    rentals: rentals,
    amount: amount,
    frequentRenterPoints: frequentRenterPoints
  };

  function rentals() {
    return data.rentals.map(r => createRental(r, movies));
  }
  function frequentRenterPoints() {
    return rentals()
      .map((r) => r.frequentRenterPoints())
      .reduce((a, b) => a + b)
      ;
  }
  function amount() {
    return rentals()
      .reduce((total, r) => total + r.amount(), 0);
  }
}

function createRental(data, movies) {
  return {
    days: () => data.days,
    movieID: () => data.movieID,
    movie: movie,
    amount: amount,
    frequentRenterPoints: frequentRenterPoints
  };

  function movie() {
    return movies[data.movieID];
  }

  function amount() {
    let result = 0;
    switch (movie().code) {
      case "regular":
        result = 2;
        if (data.days > 2) {
          result += (data.days - 2) * 1.5;
        }
        return result;
      case "new":
        result = data.days * 3;
        return result;
      case "childrens":
        result = 1.5;
        if (data.days > 3) {
          result += (data.days - 3) * 1.5;
        }
        return result;
    }
    return result;
  }

  function frequentRenterPoints() {
    return (movie().code === "new" && data.days > 2) ? 2 : 1;
  }

В этом подходе используется шаблон Function As Object. Функции-конструкторы (createCustomer и createRental) возвращают объект JavaScript (хэш) вызовов функции. Каждая функция-конструктор содержит замыкание с данными объекта. Поскольку возвращаемые объекты функции находятся в том же контексте функции, они имеют доступ к этим данным. С моей точки зрения это такой же шаблон, что и использование синтаксиса классов, но реализованный иначе. Я предпочитаю использовать явный синтаксис, потому что он более явный — это позволяет мне яснее рассуждать.Преобразование данных
Все эти подходы предусматривают, что функции печати statement вызывают другие функции для вычисления нужных данных. Это можно сделать иначе: передать эти данные функции печати отчёта в самой структуре данных. При таком подходе функции вычисления используются для преобразования структуры данных customer таким образом, что она будет содержать все данные, необходимые функции печати.

В терминах рефакторинга это пример ещё не написанного рефакторинга Split Phase, который описал мне Кент Бек прошлым летом. С таким рефакторингом я разбиваю вычисления на две фазы, которые сообщаются между собой через промежуточную структуру данных. Начнём этот рефакторинг с введения промежуточной структуры данных.

  function statement(customer, movies) {
    const data = createStatementData(customer, movies);
    let result = `Rental Record for ${data.name}\n`;
    for (let r of data.rentals) {
      result += `\t${movieFor(r).title}\t${amountFor(r)}\n`;
    }
    result += `Amount owed is ${totalAmount()}\n`;
    result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`;
    return result;
  
    function createStatementData(customer, movies) {
      let result = Object.assign({}, customer);
      return result;
    }

Для этого случая я улучшу оригинальную структуру данных customer, добавив в неё элементы, начав с вызова к Object.assign. Я мог бы сделать и полностью новую структуру данных. В реальности выбор зависит от того, насколько отличается преобразованная структура данных от оригинальной.

Затем то же самое сделаем с каждой строкой rental.

function statement…

  function createStatementData(customer, movies) {
    let result = Object.assign({}, customer);
    result.rentals = customer.rentals.map(r => createRentalData(r));
    return result;

    function createRentalData(rental) {
      let result = Object.assign({}, rental);
      return result;
    }
  }

Обратите внимание, что я встроил createRentalData внутрь createStatementData, поскольку для любого вызова createStatementData не требуется знать, как всё устроено внутри.

Затем можно приступить к заполнению преобразованных данных, начав с названий фильмов, выданных напрокат.

function statement(customer, movies) {
  const data = createStatementData(customer, movies);
  let result = `Rental Record for ${data.name}\n`;
  for (let r of data.rentals) {
    result += `\t${r.title}\t${amountFor(r)}\n`;
  }
  result += `Amount owed is ${totalAmount()}\n`;
  result += `You earned ${totalFrequentRenterPoints()} frequent renter points\n`;
  return result;
  //…

  function createStatementData(customer, movies) {
    // …
    function createRentalData(rental) {
      let result = Object.assign({}, rental);
      result.title = movieFor(rental).title;
      return result;
    }
  }

Продолжим с вычислением количества и общей суммы.
function statement(customer, movies) {
  const data = createStatementData(customer, movies);
  let result = `Rental Record for ${data.name}\n`;
  for (let r of data.rentals) {
    result += `\t${r.title}\t${r.amount}\n`;
  }
  result += `Amount owed is ${data.totalAmount}\n`;
  result += `You earned ${data.totalFrequentRenterPoints} frequent renter points\n`;
  return result;

  function createStatementData(customer, movies) {
    let result = Object.assign({}, customer);
    result.rentals = customer.rentals.map(r => createRentalData(r));
    result.totalAmount = totalAmount();
    result.totalFrequentRenterPoints = totalFrequentRenterPoints();
    return result;

    function createRentalData(rental) {
      let result = Object.assign({}, rental);
      result.title = movieFor(rental).title;
      result.amount = amountFor(rental);
      return result;
    }
  }

Теперь, когда все вычислительные функции выкладывают результат своих вычислений в виде данных, можно перенести функции, отделив их от функции рендеринга statement. Сначала я перенесу все вычислительные функции внутрь createStatementData.
function statement (customer, movies) {
  // body …
  function createStatementData (customer, movies) {
    // body …

    function createRentalData(rental) { … }
    function totalFrequentRenterPoints() { … }
    function totalAmount() { … }
    function movieFor(rental) { … }
    function amountFor(rental) { … }
    function frequentRenterPointsFor(rental) { … }
  }
}

Затем перенесу createStatementData за пределы statement.
function statement (customer, movies) { … }

function createStatementData (customer, movies) {
  function createRentalData(rental) { … }
  function totalFrequentRenterPoints() { … }
  function totalAmount() { … }
  function movieFor(rental) { … }
  function amountFor(rental) { … }
  function frequentRenterPointsFor(rental) { … }
}

Когда я разделил функции таким образом, можно написать HTML-версию statement, которая будет использовать ту же структуру данных.
function htmlStatement(customer, movies) {
  const data = createStatementData(customer, movies);
  let result = `

Rental Record for ${data.name}

\n`; result += "\n"; for (let r of data.rentals) { result += ` \n`; } result += "
${r.title}${r.amount}
\n"; result += `

Amount owed is ${data.totalAmount}

\n`; result += `

You earned ${data.totalFrequentRenterPoints} frequent renter points

\n`; return result; }

Можно также перенести createStatementData в отдельный модуль, чтобы ещё чётче обозначить границы между вычислением данных и рендерингом (печатью) отчётов. Сравнение подходов
Итак, пришло время отступить назад и окинуть взглядом результат. Есть первоначальный код, написанный как единая встроенная функция. Я захотел провести рефакторинг этого кода, чтобы сделать HTML-рендеринг отчёта без повторения кода вычислений. Первым делом я разбил этот код на несколько функций, существующих внутри оригинальной функции. После этого я исследовал четыре отдельных пути.

9a77c51f9f7b02e63bc3a2778a0c14e6.png


top-level-functions


все функции пишем как функции верхнего уровня
    function htmlStatement(customer, movies)
    function textStatement(customer, movies)
    function totalAmount(customer, movies)
    function totalFrequentRenterPoints(customer, movies)
    function amountFor(rental, movies)
    function frequentRenterPointsFor(rental, movies)
    function movieFor(rental, movies)
показать код

parameter-dispatch


используем параметр функции верхнего уровня для утверждения формата выдачи
    function statement(customer, movies, format)
        function htmlStatement()
        function textStatement()
        function totalAmount()
        function totalFrequentRenterPoints()
        function amountFor(rental)
        function frequentRenterPointsFor(rental)
        function movieFor(rental)
показать код

classes


переносим логику вычислений в классы, которые используются функциями рендеринга
    function textStatement(customer, movies)
    function htmlStatement(customer, movies)
    class Customer
        get amount()
        get frequentRenterPoints()
        get rentals()
    class Rental
        get amount()
        get frequentRenterPoints()
        get movie()
показать код

transform


делим логику вычислений на отдельные вложенные функции, которые производят промежуточную структуру данных для функций рендеринга
    function statement(customer, movies)
    function htmlStatement(customer, movies)
    function createStatementData(customer, movies)
        function createRentalData()
        function totalAmount()
        function totalFrequentRenterPoints()
        function amountFor(rental)
        function frequentRenterPointsFor(rental)
        function movieFor(rental)
показать код

Начну с примера функций верхнего уровня, чтобы выбрать в качестве базиса для сравнения концептуально простейшую альтернативу. [2] Она простая, потому что делит работу на ряд чистых функций, а к ним всем можно обратиться из любой точки кода. Такое просто использовать и просто тестировать — я могу легко протестировать любую отдельную функцию или с помощью наборов тестовых данных, или с помощью REPL.

Отрицательная сторона top-level-functions — в большом количестве повторяющихся передач параметров. Каждой функции нужно дать структуру данных с фильмами, а функциям уровня customer — ещё и структуру данных пользователей. Меня здесь волнует не набор одного и того же текста на клавиатуре, а чтение одного и того же. Каждый раз при чтении параметров я должен понять, что это такое, и проверить их на изменение. Для всех этих функций данные о пользователях и фильмах являются общим контекстом —, но с функциями верхнего уровня этот общий контекст не выделен явно. Я делаю такой вывод, когда читаю программу и строю модель её выполнения в своей голове, и я предпочитаю, чтобы вещи были настолько внятными, насколько возможно.

Этот аргумент становится более важным по мере увеличения объёма контекста. Здесь у меня только два элемента данных, но нередко встречается и большее количество. Используя только функции верхнего уровня, можно сильно увеличить списки параметров, которые содержат все контексты для многих функций, и в конце концов потерять понимание, что эти функции делают. Я могу уменьшить боль от происходящего, определив локальные функции с частичным применением, но тогда нужно бросить в коктейль ещё много дополнительных функций — которые придётся продублировать с каждым битом клиентского кода.

Преимущество трёх других альтернатив в том, что каждая из них делает явным общий контекст, захватывая его в структуру программы. Подход parameter-dispatch делает это, захватывая контекст в списке параметров верхнего уровня, который затем доступен как общий контекст для всех вложенных функций. Это особенно хорошо работает с оригинальным к

© Habrahabr.ru