Композиція функцій в JavaScript з Array.prototype.reduceRight

9 хв. читання

Функціональне програмування в 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 покроково:

  1. Колбек буде запущено 5 разів.
  2. Оскільки передбачено початкове значення, воно буде проініціалізовано значенням 0 при першому виклику.
  3. Перший виклик поверне значення 0+2, тому при наступному виклику total отримає значення 2.
  4. Результат, повернений наступним викликом — 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, а fComposed9.

Ви з легкістю могли б змінити порядок викликів в обох реалізаціях 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 в тому, що вони розглядаються як значення, тому ми з легкістю можемо використовувати композицію, аби об'єднати великі блоки коду, залежні від контексту. Тепер накопичення елементів масиву не потребує використання імперативних, вкладених викликів. Натомість функції вищого порядку дозволяють відокремити оголошення від виклику. Зрештою ми звільняємо себе від жорстких ієрархічних обмежень, передбачених принципами об'єктно-орієнтованого програмування.

Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 5.6K
Приєднався: 8 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація