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

Коли обговорюється функціональне програмування, часто розмова заходить про механізм, а не про базові принципи. Функціональне програмування, це не про монади або моноиды, це в першу чергу про написання програм з використанням узагальнених функцій. Ця стаття про применнии функціонального мислення в рефакторинге TypeScript коду.
Примітка від перекладача: весь код для зручності я оформив в репозиторії.
Для цього ми будемо використовувати три техніки:
  • функції замість примітивів
  • трансормация даних через pipeline
  • виділення загальних (generic) функцій
Почнемо ж!
Отже, у нас є два класи:
Employee (Працівник)
export default class Employee {
constructor(public name: string, public salary: number) {}
}

Department (Департамент)
export default class Department {
constructor(public employees: Employee[]) {}

works(employee: Employee): boolean {
return this.employees.indexOf(employee) > -1;
}
}

Працівники мають імена і заробітні плати, а департамент — це всього лише звичайний список працівників.
Функція averageSalary це як раз те, що ми будемо рефакторіть:
export default 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;
}
});

total return === 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)).to.equal(150);
expect(averageSalary(empls, 50)).to.equal(140);
});
});

Незважаючи на досить чіткі умови, код вийшов трохи заплутаним і важко розширюваним. Якщо я додам ще одна умова, то сигнатура функції (а таким чином і її публічний інтерфейс) можуть змінитися, а конструкції if else можуть перетворити код на справжнього монстра.
Давайте застосуємо деякі техніки з функціонального програмування для рефакторінгу цієї функції.
Функції замість примітивів
Использание функцій замість примітивів спочатку може здатися нелогічним кроком, але насправді це дуже сильна техніка для узагальнення коду. В нашому випадку це означає заміну параметрів minSalary і department на дві функції з перевіркою умов.
Крок 1 (Предикат — це вираз, повертають істину або брехня)
type Predicate = (e: Employee) => boolean;

export default 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;
}
});

total return === 0 ? 0 : total / count;
}

// ...

expect(averageSalary(empls, (e) => e.salary > 50, (e) => sales.works(e))).toEqual(150);

Ми унифицровали інтерфейси умов вибірки зарплати і департаментів. Ця уніфікація дозволить передавати всі умови у вигляді масиву.
Крок 2
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);

Тепер масив з умовами представляє з себе композицію умов, яку ми можемо зробити більш читабельною.
Крок 3
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 стала більш надійною. Нові умови можуть бути додані без зміни інтерфейсу функції та без зміни її імплементації.
Трансормация даних через pipeline
Ще одна корисна практика у функціональному програмуванні — це моделювання всіх змін даних у вигляді потоку. У нашому випадку це означає витяг фільтрації з циклу.
Крок 4
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;
}

Це зміна робить лічильник марним.
Крок 5
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;
}

Далі якщо ми виділимо зарплати окремо, то для підсумовування зможемо використати звичайний reduce.
Крок 6
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;
}

Виділення узагальнених (generic) функцій
Далі ми звернемо увагу на те, що останні два рядки коду не містять ніякої інформації про працівників або департаментах. Фактчески це всього лише функція для обчислення середнього значення. А значить її можна узагальнити.
Крок 7
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);
}

Таким чином витягнута функція тепер загальна (generic).
Після того, як ми розділили логіку обчислень і фільтрації зарплат, приступимо до фінального кроку.
Крок 8
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));
}

Порівнюючи фінальне рішення я можу сказати, що воно краще попереднього. По-перше, код більш узагальнений (ми можемо додати нову умову без розриву функції інтерфейсу). По-друге, ми отримали незмінне стан та код став більше читати і більш зрозумілий.
Коли ж варто зупинитися
Функціональний стиль програмування — це написання невеликих функцій, які приймають колекції значень і повертають нові колекції. Ці функції можуть бути переиспользованы або об'єднані в різних місцях. Єдиний недолік цього стилю в тому, що незважаючи на те, що код може стати більш абстрактним, але разом з тим він може стати і більш складним для розуміння ролі цих функцій.
Я люблю використовувати Лего-аналогію: кубики Лего можуть бути поєднані різними способами — вони легко компонуються між собою. Але не всі кубики одного розміру. Тому коли ви рефакторите з використанням технік, які були описані в цій статті, не намагайтеся створювати функції, які для прикладу беруть
Array<T>
, а повертають
Array<U>
. Звичайно ж в деяких випадках дані можна змішувати, але такий підхід значно утруднить розуміння логічного ланцюжка коду.
Підведемо підсумки
стаття я показав, як застосувати функціональне мислення під час рефакторінгу TypeScript коду. Я зробив це застосовуючи прості функції з трансформацією, дотримуючись правил:
  • функції замість примітивів
  • трансормация даних через pipeline
  • виділення узагальнених (generic) функцій
Що почитати
«JavaScript Allonge» by Reginald Braithwaite
«Functional JavaScript» by Michael Fogus
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.