Статьи

Функциональный рефакторинг в JavaScript

При обсуждении функционального программирования мы часто говорим о механизме, а не об основных принципах. Функциональное программирование — это не монады, моноиды или молнии. В первую очередь это касается написания программ путем составления универсальных функций многократного использования. Эта статья о применении функционального мышления при рефакторинге кода JavaScript.

Предположим, есть два класса: Employeeи Department. Сотрудники имеют имена и зарплаты, а отделы — это просто наборы сотрудников.

function Employee(name, salary) {
  this.name = name
  this.salary = salary
}

function Department(employees) {
  this.works = function(employee){
    return _.contains(employees, employee)
  }
}

averageSalaryФункция , что мы собираемся реорганизовать.

function averageSalary(employees, minSalary, department){
  var total = 0
  var count = 0

  _.each(employees, function(e){
    if(minSalary < e.salary && (department == undefined || department.works(e))){
      total += e.salary
      count += 1
    }
  })
  return (count == 0) ? 0 : total / count
}

Функция принимает список сотрудников, минимальную зарплату и, при необходимости, отдел. Для данного отдела рассчитывается средняя заработная плата сотрудников этого отдела. Если отдел не указан, он выполняет одинаковые расчеты для всех сотрудников.

Эта функция может быть использована следующим образом.

describe("average salary", function () {
  var empls = [
    new Employee("Jim", 100),
    new Employee("John", 200),
    new Employee("Liz", 120),
    new Employee("Penny", 30)
  ]

  var sales = new Department([empls[0], empls[1]])

  it("calculates the average salary", function(){
    expect(averageSalary(empls, 50, sales)).toEqual(150)
    expect(averageSalary(empls, 50)).toEqual(140)
  }
})

Несмотря на простые требования, полученный нами код запутан, не говоря уже о том, что его сложно расширить. Если бы я просто добавил другое условие, сигнатуру функции (то есть общедоступного интерфейса) пришлось бы изменить, и оператор if превратился бы в настоящего монстра.

Давайте применим некоторые методы функционального программирования для рефакторинга этой функции.

Используйте функции вместо простых значений

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

function averageSalary(employees, salaryCondition, departmentCondition){
  var total = 0
  var count = 0

  _.each(employees, function(e){
    if(salaryCondition(e) && (departmentCondition == undefined || departmentCondition(e))){
      total += e.salary
      count += 1
    }
  })
  return (count == 0) ? 0 : total / count
}

....

expect(averageSalary(empls, function(e){return e.salary > 50}, sales.works)).toEqual(150)

То, что мы сделали, — это то, что мы унифицировали интерфейсы зарплаты и условий отдела. Если раньше оба условия были реализованы в режиме ad-hoc, то теперь они явно определены и соответствуют одному и тому же интерфейсу. Это объединение позволяет нам передать все условия в виде массива.

function averageSalary(employees, conditions){
  var total = 0
  var count = 0

  _.each(employees, function(e){
    if(_.every(conditions, function(c){return c(e)})){
      total += e.salary
      count += 1
    }
  })
  return (count == 0) ? 0 : total / count
}

...

expect(averageSalary(empls, [function(e){return e.salary > 50}, sales.works])).toEqual(150)

Поскольку массив условий является не чем иным, как составным условием, мы можем вывести простой комбинатор, сделав его явным.

function and(predicates){
  return function(e){
    return _.every(predicates, function(p){return p(e)})
  }
}

function averageSalary(employees, conditions){
  var total = 0
  var count = 0

  _.each(employees, function(e){
    if(and(conditions)(e)){
      total += e.salary
      count += 1
    }
  })
  return (count == 0) ? 0 : total / count
}

Стоит отметить, что andкомбинатор является универсальным, и, следовательно, может быть повторно использован и потенциально извлечен в библиотеку.

Промежуточные результаты

averageSalaryФункция уже стала более надежной. Новое условие может быть добавлено без нарушения интерфейса функции или изменения ее реализации.

Преобразования данных модели как конвейер

Еще одна полезная практика функционального программирования — моделирование всех преобразований данных в виде конвейера. Что в нашем случае означает извлечение фильтрации из цикла.

function averageSalary(employees, conditions){
  var filtered = _.filter(employees, and(conditions))

  var total = 0
  var count = 0

  _.each(filtered, function(e){
    total += e.salary
    count += 1
  })
  return (count == 0) ? 0 : total / count
}

Это изменение сделало подсчет ненужным.

function averageSalary(employees, conditions){
  var filtered = _.filter(employees, and(conditions))

  var total = 0

  _.each(filtered, function(e){
    total += e.salary
  })
  return (filtered.length == 0) ? 0 : total / filtered.length
}

Далее, если мы собираем зарплаты, прежде чем сложить их, суммирование станет простым уменьшением.

function averageSalary(employees, conditions){
  var filtered = _.filter(employees, and(conditions))
  var salaries = _.pluck(filtered, 'salary')

  var total = _.reduce(salaries, function(a,b){return a + b}, 0)
  return (salaries.length == 0) ? 0 : total / salaries.length
}

Извлечь общие функции

Следующее наблюдение заключается в том, что последние две строки не имеют ничего общего с нашим доменом. Там нет ничего о сотрудниках или отделах. По сути, это реализация усредненной функции. Итак, давайте сделаем это явно.

function average(nums){
  var total = _.reduce(nums, function(a,b){return a + b}, 0)
  return (nums.length == 0) ? 0 : total / nums.length
}

function averageSalary(employees, conditions){
  var filtered = _.filter(employees, and(conditions))
  var salaries = _.pluck(filtered, 'salary')

  return average(salaries)
}

Еще раз, извлеченная функция является абсолютно общей.

Наконец, после снятия зарплаты, мы получаем наше окончательное решение.

function employeeSalaries(employees, conditions){
  var filtered = _.filter(employees, and(conditions))
  return _.pluck(filtered, 'salary')
}

function averageSalary(employees, conditions){
  return average(employeeSalaries(employees, conditions))
}

Сравнивая оригинальные и окончательные решения, я могу без сомнения сказать, что последнее значительно превосходит их. Во-первых, он более общий (мы можем добавлять новые типы условий, не нарушая интерфейс функции). Во-вторых, мы избавились от изменяемого состояния и операторов if, что облегчило чтение и понимание кода.

До одиннадцати

Большинство программистов на JavaScript останавливались бы здесь и считали, что рефакторинг завершен, но на самом деле мы можем пойти немного дальше.

В частности, мы можем переписать averageSalaryв бессмысленном стиле.

var averageSalary = _.compose(average, employeeSalaries)

Мы также можем определить обобщенную функцию, скрывающуюся в определении employeeSalaries.

function pluckWhere(field, list, conditions){
  var filtered = _.filter(list, and(conditions))
  return _.pluck(filtered, field)
}

Что делает employeeSalariesфункцию тривиальной.

var employeeSalaries = _.partial(pluckWhere, 'salary')

Подводя итоги

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

  • Используйте функции вместо простых значений
  • Преобразования данных модели как конвейер
  • Извлечь общие функции

Реорганизованная функция намного превосходит оригинал. Он более расширяемый, не имеет изменяемого состояния и операторов if.

Прочитайте больше

Настоятельно рекомендуем проверить следующие две книги: