Функциональный TypeScript

functional typescript

При обсуждении функционального программирования, мы часто говорим о механизме и не говорим об основных принципах. Функциональное программирование это не монады, моноиды и производные от типов, даже если их полезно знать. В основном это написание программ в стиле обобщенных и повторно используемых функций. В этой статье мы рассмотрим применение функционального мышления при рефакторинге кода на языке 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. Я сделал это, используя простые функции и превращения используя следующие правила:

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

Источник