
При обсуждении функционального программирования, мы часто говорим о механизме и не говорим об основных принципах. Функциональное программирование это не монады, моноиды и производные от типов, даже если их полезно знать. В основном это написание программ в стиле обобщенных и повторно используемых функций. В этой статье мы рассмотрим применение функционального мышления при рефакторинге кода на языке TypeScript.
И сделаем мы это, следуя следующим трем пунктам:
- Используем простые функции вместо простых значений
- Моделируем изменение данных как конвейер
- Извлечем обобщенные функции
Итак, начнем-с!
Предположим, что у нас есть два класса: Employee и Department. Работники имеют имена и оклад, а отдел это просто коллекция работников.
class Employee {
  constructor(public name: string, public salary: number) {}
}
class Department {
  constructor(public employees: Employee[]) {}
  works(employee: Employee): boolean {
    return this.employees.indexOf(employee) > -1;
  }
}Функция которую мы будем рефаторить averageSalary.
function averageSalary(employees: Employee[], minSalary: number, department?: Department): number {
   let total = 0;
   let count = 0;
   employees.forEach((e) => {
     if(minSalary <= e.salary && (department === undefined || department.works(e))){
       total += e.salary;
       count += 1;
     }
   });
  return total === 0 ? 0 : total / count;
}
Функция получает список работников, минимальный оклад, и по выбору - отдел. Если предоставили отдел, то она рассчитывает средний оклад работников в отделе. Если не предоставили - рассчитывает то же самое для всех работников.
Функцию можно использовать следующим образом:
describe("average salary", () => {
  const empls = [
    new Employee("Jim", 100),
    new Employee("John", 200),
    new Employee("Liz", 120),
    new Employee("Penny", 30)
  ];
  const sales = new Department([empls[0], empls[1]]);
  it("calculates the average salary", () => {
    expect(averageSalary(empls, 50, sales)).toEqual(150);
    expect(averageSalary(empls, 50)).toEqual(140);
  });
});Несмотря на несложные требования, код получился неоднозначный, не говоря уже о том, что его сложно расширять. Даже если добавить дополнительное условие, то придется изменять сигнатуру функции (и вероятно - публичный интерфейс), и количество операторов if будет просто монстрообразно. 
Давайте применим некоторые техники с функционального подхода для рефакторинга этой функции.
Функции вместо простых значений
Использование функций вместо простых значений сначала противоречит здравому смыслу, но на самом деле это очень мощная техника для обобщения кода. В нашем случае это значит, что мы заменим параметры minSalary и department двумя функциями, которые проверяют условия.
type Predicate = (e: Employee) => boolean;
function averageSalary(employees: Employee[], salaryCondition: Predicate, 
  departmentCondition?: Predicate): number {
  let total = 0;
  let count = 0;
  employees.forEach((e) => {
    if(salaryCondition(e) && (departmentCondition === undefined || departmentCondition(e))){
      total += e.salary;
      count += 1;
    }
  });
  return total === 0 ? 0 : total / count;
}
// ...
expect(averageSalary(empls, (e) => e.salary > 50, (e) => sales.works(e))).toEqual(150);Мы только что обобщили интерфейсы для условий оклада и отдела. До сих пор оба условия были реализованы через частный случай, теперь же они явно определены и соответствуют одному интерфейсу. Эта унификация позволяет нам передать все условия как коллекцию.
function averageSalary(employees: Employee[], conditions: Predicate[]): number {
  let total = 0;
  let count = 0;
  employees.forEach((e) => {
    if(conditions.every(c => c(e))){
      total += e.salary;
      count += 1;
    }
  });
  return (count === 0) ? 0 : total / count;
}
//...
expect(averageSalary(empls, [(e) => e.salary > 50, (e) => sales.works(e)])).toEqual(150);Так как коллекция условий это не более, чем композиция условий, мы можем сделать простой явный комбинатор:
function and(predicates: Predicate[]): Predicate{
  return (e) => predicates.every(p => p(e));
}
function averageSalary(employees: Employee[], conditions: Predicate[]): number {
  let total = 0;
  let count = 0;
  employees.forEach((e) => {
    if(and(conditions)(e)){
      total += e.salary;
      count += 1;
    }
  });
  return (count == 0) ? 0 : total / count;
}Необходимо отметить, что комбинатор and довольно обобщен, поэтому может быть повторно использован и вероятно перемещен в библиотеку.
Промежуточные результаты
Функция averageSalary стала более простой и понятной. Но главное, что новое условие может быть добавлено без изменения интерфейса или реализации
Моделирование изменения данных как на конвейер
Еще один полезный подход от функционального программирования - моделирование изменений данных как на конвейере. В нашем случае это значит отделить фильтрацию данных из цикла.
function averageSalary(employees: Employee[], conditions: Predicate[]): number {
  const filtered = employees.filter(and(conditions));
  let total = 0
  let count = 0
  filtered.forEach((e) => {
    total += e.salary;
    count += 1;
  });
  return (count == 0) ? 0 : total / count;
}Это изменение делает счетчик лишним.
function averageSalary(employees: Employee[], conditions: Predicate[]): number{
  const filtered = employees.filter(and(conditions));
  let total = 0
  filtered.forEach((e) => {
    total += e.salary;
  });
  return (filtered.length == 0) ? 0 : total / filtered.length;
}Далее, если мы извлечем оклад перед суммированием, то суммирование будет простой сверткой:
function averageSalary(employees: Employee[], conditions: Predicate[]): number {
  const filtered = employees.filter(and(conditions));
  const salaries = filtered.map(e => e.salary);
  const total = salaries.reduce((a,b) => a + b, 0);
  return (salaries.length == 0) ? 0 : total / salaries.length;
}Извлечение обобщенных функций
Следующим наблюдением будет то, что последние две строки не имеют ничего общего с бизнес логикой. Здесь нет упоминаний об окладе и отделах. По существу, это просто реализация функции поиска среднего значения. Давайте сделаем ее более явной:
function average(nums: number[]): number {
  const total = nums.reduce((a,b) => a + b, 0);
  return (nums.length == 0) ? 0 : total / nums.length;
}
function averageSalary(employees: Employee[], conditions: Predicate[]): number {
  const filtered = employees.filter(and(conditions));
  const salaries = filtered.map(e => e.salary);
  return average(salaries);
}И снова извлеченная функция полностью обобщенная.
Ну и наконец, после выноса извлечения оклада, мы получаем конечное решение.on.:
function employeeSalaries(employees: Employee[], conditions: Predicate[]): number[] {
  const filtered = employees.filter(and(conditions));
  return filtered.map(e => e.salary);
}
function averageSalary(employees: Employee[], conditions: Predicate[]): number {
  return average(employeeSalaries(employees, conditions));
}Сравнивая оригинальное и конечное решение, я без сомнений могу сказать, что последнее более превосходное. Во-первых, функция более обобщенная (мы можем добавить новые типы условий без изменения интерфейса функции). Во-вторых, мы избавились от изменения состояния и оператора if, что делает код более читабельным и понятным.
Нужно знать когда остановиться
Стиль функционального программирования это написание многих маленьких функций, которые получают коллекции значений и возвращают новые коллекции значений. Эти функции могут быть повторно использованы и соединены разными способами - это хорошо. Но с другой стороны, этот стиль, несмотря на это, может быть настолько абстрактным, что сложно понять, что все эти функции вместе делают.
Мне нравиться аналогия с Lego: детали Lego могут быть комбинированы множеством способов - они компонуемы. Но обратите внимание, что не все детали имеют одинаковый размер. Таким образом, при рефакторинге с применением техник, описных в статье, не превращайте аргументы функций в Array<T> и возвращаемое значение Array<U>. Да, эти функции будут чрезвычайно комбинируемы, но они чрезвычайно затрудняют понимание того что происходит.
Итоги
В этой статье мы показали, как применить функциональный подход при рефакторинге кода на TypeScript. Я сделал это, используя простые функции и превращения используя следующие правила:
- Используйте функции вместо простых значений
- Моделируйте изменение данных как конвейер
- Извлекайте обобщенные функции
