Функціональне програмування в JavaScript стрімко набирає популярності протягом останніх років. Хоча більшість принципів ФП (як от незмінюваність) зазвичай потребують обхідних шляхів, JS полегшує труднощі з композицією коду, розглядаючи функції як об'єкти першого класу. Перш ніж оглянемо, як можна динамічно створювати одні функції з інших, трохи повернемося назад.
Що таке функція
Насправді функція — процедура, що дозволяє виконати набір імперативних команд для здійснення сторонніх ефектів або повернення значення. Наприклад:
function getFullName(person) {
return `${person.firstName} ${person.surname}`;
}
Якщо функція викликається, а як аргумент передається об'єкт з властивостями firstName
та lastName
, вона повертає рядок, що утворений переданими значеннями:
const character = {
firstName: 'Homer',
surname: 'Simpson',
};
const fullName = getFullName(character);
console.log(fullName); // => 'Homer Simpson'
Починаючи з ES2015, JavaScript підтримує синтаксис стрілкових функцій:
const getFullName = (person) => {
return `${person.firstName} ${person.surname}`;
};
Оскільки наша функція getFullName
приймає один аргумент, а її тіло складається з виразу return
, ми можемо спростити вираз:
const getFullName = person => `${person.firstName} ${person.surname
Три різні на вигляд функції насправді виконують спільні кроки:
- створюють функцію з назвою, доступною через властивість
name
уgetFullName
; - приймають єдиний параметр —
person
; - повертають рядок зі значеннями властивостей
person.firstName
таperson.lastName
, розділених пробілом.
Об'єднання функцій за допомогою return
Крім присвоєння значень, що повертаються функцією, оголошенням змінних (наприклад, const person = getPerson();
), ми можемо визначати параметри для інших функцій чи значення для будь-чого, якщо це дозволяє JS. Скажімо, у нас є функції, що виконують логування та сторонні ефекти sessionStorage
відповідно:
const log = arg => {
console.log(arg);
return arg;
};
const store = arg => {
sessionStorage.setItem('state', JSON.stringify(arg));
return arg;
};
const getPerson = id => id === 'homer'
? ({ firstName: 'Homer', surname: 'Simpson' })
: {};
Ми можемо виконати ці операції після повернення значення getPerson
за допомогою вкладених викликів:
const person = store(log(getPerson('homer')));
// person.firstName === 'Homer' && person.surname === 'Simpson'; => true
Спочатку викликається найбільш вкладена функція, оскільки вона повертає необхідні параметри для іншої функції. В наведеному вище прикладі, повернене функцією getPerson
значення передається в log
, а повернене функцією log
значення передається як аргумент у store
. Такий підхід дозволяє створювати складні алгоритми з атомарних частин.
Уявіть, як все було б, якби ми вирішили об'єднати 10 функцій:
const f = x => g(h(i(j(k(l(m(n(o(p(x))))))))));
На щастя, існує елегантна та універсальна реалізація: перетворення масиву функцій на функцію вищого порядку.
Накопичення масивів з Array.prototype.reduce
Метод Array.prototype.reduce
приймає екземпляр масиву та накопичує його значення. Якщо ми хочемо обчислити суму елементів масиву, можна використати такий підхід:
const sum = numbers =>
numbers.reduce((total, number) => total + number, 0);
sum([2, 3, 5, 7, 9]); // => 26
В наведеному фрагменті коду, numbers.reduce
приймає два аргументи: колбек-функцію, яка викликається при кожній ітерації та початкове значення –total
. Значення, повернене колбеком, буде передано як значення total
на наступній ітерації.
Щоб закріпити на практиці, розглянемо роботу sum
покроково:
- Колбек буде запущено 5 разів.
- Оскільки передбачено початкове значення, воно буде проініціалізовано значенням
0
при першому виклику. - Перший виклик поверне значення
0+2
, тому при наступному викликуtotal
отримає значення2
. - Результат, повернений наступним викликом —
2+3
, буде передано як параметрtotal
при наступному виклику і так далі.
У той час як зворотний виклик приймає два додаткових аргументи, які представляють поточний індекс і екземпляр масиву, для якого був викликаний Array.prototype.reduce
, два основних аргументи є найбільш важливими і зазвичай називаються:
-
accumulator
— значення, повернене колбеком на попередній ітерації. На першій ітерації, якщо не визначено спеціально, отримує значення першого елемента масиву; -
currentValue
— значення масиву для поточної ітерації; цей аргумент прийматиме значення відarray[0]
доarray[array.length - 1]
протягом виконання функціїArray.prototype.reduce
.
Композиція функцій з Array.prototype.reduce
Тепер ми розуміємо, як можна зручно обчислити суму значень масиву. Той самий підхід можна використовувати, якщо нам треба скомбінувати наявні функції в новій.
const compose = (...funcs) =>
initialArg => funcs.reduce((acc, func) => func(acc), initialArg);
Зверніть увагу: ми використовуємо синтаксис оператора rest (...)
, щоб перетворити аргументи на масив і позбавити необхідності явно створювати новий екземпляр масиву для кожного виклику функції. compose
повертає функцію, тобто тут ми маємо справу з функцією вищого порядку, яка приймає початкове значення initialArg
. Тепер ми можемо створювати нові, повторно використовувані функції, не викликаючи їх без потреби. Це також відомо як ліниве обчислення (lazy evaluation).
Оглянемо на практиці, як можна скомбінувати декілька функцій в єдину функцію вищого порядку:
const compose = (...funcs) =>
initialArg => funcs.reduce((acc, func) => func(acc), initialArg);
const log = arg => {
console.log(arg);
return arg;
};
const store = key => arg => {
sessionStorage.setItem(key, JSON.stringify(arg));
return arg;
};
const getPerson = id => id === 'homer'
? ({ firstName: 'Homer', surname: 'Simpson' })
: {};
const getPersonWithSideEffects = compose(
getPerson,
log,
store('person'),
);
const person = getPersonWithSideEffects('homer');
В наведеному фрагменті коду:
-
person
отримає значення{ firstName: 'Homer', surname: 'Simpson' }
; - значення
person
буде виведено в консоль браузера; -
person
буде серіалізовано як JSON перед збереженням доsessionStorage
з ключемperson
.
Важливість порядку виклику функцій
Можливість об'єднати будь-яку кількість функцій дозволяє зробити ваш код чистішим та більш абстрагованим. Однак є важливий момент, на який варто звернути увагу, оглянувши вкладені виклики:
const g = x => x + 2;
const h = x => x / 2;
const i = x => x ** 2;
const fNested = x => g(h(i(x)));
Можливо, природніше було б модифікувати наведений код з функцією compose
:
const fComposed = compose(g, h, i);
Чому ж в такому випадку fNested(4) === fComposed(4)
буде false
? Мабуть, ви пам'ятаєте, як інтерпретуються внутрішні виклики: compose(g, h, i)
насправді є еквівалентом x => i(h(g(x)))
. Тепер зрозуміло, чому fNested
повертає 10
, а fComposed
— 9
.
Ви з легкістю могли б змінити порядок викликів в обох реалізаціях f
. Враховуючи те, що compose
призначений для відображення вкладених викликів, нам потрібен спосіб, з яким ми могли б отримати функціонал reduce
, але у зворотному напрямку. На щастя, JavaScript передбачає для цього метод Array.prototype.reduceRight
:
const compose = (...funcs) =>
initialArg => funcs.reduceRight((acc, func) => func(acc), initialArg);
З такою реалізацією результатом виклику як fNested(4)
, так і fComposed(4)
буде 10
. Однак наша функція getPersonWithSideEffects
тепер визначена некоректно. Хоч ми можемо змінити порядок внутрішніх функцій на зворотний, у більшості випадків аналізувати код зліва направо набагато легше. Виявляється, такий підхід вже досить поширений, просто він відомий як конвеєр (piping
).
const pipe = (...funcs) =>
initialArg => funcs.reduce((acc, func) => func(acc), initialArg);
const getPersonWithSideEffects = pipe(
getPerson,
log,
store('person'),
);
Використовуючи функцію pipe
, ми підтримаємо порядок виконання справа наліво, потрібний для getPersonWithSideEffects
.
Piping став основним елементом RxJS: можливо, більш інтуїтивно працювати з потоками даних, застосовуючи для цього відповідні оператори.
Композиція функцій як альтернатива наслідуванню
В попередньому прикладі ми вже розглянули, як можна нескінченно об'єднувати функції у більші, повторно використовувані блоки, що мають певне призначення. Додаткова перевага такого підходу в тому, що з композицією функцій ви забудете про заплутані ланцюжки наслідування.
Скажімо, ми хотіли б повторно використати логіку логування та сховища з використанням ієрархії класів. Це матиме такий вигляд:
class Storable {
constructor(key) {
this.key = key;
}
store() {
sessionStorage.setItem(
this.key,
JSON.stringify({ ...this, key: undefined }),
);
}
}
class Loggable extends Storable {
log() {
console.log(this);
}
}
class Person extends Loggable {
constructor(firstName, lastName) {
super('person');
this.firstName = firstName;
this.lastName = lastName;
}
debug() {
this.log();
this.store();
}
}
Окрім об'ємності такого підходу, отримуємо ще один важливий недолік: ми зловживаємо наслідуванням для того, щоб код можна було повторно використовувати. Якщо б інший клас наслідував Loggable
, він автоматично став би підкласом Storable
, навіть якщо його логіка нам не потрібна. Більш серйозна проблема полягає в конфліктах іменування.
class State extends Storable {
store() {
return fetch('/api/store', {
method: 'POST',
});
}
}
class MyState extends State {}
Якщо нам треба було б створити екземпляр MyState
та викликати його метод store
, ми б не змогли викликати його з класу Storable
напряму, поки не викличемо батьківський метод super.store()
у межах MyState.prototype.store
, але так ми отримуємо тісне зв'язування між State
та Storable
. Цього можна уникнути, якщо використовувати систему компонентів сутностей (Entity Component System) або шаблон проектування Стратегія (Strategy Pattern). На відміну від наслідування, функціональна композиція передбачає прямий та лаконічний спосіб поширювати код, що не залежатиме від назв методів.
Висновок
Особливість функцій JavaScript в тому, що вони розглядаються як значення, тому ми з легкістю можемо використовувати композицію, аби об'єднати великі блоки коду, залежні від контексту. Тепер накопичення елементів масиву не потребує використання імперативних, вкладених викликів. Натомість функції вищого порядку дозволяють відокремити оголошення від виклику. Зрештою ми звільняємо себе від жорстких ієрархічних обмежень, передбачених принципами об'єктно-орієнтованого програмування.
Ще немає коментарів