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